From afa51fc45aff5f2a47a2488ec46aa52da115e897 Mon Sep 17 00:00:00 2001 From: Yuuuu <1569752928@qq.com> Date: Sat, 18 Apr 2026 14:13:34 +0800 Subject: [PATCH] feat: unify project operating context and polish Feishu QA handoff --- CLAUDE.md | 9 +- backend/app/api/ai_pmo_api/routes_core.py | 10 + backend/app/api/rag_ask_contract.py | 6 + backend/app/config/ai_prompts.py | 14 +- .../app/config/ai_prompts_catalog/__init__.py | 4 +- .../app/config/ai_prompts_catalog/part_01.py | 74 ++ .../app/schemas/project_operating_context.py | 33 + backend/app/schemas/world_model.py | 8 + .../mixin_answer_mainline.py | 37 + .../mixin_answer_payload.py | 239 +++++ .../mixin_answer_retrieval.py | 111 ++- .../adaptive_rag_service/mixin_core_intent.py | 85 +- .../mixin_fast_answer_semantic_p2.py | 34 +- .../mixin_followup_anchor.py | 11 +- .../mixin_project_fit_brief.py | 232 +++++ .../mixin_qa_session_list_ops_p1.py | 16 + .../mixin_query_route_gateway.py | 164 +++- .../services/adaptive_rag_service/service.py | 2 + .../adaptive_rag_service/shared_utils.py | 14 +- ...i_pmo_kernel_mixins_world_model_session.py | 12 + .../ai_pmo_kernel_ingress_flow/mixin_entry.py | 133 +++ .../mixin_builder.py | 12 + .../project_operating_context_builder.py | 106 +++ .../external_query_service/gateway.py | 23 +- .../external_query_service/planner.py | 115 ++- .../external_query_service/presenter.py | 52 ++ .../external_query_service/schemas.py | 36 + .../mixin_receive_routing.py | 865 ++++++++++++++++- .../mixin_reply_outbox.py | 12 +- .../feishu_integration_service/shared.py | 7 +- .../test_adaptive_rag_mainline_contract.py | 799 +++++++++++++++- backend/tests/test_ai_pmo_ingress_mainline.py | 388 +++++++- .../tests/test_ai_pmo_qa_http_mainline_e2e.py | 174 ++++ backend/tests/test_external_query_planner.py | 155 +++ .../tests/test_feishu_card_action_contract.py | 330 +++++++ .../test_feishu_pipeline_queue_contract.py | 11 +- ...test_project_operating_context_contract.py | 50 + backend/tests/test_rag_route_contract.py | 58 +- docs/architecture/ARCHITECTURE_INVENTORY.md | 13 +- docs/architecture/CODE_REFERENCE_INDEX.md | 9 + docs/architecture/RUNTIME_GUARDRAILS.md | 11 +- ...perating-context-and-three-lane-handoff.md | 883 ++++++++++++++++++ .../2026-04-17-feishu-problem-inventory.md | 590 ++++++++++++ ...nified-project-ai-handoff-success-cases.md | 816 ++++++++++++++++ .../2026-04-17-unified-project-ai-handoff.md | 350 +++++++ frontend/src/api/aiSteward.ts | 48 + .../ai-steward/FloatingQAChat.test.tsx | 106 +++ .../components/ai-steward/FloatingQAChat.tsx | 131 +++ .../ai-steward/QAConversationStatusBar.tsx | 48 +- .../ai-steward/QAResponseMetaCard.test.tsx | 75 ++ .../ai-steward/QAResponseMetaCard.tsx | 157 ++++ 51 files changed, 7617 insertions(+), 61 deletions(-) create mode 100644 backend/app/schemas/project_operating_context.py create mode 100644 backend/app/services/adaptive_rag_service/mixin_project_fit_brief.py create mode 100644 backend/app/services/ai_pmo/project_operating_context_builder.py create mode 100644 backend/tests/test_project_operating_context_contract.py create mode 100644 docs/superpowers/plans/2026-04-17-unified-project-operating-context-and-three-lane-handoff.md create mode 100644 docs/superpowers/specs/2026-04-17-feishu-problem-inventory.md create mode 100644 docs/superpowers/specs/2026-04-17-unified-project-ai-handoff-success-cases.md create mode 100644 docs/superpowers/specs/2026-04-17-unified-project-ai-handoff.md create mode 100644 frontend/src/components/ai-steward/QAResponseMetaCard.test.tsx diff --git a/CLAUDE.md b/CLAUDE.md index 600f8a82..e50e23a6 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -56,7 +56,8 @@ backend/ - 当前后端重点入口优先看: - `backend/app/api/` 下的 `*_api/` 子包与聚合 router - `backend/app/services/ai_pmo/`(AI PMO 主链、Mounted Runtime、semantic context) - - `backend/app/services/adaptive_rag_service/`(只读问答主链;`mixin_query_route_gateway.py` 负责 non-plan `query.answer` 的 internal/external/uncertain 来源合同归一化、contract 审计与显式来源回复合同;`mixin_answer_retrieval.py` 现已把 `route_mode=external` 接到独立外部查询主链;internal 路径新增 `internal_query_context.py` 里的 `InternalContextPacket` 作为 `Operation Contract -> Target Resolver -> Query DSL Plan -> 受控执行器 -> semantic facts 优先 -> verifier` 的运行时单一真源) + - `backend/app/services/ai_pmo/project_operating_context_builder.py` + `backend/app/schemas/project_operating_context.py`(统一 `ProjectOperatingContext / ProjectFitBrief / ExecutionRecipe` 合同;`/rag/ask`、`/ai-pmo/process-text`、kernel ingress 会先补当前项目/仓库/线程锚点,再给 Query Route Gateway、fit brief builder 与 answer payload 消费) + - `backend/app/services/adaptive_rag_service/`(只读问答主链;`mixin_query_route_gateway.py` 负责 non-plan `query.answer` 的 internal/external/uncertain 来源合同归一化、contract 审计与显式来源回复合同;`mixin_project_fit_brief.py` 负责 current-project customer-safe fit brief;`mixin_answer_retrieval.py` 现已把 `route_mode=external` 接到独立外部查询主链,并在 payload 暴露 `fit_brief / external_recommendation`;internal 路径新增 `internal_query_context.py` 里的 `InternalContextPacket` 作为 `Operation Contract -> Target Resolver -> Query DSL Plan -> 受控执行器 -> semantic facts 优先 -> verifier` 的运行时单一真源) - `backend/app/api/rag_api/routes_route_governance.py`(manager-only 的 route/context 治理接口:`/rag/query-route-governance/summary|items|items/{id}`,只读暴露 `query_route_memories` 的 summary/list/detail) - `backend/app/services/external_query_service/`(external `query.answer` 主链:`External Query Planner -> External Query Gateway -> External Provider Registry -> MCP 优先 -> bounded fallback live search -> verifier -> ExternalContextPacket -> ExternalAnswerPresenter`;其中 `schemas.py` 负责结构化 plan/evidence/context packet,`presenter.py` 负责 external-only 回答生成) - `backend/app/services/mcp_config/`(MCP 模板接入中心:catalog、bundle 持久化、后端直连 runtime probe / Microsoft Learn 检索桥接、Local Bridge 接入说明与本机确认状态) @@ -68,7 +69,7 @@ backend/ - `backend/app/services/signal/`(信号接入、编排、planner、repair) - 本轮新增的问答路由审计存储为 `backend/app/models/query_route_memory.py`,通过 `backend/alembic/versions/20260415_add_query_route_memories.py` 建表;只用于 route audit / feedback 回灌 / 离线评估,不做在线自动调参。 - 本轮新增的语义事实读模型为 `backend/app/models/semantic_fact.py` 与 `backend/app/models/semantic_fact_extraction_run.py`,通过 `backend/alembic/versions/20260415_add_semantic_facts.py` 建表;用于内部查询的 facts-first 命中与异步抽取审计。 -- 本轮新增的外部查询 fallback 与展示回归测试位于 `backend/tests/test_external_query_planner.py`、`backend/tests/test_mcp_external_query_bridge.py` 与 `backend/tests/test_adaptive_rag_mainline_contract.py`;它们一起锁定“Query Route Gateway 不再跑 runtime probes、External Query Planner fallback 不再按对象重分类、external answer presenter 不再回流 `industry_trend/external_trend`、external freshness follow-up 不再靠硬编码词表”。 +- 本轮新增的外部查询 fallback、fit brief 与展示回归测试位于 `backend/tests/test_external_query_planner.py`、`backend/tests/test_mcp_external_query_bridge.py`、`backend/tests/test_internal_query_context_packet.py` 与 `backend/tests/test_adaptive_rag_mainline_contract.py`;它们一起锁定“Query Route Gateway 不再跑 runtime probes、External Query Planner fallback 不再按对象重分类、fit brief 会显式进入 external planner、external answer presenter 必须区分 `verifier_blocked / no_relevant_results / retrieval_failed / indirect_only`、external freshness follow-up 不再靠硬编码词表”。 - 详细目录迁移、兼容壳清理与新增模块清单见库存文档。 ## 5. 前端结构(`frontend/`) @@ -92,6 +93,7 @@ frontend/ - `frontend/src/api/`(统一 API 封装) - `frontend/src/pages/inbox/`(消息台) - `frontend/src/pages/ai-steward/`(AI 管家) + - `frontend/src/components/ai-steward/`(问答浮窗、状态条、回答元卡片;`FloatingQAChat.tsx` 现会透传并回放 `project_operating_context / execution_recipe / fit_brief / external_recommendation / recommended_next_step`,`QAConversationStatusBar.tsx` 与 `QAResponseMetaCard.tsx` 负责显性展示) - `frontend/src/pages/ai-config/mcp-config/`(AI Config -> Governance Tools 下的 MCP 模板接入中心) - `frontend/src/pages/query-route-governance/`(AI Config -> Governance Tools 下的 route/context 治理页) - `frontend/src/pages/semantic-facts-board/`(AI Config -> Governance Tools 下的 semantic facts 治理页) @@ -214,6 +216,7 @@ uvicorn app.main:app --reload --host 0.0.0.0 --port 8000 ## 12. 技术债务清单 - [x] `mixin_auto.py` `_safe_int` NameError:已通过显式导入 `_safe_int` + 回归测试锁定,避免后续共享导出调整导致运行时未定义 - [x] AI 入口主链已统一:飞书长连接(`capture_from_feishu`)、`/ai-pmo/process-text` 与 `/rag/ask` 已统一收敛到 `AIPMOKernelService.process_ingress` 主链;`/rag/ask` 通过 `rag_ask_contract.build_kernel_ingress_kwargs` 透传 RAG 上下文(`session_id/new_session/thinking_mode/action_mode/...`),query 执行后由 `build_rag_response_payload` 保持 HTTP 响应兼容,回归由 `test_rag_route_contract.py`、`test_ai_pmo_qa_http_mainline_e2e.py`、`test_ai_pmo_ingress_mainline.py` 锁定 +- [x] Unified Project Operating Context 已接到问答入口:`/rag/ask`、`/ai-pmo/process-text` 与 kernel ingress 现会先透传最小 `project_operating_context`(当前项目、当前仓库摘要、页面/线程锚点),AdaptiveRAG 的 `Query Route Gateway` 进一步产出 `execution_recipe`;external 主链会在需要时补 `fit_brief`,回答 payload 同步暴露 `project_operating_context / execution_recipe / fit_brief / external_recommendation / recommended_next_step / handoff_mode`,回归由 `test_project_operating_context_contract.py`、`test_rag_route_contract.py`、`test_ai_pmo_qa_http_mainline_e2e.py`、`test_external_query_planner.py`、`test_mcp_external_query_bridge.py`、`test_adaptive_rag_mainline_contract.py` 锁定 - [x] 飞书入口层耦合已收敛:飞书真实入口已拆为三段职责链(预处理 `mixin_receive_prepare.py` / capture 执行 `mixin_receive_capture_flow.py` / 编排 `mixin_receive.py`);`mixin_receive.py` 不再混合传输解析、附件标准化、capture 异常兜底与收尾回执逻辑,分层边界通过 `test_feishu_receive_orchestration_contract.py` 锁定 - [x] `task.create` 已升级为一等 typed intent:`IntentResolver/RouteResolver` 新增 `task.create` 常量与 `task.create` 路由族;TypedExecutor 已接入任务落库执行(含 `project_id`/任务标题槽位守卫、对齐 Charter 校验、`persist_task` 写入);`WorkflowRegistry`、`ReplyRenderer`、`followup` 校验与草稿确认闭环已纳入 `task.create`;并补 `test_ai_pmo_task_intent_contract.py` 回归锁定 - [x] `task.create` 已接入消息级幂等账本:Feishu `source_message_id/feishu_message_id` 作为 `task.create` 账本 key,重复确认只回放同一结果,不再重复落库;回归由 `test_feishu_pipeline_queue_contract.py` 锁定 @@ -221,7 +224,7 @@ uvicorn app.main:app --reload --host 0.0.0.0 --port 8000 - [x] AdaptiveRAG 职责已完成首轮收敛:`answer_question` 入口已分层为 `prepare runtime` 与 `base mainline` 两段,写确认逻辑通过 `_should_trigger_write_ack_gate` 独立门控;基础问答主路径不再与写确认判断混写在同一函数体。分层契约由 `test_adaptive_rag_mainline_contract.py` 锁定(read 绕过 ack、write 命中 ack、feishu write 维持主链) - [x] 执行层已形成统一执行账本与参数来源约束:ingress 执行路径统一通过 `KernelIngressStorageMixin` 生成 `execution_result.action_contract`(固定 `param_source=decision.execution_plan.actions+workflow_route+execution_result`)并写入 `operation_execution_ledger`(`applied/pending_manual/pending_confirmation/failed`);`mixin_auto.py`、`mixin_prepare_manual.py` 的 `operation_code` 日志来源已对齐 `action_contract`,回归由 `test_ai_pmo_execution_ledger_contract.py` 锁定 - [x] QA/RAG 路由契约双轨已收敛:`/ai-steward/rag/ask` 兼容入口已移除,问答统一收敛到 `POST /rag/ask`;参数构造与 tenant 解析收口在 `backend/app/api/rag_ask_contract.py`,前端 `FloatingQAChat` 与 Context Cache API 目录均已对齐,`backend/tests/test_rag_route_contract.py` 与 `backend/tests/test_context_cache_profile_catalog.py` 已锁定回归 -- [x] 三模式交互上下文基线已显性化:`/rag/ask`、QA session detail 与 session state replay 现统一透出 `route_decision / clarification_code / clarification_candidates / answer_mode / grounding_status / evidence_pack`;前端 `FloatingQAChat` 新增 `QAConversationStatusBar` 与 `QAResponseMetaCard`,显式展示当前对象、来源模式、证据状态与澄清按钮;`/ai-pmo/process-text` 与 `GET /ai-pmo/work-items` 现同步投影 `plan_mode / plan_status / requirement_plan_context_summary` 到 Inbox 展开区,回归由 `test_ai_pmo_qa_http_mainline_e2e.py`、`test_ai_pmo_ingress_mainline.py`、`FloatingQAChat.test.tsx`、`InboxListItem.test.tsx` 锁定 +- [x] 三模式交互上下文基线已显性化:`/rag/ask`、QA session detail 与 session state replay 现统一透出 `route_decision / clarification_code / clarification_candidates / answer_mode / grounding_status / evidence_pack / project_operating_context / execution_recipe / fit_brief / external_recommendation / handoff_mode / can_enter_plan / recommended_next_step`;前端 `FloatingQAChat` 会在即时回答与历史回放两条路径都接入这批字段,`QAConversationStatusBar` 与 `QAResponseMetaCard` 显式展示当前对象、来源模式、仓库/执行通道、项目画像、外部推荐状态与下一步;`/ai-pmo/process-text` 与 `GET /ai-pmo/work-items` 现同步投影 `plan_mode / plan_status / requirement_plan_context_summary` 到 Inbox 展开区,回归由 `test_ai_pmo_qa_http_mainline_e2e.py`、`test_ai_pmo_ingress_mainline.py`、`FloatingQAChat.test.tsx`、`QAResponseMetaCard.test.tsx`、`InboxListItem.test.tsx` 锁定 - [x] AI 管家浮窗交互已与 Grounding Runtime 对齐:回答区已切到 markdown 渲染,顾问标签升级为显式交互按钮;`consultant_mode_preference` 只用于 `/rag/ask` 内部重答,`允许执行` 明确交接到 `/ai-pmo/process-text`,避免 query/execute 边界回退到旧的一锅煮 - [x] Retrieval source registry / metadata 已统一:AdaptiveRAG 检索链已建立统一 source registry / metadata normalizer,`citations[]/sources_used[]` 直接消费标准字段 `source_type/source_id/chunk_id/target_url/project_id/updated_at/permission_scope/source_priority`;`doc/url/feishu/pmflow/graph/db` 的来源元数据与排序键已收口,并由 `test_rag_route_contract.py`、`test_adaptive_rag_mainline_contract.py` 锁定 - [x] 语义治理 baseline 已清零:`backend/app` 运行时代码中的 `looks_like_* / *_HINTS / *_KEYWORDS` 残留已清空,主链统一改为对象/上下文绑定 + `SemanticContextPacket -> SemanticDecision -> gatekeeper`;`scripts/semantic_lint_baseline.txt` 已清空,并由 `scripts/semantic_lint.py` 与 `backend/tests/test_semantic_lint.py` 做仓库级守门 diff --git a/backend/app/api/ai_pmo_api/routes_core.py b/backend/app/api/ai_pmo_api/routes_core.py index 85d2ff7e..06cd7482 100644 --- a/backend/app/api/ai_pmo_api/routes_core.py +++ b/backend/app/api/ai_pmo_api/routes_core.py @@ -9,6 +9,7 @@ from .helpers_inbox_payload import ( _serialize_work_item_summary, ) from .schemas import PMOProcessTextRequest +from ...services.ai_pmo.project_operating_context_builder import ProjectOperatingContextBuilder @router.post("/process-text") async def process_text( @@ -18,6 +19,12 @@ async def process_text( tenant_id: int = Depends(get_tenant_id), ): service = AIPMOKernelService(db, tenant_id=tenant_id) + project_operating_context = ProjectOperatingContextBuilder().build_from_ingress( + project_hint_id=request.project_id, + rag_request_context={ + "project_id": request.project_id, + }, + ) result = await service.process_ingress( source_system="pmflow_manual", source_message_id=request.source_message_id, @@ -28,6 +35,9 @@ async def process_text( captured_at=None, chat_type="manual", project_hint_id=request.project_id, + ingress_options={ + "project_operating_context": project_operating_context.model_dump(), + }, ) if not result.get("handled"): raise HTTPException(status_code=400, detail=str(result.get("reason") or "AI PMO kernel 未处理该输入")) diff --git a/backend/app/api/rag_ask_contract.py b/backend/app/api/rag_ask_contract.py index 8cef740d..8e073b79 100644 --- a/backend/app/api/rag_ask_contract.py +++ b/backend/app/api/rag_ask_contract.py @@ -5,6 +5,7 @@ from uuid import uuid4 from ..models import User from ..schemas.rag import RAGAskRequest +from ..services.ai_pmo.project_operating_context_builder import ProjectOperatingContextBuilder def resolve_effective_tenant_id(current_user: User, fallback_tenant_id: int) -> int: @@ -74,6 +75,10 @@ def _default_evidence_pack(*, answer: str, tool_trace: list[dict[str, Any]]) -> def build_kernel_ingress_kwargs(request: RAGAskRequest, current_user: User) -> dict[str, Any]: rag_request_context = build_answer_question_kwargs(request=request, current_user=current_user) + project_operating_context = ProjectOperatingContextBuilder().build_from_ingress( + project_hint_id=request.project_id, + rag_request_context=rag_request_context, + ) return { "source_system": str(request.asked_via or "pmflow").strip() or "pmflow", "source_message_id": f"rag-ask-{uuid4().hex}", @@ -86,6 +91,7 @@ def build_kernel_ingress_kwargs(request: RAGAskRequest, current_user: User) -> d "project_hint_id": request.project_id, "ingress_options": { "rag_request_context": rag_request_context, + "project_operating_context": project_operating_context.model_dump(), }, } diff --git a/backend/app/config/ai_prompts.py b/backend/app/config/ai_prompts.py index 3269eecb..1e8f4806 100644 --- a/backend/app/config/ai_prompts.py +++ b/backend/app/config/ai_prompts.py @@ -15,6 +15,8 @@ from .ai_prompts_catalog import ( PROMPT_CODE_AUDIT, PROMPT_CONTEXT_INSIGHTS, PROMPT_FEISHU_HUMANIZED_REPLY, + PROMPT_FEISHU_QA_NATURAL_REPLY, + PROMPT_FEISHU_QA_NATURAL_REPLY_REWRITE, PROMPT_FEISHU_PROJECT_SIGNAL_CLASSIFICATION, PROMPT_FEISHU_LEVEL_CLASSIFICATION, PROMPT_FEISHU_SIGNAL_EXTRACTION, @@ -323,6 +325,7 @@ PROMPT_EXTERNAL_QUERY_PLAN = """ - `answer_contract` 只能是:direct_fact、summary_with_sources、compare_with_sources、clarify。 - `search_queries` 必须是简短、可直接执行的查询短语。 - `preferred_domains` 只放域名,不要带协议头。 +- 若给了 `fit_brief_summary / project_scope_hint / recommendation_goal / project_object_anchors`,优先把它们当作查询约束,而不是忽略当前项目上下文。 - 若用户问题明显要求最新、当前、今天、最近等外部信息,`freshness_required=true`。 - 若问题更像对比/比较,`requires_multi_source=true` 且 `answer_contract=compare_with_sources`。 - 结构化失败时优先保守,不要编造实体。 @@ -341,7 +344,11 @@ JSON schema: "search_queries": ["string"], "result_limit": 3, "requires_multi_source": false, - "clarify_on_ambiguity": false + "clarify_on_ambiguity": false, + "fit_brief_summary": "string", + "project_scope_hint": "string | null", + "recommendation_goal": "string | null", + "project_object_anchors": ["string"] }} question: @@ -350,6 +357,9 @@ question: query_semantics: {query_semantics} +fit_brief: +{fit_brief} + fallback_queries: {fallback_queries} """.strip() @@ -805,6 +815,8 @@ __all__ = [ "PROMPT_CONTEXT_INSIGHTS", "PROMPT_FEISHU_REQUIREMENT_ANALYSIS", "PROMPT_FEISHU_HUMANIZED_REPLY", + "PROMPT_FEISHU_QA_NATURAL_REPLY", + "PROMPT_FEISHU_QA_NATURAL_REPLY_REWRITE", "PROMPT_FEISHU_PROJECT_SIGNAL_CLASSIFICATION", "PROMPT_FEISHU_LEVEL_CLASSIFICATION", "PROMPT_FEISHU_SIGNAL_EXTRACTION", diff --git a/backend/app/config/ai_prompts_catalog/__init__.py b/backend/app/config/ai_prompts_catalog/__init__.py index 475b42a3..3e2a9263 100644 --- a/backend/app/config/ai_prompts_catalog/__init__.py +++ b/backend/app/config/ai_prompts_catalog/__init__.py @@ -1,6 +1,6 @@ from __future__ import annotations -from .part_01 import PROMPT_IMPACT_ANALYSIS, PROMPT_STRATEGIC_ANALYSIS_HEADER, PROMPT_RAG_CONTEXT_HEADER, PROMPT_RAG_CONTEXT_ITEM, PROMPT_CODE_AUDIT, PROMPT_CONTEXT_INSIGHTS, PROMPT_FEISHU_HUMANIZED_REPLY, PROMPT_FEISHU_PROJECT_SIGNAL_CLASSIFICATION, PROMPT_FEISHU_LEVEL_CLASSIFICATION +from .part_01 import PROMPT_IMPACT_ANALYSIS, PROMPT_STRATEGIC_ANALYSIS_HEADER, PROMPT_RAG_CONTEXT_HEADER, PROMPT_RAG_CONTEXT_ITEM, PROMPT_CODE_AUDIT, PROMPT_CONTEXT_INSIGHTS, PROMPT_FEISHU_HUMANIZED_REPLY, PROMPT_FEISHU_QA_NATURAL_REPLY, PROMPT_FEISHU_QA_NATURAL_REPLY_REWRITE, PROMPT_FEISHU_PROJECT_SIGNAL_CLASSIFICATION, PROMPT_FEISHU_LEVEL_CLASSIFICATION from .part_02 import PROMPT_FEISHU_SIGNAL_EXTRACTION, PROMPT_FEISHU_PROJECT_PREFILL, PROMPT_PROJECT_REQUIREMENT_EXTRACTION, PROMPT_FEISHU_TASK_ORCHESTRATION, PROMPT_SIGNAL_SOURCE_ONBOARDING, PROMPT_AI_WORKER_SIGNAL_SOURCE_EXECUTION, PROMPT_AI_PMO_INTENT_CLASSIFICATION, PROMPT_AI_PMO_SIGNAL_BATCH_ANALYSIS, PROMPT_DRAFT_GENERATOR, PROMPT_RAG_INTENT_CLASSIFICATION from .part_03 import PROMPT_RAG_ANSWER, PROMPT_DECISION_QA, PROMPT_RAG_DB_PROJECT_QA, PROMPT_GENERATE_CHARTER, PROMPT_DECOMPOSE_TASKS, PROMPT_RECOMMEND_ASSIGNEES, PROMPT_ALIGNMENT_SCORE, PROMPT_COGNITIVE_CONFLICTS, PROMPT_EVALUATE_MODEL_REQUIREMENT, PROMPT_ANALYZE_CAPABILITY_GAP, PROMPT_ANALYZE_COST_ROI from .part_04 import PROMPT_ANALYZE_COST_OPTIMIZATION, PROMPT_ASSESS_MODEL_UPDATE_IMPACT, PROMPT_EXPERIMENT_SYNC_DECISION, PROMPT_SKILL_SCORE_UPDATE, PROMPT_WORKLOAD_OPTIMIZATION, PROMPT_ANALYZE_FOOD_CALORIES, PROMPT_SIGNAL_PLANNER @@ -25,6 +25,8 @@ __all__ = [ "PROMPT_CODE_AUDIT", "PROMPT_CONTEXT_INSIGHTS", "PROMPT_FEISHU_HUMANIZED_REPLY", + "PROMPT_FEISHU_QA_NATURAL_REPLY", + "PROMPT_FEISHU_QA_NATURAL_REPLY_REWRITE", "PROMPT_FEISHU_PROJECT_SIGNAL_CLASSIFICATION", "PROMPT_FEISHU_LEVEL_CLASSIFICATION", "PROMPT_FEISHU_SIGNAL_EXTRACTION", diff --git a/backend/app/config/ai_prompts_catalog/part_01.py b/backend/app/config/ai_prompts_catalog/part_01.py index 7eeb6593..b0639183 100644 --- a/backend/app/config/ai_prompts_catalog/part_01.py +++ b/backend/app/config/ai_prompts_catalog/part_01.py @@ -180,6 +180,78 @@ PROMPT_FEISHU_HUMANIZED_REPLY = _compose_prompt( TEXT_OUTPUT_GUARDRAIL, ) +PROMPT_FEISHU_QA_NATURAL_REPLY = """ +你要先为一条飞书 QA 结果产出一个很小的结构化分析包,不要直接写最终回复。 + +输入信息: +- 用户原问题:{question} +- 当前回答结论:{answer_summary} +- 当前项目摘要:{project_summary} +- 可选项目实体(JSON):{project_entities} +- 外部判断:{external_assessment} +- 建议下一步:{next_step} + +输出 JSON: +{{ + "conclusion": "一句人话结论,不超过40字", + "focus_entities": [ + {{ + "name": "必须从可选项目实体中选择", + "stance": "better_fit|not_fit|anchor|dependent|blocked|relevant", + "why": "它为什么和当前判断直接相关,不超过50字" + }} + ], + "missing_mapping": "还缺哪一层映射,不超过60字", + "next_step": "若仍值得继续,给一句自然下一步;否则输出空字符串" +}} + +硬性禁令: +1. `focus_entities` 最多 2 个,至少 1 个。 +2. `name` 只能从“可选项目实体”里选,禁止编造、扩写、改写或补充新实体。 +3. 优先选最能支撑判断的 1-2 个实体,不要把项目里所有任务和信号源都列出来。 +4. 不允许脑补项目目标、方案层次、执行动作或系统接口。 +5. 只输出 JSON,不要输出解释、Markdown、标题或正文。 +""" +PROMPT_FEISHU_QA_NATURAL_REPLY = _compose_prompt( + PMFLOW_PMO_STYLE_BASE, + PROMPT_FEISHU_QA_NATURAL_REPLY, + TEXT_OUTPUT_GUARDRAIL, +) + +PROMPT_FEISHU_QA_NATURAL_REPLY_REWRITE = """ +你现在已经拿到一个受控分析包,只负责把它改写成自然、克制、像同事在解释判断结果的中文回复。 + +输入信息: +- 用户原问题:{question} +- 分析包(JSON):{analysis_plan} + +要求: +1. 输出 2-3 段自然中文。 +2. 第一段直接进入判断,不要固定抬头。 +3. 只能围绕分析包里的 `focus_entities` 来写,不允许增加新实体,不允许把实体写成清单。 +4. `missing_mapping` 必须明确说出来;如果 `next_step` 为空,就不要强行补下一步。 +5. 结论必须和分析包逐项对应,不能发散、不能补戏、不能装懂。 + +硬性禁令: +1. 禁止固定开头,例如: + - 先说我的判断 + - 我先说结论 + - 这事可以继续看 + - 如果你要,我可以 +2. 禁止使用标签句式: + - 当前项目: + - 外部判断: + - 下一步: +3. 禁止空话套话,例如“从某种意义上说”“整体来看”“综合来看”“更稳妥的做法”。 +4. 禁止把项目里所有任务、信号源平铺展开。 +5. 只输出最终回复正文,不要解释写法,不要输出 Markdown 标题或列表。 +""" +PROMPT_FEISHU_QA_NATURAL_REPLY_REWRITE = _compose_prompt( + PMFLOW_PMO_STYLE_BASE, + PROMPT_FEISHU_QA_NATURAL_REPLY_REWRITE, + TEXT_OUTPUT_GUARDRAIL, +) + PROMPT_FEISHU_PROJECT_SIGNAL_CLASSIFICATION = """ 你是 PMFlow 的项目需求信号分流器(分类层)。请判断消息是否为有效需求,并识别是否能明确归属已有项目。 @@ -258,6 +330,8 @@ __all__ = [ "PROMPT_CODE_AUDIT", "PROMPT_CONTEXT_INSIGHTS", "PROMPT_FEISHU_HUMANIZED_REPLY", + "PROMPT_FEISHU_QA_NATURAL_REPLY", + "PROMPT_FEISHU_QA_NATURAL_REPLY_REWRITE", "PROMPT_FEISHU_PROJECT_SIGNAL_CLASSIFICATION", "PROMPT_FEISHU_LEVEL_CLASSIFICATION", ] diff --git a/backend/app/schemas/project_operating_context.py b/backend/app/schemas/project_operating_context.py new file mode 100644 index 00000000..3eec08ea --- /dev/null +++ b/backend/app/schemas/project_operating_context.py @@ -0,0 +1,33 @@ +"""Unified project operating context contracts.""" + +from __future__ import annotations + +from typing import Any, Literal + +from pydantic import BaseModel, Field + + +class ProjectOperatingContext(BaseModel): + context_version: str = "v1" + current_project_id: int | None = None + current_repo_scope: str | None = None + page_anchor: dict[str, Any] = Field(default_factory=dict) + thread_anchor: dict[str, Any] = Field(default_factory=dict) + fit_scope: Literal["current_project_and_repo"] = "current_project_and_repo" + decision_strategy: Literal["answer_then_offer_plan"] = "answer_then_offer_plan" + + +class ProjectFitBrief(BaseModel): + project_summary: str | None = None + repo_summary: str | None = None + current_capabilities: list[str] = Field(default_factory=list) + signal_source_anchors: list[str] = Field(default_factory=list) + constraints: list[str] = Field(default_factory=list) + recommendation_focus: list[str] = Field(default_factory=list) + + +class ExecutionRecipe(BaseModel): + primary_lane: Literal["internal", "external", "plan"] = "internal" + fit_brief_required: bool = False + external_evidence_required: bool = False + plan_handoff_mode: Literal["none", "offer_after_answer", "explicit_plan_now"] = "none" diff --git a/backend/app/schemas/world_model.py b/backend/app/schemas/world_model.py index 09893898..d33ffa84 100644 --- a/backend/app/schemas/world_model.py +++ b/backend/app/schemas/world_model.py @@ -100,6 +100,11 @@ class ActiveProjectSnapshot(TypedDict): owner_id: Optional[int] +class RepoSnapshot(TypedDict): + scope: str + summary_mode: str + + class DecisionContextSnapshot(TypedDict): captured_at: str project_id: Optional[int] @@ -112,6 +117,8 @@ class DecisionContextSnapshot(TypedDict): class WorldModel(TypedDict): sender: str resolved_project_id: Optional[int] + repo_scope: NotRequired[str] + repo_summary: NotRequired[RepoSnapshot] project: Optional[ProjectSnapshot] project_domain: Optional[ProjectDomainSnapshot] portfolio: PortfolioSummary @@ -142,6 +149,7 @@ __all__ = [ "DependencySummary", "RecentSignalItem", "ActiveProjectSnapshot", + "RepoSnapshot", "DecisionContextSnapshot", "WorldModel", ] diff --git a/backend/app/services/adaptive_rag_service/mixin_answer_mainline.py b/backend/app/services/adaptive_rag_service/mixin_answer_mainline.py index b7ac1df6..5d0581b7 100644 --- a/backend/app/services/adaptive_rag_service/mixin_answer_mainline.py +++ b/backend/app/services/adaptive_rag_service/mixin_answer_mainline.py @@ -590,12 +590,47 @@ class AdaptiveRAGAnswerMainlineMixin: route_decision=route_decision, ), "route_decision": self._normalize_query_route_decision(route_decision), + "project_operating_context": self._build_project_operating_context_packet( + project_id=project_id, + source_chat_id=source_chat_id, + reply_to_message_id=str((thread_context or {}).get("reply_to_message_id") or "").strip() or None, + root_message_id=str((thread_context or {}).get("root_message_id") or "").strip() or None, + ) if hasattr(self, "_build_project_operating_context_packet") else {}, + "execution_recipe": self._build_execution_recipe( + query_semantics=query_semantics, + route_mode=str((route_decision or {}).get("route_mode") or ""), + effective_source_policy=str((route_decision or {}).get("effective_source_policy") or ""), + project_operating_context=( + self._build_project_operating_context_packet( + project_id=project_id, + source_chat_id=source_chat_id, + reply_to_message_id=str((thread_context or {}).get("reply_to_message_id") or "").strip() or None, + root_message_id=str((thread_context or {}).get("root_message_id") or "").strip() or None, + ) + if hasattr(self, "_build_project_operating_context_packet") + else {} + ), + ) if hasattr(self, "_build_execution_recipe") else {}, } query_semantics = ( dict(route_preview.get("query_semantics") or {}) if isinstance(route_preview.get("query_semantics"), dict) else {} ) + project_operating_context = ( + dict(route_preview.get("project_operating_context") or {}) + if isinstance(route_preview.get("project_operating_context"), dict) + else {} + ) + execution_recipe = ( + dict(route_preview.get("execution_recipe") or {}) + if isinstance(route_preview.get("execution_recipe"), dict) + else {} + ) + if project_operating_context: + query_semantics["project_operating_context"] = project_operating_context + if execution_recipe: + query_semantics["execution_recipe"] = execution_recipe operation_contract = None operation_contract_builder = getattr(self, "_build_operation_contract", None) if callable(operation_contract_builder): @@ -843,6 +878,8 @@ class AdaptiveRAGAnswerMainlineMixin: user_id=asked_by, ) prefers_external_retrieval = ( + str((query_semantics.get("execution_recipe") or {}).get("primary_lane") or "").strip().lower() == "external" + or str(query_semantics.get("effective_source_policy") or query_semantics.get("source_policy") or "").strip().lower() == "external" ) diff --git a/backend/app/services/adaptive_rag_service/mixin_answer_payload.py b/backend/app/services/adaptive_rag_service/mixin_answer_payload.py index c78c21e9..177e2f43 100644 --- a/backend/app/services/adaptive_rag_service/mixin_answer_payload.py +++ b/backend/app/services/adaptive_rag_service/mixin_answer_payload.py @@ -1,7 +1,10 @@ """Adaptive RAG mixin module.""" from __future__ import annotations +from app.schemas.project_operating_context import ExecutionRecipe, ProjectFitBrief, ProjectOperatingContext + from .shared import * +from .shared_utils import _env_bool from .internal_query_context import ( internal_context_block_reason, internal_context_operation_contract, @@ -21,6 +24,221 @@ class AdaptiveRAGAnswerPayloadMixin: "system": 10, } _INTERNAL_SOURCE_TYPES = {"db", "graph", "doc", "pmflow", "feishu"} + _EXTERNAL_RETRIEVAL_STATUSES = { + "evidence_found", + "verifier_blocked", + "no_relevant_results", + "retrieval_failed", + } + _EXTERNAL_IMPACT_STATUSES = { + "direct_match", + "indirect_only", + "no_direct_impact", + } + _ENV_ENABLE_DEFAULT_PLAN_HANDOFF_RENDERING = "PMFLOW_ENABLE_DEFAULT_PLAN_HANDOFF_RENDERING" + + @staticmethod + def _normalize_project_operating_context(payload: Optional[dict[str, Any]]) -> dict[str, Any]: + if not isinstance(payload, dict): + return {} + try: + return ProjectOperatingContext.model_validate(payload).model_dump() + except Exception: + return {} + + @staticmethod + def _normalize_execution_recipe(payload: Optional[dict[str, Any]]) -> dict[str, Any]: + if not isinstance(payload, dict): + return {} + try: + return ExecutionRecipe.model_validate(payload).model_dump() + except Exception: + return {} + + @staticmethod + def _normalize_fit_brief(payload: Optional[dict[str, Any]]) -> dict[str, Any]: + if not isinstance(payload, dict) or not payload: + return {} + try: + return ProjectFitBrief.model_validate(payload).model_dump() + except Exception: + return {} + + @classmethod + def _normalize_external_recommendation(cls, payload: Optional[dict[str, Any]]) -> dict[str, Any]: + if not isinstance(payload, dict) or not payload: + return {} + retrieval_status = str(payload.get("retrieval_status") or "").strip().lower() + impact_status = str(payload.get("impact_status") or "").strip().lower() + if retrieval_status not in cls._EXTERNAL_RETRIEVAL_STATUSES: + retrieval_status = "retrieval_failed" + if impact_status not in cls._EXTERNAL_IMPACT_STATUSES: + impact_status = "no_direct_impact" + return { + "retrieval_status": retrieval_status, + "impact_status": impact_status, + "external_summary": str(payload.get("external_summary") or "").strip(), + "external_impact": str(payload.get("external_impact") or "").strip(), + "affected_signal_sources": [ + str(item).strip() + for item in list(payload.get("affected_signal_sources") or []) + if str(item).strip() + ][:6], + "external_action": str(payload.get("external_action") or "").strip(), + } + + @classmethod + def _normalize_external_anchor_text(cls, value: Any) -> str: + raw = str(value or "").strip().lower() + if not raw: + return "" + chars: list[str] = [] + for char in raw: + if char.isalnum() or "\u4e00" <= char <= "\u9fff": + chars.append(char) + return "".join(chars) + + @classmethod + def _derive_external_retrieval_status(cls, packet: Optional[dict[str, Any]]) -> str: + normalized = packet if isinstance(packet, dict) else {} + explicit = str(normalized.get("retrieval_status") or "").strip().lower() + if explicit in cls._EXTERNAL_RETRIEVAL_STATUSES: + return explicit + + verifier = normalized.get("verifier") if isinstance(normalized.get("verifier"), dict) else {} + verified_evidence = list(normalized.get("verified_evidence") or []) + if verified_evidence and bool(verifier.get("ok")): + return "evidence_found" + if verified_evidence and not bool(verifier.get("ok")): + return "verifier_blocked" + + attempts = [item for item in list(normalized.get("adapter_attempts") or []) if isinstance(item, dict)] + if any(int(item.get("evidence_count") or 0) > 0 and not bool(item.get("verifier_ok")) for item in attempts): + return "verifier_blocked" + statuses = { + str(item.get("status") or "").strip().lower() + for item in attempts + if str(item.get("status") or "").strip() + } + if any(status in {"success", "empty"} for status in statuses): + return "no_relevant_results" + return "retrieval_failed" + + @classmethod + def _collect_fit_brief_signal_source_anchors(cls, fit_brief: Optional[dict[str, Any]]) -> list[str]: + normalized_fit_brief = cls._normalize_fit_brief(fit_brief) + anchors: list[str] = [] + for item in list(normalized_fit_brief.get("signal_source_anchors") or []): + normalized = str(item or "").strip() + compact = cls._normalize_external_anchor_text(normalized) + if len(compact) >= 2 and normalized not in anchors: + anchors.append(normalized) + return anchors[:6] + + @classmethod + def _collect_external_evidence_texts(cls, packet: Optional[dict[str, Any]]) -> list[str]: + normalized = packet if isinstance(packet, dict) else {} + texts: list[str] = [] + for item in list(normalized.get("verified_evidence") or []): + if not isinstance(item, dict): + continue + for value in ( + item.get("source_label"), + item.get("title"), + item.get("snippet"), + item.get("target_url"), + ): + text = str(value or "").strip() + if text: + texts.append(text) + return texts + + @classmethod + def _build_external_recommendation( + cls, + *, + external_context_packet: Optional[dict[str, Any]], + fit_brief: Optional[dict[str, Any]], + ) -> dict[str, Any]: + packet = dict(external_context_packet or {}) if isinstance(external_context_packet, dict) else {} + retrieval_status = cls._derive_external_retrieval_status(packet) + if retrieval_status != "evidence_found": + return { + "retrieval_status": retrieval_status, + "impact_status": "no_direct_impact", + "external_summary": "", + "external_impact": "", + "affected_signal_sources": [], + "external_action": "", + } + + anchors = cls._collect_fit_brief_signal_source_anchors(fit_brief) + evidence_texts = [cls._normalize_external_anchor_text(item) for item in cls._collect_external_evidence_texts(packet)] + affected_signal_sources: list[str] = [] + for anchor in anchors: + compact_anchor = cls._normalize_external_anchor_text(anchor) + if len(compact_anchor) < 2: + continue + if any(compact_anchor in text for text in evidence_texts if text): + affected_signal_sources.append(anchor) + + first_evidence = next( + (item for item in list(packet.get("verified_evidence") or []) if isinstance(item, dict)), + {}, + ) + external_summary = str( + first_evidence.get("snippet") + or first_evidence.get("title") + or first_evidence.get("source_label") + or "" + ).strip()[:240] + if affected_signal_sources: + return { + "retrieval_status": "evidence_found", + "impact_status": "direct_match", + "external_summary": external_summary, + "external_impact": f"外部证据已和当前项目对象建立直接映射:{'、'.join(affected_signal_sources[:3])}", + "affected_signal_sources": affected_signal_sources[:6], + "external_action": "先做映射验证,再决定是否把这条外部变化正式纳入当前项目计划。", + } + + return { + "retrieval_status": "evidence_found", + "impact_status": "indirect_only", + "external_summary": external_summary, + "external_impact": "当前外部证据只支持背景相关性,暂不能直接映射到当前项目对象。", + "affected_signal_sources": [], + "external_action": "先做映射验证,确认这些外部变化是否真的影响当前项目。", + } + + def _build_plan_handoff_fields( + self, + *, + execution_recipe: Optional[dict[str, Any]], + project_operating_context: Optional[dict[str, Any]], + ) -> dict[str, Any]: + normalized_recipe = self._normalize_execution_recipe(execution_recipe) + normalized_context = self._normalize_project_operating_context(project_operating_context) + handoff_mode = str(normalized_recipe.get("plan_handoff_mode") or "").strip().lower() or None + if ( + handoff_mode == "offer_after_answer" + and not _env_bool(self._ENV_ENABLE_DEFAULT_PLAN_HANDOFF_RENDERING, True) + ): + handoff_mode = None + can_enter_plan = handoff_mode in {"offer_after_answer", "explicit_plan_now"} + recommended_next_step = None + if handoff_mode == "offer_after_answer": + if normalized_context.get("current_project_id") is not None: + recommended_next_step = "如果你要,我可以基于当前结论进入 plan,继续把需求范围和下一步收清楚。" + else: + recommended_next_step = "如果你要,我可以基于当前结论继续进入 plan。" + elif handoff_mode == "explicit_plan_now": + recommended_next_step = "当前已命中明确的计划上下文,可以直接进入 plan 继续推进。" + return { + "handoff_mode": handoff_mode, + "can_enter_plan": can_enter_plan, + "recommended_next_step": recommended_next_step, + } @staticmethod def _safe_source_metadata(raw: Any) -> dict[str, Any]: @@ -646,6 +864,10 @@ class AdaptiveRAGAnswerPayloadMixin: evidence_pack: Optional[dict[str, Any]] = None, prompt_variant_id: Optional[str] = None, route_decision: Optional[dict[str, Any]] = None, + project_operating_context: Optional[dict[str, Any]] = None, + execution_recipe: Optional[dict[str, Any]] = None, + fit_brief: Optional[dict[str, Any]] = None, + external_recommendation: Optional[dict[str, Any]] = None, ) -> dict[str, Any]: evidence: list[dict[str, Any]] = [] for item in sources[:8]: @@ -695,6 +917,16 @@ class AdaptiveRAGAnswerPayloadMixin: if hasattr(self, "_normalize_query_route_decision") else {} ) + normalized_project_operating_context = self._normalize_project_operating_context( + project_operating_context + ) + normalized_execution_recipe = self._normalize_execution_recipe(execution_recipe) + normalized_fit_brief = self._normalize_fit_brief(fit_brief) + normalized_external_recommendation = self._normalize_external_recommendation(external_recommendation) + handoff_fields = self._build_plan_handoff_fields( + execution_recipe=normalized_execution_recipe, + project_operating_context=normalized_project_operating_context, + ) return { "answer": str(answer or "").strip(), "evidence": evidence, @@ -742,6 +974,13 @@ class AdaptiveRAGAnswerPayloadMixin: "sources_used": normalized_sources_used, "evidence_pack": normalized_evidence_pack, "route_decision": normalized_route_decision, + "project_operating_context": normalized_project_operating_context, + "execution_recipe": normalized_execution_recipe, + "fit_brief": normalized_fit_brief, + "external_recommendation": normalized_external_recommendation, + "handoff_mode": handoff_fields["handoff_mode"], + "can_enter_plan": handoff_fields["can_enter_plan"], + "recommended_next_step": handoff_fields["recommended_next_step"], } @staticmethod diff --git a/backend/app/services/adaptive_rag_service/mixin_answer_retrieval.py b/backend/app/services/adaptive_rag_service/mixin_answer_retrieval.py index 866e40bd..2ca0b879 100644 --- a/backend/app/services/adaptive_rag_service/mixin_answer_retrieval.py +++ b/backend/app/services/adaptive_rag_service/mixin_answer_retrieval.py @@ -2,10 +2,13 @@ from __future__ import annotations from .shared import * +from .shared_utils import _env_bool from .internal_query_context import update_internal_query_context_packet class AdaptiveRAGAnswerRetrievalMixin: + _ENV_ENABLE_FIT_BRIEF_EXTERNAL_RECOMMENDATION = "PMFLOW_ENABLE_FIT_BRIEF_EXTERNAL_RECOMMENDATION" + async def _execute_external_mcp_search(self, plan) -> dict[str, Any]: from ..external_query_service.schemas import ExternalAdapterResult, ExternalQueryPlan from ..mcp_config import McpConfigService @@ -73,6 +76,7 @@ class AdaptiveRAGAnswerRetrievalMixin: *, question: str, query_semantics: Optional[dict[str, Any]] = None, + fit_brief: Optional[dict[str, Any]] = None, result_limit: int = 3, max_pages: int = 3, ) -> tuple[list[RetrievedItem], dict[str, Any]]: @@ -142,6 +146,7 @@ class AdaptiveRAGAnswerRetrievalMixin: ).execute( question=question, query_semantics=query_semantics, + fit_brief=fit_brief, ) context_packet = execution.to_context_packet(question=question).model_dump(mode="json") verifier = dict(execution.verifier or {}) @@ -322,6 +327,16 @@ class AdaptiveRAGAnswerRetrievalMixin: route_decision: Optional[dict[str, Any]] = None, internal_context_packet: Any = None, ) -> dict[str, Any]: + project_operating_context = ( + query_semantics.get("project_operating_context") + if isinstance(query_semantics.get("project_operating_context"), dict) + else {} + ) + execution_recipe = ( + query_semantics.get("execution_recipe") + if isinstance(query_semantics.get("execution_recipe"), dict) + else {} + ) domains = [str(item).strip().lower() for item in retrieval_domains if str(item or "").strip()] if not domains: domains = ["db", "graph", "doc", "feishu", "pmflow", "url"] @@ -333,16 +348,41 @@ class AdaptiveRAGAnswerRetrievalMixin: if callable(normalize_route_decision) else (dict(route_decision or {}) if isinstance(route_decision, dict) else {}) ) + execution_primary_lane = str(execution_recipe.get("primary_lane") or "").strip().lower() external_route_only = str( normalized_route_decision.get("effective_source_policy") or query_semantics.get("effective_source_policy") or query_semantics.get("source_policy") or "" - ).strip().lower() == "external" + ).strip().lower() == "external" or execution_primary_lane == "external" + fit_brief_external_recommendation_enabled = _env_bool( + self._ENV_ENABLE_FIT_BRIEF_EXTERNAL_RECOMMENDATION, + True, + ) + fit_brief: dict[str, Any] = {} + build_project_fit_brief = getattr(self, "_build_project_fit_brief", None) + if ( + fit_brief_external_recommendation_enabled + and external_route_only + and bool(execution_recipe.get("fit_brief_required")) + and callable(build_project_fit_brief) + ): + try: + fit_brief = ( + await build_project_fit_brief( + question=effective_q or clean_q, + project_operating_context=project_operating_context, + system_snapshot=system_snapshot, + user_snapshot=user_snapshot, + ) + ) or {} + except Exception: + fit_brief = {} if external_route_only: retrieved, search_meta = await self._execute_external_query_gateway( question=effective_q, query_semantics=query_semantics, + fit_brief=fit_brief, result_limit=3, max_pages=3, ) @@ -377,6 +417,16 @@ class AdaptiveRAGAnswerRetrievalMixin: ), }, ) + tool_trace.insert( + 0, + { + "step": 0, + "tool": "execution_recipe", + "primary_lane": execution_recipe.get("primary_lane"), + "plan_handoff_mode": execution_recipe.get("plan_handoff_mode"), + "fit_brief_required": bool(execution_recipe.get("fit_brief_required")), + }, + ) tool_trace.insert( 0, { @@ -394,6 +444,45 @@ class AdaptiveRAGAnswerRetrievalMixin: if external_route_only and isinstance(search_meta.get("external_context_packet"), dict) else None ) + build_external_recommendation = getattr(self, "_build_external_recommendation", None) + external_recommendation = ( + build_external_recommendation( + external_context_packet=external_context_packet, + fit_brief=fit_brief, + ) + if ( + fit_brief_external_recommendation_enabled + and external_route_only + and isinstance(external_context_packet, dict) + and callable(build_external_recommendation) + ) + else {} + ) + if external_route_only and isinstance(external_context_packet, dict) and isinstance(external_recommendation, dict): + external_context_packet = { + **external_context_packet, + "retrieval_status": external_recommendation.get("retrieval_status"), + "impact_status": external_recommendation.get("impact_status"), + "affected_signal_sources": list(external_recommendation.get("affected_signal_sources") or []), + } + build_plan_handoff_fields = getattr(self, "_build_plan_handoff_fields", None) + plan_handoff_fields = ( + build_plan_handoff_fields( + execution_recipe=execution_recipe, + project_operating_context=project_operating_context, + ) + if callable(build_plan_handoff_fields) + else {} + ) + response_payload_snapshot = { + "project_operating_context": project_operating_context, + "execution_recipe": execution_recipe, + "fit_brief": fit_brief, + "external_recommendation": external_recommendation, + "handoff_mode": plan_handoff_fields.get("handoff_mode"), + "can_enter_plan": plan_handoff_fields.get("can_enter_plan"), + "recommended_next_step": plan_handoff_fields.get("recommended_next_step"), + } external_verifier = ( dict(external_context_packet.get("verifier") or {}) if isinstance(external_context_packet, dict) @@ -610,6 +699,21 @@ class AdaptiveRAGAnswerRetrievalMixin: ) if isinstance(live_meta.get("external_context_packet"), dict): external_context_packet = dict(live_meta.get("external_context_packet") or {}) + external_recommendation = ( + build_external_recommendation( + external_context_packet=external_context_packet, + fit_brief=fit_brief, + ) + if fit_brief_external_recommendation_enabled and callable(build_external_recommendation) + else {} + ) + if isinstance(external_recommendation, dict): + external_context_packet = { + **external_context_packet, + "retrieval_status": external_recommendation.get("retrieval_status"), + "impact_status": external_recommendation.get("impact_status"), + "affected_signal_sources": list(external_recommendation.get("affected_signal_sources") or []), + } external_verifier = dict(external_context_packet.get("verifier") or {}) external_missing_fields = [ str(item).strip() @@ -917,6 +1021,7 @@ class AdaptiveRAGAnswerRetrievalMixin: anchor_ref=None, routing_snapshot=routing_snapshot, route_decision=normalized_route_decision, + response_payload=response_payload_snapshot, ), **runtime_fields, ) @@ -982,4 +1087,8 @@ class AdaptiveRAGAnswerRetrievalMixin: evidence_pack=runtime_fields["evidence_pack"], prompt_variant_id=prompt_variant_id, route_decision=normalized_route_decision, + project_operating_context=project_operating_context, + execution_recipe=execution_recipe, + fit_brief=fit_brief, + external_recommendation=external_recommendation, ) diff --git a/backend/app/services/adaptive_rag_service/mixin_core_intent.py b/backend/app/services/adaptive_rag_service/mixin_core_intent.py index b2c4e143..7266f612 100644 --- a/backend/app/services/adaptive_rag_service/mixin_core_intent.py +++ b/backend/app/services/adaptive_rag_service/mixin_core_intent.py @@ -1,6 +1,8 @@ """Adaptive RAG mixin module.""" from __future__ import annotations +from app.schemas.project_operating_context import ExecutionRecipe, ProjectFitBrief, ProjectOperatingContext + from .shared import * from ..ai_pmo.workflow_semantic_planner import WorkflowSemanticPlanner @@ -24,6 +26,8 @@ class AdaptiveRAGCoreIntentMixin: _ALLOWED_SCOPE_VALUES = {"none", "global", "project"} _SEMANTIC_FEATURE_FOCI = {"none", "purpose", "entry", "catalog"} _SOURCE_POLICIES = {"internal", "external", "uncertain"} + _EXTERNAL_RETRIEVAL_STATUSES = {"evidence_found", "verifier_blocked", "no_relevant_results", "retrieval_failed"} + _EXTERNAL_IMPACT_STATUSES = {"direct_match", "indirect_only", "no_direct_impact"} def __init__(self, db: AsyncSession, *, tenant_id: int) -> None: self.db = db @@ -165,7 +169,77 @@ class AdaptiveRAGCoreIntentMixin: return dict(payload) @staticmethod - def _normalize_routing_snapshot(raw: Any) -> dict[str, Any]: + def _normalize_project_operating_context_snapshot(payload: Any) -> dict[str, Any]: + if not isinstance(payload, dict): + return {} + try: + return ProjectOperatingContext.model_validate(payload).model_dump() + except Exception: + return {} + + @staticmethod + def _normalize_execution_recipe_snapshot(payload: Any) -> dict[str, Any]: + if not isinstance(payload, dict): + return {} + try: + return ExecutionRecipe.model_validate(payload).model_dump() + except Exception: + return {} + + @staticmethod + def _normalize_fit_brief_snapshot(payload: Any) -> dict[str, Any]: + if not isinstance(payload, dict): + return {} + try: + return ProjectFitBrief.model_validate(payload).model_dump() + except Exception: + return {} + + def _normalize_external_recommendation_snapshot(self, payload: Any) -> dict[str, Any]: + if not isinstance(payload, dict): + return {} + retrieval_status = str(payload.get("retrieval_status") or "").strip().lower() + impact_status = str(payload.get("impact_status") or "").strip().lower() + if retrieval_status not in self._EXTERNAL_RETRIEVAL_STATUSES: + retrieval_status = "retrieval_failed" + if impact_status not in self._EXTERNAL_IMPACT_STATUSES: + impact_status = "no_direct_impact" + return { + "retrieval_status": retrieval_status, + "impact_status": impact_status, + "external_summary": str(payload.get("external_summary") or "").strip(), + "external_impact": str(payload.get("external_impact") or "").strip(), + "affected_signal_sources": [ + str(item).strip() + for item in list(payload.get("affected_signal_sources") or []) + if str(item).strip() + ][:6], + "external_action": str(payload.get("external_action") or "").strip(), + } + + def _normalize_routing_response_payload(self, payload: Any) -> dict[str, Any]: + if not isinstance(payload, dict): + return {} + normalized = { + "project_operating_context": self._normalize_project_operating_context_snapshot( + payload.get("project_operating_context") + ), + "execution_recipe": self._normalize_execution_recipe_snapshot(payload.get("execution_recipe")), + "fit_brief": self._normalize_fit_brief_snapshot(payload.get("fit_brief")), + "external_recommendation": self._normalize_external_recommendation_snapshot( + payload.get("external_recommendation") + ), + "handoff_mode": str(payload.get("handoff_mode") or "").strip().lower() or None, + "can_enter_plan": bool(payload.get("can_enter_plan")) if "can_enter_plan" in payload else None, + "recommended_next_step": str(payload.get("recommended_next_step") or "").strip() or None, + } + return { + key: value + for key, value in normalized.items() + if value not in (None, {}) and value != [] + } + + def _normalize_routing_snapshot(self, raw: Any) -> dict[str, Any]: payload = raw if isinstance(raw, dict) else {} planned_tier = str(payload.get("planned_tier") or "").strip().lower() executed_tier = str(payload.get("executed_tier") or "").strip().lower() @@ -203,6 +277,9 @@ class AdaptiveRAGCoreIntentMixin: else {} ), } + response_payload = self._normalize_routing_response_payload(payload.get("response_payload")) + if response_payload: + normalized["response_payload"] = response_payload return {key: value for key, value in normalized.items() if value is not None} def _merge_query_route_audit_into_routing_snapshot( @@ -210,12 +287,16 @@ class AdaptiveRAGCoreIntentMixin: *, routing_snapshot: Optional[dict[str, Any]], route_decision: Optional[dict[str, Any]], + response_payload: Optional[dict[str, Any]] = None, ) -> dict[str, Any]: normalized_snapshot = self._normalize_routing_snapshot(routing_snapshot) if hasattr(self, "_build_query_route_audit_snapshot"): query_route = self._build_query_route_audit_snapshot(route_decision=route_decision) if query_route: normalized_snapshot["query_route"] = query_route + normalized_response_payload = self._normalize_routing_response_payload(response_payload) + if normalized_response_payload: + normalized_snapshot["response_payload"] = normalized_response_payload return normalized_snapshot def _peek_last_answer_routing_snapshot(self) -> dict[str, Any]: @@ -236,11 +317,13 @@ class AdaptiveRAGCoreIntentMixin: anchor_ref: Optional[str] = None, routing_snapshot: Optional[dict[str, Any]] = None, route_decision: Optional[dict[str, Any]] = None, + response_payload: Optional[dict[str, Any]] = None, ) -> dict[str, Any]: context = self._get_active_external_qa_context() normalized_snapshot = self._merge_query_route_audit_into_routing_snapshot( routing_snapshot=routing_snapshot, route_decision=route_decision, + response_payload=response_payload, ) or None return { "source_message_id": context.get("source_message_id"), diff --git a/backend/app/services/adaptive_rag_service/mixin_fast_answer_semantic_p2.py b/backend/app/services/adaptive_rag_service/mixin_fast_answer_semantic_p2.py index 94e8edf3..fa600bc6 100644 --- a/backend/app/services/adaptive_rag_service/mixin_fast_answer_semantic_p2.py +++ b/backend/app/services/adaptive_rag_service/mixin_fast_answer_semantic_p2.py @@ -29,6 +29,17 @@ class AdaptiveRAGFastAnswerSemanticMixinP2: internal_context_packet: Any = None, ) -> dict[str, Any]: started = time.perf_counter() + normalized_query_semantics = dict(query_semantics or {}) if isinstance(query_semantics, dict) else {} + project_operating_context = ( + normalized_query_semantics.get("project_operating_context") + if isinstance(normalized_query_semantics.get("project_operating_context"), dict) + else {} + ) + execution_recipe = ( + normalized_query_semantics.get("execution_recipe") + if isinstance(normalized_query_semantics.get("execution_recipe"), dict) + else {} + ) sources = list(fast_result.get("sources") or []) render_with_contract = getattr(self, "_render_answer_with_route_contract", None) answer_text = str(fast_result.get("answer") or "未命中结果") @@ -125,6 +136,22 @@ class AdaptiveRAGFastAnswerSemanticMixinP2: reason_code=effective_reason_code, retrieval_metrics=retrieval_metrics, ) + build_plan_handoff_fields = getattr(self, "_build_plan_handoff_fields", None) + plan_handoff_fields = ( + build_plan_handoff_fields( + execution_recipe=execution_recipe, + project_operating_context=project_operating_context, + ) + if callable(build_plan_handoff_fields) + else {} + ) + response_payload_snapshot = { + "project_operating_context": project_operating_context, + "execution_recipe": execution_recipe, + "handoff_mode": plan_handoff_fields.get("handoff_mode"), + "can_enter_plan": plan_handoff_fields.get("can_enter_plan"), + "recommended_next_step": plan_handoff_fields.get("recommended_next_step"), + } qa = QARecord( tenant_id=self.tenant_id, @@ -139,7 +166,10 @@ class AdaptiveRAGFastAnswerSemanticMixinP2: project_id=project_id, session_id=active_session_id_int, turn_index=active_turn_index, - **self._build_qa_record_external_fields(route_decision=route_decision), + **self._build_qa_record_external_fields( + route_decision=route_decision, + response_payload=response_payload_snapshot, + ), **runtime_fields, ) self.db.add(qa) @@ -203,4 +233,6 @@ class AdaptiveRAGFastAnswerSemanticMixinP2: prompt_variant_id=prompt_variant_id, reason_code=effective_reason_code, route_decision=route_decision, + project_operating_context=project_operating_context, + execution_recipe=execution_recipe, ) diff --git a/backend/app/services/adaptive_rag_service/mixin_followup_anchor.py b/backend/app/services/adaptive_rag_service/mixin_followup_anchor.py index a99e0683..a6cfe616 100644 --- a/backend/app/services/adaptive_rag_service/mixin_followup_anchor.py +++ b/backend/app/services/adaptive_rag_service/mixin_followup_anchor.py @@ -63,6 +63,12 @@ class AdaptiveRAGFollowupAnchorMixin: compact = "".join(str(text or "").split()).strip() if not compact: return 0 + if compact.startswith("第") and len(compact) > 1: + compact = compact[1:] + for suffix in ("条", "个", "项"): + if compact.endswith(suffix) and len(compact) > 1: + compact = compact[: -len(suffix)] + break if compact.isdigit(): try: parsed = int(compact) @@ -675,13 +681,10 @@ class AdaptiveRAGFollowupAnchorMixin: anchor_reply=chosen, ) - if is_meta_followup: - return FollowupAnchorResolution() - return FollowupAnchorResolution( followup_mode=followup_mode, anchor_mode="ambiguous_candidates", - anchor_kind="recent_scope_reply", + anchor_kind="meta_recent_reply" if is_meta_followup else "recent_scope_reply", anchor_session_id=active_session_id, candidate_replies=candidates, is_meta_followup=is_meta_followup, diff --git a/backend/app/services/adaptive_rag_service/mixin_project_fit_brief.py b/backend/app/services/adaptive_rag_service/mixin_project_fit_brief.py new file mode 100644 index 00000000..d3af3a95 --- /dev/null +++ b/backend/app/services/adaptive_rag_service/mixin_project_fit_brief.py @@ -0,0 +1,232 @@ +"""Adaptive RAG project fit brief helpers.""" +from __future__ import annotations + +from app.models import SignalSourceRegistry +from app.schemas.project_operating_context import ProjectFitBrief, ProjectOperatingContext + +from .shared import * + + +class AdaptiveRAGProjectFitBriefMixin: + @staticmethod + def _normalize_project_operating_context_for_fit_brief(payload: Optional[dict[str, Any]]) -> dict[str, Any]: + if not isinstance(payload, dict): + return {} + try: + return ProjectOperatingContext.model_validate(payload).model_dump() + except Exception: + return {} + + @staticmethod + def _normalize_project_fit_brief_payload(payload: Optional[dict[str, Any]]) -> dict[str, Any]: + if not isinstance(payload, dict): + return {} + try: + return ProjectFitBrief.model_validate(payload).model_dump() + except Exception: + return {} + + def _collect_fit_brief_capabilities( + self, + *, + system_snapshot: Optional[dict[str, Any]], + limit: int = 4, + ) -> list[str]: + feature_catalog: list[dict[str, Any]] = [] + extractor = getattr(self, "_feature_catalog_from_snapshot", None) + if callable(extractor): + try: + feature_catalog = extractor(system_snapshot) + except Exception: + feature_catalog = [] + elif isinstance(system_snapshot, dict): + platform = system_snapshot.get("platform_profile") if isinstance(system_snapshot.get("platform_profile"), dict) else {} + feature_catalog = platform.get("feature_catalog") if isinstance(platform.get("feature_catalog"), list) else [] + + capabilities: list[str] = [] + for item in feature_catalog: + if not isinstance(item, dict): + continue + name = str(item.get("name") or "").strip() + if name and name not in capabilities: + capabilities.append(name) + return capabilities[: max(1, int(limit))] + + async def _collect_fit_brief_signal_source_anchors( + self, + *, + current_project_id: Optional[int], + user_snapshot: Optional[dict[str, Any]], + limit: int = 4, + ) -> list[str]: + anchors: list[str] = [] + max_items = max(1, int(limit)) + + if current_project_id is None: + return [] + + if getattr(self, "db", None) is not None: + try: + rows = ( + await self.db.execute( + select( + SignalSourceRegistry.source_name, + SignalSourceRegistry.source_identifier, + ).where( + and_( + SignalSourceRegistry.tenant_id == getattr(self, "tenant_id", 1), + SignalSourceRegistry.project_id == int(current_project_id), + SignalSourceRegistry.status == "active", + ) + ) + .order_by(SignalSourceRegistry.created_at.desc(), SignalSourceRegistry.id.desc()) + .limit(max_items) + ) + ).all() + except Exception: + rows = [] + for row in rows: + candidate = str(row.source_name or row.source_identifier or "").strip() + if candidate and candidate not in anchors: + anchors.append(candidate) + + if anchors: + return anchors[:max_items] + + if isinstance(user_snapshot, dict): + for project in list(user_snapshot.get("projects") or []): + if not isinstance(project, dict): + continue + if int(project.get("id") or 0) != int(current_project_id): + continue + for item in list(project.get("signal_sources") or []): + if not isinstance(item, dict): + continue + candidate = str(item.get("source_name") or item.get("technical_identifier") or "").strip() + if candidate and candidate not in anchors: + anchors.append(candidate) + break + return anchors[:max_items] + + @staticmethod + def _build_repo_summary( + *, + system_snapshot: Optional[dict[str, Any]], + repo_scope: Optional[str], + ) -> str | None: + system_data = system_snapshot if isinstance(system_snapshot, dict) else {} + platform = system_data.get("platform_profile") if isinstance(system_data.get("platform_profile"), dict) else {} + backend = platform.get("backend") if isinstance(platform.get("backend"), dict) else {} + frontend = platform.get("frontend") if isinstance(platform.get("frontend"), dict) else {} + feature_catalog = platform.get("feature_catalog") if isinstance(platform.get("feature_catalog"), list) else [] + module_counts = backend.get("module_counts") if isinstance(backend.get("module_counts"), dict) else {} + + parts: list[str] = [] + scope = str(repo_scope or "").strip() + if scope: + parts.append(f"repo={scope}") + if backend: + parts.append( + "后端 " + f"{str(backend.get('framework') or 'unknown')}," + f"services={int(module_counts.get('services', 0))}," + f"api={int(module_counts.get('api', 0))}" + ) + if frontend: + parts.append( + "前端 " + f"{str(frontend.get('framework') or 'unknown')}," + f"route≈{int(frontend.get('route_count') or 0)}" + ) + if feature_catalog: + parts.append(f"功能目录 {len(feature_catalog)} 个") + return ";".join(parts)[:320] if parts else None + + @staticmethod + def _build_recommendation_focus(question: str, *, current_project_id: Optional[int]) -> list[str]: + text = str(question or "").strip() + focus = [] + if text: + focus.append(f"判断“{text[:80]}”是否适合当前项目接入") + if current_project_id is not None: + focus.append(f"优先绑定当前项目#{int(current_project_id)}的正式对象,不做泛化建议") + focus.append("先回答外部适配性,再决定是否进入 plan") + return focus[:3] + + async def _build_project_fit_brief( + self, + *, + question: str, + project_operating_context: Optional[dict[str, Any]], + system_snapshot: Optional[dict[str, Any]], + user_snapshot: Optional[dict[str, Any]], + ) -> dict[str, Any]: + normalized_context = self._normalize_project_operating_context_for_fit_brief(project_operating_context) + current_project_id = normalized_context.get("current_project_id") + current_repo_scope = str(normalized_context.get("current_repo_scope") or "").strip() or None + + project_summary = None + if current_project_id is not None and getattr(self, "db", None) is not None: + row = ( + await self.db.execute( + select(Project.id, Project.title, Project.name, Project.status).where( + and_( + Project.tenant_id == getattr(self, "tenant_id", 1), + Project.id == int(current_project_id), + ) + ) + ) + ).first() + if row is not None: + project_summary = ( + f"当前项目:#{int(row.id)} " + f"{str(row.title or row.name or f'项目#{int(row.id)}')} " + f"(status={str(row.status or 'unknown')})" + ) + + if project_summary is None and isinstance(user_snapshot, dict) and current_project_id is not None: + for item in list(user_snapshot.get("projects") or []): + if not isinstance(item, dict): + continue + if int(item.get("id") or 0) != int(current_project_id): + continue + project_summary = ( + f"当前项目:#{int(current_project_id)} " + f"{str(item.get('name') or f'项目#{int(current_project_id)}')} " + f"(status={str(item.get('status') or 'unknown')})" + ) + break + + capabilities = self._collect_fit_brief_capabilities( + system_snapshot=system_snapshot, + limit=4, + ) + signal_source_anchors = await self._collect_fit_brief_signal_source_anchors( + current_project_id=int(current_project_id) if current_project_id is not None else None, + user_snapshot=user_snapshot, + limit=4, + ) + constraints = [ + "只允许基于可核验外部公开证据判断适配性。", + "结论必须优先绑定当前项目与当前仓库上下文。", + ] + if current_repo_scope: + constraints.append(f"当前 repo scope={current_repo_scope}") + if current_project_id is not None: + constraints.append(f"优先判断对当前项目#{int(current_project_id)}的直接影响。") + + payload = ProjectFitBrief( + project_summary=project_summary, + repo_summary=self._build_repo_summary( + system_snapshot=system_snapshot, + repo_scope=current_repo_scope, + ), + current_capabilities=capabilities, + signal_source_anchors=signal_source_anchors, + constraints=constraints[:4], + recommendation_focus=self._build_recommendation_focus( + question, + current_project_id=int(current_project_id) if current_project_id is not None else None, + ), + ) + return payload.model_dump() diff --git a/backend/app/services/adaptive_rag_service/mixin_qa_session_list_ops_p1.py b/backend/app/services/adaptive_rag_service/mixin_qa_session_list_ops_p1.py index 2be09651..0ee04741 100644 --- a/backend/app/services/adaptive_rag_service/mixin_qa_session_list_ops_p1.py +++ b/backend/app/services/adaptive_rag_service/mixin_qa_session_list_ops_p1.py @@ -168,6 +168,7 @@ class AdaptiveRAGQASessionListOpsMixinP1: fb = feedback_map.get(int(row.id)) ack_meta = self._extract_ack_metadata_from_sources(row.sources, answer=row.answer) routing_snapshot = self._normalize_message_dict(row.routing_snapshot) + response_payload = self._normalize_message_dict(routing_snapshot.get("response_payload")) if fb: rating = str(fb.rating or "").strip().lower() if rating == "positive": @@ -196,6 +197,21 @@ class AdaptiveRAGQASessionListOpsMixinP1: "citations": self._normalize_message_list(row.citations), "sources_used": self._normalize_message_list(row.sources_used), "evidence_pack": self._normalize_message_dict(row.evidence_pack), + "project_operating_context": self._normalize_message_dict( + response_payload.get("project_operating_context") + ), + "execution_recipe": self._normalize_message_dict(response_payload.get("execution_recipe")), + "fit_brief": self._normalize_message_dict(response_payload.get("fit_brief")), + "external_recommendation": self._normalize_message_dict( + response_payload.get("external_recommendation") + ), + "handoff_mode": str(response_payload.get("handoff_mode") or "").strip() or None, + "can_enter_plan": ( + bool(response_payload.get("can_enter_plan")) + if "can_enter_plan" in response_payload + else None + ), + "recommended_next_step": str(response_payload.get("recommended_next_step") or "").strip() or None, "turn_index": int(row.turn_index or 1), "created_at": row.created_at.isoformat() if row.created_at else None, "feedback": { diff --git a/backend/app/services/adaptive_rag_service/mixin_query_route_gateway.py b/backend/app/services/adaptive_rag_service/mixin_query_route_gateway.py index 64f39581..f5b5dd8a 100644 --- a/backend/app/services/adaptive_rag_service/mixin_query_route_gateway.py +++ b/backend/app/services/adaptive_rag_service/mixin_query_route_gateway.py @@ -1,13 +1,132 @@ """Adaptive RAG query route gateway helpers.""" from __future__ import annotations +from app.schemas.project_operating_context import ExecutionRecipe, ProjectOperatingContext +from app.services.ai_pmo.project_operating_context_builder import ProjectOperatingContextBuilder + from .shared import * +from .shared_utils import _env_bool class AdaptiveRAGQueryRouteGatewayMixin: _QUERY_ROUTE_MODES = {"internal", "external", "uncertain"} _INTERNAL_ALLOWED_SOURCES = {"db", "graph", "doc", "pmflow", "feishu"} _EXTERNAL_ALLOWED_SOURCES = {"url", "mcp_search", "system"} + _PLAN_MESSAGE_INTENTS = {"requirement_plan", "requirement_gathering", "requirement_convergence"} + _ENV_ENABLE_UNIFIED_EXECUTION_RECIPE = "PMFLOW_ENABLE_UNIFIED_EXECUTION_RECIPE" + + @staticmethod + def _normalize_project_operating_context(payload: Optional[dict[str, Any]]) -> dict[str, Any]: + if not isinstance(payload, dict): + return {} + try: + return ProjectOperatingContext.model_validate(payload).model_dump() + except Exception: + return {} + + @staticmethod + def _normalize_execution_recipe(payload: Optional[dict[str, Any]]) -> dict[str, Any]: + if not isinstance(payload, dict): + return {} + try: + return ExecutionRecipe.model_validate(payload).model_dump() + except Exception: + return {} + + def _build_project_operating_context_packet( + self, + *, + project_id: Optional[int], + source_chat_id: Optional[str], + reply_to_message_id: Optional[str] = None, + root_message_id: Optional[str] = None, + project_operating_context: Optional[dict[str, Any]] = None, + ) -> dict[str, Any]: + builder = ProjectOperatingContextBuilder( + repo_scope=str((project_operating_context or {}).get("current_repo_scope") or "").strip() or None + ) + packet = builder.build_from_ingress( + project_hint_id=project_id, + rag_request_context={ + "project_id": project_id, + "source_chat_id": source_chat_id, + }, + thread_context={ + "reply_to_message_id": reply_to_message_id, + "root_message_id": root_message_id, + }, + existing_packet=project_operating_context, + ) + return packet.model_dump() + + def _build_execution_recipe( + self, + *, + query_semantics: Optional[dict[str, Any]], + route_mode: str, + effective_source_policy: str, + project_operating_context: Optional[dict[str, Any]], + ) -> dict[str, Any]: + semantics = query_semantics if isinstance(query_semantics, dict) else {} + context = self._normalize_project_operating_context(project_operating_context) + message_intent = str( + semantics.get("message_intent") + or semantics.get("intent") + or "" + ).strip().lower() + query_type = str(semantics.get("query_type") or "").strip().lower() + try: + has_current_project = int(context.get("current_project_id")) >= 0 + except Exception: + has_current_project = False + decision_strategy = str(context.get("decision_strategy") or "").strip().lower() + unified_execution_recipe_enabled = _env_bool( + self._ENV_ENABLE_UNIFIED_EXECUTION_RECIPE, + True, + ) + + if message_intent in self._PLAN_MESSAGE_INTENTS or query_type == "plan": + return ExecutionRecipe( + primary_lane="plan", + fit_brief_required=False, + external_evidence_required=False, + plan_handoff_mode="explicit_plan_now", + ).model_dump() + + normalized_effective = self._normalize_query_route_mode(effective_source_policy) + normalized_route = self._normalize_query_route_mode(route_mode) + if not unified_execution_recipe_enabled: + if normalized_effective == "external" or normalized_route == "external": + return ExecutionRecipe( + primary_lane="external", + fit_brief_required=False, + external_evidence_required=True, + plan_handoff_mode="none", + ).model_dump() + return ExecutionRecipe( + primary_lane="internal", + fit_brief_required=False, + external_evidence_required=False, + plan_handoff_mode="none", + ).model_dump() + if normalized_effective == "external" or normalized_route == "external": + return ExecutionRecipe( + primary_lane="external", + fit_brief_required=has_current_project, + external_evidence_required=True, + plan_handoff_mode=( + "offer_after_answer" + if has_current_project and decision_strategy == "answer_then_offer_plan" + else "none" + ), + ).model_dump() + + return ExecutionRecipe( + primary_lane="internal", + fit_brief_required=False, + external_evidence_required=False, + plan_handoff_mode="none", + ).model_dump() def _normalize_query_route_decision(self, payload: Optional[dict[str, Any]]) -> dict[str, Any]: raw = payload if isinstance(payload, dict) else {} @@ -217,12 +336,24 @@ class AdaptiveRAGQueryRouteGatewayMixin: asked_via: str, project_id: Optional[int], source_chat_id: Optional[str], + source_message_id: Optional[str] = None, + conversation_scope_key: Optional[str] = None, + reply_to_message_id: Optional[str] = None, + root_message_id: Optional[str] = None, query_semantics: Optional[dict[str, Any]], source_policy_hint: Optional[str], + project_operating_context: Optional[dict[str, Any]] = None, ) -> dict[str, Any]: - _ = (question, asked_via, project_id, source_chat_id) + _ = (question, asked_via, source_message_id, conversation_scope_key) semantics = dict(query_semantics or {}) query_profile = self._normalize_query_profile(semantics.get("query_profile")) + normalized_project_operating_context = self._build_project_operating_context_packet( + project_id=project_id, + source_chat_id=source_chat_id, + reply_to_message_id=reply_to_message_id, + root_message_id=root_message_id, + project_operating_context=project_operating_context, + ) contract = self._resolve_route_contract( query_semantics=semantics, source_policy_hint=source_policy_hint, @@ -242,6 +373,12 @@ class AdaptiveRAGQueryRouteGatewayMixin: "decision_origin": decision_origin, } } + execution_recipe = self._build_execution_recipe( + query_semantics=semantics, + route_mode=route_mode, + effective_source_policy=effective_source_policy, + project_operating_context=normalized_project_operating_context, + ) return { "route_mode": route_mode, "effective_source_policy": effective_source_policy, @@ -261,6 +398,8 @@ class AdaptiveRAGQueryRouteGatewayMixin: effective_source_policy=effective_source_policy, decision_origin=decision_origin, ), + "project_operating_context": normalized_project_operating_context, + "execution_recipe": execution_recipe, } async def preview_query_route_decision( @@ -276,8 +415,16 @@ class AdaptiveRAGQueryRouteGatewayMixin: root_message_id: Optional[str] = None, source_policy_hint: Optional[str] = None, query_semantics: Optional[dict[str, Any]] = None, + project_operating_context: Optional[dict[str, Any]] = None, ) -> dict[str, Any]: semantics = dict(query_semantics or {}) + normalized_project_operating_context = self._build_project_operating_context_packet( + project_id=project_id, + source_chat_id=source_chat_id, + reply_to_message_id=reply_to_message_id, + root_message_id=root_message_id, + project_operating_context=project_operating_context, + ) if not semantics: semantics = await self._resolve_query_semantics( message=question, @@ -289,6 +436,7 @@ class AdaptiveRAGQueryRouteGatewayMixin: "conversation_scope_key": conversation_scope_key, "reply_to_message_id": reply_to_message_id, "root_message_id": root_message_id, + "project_operating_context": normalized_project_operating_context, }, source_policy_hint=source_policy_hint, ) @@ -298,17 +446,31 @@ class AdaptiveRAGQueryRouteGatewayMixin: asked_via=asked_via, project_id=project_id, source_chat_id=source_chat_id, + source_message_id=source_message_id, + conversation_scope_key=conversation_scope_key, + reply_to_message_id=reply_to_message_id, + root_message_id=root_message_id, query_semantics=semantics, source_policy_hint=source_policy_hint or semantics.get("source_policy"), + project_operating_context=normalized_project_operating_context, ) normalized_decision = self._normalize_query_route_decision(decision) normalized_decision["probe_snapshot"] = dict(decision.get("probe_snapshot") or {}) + normalized_execution_recipe = self._normalize_execution_recipe( + decision.get("execution_recipe") if isinstance(decision.get("execution_recipe"), dict) else None + ) + if normalized_project_operating_context: + semantics["project_operating_context"] = dict(normalized_project_operating_context) + if normalized_execution_recipe: + semantics["execution_recipe"] = dict(normalized_execution_recipe) return { "query_semantics": self._apply_route_decision_to_query_semantics( query_semantics=semantics, route_decision=normalized_decision, ), "route_decision": normalized_decision, + "project_operating_context": normalized_project_operating_context, + "execution_recipe": normalized_execution_recipe, } async def _persist_query_route_memory( diff --git a/backend/app/services/adaptive_rag_service/service.py b/backend/app/services/adaptive_rag_service/service.py index 1c474f84..f80f7e83 100644 --- a/backend/app/services/adaptive_rag_service/service.py +++ b/backend/app/services/adaptive_rag_service/service.py @@ -7,6 +7,7 @@ from .mixin_answer_mainline import AdaptiveRAGAnswerMainlineMixin from .mixin_answer_precheck import AdaptiveRAGAnswerPrecheckMixin from .mixin_answer_retrieval import AdaptiveRAGAnswerRetrievalMixin from .mixin_answer_payload import AdaptiveRAGAnswerPayloadMixin +from .mixin_project_fit_brief import AdaptiveRAGProjectFitBriefMixin from .mixin_requirement_ack_core import AdaptiveRAGRequirementAckCoreMixin from .mixin_requirement_ack_flow import AdaptiveRAGRequirementAckFlowMixin from .mixin_deterministic_query_core import AdaptiveRAGDeterministicQueryCoreMixin @@ -53,6 +54,7 @@ class AdaptiveRAGService( AdaptiveRAGAnswerPrecheckMixin, AdaptiveRAGAnswerRetrievalMixin, AdaptiveRAGAnswerPayloadMixin, + AdaptiveRAGProjectFitBriefMixin, AdaptiveRAGRequirementAckCoreMixin, AdaptiveRAGRequirementAckFlowMixin, AdaptiveRAGDeterministicQueryCoreMixin, diff --git a/backend/app/services/adaptive_rag_service/shared_utils.py b/backend/app/services/adaptive_rag_service/shared_utils.py index 33487358..2e3e5528 100644 --- a/backend/app/services/adaptive_rag_service/shared_utils.py +++ b/backend/app/services/adaptive_rag_service/shared_utils.py @@ -3,6 +3,17 @@ from __future__ import annotations from .shared_imports import * +def _env_bool(name: str, default: bool = False) -> bool: + raw = str(os.getenv(name, "") or "").strip().lower() + if not raw: + return bool(default) + if raw in {"1", "true", "yes", "on"}: + return True + if raw in {"0", "false", "no", "off"}: + return False + return bool(default) + + def _env_int(name: str, default: int, *, minimum: int, maximum: int) -> int: raw = str(os.getenv(name, "") or "").strip() if not raw: @@ -15,5 +26,4 @@ def _env_int(name: str, default: int, *, minimum: int, maximum: int) -> int: return max(minimum, min(maximum, value)) -__all__ = ["_env_int"] - +__all__ = ["_env_bool", "_env_int"] diff --git a/backend/app/services/ai_pmo/ai_pmo_kernel/mixins/world_model/ai_pmo_kernel_mixins_world_model_session.py b/backend/app/services/ai_pmo/ai_pmo_kernel/mixins/world_model/ai_pmo_kernel_mixins_world_model_session.py index 9d56416f..2c0a95fb 100644 --- a/backend/app/services/ai_pmo/ai_pmo_kernel/mixins/world_model/ai_pmo_kernel_mixins_world_model_session.py +++ b/backend/app/services/ai_pmo/ai_pmo_kernel/mixins/world_model/ai_pmo_kernel_mixins_world_model_session.py @@ -12,6 +12,18 @@ for _name, _value in vars(_common).items(): class KernelWorldModelSessionMixin: + @staticmethod + def _project_operating_context_defaults_from_session( + *, + session: Optional[ConversationSession], + ) -> dict[str, Any]: + if session is None: + return {} + current_project_id = _common._safe_int(getattr(session, "current_project_id", None)) + if current_project_id is None: + return {} + return {"current_project_id": current_project_id} + @staticmethod def _within_recent_window(last_active_at: Optional[datetime], *, window_seconds: int) -> bool: if not isinstance(last_active_at, datetime): 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 0ca95d5a..199881c0 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 @@ -16,6 +16,7 @@ from app.services.ai_pmo.workflow_semantic_planner.constants import ( QUERY_SCENE_CORRECTION_ESCALATION, QUERY_SCENE_REQUIREMENT_PLAN, ) +from app.services.ai_pmo.project_operating_context_builder import ProjectOperatingContextBuilder from app.services.mounted_ai_runtime_write_service import MountedAIRuntimeWriteService class KernelIngressFlowEntryMixin: @@ -41,6 +42,51 @@ class KernelIngressFlowEntryMixin: def _normalize_compact_text(value: Any) -> str: return re.sub(r"\s+", "", str(value or "")).strip().lower() + def _ensure_project_operating_context( + self, + *, + ingress_options: Optional[dict[str, Any]], + project_hint_id: Optional[int], + world_model: Optional[dict[str, Any]] = None, + thread_context: Optional[dict[str, Any]] = None, + session: Optional[ConversationSession] = None, + ) -> dict[str, Any]: + normalized_options = dict(ingress_options or {}) + rag_request_context = ( + normalized_options.get("rag_request_context") + if isinstance(normalized_options.get("rag_request_context"), dict) + else None + ) + existing_packet = ( + normalized_options.get("project_operating_context") + if isinstance(normalized_options.get("project_operating_context"), dict) + else None + ) + session_defaults_builder = getattr(self, "_project_operating_context_defaults_from_session", None) + session_defaults = ( + session_defaults_builder(session=session) + if callable(session_defaults_builder) + else {} + ) + builder = ProjectOperatingContextBuilder( + repo_scope=str( + (world_model or {}).get("repo_scope") + or (existing_packet or {}).get("current_repo_scope") + or "" + ).strip() + or None + ) + packet = builder.build_from_ingress( + project_hint_id=project_hint_id, + rag_request_context=rag_request_context, + thread_context=thread_context, + world_model=world_model, + session_project_id=_safe_int(session_defaults.get("current_project_id")), + existing_packet=existing_packet, + ) + normalized_options["project_operating_context"] = packet.model_dump() + return normalized_options + @classmethod def _payload_has_external_freshness_guard_source(cls, value: Any) -> bool: if isinstance(value, str): @@ -193,6 +239,62 @@ class KernelIngressFlowEntryMixin: compact = cls._normalize_compact_text(message_text) return bool(compact) and len(compact) <= 24 + @staticmethod + async def _should_skip_query_pending_clarification_followup_by_bundle( + *, + message_text: str, + source_system: str, + ) -> bool: + text = str(message_text or "").strip() + if not text: + return False + try: + bundle = await WorkflowSemanticPlanner.aplan_message( + message_text=text, + source_system=str(source_system or "").strip() or "unknown", + metadata={}, + ) + except Exception: + return False + workflows = list(getattr(bundle, "workflows", []) or []) + if any( + str(getattr(item, "workflow_type", "") or "").strip() + in {"new_project_flow", "project_flow", "task_flow", "signal_source_flow", "project_signal_flow"} + for item in workflows + ): + return True + diagnostics = getattr(bundle, "diagnostics", None) + if not isinstance(diagnostics, dict): + return False + return str(diagnostics.get("topic_switch") or "").strip().lower() == "new" + + async def _clear_query_pending_clarification_context( + self, + *, + session_id: Optional[int], + ) -> None: + normalized_session_id = _safe_int(session_id) + if normalized_session_id is None or not hasattr(self.db, "execute"): + return + from app.models.qa_session_state import QASessionState + + state_row = ( + await self.db.execute( + select(QASessionState).where( + and_( + QASessionState.tenant_id == int(self.tenant_id), + QASessionState.session_id == int(normalized_session_id), + ) + ).limit(1) + ) + ).scalars().first() + if state_row is None: + return + state_row.pending_clarification = None + state_row.pending_clarification_expires_at = None + if hasattr(self.db, "flush"): + await self.db.flush() + @staticmethod def _is_query_pending_clarification_expired(expires_at: Any) -> bool: if expires_at is None: @@ -375,6 +477,14 @@ class KernelIngressFlowEntryMixin: return None if pending_context is None: return None + if not self._has_explicit_thread_anchor(thread_context) and await self._should_skip_query_pending_clarification_followup_by_bundle( + message_text=message_text, + source_system=source_system, + ): + await self._clear_query_pending_clarification_context( + session_id=_safe_int(pending_context.get("session_id")), + ) + return None rag_service = AdaptiveRAGService(self.db, tenant_id=self.tenant_id) rag_kwargs = { @@ -1262,6 +1372,12 @@ class KernelIngressFlowEntryMixin: operator = ingress_prepare["operator"] work_item = ingress_prepare["work_item"] session = ingress_prepare["session"] + ingress_options = self._ensure_project_operating_context( + ingress_options=ingress_options, + project_hint_id=project_hint_id, + thread_context=thread_context, + session=session, + ) await _safe_begin_mounted_runtime( runtime_message_id=synthetic_message_id, runtime_message_text=clean_text, @@ -1431,6 +1547,13 @@ class KernelIngressFlowEntryMixin: project_hint_id=project_hint_id, session=session, ) + ingress_options = self._ensure_project_operating_context( + ingress_options=ingress_options, + project_hint_id=project_hint_id, + world_model=world_model, + thread_context=thread_context, + session=session, + ) _log_perf("_build_world_model", stage_started_at) self._schedule_portfolio_snapshot_persist(world_model=world_model) work_item.state = STATE_MODELED @@ -1439,6 +1562,11 @@ class KernelIngressFlowEntryMixin: if isinstance(ingress_options, dict) else None ) + project_operating_context = ( + ingress_options.get("project_operating_context") + if isinstance(ingress_options, dict) + else None + ) stage_started_at = time.perf_counter() intent, intent_context_source = await self._resolve_intent( message_text=clean_text, @@ -1472,6 +1600,11 @@ class KernelIngressFlowEntryMixin: **(intent.metadata or {}), "rag_request_context": dict(rag_request_context), } + if isinstance(project_operating_context, dict): + intent.metadata = { + **(intent.metadata or {}), + "project_operating_context": dict(project_operating_context), + } source_context_metadata = { "source_sender_open_id": str(source_sender_open_id or "").strip() or None, "source_chat_id": str(channel_ref or "").strip() or None, diff --git a/backend/app/services/ai_pmo/ai_pmo_kernel_world_model_core/mixin_builder.py b/backend/app/services/ai_pmo/ai_pmo_kernel_world_model_core/mixin_builder.py index a04e3fd5..be9cf32a 100644 --- a/backend/app/services/ai_pmo/ai_pmo_kernel_world_model_core/mixin_builder.py +++ b/backend/app/services/ai_pmo/ai_pmo_kernel_world_model_core/mixin_builder.py @@ -2,10 +2,16 @@ from __future__ import annotations +from pathlib import Path + from .shared import * # noqa: F401, F403 class KernelWorldModelCoreBuilderMixin: + @staticmethod + def _build_repo_scope() -> str: + return Path(__file__).resolve().parents[4].name + async def build_world_model( self, *, @@ -108,9 +114,15 @@ class KernelWorldModelCoreBuilderMixin: for row in active_projects_rows ] + repo_scope = self._build_repo_scope() world_model: WorldModel = { "sender": sender, "resolved_project_id": resolved_project_id, + "repo_scope": repo_scope, + "repo_summary": { + "scope": repo_scope, + "summary_mode": "backend_snapshot", + }, "project": project_snapshot, "project_domain": domain, "portfolio": portfolio_summary, diff --git a/backend/app/services/ai_pmo/project_operating_context_builder.py b/backend/app/services/ai_pmo/project_operating_context_builder.py new file mode 100644 index 00000000..c8d7fa9d --- /dev/null +++ b/backend/app/services/ai_pmo/project_operating_context_builder.py @@ -0,0 +1,106 @@ +"""Build a minimal unified project operating context packet.""" + +from __future__ import annotations + +from pathlib import Path +from typing import Any, Optional + +from app.schemas.project_operating_context import ProjectOperatingContext + + +def _safe_int(value: Any) -> Optional[int]: + try: + if value is None: + return None + return int(value) + except Exception: + return None + + +class ProjectOperatingContextBuilder: + def __init__(self, repo_scope: str | None = None) -> None: + self.repo_scope = str(repo_scope or "").strip() or self._default_repo_scope() + + @staticmethod + def _default_repo_scope() -> str: + return Path(__file__).resolve().parents[4].name + + @staticmethod + def _normalize_anchor(value: Any) -> dict[str, Any]: + if not isinstance(value, dict): + return {} + normalized: dict[str, Any] = {} + for key, raw in value.items(): + if not str(key or "").strip(): + continue + if raw in (None, "", [], {}): + continue + normalized[str(key).strip()] = raw + return normalized + + def build( + self, + *, + current_project_id: int | None = None, + page_anchor: dict[str, Any] | None = None, + thread_anchor: dict[str, Any] | None = None, + ) -> ProjectOperatingContext: + return ProjectOperatingContext( + current_project_id=_safe_int(current_project_id), + current_repo_scope=self.repo_scope, + page_anchor=self._normalize_anchor(page_anchor), + thread_anchor=self._normalize_anchor(thread_anchor), + ) + + def build_from_ingress( + self, + *, + project_hint_id: int | None = None, + rag_request_context: dict[str, Any] | None = None, + thread_context: dict[str, Any] | None = None, + world_model: dict[str, Any] | None = None, + session_project_id: int | None = None, + existing_packet: dict[str, Any] | None = None, + ) -> ProjectOperatingContext: + context = rag_request_context if isinstance(rag_request_context, dict) else {} + world = world_model if isinstance(world_model, dict) else {} + existing = existing_packet if isinstance(existing_packet, dict) else {} + + page_anchor = self._normalize_anchor(existing.get("page_anchor")) + if not page_anchor: + requested_project_id = _safe_int(context.get("project_id")) or _safe_int(project_hint_id) + source_chat_id = str(context.get("source_chat_id") or "").strip() + if requested_project_id is not None: + page_anchor["project_id"] = requested_project_id + if source_chat_id: + page_anchor["source_chat_id"] = source_chat_id + + thread_anchor = self._normalize_anchor(existing.get("thread_anchor")) + if not thread_anchor: + raw_thread = thread_context if isinstance(thread_context, dict) else {} + reply_to_message_id = str(raw_thread.get("reply_to_message_id") or "").strip() + root_message_id = str(raw_thread.get("root_message_id") or "").strip() + if reply_to_message_id: + thread_anchor["reply_to_message_id"] = reply_to_message_id + if root_message_id: + thread_anchor["root_message_id"] = root_message_id + + current_project_id = ( + _safe_int(world.get("resolved_project_id")) + or _safe_int(existing.get("current_project_id")) + or _safe_int(session_project_id) + or _safe_int(context.get("project_id")) + or _safe_int(project_hint_id) + ) + repo_scope = str( + world.get("repo_scope") + or existing.get("current_repo_scope") + or self.repo_scope + or "" + ).strip() or self.repo_scope + + return ProjectOperatingContextBuilder(repo_scope=repo_scope).build( + current_project_id=current_project_id, + page_anchor=page_anchor, + thread_anchor=thread_anchor, + ) diff --git a/backend/app/services/external_query_service/gateway.py b/backend/app/services/external_query_service/gateway.py index 47555247..0c0a6f32 100644 --- a/backend/app/services/external_query_service/gateway.py +++ b/backend/app/services/external_query_service/gateway.py @@ -1,5 +1,6 @@ from __future__ import annotations +import inspect from typing import Any from urllib.parse import urlparse @@ -22,11 +23,24 @@ class ExternalQueryGateway: *, question: str, query_semantics: dict[str, Any] | None = None, + fit_brief: dict[str, Any] | None = None, ) -> ExternalQueryExecution: - planned = await self.planner.build_plan( - question=question, - query_semantics=query_semantics, - ) + build_plan = getattr(self.planner, "build_plan") + try: + parameters = inspect.signature(build_plan).parameters + except Exception: + parameters = {} + if "fit_brief" in parameters: + planned = await build_plan( + question=question, + query_semantics=query_semantics, + fit_brief=fit_brief, + ) + else: + planned = await build_plan( + question=question, + query_semantics=query_semantics, + ) planner_meta: dict[str, Any] = {} if isinstance(planned, tuple) and len(planned) == 2: plan, planner_meta = planned @@ -66,6 +80,7 @@ class ExternalQueryGateway: meta = dict(selected.meta or {}) meta["fallback_used"] = bool(fallback_used) meta["adapter_attempts"] = adapter_attempts + meta["selected_status"] = str(selected.status or "").strip() or "empty" return ExternalQueryExecution( plan=plan, adapter_name=selected.adapter_name, diff --git a/backend/app/services/external_query_service/planner.py b/backend/app/services/external_query_service/planner.py index 8c2c8173..2f2e0525 100644 --- a/backend/app/services/external_query_service/planner.py +++ b/backend/app/services/external_query_service/planner.py @@ -5,6 +5,7 @@ import re from typing import Any, Callable from ...config.ai_prompts import PROMPT_EXTERNAL_QUERY_PLAN +from ...schemas.project_operating_context import ProjectFitBrief from .schemas import ExternalQueryPlan @@ -29,18 +30,26 @@ class ExternalQueryPlanner: *, question: str, query_semantics: dict[str, Any] | None = None, + fit_brief: dict[str, Any] | None = None, ) -> tuple[ExternalQueryPlan, dict[str, Any]]: fallback_plan = self._build_fallback_plan( question=question, query_semantics=query_semantics, + fit_brief=fit_brief, ) generator = getattr(self.llm_service, "generate_structured_object", None) if not callable(generator): - return fallback_plan, {"planner": "heuristic", "fallback_taken": True} + return fallback_plan, { + "planner": "heuristic", + "fallback_taken": True, + "fit_brief_summary": fallback_plan.fit_brief_summary, + } + normalized_fit_brief = self._normalize_fit_brief(fit_brief) prompt = PROMPT_EXTERNAL_QUERY_PLAN.format( question=str(question or "").strip()[:1200], query_semantics=json.dumps(query_semantics or {}, ensure_ascii=False)[:4000], + fit_brief=json.dumps(normalized_fit_brief, ensure_ascii=False)[:2500], fallback_queries=json.dumps(fallback_plan.search_queries, ensure_ascii=False), ).strip() try: @@ -70,12 +79,17 @@ class ExternalQueryPlanner: plan.preferred_domains = list(fallback_plan.preferred_domains) if not plan.topic: plan.topic = fallback_plan.topic - return plan, {"planner": "llm_structured", "fallback_taken": False} + return plan, { + "planner": "llm_structured", + "fallback_taken": False, + "fit_brief_summary": plan.fit_brief_summary or fallback_plan.fit_brief_summary, + } except Exception as exc: return fallback_plan, { "planner": "heuristic", "fallback_taken": True, "reason": str(exc)[:200], + "fit_brief_summary": fallback_plan.fit_brief_summary, } def _build_fallback_plan( @@ -83,10 +97,12 @@ class ExternalQueryPlanner: *, question: str, query_semantics: dict[str, Any] | None = None, + fit_brief: dict[str, Any] | None = None, ) -> ExternalQueryPlan: text = re.sub(r"\s+", " ", str(question or "")).strip() semantics = query_semantics if isinstance(query_semantics, dict) else {} query_profile = semantics.get("query_profile") if isinstance(semantics.get("query_profile"), dict) else {} + normalized_fit_brief = self._normalize_fit_brief(fit_brief) object_mentions = [ str(item or "").strip() for item in list(semantics.get("object_mentions") or []) @@ -102,7 +118,10 @@ class ExternalQueryPlanner: answer_contract = "direct_fact" topic = text[:120] or "外部查询" - search_queries = self._build_search_queries(text) + search_queries = self._build_search_queries( + text, + fit_brief=normalized_fit_brief, + ) preferred_domains = self._infer_preferred_domains(text) subject_entities = object_mentions[:6] location_hint = None @@ -110,6 +129,8 @@ class ExternalQueryPlanner: requires_multi_source = answer_contract == "compare_with_sources" clarify_on_ambiguity = False result_limit = 3 if requires_multi_source or bool(query_profile.get("needs_freshness")) else 2 + fit_brief_summary = self._summarize_fit_brief(normalized_fit_brief) + project_object_anchors = self._collect_project_object_anchors(normalized_fit_brief) return ExternalQueryPlan( plan_version="v1", @@ -125,19 +146,32 @@ class ExternalQueryPlanner: result_limit=result_limit, requires_multi_source=requires_multi_source, clarify_on_ambiguity=clarify_on_ambiguity, + fit_brief_summary=fit_brief_summary, + project_scope_hint="current_project_and_repo" if fit_brief_summary else None, + recommendation_goal=self._build_recommendation_goal( + question=text, + fit_brief=normalized_fit_brief, + ), + project_object_anchors=project_object_anchors, ) - def _build_search_queries(self, question: str) -> list[str]: + def _build_search_queries( + self, + question: str, + *, + fit_brief: dict[str, Any] | None = None, + ) -> list[str]: if callable(self.query_builder): try: queries = self.query_builder(question) normalized = [str(item or "").strip() for item in list(queries or []) if str(item or "").strip()] if normalized: - return normalized[:5] + return self._merge_fit_brief_queries(normalized, fit_brief=fit_brief) except Exception: pass text = re.sub(r"\s+", " ", str(question or "")).strip() - return [text] if text else [] + queries = [text] if text else [] + return self._merge_fit_brief_queries(queries, fit_brief=fit_brief) @staticmethod def _infer_preferred_domains(question: str) -> list[str]: @@ -161,3 +195,72 @@ class ExternalQueryPlanner: if match: return str(match.group(0) or "").strip() or None return None + + @staticmethod + def _normalize_fit_brief(payload: dict[str, Any] | None) -> dict[str, Any]: + if not isinstance(payload, dict): + return {} + try: + return ProjectFitBrief.model_validate(payload).model_dump() + except Exception: + return {} + + @staticmethod + def _summarize_fit_brief(fit_brief: dict[str, Any] | None) -> str: + brief = fit_brief if isinstance(fit_brief, dict) else {} + parts: list[str] = [] + project_summary = str(brief.get("project_summary") or "").strip() + repo_summary = str(brief.get("repo_summary") or "").strip() + capabilities = [ + str(item or "").strip() + for item in list(brief.get("current_capabilities") or []) + if str(item or "").strip() + ] + if project_summary: + parts.append(project_summary) + if repo_summary: + parts.append(repo_summary) + if capabilities: + parts.append(f"当前能力:{'、'.join(capabilities[:4])}") + return " ".join(parts)[:600] + + @staticmethod + def _collect_project_object_anchors(fit_brief: dict[str, Any] | None) -> list[str]: + brief = fit_brief if isinstance(fit_brief, dict) else {} + anchors: list[str] = [] + for pool_name in ("signal_source_anchors", "current_capabilities", "recommendation_focus"): + for item in list(brief.get(pool_name) or []): + normalized = str(item or "").strip() + if normalized and normalized not in anchors: + anchors.append(normalized) + return anchors[:6] + + def _build_recommendation_goal(self, *, question: str, fit_brief: dict[str, Any] | None) -> str | None: + focus = [ + str(item or "").strip() + for item in list((fit_brief or {}).get("recommendation_focus") or []) + if str(item or "").strip() + ] + if focus: + return ";".join(focus[:3])[:240] + text = str(question or "").strip() + if not text: + return None + return f"判断“{text[:80]}”是否适合当前项目接入或跟进。" + + def _merge_fit_brief_queries( + self, + queries: list[str], + *, + fit_brief: dict[str, Any] | None = None, + ) -> list[str]: + normalized = [str(item or "").strip() for item in list(queries or []) if str(item or "").strip()] + if not normalized: + return [] + anchors = self._collect_project_object_anchors(fit_brief) + if anchors: + anchor = anchors[0] + candidate = f"{normalized[0]} {anchor}".strip() + if candidate and candidate not in normalized: + normalized.append(candidate) + return normalized[:5] diff --git a/backend/app/services/external_query_service/presenter.py b/backend/app/services/external_query_service/presenter.py index 3c0ddc43..c4a3f18e 100644 --- a/backend/app/services/external_query_service/presenter.py +++ b/backend/app/services/external_query_service/presenter.py @@ -90,6 +90,46 @@ class ExternalAnswerPresenter: rows.append(f"对象: {'、'.join([str(item).strip() for item in plan.subject_entities[:5] if str(item).strip()])}") return "\n".join(rows) + @staticmethod + def _build_failure_response(*, question: str, retrieval_status: str) -> dict[str, str]: + short_question = str(question or "").strip()[:80] + if retrieval_status == "verifier_blocked": + return { + "answer_text": ( + f"关于“{short_question}”,我拿到过外部候选结果,但它们没有通过核验," + "所以先不给你直接结论。你如果愿意,我可以继续按更明确的对象或范围再检一次。" + ), + "prompt_variant_id": "external_verifier_blocked", + } + if retrieval_status == "no_relevant_results": + return { + "answer_text": ( + f"关于“{short_question}”,外部检索已经执行完成,但没有找到足够相关的外部公开证据。" + "现阶段我不建议把它直接当成当前项目的有效外部变化。" + ), + "prompt_variant_id": "external_no_relevant_results", + } + return { + "answer_text": ( + f"关于“{short_question}”,外部检索这次执行失败,没有产出可用的外部公开证据。" + "你可以稍后重试,或把要核验的公开对象说得更具体一些。" + ), + "prompt_variant_id": "external_retrieval_failed", + } + + @classmethod + def _build_indirect_only_response(cls, *, question: str, packet: ExternalContextPacket) -> dict[str, str]: + short_question = str(question or "").strip()[:80] + source_summaries = cls._build_source_summaries(packet) + return { + "answer_text": ( + f"关于“{short_question}”,当前外部证据只能说明它和当前项目有背景相关性," + "不能直接说明它已经命中当前项目对象。" + f"现有来源:{source_summaries}。如果你要,我可以继续把它整理成一份映射验证清单。" + ), + "prompt_variant_id": "external_indirect_only", + } + async def _render_prompt( self, *, @@ -119,6 +159,18 @@ class ExternalAnswerPresenter: if isinstance(context_packet, ExternalContextPacket) else ExternalContextPacket.model_validate(context_packet) ) + retrieval_status = str(packet.retrieval_status or "").strip().lower() or "retrieval_failed" + impact_status = str(packet.impact_status or "").strip().lower() or "no_direct_impact" + if retrieval_status != "evidence_found": + return self._build_failure_response( + question=question, + retrieval_status=retrieval_status, + ) + if impact_status == "indirect_only": + return self._build_indirect_only_response( + question=question, + packet=packet, + ) verifier = dict(packet.verifier or {}) if not packet.verified_evidence or not bool(verifier.get("ok")): return { diff --git a/backend/app/services/external_query_service/schemas.py b/backend/app/services/external_query_service/schemas.py index b63ee350..15da5d41 100644 --- a/backend/app/services/external_query_service/schemas.py +++ b/backend/app/services/external_query_service/schemas.py @@ -7,6 +7,8 @@ from pydantic import BaseModel, ConfigDict, Field ExternalQueryKind = Literal["weather", "news", "company", "model", "market", "generic_web", "unknown"] ExternalAnswerContract = Literal["direct_fact", "summary_with_sources", "compare_with_sources", "clarify"] +ExternalRetrievalStatus = Literal["evidence_found", "verifier_blocked", "no_relevant_results", "retrieval_failed"] +ExternalImpactStatus = Literal["direct_match", "indirect_only", "no_direct_impact"] class ExternalQueryPlan(BaseModel): @@ -25,6 +27,10 @@ class ExternalQueryPlan(BaseModel): result_limit: int = Field(default=3, ge=1, le=5) requires_multi_source: bool = False clarify_on_ambiguity: bool = False + fit_brief_summary: str = "" + project_scope_hint: str | None = None + recommendation_goal: str | None = None + project_object_anchors: list[str] = Field(default_factory=list) class ExternalEvidenceItem(BaseModel): @@ -61,6 +67,30 @@ class ExternalQueryExecution(BaseModel): planner_meta: dict[str, Any] = Field(default_factory=dict) meta: dict[str, Any] = Field(default_factory=dict) + def derive_retrieval_status(self) -> ExternalRetrievalStatus: + verifier = dict(self.verifier or {}) + attempts = list((self.meta or {}).get("adapter_attempts") or []) + if self.evidence and bool(verifier.get("ok")): + return "evidence_found" + if self.evidence and not bool(verifier.get("ok")): + return "verifier_blocked" + + if any( + int(item.get("evidence_count") or 0) > 0 and not bool(item.get("verifier_ok")) + for item in attempts + if isinstance(item, dict) + ): + return "verifier_blocked" + + statuses = { + str(item.get("status") or "").strip().lower() + for item in attempts + if isinstance(item, dict) and str(item.get("status") or "").strip() + } + if any(status in {"success", "empty"} for status in statuses): + return "no_relevant_results" + return "retrieval_failed" + def to_context_packet(self, *, question: str) -> "ExternalContextPacket": verifier = dict(self.verifier or {}) uncertainties: list[str] = [] @@ -78,6 +108,9 @@ class ExternalQueryExecution(BaseModel): verified_evidence=list(self.evidence or []) if bool(verifier.get("ok")) else [], verifier=verifier, uncertainties=uncertainties, + retrieval_status=self.derive_retrieval_status(), + impact_status="no_direct_impact", + affected_signal_sources=[], ) @@ -90,3 +123,6 @@ class ExternalContextPacket(BaseModel): verified_evidence: list[ExternalEvidenceItem] = Field(default_factory=list) verifier: dict[str, Any] = Field(default_factory=dict) uncertainties: list[str] = Field(default_factory=list) + retrieval_status: ExternalRetrievalStatus = "retrieval_failed" + impact_status: ExternalImpactStatus = "no_direct_impact" + affected_signal_sources: list[str] = Field(default_factory=list) diff --git a/backend/app/services/integrations/feishu_integration_service/mixin_receive_routing.py b/backend/app/services/integrations/feishu_integration_service/mixin_receive_routing.py index 15135007..7224ba51 100644 --- a/backend/app/services/integrations/feishu_integration_service/mixin_receive_routing.py +++ b/backend/app/services/integrations/feishu_integration_service/mixin_receive_routing.py @@ -3,6 +3,866 @@ from __future__ import annotations from .shared import * class FeishuReceiveRoutingMixin: + _FEISHU_QA_BANNED_OPENERS = ( + "先说我的判断", + "我先说结论", + "我先说我的判断", + "这事可以继续看", + "如果你要,我可以", + ) + _FEISHU_QA_BANNED_PHRASES = ( + "当前项目:", + "外部判断:", + "下一步:", + "如果你要,我可以", + ) + + @staticmethod + def _normalize_feishu_qa_summary_text( + value: Any, + *, + max_len: int = 220, + ) -> str: + text = re.sub(r"\s+", " ", str(value or "")).strip() + if not text: + return "" + return text[:max_len] + + @classmethod + def _compose_feishu_qa_line( + cls, + prefix: str, + parts: list[str], + ) -> Optional[str]: + cleaned_parts: list[str] = [] + for index, part in enumerate(parts): + normalized = cls._normalize_feishu_qa_summary_text(part) + if not normalized: + continue + if index < len(parts) - 1: + normalized = normalized.rstrip("。;;,, ") + cleaned_parts.append(normalized) + if not cleaned_parts: + return None + return f"{prefix}{';'.join(cleaned_parts)}" + + @classmethod + def _strip_feishu_qa_label(cls, value: Any, *, prefix: str) -> str: + normalized = str(value or "").strip() + if normalized.startswith(prefix): + return normalized[len(prefix):].strip() + return normalized + + @staticmethod + def _trim_feishu_qa_clause(value: Any) -> str: + return str(value or "").strip().rstrip("。;;,, ") + + @classmethod + def _build_feishu_fit_brief_summary_text(cls, fit_brief: Any) -> str: + payload = fit_brief if isinstance(fit_brief, dict) else {} + parts: list[str] = [] + project_summary = cls._trim_feishu_qa_clause( + cls._strip_feishu_qa_label( + cls._normalize_feishu_qa_summary_text(payload.get("project_summary")), + prefix="当前项目:", + ) + ) + repo_summary = cls._trim_feishu_qa_clause( + cls._normalize_feishu_qa_summary_text(payload.get("repo_summary")) + ) + capabilities = [ + cls._normalize_feishu_qa_summary_text(item, max_len=40) + for item in list(payload.get("current_capabilities") or [])[:2] + if cls._normalize_feishu_qa_summary_text(item, max_len=40) + ] + if project_summary: + parts.append(project_summary) + if repo_summary: + parts.append(repo_summary) + elif capabilities: + parts.append(f"当前能力包括{'、'.join(capabilities)}") + return ",".join(parts) + + @classmethod + def _build_feishu_fit_brief_line(cls, fit_brief: Any) -> Optional[str]: + summary = cls._build_feishu_fit_brief_summary_text(fit_brief) + if not summary: + return None + return f"当前项目:{summary}" + + @classmethod + def _build_feishu_external_summary_text(cls, external_recommendation: Any) -> str: + payload = external_recommendation if isinstance(external_recommendation, dict) else {} + retrieval_status = str(payload.get("retrieval_status") or "").strip().lower() + impact_status = str(payload.get("impact_status") or "").strip().lower() + explicit_summary = cls._normalize_feishu_qa_summary_text(payload.get("external_impact")) + if not explicit_summary: + explicit_summary = cls._normalize_feishu_qa_summary_text(payload.get("external_summary")) + if not explicit_summary: + if retrieval_status == "verifier_blocked": + explicit_summary = "外部结果已拿到,但校验未通过,当前不能把它当成可靠结论。" + elif retrieval_status == "no_relevant_results": + explicit_summary = "已尝试外部检索,但暂未找到与当前项目直接相关的公开证据。" + elif retrieval_status == "retrieval_failed": + explicit_summary = "这次外部检索失败,当前结论不能当成已完成外部核实。" + elif retrieval_status == "evidence_found" and impact_status == "indirect_only": + explicit_summary = "目前更像背景相关,还不能直接映射到当前正式对象。" + elif retrieval_status == "evidence_found" and impact_status == "direct_match": + explicit_summary = "已找到能直接映射当前项目判断的外部证据。" + if not explicit_summary: + explicit_summary = cls._normalize_feishu_qa_summary_text(payload.get("external_action")) + if not explicit_summary: + return "" + return cls._strip_feishu_qa_label(explicit_summary, prefix="外部判断:") + + @classmethod + def _build_feishu_external_summary_line(cls, external_recommendation: Any) -> Optional[str]: + summary = cls._build_feishu_external_summary_text(external_recommendation) + if not summary: + return None + return f"外部判断:{summary}" + + @classmethod + def _build_feishu_next_step_text(cls, recommended_next_step: Any) -> str: + normalized = cls._normalize_feishu_qa_summary_text(recommended_next_step, max_len=240) + if not normalized: + return "" + return cls._strip_feishu_qa_label(normalized, prefix="下一步:") + + @classmethod + def _build_feishu_next_step_line(cls, recommended_next_step: Any) -> Optional[str]: + summary = cls._build_feishu_next_step_text(recommended_next_step) + if not summary: + return None + return f"下一步:{summary}" + + @classmethod + def _clean_feishu_qa_natural_reply(cls, value: Any) -> str: + text = str(value or "").strip() + if text.startswith("```"): + text = text.replace("```", "").strip() + if text.lower().startswith("json"): + text = text[4:].strip() + if text: + text = "\n".join(line.rstrip() for line in text.splitlines()) + text = re.sub(r"\n{3,}", "\n\n", text) + return text[:4000] + + @classmethod + def _normalize_feishu_entity_candidates(cls, entity_candidates: list[str]) -> list[str]: + normalized: list[str] = [] + for raw in entity_candidates: + text = re.sub(r"\s+", "", str(raw or "")).strip() + if not text: + continue + variants = { + text, + re.sub(r"(.*?)|\(.*?\)", "", text).strip(), + } + if len(text) >= 6: + variants.update(text[idx: idx + 6] for idx in range(0, max(len(text) - 5, 1))) + variants.update(text[idx: idx + 8] for idx in range(0, max(len(text) - 7, 1))) + for variant in variants: + cleaned = str(variant or "").strip() + if cleaned and cleaned not in normalized: + normalized.append(cleaned) + return normalized + + @staticmethod + def _normalize_feishu_entity_key(value: Any) -> str: + text = re.sub(r"\s+", "", str(value or "")).strip().lower() + text = re.sub(r"(.*?)|\(.*?\)", "", text).strip() + return text + + @staticmethod + def _normalize_feishu_focus_stance(value: Any) -> str: + stance = str(value or "").strip().lower() + return stance if stance in {"better_fit", "not_fit", "anchor", "dependent", "blocked", "relevant"} else "relevant" + + @classmethod + def _clean_feishu_focus_why(cls, value: Any) -> str: + text = cls._normalize_feishu_qa_summary_text(value, max_len=160) + text = re.sub(r"^(这里|这一步|这一层|这个位置|这条链路|当前|它)(更像|属于|是|更适合)", "", text).strip() + text = text.lstrip(",,:: ") + return text.rstrip("。;;,, ") + + @classmethod + def _build_feishu_entity_catalog(cls, project_snapshot: dict[str, Any]) -> list[dict[str, str]]: + catalog: list[dict[str, str]] = [] + + def _append_entity(name: Any, kind: str) -> None: + normalized = str(name or "").strip() + if not normalized: + return + if any(item["name"] == normalized for item in catalog): + return + catalog.append({"name": normalized, "kind": kind}) + + for item in list(project_snapshot.get("task_titles") or [])[:4]: + _append_entity(item, "task") + for item in list(project_snapshot.get("signal_titles") or [])[:4]: + _append_entity(item, "signal") + if not catalog: + _append_entity(project_snapshot.get("project_name"), "project") + return catalog + + @classmethod + def _match_feishu_entity_catalog_item( + cls, + raw_name: Any, + *, + entity_catalog: list[dict[str, str]], + ) -> Optional[dict[str, str]]: + target_key = cls._normalize_feishu_entity_key(raw_name) + if not target_key: + return None + for item in entity_catalog: + item_key = cls._normalize_feishu_entity_key(item.get("name")) + if item_key == target_key: + return item + for item in entity_catalog: + item_key = cls._normalize_feishu_entity_key(item.get("name")) + if target_key and item_key and (target_key in item_key or item_key in target_key): + return item + return None + + @classmethod + def _normalize_feishu_qa_analysis_plan( + cls, + *, + payload: Any, + entity_catalog: list[dict[str, str]], + fallback_next_step: str, + ) -> dict[str, Any]: + if not isinstance(payload, dict): + return {} + + conclusion = cls._normalize_feishu_qa_summary_text(payload.get("conclusion"), max_len=160) + missing_mapping = cls._normalize_feishu_qa_summary_text(payload.get("missing_mapping"), max_len=220) + next_step = cls._normalize_feishu_qa_summary_text( + payload.get("next_step") or fallback_next_step, + max_len=180, + ) + + focus_entities: list[dict[str, str]] = [] + raw_focus_entities = payload.get("focus_entities") if isinstance(payload.get("focus_entities"), list) else [] + for raw_focus in raw_focus_entities: + if len(focus_entities) >= 2: + break + if isinstance(raw_focus, dict): + raw_name = raw_focus.get("name") or raw_focus.get("entity_name") + raw_why = raw_focus.get("why") or raw_focus.get("relationship") or raw_focus.get("reason") + else: + raw_name = raw_focus + raw_why = "" + matched = cls._match_feishu_entity_catalog_item( + raw_name, + entity_catalog=entity_catalog, + ) + if not matched: + continue + if any(item["name"] == matched["name"] for item in focus_entities): + continue + why = cls._normalize_feishu_qa_summary_text(raw_why, max_len=160) + if not why: + continue + stance = cls._normalize_feishu_focus_stance( + raw_focus.get("stance") if isinstance(raw_focus, dict) else "" + ) + focus_entities.append( + { + "name": matched["name"], + "kind": matched.get("kind", ""), + "stance": stance, + "why": why, + } + ) + + if not conclusion or not missing_mapping or not focus_entities: + return {} + return { + "conclusion": conclusion, + "focus_entities": focus_entities, + "missing_mapping": missing_mapping, + "next_step": next_step, + } + + @classmethod + def _should_accept_feishu_qa_natural_reply( + cls, + value: Any, + *, + selected_entity_candidates: list[str], + blocked_entity_candidates: list[str], + ) -> bool: + text = cls._clean_feishu_qa_natural_reply(value) + if not text: + return False + paragraphs = [chunk.strip() for chunk in re.split(r"\n\s*\n", text) if str(chunk).strip()] + lead = paragraphs[0] if paragraphs else text + normalized_lead = re.sub(r'^[\s>"\'“”‘’「」【】\(\)\[\]\-—::、\d\.]+', "", lead).strip() + if any(normalized_lead.startswith(prefix) for prefix in cls._FEISHU_QA_BANNED_OPENERS): + return False + if any(token in text for token in cls._FEISHU_QA_BANNED_PHRASES): + return False + if re.search(r"(^|\n)\s*[-*•]\s", text): + return False + normalized_text = re.sub(r"\s+", "", text) + selected_candidates = cls._normalize_feishu_entity_candidates(selected_entity_candidates) + blocked_candidates = cls._normalize_feishu_entity_candidates(blocked_entity_candidates) + if selected_candidates and not any(candidate and candidate in normalized_text for candidate in selected_candidates): + return False + if blocked_candidates and any(candidate and candidate in normalized_text for candidate in blocked_candidates): + return False + return True + + @staticmethod + def _extract_feishu_project_id(result: Dict[str, Any], rag_result: dict[str, Any]) -> int | None: + project_context = rag_result.get("project_operating_context") if isinstance(rag_result.get("project_operating_context"), dict) else {} + current_project_id = project_context.get("current_project_id") + try: + return int(current_project_id) if current_project_id is not None else None + except Exception: + return None + + async def _load_feishu_project_entity_snapshot(self, project_id: int | None) -> dict[str, Any]: + if project_id is None or not hasattr(getattr(self, "db", None), "execute"): + return {} + project_row = ( + await self.db.execute( + select(Project).where(Project.id == int(project_id)) + ) + ).scalar_one_or_none() + if project_row is None: + return {} + task_rows = ( + await self.db.execute( + select(Task).where(Task.project_id == int(project_id)).order_by(Task.id.desc()).limit(4) + ) + ).scalars().all() + signal_rows = ( + await self.db.execute( + select(Signal).where(Signal.matched_project_id == int(project_id)).order_by(Signal.id.desc()).limit(4) + ) + ).scalars().all() + return { + "project_name": str(getattr(project_row, "title", None) or getattr(project_row, "name", "") or "").strip(), + "project_description": str(getattr(project_row, "description", "") or "").strip(), + "task_titles": [str(getattr(row, "title", "") or "").strip() for row in task_rows if str(getattr(row, "title", "") or "").strip()], + "signal_titles": [str(getattr(row, "title", "") or "").strip() for row in signal_rows if str(getattr(row, "title", "") or "").strip()], + } + + @classmethod + def _build_feishu_project_summary_text( + cls, + *, + fit_brief: Any, + project_snapshot: dict[str, Any], + ) -> str: + parts: list[str] = [] + project_name = cls._normalize_feishu_qa_summary_text(project_snapshot.get("project_name"), max_len=120) + project_description = cls._normalize_feishu_qa_summary_text(project_snapshot.get("project_description"), max_len=180) + fit_summary = cls._build_feishu_fit_brief_summary_text(fit_brief) + if project_name: + parts.append(project_name) + if project_description: + parts.append(project_description) + if fit_summary and fit_summary not in parts: + parts.append(fit_summary) + return "\n".join(parts[:3]).strip() + + @classmethod + def _build_feishu_project_entities_text(cls, project_snapshot: dict[str, Any]) -> str: + lines: list[str] = [] + task_titles = [cls._normalize_feishu_qa_summary_text(item, max_len=80) for item in list(project_snapshot.get("task_titles") or [])[:4]] + signal_titles = [cls._normalize_feishu_qa_summary_text(item, max_len=80) for item in list(project_snapshot.get("signal_titles") or [])[:4]] + task_titles = [item for item in task_titles if item] + signal_titles = [item for item in signal_titles if item] + if task_titles: + lines.append("当前任务:") + lines.extend(f"- {item}" for item in task_titles) + if signal_titles: + lines.append("当前信号:") + lines.extend(f"- {item}" for item in signal_titles) + return "\n".join(lines).strip() + + @classmethod + def _collect_feishu_project_entity_candidates(cls, project_snapshot: dict[str, Any]) -> list[str]: + candidates: list[str] = [] + for value in [ + project_snapshot.get("project_name"), + *(list(project_snapshot.get("task_titles") or [])[:4]), + *(list(project_snapshot.get("signal_titles") or [])[:4]), + ]: + normalized = str(value or "").strip() + if normalized and normalized not in candidates: + candidates.append(normalized) + return candidates + + @classmethod + def _pick_feishu_entity_by_keywords( + cls, + entity_catalog: list[dict[str, str]], + *, + kind: str | None = None, + keywords: list[str] | None = None, + used_names: set[str] | None = None, + ) -> Optional[dict[str, str]]: + used = used_names or set() + candidates = [ + item + for item in entity_catalog + if item.get("name") not in used and (kind is None or item.get("kind") == kind) + ] + if not candidates: + return None + if not keywords: + return candidates[0] + lowered_keywords = [str(keyword or "").strip().lower() for keyword in keywords if str(keyword or "").strip()] + for item in candidates: + name = str(item.get("name") or "").lower() + if any(keyword in name for keyword in lowered_keywords): + return item + return candidates[0] + + @classmethod + def _build_feishu_qa_analysis_fallback_plan( + cls, + *, + result: Dict[str, Any], + entity_catalog: list[dict[str, str]], + ) -> dict[str, Any]: + rag_result = result.get("rag_result") if isinstance(result.get("rag_result"), dict) else {} + external_payload = rag_result.get("external_recommendation") if isinstance(rag_result.get("external_recommendation"), dict) else {} + retrieval_status = str(external_payload.get("retrieval_status") or "").strip().lower() + impact_status = str(external_payload.get("impact_status") or "").strip().lower() + next_step = cls._build_feishu_next_step_text(rag_result.get("recommended_next_step")) + used_names: set[str] = set() + focus_entities: list[dict[str, str]] = [] + + def _append_focus(entity: Optional[dict[str, str]], why: str, stance: str) -> None: + if not entity: + return + name = str(entity.get("name") or "").strip() + if not name or name in used_names or len(focus_entities) >= 2: + return + focus_entities.append( + { + "name": name, + "kind": str(entity.get("kind") or "").strip(), + "stance": cls._normalize_feishu_focus_stance(stance), + "why": cls._normalize_feishu_qa_summary_text(why, max_len=160), + } + ) + used_names.add(name) + + if retrieval_status == "verifier_blocked": + _append_focus( + cls._pick_feishu_entity_by_keywords( + entity_catalog, + kind="signal", + used_names=used_names, + ), + "这条信号源当前还没通过校验,不能直接挂到项目结论上。", + "blocked", + ) + _append_focus( + cls._pick_feishu_entity_by_keywords( + entity_catalog, + kind="task", + keywords=["排序", "卡片", "拉取", "清洗"], + used_names=used_names, + ), + "这一步还没有拿到能稳定消费这条外部变化的依据。", + "dependent", + ) + conclusion = "这条外部变化现在还不能直接落当前项目。" + missing_mapping = "还缺校验通过后的字段映射和消费位置。" + elif impact_status == "direct_match": + _append_focus( + cls._pick_feishu_entity_by_keywords( + entity_catalog, + kind="signal", + used_names=used_names, + ), + "这条信号源已经和当前外部变化建立了直接映射。", + "anchor", + ) + _append_focus( + cls._pick_feishu_entity_by_keywords( + entity_catalog, + kind="task", + used_names=used_names, + ), + "这项任务是当前最直接会受影响的项目对象。", + "dependent", + ) + conclusion = "这条外部变化已经和当前项目对象对上了。" + missing_mapping = "还缺它具体作用到哪个字段或触发条件的明确信息。" + else: + _append_focus( + cls._pick_feishu_entity_by_keywords( + entity_catalog, + kind="task", + keywords=["摘要", "卡片", "推送", "解释", "追问"], + used_names=used_names, + ), + "这一步更像结果出来之后的解释和追问层。", + "better_fit", + ) + _append_focus( + cls._pick_feishu_entity_by_keywords( + entity_catalog, + kind="task", + keywords=["拉取", "清洗", "排序", "逻辑", "接口"], + used_names=used_names, + ), + "这一步属于确定性取数或计算主链,不适合直接塞进生成能力。", + "not_fit", + ) + conclusion = "目前还不能直接说它已经适合当前项目。" + missing_mapping = "还缺它在当前项目里到底承接哪个节点的明确映射。" + + if not focus_entities and entity_catalog: + _append_focus( + entity_catalog[0], + "这是当前最直接能挂住这次判断的项目对象。", + "relevant", + ) + if not focus_entities: + return {} + return { + "conclusion": conclusion, + "focus_entities": focus_entities[:2], + "missing_mapping": missing_mapping, + "next_step": next_step, + } + + @classmethod + def _choose_feishu_qa_analysis_plan( + cls, + *, + normalized_plan: dict[str, Any], + fallback_plan: dict[str, Any], + retrieval_status: str, + impact_status: str, + ) -> dict[str, Any]: + plan = normalized_plan if isinstance(normalized_plan, dict) else {} + fallback = fallback_plan if isinstance(fallback_plan, dict) else {} + if not plan: + return fallback + + focus_entities = plan.get("focus_entities") if isinstance(plan.get("focus_entities"), list) else [] + stances = [cls._normalize_feishu_focus_stance(item.get("stance")) for item in focus_entities if isinstance(item, dict)] + if retrieval_status == "evidence_found" and impact_status == "indirect_only": + if "better_fit" not in stances or "not_fit" not in stances: + return fallback or plan + if retrieval_status == "verifier_blocked": + if "blocked" not in stances: + return fallback or plan + if retrieval_status == "evidence_found" and impact_status == "direct_match": + if "anchor" not in stances: + return fallback or plan + return plan or fallback + + async def _generate_feishu_qa_analysis_plan( + self, + *, + result: Dict[str, Any], + message_content: str, + project_snapshot: dict[str, Any], + entity_catalog: list[dict[str, str]], + ) -> dict[str, Any]: + rag_result = result.get("rag_result") if isinstance(result.get("rag_result"), dict) else {} + fallback_next_step = self._build_feishu_next_step_text(rag_result.get("recommended_next_step")) + fallback_plan = self._build_feishu_qa_analysis_fallback_plan( + result=result, + entity_catalog=entity_catalog, + ) + external_payload = rag_result.get("external_recommendation") if isinstance(rag_result.get("external_recommendation"), dict) else {} + retrieval_status = str(external_payload.get("retrieval_status") or "").strip().lower() + impact_status = str(external_payload.get("impact_status") or "").strip().lower() + if not entity_catalog: + return fallback_plan + prompt = PROMPT_FEISHU_QA_NATURAL_REPLY.format( + question=str(message_content or "").strip()[:500] or str(result.get("direct_reply_text") or "").strip()[:500], + answer_summary=str(result.get("direct_reply_text") or "").strip()[:600], + project_summary=self._build_feishu_project_summary_text( + fit_brief=rag_result.get("fit_brief"), + project_snapshot=project_snapshot, + )[:600], + project_entities=json.dumps(entity_catalog, ensure_ascii=False)[:1200] or "[]", + external_assessment=self._build_feishu_external_summary_text(rag_result.get("external_recommendation"))[:400] or "无", + next_step=fallback_next_step[:400] or "无", + ) + llm = getattr(self, "llm", None) or LLMService() + generator = getattr(llm, "generate_structured_object", None) + if not callable(generator): + return fallback_plan + try: + structured = await generator( + prompt=prompt, + db=getattr(self, "db", None), + tenant_id=int(getattr(self, "tenant_id", 1) or 1), + function_name="feishu_qa_analysis_plan", + use_prompt_profile=False, + temperature=0.0, + response_mode="fast", + ) + payload = getattr(structured, "payload", None) + if payload is None and hasattr(structured, "model_dump"): + payload = structured.model_dump() + normalized_plan = self._normalize_feishu_qa_analysis_plan( + payload=payload, + entity_catalog=entity_catalog, + fallback_next_step=fallback_next_step, + ) + return self._choose_feishu_qa_analysis_plan( + normalized_plan=normalized_plan, + fallback_plan=fallback_plan, + retrieval_status=retrieval_status, + impact_status=impact_status, + ) + except Exception as exc: + print(f"[Feishu] generate qa analysis plan failed: {exc}") + return fallback_plan + + @classmethod + def _build_feishu_qa_analysis_plan_reply_text( + cls, + analysis_plan: dict[str, Any], + ) -> str: + if not isinstance(analysis_plan, dict): + return "" + conclusion = cls._normalize_feishu_qa_summary_text(analysis_plan.get("conclusion"), max_len=160) + missing_mapping = cls._normalize_feishu_qa_summary_text(analysis_plan.get("missing_mapping"), max_len=220) + next_step = cls._normalize_feishu_qa_summary_text(analysis_plan.get("next_step"), max_len=180) + focus_entities = analysis_plan.get("focus_entities") if isinstance(analysis_plan.get("focus_entities"), list) else [] + + paragraphs: list[str] = [] + if conclusion: + paragraphs.append(conclusion if conclusion.endswith("。") else f"{conclusion}。") + + normalized_focus_entities: list[dict[str, str]] = [] + for item in focus_entities[:2]: + if not isinstance(item, dict): + continue + name = cls._normalize_feishu_qa_summary_text(item.get("name"), max_len=80) + why = cls._clean_feishu_focus_why(item.get("why")) + stance = cls._normalize_feishu_focus_stance(item.get("stance")) + if not name or not why: + continue + normalized_focus_entities.append({"name": name, "why": why, "stance": stance}) + + focus_paragraph = "" + if len(normalized_focus_entities) >= 2: + first = normalized_focus_entities[0] + second = normalized_focus_entities[1] + if first["stance"] == "better_fit" and second["stance"] == "not_fit": + first_phrase = "更像接在" + first["name"] + "后面的解释和追问层" + if "解释和追问层" not in first["why"]: + first_phrase = f"更像落在{first['name']}这一层" + second_phrase = f"不像 {second['name']} 这种当前主链" + if "稳定取数" in second["why"] or "取数" in second["why"]: + second_phrase = f"不像 {second['name']} 这种要稳定取数的主链" + elif "计算" in second["why"]: + second_phrase = f"不像 {second['name']} 这种要稳定计算的主链" + focus_paragraph = f"如果放在这个项目里,它{first_phrase},{second_phrase}。" + elif first["stance"] == "blocked" and second["stance"] == "dependent": + focus_paragraph = ( + f"现在先卡在{first['name']},{first['why']}。" + f"{second['name']}也会直接受这条输入影响,{second['why']}。" + ) + elif first["stance"] == "anchor" and second["stance"] == "dependent": + focus_paragraph = ( + f"先对上的,是{first['name']},{first['why']}。" + f"离它最近的项目动作是{second['name']},{second['why']}。" + ) + if not focus_paragraph and normalized_focus_entities: + focus_sentences = [ + f"{item['name']}相关,因为{item['why']}。" + for item in normalized_focus_entities + ] + focus_paragraph = "".join(focus_sentences) + if focus_paragraph: + paragraphs.append(focus_paragraph) + if missing_mapping: + paragraphs.append(missing_mapping if missing_mapping.endswith("。") else f"{missing_mapping}。") + if next_step: + paragraphs.append(next_step if next_step.endswith("。") else f"{next_step}。") + return "\n\n".join(paragraphs).strip() + + async def _build_feishu_qa_presentation_context( + self, + *, + result: Dict[str, Any], + message_content: str, + ) -> dict[str, Any]: + rag_result = result.get("rag_result") if isinstance(result.get("rag_result"), dict) else {} + project_id = self._extract_feishu_project_id(result, rag_result) + project_snapshot = await self._load_feishu_project_entity_snapshot(project_id) + entity_catalog = self._build_feishu_entity_catalog(project_snapshot) + analysis_plan = await self._generate_feishu_qa_analysis_plan( + result=result, + message_content=message_content, + project_snapshot=project_snapshot, + entity_catalog=entity_catalog, + ) + selected_entity_candidates = [ + str(item.get("name") or "").strip() + for item in list(analysis_plan.get("focus_entities") or []) + if isinstance(item, dict) and str(item.get("name") or "").strip() + ] + blocked_entity_candidates = [ + str(item.get("name") or "").strip() + for item in entity_catalog + if str(item.get("name") or "").strip() and str(item.get("name") or "").strip() not in selected_entity_candidates + ] + return { + "project_snapshot": project_snapshot, + "entity_catalog": entity_catalog, + "analysis_plan": analysis_plan, + "selected_entity_candidates": selected_entity_candidates, + "blocked_entity_candidates": blocked_entity_candidates, + } + + async def _generate_feishu_qa_natural_reply( + self, + *, + message_content: str, + analysis_plan: dict[str, Any], + selected_entity_candidates: list[str], + blocked_entity_candidates: list[str], + ) -> str: + if not selected_entity_candidates or not isinstance(analysis_plan, dict): + return "" + prompt = PROMPT_FEISHU_QA_NATURAL_REPLY_REWRITE.format( + question=str(message_content or "").strip()[:500], + analysis_plan=json.dumps(analysis_plan, ensure_ascii=False)[:1800], + ) + llm = getattr(self, "llm", None) or LLMService() + raw_reply = await llm.generate( + prompt, + db=getattr(self, "db", None), + tenant_id=int(getattr(self, "tenant_id", 1) or 1), + function_name="feishu_qa_direct_natural_reply", + temperature=0.2, + max_tokens=260, + use_prompt_profile=False, + routing_meta={ + "tier_alias": "low", + "planned_tier": "low", + "executed_tier": "low", + "response_contract": "complete", + "fallback_policy": "none", + "execution_mode": "query", + "routing_decision": "force_low_first", + }, + ) + reply_text = self._clean_feishu_qa_natural_reply(raw_reply) + if not self._should_accept_feishu_qa_natural_reply( + reply_text, + selected_entity_candidates=selected_entity_candidates, + blocked_entity_candidates=blocked_entity_candidates, + ): + return "" + return reply_text + + @classmethod + def _build_feishu_analysis_paragraph( + cls, + *, + fit_brief: Any, + external_recommendation: Any, + ) -> Optional[str]: + context_summary = cls._build_feishu_fit_brief_summary_text(fit_brief) + external_summary = cls._build_feishu_external_summary_text(external_recommendation) + payload = external_recommendation if isinstance(external_recommendation, dict) else {} + retrieval_status = str(payload.get("retrieval_status") or "").strip().lower() + impact_status = str(payload.get("impact_status") or "").strip().lower() + + segments: list[str] = [] + if context_summary: + segments.append(f"结合当前项目看,{context_summary}。") + + analysis_sentence = "" + if retrieval_status == "evidence_found" and impact_status == "indirect_only": + analysis_sentence = ( + "就这次判断来说,这条外部信息目前更像背景相关,还不能直接映射到当前正式对象," + "所以现在更适合作为参考,而不是直接当成已经命中当前项目的正式变化。" + ) + elif retrieval_status == "evidence_found" and impact_status == "direct_match": + analysis_sentence = ( + "就这次判断来说,外部证据已经能直接映射到当前项目判断," + "所以它已经不只是背景参考,可以作为后续 plan 的正式输入。" + ) + elif retrieval_status == "verifier_blocked": + analysis_sentence = ( + "就这次判断来说,外部线索已经拿到,但校验还没通过," + "所以现在还不能把它当成可靠结论。" + ) + elif retrieval_status == "no_relevant_results": + analysis_sentence = ( + "就这次判断来说,目前还没拿到与当前项目直接相关的公开证据," + "所以结论只能先停在保守判断。" + ) + elif retrieval_status == "retrieval_failed": + analysis_sentence = ( + "就这次判断来说,这次外部检索本身失败了," + "所以当前结论还不能当成已经完成外部核实。" + ) + elif external_summary: + analysis_sentence = f"就这次判断来说,{cls._trim_feishu_qa_clause(external_summary)}。" + + if analysis_sentence: + segments.append(analysis_sentence) + if not segments: + return None + return "".join(segments) + + @classmethod + def _build_feishu_qa_direct_reply_text(cls, result: Dict[str, Any]) -> str: + payload = result if isinstance(result, dict) else {} + base_text = str(payload.get("direct_reply_text") or "").strip() + rag_result = payload.get("rag_result") if isinstance(payload.get("rag_result"), dict) else {} + extra_sections = [ + cls._build_feishu_analysis_paragraph( + fit_brief=rag_result.get("fit_brief"), + external_recommendation=rag_result.get("external_recommendation"), + ), + cls._build_feishu_next_step_text(rag_result.get("recommended_next_step")), + ] + sections = [section for section in extra_sections if isinstance(section, str) and section.strip()] + if not sections: + return base_text or "已收到,正在处理。" + if base_text: + return f"{base_text}\n\n" + "\n\n".join(sections) + return "\n\n".join(sections) + + async def _render_feishu_qa_direct_reply_text( + self, + *, + result: Dict[str, Any], + message_content: str, + ) -> str: + presentation_context = await self._build_feishu_qa_presentation_context( + result=result, + message_content=message_content, + ) + analysis_plan = presentation_context.get("analysis_plan") if isinstance(presentation_context.get("analysis_plan"), dict) else {} + fallback_text = self._build_feishu_qa_analysis_plan_reply_text(analysis_plan) or self._build_feishu_qa_direct_reply_text(result) + try: + natural_reply = await self._generate_feishu_qa_natural_reply( + message_content=message_content, + analysis_plan=analysis_plan, + selected_entity_candidates=list(presentation_context.get("selected_entity_candidates") or []), + blocked_entity_candidates=list(presentation_context.get("blocked_entity_candidates") or []), + ) + except Exception as exc: + print(f"[Feishu] generate qa direct reply failed: {exc}") + natural_reply = "" + return natural_reply or fallback_text + async def _route_scene_reply( self, *, @@ -114,7 +974,10 @@ class FeishuReceiveRoutingMixin: chat_id=chat_id, mention_open_id=sender_open_id, mention_name=sender_name, - text=str(result.get("direct_reply_text") or "已收到,正在处理。"), + text=await self._render_feishu_qa_direct_reply_text( + result=result, + message_content=message_content, + ), trace_id=trace_id, source_message_id=message_id, perf_t0=perf_t0, diff --git a/backend/app/services/integrations/feishu_integration_service/mixin_reply_outbox.py b/backend/app/services/integrations/feishu_integration_service/mixin_reply_outbox.py index 6e6fd527..cc1a15c6 100644 --- a/backend/app/services/integrations/feishu_integration_service/mixin_reply_outbox.py +++ b/backend/app/services/integrations/feishu_integration_service/mixin_reply_outbox.py @@ -543,6 +543,9 @@ class FeishuReplyOutboxMixin: raw = str(value or "").strip() if not raw: return "" + preserve_handoff_next_step = "下一步:" in raw and any( + marker in raw for marker in ("当前项目:", "外部判断:") + ) label_map = { "当前我更推荐:": "⭐ 当前我更推荐:", @@ -564,7 +567,14 @@ class FeishuReplyOutboxMixin: if beautified and beautified[-1] != "": beautified.append("") continue - for legacy, replacement in legacy_prefix_map.items(): + line_prefix_map = legacy_prefix_map + if preserve_handoff_next_step and stripped.startswith("下一步:"): + line_prefix_map = { + legacy: replacement + for legacy, replacement in legacy_prefix_map.items() + if legacy != "下一步:" + } + for legacy, replacement in line_prefix_map.items(): if stripped.startswith(legacy): stripped = f"{replacement}{stripped[len(legacy):]}".strip() break diff --git a/backend/app/services/integrations/feishu_integration_service/shared.py b/backend/app/services/integrations/feishu_integration_service/shared.py index 088890a8..7cbde1b3 100644 --- a/backend/app/services/integrations/feishu_integration_service/shared.py +++ b/backend/app/services/integrations/feishu_integration_service/shared.py @@ -31,6 +31,7 @@ from sqlalchemy.future import select from ....models import ( User, Project, + Task, Signal, ProjectSignalLink, Integration, @@ -38,7 +39,11 @@ from ....models import ( AIProcessingLog, ) from ....core.database import AsyncSessionLocal -from ....config.ai_prompts import PROMPT_FEISHU_HUMANIZED_REPLY +from ....config.ai_prompts import ( + PROMPT_FEISHU_HUMANIZED_REPLY, + PROMPT_FEISHU_QA_NATURAL_REPLY, + PROMPT_FEISHU_QA_NATURAL_REPLY_REWRITE, +) from ...notification_service import NotificationService from ...signal.signal_capture_service import ProactiveSignalCaptureService from ...llm_service import LLMService diff --git a/backend/tests/test_adaptive_rag_mainline_contract.py b/backend/tests/test_adaptive_rag_mainline_contract.py index 89dd0210..4bdb358a 100644 --- a/backend/tests/test_adaptive_rag_mainline_contract.py +++ b/backend/tests/test_adaptive_rag_mainline_contract.py @@ -1,4 +1,5 @@ import pytest +import time from types import SimpleNamespace from app.config.ai_prompts import PROMPT_AI_PMO_QA, PROMPT_RAG_QUERY_SEMANTICS @@ -33,6 +34,55 @@ from app.services.adaptive_rag_service.shared_part_02 import ACTION_MODE_READ, D from app.services.ai_pmo.workflow_semantic_planner import WorkflowSemanticPlanner +def _make_internal_context_packet( + *, + query_type: str, + answer_contract: str, + target_ref: str | None = None, +) -> InternalContextPacket: + target_resolution = {} + if target_ref: + target_resolution = { + "target_entity_type": "project", + "target_ref": target_ref, + "resolution_mode": "deterministic", + "confidence": 0.96, + } + return InternalContextPacket.model_validate( + { + "packet_version": "v1", + "question": "", + "scope": { + "tenant_id": 1, + "project_id": None, + "session_id": 11, + "conversation_scope_key": None, + "reply_to_message_id": None, + "root_message_id": None, + }, + "route_decision": { + "route_mode": "internal", + "effective_source_policy": "internal", + }, + "operation_contract": { + "query_type": query_type, + }, + "target_resolution": target_resolution, + "query_plan": { + "plan_version": "v2", + "query_type": query_type, + "entity_type": "project", + "answer_contract": answer_contract, + "target_ref": target_ref, + "filters": [], + }, + "executor_summary": {}, + "verifier_summary": {}, + "uncertainties": [], + } + ) + + class _FakeAdaptiveRAGService( AdaptiveRAGAnswerEntryMixin, AdaptiveRAGAnswerMainlineMixin, @@ -605,19 +655,40 @@ async def test_deterministic_query_prefers_project_created_at_answer() -> None: self.db = object() self.tenant_id = 1 - async def _try_fast_project_created_at_answer(self, *, clean_q: str, project_id, query_semantics=None): + async def _try_fast_project_created_at_answer( + self, + *, + clean_q: str, + project_id, + query_semantics=None, + internal_context_packet=None, + ): calls.append("created_at") - _ = (clean_q, project_id, query_semantics) + _ = (clean_q, project_id, query_semantics, internal_context_packet) return {"answer": "创建于 2026-03-01 09:30。", "sources": [], "missing_fields": []} - async def _try_fast_project_progress_answer(self, *, clean_q: str, project_id, query_semantics=None): + async def _try_fast_project_progress_answer( + self, + *, + clean_q: str, + project_id, + query_semantics=None, + internal_context_packet=None, + ): calls.append("progress") - _ = (clean_q, project_id, query_semantics) + _ = (clean_q, project_id, query_semantics, internal_context_packet) return {"answer": "进度回答", "sources": []} - async def _try_fast_project_existence_answer(self, *, clean_q: str, project_id): + async def _try_fast_project_existence_answer( + self, + *, + clean_q: str, + project_id, + query_semantics=None, + internal_context_packet=None, + ): calls.append("existence") - _ = (clean_q, project_id) + _ = (clean_q, project_id, query_semantics, internal_context_packet) return {"answer": "存在性回答", "sources": []} service = _FakeDeterministicService() @@ -625,6 +696,11 @@ async def test_deterministic_query_prefers_project_created_at_answer() -> None: question="他是什么时候创建的?", project_id=7, query_type="status", + internal_context_packet=_make_internal_context_packet( + query_type="status", + answer_contract="entity_snapshot", + target_ref="project:7", + ), ) assert result is not None @@ -641,9 +717,16 @@ async def test_deterministic_query_runs_project_existence_for_existence_type() - self.db = object() self.tenant_id = 1 - async def _try_fast_project_existence_answer(self, *, clean_q: str, project_id, query_semantics=None): + async def _try_fast_project_existence_answer( + self, + *, + clean_q: str, + project_id, + query_semantics=None, + internal_context_packet=None, + ): calls.append("existence") - _ = (clean_q, project_id, query_semantics) + _ = (clean_q, project_id, query_semantics, internal_context_packet) return {"answer": "有,匹配到 1 个项目。", "sources": []} service = _FakeDeterministicService() @@ -652,6 +735,10 @@ async def test_deterministic_query_runs_project_existence_for_existence_type() - project_id=None, query_type="existence", query_semantics={"subject_focus": "project"}, + internal_context_packet=_make_internal_context_packet( + query_type="existence", + answer_contract="yes_no_with_evidence", + ), ) assert result is not None @@ -1279,6 +1366,10 @@ def test_internal_answer_contract_verifier_blocks_total_count_substitution_for_e } ], route_decision={"route_mode": "internal", "effective_source_policy": "internal"}, + internal_context_packet=_make_internal_context_packet( + query_type="existence", + answer_contract="yes_no_with_evidence", + ), ) assert verdict["ok"] is False @@ -1305,6 +1396,11 @@ def test_internal_answer_contract_verifier_allows_project_snapshot_with_task_tot } ], route_decision={"route_mode": "internal", "effective_source_policy": "internal"}, + internal_context_packet=_make_internal_context_packet( + query_type="existence", + answer_contract="yes_no_with_evidence", + target_ref="project:123", + ), ) assert verdict["ok"] is True @@ -1328,6 +1424,11 @@ def test_internal_answer_contract_verifier_allows_evidence_backed_existence_answ } ], route_decision={"route_mode": "internal", "effective_source_policy": "internal"}, + internal_context_packet=_make_internal_context_packet( + query_type="existence", + answer_contract="yes_no_with_evidence", + target_ref="project:101", + ), ) assert verdict["ok"] is True @@ -1772,7 +1873,9 @@ async def test_retrieval_response_external_insufficient_evidence_keeps_external_ route_decision={ "route_mode": "external", "effective_source_policy": "external", + "decision_origin": "query_semantics_contract", "reply_contract": {"head": "外部实时查询:", "tail_hint": None}, + "query_profile": {"needs_freshness": True, "referenced_objects": ["public_api"]}, }, ) @@ -1786,6 +1889,459 @@ async def test_retrieval_response_external_insufficient_evidence_keeps_external_ assert result["missing_fields"] == ["external_evidence"] +@pytest.mark.asyncio +async def test_retrieval_response_exposes_fit_brief_and_external_recommendation_blocks() -> None: + class _FakeDB: + def add(self, obj) -> None: + obj.id = 2 + + async def flush(self) -> None: + return None + + async def commit(self) -> None: + return None + + async def refresh(self, obj) -> None: + _ = obj + return None + + class _FakeContextCache: + async def append_session_messages(self, **kwargs) -> None: + _ = kwargs + return None + + class _FakeService( + AdaptiveRAGAnswerRetrievalMixin, + AdaptiveRAGAnswerPayloadMixin, + AdaptiveRAGQueryRouteGatewayMixin, + AdaptiveRAGDeepGuardRulesMixin, + ): + def __init__(self) -> None: + self.db = _FakeDB() + self.context_cache = _FakeContextCache() + self.tenant_id = 1 + self.fit_brief_seen = None + + def _normalize_thinking_mode(self, thinking_mode: str) -> str: + return "deep" if str(thinking_mode or "").strip().lower() == "deep" else "normal" + + def _to_float(self, value, default=0.0): + try: + return float(value) + except Exception: + return float(default) + + def _build_policy_version(self, *, prompt_variant_id: str | None = None) -> str: + return f"retrieval-v2.1|routing:unknown|prompt:{prompt_variant_id or 'unknown'}" + + def _normalize_query_route_decision(self, payload): + return dict(payload or {}) + + async def _build_project_fit_brief(self, **kwargs): + _ = kwargs + return { + "project_summary": "当前项目:PMFlow AI 管家,聚焦项目内问答与需求流转。", + "repo_summary": "当前仓库:FastAPI 后端 + React 前端。", + "current_capabilities": ["AI 管家", "智能配置"], + "constraints": ["只允许基于外部公开证据给出推荐。"], + "recommendation_focus": ["判断 Responses API 是否适合当前项目接入"], + } + + async def _execute_external_query_gateway(self, **kwargs): + self.fit_brief_seen = kwargs.get("fit_brief") + return [ + RetrievedItem( + source="url", + content="Responses API 支持工具调用与结构化输出。", + score=0.93, + source_label="OpenAI Docs", + metadata={ + "target_url": "https://platform.openai.com/docs/guides/responses", + }, + ) + ], { + "mode": "external_query_gateway", + "external_context_packet": { + "question": "这个项目适不适合接 OpenAI Responses API?", + "plan": { + "answer_contract": "summary_with_sources", + "topic": "Responses API 适配性", + }, + "adapter_attempts": [{"adapter": "live_search", "status": "success"}], + "verified_evidence": [ + { + "source_type": "url", + "source_label": "OpenAI Docs", + "title": "Responses API guide", + "target_url": "https://platform.openai.com/docs/guides/responses", + "snippet": "Responses API supports tool calls and structured outputs.", + } + ], + "verifier": {"ok": True}, + "uncertainties": [], + "retrieval_status": "evidence_found", + }, + "tool_trace": [], + "domains_used": ["url"], + } + + async def _generate_answer(self, *args, **kwargs): + _ = (args, kwargs) + return "外部公开资料显示 Responses API 值得关注,但目前还需要先做项目映射验证。" + + def _peek_answer_prompt_variant(self): + return "external_summary_with_sources" + + def _peek_last_answer_routing_snapshot(self): + return {} + + def _dedupe_sources(self, retrieved): + _ = retrieved + return [ + { + "source": "url", + "label": "OpenAI Docs", + "score": 0.93, + "metadata": { + "target_url": "https://platform.openai.com/docs/guides/responses", + }, + } + ] + + def _build_qa_record_runtime_fields(self, **kwargs): + return { + "policy_version": "test", + "citations": [], + "sources_used": list(kwargs.get("sources") or []), + "evidence_pack": {}, + "tool_trace": list(kwargs.get("tool_trace") or []), + } + + def _build_qa_record_external_fields(self, **kwargs): + _ = kwargs + return {} + + async def _update_session_state_after_turn(self, **kwargs): + _ = kwargs + return None + + service = _FakeService() + result = await service._build_retrieval_response( + clean_q="这个项目适不适合接 OpenAI Responses API?", + effective_q="这个项目适不适合接 OpenAI Responses API?", + asked_by=1, + asked_via="feishu", + project_id=17, + source_chat_id=None, + session_id=12, + turn_index=1, + context_session_id=12, + conversation=[], + state_row=None, + rewrite_meta={"mode": "direct"}, + query_type="explain", + query_semantics={ + "effective_source_policy": "external", + "project_operating_context": { + "current_project_id": 17, + "current_repo_scope": "pmflow-main", + "page_anchor": {"project_id": 17}, + "thread_anchor": {}, + "fit_scope": "current_project_and_repo", + "decision_strategy": "answer_then_offer_plan", + }, + "execution_recipe": { + "primary_lane": "external", + "fit_brief_required": True, + "external_evidence_required": True, + "plan_handoff_mode": "offer_after_answer", + }, + }, + retrieval_domains=["url"], + scene_semantics={"scene_key": "default", "deep_intent": "none"}, + thinking_mode="normal", + active_thinking_mode="normal", + deep_thinking=False, + forced_deep_reason="", + deep_retrieval_steps=2, + deep_vector_top_k=3, + system_snapshot={}, + user_snapshot=None, + started=time.perf_counter(), + message_intent=None, + mixed_signal_part=None, + answer_mode="retrieval_normal", + consultant_mode_preference=None, + route_decision={ + "route_mode": "external", + "effective_source_policy": "external", + "decision_origin": "query_semantics_contract", + "reply_contract": {"head": "外部实时查询:", "tail_hint": None}, + "query_profile": {"needs_freshness": True, "referenced_objects": ["public_api"]}, + }, + ) + + assert service.fit_brief_seen is not None + assert result["fit_brief"]["project_summary"].startswith("当前项目:PMFlow AI 管家") + assert result["fit_brief"]["current_capabilities"] == ["AI 管家", "智能配置"] + assert result["external_recommendation"]["retrieval_status"] == "evidence_found" + assert result["external_recommendation"]["impact_status"] == "indirect_only" + assert result["external_recommendation"]["affected_signal_sources"] == [] + assert "映射验证" in str(result["external_recommendation"]["external_action"]) + + +@pytest.mark.asyncio +async def test_retrieval_response_can_disable_fit_brief_external_recommendation( + monkeypatch: pytest.MonkeyPatch, +) -> None: + class _FakeDB: + def add(self, obj) -> None: + obj.id = 2 + + async def flush(self) -> None: + return None + + async def commit(self) -> None: + return None + + async def refresh(self, obj) -> None: + _ = obj + return None + + class _FakeContextCache: + async def append_session_messages(self, **kwargs) -> None: + _ = kwargs + return None + + class _FakeService( + AdaptiveRAGAnswerRetrievalMixin, + AdaptiveRAGAnswerPayloadMixin, + AdaptiveRAGQueryRouteGatewayMixin, + AdaptiveRAGDeepGuardRulesMixin, + ): + def __init__(self) -> None: + self.db = _FakeDB() + self.context_cache = _FakeContextCache() + self.tenant_id = 1 + self.fit_brief_seen = "unset" + + def _normalize_thinking_mode(self, thinking_mode: str) -> str: + return "deep" if str(thinking_mode or "").strip().lower() == "deep" else "normal" + + def _to_float(self, value, default=0.0): + try: + return float(value) + except Exception: + return float(default) + + def _build_policy_version(self, *, prompt_variant_id: str | None = None) -> str: + return f"retrieval-v2.1|routing:unknown|prompt:{prompt_variant_id or 'unknown'}" + + def _normalize_query_route_decision(self, payload): + return dict(payload or {}) + + async def _build_project_fit_brief(self, **kwargs): + _ = kwargs + raise AssertionError("fit brief builder should be bypassed when rollback flag is off") + + async def _execute_external_query_gateway(self, **kwargs): + self.fit_brief_seen = kwargs.get("fit_brief") + return [ + RetrievedItem( + source="url", + content="Responses API 支持工具调用与结构化输出。", + score=0.93, + source_label="OpenAI Docs", + metadata={ + "target_url": "https://platform.openai.com/docs/guides/responses", + }, + ) + ], { + "mode": "external_query_gateway", + "external_context_packet": { + "question": "这个项目适不适合接 OpenAI Responses API?", + "plan": { + "answer_contract": "summary_with_sources", + "topic": "Responses API 适配性", + }, + "adapter_attempts": [{"adapter": "live_search", "status": "success"}], + "verified_evidence": [ + { + "source_type": "url", + "source_label": "OpenAI Docs", + "title": "Responses API guide", + "target_url": "https://platform.openai.com/docs/guides/responses", + "snippet": "Responses API supports tool calls and structured outputs.", + } + ], + "verifier": {"ok": True}, + "uncertainties": [], + "retrieval_status": "evidence_found", + }, + "tool_trace": [], + "domains_used": ["url"], + } + + async def _generate_answer(self, *args, **kwargs): + _ = (args, kwargs) + return "外部公开资料显示 Responses API 值得关注,但目前还需要先做项目映射验证。" + + def _peek_answer_prompt_variant(self): + return "external_summary_with_sources" + + def _peek_last_answer_routing_snapshot(self): + return {} + + def _dedupe_sources(self, retrieved): + _ = retrieved + return [ + { + "source": "url", + "label": "OpenAI Docs", + "score": 0.93, + "metadata": { + "target_url": "https://platform.openai.com/docs/guides/responses", + }, + } + ] + + def _build_qa_record_runtime_fields(self, **kwargs): + return { + "policy_version": "test", + "citations": [], + "sources_used": list(kwargs.get("sources") or []), + "evidence_pack": {}, + "tool_trace": list(kwargs.get("tool_trace") or []), + } + + def _build_qa_record_external_fields(self, **kwargs): + _ = kwargs + return {} + + async def _update_session_state_after_turn(self, **kwargs): + _ = kwargs + return None + + monkeypatch.setenv("PMFLOW_ENABLE_FIT_BRIEF_EXTERNAL_RECOMMENDATION", "0") + service = _FakeService() + result = await service._build_retrieval_response( + clean_q="这个项目适不适合接 OpenAI Responses API?", + effective_q="这个项目适不适合接 OpenAI Responses API?", + asked_by=1, + asked_via="feishu", + project_id=17, + source_chat_id=None, + session_id=12, + turn_index=1, + context_session_id=12, + conversation=[], + state_row=None, + rewrite_meta={"mode": "direct"}, + query_type="explain", + query_semantics={ + "effective_source_policy": "external", + "project_operating_context": { + "current_project_id": 17, + "current_repo_scope": "pmflow-main", + "page_anchor": {"project_id": 17}, + "thread_anchor": {}, + "fit_scope": "current_project_and_repo", + "decision_strategy": "answer_then_offer_plan", + }, + "execution_recipe": { + "primary_lane": "external", + "fit_brief_required": True, + "external_evidence_required": True, + "plan_handoff_mode": "offer_after_answer", + }, + }, + retrieval_domains=["url"], + scene_semantics={"scene_key": "default", "deep_intent": "none"}, + thinking_mode="normal", + active_thinking_mode="normal", + deep_thinking=False, + forced_deep_reason="", + deep_retrieval_steps=2, + deep_vector_top_k=3, + system_snapshot={}, + user_snapshot=None, + started=time.perf_counter(), + message_intent=None, + mixed_signal_part=None, + answer_mode="retrieval_normal", + consultant_mode_preference=None, + route_decision={ + "route_mode": "external", + "effective_source_policy": "external", + "decision_origin": "query_semantics_contract", + "reply_contract": {"head": "外部实时查询:", "tail_hint": None}, + "query_profile": {"needs_freshness": True, "referenced_objects": ["public_api"]}, + }, + ) + + assert service.fit_brief_seen == {} + assert result["fit_brief"] == {} + assert result["external_recommendation"] == {} + + +def test_external_recommendation_does_not_treat_capability_name_as_direct_match_without_formal_anchor() -> None: + result = AdaptiveRAGAnswerPayloadMixin._build_external_recommendation( + external_context_packet={ + "retrieval_status": "evidence_found", + "verified_evidence": [ + { + "source_type": "url", + "source_label": "OpenAI Docs", + "title": "AI 管家接入 Responses API", + "target_url": "https://platform.openai.com/docs/guides/responses", + "snippet": "AI 管家可以通过 Responses API 编排工具调用。", + } + ], + "verifier": {"ok": True}, + }, + fit_brief={ + "project_summary": "当前项目:PMFlow AI 管家", + "current_capabilities": ["AI 管家"], + "signal_source_anchors": [], + "constraints": [], + "recommendation_focus": [], + }, + ) + + assert result["retrieval_status"] == "evidence_found" + assert result["impact_status"] == "indirect_only" + assert result["affected_signal_sources"] == [] + + +def test_external_recommendation_uses_formal_signal_source_anchor_for_direct_match() -> None: + result = AdaptiveRAGAnswerPayloadMixin._build_external_recommendation( + external_context_packet={ + "retrieval_status": "evidence_found", + "verified_evidence": [ + { + "source_type": "url", + "source_label": "OpenAI Docs", + "title": "Responses API guide", + "target_url": "https://platform.openai.com/docs/guides/responses", + "snippet": "OpenAI Docs 说明 Responses API 支持结构化输出与工具调用。", + } + ], + "verifier": {"ok": True}, + }, + fit_brief={ + "project_summary": "当前项目:PMFlow AI 管家", + "current_capabilities": ["AI 管家"], + "signal_source_anchors": ["OpenAI Docs"], + "constraints": [], + "recommendation_focus": [], + }, + ) + + assert result["retrieval_status"] == "evidence_found" + assert result["impact_status"] == "direct_match" + assert result["affected_signal_sources"] == ["OpenAI Docs"] + + @pytest.mark.asyncio async def test_deep_guard_answer_prefers_member_workload_direct_answer() -> None: class _FakeResult: @@ -3342,6 +3898,118 @@ async def test_query_route_gateway_keeps_uncertain_contract_as_clarify_without_e assert decision["probe_snapshot"]["contract"]["source_policy"] == "uncertain" +@pytest.mark.asyncio +async def test_query_route_gateway_builds_external_execution_recipe_for_current_project() -> None: + class _FakeService(AdaptiveRAGQueryRouteGatewayMixin): + def __init__(self) -> None: + self.db = object() + self.tenant_id = 1 + + service = _FakeService() + decision = await service._run_query_route_gateway( + question="这个方案适不适合我们当前项目?", + asked_via="pmflow_manual", + project_id=17, + source_chat_id="pmflow-project-17", + query_semantics={ + "message_intent": "question", + "query_type": "explain", + "source_policy": "external", + "effective_source_policy": "external", + "query_profile": { + "needs_private_context": False, + "needs_freshness": True, + "referenced_objects": ["public_solution"], + }, + }, + source_policy_hint=None, + ) + + packet = decision["project_operating_context"] + recipe = decision["execution_recipe"] + + assert packet["current_project_id"] == 17 + assert packet["page_anchor"]["project_id"] == 17 + assert packet["page_anchor"]["source_chat_id"] == "pmflow-project-17" + assert recipe == { + "primary_lane": "external", + "fit_brief_required": True, + "external_evidence_required": True, + "plan_handoff_mode": "offer_after_answer", + } + + +@pytest.mark.asyncio +async def test_query_route_gateway_keeps_explicit_requirement_plan_recipe() -> None: + class _FakeService(AdaptiveRAGQueryRouteGatewayMixin): + def __init__(self) -> None: + self.db = object() + self.tenant_id = 1 + + service = _FakeService() + decision = await service._run_query_route_gateway( + question="那按这个结论继续收需求", + asked_via="pmflow_manual", + project_id=17, + source_chat_id="pmflow-project-17", + query_semantics={ + "message_intent": "requirement_plan", + "query_type": "plan", + "source_policy": "internal", + "effective_source_policy": "internal", + "query_profile": { + "needs_private_context": True, + "needs_freshness": False, + "referenced_objects": ["project_requirement"], + }, + }, + source_policy_hint=None, + ) + + assert decision["execution_recipe"] == { + "primary_lane": "plan", + "fit_brief_required": False, + "external_evidence_required": False, + "plan_handoff_mode": "explicit_plan_now", + } + + +@pytest.mark.asyncio +async def test_query_route_gateway_can_disable_unified_execution_recipe(monkeypatch: pytest.MonkeyPatch) -> None: + class _FakeService(AdaptiveRAGQueryRouteGatewayMixin): + def __init__(self) -> None: + self.db = object() + self.tenant_id = 1 + + monkeypatch.setenv("PMFLOW_ENABLE_UNIFIED_EXECUTION_RECIPE", "0") + service = _FakeService() + decision = await service._run_query_route_gateway( + question="这个方案适不适合当前项目?", + asked_via="pmflow_manual", + project_id=17, + source_chat_id="pmflow-project-17", + query_semantics={ + "message_intent": "question", + "query_type": "explain", + "source_policy": "external", + "effective_source_policy": "external", + "query_profile": { + "needs_private_context": False, + "needs_freshness": True, + "referenced_objects": ["public_solution"], + }, + }, + source_policy_hint=None, + ) + + assert decision["execution_recipe"] == { + "primary_lane": "external", + "fit_brief_required": False, + "external_evidence_required": True, + "plan_handoff_mode": "none", + } + + @pytest.mark.asyncio async def test_query_route_clarification_response_stores_pending_route_protocol() -> None: class _FakeDB: @@ -3782,6 +4450,121 @@ async def test_render_answer_with_route_contract_adds_explicit_source_prefix_and assert "Integration[llm_engine]" in formatted +def test_build_response_payload_exposes_execution_recipe_and_plan_handoff() -> None: + class _FakeService(AdaptiveRAGAnswerPayloadMixin): + def _to_float(self, value, default=0.0): + try: + return float(value) + except Exception: + return float(default) + + def _normalize_thinking_mode(self, thinking_mode: str) -> str: + return "deep" if str(thinking_mode or "").strip().lower() == "deep" else "normal" + + def _normalize_query_route_decision(self, payload): + return dict(payload or {}) + + def _build_policy_version(self, *, prompt_variant_id: str | None = None) -> str: + return f"retrieval-v2.1|routing:unknown|prompt:{prompt_variant_id or 'unknown'}" + + service = _FakeService() + payload = service._build_response_payload( + answer="建议先做一轮小范围验证。", + sources=[ + { + "source": "url", + "label": "OpenAI Docs", + "score": 0.93, + "metadata": {"target_url": "https://platform.openai.com/docs"}, + } + ], + domains_used=["url"], + response_time_ms=32, + db_query_ms=0, + vector_search_ms=0, + query_type="explain", + confidence=0.93, + route_decision={"route_mode": "external", "effective_source_policy": "external"}, + project_operating_context={ + "current_project_id": 17, + "current_repo_scope": "pmflow-main", + "page_anchor": {"project_id": 17}, + "thread_anchor": {}, + "fit_scope": "current_project_and_repo", + "decision_strategy": "answer_then_offer_plan", + }, + execution_recipe={ + "primary_lane": "external", + "fit_brief_required": True, + "external_evidence_required": True, + "plan_handoff_mode": "offer_after_answer", + }, + ) + + assert payload["project_operating_context"]["current_project_id"] == 17 + assert payload["execution_recipe"]["primary_lane"] == "external" + assert payload["handoff_mode"] == "offer_after_answer" + assert payload["can_enter_plan"] is True + assert "进入 plan" in payload["recommended_next_step"] + + +def test_response_payload_can_disable_default_offer_plan_rendering(monkeypatch: pytest.MonkeyPatch) -> None: + class _FakeService(AdaptiveRAGAnswerPayloadMixin): + def _to_float(self, value, default: float = 0.0) -> float: + try: + return float(value) + except Exception: + return float(default) + + def _normalize_thinking_mode(self, thinking_mode: str) -> str: + return "deep" if str(thinking_mode or "").strip().lower() == "deep" else "normal" + + def _normalize_query_route_decision(self, payload): + return dict(payload or {}) + + def _build_policy_version(self, *, prompt_variant_id: str | None = None) -> str: + return f"retrieval-v2.1|routing:unknown|prompt:{prompt_variant_id or 'unknown'}" + + monkeypatch.setenv("PMFLOW_ENABLE_DEFAULT_PLAN_HANDOFF_RENDERING", "0") + service = _FakeService() + payload = service._build_response_payload( + answer="建议先做一轮小范围验证。", + sources=[ + { + "source": "url", + "label": "OpenAI Docs", + "score": 0.93, + "metadata": {"target_url": "https://platform.openai.com/docs"}, + } + ], + domains_used=["url"], + response_time_ms=32, + db_query_ms=0, + vector_search_ms=0, + query_type="explain", + confidence=0.93, + route_decision={"route_mode": "external", "effective_source_policy": "external"}, + project_operating_context={ + "current_project_id": 17, + "current_repo_scope": "pmflow-main", + "page_anchor": {"project_id": 17}, + "thread_anchor": {}, + "fit_scope": "current_project_and_repo", + "decision_strategy": "answer_then_offer_plan", + }, + execution_recipe={ + "primary_lane": "external", + "fit_brief_required": True, + "external_evidence_required": True, + "plan_handoff_mode": "offer_after_answer", + }, + ) + + assert payload["handoff_mode"] is None + assert payload["can_enter_plan"] is False + assert payload["recommended_next_step"] is None + + @pytest.mark.asyncio async def test_meta_followup_without_real_anchor_does_not_return_no_route_record_answer() -> None: class _FakeService(AdaptiveRAGFollowupAnchorMixin): diff --git a/backend/tests/test_ai_pmo_ingress_mainline.py b/backend/tests/test_ai_pmo_ingress_mainline.py index eee344d1..495129b2 100644 --- a/backend/tests/test_ai_pmo_ingress_mainline.py +++ b/backend/tests/test_ai_pmo_ingress_mainline.py @@ -45,6 +45,8 @@ from app.services.adaptive_rag_service import mixin_deterministic_query_core from app.services.adaptive_rag_service import mixin_fast_answer_project_main from app.services.adaptive_rag_service.shared_part_02 import RetrievedItem from app.services.mounted_ai_runtime_write_service import MountedAIRuntimeWriteService +from app.services.integrations.feishu_integration_service.mixin_card_action import FeishuCardActionMixin +from app.services.integrations.feishu_integration_service.mixin_receive_routing import FeishuReceiveRoutingMixin from app.services.integrations.feishu_integration_service import mixin_reply_outbox as feishu_mixin_reply_outbox from app.services.integrations.feishu_outbox.service import FeishuOutboxService from app.services.llm_service import ChatCompletionResult, StructuredExtractionResult @@ -4891,6 +4893,190 @@ async def test_feishu_reply_outbox_mixin_enqueue_preserves_requirement_plan_text assert "⭐ 当前我更推荐:B" in row.message_text +@pytest.mark.asyncio +async def test_route_scene_reply_enqueues_rich_qa_direct_text_into_feishu_outbox( + monkeypatch, + feishu_outbox_db_session, +) -> None: + maker = async_sessionmaker(feishu_outbox_db_session.bind, expire_on_commit=False) + + class FakeRoutingOutbox( + FeishuReceiveRoutingMixin, + feishu_mixin_reply_outbox.FeishuReplyOutboxMixin, + ): + def __init__(self) -> None: + self.tenant_id = 3 + + def _build_capability_reply(self, message_content: str): # noqa: ANN001 + _ = message_content + return None + + def _should_allow_group_qa_reply(self, **kwargs): # noqa: ANN001 + _ = kwargs + return True + + def _emit_state_transition(self, **kwargs): # noqa: ANN001 + _ = kwargs + + def _build_action_card_from_capture_result(self, *, capture_result, mention_open_id, mention_name): # noqa: ANN001 + _ = (capture_result, mention_open_id, mention_name) + return None + + @staticmethod + def _build_mention_prefix(*, open_id=None, display_name=None): # noqa: ANN001 + _ = (open_id, display_name) + return "" + + @staticmethod + async def _notify_outbox_ready(db, *, outbox_id): # noqa: ANN001 + _ = (db, outbox_id) + return None + + async def _render_feishu_qa_direct_reply_text(self, *, result, message_content): # noqa: ANN001 + _ = (result, message_content) + return ( + "Responses API 不适合放在 WMS库存快照自动拉取或者 TOP10缺货排序这两层," + "更像放在每日结构化摘要卡片之后的解释和追问层。\n\n" + "这个项目当前在做的是先把 WMS库存快照拉进来,再完成 TOP10缺货排序," + "最后按固定时段推送结构化摘要卡片。前两层都属于取数和计算主链,应该保持确定、可复算," + "不适合把 Responses API 放进去做主判断。\n\n" + "它真正更可能有价值的地方,是采购经理收到摘要卡片之后继续追问的时候。" + ) + + monkeypatch.setattr(feishu_mixin_reply_outbox, "AsyncSessionLocal", maker) + + routing = FakeRoutingOutbox() + reply_route = await routing._route_scene_reply( + scene="qa", + result={ + "direct_reply_required": True, + "direct_reply_text": "外部实时查询:目前证据不足,建议先明确要查融资还是招聘动态。", + "rag_result": { + "fit_brief": { + "project_summary": "Alpha 是一个 AI 项目管理工作台。", + "repo_summary": "当前仓库负责统一项目上下文与 AI handoff。", + }, + "external_recommendation": { + "retrieval_status": "evidence_found", + "impact_status": "indirect_only", + "external_impact": "目前更像背景相关,还不能直接映射到当前正式对象。", + }, + "recommended_next_step": "如果你要,我可以基于当前结论进入 plan,继续把需求范围和下一步收清楚。", + }, + }, + message_content="这个适合吗", + chat_id="chat-rich-qa", + chat_type="group", + sender_open_id="ou-rich-qa", + sender_name="测试用户", + mentioned_bot_names=[], + thread_context={"reply_to_message_id": "om-rich-qa"}, + trace_id="trace-rich-qa", + message_id="msg-rich-qa", + perf_t0=None, + ) + + assert reply_route == "qa_direct" + + async with maker() as verify_session: + row = ( + await verify_session.execute( + select(FeishuReplyOutbox).where(FeishuReplyOutbox.source_message_id == "msg-rich-qa") + ) + ).scalar_one() + + assert row.chat_id == "chat-rich-qa" + assert not feishu_mixin_reply_outbox.FeishuReplyOutboxMixin._is_card_outbox_message(row.message_text) + assert feishu_mixin_reply_outbox.FeishuReplyOutboxMixin._decode_outbox_card_message(row.message_text) is None + expected = ( + "Responses API 不适合放在 WMS库存快照自动拉取或者 TOP10缺货排序这两层," + "更像放在每日结构化摘要卡片之后的解释和追问层。\n\n" + "这个项目当前在做的是先把 WMS库存快照拉进来,再完成 TOP10缺货排序," + "最后按固定时段推送结构化摘要卡片。前两层都属于取数和计算主链,应该保持确定、可复算," + "不适合把 Responses API 放进去做主判断。\n\n" + "它真正更可能有价值的地方,是采购经理收到摘要卡片之后继续追问的时候。" + ) + assert row.message_text == expected + assert "这事可以继续看" not in row.message_text + assert "先说我的判断" not in row.message_text + + +@pytest.mark.asyncio +async def test_route_scene_reply_enqueues_requirement_plan_followup_card_into_feishu_outbox( + monkeypatch, + feishu_outbox_db_session, +) -> None: + maker = async_sessionmaker(feishu_outbox_db_session.bind, expire_on_commit=False) + + class FakeRoutingOutbox( + FeishuReceiveRoutingMixin, + FeishuCardActionMixin, + feishu_mixin_reply_outbox.FeishuReplyOutboxMixin, + ): + def __init__(self) -> None: + self.tenant_id = 3 + + def _build_capability_reply(self, message_content: str): # noqa: ANN001 + _ = message_content + return None + + def _should_allow_group_qa_reply(self, **kwargs): # noqa: ANN001 + _ = kwargs + return True + + def _emit_state_transition(self, **kwargs): # noqa: ANN001 + _ = kwargs + + @staticmethod + def _build_mention_prefix(*, open_id=None, display_name=None): # noqa: ANN001 + _ = (open_id, display_name) + return "" + + @staticmethod + async def _notify_outbox_ready(db, *, outbox_id): # noqa: ANN001 + _ = (db, outbox_id) + return None + + monkeypatch.setattr(feishu_mixin_reply_outbox, "AsyncSessionLocal", maker) + + routing = FakeRoutingOutbox() + reply_route = await routing._route_scene_reply( + scene="qa", + result={ + "reply_template_key": "requirement_plan_followup", + "work_item_id": 88, + "direct_reply_text": "C. 混合增强路径\n\n⭐ 当前我更推荐:C", + }, + message_content="C", + chat_id="chat-plan-card", + chat_type="group", + sender_open_id="ou-plan", + sender_name="计划用户", + mentioned_bot_names=[], + thread_context=None, + trace_id="trace-plan-card", + message_id="msg-plan-card", + perf_t0=None, + ) + + assert reply_route == "qa_action_card" + + async with maker() as verify_session: + row = ( + await verify_session.execute( + select(FeishuReplyOutbox).where(FeishuReplyOutbox.source_message_id == "msg-plan-card") + ) + ).scalar_one() + + assert row.chat_id == "chat-plan-card" + assert feishu_mixin_reply_outbox.FeishuReplyOutboxMixin._is_card_outbox_message(row.message_text) + decoded_card = feishu_mixin_reply_outbox.FeishuReplyOutboxMixin._decode_outbox_card_message(row.message_text) + assert isinstance(decoded_card, dict) + assert decoded_card["header"]["title"]["content"] == "AI PMO 需求收敛" + action_rows = [item for item in decoded_card["elements"] if item.get("tag") == "action"] + assert len(action_rows) == 2 + + @pytest.mark.asyncio async def test_finalize_waiting_manual_passes_execution_result_to_renderer(monkeypatch) -> None: captured: dict[str, object] = {} @@ -12008,6 +12194,42 @@ async def test_should_route_to_draft_confirmation_session_does_not_lock_smalltal assert called is False +def test_ensure_project_operating_context_prefers_world_model_and_preserves_anchors() -> None: + class FakeKernel(mixin_entry.KernelIngressFlowEntryMixin): + def __init__(self) -> None: + self.tenant_id = 1 + + kernel = FakeKernel() + ingress_options = { + "rag_request_context": { + "project_id": 17, + "source_chat_id": "pmflow-project-17", + }, + "thread_context": { + "reply_to_message_id": "om_xxx", + "root_message_id": "om_root", + }, + } + + normalized = kernel._ensure_project_operating_context( + ingress_options=ingress_options, + project_hint_id=17, + world_model={ + "resolved_project_id": 19, + "repo_scope": "pmflow-main", + }, + thread_context=ingress_options["thread_context"], + ) + + packet = normalized["project_operating_context"] + assert packet["current_project_id"] == 19 + assert packet["current_repo_scope"] == "pmflow-main" + assert packet["page_anchor"] == {"project_id": 17, "source_chat_id": "pmflow-project-17"} + assert packet["thread_anchor"] == {"reply_to_message_id": "om_xxx", "root_message_id": "om_root"} + assert packet["fit_scope"] == "current_project_and_repo" + assert packet["decision_strategy"] == "answer_then_offer_plan" + + @pytest.mark.asyncio async def test_should_route_to_draft_confirmation_session_keeps_pending_confirm_session_without_keywords( monkeypatch, @@ -12190,7 +12412,42 @@ async def test_qa_session_detail_and_state_replay_expose_rich_runtime_fields(ext "route_mode": "external", "effective_source_policy": "external", "decision_origin": "freshness_guard", - } + }, + "response_payload": { + "project_operating_context": { + "context_version": "v1", + "current_project_id": 88, + "current_repo_scope": "alpha-repo", + "page_anchor": {"type": "project_board", "ref": "project:88"}, + "thread_anchor": {"source": "pmflow"}, + "fit_scope": "current_project_and_repo", + "decision_strategy": "answer_then_offer_plan", + }, + "execution_recipe": { + "primary_lane": "external", + "fit_brief_required": True, + "external_evidence_required": True, + "plan_handoff_mode": "offer_after_answer", + }, + "fit_brief": { + "project_summary": "Alpha 是一个 AI 项目管理工作台。", + "repo_summary": "当前仓库负责统一项目上下文与 AI handoff。", + "current_capabilities": ["需求 intake", "外部信息映射"], + "constraints": ["不能脱离正式业务对象"], + "recommendation_focus": ["映射验证", "handoff 收口"], + }, + "external_recommendation": { + "retrieval_status": "evidence_found", + "impact_status": "indirect_only", + "external_summary": "外部证据说明同类团队开始把统一 handoff 做成默认能力。", + "external_impact": "目前更像背景相关,还不能直接映射到 Alpha 的正式对象。", + "affected_signal_sources": [], + "external_action": "先验证映射,再决定是否纳入当前项目计划。", + }, + "handoff_mode": "offer_after_answer", + "can_enter_plan": True, + "recommended_next_step": "如果你要,我可以基于当前结论进入 plan,继续把需求范围和下一步收清楚。", + }, }, answer_mode="clarify", grounding_status="insufficient_evidence", @@ -12243,6 +12500,13 @@ async def test_qa_session_detail_and_state_replay_expose_rich_runtime_fields(ext assert detail["messages"][0]["grounding_status"] == "insufficient_evidence" assert detail["messages"][0]["policy_version"] == "retrieval-v2.1|routing:v3|prompt:industry_trend" assert detail["messages"][0]["evidence_pack"]["summary"]["conclusion"] == "需要进一步澄清外部目标。" + assert detail["messages"][0]["project_operating_context"]["current_project_id"] == 88 + assert detail["messages"][0]["execution_recipe"]["primary_lane"] == "external" + assert detail["messages"][0]["fit_brief"]["current_capabilities"] == ["需求 intake", "外部信息映射"] + assert detail["messages"][0]["external_recommendation"]["impact_status"] == "indirect_only" + assert detail["messages"][0]["handoff_mode"] == "offer_after_answer" + assert detail["messages"][0]["can_enter_plan"] is True + assert "进入 plan" in detail["messages"][0]["recommended_next_step"] state = await service.get_session_state_replay(session_id=int(session.id), user_id=101) @@ -12636,6 +12900,128 @@ async def test_process_ingress_short_circuits_query_route_pending_clarification_ assert captured["answer_kwargs"]["conversation_key"] == conversation_scope_key +@pytest.mark.asyncio +async def test_query_pending_clarification_followup_skips_explicit_new_topic_and_clears_stale_state( + external_qa_db_session, + monkeypatch, +) -> None: + conversation_scope_key = build_feishu_conversation_scope_key( + source_chat_id="chat-query-route-reset", + source_sender_open_id="ou-query-route-reset", + sender=None, + ) + session = QASession( + tenant_id=1, + user_id=None, + title="旧澄清不该劫持新话题", + external_conversation_key=conversation_scope_key, + message_count=1, + ) + external_qa_db_session.add(session) + await external_qa_db_session.flush() + external_qa_db_session.add( + QASessionState( + tenant_id=1, + session_id=int(session.id), + ambiguity="needs_clarification", + pending_clarification={ + "kind": "query_route_selection", + "original_question": "当前系统内是否有为采购经理提出的需求?", + "option_map": {"A": "external", "B": "internal"}, + "options": [ + {"key": "A", "label": "外部公开事实"}, + {"key": "B", "label": "系统内部配置/项目对象"}, + ], + }, + pending_clarification_expires_at=datetime.now() + timedelta(minutes=5), + ) + ) + await external_qa_db_session.commit() + + captured: dict[str, object] = {} + + class FakeAdaptiveRAGService: + def __init__(self, db, tenant_id: int): + captured["tenant_id"] = tenant_id + captured["db"] = db + + async def answer_question(self, **kwargs): + captured["answer_kwargs"] = kwargs + return { + "answer": "不应该沿用旧澄清上下文。", + "grounding_status": "grounded", + "policy_version": "retrieval-v2.1|routing:v3|prompt:internal_knowledge", + } + + async def _fake_aplan_message(cls, *, message_text: str, source_system: str, metadata=None): + _ = cls + captured["planner_kwargs"] = { + "message_text": message_text, + "source_system": source_system, + "metadata": metadata, + } + return WorkflowSemanticPlanner.deserialize_bundle( + { + "bundle_id": "query-route-explicit-new-topic", + "semantic_version": "v1", + "source_system": "feishu", + "primary_workflow": "query_flow", + "workflows": [ + { + "workflow_type": "query_flow", + "workflow_action": "answer", + "target_scope": "global", + "operation_codes": ["query.answer"], + "requires_confirmation": False, + "confidence": 0.88, + "clarification_needed": False, + "metadata": {}, + } + ], + "message_gate": "auto", + "reply_contract": {"mode": "text", "template_key": "workflow_bundle", "summary": "query_flow"}, + "diagnostics": { + "message_intent": "question", + "topic_switch": "new", + }, + } + ) + + class FakeKernel(mixin_entry.KernelIngressFlowEntryMixin): + def __init__(self) -> None: + self.db = external_qa_db_session + self.tenant_id = 1 + + monkeypatch.setattr(mixin_entry, "AdaptiveRAGService", FakeAdaptiveRAGService) + monkeypatch.setattr(WorkflowSemanticPlanner, "aplan_message", classmethod(_fake_aplan_message)) + + kernel = FakeKernel() + result = await kernel._maybe_handle_query_pending_clarification_followup( + source_system="feishu", + source_message_id="om-query-route-reset-1", + channel_ref="chat-query-route-reset", + source_sender_open_id="ou-query-route-reset", + message_text="不是那个,我现在问外部网关", + thread_context=None, + ) + + state_row = ( + await external_qa_db_session.execute( + select(QASessionState).where(QASessionState.session_id == int(session.id)) + ) + ).scalar_one() + + assert result is None + assert captured["planner_kwargs"] == { + "message_text": "不是那个,我现在问外部网关", + "source_system": "feishu", + "metadata": {}, + } + assert "answer_kwargs" not in captured + assert state_row.pending_clarification is None + assert state_row.pending_clarification_expires_at is None + + def test_query_pending_clarification_expiry_uses_same_naive_cn_rule_as_adaptive_rag() -> None: expires_at = datetime.now() - timedelta(minutes=1) diff --git a/backend/tests/test_ai_pmo_qa_http_mainline_e2e.py b/backend/tests/test_ai_pmo_qa_http_mainline_e2e.py index 69691667..1431e590 100644 --- a/backend/tests/test_ai_pmo_qa_http_mainline_e2e.py +++ b/backend/tests/test_ai_pmo_qa_http_mainline_e2e.py @@ -155,6 +155,139 @@ async def test_http_rag_ask_mixed_mainline_contract(monkeypatch) -> None: assert rag_request_context["mixed_signal_part"] == "新增任务确认流" +@pytest.mark.asyncio +async def test_http_rag_ask_should_attach_project_operating_context_for_current_project(monkeypatch) -> None: + captured: dict[str, object] = {} + + class FakeKernel: + def __init__(self, db, tenant_id: int): + _ = db + captured["tenant_id"] = tenant_id + + async def process_ingress(self, **kwargs): + captured["ingress_kwargs"] = kwargs + return { + "handled": True, + "status": "answered", + "intent": "query.read_only", + "direct_reply_required": True, + "direct_reply_text": "先给判断,再给下一步。", + "rag_result": { + "answer": "先给判断,再给下一步。", + "route_decision": { + "route_mode": "external", + "effective_source_policy": "external", + "decision_origin": "query_route_gateway", + }, + }, + } + + monkeypatch.setattr(routes_query, "AIPMOKernelService", FakeKernel) + app = _build_test_app() + transport = ASGITransport(app=app) + async with AsyncClient(transport=transport, base_url="http://test") as client: + response = await client.post( + "/rag/ask", + json={ + "question": "OpenAI Docs 这种能力适不适合我们当前项目?", + "asked_via": "feishu", + "project_id": 12, + "source_chat_id": "chat-12", + "action_mode": "read", + }, + ) + + assert response.status_code == 200 + ingress_options = captured["ingress_kwargs"]["ingress_options"] + packet = ingress_options["project_operating_context"] + assert packet["current_project_id"] == 12 + assert packet["current_repo_scope"] + assert packet["fit_scope"] == "current_project_and_repo" + assert packet["decision_strategy"] == "answer_then_offer_plan" + + +@pytest.mark.asyncio +async def test_http_rag_ask_returns_offer_after_answer_handoff_metadata_for_current_project_fit_question( + monkeypatch, +) -> None: + class FakeKernel: + def __init__(self, db, tenant_id: int): + _ = (db, tenant_id) + + async def process_ingress(self, **kwargs): + _ = kwargs + return { + "handled": True, + "status": "answered", + "intent": "query.read_only", + "direct_reply_required": True, + "direct_reply_text": "外部实时查询:目前证据不足,建议先明确要查融资还是招聘动态。", + "rag_result": { + "answer": "外部实时查询:目前证据不足,建议先明确要查融资还是招聘动态。", + "route_decision": { + "route_mode": "external", + "effective_source_policy": "external", + "decision_origin": "query_route_gateway", + }, + "project_operating_context": { + "context_version": "v1", + "current_project_id": 12, + "current_repo_scope": "alpha-repo", + "page_anchor": {"type": "project_board", "ref": "project:12"}, + "thread_anchor": {"source": "pmflow"}, + "fit_scope": "current_project_and_repo", + "decision_strategy": "answer_then_offer_plan", + }, + "execution_recipe": { + "primary_lane": "external", + "fit_brief_required": True, + "external_evidence_required": True, + "plan_handoff_mode": "offer_after_answer", + }, + "fit_brief": { + "project_summary": "Alpha 是一个 AI 项目管理工作台。", + "repo_summary": "当前仓库负责统一项目上下文与 AI handoff。", + }, + "external_recommendation": { + "retrieval_status": "evidence_found", + "impact_status": "indirect_only", + "external_impact": "目前更像背景相关,还不能直接映射到当前正式对象。", + }, + "handoff_mode": "offer_after_answer", + "can_enter_plan": True, + "recommended_next_step": "如果你要,我可以基于当前结论进入 plan,继续把需求范围和下一步收清楚。", + }, + } + + monkeypatch.setattr(routes_query, "AIPMOKernelService", FakeKernel) + app = _build_test_app() + transport = ASGITransport(app=app) + async with AsyncClient(transport=transport, base_url="http://test") as client: + response = await client.post( + "/rag/ask", + json={ + "question": "这个适合吗", + "asked_via": "pmflow_manual", + "project_id": 12, + "source_chat_id": "chat-12", + "action_mode": "read", + }, + ) + + assert response.status_code == 200 + payload = response.json() + assert payload["code"] == 0 + data = payload["data"] + assert data["route_decision"]["route_mode"] == "external" + assert data["project_operating_context"]["current_project_id"] == 12 + assert data["execution_recipe"]["plan_handoff_mode"] == "offer_after_answer" + assert data["fit_brief"]["project_summary"] == "Alpha 是一个 AI 项目管理工作台。" + assert data["external_recommendation"]["impact_status"] == "indirect_only" + assert data["handoff_mode"] == "offer_after_answer" + assert data["can_enter_plan"] is True + assert "进入 plan" in data["recommended_next_step"] + + @pytest.mark.asyncio async def test_http_ai_pmo_process_text_draft_pending_confirm_mainline(monkeypatch) -> None: captured: dict[str, object] = {} @@ -201,6 +334,47 @@ async def test_http_ai_pmo_process_text_draft_pending_confirm_mainline(monkeypat assert kwargs["project_hint_id"] == 12 +@pytest.mark.asyncio +async def test_http_ai_pmo_process_text_should_attach_project_operating_context(monkeypatch) -> None: + captured: dict[str, object] = {} + + class FakeKernel: + def __init__(self, db, tenant_id: int): + _ = db + captured["tenant_id"] = tenant_id + + async def process_ingress(self, **kwargs): + captured["ingress_kwargs"] = kwargs + return { + "handled": True, + "status": "answered", + "classification": "query", + "direct_reply_required": True, + "direct_reply_text": "这个方向先判断,再决定是否进入计划。", + } + + monkeypatch.setattr(routes_core, "AIPMOKernelService", FakeKernel) + app = _build_test_app() + transport = ASGITransport(app=app) + async with AsyncClient(transport=transport, base_url="http://test") as client: + response = await client.post( + "/ai-pmo/process-text", + json={ + "message_text": "这个方案适不适合我们当前项目?", + "sender": "产品经理A", + "source_message_id": "manual-msg-200", + "project_id": 12, + }, + ) + + assert response.status_code == 200 + ingress_options = captured["ingress_kwargs"]["ingress_options"] + packet = ingress_options["project_operating_context"] + assert packet["current_project_id"] == 12 + assert packet["fit_scope"] == "current_project_and_repo" + assert packet["decision_strategy"] == "answer_then_offer_plan" + + @pytest.mark.asyncio async def test_http_ai_pmo_work_items_list_smoke(monkeypatch) -> None: class FakeResult: diff --git a/backend/tests/test_external_query_planner.py b/backend/tests/test_external_query_planner.py index d12300a0..d3358cca 100644 --- a/backend/tests/test_external_query_planner.py +++ b/backend/tests/test_external_query_planner.py @@ -1,4 +1,9 @@ +from types import SimpleNamespace + +import pytest + from app.services.external_query_service.planner import ExternalQueryPlanner +from app.services.external_query_service.presenter import ExternalAnswerPresenter def test_fallback_plan_stays_generic_for_fresh_weather_semantics() -> None: @@ -49,3 +54,153 @@ def test_fallback_plan_uses_compare_contract_without_object_kind_mapping() -> No assert plan.answer_contract == "compare_with_sources" assert plan.requires_multi_source is True assert plan.result_limit == 3 + + +@pytest.mark.asyncio +async def test_build_plan_threads_fit_brief_guidance_into_prompt_and_plan() -> None: + class _FakeLLM: + def __init__(self) -> None: + self.prompt = "" + + async def generate_structured_object(self, **kwargs): + self.prompt = str(kwargs.get("prompt") or "") + return SimpleNamespace( + payload={ + "topic": "Responses API 适配 PMFlow", + "search_queries": ["OpenAI Responses API project management workflow"], + } + ) + + fake_llm = _FakeLLM() + planner = ExternalQueryPlanner( + llm_service=fake_llm, + db=object(), + tenant_id=1, + ) + + fit_brief = { + "project_summary": "当前项目:PMFlow AI 管家,聚焦项目内问答与需求流转。", + "repo_summary": "当前仓库:FastAPI 后端 + React 前端。", + "current_capabilities": ["AI 管家", "智能配置"], + "constraints": ["只允许基于外部公开证据给出推荐。"], + "recommendation_focus": ["判断 Responses API 是否适合当前项目接入"], + } + plan, meta = await planner.build_plan( + question="这个项目适不适合接 OpenAI Responses API?", + query_semantics={ + "query_profile": {"needs_freshness": True}, + "object_mentions": ["OpenAI Responses API"], + }, + fit_brief=fit_brief, + ) + + assert meta["planner"] == "llm_structured" + assert "PMFlow AI 管家" in fake_llm.prompt + assert "智能配置" in fake_llm.prompt + assert plan.fit_brief_summary + assert plan.project_scope_hint == "current_project_and_repo" + assert "适合当前项目接入" in str(plan.recommendation_goal) + assert "AI 管家" in plan.project_object_anchors + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + ("retrieval_status", "expected_variant", "expected_phrase"), + [ + ("verifier_blocked", "external_verifier_blocked", "没有通过核验"), + ("no_relevant_results", "external_no_relevant_results", "没有找到足够相关的外部公开证据"), + ("retrieval_failed", "external_retrieval_failed", "外部检索这次执行失败"), + ], +) +async def test_presenter_distinguishes_external_failure_states( + retrieval_status: str, + expected_variant: str, + expected_phrase: str, +) -> None: + class _FakeLLM: + def __init__(self) -> None: + self.calls = 0 + + async def generate(self, *args, **kwargs): + _ = (args, kwargs) + self.calls += 1 + return "should not be used" + + fake_llm = _FakeLLM() + presenter = ExternalAnswerPresenter( + llm_service=fake_llm, + db=object(), + tenant_id=1, + ) + + result = await presenter.present( + question="这个项目适不适合接某个外部能力?", + context_packet={ + "question": "这个项目适不适合接某个外部能力?", + "plan": { + "answer_contract": "summary_with_sources", + "topic": "外部能力适配判断", + }, + "adapter_attempts": [{"adapter": "live_search", "status": "empty"}], + "verified_evidence": [], + "verifier": {"ok": False, "reason_code": "external_evidence_off_topic"}, + "uncertainties": ["external_evidence_off_topic"], + "retrieval_status": retrieval_status, + "impact_status": "no_direct_impact", + }, + thinking_mode="normal", + ) + + assert result["prompt_variant_id"] == expected_variant + assert expected_phrase in result["answer_text"] + assert fake_llm.calls == 0 + + +@pytest.mark.asyncio +async def test_presenter_distinguishes_indirect_only_without_overclaiming_direct_match() -> None: + class _FakeLLM: + def __init__(self) -> None: + self.calls = 0 + + async def generate(self, *args, **kwargs): + _ = (args, kwargs) + self.calls += 1 + return "should not be used" + + fake_llm = _FakeLLM() + presenter = ExternalAnswerPresenter( + llm_service=fake_llm, + db=object(), + tenant_id=1, + ) + + result = await presenter.present( + question="这个项目要不要跟进 OpenAI Responses API?", + context_packet={ + "question": "这个项目要不要跟进 OpenAI Responses API?", + "plan": { + "answer_contract": "summary_with_sources", + "topic": "Responses API 适配性", + }, + "adapter_attempts": [{"adapter": "live_search", "status": "success"}], + "verified_evidence": [ + { + "source_type": "url", + "source_label": "OpenAI Docs", + "title": "Responses API guide", + "target_url": "https://platform.openai.com/docs/guides/responses", + "snippet": "Responses API can orchestrate tool calls and structured outputs.", + } + ], + "verifier": {"ok": True}, + "uncertainties": [], + "retrieval_status": "evidence_found", + "impact_status": "indirect_only", + }, + thinking_mode="normal", + ) + + assert result["prompt_variant_id"] == "external_indirect_only" + assert "只能说明它和当前项目有背景相关性" in result["answer_text"] + assert "不能直接说明它已经命中当前项目对象" in result["answer_text"] + assert fake_llm.calls == 0 diff --git a/backend/tests/test_feishu_card_action_contract.py b/backend/tests/test_feishu_card_action_contract.py index fe3b2d94..9c627b88 100644 --- a/backend/tests/test_feishu_card_action_contract.py +++ b/backend/tests/test_feishu_card_action_contract.py @@ -139,6 +139,256 @@ def test_build_action_card_for_requirement_plan_followup() -> None: assert [((btn.get("value") or {}).get("synthetic_text")) for btn in row_2_actions] == ["继续出草稿", "重出一版"] +@pytest.mark.parametrize( + ("external_recommendation", "expected_fragment"), + [ + ( + { + "retrieval_status": "evidence_found", + "impact_status": "indirect_only", + }, + "目前更像背景相关", + ), + ( + { + "retrieval_status": "verifier_blocked", + "impact_status": "uncertain", + }, + "校验未通过", + ), + ( + { + "retrieval_status": "no_relevant_results", + "impact_status": "no_direct_match", + }, + "暂未找到与当前项目直接相关的公开证据", + ), + ( + { + "retrieval_status": "retrieval_failed", + "impact_status": "uncertain", + }, + "这次外部检索失败", + ), + ], +) +def test_build_feishu_external_summary_line_preserves_distinct_states( + external_recommendation: dict[str, str], + expected_fragment: str, +) -> None: + line = FeishuReceiveRoutingMixin._build_feishu_external_summary_line(external_recommendation) + + assert line.startswith("外部判断:") + assert expected_fragment in line + + +def test_should_accept_feishu_qa_natural_reply_rejects_fixed_opener_without_project_entities() -> None: + accepted = FeishuReceiveRoutingMixin._should_accept_feishu_qa_natural_reply( + "这事可以继续看,但现在还不能直接说它适合这个项目。", + selected_entity_candidates=[ + "采购经理每日实时库存上报服务", + "WMS库存快照自动拉取与清洗", + "每日固定时段生成并推送结构化摘要卡片", + ], + blocked_entity_candidates=[], + ) + + assert accepted is False + + +def test_should_accept_feishu_qa_natural_reply_accepts_project_entity_analysis() -> None: + accepted = FeishuReceiveRoutingMixin._should_accept_feishu_qa_natural_reply( + ( + "Responses API 不适合放在 WMS库存快照自动拉取或者 TOP10缺货排序这两层," + "更像放在每日固定时段生成并推送结构化摘要卡片之后的解释和追问层。" + ), + selected_entity_candidates=[ + "采购经理每日实时库存上报服务", + "WMS库存快照自动拉取与清洗", + "TOP10商品缺货排序逻辑实现(基于当前库存与安全库存差值)", + "每日固定时段生成并推送结构化摘要卡片", + ], + blocked_entity_candidates=[], + ) + + assert accepted is True + + +def test_should_accept_feishu_qa_natural_reply_rejects_unplanned_project_entities() -> None: + accepted = FeishuReceiveRoutingMixin._should_accept_feishu_qa_natural_reply( + ( + "Responses API 更像放在每日固定时段生成并推送结构化摘要卡片之后的解释层," + "不适合直接放进 WMS库存快照自动拉取与清洗 主链。" + "但它和外部信号源 - WMS实时库存接口 也许也能直接打通。" + ), + selected_entity_candidates=[ + "WMS库存快照自动拉取与清洗", + "每日固定时段生成并推送结构化摘要卡片", + ], + blocked_entity_candidates=[ + "外部信号源 - WMS实时库存接口", + "TOP10商品缺货排序逻辑实现(基于当前库存与安全库存差值)", + ], + ) + + assert accepted is False + + +def test_normalize_feishu_qa_analysis_plan_keeps_only_allowed_focus_entities() -> None: + plan = FeishuReceiveRoutingMixin._normalize_feishu_qa_analysis_plan( + payload={ + "conclusion": "Responses API 更像摘要卡片之后的解释层。", + "focus_entities": [ + { + "name": "每日固定时段生成并推送结构化摘要卡片", + "stance": "better_fit", + "why": "这里更适合承接解释和追问。", + }, + { + "name": "WMS库存快照自动拉取与清洗", + "stance": "not_fit", + "why": "这是确定性取数链路,不适合塞进生成能力。", + }, + { + "name": "外部并不存在的对象", + "stance": "not_fit", + "why": "这条应该被过滤掉。", + }, + ], + "missing_mapping": "当前没有证据说明它已接入现有信号源或主计算链。", + "next_step": "先确认它到底承担哪个节点的角色。", + }, + entity_catalog=[ + { + "name": "每日固定时段生成并推送结构化摘要卡片", + "kind": "task", + }, + { + "name": "WMS库存快照自动拉取与清洗", + "kind": "task", + }, + { + "name": "外部信号源 - WMS实时库存接口", + "kind": "signal", + }, + ], + fallback_next_step="继续确认它到底落在这个项目的哪一层,再决定要不要进入 plan。", + ) + + assert plan["conclusion"] == "Responses API 更像摘要卡片之后的解释层。" + assert [item["name"] for item in plan["focus_entities"]] == [ + "每日固定时段生成并推送结构化摘要卡片", + "WMS库存快照自动拉取与清洗", + ] + assert [item["stance"] for item in plan["focus_entities"]] == ["better_fit", "not_fit"] + assert all(item["name"] != "外部并不存在的对象" for item in plan["focus_entities"]) + assert plan["missing_mapping"] == "当前没有证据说明它已接入现有信号源或主计算链。" + + +def test_build_feishu_qa_analysis_fallback_plan_prefers_presentation_and_core_chain_entities_for_indirect_only() -> None: + plan = FeishuReceiveRoutingMixin._build_feishu_qa_analysis_fallback_plan( + result={ + "rag_result": { + "external_recommendation": { + "retrieval_status": "evidence_found", + "impact_status": "indirect_only", + }, + "recommended_next_step": "继续确认它到底落在这个项目的哪一层,再决定要不要进入 plan。", + }, + }, + entity_catalog=[ + {"name": "WMS库存快照自动拉取与清洗", "kind": "task"}, + {"name": "TOP10商品缺货排序逻辑实现(基于当前库存与安全库存差值)", "kind": "task"}, + {"name": "每日固定时段生成并推送结构化摘要卡片", "kind": "task"}, + {"name": "试点小组成员接收通道配置与权限开通", "kind": "task"}, + ], + ) + + assert plan["conclusion"] == "目前还不能直接说它已经适合当前项目。" + assert [item["name"] for item in plan["focus_entities"]] == [ + "每日固定时段生成并推送结构化摘要卡片", + "WMS库存快照自动拉取与清洗", + ] + assert [item["stance"] for item in plan["focus_entities"]] == ["better_fit", "not_fit"] + + +def test_build_feishu_qa_analysis_plan_reply_text_avoids_stepwise_checklist_tone() -> None: + reply = FeishuReceiveRoutingMixin._build_feishu_qa_analysis_plan_reply_text( + { + "conclusion": "目前还不能直接说它已经适合当前项目。", + "focus_entities": [ + { + "name": "每日固定时段生成并推送结构化摘要卡片", + "stance": "better_fit", + "why": "这里更像结果出来之后的解释和追问层。", + }, + { + "name": "WMS库存快照自动拉取与清洗", + "stance": "not_fit", + "why": "这是稳定取数的主链,不适合直接塞进生成能力。", + }, + ], + "missing_mapping": "还缺它在当前项目里到底承接哪个节点的明确映射。", + "next_step": "先确认它是不是只用在摘要卡片之后。", + } + ) + + expected = ( + "目前还不能直接说它已经适合当前项目。\n\n" + "如果放在这个项目里,它更像接在每日固定时段生成并推送结构化摘要卡片后面的解释和追问层," + "不像 WMS库存快照自动拉取与清洗 这种要稳定取数的主链。\n\n" + "还缺它在当前项目里到底承接哪个节点的明确映射。\n\n" + "先确认它是不是只用在摘要卡片之后。" + ) + assert reply == expected + + +def test_choose_feishu_qa_analysis_plan_prefers_fallback_when_indirect_only_plan_lacks_contrast() -> None: + chosen = FeishuReceiveRoutingMixin._choose_feishu_qa_analysis_plan( + normalized_plan={ + "conclusion": "目前还不能直接说它已经适合当前项目。", + "focus_entities": [ + { + "name": "WMS库存快照自动拉取与清洗", + "stance": "relevant", + "why": "该任务聚焦内部WMS数据接入。", + }, + { + "name": "外部信号源 - WMS实时库存接口", + "stance": "relevant", + "why": "同为外部API调用场景。", + }, + ], + "missing_mapping": "缺少具体作用层映射。", + "next_step": "确认它是不是用于摘要生成环节。", + }, + fallback_plan={ + "conclusion": "目前还不能直接说它已经适合当前项目。", + "focus_entities": [ + { + "name": "每日固定时段生成并推送结构化摘要卡片", + "stance": "better_fit", + "why": "这一步更像结果出来之后的解释和追问层。", + }, + { + "name": "WMS库存快照自动拉取与清洗", + "stance": "not_fit", + "why": "这一步属于确定性取数或计算主链,不适合直接塞进生成能力。", + }, + ], + "missing_mapping": "还缺它在当前项目里到底承接哪个节点的明确映射。", + "next_step": "继续确认它到底落在这个项目的哪一层,再决定要不要进入 plan。", + }, + retrieval_status="evidence_found", + impact_status="indirect_only", + ) + + assert [item["name"] for item in chosen["focus_entities"]] == [ + "每日固定时段生成并推送结构化摘要卡片", + "WMS库存快照自动拉取与清洗", + ] + + def test_build_execution_receipt_reply_prefers_compact_multiline_summary() -> None: reply = FeishuReplyTemplatesMixin._build_execution_receipt_reply( message_content="请处理这条信号", @@ -379,3 +629,83 @@ async def test_route_scene_reply_sends_requirement_plan_followup_as_card(monkeyp assert "reply_text" not in calls card_content = calls["reply_card"]["card_content"] if isinstance(calls["reply_card"], dict) else {} assert card_content["header"]["title"]["content"] == "AI PMO 需求收敛" + + +@pytest.mark.asyncio +async def test_route_scene_reply_sends_rich_qa_direct_text_for_handoff_metadata() -> None: + calls: dict[str, object] = {} + + class FakeRouting(FeishuReceiveRoutingMixin): + def _build_capability_reply(self, message_content: str): # noqa: ANN001 + _ = message_content + return None + + def _build_action_card_from_capture_result(self, *, capture_result, mention_open_id, mention_name): # noqa: ANN001 + _ = (capture_result, mention_open_id, mention_name) + return None + + async def _safe_reply_text(self, **kwargs): # noqa: ANN001 + calls["reply_text"] = kwargs + + def _emit_state_transition(self, **kwargs): # noqa: ANN001 + calls["state_transition"] = kwargs + + def _should_allow_group_qa_reply(self, **kwargs): # noqa: ANN001 + _ = kwargs + return True + + async def _render_feishu_qa_direct_reply_text(self, *, result, message_content): # noqa: ANN001 + _ = (result, message_content) + return ( + "Responses API 不适合放在 WMS库存快照自动拉取或者 TOP10缺货排序这两层," + "更像放在每日结构化摘要卡片之后的解释和追问层。\n\n" + "这个项目当前在做的是先把 WMS库存快照拉进来,再完成 TOP10缺货排序," + "最后按固定时段推送结构化摘要卡片。前两层都属于取数和计算主链,应该保持确定、可复算," + "不适合把 Responses API 放进去做主判断。\n\n" + "它真正更可能有价值的地方,是采购经理收到摘要卡片之后继续追问的时候。" + ) + + routing = FakeRouting() + result = await routing._route_scene_reply( + scene="qa", + result={ + "direct_reply_required": True, + "direct_reply_text": "外部实时查询:目前证据不足,建议先明确要查融资还是招聘动态。", + "rag_result": { + "fit_brief": { + "project_summary": "Alpha 是一个 AI 项目管理工作台。", + "repo_summary": "当前仓库负责统一项目上下文与 AI handoff。", + }, + "external_recommendation": { + "retrieval_status": "evidence_found", + "impact_status": "indirect_only", + "external_impact": "目前更像背景相关,还不能直接映射到当前正式对象。", + }, + "recommended_next_step": "如果你要,我可以基于当前结论进入 plan,继续把需求范围和下一步收清楚。", + }, + }, + message_content="这个适合吗", + chat_id="chat-rich-qa", + chat_type="group", + sender_open_id="ou-rich-qa", + sender_name="测试用户", + mentioned_bot_names=[], + thread_context={"reply_to_message_id": "om-rich-qa"}, + trace_id="trace-rich-qa", + message_id="msg-rich-qa", + perf_t0=None, + ) + + assert result == "qa_direct" + reply_text = calls["reply_text"]["text"] if isinstance(calls.get("reply_text"), dict) else "" + expected = ( + "Responses API 不适合放在 WMS库存快照自动拉取或者 TOP10缺货排序这两层," + "更像放在每日结构化摘要卡片之后的解释和追问层。\n\n" + "这个项目当前在做的是先把 WMS库存快照拉进来,再完成 TOP10缺货排序," + "最后按固定时段推送结构化摘要卡片。前两层都属于取数和计算主链,应该保持确定、可复算," + "不适合把 Responses API 放进去做主判断。\n\n" + "它真正更可能有价值的地方,是采购经理收到摘要卡片之后继续追问的时候。" + ) + assert reply_text == expected + assert "这事可以继续看" not in reply_text + assert "先说我的判断" not in reply_text diff --git a/backend/tests/test_feishu_pipeline_queue_contract.py b/backend/tests/test_feishu_pipeline_queue_contract.py index 1eeb53fb..65285d5e 100644 --- a/backend/tests/test_feishu_pipeline_queue_contract.py +++ b/backend/tests/test_feishu_pipeline_queue_contract.py @@ -1051,12 +1051,14 @@ async def test_should_process_feishu_high_accuracy_treats_requirement_plan_as_sy classmethod(lambda cls, **kwargs: asyncio.sleep(0, result=_FakeBundle())), ) - should_sync, reason = await mixin_capture_entry.SignalCaptureEntryMixin._should_process_feishu_high_accuracy( + should_sync, reason, bundle = await mixin_capture_entry.SignalCaptureEntryMixin._should_process_feishu_high_accuracy( message_text="@PMFlow 想跟你聊聊需求,做一个自动给每日的大A趋势打分的小工具,先不创建,先收敛第一版。", ) assert should_sync is True assert reason == "semantic:requirement_plan" + assert bundle is not None + assert getattr(bundle, "diagnostics", {}).get("message_intent") == "requirement_plan" @pytest.mark.asyncio @@ -1072,12 +1074,14 @@ async def test_should_process_feishu_high_accuracy_treats_requirement_plan_slot_ classmethod(lambda cls, **kwargs: asyncio.sleep(0, result=_FakeBundle())), ) - should_sync, reason = await mixin_capture_entry.SignalCaptureEntryMixin._should_process_feishu_high_accuracy( + should_sync, reason, bundle = await mixin_capture_entry.SignalCaptureEntryMixin._should_process_feishu_high_accuracy( message_text="@PMFlow 先按技术指标来,主要给投顾看。", ) assert should_sync is True assert reason == "semantic:requirement_plan" + assert bundle is not None + assert getattr(bundle, "diagnostics", {}).get("message_intent") == "requirement_plan" @pytest.mark.asyncio @@ -1093,12 +1097,13 @@ async def test_should_process_feishu_high_accuracy_no_longer_uses_question_suffi classmethod(lambda cls, **kwargs: asyncio.sleep(0, result=_FakeBundle())), ) - should_sync, reason = await mixin_capture_entry.SignalCaptureEntryMixin._should_process_feishu_high_accuracy( + should_sync, reason, bundle = await mixin_capture_entry.SignalCaptureEntryMixin._should_process_feishu_high_accuracy( message_text="@PMFlow 现在几点?", ) assert should_sync is False assert reason == "async_queue" + assert bundle is None @pytest.mark.asyncio diff --git a/backend/tests/test_project_operating_context_contract.py b/backend/tests/test_project_operating_context_contract.py new file mode 100644 index 00000000..53397950 --- /dev/null +++ b/backend/tests/test_project_operating_context_contract.py @@ -0,0 +1,50 @@ +from app.schemas.project_operating_context import ExecutionRecipe, ProjectOperatingContext +from app.services.ai_pmo.project_operating_context_builder import ProjectOperatingContextBuilder + + +def test_project_operating_context_schema_contract() -> None: + context = ProjectOperatingContext( + current_project_id=17, + current_repo_scope="pmflow-main", + page_anchor={"project_id": 17, "origin": "pathname"}, + thread_anchor={"reply_to_message_id": "om_xxx"}, + ) + + assert context.context_version == "v1" + assert context.current_project_id == 17 + assert context.current_repo_scope == "pmflow-main" + assert context.page_anchor["project_id"] == 17 + assert context.thread_anchor["reply_to_message_id"] == "om_xxx" + assert context.fit_scope == "current_project_and_repo" + assert context.decision_strategy == "answer_then_offer_plan" + + +def test_project_operating_context_builder_collects_project_repo_and_anchors() -> None: + builder = ProjectOperatingContextBuilder(repo_scope="pmflow-main") + + context = builder.build( + current_project_id=17, + page_anchor={"project_id": 17, "origin": "rag_request_context"}, + thread_anchor={"reply_to_message_id": "om_xxx", "root_message_id": "om_root"}, + ) + + assert context.current_project_id == 17 + assert context.current_repo_scope == "pmflow-main" + assert context.page_anchor == {"project_id": 17, "origin": "rag_request_context"} + assert context.thread_anchor == {"reply_to_message_id": "om_xxx", "root_message_id": "om_root"} + assert context.fit_scope == "current_project_and_repo" + assert context.decision_strategy == "answer_then_offer_plan" + + +def test_execution_recipe_schema_contract() -> None: + recipe = ExecutionRecipe( + primary_lane="external", + fit_brief_required=True, + external_evidence_required=True, + plan_handoff_mode="offer_after_answer", + ) + + assert recipe.primary_lane == "external" + assert recipe.fit_brief_required is True + assert recipe.external_evidence_required is True + assert recipe.plan_handoff_mode == "offer_after_answer" diff --git a/backend/tests/test_rag_route_contract.py b/backend/tests/test_rag_route_contract.py index a32f2705..d8c24d04 100644 --- a/backend/tests/test_rag_route_contract.py +++ b/backend/tests/test_rag_route_contract.py @@ -109,6 +109,27 @@ def test_build_kernel_ingress_kwargs_preserves_explicit_anchor_fields() -> None: assert kwargs["ingress_options"]["rag_request_context"]["source_chat_id"] == "pmflow-project-17" +def test_build_kernel_ingress_kwargs_attaches_project_operating_context() -> None: + request = RAGAskRequest( + question="OpenAI Docs 这种能力适不适合我们当前项目?", + project_id=17, + source_chat_id="pmflow-project-17", + asked_via="feishu", + ) + + kwargs = rag_ask_contract.build_kernel_ingress_kwargs( + request=request, + current_user=SimpleNamespace(id=1, tenant_id=1, username="admin"), + ) + + packet = kwargs["ingress_options"]["project_operating_context"] + assert packet["current_project_id"] == 17 + assert packet["current_repo_scope"] + assert packet["page_anchor"]["project_id"] == 17 + assert packet["fit_scope"] == "current_project_and_repo" + assert packet["decision_strategy"] == "answer_then_offer_plan" + + @pytest.mark.asyncio async def test_rag_ask_route_forwards_contract_fields(monkeypatch) -> None: captured: dict[str, object] = {} @@ -161,6 +182,10 @@ async def test_rag_ask_route_forwards_contract_fields(monkeypatch) -> None: assert rag_request_context["message_intent"] == "mixed" assert rag_request_context["mixed_signal_part"] == "新增任务确认流" assert rag_request_context["consultant_mode_preference"] == "clarify" + project_operating_context = ingress_kwargs["ingress_options"]["project_operating_context"] + assert project_operating_context["current_repo_scope"] + assert project_operating_context["fit_scope"] == "current_project_and_repo" + assert project_operating_context["decision_strategy"] == "answer_then_offer_plan" def test_ai_steward_router_does_not_expose_legacy_rag_ask_route() -> None: @@ -215,24 +240,23 @@ async def test_rag_ask_route_uses_shared_contract_builder(monkeypatch) -> None: assert len(captured_kwargs) == 1 forwarded = captured_kwargs[0] assert str(forwarded["source_message_id"]).startswith("rag-ask-") - assert forwarded == { - "source_system": "pmflow", - "source_message_id": forwarded["source_message_id"], - "message_text": "q1", - "sender": "PMFlow用户", - "channel_ref": None, - "source_sender_open_id": None, - "captured_at": None, - "chat_type": "manual", - "project_hint_id": None, - "ingress_options": { - "rag_request_context": { - "question": "q1", - "asked_by": 1, - "asked_via": "shared-builder", - } - }, + assert forwarded["source_system"] == "pmflow" + assert forwarded["message_text"] == "q1" + assert forwarded["sender"] == "PMFlow用户" + assert forwarded["channel_ref"] is None + assert forwarded["source_sender_open_id"] is None + assert forwarded["captured_at"] is None + assert forwarded["chat_type"] == "manual" + assert forwarded["project_hint_id"] is None + assert forwarded["ingress_options"]["rag_request_context"] == { + "question": "q1", + "asked_by": 1, + "asked_via": "shared-builder", } + assert forwarded["ingress_options"]["project_operating_context"]["current_project_id"] is None + assert forwarded["ingress_options"]["project_operating_context"]["current_repo_scope"] + assert forwarded["ingress_options"]["project_operating_context"]["fit_scope"] == "current_project_and_repo" + assert forwarded["ingress_options"]["project_operating_context"]["decision_strategy"] == "answer_then_offer_plan" def test_build_rag_response_payload_preserves_rag_fields() -> None: diff --git a/docs/architecture/ARCHITECTURE_INVENTORY.md b/docs/architecture/ARCHITECTURE_INVENTORY.md index 7c38eaa4..1919f4dc 100644 --- a/docs/architecture/ARCHITECTURE_INVENTORY.md +++ b/docs/architecture/ARCHITECTURE_INVENTORY.md @@ -45,6 +45,7 @@ ### 4.2 AI PMO 与语义主链 - `backend/app/services/ai_pmo/ai_pmo_kernel/` - `backend/app/services/ai_pmo/ai_pmo_kernel_ingress_flow/` +- `backend/app/services/ai_pmo/project_operating_context_builder.py` - `backend/app/services/ai_pmo/ai_pmo_typed_executor_execute/` - `backend/app/services/ai_pmo/ai_pmo_typed_executor_discovery/` - `backend/app/services/ai_pmo/semantic_context/` @@ -54,8 +55,10 @@ ### 4.3 问答、模型与生成 - `backend/app/services/adaptive_rag_service/` - `backend/app/services/adaptive_rag_service/internal_query_context.py` +- `backend/app/services/adaptive_rag_service/mixin_project_fit_brief.py` - `backend/app/services/adaptive_rag_service/mixin_query_route_gateway.py` - `backend/app/services/adaptive_rag_service/mixin_query_route_governance.py` +- `backend/app/schemas/project_operating_context.py` - `backend/app/services/external_query_service/` - `backend/app/services/semantic_fact_service/` - `backend/app/services/semantic_fact_service/hooks.py` @@ -99,10 +102,14 @@ - `backend/app/services/` 根目录已目录化收敛,新增服务优先进入对应子包。 - API canonical 入口优先使用 `*_api/` 子包,不再优先引用顶层兼容壳。 - 运行时业务动作归因优先走“对象/上下文绑定 -> `SemanticContextPacket` -> `SemanticDecision` -> gatekeeper”。 -- non-plan `query.answer` 现已在 `AdaptiveRAG` 内增加 `Query Route Gateway`:只消费上游单次结构化 `query_profile/source_policy/effective_source_policy` 与显式 hint,归一化产出 `route_mode / decision_origin / reply_contract / probe_snapshot(contract audit)`,不再在运行时执行 internal/external probe 或 route memory 强拍板,再进入检索与回答主链。 +- non-plan `query.answer` 现已在 `AdaptiveRAG` 内增加 `Query Route Gateway`:只消费上游单次结构化 `query_profile/source_policy/effective_source_policy` 与显式 hint,结合 `ProjectOperatingContext` 归一化产出 `route_mode / decision_origin / reply_contract / probe_snapshot(contract audit) / execution_recipe`,不再在运行时执行 internal/external probe 或 route memory 强拍板,再进入检索与回答主链。 +- `ProjectOperatingContext` 当前由 `project_operating_context_builder.py` 统一组装:最小范围固定为“当前项目 + 当前仓库摘要 + 页面/线程锚点”;world model 建完后会回填 `resolved_project_id / repo_scope`,再供 `Query Route Gateway`、`mixin_project_fit_brief.py` 与 answer payload 消费。 - internal `query.answer` 的下一阶段基线已经确认:`Query Route Gateway` 之后不再直接靠快答分支拍板,而是统一进入 `Operation Contract -> Target Resolver -> Query DSL Plan -> InternalContextPacket -> 白名单受控执行器 -> semantic facts 优先 -> verifier` 主链;`InternalContextPacket` 由 `backend/app/services/adaptive_rag_service/internal_query_context.py` 定义,并作为 internal executor / verifier / runtime summary 的运行时单一真源;相关设计规格见 `docs/superpowers/specs/2026-04-15-internal-query-dsl-plan-design.md` 与 `docs/superpowers/specs/2026-04-15-semantic-facts-read-model-design.md`。 - internal 主链现已补上 packet 预检:`mixin_answer_mainline.py` 在组完 `InternalContextPacket` 后,会先拦截 `missing packet / missing operation contract / target clarify / missing query plan contract` 这类上下文不完整场景,再决定是否进入 deterministic fast path、internal model-config fast path、GLM semantic fast path 或 retrieval;`query_semantics` 对 `operation_contract / target_resolution / query_plan` 的镜像也已收口到单点兼容写回。 -- external `query.answer` 已完成 first-pass 收口:`Query Route Gateway` 之后统一进入 `backend/app/services/external_query_service/`,按 `External Query Planner -> External Query Gateway -> External Provider Registry -> MCP adapter 优先 -> bounded fallback live search -> external answer verifier -> ExternalContextPacket -> ExternalAnswerPresenter` 执行;AdaptiveRAG external probe、freshness fetch 与 external route retrieval 已接到这条链上,并对 external sources 执行 fail-closed 过滤;当前 `presenter.py` 使用独立 external-only prompt 家族,不再回流 `industry_trend/external_trend` scene prompt;相关设计规格见 `docs/superpowers/specs/2026-04-15-external-query-gateway-design.md`。 +- external `query.answer` 已完成 second-pass 收口:`Query Route Gateway` 之后统一进入 `backend/app/services/external_query_service/`,按 `ProjectFitBrief -> External Query Planner -> External Query Gateway -> External Provider Registry -> MCP adapter 优先 -> bounded fallback live search -> external answer verifier -> ExternalContextPacket -> ExternalAnswerPresenter` 执行;planner 会显式消费 `fit_brief_summary / project_scope_hint / recommendation_goal / project_object_anchors`,其中 formal object anchors 现优先来自 `ProjectFitBrief.signal_source_anchors`,不再拿 feature capability 名称直接冒充 formal project object;payload 会拆出 `fit_brief / external_recommendation`,AdaptiveRAG external probe、freshness fetch 与 external route retrieval 已接到这条链上,并对 external sources 执行 fail-closed 过滤;当前 `presenter.py` 使用独立 external-only prompt 家族,并强制区分 `verifier_blocked / no_relevant_results / retrieval_failed / indirect_only`,不再回流 `industry_trend/external_trend` scene prompt;相关设计规格见 `docs/superpowers/specs/2026-04-15-external-query-gateway-design.md`。 +- QA session detail 持久化现已补齐 `routing_snapshot.response_payload`:`mixin_core_intent.py` 在写 `QARecord` 时会把 `project_operating_context / execution_recipe / fit_brief / external_recommendation / handoff_mode / can_enter_plan / recommended_next_step` 一并归档,`mixin_qa_session_list_ops_p1.py` 再将其投影给 AI Steward 历史回放,避免即时回答与 session detail 出现两套合同。 +- Feishu QA 呈现现与同一份 handoff payload 对齐:`mixin_receive_routing.py` 会先基于 `project_operating_context` 读取当前项目实体快照,再走“两段式 presenter”:第一段用结构化 analysis plan 只挑 1-2 个当前项目实体并写清 `conclusion / focus_entities / missing_mapping / next_step`;第二段才允许自然化改写最终 `qa_direct` 文本。运行时会同时校验 fixed opener、标签摘要以及未入选实体泄漏;若任一条件不满足,则 fail-closed 回退到 analysis-plan deterministic 文本。同时保留 `draft_pending_confirm / requirement_plan_followup / pending_manual` 优先走 action card,避免把飞书回归修成单纯文案 bypass。 +- unified handoff 当前补有最小 rollback guard:`shared_utils.py` 提供 env flag 解析,`mixin_query_route_gateway.py` / `mixin_answer_retrieval.py` / `mixin_answer_payload.py` 分别支持独立关闭 `execution_recipe` 增强、`fit_brief + external_recommendation` 构建、以及默认 `offer_after_answer` 呈现,不需要回退 ingress/context 真源。 - `query.answer` 的客户可见回答展示也已确认独立基线:运行时证据必须拆成 `customer_safe` 与 `admin_debug` 双视图,客户侧统一消费中文化 `display_label`,禁止直接暴露 `raw_source_label`、机器来源或内部术语;external answer presenter 的 prompt 输入也必须保持 external-only,仅允许消费经 verifier 通过的外部证据摘要与计划摘要;相关设计规格见 `docs/superpowers/specs/2026-04-15-answer-presentation-policy-design.md`。 - `semantic_fact_service` 已升级为统一基线:`semantic_facts / semantic_fact_extraction_runs` 读模型、ORM 提交钩子自动收集对象变更并 upsert `ingest_events(channel=semantic_fact_extract)`、统一 worker 抽取/失活,以及 internal query 的 facts-first 主路径。 - semantic facts 自动抽取覆盖 `project / task / signal / document / user / requirement(charter) / release / review` 八类业务对象;manual retry 与 backfill 走同一队列、同一 worker,不再开旁路。 @@ -149,6 +156,8 @@ - `frontend/src/components/ai-steward/` - `frontend/src/components/ai-steward/QAConversationStatusBar.tsx` - `frontend/src/components/ai-steward/QAResponseMetaCard.tsx` +- `frontend/src/components/ai-steward/QAResponseMetaCard.test.tsx` +- AI Steward 问答浮窗现分三层显性化:`FloatingQAChat.tsx` 负责即时回答/历史回放归一化,`QAConversationStatusBar.tsx` 展示对象/来源/仓库/执行通道/plan handoff,`QAResponseMetaCard.tsx` 展示 `fit_brief / external_recommendation / recommended_next_step`,对应前端回归由 `FloatingQAChat.test.tsx` 与 `QAResponseMetaCard.test.tsx` 锁定。 - `frontend/src/api/taskBriefs.ts` - `frontend/src/api/modelRouting.ts` - `frontend/src/api/mcp.ts` diff --git a/docs/architecture/CODE_REFERENCE_INDEX.md b/docs/architecture/CODE_REFERENCE_INDEX.md index dbabc5c3..07609a0f 100644 --- a/docs/architecture/CODE_REFERENCE_INDEX.md +++ b/docs/architecture/CODE_REFERENCE_INDEX.md @@ -31,7 +31,13 @@ ### 2.3 RAG / AI PMO / Knowledge Graph - RAG 问答主入口:参考 `backend/app/api/rag_api/` 与 `backend/app/api/rag_ask_contract.py` - `/rag/ask` richer response contract、QA session detail 与 state replay:参考 `backend/app/api/rag_ask_contract.py`、`backend/app/services/adaptive_rag_service/mixin_qa_session_list_ops_p1.py` +- Unified Project Operating Context 合同与 builder:参考 `backend/app/schemas/project_operating_context.py`、`backend/app/services/ai_pmo/project_operating_context_builder.py` - Query 路由网关与显式来源回复合同:参考 `backend/app/services/adaptive_rag_service/mixin_query_route_gateway.py` +- `ProjectFitBrief` 构建与 external handoff 接缝:参考 `backend/app/services/adaptive_rag_service/mixin_project_fit_brief.py`、`backend/app/services/adaptive_rag_service/mixin_answer_retrieval.py`;formal `signal_source_anchors` 也在这里生成 +- `execution_recipe / fit_brief / external_recommendation / recommended_next_step / handoff_mode` 回答接缝:参考 `backend/app/services/adaptive_rag_service/mixin_query_route_gateway.py`、`mixin_answer_mainline.py`、`mixin_answer_payload.py` +- unified handoff rollback guard:参考 `backend/app/services/adaptive_rag_service/shared_utils.py`(env flags)、`mixin_query_route_gateway.py`、`mixin_answer_retrieval.py`、`mixin_answer_payload.py` +- QA session detail 对 handoff 元数据的持久化/回放:参考 `backend/app/services/adaptive_rag_service/mixin_core_intent.py`、`mixin_qa_session_list_ops_p1.py` +- Feishu QA 文本呈现与 action card 分流:参考 `backend/app/services/integrations/feishu_integration_service/mixin_receive_routing.py`、`mixin_card_action.py`、`backend/tests/test_feishu_card_action_contract.py`;当前实现为“结构化分析包 -> 受控改写 -> blocked-entity 校验”的两段式 presenter,相关 prompt 在 `backend/app/config/ai_prompts_catalog/part_01.py` - Query route governance 只读审计接口:参考 `backend/app/api/rag_api/routes_route_governance.py` 与 `backend/app/services/adaptive_rag_service/mixin_query_route_governance.py` - External Query Service 主链:参考 `backend/app/services/external_query_service/`(`planner.py`、`gateway.py`、`registry.py`、`schemas.py`、`presenter.py`、`adapters/`) - external route 回答接缝与回归:参考 `backend/app/services/adaptive_rag_service/mixin_answer_retrieval.py`、`mixin_deep_guard_answer.py`、`backend/tests/test_mcp_external_query_bridge.py`、`backend/tests/test_adaptive_rag_mainline_contract.py` @@ -51,6 +57,7 @@ - AI PMO 审批接口:参考 `POST /ai-pmo/work-items/{work_item_id}/decision` - AI PMO 消息台列表接口:参考 `GET /ai-pmo/work-items` - AI PMO `process-text/work-item` 的 `plan_mode / plan_status / requirement_plan_context_summary` 投影:参考 `backend/app/api/ai_pmo_api/helpers_inbox_payload.py` 与 `backend/app/api/ai_pmo_api/routes_core.py` +- `/rag/ask` 与 `/ai-pmo/process-text` 注入 `project_operating_context`:参考 `backend/app/api/rag_ask_contract.py` 与 `backend/app/api/ai_pmo_api/routes_core.py` ### 2.4 Signal / Project / SSTC - 需求点落信号源:参考 `backend/app/services/signal/signal_source_orchestration/mixin_registry_execute.py` @@ -72,6 +79,8 @@ - 新增前端 API 调用:参考 `frontend/src/api/sstc.ts` - AI Steward 页面入口:参考 `frontend/src/pages/ai-steward/` - AI Steward 问答浮窗与上下文显性化组件:参考 `frontend/src/components/ai-steward/FloatingQAChat.tsx`、`frontend/src/components/ai-steward/QAConversationStatusBar.tsx`、`frontend/src/components/ai-steward/QAResponseMetaCard.tsx` +- AI Steward QA contract 前端类型:参考 `frontend/src/api/aiSteward.ts` +- AI Steward handoff 元卡片回归:参考 `frontend/src/components/ai-steward/FloatingQAChat.test.tsx`、`frontend/src/components/ai-steward/QAResponseMetaCard.test.tsx` - AI Steward / QA session 前端 contract:参考 `frontend/src/api/aiSteward.ts` - 网页问答显式锚点与 AI Steward URL 构造:参考 `frontend/src/components/ai-steward/qaPageAnchor.ts` - Inbox 页面入口:参考 `frontend/src/pages/inbox/` diff --git a/docs/architecture/RUNTIME_GUARDRAILS.md b/docs/architecture/RUNTIME_GUARDRAILS.md index b7a85851..0dda33bc 100644 --- a/docs/architecture/RUNTIME_GUARDRAILS.md +++ b/docs/architecture/RUNTIME_GUARDRAILS.md @@ -108,8 +108,17 @@ - 飞书与 AI PMO 入口(含 `message_intent=mixed`)问答链路必须绕过 deterministic/snapshot 快路径,统一落到场景化 Prompt 生成。 - `query.answer` 在进入 AdaptiveRAG 检索前必须先产出单一 `source_policy`(`internal / external / uncertain`)审计字段;来源判断只允许消费上游明确锚点与单次结构化语义结果,禁止再新增基于“问题长相”的本地关键词/正则拍板层。若上游合同仍是 `uncertain`,执行层只能保持 `uncertain` 或消费上游显式给出的 `effective_source_policy`,不得私自默认 external-first。v1 禁止同一轮 query 同时混搜内部知识库与外部网页,必须先定来源再检索。 - non-plan `query.answer` 的来源裁决统一由 `Query Route Gateway` 执行,输出必须至少包含 `route_mode / decision_origin / reply_contract / probe_snapshot(contract audit)`;回复正文必须遵守显式来源合同(`内部知识库查询:` / `外部实时查询:`),`uncertain + effective_source_policy=uncertain` 必须直接澄清,禁止混合答案。 -- `/rag/ask`、QA session detail 与 session state replay 对前台必须显式暴露 `route_decision / clarification_code / clarification_candidates / answer_mode / grounding_status / evidence_pack / session_state(last_subject, pending_clarification...)`;AI Steward UI 只能消费这些结构化字段展示当前对象、来源模式与澄清入口,禁止再从回答正文、来源标签文案或按钮文案反推 route mode。 +- query-route 澄清协议也属于受控 state machine:若线程上仍挂着 `pending_clarification(kind=query_route_selection)`,但当前短消息通过 `WorkflowSemanticPlanner` 给出显式 `topic_switch=new` 或命中新话题 workflow,ingress 必须先让旧澄清立即失效,再回到新的 `query.answer` 主链;禁止因为“短句”条件成立就继续消费旧 option map。 +- `Project Operating Context` 是 non-plan `query.answer` 的统一上下文基线:`/rag/ask`、`/ai-pmo/process-text` 与 kernel ingress 至少要透传 `current_project_id / current_repo_scope / page_anchor / thread_anchor / fit_scope / decision_strategy`;`Query Route Gateway` 只能在此基础上产出单次 `execution_recipe(primary_lane / fit_brief_required / external_evidence_required / plan_handoff_mode)`,external 主链若命中 `fit_brief_required=true` 必须先补 customer-safe `fit_brief`,回答 payload 只允许继续暴露 `fit_brief / external_recommendation / recommended_next_step / can_enter_plan / handoff_mode`,禁止在 presenter 或下游 helper 再靠问题文本重判 “要不要进 plan”。 +- external presenter 与 payload 必须显式保留系统态 `retrieval_status` 区分:`verifier_blocked / no_relevant_results / retrieval_failed / evidence_found` 不能折叠成单一“暂无外部变化”;若 `retrieval_status != evidence_found`,必须强制 `impact_status=no_direct_impact`,并清空 `external_summary / external_impact / affected_signal_sources / external_action`。 +- external recommendation 只有在 verified external evidence 与 `fit_brief.signal_source_anchors` 这类 formal current-project signal-source 锚点建立直接映射时,才允许 `impact_status=direct_match`;`current_capabilities`、feature catalog 名称或 recommendation focus 文案都不得直接触发 `direct_match`。若证据只支持背景相关性,必须显式标记 `indirect_only`,禁止冒充“已命中当前项目推荐”。 +- `/rag/ask`、QA session detail 与 session state replay 对前台必须显式暴露 `route_decision / clarification_code / clarification_candidates / answer_mode / grounding_status / evidence_pack / project_operating_context / execution_recipe / fit_brief / external_recommendation / handoff_mode / can_enter_plan / recommended_next_step / session_state(last_subject, pending_clarification...)`;QA session detail 必须与即时回答共用同一份 `routing_snapshot.response_payload` 归档,禁止历史回放丢失 handoff 元数据;AI Steward UI 只能消费这些结构化字段展示当前对象、来源模式、仓库/执行通道、项目画像、外部推荐状态与下一步,禁止再从回答正文、来源标签文案或按钮文案反推 route mode。 +- Feishu QA 直出文本必须消费同一份结构化 handoff 字段,但呈现层必须先收成 analysis plan 再允许自然化改写:`mixin_receive_routing.py` 先基于 `project_operating_context.current_project_id` 补当前项目实体快照,再用结构化 planner 只选择 1-2 个最相关的当前项目实体,固定产出 `conclusion / focus_entities / missing_mapping / next_step`,之后才允许自然化改写最终文本。自然化阶段禁止新增 analysis plan 之外的项目实体;运行时必须同时拒绝 fixed opener、`当前项目:/外部判断:/下一步:` 标签摘要、项目实体清单平铺以及 blocked entity 泄漏。若结构化 planner、自然化输出或校验失败,必须 fail-closed 回退到 analysis-plan deterministic 文本;`draft_pending_confirm / requirement_plan_followup / pending_manual` 这类显式模板键仍优先走 action card,且 `verifier_blocked / no_relevant_results / retrieval_failed / indirect_only` 必须保留为可区分文案,禁止重新折叠成统一空兜底句。 +- 飞书客户可见验收基线必须看“后端最终格式化输出”而不是内部 payload:非交互 QA 看 `FeishuReceiveRoutingMixin._build_feishu_qa_direct_reply_text()` 产出的 `qa_direct` 文本;强交互 requirement / draft / pending-manual 看 `FeishuCardActionMixin._build_action_card_from_capture_result()` 产出的 action card payload。禁止只根据 `rag_result.fit_brief / external_recommendation / recommended_next_step` 是否存在就宣称飞书前台验收通过。 +- unified handoff 运行时必须支持独立 rollback guard,且关闭后不需要回退 ingress/context 真源:`PMFLOW_ENABLE_UNIFIED_EXECUTION_RECIPE=0` 仅把 recipe 退化为 lane-only 合同、关闭 `fit_brief_required / offer_after_answer`;`PMFLOW_ENABLE_FIT_BRIEF_EXTERNAL_RECOMMENDATION=0` 只跳过 `fit_brief / external_recommendation` 构建,不影响 external 主检索;`PMFLOW_ENABLE_DEFAULT_PLAN_HANDOFF_RENDERING=0` 只关闭默认 `offer_after_answer` 的 `recommended_next_step` 呈现,显式 `explicit_plan_now` 锚点仍必须继续生效。 - 网页 `query.answer` 入口的显式锚点 v1 只允许消费稳定强锚点:项目页路径里的 `project_id`,以及 URL query 中已经显式存在的 `project_id / source_chat_id`;全局浮窗与项目页入口必须原样透传这些字段到 `/rag/ask`,禁止在前端或运行时新增“猜测型 source_chat_id 推断器”。 +- `统一 Project Operating Context + 三路 handoff` 的实现验收必须同时通过 `docs/superpowers/specs/2026-04-17-feishu-problem-inventory.md` 中的黄金样例与对应回归测试,不能只看 architecture contract 是否对齐。 +- This work is not allowed to introduce new keyword routing, regex routing, or hard-coded semantic split helpers. - `Query Route Gateway` 只能汇总上游显式语义、审计锚点与执行结果,不得先并行跑 internal/external 语义探针再做“strong/weak/probe_conflict”裁决。internal / external 的执行分叉必须发生在来源合同已经确定之后;若合同不确定,只能停在 `uncertain/clarify`,禁止先执行双探针再回写路由结论。 - internal `query.answer` 在 `route_mode=internal` 后必须继续经过受控内部查询主链:`rag_query_semantics -> Operation Contract -> Target Resolver -> Query DSL Plan -> InternalContextPacket -> DSL normalizer -> 白名单执行器 -> semantic facts 优先(可用时)-> bounded fallback retrieval -> answer verifier`。`Operation Contract` 只负责回答合同与对象 mention,`Target Resolver` 只负责对象仲裁;planner 只允许输出结构化计划,禁止让 LLM 直接写 SQL、决定自由 join 或直接输出未取证的内部结论。 - `InternalContextPacket` 是 internal-only 运行时单一真源,至少固定携带 `packet_version / question / scope / route_decision / operation_contract / target_resolution / query_plan / executor_summary / verifier_summary / uncertainties`。`query_semantics["operation_contract"] / ["target_resolution"] / ["query_plan"]` 只允许作为上游 scratchpad 或同请求兼容镜像,不得再被 internal executor、internal verifier 或 runtime summary 注入链路当作真源读取。 diff --git a/docs/superpowers/plans/2026-04-17-unified-project-operating-context-and-three-lane-handoff.md b/docs/superpowers/plans/2026-04-17-unified-project-operating-context-and-three-lane-handoff.md new file mode 100644 index 00000000..23f91c7f --- /dev/null +++ b/docs/superpowers/plans/2026-04-17-unified-project-operating-context-and-three-lane-handoff.md @@ -0,0 +1,883 @@ +# Unified Project Operating Context And Three-Lane Handoff Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** 先补一层统一的 `Project Operating Context`,再把 `internal / external / planmode` 收口成一个统一入口下的三条执行能力,让 PMFlow 变成“懂当前项目、能取外部资料、能继续推进计划”的项目型 AI,而不是三套互相打架的聊天模式。 + +**Architecture:** 本次不把三条链继续拆成三个彼此孤立的产品模式,而是在 ingress 先建立统一上下文层,再产出单次 `execution_recipe`。`internal` 负责内部对象事实与项目适配画像,`external` 负责外部可核验资料与推荐,`planmode` 只作为后续 handoff 能力接棒,默认策略固定为“先给判断结论,再明确给一个进入 plan 的下一步”。这里的 `planmode` 只是对话层简称;落到 repo 真名时,统一指 `plan_mode` 顶层 gate 以及其后的 `requirement_plan / draft_pending_confirm` 主链,不额外引入第四个 runtime route 名。外部推荐允许消费内部 `fit brief` 生成检索计划,但最终外部答案正文仍必须只由外部证据支撑。 + +**Tech Stack:** FastAPI, Pydantic, SQLAlchemy 2.0, AdaptiveRAG, AI PMO Kernel, External Query Gateway, React 18, TypeScript, React Query, Vitest, pytest + +--- + +## Scope And Guardrails + +- 本计划在真正实现前,必须先补一份 `Feishu 问题清单 + 黄金对话样例`,把用户已知痛点收成可回归样本;没有这一步,不允许直接进入三路整合实现。 +- 本计划的统一上下文默认范围固定为: + - `当前项目` + - `当前仓库摘要` + - `当前线程 / 页面 / reply` 强锚点 +- 这里的“当前仓库”按 repo 现状必须理解成“运行时可取得的 codebase snapshot / repo summary”,不是默认已经具备全 workspace、全语言、全目录的完整理解;当前 codebase overview 仍以 backend snapshot 为主,若后续扩展前端或全仓库画像,应作为显式增量实现。 +- v1 不默认扩到“用户名下所有项目”或“整个租户 portfolio”,避免重新回到全局聊天串线。 +- 默认多意图策略固定为: + - `A`:先给基于项目的判断结论 + - 再明确给一个“要不要基于这个进入 plan”的下一步 +- 强锚点仍优先于旧 session 和记忆续写: + - `reply/thread` + - 页面 `project_id` + - 显式 `work_item_id` + - 已绑定 active requirement / draft context +- 默认 `A` 只适用于未命中显式 `plan_mode` gate 的 recommendation / 判断题;一旦命中 active requirement context、draft confirm、显式 `reply/thread` 或 `work_item_id` 强锚点,必须继续留在 repo 既有的 `requirement_plan / draft_pending_confirm` 主链,不能被“先答再给 plan 入口”抢走。 +- 严禁回到散落运行时语义拍板,这是一条硬约束: + - 不准新增关键词仓 + - 不准新增正则拍板 helper + - 不准新增“看到某些词就切 internal / external / plan”的硬编码语义分流 + - 不准在执行层靠“问题长相”重判 internal / external / plan + - 不准把飞书问题修成一堆 bypass 规则 +- repo 里其它路径仍可能残留历史 keyword / lookup 工具;这轮 unified ingress 和三路 handoff 不得复用它们来做来源裁决、plan 夺权或飞书短句分流。 +- external 推荐允许使用 internal 侧生成的 `fit brief` 作为“检索约束输入”,但 final external answer 仍必须遵守 external-only 展示合同: + - `fit brief` 只属于 pre-retrieval planning input,不放宽 repo 现有的单一 `source_policy` 合同,也不允许同一轮 query 混搜 internal + external 证据 + - presenter 不重新注入 `system_snapshot / user_snapshot / project_context / internal context_blocks` + - 客户可见正文不混写内部事实充当外部证据,且最终展示仍必须遵守 `customer_safe / admin_debug` 双视图边界 +- external recommendation 必须显式区分外部证据状态: + - `evidence_found` + - `verifier_blocked` + - `no_relevant_results` + - `retrieval_failed` +- 下面这些 `impact/action` 约束属于这份计划的 v1 product guardrail,不应被误读成 repo 现状已经完整落地的 runtime contract;实现时若与现有对象模型冲突,应先回到正式业务对象和 guardrail 真源校正。 +- external recommendation 的“影响判断”必须独立于“检索状态”表达: + - `direct_match` + - `indirect_only` + - `no_direct_impact` +- 外部影响默认优先绑定正式项目对象;若当前项目存在正式 `signal source` / capability 锚点,则优先绑定这些对象。 +- 若外部证据只能说明行业背景、技术可行性或泛市场趋势,而不能精确命中当前项目正式对象,只能返回 `indirect_only`,禁止伪装成“已命中项目推荐”。 +- 外部动作只能落在: + - 范围调整 + - 规则/口径核验 + - 接口兼容性验证 + - 监控补点 + - 数据映射补齐 +- 外部动作禁止涉及: + - 招聘 + - 人员分配 + - 负责人指派 + - 组织调整 + - 直接修改项目/信号源状态 +- planmode 不再作为第一反应默认吞掉“适不适合 / 推荐什么 / 要不要接”这类问题;只有显式强动作锚点或统一 ingress recipe 判定为 `explicit_plan_now` 时才直接接管。 + +## Non-Goals For This Plan + +- 不做 portfolio-wide 全局 AI 助手 +- 不做“只要像建议题就自动进 plan”的激进策略 +- 不做在线自动学习路由阈值 +- 不在 v1 里引入新的自由工具调用或自由联网 agent +- 不把 external answer presenter 改成混合 internal + external 的单 prompt +- 不以关键词/正则/硬编码语义规则作为飞书问题的修复手段 + +## Baseline File Map + +### Context Layer + +- Create: `backend/app/schemas/project_operating_context.py` + - 统一定义 `ProjectOperatingContext`、`ProjectFitBrief`、`ExecutionRecipe` 等结构化合同。 +- Create: `backend/app/services/ai_pmo/project_operating_context_builder.py` + - 从 `current_project_id / current repo snapshot / page anchor / thread anchor / world model` 组装统一上下文包。 +- Modify: `backend/app/services/ai_pmo/ai_pmo_kernel_ingress_flow/mixin_entry.py` + - 在 ingress 入口建立并透传统一上下文,而不是先各走各的语义判断。 +- Modify: `backend/app/services/ai_pmo/ai_pmo_kernel_world_model_core/mixin_builder.py` + - 暴露 `resolved_project_id` 之外的项目运行态摘要,供统一上下文层使用。 +- Modify: `backend/app/services/ai_pmo/ai_pmo_kernel/mixins/world_model/ai_pmo_kernel_mixins_world_model_session.py` + - 持久化最小必要的 project operating context snapshot,避免 session 只记 `current_project_id` 太粗。 + +### Unified Query Handoff + +- Modify: `backend/app/services/adaptive_rag_service/mixin_query_route_gateway.py` + - 在保留 `route_mode / effective_source_policy / reply_contract / probe_snapshot` 合同的前提下,叠加更高层的 `execution_recipe` 输出。 +- Modify: `backend/app/services/adaptive_rag_service/mixin_core_intent.py` + - 让 query semantics 可消费统一上下文和 handoff hint,而不是只看问题文本。 +- Modify: `backend/app/services/adaptive_rag_service/mixin_answer_mainline.py` + - 按 `execution_recipe` 接棒 internal / external / plan-offer,而不是三路各自隐式争抢。 +- Modify: `backend/app/services/adaptive_rag_service/mixin_followup_anchor.py` + - follow-up 续接优先基于统一上下文,而不是仅靠 session state。 + +### Internal Fit Brief + +- Create: `backend/app/services/adaptive_rag_service/mixin_project_fit_brief.py` + - 负责从项目对象、功能目录、repo 概览、当前能力缺口生成 customer-safe `fit brief`。 +- Modify: `backend/app/services/adaptive_rag_service/service.py` + - 接入新的 fit-brief mixin。 +- Modify: `backend/app/services/adaptive_rag_service/mixin_manifest_feature_p1.py` + - 暴露 feature catalog 命中摘要给 fit brief 使用。 +- Modify: `backend/app/services/project_knowledge_graph_service/mixin_codebase_overview.py` + - 把 codebase overview 从单纯 backend Python 统计扩成可用于“当前仓库摘要”的最小基线。 +- Modify: `backend/app/services/context_cache_service/mixin_user_snapshot.py` + - 只补 customer-safe 的当前项目参与状态,不扩成 portfolio 大杂烩。 + +### External Recommendation + +- Modify: `backend/app/services/external_query_service/schemas.py` + - 给 external plan / execution 增加 `fit_brief_summary / recommendation_goal / project_scope_hint` 等检索约束字段。 +- Modify: `backend/app/services/external_query_service/planner.py` + - 让 planner 可以吃 `fit brief`,生成更贴项目的 query plan。 +- Modify: `backend/app/services/external_query_service/gateway.py` + - 保留 adapter/verifier 主链,同时把 `fit_brief` 作为 planner meta 一部分审计。 +- Modify: `backend/app/services/external_query_service/presenter.py` + - 明确拒绝消费 internal evidence;只消费 external packet,但支持输出“适配判断”模板位。 +- Modify: `backend/app/services/adaptive_rag_service/mixin_answer_payload.py` + - 把 `fit brief` 和 `external evidence` 拆成不同 payload 区块,而不是混成一段答案。 + +### Plan Handoff And UX + +- Modify: `backend/app/services/adaptive_rag_service/mixin_answer_payload.py` + - 在回答 payload 里增加 `recommended_next_step / can_enter_plan / handoff_mode`。 +- Modify: `backend/app/services/integrations/feishu_signal_pipeline/mixin_process_message_default.py` + - 飞书默认按 `A` 策略:先答,再给一个明确的 plan handoff 入口。 +- Modify: `backend/app/services/integrations/feishu_signal_pipeline/mixin_context_prefill_a.py` + - 把当前项目 / 当前线程 / 当前 repo 级别提示收进统一上下文,不在飞书旁路拼接脏 context。 +- Modify: `frontend/src/components/ai-steward/FloatingQAChat.tsx` + - 展示 unified context、fit brief、外部推荐结果和“进入 plan”下一步。 +- Modify: `frontend/src/components/ai-steward/QAConversationStatusBar.tsx` + - 展示当前 operating context,而不是只展示“内部还是外部”。 +- Modify: `frontend/src/components/ai-steward/QAResponseMetaCard.tsx` + - 新增“项目画像 / 外部证据 / 下一步”三段式元信息卡。 + +### Tests + +- Create: `backend/tests/test_project_operating_context_contract.py` +- Modify: `backend/tests/test_ai_pmo_ingress_mainline.py` +- Modify: `backend/tests/test_rag_route_contract.py` +- Modify: `backend/tests/test_adaptive_rag_mainline_contract.py` +- Modify: `backend/tests/test_adaptive_rag_external_followup_contract.py` +- Modify: `backend/tests/test_external_query_planner.py` +- Modify: `backend/tests/test_internal_query_context_packet.py` +- Modify: `backend/tests/test_ai_pmo_qa_http_mainline_e2e.py` +- Modify: `backend/tests/test_feishu_pipeline_queue_contract.py` +- Create: `frontend/src/components/ai-steward/QAResponseMetaCard.test.tsx` +- Modify: `frontend/src/components/ai-steward/FloatingQAChat.test.tsx` + +### Docs + +- Modify: `CLAUDE.md` +- Modify: `docs/architecture/ARCHITECTURE_INVENTORY.md` +- Modify: `docs/architecture/CODE_REFERENCE_INDEX.md` +- Modify: `docs/architecture/RUNTIME_GUARDRAILS.md` +- Create: `docs/superpowers/specs/2026-04-17-feishu-problem-inventory.md` + +--- + +### Task 0: Build The Feishu Problem Inventory Before Any Runtime Changes + +**Files:** +- Create: `docs/superpowers/specs/2026-04-17-feishu-problem-inventory.md` +- Modify: `backend/tests/test_feishu_pipeline_queue_contract.py` +- Modify: `backend/tests/test_ai_pmo_qa_http_mainline_e2e.py` +- Modify: `backend/tests/test_adaptive_rag_external_followup_contract.py` +- Modify: `docs/architecture/RUNTIME_GUARDRAILS.md` + +- [ ] **Step 1: Collect the current Feishu failure classes from existing code, tests, and guardrails** + +The inventory must explicitly classify at least these buckets: + +```text +1. reply/thread 锚点丢失或绑错 +2. internal / external / planmode 误分流 +3. 外部问答后续一句话错误掉进 internal 或 plan +4. “适不适合 / 推荐什么 / 怎么推进” 这类复合问题被拆错 +5. 同群同人 scope 串线 +6. 旧 active context 劫持新问题 +7. 明明该继续 plan,却掉回 query.answer +8. 明明只是问结论,却被直接拉进 plan +9. 卡片动作和纯文本回复行为不一致 +10. Web 与 Feishu 对同一句话给出不同链路 +``` + +- [ ] **Step 2: Write 10-15 gold conversation cases in the inventory doc** + +Each case must include: + +```markdown +- 场景名 +- 初始上下文 +- 用户原话 +- 当前错误行为 +- 目标行为 +- 应命中的 lane / handoff +- 验证方式 +``` + +At least one case must cover each of: +- 纯内部系统问题 +- 纯外部事实问题 +- “先判断,再给 plan 入口” +- 显式强锚点直接进 plan +- reply/thread 明确续问 +- 同群同人但切新话题 + +- [ ] **Step 3: Convert the highest-value cases into failing regression tests** + +Required minimum: +- 2 cases in `test_feishu_pipeline_queue_contract.py` +- 2 cases in `test_ai_pmo_qa_http_mainline_e2e.py` +- 1 case in `test_adaptive_rag_external_followup_contract.py` + +These tests should fail under the current runtime if the problem still exists, or be marked as expected coverage gaps if the exact behavior is not yet implemented. + +- [ ] **Step 4: Record explicit acceptance criteria in `RUNTIME_GUARDRAILS.md`** + +Add a short section saying the unified context / three-lane handoff work must be accepted against the Feishu problem inventory, not just architecture conformance. + +Also add an explicit line: + +```text +This work is not allowed to introduce new keyword routing, regex routing, or hard-coded semantic split helpers. +``` + +- [ ] **Step 5: Run the inventory-aligned tests and capture the baseline** + +Run: + +```bash +cd /Users/yuuuu/Documents/AIProjectManger && PYTHONPATH=backend backend/venv/bin/python -m pytest \ + backend/tests/test_feishu_pipeline_queue_contract.py \ + backend/tests/test_ai_pmo_qa_http_mainline_e2e.py \ + backend/tests/test_adaptive_rag_external_followup_contract.py -v +``` + +Expected: +- either explicit FAIL on known bad cases +- or explicit PASS with coverage notes in the inventory doc + +This baseline becomes the “before” snapshot for the rest of the plan. + +### Task 1: Lock The Unified Project Operating Context Contract + +**Files:** +- Create: `backend/tests/test_project_operating_context_contract.py` +- Modify: `backend/tests/test_ai_pmo_ingress_mainline.py` +- Modify: `backend/tests/test_rag_route_contract.py` + +- [ ] **Step 1: Write the failing contract tests for the new context packet** + +Add tests that lock a minimal packet such as: + +```python +context = ProjectOperatingContext( + current_project_id=17, + current_repo_scope="pmflow-main", + page_anchor={"project_id": 17, "origin": "pathname"}, + thread_anchor={"reply_to_message_id": "om_xxx"}, + fit_scope="current_project_and_repo", + decision_strategy="answer_then_offer_plan", +) + +assert context.current_project_id == 17 +assert context.current_repo_scope == "pmflow-main" +assert context.decision_strategy == "answer_then_offer_plan" +``` + +- [ ] **Step 2: Add ingress tests that require the packet to be attached before query execution** + +Lock that `AIPMOKernelService.process_ingress()` or its contract builder must produce: + +```python +assert ingress_options["project_operating_context"]["current_project_id"] == 17 +assert ingress_options["project_operating_context"]["fit_scope"] == "current_project_and_repo" +``` + +- [ ] **Step 3: Lock route-contract tests so `/rag/ask` receives the unified packet** + +The HTTP-facing RAG contract should prove: + +```python +assert payload["project_operating_context"]["current_project_id"] == 17 +assert payload["project_operating_context"]["decision_strategy"] == "answer_then_offer_plan" +``` + +- [ ] **Step 4: Run focused tests and confirm they fail before implementation** + +Run: + +```bash +cd /Users/yuuuu/Documents/AIProjectManger && PYTHONPATH=backend backend/venv/bin/python -m pytest \ + backend/tests/test_project_operating_context_contract.py \ + backend/tests/test_ai_pmo_ingress_mainline.py \ + backend/tests/test_rag_route_contract.py -v +``` + +Expected: FAIL because the packet/schema/builder do not exist yet. + +### Task 2: Build And Persist Project Operating Context At Ingress + +**Files:** +- Create: `backend/app/schemas/project_operating_context.py` +- Create: `backend/app/services/ai_pmo/project_operating_context_builder.py` +- Modify: `backend/app/services/ai_pmo/ai_pmo_kernel_ingress_flow/mixin_entry.py` +- Modify: `backend/app/services/ai_pmo/ai_pmo_kernel_world_model_core/mixin_builder.py` +- Modify: `backend/app/services/ai_pmo/ai_pmo_kernel/mixins/world_model/ai_pmo_kernel_mixins_world_model_session.py` +- Test: `backend/tests/test_project_operating_context_contract.py` +- Test: `backend/tests/test_ai_pmo_ingress_mainline.py` + +- [ ] **Step 1: Add the minimal Pydantic schema** + +Create a focused schema module: + +```python +class ProjectOperatingContext(BaseModel): + context_version: str = "v1" + current_project_id: int | None = None + current_repo_scope: str | None = None + page_anchor: dict[str, Any] = Field(default_factory=dict) + thread_anchor: dict[str, Any] = Field(default_factory=dict) + fit_scope: Literal["current_project_and_repo"] = "current_project_and_repo" + decision_strategy: Literal["answer_then_offer_plan"] = "answer_then_offer_plan" +``` + +- [ ] **Step 2: Implement a small builder that composes current project, repo, and anchors** + +The builder should only consume stable inputs already present in runtime: +- `resolved_project_id` +- current repo / codebase scope +- explicit page anchor +- explicit thread anchor + +Do not infer portfolio scope or global user history here. + +- [ ] **Step 3: Attach the packet in kernel ingress before any query route or plan handoff logic** + +Make `mixin_entry.py` the single place that builds and stores the packet in `ingress_options`. + +- [ ] **Step 4: Persist only the minimal session snapshot** + +Store only: +- `current_project_id` +- `current_repo_scope` +- `last_page_anchor` +- `last_thread_anchor` + +Do not persist a giant free-form context blob. + +- [ ] **Step 5: Re-run focused tests and confirm they pass** + +Run: + +```bash +cd /Users/yuuuu/Documents/AIProjectManger && PYTHONPATH=backend backend/venv/bin/python -m pytest \ + backend/tests/test_project_operating_context_contract.py \ + backend/tests/test_ai_pmo_ingress_mainline.py -v +``` + +Expected: PASS + +- [ ] **Step 6: Commit** + +```bash +git add backend/app/schemas/project_operating_context.py \ + backend/app/services/ai_pmo/project_operating_context_builder.py \ + backend/app/services/ai_pmo/ai_pmo_kernel_ingress_flow/mixin_entry.py \ + backend/app/services/ai_pmo/ai_pmo_kernel_world_model_core/mixin_builder.py \ + backend/app/services/ai_pmo/ai_pmo_kernel/mixins/world_model/ai_pmo_kernel_mixins_world_model_session.py \ + backend/tests/test_project_operating_context_contract.py \ + backend/tests/test_ai_pmo_ingress_mainline.py +git commit -m "feat: add unified project operating context" +``` + +### Task 3: Replace Hard Split Mode Guessing With A Unified Execution Recipe + +**Files:** +- Modify: `backend/app/services/adaptive_rag_service/mixin_query_route_gateway.py` +- Modify: `backend/app/services/adaptive_rag_service/mixin_core_intent.py` +- Modify: `backend/app/services/adaptive_rag_service/mixin_answer_mainline.py` +- Modify: `backend/app/services/adaptive_rag_service/mixin_followup_anchor.py` +- Modify: `backend/tests/test_adaptive_rag_mainline_contract.py` +- Modify: `backend/tests/test_rag_route_contract.py` + +- [ ] **Step 1: Write the failing tests for `execution_recipe`** + +Lock a composed decision like: + +```python +assert recipe == { + "primary_lane": "external", + "fit_brief_required": True, + "external_evidence_required": True, + "plan_handoff_mode": "offer_after_answer", +} +``` + +Use a scenario like: +- question asks “这个方案适不适合我们项目” +- current project exists +- no explicit draft / requirement strong anchor + +- [ ] **Step 2: Extend query route gateway output from `route_mode` to `execution_recipe`** + +Keep `route_mode / effective_source_policy / reply_contract` for compatibility, but add a higher-level recipe: + +```python +{ + "primary_lane": "internal" | "external" | "plan", + "fit_brief_required": bool, + "external_evidence_required": bool, + "plan_handoff_mode": "none" | "offer_after_answer" | "explicit_plan_now", +} +``` + +- [ ] **Step 3: Make answer mainline follow the recipe instead of local mode guesses** + +Required behavior: +- explicit plan anchor -> `explicit_plan_now` +- pure internal question -> `internal` +- pure external fact question -> `external` +- “适不适合当前项目 / 推荐什么 / 要不要接” -> `external + fit_brief + offer_after_answer` + +- [ ] **Step 4: Keep follow-up rewrite anchored to the unified packet** + +Follow-up should first look at: +- explicit anchor +- project operating context +- prior handoff state + +Only then fall back to session subject. + +- [ ] **Step 5: Run the query mainline tests** + +Run: + +```bash +cd /Users/yuuuu/Documents/AIProjectManger && PYTHONPATH=backend backend/venv/bin/python -m pytest \ + backend/tests/test_rag_route_contract.py \ + backend/tests/test_adaptive_rag_mainline_contract.py -v +``` + +Expected: PASS + +- [ ] **Step 6: Commit** + +```bash +git add backend/app/services/adaptive_rag_service/mixin_query_route_gateway.py \ + backend/app/services/adaptive_rag_service/mixin_core_intent.py \ + backend/app/services/adaptive_rag_service/mixin_answer_mainline.py \ + backend/app/services/adaptive_rag_service/mixin_followup_anchor.py \ + backend/tests/test_adaptive_rag_mainline_contract.py \ + backend/tests/test_rag_route_contract.py +git commit -m "feat: add unified execution recipe for project ai" +``` + +### Task 4: Add Project Fit Brief Without Breaking External-Only Answer Contracts + +**Files:** +- Create: `backend/app/services/adaptive_rag_service/mixin_project_fit_brief.py` +- Modify: `backend/app/services/adaptive_rag_service/service.py` +- Modify: `backend/app/services/adaptive_rag_service/mixin_manifest_feature_p1.py` +- Modify: `backend/app/services/project_knowledge_graph_service/mixin_codebase_overview.py` +- Modify: `backend/app/services/context_cache_service/mixin_user_snapshot.py` +- Modify: `backend/app/services/external_query_service/schemas.py` +- Modify: `backend/app/services/external_query_service/planner.py` +- Modify: `backend/app/services/external_query_service/gateway.py` +- Modify: `backend/app/services/external_query_service/presenter.py` +- Modify: `backend/app/services/adaptive_rag_service/mixin_answer_payload.py` +- Modify: `backend/tests/test_external_query_planner.py` +- Modify: `backend/tests/test_internal_query_context_packet.py` +- Modify: `backend/tests/test_adaptive_rag_external_followup_contract.py` + +- [ ] **Step 1: Write failing tests for a customer-safe fit brief** + +Lock a brief such as: + +```python +brief = { + "project_summary": "当前项目是 React + FastAPI 的项目管理系统", + "repo_summary": "当前仓库同时包含 frontend/backend", + "current_capabilities": ["feature_catalog", "feishu", "adaptive_rag"], + "constraints": ["必须绑定正式业务对象", "不能做泛化聊天"], + "recommendation_focus": ["适合当前项目", "接入成本", "对现有架构影响"], +} +``` + +Also lock the external recommendation contract: + +```python +assert payload["external_recommendation"]["retrieval_status"] in { + "evidence_found", + "verifier_blocked", + "no_relevant_results", + "retrieval_failed", +} +assert payload["external_recommendation"]["impact_status"] in { + "direct_match", + "indirect_only", + "no_direct_impact", +} +``` + +- [ ] **Step 2: Implement the fit-brief builder from internal project and repo signals** + +Allowed inputs: +- current project summary +- feature catalog +- codebase overview +- customer-safe current user/project participation snapshot + +Not allowed: +- raw internal source labels in final external answer prompt +- giant free-form repo dump +- silent fallback that collapses `verifier_blocked / no_relevant_results / retrieval_failed` into one generic “暂无外部变化” + +- [ ] **Step 3: Extend external planner to consume the brief as search guidance** + +The planner may use: +- `fit_brief_summary` +- `project_scope_hint` +- `recommendation_goal` +- formal project object anchors such as `signal_sources` / current capability names as query constraints + +But `presenter` must still only consume verified external evidence and plan summary. + +- [ ] **Step 3.5: Add object-binding and impact-tier rules before final external recommendation** + +Required behavior: +- `retrieval_status` is system-owned and passed through to payload/presenter; the model must not guess it. +- if `retrieval_status != evidence_found`, force: + - `impact_status=no_direct_impact` + - empty `external_summary` + - empty `external_impact` + - empty `affected_signal_sources` + - empty `external_action` +- if evidence is real but only loosely related to the project, force `impact_status=indirect_only` +- only allow `impact_status=direct_match` when external evidence can be mapped to at least one formal current-project object, preferably a formal `signal source` +- if current project has formal `signal_sources`, the recommendation layer should prefer binding to those sources before falling back to abstract capability labels +- `external_action` must be a validation/mapping/monitoring action, never a staffing or ownership action + +- [ ] **Step 3.6: Lock presenter wording for failure and indirect-only states** + +Web/Feishu presentation must explicitly distinguish: +- `verifier_blocked`: searched candidates existed but did not pass grounding / verifier checks +- `no_relevant_results`: search completed but no sufficiently relevant external evidence was found +- `retrieval_failed`: external retrieval failed and no usable external evidence was produced +- `indirect_only`: evidence exists but only supports broad market/background relevance, not a direct project-object match + +- [ ] **Step 4: Emit fit brief as a separate payload block** + +The answer payload should look more like: + +```json +{ + "fit_brief": {...}, + "external_recommendation": { + "retrieval_status": "evidence_found | verifier_blocked | no_relevant_results | retrieval_failed", + "impact_status": "direct_match | indirect_only | no_direct_impact", + "affected_signal_sources": [...] + }, + "recommended_next_step": {...} +} +``` + +Do not collapse all three into one answer string. +Do not collapse retrieval state, impact state, and next-step CTA into one generic prose paragraph. + +- [ ] **Step 5: Run focused fit-brief and external planner tests** + +Run: + +```bash +cd /Users/yuuuu/Documents/AIProjectManger && PYTHONPATH=backend backend/venv/bin/python -m pytest \ + backend/tests/test_external_query_planner.py \ + backend/tests/test_internal_query_context_packet.py \ + backend/tests/test_adaptive_rag_external_followup_contract.py -v +``` + +Expected: PASS + +- [ ] **Step 6: Commit** + +```bash +git add backend/app/services/adaptive_rag_service/mixin_project_fit_brief.py \ + backend/app/services/adaptive_rag_service/service.py \ + backend/app/services/adaptive_rag_service/mixin_manifest_feature_p1.py \ + backend/app/services/project_knowledge_graph_service/mixin_codebase_overview.py \ + backend/app/services/context_cache_service/mixin_user_snapshot.py \ + backend/app/services/external_query_service/schemas.py \ + backend/app/services/external_query_service/planner.py \ + backend/app/services/external_query_service/gateway.py \ + backend/app/services/external_query_service/presenter.py \ + backend/app/services/adaptive_rag_service/mixin_answer_payload.py \ + backend/tests/test_external_query_planner.py \ + backend/tests/test_internal_query_context_packet.py \ + backend/tests/test_adaptive_rag_external_followup_contract.py +git commit -m "feat: add project fit brief for external recommendation" +``` + +### Task 5: Implement Default A Handoff In Web And Feishu + +**Files:** +- Modify: `backend/app/services/adaptive_rag_service/mixin_answer_payload.py` +- Modify: `backend/app/services/ai_pmo/ai_pmo_kernel_ingress_flow/mixin_entry.py` +- Modify: `backend/app/services/integrations/feishu_signal_pipeline/mixin_process_message_default.py` +- Modify: `backend/app/services/integrations/feishu_signal_pipeline/mixin_context_prefill_a.py` +- Modify: `backend/tests/test_ai_pmo_qa_http_mainline_e2e.py` +- Modify: `backend/tests/test_feishu_pipeline_queue_contract.py` +- Modify: `frontend/src/components/ai-steward/FloatingQAChat.tsx` +- Modify: `frontend/src/components/ai-steward/QAConversationStatusBar.tsx` +- Modify: `frontend/src/components/ai-steward/QAResponseMetaCard.tsx` +- Create: `frontend/src/components/ai-steward/QAResponseMetaCard.test.tsx` +- Modify: `frontend/src/components/ai-steward/FloatingQAChat.test.tsx` + +- [ ] **Step 1: Write failing end-to-end tests for the default A behavior** + +Lock a case where the user asks: + +```text +这个方案适不适合我们当前项目? +``` + +Expected response contract: +- first returns a judgment +- includes fit brief summary +- includes external evidence summary +- includes one clear “是否进入 plan” next step +- does not directly jump into requirement-plan +- if external recommendation is `verifier_blocked / no_relevant_results / retrieval_failed / indirect_only`, the UI copy must preserve that distinction instead of rendering a single generic empty-state sentence + +- [ ] **Step 2: Add payload fields for handoff and next-step CTA** + +Minimum fields: + +```json +{ + "recommended_next_step": { + "mode": "offer_plan", + "label": "基于这个结论进入计划", + "allowed": true + } +} +``` + +- [ ] **Step 3: Render the same structure in web and Feishu** + +Web should show three regions: +- 当前项目画像 +- 外部推荐与证据 +- 下一步 + +Feishu should keep a simpler rendering: +- 先给结论 +- 再给一行“如果你要,我可以基于这个结论继续帮你出计划” + +Feishu 验收时不要只看内部 handoff payload;必须以“后端最终准备发给飞书的格式”为准: +- `qa_direct` 文本:看 `FeishuReceiveRoutingMixin._build_feishu_qa_direct_reply_text()` +- `requirement_plan_followup / draft_pending_confirm / pending_manual`:看 `FeishuCardActionMixin._build_action_card_from_capture_result()` + +- [ ] **Step 3.5: Render explicit external state variants instead of a single empty fallback** + +Web must distinguish at least: +- `evidence_found + direct_match` +- `evidence_found + indirect_only` +- `verifier_blocked` +- `no_relevant_results` +- `retrieval_failed` + +Feishu may be shorter, but must still avoid collapsing the above states into one generic “暂无外部变化/暂无推荐”. + +- [ ] **Step 4: Keep explicit plan anchors stronger than the default A fallback** + +If the message already contains a strong plan anchor: +- skip the offer +- enter plan immediately + +This prevents regression for active requirement / draft threads. + +- [ ] **Step 5: Run web and Feishu integration tests** + +Run: + +```bash +cd /Users/yuuuu/Documents/AIProjectManger && PYTHONPATH=backend backend/venv/bin/python -m pytest \ + backend/tests/test_ai_pmo_qa_http_mainline_e2e.py \ + backend/tests/test_feishu_pipeline_queue_contract.py -v + +cd /Users/yuuuu/Documents/AIProjectManger/frontend && npx vitest run \ + src/components/ai-steward/FloatingQAChat.test.tsx \ + src/components/ai-steward/QAResponseMetaCard.test.tsx +``` + +Expected: PASS + +- [ ] **Step 6: Commit** + +```bash +git add backend/app/services/adaptive_rag_service/mixin_answer_payload.py \ + backend/app/services/ai_pmo/ai_pmo_kernel_ingress_flow/mixin_entry.py \ + backend/app/services/integrations/feishu_signal_pipeline/mixin_process_message_default.py \ + backend/app/services/integrations/feishu_signal_pipeline/mixin_context_prefill_a.py \ + backend/tests/test_ai_pmo_qa_http_mainline_e2e.py \ + backend/tests/test_feishu_pipeline_queue_contract.py \ + frontend/src/components/ai-steward/FloatingQAChat.tsx \ + frontend/src/components/ai-steward/QAConversationStatusBar.tsx \ + frontend/src/components/ai-steward/QAResponseMetaCard.tsx \ + frontend/src/components/ai-steward/QAResponseMetaCard.test.tsx \ + frontend/src/components/ai-steward/FloatingQAChat.test.tsx +git commit -m "feat: add unified plan handoff for project ai answers" +``` + +### Task 6: Update Docs And Run Final Regression + +**Files:** +- Modify: `CLAUDE.md` +- Modify: `docs/architecture/ARCHITECTURE_INVENTORY.md` +- Modify: `docs/architecture/CODE_REFERENCE_INDEX.md` +- Modify: `docs/architecture/RUNTIME_GUARDRAILS.md` + +- [ ] **Step 1: Update runtime guardrails** + +Document these new stable rules: +- unified `Project Operating Context` +- default scope is `current project + current repo` +- default multi-intent strategy is `answer_then_offer_plan` +- external planner may consume `fit brief`, but external presenter remains external-only + +- [ ] **Step 2: Update inventory and code reference docs** + +Add: +- new schema/module paths +- ingress builder path +- fit brief builder path +- handoff payload path +- external recommendation retrieval/impact state contract +- explicit object-binding preference for formal `signal_sources` + +- [ ] **Step 3: Run backend compile, tests, and deployment restart** + +Run: + +```bash +cd /Users/yuuuu/Documents/AIProjectManger && backend/venv/bin/python -m py_compile \ + backend/app/schemas/project_operating_context.py \ + backend/app/services/ai_pmo/project_operating_context_builder.py \ + backend/app/services/adaptive_rag_service/*.py \ + backend/app/services/external_query_service/*.py + +cd /Users/yuuuu/Documents/AIProjectManger && PYTHONPATH=backend backend/venv/bin/python -m pytest \ + backend/tests/test_project_operating_context_contract.py \ + backend/tests/test_ai_pmo_ingress_mainline.py \ + backend/tests/test_rag_route_contract.py \ + backend/tests/test_adaptive_rag_mainline_contract.py \ + backend/tests/test_adaptive_rag_external_followup_contract.py \ + backend/tests/test_external_query_planner.py \ + backend/tests/test_internal_query_context_packet.py \ + backend/tests/test_ai_pmo_qa_http_mainline_e2e.py \ + backend/tests/test_feishu_pipeline_queue_contract.py -v + +cd /Users/yuuuu/Documents/AIProjectManger/frontend && npx tsc --noEmit +cd /Users/yuuuu/Documents/AIProjectManger/frontend && npm run build +cd /Users/yuuuu/Documents/AIProjectManger && docker compose up -d --build backend +``` + +Expected: +- `py_compile` success +- targeted pytest all PASS +- `tsc --noEmit` success +- `npm run build` success +- backend container returns `healthy` + +- [ ] **Step 3.5: Add runtime feature flags / rollback guards** + +At minimum, the implementation should support disabling independently: +- unified execution recipe +- fit-brief-driven external recommendation +- default `answer_then_offer_plan` handoff rendering + +The rollback path must not require reverting unrelated ingress/context changes. + +- [ ] **Step 3.6: Run real-chain verification on at least three external recommendation states** + +Before final sign-off, capture real examples for: +- `verifier_blocked` +- `evidence_found + indirect_only` +- `evidence_found + direct_match` + +For each example, confirm: +- external payload state is correct +- presenter wording matches the state +- no generic empty fallback sentence hides the real reason +- `direct_match` only appears when a formal project object is actually bound + +- [ ] **Step 4: Commit docs and verification updates** + +```bash +git add CLAUDE.md \ + docs/architecture/ARCHITECTURE_INVENTORY.md \ + docs/architecture/CODE_REFERENCE_INDEX.md \ + docs/architecture/RUNTIME_GUARDRAILS.md +git commit -m "docs: document unified project ai context and handoff" +``` + +## Final Verification Checklist + +- `Project Operating Context` is present in ingress before any route or plan handoff +- external recommendation can consume internal fit brief without mixing internal evidence into final external answer +- external recommendation explicitly separates retrieval state from impact state +- external recommendation prefers binding to formal current-project objects, especially `signal_sources` +- `direct_match` is only used when external evidence truly maps to a formal project object +- `indirect_only` is used for broad market/background relevance instead of pretending the recommendation is project-bound +- external actions never include staffing/ownership changes or direct business-status mutation +- multi-intent recommendation questions default to “answer first, then offer plan” +- strong plan anchors still override the offer and enter plan directly +- web and Feishu both display the same handoff logic +- no new runtime keyword/regex route-takeover helpers were introduced +- implementation diff does not reintroduce hard semantic routing under another name + +## Execution Notes + +- Task order matters: + - Task 1 and Task 2 first, otherwise later tasks will keep smuggling context through ad-hoc fields + - Task 3 before Task 4, otherwise fit brief will bolt onto the old route split + - Task 5 only after the recipe and fit-brief payload exist +- Keep commits small and reviewable; do not batch all six tasks into one mega-commit. +- If the implementation reveals that full repo understanding needs a separate ingestion pipeline beyond current `codebase snapshot`, split that work out as a follow-up plan instead of bloating this one. + +## Final Architecture Baseline + +This plan must land on the following stable architecture baseline and should not drift after implementation starts. + +1. PMFlow must end up with `one unified project AI ingress`, not three isolated user-facing modes. +2. The first runtime layer is always `Project Operating Context`. + Default scope is fixed to: + - current project + - current repo summary when runtime snapshot is available + - explicit thread / page / reply anchors +3. After the top-level `plan_mode` gate, non-plan recommendation / query flows should produce a single `execution_recipe`. + That recipe layers on top of the existing `route_mode / effective_source_policy / reply_contract / probe_snapshot` contract and decides: + - internal execution + - external execution + - post-answer handoff to `plan_mode` downstream capability +4. `internal` is responsible for: + - internal object facts + - project state + - project fit brief + - bounded compatibility / capability constraints +5. `external` is responsible for: + - external evidence + - grounded recommendations + - impact assessment + - explicit retrieval / verifier state + - customer-safe presentation on top of external-only evidence +6. Here `planmode` means the repo's `plan_mode` gate plus `requirement_plan / draft_pending_confirm` mainline, not a separate free-floating route name. +7. `planmode` is a downstream capability for recommendation questions, not the default first response. + Default strategy remains: + - answer first + - then offer plan handoff +8. External recommendation may consume internal `fit brief`, but the final external answer must remain external-only and single-source-policy compliant. +9. Strong anchors override the default answer-then-offer-plan strategy: + - explicit reply/thread + - explicit work item + - active requirement/draft context +10. Feishu is a first-class acceptance surface. + The work is not complete until the Feishu problem inventory and golden cases pass. +11. The implementation is forbidden from reintroducing hard semantic routing: + - no keyword warehouse + - no regex routing helpers + - no “looks like this kind of question” split logic in execution code + +Target end-state in one sentence: + +`Understand the current PMFlow project first, then decide how internal evidence, external evidence, and plan handoff should cooperate under one ingress.` diff --git a/docs/superpowers/specs/2026-04-17-feishu-problem-inventory.md b/docs/superpowers/specs/2026-04-17-feishu-problem-inventory.md new file mode 100644 index 00000000..fc8c6e01 --- /dev/null +++ b/docs/superpowers/specs/2026-04-17-feishu-problem-inventory.md @@ -0,0 +1,590 @@ +# Feishu 问题清单与黄金对话样例 + +## 目的 + +这份清单用于给 `统一 Project Operating Context + 三路 handoff` 提供验收基线。 + +本文件不负责定义实现方案,只负责收口三件事: + +1. 当前 Feishu / Web / query.answer / requirement-plan 的已知失败类型 +2. 用户可感知的黄金对话样例 +3. 能落到回归测试里的 before snapshot + +若要直接过目 12 条成功案例的完整输入/输出/步骤,请看: + +- [2026-04-17-unified-project-ai-handoff-success-cases.md](/Users/yuuuu/Documents/AIProjectManger/.worktrees/codex/unified-project-ai-handoff/docs/superpowers/specs/2026-04-17-unified-project-ai-handoff-success-cases.md) + +## Baseline Snapshot + +- 记录时间:2026-04-17 +- 基线分支:`master` +- 基线提交:`3f94cf7` (`Add explicit QA anchors and route governance pages`) +- 执行命令: + +```bash +cd /Users/yuuuu/Documents/AIProjectManger && PYTHONPATH=backend backend/venv/bin/python -m pytest \ + backend/tests/test_feishu_pipeline_queue_contract.py \ + backend/tests/test_ai_pmo_qa_http_mainline_e2e.py \ + backend/tests/test_adaptive_rag_external_followup_contract.py -v +``` + +- 基线结果: + - `73` 个用例里 `68` 通过,`5` 失败 + - 其中 `3` 个失败属于测试契约漂移,不直接代表客户可见 runtime 回归: + - `_should_process_feishu_high_accuracy()` 已改为返回 `(should_sync, reason, semantic_bundle)`,但测试仍按二元组解包 + - 其中 `2` 个失败属于真实 follow-up 退化,直接影响 Feishu / QA 追问体验: + - 多候选 meta follow-up 没进入澄清分支 + - `第二条` 这类中文编号选择无法恢复原问题 + +## 飞书展示验收基线 + +飞书侧的客户可见验收,不以内部 `rag_result / fit_brief / external_recommendation` payload 直接为准,而以“后端最终准备发给飞书的内容”为准。 + +- 非交互 QA: + - 以 `qa_direct` 文本为基线 + - 真正的格式化出口是 `FeishuReceiveRoutingMixin._build_feishu_qa_direct_reply_text()` + - 验收时要看最终文本是否已经包含: + - 结论正文 + - 围绕当前项目实体的分析段 + - 外部判断对应的分析结果 + - 必要时才出现的下一步建议 +- 强交互 requirement / draft / pending-manual: + - 以 action card payload 为基线 + - 真正的格式化出口是 `FeishuCardActionMixin._build_action_card_from_capture_result()` + - 验收时要看最终 card 结构、按钮动作码与 `work_item_id`,而不是只看内部语义字段 +- 两类 Feishu 输出都必须经过 `FeishuReplyOutboxMixin._enqueue_reply_outbox()` 写入 `feishu_reply_outbox` +- 因此飞书验收必须分开看两类最终输出: + - `qa_direct` 文本 + - `draft_pending_confirm / requirement_plan_followup / pending_manual` card + +最小样例口径: + +```text +外部实时查询:<结论正文> + +结合当前项目看,<项目/仓库摘要>。就这次判断来说,。 + + +``` + +```json +{ + "header": {"title": {"content": "AI PMO 需求收敛"}}, + "elements": [ + {"tag": "div", "text": {"content": "A/B/C 选项正文"}}, + {"tag": "action", "actions": [{"value": {"action": "plan_choice", "work_item_id": 77}}]} + ] +} +``` + +## 失败类型清单 + +### 1. `reply/thread` 锚点丢失或绑错 + +- 表现:明明在回复某条机器人消息,runtime 却新开普通 query 话题 +- 风险:requirement-plan / draft confirm / follow-up 全部容易串线 +- 重点证据: + - `backend/tests/test_feishu_pipeline_queue_contract.py` + - `docs/architecture/RUNTIME_GUARDRAILS.md` + +### 2. `internal / external / planmode` 误分流 + +- 表现:同一句话在不同入口被解释成不同主链 +- 风险:回答与动作边界漂移,用户不知道当前系统在“答问题”还是“要执行” + +### 3. 外部问答后的短 follow-up 错掉进 internal 或 plan + +- 表现:用户只回一句“继续核实”“那你刚才那条是哪个模型回的”,系统不留在 query.answer +- 风险:外部 freshness 问答续链失败,或被错误拉进 plan +- 重点证据: + - `backend/tests/test_adaptive_rag_external_followup_contract.py::test_ambiguous_recent_reply_returns_numbered_candidates` + - `backend/tests/test_adaptive_rag_external_followup_contract.py::test_pending_clarification_selection_restores_original_question` + +### 4. “适不适合 / 推荐什么 / 怎么推进” 这类复合问题被拆错 + +- 表现:系统只做内部事实问答,或只做外部搜索,或直接进 plan +- 风险:丢掉“先判断,再给 plan 入口”的默认策略 + +### 5. 同群同人 scope 串线 + +- 表现:同一聊天里换了新话题,旧 requirement / draft / QA subject 继续抢主链 +- 风险:用户需要反复声明“我现在说的是另一个东西” + +### 6. 旧 active context 劫持新问题 + +- 表现:明明已是新问题,runtime 还沿旧 `pending_clarification / draft / active requirement` 继续解释 +- 风险:回复看起来像“接着上轮说”,但对象完全不对 + +### 7. 明明该继续 plan,却掉回 `query.answer` + +- 表现:已经在 active requirement / explicit reply 锚点里,用户继续 `A/B/C` 或“那按这个推进”,却被当普通 QA +- 风险:需求收敛链断掉 + +### 8. 明明只是问结论,却被直接拉进 plan + +- 表现:用户只是在问适配性或建议,系统马上切成 requirement-plan +- 风险:用户没有先拿到结论判断,产品体验像“被抢操作权” + +### 9. 卡片动作和纯文本回复行为不一致 + +- 表现:点按钮能续上,文本协议续不上;或者反过来 +- 风险:同一 requirement thread 出现双轨行为 + +### 10. Web 与 Feishu 对同一句话给出不同链路 + +- 表现:Web 浮窗保留在问答,Feishu 同句掉进计划链,或反过来 +- 风险:统一项目型 AI 的前台体验不一致 + +## 黄金对话样例 + +### Case 01: 纯内部系统问题 + +- 场景名:内部配置只读问答 +- 初始上下文:项目页内,`project_id=17` +- 用户原话:`当前 PMFlow 里 MCP 模板有几个?` +- 当前错误行为:被当成外部搜索或泛化说明题 +- 目标行为:走 internal,只回答当前系统内对象事实 +- 应命中的 lane / handoff:`internal` +- 验证方式:`/rag/ask` 的 `route_mode=internal`,回答不混外部来源 + +### Case 02: 纯外部事实问题 + +- 场景名:外部官方信息问答 +- 初始上下文:普通 QA 线程,无 active plan +- 用户原话:`OpenAI Docs 最近有没有新的 Responses API 最佳实践?` +- 当前错误行为:被内化成系统配置题或泛化聊天 +- 目标行为:走 external,只基于外部证据回答 +- 应命中的 lane / handoff:`external` +- 验证方式:external-only 来源合同生效,内部来源不进入正文 + +### Case 03: 先判断,再给 plan 入口 + +- 场景名:项目适配性判断 +- 初始上下文:当前项目明确,repo 摘要可用 +- 用户原话:`OpenAI Docs 这种能力适不适合我们当前项目?` +- 当前错误行为:直接进 plan,或者只给功能介绍 +- 目标行为:先给项目判断,再给一个明确 plan 入口 +- 应命中的 lane / handoff:`external + fit brief + offer_after_answer` +- 验证方式:回答里先有结论,再有 `recommended_next_step / can_enter_plan` + +### Case 04: 显式强锚点直接进 plan + +- 场景名:结论后直接推进 +- 初始上下文:上一轮已给适配性判断,当前消息显式 reply 到该轮 +- 用户原话:`那按这个推进` +- 当前错误行为:回到普通 query.answer +- 目标行为:直接 handoff 给 plan 主链 +- 应命中的 lane / handoff:`explicit_plan_now` +- 验证方式:命中 active requirement / draft 主链,不再重问模式 + +### Case 05: reply/thread 明确续问 + +- 场景名:对上一条 QA 解释继续追问 +- 初始上下文:显式 reply 到机器人上一条解释性回复 +- 用户原话:`你刚才这条说的“模型路由”具体指哪层?` +- 当前错误行为:按 session 旧主题或新问题重开 +- 目标行为:优先用 reply/thread 锚点改写问题 +- 应命中的 lane / handoff:`query.answer follow-up` +- 验证方式:锚点优先于 `last_subject` + +### Case 06: 同群同人但切新话题 + +- 场景名:scope 内新问题 +- 初始上下文:同一群、同一用户,上一轮刚结束 requirement-plan +- 用户原话:`另外,现在 PMFlow 里 signal source onboarding 做到哪了?` +- 当前错误行为:继续沿 requirement-plan 解析 +- 目标行为:显式识别为新 query 话题 +- 应命中的 lane / handoff:`internal` +- 验证方式:旧 active context 不得抢走新问题 + +### Case 07: 旧 active context 劫持新问题 + +- 场景名:pending clarification 清理不彻底 +- 初始上下文:上一轮存在 follow-up 澄清候选 +- 用户原话:`不是说那个,我现在问 external gateway。` +- 当前错误行为:继续消费旧澄清状态 +- 目标行为:新话题应显式跳出旧 clarification +- 应命中的 lane / handoff:`query.answer` 新话题 +- 验证方式:pending clarification 被清理或失效 + +### Case 08: 明明该继续 plan,却掉回 query.answer + +- 场景名:A/B/C 协议回复掉链 +- 初始上下文:active requirement-plan,上一轮刚发 A/B/C +- 用户原话:`我选 C` +- 当前错误行为:普通 QA 回复或 mixed reply +- 目标行为:继续 requirement-plan.collect +- 应命中的 lane / handoff:`plan` +- 验证方式:协议解析命中当前 option map + +### Case 09: 明明只是问结论,却被直接拉进 plan + +- 场景名:建议题被抢执行权 +- 初始上下文:无 active plan,无强锚点 +- 用户原话:`这个方案适不适合我们现在这套 PMFlow?` +- 当前错误行为:直接开始 requirement-plan 追问 +- 目标行为:先答结论,再给可选下一步 +- 应命中的 lane / handoff:`offer_after_answer` +- 验证方式:首轮不得直接进入 plan 主链 + +### Case 10: 卡片动作和纯文本回复不一致 + +- 场景名:同一 requirement thread 的双轨行为 +- 初始上下文:同一轮次同时支持卡片按钮与文本协议 +- 用户原话:`选 B` +- 当前错误行为:按钮能续上,文本不行 +- 目标行为:按钮与文本都复用 canonical thread context +- 应命中的 lane / handoff:`plan` +- 验证方式:按钮 / 文本都命中同一 active topic + +### Case 11: Web 与 Feishu 对同一句话不同链路 + +- 场景名:跨前台不一致 +- 初始上下文:项目页 Web 与 Feishu 同时可问答 +- 用户原话:`这个适合吗` +- 当前错误行为:Web 走判断,Feishu 直接进 plan 或掉成 internal +- 目标行为:统一遵守“先判断,再给 plan 入口” +- 应命中的 lane / handoff:`external + offer_after_answer` +- 验证方式:Web / Feishu 结构化结果一致 + +### Case 12: 多候选 meta follow-up 需要澄清 + +- 场景名:上一轮候选不唯一 +- 初始上下文:最近 5 分钟内有 2 条候选回复 +- 用户原话:`那你刚才那条是哪个模型回复的?` +- 当前错误行为:直接 no-anchor,或者答错对象 +- 目标行为:返回编号候选,等待用户选条目 +- 应命中的 lane / handoff:`query.answer follow-up clarify` +- 验证方式:返回 `ambiguous_recent_reply`,并能消费 `第二条` + +## 当前测试映射 + +- Feishu pipeline: + - `backend/tests/test_feishu_pipeline_queue_contract.py` + - 当前已覆盖:reply/thread canonicalization、requirement-plan sync gate、显式协议续问 +- Feishu 最终展示: + - `backend/tests/test_feishu_card_action_contract.py` + - 当前已覆盖:`qa_direct` 富文本摘要、`requirement_plan_followup` action card、外部状态文案分流 +- HTTP mainline: + - `backend/tests/test_ai_pmo_qa_http_mainline_e2e.py` + - 当前已覆盖:`/rag/ask` 与 `/ai-pmo/process-text` 的 `project_operating_context` 前门 contract +- external follow-up: + - `backend/tests/test_adaptive_rag_external_followup_contract.py` + - 当前已覆盖:显式 reply 锚点、recent reply candidate、meta follow-up clarification + +## 严格验收矩阵 + +下面这张矩阵才是本轮统一 handoff 的正式验收标准。 + +- 每个 case 都必须先给出最小输入 +- 每个 case 都必须指明真实入站面和函数入口 +- 每个 case 都必须说明“必须经过哪些主链” +- 每个 case 都必须区分: + - 结构化判定输出 + - 最终用户可见输出 +- 若当前没有完整自动化覆盖,必须显式标成 `partial` 或 `gap` +- 本表最后一列只表示“自动化覆盖状态”,不等于“业务验收完成” +- 若未单独注明,默认这些 case 仍未经过完整的人工业务验收 / 真实用户场景验收 +- 当前真正做过的人工级 smoke 只包括: + - 后端模拟 `qa_direct` 最终出站文本 + - 后端模拟 `requirement_plan_followup` 最终 action card 出站 +- 这些 smoke 只能证明“后端最终输出层可工作”,不能替代完整业务验收 + +| Case | 最小输入 | 真实入站面 | 必经主链 | 必验结构化输出 | 必验最终输出 | 当前自动化证据 | 自动化覆盖状态 | +| --- | --- | --- | --- | --- | --- | --- | --- | +| 01 纯内部系统问题 | `当前 PMFlow 里 MCP 模板有几个?` | Web `POST /rag/ask`
`backend/app/api/rag_api/routes_query.py::rag_ask` | `rag_ask` -> `rag_ask_contract.build_kernel_ingress_kwargs()` -> `AIPMOKernelService.process_ingress()` -> `AdaptiveRAGQueryRouteGatewayMixin._run_query_route_gateway()` -> internal query path -> `_render_answer_with_route_contract()` | `route_mode=internal`
`effective_source_policy=internal`
`reply_contract.head=内部知识库查询:` | HTTP `answer` 必须以 `内部知识库查询:` 开头;不得混入 external-only source | `backend/tests/test_adaptive_rag_mainline_contract.py::test_query_route_gateway_uses_upstream_internal_contract_without_runtime_probes`
`backend/tests/test_adaptive_rag_mainline_contract.py::test_render_answer_with_route_contract_adds_explicit_source_prefix_and_summary` | covered | +| 02 纯外部事实问题 | `OpenAI Docs 最近有没有新的 Responses API 最佳实践?` | Web `POST /rag/ask` | `rag_ask` -> `build_kernel_ingress_kwargs()` -> `process_ingress()` -> `AdaptiveRAGQueryRouteGatewayMixin._run_query_route_gateway()` -> `external_query_service/*` -> `ExternalAnswerPresenter.present()` | `route_mode=external`
`effective_source_policy=external`
external source filter 必须 fail-closed | 最终回答只能来自 external route;internal source 进入 external route 时必须被过滤掉 | `backend/tests/test_adaptive_rag_mainline_contract.py::test_deep_guard_answer_external_route_uses_external_presenter_without_internal_context`
`backend/tests/test_adaptive_rag_mainline_contract.py::test_external_route_source_filter_is_fail_closed` | covered | +| 03 先判断,再给 plan 入口 | `OpenAI Docs 这种能力适不适合我们当前项目?` | Web `POST /rag/ask`;Feishu `message.receive_v1` | Web:`rag_ask` -> `build_kernel_ingress_kwargs()` -> `process_ingress()` -> `ProjectOperatingContextBuilder.build_from_ingress()` -> `_run_query_route_gateway()` -> `_build_response_payload()`
Feishu:`handle_message_event()` -> `_handle_message_receive()` -> `_capture_and_persist_from_ingress()` -> `_finalize_receive_result()` -> `_route_scene_reply()` -> `_render_feishu_qa_direct_reply_text()` -> `_safe_reply_text()` -> `feishu_reply_outbox` | `project_operating_context.current_project_id` 必须存在
`execution_recipe={primary_lane: external, fit_brief_required: true, external_evidence_required: true, plan_handoff_mode: offer_after_answer}`
`handoff_mode=offer_after_answer`
`can_enter_plan=true`
`recommended_next_step` 非空 | Feishu `qa_direct` 文本必须先经过受控 analysis plan,再输出最终文本;最终回复只允许围绕 1-2 个入选项目实体解释判断,不得把项目里所有任务/信号源摊开讲。若没有足够依据,不得硬塞下一步建议,也不得退回 `当前项目:/外部判断:/下一步:` 标签摘要 | `backend/tests/test_ai_pmo_qa_http_mainline_e2e.py::test_http_rag_ask_should_attach_project_operating_context_for_current_project`
`backend/tests/test_adaptive_rag_mainline_contract.py::test_query_route_gateway_builds_external_execution_recipe_for_current_project`
`backend/tests/test_adaptive_rag_mainline_contract.py::test_build_response_payload_exposes_execution_recipe_and_plan_handoff`
`backend/tests/test_feishu_card_action_contract.py::test_route_scene_reply_sends_rich_qa_direct_text_for_handoff_metadata`
`backend/tests/test_ai_pmo_ingress_mainline.py::test_route_scene_reply_enqueues_rich_qa_direct_text_into_feishu_outbox` | covered | +| 04 显式强锚点直接进 plan | `那按这个推进` | Feishu `message.receive_v1` reply/thread;Web active thread follow-up | `handle_message_event()` -> `_handle_message_receive()` -> `capture_from_feishu()` -> canonical thread context -> active requirement / follow-up resolution -> requirement-plan mainline | `execution_recipe.plan_handoff_mode=explicit_plan_now` 或 requirement mainline 直接命中 active context;不得退回普通 `query.answer` | 输出必须继续 plan 主链;不得重新询问 internal / external / plan 是哪种模式 | `backend/tests/test_adaptive_rag_mainline_contract.py::test_query_route_gateway_keeps_explicit_requirement_plan_recipe`
`backend/tests/test_feishu_pipeline_queue_contract.py::test_capture_from_feishu_explicit_reply_choice_protocol_recovers_active_requirement_thread_context` | covered | +| 05 reply/thread 明确续问 | `你刚才这条说的“模型路由”具体指哪层?` | Feishu reply/thread;QA session follow-up | `handle_message_event()` -> `_handle_message_receive()` -> `capture_from_feishu()` -> `AdaptiveRAGFollowupAnchorMixin._resolve_followup_anchor()` -> answer mainline | 必须先命中 reply anchor / resolved anchor reply;不得优先吃 `last_subject` 或旧 session lexical hint | 有效问题必须被重写到上一条回复对象;不能当新题重开 | `backend/tests/test_adaptive_rag_external_followup_contract.py::test_explicit_reply_to_message_id_resolves_anchor_without_session_history`
`backend/tests/test_adaptive_rag_external_followup_contract.py::test_followup_anchor_prefers_structured_semantics_without_keyword_hints` | covered | +| 06 同群同人但切新话题 | `另外,现在 PMFlow 里 signal source onboarding 做到哪了?` | Feishu `message.receive_v1` | `handle_message_event()` -> `_handle_message_receive()` -> `capture_from_feishu()` -> kernel intent/session inheritance guard | 旧 requirement / draft session 不得被继承;`_should_inherit_recent_feishu_session_context()` 必须返回 false;显式 signal-source 请求不得被 draft/plan 劫持 | 必须回到新的 internal query;不能继续 requirement-plan / draft-confirm | `backend/tests/test_ai_pmo_ingress_mainline.py::test_should_inherit_recent_feishu_session_context_skips_explicit_new_requirement_topic`
`backend/tests/test_ai_pmo_ingress_mainline.py::test_should_inherit_recent_feishu_session_context_skips_draft_pending_confirm_session`
`backend/tests/test_ai_pmo_ingress_mainline.py::test_should_inherit_recent_feishu_session_context_is_disabled_for_query_session`
`backend/tests/test_ai_pmo_ingress_mainline.py::test_draft_confirmation_route_does_not_hijack_explicit_signal_source_request` | covered | +| 07 旧 active context 劫持新问题 | `不是说那个,我现在问 external gateway。` | Feishu `message.receive_v1` 或 Web QA session | `process_ingress()` -> pending clarification / active draft guard -> new query path | 旧 `pending_clarification` 必须被识别为不再适用,或在新题下显式失效;不得强制消费旧 option map / draft route | 必须进入新 query topic;不能继续用旧 clarification 或旧 draft 解释 | `backend/tests/test_ai_pmo_ingress_mainline.py::test_process_ingress_short_circuits_query_route_pending_clarification_followup`
`backend/tests/test_ai_pmo_ingress_mainline.py::test_query_pending_clarification_followup_skips_explicit_new_topic_and_clears_stale_state` | covered | +| 08 明明该继续 plan,却掉回 query.answer | `我选 C` | Feishu `message.receive_v1`;kernel active context follow-up | `handle_message_event()` -> `_handle_message_receive()` -> `capture_from_feishu()` -> active requirement workspace lookup -> requirement choice protocol parser -> requirement-plan direct result | 必须命中当前 option map / active requirement context;不得走普通 QA semantic gate | 输出必须继续 `requirement_plan_followup`;不得出现普通 QA 解释文案 | `backend/tests/test_feishu_pipeline_queue_contract.py::test_capture_from_feishu_allows_group_choice_protocol_without_repeat_mention_when_requirement_plan_active`
`backend/tests/test_ai_pmo_ingress_mainline.py::test_active_requirement_choice_protocol_followup_bypasses_semantic_decision_gate`
`backend/tests/test_ai_pmo_ingress_mainline.py::test_execute_query_intent_requirement_plan_followup_persists_progress_and_asks_next_question` | covered | +| 09 明明只是问结论,却被直接拉进 plan | `这个方案适不适合我们现在这套 PMFlow?` | Web `POST /rag/ask`;Feishu `message.receive_v1` | 与 Case 03 相同,但验收重点是不能直接 handoff 到 plan 主链 | `execution_recipe.plan_handoff_mode=offer_after_answer`
首轮不能直接 `explicit_plan_now` | 首轮输出必须先给结论;`recommended_next_step` 只能作为可选下一步,不能直接切 plan scene | `backend/tests/test_adaptive_rag_mainline_contract.py::test_query_route_gateway_builds_external_execution_recipe_for_current_project`
`backend/tests/test_adaptive_rag_mainline_contract.py::test_build_response_payload_exposes_execution_recipe_and_plan_handoff`
`backend/tests/test_feishu_card_action_contract.py::test_route_scene_reply_sends_rich_qa_direct_text_for_handoff_metadata` | covered | +| 10 卡片动作和纯文本回复不一致 | 文本:`选 B`
卡片:点击 `B` 按钮 | Feishu `message.receive_v1`;Feishu `card.action.trigger` | 文本:`handle_message_event()` -> `_handle_message_receive()` -> `capture_from_feishu()` -> active requirement lookup -> requirement-plan mainline
卡片:`handle_message_event()` -> `_handle_card_action_trigger()` -> synthetic ingress -> `process_ingress()` -> `_safe_reply_card()` | 文本与卡片都必须落到同一 `work_item_id` / canonical thread context;不得一条续上、一条掉链 | 文本续问与卡片动作都必须回到同一 requirement thread;输出都应继续 requirement-plan | `backend/tests/test_feishu_card_action_contract.py::test_handle_card_action_trigger_plan_choice_dispatches_synthetic_ingress`
`backend/tests/test_feishu_pipeline_queue_contract.py::test_capture_from_feishu_group_choice_protocol_prefers_active_requirement_over_older_pending_draft`
`backend/tests/test_feishu_card_action_contract.py::test_route_scene_reply_sends_requirement_plan_followup_as_card` | covered | +| 11 Web 与 Feishu 对同一句话不同链路 | `这个适合吗` | Web `POST /rag/ask`;Feishu `message.receive_v1` | Web:`rag_ask` -> `build_kernel_ingress_kwargs()` -> `process_ingress()` -> `AdaptiveRAG`
Feishu:`handle_message_event()` -> `_handle_message_receive()` -> `capture_from_feishu()` -> `_route_scene_reply()` | Web 与 Feishu 必须共享同一 `project_operating_context / execution_recipe / handoff_mode` 语义;不得 Web 是 `offer_after_answer` 而 Feishu 直接 `plan` 或 `internal` | Web 要显示同一 handoff meta;Feishu `qa_direct` 要落到同一“先判断,再给 plan 入口”文案结构 | Web 侧:`backend/tests/test_adaptive_rag_mainline_contract.py::test_build_response_payload_exposes_execution_recipe_and_plan_handoff`
`backend/tests/test_ai_pmo_qa_http_mainline_e2e.py::test_http_rag_ask_returns_offer_after_answer_handoff_metadata_for_current_project_fit_question`
`frontend/src/components/ai-steward/FloatingQAChat.test.tsx`
Feishu 侧:`backend/tests/test_feishu_card_action_contract.py::test_route_scene_reply_sends_rich_qa_direct_text_for_handoff_metadata` | covered | +| 12 多候选 meta follow-up 需要澄清 | 第一轮:`那你刚才那条是哪个模型回复的?`
第二轮:`第二条` | Feishu / QA session follow-up | `_resolve_followup_anchor()` -> ambiguous candidates -> pending clarification -> `_consume_pending_clarification_selection()` | 第一轮必须返回 `ambiguous_candidates`,带编号候选;第二轮必须恢复 `original_question` 并绑定 `selected_qa_id` | 用户可见输出必须先列编号候选,再能消费中文编号 `第二条` | `backend/tests/test_adaptive_rag_external_followup_contract.py::test_ambiguous_recent_reply_returns_numbered_candidates`
`backend/tests/test_adaptive_rag_external_followup_contract.py::test_pending_clarification_selection_restores_original_question` | covered | + +### 严格判定规则 + +- `covered` + - 至少同时有: + - 一个结构化主链测试 + - 一个最终输出测试 + - 或者该 case 本身只适合看结构化输出,且已由主链 contract 直接覆盖 +- `partial` + - 已有相邻 contract,但还没有完全对上黄金输入或黄金输出 +- `gap` + - 当前没有可直接证明该 case 的自动化证据 + +### 业务验收口径 + +- “自动化覆盖” != “业务验收完成” +- 业务验收至少要求: + - 按黄金输入从真实入站面进入 + - 经过本文声明的主链 + - 结构化输出与最终用户可见输出都对上 + - 对用户有意义的业务结论和线程续接行为都正确 +- 按这个口径,当前还不能说“只剩 2 个业务 case 没测” +- 当前能诚实宣称的只有: + - `12/12` 个 case 已有自动化证据 + - 但完整业务验收并未按 12 个黄金场景逐条跑完 + +### 本轮补齐结果 + +- `Case 07` + - 已补 `backend/tests/test_ai_pmo_ingress_mainline.py::test_query_pending_clarification_followup_skips_explicit_new_topic_and_clears_stale_state` + - 当前要求是:显式新话题信号不能再被旧 `pending_clarification` 抢走,且旧澄清状态必须立即失效 +- `Case 11` + - 已补 `backend/tests/test_ai_pmo_qa_http_mainline_e2e.py::test_http_rag_ask_returns_offer_after_answer_handoff_metadata_for_current_project_fit_question` + - 当前要求是:同一句话在 Web 与 Feishu 两端都必须保留同一份 `offer_after_answer` handoff 语义 + +## 当前执行记录(2026-04-17) + +本轮已实际执行一组覆盖 10 个黄金场景的关键切片,不再只停在“文档映射”。 + +执行命令: + +```bash +cd /Users/yuuuu/Documents/AIProjectManger/.worktrees/codex/unified-project-ai-handoff +PYTHONPATH=backend /Users/yuuuu/Documents/AIProjectManger/backend/venv/bin/python -m pytest \ + backend/tests/test_adaptive_rag_mainline_contract.py::test_query_route_gateway_uses_upstream_internal_contract_without_runtime_probes \ + backend/tests/test_adaptive_rag_mainline_contract.py::test_render_answer_with_route_contract_adds_explicit_source_prefix_and_summary \ + backend/tests/test_adaptive_rag_mainline_contract.py::test_deep_guard_answer_external_route_uses_external_presenter_without_internal_context \ + backend/tests/test_adaptive_rag_mainline_contract.py::test_external_route_source_filter_is_fail_closed \ + backend/tests/test_adaptive_rag_mainline_contract.py::test_query_route_gateway_builds_external_execution_recipe_for_current_project \ + backend/tests/test_adaptive_rag_mainline_contract.py::test_query_route_gateway_keeps_explicit_requirement_plan_recipe \ + backend/tests/test_adaptive_rag_mainline_contract.py::test_build_response_payload_exposes_execution_recipe_and_plan_handoff \ + backend/tests/test_ai_pmo_qa_http_mainline_e2e.py::test_http_rag_ask_should_attach_project_operating_context_for_current_project \ + backend/tests/test_feishu_card_action_contract.py::test_route_scene_reply_sends_rich_qa_direct_text_for_handoff_metadata \ + backend/tests/test_ai_pmo_ingress_mainline.py::test_route_scene_reply_enqueues_rich_qa_direct_text_into_feishu_outbox \ + backend/tests/test_adaptive_rag_external_followup_contract.py::test_explicit_reply_to_message_id_resolves_anchor_without_session_history \ + backend/tests/test_ai_pmo_ingress_mainline.py::test_should_inherit_recent_feishu_session_context_is_disabled_for_query_session \ + backend/tests/test_ai_pmo_ingress_mainline.py::test_draft_confirmation_route_does_not_hijack_explicit_signal_source_request \ + backend/tests/test_feishu_pipeline_queue_contract.py::test_capture_from_feishu_allows_group_choice_protocol_without_repeat_mention_when_requirement_plan_active \ + backend/tests/test_ai_pmo_ingress_mainline.py::test_active_requirement_choice_protocol_followup_bypasses_semantic_decision_gate \ + backend/tests/test_feishu_card_action_contract.py::test_handle_card_action_trigger_plan_choice_dispatches_synthetic_ingress \ + backend/tests/test_feishu_pipeline_queue_contract.py::test_capture_from_feishu_group_choice_protocol_prefers_active_requirement_over_older_pending_draft \ + backend/tests/test_feishu_card_action_contract.py::test_route_scene_reply_sends_requirement_plan_followup_as_card \ + backend/tests/test_adaptive_rag_external_followup_contract.py::test_ambiguous_recent_reply_returns_numbered_candidates \ + backend/tests/test_adaptive_rag_external_followup_contract.py::test_pending_clarification_selection_restores_original_question \ + -v +``` + +执行结果: + +- `20 passed` +- 这组切片直接覆盖: + - Case 01 + - Case 02 + - Case 03 + - Case 04 + - Case 05 + - Case 06 + - Case 08 + - Case 09 + - Case 10 + - Case 12 +- 未被这轮实跑切片覆盖的仍是: + - Case 07 + - Case 11 + +补齐剩余 2 个 gap 的执行命令: + +```bash +cd /Users/yuuuu/Documents/AIProjectManger/.worktrees/codex/unified-project-ai-handoff +PYTHONPATH=backend /Users/yuuuu/Documents/AIProjectManger/backend/venv/bin/python -m pytest \ + backend/tests/test_ai_pmo_ingress_mainline.py::test_query_pending_clarification_followup_skips_explicit_new_topic_and_clears_stale_state \ + backend/tests/test_ai_pmo_qa_http_mainline_e2e.py::test_http_rag_ask_returns_offer_after_answer_handoff_metadata_for_current_project_fit_question \ + -v +``` + +补齐结果: + +- `2 passed` +- 这轮补齐后,12 个黄金场景都已有对应自动化证据 + +最终合并验收命令: + +```bash +cd /Users/yuuuu/Documents/AIProjectManger/.worktrees/codex/unified-project-ai-handoff +PYTHONPATH=backend /Users/yuuuu/Documents/AIProjectManger/backend/venv/bin/python -m pytest \ + backend/tests/test_adaptive_rag_mainline_contract.py::test_query_route_gateway_uses_upstream_internal_contract_without_runtime_probes \ + backend/tests/test_adaptive_rag_mainline_contract.py::test_render_answer_with_route_contract_adds_explicit_source_prefix_and_summary \ + backend/tests/test_adaptive_rag_mainline_contract.py::test_deep_guard_answer_external_route_uses_external_presenter_without_internal_context \ + backend/tests/test_adaptive_rag_mainline_contract.py::test_external_route_source_filter_is_fail_closed \ + backend/tests/test_adaptive_rag_mainline_contract.py::test_query_route_gateway_builds_external_execution_recipe_for_current_project \ + backend/tests/test_adaptive_rag_mainline_contract.py::test_query_route_gateway_keeps_explicit_requirement_plan_recipe \ + backend/tests/test_adaptive_rag_mainline_contract.py::test_build_response_payload_exposes_execution_recipe_and_plan_handoff \ + backend/tests/test_ai_pmo_qa_http_mainline_e2e.py::test_http_rag_ask_should_attach_project_operating_context_for_current_project \ + backend/tests/test_ai_pmo_qa_http_mainline_e2e.py::test_http_rag_ask_returns_offer_after_answer_handoff_metadata_for_current_project_fit_question \ + backend/tests/test_feishu_card_action_contract.py::test_route_scene_reply_sends_rich_qa_direct_text_for_handoff_metadata \ + backend/tests/test_ai_pmo_ingress_mainline.py::test_route_scene_reply_enqueues_rich_qa_direct_text_into_feishu_outbox \ + backend/tests/test_adaptive_rag_external_followup_contract.py::test_explicit_reply_to_message_id_resolves_anchor_without_session_history \ + backend/tests/test_ai_pmo_ingress_mainline.py::test_should_inherit_recent_feishu_session_context_is_disabled_for_query_session \ + backend/tests/test_ai_pmo_ingress_mainline.py::test_draft_confirmation_route_does_not_hijack_explicit_signal_source_request \ + backend/tests/test_feishu_pipeline_queue_contract.py::test_capture_from_feishu_allows_group_choice_protocol_without_repeat_mention_when_requirement_plan_active \ + backend/tests/test_ai_pmo_ingress_mainline.py::test_active_requirement_choice_protocol_followup_bypasses_semantic_decision_gate \ + backend/tests/test_feishu_card_action_contract.py::test_handle_card_action_trigger_plan_choice_dispatches_synthetic_ingress \ + backend/tests/test_feishu_pipeline_queue_contract.py::test_capture_from_feishu_group_choice_protocol_prefers_active_requirement_over_older_pending_draft \ + backend/tests/test_feishu_card_action_contract.py::test_route_scene_reply_sends_requirement_plan_followup_as_card \ + backend/tests/test_adaptive_rag_external_followup_contract.py::test_ambiguous_recent_reply_returns_numbered_candidates \ + backend/tests/test_adaptive_rag_external_followup_contract.py::test_pending_clarification_selection_restores_original_question \ + backend/tests/test_ai_pmo_ingress_mainline.py::test_query_pending_clarification_followup_skips_explicit_new_topic_and_clears_stale_state \ + -v +``` + +最终合并结果: + +- `22 passed` +- 这条命令是当前严格自动化验收基线;对应 12 个黄金场景的关键证据都已并入同一轮执行 + +## 代表性实跑输入输出 + +下面这些是当前真正拿得到的“实跑级”输入输出样本,用来证明本文不是只列测试名。 + +### Sample A: Case 03 / Case 09 的 Feishu `qa_direct` + +- 输入: + +```json +{ + "scene": "qa", + "message_content": "这个适合吗", + "result": { + "direct_reply_required": true, + "direct_reply_text": "外部实时查询:目前证据不足,建议先明确要查融资还是招聘动态。", + "rag_result": { + "fit_brief": { + "project_summary": "Alpha 是一个 AI 项目管理工作台。", + "repo_summary": "当前仓库负责统一项目上下文与 AI handoff。" + }, + "external_recommendation": { + "retrieval_status": "evidence_found", + "impact_status": "indirect_only", + "external_impact": "目前更像背景相关,还不能直接映射到当前正式对象。" + }, + "recommended_next_step": "如果你要,我可以基于当前结论进入 plan,继续把需求范围和下一步收清楚。" + } + } +} +``` + +- 最终出站文本: + +```text +外部实时查询:目前证据不足,建议先明确要查融资还是招聘动态。 + +结合当前项目看,Alpha 是一个 AI 项目管理工作台,当前仓库负责统一项目上下文与 AI handoff。就这次判断来说,这条外部信息目前更像背景相关,还不能直接映射到当前正式对象,所以现在更适合作为参考,而不是直接当成已经命中当前项目的正式变化。 + +如果你要,我可以基于当前结论进入 plan,继续把需求范围和下一步收清楚。 +``` + +- 判定重点: + - 先答结论 + - 再追加分析式项目映射和下一步建议 + - 不是直接切 plan + +### Sample B: Case 10 的 `requirement_plan_followup` action card + +- 输入: + +```json +{ + "scene": "qa", + "message_content": "C", + "result": { + "reply_template_key": "requirement_plan_followup", + "work_item_id": 88, + "direct_reply_text": "C. 混合增强路径\n\n⭐ 当前我更推荐:C" + } +} +``` + +- 最终出站 card 关键结构: + +```json +{ + "header": { + "title": { + "content": "AI PMO 需求收敛" + } + }, + "elements": [ + { + "tag": "div", + "text": { + "content": "C. 混合增强路径" + } + }, + { + "tag": "div", + "text": { + "content": "⭐ 当前我更推荐:C" + } + }, + { + "tag": "action", + "actions": [ + { "value": { "action": "plan_choice", "choice": "A", "work_item_id": 88 } }, + { "value": { "action": "plan_choice", "choice": "B", "work_item_id": 88 } }, + { "value": { "action": "plan_choice", "choice": "C", "work_item_id": 88 } }, + { "value": { "action": "plan_choice", "choice": "D", "work_item_id": 88 } } + ] + } + ] +} +``` + +- 判定重点: + - 最终输出不是普通文本 + - 必须是 `requirement_plan_followup` card + - `work_item_id` 和 `plan_choice` 动作码必须保留 + +### Sample C: Case 12 的编号澄清协议 + +- 第一轮输入: + +```text +那你刚才那条是哪个模型回复的? +``` + +- 第一轮结构化输出要求: + +```json +{ + "anchor_mode": "ambiguous_candidates", + "candidate_replies": [ + { "index": 1 }, + { "index": 2 } + ] +} +``` + +- 第二轮输入: + +```text +第二条 +``` + +- 第二轮结构化输出要求: + +```json +{ + "selected_qa_id": 102, + "effective_question": "我问你这个是哪个模型执行回复的?" +} +``` + +- 判定重点: + - 不得直接答错对象 + - 必须先给编号候选 + - 必须支持中文编号选择 + +## Task 0 验收口径 + +- 这轮 unified context / three-lane handoff 不以“架构看起来合理”为验收 +- 必须以本文件里的黄金样例 + 对应回归测试为准 +- 飞书前台展示必须额外以“后端最终格式化后的 `qa_direct` 文本 / action card payload”验收,不能只看内部 handoff payload +- 若某个样例当前无法直接落测试,至少要在测试里以 `xfail` 或 coverage gap 形式显式占位 +- 任何修复不得回到关键词仓、正则路由或硬编码 semantic split helper diff --git a/docs/superpowers/specs/2026-04-17-unified-project-ai-handoff-success-cases.md b/docs/superpowers/specs/2026-04-17-unified-project-ai-handoff-success-cases.md new file mode 100644 index 00000000..8c2d84bc --- /dev/null +++ b/docs/superpowers/specs/2026-04-17-unified-project-ai-handoff-success-cases.md @@ -0,0 +1,816 @@ +# Unified Project AI Handoff 成功案例验收包 + +## 目的 + +这份文档把 `统一 Project Operating Context + 三路 handoff` 的 12 条黄金场景收成可直接过目的“成功案例包”。 + +本文聚焦三件事: + +1. 每条 case 的最小输入 +2. 从哪里入站、经过哪些主链、按什么步骤验 +3. 成功时应该看到的结构化输出与最终用户可见输出 + +## 使用边界 + +- 本文是“成功目标样例 + 验收步骤”文档,不等于 12 条都已经做完人工业务走读。 +- 文中的结构化输出与最终输出,表示该 case 验收成功时应看到的目标结果。 +- 自动化证据以 [2026-04-17-feishu-problem-inventory.md](/Users/yuuuu/Documents/AIProjectManger/.worktrees/codex/unified-project-ai-handoff/docs/superpowers/specs/2026-04-17-feishu-problem-inventory.md) 为真源。 +- 飞书前台验收默认先看“后端最终出站”: + - 非交互 QA 看 `qa_direct` 文本 + - 强交互 requirement / draft 看 action card payload +- 所有 Feishu 场景都必须经过 `feishu_reply_outbox`;如果没有看到 outbox,就不能算飞书链路验收成功。 + +## 通用验收步骤 + +每条 case 都按同一顺序验: + +1. 准备前置上下文 +2. 用真实入站面发送最小输入 +3. 先看结构化输出是否命中预期主链 +4. 再看最终用户可见输出是否符合合同 +5. 记录 pass / fail 与证据 + +每条 case 至少记录这 6 项: + +- 输入 +- 入站面 +- 主链 +- 结构化输出 +- 最终输出 +- 结果判定 + +## Feishu 场景统一 Transport 合同 + +凡是本文中真实入站面包含 Feishu 的 case,除了看业务主链,还必须额外确认 transport 层。 + +### 非交互 QA 文本 + +必经链路: + +```text +message.receive_v1 +-> handle_message_event() +-> _handle_message_receive() +-> capture_from_feishu() +-> _route_scene_reply() +-> _build_feishu_qa_direct_reply_text() +-> _safe_reply_text() +-> FeishuReplyOutboxMixin._enqueue_reply_outbox() +-> feishu_reply_outbox +``` + +必验点: + +- `reply_route=qa_direct` +- `feishu_reply_outbox.message_text` 是纯文本 +- 文本里包含成功案例要求的正文 / 当前项目 / 外部判断 / 下一步 + +### 交互卡片 + +必经链路: + +```text +message.receive_v1 或 card.action.trigger +-> handle_message_event() +-> _handle_message_receive() / _handle_card_action_trigger() +-> _route_scene_reply() +-> _build_action_card_from_capture_result() +-> _safe_reply_card() +-> FeishuReplyOutboxMixin._enqueue_reply_outbox() +-> feishu_reply_outbox +``` + +必验点: + +- `reply_route=qa_action_card` 或对应 card action 路由 +- `feishu_reply_outbox.message_text` 以 `__pmflow_feishu_card__:` 开头 +- 解码后的 card payload 保留正确 `action` 和 `work_item_id` + +--- + +## Case 01 + +### 场景 + +纯内部系统问题 + +### 前置条件 + +- 当前在项目页或带 `project_id=17` 的 Web 问答入口 +- 无 active requirement-plan / draft confirm 干扰 + +### 输入 + +- 用户原话:`当前 PMFlow 里 MCP 模板有几个?` +- 真实入站面:Web `POST /rag/ask` + +示例请求: + +```json +{ + "question": "当前 PMFlow 里 MCP 模板有几个?", + "asked_via": "pmflow_manual", + "project_id": 17, + "action_mode": "read" +} +``` + +### 详细步骤 + +1. 从项目页问答入口发起问题。 +2. 查看 `/rag/ask` 返回 payload 中的 `route_decision`。 +3. 确认 `route_mode=internal`,且没有被转成 external。 +4. 查看最终 `answer` 是否以 `内部知识库查询:` 开头。 + +### 成功结构化输出 + +```json +{ + "route_decision": { + "route_mode": "internal", + "effective_source_policy": "internal" + } +} +``` + +### 成功最终输出 + +```text +内部知识库查询:当前 PMFlow 里共有 2 个 MCP 模板。 +``` + +### 成功判定 + +- 命中 internal 主链 +- 不混入外部来源摘要 +- 不是泛化聊天式说明 + +--- + +## Case 02 + +### 场景 + +纯外部事实问题 + +### 前置条件 + +- 普通 QA 线程 +- 无 active requirement-plan + +### 输入 + +- 用户原话:`OpenAI Docs 最近有没有新的 Responses API 最佳实践?` +- 真实入站面:Web `POST /rag/ask` + +示例请求: + +```json +{ + "question": "OpenAI Docs 最近有没有新的 Responses API 最佳实践?", + "asked_via": "pmflow_manual", + "action_mode": "read" +} +``` + +### 详细步骤 + +1. 从 Web QA 入口提交问题。 +2. 检查 `route_decision.route_mode` 是否为 `external`。 +3. 检查 external 证据是否经过 fail-closed 过滤。 +4. 查看最终回答是否保持 `外部实时查询:` 合同。 + +### 成功结构化输出 + +```json +{ + "route_decision": { + "route_mode": "external", + "effective_source_policy": "external" + } +} +``` + +### 成功最终输出 + +```text +外部实时查询:OpenAI Docs 最近补充了新的 Responses API 最佳实践说明。 +``` + +### 成功判定 + +- 只能走 external 主链 +- internal 来源不得混进 external 正文 + +--- + +## Case 03 + +### 场景 + +先判断,再给 plan 入口 + +### 前置条件 + +- 当前项目明确 +- repo 摘要可用 +- 允许 external lane 构建 `fit_brief` + +### 输入 + +- 用户原话:`OpenAI Docs 这种能力适不适合我们当前项目?` +- 真实入站面: + - Web `POST /rag/ask` + - Feishu `message.receive_v1` + +Web 示例请求: + +```json +{ + "question": "OpenAI Docs 这种能力适不适合我们当前项目?", + "asked_via": "pmflow_manual", + "project_id": 12, + "source_chat_id": "chat-12", + "action_mode": "read" +} +``` + +Feishu 最小入站: + +```json +{ + "message_type": "text", + "chat_id": "chat-12", + "sender_open_id": "ou-alpha", + "message_id": "om-case-03", + "content": "OpenAI Docs 这种能力适不适合我们当前项目?" +} +``` + +### 详细步骤 + +1. 从 Web 或 Feishu 发问题。 +2. 检查 `project_operating_context` 是否带上当前项目。 +3. 检查 `execution_recipe.plan_handoff_mode` 是否为 `offer_after_answer`。 +4. 检查 `handoff_mode / can_enter_plan / recommended_next_step`。 +5. Feishu 侧检查 `_route_scene_reply()` 是否走到 `qa_direct`。 +6. Feishu 侧检查 `feishu_reply_outbox.message_text` 是否为最终出站文本。 +7. Feishu 侧再看最终 `qa_direct` 文本是否已经变成围绕当前项目实体的自然分析回复,而不是标签摘要。 + +### 成功结构化输出 + +```json +{ + "project_operating_context": { + "current_project_id": 12, + "fit_scope": "current_project_and_repo", + "decision_strategy": "answer_then_offer_plan" + }, + "execution_recipe": { + "primary_lane": "external", + "fit_brief_required": true, + "external_evidence_required": true, + "plan_handoff_mode": "offer_after_answer" + }, + "handoff_mode": "offer_after_answer", + "can_enter_plan": true, + "recommended_next_step": "如果你要,我可以基于当前结论进入 plan,继续把需求范围和下一步收清楚。" +} +``` + +### 成功最终输出 + +Web: + +```text +外部实时查询:目前更像背景相关,建议先验证它是否能映射到当前项目正式对象。 +``` + +Feishu: + +```text +外部实时查询:目前更像背景相关,建议先验证它是否能映射到当前项目正式对象。 + +结合当前项目看,Alpha 是一个 AI 项目管理工作台,当前仓库负责统一项目上下文与 AI handoff。就这次判断来说,这条外部信息目前更像背景相关,还不能直接映射到当前正式对象,所以现在更适合作为参考,而不是直接当成已经命中当前项目的正式变化。 + +如果你要,我可以基于当前结论进入 plan,继续把需求范围和下一步收清楚。 +``` + +### 成功判定 + +- 先给判断 +- 再给 plan 入口 +- 不得首轮直接切到 plan 主链 + +--- + +## Case 04 + +### 场景 + +显式强锚点直接进 plan + +### 前置条件 + +- 上一轮已经给出结论 +- 当前消息显式 reply 到上一轮结论 + +### 输入 + +- 用户原话:`那按这个推进` +- 真实入站面:Feishu `message.receive_v1` reply/thread + +示例入站: + +```json +{ + "message_id": "om-case-04", + "reply_to_message_id": "om-case-03-bot", + "chat_id": "chat-12", + "content": "那按这个推进" +} +``` + +### 详细步骤 + +1. 用 reply/thread 回复上一条结论消息。 +2. 检查 canonical thread context 是否恢复成功。 +3. 检查 `execution_recipe.plan_handoff_mode` 是否命中 `explicit_plan_now`,或直接回到 active requirement context。 +4. 检查 `_route_scene_reply()` 后是否进入 card 或 follow-up 对应出站路径。 +5. 检查最终 Feishu 回包是否通过 `feishu_reply_outbox` 出站。 +6. 检查输出是否继续 requirement-plan,而不是普通 QA。 + +### 成功结构化输出 + +```json +{ + "execution_recipe": { + "plan_handoff_mode": "explicit_plan_now" + } +} +``` + +### 成功最终输出 + +```text +AI PMO 需求收敛:我先按这条结论继续收敛需求范围。 +``` + +### 成功判定 + +- 直接 handoff 到 plan +- 不是重新解释 internal / external 模式 + +--- + +## Case 05 + +### 场景 + +reply/thread 明确续问 + +### 前置条件 + +- 机器人上一条已给出解释性回复 +- 当前消息显式 reply 到该条回复 + +### 输入 + +- 用户原话:`你刚才这条说的“模型路由”具体指哪层?` +- 真实入站面:Feishu reply/thread 或 QA session follow-up + +### 详细步骤 + +1. 对上一条机器人解释消息做显式回复。 +2. 检查 follow-up anchor 是否优先命中 reply anchor。 +3. 检查问题是否被重写到上一条回复对象。 +4. 确认没有被旧 `last_subject` 抢走。 + +### 成功结构化输出 + +```json +{ + "anchor_mode": "reply_anchor", + "resolved_anchor": { + "source_message_id": "om-prev-answer" + } +} +``` + +### 成功最终输出 + +```text +内部知识库查询:这里说的“模型路由”指的是 PMFlow 本地路由层里的 tier / provider / response contract 决策层。 +``` + +### 成功判定 + +- 续问对象来自 reply anchor +- 不是把它当新问题重开 + +--- + +## Case 06 + +### 场景 + +同群同人但切新话题 + +### 前置条件 + +- 同一群聊、同一用户 +- 上一轮刚结束 requirement-plan + +### 输入 + +- 用户原话:`另外,现在 PMFlow 里 signal source onboarding 做到哪了?` +- 真实入站面:Feishu `message.receive_v1` + +### 详细步骤 + +1. 在同一群里直接发一条新的事实问题。 +2. 检查 session inheritance guard 是否拦住旧 requirement-plan 续写。 +3. 检查显式 signal-source 请求是否避开 draft/plan 劫持。 +4. 检查最终回答是否回到 internal query。 + +### 成功结构化输出 + +```json +{ + "route_decision": { + "route_mode": "internal" + } +} +``` + +### 成功最终输出 + +```text +内部知识库查询:当前 PMFlow 的 signal source onboarding 已支持受控接入与运行时探测。 +``` + +### 成功判定 + +- 不得继续沿 requirement-plan 解释 +- 必须识别为新的 internal query + +--- + +## Case 07 + +### 场景 + +旧 active context 劫持新问题 + +### 前置条件 + +- 上一轮挂着 `pending_clarification(kind=query_route_selection)` +- 当前用户显式改题 + +### 输入 + +- 用户原话:`不是说那个,我现在问 external gateway。` +- 真实入站面:Feishu `message.receive_v1` 或 Web QA session + +### 详细步骤 + +1. 先制造一个 query-route 澄清状态。 +2. 再发送这条显式改题消息。 +3. 检查 ingress 是否调用 `WorkflowSemanticPlanner` 看 `topic_switch=new`。 +4. 检查旧 `pending_clarification` 是否被立即清空。 +5. 确认没有继续调用旧 session 的澄清协议消费链。 + +### 成功结构化输出 + +```json +{ + "pending_clarification": null, + "next_route": "query.answer" +} +``` + +### 成功最终输出 + +```text +外部实时查询:external gateway 当前负责统一外部查询计划、provider 分发和结果校验。 +``` + +### 成功判定 + +- 旧 `pending_clarification` 必须失效 +- 当前消息必须作为新 query 进入主链 + +--- + +## Case 08 + +### 场景 + +明明该继续 plan,却掉回 query.answer + +### 前置条件 + +- active requirement-plan 正在进行 +- 上一轮刚发 A/B/C 选项 + +### 输入 + +- 用户原话:`我选 C` +- 真实入站面:Feishu `message.receive_v1` + +### 详细步骤 + +1. 先进入 requirement-plan 并发出 A/B/C。 +2. 再发送 `我选 C`。 +3. 检查 active requirement context 是否命中当前 option map。 +4. 确认协议解析直接 bypass 普通 QA semantic gate。 +5. 查看最终输出是否继续 requirement-plan follow-up。 + +### 成功结构化输出 + +```json +{ + "reply_template_key": "requirement_plan_followup", + "parsed_choice": "C" +} +``` + +### 成功最终输出 + +```text +C. 混合增强路径 + +⭐ 当前我更推荐:C +``` + +### 成功判定 + +- 不得掉回普通 QA +- 必须继续当前 requirement-plan 主链 + +--- + +## Case 09 + +### 场景 + +明明只是问结论,却被直接拉进 plan + +### 前置条件 + +- 无 active plan +- 无显式 reply/thread 强锚点 + +### 输入 + +- 用户原话:`这个方案适不适合我们现在这套 PMFlow?` +- 真实入站面:Web `POST /rag/ask` 或 Feishu `message.receive_v1` + +### 详细步骤 + +1. 直接发一条适配性判断问题。 +2. 检查 `execution_recipe.plan_handoff_mode`。 +3. 确认首轮还是 `offer_after_answer`,不是 `explicit_plan_now`。 +4. 确认最终输出先给结论,再给下一步。 + +### 成功结构化输出 + +```json +{ + "execution_recipe": { + "plan_handoff_mode": "offer_after_answer" + }, + "recommended_next_step": "如果你要,我可以基于当前结论进入 plan。" +} +``` + +### 成功最终输出 + +```text +外部实时查询:目前适合作为背景参考,但还需要验证它与当前 PMFlow 正式对象的映射关系。 +``` + +### 成功判定 + +- 首轮不能直接进入 plan +- 必须保留“先答结论,再给下一步” + +--- + +## Case 10 + +### 场景 + +卡片动作和纯文本回复不一致 + +### 前置条件 + +- 同一 requirement thread 同时支持按钮和文本协议 +- 当前 active `work_item_id` 已存在 + +### 输入 + +- 文本输入:`选 B` +- 卡片输入:点击 `B` 按钮 +- 真实入站面: + - 文本:Feishu `message.receive_v1` + - 卡片:Feishu `card.action.trigger` + +### 详细步骤 + +1. 在同一 requirement 轮次里保留当前 action card。 +2. 用文本发 `选 B`。 +3. 再用卡片点击 `B`。 +4. 检查两条路径是否都命中同一 `work_item_id` 和 canonical thread context。 +5. 检查文本和卡片两条路径都写入 `feishu_reply_outbox`。 +6. 检查最终都继续 requirement-plan。 + +### 成功结构化输出 + +```json +{ + "work_item_id": 88, + "action": "plan_choice", + "choice": "B" +} +``` + +### 成功最终输出 + +```json +{ + "header": { + "title": { + "content": "AI PMO 需求收敛" + } + }, + "elements": [ + { + "tag": "action", + "actions": [ + { + "value": { + "action": "plan_choice", + "choice": "B", + "work_item_id": 88 + } + } + ] + } + ] +} +``` + +### 成功判定 + +- 文本和按钮都命中同一 requirement thread +- 不能出现“一条续上、一条掉链” + +--- + +## Case 11 + +### 场景 + +Web 与 Feishu 对同一句话不同链路 + +### 前置条件 + +- 当前项目一致 +- Web 与 Feishu 都从同一项目语境发问 + +### 输入 + +- 用户原话:`这个适合吗` +- 真实入站面: + - Web `POST /rag/ask` + - Feishu `message.receive_v1` + +### 详细步骤 + +1. 用同一句话分别从 Web 和 Feishu 发起问题。 +2. 对比两边的 `project_operating_context`。 +3. 对比两边的 `execution_recipe` 和 `handoff_mode`。 +4. Web 看结构化 meta。 +5. Feishu 检查 `_route_scene_reply()` 是否进入 `qa_direct`,并写入 `feishu_reply_outbox`。 +6. Feishu 再看最终 `qa_direct` 文本。 + +### 成功结构化输出 + +```json +{ + "execution_recipe": { + "plan_handoff_mode": "offer_after_answer" + }, + "handoff_mode": "offer_after_answer", + "can_enter_plan": true +} +``` + +### 成功最终输出 + +Web: + +```text +外部实时查询:目前更像背景相关,还不能直接映射到当前正式对象。 +``` + +Feishu: + +```text +外部实时查询:目前更像背景相关,还不能直接映射到当前正式对象。 + +结合当前项目看,Alpha 是一个 AI 项目管理工作台,当前仓库负责统一项目上下文与 AI handoff。就这次判断来说,这条外部信息目前更像背景相关,还不能直接映射到当前正式对象,所以现在更适合作为参考,而不是直接当成已经命中当前项目的正式变化。 + +如果你要,我可以基于当前结论进入 plan,继续把需求范围和下一步收清楚。 +``` + +### 成功判定 + +- 两边都必须是 `offer_after_answer` +- 不能一边进 plan、一边留在普通 QA + +--- + +## Case 12 + +### 场景 + +多候选 meta follow-up 需要澄清 + +### 前置条件 + +- 最近 5 分钟内有 2 条候选回复 +- 当前问题无法唯一锚定某一条 + +### 输入 + +- 第一轮:`那你刚才那条是哪个模型回复的?` +- 第二轮:`第二条` +- 真实入站面:Feishu / QA session follow-up + +### 详细步骤 + +1. 先制造两个最近候选回复。 +2. 发送第一轮 meta follow-up。 +3. 检查是否返回 `ambiguous_recent_reply` 和编号候选。 +4. 再发送 `第二条`。 +5. 检查是否恢复 `original_question` 并绑定正确 `selected_qa_id`。 + +### 成功结构化输出 + +第一轮: + +```json +{ + "clarification_code": "ambiguous_recent_reply", + "clarification_candidates": [ + {"index": 1}, + {"index": 2} + ] +} +``` + +第二轮: + +```json +{ + "selected_qa_id": 102, + "effective_question": "我问你这个是哪个模型执行回复的?" +} +``` + +### 成功最终输出 + +第一轮: + +```text +我找到两条可能对应的回复: +1. ... +2. ... +请告诉我是第几条。 +``` + +第二轮: + +```text +内部知识库查询:你选的是第二条,那条回复由当前 query.answer 主链执行。 +``` + +### 成功判定 + +- 第一轮必须先澄清 +- 第二轮必须支持中文编号恢复原问题 + +--- + +## 建议过目顺序 + +如果你是要先审文档,再决定人工走读顺序,建议按这 4 条先看: + +1. Case 03:先判断,再给 plan 入口 +2. Case 07:旧澄清失效 +3. Case 08:A/B/C 继续 requirement-plan +4. Case 11:Web / Feishu 同句一致 + +这 4 条最能看出 unified handoff 现在是不是真的站住了。 diff --git a/docs/superpowers/specs/2026-04-17-unified-project-ai-handoff.md b/docs/superpowers/specs/2026-04-17-unified-project-ai-handoff.md new file mode 100644 index 00000000..01943f9c --- /dev/null +++ b/docs/superpowers/specs/2026-04-17-unified-project-ai-handoff.md @@ -0,0 +1,350 @@ +# Unified Project AI Handoff + +## Purpose + +这份文档是给下一窗口直接续写用的 handoff。 + +目标不是继续把 `internal / external / planmode` 当成 3 个割裂模式分别修,而是做一个统一入口的项目型 AI: + +- 先理解当前项目 +- 必要时获取外部资料 +- 再决定是否把结论 handoff 给 plan + +当前仓库里把三条链分开讨论,只是因为飞书上下文和短句 follow-up 很容易串线;最终产品形态不是“3 个分离模式”,而是“1 个懂项目的统一 AI”。这里的 `planmode` 只是对话简称;落到 repo 真名时,统一指 `plan_mode` 顶层 gate 和其后的 `requirement_plan / draft_pending_confirm` 主链,不新增一个新的 runtime route 名。 + +## User-Aligned Goal + +用户当前明确要的是: + +`一个懂 PMFlow 当前项目、能看当前仓库摘要、能结合外部资料做判断、再自然推进计划的 AI。` + +不是: + +- 泛化聊天产品 +- 单纯外部搜索框 +- 单纯 requirement-plan 助手 +- 靠关键词和正则硬切模式的路由器 + +## Confirmed Defaults + +这些是已经在对话里拍板过的默认策略,下一窗口不要再重新猜。 + +### 0. 禁止回到硬性语义和正则路由 + +这是硬约束,不是建议: + +- 不准新增关键词仓去硬切 `internal / external / planmode` +- 不准新增正则路由 helper 去抢统一入口的决策权 +- 不准把“像不像某类问题”当成执行层主判断依据 +- 不准再用散落运行时代码的硬性语义拍板修飞书问题 + +允许做的是: + +- 基于显式锚点 +- 基于统一 `Project Operating Context` +- 基于单次结构化语义结果 +- 基于统一 `execution_recipe` + +不允许做的是: + +- “看到适合吗就大概率 external” +- “看到怎么推进就直接 plan” +- “看到配置/系统/模型这些词就直接 internal” + +### 1. 默认多意图策略 + +固定为 `A`: + +- 先给基于当前项目的判断结论 +- 再明确给一个“是否基于这个结论进入 plan”的下一步 + +不是: + +- 先追问用户要不要开 plan +- 也不是只要像建议题就直接进 plan +- 也不能覆盖已命中的 `plan_mode` 强锚点或 active requirement / draft 主链 + +### 2. 默认上下文范围 + +固定为: + +- `当前项目` +- `当前仓库摘要` +- `当前线程 / 页面 / reply` 强锚点 + +这里的“当前仓库”按 repo 现状应理解成“运行时拿得到的 codebase snapshot / repo summary”,不是默认已经拥有全 workspace、全语言、全目录的完整理解;当前 codebase overview 仍偏 backend snapshot,这一点要保持诚实。 + +暂不默认扩到: + +- 用户名下所有项目 +- 整个租户 portfolio + +原因:先把当前项目型 AI 稳住,避免重新回到全局聊天串线。 + +### 3. Internal / External / Plan 的关系 + +不是三个平级产品模式,而是: + +- `internal`:内部事实、对象状态、项目画像、适配约束 +- `external`:外部资料、方案、生态、官方信息、可核验证据 +- `planmode`:后续推进能力,不应该抢掉“判断题”的第一响应;repo 真正落地时仍由 `plan_mode` gate 和 `requirement_plan / draft_pending_confirm` 主链承接 + +推荐的执行顺序是: + +`统一上下文 -> 统一入口裁决 -> internal/external 产出 -> plan handoff` + +### 4. External 允许吃 internal fit brief,但 final answer 仍 external-only + +这是一个关键边界: + +- internal 可以先生成 `Project Fit Brief` +- external planner 可以用这个 brief 去约束外部检索 +- 但 external 最终正文和外部证据仍必须保持 external-only contract +- 这个 brief 只能作为 pre-retrieval planning input,不放宽单一 `source_policy` 和“不混搜 internal + external 证据”的 repo 现有边界 + +也就是: + +- 可以“先懂项目,再去搜外部” +- 不可以“把内部事实混成外部证据一起答” + +## Root Problem Statement + +用户当前最不满意的,不是单独某一条链不够强,而是: + +### 1. 飞书里的统一入口很混乱 + +同一句话系统经常分不清: + +- 是在问内部系统问题 +- 是在问外部问题 +- 还是想基于外部问题顺手进入 plan + +### 2. 复合意图问题没有被好好处理 + +像这些问题最典型: + +- `这个适合吗` +- `那要不要接` +- `这个方案适不适合我们当前项目` +- `那怎么推进` + +这些其实天然同时包含: + +- 当前项目画像 +- 外部资料判断 +- 是否进入 plan 的手势 + +如果入口没有统一上下文层,就会继续在三条链之间乱跳。 + +### 3. 历史上加过太多硬性语义、关键词、正则,会让系统越修越乱 + +当前方向已经明确: + +- 不准再在执行层补散落业务语义规则 +- 不准再靠问题“长得像什么”去硬切模式 +- 不准再用正则和硬编码语义兜底飞书入口 +- 必须回到对象/上下文优先 + +## Current Plan Of Record + +当前正式计划文件: + +[2026-04-17-unified-project-operating-context-and-three-lane-handoff.md](/Users/yuuuu/Documents/AIProjectManger/docs/superpowers/plans/2026-04-17-unified-project-operating-context-and-three-lane-handoff.md) + +这份计划不是“只做上下文”的计划,而是: + +- 先补统一上下文层 +- 再统一入口裁决 +- 再整合 `internal / external / planmode` + +只是执行顺序上,先修地基,再接三路。 + +## New Requirement Added After Plan Draft + +用户补了一个非常重要的要求: + +`飞书问题清单还不够,必须先收集并固化,再开始真正实现。` + +所以计划里已经新增了前置 `Task 0`: + +- 先做 `Feishu 问题清单 + 黄金对话样例` +- 再做统一上下文和三路整合 + +这是一个硬前置,不应跳过。 + +## Feishu Problem Inventory Direction + +下一窗口优先应该收的问题类型包括: + +1. `reply/thread` 锚点丢失或绑错 +2. `internal / external / planmode` 误分流 +3. 外部问答后续一句话错误掉进 internal 或 plan +4. “适不适合 / 推荐什么 / 怎么推进” 这类复合问题被拆错 +5. 同群同人 scope 串线 +6. 旧 active context 劫持新问题 +7. 明明该继续 plan,却掉回 `query.answer` +8. 明明只是问结论,却被直接拉进 plan +9. 卡片动作和纯文本行为不一致 +10. Web 和 Feishu 对同一句话给出不同链路 + +这些不只是问题分类,还应该进一步整理成黄金对话样例和回归测试。 + +## User-Visible Gains To Preserve + +下一窗口如果继续设计或实现,不要只谈架构,要一直回到这些“实际收益”。 + +### 场景 1:外部工具是否适合当前 PMFlow 项目 + +用户问: + +`OpenAI Docs 这种能力适不适合我们现在这个项目?` + +目标行为: + +- 先基于当前项目判断适不适合 +- 明确说明原因、代价、适配点 +- 再给一个“是否进入 plan”的下一步 + +不是: + +- 泛泛讲功能 +- 直接进 plan +- 或掉成纯 external 搜索 + +### 场景 2:飞书线程里一句“这个适合吗” + +目标行为: + +- 能认出当前线程正在讨论哪个对象 +- 能站在当前项目上回答“适不适合” +- 先答结论,再给 plan 入口 + +### 场景 3:结合当前 repo 判断外部方案 + +用户问: + +`如果我们想把 GitHub repo 信号也接进来,现在仓库里有没有基础?` + +目标行为: + +- internal 先看当前 repo / current project capability +- external 再补行业做法和方案证据 +- 最后回答成一个贴当前项目的判断 + +### 场景 4:战略判断题 + +用户问: + +`semantic facts 这条线还值不值得继续做?` + +目标行为: + +- internal 知道当前项目已经做到哪 +- external 辅助做对照 +- 回答成一个项目管理判断,而不是搜索结果堆砌 + +### 场景 5:自然接到 plan + +用户在上一轮判断后只回一句: + +`那按这个推进` + +目标行为: + +- 直接 handoff 给 plan +- 不再重新问 internal / external / plan 是哪种模式 + +## What This Work Should Solve + +如果这轮做完,至少应该明显改善这三件事: + +1. 用户不用反复重述“我是说当前 PMFlow 项目” +2. 外部判断题不再和 planmode 争第一响应 +3. 飞书短句 follow-up 不再在三路之间随机乱跳 + +## What This Work Does Not Automatically Solve + +要对下一窗口保持诚实边界: + +这轮统一上下文和三路整合,不自动等于修完所有飞书历史 bug。 + +如果底层仍存在这些问题,还需要单独查: + +- canonical thread mapping 本身出错 +- reply outbox / sent_message_id -> source_message_id 归一化异常 +- card action 回流上下文丢失 +- Feishu transport 层事件字段不完整 + +也就是说: + +这轮会解决“入口理解和三路 handoff 混乱”的大头问题, +但不是自动包治所有 Feishu 基础设施问题。 + +## Recommended Starting Point For The Next Window + +不要直接进实现。 + +下一窗口最合适的顺序是: + +1. 打开并继续使用这两份文件: + - [2026-04-17-unified-project-ai-handoff.md](/Users/yuuuu/Documents/AIProjectManger/docs/superpowers/specs/2026-04-17-unified-project-ai-handoff.md) + - [2026-04-17-unified-project-operating-context-and-three-lane-handoff.md](/Users/yuuuu/Documents/AIProjectManger/docs/superpowers/plans/2026-04-17-unified-project-operating-context-and-three-lane-handoff.md) +2. 先执行计划里的 `Task 0` +3. 先把 `Feishu 问题清单 + 黄金对话样例` 写出来 +4. 再决定是否继续进入上下文层实现 + +## One-Line Summary + +下一窗口要接着做的,不是“继续拆 3 个模式”,而是: + +`先把飞书问题清单收完整,再把 internal / external / plan 收到统一项目上下文下面。` + +补充硬约束: + +`整个过程中不准回到硬性语义、关键词仓或正则路由。` + +## Final Architecture Baseline + +这部分是本轮已经拍板的终态基线,下一窗口不应再偏离。 + +1. PMFlow 的目标形态是 `统一项目型 AI`,不是 3 个割裂模式。 +2. 顶层一定先有 `Project Operating Context`,默认范围固定为: + - `当前项目` + - `当前仓库摘要` + - `当前线程 / 页面 / reply` 强锚点 + 这里的“当前仓库”是运行时可取得的 repo summary,不默认等于全仓库完整理解。 +3. 顶层仍先尊重 repo 现有的 `plan_mode` gate;只有非 plan 主链的 recommendation / query 流程,才继续产出统一的 `execution_recipe`,再决定: + - 走 `internal` + - 走 `external` + - 还是在回答后 handoff 给 `planmode` +4. `internal` 的职责是: + - 内部对象事实 + - 当前项目画像 + - `fit brief` + - 适配约束 +5. `external` 的职责是: + - 外部资料 + - 官方/公开证据 + - 可核验推荐 + - 外部影响判断 +6. 这里的 `planmode` 只是对话简称,repo 真正落地仍是 `plan_mode` gate + `requirement_plan / draft_pending_confirm` 主链。 +7. `planmode` 不再抢第一响应。 + 默认策略固定为 `A`: + - 先给基于当前项目的判断结论 + - 再给一个明确的 plan handoff 下一步 +8. external 可以消费 internal 生成的 `fit brief`,但 final external answer 仍必须保持 external-only: + - 不混 internal 证据 + - 不把 `system_snapshot / user_snapshot / project_context / internal context_blocks` 重新注入 external presenter + - 不放宽单一 `source_policy` 和 v1 不混搜 internal + external 的 repo 基线 + - 最终展示仍遵守 `customer_safe / admin_debug` 双视图边界 +9. 强锚点优先级高于默认策略: + - 显式 `reply/thread` + - 显式 `work_item_id` + - 已绑定 active requirement / draft context + 这些命中后可以直接进入 plan 主链。 +10. 飞书问题必须先通过 `问题清单 + 黄金样例 + 回归测试` 验收,不能只看架构是否漂亮。 +11. 整个方案禁止回到硬性语义、关键词仓和正则路由。 + +一句话终态: + +`先理解当前 PMFlow 项目,再决定内部取证、外部取证和计划推进如何接棒;默认先答判断,再给计划入口。` diff --git a/frontend/src/api/aiSteward.ts b/frontend/src/api/aiSteward.ts index 4e02075b..625e4f73 100644 --- a/frontend/src/api/aiSteward.ts +++ b/frontend/src/api/aiSteward.ts @@ -307,6 +307,40 @@ export interface QARouteDecision { [key: string]: unknown; } +export interface QAProjectOperatingContext { + context_version?: string; + current_project_id?: number | null; + current_repo_scope?: string | null; + page_anchor?: Record; + thread_anchor?: Record; + fit_scope?: string | null; + decision_strategy?: string | null; +} + +export interface QAExecutionRecipe { + primary_lane?: string; + fit_brief_required?: boolean; + external_evidence_required?: boolean; + plan_handoff_mode?: string | null; +} + +export interface QAProjectFitBrief { + project_summary?: string | null; + repo_summary?: string | null; + current_capabilities?: string[]; + constraints?: string[]; + recommendation_focus?: string[]; +} + +export interface QAExternalRecommendation { + retrieval_status?: string | null; + impact_status?: string | null; + external_summary?: string | null; + external_impact?: string | null; + affected_signal_sources?: string[]; + external_action?: string | null; +} + export interface QASessionStateReplay { session_id: number; last_subject?: string; @@ -367,6 +401,13 @@ export interface QASessionMessage { citations?: Array>; sources_used?: Array>; evidence_pack?: Record | null; + project_operating_context?: QAProjectOperatingContext | null; + execution_recipe?: QAExecutionRecipe | null; + fit_brief?: QAProjectFitBrief | null; + external_recommendation?: QAExternalRecommendation | null; + handoff_mode?: string | null; + can_enter_plan?: boolean | null; + recommended_next_step?: string | null; } export interface QASessionDetail { @@ -431,6 +472,13 @@ export interface QAAskResponse { citations?: Array>; sources_used?: Array>; evidence_pack?: Record | null; + project_operating_context?: QAProjectOperatingContext | null; + execution_recipe?: QAExecutionRecipe | null; + fit_brief?: QAProjectFitBrief | null; + external_recommendation?: QAExternalRecommendation | null; + handoff_mode?: string | null; + can_enter_plan?: boolean | null; + recommended_next_step?: string | null; } export const aiStewardApi = { diff --git a/frontend/src/components/ai-steward/FloatingQAChat.test.tsx b/frontend/src/components/ai-steward/FloatingQAChat.test.tsx index 11068af6..bf313662 100644 --- a/frontend/src/components/ai-steward/FloatingQAChat.test.tsx +++ b/frontend/src/components/ai-steward/FloatingQAChat.test.tsx @@ -64,6 +64,33 @@ describe("FloatingQAChat", () => { route_decision: { effective_source_policy: "external", }, + project_operating_context: { + context_version: "v1", + current_project_id: 11, + current_repo_scope: "alpha-repo", + }, + execution_recipe: { + primary_lane: "external", + plan_handoff_mode: "offer_after_answer", + }, + fit_brief: { + project_summary: "Alpha 是一个统一项目上下文试点。", + repo_summary: "当前仓库负责 unified handoff。", + current_capabilities: ["需求 intake"], + constraints: ["必须绑定正式业务对象"], + recommendation_focus: ["映射验证"], + }, + external_recommendation: { + retrieval_status: "evidence_found", + impact_status: "indirect_only", + external_summary: "外部同类产品开始强化 handoff。", + external_impact: "当前更像背景相关,尚未直接映射到 Alpha。", + affected_signal_sources: [], + external_action: "先验证映射,再考虑纳入当前计划。", + }, + handoff_mode: "offer_after_answer", + can_enter_plan: true, + recommended_next_step: "如果你要,我可以基于当前结论进入 plan,继续把需求范围和下一步收清楚。", grounding_status: "insufficient_evidence", answer_mode: "clarify", evidence_pack: { @@ -117,6 +144,14 @@ describe("FloatingQAChat", () => { { expect(screen.getByText(/当前对象/i).textContent ?? "").toContain("Alpha项目"); expect(screen.getByText(/当前来源/i).textContent ?? "").toContain("外部公开事实"); + expect(screen.getByText(/当前仓库/i).textContent ?? "").toContain("alpha-repo"); + expect(screen.getByText(/执行通道/i).textContent ?? "").toContain("外部推荐"); + expect(screen.getByText(/Plan 交接/i).textContent ?? "").toContain("答后可进 plan"); expect(screen.getByText(/页面锚点/i).textContent ?? "").toContain("项目 #17"); expect(screen.queryByText(/正在继承上一轮上下文/)).toBeTruthy(); }); @@ -187,4 +225,72 @@ describe("FloatingQAChat", () => { project_id: 17, }); }); + + it("renders handoff metadata from ask response and can prefill next step", async () => { + aiStewardApiMock.askRag.mockResolvedValueOnce({ + answer: "外部实时查询:当前证据更适合作为背景参考。", + route_decision: { + effective_source_policy: "external", + }, + project_operating_context: { + context_version: "v1", + current_project_id: 17, + current_repo_scope: "alpha-repo", + }, + execution_recipe: { + primary_lane: "external", + fit_brief_required: true, + external_evidence_required: true, + plan_handoff_mode: "offer_after_answer", + }, + fit_brief: { + project_summary: "Alpha 聚焦统一项目上下文和 handoff。", + repo_summary: "当前仓库负责前后台联动。", + current_capabilities: ["需求 intake", "统一 handoff"], + constraints: ["必须绑定正式业务对象"], + recommendation_focus: ["映射验证", "计划收口"], + }, + external_recommendation: { + retrieval_status: "evidence_found", + impact_status: "indirect_only", + external_summary: "外部案例显示 handoff 入口正在收敛。", + external_impact: "当前只支持背景相关,还不能直接映射到 Alpha 的正式对象。", + affected_signal_sources: [], + external_action: "先验证映射,再决定是否纳入当前项目计划。", + }, + handoff_mode: "offer_after_answer", + can_enter_plan: true, + recommended_next_step: "如果你要,我可以基于当前结论进入 plan,继续把需求范围和下一步收清楚。", + grounding_status: "partially_grounded", + answer_mode: "normal", + evidence_pack: { + summary: { + conclusion: "可以先做映射验证,再决定是否进入 plan。", + uncertainties: ["尚未建立正式对象映射"], + }, + }, + sources: [], + }); + + renderWithProviders(); + + fireEvent.click(screen.getByRole("button", { name: "打开 AI 聊天" })); + fireEvent.change(await screen.findByPlaceholderText("输入问题..."), { + target: { value: "这个项目最近值得参考哪些外部变化?" }, + }); + fireEvent.click(screen.getByRole("button", { name: "提问" })); + + expect(await screen.findByText("项目画像")).toBeTruthy(); + expect(screen.getByText(/Alpha 聚焦统一项目上下文和 handoff/)).toBeTruthy(); + expect(screen.getByText("外部证据仅形成背景相关")).toBeTruthy(); + expect(screen.getByText(/进入 plan,继续把需求范围和下一步收清楚/)).toBeTruthy(); + + fireEvent.click(screen.getByRole("button", { name: "带入输入框" })); + + await waitFor(() => { + expect( + (screen.getByPlaceholderText("输入问题...") as HTMLTextAreaElement).value, + ).toBe("如果你要,我可以基于当前结论进入 plan,继续把需求范围和下一步收清楚。"); + }); + }); }); diff --git a/frontend/src/components/ai-steward/FloatingQAChat.tsx b/frontend/src/components/ai-steward/FloatingQAChat.tsx index eedf89ef..1621dbff 100644 --- a/frontend/src/components/ai-steward/FloatingQAChat.tsx +++ b/frontend/src/components/ai-steward/FloatingQAChat.tsx @@ -44,6 +44,36 @@ type RouteDecision = { decision_origin?: string; [key: string]: unknown; }; +type ProjectOperatingContext = { + context_version?: string; + current_project_id?: number | null; + current_repo_scope?: string | null; + page_anchor?: Record; + thread_anchor?: Record; + fit_scope?: string | null; + decision_strategy?: string | null; +}; +type ExecutionRecipe = { + primary_lane?: string; + fit_brief_required?: boolean; + external_evidence_required?: boolean; + plan_handoff_mode?: string | null; +}; +type ProjectFitBrief = { + project_summary?: string | null; + repo_summary?: string | null; + current_capabilities?: string[]; + constraints?: string[]; + recommendation_focus?: string[]; +}; +type ExternalRecommendation = { + retrieval_status?: string | null; + impact_status?: string | null; + external_summary?: string | null; + external_impact?: string | null; + affected_signal_sources?: string[]; + external_action?: string | null; +}; type SessionStateReplay = { session_id: number; last_subject?: string; @@ -103,6 +133,13 @@ type SessionMessage = { citations?: Array>; sources_used?: Array>; evidence_pack?: Record | null; + project_operating_context?: ProjectOperatingContext | null; + execution_recipe?: ExecutionRecipe | null; + fit_brief?: ProjectFitBrief | null; + external_recommendation?: ExternalRecommendation | null; + handoff_mode?: string | null; + can_enter_plan?: boolean | null; + recommended_next_step?: string | null; }; type SessionDetail = { @@ -206,6 +243,67 @@ function normalizeEvidencePack(value: unknown): Record | null { return value as Record; } +function normalizeStringList(value: unknown, limit = 6): string[] { + if (!Array.isArray(value)) return []; + return value + .map((item) => String(item || "").trim()) + .filter(Boolean) + .slice(0, limit); +} + +function normalizeProjectOperatingContext(value: unknown): ProjectOperatingContext | null { + if (!value || typeof value !== "object") return null; + const item = value as Record; + return { + context_version: String(item.context_version || "").trim() || undefined, + current_project_id: typeof item.current_project_id === "number" ? item.current_project_id : Number(item.current_project_id || 0) || null, + current_repo_scope: String(item.current_repo_scope || "").trim() || null, + page_anchor: item.page_anchor && typeof item.page_anchor === "object" ? (item.page_anchor as Record) : undefined, + thread_anchor: item.thread_anchor && typeof item.thread_anchor === "object" ? (item.thread_anchor as Record) : undefined, + fit_scope: String(item.fit_scope || "").trim() || null, + decision_strategy: String(item.decision_strategy || "").trim() || null, + }; +} + +function normalizeExecutionRecipe(value: unknown): ExecutionRecipe | null { + if (!value || typeof value !== "object") return null; + const item = value as Record; + return { + primary_lane: String(item.primary_lane || "").trim() || undefined, + fit_brief_required: typeof item.fit_brief_required === "boolean" ? item.fit_brief_required : Boolean(item.fit_brief_required), + external_evidence_required: + typeof item.external_evidence_required === "boolean" + ? item.external_evidence_required + : Boolean(item.external_evidence_required), + plan_handoff_mode: String(item.plan_handoff_mode || "").trim() || null, + }; +} + +function normalizeProjectFitBrief(value: unknown): ProjectFitBrief | null { + if (!value || typeof value !== "object") return null; + const item = value as Record; + return { + project_summary: String(item.project_summary || "").trim() || null, + repo_summary: String(item.repo_summary || "").trim() || null, + current_capabilities: normalizeStringList(item.current_capabilities), + constraints: normalizeStringList(item.constraints), + recommendation_focus: normalizeStringList(item.recommendation_focus), + }; +} + +function normalizeExternalRecommendation(value: unknown): ExternalRecommendation | null { + if (!value || typeof value !== "object") return null; + const item = value as Record; + return { + retrieval_status: String(item.retrieval_status || "").trim().toLowerCase() || null, + impact_status: String(item.impact_status || "").trim().toLowerCase() || null, + external_summary: String(item.external_summary || "").trim() || null, + external_impact: String(item.external_impact || "").trim() || null, + affected_signal_sources: normalizeStringList(item.affected_signal_sources), + external_action: String(item.external_action || "").trim() || null, + }; +} + function normalizeSessionStateReplay(value: unknown): SessionStateReplay | null { if (!value || typeof value !== "object") return null; const item = value as Record; @@ -448,6 +546,13 @@ export function FloatingQAChat() { citations: normalizeRecordList(message.citations), sources_used: normalizeRecordList(message.sources_used), evidence_pack: normalizeEvidencePack(message.evidence_pack), + project_operating_context: normalizeProjectOperatingContext(message.project_operating_context), + execution_recipe: normalizeExecutionRecipe(message.execution_recipe), + fit_brief: normalizeProjectFitBrief(message.fit_brief), + external_recommendation: normalizeExternalRecommendation(message.external_recommendation), + handoff_mode: String(message.handoff_mode || "").trim() || null, + can_enter_plan: typeof message.can_enter_plan === "boolean" ? message.can_enter_plan : null, + recommended_next_step: String(message.recommended_next_step || "").trim() || null, }); const normalizeSessionDetail = (detail: SessionDetail): SessionDetail => ({ @@ -633,6 +738,13 @@ export function FloatingQAChat() { citations: normalizeRecordList(data.citations), sources_used: normalizeRecordList(data.sources_used), evidence_pack: normalizeEvidencePack(data.evidence_pack), + project_operating_context: normalizeProjectOperatingContext(data.project_operating_context), + execution_recipe: normalizeExecutionRecipe(data.execution_recipe), + fit_brief: normalizeProjectFitBrief(data.fit_brief), + external_recommendation: normalizeExternalRecommendation(data.external_recommendation), + handoff_mode: String(data.handoff_mode || "").trim() || null, + can_enter_plan: typeof data.can_enter_plan === "boolean" ? data.can_enter_plan : null, + recommended_next_step: String(data.recommended_next_step || "").trim() || null, }); const returnedSessionId = Number(data.session_id || 0) || null; if (historyApiAvailable) { @@ -735,6 +847,18 @@ export function FloatingQAChat() { }, 0); }; + const handleUseRecommendedNextStep = (recommendedNextStep?: string | null) => { + const nextStep = String(recommendedNextStep || "").trim(); + if (!nextStep) return; + setMode("chat"); + setQuestion(nextStep); + setAskError(""); + window.setTimeout(() => { + questionInputRef.current?.focus(); + questionInputRef.current?.setSelectionRange(nextStep.length, nextStep.length); + }, 0); + }; + const handleRouteClarificationChoice = async (message: SessionMessage, choice: "A" | "B") => { setActionBusyKey(`route-clarify-${message.id}-${choice}`); try { @@ -1025,6 +1149,8 @@ export function FloatingQAChat() { handleUseRecommendedNextStep(msg.recommended_next_step)} /> {msg.clarification_code === "query_route_clarify" ? (
diff --git a/frontend/src/components/ai-steward/QAConversationStatusBar.tsx b/frontend/src/components/ai-steward/QAConversationStatusBar.tsx index 2a287783..854eca84 100644 --- a/frontend/src/components/ai-steward/QAConversationStatusBar.tsx +++ b/frontend/src/components/ai-steward/QAConversationStatusBar.tsx @@ -21,9 +21,26 @@ function resolveClarificationLabel(pendingClarification?: Record | null; + executionRecipe?: Record | null; pendingClarification?: Record | null; isFollowingSessionContext?: boolean; pageAnchorLabel?: string | null; @@ -33,6 +50,8 @@ interface QAConversationStatusBarProps { export function QAConversationStatusBar({ currentSubject, effectiveSourcePolicy, + projectOperatingContext, + executionRecipe, pendingClarification, isFollowingSessionContext = false, pageAnchorLabel, @@ -42,7 +61,19 @@ export function QAConversationStatusBar({ const anchorLabel = String(pageAnchorLabel || "").trim(); const sourceLabel = resolveSourceLabel(effectiveSourcePolicy, pendingClarification); const clarificationLabel = resolveClarificationLabel(pendingClarification); - if (!subject && !anchorLabel && sourceLabel === "未锁定" && !clarificationLabel && !isFollowingSessionContext) { + const repoScopeLabel = String(projectOperatingContext?.current_repo_scope || "").trim(); + const laneLabel = resolveLaneLabel(String(executionRecipe?.primary_lane || "")); + const handoffLabel = resolvePlanHandoffLabel(String(executionRecipe?.plan_handoff_mode || "")); + if ( + !subject && + !anchorLabel && + !repoScopeLabel && + !laneLabel && + !handoffLabel && + sourceLabel === "未锁定" && + !clarificationLabel && + !isFollowingSessionContext + ) { return null; } @@ -56,6 +87,21 @@ export function QAConversationStatusBar({
当前来源 {sourceLabel}
+ {repoScopeLabel ? ( +
+ 当前仓库 {repoScopeLabel} +
+ ) : null} + {laneLabel ? ( +
+ 执行通道 {laneLabel} +
+ ) : null} + {handoffLabel ? ( +
+ Plan 交接 {handoffLabel} +
+ ) : null} {anchorLabel ? (
页面锚点 {anchorLabel} diff --git a/frontend/src/components/ai-steward/QAResponseMetaCard.test.tsx b/frontend/src/components/ai-steward/QAResponseMetaCard.test.tsx new file mode 100644 index 00000000..3ddfe7f6 --- /dev/null +++ b/frontend/src/components/ai-steward/QAResponseMetaCard.test.tsx @@ -0,0 +1,75 @@ +import { fireEvent, screen } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; + +import { QAResponseMetaCard } from "@/components/ai-steward/QAResponseMetaCard"; +import { renderWithProviders } from "@/test/renderWithProviders"; + +describe("QAResponseMetaCard", () => { + it("renders fit brief, external recommendation, and next-step sections", () => { + const onUseRecommendedNextStep = vi.fn(); + + renderWithProviders( + , + ); + + expect(screen.getByText("项目画像")).toBeTruthy(); + expect(screen.getByText(/Alpha 聚焦统一项目上下文/)).toBeTruthy(); + expect(screen.getByText(/需求 intake \/ 统一 handoff/)).toBeTruthy(); + expect(screen.getByText("外部推荐")).toBeTruthy(); + expect(screen.getByText("外部证据仅形成背景相关")).toBeTruthy(); + expect(screen.getByText(/先验证映射,再决定是否纳入当前项目计划/)).toBeTruthy(); + expect(screen.getByText("下一步")).toBeTruthy(); + expect(screen.getByText(/进入 plan,继续把需求范围和下一步收清楚/)).toBeTruthy(); + + fireEvent.click(screen.getByRole("button", { name: "带入输入框" })); + expect(onUseRecommendedNextStep).toHaveBeenCalledTimes(1); + }); + + it("renders blocked external recommendation states without collapsing them", () => { + renderWithProviders( + , + ); + + expect(screen.getByText("外部证据被验证器阻断")).toBeTruthy(); + expect(screen.getByText(/先修正证据质量,再决定是否继续外部推荐/)).toBeTruthy(); + }); +}); diff --git a/frontend/src/components/ai-steward/QAResponseMetaCard.tsx b/frontend/src/components/ai-steward/QAResponseMetaCard.tsx index ac0741d6..82aa3971 100644 --- a/frontend/src/components/ai-steward/QAResponseMetaCard.tsx +++ b/frontend/src/components/ai-steward/QAResponseMetaCard.tsx @@ -1,4 +1,5 @@ import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; function resolveRouteLabel(routeDecision?: Record | null): string { const normalized = String(routeDecision?.effective_source_policy || "").trim().toLowerCase(); @@ -41,11 +42,84 @@ function normalizeUncertainties(evidencePack?: Record | null): return raw.map((item) => String(item || "").trim()).filter(Boolean).slice(0, 2); } +function normalizeStringList(value: unknown, limit = 6): string[] { + if (!Array.isArray(value)) return []; + return value + .map((item) => String(item || "").trim()) + .filter(Boolean) + .slice(0, limit); +} + +function normalizeFitBrief( + fitBrief?: Record | null, +): { + projectSummary: string | null; + repoSummary: string | null; + currentCapabilities: string[]; + constraints: string[]; + recommendationFocus: string[]; +} { + return { + projectSummary: String(fitBrief?.project_summary || "").trim() || null, + repoSummary: String(fitBrief?.repo_summary || "").trim() || null, + currentCapabilities: normalizeStringList(fitBrief?.current_capabilities), + constraints: normalizeStringList(fitBrief?.constraints), + recommendationFocus: normalizeStringList(fitBrief?.recommendation_focus), + }; +} + +function normalizeExternalRecommendation( + externalRecommendation?: Record | null, +): { + retrievalStatus: string | null; + impactStatus: string | null; + externalSummary: string | null; + externalImpact: string | null; + affectedSignalSources: string[]; + externalAction: string | null; +} { + return { + retrievalStatus: String(externalRecommendation?.retrieval_status || "").trim().toLowerCase() || null, + impactStatus: String(externalRecommendation?.impact_status || "").trim().toLowerCase() || null, + externalSummary: String(externalRecommendation?.external_summary || "").trim() || null, + externalImpact: String(externalRecommendation?.external_impact || "").trim() || null, + affectedSignalSources: normalizeStringList(externalRecommendation?.affected_signal_sources), + externalAction: String(externalRecommendation?.external_action || "").trim() || null, + }; +} + +function resolveExternalRecommendationLabel( + externalRecommendation?: Record | null, +): { label: string; variant: "secondary" | "outline" | "destructive" } | null { + const normalized = normalizeExternalRecommendation(externalRecommendation); + if (normalized.retrievalStatus === "evidence_found" && normalized.impactStatus === "direct_match") { + return { label: "外部证据已建立直接映射", variant: "secondary" }; + } + if (normalized.retrievalStatus === "evidence_found" && normalized.impactStatus === "indirect_only") { + return { label: "外部证据仅形成背景相关", variant: "outline" }; + } + if (normalized.retrievalStatus === "verifier_blocked") { + return { label: "外部证据被验证器阻断", variant: "destructive" }; + } + if (normalized.retrievalStatus === "no_relevant_results") { + return { label: "未检索到相关外部证据", variant: "outline" }; + } + if (normalized.retrievalStatus === "retrieval_failed") { + return { label: "外部检索失败", variant: "destructive" }; + } + return null; +} + interface QAResponseMetaCardProps { routeDecision?: Record | null; groundingStatus?: string | null; sourcesUsed?: Array>; evidencePack?: Record | null; + fitBrief?: Record | null; + externalRecommendation?: Record | null; + canEnterPlan?: boolean | null; + recommendedNextStep?: string | null; + onUseRecommendedNextStep?: (() => void) | null; } export function QAResponseMetaCard({ @@ -53,12 +127,34 @@ export function QAResponseMetaCard({ groundingStatus, sourcesUsed, evidencePack, + fitBrief, + externalRecommendation, + canEnterPlan, + recommendedNextStep, + onUseRecommendedNextStep, }: QAResponseMetaCardProps) { const routeLabel = resolveRouteLabel(routeDecision); const groundingLabel = resolveGroundingLabel(groundingStatus); const sourceSummary = normalizeSourceSummary(sourcesUsed); const conclusion = normalizeConclusion(evidencePack); const uncertainties = normalizeUncertainties(evidencePack); + const fitBriefSummary = normalizeFitBrief(fitBrief); + const externalSummary = normalizeExternalRecommendation(externalRecommendation); + const externalStatus = resolveExternalRecommendationLabel(externalRecommendation); + const hasFitBrief = + Boolean(fitBriefSummary.projectSummary) || + Boolean(fitBriefSummary.repoSummary) || + fitBriefSummary.currentCapabilities.length > 0 || + fitBriefSummary.constraints.length > 0 || + fitBriefSummary.recommendationFocus.length > 0; + const hasExternalRecommendation = + Boolean(externalStatus) || + Boolean(externalSummary.externalSummary) || + Boolean(externalSummary.externalImpact) || + externalSummary.affectedSignalSources.length > 0 || + Boolean(externalSummary.externalAction); + const nextStep = String(recommendedNextStep || "").trim(); + const showNextStep = Boolean(nextStep); return (
@@ -71,6 +167,67 @@ export function QAResponseMetaCard({ {uncertainties.length ? (
不确定项:{uncertainties.join(" / ")}
) : null} + {hasFitBrief ? ( +
+
项目画像
+ {fitBriefSummary.projectSummary ? ( +
{fitBriefSummary.projectSummary}
+ ) : null} + {fitBriefSummary.repoSummary ? ( +
代码范围:{fitBriefSummary.repoSummary}
+ ) : null} + {fitBriefSummary.currentCapabilities.length ? ( +
+ 当前能力:{fitBriefSummary.currentCapabilities.join(" / ")} +
+ ) : null} + {fitBriefSummary.recommendationFocus.length ? ( +
+ 推荐关注:{fitBriefSummary.recommendationFocus.join(" / ")} +
+ ) : null} + {fitBriefSummary.constraints.length ? ( +
+ 约束:{fitBriefSummary.constraints.join(" / ")} +
+ ) : null} +
+ ) : null} + {hasExternalRecommendation ? ( +
+
+
外部推荐
+ {externalStatus ? {externalStatus.label} : null} +
+ {externalSummary.externalSummary ? ( +
{externalSummary.externalSummary}
+ ) : null} + {externalSummary.externalImpact ? ( +
影响判断:{externalSummary.externalImpact}
+ ) : null} + {externalSummary.affectedSignalSources.length ? ( +
+ 命中对象:{externalSummary.affectedSignalSources.join(" / ")} +
+ ) : null} + {externalSummary.externalAction ? ( +
建议动作:{externalSummary.externalAction}
+ ) : null} +
+ ) : null} + {showNextStep ? ( +
+
+
下一步
+ {canEnterPlan && onUseRecommendedNextStep ? ( + + ) : null} +
+
{nextStep}
+
+ ) : null}
); }