From 5724cdc2dcdaf9dea404038445a82db0fdedabf5 Mon Sep 17 00:00:00 2001 From: Yuuuu <1569752928@qq.com> Date: Sat, 11 Apr 2026 20:05:42 +0800 Subject: [PATCH] Fix requirement-plan followup routing in Feishu replies --- .gitignore | 3 + AGENTS.md | 3 + CLAUDE.md | 3 +- .../services/ai_pmo/ai_pmo_intent/__init__.py | 4 + .../services/ai_pmo/ai_pmo_intent/shared.py | 11 + .../ai_pmo_typed_executor_common.py | 3 + .../ai_pmo_kernel_mixins_ingress_render.py | 22 +- .../ai_pmo_kernel_common_core/imports.py | 4 + .../ai_pmo_kernel_ingress_flow/mixin_entry.py | 3 +- .../mixin_execute_query_project.py | 178 +++++--- .../services/ai_pmo/draft_confirm_handler.py | 340 ++++++++++++-- .../ai_pmo/semantic_context/assembler.py | 4 + .../ai_service_core/mixin_business_tasks.py | 7 +- .../mixin_runtime_prompt_retry.py | 2 + .../mixin_creation_normalize_persist.py | 2 + .../mixin_creation_pipeline.py | 11 +- .../mixin_draft_generation.py | 251 +++++++--- .../ai_steward_service/mixin_json_parsing.py | 4 + .../mounted_ai_runtime_write_service.py | 72 ++- .../repair_requirement_plan_history.py | 180 ++++++++ backend/tests/test_ai_pmo_ingress_mainline.py | 382 +++++++++++++++- .../tests/test_ai_pmo_task_intent_contract.py | 21 + ...t_ai_steward_project_from_text_contract.py | 284 ++++++++++-- backend/tests/test_draft_confirm_handler.py | 431 +++++++++++++++++- .../test_mounted_ai_runtime_write_service.py | 52 ++- .../tests/test_semantic_context_assembler.py | 40 ++ docs/architecture/ARCHITECTURE_INVENTORY.md | 1 + docs/architecture/RUNTIME_GUARDRAILS.md | 5 +- 28 files changed, 2102 insertions(+), 221 deletions(-) create mode 100644 backend/scripts/repair_requirement_plan_history.py diff --git a/.gitignore b/.gitignore index dd8296d8..e1ae683f 100644 --- a/.gitignore +++ b/.gitignore @@ -68,3 +68,6 @@ Pixelle-Video/ .env .env.* !.env.example + +# Playwright browser profiles +frontend/.playwright-profiles/ diff --git a/AGENTS.md b/AGENTS.md index dddc2da7..c2fbc316 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -98,12 +98,15 @@ frontend/ # React 前端 - `llm_service` 运行时路由必须显式传入 `routing_meta`,至少统一 `tier_alias / response_contract / fallback_policy / execution_mode`;禁止再通过函数名片段、hint 常量或模板词在底层推断 tier、结构化输出或本地 fallback 策略 - AI Steward 与其它生成链的结果判定必须优先使用结构校验、字段完整性和证据一致性;禁止继续依赖“像不像模板句/指令句/stub 文案”这类启发式判断生成成功或失败 - 显式 `reply/thread` 命中的需求收敛线程,一旦上下文里已经完成 `A/B/C` 选项收敛,后续追问必须优先升格到 `requirement_plan` workspace;禁止继续落回通用 `query.answer`,也禁止把代码路径、内部函数名或工程证据直接暴露给用户 +- requirement-plan 运行时路由必须显式落在 `requirement_plan.collect / requirement_plan.draft`;`query.answer` 仅保留给通用问答,只要已经进入需求收敛/草稿确认主链,ingress render、runtime 写回、审计记录与 topic metadata 都不得再把该轮记成 `query.answer` - requirement-plan 选项协议回复必须覆盖裸 `A/B/C`、`我选C`、`选C`、`就C吧`、`先按C` 等显式选项文本,并优先按上一轮真实选项表解释;若缺少上一轮选项表,只能显式失败或重发该轮,禁止回退到旧的 IT 默认映射误译用户选择 - requirement-plan 选项协议识别属于协议层守门,必须复用单点协议解析器输出结构化 `choice + remaining_text`;禁止在运行时代码里重新散落正则或关键词规则来拍板这类协议回复 - 若要在任意模块新增正则/关键词驱动的协议或业务语义判断,必须先向 Host 明确请示并获得确认;未经确认禁止新增这类规则。协议解析优先使用单点结构化 parser / state machine / 上下文快照,若结构证据不足必须显式失败,禁止再用“最小补丁式”扩词表、补正则或追加前缀枚举搪塞 - 显式 `reply/thread` 下的低信息量协议回复(如 `A/B/C`、`我选C`、`就C吧`)禁止先走 mixed sidecar 预检查;必须优先交给 active-context / requirement-plan 主链处理 - orphan requirement-plan followup 在有显式 `reply/thread` 锚点时,必须先尝试从 reply 命中的 chat context 恢复或升格 requirement workspace;只有恢复失败时才允许回“当前没有进行中的 A/B/C 收敛轮次” - Feishu 显式 `reply/thread` 到机器人消息时,ingress 与 mounted runtime 必须先通过 `feishu_reply_outbox.sent_message_id -> source_message_id` 归一化 `root_message_id/reply_to_message_id`,并在后续 active-context / requirement-plan / draft 主链里复用同一份 canonical thread context;禁止直接用机器人 `sent_message_id` 新开 chat topic 把 requirement-plan / draft 线程分叉出去 +- requirement-plan 运行时必须以 `MountedDraftWorkspace.requirement_card` 作为唯一业务真源;`mounted_topic_workspaces` 只允许保留线程索引与最小摘要(如 `title / plan_profile`),`work_items.execution_result` 与 `mounted_conversation_runs.final_reply` 只做审计/回放,禁止再从 assistant prose、topic facts 或 run 文案反推需求字段 +- `project_create.create / followup.confirm` 的任务拆解属于从属子步骤,不得再阻塞项目、主信号与宪章的确定性创建;运行时必须先走 `low-first` 的结构化任务生成,并在质量不足或 provider 失败时升级 `high`,若仍失败则显式写入 `task_generation=pending` 并保持可重试,不得冒充任务已生成,也不得把整条确认入库打成失败 - 长运行边界、已知运行假设与跨链路守门细节统一维护在 `docs/architecture/RUNTIME_GUARDRAILS.md`;`AGENTS.md` 保留必须立即遵守的摘要规则 ### 8.1 系统定位 diff --git a/CLAUDE.md b/CLAUDE.md index 3bea529f..746edc3d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -48,7 +48,7 @@ backend/ │ ├── config/ # 配置与 prompt │ └── utils/ # 通用工具 ├── alembic/ # 数据库迁移真源 -├── scripts/ # 后端运维/修复脚本 +├── scripts/ # 后端运维/修复脚本(含历史 backfill / requirement-plan 修复) ├── tests/ # 后端回归测试 ├── requirements.txt └── Dockerfile @@ -136,6 +136,7 @@ frontend/ - 正常启动器:`./start.sh` - 公网穿透启动器:`./devops/tunnel/start.sh` - 运行脚本、模型探针、契约校验与 E2E 工具清单已迁到 [`docs/architecture/ARCHITECTURE_INVENTORY.md`](/Users/yuuuu/Documents/AIProjectManger/docs/architecture/ARCHITECTURE_INVENTORY.md)。 +- 当前新增的 requirement-plan 历史修复工具位于 `backend/scripts/repair_requirement_plan_history.py`,默认 dry-run,仅在显式 `--apply` 时写回 `mounted_draft_workspaces / mounted_topic_workspaces`。 ### 前端 ```bash diff --git a/backend/app/services/ai_pmo/ai_pmo_intent/__init__.py b/backend/app/services/ai_pmo/ai_pmo_intent/__init__.py index 3dfa2644..9b1230eb 100644 --- a/backend/app/services/ai_pmo/ai_pmo_intent/__init__.py +++ b/backend/app/services/ai_pmo/ai_pmo_intent/__init__.py @@ -21,7 +21,9 @@ __all__ = [ "ROUTE_FAMILY_PROJECT_SIGNAL", "ROUTE_FAMILY_SIGNAL_SOURCE", "ROUTE_FAMILY_FOLLOWUP", + "ROUTE_FAMILY_REQUIREMENT_PLAN", "ROUTE_ACTION_ANSWER", + "ROUTE_ACTION_COLLECT", "ROUTE_ACTION_CREATE", "ROUTE_ACTION_DISCOVER", "ROUTE_ACTION_COMMIT", @@ -41,6 +43,8 @@ __all__ = [ "ROUTE_SIGNAL_SOURCE_ONBOARD", "ROUTE_FOLLOWUP_CONFIRM", "ROUTE_FOLLOWUP_CONTINUE", + "ROUTE_REQUIREMENT_PLAN_COLLECT", + "ROUTE_REQUIREMENT_PLAN_DRAFT", "WORKFLOW_ROUTE_KEYS", "ROUTE_CANONICAL_INTENT_MAP", "LEGACY_INTENT_ROUTE_MAP", diff --git a/backend/app/services/ai_pmo/ai_pmo_intent/shared.py b/backend/app/services/ai_pmo/ai_pmo_intent/shared.py index 9fc65b94..71852b8a 100644 --- a/backend/app/services/ai_pmo/ai_pmo_intent/shared.py +++ b/backend/app/services/ai_pmo/ai_pmo_intent/shared.py @@ -57,6 +57,7 @@ ROUTE_FAMILY_NOTIFICATION = "notification" ROUTE_FAMILY_PROJECT_SIGNAL = "project_signal" ROUTE_FAMILY_SIGNAL_SOURCE = "signal_source" ROUTE_FAMILY_FOLLOWUP = "followup" +ROUTE_FAMILY_REQUIREMENT_PLAN = "requirement_plan" ROUTE_ACTION_ANSWER = "answer" ROUTE_ACTION_CREATE = "create" @@ -83,6 +84,8 @@ ROUTE_ACTION_REFRESH = "refresh" ROUTE_ACTION_ONBOARD = "onboard" ROUTE_ACTION_CONTINUE = "continue" ROUTE_ACTION_CONFIRM = "confirm" +ROUTE_ACTION_COLLECT = "collect" +ROUTE_ACTION_DRAFT = "draft" ROUTE_QUERY_ANSWER = f"{ROUTE_FAMILY_QUERY}.{ROUTE_ACTION_ANSWER}" ROUTE_PROJECT_CREATE = f"{ROUTE_FAMILY_PROJECT_CREATE}.{ROUTE_ACTION_CREATE}" @@ -114,6 +117,8 @@ ROUTE_PROJECT_SIGNAL_REFRESH = f"{ROUTE_FAMILY_PROJECT_SIGNAL}.{ROUTE_ACTION_REF ROUTE_SIGNAL_SOURCE_ONBOARD = f"{ROUTE_FAMILY_SIGNAL_SOURCE}.{ROUTE_ACTION_ONBOARD}" ROUTE_FOLLOWUP_CONFIRM = f"{ROUTE_FAMILY_FOLLOWUP}.{ROUTE_ACTION_CONFIRM}" ROUTE_FOLLOWUP_CONTINUE = f"{ROUTE_FAMILY_FOLLOWUP}.{ROUTE_ACTION_CONTINUE}" +ROUTE_REQUIREMENT_PLAN_COLLECT = f"{ROUTE_FAMILY_REQUIREMENT_PLAN}.{ROUTE_ACTION_COLLECT}" +ROUTE_REQUIREMENT_PLAN_DRAFT = f"{ROUTE_FAMILY_REQUIREMENT_PLAN}.{ROUTE_ACTION_DRAFT}" WORKFLOW_ROUTE_KEYS = { ROUTE_QUERY_ANSWER, @@ -146,6 +151,8 @@ WORKFLOW_ROUTE_KEYS = { ROUTE_SIGNAL_SOURCE_ONBOARD, ROUTE_FOLLOWUP_CONFIRM, ROUTE_FOLLOWUP_CONTINUE, + ROUTE_REQUIREMENT_PLAN_COLLECT, + ROUTE_REQUIREMENT_PLAN_DRAFT, } ROUTE_CANONICAL_INTENT_MAP: dict[str, str] = { @@ -179,6 +186,8 @@ ROUTE_CANONICAL_INTENT_MAP: dict[str, str] = { ROUTE_SIGNAL_SOURCE_ONBOARD: INTENT_SIGNAL_SOURCE_ONBOARD, ROUTE_FOLLOWUP_CONFIRM: INTENT_FOLLOWUP_CONFIRM, ROUTE_FOLLOWUP_CONTINUE: INTENT_FOLLOWUP_CONTINUE, + ROUTE_REQUIREMENT_PLAN_COLLECT: INTENT_QUERY, + ROUTE_REQUIREMENT_PLAN_DRAFT: INTENT_QUERY, } LEGACY_INTENT_ROUTE_MAP: dict[str, str] = { @@ -217,6 +226,8 @@ LEGACY_INTENT_ROUTE_MAP: dict[str, str] = { "project_signal.cancel": ROUTE_PROJECT_SIGNAL_CANCEL, "project_signal.refresh": ROUTE_PROJECT_SIGNAL_REFRESH, "signal_source.onboard": ROUTE_SIGNAL_SOURCE_ONBOARD, + "requirement_plan.collect": ROUTE_REQUIREMENT_PLAN_COLLECT, + "requirement_plan.draft": ROUTE_REQUIREMENT_PLAN_DRAFT, } INTENT_LLM_FUNCTION_NAME = "pmflow_semantic_context_decision" diff --git a/backend/app/services/ai_pmo/ai_pmo_kernel/ai_pmo_typed_executor_common.py b/backend/app/services/ai_pmo/ai_pmo_kernel/ai_pmo_typed_executor_common.py index 7acf2fb5..c1746d92 100644 --- a/backend/app/services/ai_pmo/ai_pmo_kernel/ai_pmo_typed_executor_common.py +++ b/backend/app/services/ai_pmo/ai_pmo_kernel/ai_pmo_typed_executor_common.py @@ -93,6 +93,9 @@ from ..ai_pmo_intent import ( ROUTE_NOTIFICATION_CREATE, ROUTE_NOTIFICATION_ACK, ROUTE_NOTIFICATION_CLOSE, + ROUTE_QUERY_ANSWER, + ROUTE_REQUIREMENT_PLAN_COLLECT, + ROUTE_REQUIREMENT_PLAN_DRAFT, CanonicalIntent, IntentResolver, RouteResolver, diff --git a/backend/app/services/ai_pmo/ai_pmo_kernel/mixins/ingress/ai_pmo_kernel_mixins_ingress_render.py b/backend/app/services/ai_pmo/ai_pmo_kernel/mixins/ingress/ai_pmo_kernel_mixins_ingress_render.py index f231079e..2e8c7f60 100644 --- a/backend/app/services/ai_pmo/ai_pmo_kernel/mixins/ingress/ai_pmo_kernel_mixins_ingress_render.py +++ b/backend/app/services/ai_pmo/ai_pmo_kernel/mixins/ingress/ai_pmo_kernel_mixins_ingress_render.py @@ -33,9 +33,15 @@ class KernelIngressRenderMixin: diagnostics[key] = value if str(intent.name or "").strip(): diagnostics["intent_name"] = str(intent.name or "").strip() - if str(workflow_route or "").strip(): - diagnostics["workflow_route"] = str(workflow_route or "").strip() - primary_workflow = "query_flow" if str(workflow_route or "").strip().startswith("query.") else "action_flow" + normalized_route = str(workflow_route or "").strip() + if normalized_route: + diagnostics["workflow_route"] = normalized_route + if normalized_route.startswith(f"{ROUTE_FAMILY_REQUIREMENT_PLAN}."): + primary_workflow = "plan_flow" + elif normalized_route.startswith("query."): + primary_workflow = "query_flow" + else: + primary_workflow = "action_flow" return { "bundle_id": None, "semantic_version": "compat-direct-metadata-v1", @@ -59,7 +65,9 @@ class KernelIngressRenderMixin: ) -> dict[str, Any]: linked_project_ids = [int(work_item.project_id)] if work_item.project_id is not None else [] direct_reply_text = str(render.get("text") or "").strip() - workflow_route = str(work_item.intent or execution_result.get("workflow_route") or "").strip() or None + workflow_route = str(execution_result.get("workflow_route") or work_item.intent or "").strip() or None + normalized_route = str(workflow_route or "").strip().lower() + is_requirement_plan_route = normalized_route.startswith(f"{ROUTE_FAMILY_REQUIREMENT_PLAN}.") mixed_sidecar_reply = ( str((intent.metadata or {}).get("mixed_sidecar_reply") or "").strip() if isinstance(intent.metadata, dict) @@ -145,9 +153,9 @@ class KernelIngressRenderMixin: draft_expires_at = payload.get("draft_expires_at") if isinstance(draft_expires_at, str) and draft_expires_at.strip(): result["draft_expires_at"] = draft_expires_at.strip() - if workflow_route == ROUTE_QUERY_ANSWER and isinstance(execution_result.get("rag_result"), dict): + if (normalized_route == ROUTE_QUERY_ANSWER or is_requirement_plan_route) and isinstance(execution_result.get("rag_result"), dict): result["rag_result"] = dict(execution_result["rag_result"]) - if workflow_route == ROUTE_QUERY_ANSWER and isinstance(execution_result.get("requirement_plan_context"), dict): + if (normalized_route == ROUTE_QUERY_ANSWER or is_requirement_plan_route) and isinstance(execution_result.get("requirement_plan_context"), dict): result["requirement_plan_context"] = dict(execution_result["requirement_plan_context"]) if isinstance(execution_result.get("draft_artifact"), dict): result["draft_artifact"] = dict(execution_result["draft_artifact"]) @@ -165,6 +173,8 @@ class KernelIngressRenderMixin: normalized_route = RouteResolver.normalize_route_key(intent_name) if normalized_route in INTENT_CANDIDATES: return normalized_route + if normalized_route in {ROUTE_REQUIREMENT_PLAN_COLLECT, ROUTE_REQUIREMENT_PLAN_DRAFT}: + return normalized_route if normalized_route == ROUTE_QUERY_ANSWER: return "query.answer" if normalized_route == ROUTE_PROJECT_CREATE: diff --git a/backend/app/services/ai_pmo/ai_pmo_kernel_common_core/imports.py b/backend/app/services/ai_pmo/ai_pmo_kernel_common_core/imports.py index f87fe7a6..69161a46 100644 --- a/backend/app/services/ai_pmo/ai_pmo_kernel_common_core/imports.py +++ b/backend/app/services/ai_pmo/ai_pmo_kernel_common_core/imports.py @@ -112,6 +112,7 @@ from ..ai_pmo_intent import ( ROUTE_ACTION_CLOSE, ROUTE_ACTION_COMMIT, ROUTE_ACTION_CONFIRM, + ROUTE_ACTION_COLLECT, ROUTE_ACTION_CONTINUE, ROUTE_ACTION_CREATE, ROUTE_ACTION_DISCOVER, @@ -138,6 +139,7 @@ from ..ai_pmo_intent import ( ROUTE_FAMILY_PROJECT_CREATE, ROUTE_FAMILY_PROJECT_SIGNAL, ROUTE_FAMILY_QUERY, + ROUTE_FAMILY_REQUIREMENT_PLAN, ROUTE_FAMILY_SIGNAL_SOURCE, ROUTE_FAMILY_TASK, ROUTE_FOLLOWUP_CONFIRM, @@ -163,6 +165,8 @@ from ..ai_pmo_intent import ( ROUTE_PROJECT_SIGNAL_DISCOVER, ROUTE_PROJECT_SIGNAL_REFRESH, ROUTE_QUERY_ANSWER, + ROUTE_REQUIREMENT_PLAN_COLLECT, + ROUTE_REQUIREMENT_PLAN_DRAFT, ROUTE_TASK_ASSIGN, ROUTE_TASK_DELETE, ROUTE_TASK_PRIORITIZE, diff --git a/backend/app/services/ai_pmo/ai_pmo_kernel_ingress_flow/mixin_entry.py b/backend/app/services/ai_pmo/ai_pmo_kernel_ingress_flow/mixin_entry.py index 210fe8cd..ae7f703c 100644 --- a/backend/app/services/ai_pmo/ai_pmo_kernel_ingress_flow/mixin_entry.py +++ b/backend/app/services/ai_pmo/ai_pmo_kernel_ingress_flow/mixin_entry.py @@ -1401,6 +1401,7 @@ class KernelIngressFlowEntryMixin: handler = DraftConfirmHandler(self) thread_context = self._extract_thread_context_from_ingress_options(ingress_options) normalized_text = str(message_text or "").strip() + has_reply_anchor = bool(str(thread_context.get("reply_to_message_id") or "").strip()) if self._should_route_to_draft_confirmation( session=session, message_text=message_text, @@ -1409,7 +1410,7 @@ class KernelIngressFlowEntryMixin: return True if not normalized_text: return False - if await self._should_skip_no_anchor_draft_routing_by_bundle( + if not has_reply_anchor and await self._should_skip_no_anchor_draft_routing_by_bundle( message_text=normalized_text, source_system=source_system, ): diff --git a/backend/app/services/ai_pmo/ai_pmo_typed_executor_execute/mixin_execute_query_project.py b/backend/app/services/ai_pmo/ai_pmo_typed_executor_execute/mixin_execute_query_project.py index d0c43cbb..bc71577f 100644 --- a/backend/app/services/ai_pmo/ai_pmo_typed_executor_execute/mixin_execute_query_project.py +++ b/backend/app/services/ai_pmo/ai_pmo_typed_executor_execute/mixin_execute_query_project.py @@ -740,14 +740,7 @@ class ExecuteQueryProjectMixin: *, topic_workspace_id: int, ) -> MountedDraftWorkspace: - draft = ( - await self.db.execute( - select(MountedDraftWorkspace).where( - MountedDraftWorkspace.tenant_id == int(self.tenant_id), - MountedDraftWorkspace.topic_workspace_id == int(topic_workspace_id), - ) - ) - ).scalar_one_or_none() + draft = await self._load_requirement_plan_draft_workspace(topic_workspace_id=int(topic_workspace_id)) if draft is None: draft = MountedDraftWorkspace( tenant_id=int(self.tenant_id), @@ -762,6 +755,20 @@ class ExecuteQueryProjectMixin: await self.db.flush() return draft + async def _load_requirement_plan_draft_workspace( + self, + *, + topic_workspace_id: int, + ) -> Optional[MountedDraftWorkspace]: + return ( + await self.db.execute( + select(MountedDraftWorkspace).where( + MountedDraftWorkspace.tenant_id == int(self.tenant_id), + MountedDraftWorkspace.topic_workspace_id == int(topic_workspace_id), + ) + ) + ).scalar_one_or_none() + async def _load_contextual_reply_turns( self, *, @@ -878,7 +885,6 @@ class ExecuteQueryProjectMixin: card.update(cls._extract_requirement_plan_updates(message_text=user_text, card=card)) if assistant_text: assistant_turns.append(assistant_text) - card.update(cls._extract_requirement_plan_resolved_updates_from_assistant_reply(assistant_text)) card = cls._merge_requirement_plan_round_options_from_assistant_reply(card=card, assistant_text=assistant_text) card = cls._merge_requirement_plan_context_snapshot( card=card, @@ -1183,6 +1189,52 @@ class ExecuteQueryProjectMixin: } return snapshot + @classmethod + def _build_requirement_plan_topic_index_facts( + cls, + *, + card: dict[str, Any], + ) -> dict[str, Any]: + if not isinstance(card, dict): + return {} + facts: dict[str, Any] = {} + title = str(card.get("title") or "").strip() + if title: + facts["title"] = title + plan_profile = str(card.get("plan_profile") or "").strip() + if plan_profile: + facts["plan_profile"] = plan_profile + return facts + + async def _load_contextual_requirement_plan_snapshot( + self, + *, + topic: Optional[MountedTopicWorkspace], + current_message_text: Optional[str] = None, + prefer_draft_snapshot: bool = True, + ) -> tuple[dict[str, Any], list[dict[str, Any]]]: + if topic is None: + return {}, [] + if prefer_draft_snapshot: + contextual_draft = await self._load_requirement_plan_draft_workspace(topic_workspace_id=int(topic.id)) + if contextual_draft is not None and isinstance(contextual_draft.requirement_card, dict): + sanitized_card = self._sanitize_requirement_plan_card_runtime_context( + card=dict(contextual_draft.requirement_card or {}) + ) + draft_snapshot = self._build_requirement_plan_context_snapshot(card=sanitized_card) + if draft_snapshot: + return draft_snapshot, [] + + contextual_turns = await self._load_contextual_reply_turns(topic_workspace_id=int(topic.id)) + if not self._reply_context_contains_requirement_plan_contract( + turns=contextual_turns, + current_message_text=current_message_text, + ): + return {}, contextual_turns + + contextual_card = self._build_contextual_requirement_plan_card(topic=topic, turns=contextual_turns) + return self._build_requirement_plan_context_snapshot(card=contextual_card), contextual_turns + @classmethod def _infer_requirement_plan_round_field_from_assistant_reply( cls, @@ -1217,28 +1269,6 @@ class ExecuteQueryProjectMixin: return "delivery_mode" return None - @classmethod - def _extract_requirement_plan_resolved_updates_from_assistant_reply(cls, assistant_text: str) -> dict[str, str]: - text = str(assistant_text or "").strip() - if not text: - return {} - updates: dict[str, str] = {} - marker = "当前确认走" - marker_index = text.find(marker) - if marker_index >= 0: - suffix = text[marker_index + len(marker) :].lstrip() - if suffix.startswith("「"): - end_index = suffix.find("」") - if end_index > 1: - value = suffix[1:end_index].strip() - if value: - updates["project_nature"] = value - elif "路线" in suffix: - value = suffix.split("路线", 1)[0].strip(" ::,,。.!!??") - if value: - updates["project_nature"] = value - return updates - @classmethod def _parse_requirement_plan_option_line(cls, line: str) -> Optional[tuple[str, str]]: raw = str(line or "").strip() @@ -1392,15 +1422,7 @@ class ExecuteQueryProjectMixin: ) missing_fields = self._requirement_plan_missing_fields(draft_card) - topic.facts_json = { - "plan_profile": draft_card.get("plan_profile"), - "title": draft_card.get("title"), - "source_material": draft_card.get("source_material"), - "target_user": draft_card.get("target_user"), - "scoring_basis": draft_card.get("scoring_basis"), - "output_form": draft_card.get("output_form"), - "cadence": draft_card.get("cadence"), - } + topic.facts_json = self._build_requirement_plan_topic_index_facts(card=draft_card) topic.goals_json = [draft_card.get("goal")] if str(draft_card.get("goal") or "").strip() else [] topic.constraints_json = {"first_scope": draft_card.get("first_scope")} topic.last_message_id = str(source_message_id or "").strip() or topic.last_message_id @@ -1461,15 +1483,11 @@ class ExecuteQueryProjectMixin: ) return topic, draft - contextual_turns = await self._load_contextual_reply_turns(topic_workspace_id=int(context_topic.id)) - if not self._reply_context_contains_requirement_plan_contract( - turns=contextual_turns, + contextual_snapshot, contextual_turns = await self._load_contextual_requirement_plan_snapshot( + topic=context_topic, current_message_text=message_text, - ): - return topic, draft - - contextual_card = self._build_contextual_requirement_plan_card(topic=context_topic, turns=contextual_turns) - contextual_snapshot = self._build_requirement_plan_context_snapshot(card=contextual_card) + prefer_draft_snapshot=int(getattr(context_topic, "id", 0) or 0) != int(getattr(topic, "id", 0) or 0), + ) if not contextual_snapshot: return topic, draft @@ -1530,8 +1548,13 @@ class ExecuteQueryProjectMixin: "project_id": getattr(intent, "project_id", None), } - contextual_turns = await self._load_contextual_reply_turns(topic_workspace_id=int(context_topic.id)) - card = self._build_contextual_requirement_plan_card(topic=context_topic, turns=contextual_turns) + contextual_snapshot, contextual_turns = await self._load_contextual_requirement_plan_snapshot( + topic=context_topic, + current_message_text=message_text, + ) + card = self._sanitize_requirement_plan_card_runtime_context( + card=dict(contextual_snapshot) if isinstance(contextual_snapshot, dict) else {} + ) if not str(card.get("title") or "").strip() or not str(card.get("source_material") or "").strip(): return { "status": "failed_safe", @@ -1583,15 +1606,7 @@ class ExecuteQueryProjectMixin: } missing_fields = self._requirement_plan_missing_fields(draft_card) - topic.facts_json = { - "plan_profile": draft_card.get("plan_profile"), - "title": draft_card.get("title"), - "source_material": draft_card.get("source_material"), - "target_user": draft_card.get("target_user"), - "scoring_basis": draft_card.get("scoring_basis"), - "output_form": draft_card.get("output_form"), - "cadence": draft_card.get("cadence"), - } + topic.facts_json = self._build_requirement_plan_topic_index_facts(card=draft_card) topic.goals_json = [draft_card.get("goal")] if str(draft_card.get("goal") or "").strip() else [] topic.constraints_json = {"first_scope": draft_card.get("first_scope")} topic.last_message_id = str(source_message_id or "").strip() or topic.last_message_id @@ -1624,6 +1639,7 @@ class ExecuteQueryProjectMixin: scene_label="Mounted Runtime Draft", reason_code="contextual_requirement_draft_ready", query_type="draft", + workflow_route=ROUTE_REQUIREMENT_PLAN_DRAFT, ) result["reply_template_key"] = "draft_pending_confirm" result["draft_version"] = int(draft.draft_version or 0) @@ -2613,7 +2629,7 @@ class ExecuteQueryProjectMixin: if not message_tokens: return 0.0 context_fragments: list[str] = [] - if topic is not None: + if topic is not None and draft is None: context_fragments.extend(cls._collect_requirement_topic_text_fragments(getattr(topic, "facts_json", None))) context_fragments.extend(cls._collect_requirement_topic_text_fragments(getattr(topic, "goals_json", None))) context_fragments.extend( @@ -3892,6 +3908,7 @@ class ExecuteQueryProjectMixin: "project_id": getattr(intent, "project_id", None), } if isinstance(direct_result, dict): + direct_result = self._ensure_requirement_plan_workflow_route(direct_result) direct_result["project_id"] = getattr(intent, "project_id", None) return direct_result @@ -3947,6 +3964,7 @@ class ExecuteQueryProjectMixin: scene_label="Mounted Runtime Plan", reason_code="requirement_planning_direct_fallback", query_type="plan", + workflow_route=ROUTE_REQUIREMENT_PLAN_COLLECT, ) requirement_plan_context = self._build_requirement_plan_context_snapshot(card=card) if requirement_plan_context: @@ -4013,6 +4031,7 @@ class ExecuteQueryProjectMixin: scene_label="Mounted Runtime Plan", reason_code="requirement_planning_new_topic", query_type="plan", + workflow_route=ROUTE_REQUIREMENT_PLAN_COLLECT, ) if self._is_low_information_choice_reply_without_option_map(message_text=message_text, card=card): @@ -4024,6 +4043,7 @@ class ExecuteQueryProjectMixin: scene_label="Mounted Runtime Plan", reason_code="requirement_planning_choice_context_missing", query_type="plan", + workflow_route=ROUTE_REQUIREMENT_PLAN_COLLECT, ) updates = self._extract_requirement_plan_updates(message_text=message_text, card=card) @@ -4037,15 +4057,7 @@ class ExecuteQueryProjectMixin: readiness_denominator = 5 readiness = round((readiness_denominator - len(missing_fields)) / readiness_denominator, 2) - topic.facts_json = { - "plan_profile": profile, - "title": card.get("title"), - "source_material": card.get("source_material"), - "target_user": card.get("target_user"), - "scoring_basis": card.get("scoring_basis"), - "output_form": card.get("output_form"), - "cadence": card.get("cadence"), - } + topic.facts_json = self._build_requirement_plan_topic_index_facts(card=card) topic.goals_json = [card.get("goal")] if str(card.get("goal") or "").strip() else [] topic.constraints_json = {"first_scope": card.get("first_scope")} topic.last_message_id = str(source_message_id or "").strip() or topic.last_message_id @@ -4102,6 +4114,7 @@ class ExecuteQueryProjectMixin: scene_label="Mounted Runtime Draft", reason_code="requirement_planning_draft_ready", query_type="draft", + workflow_route=ROUTE_REQUIREMENT_PLAN_DRAFT, ) requirement_plan_context = self._build_requirement_plan_context_snapshot(card=card) if requirement_plan_context: @@ -4155,6 +4168,7 @@ class ExecuteQueryProjectMixin: scene_label="Mounted Runtime Plan", reason_code="requirement_planning_direct", query_type="plan", + workflow_route=ROUTE_REQUIREMENT_PLAN_COLLECT, ) requirement_plan_context = self._build_requirement_plan_context_snapshot(card=card) if requirement_plan_context: @@ -4448,11 +4462,19 @@ class ExecuteQueryProjectMixin: } @staticmethod - def _build_direct_query_result(*, answer: str, scene_label: str, reason_code: str, query_type: str) -> dict[str, Any]: + def _build_direct_query_result( + *, + answer: str, + scene_label: str, + reason_code: str, + query_type: str, + workflow_route: str = ROUTE_QUERY_ANSWER, + ) -> dict[str, Any]: text = str(answer or "").strip() return { "status": "succeeded", "answer": text, + "workflow_route": str(workflow_route or ROUTE_QUERY_ANSWER).strip() or ROUTE_QUERY_ANSWER, "rag_result": { "answer": text, "sources": [ @@ -4472,6 +4494,21 @@ class ExecuteQueryProjectMixin: }, } + @staticmethod + def _ensure_requirement_plan_workflow_route(result: Optional[dict[str, Any]]) -> Optional[dict[str, Any]]: + if not isinstance(result, dict): + return result + if str(result.get("workflow_route") or "").strip(): + return result + direct_query_type = str((result.get("rag_result") or {}).get("query_type") or "").strip().lower() + reply_template_key = str(result.get("reply_template_key") or "").strip().lower() + if direct_query_type == "draft" or reply_template_key == "draft_pending_confirm": + result["workflow_route"] = ROUTE_REQUIREMENT_PLAN_DRAFT + return result + if direct_query_type == "plan" or reply_template_key == "requirement_plan_followup": + result["workflow_route"] = ROUTE_REQUIREMENT_PLAN_COLLECT + return result + async def _execute_query_intent( self, *, @@ -4848,6 +4885,7 @@ class ExecuteQueryProjectMixin: conversation_key=conversation_key, topic_switch=topic_switch, ) + direct_result = self._ensure_requirement_plan_workflow_route(direct_result) direct_result["project_id"] = intent.project_id return direct_result if self._should_use_assignee_recommendation_answer( diff --git a/backend/app/services/ai_pmo/draft_confirm_handler.py b/backend/app/services/ai_pmo/draft_confirm_handler.py index 66eb1026..f95e71ee 100644 --- a/backend/app/services/ai_pmo/draft_confirm_handler.py +++ b/backend/app/services/ai_pmo/draft_confirm_handler.py @@ -633,6 +633,7 @@ class DraftConfirmHandler: signal_id=_safe_int(locked.signal_id), project_id=_safe_int(locked.project_id), signal_source_count=signal_source_count, + task_generation=project_payload.get("task_generation") if isinstance(project_payload, dict) else None, ) locked.execution_result = { **existing_execution, @@ -643,6 +644,7 @@ class DraftConfirmHandler: "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 [], + "task_generation": project_payload.get("task_generation") if isinstance(project_payload, dict) else None, "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, @@ -1037,7 +1039,6 @@ class DraftConfirmHandler: 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) @@ -1123,9 +1124,228 @@ class DraftConfirmHandler: candidates.append((work_item, ingest)) if not candidates: + if active_mounted is not None: + return await self._materialize_retryable_pending_draft_from_mounted( + mounted=active_mounted, + sender=sender, + source_system=source_system, + channel_ref=channel_ref, + source_sender_open_id=source_sender_open_id, + thread_context=thread_context, + ) return None + if active_mounted is not None: + mounted_draft, _ = active_mounted + preferred_source_message_id = str( + getattr(mounted_draft, "latest_source_message_id", "") or "" + ).strip() + preferred_reply_message_id = str( + getattr(mounted_draft, "latest_reply_message_id", "") or "" + ).strip() + if preferred_source_message_id: + for candidate in candidates: + candidate_work_item, _ = candidate + candidate_source_message_id = str( + getattr(candidate_work_item, "source_message_id", "") or "" + ).strip() + if candidate_source_message_id == preferred_source_message_id: + return candidate + elif preferred_reply_message_id: + for candidate in candidates: + candidate_work_item, _ = candidate + candidate_source_message_id = str( + getattr(candidate_work_item, "source_message_id", "") or "" + ).strip() + candidate_reply_message_id = str( + sent_message_id_map.get(candidate_source_message_id) or "" + ).strip() + if candidate_reply_message_id == preferred_reply_message_id: + return candidate + for candidate in candidates: + candidate_work_item, _ = candidate + if self._candidate_matches_mounted_draft( + work_item=candidate_work_item, + mounted_draft=mounted_draft, + ): + return candidate + return await self._materialize_retryable_pending_draft_from_mounted( + mounted=active_mounted, + sender=sender, + source_system=source_system, + channel_ref=channel_ref, + source_sender_open_id=source_sender_open_id, + thread_context=thread_context, + ) return candidates[0] + @staticmethod + def _extract_draft_title(payload: Any) -> str: + if not isinstance(payload, dict): + return "" + return str(payload.get("title") or "").strip() + + @classmethod + def _candidate_matches_mounted_draft( + cls, + *, + work_item: WorkItem, + mounted_draft: MountedDraftWorkspace, + ) -> bool: + candidate_payload = work_item.draft_content if isinstance(getattr(work_item, "draft_content", None), dict) else {} + mounted_payload = ( + mounted_draft.draft_payload + if isinstance(getattr(mounted_draft, "draft_payload", None), dict) + else {} + ) + mounted_card = ( + mounted_draft.requirement_card + if isinstance(getattr(mounted_draft, "requirement_card", None), dict) + else {} + ) + candidate_title = cls._extract_draft_title(candidate_payload) + mounted_title = cls._extract_draft_title(mounted_payload) or cls._extract_draft_title(mounted_card) + if candidate_title and mounted_title and candidate_title == mounted_title: + return True + return False + + async def _materialize_retryable_pending_draft_from_mounted( + self, + *, + mounted: tuple[MountedDraftWorkspace, MountedTopicWorkspace], + sender: str, + source_system: str, + channel_ref: Optional[str], + source_sender_open_id: Optional[str], + thread_context: Optional[dict[str, Any]], + ) -> Optional[tuple[WorkItem, IngestEvent]]: + if not hasattr(self.db, "add") or not hasattr(self.db, "flush"): + return None + mounted_draft, mounted_topic = mounted + draft_payload = ( + mounted_draft.draft_payload + if isinstance(getattr(mounted_draft, "draft_payload", None), dict) + else {} + ) + if not draft_payload: + return None + + normalized_source_system = str(source_system or "").strip() or "unknown" + owner_user_id = await self._resolve_retryable_draft_owner_user_id( + mounted_draft=mounted_draft, + mounted_topic=mounted_topic, + ) + retry_source_message_id = ( + f"draft-retry-{int(getattr(mounted_draft, 'id', 0) or 0)}-{int(datetime.now().timestamp() * 1000)}" + )[:128] + normalized_thread_context = self._normalize_thread_context(thread_context) + ingest_payload = { + "message_text": str(draft_payload.get("draft_text") or draft_payload.get("title") or "mounted draft retry"), + "sender": str(sender or "").strip() or None, + "channel_ref": str(channel_ref or getattr(mounted_topic, "channel_ref", "") or "").strip() or None, + "source_sender_open_id": str(source_sender_open_id or "").strip() or None, + "owner_user_id": owner_user_id, + "reply_to_message_id": str(normalized_thread_context.get("reply_to_message_id") or "").strip() or None, + "root_message_id": str( + normalized_thread_context.get("root_message_id") + or getattr(mounted_topic, "root_message_id", "") + or "" + ).strip() + or None, + "mounted_draft_id": _safe_int(getattr(mounted_draft, "id", None)), + "mounted_topic_workspace_id": _safe_int(getattr(mounted_topic, "id", None)), + "resumed_from": "draft_pending_confirm", + } + ingest_event = IngestEvent( + tenant_id=int(self.tenant_id), + channel=f"{normalized_source_system}_message", + event_key=retry_source_message_id, + status="processing", + payload=ingest_payload, + retry_count=0, + error_message=None, + processed_at=None, + ) + self.db.add(ingest_event) + await self.db.flush() + + work_item = WorkItem( + tenant_id=int(self.tenant_id), + ingest_event_id=int(ingest_event.id), + source_system=normalized_source_system, + source_message_id=retry_source_message_id, + state=STATE_DRAFT_PENDING_CONFIRM, + intent=ROUTE_PROJECT_CREATE, + owner_user_id=owner_user_id, + dedupe_key=f"{normalized_source_system}:{retry_source_message_id}", + ) + work_item.draft_content = dict(draft_payload) + work_item.draft_version = int(getattr(mounted_draft, "draft_version", 0) or 0) + work_item.reply_template_key = "draft_pending_confirm" + work_item.execution_result = { + "status": "draft_pending_confirm", + "workflow_route": ROUTE_PROJECT_CREATE, + "workflow_action": ROUTE_ACTION_CREATE, + "draft": dict(draft_payload), + "draft_version": int(getattr(mounted_draft, "draft_version", 0) or 0), + "resumed_from": "mounted_draft_retry", + "topic_workspace_id": _safe_int(getattr(mounted_topic, "id", None)), + "mounted_draft_id": _safe_int(getattr(mounted_draft, "id", None)), + } + self.db.add(work_item) + await self.db.flush() + + mounted_draft.active_work_item_id = int(work_item.id) + mounted_draft.confirm_message_id = None + mounted_draft.state = STATE_DRAFT_PENDING_CONFIRM + mounted_topic.status = "active" + mounted_topic.plan_status = STATE_DRAFT_PENDING_CONFIRM + mounted_topic.mode = "plan" + mounted_topic.metadata_json = { + **(mounted_topic.metadata_json if isinstance(mounted_topic.metadata_json, dict) else {}), + "active_draft_work_item_id": int(work_item.id), + "draft_lifecycle_state": STATE_DRAFT_PENDING_CONFIRM, + } + await self.db.flush() + return work_item, ingest_event + + async def _resolve_retryable_draft_owner_user_id( + self, + *, + mounted_draft: MountedDraftWorkspace, + mounted_topic: MountedTopicWorkspace, + ) -> Optional[int]: + if not hasattr(self.db, "execute"): + return None + candidate_source_message_ids = [ + str(getattr(mounted_draft, "latest_source_message_id", "") or "").strip(), + str(getattr(mounted_topic, "last_message_id", "") or "").strip(), + str(getattr(mounted_topic, "root_message_id", "") or "").strip(), + ] + seen: set[str] = set() + normalized_candidates = [] + for value in candidate_source_message_ids: + if not value or value in seen: + continue + seen.add(value) + normalized_candidates.append(value) + if not normalized_candidates: + return None + row = ( + await self.db.execute( + select(WorkItem.owner_user_id) + .where( + and_( + WorkItem.tenant_id == int(self.tenant_id), + WorkItem.source_message_id.in_(normalized_candidates), + WorkItem.owner_user_id.is_not(None), + ) + ) + .order_by(WorkItem.id.desc()) + .limit(1) + ) + ).scalar_one_or_none() + return _safe_int(row) + async def has_active_draft_session( self, *, @@ -1199,10 +1419,17 @@ class DraftConfirmHandler: 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 = [] + retryable_failure = normalized_final_state == STATE_FAILED and bool(getattr(draft_row, "draft_payload", None)) + if retryable_failure: + draft_row.state = STATE_DRAFT_PENDING_CONFIRM + topic_row.status = "active" + topic_row.plan_status = STATE_DRAFT_PENDING_CONFIRM + topic_row.pending_actions = ["confirm_requirement_draft"] + else: + 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, @@ -1260,25 +1487,24 @@ class DraftConfirmHandler: 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 + 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 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 @@ -1411,6 +1637,7 @@ class DraftConfirmHandler: signal_id: Optional[int], project_id: Optional[int] = None, signal_source_count: int = 0, + task_generation: Optional[dict[str, Any]] = None, ) -> str: title = str(draft.get("title") or "需求").strip() or "需求" assignee_name = str(draft.get("recommended_assignee_name") or "待指定").strip() or "待指定" @@ -1428,6 +1655,13 @@ class DraftConfirmHandler: lines.append(f"信号 #{int(signal_id)} 已创建") else: lines.append("已完成入库并开始执行") + task_generation_status = ( + str((task_generation or {}).get("status") or "").strip().lower() + if isinstance(task_generation, dict) + else "" + ) + if task_generation_status == "pending": + lines.append("任务拆解:待补全(可稍后重试)") lines.append(f"负责人:{assignee_name}") return "\n".join(lines) @@ -1752,6 +1986,10 @@ class DraftConfirmHandler: fallback_draft: dict[str, Any], ) -> dict[str, Any]: if self._has_meaningful_task_drafts(fallback_draft.get("tasks")): + fallback_draft["task_generation"] = { + "status": "ready", + "error_message": None, + } return fallback_draft analysis = fallback_draft.get("analysis") if isinstance(fallback_draft.get("analysis"), dict) else {} @@ -1773,16 +2011,52 @@ class DraftConfirmHandler: 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 - ), - ) + generate_task_chain_bundle = getattr(steward, "_generate_task_chain_bundle", None) + if callable(generate_task_chain_bundle): + task_bundle = await generate_task_chain_bundle( + 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"] = task_bundle.get("tasks") if isinstance(task_bundle.get("tasks"), list) else [] + fallback_draft["task_generation"] = ( + task_bundle.get("task_generation") + if isinstance(task_bundle.get("task_generation"), dict) + else steward._task_generation_ready_state() + ) + return fallback_draft + + try: + 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 + ), + ) + except Exception as exc: + logger.warning( + "draft_confirm_structured_task_chain_generation_deferred", + tenant_id=int(self.tenant_id), + error=str(exc), + ) + fallback_draft["tasks"] = [] + fallback_draft["task_generation"] = { + "status": "pending", + "error_message": str(exc)[:240], + } + return fallback_draft fallback_draft["tasks"] = generated_tasks if isinstance(generated_tasks, list) else [] + fallback_draft["task_generation"] = { + "status": "ready", + "error_message": None, + } return fallback_draft async def _persist_project_from_structured_draft( diff --git a/backend/app/services/ai_pmo/semantic_context/assembler.py b/backend/app/services/ai_pmo/semantic_context/assembler.py index 6f8f0db8..9eac37e4 100644 --- a/backend/app/services/ai_pmo/semantic_context/assembler.py +++ b/backend/app/services/ai_pmo/semantic_context/assembler.py @@ -194,11 +194,15 @@ class SemanticContextAssembler: ) metadata = topic_row.metadata_json if isinstance(topic_row.metadata_json, dict) else {} + draft_title = None + if draft is not None and isinstance(draft.requirement_card, dict): + draft_title = str(draft.requirement_card.get("title") or "").strip() or None thread = SemanticContextThread( thread_key=topic_row.thread_key, conversation_scope_key=topic_row.conversation_scope_key, thread_summary=str( metadata.get("last_message_preview") + or draft_title or (topic_row.facts_json or {}).get("title") or "" ).strip() diff --git a/backend/app/services/ai_service_core/mixin_business_tasks.py b/backend/app/services/ai_service_core/mixin_business_tasks.py index f1daa9b6..d0fb939a 100644 --- a/backend/app/services/ai_service_core/mixin_business_tasks.py +++ b/backend/app/services/ai_service_core/mixin_business_tasks.py @@ -107,6 +107,7 @@ class AIServiceBusinessTasksMixin: project_id: Union[str, int], tenant_id: Union[str, int] = "default", db=None, + routing_meta: Optional[dict[str, Any]] = None, ) -> dict: """从宪章拆解任务,自动识别所需技能标签,返回带技能要求的任务列表""" from ...config.ai_prompts import PROMPT_DECOMPOSE_TASKS @@ -123,7 +124,11 @@ class AIServiceBusinessTasksMixin: available_skills=available_skills, ) text = await self._invoke_with_retry( - prompt, db=db, tenant_id=tid, function_name="decompose_tasks_from_charter", + prompt, + db=db, + tenant_id=tid, + function_name="decompose_tasks_from_charter", + routing_meta=routing_meta, ) return self._parse_json(text) diff --git a/backend/app/services/ai_service_core/mixin_runtime_prompt_retry.py b/backend/app/services/ai_service_core/mixin_runtime_prompt_retry.py index 8e66324c..aede9cf4 100644 --- a/backend/app/services/ai_service_core/mixin_runtime_prompt_retry.py +++ b/backend/app/services/ai_service_core/mixin_runtime_prompt_retry.py @@ -124,6 +124,7 @@ class AIServiceRuntimePromptRetryMixin: use_cache: bool = True, cache_key: Optional[str] = None, project_id: Optional[int] = None, + routing_meta: Optional[dict[str, Any]] = None, ) -> str: """带缓存、重试、预算治理的 LLM 调用(统一走 llm_service)。""" from ..llm_service import llm_service @@ -169,6 +170,7 @@ class AIServiceRuntimePromptRetryMixin: tenant_id=_tenant_to_int(tenant_id), function_name=function_name, use_prompt_profile=True, + routing_meta=routing_meta, ) text = str(text or "") if not text.strip(): diff --git a/backend/app/services/ai_steward_service/mixin_creation_normalize_persist.py b/backend/app/services/ai_steward_service/mixin_creation_normalize_persist.py index 829e313a..8893cd60 100644 --- a/backend/app/services/ai_steward_service/mixin_creation_normalize_persist.py +++ b/backend/app/services/ai_steward_service/mixin_creation_normalize_persist.py @@ -265,6 +265,7 @@ class AIStewardCreationPersistMixin: }, "tasks": normalized_task_drafts, "task_audit": task_audit, + "task_generation": draft.get("task_generation") if isinstance(draft.get("task_generation"), dict) else {}, "risks": normalized_risks, "creator_id": int(creator_id), } @@ -439,6 +440,7 @@ class AIStewardCreationPersistMixin: "content": charter.content, }, "tasks": persisted_tasks, + "task_generation": normalized.get("task_generation") if isinstance(normalized.get("task_generation"), dict) else {}, "risks": risks_payload, "signal_sources": signal_sources, "analysis": normalized["analysis"], diff --git a/backend/app/services/ai_steward_service/mixin_creation_pipeline.py b/backend/app/services/ai_steward_service/mixin_creation_pipeline.py index dfa04805..ab292478 100644 --- a/backend/app/services/ai_steward_service/mixin_creation_pipeline.py +++ b/backend/app/services/ai_steward_service/mixin_creation_pipeline.py @@ -75,7 +75,7 @@ class AIStewardCreationPipelineMixin: if PROJECT_CREATION_SERIAL_DRAFT_STEPS: signal_draft = await self._generate_competitive_signal_draft(analysis=analysis, tenant_id=tenant_id) charter_draft = await self._generate_charter_draft(analysis=analysis, tenant_id=tenant_id) - task_drafts = await self._generate_task_chain_draft( + task_bundle = await self._generate_task_chain_bundle( analysis=analysis, tenant_id=tenant_id, charter_content=charter_draft.get("content"), @@ -85,7 +85,7 @@ class AIStewardCreationPipelineMixin: self._generate_competitive_signal_draft(analysis=analysis, tenant_id=tenant_id), self._generate_charter_draft(analysis=analysis, tenant_id=tenant_id), ) - task_drafts = await self._generate_task_chain_draft( + task_bundle = await self._generate_task_chain_bundle( analysis=analysis, tenant_id=tenant_id, charter_content=charter_draft.get("content"), @@ -101,6 +101,11 @@ class AIStewardCreationPipelineMixin: }, "signal": signal_draft, "charter": charter_draft, - "tasks": task_drafts, + "tasks": task_bundle.get("tasks") if isinstance(task_bundle.get("tasks"), list) else [], + "task_generation": ( + task_bundle.get("task_generation") + if isinstance(task_bundle.get("task_generation"), dict) + else self._task_generation_ready_state() + ), "risks": self._build_risk_alert_drafts(analysis=analysis), } diff --git a/backend/app/services/ai_steward_service/mixin_draft_generation.py b/backend/app/services/ai_steward_service/mixin_draft_generation.py index 3dc68e8e..1afeeb48 100644 --- a/backend/app/services/ai_steward_service/mixin_draft_generation.py +++ b/backend/app/services/ai_steward_service/mixin_draft_generation.py @@ -5,6 +5,31 @@ from ...config.ai_prompts import PROMPT_PROJECT_REQUIREMENT_EXTRACTION class AIStewardDraftGenerationMixin: + @staticmethod + def _task_chain_generation_routing_meta(*, tier_alias: str = "low") -> dict[str, Any]: + tier = "high" if str(tier_alias or "").strip().lower() == "high" else "low" + return { + "tier_alias": tier, + "planned_tier": tier, + "routing_decision": "force_low_first" if tier == "low" else "explicit_high_retry", + "response_contract": "structured", + "execution_mode": "local_planner", + } + + @staticmethod + def _task_generation_ready_state() -> dict[str, Any]: + return { + "status": "ready", + "error_message": None, + } + + @staticmethod + def _task_generation_pending_state(error: Exception | str) -> dict[str, Any]: + return { + "status": "pending", + "error_message": str(error or "").strip()[:240] or "task_generation_pending", + } + @staticmethod def _normalize_generation_text_list(values: Any, *, max_items: int = 8, max_len: int = 120) -> list[str]: if not isinstance(values, list): @@ -604,6 +629,8 @@ class AIStewardDraftGenerationMixin: analysis: Dict[str, Any], tenant_id: int, charter_content: Any = None, + routing_meta: dict[str, Any] | None = None, + available_skill_names: list[str] | None = None, ) -> list[dict[str, Any]]: from ..ai_service_core.service import AIService @@ -617,16 +644,24 @@ class AIStewardDraftGenerationMixin: project_id=0, tenant_id=int(tenant_id), db=self.db, + routing_meta=routing_meta or self._task_chain_generation_routing_meta(), ) tasks = self._normalize_decomposed_task_rows(payload.get("tasks")) - if tasks: + if not tasks: + raise RuntimeError("AI 管家任务链生成失败:charter decomposition returned empty tasks") + report = await self._inspect_task_chain_candidate(tasks=tasks, tenant_id=int(tenant_id)) + requires_skill_gate = bool(available_skill_names) + if not requires_skill_gate: return tasks - raise RuntimeError("AI 管家任务链生成失败:charter decomposition returned empty tasks") + if not report["invalid_tasks"] and int(report.get("mappable_task_count") or 0) > 0: + return tasks + raise RuntimeError("AI 管家任务链生成失败:charter decomposition returned low-quality tasks") except Exception as exc: logger.warning( "ai_steward_charter_task_chain_decomposition_failed", tenant_id=int(tenant_id), project_title=str(analysis.get("project_title") or analysis.get("project_name") or "").strip()[:120], + tier_alias=str((routing_meta or {}).get("tier_alias") or "").strip() or "default", error=str(exc), ) raise @@ -699,6 +734,103 @@ class AIStewardDraftGenerationMixin: int(report.get("total_task_count") or 0), ) + @staticmethod + def _task_chain_candidate_is_acceptable( + report: dict[str, Any], + *, + available_skill_names: list[str], + ) -> bool: + if not available_skill_names: + return True + return not report.get("invalid_tasks") and int(report.get("mappable_task_count") or 0) > 0 + + async def _generate_task_chain_from_primary_prompt( + self, + *, + analysis: Dict[str, Any], + tenant_id: int, + available_skill_names: list[str], + routing_meta: dict[str, Any], + ) -> list[dict[str, Any]]: + prompt = self._build_task_chain_prompt( + analysis=analysis, + available_skill_names=available_skill_names, + ) + tasks_data = await self._generate_project_creation_object( + prompt=prompt, + tenant_id=tenant_id, + function_name="project_from_text_task_chain", + stage_label="AI 管家任务链生成失败", + timeout_seconds=PROJECT_CREATION_TASK_CHAIN_TIMEOUT_SECONDS, + routing_meta=routing_meta, + ) + tasks_list = tasks_data.get("tasks", []) + if not isinstance(tasks_list, list) or not tasks_list: + raise RuntimeError("AI 管家任务链生成失败:返回 tasks 为空") + + report = await self._inspect_task_chain_candidate(tasks=tasks_list, tenant_id=int(tenant_id)) + best_tasks = tasks_list + best_report = report + if self._task_chain_candidate_is_acceptable(report, available_skill_names=available_skill_names): + return best_tasks + + retry_feedback = self._build_task_chain_retry_feedback( + invalid_tasks=report["invalid_tasks"], + available_skill_names=available_skill_names, + ) + retry_prompt = self._build_task_chain_prompt( + analysis=analysis, + available_skill_names=available_skill_names, + retry_feedback=retry_feedback, + ) + retry_data = await self._generate_project_creation_object( + prompt=retry_prompt, + tenant_id=tenant_id, + function_name="project_from_text_task_chain", + stage_label="AI 管家任务链生成失败", + timeout_seconds=PROJECT_CREATION_TASK_CHAIN_TIMEOUT_SECONDS, + routing_meta=routing_meta, + ) + retry_tasks = retry_data.get("tasks", []) + if isinstance(retry_tasks, list) and retry_tasks: + retry_report = await self._inspect_task_chain_candidate(tasks=retry_tasks, tenant_id=int(tenant_id)) + if self._task_chain_candidate_score(retry_report) >= self._task_chain_candidate_score(best_report): + best_tasks = retry_tasks + best_report = retry_report + if self._task_chain_candidate_is_acceptable(retry_report, available_skill_names=available_skill_names): + return retry_tasks + + raise RuntimeError("AI 管家任务链生成失败:prompt route returned low-quality tasks") + + async def _generate_task_chain_bundle( + self, + *, + analysis: Dict[str, Any], + tenant_id: int, + charter_content: Any = None, + ) -> dict[str, Any]: + try: + tasks = await self._generate_task_chain_draft( + analysis=analysis, + tenant_id=int(tenant_id), + charter_content=charter_content, + ) + except Exception as exc: + logger.warning( + "ai_steward_task_chain_generation_deferred", + tenant_id=int(tenant_id), + project_title=str(analysis.get("project_title") or analysis.get("project_name") or "").strip()[:120], + error=str(exc), + ) + return { + "tasks": [], + "task_generation": self._task_generation_pending_state(exc), + } + return { + "tasks": tasks if isinstance(tasks, list) else [], + "task_generation": self._task_generation_ready_state(), + } + async def _analyze_project_input(self, input_text: str, tenant_id: int) -> Dict[str, Any]: """使用 AI 分析项目输入,提取关键信息。""" prompt = PROMPT_PROJECT_REQUIREMENT_EXTRACTION.format( @@ -828,67 +960,72 @@ class AIStewardDraftGenerationMixin: tenant_id: int, charter_content: Any = None, ) -> List[Dict[str, Any]]: - if self._has_six_module_charter_content(charter_content): - return await self._generate_task_chain_via_charter_decomposition( - analysis=analysis, - tenant_id=int(tenant_id), - charter_content=charter_content, - ) - available_skill_names = await self._load_available_skill_names(tenant_id=int(tenant_id)) - prompt = self._build_task_chain_prompt( - analysis=analysis, - available_skill_names=available_skill_names, - ) - try: - tasks_data = await self._generate_project_creation_object( - prompt=prompt, - tenant_id=tenant_id, - function_name="project_from_text_task_chain", - stage_label="AI 管家任务链生成失败", - timeout_seconds=PROJECT_CREATION_TASK_CHAIN_TIMEOUT_SECONDS, - ) - tasks_list = tasks_data.get("tasks", []) - if isinstance(tasks_list, list) and tasks_list: - report = await self._inspect_task_chain_candidate(tasks=tasks_list, tenant_id=int(tenant_id)) - if not report["invalid_tasks"] or not available_skill_names: - return tasks_list + if self._has_six_module_charter_content(charter_content): + last_error: Exception | None = None + for tier_alias in ("low", "high"): + try: + return await self._generate_task_chain_via_charter_decomposition( + analysis=analysis, + tenant_id=int(tenant_id), + charter_content=charter_content, + routing_meta=self._task_chain_generation_routing_meta(tier_alias=tier_alias), + available_skill_names=available_skill_names, + ) + except Exception as exc: + last_error = exc + logger.warning( + "ai_steward_task_chain_charter_tier_attempt_failed", + tenant_id=int(tenant_id), + project_title=str(analysis.get("project_title") or analysis.get("project_name") or "").strip()[:120], + tier_alias=tier_alias, + error=str(exc), + ) + if last_error is not None: + raise last_error + raise RuntimeError("AI 管家任务链生成失败:charter decomposition unavailable") - retry_feedback = self._build_task_chain_retry_feedback( - invalid_tasks=report["invalid_tasks"], - available_skill_names=available_skill_names, - ) - retry_prompt = self._build_task_chain_prompt( + last_error: Exception | None = None + for tier_alias in ("low", "high"): + try: + return await self._generate_task_chain_from_primary_prompt( analysis=analysis, + tenant_id=int(tenant_id), available_skill_names=available_skill_names, - retry_feedback=retry_feedback, + routing_meta=self._task_chain_generation_routing_meta(tier_alias=tier_alias), ) - retry_data = await self._generate_project_creation_object( - prompt=retry_prompt, - tenant_id=tenant_id, - function_name="project_from_text_task_chain", - stage_label="AI 管家任务链生成失败", - timeout_seconds=PROJECT_CREATION_TASK_CHAIN_TIMEOUT_SECONDS, + except Exception as exc: + last_error = exc + logger.warning( + "ai_steward_task_chain_prompt_tier_attempt_failed", + tenant_id=int(tenant_id), + project_title=str(analysis.get("project_title") or analysis.get("project_name") or "").strip()[:120], + tier_alias=tier_alias, + error=str(exc), ) - retry_tasks = retry_data.get("tasks", []) - if isinstance(retry_tasks, list) and retry_tasks: - retry_report = await self._inspect_task_chain_candidate(tasks=retry_tasks, tenant_id=int(tenant_id)) - if self._task_chain_candidate_score(retry_report) >= self._task_chain_candidate_score(report): - return retry_tasks - return tasks_list - raise RuntimeError("AI 管家任务链生成失败:返回 tasks 为空") - except Exception as exc: - logger.warning( - "ai_steward_task_chain_primary_failed_fallback_to_charter_decomposition", - tenant_id=int(tenant_id), - project_title=str(analysis.get("project_title") or analysis.get("project_name") or "").strip()[:120], - error=str(exc), - ) - return await self._generate_task_chain_via_charter_decomposition( - analysis=analysis, - tenant_id=int(tenant_id), - charter_content=charter_content, - ) + + for tier_alias in ("low", "high"): + try: + return await self._generate_task_chain_via_charter_decomposition( + analysis=analysis, + tenant_id=int(tenant_id), + charter_content=charter_content, + routing_meta=self._task_chain_generation_routing_meta(tier_alias=tier_alias), + available_skill_names=available_skill_names, + ) + except Exception as exc: + last_error = exc + logger.warning( + "ai_steward_task_chain_charter_fallback_attempt_failed", + tenant_id=int(tenant_id), + project_title=str(analysis.get("project_title") or analysis.get("project_name") or "").strip()[:120], + tier_alias=tier_alias, + error=str(exc), + ) + + if last_error is not None: + raise last_error + raise RuntimeError("AI 管家任务链生成失败:no task generation route available") @staticmethod def _build_risk_alert_drafts(*, analysis: Dict[str, Any]) -> List[Dict[str, Any]]: diff --git a/backend/app/services/ai_steward_service/mixin_json_parsing.py b/backend/app/services/ai_steward_service/mixin_json_parsing.py index 69549611..45c50be8 100644 --- a/backend/app/services/ai_steward_service/mixin_json_parsing.py +++ b/backend/app/services/ai_steward_service/mixin_json_parsing.py @@ -141,6 +141,7 @@ class AIStewardJsonParsingMixin: function_name: str, stage_label: str, timeout_seconds: float | None = None, + routing_meta: dict[str, Any] | None = None, ) -> Dict[str, Any]: max_rounds = PROJECT_CREATION_JSON_PARSE_RETRY_COUNT + 1 last_error: Exception | None = None @@ -153,6 +154,7 @@ class AIStewardJsonParsingMixin: function_name=function_name, timeout_seconds=float(timeout_seconds) if timeout_seconds is not None else PROJECT_CREATION_DRAFT_TIMEOUT_SECONDS, max_tokens=max_tokens, + routing_meta=routing_meta, ) except Exception as exc: last_error = exc if isinstance(exc, Exception) else RuntimeError(str(exc)) @@ -193,6 +195,7 @@ class AIStewardJsonParsingMixin: function_name: str, timeout_seconds: float = PROJECT_CREATION_DRAFT_TIMEOUT_SECONDS, max_tokens: int = PROJECT_CREATION_STEP_INITIAL_MAX_TOKENS, + routing_meta: dict[str, Any] | None = None, ) -> str: attempts_per_function = PROJECT_CREATION_STEP_RETRY_COUNT + 1 last_error: Exception | None = None @@ -208,6 +211,7 @@ class AIStewardJsonParsingMixin: use_prompt_profile=False, temperature=0.0, max_tokens=max_tokens, + routing_meta=routing_meta, ), timeout=timeout_seconds, ) diff --git a/backend/app/services/mounted_ai_runtime_write_service.py b/backend/app/services/mounted_ai_runtime_write_service.py index 490eb262..9396179f 100644 --- a/backend/app/services/mounted_ai_runtime_write_service.py +++ b/backend/app/services/mounted_ai_runtime_write_service.py @@ -7,6 +7,7 @@ from typing import Any, Optional from sqlalchemy import or_, select from sqlalchemy.ext.asyncio import AsyncSession +from .ai_pmo.ai_pmo_intent import ROUTE_QUERY_ANSWER, ROUTE_REQUIREMENT_PLAN_COLLECT, ROUTE_REQUIREMENT_PLAN_DRAFT from ..models import FeishuReplyOutbox, MountedAgentTrace, MountedConversationRun, MountedSourceActivation, MountedTopicWorkspace from ..utils.thread_context import build_feishu_conversation_scope_key from ..utils.timezone import now_cn_naive @@ -158,6 +159,43 @@ class MountedAIRuntimeWriteService: return "plan" return "plan" + @classmethod + def _normalize_runtime_workflow_route( + cls, + *, + workflow_route: Any, + topic_row: Optional[MountedTopicWorkspace] = None, + route_metadata: Optional[dict[str, Any]] = None, + result_payload: Optional[dict[str, Any]] = None, + ) -> Optional[str]: + normalized_route = str(workflow_route or "").strip().lower() + if normalized_route in {ROUTE_REQUIREMENT_PLAN_COLLECT, ROUTE_REQUIREMENT_PLAN_DRAFT}: + return normalized_route + + message_intent = ( + str((route_metadata or {}).get("message_intent") or "").strip().lower() + if isinstance(route_metadata, dict) + else "" + ) + has_requirement_context = bool( + topic_row is not None and cls._topic_has_requirement_plan_context(topic_row) + ) + has_requirement_snapshot = bool( + isinstance((result_payload or {}).get("requirement_plan_context"), dict) + ) + has_requirement_draft = bool( + isinstance((result_payload or {}).get("draft_artifact"), dict) + and str(((result_payload or {}).get("draft_artifact") or {}).get("type") or "").strip() == "requirement_plan_draft" + ) + + if has_requirement_draft: + return ROUTE_REQUIREMENT_PLAN_DRAFT + if normalized_route == ROUTE_QUERY_ANSWER and (message_intent == "requirement_plan" or has_requirement_context or has_requirement_snapshot): + return ROUTE_REQUIREMENT_PLAN_COLLECT + if not normalized_route and (message_intent == "requirement_plan" or has_requirement_context or has_requirement_snapshot): + return ROUTE_REQUIREMENT_PLAN_COLLECT + return normalized_route or None + @classmethod def _topic_has_requirement_plan_context(cls, topic_row: MountedTopicWorkspace) -> bool: metadata = topic_row.metadata_json if isinstance(getattr(topic_row, "metadata_json", None), dict) else {} @@ -578,8 +616,13 @@ class MountedAIRuntimeWriteService: route_metadata: Optional[dict[str, Any]], mode_override: Optional[str] = None, ) -> str: - mode_after = self._mode_for_route( + normalized_workflow_route = self._normalize_runtime_workflow_route( workflow_route=workflow_route, + topic_row=tracking.topic_workspace, + route_metadata=route_metadata, + ) or str(workflow_route or "").strip() + mode_after = self._mode_for_route( + workflow_route=normalized_workflow_route, auto_execute=auto_execute, mode_override=mode_override, ) @@ -590,7 +633,7 @@ class MountedAIRuntimeWriteService: topic_row.mode = mode_after topic_row.metadata_json = { **(topic_row.metadata_json if isinstance(topic_row.metadata_json, dict) else {}), - "last_workflow_route": str(workflow_route or "").strip() or None, + "last_workflow_route": normalized_workflow_route or None, "last_intent_name": str(intent_name or "").strip() or None, "last_intent_confidence": float(intent_confidence or 0.0), "last_work_item_id": int(work_item_id) if work_item_id is not None else None, @@ -615,7 +658,7 @@ class MountedAIRuntimeWriteService: }, output_summary={ "mode_after": mode_after, - "workflow_route": str(workflow_route or "").strip() or None, + "workflow_route": normalized_workflow_route or None, "intent_name": str(intent_name or "").strip() or None, "auto_execute": bool(auto_execute), "route_metadata": dict(route_metadata or {}), @@ -754,6 +797,13 @@ class MountedAIRuntimeWriteService: if isinstance(value, str) and not value.strip(): continue persisted_result_fields[field] = value + normalized_workflow_route = self._normalize_runtime_workflow_route( + workflow_route=persisted_result_fields.get("workflow_route"), + topic_row=topic_row, + result_payload=result, + ) + if normalized_workflow_route: + persisted_result_fields["workflow_route"] = normalized_workflow_route run_row.status = "completed" run_row.active_agent = "ReplyComposer" run_row.final_reply = final_reply or None @@ -779,6 +829,22 @@ class MountedAIRuntimeWriteService: topic_row = canonical_topic tracking.topic_workspace = canonical_topic run_row.topic_workspace_id = int(canonical_topic.id) + if normalized_workflow_route: + topic_row.metadata_json = { + **(topic_row.metadata_json if isinstance(topic_row.metadata_json, dict) else {}), + "last_workflow_route": normalized_workflow_route, + "plan_mode": ( + True + if normalized_workflow_route.startswith("requirement_plan.") + else ( + topic_row.metadata_json.get("plan_mode") + if isinstance(topic_row.metadata_json, dict) + else None + ) + ), + } + if normalized_workflow_route.startswith("requirement_plan.") and str(topic_row.mode or "").strip().lower() == "chat": + topic_row.mode = "plan" run_row.completed_at = now_at self.db.add( MountedAgentTrace( diff --git a/backend/scripts/repair_requirement_plan_history.py b/backend/scripts/repair_requirement_plan_history.py new file mode 100644 index 00000000..767edf25 --- /dev/null +++ b/backend/scripts/repair_requirement_plan_history.py @@ -0,0 +1,180 @@ +#!/usr/bin/env python3 +"""预览或修复历史 requirement-plan draft/topic 污染。""" + +from __future__ import annotations + +import argparse +import asyncio +from dataclasses import dataclass +from typing import Any + +from sqlalchemy import select + +from app.core.database import AsyncSessionLocal +from app.models import MountedDraftWorkspace, MountedTopicWorkspace +from app.services.ai_pmo.ai_pmo_typed_executor_execute.mixin_execute_query_project import ExecuteQueryProjectMixin + + +@dataclass +class RepairCandidate: + draft_id: int + topic_id: int + reasons: list[str] + before_card: dict[str, Any] + after_card: dict[str, Any] + before_facts: dict[str, Any] + after_facts: dict[str, Any] + before_goals: Any + after_goals: list[str] + before_constraints: Any + after_constraints: dict[str, Any] + + +def _repair_candidate( + *, + draft: MountedDraftWorkspace, + topic: MountedTopicWorkspace, +) -> RepairCandidate | None: + before_card = dict(draft.requirement_card or {}) if isinstance(draft.requirement_card, dict) else {} + if not before_card: + return None + after_card = ExecuteQueryProjectMixin._sanitize_requirement_plan_card_runtime_context(card=before_card) + before_facts = dict(topic.facts_json or {}) if isinstance(topic.facts_json, dict) else {} + after_facts = ExecuteQueryProjectMixin._build_requirement_plan_topic_index_facts(card=after_card) + before_goals = topic.goals_json + after_goals = [after_card.get("goal")] if str(after_card.get("goal") or "").strip() else [] + before_constraints = topic.constraints_json + after_constraints = {"first_scope": after_card.get("first_scope")} + + reasons: list[str] = [] + if after_card != before_card: + reasons.append("requirement_card") + if before_facts != after_facts: + reasons.append("facts_json") + if before_goals != after_goals: + reasons.append("goals_json") + if before_constraints != after_constraints: + reasons.append("constraints_json") + if not reasons: + return None + return RepairCandidate( + draft_id=int(draft.id), + topic_id=int(topic.id), + reasons=reasons, + before_card=before_card, + after_card=after_card, + before_facts=before_facts, + after_facts=after_facts, + before_goals=before_goals, + after_goals=after_goals, + before_constraints=before_constraints, + after_constraints=after_constraints, + ) + + +async def _load_candidates( + *, + tenant_id: int, + topic_id: int | None, + draft_id: int | None, +) -> list[RepairCandidate]: + async with AsyncSessionLocal() as db: + stmt = ( + select(MountedDraftWorkspace, MountedTopicWorkspace) + .join( + MountedTopicWorkspace, + MountedTopicWorkspace.id == MountedDraftWorkspace.topic_workspace_id, + ) + .where(MountedDraftWorkspace.tenant_id == int(tenant_id)) + .order_by(MountedDraftWorkspace.id.asc()) + ) + if topic_id is not None: + stmt = stmt.where(MountedDraftWorkspace.topic_workspace_id == int(topic_id)) + if draft_id is not None: + stmt = stmt.where(MountedDraftWorkspace.id == int(draft_id)) + rows = (await db.execute(stmt)).all() + candidates: list[RepairCandidate] = [] + for draft, topic in rows: + candidate = _repair_candidate(draft=draft, topic=topic) + if candidate is not None: + candidates.append(candidate) + return candidates + + +async def _apply_candidates( + *, + tenant_id: int, + topic_id: int | None, + draft_id: int | None, +) -> int: + async with AsyncSessionLocal() as db: + stmt = ( + select(MountedDraftWorkspace, MountedTopicWorkspace) + .join( + MountedTopicWorkspace, + MountedTopicWorkspace.id == MountedDraftWorkspace.topic_workspace_id, + ) + .where(MountedDraftWorkspace.tenant_id == int(tenant_id)) + .order_by(MountedDraftWorkspace.id.asc()) + ) + if topic_id is not None: + stmt = stmt.where(MountedDraftWorkspace.topic_workspace_id == int(topic_id)) + if draft_id is not None: + stmt = stmt.where(MountedDraftWorkspace.id == int(draft_id)) + rows = (await db.execute(stmt)).all() + updated = 0 + for draft, topic in rows: + candidate = _repair_candidate(draft=draft, topic=topic) + if candidate is None: + continue + draft.requirement_card = candidate.after_card + topic.facts_json = candidate.after_facts + topic.goals_json = candidate.after_goals + topic.constraints_json = candidate.after_constraints + updated += 1 + if updated: + await db.commit() + return updated + + +async def _main() -> None: + parser = argparse.ArgumentParser(description="Preview/apply requirement-plan history cleanup") + parser.add_argument("--tenant-id", type=int, default=1, help="目标租户 ID,默认 1") + parser.add_argument("--topic-id", type=int, default=None, help="仅检查指定 topic_workspace_id") + parser.add_argument("--draft-id", type=int, default=None, help="仅检查指定 draft_workspace_id") + parser.add_argument("--apply", action="store_true", help="执行修复写入;默认仅预览") + args = parser.parse_args() + + candidates = await _load_candidates( + tenant_id=args.tenant_id, + topic_id=args.topic_id, + draft_id=args.draft_id, + ) + print(f"[preview] tenant_id={args.tenant_id} candidate_count={len(candidates)}") + for item in candidates[:20]: + title = str(item.after_card.get("title") or item.before_card.get("title") or "")[:80] + print( + f"- draft_id={item.draft_id} topic_id={item.topic_id} reasons={','.join(item.reasons)} title={title}" + ) + if len(candidates) > 20: + print(f"... ({len(candidates) - 20} more)") + + if not args.apply: + print("[dry-run] 未执行写入。加 --apply 以修复历史 requirement-plan 污染。") + return + + updated = await _apply_candidates( + tenant_id=args.tenant_id, + topic_id=args.topic_id, + draft_id=args.draft_id, + ) + remaining = await _load_candidates( + tenant_id=args.tenant_id, + topic_id=args.topic_id, + draft_id=args.draft_id, + ) + print(f"[apply] updated={updated} remaining_candidates={len(remaining)}") + + +if __name__ == "__main__": + asyncio.run(_main()) diff --git a/backend/tests/test_ai_pmo_ingress_mainline.py b/backend/tests/test_ai_pmo_ingress_mainline.py index a8a062fa..44846805 100644 --- a/backend/tests/test_ai_pmo_ingress_mainline.py +++ b/backend/tests/test_ai_pmo_ingress_mainline.py @@ -563,6 +563,27 @@ def test_build_contextual_requirement_plan_card_keeps_source_material_user_only( assert "当前需求草稿已完成第一轮关键判断" not in str(card.get("source_material") or "") +def test_build_contextual_requirement_plan_card_does_not_infer_business_fields_from_assistant_prose() -> None: + turns = [ + { + "user": "做一个门店库存异常晨报助手,先不要创建项目,只收第一版需求。", + "assistant": ( + "已确认选项 A(运营团队),继续收第二轮关键信息。\n\n" + "A. 单一区域经理\nB. 区域经理+店长\nC. 总部+区域+门店" + ), + } + ] + + card = mixin_execute_query_project.ExecuteQueryProjectMixin._build_contextual_requirement_plan_card( + topic=None, + turns=turns, + ) + + assert "target_user" not in card + assert "project_nature" not in card + assert str(card.get("source_material") or "").startswith("用户:做一个门店库存异常晨报助手") + + def test_build_requirement_plan_draft_packet_uses_contextual_goal_instead_of_generic_it_project() -> None: card = { "title": "每晚自动汇总门禁异常开门记录并发给行政安保的轻量助手", @@ -5882,6 +5903,7 @@ async def test_execute_query_intent_promotes_explicit_choice_followup_into_requi assert result["status"] == "succeeded" assert str(result["rag_result"]["query_type"]) == "plan" + assert str(result["workflow_route"]) == "requirement_plan.collect" direct_kwargs = captured.get("direct_kwargs") assert isinstance(direct_kwargs, dict) assert str(direct_kwargs["message_text"]) == "三个问题都有" @@ -6087,6 +6109,7 @@ async def test_execute_query_intent_converts_query_answer_requirement_contract_i assert result["status"] == "succeeded" assert str(result["rag_result"]["query_type"]) == "plan" + assert str(result["workflow_route"]) == "requirement_plan.collect" assert isinstance(captured.get("rag_kwargs"), dict) direct_kwargs = captured.get("direct_kwargs") assert isinstance(direct_kwargs, dict) @@ -7151,7 +7174,7 @@ async def test_build_requirement_planning_direct_result_emits_structured_require @pytest.mark.asyncio -async def test_repair_requirement_plan_workspace_from_reply_context_prefers_semantic_reply_snapshot( +async def test_repair_requirement_plan_workspace_from_reply_context_prefers_structured_reply_snapshot( requirement_plan_db_session, ) -> None: class FakeExecutor(mixin_execute_query_project.ExecuteQueryProjectMixin): @@ -7258,6 +7281,30 @@ async def test_repair_requirement_plan_workspace_from_reply_context_prefers_sema } ], }, + "requirement_plan_context": { + "plan_profile": "it_project", + "title": "门店库存异常日报助手", + "goal": "先把门店库存异常日报助手的第一版目标、技术路线和推进方式收清楚,再决定是否正式创建。", + "project_nature": "轻量定时简报", + "target_user": "单一区域经理", + "question_round": 2, + "round_option_maps": { + "target_user": { + "A": "单一区域经理", + "B": "区域经理 + 店长", + "C": "总部 + 区域 + 门店多层级", + } + }, + "last_round_options": { + "field": "target_user", + "options": [ + {"key": "A", "title": "单一区域经理"}, + {"key": "B", "title": "区域经理 + 店长"}, + {"key": "C", "title": "总部 + 区域 + 门店多层级"}, + ], + "recommendation": {"key": "A"}, + }, + }, "semantic_decision": { "assistant_reply": ( "已确认选项 A(单一层级区域经理),继续收第二轮关键信息 👇\n\n" @@ -7432,6 +7479,101 @@ async def test_build_requirement_planning_direct_result_forces_new_topic_when_sa assert int(current_draft.topic_workspace_id) == int(current_topic.id) +@pytest.mark.asyncio +async def test_build_requirement_planning_direct_result_only_persists_topic_index_facts( + monkeypatch, + requirement_plan_db_session, +) -> None: + class FakeExecutor(mixin_execute_query_project.ExecuteQueryProjectMixin): + def __init__(self) -> None: + self.db = requirement_plan_db_session + self.tenant_id = 3 + + async def _fake_build_requirement_plan_next_question( + self, + *, + missing_field: str, + profile: str, + message_text: str = "", + card: dict[str, object] | None = None, + latest_updates: dict[str, object] | None = None, + ) -> str: + _ = (self, profile, message_text, latest_updates) + assert isinstance(card, dict) + card["round_option_maps"] = { + **(dict(card.get("round_option_maps") or {}) if isinstance(card.get("round_option_maps"), dict) else {}), + str(missing_field): { + "A": "轻量晨报工具", + "B": "业务平台入口", + "C": "能力底座组件", + }, + } + card["last_round_options"] = { + "field": str(missing_field), + "options": [ + {"key": "A", "title": "轻量晨报工具"}, + {"key": "B", "title": "业务平台入口"}, + {"key": "C", "title": "能力底座组件"}, + ], + "recommendation": {"key": "A"}, + } + return "第一轮:项目性质\n\nA. 轻量晨报工具\nB. 业务平台入口\nC. 能力底座组件" + + monkeypatch.setattr( + mixin_execute_query_project.ExecuteQueryProjectMixin, + "_build_requirement_plan_next_question", + _fake_build_requirement_plan_next_question, + raising=False, + ) + + conversation_scope_key = build_feishu_conversation_scope_key( + source_chat_id="chat-plan-topic-index-facts", + source_sender_open_id="ou-plan-topic-index-facts", + sender=None, + ) + topic = MountedTopicWorkspace( + tenant_id=3, + topic_key="feishu:chat-plan-topic-index-facts:message:msg-plan-topic-index-facts-root", + source_system="feishu", + channel_ref="chat-plan-topic-index-facts", + conversation_scope_key=conversation_scope_key, + root_message_id=None, + last_message_id="msg-plan-topic-index-facts-root", + mode="chat", + status="active", + plan_status="idle", + metadata_json={}, + ) + requirement_plan_db_session.add(topic) + await requirement_plan_db_session.flush() + + executor = FakeExecutor() + result = await executor._build_requirement_planning_direct_result( + message_text="做一个给采购经理用的跨仓调拨异常晚报助手,只收第一版需求,不要直接创建项目。", + source_system="feishu", + source_message_id="msg-plan-topic-index-facts-root", + source_chat_id="chat-plan-topic-index-facts", + sender=None, + source_sender_open_id="ou-plan-topic-index-facts", + thread_context=None, + conversation_key=conversation_scope_key, + topic_switch=None, + ) + + await requirement_plan_db_session.flush() + saved_topic = ( + await requirement_plan_db_session.execute( + select(MountedTopicWorkspace).where(MountedTopicWorkspace.id == int(topic.id)) + ) + ).scalar_one() + + assert result["status"] == "succeeded" + assert saved_topic.facts_json == { + "title": "给采购经理用的跨仓调拨异常晚报助手", + "plan_profile": "it_project", + } + + @pytest.mark.asyncio async def test_build_requirement_planning_direct_result_prefers_reply_anchor_topic_over_same_scope_sibling( monkeypatch, @@ -7760,6 +7902,134 @@ async def test_repair_requirement_plan_workspace_from_reply_context_blocks_sibli assert "tech_route" not in target_draft.requirement_card +@pytest.mark.asyncio +async def test_repair_requirement_plan_workspace_from_reply_context_prefers_contextual_draft_snapshot_over_turn_history( + monkeypatch, + requirement_plan_db_session, +) -> None: + class FakeExecutor(mixin_execute_query_project.ExecuteQueryProjectMixin): + def __init__(self) -> None: + self.db = requirement_plan_db_session + self.tenant_id = 3 + + conversation_scope_key = build_feishu_conversation_scope_key( + source_chat_id="chat-plan-draft-snapshot-repair", + source_sender_open_id="ou-plan-draft-snapshot-repair", + sender=None, + ) + current_topic = MountedTopicWorkspace( + tenant_id=3, + topic_key="feishu:chat-plan-draft-snapshot-repair:message:om-plan-current-root", + source_system="feishu", + channel_ref="chat-plan-draft-snapshot-repair", + conversation_scope_key=conversation_scope_key, + root_message_id=None, + last_message_id="om-plan-current-root", + mode="plan", + status="active", + plan_status="collecting", + metadata_json={"plan_mode": True}, + ) + requirement_plan_db_session.add(current_topic) + await requirement_plan_db_session.flush() + current_draft = MountedDraftWorkspace( + tenant_id=3, + topic_workspace_id=int(current_topic.id), + state="collecting", + draft_version=0, + requirement_card={ + "plan_profile": "it_project", + "title": "门店库存异常晨报助手", + "goal": "先把门店库存异常晨报助手的第一版需求收敛清楚。", + "question_round": 1, + }, + draft_payload=None, + required_confirmations=[], + ) + requirement_plan_db_session.add(current_draft) + + contextual_topic = MountedTopicWorkspace( + tenant_id=3, + topic_key="feishu:chat-plan-draft-snapshot-repair:message:om-plan-context-root", + source_system="feishu", + channel_ref="chat-plan-draft-snapshot-repair", + conversation_scope_key=conversation_scope_key, + root_message_id=None, + last_message_id="om-plan-context-root", + mode="plan", + status="active", + plan_status="options_ready", + metadata_json={"plan_mode": True}, + ) + requirement_plan_db_session.add(contextual_topic) + await requirement_plan_db_session.flush() + requirement_plan_db_session.add( + MountedDraftWorkspace( + tenant_id=3, + topic_workspace_id=int(contextual_topic.id), + state="collecting", + draft_version=0, + requirement_card={ + "plan_profile": "it_project", + "title": "门店库存异常晨报助手", + "goal": "先把门店库存异常晨报助手的第一版需求收敛清楚。", + "project_nature": "轻量晨报工具", + "target_user": "区域经理", + "question_round": 2, + "round_option_maps": { + "project_nature": { + "A": "轻量晨报工具", + "B": "业务平台入口", + "C": "能力底座组件", + } + }, + "last_round_options": { + "field": "project_nature", + "options": [ + {"key": "A", "title": "轻量晨报工具"}, + {"key": "B", "title": "业务平台入口"}, + {"key": "C", "title": "能力底座组件"}, + ], + "recommendation": {"key": "A"}, + }, + }, + draft_payload=None, + required_confirmations=[], + ) + ) + await requirement_plan_db_session.flush() + + async def _fake_find_contextual_reply_topic(self, **kwargs): # noqa: ANN001 + _ = (self, kwargs) + return contextual_topic + + monkeypatch.setattr( + mixin_execute_query_project.ExecuteQueryProjectMixin, + "_find_contextual_reply_topic", + _fake_find_contextual_reply_topic, + raising=False, + ) + + executor = FakeExecutor() + repaired_topic, repaired_draft = await executor._repair_requirement_plan_workspace_from_reply_context( + topic=current_topic, + draft=current_draft, + source_system="feishu", + source_message_id="msg-plan-draft-snapshot-repair-followup", + source_chat_id="chat-plan-draft-snapshot-repair", + source_sender_open_id="ou-plan-draft-snapshot-repair", + thread_context=None, + conversation_key=conversation_scope_key, + message_text="A,继续往下收。", + ) + + assert repaired_topic is current_topic + assert repaired_draft is current_draft + assert current_draft.requirement_card["project_nature"] == "轻量晨报工具" + assert current_draft.requirement_card["target_user"] == "区域经理" + assert current_draft.requirement_card["last_round_options"]["field"] == "project_nature" + + @pytest.mark.asyncio async def test_execute_query_intent_contextual_draft_generation_bootstraps_requirement_plan_from_reply_topic( monkeypatch, @@ -7873,7 +8143,13 @@ async def test_execute_query_intent_contextual_draft_generation_bootstraps_requi source_message_id="msg-anchor-1", status="completed", final_reply="你提到的是给运营公司打造主播这个新需求,我更推荐 C:主播协同作战平台。", - execution_outcome={"source_message_preview": "想跟你聊个新需求,关于给运营公司打造主播推流的需求。"}, + execution_outcome={ + "source_message_preview": "想跟你聊个新需求,关于给运营公司打造主播推流的需求。", + "rag_result": { + "query_type": "plan", + "sources": [{"source": "system", "metadata": {"reason_code": "requirement_planning_direct"}}], + }, + }, ), MountedConversationRun( tenant_id=3, @@ -7883,7 +8159,37 @@ async def test_execute_query_intent_contextual_draft_generation_bootstraps_requi source_message_id="msg-anchor-2", status="completed", final_reply="咱们还没确认它到底要解决哪类业务卡点:是开播前脚本耗时、直播中突发问题不会接、还是下播后不知道优化啥?", - execution_outcome={"source_message_preview": "C"}, + execution_outcome={ + "source_message_preview": "C", + "rag_result": { + "query_type": "plan", + "sources": [{"source": "system", "metadata": {"reason_code": "requirement_planning_direct"}}], + }, + "requirement_plan_context": { + "plan_profile": "it_project", + "title": "给运营公司打造主播协同作战平台", + "goal": "先验证主播在关键节点是否能得到更及时的脚本、应变和复盘支持。", + "project_nature": "主播协同作战平台", + "question_round": 1, + "round_option_maps": { + "project_nature": { + "A": "脚本提效助手", + "B": "直播问题应答台", + "C": "主播协同作战平台", + } + }, + "last_round_options": { + "field": "project_nature", + "options": [ + {"key": "A", "title": "脚本提效助手"}, + {"key": "B", "title": "直播问题应答台"}, + {"key": "C", "title": "主播协同作战平台"}, + ], + "recommendation": {"key": "C"}, + }, + "source_material": "用户:想跟你聊个新需求,关于给运营公司打造主播推流的需求。 | 用户:C", + }, + }, ), MountedConversationRun( tenant_id=3, @@ -7893,7 +8199,25 @@ async def test_execute_query_intent_contextual_draft_generation_bootstraps_requi source_message_id="msg-anchor-3", status="completed", final_reply="收到,我会按当前上下文继续。", - execution_outcome={"source_message_preview": "三个问题都有"}, + execution_outcome={ + "source_message_preview": "三个问题都有", + "rag_result": { + "query_type": "plan", + "sources": [{"source": "system", "metadata": {"reason_code": "contextual_requirement_plan_draft"}}], + }, + "requirement_plan_context": { + "plan_profile": "it_project", + "title": "给运营公司打造主播协同作战平台", + "goal": "先验证主播在关键节点是否能得到更及时的脚本、应变和复盘支持。", + "project_nature": "主播协同作战平台", + "first_scope": "开播前脚本、直播中应变、下播后复盘", + "question_round": 2, + "source_material": ( + "用户:想跟你聊个新需求,关于给运营公司打造主播推流的需求。" + " | 用户:C | 用户:三个问题都有" + ), + }, + }, ), ] ) @@ -9135,6 +9459,56 @@ async def test_should_route_to_draft_confirmation_session_uses_active_mounted_dr } +@pytest.mark.asyncio +async def test_should_route_to_draft_confirmation_session_prioritizes_explicit_reply_anchor_over_bundle_skip( + monkeypatch, +) -> None: + captured: dict[str, object] = {"skip_called": False} + + class FakeKernel(mixin_entry.KernelIngressFlowEntryMixin): + def __init__(self) -> None: + self.db = object() + self.tenant_id = 1 + + async def _fake_has_active_draft_session(self, **kwargs): + _ = self + captured["kwargs"] = kwargs + return True + + async def _fake_skip(*, message_text: str, source_system: str) -> bool: + _ = (message_text, source_system) + captured["skip_called"] = True + return True + + monkeypatch.setattr(DraftConfirmHandler, "has_active_draft_session", _fake_has_active_draft_session) + monkeypatch.setattr( + mixin_entry.KernelIngressFlowEntryMixin, + "_should_skip_no_anchor_draft_routing_by_bundle", + staticmethod(_fake_skip), + ) + + kernel = FakeKernel() + should_route = await kernel._should_route_to_draft_confirmation_session( + session=SimpleNamespace(last_intent="query.answer"), + sender="tester", + source_system="feishu", + channel_ref="chat-active", + source_sender_open_id="ou_active", + message_text="确认", + ingress_options={"thread_context": {"reply_to_message_id": "msg-bot-reply-1"}}, + ) + + assert should_route is True + assert captured["skip_called"] is False + assert captured["kwargs"] == { + "sender": "tester", + "source_system": "feishu", + "channel_ref": "chat-active", + "source_sender_open_id": "ou_active", + "thread_context": {"reply_to_message_id": "msg-bot-reply-1"}, + } + + @pytest.mark.asyncio async def test_should_route_to_draft_confirmation_session_does_not_hijack_smalltalk_even_if_active_draft_exists( monkeypatch, diff --git a/backend/tests/test_ai_pmo_task_intent_contract.py b/backend/tests/test_ai_pmo_task_intent_contract.py index 636c7643..dda98968 100644 --- a/backend/tests/test_ai_pmo_task_intent_contract.py +++ b/backend/tests/test_ai_pmo_task_intent_contract.py @@ -3,12 +3,15 @@ import pytest from app.services.ai_pmo.ai_pmo_intent import ( INTENT_FOLLOWUP_CONTINUE, INTENT_QUERY, + ROUTE_ACTION_COLLECT, INTENT_TASK_CREATE, ROUTE_ACTION_ANSWER, ROUTE_ACTION_CANCEL, ROUTE_ACTION_CONTINUE, ROUTE_ACTION_CREATE, ROUTE_FAMILY_QUERY, + ROUTE_FAMILY_REQUIREMENT_PLAN, + ROUTE_REQUIREMENT_PLAN_COLLECT, ROUTE_FAMILY_TASK, ROUTE_QUERY_ANSWER, ROUTE_TASK_CREATE, @@ -171,6 +174,23 @@ def test_route_resolver_builds_query_answer_route() -> None: assert route.key == ROUTE_QUERY_ANSWER +def test_route_resolver_keeps_explicit_requirement_plan_collect_route_for_query_intent() -> None: + route = RouteResolver.from_canonical( + canonical_intent=CanonicalIntent( + name=INTENT_QUERY, + confidence=0.92, + project_id=7, + signal_title="", + signal_summary="收敛第一版需求", + metadata={"workflow_route": ROUTE_REQUIREMENT_PLAN_COLLECT, "message_intent": "requirement_plan"}, + ), + message_text="继续收敛第一版需求", + ) + assert route.family == ROUTE_FAMILY_REQUIREMENT_PLAN + assert route.action == ROUTE_ACTION_COLLECT + assert route.key == ROUTE_REQUIREMENT_PLAN_COLLECT + + def test_route_resolver_followup_action_requires_structured_metadata() -> None: action = RouteResolver.resolve_followup_route_action( canonical_intent=INTENT_FOLLOWUP_CONTINUE, @@ -190,6 +210,7 @@ def test_route_resolver_followup_action_no_longer_parses_message_text_with_conte def test_kernel_common_imports_exposes_workflow_route_keys() -> None: assert "query.answer" in WORKFLOW_ROUTE_KEYS + assert ROUTE_REQUIREMENT_PLAN_COLLECT in WORKFLOW_ROUTE_KEYS assert "task.create" in WORKFLOW_ROUTE_KEYS diff --git a/backend/tests/test_ai_steward_project_from_text_contract.py b/backend/tests/test_ai_steward_project_from_text_contract.py index 5762a7d0..0e2d331a 100644 --- a/backend/tests/test_ai_steward_project_from_text_contract.py +++ b/backend/tests/test_ai_steward_project_from_text_contract.py @@ -579,8 +579,15 @@ async def test_generate_task_chain_falls_back_to_charter_decomposition_when_prim captured["primary_function_name"] = kwargs.get("function_name") raise RuntimeError("provider_circuit_open:openai") - async def fake_decompose_tasks_from_charter(self, charter_content, project_id, tenant_id="default", db=None): # noqa: ANN001 - _ = self, project_id, db + async def fake_decompose_tasks_from_charter( + self, + charter_content, + project_id, + tenant_id="default", + db=None, + routing_meta=None, + ): # noqa: ANN001 + _ = self, project_id, db, routing_meta captured["charter_content"] = charter_content captured["decompose_tenant_id"] = tenant_id return { @@ -606,8 +613,18 @@ async def test_generate_task_chain_falls_back_to_charter_decomposition_when_prim ] } + async def fake_inspect_task_chain_candidate(self, *, tasks, tenant_id): # noqa: ANN001 + _ = self, tenant_id + return { + "tasks": tasks, + "invalid_tasks": [], + "mappable_task_count": len(tasks), + "total_task_count": len(tasks), + } + monkeypatch.setattr(AIStewardService, "_load_available_skill_names", fake_load_available_skill_names) monkeypatch.setattr(AIStewardService, "_generate_project_creation_object", fake_generate_project_creation_object) + monkeypatch.setattr(AIStewardService, "_inspect_task_chain_candidate", fake_inspect_task_chain_candidate) monkeypatch.setattr( "app.services.ai_service_core.service.AIService.decompose_tasks_from_charter", fake_decompose_tasks_from_charter, @@ -651,10 +668,18 @@ async def test_generate_task_chain_prefers_six_module_charter_content_when_prese _ = self, kwargs raise AssertionError("six-module charter path should bypass project_from_text_task_chain prompt") - async def fake_decompose_tasks_from_charter(self, charter_content, project_id, tenant_id="default", db=None): # noqa: ANN001 + async def fake_decompose_tasks_from_charter( + self, + charter_content, + project_id, + tenant_id="default", + db=None, + routing_meta=None, + ): # noqa: ANN001 _ = self, project_id, db captured["charter_content"] = charter_content captured["decompose_tenant_id"] = tenant_id + captured["routing_meta"] = routing_meta return { "tasks": [ { @@ -669,8 +694,18 @@ async def test_generate_task_chain_prefers_six_module_charter_content_when_prese ] } + async def fake_inspect_task_chain_candidate(self, *, tasks, tenant_id): # noqa: ANN001 + _ = self, tenant_id + return { + "tasks": tasks, + "invalid_tasks": [], + "mappable_task_count": len(tasks), + "total_task_count": len(tasks), + } + monkeypatch.setattr(AIStewardService, "_load_available_skill_names", fake_load_available_skill_names) monkeypatch.setattr(AIStewardService, "_generate_project_creation_object", fake_generate_project_creation_object) + monkeypatch.setattr(AIStewardService, "_inspect_task_chain_candidate", fake_inspect_task_chain_candidate) monkeypatch.setattr( "app.services.ai_service_core.service.AIService.decompose_tasks_from_charter", fake_decompose_tasks_from_charter, @@ -707,6 +742,9 @@ async def test_generate_task_chain_prefers_six_module_charter_content_when_prese assert captured["decompose_tenant_id"] == 1 assert "\"modules\"" in str(captured["charter_content"] or "") assert "直播录屏与评论回放切片" in str(captured["charter_content"] or "") + assert captured["routing_meta"]["tier_alias"] == "low" + assert captured["routing_meta"]["routing_decision"] == "force_low_first" + assert captured["routing_meta"]["response_contract"] == "structured" assert tasks[0]["title"] == "直播数据采集与清洗" assert tasks[0]["required_skills"] == ["Python", "FastAPI"] @@ -715,14 +753,26 @@ async def test_generate_task_chain_prefers_six_module_charter_content_when_prese async def test_generate_task_chain_raises_when_charter_decomposition_provider_fails(monkeypatch) -> None: service = AIStewardService(object()) - async def fake_decompose_tasks_from_charter(self, charter_content, project_id, tenant_id="default", db=None): # noqa: ANN001 - _ = self, charter_content, project_id, tenant_id, db + async def fake_load_available_skill_names(self, *, tenant_id: int, limit: int = 24): # noqa: ANN001 + _ = self, tenant_id, limit + return ["Python", "FastAPI", "React", "TypeScript"] + + async def fake_decompose_tasks_from_charter( + self, + charter_content, + project_id, + tenant_id="default", + db=None, + routing_meta=None, + ): # noqa: ANN001 + _ = self, charter_content, project_id, tenant_id, db, routing_meta raise RuntimeError("provider_circuit_open:openai") monkeypatch.setattr( "app.services.ai_service_core.service.AIService.decompose_tasks_from_charter", fake_decompose_tasks_from_charter, ) + monkeypatch.setattr(AIStewardService, "_load_available_skill_names", fake_load_available_skill_names) with pytest.raises(RuntimeError, match="provider_circuit_open:openai"): await service._generate_task_chain_draft( @@ -744,32 +794,204 @@ async def test_generate_task_chain_raises_when_charter_decomposition_provider_fa "out_of_scope": "- 不做达人 CRM", "alignment_qa": "优先交付复盘效率闭环", }, - "module_3": { - "engineering_view": "Python FastAPI React TypeScript 数据分析", - }, - "module_4": { - "milestones": "- 第 1 周完成直播与订单数据对齐\n- 第 2 周完成复盘看板 MVP", - }, - "module_5": { - "risk_register": "- 数据口径不一致", - }, - "module_6": { - "governance_rule": "- 复盘记录保留 180 天", - }, - }, - "derived_artifacts": { - "task_seeds": [ - { - "title": "直播录屏与评论回放切片", - "description": "接入录屏与评论数据,支持按时间轴回放。", - "evidence_refs": ["module_2.in_scope"], - }, - { - "title": "亮点和掉点自动总结", - "description": "自动总结直播亮点、掉点与问题复现线索。", - "evidence_refs": ["module_1.goal_statement", "module_5.risk_register"], - }, - ] + "module_3": {"engineering_view": "Python FastAPI React TypeScript 数据分析"}, + "module_4": {"milestones": "- 第 1 周完成直播与订单数据对齐"}, + "module_5": {"risk_register": "- 数据口径不一致"}, + "module_6": {"governance_rule": "- 复盘记录保留 180 天"}, }, }, ) + + +@pytest.mark.asyncio +async def test_generate_task_chain_primary_prompt_uses_low_first_routing_meta(monkeypatch) -> None: + service = AIStewardService(object()) + captured: dict[str, object] = {} + + async def fake_load_available_skill_names(self, *, tenant_id: int, limit: int = 24): # noqa: ANN001 + _ = self, tenant_id, limit + return ["Python", "FastAPI", "React"] + + async def fake_generate_project_creation_object(self, **kwargs): # noqa: ANN001 + _ = self + captured["function_name"] = kwargs.get("function_name") + captured["routing_meta"] = kwargs.get("routing_meta") + return { + "tasks": [ + { + "title": "直播数据采集与清洗", + "description": "接入录屏与评论回放。", + "priority": "high", + "estimated_hours": 16, + "required_skills": ["Python", "FastAPI"], + } + ] + } + + async def fake_inspect_task_chain_candidate(self, *, tasks, tenant_id): # noqa: ANN001 + _ = self, tenant_id + return { + "tasks": tasks, + "invalid_tasks": [], + "mappable_task_count": len(tasks), + "total_task_count": len(tasks), + } + + monkeypatch.setattr(AIStewardService, "_load_available_skill_names", fake_load_available_skill_names) + monkeypatch.setattr(AIStewardService, "_generate_project_creation_object", fake_generate_project_creation_object) + monkeypatch.setattr(AIStewardService, "_inspect_task_chain_candidate", fake_inspect_task_chain_candidate) + + tasks = await service._generate_task_chain_draft( + analysis={ + "project_title": "跨境直播复盘", + "primary_role": "backend", + "key_features": ["直播录屏与评论回放切片"], + "success_metrics": ["7天内复盘时长下降60%"], + }, + tenant_id=1, + ) + + assert captured["function_name"] == "project_from_text_task_chain" + assert captured["routing_meta"]["tier_alias"] == "low" + assert captured["routing_meta"]["routing_decision"] == "force_low_first" + assert captured["routing_meta"]["response_contract"] == "structured" + assert tasks[0]["title"] == "直播数据采集与清洗" + + +@pytest.mark.asyncio +async def test_generate_task_chain_escalates_to_high_when_low_route_returns_low_quality_tasks( + monkeypatch, +) -> None: + service = AIStewardService(object()) + captured_tiers: list[str] = [] + + async def fake_load_available_skill_names(self, *, tenant_id: int, limit: int = 24): # noqa: ANN001 + _ = self, tenant_id, limit + return ["Python", "FastAPI", "React", "TypeScript"] + + async def fake_generate_project_creation_object(self, **kwargs): # noqa: ANN001 + _ = self + routing_meta = kwargs.get("routing_meta") or {} + captured_tiers.append(str(routing_meta.get("tier_alias") or "")) + if routing_meta.get("tier_alias") == "low": + return { + "tasks": [ + { + "title": "权限系统", + "description": "实现统一权限系统。", + "priority": "high", + "estimated_hours": 16, + "required_skills": ["JWT", "Node.js"], + } + ] + } + return { + "tasks": [ + { + "title": "直播数据采集与清洗", + "description": "接入录屏与评论回放。", + "priority": "high", + "estimated_hours": 16, + "required_skills": ["Python", "FastAPI"], + } + ] + } + + async def fake_inspect_task_chain_candidate(self, *, tasks, tenant_id): # noqa: ANN001 + _ = self, tenant_id + required_skills = list(tasks[0].get("required_skills") or []) if tasks else [] + if required_skills == ["Python", "FastAPI"]: + return { + "tasks": tasks, + "invalid_tasks": [], + "mappable_task_count": len(tasks), + "total_task_count": len(tasks), + } + return { + "tasks": tasks, + "invalid_tasks": [ + { + "title": str(tasks[0].get("title") or ""), + "required_skills": required_skills, + "reason": "required_skills_unmappable", + } + ], + "mappable_task_count": 0, + "total_task_count": len(tasks), + } + + monkeypatch.setattr(AIStewardService, "_load_available_skill_names", fake_load_available_skill_names) + monkeypatch.setattr(AIStewardService, "_generate_project_creation_object", fake_generate_project_creation_object) + monkeypatch.setattr(AIStewardService, "_inspect_task_chain_candidate", fake_inspect_task_chain_candidate) + + tasks = await service._generate_task_chain_draft( + analysis={ + "project_title": "跨境直播复盘", + "primary_role": "backend", + "key_features": ["直播录屏与评论回放切片"], + }, + tenant_id=1, + ) + + assert captured_tiers[0] == "low" + assert "high" in captured_tiers + assert tasks[0]["title"] == "直播数据采集与清洗" + assert tasks[0]["required_skills"] == ["Python", "FastAPI"] + + +@pytest.mark.asyncio +async def test_generate_project_creation_draft_keeps_task_generation_pending_when_task_chain_fails( + monkeypatch, +) -> None: + service = AIStewardService(object()) + + async def fake_generate_signal(self, *, analysis, tenant_id): # noqa: ANN001 + _ = self, analysis, tenant_id + return { + "content": "直播复盘主信号", + "impact_score": 0.9, + "priority": "high", + "strategic_analysis": {}, + } + + async def fake_generate_charter(self, *, analysis, tenant_id): # noqa: ANN001 + _ = self, analysis, tenant_id + return { + "title": "跨境直播复盘项目宪章", + "content": { + "schema_version": "charter_ai_first_v1", + "modules": { + "module_1": {"problem_statement": "复盘慢"}, + "module_2": {"in_scope": "- 直播复盘"}, + "module_3": {}, + "module_4": {}, + "module_5": {}, + "module_6": {}, + }, + }, + } + + async def fake_generate_task_chain(self, *, analysis, tenant_id, charter_content=None): # noqa: ANN001 + _ = self, analysis, tenant_id, charter_content + raise RuntimeError("provider_circuit_open:openai") + + monkeypatch.setattr(AIStewardService, "_generate_competitive_signal_draft", fake_generate_signal) + monkeypatch.setattr(AIStewardService, "_generate_charter_draft", fake_generate_charter) + monkeypatch.setattr(AIStewardService, "_generate_task_chain_draft", fake_generate_task_chain) + + draft = await service._generate_project_creation_draft( + input_text="做一个给北美 DTC 运营团队用的跨境直播复盘助手", + analysis={ + "project_name": "跨境直播复盘", + "project_title": "跨境直播复盘", + "primary_role": "backend", + }, + tenant_id=1, + ) + + assert draft["project"]["title"] == "跨境直播复盘" + assert draft["signal"]["content"] == "直播复盘主信号" + assert draft["charter"]["title"] == "跨境直播复盘项目宪章" + assert draft["tasks"] == [] + assert draft["task_generation"]["status"] == "pending" + assert "provider_circuit_open:openai" in str(draft["task_generation"]["error_message"] or "") diff --git a/backend/tests/test_draft_confirm_handler.py b/backend/tests/test_draft_confirm_handler.py index 0679b615..fee92d8e 100644 --- a/backend/tests/test_draft_confirm_handler.py +++ b/backend/tests/test_draft_confirm_handler.py @@ -905,10 +905,59 @@ async def test_draft_confirm_handler_project_create_confirm_fails_when_charter_t _ = self, tenant_id, limit return ["Python", "FastAPI", "React", "TypeScript", "数据分析", "项目管理"] - async def _fail_decompose_tasks_from_charter(self, charter_content, project_id, tenant_id="default", db=None): # noqa: ANN001 - _ = self, charter_content, project_id, tenant_id, db + async def _fail_decompose_tasks_from_charter( + self, + charter_content, + project_id, + tenant_id="default", + db=None, + routing_meta=None, + ): # noqa: ANN001 + _ = self, charter_content, project_id, tenant_id, db, routing_meta raise RuntimeError("provider_circuit_open:openai") + async def _fake_normalize_project_creation_draft(self, **kwargs): # noqa: ANN001 + captured["normalize_kwargs"] = kwargs + return { + "analysis": {}, + "project": { + "name": "跨境直播复盘工具", + "title": "跨境直播复盘工具", + "description": "项目:跨境直播复盘工具", + "assigned_role": "backend", + "domain_id": None, + }, + "signal": { + "content": "main signal", + "impact_score": 0.9, + "priority": "high", + "strategic_analysis": {}, + }, + "charter": { + "title": "跨境直播复盘工具项目宪章", + "content": {"summary": "demo"}, + }, + "tasks": kwargs["draft"].get("tasks", []), + "task_audit": [], + "task_generation": kwargs["draft"].get("task_generation", {}), + "risks": [], + "creator_id": 77, + } + + async def _fake_persist_project_creation_draft(self, **kwargs): # noqa: ANN001 + captured["persist_kwargs"] = kwargs + return { + "project": {"id": "101", "title": "跨境直播复盘工具", "name": "跨境直播复盘工具"}, + "project_id": 101, + "signal": {"id": "202", "content": "main signal"}, + "signal_id": 202, + "charter": {"id": "303", "title": "宪章", "content": {"summary": "demo"}}, + "tasks": [], + "task_generation": kwargs["normalized"].get("task_generation", {}), + "risks": [], + "signal_sources": [], + } + monkeypatch.setattr( "app.services.ai_pmo.draft_confirm_handler.AIStewardService.create_project_from_natural_language", _fail_create_project_from_natural_language, @@ -921,6 +970,14 @@ async def test_draft_confirm_handler_project_create_confirm_fails_when_charter_t "app.services.ai_service_core.service.AIService.decompose_tasks_from_charter", _fail_decompose_tasks_from_charter, ) + monkeypatch.setattr( + "app.services.ai_pmo.draft_confirm_handler.AIStewardService._normalize_project_creation_draft", + _fake_normalize_project_creation_draft, + ) + monkeypatch.setattr( + "app.services.ai_pmo.draft_confirm_handler.AIStewardService._persist_project_creation_draft", + _fake_persist_project_creation_draft, + ) result = await handler.confirm_draft( target_work_item=SimpleNamespace( @@ -940,11 +997,17 @@ async def test_draft_confirm_handler_project_create_confirm_fails_when_charter_t ) assert result["handled"] is True - assert result["status"] == "failed_safe" - assert result["project_id"] is None - assert "provider_circuit_open:openai" in str(result["followup_reply"] or "") - assert "normalize_kwargs" not in captured - assert "persist_kwargs" not in captured + assert result["status"] == "succeeded" + assert result["project_id"] == 101 + assert result["signal_id"] == 202 + assert "任务拆解:待补全" in str(result["followup_reply"] or "") + assert captured["normalize_kwargs"]["draft"]["tasks"] == [] + assert captured["normalize_kwargs"]["draft"]["task_generation"]["status"] == "pending" + assert "provider_circuit_open:openai" in str( + captured["normalize_kwargs"]["draft"]["task_generation"]["error_message"] or "" + ) + assert captured["persist_kwargs"]["normalized"]["tasks"] == [] + assert captured["persist_kwargs"]["normalized"]["task_generation"]["status"] == "pending" @pytest.mark.asyncio @@ -1385,7 +1448,19 @@ async def test_draft_confirm_handler_semantic_regenerate_routes_to_regenerate_dr @pytest.mark.asyncio async def test_resolve_pending_draft_releases_stale_active_work_item_binding(monkeypatch) -> None: - kernel = type("Kernel", (), {"db": object(), "tenant_id": 1})() + class _FakeResult: + def __init__(self, rows) -> None: + self._rows = list(rows) + + def all(self): + return list(self._rows) + + class _FakeDB: + async def execute(self, stmt): # pragma: no cover - structure only + _ = stmt + return _FakeResult([]) + + kernel = type("Kernel", (), {"db": _FakeDB(), "tenant_id": 1})() handler = DraftConfirmHandler(kernel) captured: dict[str, object] = {} @@ -1431,6 +1506,346 @@ async def test_resolve_pending_draft_releases_stale_active_work_item_binding(mon assert captured["release_kwargs"]["final_state"] == "cancelled" +@pytest.mark.asyncio +async def test_resolve_active_mounted_draft_keeps_retryable_pending_confirm_without_active_work_item_when_anchor_matches( + monkeypatch, +) -> None: + class _FakeResult: + def __init__(self, rows) -> None: + self._rows = list(rows) + + def all(self): + return list(self._rows) + + draft_row = SimpleNamespace( + id=8, + topic_workspace_id=77, + active_work_item_id=None, + state=STATE_DRAFT_PENDING_CONFIRM, + latest_reply_message_id="om-bot-retry-1", + latest_source_message_id="msg-draft-root", + ) + topic_row = SimpleNamespace( + id=77, + tenant_id=1, + source_system="feishu", + mode="plan", + status="active", + channel_ref="chat-1", + conversation_scope_key="feishu:chat-1:ou_xxx", + plan_status=STATE_DRAFT_PENDING_CONFIRM, + root_message_id="msg-draft-root", + thread_key="", + last_message_id="msg-draft-root", + updated_at=None, + ) + + class _FakeDB: + async def execute(self, query): # noqa: ARG002 + return _FakeResult([(draft_row, topic_row)]) + + async def _unexpected_load(*args, **kwargs): + raise AssertionError(f"released retryable draft should not load active work item: {args} {kwargs}") + + async def _unexpected_release(*args, **kwargs): + raise AssertionError(f"released retryable draft should not release binding again: {args} {kwargs}") + + handler = DraftConfirmHandler(type("Kernel", (), {"db": _FakeDB(), "tenant_id": 1})()) + monkeypatch.setattr(handler, "_load_work_item_target_by_id", _unexpected_load) + monkeypatch.setattr(handler, "_release_mounted_draft_binding", _unexpected_release) + + result = await handler._resolve_active_mounted_draft( + sender="林宇航", + source_system="feishu", + channel_ref="chat-1", + source_sender_open_id="ou_xxx", + thread_context={"reply_to_message_id": "om-bot-retry-1"}, + ) + + assert result == (draft_row, topic_row) + + +@pytest.mark.asyncio +async def test_resolve_pending_draft_falls_back_to_anchor_matched_pending_work_item_when_active_mounted_draft_has_no_active_work_item_id( + monkeypatch, +) -> None: + class _FakeResult: + def __init__(self, rows) -> None: + self._rows = list(rows) + + def all(self): + return list(self._rows) + + canonical_work_item = SimpleNamespace( + id=1092, + state=STATE_DRAFT_PENDING_CONFIRM, + intent="project_create.create", + project_id=None, + signal_id=None, + source_message_id="src-draft-confirm-1", + ) + canonical_ingest = SimpleNamespace( + payload={ + "sender": "Codex真实验收机器人", + "channel_ref": "chat-1", + "source_sender_open_id": "ou_xxx", + "reply_to_message_id": "src-draft-confirm-1", + "root_message_id": "msg-draft-root", + }, + source_message_id="src-draft-confirm-1", + ) + wrong_newer_work_item = SimpleNamespace( + id=1097, + state=STATE_DRAFT_PENDING_CONFIRM, + intent="project_create.create", + project_id=None, + signal_id=None, + source_message_id="src-wrong-newer-confirm", + ) + wrong_newer_ingest = SimpleNamespace( + payload={ + "sender": "Codex真实验收机器人", + "channel_ref": "chat-1", + "source_sender_open_id": "ou_xxx", + "reply_to_message_id": "src-draft-confirm-1", + "root_message_id": "msg-draft-root", + }, + source_message_id="src-wrong-newer-confirm", + ) + + class _FakeDB: + async def execute(self, query): + sql = str(query.compile(compile_kwargs={"literal_binds": True})).lower() + if "from work_items" in sql: + return _FakeResult( + [ + (wrong_newer_work_item, wrong_newer_ingest), + (canonical_work_item, canonical_ingest), + ] + ) + if "from feishu_reply_outbox" in sql: + return _FakeResult( + [ + ("src-wrong-newer-confirm", "om-bot-retry-1"), + ("src-draft-confirm-1", "om-bot-retry-1"), + ] + ) + raise AssertionError(f"unexpected query: {sql}") + + released_mounted = ( + SimpleNamespace( + id=8, + topic_workspace_id=77, + active_work_item_id=None, + state=STATE_DRAFT_PENDING_CONFIRM, + latest_reply_message_id="om-bot-retry-1", + latest_source_message_id="src-draft-confirm-1", + ), + SimpleNamespace( + id=77, + mode="plan", + status="active", + plan_status=STATE_DRAFT_PENDING_CONFIRM, + channel_ref="chat-1", + conversation_scope_key="feishu:chat-1:ou_xxx", + root_message_id="msg-draft-root", + thread_key="", + last_message_id="src-draft-confirm-1", + updated_at=None, + ), + ) + + handler = DraftConfirmHandler(type("Kernel", (), {"db": _FakeDB(), "tenant_id": 1})()) + + async def _fake_resolve_active_mounted_draft(**kwargs): + _ = kwargs + return released_mounted + + monkeypatch.setattr(handler, "_resolve_active_mounted_draft", _fake_resolve_active_mounted_draft) + + result = await handler._resolve_pending_draft( + sender="Codex真实验收机器人", + source_system="feishu", + channel_ref="chat-1", + source_sender_open_id="ou_xxx", + explicit_work_item_id=None, + thread_context={ + "reply_to_message_id": "om-bot-retry-1", + "root_message_id": "msg-draft-root", + }, + ) + + assert result == (canonical_work_item, canonical_ingest) + + +@pytest.mark.asyncio +async def test_resolve_pending_draft_materializes_retryable_work_item_from_mounted_draft_when_only_wrong_candidate_matches_anchor( + monkeypatch, +) -> None: + class _FakeResult: + def __init__(self, rows) -> None: + self._rows = list(rows) + + def all(self): + return list(self._rows) + + wrong_newer_work_item = SimpleNamespace( + id=1097, + state=STATE_DRAFT_PENDING_CONFIRM, + intent="project_create.create", + project_id=None, + signal_id=None, + source_message_id="src-wrong-newer-confirm", + draft_content={"title": "需求确认立项"}, + ) + wrong_newer_ingest = SimpleNamespace( + payload={ + "sender": "Codex真实验收机器人", + "channel_ref": "chat-1", + "source_sender_open_id": "ou_xxx", + "reply_to_message_id": "src-draft-confirm-1", + "root_message_id": "msg-draft-root", + }, + source_message_id="src-wrong-newer-confirm", + ) + mounted_draft = SimpleNamespace( + id=8, + topic_workspace_id=77, + active_work_item_id=None, + state=STATE_DRAFT_PENDING_CONFIRM, + latest_reply_message_id="om-bot-retry-1", + latest_source_message_id="src-draft-confirm-1", + draft_payload={"title": "直播复盘辅助工具(速记版)"}, + draft_version=1, + requirement_card={"title": "直播复盘辅助工具"}, + ) + mounted_topic = SimpleNamespace( + id=77, + mode="plan", + status="active", + plan_status=STATE_DRAFT_PENDING_CONFIRM, + channel_ref="chat-1", + conversation_scope_key="feishu:chat-1:ou_xxx", + root_message_id="msg-draft-root", + thread_key="", + last_message_id="src-draft-confirm-1", + updated_at=None, + metadata_json={}, + ) + + class _FakeDB: + async def execute(self, query): + sql = str(query.compile(compile_kwargs={"literal_binds": True})).lower() + if "from work_items" in sql: + return _FakeResult([(wrong_newer_work_item, wrong_newer_ingest)]) + if "from feishu_reply_outbox" in sql: + return _FakeResult([("src-wrong-newer-confirm", "om-bot-retry-1")]) + raise AssertionError(f"unexpected query: {sql}") + + handler = DraftConfirmHandler(type("Kernel", (), {"db": _FakeDB(), "tenant_id": 1})()) + materialized = ( + SimpleNamespace(id=2001, state=STATE_DRAFT_PENDING_CONFIRM, source_message_id="draft-retry-2001"), + SimpleNamespace(source_message_id="draft-retry-2001", payload={"message_text": "mounted draft retry"}), + ) + captured: dict[str, object] = {} + + async def _fake_resolve_active_mounted_draft(**kwargs): + _ = kwargs + return (mounted_draft, mounted_topic) + + async def _fake_materialize(**kwargs): + captured.update(kwargs) + return materialized + + monkeypatch.setattr(handler, "_resolve_active_mounted_draft", _fake_resolve_active_mounted_draft) + monkeypatch.setattr(handler, "_materialize_retryable_pending_draft_from_mounted", _fake_materialize) + + result = await handler._resolve_pending_draft( + sender="Codex真实验收机器人", + source_system="feishu", + channel_ref="chat-1", + source_sender_open_id="ou_xxx", + explicit_work_item_id=None, + thread_context={ + "reply_to_message_id": "om-bot-retry-1", + "root_message_id": "msg-draft-root", + }, + ) + + assert result == materialized + assert captured["mounted"] == (mounted_draft, mounted_topic) + assert captured["source_system"] == "feishu" + assert captured["channel_ref"] == "chat-1" + + +@pytest.mark.asyncio +async def test_resolve_retryable_draft_owner_user_id_prefers_latest_source_message_id(monkeypatch) -> None: + class _FakeScalarResult: + def __init__(self, value) -> None: + self._value = value + + def scalar_one_or_none(self): + return self._value + + class _FakeDB: + async def execute(self, query): + sql = str(query.compile(compile_kwargs={"literal_binds": True})).lower() + assert "from work_items" in sql + assert "src-draft-confirm-1" in sql + return _FakeScalarResult(42) + + handler = DraftConfirmHandler(type("Kernel", (), {"db": _FakeDB(), "tenant_id": 1})()) + owner_user_id = await handler._resolve_retryable_draft_owner_user_id( + mounted_draft=SimpleNamespace(latest_source_message_id="src-draft-confirm-1"), + mounted_topic=SimpleNamespace(last_message_id="src-wrong-newer-confirm", root_message_id="msg-draft-root"), + ) + + assert owner_user_id == 42 + + +@pytest.mark.asyncio +async def test_release_mounted_draft_binding_keeps_retryable_failed_confirm_in_pending_confirm() -> None: + class _FakeDB: + async def execute(self, query): # noqa: ARG002 + raise AssertionError("mounted rows are injected directly; execute should not be called") + + async def flush(self): + return None + + handler = DraftConfirmHandler(type("Kernel", (), {"db": _FakeDB(), "tenant_id": 1})()) + draft = SimpleNamespace( + id=8, + topic_workspace_id=77, + active_work_item_id=42, + confirm_message_id="msg-confirm-1", + state="executing", + draft_payload={"title": "跨境直播复盘助手"}, + ) + topic = SimpleNamespace( + id=77, + mode="plan", + status="active", + plan_status="executing", + pending_actions=[], + metadata_json={"active_draft_work_item_id": 42}, + ) + + await handler._release_mounted_draft_binding( + work_item_id=42, + final_state="failed", + mounted=(draft, topic), + ) + + assert draft.active_work_item_id is None + assert draft.confirm_message_id is None + assert draft.state == STATE_DRAFT_PENDING_CONFIRM + assert topic.status == "active" + assert topic.plan_status == STATE_DRAFT_PENDING_CONFIRM + assert topic.pending_actions == ["confirm_requirement_draft"] + assert topic.metadata_json["active_draft_work_item_id"] is None + assert topic.metadata_json["draft_lifecycle_state"] == "failed" + + @pytest.mark.asyncio async def test_draft_confirm_handler_uses_work_item_bound_mounted_draft_for_semantic_followup(monkeypatch) -> None: kernel = type("Kernel", (), {"db": SimpleNamespace(execute=object()), "tenant_id": 1})() diff --git a/backend/tests/test_mounted_ai_runtime_write_service.py b/backend/tests/test_mounted_ai_runtime_write_service.py index fe572fed..9ec06aeb 100644 --- a/backend/tests/test_mounted_ai_runtime_write_service.py +++ b/backend/tests/test_mounted_ai_runtime_write_service.py @@ -306,7 +306,7 @@ async def test_record_decision_and_finalize_run_persist_topic_mode_and_traces(db @pytest.mark.asyncio -async def test_record_orchestrator_decision_preserves_requirement_plan_mode_for_query_answer_turns(db_session) -> None: +async def test_record_orchestrator_decision_promotes_requirement_plan_route_for_requirement_plan_turns(db_session) -> None: service = MountedAIRuntimeWriteService(db_session) tracking = await service.begin_ingress_run( @@ -345,7 +345,7 @@ async def test_record_orchestrator_decision_preserves_requirement_plan_mode_for_ assert saved_topic.mode == "plan" assert saved_run.mode_after == "plan" - assert saved_topic.metadata_json["last_workflow_route"] == "query.answer" + assert saved_topic.metadata_json["last_workflow_route"] == "requirement_plan.collect" @pytest.mark.asyncio @@ -654,6 +654,54 @@ async def test_finalize_run_reconciles_semantic_packet_summary_requirement_card_ assert packet_card["last_round_options"]["field"] == "first_scope" +@pytest.mark.asyncio +async def test_finalize_run_updates_topic_last_workflow_route_from_requirement_plan_result(db_session) -> None: + service = MountedAIRuntimeWriteService(db_session) + + tracking = await service.begin_ingress_run( + tenant_id=1, + source_system="feishu", + source_message_id="om_message_plan_route_sync", + message_text="继续往下收。", + sender="林宇航", + channel_ref="chat_plan_route_sync", + thread_context={"root_message_id": "om_root_plan_route_sync"}, + source_sender_open_id="ou_plan_route_sync", + ) + tracking.topic_workspace.metadata_json = { + **(tracking.topic_workspace.metadata_json if isinstance(tracking.topic_workspace.metadata_json, dict) else {}), + "last_workflow_route": "query.answer", + "plan_mode": True, + } + tracking.topic_workspace.mode = "plan" + tracking.topic_workspace.plan_status = "collecting" + await db_session.flush() + + await service.finalize_run( + tracking=tracking, + result={ + "handled": True, + "status": "answered", + "classification": "question", + "workflow_route": "requirement_plan.collect", + "work_item_id": 902, + "direct_reply_text": "我先继续收下一轮关键信息。", + "rag_result": { + "answer": "我先继续收下一轮关键信息。", + "query_type": "plan", + }, + "requirement_plan_context": {"question_round": 3}, + }, + ) + await db_session.commit() + + saved_topic = (await db_session.execute(select(MountedTopicWorkspace))).scalar_one() + saved_run = (await db_session.execute(select(MountedConversationRun))).scalar_one() + + assert saved_topic.metadata_json["last_workflow_route"] == "requirement_plan.collect" + assert saved_run.execution_outcome["workflow_route"] == "requirement_plan.collect" + + @pytest.mark.asyncio async def test_finalize_run_rebinds_conversation_run_to_canonical_draft_artifact_topic(db_session) -> None: service = MountedAIRuntimeWriteService(db_session) diff --git a/backend/tests/test_semantic_context_assembler.py b/backend/tests/test_semantic_context_assembler.py index 0609ac34..c7350378 100644 --- a/backend/tests/test_semantic_context_assembler.py +++ b/backend/tests/test_semantic_context_assembler.py @@ -202,6 +202,46 @@ async def test_semantic_context_assembler_prefers_accepted_semantic_requirement_ assert assistant_turns[-1] == "已确认选项 A(单一层级区域经理),继续收第二轮关键信息。" +@pytest.mark.asyncio +async def test_semantic_context_assembler_prefers_draft_title_for_thread_summary_when_topic_facts_are_minimal(db_session) -> None: + topic = MountedTopicWorkspace( + tenant_id=7, + topic_key="feishu:chat_semantic:root:om_root_semantic_draft_summary", + source_system="feishu", + channel_ref="chat_semantic_draft_summary", + conversation_scope_key="feishu:chat_semantic_draft_summary:ou_semantic", + root_message_id="om_root_semantic_draft_summary", + mode="plan", + status="active", + plan_status="collecting", + facts_json={"plan_profile": "it_project"}, + metadata_json={}, + ) + db_session.add(topic) + await db_session.flush() + db_session.add( + MountedDraftWorkspace( + tenant_id=7, + topic_workspace_id=int(topic.id), + state="collecting", + draft_version=1, + requirement_card={"title": "门店库存异常晨报助手", "goal": "先收第一版需求"}, + draft_payload=None, + required_confirmations=[], + ) + ) + await db_session.commit() + + packet = await SemanticContextAssembler(db_session).assemble( + tenant_id=7, + operator_user_id=99, + topic_workspace_id=int(topic.id), + latest_user_message="继续往下收。", + ) + + assert packet.thread.thread_summary == "门店库存异常晨报助手" + + @pytest.mark.asyncio async def test_semantic_context_assembler_adds_draft_followup_ingress_actions(db_session) -> None: topic = MountedTopicWorkspace( diff --git a/docs/architecture/ARCHITECTURE_INVENTORY.md b/docs/architecture/ARCHITECTURE_INVENTORY.md index 0b1dd77d..3c8d4cb9 100644 --- a/docs/architecture/ARCHITECTURE_INVENTORY.md +++ b/docs/architecture/ARCHITECTURE_INVENTORY.md @@ -139,6 +139,7 @@ - `frontend/src/components/sstc/` 继续作为 SSTC 共享组件目录维护。 ## 6. 工具与运行库存 +- `backend/scripts/repair_requirement_plan_history.py` - `scripts/start_model_probe.sh` - `scripts/model_probe.py` - `scripts/gen_task_brief.sh` diff --git a/docs/architecture/RUNTIME_GUARDRAILS.md b/docs/architecture/RUNTIME_GUARDRAILS.md index 300f3b09..e3a67a0d 100644 --- a/docs/architecture/RUNTIME_GUARDRAILS.md +++ b/docs/architecture/RUNTIME_GUARDRAILS.md @@ -79,12 +79,15 @@ - AI PMO ingress 的 mixed sidecar 预检查不得拦截显式 `reply/thread` 下的低信息量协议回复(如 `A/B/C`、`我选C`、`就C吧`);这类消息必须优先交给 active-context / requirement-plan 主链处理,禁止先生成 `[回答]...[已收到需求]` 的 mixed 拼接回复污染主结果。 - orphan requirement-plan followup 在显式 `reply/thread` 存在时,必须先尝试从 reply 命中的 chat context 恢复或升格 requirement workspace;只有恢复失败时才允许回“当前没有进行中的 A/B/C 收敛轮次”。 - Feishu 显式 `reply/thread` 到机器人消息时,ingress 与 mounted runtime 必须先通过 `feishu_reply_outbox.sent_message_id -> source_message_id` 归一化 `root_message_id/reply_to_message_id`,并在后续 active-context / requirement-plan / draft 主链里复用同一份 canonical thread context;禁止直接用机器人 `sent_message_id` 新开 chat topic,导致 requirement-plan / draft 线程分叉。 +- requirement-plan 的唯一业务真源固定为 `MountedDraftWorkspace.requirement_card`:收敛轮次、已确认字段、`round_option_maps`、`last_round_options`、`source_material` 等业务状态只能从 draft 或结构化 `requirement_plan_context` 恢复;`mounted_topic_workspaces.facts_json` 只允许保留线程索引级最小字段(当前限定为 `title / plan_profile`),`work_items.execution_result` 与 `mounted_conversation_runs.final_reply` 仅作审计/回放,禁止再从 assistant prose、topic facts 或 run 文案反推需求字段。 +- requirement-plan 的运行时路由命名也必须单独收口:收敛轮次统一写 `requirement_plan.collect`,草稿确认前的结构化草稿统一写 `requirement_plan.draft`;`query.answer` 只保留给通用问答,禁止在 requirement-plan 主链里继续借用旧 query route 记账。 - 飞书历史队列协议已停用:卡片动作与文本 followup 只允许走 `work_item_id + work_item.resume/draft_confirm` 主链。 - AI PMO 写操作默认先生成草稿并进入 `draft_pending_confirm`,未确认前禁止自动执行入库。 +- `project_create.create` 与 requirement draft confirm 的任务拆解是从属子步骤,不再允许阻塞项目、主信号、来源挂载与宪章的确定性创建。任务生成链必须先按 `routing_meta(tier_alias=low, response_contract=structured, execution_mode=local_planner)` 尝试,并在结构质量不足、空任务、不可映射技能或 provider 失败时升级 `high`;若高阶仍失败,运行时必须显式写回 `task_generation.status=pending` 与真实失败原因,保持 `draft_pending_confirm` 可重试,禁止返回默认任务、模板任务或把整条确认入库伪装成成功/失败错态。 ### 3.6 AdaptiveRAG / Query 主链 - `POST /rag/ask` 在命中 `business_review/correction_escalation` 时,必须强制留在 `query.read_only` 主链,禁止误送到写操作草稿链。 -- Feishu 新消息即使被低阶 LLM 先判成写动作,只要统一语义规划层命中 `requirement_plan/business_review/correction_escalation`,就必须在 ingress 阶段强制拉回 `query.answer`。 +- Feishu 新消息即使被低阶 LLM 先判成写动作,只要统一语义规划层命中 `requirement_plan/business_review/correction_escalation`,就必须在 ingress 阶段强制拉回受控问答主链;其中 requirement-plan 收敛轮次必须落到 `requirement_plan.collect / requirement_plan.draft`,不得再回写为 `query.answer`。 - 飞书群聊 `query/followup` 同步直答只允许显式触发:`@PMFlow`、显式 `reply_to_message_id`、卡片动作或显式 `work_item_id`;无显式触发时不得同步直答,也不得写入 followup QA 桶。 - Feishu `query/followup` 会话键在命中 `reply/thread` 锚点时必须优先使用 thread key;仅在没有显式锚点时才回落到默认的 `chat + sender` scope key。 - AdaptiveRAG 命中显式 `reply/thread` 锚点后,问题改写必须优先使用该锚点;`session_state.last_subject / last_entity_ids` 只允许在没有显式锚点时参与续写,禁止旧会话主题覆盖显式引用上下文。