"""Draft confirmation/revision/cancel handler for AI PMO write intents.""" from __future__ import annotations from datetime import datetime, timedelta 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 ..ai_steward_service import AIStewardService from ..mounted_ai_runtime_write_service import MountedAIRuntimeWriteService from .ai_pmo_typed_executor_execute import mixin_execute_query_project from .semantic_context.assembler import SemanticContextAssembler from .semantic_context.gatekeeper import SemanticDecisionGatekeeper from .semantic_context.semantic_decision_service import SemanticDecisionService from ..signal.draft_generator_service import DraftGeneratorService from ...models import MountedDraftWorkspace, MountedTopicWorkspace 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_REGENERATE = "regenerate" _ACTION_CANCEL = "cancel" _ACTION_ANSWER_ONLY = "answer_only" _ACTION_UNKNOWN = "unknown" _SEMANTIC_FOLLOWUP_ACTIONS = ( _ACTION_CONFIRM, _ACTION_REVISE, _ACTION_REGENERATE, _ACTION_CANCEL, _ACTION_ANSWER_ONLY, ) _EXPLICIT_CONFIRM_COMMANDS = frozenset({"确认", "确认本版", "confirm", "ok", "好的", "好", "yes", "y"}) _EXPLICIT_CANCEL_COMMANDS = frozenset({"取消", "取消本版", "cancel", "不用了", "算了"}) _DEFAULT_DISPLAY_FEEDBACK_REPLY = ( "收到,这是展示形式反馈:卡片可以更完整,聊天直发我会控制得更短;当前草稿内容我先不重起。" ) _WORK_ITEM_ID_RE = re.compile(r"(?:work[_ -]?item|工作项|草稿)\s*(?:id|ID)?\s*[:#:]?\s*(\d+)", re.IGNORECASE) @classmethod def is_explicit_protocol_followup_text(cls, text: str) -> bool: normalized_text = str(text or "").strip().lower() if not normalized_text: return False return normalized_text in (cls._EXPLICIT_CONFIRM_COMMANDS | cls._EXPLICIT_CANCEL_COMMANDS) 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) 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, semantic_followup_override: Optional[dict[str, str]] = None, ) -> dict[str, Any]: explicit_work_item_id = self._extract_work_item_id(message_text) normalized_thread_context = self._normalize_thread_context(thread_context) has_reply_anchor = bool(str(normalized_thread_context.get("reply_to_message_id") or "").strip()) normalized_text = str(message_text or "").strip().lower() 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 or ( has_reply_anchor and normalized_text in (self._EXPLICIT_CONFIRM_COMMANDS | self._EXPLICIT_CANCEL_COMMANDS) ): return { "handled": True, "status": "failed_safe", "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 action = self._ACTION_UNKNOWN revision_note = "" active_mounted = await self._resolve_target_mounted_draft( sender=sender, source_system=source_system, channel_ref=channel_ref, source_sender_open_id=source_sender_open_id, thread_context=thread_context, target_work_item_id=int(target_work_item.id), ) semantic_followup = ( { "action": str((semantic_followup_override or {}).get("action") or "").strip().lower(), "revision_note": str((semantic_followup_override or {}).get("revision_note") or "").strip(), "assistant_reply": str((semantic_followup_override or {}).get("assistant_reply") or "").strip(), } if isinstance(semantic_followup_override, dict) else await self._resolve_semantic_followup_decision( message_text=message_text, source_system=source_system, thread_context=thread_context, active_mounted=active_mounted, ) ) if semantic_followup is not None: action = str(semantic_followup.get("action") or action).strip().lower() or action revision_note = str(semantic_followup.get("revision_note") or revision_note).strip() if action == self._ACTION_ANSWER_ONLY: return { "handled": True, "status": "pending_manual", "followup_reply": str(semantic_followup.get("assistant_reply") or "").strip() or self._DEFAULT_DISPLAY_FEEDBACK_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 "")), } 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_REGENERATE: return await self.regenerate_draft( target_work_item=target_work_item, target_ingest=target_ingest, message_text=message_text, expected_version=expected_version, ) if action == self._ACTION_CANCEL: return await self.cancel_draft( target_work_item=target_work_item, reason="用户取消草稿", ) if explicit_work_item_id is None and not has_reply_anchor: 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": 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 _resolve_target_mounted_draft( self, *, sender: str, source_system: str, channel_ref: Optional[str], source_sender_open_id: Optional[str], thread_context: Optional[dict[str, Any]], target_work_item_id: int, ) -> Optional[tuple[MountedDraftWorkspace, MountedTopicWorkspace]]: if not hasattr(self.db, "execute"): return None active_mounted = await self._resolve_active_mounted_draft( sender=sender, source_system=source_system, channel_ref=channel_ref, source_sender_open_id=source_sender_open_id, thread_context=thread_context, ) if active_mounted is not None: return active_mounted return await self._resolve_mounted_draft_by_work_item_id(work_item_id=int(target_work_item_id)) async def _resolve_semantic_followup_decision( self, *, message_text: str, source_system: str, thread_context: Optional[dict[str, Any]], active_mounted: Optional[tuple[MountedDraftWorkspace, MountedTopicWorkspace]], ) -> Optional[dict[str, str]]: if active_mounted is None: return None _, mounted_topic = active_mounted try: packet = await SemanticContextAssembler(self.db).assemble( tenant_id=int(self.tenant_id), operator_user_id=0, topic_workspace_id=int(mounted_topic.id), latest_user_message=message_text, ingress_context={ "candidate_kind": "draft_followup", "source_system": source_system, "reply_anchor_present": bool(self._normalize_thread_context(thread_context)), "allowed_followup_actions": list(self._SEMANTIC_FOLLOWUP_ACTIONS), "thread_context": self._normalize_thread_context(thread_context), }, ) decision = await SemanticDecisionService(llm_service).decide(packet=packet) followup_action = str(getattr(decision, "followup_action", "") or "").strip().lower() if followup_action not in self._SEMANTIC_FOLLOWUP_ACTIONS: return None gate_result = SemanticDecisionGatekeeper.evaluate( mode_card=packet.mode_card, decision=decision, ) if not bool(gate_result.accepted): return None allowed_actions = { str(item).strip() for item in (getattr(packet.mode_card, "allowed_actions", None) or []) if str(item or "").strip() } action_code = ( "draft.followup.answer_only" if followup_action == self._ACTION_ANSWER_ONLY else f"draft.followup.{followup_action}" ) if action_code not in allowed_actions: return None revision_note = str(getattr(decision, "revision_note", "") or "").strip() assistant_reply = str(getattr(decision, "assistant_reply", "") or "").strip() except Exception as exc: logger.warning( "draft_confirm_semantic_followup_failed", tenant_id=self.tenant_id, topic_workspace_id=_safe_int(getattr(mounted_topic, "id", None)), error=str(exc), ) return None if followup_action == self._ACTION_REVISE and not revision_note: return { "action": self._ACTION_REVISE, "revision_note": "", "assistant_reply": assistant_reply, } if followup_action == self._ACTION_ANSWER_ONLY and not assistant_reply: assistant_reply = self._DEFAULT_DISPLAY_FEEDBACK_REPLY return { "action": followup_action, "revision_note": revision_note, "assistant_reply": assistant_reply, } async def _resolve_mounted_draft_by_work_item_id( self, *, work_item_id: int, ) -> Optional[tuple[MountedDraftWorkspace, MountedTopicWorkspace]]: if not hasattr(self.db, "execute"): return None row = ( await self.db.execute( select(MountedDraftWorkspace, MountedTopicWorkspace) .join( MountedTopicWorkspace, MountedTopicWorkspace.id == MountedDraftWorkspace.topic_workspace_id, ) .where( and_( MountedDraftWorkspace.tenant_id == int(self.tenant_id), MountedTopicWorkspace.tenant_id == int(self.tenant_id), MountedDraftWorkspace.active_work_item_id == int(work_item_id), MountedTopicWorkspace.status == "active", ) ) .order_by(MountedDraftWorkspace.id.desc()) .limit(1) ) ).first() if row is None: return None return row 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, ) await self._release_mounted_draft_binding( work_item_id=int(locked.id), final_state=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 {} canonical_intent = RouteResolver.canonical_intent_for_route(route_key) creator_user_id = _safe_int(getattr(locked, "owner_user_id", None)) if creator_user_id is None and isinstance(target_ingest.payload, dict): creator_user_id = _safe_int(target_ingest.payload.get("owner_user_id")) project_payload: Optional[dict[str, Any]] = None signal_source_count = 0 try: if canonical_intent == INTENT_PROJECT_CREATE: project_payload = await self._persist_project_from_draft( work_item=locked, draft=draft_payload, source_system=source_system, source_message_id=source_message_id, sender=sender, message_text=message_text, creator_user_id=creator_user_id, ) signal_source_count = len( project_payload.get("signal_sources") if isinstance(project_payload.get("signal_sources"), list) else [] ) signal = project_payload.get("signal") if isinstance(project_payload.get("signal"), dict) else None else: 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._release_mounted_draft_binding( work_item_id=int(locked.id), final_state=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, } project_data = project_payload.get("project") if isinstance(project_payload, dict) and isinstance(project_payload.get("project"), dict) else {} project_id = _safe_int(project_payload.get("project_id")) if isinstance(project_payload, dict) else None if project_id is None: project_id = _safe_int(project_data.get("id")) signal_id = _safe_int(project_payload.get("signal_id")) if isinstance(project_payload, dict) else None if signal_id is None and isinstance(signal, dict): signal_id = _safe_int(signal.get("id")) if project_id is not None: locked.project_id = int(project_id) 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), project_id=_safe_int(locked.project_id), signal_source_count=signal_source_count, ) 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), "signal_source_count": signal_source_count, "signal_sources": project_payload.get("signal_sources") if isinstance(project_payload, dict) else [], "created_project": project_payload.get("project") if isinstance(project_payload, dict) else None, "created_signal": project_payload.get("signal") if isinstance(project_payload, dict) else None, "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._release_mounted_draft_binding( work_item_id=int(locked.id), final_state=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]: return await self._rerender_draft( target_work_item=target_work_item, target_ingest=target_ingest, rewrite_note=str(revision_note or "").strip(), expected_version=expected_version, require_note=True, missing_note_reply="请先补充修改意见再继续,例如:修改 目标客户改成北美 DTC 品牌。", invalid_state_reply="当前草稿已不在待确认状态,无法再修改。", version_conflict_reply_prefix="草稿版本已更新,请刷新后基于", ) async def regenerate_draft( self, *, target_work_item: WorkItem, target_ingest: IngestEvent, message_text: str, expected_version: Optional[int], ) -> dict[str, Any]: regenerate_note = self._build_regenerate_revision_note(message_text=message_text) return await self._rerender_draft( target_work_item=target_work_item, target_ingest=target_ingest, rewrite_note=regenerate_note, expected_version=expected_version, require_note=False, missing_note_reply="", invalid_state_reply="当前草稿已不在待确认状态,无法再重出一版。", version_conflict_reply_prefix="草稿版本已更新,请刷新后基于", ) async def _rerender_draft( self, *, target_work_item: WorkItem, target_ingest: IngestEvent, rewrite_note: str, expected_version: Optional[int], require_note: bool, missing_note_reply: str, invalid_state_reply: str, version_conflict_reply_prefix: 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, } state = str(locked.state or "").strip() if state != STATE_DRAFT_PENDING_CONFIRM: return { "handled": True, "status": "failed_safe", "followup_reply": invalid_state_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"{version_conflict_reply_prefix} 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 "")), } if require_note and not str(rewrite_note or "").strip(): return { "handled": True, "status": "draft_pending_confirm", "followup_reply": missing_note_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": current_version, "draft_content": locked.draft_content if isinstance(locked.draft_content, dict) else None, } 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 ""))) if self._is_requirement_plan_draft_payload(locked.draft_content): generated = await self._revise_requirement_plan_draft( locked=locked, target_ingest=target_ingest, revision_note=str(rewrite_note or "").strip(), ) else: 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(getattr(locked, "context_snapshot", None), dict) else {}, revision_note=str(rewrite_note or "").strip(), ) 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, } @staticmethod def _build_regenerate_revision_note(*, message_text: str) -> str: normalized_text = str(message_text or "").strip() if normalized_text: return ( "请基于当前已经收敛出的事实重出一版草稿,不新增虚构事实,不把展示层反馈误当成字段修改。" f"用户原话:{normalized_text}" ) return "请基于当前已经收敛出的事实重出一版草稿,不新增虚构事实,只重写组织方式和表达。" @staticmethod def _is_requirement_plan_draft_payload(draft: Any) -> bool: return isinstance(draft, dict) and str(draft.get("draft_source") or "").strip() == "requirement_plan" async def _revise_requirement_plan_draft( self, *, locked: WorkItem, target_ingest: IngestEvent, revision_note: str, ) -> dict[str, Any]: draft_payload = locked.draft_content if isinstance(locked.draft_content, dict) else {} base_card = draft_payload.get("requirement_card") if isinstance(draft_payload.get("requirement_card"), dict) else {} if not base_card: raise RuntimeError("requirement_plan_draft_missing_card") runtime = _RequirementPlanDraftRuntime(self.db, tenant_id=int(self.tenant_id)) merged_card = dict(base_card) merged_card.update(runtime._extract_requirement_plan_updates(message_text=revision_note, card=merged_card)) if not str(merged_card.get("title") or "").strip(): merged_card["title"] = runtime._derive_requirement_plan_title(message_text=revision_note, card=merged_card) if not str(merged_card.get("goal") or "").strip(): merged_card["goal"] = runtime._derive_requirement_plan_goal(message_text=revision_note, card=merged_card) rendered_text, _, project_creation_draft = await runtime._generate_requirement_plan_confirmation_draft( card=merged_card, revision_note=revision_note, ) next_version = int(locked.draft_version or 0) + 1 locked.draft_content = dict(project_creation_draft) locked.draft_version = next_version locked.draft_expires_at = _utc_now_naive() + timedelta(hours=24) locked.execution_result = { **(locked.execution_result if isinstance(locked.execution_result, dict) else {}), "status": "draft_pending_confirm", "draft": dict(project_creation_draft), "draft_version": next_version, "draft_expires_at": locked.draft_expires_at.isoformat() if locked.draft_expires_at else None, "revision_note": str(revision_note or "")[:500] or None, } mounted_draft = ( await self.db.execute( select(MountedDraftWorkspace) .where( and_( MountedDraftWorkspace.tenant_id == int(self.tenant_id), MountedDraftWorkspace.active_work_item_id == int(locked.id), ) ) .order_by(MountedDraftWorkspace.id.desc()) .limit(1) ) ).scalar_one_or_none() if mounted_draft is not None: mounted_draft.state = "ready" mounted_draft.draft_version = next_version mounted_draft.requirement_card = dict(merged_card) mounted_draft.draft_payload = dict(project_creation_draft) mounted_draft.last_ready_at = _utc_now_naive() return { "draft": dict(project_creation_draft), "draft_text": rendered_text, "draft_version": next_version, } 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._release_mounted_draft_binding( work_item_id=int(locked.id), final_state=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]]: if explicit_work_item_id is not None: return await self._load_work_item_target_by_id(work_item_id=int(explicit_work_item_id)) active_mounted = await self._resolve_active_mounted_draft( sender=sender, source_system=source_system, channel_ref=channel_ref, source_sender_open_id=source_sender_open_id, thread_context=thread_context, ) if active_mounted is not None: mounted_draft, _ = active_mounted active_work_item_id = _safe_int(getattr(mounted_draft, "active_work_item_id", None)) if active_work_item_id is not None: target = await self._load_work_item_target_by_id(work_item_id=int(active_work_item_id)) if target is not None and str(getattr(target[0], "state", "") or "").strip() == STATE_DRAFT_PENDING_CONFIRM: return target await self._release_mounted_draft_binding( work_item_id=int(active_work_item_id), final_state=str(getattr(target[0], "state", "") or "").strip() if target is not None else "missing", mounted=active_mounted, ) return None 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() 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) sender_scope_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 sender_scope_candidates.append((work_item, ingest)) if not anchor_context: for candidate in sender_scope_candidates: candidate_work_item, _ = candidate candidate_route = RouteResolver.normalize_route_key(str(getattr(candidate_work_item, "intent", "") or "")) if candidate_route != ROUTE_FOLLOWUP_CONFIRM: return candidate return sender_scope_candidates[0] if sender_scope_candidates else None source_message_ids = sorted( { str(getattr(work_item, "source_message_id", "") or "").strip() for work_item, _ in sender_scope_candidates 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 sender_scope_candidates: 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] async def has_active_draft_session( self, *, sender: str, source_system: str, channel_ref: Optional[str], source_sender_open_id: Optional[str], thread_context: Optional[dict[str, Any]], ) -> bool: if not hasattr(self.db, "execute"): return False mounted = await self._resolve_active_mounted_draft( sender=sender, source_system=source_system, channel_ref=channel_ref, source_sender_open_id=source_sender_open_id, thread_context=thread_context, ) return mounted is not None async def _load_work_item_target_by_id( self, *, work_item_id: int, ) -> Optional[tuple[WorkItem, IngestEvent]]: 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 None return row async def _release_mounted_draft_binding( self, *, work_item_id: int, final_state: Optional[str], mounted: Optional[tuple[MountedDraftWorkspace, MountedTopicWorkspace]] = None, ) -> None: if not hasattr(self.db, "execute"): return if mounted is not None: rows = [mounted] else: result = await self.db.execute( select(MountedDraftWorkspace, MountedTopicWorkspace) .join( MountedTopicWorkspace, MountedTopicWorkspace.id == MountedDraftWorkspace.topic_workspace_id, ) .where( and_( MountedDraftWorkspace.tenant_id == int(self.tenant_id), MountedTopicWorkspace.tenant_id == int(self.tenant_id), MountedDraftWorkspace.active_work_item_id == int(work_item_id), ) ) ) rows = result.all() if hasattr(result, "all") else [] normalized_final_state = str(final_state or "").strip().lower() or None for draft_row, topic_row in rows: draft_row.active_work_item_id = None draft_row.confirm_message_id = None draft_row.state = "ready" if getattr(draft_row, "draft_payload", None) else "collecting" topic_row.status = "suspended" topic_row.plan_status = "suspended" topic_row.pending_actions = [] topic_row.metadata_json = { **(topic_row.metadata_json if isinstance(topic_row.metadata_json, dict) else {}), "active_draft_work_item_id": None, "draft_lifecycle_state": normalized_final_state, } await self.db.flush() async def _resolve_active_mounted_draft( self, *, sender: str, source_system: str, channel_ref: Optional[str], source_sender_open_id: Optional[str], thread_context: Optional[dict[str, Any]], ) -> Optional[tuple[MountedDraftWorkspace, MountedTopicWorkspace]]: normalized_source = str(source_system or "").strip().lower() if not normalized_source: return None conversation_scope_key = MountedAIRuntimeWriteService.build_conversation_scope_key( source_system=normalized_source, channel_ref=channel_ref, sender=sender, source_sender_open_id=source_sender_open_id, ) anchor_context = self._normalize_thread_context(thread_context) rows = ( await self.db.execute( select(MountedDraftWorkspace, MountedTopicWorkspace) .join( MountedTopicWorkspace, MountedTopicWorkspace.id == MountedDraftWorkspace.topic_workspace_id, ) .where( and_( MountedDraftWorkspace.tenant_id == int(self.tenant_id), MountedTopicWorkspace.tenant_id == int(self.tenant_id), MountedTopicWorkspace.source_system == normalized_source, MountedTopicWorkspace.mode == "plan", MountedTopicWorkspace.status == "active", MountedDraftWorkspace.state.in_(("ready", STATE_DRAFT_PENDING_CONFIRM)), ) ) .order_by(MountedTopicWorkspace.updated_at.desc(), MountedDraftWorkspace.id.desc()) ) ).all() if not rows: return None normalized_channel = str(channel_ref or "").strip() best: Optional[tuple[MountedDraftWorkspace, MountedTopicWorkspace]] = None best_score = -1 for draft_row, topic_row in rows: if not hasattr(draft_row, "topic_workspace_id") or not hasattr(topic_row, "mode"): continue active_work_item_id = _safe_int(getattr(draft_row, "active_work_item_id", None)) if active_work_item_id is None: continue target = await self._load_work_item_target_by_id(work_item_id=int(active_work_item_id)) if target is None: await self._release_mounted_draft_binding( work_item_id=int(active_work_item_id), final_state="missing", mounted=(draft_row, topic_row), ) continue target_work_item, _ = target target_state = str(getattr(target_work_item, "state", "") or "").strip().lower() if target_state != STATE_DRAFT_PENDING_CONFIRM: await self._release_mounted_draft_binding( work_item_id=int(active_work_item_id), final_state=target_state, mounted=(draft_row, topic_row), ) continue score = 0 if normalized_channel and str(getattr(topic_row, "channel_ref", "") or "").strip() == normalized_channel: score += 30 if conversation_scope_key and str(getattr(topic_row, "conversation_scope_key", "") or "").strip() == str(conversation_scope_key).strip(): score += 60 if anchor_context and self._matches_mounted_draft_anchor( draft=draft_row, topic=topic_row, anchor_context=anchor_context, ): score += 120 if str(getattr(topic_row, "plan_status", "") or "").strip().lower() in { STATE_DRAFT_PENDING_CONFIRM, "awaiting_execute_confirm", "draft_ready", }: score += 20 score += 10 if score > best_score: best = (draft_row, topic_row) best_score = score if best_score <= 0: return None return best @classmethod def _matches_mounted_draft_anchor( cls, *, draft: MountedDraftWorkspace, topic: MountedTopicWorkspace, anchor_context: dict[str, str], ) -> bool: if not anchor_context: return False candidate_values = { str(getattr(draft, "latest_reply_message_id", "") or "").strip(), str(getattr(draft, "latest_source_message_id", "") or "").strip(), str(getattr(topic, "root_message_id", "") or "").strip(), str(getattr(topic, "thread_key", "") or "").strip(), str(getattr(topic, "last_message_id", "") or "").strip(), } candidate_values.discard("") return any(str(value or "").strip() in candidate_values for value in anchor_context.values()) @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)) @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], project_id: Optional[int] = None, signal_source_count: int = 0, ) -> str: title = str(draft.get("title") or "需求").strip() or "需求" assignee_name = str(draft.get("recommended_assignee_name") or "待指定").strip() or "待指定" if project_id is not None: lines = ["✅ 已入库", "", f"📌 {title}", f"项目 #{int(project_id)} 已创建"] if signal_id is not None: lines.append(f"主信号 #{int(signal_id)} 已创建") if signal_source_count > 0: lines.append(f"信号源:{int(signal_source_count)} 个") else: lines.append("信号源:已生成") else: 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) @staticmethod def _build_project_creation_input_text( *, draft: dict[str, Any], fallback_message: str, ) -> str: lines: list[str] = [] def _append_line(label: str, value: Any) -> None: text = str(value or "").strip() if text: lines.append(f"{label}:{text}") def _append_list_section(label: str, values: list[str]) -> None: normalized = [str(item or "").strip() for item in values if str(item or "").strip()] if not normalized: return lines.append(f"{label}:") lines.extend(f"- {item}" for item in normalized) _append_line("项目标题", draft.get("title")) _append_line("项目摘要", draft.get("summary")) _append_line("一句话定位", draft.get("positioning_one_liner")) _append_line("目标客户", draft.get("target_customers")) core_pain_points = draft.get("core_pain_points") if isinstance(draft.get("core_pain_points"), list) else [] pain_lines: list[str] = [] for item in core_pain_points[:5]: if not isinstance(item, dict): text = str(item or "").strip() if text: pain_lines.append(text) continue pain = str(item.get("pain") or "").strip() if not pain: continue extra_parts = [] status = str(item.get("status") or "").strip() if status: extra_parts.append(f"现状:{status}") opportunity = str(item.get("opportunity") or "").strip() if opportunity: extra_parts.append(f"机会:{opportunity}") suffix = f";{';'.join(extra_parts)}" if extra_parts else "" pain_lines.append(f"{pain}{suffix}") _append_list_section("核心痛点", pain_lines) _append_list_section( "MVP 功能", draft.get("mvp_items") if isinstance(draft.get("mvp_items"), list) else [], ) _append_list_section( "MVP Phase 1", draft.get("mvp_phase_1") if isinstance(draft.get("mvp_phase_1"), list) else [], ) _append_list_section( "MVP Phase 2", draft.get("mvp_phase_2") if isinstance(draft.get("mvp_phase_2"), list) else [], ) _append_list_section( "技术架构", draft.get("technical_architecture") if isinstance(draft.get("technical_architecture"), list) else [], ) business_model = draft.get("business_model") if isinstance(draft.get("business_model"), list) else [] business_lines: list[str] = [] for item in business_model[:5]: if not isinstance(item, dict): text = str(item or "").strip() if text: business_lines.append(text) continue plan = str(item.get("plan") or item.get("name") or "").strip() price = str(item.get("price") or "").strip() features = item.get("features") if isinstance(features, list): feature_text = "、".join(str(part or "").strip() for part in features if str(part or "").strip()) else: feature_text = str(features or "").strip() parts = [part for part in (plan, price, feature_text) if part] if parts: business_lines.append(";".join(parts)) _append_list_section("商业模式", business_lines) validation_metrics = draft.get("validation_metrics") if isinstance(draft.get("validation_metrics"), dict) else {} _append_list_section( "产品验证指标", validation_metrics.get("product") if isinstance(validation_metrics.get("product"), list) else [], ) _append_list_section( "市场验证指标", validation_metrics.get("market") if isinstance(validation_metrics.get("market"), list) else [], ) team_setup = draft.get("team_setup") if isinstance(draft.get("team_setup"), list) else [] team_lines: list[str] = [] for item in team_setup[:8]: if not isinstance(item, dict): text = str(item or "").strip() if text: team_lines.append(text) continue role = str(item.get("role") or item.get("title") or item.get("name") or "").strip() notes = str(item.get("notes") or item.get("description") or "").strip() if role and notes: team_lines.append(f"{role}:{notes}") elif role: team_lines.append(role) _append_list_section("团队配置", team_lines) requirement_items = draft.get("requirement_items") if isinstance(draft.get("requirement_items"), list) else [] requirement_lines: list[str] = [] for item in requirement_items[:12]: if not isinstance(item, dict): text = str(item or "").strip() if text: requirement_lines.append(text) continue section = str(item.get("section") or "").strip() title = str(item.get("title") or "").strip() description = str(item.get("description") or "").strip() prefix = f"[{section}] " if section else "" body = title or description if body and description and description != body: body = f"{body}:{description}" if body: requirement_lines.append(f"{prefix}{body}") _append_list_section("需求清单", requirement_lines) external_signal_sources = ( draft.get("external_signal_sources") if isinstance(draft.get("external_signal_sources"), list) else [] ) external_signal_lines: list[str] = [] for item in external_signal_sources[:5]: if not isinstance(item, dict): continue name = str(item.get("name") or "").strip() purpose = str(item.get("purpose") or "").strip() phase = str(item.get("phase") or "").strip() if name and purpose and phase: external_signal_lines.append(f"{name}:{purpose}({phase})") elif name and purpose: external_signal_lines.append(f"{name}:{purpose}") _append_list_section("外部信号源", external_signal_lines) _append_list_section( "待确认信息", draft.get("missing_info") if isinstance(draft.get("missing_info"), list) else [], ) _append_line("可行性", draft.get("feasibility")) _append_line("可行性说明", draft.get("feasibility_reason")) _append_line("推荐负责人", draft.get("recommended_assignee_name")) _append_line("原始用户需求", draft.get("original_text") or fallback_message) return "\n".join(lines).strip() @staticmethod def _is_project_creation_reextract_failure(error: Exception) -> bool: raw = str(error or "").strip() normalized = re.sub(r"\s+", " ", raw).lower() return any( marker in normalized for marker in ( "project_requirement_extraction", "provider_circuit", "provider circuit", "structured_http_error", "模型调用失败", ) ) @staticmethod def _build_structured_project_creation_fallback_draft( *, draft: dict[str, Any], input_text: str, ) -> dict[str, Any]: project_title = str(draft.get("title") or draft.get("project_title") or "").strip() or "未命名项目" summary = str(draft.get("summary") or "").strip() positioning = str(draft.get("positioning_one_liner") or "").strip() signal_content = summary or positioning or str(input_text or "").strip()[:2000] or project_title return { "analysis": { "project_name": project_title, "project_title": project_title, "primary_role": "backend", "requirement_items": ( draft.get("requirement_items") if isinstance(draft.get("requirement_items"), list) else [] ), }, "project": { "name": project_title, "title": project_title, "description": summary or str(input_text or "").strip() or project_title, "assigned_role": "backend", "domain_id": None, }, "signal": { "content": signal_content, "impact_score": 0.8, "priority": "high", "strategic_analysis": {}, }, "charter": { "title": f"{project_title}项目宪章", "content": { "summary": summary, "positioning_one_liner": positioning, "target_customers": str(draft.get("target_customers") or "").strip(), "core_pain_points": ( draft.get("core_pain_points") if isinstance(draft.get("core_pain_points"), list) else [] ), "mvp_items": draft.get("mvp_items") if isinstance(draft.get("mvp_items"), list) else [], "team_setup": draft.get("team_setup") if isinstance(draft.get("team_setup"), list) else [], "validation_metrics": ( draft.get("validation_metrics") if isinstance(draft.get("validation_metrics"), dict) else {} ), "missing_info": ( draft.get("missing_info") if isinstance(draft.get("missing_info"), list) else [] ), }, }, "tasks": draft.get("tasks") if isinstance(draft.get("tasks"), list) else [], "risks": draft.get("risks") if isinstance(draft.get("risks"), list) else [], } @staticmethod def _build_six_module_charter_content_from_rich_draft(draft: dict[str, Any]) -> dict[str, Any]: from app.services.ai_steward_service.service import AIStewardService as RealAIStewardService raw_content = draft.get("content") if isinstance(draft.get("content"), dict) else {} if RealAIStewardService._has_six_module_charter_content(raw_content): return dict(raw_content) seed = dict(draft) if isinstance(raw_content, dict): seed = {**raw_content, **seed} return RealAIStewardService._build_six_module_charter_content_from_brief(seed) @staticmethod def _has_meaningful_task_drafts(tasks: Any) -> bool: if not isinstance(tasks, list) or not tasks: return False for task in tasks: if not isinstance(task, dict): continue if str(task.get("title") or "").strip(): return True return False @staticmethod def _has_meaningful_project_creation_section(value: Any) -> bool: if isinstance(value, str): return bool(value.strip()) if isinstance(value, dict): return any( DraftConfirmHandler._has_meaningful_project_creation_section(item) for item in value.values() ) if isinstance(value, list): return any( DraftConfirmHandler._has_meaningful_project_creation_section(item) for item in value ) return False @classmethod def _is_rich_project_creation_draft(cls, draft: dict[str, Any]) -> bool: if not isinstance(draft, dict): return False title = str(draft.get("title") or draft.get("project_title") or "").strip() summary = str(draft.get("summary") or draft.get("positioning_one_liner") or "").strip() if not title or not summary: return False richness_score = 0 rich_fields = ( draft.get("target_customers"), draft.get("requirement_items"), draft.get("core_pain_points"), draft.get("mvp_items"), draft.get("mvp_phase_1"), draft.get("team_setup"), draft.get("validation_metrics"), ) for field in rich_fields: if cls._has_meaningful_project_creation_section(field): richness_score += 1 return richness_score >= 2 @staticmethod def _merge_validation_metrics_for_task_chain(draft: dict[str, Any]) -> list[str]: metrics = draft.get("validation_metrics") if not isinstance(metrics, dict): return [] flattened: list[str] = [] for section in metrics.values(): if isinstance(section, list): for item in section: text = str(item or "").strip() if text: flattened.append(text) elif isinstance(section, str): text = section.strip() if text: flattened.append(text) return flattened[:8] async def _ensure_structured_project_creation_fallback_tasks( self, *, steward: AIStewardService, fallback_draft: dict[str, Any], ) -> dict[str, Any]: if self._has_meaningful_task_drafts(fallback_draft.get("tasks")): return fallback_draft analysis = fallback_draft.get("analysis") if isinstance(fallback_draft.get("analysis"), dict) else {} if not analysis: analysis = {} fallback_draft["analysis"] = analysis project = fallback_draft.get("project") if isinstance(fallback_draft.get("project"), dict) else {} analysis.setdefault("project_name", str(project.get("name") or project.get("title") or "").strip()) analysis.setdefault("project_title", str(project.get("title") or project.get("name") or "").strip()) analysis.setdefault("primary_role", str(project.get("assigned_role") or "backend").strip() or "backend") if not isinstance(analysis.get("key_features"), list) or not analysis.get("key_features"): analysis["key_features"] = [ str(item).strip() for item in (fallback_draft.get("mvp_items") if isinstance(fallback_draft.get("mvp_items"), list) else []) if str(item).strip() ][:8] if not isinstance(analysis.get("success_metrics"), list) or not analysis.get("success_metrics"): analysis["success_metrics"] = self._merge_validation_metrics_for_task_chain(fallback_draft) generated_tasks = await steward._generate_task_chain_draft( analysis=analysis, tenant_id=int(self.tenant_id), charter_content=( fallback_draft.get("charter", {}).get("content") if isinstance(fallback_draft.get("charter"), dict) else None ), ) fallback_draft["tasks"] = generated_tasks if isinstance(generated_tasks, list) else [] return fallback_draft async def _persist_project_from_structured_draft( self, *, steward: AIStewardService, draft: dict[str, Any], project_input_text: str, creator_id: int, requested_member_user_ids: list[int] | None = None, ) -> dict[str, Any]: fallback_draft = self._build_structured_project_creation_fallback_draft( draft=draft, input_text=project_input_text, ) fallback_draft["charter"]["content"] = self._build_six_module_charter_content_from_rich_draft(draft) fallback_draft = await self._ensure_structured_project_creation_fallback_tasks( steward=steward, fallback_draft=fallback_draft, ) normalized = await steward._normalize_project_creation_draft( draft=fallback_draft, input_text=project_input_text, creator_id=int(creator_id), tenant_id=int(self.tenant_id), ) return await steward._persist_project_creation_draft( normalized=normalized, input_text=project_input_text, creator_id=int(creator_id), tenant_id=int(self.tenant_id), requested_member_user_ids=requested_member_user_ids or None, ) async def _persist_project_from_draft( self, *, work_item: WorkItem, draft: dict[str, Any], source_system: str, source_message_id: str, sender: str, message_text: str, creator_user_id: Optional[int], ) -> dict[str, Any]: _ = work_item, source_system, source_message_id, sender creator_id = _safe_int(creator_user_id) if creator_id is None or creator_id <= 0: raise RuntimeError("草稿缺少创建人信息,无法创建项目。") project_input_text = self._build_project_creation_input_text( draft=draft, fallback_message=message_text, ) if not project_input_text: project_input_text = str(draft.get("original_text") or message_text or "").strip() steward = AIStewardService(self.db) requested_member_user_ids: list[int] = [] assignee_id = _safe_int(draft.get("recommended_assignee_id")) if assignee_id is not None and assignee_id > 0: requested_member_user_ids = [int(assignee_id)] if self._is_rich_project_creation_draft(draft): logger.info( "draft_confirm_project_creation_use_structured_draft_directly", tenant_id=int(self.tenant_id), work_item_id=int(getattr(work_item, "id", 0) or 0), ) payload = await self._persist_project_from_structured_draft( steward=steward, draft=draft, project_input_text=project_input_text or str(message_text or "").strip(), creator_id=int(creator_id), requested_member_user_ids=requested_member_user_ids or None, ) else: try: payload = await steward.create_project_from_natural_language( input_text=project_input_text or str(message_text or "").strip(), creator_id=int(creator_id), tenant_id=int(self.tenant_id), requested_member_user_ids=requested_member_user_ids or None, ) except Exception as exc: if not self._is_project_creation_reextract_failure(exc): raise logger.warning( "draft_confirm_project_creation_reextract_failed_fallback", tenant_id=int(self.tenant_id), work_item_id=int(getattr(work_item, "id", 0) or 0), error=str(exc), ) payload = await self._persist_project_from_structured_draft( steward=steward, draft=draft, project_input_text=project_input_text or str(message_text or "").strip(), creator_id=int(creator_id), requested_member_user_ids=requested_member_user_ids or None, ) if not isinstance(payload, dict): raise RuntimeError("项目创建返回结果无效。") project = payload.get("project") if isinstance(payload.get("project"), dict) else {} signal = payload.get("signal") if isinstance(payload.get("signal"), dict) else {} project_id = _safe_int(project.get("id")) signal_id = _safe_int(signal.get("id")) signal_sources = payload.get("signal_sources") if isinstance(payload.get("signal_sources"), list) else [] return { **payload, "creator_user_id": int(creator_id), "project_id": project_id, "signal_id": signal_id, "signal_source_count": len(signal_sources), "project_input_text": project_input_text or str(message_text or "").strip(), } 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 class _RequirementPlanDraftRuntime(mixin_execute_query_project.ExecuteQueryProjectMixin): def __init__(self, db: Any, *, tenant_id: int) -> None: self.db = db self.tenant_id = int(tenant_id) @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), }, )