"""Draft confirmation/revision/cancel handler for AI PMO write intents.""" from __future__ import annotations from datetime import datetime from typing import Any, Optional from . import ai_pmo_kernel_common_core as _common for _name, _value in vars(_common).items(): if not _name.startswith("__"): globals()[_name] = _value from ..signal.draft_generator_service import DraftGeneratorService from ...models.feishu_reply_outbox import FeishuReplyOutbox class DraftConfirmHandler: """Handle draft decision from Feishu text or inbox API.""" _ACTION_CONFIRM = "confirm" _ACTION_REVISE = "revise" _ACTION_CANCEL = "cancel" _ACTION_UNKNOWN = "unknown" _CONFIRM_HINTS = ("确认", "好的", "就这样", "行", "ok", "okay", "同意", "没问题") _REVISE_HINTS = ("修改", "改成", "改为", "改", "换成") _CANCEL_HINTS = ("取消", "不要", "算了", "放弃") _WORK_ITEM_ID_RE = re.compile(r"(?:work[_ -]?item|工作项|草稿)\s*(?:id|ID)?\s*[:#:]?\s*(\d+)", re.IGNORECASE) def __init__(self, kernel: Any) -> None: self.kernel = kernel self.db = kernel.db self.tenant_id = int(kernel.tenant_id) self.draft_generator = DraftGeneratorService(self.db, tenant_id=self.tenant_id) @classmethod def parse_action_from_text(cls, *, message_text: str) -> tuple[str, str]: text = str(message_text or "").strip() lowered = text.lower() if any(hint in text for hint in cls._REVISE_HINTS): return cls._ACTION_REVISE, cls._extract_revision_note(text) if any(hint in text for hint in cls._CANCEL_HINTS): return cls._ACTION_CANCEL, "" if any(hint in text for hint in cls._CONFIRM_HINTS) or lowered in {"y", "yes"}: return cls._ACTION_CONFIRM, "" return cls._ACTION_UNKNOWN, "" async def handle_text_reply( self, *, message_text: str, sender: str, source_system: str, source_message_id: str, channel_ref: Optional[str], source_sender_open_id: Optional[str], expected_version: Optional[int] = None, thread_context: Optional[dict[str, Any]] = None, ) -> dict[str, Any]: action, revision_note = self.parse_action_from_text(message_text=message_text) explicit_work_item_id = self._extract_work_item_id(message_text) target = await self._resolve_pending_draft( sender=sender, source_system=source_system, channel_ref=channel_ref, source_sender_open_id=source_sender_open_id, explicit_work_item_id=explicit_work_item_id, thread_context=thread_context, ) if target is None: if explicit_work_item_id is not None: return { "handled": False, "status": "ignored", "followup_reply": "", "target_work_item_id": None, "target_state": None, "target_intent": None, "project_id": None, "signal_id": None, "workflow_route": None, } return { "handled": False, "status": "ignored", "followup_reply": "", "target_work_item_id": None, "target_state": None, "target_intent": None, "project_id": None, "signal_id": None, "workflow_route": None, } target_work_item, target_ingest = target if action == self._ACTION_CONFIRM: return await self.confirm_draft( target_work_item=target_work_item, target_ingest=target_ingest, sender=sender, source_system=source_system, source_message_id=source_message_id, message_text=message_text, expected_version=expected_version, ) if action == self._ACTION_REVISE: return await self.revise_draft( target_work_item=target_work_item, target_ingest=target_ingest, revision_note=revision_note, expected_version=expected_version, ) if action == self._ACTION_CANCEL: return await self.cancel_draft( target_work_item=target_work_item, reason="用户取消草稿", ) return { "handled": True, "status": "pending_manual", "followup_reply": "请回复“确认”、“修改 + 你的意见”或“取消”来处理当前草稿。", "target_work_item_id": int(target_work_item.id), "target_state": str(target_work_item.state or "").strip() or None, "target_intent": str(target_work_item.intent or "").strip() or None, "project_id": _safe_int(target_work_item.project_id), "signal_id": _safe_int(target_work_item.signal_id), "workflow_route": RouteResolver.normalize_route_key(str(target_work_item.intent or "")), } async def handle_api_decision( self, *, work_item_id: int, action: str, operator_sender: str, source_message_id: Optional[str], revision_note: Optional[str], draft_version: Optional[int], ) -> dict[str, Any]: row = ( await self.db.execute( select(WorkItem, IngestEvent) .join(IngestEvent, IngestEvent.id == WorkItem.ingest_event_id) .where( and_( WorkItem.tenant_id == int(self.tenant_id), WorkItem.id == int(work_item_id), ) ) .limit(1) ) ).first() if row is None: return { "handled": False, "status": "failed_safe", "followup_reply": "草稿不存在或已被删除。", "target_work_item_id": int(work_item_id), "target_state": None, "target_intent": None, "project_id": None, "signal_id": None, "workflow_route": None, } target_work_item, target_ingest = row normalized_action = str(action or "").strip().lower() normalized_source_message_id = ( str(source_message_id or "").strip() or f"draft-api-{normalized_action}-{int(work_item_id)}-{int(datetime.now().timestamp() * 1000)}" )[:128] if normalized_action == self._ACTION_CONFIRM: return await self.confirm_draft( target_work_item=target_work_item, target_ingest=target_ingest, sender=operator_sender, source_system="ai_pmo_ui", source_message_id=normalized_source_message_id, message_text="确认", expected_version=draft_version, ) if normalized_action == self._ACTION_REVISE: return await self.revise_draft( target_work_item=target_work_item, target_ingest=target_ingest, revision_note=str(revision_note or "").strip(), expected_version=draft_version, ) if normalized_action == self._ACTION_CANCEL: return await self.cancel_draft( target_work_item=target_work_item, reason="用户在消息台取消草稿", ) return { "handled": False, "status": "failed_safe", "followup_reply": "不支持的草稿操作。", "target_work_item_id": int(work_item_id), "target_state": str(target_work_item.state or "").strip() or None, "target_intent": str(target_work_item.intent or "").strip() or None, "project_id": _safe_int(target_work_item.project_id), "signal_id": _safe_int(target_work_item.signal_id), "workflow_route": RouteResolver.normalize_route_key(str(target_work_item.intent or "")), } async def confirm_draft( self, *, target_work_item: WorkItem, target_ingest: IngestEvent, sender: str, source_system: str, source_message_id: str, message_text: str, expected_version: Optional[int], ) -> dict[str, Any]: locked = ( await self.db.execute( select(WorkItem) .where( and_( WorkItem.tenant_id == int(self.tenant_id), WorkItem.id == int(target_work_item.id), ) ) .with_for_update() .limit(1) ) ).scalar_one_or_none() if locked is None: return { "handled": False, "status": "failed_safe", "followup_reply": "草稿不存在或已被删除。", "target_work_item_id": int(target_work_item.id), "target_state": None, "target_intent": None, "project_id": None, "signal_id": None, "workflow_route": None, } state = str(locked.state or "").strip() if state != STATE_DRAFT_PENDING_CONFIRM: if state == STATE_SUCCEEDED: followup_reply = "已入库成功,无需重复确认。" elif state == STATE_CANCELLED: followup_reply = "该草稿已取消,无需重复确认。" else: followup_reply = "该草稿已处理,无需重复确认。" return { "handled": True, "status": "succeeded" if state == STATE_SUCCEEDED else "failed_safe", "followup_reply": followup_reply, "target_work_item_id": int(locked.id), "target_state": state or None, "target_intent": str(locked.intent or "").strip() or None, "project_id": _safe_int(locked.project_id), "signal_id": _safe_int(locked.signal_id), "workflow_route": RouteResolver.normalize_route_key(str(locked.intent or "")), } current_version = int(locked.draft_version or 0) if expected_version is not None and int(expected_version) != current_version: return { "handled": True, "status": "pending_manual", "followup_reply": f"草稿版本已更新,请基于最新版本 v{current_version} 再确认。", "target_work_item_id": int(locked.id), "target_state": state, "target_intent": str(locked.intent or "").strip() or None, "project_id": _safe_int(locked.project_id), "signal_id": _safe_int(locked.signal_id), "workflow_route": RouteResolver.normalize_route_key(str(locked.intent or "")), } now_at = _utc_now_naive() if isinstance(locked.draft_expires_at, datetime) and locked.draft_expires_at <= now_at: locked.state = STATE_CANCELLED locked.execution_result = { **(locked.execution_result if isinstance(locked.execution_result, dict) else {}), "status": "cancelled", "cancel_reason": "draft_expired", "cancelled_at": now_at.isoformat(), } await self._sync_decision_outcome( work_item_id=int(locked.id), execution_result=locked.execution_result, final_outcome=STATE_CANCELLED, ) return { "handled": True, "status": "cancelled", "followup_reply": "⏰ 草稿已超时取消,请重新发送需求。", "target_work_item_id": int(locked.id), "target_state": STATE_CANCELLED, "target_intent": str(locked.intent or "").strip() or None, "project_id": _safe_int(locked.project_id), "signal_id": _safe_int(locked.signal_id), "workflow_route": RouteResolver.normalize_route_key(str(locked.intent or "")), } existing_confirm_id = str(locked.confirm_message_id or "").strip() if existing_confirm_id and existing_confirm_id != str(source_message_id or "").strip(): return { "handled": True, "status": "failed_safe", "followup_reply": "该草稿已被其他确认消息处理,请刷新状态后查看结果。", "target_work_item_id": int(locked.id), "target_state": str(locked.state or "").strip() or None, "target_intent": str(locked.intent or "").strip() or None, "project_id": _safe_int(locked.project_id), "signal_id": _safe_int(locked.signal_id), "workflow_route": RouteResolver.normalize_route_key(str(locked.intent or "")), } locked.confirm_message_id = str(source_message_id or "").strip()[:128] or None locked.state = STATE_EXECUTING locked.reply_template_key = "action_processing" await self.db.flush() route_key = RouteResolver.normalize_route_key(str(locked.intent or "")) _, route_action = RouteResolver._split_route_key(route_key) draft_payload = locked.draft_content if isinstance(locked.draft_content, dict) else {} existing_execution = locked.execution_result if isinstance(locked.execution_result, dict) else {} try: signal = await self._persist_signal_from_draft( work_item=locked, draft=draft_payload, source_system=source_system, source_message_id=source_message_id, sender=sender, message_text=message_text, ) except Exception as exc: locked.state = STATE_FAILED locked.draft_expires_at = None locked.reply_template_key = "safe_error" error_message = f"草稿确认入库失败:{str(exc)[:240]}" locked.execution_result = { **existing_execution, "status": "failed_safe", "workflow_route": route_key, "workflow_action": route_action, "error_message": error_message, "committed_from_draft": False, } await self._sync_decision_outcome( work_item_id=int(locked.id), execution_result=locked.execution_result, final_outcome=STATE_FAILED, ) await self.db.flush() return { "handled": True, "status": "failed_safe", "followup_reply": error_message, "target_work_item_id": int(locked.id), "target_state": STATE_FAILED, "target_intent": str(locked.intent or "").strip() or None, "project_id": _safe_int(locked.project_id), "signal_id": _safe_int(locked.signal_id), "workflow_route": route_key, } signal_id = _safe_int(getattr(signal, "id", None)) if signal_id is not None: locked.signal_id = int(signal_id) locked.state = STATE_SUCCEEDED locked.draft_expires_at = None locked.reply_template_key = "action_succeeded" reply_text = self._build_confirm_success_text( draft=draft_payload, signal_id=_safe_int(locked.signal_id), ) locked.execution_result = { **existing_execution, "status": "succeeded", "workflow_route": route_key, "workflow_action": route_action, "signal_id": _safe_int(locked.signal_id), "project_id": _safe_int(locked.project_id), "draft_version": current_version, "followup_reply": reply_text, "committed_from_draft": True, } await self._sync_decision_outcome( work_item_id=int(locked.id), execution_result=locked.execution_result, final_outcome=STATE_SUCCEEDED, ) await self.db.flush() return { "handled": True, "status": "succeeded", "followup_reply": reply_text, "target_work_item_id": int(locked.id), "target_state": STATE_SUCCEEDED, "target_intent": str(locked.intent or "").strip() or None, "project_id": _safe_int(locked.project_id), "signal_id": _safe_int(locked.signal_id), "workflow_route": route_key, } async def revise_draft( self, *, target_work_item: WorkItem, target_ingest: IngestEvent, revision_note: str, expected_version: Optional[int], ) -> dict[str, Any]: locked = ( await self.db.execute( select(WorkItem) .where( and_( WorkItem.tenant_id == int(self.tenant_id), WorkItem.id == int(target_work_item.id), ) ) .with_for_update() .limit(1) ) ).scalar_one_or_none() if locked is None: return { "handled": False, "status": "failed_safe", "followup_reply": "草稿不存在或已被删除。", "target_work_item_id": int(target_work_item.id), "target_state": None, "target_intent": None, "project_id": None, "signal_id": None, "workflow_route": None, } state = str(locked.state or "").strip() if state != STATE_DRAFT_PENDING_CONFIRM: return { "handled": True, "status": "failed_safe", "followup_reply": "当前草稿已不在待确认状态,无法再修改。", "target_work_item_id": int(locked.id), "target_state": state or None, "target_intent": str(locked.intent or "").strip() or None, "project_id": _safe_int(locked.project_id), "signal_id": _safe_int(locked.signal_id), "workflow_route": RouteResolver.normalize_route_key(str(locked.intent or "")), } current_version = int(locked.draft_version or 0) if expected_version is not None and int(expected_version) != current_version: return { "handled": True, "status": "pending_manual", "followup_reply": f"草稿版本已更新,请刷新后基于 v{current_version} 再修改。", "target_work_item_id": int(locked.id), "target_state": state, "target_intent": str(locked.intent or "").strip() or None, "project_id": _safe_int(locked.project_id), "signal_id": _safe_int(locked.signal_id), "workflow_route": RouteResolver.normalize_route_key(str(locked.intent or "")), } ingest_payload = target_ingest.payload if isinstance(target_ingest.payload, dict) else {} original_message = str(ingest_payload.get("message_text") or "").strip() if not original_message: original_message = str((locked.draft_content or {}).get("original_text") or "").strip() intent_name = RouteResolver.canonical_intent_for_route(RouteResolver.normalize_route_key(str(locked.intent or ""))) generated = await self.draft_generator.generate_and_persist( work_item=locked, intent_name=intent_name, message_text=original_message, world_model=locked.context_snapshot if isinstance(locked.context_snapshot, dict) else {}, revision_note=revision_note, ) locked.state = STATE_DRAFT_PENDING_CONFIRM locked.reply_template_key = "draft_pending_confirm" await self.db.flush() draft_text = str(generated.get("draft_text") or "").strip() if not draft_text: draft_text = DraftGeneratorService.render_draft_message(locked.draft_content if isinstance(locked.draft_content, dict) else {}) followup_reply = "🔄 正在重新起草,请稍候...\n\n" + draft_text return { "handled": True, "status": "draft_pending_confirm", "followup_reply": followup_reply, "target_work_item_id": int(locked.id), "target_state": STATE_DRAFT_PENDING_CONFIRM, "target_intent": str(locked.intent or "").strip() or None, "project_id": _safe_int(locked.project_id), "signal_id": _safe_int(locked.signal_id), "workflow_route": RouteResolver.normalize_route_key(str(locked.intent or "")), "draft_version": int(locked.draft_version or 0), "draft_content": locked.draft_content if isinstance(locked.draft_content, dict) else None, } async def cancel_draft( self, *, target_work_item: WorkItem, reason: str, ) -> dict[str, Any]: locked = ( await self.db.execute( select(WorkItem) .where( and_( WorkItem.tenant_id == int(self.tenant_id), WorkItem.id == int(target_work_item.id), ) ) .with_for_update() .limit(1) ) ).scalar_one_or_none() if locked is None: return { "handled": False, "status": "failed_safe", "followup_reply": "草稿不存在或已被删除。", "target_work_item_id": int(target_work_item.id), "target_state": None, "target_intent": None, "project_id": None, "signal_id": None, "workflow_route": None, } if str(locked.state or "").strip() == STATE_CANCELLED: return { "handled": True, "status": "cancelled", "followup_reply": "已取消,如需重新提交请再次发送。", "target_work_item_id": int(locked.id), "target_state": STATE_CANCELLED, "target_intent": str(locked.intent or "").strip() or None, "project_id": _safe_int(locked.project_id), "signal_id": _safe_int(locked.signal_id), "workflow_route": RouteResolver.normalize_route_key(str(locked.intent or "")), } if str(locked.state or "").strip() != STATE_DRAFT_PENDING_CONFIRM: return { "handled": True, "status": "failed_safe", "followup_reply": "当前草稿已不在待确认状态,无法取消。", "target_work_item_id": int(locked.id), "target_state": str(locked.state or "").strip() or None, "target_intent": str(locked.intent or "").strip() or None, "project_id": _safe_int(locked.project_id), "signal_id": _safe_int(locked.signal_id), "workflow_route": RouteResolver.normalize_route_key(str(locked.intent or "")), } locked.state = STATE_CANCELLED locked.draft_expires_at = None locked.reply_template_key = "draft_cancelled" locked.execution_result = { **(locked.execution_result if isinstance(locked.execution_result, dict) else {}), "status": "cancelled", "cancel_reason": str(reason or "用户取消草稿")[:120], "cancelled_at": _utc_now_naive().isoformat(), } await self._sync_decision_outcome( work_item_id=int(locked.id), execution_result=locked.execution_result, final_outcome=STATE_CANCELLED, ) await self.db.flush() return { "handled": True, "status": "cancelled", "followup_reply": "已取消,如需重新提交请再次发送。", "target_work_item_id": int(locked.id), "target_state": STATE_CANCELLED, "target_intent": str(locked.intent or "").strip() or None, "project_id": _safe_int(locked.project_id), "signal_id": _safe_int(locked.signal_id), "workflow_route": RouteResolver.normalize_route_key(str(locked.intent or "")), } async def _resolve_pending_draft( self, *, sender: str, source_system: str, channel_ref: Optional[str], source_sender_open_id: Optional[str], explicit_work_item_id: Optional[int], thread_context: Optional[dict[str, Any]], ) -> Optional[tuple[WorkItem, IngestEvent]]: stmt = ( select(WorkItem, IngestEvent) .join(IngestEvent, IngestEvent.id == WorkItem.ingest_event_id) .where( and_( WorkItem.tenant_id == int(self.tenant_id), WorkItem.state == STATE_DRAFT_PENDING_CONFIRM, ) ) .order_by(WorkItem.id.desc()) .limit(30) ) rows = (await self.db.execute(stmt)).all() if explicit_work_item_id is not None: for work_item, ingest in rows: if int(work_item.id) == int(explicit_work_item_id): return work_item, ingest return None normalized_sender = str(sender or "").strip() normalized_channel = str(channel_ref or "").strip() normalized_open_id = str(source_sender_open_id or "").strip() anchor_context = self._normalize_thread_context(thread_context) if not anchor_context: return None source_message_ids = sorted( { str(getattr(work_item, "source_message_id", "") or "").strip() for work_item, _ in rows if str(getattr(work_item, "source_message_id", "") or "").strip() } ) sent_message_id_map: dict[str, str] = {} if source_message_ids: outbox_rows = ( await self.db.execute( select( FeishuReplyOutbox.source_message_id, FeishuReplyOutbox.sent_message_id, ) .where( and_( FeishuReplyOutbox.tenant_id == int(self.tenant_id), FeishuReplyOutbox.source_message_id.in_(source_message_ids), FeishuReplyOutbox.status == "sent", FeishuReplyOutbox.sent_message_id.is_not(None), ) ) .order_by(FeishuReplyOutbox.id.desc()) ) ).all() for source_message_id, sent_message_id in outbox_rows: source_key = str(source_message_id or "").strip() sent_key = str(sent_message_id or "").strip() if source_key and sent_key and source_key not in sent_message_id_map: sent_message_id_map[source_key] = sent_key candidates: list[tuple[WorkItem, IngestEvent]] = [] for work_item, ingest in rows: payload = ingest.payload if isinstance(ingest.payload, dict) else {} same_sender = normalized_sender and str(payload.get("sender") or "").strip() == normalized_sender same_channel = normalized_channel and str(payload.get("channel_ref") or "").strip() == normalized_channel same_open_id = normalized_open_id and str(payload.get("source_sender_open_id") or "").strip() == normalized_open_id if str(source_system or "").strip().lower() == "feishu": if not (same_open_id or same_channel or same_sender): continue else: if not (same_sender or same_channel): continue if not self._matches_thread_anchor( work_item=work_item, ingest=ingest, anchor_context=anchor_context, draft_reply_message_id=sent_message_id_map.get(str(getattr(work_item, "source_message_id", "") or "").strip()), ): continue candidates.append((work_item, ingest)) if not candidates: return None return candidates[0] @staticmethod def _normalize_thread_context( thread_context: Optional[dict[str, Any]], ) -> dict[str, str]: if not isinstance(thread_context, dict): return {} normalized: dict[str, str] = {} for key in ("reply_to_message_id", "root_message_id", "thread_id"): value = str(thread_context.get(key) or "").strip() if value: normalized[key] = value[:128] return normalized @classmethod def _matches_thread_anchor( cls, *, work_item: WorkItem, ingest: IngestEvent, anchor_context: dict[str, str], draft_reply_message_id: Optional[str] = None, ) -> bool: if not anchor_context: return False payload = ingest.payload if isinstance(ingest.payload, dict) else {} candidate_values = { str(payload.get("reply_to_message_id") or "").strip(), str(payload.get("root_message_id") or "").strip(), str(getattr(ingest, "source_message_id", "") or "").strip(), str(getattr(work_item, "source_message_id", "") or "").strip(), str(draft_reply_message_id or "").strip(), } candidate_values.discard("") return any( str(value or "").strip() in candidate_values for value in anchor_context.values() ) async def _sync_decision_outcome( self, *, work_item_id: int, execution_result: dict[str, Any], final_outcome: str, ) -> None: decision = ( await self.db.execute( select(DecisionRecord) .where( and_( DecisionRecord.tenant_id == int(self.tenant_id), DecisionRecord.work_item_id == int(work_item_id), ) ) .order_by(DecisionRecord.id.desc()) .limit(1) ) ).scalar_one_or_none() if decision is None: return decision.execution_result = execution_result decision.final_outcome = str(final_outcome or "")[:32] or None await self.db.flush() @classmethod def _extract_work_item_id(cls, text: str) -> Optional[int]: matched = cls._WORK_ITEM_ID_RE.search(str(text or "")) if matched is None: return None return _safe_int(matched.group(1)) @classmethod def _extract_revision_note(cls, text: str) -> str: normalized = str(text or "").strip() for hint in cls._REVISE_HINTS: if normalized.startswith(hint): note = normalized[len(hint):].strip(" ::") return note[:500] return "" @staticmethod def _safe_float(value: Any) -> Optional[float]: try: if value is None or value == "": return None return float(value) except (TypeError, ValueError): return None @staticmethod def _build_confirm_success_text(*, draft: dict[str, Any], signal_id: Optional[int]) -> str: title = str(draft.get("title") or "需求").strip() or "需求" assignee_name = str(draft.get("recommended_assignee_name") or "待指定").strip() or "待指定" lines = ["✅ 已入库", "", f"📌 {title}"] if signal_id is not None: lines.append(f"信号 #{int(signal_id)} 已创建") else: lines.append("已完成入库并开始执行") lines.append(f"负责人:{assignee_name}") return "\n".join(lines) async def _persist_signal_from_draft( self, *, work_item: WorkItem, draft: dict[str, Any], source_system: str, source_message_id: str, sender: str, message_text: str, ) -> Signal: title = str(draft.get("title") or "").strip()[:200] if not title: title = str(draft.get("summary") or "").strip()[:50] if not title: title = "新需求" summary = str(draft.get("summary") or "").strip()[:300] or title content = str(draft.get("original_text") or "").strip()[:2000] or summary assignee_id = _safe_int(draft.get("recommended_assignee_id")) assignee_name = str(draft.get("recommended_assignee_name") or "").strip()[:40] workload = self._safe_float(draft.get("recommended_assignee_workload")) feasibility = str(draft.get("feasibility") or "").strip()[:20] feasibility_reason = str(draft.get("feasibility_reason") or "").strip()[:120] followup_plan = draft.get("followup_plan") if isinstance(draft.get("followup_plan"), list) else [] project_id = _safe_int(work_item.project_id) strategy_payload = { "source_context": { "source_message_id": str(source_message_id or "").strip()[:128], "source_system": str(source_system or "").strip()[:32], "from_work_item_id": int(work_item.id), "followup_sender": str(sender or "").strip()[:80], }, "ai_analysis": { "summary": summary, "intent": RouteResolver.canonical_intent_for_route( RouteResolver.normalize_route_key(str(work_item.intent or "")) ), "draft_confirmed_direct_commit": True, "feasibility": feasibility, "feasibility_reason": feasibility_reason, "followup_plan": followup_plan[:3], }, "assignee": { "user_id": assignee_id, "name": assignee_name, "workload_percent": workload, }, } reasoning_items = [ "来源:草稿确认后直接入库(不重新分析)。", f"可行性:{feasibility or '待讨论'}", f"原因:{feasibility_reason or '无'}", ] signal = Signal( tenant_id=int(self.tenant_id), source="PMFlow", title=title, summary=summary, content=content, signal_type="新需求", reasoning=json.dumps(reasoning_items, ensure_ascii=False), strategic_analysis=json.dumps(strategy_payload, ensure_ascii=False), confidence=0.78, recommended_action="enter_only", matched_project_id=project_id, impact_score=0.7, priority="medium", processed=False, tasks_generated=json.dumps([], ensure_ascii=False), status="待处理", planner_assignee_user_id=assignee_id, ) self.db.add(signal) await self.db.flush() if project_id is not None: link_exists = ( await self.db.execute( select(ProjectSignalLink.id) .where( and_( ProjectSignalLink.tenant_id == int(self.tenant_id), ProjectSignalLink.project_id == int(project_id), ProjectSignalLink.signal_id == int(signal.id), ) ) .limit(1) ) ).scalar_one_or_none() if link_exists is None: self.db.add( ProjectSignalLink( tenant_id=int(self.tenant_id), project_id=int(project_id), signal_id=int(signal.id), link_type="related", relevance_score=1.0, ) ) await self.db.flush() if not str(message_text or "").strip(): message_text = summary return signal @staticmethod def _build_execution_route(*, work_item: WorkItem) -> WorkflowRoute: normalized_key = RouteResolver.normalize_route_key(str(work_item.intent or "")) family, action = RouteResolver._split_route_key(normalized_key) execution_result = work_item.execution_result if isinstance(work_item.execution_result, dict) else {} requested_count = _safe_int(execution_result.get("requested_count")) or 1 if family != ROUTE_FAMILY_PROJECT_SIGNAL: requested_count = 1 return WorkflowRoute( family=family, action=action, project_id=_safe_int(work_item.project_id), requested_count=requested_count, requires_confirmation=True, metadata={ "workflow_route": normalized_key, "workflow_family": family, "workflow_action": action, "resumed_from": "draft_pending_confirm", "target_work_item_id": int(work_item.id), }, )