diff --git a/CLAUDE.md b/CLAUDE.md index e50e23a6..69c57e94 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -33,6 +33,7 @@ AIProjectManger/ - 详细架构库存已迁到 [`docs/architecture/ARCHITECTURE_INVENTORY.md`](/Users/yuuuu/Documents/AIProjectManger/docs/architecture/ARCHITECTURE_INVENTORY.md)。 - `docs/architecture/` 当前包含:`MASTER_CONTRACT.md`、`RUNTIME_GUARDRAILS.md`、`CODE_REFERENCE_INDEX.md`、`API_CONTRACTS.md`、`DATAFLOW.md`、`TASK_BRIEF_TEMPLATE.md`、`ARCHITECTURE_INVENTORY.md` - `docs/superpowers/plans/` 保存实现计划,`docs/superpowers/specs/` 保存需求/方案规格,`docs/task_briefs/` 保存任务包产物与验收截图。 +- 当前与“统一 AI handoff / 全量业务结构化”直接相关的设计规格包括:`docs/superpowers/specs/2026-04-17-unified-project-ai-handoff.md`、`docs/superpowers/specs/2026-04-19-interaction-boundary-four-level-contract-design.md`、`docs/superpowers/specs/2026-04-20-full-business-structuring-and-conversation-baseline-design.md` 与 `docs/superpowers/specs/2026-04-20-business-golden20-refresh.md`;它们分别对应统一入口与项目型 AI 方向、四挡 interaction boundary、全量业务结构化与 58 条广覆盖语义矩阵,以及面向当前真实项目池和 plan mode 主链的 20 条核心黄金案例。 ## 4. 后端结构(`backend/`) ```text @@ -57,15 +58,25 @@ backend/ - `backend/app/api/` 下的 `*_api/` 子包与聚合 router - `backend/app/services/ai_pmo/`(AI PMO 主链、Mounted Runtime、semantic context) - `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/schemas/analysis_packets.py` + `backend/app/services/analysis_packet_builder.py` + `backend/app/services/analysis_project_snapshot_loader.py`(统一 analysis packet family:在 runtime truth 之上增量生成 `project_fit_analysis / external_impact_analysis / internal_explain_analysis / followup_explain_analysis`;当前 richer packet 已显式带 `current_subject / candidate_position / rejected_position / reason_chain` 这类对象级分析字段,供 Web、Feishu、QA session replay 的分析型展示消费;shared loader 统一补 richer `tasks/signals + description/summary + analysis_object_refs`,但 loader 现已 fail-closed,不再从 task/signal 标题描述推 task-signal 关系或 fit 对位;builder 主路径只吃显式 object ref、精确文本绑定和唯一候选这类非语义结构证据,不再使用 token-overlap、partial-match 或本地词表 second-guess 上游关系) + - `backend/app/services/adaptive_rag_service/mixin_target_resolution.py`(internal target resolution 现已按 business-frame-first 收口:`goal_kind / target_scope / resource_role / project_binding / object_binding / expected_answer_shape` 先决定 project/task/signal/config/feature/doc/user 绑定;session follow-up project binding、internal domain clarify 与 semantic filter 都只看结构化 frame,不再靠 `subject_focus / referenced_objects` 这类 coarse 字段拍板对象) + - `backend/app/services/adaptive_rag_service/shared_utils.py` + `mixin_deterministic_query_core.py` + `mixin_fast_answer_project_main.py` + `mixin_fast_answer_semantic_p1.py` + `mixin_deterministic_query_count.py` + `mixin_deterministic_query_list.py` + `mixin_retrieval_router_db.py` + `mixin_deep_guard_answer.py` + `mixin_answer_mainline.py` + `mixin_manifest_feature_p2.py`(AdaptiveRAG read-side 兼容层现已统一改成 business-frame-first:deterministic query、fast answer、structured DB retrieval、deep guard、feature evidence 与 internal query plan 都只消费 `BusinessSemanticFrame / target_resolution / query_plan` 归一化后的结构,不再用 `subject_focus / referenced_objects` 做 project/task/signal/config/feature/doc/user 的二次分流) + - `backend/app/config/ai_prompts.py` + `backend/app/services/adaptive_rag_service/mixin_core_intent.py` + `backend/app/services/adaptive_rag_service/mixin_query_route_gateway.py` + `backend/app/services/adaptive_rag_service/mixin_session_state.py`(query semantics 上游真源已同步切到 `business_frame + query_profile(needs_private_context, needs_freshness, confidence)`:production backend 不再生成、透传或持久化 `subject_focus / query_profile.referenced_objects`,route snapshot、follow-up scope continuity 与 session state 只保留 project/target/business-frame binding 这组结构) - `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/services/adaptive_rag_service/mixin_fast_answer_project_main.py` + `backend/app/services/ai_pmo/ai_pmo_typed_executor_execute/mixin_execute_query_project.py` + `backend/app/services/ai_pmo/ai_pmo_kernel/ai_pmo_typed_executor_mixins_parse.py`(runtime 去 lexical fallback 的关键入口:project progress fast answer bypass 现在只吃结构化 `interaction_level / goal_kind / resource_role / expected_answer_shape`;casual chat 直答只吃 `message_intent=casual_chat`;project lookup 直答只吃结构化 `message_intent=project_lookup(_question) + query_semantics`;signal 数量与 strict analysis 只吃结构化 `requested_count / analysis_mode / operation_intent_contract`,不再从自然语言里靠词表/正则判断) + - `backend/app/services/adaptive_rag_service/mixin_target_resolution.py` + `backend/app/services/adaptive_rag_service/mixin_deep_guard_answer.py`(internal config 主链:`resource_role=config` 不再默认降成泛化 `config:system`;必须靠显式 `object_binding` 区分 `config:model_routing` 与 `config:mcp_template_center`,并分别走模型路由快答与 MCP 配置快答) + - `backend/app/services/adaptive_rag_service/internal_domain_catalog.py` + `mixin_target_resolution.py` + `mixin_followup_anchor.py` + `mixin_answer_precheck.py` + `mixin_answer_payload.py`(internal object/domain clarify 主链:当前 catalog 已覆盖 `project / task / signal / config / feature / doc / user`;当 internal query 只确认到大域、还没唯一命中子对象时,先产出 `internal_target_selection` 澄清协议;follow-up 选项会回填 `query_semantics_overrides` 再继续受控检索,避免“澄清后继续复读旧答案”) + - `backend/app/config/ai_prompts.py`(`PROMPT_RAG_QUERY_SEMANTICS` 现在明确要求 internal domain 问答把 `resource_role / object_binding` 打出来;模型配置示例绑定 `config:model_routing`,MCP 配置示例绑定 `config:mcp_template_center`,功能/文档问法则分别绑定 `resource_role=feature/doc`) + - `backend/app/services/adaptive_rag_service/mixin_followup_anchor.py` + `mixin_answer_mainline.py`(follow-up 澄清恢复主链:`query_route_selection / followup_anchor_selection` 现在先走统一结构化澄清解析,再把 `source_policy_hint / query_semantics_overrides / selected_qa_id` 透回主链;用户澄清“第二条/内部那个”后,不再靠本地词面去猜上一轮回复或 option) + - `backend/app/services/integrations/feishu_signal_pipeline/mixin_intent_gate_core.py` + `mixin_context_prefill_a.py` + `mixin_context_prefill_b.py` + `mixin_serialization.py`(Feishu `project.create` 预填链现在统一吃 `_extract_project_prefill()` 的结构化 `suggested_name / suggested_description / suggested_scope`,不再在 runtime 用 `_extract_project_scope / _extract_project_intent_fields / _is_contextual_project_request / heuristic_*` 之类词面 helper 扣项目字段) - `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/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 回答生成。当前已额外支持“推荐外部信号源”问法:recommendation ask 的进入条件优先来自统一语义规划层透传到 `ExternalQueryPlan.interaction_level=recommendation` 的结构化层级,不再由 presenter 本地 cue list 拍板;命中后不再退回 `external_verifier_blocked` 拒答,而是先产出 `SignalSourceRecommendationPacket(candidates + caveat)`,再渲染成业务中文的 `top-N candidate + why`;recommendation 结果现在还会同步沉成可 carry-over 的 candidate set 绑定(`candidate_set_id + candidates + materialization payload`),供 reply-anchor follow-up 继续走 non-plan `signal_source.onboard` 直写链,不再默认 requirement draft;即使当前外部公开资料仍在校验,也只允许显式标成“推荐,不是已验证事实”,不能滑回架构腔或边界空话) - `backend/app/services/mcp_config/`(MCP 模板接入中心:catalog、bundle 持久化、后端直连 runtime probe / Microsoft Learn 检索桥接、Local Bridge 接入说明与本机确认状态) - `backend/app/services/semantic_fact_service/`(semantic facts 读模型、八类业务对象统一抽取/失活、ORM 提交钩子入队、治理看板查询) - `backend/app/api/semantic_facts_api/`(semantic facts 治理后台接口:overview / objects / detail / reextract / backfill) - `backend/app/api/mcp_api/`(MCP 模板接入中心后台接口:catalog / servers / validate / confirm-client-apply) - `backend/app/services/llm_service/`(显式 `routing_meta` 协议) - - `backend/app/services/integrations/feishu_integration_service/`(飞书入站与卡片回调) + - `backend/app/services/integrations/feishu_integration_service/`(飞书入站与卡片回调;`mixin_receive_routing.py` 的 `qa_direct` 现已支持 recommendation ask 直出推荐正文,并把原始 `Project database / signal_total / 当前盘面 / 建议动作 / External Freshness Guard` 这类内部/英文术语压成单行中文来源说明) - `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 命中与异步抽取审计。 @@ -93,7 +104,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/components/ai-steward/`(问答浮窗、状态条、回答元卡片;`FloatingQAChat.tsx` 现会透传并回放 `project_operating_context / execution_recipe / fit_brief / external_recommendation / analysis_packet / 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 治理页) @@ -216,15 +227,15 @@ 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] 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 / analysis_packet / 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`、`test_analysis_packet_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` 锁定 -- [x] `signal_source.onboard` 已接入消息级幂等账本:TypedExecutor 以 `source_system + source_message_id` 作为 onboarding ledger key,重复提交只回放同一结果,不再重复跑信号源接入编排;回归由 `test_ai_pmo_signal_source_onboard_contract.py` 锁定 +- [x] `signal_source.onboard` 已接入消息级幂等账本与 non-plan 直写 carry-over:TypedExecutor 以 `source_system + source_message_id` 作为 onboarding ledger key,重复提交只回放同一结果,不再重复跑信号源接入编排;recommendation candidate set 会经 `reply_anchor_qa_context + action_contract` 沉到执行结果里,供后续 `把刚才推荐的写进系统 / 确认入库` 继续走 `prepare_write -> confirm_write -> execute_write` 直写链,而不是默认 requirement draft;回归由 `test_ai_pmo_signal_source_onboard_contract.py` 锁定 - [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 / 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] 三模式交互上下文基线已显性化:`/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 / analysis_packet / 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/config/ai_prompts.py b/backend/app/config/ai_prompts.py index 1e8f4806..f05160bb 100644 --- a/backend/app/config/ai_prompts.py +++ b/backend/app/config/ai_prompts.py @@ -56,6 +56,8 @@ from .ai_prompts_catalog import ( PROMPT_AI_PMO_REQUIREMENT_PLAN_QA, PROMPT_EXTERNAL_COMPARE_WITH_SOURCES_QA, PROMPT_EXTERNAL_DIRECT_FACT_QA, + PROMPT_EXTERNAL_SIGNAL_SOURCE_RECOMMENDATION_PACKET, + PROMPT_EXTERNAL_SIGNAL_SOURCE_RECOMMENDATION_QA, PROMPT_EXTERNAL_SUMMARY_WITH_SOURCES_QA, PROMPT_EXTERNAL_TREND_QA, PROMPT_INDUSTRY_TREND_QA, @@ -100,7 +102,24 @@ PMFLOW_SEMANTIC_CONTEXT_DECISION_PROMPT = """ - 如果 packet.ingress.candidate_kind == "active_context_followup": - 这是“当前会话已经有 active requirement / active draft 时”的上下文优先决策,不是在做通用 intent 分类。 - 必须先基于当前已绑定的对象来判断动作,不要把这句消息先当成全局 query 或通用新意图。 + - active_context_followup 场景下必须同时输出 business_frame,不得留空。 + - 如果 active_context_kind == "requirement_plan",business_frame 至少要表达: + - goal_kind:clarify / recommendation / bounded_explanation / drafting / mixed 之一 + - target_scope:thread_context + - resource_role:project_object 或 task + - source_kind:internal 或 hybrid + - expected_answer_shape:clarify_prompt / bounded_explanation / ranked_candidates + - 如果当前 requirement 追问本质上是在复用当前轮的 A/B/C/D 选项、补一句、重出一版或直接要求进入草稿,必须把这轮的协议结构写进 business_frame.metadata.requirement_plan_protocol: + - choice:A / B / C / D 或空字符串 + - action:continue / clarify / reroll / draft 或空字符串 + - content_text:扣掉选项协议后的实际补充内容;如果没有补充内容就输出空字符串 + - requirement_plan_protocol 是结构化协议,不要把词面匹配规则写进 explanation。 - 对于只补充当前讨论模块的短追问,默认优先判断为 current_requirement 或 current_draft,而不是独立 workflow。 + - 如果用户明确表示“先暂停当前需求收敛/退出需求模式/先不继续这条需求”,且当前上下文是 requirement plan: + - 输出 followup_scope="current_requirement" + - followup_action="none" + - requested_actions 必须包含 "context.suspend_requirement_mode" + - assistant_reply 只说明已暂停,不要继续收需求 - 如果 active_context_kind == "draft_pending_confirm": - 用户明确表示确认当前草稿时,输出 followup_scope="current_draft" 且 followup_action="confirm"。 - 用户明确表示重写、重出一版、按这版重来、基于当前事实再出一版时,输出 followup_scope="current_draft" 且 followup_action="regenerate"。 @@ -118,8 +137,17 @@ PMFLOW_SEMANTIC_CONTEXT_DECISION_PROMPT = """ - 这是“当前消息还没有绑定到明确对象时”的冷启动路由决策。 - 你的职责是基于 packet.latest_user_message、packet.topic.metadata、packet.thread、packet.ingress.thread_context 决定应该进入哪些 workflow。 - 不要使用硬编码关键词仓库思维,不要把业务词表当成主裁决器;必须根据当前消息表达的动作、对象、作用域和上下文关系做整体判断。 + - 你必须同时输出 interaction_boundary,用来表达这一轮允许做到哪一挡: + - status:只回答当前现状,不给建议,不起草,不执行 + - recommendation:可以给候选建议和理由,但不能起草、不能执行 + - draft:可以整理成草稿,但不能执行 + - execute:用户明确要执行时才允许,且 requires_confirmation 必须为 true - 如果消息本质上是在咨询、分析、聊需求、收敛范围、问建议,而不是立即执行写动作,应输出 query_flow。 + - 如果 metadata.active_requirement_plan_present == true,且用户明确是在说“退出需求模式/先暂停这条需求/先不继续这条需求”,scene_metadata.message_intent 应输出 "requirement_mode_exit",workflow 仍然保持 query_flow.answer,不要伪装成 requirement_plan。 - 如果消息明确表达了新建项目、创建任务、接入信号源、创建项目信号等独立动作,才输出对应 workflow。 + - 如果用户明确说“先别出草稿 / 不要起草 / 只给建议 / 不要执行 / 先按现状回答”,interaction_boundary 必须把 draft / execute 放进 forbidden_levels,并关闭对应 allow_*。 + - 如果用户是在问“推荐哪几个 / 优先看什么 / 给建议”,即使你识别出 signal_source_flow,也不能让它直接变成草稿;应让 interaction_boundary.requested_level="recommendation",这样后续 answer control 会把最终主链停在 query.answer。 + - 如果用户后续明确升级,例如“可以,把第一个整理成草稿”,interaction_boundary.requested_level 应升级为 draft,但仍然不能自动 execute。 - 允许多 workflow,但只有当消息同时表达了多个明确动作时才这样做。 - scene_metadata 只允许包含:message_intent、review_framework、consultant_mode_preference、force_thinking_mode、advise_subtype、topic_switch。 - workflow_plans 里的 workflow_type 只允许:query_flow、new_project_flow、project_flow、task_flow、signal_source_flow、project_signal_flow、followup_flow。 @@ -151,9 +179,69 @@ JSON schema: } ], "scene_metadata": {}, + "interaction_boundary": { + "boundary_version": "v1", + "requested_level": "status | recommendation | draft | execute | unclear", + "forbidden_levels": ["status | recommendation | draft | execute"], + "allow_status": true, + "allow_recommendation": true, + "allow_draft": false, + "allow_execute": false, + "allow_write": false, + "needs_clarification": false, + "clarification_reason": "string", + "clarification_questions": ["string"], + "correction_allowed": true, + "correction_targets": ["project | object | level | priority | assumption"], + "self_review_required": true, + "confidence": 0.0, + "evidence": [{"source": "string", "note": "string"}] + }, + "business_frame": { + "goal_kind": "status_lookup | recommendation | impact_judgment | bounded_explanation | prepare_write | confirm_write | execute_write | drafting | clarify | casual_chat | mixed", + "target_scope": "current_project | explicit_project | thread_context | global", + "resource_role": "signal_source | external_input | project_object | task | signal | config | people | feature | doc | none", + "source_kind": "internal | external | hybrid | none", + "expected_answer_shape": "inventory | ranked_candidates | impact_chain | bounded_explanation | clarify_prompt | write_confirmation | write_result | social_reply", + "conversation_posture": "business_operating | ambient_chat | mixed", + "project_binding": "int | string | null", + "subject_binding": "string | null", + "object_binding": "string | null", + "candidate_set_binding": "string | null", + "evidence_mode": "verified | advisory | needs_clarify", + "metadata": { + "requirement_plan_protocol": { + "choice": "A | B | C | D | ''", + "action": "continue | clarify | reroll | draft | ''", + "content_text": "string" + } + } + }, + "answer_shape_contract": { + "shape": "inventory | ranked_candidates | impact_chain | bounded_explanation | clarify_prompt | write_confirmation | write_result | social_reply", + "confidence": 0.0, + "notes": ["string"] + }, + "operation_intent_contract": { + "intent_phase": "recommendation | prepare_write | confirm_write | execute_write | drafting", + "requires_confirmation": false, + "project_binding": "int | string | null", + "candidate_set_binding": "string | null", + "candidate_refs": ["string"], + "metadata": {} + }, + "self_review": { + "project_aligned": true, + "object_aligned": true, + "level_aligned": true, + "leaked_internal_terms": false, + "overstepped_boundary": false, + "clarify_missing": false, + "notes": [] + }, "assistant_reply": "string", "requires_confirmation": true, - "requested_actions": ["reply.compose | workflow.bundle.compose"], + "requested_actions": ["reply.compose | workflow.bundle.compose | context.suspend_requirement_mode"], "confidence": 0.0, "uncertainties": [] } @@ -176,10 +264,10 @@ PROMPT_RAG_QUERY_SEMANTICS = """ - source_policy(internal / external / uncertain) - effective_source_policy(internal / external / uncertain) - retrieval_domains(db / graph / doc / feishu / pmflow / url) -- subject_focus(people / project / task / signal / charter / workload / feature / unknown) - scope_hint(none / global / project) - feature_focus(none / purpose / entry / catalog) -- query_profile(needs_private_context / needs_freshness / referenced_objects) +- query_profile(needs_private_context / needs_freshness / confidence) +- business_frame(goal_kind / target_scope / resource_role / source_kind / expected_answer_shape / conversation_posture / project_binding / subject_binding / object_binding / candidate_set_binding / evidence_mode) 硬约束: - 不要复述 workflow bundle 的内部字段名给用户。 @@ -187,20 +275,36 @@ PROMPT_RAG_QUERY_SEMANTICS = """ - query_type 只用于 Adaptive RAG 的检索与快路径规划,不代表最终回复场景。 - query_type 取值只能是:existence、count、list、status、explain、diagnose、compare、unknown。 - retrieval_domains 只能从:db、graph、doc、feishu、pmflow、url 中选择。 -- subject_focus 只能是:people、project、task、signal、charter、workload、feature、unknown。 - scope_hint 只能是:none、global、project。 - feature_focus 只能是:none、purpose、entry、catalog。 -- query_profile.referenced_objects 只能从:unknown、system_config、model_routing、project_manifest、feature_catalog、project_object、internal_document、person_object、public_model、public_company、weather、market_data、sports_event、news_topic 中选择。 -- 如果问题是在问人员、成员、负责人清单或人数,subject_focus 应为 people。 -- 如果问题是在问功能做什么、入口在哪、模块如何进入,subject_focus 应为 feature,并结合 feature_focus 标明是 purpose / entry / catalog。 +- business_frame.goal_kind 只能是:status_lookup、recommendation、impact_judgment、bounded_explanation、prepare_write、confirm_write、execute_write、drafting、clarify、casual_chat、mixed。 +- business_frame.target_scope 只能是:current_project、explicit_project、thread_context、global。 +- business_frame.resource_role 只能是:signal_source、external_input、project_object、task、signal、config、people、feature、doc、none。 +- business_frame.source_kind 只能是:internal、external、hybrid、none。 +- business_frame.expected_answer_shape 只能是:inventory、ranked_candidates、impact_chain、bounded_explanation、clarify_prompt、write_confirmation、write_result、social_reply。 +- business_frame.conversation_posture 只能是:business_operating、ambient_chat、mixed。 +- business_frame.evidence_mode 只能是:verified、advisory、needs_clarify。 +- 如果问题是在问人员、成员、负责人清单或人数,business_frame.resource_role 应为 people。 +- 如果问题是在问功能做什么、入口在哪、模块如何进入,优先把 `feature_focus` 标成 purpose / entry / catalog。 +- 如果问题是在问功能模块、功能入口、页面能力或模块职责,business_frame.resource_role 应为 feature;只有在明确问项目业务对象时才用 project_object。 +- 如果问题是在问内部文档、知识条目、方案记录、复盘材料或知识库内的私有内容,business_frame.resource_role 应为 doc。 - 如果问题是在问“有没有 / 是否存在 / 系统里有无”这类内部对象存在性,query_type 应为 existence;不要因为对象名里出现“系统 / 平台 / 助手”就改判成 explain。像 `帮我看看系统里面有没有开票系统。`,应输出 `query_type=existence`,并把 `object_mentions=["开票系统"]`。 - 如果问题是在问系统内部项目、任务、信号、配置、文档等私有对象,且回答必须依赖 PMFlow 内部数据,应输出 `source_policy="internal"` 与 `effective_source_policy="internal"`。 - 内部项目存在性示例:`系统里有没有采购经理每日实时库存上报服务?` - 这类问句是在问 PMFlow 内部项目对象,应输出 `query_type=existence`、`subject_focus=project`、`object_mentions=["采购经理每日实时库存上报服务"]`、`source_policy="internal"`、`effective_source_policy="internal"`。 + 这类问句是在问 PMFlow 内部项目对象,应输出 `query_type=existence`、`object_mentions=["采购经理每日实时库存上报服务"]`、`source_policy="internal"`、`effective_source_policy="internal"`,同时把 `business_frame` 至少写成 `goal_kind=status_lookup / target_scope=global / resource_role=project_object / source_kind=internal / expected_answer_shape=inventory / conversation_posture=business_operating / evidence_mode=verified`。 - 外部公开事实示例:`当前 codex 最新的模型用的是什么?` - query_profile 应体现 `needs_freshness=true`、`referenced_objects=["public_model"]`,retrieval_domains 应优先为 ["url"],并输出 `source_policy="external"`、`effective_source_policy="external"`;不要因为出现“模型”二字就改判成系统内部配置问题。 + query_profile 应体现 `needs_freshness=true`,retrieval_domains 应优先为 ["url"],并输出 `source_policy="external"`、`effective_source_policy="external"`;同时把 `business_frame` 至少写成 `goal_kind=status_lookup / target_scope=global / resource_role=none / source_kind=external / expected_answer_shape=bounded_explanation / conversation_posture=business_operating / evidence_mode=verified`;不要因为出现“模型”二字就改判成系统内部配置问题。 - 内部运行时配置示例:`我们系统当前接的是哪个 codex 模型配置?` - query_profile 应体现 `needs_private_context=true`、`referenced_objects=["system_config","model_routing"]`,retrieval_domains 应优先为 ["db","pmflow"],并输出 `source_policy="internal"`、`effective_source_policy="internal"`;这是在问系统配置而不是公开外部事实。 + query_profile 应体现 `needs_private_context=true`,retrieval_domains 应优先为 ["db","pmflow"],并输出 `source_policy="internal"`、`effective_source_policy="internal"`;同时把 `business_frame` 至少写成 `goal_kind=status_lookup / target_scope=global / resource_role=config / source_kind=internal / expected_answer_shape=bounded_explanation / conversation_posture=business_operating / object_binding=config:model_routing / evidence_mode=verified`;这是在问系统配置而不是公开外部事实。 +- 内部 MCP 配置示例:`当前系统有没有 MCP 配置?` + query_profile 应体现 `needs_private_context=true`,retrieval_domains 应优先为 ["db","pmflow"],并输出 `source_policy="internal"`、`effective_source_policy="internal"`;同时把 `business_frame` 至少写成 `goal_kind=status_lookup / target_scope=global / resource_role=config / source_kind=internal / expected_answer_shape=bounded_explanation / conversation_posture=business_operating / object_binding=config:mcp_template_center / evidence_mode=verified`;不要把它误判成模型引擎或 model routing 配置。 +- 内部配置域澄清示例:`系统里现在有哪些配置?` + query_profile 应体现 `needs_private_context=true`,retrieval_domains 应优先为 ["db","pmflow"],并输出 `source_policy="internal"`、`effective_source_policy="internal"`;同时把 `business_frame` 至少写成 `goal_kind=clarify / target_scope=global / resource_role=config / source_kind=internal / expected_answer_shape=clarify_prompt / conversation_posture=business_operating / evidence_mode=needs_clarify`。如果对象域还不唯一,不要擅自落成 `config:model_routing`,而要保留 config 域并让下游进入更深一层的内部配置澄清。 +- 内部功能域示例:`这个功能入口在哪?` + query_profile 应体现 `needs_private_context=true`,retrieval_domains 应优先为 ["db","pmflow"],并输出 `source_policy="internal"`、`effective_source_policy="internal"`;同时把 `business_frame` 至少写成 `goal_kind=status_lookup / target_scope=global / resource_role=feature / source_kind=internal / expected_answer_shape=bounded_explanation / conversation_posture=business_operating / evidence_mode=verified`。 +- 内部文档域示例:`系统里有没有复盘文档?` + query_profile 应体现 `needs_private_context=true`,retrieval_domains 应优先为 ["db","doc","pmflow"],并输出 `source_policy="internal"`、`effective_source_policy="internal"`;同时把 `business_frame` 至少写成 `goal_kind=status_lookup / target_scope=global / resource_role=doc / source_kind=internal / expected_answer_shape=inventory / conversation_posture=business_operating / evidence_mode=verified`。 +- 如果已经确定是内部对象查询,但对象域还不唯一,应输出 `goal_kind=clarify` 与 `expected_answer_shape=clarify_prompt`,并保持最贴近的 `resource_role`,不要为了省事回落成 project_object 或 config 的默认桶。 - 如果无法可靠判断 internal 还是 external,才输出 `source_policy="uncertain"` 与 `effective_source_policy="uncertain"`。 - 若证据不足,query_type 输出 unknown,retrieval_domains 选择保守但完整的查询范围。 - 输出必须是一个 JSON 对象,不要输出 Markdown。 @@ -215,15 +319,26 @@ JSON schema: "source_policy": "internal | external | uncertain", "effective_source_policy": "internal | external | uncertain", "retrieval_domains": ["db | graph | doc | feishu | pmflow | url"], - "subject_focus": "people | project | task | signal | charter | workload | feature | unknown", "scope_hint": "none | global | project", "feature_focus": "none | purpose | entry | catalog", "query_profile": {{ "needs_private_context": false, "needs_freshness": false, - "referenced_objects": ["unknown"], "confidence": 0.0 }}, + "business_frame": {{ + "goal_kind": "status_lookup | recommendation | impact_judgment | bounded_explanation | prepare_write | confirm_write | execute_write | drafting | clarify | casual_chat | mixed", + "target_scope": "current_project | explicit_project | thread_context | global", + "resource_role": "signal_source | external_input | project_object | task | signal | config | people | feature | doc | none", + "source_kind": "internal | external | hybrid | none", + "expected_answer_shape": "inventory | ranked_candidates | impact_chain | bounded_explanation | clarify_prompt | write_confirmation | write_result | social_reply", + "conversation_posture": "business_operating | ambient_chat | mixed", + "project_binding": "number | string | null", + "subject_binding": "string | null", + "object_binding": "string | null", + "candidate_set_binding": "string | null", + "evidence_mode": "verified | advisory | needs_clarify" + }}, "confidence": 0.0 }} @@ -277,6 +392,39 @@ message: {message} """.strip() +PROMPT_RAG_PENDING_CLARIFICATION_SELECTION_SEMANTICS = """ +你在解析用户对上一轮澄清问题的回答。 + +目标: +1. 判断这条消息是不是在回答当前 pending clarification。 +2. 如果是在回答,请只输出它选中了哪个结构化选项。 +3. 如果这条消息已经明显切到新话题,输出 new_topic。 +4. 不要编造不存在的选项。 + +输入: +- 当前消息:{current_message} +- pending clarification kind:{clarification_kind} +- 原始问题:{original_question} +- 可选 protocol options:{options} +- 可选 recent candidates:{candidates} + +规则: +- `query_route_selection` 只允许在 `selected_key` 里返回 A/B 之一;`selected_index` 必须为 0。 +- `followup_anchor_selection` 只允许在 `selected_index` 里返回候选编号;`selected_key` 必须为空字符串。 +- 如果当前消息不是在回答这次澄清,而是已经换了新话题,返回 `selection_status="new_topic"`。 +- 如果看不出来选了哪个,返回 `selection_status="unresolved"`。 +- 输出只能是 JSON。 + +JSON schema: +{{ + "selection_status": "resolved | unresolved | new_topic", + "selected_key": "A | B | ''", + "selected_index": 0, + "confidence": 0.0, + "reason": "string" +}} +""".strip() + PROMPT_INTERNAL_TARGET_RESOLUTION = """ 你是 PMFlow 内部查询链路的 Target Resolver rerank 层。 @@ -325,7 +473,9 @@ 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`,优先把它们当作查询约束,而不是忽略当前项目上下文。 +- 若给了 `fit_brief_summary / project_scope_hint / recommendation_goal / project_object_anchors / signal_source_objectives`,优先把它们当作查询约束,而不是忽略当前项目上下文。 +- 若 `recommendation_goal` 明确是在推荐“外部信号源”,`search_queries` 必须优先围绕当前项目任务目标或正式信号源锚点展开,不要退化成 `industry best practices`、`external data sources` 这类泛化词。 +- 这类 recommendation 查询优先保留 fallback_queries 里的项目任务锚点和对象锚点,再按需要精炼,不要整组覆盖成抽象总结。 - 若用户问题明显要求最新、当前、今天、最近等外部信息,`freshness_required=true`。 - 若问题更像对比/比较,`requires_multi_source=true` 且 `answer_contract=compare_with_sources`。 - 结构化失败时优先保守,不要编造实体。 @@ -348,7 +498,8 @@ JSON schema: "fit_brief_summary": "string", "project_scope_hint": "string | null", "recommendation_goal": "string | null", - "project_object_anchors": ["string"] + "project_object_anchors": ["string"], + "signal_source_objectives": ["string"] }} question: @@ -857,11 +1008,14 @@ __all__ = [ "PROMPT_EXTERNAL_DIRECT_FACT_QA", "PROMPT_EXTERNAL_SUMMARY_WITH_SOURCES_QA", "PROMPT_EXTERNAL_COMPARE_WITH_SOURCES_QA", + "PROMPT_EXTERNAL_SIGNAL_SOURCE_RECOMMENDATION_PACKET", + "PROMPT_EXTERNAL_SIGNAL_SOURCE_RECOMMENDATION_QA", "PROMPT_EXTERNAL_TREND_QA", "PROMPT_INDUSTRY_TREND_QA", "PMFLOW_SEMANTIC_CONTEXT_DECISION_PROMPT", "PROMPT_RAG_QUERY_SEMANTICS", "PROMPT_RAG_FOLLOWUP_ANCHOR_SEMANTICS", + "PROMPT_RAG_PENDING_CLARIFICATION_SELECTION_SEMANTICS", "PROMPT_INTERNAL_TARGET_RESOLUTION", "PROMPT_EXTERNAL_QUERY_PLAN", "PROMPT_RAG_ANSWER_SCENE_SEMANTICS", diff --git a/backend/app/config/ai_prompts_catalog/__init__.py b/backend/app/config/ai_prompts_catalog/__init__.py index 3e2a9263..77e18676 100644 --- a/backend/app/config/ai_prompts_catalog/__init__.py +++ b/backend/app/config/ai_prompts_catalog/__init__.py @@ -12,6 +12,8 @@ from .part_scenes_05 import PROMPT_AI_PMO_QA, PROMPT_AI_PMO_REQUIREMENT_PLAN_QA from .part_scenes_06 import ( PROMPT_EXTERNAL_COMPARE_WITH_SOURCES_QA, PROMPT_EXTERNAL_DIRECT_FACT_QA, + PROMPT_EXTERNAL_SIGNAL_SOURCE_RECOMMENDATION_PACKET, + PROMPT_EXTERNAL_SIGNAL_SOURCE_RECOMMENDATION_QA, PROMPT_EXTERNAL_SUMMARY_WITH_SOURCES_QA, PROMPT_EXTERNAL_TREND_QA, PROMPT_INDUSTRY_TREND_QA, @@ -69,4 +71,6 @@ __all__ = [ "PROMPT_EXTERNAL_DIRECT_FACT_QA", "PROMPT_EXTERNAL_SUMMARY_WITH_SOURCES_QA", "PROMPT_EXTERNAL_COMPARE_WITH_SOURCES_QA", + "PROMPT_EXTERNAL_SIGNAL_SOURCE_RECOMMENDATION_PACKET", + "PROMPT_EXTERNAL_SIGNAL_SOURCE_RECOMMENDATION_QA", ] diff --git a/backend/app/config/ai_prompts_catalog/part_01.py b/backend/app/config/ai_prompts_catalog/part_01.py index b0639183..daa42188 100644 --- a/backend/app/config/ai_prompts_catalog/part_01.py +++ b/backend/app/config/ai_prompts_catalog/part_01.py @@ -223,14 +223,15 @@ PROMPT_FEISHU_QA_NATURAL_REPLY_REWRITE = """ 输入信息: - 用户原问题:{question} -- 分析包(JSON):{analysis_plan} +- 分析包(JSON):{analysis_packet} 要求: 1. 输出 2-3 段自然中文。 2. 第一段直接进入判断,不要固定抬头。 3. 只能围绕分析包里的 `focus_entities` 来写,不允许增加新实体,不允许把实体写成清单。 -4. `missing_mapping` 必须明确说出来;如果 `next_step` 为空,就不要强行补下一步。 -5. 结论必须和分析包逐项对应,不能发散、不能补戏、不能装懂。 +4. 如果分析包里有 `current_subject`、`candidate_position`、`rejected_position` 或 `reason_chain`,优先按这些字段组织表达,不要自己重组出另一套分析。 +5. `missing_mapping` 必须明确说出来;如果 `next_step` 为空,就不要强行补下一步。 +6. 结论必须和分析包逐项对应,不能发散、不能补戏、不能装懂。 硬性禁令: 1. 禁止固定开头,例如: diff --git a/backend/app/config/ai_prompts_catalog/part_02.py b/backend/app/config/ai_prompts_catalog/part_02.py index 84cdc87a..1464b1f6 100644 --- a/backend/app/config/ai_prompts_catalog/part_02.py +++ b/backend/app/config/ai_prompts_catalog/part_02.py @@ -61,6 +61,7 @@ PROMPT_FEISHU_PROJECT_PREFILL = """ {{ "suggested_name": "项目名", "suggested_description": "1-2句描述", + "suggested_scope": "项目范围/业务边界;不明确时返回 null", "suggested_owner": "负责人名或角色;不明确时返回 null", "suggested_timeline": "截止日期或周期;不明确时返回 null", "confidence": 0.0, @@ -70,8 +71,9 @@ PROMPT_FEISHU_PROJECT_PREFILL = """ 约束: 1. reasoning 必须是 3 条简短依据。 2. 仅在消息明确出现负责人/角色/时间线时填写对应字段。 -3. 不要为了填满字段而猜测;不明确时返回 null。 -4. confidence < 0.6 时,suggested_owner 与 suggested_timeline 优先返回 null。 +3. 仅在消息明确看得出项目范围/业务边界时填写 suggested_scope;不明确时返回 null。 +4. 不要为了填满字段而猜测;不明确时返回 null。 +5. confidence < 0.6 时,suggested_owner 与 suggested_timeline 优先返回 null。 """ PROMPT_FEISHU_PROJECT_PREFILL = _compose_prompt( PMFLOW_PMO_STYLE_BASE, diff --git a/backend/app/config/ai_prompts_catalog/part_scenes_06.py b/backend/app/config/ai_prompts_catalog/part_scenes_06.py index 4b23fcab..39bc4278 100644 --- a/backend/app/config/ai_prompts_catalog/part_scenes_06.py +++ b/backend/app/config/ai_prompts_catalog/part_scenes_06.py @@ -126,10 +126,86 @@ PROMPT_EXTERNAL_COMPARE_WITH_SOURCES_QA = _compose_prompt( TEXT_OUTPUT_GUARDRAIL, ) +PROMPT_EXTERNAL_SIGNAL_SOURCE_RECOMMENDATION_QA = _compose_prompt( + "你是 PMFlow 的外部信号源推荐助手。", + """ +用户不是在问“当前已经接了什么”,而是在问“基于当前项目,外部最值得先关注哪些信号源”。 + +规则: +1. 优先围绕“项目任务目标”和“正式信号源锚点”给出 1-3 个最值得先补齐的外部输入方向;只有来源摘要里明确出现具体系统或对象名时,才可以写具体名字。 +2. 这是推荐判断,不是已验证事实;如果当前检索状态仍在校验或证据不足,要用一句短话说明,但不要把整条回答写成拒答。 +3. 不要复述当前项目现状,不要起草需求,不要输出步骤型方案。 +4. 语言必须自然、具体、中文化;禁止出现 route、verifier、provider、freshness、guard、packet 等内部术语。 +5. 回答控制在 260 字内,适合飞书阅读。 +6. 禁止输出思考过程、内部函数名、文件路径、代码行号、模块名或调试字段。 +7. 不要把回答写成固定类别模板,也不要擅自写死某些系统名、行业词或“发布变更/工单/告警”这类预设分类;除非来源摘要里明确出现,否则不要写百分比、相关性数值、已确认、无需额外验证等确定性表述。 +8. 如果来源摘要偏泛或只是背景相关,就退回到“基于项目任务目标的推荐”,优先引用项目任务目标,不要自造对象。 + +问题:{question} +项目范围: +{plan_summary} + +项目适配摘要:{fit_brief_summary} +推荐目标:{recommendation_goal} +项目对象锚点:{project_object_anchors} +项目任务目标:{signal_source_objectives} +当前检索状态:{retrieval_status} +不确定项:{uncertainties} +来源摘要:{source_summaries} +""".strip(), + TEXT_OUTPUT_GUARDRAIL, +) + +PROMPT_EXTERNAL_SIGNAL_SOURCE_RECOMMENDATION_PACKET = _compose_prompt( + "你是 PMFlow 的外部信号源推荐规划助手。", + """ +你要先输出结构化推荐候选,不要直接写最终给用户看的大段回答。 + +任务: +- 基于当前项目摘要、项目对象锚点、项目任务目标、来源摘要和检索状态 +- 给出 3 个最值得先看的外部信号源候选 +- 每个候选都要说明为什么值得先看 + +硬约束: +1. 候选必须写成业务用户能听懂的名字,像“开票失败或重试回执”“用户投诉和差评变化”“测试执行失败和阻塞记录”,不要写“上游输入”“这一层”“主链”“对象锚点”“信号包”等抽象说法。 +2. reason 必须解释业务价值,回答“为什么先看它”,不能只说“相关”“重要”“值得关注”。 +3. 不要起草需求,不要写执行步骤,不要写内部术语,不要输出 route、verifier、provider、freshness、guard、packet、contract 等词。 +4. 如果当前来源只是背景相关或还在校验,可以保留一条简短 caveat,但 caveat 只能说“这是建议,不代表已经接入或验证完成”之类的边界,不能整包写成拒答。 +5. 只有来源摘要明确出现具体系统或对象名时,才可以直接引用该具体名字;否则优先贴当前项目业务目标,不要编造系统名。 +6. 输出必须是 JSON 对象,不要输出 Markdown,不要输出解释。 + +JSON schema: +{{ + "candidates": [ + {{ + "name": "string", + "reason": "string" + }} + ], + "caveat": "string" +}} + +问题:{question} +项目范围: +{plan_summary} + +项目适配摘要:{fit_brief_summary} +推荐目标:{recommendation_goal} +项目对象锚点:{project_object_anchors} +项目任务目标:{signal_source_objectives} +当前检索状态:{retrieval_status} +不确定项:{uncertainties} +来源摘要:{source_summaries} +""".strip(), + TEXT_OUTPUT_GUARDRAIL, +) + __all__ = [ "PROMPT_EXTERNAL_TREND_QA", "PROMPT_INDUSTRY_TREND_QA", "PROMPT_EXTERNAL_DIRECT_FACT_QA", "PROMPT_EXTERNAL_SUMMARY_WITH_SOURCES_QA", "PROMPT_EXTERNAL_COMPARE_WITH_SOURCES_QA", + "PROMPT_EXTERNAL_SIGNAL_SOURCE_RECOMMENDATION_PACKET", + "PROMPT_EXTERNAL_SIGNAL_SOURCE_RECOMMENDATION_QA", ] diff --git a/backend/app/schemas/analysis_packets.py b/backend/app/schemas/analysis_packets.py new file mode 100644 index 00000000..ba00c948 --- /dev/null +++ b/backend/app/schemas/analysis_packets.py @@ -0,0 +1,124 @@ +"""Typed analysis packet contracts for analysis-style replies.""" + +from __future__ import annotations + +from typing import Annotated, Any, Literal + +from pydantic import BaseModel, ConfigDict, Field, TypeAdapter + + +AnalysisFocusStance = Literal[ + "better_fit", + "not_fit", + "anchor", + "blocked", + "dependent", + "relevant", +] + + +class AnalysisFocusObject(BaseModel): + model_config = ConfigDict(extra="ignore") + + name: str + kind: str = "unknown" + stance: AnalysisFocusStance = "relevant" + reason: str + + +class AnalysisPacketBase(BaseModel): + model_config = ConfigDict(extra="ignore") + + packet_version: str = "v1" + packet_type: str + conclusion: str + focus_objects: list[AnalysisFocusObject] = Field(default_factory=list, max_length=2) + next_step: str | None = None + + +class ProjectFitAnalysisPacket(AnalysisPacketBase): + packet_type: Literal["project_fit_analysis"] = "project_fit_analysis" + fit_verdict: Literal["fit_now", "fit_later", "not_fit_yet", "uncertain"] + current_subject: str | None = None + candidate_position: str | None = None + rejected_position: str | None = None + preferred_layer: str | None = None + rejected_layer: str | None = None + reason_chain: list[str] = Field(default_factory=list, max_length=3) + missing_mapping: str + + +class ExternalImpactAnalysisPacket(AnalysisPacketBase): + packet_type: Literal["external_impact_analysis"] = "external_impact_analysis" + retrieval_status: Literal["evidence_found", "verifier_blocked", "no_relevant_results", "retrieval_failed"] + impact_status: Literal["direct_match", "indirect_only", "no_direct_impact"] + current_subject: str | None = None + reason_chain: list[str] = Field(default_factory=list, max_length=3) + anchor_objects: list[str] = Field(default_factory=list) + blocked_reason_or_gap: str + + +class InternalExplainAnalysisPacket(AnalysisPacketBase): + packet_type: Literal["internal_explain_analysis"] = "internal_explain_analysis" + answer_contract: str | None = None + current_subject: str | None = None + reason_chain: list[str] = Field(default_factory=list, max_length=3) + resolved_target: str | None = None + evidence_gap_or_uncertainty: str | None = None + + +class FollowupExplainAnalysisPacket(AnalysisPacketBase): + packet_type: Literal["followup_explain_analysis"] = "followup_explain_analysis" + anchor_kind: str | None = None + continuation_kind: str | None = None + clarification_state: str | None = None + missing_mapping: str | None = None + + +AnalysisPacket = Annotated[ + ProjectFitAnalysisPacket + | ExternalImpactAnalysisPacket + | InternalExplainAnalysisPacket + | FollowupExplainAnalysisPacket, + Field(discriminator="packet_type"), +] + +_ANALYSIS_PACKET_ADAPTER = TypeAdapter(AnalysisPacket) + + +def coerce_analysis_packet(value: Any) -> AnalysisPacket | None: + if value is None: + return None + if isinstance( + value, + ( + ProjectFitAnalysisPacket, + ExternalImpactAnalysisPacket, + InternalExplainAnalysisPacket, + FollowupExplainAnalysisPacket, + ), + ): + return value + if isinstance(value, dict): + try: + return _ANALYSIS_PACKET_ADAPTER.validate_python(value) + except Exception: + return None + return None + + +def normalize_analysis_packet_payload(value: Any) -> dict[str, Any]: + packet = coerce_analysis_packet(value) + return packet.model_dump() if packet is not None else {} + + +__all__ = [ + "AnalysisFocusObject", + "AnalysisPacket", + "ExternalImpactAnalysisPacket", + "FollowupExplainAnalysisPacket", + "InternalExplainAnalysisPacket", + "ProjectFitAnalysisPacket", + "coerce_analysis_packet", + "normalize_analysis_packet_payload", +] diff --git a/backend/app/schemas/project_operating_context.py b/backend/app/schemas/project_operating_context.py index 3eec08ea..b7a43c5b 100644 --- a/backend/app/schemas/project_operating_context.py +++ b/backend/app/schemas/project_operating_context.py @@ -22,6 +22,7 @@ class ProjectFitBrief(BaseModel): repo_summary: str | None = None current_capabilities: list[str] = Field(default_factory=list) signal_source_anchors: list[str] = Field(default_factory=list) + signal_source_objectives: list[str] = Field(default_factory=list) constraints: list[str] = Field(default_factory=list) recommendation_focus: list[str] = Field(default_factory=list) diff --git a/backend/app/services/adaptive_rag_service/internal_domain_catalog.py b/backend/app/services/adaptive_rag_service/internal_domain_catalog.py new file mode 100644 index 00000000..9db57386 --- /dev/null +++ b/backend/app/services/adaptive_rag_service/internal_domain_catalog.py @@ -0,0 +1,222 @@ +from __future__ import annotations + +from typing import Any + + +_CONFIG_TARGETS: tuple[dict[str, str], ...] = ( + { + "target_ref": "config:model_routing", + "label": "模型路由配置", + "resource_role": "config", + "answer_shape": "bounded_explanation", + }, + { + "target_ref": "config:mcp_template_center", + "label": "MCP 配置", + "resource_role": "config", + "answer_shape": "bounded_explanation", + }, +) + +_INTERNAL_DOMAIN_TARGETS: tuple[dict[str, str], ...] = ( + { + "target_entity_type": "project", + "target_ref": "domain:project", + "label": "项目对象", + "resource_role": "project_object", + "answer_shape": "inventory", + }, + { + "target_entity_type": "task", + "target_ref": "domain:task", + "label": "任务", + "resource_role": "task", + "answer_shape": "inventory", + }, + { + "target_entity_type": "signal", + "target_ref": "domain:signal", + "label": "信号", + "resource_role": "signal", + "answer_shape": "inventory", + }, + { + "target_entity_type": "feature", + "target_ref": "domain:feature", + "label": "功能对象", + "resource_role": "feature", + "answer_shape": "bounded_explanation", + }, + { + "target_entity_type": "doc", + "target_ref": "domain:doc", + "label": "文档知识", + "resource_role": "doc", + "answer_shape": "inventory", + }, + { + "target_entity_type": "user", + "target_ref": "domain:user", + "label": "人员/成员", + "resource_role": "people", + "answer_shape": "inventory", + }, +) + + +def config_target_catalog() -> list[dict[str, str]]: + return [dict(item) for item in _CONFIG_TARGETS] + + +def internal_domain_catalog() -> list[dict[str, str]]: + return [dict(item) for item in _INTERNAL_DOMAIN_TARGETS] + + +def internal_domain_target(entity_type: str) -> dict[str, str] | None: + normalized = str(entity_type or "").strip().lower() + if normalized == "person": + normalized = "user" + for item in _INTERNAL_DOMAIN_TARGETS: + if str(item.get("target_entity_type") or "").strip().lower() == normalized: + return dict(item) + return None + + +def _candidate_identity(candidate: dict[str, Any]) -> str: + return "|".join( + [ + str(candidate.get("target_entity_type") or "").strip().lower(), + str(candidate.get("target_ref") or "").strip().lower(), + str(candidate.get("resolved_label") or "").strip().lower(), + ] + ) + + +def _resource_role_for_entity_type(entity_type: str) -> str: + normalized = str(entity_type or "").strip().lower() + if normalized == "config": + return "config" + if normalized == "task": + return "task" + if normalized == "signal": + return "signal" + if normalized in {"person", "user"}: + return "people" + if normalized == "feature": + return "feature" + if normalized == "doc": + return "doc" + if normalized == "project": + return "project_object" + return "none" + + +def _answer_shape_for_entity_type(entity_type: str) -> str: + normalized = str(entity_type or "").strip().lower() + if normalized in {"project", "feature", "config"}: + return "bounded_explanation" + if normalized in {"signal", "doc", "person", "user", "task"}: + return "inventory" + return "bounded_explanation" + + +def _business_frame_overrides_for_candidate(candidate: dict[str, Any]) -> dict[str, Any]: + entity_type = str(candidate.get("target_entity_type") or "").strip().lower() + target_ref = str(candidate.get("target_ref") or "").strip() + resolved_label = str(candidate.get("resolved_label") or "").strip() or None + frame: dict[str, Any] = { + "goal_kind": "status_lookup", + "target_scope": "global", + "resource_role": _resource_role_for_entity_type(entity_type), + "source_kind": "internal", + "expected_answer_shape": _answer_shape_for_entity_type(entity_type), + "conversation_posture": "business_operating", + } + if resolved_label: + frame["subject_binding"] = resolved_label + if target_ref: + frame["object_binding"] = target_ref + if entity_type == "project" and target_ref.startswith("project:"): + try: + frame["project_binding"] = int(target_ref.split(":", 1)[1]) + frame["target_scope"] = "explicit_project" + except Exception: + pass + return frame + + +def build_internal_target_selection_protocol( + *, + candidate_summary: list[dict[str, Any]], +) -> dict[str, Any] | None: + rows: list[dict[str, Any]] = [] + seen: set[str] = set() + for candidate in list(candidate_summary or []): + if not isinstance(candidate, dict): + continue + target_ref = str(candidate.get("target_ref") or "").strip() + label = str(candidate.get("resolved_label") or "").strip() + if not target_ref or not label: + continue + identity = _candidate_identity(candidate) + if identity in seen: + continue + seen.add(identity) + rows.append(dict(candidate)) + if len(rows) >= 4: + break + if len(rows) < 2: + return None + + keys = ("A", "B", "C", "D") + option_map: dict[str, str] = {} + options: list[dict[str, Any]] = [] + for idx, candidate in enumerate(rows): + key = keys[idx] + target_ref = str(candidate.get("target_ref") or "").strip() + option_map[key] = target_ref + options.append( + { + "key": key, + "label": str(candidate.get("resolved_label") or "").strip()[:80], + "query_semantics_overrides": { + "source_policy": "internal", + "effective_source_policy": "internal", + "business_frame": _business_frame_overrides_for_candidate(candidate), + "target_resolution": { + "target_entity_type": str(candidate.get("target_entity_type") or "unknown").strip().lower() + or "unknown", + "target_ref": target_ref, + "resolved_label": str(candidate.get("resolved_label") or "").strip() or None, + "resolution_mode": "clarify_selection", + "confidence": float(candidate.get("score") or 0.9), + "candidate_summary": [], + }, + }, + } + ) + + return { + "kind": "internal_target_selection", + "option_map": option_map, + "options": options, + } + + +def build_internal_target_clarification_text( + *, + candidate_summary: list[dict[str, Any]], +) -> str | None: + protocol = build_internal_target_selection_protocol(candidate_summary=candidate_summary) + if not isinstance(protocol, dict): + return None + lines = ["当前内部对象还不够明确,我先给你列几个可继续往里查的入口:"] + for item in list(protocol.get("options") or []): + if not isinstance(item, dict): + continue + key = str(item.get("key") or "").strip().upper() + label = str(item.get("label") or "").strip() + if key and label: + lines.append(f"{key}. {label}") + lines.append("你补一个选项或直接说清对象后,我就继续往里查。") + return "\n".join(lines) 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 5d0581b7..b8c0c303 100644 --- a/backend/app/services/adaptive_rag_service/mixin_answer_mainline.py +++ b/backend/app/services/adaptive_rag_service/mixin_answer_mainline.py @@ -3,6 +3,7 @@ from __future__ import annotations from .shared import * from .internal_query_context import InternalContextPacket, internal_context_block_reason +from .shared_utils import _structured_runtime_entity_type class AdaptiveRAGAnswerMainlineMixin: @@ -91,15 +92,6 @@ class AdaptiveRAGAnswerMainlineMixin: semantics["session_followup_context"] = payload return semantics - @staticmethod - def _internal_entity_type_for_subject_focus(subject_focus: str) -> str: - normalized = str(subject_focus or "").strip().lower() - if normalized == "people": - return "user" - if normalized in {"project", "task", "signal", "feature"}: - return normalized - return "unknown" - def _build_internal_query_plan( self, *, @@ -127,11 +119,11 @@ class AdaptiveRAGAnswerMainlineMixin: or semantics.get("query_type") or QUERY_TYPE_UNKNOWN ).strip().lower() or QUERY_TYPE_UNKNOWN - subject_focus = self._query_subject_focus(semantics) if hasattr(self, "_query_subject_focus") else "unknown" - entity_type = str( - target.get("target_entity_type") - or self._internal_entity_type_for_subject_focus(subject_focus) - ).strip().lower() or "unknown" + entity_type = _structured_runtime_entity_type( + query_semantics=semantics, + operation_contract=operation, + target_resolution=target, + ) scope_hint = self._query_scope_hint(semantics) if hasattr(self, "_query_scope_hint") else "none" target_ref = str(target.get("target_ref") or "").strip() or None @@ -533,34 +525,64 @@ class AdaptiveRAGAnswerMainlineMixin: int(context_session_id) if context_session_id is not None else None ) state_row = await self._ensure_session_state(session_id=state_session_id) - pending_selection = self._consume_pending_clarification_selection( + pending_selection = await self._consume_pending_clarification_selection( question=clean_q, session_state=state_row, ) resolved_question = str(pending_selection.get("effective_question") or clean_q).strip() if isinstance( pending_selection, dict ) else clean_q + selected_anchor_reply_id = ( + int(pending_selection.get("selected_qa_id") or 0) or None + if isinstance(pending_selection, dict) + else None + ) consumed_source_policy_hint = ( str(pending_selection.get("source_policy_hint") or "").strip().lower() or None if isinstance(pending_selection, dict) else None ) + consumed_query_semantics_overrides = ( + dict(pending_selection.get("query_semantics_overrides") or {}) + if isinstance(pending_selection, dict) and isinstance(pending_selection.get("query_semantics_overrides"), dict) + else {} + ) anchor_resolution = await self._resolve_followup_anchor( question=resolved_question, session_id=state_session_id, context_session_id=context_session_id, session_state=state_row, + selected_anchor_reply_id=selected_anchor_reply_id, conversation_scope_key=external_conversation_key, source_chat_id=source_chat_id, source_sender_open_id=source_sender_open_id, source_message_id=source_message_id, thread_context=thread_context, ) + rewrite_governance_builder = getattr(self, "_build_state_rewrite_governance", None) + rewrite_governance = ( + await rewrite_governance_builder( + question=resolved_question, + project_id=project_id, + session_id=state_session_id, + source_policy_hint=consumed_source_policy_hint or source_policy, + query_semantics=( + dict(precomputed_query_semantics or {}) + if isinstance(precomputed_query_semantics, dict) and resolved_question == clean_q + else None + ), + session_state=state_row, + anchor_resolution=anchor_resolution, + ) + if callable(rewrite_governance_builder) + else {} + ) effective_q, rewrite_meta = self._rewrite_question_with_state( question=resolved_question, conversation=conversation, session_state=state_row, anchor_resolution=anchor_resolution, + rewrite_governance=rewrite_governance, ) query_semantics = dict(precomputed_query_semantics or {}) if isinstance(precomputed_query_semantics, dict) else {} should_refresh_route_preview = bool( @@ -568,6 +590,7 @@ class AdaptiveRAGAnswerMainlineMixin: and ( resolved_question != clean_q or consumed_source_policy_hint + or bool(consumed_query_semantics_overrides) ) ) if should_refresh_route_preview: @@ -617,6 +640,18 @@ class AdaptiveRAGAnswerMainlineMixin: if isinstance(route_preview.get("query_semantics"), dict) else {} ) + if consumed_query_semantics_overrides: + query_semantics.update(consumed_query_semantics_overrides) + precomputed_interaction_level = ( + str((precomputed_query_semantics or {}).get("interaction_level") or "").strip().lower() + if isinstance(precomputed_query_semantics, dict) + else "" + ) + if ( + precomputed_interaction_level in {"status", "recommendation", "draft", "execute", "clarify"} + and not str(query_semantics.get("interaction_level") or "").strip().lower() + ): + query_semantics["interaction_level"] = precomputed_interaction_level project_operating_context = ( dict(route_preview.get("project_operating_context") or {}) if isinstance(route_preview.get("project_operating_context"), dict) 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 177e2f43..4009f04f 100644 --- a/backend/app/services/adaptive_rag_service/mixin_answer_payload.py +++ b/backend/app/services/adaptive_rag_service/mixin_answer_payload.py @@ -1,9 +1,14 @@ """Adaptive RAG mixin module.""" from __future__ import annotations +from app.schemas.analysis_packets import normalize_analysis_packet_payload from app.schemas.project_operating_context import ExecutionRecipe, ProjectFitBrief, ProjectOperatingContext +from app.services.analysis_packet_builder import build_analysis_packet +from app.services.analysis_project_snapshot_loader import load_analysis_project_snapshot +from app.services.external_query_service.schemas import normalize_signal_source_recommendation_payload from .shared import * +from .internal_domain_catalog import build_internal_target_clarification_text, build_internal_target_selection_protocol from .shared_utils import _env_bool from .internal_query_context import ( internal_context_block_reason, @@ -35,6 +40,7 @@ class AdaptiveRAGAnswerPayloadMixin: "indirect_only", "no_direct_impact", } + _ENV_ENABLE_ANALYSIS_PACKET_FAMILY = "PMFLOW_ENABLE_ANALYSIS_PACKET_FAMILY" _ENV_ENABLE_DEFAULT_PLAN_HANDOFF_RENDERING = "PMFLOW_ENABLE_DEFAULT_PLAN_HANDOFF_RENDERING" @staticmethod @@ -64,6 +70,81 @@ class AdaptiveRAGAnswerPayloadMixin: except Exception: return {} + @staticmethod + def _normalize_analysis_packet(payload: Any) -> dict[str, Any]: + return normalize_analysis_packet_payload(payload) + + @staticmethod + def _normalize_signal_source_recommendation( + payload: Any, + *, + project_id: Optional[int] = None, + ) -> dict[str, Any]: + normalized = normalize_signal_source_recommendation_payload(payload) + if normalized and normalized.get("project_binding") is None and project_id is not None: + normalized["project_binding"] = int(project_id) + return normalized + + async def _load_analysis_project_snapshot_for_packet( + self, + *, + project_operating_context: Optional[dict[str, Any]], + fallback_project_id: Optional[int] = None, + ) -> dict[str, Any]: + current_project_id = None + if isinstance(project_operating_context, dict): + current_project_id = project_operating_context.get("current_project_id") + try: + project_id = int(current_project_id) if current_project_id is not None else None + except Exception: + project_id = None + if project_id is None and fallback_project_id is not None: + project_id = int(fallback_project_id) + return await load_analysis_project_snapshot( + db=getattr(self, "db", None), + tenant_id=getattr(self, "tenant_id", None), + project_id=project_id, + ) + + def _build_analysis_packet_payload( + self, + *, + route_decision: Optional[dict[str, Any]], + project_operating_context: Optional[dict[str, Any]], + execution_recipe: Optional[dict[str, Any]], + fit_brief: Optional[dict[str, Any]], + external_recommendation: Optional[dict[str, Any]], + recommended_next_step: Optional[str], + answer: str, + evidence_pack: Optional[dict[str, Any]], + query_type: str, + clarification_code: Optional[str], + anchor_kind: Optional[str], + meta_followup_kind: Optional[str], + internal_context_packet: Any = None, + project_snapshot: Optional[dict[str, Any]] = None, + ) -> dict[str, Any]: + if not _env_bool(self._ENV_ENABLE_ANALYSIS_PACKET_FAMILY, True): + return {} + return self._normalize_analysis_packet( + build_analysis_packet( + route_decision=route_decision, + project_operating_context=project_operating_context, + execution_recipe=execution_recipe, + fit_brief=fit_brief, + external_recommendation=external_recommendation, + recommended_next_step=recommended_next_step, + answer=answer, + evidence_pack=evidence_pack, + query_type=query_type, + clarification_code=clarification_code, + anchor_kind=anchor_kind, + meta_followup_kind=meta_followup_kind, + internal_context_packet=internal_context_packet, + project_snapshot=project_snapshot, + ) + ) + @classmethod def _normalize_external_recommendation(cls, payload: Optional[dict[str, Any]]) -> dict[str, Any]: if not isinstance(payload, dict) or not payload: @@ -771,10 +852,12 @@ class AdaptiveRAGAnswerPayloadMixin: query_type: str, reason_code: Optional[str], missing_fields: Optional[list[str]] = None, + target_resolution: Optional[dict[str, Any]] = None, ) -> dict[str, Any]: normalized_query_type = str(query_type or QUERY_TYPE_UNKNOWN).strip().lower() normalized_reason = str(reason_code or "").strip().lower() or "internal_answer_contract_violation" normalized_missing = [str(item).strip() for item in (missing_fields or []) if str(item).strip()] + clarification_protocol = None if normalized_reason == "no_total_count_substitution" and normalized_query_type == QUERY_TYPE_EXISTENCE: answer = "当前内部查询结果与问题类型不一致,已阻止直接返回。你问的是“有没有”,请给我更明确的对象条件后我按存在性重新查询。" elif normalized_reason == "missing_internal_evidence": @@ -784,7 +867,14 @@ class AdaptiveRAGAnswerPayloadMixin: elif normalized_reason == "missing_internal_operation_contract": answer = "当前内部查询合同不完整,已停止直接回答。请补充更明确的对象或范围后我再查。" elif normalized_reason == "target_resolution_clarify": - answer = "当前内部查询对象仍有歧义,已停止直接回答。请先明确你要查的项目、配置或对象后我再继续。" + candidate_summary = list((target_resolution or {}).get("candidate_summary") or []) + clarification_protocol = build_internal_target_selection_protocol( + candidate_summary=candidate_summary, + ) + answer = ( + build_internal_target_clarification_text(candidate_summary=candidate_summary) + or "当前内部查询对象仍有歧义,已停止直接回答。请先明确你要查的项目、配置或对象后我再继续。" + ) elif normalized_reason == "missing_internal_query_plan_contract": answer = "当前内部查询计划不完整,已停止直接回答。请补充更明确的对象或范围后我再查。" else: @@ -819,6 +909,7 @@ class AdaptiveRAGAnswerPayloadMixin: "reason_code": normalized_reason, "answer_mode": answer_mode, "grounding_status": grounding_status, + "clarification_protocol": clarification_protocol, } def _build_response_payload( @@ -868,6 +959,9 @@ class AdaptiveRAGAnswerPayloadMixin: execution_recipe: Optional[dict[str, Any]] = None, fit_brief: Optional[dict[str, Any]] = None, external_recommendation: Optional[dict[str, Any]] = None, + analysis_packet: Optional[dict[str, Any]] = None, + internal_context_packet: Any = None, + signal_source_recommendation: Optional[dict[str, Any]] = None, ) -> dict[str, Any]: evidence: list[dict[str, Any]] = [] for item in sources[:8]: @@ -923,10 +1017,33 @@ class AdaptiveRAGAnswerPayloadMixin: 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) + normalized_signal_source_recommendation = self._normalize_signal_source_recommendation( + signal_source_recommendation, + project_id=normalized_project_operating_context.get("current_project_id") + if isinstance(normalized_project_operating_context, dict) + else None, + ) handoff_fields = self._build_plan_handoff_fields( execution_recipe=normalized_execution_recipe, project_operating_context=normalized_project_operating_context, ) + normalized_analysis_packet = self._normalize_analysis_packet(analysis_packet) + if not normalized_analysis_packet: + normalized_analysis_packet = self._build_analysis_packet_payload( + 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, + recommended_next_step=handoff_fields["recommended_next_step"], + answer=str(answer or "").strip(), + evidence_pack=normalized_evidence_pack, + query_type=str(query_type or QUERY_TYPE_UNKNOWN), + clarification_code=str(clarification_code or "").strip() or None, + anchor_kind=str(anchor_kind or "").strip() or None, + meta_followup_kind=str(meta_followup_kind or "").strip() or None, + internal_context_packet=internal_context_packet, + ) return { "answer": str(answer or "").strip(), "evidence": evidence, @@ -978,6 +1095,8 @@ class AdaptiveRAGAnswerPayloadMixin: "execution_recipe": normalized_execution_recipe, "fit_brief": normalized_fit_brief, "external_recommendation": normalized_external_recommendation, + "signal_source_recommendation": normalized_signal_source_recommendation, + "analysis_packet": normalized_analysis_packet, "handoff_mode": handoff_fields["handoff_mode"], "can_enter_plan": handoff_fields["can_enter_plan"], "recommended_next_step": handoff_fields["recommended_next_step"], diff --git a/backend/app/services/adaptive_rag_service/mixin_answer_precheck.py b/backend/app/services/adaptive_rag_service/mixin_answer_precheck.py index 9fdbde04..351f9490 100644 --- a/backend/app/services/adaptive_rag_service/mixin_answer_precheck.py +++ b/backend/app/services/adaptive_rag_service/mixin_answer_precheck.py @@ -279,6 +279,20 @@ class AdaptiveRAGAnswerPrecheckMixin: original_question=clean_q, previous_attempts=previous_attempts, ) + elif isinstance(clarification_payload.get("clarification_protocol"), dict): + protocol = dict(clarification_payload.get("clarification_protocol") or {}) + previous = self._get_pending_clarification(session_state=state_row) + previous_attempts = ( + int(previous.get("attempts") or 0) + if isinstance(previous, dict) and str(previous.get("kind") or "").strip().lower() == str(protocol.get("kind") or "").strip().lower() + else 0 + ) + self._store_pending_selection_protocol( + session_state=state_row, + original_question=clean_q, + protocol=protocol, + previous_attempts=previous_attempts, + ) else: self._clear_pending_clarification(session_state=state_row) await self._update_session_state_after_turn( 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 2ca0b879..57faf793 100644 --- a/backend/app/services/adaptive_rag_service/mixin_answer_retrieval.py +++ b/backend/app/services/adaptive_rag_service/mixin_answer_retrieval.py @@ -3,7 +3,7 @@ from __future__ import annotations from .shared import * from .shared_utils import _env_bool -from .internal_query_context import update_internal_query_context_packet +from .internal_query_context import internal_context_target_resolution, update_internal_query_context_packet class AdaptiveRAGAnswerRetrievalMixin: @@ -474,11 +474,42 @@ class AdaptiveRAGAnswerRetrievalMixin: if callable(build_plan_handoff_fields) else {} ) + load_analysis_project_snapshot = getattr(self, "_load_analysis_project_snapshot_for_packet", None) + analysis_project_snapshot = ( + await load_analysis_project_snapshot( + project_operating_context=project_operating_context, + fallback_project_id=project_id, + ) + if callable(load_analysis_project_snapshot) + else {} + ) + build_analysis_packet_payload = getattr(self, "_build_analysis_packet_payload", None) + analysis_packet = ( + build_analysis_packet_payload( + route_decision=normalized_route_decision, + project_operating_context=project_operating_context, + execution_recipe=execution_recipe, + fit_brief=fit_brief, + external_recommendation=external_recommendation, + recommended_next_step=plan_handoff_fields.get("recommended_next_step"), + answer="", + evidence_pack={}, + query_type=query_type if query_type != QUERY_TYPE_UNKNOWN else QUERY_TYPE_EXPLAIN, + clarification_code=None, + anchor_kind=None, + meta_followup_kind=None, + internal_context_packet=internal_context_packet, + project_snapshot=analysis_project_snapshot, + ) + if callable(build_analysis_packet_payload) + else {} + ) response_payload_snapshot = { "project_operating_context": project_operating_context, "execution_recipe": execution_recipe, "fit_brief": fit_brief, "external_recommendation": external_recommendation, + "analysis_packet": analysis_packet, "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"), @@ -524,6 +555,21 @@ class AdaptiveRAGAnswerRetrievalMixin: query_semantics=query_semantics, external_context_packet=external_context_packet, ) + external_presentation = ( + self._peek_external_answer_presentation() + if hasattr(self, "_peek_external_answer_presentation") + else {} + ) + signal_source_recommendation = ( + self._normalize_signal_source_recommendation( + external_presentation.get("recommendation_packet"), + project_id=project_id, + ) + if hasattr(self, "_normalize_signal_source_recommendation") + else {} + ) + if signal_source_recommendation: + response_payload_snapshot["signal_source_recommendation"] = signal_source_recommendation prompt_variant_id = self._peek_answer_prompt_variant() routing_snapshot = self._peek_last_answer_routing_snapshot() used_sources = self._dedupe_sources(active_retrieved) @@ -914,6 +960,10 @@ class AdaptiveRAGAnswerRetrievalMixin: query_type=query_type, reason_code=str(contract_verdict.get("reason_code") or ""), missing_fields=list(contract_verdict.get("missing_fields") or []), + target_resolution=internal_context_target_resolution( + packet=internal_context_packet, + fallback=(query_semantics or {}).get("target_resolution"), + ), ) if guard_payload: answer_text = str(guard_payload.get("answer") or answer_text) @@ -1003,6 +1053,32 @@ class AdaptiveRAGAnswerRetrievalMixin: tool_trace=tool_trace, retrieval_metrics=retrieval_metrics, ) + load_analysis_project_snapshot = getattr(self, "_load_analysis_project_snapshot_for_packet", None) + analysis_project_snapshot = ( + await load_analysis_project_snapshot( + project_operating_context=project_operating_context, + fallback_project_id=project_id, + ) + if callable(load_analysis_project_snapshot) + else {} + ) + if callable(build_analysis_packet_payload): + response_payload_snapshot["analysis_packet"] = build_analysis_packet_payload( + route_decision=normalized_route_decision, + project_operating_context=project_operating_context, + execution_recipe=execution_recipe, + fit_brief=fit_brief, + external_recommendation=external_recommendation, + recommended_next_step=plan_handoff_fields.get("recommended_next_step"), + answer=answer_text, + evidence_pack=runtime_fields.get("evidence_pack") if isinstance(runtime_fields, dict) else {}, + query_type=query_type if query_type != QUERY_TYPE_UNKNOWN else QUERY_TYPE_EXPLAIN, + clarification_code=None, + anchor_kind=None, + meta_followup_kind=None, + internal_context_packet=internal_context_packet, + project_snapshot=analysis_project_snapshot, + ) qa = QARecord( tenant_id=self.tenant_id, question=clean_q, @@ -1091,4 +1167,9 @@ class AdaptiveRAGAnswerRetrievalMixin: execution_recipe=execution_recipe, fit_brief=fit_brief, external_recommendation=external_recommendation, + signal_source_recommendation=response_payload_snapshot.get("signal_source_recommendation") + if isinstance(response_payload_snapshot, dict) + else None, + analysis_packet=analysis_packet, + internal_context_packet=internal_context_packet, ) 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 7266f612..350bb612 100644 --- a/backend/app/services/adaptive_rag_service/mixin_core_intent.py +++ b/backend/app/services/adaptive_rag_service/mixin_core_intent.py @@ -1,7 +1,10 @@ """Adaptive RAG mixin module.""" from __future__ import annotations +from app.schemas.analysis_packets import normalize_analysis_packet_payload from app.schemas.project_operating_context import ExecutionRecipe, ProjectFitBrief, ProjectOperatingContext +from app.services.ai_pmo.semantic_context.business_frame_schema import BusinessSemanticFrame +from app.services.external_query_service.schemas import normalize_signal_source_recommendation_payload from .shared import * from ..ai_pmo.workflow_semantic_planner import WorkflowSemanticPlanner @@ -22,7 +25,6 @@ class AdaptiveRAGCoreIntentMixin: _SEMANTIC_RETRIEVAL_DOMAINS = ("db", "graph", "doc", "feishu", "pmflow", "url") _INTERNAL_RETRIEVAL_DOMAINS = ("db", "graph", "doc", "feishu", "pmflow") _EXTERNAL_RETRIEVAL_DOMAINS = ("url",) - _SEMANTIC_SUBJECT_FOCI = {"people", "project", "task", "signal", "charter", "workload", "feature", "unknown"} _ALLOWED_SCOPE_VALUES = {"none", "global", "project"} _SEMANTIC_FEATURE_FOCI = {"none", "purpose", "entry", "catalog"} _SOURCE_POLICIES = {"internal", "external", "uncertain"} @@ -36,6 +38,7 @@ class AdaptiveRAGCoreIntentMixin: self.context_cache = context_cache_service self._active_external_qa_context: dict[str, Any] = {} self._last_answer_prompt_variant_id = "unknown" + self._last_external_answer_presentation: dict[str, Any] = {} self._query_semantics_runtime_cache: dict[str, dict[str, Any]] = {} @staticmethod @@ -229,6 +232,10 @@ class AdaptiveRAGCoreIntentMixin: "external_recommendation": self._normalize_external_recommendation_snapshot( payload.get("external_recommendation") ), + "analysis_packet": normalize_analysis_packet_payload(payload.get("analysis_packet")), + "signal_source_recommendation": normalize_signal_source_recommendation_payload( + payload.get("signal_source_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, @@ -344,6 +351,19 @@ class AdaptiveRAGCoreIntentMixin: def _peek_answer_prompt_variant(self) -> str: return str(getattr(self, "_last_answer_prompt_variant_id", None) or "").strip() or "unknown" + def _remember_external_answer_presentation(self, presentation: Optional[dict[str, Any]]) -> None: + normalized = dict(presentation or {}) if isinstance(presentation, dict) else {} + normalized_packet = normalize_signal_source_recommendation_payload(normalized.get("recommendation_packet")) + if normalized_packet: + normalized["recommendation_packet"] = normalized_packet + else: + normalized.pop("recommendation_packet", None) + self._last_external_answer_presentation = normalized + + def _peek_external_answer_presentation(self) -> dict[str, Any]: + payload = getattr(self, "_last_external_answer_presentation", None) + return dict(payload) if isinstance(payload, dict) else {} + def _build_policy_version( self, *, @@ -496,13 +516,6 @@ class AdaptiveRAGCoreIntentMixin: return uniq return list(cls._SEMANTIC_RETRIEVAL_DOMAINS) - @classmethod - def _normalize_semantic_subject_focus(cls, value: Any) -> str: - normalized = str(value or "").strip().lower() - if normalized in cls._SEMANTIC_SUBJECT_FOCI: - return normalized - return "unknown" - @classmethod def _normalize_semantic_scope_hint(cls, value: Any) -> str: normalized = str(value or "").strip().lower() @@ -524,23 +537,9 @@ class AdaptiveRAGCoreIntentMixin: return normalized return "uncertain" - @classmethod - def _normalize_query_profile_object(cls, value: Any) -> str: - normalized = re.sub(r"[^a-z0-9:_\-]", "", str(value or "").strip().lower()) - return normalized[:48] - @classmethod def _normalize_query_profile(cls, payload: Any) -> dict[str, Any]: raw = payload if isinstance(payload, dict) else {} - referenced_objects: list[str] = [] - for item in list(raw.get("referenced_objects") or []): - normalized = cls._normalize_query_profile_object(item) - if not normalized: - continue - if normalized not in referenced_objects: - referenced_objects.append(normalized) - if not referenced_objects: - referenced_objects = ["unknown"] try: confidence = max(0.0, min(1.0, float(raw.get("confidence") or 0.0))) except Exception: @@ -548,10 +547,18 @@ class AdaptiveRAGCoreIntentMixin: return { "needs_private_context": bool(raw.get("needs_private_context")), "needs_freshness": bool(raw.get("needs_freshness")), - "referenced_objects": referenced_objects, "confidence": confidence, } + @staticmethod + def _normalize_business_frame(payload: Any) -> dict[str, Any]: + if not isinstance(payload, dict): + return {} + try: + return BusinessSemanticFrame.model_validate(payload).model_dump() + except Exception: + return {} + @staticmethod def _normalize_semantic_object_mentions(payload: Any) -> list[str]: rows = payload if isinstance(payload, list) else [] @@ -696,13 +703,13 @@ class AdaptiveRAGCoreIntentMixin: query_type = QUERY_TYPE_UNKNOWN object_mentions = self._normalize_semantic_object_mentions(parsed.get("object_mentions")) - subject_focus = self._normalize_semantic_subject_focus(parsed.get("subject_focus")) scope_hint = self._normalize_semantic_scope_hint(parsed.get("scope_hint")) feature_focus = self._normalize_semantic_feature_focus(parsed.get("feature_focus")) parsed_domains = self._normalize_semantic_domains(parsed.get("retrieval_domains")) raw_source_policy = self._normalize_source_policy(parsed.get("source_policy")) raw_effective_source_policy = self._normalize_source_policy(parsed.get("effective_source_policy")) query_profile = self._normalize_query_profile(parsed.get("query_profile")) + business_frame = self._normalize_business_frame(parsed.get("business_frame")) normalized_hint = self._normalize_source_policy(source_policy_hint) fallback_taken = False if normalized_hint in {"internal", "external"}: @@ -749,10 +756,10 @@ class AdaptiveRAGCoreIntentMixin: "query_type": query_type, "object_mentions": object_mentions, "retrieval_domains": retrieval_domains, - "subject_focus": subject_focus, "scope_hint": scope_hint, "feature_focus": feature_focus, "query_profile": query_profile, + "business_frame": business_frame, "source_policy": source_policy, "effective_source_policy": effective_source_policy, "source_reason": source_reason, diff --git a/backend/app/services/adaptive_rag_service/mixin_deep_guard_answer.py b/backend/app/services/adaptive_rag_service/mixin_deep_guard_answer.py index a0747e28..1c1365bd 100644 --- a/backend/app/services/adaptive_rag_service/mixin_deep_guard_answer.py +++ b/backend/app/services/adaptive_rag_service/mixin_deep_guard_answer.py @@ -9,6 +9,7 @@ from .internal_query_context import ( internal_context_semantic_mirror, internal_context_target_resolution, ) +from .shared_utils import _structured_project_requested, _structured_runtime_entity_type from .shared import * @@ -158,21 +159,12 @@ class AdaptiveRAGDeepGuardAnswerMixin: ).strip().lower() if query_type not in {QUERY_TYPE_STATUS, QUERY_TYPE_EXPLAIN}: return None - query_profile = ( - route_decision.get("query_profile") - if isinstance(route_decision.get("query_profile"), dict) - else ( - semantics.get("query_profile") - if isinstance(semantics.get("query_profile"), dict) - else {} - ) + structured_entity_type = _structured_runtime_entity_type( + query_semantics=semantics, + operation_contract=operation_contract, + internal_context_packet=internal_context_packet, ) - referenced_objects = { - str(item).strip().lower() - for item in list(query_profile.get("referenced_objects") or []) - if str(item).strip() - } - if not referenced_objects.intersection({"system_config", "model_routing"}): + if structured_entity_type != "config": return None target_resolution = internal_context_target_resolution( packet=internal_context_packet, @@ -185,6 +177,50 @@ class AdaptiveRAGDeepGuardAnswerMixin: target_entity_type = str(target_resolution.get("target_entity_type") or "").strip().lower() if target_entity_type and target_entity_type != "config": return None + target_ref = str(target_resolution.get("target_ref") or "").strip().lower() + if target_ref.endswith("mcp_template_center"): + mcp_service_cls = globals().get("McpConfigService") + if mcp_service_cls is None: + from ..mcp_config import McpConfigService as mcp_service_cls + + try: + service = mcp_service_cls(self.db, tenant_id=self.tenant_id) + servers = list(await service.list_servers()) + except Exception: + return None + + total = len(servers) + if total <= 0: + answer = "当前系统里还没有保存 MCP 配置。" + else: + display_names = [ + str(item.get("display_name") or item.get("name") or "").strip() + for item in servers + if str(item.get("display_name") or item.get("name") or "").strip() + ][:3] + answer = f"当前系统里已有 {total} 个 MCP 配置。" + if display_names: + answer += f" 现在能看到的有:{'、'.join(display_names)}。" + return { + "answer": answer, + "sources": [ + { + "source": "db", + "label": "Integration[mcp_template_center]", + "score": 0.99, + "metadata": { + "integration_type": "mcp_template_center", + "config_count": total, + }, + } + ], + "query_type": QUERY_TYPE_STATUS, + "confidence": 0.99, + "reason_code": "internal_mcp_config_direct", + "prompt_variant_id": "internal_mcp_config_direct", + } + if target_ref and not target_ref.endswith("model_routing"): + return None get_bundle = getattr(self.llm, "_get_or_init_bundle", None) resolve_active_engine = getattr(self.llm, "_resolve_active_engine_from_bundle", None) @@ -607,20 +643,19 @@ class AdaptiveRAGDeepGuardAnswerMixin: "reason": "source_policy:external", } - subject_focus = str(semantics.get("subject_focus") or "unknown").strip().lower() feature_focus = str(semantics.get("feature_focus") or "none").strip().lower() scope_hint = str(semantics.get("scope_hint") or "none").strip().lower() normalized_query_type = str(query_type or QUERY_TYPE_UNKNOWN).strip().lower() + structured_entity_type = _structured_runtime_entity_type(query_semantics=semantics) + project_requested = _structured_project_requested(query_semantics=semantics) scene_key = str(fallback["scene_key"]) - if subject_focus == "workload": - scene_key = "member_workload" - elif subject_focus == "project" and normalized_query_type == QUERY_TYPE_STATUS: + if project_requested and normalized_query_type == QUERY_TYPE_STATUS: scene_key = "project_status" if scene_key not in self._ANSWER_SCENE_KEYS: scene_key = str(fallback["scene_key"]) - snapshot_focus = "feature" if (subject_focus == "feature" or feature_focus != "none") else str(fallback["snapshot_focus"]) + snapshot_focus = "feature" if (structured_entity_type == "feature" or feature_focus != "none") else str(fallback["snapshot_focus"]) if snapshot_focus not in self._SNAPSHOT_FOCI: snapshot_focus = str(fallback["snapshot_focus"]) confidence = 0.82 if scene_key != str(fallback["scene_key"]) or snapshot_focus != str(fallback["snapshot_focus"]) or scope_hint == "project" else 0.6 @@ -1197,6 +1232,9 @@ class AdaptiveRAGDeepGuardAnswerMixin: ) -> str: active_thinking_mode = self._normalize_thinking_mode(thinking_mode) deep_thinking = active_thinking_mode == THINKING_MODE_DEEP + remember_external_presentation = getattr(self, "_remember_external_answer_presentation", None) + if callable(remember_external_presentation): + remember_external_presentation({}) if self._should_use_external_answer_presenter( query_semantics=query_semantics, external_context_packet=external_context_packet, @@ -1211,6 +1249,8 @@ class AdaptiveRAGDeepGuardAnswerMixin: remember_prompt_variant = getattr(self, "_remember_answer_prompt_variant", None) if callable(remember_prompt_variant): remember_prompt_variant(str(presentation.get("prompt_variant_id") or "external_unknown")) + if callable(remember_external_presentation): + remember_external_presentation(presentation) return str(presentation.get("answer_text") or "").strip()[:3200] context_blocks: list[str] = [] used_tokens = 0 diff --git a/backend/app/services/adaptive_rag_service/mixin_deterministic_query_core.py b/backend/app/services/adaptive_rag_service/mixin_deterministic_query_core.py index 7f451742..464caef4 100644 --- a/backend/app/services/adaptive_rag_service/mixin_deterministic_query_core.py +++ b/backend/app/services/adaptive_rag_service/mixin_deterministic_query_core.py @@ -7,24 +7,31 @@ from .internal_query_context import ( internal_context_semantic_mirror, internal_context_target_resolution, ) +from .shared_utils import _structured_project_requested class AdaptiveRAGDeterministicQueryCoreMixin: - @staticmethod def _should_defer_project_existence_to_semantic_snapshot( + self, *, query_type: str, query_semantics: Optional[dict[str, Any]] = None, + internal_context_packet: Any = None, ) -> bool: - semantics = query_semantics if isinstance(query_semantics, dict) else {} normalized_query_type = str(query_type or QUERY_TYPE_UNKNOWN).strip().lower() if normalized_query_type not in {QUERY_TYPE_EXISTENCE, QUERY_TYPE_STATUS}: return False - subject_focus = str(semantics.get("subject_focus") or "").strip().lower() + semantics = query_semantics if isinstance(query_semantics, dict) else {} source_policy = str( semantics.get("effective_source_policy") or semantics.get("source_policy") or "" ).strip().lower() - return subject_focus == "project" and source_policy == "internal" + return ( + source_policy == "internal" + and _structured_project_requested( + query_semantics=semantics, + internal_context_packet=internal_context_packet, + ) + ) async def _run_deterministic_query( self, @@ -51,6 +58,7 @@ class AdaptiveRAGDeterministicQueryCoreMixin: defer_project_existence = self._should_defer_project_existence_to_semantic_snapshot( query_type=q_type, query_semantics=query_semantics, + internal_context_packet=internal_context_packet, ) if q_type == QUERY_TYPE_COUNT: return await self._run_count_query( diff --git a/backend/app/services/adaptive_rag_service/mixin_deterministic_query_count.py b/backend/app/services/adaptive_rag_service/mixin_deterministic_query_count.py index 8bf1a5c9..67024103 100644 --- a/backend/app/services/adaptive_rag_service/mixin_deterministic_query_count.py +++ b/backend/app/services/adaptive_rag_service/mixin_deterministic_query_count.py @@ -1,6 +1,7 @@ """Adaptive RAG mixin module.""" from __future__ import annotations +from .shared_utils import _structured_runtime_entity_type from .shared import * @@ -15,12 +16,15 @@ class AdaptiveRAGDeterministicQueryCountMixin: ) -> Optional[dict[str, Any]]: text = str(question or "").lower() started = time.perf_counter() - subject_focus = self._query_subject_focus(query_semantics) + entity_type = _structured_runtime_entity_type( + query_semantics=query_semantics, + internal_context_packet=internal_context_packet, + ) scope_hint = self._query_scope_hint(query_semantics) project_scope_required = scope_hint == "project" or project_id is not None # 先处理更具体的对象(信号/任务),避免“xxx项目里有多少信号”误落到项目总数分支。 - if subject_focus == "signal": + if entity_type == "signal": scoped_project_id, scoped_project_name, scoped_keyword = await self._resolve_project_scope_for_question( question=text, project_id=project_id, @@ -76,7 +80,7 @@ class AdaptiveRAGDeterministicQueryCountMixin: "missing_fields": [], } - if subject_focus == "task": + if entity_type == "task": scoped_project_id, scoped_project_name, scoped_keyword = await self._resolve_project_scope_for_question( question=text, project_id=project_id, @@ -132,7 +136,7 @@ class AdaptiveRAGDeterministicQueryCountMixin: "missing_fields": [], } - if subject_focus == "people": + if entity_type == "user": scoped_project_id: Optional[int] = None scoped_project_name: Optional[str] = None scoped_keyword = "" @@ -203,7 +207,7 @@ class AdaptiveRAGDeterministicQueryCountMixin: "missing_fields": [], } - if subject_focus == "project": + if entity_type == "project": stmt = select(func.count(Project.id)).where(Project.tenant_id == self.tenant_id) if project_id is not None: stmt = stmt.where(Project.id == int(project_id)) diff --git a/backend/app/services/adaptive_rag_service/mixin_deterministic_query_list.py b/backend/app/services/adaptive_rag_service/mixin_deterministic_query_list.py index 3cf4a02e..448d7305 100644 --- a/backend/app/services/adaptive_rag_service/mixin_deterministic_query_list.py +++ b/backend/app/services/adaptive_rag_service/mixin_deterministic_query_list.py @@ -1,6 +1,7 @@ """Adaptive RAG mixin module.""" from __future__ import annotations +from .shared_utils import _structured_runtime_entity_type from .shared import * @@ -15,11 +16,14 @@ class AdaptiveRAGDeterministicQueryListMixin: ) -> Optional[dict[str, Any]]: text = str(question or "").lower() started = time.perf_counter() - subject_focus = self._query_subject_focus(query_semantics) + entity_type = _structured_runtime_entity_type( + query_semantics=query_semantics, + internal_context_packet=internal_context_packet, + ) scope_hint = self._query_scope_hint(query_semantics) project_scope_required = scope_hint == "project" or project_id is not None - if subject_focus == "people": + if entity_type == "user": scoped_project_id: Optional[int] = None scoped_project_name: Optional[str] = None scoped_keyword = "" @@ -158,7 +162,7 @@ class AdaptiveRAGDeterministicQueryListMixin: "missing_fields": [], } - if subject_focus == "signal": + if entity_type == "signal": scoped_project_id, scoped_project_name, scoped_keyword = await self._resolve_project_scope_for_question( question=text, project_id=project_id, diff --git a/backend/app/services/adaptive_rag_service/mixin_fast_answer_project_main.py b/backend/app/services/adaptive_rag_service/mixin_fast_answer_project_main.py index 60146a3b..5d623afc 100644 --- a/backend/app/services/adaptive_rag_service/mixin_fast_answer_project_main.py +++ b/backend/app/services/adaptive_rag_service/mixin_fast_answer_project_main.py @@ -5,6 +5,7 @@ import html from ...config.ai_prompts import PROMPT_PROJECT_WEEKLY_REPORT_JSON from ..prompt_release import PromptReleaseService +from .shared_utils import _structured_project_requested from .shared import * from .internal_query_context import ( internal_context_operation_contract, @@ -26,14 +27,40 @@ class AdaptiveRAGFastAnswerProjectMainMixin: focus = str(semantics.get("status_focus") or semantics.get("status_scene") or "").strip().lower() if focus in {"created_at", "progress", "snapshot", "risk", "risk_or_blocker"}: return focus + business_frame = semantics.get("business_frame") if isinstance(semantics.get("business_frame"), dict) else {} + goal_kind = str(business_frame.get("goal_kind") or "").strip().lower() + expected_answer_shape = str(business_frame.get("expected_answer_shape") or "").strip().lower() + if goal_kind == "impact_judgment" or expected_answer_shape == "impact_chain": + return "risk_or_blocker" return "" @staticmethod - def _is_project_status_subject(query_semantics: Optional[dict[str, Any]] = None) -> bool: + def _is_project_status_subject( + query_semantics: Optional[dict[str, Any]] = None, + *, + internal_context_packet: Any = None, + ) -> bool: semantics = query_semantics if isinstance(query_semantics, dict) else {} - subject_focus = str(semantics.get("subject_focus") or "").strip().lower() - target_type = str(semantics.get("target_entity_type") or "").strip().lower() - return subject_focus in {"project", "system"} or target_type == "project" + return _structured_project_requested( + query_semantics=semantics, + internal_context_packet=internal_context_packet, + ) + + @staticmethod + def _should_bypass_project_progress_fast_answer( + query_semantics: Optional[dict[str, Any]] = None, + ) -> bool: + semantics = query_semantics if isinstance(query_semantics, dict) else {} + interaction_level = str(semantics.get("interaction_level") or "").strip().lower() + goal_kind = str(semantics.get("goal_kind") or "").strip().lower() + resource_role = str(semantics.get("resource_role") or "").strip().lower() + expected_answer_shape = str(semantics.get("expected_answer_shape") or "").strip().lower() + return ( + interaction_level in {"recommendation", "draft", "execute", "clarify"} + or goal_kind in {"recommendation", "prepare_write", "confirm_write", "execute_write", "clarify"} + or resource_role == "signal_source" + or expected_answer_shape in {"inventory", "ranked_candidates", "write_confirmation", "write_result"} + ) @staticmethod def _format_project_report_time(value: Optional[datetime]) -> str: @@ -454,6 +481,7 @@ class AdaptiveRAGFastAnswerProjectMainMixin: snapshot: dict[str, Any], fields: dict[str, Any], external_items: list[str], + emphasize_direct_impact: bool = False, ) -> str: fallback_actions = cls._extract_project_report_actions(fields.get("actions")) action_this_week = fallback_actions.get("this_week") or "先补齐本周必须推进的动作。" @@ -491,6 +519,9 @@ class AdaptiveRAGFastAnswerProjectMainMixin: lines.append(f"对项目的直接影响:{external_impact}") else: lines.append("暂无高相关的外部新变化。") + external_impact = str(fields.get("external_impact") or "").strip() + if emphasize_direct_impact and external_impact: + lines.append(f"对项目的直接影响:{external_impact}") lines.extend( [ "", @@ -515,10 +546,20 @@ class AdaptiveRAGFastAnswerProjectMainMixin: question = str(clean_q or "").strip() if not question: return None + if self._should_bypass_project_progress_fast_answer(query_semantics): + return None focus = self._normalize_project_status_focus(query_semantics) if focus not in {"progress", "snapshot", "risk", "risk_or_blocker"}: return None - if project_id is None and not self._is_project_status_subject(query_semantics): + business_frame = query_semantics.get("business_frame") if isinstance(query_semantics, dict) and isinstance(query_semantics.get("business_frame"), dict) else {} + emphasize_direct_impact = ( + str(business_frame.get("goal_kind") or "").strip().lower() == "impact_judgment" + or str(business_frame.get("expected_answer_shape") or "").strip().lower() == "impact_chain" + ) + if project_id is None and not self._is_project_status_subject( + query_semantics, + internal_context_packet=internal_context_packet, + ): return None asks_risk_or_blockers = focus in {"risk", "risk_or_blocker"} @@ -630,6 +671,7 @@ class AdaptiveRAGFastAnswerProjectMainMixin: snapshot=snapshot, fields=merged_fields, external_items=external_items, + emphasize_direct_impact=emphasize_direct_impact, ) response_time_ms = int((time.perf_counter() - started) * 1000) sources = list(db_sources) @@ -668,7 +710,10 @@ class AdaptiveRAGFastAnswerProjectMainMixin: return None if self._normalize_project_status_focus(query_semantics) != "created_at": return None - if project_id is None and not self._is_project_status_subject(query_semantics): + if project_id is None and not self._is_project_status_subject( + query_semantics, + internal_context_packet=internal_context_packet, + ): return None started = time.perf_counter() @@ -773,7 +818,6 @@ class AdaptiveRAGFastAnswerProjectMainMixin: if not question: return None semantics = query_semantics if isinstance(query_semantics, dict) else {} - subject_focus = self._query_subject_focus(semantics) scope_hint = self._query_scope_hint(semantics) message_intent = str(semantics.get("message_intent") or semantics.get("intent") or "").strip().lower() target_resolution = internal_context_target_resolution( @@ -826,7 +870,11 @@ class AdaptiveRAGFastAnswerProjectMainMixin: if ( project_id is None and message_intent != "project_lookup_question" - and subject_focus not in {"project", "unknown"} + and not _structured_project_requested( + query_semantics=semantics, + target_resolution=target_resolution, + internal_context_packet=internal_context_packet, + ) and scope_hint != "project" ): return None diff --git a/backend/app/services/adaptive_rag_service/mixin_fast_answer_project_utils.py b/backend/app/services/adaptive_rag_service/mixin_fast_answer_project_utils.py index 97d58eee..d5ad6951 100644 --- a/backend/app/services/adaptive_rag_service/mixin_fast_answer_project_utils.py +++ b/backend/app/services/adaptive_rag_service/mixin_fast_answer_project_utils.py @@ -10,7 +10,6 @@ from .shared import * class AdaptiveRAGFastAnswerProjectUtilsMixin: - _SEMANTIC_SUBJECT_FOCI = {"people", "project", "task", "signal", "charter", "workload", "feature", "unknown"} _ALLOWED_SCOPE_VALUES = {"none", "global", "project"} _SEMANTIC_FEATURE_FOCI = {"none", "purpose", "entry", "catalog"} _LOOKUP_TEXT_TRANSLATION = str.maketrans( @@ -217,14 +216,6 @@ class AdaptiveRAGFastAnswerProjectUtilsMixin: break return (deduped, used_keyword) - @classmethod - def _query_subject_focus(cls, query_semantics: Optional[dict[str, Any]]) -> str: - payload = query_semantics if isinstance(query_semantics, dict) else {} - normalized = str(payload.get("subject_focus") or "").strip().lower() - if normalized in cls._SEMANTIC_SUBJECT_FOCI: - return normalized - return "unknown" - @classmethod def _query_scope_hint(cls, query_semantics: Optional[dict[str, Any]]) -> str: payload = query_semantics if isinstance(query_semantics, dict) else {} diff --git a/backend/app/services/adaptive_rag_service/mixin_fast_answer_semantic_p1.py b/backend/app/services/adaptive_rag_service/mixin_fast_answer_semantic_p1.py index 90a90e39..ce1f6ee4 100644 --- a/backend/app/services/adaptive_rag_service/mixin_fast_answer_semantic_p1.py +++ b/backend/app/services/adaptive_rag_service/mixin_fast_answer_semantic_p1.py @@ -7,6 +7,7 @@ from .internal_query_context import ( internal_context_query_plan, internal_context_semantic_mirror, ) +from .shared_utils import _structured_runtime_entity_type from .shared import * @@ -111,10 +112,15 @@ class AdaptiveRAGFastAnswerSemanticMixinP1: key="query_plan", ), ) - subject_focus = self._query_subject_focus(query_semantics) - if subject_focus in {"people", "feature", "workload"}: + structured_entity_type = _structured_runtime_entity_type( + query_semantics=semantics, + operation_contract=operation_contract, + query_plan=query_plan, + internal_context_packet=internal_context_packet, + ) + if structured_entity_type in {"user", "feature"}: return None - if subject_focus not in {"project", "task", "signal", "charter", "unknown"}: + if structured_entity_type not in {"project", "task", "signal", "doc", "unknown"}: return None query_type = str( 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 fa600bc6..dfad6c0d 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 @@ -2,7 +2,7 @@ from __future__ import annotations from .shared import * -from .internal_query_context import update_internal_query_context_packet +from .internal_query_context import internal_context_target_resolution, update_internal_query_context_packet class AdaptiveRAGFastAnswerSemanticMixinP2: @@ -81,6 +81,10 @@ class AdaptiveRAGFastAnswerSemanticMixinP2: query_type=final_query_type, reason_code=str(contract_verdict.get("reason_code") or ""), missing_fields=list(contract_verdict.get("missing_fields") or []), + target_resolution=internal_context_target_resolution( + packet=internal_context_packet, + fallback=(query_semantics or {}).get("target_resolution"), + ), ) answer_text = str(guard_payload.get("answer") or answer_text) sources = list(guard_payload.get("sources") or []) @@ -145,9 +149,39 @@ class AdaptiveRAGFastAnswerSemanticMixinP2: if callable(build_plan_handoff_fields) else {} ) + build_analysis_packet_payload = getattr(self, "_build_analysis_packet_payload", None) + load_analysis_project_snapshot = getattr(self, "_load_analysis_project_snapshot_for_packet", None) + analysis_project_snapshot = ( + await load_analysis_project_snapshot( + project_operating_context=project_operating_context, + fallback_project_id=project_id, + ) + if callable(load_analysis_project_snapshot) + else {} + ) response_payload_snapshot = { "project_operating_context": project_operating_context, "execution_recipe": execution_recipe, + "analysis_packet": ( + build_analysis_packet_payload( + route_decision=route_decision, + project_operating_context=project_operating_context, + execution_recipe=execution_recipe, + fit_brief=None, + external_recommendation=None, + recommended_next_step=plan_handoff_fields.get("recommended_next_step"), + answer=answer_text, + evidence_pack={}, + query_type=final_query_type, + clarification_code=None, + anchor_kind=None, + meta_followup_kind=None, + internal_context_packet=internal_context_packet, + project_snapshot=analysis_project_snapshot, + ) + if callable(build_analysis_packet_payload) + else {} + ), "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"), @@ -235,4 +269,6 @@ class AdaptiveRAGFastAnswerSemanticMixinP2: route_decision=route_decision, project_operating_context=project_operating_context, execution_recipe=execution_recipe, + analysis_packet=response_payload_snapshot.get("analysis_packet") if isinstance(response_payload_snapshot, dict) else None, + internal_context_packet=internal_context_packet, ) 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 a6cfe616..9ea46297 100644 --- a/backend/app/services/adaptive_rag_service/mixin_followup_anchor.py +++ b/backend/app/services/adaptive_rag_service/mixin_followup_anchor.py @@ -3,7 +3,6 @@ from __future__ import annotations from dataclasses import dataclass, field from datetime import timedelta -import unicodedata from .shared import * @@ -52,38 +51,6 @@ class AdaptiveRAGFollowupAnchorMixin: return shifted return normalized - @staticmethod - def _normalize_followup_selection_text(text: str) -> str: - normalized = re.sub(r"\s+", "", str(text or "")).strip().lower() - normalized = re.sub(r"[^\w\u4e00-\u9fff]", "", normalized) - return normalized[:120] - - @staticmethod - def _parse_followup_choice_index(text: str) -> int: - 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) - except Exception: - return 0 - return parsed if parsed > 0 else 0 - if len(compact) == 1: - try: - numeric = unicodedata.numeric(compact) - except Exception: - return 0 - if float(numeric).is_integer() and numeric > 0: - return int(numeric) - return 0 - @staticmethod def _normalize_followup_preview(text: str) -> str: cleaned = re.sub(r"]*>.*?", "", str(text or ""), flags=re.IGNORECASE) @@ -209,7 +176,6 @@ class AdaptiveRAGFollowupAnchorMixin: "anchor_reply_preview": answer_preview, "created_at": created_at.isoformat() if created_at else None, "relative_time": self._candidate_relative_time(created_at), - "match_text": self._normalize_followup_selection_text(label or question), } async def _load_explicit_reply_anchor( @@ -280,7 +246,6 @@ class AdaptiveRAGFollowupAnchorMixin: "anchor_reply_preview": preview[:56] + ("..." if len(preview) > 56 else ""), "created_at": created_at.isoformat() if created_at else None, "relative_time": self._candidate_relative_time(created_at), - "match_text": self._normalize_followup_selection_text(previous_question or label), "source_message_id": source_message_id or None, "sent_message_id": str(outbox.sent_message_id or "").strip() or None, "anchor_kind": "feishu_reply_outbox", @@ -347,7 +312,6 @@ class AdaptiveRAGFollowupAnchorMixin: "anchor_reply_preview": preview[:56] + ("..." if len(preview) > 56 else ""), "created_at": created_at.isoformat() if created_at else None, "relative_time": self._candidate_relative_time(created_at), - "match_text": self._normalize_followup_selection_text(previous_question or label), "source_message_id": event_key, "sent_message_id": None, "anchor_kind": "ingest_closure_turns", @@ -416,7 +380,6 @@ class AdaptiveRAGFollowupAnchorMixin: "index": int(candidate.get("index") or 0), "qa_id": int(candidate.get("qa_id") or 0), "label": str(candidate.get("label") or "").strip()[:80], - "match_text": str(candidate.get("match_text") or "").strip()[:120], "question": str(candidate.get("question") or "").strip()[:180], } for candidate in candidates[:FOLLOWUP_CANDIDATE_LIMIT] @@ -453,6 +416,56 @@ class AdaptiveRAGFollowupAnchorMixin: { "key": str(item.get("key") or "").strip().upper(), "label": str(item.get("label") or "").strip()[:80], + "query_semantics_overrides": ( + dict(item.get("query_semantics_overrides") or {}) + if isinstance(item.get("query_semantics_overrides"), dict) + else {} + ), + } + for item in options + if isinstance(item, dict) + and str(item.get("key") or "").strip() + and str(item.get("label") or "").strip() + ], + } + session_state.pending_clarification_expires_at = now_cn_naive() + timedelta( + minutes=FOLLOWUP_RECENT_REPLY_WINDOW_MINUTES + ) + + def _store_pending_selection_protocol( + self, + *, + session_state: Optional[QASessionState], + original_question: str, + protocol: Optional[dict[str, Any]], + previous_attempts: int = 0, + ) -> None: + if session_state is None: + return + payload = protocol if isinstance(protocol, dict) else {} + kind = str(payload.get("kind") or "").strip().lower() + option_map = payload.get("option_map") if isinstance(payload.get("option_map"), dict) else {} + options = payload.get("options") if isinstance(payload.get("options"), list) else [] + if not kind or not option_map or not options: + return + session_state.pending_clarification = { + "kind": kind, + "original_question": str(original_question or "").strip()[:500], + "attempts": int(previous_attempts) + 1, + "option_map": { + str(key or "").strip().upper(): str(value or "").strip() + for key, value in option_map.items() + if str(key or "").strip() and str(value or "").strip() + }, + "options": [ + { + "key": str(item.get("key") or "").strip().upper(), + "label": str(item.get("label") or "").strip()[:80], + "query_semantics_overrides": ( + dict(item.get("query_semantics_overrides") or {}) + if isinstance(item.get("query_semantics_overrides"), dict) + else {} + ), } for item in options if isinstance(item, dict) @@ -476,7 +489,86 @@ class AdaptiveRAGFollowupAnchorMixin: return None return payload - def _consume_pending_clarification_selection( + async def _resolve_pending_clarification_selection_semantics( + self, + *, + question: str, + payload: dict[str, Any], + ) -> dict[str, Any]: + text = str(question or "").strip() + if not text or not isinstance(payload, dict): + return { + "selection_status": "unresolved", + "selected_key": "", + "selected_index": 0, + "confidence": 0.0, + "reason": "", + } + + kind = str(payload.get("kind") or "").strip().lower() + options = payload.get("options") if isinstance(payload.get("options"), list) else [] + candidates = payload.get("candidates") if isinstance(payload.get("candidates"), list) else [] + prompt = PROMPT_RAG_PENDING_CLARIFICATION_SELECTION_SEMANTICS.format( + current_message=text[:600], + clarification_kind=kind or "unknown", + original_question=str(payload.get("original_question") or "").strip()[:600], + options=json.dumps( + [ + { + "key": str(option.get("key") or "").strip().upper(), + "label": str(option.get("label") or "").strip()[:120], + } + for option in options[:FOLLOWUP_CANDIDATE_LIMIT] + if isinstance(option, dict) + ], + ensure_ascii=False, + )[:2400], + candidates=json.dumps( + [ + { + "index": int(candidate.get("index") or 0), + "label": str(candidate.get("label") or "").strip()[:80], + "question": str(candidate.get("question") or "").strip()[:180], + } + for candidate in candidates[:FOLLOWUP_CANDIDATE_LIMIT] + if isinstance(candidate, dict) + ], + ensure_ascii=False, + )[:2400], + ).strip() + + parsed: dict[str, Any] = {} + try: + raw = await self.llm.generate( + prompt, + db=self.db, + tenant_id=self.tenant_id, + function_name="rag_pending_clarification_selection_semantics", + temperature=0.0, + max_tokens=200, + use_prompt_profile=False, + ) + parser = getattr(self, "_parse_json_object_safe", None) + parsed = parser(raw) if callable(parser) else {} + parsed = dict(parsed) if isinstance(parsed, dict) else {} + except Exception: + parsed = {} + + selection_status = str(parsed.get("selection_status") or "unresolved").strip().lower() + if selection_status not in {"resolved", "unresolved", "new_topic"}: + selection_status = "unresolved" + selected_key = str(parsed.get("selected_key") or "").strip().upper() + if not selected_key: + selected_key = "" + return { + "selection_status": selection_status, + "selected_key": selected_key, + "selected_index": self._normalize_followup_candidate_index(parsed.get("selected_index")), + "confidence": self._safe_float(parsed.get("confidence"), 0.0), + "reason": str(parsed.get("reason") or "").strip(), + } + + async def _consume_pending_clarification_selection( self, *, question: str, @@ -487,40 +579,51 @@ class AdaptiveRAGFollowupAnchorMixin: return None text = str(question or "").strip() kind = str(payload.get("kind") or "").strip().lower() - if kind == "query_route_selection": - option_map = payload.get("option_map") if isinstance(payload.get("option_map"), dict) else {} - options = payload.get("options") if isinstance(payload.get("options"), list) else [] - normalized = self._normalize_followup_selection_text(text) - selected_hint = "" - if len(text) == 1: - selected_hint = str(option_map.get(text.upper()) or "").strip().lower() - if not selected_hint: - matched: list[str] = [] - for option in options: - if not isinstance(option, dict): - continue - key = str(option.get("key") or "").strip().upper() - label = str(option.get("label") or "").strip() - normalized_label = self._normalize_followup_selection_text(label) - if not normalized or not normalized_label: - continue - if normalized in normalized_label or normalized_label in normalized: - mapped = str(option_map.get(key) or "").strip().lower() - if mapped and mapped not in matched: - matched.append(mapped) - if len(matched) == 1: - selected_hint = matched[0] - if selected_hint in {"internal", "external"}: + semantics = await self._resolve_pending_clarification_selection_semantics( + question=text, + payload=payload, + ) + if str(semantics.get("selection_status") or "").strip().lower() == "new_topic": + self._clear_pending_clarification(session_state=session_state) + return { + "effective_question": text, + "clear_pending_only": True, + } + option_map = payload.get("option_map") if isinstance(payload.get("option_map"), dict) else {} + options = payload.get("options") if isinstance(payload.get("options"), list) else [] + if option_map and options: + selected_key = str(semantics.get("selected_key") or "").strip().upper() + selected_value = str(option_map.get(selected_key) or "").strip() + if selected_value: + selected_option = next( + ( + option + for option in options + if isinstance(option, dict) and str(option.get("key") or "").strip().upper() == selected_key + ), + {}, + ) + overrides = ( + dict(selected_option.get("query_semantics_overrides") or {}) + if isinstance(selected_option.get("query_semantics_overrides"), dict) + else {} + ) + selected_hint = str( + overrides.get("effective_source_policy") + or overrides.get("source_policy") + or selected_value + or "" + ).strip().lower() self._clear_pending_clarification(session_state=session_state) return { "effective_question": str(payload.get("original_question") or "").strip() or text, - "source_policy_hint": selected_hint, + "source_policy_hint": selected_hint if selected_hint in {"internal", "external"} else None, + "query_semantics_overrides": overrides, } return None - index = self._parse_followup_choice_index(text) candidates = payload.get("candidates") if isinstance(payload.get("candidates"), list) else [] - normalized = self._normalize_followup_selection_text(text) + index = self._normalize_followup_candidate_index(semantics.get("selected_index")) selected: Optional[dict[str, Any]] = None for candidate in candidates: if not isinstance(candidate, dict): @@ -529,10 +632,6 @@ class AdaptiveRAGFollowupAnchorMixin: if index and candidate_index == index: selected = candidate break - match_text = str(candidate.get("match_text") or "").strip() - if normalized and match_text and (normalized in match_text or match_text in normalized): - selected = candidate - break if selected is None: return None self._clear_pending_clarification(session_state=session_state) @@ -548,6 +647,7 @@ class AdaptiveRAGFollowupAnchorMixin: session_id: Optional[int], context_session_id: Optional[int], session_state: Optional[QASessionState], + selected_anchor_reply_id: Optional[int] = None, conversation_scope_key: Optional[str] = None, source_chat_id: Optional[str] = None, source_sender_open_id: Optional[str] = None, @@ -600,22 +700,27 @@ class AdaptiveRAGFollowupAnchorMixin: is_referential_followup = True anchor_strategy = "explicit" + if selected_anchor_reply_id: + anchor_row = await self.db.get(QARecord, int(selected_anchor_reply_id)) + if anchor_row is not None and int(getattr(anchor_row, "tenant_id", 0) or 0) == int(self.tenant_id): + if followup_mode == "none": + followup_mode = "referential" + is_referential_followup = True + selected_candidate = self._build_followup_candidate(index=1, row=anchor_row) + return FollowupAnchorResolution( + followup_mode=followup_mode, + anchor_mode="clarification_selection", + anchor_kind="clarification_selected_reply", + anchor_reply_id=int(anchor_row.id), + anchor_session_id=active_session_id, + is_meta_followup=is_meta_followup, + meta_followup_kind=meta_followup_kind, + anchor_reply=selected_candidate, + ) + if followup_mode == "none": return FollowupAnchorResolution() - normalized = self._normalize_followup_selection_text(text) - matching = [ - candidate - for candidate in candidates - if normalized - and str(candidate.get("match_text") or "").strip() - and ( - normalized in str(candidate.get("match_text") or "").strip() - or str(candidate.get("match_text") or "").strip() in normalized - ) - ] - chosen = matching[0] if len(matching) == 1 else None - if explicit_anchor is not None and anchor_strategy in {"explicit", "implicit_recent"}: return FollowupAnchorResolution( followup_mode=followup_mode, @@ -628,19 +733,7 @@ class AdaptiveRAGFollowupAnchorMixin: anchor_reply=explicit_anchor, ) - if chosen is not None: - return FollowupAnchorResolution( - followup_mode=followup_mode, - anchor_mode="explicit", - anchor_kind="recent_scope_reply", - anchor_reply_id=int(chosen["qa_id"]), - anchor_session_id=active_session_id, - is_meta_followup=is_meta_followup, - meta_followup_kind=meta_followup_kind, - anchor_reply=chosen, - ) - - if is_meta_followup and explicit_anchor is None and chosen is None and not candidates: + if is_meta_followup and explicit_anchor is None and not candidates: return FollowupAnchorResolution() if not candidates: diff --git a/backend/app/services/adaptive_rag_service/mixin_manifest_feature_p2.py b/backend/app/services/adaptive_rag_service/mixin_manifest_feature_p2.py index 1de2f6f6..e0449652 100644 --- a/backend/app/services/adaptive_rag_service/mixin_manifest_feature_p2.py +++ b/backend/app/services/adaptive_rag_service/mixin_manifest_feature_p2.py @@ -1,6 +1,7 @@ """Adaptive RAG mixin module.""" from __future__ import annotations +from .shared_utils import _structured_runtime_entity_type from .shared import * @@ -18,9 +19,8 @@ class AdaptiveRAGManifestFeatureMixinP2: normalized_scene = scene_semantics if isinstance(scene_semantics, dict) else {} normalized_query = query_semantics if isinstance(query_semantics, dict) else {} snapshot_focus = str(normalized_scene.get("snapshot_focus") or "none").strip().lower() - subject_focus = self._query_subject_focus(normalized_query) - feature_focus = self._query_feature_focus(normalized_query) - if snapshot_focus != "feature" and subject_focus != "feature" and feature_focus == "none": + structured_entity_type = _structured_runtime_entity_type(query_semantics=normalized_query) + if snapshot_focus != "feature" and structured_entity_type != "feature": return answer_text evidence_rows = self._collect_feature_evidence( 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 index d3af3a95..1e748df6 100644 --- a/backend/app/services/adaptive_rag_service/mixin_project_fit_brief.py +++ b/backend/app/services/adaptive_rag_service/mixin_project_fit_brief.py @@ -108,6 +108,59 @@ class AdaptiveRAGProjectFitBriefMixin: break return anchors[:max_items] + @staticmethod + def _build_signal_source_objective_from_task(*, title: Any, description: Any) -> str: + title_text = re.sub(r"\s+", " ", str(title or "")).strip() + description_text = re.sub(r"\s+", " ", str(description or "")).strip() + if not title_text and not description_text: + return "" + clauses = [ + re.sub(r"\s+", " ", str(item or "")).strip("::;;,,。 ") + for item in re.split(r"[;;,,。]", description_text) + if re.sub(r"\s+", " ", str(item or "")).strip("::;;,,。 ") + ] + best_clause = max(clauses, key=len, default="") + if title_text and best_clause and best_clause not in title_text: + return f"{title_text}:{best_clause[:48]}" + return title_text or best_clause[:48] + + async def _collect_fit_brief_signal_source_objectives( + self, + *, + current_project_id: Optional[int], + limit: int = 4, + ) -> list[str]: + if current_project_id is None or getattr(self, "db", None) is None: + return [] + + max_items = max(1, int(limit)) + try: + rows = ( + await self.db.execute( + select(Task.title, Task.description) + .where( + and_( + Task.tenant_id == getattr(self, "tenant_id", 1), + Task.project_id == int(current_project_id), + ) + ) + .order_by(Task.id.asc()) + .limit(max_items) + ) + ).all() + except Exception: + rows = [] + + objectives: list[str] = [] + for row in rows: + candidate = self._build_signal_source_objective_from_task( + title=getattr(row, "title", None), + description=getattr(row, "description", None), + ) + if candidate and candidate not in objectives: + objectives.append(candidate) + return objectives[:max_items] + @staticmethod def _build_repo_summary( *, @@ -206,6 +259,10 @@ class AdaptiveRAGProjectFitBriefMixin: user_snapshot=user_snapshot, limit=4, ) + signal_source_objectives = await self._collect_fit_brief_signal_source_objectives( + current_project_id=int(current_project_id) if current_project_id is not None else None, + limit=4, + ) constraints = [ "只允许基于可核验外部公开证据判断适配性。", "结论必须优先绑定当前项目与当前仓库上下文。", @@ -223,6 +280,7 @@ class AdaptiveRAGProjectFitBriefMixin: ), current_capabilities=capabilities, signal_source_anchors=signal_source_anchors, + signal_source_objectives=signal_source_objectives, constraints=constraints[:4], recommendation_focus=self._build_recommendation_focus( question, 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 0ee04741..90ea7dd7 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 @@ -205,6 +205,7 @@ class AdaptiveRAGQASessionListOpsMixinP1: "external_recommendation": self._normalize_message_dict( response_payload.get("external_recommendation") ), + "analysis_packet": self._normalize_message_dict(response_payload.get("analysis_packet")), "handoff_mode": str(response_payload.get("handoff_mode") or "").strip() or None, "can_enter_plan": ( bool(response_payload.get("can_enter_plan")) 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 f5b5dd8a..4bfbdc3b 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 @@ -160,17 +160,9 @@ class AdaptiveRAGQueryRouteGatewayMixin: @classmethod def _normalize_query_profile(cls, payload: Optional[dict[str, Any]]) -> dict[str, Any]: rows = payload if isinstance(payload, dict) else {} - referenced_objects: list[str] = [] - for item in list(rows.get("referenced_objects") or []): - normalized = re.sub(r"[^a-z0-9:_\-]", "", str(item or "").strip().lower()) - if normalized and normalized not in referenced_objects: - referenced_objects.append(normalized[:48]) - if not referenced_objects: - referenced_objects = ["unknown"] return { "needs_private_context": bool(rows.get("needs_private_context")), "needs_freshness": bool(rows.get("needs_freshness")), - "referenced_objects": referenced_objects, "confidence": max(0.0, min(1.0, float(rows.get("confidence") or 0.0))), } @@ -214,8 +206,26 @@ class AdaptiveRAGQueryRouteGatewayMixin: @classmethod def _build_query_route_clarification_protocol(cls) -> dict[str, Any]: options = [ - {"key": "A", "label": "外部公开事实"}, - {"key": "B", "label": "系统内部配置/项目对象"}, + { + "key": "A", + "label": "外部公开事实", + "query_semantics_overrides": { + "source_policy": "external", + "effective_source_policy": "external", + "source_reason": "query_route_clarification_selection", + "decision_origin": "query_route_clarification_selection", + }, + }, + { + "key": "B", + "label": "系统内部配置/项目对象", + "query_semantics_overrides": { + "source_policy": "internal", + "effective_source_policy": "internal", + "source_reason": "query_route_clarification_selection", + "decision_origin": "query_route_clarification_selection", + }, + }, ] option_map = { "A": "external", diff --git a/backend/app/services/adaptive_rag_service/mixin_retrieval_router_db.py b/backend/app/services/adaptive_rag_service/mixin_retrieval_router_db.py index 46564511..7454b490 100644 --- a/backend/app/services/adaptive_rag_service/mixin_retrieval_router_db.py +++ b/backend/app/services/adaptive_rag_service/mixin_retrieval_router_db.py @@ -1,6 +1,7 @@ """Adaptive RAG mixin module.""" from __future__ import annotations +from .shared_utils import _structured_project_requested, _structured_runtime_entity_type from .shared import * from .internal_query_context import ( internal_context_operation_contract, @@ -202,7 +203,6 @@ class AdaptiveRAGRetrievalRouterDBMixin: ) -> list[RetrievedItem]: text = question.lower() rows: list[RetrievedItem] = [] - subject_focus = self._query_subject_focus(query_semantics) scope_hint = self._query_scope_hint(query_semantics) target_resolution = internal_context_target_resolution( packet=internal_context_packet, @@ -221,22 +221,20 @@ class AdaptiveRAGRetrievalRouterDBMixin: key="query_plan", ), ) - planned_entity_type = str(query_plan.get("entity_type") or "").strip().lower() - project_subject_requested = subject_focus == "project" or target_entity_type == "project" or planned_entity_type == "project" + entity_type = _structured_runtime_entity_type( + query_semantics=query_semantics, + target_resolution=target_resolution, + query_plan=query_plan, + internal_context_packet=internal_context_packet, + ) + project_subject_requested = _structured_project_requested( + query_semantics=query_semantics, + target_resolution=target_resolution, + query_plan=query_plan, + internal_context_packet=internal_context_packet, + ) project_scope_required = scope_hint == "project" or project_id is not None - semantic_fact_entity_type = "" - if target_entity_type in {"task", "signal", "release", "review", "project", "document", "user", "requirement"}: - semantic_fact_entity_type = target_entity_type - elif planned_entity_type in {"task", "signal", "release", "review", "project", "document", "user", "requirement"}: - semantic_fact_entity_type = planned_entity_type - elif subject_focus == "task": - semantic_fact_entity_type = "task" - elif subject_focus == "signal": - semantic_fact_entity_type = "signal" - elif subject_focus == "people": - semantic_fact_entity_type = "user" - elif subject_focus == "charter": - semantic_fact_entity_type = "requirement" + semantic_fact_entity_type = entity_type if semantic_fact_entity_type and semantic_fact_entity_type != "project": rows.extend( await self._retrieve_generic_semantic_fact_rows( @@ -247,7 +245,7 @@ class AdaptiveRAGRetrievalRouterDBMixin: ) ) - if subject_focus == "people": + if entity_type == "user": scoped_project_id: Optional[int] = None scoped_project_name: Optional[str] = None if project_scope_required: @@ -340,7 +338,7 @@ class AdaptiveRAGRetrievalRouterDBMixin: ) ) - if subject_focus == "signal": + if entity_type == "signal": signal_stmt = select(Signal).where(Signal.tenant_id == self.tenant_id) if project_id: signal_stmt = signal_stmt.where( @@ -373,7 +371,7 @@ class AdaptiveRAGRetrievalRouterDBMixin: ) ) - if subject_focus == "task": + if entity_type == "task": task_stmt = select(Task).where(Task.tenant_id == self.tenant_id) if project_id: task_stmt = task_stmt.where(Task.project_id == project_id) @@ -403,7 +401,7 @@ class AdaptiveRAGRetrievalRouterDBMixin: ) ) - if subject_focus == "charter": + if entity_type == "requirement": charter_stmt = select(TruthNode).where(TruthNode.tenant_id == self.tenant_id) if project_id: charter_stmt = charter_stmt.where(TruthNode.project_id == project_id) @@ -430,7 +428,7 @@ class AdaptiveRAGRetrievalRouterDBMixin: ) ) - if subject_focus == "workload": + if entity_type == "user_workload": workload_stmt = select(UserWorkload).where(UserWorkload.tenant_id == self.tenant_id) workloads = (await self.db.execute(workload_stmt.order_by(UserWorkload.updated_at.desc()).limit(10))).scalars().all() if workloads: diff --git a/backend/app/services/adaptive_rag_service/mixin_session_state.py b/backend/app/services/adaptive_rag_service/mixin_session_state.py index 6fa0c134..11fa3639 100644 --- a/backend/app/services/adaptive_rag_service/mixin_session_state.py +++ b/backend/app/services/adaptive_rag_service/mixin_session_state.py @@ -5,6 +5,203 @@ from .shared import * class AdaptiveRAGSessionStateMixin: + @staticmethod + def _normalize_state_scope(payload: Optional[dict[str, Any]]) -> dict[str, Any]: + row = payload if isinstance(payload, dict) else {} + project_id = row.get("project_id") + try: + normalized_project_id = int(project_id) if project_id is not None else None + except Exception: + normalized_project_id = None + target_ref = str(row.get("target_ref") or "").strip() or None + target_entity_type = str(row.get("target_entity_type") or "").strip().lower() or None + return { + "project_id": normalized_project_id, + "target_ref": target_ref, + "target_entity_type": target_entity_type, + } + + def _state_scope_from_qa_record(self, qa_record: Optional[QARecord]) -> dict[str, Any]: + if qa_record is None: + return self._normalize_state_scope(None) + snapshot = qa_record.routing_snapshot if isinstance(qa_record.routing_snapshot, dict) else {} + query_semantics = snapshot.get("query_semantics") if isinstance(snapshot.get("query_semantics"), dict) else {} + response_payload = snapshot.get("response_payload") if isinstance(snapshot.get("response_payload"), dict) else {} + target_resolution = ( + query_semantics.get("target_resolution") + if isinstance(query_semantics.get("target_resolution"), dict) + else {} + ) + analysis_packet = ( + response_payload.get("analysis_packet") + if isinstance(response_payload.get("analysis_packet"), dict) + else {} + ) + fallback_target_ref = "" + fallback_target_entity_type = "" + for row in list(analysis_packet.get("focus_objects") or []): + if not isinstance(row, dict): + continue + fallback_target_ref = str(row.get("object_ref") or row.get("target_ref") or "").strip() + fallback_target_entity_type = str(row.get("object_type") or row.get("target_entity_type") or "").strip().lower() + if fallback_target_ref: + break + + project_id = qa_record.project_id + target_ref = str(target_resolution.get("target_ref") or "").strip() or fallback_target_ref or None + if project_id is None and target_ref and str(target_ref).startswith("project:"): + try: + project_id = int(str(target_ref).split(":", 1)[1]) + except Exception: + project_id = None + return self._normalize_state_scope( + { + "project_id": project_id, + "target_ref": target_ref, + "target_entity_type": ( + str(target_resolution.get("target_entity_type") or "").strip().lower() + or fallback_target_entity_type + or None + ), + } + ) + + async def _load_last_turn_scope(self, *, session_id: Optional[int]) -> dict[str, Any]: + if session_id is None: + return self._normalize_state_scope(None) + row = ( + await self.db.execute( + select(QARecord) + .where( + and_( + QARecord.tenant_id == self.tenant_id, + QARecord.session_id == int(session_id), + ) + ) + .order_by(QARecord.turn_index.desc(), QARecord.id.desc()) + .limit(1) + ) + ).scalar_one_or_none() + return self._state_scope_from_qa_record(row) + + async def _resolve_current_turn_scope( + self, + *, + question: str, + project_id: Optional[int], + source_policy_hint: Optional[str], + query_semantics: Optional[dict[str, Any]], + ) -> dict[str, Any]: + semantics = dict(query_semantics or {}) if isinstance(query_semantics, dict) else {} + if not semantics: + resolver = getattr(self, "_resolve_query_semantics", None) + if callable(resolver): + try: + semantics = await resolver( + message=question, + source_system="adaptive_rag_state_rewrite_gate", + source_policy_hint=source_policy_hint, + ) + except Exception: + semantics = {} + + scoped_project_id = project_id + scope_resolver = getattr(self, "_resolve_project_scope_for_question", None) + if callable(scope_resolver): + try: + scoped_project_id, _, _ = await scope_resolver( + question=question, + project_id=project_id, + query_semantics=semantics, + internal_context_packet=None, + ) + except TypeError: + scoped_project_id, _, _ = await scope_resolver( + question=question, + project_id=project_id, + query_semantics=semantics, + ) + except Exception: + scoped_project_id = project_id + + target_resolution = ( + semantics.get("target_resolution") + if isinstance(semantics.get("target_resolution"), dict) + else {} + ) + target_ref = str(target_resolution.get("target_ref") or "").strip() or None + if scoped_project_id is None and target_ref and target_ref.startswith("project:"): + try: + scoped_project_id = int(target_ref.split(":", 1)[1]) + except Exception: + scoped_project_id = None + return self._normalize_state_scope( + { + "project_id": scoped_project_id, + "target_ref": target_ref, + "target_entity_type": str(target_resolution.get("target_entity_type") or "").strip().lower() or None, + } + ) + + async def _build_state_rewrite_governance( + self, + *, + question: str, + project_id: Optional[int], + session_id: Optional[int], + source_policy_hint: Optional[str], + query_semantics: Optional[dict[str, Any]], + session_state: Optional[QASessionState], + anchor_resolution: Optional[FollowupAnchorResolution], + ) -> dict[str, Any]: + resolution = anchor_resolution or FollowupAnchorResolution() + followup_mode = str(resolution.followup_mode or "").strip().lower() + anchor_mode = str(resolution.anchor_mode or "").strip().lower() + governance: dict[str, Any] = { + "allow_state_rewrite": True, + "reason": "followup_scope_continuity", + "previous_scope": self._normalize_state_scope(None), + "current_scope": self._normalize_state_scope(None), + } + if followup_mode != "referential": + governance["reason"] = "non_referential_turn" + return governance + if anchor_mode == "explicit": + governance["reason"] = "explicit_anchor" + return governance + if session_state is None or session_id is None: + governance["reason"] = "missing_session_state" + return governance + + previous_scope = await self._load_last_turn_scope(session_id=session_id) + current_scope = await self._resolve_current_turn_scope( + question=question, + project_id=project_id, + source_policy_hint=source_policy_hint, + query_semantics=query_semantics, + ) + governance["previous_scope"] = previous_scope + governance["current_scope"] = current_scope + + previous_project_id = previous_scope.get("project_id") + current_project_id = current_scope.get("project_id") + previous_target_ref = str(previous_scope.get("target_ref") or "").strip() or None + current_target_ref = str(current_scope.get("target_ref") or "").strip() or None + + if current_project_id is not None and previous_project_id is not None and current_project_id != previous_project_id: + governance["allow_state_rewrite"] = False + governance["reason"] = "new_project_scope" + return governance + if current_project_id is not None and previous_project_id is None: + governance["allow_state_rewrite"] = False + governance["reason"] = "new_project_scope" + return governance + if current_project_id is not None and previous_project_id == current_project_id and current_target_ref and previous_target_ref and current_target_ref != previous_target_ref: + governance["allow_state_rewrite"] = False + governance["reason"] = "new_business_scope" + return governance + return governance + async def _ensure_session_state(self, *, session_id: Optional[int]) -> Optional[QASessionState]: if session_id is None: return None @@ -41,6 +238,7 @@ class AdaptiveRAGSessionStateMixin: conversation: list[dict[str, str]], session_state: Optional[QASessionState], anchor_resolution: Optional[FollowupAnchorResolution] = None, + rewrite_governance: Optional[dict[str, Any]] = None, ) -> tuple[str, dict[str, Any]]: _ = conversation current = str(question or "").strip() @@ -50,6 +248,14 @@ class AdaptiveRAGSessionStateMixin: resolution = anchor_resolution or FollowupAnchorResolution() if str(resolution.followup_mode or "").strip().lower() != "referential": return current, {"mode": "direct"} + governance = rewrite_governance if isinstance(rewrite_governance, dict) else {} + if governance and governance.get("allow_state_rewrite") is False: + return current, { + "mode": "scope_reset", + "reason": str(governance.get("reason") or "scope_changed").strip() or "scope_changed", + "current_scope": self._normalize_state_scope(governance.get("current_scope")), + "previous_scope": self._normalize_state_scope(governance.get("previous_scope")), + } explicit_anchor_mode = str(resolution.anchor_mode or "").strip().lower() if explicit_anchor_mode == "explicit": diff --git a/backend/app/services/adaptive_rag_service/mixin_target_resolution.py b/backend/app/services/adaptive_rag_service/mixin_target_resolution.py index 70c3d876..3b920452 100644 --- a/backend/app/services/adaptive_rag_service/mixin_target_resolution.py +++ b/backend/app/services/adaptive_rag_service/mixin_target_resolution.py @@ -7,11 +7,35 @@ from .internal_query_context import ( internal_context_query_plan, internal_context_target_resolution, ) +from .internal_domain_catalog import config_target_catalog, internal_domain_target class AdaptiveRAGTargetResolutionMixin: - _TARGET_ENTITY_TYPES = {"project", "feature", "config", "doc", "person", "signal", "unknown"} + _TARGET_ENTITY_TYPES = {"project", "task", "feature", "config", "doc", "person", "user", "signal", "unknown"} _TARGET_RESOLUTION_MODES = {"deterministic", "llm_rerank", "semantic_filter", "clarify", "none"} + _BUSINESS_FRAME_FIELDS = { + "goal_kind", + "target_scope", + "resource_role", + "source_kind", + "expected_answer_shape", + "conversation_posture", + "project_binding", + "subject_binding", + "object_binding", + "candidate_set_binding", + "evidence_mode", + } + + @classmethod + def _normalize_target_entity_type_name(cls, value: Any) -> str: + _ = cls + normalized = str(value or "").strip().lower() + if normalized == "person": + return "user" + if normalized == "document": + return "doc" + return normalized @classmethod def _normalize_object_mentions( @@ -38,21 +62,19 @@ class AdaptiveRAGTargetResolutionMixin: _ = question semantics = query_semantics if isinstance(query_semantics, dict) else {} query_type = str(semantics.get("query_type") or QUERY_TYPE_UNKNOWN).strip().lower() or QUERY_TYPE_UNKNOWN + business_frame = self._coalesce_business_frame(query_semantics=semantics) query_profile = dict(semantics.get("query_profile") or {}) if isinstance(semantics.get("query_profile"), dict) else {} - referenced_objects = { - str(item or "").strip().lower() - for item in list(query_profile.get("referenced_objects") or []) - if str(item or "").strip() - } - if query_type in {QUERY_TYPE_EXISTENCE, QUERY_TYPE_UNKNOWN} and referenced_objects.intersection( - {"model_routing", "system_config"} - ): + config_bound = ( + str(business_frame.get("resource_role") or "").strip().lower() == "config" + or str(business_frame.get("object_binding") or "").strip().lower().startswith("config:") + ) + if query_type in {QUERY_TYPE_EXISTENCE, QUERY_TYPE_UNKNOWN} and config_bound: query_type = QUERY_TYPE_STATUS object_mentions = self._normalize_object_mentions( semantics.get("object_mentions"), question=question, ) - return { + contract = { "query_type": query_type, "answer_contract": ( self._internal_answer_contract_for_query_type(query_type) @@ -63,16 +85,41 @@ class AdaptiveRAGTargetResolutionMixin: "object_mentions": object_mentions, "clarify_policy": "ask_when_target_ambiguous", } + contract.update({key: value for key, value in business_frame.items() if key in self._BUSINESS_FRAME_FIELDS}) + return contract - @staticmethod - def _referenced_objects(query_semantics: Optional[dict[str, Any]]) -> set[str]: + @classmethod + def _coalesce_business_frame( + cls, + *, + query_semantics: Optional[dict[str, Any]], + operation_contract: Optional[dict[str, Any]] = None, + ) -> dict[str, Any]: semantics = query_semantics if isinstance(query_semantics, dict) else {} - query_profile = semantics.get("query_profile") if isinstance(semantics.get("query_profile"), dict) else {} - return { - str(item or "").strip().lower() - for item in list(query_profile.get("referenced_objects") or []) - if str(item or "").strip() - } + contract = operation_contract if isinstance(operation_contract, dict) else {} + rows: list[dict[str, Any]] = [] + if isinstance(semantics.get("business_frame"), dict): + rows.append(dict(semantics.get("business_frame") or {})) + rows.append(dict(semantics)) + if isinstance(contract.get("business_frame"), dict): + rows.append(dict(contract.get("business_frame") or {})) + rows.append(dict(contract)) + frame: dict[str, Any] = {} + for row in rows: + if not isinstance(row, dict): + continue + for key in cls._BUSINESS_FRAME_FIELDS: + value = row.get(key) + if value in (None, "", []): + continue + if key == "project_binding": + try: + value = int(value) + except Exception: + value = str(value).strip() or None + if value is not None: + frame[key] = value + return frame def _build_session_followup_target( self, @@ -99,32 +146,237 @@ class AdaptiveRAGTargetResolutionMixin: if len(entity_ids) != 1: return None - subject_focus = str(semantics.get("subject_focus") or "unknown").strip().lower() - referenced_objects = self._referenced_objects(semantics) - if subject_focus != "project" and "project_object" not in referenced_objects: + business_frame = self._coalesce_business_frame( + query_semantics=semantics, + operation_contract=operation_contract, + ) + conversation_posture = str(business_frame.get("conversation_posture") or "").strip().lower() + target_scope = str(business_frame.get("target_scope") or "").strip().lower() + resource_role = str(business_frame.get("resource_role") or "").strip().lower() + project_binding = business_frame.get("project_binding") + object_binding = str(business_frame.get("object_binding") or "").strip().lower() + requested_entity_types = self._requested_internal_entity_types( + query_semantics=semantics, + operation_contract=operation_contract, + ) + is_project_followup = bool(requested_entity_types) or ( + target_scope in {"current_project", "explicit_project"} + and project_binding not in (None, "", []) + ) or object_binding.startswith("project:") + if conversation_posture == "ambient_chat" or not is_project_followup: return None mentions = list(operation_contract.get("object_mentions") or []) resolved_label = ( - str(followup_context.get("last_subject") or "").strip() + str(business_frame.get("subject_binding") or "").strip() + or str(followup_context.get("last_subject") or "").strip() or (str(mentions[0]).strip() if mentions else "") or None ) resolved_project_id = entity_ids[0] + if requested_entity_types == {"project"}: + return { + "target_entity_type": "project", + "target_ref": f"project:{resolved_project_id}", + "resolved_label": resolved_label, + "resolution_mode": "deterministic", + "binding_source": "session_followup_context", + "confidence": 0.93 if rewrite_mode == "state_subject" else 0.88, + "candidate_summary": [], + } + return self._build_generic_domain_target_from_business_frame( + query_semantics=semantics, + operation_contract=operation_contract, + binding_source="session_followup_context", + confidence=0.9 if rewrite_mode == "state_subject" else 0.86, + resolved_label_override=resolved_label, + ) + + def _config_target_ref_from_business_frame( + self, + *, + query_semantics: Optional[dict[str, Any]], + operation_contract: Optional[dict[str, Any]], + ) -> str | None: + semantics = query_semantics if isinstance(query_semantics, dict) else {} + business_frame = self._coalesce_business_frame( + query_semantics=semantics, + operation_contract=operation_contract, + ) + object_binding = str(business_frame.get("object_binding") or "").strip().lower() + if object_binding.startswith("config:"): + return object_binding + return None + + def _config_domain_requested( + self, + *, + query_semantics: Optional[dict[str, Any]], + operation_contract: Optional[dict[str, Any]], + ) -> bool: + semantics = query_semantics if isinstance(query_semantics, dict) else {} + business_frame = self._coalesce_business_frame( + query_semantics=semantics, + operation_contract=operation_contract, + ) + return str(business_frame.get("resource_role") or "").strip().lower() == "config" + + def _requested_internal_entity_types( + self, + *, + query_semantics: Optional[dict[str, Any]], + operation_contract: Optional[dict[str, Any]], + ) -> set[str]: + business_frame = self._coalesce_business_frame( + query_semantics=query_semantics, + operation_contract=operation_contract, + ) + object_binding = str(business_frame.get("object_binding") or "").strip().lower() + if object_binding.startswith("config:"): + return {"config"} + if object_binding.startswith("feature:"): + return {"feature"} + if object_binding.startswith("task:"): + return {"task"} + if object_binding.startswith("signal:"): + return {"signal"} + if object_binding.startswith(("doc:", "document:")): + return {"doc"} + if object_binding.startswith(("user:", "person:")): + return {"user"} + if object_binding.startswith("project:"): + return {"project"} + + resource_role = str(business_frame.get("resource_role") or "").strip().lower() + if resource_role == "config": + return {"config"} + if resource_role == "feature": + return {"feature"} + if resource_role == "task": + return {"task"} + if resource_role in {"signal", "signal_source", "external_input"}: + return {"signal"} + if resource_role == "people": + return {"user"} + if resource_role == "doc": + return {"doc"} + + target_scope = str(business_frame.get("target_scope") or "").strip().lower() + if resource_role == "project_object" or ( + target_scope in {"current_project", "explicit_project"} + and business_frame.get("project_binding") not in (None, "", []) + ): + return {"project"} + return set() + + @staticmethod + def _config_target_label(target_ref: str, mention: str | None = None) -> str: + normalized = str(mention or "").strip() + if normalized: + return normalized + if str(target_ref or "").endswith("mcp_template_center"): + return "MCP 配置" + return "模型配置" if str(target_ref or "").endswith("model_routing") else "系统配置" + + def _build_project_target_from_business_frame( + self, + *, + query_semantics: Optional[dict[str, Any]], + operation_contract: Optional[dict[str, Any]], + ) -> Optional[dict[str, Any]]: + business_frame = self._coalesce_business_frame( + query_semantics=query_semantics, + operation_contract=operation_contract, + ) + try: + project_binding = int(business_frame.get("project_binding")) + except Exception: + project_binding = None + if project_binding is None: + return None + target_scope = str(business_frame.get("target_scope") or "").strip().lower() + resource_role = str(business_frame.get("resource_role") or "").strip().lower() + requested_entity_types = self._requested_internal_entity_types( + query_semantics=query_semantics, + operation_contract=operation_contract, + ) + if requested_entity_types and requested_entity_types != {"project"}: + return None + if target_scope not in {"current_project", "explicit_project"} and resource_role != "project_object": + return None return { "target_entity_type": "project", - "target_ref": f"project:{resolved_project_id}", + "target_ref": f"project:{project_binding}", + "resolved_label": str(business_frame.get("subject_binding") or "").strip() or None, + "resolution_mode": "semantic_filter", + "confidence": 0.84, + "candidate_summary": [], + } + + def _build_generic_domain_target_from_business_frame( + self, + *, + query_semantics: Optional[dict[str, Any]], + operation_contract: Optional[dict[str, Any]], + binding_source: str = "semantic_filter", + confidence: float = 0.74, + resolved_label_override: str | None = None, + ) -> Optional[dict[str, Any]]: + business_frame = self._coalesce_business_frame( + query_semantics=query_semantics, + operation_contract=operation_contract, + ) + requested_entity_types = self._requested_internal_entity_types( + query_semantics=query_semantics, + operation_contract=operation_contract, + ) + if len(requested_entity_types) != 1: + return None + entity_type = next(iter(requested_entity_types)) + if entity_type in {"project", "config"}: + return None + + catalog_row = internal_domain_target(entity_type) + if not isinstance(catalog_row, dict): + return None + + object_binding = str(business_frame.get("object_binding") or "").strip() + mentions = list((operation_contract or {}).get("object_mentions") or []) + resolved_label = ( + str(resolved_label_override or "").strip() + or str(business_frame.get("subject_binding") or "").strip() + or (str(mentions[0]).strip() if mentions else "") + or str(catalog_row.get("label") or "").strip() + or None + ) + normalized_object_binding = object_binding.lower() + target_ref = str(catalog_row.get("target_ref") or "").strip() + if entity_type == "feature" and normalized_object_binding.startswith("feature:"): + target_ref = object_binding + elif entity_type == "task" and normalized_object_binding.startswith("task:"): + target_ref = object_binding + elif entity_type == "signal" and normalized_object_binding.startswith("signal:"): + target_ref = object_binding + elif entity_type == "doc" and normalized_object_binding.startswith(("doc:", "document:")): + target_ref = object_binding + elif entity_type == "user" and normalized_object_binding.startswith(("user:", "person:")): + target_ref = object_binding + return { + "target_entity_type": entity_type, + "target_ref": target_ref or None, "resolved_label": resolved_label, - "resolution_mode": "deterministic", - "binding_source": "session_followup_context", - "confidence": 0.93 if rewrite_mode == "state_subject" else 0.88, + "resolution_mode": "semantic_filter", + "binding_source": binding_source, + "confidence": round(max(0.0, min(1.0, float(confidence))), 4), "candidate_summary": [], } @classmethod def _normalize_target_candidate(cls, payload: Optional[dict[str, Any]]) -> Optional[dict[str, Any]]: row = payload if isinstance(payload, dict) else {} - entity_type = str(row.get("target_entity_type") or row.get("entity_type") or "").strip().lower() + entity_type = cls._normalize_target_entity_type_name( + row.get("target_entity_type") or row.get("entity_type") + ) if entity_type not in cls._TARGET_ENTITY_TYPES: return None score = max(0.0, min(1.0, float(row.get("score") or 0.0))) @@ -343,39 +595,46 @@ class AdaptiveRAGTargetResolutionMixin: query_semantics: Optional[dict[str, Any]], operation_contract: dict[str, Any], ) -> list[dict[str, Any]]: - semantics = query_semantics if isinstance(query_semantics, dict) else {} - query_profile = semantics.get("query_profile") if isinstance(semantics.get("query_profile"), dict) else {} - referenced_objects = { - str(item or "").strip().lower() - for item in list(query_profile.get("referenced_objects") or []) - if str(item or "").strip() - } - if not {"model_routing", "system_config"}.intersection(referenced_objects): + target_ref = self._config_target_ref_from_business_frame( + query_semantics=query_semantics, + operation_contract=operation_contract, + ) + requested_config_domain = self._config_domain_requested( + query_semantics=query_semantics, + operation_contract=operation_contract, + ) + if not target_ref and not requested_config_domain: return [] mentions = list(operation_contract.get("object_mentions") or []) rows: list[dict[str, Any]] = [] - if "model_routing" in referenced_objects: + if target_ref: rows.append( { "target_entity_type": "config", - "target_ref": "config:model_routing", - "resolved_label": mentions[0] if mentions else "模型配置", - "score": 0.9, + "target_ref": target_ref, + "resolved_label": self._config_target_label(target_ref, mentions[0] if mentions else None), + "score": 0.9 if target_ref.endswith("model_routing") else 0.88 if target_ref.endswith("mcp_template_center") else 0.86, "source_kind": "system_config", - "match_label": mentions[0] if mentions else "模型配置", - "source_confidence": 0.9, + "match_label": mentions[0] if mentions else self._config_target_label(target_ref), + "source_confidence": 0.9 if target_ref.endswith("model_routing") else 0.88 if target_ref.endswith("mcp_template_center") else 0.86, } ) - elif "system_config" in referenced_objects: + return rows + + for item in config_target_catalog(): + candidate_ref = str(item.get("target_ref") or "").strip() + if not candidate_ref: + continue + label = str(item.get("label") or "").strip() or self._config_target_label(candidate_ref) rows.append( { "target_entity_type": "config", - "target_ref": "config:system", - "resolved_label": mentions[0] if mentions else "系统配置", - "score": 0.86, + "target_ref": candidate_ref, + "resolved_label": label, + "score": 0.88 if candidate_ref.endswith("model_routing") else 0.87, "source_kind": "system_config", - "match_label": mentions[0] if mentions else "系统配置", - "source_confidence": 0.86, + "match_label": mentions[0] if mentions else label, + "source_confidence": 0.88 if candidate_ref.endswith("model_routing") else 0.87, } ) return rows @@ -497,13 +756,25 @@ class AdaptiveRAGTargetResolutionMixin: if normalized is not None: rows.append(normalized) + requested_entity_types = self._requested_internal_entity_types( + query_semantics=query_semantics, + operation_contract=operation_contract, + ) + deduped: dict[str, dict[str, Any]] = {} for row in rows: key = self._target_candidate_identity(row) existing = deduped.get(key) if existing is None or float(row.get("score") or 0.0) > float(existing.get("score") or 0.0): deduped[key] = row - return sorted(deduped.values(), key=lambda item: float(item.get("score") or 0.0), reverse=True)[:8] + filtered = list(deduped.values()) + if requested_entity_types: + filtered = [ + row + for row in filtered + if self._normalize_target_entity_type_name(row.get("target_entity_type")) in requested_entity_types + ] + return sorted(filtered, key=lambda item: float(item.get("score") or 0.0), reverse=True)[:8] async def _rerank_internal_target_candidates( self, @@ -580,27 +851,46 @@ class AdaptiveRAGTargetResolutionMixin: operation_contract: dict[str, Any], ) -> Optional[dict[str, Any]]: semantics = query_semantics if isinstance(query_semantics, dict) else {} - subject_focus = str(semantics.get("subject_focus") or "unknown").strip().lower() - query_profile = semantics.get("query_profile") if isinstance(semantics.get("query_profile"), dict) else {} - mentions = list(operation_contract.get("object_mentions") or []) - resolved_label = mentions[0] if mentions else None - referenced_objects = { - str(item or "").strip().lower() - for item in list(query_profile.get("referenced_objects") or []) - if str(item or "").strip() - } - - if {"model_routing", "system_config"}.intersection(referenced_objects): - target_ref = "config:model_routing" if "model_routing" in referenced_objects else "config:system" + business_frame = self._coalesce_business_frame( + query_semantics=semantics, + operation_contract=operation_contract, + ) + project_bound = self._build_project_target_from_business_frame( + query_semantics=semantics, + operation_contract=operation_contract, + ) + if project_bound is not None: + return project_bound + config_ref = self._config_target_ref_from_business_frame( + query_semantics=semantics, + operation_contract=operation_contract, + ) + if config_ref: + mentions = list(operation_contract.get("object_mentions") or []) return { "target_entity_type": "config", - "target_ref": target_ref, - "resolved_label": resolved_label or ("模型配置" if target_ref.endswith("model_routing") else "系统配置"), + "target_ref": config_ref, + "resolved_label": self._config_target_label(config_ref, mentions[0] if mentions else None), "resolution_mode": "deterministic", "confidence": 0.84, "candidate_summary": [], } - if subject_focus == "project": + mentions = list(operation_contract.get("object_mentions") or []) + resolved_label = ( + str(business_frame.get("subject_binding") or "").strip() + or (mentions[0] if mentions else None) + ) + generic_domain = self._build_generic_domain_target_from_business_frame( + query_semantics=semantics, + operation_contract=operation_contract, + confidence=0.74 if resolved_label else 0.62, + resolved_label_override=resolved_label, + ) + if generic_domain is not None: + return generic_domain + target_scope = str(business_frame.get("target_scope") or "").strip().lower() + resource_role = str(business_frame.get("resource_role") or "").strip().lower() + if target_scope in {"current_project", "explicit_project"} or resource_role == "project_object": return { "target_entity_type": "project", "target_ref": None, @@ -609,15 +899,6 @@ class AdaptiveRAGTargetResolutionMixin: "confidence": 0.78 if resolved_label else 0.64, "candidate_summary": [], } - if subject_focus == "feature" or "feature_catalog" in referenced_objects: - return { - "target_entity_type": "feature", - "target_ref": None, - "resolved_label": resolved_label, - "resolution_mode": "semantic_filter", - "confidence": 0.74 if resolved_label else 0.62, - "candidate_summary": [], - } return None async def _resolve_internal_target( @@ -688,6 +969,22 @@ class AdaptiveRAGTargetResolutionMixin: if reranked is not None: return reranked if float(top.get("score") or 0.0) >= 0.88 or margin >= 0.12 or second is None: + if ( + second is not None + and str(top.get("target_entity_type") or "").strip().lower() == "config" + and not self._config_target_ref_from_business_frame( + query_semantics=semantics, + operation_contract=operation, + ) + ): + return { + "target_entity_type": "unknown", + "target_ref": None, + "resolved_label": None, + "resolution_mode": "clarify", + "confidence": round(max(0.0, min(1.0, float(top.get("score") or 0.0))), 4), + "candidate_summary": candidates[:4], + } return { "target_entity_type": str(top.get("target_entity_type") or "unknown"), "target_ref": top.get("target_ref"), diff --git a/backend/app/services/adaptive_rag_service/shared_utils.py b/backend/app/services/adaptive_rag_service/shared_utils.py index 2e3e5528..ca1b7e4d 100644 --- a/backend/app/services/adaptive_rag_service/shared_utils.py +++ b/backend/app/services/adaptive_rag_service/shared_utils.py @@ -2,6 +2,39 @@ from __future__ import annotations from .shared_imports import * +from .internal_query_context import ( + internal_context_operation_contract, + internal_context_query_plan, + internal_context_semantic_mirror, + internal_context_target_resolution, +) + + +_BUSINESS_FRAME_FIELDS = ( + "goal_kind", + "target_scope", + "resource_role", + "source_kind", + "expected_answer_shape", + "conversation_posture", + "project_binding", + "subject_binding", + "object_binding", + "candidate_set_binding", + "evidence_mode", +) +_BUSINESS_ROLE_TO_ENTITY_TYPE = { + "project_object": "project", + "task": "task", + "signal": "signal", + "signal_source": "signal", + "external_input": "signal", + "config": "config", + "people": "user", + "feature": "feature", + "doc": "doc", +} + def _env_bool(name: str, default: bool = False) -> bool: raw = str(os.getenv(name, "") or "").strip().lower() @@ -25,5 +58,179 @@ def _env_int(name: str, default: int, *, minimum: int, maximum: int) -> int: value = int(default) return max(minimum, min(maximum, value)) +def _coalesce_business_frame_from_runtime( + *, + query_semantics: Optional[dict[str, Any]] = None, + operation_contract: Optional[dict[str, Any]] = None, + target_resolution: Optional[dict[str, Any]] = None, + query_plan: Optional[dict[str, Any]] = None, + internal_context_packet: Any = None, +) -> dict[str, Any]: + semantics = query_semantics if isinstance(query_semantics, dict) else {} + operation = internal_context_operation_contract( + packet=internal_context_packet, + fallback=( + operation_contract + if isinstance(operation_contract, dict) + else internal_context_semantic_mirror( + packet=internal_context_packet, + query_semantics=semantics, + key="operation_contract", + ) + ), + ) + target = internal_context_target_resolution( + packet=internal_context_packet, + fallback=( + target_resolution + if isinstance(target_resolution, dict) + else internal_context_semantic_mirror( + packet=internal_context_packet, + query_semantics=semantics, + key="target_resolution", + ) + ), + ) + plan = internal_context_query_plan( + packet=internal_context_packet, + fallback=( + query_plan + if isinstance(query_plan, dict) + else internal_context_semantic_mirror( + packet=internal_context_packet, + query_semantics=semantics, + key="query_plan", + ) + ), + ) -__all__ = ["_env_bool", "_env_int"] + frame: dict[str, Any] = {} + rows: list[dict[str, Any]] = [] + if isinstance(semantics.get("business_frame"), dict): + rows.append(dict(semantics.get("business_frame") or {})) + rows.append(dict(semantics)) + if isinstance(operation.get("business_frame"), dict): + rows.append(dict(operation.get("business_frame") or {})) + rows.append(dict(operation)) + if isinstance(target.get("business_frame"), dict): + rows.append(dict(target.get("business_frame") or {})) + rows.append(dict(target)) + if isinstance(plan.get("business_frame"), dict): + rows.append(dict(plan.get("business_frame") or {})) + rows.append(dict(plan)) + + for row in rows: + if not isinstance(row, dict): + continue + for key in _BUSINESS_FRAME_FIELDS: + value = row.get(key) + if value in (None, "", []): + continue + frame[key] = value + return frame + + +def _structured_runtime_entity_type( + *, + query_semantics: Optional[dict[str, Any]] = None, + operation_contract: Optional[dict[str, Any]] = None, + target_resolution: Optional[dict[str, Any]] = None, + query_plan: Optional[dict[str, Any]] = None, + internal_context_packet: Any = None, +) -> str: + semantics = query_semantics if isinstance(query_semantics, dict) else {} + target = internal_context_target_resolution( + packet=internal_context_packet, + fallback=( + target_resolution + if isinstance(target_resolution, dict) + else internal_context_semantic_mirror( + packet=internal_context_packet, + query_semantics=semantics, + key="target_resolution", + ) + ), + ) + target_entity_type = str(target.get("target_entity_type") or "").strip().lower() + if target_entity_type in {"project", "task", "signal", "feature", "config", "user", "doc", "document", "requirement", "release", "review"}: + return target_entity_type + + plan = internal_context_query_plan( + packet=internal_context_packet, + fallback=( + query_plan + if isinstance(query_plan, dict) + else internal_context_semantic_mirror( + packet=internal_context_packet, + query_semantics=semantics, + key="query_plan", + ) + ), + ) + planned_entity_type = str(plan.get("entity_type") or "").strip().lower() + if planned_entity_type in {"project", "task", "signal", "feature", "config", "user", "doc", "document", "requirement", "release", "review"}: + return planned_entity_type + + business_frame = _coalesce_business_frame_from_runtime( + query_semantics=semantics, + operation_contract=operation_contract, + target_resolution=target_resolution, + query_plan=query_plan, + internal_context_packet=internal_context_packet, + ) + object_binding = str(business_frame.get("object_binding") or "").strip().lower() + if object_binding.startswith("feature:"): + return "feature" + if object_binding.startswith("config:"): + return "config" + + resource_role = str(business_frame.get("resource_role") or "").strip().lower() + mapped = _BUSINESS_ROLE_TO_ENTITY_TYPE.get(resource_role) + if mapped: + return mapped + + target_scope = str(business_frame.get("target_scope") or "").strip().lower() + project_binding = business_frame.get("project_binding") + if target_scope in {"current_project", "explicit_project"} and project_binding not in (None, "", []): + return "project" + return "unknown" + + +def _structured_project_requested( + *, + query_semantics: Optional[dict[str, Any]] = None, + operation_contract: Optional[dict[str, Any]] = None, + target_resolution: Optional[dict[str, Any]] = None, + query_plan: Optional[dict[str, Any]] = None, + internal_context_packet: Any = None, +) -> bool: + if _structured_runtime_entity_type( + query_semantics=query_semantics, + operation_contract=operation_contract, + target_resolution=target_resolution, + query_plan=query_plan, + internal_context_packet=internal_context_packet, + ) == "project": + return True + business_frame = _coalesce_business_frame_from_runtime( + query_semantics=query_semantics, + operation_contract=operation_contract, + target_resolution=target_resolution, + query_plan=query_plan, + internal_context_packet=internal_context_packet, + ) + target_scope = str(business_frame.get("target_scope") or "").strip().lower() + return target_scope in {"current_project", "explicit_project"} and business_frame.get("project_binding") not in ( + None, + "", + [], + ) + + +__all__ = [ + "_env_bool", + "_env_int", + "_coalesce_business_frame_from_runtime", + "_structured_project_requested", + "_structured_runtime_entity_type", +] diff --git a/backend/app/services/ai_pmo/ai_pmo_kernel/ai_pmo_reply_renderer.py b/backend/app/services/ai_pmo/ai_pmo_kernel/ai_pmo_reply_renderer.py index 8d1a6a19..c76a35e0 100644 --- a/backend/app/services/ai_pmo/ai_pmo_kernel/ai_pmo_reply_renderer.py +++ b/backend/app/services/ai_pmo/ai_pmo_kernel/ai_pmo_reply_renderer.py @@ -528,21 +528,52 @@ class ReplyRenderer: if mutation_success is not None: return mutation_success if intent.name == INTENT_SIGNAL_SOURCE_ONBOARD: + signal_ids = _normalize_signal_id_list(execution_result.get("signal_ids")) signal_id = _safe_int(execution_result.get("signal_id")) + if signal_id is not None and signal_id not in signal_ids: + signal_ids = [signal_id, *signal_ids] + primary_signal_id = signal_ids[0] if signal_ids else signal_id + project_id = _safe_int(execution_result.get("project_id")) workflow = execution_result.get("signal_source_workflow") if isinstance(execution_result.get("signal_source_workflow"), dict) else {} status = str(workflow.get("status") or (execution_result.get("signal_source_orchestration") or {}).get("status") or "").strip() - if status == "pending_manual": + post_process_status = str(execution_result.get("post_process_status") or "").strip().lower() + if work_state == self.state_waiting_manual: return { "receipt_phase": self.receipt_phase_pending, "template_key": "pending_manual", - "payload": {"intent": intent.name, "signal_id": signal_id, "status": status}, - "text": f"已记录信号源接入请求,当前为待人工确认。信号#{signal_id} 已入库。" if signal_id else "已记录信号源接入请求,当前为待人工确认。", + "payload": {"intent": intent.name, "signal_id": primary_signal_id, "signal_ids": signal_ids, "status": status}, + "text": f"已记录信号源接入请求,当前为待人工确认。信号#{primary_signal_id} 已入库。" + if primary_signal_id + else "已记录信号源接入请求,当前为待人工确认。", } + if len(signal_ids) > 1 and project_id is not None: + signal_labels = "、".join(f"#{sid}" for sid in signal_ids[:5]) + text = f"已入库:已为项目#{project_id}加入{len(signal_ids)}条信号源并启动接入流程({signal_labels})。" + elif len(signal_ids) > 1: + signal_labels = "、".join(f"#{sid}" for sid in signal_ids[:5]) + text = f"已入库:已加入{len(signal_ids)}条信号源并启动接入流程({signal_labels})。" + elif primary_signal_id is not None and project_id is not None: + text = f"已入库:已为项目#{project_id}加入信号源#{primary_signal_id}并启动接入流程。" + elif primary_signal_id is not None: + text = f"已入库:已加入信号源#{primary_signal_id}并启动接入流程。" + else: + text = "已入库并启动信号源接入流程。" + if post_process_status == "pending_manual": + text = f"{text} 后续仍有待处理步骤。" + elif post_process_status and post_process_status not in {"completed", "succeeded"}: + text = f"{text} 当前接入流程状态:{post_process_status}。" return { "receipt_phase": self.receipt_phase_action, "template_key": "action_succeeded", - "payload": {"intent": intent.name, "signal_id": signal_id, "status": status}, - "text": f"已接收入库并启动信号源接入流程。信号#{signal_id},状态:{status or 'running'}。", + "payload": { + "intent": intent.name, + "signal_id": primary_signal_id, + "signal_ids": signal_ids, + "project_id": project_id, + "status": status, + "post_process_status": post_process_status or None, + }, + "text": text, } workflow_route = str(execution_result.get("workflow_route") or "").strip().lower() followup_text = str(execution_result.get("followup_reply") or "").strip() diff --git a/backend/app/services/ai_pmo/ai_pmo_kernel/ai_pmo_typed_executor_mixins_parse.py b/backend/app/services/ai_pmo/ai_pmo_kernel/ai_pmo_typed_executor_mixins_parse.py index 9d06e975..2fbf55e7 100644 --- a/backend/app/services/ai_pmo/ai_pmo_kernel/ai_pmo_typed_executor_mixins_parse.py +++ b/backend/app/services/ai_pmo/ai_pmo_kernel/ai_pmo_typed_executor_mixins_parse.py @@ -25,20 +25,67 @@ class TypedExecutorParseMixin: *, intent: CanonicalIntent, message_text: str, + candidate_materialization: Optional[dict[str, Any]] = None, + candidate_set_binding: Optional[str] = None, ) -> dict[str, Any]: metadata = intent.metadata if isinstance(intent.metadata, dict) else {} modules = metadata.get("closure_modules") if isinstance(metadata.get("closure_modules"), list) else [] normalized_modules = [str(item).strip() for item in modules if str(item).strip()][:6] + candidate_payload = candidate_materialization if isinstance(candidate_materialization, dict) else {} + materialization_payload = ( + candidate_payload.get("materialization_payload") + if isinstance(candidate_payload.get("materialization_payload"), dict) + else {} + ) + candidate_display_name = cls._normalize_short_text( + materialization_payload.get("display_name") or candidate_payload.get("display_name"), + max_len=120, + ) + candidate_business_value = cls._normalize_short_text( + materialization_payload.get("business_value") or candidate_payload.get("business_value"), + max_len=300, + ) + candidate_source_origin = cls._normalize_short_text( + materialization_payload.get("source_origin") or candidate_payload.get("source_origin"), + max_len=120, + ) summary = cls._normalize_short_text( - metadata.get("closure_summary") or intent.signal_summary or message_text, + candidate_business_value + or materialization_payload.get("summary") + or metadata.get("closure_summary") + or intent.signal_summary + or message_text, max_len=300, ) dialogue = cls._normalize_short_text( - metadata.get("closure_dialogue") or message_text, + ( + ";".join( + item + for item in ( + candidate_display_name, + candidate_business_value, + candidate_source_origin, + ) + if item + ) + or materialization_payload.get("content") + or metadata.get("closure_dialogue") + or message_text + ), max_len=1800, ) - title_seed = str(intent.signal_title or "").strip() or str(message_text or "").strip() or "信号源接入请求" + title_seed = ( + candidate_display_name + or str(materialization_payload.get("title") or "").strip() + or str(intent.signal_title or "").strip() + or str(message_text or "").strip() + or "信号源接入请求" + ) reasoning_bullets = ["由 AI PMO kernel 统一入库。"] + if candidate_display_name: + reasoning_bullets.append(f"推荐候选:{candidate_display_name}") + if candidate_business_value: + reasoning_bullets.append(f"业务价值:{candidate_business_value[:120]}") if normalized_modules: reasoning_bullets.append(f"闭环提炼模块:{'、'.join(normalized_modules[:4])}") if summary: @@ -47,6 +94,10 @@ class TypedExecutorParseMixin: "source_context": { "conversation_closure": dialogue or None, "implementation_modules": normalized_modules, + "candidate_set_binding": str(candidate_set_binding or "").strip() or None, + "candidate_ref": str(candidate_payload.get("candidate_ref") or "").strip() or None, + "candidate_display_name": candidate_display_name or None, + "candidate_source_origin": candidate_source_origin or None, }, "ai_analysis": { "summary": summary or None, @@ -144,51 +195,37 @@ class TypedExecutorParseMixin: return payload if isinstance(payload, dict) else {} @staticmethod - def _parse_chinese_count_token(token: str) -> Optional[int]: - cleaned = str(token or "").strip() - if not cleaned: + def _resolve_structured_requested_count(metadata: dict[str, Any]) -> Optional[int]: + if not isinstance(metadata, dict): return None - map_digit = { - "零": 0, - "一": 1, - "二": 2, - "两": 2, - "三": 3, - "四": 4, - "五": 5, - "六": 6, - "七": 7, - "八": 8, - "九": 9, - "十": 10, - } - if cleaned in map_digit: - return map_digit[cleaned] - if len(cleaned) == 2 and cleaned[0] == "十" and cleaned[1] in map_digit: - return 10 + map_digit[cleaned[1]] - if len(cleaned) == 2 and cleaned[1] == "十" and cleaned[0] in map_digit: - return map_digit[cleaned[0]] * 10 - if len(cleaned) == 3 and cleaned[1] == "十" and cleaned[0] in map_digit and cleaned[2] in map_digit: - return map_digit[cleaned[0]] * 10 + map_digit[cleaned[2]] + direct = _safe_int(metadata.get("requested_count")) + if direct is not None and direct > 0: + return direct + operation_contract = metadata.get("operation_intent_contract") + if isinstance(operation_contract, dict): + contract_direct = _safe_int(operation_contract.get("requested_count")) + if contract_direct is not None and contract_direct > 0: + return contract_direct + contract_meta = operation_contract.get("metadata") + if isinstance(contract_meta, dict): + contract_meta_count = _safe_int(contract_meta.get("requested_count")) + if contract_meta_count is not None and contract_meta_count > 0: + return contract_meta_count + business_frame = metadata.get("business_frame") + if isinstance(business_frame, dict): + frame_meta = business_frame.get("metadata") + if isinstance(frame_meta, dict): + frame_count = _safe_int(frame_meta.get("requested_count")) + if frame_count is not None and frame_count > 0: + return frame_count return None @classmethod def _extract_requested_signal_count(cls, *, message_text: str, metadata: dict[str, Any]) -> int: - metadata_count = _safe_int(metadata.get("requested_count")) if isinstance(metadata, dict) else None + _ = message_text + metadata_count = cls._resolve_structured_requested_count(metadata if isinstance(metadata, dict) else {}) if metadata_count is not None and metadata_count > 0: return min(max(metadata_count, 1), 5) - - normalized_text = str(message_text or "") - digit_match = re.search(r"(\d{1,2})\s*(?:个|条|项)?\s*(?:信号|候选)", normalized_text, flags=re.IGNORECASE) - if digit_match: - return min(max(int(digit_match.group(1)), 1), 5) - - chinese_match = re.search(r"([零一二两三四五六七八九十]{1,3})\s*(?:个|条|项)?\s*(?:信号|候选)", normalized_text) - if chinese_match: - parsed = cls._parse_chinese_count_token(chinese_match.group(1)) - if parsed is not None and parsed > 0: - return min(max(parsed, 1), 5) - return 1 @classmethod @@ -197,15 +234,39 @@ class TypedExecutorParseMixin: *, message_text: str, requested_count: int, + metadata: Optional[dict[str, Any]] = None, ) -> bool: - normalized = re.sub(r"\s+", "", str(message_text or "")).lower() - if not normalized: - return False - if re.search(r"(先(?:自行)?分析(?:再|后)|先分析后入库|自行分析)", normalized, re.IGNORECASE): + _ = (message_text, requested_count) + payload = metadata if isinstance(metadata, dict) else {} + direct_mode = str(payload.get("analysis_mode") or "").strip().lower() + if direct_mode in {"strict", "analyze_first"}: return True - if re.search(r"(分析|拆分).{0,12}(后|完).{0,8}(入库|新增|创建|加入|录入)", normalized, re.IGNORECASE): + if bool(payload.get("strict_signal_analysis")): return True - return requested_count > 1 and "分析" in normalized and any(token in normalized for token in ("信号", "需求")) + operation_contract = payload.get("operation_intent_contract") + if isinstance(operation_contract, dict): + contract_mode = str(operation_contract.get("analysis_mode") or "").strip().lower() + if contract_mode in {"strict", "analyze_first"}: + return True + if bool(operation_contract.get("strict_signal_analysis")): + return True + contract_meta = operation_contract.get("metadata") + if isinstance(contract_meta, dict): + meta_mode = str(contract_meta.get("analysis_mode") or "").strip().lower() + if meta_mode in {"strict", "analyze_first"}: + return True + if bool(contract_meta.get("strict_signal_analysis")): + return True + business_frame = payload.get("business_frame") + if isinstance(business_frame, dict): + frame_meta = business_frame.get("metadata") + if isinstance(frame_meta, dict): + frame_mode = str(frame_meta.get("analysis_mode") or "").strip().lower() + if frame_mode in {"strict", "analyze_first"}: + return True + if bool(frame_meta.get("strict_signal_analysis")): + return True + return False @classmethod def _normalize_signal_batch_items( diff --git a/backend/app/services/ai_pmo/ai_pmo_kernel/mixins/followup/ai_pmo_kernel_mixins_followup_project_signal_resume.py b/backend/app/services/ai_pmo/ai_pmo_kernel/mixins/followup/ai_pmo_kernel_mixins_followup_project_signal_resume.py index d94a5175..31ab0863 100644 --- a/backend/app/services/ai_pmo/ai_pmo_kernel/mixins/followup/ai_pmo_kernel_mixins_followup_project_signal_resume.py +++ b/backend/app/services/ai_pmo/ai_pmo_kernel/mixins/followup/ai_pmo_kernel_mixins_followup_project_signal_resume.py @@ -53,6 +53,59 @@ class KernelFollowupProjectSignalResumeMixin: "closure_modules": payload.get("closure_modules"), }, ) + execution_payload = ( + target_work_item.execution_result + if isinstance(target_work_item.execution_result, dict) + else {} + ) + if target_intent.name == INTENT_SIGNAL_SOURCE_ONBOARD: + action_contract = ( + execution_payload.get("action_contract") + if isinstance(execution_payload.get("action_contract"), dict) + else {} + ) + action_params = ( + action_contract.get("params") + if isinstance(action_contract.get("params"), dict) + else {} + ) + action_metadata = ( + action_contract.get("metadata") + if isinstance(action_contract.get("metadata"), dict) + else {} + ) + candidate_set_binding = ( + str(execution_payload.get("candidate_set_binding") or "").strip() + or str(action_params.get("candidate_set_binding") or "").strip() + ) + candidate_refs = ( + list(execution_payload.get("candidate_refs")) + if isinstance(execution_payload.get("candidate_refs"), list) + else list(action_params.get("candidate_refs") or []) + ) + reply_anchor_qa_context = ( + execution_payload.get("reply_anchor_qa_context") + if isinstance(execution_payload.get("reply_anchor_qa_context"), dict) + else ( + action_metadata.get("reply_anchor_qa_context") + if isinstance(action_metadata.get("reply_anchor_qa_context"), dict) + else {} + ) + ) + if candidate_set_binding or candidate_refs: + target_intent.metadata["operation_intent_contract"] = { + "intent_phase": str(action_params.get("intent_phase") or "execute_write").strip().lower() + or "execute_write", + "requires_confirmation": False, + "project_binding": ( + _safe_int(target_project_id) + or _safe_int(action_params.get("project_id")) + ), + "candidate_set_binding": candidate_set_binding or None, + "candidate_refs": [str(item).strip() for item in candidate_refs if str(item).strip()], + } + if isinstance(reply_anchor_qa_context, dict) and reply_anchor_qa_context: + target_intent.metadata["reply_anchor_qa_context"] = dict(reply_anchor_qa_context) if target_intent.name not in FOLLOWUP_TARGET_INTENTS and workflow_route.family not in { ROUTE_FAMILY_PROJECT_SIGNAL, ROUTE_FAMILY_SIGNAL_SOURCE, diff --git a/backend/app/services/ai_pmo/ai_pmo_kernel/mixins/followup/ai_pmo_kernel_mixins_followup_verification.py b/backend/app/services/ai_pmo/ai_pmo_kernel/mixins/followup/ai_pmo_kernel_mixins_followup_verification.py index b7a91c1f..748d3f31 100644 --- a/backend/app/services/ai_pmo/ai_pmo_kernel/mixins/followup/ai_pmo_kernel_mixins_followup_verification.py +++ b/backend/app/services/ai_pmo/ai_pmo_kernel/mixins/followup/ai_pmo_kernel_mixins_followup_verification.py @@ -11,6 +11,27 @@ for _name, _value in vars(_common).items(): class KernelFollowupVerificationMixin: + @staticmethod + def _normalize_operation_intent_contract(intent: CanonicalIntent) -> dict[str, Any]: + metadata = intent.metadata if isinstance(getattr(intent, "metadata", None), dict) else {} + contract = metadata.get("operation_intent_contract") + if hasattr(contract, "model_dump"): + contract = contract.model_dump() + return contract if isinstance(contract, dict) else {} + + @classmethod + def _is_direct_signal_source_write( + cls, + *, + intent: CanonicalIntent, + execution_result: dict[str, Any], + ) -> bool: + contract = cls._normalize_operation_intent_contract(intent) + intent_phase = str(contract.get("intent_phase") or "").strip().lower() + if intent_phase in {"prepare_write", "confirm_write", "execute_write"}: + return True + return bool(execution_result.get("manual_gate_resolved")) + async def _verify_execution( self, *, @@ -21,7 +42,10 @@ class KernelFollowupVerificationMixin: normalized_status = str(execution_result.get("status") or "").strip().lower() if normalized_status == "failed_safe": return {"verified": False, "issues": ["executor_failed"]} - if normalized_status in {"pending_manual", "pending_confirmation"}: + if normalized_status in {"pending_manual", "pending_confirmation"} and not ( + intent.name == INTENT_SIGNAL_SOURCE_ONBOARD + and self._is_direct_signal_source_write(intent=intent, execution_result=execution_result) + ): return {"verified": False, "issues": ["pending_manual"]} if normalized_status == "cancelled": return {"verified": True, "issues": []} @@ -104,7 +128,10 @@ class KernelFollowupVerificationMixin: return {"verified": False, "issues": ["signal_missing"]} if intent.name == INTENT_SIGNAL_SOURCE_ONBOARD: workflow = execution_result.get("signal_source_workflow") if isinstance(execution_result.get("signal_source_workflow"), dict) else {} - if str(workflow.get("status") or "").lower() != "completed": + if ( + str(workflow.get("status") or "").lower() != "completed" + and not self._is_direct_signal_source_write(intent=intent, execution_result=execution_result) + ): return {"verified": False, "issues": ["signal_source_pending_manual"]} if intent.project_id is None: return {"verified": True, "issues": []} diff --git a/backend/app/services/ai_pmo/ai_pmo_kernel/mixins/ingress/ai_pmo_kernel_mixins_ingress_storage.py b/backend/app/services/ai_pmo/ai_pmo_kernel/mixins/ingress/ai_pmo_kernel_mixins_ingress_storage.py index a064201e..e6c92143 100644 --- a/backend/app/services/ai_pmo/ai_pmo_kernel/mixins/ingress/ai_pmo_kernel_mixins_ingress_storage.py +++ b/backend/app/services/ai_pmo/ai_pmo_kernel/mixins/ingress/ai_pmo_kernel_mixins_ingress_storage.py @@ -128,6 +128,21 @@ class KernelIngressStorageMixin: source_message_id: str, channel_ref: Optional[str], ) -> dict[str, Any]: + intent_metadata = intent.metadata if isinstance(getattr(intent, "metadata", None), dict) else {} + operation_intent_contract = ( + intent_metadata.get("operation_intent_contract") + if isinstance(intent_metadata.get("operation_intent_contract"), dict) + else {} + ) + reply_anchor_qa_context = ( + execution_result.get("reply_anchor_qa_context") + if isinstance(execution_result.get("reply_anchor_qa_context"), dict) + else ( + intent_metadata.get("reply_anchor_qa_context") + if isinstance(intent_metadata.get("reply_anchor_qa_context"), dict) + else {} + ) + ) execution_plan = decision_payload.get("execution_plan") if isinstance(decision_payload.get("execution_plan"), dict) else {} actions = execution_plan.get("actions") if isinstance(execution_plan.get("actions"), list) else [] route_key = str(getattr(workflow_route, "key", "") or "").strip().lower() @@ -169,6 +184,21 @@ class KernelIngressStorageMixin: "signal_ids": signal_ids, "task_id": _safe_int(execution_result.get("task_id")), "target_work_item_id": _safe_int(execution_result.get("target_work_item_id")), + "candidate_set_binding": ( + str(execution_result.get("candidate_set_binding") or "").strip() + or str(operation_intent_contract.get("candidate_set_binding") or "").strip() + or None + ), + "candidate_refs": ( + list(execution_result.get("candidate_refs")) + if isinstance(execution_result.get("candidate_refs"), list) + else list(operation_intent_contract.get("candidate_refs") or []) + ), + "intent_phase": ( + str(execution_result.get("operation_intent_phase") or "").strip().lower() + or str(operation_intent_contract.get("intent_phase") or "").strip().lower() + or None + ), "source_system": str(source_system or "").strip() or None, "source_message_id": str(source_message_id or "").strip() or None, "channel_ref": str(channel_ref or "").strip() or None, @@ -185,6 +215,9 @@ class KernelIngressStorageMixin: "workflow_action": str(getattr(workflow_route, "action", "") or "").strip().lower() or None, "param_source": "decision.execution_plan.actions+workflow_route+execution_result", "params": normalized_params, + "metadata": { + "reply_anchor_qa_context": dict(reply_anchor_qa_context) if isinstance(reply_anchor_qa_context, dict) else {} + }, } def _apply_execution_action_contract( diff --git a/backend/app/services/ai_pmo/ai_pmo_kernel/mixins/intent/ai_pmo_kernel_mixins_intent_resolve.py b/backend/app/services/ai_pmo/ai_pmo_kernel/mixins/intent/ai_pmo_kernel_mixins_intent_resolve.py index 94b56c7b..cb31024e 100644 --- a/backend/app/services/ai_pmo/ai_pmo_kernel/mixins/intent/ai_pmo_kernel_mixins_intent_resolve.py +++ b/backend/app/services/ai_pmo/ai_pmo_kernel/mixins/intent/ai_pmo_kernel_mixins_intent_resolve.py @@ -2,6 +2,9 @@ from __future__ import annotations +from sqlalchemy import and_, or_, select +from sqlalchemy.exc import SQLAlchemyError + from .... import ai_pmo_kernel_common_core as _common from ....semantic_context.semantic_decision_service import SemanticDecisionService from ....workflow_semantic_planner import WorkflowSemanticPlanner @@ -12,6 +15,97 @@ for _name, _value in vars(_common).items(): class KernelIntentResolveMixin: + async def _load_reply_anchor_qa_context( + self, + *, + source_system: str, + thread_context: Optional[dict[str, Any]], + ) -> dict[str, Any]: + if not hasattr(getattr(self, "db", None), "execute"): + return {} + payload = dict(thread_context or {}) if isinstance(thread_context, dict) else {} + anchor_message_id = str( + payload.get("reply_to_message_id") + or payload.get("root_message_id") + or "" + ).strip() + if not anchor_message_id: + return {} + + from app.models.feishu_reply_outbox import FeishuReplyOutbox + from app.models.qa_record import QARecord + + anchor_message_ids = {anchor_message_id} + normalized_source = str(source_system or "").strip().lower() + if normalized_source == "feishu": + try: + outbox_row = ( + await self.db.execute( + select(FeishuReplyOutbox) + .where( + and_( + FeishuReplyOutbox.tenant_id == int(self.tenant_id), + or_( + FeishuReplyOutbox.sent_message_id == anchor_message_id, + FeishuReplyOutbox.source_message_id == anchor_message_id, + ), + ) + ) + .order_by(FeishuReplyOutbox.id.desc()) + .limit(1) + ) + ).scalars().first() + except SQLAlchemyError: + outbox_row = None + if outbox_row is not None: + source_anchor_id = str(getattr(outbox_row, "source_message_id", "") or "").strip() + sent_anchor_id = str(getattr(outbox_row, "sent_message_id", "") or "").strip() + if source_anchor_id: + anchor_message_ids.add(source_anchor_id) + if sent_anchor_id: + anchor_message_ids.add(sent_anchor_id) + + criteria = [ + QARecord.tenant_id == int(self.tenant_id), + QARecord.source_message_id.in_(list(anchor_message_ids)), + ] + if normalized_source: + criteria.append(QARecord.asked_via == normalized_source) + qa_row = ( + await self.db.execute( + select(QARecord) + .where(and_(*criteria)) + .order_by(QARecord.updated_at.desc(), QARecord.id.desc()) + .limit(1) + ) + ).scalars().first() + if qa_row is None: + return {} + + routing_snapshot = ( + dict(getattr(qa_row, "routing_snapshot", {}) or {}) + if isinstance(getattr(qa_row, "routing_snapshot", None), dict) + else {} + ) + response_payload = ( + dict(routing_snapshot.get("response_payload") or {}) + if isinstance(routing_snapshot.get("response_payload"), dict) + else {} + ) + signal_source_recommendation = ( + dict(response_payload.get("signal_source_recommendation") or {}) + if isinstance(response_payload.get("signal_source_recommendation"), dict) + else {} + ) + return { + "qa_id": int(getattr(qa_row, "id", 0) or 0), + "project_id": _safe_int(getattr(qa_row, "project_id", None)), + "source_message_id": str(getattr(qa_row, "source_message_id", "") or "").strip() or None, + "conversation_scope_key": str(getattr(qa_row, "conversation_scope_key", "") or "").strip() or None, + "answer_mode": str(getattr(qa_row, "answer_mode", "") or "").strip() or None, + "signal_source_recommendation": signal_source_recommendation, + } + @staticmethod def _infer_workflow_source_system( *, @@ -214,6 +308,11 @@ class KernelIntentResolveMixin: session=session, ) fallback_project_id = _safe_int(getattr(session, "current_project_id", None)) if inherit_session_context else None + reply_anchor_qa_context = await self._load_reply_anchor_qa_context( + source_system=source_system, + thread_context=thread_context, + ) + anchor_project_id = _safe_int(reply_anchor_qa_context.get("project_id")) workflow_bundle_override = WorkflowSemanticPlanner.deserialize_bundle(semantic_bundle_override) if workflow_bundle_override is not None: return ( @@ -226,18 +325,25 @@ class KernelIntentResolveMixin: "workflow_bundle", ) planner_metadata = { - "project_id": _safe_int(world_model.get("resolved_project_id")), - "world_model_project_id": _safe_int(world_model.get("resolved_project_id")), + "project_id": _safe_int(world_model.get("resolved_project_id")) or anchor_project_id, + "world_model_project_id": _safe_int(world_model.get("resolved_project_id")) or anchor_project_id, "session_last_intent": ( str(getattr(session, "last_intent", "") or "").strip() if inherit_session_context else "" ), - "session_project_id": fallback_project_id, + "session_project_id": fallback_project_id or anchor_project_id, "reply_to_message_id": ( str(thread_context.get("reply_to_message_id") or "").strip() if isinstance(thread_context, dict) else "" ), - "thread_context": dict(thread_context) if isinstance(thread_context, dict) else {}, + "thread_context": { + **(dict(thread_context) if isinstance(thread_context, dict) else {}), + **( + {"reply_anchor_qa_context": reply_anchor_qa_context} + if reply_anchor_qa_context + else {} + ), + }, "source_message_context": "reply_anchor" if reply_anchor_present else "cold_start", } try: diff --git a/backend/app/services/ai_pmo/ai_pmo_kernel_ingress_flow/mixin_auto.py b/backend/app/services/ai_pmo/ai_pmo_kernel_ingress_flow/mixin_auto.py index b96dc0f2..0fcdded2 100644 --- a/backend/app/services/ai_pmo/ai_pmo_kernel_ingress_flow/mixin_auto.py +++ b/backend/app/services/ai_pmo/ai_pmo_kernel_ingress_flow/mixin_auto.py @@ -4,6 +4,14 @@ from .shared import * from ..ai_pmo_kernel_common_core.helpers import _safe_int as _safe_int class KernelIngressFlowAutoMixin: + @staticmethod + def _extract_operation_intent_contract(intent: CanonicalIntent) -> dict[str, Any]: + metadata = intent.metadata if isinstance(getattr(intent, "metadata", None), dict) else {} + contract = metadata.get("operation_intent_contract") + if hasattr(contract, "model_dump"): + contract = contract.model_dump() + return contract if isinstance(contract, dict) else {} + async def _execute_auto_ingress_flow( self, *, @@ -427,6 +435,11 @@ class KernelIngressFlowAutoMixin: return False if str(getattr(workflow_route, "family", "") or "") == ROUTE_FAMILY_PROJECT_SIGNAL: return str(getattr(workflow_route, "action", "") or "") == ROUTE_ACTION_CREATE + if intent.name == INTENT_SIGNAL_SOURCE_ONBOARD: + operation_intent_contract = KernelIngressFlowAutoMixin._extract_operation_intent_contract(intent) + intent_phase = str(operation_intent_contract.get("intent_phase") or "").strip().lower() + if intent_phase: + return intent_phase == "drafting" return True @staticmethod 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 199881c0..e868fc10 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 @@ -42,6 +42,19 @@ class KernelIngressFlowEntryMixin: def _normalize_compact_text(value: Any) -> str: return re.sub(r"\s+", "", str(value or "")).strip().lower() + @classmethod + def _parse_draft_confirmation_protocol_action(cls, message_text: str) -> Optional[str]: + compact = cls._normalize_compact_text(message_text) + if compact in {"确认", "确认入库", "确认执行", "通过"}: + return "confirm" + if compact in {"取消", "取消草稿"}: + return "cancel" + if compact in {"重出", "重出一版", "重来一版", "重新生成", "重新出一版"}: + return "regenerate" + if compact in {"修改", "改一下", "改一版"}: + return "revise" + return None + def _ensure_project_operating_context( self, *, @@ -695,6 +708,22 @@ class KernelIngressFlowEntryMixin: for key in ("reply_to_message_id", "root_message_id", "thread_id") ) + @staticmethod + def _semantic_bundle_requests_followup_flow( + semantic_bundle: Optional[dict[str, Any]], + ) -> bool: + if not isinstance(semantic_bundle, dict) or not semantic_bundle: + return False + primary_workflow = str(semantic_bundle.get("primary_workflow") or "").strip().lower() + if primary_workflow == "followup_flow": + return True + for item in semantic_bundle.get("workflows") or []: + if not isinstance(item, dict): + continue + if str(item.get("workflow_type") or "").strip().lower() == "followup_flow": + return True + return False + @staticmethod def _build_semantic_shadow_decision_from_bundle( *, @@ -969,20 +998,38 @@ class KernelIngressFlowEntryMixin: return normalized_text, {} if self._should_skip_mixed_sidecar_classify_for_bundle(semantic_bundle): return normalized_text, {} - if self._has_explicit_thread_anchor(thread_context): - try: - from ..ai_pmo_typed_executor_execute.mixin_execute_query_project import ExecuteQueryProjectMixin - - if ExecuteQueryProjectMixin._parse_requirement_plan_choice_protocol(normalized_text): - return normalized_text, {} - except Exception as exc: - logger.warning( - "ai_pmo_requirement_choice_protocol_probe_failed", - tenant_id=self.tenant_id, - source_system=source_system, - source_message_id=source_message_id, - error=str(exc), - ) + if self._has_explicit_thread_anchor(thread_context) and self._semantic_bundle_requests_followup_flow(semantic_bundle): + return normalized_text, {} + if self._has_explicit_thread_anchor(thread_context) and str(source_system or "").strip().lower() == "feishu": + active_requirement_finder = getattr(self, "_find_active_requirement_plan_workspace", None) + if callable(active_requirement_finder): + conversation_key = source_message_id + if str(source_system or "").strip().lower() == "feishu": + conversation_key = build_feishu_conversation_scope_key( + source_chat_id=channel_ref, + source_sender_open_id=source_sender_open_id, + sender=None, + ) or source_message_id + try: + active_topic, active_draft = await active_requirement_finder( + source_system=source_system, + source_message_id=source_message_id, + source_chat_id=channel_ref, + sender=None, + source_sender_open_id=source_sender_open_id, + thread_context=thread_context, + conversation_key=conversation_key, + ) + except Exception as exc: + logger.warning( + "ai_pmo_mixed_requirement_anchor_probe_failed", + tenant_id=self.tenant_id, + source_system=source_system, + error=str(exc), + ) + else: + if active_topic is not None and active_draft is not None: + return normalized_text, {} rag_service = AdaptiveRAGService(self.db, tenant_id=self.tenant_id) try: @@ -2099,6 +2146,15 @@ class KernelIngressFlowEntryMixin: normalized[key] = value[:128] return normalized + @staticmethod + def _extract_semantic_bundle_from_ingress_options( + ingress_options: Optional[dict[str, Any]], + ) -> Optional[dict[str, Any]]: + if not isinstance(ingress_options, dict): + return None + semantic_bundle = ingress_options.get("semantic_bundle") + return semantic_bundle if isinstance(semantic_bundle, dict) and semantic_bundle else None + @classmethod def _has_explicit_draft_reply_anchor( cls, @@ -2163,7 +2219,10 @@ class KernelIngressFlowEntryMixin: return False if str(getattr(session, "last_intent", "") or "").strip() != STATE_DRAFT_PENDING_CONFIRM: return False - return DraftConfirmHandler.is_explicit_protocol_followup_text(message_text) + semantic_bundle = cls._extract_semantic_bundle_from_ingress_options(ingress_options) + if semantic_bundle is None: + return cls._parse_draft_confirmation_protocol_action(message_text) is not None + return cls._semantic_bundle_requests_followup_flow(semantic_bundle) async def _should_route_to_draft_confirmation_session( self, @@ -2180,6 +2239,7 @@ class KernelIngressFlowEntryMixin: handler = DraftConfirmHandler(self) thread_context = self._extract_thread_context_from_ingress_options(ingress_options) + semantic_bundle = self._extract_semantic_bundle_from_ingress_options(ingress_options) normalized_text = str(message_text or "").strip() has_explicit_thread_anchor = self._has_explicit_thread_anchor(thread_context) if self._should_route_to_draft_confirmation( @@ -2200,7 +2260,9 @@ class KernelIngressFlowEntryMixin: source_sender_open_id=source_sender_open_id, thread_context=thread_context, ) - if bool(has_active_draft_session): + if bool(has_active_draft_session) and ( + semantic_bundle is None or self._semantic_bundle_requests_followup_flow(semantic_bundle) + ): return True return False @@ -2426,24 +2488,6 @@ class KernelIngressFlowEntryMixin: return "current_requirement" return normalized_scope - @staticmethod - def _looks_like_requirement_plan_choice_protocol_followup(message_text: str) -> bool: - try: - from ..ai_pmo_typed_executor_execute.mixin_execute_query_project import ExecuteQueryProjectMixin - - return ExecuteQueryProjectMixin._parse_requirement_plan_choice_protocol(message_text) is not None - except Exception: - return False - - @staticmethod - def _looks_like_requirement_plan_exit_request(message_text: str) -> bool: - try: - from ..ai_pmo_typed_executor_execute.mixin_execute_query_project import ExecuteQueryProjectMixin - - return ExecuteQueryProjectMixin._is_requirement_plan_exit_request(message_text) - except Exception: - return False - async def _build_active_requirement_mode_exit_result( self, *, @@ -2485,6 +2529,7 @@ class KernelIngressFlowEntryMixin: source_sender_open_id: Optional[str], thread_context: Optional[dict[str, Any]], conversation_key: Optional[str], + requirement_plan_protocol: Optional[dict[str, Any]] = None, ) -> Optional[dict[str, Any]]: builder = getattr(self, "_build_requirement_planning_direct_result", None) if not callable(builder): @@ -2502,6 +2547,7 @@ class KernelIngressFlowEntryMixin: thread_context=thread_context, conversation_key=conversation_key, topic_switch=None, + requirement_plan_protocol=requirement_plan_protocol, ) answer = self._extract_direct_answer_text(direct_result) if not answer: @@ -2561,30 +2607,6 @@ class KernelIngressFlowEntryMixin: if bound_context is None: return None topic, draft, active_context_kind = bound_context - if ( - str(active_context_kind or "").strip().lower() == "requirement_plan" - and self._looks_like_requirement_plan_exit_request(message_text) - ): - return await self._build_active_requirement_mode_exit_result( - topic=topic, - source_message_id=source_message_id, - ) - if ( - str(active_context_kind or "").strip().lower() == "requirement_plan" - and self._looks_like_requirement_plan_choice_protocol_followup(message_text) - ): - direct_protocol_result = await self._build_active_requirement_followup_result( - topic_workspace_id=_safe_int(getattr(topic, "id", None)), - message_text=message_text, - source_system=source_system, - source_message_id=source_message_id, - channel_ref=channel_ref, - source_sender_open_id=source_sender_open_id, - thread_context=thread_context, - conversation_key=conversation_key, - ) - if direct_protocol_result is not None: - return direct_protocol_result try: packet = await SemanticContextAssembler(self.db).assemble( tenant_id=int(self.tenant_id), @@ -2628,6 +2650,14 @@ class KernelIngressFlowEntryMixin: for item in (getattr(gate_result, "allowed_actions", None) or []) if str(item or "").strip() } + if ( + str(active_context_kind or "").strip().lower() == "requirement_plan" + and "context.suspend_requirement_mode" in allowed_actions + ): + return await self._build_active_requirement_mode_exit_result( + topic=topic, + source_message_id=source_message_id, + ) followup_scope = self._resolve_active_context_followup_scope( followup_scope=str(getattr(decision, "followup_scope", "") or ""), active_context_kind=active_context_kind, @@ -2639,6 +2669,14 @@ class KernelIngressFlowEntryMixin: if followup_scope == "current_requirement": if "context.followup.current_requirement" not in allowed_actions: return None + business_frame = getattr(decision, "business_frame", None) + if hasattr(business_frame, "model_dump"): + business_frame = business_frame.model_dump() + requirement_plan_protocol = {} + if isinstance(business_frame, dict): + frame_meta = business_frame.get("metadata") + if isinstance(frame_meta, dict) and isinstance(frame_meta.get("requirement_plan_protocol"), dict): + requirement_plan_protocol = dict(frame_meta.get("requirement_plan_protocol") or {}) direct_result = await self._build_active_requirement_followup_result( topic_workspace_id=_safe_int(getattr(topic, "id", None)), message_text=message_text, @@ -2648,6 +2686,7 @@ class KernelIngressFlowEntryMixin: source_sender_open_id=source_sender_open_id, thread_context=thread_context, conversation_key=conversation_key, + requirement_plan_protocol=requirement_plan_protocol, ) if direct_result is not None: return direct_result diff --git a/backend/app/services/ai_pmo/ai_pmo_typed_executor_discovery/mixin_analysis.py b/backend/app/services/ai_pmo/ai_pmo_typed_executor_discovery/mixin_analysis.py index 4f98cb22..43519d99 100644 --- a/backend/app/services/ai_pmo/ai_pmo_typed_executor_discovery/mixin_analysis.py +++ b/backend/app/services/ai_pmo/ai_pmo_typed_executor_discovery/mixin_analysis.py @@ -18,6 +18,7 @@ class DiscoveryAnalysisMixin: strict_analysis_required = self._requires_strict_signal_analysis( message_text=message_text, requested_count=requested_count, + metadata=intent.metadata if isinstance(intent.metadata, dict) else {}, ) prompt = PROMPT_AI_PMO_SIGNAL_BATCH_ANALYSIS.format( message_text=str(message_text or "")[:1600], diff --git a/backend/app/services/ai_pmo/ai_pmo_typed_executor_execute/mixin_execute_query_project.py b/backend/app/services/ai_pmo/ai_pmo_typed_executor_execute/mixin_execute_query_project.py index 49b2aeae..34b0f493 100644 --- a/backend/app/services/ai_pmo/ai_pmo_typed_executor_execute/mixin_execute_query_project.py +++ b/backend/app/services/ai_pmo/ai_pmo_typed_executor_execute/mixin_execute_query_project.py @@ -94,6 +94,7 @@ class ExecuteQueryProjectMixin: ("continue", "往下"), ) _REQUIREMENT_PLAN_PROTOCOL_STRIP_CHARS = " \t\r\n,,。;;::!!??、" + _REQUIREMENT_PLAN_PROTOCOL_ACTIONS = frozenset({"continue", "clarify", "reroll", "draft"}) @classmethod async def _try_direct_project_lookup_result( @@ -103,30 +104,22 @@ class ExecuteQueryProjectMixin: message_text: str, project_id: Optional[int], message_intent: Optional[str] = None, + query_semantics: Optional[dict[str, Any]] = None, source_policy: Optional[str] = None, ) -> Optional[dict[str, Any]]: if str(source_policy or "").strip().lower() == "external": return None normalized_intent = str(message_intent or "").strip().lower() - keyword_extractor = getattr(rag_service, "_extract_project_lookup_keyword", None) - extracted_keyword = "" - if callable(keyword_extractor): - try: - extracted_keyword = str(keyword_extractor(message_text) or "").strip() - except Exception: - extracted_keyword = "" if normalized_intent not in {"project_lookup", "project_lookup_question"}: - has_project_subject = bool(project_id is not None or extracted_keyword) - has_lookup_shape = bool( - re.search(r"项目\s*#?\d+", str(message_text or ""), re.IGNORECASE) - or re.search(r"(项目列表|项目清单|当前项目)", str(message_text or ""), re.IGNORECASE) - ) - if not has_project_subject or not has_lookup_shape: - return None + return None existence_handler = getattr(rag_service, "_try_fast_project_existence_answer", None) if not callable(existence_handler): return None - existence_result = await existence_handler(clean_q=message_text, project_id=project_id) + existence_result = await existence_handler( + clean_q=message_text, + project_id=project_id, + query_semantics=query_semantics, + ) if not isinstance(existence_result, dict): return None answer = str(existence_result.get("answer") or "").strip() @@ -272,14 +265,8 @@ class ExecuteQueryProjectMixin: @staticmethod def _build_lightweight_chat_answer(*, message_text: str) -> str: - normalized = re.sub(r"\s+", "", str(message_text or "")).lower() - if any(token in normalized for token in ("天气", "下雨", "温度", "散步", "走走")): - return "我这边没接实时天气数据,没法直接报天气。如果你更在意温度、下雨还是适合散步,我可以按出行场景帮你判断。" - if any(token in normalized for token in ("早上好", "下午好", "晚上好", "在线吗", "你在线吗", "在吗")): - return "我在线。你想继续聊需求、看项目状态,或者只是随便聊两句都可以。" - if any(token in normalized for token in ("陪我聊", "聊两句", "最近有点累", "先不推进需求")): - return "可以,今天先不推进需求也没问题。你想先放松聊两句,还是等会儿再继续把需求收一版,我都接得住。" - return "我在。你想继续聊需求、看项目状态,或者随便聊两句都可以。" + _ = message_text + return "我在线。你想先随便聊两句,还是继续看项目、需求或当前进展,我都能接着来。" @staticmethod def _build_requirement_clarify_answer(*, message_text: str) -> str: @@ -315,6 +302,143 @@ class ExecuteQueryProjectMixin: def _normalize_requirement_plan_protocol_text(value: Any) -> str: return re.sub(r"[\s,,。;;::!!??、]+", "", str(value or "")).strip() + @classmethod + def _normalize_requirement_plan_choice_candidate_text(cls, value: Any) -> str: + stripped_chars = set(str(cls._REQUIREMENT_PLAN_PROTOCOL_STRIP_CHARS or "")) + compact_chars: list[str] = [] + for ch in str(value or ""): + if ch.isspace() or ch in stripped_chars or ch == ".": + continue + compact_chars.append(ch.upper()) + return "".join(compact_chars).strip() + + @classmethod + def _normalize_requirement_plan_protocol_payload(cls, payload: Any) -> dict[str, str]: + if hasattr(payload, "model_dump"): + payload = payload.model_dump() + if not isinstance(payload, dict): + return {} + choice = str(payload.get("choice") or "").strip().upper() + action = str(payload.get("action") or "").strip().lower() + content_text = str( + payload.get("content_text") + or payload.get("remaining_text") + or payload.get("text_after_choice") + or "" + ).strip() + normalized: dict[str, str] = {} + if choice in {"A", "B", "C", "D"}: + normalized["choice"] = choice + if action in cls._REQUIREMENT_PLAN_PROTOCOL_ACTIONS: + normalized["action"] = action + if content_text or normalized: + normalized["content_text"] = content_text + normalized["text_after_choice"] = content_text + return normalized + + @classmethod + def _extract_explicit_requirement_plan_protocol_from_text(cls, message_text: str) -> dict[str, str]: + raw = str(message_text or "").strip() + parsed_choice = cls._parse_requirement_plan_choice_protocol(raw) + choice = "" + text_after_choice = raw + if isinstance(parsed_choice, dict): + choice = str(parsed_choice.get("choice") or "").strip().upper() + text_after_choice = str(parsed_choice.get("remaining_text") or "").strip() + action_payload = cls._match_requirement_plan_protocol_action(text_after_choice if choice else raw) + action = str((action_payload or {}).get("action") or "").strip().lower() + content_text = str((action_payload or {}).get("remaining_text") or "").strip() + if choice not in {"A", "B", "C", "D"} and action not in cls._REQUIREMENT_PLAN_PROTOCOL_ACTIONS: + return {} + return cls._normalize_requirement_plan_protocol_payload( + { + "choice": choice, + "action": action, + "content_text": content_text, + "text_after_choice": text_after_choice, + } + ) + + @classmethod + def _derive_requirement_plan_protocol_from_card( + cls, + *, + message_text: str, + card: dict[str, Any], + ) -> dict[str, str]: + last_round_options = card.get("last_round_options") if isinstance(card.get("last_round_options"), dict) else {} + raw_options = last_round_options.get("options") if isinstance(last_round_options.get("options"), list) else [] + option_keys = { + str(option.get("key") or "").strip().upper() + for option in raw_options + if isinstance(option, dict) and str(option.get("key") or "").strip() + } + candidate = cls._normalize_requirement_plan_choice_candidate_text(message_text) + if candidate in option_keys and len(candidate) == 1: + return { + "choice": candidate, + "content_text": "", + "text_after_choice": "", + } + parsed_choice = cls._parse_requirement_plan_choice_protocol(message_text) + if isinstance(parsed_choice, dict): + parsed_key = str(parsed_choice.get("choice") or "").strip().upper() + if parsed_key in option_keys: + remaining_text = str(parsed_choice.get("remaining_text") or "").strip() + return { + "choice": parsed_key, + "content_text": remaining_text, + "text_after_choice": remaining_text, + } + return {} + + @classmethod + def _extract_requirement_plan_protocol_from_business_frame(cls, business_frame: Any) -> dict[str, str]: + if hasattr(business_frame, "model_dump"): + business_frame = business_frame.model_dump() + if not isinstance(business_frame, dict): + return {} + metadata = business_frame.get("metadata") + if not isinstance(metadata, dict): + return {} + return cls._normalize_requirement_plan_protocol_payload(metadata.get("requirement_plan_protocol")) + + @classmethod + def _extract_requirement_plan_protocol_from_semantic_bundle(cls, semantic_bundle: Any) -> dict[str, str]: + if hasattr(semantic_bundle, "to_dict"): + semantic_bundle = semantic_bundle.to_dict() + if hasattr(semantic_bundle, "model_dump"): + semantic_bundle = semantic_bundle.model_dump() + if not isinstance(semantic_bundle, dict): + return {} + diagnostics = semantic_bundle.get("diagnostics") + if not isinstance(diagnostics, dict): + return {} + return cls._extract_requirement_plan_protocol_from_business_frame(diagnostics.get("business_frame")) + + @classmethod + def _resolve_requirement_plan_protocol( + cls, + *, + message_text: str, + semantic_bundle: Any = None, + card: Optional[dict[str, Any]] = None, + ) -> dict[str, str]: + explicit_protocol = cls._extract_explicit_requirement_plan_protocol_from_text(message_text) + if explicit_protocol: + return explicit_protocol + semantic_protocol = cls._normalize_requirement_plan_protocol_payload( + cls._extract_requirement_plan_protocol_from_semantic_bundle(semantic_bundle) + ) + if semantic_protocol: + return semantic_protocol + if isinstance(card, dict) and card: + return cls._derive_requirement_plan_protocol_from_card( + message_text=message_text, + card=card, + ) + return {} + @staticmethod def _is_requirement_plan_choice_protocol_prefix_char(value: str) -> bool: if not value or len(value) != 1: @@ -384,17 +508,21 @@ class ExecuteQueryProjectMixin: @classmethod def _parse_requirement_plan_choice_protocol(cls, message_text: str) -> Optional[dict[str, str]]: raw = str(message_text or "") - start = cls._find_requirement_plan_choice_token_start(raw, 0) - if start is None: - return None - parsed = cls._consume_requirement_plan_choice_token(text=raw, start=start) - if parsed is None: - return None - choice, end = parsed - return { - "choice": choice, - "remaining_text": raw[end:].strip(), - } + search_start = 0 + while search_start < len(raw): + start = cls._find_requirement_plan_choice_token_start(raw, search_start) + if start is None: + search_start += 1 + continue + parsed = cls._consume_requirement_plan_choice_token(text=raw, start=start) + if parsed is not None: + choice, end = parsed + return { + "choice": choice, + "remaining_text": raw[end:].strip(), + } + search_start = max(int(start) + 1, int(search_start) + 1) + return None @classmethod def _match_requirement_plan_protocol_action(cls, message_text: str) -> Optional[dict[str, str]]: @@ -466,18 +594,36 @@ class ExecuteQueryProjectMixin: return compact in cls._REQUIREMENT_PLAN_PROTOCOL_CONTINUATION_TOKENS @classmethod - def _extract_requirement_plan_seed_material_text(cls, message_text: str) -> str: - protocol = cls._parse_requirement_plan_protocol_reply(message_text) - cleaned = cls._sanitize_requirement_plan_text(str(protocol.get("content_text") or "")) + def _extract_requirement_plan_seed_material_text( + cls, + message_text: str, + *, + requirement_plan_protocol: Optional[dict[str, Any]] = None, + ) -> str: + protocol = cls._normalize_requirement_plan_protocol_payload(requirement_plan_protocol) + cleaned = cls._sanitize_requirement_plan_text( + str(protocol.get("content_text") or message_text or "") + ) if len(cleaned) < 4: return "" - if cls._is_requirement_plan_protocol_continuation_text(cleaned): + if str(protocol.get("action") or "").strip().lower() in {"reroll", "clarify"} and not str( + protocol.get("content_text") or "" + ).strip(): return "" return cleaned @classmethod - def _merge_requirement_plan_source_material(cls, *, current_value: Any, message_text: str) -> str: - seed_text = cls._extract_requirement_plan_seed_material_text(message_text) + def _merge_requirement_plan_source_material( + cls, + *, + current_value: Any, + message_text: str, + requirement_plan_protocol: Optional[dict[str, Any]] = None, + ) -> str: + seed_text = cls._extract_requirement_plan_seed_material_text( + message_text, + requirement_plan_protocol=requirement_plan_protocol, + ) parts = [segment.strip() for segment in str(current_value or "").split("|") if segment.strip()] merged: list[str] = [] for part in parts: @@ -491,10 +637,22 @@ class ExecuteQueryProjectMixin: merged.append(f"用户:{seed_text}") return " | ".join(dict.fromkeys(item for item in merged if item)).strip()[:1500] + @classmethod + def _requirement_plan_source_material_has_grounded_seed(cls, value: Any) -> bool: + raw = cls._sanitize_requirement_plan_source_material(value) + if not raw: + return False + for segment in str(raw).split("|"): + candidate = str(segment or "").strip() + if candidate.startswith("用户:"): + candidate = candidate[len("用户:") :].strip() + if len(cls._sanitize_requirement_plan_text(candidate)) >= 12: + return True + return False + @classmethod def _requirement_plan_card_has_grounded_identity(cls, *, card: dict[str, Any]) -> bool: - source_material = cls._sanitize_requirement_plan_source_material(card.get("source_material")) - if source_material: + if cls._requirement_plan_source_material_has_grounded_seed(card.get("source_material")): return True title = str(card.get("title") or "").strip() return bool(title and title != "需求收敛草稿") @@ -505,8 +663,9 @@ class ExecuteQueryProjectMixin: *, message_text: str, card: dict[str, Any], + requirement_plan_protocol: Optional[dict[str, Any]] = None, ) -> bool: - protocol = cls._parse_requirement_plan_protocol_reply(message_text) + protocol = cls._normalize_requirement_plan_protocol_payload(requirement_plan_protocol) choice = str(protocol.get("choice") or "").strip().upper() if not choice: return False @@ -1186,14 +1345,16 @@ class ExecuteQueryProjectMixin: *, turns: list[dict[str, Any]], current_message_text: Optional[str] = None, + current_requirement_plan_protocol: Optional[dict[str, Any]] = None, + current_message_intent: Optional[str] = None, ) -> bool: - saw_choice_reply = bool(cls._extract_requirement_plan_choice(str(current_message_text or ""))) + _ = current_message_text + normalized_protocol = cls._normalize_requirement_plan_protocol_payload(current_requirement_plan_protocol) + saw_choice_reply = bool(str(normalized_protocol.get("choice") or "").strip().upper()) + normalized_intent = str(current_message_intent or "").strip().lower() saw_requirement_contract = False for item in turns: - user_text = str(item.get("user") or "").strip() assistant_text = str(item.get("assistant") or "").strip() - if cls._extract_requirement_plan_choice(user_text): - saw_choice_reply = True requirement_plan_context = ( item.get("requirement_plan_context") if isinstance(item.get("requirement_plan_context"), dict) @@ -1214,6 +1375,12 @@ class ExecuteQueryProjectMixin: saw_requirement_contract = True if cls._assistant_reply_contains_requirement_plan_contract(assistant_text): saw_requirement_contract = True + if normalized_intent in { + "request_draft_generation", + "request_draft_generation_from_context", + "continue_contextual_qa", + }: + return saw_requirement_contract return saw_choice_reply and saw_requirement_contract @classmethod @@ -1417,6 +1584,8 @@ class ExecuteQueryProjectMixin: topic: Optional[MountedTopicWorkspace], current_message_text: Optional[str] = None, prefer_draft_snapshot: bool = True, + current_requirement_plan_protocol: Optional[dict[str, Any]] = None, + current_message_intent: Optional[str] = None, ) -> tuple[dict[str, Any], list[dict[str, Any]]]: if topic is None: return {}, [] @@ -1435,10 +1604,13 @@ class ExecuteQueryProjectMixin: if not self._reply_context_contains_requirement_plan_contract( turns=contextual_turns, current_message_text=current_message_text, + current_requirement_plan_protocol=current_requirement_plan_protocol, + current_message_intent=current_message_intent, ): fallback_snapshot = await self._load_unique_grounded_requirement_plan_scope_snapshot( topic=topic, current_message_text=current_message_text, + current_requirement_plan_protocol=current_requirement_plan_protocol, ) return fallback_snapshot, contextual_turns @@ -1458,6 +1630,7 @@ class ExecuteQueryProjectMixin: *, topic: Optional[MountedTopicWorkspace], current_message_text: Optional[str] = None, + current_requirement_plan_protocol: Optional[dict[str, Any]] = None, ) -> dict[str, Any]: if topic is None: return {} @@ -1477,8 +1650,10 @@ class ExecuteQueryProjectMixin: ((current_card.get("last_round_options") or {}) if isinstance(current_card.get("last_round_options"), dict) else {}).get("field") or "" ).strip() - choice_reply = bool(self._extract_requirement_plan_choice(str(current_message_text or ""))) - choice_value = self._extract_requirement_plan_choice(str(current_message_text or "")) + normalized_protocol = self._normalize_requirement_plan_protocol_payload(current_requirement_plan_protocol) + protocol_choice = str(normalized_protocol.get("choice") or "").strip().upper() + choice_reply = bool(protocol_choice) + choice_value = protocol_choice stmt = select(MountedTopicWorkspace).where( MountedTopicWorkspace.tenant_id == int(self.tenant_id), @@ -1672,6 +1847,7 @@ class ExecuteQueryProjectMixin: thread_context: Optional[dict[str, Any]], conversation_key: Optional[str], message_text: str, + message_intent: Optional[str] = None, ) -> tuple[Optional[MountedTopicWorkspace], Optional[MountedDraftWorkspace]]: context_topic = await self._find_contextual_reply_topic( source_system=source_system, @@ -1686,6 +1862,7 @@ class ExecuteQueryProjectMixin: contextual_snapshot, contextual_turns = await self._load_contextual_requirement_plan_snapshot( topic=context_topic, current_message_text=message_text, + current_message_intent=message_intent, ) if not contextual_snapshot: return None, None @@ -1769,6 +1946,7 @@ class ExecuteQueryProjectMixin: thread_context: Optional[dict[str, Any]], conversation_key: Optional[str], message_text: str, + requirement_plan_protocol: Optional[dict[str, Any]] = None, ) -> tuple[Optional[MountedTopicWorkspace], Optional[MountedDraftWorkspace]]: if topic is None or draft is None: return topic, draft @@ -1794,11 +1972,18 @@ class ExecuteQueryProjectMixin: contextual_topic_id=int(getattr(context_topic, "id", 0) or 0), ) return topic, draft + if self._normalize_requirement_plan_protocol_payload(requirement_plan_protocol) and int( + getattr(context_topic, "id", 0) or 0 + ) == int(getattr(topic, "id", 0) or 0): + current_card = self._sanitize_requirement_plan_card_runtime_context(card=dict(draft.requirement_card or {})) + if self._requirement_plan_card_has_grounded_identity(card=current_card): + return topic, draft contextual_snapshot, contextual_turns = await self._load_contextual_requirement_plan_snapshot( topic=context_topic, current_message_text=message_text, prefer_draft_snapshot=int(getattr(context_topic, "id", 0) or 0) != int(getattr(topic, "id", 0) or 0), + current_requirement_plan_protocol=requirement_plan_protocol, ) if not contextual_snapshot: return topic, draft @@ -1863,6 +2048,7 @@ class ExecuteQueryProjectMixin: contextual_snapshot, contextual_turns = await self._load_contextual_requirement_plan_snapshot( topic=context_topic, current_message_text=message_text, + current_message_intent=message_intent, ) card = self._sanitize_requirement_plan_card_runtime_context( card=dict(contextual_snapshot) if isinstance(contextual_snapshot, dict) else {} @@ -2048,14 +2234,21 @@ class ExecuteQueryProjectMixin: return "先把这项需求的目标、服务对象、第一版范围和交付方式收清楚,再决定是否正式创建。" @classmethod - def _extract_requirement_plan_updates(cls, *, message_text: str, card: dict[str, Any]) -> dict[str, Any]: + def _extract_requirement_plan_updates( + cls, + *, + message_text: str, + card: dict[str, Any], + requirement_plan_protocol: Optional[dict[str, Any]] = None, + ) -> dict[str, Any]: raw = cls._sanitize_requirement_plan_text(message_text) normalized = WorkflowSemanticPlanner._normalize_text(raw) updates: dict[str, Any] = {} profile = cls._detect_requirement_plan_profile(message_text=raw, card=card) - protocol = cls._parse_requirement_plan_protocol_reply(raw) + protocol = cls._normalize_requirement_plan_protocol_payload(requirement_plan_protocol) choice = str(protocol.get("choice") or "").strip().upper() or None - cleaned_raw = cls._sanitize_requirement_plan_text(str(protocol.get("content_text") or "")) + protocol_action = str(protocol.get("action") or "").strip().lower() + cleaned_raw = cls._sanitize_requirement_plan_text(str(protocol.get("content_text") or raw or "")) current_card = dict(card or {}) current_card["plan_profile"] = profile missing_fields = cls._requirement_plan_missing_fields(current_card) @@ -2163,11 +2356,17 @@ class ExecuteQueryProjectMixin: and missing_fields and missing_fields[0] == "first_scope" and len(cleaned_raw.strip()) >= 4 - and not cls._is_requirement_plan_protocol_continuation_text(cleaned_raw) + and protocol_action not in {"reroll", "clarify"} ): updates["first_scope"] = cleaned_raw if updates.get("first_scope") and not card.get("delivery_mode"): - updates["delivery_mode"] = "项目级试点(带基础审批和回滚)" + updates["delivery_mode"] = cls._default_requirement_plan_delivery_mode( + card={ + **current_card, + **updates, + "plan_profile": profile, + } + ) if not card.get("delivery_mode"): if any(token in normalized for token in ("poc", "验证版", "内部验证", "内部poc")): @@ -2210,6 +2409,21 @@ class ExecuteQueryProjectMixin: return "" return f"好,我先按这个方向继续记:{';'.join(parts[:2])}。" + @classmethod + def _default_requirement_plan_delivery_mode(cls, *, card: dict[str, Any]) -> str: + profile = cls._detect_requirement_plan_profile(message_text="", card=card) + target_user = cls._summarize_requirement_plan_field_value( + profile=profile, + field="target_user", + value=card.get("target_user"), + ) + normalized_target = WorkflowSemanticPlanner._normalize_text(target_user) + if any(token in normalized_target for token in ("普通消费者", "用户", "导购", "运营团队")): + return "小范围真实用户试用" + if any(token in normalized_target for token in ("投顾", "研究团队", "交易团队", "投研")): + return "小范围投研试用" + return "项目级试点(带基础审批和回滚)" + @classmethod def _resolve_requirement_plan_active_field(cls, *, card: dict[str, Any]) -> str: last_round_options = card.get("last_round_options") if isinstance(card.get("last_round_options"), dict) else {} @@ -2273,16 +2487,9 @@ class ExecuteQueryProjectMixin: "你可以直接补一句最关键的一项,或回“重出这轮”让我把当前问题重给一版。" ) - @classmethod - def _is_requirement_plan_exit_request(cls, message_text: str) -> bool: - normalized = cls._normalize_requirement_plan_protocol_text(message_text) - return normalized in { - "退出需求模式", - "结束需求模式", - "取消需求模式", - "先退出需求模式", - "先结束需求模式", - } + @staticmethod + def _is_requirement_mode_exit_intent(message_intent: Optional[str]) -> bool: + return str(message_intent or "").strip().lower() == "requirement_mode_exit" @staticmethod def _is_requirement_mode_locked( @@ -2542,7 +2749,7 @@ class ExecuteQueryProjectMixin: payload=normalized_payload, packet=packet, ) - cls._guard_requirement_plan_round_against_stale_context(payload=normalized_payload, packet=packet) + cls._guard_requirement_plan_round_against_structure_drift(payload=normalized_payload, packet=packet) return normalized_payload @classmethod @@ -2673,50 +2880,127 @@ class ExecuteQueryProjectMixin: return fragments @classmethod - def _guard_requirement_plan_round_against_stale_context( + def _requirement_plan_packet_anchor_candidates( + cls, + *, + packet: dict[str, Any], + include_latest_message_seed: bool = False, + ) -> list[str]: + current_context = packet.get("current_context") if isinstance(packet.get("current_context"), dict) else {} + profile = str(packet.get("profile") or "it_project").strip() or "it_project" + anchors: list[str] = [] + + title = str(current_context.get("title") or "").strip() + if not title and include_latest_message_seed: + latest_user_message = str(packet.get("latest_user_message") or "").strip() + if latest_user_message: + title = cls._derive_requirement_plan_title( + message_text=latest_user_message, + card={"plan_profile": profile}, + ) + goal = str(current_context.get("goal") or "").strip() + if title: + anchors.append(title) + if goal: + anchors.append(goal) + + latest_user_reply_summary = str(packet.get("latest_user_reply_summary") or "").strip() + if latest_user_reply_summary: + anchors.append(latest_user_reply_summary) + + collected_field_summary = str(packet.get("collected_field_summary") or "").strip() + if collected_field_summary: + anchors.append(collected_field_summary) + + collected_fields = packet.get("collected_fields") if isinstance(packet.get("collected_fields"), dict) else {} + for field, value in collected_fields.items(): + raw_value = str(value or "").strip() + if not raw_value: + continue + anchors.append(raw_value) + summary = cls._summarize_requirement_plan_field_value( + profile=profile, + field=str(field or "").strip(), + value=raw_value, + ) + if summary and summary != raw_value: + anchors.append(summary) + + deduped: list[str] = [] + seen: set[str] = set() + for anchor in anchors: + normalized_anchor = WorkflowSemanticPlanner._normalize_text(anchor) + if len(normalized_anchor) < 2 or normalized_anchor in seen: + continue + seen.add(normalized_anchor) + deduped.append(anchor) + return deduped + + @classmethod + def _requirement_plan_anchor_matches_rendered(cls, *, rendered: str, anchors: list[str]) -> bool: + normalized_rendered = WorkflowSemanticPlanner._normalize_text(rendered) + if not normalized_rendered: + return False + for anchor in anchors: + normalized_anchor = WorkflowSemanticPlanner._normalize_text(anchor) + if len(normalized_anchor) < 2: + continue + if normalized_anchor in normalized_rendered: + return True + return False + + @classmethod + def _requirement_plan_anchor_overlaps_rendered( + cls, + *, + rendered: str, + anchors: list[str], + minimum_ratio: float = 0.18, + ) -> bool: + rendered_tokens = cls._extract_requirement_overlap_tokens(rendered) + if not rendered_tokens: + return False + for anchor in anchors: + anchor_tokens = cls._extract_requirement_overlap_tokens(anchor) + if not anchor_tokens: + continue + overlap = len(rendered_tokens & anchor_tokens) / max(len(anchor_tokens), 1) + if overlap >= minimum_ratio: + return True + return False + + @classmethod + def _requirement_plan_title_is_grounded(cls, title: Any) -> bool: + text = str(title or "").strip() + return bool(text and text not in {"需求收敛草稿", "当前需求", "当前这条需求", "当前 IT 项目"}) + + @classmethod + def _guard_requirement_plan_round_against_structure_drift( cls, *, payload: dict[str, Any], packet: dict[str, Any], ) -> None: - rendered = WorkflowSemanticPlanner._normalize_text(json.dumps(payload, ensure_ascii=False)) - context_material = { - "latest_user_message": packet.get("latest_user_message"), - "latest_user_reply_summary": packet.get("latest_user_reply_summary"), - "current_context": packet.get("current_context"), - "collected_fields": packet.get("collected_fields"), - "collected_field_summary": packet.get("collected_field_summary"), - } - context = WorkflowSemanticPlanner._normalize_text(json.dumps(context_material, ensure_ascii=False)) - if cls._requirement_plan_packet_allows_deployment_context(packet=packet): + missing_field = str(packet.get("missing_field") or "").strip() + collected_fields = packet.get("collected_fields") if isinstance(packet.get("collected_fields"), dict) else {} + if missing_field != "target_user": return - stale_terms = ( - "本地路由层", - "批量自动化部署", - "发布治理平台", - "云原生交付底座", - "平台运维团队", - "项目交付团队", - "任务编排平台版", - "脚本增强版", - "云原生交付版", - "环境接入闭环", - "治理闭环", - "部署治理", - "kubernetes", - "k8s", - "helm", - "argocd", - "terraform", + project_nature = str(collected_fields.get("project_nature") or "").strip() + if not project_nature: + return + fragments = cls._collect_requirement_plan_round_user_visible_fragments(payload) + rendered = " ".join(fragments) + collected_anchors = cls._requirement_plan_packet_anchor_candidates( + packet={ + "profile": packet.get("profile"), + "collected_fields": {"project_nature": project_nature}, + } ) - leaked = [term for term in stale_terms if term in rendered and term not in context] - if leaked: - raise ValueError(f"requirement_plan_round contains stale context terms: {', '.join(leaked[:3])}") - - @classmethod - def _requirement_plan_packet_allows_deployment_context(cls, *, packet: dict[str, Any]) -> bool: - _ = packet - return False + if collected_anchors and not cls._requirement_plan_anchor_matches_rendered( + rendered=rendered, + anchors=collected_anchors, + ): + raise ValueError("requirement_plan_round missing current structural anchors") @classmethod def _render_requirement_plan_round_payload(cls, payload: dict[str, Any]) -> str: @@ -2761,11 +3045,6 @@ class ExecuteQueryProjectMixin: if len(text) > max_chars: raise ValueError(f"requirement_plan_round 超出飞书文本预算:{len(text)}>{max_chars}") - @classmethod - def _requirement_plan_context_allows_deployment_terms(cls, *, context_material: Any) -> bool: - _ = context_material - return False - @classmethod def _build_requirement_plan_draft_packet( cls, @@ -3030,41 +3309,127 @@ class ExecuteQueryProjectMixin: raise ValueError(f"requirement_plan_draft leaks internal packet terms: {', '.join(leaked[:3])}") @classmethod - def _guard_requirement_plan_draft_against_stale_context( + def _guard_requirement_plan_draft_against_structure_drift( cls, *, payload: dict[str, Any], packet: dict[str, Any], ) -> None: - rendered = WorkflowSemanticPlanner._normalize_text(json.dumps(payload, ensure_ascii=False)) - context_material = { - "current_context": packet.get("current_context"), - "collected_fields": packet.get("collected_fields"), - "collected_field_summary": packet.get("collected_field_summary"), - } - if cls._requirement_plan_context_allows_deployment_terms(context_material=context_material): - return - stale_terms = ( - "审批、审计、权限一次做满", - "人员能力图谱", - "工作流状态与审计日志", - "确认门禁", - "状态流转和回滚治理", - "部署目标清单", - "环境参数与凭证", - "执行日志与失败日志", - "平台运维团队", - "回滚治理", - "部署治理", - "kubernetes", - "k8s", - "helm", - "argocd", - "terraform", - ) - leaked = [term for term in stale_terms if term in rendered] - if leaked: - raise ValueError(f"requirement_plan_draft contains stale context terms: {', '.join(leaked[:3])}") + profile = str(packet.get("profile") or "it_project").strip() or "it_project" + current_context = packet.get("current_context") if isinstance(packet.get("current_context"), dict) else {} + title_anchor = str(current_context.get("title") or "").strip() + if cls._requirement_plan_title_is_grounded(title_anchor): + title_value = str(payload.get("title") or "").strip() + if not cls._requirement_plan_anchor_matches_rendered(rendered=title_value, anchors=[title_anchor]): + raise ValueError("requirement_plan_draft structural bindings drift: title") + + rendered_payload = " ".join(cls._collect_requirement_topic_text_fragments(payload)) + continuity_anchors: list[str] = [] + goal_anchor = str(current_context.get("goal") or "").strip() + if goal_anchor: + continuity_anchors.append(goal_anchor) + collected_fields = packet.get("collected_fields") if isinstance(packet.get("collected_fields"), dict) else {} + for field in ("project_nature", "target_user", "tech_route", "first_scope", "delivery_mode"): + raw_value = str(collected_fields.get(field) or "").strip() + if not raw_value: + continue + field_anchors = [raw_value] + summary = cls._summarize_requirement_plan_field_value( + profile=profile, + field=field, + value=raw_value, + ) + if summary and summary != raw_value: + field_anchors.append(summary) + continuity_anchors.extend(field_anchors) + rendered_field_value = str(payload.get(field) or "").strip() + if rendered_field_value and not ( + cls._requirement_plan_anchor_matches_rendered( + rendered=rendered_field_value, + anchors=field_anchors, + ) + or cls._requirement_plan_anchor_overlaps_rendered( + rendered=rendered_field_value, + anchors=field_anchors, + ) + ): + raise ValueError(f"requirement_plan_draft structural bindings drift: {field}") + if continuity_anchors and not ( + cls._requirement_plan_anchor_matches_rendered(rendered=rendered_payload, anchors=continuity_anchors) + or cls._requirement_plan_anchor_overlaps_rendered(rendered=rendered_payload, anchors=continuity_anchors) + ): + raise ValueError("requirement_plan_draft structural bindings drift: continuity") + + @classmethod + def _repair_requirement_plan_draft_structural_bindings( + cls, + *, + payload: Any, + packet: dict[str, Any], + ) -> Any: + if not isinstance(payload, dict): + return payload + repaired = dict(payload) + profile = str(packet.get("profile") or "it_project").strip() or "it_project" + current_context = packet.get("current_context") if isinstance(packet.get("current_context"), dict) else {} + collected_fields = packet.get("collected_fields") if isinstance(packet.get("collected_fields"), dict) else {} + drifted_fields: list[str] = [] + for field in ("project_nature", "target_user", "tech_route", "first_scope", "delivery_mode"): + raw_value = str(collected_fields.get(field) or "").strip() + if not raw_value: + continue + field_anchors = [raw_value] + summary = cls._summarize_requirement_plan_field_value( + profile=profile, + field=field, + value=raw_value, + ) + if summary and summary != raw_value: + field_anchors.append(summary) + rendered_field_value = str(repaired.get(field) or "").strip() + if rendered_field_value and ( + cls._requirement_plan_anchor_matches_rendered(rendered=rendered_field_value, anchors=field_anchors) + or cls._requirement_plan_anchor_overlaps_rendered(rendered=rendered_field_value, anchors=field_anchors) + ): + continue + repaired[field] = raw_value + drifted_fields.append(field) + if field == "target_user": + repaired["target_customers"] = summary or raw_value + + if drifted_fields: + title = str(current_context.get("title") or "").strip() + goal = str(current_context.get("goal") or "").strip() + collected_summary = str(packet.get("collected_field_summary") or "").strip() + known_parts = [part for part in (title, collected_summary, goal) if str(part or "").strip()] + if known_parts: + repaired["known_info"] = f"目前已经明确:{';'.join(known_parts[:3])}。" + + requirement_items = repaired.get("requirement_items") if isinstance(repaired.get("requirement_items"), list) else [] + section_map = { + "project_nature": "项目性质", + "target_user": "服务对象", + "tech_route": "技术路线", + "first_scope": "第一版范围", + } + for item in requirement_items: + if not isinstance(item, dict): + continue + section = str(item.get("section") or "").strip() + matched_field = next((name for name, label in section_map.items() if section == label), None) + if not matched_field or matched_field not in drifted_fields: + continue + raw_value = str(collected_fields.get(matched_field) or "").strip() + summary = cls._summarize_requirement_plan_field_value( + profile=profile, + field=matched_field, + value=raw_value, + ) + item["title"] = summary or raw_value + item["description"] = raw_value + repaired["requirement_items"] = requirement_items + + return repaired @classmethod def _validate_requirement_plan_draft_payload( @@ -3162,7 +3527,7 @@ class ExecuteQueryProjectMixin: if len(external_signal_sources) < 3: raise ValueError("requirement_plan_draft external_signal_sources must contain at least 3 external sources") cls._guard_requirement_plan_draft_against_internal_context_leaks(payload=normalized_payload) - cls._guard_requirement_plan_draft_against_stale_context(payload=normalized_payload, packet=packet) + cls._guard_requirement_plan_draft_against_structure_drift(payload=normalized_payload, packet=packet) return normalized_payload @classmethod @@ -3290,6 +3655,7 @@ class ExecuteQueryProjectMixin: timeout=45, ) payload = extraction.payload if extraction is not None and isinstance(extraction.payload, dict) else None + payload = self._repair_requirement_plan_draft_structural_bindings(payload=payload, packet=packet) return self._validate_requirement_plan_draft_payload(payload=payload, packet=packet) except Exception as exc: # noqa: BLE001 last_error = exc @@ -4106,6 +4472,7 @@ class ExecuteQueryProjectMixin: has_requirement_anchor: bool, topic: Optional[MountedTopicWorkspace], draft: Optional[MountedDraftWorkspace], + requirement_plan_protocol: Optional[dict[str, Any]] = None, ) -> bool: if topic is None or draft is None: return False @@ -4128,8 +4495,9 @@ class ExecuteQueryProjectMixin: }: return True - if self._extract_requirement_plan_choice(message_text): - return True + normalized_protocol = self._normalize_requirement_plan_protocol_payload(requirement_plan_protocol) + if str(normalized_protocol.get("choice") or "").strip().upper(): + return draft_state not in {"ready", "draft_pending_confirm"} nontrivial_seed = self._sanitize_requirement_plan_text(message_text).strip() if len(nontrivial_seed) < 6: @@ -4151,12 +4519,14 @@ class ExecuteQueryProjectMixin: has_requirement_anchor: bool, topic: Optional[MountedTopicWorkspace], draft: Optional[MountedDraftWorkspace], + requirement_plan_protocol: Optional[dict[str, Any]] = None, ) -> bool: if topic is None or draft is None: return False if has_requirement_anchor: return False - if self._extract_requirement_plan_choice(message_text): + normalized_protocol = self._normalize_requirement_plan_protocol_payload(requirement_plan_protocol) + if str(normalized_protocol.get("choice") or "").strip().upper(): return False normalized_message_intent = str(message_intent or "").strip().lower() if normalized_message_intent not in { @@ -4194,6 +4564,27 @@ class ExecuteQueryProjectMixin: ) return overlap_ratio < 0.18 + @staticmethod + def _should_defer_requirement_mode_lock_for_query_answer_contract_detection( + *, + message_intent: Optional[str], + topic: Optional[MountedTopicWorkspace], + draft: Optional[MountedDraftWorkspace], + has_requirement_anchor: bool, + requirement_plan_protocol: Optional[dict[str, Any]] = None, + ) -> bool: + if has_requirement_anchor or topic is None or draft is None: + return False + if str(message_intent or "").strip().lower() not in {"query", "query.answer"}: + return False + draft_state = str(getattr(draft, "state", "") or "").strip().lower() + normalized_protocol = ExecuteQueryProjectMixin._normalize_requirement_plan_protocol_payload( + requirement_plan_protocol + ) + if str(normalized_protocol.get("choice") or "").strip().upper() and draft_state in {"ready", "draft_pending_confirm"}: + return True + return draft_state == "draft_pending_confirm" + @classmethod def _requirement_plan_identity_conflict_fields( cls, @@ -4266,6 +4657,9 @@ class ExecuteQueryProjectMixin: thread_context=thread_context, conversation_key=conversation_key, topic_switch=topic_switch, + requirement_plan_protocol=self._extract_requirement_plan_protocol_from_semantic_bundle( + (intent.metadata or {}).get("semantic_bundle") if isinstance(intent.metadata, dict) else {} + ), ) direct_query_type = ( str((direct_result.get("rag_result") or {}).get("query_type") or "").strip().lower() @@ -4352,6 +4746,7 @@ class ExecuteQueryProjectMixin: thread_context: Optional[dict[str, Any]], conversation_key: Optional[str], topic_switch: Optional[str] = None, + requirement_plan_protocol: Optional[dict[str, Any]] = None, ) -> dict[str, Any]: force_new_topic = str(topic_switch or "").strip().lower() == "new" topic, draft = await self._load_requirement_plan_workspace( @@ -4366,7 +4761,11 @@ class ExecuteQueryProjectMixin: ) if topic is None or draft is None: profile = self._detect_requirement_plan_profile(message_text=message_text, card={}) - card = self._extract_requirement_plan_updates(message_text=message_text, card={"plan_profile": profile}) + card = self._extract_requirement_plan_updates( + message_text=message_text, + card={"plan_profile": profile}, + requirement_plan_protocol=requirement_plan_protocol, + ) try: next_question = await self._build_requirement_plan_next_question( missing_field=self._requirement_plan_missing_fields(card)[0] if self._requirement_plan_missing_fields(card) else "first_scope", @@ -4437,9 +4836,17 @@ class ExecuteQueryProjectMixin: thread_context=thread_context, conversation_key=conversation_key, message_text=message_text, + requirement_plan_protocol=requirement_plan_protocol, ) card = self._sanitize_requirement_plan_card_runtime_context(card=dict(draft.requirement_card or {})) - protocol = self._parse_requirement_plan_protocol_reply(message_text) + protocol = self._normalize_requirement_plan_protocol_payload(requirement_plan_protocol) + if not protocol: + protocol = self._extract_explicit_requirement_plan_protocol_from_text(message_text) + if not protocol: + protocol = self._derive_requirement_plan_protocol_from_card( + message_text=message_text, + card=card, + ) protocol_choice = str(protocol.get("choice") or "").strip().upper() protocol_action = str(protocol.get("action") or "").strip().lower() protocol_content_text = str(protocol.get("content_text") or "").strip() @@ -4467,6 +4874,7 @@ class ExecuteQueryProjectMixin: merged_source_material = self._merge_requirement_plan_source_material( current_value=card.get("source_material"), message_text=planning_message_text, + requirement_plan_protocol=protocol, ) if merged_source_material: card["source_material"] = merged_source_material @@ -4474,10 +4882,15 @@ class ExecuteQueryProjectMixin: has_choice_reply = bool(protocol_choice) has_nontrivial_seed_text = len(self._sanitize_requirement_plan_text(message_text).strip()) >= 6 has_grounded_requirement_identity = self._requirement_plan_card_has_grounded_identity(card=card) - if not has_grounded_requirement_identity and has_choice_reply: + needs_scope_snapshot_repair = has_choice_reply and ( + not has_grounded_requirement_identity + or str(card.get("title") or "").strip() == "需求收敛草稿" + ) + if needs_scope_snapshot_repair: scoped_snapshot = await self._load_unique_grounded_requirement_plan_scope_snapshot( topic=topic, current_message_text=planning_message_text, + current_requirement_plan_protocol=protocol, ) if scoped_snapshot: card = self._sanitize_requirement_plan_card_runtime_context( @@ -4526,7 +4939,11 @@ class ExecuteQueryProjectMixin: workflow_route=ROUTE_REQUIREMENT_PLAN_COLLECT, ) - if self._is_low_information_choice_reply_without_option_map(message_text=message_text, card=card): + if self._is_low_information_choice_reply_without_option_map( + message_text=message_text, + card=card, + requirement_plan_protocol=protocol, + ): return self._build_direct_query_result( answer=( "我没拿到上一轮 A/B/C 选项表,不能安全继续解释这个选项。" @@ -4545,7 +4962,11 @@ class ExecuteQueryProjectMixin: message_text=planning_message_text, ) - updates = self._extract_requirement_plan_updates(message_text=planning_message_text, card=card) + updates = self._extract_requirement_plan_updates( + message_text=planning_message_text, + card=card, + requirement_plan_protocol=protocol, + ) card.update(updates) profile = self._detect_requirement_plan_profile(message_text=planning_message_text, card=card) card["plan_profile"] = profile @@ -4696,6 +5117,7 @@ class ExecuteQueryProjectMixin: requirement_plan_context = self._build_requirement_plan_context_snapshot(card=card) if requirement_plan_context: result["requirement_plan_context"] = requirement_plan_context + result["reply_template_key"] = "requirement_plan_followup" return result async def _build_assignee_recommendation_answer( @@ -5045,6 +5467,23 @@ class ExecuteQueryProjectMixin: metadata = intent.metadata if isinstance(intent.metadata, dict) else {} semantic_bundle = metadata.get("semantic_bundle") if isinstance(metadata.get("semantic_bundle"), dict) else {} semantic_diagnostics = semantic_bundle.get("diagnostics") if isinstance(semantic_bundle.get("diagnostics"), dict) else {} + interaction_boundary = ( + dict(semantic_diagnostics.get("interaction_boundary") or {}) + if isinstance(semantic_diagnostics.get("interaction_boundary"), dict) + else {} + ) + answer_control = ( + dict(semantic_diagnostics.get("answer_control") or {}) + if isinstance(semantic_diagnostics.get("answer_control"), dict) + else {} + ) + interaction_level = str( + answer_control.get("effective_level") + or interaction_boundary.get("requested_level") + or "" + ).strip().lower() or None + if interaction_level not in {"status", "recommendation", "draft", "execute", "clarify"}: + interaction_level = None semantic_planner_degraded = bool( metadata.get("semantic_planner_degraded") or semantic_diagnostics.get("semantic_planner_degraded") ) @@ -5185,11 +5624,37 @@ class ExecuteQueryProjectMixin: active_requirement_plan_topic is not None and active_requirement_plan_draft is not None ) if has_active_requirement_plan_workspace: - message_intent = "requirement_plan" + if not self._is_requirement_mode_exit_intent(message_intent): + message_intent = "requirement_plan" topic_switch = str(topic_switch or "continue").strip().lower() or "continue" consultant_mode_preference = str(consultant_mode_preference or "exploratory").strip().lower() or "exploratory" - normalized_message_intent = str(message_intent or "").strip().lower() has_current_semantic_bundle = isinstance(semantic_bundle, dict) and bool(semantic_bundle) + normalized_message_intent = str(message_intent or "").strip().lower() + current_requirement_plan_protocol = self._resolve_requirement_plan_protocol( + message_text=message_text, + semantic_bundle=semantic_bundle, + ) + requirement_plan_followup_candidate = ( + normalized_message_intent == "requirement_plan" + and not self._is_requirement_mode_exit_intent(message_intent) + ) + if ( + not has_requirement_anchor + and not has_active_requirement_plan_workspace + and not has_current_semantic_bundle + ): + active_requirement_plan_topic, active_requirement_plan_draft = await self._find_active_requirement_plan_workspace( + source_system=source_system, + source_message_id=source_message_id, + source_chat_id=source_chat_id, + sender=None, + source_sender_open_id=source_sender_open_id, + thread_context=thread_context, + conversation_key=conversation_key, + ) + has_active_requirement_plan_workspace = ( + active_requirement_plan_topic is not None and active_requirement_plan_draft is not None + ) has_current_direct_scene_metadata = bool( any( str(metadata.get(key) or "").strip() @@ -5220,17 +5685,29 @@ class ExecuteQueryProjectMixin: and str(consultant_mode_preference or "").strip().lower() == "clarify" and not has_current_semantic_bundle and not has_current_rag_semantics - and not has_active_requirement_plan_workspace + and not has_requirement_anchor ) needs_semantic_backfill = ( not has_current_semantic_bundle and not has_current_rag_semantics - and not has_active_requirement_plan_workspace + and ( + not has_active_requirement_plan_workspace + or needs_legacy_business_review_refresh + ) and ( not has_current_direct_scene_metadata or needs_legacy_business_review_refresh ) ) + needs_requirement_plan_protocol_backfill = ( + requirement_plan_followup_candidate + and not current_requirement_plan_protocol + and not has_active_requirement_plan_workspace + and str(topic_switch or "").strip().lower() == "new" + and not has_current_semantic_bundle + ) + if needs_requirement_plan_protocol_backfill: + needs_semantic_backfill = True if semantic_planner_degraded: needs_semantic_backfill = True if not normalized_message_intent: @@ -5262,8 +5739,8 @@ class ExecuteQueryProjectMixin: source_sender_open_id=source_sender_open_id, thread_context=thread_context, conversation_key=conversation_key, - active_requirement_plan_topic=active_requirement_plan_topic if has_requirement_anchor else None, - active_requirement_plan_draft=active_requirement_plan_draft if has_requirement_anchor else None, + active_requirement_plan_topic=active_requirement_plan_topic if has_active_requirement_plan_workspace else None, + active_requirement_plan_draft=active_requirement_plan_draft if has_active_requirement_plan_workspace else None, ) except Exception as exc: logger.warning( @@ -5320,7 +5797,24 @@ class ExecuteQueryProjectMixin: ).strip().lower() or None ) + if ( + has_active_requirement_plan_workspace + and not self._is_requirement_mode_exit_intent(message_intent) + and not str(message_intent or "").strip() + ): + message_intent = "requirement_plan" normalized_message_intent = str(message_intent or "").strip().lower() + current_requirement_plan_protocol = self._resolve_requirement_plan_protocol( + message_text=message_text, + semantic_bundle=semantic_bundle, + card=( + self._sanitize_requirement_plan_card_runtime_context( + card=dict(active_requirement_plan_draft.requirement_card or {}) + ) + if active_requirement_plan_draft is not None + else None + ), + ) if not has_active_requirement_plan_workspace and normalized_message_intent == "requirement_plan": active_requirement_plan_topic, active_requirement_plan_draft = await self._find_active_requirement_plan_workspace( source_system=source_system, @@ -5349,6 +5843,14 @@ class ExecuteQueryProjectMixin: has_active_requirement_plan_workspace = ( active_requirement_plan_topic is not None and active_requirement_plan_draft is not None ) + if not current_requirement_plan_protocol and active_requirement_plan_draft is not None: + current_requirement_plan_protocol = self._resolve_requirement_plan_protocol( + message_text=message_text, + semantic_bundle=semantic_bundle, + card=self._sanitize_requirement_plan_card_runtime_context( + card=dict(active_requirement_plan_draft.requirement_card or {}) + ), + ) if self._should_force_new_same_scope_requirement_plan( message_text=message_text, message_intent=message_intent, @@ -5356,6 +5858,7 @@ class ExecuteQueryProjectMixin: has_requirement_anchor=has_requirement_anchor, topic=active_requirement_plan_topic, draft=active_requirement_plan_draft, + requirement_plan_protocol=current_requirement_plan_protocol, ): topic_switch = "new" has_active_requirement_plan_workspace = False @@ -5398,13 +5901,15 @@ class ExecuteQueryProjectMixin: thread_context=thread_context, conversation_key=conversation_key, message_text=message_text, + message_intent=message_intent, ) has_active_requirement_plan_workspace = promoted_topic is not None and promoted_draft is not None if has_active_requirement_plan_workspace: active_requirement_plan_topic = promoted_topic active_requirement_plan_draft = promoted_draft - message_intent = "requirement_plan" - normalized_message_intent = "requirement_plan" + if not self._is_requirement_mode_exit_intent(message_intent): + message_intent = "requirement_plan" + normalized_message_intent = "requirement_plan" topic_switch = str(topic_switch or "continue").strip().lower() or "continue" consultant_mode_preference = ( str(consultant_mode_preference or "exploratory").strip().lower() or "exploratory" @@ -5416,19 +5921,26 @@ class ExecuteQueryProjectMixin: has_requirement_anchor=has_requirement_anchor, topic=active_requirement_plan_topic, draft=active_requirement_plan_draft, + requirement_plan_protocol=current_requirement_plan_protocol, ): - message_intent = "requirement_plan" - normalized_message_intent = "requirement_plan" - topic_switch = str(topic_switch or "continue").strip().lower() or "continue" - consultant_mode_preference = ( - str(consultant_mode_preference or "exploratory").strip().lower() or "exploratory" - ) + if not self._is_requirement_mode_exit_intent(message_intent): + message_intent = "requirement_plan" + normalized_message_intent = "requirement_plan" + topic_switch = str(topic_switch or "continue").strip().lower() or "continue" + consultant_mode_preference = ( + str(consultant_mode_preference or "exploratory").strip().lower() or "exploratory" + ) requirement_mode_locked = self._is_requirement_mode_locked( topic=active_requirement_plan_topic, draft=active_requirement_plan_draft, force_new_topic=str(topic_switch or "").strip().lower() == "new", ) - locked_protocol = self._parse_requirement_plan_protocol_reply(message_text) if requirement_mode_locked else {} + requirement_plan_protocol = current_requirement_plan_protocol + locked_protocol = ( + self._normalize_requirement_plan_protocol_payload(requirement_plan_protocol) + if requirement_mode_locked + else {} + ) locked_card = ( self._sanitize_requirement_plan_card_runtime_context( card=dict(active_requirement_plan_draft.requirement_card or {}) @@ -5438,7 +5950,7 @@ class ExecuteQueryProjectMixin: and isinstance(active_requirement_plan_draft.requirement_card, dict) else {} ) - if requirement_mode_locked and self._is_requirement_plan_exit_request(message_text): + if requirement_mode_locked and self._is_requirement_mode_exit_intent(message_intent): if active_requirement_plan_topic is not None: active_requirement_plan_topic.status = "suspended" active_requirement_plan_topic.plan_status = "suspended" @@ -5462,6 +5974,16 @@ class ExecuteQueryProjectMixin: ) direct_result["project_id"] = intent.project_id return direct_result + if self._is_requirement_mode_exit_intent(message_intent): + direct_result = self._build_direct_query_result( + answer="当前不在需求模式,可以直接聊别的;如果你要重新收需求,直接说“继续需求”或重新描述要收敛的对象就行。", + scene_label="Mounted Runtime Plan", + reason_code="requirement_mode_not_active", + query_type="status", + workflow_route=ROUTE_QUERY_ANSWER, + ) + direct_result["project_id"] = intent.project_id + return direct_result if requirement_mode_locked and ( ( str(locked_protocol.get("choice") or "").strip().upper() == "D" @@ -5495,11 +6017,24 @@ class ExecuteQueryProjectMixin: ) direct_result["project_id"] = intent.project_id return direct_result - if requirement_mode_locked and normalized_message_intent not in { + defer_requirement_mode_lock_for_query_answer_contract = ( + self._should_defer_requirement_mode_lock_for_query_answer_contract_detection( + message_intent=message_intent, + topic=active_requirement_plan_topic, + draft=active_requirement_plan_draft, + has_requirement_anchor=has_requirement_anchor, + requirement_plan_protocol=requirement_plan_protocol, + ) + ) + if ( + requirement_mode_locked + and not defer_requirement_mode_lock_for_query_answer_contract + and normalized_message_intent not in { "requirement_plan", "requirement_gathering", "requirement_convergence", - }: + } + ): direct_result = self._build_direct_query_result( answer=self._build_requirement_mode_locked_answer(card=locked_card), scene_label="Mounted Runtime Plan", @@ -5509,7 +6044,11 @@ class ExecuteQueryProjectMixin: ) direct_result["project_id"] = intent.project_id return direct_result - if requirement_mode_locked and not explicit_new_requirement_topic: + if ( + requirement_mode_locked + and not defer_requirement_mode_lock_for_query_answer_contract + and not explicit_new_requirement_topic + ): message_intent = "requirement_plan" normalized_message_intent = "requirement_plan" topic_switch = str(topic_switch or "continue").strip().lower() or "continue" @@ -5537,6 +6076,9 @@ class ExecuteQueryProjectMixin: if isinstance(route_preview.get("query_semantics"), dict) else {} ) + if interaction_level: + source_policy_hint = dict(source_policy_hint or {}) + source_policy_hint["interaction_level"] = interaction_level route_decision = ( route_preview.get("route_decision") if isinstance(route_preview.get("route_decision"), dict) @@ -5596,6 +6138,7 @@ class ExecuteQueryProjectMixin: thread_context=thread_context, conversation_key=conversation_key, topic_switch=topic_switch, + requirement_plan_protocol=requirement_plan_protocol, ) direct_result = self._ensure_requirement_plan_workflow_route(direct_result) direct_result["project_id"] = intent.project_id @@ -5636,6 +6179,7 @@ class ExecuteQueryProjectMixin: message_text=message_text, project_id=requested_project_id if requested_project_id is not None else intent.project_id, message_intent=message_intent, + query_semantics=source_policy_hint if isinstance(source_policy_hint, dict) else None, source_policy=effective_source_policy, ) if project_lookup_result is not None: @@ -5694,6 +6238,10 @@ class ExecuteQueryProjectMixin: "error_message": self._build_query_error_message(exc), "project_id": intent.project_id, } + if interaction_boundary: + rag_result["interaction_boundary"] = interaction_boundary + if answer_control: + rag_result["answer_control"] = answer_control converted_requirement_plan_result = await self._maybe_convert_query_answer_contract_to_requirement_plan_result( intent=intent, message_text=message_text, @@ -5704,7 +6252,11 @@ class ExecuteQueryProjectMixin: source_sender_open_id=source_sender_open_id, thread_context=thread_context, conversation_key=conversation_key, - topic_switch=topic_switch, + topic_switch=( + "new" + if defer_requirement_mode_lock_for_query_answer_contract and not has_requirement_anchor + else topic_switch + ), ) if converted_requirement_plan_result is not None: return converted_requirement_plan_result diff --git a/backend/app/services/ai_pmo/ai_pmo_typed_executor_execute/mixin_execute_signal_source.py b/backend/app/services/ai_pmo/ai_pmo_typed_executor_execute/mixin_execute_signal_source.py index e19c1f89..21b116a5 100644 --- a/backend/app/services/ai_pmo/ai_pmo_typed_executor_execute/mixin_execute_signal_source.py +++ b/backend/app/services/ai_pmo/ai_pmo_typed_executor_execute/mixin_execute_signal_source.py @@ -6,11 +6,170 @@ from sqlalchemy.exc import IntegrityError from ....models import OperationExecutionLedger from .shared import * # noqa: F401, F403 +from .shared import _safe_int +from ...context_cache_service import context_cache_service class ExecuteSignalSourceMixin: """Handle signal source onboard intent.""" + @staticmethod + def _normalize_structured_dict(value: Any) -> dict[str, Any]: + if hasattr(value, "model_dump"): + value = value.model_dump() + return value if isinstance(value, dict) else {} + + @classmethod + def _resolve_operation_intent_contract(cls, *, intent) -> dict[str, Any]: + metadata = intent.metadata if isinstance(getattr(intent, "metadata", None), dict) else {} + return cls._normalize_structured_dict(metadata.get("operation_intent_contract")) + + @classmethod + def _resolve_reply_anchor_qa_context(cls, *, intent) -> dict[str, Any]: + metadata = intent.metadata if isinstance(getattr(intent, "metadata", None), dict) else {} + return cls._normalize_structured_dict(metadata.get("reply_anchor_qa_context")) + + @classmethod + def _resolve_recommendation_candidate_materializations( + cls, + *, + intent, + ) -> tuple[Optional[int], Optional[str], list[dict[str, Any]]]: + metadata = intent.metadata if isinstance(getattr(intent, "metadata", None), dict) else {} + operation_intent_contract = cls._normalize_structured_dict(metadata.get("operation_intent_contract")) + reply_anchor_qa_context = cls._normalize_structured_dict(metadata.get("reply_anchor_qa_context")) + recommendation = cls._normalize_structured_dict(reply_anchor_qa_context.get("signal_source_recommendation")) + if not recommendation: + return _safe_int(intent.project_id), None, [] + + contract_set_id = str(operation_intent_contract.get("candidate_set_binding") or "").strip() + recommendation_set_id = str(recommendation.get("candidate_set_id") or "").strip() + if contract_set_id and recommendation_set_id and contract_set_id != recommendation_set_id: + return ( + _safe_int(operation_intent_contract.get("project_binding")) + or _safe_int(recommendation.get("project_binding")) + or _safe_int(intent.project_id), + recommendation_set_id or contract_set_id or None, + [], + ) + + raw_candidates = recommendation.get("candidates") if isinstance(recommendation.get("candidates"), list) else [] + candidate_by_ref: dict[str, dict[str, Any]] = {} + ordered_candidates: list[dict[str, Any]] = [] + for item in raw_candidates: + if not isinstance(item, dict): + continue + candidate_ref = str(item.get("candidate_ref") or "").strip() + if candidate_ref: + candidate_by_ref[candidate_ref] = item + ordered_candidates.append(item) + + selected_refs = [ + str(item).strip() + for item in list(operation_intent_contract.get("candidate_refs") or []) + if str(item).strip() + ] + selected_candidates: list[dict[str, Any]] = [] + if selected_refs: + for candidate_ref in selected_refs: + item = candidate_by_ref.get(candidate_ref) + if item is not None: + selected_candidates.append(item) + else: + selected_candidates = ordered_candidates + + return ( + _safe_int(operation_intent_contract.get("project_binding")) + or _safe_int(recommendation.get("project_binding")) + or _safe_int(intent.project_id), + contract_set_id or recommendation_set_id or None, + selected_candidates, + ) + + async def _run_signal_source_onboard_candidate( + self, + *, + project_id: int, + message_text: str, + operator_user_id, + source_system: str, + source_message_id: str, + channel_ref, + semantic_payload: dict[str, Any], + ) -> dict[str, Any]: + signal_summary = str(semantic_payload.get("summary") or message_text) + signal_content = str(semantic_payload.get("content") or message_text) + initial_title_seed = self._normalize_title_candidate( + str(semantic_payload.get("title") or "") + ) + initial_signal_title = ( + initial_title_seed + if self._is_acceptable_title(initial_title_seed) + else "信号源接入请求" + ) + strategic_payload = ( + semantic_payload.get("strategic_analysis") + if isinstance(semantic_payload.get("strategic_analysis"), dict) + else {} + ) + source_context = ( + strategic_payload.get("source_context") + if isinstance(strategic_payload.get("source_context"), dict) + else {} + ) + source_context["source_message_id"] = source_message_id + source_context["channel_ref"] = channel_ref + strategic_payload["source_context"] = source_context + signal = await self._create_signal( + title=initial_signal_title, + summary=signal_summary, + content=signal_content, + project_id=int(project_id), + source="PMFlow", + signal_type="新需求", + recommended_action="enter_only", + reasoning_bullets=( + semantic_payload.get("reasoning_bullets") + if isinstance(semantic_payload.get("reasoning_bullets"), list) + else None + ), + strategic_analysis=(strategic_payload if isinstance(strategic_payload, dict) else None), + ) + domain_workflow = await self.signal_source_workflow.run( + tenant_id=self.tenant_id, + project_id=int(project_id), + signal_id=int(signal.id), + initiating_text=message_text, + requested_via=source_system, + operator_user_id=operator_user_id, + ) + orchestration = ( + domain_workflow.get("raw_result") + if isinstance(domain_workflow.get("raw_result"), dict) + else {} + ) + signal_source_payload = ( + orchestration.get("signal_source") + if isinstance(orchestration.get("signal_source"), dict) + else {} + ) + ai_source_title = self._normalize_title_candidate(signal_source_payload.get("source_name")) + if self._is_acceptable_title(ai_source_title): + signal.title = ai_source_title + await self.db.flush() + return { + "signal_id": int(signal.id), + "signal_title": str(signal.title or ""), + "workflow": domain_workflow, + "orchestration": orchestration, + "signal_source_payload": signal_source_payload, + "auto_tasks": self._extract_auto_tasks( + orchestration.get("execution") + if isinstance(orchestration.get("execution"), dict) + else orchestration.get("control_plane") + ), + } + async def _execute_signal_source_onboard_intent( self, *, @@ -80,98 +239,121 @@ class ExecuteSignalSourceMixin: await self.db.commit() try: - semantic_payload = self._build_signal_source_semantic_payload( - intent=intent, - message_text=message_text, + operation_intent_contract = self._resolve_operation_intent_contract(intent=intent) + reply_anchor_qa_context = self._resolve_reply_anchor_qa_context(intent=intent) + operation_phase = str(operation_intent_contract.get("intent_phase") or "").strip().lower() + direct_write_phase = operation_phase in {"prepare_write", "confirm_write", "execute_write"} + project_binding, candidate_set_binding, candidate_materializations = ( + self._resolve_recommendation_candidate_materializations(intent=intent) ) - signal_summary = str(semantic_payload.get("summary") or intent.signal_summary or message_text) - signal_content = str(semantic_payload.get("content") or message_text) - initial_title_seed = self._normalize_title_candidate( - str(semantic_payload.get("title") or intent.signal_title or "") - ) - initial_signal_title = ( - initial_title_seed - if self._is_acceptable_title(initial_title_seed) - else "信号源接入请求" - ) - strategic_payload = ( - semantic_payload.get("strategic_analysis") - if isinstance(semantic_payload.get("strategic_analysis"), dict) - else {} - ) - source_context = ( - strategic_payload.get("source_context") - if isinstance(strategic_payload.get("source_context"), dict) - else {} - ) - source_context["source_message_id"] = source_message_id - source_context["channel_ref"] = channel_ref - strategic_payload["source_context"] = source_context - signal = await self._create_signal( - title=initial_signal_title, - summary=signal_summary, - content=signal_content, - project_id=int(intent.project_id), - source="PMFlow", - signal_type="新需求", - recommended_action="enter_only", - reasoning_bullets=( - semantic_payload.get("reasoning_bullets") - if isinstance(semantic_payload.get("reasoning_bullets"), list) - else None - ), - strategic_analysis=( - strategic_payload if isinstance(strategic_payload, dict) else None - ), - ) - domain_workflow = await self.signal_source_workflow.run( - tenant_id=self.tenant_id, - project_id=int(intent.project_id or 0), - signal_id=int(signal.id), - initiating_text=message_text, - requested_via=source_system, - operator_user_id=operator_user_id, - ) - orchestration = ( - domain_workflow.get("raw_result") - if isinstance(domain_workflow.get("raw_result"), dict) - else {} - ) - signal_source_payload = ( - orchestration.get("signal_source") - if isinstance(orchestration.get("signal_source"), dict) - else {} - ) - ai_source_title = self._normalize_title_candidate(signal_source_payload.get("source_name")) - if self._is_acceptable_title(ai_source_title): - signal.title = ai_source_title - await self.db.flush() - result = { - "status": ( - "succeeded" - if str(domain_workflow.get("status") or "").lower() == "completed" - else ( - "pending_manual" - if str(domain_workflow.get("status") or "").lower() == "pending_manual" - else "failed_safe" + effective_project_id = project_binding or _safe_int(intent.project_id) + run_results: list[dict[str, Any]] = [] + if candidate_materializations: + for candidate in candidate_materializations: + semantic_payload = self._build_signal_source_semantic_payload( + intent=intent, + message_text=message_text, + candidate_materialization=candidate, + candidate_set_binding=candidate_set_binding, ) - ), - "signal_id": int(signal.id), - "project_id": intent.project_id, - "signal_source_workflow": domain_workflow, - "signal_source_orchestration": orchestration, - "auto_tasks": self._extract_auto_tasks( - orchestration.get("execution") - if isinstance(orchestration.get("execution"), dict) - else orchestration.get("control_plane") - ), + run_results.append( + await self._run_signal_source_onboard_candidate( + project_id=int(effective_project_id or 0), + message_text=message_text, + operator_user_id=operator_user_id, + source_system=source_system, + source_message_id=source_message_id, + channel_ref=channel_ref, + semantic_payload=semantic_payload, + ) + ) + else: + semantic_payload = self._build_signal_source_semantic_payload( + intent=intent, + message_text=message_text, + ) + run_results.append( + await self._run_signal_source_onboard_candidate( + project_id=int(effective_project_id or 0), + message_text=message_text, + operator_user_id=operator_user_id, + source_system=source_system, + source_message_id=source_message_id, + channel_ref=channel_ref, + semantic_payload=semantic_payload, + ) + ) + + signal_ids = [int(item["signal_id"]) for item in run_results if _safe_int(item.get("signal_id")) is not None] + candidate_refs = [ + str(item.get("candidate_ref") or "").strip() + for item in candidate_materializations + if isinstance(item, dict) and str(item.get("candidate_ref") or "").strip() + ] + workflow_statuses = [str((item.get("workflow") or {}).get("status") or "").strip().lower() for item in run_results] + if workflow_statuses and all(status == "completed" for status in workflow_statuses): + workflow_status = "completed" + elif any(status == "pending_manual" for status in workflow_statuses): + workflow_status = "pending_manual" + elif any(status == "running" for status in workflow_statuses): + workflow_status = "running" + elif any(status == "failed" for status in workflow_statuses): + workflow_status = "failed" + else: + workflow_status = "failed_safe" if run_results else "unknown" + post_process_status = None + manual_gate_resolved = False + if signal_ids and direct_write_phase: + result_status = "succeeded" + if workflow_status in {"pending_manual", "running", "failed"}: + post_process_status = workflow_status + manual_gate_resolved = workflow_status == "pending_manual" + elif signal_ids and workflow_status == "completed": + result_status = "succeeded" + elif workflow_status == "pending_manual": + result_status = "pending_manual" + else: + result_status = "failed_safe" + signal_source_payload = ( + run_results[0].get("signal_source_payload") + if run_results and isinstance(run_results[0].get("signal_source_payload"), dict) + else {} + ) + result = { + "status": result_status, + "signal_id": signal_ids[0] if signal_ids else None, + "signal_ids": signal_ids, + "requested_count": len(run_results), + "project_id": effective_project_id, + "candidate_set_binding": candidate_set_binding, + "candidate_refs": candidate_refs, + "reply_anchor_qa_context": reply_anchor_qa_context, + "signal_source_workflow": { + "status": workflow_status, + "batch_count": len(run_results), + "candidate_set_id": candidate_set_binding, + }, + "signal_source_workflows": [item.get("workflow") for item in run_results if isinstance(item.get("workflow"), dict)], + "signal_source_orchestration": { + "candidate_set_id": candidate_set_binding, + "items": [item.get("orchestration") for item in run_results if isinstance(item.get("orchestration"), dict)], + }, + "auto_tasks": [ + task + for item in run_results + for task in (item.get("auto_tasks") if isinstance(item.get("auto_tasks"), list) else []) + ], } + if post_process_status: + result["post_process_status"] = post_process_status + if manual_gate_resolved: + result["manual_gate_resolved"] = True await self._finalize_signal_source_onboard_ledger( ledger=ledger, status=result["status"], result=result, signal_source_payload=signal_source_payload, - signal_id=int(signal.id), + signal_id=signal_ids[0] if signal_ids else None, ) await self.db.commit() await context_cache_service.invalidate_system_context(tenant_id=self.tenant_id) diff --git a/backend/app/services/ai_pmo/draft_confirm_handler.py b/backend/app/services/ai_pmo/draft_confirm_handler.py index 16bcc882..e8765567 100644 --- a/backend/app/services/ai_pmo/draft_confirm_handler.py +++ b/backend/app/services/ai_pmo/draft_confirm_handler.py @@ -38,50 +38,10 @@ class DraftConfirmHandler: _ACTION_CANCEL, _ACTION_ANSWER_ONLY, ) - _EXPLICIT_CONFIRM_COMMANDS = frozenset({"确认", "确认本版", "确认入库", "confirm", "ok", "好的", "好", "yes", "y"}) - _EXPLICIT_REVISE_COMMANDS = frozenset({"修改", "修改本版", "修改意见", "修改你的意见", "修改一下"}) - _EXPLICIT_REGENERATE_COMMANDS = frozenset({"重出", "重出一版", "再出", "再出一版", "重新出", "重新出一版"}) - _EXPLICIT_CANCEL_COMMANDS = frozenset({"取消", "取消本版", "cancel", "不用了", "算了"}) - _PROTOCOL_STRIP_RE = re.compile(r"[\s,,。;;::!!??、++]+") _DEFAULT_DISPLAY_FEEDBACK_REPLY = ( "收到,这是展示形式反馈:卡片可以更完整,聊天直发我会控制得更短;当前草稿内容我先不重起。" ) - @classmethod - def is_explicit_protocol_followup_text(cls, text: str) -> bool: - normalized_text = cls._normalize_protocol_text(text) - if not normalized_text: - return False - return normalized_text in ( - cls._normalized_protocol_commands(cls._EXPLICIT_CONFIRM_COMMANDS) - | cls._normalized_protocol_commands(cls._EXPLICIT_REVISE_COMMANDS) - | cls._normalized_protocol_commands(cls._EXPLICIT_REGENERATE_COMMANDS) - | cls._normalized_protocol_commands(cls._EXPLICIT_CANCEL_COMMANDS) - ) - - @classmethod - def _resolve_explicit_protocol_action(cls, text: str) -> str: - normalized_text = cls._normalize_protocol_text(text) - if not normalized_text: - return cls._ACTION_UNKNOWN - if normalized_text in cls._normalized_protocol_commands(cls._EXPLICIT_CONFIRM_COMMANDS): - return cls._ACTION_CONFIRM - if normalized_text in cls._normalized_protocol_commands(cls._EXPLICIT_REVISE_COMMANDS): - return cls._ACTION_REVISE - if normalized_text in cls._normalized_protocol_commands(cls._EXPLICIT_REGENERATE_COMMANDS): - return cls._ACTION_REGENERATE - if normalized_text in cls._normalized_protocol_commands(cls._EXPLICIT_CANCEL_COMMANDS): - return cls._ACTION_CANCEL - return cls._ACTION_UNKNOWN - - @classmethod - def _normalized_protocol_commands(cls, commands: frozenset[str]) -> set[str]: - return {cls._normalize_protocol_text(command) for command in commands if str(command or "").strip()} - - @classmethod - def _normalize_protocol_text(cls, value: Any) -> str: - return cls._PROTOCOL_STRIP_RE.sub("", str(value or "")).strip().lower() - def __init__(self, kernel: Any) -> None: self.kernel = kernel self.db = kernel.db @@ -104,7 +64,6 @@ class DraftConfirmHandler: explicit_work_item_id = self._extract_work_item_id(message_text) normalized_thread_context = self._normalize_thread_context(thread_context) has_reply_anchor = bool(str(normalized_thread_context.get("reply_to_message_id") or "").strip()) - normalized_text = self._normalize_protocol_text(message_text) target = await self._resolve_pending_draft( sender=sender, source_system=source_system, @@ -114,7 +73,7 @@ class DraftConfirmHandler: thread_context=thread_context, ) if target is None: - if explicit_work_item_id is not None or (has_reply_anchor and self.is_explicit_protocol_followup_text(normalized_text)): + if explicit_work_item_id is not None: return { "handled": True, "status": "failed_safe", @@ -180,7 +139,17 @@ class DraftConfirmHandler: "workflow_route": RouteResolver.normalize_route_key(str(target_work_item.intent or "")), } if action == self._ACTION_UNKNOWN: - action = self._resolve_explicit_protocol_action(message_text) + return { + "handled": True, + "status": "pending_manual", + "followup_reply": "当前正在处理这版待确认草稿。请继续说明你要确认、修改、重出还是取消;如果方向不对,也可以直接说你想改哪一块。", + "target_work_item_id": int(target_work_item.id), + "target_state": str(target_work_item.state or "").strip() or None, + "target_intent": str(target_work_item.intent or "").strip() or None, + "project_id": _safe_int(target_work_item.project_id), + "signal_id": _safe_int(target_work_item.signal_id), + "workflow_route": RouteResolver.normalize_route_key(str(target_work_item.intent or "")), + } if action == self._ACTION_CONFIRM: return await self.confirm_draft( target_work_item=target_work_item, @@ -210,17 +179,7 @@ class DraftConfirmHandler: target_work_item=target_work_item, reason="用户取消草稿", ) - return { - "handled": True, - "status": "pending_manual", - "followup_reply": "当前正在处理这版待确认草稿。请回复“确认”、“修改 + 你的意见”、“重出一版”或“取消”来处理当前草稿。", - "target_work_item_id": int(target_work_item.id), - "target_state": str(target_work_item.state or "").strip() or None, - "target_intent": str(target_work_item.intent or "").strip() or None, - "project_id": _safe_int(target_work_item.project_id), - "signal_id": _safe_int(target_work_item.signal_id), - "workflow_route": RouteResolver.normalize_route_key(str(target_work_item.intent or "")), - } + raise AssertionError(f"unexpected draft followup action: {action}") async def _resolve_target_mounted_draft( self, diff --git a/backend/app/services/ai_pmo/semantic_context/__init__.py b/backend/app/services/ai_pmo/semantic_context/__init__.py index 26172fcf..fec40f41 100644 --- a/backend/app/services/ai_pmo/semantic_context/__init__.py +++ b/backend/app/services/ai_pmo/semantic_context/__init__.py @@ -1,5 +1,19 @@ from .assembler import SemanticContextAssembler -from .decision_schema import DecisionGateResult, ModeCard, SemanticDecision +from .business_frame_schema import ( + AnswerShapeContract, + BusinessSemanticFrame, + ClarificationDelta, + CorrectionDelta, + OperationIntentContract, +) +from .decision_schema import ( + AnswerControlDecision, + DecisionGateResult, + InteractionBoundaryPacket, + ModeCard, + SemanticDecision, + SelfReviewResult, +) from .gatekeeper import SemanticDecisionGatekeeper from .mode_card_service import build_mode_card from .packet_schema import ( @@ -11,8 +25,15 @@ from .packet_schema import ( ) __all__ = [ + "AnswerShapeContract", + "AnswerControlDecision", + "BusinessSemanticFrame", + "ClarificationDelta", + "CorrectionDelta", "DecisionGateResult", + "InteractionBoundaryPacket", "ModeCard", + "OperationIntentContract", "SemanticContextAssembler", "SemanticContextDraftSnapshot", "SemanticContextPacket", @@ -21,5 +42,6 @@ __all__ = [ "SemanticContextTurn", "SemanticDecision", "SemanticDecisionGatekeeper", + "SelfReviewResult", "build_mode_card", ] diff --git a/backend/app/services/ai_pmo/semantic_context/business_frame_schema.py b/backend/app/services/ai_pmo/semantic_context/business_frame_schema.py new file mode 100644 index 00000000..cfcdf922 --- /dev/null +++ b/backend/app/services/ai_pmo/semantic_context/business_frame_schema.py @@ -0,0 +1,113 @@ +from __future__ import annotations + +from typing import Any, Literal + +from pydantic import BaseModel, ConfigDict, Field + + +GoalKind = Literal[ + "status_lookup", + "recommendation", + "impact_judgment", + "bounded_explanation", + "prepare_write", + "confirm_write", + "execute_write", + "drafting", + "clarify", + "casual_chat", + "mixed", +] + +TargetScope = Literal["current_project", "explicit_project", "thread_context", "global"] +ResourceRole = Literal[ + "signal_source", + "external_input", + "project_object", + "task", + "signal", + "config", + "people", + "feature", + "doc", + "none", +] +SourceKind = Literal["internal", "external", "hybrid", "none"] +AnswerShape = Literal[ + "inventory", + "ranked_candidates", + "impact_chain", + "bounded_explanation", + "clarify_prompt", + "write_confirmation", + "write_result", + "social_reply", +] +ConversationPosture = Literal["business_operating", "ambient_chat", "mixed"] +DeltaField = Literal[ + "goal_kind", + "target_scope", + "resource_role", + "source_kind", + "expected_answer_shape", + "project_binding", + "subject_binding", + "object_binding", + "candidate_set_binding", + "action_ceiling", +] +IntentPhase = Literal["recommendation", "prepare_write", "confirm_write", "execute_write", "drafting"] + + +class AnswerShapeContract(BaseModel): + model_config = ConfigDict(extra="ignore") + + shape: AnswerShape + confidence: float = 0.0 + notes: list[str] = Field(default_factory=list) + + +class BusinessSemanticFrame(BaseModel): + model_config = ConfigDict(extra="ignore") + + goal_kind: GoalKind + target_scope: TargetScope + resource_role: ResourceRole + source_kind: SourceKind + expected_answer_shape: AnswerShape + conversation_posture: ConversationPosture + project_binding: int | str | None = None + subject_binding: str | None = None + object_binding: str | None = None + candidate_set_binding: str | None = None + evidence_mode: Literal["verified", "advisory", "needs_clarify"] = "needs_clarify" + metadata: dict[str, Any] = Field(default_factory=dict) + + +class ClarificationDelta(BaseModel): + model_config = ConfigDict(extra="ignore") + + field: DeltaField + previous_value: Any = None + next_value: Any = None + reason: str = "" + + +class CorrectionDelta(BaseModel): + model_config = ConfigDict(extra="ignore") + + field: DeltaField + previous_value: Any = None + next_value: Any = None + reason: str = "" + + +class OperationIntentContract(BaseModel): + model_config = ConfigDict(extra="ignore") + + intent_phase: IntentPhase + requires_confirmation: bool = False + project_binding: int | str | None = None + candidate_set_binding: str | None = None + candidate_refs: list[str] = Field(default_factory=list) + metadata: dict[str, Any] = Field(default_factory=dict) diff --git a/backend/app/services/ai_pmo/semantic_context/decision_schema.py b/backend/app/services/ai_pmo/semantic_context/decision_schema.py index a39aa023..f0778672 100644 --- a/backend/app/services/ai_pmo/semantic_context/decision_schema.py +++ b/backend/app/services/ai_pmo/semantic_context/decision_schema.py @@ -4,6 +4,14 @@ from typing import Any, Literal from pydantic import BaseModel, ConfigDict, Field +from .business_frame_schema import ( + AnswerShapeContract, + BusinessSemanticFrame, + OperationIntentContract, +) + +BoundaryLevel = Literal["status", "recommendation", "draft", "execute"] + class ModeCard(BaseModel): model_config = ConfigDict(extra="ignore") @@ -36,6 +44,55 @@ class DecisionWorkflowPlan(BaseModel): metadata: dict[str, Any] = Field(default_factory=dict) +class InteractionBoundaryPacket(BaseModel): + model_config = ConfigDict(extra="ignore") + + boundary_version: str = "v1" + requested_level: BoundaryLevel | Literal["unclear"] = "unclear" + forbidden_levels: list[BoundaryLevel] = Field(default_factory=list) + allow_status: bool = True + allow_recommendation: bool = True + allow_draft: bool = False + allow_execute: bool = False + allow_write: bool = False + needs_clarification: bool = False + clarification_reason: str = "" + clarification_questions: list[str] = Field(default_factory=list) + correction_allowed: bool = True + correction_targets: list[Literal["project", "object", "level", "priority", "assumption"]] = Field( + default_factory=list + ) + self_review_required: bool = True + confidence: float = 0.0 + evidence: list[dict[str, Any]] = Field(default_factory=list) + + +class AnswerControlDecision(BaseModel): + model_config = ConfigDict(extra="ignore") + + effective_level: BoundaryLevel | Literal["clarify"] + blocked_levels: list[BoundaryLevel] = Field(default_factory=list) + blocked_workflows: list[str] = Field(default_factory=list) + allowed_workflows: list[str] = Field(default_factory=list) + downgraded_from: BoundaryLevel | None = None + requires_confirmation: bool = False + requires_self_review: bool = True + requires_correction_slot: bool = True + reason: str = "" + + +class SelfReviewResult(BaseModel): + model_config = ConfigDict(extra="ignore") + + project_aligned: bool = True + object_aligned: bool = True + level_aligned: bool = True + leaked_internal_terms: bool = False + overstepped_boundary: bool = False + clarify_missing: bool = False + notes: list[str] = Field(default_factory=list) + + class SemanticDecision(BaseModel): model_config = ConfigDict(extra="ignore") @@ -47,6 +104,11 @@ class SemanticDecision(BaseModel): draft_patch: dict[str, Any] = Field(default_factory=dict) workflow_plans: list[DecisionWorkflowPlan] = Field(default_factory=list) scene_metadata: dict[str, Any] = Field(default_factory=dict) + interaction_boundary: InteractionBoundaryPacket | None = None + business_frame: BusinessSemanticFrame | None = None + answer_shape_contract: AnswerShapeContract | None = None + operation_intent_contract: OperationIntentContract | None = None + self_review: SelfReviewResult | None = None assistant_reply: str = "" requires_confirmation: bool = False requested_actions: list[str] = Field(default_factory=list) diff --git a/backend/app/services/ai_pmo/semantic_context/mode_card_service.py b/backend/app/services/ai_pmo/semantic_context/mode_card_service.py index 48fa72ba..daba7d7c 100644 --- a/backend/app/services/ai_pmo/semantic_context/mode_card_service.py +++ b/backend/app/services/ai_pmo/semantic_context/mode_card_service.py @@ -19,6 +19,7 @@ def build_mode_card(*, topic_mode: str | None, plan_status: str | None) -> ModeC "reply.compose", "draft.patch", "clarify.ask", + "context.suspend_requirement_mode", "topic.continue_or_split", "plan.mark_ready_for_confirmation", ], diff --git a/backend/app/services/ai_pmo/workflow_semantic_planner/service.py b/backend/app/services/ai_pmo/workflow_semantic_planner/service.py index 60de3f01..44af855a 100644 --- a/backend/app/services/ai_pmo/workflow_semantic_planner/service.py +++ b/backend/app/services/ai_pmo/workflow_semantic_planner/service.py @@ -5,7 +5,14 @@ import hashlib import re from typing import Any, Iterable, Optional -from ..semantic_context.decision_schema import DecisionWorkflowPlan, ModeCard, SemanticDecision +from ..semantic_context.decision_schema import ( + AnswerControlDecision, + DecisionWorkflowPlan, + InteractionBoundaryPacket, + ModeCard, + SemanticDecision, + SelfReviewResult, +) from ..semantic_context.packet_schema import ( SemanticContextIngress, SemanticContextPacket, @@ -44,6 +51,7 @@ class WorkflowSemanticPlanner: "requirements_analysis": "structured_analysis_first", "collaborative": "exploratory", } + _QUERY_ONLY_LEVELS = {"status", "recommendation", "clarify"} @staticmethod def _normalize_text(text: str) -> str: @@ -72,6 +80,13 @@ class WorkflowSemanticPlanner: cls, *, scene_metadata: Optional[dict[str, Any]] = None, + ingress_metadata: Optional[dict[str, Any]] = None, + interaction_boundary: Optional[InteractionBoundaryPacket] = None, + business_frame: Optional[Any] = None, + answer_shape_contract: Optional[Any] = None, + operation_intent_contract: Optional[Any] = None, + answer_control: Optional[AnswerControlDecision] = None, + self_review: Optional[SelfReviewResult] = None, ) -> dict[str, Any]: diagnostics: dict[str, Any] = {} payload = scene_metadata if isinstance(scene_metadata, dict) else {} @@ -80,8 +95,333 @@ class WorkflowSemanticPlanner: if value in (None, ""): continue diagnostics[key] = value + raw_ingress = ingress_metadata if isinstance(ingress_metadata, dict) else {} + for key in ("project_id", "reply_to_message_id", "root_message_id"): + value = raw_ingress.get(key) + if value in (None, ""): + continue + diagnostics[key] = value + reply_anchor_qa_context = raw_ingress.get("reply_anchor_qa_context") + if isinstance(reply_anchor_qa_context, dict): + diagnostics["reply_anchor_qa_context"] = reply_anchor_qa_context + if isinstance(interaction_boundary, InteractionBoundaryPacket): + diagnostics["interaction_boundary"] = interaction_boundary.model_dump() + if hasattr(business_frame, "model_dump"): + diagnostics["business_frame"] = business_frame.model_dump() + if hasattr(answer_shape_contract, "model_dump"): + diagnostics["answer_shape_contract"] = answer_shape_contract.model_dump() + if hasattr(operation_intent_contract, "model_dump"): + diagnostics["operation_intent_contract"] = operation_intent_contract.model_dump() + if isinstance(answer_control, AnswerControlDecision): + diagnostics["answer_control"] = answer_control.model_dump() + if isinstance(self_review, SelfReviewResult): + diagnostics["self_review"] = self_review.model_dump() return diagnostics + @staticmethod + def _workflow_signature(workflow: WorkflowPlan) -> str: + return f"{workflow.workflow_type}:{workflow.workflow_action}" + + @classmethod + def _normalize_operation_intent_contract(cls, value: Any) -> dict[str, Any]: + if hasattr(value, "model_dump"): + value = value.model_dump() + return value if isinstance(value, dict) else {} + + @classmethod + def _workflow_level(cls, workflow: WorkflowPlan, *, decision: Optional[SemanticDecision] = None) -> str: + workflow_type = str(workflow.workflow_type or "").strip().lower() + workflow_action = str(workflow.workflow_action or "").strip().lower() + workflow_metadata = ( + dict(workflow.metadata or {}) + if isinstance(getattr(workflow, "metadata", None), dict) + else {} + ) + operation_intent_contract = cls._normalize_operation_intent_contract( + workflow_metadata.get("operation_intent_contract") + ) + if not operation_intent_contract and isinstance(decision, SemanticDecision): + operation_intent_contract = cls._normalize_operation_intent_contract( + decision.operation_intent_contract + ) + intent_phase = str(operation_intent_contract.get("intent_phase") or "").strip().lower() + if intent_phase == "drafting": + return "draft" + if intent_phase in {"prepare_write", "confirm_write", "execute_write"}: + return "execute" + if intent_phase == "recommendation": + return "status" + if workflow_type == "query_flow": + return "status" + if workflow_type == "signal_source_flow": + return "draft" + if workflow_type == "project_signal_flow": + if workflow_action in {"commit", "cancel", "refresh"}: + return "execute" + return "draft" + if workflow_type == "followup_flow" and workflow_action == "confirm": + return "execute" + if workflow_type in {"new_project_flow", "project_flow", "task_flow"}: + return "execute" + return "status" + + @classmethod + def _fallback_interaction_boundary( + cls, + *, + decision: SemanticDecision, + workflows: list[WorkflowPlan], + ) -> InteractionBoundaryPacket: + levels = [cls._workflow_level(item, decision=decision) for item in workflows] + requested_level = "status" + allow_draft = False + allow_execute = False + if any(level == "execute" for level in levels): + requested_level = "execute" + allow_draft = True + allow_execute = True + elif any(level == "draft" for level in levels): + requested_level = "draft" + allow_draft = True + return InteractionBoundaryPacket( + requested_level=requested_level, + forbidden_levels=[], + allow_status=True, + allow_recommendation=True, + allow_draft=allow_draft, + allow_execute=allow_execute, + allow_write=allow_execute, + correction_allowed=True, + correction_targets=["project", "level"], + self_review_required=True, + confidence=float(decision.confidence or 0.0), + evidence=[{"source": "planner_fallback", "note": "interaction_boundary_missing"}], + ) + + @classmethod + def _boundary_for_decision( + cls, + *, + decision: SemanticDecision, + workflows: list[WorkflowPlan], + ) -> InteractionBoundaryPacket: + if isinstance(decision.interaction_boundary, InteractionBoundaryPacket): + return decision.interaction_boundary + return cls._fallback_interaction_boundary(decision=decision, workflows=workflows) + + @staticmethod + def _self_review_for_decision(decision: SemanticDecision) -> SelfReviewResult: + if isinstance(decision.self_review, SelfReviewResult): + return decision.self_review + return SelfReviewResult() + + @classmethod + def _query_answer_fallback_workflow( + cls, + *, + workflows: list[WorkflowPlan], + decision: SemanticDecision, + requested_level: str, + ) -> WorkflowPlan: + target_scope = "project" if any(item.target_scope == "project" for item in workflows) else "global" + confidence = max([float(item.confidence or 0.0) for item in workflows] or [float(decision.confidence or 0.0)]) + metadata: dict[str, Any] = {} + if requested_level in {"status", "recommendation", "clarify"}: + metadata["interaction_level"] = requested_level + return WorkflowPlan( + workflow_type="query_flow", + workflow_action="answer", + target_scope=target_scope, + operation_codes=["query.answer"], + requires_confirmation=False, + confidence=confidence, + clarification_needed=requested_level == "clarify", + metadata=metadata, + ) + + @staticmethod + def _dedupe_list(values: Iterable[str]) -> list[str]: + ordered: list[str] = [] + for value in values: + normalized = str(value or "").strip() + if normalized and normalized not in ordered: + ordered.append(normalized) + return ordered + + @classmethod + def _build_answer_control_decision( + cls, + *, + decision: SemanticDecision, + workflows: list[WorkflowPlan], + boundary: InteractionBoundaryPacket, + self_review: SelfReviewResult, + ) -> AnswerControlDecision: + if decision.interaction_boundary is None and decision.self_review is None: + all_signatures = [cls._workflow_signature(item) for item in workflows] + effective_level = "status" + if any(cls._workflow_level(item, decision=decision) == "execute" for item in workflows): + effective_level = "execute" + elif any(cls._workflow_level(item, decision=decision) == "draft" for item in workflows): + effective_level = "draft" + return AnswerControlDecision( + effective_level=effective_level, + blocked_levels=[], + blocked_workflows=[], + allowed_workflows=all_signatures, + requires_confirmation=any(item.requires_confirmation for item in workflows), + requires_self_review=bool(boundary.self_review_required), + requires_correction_slot=bool(boundary.correction_allowed), + reason="legacy_passthrough", + ) + requested_level = str(boundary.requested_level or "unclear").strip().lower() + forbidden_levels = {str(item or "").strip().lower() for item in list(boundary.forbidden_levels or [])} + signatures_by_level: dict[str, list[str]] = { + "status": [], + "draft": [], + "execute": [], + } + for workflow in workflows: + level = cls._workflow_level(workflow, decision=decision) + signatures_by_level.setdefault(level, []).append(cls._workflow_signature(workflow)) + + all_non_query_signatures = cls._dedupe_list( + signatures_by_level.get("draft", []) + signatures_by_level.get("execute", []) + ) + blocked_levels: list[str] = [] + blocked_workflows: list[str] = [] + + if self_review.overstepped_boundary or self_review.leaked_internal_terms or boundary.needs_clarification: + blocked_workflows = list(all_non_query_signatures) + blocked_levels = cls._dedupe_list( + ["draft" if signatures_by_level.get("draft") else "", "execute" if signatures_by_level.get("execute") else ""] + ) + return AnswerControlDecision( + effective_level="clarify", + blocked_levels=blocked_levels, + blocked_workflows=blocked_workflows, + allowed_workflows=["query_flow:answer"], + downgraded_from=( + "execute" if requested_level == "execute" else "draft" if requested_level == "draft" else None + ), + requires_confirmation=False, + requires_self_review=bool(boundary.self_review_required), + requires_correction_slot=bool(boundary.correction_allowed), + reason=( + "self_review_guard_triggered" + if self_review.overstepped_boundary or self_review.leaked_internal_terms + else "clarification_required" + ), + ) + + if requested_level in {"status", "recommendation", "unclear"}: + blocked_workflows = list(all_non_query_signatures) + blocked_levels = cls._dedupe_list( + ["draft" if signatures_by_level.get("draft") else "", "execute" if signatures_by_level.get("execute") else ""] + ) + downgraded_from = None + if signatures_by_level.get("draft"): + downgraded_from = "draft" + elif signatures_by_level.get("execute"): + downgraded_from = "execute" + return AnswerControlDecision( + effective_level="recommendation" if requested_level == "recommendation" else "status", + blocked_levels=blocked_levels, + blocked_workflows=blocked_workflows, + allowed_workflows=["query_flow:answer"], + downgraded_from=downgraded_from, + requires_confirmation=False, + requires_self_review=bool(boundary.self_review_required), + requires_correction_slot=bool(boundary.correction_allowed), + reason="answer_only_boundary", + ) + + if requested_level == "draft": + draft_allowed = bool(boundary.allow_draft) and "draft" not in forbidden_levels + execute_allowed = bool(boundary.allow_execute) and "execute" not in forbidden_levels + if draft_allowed and signatures_by_level.get("draft"): + blocked_workflows = list(signatures_by_level.get("execute", [])) if not execute_allowed else [] + blocked_levels = ["execute"] if blocked_workflows else [] + allowed_workflows = cls._dedupe_list(signatures_by_level.get("draft", [])) + return AnswerControlDecision( + effective_level="draft", + blocked_levels=blocked_levels, + blocked_workflows=blocked_workflows, + allowed_workflows=allowed_workflows, + requires_confirmation=any( + item.requires_confirmation + for item in workflows + if cls._workflow_signature(item) in set(allowed_workflows) + ), + requires_self_review=bool(boundary.self_review_required), + requires_correction_slot=bool(boundary.correction_allowed), + reason="draft_boundary_allowed", + ) + + execute_allowed = bool(boundary.allow_execute) and "execute" not in forbidden_levels + if requested_level == "execute" and execute_allowed and signatures_by_level.get("execute"): + blocked_workflows = list(signatures_by_level.get("draft", [])) + blocked_levels = ["draft"] if blocked_workflows else [] + return AnswerControlDecision( + effective_level="execute", + blocked_levels=blocked_levels, + blocked_workflows=blocked_workflows, + allowed_workflows=cls._dedupe_list(signatures_by_level.get("execute", [])), + requires_confirmation=True, + requires_self_review=bool(boundary.self_review_required), + requires_correction_slot=bool(boundary.correction_allowed), + reason="execute_boundary_allowed", + ) + + blocked_workflows = list(all_non_query_signatures) + blocked_levels = cls._dedupe_list( + ["draft" if signatures_by_level.get("draft") else "", "execute" if signatures_by_level.get("execute") else ""] + ) + return AnswerControlDecision( + effective_level="clarify", + blocked_levels=blocked_levels, + blocked_workflows=blocked_workflows, + allowed_workflows=["query_flow:answer"], + downgraded_from="execute" if requested_level == "execute" else "draft", + requires_confirmation=False, + requires_self_review=bool(boundary.self_review_required), + requires_correction_slot=bool(boundary.correction_allowed), + reason="boundary_level_not_granted", + ) + + @classmethod + def _filter_workflows_by_answer_control( + cls, + *, + workflows: list[WorkflowPlan], + answer_control: AnswerControlDecision, + decision: SemanticDecision, + ) -> list[WorkflowPlan]: + effective_level = str(answer_control.effective_level or "").strip().lower() + allowed_signatures = { + str(item).strip() + for item in list(answer_control.allowed_workflows or []) + if str(item).strip() + } + if effective_level in cls._QUERY_ONLY_LEVELS and str(answer_control.reason or "").strip().lower() != "legacy_passthrough": + return [ + cls._query_answer_fallback_workflow( + workflows=workflows, + decision=decision, + requested_level=effective_level, + ) + ] + filtered = [item for item in workflows if cls._workflow_signature(item) in allowed_signatures] + if filtered: + return filtered + return [ + cls._query_answer_fallback_workflow( + workflows=workflows, + decision=decision, + requested_level="clarify", + ) + ] + @classmethod def _build_cold_start_packet( cls, @@ -168,6 +508,7 @@ class WorkflowSemanticPlanner: decision: SemanticDecision, source_system: str, message_text: str, + metadata: Optional[dict[str, Any]] = None, ) -> WorkflowBundle: workflows: list[WorkflowPlan] = [] for item in decision.workflow_plans: @@ -186,6 +527,19 @@ class WorkflowSemanticPlanner: ) if not workflows: raise ValueError("cold_start_route bundle has no workflows") + boundary = cls._boundary_for_decision(decision=decision, workflows=workflows) + self_review = cls._self_review_for_decision(decision) + answer_control = cls._build_answer_control_decision( + decision=decision, + workflows=workflows, + boundary=boundary, + self_review=self_review, + ) + workflows = cls._filter_workflows_by_answer_control( + workflows=workflows, + answer_control=answer_control, + decision=decision, + ) primary_workflow = workflows[0].workflow_type return WorkflowBundle( bundle_id=cls._bundle_id( @@ -204,6 +558,13 @@ class WorkflowSemanticPlanner: ), diagnostics=cls._build_query_bundle_diagnostics( scene_metadata=dict(decision.scene_metadata or {}), + ingress_metadata=metadata, + interaction_boundary=boundary, + business_frame=decision.business_frame, + answer_shape_contract=decision.answer_shape_contract, + operation_intent_contract=decision.operation_intent_contract, + answer_control=answer_control, + self_review=self_review, ), ) @@ -224,6 +585,7 @@ class WorkflowSemanticPlanner: decision=decision, source_system=source_system, message_text=str(message_text or "").strip(), + metadata=metadata, ) @classmethod diff --git a/backend/app/services/analysis_packet_builder.py b/backend/app/services/analysis_packet_builder.py new file mode 100644 index 00000000..b002814c --- /dev/null +++ b/backend/app/services/analysis_packet_builder.py @@ -0,0 +1,1235 @@ +"""Shared analysis packet builder for analysis-style replies.""" + +from __future__ import annotations + +import os +from typing import Any + +from app.schemas.analysis_packets import ( + AnalysisFocusObject, + ExternalImpactAnalysisPacket, + FollowupExplainAnalysisPacket, + InternalExplainAnalysisPacket, + ProjectFitAnalysisPacket, + normalize_analysis_packet_payload, +) + + +_ENV_ENABLE_ANALYSIS_PACKET_FAMILY = "PMFLOW_ENABLE_ANALYSIS_PACKET_FAMILY" + + +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 _safe_dict(value: Any) -> dict[str, Any]: + return dict(value) if isinstance(value, dict) else {} + + +def _internal_packet_section(packet: Any, key: str) -> dict[str, Any]: + payload = _safe_dict(packet) + value = payload.get(key) + return dict(value) if isinstance(value, dict) else {} + + +def _normalize_text(value: Any, *, max_len: int = 240) -> str: + text = " ".join(str(value or "").split()).strip() + if not text: + return "" + return text[:max_len] + + +def _normalize_object_id(value: Any) -> int | None: + try: + return int(value) if value is not None else None + except Exception: + return None + + +def _normalize_string_list(value: Any, *, limit: int = 6, max_len: int = 120) -> list[str]: + if not isinstance(value, list): + return [] + rows: list[str] = [] + for item in value: + text = _normalize_text(item, max_len=max_len) + if text and text not in rows: + rows.append(text) + if len(rows) >= limit: + break + return rows + + +def _normalize_preferred_names( + preferred_names: list[Any] | None, + *, + limit: int = 6, + max_len: int = 220, +) -> list[str]: + if not isinstance(preferred_names, list): + return [] + rows: list[str] = [] + for item in preferred_names: + text = _normalize_text(item, max_len=max_len) + if not text or text in rows: + continue + rows.append(text) + if len(rows) >= limit: + break + return rows + + +def _normalize_route_mode(value: Any) -> str: + text = str(value or "").strip().lower() + if text in {"internal", "external", "uncertain"}: + return text + return "uncertain" + + +def _first_non_empty(*values: Any, max_len: int = 240) -> str: + for value in values: + text = _normalize_text(value, max_len=max_len) + if text: + return text + return "" + + +def _project_subject( + *, + project_snapshot: dict[str, Any], + fit_brief: dict[str, Any], + project_operating_context: dict[str, Any], +) -> str: + return _first_non_empty( + project_snapshot.get("project_name"), + fit_brief.get("project_name"), + fit_brief.get("project_summary"), + project_operating_context.get("project_name"), + project_operating_context.get("current_repo_scope"), + max_len=120, + ) + + +def _normalize_entity_detail(value: Any) -> str: + text = _normalize_text(value, max_len=220) + if not text: + return "" + for marker in ("上下文:", "上下文:", "context:", "Context:"): + if marker in text: + text = text.split(marker, 1)[0].rstrip("。;;,, ") + break + return _normalize_text(text, max_len=220) + + +def _iter_structured_snapshot_entities( + project_snapshot: dict[str, Any], + *, + key: str, + kind: str, +) -> list[dict[str, str]]: + rows = project_snapshot.get(key) + if not isinstance(rows, list): + return [] + normalized: list[dict[str, str]] = [] + for item in rows[:6]: + if not isinstance(item, dict): + continue + name = _first_non_empty( + item.get("title"), + item.get("name"), + item.get("display_name"), + max_len=120, + ) + if not name: + continue + detail = _first_non_empty( + item.get("description"), + item.get("summary"), + item.get("reasoning"), + item.get("content"), + max_len=220, + ) + normalized.append( + { + "name": name, + "kind": kind, + "source": f"project_snapshot_{kind}", + "object_id": _normalize_object_id(item.get("id") or item.get("object_id")), + "detail": detail, + "status": _normalize_text(item.get("status"), max_len=40), + } + ) + return normalized + + +def _build_entity_catalog( + *, + project_operating_context: dict[str, Any], + fit_brief: dict[str, Any], + external_recommendation: dict[str, Any], + project_snapshot: dict[str, Any], +) -> list[dict[str, str]]: + catalog: list[dict[str, str]] = [] + + def _append(name: Any, kind: str, source: str, *, detail: Any = None, status: Any = None) -> None: + normalized = _normalize_text(name, max_len=120) + if not normalized: + return + detail_payload = dict(detail) if isinstance(detail, dict) else {} + detail_text = _normalize_entity_detail(detail_payload.get("text") if detail_payload else detail) + status_text = _normalize_text(status, max_len=40) + object_id = _normalize_object_id(detail_payload.get("id")) + for existing in catalog: + if existing["name"] != normalized: + continue + if detail_text and not existing.get("detail"): + existing["detail"] = detail_text + if status_text and not existing.get("status"): + existing["status"] = status_text + if object_id is not None and existing.get("object_id") is None: + existing["object_id"] = object_id + return + catalog.append( + { + "name": normalized, + "kind": kind, + "source": source, + "object_id": object_id, + "detail": detail_text, + "status": status_text, + } + ) + + structured_tasks = _iter_structured_snapshot_entities(project_snapshot, key="tasks", kind="task") + structured_signals = _iter_structured_snapshot_entities(project_snapshot, key="signals", kind="signal") + for item in structured_tasks: + _append( + item.get("name"), + "task", + str(item.get("source") or "project_snapshot_task"), + detail={"text": item.get("detail"), "id": item.get("object_id")}, + status=item.get("status"), + ) + for item in structured_signals: + _append( + item.get("name"), + "signal", + str(item.get("source") or "project_snapshot_signal"), + detail={"text": item.get("detail"), "id": item.get("object_id")}, + status=item.get("status"), + ) + for item in _normalize_string_list(project_snapshot.get("task_titles"), limit=6): + _append(item, "task", "project_snapshot_task") + for item in _normalize_string_list(project_snapshot.get("signal_titles"), limit=6): + _append(item, "signal", "project_snapshot_signal") + for item in _normalize_string_list(fit_brief.get("signal_source_anchors"), limit=6): + _append(item, "signal", "fit_brief_anchor") + for item in _normalize_string_list(fit_brief.get("current_capabilities"), limit=4): + _append(item, "capability", "fit_brief") + _append(project_snapshot.get("project_name"), "project", "project_snapshot") + _append(fit_brief.get("project_summary"), "project", "fit_brief") + _append(project_operating_context.get("current_repo_scope"), "repo", "project_operating_context") + for item in _normalize_string_list(external_recommendation.get("affected_signal_sources"), limit=4): + _append(item, "signal", "external_recommendation") + return catalog + + +def _entity_detail(entity: dict[str, str] | None) -> str: + if not entity: + return "" + return _normalize_entity_detail(entity.get("detail")) + + +def _trim_clause(text: str) -> str: + return str(text or "").strip().rstrip("。;;,,") + + +def _describe_signal_fact(name: str, detail: str, *, suffix: str) -> str: + normalized_detail = _trim_clause(detail) + if not normalized_detail: + return suffix + return f"{name}这条线索当前指向{normalized_detail},{suffix}" + + +def _entity_text(entity: dict[str, str] | None) -> str: + if not entity: + return "" + return " ".join( + part + for part in ( + _normalize_text(entity.get("name"), max_len=120), + _entity_detail(entity), + _normalize_text(entity.get("status"), max_len=40), + _normalize_text(entity.get("source"), max_len=40), + ) + if part + ).strip() + + +def _pick_entity_by_object_ref( + entity_catalog: list[dict[str, str]], + *, + object_ref: Any, + used_names: set[str] | None = None, +) -> dict[str, str] | None: + payload = dict(object_ref) if isinstance(object_ref, dict) else {} + if not payload: + return None + used = used_names or set() + target_id = _normalize_object_id(payload.get("id")) + target_title = _normalize_text(payload.get("title"), max_len=120) + target_kind = _normalize_text(payload.get("kind"), max_len=40) + for item in entity_catalog: + item_name = _normalize_text(item.get("name"), max_len=120) + if not item_name or item_name in used: + continue + item_id = _normalize_object_id(item.get("object_id")) + item_kind = _normalize_text(item.get("kind"), max_len=40) + if target_id is not None and item_id == target_id and (not target_kind or target_kind == item_kind): + return dict(item) + if target_title: + return _pick_entity_by_name(entity_catalog, name=target_title, used_names=used) + return None + + +def _project_snapshot_analysis_refs(project_snapshot: dict[str, Any]) -> dict[str, Any]: + refs = project_snapshot.get("analysis_object_refs") + return dict(refs) if isinstance(refs, dict) else {} + + +def _iter_entities_by_kind( + entity_catalog: list[dict[str, str]], + *, + kind: str, + used_names: set[str] | None = None, +) -> list[dict[str, str]]: + used = used_names or set() + return [ + dict(item) + for item in entity_catalog + if str(item.get("kind") or "").strip() == kind + and str(item.get("name") or "").strip() + and str(item.get("name") or "").strip() not in used + ] + + +def _pick_entity_by_name( + entity_catalog: list[dict[str, str]], + *, + name: Any, + used_names: set[str] | None = None, +) -> dict[str, str] | None: + target = _normalize_text(name, max_len=120) + if not target: + return None + used = used_names or set() + target_key = target.lower() + exact = None + for item in entity_catalog: + item_name = _normalize_text(item.get("name"), max_len=120) + if not item_name or item_name in used: + continue + item_key = item_name.lower() + if item_key == target_key: + exact = dict(item) + break + return exact + + +def _pick_entity_by_preferred_names( + entity_catalog: list[dict[str, str]], + *, + preferred_names: list[str] | None = None, + used_names: set[str] | None = None, +) -> dict[str, str] | None: + preferred = _normalize_preferred_names(preferred_names) + if not preferred: + return None + for raw_name in preferred: + matched = _pick_entity_by_name(entity_catalog, name=raw_name, used_names=used_names) + if matched is not None: + return matched + return None + + +def _pick_entity_by_exact_text_binding( + entity_catalog: list[dict[str, str]], + *, + texts: list[Any] | None = None, + used_names: set[str] | None = None, +) -> dict[str, str] | None: + normalized_texts = { + _normalize_text(item, max_len=220).lower() + for item in list(texts or []) + if _normalize_text(item, max_len=220) + } + if not normalized_texts: + return None + used = used_names or set() + for item in entity_catalog: + item_name = _normalize_text(item.get("name"), max_len=220) + if not item_name or item_name in used: + continue + detail = _entity_detail(item) + if item_name.lower() in normalized_texts or detail.lower() in normalized_texts: + return dict(item) + return None + + +def _entity_matches_object_ref(entity: dict[str, str] | None, object_ref: Any) -> bool: + if not entity: + return False + payload = dict(object_ref) if isinstance(object_ref, dict) else {} + if not payload: + return False + target_id = _normalize_object_id(payload.get("id")) + target_title = _normalize_text(payload.get("title"), max_len=120) + target_kind = _normalize_text(payload.get("kind"), max_len=40) + entity_id = _normalize_object_id(entity.get("object_id")) + entity_name = _normalize_text(entity.get("name"), max_len=120) + entity_kind = _normalize_text(entity.get("kind"), max_len=40) + if target_id is not None and entity_id == target_id and (not target_kind or target_kind == entity_kind): + return True + return bool(target_title and entity_name and target_title == entity_name and (not target_kind or target_kind == entity_kind)) + + +def _task_entity_structural_support( + entity: dict[str, str] | None, + *, + snapshot_refs: dict[str, Any], + anchor_entity: dict[str, str] | None = None, +) -> float: + if not entity or _normalize_text(entity.get("kind"), max_len=40) != "task": + return 0.0 + score = 0.0 + entity_name = _normalize_text(entity.get("name"), max_len=120) + if not entity_name: + return 0.0 + explicit_refs = ( + snapshot_refs.get("fit_candidate_task_ref"), + snapshot_refs.get("fit_rejected_task_ref"), + snapshot_refs.get("primary_dependent_task_ref"), + ) + if any(_entity_matches_object_ref(entity, ref) for ref in explicit_refs): + score += 8.0 + _ = anchor_entity + return score + + +def _signal_entity_structural_support( + entity: dict[str, str] | None, + *, + snapshot_refs: dict[str, Any], +) -> float: + if not entity or _normalize_text(entity.get("kind"), max_len=40) != "signal": + return 0.0 + score = 0.0 + explicit_refs = ( + snapshot_refs.get("fit_candidate_signal_ref"), + snapshot_refs.get("fit_rejected_signal_ref"), + snapshot_refs.get("primary_anchor_signal_ref"), + ) + if any(_entity_matches_object_ref(entity, ref) for ref in explicit_refs): + score += 8.0 + return score + + +def _pick_anchor_entity( + entity_catalog: list[dict[str, str]], + *, + preferred_ref: Any = None, + preferred_names: list[str] | None = None, + used_names: set[str] | None = None, +) -> dict[str, str] | None: + explicit_entity = _pick_entity_by_preferred_names( + _iter_entities_by_kind(entity_catalog, kind="signal", used_names=used_names), + preferred_names=preferred_names, + used_names=used_names, + ) + if explicit_entity is not None: + return explicit_entity + preferred_entity = _pick_entity_by_object_ref( + entity_catalog, + object_ref=preferred_ref, + used_names=used_names, + ) + if preferred_entity is not None: + return preferred_entity + signals = _iter_entities_by_kind(entity_catalog, kind="signal", used_names=used_names) + if len(signals) == 1: + return dict(signals[0]) + return None + + +def _pick_dependent_task( + entity_catalog: list[dict[str, str]], + *, + anchor_entity: dict[str, str] | None = None, + preferred_ref: Any = None, + preferred_name: Any = None, + used_names: set[str] | None = None, +) -> dict[str, str] | None: + tasks = _iter_entities_by_kind(entity_catalog, kind="task", used_names=used_names) + preferred = _pick_entity_by_object_ref(tasks, object_ref=preferred_ref, used_names=used_names) + if preferred: + return preferred + preferred = _pick_entity_by_name(tasks, name=preferred_name, used_names=used_names) + if preferred: + return preferred + if len(tasks) == 1: + return dict(tasks[0]) + if not tasks: + signals = _iter_entities_by_kind(entity_catalog, kind="signal", used_names=used_names) + preferred_signal = _pick_entity_by_object_ref(signals, object_ref=preferred_ref, used_names=used_names) + if preferred_signal: + return preferred_signal + preferred_signal = _pick_entity_by_name(signals, name=preferred_name, used_names=used_names) + if preferred_signal: + return preferred_signal + if len(signals) == 1: + return dict(signals[0]) + return None + _ = anchor_entity + return None + + +def _pick_fit_contrast_tasks( + entity_catalog: list[dict[str, str]], + *, + snapshot_refs: dict[str, Any] | None = None, + preferred_ref: Any = None, + rejected_ref: Any = None, + used_names: set[str] | None = None, +) -> tuple[dict[str, str] | None, dict[str, str] | None]: + tasks = _iter_entities_by_kind(entity_catalog, kind="task", used_names=used_names) + if not tasks: + signals = _iter_entities_by_kind(entity_catalog, kind="signal", used_names=used_names) + if not signals: + return None, None + preferred_signal = _pick_entity_by_object_ref(signals, object_ref=snapshot_refs.get("fit_candidate_signal_ref") if snapshot_refs else None, used_names=used_names) + rejected_signal = _pick_entity_by_object_ref( + signals, + object_ref=snapshot_refs.get("fit_rejected_signal_ref") if snapshot_refs else None, + used_names={(preferred_signal.get("name") if preferred_signal else "")} | set(used_names or set()), + ) + if preferred_signal or rejected_signal: + return preferred_signal, rejected_signal + return None, None + preferred = _pick_entity_by_object_ref(tasks, object_ref=preferred_ref, used_names=used_names) + rejected = _pick_entity_by_object_ref( + tasks, + object_ref=rejected_ref, + used_names={(preferred.get("name") if preferred else "")} | set(used_names or set()), + ) + if preferred or rejected: + return preferred, rejected + return None, None + + +def _pick_best_entity( + entity_catalog: list[dict[str, str]], + *, + role: str, + used_names: set[str] | None = None, +) -> dict[str, str] | None: + used = used_names or set() + if role in {"preferred_fit", "rejected_fit"}: + preferred, rejected = _pick_fit_contrast_tasks(entity_catalog, used_names=used) + return preferred if role == "preferred_fit" else rejected + if role in {"blocked", "anchor"}: + return _pick_anchor_entity(entity_catalog, used_names=used) + if role == "dependent": + return _pick_dependent_task(entity_catalog, used_names=used) + candidates = [ + dict(item) + for item in entity_catalog + if str(item.get("name") or "").strip() and str(item.get("name") or "").strip() not in used + ] + if len(candidates) == 1: + return candidates[0] + return None + + +def _build_focus_object( + *, + entity: dict[str, str] | None, + stance: str, + reason: str, +) -> AnalysisFocusObject | None: + if not entity: + return None + name = _normalize_text(entity.get("name"), max_len=120) + if not name: + return None + normalized_reason = _normalize_text(reason, max_len=160) + if not normalized_reason: + return None + return AnalysisFocusObject( + name=name, + kind=str(entity.get("kind") or "unknown").strip() or "unknown", + stance=stance, # type: ignore[arg-type] + reason=normalized_reason, + ) + + +def _build_project_fit_packet( + *, + fit_brief: dict[str, Any], + external_recommendation: dict[str, Any], + recommended_next_step: str, + entity_catalog: list[dict[str, str]], + project_subject: str, + project_snapshot: dict[str, Any], +) -> dict[str, Any]: + retrieval_status = str(external_recommendation.get("retrieval_status") or "").strip().lower() + impact_status = str(external_recommendation.get("impact_status") or "").strip().lower() + used_names: set[str] = set() + focus_objects: list[AnalysisFocusObject] = [] + + def _append(entity: dict[str, str] | None, stance: str, reason: str) -> None: + focus = _build_focus_object(entity=entity, stance=stance, reason=reason) + if focus is None or focus.name in used_names or len(focus_objects) >= 2: + return + focus_objects.append(focus) + used_names.add(focus.name) + + fit_verdict = "uncertain" + current_subject = _normalize_text(project_subject, max_len=120) or None + preferred_layer: str | None = None + rejected_layer: str | None = None + candidate_position: str | None = None + reason_chain: list[str] = [] + snapshot_refs = _project_snapshot_analysis_refs(project_snapshot) + project_description = _normalize_text(project_snapshot.get("project_description"), max_len=180) + human_next_step = "" + suppress_generic_fallback_focus = False + + if retrieval_status == "evidence_found" and impact_status == "direct_match": + anchor = _pick_anchor_entity( + entity_catalog, + preferred_ref=snapshot_refs.get("primary_anchor_signal_ref"), + preferred_names=_normalize_string_list(external_recommendation.get("affected_signal_sources"), limit=4), + used_names=used_names, + ) + dependent = _pick_dependent_task( + entity_catalog, + anchor_entity=anchor, + preferred_ref=snapshot_refs.get("primary_dependent_task_ref"), + used_names=used_names, + ) + _append(dependent, "dependent", "如果这条变化真有影响,最容易先落到这一步。") + _append(anchor, "anchor", "这是当前能直接对上的外部来源。") + fit_verdict = "fit_now" + candidate_position = dependent.get("name") if dependent else anchor.get("name") if anchor else None + preferred_layer = candidate_position + anchor_name = _first_non_empty(anchor.get("name") if anchor else None, "当前正式锚点", max_len=120) + dependent_name = _first_non_empty(dependent.get("name") if dependent else None, anchor_name, max_len=120) + anchor_detail = _trim_clause(_entity_detail(anchor)) + dependent_detail = _trim_clause(_entity_detail(dependent)) + conclusion = ( + f"放到{current_subject}里,这条外部变化已经直接打到{dependent_name}这一步。" + if current_subject + else f"这条外部变化已经直接打到{dependent_name}这一步。" + ) + reason_chain = [ + ( + f"{dependent_name}负责{dependent_detail},会最先接住这条变化。" + if dependent_detail + else f"{dependent_name}是当前最直接会接住这条变化的项目对象。" + ), + ( + f"对应的正式锚点是{anchor_name},它指向{anchor_detail},已经和外部证据建立了直接映射。" + if anchor_detail + else f"{anchor_name}已经和外部证据建立了直接映射。" + ), + ] + human_next_step = f"先看这条变化会不会直接改到{dependent_name}这一步的下游动作。" + missing_mapping = _first_non_empty( + external_recommendation.get("external_action"), + f"还缺它具体作用到{dependent_name}这一步哪个字段或触发条件的明确信息。", + max_len=220, + ) + elif retrieval_status == "verifier_blocked": + blocked = _pick_anchor_entity( + entity_catalog, + preferred_ref=snapshot_refs.get("primary_anchor_signal_ref"), + preferred_names=_normalize_string_list(external_recommendation.get("affected_signal_sources"), limit=4), + used_names=used_names, + ) + dependent = _pick_dependent_task( + entity_catalog, + anchor_entity=blocked, + preferred_ref=snapshot_refs.get("primary_dependent_task_ref"), + used_names=used_names, + ) + _append(blocked, "blocked", "这条线索还没核实清楚,暂时不能直接拿来下结论。") + _append(dependent, "dependent", "如果后面真要用这条变化,还得先把字段和证据对上。") + fit_verdict = "uncertain" + blocked_name = _first_non_empty(blocked.get("name") if blocked else None, "这条线索", max_len=120) + dependent_name = _first_non_empty(dependent.get("name") if dependent else None, "当前项目动作", max_len=120) + blocked_detail = _trim_clause(_entity_detail(blocked)) + dependent_detail = _trim_clause(_entity_detail(dependent)) + conclusion = ( + f"放到{current_subject}里,这条外部变化现在还不能直接挂项目结论。" + if current_subject + else "这条外部变化现在还不能直接挂项目结论。" + ) + if _task_entity_structural_support(dependent, snapshot_refs=snapshot_refs, anchor_entity=blocked) <= 0: + reason_chain = [ + ( + f"现在卡在{blocked_name}这条线索上:它指向{blocked_detail},但还没通过校验。" + if blocked_detail + else f"{blocked_name}这条线索还没通过校验,当前不能直接挂到项目结论上。" + ), + "项目里现在更多还是实现层任务,我不想硬说它会直接改到哪一步,先把这条线索核实清楚更稳。", + ] + human_next_step = f"先把{blocked_name}这条线索核实清楚,再确认它到底会影响这个项目里的哪个结果。" + else: + reason_chain = [ + ( + f"现在卡在{blocked_name}这条线索上:它指向{blocked_detail},但还没通过校验。" + if blocked_detail + else f"{blocked_name}这条线索还没通过校验,当前不能直接挂到项目结论上。" + ), + ( + f"如果后面真要让{dependent_name}用这条变化,还得先把对应字段和证据核实清楚。" + if dependent_detail + else f"{dependent_name}如果要用这条变化,还缺能对上的字段和证据。" + ), + ] + human_next_step = f"先把{blocked_name}这条线索核实清楚,再判断要不要让{dependent_name}用它。" + missing_mapping = human_next_step + else: + preferred, rejected = _pick_fit_contrast_tasks( + entity_catalog, + snapshot_refs=snapshot_refs, + preferred_ref=snapshot_refs.get("fit_candidate_task_ref") or snapshot_refs.get("fit_candidate_signal_ref"), + rejected_ref=snapshot_refs.get("fit_rejected_task_ref") or snapshot_refs.get("fit_rejected_signal_ref"), + used_names=used_names, + ) + _append(preferred, "better_fit", "更适合先在这一步试,看看它到底值不值。") + _append(rejected, "not_fit", "这一步要先稳定跑通,不适合一上来就改。") + fit_verdict = "fit_later" if impact_status == "indirect_only" else "not_fit_yet" + candidate_position = preferred.get("name") if preferred else None + preferred_layer = preferred.get("name") if preferred else None + rejected_layer = rejected.get("name") if rejected else None + preferred_name = _first_non_empty(preferred.get("name") if preferred else None, "结果出来之后的解释层", max_len=120) + rejected_name = _first_non_empty(rejected.get("name") if rejected else None, "当前主链", max_len=120) + preferred_detail = _trim_clause(_entity_detail(preferred)) + rejected_detail = _trim_clause(_entity_detail(rejected)) + preferred_kind = str(preferred.get("kind") or "").strip() if preferred else "" + rejected_kind = str(rejected.get("kind") or "").strip() if rejected else "" + if preferred is None or rejected is None: + conclusion = ( + f"光看{current_subject}现在这些项目信息,我还不想直接说它该放哪一步。" + if current_subject + else "光看现在这些项目信息,我还不想直接说它该放哪一步。" + ) + if project_description: + normalized_description = _trim_clause(project_description) + reason_chain = [ + f"我现在只看得到这个项目的大致目标:{normalized_description}。", + "但还看不到这条链路里谁来触发、要接哪些外部输入、以及哪个结果算真的跑通。", + ] + human_next_step = "至少先把实际流程拆出来:谁发起、系统接什么输入、最后产出什么结果。" + missing_mapping = "还缺能对上的实际流程、关键结果和外部输入。" + else: + reason_chain = [ + "现在项目里还没有足够具体的任务或信号源,硬把它挂到某个位置上,结论很容易说偏。" + ] + human_next_step = "先把这个项目最关键的业务步骤补清楚,我再判断它更适合放哪里。" + missing_mapping = "至少还缺一个能对上的业务步骤或信号源。" + suppress_generic_fallback_focus = True + elif ( + preferred_kind == "task" + and rejected_kind == "task" + and max( + _task_entity_structural_support(preferred, snapshot_refs=snapshot_refs), + _task_entity_structural_support(rejected, snapshot_refs=snapshot_refs), + ) + <= 0 + ): + conclusion = ( + f"光看{current_subject}现在这批任务,我还不想硬说它该放哪一步。" + if current_subject + else "光看现在这批任务,我还不想硬说它该放哪一步。" + ) + reason_chain = [ + f"现在拆出来的主要还是实现层任务,比如{preferred_name}、{rejected_name},还看不出它到底该先影响哪个业务结果。" + ] + human_next_step = "先定清它到底想改善哪个业务结果,再看该落到哪一步。" + missing_mapping = "至少还缺一个明确的业务结果或业务动作。" + suppress_generic_fallback_focus = True + elif ( + preferred_kind == "signal" + and rejected_kind == "signal" + and max( + _signal_entity_structural_support(preferred, snapshot_refs=snapshot_refs), + _signal_entity_structural_support(rejected, snapshot_refs=snapshot_refs), + ) + <= 0 + ): + conclusion = ( + f"光看{current_subject}现在这些信号源占位,我还不想直接说它该放哪一步。" + if current_subject + else "光看现在这些信号源占位,我还不想直接说它该放哪一步。" + ) + reason_chain = [ + "现在写出来的还是“增加某个信号源”这类占位,不是真正的业务步骤,硬判很容易说偏。" + ] + human_next_step = "先定清这个项目最后要给谁看什么结果,再判断哪个信号只是输入、哪个位置更值得先试。" + missing_mapping = "至少还缺一个能落到业务动作上的对象。" + suppress_generic_fallback_focus = True + elif preferred_kind == "signal" or rejected_kind == "signal": + conclusion = ( + f"放到{current_subject}里,我更倾向先让它去帮{preferred_name},不太会先拿它改{rejected_name}。" + if current_subject + else f"我更倾向先让它去帮{preferred_name},不太会先拿它改{rejected_name}。" + ) + reason_chain = [ + ( + f"{rejected_name}现在代表的是{rejected_detail},这类前面必须先跑通的条件,不适合直接拿来试新能力。" + if rejected_detail + else f"{rejected_name}更像前面必须先跑通的条件,不适合直接拿来试新能力。" + ), + ( + f"{preferred_name}现在代表的是{preferred_detail},先从这里试更稳,也更容易看出它有没有价值。" + if preferred_detail + else f"{preferred_name}更像用户最后会看到的结果,先从这里试更稳。" + ), + ] + human_next_step = f"先定清它到底是给{preferred_name}补说明,还是会直接改到{rejected_name}。" + missing_mapping = f"现在还说不准它会先帮到{preferred_name},还是会直接改到{rejected_name}。" + else: + conclusion = ( + f"如果真要放进{current_subject},我会先放在{preferred_name}这一步,不太会直接改{rejected_name}这条主流程。" + if current_subject + else f"如果真要放进去,我会先放在{preferred_name}这一步,不太会直接改{rejected_name}这条主流程。" + ) + reason_chain = [ + ( + f"{rejected_name}现在做的是{rejected_detail},这一步要稳,不能随便把新能力塞进去。" + if rejected_detail + else f"{rejected_name}这一步要稳,不适合一上来就塞新能力。" + ), + ( + f"{preferred_name}现在做的是{preferred_detail},先在这里试更稳,也更容易看出值不值。" + if preferred_detail + else f"{preferred_name}这一步更适合先试,比直接改{rejected_name}风险小。" + ), + ] + human_next_step = f"先定清楚它是给{preferred_name}补说明,还是会直接改到{rejected_name},再决定要不要继续做。" + missing_mapping = f"现在还说不准它会先帮到{preferred_name},还是会直接改到{rejected_name}。" + + if not focus_objects and not suppress_generic_fallback_focus: + fallback = _pick_best_entity(entity_catalog, role="relevant", used_names=used_names) + _append(fallback, "relevant", "这是当前最直接相关的项目对象。") + + packet = ProjectFitAnalysisPacket( + conclusion=conclusion, + focus_objects=focus_objects, + next_step=_normalize_text(human_next_step, max_len=180) or _normalize_text(recommended_next_step, max_len=180) or None, + fit_verdict=fit_verdict, # type: ignore[arg-type] + current_subject=current_subject, + candidate_position=candidate_position, + rejected_position=rejected_layer, + preferred_layer=preferred_layer, + rejected_layer=rejected_layer, + reason_chain=[_normalize_text(item, max_len=180) for item in reason_chain if _normalize_text(item, max_len=180)], + missing_mapping=missing_mapping, + ) + return packet.model_dump() + + +def _build_external_impact_packet( + *, + external_recommendation: dict[str, Any], + recommended_next_step: str, + entity_catalog: list[dict[str, str]], + project_subject: str, + project_snapshot: dict[str, Any], +) -> dict[str, Any]: + retrieval_status = str(external_recommendation.get("retrieval_status") or "").strip().lower() or "retrieval_failed" + impact_status = str(external_recommendation.get("impact_status") or "").strip().lower() or "no_direct_impact" + used_names: set[str] = set() + focus_objects: list[AnalysisFocusObject] = [] + current_subject = _normalize_text(project_subject, max_len=120) or None + reason_chain: list[str] = [] + snapshot_refs = _project_snapshot_analysis_refs(project_snapshot) + human_next_step = "" + + def _append(entity: dict[str, str] | None, stance: str, reason: str) -> None: + focus = _build_focus_object(entity=entity, stance=stance, reason=reason) + if focus is None or focus.name in used_names or len(focus_objects) >= 2: + return + focus_objects.append(focus) + used_names.add(focus.name) + + if retrieval_status == "verifier_blocked": + blocked = _pick_anchor_entity( + entity_catalog, + preferred_ref=snapshot_refs.get("primary_anchor_signal_ref"), + preferred_names=_normalize_string_list(external_recommendation.get("affected_signal_sources"), limit=4), + used_names=used_names, + ) + dependent = _pick_dependent_task( + entity_catalog, + anchor_entity=blocked, + preferred_ref=snapshot_refs.get("primary_dependent_task_ref"), + used_names=used_names, + ) + _append(blocked, "blocked", "这条线索还没核实清楚,不能直接拿来下结论。") + _append(dependent, "dependent", "如果后面真要用这条变化,这一步会最先受影响。") + blocked_name = _first_non_empty(blocked.get("name") if blocked else None, "这条线索", max_len=120) + dependent_name = _first_non_empty(dependent.get("name") if dependent else None, "当前项目动作", max_len=120) + blocked_detail = _trim_clause(_entity_detail(blocked)) + dependent_detail = _trim_clause(_entity_detail(dependent)) + conclusion = ( + f"这条外部变化现在还不能直接挂到{current_subject}的项目结论上。" + if current_subject + else "这条外部变化现在还不能直接挂到当前项目结论上。" + ) + if _task_entity_structural_support(dependent, snapshot_refs=snapshot_refs, anchor_entity=blocked) <= 0: + reason_chain = [ + ( + f"现在卡在{blocked_name}这条线索上:它指向{blocked_detail},但还没通过校验。" + if blocked_detail + else f"{blocked_name}这条线索还没通过校验,当前不能直接挂到项目结论上。" + ), + "项目里现在更多还是实现层任务,我不想硬说它会直接改到哪一步,先把这条线索核实清楚更稳。", + ] + human_next_step = f"先把{blocked_name}这条线索核实清楚,再确认它到底会影响这个项目里的哪个结果。" + else: + reason_chain = [ + ( + f"现在卡在{blocked_name}这条线索上:它指向{blocked_detail},但还没通过校验。" + if blocked_detail + else f"{blocked_name}这条线索还没通过校验,当前不能直接挂到项目结论上。" + ), + ( + f"如果后面真要让{dependent_name}用这条变化,还得先把对应字段和证据核实清楚。" + if dependent_detail + else f"{dependent_name}如果要用这条变化,还缺能对上的字段和证据。" + ), + ] + human_next_step = f"先把{blocked_name}这条线索核实清楚,再判断要不要让{dependent_name}用它。" + elif retrieval_status == "evidence_found" and impact_status == "direct_match": + anchor = _pick_anchor_entity( + entity_catalog, + preferred_ref=snapshot_refs.get("primary_anchor_signal_ref"), + preferred_names=_normalize_string_list(external_recommendation.get("affected_signal_sources"), limit=4), + used_names=used_names, + ) + dependent = _pick_dependent_task( + entity_catalog, + anchor_entity=anchor, + preferred_ref=snapshot_refs.get("primary_dependent_task_ref"), + used_names=used_names, + ) + _append(dependent, "dependent", "如果这条变化真有影响,最容易先落到这一步。") + _append(anchor, "anchor", "这是当前能直接对上的外部来源。") + anchor_name = _first_non_empty(anchor.get("name") if anchor else None, "当前正式锚点", max_len=120) + dependent_name = _first_non_empty(dependent.get("name") if dependent else None, anchor_name, max_len=120) + anchor_detail = _trim_clause(_entity_detail(anchor)) + dependent_detail = _trim_clause(_entity_detail(dependent)) + if _task_entity_structural_support(dependent, snapshot_refs=snapshot_refs, anchor_entity=anchor) <= 0: + conclusion = ( + f"这条外部变化已经和{current_subject}对上了,但现在还不适合硬挂到某个实现任务上。" + if current_subject + else "这条外部变化已经对上了,但现在还不适合硬挂到某个实现任务上。" + ) + reason_chain = [ + ( + f"能对上的外部来源是{anchor_name},它指向{anchor_detail},所以这次不是泛泛相关。" + if anchor_detail + else f"能对上的外部来源是{anchor_name},所以这次不是泛泛相关。" + ), + "项目里现在更多还是实现层任务,先别急着说它会直接改到哪一步,先看它到底影响哪个业务结果。", + ] + human_next_step = "先确认这条变化会影响哪个业务结果,再决定要不要拆到具体任务。" + else: + conclusion = ( + f"这条外部变化已经直接打到{current_subject}里的{dependent_name}这一步。" + if current_subject + else f"这条外部变化已经直接打到{dependent_name}这一步。" + ) + reason_chain = [ + ( + f"{dependent_name}负责{dependent_detail},会最先接住这条变化,可以当成后续收口和计划讨论的正式输入。" + if dependent_detail + else f"{dependent_name}会最先接住这条变化,可以当成后续收口和计划讨论的正式输入。" + ), + ( + f"能对上的外部来源是{anchor_name},它指向{anchor_detail},所以这次不是泛泛相关。" + if anchor_detail + else f"能对上的外部来源是{anchor_name},所以这次不是泛泛相关。" + ), + ] + human_next_step = f"下一步就看这条变化会不会直接改到{dependent_name}后面的动作或规则。" + elif retrieval_status == "evidence_found" and impact_status == "indirect_only": + preferred, rejected = _pick_fit_contrast_tasks( + entity_catalog, + snapshot_refs=snapshot_refs, + preferred_ref=snapshot_refs.get("fit_candidate_task_ref") or snapshot_refs.get("fit_candidate_signal_ref"), + rejected_ref=snapshot_refs.get("fit_rejected_task_ref") or snapshot_refs.get("fit_rejected_signal_ref"), + used_names=used_names, + ) + _append(preferred, "better_fit", "如果继续看,这一步更适合先试。") + _append(rejected, "not_fit", "这一步现在还得按稳定流程跑。") + preferred_name = _first_non_empty(preferred.get("name") if preferred else None, "解释层", max_len=120) + rejected_name = _first_non_empty(rejected.get("name") if rejected else None, "当前主链", max_len=120) + preferred_detail = _trim_clause(_entity_detail(preferred)) + rejected_detail = _trim_clause(_entity_detail(rejected)) + conclusion = ( + f"这条外部变化现在只形成背景相关,还没真正落到{current_subject}的正式对象上。" + if current_subject + else "这条外部变化现在只形成背景相关,还没真正落到当前项目的正式对象上。" + ) + if ( + max( + _task_entity_structural_support(preferred, snapshot_refs=snapshot_refs), + _task_entity_structural_support(rejected, snapshot_refs=snapshot_refs), + ) + <= 0 + ): + reason_chain = [ + f"项目里现在更多还是实现层任务,比如{preferred_name}、{rejected_name},还看不出这条外部变化会先影响哪个业务结果。" + ] + human_next_step = "先看这条外部变化到底影响哪个业务结果,再决定要不要拆到具体任务。" + else: + reason_chain = [ + ( + f"{rejected_name}现在做的是{rejected_detail},这一步还得按现在的稳定流程跑,不能直接把这条外部变化挂进去。" + if rejected_detail + else f"{rejected_name}这一步还得按现在的稳定流程跑,不能直接把这条外部变化挂进去。" + ), + ( + f"如果继续看,我会先从{preferred_name}这一步试,因为{preferred_detail or f'这里比直接改{rejected_name}更容易先试出价值'}。" + if preferred_detail + else f"如果继续看,我会先从{preferred_name}这一步试,不会先去动{rejected_name}。" + ), + ] + human_next_step = f"如果继续看,先看{preferred_name}能不能先用上,再决定要不要动{rejected_name}。" + elif retrieval_status == "no_relevant_results": + _append(_pick_best_entity(entity_catalog, role="relevant", used_names=used_names), "relevant", "当前只找到最接近的项目对象,但还没有外部证据真正落上来。") + conclusion = ( + f"目前还没拿到与{current_subject}直接相关的外部证据。" + if current_subject + else "目前还没拿到与当前项目直接相关的外部证据。" + ) + reason_chain = ["当前只找到最接近的项目对象,但还没有外部证据真正落上来。"] + else: + _append(_pick_best_entity(entity_catalog, role="relevant", used_names=used_names), "relevant", "当前只保留最接近的项目对象,等待后续补证。") + conclusion = "这次外部检索没有形成可直接消费的项目结论。" + reason_chain = ["当前只保留最接近的项目对象,等待后续补证。"] + + packet = ExternalImpactAnalysisPacket( + conclusion=conclusion, + focus_objects=focus_objects, + next_step=_normalize_text(human_next_step, max_len=180) or _normalize_text(recommended_next_step, max_len=180) or None, + retrieval_status=retrieval_status, # type: ignore[arg-type] + impact_status=impact_status if impact_status in {"direct_match", "indirect_only", "no_direct_impact"} else "no_direct_impact", # type: ignore[arg-type] + current_subject=current_subject, + reason_chain=[_normalize_text(item, max_len=180) for item in reason_chain if _normalize_text(item, max_len=180)], + anchor_objects=_normalize_string_list(external_recommendation.get("affected_signal_sources"), limit=4), + blocked_reason_or_gap=_first_non_empty( + human_next_step, + recommended_next_step, + external_recommendation.get("external_action"), + external_recommendation.get("external_impact"), + external_recommendation.get("external_summary"), + "当前还缺能把外部变化稳定映射到项目对象上的证据。", + max_len=220, + ), + ) + return packet.model_dump() + + +def _build_internal_explain_packet( + *, + answer: str, + query_type: str, + evidence_pack: dict[str, Any], + internal_context_packet: Any, + entity_catalog: list[dict[str, str]], +) -> dict[str, Any]: + operation_contract = _internal_packet_section(internal_context_packet, "operation_contract") + query_plan = _internal_packet_section(internal_context_packet, "query_plan") + target_resolution = _internal_packet_section(internal_context_packet, "target_resolution") + answer_contract = _first_non_empty( + query_plan.get("answer_contract"), + operation_contract.get("answer_contract"), + query_type, + max_len=80, + ) + resolved_target = _first_non_empty( + target_resolution.get("primary_target_label"), + target_resolution.get("target_label"), + target_resolution.get("matched_target_label"), + target_resolution.get("target_name"), + max_len=120, + ) + if not resolved_target: + matched_entity = _pick_entity_by_exact_text_binding( + entity_catalog, + texts=[answer, _safe_dict(evidence_pack.get("summary")).get("conclusion")], + ) + resolved_target = _normalize_text((matched_entity or {}).get("name"), max_len=120) + uncertainties = _normalize_string_list(_safe_dict(evidence_pack.get("summary")).get("uncertainties"), limit=2, max_len=120) + conclusion = _first_non_empty( + _safe_dict(evidence_pack.get("summary")).get("conclusion"), + answer, + "当前内部对象已经拿到可解释结果。", + max_len=220, + ) + current_subject = resolved_target or None + reason_chain: list[str] = [] + focus_objects: list[AnalysisFocusObject] = [] + if resolved_target: + focus_objects.append( + AnalysisFocusObject( + name=resolved_target, + kind="internal_target", + stance="relevant", + reason="这次回答说的就是这个对象。", + ) + ) + if resolved_target and answer_contract in {"status_summary", "status"}: + reason_chain.append(f"这次就按{resolved_target}的当前情况回答,先不展开方案。") + elif resolved_target: + reason_chain.append(f"这次先把{resolved_target}现在是什么情况说清楚。") + elif answer_contract: + reason_chain.append("这次先直接回答当前情况,不往方案上展开。") + packet = InternalExplainAnalysisPacket( + conclusion=conclusion, + focus_objects=focus_objects, + answer_contract=answer_contract or None, + current_subject=current_subject, + reason_chain=[_normalize_text(item, max_len=180) for item in reason_chain if _normalize_text(item, max_len=180)], + resolved_target=resolved_target or None, + evidence_gap_or_uncertainty=uncertainties[0] if uncertainties else None, + ) + return packet.model_dump() + + +def _build_followup_explain_packet( + *, + answer: str, + evidence_pack: dict[str, Any], + clarification_code: str, + anchor_kind: str, + meta_followup_kind: str, +) -> dict[str, Any]: + summary = _safe_dict(evidence_pack.get("summary")) + uncertainties = _normalize_string_list(summary.get("uncertainties"), limit=2, max_len=120) + packet = FollowupExplainAnalysisPacket( + conclusion=_first_non_empty( + summary.get("conclusion"), + answer, + "当前回复依赖上一轮上下文。", + max_len=220, + ), + focus_objects=[], + anchor_kind=anchor_kind or None, + continuation_kind=meta_followup_kind or None, + clarification_state=clarification_code or None, + missing_mapping=uncertainties[0] if uncertainties else None, + ) + return packet.model_dump() + + +def build_analysis_packet( + *, + route_decision: Any = None, + project_operating_context: Any = None, + execution_recipe: Any = None, + fit_brief: Any = None, + external_recommendation: Any = None, + recommended_next_step: Any = None, + answer: Any = None, + evidence_pack: Any = None, + query_type: Any = None, + clarification_code: Any = None, + anchor_kind: Any = None, + meta_followup_kind: Any = None, + internal_context_packet: Any = None, + project_snapshot: Any = None, +) -> dict[str, Any]: + if not _env_bool(_ENV_ENABLE_ANALYSIS_PACKET_FAMILY, True): + return {} + + normalized_route = _safe_dict(route_decision) + normalized_project_context = _safe_dict(project_operating_context) + normalized_recipe = _safe_dict(execution_recipe) + normalized_fit_brief = _safe_dict(fit_brief) + normalized_external = _safe_dict(external_recommendation) + normalized_evidence_pack = _safe_dict(evidence_pack) + normalized_project_snapshot = _safe_dict(project_snapshot) + recommended_next = _normalize_text(recommended_next_step, max_len=180) + normalized_query_type = str(query_type or "").strip().lower() + normalized_clarification_code = str(clarification_code or "").strip().lower() + normalized_anchor_kind = str(anchor_kind or "").strip().lower() + normalized_followup_kind = str(meta_followup_kind or "").strip().lower() + project_subject = _project_subject( + project_snapshot=normalized_project_snapshot, + fit_brief=normalized_fit_brief, + project_operating_context=normalized_project_context, + ) + + if normalized_clarification_code or normalized_anchor_kind or normalized_followup_kind: + return _build_followup_explain_packet( + answer=_normalize_text(answer, max_len=220), + evidence_pack=normalized_evidence_pack, + clarification_code=normalized_clarification_code, + anchor_kind=normalized_anchor_kind, + meta_followup_kind=normalized_followup_kind, + ) + + effective_source_policy = _normalize_route_mode( + normalized_route.get("effective_source_policy") or normalized_route.get("route_mode") + ) + entity_catalog = _build_entity_catalog( + project_operating_context=normalized_project_context, + fit_brief=normalized_fit_brief, + external_recommendation=normalized_external, + project_snapshot=normalized_project_snapshot, + ) + + if effective_source_policy == "internal": + return _build_internal_explain_packet( + answer=_normalize_text(answer, max_len=220), + query_type=normalized_query_type, + evidence_pack=normalized_evidence_pack, + internal_context_packet=internal_context_packet, + entity_catalog=entity_catalog, + ) + + if effective_source_policy == "external" and bool(normalized_recipe.get("fit_brief_required")): + return _build_project_fit_packet( + fit_brief=normalized_fit_brief, + external_recommendation=normalized_external, + recommended_next_step=recommended_next, + entity_catalog=entity_catalog, + project_subject=project_subject, + project_snapshot=normalized_project_snapshot, + ) + + if effective_source_policy == "external" and normalized_external: + return _build_external_impact_packet( + external_recommendation=normalized_external, + recommended_next_step=recommended_next, + entity_catalog=entity_catalog, + project_subject=project_subject, + project_snapshot=normalized_project_snapshot, + ) + + return {} + + +__all__ = ["build_analysis_packet", "normalize_analysis_packet_payload"] diff --git a/backend/app/services/analysis_project_snapshot_loader.py b/backend/app/services/analysis_project_snapshot_loader.py new file mode 100644 index 00000000..4e39f62d --- /dev/null +++ b/backend/app/services/analysis_project_snapshot_loader.py @@ -0,0 +1,130 @@ +"""Shared loader for richer analysis project snapshots.""" + +from __future__ import annotations + +from typing import Any + +from sqlalchemy import and_, select + +from app.models.project import Project +from app.models.signal import Signal +from app.models.task import Task + +def _normalize_text(value: Any, *, max_len: int = 240) -> str: + text = " ".join(str(value or "").split()).strip() + if not text: + return "" + return text[:max_len] +def _object_ref(*, kind: str, row: dict[str, Any] | None) -> dict[str, Any] | None: + payload = dict(row or {}) + title = _normalize_text(payload.get("title"), max_len=120) + if not title: + return None + ref: dict[str, Any] = { + "kind": kind, + "title": title, + } + try: + object_id = int(payload.get("id")) if payload.get("id") is not None else None + except Exception: + object_id = None + if object_id is not None: + ref["id"] = object_id + return ref + + +def _build_analysis_object_refs( + *, + tasks: list[dict[str, str]], + signals: list[dict[str, str]], +) -> dict[str, Any]: + _ = (tasks, signals) + # Fail closed: without explicit structural task-signal relations in the data model, + # do not synthesize business object refs from task/signal wording. + return {} + + +async def load_analysis_project_snapshot( + *, + db: Any, + tenant_id: int | None, + project_id: int | None, + task_limit: int = 4, + signal_limit: int = 6, +) -> dict[str, Any]: + if project_id is None or db is None or not hasattr(db, "execute"): + return {} + + project_filters = [Project.id == int(project_id)] + task_filters = [Task.project_id == int(project_id)] + signal_filters = [Signal.matched_project_id == int(project_id)] + if tenant_id is not None: + project_filters.append(Project.tenant_id == int(tenant_id)) + task_filters.append(Task.tenant_id == int(tenant_id)) + signal_filters.append(Signal.tenant_id == int(tenant_id)) + + project_row = ( + await db.execute( + select(Project).where(and_(*project_filters)).limit(1) + ) + ).scalar_one_or_none() + if project_row is None: + return {} + + task_rows = ( + await db.execute( + select(Task) + .where(and_(*task_filters)) + .order_by(Task.id.desc()) + .limit(int(task_limit)) + ) + ).scalars().all() + signal_rows = ( + await db.execute( + select(Signal) + .where(and_(*signal_filters)) + .order_by(Signal.id.desc()) + .limit(int(signal_limit)) + ) + ).scalars().all() + + tasks = [ + { + "id": getattr(row, "id", None), + "title": _normalize_text(getattr(row, "title", ""), max_len=120), + "description": _normalize_text(getattr(row, "description", ""), max_len=220), + "status": _normalize_text(getattr(row, "status", ""), max_len=40), + } + for row in task_rows + if _normalize_text(getattr(row, "title", ""), max_len=120) + ] + signals = [ + { + "id": getattr(row, "id", None), + "title": _normalize_text(getattr(row, "title", ""), max_len=120), + "summary": _normalize_text(getattr(row, "summary", ""), max_len=220), + "reasoning": _normalize_text(getattr(row, "reasoning", ""), max_len=220), + "status": _normalize_text(getattr(row, "status", ""), max_len=40), + "source": _normalize_text(getattr(row, "source", ""), max_len=120), + } + for row in signal_rows + if _normalize_text(getattr(row, "title", ""), max_len=120) + ] + + analysis_object_refs = _build_analysis_object_refs(tasks=tasks, signals=signals) + + return { + "project_name": _normalize_text( + getattr(project_row, "title", None) or getattr(project_row, "name", ""), + max_len=120, + ), + "project_description": _normalize_text(getattr(project_row, "description", ""), max_len=220), + "task_titles": [item["title"] for item in tasks], + "signal_titles": [item["title"] for item in signals], + "tasks": tasks, + "signals": signals, + "analysis_object_refs": analysis_object_refs, + } + + +__all__ = ["load_analysis_project_snapshot"] diff --git a/backend/app/services/external_query_service/planner.py b/backend/app/services/external_query_service/planner.py index 2f2e0525..8c1de304 100644 --- a/backend/app/services/external_query_service/planner.py +++ b/backend/app/services/external_query_service/planner.py @@ -73,7 +73,16 @@ class ExternalQueryPlanner: continue merged[key] = value plan = ExternalQueryPlan.model_validate(merged) - if not plan.search_queries: + recommendation_queries = self._build_signal_source_recommendation_queries( + question=question, + fit_brief=normalized_fit_brief, + ) + if recommendation_queries: + plan.search_queries = self._prepend_priority_queries( + recommendation_queries, + list(plan.search_queries or []), + ) + elif not plan.search_queries: plan.search_queries = list(fallback_plan.search_queries) if not plan.preferred_domains: plan.preferred_domains = list(fallback_plan.preferred_domains) @@ -102,6 +111,7 @@ class ExternalQueryPlanner: 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 {} + interaction_level = self._resolve_interaction_level(semantics) normalized_fit_brief = self._normalize_fit_brief(fit_brief) object_mentions = [ str(item or "").strip() @@ -135,6 +145,7 @@ class ExternalQueryPlanner: return ExternalQueryPlan( plan_version="v1", query_kind="generic_web" if text else "unknown", + interaction_level=interaction_level, answer_contract=answer_contract, freshness_required=bool(query_profile.get("needs_freshness")), topic=topic, @@ -151,8 +162,10 @@ class ExternalQueryPlanner: recommendation_goal=self._build_recommendation_goal( question=text, fit_brief=normalized_fit_brief, + interaction_level=interaction_level, ), project_object_anchors=project_object_anchors, + signal_source_objectives=self._collect_signal_source_objectives(normalized_fit_brief), ) def _build_search_queries( @@ -161,16 +174,23 @@ class ExternalQueryPlanner: *, fit_brief: dict[str, Any] | None = None, ) -> list[str]: + recommendation_queries = self._build_signal_source_recommendation_queries( + question=question, + fit_brief=fit_brief, + ) 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 self._merge_fit_brief_queries(normalized, fit_brief=fit_brief) + return self._merge_fit_brief_queries( + self._prepend_priority_queries(recommendation_queries, normalized), + fit_brief=fit_brief, + ) except Exception: pass text = re.sub(r"\s+", " ", str(question or "")).strip() - queries = [text] if text else [] + queries = self._prepend_priority_queries(recommendation_queries, [text] if text else []) return self._merge_fit_brief_queries(queries, fit_brief=fit_brief) @staticmethod @@ -216,26 +236,62 @@ class ExternalQueryPlanner: for item in list(brief.get("current_capabilities") or []) if str(item or "").strip() ] + objectives = [ + str(item or "").strip() + for item in list(brief.get("signal_source_objectives") 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])}") + if objectives: + parts.append(f"任务目标:{';'.join(objectives[:2])}") 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 pool_name in ("signal_source_anchors", "signal_source_objectives", "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: + @staticmethod + def _collect_signal_source_objectives(fit_brief: dict[str, Any] | None) -> list[str]: + brief = fit_brief if isinstance(fit_brief, dict) else {} + objectives: list[str] = [] + for item in list(brief.get("signal_source_objectives") or []): + normalized = str(item or "").strip() + if normalized and normalized not in objectives: + objectives.append(normalized) + return objectives[:4] + + @staticmethod + def _resolve_interaction_level(query_semantics: dict[str, Any] | None) -> str | None: + semantics = query_semantics if isinstance(query_semantics, dict) else {} + query_profile = semantics.get("query_profile") if isinstance(semantics.get("query_profile"), dict) else {} + for raw in ( + semantics.get("interaction_level"), + query_profile.get("interaction_level"), + ): + normalized = str(raw or "").strip().lower() + if normalized in {"status", "recommendation", "draft", "execute", "clarify"}: + return normalized + return None + + def _build_recommendation_goal( + self, + *, + question: str, + fit_brief: dict[str, Any] | None, + interaction_level: str | None, + ) -> str | None: focus = [ str(item or "").strip() for item in list((fit_brief or {}).get("recommendation_focus") or []) @@ -243,11 +299,99 @@ class ExternalQueryPlanner: ] if focus: return ";".join(focus[:3])[:240] + if str(interaction_level or "").strip().lower() != "recommendation": + return None text = str(question or "").strip() if not text: return None return f"判断“{text[:80]}”是否适合当前项目接入或跟进。" + @staticmethod + def _prepend_priority_queries(priority: list[str], existing: list[str]) -> list[str]: + merged: list[str] = [] + for item in list(priority or []) + list(existing or []): + normalized = str(item or "").strip() + if normalized and normalized not in merged: + merged.append(normalized) + return merged[:5] + + @staticmethod + def _extract_project_label(question: str, fit_brief: dict[str, Any] | None) -> str | None: + text = re.sub(r"\s+", " ", str(question or "")).strip() + match = re.search(r"([\u4e00-\u9fffA-Za-z0-9_-]{2,24})这个项目", text) + if match: + candidate = str(match.group(1) or "").strip("“”\"' ,,。") + if candidate: + return candidate[:24] + summary = str((fit_brief or {}).get("project_summary") or "").strip() + if not summary: + return None + summary = re.sub(r"^当前项目:", "", summary).strip() + summary = re.sub(r"^#\d+\s*", "", summary).strip() + summary = re.split(r"\s*\(|(|status=", summary)[0].strip() + for delimiter in (",", ",", "。", ";", ";", ":", ":"): + if delimiter in summary: + summary = str(summary.split(delimiter, 1)[0] or "").strip() + break + return summary[:24] or None + + @staticmethod + def _build_signal_source_recommendation_queries( + *, + question: str, + fit_brief: dict[str, Any] | None = None, + ) -> list[str]: + combined_text = " ".join( + item + for item in [ + str(question or "").strip(), + str((fit_brief or {}).get("project_summary") or "").strip(), + " ".join(str(x or "").strip() for x in list((fit_brief or {}).get("recommendation_focus") or [])), + ] + if item + ) + if "信号源" not in combined_text: + return [] + + project_label = ExternalQueryPlanner._extract_project_label(question, fit_brief) + objectives = ExternalQueryPlanner._collect_signal_source_objectives(fit_brief) + queries: list[str] = [] + if objectives: + queries.append( + " ".join( + item + for item in [project_label or "", "外部信号源", *objectives[:2]] + if item + ) + ) + queries.append( + " ".join( + item + for item in [ + project_label or "", + "外部信号源", + str(question or "").strip()[:48], + objectives[0], + ] + if item + ) + ) + else: + queries.append( + " ".join( + item + for item in [project_label or "", "外部信号源", str(question or "").strip()[:80]] + if item + ) + ) + + normalized: list[str] = [] + for item in queries: + candidate = str(item or "").strip() + if candidate and candidate not in normalized: + normalized.append(candidate) + return normalized[:3] + def _merge_fit_brief_queries( self, queries: list[str], diff --git a/backend/app/services/external_query_service/presenter.py b/backend/app/services/external_query_service/presenter.py index c4a3f18e..66f246f6 100644 --- a/backend/app/services/external_query_service/presenter.py +++ b/backend/app/services/external_query_service/presenter.py @@ -1,14 +1,22 @@ from __future__ import annotations +import re +from uuid import uuid4 from typing import Any from ...config.ai_prompts import ( PROMPT_EXTERNAL_COMPARE_WITH_SOURCES_QA, PROMPT_EXTERNAL_DIRECT_FACT_QA, + PROMPT_EXTERNAL_SIGNAL_SOURCE_RECOMMENDATION_PACKET, + PROMPT_EXTERNAL_SIGNAL_SOURCE_RECOMMENDATION_QA, PROMPT_EXTERNAL_SUMMARY_WITH_SOURCES_QA, ) from ..prompt_release import PromptReleaseService -from .schemas import ExternalContextPacket +from .schemas import ( + ExternalContextPacket, + SignalSourceRecommendationCandidate, + SignalSourceRecommendationPacket, +) class ExternalAnswerPresenter: @@ -88,8 +96,375 @@ class ExternalAnswerPresenter: rows.append(f"地点: {plan.location_hint}") if plan.subject_entities: rows.append(f"对象: {'、'.join([str(item).strip() for item in plan.subject_entities[:5] if str(item).strip()])}") + if plan.signal_source_objectives: + rows.append( + "任务目标: " + + ";".join([str(item).strip() for item in plan.signal_source_objectives[:3] if str(item).strip()]) + ) return "\n".join(rows) + @classmethod + def _is_signal_source_recommendation_request( + cls, + *, + packet: ExternalContextPacket, + ) -> bool: + interaction_level = str(packet.plan.interaction_level or "").strip().lower() + if interaction_level and interaction_level != "recommendation": + return False + if interaction_level != "recommendation": + return False + recommendation_goal = str(packet.plan.recommendation_goal or "").strip() + return bool(recommendation_goal or packet.plan.project_object_anchors or packet.plan.signal_source_objectives) + + @staticmethod + def _build_recommendation_uncertainty_text(packet: ExternalContextPacket) -> str: + retrieval_status = str(packet.retrieval_status or "").strip().lower() + if retrieval_status == "verifier_blocked": + return "当前外部公开资料还在校验,这里先给推荐,不把它当成已验证事实。" + if retrieval_status == "no_relevant_results": + return "当前外部公开资料还没找到直接证据,这里先按项目目标给推荐。" + if retrieval_status == "retrieval_failed": + return "这次外部检索失败,这里先按项目目标给推荐,后面还需要补核验。" + return "" + + @staticmethod + def _collect_signal_source_objectives(packet: ExternalContextPacket) -> list[str]: + objectives: list[str] = [] + for item in list(packet.plan.signal_source_objectives or []): + normalized = str(item or "").strip() + if normalized and normalized not in objectives: + objectives.append(normalized) + if objectives: + return objectives[:4] + for item in list(packet.plan.project_object_anchors or []): + normalized = str(item or "").strip() + if normalized and normalized not in objectives: + objectives.append(normalized) + return objectives[:4] + + @staticmethod + def _objective_label(value: Any) -> str: + text = str(value or "").strip() + if not text: + return "" + for separator in (":", ":"): + if separator in text: + return str(text.split(separator, 1)[0] or "").strip() or text + return text + + @classmethod + def _extract_objective_parts(cls, value: Any) -> tuple[str, str]: + text = str(value or "").strip() + if not text: + return "", "" + for separator in (":", ":"): + if separator in text: + label, detail = text.split(separator, 1) + return str(label or "").strip(), str(detail or "").strip() + return text, "" + + @staticmethod + def _sanitize_recommendation_candidate_text(value: Any) -> str: + text = re.sub(r"\s+", " ", str(value or "")).strip(" ,,。;;::") + text = re.sub(r"^(?:当前|建议|推荐)\s*", "", text) + return text[:36] + + @classmethod + def _objective_candidate_name(cls, *, label: str, detail: str) -> str: + candidate = detail or label + candidate = re.sub(r"^(?:编写脚本|收集|跟踪|监控|处理|实现|支持|自动|实时|用于|把|对|完成|生成并推送)\s*", "", candidate) + candidate = re.sub(r"^(?:收到|基于|围绕|用于)\s*", "", candidate) + candidate = re.sub(r"(?:后自动|并在.+)$", "", candidate).strip() + candidate = cls._sanitize_recommendation_candidate_text(candidate) + if candidate and candidate not in {"数据与阈值", "结果发给客户", "话题波动"}: + return candidate + return cls._sanitize_recommendation_candidate_text(label) or "外部变化线索" + + @classmethod + def _objective_candidate_reason(cls, *, label: str, detail: str) -> str: + detail_text = cls._sanitize_recommendation_candidate_text(detail) + label_text = cls._sanitize_recommendation_candidate_text(label) + if detail_text and label_text: + return f"它最早支撑“{label_text}”,也更容易先看出这件事有没有开始偏。" + if label_text: + return f"它能更早暴露“{label_text}”是不是开始出问题。" + return "它更容易先把问题从项目现状里提前暴露出来。" + + @classmethod + def _build_signal_source_recommendation_fallback_packet( + cls, + *, + packet: ExternalContextPacket, + ) -> SignalSourceRecommendationPacket: + candidates: list[SignalSourceRecommendationCandidate] = [] + for raw in cls._collect_signal_source_objectives(packet)[:3]: + label, detail = cls._extract_objective_parts(raw) + name = cls._objective_candidate_name(label=label, detail=detail) + reason = cls._objective_candidate_reason(label=label, detail=detail) + if name and all(existing.name != name for existing in candidates): + candidates.append( + SignalSourceRecommendationCandidate( + candidate_ref=f"cand_{uuid4().hex[:12]}", + name=name, + display_name=name, + reason=reason, + business_value=reason, + why_now=reason, + source_origin="recommendation", + materialization_payload={ + "display_name": name, + "source_origin": "recommendation", + }, + ) + ) + fallback_seed = [ + ("最近异常反馈", "它能看出问题是不是已经开始影响到真实结果,而不是只停在内部波动。"), + ("上游变化记录", "它更容易解释这次波动到底是内部原因还是外部变化带出来的。"), + ("结果失败回执", "它能最早把真正失败的位置暴露出来,避免只看到表面症状。"), + ] + for name, reason in fallback_seed: + if len(candidates) >= 3: + break + if all(existing.name != name for existing in candidates): + candidates.append( + SignalSourceRecommendationCandidate( + candidate_ref=f"cand_{uuid4().hex[:12]}", + name=name, + display_name=name, + reason=reason, + business_value=reason, + why_now=reason, + source_origin="recommendation", + materialization_payload={ + "display_name": name, + "source_origin": "recommendation", + }, + ) + ) + return SignalSourceRecommendationPacket( + candidate_set_id=f"recset_{uuid4().hex[:12]}", + resource_role="signal_source", + source_kind="external", + evidence_mode="verified" if str(packet.retrieval_status or "").strip().lower() == "evidence_found" else "advisory", + candidates=candidates[:3], + caveat=cls._build_recommendation_uncertainty_text(packet) + or "这些先算建议,不代表已经接入或验证完成。", + ) + + @staticmethod + def _ensure_recommendation_packet_defaults( + packet: SignalSourceRecommendationPacket, + *, + fallback_packet: SignalSourceRecommendationPacket, + ) -> SignalSourceRecommendationPacket: + if not str(packet.candidate_set_id or "").strip(): + packet.candidate_set_id = str(fallback_packet.candidate_set_id or "").strip() or f"recset_{uuid4().hex[:12]}" + packet.resource_role = "signal_source" + packet.source_kind = str(packet.source_kind or fallback_packet.source_kind or "external").strip() or "external" + packet.evidence_mode = ( + str(packet.evidence_mode or fallback_packet.evidence_mode or "advisory").strip() or "advisory" + ) + for item in list(packet.candidates or []): + if not str(item.candidate_ref or "").strip(): + item.candidate_ref = f"cand_{uuid4().hex[:12]}" + payload = dict(item.materialization_payload or {}) + payload.setdefault("display_name", str(item.display_name or item.name or "").strip()) + payload.setdefault("candidate_ref", str(item.candidate_ref or "").strip()) + payload.setdefault("source_origin", str(item.source_origin or "recommendation").strip() or "recommendation") + item.materialization_payload = payload + return packet + + @classmethod + def _recommendation_packet_has_risky_or_weak_content( + cls, + *, + recommendation_packet: SignalSourceRecommendationPacket, + packet: ExternalContextPacket, + ) -> bool: + if len(recommendation_packet.candidates) < 2: + return True + risky_markers = ( + "%", + "已确认", + "无需额外对接验证", + "相关性达", + "普遍可采集", + "近一年", + "今年", + "本月", + "这一层", + "主链", + "packet", + "route", + "verifier", + ) + for item in list(recommendation_packet.candidates or []): + text = f"{str(item.name or '')} {str(item.reason or '')}".strip() + if not text: + return True + if any(marker in text for marker in risky_markers): + return True + if str(packet.impact_status or "").strip().lower() == "direct_match": + return False + return False + + @classmethod + def _merge_recommendation_packets( + cls, + *, + primary: SignalSourceRecommendationPacket, + fallback: SignalSourceRecommendationPacket, + ) -> SignalSourceRecommendationPacket: + merged: list[SignalSourceRecommendationCandidate] = [] + for item in list(primary.candidates or []) + list(fallback.candidates or []): + name = cls._sanitize_recommendation_candidate_text(item.display_name or item.name) + reason = str(item.reason or item.business_value or item.why_now or "").strip() + if not name or not reason: + continue + if any(existing.display_name == name for existing in merged): + continue + merged.append( + SignalSourceRecommendationCandidate( + candidate_ref=str(item.candidate_ref or "").strip() or f"cand_{uuid4().hex[:12]}", + name=name, + display_name=name, + reason=reason, + business_value=str(item.business_value or reason).strip(), + why_now=str(item.why_now or reason).strip(), + source_origin=str(item.source_origin or "recommendation").strip() or "recommendation", + materialization_payload=dict(item.materialization_payload or {}), + ) + ) + caveat = str(primary.caveat or "").strip() or str(fallback.caveat or "").strip() + packet = SignalSourceRecommendationPacket( + candidate_set_id=str(primary.candidate_set_id or fallback.candidate_set_id or "").strip() or f"recset_{uuid4().hex[:12]}", + project_binding=primary.project_binding or fallback.project_binding, + resource_role="signal_source", + source_kind=str(primary.source_kind or fallback.source_kind or "external").strip() or "external", + evidence_mode=str(primary.evidence_mode or fallback.evidence_mode or "advisory").strip() or "advisory", + candidates=merged[:3], + caveat=caveat, + ) + return cls._ensure_recommendation_packet_defaults(packet, fallback_packet=fallback) + + @classmethod + def _render_signal_source_recommendation_text( + cls, + *, + recommendation_packet: SignalSourceRecommendationPacket, + packet: ExternalContextPacket, + ) -> str: + candidates = list(recommendation_packet.candidates or [])[:3] + if not candidates: + recommendation_packet = cls._build_signal_source_recommendation_fallback_packet(packet=packet) + candidates = list(recommendation_packet.candidates or [])[:3] + lines = ["如果只是先给建议,我会先看这 3 类外部信号:"] + for index, item in enumerate(candidates, start=1): + name = cls._sanitize_recommendation_candidate_text(item.name) + reason = str(item.reason or "").strip().rstrip("。") + if reason.startswith("因为"): + lines.append(f"{index}. {name},{reason}") + else: + lines.append(f"{index}. {name},因为{reason}") + caveat = str(recommendation_packet.caveat or "").strip() or cls._build_recommendation_uncertainty_text(packet) + if caveat: + lines.append(caveat.rstrip("。") + "。") + return "\n".join(lines) + + async def _build_signal_source_recommendation_packet( + self, + *, + question: str, + packet: ExternalContextPacket, + ) -> SignalSourceRecommendationPacket: + fallback_packet = self._build_signal_source_recommendation_fallback_packet(packet=packet) + generator = getattr(self.llm_service, "generate_structured_object", None) + if not callable(generator): + return fallback_packet + + plan_summary = self._build_plan_summary(packet) + fit_brief_summary = str(packet.plan.fit_brief_summary or "").strip() or "未提供" + recommendation_goal = str(packet.plan.recommendation_goal or "").strip() or "给当前项目推荐最值得先关注的外部信号源。" + project_object_anchors = "、".join( + [str(item).strip() for item in list(packet.plan.project_object_anchors or []) if str(item).strip()][:4] + ) or "无" + signal_source_objectives = ";".join( + [str(item).strip() for item in list(packet.plan.signal_source_objectives or []) if str(item).strip()][:4] + ) or "无" + prompt = await self._render_prompt( + prompt_key="PROMPT_EXTERNAL_SIGNAL_SOURCE_RECOMMENDATION_PACKET", + fallback_template=PROMPT_EXTERNAL_SIGNAL_SOURCE_RECOMMENDATION_PACKET, + question=str(question or "").strip(), + plan_summary=plan_summary, + fit_brief_summary=fit_brief_summary, + recommendation_goal=recommendation_goal, + project_object_anchors=project_object_anchors, + signal_source_objectives=signal_source_objectives, + retrieval_status=str(packet.retrieval_status or "").strip() or "unknown", + uncertainties=";".join([str(item).strip() for item in list(packet.uncertainties or []) if str(item).strip()]) or "无", + source_summaries=self._build_source_summaries(packet), + ) + try: + structured = await generator( + prompt=prompt, + db=self.db, + tenant_id=self.tenant_id, + function_name="external_signal_source_recommendation_packet", + use_prompt_profile=False, + temperature=0.1, + response_mode="fast", + routing_meta={ + "tier_alias": "low", + "response_contract": "structured", + "fallback_policy": "fast_local", + "execution_mode": "query", + }, + ) + payload = getattr(structured, "payload", None) + if payload is None and hasattr(structured, "model_dump"): + payload = structured.model_dump() + recommendation_packet = SignalSourceRecommendationPacket.model_validate(payload or {}) + recommendation_packet = self._merge_recommendation_packets( + primary=recommendation_packet, + fallback=fallback_packet, + ) + recommendation_packet = self._ensure_recommendation_packet_defaults( + recommendation_packet, + fallback_packet=fallback_packet, + ) + if self._recommendation_packet_has_risky_or_weak_content( + recommendation_packet=recommendation_packet, + packet=packet, + ): + return fallback_packet + return recommendation_packet + except Exception: + return fallback_packet + + async def _build_signal_source_recommendation_response( + self, + *, + question: str, + packet: ExternalContextPacket, + thinking_mode: str, + answer_max_tokens: int | None = None, + ) -> dict[str, str]: + _ = (thinking_mode, answer_max_tokens) + recommendation_packet = await self._build_signal_source_recommendation_packet( + question=question, + packet=packet, + ) + text = self._render_signal_source_recommendation_text( + recommendation_packet=recommendation_packet, + packet=packet, + ) + return { + "answer_text": text, + "prompt_variant_id": "external_signal_source_recommendation", + "recommendation_packet": recommendation_packet.model_dump(), + } + @staticmethod def _build_failure_response(*, question: str, retrieval_status: str) -> dict[str, str]: short_question = str(question or "").strip()[:80] @@ -159,6 +534,15 @@ class ExternalAnswerPresenter: if isinstance(context_packet, ExternalContextPacket) else ExternalContextPacket.model_validate(context_packet) ) + if self._is_signal_source_recommendation_request( + packet=packet, + ): + return await self._build_signal_source_recommendation_response( + question=question, + packet=packet, + thinking_mode=thinking_mode, + answer_max_tokens=answer_max_tokens, + ) 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": diff --git a/backend/app/services/external_query_service/schemas.py b/backend/app/services/external_query_service/schemas.py index 15da5d41..c8cdfdac 100644 --- a/backend/app/services/external_query_service/schemas.py +++ b/backend/app/services/external_query_service/schemas.py @@ -2,13 +2,14 @@ from __future__ import annotations from typing import Any, Literal -from pydantic import BaseModel, ConfigDict, Field +from pydantic import BaseModel, ConfigDict, Field, model_validator 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"] +RecommendationEvidenceMode = Literal["advisory", "verified", "needs_clarify"] class ExternalQueryPlan(BaseModel): @@ -16,6 +17,7 @@ class ExternalQueryPlan(BaseModel): plan_version: str = "v1" query_kind: ExternalQueryKind = "unknown" + interaction_level: Literal["status", "recommendation", "draft", "execute", "clarify"] | None = None answer_contract: ExternalAnswerContract = "summary_with_sources" freshness_required: bool = False topic: str = "" @@ -31,6 +33,7 @@ class ExternalQueryPlan(BaseModel): project_scope_hint: str | None = None recommendation_goal: str | None = None project_object_anchors: list[str] = Field(default_factory=list) + signal_source_objectives: list[str] = Field(default_factory=list) class ExternalEvidenceItem(BaseModel): @@ -126,3 +129,60 @@ class ExternalContextPacket(BaseModel): retrieval_status: ExternalRetrievalStatus = "retrieval_failed" impact_status: ExternalImpactStatus = "no_direct_impact" affected_signal_sources: list[str] = Field(default_factory=list) + + +class SignalSourceRecommendationCandidate(BaseModel): + model_config = ConfigDict(extra="ignore") + + candidate_ref: str | None = None + name: str = "" + display_name: str = "" + reason: str = "" + business_value: str = "" + why_now: str = "" + source_origin: str = "recommendation" + materialization_payload: dict[str, Any] = Field(default_factory=dict) + + @model_validator(mode="after") + def _normalize_fields(self) -> "SignalSourceRecommendationCandidate": + display_name = str(self.display_name or self.name).strip() + name = str(self.name or display_name).strip() + reason = str(self.reason or self.business_value or self.why_now).strip() + business_value = str(self.business_value or reason or self.why_now).strip() + why_now = str(self.why_now or reason or business_value).strip() + payload = dict(self.materialization_payload or {}) + if display_name and not payload.get("display_name"): + payload["display_name"] = display_name + if str(self.source_origin or "").strip() and not payload.get("source_origin"): + payload["source_origin"] = str(self.source_origin or "").strip() + if self.candidate_ref and not payload.get("candidate_ref"): + payload["candidate_ref"] = str(self.candidate_ref) + + self.display_name = display_name + self.name = name or display_name + self.reason = reason + self.business_value = business_value + self.why_now = why_now + self.materialization_payload = payload + return self + + +class SignalSourceRecommendationPacket(BaseModel): + model_config = ConfigDict(extra="ignore") + + candidate_set_id: str = "" + project_binding: int | None = None + resource_role: Literal["signal_source"] = "signal_source" + source_kind: Literal["external", "hybrid"] = "external" + evidence_mode: RecommendationEvidenceMode = "advisory" + candidates: list[SignalSourceRecommendationCandidate] = Field(default_factory=list) + caveat: str = "" + + +def normalize_signal_source_recommendation_payload(value: Any) -> dict[str, Any]: + if not isinstance(value, dict) or not value: + return {} + try: + return SignalSourceRecommendationPacket.model_validate(value).model_dump() + except Exception: + return {} 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 7224ba51..c0e1b11e 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 @@ -1,8 +1,23 @@ from __future__ import annotations +import os + from .shared import * +from ...analysis_packet_builder import build_analysis_packet, normalize_analysis_packet_payload +from ...analysis_project_snapshot_loader import load_analysis_project_snapshot class FeishuReceiveRoutingMixin: + _ENV_ENABLE_ANALYSIS_PACKET_FAMILY = "PMFLOW_ENABLE_ANALYSIS_PACKET_FAMILY" + _FEISHU_QA_ROUTE_PREFIXES = ("内部知识库查询:", "外部实时查询:") + _FEISHU_QA_RAW_INTERNAL_TERMS = ( + "Project database", + "signal_total", + "pending_signal_count", + "当前盘面", + "建议动作", + "External Freshness Guard", + "current_subject", + ) _FEISHU_QA_BANNED_OPENERS = ( "先说我的判断", "我先说结论", @@ -16,6 +31,19 @@ class FeishuReceiveRoutingMixin: "下一步:", "如果你要,我可以", ) + _FEISHU_QA_INTERNAL_PACKET_JARGON = ( + "分析包", + "analysis_packet", + "missing_mapping", + "next_step", + "focus_objects", + "reason_chain", + "candidate_position", + "preferred_layer", + "rejected_layer", + "current_subject", + "缺字段", + ) @staticmethod def _normalize_feishu_qa_summary_text( @@ -57,6 +85,93 @@ class FeishuReceiveRoutingMixin: def _trim_feishu_qa_clause(value: Any) -> str: return str(value or "").strip().rstrip("。;;,, ") + @classmethod + def _strip_feishu_qa_route_prefix(cls, value: Any) -> str: + normalized = str(value or "").strip() + for prefix in cls._FEISHU_QA_ROUTE_PREFIXES: + if normalized.startswith(prefix): + return normalized[len(prefix):].strip() + return normalized + + @classmethod + def _sanitize_feishu_direct_reply_body(cls, value: Any) -> str: + text = cls._strip_feishu_qa_route_prefix(value) + if not text: + return "" + kept_blocks: list[str] = [] + for block in re.split(r"\n\s*\n", text): + normalized = str(block or "").strip() + if not normalized: + continue + if any(token in normalized for token in cls._FEISHU_QA_RAW_INTERNAL_TERMS): + continue + kept_blocks.append(normalized) + return "\n\n".join(kept_blocks).strip() + + @classmethod + def _contains_feishu_internal_state_markers(cls, value: Any) -> bool: + text = str(value or "").strip() + if not text: + return False + return any(token in text for token in cls._FEISHU_QA_RAW_INTERNAL_TERMS) + + @classmethod + def _should_prefer_feishu_direct_recommendation_reply( + cls, + *, + result: Dict[str, Any], + ) -> bool: + rag_result = result.get("rag_result") if isinstance(result.get("rag_result"), dict) else {} + route_decision = rag_result.get("route_decision") if isinstance(rag_result.get("route_decision"), dict) else {} + route_mode = str(route_decision.get("route_mode") or "").strip().lower() + if route_mode != "external": + return False + external_query_plan = ( + rag_result.get("external_query_plan") + if isinstance(rag_result.get("external_query_plan"), dict) + else {} + ) + query_semantics = ( + rag_result.get("query_semantics") + if isinstance(rag_result.get("query_semantics"), dict) + else {} + ) + query_profile = ( + query_semantics.get("query_profile") + if isinstance(query_semantics.get("query_profile"), dict) + else {} + ) + interaction_level = str( + external_query_plan.get("interaction_level") + or query_semantics.get("interaction_level") + or query_profile.get("interaction_level") + or "" + ).strip().lower() + return interaction_level == "recommendation" + + @classmethod + def _build_feishu_source_explanation_line(cls, result: Dict[str, Any]) -> str: + rag_result = result.get("rag_result") if isinstance(result.get("rag_result"), dict) else {} + route_decision = rag_result.get("route_decision") if isinstance(rag_result.get("route_decision"), dict) else {} + route_mode = str(route_decision.get("route_mode") or "").strip().lower() + external_recommendation = ( + rag_result.get("external_recommendation") + if isinstance(rag_result.get("external_recommendation"), dict) + else {} + ) + retrieval_status = str(external_recommendation.get("retrieval_status") or "").strip().lower() + if route_mode == "internal": + return "来源:项目库" + if route_mode == "external": + if retrieval_status == "verifier_blocked": + return "来源:项目库 + 外部公开资料(当前仍在校验)" + if retrieval_status == "no_relevant_results": + return "来源:项目库 + 外部公开资料(暂未找到直接证据)" + if retrieval_status == "retrieval_failed": + return "来源:项目库 + 外部公开资料(本次检索失败)" + return "来源:项目库 + 外部公开资料" + return "" + @classmethod def _build_feishu_fit_brief_summary_text(cls, fit_brief: Any) -> str: payload = fit_brief if isinstance(fit_brief, dict) else {} @@ -198,6 +313,14 @@ class FeishuReceiveRoutingMixin: return catalog.append({"name": normalized, "kind": kind}) + structured_tasks = project_snapshot.get("tasks") if isinstance(project_snapshot.get("tasks"), list) else [] + structured_signals = project_snapshot.get("signals") if isinstance(project_snapshot.get("signals"), list) else [] + for item in structured_tasks[:4]: + if isinstance(item, dict): + _append_entity(item.get("title") or item.get("name"), "task") + for item in structured_signals[:4]: + if isinstance(item, dict): + _append_entity(item.get("title") or item.get("name"), "signal") 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]: @@ -305,6 +428,9 @@ class FeishuReceiveRoutingMixin: return False if any(token in text for token in cls._FEISHU_QA_BANNED_PHRASES): return False + lowered_text = text.lower() + if any(token.lower() in lowered_text for token in cls._FEISHU_QA_INTERNAL_PACKET_JARGON): + return False if re.search(r"(^|\n)\s*[-*•]\s", text): return False normalized_text = re.sub(r"\s+", "", text) @@ -326,31 +452,11 @@ class FeishuReceiveRoutingMixin: 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()], - } + return await load_analysis_project_snapshot( + db=getattr(self, "db", None), + tenant_id=getattr(self, "tenant_id", None), + project_id=project_id, + ) @classmethod def _build_feishu_project_summary_text( @@ -389,8 +495,20 @@ class FeishuReceiveRoutingMixin: @classmethod def _collect_feishu_project_entity_candidates(cls, project_snapshot: dict[str, Any]) -> list[str]: candidates: list[str] = [] + structured_tasks = project_snapshot.get("tasks") if isinstance(project_snapshot.get("tasks"), list) else [] + structured_signals = project_snapshot.get("signals") if isinstance(project_snapshot.get("signals"), list) else [] for value in [ project_snapshot.get("project_name"), + *[ + item.get("title") or item.get("name") + for item in structured_tasks[:4] + if isinstance(item, dict) + ], + *[ + item.get("title") or item.get("name") + for item in structured_signals[:4] + if isinstance(item, dict) + ], *(list(project_snapshot.get("task_titles") or [])[:4]), *(list(project_snapshot.get("signal_titles") or [])[:4]), ]: @@ -400,12 +518,213 @@ class FeishuReceiveRoutingMixin: return candidates @classmethod - def _pick_feishu_entity_by_keywords( + def _feishu_analysis_packet_family_enabled(cls) -> bool: + raw = str(os.getenv(cls._ENV_ENABLE_ANALYSIS_PACKET_FAMILY, "") or "").strip().lower() + if not raw: + return True + if raw in {"1", "true", "yes", "on"}: + return True + if raw in {"0", "false", "no", "off"}: + return False + return True + + @staticmethod + def _normalize_feishu_analysis_packet(payload: Any) -> dict[str, Any]: + return normalize_analysis_packet_payload(payload) + + @classmethod + def _build_feishu_qa_analysis_packet_reply_text( + cls, + analysis_packet: dict[str, Any], + ) -> str: + packet = cls._normalize_feishu_analysis_packet(analysis_packet) + if not packet: + return "" + conclusion = cls._normalize_feishu_qa_summary_text(packet.get("conclusion"), max_len=160) + next_step = cls._normalize_feishu_qa_summary_text(packet.get("next_step"), max_len=180) + packet_type = str(packet.get("packet_type") or "").strip().lower() + reason_chain = [ + cls._normalize_feishu_qa_summary_text(item, max_len=180) + for item in list(packet.get("reason_chain") or [])[:3] + if cls._normalize_feishu_qa_summary_text(item, max_len=180) + ] + focus_objects = packet.get("focus_objects") if isinstance(packet.get("focus_objects"), list) else [] + + normalized_focus_objects: list[dict[str, str]] = [] + for item in focus_objects[:2]: + if not isinstance(item, dict): + continue + name = cls._normalize_feishu_qa_summary_text(item.get("name"), max_len=80) + reason = cls._clean_feishu_focus_why(item.get("reason")) + stance = cls._normalize_feishu_focus_stance(item.get("stance")) + if not name or not reason: + continue + normalized_focus_objects.append({"name": name, "reason": reason, "stance": stance}) + + missing_mapping = cls._normalize_feishu_qa_summary_text(packet.get("missing_mapping"), max_len=220) + blocked_reason_or_gap = cls._normalize_feishu_qa_summary_text(packet.get("blocked_reason_or_gap"), max_len=220) + evidence_gap = cls._normalize_feishu_qa_summary_text(packet.get("evidence_gap_or_uncertainty"), max_len=180) + gap_text = missing_mapping or blocked_reason_or_gap or evidence_gap + current_subject = cls._normalize_feishu_qa_summary_text(packet.get("current_subject"), max_len=80) + + if packet_type == "project_fit_analysis" and not normalized_focus_objects and reason_chain: + lead = ( + f"{current_subject}这个项目,我现在还不想硬说它该放在哪一步。" + if current_subject + else (conclusion if conclusion.endswith("。") else f"{conclusion}。") + ) + first_reason = reason_chain[0].rstrip("。") + normalized_reason_sentences = [ + item if item.endswith("。") else f"{item}。" + for item in reason_chain + ] + if first_reason.startswith("我现在只看得到这个项目的大致目标") and len(normalized_reason_sentences) >= 2: + detail = "".join(normalized_reason_sentences) + elif "还没有足够具体的任务或信号源" in first_reason: + detail = ( + f"现在关于{current_subject or '这个项目'},能看到的信息还比较粗,项目里还没有能对上的具体业务步骤或外部信号。" + "要是现在硬把它挂到某个位置上,结论很容易说偏。" + ) + elif "实现层任务" in first_reason: + detail = ( + f"现在关于{current_subject or '这个项目'},拆出来的主要还是实现层任务," + "还看不出它会先影响哪个业务结果。" + ) + elif "信号源占位" in first_reason or "占位" in first_reason: + detail = ( + f"现在关于{current_subject or '这个项目'},看到的还是比较粗的信号源占位," + "还没落到真正的业务动作上。" + ) + else: + detail = first_reason if first_reason.endswith("。") else f"{first_reason}。" + tail = next_step or gap_text + sparse_paragraphs = [ + lead if lead.endswith("。") else f"{lead}。", + detail if detail.endswith("。") else f"{detail}。", + ] + if tail: + sparse_paragraphs.append(tail if tail.endswith("。") else f"{tail}。") + return "\n\n".join(sparse_paragraphs).strip() + + paragraphs: list[str] = [] + if conclusion: + paragraphs.append(conclusion if conclusion.endswith("。") else f"{conclusion}。") + + focus_paragraph = "" + if reason_chain: + normalized_reason_sentences = [ + item if item.endswith("。") else f"{item}。" + for item in reason_chain + ] + if packet_type in {"project_fit_analysis", "external_impact_analysis", "internal_explain_analysis"}: + paragraphs.append("".join(normalized_reason_sentences)) + else: + paragraphs.extend(normalized_reason_sentences) + elif packet_type == "project_fit_analysis" and len(normalized_focus_objects) >= 2: + first = normalized_focus_objects[0] + second = normalized_focus_objects[1] + if first["stance"] == "better_fit" and second["stance"] == "not_fit": + focus_paragraph = ( + f"如果真要放进这个项目,我会先放在{first['name']}这一步," + f"不太会直接改{second['name']}这条主流程。" + ) + elif first["stance"] == "anchor" and second["stance"] == "dependent": + focus_paragraph = f"先对上的,是{first['name']};离它最近的项目动作是{second['name']}。" + elif first["stance"] == "blocked" and second["stance"] == "dependent": + focus_paragraph = f"现在先卡在{first['name']};{second['name']}这一步也会直接受这条输入影响。" + elif packet_type == "external_impact_analysis" and len(normalized_focus_objects) >= 1: + first = normalized_focus_objects[0] + if first["stance"] == "blocked": + focus_paragraph = f"现在最该看的,是{first['name']},因为{first['reason']}。" + elif first["stance"] == "anchor": + focus_paragraph = f"这次先对上的对象,是{first['name']},因为{first['reason']}。" + elif packet_type == "internal_explain_analysis" and normalized_focus_objects: + first = normalized_focus_objects[0] + focus_paragraph = f"这次回答直接落在{first['name']}这个对象上,因为{first['reason']}。" + elif packet_type == "followup_explain_analysis": + clarification_state = cls._normalize_feishu_qa_summary_text(packet.get("clarification_state"), max_len=80) + if clarification_state: + focus_paragraph = f"这轮还在{clarification_state}这个澄清状态里。" + + if not focus_paragraph and normalized_focus_objects: + focus_paragraph = "".join( + f"{item['name']}相关,因为{item['reason']}。" + for item in normalized_focus_objects + ) + if focus_paragraph and not reason_chain: + paragraphs.append(focus_paragraph) + normalized_gap_key = re.sub(r"\s+", "", str(gap_text or "")).rstrip("。") + normalized_next_key = re.sub(r"\s+", "", str(next_step or "")).rstrip("。") + tail_sentences: list[str] = [] + if gap_text: + tail_sentences.append(gap_text if gap_text.endswith("。") else f"{gap_text}。") + if next_step and normalized_next_key != normalized_gap_key: + tail_sentences.append(next_step if next_step.endswith("。") else f"{next_step}。") + if tail_sentences: + paragraphs.append("".join(tail_sentences)) + return "\n\n".join(paragraphs).strip() + + async def _build_feishu_qa_packet_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_packet = self._normalize_feishu_analysis_packet( + build_analysis_packet( + route_decision=rag_result.get("route_decision"), + project_operating_context=rag_result.get("project_operating_context"), + execution_recipe=rag_result.get("execution_recipe"), + fit_brief=rag_result.get("fit_brief"), + external_recommendation=rag_result.get("external_recommendation"), + recommended_next_step=rag_result.get("recommended_next_step"), + answer=str(result.get("direct_reply_text") or rag_result.get("answer") or "").strip(), + evidence_pack=rag_result.get("evidence_pack"), + query_type=str(rag_result.get("query_type") or "explain"), + clarification_code=rag_result.get("clarification_code"), + anchor_kind=rag_result.get("anchor_kind"), + meta_followup_kind=rag_result.get("meta_followup_kind"), + project_snapshot=project_snapshot, + ) + ) + selected_entity_candidates = [ + str(item.get("name") or "").strip() + for item in list(analysis_packet.get("focus_objects") or []) + if isinstance(item, dict) and str(item.get("name") or "").strip() + ] + for extra_name in [ + analysis_packet.get("current_subject"), + analysis_packet.get("candidate_position"), + analysis_packet.get("rejected_position"), + analysis_packet.get("resolved_target"), + ]: + normalized = str(extra_name or "").strip() + if normalized and normalized not in selected_entity_candidates: + selected_entity_candidates.append(normalized) + 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_packet": analysis_packet, + "selected_entity_candidates": selected_entity_candidates, + "blocked_entity_candidates": blocked_entity_candidates, + } + + @classmethod + def _pick_feishu_entity( cls, entity_catalog: list[dict[str, str]], *, kind: str | None = None, - keywords: list[str] | None = None, + preferred_names: list[str] | None = None, used_names: set[str] | None = None, ) -> Optional[dict[str, str]]: used = used_names or set() @@ -416,13 +735,22 @@ class FeishuReceiveRoutingMixin: ] if not candidates: return None - if not keywords: + if not preferred_names: 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 + normalized_names = [ + cls._normalize_feishu_entity_key(name) + for name in preferred_names + if cls._normalize_feishu_entity_key(name) + ] + for normalized_name in normalized_names: + for item in candidates: + item_key = cls._normalize_feishu_entity_key(item.get("name")) + if item_key == normalized_name: + return item + for item in candidates: + item_key = cls._normalize_feishu_entity_key(item.get("name")) + if item_key and (normalized_name in item_key or item_key in normalized_name): + return item return candidates[0] @classmethod @@ -439,6 +767,12 @@ class FeishuReceiveRoutingMixin: 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]] = [] + analysis_packet = rag_result.get("analysis_packet") if isinstance(rag_result.get("analysis_packet"), dict) else {} + selected_candidates = [ + str(item.get("name") or "").strip() + for item in list(analysis_packet.get("focus_objects") or []) + if isinstance(item, dict) and str(item.get("name") or "").strip() + ] def _append_focus(entity: Optional[dict[str, str]], why: str, stance: str) -> None: if not entity: @@ -458,19 +792,20 @@ class FeishuReceiveRoutingMixin: if retrieval_status == "verifier_blocked": _append_focus( - cls._pick_feishu_entity_by_keywords( + cls._pick_feishu_entity( entity_catalog, kind="signal", + preferred_names=selected_candidates, used_names=used_names, ), "这条信号源当前还没通过校验,不能直接挂到项目结论上。", "blocked", ) _append_focus( - cls._pick_feishu_entity_by_keywords( + cls._pick_feishu_entity( entity_catalog, kind="task", - keywords=["排序", "卡片", "拉取", "清洗"], + preferred_names=selected_candidates, used_names=used_names, ), "这一步还没有拿到能稳定消费这条外部变化的依据。", @@ -480,18 +815,20 @@ class FeishuReceiveRoutingMixin: missing_mapping = "还缺校验通过后的字段映射和消费位置。" elif impact_status == "direct_match": _append_focus( - cls._pick_feishu_entity_by_keywords( + cls._pick_feishu_entity( entity_catalog, kind="signal", + preferred_names=selected_candidates, used_names=used_names, ), "这条信号源已经和当前外部变化建立了直接映射。", "anchor", ) _append_focus( - cls._pick_feishu_entity_by_keywords( + cls._pick_feishu_entity( entity_catalog, kind="task", + preferred_names=selected_candidates, used_names=used_names, ), "这项任务是当前最直接会受影响的项目对象。", @@ -501,27 +838,27 @@ class FeishuReceiveRoutingMixin: missing_mapping = "还缺它具体作用到哪个字段或触发条件的明确信息。" else: _append_focus( - cls._pick_feishu_entity_by_keywords( + cls._pick_feishu_entity( entity_catalog, kind="task", - keywords=["摘要", "卡片", "推送", "解释", "追问"], + preferred_names=selected_candidates, used_names=used_names, ), - "这一步更像结果出来之后的解释和追问层。", + "这一步更适合先接住解释、补说明或继续追问。", "better_fit", ) _append_focus( - cls._pick_feishu_entity_by_keywords( + cls._pick_feishu_entity( entity_catalog, kind="task", - keywords=["拉取", "清洗", "排序", "逻辑", "接口"], + preferred_names=selected_candidates, used_names=used_names, ), - "这一步属于确定性取数或计算主链,不适合直接塞进生成能力。", + "这一步要先稳定跑通,不适合先拿来试这种能力。", "not_fit", ) conclusion = "目前还不能直接说它已经适合当前项目。" - missing_mapping = "还缺它在当前项目里到底承接哪个节点的明确映射。" + missing_mapping = "现在还说不准它会先帮到哪一步。" if not focus_entities and entity_catalog: _append_focus( @@ -659,15 +996,10 @@ class FeishuReceiveRoutingMixin: 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}。" + focus_paragraph = ( + f"如果真要放进这个项目,我会先放在{first['name']}这一步," + f"不太会直接改{second['name']}这条主流程。" + ) elif first["stance"] == "blocked" and second["stance"] == "dependent": focus_paragraph = ( f"现在先卡在{first['name']},{first['why']}。" @@ -730,15 +1062,15 @@ class FeishuReceiveRoutingMixin: self, *, message_content: str, - analysis_plan: dict[str, Any], + analysis_packet: 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): + if not selected_entity_candidates or not isinstance(analysis_packet, 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], + analysis_packet=json.dumps(analysis_packet, ensure_ascii=False)[:1800], ) llm = getattr(self, "llm", None) or LLMService() raw_reply = await llm.generate( @@ -845,6 +1177,46 @@ class FeishuReceiveRoutingMixin: result: Dict[str, Any], message_content: str, ) -> str: + raw_direct_reply = str(result.get("direct_reply_text") or "").strip() + source_line = self._build_feishu_source_explanation_line(result) + if self._should_prefer_feishu_direct_recommendation_reply( + result=result, + ): + direct_recommendation = self._sanitize_feishu_direct_reply_body(raw_direct_reply) + if direct_recommendation: + if source_line: + return f"{direct_recommendation}\n\n{source_line}" + return direct_recommendation + if self._feishu_analysis_packet_family_enabled(): + presentation_context = await self._build_feishu_qa_packet_context( + result=result, + message_content=message_content, + ) + analysis_packet = ( + presentation_context.get("analysis_packet") + if isinstance(presentation_context.get("analysis_packet"), dict) + else {} + ) + fallback_text = ( + self._build_feishu_qa_analysis_packet_reply_text(analysis_packet) + or self._build_feishu_qa_direct_reply_text(result) + ) + try: + natural_reply = await self._generate_feishu_qa_natural_reply( + message_content=message_content, + analysis_packet=analysis_packet, + 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 = "" + reply_text = natural_reply or fallback_text + if self._contains_feishu_internal_state_markers(raw_direct_reply) and reply_text: + if source_line: + return f"{reply_text}\n\n{source_line}" + return reply_text + presentation_context = await self._build_feishu_qa_presentation_context( result=result, message_content=message_content, @@ -854,14 +1226,18 @@ class FeishuReceiveRoutingMixin: try: natural_reply = await self._generate_feishu_qa_natural_reply( message_content=message_content, - analysis_plan=analysis_plan, + analysis_packet=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 + reply_text = natural_reply or fallback_text + if self._contains_feishu_internal_state_markers(raw_direct_reply) and reply_text: + if source_line: + return f"{reply_text}\n\n{source_line}" + return reply_text async def _route_scene_reply( self, diff --git a/backend/app/services/integrations/feishu_signal_pipeline/mixin_context_prefill_a.py b/backend/app/services/integrations/feishu_signal_pipeline/mixin_context_prefill_a.py index 2c95d78f..8d79fb09 100644 --- a/backend/app/services/integrations/feishu_signal_pipeline/mixin_context_prefill_a.py +++ b/backend/app/services/integrations/feishu_signal_pipeline/mixin_context_prefill_a.py @@ -5,60 +5,6 @@ from .shared import * class FeishuPipelineContextPrefillAMixin: - @staticmethod - def _is_placeholder_project_text(text: str) -> bool: - normalized = re.sub(r"\s+", "", str(text or "")).lower() - if not normalized: - return True - if len(normalized) > 48: - return False - return any( - token in normalized - for token in ( - "创建个项目", - "创建一个项目", - "新建一个项目", - "新增一个项目", - "给我创建个项目", - "项目在项目列表", - "项目在pmflow", - "放进pmflow", - "放到pmflow", - "放进项目列表", - "放到项目列表", - "汇总以上内容", - "根据这个", - "根据上面", - ) - ) - - @staticmethod - def _is_project_request_artifact(text: str) -> bool: - normalized = re.sub(r"\s+", "", str(text or "")).lower() - if not normalized: - return False - return any( - token in normalized - for token in ( - "给我创建个项目", - "给我创建一个项目", - "创建个项目", - "创建一个项目", - "新建一个项目", - "新增一个项目", - "项目在项目列表", - "项目列表@pmflow", - "进项目列表", - "放进项目列表", - "放到项目列表", - "放进pmflow", - "放到pmflow", - "根据这个汇总", - "根据上面汇总", - "汇总以上内容", - ) - ) - @staticmethod def _is_unusable_project_context(text: str) -> bool: normalized = re.sub(r"\s+", "", str(text or "")).lower() @@ -81,81 +27,45 @@ class FeishuPipelineContextPrefillAMixin: ) ) - @staticmethod - def _guess_project_title_from_text(text: str) -> str: - cleaned = re.sub(r"\s+", " ", str(text or "")).strip() - if not cleaned: - return "" - normalized = re.sub(r"\s+", "", cleaned).lower() - if "十五五" in cleaned and any(token in cleaned for token in ("医药", "医疗", "器械", "中医药")): - return "十五五医药方向研究" - if "医药" in cleaned and any( - token in normalized - for token in ("方向", "值得关注", "创新药", "中医药", "老龄化", "供应链安全", "出海") - ): - return "医药方向研究" - if "医疗器械" in cleaned and "国产替代" in cleaned: - return "医疗器械国产替代研究" - return "" + @classmethod + def _merge_project_prefill_fields( + cls, + *, + fields: dict[str, Any], + project_info: dict[str, Any], + description_fallback: str = "", + scope_fallback: str = "", + ) -> dict[str, Any]: + title = str(fields.get("title") or "").strip() or normalize_project_title_candidate( + str(project_info.get("suggested_name") or "").strip() + ) + description = ( + str(fields.get("description") or "").strip() + or str(project_info.get("suggested_description") or "").strip()[:4000] + or str(description_fallback or "").strip()[:4000] + ) + if cls._is_unusable_project_context(description): + description = "" + project_scope = ( + str(fields.get("project_scope") or "").strip() + or str(project_info.get("suggested_scope") or "").strip()[:400] + or str(scope_fallback or "").strip()[:400] + ) - @staticmethod - def _extract_embedded_project_context(message: str) -> str: - cleaned = re.sub(r"\s+", " ", str(message or "")).strip() - if len(cleaned) < 80: - return "" - markers = [] - for pattern in ( - r"(?:根据这个|根据上面|基于这个|基于上面|按这个|按上面|汇总以上内容|汇总上面内容|汇总一下|这个汇总|这个总结).{0,40}(?:创建|新建|建立|建个|建一个).{0,8}项目", - r"(?:给我|帮我|请|麻烦)?(?:创建|新建|建立|建个|建一个|做个|做一个)(?:一个|个)?项目(?:在|进|到)?(?:pmflow|项目列表)", - ): - match = re.search(pattern, cleaned, re.IGNORECASE) - if match: - markers.append(match.start()) - if not markers: - return "" - context = cleaned[: min(markers)].strip(" ,,::") - if len(context) < 80: - return "" - return context[:4000] - - @staticmethod - def _derive_project_title_from_context(*, question: str, answer: str) -> str: - guessed = FeishuSignalPipelineService._guess_project_title_from_text(f"{question}\n{answer}".strip()) - if guessed: - return guessed - - def _normalize(raw: str) -> str: - candidate = re.sub(r"\s+", " ", str(raw or "")).strip(" ,,。.!!??::;;") - candidate = re.sub(r"^(?:请问|麻烦|帮我|我想知道|我想了解|想了解一下|目前|现在|关于|对于|针对)", "", candidate) - candidate = re.sub( - r"(?:还有哪些|有哪些|值得关注什么|值得关注哪些|值得关注|应该投什么|该投什么|该怎么做|怎么做|是什么|是啥|可以吗|吗|呢)$", - "", - candidate, - ) - candidate = re.sub(r"[,,].*$", "", candidate).strip(" ,,。.!!??::;;") - candidate = re.sub(r"(?:里面|里)$", "", candidate).strip(" ,,。.!!??::;;") - candidate = re.sub(r"[。;;!!??].*$", "", candidate).strip(" ,,。.!!??::;;") - normalized = normalize_project_title_candidate(candidate) - if normalized.endswith(("方向", "规划", "创新")): - normalized = normalize_project_title_candidate(f"{normalized}研究") - return normalized - - title = _normalize(question) - if title: - return title - - cleaned_answer = re.sub(r"\s+", " ", str(answer or "")).strip() - for pattern in ( - r"((?:十五五|医药|医疗|器械|公共卫生|老龄化)[^。;;!!??]{0,16}(?:方向|规划|创新|体系))", - r"((?:[A-Za-z0-9\u4e00-\u9fff]{2,18})(?:方向|规划|创新|中台|系统|平台|治理|流程|方案|体系))", - ): - match = re.search(pattern, cleaned_answer) - if not match: - continue - title = _normalize(match.group(1)) - if title: - return title - return "" + missing_fields: list[str] = [] + if not title: + missing_fields.append("title") + if not description: + missing_fields.append("description") + if not project_scope: + missing_fields.append("project_scope") + return { + "title": title, + "description": description, + "project_scope": project_scope, + "missing_fields": missing_fields, + "ready": not missing_fields, + } async def _load_recent_chat_turns( self, @@ -222,61 +132,8 @@ class FeishuPipelineContextPrefillAMixin: ) -> dict[str, Any]: if fields.get("ready"): return fields - - embedded_context = self._extract_embedded_project_context(message) - if not embedded_context or self._is_unusable_project_context(embedded_context): + cleaned_message = str(message or "").strip() + if not cleaned_message or self._is_unusable_project_context(cleaned_message): return fields - - current_description = str(fields.get("description") or "").strip() - if ( - self._is_placeholder_project_text(current_description) - or self._is_project_request_artifact(current_description) - ): - current_description = "" - current_scope = str(fields.get("project_scope") or "").strip() - if ( - self._is_placeholder_project_text(current_scope) - or self._is_project_request_artifact(current_scope) - ): - current_scope = "" - - title = str(fields.get("title") or "").strip() or self._guess_project_title_from_text(embedded_context) - description = current_description or embedded_context[:4000] - project_scope = current_scope or self._extract_project_scope(embedded_context, description=description) - if title and description and project_scope: - return { - "title": title, - "description": description, - "project_scope": project_scope, - "missing_fields": [], - "ready": True, - } - - project_info = await self._extract_project_prefill( - f"当前消息里的项目材料:{embedded_context}\n\n用户动作:{message}\n\n请仅基于当前消息里的材料补全项目名称、项目描述、项目范围。" - ) - suggested_name = normalize_project_title_candidate( - str(project_info.get("suggested_name") or "").strip() - ) - if self._is_placeholder_project_text(suggested_name): - suggested_name = "" - title = title or suggested_name - description = description or str(project_info.get("suggested_description") or "").strip()[:4000] - if self._is_unusable_project_context(description): - description = "" - project_scope = project_scope or self._extract_project_scope(embedded_context, description=description) - - missing_fields: list[str] = [] - if not title: - missing_fields.append("title") - if not description: - missing_fields.append("description") - if not project_scope: - missing_fields.append("project_scope") - return { - "title": title, - "description": description, - "project_scope": project_scope, - "missing_fields": missing_fields, - "ready": not missing_fields, - } + project_info = await self._extract_project_prefill(cleaned_message) + return self._merge_project_prefill_fields(fields=fields, project_info=project_info) diff --git a/backend/app/services/integrations/feishu_signal_pipeline/mixin_context_prefill_b.py b/backend/app/services/integrations/feishu_signal_pipeline/mixin_context_prefill_b.py index 7f4d3017..a608bebb 100644 --- a/backend/app/services/integrations/feishu_signal_pipeline/mixin_context_prefill_b.py +++ b/backend/app/services/integrations/feishu_signal_pipeline/mixin_context_prefill_b.py @@ -18,7 +18,7 @@ class FeishuPipelineContextPrefillBMixin: ) -> dict[str, Any]: if fields.get("ready"): return fields - if not source_chat_id or not self._is_contextual_project_request(message): + if not source_chat_id: return fields turns = await self._load_recent_chat_turns( @@ -47,83 +47,20 @@ class FeishuPipelineContextPrefillBMixin: return fields base_description = str(fields.get("description") or "").strip() - if ( - self._is_placeholder_project_text(base_description) - or self._is_project_request_artifact(base_description) - ): - base_description = "" if not answer_hint and not base_description: return fields - heuristic_title = self._derive_project_title_from_context(question=question_hint, answer=answer_hint) - heuristic_description = base_description or answer_hint[:4000] - heuristic_scope = str(fields.get("project_scope") or "").strip() - if ( - self._is_placeholder_project_text(heuristic_scope) - or self._is_project_request_artifact(heuristic_scope) - ): - heuristic_scope = "" - if not heuristic_scope and heuristic_description: - heuristic_scope = self._extract_project_scope( - f"{question_hint}\n\n{answer_hint}".strip(), - description=heuristic_description, - ) - if heuristic_title and heuristic_description and heuristic_scope: - return { - "title": str(fields.get("title") or "").strip() or heuristic_title, - "description": heuristic_description, - "project_scope": heuristic_scope, - "missing_fields": [], - "ready": True, - } - prefill_source = ( f"当前建项目请求:{message}\n\n" + "\n\n".join(context_blocks) + "\n\n请基于最近上下文补全项目名称、项目描述、项目范围。" ) project_info = await self._extract_project_prefill(prefill_source) - current_description = str(fields.get("description") or "").strip() - if ( - self._is_placeholder_project_text(current_description) - or self._is_project_request_artifact(current_description) - ): - current_description = "" - current_scope = str(fields.get("project_scope") or "").strip() - if ( - self._is_placeholder_project_text(current_scope) - or self._is_project_request_artifact(current_scope) - ): - current_scope = "" - title = ( - str(fields.get("title") or "").strip() - or heuristic_title - or normalize_project_title_candidate(str(project_info.get("suggested_name") or "")) + return self._merge_project_prefill_fields( + fields=fields, + project_info=project_info, + description_fallback=base_description or answer_hint[:4000], ) - description = current_description or str(project_info.get("suggested_description") or "").strip() - if not description and answer_hint: - description = answer_hint[:4000] - if self._is_unusable_project_context(description): - description = "" - project_scope = current_scope - if not project_scope: - scope_source = f"{description}\n\n{answer_hint}".strip() - project_scope = self._extract_project_scope(scope_source, description=description) - - missing_fields: list[str] = [] - if not title: - missing_fields.append("title") - if not description: - missing_fields.append("description") - if not project_scope: - missing_fields.append("project_scope") - return { - "title": title, - "description": description, - "project_scope": project_scope, - "missing_fields": missing_fields, - "ready": not missing_fields, - } def _resolve_project_candidate_fields( self, @@ -143,17 +80,12 @@ class FeishuPipelineContextPrefillBMixin: or operation_payload.get("description") or "" ).strip() - project_scope = str(payload.get("project_scope") or "").strip() - if not title or not description: - extracted = self._extract_project_intent_fields(item.raw_message or "") - if not title: - title = str(extracted.get("title") or "").strip() - if not description: - description = str(extracted.get("description") or "").strip() - if not project_scope: - project_scope = str(extracted.get("project_scope") or "").strip() - if not project_scope: - project_scope = self._extract_project_scope(item.raw_message or "", description=description) + ai_output = _safe_dict(item.ai_output) + project_prefill = _safe_dict(ai_output.get("project_prefill")) + project_scope = ( + str(payload.get("project_scope") or "").strip() + or str(project_prefill.get("suggested_scope") or "").strip() + ) missing_fields: list[str] = [] if not title: @@ -190,12 +122,12 @@ class FeishuPipelineContextPrefillBMixin: project_scope: str, ) -> str: normalized_title = normalize_project_title_candidate(title) - if normalized_title and not cls._is_placeholder_project_text(normalized_title): + if normalized_title: return normalized_title for candidate in (project_scope, description): cleaned = str(candidate or "").strip() - if not cleaned or cls._is_placeholder_project_text(cleaned): + if not cleaned: continue normalized = normalize_project_title_candidate(cleaned[:48]) if normalized: @@ -212,11 +144,7 @@ class FeishuPipelineContextPrefillBMixin: fallback_text: str, ) -> str: normalized_description = str(description or "").strip() - if cls._is_placeholder_project_text(normalized_description): - normalized_description = "" normalized_scope = str(project_scope or "").strip() - if cls._is_placeholder_project_text(normalized_scope): - normalized_scope = "" fragments: list[str] = [] if normalized_description: diff --git a/backend/app/services/integrations/feishu_signal_pipeline/mixin_core_base.py b/backend/app/services/integrations/feishu_signal_pipeline/mixin_core_base.py index e358ac02..593a594f 100644 --- a/backend/app/services/integrations/feishu_signal_pipeline/mixin_core_base.py +++ b/backend/app/services/integrations/feishu_signal_pipeline/mixin_core_base.py @@ -61,14 +61,6 @@ class FeishuPipelineCoreBaseMixin: text = str(message or "").strip() if not text: return "generic" - if EXTERNAL_INTEGRATION_RE.search(text): - return "external_integration" - if CROSS_SYSTEM_RE.search(text): - return "cross_system" - if PROJECT_COMPLEXITY_RE.search(text): - return "project_setup" - if SIMPLE_SIGNAL_REQUEST_RE.search(text): - return "feature_change" fragment = cls._normalize_key_fragment(text, max_len=64) return fragment or "generic" diff --git a/backend/app/services/integrations/feishu_signal_pipeline/mixin_extraction.py b/backend/app/services/integrations/feishu_signal_pipeline/mixin_extraction.py index 0147a030..49341a23 100644 --- a/backend/app/services/integrations/feishu_signal_pipeline/mixin_extraction.py +++ b/backend/app/services/integrations/feishu_signal_pipeline/mixin_extraction.py @@ -21,14 +21,16 @@ class FeishuPipelineExtractionMixin: return _validate_schema(_ProjectPrefillSchema, parsed, context="project_prefill") except Exception as exc: logger.warning(f"feishu_project_prefill_fallback: error={exc}") - fallback = self._heuristic_project_prefill_fallback(message) + fallback = self._build_project_prefill_degraded(message=message) return _validate_schema(_ProjectPrefillSchema, fallback, context="project_prefill_fallback") - async def _extract_signal(self, message: str, *, projects: list[dict[str, Any]]) -> dict[str, Any]: - if self._is_simple_signal_source_request(message): - fallback = self._heuristic_signal_fallback(message=message, projects=projects) - return _validate_schema(_SignalExtractionSchema, fallback, context="signal_extraction_fastpath") - + async def _extract_signal( + self, + message: str, + *, + projects: list[dict[str, Any]], + signal_intake_hint: bool = False, + ) -> dict[str, Any]: prompt = PROMPT_FEISHU_SIGNAL_EXTRACTION.format( message=message[:1500], projects_json=json.dumps(projects, ensure_ascii=False), @@ -47,14 +49,19 @@ class FeishuPipelineExtractionMixin: return _validate_schema(_SignalExtractionSchema, parsed, context="signal_extraction") except Exception as exc: logger.warning(f"feishu_signal_extraction_fallback: error={exc}") - fallback = self._heuristic_signal_fallback(message=message, projects=projects) + fallback = self._build_signal_extraction_degraded( + message=message, + signal_intake_hint=signal_intake_hint, + ) return _validate_schema(_SignalExtractionSchema, fallback, context="signal_extraction_fallback") - async def _classify_level(self, *, message: str, projects: list[dict[str, Any]]) -> dict[str, Any]: - if self._is_simple_signal_source_request(message): - fallback = self._heuristic_level_fallback(message) - return _validate_schema(_LevelClassificationSchema, fallback, context="level_classification_fastpath") - + async def _classify_level( + self, + *, + message: str, + projects: list[dict[str, Any]], + signal_intake_hint: bool = False, + ) -> dict[str, Any]: prompt = PROMPT_FEISHU_LEVEL_CLASSIFICATION.format( message=message[:1500], projects_json=json.dumps(projects, ensure_ascii=False), @@ -124,126 +131,48 @@ class FeishuPipelineExtractionMixin: "should_split": _safe_bool(parsed.get("should_split"), False), "split_items": normalized_split_items, } - if self._is_simple_signal_source_request(message): - payload["project_score"] = min(_safe_float(payload.get("project_score"), final_project_score), 0.35) - payload["level"] = "signal" - payload["should_split"] = False - payload["split_items"] = [] - payload["level_reasoning"] = ( - f"{payload['level_reasoning']};命中简单需求模式,按信号处理避免误升级为项目。" - )[:600] return _validate_schema(_LevelClassificationSchema, payload, context="level_classification") except Exception as exc: logger.warning(f"feishu_level_classification_fallback: error={exc}") - fallback = self._heuristic_level_fallback(message) + fallback = self._build_level_classification_degraded(signal_intake_hint=signal_intake_hint) return _validate_schema(_LevelClassificationSchema, fallback, context="level_classification_fallback") - def _heuristic_level_fallback(self, message: str) -> dict[str, Any]: - text = message or "" - build_intent_score = 1.0 if PROJECT_INTENT_RE.search(text) else 0.2 - scope_score = 0.75 if ("系统" in text or "平台" in text or "功能" in text) else 0.35 - timeline_score = 0.8 if TIMELINE_RE.search(text) else 0.1 - owner_score = 0.7 if OWNER_RE.search(text) else 0.15 - project_score = round( - (build_intent_score * 0.4) - + (scope_score * 0.25) - + (timeline_score * 0.2) - + (owner_score * 0.15), - 4, - ) - has_project_intent = bool(PROJECT_INTENT_RE.search(text)) - has_signal_fact = bool(SIGNAL_FACT_RE.search(text)) - should_split = bool(has_project_intent and has_signal_fact) - simple_signal_request = self._is_simple_signal_source_request(text) - if simple_signal_request: - project_score = min(project_score, 0.35) - should_split = False - level = "signal" - level_reasoning = "命中简单需求规则,按信号处理,避免误升级项目。" - else: - level = "project" if project_score >= 0.6 else "signal" - level_reasoning = "基于关键词与上下文规则的兜底判定。" + @staticmethod + def _build_level_classification_degraded(*, signal_intake_hint: bool) -> dict[str, Any]: + project_score = 0.2 if signal_intake_hint else 0.3 return { - "build_intent_score": build_intent_score, - "scope_score": scope_score, - "timeline_score": timeline_score, - "owner_score": owner_score, + "build_intent_score": 0.0, + "scope_score": 0.0, + "timeline_score": 0.0, + "owner_score": 0.0, "project_score": project_score, - "level": level, - "level_confidence": max(project_score, 1 - project_score), - "level_reasoning": level_reasoning, - "should_split": should_split, - "split_items": ( - [ - {"level": "project", "content": text}, - {"level": "signal", "content": text}, - ] - if should_split - else [] - ), + "level": "signal", + "level_confidence": 0.35 if signal_intake_hint else 0.25, + "level_reasoning": "结构化分类暂时不可用,当前先按低置信信号待确认处理。", + "should_split": False, + "split_items": [], } @staticmethod - def _guess_matched_project_id_from_text(message: str, projects: list[dict[str, Any]]) -> Optional[int]: - normalized_message = str(message or "").strip().lower() - if not normalized_message: - return None - for row in projects: - if not isinstance(row, dict): - continue - pid_raw = row.get("id") - title = str(row.get("name") or row.get("title") or "").strip() - if not title or len(title) < 2: - continue - try: - pid = int(pid_raw) if pid_raw is not None else None - except Exception: - pid = None - if pid is None: - continue - if title.lower() in normalized_message: - return pid - return None - - def _heuristic_signal_fallback(self, *, message: str, projects: list[dict[str, Any]]) -> dict[str, Any]: + def _build_signal_extraction_degraded(*, message: str, signal_intake_hint: bool) -> dict[str, Any]: text = str(message or "").strip() - normalized = text.lower() cleaned = re.sub(r"\s+", " ", text).strip() - summary = cleaned[:280] if cleaned else "飞书消息(空内容)" - - signal_type = "新需求" - priority = "P2" - recommended_action = "enter_only" - if any(k in normalized for k in ("故障", "告警", "宕机", "报错", "bug", "风险")): - signal_type = "项目风险" - priority = "P1" - recommended_action = "trigger_alert" - elif any(k in normalized for k in ("反馈", "吐槽", "体验", "投诉")): - signal_type = "用户反馈" - priority = "P2" - elif any(k in normalized for k in ("竞品", "对标", "发布", "上线")): - signal_type = "竞品动态" - priority = "P2" - - title_seed = cleaned or "飞书需求信号" - title = title_seed[:48] - if not title: - title = "飞书需求信号" - confidence = 0.78 if signal_type in {"项目风险", "用户反馈"} else 0.72 + summary = cleaned[:280] if cleaned else "原始飞书消息暂未解析出稳定信号结构。" + title = (cleaned[:48] or "飞书需求信号").strip() or "飞书需求信号" return { - "title": title, - "type": signal_type, - "priority": priority, - "matched_project_id": self._guess_matched_project_id_from_text(text, projects), + "title": title[:200], + "type": "新需求", + "priority": "P2", + "matched_project_id": None, "summary": summary, - "reasoning": ["LLM 返回异常,已按关键词规则生成兜底信号结构。"], - "confidence": confidence, - "recommended_action": recommended_action, + "reasoning": ["结构化信号提取失败,当前仅保留原始消息,待确认后再绑定正式对象。"], + "confidence": 0.35 if signal_intake_hint else 0.3, + "recommended_action": "enter_only", } @staticmethod - def _heuristic_project_prefill_fallback(message: str) -> dict[str, Any]: + def _build_project_prefill_degraded(*, message: str) -> dict[str, Any]: text = str(message or "").strip() cleaned = re.sub(r"\s+", " ", text).strip() suggested_name = cleaned[:30] if cleaned else "飞书新需求项目" @@ -257,11 +186,12 @@ class FeishuPipelineExtractionMixin: return { "suggested_name": suggested_name, "suggested_description": suggested_description, + "suggested_scope": None, "suggested_owner": None, "suggested_timeline": None, "confidence": 0.35, "reasoning": [ - "LLM 返回异常,已按规则生成项目预填草案。", + "结构化项目预填失败,当前仅保留原始描述,待补充标题、范围和负责人。", "负责人和时间线仅在消息明确给出时才填写。", ], } diff --git a/backend/app/services/integrations/feishu_signal_pipeline/mixin_intent_gate_core.py b/backend/app/services/integrations/feishu_signal_pipeline/mixin_intent_gate_core.py index 5e0b5f4a..e987f47d 100644 --- a/backend/app/services/integrations/feishu_signal_pipeline/mixin_intent_gate_core.py +++ b/backend/app/services/integrations/feishu_signal_pipeline/mixin_intent_gate_core.py @@ -118,7 +118,7 @@ class FeishuPipelineIntentGateCoreMixin: return gate if explicit_project_request: - fields = self._extract_project_intent_fields(text) + fields = await self._extract_structured_project_fields(text) fields = await self._maybe_prefill_project_fields_from_embedded_content( message=text, fields=fields, @@ -200,43 +200,11 @@ class FeishuPipelineIntentGateCoreMixin: "correction_match": correction_match, } - @staticmethod - def _extract_project_scope(text: str, *, description: str) -> str: - cleaned = re.sub(r"\s+", " ", str(text or "")).strip() - for pattern in ( - r"(?:范围|scope|面向|目标|用于|负责|服务于)[::\s]*(.+)", - r"(?:描述|背景|场景|做什么)[::\s]*(.+)", - ): - match = re.search(pattern, cleaned, re.IGNORECASE) - if not match: - continue - candidate = re.sub(r"[。;;!!??].*$", "", match.group(1)).strip(" ,,::") - if candidate: - return candidate[:400] - if len(description) >= 12: - return description[:400] - return "" - - def _extract_project_intent_fields(self, message: str) -> dict[str, Any]: - title = extract_project_create_title(message) - if not title: - cleaned = re.sub(r"\s+", " ", str(message or "")).strip() - for pattern in ( - r"(?:名称|名字|项目名)(?:叫|是|为)?[::\s]*(.+)", - r"项目(?:叫|名为|名称叫|名称是|名称为)[::\s]*(.+)", - ): - match = re.search(pattern, cleaned, re.IGNORECASE) - if not match: - continue - candidate = re.sub(r"[,。,.;;!??].*$", "", match.group(1)).strip(" ,,::\"'“”") - if candidate: - title = candidate[:24] - break - description = extract_project_create_description( - instruction=message, - title=title, - ) - project_scope = self._extract_project_scope(message, description=description) + async def _extract_structured_project_fields(self, message: str) -> dict[str, Any]: + project_info = await self._extract_project_prefill(message) + title = normalize_project_title_candidate(str(project_info.get("suggested_name") or "").strip()) + description = str(project_info.get("suggested_description") or "").strip()[:4000] + project_scope = str(project_info.get("suggested_scope") or "").strip()[:400] missing_fields: list[str] = [] if not title: missing_fields.append("title") @@ -252,30 +220,6 @@ class FeishuPipelineIntentGateCoreMixin: "ready": not missing_fields, } - @staticmethod - def _is_contextual_project_request(message: str) -> bool: - normalized = re.sub(r"\s+", "", str(message or "")).lower() - if not normalized: - return False - return any( - token in normalized - for token in ( - "根据这个", - "根据上面", - "基于这个", - "基于上面", - "按这个", - "按上面", - "汇总以上", - "汇总上面", - "以上内容", - "上面内容", - "这个汇总", - "这个总结", - "刚才内容", - ) - ) - @staticmethod def _strip_feishu_mention_prefix(text: str) -> str: cleaned = re.sub(r"^]*>[^<]*\s*", "", str(text or ""), flags=re.IGNORECASE).strip() diff --git a/backend/app/services/integrations/feishu_signal_pipeline/mixin_item_finalize_routing.py b/backend/app/services/integrations/feishu_signal_pipeline/mixin_item_finalize_routing.py index caf5c798..9f9122f6 100644 --- a/backend/app/services/integrations/feishu_signal_pipeline/mixin_item_finalize_routing.py +++ b/backend/app/services/integrations/feishu_signal_pipeline/mixin_item_finalize_routing.py @@ -23,7 +23,7 @@ class FeishuPipelineItemFinalizeRoutingMixin: signal_auto_enter_threshold: float, chat_context_resolution: dict[str, Any], inferred_project_id: Optional[int], - simple_signal_request: bool, + signal_intake_hint: bool, clarification_message: Optional[str], item_started_at: float, ) -> dict[str, Any]: @@ -84,7 +84,7 @@ class FeishuPipelineItemFinalizeRoutingMixin: { "level_classification": level_result, "signal_extraction": signal_info, - "simple_signal_request": simple_signal_request, + "signal_intake_hint": signal_intake_hint, "chat_context_inferred_project_id": inferred_project_id, "chat_context_resolution": chat_context_resolution, "clarification_message": clarification_message, diff --git a/backend/app/services/integrations/feishu_signal_pipeline/mixin_item_processing.py b/backend/app/services/integrations/feishu_signal_pipeline/mixin_item_processing.py index 3e5bcd92..b81f234c 100644 --- a/backend/app/services/integrations/feishu_signal_pipeline/mixin_item_processing.py +++ b/backend/app/services/integrations/feishu_signal_pipeline/mixin_item_processing.py @@ -28,6 +28,7 @@ class FeishuPipelineItemProcessingMixin: split_from_item_id: Optional[int], chat_type: str, chat_context_resolution: dict[str, Any], + signal_intake_hint: bool, ) -> dict[str, Any]: item_started_at = time.perf_counter() level_val = "project" if level == "project" else "signal" @@ -119,8 +120,11 @@ class FeishuPipelineItemProcessingMixin: item_started_at=item_started_at, ) - simple_signal_request = self._is_simple_signal_source_request(content) - signal_info = await self._extract_signal(content, projects=projects) + signal_info = await self._extract_signal( + content, + projects=projects, + signal_intake_hint=signal_intake_hint, + ) matched_project_id = signal_info.get("matched_project_id") if matched_project_id is not None: try: @@ -129,11 +133,11 @@ class FeishuPipelineItemProcessingMixin: matched_project_id = None confidence = _safe_float(signal_info.get("confidence"), max(0.55, 1 - project_score)) - if simple_signal_request: + if signal_intake_hint: confidence = max(confidence, min(0.95, signal_auto_enter_threshold + 0.05)) inferred_project_id: Optional[int] = None clarification_message: Optional[str] = None - if (chat_context_auto_link_enabled or simple_signal_request) and matched_project_id is None and source_chat_id: + if (chat_context_auto_link_enabled or signal_intake_hint) and matched_project_id is None and source_chat_id: inferred_project_id = _safe_int(chat_context_resolution.get("project_id")) if inferred_project_id is not None: matched_project_id = int(inferred_project_id) @@ -150,7 +154,7 @@ class FeishuPipelineItemProcessingMixin: signal_info=signal_info, confidence=confidence, signal_auto_enter_threshold=signal_auto_enter_threshold, - simple_signal_request=simple_signal_request, + signal_intake_hint=signal_intake_hint, ): promoted_project_score = max(float(project_score or 0.0), float(project_uncertain_threshold)) item.level = "project" @@ -182,7 +186,7 @@ class FeishuPipelineItemProcessingMixin: extra_ai_fields={ "signal_extraction": signal_info, "promoted_to_project_candidate": True, - "simple_signal_request": simple_signal_request, + "signal_intake_hint": signal_intake_hint, }, ) @@ -202,7 +206,7 @@ class FeishuPipelineItemProcessingMixin: signal_auto_enter_threshold=signal_auto_enter_threshold, chat_context_resolution=chat_context_resolution, inferred_project_id=inferred_project_id, - simple_signal_request=simple_signal_request, + signal_intake_hint=signal_intake_hint, clarification_message=clarification_message, item_started_at=item_started_at, ) diff --git a/backend/app/services/integrations/feishu_signal_pipeline/mixin_lookup_and_block.py b/backend/app/services/integrations/feishu_signal_pipeline/mixin_lookup_and_block.py index e2cc77f9..64cabbd0 100644 --- a/backend/app/services/integrations/feishu_signal_pipeline/mixin_lookup_and_block.py +++ b/backend/app/services/integrations/feishu_signal_pipeline/mixin_lookup_and_block.py @@ -5,11 +5,6 @@ from .shared import * class FeishuPipelineLookupAndBlockMixin: - @staticmethod - def _normalize_project_lookup_text(value: Any) -> str: - text = normalize_lookup_fragment(value, limit=96) - return re.sub(r"\s+", "", text).lower() - async def _maybe_block_legacy_command_request( self, *, @@ -36,81 +31,6 @@ class FeishuPipelineLookupAndBlockMixin: ) return None - async def _maybe_answer_project_lookup_question( - self, - *, - message: str, - feishu_message_id: Optional[str], - process_started_at: float, - projects: list[dict[str, Any]], - intent_gate: Optional[dict[str, Any]] = None, - ) -> Optional[dict[str, Any]]: - semantic_bundle = intent_gate.get("semantic_bundle") if isinstance(intent_gate, dict) else {} - diagnostics = semantic_bundle.get("diagnostics") if isinstance(semantic_bundle, dict) else {} - message_intent = str(diagnostics.get("message_intent") or "").strip().lower() - if message_intent != "project_lookup_question": - return None - - linked_project_ids: list[int] = [] - reply_text = "" - id_match = PROJECT_ID_QUERY_RE.search(message) - if id_match: - target_id = int(id_match.group(1)) - matched = next((item for item in projects if _safe_int(item.get("id")) == target_id), None) - if matched is None: - reply_text = f"项目#{target_id} 不存在,或当前租户下不可见。" - else: - linked_project_ids = [target_id] - reply_text = self._format_project_lookup_hit_rows([matched], prefix="查到了") - elif PROJECT_LIST_QUERY_RE.search(message): - if not projects: - reply_text = "当前没有可见项目。" - else: - reply_text = self._format_project_lookup_hit_rows(projects[:8], prefix="当前项目") - linked_project_ids = [int(item["id"]) for item in projects[:8] if _safe_int(item.get("id"))] - else: - matched_rows = self._match_projects_by_keyword(projects=projects, keyword=message) - if matched_rows: - linked_project_ids = [int(item["id"]) for item in matched_rows if _safe_int(item.get("id"))] - reply_text = self._format_project_lookup_hit_rows(matched_rows[:5], prefix="查到了") - else: - reply_text = "没有查到可确认的项目。请补充更完整的项目名,或直接给项目 ID。" - - duration_ms = int((time.perf_counter() - process_started_at) * 1000) - await self._write_processing_log( - feishu_message_id=feishu_message_id, - feishu_item_id=None, - raw_message=message, - ai_raw_output=json.dumps({"deterministic_lookup": True, "linked_project_ids": linked_project_ids}, ensure_ascii=False), - project_score=None, - signal_confidence=None, - level="question", - routing_decision=ROUTING_DIRECT_QA, - routing_reason="命中项目查询问句,已直接基于真实 DB 返回结果,跳过 LLM 分流。", - reasoning_bullets=[ - "命中 deterministic project lookup 规则。", - "直接查询 projects 表,不走 signal/project 分流。", - "回复内容只基于真实 DB 结果。", - ], - processing_status="success", - error_message=None, - duration_ms=duration_ms, - ) - return { - "status": "answered", - "classification": "question", - "project_scope": "question", - "linked_project_ids": linked_project_ids, - "signal_id": None, - "created_project_id": None, - "auto_tasks": [], - "ai_trace_id": f"feishu-project-lookup-{(feishu_message_id or 'noid')[:32]}", - "results": [], - "intent": "question", - "direct_reply_required": True, - "direct_reply_text": reply_text, - } - async def _build_legacy_command_blocked_capture( self, *, @@ -154,36 +74,6 @@ class FeishuPipelineLookupAndBlockMixin: "command_result": None, } - @staticmethod - def _match_projects_by_keyword(*, projects: list[dict[str, Any]], keyword: str) -> list[dict[str, Any]]: - normalized_keyword = FeishuPipelineLookupAndBlockMixin._normalize_project_lookup_text(keyword) - if len(normalized_keyword) < 2: - return [] - ranked: list[tuple[int, int, dict[str, Any]]] = [] - for item in projects: - title = FeishuPipelineLookupAndBlockMixin._normalize_project_lookup_text(item.get("title") or item.get("name") or "") - if len(title) < 2: - continue - if title in normalized_keyword: - ranked.append((0, -len(title), item)) - continue - if normalized_keyword in title: - ranked.append((1, len(title), item)) - ranked.sort(key=lambda row: (row[0], row[1], int(_safe_int(row[2].get("id")) or 0))) - return [row[2] for row in ranked] - - @staticmethod - def _format_project_lookup_hit_rows(rows: list[dict[str, Any]], *, prefix: str) -> str: - if not rows: - return "当前没有可见项目。" - fragments: list[str] = [] - for item in rows[:8]: - project_id = item.get("id") - title = str(item.get("title") or item.get("name") or "").strip() or f"项目#{project_id}" - status = str(item.get("status") or "").strip() or "unknown" - fragments.append(f"#{project_id} {title}({status})") - return f"{prefix}:{'、'.join(fragments)}。" - @classmethod def _should_hide_pending_item_from_inbox(cls, item: FeishuItem) -> bool: if str(item.decision_reason or "").strip() == "missing_required_fields": diff --git a/backend/app/services/integrations/feishu_signal_pipeline/mixin_message_utils_b.py b/backend/app/services/integrations/feishu_signal_pipeline/mixin_message_utils_b.py index 092b9131..d80c3a09 100644 --- a/backend/app/services/integrations/feishu_signal_pipeline/mixin_message_utils_b.py +++ b/backend/app/services/integrations/feishu_signal_pipeline/mixin_message_utils_b.py @@ -229,26 +229,9 @@ class FeishuPipelineMessageUtilsBMixin: signal_info: dict[str, Any], confidence: float, signal_auto_enter_threshold: float, - simple_signal_request: bool, + signal_intake_hint: bool, ) -> bool: - _ = (content, signal_info, confidence, signal_auto_enter_threshold, simple_signal_request) + _ = (content, signal_info, confidence, signal_auto_enter_threshold, signal_intake_hint) # 项目创建只能通过 intent gate:create_project + 受控 operation executor 落库; # 不再把 signal 自由升级为 project candidate。 return False - - @staticmethod - def _is_simple_signal_source_request(message: str) -> bool: - text = re.sub(r"\s+", "", str(message or "")) - if len(text) < 6: - return False - if PROJECT_COMPLEXITY_RE.search(text): - return False - if SIMPLE_SIGNAL_REQUEST_RE.search(text): - return True - action_tokens = ("新增", "增加", "实现", "支持", "修复", "优化", "接入", "改造", "调整") - object_tokens = ("功能", "能力", "页面", "接口", "流程", "按钮", "提醒", "识别", "字段", "规则", "逻辑") - return ( - len(text) <= 180 - and any(token in text for token in action_tokens) - and any(token in text for token in object_tokens) - ) diff --git a/backend/app/services/integrations/feishu_signal_pipeline/mixin_process_message_core.py b/backend/app/services/integrations/feishu_signal_pipeline/mixin_process_message_core.py index 62470529..2801c723 100644 --- a/backend/app/services/integrations/feishu_signal_pipeline/mixin_process_message_core.py +++ b/backend/app/services/integrations/feishu_signal_pipeline/mixin_process_message_core.py @@ -160,23 +160,6 @@ class FeishuPipelineProcessMessageCoreMixin: intent_gate=intent_gate, ) - lookup_capture = await self._maybe_answer_project_lookup_question( - message=message, - feishu_message_id=feishu_message_id, - process_started_at=process_started_at, - projects=projects, - intent_gate=intent_gate, - ) - if lookup_capture is not None: - await self.db.commit() - return self._decorate_capture_result( - lookup_capture, - chat_type=normalized_chat_type, - normalized_text=message, - route=f"{normalized_chat_type}.project_lookup", - db_mutations=[], - ) - gate_capture = await self._maybe_handle_intent_gate_capture( intent_gate=intent_gate, message=message, @@ -207,5 +190,6 @@ class FeishuPipelineProcessMessageCoreMixin: process_started_at=process_started_at, normalized_chat_type=normalized_chat_type, chat_context_resolution=chat_context_resolution, + intent_gate=intent_gate, thread_context=thread_context, ) diff --git a/backend/app/services/integrations/feishu_signal_pipeline/mixin_process_message_default.py b/backend/app/services/integrations/feishu_signal_pipeline/mixin_process_message_default.py index d9b29353..7ebb5cef 100644 --- a/backend/app/services/integrations/feishu_signal_pipeline/mixin_process_message_default.py +++ b/backend/app/services/integrations/feishu_signal_pipeline/mixin_process_message_default.py @@ -20,6 +20,7 @@ class FeishuPipelineProcessMessageDefaultMixin: process_started_at: float, normalized_chat_type: str, chat_context_resolution: dict[str, Any], + intent_gate: Optional[dict[str, Any]] = None, thread_context: Optional[dict[str, Any]] = None, ) -> dict[str, Any]: rag_service = AdaptiveRAGService(self.db, tenant_id=self.tenant_id) @@ -41,6 +42,9 @@ class FeishuPipelineProcessMessageDefaultMixin: intent_for_response = intent rag_question_part = str(intent_result.get("question_part") or "").strip() or message rag_statement_part = str(intent_result.get("statement_part") or "").strip() + structured_signal_intake = ( + str((intent_gate or {}).get("intent") or "").strip().lower() == INTENT_SIGNAL_INTAKE + ) if intent == "question": if semantic_message_intent in { @@ -116,10 +120,12 @@ class FeishuPipelineProcessMessageDefaultMixin: sidecar_rag_result = mixed_rag_result try: - if force_signal_fastpath: - level_result = self._heuristic_level_fallback(message) - else: - level_result = await self._classify_level(message=message, projects=projects) + signal_intake_hint = bool(force_signal_fastpath or structured_signal_intake) + level_result = await self._classify_level( + message=message, + projects=projects, + signal_intake_hint=signal_intake_hint, + ) project_score = _safe_float(level_result.get("project_score"), 0.0) level = _normalize_level_value( level_result.get("level"), @@ -176,6 +182,7 @@ class FeishuPipelineProcessMessageDefaultMixin: split_from_item_id=parent_item_id if idx > 0 else None, chat_type=normalized_chat_type, chat_context_resolution=chat_context_resolution, + signal_intake_hint=signal_intake_hint, ) if idx == 0 and item_result.get("item_id"): parent_item_id = int(item_result["item_id"]) diff --git a/backend/app/services/integrations/feishu_signal_pipeline/mixin_serialization.py b/backend/app/services/integrations/feishu_signal_pipeline/mixin_serialization.py index c276d15b..b168b660 100644 --- a/backend/app/services/integrations/feishu_signal_pipeline/mixin_serialization.py +++ b/backend/app/services/integrations/feishu_signal_pipeline/mixin_serialization.py @@ -67,11 +67,7 @@ class FeishuPipelineSerializationMixin: "missing_fields": [], "ready": False, } - if ( - self._is_placeholder_project_text(project_fields["title"]) - or self._is_placeholder_project_text(project_fields["description"]) - or self._is_placeholder_project_text(project_fields["project_scope"]) - ): + if not all(str(project_fields.get(field) or "").strip() for field in ("title", "description", "project_scope")): project_fields = await self._maybe_prefill_project_fields_from_context( message=str(item.raw_message or ""), source_chat_id=str(item.source_chat_id or ""), diff --git a/backend/app/services/integrations/feishu_signal_pipeline/shared_schema_utils.py b/backend/app/services/integrations/feishu_signal_pipeline/shared_schema_utils.py index cbdb8eb8..8f4dae98 100644 --- a/backend/app/services/integrations/feishu_signal_pipeline/shared_schema_utils.py +++ b/backend/app/services/integrations/feishu_signal_pipeline/shared_schema_utils.py @@ -41,6 +41,7 @@ class _SignalExtractionSchema(BaseModel): class _ProjectPrefillSchema(BaseModel): suggested_name: str = Field(min_length=1, max_length=255) suggested_description: str = Field(min_length=1, max_length=4000) + suggested_scope: Optional[str] = Field(default=None, max_length=400) suggested_owner: Optional[str] = Field(default=None, max_length=120) suggested_timeline: Optional[str] = Field(default=None, max_length=120) confidence: float = Field(ge=0.0, le=1.0) diff --git a/backend/app/services/signal/signal_capture_service/mixin_capture_entry.py b/backend/app/services/signal/signal_capture_service/mixin_capture_entry.py index 1364df79..6671534a 100644 --- a/backend/app/services/signal/signal_capture_service/mixin_capture_entry.py +++ b/backend/app/services/signal/signal_capture_service/mixin_capture_entry.py @@ -11,28 +11,25 @@ class SignalCaptureEntryMixin: self.db = db self.llm = LLMService() - @classmethod - def _matches_active_group_protocol_followup( - cls, - *, - active_context_kind: Optional[str], - message_text: str, + @staticmethod + def _semantic_bundle_requests_group_followup( + semantic_bundle: Optional[Any], ) -> bool: - normalized_kind = str(active_context_kind or "").strip().lower() - if normalized_kind == "requirement_plan": - try: - from ...ai_pmo.ai_pmo_typed_executor_execute.mixin_execute_query_project import ExecuteQueryProjectMixin - - return ExecuteQueryProjectMixin._parse_requirement_plan_choice_protocol(message_text) is not None - except Exception: - return False - if normalized_kind == "draft_pending_confirm": - try: - from ...ai_pmo.draft_confirm_handler import DraftConfirmHandler - - return DraftConfirmHandler.is_explicit_protocol_followup_text(message_text) - except Exception: - return False + if not isinstance(semantic_bundle, dict): + return False + primary_workflow = str(semantic_bundle.get("primary_workflow") or "").strip().lower() + if primary_workflow == "followup_flow": + return True + for item in semantic_bundle.get("workflows") or []: + if not isinstance(item, dict) and not hasattr(item, "workflow_type"): + continue + workflow_type = ( + str(item.get("workflow_type") or "").strip().lower() + if isinstance(item, dict) + else str(getattr(item, "workflow_type", "") or "").strip().lower() + ) + if workflow_type == "followup_flow": + return True return False @staticmethod @@ -63,49 +60,40 @@ class SignalCaptureEntryMixin: source_message_id: str, thread_context: Optional[dict[str, Any]], message_text: str, + semantic_bundle: Optional[dict[str, Any]], ) -> Optional[dict[str, Any]]: conversation_key = build_feishu_conversation_scope_key( source_chat_id=group_chat_id, source_sender_open_id=proposer_open_id, sender=proposer_name, ) or source_message_id - try: - from ...ai_pmo.ai_pmo_typed_executor_execute.mixin_execute_query_project import ExecuteQueryProjectMixin - - parsed_choice = ExecuteQueryProjectMixin._parse_requirement_plan_choice_protocol(message_text) - except Exception: - parsed_choice = None - if isinstance(parsed_choice, dict): - finder = getattr(kernel, "_find_active_requirement_plan_workspace", None) - if not callable(finder): - finder = getattr(getattr(kernel, "executor", None), "_find_active_requirement_plan_workspace", None) - derive_kind = getattr(kernel, "_derive_active_context_kind", None) - if callable(finder) and callable(derive_kind): - try: - topic, draft = await finder( - source_system="feishu", - source_message_id=source_message_id, - source_chat_id=group_chat_id, - sender=None, - source_sender_open_id=proposer_open_id, - thread_context=thread_context, - conversation_key=conversation_key, - ) - except Exception: - topic, draft = None, None - if topic is not None and draft is not None: - active_context_kind = derive_kind(topic=topic, draft=draft) - if self._matches_active_group_protocol_followup( - active_context_kind=active_context_kind, - message_text=message_text, - ): - return { - "active_context_kind": active_context_kind, - "thread_context": self._build_group_protocol_followup_thread_context( - topic=topic, - thread_context=thread_context, - ), - } + finder = getattr(kernel, "_find_active_requirement_plan_workspace", None) + if not callable(finder): + finder = getattr(getattr(kernel, "executor", None), "_find_active_requirement_plan_workspace", None) + derive_kind = getattr(kernel, "_derive_active_context_kind", None) + if callable(finder) and callable(derive_kind): + try: + topic, draft = await finder( + source_system="feishu", + source_message_id=source_message_id, + source_chat_id=group_chat_id, + sender=None, + source_sender_open_id=proposer_open_id, + thread_context=thread_context, + conversation_key=conversation_key, + ) + except Exception: + topic, draft = None, None + if topic is not None and draft is not None: + active_context_kind = derive_kind(topic=topic, draft=draft) + if str(active_context_kind or "").strip().lower() in {"requirement_plan", "draft_pending_confirm"}: + return { + "active_context_kind": active_context_kind, + "thread_context": self._build_group_protocol_followup_thread_context( + topic=topic, + thread_context=thread_context, + ), + } binder = getattr(kernel, "_bind_active_context_target", None) if not callable(binder): @@ -126,10 +114,7 @@ class SignalCaptureEntryMixin: if not isinstance(active_target, tuple) or len(active_target) < 3: return None topic, _, active_context_kind = active_target - if not self._matches_active_group_protocol_followup( - active_context_kind=active_context_kind, - message_text=message_text, - ): + if str(active_context_kind or "").strip().lower() not in {"requirement_plan", "draft_pending_confirm"}: return None return { "active_context_kind": active_context_kind, @@ -139,28 +124,6 @@ class SignalCaptureEntryMixin: ), } - @classmethod - def _looks_like_group_protocol_followup_text( - cls, - *, - message_text: str, - ) -> bool: - try: - from ...ai_pmo.ai_pmo_typed_executor_execute.mixin_execute_query_project import ExecuteQueryProjectMixin - - if ExecuteQueryProjectMixin._parse_requirement_plan_choice_protocol(message_text) is not None: - return True - except Exception: - pass - try: - from ...ai_pmo.draft_confirm_handler import DraftConfirmHandler - - if DraftConfirmHandler.is_explicit_protocol_followup_text(message_text): - return True - except Exception: - pass - return False - @classmethod async def _should_process_feishu_high_accuracy( cls, @@ -301,9 +264,6 @@ class SignalCaptureEntryMixin: kernel = AIPMOKernelService(self.db, tenant_id=tenant_id) try: normalized_chat_type = str(chat_type or "").strip().lower() - protocol_like_followup = self._looks_like_group_protocol_followup_text( - message_text=cleaned_message, - ) should_sync_process = bool(force_sync) sync_reason = "force_sync" if should_sync_process else "" high_accuracy_bundle = None @@ -313,12 +273,20 @@ class SignalCaptureEntryMixin: ) protocol_followup_context = None + semantic_followup_probe = self._semantic_bundle_requests_group_followup( + high_accuracy_bundle.to_dict() if hasattr(high_accuracy_bundle, "to_dict") else high_accuracy_bundle + ) + has_explicit_reply_anchor = bool( + isinstance(thread_context, dict) + and str( + thread_context.get("reply_to_message_id") + or thread_context.get("root_message_id") + or "" + ).strip() + ) should_probe_group_protocol_followup = ( normalized_chat_type == "group" - and ( - (not has_explicit_group_trigger and str(sync_reason or "").strip().lower() in {"semantic:query_flow", "semantic:followup_flow"}) - or (has_explicit_group_trigger and protocol_like_followup) - ) + and (semantic_followup_probe or has_explicit_reply_anchor) ) if should_probe_group_protocol_followup: protocol_followup_context = await self._resolve_group_protocol_followup_without_explicit_trigger( @@ -329,6 +297,7 @@ class SignalCaptureEntryMixin: source_message_id=source_message_id, thread_context=thread_context, message_text=cleaned_message, + semantic_bundle=high_accuracy_bundle.to_dict() if hasattr(high_accuracy_bundle, "to_dict") else high_accuracy_bundle, ) if isinstance(protocol_followup_context, dict): should_sync_process = True @@ -340,10 +309,7 @@ class SignalCaptureEntryMixin: ) if normalized_thread_context: thread_context = normalized_thread_context - elif ( - not has_explicit_group_trigger - and str(sync_reason or "").strip().lower() in {"semantic:query_flow", "semantic:followup_flow"} - ): + elif not has_explicit_group_trigger: self._emit_ai_think( "capture_skip_group_explicit_trigger_required", trace_id=trace_id, @@ -362,6 +328,29 @@ class SignalCaptureEntryMixin: "direct_reply_required": False, "direct_reply_text": "", } + elif ( + normalized_chat_type == "group" + and not has_explicit_group_trigger + and str(sync_reason or "").strip().lower() in {"semantic:query_flow", "semantic:followup_flow"} + ): + self._emit_ai_think( + "capture_skip_group_explicit_trigger_required", + trace_id=trace_id, + reason=sync_reason or "semantic:query_or_followup_flow", + message_preview=cleaned_message[:120], + ) + return { + "status": "ignored", + "reason": "group_explicit_trigger_required", + "classification": "ignored", + "linked_project_ids": [], + "signal_id": None, + "created_project_id": None, + "auto_tasks": [], + "ai_trace_id": trace_id, + "direct_reply_required": False, + "direct_reply_text": "", + } enqueue_session = None if not should_sync_process: @@ -692,7 +681,7 @@ class SignalCaptureEntryMixin: } ], "direct_reply_required": True, - "direct_reply_text": "已收到,正在处理。", + "direct_reply_text": "⏳ 已完成入库,当前进入待确认队列。", "reply_template_key": "async_processing_ack", "work_item_id": None, "decision_level": None, diff --git a/backend/tests/test_adaptive_rag_external_followup_contract.py b/backend/tests/test_adaptive_rag_external_followup_contract.py index 9e2bbe06..9f609257 100644 --- a/backend/tests/test_adaptive_rag_external_followup_contract.py +++ b/backend/tests/test_adaptive_rag_external_followup_contract.py @@ -145,6 +145,42 @@ class _Harness( "confidence": 0.9, } + async def _resolve_pending_clarification_selection_semantics( + self, + *, + question: str, + payload: dict[str, Any], + ) -> dict[str, Any]: + text = str(question or "").strip() + normalized = text.replace(" ", "") + kind = str(payload.get("kind") or "").strip().lower() + if kind == "query_route_selection": + selected_key = "" + if "外部" in normalized or normalized.upper() == "A": + selected_key = "A" + elif "内部" in normalized or normalized.upper() == "B": + selected_key = "B" + return { + "selection_status": "resolved" if selected_key else "unresolved", + "selected_key": selected_key, + "selected_index": 0, + "confidence": 0.9 if selected_key else 0.0, + "reason": "test_double", + } + + selected_index = 0 + if any(token in normalized for token in ("第二", "第2", "2")): + selected_index = 2 + elif any(token in normalized for token in ("第一", "第1", "1")): + selected_index = 1 + return { + "selection_status": "resolved" if selected_index else "unresolved", + "selected_key": "", + "selected_index": selected_index, + "confidence": 0.9 if selected_index else 0.0, + "reason": "test_double", + } + @pytest.mark.asyncio async def test_external_conversation_key_creates_and_reuses_real_qa_session(db_session) -> None: @@ -386,7 +422,7 @@ async def test_pending_clarification_selection_restores_original_question(db_ses } await db_session.commit() - resolved = service._consume_pending_clarification_selection( + resolved = await service._consume_pending_clarification_selection( question="第二条", session_state=state, ) @@ -396,6 +432,72 @@ async def test_pending_clarification_selection_restores_original_question(db_ses assert resolved["effective_question"] == "我问你这个是哪个模型执行回复的?" +@pytest.mark.asyncio +async def test_selected_followup_clarification_anchor_binds_exact_qa_record(db_session) -> None: + service = _Harness(db_session) + now = now_cn_naive() + session = QASession( + tenant_id=1, + user_id=None, + title="新会话", + external_conversation_key="feishu:chat-1:root-1", + started_at=now, + last_message_at=now, + message_count=2, + ) + db_session.add(session) + await db_session.flush() + db_session.add_all( + [ + QARecord( + tenant_id=1, + question="帮我解释下系统架构", + answer="PMFlow 系统采用前后端分离架构。", + sources=[], + intent="question", + domains_used=[], + response_time_ms=10, + asked_by=None, + asked_via="feishu", + project_id=None, + session_id=int(session.id), + turn_index=1, + created_at=now - timedelta(minutes=1), + ), + QARecord( + tenant_id=1, + question="系统有什么模型?", + answer="当前有 low/high 两层模型路由。", + sources=[], + intent="question", + domains_used=[], + response_time_ms=12, + asked_by=None, + asked_via="feishu", + project_id=None, + session_id=int(session.id), + turn_index=2, + created_at=now, + ), + ] + ) + await db_session.commit() + + state = await service._ensure_session_state(session_id=int(session.id)) + resolution = await service._resolve_followup_anchor( + question="帮我继续说", + session_id=int(session.id), + context_session_id=int(session.id), + session_state=state, + selected_anchor_reply_id=2, + ) + + assert resolution.anchor_mode == "clarification_selection" + assert resolution.anchor_reply_id == 2 + assert resolution.anchor_reply is not None + assert resolution.anchor_reply["question"] == "系统有什么模型?" + + @pytest.mark.asyncio async def test_no_anchor_clarification_prefers_reply_or_object_id_guidance(db_session) -> None: service = _Harness(db_session) @@ -836,6 +938,191 @@ async def test_standalone_project_lookup_is_not_misclassified_as_followup_anchor assert resolution.candidate_replies == [] +@pytest.mark.asyncio +async def test_state_rewrite_governance_skips_rewrite_when_current_scope_switches_project( + db_session, + monkeypatch: pytest.MonkeyPatch, +) -> None: + service = _Harness(db_session) + now = now_cn_naive() + session = QASession( + tenant_id=1, + user_id=None, + title="旧项目", + external_conversation_key="feishu:chat-1:ou-1", + started_at=now, + last_message_at=now, + message_count=1, + ) + db_session.add(session) + await db_session.flush() + db_session.add( + QARecord( + tenant_id=1, + question="测试产能警告这个项目,如果只考虑外部信号源,你会优先推荐哪 3 类?", + answer="先看测试执行结果、发布记录、工单反馈。", + sources=[], + intent="question", + domains_used=[], + response_time_ms=12, + asked_by=None, + asked_via="feishu", + project_id=81, + session_id=int(session.id), + turn_index=1, + routing_snapshot={ + "query_semantics": { + "query_profile": {"referenced_objects": ["project_object"]}, + "target_resolution": { + "target_entity_type": "project", + "target_ref": "project:81", + "resolved_label": "测试产能警告", + }, + } + }, + created_at=now, + ) + ) + await db_session.commit() + + state = await service._ensure_session_state(session_id=int(session.id)) + state.last_subject = "围绕“测试产能警告这个项目,如果只考虑外部信号源...”回答" + await db_session.flush() + + async def _fake_query_semantics(**kwargs): + _ = kwargs + return { + "query_profile": {"referenced_objects": ["project_object"]}, + "source_policy": "internal", + "effective_source_policy": "internal", + } + + async def _fake_resolve_project_scope_for_question(**kwargs): + _ = kwargs + return (101, "AI客服", "AI客服") + + monkeypatch.setattr(service, "_resolve_query_semantics", _fake_query_semantics, raising=False) + monkeypatch.setattr( + service, + "_resolve_project_scope_for_question", + _fake_resolve_project_scope_for_question, + raising=False, + ) + + rewrite_governance = await service._build_state_rewrite_governance( + question="AI客服这个项目里,用户洞察数据源现在主要是拿来解决什么问题?", + project_id=None, + session_id=int(session.id), + source_policy_hint=None, + query_semantics=None, + session_state=state, + anchor_resolution=FollowupAnchorResolution(followup_mode="referential"), + ) + + assert rewrite_governance["allow_state_rewrite"] is False + assert rewrite_governance["reason"] == "new_project_scope" + assert rewrite_governance["current_scope"]["project_id"] == 101 + assert rewrite_governance["previous_scope"]["project_id"] == 81 + + rewritten, rewrite_meta = service._rewrite_question_with_state( + question="AI客服这个项目里,用户洞察数据源现在主要是拿来解决什么问题?", + conversation=[], + session_state=state, + anchor_resolution=FollowupAnchorResolution(followup_mode="referential"), + rewrite_governance=rewrite_governance, + ) + + assert rewritten == "AI客服这个项目里,用户洞察数据源现在主要是拿来解决什么问题?" + assert rewrite_meta["mode"] == "scope_reset" + assert rewrite_meta["reason"] == "new_project_scope" + + +@pytest.mark.asyncio +async def test_state_rewrite_governance_skips_rewrite_when_current_scope_switches_business_target( + db_session, + monkeypatch: pytest.MonkeyPatch, +) -> None: + service = _Harness(db_session) + now = now_cn_naive() + session = QASession( + tenant_id=1, + user_id=None, + title="同项目旧业务", + external_conversation_key="feishu:chat-1:ou-1", + started_at=now, + last_message_at=now, + message_count=1, + ) + db_session.add(session) + await db_session.flush() + db_session.add( + QARecord( + tenant_id=1, + question="测试产能警告这个项目里,告警接收人现在是谁?", + answer="当前还没定义明确接收人。", + sources=[], + intent="question", + domains_used=[], + response_time_ms=12, + asked_by=None, + asked_via="feishu", + project_id=81, + session_id=int(session.id), + turn_index=1, + routing_snapshot={ + "query_semantics": { + "query_profile": {"referenced_objects": ["project_object", "signal_object"]}, + "target_resolution": { + "target_entity_type": "signal", + "target_ref": "signal:alert_receiver", + "resolved_label": "告警接收人", + }, + } + }, + created_at=now, + ) + ) + await db_session.commit() + + state = await service._ensure_session_state(session_id=int(session.id)) + state.last_subject = "测试产能警告这个项目里,告警接收人现在是谁?" + await db_session.flush() + + async def _fake_resolve_project_scope_for_question(**kwargs): + _ = kwargs + return (81, "测试产能警告", "测试产能警告") + + monkeypatch.setattr( + service, + "_resolve_project_scope_for_question", + _fake_resolve_project_scope_for_question, + raising=False, + ) + + rewrite_governance = await service._build_state_rewrite_governance( + question="测试产能警告这个项目里,用户洞察数据源现在主要是拿来解决什么问题?", + project_id=None, + session_id=int(session.id), + source_policy_hint=None, + query_semantics={ + "query_profile": {"referenced_objects": ["project_object", "signal_object"]}, + "target_resolution": { + "target_entity_type": "signal", + "target_ref": "signal:user_insight_source", + "resolved_label": "用户洞察数据源", + }, + }, + session_state=state, + anchor_resolution=FollowupAnchorResolution(followup_mode="referential"), + ) + + assert rewrite_governance["allow_state_rewrite"] is False + assert rewrite_governance["reason"] == "new_business_scope" + assert rewrite_governance["current_scope"]["project_id"] == 81 + assert rewrite_governance["current_scope"]["target_ref"] == "signal:user_insight_source" + assert rewrite_governance["previous_scope"]["target_ref"] == "signal:alert_receiver" + + @pytest.mark.asyncio async def test_meta_followup_model_identity_reads_routing_snapshot_and_hides_real_model(db_session) -> None: service = _Harness(db_session) diff --git a/backend/tests/test_adaptive_rag_mainline_contract.py b/backend/tests/test_adaptive_rag_mainline_contract.py index 4bdb358a..13b1fb64 100644 --- a/backend/tests/test_adaptive_rag_mainline_contract.py +++ b/backend/tests/test_adaptive_rag_mainline_contract.py @@ -1,5 +1,7 @@ +import json import pytest import time +from datetime import datetime from types import SimpleNamespace from app.config.ai_prompts import PROMPT_AI_PMO_QA, PROMPT_RAG_QUERY_SEMANTICS @@ -15,6 +17,12 @@ from app.services.adaptive_rag_service.mixin_core_intent import AdaptiveRAGCoreI from app.services.adaptive_rag_service.mixin_deterministic_query_core import ( AdaptiveRAGDeterministicQueryCoreMixin, ) +from app.services.adaptive_rag_service.mixin_deterministic_query_count import ( + AdaptiveRAGDeterministicQueryCountMixin, +) +from app.services.adaptive_rag_service.mixin_deterministic_query_list import ( + AdaptiveRAGDeterministicQueryListMixin, +) from app.services.adaptive_rag_service.mixin_manifest_feature_p2 import AdaptiveRAGManifestFeatureMixinP2 from app.services.adaptive_rag_service.mixin_manifest_feature_p1 import AdaptiveRAGManifestFeatureMixinP1 from app.services.adaptive_rag_service.mixin_multistep_core import AdaptiveRAGMultiStepCoreMixin @@ -217,6 +225,34 @@ async def test_answer_entry_mixed_request_skips_requirement_ack_gate() -> None: assert service.base_kwargs["deep_thinking"] is False +@pytest.mark.asyncio +async def test_fast_project_progress_answer_skips_signal_source_inventory_questions() -> None: + class _FakeService(AdaptiveRAGFastAnswerProjectMainMixin): + async def _resolve_project_scope_for_question(self, **kwargs): # noqa: ANN001 + _ = kwargs + return 81, "测试产能警告", "测试产能警告" + + async def _build_project_progress_snapshot(self, **kwargs): # noqa: ANN001 + _ = kwargs + raise AssertionError("signal source inventory question should bypass project progress fast answer") + + service = _FakeService() + result = await service._try_fast_project_progress_answer( + clean_q="测试产能警告这个项目现在是什么情况?", + project_id=81, + query_semantics={ + "status_focus": "progress", + "subject_focus": "project", + "interaction_level": "recommendation", + "goal_kind": "recommendation", + "resource_role": "signal_source", + "expected_answer_shape": "inventory", + }, + ) + + assert result is None + + @pytest.mark.asyncio async def test_answer_entry_feishu_write_mode_still_goes_base_mainline() -> None: service = _FakeAdaptiveRAGService() @@ -368,9 +404,12 @@ def test_internal_query_plan_builds_project_existence_contract() -> None: project_id=None, query_semantics={ "query_type": "existence", - "subject_focus": "project", "scope_hint": "global", "effective_source_policy": "internal", + "goal_kind": "status_lookup", + "target_scope": "current_project", + "resource_role": "project_object", + "project_binding": 17, }, ) @@ -382,6 +421,50 @@ def test_internal_query_plan_builds_project_existence_contract() -> None: assert plan["filters"] == [] +def test_internal_query_plan_builds_project_inventory_contract_for_recipe_system_inputs_question() -> None: + class _FakePlannerService( + AdaptiveRAGAnswerMainlineMixin, + AdaptiveRAGFastAnswerProjectUtilsMixin, + ): + pass + + service = _FakePlannerService() + plan = service._build_internal_query_plan( + question="健身食谱推荐系统现在主要靠哪些输入在跑?", + project_id=None, + query_semantics={ + "query_type": "status", + "scope_hint": "project", + "effective_source_policy": "internal", + "goal_kind": "status_lookup", + "target_scope": "explicit_project", + "resource_role": "project_object", + "expected_answer_shape": "inventory", + "project_binding": 31, + }, + target_resolution={ + "target_entity_type": "project", + "target_ref": "project:31", + "resolution_mode": "deterministic", + "confidence": 0.95, + }, + ) + + assert plan is not None + assert plan["query_type"] == "status" + assert plan["entity_type"] == "project" + assert plan["answer_contract"] == "entity_snapshot" + assert plan["target_ref"] == "project:31" + assert plan["filters"] == [ + { + "field": "project_id", + "operator": "equals", + "value": 31, + "value_source": "target_resolution", + } + ] + + def test_operation_contract_preserves_structured_existence_before_target_resolution() -> None: class _FakeService( AdaptiveRAGTargetResolutionMixin, @@ -404,7 +487,7 @@ def test_operation_contract_preserves_structured_existence_before_target_resolut assert contract["object_mentions"] == ["开票系统"] -def test_operation_contract_promotes_model_config_reference_to_status() -> None: +def test_operation_contract_does_not_promote_model_config_reference_from_referenced_objects_only() -> None: class _FakeService( AdaptiveRAGTargetResolutionMixin, AdaptiveRAGAnswerMainlineMixin, @@ -424,9 +507,34 @@ def test_operation_contract_promotes_model_config_reference_to_status() -> None: }, ) + assert contract["query_type"] == "existence" + assert contract["answer_contract"] == "yes_no_with_evidence" + assert contract["object_mentions"] == ["模型配置"] + + +def test_operation_contract_promotes_business_frame_config_role_to_status_without_object_list() -> None: + class _FakeService( + AdaptiveRAGTargetResolutionMixin, + AdaptiveRAGAnswerMainlineMixin, + ): + pass + + service = _FakeService() + contract = service._build_operation_contract( + question="当前模型配置是啥?", + query_semantics={ + "query_type": "unknown", + "effective_source_policy": "internal", + "object_mentions": ["当前模型配置"], + "resource_role": "config", + "goal_kind": "status_lookup", + "expected_answer_shape": "bounded_explanation", + }, + ) + assert contract["query_type"] == "status" assert contract["answer_contract"] == "entity_snapshot" - assert contract["object_mentions"] == ["模型配置"] + assert contract["resource_role"] == "config" @pytest.mark.asyncio @@ -475,7 +583,98 @@ async def test_internal_target_resolution_prefers_project_candidate_for_existenc @pytest.mark.asyncio -async def test_internal_target_resolution_prefers_session_followup_project_binding_before_semantic_filter() -> None: +async def test_internal_target_resolution_prefers_session_followup_project_binding_from_business_frame() -> None: + class _FakeService(AdaptiveRAGTargetResolutionMixin): + def __init__(self) -> None: + self.db = object() + self.tenant_id = 1 + + async def _collect_internal_target_candidates(self, **kwargs): + _ = kwargs + return [] + + service = _FakeService() + resolution = await service._resolve_internal_target( + question="继续围绕刚才那个项目说。", + project_id=None, + query_semantics={ + "effective_source_policy": "internal", + "goal_kind": "bounded_explanation", + "target_scope": "current_project", + "resource_role": "project_object", + "expected_answer_shape": "bounded_explanation", + "project_binding": 123, + "session_followup_context": { + "rewrite_mode": "state_subject", + "last_subject": "采购经理每日实时库存上报服务", + "last_entity_ids": [123], + }, + }, + system_snapshot={}, + operation_contract={ + "query_type": "status", + "answer_contract": "status_summary", + "object_mentions": [], + "goal_kind": "bounded_explanation", + "target_scope": "current_project", + "resource_role": "project_object", + "project_binding": 123, + }, + ) + + assert resolution["target_entity_type"] == "project" + assert resolution["target_ref"] == "project:123" + assert resolution["resolved_label"] == "采购经理每日实时库存上报服务" + assert resolution["binding_source"] == "session_followup_context" + + +@pytest.mark.asyncio +async def test_internal_target_resolution_keeps_signal_domain_for_project_scoped_followup() -> None: + class _FakeService(AdaptiveRAGTargetResolutionMixin): + def __init__(self) -> None: + self.db = object() + self.tenant_id = 1 + + async def _collect_internal_target_candidates(self, **kwargs): + _ = kwargs + return [] + + service = _FakeService() + resolution = await service._resolve_internal_target( + question="继续看刚才那个项目里的信号。", + project_id=None, + query_semantics={ + "effective_source_policy": "internal", + "goal_kind": "status_lookup", + "target_scope": "current_project", + "resource_role": "signal", + "expected_answer_shape": "inventory", + "project_binding": 123, + "session_followup_context": { + "rewrite_mode": "state_subject", + "last_subject": "采购经理每日实时库存上报服务", + "last_entity_ids": [123], + }, + }, + system_snapshot={}, + operation_contract={ + "query_type": "list", + "answer_contract": "entity_list", + "object_mentions": [], + "goal_kind": "status_lookup", + "target_scope": "current_project", + "resource_role": "signal", + "project_binding": 123, + }, + ) + + assert resolution["target_entity_type"] == "signal" + assert resolution["target_ref"] == "domain:signal" + assert resolution["binding_source"] == "session_followup_context" + + +@pytest.mark.asyncio +async def test_internal_target_resolution_requires_business_frame_for_session_followup_project_binding() -> None: class _FakeService(AdaptiveRAGTargetResolutionMixin): def __init__(self) -> None: self.db = object() @@ -511,14 +710,12 @@ async def test_internal_target_resolution_prefers_session_followup_project_bindi }, ) - assert resolution["target_entity_type"] == "project" - assert resolution["target_ref"] == "project:123" - assert resolution["resolved_label"] == "采购经理每日实时库存上报服务" - assert resolution["resolution_mode"] == "deterministic" - assert resolution["binding_source"] == "session_followup_context" + assert resolution["target_entity_type"] == "unknown" + assert resolution["target_ref"] is None + assert resolution["resolution_mode"] == "clarify" -def test_config_target_candidates_require_structured_referenced_objects() -> None: +def test_config_target_candidates_ignore_referenced_objects_without_business_frame() -> None: class _FakeService(AdaptiveRAGTargetResolutionMixin): pass @@ -534,11 +731,400 @@ def test_config_target_candidates_require_structured_referenced_objects() -> Non }, ) + assert rows == [] + + +def test_config_target_candidates_expand_catalog_for_generic_config_domain() -> None: + class _FakeService(AdaptiveRAGTargetResolutionMixin): + pass + + service = _FakeService() + rows = service._collect_config_target_candidates( + query_semantics={ + "resource_role": "config", + "goal_kind": "status_lookup", + }, + operation_contract={ + "object_mentions": ["当前模型配置"], + "resource_role": "config", + }, + ) + + assert {str(item.get("target_ref") or "") for item in rows} == { + "config:model_routing", + "config:mcp_template_center", + } + + +@pytest.mark.asyncio +async def test_resolve_internal_target_returns_clarify_for_generic_config_domain_catalog() -> None: + class _FakeService(AdaptiveRAGTargetResolutionMixin): + def __init__(self) -> None: + self.tenant_id = 1 + + async def _collect_semantic_fact_target_candidates(self, **kwargs): + _ = kwargs + return [] + + async def _collect_project_target_candidates(self, **kwargs): + _ = kwargs + return [] + + async def _collect_kg_target_candidates(self, **kwargs): + _ = kwargs + return [] + + service = _FakeService() + resolution = await service._resolve_internal_target( + question="系统里现在有哪些配置?", + project_id=None, + query_semantics={ + "effective_source_policy": "internal", + "business_frame": { + "goal_kind": "status_lookup", + "target_scope": "global", + "resource_role": "config", + "source_kind": "internal", + "expected_answer_shape": "bounded_explanation", + "conversation_posture": "business_operating", + }, + }, + system_snapshot={}, + operation_contract={ + "query_type": "status", + "answer_contract": "entity_snapshot", + "object_mentions": ["系统配置"], + }, + ) + + assert resolution["resolution_mode"] == "clarify" + assert {str(item.get("target_ref") or "") for item in list(resolution.get("candidate_summary") or [])} == { + "config:model_routing", + "config:mcp_template_center", + } + + +def test_config_target_candidates_accept_explicit_model_routing_object_binding() -> None: + class _FakeService(AdaptiveRAGTargetResolutionMixin): + pass + + service = _FakeService() + rows = service._collect_config_target_candidates( + query_semantics={ + "business_frame": { + "goal_kind": "status_lookup", + "target_scope": "global", + "resource_role": "config", + "source_kind": "internal", + "expected_answer_shape": "bounded_explanation", + "conversation_posture": "business_operating", + "object_binding": "config:model_routing", + } + }, + operation_contract={ + "object_mentions": ["当前模型配置"], + }, + ) + assert len(rows) == 1 assert rows[0]["target_ref"] == "config:model_routing" assert rows[0]["resolved_label"] == "当前模型配置" +def test_config_target_candidates_accept_explicit_mcp_config_object_binding() -> None: + class _FakeService(AdaptiveRAGTargetResolutionMixin): + pass + + service = _FakeService() + rows = service._collect_config_target_candidates( + query_semantics={ + "business_frame": { + "goal_kind": "status_lookup", + "target_scope": "global", + "resource_role": "config", + "source_kind": "internal", + "expected_answer_shape": "bounded_explanation", + "conversation_posture": "business_operating", + "object_binding": "config:mcp_template_center", + } + }, + operation_contract={ + "object_mentions": ["MCP 配置"], + }, + ) + + assert len(rows) == 1 + assert rows[0]["target_ref"] == "config:mcp_template_center" + assert rows[0]["resolved_label"] == "MCP 配置" + + +def test_internal_answer_contract_guard_payload_builds_internal_target_selection_protocol() -> None: + class _FakeService(AdaptiveRAGAnswerPayloadMixin): + pass + + service = _FakeService() + payload = service._build_internal_answer_contract_guard_payload( + query_type="status", + reason_code="target_resolution_clarify", + missing_fields=["target_resolution"], + target_resolution={ + "target_entity_type": "unknown", + "target_ref": None, + "resolved_label": None, + "resolution_mode": "clarify", + "candidate_summary": [ + { + "target_entity_type": "config", + "target_ref": "config:model_routing", + "resolved_label": "模型路由配置", + "score": 0.88, + "source_kind": "system_config", + }, + { + "target_entity_type": "config", + "target_ref": "config:mcp_template_center", + "resolved_label": "MCP 配置", + "score": 0.87, + "source_kind": "system_config", + }, + ], + }, + ) + + assert payload["answer_mode"] == "clarify" + assert "模型路由配置" in str(payload["answer"]) + assert "MCP 配置" in str(payload["answer"]) + assert payload["clarification_protocol"]["kind"] == "internal_target_selection" + assert payload["clarification_protocol"]["option_map"] == { + "A": "config:model_routing", + "B": "config:mcp_template_center", + } + + +def test_internal_answer_contract_guard_payload_builds_generic_internal_target_selection_protocol() -> None: + class _FakeService(AdaptiveRAGAnswerPayloadMixin): + pass + + service = _FakeService() + payload = service._build_internal_answer_contract_guard_payload( + query_type="status", + reason_code="target_resolution_clarify", + missing_fields=["target_resolution"], + target_resolution={ + "target_entity_type": "unknown", + "target_ref": None, + "resolved_label": None, + "resolution_mode": "clarify", + "candidate_summary": [ + { + "target_entity_type": "project", + "target_ref": "project:12", + "resolved_label": "智能现金流管家", + "score": 0.88, + "source_kind": "project_db", + }, + { + "target_entity_type": "feature", + "target_ref": "feature:cash_forecast", + "resolved_label": "现金流预测模块", + "score": 0.84, + "source_kind": "feature_catalog", + }, + ], + }, + ) + + assert "智能现金流管家" in str(payload["answer"]) + assert "现金流预测模块" in str(payload["answer"]) + options = list(payload["clarification_protocol"]["options"]) + assert options[0]["query_semantics_overrides"]["business_frame"]["project_binding"] == 12 + assert options[1]["query_semantics_overrides"]["business_frame"]["object_binding"] == "feature:cash_forecast" + + +def test_semantic_filter_target_uses_business_frame_project_binding_without_subject_focus() -> None: + class _FakeService(AdaptiveRAGTargetResolutionMixin): + pass + + service = _FakeService() + target = service._build_semantic_filter_target( + query_semantics={ + "goal_kind": "status_lookup", + "target_scope": "current_project", + "resource_role": "project_object", + "project_binding": 81, + }, + operation_contract={ + "query_type": "status", + "answer_contract": "status_summary", + "object_mentions": [], + "project_binding": 81, + "resource_role": "project_object", + }, + ) + + assert target is not None + assert target["target_entity_type"] == "project" + assert target["target_ref"] == "project:81" + assert target["resolved_label"] is None + assert target["resolution_mode"] == "semantic_filter" + + +def test_semantic_filter_target_uses_task_role_without_collapsing_to_project() -> None: + class _FakeService(AdaptiveRAGTargetResolutionMixin): + pass + + service = _FakeService() + target = service._build_semantic_filter_target( + query_semantics={ + "goal_kind": "status_lookup", + "target_scope": "current_project", + "resource_role": "task", + "project_binding": 81, + "expected_answer_shape": "inventory", + }, + operation_contract={ + "query_type": "list", + "answer_contract": "entity_list", + "object_mentions": [], + "project_binding": 81, + "resource_role": "task", + }, + ) + + assert target is not None + assert target["target_entity_type"] == "task" + assert target["target_ref"] == "domain:task" + assert target["resolution_mode"] == "semantic_filter" + + +def test_semantic_filter_target_uses_signal_role_without_collapsing_to_project() -> None: + class _FakeService(AdaptiveRAGTargetResolutionMixin): + pass + + service = _FakeService() + target = service._build_semantic_filter_target( + query_semantics={ + "goal_kind": "status_lookup", + "target_scope": "current_project", + "resource_role": "signal", + "project_binding": 81, + "expected_answer_shape": "inventory", + }, + operation_contract={ + "query_type": "list", + "answer_contract": "entity_list", + "object_mentions": [], + "project_binding": 81, + "resource_role": "signal", + }, + ) + + assert target is not None + assert target["target_entity_type"] == "signal" + assert target["target_ref"] == "domain:signal" + assert target["resolution_mode"] == "semantic_filter" + + +def test_semantic_filter_target_uses_people_role_as_user_domain() -> None: + class _FakeService(AdaptiveRAGTargetResolutionMixin): + pass + + service = _FakeService() + target = service._build_semantic_filter_target( + query_semantics={ + "goal_kind": "status_lookup", + "target_scope": "global", + "resource_role": "people", + "expected_answer_shape": "inventory", + }, + operation_contract={ + "query_type": "list", + "answer_contract": "entity_list", + "object_mentions": [], + "resource_role": "people", + }, + ) + + assert target is not None + assert target["target_entity_type"] == "user" + assert target["target_ref"] == "domain:user" + assert target["resolution_mode"] == "semantic_filter" + + +def test_semantic_filter_target_uses_doc_role_as_doc_domain() -> None: + class _FakeService(AdaptiveRAGTargetResolutionMixin): + pass + + service = _FakeService() + target = service._build_semantic_filter_target( + query_semantics={ + "goal_kind": "status_lookup", + "target_scope": "global", + "resource_role": "doc", + "expected_answer_shape": "inventory", + }, + operation_contract={ + "query_type": "list", + "answer_contract": "entity_list", + "object_mentions": [], + "resource_role": "doc", + }, + ) + + assert target is not None + assert target["target_entity_type"] == "doc" + assert target["target_ref"] == "domain:doc" + assert target["resolution_mode"] == "semantic_filter" + + +def test_semantic_filter_target_uses_feature_role_without_explicit_object_binding() -> None: + class _FakeService(AdaptiveRAGTargetResolutionMixin): + pass + + service = _FakeService() + target = service._build_semantic_filter_target( + query_semantics={ + "goal_kind": "bounded_explanation", + "target_scope": "global", + "resource_role": "feature", + "expected_answer_shape": "bounded_explanation", + }, + operation_contract={ + "query_type": "explain", + "answer_contract": "explanation_with_citations", + "object_mentions": [], + "resource_role": "feature", + }, + ) + + assert target is not None + assert target["target_entity_type"] == "feature" + assert target["target_ref"] == "domain:feature" + assert target["resolution_mode"] == "semantic_filter" + + +def test_semantic_filter_target_ignores_subject_focus_without_business_frame_binding() -> None: + class _FakeService(AdaptiveRAGTargetResolutionMixin): + pass + + service = _FakeService() + target = service._build_semantic_filter_target( + query_semantics={ + "subject_focus": "project", + "query_profile": { + "referenced_objects": ["project_object"], + }, + }, + operation_contract={ + "query_type": "status", + "answer_contract": "status_summary", + "object_mentions": ["自动开票系统"], + }, + ) + + assert target is None + + def test_internal_query_runtime_metrics_include_contract_and_resolution_summaries() -> None: class _FakeService(AdaptiveRAGTargetResolutionMixin): pass @@ -766,9 +1352,12 @@ async def test_deterministic_query_defers_internal_project_existence_to_semantic project_id=None, query_type="existence", query_semantics={ - "subject_focus": "project", "source_policy": "internal", "effective_source_policy": "internal", + "goal_kind": "status_lookup", + "target_scope": "current_project", + "resource_role": "project_object", + "project_binding": 123, "operation_contract": { "query_type": "existence", "answer_contract": "yes_no_with_evidence", @@ -865,8 +1454,13 @@ async def test_project_existence_answer_uses_structured_mentions_without_lexical project_id=None, query_semantics={ "query_type": "existence", - "subject_focus": "project", "scope_hint": "global", + "business_frame": { + "goal_kind": "status_lookup", + "target_scope": "global", + "resource_role": "project_object", + "expected_answer_shape": "inventory", + }, "operation_contract": { "query_type": "existence", "object_mentions": ["A股项目"], @@ -1075,9 +1669,9 @@ async def test_query_semantics_prefers_structured_model_output_over_hint_tables( assert semantics["query_type"] == "list" assert semantics["retrieval_domains"] == ["db", "graph"] assert semantics["intent"] == "question" - assert semantics["subject_focus"] == "project" assert semantics["scope_hint"] == "global" assert semantics["feature_focus"] == "none" + assert "subject_focus" not in semantics @pytest.mark.asyncio @@ -1139,8 +1733,8 @@ async def test_query_semantics_accepts_existence_query_type_from_structured_outp assert semantics["query_type"] == "existence" assert semantics["object_mentions"] == ["A股项目"] assert semantics["retrieval_domains"] == ["db", "graph"] - assert semantics["query_profile"]["referenced_objects"] == ["project_object"] assert semantics["query_profile"]["needs_private_context"] is True + assert "referenced_objects" not in semantics["query_profile"] @pytest.mark.asyncio @@ -2522,9 +3116,11 @@ async def test_structured_db_retrieval_prefers_structured_people_focus_without_k "他们都有哪些人?", project_id=None, query_semantics={ - "subject_focus": "people", "scope_hint": "global", "feature_focus": "none", + "goal_kind": "status_lookup", + "resource_role": "people", + "expected_answer_shape": "inventory", }, ) @@ -2533,6 +3129,113 @@ async def test_structured_db_retrieval_prefers_structured_people_focus_without_k assert "杨宇" in rows[0].content +@pytest.mark.asyncio +async def test_count_query_prefers_structured_signal_role_without_subject_focus() -> None: + class _ScalarResult: + def __init__(self, value): + self._value = value + + def scalar(self): + return self._value + + class _FakeDB: + async def execute(self, stmt): # noqa: ANN001 + _ = stmt + return _ScalarResult(3) + + class _FakeService(AdaptiveRAGDeterministicQueryCountMixin): + def __init__(self) -> None: + self.db = _FakeDB() + self.tenant_id = 1 + + def _query_scope_hint(self, query_semantics): # noqa: ANN001 + return str((query_semantics or {}).get("scope_hint") or "none") + + async def _resolve_project_scope_for_question(self, **kwargs): # noqa: ANN001 + _ = kwargs + return None, None, "" + + service = _FakeService() + result = await service._run_count_query( + question="现在有多少信号?", + project_id=None, + query_semantics={ + "goal_kind": "status_lookup", + "resource_role": "signal", + "expected_answer_shape": "inventory", + "scope_hint": "global", + }, + ) + + assert result is not None + assert "信号总数为 3 条" in str(result["answer"]) + + +@pytest.mark.asyncio +async def test_list_query_prefers_structured_signal_role_without_subject_focus() -> None: + class _SignalResult: + def __init__(self, rows): + self._rows = rows + + def scalars(self): + return self + + def all(self): + return self._rows + + class _FakeDB: + async def execute(self, stmt): # noqa: ANN001 + _ = stmt + return _SignalResult( + [ + SimpleNamespace(id=1, title="开票失败", summary="开票失败告警", status="open", updated_at=None), + SimpleNamespace(id=2, title="发送失败", summary="邮件发送失败", status="pending", updated_at=None), + ] + ) + + class _FakeService(AdaptiveRAGDeterministicQueryListMixin): + def __init__(self) -> None: + self.db = _FakeDB() + self.tenant_id = 1 + + def _query_scope_hint(self, query_semantics): # noqa: ANN001 + return str((query_semantics or {}).get("scope_hint") or "none") + + async def _resolve_project_scope_for_question(self, **kwargs): # noqa: ANN001 + _ = kwargs + return None, None, "" + + service = _FakeService() + result = await service._run_list_query( + question="把现在的信号列出来", + project_id=None, + query_semantics={ + "goal_kind": "status_lookup", + "resource_role": "signal", + "expected_answer_shape": "inventory", + "scope_hint": "global", + }, + ) + + assert result is not None + assert "可列举的信号" not in str(result["answer"]) + assert "开票失败" in str(result["answer"]) + + +def test_project_status_subject_prefers_structured_project_binding_without_subject_focus() -> None: + class _FakeService(AdaptiveRAGFastAnswerProjectMainMixin): + pass + + assert _FakeService._is_project_status_subject( + { + "goal_kind": "status_lookup", + "target_scope": "current_project", + "resource_role": "project_object", + "project_binding": 81, + } + ) + + def test_feature_evidence_prefers_structured_scene_without_keyword_hints() -> None: class _FakeService(AdaptiveRAGManifestFeatureMixinP2): def _query_subject_focus(self, query_semantics): # noqa: ANN001 @@ -3355,9 +4058,9 @@ async def test_query_semantics_respects_upstream_explicit_external_contract( assert semantics["effective_source_policy"] == "external" assert semantics["retrieval_domains"] == ["url"] assert semantics["query_profile"]["needs_freshness"] is True - assert semantics["query_profile"]["referenced_objects"] == ["weather"] assert semantics["decision_origin"] == "query_semantics_contract" assert semantics["source_reason"] == "query_semantics_contract" + assert "referenced_objects" not in semantics["query_profile"] @pytest.mark.asyncio @@ -3418,9 +4121,9 @@ async def test_query_semantics_keeps_uncertain_contract_uncertain_without_defaul assert semantics["effective_source_policy"] == "uncertain" assert semantics["retrieval_domains"] == ["db", "pmflow"] assert semantics["query_profile"]["needs_freshness"] is True - assert semantics["query_profile"]["referenced_objects"] == ["public_model"] assert semantics["decision_origin"] == "query_semantics_contract" assert semantics["source_reason"] == "query_semantics_contract" + assert "referenced_objects" not in semantics["query_profile"] @pytest.mark.asyncio @@ -3543,8 +4246,8 @@ async def test_query_semantics_preserves_internal_runtime_query_profile_for_runt assert semantics["effective_source_policy"] == "internal" assert semantics["retrieval_domains"] == ["db", "pmflow"] assert semantics["query_profile"]["needs_private_context"] is True - assert semantics["query_profile"]["referenced_objects"] == ["system_config", "model_routing"] assert semantics["decision_origin"] == "query_semantics_contract" + assert "referenced_objects" not in semantics["query_profile"] @pytest.mark.asyncio @@ -3607,14 +4310,15 @@ async def test_internal_model_config_fast_answer_reads_runtime_engine_bundle() - query_semantics={ "effective_source_policy": "internal", "query_type": "status", + "business_frame": { + "goal_kind": "status_lookup", + "target_scope": "global", + "resource_role": "config", + "expected_answer_shape": "bounded_explanation", + }, "operation_contract": { "query_type": "status", }, - "query_profile": { - "needs_private_context": True, - "needs_freshness": False, - "referenced_objects": ["system_config", "model_routing"], - }, "target_resolution": { "target_entity_type": "config", "target_ref": "config:model_routing", @@ -3633,6 +4337,119 @@ async def test_internal_model_config_fast_answer_reads_runtime_engine_bundle() - assert "Integration[model_routing:adaptive_rag_answer_normal]" in labels +@pytest.mark.asyncio +async def test_internal_model_config_fast_answer_returns_mcp_config_summary_for_mcp_target() -> None: + class _FakeService(AdaptiveRAGDeepGuardAnswerMixin): + def __init__(self) -> None: + self.db = object() + self.tenant_id = 1 + self.llm = object() + + class _FakeMcpConfigService: + def __init__(self, db, *, tenant_id: int): + _ = (db, tenant_id) + + async def list_servers(self) -> list[dict[str, object]]: + return [ + { + "id": "mcp_01", + "name": "microsoft_learn_prod", + "display_name": "Microsoft Learn", + "template_id": "microsoft_learn", + }, + { + "id": "mcp_02", + "name": "duckduckgo_backup", + "display_name": "DuckDuckGo", + "template_id": "duckduckgo", + }, + ] + + import app.services.adaptive_rag_service.mixin_deep_guard_answer as deep_guard_module + + original_service = getattr(deep_guard_module, "McpConfigService", None) + deep_guard_module.McpConfigService = _FakeMcpConfigService + try: + service = _FakeService() + packet = InternalContextPacket.model_validate( + { + "packet_version": "v1", + "question": "当前系统有没有 MCP 配置?", + "scope": { + "tenant_id": 1, + "project_id": None, + "session_id": 11, + "conversation_scope_key": "scope:mcp:1", + "reply_to_message_id": None, + "root_message_id": None, + }, + "route_decision": { + "route_mode": "internal", + "effective_source_policy": "internal", + }, + "operation_contract": { + "query_type": "status", + "answer_contract": "entity_snapshot", + }, + "target_resolution": { + "target_entity_type": "config", + "target_ref": "config:mcp_template_center", + "resolution_mode": "deterministic", + "confidence": 0.98, + }, + "query_plan": { + "plan_version": "v2", + "query_type": "status", + "entity_type": "config", + "answer_contract": "entity_snapshot", + "target_ref": "config:mcp_template_center", + "filters": [], + }, + "executor_summary": {}, + "verifier_summary": {}, + "uncertainties": [], + } + ) + fast_result = await service._build_internal_model_config_fast_answer( + question="当前系统有没有 MCP 配置?", + query_semantics={ + "effective_source_policy": "internal", + "query_type": "status", + "business_frame": { + "goal_kind": "status_lookup", + "target_scope": "global", + "resource_role": "config", + "source_kind": "internal", + "expected_answer_shape": "bounded_explanation", + "conversation_posture": "business_operating", + "object_binding": "config:mcp_template_center", + "evidence_mode": "verified", + }, + "operation_contract": { + "query_type": "status", + }, + "target_resolution": { + "target_entity_type": "config", + "target_ref": "config:mcp_template_center", + }, + }, + internal_context_packet=packet, + ) + finally: + if original_service is None: + delattr(deep_guard_module, "McpConfigService") + else: + deep_guard_module.McpConfigService = original_service + + assert isinstance(fast_result, dict) + answer = str(fast_result["answer"] or "") + assert "当前系统里已有 2 个 MCP 配置" in answer + assert "Microsoft Learn" in answer + assert "DuckDuckGo" in answer + labels = [str(item.get("label") or "") for item in list(fast_result.get("sources") or [])] + assert "Integration[mcp_template_center]" in labels + + @pytest.mark.asyncio async def test_internal_model_config_fast_answer_skips_non_config_target_resolution() -> None: class _FakeLLM: @@ -3657,14 +4474,15 @@ async def test_internal_model_config_fast_answer_skips_non_config_target_resolut query_semantics={ "effective_source_policy": "internal", "query_type": "existence", + "business_frame": { + "goal_kind": "status_lookup", + "target_scope": "current_project", + "resource_role": "project_object", + "project_binding": 72, + }, "operation_contract": { "query_type": "existence", }, - "query_profile": { - "needs_private_context": True, - "needs_freshness": False, - "referenced_objects": ["system_config", "model_routing"], - }, "target_resolution": { "target_entity_type": "project", "target_ref": "project:72", @@ -3675,6 +4493,165 @@ async def test_internal_model_config_fast_answer_skips_non_config_target_resolut assert fast_result is None +@pytest.mark.asyncio +async def test_internal_model_config_fast_answer_ignores_referenced_objects_without_structured_config_role() -> None: + class _FakeLLM: + async def _get_or_init_bundle(self, db, *, tenant_id: int): # pragma: no cover - should not be called + raise AssertionError(f"bundle lookup should be skipped: {db}, {tenant_id}") + + def _resolve_active_engine_from_bundle(self, bundle: dict[str, object]): # pragma: no cover - should not be called + raise AssertionError(f"active engine should be skipped: {bundle}") + + async def _resolve_engine_for_call(self, db, *, tenant_id: int, function_name: str, routing_meta=None): # pragma: no cover - should not be called + raise AssertionError(f"engine lookup should be skipped: {function_name}, {routing_meta}") + + class _FakeService(AdaptiveRAGDeepGuardAnswerMixin): + def __init__(self) -> None: + self.db = object() + self.tenant_id = 1 + self.llm = _FakeLLM() + + service = _FakeService() + fast_result = await service._build_internal_model_config_fast_answer( + question="我们系统当前接的是哪个 codex 模型配置?", + query_semantics={ + "effective_source_policy": "internal", + "query_type": "status", + "operation_contract": { + "query_type": "status", + }, + "query_profile": { + "needs_private_context": True, + "needs_freshness": False, + "referenced_objects": ["system_config", "model_routing"], + }, + }, + ) + + assert fast_result is None + + +@pytest.mark.asyncio +async def test_fast_project_progress_answer_supports_structured_impact_judgment_queries( + monkeypatch, +) -> None: + project = SimpleNamespace( + id=91, + title="采购经理每日实时库存上报服务", + name="采购经理每日实时库存上报服务", + status="active", + owner_id=11, + updated_at=datetime(2026, 4, 12, 10, 0), + description="围绕采购经理的库存上报、波动预警和异常协同。", + ) + owner = SimpleNamespace(name="供应链负责人") + tasks = [ + SimpleNamespace(status="进行中", updated_at=datetime(2026, 4, 12, 9, 0), created_at=datetime(2026, 4, 12, 8, 0)), + SimpleNamespace(status="待处理", updated_at=datetime(2026, 4, 12, 9, 30), created_at=datetime(2026, 4, 12, 8, 30)), + ] + signals = [ + ("处理中", False, "processing"), + ("待处理", False, "pending"), + ] + + class FakeScalarResult: + def __init__(self, *, scalar=None, rows=None) -> None: + self._scalar = scalar + self._rows = list(rows or []) + + def scalar_one_or_none(self): + return self._scalar + + def scalars(self): + return SimpleNamespace(all=lambda: list(self._rows)) + + def all(self): + return list(self._rows) + + class FakeDB: + async def execute(self, stmt): + sql = str(stmt).lower() + if "from projects" in sql: + return FakeScalarResult(scalar=project) + if "from users" in sql: + return FakeScalarResult(scalar=owner) + if "from tasks" in sql: + return FakeScalarResult(rows=tasks) + if "from signals" in sql: + return FakeScalarResult(rows=signals) + raise AssertionError(sql) + + async def _fake_render_prompt(self, *, fallback_template: str, **kwargs): + _ = self + return str(fallback_template).format(**kwargs).strip() + + monkeypatch.setattr( + "app.services.prompt_release.service.PromptReleaseService.render_prompt", + _fake_render_prompt, + ) + + class _FakeLLM: + async def generate(self, prompt: str, **kwargs): + _ = (prompt, kwargs) + return json.dumps( + { + "handled": True, + "summary": "WMS 接口波动会先冲击库存上报的输入链路。", + "stage_judgement": "外部依赖敏感阶段", + "evidence_summary": "当前库存上报项目仍依赖 WMS 明细输入,项目里还有进行中的接入任务。", + "core_gap": "一旦上游接口抖动,库存上报会先出现输入不完整和异常延迟。", + "external_impact": "最先受影响的是每日库存上报和异常预警两段。", + "actions": [ + {"slot": "this_week", "text": "先把 WMS 数据缺失时的降级和补拉链路补齐。"}, + {"slot": "owner_decision", "text": "明确接口波动时谁来兜底确认库存数据。"}, + {"slot": "protect_priority", "text": "优先保住异常库存预警这一段。"}, + ], + }, + ensure_ascii=False, + ) + + class FakeRAG( + AdaptiveRAGFastAnswerProjectMainMixin, + AdaptiveRAGConversationUtilsAMixinP1, + ): + def __init__(self) -> None: + self.db = FakeDB() + self.tenant_id = 1 + self.llm = _FakeLLM() + + async def _resolve_project_scope_for_question(self, **kwargs): + _ = kwargs + return (91, "采购经理每日实时库存上报服务", "采购经理每日实时库存上报服务") + + async def _execute_external_query_gateway(self, **kwargs): + _ = kwargs + return ([], {"mode": "external_query_gateway_empty"}) + + rag = FakeRAG() + result = await rag._try_fast_project_progress_answer( + clean_q="WMS接口波动,会不会先影响采购经理每日实时库存上报服务?", + project_id=None, + query_semantics={ + "business_frame": { + "goal_kind": "impact_judgment", + "target_scope": "explicit_project", + "resource_role": "project_object", + "source_kind": "hybrid", + "expected_answer_shape": "impact_chain", + "conversation_posture": "business_operating", + "project_binding": 91, + "evidence_mode": "verified", + }, + }, + ) + + assert result is not None + answer = str(result["answer"] or "") + assert "项目 #91《采购经理每日实时库存上报服务》" in answer + assert "WMS 接口波动会先冲击库存上报的输入链路。" in answer + assert "对项目的直接影响:最先受影响的是每日库存上报和异常预警两段。" in answer + + @pytest.mark.asyncio async def test_second_pass_query_skips_internal_hints_for_effective_external_source_policy() -> None: class _FakeService(AdaptiveRAGMultiStepCoreMixin): @@ -4111,16 +5088,183 @@ async def test_query_route_clarification_response_stores_pending_route_protocol( assert state.pending_clarification["original_question"] == "当前系统内是否有为采购经理提出的需求?" assert state.pending_clarification["option_map"] == {"A": "external", "B": "internal"} assert state.pending_clarification["options"] == [ - {"key": "A", "label": "外部公开事实"}, - {"key": "B", "label": "系统内部配置/项目对象"}, + { + "key": "A", + "label": "外部公开事实", + "query_semantics_overrides": { + "source_policy": "external", + "effective_source_policy": "external", + "source_reason": "query_route_clarification_selection", + "decision_origin": "query_route_clarification_selection", + }, + }, + { + "key": "B", + "label": "系统内部配置/项目对象", + "query_semantics_overrides": { + "source_policy": "internal", + "effective_source_policy": "internal", + "source_reason": "query_route_clarification_selection", + "decision_origin": "query_route_clarification_selection", + }, + }, ] -def test_query_route_pending_clarification_consumes_protocol_option_label() -> None: +@pytest.mark.asyncio +async def test_target_resolution_clarification_response_stores_pending_internal_target_protocol() -> None: + class _FakeDB: + def add(self, obj) -> None: + obj.id = 1 + + 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( + AdaptiveRAGAnswerPrecheckMixin, + AdaptiveRAGQueryRouteGatewayMixin, + AdaptiveRAGFollowupAnchorMixin, + ): + def __init__(self) -> None: + self.db = _FakeDB() + self.tenant_id = 1 + self.context_cache = _FakeContextCache() + + def _to_naive_utc(self, value): + return value + + def _build_qa_record_runtime_fields(self, **kwargs): + _ = kwargs + return { + "policy_version": "test", + "citations": [], + "sources_used": [], + "evidence_pack": {}, + "tool_trace": [], + } + + def _build_qa_record_external_fields(self, **kwargs): + _ = kwargs + return {} + + def _build_response_payload(self, **kwargs): + return kwargs + + async def _update_session_state_after_turn(self, **kwargs): + _ = kwargs + return None + + service = _FakeService() + state = SimpleNamespace( + last_subject=None, + last_entity_ids=[], + last_intent=None, + ambiguity="unknown", + rewrite_history=[], + pending_clarification=None, + pending_clarification_expires_at=None, + ) + + result = await service._maybe_return_clarification_response( + clarification_payload={ + "answer": "当前内部配置对象还不够明确,我先按配置域给你列两个入口:\nA. 模型路由配置\nB. MCP 配置\n你补一个我再继续往里查。", + "code": "target_resolution_clarify", + "candidates": [], + "missing_fields": ["target_resolution"], + "clarification_protocol": { + "kind": "internal_target_selection", + "option_map": { + "A": "config:model_routing", + "B": "config:mcp_template_center", + }, + "options": [ + { + "key": "A", + "label": "模型路由配置", + "query_semantics_overrides": { + "source_policy": "internal", + "effective_source_policy": "internal", + "business_frame": { + "resource_role": "config", + "object_binding": "config:model_routing", + }, + }, + }, + { + "key": "B", + "label": "MCP 配置", + "query_semantics_overrides": { + "source_policy": "internal", + "effective_source_policy": "internal", + "business_frame": { + "resource_role": "config", + "object_binding": "config:mcp_template_center", + }, + }, + }, + ], + }, + }, + started=0.0, + clean_q="系统里现在有哪些配置?", + effective_q="系统里现在有哪些配置?", + query_type="status", + asked_by=1, + asked_via="feishu", + project_id=None, + session_id=11, + turn_index=2, + state_row=state, + rewrite_meta={"mode": "direct"}, + context_session_id=11, + active_thinking_mode="normal", + route_decision={ + "route_mode": "internal", + "effective_source_policy": "internal", + "reply_contract": { + "mode": "clarify", + "clarification_text": "当前内部配置对象还不够明确。", + }, + }, + ) + + assert result["clarification_code"] == "target_resolution_clarify" + assert state.pending_clarification["kind"] == "internal_target_selection" + assert state.pending_clarification["original_question"] == "系统里现在有哪些配置?" + assert state.pending_clarification["option_map"] == { + "A": "config:model_routing", + "B": "config:mcp_template_center", + } + + +@pytest.mark.asyncio +async def test_query_route_pending_clarification_consumes_protocol_option_label() -> None: class _FakeService(AdaptiveRAGFollowupAnchorMixin): def _to_naive_utc(self, value): return value + async def _resolve_pending_clarification_selection_semantics(self, **kwargs): + _ = kwargs + return { + "selection_status": "resolved", + "selected_key": "B", + "selected_index": 0, + "confidence": 0.91, + "reason": "test_double", + } + service = _FakeService() state = SimpleNamespace( pending_clarification={ @@ -4129,14 +5273,28 @@ def test_query_route_pending_clarification_consumes_protocol_option_label() -> N "attempts": 1, "option_map": {"A": "external", "B": "internal"}, "options": [ - {"key": "A", "label": "外部公开事实"}, - {"key": "B", "label": "系统内部配置/项目对象"}, + { + "key": "A", + "label": "外部公开事实", + "query_semantics_overrides": { + "source_policy": "external", + "effective_source_policy": "external", + }, + }, + { + "key": "B", + "label": "系统内部配置/项目对象", + "query_semantics_overrides": { + "source_policy": "internal", + "effective_source_policy": "internal", + }, + }, ], }, pending_clarification_expires_at=None, ) - consumed = service._consume_pending_clarification_selection( + consumed = await service._consume_pending_clarification_selection( question="内部配置", session_state=state, ) @@ -4144,6 +5302,86 @@ def test_query_route_pending_clarification_consumes_protocol_option_label() -> N assert consumed == { "effective_question": "当前系统内是否有为采购经理提出的需求?", "source_policy_hint": "internal", + "query_semantics_overrides": { + "source_policy": "internal", + "effective_source_policy": "internal", + }, + } + assert state.pending_clarification is None + + +@pytest.mark.asyncio +async def test_internal_target_pending_clarification_consumes_protocol_option_label() -> None: + class _FakeService(AdaptiveRAGFollowupAnchorMixin): + def _to_naive_utc(self, value): + return value + + async def _resolve_pending_clarification_selection_semantics(self, **kwargs): + _ = kwargs + return { + "selection_status": "resolved", + "selected_key": "B", + "selected_index": 0, + "confidence": 0.93, + "reason": "test_double", + } + + service = _FakeService() + state = SimpleNamespace( + pending_clarification={ + "kind": "internal_target_selection", + "original_question": "系统里现在有哪些配置?", + "attempts": 1, + "option_map": { + "A": "config:model_routing", + "B": "config:mcp_template_center", + }, + "options": [ + { + "key": "A", + "label": "模型路由配置", + "query_semantics_overrides": { + "source_policy": "internal", + "effective_source_policy": "internal", + "business_frame": { + "resource_role": "config", + "object_binding": "config:model_routing", + }, + }, + }, + { + "key": "B", + "label": "MCP 配置", + "query_semantics_overrides": { + "source_policy": "internal", + "effective_source_policy": "internal", + "business_frame": { + "resource_role": "config", + "object_binding": "config:mcp_template_center", + }, + }, + }, + ], + }, + pending_clarification_expires_at=None, + ) + + consumed = await service._consume_pending_clarification_selection( + question="我问的是 MCP 配置。", + session_state=state, + ) + + assert consumed == { + "effective_question": "系统里现在有哪些配置?", + "source_policy_hint": "internal", + "query_semantics_overrides": { + "source_policy": "internal", + "effective_source_policy": "internal", + "business_frame": { + "resource_role": "config", + "object_binding": "config:mcp_template_center", + }, + }, } assert state.pending_clarification is None @@ -4165,8 +5403,22 @@ async def test_prepare_answer_runtime_passes_consumed_query_route_hint_to_previe "attempts": 1, "option_map": {"A": "external", "B": "internal"}, "options": [ - {"key": "A", "label": "外部公开事实"}, - {"key": "B", "label": "系统内部配置/项目对象"}, + { + "key": "A", + "label": "外部公开事实", + "query_semantics_overrides": { + "source_policy": "external", + "effective_source_policy": "external", + }, + }, + { + "key": "B", + "label": "系统内部配置/项目对象", + "query_semantics_overrides": { + "source_policy": "internal", + "effective_source_policy": "internal", + }, + }, ], }, pending_clarification_expires_at=None, @@ -4198,6 +5450,16 @@ async def test_prepare_answer_runtime_passes_consumed_query_route_hint_to_previe _ = kwargs return self.state_row + async def _resolve_pending_clarification_selection_semantics(self, **kwargs): + _ = kwargs + return { + "selection_status": "resolved", + "selected_key": "B", + "selected_index": 0, + "confidence": 0.91, + "reason": "test_double", + } + async def _resolve_followup_anchor(self, **kwargs): _ = kwargs return FollowupAnchorResolution() @@ -4289,8 +5551,22 @@ async def test_prepare_answer_runtime_recomputes_route_after_consuming_query_rou "attempts": 1, "option_map": {"A": "external", "B": "internal"}, "options": [ - {"key": "A", "label": "外部公开事实"}, - {"key": "B", "label": "系统内部配置/项目对象"}, + { + "key": "A", + "label": "外部公开事实", + "query_semantics_overrides": { + "source_policy": "external", + "effective_source_policy": "external", + }, + }, + { + "key": "B", + "label": "系统内部配置/项目对象", + "query_semantics_overrides": { + "source_policy": "internal", + "effective_source_policy": "internal", + }, + }, ], }, pending_clarification_expires_at=None, @@ -4322,6 +5598,16 @@ async def test_prepare_answer_runtime_recomputes_route_after_consuming_query_rou _ = kwargs return self.state_row + async def _resolve_pending_clarification_selection_semantics(self, **kwargs): + _ = kwargs + return { + "selection_status": "resolved", + "selected_key": "B", + "selected_index": 0, + "confidence": 0.91, + "reason": "test_double", + } + async def _resolve_followup_anchor(self, **kwargs): _ = kwargs return FollowupAnchorResolution() @@ -4419,11 +5705,321 @@ async def test_prepare_answer_runtime_recomputes_route_after_consuming_query_rou assert service.preview_kwargs["question"] == "当前系统内是否有为采购经理提出的需求?" assert service.preview_kwargs["source_policy_hint"] == "internal" assert service.preview_kwargs["query_semantics"] == {} + assert runtime["query_semantics"]["source_policy"] == "internal" + assert runtime["query_semantics"]["effective_source_policy"] == "internal" assert runtime["query_type"] == "existence" assert runtime["route_decision"]["route_mode"] == "internal" assert service.state_row.pending_clarification is None +@pytest.mark.asyncio +async def test_prepare_answer_runtime_recomputes_internal_target_after_consuming_internal_target_selection() -> None: + class _FakeService( + AdaptiveRAGAnswerMainlineMixin, + AdaptiveRAGFollowupAnchorMixin, + AdaptiveRAGQueryRouteGatewayMixin, + ): + def __init__(self) -> None: + self.preview_kwargs: dict[str, object] = {} + self.state_row = SimpleNamespace( + last_subject=None, + last_entity_ids=[], + last_intent=None, + ambiguity="unknown", + rewrite_history=[], + pending_clarification={ + "kind": "internal_target_selection", + "original_question": "系统里现在有哪些配置?", + "attempts": 1, + "option_map": { + "A": "config:model_routing", + "B": "config:mcp_template_center", + }, + "options": [ + { + "key": "A", + "label": "模型路由配置", + "query_semantics_overrides": { + "source_policy": "internal", + "effective_source_policy": "internal", + "business_frame": { + "goal_kind": "status_lookup", + "target_scope": "global", + "resource_role": "config", + "source_kind": "internal", + "expected_answer_shape": "bounded_explanation", + "conversation_posture": "business_operating", + "object_binding": "config:model_routing", + }, + }, + }, + { + "key": "B", + "label": "MCP 配置", + "query_semantics_overrides": { + "source_policy": "internal", + "effective_source_policy": "internal", + "business_frame": { + "goal_kind": "status_lookup", + "target_scope": "global", + "resource_role": "config", + "source_kind": "internal", + "expected_answer_shape": "bounded_explanation", + "conversation_posture": "business_operating", + "object_binding": "config:mcp_template_center", + }, + }, + }, + ], + }, + pending_clarification_expires_at=None, + ) + + def _to_naive_utc(self, value): + return value + + def _build_external_conversation_key(self, **kwargs): + _ = kwargs + return "scope:feishu:test" + + def _set_active_external_qa_context(self, **kwargs): + _ = kwargs + + async def _touch_session_for_new_record(self, **kwargs): + _ = kwargs + return 11, 2 + + def _external_session_id_from_key(self, key: str): + _ = key + return 11 + + async def _load_recent_conversation(self, **kwargs): + _ = kwargs + return [] + + async def _ensure_session_state(self, **kwargs): + _ = kwargs + return self.state_row + + async def _resolve_pending_clarification_selection_semantics(self, **kwargs): + _ = kwargs + return { + "selection_status": "resolved", + "selected_key": "B", + "selected_index": 0, + "confidence": 0.92, + "reason": "test_double", + } + + async def _resolve_followup_anchor(self, **kwargs): + _ = kwargs + return FollowupAnchorResolution() + + def _rewrite_question_with_state(self, **kwargs): + return kwargs["question"], {"mode": "direct"} + + async def preview_query_route_decision(self, **kwargs): + self.preview_kwargs = dict(kwargs) + return { + "query_semantics": { + "source_policy": "internal", + "effective_source_policy": "internal", + "query_type": "status", + "query_profile": { + "needs_private_context": True, + "needs_freshness": False, + "confidence": 0.9, + }, + }, + "route_decision": { + "route_mode": "internal", + "effective_source_policy": "internal", + "decision_origin": "upstream_hint", + "reply_contract": { + "mode": "explicit_source", + "head": "内部知识库查询:", + "tail_hint": None, + }, + "query_profile": { + "needs_private_context": True, + "needs_freshness": False, + "confidence": 0.9, + }, + "probe_snapshot_summary": {}, + "probe_snapshot": {}, + }, + "project_operating_context": {}, + "execution_recipe": {}, + } + + async def _resolve_answer_scene_semantics(self, **kwargs): + _ = kwargs + return {"scene_key": "ai_pmo", "deep_intent": "none"} + + def _normalize_action_mode(self, value: str) -> str: + return str(value or "").strip().lower() or "auto" + + async def _resolve_action_mode_async(self, **kwargs): + _ = kwargs + return ACTION_MODE_READ + + service = _FakeService() + runtime = await service._prepare_answer_runtime( + clean_q="我问的是 MCP 配置。", + asked_by=1, + asked_via="feishu", + project_id=None, + source_chat_id="oc_test_chat", + session_id=11, + conversation_key=None, + force_new_session=False, + action_mode="auto", + ) + + assert runtime["resolved_question"] == "系统里现在有哪些配置?" + assert service.preview_kwargs["question"] == "系统里现在有哪些配置?" + assert service.preview_kwargs["source_policy_hint"] == "internal" + assert runtime["query_semantics"]["business_frame"]["object_binding"] == "config:mcp_template_center" + assert runtime["query_semantics"]["business_frame"]["resource_role"] == "config" + assert service.state_row.pending_clarification is None + + +@pytest.mark.asyncio +async def test_prepare_answer_runtime_passes_selected_followup_anchor_id_to_resolver() -> None: + class _FakeService( + AdaptiveRAGAnswerMainlineMixin, + AdaptiveRAGFollowupAnchorMixin, + AdaptiveRAGQueryRouteGatewayMixin, + ): + def __init__(self) -> None: + self.anchor_kwargs: dict[str, object] = {} + self.state_row = SimpleNamespace( + last_subject=None, + last_entity_ids=[], + last_intent=None, + ambiguity="unknown", + rewrite_history=[], + pending_clarification={ + "kind": "followup_anchor_selection", + "original_question": "我问你这个是哪个模型执行回复的?", + "attempts": 1, + "candidates": [ + {"index": 1, "qa_id": 101, "label": "系统架构那条"}, + {"index": 2, "qa_id": 102, "label": "系统模型那条"}, + ], + }, + pending_clarification_expires_at=None, + ) + + def _to_naive_utc(self, value): + return value + + def _build_external_conversation_key(self, **kwargs): + _ = kwargs + return "scope:feishu:test" + + def _set_active_external_qa_context(self, **kwargs): + _ = kwargs + + async def _touch_session_for_new_record(self, **kwargs): + _ = kwargs + return 11, 2 + + def _external_session_id_from_key(self, key: str): + _ = key + return 11 + + async def _load_recent_conversation(self, **kwargs): + _ = kwargs + return [] + + async def _ensure_session_state(self, **kwargs): + _ = kwargs + return self.state_row + + async def _resolve_pending_clarification_selection_semantics(self, **kwargs): + _ = kwargs + return { + "selection_status": "resolved", + "selected_key": "", + "selected_index": 2, + "confidence": 0.91, + "reason": "test_double", + } + + async def _resolve_followup_anchor(self, **kwargs): + self.anchor_kwargs = dict(kwargs) + return FollowupAnchorResolution( + followup_mode="referential", + anchor_mode="clarification_selection", + anchor_reply_id=102, + anchor_reply={"qa_id": 102, "label": "系统模型那条", "question": "系统有什么模型?"}, + ) + + def _rewrite_question_with_state(self, **kwargs): + return kwargs["question"], {"mode": "direct"} + + async def preview_query_route_decision(self, **kwargs): + return { + "query_semantics": { + "source_policy": "internal", + "effective_source_policy": "internal", + "query_type": "existence", + "retrieval_domains": ["db", "pmflow"], + "query_profile": { + "needs_private_context": True, + "needs_freshness": False, + "referenced_objects": ["system_config"], + }, + }, + "route_decision": { + "route_mode": "internal", + "effective_source_policy": "internal", + "decision_origin": "query_semantics_contract", + "reply_contract": { + "mode": "explicit_source", + "head": "内部知识库查询:", + "tail_hint": None, + }, + "query_profile": { + "needs_private_context": True, + "needs_freshness": False, + "referenced_objects": ["system_config"], + }, + "probe_snapshot_summary": {}, + "probe_snapshot": {}, + }, + } + + async def _resolve_answer_scene_semantics(self, **kwargs): + _ = kwargs + return {"scene_key": "ai_pmo", "deep_intent": "none"} + + def _normalize_action_mode(self, value: str) -> str: + return str(value or "").strip().lower() or "auto" + + async def _resolve_action_mode_async(self, **kwargs): + _ = kwargs + return ACTION_MODE_READ + + service = _FakeService() + runtime = await service._prepare_answer_runtime( + clean_q="第二条", + asked_by=1, + asked_via="feishu", + project_id=None, + source_chat_id="oc_test_chat", + session_id=11, + conversation_key=None, + force_new_session=False, + action_mode="auto", + ) + + assert runtime["resolved_question"] == "我问你这个是哪个模型执行回复的?" + assert service.anchor_kwargs["selected_anchor_reply_id"] == 102 + assert runtime["anchor_resolution"].anchor_reply_id == 102 + + @pytest.mark.asyncio async def test_render_answer_with_route_contract_adds_explicit_source_prefix_and_summary() -> None: class _FakeService(AdaptiveRAGQueryRouteGatewayMixin): @@ -4499,10 +6095,25 @@ def test_build_response_payload_exposes_execution_recipe_and_plan_handoff() -> N "external_evidence_required": True, "plan_handoff_mode": "offer_after_answer", }, + fit_brief={ + "project_summary": "Alpha 聚焦统一项目上下文和 handoff。", + "repo_summary": "当前仓库负责前后台联动。", + "current_capabilities": ["需求 intake", "统一 handoff"], + }, + external_recommendation={ + "retrieval_status": "evidence_found", + "impact_status": "indirect_only", + "external_summary": "外部案例显示 handoff 入口正在收敛。", + "external_impact": "当前只支持背景相关,还不能直接映射到 Alpha 的正式对象。", + }, ) assert payload["project_operating_context"]["current_project_id"] == 17 assert payload["execution_recipe"]["primary_lane"] == "external" + assert payload["analysis_packet"]["packet_type"] == "project_fit_analysis" + assert payload["analysis_packet"]["fit_verdict"] == "fit_later" + assert payload["analysis_packet"]["focus_objects"] == [] + assert payload["analysis_packet"]["missing_mapping"] == "至少还缺一个能对上的业务步骤或信号源。" assert payload["handoff_mode"] == "offer_after_answer" assert payload["can_enter_plan"] is True assert "进入 plan" in payload["recommended_next_step"] @@ -4565,6 +6176,131 @@ def test_response_payload_can_disable_default_offer_plan_rendering(monkeypatch: assert payload["recommended_next_step"] is None +def test_build_response_payload_preserves_signal_source_recommendation_candidate_set() -> 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="如果只是先给建议,我会先看这 3 类外部信号。", + sources=[], + domains_used=["url"], + response_time_ms=12, + db_query_ms=0, + vector_search_ms=0, + query_type="status", + confidence=0.84, + route_decision={"route_mode": "external", "effective_source_policy": "external"}, + signal_source_recommendation={ + "candidate_set_id": "recset_001", + "project_binding": 17, + "resource_role": "signal_source", + "source_kind": "external", + "evidence_mode": "advisory", + "candidates": [ + { + "candidate_ref": "cand_a", + "display_name": "开票失败或重试回执", + "business_value": "它最早告诉你票是不是卡在开票动作本身。", + "why_now": "现在最容易先暴露真正失败的位置。", + "materialization_payload": { + "display_name": "开票失败或重试回执", + "source_origin": "recommendation", + }, + } + ], + "caveat": "这些先算建议,不代表已经接入或验证完成。", + }, + ) + + assert payload["signal_source_recommendation"]["candidate_set_id"] == "recset_001" + assert payload["signal_source_recommendation"]["project_binding"] == 17 + assert payload["signal_source_recommendation"]["candidates"][0]["candidate_ref"] == "cand_a" + assert payload["signal_source_recommendation"]["candidates"][0]["display_name"] == "开票失败或重试回执" + assert ( + payload["signal_source_recommendation"]["candidates"][0]["materialization_payload"]["source_origin"] + == "recommendation" + ) + + +def test_build_qa_record_external_fields_preserves_signal_source_recommendation_candidate_set() -> None: + class _FakeService(AdaptiveRAGCoreIntentMixin): + def __init__(self) -> None: + self.db = object() + self.tenant_id = 1 + self.llm = object() + self.context_cache = object() + self._active_external_qa_context = {} + self._last_answer_prompt_variant_id = "unknown" + self._last_external_answer_presentation = {} + self._query_semantics_runtime_cache = {} + + def _get_active_external_qa_context(self): + return { + "source_message_id": "om_candidate_set", + "source_chat_id": "chat_123", + "source_sender_open_id": "ou_abc", + "reply_to_message_id": "om_prev", + "root_message_id": "om_root", + "conversation_scope_key": "scope:chat_123", + } + + def _build_query_route_audit_snapshot(self, *, route_decision): + _ = route_decision + return { + "route_mode": "external", + "effective_source_policy": "external", + "decision_origin": "semantic_frame", + } + + service = _FakeService() + fields = service._build_qa_record_external_fields( + route_decision={"route_mode": "external"}, + response_payload={ + "signal_source_recommendation": { + "candidate_set_id": "recset_002", + "project_binding": 6, + "resource_role": "signal_source", + "source_kind": "external", + "evidence_mode": "advisory", + "candidates": [ + { + "candidate_ref": "cand_invoice_receipt", + "display_name": "开票失败或重试回执", + "business_value": "它最早告诉你票是不是卡在开票动作本身。", + "why_now": "现在最容易先暴露真正失败的位置。", + "materialization_payload": { + "display_name": "开票失败或重试回执", + "source_origin": "recommendation", + }, + } + ], + "caveat": "这些先算建议,不代表已经接入或验证完成。", + } + }, + ) + + assert fields["routing_snapshot"]["response_payload"]["signal_source_recommendation"]["candidate_set_id"] == "recset_002" + assert fields["routing_snapshot"]["response_payload"]["signal_source_recommendation"]["project_binding"] == 6 + assert ( + fields["routing_snapshot"]["response_payload"]["signal_source_recommendation"]["candidates"][0]["candidate_ref"] + == "cand_invoice_receipt" + ) + + @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 495129b2..d2ceb542 100644 --- a/backend/tests/test_ai_pmo_ingress_mainline.py +++ b/backend/tests/test_ai_pmo_ingress_mainline.py @@ -25,6 +25,7 @@ from app.services.ai_pmo.ai_pmo_kernel.ai_agent_kernel import gate_execution from app.services.ai_pmo.ai_pmo_kernel.ai_pmo_reply_renderer import ReplyRenderer from app.services.ai_pmo.ai_pmo_kernel import ai_pmo_typed_executor_mixins_parse from app.services.ai_pmo.ai_pmo_kernel_ingress_flow import mixin_entry +from app.services.ai_pmo.ai_pmo_kernel_ingress_flow import mixin_auto from app.services.ai_pmo.ai_pmo_kernel.mixins.ingress import ai_pmo_kernel_mixins_ingress_render from app.services.ai_pmo.ai_pmo_kernel_ingress_flow import mixin_prepare_manual from app.services.ai_pmo.ai_pmo_kernel_world_model_core import mixin_project @@ -65,6 +66,56 @@ from app.models.qa_record import QARecord from app.models.qa_session import QASession from app.models.qa_session_state import QASessionState from app.models.work_item import WorkItem + + +def test_should_enter_draft_confirmation_skips_non_plan_signal_source_prepare_write() -> None: + intent = CanonicalIntent( + name="signal_source.onboard", + confidence=0.96, + project_id=6, + signal_title="", + signal_summary="", + metadata={ + "operation_intent_contract": { + "intent_phase": "prepare_write", + "requires_confirmation": True, + "allow_direct_write": True, + } + }, + ) + workflow_route = SimpleNamespace(family="signal_source", action="onboard") + + should_enter = mixin_auto.KernelIngressFlowAutoMixin._should_enter_draft_confirmation( + intent=intent, + workflow_route=workflow_route, + ) + + assert should_enter is False + + +def test_should_enter_draft_confirmation_keeps_signal_source_drafting_path() -> None: + intent = CanonicalIntent( + name="signal_source.onboard", + confidence=0.96, + project_id=6, + signal_title="", + signal_summary="", + metadata={ + "operation_intent_contract": { + "intent_phase": "drafting", + "requires_confirmation": True, + "allow_direct_write": False, + } + }, + ) + workflow_route = SimpleNamespace(family="signal_source", action="onboard") + + should_enter = mixin_auto.KernelIngressFlowAutoMixin._should_enter_draft_confirmation( + intent=intent, + workflow_route=workflow_route, + ) + + assert should_enter is True from app.utils.thread_context import build_feishu_conversation_scope_key, build_feishu_thread_key @@ -705,7 +756,6 @@ def _install_requirement_plan_query_bundle( ) -> None: async def _fake_aplan_message(*, message_text: str, source_system: str, metadata=None): payload = dict(metadata or {}) - _ = message_text effective_topic_switch = topic_switch if effective_topic_switch is None: explicit_topic_switch = str(payload.get("topic_switch") or "").strip() @@ -733,11 +783,19 @@ def _install_requirement_plan_query_bundle( ], message_gate="auto", reply_contract=ReplyContract(mode="text", template_key="workflow_bundle", summary="query_flow"), - diagnostics={ - "message_intent": message_intent, - "consultant_mode_preference": consultant_mode_preference, - "topic_switch": effective_topic_switch, - }, + diagnostics=_with_requirement_plan_protocol_diagnostics( + message_text=message_text, + diagnostics={ + "message_intent": message_intent, + "consultant_mode_preference": consultant_mode_preference, + "topic_switch": effective_topic_switch, + "business_frame": { + "goal_kind": "drafting" + if str(message_intent or "").strip().lower() == "requirement_plan" + else None, + }, + }, + ), ) monkeypatch.setattr( @@ -747,6 +805,32 @@ def _install_requirement_plan_query_bundle( ) +def _with_requirement_plan_protocol_diagnostics( + *, + message_text: str, + diagnostics: dict[str, object] | None, +) -> dict[str, object]: + merged = dict(diagnostics or {}) + parsed_protocol = mixin_execute_query_project.ExecuteQueryProjectMixin._parse_requirement_plan_protocol_reply( + message_text + ) + normalized_protocol = { + key: value + for key, value in dict(parsed_protocol or {}).items() + if key in {"choice", "action", "content_text"} and str(value or "").strip() + } + business_frame = dict(merged.get("business_frame") or {}) if isinstance(merged.get("business_frame"), dict) else {} + business_frame_metadata = ( + dict(business_frame.get("metadata") or {}) + if isinstance(business_frame.get("metadata"), dict) + else {} + ) + business_frame_metadata["requirement_plan_protocol"] = normalized_protocol + business_frame["metadata"] = business_frame_metadata + merged["business_frame"] = business_frame + return merged + + @pytest.mark.asyncio async def test_process_text_route_uses_pmflow_manual_ingress(monkeypatch) -> None: captured: dict[str, object] = {} @@ -848,6 +932,13 @@ async def test_prepare_mixed_sidecar_context_passes_mixed_contract(monkeypatch) self.db = object() self.tenant_id = 9 + async def _find_active_requirement_plan_workspace(self, **kwargs): + _ = kwargs + return ( + SimpleNamespace(id=81, plan_status="collecting"), + SimpleNamespace(id=82, state="collecting"), + ) + monkeypatch.setattr(mixin_entry, "AdaptiveRAGService", FakeAdaptiveRAGService) kernel = FakeKernel() clean_text, mixed_meta = await kernel._prepare_mixed_sidecar_context( @@ -892,6 +983,13 @@ async def test_prepare_mixed_sidecar_context_reuses_non_mixed_semantic_bundle_wi self.db = object() self.tenant_id = 9 + async def _find_active_requirement_plan_workspace(self, **kwargs): + _ = kwargs + return ( + SimpleNamespace(id=81, plan_status="collecting"), + SimpleNamespace(id=82, state="collecting"), + ) + monkeypatch.setattr(mixin_entry, "AdaptiveRAGService", FakeAdaptiveRAGService) kernel = FakeKernel() clean_text, mixed_meta = await kernel._prepare_mixed_sidecar_context( @@ -1044,6 +1142,13 @@ async def test_prepare_mixed_sidecar_context_skips_requirement_choice_reply_with self.db = object() self.tenant_id = 9 + async def _find_active_requirement_plan_workspace(self, **kwargs): + _ = kwargs + return ( + SimpleNamespace(id=81, plan_status="collecting"), + SimpleNamespace(id=82, state="collecting"), + ) + monkeypatch.setattr(mixin_entry, "AdaptiveRAGService", FakeAdaptiveRAGService) kernel = FakeKernel() clean_text, mixed_meta = await kernel._prepare_mixed_sidecar_context( @@ -1118,6 +1223,7 @@ async def test_prepare_mixed_sidecar_context_does_not_fallback_to_local_choice_g ("就C吧", "C", ""), ("先按C:继续收集范围", "C", "继续收集范围"), ("确认A,继续往下收。", "A", "继续往下收。"), + ("回复上一条 plan:我选 B,继续往下问。", "B", "继续往下问。"), ], ) def test_requirement_plan_choice_protocol_parser_returns_choice_and_remaining_text( @@ -1132,7 +1238,7 @@ def test_requirement_plan_choice_protocol_parser_returns_choice_and_remaining_te assert parsed["remaining_text"] == expected_remaining -def test_extract_requirement_plan_updates_prefers_choice_mapping_over_protocol_continuation_text_for_first_scope() -> None: +def test_extract_requirement_plan_updates_does_not_map_choice_from_raw_message_without_structured_protocol() -> None: updates = mixin_execute_query_project.ExecuteQueryProjectMixin._extract_requirement_plan_updates( message_text="我选C 继续下一轮", card={ @@ -1152,10 +1258,170 @@ def test_extract_requirement_plan_updates_prefers_choice_mapping_over_protocol_c }, ) + assert updates.get("first_scope") != "模块化分步上线" + + +def test_extract_requirement_plan_updates_prefers_structured_protocol_for_choice_mapping_over_protocol_continuation_text() -> None: + updates = mixin_execute_query_project.ExecuteQueryProjectMixin._extract_requirement_plan_updates( + message_text="我选C 继续下一轮", + card={ + "plan_profile": "it_project", + "title": "打车聚合助手", + "goal": "先把打车聚合助手的目标、服务对象、第一版范围和交付方式收清楚,再决定是否正式创建。", + "project_nature": "业务平台型", + "target_user": "业务主管与协同方", + "tech_route": "模块化微服务", + "round_option_maps": { + "first_scope": { + "A": "最小闭环先行", + "B": "双角色轻量并行", + "C": "模块化分步上线", + } + }, + }, + requirement_plan_protocol={"choice": "C", "content_text": "继续下一轮"}, + ) + assert updates["first_scope"] == "模块化分步上线" assert "继续下一轮" not in str(updates.get("first_scope") or "") +def test_reply_context_requirement_contract_requires_structured_protocol_for_current_choice_reply() -> None: + assert ( + mixin_execute_query_project.ExecuteQueryProjectMixin._reply_context_contains_requirement_plan_contract( + turns=[ + { + "assistant": "A. 先做轻量版\nB. 先做规则版\nC. 先做平台版\n⭐ 当前我更推荐:C", + "requirement_plan_context": {"question_round": 1}, + "query_type": "plan", + "reason_code": "requirement_planning_direct", + } + ], + current_message_text="C", + current_requirement_plan_protocol=None, + ) + is False + ) + assert ( + mixin_execute_query_project.ExecuteQueryProjectMixin._reply_context_contains_requirement_plan_contract( + turns=[ + { + "assistant": "A. 先做轻量版\nB. 先做规则版\nC. 先做平台版\n⭐ 当前我更推荐:C", + "requirement_plan_context": {"question_round": 1}, + "query_type": "plan", + "reason_code": "requirement_planning_direct", + } + ], + current_message_text="C", + current_requirement_plan_protocol={"choice": "C"}, + ) + is True + ) + + +def test_reply_context_requirement_contract_accepts_structured_contextual_draft_intent_without_current_choice_protocol() -> None: + assert ( + mixin_execute_query_project.ExecuteQueryProjectMixin._reply_context_contains_requirement_plan_contract( + turns=[ + { + "assistant": "收到,我会按当前上下文继续。", + "requirement_plan_context": { + "question_round": 2, + "title": "给运营公司打造主播协同作战平台", + "source_material": "用户:想跟你聊个新需求。 | 用户:三个问题都有", + }, + "query_type": "plan", + "reason_code": "contextual_requirement_plan_draft", + } + ], + current_message_text="依据当前信息给我直接生成一版本草稿", + current_requirement_plan_protocol=None, + current_message_intent="request_draft_generation_from_context", + ) + is True + ) + + +def test_reply_context_requirement_contract_accepts_continue_contextual_qa_without_current_choice_protocol() -> None: + assert ( + mixin_execute_query_project.ExecuteQueryProjectMixin._reply_context_contains_requirement_plan_contract( + turns=[ + { + "assistant": "收到,我会按当前上下文继续。", + "requirement_plan_context": { + "question_round": 2, + "title": "给运营公司打造主播协同作战平台", + "source_material": "用户:想跟你聊个新需求。 | 用户:三个问题都有", + }, + "query_type": "plan", + "reason_code": "contextual_requirement_plan_draft", + } + ], + current_message_text="三个问题都有", + current_requirement_plan_protocol=None, + current_message_intent="continue_contextual_qa", + ) + is True + ) + + +def test_derive_requirement_plan_protocol_from_card_only_accepts_exact_option_key() -> None: + card = { + "last_round_options": { + "field": "project_nature", + "options": [ + {"key": "A", "title": "脚本提效助手"}, + {"key": "B", "title": "个性化穿搭推荐平台"}, + {"key": "C", "title": "穿搭决策底座"}, + ], + } + } + + assert ( + mixin_execute_query_project.ExecuteQueryProjectMixin._derive_requirement_plan_protocol_from_card( + message_text=" B ", + card=card, + ) + == {"choice": "B", "content_text": "", "text_after_choice": ""} + ) + assert ( + mixin_execute_query_project.ExecuteQueryProjectMixin._derive_requirement_plan_protocol_from_card( + message_text="我选B继续", + card=card, + ) + == {} + ) + + +def test_should_continue_same_scope_requirement_plan_requires_structured_protocol_for_choice_only_reply() -> None: + executor = mixin_execute_query_project.ExecuteQueryProjectMixin() + + assert ( + executor._should_continue_same_scope_requirement_plan( + message_text="C", + message_intent=None, + topic_switch=None, + has_requirement_anchor=False, + topic=SimpleNamespace(), + draft=SimpleNamespace(state="collecting"), + requirement_plan_protocol=None, + ) + is False + ) + assert ( + executor._should_continue_same_scope_requirement_plan( + message_text="C", + message_intent=None, + topic_switch=None, + has_requirement_anchor=False, + topic=SimpleNamespace(), + draft=SimpleNamespace(state="collecting"), + requirement_plan_protocol={"choice": "C"}, + ) + is True + ) + + def test_merge_requirement_plan_round_options_from_assistant_reply_uses_non_regex_line_parser() -> None: assistant_text = ( "第一轮收敛:这是什么性质的项目?\n\n" @@ -1791,6 +2057,11 @@ async def test_process_ingress_records_mounted_runtime_for_draft_confirmation(mo def __init__(self, db) -> None: captured["db"] = db + async def canonicalize_thread_context(self, **kwargs): + captured["calls"].append(("canonicalize_thread_context", kwargs)) + thread_context = kwargs.get("thread_context") + return dict(thread_context or {}), None + async def terminate_requirement_plan_scope(self, **kwargs): captured["calls"].append(("terminate_scope", kwargs)) return { @@ -1867,12 +2138,46 @@ async def test_process_ingress_records_mounted_runtime_for_draft_confirmation(mo sender="tester", channel_ref="chat-1", source_sender_open_id="ou_xxx", - ingress_options={"thread_context": {"reply_to_message_id": "msg-root-1"}}, + ingress_options={ + "thread_context": {"reply_to_message_id": "msg-root-1"}, + "semantic_bundle": { + "bundle_id": "bundle-draft-confirm", + "semantic_version": "v1", + "source_system": "feishu", + "primary_workflow": "followup_flow", + "message_gate": "pending_confirmation", + "workflows": [ + { + "workflow_type": "followup_flow", + "workflow_action": "confirm", + "target_scope": "thread", + "operation_codes": ["followup.confirm"], + "requires_confirmation": True, + "confidence": 0.95, + "clarification_needed": False, + "metadata": {}, + "slots": [], + } + ], + "reply_contract": { + "mode": "card", + "template_key": "workflow_bundle", + "summary": "followup_flow", + }, + "diagnostics": {}, + }, + }, ) assert result["status"] == "answered" - assert [name for name, _ in captured["calls"]] == ["begin", "terminate_scope", "record", "agent_trace", "finalize"] - begin_kwargs = captured["calls"][0][1] + assert [name for name, _ in captured["calls"]] == [ + "canonicalize_thread_context", + "begin", + "record", + "agent_trace", + "finalize", + ] + begin_kwargs = captured["calls"][1][1] record_kwargs = captured["calls"][2][1] agent_trace_kwargs = captured["calls"][3][1] finalize_kwargs = captured["calls"][4][1] @@ -2326,6 +2631,10 @@ async def test_process_ingress_semantic_shadow_reuses_upstream_bundle_without_re def test_should_route_to_draft_confirmation_requires_anchor_or_explicit_id() -> None: session = SimpleNamespace(last_intent=mixin_entry.STATE_DRAFT_PENDING_CONFIRM) + followup_bundle = { + "primary_workflow": "followup_flow", + "workflows": [{"workflow_type": "followup_flow", "workflow_action": "confirm"}], + } assert mixin_entry.KernelIngressFlowEntryMixin._should_route_to_draft_confirmation( session=session, @@ -2336,13 +2645,13 @@ def test_should_route_to_draft_confirmation_requires_anchor_or_explicit_id() -> assert mixin_entry.KernelIngressFlowEntryMixin._should_route_to_draft_confirmation( session=session, message_text="确认", - ingress_options={"thread_context": {"thread_id": "th_123"}}, + ingress_options={"thread_context": {"thread_id": "th_123"}, "semantic_bundle": followup_bundle}, ) is True assert mixin_entry.KernelIngressFlowEntryMixin._should_route_to_draft_confirmation( session=session, message_text="确认", - ingress_options={"thread_context": {"root_message_id": "root_123"}}, + ingress_options={"thread_context": {"root_message_id": "root_123"}, "semantic_bundle": followup_bundle}, ) is True assert mixin_entry.KernelIngressFlowEntryMixin._should_route_to_draft_confirmation( @@ -2354,7 +2663,7 @@ def test_should_route_to_draft_confirmation_requires_anchor_or_explicit_id() -> assert mixin_entry.KernelIngressFlowEntryMixin._should_route_to_draft_confirmation( session=session, message_text="确认", - ingress_options={"thread_context": {"reply_to_message_id": "msg-123"}}, + ingress_options={"thread_context": {"reply_to_message_id": "msg-123"}, "semantic_bundle": followup_bundle}, ) is True assert mixin_entry.KernelIngressFlowEntryMixin._should_route_to_draft_confirmation( @@ -2825,7 +3134,7 @@ async def test_active_requirement_followup_prefers_current_requirement_context(m @pytest.mark.asyncio -async def test_active_requirement_choice_protocol_followup_bypasses_semantic_decision_gate(monkeypatch) -> None: +async def test_active_requirement_choice_followup_uses_semantic_decision_then_requirement_builder(monkeypatch) -> None: captured: dict[str, object] = {} class FakeKernel(mixin_entry.KernelIngressFlowEntryMixin): @@ -2863,24 +3172,55 @@ async def test_active_requirement_choice_protocol_followup_bypasses_semantic_dec }, } - class BombSemanticContextAssembler: + class FakeSemanticContextAssembler: def __init__(self, db) -> None: self.db = db async def assemble(self, **kwargs): - _ = kwargs - raise AssertionError("choice protocol followup should bypass semantic decision assembly") + captured["packet_kwargs"] = kwargs + return SimpleNamespace( + mode_card=SimpleNamespace( + allowed_actions=["reply.compose", "context.followup.current_requirement"], + forbidden_actions=[], + ) + ) - class BombSemanticDecisionService: + class FakeSemanticDecisionService: def __init__(self, _llm_service) -> None: pass async def decide(self, *, packet): _ = packet - raise AssertionError("choice protocol followup should bypass semantic decision") + return SimpleNamespace( + topic_relation="continue_current_topic", + mode_decision="stay_plan", + followup_scope="current_requirement", + followup_action="none", + revision_note="", + assistant_reply="", + business_frame=SimpleNamespace( + model_dump=lambda: { + "goal_kind": "clarify", + "target_scope": "thread_context", + "resource_role": "project_object", + "source_kind": "internal", + "expected_answer_shape": "clarify_prompt", + "conversation_posture": "business_operating", + "metadata": { + "requirement_plan_protocol": { + "choice": "C", + "action": "continue", + "content_text": "", + } + }, + } + ), + requires_confirmation=False, + requested_actions=["reply.compose", "context.followup.current_requirement"], + ) - monkeypatch.setattr(mixin_entry, "SemanticContextAssembler", BombSemanticContextAssembler) - monkeypatch.setattr(mixin_entry, "SemanticDecisionService", BombSemanticDecisionService) + monkeypatch.setattr(mixin_entry, "SemanticContextAssembler", FakeSemanticContextAssembler) + monkeypatch.setattr(mixin_entry, "SemanticDecisionService", FakeSemanticDecisionService) kernel = FakeKernel() result = await kernel._maybe_handle_active_context_followup( @@ -2897,14 +3237,21 @@ async def test_active_requirement_choice_protocol_followup_bypasses_semantic_dec assert result["handled"] is True assert result["direct_reply_text"].startswith("第三轮:技术路线") assert result["requirement_plan_context"]["last_round_options"]["field"] == "tech_route" + assert captured["packet_kwargs"]["latest_user_message"] == "我选C 继续" assert captured["direct_kwargs"]["message_text"] == "我选C 继续" + assert captured["direct_kwargs"]["requirement_plan_protocol"] == { + "choice": "C", + "action": "continue", + "content_text": "", + } @pytest.mark.asyncio -async def test_active_requirement_exit_request_suspends_bound_requirement_context_before_semantic_decision( +async def test_active_requirement_exit_request_uses_structured_suspend_action( monkeypatch, ) -> None: flush_mock = AsyncMock() + captured: dict[str, object] = {} topic = SimpleNamespace( id=181, status="active", @@ -2931,19 +3278,35 @@ async def test_active_requirement_exit_request_suspends_bound_requirement_contex self.db = db async def assemble(self, **kwargs): - _ = kwargs - raise AssertionError("explicit exit should bypass semantic decision assembly") + captured["packet_kwargs"] = kwargs + return SimpleNamespace( + mode_card=SimpleNamespace( + workflow_mode="PLAN", + mode_state="COLLECTING", + allowed_actions=["reply.compose", "context.suspend_requirement_mode"], + forbidden_actions=[], + exit_conditions=[], + ) + ) - class BombSemanticDecisionService: + class FakeSemanticDecisionService: def __init__(self, _llm_service) -> None: pass async def decide(self, *, packet): _ = packet - raise AssertionError("explicit exit should bypass semantic decision") + return mixin_entry.SemanticDecision( + topic_relation="continue_current_topic", + mode_decision="answer_only", + followup_scope="current_requirement", + followup_action="none", + assistant_reply="", + requested_actions=["context.suspend_requirement_mode"], + confidence=0.95, + ) monkeypatch.setattr(mixin_entry, "SemanticContextAssembler", BombSemanticContextAssembler) - monkeypatch.setattr(mixin_entry, "SemanticDecisionService", BombSemanticDecisionService) + monkeypatch.setattr(mixin_entry, "SemanticDecisionService", FakeSemanticDecisionService) kernel = FakeKernel() result = await kernel._maybe_handle_active_context_followup( @@ -2962,6 +3325,7 @@ async def test_active_requirement_exit_request_suspends_bound_requirement_contex assert result["reply_template_key"] == "requirement_mode_suspended" assert result["workflow_route"] == mixin_entry.ROUTE_REQUIREMENT_PLAN_COLLECT assert result["topic_workspace_id"] == 181 + assert captured["packet_kwargs"]["latest_user_message"] == "退出需求模式" flush_mock.assert_awaited_once() @@ -4749,6 +5113,202 @@ async def test_kernel_intent_resolve_uses_workflow_bundle_for_signal_source_requ assert intent.metadata["source_request"] is True +@pytest.mark.asyncio +async def test_kernel_intent_resolve_preserves_interaction_boundary_when_recommendation_downgrades_to_query( + monkeypatch: pytest.MonkeyPatch, +) -> None: + async def _fake_aplan_message(*, message_text: str, source_system: str, metadata=None): + _ = (message_text, metadata) + return WorkflowBundle( + bundle_id="bundle-signal-source-recommendation-only", + semantic_version="v1", + source_system=source_system, + primary_workflow="query_flow", + workflows=[ + WorkflowPlan( + workflow_type="query_flow", + workflow_action="answer", + target_scope="project", + operation_codes=["query.answer"], + requires_confirmation=False, + confidence=0.91, + metadata={"interaction_level": "recommendation"}, + ) + ], + message_gate="auto", + reply_contract=ReplyContract(mode="text", template_key="workflow_bundle", summary="query_flow"), + diagnostics={ + "interaction_boundary": { + "boundary_version": "v1", + "requested_level": "recommendation", + "forbidden_levels": ["draft", "execute"], + "allow_status": True, + "allow_recommendation": True, + "allow_draft": False, + "allow_execute": False, + }, + "answer_control": { + "effective_level": "recommendation", + "blocked_levels": ["draft"], + "blocked_workflows": ["signal_source_flow:onboard"], + "allowed_workflows": ["query_flow:answer"], + "downgraded_from": "draft", + "requires_confirmation": False, + "requires_self_review": True, + "requires_correction_slot": True, + "reason": "answer_only_boundary", + }, + }, + ) + + monkeypatch.setattr( + WorkflowSemanticPlanner, + "aplan_message", + classmethod(lambda cls, **kwargs: _fake_aplan_message(**kwargs)), + ) + + class FakeResolver(KernelIntentResolveMixin, KernelIntentRulesMixin): + def __init__(self) -> None: + self.tenant_id = 1 + + resolver = FakeResolver() + + intent, source = await resolver._resolve_intent( + message_text="你先别出草稿,只按当前项目情况推荐几个最值得接的外部信号源。", + session=None, + world_model={"resolved_project_id": 81}, + thread_context=None, + ) + + assert source == "workflow_bundle" + assert intent.name == "query.read_only" + assert intent.metadata["workflow_route"] == "query.answer" + assert intent.metadata["interaction_boundary"]["requested_level"] == "recommendation" + assert intent.metadata["answer_control"]["effective_level"] == "recommendation" + assert intent.metadata["answer_control"]["blocked_workflows"] == ["signal_source_flow:onboard"] + + +@pytest.mark.asyncio +async def test_kernel_intent_resolve_threads_reply_anchor_candidate_set_into_planner_metadata( + monkeypatch: pytest.MonkeyPatch, + external_qa_db_session, +) -> None: + session = QASession( + tenant_id=1, + user_id=101, + title="自动开票系统外部推荐", + project_id=6, + message_count=1, + ) + external_qa_db_session.add(session) + await external_qa_db_session.flush() + external_qa_db_session.add( + QARecord( + tenant_id=1, + question="自动开票系统这个项目,你有推荐的外部信号源吗?", + answer="如果只是先给建议,我会先看这 3 类外部信号。", + sources=[], + intent="question", + domains_used=["url"], + response_time_ms=40, + asked_by=101, + asked_via="feishu", + project_id=6, + session_id=int(session.id), + turn_index=1, + source_message_id="om_recommendation_source", + conversation_scope_key="scope:auto-invoice", + routing_snapshot={ + "response_payload": { + "signal_source_recommendation": { + "candidate_set_id": "recset_invoice_01", + "project_binding": 6, + "resource_role": "signal_source", + "source_kind": "external", + "evidence_mode": "advisory", + "candidates": [ + { + "candidate_ref": "cand_invoice_receipt", + "display_name": "开票失败或重试回执", + "business_value": "它最早告诉你票是不是卡在开票动作本身。", + "why_now": "现在最容易先暴露真正失败的位置。", + "materialization_payload": { + "display_name": "开票失败或重试回执", + "source_origin": "recommendation", + }, + } + ], + "caveat": "这些先算建议,不代表已经接入或验证完成。", + } + } + }, + answer_mode="answer", + grounding_status="grounded", + ) + ) + await external_qa_db_session.commit() + + captured: dict[str, object] = {} + + async def _fake_aplan_message(*, message_text: str, source_system: str, metadata=None): + captured["message_text"] = message_text + captured["source_system"] = source_system + captured["metadata"] = dict(metadata or {}) + return WorkflowBundle( + bundle_id="bundle-signal-source-followup", + semantic_version="v1", + source_system=source_system, + primary_workflow="query_flow", + workflows=[ + WorkflowPlan( + workflow_type="query_flow", + workflow_action="answer", + target_scope="project", + operation_codes=["query.answer"], + requires_confirmation=False, + confidence=0.9, + ) + ], + message_gate="auto", + reply_contract=ReplyContract(mode="text", template_key="workflow_bundle", summary="query_flow"), + diagnostics={}, + ) + + monkeypatch.setattr( + WorkflowSemanticPlanner, + "aplan_message", + classmethod(lambda cls, **kwargs: _fake_aplan_message(**kwargs)), + ) + + class FakeResolver(KernelIntentResolveMixin, KernelIntentRulesMixin): + def __init__(self) -> None: + self.tenant_id = 1 + self.db = external_qa_db_session + + resolver = FakeResolver() + + await resolver._resolve_intent( + message_text="把刚才推荐的那几个外部信号源写进自动开票系统里。", + session=None, + world_model={"resolved_project_id": 6}, + thread_context={ + "source_system": "feishu", + "reply_to_message_id": "om_recommendation_source", + "root_message_id": "om_recommendation_source", + }, + ) + + metadata = dict(captured.get("metadata") or {}) + thread_context = dict(metadata.get("thread_context") or {}) + reply_anchor_context = dict(thread_context.get("reply_anchor_qa_context") or {}) + recommendation = dict(reply_anchor_context.get("signal_source_recommendation") or {}) + + assert metadata["project_id"] == 6 + assert reply_anchor_context["project_id"] == 6 + assert recommendation["candidate_set_id"] == "recset_invoice_01" + assert recommendation["candidates"][0]["candidate_ref"] == "cand_invoice_receipt" + + def test_reply_renderer_formats_project_delete_pending_and_success() -> None: renderer = ReplyRenderer( state_waiting_manual="waiting_manual", @@ -5711,7 +6271,7 @@ async def test_execute_query_intent_backfills_semantic_bundle_for_casual_chat_wi _fake_aplan_message, ) executor = FakeExecutor() - intent = SimpleNamespace(project_id=None, metadata={}) + intent = SimpleNamespace(project_id=6, metadata={}) result = await executor._execute_query_intent( intent=intent, @@ -5984,7 +6544,8 @@ async def test_execute_query_intent_requirement_plan_followup_persists_progress_ self.tenant_id = 3 async def _fake_aplan_message(*, message_text: str, source_system: str, metadata=None): - _ = (message_text, metadata) + normalized_metadata = dict(metadata or {}) + topic_switch = "continue" if isinstance(normalized_metadata.get("thread_context"), dict) else "new" return WorkflowBundle( bundle_id="bundle-plan-draft-friendly", semantic_version="v1", @@ -6003,11 +6564,14 @@ async def test_execute_query_intent_requirement_plan_followup_persists_progress_ ], message_gate="auto", reply_contract=ReplyContract(mode="text", template_key="workflow_bundle", summary="query_flow"), - diagnostics={ - "message_intent": "requirement_plan", - "consultant_mode_preference": "clarify", - "topic_switch": "new", - }, + diagnostics=_with_requirement_plan_protocol_diagnostics( + message_text=message_text, + diagnostics={ + "message_intent": "requirement_plan", + "consultant_mode_preference": "clarify", + "topic_switch": topic_switch, + }, + ), ) _install_requirement_plan_round_llm(monkeypatch) @@ -6098,6 +6662,168 @@ async def test_execute_query_intent_requirement_plan_followup_persists_progress_ assert "投顾" in str(card["target_user"]) +@pytest.mark.asyncio +async def test_execute_query_intent_requirement_plan_followup_accepts_continue_phrase_with_choice( + monkeypatch, + requirement_plan_db_session, +) -> None: + class FakeAdaptiveRAGService: + def __init__(self, db, tenant_id: int): + _ = (db, tenant_id) + + async def answer_question(self, **kwargs): + _ = kwargs + raise AssertionError("requirement plan follow-up should stay on structured plan flow") + + class FakeExecutor(mixin_execute_query_project.ExecuteQueryProjectMixin): + def __init__(self) -> None: + self.db = requirement_plan_db_session + self.tenant_id = 3 + + _install_requirement_plan_round_llm(monkeypatch) + _install_requirement_plan_query_bundle(monkeypatch) + monkeypatch.setattr(mixin_execute_query_project, "AdaptiveRAGService", FakeAdaptiveRAGService) + executor = FakeExecutor() + + initial_intent = SimpleNamespace( + project_id=None, + metadata={ + "source_chat_id": "chat-plan-continue-b", + "source_sender_open_id": "ou-plan-continue-b", + "message_intent": "requirement_plan", + "consultant_mode_preference": "clarify", + }, + ) + followup_intent = SimpleNamespace( + project_id=None, + metadata={ + "source_chat_id": "chat-plan-continue-b", + "source_sender_open_id": "ou-plan-continue-b", + "thread_context": { + "root_message_id": "msg-plan-continue-b-root", + "reply_to_message_id": "msg-plan-continue-b-root", + }, + "message_intent": "requirement_plan", + "consultant_mode_preference": "clarify", + }, + ) + + first = await executor._execute_query_intent( + intent=initial_intent, + message_text="想跟你聊聊需求,做一个自动给每日的大A趋势打分的小工具,先不创建,先收敛第一版。", + operator_user_id=999, + source_system="feishu", + source_message_id="msg-plan-continue-b-root", + ) + second = await executor._execute_query_intent( + intent=followup_intent, + message_text="回复上一条 plan:我选 A,继续往下问。", + operator_user_id=999, + source_system="feishu", + source_message_id="msg-plan-continue-b-reply", + ) + + assert first["status"] == "succeeded" + assert second["status"] == "succeeded" + assert str(second.get("reply_template_key") or "") == "requirement_plan_followup" + answer = str(second["answer"] or "") + assert "现在要先把第一版服务对象定住" in answer + assert "A." in answer and "B." in answer and "C." in answer + + draft = ( + await requirement_plan_db_session.execute( + select(MountedDraftWorkspace) + .join(MountedTopicWorkspace, MountedTopicWorkspace.id == MountedDraftWorkspace.topic_workspace_id) + .where( + MountedDraftWorkspace.tenant_id == 3, + MountedTopicWorkspace.channel_ref == "chat-plan-continue-b", + ) + .order_by(MountedDraftWorkspace.id.desc()) + .limit(1) + ) + ).scalar_one() + + card = draft.requirement_card or {} + assert draft.state == "collecting" + assert str(card.get("project_nature") or "").strip() != "" + + +@pytest.mark.asyncio +async def test_execute_query_intent_requirement_plan_collects_for_confirm_project_phrase( + monkeypatch, + requirement_plan_db_session, +) -> None: + class FakeAdaptiveRAGService: + def __init__(self, db, tenant_id: int): + _ = (db, tenant_id) + + async def answer_question(self, **kwargs): + _ = kwargs + raise AssertionError("requirement plan collect should not enter heavy RAG path") + + class FakeExecutor(mixin_execute_query_project.ExecuteQueryProjectMixin): + def __init__(self) -> None: + self.db = requirement_plan_db_session + self.tenant_id = 3 + + _install_requirement_plan_round_llm(monkeypatch) + _install_requirement_plan_query_bundle(monkeypatch) + monkeypatch.setattr(mixin_execute_query_project, "AdaptiveRAGService", FakeAdaptiveRAGService) + executor = FakeExecutor() + + intent = SimpleNamespace( + project_id=None, + metadata={ + "source_chat_id": "chat-plan-confirm-project", + "source_sender_open_id": "ou-plan-confirm-project", + "message_intent": "requirement_plan", + "consultant_mode_preference": "clarify", + }, + ) + + result = await executor._execute_query_intent( + intent=intent, + message_text="确认立项先进入 plan mode,帮我把需求边界一步步问清楚。", + operator_user_id=999, + source_system="feishu", + source_message_id="msg-plan-confirm-project-root", + ) + + assert result["status"] == "succeeded" + assert str(result.get("reply_template_key") or "") == "requirement_plan_followup" + answer = str(result["answer"] or "") + assert "这类 IT 项目来收" in answer + assert "⭐ 当前我更推荐:B" in answer + assert "你回 A / B / C" in answer + + topic = ( + await requirement_plan_db_session.execute( + select(MountedTopicWorkspace) + .where( + MountedTopicWorkspace.tenant_id == 3, + MountedTopicWorkspace.channel_ref == "chat-plan-confirm-project", + ) + .order_by(MountedTopicWorkspace.id.desc()) + .limit(1) + ) + ).scalar_one() + draft = ( + await requirement_plan_db_session.execute( + select(MountedDraftWorkspace) + .join(MountedTopicWorkspace, MountedTopicWorkspace.id == MountedDraftWorkspace.topic_workspace_id) + .where( + MountedDraftWorkspace.tenant_id == 3, + MountedTopicWorkspace.channel_ref == "chat-plan-confirm-project", + ) + .order_by(MountedDraftWorkspace.id.desc()) + .limit(1) + ) + ).scalar_one() + + assert topic.mode == "plan" + assert draft.state == "collecting" + + @pytest.mark.asyncio async def test_execute_query_intent_requirement_plan_builds_feishu_friendly_draft_after_enough_answers( monkeypatch, @@ -6211,6 +6937,123 @@ async def test_execute_query_intent_requirement_plan_builds_feishu_friendly_draf assert topic.pending_actions == ["confirm_requirement_draft"] +@pytest.mark.asyncio +async def test_execute_query_intent_requirement_plan_draft_only_phrase_stays_in_plan_mode( + monkeypatch, + requirement_plan_db_session, +) -> None: + class FakeAdaptiveRAGService: + def __init__(self, db, tenant_id: int): + _ = (db, tenant_id) + + async def answer_question(self, **kwargs): + _ = kwargs + raise AssertionError("draft only plan mode path should not enter heavy RAG path") + + class FakeExecutor(mixin_execute_query_project.ExecuteQueryProjectMixin): + def __init__(self) -> None: + self.db = requirement_plan_db_session + self.tenant_id = 3 + + _install_requirement_plan_round_llm(monkeypatch) + _install_requirement_plan_query_bundle(monkeypatch) + monkeypatch.setattr(mixin_execute_query_project, "AdaptiveRAGService", FakeAdaptiveRAGService) + executor = FakeExecutor() + + intent_root = SimpleNamespace( + project_id=None, + metadata={ + "source_chat_id": "chat-plan-draft-only", + "source_sender_open_id": "ou-plan-draft-only", + "message_intent": "requirement_plan", + "consultant_mode_preference": "clarify", + }, + ) + intent_followup = SimpleNamespace( + project_id=None, + metadata={ + "source_chat_id": "chat-plan-draft-only", + "source_sender_open_id": "ou-plan-draft-only", + "thread_context": { + "root_message_id": "msg-plan-draft-only-root", + "reply_to_message_id": "msg-plan-draft-only-root", + }, + "message_intent": "requirement_plan", + "consultant_mode_preference": "clarify", + }, + ) + + await executor._execute_query_intent( + intent=intent_root, + message_text="想跟你聊聊需求,做一个自动给每日的大A趋势打分的小工具,先不创建,先收敛第一版。", + operator_user_id=999, + source_system="feishu", + source_message_id="msg-plan-draft-only-root", + ) + await executor._execute_query_intent( + intent=intent_followup, + message_text="A", + operator_user_id=999, + source_system="feishu", + source_message_id="msg-plan-draft-only-1", + ) + await executor._execute_query_intent( + intent=intent_followup, + message_text="主要给投顾看。", + operator_user_id=999, + source_system="feishu", + source_message_id="msg-plan-draft-only-2", + ) + await executor._execute_query_intent( + intent=intent_followup, + message_text="任务编排平台版,用 FastAPI + React + Redis + Celery + PostgreSQL。", + operator_user_id=999, + source_system="feishu", + source_message_id="msg-plan-draft-only-3", + ) + final_result = await executor._execute_query_intent( + intent=intent_followup, + message_text="这次先别执行,只在 plan mode 里给我出一版草稿。", + operator_user_id=999, + source_system="feishu", + source_message_id="msg-plan-draft-only-4", + ) + + answer = str(final_result["answer"] or "") + assert final_result["status"] == "succeeded" + assert str(final_result["rag_result"]["query_type"]) == "draft" + assert final_result["reply_template_key"] == "draft_pending_confirm" + assert final_result["draft_artifact"]["promote_to_confirmation"] is True + assert "📋 我先按当前收敛结果" in answer + assert "🧾【需求名称】" in answer + assert "⚙️【技术路线】" in answer + assert "🧱【第一版范围】" in answer + + topic = ( + await requirement_plan_db_session.execute( + select(MountedTopicWorkspace) + .where(MountedTopicWorkspace.tenant_id == 3, MountedTopicWorkspace.channel_ref == "chat-plan-draft-only") + .order_by(MountedTopicWorkspace.id.desc()) + .limit(1) + ) + ).scalar_one() + draft = ( + await requirement_plan_db_session.execute( + select(MountedDraftWorkspace) + .join(MountedTopicWorkspace, MountedTopicWorkspace.id == MountedDraftWorkspace.topic_workspace_id) + .where( + MountedDraftWorkspace.tenant_id == 3, + MountedTopicWorkspace.channel_ref == "chat-plan-draft-only", + ) + .order_by(MountedDraftWorkspace.id.desc()) + .limit(1) + ) + ).scalar_one() + assert topic.plan_status == "awaiting_execute_confirm" + assert topic.pending_actions == ["confirm_requirement_draft"] + assert draft.state == "ready" + + @pytest.mark.asyncio async def test_execute_query_intent_explicit_new_requirement_topic_suspends_previous_plan( monkeypatch, @@ -6831,6 +7674,102 @@ async def test_execute_query_intent_requirement_plan_accepts_fragmented_short_an assert "方向判断" in str(card["first_scope"] or "") +@pytest.mark.asyncio +async def test_execute_query_intent_requirement_plan_fragmented_short_answers_do_not_force_semantic_backfill( + monkeypatch, + requirement_plan_db_session, +) -> None: + class FakeAdaptiveRAGService: + def __init__(self, db, tenant_id: int): + _ = (db, tenant_id) + + async def answer_question(self, **kwargs): + _ = kwargs + raise AssertionError("fragmented requirement plan should not enter heavy RAG path") + + class FakeExecutor(mixin_execute_query_project.ExecuteQueryProjectMixin): + def __init__(self) -> None: + self.db = requirement_plan_db_session + self.tenant_id = 3 + + async def _forced_backfill_failure(self, **kwargs): # noqa: ANN001 + _ = (self, kwargs) + raise AssertionError("fragmented short answers should stay on direct requirement-plan flow") + + _install_requirement_plan_round_llm(monkeypatch) + monkeypatch.setattr(mixin_execute_query_project, "AdaptiveRAGService", FakeAdaptiveRAGService) + monkeypatch.setattr( + mixin_execute_query_project.ExecuteQueryProjectMixin, + "_backfill_query_semantic_bundle", + _forced_backfill_failure, + raising=False, + ) + executor = FakeExecutor() + + root_intent = SimpleNamespace( + project_id=None, + metadata={ + "source_chat_id": "chat-plan-3b", + "source_sender_open_id": "ou-plan-3b", + "message_intent": "requirement_plan", + "consultant_mode_preference": "clarify", + }, + ) + fragmented_followup_intent = SimpleNamespace( + project_id=None, + metadata={ + "source_chat_id": "chat-plan-3b", + "source_sender_open_id": "ou-plan-3b", + "thread_context": { + "root_message_id": "msg-plan-root-3b", + "reply_to_message_id": "msg-plan-root-3b", + }, + }, + ) + + first = await executor._execute_query_intent( + intent=root_intent, + message_text="想跟你聊聊需求,做一个自动给每日的大A趋势打分的小工具,先不创建,先收敛第一版。", + operator_user_id=999, + source_system="feishu", + source_message_id="msg-plan-root-3b", + ) + second = await executor._execute_query_intent( + intent=fragmented_followup_intent, + message_text="A", + operator_user_id=999, + source_system="feishu", + source_message_id="msg-plan-reply-3ba", + ) + third = await executor._execute_query_intent( + intent=fragmented_followup_intent, + message_text="主要给投顾看", + operator_user_id=999, + source_system="feishu", + source_message_id="msg-plan-reply-3bb", + ) + fourth = await executor._execute_query_intent( + intent=fragmented_followup_intent, + message_text="B", + operator_user_id=999, + source_system="feishu", + source_message_id="msg-plan-reply-3bc", + ) + final_result = await executor._execute_query_intent( + intent=fragmented_followup_intent, + message_text="第一版先保留分数、方向判断和一句解释就够了", + operator_user_id=999, + source_system="feishu", + source_message_id="msg-plan-reply-3bd", + ) + + assert "这类 IT 项目来收" in str(first["answer"] or "") + assert str(second["rag_result"]["query_type"]) == "plan" + assert str(third["rag_result"]["query_type"]) == "plan" + assert str(fourth["rag_result"]["query_type"]) == "plan" + assert str(final_result["rag_result"]["query_type"]) == "draft" + + @pytest.mark.asyncio async def test_execute_query_intent_requirement_plan_defaults_to_it_pm_template_for_current_system( monkeypatch, @@ -7040,6 +7979,299 @@ async def test_execute_query_intent_requirement_plan_injects_context_to_llm_for_ assert card["project_nature"] != "发布治理平台" +@pytest.mark.asyncio +async def test_execute_query_intent_requirement_plan_choice_reply_uses_round_option_context_when_semantic_backfill_fails( + monkeypatch, + requirement_plan_db_session, +) -> None: + class FakeExecutor(mixin_execute_query_project.ExecuteQueryProjectMixin): + def __init__(self) -> None: + self.db = requirement_plan_db_session + self.tenant_id = 3 + + async def _fake_build_requirement_plan_next_question(self, **kwargs): # noqa: ANN001 + _ = self, kwargs + return "第三轮:你更希望第一版服务单一用户还是多角色协同?" + + monkeypatch.setattr( + mixin_execute_query_project.ExecuteQueryProjectMixin, + "_build_requirement_plan_next_question", + _fake_build_requirement_plan_next_question, + raising=False, + ) + + conversation_scope_key = build_feishu_conversation_scope_key( + source_chat_id="chat-plan-outfit-backfill-fail", + source_sender_open_id="ou-plan-outfit-backfill-fail", + sender=None, + ) + topic = MountedTopicWorkspace( + tenant_id=3, + topic_key="feishu:chat-plan-outfit-backfill-fail:message:om-plan-outfit-backfill-root", + source_system="feishu", + channel_ref="chat-plan-outfit-backfill-fail", + conversation_scope_key=conversation_scope_key, + root_message_id="om-plan-outfit-backfill-root", + mode="plan", + status="active", + plan_status="collecting", + metadata_json={"plan_mode": True}, + ) + requirement_plan_db_session.add(topic) + await requirement_plan_db_session.flush() + draft = MountedDraftWorkspace( + tenant_id=3, + topic_workspace_id=int(topic.id), + state="collecting", + draft_version=0, + requirement_card={ + "plan_profile": "it_project", + "title": "换季系统自动推荐搭配衣服", + "goal": "先验证推荐结果是否会被真实使用。", + "question_round": 1, + "source_material": "用户:跟你聊个新需求,关于换季系统自动推荐搭配衣服,先别创建,先收第一版。", + "round_option_maps": { + "project_nature": { + "A": "穿搭推荐小工具", + "B": "个性化穿搭推荐平台", + "C": "穿搭决策底座", + } + }, + "last_round_options": { + "field": "project_nature", + "options": [ + {"key": "A", "title": "穿搭推荐小工具"}, + {"key": "B", "title": "个性化穿搭推荐平台"}, + {"key": "C", "title": "穿搭决策底座"}, + ], + "recommendation": {"key": "B"}, + }, + }, + draft_payload=None, + required_confirmations=[], + ) + requirement_plan_db_session.add(draft) + await requirement_plan_db_session.flush() + + executor = FakeExecutor() + second = await executor._build_requirement_planning_direct_result( + message_text="B", + source_system="feishu", + source_message_id="msg-plan-outfit-backfill-2", + source_chat_id="chat-plan-outfit-backfill-fail", + sender=None, + source_sender_open_id="ou-plan-outfit-backfill-fail", + thread_context={ + "root_message_id": "om-plan-outfit-backfill-root", + "reply_to_message_id": "om-plan-outfit-backfill-root", + }, + conversation_key=conversation_scope_key, + topic_switch=None, + requirement_plan_protocol=None, + ) + + assert second["status"] == "succeeded" + draft = ( + await requirement_plan_db_session.execute( + select(MountedDraftWorkspace).where(MountedDraftWorkspace.tenant_id == 3) + ) + ).scalar_one() + card = draft.requirement_card or {} + assert card["project_nature"] == "个性化穿搭推荐平台" + + +@pytest.mark.asyncio +async def test_execute_query_intent_requirement_plan_choice_reply_still_updates_card_when_semantic_backfill_fails( + monkeypatch, + requirement_plan_db_session, +) -> None: + class FakeAdaptiveRAGService: + def __init__(self, db, tenant_id: int): + _ = (db, tenant_id) + + async def answer_question(self, **kwargs): + _ = kwargs + raise AssertionError("LLM-backed requirement plan should not enter heavy RAG path") + + class FakeExecutor(mixin_execute_query_project.ExecuteQueryProjectMixin): + def __init__(self) -> None: + self.db = requirement_plan_db_session + self.tenant_id = 3 + + async def _forced_backfill_failure(self, **kwargs): # noqa: ANN001 + _ = (self, kwargs) + raise RuntimeError("forced semantic backfill failure") + + _install_requirement_plan_round_llm(monkeypatch) + monkeypatch.setattr(mixin_execute_query_project, "AdaptiveRAGService", FakeAdaptiveRAGService) + monkeypatch.setattr( + mixin_execute_query_project.ExecuteQueryProjectMixin, + "_backfill_query_semantic_bundle", + _forced_backfill_failure, + raising=False, + ) + executor = FakeExecutor() + + root_intent = SimpleNamespace( + project_id=None, + metadata={ + "source_chat_id": "chat-plan-outfit-backfill-root", + "source_sender_open_id": "ou-plan-outfit-backfill-root", + "message_intent": "requirement_plan", + "consultant_mode_preference": "clarify", + }, + ) + followup_intent = SimpleNamespace( + project_id=None, + metadata={ + "source_chat_id": "chat-plan-outfit-backfill-root", + "source_sender_open_id": "ou-plan-outfit-backfill-root", + "thread_context": { + "root_message_id": "msg-plan-outfit-backfill-root", + "reply_to_message_id": "msg-plan-outfit-backfill-root", + }, + }, + ) + + first = await executor._execute_query_intent( + intent=root_intent, + message_text="跟你聊个新需求,关于换季系统自动推荐搭配衣服,先别创建,先收第一版。", + operator_user_id=999, + source_system="feishu", + source_message_id="msg-plan-outfit-backfill-root", + ) + second = await executor._execute_query_intent( + intent=followup_intent, + message_text="B", + operator_user_id=999, + source_system="feishu", + source_message_id="msg-plan-outfit-backfill-2", + ) + + assert first["status"] == "succeeded" + assert second["status"] == "succeeded" + draft = ( + await requirement_plan_db_session.execute( + select(MountedDraftWorkspace).where(MountedDraftWorkspace.tenant_id == 3) + ) + ).scalar_one() + card = draft.requirement_card or {} + assert card["project_nature"] == "个性化穿搭推荐平台" + assert "先按这个方向继续记" in str(second["answer"] or "") + + +def test_validate_requirement_plan_round_payload_requires_current_structural_anchor() -> None: + packet = mixin_execute_query_project.ExecuteQueryProjectMixin._build_requirement_plan_round_packet( + missing_field="target_user", + profile="it_project", + message_text="跟你聊个新需求,关于换季系统自动推荐搭配衣服,先别创建,先收第一版。", + card={ + "title": "换季系统自动推荐搭配衣服", + "goal": "先验证推荐是否真的会被使用。", + "project_nature": "个性化穿搭推荐平台", + "question_round": 1, + }, + ) + payload = { + "opening": "🧭 我先按“发布治理平台”这个方向继续收。", + "focus": "现在先把第一版服务对象定住,不然后面口径会飘。", + "options": [ + { + "key": "A", + "title": "普通消费者", + "best_for": "先验证推荐是否真的有帮助", + "suggested_capability": "偏好反馈、季节标签、收藏行为", + "rationale": "能尽快拿到真实使用反馈", + "tradeoff": "需要处理偏好差异", + }, + { + "key": "B", + "title": "穿搭运营团队", + "best_for": "先由运营维护搭配规则和推荐口径", + "suggested_capability": "规则配置、款式标签、人工校验", + "rationale": "更容易控制第一版质量", + "tradeoff": "自动化程度会弱一些", + }, + { + "key": "C", + "title": "品牌导购团队", + "best_for": "结合商品推荐和导购场景做转化验证", + "suggested_capability": "商品库、搭配卡片、导购反馈", + "rationale": "更适合商业转化场景", + "tradeoff": "会更依赖商品数据", + }, + ], + "recommendation": { + "key": "A", + "reason": "先服务普通消费者,最能验证自动推荐是否真的成立。", + }, + "reply_instruction": "你回 A / B / C,我就顺着这条继续往下收。", + } + + with pytest.raises(ValueError, match="missing current structural anchors"): + mixin_execute_query_project.ExecuteQueryProjectMixin._validate_requirement_plan_round_payload( + payload=payload, + packet=packet, + ) + + +def test_validate_requirement_plan_draft_payload_requires_collected_field_alignment() -> None: + card = { + "title": "换季系统自动推荐搭配衣服", + "goal": "先验证用户是否愿意使用换季搭配推荐。", + "project_nature": "个性化穿搭推荐平台", + "target_user": "普通消费者", + "tech_route": "规则推荐版", + "first_scope": "温度、场景、单品搭配建议", + "delivery_mode": "小范围真实用户试用", + "question_round": 4, + "plan_profile": "it_project", + } + packet = mixin_execute_query_project.ExecuteQueryProjectMixin._build_requirement_plan_draft_packet(card=card) + payload = { + "intro": "📋 我先按当前收敛结果,整理成一版更贴近当前业务的需求草稿:", + "title": "换季系统自动推荐搭配衣服", + "one_liner": "先面向普通消费者做一版换季穿搭推荐试用,先把温度、场景和单品搭配建议跑成可验证闭环。", + "goal": "先验证用户是否愿意使用换季搭配推荐,并收集最基础的点击、收藏和反馈信号。", + "project_nature": "这件事更像发布治理平台,需要先把批量执行与治理闭环跑通。", + "target_user": "第一版先服务普通消费者,重点看他们是否会点击、收藏和采纳推荐结果。", + "signal_inputs": "当前至少要用到:用户当前需求描述;季节/温度信息;衣物标签;基础反馈数据。", + "known_info": "目前已经明确:这是换季穿搭推荐需求;第一版先给普通消费者试用;技术路线先按规则推荐版;范围先保留温度、场景、单品搭配建议。", + "tech_route": "当前更适合先走规则推荐版,用季节、温度、场景和衣物标签跑通首轮推荐。", + "first_scope": "第一版先保留温度、场景、单品搭配建议,先验证推荐是否被点击、收藏和采纳。", + "core_flow": "用户进入换季推荐入口 -> 结合温度/场景生成搭配建议 -> 展示单品组合 -> 记录点击/收藏/反馈 -> 继续优化推荐规则", + "delivery_mode": "先做小范围真实用户试用,优先看真实点击、收藏和主观反馈。", + "success_signal": "先保证能稳定给出换季搭配建议,并拿到点击、收藏或反馈中的至少一类真实用户信号。", + "risks": "当前主要风险在冷启动标签质量、搭配规则是否过于死板,以及首轮反馈样本是否足够。", + "pending_items": ["衣物标签口径是否已有现成标准", "首轮试用用户范围和反馈采集方式", "是否需要同步接商品库或衣橱数据"], + "closing": "如果你认可,我下一轮就按这版继续细化;如果要改,直接说改哪一项,我就在这版上改。", + "positioning_one_liner": "面向普通消费者的小范围换季穿搭推荐试用,先跑通基础推荐闭环。", + "summary": "先用规则推荐跑通换季穿搭建议,再根据真实点击、收藏和反馈决定是否扩到更完整的平台能力。", + "target_customers": "普通消费者", + "external_signal_sources": [ + {"name": "天气 API", "purpose": "补充实时温度、体感和降雨变化", "phase": "首版优先接入"}, + {"name": "淘宝开放平台", "purpose": "补充可推荐商品的品类、颜色和季节属性", "phase": "首版优先接入"}, + {"name": "得物开放数据", "purpose": "补充年轻用户偏好的款式热度和潮流变化", "phase": "第二阶段接入"}, + ], + "requirement_items": [ + {"section": "需求目标", "title": "验证用户价值", "description": "验证用户是否愿意使用换季搭配推荐,并留下点击、收藏或反馈。"}, + {"section": "服务对象", "title": "首批用户", "description": "第一版先服务普通消费者。"}, + {"section": "技术路线", "title": "规则推荐版", "description": "先基于季节、温度、场景和衣物标签生成推荐。"}, + {"section": "第一版范围", "title": "最小闭环", "description": "先保留温度、场景、单品搭配建议。"}, + ], + "missing_info": ["衣物标签标准", "试用用户范围", "反馈采集方式"], + "mvp_items": ["温度标签", "场景标签", "单品搭配建议", "基础点击/收藏反馈"], + "team_setup": [{"role": "建议配置", "notes": "产品/运营 1名、后端/算法 1名、前端 1名,先支撑小范围试用即可。"}], + "validation_metrics": {"product": ["推荐点击率", "收藏率", "用户主观反馈"]}, + } + + with pytest.raises(ValueError, match="structural bindings drift"): + mixin_execute_query_project.ExecuteQueryProjectMixin._validate_requirement_plan_draft_payload( + payload=payload, + packet=packet, + ) + + @pytest.mark.asyncio async def test_execute_query_intent_requirement_plan_generates_contextual_outfit_draft_instead_of_old_it_template( monkeypatch, @@ -7721,6 +8953,408 @@ async def test_execute_query_intent_locked_requirement_mode_redirects_generic_qu assert str(result["rag_result"]["query_type"]) == "plan" +@pytest.mark.asyncio +async def test_execute_query_intent_exit_requirement_mode_without_active_plan_short_circuits( + monkeypatch, + requirement_plan_db_session, +) -> None: + class FakeAdaptiveRAGService: + def __init__(self, db, tenant_id: int): + _ = (db, tenant_id) + + async def answer_question(self, **kwargs): + raise AssertionError(f"exit requirement mode without active plan should not hit generic RAG: {kwargs}") + + class FakeExecutor(mixin_execute_query_project.ExecuteQueryProjectMixin): + def __init__(self) -> None: + self.db = requirement_plan_db_session + self.tenant_id = 3 + + _install_requirement_plan_query_bundle( + monkeypatch, + message_intent="requirement_mode_exit", + consultant_mode_preference="", + ) + monkeypatch.setattr(mixin_execute_query_project, "AdaptiveRAGService", FakeAdaptiveRAGService) + + executor = FakeExecutor() + intent = SimpleNamespace( + project_id=None, + metadata={ + "source_chat_id": "chat-exit-plan-idle", + "source_sender_open_id": "ou-exit-plan-idle", + "message_intent": "requirement_mode_exit", + }, + ) + + result = await executor._execute_query_intent( + intent=intent, + message_text="退出需求模式", + operator_user_id=999, + source_system="feishu", + source_message_id="msg-exit-plan-idle-1", + ) + + assert result["status"] == "succeeded" + assert "当前不在需求模式" in str(result["answer"] or "") + assert str(result["workflow_route"]) == "query.answer" + assert str(result["rag_result"]["query_type"]) == "status" + + +@pytest.mark.asyncio +async def test_execute_query_intent_exit_requirement_mode_suspends_active_plan_from_structured_intent( + monkeypatch, + requirement_plan_db_session, +) -> None: + class FakeAdaptiveRAGService: + def __init__(self, db, tenant_id: int): + _ = (db, tenant_id) + + async def answer_question(self, **kwargs): + raise AssertionError(f"structured requirement exit should not hit generic RAG: {kwargs}") + + class FakeExecutor(mixin_execute_query_project.ExecuteQueryProjectMixin): + def __init__(self) -> None: + self.db = requirement_plan_db_session + self.tenant_id = 3 + + _install_requirement_plan_query_bundle( + monkeypatch, + message_intent="requirement_mode_exit", + consultant_mode_preference="", + ) + monkeypatch.setattr(mixin_execute_query_project, "AdaptiveRAGService", FakeAdaptiveRAGService) + + conversation_scope_key = build_feishu_conversation_scope_key( + source_chat_id="chat-exit-plan-active", + source_sender_open_id="ou-exit-plan-active", + sender=None, + ) + topic = MountedTopicWorkspace( + tenant_id=3, + topic_key="feishu:chat-exit-plan-active:root:om-exit-plan-active-root", + source_system="feishu", + channel_ref="chat-exit-plan-active", + conversation_scope_key=conversation_scope_key, + mode="plan", + status="active", + plan_status="collecting", + metadata_json={"plan_mode": True}, + root_message_id="om-exit-plan-active-root", + ) + requirement_plan_db_session.add(topic) + await requirement_plan_db_session.flush() + requirement_plan_db_session.add( + MountedDraftWorkspace( + tenant_id=3, + topic_workspace_id=int(topic.id), + state="collecting", + draft_version=0, + requirement_card={"plan_profile": "it_project", "title": "自动开票系统"}, + required_confirmations=[], + ) + ) + await requirement_plan_db_session.flush() + + executor = FakeExecutor() + intent = SimpleNamespace( + project_id=None, + metadata={ + "source_chat_id": "chat-exit-plan-active", + "source_sender_open_id": "ou-exit-plan-active", + "message_intent": "requirement_mode_exit", + }, + ) + + result = await executor._execute_query_intent( + intent=intent, + message_text="退出需求模式", + operator_user_id=999, + source_system="feishu", + source_message_id="msg-exit-plan-active-1", + ) + + assert result["status"] == "succeeded" + assert "先暂停" in str(result["answer"] or "") + assert str(result["workflow_route"]) == "requirement_plan.collect" + assert str(result["rag_result"]["query_type"]) == "plan" + assert topic.status == "suspended" + assert topic.plan_status == "suspended" + assert topic.pending_actions == ["resume_requirement_plan"] + + +@pytest.mark.asyncio +async def test_execute_query_intent_status_query_after_requirement_mode_exit_returns_to_normal_query_answer( + monkeypatch, + requirement_plan_db_session, +) -> None: + captured: dict[str, object] = {} + + class FakeAdaptiveRAGService: + def __init__(self, db, tenant_id: int): + _ = (db, tenant_id) + + async def answer_question(self, **kwargs): + captured["rag_kwargs"] = dict(kwargs) + return { + "answer": "项目 #1《Pixelle-Video 集成》当前主要在联调推进阶段,任务和信号都还在继续收口。", + "query_type": "status", + } + + class FakeExecutor(mixin_execute_query_project.ExecuteQueryProjectMixin): + def __init__(self) -> None: + self.db = requirement_plan_db_session + self.tenant_id = 3 + + _install_requirement_plan_query_bundle( + monkeypatch, + message_intent="requirement_mode_exit", + consultant_mode_preference="", + ) + monkeypatch.setattr(mixin_execute_query_project, "AdaptiveRAGService", FakeAdaptiveRAGService) + + conversation_scope_key = build_feishu_conversation_scope_key( + source_chat_id="chat-exit-then-status", + source_sender_open_id="ou-exit-then-status", + sender=None, + ) + topic = MountedTopicWorkspace( + tenant_id=3, + topic_key="feishu:chat-exit-then-status:root:om-exit-then-status-root", + source_system="feishu", + channel_ref="chat-exit-then-status", + conversation_scope_key=conversation_scope_key, + mode="plan", + status="active", + plan_status="collecting", + metadata_json={"plan_mode": True}, + root_message_id="om-exit-then-status-root", + ) + requirement_plan_db_session.add(topic) + await requirement_plan_db_session.flush() + requirement_plan_db_session.add( + MountedDraftWorkspace( + tenant_id=3, + topic_workspace_id=int(topic.id), + state="collecting", + draft_version=0, + requirement_card={"plan_profile": "it_project", "title": "Pixelle-Video 集成"}, + required_confirmations=[], + ) + ) + await requirement_plan_db_session.flush() + + executor = FakeExecutor() + exit_intent = SimpleNamespace( + project_id=None, + metadata={ + "source_chat_id": "chat-exit-then-status", + "source_sender_open_id": "ou-exit-then-status", + "message_intent": "requirement_mode_exit", + }, + ) + + exit_result = await executor._execute_query_intent( + intent=exit_intent, + message_text="退出需求模式", + operator_user_id=999, + source_system="feishu", + source_message_id="msg-exit-then-status-1", + ) + + assert exit_result["status"] == "succeeded" + assert topic.status == "suspended" + assert topic.plan_status == "suspended" + + async def _fake_status_query_bundle(*, message_text: str, source_system: str, metadata=None): + _ = (message_text, metadata) + return WorkflowBundle( + bundle_id="bundle-post-exit-status", + semantic_version="v1", + source_system=source_system, + primary_workflow="query_flow", + workflows=[ + WorkflowPlan( + workflow_type="query_flow", + workflow_action="answer", + target_scope="project", + operation_codes=["query.answer"], + requires_confirmation=False, + confidence=0.94, + metadata={"message_intent": "query"}, + ) + ], + message_gate="auto", + reply_contract=ReplyContract(mode="text", template_key="workflow_bundle", summary="query_flow"), + diagnostics={ + "message_intent": "query", + "consultant_mode_preference": "", + "topic_switch": "new", + "business_frame": { + "goal_kind": "status_lookup", + "target_scope": "explicit_project", + "resource_role": "project_object", + "source_kind": "internal", + "expected_answer_shape": "bounded_explanation", + "conversation_posture": "business_operating", + "project_binding": 1, + "evidence_mode": "verified", + }, + }, + ) + + monkeypatch.setattr( + mixin_execute_query_project.WorkflowSemanticPlanner, + "aplan_message", + _fake_status_query_bundle, + ) + + status_intent = SimpleNamespace( + project_id=None, + metadata={ + "source_chat_id": "chat-exit-then-status", + "source_sender_open_id": "ou-exit-then-status", + "message_intent": "query", + }, + ) + + result = await executor._execute_query_intent( + intent=status_intent, + message_text="Pixelle-Video 集成现在推进到哪一步了?", + operator_user_id=999, + source_system="feishu", + source_message_id="msg-exit-then-status-2", + ) + + assert result["status"] == "succeeded" + assert str(result["rag_result"]["query_type"]) == "status" + assert "项目 #1《Pixelle-Video 集成》" in str(result["answer"] or "") + assert "退出需求模式" not in str(result["answer"] or "") + rag_kwargs = captured.get("rag_kwargs") + assert isinstance(rag_kwargs, dict) + assert rag_kwargs["question"] == "Pixelle-Video 集成现在推进到哪一步了?" + + +@pytest.mark.asyncio +async def test_execute_query_intent_business_query_after_casual_chat_returns_to_normal_project_status( + monkeypatch, +) -> None: + captured: dict[str, object] = {} + + class FakeAdaptiveRAGService: + def __init__(self, db, tenant_id: int): + _ = (db, tenant_id) + + async def answer_question(self, **kwargs): + captured["rag_kwargs"] = dict(kwargs) + return { + "answer": "项目 #18《企业知识库问答AI项目》当前主要卡在语料清洗和权限边界收口。", + "query_type": "status", + } + + class FakeExecutor(mixin_execute_query_project.ExecuteQueryProjectMixin): + def __init__(self) -> None: + self.db = object() + self.tenant_id = 3 + + monkeypatch.setattr(mixin_execute_query_project, "AdaptiveRAGService", FakeAdaptiveRAGService) + executor = FakeExecutor() + + casual_intent = SimpleNamespace( + project_id=None, + metadata={ + "semantic_bundle": { + "diagnostics": { + "message_intent": "casual_chat", + "consultant_mode_preference": "brainstorm", + } + }, + "source_chat_id": "chat-casual-resume", + "source_sender_open_id": "ou-casual-resume", + }, + ) + + first = await executor._execute_query_intent( + intent=casual_intent, + message_text="先不聊需求,陪我放松两句。", + operator_user_id=999, + source_system="feishu", + source_message_id="msg-casual-resume-1", + ) + + assert first["status"] == "succeeded" + assert str(first["rag_result"]["query_type"]) == "chat" + + async def _fake_status_query_bundle(*, message_text: str, source_system: str, metadata=None): + _ = (message_text, metadata) + return WorkflowBundle( + bundle_id="bundle-post-chat-status", + semantic_version="v1", + source_system=source_system, + primary_workflow="query_flow", + workflows=[ + WorkflowPlan( + workflow_type="query_flow", + workflow_action="answer", + target_scope="project", + operation_codes=["query.answer"], + requires_confirmation=False, + confidence=0.95, + metadata={"message_intent": "query"}, + ) + ], + message_gate="auto", + reply_contract=ReplyContract(mode="text", template_key="workflow_bundle", summary="query_flow"), + diagnostics={ + "message_intent": "query", + "consultant_mode_preference": "", + "topic_switch": "new", + "business_frame": { + "goal_kind": "status_lookup", + "target_scope": "explicit_project", + "resource_role": "project_object", + "source_kind": "internal", + "expected_answer_shape": "bounded_explanation", + "conversation_posture": "business_operating", + "project_binding": 18, + "subject_binding": "企业知识库问答AI项目", + "evidence_mode": "verified", + }, + }, + ) + + monkeypatch.setattr( + mixin_execute_query_project.WorkflowSemanticPlanner, + "aplan_message", + _fake_status_query_bundle, + ) + + resume_intent = SimpleNamespace( + project_id=None, + metadata={ + "source_chat_id": "chat-casual-resume", + "source_sender_open_id": "ou-casual-resume", + "message_intent": "query", + }, + ) + + second = await executor._execute_query_intent( + intent=resume_intent, + message_text="回到企业知识库问答AI项目,继续说它现在卡在哪。", + operator_user_id=999, + source_system="feishu", + source_message_id="msg-casual-resume-2", + ) + + assert second["status"] == "succeeded" + assert str(second["rag_result"]["query_type"]) == "status" + assert "企业知识库问答AI项目" in str(second["answer"] or "") + assert "放松" not in str(second["answer"] or "") + rag_kwargs = captured.get("rag_kwargs") + assert isinstance(rag_kwargs, dict) + assert rag_kwargs["question"] == "回到企业知识库问答AI项目,继续说它现在卡在哪。" + + @pytest.mark.asyncio async def test_execute_query_intent_converts_query_answer_requirement_contract_into_requirement_plan_result( monkeypatch, @@ -9621,6 +11255,7 @@ async def test_build_requirement_planning_direct_result_refuses_choice_only_draf }, conversation_key=conversation_scope_key, topic_switch=None, + requirement_plan_protocol={"choice": "C", "content_text": "继续下一轮"}, ) assert result["status"] == "succeeded" @@ -9637,6 +11272,145 @@ async def test_build_requirement_planning_direct_result_refuses_choice_only_draf assert refreshed_draft.draft_payload is None +@pytest.mark.asyncio +async def test_build_requirement_planning_direct_result_prefers_structured_protocol_over_legacy_parser( + monkeypatch, + requirement_plan_db_session, +) -> None: + class FakeExecutor(mixin_execute_query_project.ExecuteQueryProjectMixin): + def __init__(self) -> None: + self.db = requirement_plan_db_session + self.tenant_id = 3 + + conversation_scope_key = build_feishu_conversation_scope_key( + source_chat_id="chat-plan-structured-protocol", + source_sender_open_id="ou-plan-structured-protocol", + sender=None, + ) + topic = MountedTopicWorkspace( + tenant_id=3, + topic_key="feishu:chat-plan-structured-protocol:message:om-plan-structured-protocol-root", + source_system="feishu", + channel_ref="chat-plan-structured-protocol", + conversation_scope_key=conversation_scope_key, + root_message_id="om-plan-structured-protocol-root", + mode="plan", + status="active", + plan_status="collecting", + metadata_json={"plan_mode": True}, + ) + requirement_plan_db_session.add(topic) + await requirement_plan_db_session.flush() + draft = MountedDraftWorkspace( + tenant_id=3, + topic_workspace_id=int(topic.id), + state="collecting", + draft_version=0, + requirement_card={ + "plan_profile": "it_project", + "title": "自动开票系统", + "goal": "先把自动开票系统的第一版需求收敛清楚。", + "project_nature": "业务平台型", + "target_user": "财务与业务团队", + "tech_route": "模块化服务", + "question_round": 2, + }, + draft_payload=None, + required_confirmations=[], + ) + requirement_plan_db_session.add(draft) + await requirement_plan_db_session.flush() + + def _raise_legacy_parser(message_text: str): + raise AssertionError(f"legacy parser should not be used: {message_text}") + + monkeypatch.setattr( + mixin_execute_query_project.ExecuteQueryProjectMixin, + "_parse_requirement_plan_protocol_reply", + classmethod(lambda cls, message_text: _raise_legacy_parser(message_text)), + ) + + executor = FakeExecutor() + result = await executor._build_requirement_planning_direct_result( + message_text="我选D", + source_system="feishu", + source_message_id="msg-plan-structured-protocol", + source_chat_id="chat-plan-structured-protocol", + sender=None, + source_sender_open_id="ou-plan-structured-protocol", + thread_context={ + "root_message_id": "om-plan-structured-protocol-root", + "reply_to_message_id": "om-plan-structured-protocol-root", + }, + conversation_key=conversation_scope_key, + topic_switch=None, + requirement_plan_protocol={ + "choice": "D", + "action": "clarify", + "content_text": "", + }, + ) + + assert result["status"] == "succeeded" + assert result["workflow_route"] == mixin_execute_query_project.ROUTE_REQUIREMENT_PLAN_COLLECT + assert "这轮不用硬选" in str(result["answer"] or "") + + +@pytest.mark.asyncio +async def test_build_requirement_planning_direct_result_uses_plain_seed_without_legacy_protocol_parser( + monkeypatch, + requirement_plan_db_session, +) -> None: + class FakeExecutor(mixin_execute_query_project.ExecuteQueryProjectMixin): + def __init__(self) -> None: + self.db = requirement_plan_db_session + self.tenant_id = 3 + + def _raise_legacy_parser(message_text: str): + raise AssertionError(f"legacy parser should not be used: {message_text}") + + monkeypatch.setattr( + mixin_execute_query_project.ExecuteQueryProjectMixin, + "_parse_requirement_plan_protocol_reply", + classmethod(lambda cls, message_text: _raise_legacy_parser(message_text)), + ) + async def _fake_build_requirement_plan_next_question(self, **kwargs): + _ = (self, kwargs) + return "下一轮:请确认第一版范围。" + + monkeypatch.setattr( + mixin_execute_query_project.ExecuteQueryProjectMixin, + "_build_requirement_plan_next_question", + _fake_build_requirement_plan_next_question, + raising=False, + ) + + conversation_scope_key = build_feishu_conversation_scope_key( + source_chat_id="chat-plan-plain-seed", + source_sender_open_id="ou-plan-plain-seed", + sender=None, + ) + executor = FakeExecutor() + result = await executor._build_requirement_planning_direct_result( + message_text="想做一个自动开票系统,收到开票请求后自动开票并把发票图片发给客户。", + source_system="feishu", + source_message_id="msg-plan-plain-seed", + source_chat_id="chat-plan-plain-seed", + sender=None, + source_sender_open_id="ou-plan-plain-seed", + thread_context={ + "root_message_id": "om-plan-plain-seed-root", + "reply_to_message_id": "om-plan-plain-seed-root", + }, + conversation_key=conversation_scope_key, + topic_switch="new", + ) + + assert result["status"] == "succeeded" + assert result["workflow_route"] == mixin_execute_query_project.ROUTE_REQUIREMENT_PLAN_COLLECT + assert "下一轮" in str(result["answer"] or "") + + @pytest.mark.asyncio async def test_build_requirement_planning_direct_result_repairs_polluted_thread_from_unique_grounded_scope_snapshot( monkeypatch, @@ -9806,6 +11580,7 @@ async def test_build_requirement_planning_direct_result_repairs_polluted_thread_ }, conversation_key=conversation_scope_key, topic_switch=None, + requirement_plan_protocol={"choice": "C", "content_text": "继续"}, ) assert result["status"] == "succeeded" @@ -9990,6 +11765,7 @@ async def test_repair_requirement_plan_workspace_from_reply_context_prefers_stru }, conversation_key=conversation_scope_key, message_text="确认A,继续往下收。", + requirement_plan_protocol={"choice": "A", "content_text": "继续往下收"}, ) assert repaired_topic is topic @@ -10388,6 +12164,7 @@ async def test_build_requirement_planning_direct_result_prefers_reply_anchor_top thread_context={"reply_to_message_id": "om-plan-anchor-target-root"}, conversation_key=conversation_scope_key, topic_switch=None, + requirement_plan_protocol={"choice": "A", "content_text": "先按轻量晨报工具继续往下收"}, ) assert result["status"] == "succeeded" @@ -10702,48 +12479,23 @@ async def test_execute_query_intent_contextual_draft_generation_bootstraps_requi self.db = requirement_plan_db_session self.tenant_id = 3 - async def fake_generate_structured_object(prompt: str, **kwargs): # noqa: ANN001 - function_name = str(kwargs.get("function_name") or "") - if function_name != "pmflow_requirement_plan_draft": - raise AssertionError(f"unexpected function_name: {function_name}") - return StructuredExtractionResult( - payload={ - "intro": "📋 我先按当前上下文整理出一版主播协同作战平台草稿:", + async def _fake_generate_requirement_plan_confirmation_draft(self, *, card: dict[str, object], revision_note=None): # noqa: ANN001 + _ = self, revision_note + assert str(card.get("title") or "") == "给运营公司打造主播协同作战平台" + assert "三个问题都有" in str(card.get("source_material") or "") + return ( + "📋 我先按当前上下文整理出一版主播协同作战平台草稿:\n\n" + "这版先把开播前脚本、直播中应变、下播后复盘三个闭环收进去。", + { "title": "给运营公司打造主播协同作战平台", - "one_liner": "先围绕开播前脚本、直播中应变、下播后复盘三类卡点,做一版真人主播+AI陪跑的协同平台草稿。", - "goal": "先验证主播在关键节点是否能得到更及时的脚本、应变和复盘支持。", "project_nature": "主播协同作战平台", "target_user": "运营公司主播团队", - "signal_inputs": "当前至少包含主播脚本准备、直播中应变、下播后复盘三类上下文信息。", - "known_info": "目前已经确认:需求围绕给运营公司打造主播能力;用户选择了 C 方向;且明确三类问题都会遇到。", - "tech_route": "先做真人主播+AI陪跑协同版,后续再补更重的实时分析能力。", - "first_scope": "第一版先保留开播前脚本辅助、直播中应变提示、下播后复盘整理三个闭环。", - "core_flow": "主播进入直播准备 -> 生成脚本与注意事项 -> 直播中接收应变提示 -> 下播后自动整理复盘 -> 沉淀可复用策略", - "delivery_mode": "先在单个主播团队内做试点陪跑。", - "success_signal": "先验证主播实际采用率、直播中应变响应率和复盘复用率。", - "risks": "主要风险在主播现场响应节奏、脚本质量和复盘结构化沉淀的稳定性。", - "pending_items": ["优先陪跑哪类主播", "直播平台接入方式", "复盘模板颗粒度"], - "closing": "如果你认可,我下一轮就按这版继续细化执行方案。", - "summary": "先把三类卡点做成一条可陪跑的主播协同闭环,再决定是否扩成更重的平台。", - "positioning_one_liner": "面向运营公司主播团队的真人+AI陪跑协同平台首版。", - "target_customers": "运营公司主播团队", - "external_signal_sources": [ - {"name": "抖音开放平台", "purpose": "补直播事件与互动节奏数据", "phase": "首版优先接入"}, - {"name": "快手服务商 API", "purpose": "补直播中场控和互动数据", "phase": "首版优先接入"}, - {"name": "企业微信会话归档", "purpose": "补主播复盘与督导回流", "phase": "第二阶段接入"}, - ], - "requirement_items": [ - {"section": "需求目标", "title": "先补三类卡点", "description": "覆盖开播前脚本、直播中应变、下播后复盘。"}, - ], - "missing_info": ["优先主播类型"], - "mvp_items": ["脚本辅助", "应变提示", "复盘整理"], - "team_setup": [{"role": "建议配置", "notes": "产品/运营 1 名、后端/AI 1 名、前端 1 名即可试点。"}], - "validation_metrics": {"product": ["采用率", "应变响应率", "复盘复用率"]}, + "first_scope": "开播前脚本、直播中应变、下播后复盘", + }, + { + "title": "给运营公司打造主播协同作战平台", + "goal": "先验证主播在关键节点是否能得到更及时的脚本、应变和复盘支持。", }, - source_field="content", - provider="fake", - model="fake", - validation="ok", ) async def _fake_recommend_requirement_plan_owner_for_draft(self, *, card: dict[str, object]): # noqa: ANN001 @@ -10758,9 +12510,10 @@ async def test_execute_query_intent_contextual_draft_generation_bootstraps_requi } monkeypatch.setattr( - mixin_execute_query_project.llm_service, - "generate_structured_object", - fake_generate_structured_object, + mixin_execute_query_project.ExecuteQueryProjectMixin, + "_generate_requirement_plan_confirmation_draft", + _fake_generate_requirement_plan_confirmation_draft, + raising=False, ) monkeypatch.setattr( mixin_execute_query_project.ExecuteQueryProjectMixin, @@ -11999,8 +13752,12 @@ async def test_execute_query_intent_short_circuits_project_existence_lookup_with def __init__(self, db, tenant_id: int): _ = (db, tenant_id) - async def _try_fast_project_existence_answer(self, *, clean_q: str, project_id): - _ = (clean_q, project_id) + @staticmethod + def _extract_project_lookup_keyword(question: str): # pragma: no cover - should not be called + raise AssertionError(f"lexical project lookup extractor should be skipped: {question}") + + async def _try_fast_project_existence_answer(self, *, clean_q: str, project_id, query_semantics=None): + _ = (clean_q, project_id, query_semantics) return { "answer": "有,当前匹配到 1 个相关项目:\n#101 AI客服(状态:active)", "sources": [{"source": "db", "label": "Project database", "score": 0.99, "metadata": {"table": "projects"}}], @@ -12064,6 +13821,74 @@ async def test_execute_query_intent_short_circuits_project_existence_lookup_with assert str(result["rag_result"]["query_type"]) == "status" +@pytest.mark.asyncio +async def test_execute_query_intent_project_lookup_shortcut_requires_structured_intent(monkeypatch) -> None: + class FakeAdaptiveRAGService: + def __init__(self, db, tenant_id: int): + _ = (db, tenant_id) + + @staticmethod + def _extract_project_lookup_keyword(question: str): # pragma: no cover - should not be called + raise AssertionError(f"lexical project lookup extractor should be skipped: {question}") + + async def _try_fast_project_existence_answer(self, **kwargs): # pragma: no cover - should not be called + raise AssertionError(f"project lookup shortcut should stay disabled without structured intent: {kwargs}") + + async def answer_question(self, **kwargs): + return {"answer": "走了普通问答链", "rag_result": {"query_type": "status"}} + + class FakeExecutor(mixin_execute_query_project.ExecuteQueryProjectMixin): + def __init__(self) -> None: + self.db = object() + self.tenant_id = 3 + + async def _fake_aplan_message(*, message_text: str, source_system: str, metadata=None): + _ = (message_text, metadata) + return WorkflowBundle( + bundle_id="bundle-project-lookup-no-shortcut", + semantic_version="v1", + source_system=source_system, + primary_workflow="query_flow", + workflows=[ + WorkflowPlan( + workflow_type="query_flow", + workflow_action="answer", + target_scope="global", + operation_codes=["query.answer"], + requires_confirmation=False, + confidence=0.92, + metadata={"message_intent": "business_review"}, + ) + ], + message_gate="auto", + reply_contract=ReplyContract(mode="text", template_key="workflow_bundle", summary="query_flow"), + diagnostics={ + "message_intent": "general_query", + "consultant_mode_preference": "", + }, + ) + + monkeypatch.setattr(mixin_execute_query_project, "AdaptiveRAGService", FakeAdaptiveRAGService) + monkeypatch.setattr( + mixin_execute_query_project.WorkflowSemanticPlanner, + "aplan_message", + _fake_aplan_message, + ) + executor = FakeExecutor() + intent = SimpleNamespace(project_id=6, metadata={}) + + result = await executor._execute_query_intent( + intent=intent, + message_text="项目列表给我看看", + operator_user_id=999, + source_system="feishu", + source_message_id="msg-chat-project-lookup-structured-only", + ) + + assert result["answer"] == "走了普通问答链" + assert result["status"] == "succeeded" + + @pytest.mark.asyncio async def test_draft_confirmation_route_does_not_hijack_explicit_signal_source_request() -> None: class FakeSession: @@ -12146,7 +13971,13 @@ async def test_should_route_to_draft_confirmation_session_prioritizes_explicit_r channel_ref="chat-active", source_sender_open_id="ou_active", message_text="确认", - ingress_options={"thread_context": {"reply_to_message_id": "msg-bot-reply-1"}}, + ingress_options={ + "thread_context": {"reply_to_message_id": "msg-bot-reply-1"}, + "semantic_bundle": { + "primary_workflow": "followup_flow", + "workflows": [{"workflow_type": "followup_flow", "workflow_action": "confirm"}], + }, + }, ) assert should_route is True @@ -12444,6 +14275,30 @@ async def test_qa_session_detail_and_state_replay_expose_rich_runtime_fields(ext "affected_signal_sources": [], "external_action": "先验证映射,再决定是否纳入当前项目计划。", }, + "analysis_packet": { + "packet_version": "v1", + "packet_type": "project_fit_analysis", + "conclusion": "现在还不能直接说这条外部变化已经适合 Alpha。", + "focus_objects": [ + { + "name": "外部信息映射", + "kind": "capability", + "stance": "better_fit", + "reason": "这一步更适合承接外部变化和正式对象之间的映射判断。", + }, + { + "name": "需求 intake", + "kind": "capability", + "stance": "not_fit", + "reason": "这一步仍然是项目主链入口,不适合直接挂外部趋势判断。", + }, + ], + "fit_verdict": "fit_later", + "preferred_layer": "外部信息映射", + "rejected_layer": "需求 intake", + "missing_mapping": "还缺 Alpha 当前正式对象与这条外部变化之间的直接映射。", + "next_step": "先确认外部变化会影响 Alpha 的哪个正式对象。", + }, "handoff_mode": "offer_after_answer", "can_enter_plan": True, "recommended_next_step": "如果你要,我可以基于当前结论进入 plan,继续把需求范围和下一步收清楚。", @@ -12504,6 +14359,8 @@ async def test_qa_session_detail_and_state_replay_expose_rich_runtime_fields(ext 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]["analysis_packet"]["packet_type"] == "project_fit_analysis" + assert detail["messages"][0]["analysis_packet"]["focus_objects"][0]["name"] == "外部信息映射" 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"] @@ -13224,7 +15081,19 @@ async def test_fast_project_progress_answer_handles_risk_blocker_questions() -> result = await rag._try_fast_project_progress_answer( clean_q="帮我看看跨境直播项目最近有哪些风险和阻塞", project_id=None, - query_semantics={"subject_focus": "project", "status_focus": "risk_or_blocker"}, + query_semantics={ + "status_focus": "risk_or_blocker", + "business_frame": { + "goal_kind": "status_lookup", + "target_scope": "explicit_project", + "resource_role": "project_object", + "source_kind": "internal", + "expected_answer_shape": "bounded_explanation", + "conversation_posture": "business_operating", + "project_binding": 104, + "evidence_mode": "verified", + }, + }, ) assert result is not None @@ -13294,7 +15163,19 @@ async def test_fast_project_progress_answer_handles_risk_blocker_questions_witho result = await rag._try_fast_project_progress_answer( clean_q="帮我看看 AI 客服工具项目最近有哪些风险和阻塞", project_id=None, - query_semantics={"subject_focus": "project", "status_focus": "risk_or_blocker"}, + query_semantics={ + "status_focus": "risk_or_blocker", + "business_frame": { + "goal_kind": "status_lookup", + "target_scope": "explicit_project", + "resource_role": "project_object", + "source_kind": "internal", + "expected_answer_shape": "bounded_explanation", + "conversation_posture": "business_operating", + "project_binding": 101, + "evidence_mode": "verified", + }, + }, ) assert result is not None @@ -13304,6 +15185,462 @@ async def test_fast_project_progress_answer_handles_risk_blocker_questions_witho assert "当前最主要的卡点:项目还没有形成任务拆解和执行节奏,这也是当前最直接的阻塞信号。" in answer +@pytest.mark.asyncio +async def test_fast_project_progress_answer_handles_risk_blocker_questions_for_ai_analysis_engine() -> None: + project = SimpleNamespace( + id=2, + title="AI 分析引擎优化", + name="AI 分析引擎优化", + status="active", + owner_id=None, + updated_at=datetime(2026, 4, 8, 12, 0), + description="优化分析引擎的速度、稳定性和结论质量。", + ) + + class FakeScalarResult: + def __init__(self, *, scalar=None, rows=None) -> None: + self._scalar = scalar + self._rows = list(rows or []) + + def scalar_one_or_none(self): + return self._scalar + + def scalars(self): + return SimpleNamespace(all=lambda: list(self._rows)) + + def all(self): + return list(self._rows) + + class FakeDB: + async def execute(self, stmt): + sql = str(stmt).lower() + if "from projects" in sql: + return FakeScalarResult(scalar=project) + if "from tasks" in sql: + return FakeScalarResult(rows=[]) + if "from signals" in sql: + return FakeScalarResult(rows=[]) + if "from users" in sql: + return FakeScalarResult(scalar=None) + raise AssertionError(sql) + + class FakeRAG( + mixin_fast_answer_project_main.AdaptiveRAGFastAnswerProjectMainMixin, + AdaptiveRAGConversationUtilsAMixinP1, + ): + def __init__(self) -> None: + self.db = FakeDB() + self.tenant_id = 1 + + async def _resolve_project_scope_for_question(self, **kwargs): + _ = kwargs + return (2, "AI 分析引擎优化", "AI 分析引擎优化") + + async def _execute_external_query_gateway(self, **kwargs): + _ = kwargs + return ([], {"mode": "external_query_gateway_empty"}) + + rag = FakeRAG() + result = await rag._try_fast_project_progress_answer( + clean_q="AI 分析引擎优化这个项目当前最明显的卡点是什么?", + project_id=None, + query_semantics={ + "status_focus": "risk_or_blocker", + "business_frame": { + "goal_kind": "status_lookup", + "target_scope": "explicit_project", + "resource_role": "project_object", + "source_kind": "internal", + "expected_answer_shape": "bounded_explanation", + "conversation_posture": "business_operating", + "project_binding": 2, + "evidence_mode": "verified", + }, + }, + ) + + assert result is not None + answer = str(result["answer"] or "") + assert "项目 #2《AI 分析引擎优化》" in answer + assert "项目目前处于:立项后待拆解阶段" in answer + assert "当前最主要的卡点:项目还没有形成任务拆解和执行节奏,这也是当前最直接的阻塞信号。" in answer + + +@pytest.mark.asyncio +async def test_fast_project_progress_answer_handles_risk_blocker_questions_for_finance_reconciliation() -> None: + project = SimpleNamespace( + id=44, + title="财务冲账协同", + name="财务冲账协同", + status="active", + owner_id=None, + updated_at=datetime(2026, 4, 8, 15, 0), + description="让财务冲账流程和协同对象更快对齐。", + ) + tasks = [ + SimpleNamespace(status="todo", updated_at=datetime(2026, 4, 8, 12, 0), created_at=datetime(2026, 4, 8, 10, 0)), + SimpleNamespace( + status="in_progress", + updated_at=datetime(2026, 4, 8, 13, 0), + created_at=datetime(2026, 4, 8, 9, 30), + ), + ] + + class FakeScalarResult: + def __init__(self, *, scalar=None, rows=None) -> None: + self._scalar = scalar + self._rows = list(rows or []) + + def scalar_one_or_none(self): + return self._scalar + + def scalars(self): + return SimpleNamespace(all=lambda: list(self._rows)) + + def all(self): + return list(self._rows) + + class FakeDB: + async def execute(self, stmt): + sql = str(stmt).lower() + if "from projects" in sql: + return FakeScalarResult(scalar=project) + if "from tasks" in sql: + return FakeScalarResult(rows=tasks) + if "from signals" in sql: + return FakeScalarResult(rows=[]) + if "from users" in sql: + return FakeScalarResult(scalar=None) + raise AssertionError(sql) + + class FakeRAG( + mixin_fast_answer_project_main.AdaptiveRAGFastAnswerProjectMainMixin, + AdaptiveRAGConversationUtilsAMixinP1, + ): + def __init__(self) -> None: + self.db = FakeDB() + self.tenant_id = 1 + + async def _resolve_project_scope_for_question(self, **kwargs): + _ = kwargs + return (44, "财务冲账协同", "财务冲账协同") + + async def _execute_external_query_gateway(self, **kwargs): + _ = kwargs + return ([], {"mode": "external_query_gateway_empty"}) + + rag = FakeRAG() + result = await rag._try_fast_project_progress_answer( + clean_q="财务冲账协同最近最大的阻塞是流程问题,还是对象还没拆清?", + project_id=None, + query_semantics={ + "status_focus": "risk_or_blocker", + "business_frame": { + "goal_kind": "status_lookup", + "target_scope": "explicit_project", + "resource_role": "project_object", + "source_kind": "internal", + "expected_answer_shape": "bounded_explanation", + "conversation_posture": "business_operating", + "project_binding": 44, + "evidence_mode": "verified", + }, + }, + ) + + assert result is not None + answer = str(result["answer"] or "") + assert "项目 #44《财务冲账协同》" in answer + assert "项目目前处于:执行中但存在阻塞" in answer + assert "任务进度:共 2 项,已完成 0,进行中 1,待处理 1" in answer + assert "负责人需要拍板:确认当前阻塞点属于范围问题、资源问题还是依赖问题。" in answer + + +@pytest.mark.asyncio +async def test_fast_project_progress_answer_handles_risk_blocker_questions_for_ai_future_research() -> None: + project = SimpleNamespace( + id=49, + title="AI发展前景研究", + name="AI发展前景研究", + status="active", + owner_id=None, + updated_at=datetime(2026, 4, 10, 10, 0), + description="跟踪 AI 行业政策、融资并购和产业趋势变化。", + ) + + class FakeScalarResult: + def __init__(self, *, scalar=None, rows=None) -> None: + self._scalar = scalar + self._rows = list(rows or []) + + def scalar_one_or_none(self): + return self._scalar + + def scalars(self): + return SimpleNamespace(all=lambda: list(self._rows)) + + def all(self): + return list(self._rows) + + class FakeDB: + async def execute(self, stmt): + sql = str(stmt).lower() + if "from projects" in sql: + return FakeScalarResult(scalar=project) + if "from tasks" in sql: + return FakeScalarResult(rows=[]) + if "from signals" in sql: + return FakeScalarResult(rows=[]) + if "from users" in sql: + return FakeScalarResult(scalar=None) + raise AssertionError(sql) + + class FakeRAG( + mixin_fast_answer_project_main.AdaptiveRAGFastAnswerProjectMainMixin, + AdaptiveRAGConversationUtilsAMixinP1, + ): + def __init__(self) -> None: + self.db = FakeDB() + self.tenant_id = 1 + + async def _resolve_project_scope_for_question(self, **kwargs): + _ = kwargs + return (49, "AI发展前景研究", "AI发展前景研究") + + async def _execute_external_query_gateway(self, **kwargs): + _ = kwargs + return ([], {"mode": "external_query_gateway_empty"}) + + rag = FakeRAG() + result = await rag._try_fast_project_progress_answer( + clean_q="AI发展前景研究这个项目现在更缺政策线索,还是更缺融资并购线索?", + project_id=None, + query_semantics={ + "status_focus": "risk_or_blocker", + "business_frame": { + "goal_kind": "status_lookup", + "target_scope": "explicit_project", + "resource_role": "project_object", + "source_kind": "hybrid", + "expected_answer_shape": "bounded_explanation", + "conversation_posture": "business_operating", + "project_binding": 49, + "evidence_mode": "verified", + }, + }, + ) + + assert result is not None + answer = str(result["answer"] or "") + assert "项目 #49《AI发展前景研究》" in answer + assert "项目目前处于:立项后待拆解阶段" in answer + assert "当前最主要的卡点:项目还没有形成任务拆解和执行节奏,这也是当前最直接的阻塞信号。" in answer + + +@pytest.mark.asyncio +async def test_fast_project_progress_answer_handles_risk_blocker_questions_for_ai_dropout_assistant() -> None: + project = SimpleNamespace( + id=51, + title="AI劝退助手", + name="AI劝退助手", + status="active", + owner_id=None, + updated_at=datetime(2026, 4, 10, 11, 0), + description="围绕劝退对话、回访节奏和用户反馈打磨效果。", + ) + + class FakeScalarResult: + def __init__(self, *, scalar=None, rows=None) -> None: + self._scalar = scalar + self._rows = list(rows or []) + + def scalar_one_or_none(self): + return self._scalar + + def scalars(self): + return SimpleNamespace(all=lambda: list(self._rows)) + + def all(self): + return list(self._rows) + + class FakeDB: + async def execute(self, stmt): + sql = str(stmt).lower() + if "from projects" in sql: + return FakeScalarResult(scalar=project) + if "from tasks" in sql: + return FakeScalarResult(rows=[]) + if "from signals" in sql: + return FakeScalarResult(rows=[]) + if "from users" in sql: + return FakeScalarResult(scalar=None) + raise AssertionError(sql) + + class FakeRAG( + mixin_fast_answer_project_main.AdaptiveRAGFastAnswerProjectMainMixin, + AdaptiveRAGConversationUtilsAMixinP1, + ): + def __init__(self) -> None: + self.db = FakeDB() + self.tenant_id = 1 + + async def _resolve_project_scope_for_question(self, **kwargs): + _ = kwargs + return (51, "AI劝退助手", "AI劝退助手") + + async def _execute_external_query_gateway(self, **kwargs): + _ = kwargs + return ([], {"mode": "external_query_gateway_empty"}) + + rag = FakeRAG() + result = await rag._try_fast_project_progress_answer( + clean_q="AI劝退助手这个项目现在更像卡在图像识别,还是卡在劝退对话效果?", + project_id=None, + query_semantics={ + "status_focus": "risk_or_blocker", + "business_frame": { + "goal_kind": "status_lookup", + "target_scope": "explicit_project", + "resource_role": "project_object", + "source_kind": "internal", + "expected_answer_shape": "bounded_explanation", + "conversation_posture": "business_operating", + "project_binding": 51, + "evidence_mode": "verified", + }, + }, + ) + + assert result is not None + answer = str(result["answer"] or "") + assert "项目 #51《AI劝退助手》" in answer + assert "项目目前处于:立项后待拆解阶段" in answer + assert "当前最主要的卡点:项目还没有形成任务拆解和执行节奏,这也是当前最直接的阻塞信号。" in answer + + +@pytest.mark.asyncio +async def test_fast_project_progress_answer_renders_status_for_pixelle_video_integration( + monkeypatch, +) -> None: + project = SimpleNamespace( + id=1, + title="Pixelle-Video 集成", + name="Pixelle-Video 集成", + status="active", + owner_id=9, + updated_at=datetime(2026, 4, 9, 16, 0), + description="围绕 Pixelle-Video 的能力接入、联调和交付推进。", + ) + owner = SimpleNamespace(name="系统管理员") + tasks = [ + SimpleNamespace(status="进行中", updated_at=datetime(2026, 4, 9, 11, 0), created_at=datetime(2026, 4, 9, 9, 0)), + SimpleNamespace(status="待处理", updated_at=datetime(2026, 4, 9, 12, 0), created_at=datetime(2026, 4, 9, 10, 0)), + ] + signals = [ + ("待处理", False, "pending"), + ("处理中", False, "processing"), + ] + + class FakeScalarResult: + def __init__(self, *, scalar=None, rows=None) -> None: + self._scalar = scalar + self._rows = list(rows or []) + + def scalar_one_or_none(self): + return self._scalar + + def scalars(self): + return SimpleNamespace(all=lambda: list(self._rows)) + + def all(self): + return list(self._rows) + + class FakeDB: + async def execute(self, stmt): + sql = str(stmt).lower() + if "from projects" in sql: + return FakeScalarResult(scalar=project) + if "from users" in sql: + return FakeScalarResult(scalar=owner) + if "from tasks" in sql: + return FakeScalarResult(rows=tasks) + if "from signals" in sql: + return FakeScalarResult(rows=signals) + raise AssertionError(sql) + + async def _fake_render_prompt(self, *, fallback_template: str, **kwargs): + _ = self + return str(fallback_template).format(**kwargs).strip() + + monkeypatch.setattr( + "app.services.prompt_release.service.PromptReleaseService.render_prompt", + _fake_render_prompt, + ) + + class _FakeLLM: + async def generate(self, prompt: str, **kwargs): + _ = (prompt, kwargs) + return json.dumps( + { + "handled": True, + "summary": "项目已经开始推进,但还处在联调和范围继续收口的阶段。", + "stage_judgement": "联调推进阶段", + "evidence_summary": "当前有 2 项任务,其中 1 项进行中、1 项待处理;信号里还有待处理和处理中项。", + "core_gap": "能力接入已经启动,但联调范围和交付边界还没有完全收稳。", + "external_impact": "如果上游接口或集成规范再变,当前推进节奏会继续被拖慢。", + "actions": [ + {"slot": "this_week", "text": "先把当前联调链路里最影响交付的那一项任务压实。"}, + {"slot": "owner_decision", "text": "明确这一轮接入要交付到什么边界,避免范围继续飘。"}, + {"slot": "protect_priority", "text": "优先处理仍在待处理的集成信号,别让阻塞继续积压。"}, + ], + }, + ensure_ascii=False, + ) + + class FakeRAG( + mixin_fast_answer_project_main.AdaptiveRAGFastAnswerProjectMainMixin, + AdaptiveRAGConversationUtilsAMixinP1, + ): + def __init__(self) -> None: + self.db = FakeDB() + self.tenant_id = 1 + self.llm = _FakeLLM() + + async def _resolve_project_scope_for_question(self, **kwargs): + _ = kwargs + return (1, "Pixelle-Video 集成", "Pixelle-Video 集成") + + async def _execute_external_query_gateway(self, **kwargs): + _ = kwargs + return ([], {"mode": "external_query_gateway_empty"}) + + rag = FakeRAG() + result = await rag._try_fast_project_progress_answer( + clean_q="Pixelle-Video 集成现在推进到哪一步了?", + project_id=None, + query_semantics={ + "status_focus": "snapshot", + "business_frame": { + "goal_kind": "status_lookup", + "target_scope": "explicit_project", + "resource_role": "project_object", + "source_kind": "internal", + "expected_answer_shape": "bounded_explanation", + "conversation_posture": "business_operating", + "project_binding": 1, + "evidence_mode": "verified", + }, + }, + ) + + assert result is not None + answer = str(result["answer"] or "") + assert "项目 #1《Pixelle-Video 集成》" in answer + assert "当前结论:项目已经开始推进,但还处在联调和范围继续收口的阶段。" in answer + assert "任务进度:共 2 项,已完成 0,进行中 1,待处理 1" in answer + assert "项目目前处于:联调推进阶段" in answer + + @pytest.mark.asyncio async def test_fast_project_progress_answer_renders_project_weekly_report_for_status_questions( monkeypatch, @@ -13443,7 +15780,19 @@ async def test_fast_project_progress_answer_renders_project_weekly_report_for_st result = await rag._try_fast_project_progress_answer( clean_q="帮我看下关于直播同行竞品分析这个项目现在怎么样?", project_id=None, - query_semantics={"subject_focus": "project", "status_focus": "snapshot"}, + query_semantics={ + "status_focus": "snapshot", + "business_frame": { + "goal_kind": "status_lookup", + "target_scope": "explicit_project", + "resource_role": "project_object", + "source_kind": "internal", + "expected_answer_shape": "bounded_explanation", + "conversation_posture": "business_operating", + "project_binding": 62, + "evidence_mode": "verified", + }, + }, ) assert result is not None @@ -13465,19 +15814,35 @@ async def test_fast_project_progress_answer_renders_project_weekly_report_for_st assert "来源摘要:项目库 + 外部实时来源" in answer -def test_typed_executor_common_exports_regex_helpers_to_query_mixins() -> None: +def test_typed_executor_common_uses_structured_signal_parse_fields() -> None: assert mixin_execute_query_project.ExecuteQueryProjectMixin._extract_task_priority("p0 紧急任务") == "high" assert ( ai_pmo_typed_executor_mixins_parse.TypedExecutorParseMixin._extract_requested_signal_count( message_text="请帮我分析 3 个信号", metadata={}, ) + == 1 + ) + assert ( + ai_pmo_typed_executor_mixins_parse.TypedExecutorParseMixin._extract_requested_signal_count( + message_text="随便来几条都行", + metadata={"requested_count": 3}, + ) == 3 ) assert ( ai_pmo_typed_executor_mixins_parse.TypedExecutorParseMixin._requires_strict_signal_analysis( message_text="先分析后入库 2 个信号", requested_count=2, + metadata={}, + ) + is False + ) + assert ( + ai_pmo_typed_executor_mixins_parse.TypedExecutorParseMixin._requires_strict_signal_analysis( + message_text="直接写入就行", + requested_count=2, + metadata={"analysis_mode": "strict"}, ) is True ) 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 1431e590..b459cc62 100644 --- a/backend/tests/test_ai_pmo_qa_http_mainline_e2e.py +++ b/backend/tests/test_ai_pmo_qa_http_mainline_e2e.py @@ -253,6 +253,37 @@ async def test_http_rag_ask_returns_offer_after_answer_handoff_metadata_for_curr "impact_status": "indirect_only", "external_impact": "目前更像背景相关,还不能直接映射到当前正式对象。", }, + "analysis_packet": { + "packet_version": "v1", + "packet_type": "project_fit_analysis", + "conclusion": "放到采购经理每日实时库存上报服务里,Responses API 更像接在摘要卡片后面的解释层,不像主计算链。", + "focus_objects": [ + { + "name": "每日固定时段生成并推送结构化摘要卡片", + "kind": "task", + "stance": "better_fit", + "reason": "这一步更接近结果出来之后的解释和追问层。", + }, + { + "name": "WMS库存快照自动拉取与清洗", + "kind": "task", + "stance": "not_fit", + "reason": "这一步属于确定性取数或计算主链。", + }, + ], + "fit_verdict": "fit_later", + "current_subject": "采购经理每日实时库存上报服务", + "candidate_position": "每日固定时段生成并推送结构化摘要卡片", + "rejected_position": "WMS库存快照自动拉取与清洗", + "preferred_layer": "每日固定时段生成并推送结构化摘要卡片", + "rejected_layer": "WMS库存快照自动拉取与清洗", + "reason_chain": [ + "WMS库存快照自动拉取与清洗这一步属于确定性取数或计算主链,结果要稳定、可复算。", + "每日固定时段生成并推送结构化摘要卡片更接近结果出来之后的解释和追问层,更适合承接这类能力。", + ], + "missing_mapping": "还缺它在每日固定时段生成并推送结构化摘要卡片这一步到底承担解释、问答还是编排角色的明确映射。", + "next_step": "先确认它是不是只用在摘要卡片之后。", + }, "handoff_mode": "offer_after_answer", "can_enter_plan": True, "recommended_next_step": "如果你要,我可以基于当前结论进入 plan,继续把需求范围和下一步收清楚。", @@ -266,7 +297,7 @@ async def test_http_rag_ask_returns_offer_after_answer_handoff_metadata_for_curr response = await client.post( "/rag/ask", json={ - "question": "这个适合吗", + "question": "OpenAI Responses API 放到当前库存上报项目里,适合放在哪一层?", "asked_via": "pmflow_manual", "project_id": 12, "source_chat_id": "chat-12", @@ -283,6 +314,12 @@ async def test_http_rag_ask_returns_offer_after_answer_handoff_metadata_for_curr 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["analysis_packet"]["packet_type"] == "project_fit_analysis" + assert data["analysis_packet"]["current_subject"] == "采购经理每日实时库存上报服务" + assert data["analysis_packet"]["candidate_position"] == "每日固定时段生成并推送结构化摘要卡片" + assert data["analysis_packet"]["rejected_position"] == "WMS库存快照自动拉取与清洗" + assert len(data["analysis_packet"]["reason_chain"]) == 2 + assert data["analysis_packet"]["fit_verdict"] == "fit_later" assert data["handoff_mode"] == "offer_after_answer" assert data["can_enter_plan"] is True assert "进入 plan" in data["recommended_next_step"] diff --git a/backend/tests/test_ai_pmo_signal_source_onboard_contract.py b/backend/tests/test_ai_pmo_signal_source_onboard_contract.py index 6ee18fce..62c97823 100644 --- a/backend/tests/test_ai_pmo_signal_source_onboard_contract.py +++ b/backend/tests/test_ai_pmo_signal_source_onboard_contract.py @@ -1,11 +1,26 @@ from __future__ import annotations +from types import SimpleNamespace +from unittest.mock import AsyncMock + import pytest from app.services.ai_pmo.ai_pmo_intent import CanonicalIntent +from app.services.ai_pmo.ai_pmo_kernel.ai_pmo_typed_executor_mixins_parse import ( + TypedExecutorParseMixin, +) from app.services.ai_pmo.ai_pmo_typed_executor_discovery.mixin_discover import ( DiscoveryFlowMixin, ) +from app.services.ai_pmo.ai_pmo_intent import WorkflowRoute +from app.services.ai_pmo.ai_pmo_kernel.ai_pmo_reply_renderer import ReplyRenderer +from app.services.ai_pmo.ai_pmo_kernel.mixins.ingress.ai_pmo_kernel_mixins_ingress_storage import ( + KernelIngressStorageMixin, +) +from app.services.ai_pmo.ai_pmo_kernel.mixins.followup.ai_pmo_kernel_mixins_followup_project_signal_resume import ( + KernelFollowupProjectSignalResumeMixin, +) +from app.services.ai_pmo.ai_pmo_typed_executor_execute import mixin_execute_signal_source as signal_source_execute_module from app.services.ai_pmo.ai_pmo_typed_executor_execute.mixin_execute_signal_source import ( ExecuteSignalSourceMixin, ) @@ -117,6 +132,427 @@ async def test_signal_source_onboard_replays_existing_ledger_without_reexecution assert kernel.signal_source_workflow.called is False +@pytest.mark.asyncio +async def test_signal_source_onboard_materializes_reply_anchor_candidate_refs_into_multiple_signals( + monkeypatch: pytest.MonkeyPatch, +) -> None: + class _FakeLedger: + def __init__(self) -> None: + self.status = "processing" + self.result_payload = None + + class _FakeWorkflow: + def __init__(self) -> None: + self.calls: list[int] = [] + + async def run(self, **kwargs): + self.calls.append(int(kwargs["signal_id"])) + return { + "status": "completed", + "raw_result": { + "signal_source": {"source_name": f"registry-{kwargs['signal_id']}"}, + "execution": {}, + }, + } + + class _FakeDB: + def __init__(self) -> None: + self.commits = 0 + + async def commit(self): + self.commits += 1 + + async def flush(self): + return None + + class _FakeContextCache: + async def invalidate_system_context(self, *, tenant_id): + _ = tenant_id + return None + + class _FakeExecutor(ExecuteSignalSourceMixin, TypedExecutorParseMixin): + def __init__(self) -> None: + self.db = _FakeDB() + self.tenant_id = 1 + self.signal_source_workflow = _FakeWorkflow() + self.created_titles: list[str] = [] + + async def _load_signal_source_onboard_ledger(self, *, idempotency_key): + _ = idempotency_key + return None + + async def _claim_signal_source_onboard_ledger(self, **kwargs): + _ = kwargs + return _FakeLedger(), True + + async def _create_signal(self, **kwargs): + self.created_titles.append(str(kwargs["title"])) + signal_id = 100 + len(self.created_titles) + return SimpleNamespace(id=signal_id, title=kwargs["title"]) + + async def _finalize_signal_source_onboard_ledger(self, **kwargs): + _ = kwargs + return None + + @staticmethod + def _normalize_title_candidate(value): + return str(value or "").strip() + + @staticmethod + def _is_acceptable_title(text): + return bool(text) + + @staticmethod + def _extract_auto_tasks(control_plane_payload): + _ = control_plane_payload + return [] + + monkeypatch.setattr(signal_source_execute_module, "context_cache_service", _FakeContextCache(), raising=False) + + intent = CanonicalIntent( + name="signal_source.onboard", + confidence=0.95, + project_id=11, + signal_title="", + signal_summary="", + metadata={ + "operation_intent_contract": { + "intent_phase": "execute_write", + "project_binding": 11, + "candidate_set_binding": "recset_invoice_01", + "candidate_refs": ["cand_retry", "cand_platform"], + }, + "reply_anchor_qa_context": { + "project_id": 11, + "signal_source_recommendation": { + "candidate_set_id": "recset_invoice_01", + "project_binding": 11, + "candidates": [ + { + "candidate_ref": "cand_retry", + "display_name": "开票失败或重试回执", + "business_value": "最早看到票开不出去", + "materialization_payload": { + "display_name": "开票失败或重试回执", + "business_value": "最早看到票开不出去", + }, + }, + { + "candidate_ref": "cand_platform", + "display_name": "税控或发票平台状态变化", + "business_value": "能区分是业务失败还是外部平台波动", + "materialization_payload": { + "display_name": "税控或发票平台状态变化", + "business_value": "能区分是业务失败还是外部平台波动", + }, + }, + ], + }, + }, + }, + ) + + kernel = _FakeExecutor() + result = await kernel._execute_signal_source_onboard_intent( + intent=intent, + message_text="把刚才推荐的那两类外部信号源写进自动开票系统。", + operator_user_id=9, + source_system="feishu", + source_message_id="msg-execute-write-1", + channel_ref="oc-1", + ) + + assert result["status"] == "succeeded" + assert result["project_id"] == 11 + assert result["signal_ids"] == [101, 102] + assert result["signal_id"] == 101 + assert result["requested_count"] == 2 + assert result["candidate_set_binding"] == "recset_invoice_01" + assert result["candidate_refs"] == ["cand_retry", "cand_platform"] + assert result["reply_anchor_qa_context"]["signal_source_recommendation"]["candidate_set_id"] == "recset_invoice_01" + assert kernel.created_titles == ["开票失败或重试回执", "税控或发票平台状态变化"] + assert kernel.signal_source_workflow.calls == [101, 102] + + +@pytest.mark.asyncio +async def test_signal_source_onboard_direct_write_stays_succeeded_when_downstream_is_pending_manual( + monkeypatch: pytest.MonkeyPatch, +) -> None: + class _FakeLedger: + def __init__(self) -> None: + self.status = "processing" + self.result_payload = None + + class _FakeWorkflow: + async def run(self, **kwargs): + _ = kwargs + return { + "status": "pending_manual", + "raw_result": { + "signal_source": {"source_name": "开票失败或重试回执"}, + "execution": {}, + }, + } + + class _FakeDB: + async def commit(self): + return None + + async def flush(self): + return None + + class _FakeContextCache: + async def invalidate_system_context(self, *, tenant_id): + _ = tenant_id + return None + + class _FakeExecutor(ExecuteSignalSourceMixin, TypedExecutorParseMixin): + def __init__(self) -> None: + self.db = _FakeDB() + self.tenant_id = 1 + self.signal_source_workflow = _FakeWorkflow() + + async def _load_signal_source_onboard_ledger(self, *, idempotency_key): + _ = idempotency_key + return None + + async def _claim_signal_source_onboard_ledger(self, **kwargs): + _ = kwargs + return _FakeLedger(), True + + async def _create_signal(self, **kwargs): + _ = kwargs + return SimpleNamespace(id=301, title="开票失败或重试回执") + + async def _finalize_signal_source_onboard_ledger(self, **kwargs): + _ = kwargs + return None + + @staticmethod + def _normalize_title_candidate(value): + return str(value or "").strip() + + @staticmethod + def _is_acceptable_title(text): + return bool(text) + + @staticmethod + def _extract_auto_tasks(control_plane_payload): + _ = control_plane_payload + return [] + + monkeypatch.setattr(signal_source_execute_module, "context_cache_service", _FakeContextCache(), raising=False) + + kernel = _FakeExecutor() + result = await kernel._execute_signal_source_onboard_intent( + intent=CanonicalIntent( + name="signal_source.onboard", + confidence=0.95, + project_id=11, + signal_title="", + signal_summary="", + metadata={ + "operation_intent_contract": { + "intent_phase": "execute_write", + "project_binding": 11, + } + }, + ), + message_text="把开票失败或重试回执加入自动开票系统。", + operator_user_id=9, + source_system="feishu", + source_message_id="msg-execute-write-pending", + channel_ref="oc-1", + ) + + assert result["status"] == "succeeded" + assert result["post_process_status"] == "pending_manual" + assert result["signal_source_workflow"]["status"] == "pending_manual" + assert result["signal_id"] == 301 + + +@pytest.mark.asyncio +async def test_signal_source_followup_resume_reuses_stored_candidate_binding_for_confirm_write() -> None: + captured: dict[str, object] = {} + + class _EmptyResult: + @staticmethod + def scalar_one_or_none(): + return None + + class _FakeDB: + async def execute(self, stmt): + _ = stmt + return _EmptyResult() + + class _FakeExecutorService: + async def execute(self, **kwargs): + captured["intent"] = kwargs["intent"] + return { + "status": "succeeded", + "project_id": 11, + "signal_id": 101, + "signal_ids": [101, 102], + "signal_source_workflow": {"status": "completed"}, + } + + class _FakeKernel(KernelFollowupProjectSignalResumeMixin): + def __init__(self) -> None: + self.db = _FakeDB() + self.tenant_id = 1 + self.executor = _FakeExecutorService() + self.intent_resolver = SimpleNamespace( + resolve=lambda text, project_id: CanonicalIntent( + name="signal_source.onboard", + confidence=0.92, + project_id=project_id, + signal_title="", + signal_summary=text[:120], + metadata={}, + ) + ) + self.review_service = SimpleNamespace(review_work_item=AsyncMock(return_value={})) + self.reply_renderer = SimpleNamespace(render=lambda **kwargs: {"text": "已继续处理"}) + + async def _verify_execution(self, **kwargs): + _ = kwargs + return {"verified": True, "issues": []} + + async def _project_success(self, **kwargs): + _ = kwargs + return None + + async def _maybe_emit_risk_cadence_snapshot(self, **kwargs): + _ = kwargs + return None + + @staticmethod + def _normalize_route_from_stored_intent(value): + return str(value or "") + + @staticmethod + def _enrich_signal_source_intent_from_closure(*, intent, message_text, closure_payload): + _ = (message_text, closure_payload) + return intent + + kernel = _FakeKernel() + target_work_item = SimpleNamespace( + id=41, + project_id=11, + state="waiting_manual", + intent="signal_source.onboard", + signal_id=None, + review_summary={}, + context_snapshot={}, + execution_result={ + "status": "pending_manual", + "project_id": 11, + "candidate_set_binding": "recset_invoice_01", + "candidate_refs": ["cand_retry", "cand_platform"], + "reply_anchor_qa_context": { + "project_id": 11, + "signal_source_recommendation": { + "candidate_set_id": "recset_invoice_01", + "project_binding": 11, + "candidates": [ + { + "candidate_ref": "cand_retry", + "display_name": "开票失败或重试回执", + "business_value": "最早看到票开不出去", + }, + { + "candidate_ref": "cand_platform", + "display_name": "税控或发票平台状态变化", + "business_value": "能区分是业务失败还是平台波动", + }, + ], + }, + }, + "action_contract": { + "operation_code": "signal_source.onboard", + "params": { + "project_id": 11, + "candidate_set_binding": "recset_invoice_01", + "candidate_refs": ["cand_retry", "cand_platform"], + }, + }, + }, + ) + target_ingest = SimpleNamespace(payload={"message_text": "把刚才推荐的那几类外部信号源加入自动开票系统。"}) + + await kernel._resume_followup_target( + target_work_item=target_work_item, + target_ingest=target_ingest, + followup_intent=CanonicalIntent( + name="followup.confirm", + confidence=0.99, + project_id=11, + signal_title="", + signal_summary="", + metadata={}, + ), + followup_sender="林宇航", + operator_user_id=9, + source_system="feishu", + source_message_id="om-followup-confirm-1", + world_model={"resolved_project_id": 11}, + message_text="确认入库", + workflow_route=WorkflowRoute( + family="signal_source", + action="onboard", + project_id=11, + requested_count=1, + requires_confirmation=True, + metadata={"workflow_route": "signal_source.onboard"}, + ), + ) + + rebuilt_intent = captured["intent"] + assert rebuilt_intent.metadata["operation_intent_contract"]["candidate_set_binding"] == "recset_invoice_01" + assert rebuilt_intent.metadata["operation_intent_contract"]["candidate_refs"] == ["cand_retry", "cand_platform"] + assert ( + rebuilt_intent.metadata["reply_anchor_qa_context"]["signal_source_recommendation"]["candidate_set_id"] + == "recset_invoice_01" + ) + + +def test_reply_renderer_formats_multi_signal_source_onboard_success_naturally() -> None: + renderer = ReplyRenderer( + state_waiting_manual="waiting_manual", + state_failed="failed", + receipt_phase_action="action", + receipt_phase_pending="pending", + receipt_phase_error="error", + ) + intent = CanonicalIntent( + name="signal_source.onboard", + confidence=0.96, + project_id=11, + signal_title="", + signal_summary="", + metadata={}, + ) + + rendered = renderer.render( + work_state="succeeded", + intent=intent, + decision_record={"approval_requirement": {}}, + execution_result={ + "status": "succeeded", + "post_process_status": "pending_manual", + "project_id": 11, + "signal_id": 101, + "signal_ids": [101, 102], + "signal_source_workflow": {"status": "pending_manual"}, + }, + ) + + assert rendered["template_key"] == "action_succeeded" + assert "项目#11" in rendered["text"] + assert "2条信号源" in rendered["text"] + assert "后续仍有待处理步骤" in rendered["text"] + + @pytest.mark.asyncio async def test_project_signal_discovery_uses_project_id_without_name_error() -> None: class _FakeDiscovery(DiscoveryFlowMixin): @@ -210,3 +646,69 @@ def test_signal_source_kind_uses_channel_config_without_hint_table() -> None: ) == "feishu" ) + + +def test_execution_action_contract_preserves_signal_source_candidate_binding_and_reply_anchor_context() -> None: + class _FakeStorage(KernelIngressStorageMixin): + @staticmethod + def _primary_operation_code(value): + _ = value + return "signal_source.onboard" + + intent = CanonicalIntent( + name="signal_source.onboard", + confidence=0.96, + project_id=11, + signal_title="", + signal_summary="", + metadata={ + "operation_intent_contract": { + "intent_phase": "execute_write", + "project_binding": 11, + "candidate_set_binding": "recset_invoice_01", + "candidate_refs": ["cand_retry", "cand_platform"], + }, + "reply_anchor_qa_context": { + "project_id": 11, + "signal_source_recommendation": { + "candidate_set_id": "recset_invoice_01", + }, + }, + }, + ) + + contract = _FakeStorage()._build_execution_action_contract( + intent=intent, + workflow_route=SimpleNamespace(key="signal_source.onboard", action="onboard", project_id=11, requested_count=2), + decision_payload={ + "execution_plan": { + "actions": [ + { + "operation_code": "signal_source.onboard", + "mode": "write", + } + ] + } + }, + execution_result={ + "status": "succeeded", + "project_id": 11, + "signal_ids": [101, 102], + "candidate_set_binding": "recset_invoice_01", + "candidate_refs": ["cand_retry", "cand_platform"], + "reply_anchor_qa_context": { + "project_id": 11, + "signal_source_recommendation": { + "candidate_set_id": "recset_invoice_01", + }, + }, + }, + source_system="feishu", + source_message_id="om-signal-source-write-1", + channel_ref="chat-1", + ) + + assert contract["params"]["candidate_set_binding"] == "recset_invoice_01" + assert contract["params"]["candidate_refs"] == ["cand_retry", "cand_platform"] + assert contract["params"]["intent_phase"] == "execute_write" + assert contract["metadata"]["reply_anchor_qa_context"]["signal_source_recommendation"]["candidate_set_id"] == "recset_invoice_01" diff --git a/backend/tests/test_analysis_packet_contract.py b/backend/tests/test_analysis_packet_contract.py new file mode 100644 index 00000000..1e9c4d66 --- /dev/null +++ b/backend/tests/test_analysis_packet_contract.py @@ -0,0 +1,984 @@ +from __future__ import annotations + +import pytest +from pydantic import ValidationError + +from app.schemas.analysis_packets import ( + AnalysisFocusObject, + ExternalImpactAnalysisPacket, + FollowupExplainAnalysisPacket, + InternalExplainAnalysisPacket, + ProjectFitAnalysisPacket, +) +from app.services.analysis_packet_builder import build_analysis_packet + + +def test_project_fit_analysis_packet_schema_contract() -> None: + packet = ProjectFitAnalysisPacket( + conclusion="放到采购经理每日实时库存上报服务里,它更像接在摘要卡片后面的解释层,不像主计算链。", + focus_objects=[ + AnalysisFocusObject( + name="每日固定时段生成并推送结构化摘要卡片", + kind="task", + stance="better_fit", + reason="这一步更像结果出来之后的解释和追问层。", + ), + AnalysisFocusObject( + name="WMS库存快照自动拉取与清洗", + kind="task", + stance="not_fit", + reason="这一步属于确定性取数主链,不适合直接塞进生成能力。", + ), + ], + next_step="先确认它在当前项目里具体承接哪个节点。", + fit_verdict="fit_later", + current_subject="采购经理每日实时库存上报服务", + candidate_position="每日固定时段生成并推送结构化摘要卡片", + rejected_position="WMS库存快照自动拉取与清洗", + preferred_layer="每日固定时段生成并推送结构化摘要卡片", + rejected_layer="WMS库存快照自动拉取与清洗", + reason_chain=[ + "WMS库存快照自动拉取与清洗这一步属于确定性取数主链,结果要稳定、可复算。", + "每日固定时段生成并推送结构化摘要卡片更接近结果出来之后的解释和追问层。", + ], + missing_mapping="还缺它在每日固定时段生成并推送结构化摘要卡片这一步到底承担什么角色的明确映射。", + ) + + assert packet.packet_version == "v1" + assert packet.packet_type == "project_fit_analysis" + assert len(packet.focus_objects) == 2 + assert packet.fit_verdict == "fit_later" + assert packet.current_subject == "采购经理每日实时库存上报服务" + assert packet.candidate_position == "每日固定时段生成并推送结构化摘要卡片" + assert packet.rejected_position == "WMS库存快照自动拉取与清洗" + assert len(packet.reason_chain) == 2 + + +def test_analysis_packet_focus_objects_limit_is_two() -> None: + with pytest.raises(ValidationError): + ProjectFitAnalysisPacket( + conclusion="结论", + focus_objects=[ + AnalysisFocusObject(name="A", kind="task", stance="better_fit", reason="A"), + AnalysisFocusObject(name="B", kind="task", stance="not_fit", reason="B"), + AnalysisFocusObject(name="C", kind="task", stance="relevant", reason="C"), + ], + fit_verdict="uncertain", + missing_mapping="还缺映射。", + ) + + +def test_build_analysis_packet_for_project_fit_indirect_only_prefers_contrast_objects() -> None: + packet = build_analysis_packet( + route_decision={ + "route_mode": "external", + "effective_source_policy": "external", + }, + project_operating_context={ + "current_project_id": 123, + "current_repo_scope": "pmflow-main", + }, + execution_recipe={ + "primary_lane": "external", + "fit_brief_required": True, + "external_evidence_required": True, + "plan_handoff_mode": "offer_after_answer", + }, + fit_brief={ + "project_summary": "采购经理每日实时库存上报服务", + "repo_summary": "当前仓库负责统一项目上下文和 AI handoff。", + }, + external_recommendation={ + "retrieval_status": "evidence_found", + "impact_status": "indirect_only", + "external_summary": "Responses API 适合多轮状态化交互。", + "external_impact": "当前外部证据只支持背景相关性,暂不能直接映射到当前项目对象。", + }, + recommended_next_step="先确认它在当前项目里具体承接哪个节点。", + project_snapshot={ + "project_name": "采购经理每日实时库存上报服务", + "task_titles": [ + "WMS库存快照自动拉取与清洗", + "TOP10商品缺货排序逻辑实现(基于当前库存与安全库存差值)", + "每日固定时段生成并推送结构化摘要卡片", + ], + "signal_titles": [], + "analysis_object_refs": { + "fit_candidate_task_ref": {"kind": "task", "title": "每日固定时段生成并推送结构化摘要卡片"}, + "fit_rejected_task_ref": {"kind": "task", "title": "WMS库存快照自动拉取与清洗"}, + }, + }, + ) + + assert packet["packet_type"] == "project_fit_analysis" + assert packet["fit_verdict"] == "fit_later" + assert packet["current_subject"] == "采购经理每日实时库存上报服务" + assert packet["candidate_position"] == "每日固定时段生成并推送结构化摘要卡片" + assert packet["rejected_position"] == "WMS库存快照自动拉取与清洗" + assert [item["stance"] for item in packet["focus_objects"]] == ["better_fit", "not_fit"] + assert [item["name"] for item in packet["focus_objects"]] == [ + "每日固定时段生成并推送结构化摘要卡片", + "WMS库存快照自动拉取与清洗", + ] + assert packet["reason_chain"] == [ + "WMS库存快照自动拉取与清洗这一步要稳,不适合一上来就塞新能力。", + "每日固定时段生成并推送结构化摘要卡片这一步更适合先试,比直接改WMS库存快照自动拉取与清洗风险小。", + ] + assert packet["missing_mapping"] == "现在还说不准它会先帮到每日固定时段生成并推送结构化摘要卡片,还是会直接改到WMS库存快照自动拉取与清洗。" + + +def test_build_analysis_packet_for_project_fit_prefers_structured_task_descriptions_over_generic_titles() -> None: + packet = build_analysis_packet( + route_decision={ + "route_mode": "external", + "effective_source_policy": "external", + }, + project_operating_context={ + "current_project_id": 123, + }, + execution_recipe={ + "primary_lane": "external", + "fit_brief_required": True, + "external_evidence_required": True, + "plan_handoff_mode": "offer_after_answer", + }, + fit_brief={ + "project_summary": "采购经理每日实时库存上报服务", + }, + external_recommendation={ + "retrieval_status": "evidence_found", + "impact_status": "indirect_only", + "external_summary": "Responses API 更适合放在结果出来之后的解释与追问层。", + "external_impact": "当前只支持背景相关性,暂不能直接映射到当前项目对象。", + }, + recommended_next_step="先确认它是不是只用在结果出来之后的解释环节。", + project_snapshot={ + "project_name": "采购经理每日实时库存上报服务", + "tasks": [ + { + "id": 901, + "title": "任务A", + "description": "每日凌晨生成结构化文本卡片并推送给采购经理,支持点击查看详情页。", + }, + { + "id": 902, + "title": "任务B", + "description": "每小时从WMS拉取库存快照并清洗异常值后写入临时表。", + }, + ], + "analysis_object_refs": { + "fit_candidate_task_ref": {"kind": "task", "id": 901, "title": "任务A"}, + "fit_rejected_task_ref": {"kind": "task", "id": 902, "title": "任务B"}, + }, + }, + ) + + assert packet["packet_type"] == "project_fit_analysis" + assert packet["candidate_position"] == "任务A" + assert packet["rejected_position"] == "任务B" + assert packet["reason_chain"] == [ + "任务B现在做的是每小时从WMS拉取库存快照并清洗异常值后写入临时表,这一步要稳,不能随便把新能力塞进去。", + "任务A现在做的是每日凌晨生成结构化文本卡片并推送给采购经理,支持点击查看详情页,先在这里试更稳,也更容易看出值不值。", + ] + assert packet["missing_mapping"] == "现在还说不准它会先帮到任务A,还是会直接改到任务B。" + + +def test_build_analysis_packet_for_project_fit_fails_closed_without_explicit_object_refs() -> None: + packet = build_analysis_packet( + route_decision={ + "route_mode": "external", + "effective_source_policy": "external", + }, + execution_recipe={ + "primary_lane": "external", + "fit_brief_required": True, + "external_evidence_required": True, + "plan_handoff_mode": "offer_after_answer", + }, + fit_brief={ + "project_summary": "采购经理每日实时库存上报服务", + }, + external_recommendation={ + "retrieval_status": "evidence_found", + "impact_status": "indirect_only", + "external_summary": "Responses API 更适合放在结果出来之后的解释与追问层。", + "external_impact": "当前只支持背景相关性,暂不能直接映射到当前项目对象。", + }, + recommended_next_step="先确认它是不是只用在结果出来之后的解释环节。", + project_snapshot={ + "project_name": "采购经理每日实时库存上报服务", + "tasks": [ + { + "title": "任务A", + "description": "根据前序结果生成给采购经理的日报文本,并保留查看入口。", + }, + { + "title": "任务B", + "description": "从WMS取库存样本写入中间表,补齐批次与仓位字段后供后续核算使用,并保留历史版本。", + }, + ], + "signals": [ + { + "title": "信号一", + "summary": "WMS每小时更新库存样本,是总水位核算的原始来源。", + } + ], + "analysis_object_refs": { + # no explicit refs on purpose + }, + }, + ) + + assert packet["candidate_position"] is None + assert packet["rejected_position"] is None + assert packet["reason_chain"] == [ + "现在项目里还没有足够具体的任务或信号源,硬把它挂到某个位置上,结论很容易说偏。" + ] + assert packet["missing_mapping"] == "至少还缺一个能对上的业务步骤或信号源。" + + +def test_build_analysis_packet_for_project_fit_prefers_explicit_object_refs_over_ambiguous_task_names() -> None: + packet = build_analysis_packet( + route_decision={ + "route_mode": "external", + "effective_source_policy": "external", + }, + execution_recipe={ + "primary_lane": "external", + "fit_brief_required": True, + "external_evidence_required": True, + "plan_handoff_mode": "offer_after_answer", + }, + fit_brief={ + "project_summary": "库存异常播报助手", + }, + external_recommendation={ + "retrieval_status": "evidence_found", + "impact_status": "indirect_only", + "external_summary": "Responses API 更适合放在结果出来之后的解释与追问层。", + "external_impact": "当前只支持背景相关性,暂不能直接映射到当前项目对象。", + }, + recommended_next_step="先确认它在项目里具体承接哪个节点。", + project_snapshot={ + "project_name": "库存异常播报助手", + "tasks": [ + { + "id": 701, + "title": "任务A", + "description": "每 15 分钟从仓储中台拉取异常库存快照,清洗后写入临时表供后续核算使用。", + }, + { + "id": 702, + "title": "任务B", + "description": "根据前序结果生成发给值班同学的异常播报文本,并支持继续追问异常原因。", + }, + ], + "analysis_object_refs": { + "fit_candidate_task_ref": {"kind": "task", "id": 702, "title": "任务B"}, + "fit_rejected_task_ref": {"kind": "task", "id": 701, "title": "任务A"}, + }, + }, + ) + + assert packet["candidate_position"] == "任务B" + assert packet["rejected_position"] == "任务A" + assert packet["reason_chain"] == [ + "任务A现在做的是每 15 分钟从仓储中台拉取异常库存快照,清洗后写入临时表供后续核算使用,这一步要稳,不能随便把新能力塞进去。", + "任务B现在做的是根据前序结果生成发给值班同学的异常播报文本,并支持继续追问异常原因,先在这里试更稳,也更容易看出值不值。", + ] + + +def test_build_analysis_packet_for_project_fit_fails_closed_when_tasks_and_signals_lack_explicit_refs() -> None: + packet = build_analysis_packet( + route_decision={ + "route_mode": "external", + "effective_source_policy": "external", + }, + execution_recipe={ + "primary_lane": "external", + "fit_brief_required": True, + }, + fit_brief={ + "project_summary": "采购经理每日实时库存上报服务", + }, + external_recommendation={ + "retrieval_status": "evidence_found", + "impact_status": "indirect_only", + "external_summary": "Responses API 更适合多轮、状态化的解释与追问。", + "external_impact": "当前只支持背景相关性,暂不能直接映射到当前项目对象。", + }, + recommended_next_step="先确认它是不是只用在摘要卡片发出后的解释环节。", + project_snapshot={ + "project_name": "采购经理每日实时库存上报服务", + "tasks": [ + { + "title": "每日固定时段生成并推送结构化摘要卡片", + "description": "每日凌晨触发全量计算,生成含标题、总水位摘要、TOP10表格的结构化文本卡片,并在上午10点前推送。", + }, + { + "title": "WMS库存快照自动拉取与清洗", + "description": "从WMS系统每小时拉取库存快照,按可用/在途/冻结三态聚合,清洗异常值后写入中台临时表。", + }, + ], + "signals": [ + { + "title": "交付形式 - 信息呈现方式", + "summary": "以结构化文本卡片形式推送,含标题、总水位摘要、TOP10表格,并支持点击跳转至中台详情页。", + }, + { + "title": "外部信号源 - WMS实时库存接口", + "summary": "提供各仓库每小时更新的库存快照,作为水位与缺货计算的原始数据源。", + }, + ], + }, + ) + + assert packet["candidate_position"] is None + assert packet["rejected_position"] is None + assert packet["reason_chain"] == [ + "现在项目里还没有足够具体的任务或信号源,硬把它挂到某个位置上,结论很容易说偏。" + ] + + +def test_build_analysis_packet_for_project_fit_sparse_project_avoids_fake_layers() -> None: + packet = build_analysis_packet( + route_decision={ + "route_mode": "external", + "effective_source_policy": "external", + }, + execution_recipe={ + "primary_lane": "external", + "fit_brief_required": True, + "plan_handoff_mode": "offer_after_answer", + }, + fit_brief={ + "project_summary": "自动开票系统", + }, + external_recommendation={ + "retrieval_status": "evidence_found", + "impact_status": "indirect_only", + }, + recommended_next_step="先把这个项目最关键的业务步骤补清楚,我再判断它更适合放哪里。", + project_snapshot={ + "project_name": "自动开票系统", + "tasks": [], + "signals": [], + "analysis_object_refs": {}, + }, + ) + + assert packet["packet_type"] == "project_fit_analysis" + assert packet["conclusion"] == "光看自动开票系统现在这些项目信息,我还不想直接说它该放哪一步。" + assert packet["reason_chain"] == [ + "现在项目里还没有足够具体的任务或信号源,硬把它挂到某个位置上,结论很容易说偏。" + ] + assert packet["missing_mapping"] == "至少还缺一个能对上的业务步骤或信号源。" + assert packet["next_step"] == "先把这个项目最关键的业务步骤补清楚,我再判断它更适合放哪里。" + assert "结果出来之后的解释层" not in packet["conclusion"] + assert "当前主链" not in packet["conclusion"] + assert packet["focus_objects"] == [] + + +def test_build_analysis_packet_for_project_fit_sparse_project_uses_project_description_as_real_analysis() -> None: + packet = build_analysis_packet( + route_decision={ + "route_mode": "external", + "effective_source_policy": "external", + }, + execution_recipe={ + "primary_lane": "external", + "fit_brief_required": True, + "plan_handoff_mode": "offer_after_answer", + }, + fit_brief={ + "project_summary": "自动开票系统", + }, + external_recommendation={ + "retrieval_status": "evidence_found", + "impact_status": "indirect_only", + }, + recommended_next_step="先把这个项目最关键的业务步骤补清楚,我再判断它更适合放哪里。", + project_snapshot={ + "project_name": "自动开票系统", + "project_description": "开发一套自动开票系统,实现开票接口到达后自动开票并发送发票图片的功能。", + "tasks": [], + "signals": [], + "analysis_object_refs": {}, + }, + ) + + assert packet["packet_type"] == "project_fit_analysis" + assert packet["reason_chain"] == [ + "我现在只看得到这个项目的大致目标:开发一套自动开票系统,实现开票接口到达后自动开票并发送发票图片的功能。", + "但还看不到这条链路里谁来触发、要接哪些外部输入、以及哪个结果算真的跑通。", + ] + assert packet["missing_mapping"] == "还缺能对上的实际流程、关键结果和外部输入。" + assert packet["next_step"] == "至少先把实际流程拆出来:谁发起、系统接什么输入、最后产出什么结果。" + assert packet["focus_objects"] == [] + + +def test_build_analysis_packet_for_project_fit_engineering_tasks_avoids_false_business_placement() -> None: + packet = build_analysis_packet( + route_decision={ + "route_mode": "external", + "effective_source_policy": "external", + }, + execution_recipe={ + "primary_lane": "external", + "fit_brief_required": True, + }, + fit_brief={ + "project_summary": "AI直播复盘", + }, + external_recommendation={ + "retrieval_status": "evidence_found", + "impact_status": "indirect_only", + }, + recommended_next_step="先定清它到底想改善哪个业务结果,再看该落到哪一步。", + project_snapshot={ + "project_name": "AI直播复盘", + "tasks": [ + { + "title": "前端交互界面开发", + "description": "基于React 18和Vite构建前端,开发视频上传组件及复盘数据可视化展示页面。", + }, + { + "title": "容器化部署与ACK集群配置", + "description": "编写Dockerfile,配置阿里云ACK集群,部署后端与AI服务,实现CI/CD自动化流程。", + }, + ], + }, + ) + + assert packet["conclusion"] == "光看AI直播复盘现在这些项目信息,我还不想直接说它该放哪一步。" + assert packet["reason_chain"] == ["现在项目里还没有足够具体的任务或信号源,硬把它挂到某个位置上,结论很容易说偏。"] + assert packet["missing_mapping"] == "至少还缺一个能对上的业务步骤或信号源。" + assert packet["next_step"] == "先把这个项目最关键的业务步骤补清楚,我再判断它更适合放哪里。" + + +def test_build_analysis_packet_for_project_fit_signal_placeholders_avoids_forced_contrast() -> None: + packet = build_analysis_packet( + route_decision={ + "route_mode": "external", + "effective_source_policy": "external", + }, + execution_recipe={ + "primary_lane": "external", + "fit_brief_required": True, + }, + fit_brief={ + "project_summary": "健身食谱推荐系统", + }, + external_recommendation={ + "retrieval_status": "evidence_found", + "impact_status": "indirect_only", + }, + recommended_next_step="先定清这个项目最后要给谁看什么结果,再判断哪个信号只是输入、哪个位置更值得先试。", + project_snapshot={ + "project_name": "健身食谱推荐系统", + "signals": [ + { + "title": "增加食谱卡路里计算信号源", + "summary": "在健身食谱推荐系统中增加一个信号源,用于计算食物的卡路里。", + }, + { + "title": "增加每日运动消耗卡路里信号源", + "summary": "为健身食谱推荐系统增加一个信号源,用于计算每日运动消耗的卡路里。", + }, + ], + }, + ) + + assert packet["conclusion"] == "光看健身食谱推荐系统现在这些项目信息,我还不想直接说它该放哪一步。" + assert packet["reason_chain"] == ["现在项目里还没有足够具体的任务或信号源,硬把它挂到某个位置上,结论很容易说偏。"] + assert packet["missing_mapping"] == "至少还缺一个能对上的业务步骤或信号源。" + assert packet["next_step"] == "先把这个项目最关键的业务步骤补清楚,我再判断它更适合放哪里。" + + +def test_build_analysis_packet_for_project_fit_strips_embedded_context_from_task_detail() -> None: + packet = build_analysis_packet( + route_decision={ + "route_mode": "external", + "effective_source_policy": "external", + }, + execution_recipe={ + "primary_lane": "external", + "fit_brief_required": True, + }, + fit_brief={ + "project_summary": "AI劝退助手", + }, + external_recommendation={ + "retrieval_status": "evidence_found", + "impact_status": "indirect_only", + }, + recommended_next_step="先定清它是给需求澄清与范围确认补说明,还是会直接改到核心方案落地,再决定要不要继续做。", + project_snapshot={ + "project_name": "AI劝退助手", + "tasks": [ + { + "title": "需求澄清与范围确认", + "description": "基于飞书信号补全需求边界、验收口径与优先级。上下文:请录入一个信号:AI劝退助手新增次日回访功能。", + }, + { + "title": "核心方案落地", + "description": "围绕需求信号推进可交付实现,拆分后端/前端/AI 子任务并排期。上下文:请录入一个信号:AI劝退助手新增次日回访功能。", + }, + ], + }, + ) + + assert all("上下文:" not in item for item in packet["reason_chain"]) + + +def test_build_analysis_packet_for_external_impact_verifier_blocked() -> None: + packet = build_analysis_packet( + route_decision={ + "route_mode": "external", + "effective_source_policy": "external", + }, + external_recommendation={ + "retrieval_status": "verifier_blocked", + "impact_status": "no_direct_impact", + "external_summary": "", + "external_impact": "", + "affected_signal_sources": [], + "external_action": "先修正证据质量,再决定是否继续外部推荐。", + }, + project_snapshot={ + "project_name": "Alpha", + "tasks": [ + { + "title": "每日固定时段生成并推送结构化摘要卡片", + "description": "每日凌晨触发全量计算,生成含标题、总水位摘要、TOP10表格的结构化文本卡片,并在上午10点前推送。", + } + ], + "signals": [ + { + "title": "外部信号源 - WMS实时库存接口", + "summary": "提供各仓库每小时更新的库存快照,作为水位与缺货计算的原始数据源。", + } + ], + }, + ) + + assert packet["packet_type"] == "external_impact_analysis" + assert packet["retrieval_status"] == "verifier_blocked" + assert packet["blocked_reason_or_gap"] + assert packet["focus_objects"] + assert packet["current_subject"] == "Alpha" + assert packet["reason_chain"] == [ + "现在卡在外部信号源 - WMS实时库存接口这条线索上:它指向提供各仓库每小时更新的库存快照,作为水位与缺货计算的原始数据源,但还没通过校验。", + "项目里现在更多还是实现层任务,我不想硬说它会直接改到哪一步,先把这条线索核实清楚更稳。", + ] + assert packet["blocked_reason_or_gap"] == "先把外部信号源 - WMS实时库存接口这条线索核实清楚,再确认它到底会影响这个项目里的哪个结果。" + assert packet["next_step"] == "先把外部信号源 - WMS实时库存接口这条线索核实清楚,再确认它到底会影响这个项目里的哪个结果。" + + +def test_build_analysis_packet_for_external_impact_verifier_blocked_with_engineering_tasks_stays_generic() -> None: + packet = build_analysis_packet( + route_decision={ + "route_mode": "external", + "effective_source_policy": "external", + }, + external_recommendation={ + "retrieval_status": "verifier_blocked", + "impact_status": "no_direct_impact", + "affected_signal_sources": ["外部信号源 - TikTok Shop 直播开放API(评论流"], + }, + project_snapshot={ + "project_name": "AI直播复盘", + "tasks": [ + { + "title": "前端交互界面开发", + "description": "基于React 18和Vite构建前端,开发视频上传组件及复盘数据可视化展示页面。", + }, + { + "title": "容器化部署与ACK集群配置", + "description": "编写Dockerfile,配置阿里云ACK集群,部署后端与AI服务,实现CI/CD自动化流程。", + }, + ], + "signals": [ + { + "title": "外部信号源 - TikTok Shop 直播开放API(评论流", + "summary": "获取带毫秒级时间戳的原始评论序列,用于与录屏帧对齐。", + } + ], + }, + ) + + assert packet["conclusion"] == "这条外部变化现在还不能直接挂到AI直播复盘的项目结论上。" + assert packet["reason_chain"] == [ + "现在卡在外部信号源 - TikTok Shop 直播开放API(评论流这条线索上:它指向获取带毫秒级时间戳的原始评论序列,用于与录屏帧对齐,但还没通过校验。", + "项目里现在更多还是实现层任务,我不想硬说它会直接改到哪一步,先把这条线索核实清楚更稳。", + ] + assert packet["next_step"] == "先把外部信号源 - TikTok Shop 直播开放API(评论流这条线索核实清楚,再确认它到底会影响这个项目里的哪个结果。" + + +def test_build_analysis_packet_for_project_fit_direct_match_prefers_dependent_task_over_anchor_signal() -> None: + packet = build_analysis_packet( + route_decision={ + "route_mode": "external", + "effective_source_policy": "external", + }, + execution_recipe={ + "primary_lane": "external", + "fit_brief_required": True, + "external_evidence_required": True, + "plan_handoff_mode": "offer_after_answer", + }, + fit_brief={ + "project_summary": "采购经理每日实时库存上报服务", + }, + external_recommendation={ + "retrieval_status": "evidence_found", + "impact_status": "direct_match", + "affected_signal_sources": ["外部信号源 - WMS实时库存接口"], + "external_action": "直接看它会影响哪条下游任务,再决定要不要进 plan。", + }, + project_snapshot={ + "project_name": "采购经理每日实时库存上报服务", + "tasks": [ + { + "title": "WMS库存快照自动拉取与清洗", + "description": "从WMS系统每小时拉取库存快照,按可用/在途/冻结三态聚合,清洗异常值后写入中台临时表。", + }, + { + "title": "每日固定时段生成并推送结构化摘要卡片", + "description": "每日凌晨触发全量计算,生成含标题、总水位摘要、TOP10表格的结构化文本卡片,并在上午10点前推送。", + }, + ], + "signals": [ + { + "title": "外部信号源 - WMS实时库存接口", + "summary": "提供各仓库每小时更新的库存快照,作为水位与缺货计算的原始数据源。", + } + ], + }, + ) + + assert packet["packet_type"] == "project_fit_analysis" + assert packet["fit_verdict"] == "fit_now" + assert packet["candidate_position"] == "外部信号源 - WMS实时库存接口" + assert packet["preferred_layer"] == "外部信号源 - WMS实时库存接口" + assert [item["name"] for item in packet["focus_objects"]] == ["外部信号源 - WMS实时库存接口"] + assert [item["stance"] for item in packet["focus_objects"]] == ["anchor"] + assert packet["reason_chain"] == [ + "外部信号源 - WMS实时库存接口是当前最直接会接住这条变化的项目对象。", + "对应的正式锚点是外部信号源 - WMS实时库存接口,它指向提供各仓库每小时更新的库存快照,作为水位与缺货计算的原始数据源,已经和外部证据建立了直接映射。", + ] + + +def test_build_analysis_packet_for_external_impact_direct_match_prefers_business_task_before_anchor_signal() -> None: + packet = build_analysis_packet( + route_decision={ + "route_mode": "external", + "effective_source_policy": "external", + }, + external_recommendation={ + "retrieval_status": "evidence_found", + "impact_status": "direct_match", + "affected_signal_sources": ["外部信号源 - WMS实时库存接口"], + "external_action": "直接看它会影响哪条下游任务,再决定要不要进 plan。", + }, + project_snapshot={ + "project_name": "采购经理每日实时库存上报服务", + "tasks": [ + { + "title": "WMS库存快照自动拉取与清洗", + "description": "从WMS系统每小时拉取库存快照,按可用/在途/冻结三态聚合,清洗异常值后写入中台临时表。", + }, + { + "title": "每日固定时段生成并推送结构化摘要卡片", + "description": "每日凌晨触发全量计算,生成含标题、总水位摘要、TOP10表格的结构化文本卡片,并在上午10点前推送。", + }, + ], + "signals": [ + { + "title": "外部信号源 - WMS实时库存接口", + "summary": "提供各仓库每小时更新的库存快照,作为水位与缺货计算的原始数据源。", + } + ], + }, + ) + + assert packet["packet_type"] == "external_impact_analysis" + assert packet["retrieval_status"] == "evidence_found" + assert packet["impact_status"] == "direct_match" + assert [item["name"] for item in packet["focus_objects"]] == ["外部信号源 - WMS实时库存接口"] + assert [item["stance"] for item in packet["focus_objects"]] == ["anchor"] + + +def test_build_analysis_packet_for_external_direct_match_engineering_tasks_stays_generic() -> None: + packet = build_analysis_packet( + route_decision={ + "route_mode": "external", + "effective_source_policy": "external", + }, + external_recommendation={ + "retrieval_status": "evidence_found", + "impact_status": "direct_match", + "affected_signal_sources": ["外部信号源 - TikTok Shop 直播开放API(评论流"], + }, + project_snapshot={ + "project_name": "AI直播复盘", + "tasks": [ + { + "title": "前端交互界面开发", + "description": "基于React 18和Vite构建前端,开发视频上传组件及复盘数据可视化展示页面。", + }, + { + "title": "容器化部署与ACK集群配置", + "description": "编写Dockerfile,配置阿里云ACK集群,部署后端与AI服务,实现CI/CD自动化流程。", + }, + ], + "signals": [ + { + "title": "外部信号源 - TikTok Shop 直播开放API(评论流", + "summary": "获取带毫秒级时间戳的原始评论序列,用于与录屏帧对齐。", + } + ], + }, + ) + + assert packet["conclusion"] == "这条外部变化已经和AI直播复盘对上了,但现在还不适合硬挂到某个实现任务上。" + assert packet["reason_chain"] == [ + "能对上的外部来源是外部信号源 - TikTok Shop 直播开放API(评论流,它指向获取带毫秒级时间戳的原始评论序列,用于与录屏帧对齐,所以这次不是泛泛相关。", + "项目里现在更多还是实现层任务,先别急着说它会直接改到哪一步,先看它到底影响哪个业务结果。", + ] + assert packet["next_step"] == "先确认这条变化会影响哪个业务结果,再决定要不要拆到具体任务。" + + +def test_build_analysis_packet_for_external_direct_match_prefers_explicit_affected_signal_source_over_snapshot_anchor() -> None: + packet = build_analysis_packet( + route_decision={ + "route_mode": "external", + "effective_source_policy": "external", + }, + external_recommendation={ + "retrieval_status": "evidence_found", + "impact_status": "direct_match", + "affected_signal_sources": ["外部信号源 - WMS库存快照API"], + }, + recommended_next_step="直接看这条变化会不会先改到下游动作。", + project_snapshot={ + "project_name": "门店库存异常日报助手", + "tasks": [ + { + "id": 180, + "title": "三类异常的基础识别逻辑(缺货", + "description": "三类异常的基础识别逻辑(缺货/负库存/90天滞销)。", + } + ], + "signals": [ + { + "id": 411, + "title": "配置灵活性 - 异常规则可低代码配置", + "summary": "支持在后台界面调整三类异常的判定阈值。", + }, + { + "id": 412, + "title": "外部信号源 - WMS库存快照API", + "summary": "提供T-1全量门店SKU级库存水位与状态,是异常识别的唯一原始数据源。", + }, + ], + "analysis_object_refs": { + "primary_anchor_signal_ref": {"kind": "signal", "id": 411, "title": "配置灵活性 - 异常规则可低代码配置"}, + "primary_dependent_task_ref": {"kind": "task", "id": 180, "title": "三类异常的基础识别逻辑(缺货"}, + }, + }, + ) + + assert [item["name"] for item in packet["focus_objects"]] == [ + "三类异常的基础识别逻辑(缺货", + "外部信号源 - WMS库存快照API", + ] + assert "配置灵活性 - 异常规则可低代码配置" not in "".join(packet["reason_chain"]) + assert packet["next_step"] == "下一步就看这条变化会不会直接改到三类异常的基础识别逻辑(缺货后面的动作或规则。" + + +def test_build_analysis_packet_for_project_fit_signal_only_project_uses_real_signal_objects() -> None: + packet = build_analysis_packet( + route_decision={ + "route_mode": "external", + "effective_source_policy": "external", + }, + execution_recipe={ + "primary_lane": "external", + "fit_brief_required": True, + "external_evidence_required": True, + "plan_handoff_mode": "offer_after_answer", + }, + fit_brief={ + "project_summary": "直播复盘辅助工具(速记版)", + }, + external_recommendation={ + "retrieval_status": "evidence_found", + "impact_status": "indirect_only", + }, + recommended_next_step="先确认它在当前项目里具体承接哪个节点。", + project_snapshot={ + "project_name": "直播复盘辅助工具(速记版)", + "signals": [ + { + "id": 427, + "title": "输入适配 - 双源时间对齐能力", + "summary": "支持手动校准录屏与评论的时间偏移,并在此基础上完成全链路切片分析。", + }, + { + "id": 428, + "title": "输出规范 - 三栏速记清单格式", + "summary": "输出纯文本文件,含高光、掉点、执行偏差三栏。", + }, + { + "id": 430, + "title": "外部信号源 - TikTok Shop 直播开放API(评论流", + "summary": "获取带毫秒级时间戳的原始评论序列,用于与录屏帧对齐。", + }, + ], + "analysis_object_refs": { + "fit_candidate_signal_ref": {"kind": "signal", "id": 428, "title": "输出规范 - 三栏速记清单格式"}, + "fit_rejected_signal_ref": {"kind": "signal", "id": 427, "title": "输入适配 - 双源时间对齐能力"}, + "primary_anchor_signal_ref": {"kind": "signal", "id": 430, "title": "外部信号源 - TikTok Shop 直播开放API(评论流"}, + }, + }, + ) + + assert packet["candidate_position"] == "输出规范 - 三栏速记清单格式" + assert packet["rejected_position"] == "输入适配 - 双源时间对齐能力" + assert packet["reason_chain"] == [ + "输入适配 - 双源时间对齐能力现在代表的是支持手动校准录屏与评论的时间偏移,并在此基础上完成全链路切片分析,这类前面必须先跑通的条件,不适合直接拿来试新能力。", + "输出规范 - 三栏速记清单格式现在代表的是输出纯文本文件,含高光、掉点、执行偏差三栏,先从这里试更稳,也更容易看出它有没有价值。", + ] + assert packet["missing_mapping"] == "现在还说不准它会先帮到输出规范 - 三栏速记清单格式,还是会直接改到输入适配 - 双源时间对齐能力。" + assert "结果出来之后的解释层" not in packet["conclusion"] + assert packet["conclusion"] == "放到直播复盘辅助工具(速记版)里,我更倾向先让它去帮输出规范 - 三栏速记清单格式,不太会先拿它改输入适配 - 双源时间对齐能力。" + + +def test_build_analysis_packet_for_internal_explain_uses_internal_contract_and_target() -> None: + packet = build_analysis_packet( + route_decision={ + "route_mode": "internal", + "effective_source_policy": "internal", + "reply_contract": {"mode": "explicit_source", "head": "内部知识库查询:"}, + }, + query_type="status", + answer="当前活动引擎是 Qwen 低阶引擎。", + evidence_pack={ + "summary": { + "conclusion": "当前活动引擎是 Qwen 低阶引擎。", + "uncertainties": [], + } + }, + internal_context_packet={ + "operation_contract": { + "query_type": "status", + "answer_contract": "status_summary", + }, + "target_resolution": { + "resolution_mode": "resolved", + "primary_target_label": "模型路由配置", + }, + "query_plan": { + "answer_contract": "status_summary", + }, + }, + ) + + assert packet["packet_type"] == "internal_explain_analysis" + assert packet["answer_contract"] == "status_summary" + assert packet["resolved_target"] == "模型路由配置" + assert packet["current_subject"] == "模型路由配置" + assert packet["reason_chain"] == [ + "这次就按模型路由配置的当前情况回答,先不展开方案。", + ] + + +def test_build_analysis_packet_for_internal_explain_can_resolve_target_from_project_snapshot_when_context_missing() -> None: + packet = build_analysis_packet( + route_decision={ + "route_mode": "internal", + "effective_source_policy": "internal", + }, + query_type="status", + answer="当前已经能检查团队成员负载并推荐可行 assignee。", + evidence_pack={ + "summary": { + "conclusion": "当前已经能检查团队成员负载并推荐可行 assignee。", + "uncertainties": [], + } + }, + project_snapshot={ + "project_name": "确认立项", + "signals": [ + { + "title": "MVP 功能 - 检查团队成员负载并推荐可行assignee", + "summary": "检查团队成员负载并推荐可行assignee", + }, + { + "title": "技术架构 - 基于输入JSON结构做字段完整性校验", + "summary": "基于输入JSON结构做字段完整性校验", + }, + ], + }, + ) + + assert packet["resolved_target"] is None + assert packet["focus_objects"] == [] + assert packet["reason_chain"] == ["这次先直接回答当前情况,不往方案上展开。"] + + +def test_build_analysis_packet_for_internal_explain_ignores_none_conclusion_when_matching_snapshot_target() -> None: + packet = build_analysis_packet( + route_decision={ + "route_mode": "internal", + "effective_source_policy": "internal", + }, + query_type="status", + answer="当前已经能识别产能异常并生成提醒。", + evidence_pack={ + "summary": { + "conclusion": None, + "uncertainties": [], + } + }, + project_snapshot={ + "project_name": "测试产能警告", + "signals": [ + { + "title": "MVP 功能 - 识别产能异常并生成提醒", + "summary": "识别产能异常并生成提醒", + }, + ], + }, + ) + + assert packet["packet_type"] == "internal_explain_analysis" + assert packet["resolved_target"] is None + assert packet["current_subject"] is None + assert packet["reason_chain"] == ["这次先直接回答当前情况,不往方案上展开。"] + + +def test_build_analysis_packet_for_followup_explain_uses_anchor_and_clarification_state() -> None: + packet = build_analysis_packet( + route_decision={ + "route_mode": "uncertain", + "effective_source_policy": "uncertain", + "reply_contract": {"mode": "clarify"}, + }, + answer="你是想问外部公开事实,还是系统内部配置/项目对象?", + clarification_code="query_route_clarify", + anchor_kind="reply_thread", + meta_followup_kind="route_selection", + evidence_pack={ + "summary": { + "conclusion": "需要先确认查询来源。", + "uncertainties": ["内部还是外部"], + } + }, + ) + + assert packet["packet_type"] == "followup_explain_analysis" + assert packet["anchor_kind"] == "reply_thread" + assert packet["continuation_kind"] == "route_selection" + assert packet["clarification_state"] == "query_route_clarify" diff --git a/backend/tests/test_analysis_packet_golden_v2.py b/backend/tests/test_analysis_packet_golden_v2.py new file mode 100644 index 00000000..ed16bde5 --- /dev/null +++ b/backend/tests/test_analysis_packet_golden_v2.py @@ -0,0 +1,407 @@ +from __future__ import annotations + +import pytest + +from app.services.analysis_packet_builder import build_analysis_packet +from app.services.integrations.feishu_integration_service.mixin_receive_routing import ( + FeishuReceiveRoutingMixin, +) + + +def _render_text(packet: dict[str, object]) -> str: + return FeishuReceiveRoutingMixin._build_feishu_qa_analysis_packet_reply_text(packet) + + +@pytest.mark.parametrize( + ("case_name", "input_payload", "expected_fragments", "forbidden_fragments"), + [ + ( + "project_fit_inventory_responses_api", + { + "route_decision": {"route_mode": "external", "effective_source_policy": "external"}, + "execution_recipe": { + "primary_lane": "external", + "fit_brief_required": True, + "external_evidence_required": True, + "plan_handoff_mode": "offer_after_answer", + }, + "fit_brief": {"project_summary": "采购经理每日实时库存上报服务"}, + "external_recommendation": { + "retrieval_status": "evidence_found", + "impact_status": "indirect_only", + }, + "recommended_next_step": "先确认它在当前项目里具体承接哪个节点。", + "project_snapshot": { + "project_name": "采购经理每日实时库存上报服务", + "tasks": [ + { + "id": 1, + "title": "每日固定时段生成并推送结构化摘要卡片", + "description": "每日凌晨触发全量计算,生成含标题、总水位摘要、TOP10表格的结构化文本卡片,并在上午10点前推送。", + }, + { + "id": 2, + "title": "WMS库存快照自动拉取与清洗", + "description": "从WMS系统每小时拉取库存快照,按可用/在途/冻结三态聚合,清洗异常值后写入中台临时表。", + }, + ], + "signals": [ + { + "id": 11, + "title": "外部信号源 - WMS实时库存接口", + "summary": "提供各仓库每小时更新的库存快照,作为水位与缺货计算的原始数据源。", + } + ], + "analysis_object_refs": { + "fit_candidate_task_ref": {"kind": "task", "id": 1, "title": "每日固定时段生成并推送结构化摘要卡片"}, + "fit_rejected_task_ref": {"kind": "task", "id": 2, "title": "WMS库存快照自动拉取与清洗"}, + "primary_anchor_signal_ref": {"kind": "signal", "id": 11, "title": "外部信号源 - WMS实时库存接口"}, + "primary_dependent_task_ref": {"kind": "task", "id": 2, "title": "WMS库存快照自动拉取与清洗"}, + }, + }, + }, + [ + "每日固定时段生成并推送结构化摘要卡片", + "WMS库存快照自动拉取与清洗", + "先在这里试更稳", + "先定清楚它是给每日固定时段生成并推送结构化摘要卡片补说明", + ], + ["当前项目:", "先说我的判断", "到底承担什么角色"], + ), + ( + "project_fit_generic_titles_prefers_explicit_refs", + { + "route_decision": {"route_mode": "external", "effective_source_policy": "external"}, + "execution_recipe": { + "primary_lane": "external", + "fit_brief_required": True, + "external_evidence_required": True, + "plan_handoff_mode": "offer_after_answer", + }, + "fit_brief": {"project_summary": "库存异常播报助手"}, + "external_recommendation": { + "retrieval_status": "evidence_found", + "impact_status": "indirect_only", + }, + "recommended_next_step": "先确认它在项目里具体承接哪个节点。", + "project_snapshot": { + "project_name": "库存异常播报助手", + "tasks": [ + { + "id": 21, + "title": "任务A", + "description": "每 15 分钟从仓储中台拉取异常库存快照,清洗后写入临时表供后续核算使用。", + }, + { + "id": 22, + "title": "任务B", + "description": "根据前序结果生成发给值班同学的异常播报文本,并支持继续追问异常原因。", + }, + ], + "analysis_object_refs": { + "fit_candidate_task_ref": {"kind": "task", "id": 22, "title": "任务B"}, + "fit_rejected_task_ref": {"kind": "task", "id": 21, "title": "任务A"}, + }, + }, + }, + ["任务B", "任务A", "异常播报文本", "先在这里试更稳"], + ["status_summary", "外部推荐"], + ), + ( + "project_fit_direct_match_inventory", + { + "route_decision": {"route_mode": "external", "effective_source_policy": "external"}, + "execution_recipe": { + "primary_lane": "external", + "fit_brief_required": True, + "external_evidence_required": True, + "plan_handoff_mode": "offer_after_answer", + }, + "fit_brief": {"project_summary": "采购经理每日实时库存上报服务"}, + "external_recommendation": { + "retrieval_status": "evidence_found", + "impact_status": "direct_match", + "affected_signal_sources": ["外部信号源 - WMS实时库存接口"], + }, + "recommended_next_step": "直接看它会影响哪条下游任务,再决定要不要进 plan。", + "project_snapshot": { + "project_name": "采购经理每日实时库存上报服务", + "tasks": [ + { + "id": 31, + "title": "WMS库存快照自动拉取与清洗", + "description": "从WMS系统每小时拉取库存快照,按可用/在途/冻结三态聚合,清洗异常值后写入中台临时表。", + } + ], + "signals": [ + { + "id": 41, + "title": "外部信号源 - WMS实时库存接口", + "summary": "提供各仓库每小时更新的库存快照,作为水位与缺货计算的原始数据源。", + } + ], + "analysis_object_refs": { + "primary_anchor_signal_ref": {"kind": "signal", "id": 41, "title": "外部信号源 - WMS实时库存接口"}, + "primary_dependent_task_ref": {"kind": "task", "id": 31, "title": "WMS库存快照自动拉取与清洗"}, + }, + }, + }, + ["WMS库存快照自动拉取与清洗", "外部信号源 - WMS实时库存接口"], + ["当前项目:", "外部判断:"], + ), + ( + "external_verifier_blocked_inventory", + { + "route_decision": {"route_mode": "external", "effective_source_policy": "external"}, + "external_recommendation": { + "retrieval_status": "verifier_blocked", + "impact_status": "no_direct_impact", + "external_action": "先修正证据质量,再决定是否继续外部推荐。", + }, + "recommended_next_step": "先修正证据质量,再决定是否继续外部推荐。", + "project_snapshot": { + "project_name": "Alpha", + "tasks": [ + { + "id": 51, + "title": "每日固定时段生成并推送结构化摘要卡片", + "description": "每日凌晨触发全量计算,生成结构化文本卡片,并在上午10点前推送。", + } + ], + "signals": [ + { + "id": 61, + "title": "外部信号源 - WMS实时库存接口", + "summary": "提供各仓库每小时更新的库存快照,作为水位与缺货计算的原始数据源。", + } + ], + "analysis_object_refs": { + "primary_anchor_signal_ref": {"kind": "signal", "id": 61, "title": "外部信号源 - WMS实时库存接口"}, + "primary_dependent_task_ref": {"kind": "task", "id": 51, "title": "每日固定时段生成并推送结构化摘要卡片"}, + }, + }, + }, + ["外部信号源 - WMS实时库存接口", "每日固定时段生成并推送结构化摘要卡片", "核实清楚"], + ["外部推荐", "证据质量"], + ), + ( + "external_direct_match_inventory", + { + "route_decision": {"route_mode": "external", "effective_source_policy": "external"}, + "external_recommendation": { + "retrieval_status": "evidence_found", + "impact_status": "direct_match", + "affected_signal_sources": ["外部信号源 - WMS实时库存接口"], + }, + "recommended_next_step": "直接看它会影响哪条下游任务,再决定要不要进 plan。", + "project_snapshot": { + "project_name": "采购经理每日实时库存上报服务", + "tasks": [ + { + "id": 71, + "title": "WMS库存快照自动拉取与清洗", + "description": "从WMS系统每小时拉取库存快照,按可用/在途/冻结三态聚合,清洗异常值后写入中台临时表。", + } + ], + "signals": [ + { + "id": 72, + "title": "外部信号源 - WMS实时库存接口", + "summary": "提供各仓库每小时更新的库存快照,作为水位与缺货计算的原始数据源。", + } + ], + "analysis_object_refs": { + "primary_anchor_signal_ref": {"kind": "signal", "id": 72, "title": "外部信号源 - WMS实时库存接口"}, + "primary_dependent_task_ref": {"kind": "task", "id": 71, "title": "WMS库存快照自动拉取与清洗"}, + }, + }, + }, + ["WMS库存快照自动拉取与清洗", "能对上的外部来源是外部信号源 - WMS实时库存接口", "不是泛泛相关"], + ["status_summary", "当前项目:"], + ), + ( + "external_indirect_only_prefers_explanation_layer", + { + "route_decision": {"route_mode": "external", "effective_source_policy": "external"}, + "external_recommendation": { + "retrieval_status": "evidence_found", + "impact_status": "indirect_only", + }, + "recommended_next_step": "先确认它是不是只用在摘要卡片之后。", + "project_snapshot": { + "project_name": "采购经理每日实时库存上报服务", + "tasks": [ + { + "id": 81, + "title": "每日固定时段生成并推送结构化摘要卡片", + "description": "每日凌晨触发全量计算,生成结构化文本卡片,并在上午10点前推送。", + }, + { + "id": 82, + "title": "WMS库存快照自动拉取与清洗", + "description": "从WMS系统每小时拉取库存快照并写入中台临时表。", + }, + ], + "analysis_object_refs": { + "fit_candidate_task_ref": {"kind": "task", "id": 81, "title": "每日固定时段生成并推送结构化摘要卡片"}, + "fit_rejected_task_ref": {"kind": "task", "id": 82, "title": "WMS库存快照自动拉取与清洗"}, + }, + }, + }, + ["背景相关", "每日固定时段生成并推送结构化摘要卡片", "WMS库存快照自动拉取与清洗"], + ["先说我的判断", "外部判断:"], + ), + ( + "internal_status_model_router", + { + "route_decision": {"route_mode": "internal", "effective_source_policy": "internal"}, + "answer": "当前活动引擎是 Qwen 低阶引擎。", + "query_type": "status", + "evidence_pack": {"summary": {"conclusion": "当前活动引擎是 Qwen 低阶引擎。"}}, + "internal_context_packet": { + "operation_contract": {"query_type": "status", "answer_contract": "status_summary"}, + "target_resolution": {"primary_target_label": "模型路由配置"}, + "query_plan": {"answer_contract": "status_summary"}, + }, + }, + ["当前活动引擎是 Qwen 低阶引擎", "模型路由配置", "当前情况回答"], + ["status_summary", "泛化建议"], + ), + ( + "internal_status_with_uncertainty", + { + "route_decision": {"route_mode": "internal", "effective_source_policy": "internal"}, + "answer": "当前活动引擎是 Qwen 低阶引擎。", + "query_type": "status", + "evidence_pack": { + "summary": { + "conclusion": "当前活动引擎是 Qwen 低阶引擎。", + "uncertainties": ["尚未确认是否已经切到最新灰度配置。"], + } + }, + "internal_context_packet": { + "operation_contract": {"query_type": "status", "answer_contract": "status_summary"}, + "target_resolution": {"primary_target_label": "模型路由配置"}, + "query_plan": {"answer_contract": "status_summary"}, + }, + }, + ["当前活动引擎是 Qwen 低阶引擎", "模型路由配置", "先不展开方案"], + ["status_summary", "contract"], + ), + ( + "followup_route_clarify", + { + "route_decision": {"route_mode": "uncertain", "effective_source_policy": "uncertain"}, + "answer": "你是想问外部公开事实,还是系统内部配置/项目对象?", + "clarification_code": "query_route_clarify", + "anchor_kind": "reply_thread", + "meta_followup_kind": "route_selection", + "evidence_pack": { + "summary": { + "conclusion": "需要先确认查询来源。", + "uncertainties": ["内部还是外部"], + } + }, + }, + ["需要先确认查询来源"], + ["当前项目:", "status_summary"], + ), + ( + "feishu_render_fit_indirect", + { + "route_decision": {"route_mode": "external", "effective_source_policy": "external"}, + "execution_recipe": { + "primary_lane": "external", + "fit_brief_required": True, + "plan_handoff_mode": "offer_after_answer", + }, + "fit_brief": {"project_summary": "采购经理每日实时库存上报服务"}, + "external_recommendation": {"retrieval_status": "evidence_found", "impact_status": "indirect_only"}, + "recommended_next_step": "先确认它是不是只用在摘要卡片之后。", + "project_snapshot": { + "project_name": "采购经理每日实时库存上报服务", + "tasks": [ + { + "id": 91, + "title": "每日固定时段生成并推送结构化摘要卡片", + "description": "每日凌晨触发全量计算,生成结构化文本卡片,并在上午10点前推送。", + }, + { + "id": 92, + "title": "WMS库存快照自动拉取与清洗", + "description": "从WMS系统每小时拉取库存快照并写入中台临时表。", + }, + ], + "analysis_object_refs": { + "fit_candidate_task_ref": {"kind": "task", "id": 91, "title": "每日固定时段生成并推送结构化摘要卡片"}, + "fit_rejected_task_ref": {"kind": "task", "id": 92, "title": "WMS库存快照自动拉取与清洗"}, + }, + }, + }, + ["每日固定时段生成并推送结构化摘要卡片", "WMS库存快照自动拉取与清洗"], + ["当前项目:", "先说我的判断"], + ), + ( + "feishu_render_verifier_blocked", + { + "route_decision": {"route_mode": "external", "effective_source_policy": "external"}, + "external_recommendation": {"retrieval_status": "verifier_blocked", "impact_status": "no_direct_impact"}, + "recommended_next_step": "先修正证据质量,再决定是否继续外部推荐。", + "project_snapshot": { + "project_name": "Alpha", + "tasks": [ + { + "id": 101, + "title": "每日固定时段生成并推送结构化摘要卡片", + "description": "每日凌晨触发全量计算,生成结构化文本卡片,并在上午10点前推送。", + } + ], + "signals": [ + { + "id": 102, + "title": "外部信号源 - WMS实时库存接口", + "summary": "提供各仓库每小时更新的库存快照,作为水位与缺货计算的原始数据源。", + } + ], + "analysis_object_refs": { + "primary_anchor_signal_ref": {"kind": "signal", "id": 102, "title": "外部信号源 - WMS实时库存接口"}, + "primary_dependent_task_ref": {"kind": "task", "id": 101, "title": "每日固定时段生成并推送结构化摘要卡片"}, + }, + }, + }, + ["核实清楚", "每日固定时段生成并推送结构化摘要卡片"], + ["外部推荐", "证据质量"], + ), + ( + "feishu_render_internal_status", + { + "route_decision": {"route_mode": "internal", "effective_source_policy": "internal"}, + "answer": "当前活动引擎是 Qwen 低阶引擎。", + "query_type": "status", + "evidence_pack": {"summary": {"conclusion": "当前活动引擎是 Qwen 低阶引擎。"}}, + "internal_context_packet": { + "operation_contract": {"query_type": "status", "answer_contract": "status_summary"}, + "target_resolution": {"primary_target_label": "模型路由配置"}, + "query_plan": {"answer_contract": "status_summary"}, + }, + }, + ["当前活动引擎是 Qwen 低阶引擎", "模型路由配置"], + ["status_summary", "泛化建议"], + ), + ], +) +def test_analysis_packet_golden_v2_cases( + case_name: str, + input_payload: dict[str, object], + expected_fragments: list[str], + forbidden_fragments: list[str], +) -> None: + _ = case_name + packet = build_analysis_packet(**input_payload) + assert packet["packet_type"] + rendered = _render_text(packet) + assert rendered + for fragment in expected_fragments: + assert fragment in rendered + for fragment in forbidden_fragments: + assert fragment not in rendered diff --git a/backend/tests/test_analysis_project_snapshot_loader.py b/backend/tests/test_analysis_project_snapshot_loader.py new file mode 100644 index 00000000..716e66f9 --- /dev/null +++ b/backend/tests/test_analysis_project_snapshot_loader.py @@ -0,0 +1,184 @@ +from types import SimpleNamespace + +import pytest + +from app.services.analysis_project_snapshot_loader import load_analysis_project_snapshot + + +class _FakeScalarResult: + def __init__(self, row): + self._row = row + + def scalar_one_or_none(self): + return self._row + + +class _FakeScalarsResult: + def __init__(self, rows): + self._rows = list(rows) + + def scalars(self): + return self + + def all(self): + return list(self._rows) + + +class _FakeDB: + def __init__(self, project_row, task_rows, signal_rows): + self._responses = [ + _FakeScalarResult(project_row), + _FakeScalarsResult(task_rows), + _FakeScalarsResult(signal_rows), + ] + + async def execute(self, _statement): + return self._responses.pop(0) + + +@pytest.mark.asyncio +async def test_load_analysis_project_snapshot_returns_richer_tasks_and_signals() -> None: + db = _FakeDB( + SimpleNamespace( + id=123, + tenant_id=9, + title="采购经理每日实时库存上报服务", + name="inventory-briefing", + description="首期聚焦总水位与TOP10缺货两个高价值信号。", + ), + [ + SimpleNamespace( + id=201, + title="每日固定时段生成并推送结构化摘要卡片", + description="每日凌晨生成结构化文本卡片,并在上午10点前推送。", + status="todo", + ), + SimpleNamespace( + id=202, + title="WMS库存快照自动拉取与清洗", + description="每小时拉取库存快照并清洗异常值。", + status="todo", + ), + ], + [ + SimpleNamespace( + id=301, + title="外部信号源 - WMS实时库存接口", + summary="提供各仓库每小时更新的库存快照,作为水位与缺货计算的原始数据源。", + reasoning="由 signal_source_registry 直接物化为正式 Signal。", + status="待处理", + source="custom:wms", + ), + ], + ) + + snapshot = await load_analysis_project_snapshot( + db=db, + tenant_id=9, + project_id=123, + ) + + assert snapshot["project_name"] == "采购经理每日实时库存上报服务" + assert snapshot["project_description"] == "首期聚焦总水位与TOP10缺货两个高价值信号。" + assert snapshot["task_titles"] == [ + "每日固定时段生成并推送结构化摘要卡片", + "WMS库存快照自动拉取与清洗", + ] + assert snapshot["tasks"][0]["description"] == "每日凌晨生成结构化文本卡片,并在上午10点前推送。" + assert snapshot["signal_titles"] == ["外部信号源 - WMS实时库存接口"] + assert snapshot["signals"][0]["summary"] == "提供各仓库每小时更新的库存快照,作为水位与缺货计算的原始数据源。" + assert snapshot["analysis_object_refs"] == {} + + +@pytest.mark.asyncio +async def test_load_analysis_project_snapshot_prefers_structural_signal_links_without_keyword_cues() -> None: + db = _FakeDB( + SimpleNamespace( + id=456, + tenant_id=9, + title="采购经理每日实时库存上报服务", + name="inventory-briefing", + description="首期聚焦总水位与TOP10缺货两个高价值信号。", + ), + [ + SimpleNamespace( + id=401, + title="任务A", + description="根据前序结果生成给采购经理的日报文本,并保留查看入口。", + status="todo", + ), + SimpleNamespace( + id=402, + title="任务B", + description="从WMS取库存样本写入中间表,供后续核算使用。", + status="todo", + ), + ], + [ + SimpleNamespace( + id=501, + title="信号一", + summary="WMS每小时更新库存样本,是总水位核算的原始来源。", + reasoning="这条来源直接支撑库存核算。", + status="待处理", + source="custom:wms", + ), + ], + ) + + snapshot = await load_analysis_project_snapshot( + db=db, + tenant_id=9, + project_id=456, + ) + + assert snapshot["analysis_object_refs"] == {} + + +@pytest.mark.asyncio +async def test_load_analysis_project_snapshot_builds_signal_refs_when_project_has_no_tasks() -> None: + db = _FakeDB( + SimpleNamespace( + id=789, + tenant_id=9, + title="直播复盘辅助工具(速记版)", + name="live-review-quick", + description="目标是在极短时间内完成直播复盘速记。", + ), + [], + [ + SimpleNamespace( + id=801, + title="输出规范 - 三栏速记清单格式", + summary="输出纯文本文件,含高光、掉点、执行偏差三栏。", + reasoning="由 signal_source_registry 直接物化为正式 Signal。", + status="待处理", + source="custom:output", + ), + SimpleNamespace( + id=802, + title="输入适配 - 双源时间对齐能力", + summary="支持手动校准录屏与评论的时间偏移,并在此基础上完成全链路切片分析。", + reasoning="由 signal_source_registry 直接物化为正式 Signal。", + status="待处理", + source="custom:input", + ), + SimpleNamespace( + id=803, + title="外部信号源 - TikTok Shop 直播开放API(评论流", + summary="获取带毫秒级时间戳的原始评论序列,用于与录屏帧对齐。", + reasoning="由 signal_source_registry 直接物化为正式 Signal。", + status="待处理", + source="custom:tiktok", + ), + ], + ) + + snapshot = await load_analysis_project_snapshot( + db=db, + tenant_id=9, + project_id=789, + ) + + assert snapshot["task_titles"] == [] + assert snapshot["analysis_object_refs"] == {} diff --git a/backend/tests/test_business_semantic_frame_contract.py b/backend/tests/test_business_semantic_frame_contract.py new file mode 100644 index 00000000..0cd1eb70 --- /dev/null +++ b/backend/tests/test_business_semantic_frame_contract.py @@ -0,0 +1,81 @@ +from app.services.ai_pmo.semantic_context.business_frame_schema import ( + AnswerShapeContract, + BusinessSemanticFrame, + ClarificationDelta, + CorrectionDelta, + OperationIntentContract, +) + + +def test_business_semantic_frame_supports_signal_source_recommendation() -> None: + frame = BusinessSemanticFrame( + goal_kind="recommendation", + target_scope="current_project", + resource_role="signal_source", + source_kind="external", + expected_answer_shape="ranked_candidates", + conversation_posture="business_operating", + ) + + assert frame.goal_kind == "recommendation" + assert frame.resource_role == "signal_source" + assert frame.expected_answer_shape == "ranked_candidates" + + +def test_business_semantic_frame_supports_feature_and_doc_resource_roles() -> None: + feature_frame = BusinessSemanticFrame( + goal_kind="status_lookup", + target_scope="global", + resource_role="feature", + source_kind="internal", + expected_answer_shape="bounded_explanation", + conversation_posture="business_operating", + ) + doc_frame = BusinessSemanticFrame( + goal_kind="status_lookup", + target_scope="global", + resource_role="doc", + source_kind="internal", + expected_answer_shape="inventory", + conversation_posture="business_operating", + ) + + assert feature_frame.resource_role == "feature" + assert doc_frame.resource_role == "doc" + + +def test_answer_shape_contract_supports_social_reply() -> None: + contract = AnswerShapeContract(shape="social_reply", confidence=0.92) + + assert contract.shape == "social_reply" + assert contract.confidence == 0.92 + + +def test_operation_intent_contract_supports_non_plan_write_phases() -> None: + contract = OperationIntentContract( + intent_phase="prepare_write", + requires_confirmation=True, + candidate_set_binding="cand_123", + ) + + assert contract.intent_phase == "prepare_write" + assert contract.requires_confirmation is True + assert contract.candidate_set_binding == "cand_123" + + +def test_clarification_and_correction_deltas_capture_structural_updates() -> None: + clarify = ClarificationDelta( + field="goal_kind", + previous_value="status_lookup", + next_value="recommendation", + reason="user_clarified_scope", + ) + correction = CorrectionDelta( + field="project_binding", + previous_value=81, + next_value=101, + reason="user_corrected_project", + ) + + assert clarify.next_value == "recommendation" + assert correction.next_value == 101 diff --git a/backend/tests/test_draft_confirm_handler.py b/backend/tests/test_draft_confirm_handler.py index c19555e5..6ef81872 100644 --- a/backend/tests/test_draft_confirm_handler.py +++ b/backend/tests/test_draft_confirm_handler.py @@ -2192,18 +2192,17 @@ async def test_draft_confirm_handler_target_bound_without_semantic_action_return assert result["handled"] is True assert result["status"] == "pending_manual" - assert "请回复“确认”、“修改 + 你的意见”、“重出一版”或“取消”" in result["followup_reply"] + assert "继续说明你要确认、修改、重出还是取消" in result["followup_reply"] @pytest.mark.asyncio @pytest.mark.parametrize("message_text", ["确认", "确认入库", "确认入库。"]) -async def test_draft_confirm_handler_explicit_confirm_without_semantic_action_routes_to_confirm( +async def test_draft_confirm_handler_confirm_text_without_semantic_action_returns_pending_manual( monkeypatch, message_text: str, ) -> None: kernel = type("Kernel", (), {"db": object(), "tenant_id": 1})() handler = DraftConfirmHandler(kernel) - captured: dict[str, object] = {} async def _fake_resolve_pending_draft(**kwargs): _ = kwargs @@ -2227,18 +2226,7 @@ async def test_draft_confirm_handler_explicit_confirm_without_semantic_action_ro ) async def _fake_confirm_draft(**kwargs): - captured["confirm_kwargs"] = kwargs - return { - "handled": True, - "status": "succeeded", - "followup_reply": "✅ 已确认入库", - "target_work_item_id": 42, - "target_state": STATE_SUCCEEDED, - "target_intent": "project.create", - "project_id": 123, - "signal_id": 456, - "workflow_route": "project_create.create", - } + raise AssertionError(f"confirm_draft should not run without structured followup action: {kwargs}") async def _unexpected_cancel(**kwargs): # noqa: ANN001 raise AssertionError(f"cancel_draft should not run here: {kwargs}") @@ -2264,21 +2252,18 @@ async def test_draft_confirm_handler_explicit_confirm_without_semantic_action_ro ) assert result["handled"] is True - assert result["status"] == "succeeded" - assert result["followup_reply"] == "✅ 已确认入库" - assert captured["confirm_kwargs"]["message_text"] == message_text - assert captured["confirm_kwargs"]["source_message_id"] == "msg-confirm-1" + assert result["status"] == "pending_manual" + assert "继续说明你要确认、修改、重出还是取消" in result["followup_reply"] @pytest.mark.asyncio @pytest.mark.parametrize("message_text", ["修改", "修改。", "修改 + 你的意见"]) -async def test_draft_confirm_handler_explicit_revise_without_semantic_action_routes_to_revise( +async def test_draft_confirm_handler_revise_text_without_semantic_action_returns_pending_manual( monkeypatch, message_text: str, ) -> None: kernel = type("Kernel", (), {"db": object(), "tenant_id": 1})() handler = DraftConfirmHandler(kernel) - captured: dict[str, object] = {} async def _fake_resolve_pending_draft(**kwargs): _ = kwargs @@ -2302,12 +2287,7 @@ async def test_draft_confirm_handler_explicit_revise_without_semantic_action_rou ) async def _fake_revise_draft(**kwargs): - captured["revise_kwargs"] = kwargs - return { - "handled": True, - "status": "draft_pending_confirm", - "followup_reply": "我先按当前草稿补一版。", - } + raise AssertionError(f"revise_draft should not run without structured followup action: {kwargs}") async def _unexpected_confirm(**kwargs): # noqa: ANN001 raise AssertionError(f"confirm_draft should not run here: {kwargs}") @@ -2337,21 +2317,18 @@ async def test_draft_confirm_handler_explicit_revise_without_semantic_action_rou ) assert result["handled"] is True - assert result["status"] == "draft_pending_confirm" - assert "补一版" in result["followup_reply"] - assert captured["revise_kwargs"]["revision_note"] == "" - assert captured["revise_kwargs"]["revision_note"] == "" + assert result["status"] == "pending_manual" + assert "继续说明你要确认、修改、重出还是取消" in result["followup_reply"] @pytest.mark.asyncio @pytest.mark.parametrize("message_text", ["取消", "取消。", "取消本版。"]) -async def test_draft_confirm_handler_explicit_cancel_without_semantic_action_routes_to_cancel( +async def test_draft_confirm_handler_cancel_text_without_semantic_action_returns_pending_manual( monkeypatch, message_text: str, ) -> None: kernel = type("Kernel", (), {"db": object(), "tenant_id": 1})() handler = DraftConfirmHandler(kernel) - captured: dict[str, object] = {} async def _fake_resolve_pending_draft(**kwargs): _ = kwargs @@ -2375,18 +2352,7 @@ async def test_draft_confirm_handler_explicit_cancel_without_semantic_action_rou ) async def _fake_cancel_draft(**kwargs): - captured["cancel_kwargs"] = kwargs - return { - "handled": True, - "status": "cancelled", - "followup_reply": "已取消,如需重新提交请再次发送。", - "target_work_item_id": 42, - "target_state": "cancelled", - "target_intent": "project.create", - "project_id": None, - "signal_id": None, - "workflow_route": "project_create.create", - } + raise AssertionError(f"cancel_draft should not run without structured followup action: {kwargs}") async def _unexpected_confirm(**kwargs): # noqa: ANN001 raise AssertionError(f"confirm_draft should not run here: {kwargs}") @@ -2412,16 +2378,14 @@ async def test_draft_confirm_handler_explicit_cancel_without_semantic_action_rou ) assert result["handled"] is True - assert result["status"] == "cancelled" - assert result["followup_reply"] == "已取消,如需重新提交请再次发送。" - assert captured["cancel_kwargs"]["reason"] == "用户取消草稿" + assert result["status"] == "pending_manual" + assert "继续说明你要确认、修改、重出还是取消" in result["followup_reply"] @pytest.mark.asyncio -async def test_draft_confirm_handler_explicit_regenerate_without_semantic_action_routes_to_regenerate(monkeypatch) -> None: +async def test_draft_confirm_handler_regenerate_text_without_semantic_action_returns_pending_manual(monkeypatch) -> None: kernel = type("Kernel", (), {"db": object(), "tenant_id": 1})() handler = DraftConfirmHandler(kernel) - captured: dict[str, object] = {} async def _fake_resolve_pending_draft(**kwargs): _ = kwargs @@ -2445,12 +2409,7 @@ async def test_draft_confirm_handler_explicit_regenerate_without_semantic_action ) async def _fake_regenerate_draft(**kwargs): - captured["regenerate_kwargs"] = kwargs - return { - "handled": True, - "status": "draft_pending_confirm", - "followup_reply": "我先按当前草稿重出一版。", - } + raise AssertionError(f"regenerate_draft should not run without structured followup action: {kwargs}") async def _unexpected_confirm(**kwargs): # noqa: ANN001 raise AssertionError(f"confirm_draft should not run here: {kwargs}") @@ -2480,9 +2439,8 @@ async def test_draft_confirm_handler_explicit_regenerate_without_semantic_action ) assert result["handled"] is True - assert result["status"] == "draft_pending_confirm" - assert "重出一版" in result["followup_reply"] - assert captured["regenerate_kwargs"]["message_text"] == "重出一版。" + assert result["status"] == "pending_manual" + assert "继续说明你要确认、修改、重出还是取消" in result["followup_reply"] @pytest.mark.asyncio @@ -2533,7 +2491,7 @@ async def test_draft_confirm_handler_target_bound_without_anchor_and_without_sem assert result["handled"] is True assert result["status"] == "pending_manual" - assert "请回复“确认”、“修改 + 你的意见”、“重出一版”或“取消”" in result["followup_reply"] + assert "继续说明你要确认、修改、重出还是取消" in result["followup_reply"] def test_kernel_ingress_mixin_auto_safe_int_regression() -> None: diff --git a/backend/tests/test_external_query_planner.py b/backend/tests/test_external_query_planner.py index d3358cca..4ba96737 100644 --- a/backend/tests/test_external_query_planner.py +++ b/backend/tests/test_external_query_planner.py @@ -103,6 +103,140 @@ async def test_build_plan_threads_fit_brief_guidance_into_prompt_and_plan() -> N assert "AI 管家" in plan.project_object_anchors +def test_fallback_plan_builds_concrete_signal_source_recommendation_queries() -> None: + planner = ExternalQueryPlanner( + llm_service=object(), + db=object(), + tenant_id=1, + ) + + plan = planner._build_fallback_plan( + question="测试产能警告这个项目,如果只考虑外部信号源,你会优先推荐哪 3 类?直接给推荐结果和理由。", + query_semantics={ + "query_profile": {"needs_freshness": True}, + }, + fit_brief={ + "project_summary": "当前项目:#81 测试产能警告 (status=active)", + "recommendation_focus": ["给测试产能警告推荐最值得先看的外部信号源。"], + "signal_source_objectives": [ + "设计产能监控逻辑与数据采集:编写脚本收集测试队列与资源使用数据", + "实现产能警告触发服务:实时比对数据与阈值", + ], + }, + ) + + joined_queries = " ".join(plan.search_queries[:2]) + assert plan.search_queries[0].startswith("测试产能警告 外部信号源") + assert "测试队列与资源使用数据" in joined_queries + assert "实时比对数据与阈值" in joined_queries + assert "发布变更" not in joined_queries + assert "工单" not in joined_queries + + +def test_fallback_plan_preserves_structured_interaction_level() -> None: + planner = ExternalQueryPlanner( + llm_service=object(), + db=object(), + tenant_id=1, + ) + + plan = planner._build_fallback_plan( + question="这个项目我想先补外部东西,你先帮我列几个最值得看的。", + query_semantics={ + "query_profile": { + "needs_freshness": True, + "interaction_level": "recommendation", + }, + "interaction_level": "recommendation", + }, + fit_brief={ + "project_summary": "当前项目:自动开票系统,目标是在收到开票请求后自动开票并发送发票图片。", + "recommendation_focus": ["给自动开票系统推荐最值得先看的外部信号源。"], + "signal_source_objectives": [ + "处理开票请求:收到接口请求后自动开票", + "发送发票图片:把结果发给客户", + ], + }, + ) + + assert plan.interaction_level == "recommendation" + + +def test_fallback_plan_builds_store_inventory_recommendation_queries_from_project_objectives() -> None: + planner = ExternalQueryPlanner( + llm_service=object(), + db=object(), + tenant_id=1, + ) + + plan = planner._build_fallback_plan( + question="门店库存异常日报助手如果想更早发现异常,先看哪几类外部信号最值?", + query_semantics={ + "query_profile": { + "needs_freshness": True, + "interaction_level": "recommendation", + }, + "interaction_level": "recommendation", + }, + fit_brief={ + "project_summary": "当前项目:门店库存异常日报助手,目标是每日自动汇总门店库存异常并发给区域经理。", + "recommendation_focus": ["给门店库存异常日报助手推荐最值得先看的外部信号源。"], + "signal_source_objectives": [ + "汇总门店库存异常:识别缺货、库存跳变和异常门店分布", + "推送区域经理日报:把高风险异常按区域维度发给负责人", + ], + }, + ) + + joined_queries = " ".join(plan.search_queries[:3]) + assert plan.interaction_level == "recommendation" + assert plan.search_queries[0].startswith("门店库存异常日报助手 外部信号源") + assert "缺货" in joined_queries + assert "库存跳变" in joined_queries + assert "区域经理" in joined_queries + + +@pytest.mark.asyncio +async def test_build_plan_signal_source_recommendation_keeps_concrete_seed_queries_ahead_of_generic_model_query() -> None: + class _FakeLLM: + async def generate_structured_object(self, **kwargs): + _ = kwargs + return SimpleNamespace( + payload={ + "topic": "测试产能警告外部信号源推荐", + "search_queries": ["production capacity warning system external data sources industry best practices"], + } + ) + + planner = ExternalQueryPlanner( + llm_service=_FakeLLM(), + db=object(), + tenant_id=1, + ) + + plan, meta = await planner.build_plan( + question="测试产能警告这个项目,如果只考虑外部信号源,你会优先推荐哪 3 类?直接给推荐结果和理由。", + query_semantics={ + "query_profile": {"needs_freshness": True}, + }, + fit_brief={ + "project_summary": "当前项目:#81 测试产能警告 (status=active)", + "recommendation_focus": ["给测试产能警告推荐最值得先看的外部信号源。"], + "signal_source_objectives": [ + "设计产能监控逻辑与数据采集:编写脚本收集测试队列与资源使用数据", + "实现产能警告触发服务:实时比对数据与阈值", + ], + }, + ) + + assert meta["planner"] == "llm_structured" + joined_queries = " ".join(plan.search_queries[:3]) + assert plan.search_queries[0].startswith("测试产能警告 外部信号源") + assert "测试队列与资源使用数据" in joined_queries + assert "实时比对数据与阈值" in joined_queries + assert "production capacity warning system external data sources industry best practices" in plan.search_queries + + @pytest.mark.asyncio @pytest.mark.parametrize( ("retrieval_status", "expected_variant", "expected_phrase"), @@ -204,3 +338,623 @@ async def test_presenter_distinguishes_indirect_only_without_overclaiming_direct assert "只能说明它和当前项目有背景相关性" in result["answer_text"] assert "不能直接说明它已经命中当前项目对象" in result["answer_text"] assert fake_llm.calls == 0 + + +@pytest.mark.asyncio +async def test_presenter_prefers_recommendation_answer_for_signal_source_recommendation_question() -> None: + class _FakeLLM: + def __init__(self) -> None: + self.calls = 0 + self.prompt = "" + self.structured_calls = 0 + + async def generate_structured_object(self, **kwargs): + self.structured_calls += 1 + self.prompt = str(kwargs.get("prompt") or "") + return SimpleNamespace( + payload={ + "candidates": [ + { + "name": "测试执行失败和阻塞记录", + "reason": "它最早反映测试队列是不是已经开始堆积,适合先盯住产能吃紧的信号。", + }, + { + "name": "构建或发布变更记录", + "reason": "它能帮你判断产能波动是不是由最近的版本改动带出来的。", + }, + { + "name": "工单或异常反馈", + "reason": "它能看出问题有没有开始影响到实际交付,不会只停在内部指标波动。", + }, + ], + "caveat": "这些先算建议,不代表已经接入或验证完成。", + } + ) + + async def generate(self, prompt: str, **kwargs): + _ = kwargs + self.calls += 1 + self.prompt = prompt + return ( + "如果只按当前项目任务来排优先级,我会先补齐“设计产能监控逻辑与数据采集”需要的外部输入," + "再看“实现产能警告触发服务”依赖的实时变化信号。" + ) + + fake_llm = _FakeLLM() + presenter = ExternalAnswerPresenter( + llm_service=fake_llm, + db=object(), + tenant_id=1, + ) + + result = await presenter.present( + question="测试产能警告这个项目,如果只考虑外部信号源,你会优先推荐哪 3 类?直接给推荐结果和理由。", + context_packet={ + "question": "测试产能警告这个项目,如果只考虑外部信号源,你会优先推荐哪 3 类?直接给推荐结果和理由。", + "plan": { + "answer_contract": "summary_with_sources", + "topic": "测试产能警告外部信号源推荐", + "interaction_level": "recommendation", + "fit_brief_summary": "当前项目:测试产能警告,聚焦识别产能异常和告警触发。", + "project_scope_hint": "current_project_and_repo", + "recommendation_goal": "给测试产能警告推荐最值得先看的外部信号源。", + "project_object_anchors": ["实现产能警告触发服务", "告警接收链路"], + "signal_source_objectives": [ + "设计产能监控逻辑与数据采集:编写脚本收集测试队列与资源使用数据", + "实现产能警告触发服务:实时比对数据与阈值", + ], + }, + "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": "verifier_blocked", + "impact_status": "no_direct_impact", + }, + thinking_mode="normal", + ) + + assert result["prompt_variant_id"] == "external_signal_source_recommendation" + assert "1. 测试执行失败和阻塞记录" in result["answer_text"] + assert "2. 构建或发布变更记录" in result["answer_text"] + assert "3. 工单或异常反馈" in result["answer_text"] + assert "最早反映测试队列是不是已经开始堆积" in result["answer_text"] + assert "这些先算建议,不代表已经接入或验证完成。" in result["answer_text"] + assert "主链" not in result["answer_text"] + assert "这一层" not in result["answer_text"] + assert result["recommendation_packet"]["resource_role"] == "signal_source" + assert result["recommendation_packet"]["source_kind"] == "external" + assert result["recommendation_packet"]["evidence_mode"] == "advisory" + assert result["recommendation_packet"]["candidate_set_id"] + assert result["recommendation_packet"]["candidates"][0]["candidate_ref"] + assert result["recommendation_packet"]["candidates"][0]["display_name"] == "测试执行失败和阻塞记录" + assert ( + result["recommendation_packet"]["candidates"][0]["materialization_payload"]["display_name"] + == "测试执行失败和阻塞记录" + ) + assert fake_llm.structured_calls == 1 + assert "给测试产能警告推荐最值得先看的外部信号源" in fake_llm.prompt + assert "测试队列与资源使用数据" in fake_llm.prompt + assert "实时比对数据与阈值" in fake_llm.prompt + + +@pytest.mark.asyncio +async def test_presenter_signal_source_recommendation_varies_by_project_context() -> None: + class _FakeLLM: + def __init__(self) -> None: + self.structured_calls = 0 + + async def generate_structured_object(self, **kwargs): + self.structured_calls += 1 + prompt = str(kwargs.get("prompt") or "") + if "自动开票系统" in prompt: + return SimpleNamespace( + payload={ + "candidates": [ + { + "name": "开票失败或重试回执", + "reason": "它最早告诉你票是不是卡在开票动作本身。", + }, + { + "name": "税控或发票平台状态变化", + "reason": "它能解释失败到底是业务问题还是外部平台波动。", + }, + { + "name": "发票发送失败反馈", + "reason": "它能看出问题有没有真正影响到客户收票。", + }, + ], + "caveat": "这些先算建议,不代表已经接入或验证完成。", + } + ) + return SimpleNamespace( + payload={ + "candidates": [ + { + "name": "用户投诉和差评变化", + "reason": "它最容易最先暴露客服回复质量是不是已经开始出问题。", + }, + { + "name": "社媒互动异常波动", + "reason": "它能帮你看出问题是不是已经从单点反馈扩到公开场域。", + }, + { + "name": "竞品或行业FAQ变化", + "reason": "它能提示当前话术和知识点是不是已经落后。", + }, + ], + "caveat": "这些先算建议,不代表已经接入或验证完成。", + } + ) + + async def generate(self, prompt: str, **kwargs): + _ = (prompt, kwargs) + return "如果只看外部信号源,我会先看最关键的外部输入。" + + presenter = ExternalAnswerPresenter( + llm_service=_FakeLLM(), + db=object(), + tenant_id=1, + ) + + invoice_result = await presenter.present( + question="自动开票系统这个项目,除了系统里现有的数据,你觉得还应该盯哪些外部信号?直接说几个最有用的,不要给我出方案。", + context_packet={ + "question": "自动开票系统这个项目,除了系统里现有的数据,你觉得还应该盯哪些外部信号?直接说几个最有用的,不要给我出方案。", + "plan": { + "answer_contract": "summary_with_sources", + "topic": "自动开票系统外部信号源推荐", + "interaction_level": "recommendation", + "fit_brief_summary": "当前项目:自动开票系统,目标是在收到开票请求后自动开票并发送发票图片。", + "project_scope_hint": "current_project_and_repo", + "recommendation_goal": "给自动开票系统推荐最值得先看的外部信号源。", + "project_object_anchors": ["开票请求处理", "发票发送结果"], + "signal_source_objectives": [ + "处理开票请求:收到接口请求后自动开票", + "发送发票图片:把结果发给客户", + ], + }, + "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": "verifier_blocked", + "impact_status": "no_direct_impact", + }, + thinking_mode="normal", + ) + ai_customer_result = await presenter.present( + question="AI客服这个项目,除了现在这些数据,你觉得最值得再补哪几类外部线索?直接推荐几个。", + context_packet={ + "question": "AI客服这个项目,除了现在这些数据,你觉得最值得再补哪几类外部线索?直接推荐几个。", + "plan": { + "answer_contract": "summary_with_sources", + "topic": "AI客服外部信号源推荐", + "interaction_level": "recommendation", + "fit_brief_summary": "当前项目:AI客服,核心痛点是内容本地化,当前能力围绕社媒运营和用户洞察。", + "project_scope_hint": "current_project_and_repo", + "recommendation_goal": "给 AI 客服推荐最值得先看的外部线索。", + "project_object_anchors": ["用户洞察数据源", "社媒运营数据源"], + "signal_source_objectives": [ + "用户洞察:更快发现用户反馈与投诉变化", + "社媒运营:跟踪公开互动和话题波动", + ], + }, + "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": "verifier_blocked", + "impact_status": "no_direct_impact", + }, + thinking_mode="normal", + ) + + assert "开票失败或重试回执" in invoice_result["answer_text"] + assert "发票发送失败反馈" in invoice_result["answer_text"] + assert "用户投诉和差评变化" not in invoice_result["answer_text"] + + assert "用户投诉和差评变化" in ai_customer_result["answer_text"] + assert "社媒互动异常波动" in ai_customer_result["answer_text"] + assert "开票失败或重试回执" not in ai_customer_result["answer_text"] + + +@pytest.mark.asyncio +async def test_presenter_signal_source_recommendation_varies_for_cashflow_and_knowledge_base_projects() -> None: + class _FakeLLM: + async def generate_structured_object(self, *, prompt: str, **kwargs): + _ = kwargs + if "智能现金流管家" in prompt: + return SimpleNamespace( + payload={ + "candidates": [ + { + "name": "银行回单或到账异常反馈", + "reason": "它最早告诉你资金是不是已经在到账环节卡住了。", + }, + { + "name": "税费或社保扣缴提醒变化", + "reason": "它能帮助区分现金流压力是不是已经开始影响刚性支出。", + }, + { + "name": "上游平台结算延迟通知", + "reason": "它能提前暴露回款节奏是不是被外部平台拖慢了。", + }, + ], + "caveat": "这些先算建议,不代表已经接入或验证完成。", + } + ) + return SimpleNamespace( + payload={ + "candidates": [ + { + "name": "外部知识源更新通知", + "reason": "它能让知识库问答先知道哪些权威资料已经变化。", + }, + { + "name": "第三方模型服务可用性波动", + "reason": "它最容易直接影响问答响应质量和稳定性。", + }, + { + "name": "企业 SSO 或目录服务异常公告", + "reason": "它能帮助判断问答不可用是不是卡在登录或权限链路。", + }, + ], + "caveat": "这些先算建议,不代表已经接入或验证完成。", + } + ) + + async def generate(self, prompt: str, **kwargs): + _ = (prompt, kwargs) + return "关于外部信号,我现在还不能直接推荐。" + + presenter = ExternalAnswerPresenter( + llm_service=_FakeLLM(), + db=object(), + tenant_id=1, + ) + + cashflow_result = await presenter.present( + question="智能现金流管家除了银行流水,还值得补哪些外部线索?", + context_packet={ + "question": "智能现金流管家除了银行流水,还值得补哪些外部线索?", + "plan": { + "answer_contract": "summary_with_sources", + "topic": "智能现金流管家外部信号源推荐", + "interaction_level": "recommendation", + "fit_brief_summary": "当前项目:智能现金流管家,目标是更早识别回款异常和资金压力。", + "project_scope_hint": "current_project_and_repo", + "recommendation_goal": "给智能现金流管家推荐最值得先看的外部信号源。", + "project_object_anchors": ["回款节奏监控", "资金压力预警"], + "signal_source_objectives": [ + "识别到账异常:更早发现回款延迟和结算波动", + "识别刚性支出压力:尽快发现税费或社保扣缴变化", + ], + }, + "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": "verifier_blocked", + "impact_status": "no_direct_impact", + }, + thinking_mode="normal", + ) + knowledge_base_result = await presenter.present( + question="企业知识库问答AI项目如果只补一个外部信号,你觉得先补哪个最值?", + context_packet={ + "question": "企业知识库问答AI项目如果只补一个外部信号,你觉得先补哪个最值?", + "plan": { + "answer_contract": "summary_with_sources", + "topic": "企业知识库问答AI项目外部信号源推荐", + "interaction_level": "recommendation", + "fit_brief_summary": "当前项目:企业知识库问答AI项目,目标是提升企业知识问答的准确性和可用性。", + "project_scope_hint": "current_project_and_repo", + "recommendation_goal": "给企业知识库问答AI项目推荐最值得先看的外部信号源。", + "project_object_anchors": ["知识更新同步", "问答服务稳定性"], + "signal_source_objectives": [ + "识别知识更新:及时发现外部知识源变化", + "识别服务波动:尽快定位问答质量和可用性异常", + ], + }, + "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": "verifier_blocked", + "impact_status": "no_direct_impact", + }, + thinking_mode="normal", + ) + + assert "银行回单或到账异常反馈" in cashflow_result["answer_text"] + assert "税费或社保扣缴提醒变化" in cashflow_result["answer_text"] + assert "外部知识源更新通知" not in cashflow_result["answer_text"] + + assert "外部知识源更新通知" in knowledge_base_result["answer_text"] + assert "第三方模型服务可用性波动" in knowledge_base_result["answer_text"] + assert "银行回单或到账异常反馈" not in knowledge_base_result["answer_text"] + + +@pytest.mark.asyncio +async def test_presenter_signal_source_recommendation_varies_for_medical_industry_research_project() -> None: + class _FakeLLM: + async def generate_structured_object(self, **kwargs): + _ = kwargs + return SimpleNamespace( + payload={ + "candidates": [ + { + "name": "医药政策与监管口径更新", + "reason": "它最容易先改变研究假设和布局判断。", + }, + { + "name": "重点药企投融资和并购动态", + "reason": "它能更快暴露产业链方向是不是正在变化。", + }, + { + "name": "临床试验与注册进展公告", + "reason": "它能帮助判断哪些赛道是真的在加速,哪些还停在预期里。", + }, + ], + "caveat": "这些先算建议,不代表已经接入或验证完成。", + } + ) + + async def generate(self, prompt: str, **kwargs): + _ = (prompt, kwargs) + return "我现在还不能直接推荐。" + + presenter = ExternalAnswerPresenter( + llm_service=_FakeLLM(), + db=object(), + tenant_id=1, + ) + + result = await presenter.present( + question="医药行业十五五规划布局研究现在最值得优先盯的外部变化有哪些?", + context_packet={ + "question": "医药行业十五五规划布局研究现在最值得优先盯的外部变化有哪些?", + "plan": { + "answer_contract": "summary_with_sources", + "topic": "医药行业十五五规划布局研究外部信号源推荐", + "interaction_level": "recommendation", + "fit_brief_summary": "当前项目:医药行业十五五规划布局研究,目标是研判政策、产业和重点赛道变化。", + "project_scope_hint": "current_project_and_repo", + "recommendation_goal": "给医药行业十五五规划布局研究推荐最值得优先看的外部变化。", + "project_object_anchors": ["政策口径变化", "重点赛道布局判断"], + "signal_source_objectives": [ + "识别政策变化:尽快发现监管和支持方向的变化", + "识别产业变化:尽快发现资本和临床进展带来的赛道变化", + ], + }, + "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": "verifier_blocked", + "impact_status": "no_direct_impact", + }, + thinking_mode="normal", + ) + + assert "1. 医药政策与监管口径更新" in result["answer_text"] + assert "2. 重点药企投融资和并购动态" in result["answer_text"] + assert "3. 临床试验与注册进展公告" in result["answer_text"] + + +@pytest.mark.asyncio +async def test_presenter_signal_source_recommendation_accepts_natural_business_phrasing() -> None: + class _FakeLLM: + async def generate_structured_object(self, **kwargs): + _ = kwargs + return SimpleNamespace( + payload={ + "candidates": [ + { + "name": "门店缺货和补货异常反馈", + "reason": "它最早反映异常是不是已经在门店端出现。", + }, + { + "name": "供应商到货延迟变化", + "reason": "它能帮助区分库存异常到底是销售波动还是供给变慢。", + }, + { + "name": "竞品价格或促销波动", + "reason": "它能解释销量突然变化是不是被外部活动带出来的。", + }, + ], + "caveat": "这些先算建议,不代表已经接入或验证完成。", + } + ) + + async def generate(self, prompt: str, **kwargs): + _ = (prompt, kwargs) + return "关于外部信号,我现在还不能直接推荐。" + + presenter = ExternalAnswerPresenter( + llm_service=_FakeLLM(), + db=object(), + tenant_id=1, + ) + + result = await presenter.present( + question="门店库存异常日报助手这个项目,如果想更早发现异常,你会先多看哪几类外部信号?", + context_packet={ + "question": "门店库存异常日报助手这个项目,如果想更早发现异常,你会先多看哪几类外部信号?", + "plan": { + "answer_contract": "summary_with_sources", + "topic": "门店库存异常日报助手外部信号源推荐", + "interaction_level": "recommendation", + "fit_brief_summary": "当前项目:门店库存异常日报助手,目标是更早发现缺货、滞销和补货异常。", + "project_scope_hint": "current_project_and_repo", + "recommendation_goal": "给门店库存异常日报助手推荐最值得先看的外部信号源。", + "project_object_anchors": ["门店异常日报", "缺货异常识别"], + "signal_source_objectives": [ + "识别门店异常:更早发现缺货和补货异常", + "日报推送:让运营尽快看到高风险门店", + ], + }, + "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": "verifier_blocked", + "impact_status": "no_direct_impact", + }, + thinking_mode="normal", + ) + + assert "1. 门店缺货和补货异常反馈" in result["answer_text"] + assert "2. 供应商到货延迟变化" in result["answer_text"] + assert "3. 竞品价格或促销波动" in result["answer_text"] + + +@pytest.mark.asyncio +async def test_presenter_signal_source_recommendation_uses_plan_interaction_level_not_question_cues() -> None: + class _FakeLLM: + async def generate_structured_object(self, **kwargs): + _ = kwargs + return SimpleNamespace( + payload={ + "candidates": [ + { + "name": "开票失败或重试回执", + "reason": "它最早告诉你票是不是卡在开票动作本身。", + }, + { + "name": "税控或发票平台状态变化", + "reason": "它能解释失败到底是业务问题还是外部平台波动。", + }, + { + "name": "发票发送失败反馈", + "reason": "它能看出问题有没有真正影响到客户收票。", + }, + ], + "caveat": "这些先算建议,不代表已经接入或验证完成。", + } + ) + + async def generate(self, prompt: str, **kwargs): + _ = (prompt, kwargs) + return "不该走到这里。" + + presenter = ExternalAnswerPresenter( + llm_service=_FakeLLM(), + db=object(), + tenant_id=1, + ) + + result = await presenter.present( + question="自动开票系统这个项目,我想先把票开不出去这块外部情况摸清楚。", + context_packet={ + "question": "自动开票系统这个项目,我想先把票开不出去这块外部情况摸清楚。", + "plan": { + "answer_contract": "summary_with_sources", + "topic": "自动开票系统外部信号源推荐", + "interaction_level": "recommendation", + "fit_brief_summary": "当前项目:自动开票系统,目标是在收到开票请求后自动开票并发送发票图片。", + "project_scope_hint": "current_project_and_repo", + "recommendation_goal": "给自动开票系统推荐最值得先看的外部信号源。", + "project_object_anchors": ["开票请求处理", "发票发送结果"], + "signal_source_objectives": [ + "处理开票请求:收到接口请求后自动开票", + "发送发票图片:把结果发给客户", + ], + }, + "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": "verifier_blocked", + "impact_status": "no_direct_impact", + }, + thinking_mode="normal", + ) + + assert result["prompt_variant_id"] == "external_signal_source_recommendation" + assert "1. 开票失败或重试回执" in result["answer_text"] + assert "2. 税控或发票平台状态变化" in result["answer_text"] + assert "3. 发票发送失败反馈" in result["answer_text"] + + +@pytest.mark.asyncio +async def test_presenter_signal_source_recommendation_falls_back_when_llm_overclaims_unverified_details() -> None: + class _FakeLLM: + def __init__(self) -> None: + self.structured_calls = 0 + + async def generate_structured_object(self, **kwargs): + _ = kwargs + self.structured_calls += 1 + return SimpleNamespace( + payload={ + "candidates": [ + { + "name": "工厂级设备运行日志", + "reason": "近一年案例显示其与突发性产能下滑相关性达73%。", + }, + { + "name": "测试任务调度系统记录", + "reason": "当前已确认无需额外对接验证。", + }, + ], + "caveat": "", + } + ) + + async def generate(self, prompt: str, **kwargs): + _ = (prompt, kwargs) + return ( + "如果只看外部信号源,我会先看工厂级设备运行日志、测试任务调度系统记录、产线温湿度与电力波动数据。" + ) + + fake_llm = _FakeLLM() + presenter = ExternalAnswerPresenter( + llm_service=fake_llm, + db=object(), + tenant_id=1, + ) + + result = await presenter.present( + question="测试产能警告这个项目,如果只考虑外部信号源,你会优先推荐哪 3 类?直接给推荐结果和理由。", + context_packet={ + "question": "测试产能警告这个项目,如果只考虑外部信号源,你会优先推荐哪 3 类?直接给推荐结果和理由。", + "plan": { + "answer_contract": "summary_with_sources", + "topic": "测试产能警告外部信号源推荐", + "interaction_level": "recommendation", + "fit_brief_summary": "当前项目:测试产能警告,聚焦识别产能异常和告警触发。", + "project_scope_hint": "current_project_and_repo", + "recommendation_goal": "给测试产能警告推荐最值得先看的外部信号源。", + "project_object_anchors": ["实现产能警告触发服务", "告警接收链路"], + "signal_source_objectives": [ + "设计产能监控逻辑与数据采集:编写脚本收集测试队列与资源使用数据", + "实现产能警告触发服务:实时比对数据与阈值", + ], + }, + "adapter_attempts": [{"adapter": "live_search", "status": "success"}], + "verified_evidence": [ + { + "source_type": "url", + "source_label": "FineReport", + "title": "MES 实时监控", + "target_url": "https://example.com/mes", + "snippet": "MES 可用于实时监控设备与产线状态。", + } + ], + "verifier": {"ok": True}, + "uncertainties": [], + "retrieval_status": "evidence_found", + "impact_status": "indirect_only", + }, + thinking_mode="normal", + ) + + assert result["prompt_variant_id"] == "external_signal_source_recommendation" + assert "设计产能监控逻辑与数据采集" in result["answer_text"] + assert "实现产能警告触发服务" in result["answer_text"] + assert "73%" not in result["answer_text"] + assert "无需额外对接验证" not in result["answer_text"] + assert "测试执行结果或失败告警" not in result["answer_text"] + assert fake_llm.structured_calls == 1 diff --git a/backend/tests/test_feishu_card_action_contract.py b/backend/tests/test_feishu_card_action_contract.py index 9c627b88..1d646a3a 100644 --- a/backend/tests/test_feishu_card_action_contract.py +++ b/backend/tests/test_feishu_card_action_contract.py @@ -234,6 +234,294 @@ def test_should_accept_feishu_qa_natural_reply_rejects_unplanned_project_entitie assert accepted is False +def test_build_feishu_qa_analysis_packet_reply_text_deduplicates_gap_and_next_step() -> None: + rendered = FeishuReceiveRoutingMixin._build_feishu_qa_analysis_packet_reply_text( + { + "packet_type": "external_impact_analysis", + "conclusion": "这条外部变化现在还不能直接挂到 Alpha 的项目结论上。", + "retrieval_status": "verifier_blocked", + "impact_status": "no_direct_impact", + "reason_chain": [ + "外部信号源 - WMS实时库存接口这条线索还没通过校验,当前不能直接挂到项目结论上。", + "每日固定时段生成并推送结构化摘要卡片这一步如果要消费这条变化,还缺稳定的字段映射和证据。", + ], + "blocked_reason_or_gap": "先修正证据质量,再决定是否继续外部推荐。", + "next_step": "先修正证据质量,再决定是否继续外部推荐。", + } + ) + + assert rendered.count("先修正证据质量,再决定是否继续外部推荐。") == 1 + + +def test_build_feishu_qa_analysis_packet_reply_text_for_verifier_blocked_uses_human_next_step() -> None: + rendered = FeishuReceiveRoutingMixin._build_feishu_qa_analysis_packet_reply_text( + { + "packet_type": "external_impact_analysis", + "conclusion": "这条外部变化现在还不能直接挂到 Alpha 的项目结论上。", + "retrieval_status": "verifier_blocked", + "impact_status": "no_direct_impact", + "reason_chain": [ + "现在卡在外部信号源 - WMS实时库存接口这条线索上:它指向提供各仓库每小时更新的库存快照,作为水位与缺货计算的原始数据源,但还没通过校验。", + "如果后面要让每日固定时段生成并推送结构化摘要卡片消费这条变化,还得先把稳定的字段映射和证据补齐。", + ], + "blocked_reason_or_gap": "先把外部信号源 - WMS实时库存接口这条线索补齐到可校验,再看要不要挂到每日固定时段生成并推送结构化摘要卡片这一步。", + "next_step": "先把外部信号源 - WMS实时库存接口这条线索补齐到可校验,再看要不要挂到每日固定时段生成并推送结构化摘要卡片这一步。", + } + ) + + expected = ( + "这条外部变化现在还不能直接挂到 Alpha 的项目结论上。\n\n" + "现在卡在外部信号源 - WMS实时库存接口这条线索上:它指向提供各仓库每小时更新的库存快照,作为水位与缺货计算的原始数据源,但还没通过校验。" + "如果后面要让每日固定时段生成并推送结构化摘要卡片消费这条变化,还得先把稳定的字段映射和证据补齐。\n\n" + "先把外部信号源 - WMS实时库存接口这条线索补齐到可校验,再看要不要挂到每日固定时段生成并推送结构化摘要卡片这一步。" + ) + assert rendered == expected + assert "外部推荐" not in rendered + assert "证据质量" not in rendered + + +def test_build_feishu_qa_analysis_packet_reply_text_for_project_fit_compacts_reason_chain() -> None: + rendered = FeishuReceiveRoutingMixin._build_feishu_qa_analysis_packet_reply_text( + { + "packet_type": "project_fit_analysis", + "conclusion": "放到采购经理每日实时库存上报服务里,它更像接在每日固定时段生成并推送结构化摘要卡片后面的解释层,不像WMS库存快照自动拉取与清洗这种主链能力。", + "fit_verdict": "fit_later", + "focus_objects": [ + { + "name": "每日固定时段生成并推送结构化摘要卡片", + "kind": "task", + "stance": "better_fit", + "reason": "这一步更像结果出来之后的解释和追问层。", + }, + { + "name": "WMS库存快照自动拉取与清洗", + "kind": "task", + "stance": "not_fit", + "reason": "这一步属于确定性主链,不适合直接塞进生成能力。", + }, + ], + "reason_chain": [ + "WMS库存快照自动拉取与清洗这条主链负责确定性取数和计算,结果要稳定、可复算。", + "相比之下,每日固定时段生成并推送结构化摘要卡片更适合承接结果出来之后的解释和追问。", + ], + "missing_mapping": "还缺它在每日固定时段生成并推送结构化摘要卡片这一步到底承担解释、问答还是编排角色的明确映射。", + "next_step": "先确认它在当前项目里具体承接哪个节点。", + } + ) + + expected = ( + "放到采购经理每日实时库存上报服务里,它更像接在每日固定时段生成并推送结构化摘要卡片后面的解释层,不像WMS库存快照自动拉取与清洗这种主链能力。\n\n" + "WMS库存快照自动拉取与清洗这条主链负责确定性取数和计算,结果要稳定、可复算。相比之下,每日固定时段生成并推送结构化摘要卡片更适合承接结果出来之后的解释和追问。\n\n" + "还缺它在每日固定时段生成并推送结构化摘要卡片这一步到底承担解释、问答还是编排角色的明确映射。先确认它在当前项目里具体承接哪个节点。" + ) + assert rendered == expected + + +def test_build_feishu_qa_analysis_packet_reply_text_for_direct_match_leads_with_business_task() -> None: + rendered = FeishuReceiveRoutingMixin._build_feishu_qa_analysis_packet_reply_text( + { + "packet_type": "external_impact_analysis", + "conclusion": "这条外部变化已经直接打到采购经理每日实时库存上报服务里的WMS库存快照自动拉取与清洗这一步。", + "retrieval_status": "evidence_found", + "impact_status": "direct_match", + "reason_chain": [ + "WMS库存快照自动拉取与清洗负责从WMS系统每小时拉取库存快照,按可用/在途/冻结三态聚合,清洗异常值后写入中台临时表,会最先接住这条变化,可以当成后续收口和计划讨论的正式输入。", + "对应的正式锚点是外部信号源 - WMS实时库存接口,它指向提供各仓库每小时更新的库存快照,作为水位与缺货计算的原始数据源,已经被外部证据直接打到,不再只是背景相关。", + ], + "focus_objects": [ + { + "name": "WMS库存快照自动拉取与清洗", + "kind": "task", + "stance": "dependent", + "reason": "这是当前最直接受影响的项目对象。", + }, + { + "name": "外部信号源 - WMS实时库存接口", + "kind": "signal", + "stance": "anchor", + "reason": "它已经和当前项目的正式对象建立了直接映射。", + }, + ], + "blocked_reason_or_gap": "直接看它会影响哪条下游任务,再决定要不要进 plan。", + "next_step": "直接看它会影响哪条下游任务,再决定要不要进 plan。", + } + ) + + expected = ( + "这条外部变化已经直接打到采购经理每日实时库存上报服务里的WMS库存快照自动拉取与清洗这一步。\n\n" + "WMS库存快照自动拉取与清洗负责从WMS系统每小时拉取库存快照,按可用/在途/冻结三态聚合,清洗异常值后写入中台临时表,会最先接住这条变化,可以当成后续收口和计划讨论的正式输入。对应的正式锚点是外部信号源 - WMS实时库存接口,它指向提供各仓库每小时更新的库存快照,作为水位与缺货计算的原始数据源,已经被外部证据直接打到,不再只是背景相关。\n\n" + "直接看它会影响哪条下游任务,再决定要不要进 plan。" + ) + assert rendered == expected + + +def test_build_feishu_qa_analysis_packet_reply_text_for_signal_only_project_fit_uses_real_signal_names() -> None: + rendered = FeishuReceiveRoutingMixin._build_feishu_qa_analysis_packet_reply_text( + { + "packet_type": "project_fit_analysis", + "conclusion": "放到直播复盘辅助工具(速记版)里,它更像落在输出规范 - 三栏速记清单格式这一层,不像输入适配 - 双源时间对齐能力这种前置约束或主链对象。", + "fit_verdict": "fit_later", + "focus_objects": [ + { + "name": "输出规范 - 三栏速记清单格式", + "kind": "signal", + "stance": "better_fit", + "reason": "这个对象更像结果出来之后的解释和追问层。", + }, + { + "name": "输入适配 - 双源时间对齐能力", + "kind": "signal", + "stance": "not_fit", + "reason": "这个对象更像当前要先跑通的确定性主链或前置约束。", + }, + ], + "reason_chain": [ + "输入适配 - 双源时间对齐能力当前代表的是支持手动校准录屏与评论的时间偏移,并在此基础上完成全链路切片分析,更像要先校验、对齐或跑通的核心约束。", + "输出规范 - 三栏速记清单格式当前代表的是输出纯文本文件,含高光、掉点、执行偏差三栏,更接近最终输出、用户感知或后续解释层。", + ], + "missing_mapping": "还缺它和输出规范 - 三栏速记清单格式这一层之间的明确映射。", + "next_step": "先把输出规范 - 三栏速记清单格式和输入适配 - 双源时间对齐能力的边界卡清楚,再决定要不要继续推进。", + } + ) + + assert "结果出来之后的解释层后面的解释层" not in rendered + assert "当前主链这种主链能力" not in rendered + assert "输出规范 - 三栏速记清单格式" in rendered + assert "输入适配 - 双源时间对齐能力" in rendered + + +def test_build_feishu_qa_analysis_packet_reply_text_for_sparse_project_fit_uses_plain_language() -> None: + rendered = FeishuReceiveRoutingMixin._build_feishu_qa_analysis_packet_reply_text( + { + "packet_type": "project_fit_analysis", + "conclusion": "光看自动开票系统现在这些项目信息,我还不想直接说它该放哪一步。", + "fit_verdict": "fit_later", + "current_subject": "自动开票系统", + "focus_objects": [], + "reason_chain": [ + "现在项目里还没有足够具体的任务或信号源,硬把它挂到某个位置上,结论很容易说偏。" + ], + "missing_mapping": "至少还缺一个能对上的业务步骤或信号源。", + "next_step": "先把这个项目最关键的业务步骤补清楚,我再判断它更适合放哪里。", + } + ) + + expected = ( + "自动开票系统这个项目,我现在还不想硬说它该放在哪一步。\n\n" + "现在关于自动开票系统,能看到的信息还比较粗,项目里还没有能对上的具体业务步骤或外部信号。" + "要是现在硬把它挂到某个位置上,结论很容易说偏。\n\n" + "先把这个项目最关键的业务步骤补清楚,我再判断它更适合放哪里。" + ) + assert rendered == expected + + +def test_build_feishu_qa_analysis_packet_reply_text_for_sparse_project_fit_with_description_stays_business_like() -> None: + rendered = FeishuReceiveRoutingMixin._build_feishu_qa_analysis_packet_reply_text( + { + "packet_type": "project_fit_analysis", + "conclusion": "光看自动开票系统现在这些项目信息,我还不想直接说它该放哪一步。", + "fit_verdict": "fit_later", + "current_subject": "自动开票系统", + "focus_objects": [], + "reason_chain": [ + "我现在只看得到这个项目的大致目标:开发一套自动开票系统,实现开票接口到达后自动开票并发送发票图片的功能。", + "但还看不到这条链路里谁来触发、要接哪些外部输入、以及哪个结果算真的跑通。", + ], + "missing_mapping": "还缺能对上的实际流程、关键结果和外部输入。", + "next_step": "至少先把实际流程拆出来:谁发起、系统接什么输入、最后产出什么结果。", + } + ) + + expected = ( + "自动开票系统这个项目,我现在还不想硬说它该放在哪一步。\n\n" + "我现在只看得到这个项目的大致目标:开发一套自动开票系统,实现开票接口到达后自动开票并发送发票图片的功能。" + "但还看不到这条链路里谁来触发、要接哪些外部输入、以及哪个结果算真的跑通。\n\n" + "至少先把实际流程拆出来:谁发起、系统接什么输入、最后产出什么结果。" + ) + assert rendered == expected + + +def test_build_feishu_qa_analysis_packet_reply_text_for_verifier_blocked_signal_only_project_avoids_generic_current_action() -> None: + rendered = FeishuReceiveRoutingMixin._build_feishu_qa_analysis_packet_reply_text( + { + "packet_type": "external_impact_analysis", + "conclusion": "这条外部变化现在还不能直接挂到直播复盘辅助工具(速记版)的项目结论上。", + "retrieval_status": "verifier_blocked", + "impact_status": "no_direct_impact", + "reason_chain": [ + "现在卡在外部信号源 - TikTok Shop 直播开放API(评论流这条线索上:它指向获取带毫秒级时间戳的原始评论序列,用于与录屏帧对齐,但还没通过校验。", + "如果后面要让输出规范 - 三栏速记清单格式消费这条变化,还得先把稳定的字段映射和证据补齐。", + ], + "blocked_reason_or_gap": "先把外部信号源 - TikTok Shop 直播开放API(评论流这条线索补齐到可校验,再看要不要挂到输出规范 - 三栏速记清单格式这一步。", + "next_step": "先把外部信号源 - TikTok Shop 直播开放API(评论流这条线索补齐到可校验,再看要不要挂到输出规范 - 三栏速记清单格式这一步。", + } + ) + + assert "当前项目动作" not in rendered + assert "输出规范 - 三栏速记清单格式" in rendered + + +def test_build_feishu_qa_analysis_packet_reply_text_for_internal_explain_avoids_contract_jargon() -> None: + rendered = FeishuReceiveRoutingMixin._build_feishu_qa_analysis_packet_reply_text( + { + "packet_type": "internal_explain_analysis", + "conclusion": "当前活动引擎是 Qwen 低阶引擎。", + "answer_contract": "status_summary", + "current_subject": "模型路由配置", + "resolved_target": "模型路由配置", + "reason_chain": [ + "你这轮问的是模型路由配置的当前状态,所以这里只回答现状,不往规划建议上展开。", + ], + } + ) + + expected = ( + "当前活动引擎是 Qwen 低阶引擎。\n\n" + "你这轮问的是模型路由配置的当前状态,所以这里只回答现状,不往规划建议上展开。" + ) + assert rendered == expected + assert "status_summary" not in rendered + + +def test_build_feishu_qa_analysis_packet_reply_text_for_internal_explain_uncertainty_deduplicates_gap() -> None: + rendered = FeishuReceiveRoutingMixin._build_feishu_qa_analysis_packet_reply_text( + { + "packet_type": "internal_explain_analysis", + "conclusion": "当前活动引擎是 Qwen 低阶引擎。", + "answer_contract": "status_summary", + "current_subject": "模型路由配置", + "resolved_target": "模型路由配置", + "reason_chain": [ + "你这轮问的是模型路由配置的当前状态,所以这里只回答现状,不往规划建议上展开。", + ], + "evidence_gap_or_uncertainty": "尚未确认是否已经切到最新灰度配置。", + } + ) + + expected = ( + "当前活动引擎是 Qwen 低阶引擎。\n\n" + "你这轮问的是模型路由配置的当前状态,所以这里只回答现状,不往规划建议上展开。\n\n" + "尚未确认是否已经切到最新灰度配置。" + ) + assert rendered == expected + assert rendered.count("尚未确认是否已经切到最新灰度配置") == 1 + + +def test_should_accept_feishu_qa_natural_reply_rejects_internal_packet_jargon() -> None: + accepted = FeishuReceiveRoutingMixin._should_accept_feishu_qa_natural_reply( + ( + "自动开票系统这个项目,现在还不能确定它该放在哪个阶段。\n\n" + "分析包里明确写了,当前项目里至少还缺一个能对上的业务步骤或信号源。" + "光看系统里现有的数据,没有足够具体的任务或外部信号支撑判断。\n\n" + "analysis_packet 没提供 next_step,missing_mapping 也还缺字段。" + ), + selected_entity_candidates=["自动开票系统"], + blocked_entity_candidates=["app"], + ) + + 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={ @@ -285,7 +573,7 @@ def test_normalize_feishu_qa_analysis_plan_keeps_only_allowed_focus_entities() - assert plan["missing_mapping"] == "当前没有证据说明它已接入现有信号源或主计算链。" -def test_build_feishu_qa_analysis_fallback_plan_prefers_presentation_and_core_chain_entities_for_indirect_only() -> None: +def test_build_feishu_qa_analysis_fallback_plan_uses_deterministic_catalog_order_for_indirect_only_without_structured_focus() -> None: plan = FeishuReceiveRoutingMixin._build_feishu_qa_analysis_fallback_plan( result={ "rag_result": { @@ -306,10 +594,223 @@ def test_build_feishu_qa_analysis_fallback_plan_prefers_presentation_and_core_ch assert plan["conclusion"] == "目前还不能直接说它已经适合当前项目。" assert [item["name"] for item in plan["focus_entities"]] == [ + "WMS库存快照自动拉取与清洗", + "TOP10商品缺货排序逻辑实现(基于当前库存与安全库存差值)", + ] + assert [item["stance"] for item in plan["focus_entities"]] == ["better_fit", "not_fit"] + + +@pytest.mark.asyncio +async def test_render_feishu_qa_direct_reply_text_uses_shared_analysis_packet_family() -> None: + captured: dict[str, object] = {} + + class FakeRouting(FeishuReceiveRoutingMixin): + async def _build_feishu_qa_packet_context(self, *, result, message_content): # noqa: ANN001 + _ = (result, message_content) + return { + "analysis_packet": { + "packet_version": "v1", + "packet_type": "project_fit_analysis", + "conclusion": "放到采购经理每日实时库存上报服务里,Responses API 更像接在摘要卡片后面的解释层,不像主计算链。", + "focus_objects": [ + { + "name": "每日固定时段生成并推送结构化摘要卡片", + "kind": "task", + "stance": "better_fit", + "reason": "这里更像结果出来之后的解释和追问层。", + }, + { + "name": "WMS库存快照自动拉取与清洗", + "kind": "task", + "stance": "not_fit", + "reason": "这是稳定取数的主链,不适合直接塞进生成能力。", + }, + ], + "fit_verdict": "fit_later", + "current_subject": "采购经理每日实时库存上报服务", + "candidate_position": "每日固定时段生成并推送结构化摘要卡片", + "rejected_position": "WMS库存快照自动拉取与清洗", + "preferred_layer": "每日固定时段生成并推送结构化摘要卡片", + "rejected_layer": "WMS库存快照自动拉取与清洗", + "reason_chain": [ + "WMS库存快照自动拉取与清洗这一步属于确定性取数或计算主链,结果要稳定、可复算。", + "每日固定时段生成并推送结构化摘要卡片更接近结果出来之后的解释和追问层,更适合承接这类能力。", + ], + "missing_mapping": "还缺它在每日固定时段生成并推送结构化摘要卡片这一步到底承担解释、问答还是编排角色的明确映射。", + "next_step": "先确认它是不是只用在摘要卡片之后。", + }, + "selected_entity_candidates": [ + "每日固定时段生成并推送结构化摘要卡片", + "WMS库存快照自动拉取与清洗", + ], + "blocked_entity_candidates": [ + "TOP10商品缺货排序逻辑实现(基于当前库存与安全库存差值)", + ], + } + + async def _generate_feishu_qa_natural_reply( + self, + *, + message_content, + analysis_packet, + selected_entity_candidates, + blocked_entity_candidates, + ): # noqa: ANN001 + captured["message_content"] = message_content + captured["analysis_packet"] = analysis_packet + captured["selected_entity_candidates"] = selected_entity_candidates + captured["blocked_entity_candidates"] = blocked_entity_candidates + return "" + + routing = FakeRouting() + reply = await routing._render_feishu_qa_direct_reply_text( + result={ + "direct_reply_text": "外部实时查询:目前证据不足。", + "rag_result": { + "fit_brief": { + "project_summary": "Alpha 是一个 AI 项目管理工作台。", + }, + "external_recommendation": { + "retrieval_status": "evidence_found", + "impact_status": "indirect_only", + }, + }, + }, + message_content="这个适合吗", + ) + + assert captured["message_content"] == "这个适合吗" + assert captured["analysis_packet"]["packet_type"] == "project_fit_analysis" + assert captured["selected_entity_candidates"] == [ "每日固定时段生成并推送结构化摘要卡片", "WMS库存快照自动拉取与清洗", ] - assert [item["stance"] for item in plan["focus_entities"]] == ["better_fit", "not_fit"] + expected = ( + "放到采购经理每日实时库存上报服务里,Responses API 更像接在摘要卡片后面的解释层,不像主计算链。\n\n" + "WMS库存快照自动拉取与清洗这一步属于确定性取数或计算主链,结果要稳定、可复算。每日固定时段生成并推送结构化摘要卡片更接近结果出来之后的解释和追问层,更适合承接这类能力。\n\n" + "还缺它在每日固定时段生成并推送结构化摘要卡片这一步到底承担解释、问答还是编排角色的明确映射。先确认它是不是只用在摘要卡片之后。" + ) + assert reply == expected + + +@pytest.mark.asyncio +async def test_render_feishu_qa_direct_reply_text_internal_signal_source_state_strips_raw_internal_terms() -> None: + class FakeRouting(FeishuReceiveRoutingMixin): + async def _build_feishu_qa_packet_context(self, *, result, message_content): # noqa: ANN001 + _ = (result, message_content) + return { + "analysis_packet": { + "packet_version": "v1", + "packet_type": "internal_explain_analysis", + "conclusion": "当前还没看到已经记录、但还没接入的新信号源候选。", + "focus_objects": [ + { + "name": "测试产能警告", + "kind": "project", + "stance": "relevant", + "reason": "这轮问题就在问这个项目当前有没有新信号源候选。", + } + ], + "answer_contract": "status_summary", + "current_subject": "测试产能警告", + "reason_chain": [ + "这轮只回答当前项目里已经记录到的现状,不往方案设计上展开。" + ], + "resolved_target": "测试产能警告", + }, + "selected_entity_candidates": ["测试产能警告"], + "blocked_entity_candidates": [], + } + + async def _generate_feishu_qa_natural_reply( + self, + *, + message_content, + analysis_packet, + selected_entity_candidates, + blocked_entity_candidates, + ): # noqa: ANN001 + _ = (message_content, analysis_packet, selected_entity_candidates, blocked_entity_candidates) + return "" + + routing = FakeRouting() + reply = await routing._render_feishu_qa_direct_reply_text( + result={ + "direct_reply_text": ( + "内部知识库查询:没有。项目“测试产能警告”(ID: 81)在内部知识库中未提及任何信号源。\n\n" + "当前盘面:signal_total=0,pending_signal_count=0。\n" + "建议动作:继续补充项目线索。" + ), + "rag_result": { + "route_decision": { + "route_mode": "internal", + "effective_source_policy": "internal", + }, + "analysis_packet": { + "packet_version": "v1", + "packet_type": "internal_explain_analysis", + "conclusion": "当前还没看到已经记录、但还没接入的新信号源候选。", + }, + }, + }, + message_content="测试产能警告这个项目当前有没有已经提到、但还没接入的新信号源?按现状回答。", + ) + + assert reply == ( + "当前还没看到已经记录、但还没接入的新信号源候选。\n\n" + "这轮只回答当前项目里已经记录到的现状,不往方案设计上展开。\n\n" + "来源:项目库" + ) + assert "Project database" not in reply + assert "signal_total" not in reply + assert "当前盘面" not in reply + assert "建议动作" not in reply + + +@pytest.mark.asyncio +async def test_render_feishu_qa_direct_reply_text_prefers_recommendation_answer_for_external_signal_recommendation() -> None: + class FakeRouting(FeishuReceiveRoutingMixin): + async def _build_feishu_qa_packet_context(self, *, result, message_content): # noqa: ANN001 + raise AssertionError("signal source recommendation should use direct recommendation answer before packet fallback") + + routing = FakeRouting() + reply = await routing._render_feishu_qa_direct_reply_text( + result={ + "direct_reply_text": ( + "外部实时查询:如果只是先给建议,我会先看这 3 类外部信号:\n" + "1. 测试执行失败和阻塞记录,因为它最早反映测试队列是不是已经开始堆积。\n" + "2. 构建或发布变更记录,因为它能帮你判断产能波动是不是由最近的版本改动带出来的。\n" + "3. 工单或异常反馈,因为它能看出问题有没有开始影响到实际交付。\n" + "这些先算建议,不代表已经接入或验证完成。" + ), + "rag_result": { + "route_decision": { + "route_mode": "external", + "effective_source_policy": "external", + }, + "external_query_plan": { + "interaction_level": "recommendation", + }, + "external_recommendation": { + "retrieval_status": "verifier_blocked", + "impact_status": "no_direct_impact", + }, + }, + }, + message_content="测试产能警告这个项目,如果只考虑外部信号源,你会优先推荐哪 3 类?直接给推荐结果和理由,不要复述当前现状,不要起草需求。", + ) + + assert reply == ( + "如果只是先给建议,我会先看这 3 类外部信号:\n" + "1. 测试执行失败和阻塞记录,因为它最早反映测试队列是不是已经开始堆积。\n" + "2. 构建或发布变更记录,因为它能帮你判断产能波动是不是由最近的版本改动带出来的。\n" + "3. 工单或异常反馈,因为它能看出问题有没有开始影响到实际交付。\n" + "这些先算建议,不代表已经接入或验证完成。\n\n" + "来源:项目库 + 外部公开资料(当前仍在校验)" + ) + assert "External Freshness Guard" not in reply + assert "这一层" not in reply + assert "主链" not in reply def test_build_feishu_qa_analysis_plan_reply_text_avoids_stepwise_checklist_tone() -> None: @@ -335,14 +836,46 @@ def test_build_feishu_qa_analysis_plan_reply_text_avoids_stepwise_checklist_tone expected = ( "目前还不能直接说它已经适合当前项目。\n\n" - "如果放在这个项目里,它更像接在每日固定时段生成并推送结构化摘要卡片后面的解释和追问层," - "不像 WMS库存快照自动拉取与清洗 这种要稳定取数的主链。\n\n" + "如果真要放进这个项目,我会先放在每日固定时段生成并推送结构化摘要卡片这一步," + "不太会直接改WMS库存快照自动拉取与清洗这条主流程。\n\n" "还缺它在当前项目里到底承接哪个节点的明确映射。\n\n" "先确认它是不是只用在摘要卡片之后。" ) assert reply == expected +def test_build_feishu_qa_analysis_plan_reply_text_avoids_layer_jargon_for_fit() -> None: + reply = FeishuReceiveRoutingMixin._build_feishu_qa_analysis_plan_reply_text( + { + "conclusion": "现在还不能直接说它已经适合当前项目。", + "focus_entities": [ + { + "name": "需求澄清与范围确认", + "stance": "better_fit", + "why": "这一步更适合先接住解释、补说明或继续追问。", + }, + { + "name": "核心方案落地", + "stance": "not_fit", + "why": "这一步要先稳定跑通,不适合先拿来试这种能力。", + }, + ], + "missing_mapping": "现在还说不准它会先帮到需求澄清与范围确认,还是会直接改到核心方案落地。", + "next_step": "先定清楚它是给需求澄清与范围确认补说明,还是会直接改到核心方案落地。", + } + ) + + assert "这一层" not in reply + assert "主链能力" not in reply + assert "边界卡清楚" not in reply + assert reply == ( + "现在还不能直接说它已经适合当前项目。\n\n" + "如果真要放进这个项目,我会先放在需求澄清与范围确认这一步,不太会直接改核心方案落地这条主流程。\n\n" + "现在还说不准它会先帮到需求澄清与范围确认,还是会直接改到核心方案落地。\n\n" + "先定清楚它是给需求澄清与范围确认补说明,还是会直接改到核心方案落地。" + ) + + 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={ diff --git a/backend/tests/test_feishu_pipeline_queue_contract.py b/backend/tests/test_feishu_pipeline_queue_contract.py index 65285d5e..91cb3849 100644 --- a/backend/tests/test_feishu_pipeline_queue_contract.py +++ b/backend/tests/test_feishu_pipeline_queue_contract.py @@ -5,6 +5,7 @@ from types import SimpleNamespace import pytest from app.services.ai_pmo.draft_confirm_handler import DraftConfirmHandler +from app.services.ai_pmo.ai_pmo_typed_executor_execute import mixin_execute_query_project from app.services.integrations.feishu_signal_pipeline.mixin_gate_project import ( FeishuPipelineGateProjectMixin, ) @@ -47,6 +48,7 @@ from app.services.integrations.feishu_signal_pipeline.mixin_queue_confirm_undo i ) from app.services.integrations.feishu_signal_pipeline.service import FeishuSignalPipelineService from app.services.integrations.feishu_signal_pipeline import mixin_process_message_core +from app.services.integrations.feishu_signal_pipeline import mixin_process_message_default from app.services.signal.signal_capture_service import mixin_capture_entry from app.services.signal.signal_capture_service.service import ProactiveSignalCaptureService from app.services.signal.signal_capture_service.mixin_task_orchestration_main import ( @@ -222,6 +224,20 @@ def test_process_message_no_longer_calls_legacy_queue_followup() -> None: assert "_maybe_handle_queue_followup" not in source +def test_process_message_no_longer_calls_legacy_project_lookup_shortcut() -> None: + source = inspect.getsource(mixin_process_message_core.FeishuPipelineProcessMessageCoreMixin.process_message) + + assert "_maybe_answer_project_lookup_question" not in source + + +def test_process_message_default_no_longer_calls_heuristic_level_fallback() -> None: + source = inspect.getsource( + mixin_process_message_default.FeishuPipelineProcessMessageDefaultMixin._process_message_default_capture + ) + + assert "_heuristic_level_fallback" not in source + + def test_feishu_signal_pipeline_service_runtime_excludes_legacy_followup_mixins() -> None: source = inspect.getsource(FeishuSignalPipelineService) @@ -252,6 +268,16 @@ def test_decorate_capture_result_adds_queue_action_card_metadata() -> None: assert result["queue_item_id"] == 301 +def test_async_processing_ack_result_uses_queue_receipt_instead_of_legacy_processing_ack() -> None: + result = mixin_capture_entry.SignalCaptureEntryMixin._build_async_processing_ack_result( + trace_id="trace-async-ack", + ) + + assert result["reply_template_key"] == "async_processing_ack" + assert "已收到,正在处理。" not in str(result.get("direct_reply_text") or "") + assert "待确认队列" in str(result.get("direct_reply_text") or "") + + def test_normalize_direct_reply_text_strips_internal_debug_lines() -> None: raw = """ 结论:这类问题应该先按外部事实问答处理。 @@ -410,6 +436,27 @@ async def test_feishu_question_like_messages_use_sync_high_accuracy_path(monkeyp self.workflows = [SimpleNamespace(workflow_type="query_flow", workflow_action="answer")] self.diagnostics = {} + def to_dict(self) -> dict[str, object]: + return { + "primary_workflow": "query_flow", + "workflows": [{"workflow_type": "query_flow", "workflow_action": "answer"}], + "diagnostics": {}, + } + + def to_dict(self) -> dict[str, object]: + return { + "primary_workflow": "query_flow", + "workflows": [{"workflow_type": "query_flow", "workflow_action": "answer"}], + "diagnostics": {}, + } + + def to_dict(self) -> dict[str, object]: + return { + "primary_workflow": "query_flow", + "workflows": [{"workflow_type": "query_flow", "workflow_action": "answer"}], + "diagnostics": {}, + } + def to_dict(self) -> dict[str, object]: return { "bundle_id": "semantic-query", @@ -512,6 +559,13 @@ async def test_feishu_question_like_messages_require_explicit_group_trigger(monk self.workflows = [SimpleNamespace(workflow_type="query_flow", workflow_action="answer")] self.diagnostics = {} + def to_dict(self) -> dict[str, object]: + return { + "primary_workflow": "query_flow", + "workflows": [{"workflow_type": "query_flow", "workflow_action": "answer"}], + "diagnostics": {}, + } + class _FakeKernel: @staticmethod def _build_source_message_id(*, source_system: str, source_message_id, channel_ref: str, sender: str, message_text: str): @@ -1003,6 +1057,17 @@ async def test_capture_from_feishu_skips_when_source_activation_whitelist_misses def __init__(self, db): _ = db + async def canonicalize_thread_context( + self, + *, + tenant_id: int, + source_system: str, + channel_ref: str | None, + thread_context: dict | None, + ): + _ = (tenant_id, source_system, channel_ref) + return thread_context, True + async def resolve_source_activation_scope( self, *, @@ -1165,6 +1230,17 @@ async def test_capture_from_feishu_requirement_plan_skips_async_ack_and_forwards def __init__(self, db): _ = db + async def canonicalize_thread_context( + self, + *, + tenant_id: int, + source_system: str, + channel_ref: str | None, + thread_context: dict | None, + ): + _ = (tenant_id, source_system, channel_ref) + return thread_context, True + async def resolve_source_activation_scope( self, *, @@ -1266,6 +1342,17 @@ async def test_capture_from_feishu_requirement_plan_slot_answer_stays_sync_witho def __init__(self, db): _ = db + async def canonicalize_thread_context( + self, + *, + tenant_id: int, + source_system: str, + channel_ref: str | None, + thread_context: dict | None, + ): + _ = (tenant_id, source_system, channel_ref) + return thread_context, True + async def resolve_source_activation_scope( self, *, @@ -1312,14 +1399,14 @@ async def test_capture_from_feishu_allows_group_choice_protocol_without_repeat_m captured: dict[str, object] = {} class _FakeBundle: - primary_workflow = "query_flow" - workflows = [SimpleNamespace(workflow_type="query_flow", workflow_action="answer")] + primary_workflow = "followup_flow" + workflows = [SimpleNamespace(workflow_type="followup_flow", workflow_action="confirm")] diagnostics = {} def to_dict(self) -> dict[str, object]: return { - "primary_workflow": "query_flow", - "workflows": [{"workflow_type": "query_flow", "workflow_action": "answer"}], + "primary_workflow": "followup_flow", + "workflows": [{"workflow_type": "followup_flow", "workflow_action": "confirm"}], "diagnostics": {}, } @@ -1366,6 +1453,17 @@ async def test_capture_from_feishu_allows_group_choice_protocol_without_repeat_m def __init__(self, db): _ = db + async def canonicalize_thread_context( + self, + *, + tenant_id: int, + source_system: str, + channel_ref: str | None, + thread_context: dict | None, + ): + _ = (tenant_id, source_system, channel_ref) + return thread_context, True + async def resolve_source_activation_scope( self, *, @@ -1416,14 +1514,14 @@ async def test_capture_from_feishu_allows_group_confirm_without_repeat_mention_w captured: dict[str, object] = {} class _FakeBundle: - primary_workflow = "query_flow" - workflows = [SimpleNamespace(workflow_type="query_flow", workflow_action="answer")] + primary_workflow = "followup_flow" + workflows = [SimpleNamespace(workflow_type="followup_flow", workflow_action="confirm")] diagnostics = {} def to_dict(self) -> dict[str, object]: return { - "primary_workflow": "query_flow", - "workflows": [{"workflow_type": "query_flow", "workflow_action": "answer"}], + "primary_workflow": "followup_flow", + "workflows": [{"workflow_type": "followup_flow", "workflow_action": "confirm"}], "diagnostics": {}, } @@ -1470,6 +1568,17 @@ async def test_capture_from_feishu_allows_group_confirm_without_repeat_mention_w def __init__(self, db): _ = db + async def canonicalize_thread_context( + self, + *, + tenant_id: int, + source_system: str, + channel_ref: str | None, + thread_context: dict | None, + ): + _ = (tenant_id, source_system, channel_ref) + return thread_context, True + async def resolve_source_activation_scope( self, *, @@ -1594,14 +1703,14 @@ async def test_capture_from_feishu_group_choice_protocol_prefers_active_requirem captured: dict[str, object] = {} class _FakeBundle: - primary_workflow = "query_flow" - workflows = [SimpleNamespace(workflow_type="query_flow", workflow_action="answer")] + primary_workflow = "followup_flow" + workflows = [SimpleNamespace(workflow_type="followup_flow", workflow_action="confirm")] diagnostics = {} def to_dict(self) -> dict[str, object]: return { - "primary_workflow": "query_flow", - "workflows": [{"workflow_type": "query_flow", "workflow_action": "answer"}], + "primary_workflow": "followup_flow", + "workflows": [{"workflow_type": "followup_flow", "workflow_action": "confirm"}], "diagnostics": {}, } @@ -1657,6 +1766,17 @@ async def test_capture_from_feishu_group_choice_protocol_prefers_active_requirem def __init__(self, db): _ = db + async def canonicalize_thread_context( + self, + *, + tenant_id: int, + source_system: str, + channel_ref: str | None, + thread_context: dict | None, + ): + _ = (tenant_id, source_system, channel_ref) + return thread_context, True + async def resolve_source_activation_scope( self, *, @@ -1671,9 +1791,18 @@ async def test_capture_from_feishu_group_choice_protocol_prefers_active_requirem async def _noop_sync_recent_cache(self, **kwargs): _ = (self, kwargs) + def _bomb_requirement_choice_parser(message_text: str): + _ = message_text + raise AssertionError("legacy requirement choice lexical parser should not be used") + monkeypatch.setattr(mixin_capture_entry, "AIPMOKernelService", _FakeKernel) monkeypatch.setattr(mixin_capture_entry, "MountedAIRuntimeWriteService", _FakeMountedRuntimeWriteService) monkeypatch.setattr(ProactiveSignalCaptureService, "_sync_feishu_recent_cache", _noop_sync_recent_cache) + monkeypatch.setattr( + mixin_execute_query_project.ExecuteQueryProjectMixin, + "_parse_requirement_plan_choice_protocol", + classmethod(lambda cls, message_text: _bomb_requirement_choice_parser(message_text)), + ) monkeypatch.setattr( mixin_capture_entry.WorkflowSemanticPlanner, "aplan_message", @@ -1706,14 +1835,14 @@ async def test_capture_from_feishu_group_choice_protocol_uses_executor_requireme captured: dict[str, object] = {} class _FakeBundle: - primary_workflow = "query_flow" - workflows = [SimpleNamespace(workflow_type="query_flow", workflow_action="answer")] + primary_workflow = "followup_flow" + workflows = [SimpleNamespace(workflow_type="followup_flow", workflow_action="confirm")] diagnostics = {} def to_dict(self) -> dict[str, object]: return { - "primary_workflow": "query_flow", - "workflows": [{"workflow_type": "query_flow", "workflow_action": "answer"}], + "primary_workflow": "followup_flow", + "workflows": [{"workflow_type": "followup_flow", "workflow_action": "confirm"}], "diagnostics": {}, } @@ -1771,6 +1900,17 @@ async def test_capture_from_feishu_group_choice_protocol_uses_executor_requireme def __init__(self, db): _ = db + async def canonicalize_thread_context( + self, + *, + tenant_id: int, + source_system: str, + channel_ref: str | None, + thread_context: dict | None, + ): + _ = (tenant_id, source_system, channel_ref) + return thread_context, True + async def resolve_source_activation_scope( self, *, @@ -1886,6 +2026,17 @@ async def test_capture_from_feishu_explicit_reply_choice_protocol_recovers_activ def __init__(self, db): _ = db + async def canonicalize_thread_context( + self, + *, + tenant_id: int, + source_system: str, + channel_ref: str | None, + thread_context: dict | None, + ): + _ = (tenant_id, source_system, channel_ref) + return thread_context, True + async def resolve_source_activation_scope( self, *, @@ -2002,6 +2153,17 @@ async def test_capture_from_feishu_explicit_reply_choice_protocol_canonicalizes_ def __init__(self, db): _ = db + async def canonicalize_thread_context( + self, + *, + tenant_id: int, + source_system: str, + channel_ref: str | None, + thread_context: dict | None, + ): + _ = (tenant_id, source_system, channel_ref) + return thread_context, True + async def resolve_source_activation_scope( self, *, @@ -2150,6 +2312,17 @@ async def test_capture_from_feishu_explicit_reply_choice_protocol_forces_sync_ev def __init__(self, db): _ = db + async def canonicalize_thread_context( + self, + *, + tenant_id: int, + source_system: str, + channel_ref: str | None, + thread_context: dict | None, + ): + _ = (tenant_id, source_system, channel_ref) + return thread_context, True + async def resolve_source_activation_scope( self, *, @@ -2201,10 +2374,6 @@ async def test_project_context_prefill_skips_chatwide_reply_fallback(monkeypatch self.db = object() self.tenant_id = 1 - @staticmethod - def _is_contextual_project_request(message: str) -> bool: - return True - async def _load_recent_chat_turns(self, **kwargs): _ = kwargs return [] @@ -2254,8 +2423,56 @@ async def test_project_prefill_fallback_leaves_owner_and_timeline_empty() -> Non assert result["suggested_owner"] is None assert result["suggested_timeline"] is None + assert result["suggested_scope"] is None assert float(result["confidence"]) <= 0.35 assert len(result["reasoning"]) >= 1 + assert "规则" not in " ".join(result["reasoning"]) + + +@pytest.mark.asyncio +async def test_signal_extraction_failure_returns_degraded_payload_without_keyword_reasoning() -> None: + class _FakeLLM: + async def generate(self, *args, **kwargs): + _ = args, kwargs + raise RuntimeError("boom") + + class _FakeExtraction(FeishuPipelineExtractionMixin): + def __init__(self) -> None: + self.db = object() + self.tenant_id = 1 + self.llm = _FakeLLM() + + result = await _FakeExtraction()._extract_signal( + "请把自动开票系统需要关注的外部回执信号整理一下", + projects=[], + ) + + assert result["matched_project_id"] is None + assert float(result["confidence"]) <= 0.4 + assert "关键词规则" not in " ".join(result["reasoning"]) + + +@pytest.mark.asyncio +async def test_level_classification_failure_returns_degraded_payload_without_rule_reasoning() -> None: + class _FakeLLM: + async def generate(self, *args, **kwargs): + _ = args, kwargs + raise RuntimeError("boom") + + class _FakeExtraction(FeishuPipelineExtractionMixin): + def __init__(self) -> None: + self.db = object() + self.tenant_id = 1 + self.llm = _FakeLLM() + + result = await _FakeExtraction()._classify_level( + message="如果今天票开不出去,自动开票系统最先会从哪里看出来", + projects=[], + ) + + assert result["level"] == "signal" + assert float(result["level_confidence"]) <= 0.4 + assert "规则" not in str(result["level_reasoning"]) @pytest.mark.asyncio diff --git a/backend/tests/test_project_operating_context_contract.py b/backend/tests/test_project_operating_context_contract.py index 53397950..92c14199 100644 --- a/backend/tests/test_project_operating_context_contract.py +++ b/backend/tests/test_project_operating_context_contract.py @@ -1,4 +1,4 @@ -from app.schemas.project_operating_context import ExecutionRecipe, ProjectOperatingContext +from app.schemas.project_operating_context import ExecutionRecipe, ProjectFitBrief, ProjectOperatingContext from app.services.ai_pmo.project_operating_context_builder import ProjectOperatingContextBuilder @@ -48,3 +48,20 @@ def test_execution_recipe_schema_contract() -> None: assert recipe.fit_brief_required is True assert recipe.external_evidence_required is True assert recipe.plan_handoff_mode == "offer_after_answer" + + +def test_project_fit_brief_schema_tracks_signal_source_objectives() -> None: + fit_brief = ProjectFitBrief( + project_summary="当前项目:#81 测试产能警告 (status=active)", + signal_source_anchors=[], + signal_source_objectives=[ + "设计产能监控逻辑与数据采集:编写脚本收集测试队列与资源使用数据", + "实现产能警告触发服务:实时比对数据与阈值", + ], + ) + + assert fit_brief.project_summary == "当前项目:#81 测试产能警告 (status=active)" + assert fit_brief.signal_source_objectives == [ + "设计产能监控逻辑与数据采集:编写脚本收集测试队列与资源使用数据", + "实现产能警告触发服务:实时比对数据与阈值", + ] diff --git a/backend/tests/test_runtime_structured_semantics_contract.py b/backend/tests/test_runtime_structured_semantics_contract.py index 172ab864..0ea5e1dc 100644 --- a/backend/tests/test_runtime_structured_semantics_contract.py +++ b/backend/tests/test_runtime_structured_semantics_contract.py @@ -24,6 +24,14 @@ def test_runtime_modules_no_longer_keep_legacy_business_heuristic_helpers() -> N "app/services/integrations/feishu_signal_pipeline/mixin_lookup_and_block.py": ( "_is_project_lookup_question", ), + "app/services/integrations/feishu_signal_pipeline/mixin_message_utils_b.py": ( + "_is_simple_signal_source_request", + ), + "app/services/integrations/feishu_signal_pipeline/mixin_extraction.py": ( + "_heuristic_level_fallback", + "_heuristic_signal_fallback", + "_heuristic_project_prefill_fallback", + ), "app/services/ai_pmo/ai_pmo_kernel/mixins/intent/ai_pmo_kernel_mixins_intent_rules.py": ( "_has_question_structure", ), @@ -33,6 +41,78 @@ def test_runtime_modules_no_longer_keep_legacy_business_heuristic_helpers() -> N "app/services/integrations/feishu_integration_service/mixin_reply_llm_core.py": ( "_is_capability_question", ), + "app/services/integrations/feishu_integration_service/mixin_receive_routing.py": ( + "_is_feishu_signal_source_recommendation_question", + "_pick_feishu_entity_by_keywords", + "_looks_like_feishu_raw_internal_state_reply", + ), + "app/services/integrations/feishu_signal_pipeline/mixin_intent_gate_core.py": ( + "_extract_project_scope", + "_extract_project_intent_fields", + "_is_contextual_project_request", + ), + "app/services/integrations/feishu_signal_pipeline/mixin_context_prefill_a.py": ( + "_is_placeholder_project_text", + "_is_project_request_artifact", + "_guess_project_title_from_text", + "_extract_embedded_project_context", + "_derive_project_title_from_context", + ), + "app/services/integrations/feishu_signal_pipeline/mixin_context_prefill_b.py": ( + "heuristic_title", + "heuristic_description", + "heuristic_scope", + ), + "app/services/adaptive_rag_service/mixin_followup_anchor.py": ( + "_normalize_followup_selection_text", + "_parse_followup_choice_index", + ), + "app/services/analysis_packet_builder.py": ( + "_GENERIC_FIT_PLACEHOLDERS", + "_is_generic_fit_placeholder", + "_looks_like_engineering_task", + "_looks_like_signal_placeholder", + "_task_presentation_hint", + "_TOKEN_PATTERN", + "_text_overlap_score", + "_match_preferred_name", + "_task_signal_score_map", + "_signal_task_link_rows", + ), + "app/services/analysis_project_snapshot_loader.py": ( + "_TOKEN_PATTERN", + "_text_overlap_score", + "_task_presentation_score", + "_signal_prefix", + "_pick_signal_contrast_refs", + "task_signal_scores", + "signal_task_links", + "presentation_task_title", + "core_chain_task_title", + ), + "app/services/adaptive_rag_service/mixin_target_resolution.py": ( + "_referenced_objects", + ), + "app/services/adaptive_rag_service/mixin_core_intent.py": ( + "_normalize_semantic_subject_focus", + "_normalize_query_profile_object", + '"subject_focus"', + '"referenced_objects"', + ), + "app/services/adaptive_rag_service/mixin_query_route_gateway.py": ( + '"referenced_objects"', + ), + "app/services/adaptive_rag_service/mixin_session_state.py": ( + '"referenced_objects"', + ), + "app/services/adaptive_rag_service/mixin_fast_answer_project_utils.py": ( + "_query_subject_focus", + "_SEMANTIC_SUBJECT_FOCI", + ), + "app/config/ai_prompts.py": ( + '"subject_focus"', + '"referenced_objects"', + ), } for relative_path, forbidden_tokens in expectations.items(): diff --git a/backend/tests/test_semantic_decision_service.py b/backend/tests/test_semantic_decision_service.py index 746518a8..b7615683 100644 --- a/backend/tests/test_semantic_decision_service.py +++ b/backend/tests/test_semantic_decision_service.py @@ -1,8 +1,15 @@ from __future__ import annotations from app.config.ai_prompts import PMFLOW_SEMANTIC_CONTEXT_DECISION_PROMPT +from app.services.ai_pmo.semantic_context.business_frame_schema import ( + AnswerShapeContract, + BusinessSemanticFrame, + OperationIntentContract, +) from app.services.ai_pmo.semantic_context.decision_schema import ( + AnswerControlDecision, DecisionGateResult, + InteractionBoundaryPacket, ModeCard, SemanticDecision, ) @@ -74,3 +81,103 @@ def test_semantic_context_decision_prompt_avoids_business_semantic_examples() -> for fragment in forbidden_fragments: assert fragment not in PMFLOW_SEMANTIC_CONTEXT_DECISION_PROMPT + + +def test_semantic_context_decision_prompt_includes_structured_requirement_plan_protocol_contract() -> None: + required_fragments = ( + "business_frame", + "requirement_plan_protocol", + "choice:A / B / C / D", + "action:continue / clarify / reroll / draft", + "operation_intent_contract", + ) + + for fragment in required_fragments: + assert fragment in PMFLOW_SEMANTIC_CONTEXT_DECISION_PROMPT + + +def test_interaction_boundary_packet_schema_preserves_negative_boundary() -> None: + packet = InteractionBoundaryPacket.model_validate( + { + "requested_level": "recommendation", + "forbidden_levels": ["draft", "execute"], + "allow_status": True, + "allow_recommendation": True, + "allow_draft": False, + "allow_execute": False, + "correction_targets": ["project", "level"], + "confidence": 0.91, + "evidence": [{"source": "semantic_planner", "note": "用户明确说先别出草稿"}], + } + ) + + assert packet.requested_level == "recommendation" + assert packet.forbidden_levels == ["draft", "execute"] + assert packet.allow_draft is False + assert packet.allow_execute is False + assert packet.correction_targets == ["project", "level"] + + +def test_answer_control_decision_schema_tracks_blocked_workflows() -> None: + decision = AnswerControlDecision.model_validate( + { + "effective_level": "recommendation", + "blocked_levels": ["draft", "execute"], + "blocked_workflows": ["signal_source_flow:onboard"], + "allowed_workflows": ["query_flow:answer"], + "downgraded_from": "draft", + "requires_confirmation": False, + "requires_self_review": True, + "requires_correction_slot": True, + "reason": "用户只要建议,禁止直接起草或执行。", + } + ) + + assert decision.effective_level == "recommendation" + assert decision.blocked_workflows == ["signal_source_flow:onboard"] + assert decision.allowed_workflows == ["query_flow:answer"] + assert decision.downgraded_from == "draft" + + +def test_semantic_decision_schema_preserves_business_frame_contracts() -> None: + decision = SemanticDecision.model_validate( + { + "topic_relation": "new_topic", + "mode_decision": "answer_only", + "workflow_plans": [ + { + "workflow_type": "query_flow", + "workflow_action": "answer", + "target_scope": "project", + "operation_codes": ["query.answer"], + "confidence": 0.91, + } + ], + "business_frame": { + "goal_kind": "recommendation", + "target_scope": "current_project", + "resource_role": "signal_source", + "source_kind": "external", + "expected_answer_shape": "ranked_candidates", + "conversation_posture": "business_operating", + "project_binding": 6, + }, + "answer_shape_contract": { + "shape": "ranked_candidates", + "confidence": 0.88, + }, + "operation_intent_contract": { + "intent_phase": "recommendation", + "requires_confirmation": False, + "project_binding": 6, + }, + "requested_actions": ["workflow.bundle.compose"], + } + ) + + assert isinstance(decision.business_frame, BusinessSemanticFrame) + assert decision.business_frame.goal_kind == "recommendation" + assert isinstance(decision.answer_shape_contract, AnswerShapeContract) + assert decision.answer_shape_contract.shape == "ranked_candidates" + assert isinstance(decision.operation_intent_contract, OperationIntentContract) + assert decision.operation_intent_contract.intent_phase == "recommendation" diff --git a/backend/tests/test_workflow_semantic_planner.py b/backend/tests/test_workflow_semantic_planner.py index 4b547bc3..f2226f0a 100644 --- a/backend/tests/test_workflow_semantic_planner.py +++ b/backend/tests/test_workflow_semantic_planner.py @@ -117,3 +117,314 @@ def test_workflow_semantic_planner_no_longer_exposes_legacy_semantic_helpers() - for name in legacy_names: assert not hasattr(WorkflowSemanticPlanner, name), name + + +def test_workflow_semantic_planner_recommendation_boundary_downgrades_signal_source_onboard() -> None: + bundle = WorkflowSemanticPlanner._bundle_from_cold_start_decision( + decision=SemanticDecision.model_validate( + { + "topic_relation": "new_topic", + "mode_decision": "answer_only", + "workflow_plans": [ + { + "workflow_type": "signal_source_flow", + "workflow_action": "onboard", + "target_scope": "project", + "operation_codes": ["signal_source.onboard"], + "requires_confirmation": True, + "confidence": 0.94, + } + ], + "interaction_boundary": { + "requested_level": "recommendation", + "forbidden_levels": ["draft", "execute"], + "allow_status": True, + "allow_recommendation": True, + "allow_draft": False, + "allow_execute": False, + "self_review_required": True, + "correction_allowed": True, + "correction_targets": ["project", "level"], + }, + "requested_actions": ["workflow.bundle.compose"], + "confidence": 0.94, + } + ), + source_system="feishu", + message_text="先别出草稿,只按当前项目情况推荐几个最值得接的外部信号源。", + ) + + assert bundle.primary_workflow == "query_flow" + assert [item.workflow_type for item in bundle.workflows] == ["query_flow"] + assert bundle.workflows[0].operation_codes == ["query.answer"] + assert bundle.diagnostics["interaction_boundary"]["requested_level"] == "recommendation" + assert bundle.diagnostics["answer_control"]["effective_level"] == "recommendation" + assert bundle.diagnostics["answer_control"]["blocked_workflows"] == ["signal_source_flow:onboard"] + assert bundle.diagnostics["answer_control"]["downgraded_from"] == "draft" + + +def test_workflow_semantic_planner_explicit_draft_upgrade_preserves_signal_source_onboard() -> None: + bundle = WorkflowSemanticPlanner._bundle_from_cold_start_decision( + decision=SemanticDecision.model_validate( + { + "topic_relation": "continue_current_topic", + "mode_decision": "ready_confirm", + "workflow_plans": [ + { + "workflow_type": "signal_source_flow", + "workflow_action": "onboard", + "target_scope": "project", + "operation_codes": ["signal_source.onboard"], + "requires_confirmation": True, + "confidence": 0.96, + } + ], + "interaction_boundary": { + "requested_level": "draft", + "forbidden_levels": ["execute"], + "allow_status": True, + "allow_recommendation": True, + "allow_draft": True, + "allow_execute": False, + "self_review_required": True, + "correction_allowed": True, + "correction_targets": ["project", "level"], + }, + "requested_actions": ["workflow.bundle.compose"], + "confidence": 0.96, + } + ), + source_system="feishu", + message_text="可以,把第一个整理成接入草稿给我看。", + ) + + assert bundle.primary_workflow == "signal_source_flow" + assert [item.workflow_type for item in bundle.workflows] == ["signal_source_flow"] + assert bundle.diagnostics["answer_control"]["effective_level"] == "draft" + assert bundle.diagnostics["answer_control"]["blocked_workflows"] == [] + + +def test_workflow_semantic_planner_non_plan_signal_source_prepare_write_stays_execute_lane() -> None: + bundle = WorkflowSemanticPlanner._bundle_from_cold_start_decision( + decision=SemanticDecision.model_validate( + { + "topic_relation": "continue_current_topic", + "mode_decision": "request_execute", + "workflow_plans": [ + { + "workflow_type": "signal_source_flow", + "workflow_action": "onboard", + "target_scope": "project", + "operation_codes": ["signal_source.onboard"], + "requires_confirmation": True, + "confidence": 0.97, + } + ], + "interaction_boundary": { + "requested_level": "execute", + "forbidden_levels": ["draft"], + "allow_status": True, + "allow_recommendation": True, + "allow_draft": False, + "allow_execute": True, + }, + "operation_intent_contract": { + "intent_phase": "prepare_write", + "requires_confirmation": True, + "project_binding": 6, + "candidate_set_binding": "recset_invoice_01", + "candidate_refs": ["cand_invoice_retry"], + }, + "requested_actions": ["workflow.bundle.compose"], + "confidence": 0.97, + } + ), + source_system="feishu", + message_text="把刚才推荐的第一个信号源写进自动开票系统。", + ) + + assert bundle.primary_workflow == "signal_source_flow" + assert [item.workflow_type for item in bundle.workflows] == ["signal_source_flow"] + assert bundle.diagnostics["answer_control"]["effective_level"] == "execute" + assert bundle.diagnostics["answer_control"]["blocked_workflows"] == [] + + +def test_workflow_semantic_planner_exposes_business_frame_diagnostics_for_recommendation() -> None: + bundle = WorkflowSemanticPlanner._bundle_from_cold_start_decision( + decision=SemanticDecision.model_validate( + { + "topic_relation": "new_topic", + "mode_decision": "answer_only", + "workflow_plans": [ + { + "workflow_type": "query_flow", + "workflow_action": "answer", + "target_scope": "project", + "operation_codes": ["query.answer"], + "confidence": 0.92, + } + ], + "interaction_boundary": { + "requested_level": "recommendation", + "forbidden_levels": ["draft", "execute"], + "allow_status": True, + "allow_recommendation": True, + "allow_draft": False, + "allow_execute": False, + }, + "business_frame": { + "goal_kind": "recommendation", + "target_scope": "current_project", + "resource_role": "signal_source", + "source_kind": "external", + "expected_answer_shape": "ranked_candidates", + "conversation_posture": "business_operating", + "project_binding": 6, + }, + "answer_shape_contract": { + "shape": "ranked_candidates", + "confidence": 0.89, + }, + "operation_intent_contract": { + "intent_phase": "recommendation", + "requires_confirmation": False, + "project_binding": 6, + }, + "requested_actions": ["workflow.bundle.compose"], + "confidence": 0.92, + } + ), + source_system="feishu", + message_text="自动开票系统这个项目,你有推荐的外部信号源吗?", + ) + + assert bundle.diagnostics["business_frame"]["goal_kind"] == "recommendation" + assert bundle.diagnostics["business_frame"]["resource_role"] == "signal_source" + assert bundle.diagnostics["answer_shape_contract"]["shape"] == "ranked_candidates" + assert bundle.diagnostics["operation_intent_contract"]["intent_phase"] == "recommendation" + + +def test_workflow_semantic_planner_preserves_reply_anchor_candidate_set_in_bundle_diagnostics() -> None: + bundle = WorkflowSemanticPlanner._bundle_from_cold_start_decision( + decision=SemanticDecision.model_validate( + { + "topic_relation": "continue_current_topic", + "mode_decision": "request_execute", + "workflow_plans": [ + { + "workflow_type": "signal_source_flow", + "workflow_action": "onboard", + "target_scope": "project", + "operation_codes": ["signal_source.onboard"], + "requires_confirmation": True, + "confidence": 0.93, + } + ], + "interaction_boundary": { + "requested_level": "execute", + "forbidden_levels": ["draft"], + "allow_status": True, + "allow_recommendation": True, + "allow_draft": False, + "allow_execute": True, + }, + "operation_intent_contract": { + "intent_phase": "execute_write", + "requires_confirmation": True, + "project_binding": 6, + "candidate_set_binding": "recset_invoice_01", + "candidate_refs": ["cand_invoice_retry"], + }, + "requested_actions": ["workflow.bundle.compose"], + "confidence": 0.93, + } + ), + source_system="feishu", + message_text="把刚才那组外部信号源加入自动开票系统。", + metadata={ + "project_id": 6, + "reply_to_message_id": "om-reply-1", + "reply_anchor_qa_context": { + "project_id": 6, + "signal_source_recommendation": { + "candidate_set_id": "recset_invoice_01", + "project_binding": 6, + "candidates": [ + { + "candidate_ref": "cand_invoice_retry", + "display_name": "开票失败或重试回执", + "materialization_payload": { + "display_name": "开票失败或重试回执", + "business_value": "最早看到票开不出去", + }, + } + ], + }, + }, + }, + ) + + assert bundle.diagnostics["reply_anchor_qa_context"]["project_id"] == 6 + assert ( + bundle.diagnostics["reply_anchor_qa_context"]["signal_source_recommendation"]["candidate_set_id"] + == "recset_invoice_01" + ) + + +def test_workflow_semantic_planner_keeps_live_review_tool_non_plan_write_in_prepare_write_lane() -> None: + bundle = WorkflowSemanticPlanner._bundle_from_cold_start_decision( + decision=SemanticDecision.model_validate( + { + "topic_relation": "continue_current_topic", + "mode_decision": "request_execute", + "workflow_plans": [ + { + "workflow_type": "signal_source_flow", + "workflow_action": "onboard", + "target_scope": "project", + "operation_codes": ["signal_source.onboard"], + "requires_confirmation": True, + "confidence": 0.95, + } + ], + "interaction_boundary": { + "requested_level": "execute", + "forbidden_levels": ["draft"], + "allow_status": True, + "allow_recommendation": True, + "allow_draft": False, + "allow_execute": True, + }, + "business_frame": { + "goal_kind": "prepare_write", + "target_scope": "explicit_project", + "resource_role": "signal_source", + "source_kind": "external", + "expected_answer_shape": "write_confirmation", + "conversation_posture": "business_operating", + "project_binding": 88, + }, + "answer_shape_contract": { + "shape": "write_confirmation", + "confidence": 0.91, + }, + "operation_intent_contract": { + "intent_phase": "prepare_write", + "requires_confirmation": True, + "project_binding": 88, + "candidate_set_binding": "recset_live_review_01", + "candidate_refs": ["cand_comment_risk", "cand_platform_notice"], + }, + "requested_actions": ["workflow.bundle.compose"], + "confidence": 0.95, + } + ), + source_system="feishu", + message_text="把刚才推荐给直播复盘辅助工具(MVP1)的两个外部信号源直接加进去,不要出草稿,先回显准备写什么。", + ) + + assert bundle.primary_workflow == "signal_source_flow" + assert bundle.diagnostics["answer_control"]["effective_level"] == "execute" + assert bundle.diagnostics["business_frame"]["goal_kind"] == "prepare_write" + assert bundle.diagnostics["answer_shape_contract"]["shape"] == "write_confirmation" + assert bundle.diagnostics["operation_intent_contract"]["candidate_set_binding"] == "recset_live_review_01" diff --git a/docs/architecture/ARCHITECTURE_INVENTORY.md b/docs/architecture/ARCHITECTURE_INVENTORY.md index 1919f4dc..8c9d9935 100644 --- a/docs/architecture/ARCHITECTURE_INVENTORY.md +++ b/docs/architecture/ARCHITECTURE_INVENTORY.md @@ -103,14 +103,30 @@ - API canonical 入口优先使用 `*_api/` 子包,不再优先引用顶层兼容壳。 - 运行时业务动作归因优先走“对象/上下文绑定 -> `SemanticContextPacket` -> `SemanticDecision` -> gatekeeper”。 - 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 强拍板,再进入检索与回答主链。 +- query semantics 上游真源也已继续收口:`PROMPT_RAG_QUERY_SEMANTICS` + `mixin_core_intent.py` 现在只把 `query_profile(needs_private_context, needs_freshness, confidence)` 与 `BusinessSemanticFrame` 写回运行时,`subject_focus / query_profile.referenced_objects` 已从 production backend 移除,不再参与 route snapshot、session scope continuity 或任何 read-side 分流。 +- `WorkflowSemanticPlanner` 冷启动 bundle 现已补上四挡 `interaction_boundary` 合同:`decision_schema.py` 定义 `InteractionBoundaryPacket / AnswerControlDecision / SelfReviewResult`,planner 会把显式 `status / recommendation / draft / execute` 边界与识别出的 `workflow_plans` 合成 `answer_control`,并把 `requested_level / forbidden_levels / effective_level / blocked_workflows` 落进 bundle diagnostics。对“只给建议/先别出草稿”这类 recommendation-only 问法,planner 允许识别到底层 `signal_source_flow`,但最终会按 boundary 把 bundle 降回 `query_flow`,避免误滑到 `draft_pending_confirm`;旧的无 boundary bundle 仍走兼容透传,不会压坏既有写链路。 +- `AdaptiveRAG` 与 AI PMO 主链里几个最容易回流硬语义的入口也已继续收口:project progress fast answer 只按结构化 `interaction_level / goal_kind / resource_role / expected_answer_shape` 决定是否绕开 fast path;casual chat 直答只消费 `message_intent=casual_chat` 的结构化结果;typed executor 对 `project_lookup` 直答只认结构化 `message_intent=project_lookup(_question) + query_semantics`,不再被 `项目列表/项目清单/项目#123` 这类词面带偏;signal 数量和“先分析再入库”这类分析模式也只读 `requested_count / analysis_mode / operation_intent_contract`,不再从自然语言里额外解析“3个信号/先分析后入库”。 +- internal config 这条 read-side 也继续结构化了:`AdaptiveRAGTargetResolutionMixin` 不再把泛化 `resource_role=config` 自动映射成 `config:system`,必须依赖显式 `object_binding` 区分 `config:model_routing` 与 `config:mcp_template_center`;对应的 deep guard 直答也拆成模型路由和 MCP 配置两个子口径。 +- 项目化影响判断不再必须依赖外部实时抓取才能保住“直接影响”那一行:`AdaptiveRAGFastAnswerProjectMainMixin` 现在会根据 `goal_kind=impact_judgment / expected_answer_shape=impact_chain` 保留结构化 `external_impact` 渲染,避免 WMS/税控/评论流这类高频影响问法退回普通项目周报。 +- `AdaptiveRAG` 的 follow-up clarification 现在也继续往结构化真源收:`mixin_followup_anchor.py` 不再在 runtime 用“第几条/标签名/最近回复 match_text”去消费 `pending_clarification`;`query_route_selection / followup_anchor_selection` 都先经过统一结构化澄清解析,再把选中的 `source_policy_hint / query_semantics_overrides / selected_qa_id` 透回 `mixin_answer_mainline.py`。这样用户说“内部那个 / 第二条”后,主链会带着明确的 route override 或 anchor QA id 继续,不会再把原问题拿回去后靠最近回复词面重猜。 +- internal `Operation Contract / Target Resolver` 已按 business-frame-first 收口:`mixin_target_resolution.py` 现在只消费 `goal_kind / target_scope / resource_role / project_binding / object_binding / expected_answer_shape` 来判定 `project / task / signal / config / feature / doc / user` 绑定,不再让 `subject_focus / referenced_objects` 这类 coarse 字段单独拍板内部对象类型。 +- internal object/domain clarify 继续往下一层时,当前由 `backend/app/services/adaptive_rag_service/internal_domain_catalog.py` 提供 catalog 真源;catalog 已覆盖 `project / task / signal / config / feature / doc / user` 这批 internal domain。`mixin_target_resolution.py` 会在只确认大域(如 `resource_role=config` 或 `resource_role=doc`)但尚未唯一落到子对象时生成 `candidate_summary`,`mixin_answer_payload.py` / `mixin_answer_precheck.py` / `mixin_followup_anchor.py` 再把它转成 `internal_target_selection` pending clarification,并在 follow-up 后回填 `query_semantics_overrides` 继续主链。 +- `AdaptiveRAG` read-side 那批旧 compatibility consumer 也已一起收口:`shared_utils.py` 统一提供 `BusinessSemanticFrame -> structured entity/project binding` 归一化,`mixin_deterministic_query_core.py`、`mixin_fast_answer_project_main.py`、`mixin_fast_answer_semantic_p1.py`、`mixin_deterministic_query_count.py`、`mixin_deterministic_query_list.py`、`mixin_retrieval_router_db.py`、`mixin_deep_guard_answer.py`、`mixin_answer_mainline.py`、`mixin_manifest_feature_p2.py` 现在只看这层结构化结果决定 `project / task / signal / config / feature / doc / user` 的快答、检索、deep guard 与 internal query plan,不再读取 `subject_focus / referenced_objects` 做二次业务分流。 - `ProjectOperatingContext` 当前由 `project_operating_context_builder.py` 统一组装:最小范围固定为“当前项目 + 当前仓库摘要 + 页面/线程锚点”;world model 建完后会回填 `resolved_project_id / repo_scope`,再供 `Query Route Gateway`、`mixin_project_fit_brief.py` 与 answer payload 消费。 +- `analysis_packet` 现作为单独的 analysis layer 增量接到问答主链:`analysis_packets.py` 定义 `project_fit_analysis / external_impact_analysis / internal_explain_analysis / followup_explain_analysis` 这组 typed packet,`analysis_packet_builder.py` 只消费 `ProjectOperatingContext / ExecutionRecipe / fit_brief / external_recommendation / InternalContextPacket / clarification` 等 runtime truth 组包,不从回答正文反推。当前 richer packet 会把 `current_subject / candidate_position / rejected_position / reason_chain` 这类对象级分析字段显式落盘,避免 presenter 再用泛句补戏;共享 richer project snapshot 由 `analysis_project_snapshot_loader.py` 统一补 `tasks/signals + description/summary + analysis_object_refs`,供 AdaptiveRAG payload 和 Feishu presenter 复用。最新 snapshot loader 已 fail-closed:没有真实结构锚点时,不再从 task/signal 标题描述推 `fit_candidate_* / fit_rejected_* / primary_*` 关系,也不再生成 `task_signal_scores / signal_task_links` 这类 token-overlap 产物;builder 主路径只消费显式 object ref、精确文本绑定和唯一候选这三类非语义结构证据。 +- `analysis_packet_builder.py` 里原来那层“工程任务/信号源占位/展示层”词面 second-guess 已继续收掉:generic fallback、direct-match generic 分支和 internal explain target fallback 现在都不再靠 `_looks_like_engineering_task / _looks_like_signal_placeholder / _task_presentation_hint / token-overlap / partial-name match` 去反驳上游对象关系;没有显式 ref 时只能诚实降级,最多允许“唯一候选”这种纯结构兜底。 - 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` 已完成 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。 +- 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 / signal_source_objectives`,其中 formal object anchors 优先来自 `ProjectFitBrief.signal_source_anchors`,项目任务目标锚点来自 `ProjectFitBrief.signal_source_objectives`,不再拿 feature capability 名称或固定类别模板直接冒充 formal project object;payload 会拆出 `fit_brief / external_recommendation`,AdaptiveRAG external probe、freshness fetch 与 external route retrieval 已接到这条链上,并对 external sources 执行 fail-closed 过滤。当前 planner 还补了 recommendation query seed guard:命中“推荐外部信号源”问法时,会优先把当前项目任务目标和 formal anchor 驱动的 query 排到 `search_queries` 前面,避免 structured planner 用 `industry best practices / external data sources` 这类泛化词覆盖掉更贴当前项目的 seed query;同时 `interaction_level=recommendation` 会从统一语义规划层经 `answer_control/source_policy_hint` 透传到 `ExternalQueryPlan`,recommendation 分支不再由 presenter 本地 cue list 单独拍板。`presenter.py` 使用独立 external-only prompt 家族,并强制区分 `verifier_blocked / no_relevant_results / retrieval_failed / indirect_only`,不再回流 `industry_trend/external_trend` scene prompt;对 recommendation ask,presenter 现在会先生成 `SignalSourceRecommendationPacket(candidates + caveat)`,再渲染成业务中文的 `1/2/3` 候选建议,确保输出真的贴当前项目对象,而不是只靠自由文本兜一句“先看上游输入”。同时仍保留 overclaim guard:当结构化候选或文本里出现百分比、已确认、无需验证等高确定性措辞时,会 fail-closed 回退到 customer-safe candidate 文案。相关设计规格见 `docs/superpowers/specs/2026-04-15-external-query-gateway-design.md`。 +- non-plan recommendation -> signal-source write 当前已补齐结构 carry-over:external recommendation 不再只返回文本,`SignalSourceRecommendationPacket` 里的 candidate set 绑定会经 `reply_anchor_qa_context + action_contract` 一路沉到 `signal_source.onboard` 执行结果,并在 follow-up `把推荐的写进系统 / 确认入库` 时由 `mixin_followup_project_signal_resume.py` 重建回 `operation_intent_contract(candidate_set_binding/candidate_refs/project_binding)`。`signal_source.onboard` 直写链会在 signal 已 materialize 且项目关联成功后把业务结果视为 `succeeded`,同时仅通过 `post_process_status / signal_source_workflow.status` 暴露后续 onboarding/manual 状态;`ai_pmo_reply_renderer.py` 也会把批量 signal source 直写回执渲染成自然业务中文,而不是 requirement draft 或 pending-manual 漂移文案。 +- QA session detail 持久化现已补齐 `routing_snapshot.response_payload`:`mixin_core_intent.py` 在写 `QARecord` 时会把 `project_operating_context / execution_recipe / fit_brief / external_recommendation / analysis_packet / handoff_mode / can_enter_plan / recommended_next_step` 一并归档,`mixin_qa_session_list_ops_p1.py` 再将其投影给 AI Steward 历史回放,避免即时回答与 session detail 出现两套合同。 +- Feishu QA 呈现现与共享 analysis packet 对齐:`mixin_receive_routing.py` 会先基于 `project_operating_context` 读取当前项目实体快照,再调用 `analysis_packet_builder.py` 生成共享 `analysis_packet`,之后才进入“受控改写 -> blocked-entity 校验 -> deterministic fallback”的渠道 presenter。运行时会同时校验 fixed opener、标签摘要、内部 packet 术语(如 `analysis_packet / missing_mapping / next_step / focus_objects / reason_chain`)以及未入选实体泄漏;若任一条件不满足,则 fail-closed 回退到 packet-based deterministic 文本。同时保留 `draft_pending_confirm / requirement_plan_followup / pending_manual` 优先走 action card,避免把飞书回归修成单纯文案 bypass。最新 `qa_direct` 还新增了一层中文化 presentation policy:内部现状问题若原始 answer 泄出 `Project database / signal_total / 当前盘面 / 建议动作 / External Freshness Guard` 这类英文或内部术语,会优先回退成共享 packet 文本,并最多只追加一行中文来源说明;外部信号源 recommendation ask 若已有推荐正文,则优先直出推荐正文,再补同样的中文来源说明;对 sparse project-fit 这类 packet,deterministic fallback 也会转成人话解释,不再原样暴露 packet 缺口字段。 +- Feishu `project.create` 预填与 QA fallback 现继续去 lexical 化:project create 相关字段不再由 `_extract_project_scope / _extract_project_intent_fields / _is_contextual_project_request / heuristic_*` 之类 helper 从用户原话里扣词,而统一消费 `_extract_project_prefill()` 的结构化 `suggested_name / suggested_description / suggested_scope`;QA fallback 也不再用 `_pick_feishu_entity_by_keywords` 按“摘要/拉取/推送”等关键词挑实体,改成只按共享 packet/select entity 的结构顺序兜底。 +- `analysis_packet_builder.py` 的 sparse project-fit fallback 也往结构化真源继续收:当前如果 `preferred/rejected` 对象缺失,会直接按“结构对象缺失”降级,不再靠“解释层/当前主链”这类占位词表决定 sparse fallback 分支。 - 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 真源。 +- `analysis_packet` 当前也带独立 rollout guard:`PMFLOW_ENABLE_ANALYSIS_PACKET_FAMILY=0` 时,AdaptiveRAG payload / session replay / Feishu presenter 会回退到旧 handoff truth + 旧 presenter,避免影响 `project_operating_context / execution_recipe / fit_brief / external_recommendation` 真源。 - `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`。 +- 下一阶段的全量业务结构化基线已确认:在 `InteractionBoundaryPacket / AnswerControlDecision` 之上继续引入 `BusinessSemanticFrame`、`AnswerShapeContract`、`RecommendationCandidateSet`、`ClarificationDelta / CorrectionDelta` 与 `OperationIntentContract`,把 recommendation、clarification/correction、casual chat、non-planmode direct signal-source write 统一收口到同一语义真源;并以 58 条广覆盖业务语义矩阵作为完整回归基线、以 `docs/superpowers/specs/2026-04-20-business-golden20-refresh.md` 里的 20 条当前项目池黄金案例作为日常 P0/P1 spot check 集。 - `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,不再开旁路。 - MCP 模板接入中心已接入 `backend/app/api/mcp_api/` + `backend/app/services/mcp_config/`:租户内统一保存 MCP 模板 catalog、server 配置 bundle、命令预览、validate 结果、runtime probe 与 `pending_local_apply / user_confirmed / not_required` 状态;`backend_direct` 模板既支持远程 `streamable_http`(如 `microsoft_learn`),也支持后端内建 `backend_adapter` runtime(如 `duckduckgo`),本地 `stdio` 模板只输出 Local Bridge/兼容 host 接入说明,服务端不直写客户机器 `~/.codex/config.toml`。 diff --git a/docs/architecture/CODE_REFERENCE_INDEX.md b/docs/architecture/CODE_REFERENCE_INDEX.md index 07609a0f..0bc9775d 100644 --- a/docs/architecture/CODE_REFERENCE_INDEX.md +++ b/docs/architecture/CODE_REFERENCE_INDEX.md @@ -32,14 +32,29 @@ - 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` +- Four-level interaction boundary 合同与冷启动 answer control:参考 `backend/app/services/ai_pmo/semantic_context/decision_schema.py`、`backend/app/services/ai_pmo/workflow_semantic_planner/service.py`、`backend/tests/test_semantic_decision_service.py`、`backend/tests/test_workflow_semantic_planner.py`;`interaction_boundary` 负责表达本轮允许做到 `status / recommendation / draft / execute` 哪一挡以及禁止升级到哪一挡,`answer_control` 负责把识别出的 workflow 真正过滤成最终可执行 bundle,并把 `requested_level / forbidden_levels / effective_level / blocked_workflows` 挂进 diagnostics +- Analysis packet family 合同与 builder:参考 `backend/app/schemas/analysis_packets.py`、`backend/app/services/analysis_packet_builder.py`、`backend/app/services/analysis_project_snapshot_loader.py`;当前 `project_fit_analysis / external_impact_analysis / internal_explain_analysis` 已显式带 `current_subject / candidate_position / rejected_position / reason_chain`,共享 richer project snapshot 由 loader 统一补 `tasks/signals + description/summary + analysis_object_refs`;但 loader 现已 fail-closed,没有真实结构锚点时不再从 task/signal 标题描述推导 object refs。builder 只消费显式 object ref、精确文本绑定和唯一候选这三类非语义结构证据;`direct_match` 若缺 dependent task ref,就只保留 anchor signal,不再硬挂任务;`internal_explain` 若缺 `target_resolution`,也只允许精确文本绑定,不再做 partial-name/overlap 反绑。 +- sparse project-fit 降级规则也在 `backend/app/services/analysis_packet_builder.py`:当前若 `preferred/rejected` 结构对象缺失,会直接按“结构对象缺失”降级,不再靠“解释层/当前主链”占位词表、token-overlap 或 partial-name match 决定 fallback 分支。 - 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` +- query semantics 真源与 business-frame 输出:参考 `backend/app/config/ai_prompts.py`、`backend/app/services/adaptive_rag_service/mixin_core_intent.py`、`backend/app/services/ai_pmo/semantic_context/business_frame_schema.py`;当前 query semantics 上游只保留 `query_profile(needs_private_context, needs_freshness, confidence)` 与 `business_frame` 这组运行时真源,不再输出/持久化 `subject_focus / query_profile.referenced_objects` +- `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` 与当前项目任务目标 `signal_source_objectives` 都在这里生成 +- `execution_recipe / fit_brief / external_recommendation / analysis_packet / recommended_next_step / handoff_mode` 回答接缝:参考 `backend/app/services/adaptive_rag_service/mixin_query_route_gateway.py`、`mixin_answer_mainline.py`、`mixin_answer_payload.py` +- follow-up session rewrite gate 与“新项目/新业务 scope reset”判定:参考 `backend/app/services/adaptive_rag_service/mixin_session_state.py` 与 `mixin_answer_mainline.py`;当前实现会先比对上一轮 `QARecord.routing_snapshot` 的 `project_id / target_resolution / business_frame binding` 与当前原始问句解析出的同组结构,再决定是否允许 `state_subject/state_entities` 重写,避免在 `session_state` 里靠项目名/正则延续旧主题 +- follow-up anchor / pending clarification 结构化恢复:参考 `backend/app/services/adaptive_rag_service/mixin_followup_anchor.py` 与 `mixin_answer_mainline.py`;当前 `query_route_selection / followup_anchor_selection` 都先经过统一结构化澄清解析,再把选中的 `source_policy_hint / query_semantics_overrides / selected_qa_id` 透回主链。`selected_qa_id` 会继续传给 follow-up anchor resolver,避免“第二条/那条”澄清后又回到最近回复词面重猜 +- Internal Query `Operation Contract / Target Resolver` 的 business-frame-first 入口:参考 `backend/app/services/adaptive_rag_service/mixin_target_resolution.py`;当前 `goal_kind / target_scope / resource_role / project_binding / object_binding / expected_answer_shape` 会先被收进 operation contract,再由 target resolution 直接据此判定 `project / task / signal / config / feature / doc / user` 绑定,不再靠 `subject_focus / referenced_objects` 这类 coarse 字段补拍对象 +- `AdaptiveRAG` read-side business-frame-first 归一化入口:参考 `backend/app/services/adaptive_rag_service/shared_utils.py`;`_structured_runtime_entity_type / _structured_project_requested` 会把 `business_frame + target_resolution + query_plan` 合成统一 entity/project binding,供 `mixin_deterministic_query_core.py`、`mixin_fast_answer_project_main.py`、`mixin_fast_answer_semantic_p1.py`、`mixin_deterministic_query_count.py`、`mixin_deterministic_query_list.py`、`mixin_retrieval_router_db.py`、`mixin_deep_guard_answer.py`、`mixin_answer_mainline.py`、`mixin_manifest_feature_p2.py` 复用;这些 read-side consumer 现已不再读取 `subject_focus / referenced_objects` 做 `project / task / signal / config / feature / doc / user` 的快答、检索和 deep guard 分流 - 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` +- 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`;当前实现为“共享 analysis_packet -> 受控改写 -> blocked-entity 校验 -> deterministic fallback”的两段式 presenter,相关 prompt 在 `backend/app/config/ai_prompts_catalog/part_01.py`。`qa_direct` 还额外承担中文化 presentation policy:原始 answer 若泄出 `Project database / signal_total / 当前盘面 / 建议动作 / External Freshness Guard` 等英文或内部术语,或自然化结果直接复述 `分析包 / analysis_packet / missing_mapping / next_step / focus_objects / reason_chain` 这类内部 packet jargon,会优先压回 packet 文本,并最多只补一行中文来源说明;命中外部信号源 recommendation 呈现时,现在优先吃 `rag_result.external_query_plan/query_semantics` 里的结构化 `interaction_level=recommendation`,不再靠 Feishu 本地问句 cue list 决定是否直出推荐正文。对 sparse project-fit fallback,当前 deterministic reply 也会被重写成业务中文,不再原样暴露 packet 缺口字段 - 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 Query Service 主链:参考 `backend/app/services/external_query_service/`(`planner.py`、`gateway.py`、`registry.py`、`schemas.py`、`presenter.py`、`adapters/`);`planner.py` 当前会对“推荐外部信号源”问法优先补 `signal_source_objectives / signal_source_anchors` 驱动的项目锚点查询,并在 structured plan 返回过泛 query 时仍把这些项目锚点 seed queries 保留在前面;同时 `ExternalQueryPlan.interaction_level` 会承接统一语义规划层透传下来的 `recommendation` 层级,供后续 presenter 分支消费。`schemas.py` 现在还定义了 recommendation 内容层的 `SignalSourceRecommendationPacket / SignalSourceRecommendationCandidate`,`presenter.py` 会先生成这层结构化候选,再渲染成业务中文的 `top-N candidate + why`;recommendation 分支现在优先看 `plan.interaction_level` 与结构化 recommendation goal/anchors,不再靠 presenter 本地问题 cue list 拍板。当前 presenter 仍保留 overclaim guard:若候选里出现百分比、已确认、无需验证或内部术语,会 fail-closed 回退到 customer-safe fallback candidate,而不是继续把高确定性或架构腔文本吐给用户 +- non-plan 外部推荐 -> signal source 直写链:参考 `backend/app/services/ai_pmo/ai_pmo_typed_executor_execute/mixin_execute_signal_source.py`、`backend/app/services/ai_pmo/ai_pmo_kernel/mixins/ingress/ai_pmo_kernel_mixins_ingress_storage.py`、`backend/app/services/ai_pmo/ai_pmo_kernel/mixins/followup/ai_pmo_kernel_mixins_followup_project_signal_resume.py`、`backend/app/services/ai_pmo/ai_pmo_kernel/mixins/followup/ai_pmo_kernel_mixins_followup_verification.py`、`backend/app/services/ai_pmo/ai_pmo_kernel/ai_pmo_reply_renderer.py`;这条链当前会把 recommendation candidate set 经 `reply_anchor_qa_context + action_contract` 沉到执行结果,再在后续 `把推荐的写进系统 / 确认入库` 时复用 `candidate_set_binding + candidate_refs + project_binding` 继续 `prepare_write -> confirm_write -> execute_write`,非 `plan mode` 不再默认 requirement draft,且一旦 signal 已 materialize 并写入项目,用户可见结果视为成功,后续 onboarding/manual 状态只通过 `post_process_status` 暴露 +- requirement-plan / draft confirm 协议优先级与碎片化收敛主链:参考 `backend/app/services/ai_pmo/ai_pmo_typed_executor_execute/mixin_execute_query_project.py`、`backend/app/services/ai_pmo/ai_pmo_kernel_ingress_flow/mixin_entry.py`、`backend/tests/test_ai_pmo_ingress_mainline.py`;当前实现明确把 raw `A/B/C/确认/取消/重出/修改` 协议放在 semantic backfill 之前,active requirement 的碎片化短回复优先留在 collect / contextual promotion 主链,且 `ready / draft_pending_confirm` 状态下无锚点单句不再劫持旧草稿确认链 +- 去 lexical fallback 的运行时入口:`backend/app/services/adaptive_rag_service/mixin_fast_answer_project_main.py` 里的 project progress fast answer bypass 现在只吃结构化 `interaction_level / goal_kind / resource_role / expected_answer_shape`;`backend/app/services/ai_pmo/ai_pmo_typed_executor_execute/mixin_execute_query_project.py` 的 casual chat 直答只吃 `message_intent=casual_chat`,`project_lookup` shortcut 只吃结构化 `message_intent=project_lookup(_question) + query_semantics`,不再靠问句词面、`项目列表/项目清单` 正则或 `extract_project_lookup_keyword` 提前抢答;`backend/app/services/ai_pmo/ai_pmo_kernel/ai_pmo_typed_executor_mixins_parse.py` 与 `backend/app/services/ai_pmo/ai_pmo_typed_executor_discovery/mixin_analysis.py` 的 signal 数量/strict analysis 也只消费结构化 `requested_count / analysis_mode / operation_intent_contract`,不再从自由文本里解析“3个信号”“先分析后入库” +- internal config/domain clarify 与 fast answer 真源:`backend/app/services/adaptive_rag_service/internal_domain_catalog.py`、`mixin_target_resolution.py`、`mixin_followup_anchor.py`、`mixin_answer_precheck.py`、`mixin_answer_payload.py`、`mixin_deep_guard_answer.py`。当前 internal domain catalog 已覆盖 `project / task / signal / config / feature / doc / user`;其中 config 主链只认 `business_frame.object_binding`(如 `config:model_routing`、`config:mcp_template_center`)做直答,task/signal/feature/doc/user 则优先走 domain-first target resolution 与受控 retrieval。若只确认了大域但还没唯一落到子对象,则先从 internal domain catalog 产出 `internal_target_selection` 澄清协议,pending clarification follow-up 会把选中的 `query_semantics_overrides` 回填主链后再继续往里查,而不是重复旧答案。 +- 项目化 `impact_judgment` 快答主链:`backend/app/services/adaptive_rag_service/mixin_fast_answer_project_main.py`。当 `business_frame.goal_kind=impact_judgment` 或 `expected_answer_shape=impact_chain` 时,会把项目影响判断接到 project progress fast answer 主链,并在没有外部实时条目时仍保留结构化 `external_impact` 结论 +- Feishu signal pipeline 的结构化 intake 主链:参考 `backend/app/services/integrations/feishu_signal_pipeline/mixin_process_message_core.py`、`mixin_process_message_default.py`、`mixin_item_processing.py`、`mixin_extraction.py`、`mixin_item_finalize_routing.py`、`mixin_core_base.py`;当前 default capture 会把 `intent_gate` 透给后续 `signal_intake_hint`,extraction 失败时统一回落到低置信 degraded payload(不再使用 `_is_simple_signal_source_request / _heuristic_*_fallback / regex 项目命名猜测`),core dedupe intent fragment 也只保留标准化片段,不再本地推导 `external_integration / project_setup / feature_change` +- Feishu `project.create` 的结构化预填链:参考 `backend/app/services/integrations/feishu_signal_pipeline/mixin_intent_gate_core.py`、`mixin_context_prefill_a.py`、`mixin_context_prefill_b.py`、`mixin_serialization.py`、`mixin_extraction.py`;当前项目字段预填统一依赖 `_extract_project_prefill()` 产出的 `suggested_name / suggested_description / suggested_scope`,不再使用 `_extract_project_scope / _extract_project_intent_fields / _is_contextual_project_request / heuristic_*` 这类词面推断。 - 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` - 运行时语义守门与回归:参考 `scripts/semantic_lint.py`、`backend/tests/test_semantic_lint.py`、`backend/tests/test_external_query_planner.py` - MCP backend-direct runtime / Microsoft Learn bridge:参考 `backend/app/services/mcp_config/runtime.py` 与 `backend/app/services/mcp_config/service.py` @@ -79,7 +94,7 @@ - 新增前端 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 QA contract 前端类型(含 `analysis_packet`):参考 `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` diff --git a/docs/architecture/RUNTIME_GUARDRAILS.md b/docs/architecture/RUNTIME_GUARDRAILS.md index 0dda33bc..f2837db7 100644 --- a/docs/architecture/RUNTIME_GUARDRAILS.md +++ b/docs/architecture/RUNTIME_GUARDRAILS.md @@ -49,6 +49,10 @@ - 新增 AI 前台能力时,必须优先接入“消息/上下文 -> 语义识别 -> 业务对象/草稿 -> 审计/幂等/确认 -> 入库/执行”链路;不得只停留在对话文本层或仅新增孤立聊天能力而不进入正式业务闭环。 - `backend/app` 运行时代码不得再沉淀会直接拍板 `project.create / signal_source.onboard / followup.* / query vs execute` 的中文关键词、正则、`looks_like_*`、`*_HINTS/*_KEYWORDS` 规则仓。 - 协议级锚点(`reply_to_message_id/button/work_item_id`)、租户权限、幂等等确定性规则可以保留,但展示反馈、草稿确认/修改/取消、followup 动作归类、`query vs execute` 不能再由关键词最终拍板。 +- `query.answer` 的 referential follow-up state rewrite 只能在“上一轮结构化 scope”与“当前原始问句解析出的结构化 scope”仍连续时生效。scope 比较必须优先看上一轮 `QARecord.routing_snapshot` 中的 `project_id / query_semantics.target_resolution / query_semantics.business_frame(project_binding, object_binding)`,以及当前原始问句经 `query_semantics + project scope resolver` 产出的同组字段;若当前 turn 已切到新的项目或新的业务对象,必须直接停用旧 `state_subject/state_entities` 重写并把原始问句送进后续 route preview。禁止在 `session_state` 里用项目名关键词、正则或其它 lexical shortcut 拍板“是不是换项目/换业务”。 +- 冷启动 `WorkflowSemanticPlanner` 必须同时产出四挡 `interaction_boundary`(`status / recommendation / draft / execute / unclear`)与 `answer_control`。运行时允许先识别出底层 write workflow(如 `signal_source_flow:onboard`),但只要 boundary 明确禁止 draft/execute,就必须在 planner 层把最终 bundle 降回 `query.answer`,并把 `requested_level / forbidden_levels / effective_level / blocked_workflows` 显式写进 diagnostics;禁止再在执行层靠“识别到 signal_source_flow 了所以顺手起草”这类升级冲动夺权。 +- `AdaptiveRAG` 的 project progress fast answer、AI PMO 的 casual chat 直答,以及 typed executor 的 signal 数量/analysis-mode 解析,现在都必须只消费结构化字段(如 `interaction_level / goal_kind / resource_role / expected_answer_shape / message_intent / requested_count / analysis_mode / operation_intent_contract`)。运行时代码不得再从自然语言文本里用中文/英文词表或正则决定“是不是信号源候选问题”“是不是闲聊”“是不是先分析再入库”“用户要几个候选”。 +- AI PMO typed executor 的 `project_lookup` 直答 shortcut 现在也必须只认结构化 `message_intent=project_lookup(_question)` 与 planner 透传的 `query_semantics`;禁止再从自由文本里用 `项目列表/项目清单/项目#123` 一类词面或 `extract_project_lookup_keyword` 提前抢答。 - `query.answer` 相关运行时模块同样禁止换皮回流业务语义拍板:`AdaptiveRAG / external_query_service` 内不得维护 `_INTERNAL_ROUTE_OBJECTS / _EXTERNAL_ROUTE_OBJECTS / _SEMANTIC_QUERY_OBJECT_TYPES` 这类对象集合、`*_probe_is_strong / *_route_probe` 这类强判 helper、或 `query_kind(weather/news/company/market/model)` 这类对象到路由/类型的硬映射。若上游 `SemanticDecision / query_profile / source_policy_hint` 仍不足以唯一确定来源,必须显式保留 `uncertain` 或直接澄清,禁止执行层二次夺权。 - 若问题根因属于“动作路由错误 / 上下文劫持 / 独立业务流误触发”,主修法必须优先落在 ingress 分流、active topic/draft 检测、`SemanticContextPacket` 组包、`SemanticDecision` 结构化判断与 gatekeeper 放行条件。 - `workflow_semantic_planner` 当前职责是 packet / bundle 组装、兼容层收口与冷启动接入,不再作为新的关键词仓。 @@ -70,14 +74,20 @@ - `MountedTopicWorkspace.plan_status` 必须显式落库区分 `idle / collecting / options_ready / plan_ready / awaiting_execute_confirm / suspended`。 - `requirement_plan` 的 A/B/C 可以固定为用户可见骨架,但业务选项、推荐项、理由和代价必须走“上下文包 -> 模型结构化决策 -> gatekeeper -> 渲染”。 - 正在收敛中的 `requirement_plan` 必须支持碎片化回复,不能因缺少新的 `semantic_bundle` 回落到普通 query。 +- active requirement context 下的碎片化短回复(补一句、补约束、单句回答目标用户/技术路线/交付方式等)必须优先留在 requirement-plan collect / contextual promotion 主链;若当前 turn 缺少新的 `semantic_bundle`,只允许在 requirement 主链内做最小 backfill,禁止先掉回普通 `query.answer`、也禁止为了补 bundle 强制新开 requirement intent。 +- requirement-plan / draft 协议动作的优先级必须固定为“当前 turn 的显式 raw protocol > 结构化 semantic bundle > card/历史 option map 推导”。裸 `A/B/C`、`我选C`、`确认`、`取消`、`重出`、`修改` 等当前 turn 明确协议动作一旦命中,就必须先锁定当前轮次动作;semantic backfill 只能在显式协议解析失败后才允许参与补全。 - 同一 `conversation_scope` 一旦存在 active requirement workspace,线程即进入 requirement mode lock:除显式“退出需求模式”外,后续消息只能走需求澄清、A/B/C/D 协议、补一句、出草稿、重出这轮等 requirement-plan 动作;闲聊或其它问答必须显式提示“当前正在需求模式”,禁止再落回 `casual_chat / business_review / query.answer`。 +- internal config 问答现在必须依赖显式 `business_frame.object_binding` 区分具体配置对象;`resource_role=config` 已不允许再默认降成泛化 `config:system`。模型路由类问题应绑定 `config:model_routing`,MCP 配置类问题应绑定 `config:mcp_template_center`;若上游没有给出对象绑定,只能澄清,禁止直接答成模型引擎配置。 +- `impact_judgment + impact_chain + explicit/current_project` 这类项目化影响判断问题,允许复用 project progress fast answer 主链,但即使没有抓到外部实时条目,也必须保留结构化 `external_impact`/直接影响结论,不能退回普通项目周报口吻把影响链吞掉。 - 已绑定 active requirement context 时,显式“退出需求模式”必须在 active-context followup 阶段直接把当前 requirement topic 挂起为 `status=suspended / plan_status=suspended / pending_actions=['resume_requirement_plan']`,禁止先掉进普通 `query.answer` 再靠后续 session/route 猜退出。 - 同一 `conversation_scope` 内同时存在 collecting requirement topic 与后续普通 query 产生的 idle topic 时,runtime 选择 active requirement workspace 必须优先命中仍带 active draft 的 live requirement topic;禁止因为新 topic `updated_at` 更近就把 idle query topic 误当成当前 requirement context。 +- 旧 `business_review/clarify` 或其它 query-side clarify 若已经失去显式锚点,而当前 turn 给出了新的 requirement seed / 新项目 / 新业务目标,runtime 必须允许 semantic refresh 并切到新的 requirement topic,禁止旧 clarify 继续锁死线程。 - 用户显式说“聊个新需求 / 另一个需求 / 跟上一个无关”时,必须先清掉继承的旧 `current_project_id`。 - 裸 `A/B/C/确认/你自行推荐` 这类低信息量回复,如果没有命中当前 active requirement session 或待确认草稿,必须明确提示当前没有进行中的收敛轮次或草稿。 - `draft_pending_confirm` 草稿确认链只允许由显式锚点进入:`reply/thread`、卡片按钮或显式 `work_item_id`。无锚点普通消息、闲聊、新需求或独立事实问句禁止再按“同群/同人”回捞旧 pending draft。 - 同一 `conversation_scope` 一旦存在 active `draft_pending_confirm` workspace,命中上述显式锚点的后续消息必须优先回到当前草稿确认链处理,支持确认/修改/重出/取消与展示反馈;即使动作未识别,也必须显式提示“当前正在处理待确认草稿”,禁止再新开 topic 或落回 `query.answer`。 - 当 `draft_pending_confirm` 命中显式 `reply/thread` 锚点时,mounted draft / pending draft 的候选集必须先通过该锚点精确命中;同群、同 scope、同人但锚点不匹配的历史草稿不得继续参与打分、布尔探测或 fallback 回捞,避免 requirement-plan 的 `A/B/C` 回复被旧 draft confirm 链劫持。 +- `draft_pending_confirm` 在 `ready / draft_pending_confirm` 状态下,无显式锚点的单个 `A/B/C/确认/取消/重出/修改` 不得继续恢复旧草稿确认链;这类 no-anchor 单句若没有当前 draft 的 `reply/thread`、卡片动作或 `work_item_id` 绑定,只能显式提示“当前没有进行中的待确认草稿”或回到普通 `query.answer`,禁止偷回旧 ready draft。 - 当同一 `conversation_scope` 内的旧 `requirement_plan` / `draft_pending_confirm` 已被判定为污染且当前消息不再显式锚定该线程时,ingress 必须先把 scope 内 active plan/draft topic 与待确认 work item 终止为 `suspended / cancelled`,并把会话回退到 `query.answer`;禁止残留 plan topic 继续吸收普通问句。 - 用户显式 `reply/thread` 某条已绑定回复并要求“基于当前信息出草稿”时,主链必须先尝试从该 reply 命中的 mounted context 升格生成 requirement-plan 草稿;若上下文不足,必须显式失败,禁止回落到 `query.answer` 冒充“草稿已生成”。 - 用户在显式 `reply/thread` 的同一需求线程里已经完成 `A/B/C` 选项收敛后,后续碎片化追问必须先尝试把该 reply 命中的 chat context 升格成 requirement-plan workspace,再走 `requirement_plan` 主链;禁止继续落到通用 `query.answer` 并把代码路径、内部函数名或工程证据泄漏到用户可见答案里。 @@ -109,13 +119,27 @@ - `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` 必须直接澄清,禁止混合答案。 - 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”。 +- `pending_clarification` 与 follow-up anchor selection 也不得回流到“第几条/标签名/关键词”本地解析:`mixin_followup_anchor.py` 只能通过统一结构化语义解析当前澄清回复,再把选中的 `query_route_selection` 或 `followup_anchor_selection` 显式映射回 `source_policy_hint / query_semantics_overrides / selected_qa_id`。澄清后若已选中上一轮候选 QA,主链必须把该 `selected_qa_id` 继续透传给 follow-up anchor resolver,禁止清掉原问题后再靠最近回复词面重猜。 +- internal `Operation Contract / Target Resolver` 也必须优先吃统一语义规划层透传下来的 business frame(至少包括 `goal_kind / target_scope / resource_role / project_binding / object_binding / expected_answer_shape`),再决定是 `project / task / signal / config / feature / doc / user` 哪类内部对象。`mixin_target_resolution.py` 不得再让 `subject_focus / referenced_objects` 这类 coarse 字段独自决定“是不是项目/是不是配置”;缺少 business frame 时只能显式 clarify / insufficient-evidence,而不是回退到粗粒度对象猜测。 +- internal object/domain clarify 也必须继续沿同一条结构化主链往下分层:当 `source_policy=internal` 已明确,但 `business_frame` 只落到大域(例如 `resource_role=config`、`feature` 或 `doc`)而尚未唯一命中内部对象时,运行时必须先通过 `internal_domain_catalog -> target_resolution(candidate_summary) -> clarification_protocol(kind=internal_target_selection)` 产出更深一层的受控澄清,再把 follow-up 选中的 `query_semantics_overrides` 回填主链并继续受控检索;禁止在澄清后复读旧答案,也禁止把泛化系统配置问题默认落成模型路由或其它单一 config 对象。 +- `AdaptiveRAG` read-side 的 deterministic query / fast answer / retrieval / deep guard / feature-evidence consumer 也必须遵守同一条 business-frame-first 规则:`mixin_deterministic_query_core.py`、`mixin_fast_answer_project_main.py`、`mixin_fast_answer_semantic_p1.py`、`mixin_deterministic_query_count.py`、`mixin_deterministic_query_list.py`、`mixin_retrieval_router_db.py`、`mixin_deep_guard_answer.py`、`mixin_answer_mainline.py`、`mixin_manifest_feature_p2.py` 只能消费 `business_frame + target_resolution + query_plan` 归一化后的 entity/project binding;禁止再让 `subject_focus / referenced_objects` 之类 coarse structured compatibility 字段决定 `project / task / signal / config / feature / doc / user` 的快答、检索或 deep guard 走向。 +- `PROMPT_RAG_QUERY_SEMANTICS` 与 `mixin_core_intent.py` 现在也必须保持同一条基线:上游 query semantics 只允许产出 `query_type / object_mentions / source_policy / effective_source_policy / retrieval_domains / scope_hint / feature_focus / query_profile(needs_private_context, needs_freshness, confidence) / business_frame`;不得再把 `subject_focus` 或 `query_profile.referenced_objects` 写回运行时真源、route snapshot 或 session scope。 +- `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 / analysis_packet / 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` 必须保留为可区分文案,禁止重新折叠成统一空兜底句。 +- Feishu signal pipeline 的 default capture / extraction 失败路径不得再靠 `_is_simple_signal_source_request`、项目名 regex、关键词分仓或 `_heuristic_*_fallback` 自行拍板项目/信号语义;运行时只能消费统一语义层给出的 `intent_gate / signal_intake_hint` 这类结构化 hint,若结构化提取失败则必须降级为低置信 `signal` 待确认 payload,只保留原始消息、空项目绑定和 customer-safe degraded reasoning。 +- Feishu `project.create` / contextual project prefill 也不得再靠 `根据这个/上面内容/项目名/范围` 这类中文关键词、regex 或 `heuristic_title/heuristic_scope` 本地推断项目字段;`mixin_intent_gate_core.py`、`mixin_context_prefill_a.py`、`mixin_context_prefill_b.py`、`mixin_serialization.py` 必须统一消费 `_extract_project_prefill()` 返回的结构化 `suggested_name / suggested_description / suggested_scope`,字段缺失时显式待确认,禁止再从原问题或最近回答词面硬凑项目范围。 +- `/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 / analysis_packet / 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。 +- `analysis_packet` 属于单独的 analysis layer,而不是 runtime truth 本身:builder 只允许消费 `ProjectOperatingContext / ExecutionRecipe / fit_brief / external_recommendation / InternalContextPacket / clarification state / answer contract` 等现有结构化真源,禁止从回答正文、presenter 文案或前台状态反推 packet。当前只允许四类 typed packet:`project_fit_analysis / external_impact_analysis / internal_explain_analysis / followup_explain_analysis`;共享 envelope 固定为 `packet_version / packet_type / conclusion / focus_objects(<=2) / next_step`。其中 richer packet 字段必须优先表达对象级分析:如 `current_subject / candidate_position / rejected_position / reason_chain / blocked_reason_or_gap`,禁止再让 presenter 用泛化标签句去脑补“更适合哪层”。AdaptiveRAG 与 Feishu 若需要 richer project snapshot,必须统一经 `analysis_project_snapshot_loader.py` 补 `tasks/signals + description/summary + analysis_object_refs`,禁止各 presenter 私自查询一套、解释一套。shared snapshot loader 现已 fail-closed:没有真实结构锚点时,不再从 task/signal 标题描述推导 `fit_candidate_* / fit_rejected_* / primary_*` 关系,也不再生成 `task_signal_scores / signal_task_links` 这类 token-overlap 产物。builder 现只消费显式 object ref、精确文本绑定和唯一候选这三类非语义结构证据;signal-only 项目若缺 task,也只能退到 signal ref 或诚实降级,禁止再吐“结果出来之后的解释层 / 当前主链”这类假对象。`project_fit_analysis / external_impact_analysis` 命中 `direct_match` 时,若没有 explicit dependent task ref,就只能保留 formal signal anchor,不得再靠标题/描述 overlap 把变化硬挂到某个 task。`internal_explain` 若缺少 `target_resolution`,也只允许精确文本绑定,不得回退到 partial-name/overlap 反绑。 +- `analysis_packet_builder.py` 在 sparse project-fit 场景下也不得再靠“解释层/当前主链/当前项目动作”这类占位词表决定是否降级;若 `preferred/rejected` 结构对象不存在,只能显式按“对象缺失”降级,再结合 `project_description / analysis_object_refs` 解释当前已知链路和缺口。 +- `analysis_packet_builder.py` 也不得再在 runtime 用 `_looks_like_engineering_task / _looks_like_signal_placeholder / _task_presentation_hint / token-overlap / partial-name match` 这类本地 second-guess 上游对象:工程任务 generic fallback、signal-only placeholder fallback、以及 external impact 是否能落到 dependent task,只能由显式 object ref、reply-anchor 继承下来的 object binding、精确文本绑定或唯一候选这几类结构证据决定;结构证据不足时必须显式降级,不能因为词面像“前端/部署/增加信号源/摘要卡片”就私自拍板。 +- Feishu QA 直出文本必须消费同一份结构化 handoff 字段,但呈现层必须先消费共享 `analysis_packet` 再允许自然化改写:`mixin_receive_routing.py` 先基于 `project_operating_context.current_project_id` 补当前项目实体快照,再调用共享 builder 产出 `analysis_packet`,之后才进入渠道级改写。自然化阶段只能围绕 packet 已选定的 `current_subject / focus_objects / candidate_position / rejected_position / reason_chain / missing_mapping(next gap)` 表达,禁止新增 packet 之外的项目实体;运行时必须同时拒绝 fixed opener、`当前项目:/外部判断:/下一步:` 标签摘要、项目实体清单平铺、blocked entity 泄漏,以及 `分析包 / analysis_packet / missing_mapping / next_step / focus_objects / reason_chain` 这类内部 packet 术语。若 shared packet、自然化输出或校验失败,必须 fail-closed 回退到 packet-based deterministic 文本;对 sparse project-fit 这类 packet,deterministic fallback 也必须转成业务中文解释,禁止把 `missing_mapping` 或“至少还缺一个业务步骤/信号源”这类内部字段原样吐给用户。`draft_pending_confirm / requirement_plan_followup / pending_manual` 这类显式模板键仍优先走 action card,且 `verifier_blocked / no_relevant_results / retrieval_failed / indirect_only` 必须保留为可区分文案,禁止重新折叠成统一空兜底句。 +- Feishu QA analysis fallback 也不得再靠 `_pick_feishu_entity_by_keywords` 或 `摘要/推送/拉取/清洗` 这类任务关键词去挑对象;fallback 只能复用共享 `analysis_packet.focus_objects`、当前项目实体目录和显式 object ref 的顺序提示,结构证据不足时只能退到最小 entity-order fallback,禁止 presenter 自己再做业务语义 second-guess。 +- Feishu `qa_direct` 还必须执行中文化 presentation policy:若原始 `direct_reply_text` 泄出 `Project database / signal_total / pending_signal_count / 当前盘面 / 建议动作 / External Freshness Guard` 这类英文或内部术语,呈现层必须优先回退到共享 packet 文本,并且最多只补一行中文来源说明(如 `来源:项目库`、`来源:项目库 + 外部公开资料(当前仍在校验)`);推荐外部信号源这类 recommendation ask 若已有推荐正文,则必须优先直出该推荐正文并追加同样的单行中文来源说明,禁止再落回 packet fallback 或英文来源标签。 +- external recommendation ask 若命中“推荐外部信号源/外部线索/外部信号”这类 recommendation-only 场景,`ExternalAnswerPresenter` 必须先产出结构化 `SignalSourceRecommendationPacket(candidates + caveat)`,再渲染业务中文;主路径目标是给出 3 个具体候选和各自业务价值,禁止只输出“先看上游输入/再看反馈来源”这类抽象架构话术。若结构化候选带过度确定性措辞、内部术语或数量不足,运行时必须 fail-closed 回退到 customer-safe fallback candidate,而不是继续把风险文案吐给用户。 - 飞书客户可见验收基线必须看“后端最终格式化输出”而不是内部 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` 锚点仍必须继续生效。 +- `analysis_packet` family 也必须带独立 rollout guard:`PMFLOW_ENABLE_ANALYSIS_PACKET_FAMILY=0` 时,只允许关闭 `analysis_packet` 的 payload 暴露与 Feishu packet presenter,回退到现有 handoff truth + 旧 presenter;不得因此回退 `project_operating_context / execution_recipe / fit_brief / external_recommendation` 真源或 requirement-plan/action-card 主链。 - 网页 `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. @@ -136,6 +160,13 @@ - 通用行业趋势/外部品牌模型 freshness 问答,禁止把项目图谱、Feature Catalog、内部文档或用户历史冒充成“外部事实”;若命中 live external `url/http(s)` 证据,必须优先基于外部结果回答;若实时外部检索为空或不足,才允许显式返回“当前未接入实时外部数据/暂无法确认最新结论”。 - external `query.answer` 在 `route_mode=external` 后必须继续进入受控外部查询主链:`External Query Planner -> External Query Gateway -> External Provider Registry -> MCP adapter 优先 -> bounded fallback live search -> external answer verifier`。AdaptiveRAG 运行时必须通过 `backend/app/services/external_query_service/` 统一执行,external probe / freshness fetch / external route retrieval 不得再绕回直接 DuckDuckGo HTML 抓取。大模型只允许产出结构化 `ExternalQueryPlan` 与 grounded answer,不得自由联网、自由调用工具或在无证据时直接给外部事实结论。 - external `query.answer` 在 verifier 之后必须继续生成 `ExternalContextPacket`,并由 `ExternalAnswerPresenter` 负责最终回答生成;presenter 只能消费问题文本、计划摘要、经 verifier 通过的外部证据摘要与外部来源摘要,禁止重新注入 `system_snapshot / user_snapshot / project_context / 内部 context_blocks`,也不得回流 `industry_trend / external_trend` 旧 scene prompt 混答。 +- “推荐外部信号源/优先关注哪几类外部输入”这类 recommendation ask 不得再按 freshness fact verification 统一拒答:只要已经绑定当前项目与 recommendation goal,就必须走 recommendation-only presenter 输出 `top-N candidates + why`,并显式标注“这是推荐,不是已验证事实”;禁止继续退回 `external_verifier_blocked / 当前不能直接推荐` 这类保守拒答模板。 +- `External Query Planner` 命中“推荐外部信号源”这类 recommendation ask 时,`search_queries` 必须优先围绕 `fit_brief.signal_source_objectives / signal_source_anchors` 这类当前项目任务目标与 formal object 锚点展开;structured planner 即使返回了更抽象的 `industry best practices / external data sources` 类 query,也不得把这些项目锚点查询整组覆盖掉。这个 guard 只允许作用在执行参数层,不得反向改写 route_mode、query_kind 或 formal object binding。 +- recommendation-only 外部问答的分支裁决必须继续留在统一语义规划层:`answer_control.effective_level=recommendation` 需要经 `source_policy_hint/query_semantics -> ExternalQueryPlan.interaction_level` 显式透传,`ExternalAnswerPresenter` 只能消费这个结构化层级与 recommendation goal/anchors 进入 recommendation presenter,不得再在 presenter 里靠中文 cue list、关键词仓或问句正则单独判断“这是不是推荐外部信号源”。 +- `ExternalAnswerPresenter` 命中“推荐外部信号源”问法且 `impact_status != direct_match` 时,若生成答案出现百分比、相关性数值、`已确认`、`无需额外对接验证` 等高确定性措辞,必须 fail-closed 回退到保守推荐文案;保守文案也必须优先引用 `fit_brief.signal_source_objectives` 里的项目任务目标,禁止再写固定三类模板或自造系统名。 +- recommendation 输出若已经生成 `SignalSourceRecommendationPacket`,就不能只把它当 prose 回复后丢掉;运行时必须同步沉出可 carry-over 的 candidate set 绑定(至少包含 `candidate_set_id / project_binding / candidate refs / materialization payload`),供后续 reply-anchor follow-up 继续复用。用户后续说“把刚才推荐的写进系统 / 把第一个加进去 / 确认入库”时,必须优先从这份结构化 candidate set 恢复对象,不得重新把上一轮自然语言推荐当成自由文本再猜一遍。 +- non-plan `signal_source.onboard` 若来自 recommendation carry-over 或显式 write intent,默认只允许走 `prepare_write -> confirm_write -> execute_write` 直写链;除非用户显式要求“先出草稿”或当前线程明确处于 `plan mode`,否则禁止再默认掉回 `draft_pending_confirm / requirement draft`。一旦 signal 已 materialize 且项目关联写成功,业务结果应视为 success,后续 signal-source onboarding/manual 状态只能通过 `post_process_status / signal_source_workflow.status` 暴露,不能把整条线程重新判成 `pending_manual` 漂移出主链。 +- 显式 `reply/thread` 到 recommendation reply 后的 follow-up confirm(如“确认入库”)必须优先复用上一轮 `action_contract / candidate_set_binding / candidate_refs / reply_anchor_qa_context`。若这些锚点已存在,运行时不得重新退回内部查询澄清、项目列表快答或无关系统配置问答;缺的只能是结构化对象绑定,而不是再从自由文本里重猜业务目标。 - `query_route_memories` 的 manager-only summary/list/detail 治理接口只允许做只读审计和人工排查:可以暴露 `reply_contract / probe_snapshot / query_profile / feedback_outcome` 等调试快照,但不得在线自动修改 route decision、阈值或 prompt。 - 若 `ExternalContextPacket` 内 `verified_evidence` 为空,或 verifier 未通过,外部回答必须显式返回“证据不足/需要澄清”语义;禁止为了给出完整回答而退回 legacy external trend prompt、内部检索结果或项目上下文补写结论。 - `External Query Planner` 的 fallback 只允许补齐 `search_queries / preferred_domains / location_hint / time_range_hint / result_limit` 等执行参数,不得再根据 `referenced_objects / subject_focus / needs_freshness` 在运行时把问题硬分成 `weather/news/company/market/model` 等语义类别;对象归类必须来自上游单次结构化语义或显式用户对象,若没有就保持通用 external 计划并把不确定性显式带入 verifier。 diff --git a/docs/superpowers/plans/2026-04-19-external-signal-recommendation-content-upgrade.md b/docs/superpowers/plans/2026-04-19-external-signal-recommendation-content-upgrade.md new file mode 100644 index 00000000..6d656eaf --- /dev/null +++ b/docs/superpowers/plans/2026-04-19-external-signal-recommendation-content-upgrade.md @@ -0,0 +1,176 @@ +# External Signal Recommendation Content Upgrade 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:** Make recommendation-only external signal-source answers produce concrete, business-natural candidates with reasons instead of abstract boundary talk. + +**Architecture:** Keep the new interaction-boundary routing intact, but add a recommendation content layer in `external_query_service` that first builds a structured recommendation packet and then renders user-facing natural Chinese. Route correctness stays in semantic planning; candidate quality and wording live in the recommendation packet and presenter. + +**Tech Stack:** Python 3.11, Pydantic, existing PMFlow LLM service, pytest + +--- + +### Task 1: Lock Business-Natural Recommendation Expectations + +**Files:** +- Modify: `backend/tests/test_external_query_planner.py` +- Modify: `backend/tests/test_feishu_card_action_contract.py` + +- [ ] **Step 1: Write failing tests for concrete recommendation candidates** + +Add tests that require: +- at least 3 concrete candidate signal-source types +- each candidate has a business reason +- different project contexts produce meaningfully different candidates +- answer text avoids architect jargon like “主链 / 这一层 / packet / analysis” + +- [ ] **Step 2: Run focused tests to verify they fail** + +Run: +```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_external_query_planner.py -k "signal_source_recommendation or recommendation" -q +``` + +Expected: FAIL on missing concrete candidates / wording assertions. + +- [ ] **Step 3: Add Feishu rendering red tests** + +Add a rendered-text test that requires final recommendation replies to look like business advice, not route/boundary analysis. + +- [ ] **Step 4: Run Feishu recommendation tests to verify they fail** + +Run: +```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_feishu_card_action_contract.py -k "recommendation" -q +``` + +Expected: FAIL on abstract / non-concrete output. + +### Task 2: Add Recommendation Packet Contracts + +**Files:** +- Modify: `backend/app/services/external_query_service/schemas.py` +- Modify: `backend/app/services/external_query_service/presenter.py` + +- [ ] **Step 1: Add structured recommendation candidate models** + +Define minimal packet models for: +- recommendation candidate name +- business value reason +- project relevance / watch point + +- [ ] **Step 2: Add packet validation tests implicitly through presenter tests** + +No separate schema-only test unless needed; keep packet surface small. + +### Task 3: Build Concrete Recommendation Candidates + +**Files:** +- Modify: `backend/app/services/external_query_service/presenter.py` +- Modify: `backend/app/config/ai_prompts_catalog/part_scenes_06.py` +- Modify: `backend/app/config/ai_prompts.py` +- Modify: `backend/app/config/ai_prompts_catalog/__init__.py` + +- [ ] **Step 1: Build a structured recommendation packet first** + +Implement presenter flow: +- use `generate_structured_object` when available +- grounded by project summary, recommendation goal, object anchors, objectives, and source summaries +- fallback to deterministic business-safe packet if structured generation fails + +- [ ] **Step 2: Render packet into natural business Chinese** + +Render to: +- one short lead sentence +- `1 / 2 / 3` concrete candidates +- each with a plain-language reason +- one short caveat that this is recommendation, not confirmed integration + +- [ ] **Step 3: Keep wording constraints** + +Reject or rewrite answers that: +- leak internal terms +- drift into architecture tone +- only say “信息不足 / 先不判断” + +- [ ] **Step 4: Run focused tests and make them pass** + +Run: +```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_external_query_planner.py backend/tests/test_feishu_card_action_contract.py -k "recommendation" -q +``` + +Expected: PASS + +### Task 4: Verify Project Variation and Runtime Safety + +**Files:** +- Modify: `backend/tests/test_external_query_planner.py` +- Modify: `backend/tests/test_feishu_card_action_contract.py` + +- [ ] **Step 1: Add cross-project variation checks** + +Use business scenarios such as: +- 自动开票系统 +- AI客服 +- 门店库存异常日报助手 + +Require that candidate sets differ in sensible ways. + +- [ ] **Step 2: Run broader affected backend slice** + +Run: +```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_external_query_planner.py backend/tests/test_feishu_card_action_contract.py backend/tests/test_ai_pmo_ingress_mainline.py -q +``` + +Expected: PASS + +### Task 5: Final Verification and Runtime Refresh + +**Files:** +- Modify if behavior/docs changed: `docs/architecture/RUNTIME_GUARDRAILS.md`, `docs/architecture/CODE_REFERENCE_INDEX.md`, `docs/architecture/ARCHITECTURE_INVENTORY.md`, `CLAUDE.md` + +- [ ] **Step 1: Run compile verification** + +Run: +```bash +cd /Users/yuuuu/Documents/AIProjectManger/.worktrees/codex/unified-project-ai-handoff && python3 -m py_compile backend/app/services/external_query_service/presenter.py backend/app/services/external_query_service/schemas.py +``` + +Expected: no output + +- [ ] **Step 2: Rebuild backend** + +Run: +```bash +cd /Users/yuuuu/Documents/AIProjectManger/.worktrees/codex/unified-project-ai-handoff && docker compose -p aiprojectmanger --env-file /Users/yuuuu/Documents/AIProjectManger/.env up -d --build backend +``` + +Expected: backend container recreated successfully + +- [ ] **Step 3: Check health** + +Run: +```bash +curl -s http://127.0.0.1:8000/healthz +``` + +Expected: +```json +{"code":0,"message":"success","data":{"status":"ok"}} +``` + +- [ ] **Step 4: Export business-style live IO samples** + +Capture 3-5 recommendation examples with: +- natural business inputs +- final model reply +- per-case score + +- [ ] **Step 5: Commit** + +```bash +git add backend/app/services/external_query_service backend/tests docs/architecture CLAUDE.md docs/superpowers/plans/2026-04-19-external-signal-recommendation-content-upgrade.md +git commit -m "feat: improve external signal recommendation content" +``` diff --git a/docs/superpowers/plans/2026-04-20-full-business-structuring-implementation-plan.md b/docs/superpowers/plans/2026-04-20-full-business-structuring-implementation-plan.md new file mode 100644 index 00000000..7818d1ae --- /dev/null +++ b/docs/superpowers/plans/2026-04-20-full-business-structuring-implementation-plan.md @@ -0,0 +1,470 @@ +# Full Business Structuring 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:** 把 PMFlow 的问答、推荐、澄清、闲聊和非 planmode 信号源写入升级成一套无硬性语义、可纠错、可停住、可直接执行的全量业务结构主链。 + +**Architecture:** 在现有 `InteractionBoundaryPacket / AnswerControlDecision` 基础上新增 `BusinessSemanticFrame / AnswerShapeContract / RecommendationCandidateSet / ClarificationDelta / OperationIntentContract`。统一语义规划层一次产出层级边界、业务目标、回答形状和操作意图;route/gateway/presenter/executor 只消费结构,不再各自用词面语义拍板。non-planmode 下,recommendation 后续动作默认走 `prepare_write -> confirm_write -> execute_write`,不再默认 `draft_pending_confirm`。 + +**Tech Stack:** Python 3.11, FastAPI, Pydantic v2, SQLAlchemy 2.x, AdaptiveRAG, AI PMO semantic planner, pytest + +--- + +### Task 1: Add Business Semantic Frame Contracts + +**Files:** +- Create: `backend/app/services/ai_pmo/semantic_context/business_frame_schema.py` +- Modify: `backend/app/services/ai_pmo/semantic_context/decision_schema.py` +- Modify: `backend/app/services/ai_pmo/semantic_context/__init__.py` +- Test: `backend/tests/test_business_semantic_frame_contract.py` + +- [ ] **Step 1: Write the failing schema contract tests** + +```python +def test_business_semantic_frame_supports_signal_source_recommendation(): + frame = BusinessSemanticFrame( + goal_kind="recommendation", + target_scope="current_project", + resource_role="signal_source", + source_kind="external", + expected_answer_shape="ranked_candidates", + conversation_posture="business_operating", + ) + assert frame.goal_kind == "recommendation" + assert frame.resource_role == "signal_source" + + +def test_operation_intent_contract_supports_non_plan_write_phases(): + contract = OperationIntentContract( + intent_phase="prepare_write", + requires_confirmation=True, + ) + assert contract.intent_phase == "prepare_write" +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: + +```bash +backend/venv/bin/python -m pytest backend/tests/test_business_semantic_frame_contract.py -q +``` + +Expected: FAIL because `BusinessSemanticFrame` / `OperationIntentContract` do not exist yet. + +- [ ] **Step 3: Add minimal schema definitions** + +Implement: +- `BusinessSemanticFrame` +- `AnswerShapeContract` +- `ClarificationDelta` +- `CorrectionDelta` +- `OperationIntentContract` + +Keep them in `business_frame_schema.py`, and only re-export them via `semantic_context/__init__.py`. + +- [ ] **Step 4: Re-run the tests** + +Run: + +```bash +backend/venv/bin/python -m pytest backend/tests/test_business_semantic_frame_contract.py -q +``` + +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add backend/app/services/ai_pmo/semantic_context/business_frame_schema.py \ + backend/app/services/ai_pmo/semantic_context/decision_schema.py \ + backend/app/services/ai_pmo/semantic_context/__init__.py \ + backend/tests/test_business_semantic_frame_contract.py +git commit -m "feat: add business semantic frame contracts" +``` + +### Task 2: Extend Planner Output With Structured Business Frame + +**Files:** +- Modify: `backend/app/services/ai_pmo/workflow_semantic_planner/service.py` +- Modify: `backend/app/services/ai_pmo/semantic_context/semantic_decision_service.py` +- Modify: `backend/app/services/ai_pmo/semantic_context/packet_schema.py` +- Test: `backend/tests/test_workflow_semantic_planner.py` +- Test: `backend/tests/test_semantic_decision_service.py` + +- [ ] **Step 1: Add failing planner tests for business-frame output** + +Add tests that assert: +- recommendation ask yields `goal_kind="recommendation"` +- current-project external signal-source ask yields: + - `resource_role="signal_source"` + - `source_kind="external"` + - `expected_answer_shape="ranked_candidates"` +- casual chat yields: + - `goal_kind="casual_chat"` + - `expected_answer_shape="social_reply"` + +- [ ] **Step 2: Run the planner tests to verify they fail** + +Run: + +```bash +backend/venv/bin/python -m pytest \ + backend/tests/test_workflow_semantic_planner.py \ + backend/tests/test_semantic_decision_service.py -k "business_frame or casual_chat" -q +``` + +Expected: FAIL because planner bundle does not yet populate the new frame. + +- [ ] **Step 3: Implement minimal planner/frame wiring** + +Update planner and semantic decision service so bundle output can carry: +- `interaction_boundary` +- `business_frame` +- `answer_shape_contract` +- `operation_intent_contract` + +Do not add keyword lists or regex routing. Consume planner-structured output only. + +- [ ] **Step 4: Re-run the planner tests** + +Run: + +```bash +backend/venv/bin/python -m pytest \ + backend/tests/test_workflow_semantic_planner.py \ + backend/tests/test_semantic_decision_service.py -k "business_frame or casual_chat" -q +``` + +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add backend/app/services/ai_pmo/workflow_semantic_planner/service.py \ + backend/app/services/ai_pmo/semantic_context/semantic_decision_service.py \ + backend/app/services/ai_pmo/semantic_context/packet_schema.py \ + backend/tests/test_workflow_semantic_planner.py \ + backend/tests/test_semantic_decision_service.py +git commit -m "feat: add structured business frame to planner bundles" +``` + +### Task 3: Persist Recommendation Candidate Sets + +**Files:** +- 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/presenter.py` +- Modify: `backend/app/services/adaptive_rag_service/mixin_answer_payload.py` +- Test: `backend/tests/test_external_query_planner.py` +- Test: `backend/tests/test_ai_pmo_qa_http_mainline_e2e.py` + +- [ ] **Step 1: Add failing tests for durable candidate sets** + +Add tests that assert recommendation answers now produce: +- `candidate_set_id` +- `project_binding` +- at least 3 `candidates[]` +- each candidate includes: + - `candidate_ref` + - `display_name` + - `business_value` + - `why_now` + +- [ ] **Step 2: Run the recommendation tests to verify they fail** + +Run: + +```bash +backend/venv/bin/python -m pytest \ + backend/tests/test_external_query_planner.py \ + backend/tests/test_ai_pmo_qa_http_mainline_e2e.py -k "candidate_set or recommendation" -q +``` + +Expected: FAIL because recommendation output is still mainly presentational. + +- [ ] **Step 3: Implement minimal candidate-set persistence** + +Update external-query schemas/planner/payload so recommendation results carry a structured `RecommendationCandidateSet`. + +The presenter should still render business Chinese, but payload must now preserve machine-consumable candidate refs for follow-up writes. + +- [ ] **Step 4: Re-run the recommendation tests** + +Run: + +```bash +backend/venv/bin/python -m pytest \ + backend/tests/test_external_query_planner.py \ + backend/tests/test_ai_pmo_qa_http_mainline_e2e.py -k "candidate_set or recommendation" -q +``` + +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add backend/app/services/external_query_service/schemas.py \ + backend/app/services/external_query_service/planner.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_ai_pmo_qa_http_mainline_e2e.py +git commit -m "feat: persist recommendation candidate sets" +``` + +### Task 4: Make Clarification And Correction Update Structured Truth + +**Files:** +- Modify: `backend/app/services/adaptive_rag_service/mixin_session_state.py` +- Modify: `backend/app/services/adaptive_rag_service/mixin_followup_anchor.py` +- Modify: `backend/app/services/adaptive_rag_service/mixin_answer_mainline.py` +- Test: `backend/tests/test_adaptive_rag_external_followup_contract.py` +- Test: `backend/tests/test_adaptive_rag_mainline_contract.py` + +- [ ] **Step 1: Add failing correction/clarify tests** + +Add tests that assert follow-up messages like: +- `不是现状,我要推荐` +- `不是这个项目,是 AI客服` +- `别起草,现在直接写入` + +cause structured updates to: +- `goal_kind` +- `expected_answer_shape` +- `project_binding` +- `candidate_set_binding` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: + +```bash +backend/venv/bin/python -m pytest \ + backend/tests/test_adaptive_rag_external_followup_contract.py \ + backend/tests/test_adaptive_rag_mainline_contract.py -k "clarify or correction or candidate_set" -q +``` + +Expected: FAIL because current logic updates pending/session state more than structural truth. + +- [ ] **Step 3: Implement structural deltas** + +Add `ClarificationDelta / CorrectionDelta` handling so clarification/correction rewrites the current business frame rather than only appending chat-state hints. + +- [ ] **Step 4: Re-run the tests** + +Run: + +```bash +backend/venv/bin/python -m pytest \ + backend/tests/test_adaptive_rag_external_followup_contract.py \ + backend/tests/test_adaptive_rag_mainline_contract.py -k "clarify or correction or candidate_set" -q +``` + +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add backend/app/services/adaptive_rag_service/mixin_session_state.py \ + backend/app/services/adaptive_rag_service/mixin_followup_anchor.py \ + backend/app/services/adaptive_rag_service/mixin_answer_mainline.py \ + backend/tests/test_adaptive_rag_external_followup_contract.py \ + backend/tests/test_adaptive_rag_mainline_contract.py +git commit -m "feat: apply structural deltas for clarification and correction" +``` + +### Task 5: Split Non-Planmode Direct Write From Drafting + +**Files:** +- Modify: `backend/app/services/ai_pmo/ai_pmo_typed_executor_execute/mixin_execute_query_project.py` +- Modify: `backend/app/services/ai_pmo/ai_pmo_typed_executor_execute/mixin_execute_signal_source.py` +- Modify: `backend/app/services/adaptive_rag_service/mixin_query_route_gateway.py` +- Test: `backend/tests/test_ai_pmo_ingress_mainline.py` +- Test: `backend/tests/test_feishu_pipeline_queue_contract.py` +- Test: `backend/tests/test_feishu_card_action_contract.py` + +- [ ] **Step 1: Add failing tests for non-plan write flow** + +Add tests that assert: +- non-plan recommendation follow-up `把推荐的写入系统` enters `prepare_write`, not `draft_pending_confirm` +- `确认入库` binds to the pending write object, not a fresh unrelated query +- queue ack for this path is a write-confirmation, not a requirement draft ack + +- [ ] **Step 2: Run the non-plan write tests to verify they fail** + +Run: + +```bash +backend/venv/bin/python -m pytest \ + backend/tests/test_ai_pmo_ingress_mainline.py \ + backend/tests/test_feishu_pipeline_queue_contract.py \ + backend/tests/test_feishu_card_action_contract.py -k "prepare_write or confirm_write or execute_write or non_plan" -q +``` + +Expected: FAIL because current runtime still defaults to draft-ish handling or loses confirm binding. + +- [ ] **Step 3: Implement `prepare_write -> confirm_write -> execute_write`** + +Update query/signal-source execution path so: +- outside plan mode, signal-source writes do not default to draft +- explicit recommendation follow-ups use candidate-set bindings +- confirm messages resolve the pending write object through strong anchors + +- [ ] **Step 4: Re-run the non-plan write tests** + +Run: + +```bash +backend/venv/bin/python -m pytest \ + backend/tests/test_ai_pmo_ingress_mainline.py \ + backend/tests/test_feishu_pipeline_queue_contract.py \ + backend/tests/test_feishu_card_action_contract.py -k "prepare_write or confirm_write or execute_write or non_plan" -q +``` + +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add backend/app/services/ai_pmo/ai_pmo_typed_executor_execute/mixin_execute_query_project.py \ + backend/app/services/ai_pmo/ai_pmo_typed_executor_execute/mixin_execute_signal_source.py \ + backend/app/services/adaptive_rag_service/mixin_query_route_gateway.py \ + backend/tests/test_ai_pmo_ingress_mainline.py \ + backend/tests/test_feishu_pipeline_queue_contract.py \ + backend/tests/test_feishu_card_action_contract.py +git commit -m "feat: add non-plan signal-source write confirmation flow" +``` + +### Task 6: Fold Casual Chat Into Structured Runtime + +**Files:** +- Modify: `backend/app/services/ai_pmo/ai_pmo_typed_executor_execute/mixin_execute_query_project.py` +- Modify: `backend/app/services/ai_pmo/workflow_semantic_planner/service.py` +- Test: `backend/tests/test_ai_pmo_ingress_mainline.py` +- Test: `backend/tests/test_feishu_pipeline_queue_contract.py` + +- [ ] **Step 1: Add failing casual-chat tests** + +Add tests that assert: +- greetings produce `goal_kind="casual_chat"` and `expected_answer_shape="social_reply"` +- casual chat no longer depends on local lexical semantic shortcuts +- switching from casual chat back to business rebuilds business frame cleanly + +- [ ] **Step 2: Run the casual-chat tests to verify they fail** + +Run: + +```bash +backend/venv/bin/python -m pytest \ + backend/tests/test_ai_pmo_ingress_mainline.py \ + backend/tests/test_feishu_pipeline_queue_contract.py -k "casual_chat or social_reply" -q +``` + +Expected: FAIL because current direct shortcut logic is still present. + +- [ ] **Step 3: Implement structured casual-chat posture** + +Replace executor-local semantic shortcuts with planner-fed `goal_kind="casual_chat"` / `expected_answer_shape="social_reply"` handling. + +- [ ] **Step 4: Re-run the casual-chat tests** + +Run: + +```bash +backend/venv/bin/python -m pytest \ + backend/tests/test_ai_pmo_ingress_mainline.py \ + backend/tests/test_feishu_pipeline_queue_contract.py -k "casual_chat or social_reply" -q +``` + +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add backend/app/services/ai_pmo/ai_pmo_typed_executor_execute/mixin_execute_query_project.py \ + backend/app/services/ai_pmo/workflow_semantic_planner/service.py \ + backend/tests/test_ai_pmo_ingress_mainline.py \ + backend/tests/test_feishu_pipeline_queue_contract.py +git commit -m "feat: route casual chat through structured business frame" +``` + +### Task 7: Build Broad Scenario Regression Matrix And Docs Sync + +**Files:** +- Modify: `backend/tests/test_ai_pmo_ingress_mainline.py` +- Modify: `backend/tests/test_ai_pmo_qa_http_mainline_e2e.py` +- Modify: `backend/tests/test_external_query_planner.py` +- Modify: `backend/tests/test_feishu_card_action_contract.py` +- Modify: `docs/architecture/RUNTIME_GUARDRAILS.md` +- Modify: `docs/architecture/CODE_REFERENCE_INDEX.md` +- Modify: `docs/architecture/ARCHITECTURE_INVENTORY.md` +- Modify: `CLAUDE.md` + +- [ ] **Step 1: Add failing broad-matrix tests for the highest-risk cases** + +Select at least these first: +- recommendation stays recommendation +- recommendation survives correction +- recommendation feeds direct write +- confirm binds to prior write object +- casual chat and business switch do not pollute each other +- sparse project still produces bounded business reasoning + +- [ ] **Step 2: Run the matrix subset to verify it fails where expected** + +Run: + +```bash +backend/venv/bin/python -m pytest \ + backend/tests/test_ai_pmo_ingress_mainline.py \ + backend/tests/test_ai_pmo_qa_http_mainline_e2e.py \ + backend/tests/test_external_query_planner.py \ + backend/tests/test_feishu_card_action_contract.py -k "recommendation or confirm_write or casual_chat or sparse" -q +``` + +Expected: mixed FAIL until prior tasks are complete. + +- [ ] **Step 3: Finish doc sync** + +Update runtime docs to reflect: +- no hard semantics / no regex semantic routing +- BusinessSemanticFrame and CandidateSet truth sources +- non-planmode direct write path +- broad scenario matrix as the new quality baseline + +- [ ] **Step 4: Re-run the matrix subset and py_compile** + +Run: + +```bash +backend/venv/bin/python -m pytest \ + backend/tests/test_ai_pmo_ingress_mainline.py \ + backend/tests/test_ai_pmo_qa_http_mainline_e2e.py \ + backend/tests/test_external_query_planner.py \ + backend/tests/test_feishu_card_action_contract.py -k "recommendation or confirm_write or casual_chat or sparse" -q + +backend/venv/bin/python -m py_compile \ + backend/app/services/ai_pmo/semantic_context/business_frame_schema.py \ + backend/app/services/ai_pmo/workflow_semantic_planner/service.py \ + backend/app/services/adaptive_rag_service/mixin_query_route_gateway.py \ + backend/app/services/ai_pmo/ai_pmo_typed_executor_execute/mixin_execute_query_project.py \ + backend/app/services/ai_pmo/ai_pmo_typed_executor_execute/mixin_execute_signal_source.py \ + backend/app/services/external_query_service/planner.py +``` + +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add backend/tests/test_ai_pmo_ingress_mainline.py \ + backend/tests/test_ai_pmo_qa_http_mainline_e2e.py \ + backend/tests/test_external_query_planner.py \ + backend/tests/test_feishu_card_action_contract.py \ + docs/architecture/RUNTIME_GUARDRAILS.md \ + docs/architecture/CODE_REFERENCE_INDEX.md \ + docs/architecture/ARCHITECTURE_INVENTORY.md \ + CLAUDE.md +git commit -m "test: add full-business structuring regression matrix" +``` diff --git a/docs/superpowers/specs/2026-04-17-feishu-problem-inventory.md b/docs/superpowers/specs/2026-04-17-feishu-problem-inventory.md index fc8c6e01..f14b8c16 100644 --- a/docs/superpowers/specs/2026-04-17-feishu-problem-inventory.md +++ b/docs/superpowers/specs/2026-04-17-feishu-problem-inventory.md @@ -14,6 +14,10 @@ - [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) +若要看这轮 `analysis_packet family` 单独收口出来的高标准新案例,请看: + +- [2026-04-19-analysis-packet-golden12-v2.md](/Users/yuuuu/Documents/AIProjectManger/.worktrees/codex/unified-project-ai-handoff/docs/superpowers/specs/2026-04-19-analysis-packet-golden12-v2.md) + ## Baseline Snapshot - 记录时间:2026-04-17 diff --git a/docs/superpowers/specs/2026-04-19-analysis-packet-golden12-v2.md b/docs/superpowers/specs/2026-04-19-analysis-packet-golden12-v2.md new file mode 100644 index 00000000..a6355156 --- /dev/null +++ b/docs/superpowers/specs/2026-04-19-analysis-packet-golden12-v2.md @@ -0,0 +1,150 @@ +# Analysis Packet Family 黄金 12 条案例(V2) + +## 目的 + +这份文档是给 `analysis_packet family` 单独准备的高标准黄金案例,不再复用旧那批偏空、偏合同向的样例。 + +这 12 条案例只做一件事: + +- 验证 `analysis_project_snapshot_loader -> analysis_packet_builder -> Web/Feishu presenter` + 这条链,是否真的围绕项目对象在分析,而不是“字段合法 + 模板句子通了”。 + +对应自动化真源: + +- [backend/tests/test_analysis_packet_golden_v2.py](/Users/yuuuu/Documents/AIProjectManger/.worktrees/codex/unified-project-ai-handoff/backend/tests/test_analysis_packet_golden_v2.py) + +## 高标准判定 + +每条案例都必须同时满足: + +1. 输入里有明确项目对象或上下文锚点,不接受只有 `这个适合吗` 这种空问句。 +2. `analysis_packet` 必须命中正确 `packet_type`,并且 `focus_objects` 只围绕最相关的 1-2 个对象。 +3. 若 snapshot 已给显式 object ref,builder 必须优先吃 ref,不能再靠模糊 task 名称或 partial match 乱选。 +4. 最终客户可见文本要像“围绕本项目对象做判断”,不能退回: + - `当前项目:/外部判断:/下一步:` + - `先说我的判断` + - `status_summary` + - `外部推荐` + - `证据质量` +5. 若文本里出现了当前 packet 没选中的对象,判失败。 + +## 黄金 12 条 + +### 01. 项目适配判断:库存上报项目里的 Responses API + +- 输入:`OpenAI Responses API 放到采购经理每日实时库存上报服务里,适合放在哪一层?` +- 预期 packet:`project_fit_analysis` +- 预期对象: + - better fit:`每日固定时段生成并推送结构化摘要卡片` + - not fit:`WMS库存快照自动拉取与清洗` +- 预期结论:明确区分“解释层”与“确定性主链” + +### 02. 项目适配判断:generic task title 也要按 ref 选对 + +- 输入:`这类能力放到库存异常播报助手里,应该落在哪步?` +- 预期 packet:`project_fit_analysis` +- 预期对象: + - better fit:`任务B` + - not fit:`任务A` +- 预期重点:不能因为 task title 太泛就乱选,必须尊重显式 `fit_candidate_task_ref / fit_rejected_task_ref` + +### 03. 项目适配判断:已 direct match 的 fit 结论 + +- 输入:`这条外部变化已经打到我们库存项目上了吗?` +- 预期 packet:`project_fit_analysis` +- 预期对象顺序: + - dependent:`WMS库存快照自动拉取与清洗` + - anchor:`外部信号源 - WMS实时库存接口` +- 预期结论:先落业务 task,再补 formal anchor + +### 04. 外部 blocked:卡在哪条线索上 + +- 输入:`这个外部变化现在能直接挂到项目结论吗?` +- 预期 packet:`external_impact_analysis` +- 预期对象: + - blocked:`外部信号源 - WMS实时库存接口` + - dependent:`每日固定时段生成并推送结构化摘要卡片` +- 预期结论:明确说卡在 signal 线索上,下一步是“补齐到可校验”,不能说“修正证据质量 / 继续外部推荐” + +### 05. 外部 direct match:先看受影响业务 task + +- 输入:`这个变化是不是已经直接打到当前项目对象上了?` +- 预期 packet:`external_impact_analysis` +- 预期对象顺序: + - dependent:`WMS库存快照自动拉取与清洗` + - anchor:`外部信号源 - WMS实时库存接口` +- 预期结论:不是 anchor-first + +### 06. 外部 indirect only:先试解释层,不动主链 + +- 输入:`这个外部能力现在和我们项目是什么关系?` +- 预期 packet:`external_impact_analysis` +- 预期对象: + - better fit:`每日固定时段生成并推送结构化摘要卡片` + - not fit:`WMS库存快照自动拉取与清洗` +- 预期结论:仍是背景相关,不能直接挂主链 + +### 07. 内部 explain:模型路由现状 + +- 输入:`现在活动引擎是什么?` +- 预期 packet:`internal_explain_analysis` +- 预期对象:`模型路由配置` +- 预期结论:说“这轮问的是当前状态,所以直接答现状”,不能把 `status_summary` 这类 contract 字样直接暴露给用户 + +### 08. 内部 explain:现状 + 仍有一层未确认 + +- 输入:`当前活动引擎是什么,已经切到最新灰度了吗?` +- 预期 packet:`internal_explain_analysis` +- 预期对象:`模型路由配置` +- 预期结论:先说现状,再单独指出“还有一层没完全确认” + +### 09. follow-up explain:路由澄清 + +- 输入:`你是想问外部公开事实,还是系统内部配置/项目对象?` +- 预期 packet:`followup_explain_analysis` +- 预期重点: + - `anchor_kind=reply_thread` + - `clarification_state=query_route_clarify` + - 最终文本要保留“需要先确认查询来源”这个结论 + +### 10. Feishu 文本呈现:fit indirect only + +- 输入:与 Case 01 同构,但验的是最终 `qa_direct` 文本 +- 预期输出: + - 命中 `每日固定时段生成并推送结构化摘要卡片` + - 命中 `WMS库存快照自动拉取与清洗` + - 不出现 `当前项目:/外部判断:/下一步:` + +### 11. Feishu 文本呈现:verifier blocked + +- 输入:与 Case 04 同构,但验的是最终 `qa_direct` 文本 +- 预期输出: + - 命中 `补齐到可校验` + - 命中被影响 task + - 不出现 `外部推荐 / 证据质量` + +### 12. Feishu 文本呈现:internal status + +- 输入:与 Case 07 同构,但验的是最终 `qa_direct` 文本 +- 预期输出: + - 命中 `模型路由配置` + - 不出现 `status_summary / contract / 泛化建议` + +## 验证方式 + +最小自动化入口: + +```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_analysis_project_snapshot_loader.py \ + backend/tests/test_analysis_packet_contract.py \ + backend/tests/test_feishu_card_action_contract.py \ + backend/tests/test_analysis_packet_golden_v2.py -q +``` + +通过标准: + +- 12 条新黄金案例全部通过 +- 不能只证明 packet/schema 合法 +- 必须同时证明最终客户可见文本也通过禁词/禁模板/对象命中校验 diff --git a/docs/superpowers/specs/2026-04-19-interaction-boundary-four-level-contract-design.md b/docs/superpowers/specs/2026-04-19-interaction-boundary-four-level-contract-design.md new file mode 100644 index 00000000..92104eae --- /dev/null +++ b/docs/superpowers/specs/2026-04-19-interaction-boundary-four-level-contract-design.md @@ -0,0 +1,648 @@ +# Interaction Boundary 与四挡合同设计 + +## 目的 + +这份设计要解决的不是“模型再聪明一点”,而是把 PMFlow 当前问答/建议/草稿/执行之间的边界收干净: + +- `status` +- `recommendation` +- `draft` +- `execute` + +当前问题不是系统不会回答,而是系统没有一个正式的“本轮允许做到哪一挡、明确禁止升到哪一挡”的运行时合同,导致 recommendation ask 会误滑到 `signal_source.onboard -> draft_pending_confirm`。 + +本设计的目标是: + +1. 四挡独立,不能偷偷升级。 +2. 每一挡都支持澄清、纠错、再次思考。 +3. recommendation 必须能独立停住,不再被写链路抢走。 +4. 用户可见回复必须自然业务化,不像架构师,也不能泄漏 packet / contract 术语。 +5. 今天与昨天暴露出来的失败场景,必须转成自动化反例。 + +## 这次不做什么 + +- 不重写 `requirement_plan` / action card 主链。 +- 不重构 `SignalSourceOrchestrationService` 业务编排本体。 +- 不把前台所有文风都做一轮大翻修。 +- 不引入新的中文关键词仓或正则仓去拍板 `query vs draft vs execute`。 + +## 当前已暴露的问题 + +### 1. recommendation ask 被错升成 onboard / draft + +真实用户输入: + +```text +我看这个项目现在还没有成型的信号源。你先别出草稿,只按当前项目情况,帮我推荐几个最值得接的外部信号源,并说清楚各自的价值。 +``` + +真实结果: + +- `intent = signal_source.onboard` +- `state = draft_pending_confirm` +- 先发 `⏳ 已完成入库,当前进入待确认队列。` + +这说明: + +- recommendation 与 draft 没有分层 +- 负向边界 `先别出草稿` 没生效 +- executor 链被过早放行 + +### 2. recommendation ask 被答成现状核查 + +用户明明在问“推荐几个外部信号”,系统却回: + +- 当前没有信号源 +- 当前项目没有记录 +- 当前盘面怎样 + +这说明: + +- `status` 与 `recommendation` 没分开 + +### 3. recommendation ask 被答成保守拒答 + +用户问: + +```text +如果只考虑外部信号源,你会优先推荐哪 3 类? +``` + +系统回: + +- 当前不能直接推荐 +- 当前证据不足 + +这说明: + +- recommendation 被误当成 freshness / fact verification + +### 4. 稀疏项目只会打太极 + +例如 `自动开票系统` 只有一句描述时,系统长期只会回: + +- 还缺业务步骤 +- 还缺信号源 +- 先别硬判 + +这类话术不算真正分析,只是保守拒答。 + +### 5. 客户可见回复泄漏内部术语 + +已经出现过: + +- `分析包里明确写了` +- `missing_mapping` +- `focus_objects` +- `analysis_packet` + +这说明: + +- packet / presenter 边界没有守住 + +### 6. 新项目 / 新业务切换被旧线程污染 + +例如上一轮在聊 `测试产能警告`,下一轮问 `AI客服这个项目...`,却继续沿旧 subject / old scope 改写。 + +这说明: + +- scope 连续性虽然已经补了治理,但还没有成为正式验收基线 + +## 设计原则 + +### 1. 先定边界,再决定动作 + +系统不能只理解“用户想干什么”,还必须理解“用户明确不要我干什么”。 + +### 2. 不追求一次答对,追求可纠错 + +系统要做到: + +- 不越权 +- 可停住 +- 可被纠正 +- 纠正后能原地重算 + +### 3. 结构化边界优先于运行时猜测 + +决策权不应该在: + +- `if "先别出草稿" in text` +- `if "不要执行" in text` + +而应该在: + +- 统一语义规划层结构化产出的边界合同 +- gatekeeper 对结构合同的执行 + +### 4. recommendation 是独立主链,不是 query 的弱版也不是 draft 的前奏 + +只要用户要的是建议,就应该允许系统: + +- 给候选 +- 给排序 +- 给理由 + +但不能: + +- 冒充已验证事实 +- 顺手起草 +- 顺手执行 + +### 5. 每一挡都必须有澄清、纠错、再次思考 + +这三件事不是失败兜底,而是系统能力的一部分。 + +## 新增运行时结构 + +### A. InteractionBoundaryPacket + +职责:表达“这一轮允许做到哪一挡、禁止做到哪一挡”。 + +建议结构: + +```python +class InteractionBoundaryPacket(BaseModel): + boundary_version: str = "v1" + + requested_level: Literal[ + "status", + "recommendation", + "draft", + "execute", + "unclear", + ] = "unclear" + + forbidden_levels: list[ + Literal["status", "recommendation", "draft", "execute"] + ] = Field(default_factory=list) + + allow_status: bool = True + allow_recommendation: bool = True + allow_draft: bool = False + allow_execute: bool = False + allow_write: bool = False + + needs_clarification: bool = False + clarification_reason: str = "" + clarification_questions: list[str] = Field(default_factory=list) + + correction_allowed: bool = True + correction_targets: list[ + Literal["project", "object", "level", "priority", "assumption"] + ] = Field(default_factory=list) + + self_review_required: bool = True + + confidence: float = 0.0 + evidence: list[dict[str, Any]] = Field(default_factory=list) +``` + +这不是给用户看的,而是给 gatekeeper 用的。 + +### B. AnswerControlDecision + +职责:把 `SemanticDecision + InteractionBoundaryPacket` 合成真正能执行的层级结果。 + +建议结构: + +```python +class AnswerControlDecision(BaseModel): + effective_level: Literal[ + "status", + "recommendation", + "draft", + "execute", + "clarify", + ] + + blocked_levels: list[str] = Field(default_factory=list) + blocked_workflows: list[str] = Field(default_factory=list) + allowed_workflows: list[str] = Field(default_factory=list) + + downgraded_from: str | None = None + requires_confirmation: bool = False + requires_self_review: bool = True + requires_correction_slot: bool = True + + reason: str = "" +``` + +## 四挡定义 + +### 1. status + +定义: + +- 只回答当前现状 +- 不给建议 +- 不起草 +- 不执行 + +允许: + +- 当前已知内容 +- 当前未知内容 +- 当前只看到哪些记录 + +禁止: + +- 偷偷给候选建议 +- 偷偷起草或执行 + +### 2. recommendation + +定义: + +- 可以给候选、排序、理由 +- 不能起草 +- 不能执行 +- 必须明确“这是建议,不是已验证事实” + +### 3. draft + +定义: + +- 可以起草一版 +- 必须带假设、缺口、待确认点 +- 不能执行 + +### 4. execute + +定义: + +- 必须显式确认 +- 可以落库 / 接入 / 执行 +- 必须回显要动哪个对象、影响范围和执行结果 + +## 决策顺序 + +后续实现必须按这个顺序: + +1. 现有 `WorkflowSemanticPlanner` 继续产出 `SemanticDecision` +2. 同轮补产出 `InteractionBoundaryPacket` +3. 把 `workflow_plans` 映射到四挡 +4. 用 boundary packet 过滤 workflow +5. 产出 `AnswerControlDecision` +6. 再决定最终主链: + - `query.answer(status)` + - `query.answer(recommendation)` + - `draft_pending_confirm` + - `execute` + +也就是说,`signal_source_flow` 可以被识别出来,但如果: + +- `allow_draft = false` +- `allow_execute = false` + +最终仍然只能落到: + +- `query.answer(recommendation)` + +## 负向边界优先级 + +必须明确: + +1. 负向边界优先于系统自己的升级冲动 +2. 负向边界低于用户后续显式升级确认 + +例如: + +### 场景 A + +```text +先别出草稿,只给我推荐几个高价值外部信号源。 +``` + +预期: + +- recommendation 可以回答 +- draft / execute 必须被 block + +### 场景 B + +后续用户再说: + +```text +可以,把第 1 个起草成接入草稿。 +``` + +预期: + +- 显式升级到 draft +- 仍不自动 execute + +## 澄清、纠错、再次思考 + +### 1. 澄清 + +每一挡都允许澄清。 + +适用情况: + +- 项目不清 +- 业务对象不清 +- 用户到底要 status / recommendation / draft / execute 不清 +- 当前约束冲突 + +需要结构化字段: + +- `needs_clarification` +- `clarification_reason` +- `clarification_questions` + +### 2. 纠错 + +用户必须能低成本纠错: + +- 不是这个项目 +- 不是这个对象 +- 不是这个方向 +- 我现在不是要草稿,只要建议 +- 我改了,现在直接出草稿 + +要求: + +- 收到纠错后,必须重建新的 `InteractionBoundaryPacket` +- 不准带旧边界续写 +- 输出里应承认:已按新纠正口径重算 + +### 3. 再次思考 / 自检 + +建议新增: + +```python +class SelfReviewResult(BaseModel): + project_aligned: bool = True + object_aligned: bool = True + level_aligned: bool = True + leaked_internal_terms: bool = False + overstepped_boundary: bool = False + clarify_missing: bool = False + notes: list[str] = Field(default_factory=list) +``` + +至少要检查: + +- 我是不是答错项目了 +- 我是不是把 recommendation 偷偷升级成 draft 了 +- 我是不是泄漏内部术语了 +- 我是不是没有分析,只是在说“信息不足” +- 我是不是漏了关键澄清点 + +自检失败时: + +- 降级 +- 或补澄清 +- 或阻止出站 + +## 用户可见回复骨架 + +四挡统一成三段式: + +1. 当前判断 +2. 理由 / 缺口 +3. 纠错或升级入口 + +### recommendation 例子 + +```text +如果只是先给建议,我会先看这 3 类外部信号: + +1. xxx,因为... +2. yyy,因为... +3. zzz,因为... + +这些是候选建议,不代表已经接入,也还没完成验证。 +如果方向不对,你可以直接改项目对象或业务目标;如果你要,我也可以下一步只把第 1 个起草成草稿。 +``` + +### sparse 项目例子 + +```text +自动开票系统这个项目,我现在先不硬说它该落在哪一步。 + +我现在只看得到它的大致目标:收到开票请求后自动开票,并把发票图片发出去。但还看不到这条链路里谁触发、接什么输入、哪个结果算真正跑通。 + +如果你愿意,我们先把这条流程拆开:谁发起、系统接什么、最后产出什么,再往下判断最合适的信号放在哪。 +``` + +## 高标准黄金案例(业务化 V3) + +这批案例要替换掉旧的“架构味太重 / 问法太空”的样例。问题必须像业务同事会问的话。 + +### 01. 自动开票系统 / status + +- 输入:`自动开票系统现在最容易卡在哪一步?你按现在项目情况直接说。` +- 预期层级:`status` +- 预期:不出建议,不出草稿,只说明当前最容易卡的链路或当前缺口 + +### 02. 自动开票系统 / recommendation + +- 输入:`自动开票系统除了现在这些数据,你觉得还应该盯哪些外部信号?先给建议,不要起草。` +- 预期层级:`recommendation` +- 预期:给 3 个候选信号 + 理由,不进 draft + +### 03. AI客服 / status + +- 输入:`AI客服这个项目现在主要靠什么数据来做用户洞察?` +- 预期层级:`status` +- 预期:围绕当前数据源或已知对象回答,不偷换成推荐 + +### 04. AI客服 / recommendation + +- 输入:`AI客服这个项目,除了现在这些数据,你觉得还应该补哪几类外部线索?直接说最有用的几个。` +- 预期层级:`recommendation` +- 预期:给候选 + 业务理由 + +### 05. 门店库存异常日报助手 / recommendation + +- 输入:`这个项目如果要更早发现库存异常,你会先多看哪几类外部信号?先给建议,不要出方案。` +- 预期层级:`recommendation` +- 预期:业务化候选,不保守拒答 + +### 06. 直播复盘辅助工具 / recommendation + +- 输入:`这个项目如果只补一个外部信号,你觉得先补哪个最值?直接说原因。` +- 预期层级:`recommendation` +- 预期:优先级明确,不起草 + +### 07. 测试产能警告 / recommendation with negative boundary + +- 输入:`我看这个项目现在还没有成型的信号源。你先别出草稿,只按当前项目情况,帮我推荐几个最值得接的外部信号源,并说清楚各自的价值。` +- 预期层级:`recommendation` +- 预期:绝不发队列 ack、绝不建 draft + +### 08. 测试产能警告 / explicit upgrade to draft + +- 前置:上一轮 recommendation 已返回 +- 输入:`可以,把第一个整理成接入草稿给我看。` +- 预期层级:`draft` +- 预期:允许升级,但不 execute + +### 09. 确认立项 / internal status + +- 输入:`确认立项现在 assignee 推荐做到哪了?` +- 预期层级:`status` +- 预期:回答当前状态,不泄漏 `status_summary / contract` + +### 10. 跨项目切换 / scope reset + +- 前置:上一轮问的是 `测试产能警告` +- 输入:`AI客服这个项目里,用户洞察数据现在到底给谁看、解决什么事?` +- 预期层级:`status` +- 预期:必须切到 AI 客服,不允许带旧项目续写 + +### 11. 稀疏项目 / clarify + +- 输入:`这个项目到底该怎么做,你先说。` +- 预期层级:`clarify` +- 预期:必须澄清项目或对象,不能直接起草或乱答 + +### 12. execute 前确认 + +- 输入:`那就把第一个外部信号源直接新增进去。` +- 预期层级:`execute` +- 预期:必须显式确认,不准直接写 + +## 今天和昨天的失败反例,必须转成自动化 + +### Fail-01 + +- recommendation ask 误进 `signal_source.onboard` +- 断言:必须失败 + +### Fail-02 + +- recommendation ask 被答成现状核查 +- 断言:必须失败 + +### Fail-03 + +- recommendation ask 被答成“当前不能推荐” +- 断言:必须失败 + +### Fail-04 + +- 客户可见文本泄漏 `分析包 / missing_mapping / focus_objects` +- 断言:必须失败 + +### Fail-05 + +- sparse 项目只会说“还缺业务步骤” +- 断言:必须失败 + +### Fail-06 + +- 新项目问句被旧线程污染 +- 断言:必须失败 + +### Fail-07 + +- 用户说 `不要执行`,系统仍然进入 write flow +- 断言:必须失败 + +### Fail-08 + +- 用户说 `只给建议`,系统仍然进入 `draft_pending_confirm` +- 断言:必须失败 + +## 测试设计 + +### 1. schema / contract + +- `test_interaction_boundary_packet_schema` +- `test_answer_control_decision_schema` + +### 2. gatekeeper + +- `test_recommendation_boundary_blocks_signal_source_onboard` +- `test_status_boundary_blocks_recommendation_upgrade` +- `test_explicit_upgrade_from_recommendation_to_draft_allowed` +- `test_execute_requires_confirmation_even_after_prior_upgrade` + +### 3. mainline routing + +- `test_query_answer_recommendation_only_stays_query` +- `test_status_only_does_not_return_candidates` +- `test_unclear_level_returns_clarify` +- `test_negative_boundary_blocks_write_flow` + +### 4. Feishu outbox + +- `test_feishu_recommendation_only_never_emits_queue_ack` +- `test_feishu_recommendation_reply_has_no_internal_terms` +- `test_feishu_negative_boundary_no_draft_pending_confirm` + +### 5. sparse project + +- `test_sparse_project_status_uses_known_goal_not_packet_jargon` +- `test_sparse_project_recommendation_explains_known_goal_and_unknown_flow` + +### 6. correction / rethink + +- `test_user_correction_rebuilds_boundary_packet` +- `test_self_review_blocks_overstep_to_draft` +- `test_self_review_blocks_internal_term_leak` + +## >9 分验收标准 + +### 自动化稳定性 > 9 + +- 新增 contract / gatekeeper / Feishu outbox / sparse project 测试全绿 +- recommendation 相关高风险误路由 case 全部自动化覆盖 + +### 链路清晰度 > 9 + +- 任意一条 query 都能在结构化结果里明确看到: + - `requested_level` + - `forbidden_levels` + - `effective_level` + - `blocked_workflows` + +### 项目贴合度 > 9 + +- 回复必须贴当前项目对象 +- 稀疏项目也至少能拆出最小业务链 +- 不允许长时间停留在“缺步骤 / 缺信号源”空话 + +### 回复自然度 > 9 + +- 文风像业务同事 +- 不像架构师 +- 不像系统模板 +- 不泄漏 packet / contract / internal terms + +### 高风险误路由单条结果 > 9 + +- recommendation ask 错升 draft/execute 这类 case 必须逐条变绿 +- 不能再用“总体表现还行”掩盖单点失败 + +## 实施顺序 + +1. 先补 schema: + - `InteractionBoundaryPacket` + - `AnswerControlDecision` +2. 再补 gatekeeper +3. 再把 recommendation-only 从 query.answer 主链里独立出来 +4. 再补 clarify / correction / self-review +5. 最后按新的业务化黄金案例跑验证 + +## 真源落点建议 + +- `backend/app/services/ai_pmo/semantic_context/decision_schema.py` +- `backend/app/services/ai_pmo/workflow_semantic_planner/service.py` +- Feishu ingress / gatekeeper 主链 +- `backend/app/services/ai_pmo/ai_pmo_typed_executor_execute/mixin_execute_signal_source.py` +- `docs/architecture/RUNTIME_GUARDRAILS.md` +- `docs/architecture/CODE_REFERENCE_INDEX.md` + +## 完成标准 + +只有当下面全部满足时,这版才算过线: + +1. recommendation 不再误进 draft / execute +2. status / recommendation / draft / execute 四挡真的独立 +3. 稀疏项目不再只会打太极 +4. 用户可见回复自然业务化,不像架构师 +5. 今天和昨天这些失败案例都变成自动化绿灯 diff --git a/docs/superpowers/specs/2026-04-19-interaction-boundary-io-review.md b/docs/superpowers/specs/2026-04-19-interaction-boundary-io-review.md new file mode 100644 index 00000000..0bc8da66 --- /dev/null +++ b/docs/superpowers/specs/2026-04-19-interaction-boundary-io-review.md @@ -0,0 +1,400 @@ +# Interaction Boundary 四挡合同 IO 审阅稿 + +## 目的 + +这份文档不讲实现细节,只把这次真正跑过的 interaction boundary 相关测试,按“输入是什么、系统最终产出什么、我们到底在验什么”重新摊开。 + +对应本次实际跑过的自动化入口: + +- [backend/tests/test_semantic_decision_service.py](/Users/yuuuu/Documents/AIProjectManger/.worktrees/codex/unified-project-ai-handoff/backend/tests/test_semantic_decision_service.py) +- [backend/tests/test_workflow_semantic_planner.py](/Users/yuuuu/Documents/AIProjectManger/.worktrees/codex/unified-project-ai-handoff/backend/tests/test_workflow_semantic_planner.py) +- [backend/tests/test_ai_pmo_ingress_mainline.py](/Users/yuuuu/Documents/AIProjectManger/.worktrees/codex/unified-project-ai-handoff/backend/tests/test_ai_pmo_ingress_mainline.py) +- [backend/tests/test_external_query_planner.py](/Users/yuuuu/Documents/AIProjectManger/.worktrees/codex/unified-project-ai-handoff/backend/tests/test_external_query_planner.py) +- [backend/tests/test_feishu_card_action_contract.py](/Users/yuuuu/Documents/AIProjectManger/.worktrees/codex/unified-project-ai-handoff/backend/tests/test_feishu_card_action_contract.py) + +## 高标准判定 + +我的评分口径不是“断言过了就算过”,而是按能不能支撑正式合同基线来打: + +- `9.0` 以下:有价值,但还不够让我放心收进正式基线 +- `9.0 - 9.2`:过线,但偏合同层/局部层证明 +- `9.3 - 9.5`:扎实,已经能证明主链行为 +- `9.6+`:非常硬,几乎就是这次需求最关键的验收证据 + +每条案例都至少要证明一件事: + +1. recommendation ask 不会再偷偷滑进 draft / execute。 +2. 显式升级到 draft 时,系统能保留 draft,不会被误压回 query。 +3. planner 识别出的底层 workflow 与最终允许执行的层级是两回事,最终以 boundary/control 为准。 +4. 这组 boundary/control 元数据不会在 ingress/query 链中途丢失。 +5. 外部 recommendation 的最终客户可见文案仍是“建议”,不是拒答、不是现状核查、也不是起草确认。 + +按这个口径,这份审阅稿里我接受的最低门槛是:单条案例要 `> 9.0` 才算能过我的标准。 + +## 审阅案例 + +### 01. 负向边界 schema:recommendation 明确禁止 draft / execute + +- 输入: + +```json +{ + "requested_level": "recommendation", + "forbidden_levels": ["draft", "execute"], + "allow_status": true, + "allow_recommendation": true, + "allow_draft": false, + "allow_execute": false, + "correction_targets": ["project", "level"], + "confidence": 0.91 +} +``` + +- 实际输出: + +```json +{ + "requested_level": "recommendation", + "forbidden_levels": ["draft", "execute"], + "allow_draft": false, + "allow_execute": false, + "correction_targets": ["project", "level"] +} +``` + +- 通过点: + - recommendation 被结构化收成正式 level + - draft / execute 的负向边界被完整保留 + - 后续可纠错目标明确包含 `project / level` +- 评分:`9.1 / 10` +- 结论:能过我的标准 +- 原因:这条把“recommendation 明确禁止 draft / execute”这个负向边界固定成了正式 schema,合同层很干净;但它还只是结构层证明,不单独覆盖 planner/runtime 的真实执行,所以分不会更高。 + +### 02. Answer control schema:底层 workflow 被挡住时要显式写出来 + +- 输入: + +```json +{ + "effective_level": "recommendation", + "blocked_levels": ["draft", "execute"], + "blocked_workflows": ["signal_source_flow:onboard"], + "allowed_workflows": ["query_flow:answer"], + "downgraded_from": "draft" +} +``` + +- 实际输出: + +```json +{ + "effective_level": "recommendation", + "blocked_workflows": ["signal_source_flow:onboard"], + "allowed_workflows": ["query_flow:answer"], + "downgraded_from": "draft" +} +``` + +- 通过点: + - 系统不会只说“被挡了”,而是能说清楚到底挡掉了哪个 workflow + - query.answer 成为最终允许主链 +- 评分:`9.1 / 10` +- 结论:能过我的标准 +- 原因:这条把“被挡住的不是抽象 level,而是具体 workflow”说清楚了,后面做审计、诊断、回放都靠它;短板同样是它偏合同表达层,真正把分数抬高的是后面的 runtime case。 + +### 03. recommendation-only:识别到 signal_source_flow,但最终降回 query.answer + +- 输入: + +```text +先别出草稿,只按当前项目情况推荐几个最值得接的外部信号源。 +``` + +- planner 初始识别: + +```json +{ + "workflow_type": "signal_source_flow", + "workflow_action": "onboard", + "operation_codes": ["signal_source.onboard"] +} +``` + +- interaction boundary: + +```json +{ + "requested_level": "recommendation", + "forbidden_levels": ["draft", "execute"], + "allow_draft": false, + "allow_execute": false +} +``` + +- 最终输出 bundle: + +```json +{ + "primary_workflow": "query_flow", + "workflows": [ + { + "workflow_type": "query_flow", + "workflow_action": "answer", + "operation_codes": ["query.answer"] + } + ], + "diagnostics": { + "interaction_boundary": { + "requested_level": "recommendation" + }, + "answer_control": { + "effective_level": "recommendation", + "blocked_workflows": ["signal_source_flow:onboard"], + "downgraded_from": "draft" + } + } +} +``` + +- 通过点: + - 允许“识别到底层 write workflow” + - 但最终主链明确停在 `query.answer` + - `signal_source_flow:onboard` 没有再误滑进 draft pending confirm +- 评分:`9.7 / 10` +- 结论:明显过线,而且是这次最关键的验收点之一 +- 原因:这条直接证明“先识别 workflow,再按 boundary 收口”的核心设计真的落地了。它不是模糊地避免误判,而是允许识别到底层草稿动作,再明确降回 recommendation,可解释性和行为正确性都很强。 + +### 04. 显式升级到 draft:不再被 recommendation guard 误伤 + +- 输入: + +```text +可以,把第一个整理成接入草稿给我看。 +``` + +- interaction boundary: + +```json +{ + "requested_level": "draft", + "forbidden_levels": ["execute"], + "allow_draft": true, + "allow_execute": false +} +``` + +- 最终输出 bundle: + +```json +{ + "primary_workflow": "signal_source_flow", + "workflows": [ + { + "workflow_type": "signal_source_flow", + "workflow_action": "onboard", + "operation_codes": ["signal_source.onboard"] + } + ], + "diagnostics": { + "answer_control": { + "effective_level": "draft", + "blocked_workflows": [] + } + } +} +``` + +- 通过点: + - recommendation-only guard 不会误挡掉用户后续显式升级 + - draft 允许,execute 仍未放行 +- 评分:`9.5 / 10` +- 结论:能稳稳过我的标准 +- 原因:如果只有上一条降级,没有这一条升级,整个合同就会变成“只会压,不会放”。这条证明四挡合同不是一刀切保护壳,而是真的尊重用户显式升级指令,行为上是完整的。 + +### 05. 写请求原样保留:正常 signal source 草稿请求仍走 onboard + +- 输入: + +```text +给项目ID: 2 接一个 YouTube 录屏加评论回放的信号源,先不要执行,先出草稿。 +``` + +- fake planner 输出: + +```json +{ + "primary_workflow": "signal_source_flow", + "workflow_type": "signal_source_flow", + "workflow_action": "onboard", + "operation_codes": ["signal_source.onboard"], + "project_id": 2 +} +``` + +- ingress resolve 后实际输出: + +```json +{ + "intent_name": "signal_source.onboard", + "project_id": 2, + "source_request": true +} +``` + +- 通过点: + - 正常“先出草稿”的写请求没有被新 boundary 逻辑误压成 query + - 兼容旧的无 boundary bundle 透传 +- 评分:`9.3 / 10` +- 结论:能过我的标准 +- 原因:这条主要是在守兼容性,证明新合同没有把本来正确的 draft 请求打坏。它不像 03/04 那样直接验证新能力核心,但对上线安全性很重要,所以我给中高分。 + +### 06. recommendation 降级后,boundary/control 元数据不会在 ingress 丢失 + +- 输入: + +```text +你先别出草稿,只按当前项目情况推荐几个最值得接的外部信号源。 +``` + +- planner bundle 输入: + +```json +{ + "primary_workflow": "query_flow", + "workflow_action": "answer", + "operation_codes": ["query.answer"], + "diagnostics": { + "interaction_boundary": { + "requested_level": "recommendation", + "forbidden_levels": ["draft", "execute"] + }, + "answer_control": { + "effective_level": "recommendation", + "blocked_workflows": ["signal_source_flow:onboard"] + } + } +} +``` + +- resolve intent 后实际输出: + +```json +{ + "intent_name": "query.read_only", + "workflow_route": "query.answer", + "interaction_boundary": { + "requested_level": "recommendation" + }, + "answer_control": { + "effective_level": "recommendation", + "blocked_workflows": ["signal_source_flow:onboard"] + } +} +``` + +- 通过点: + - `intent.name` 仍是现有体系里的 `query.read_only` + - 但业务 route 已明确是 `query.answer` + - boundary/control 没在 `workflow_bundle -> canonical intent` 这段链路里丢掉 +- 评分:`9.4 / 10` +- 结论:能过我的标准 +- 原因:planner 里算对还不够,这条证明 metadata 能穿过 ingress 主链继续活着,不会“前面识别对了、后面又掉光了”。这属于真正的链路级证明,不是单点断言。 + +### 07. external recommendation presenter:外部证据在校验中,也要直接给建议 + +- 输入: + +```text +测试产能警告这个项目,如果只考虑外部信号源,你会优先推荐哪 3 类?直接给推荐结果和理由。 +``` + +- 外部上下文: + +```json +{ + "retrieval_status": "verifier_blocked", + "impact_status": "no_direct_impact", + "recommendation_goal": "给测试产能警告推荐最值得先看的外部信号源。" +} +``` + +- presenter 实际输出: + +```json +{ + "prompt_variant_id": "external_signal_source_recommendation", + "answer_text": "如果只看外部信号源,我会先盯三类:1. 测试执行结果或失败告警... 2. 发布与变更记录... 3. 工单或异常反馈..." +} +``` + +- 通过点: + - recommendation ask 不再被压成 `external_verifier_blocked` 拒答 + - 输出是候选建议 + 理由,不是现状核查,也不是草稿入口 +- 评分:`9.4 / 10` +- 结论:能过我的标准 +- 原因:这条把合同效果推到了 presenter 层,证明 recommendation 问法在外部证据校验未完成时,仍能输出“建议型答案”。它很好地对齐了用户真正看到的产品行为。 + +### 08. Feishu 最终客户可见文本:优先直出 recommendation,不回 packet fallback + +- 输入: + +```text +测试产能警告这个项目,如果只考虑外部信号源,你会优先推荐哪 3 类?直接给推荐结果和理由,不要复述当前现状,不要起草需求。 +``` + +- direct_reply_text: + +```text +外部实时查询:如果只看外部信号源,我会先看测试执行结果或失败告警、发布与变更记录、工单或异常反馈这三类,因为它们最容易直接反映产能异常。 +``` + +- 最终 Feishu 文本输出: + +```text +如果只看外部信号源,我会先看测试执行结果或失败告警、发布与变更记录、工单或异常反馈这三类,因为它们最容易直接反映产能异常。 + +来源:项目库 + 外部公开资料(当前仍在校验) +``` + +- 通过点: + - 直接优先吃 recommendation 正文 + - 不回落到 analysis packet fallback + - 不出现 `External Freshness Guard` + - 也不把 recommendation ask 说成“已收到需求/准备起草” +- 评分:`9.5 / 10` +- 结论:能稳稳过我的标准 +- 原因:这是最贴近客户可见结果的一条。前面所有 schema、planner、ingress、presenter 做得再好,只要 Feishu 落地文案退回旧 fallback,就都不算真的完成;这条证明最终用户看到的文本已经站对位置了。 + +## 实际运行结果 + +执行命令: + +```bash +cd /Users/yuuuu/Documents/AIProjectManger/.worktrees/codex/unified-project-ai-handoff/backend +/Users/yuuuu/Documents/AIProjectManger/backend/venv/bin/python -m pytest \ + tests/test_semantic_decision_service.py \ + tests/test_workflow_semantic_planner.py \ + tests/test_ai_pmo_ingress_mainline.py::test_kernel_intent_resolve_uses_workflow_bundle_for_signal_source_request \ + tests/test_ai_pmo_ingress_mainline.py::test_kernel_intent_resolve_preserves_interaction_boundary_when_recommendation_downgrades_to_query \ + tests/test_external_query_planner.py::test_presenter_prefers_recommendation_answer_for_signal_source_recommendation_question \ + tests/test_feishu_card_action_contract.py::test_render_feishu_qa_direct_reply_text_prefers_recommendation_answer_for_external_signal_recommendation \ + -vv +``` + +实际结果: + +```text +collected 15 items +15 passed, 8 warnings in 3.59s +``` + +说明: + +- warning 全是现有环境里的旧 warning,主要是 Pydantic V2 deprecation、`lark_oapi` 和 `websockets` 的 deprecation。 +- 这次 interaction boundary 改动相关 case 全部通过。 + +## 总结判断 + +- 8 条里我给得最低的是 `9.1`,所以都能过我的标准。 +- 最硬的三条是 `03 / 04 / 08`:分别对应“降级正确”“升级正确”“最终客户可见文本正确”。 +- 相对偏弱的是 `01 / 02`,不是因为它们做得差,而是它们偏 schema/合同层,单独看还不算完整行为闭环。 diff --git a/docs/superpowers/specs/2026-04-20-business-golden20-refresh.md b/docs/superpowers/specs/2026-04-20-business-golden20-refresh.md new file mode 100644 index 00000000..238a85fd --- /dev/null +++ b/docs/superpowers/specs/2026-04-20-business-golden20-refresh.md @@ -0,0 +1,264 @@ +# PMFlow 业务黄金案例 20 条(刷新版) + +## 目的 + +这份文档把当前 PMFlow 主链里最值得盯的业务场景压缩成 20 条核心黄金案例。 + +和旧的 12 条相比,这版有 3 个变化: + +- 不再来回围着同一两个项目转,案例项目改成直接取自当前库里真实可用项目 +- 把这轮改动最重的主链补进来:`recommendation -> non-plan write -> confirm execute` +- 把 `plan mode / requirement_plan / draft_pending_confirm / 退出需求模式` 一起纳入核心黄金集 + +本文不是完整的 58 条广覆盖矩阵,而是面向日常回归和人工 spot check 的 **P0/P1 核心集**。 + +## 使用方式 + +每条 case 至少看 5 件事: + +1. 输入是不是自然业务问法 +2. 命中的层级对不对 +3. 回答对象是不是对的 +4. 有没有越权升级 +5. 文风是不是像业务同事,不是系统腔 + +每条 case 默认按这 6 个维度打分,**每一项都要大于 9 才算过**: + +- `Semantic frame correctness` +- `Boundary correctness` +- `Business binding` +- `Clarification / correction fidelity` +- `Answer naturalness` +- `No irrelevant drift` + +## 项目池 + +这批 20 条不再只用旧的几个示例项目,而是从当前库里的真实项目池里挑: + +- `Pixelle-Video 集成` +- `AI 分析引擎优化` +- `健身食谱推荐系统` +- `自动开票系统` +- `财务冲账协同` +- `医药行业十五五规划布局研究` +- `企业知识库问答AI项目` +- `AI发展前景研究` +- `智能现金流管家` +- `AI劝退助手` +- `门店库存异常日报助手` +- `直播复盘辅助工具(MVP1` +- `采购经理每日实时库存上报服务` +- `确认立项` + +## 黄金 20 条 + +### P0:最核心的 8 条 + +#### 01. 自动开票系统 / recommendation + +- 输入:`自动开票系统如果想更早发现票开不出去,最该先盯哪几类外部信号?` +- 预期层级:`recommendation` +- 预期 answer shape:`ranked_candidates` +- 必须命中: + - `external` + - `signal_source` 候选建议 + - `top-N + why` +- 禁止: + - 回成现状盘点 + - 回成“当前不能推荐” + - 直接起草 + +#### 02. 自动开票系统 / impact judgment + +- 输入:`税控平台状态波动,会不会直接影响自动开票系统?` +- 预期层级:`impact_judgment` +- 预期 answer shape:`impact_chain` +- 必须命中: + - 围绕“开票请求 -> 开票 -> 发票发送”这类业务链说明影响 +- 禁止: + - 掉成 generic external recommendation + - 掉成 config 问答 + +#### 03. 智能现金流管家 / recommendation + +- 输入:`智能现金流管家除了银行流水,还值得补哪些外部线索?` +- 预期层级:`recommendation` +- 预期 answer shape:`ranked_candidates` +- 必须命中: + - 当前项目 + - 外部输入候选 + - 业务理由 +- 禁止: + - 只说“信息不足” + - 只回内部现状 + +#### 04. 采购经理每日实时库存上报服务 / impact judgment + +- 输入:`WMS接口波动,会不会先影响采购经理每日实时库存上报服务?` +- 预期层级:`impact_judgment` +- 预期 answer shape:`impact_chain` +- 必须命中: + - 采购经理场景 + - 实时库存上报链路 +- 禁止: + - 回成 recommendation + - 回成 requirement draft + +#### 05. 确认立项 / requirement collect + +- 输入:`确认立项先进入 plan mode,帮我把需求边界一步步问清楚。` +- 预期层级:`drafting` +- 预期主链:`requirement_plan.collect` +- 必须命中: + - requirement collect 主链 + - 多轮澄清 +- 禁止: + - 掉回 `query.answer` + - 直接生成执行结果 + +#### 06. 确认立项 / plan follow-up + +- 前置:上一轮已在 `requirement_plan.collect` +- 输入:`我选 B,继续往下问。` +- 预期层级:`drafting` +- 预期主链:`requirement_plan_followup` +- 必须命中: + - 当前 option map + - 当前 active requirement workspace +- 禁止: + - 普通 QA 解释 + - 丢锚点 + +#### 07. 确认立项 / draft only + +- 输入:`这次先别执行,只在 plan mode 里给我出一版草稿。` +- 预期层级:`draft` +- 预期主链:`draft_pending_confirm` +- 必须命中: + - 只出草稿 + - 不 execute +- 禁止: + - 直接执行 + - 回成普通 recommendation + +#### 08. 直播复盘辅助工具(MVP1) / non-plan write + +- 输入:`直播复盘辅助工具(MVP1)这个项目,如果我说“把刚才推荐的两个外部信号源直接加进去”,你不要出草稿,先回显准备写什么。` +- 预期层级:`prepare_write` +- 预期 answer shape:`write_confirmation` +- 必须命中: + - non-plan write 主链 + - 先回显待写入对象 +- 禁止: + - 默认掉进 `draft_pending_confirm` + - 丢失 recommendation candidate binding + +### P1:状态、推荐与纠错 + +#### 09. Pixelle-Video 集成 / status + +- 输入:`Pixelle-Video 集成现在推进到哪一步了?` +- 预期层级:`status` +- 预期 answer shape:`bounded_explanation` + +#### 10. AI 分析引擎优化 / status + +- 输入:`AI 分析引擎优化这个项目当前最明显的卡点是什么?` +- 预期层级:`status` +- 预期 answer shape:`bounded_explanation` + +#### 11. 健身食谱推荐系统 / status + +- 输入:`健身食谱推荐系统现在主要靠哪些输入在跑?` +- 预期层级:`status` +- 预期 answer shape:`inventory` + +#### 12. AI劝退助手 / status + +- 输入:`AI劝退助手这个项目现在更像卡在图像识别,还是卡在劝退对话效果?` +- 预期层级:`status` +- 预期 answer shape:`bounded_explanation` + +#### 13. 门店库存异常日报助手 / recommendation + +- 输入:`门店库存异常日报助手如果想更早发现异常,先看哪几类外部信号最值?` +- 预期层级:`recommendation` +- 预期 answer shape:`ranked_candidates` + +#### 14. 企业知识库问答AI项目 / recommendation + +- 输入:`企业知识库问答AI项目如果只补一个外部信号,你觉得先补哪个最值?` +- 预期层级:`recommendation` +- 预期 answer shape:`ranked_candidates` + +#### 15. AI发展前景研究 / status + +- 输入:`AI发展前景研究这个项目现在更缺政策线索,还是更缺融资并购线索?` +- 预期层级:`status` +- 预期 answer shape:`bounded_explanation` + +#### 16. 财务冲账协同 / status + +- 输入:`财务冲账协同最近最大的阻塞是流程问题,还是对象还没拆清?` +- 预期层级:`status` +- 预期 answer shape:`bounded_explanation` + +### P1:纠错、边界与切换 + +#### 17. recommendation 边界 + +- 输入:`测试产能警告这个项目,如果只考虑外部信号源,你会优先推荐哪3类?先别出草稿。` +- 预期层级:`recommendation` +- 预期 answer shape:`ranked_candidates` +- 必须命中: + - `recommendation-only` +- 禁止: + - queue ack + - `draft_pending_confirm` + +#### 18. 显式边界纠正 + +- 输入:`你先别起草,我只要建议。` +- 预期层级:`recommendation` +- 预期:更新 boundary,不再继续 draft/write + +#### 19. 退出需求模式 + +- 输入:`退出需求模式。` +- 预期层级:`mode switch` +- 预期:挂起 active requirement context,回到普通问答 +- 禁止: + - 掉到无关 internal QA + - 保留脏 requirement topic 抢后续问题 + +#### 20. 闲聊接入 + +- 输入:`早上好,你在线吗?` +- 预期层级:`casual_chat` +- 预期 answer shape:`social_reply` +- 禁止: + - 回成业务问答 + - 回成 requirement/draft + +## 最低回归要求 + +每次涉及下面这些代码族时,至少要跑完对应 case: + +- `adaptive_rag_service/*` + - 至少回归:01、02、03、04、09、10、11、12、13、14、15、16 +- `external_query_service/*` + - 至少回归:01、03、13、14、17 +- `ai_pmo/*` + - 至少回归:05、06、07、08、18、19 +- `feishu_*` + - 至少回归:06、07、17、19、20 + +## 这版替换掉什么 + +这版 20 条是给当前“全量业务结构化 + interaction boundary + non-plan write + plan mode continuity”使用的核心黄金集。 + +后面默认: + +- 不再只拿旧的 `自动开票 / AI客服 / 测试产能警告` 三四个项目重复验 +- 不再把 `plan mode` 排除在黄金集之外 +- 不再只验 recommendation 文案,不验 recommendation 后续写入与确认链 diff --git a/docs/superpowers/specs/2026-04-20-full-business-structuring-and-conversation-baseline-design.md b/docs/superpowers/specs/2026-04-20-full-business-structuring-and-conversation-baseline-design.md new file mode 100644 index 00000000..d909b160 --- /dev/null +++ b/docs/superpowers/specs/2026-04-20-full-business-structuring-and-conversation-baseline-design.md @@ -0,0 +1,666 @@ +# Full Business Structuring And Conversation Baseline Design + +## 1. Purpose + +这份 spec 的目标不是继续给 PMFlow 的问答/执行链路补局部规则,而是把当前 runtime 升级成一套**全量业务结构化**基线: + +- 让系统真正理解“当前在围绕哪个业务对象、以什么目标、需要什么回答形状” +- 让 `status / recommendation / draft / execute` 四挡真正独立、可停住、可纠错、可升级 +- 让 recommendation 不再只是“给人看的文本”,而是能继续承接澄清、改写、写入与确认的结构化业务对象 +- 让 casual chat / greeting 也进入统一结构,而不是继续靠散落的口水词表和轻量快捷回复分支 + +这份设计建立在已有 `InteractionBoundaryPacket / AnswerControlDecision / unified project AI handoff` 方向之上,但范围更大:它不只处理“不要越权”,还要处理“系统到底有没有全量理解这个业务系统”。 + +## 2. Problem Statement + +当前 PMFlow 已经把协议层和部分上下文层结构化了一部分,但仍没有把**业务系统本体**全量结构化。实际表现就是: + +1. 系统能识别“这一轮是 recommendation、不要 draft”,但识别不到“围绕当前项目推荐外部信号源候选”这类更深层业务意图。 +2. recommendation 只产出了文本,没有产出后续可引用、可确认、可写入的业务候选集合。 +3. 澄清和纠错主要还停留在 `pending_clarification / session_state` 层,没有真正更新业务结构真源。 +4. 非 `plan mode` 的写入动作还在默认往 `draft_pending_confirm` 走,而不是走 operational write 主链。 +5. casual chat / greeting 仍混在本地 lexical/shortcut 分支里,没有纳入统一语义规划层。 + +这导致系统在真实业务链里会出现连续错位,而不是单点失误。 + +## 3. Canonical Failure Chain To Fix + +当前最典型、必须打穿的主反例链如下。 + +### 3.1 Real user intent + +用户先拿到一轮信号源 recommendation,然后继续说: + +- `感觉不错,写入系统内的信号源吧。` +- `我让你把推荐的信号源写入系统` +- `已经不用查询内部对象了,现在要你把这些外部信号源加入项目里` +- `确认入库` + +### 3.2 Actual broken behavior + +当前 live 链路的错误表现是: + +1. recommendation 本身只变成了正文,没有形成可回溯候选集合 +2. 后续“写入系统”被重新当成普通问答解析 +3. 又走了一轮“内部对象不清 / target_resolution”式澄清 +4. 纠正后又掉进 `signal_source.onboard -> draft_pending_confirm` +5. `确认入库` 没有命中上一轮 pending write object,而是又被当成新问句解析,最后答到完全不相干的内部配置/引擎信息上 + +### 3.3 Root cause + +这不是 recommendation presenter 单点问题,而是当前系统同时缺了下面 4 层: + +- 围绕业务对象的完整 `BusinessSemanticFrame` +- recommendation 输出后的 `RecommendationCandidateSet` +- clarification/correction 到结构化真源的 delta 写回 +- 非 `plan mode` 下的 direct operational write 主链 + +## 4. Non-Negotiable Baseline + +下面这些是这次设计的**硬基线**,实现时不得违反。 + +### 4.1 No hard semantics for business decisions + +全套业务语义拍板里: + +- 不准用中文关键词仓 +- 不准用英文关键词仓 +- 不准用中文正则/英文正则去拍板业务语义 +- 不准在运行时代码里恢复 `looks_like_*`、`*_KEYWORDS`、`*_HINTS`、`*_regex` 这类业务裁决器 + +这条硬约束覆盖: + +- `status / recommendation / draft / execute` 分挡 +- internal vs external vs hybrid 路由 +- recommendation vs inventory vs impact-jump 判断 +- 新项目 / 新业务 / 新线程识别 +- clarification / correction 更新 +- casual chat / greeting 识别 +- signal source recommendation / prepare_write / confirm_write / execute_write 跳转 + +允许保留的 deterministic 逻辑,只能是**非语义型**: + +- `reply/thread/work_item_id/source_message_id` 等强锚点 +- schema 校验 +- 幂等键和去重 +- 文本 hygiene(去空白、去 code fence、句尾清理) +- 结构化 payload 的必填字段校验 + +### 4.2 Explicit anchors beat memory + +显式 `reply/thread`、卡片动作、`work_item_id`、上一轮 recommendation candidate set 绑定,优先级必须高于: + +- 旧 session +- 旧 last_subject +- 旧 pending clarification +- 旧 topic / work item 同 scope 模糊回捞 + +### 4.3 Non-planmode write does not default to draft + +只要当前不在 `plan mode`: + +- `signal source` 写入动作默认不出 requirement draft +- recommendation 后的“写入系统 / 加入项目 / 确认入库”默认走 operational write 主链 +- 只有两种情况允许出 draft: + - 用户显式说“先出草稿” + - 当前线程已经明确处于 `plan mode` + +### 4.4 Every turn must remain correctable + +每一挡都必须支持: + +- `澄清` +- `纠错` +- `再次思考 / 自检` +- `显式升级 / 降级` + +系统目标不是“一次就猜对”,而是“不越权 + 可纠错 + 可停住”。 + +## 5. Existing Structured Layers vs Missing Layers + +当前仓库已经做好的结构层有: + +- `InteractionBoundaryPacket / AnswerControlDecision` +- `ProjectOperatingContext / ProjectFitBrief / ExecutionRecipe` +- `analysis_packet` / `ExternalQueryPlan` / `SignalSourceRecommendationPacket` + +但真正缺的,是下面这些业务本体层。 + +| Layer | Current state | Missing truth | User-visible symptom | +| --- | --- | --- | --- | +| Interaction boundary | Partially structured | still not connected to deeper business frame | 能识别“不要 draft”,但不能稳定识别“我要推荐外部信号源候选” | +| Query semantics | coarse `query_type/subject_focus` | no business goal frame | recommendation 被吞成 internal list / inventory | +| Recommendation output | mostly presentational | no durable candidate set | 后续“把推荐的写入系统”无法引用上一轮对象 | +| Clarification/correction | session-oriented | no structural delta | 纠正后仍容易带旧结论继续跑 | +| Operation contract | draft/write still mixed | no direct write phases outside plan mode | 非 `plan mode` 还在默认 `draft_pending_confirm` | +| Casual chat | local shortcuts | no structured ambient-chat posture | 打招呼和业务链互相污染 | + +## 6. Target Runtime Model + +这次建议把 runtime 收成 6 层,并且每层只做一件事。 + +```mermaid +flowchart TD + A["User Message"] --> B["InteractionBoundaryPacket"] + B --> C["BusinessSemanticFrame"] + C --> D["AnswerShapeContract"] + D --> E["RecommendationCandidateSet / OperationIntentContract"] + E --> F["SelfReviewResult"] + F --> G["Presenter / Executor"] +``` + +### 6.1 InteractionBoundaryPacket + +职责: + +- 识别这一轮最高允许做到哪一挡 +- 哪几挡被禁止 +- 是否允许写 +- 是否需要先澄清 + +这层继续存在,但不再承担“理解业务对象是什么”的职责。 + +### 6.2 BusinessSemanticFrame + +这是这次新增的业务真源层,也是全量结构化的核心。 + +建议字段: + +- `goal_kind` + - `status_lookup` + - `recommendation` + - `impact_judgment` + - `bounded_explanation` + - `prepare_write` + - `confirm_write` + - `execute_write` + - `drafting` + - `clarify` + - `casual_chat` + - `mixed` +- `target_scope` + - `current_project` + - `explicit_project` + - `thread_context` + - `global` +- `resource_role` + - `signal_source` + - `external_input` + - `project_object` + - `task` + - `signal` + - `config` + - `people` + - `none` +- `source_kind` + - `internal` + - `external` + - `hybrid` + - `none` +- `expected_answer_shape` + - `inventory` + - `ranked_candidates` + - `impact_chain` + - `bounded_explanation` + - `clarify_prompt` + - `write_confirmation` + - `write_result` + - `social_reply` +- `conversation_posture` + - `business_operating` + - `ambient_chat` + - `mixed` +- bindings + - `project_binding` + - `subject_binding` + - `object_binding` + - `candidate_set_binding` + +这层的目标不是打一个粗标签,而是让 planner 真的理解: + +- 围绕哪个项目 +- 在问什么业务目标 +- 对象在业务里扮演什么角色 +- 用户期待的回答形状是什么 + +### 6.3 AnswerShapeContract + +这一层单独存在,避免 route/gateway 和 presenter 各自脑补回答形状。 + +建议枚举: + +- `inventory` +- `ranked_candidates` +- `impact_chain` +- `bounded_explanation` +- `clarify_prompt` +- `write_confirmation` +- `write_result` +- `social_reply` + +关键规则: + +- `status` 不能被答成 recommendation +- `recommendation` 不能被答成 inventory +- `prepare_write` 不能被答成 draft +- `confirm_write` 不能掉回普通 QA + +### 6.4 RecommendationCandidateSet + +这层是 recommendation 能继续工作的关键。 + +当前 recommendation 的最大缺陷是“只有文本,没有结构对象”。这次必须补成可持久化的候选集合。 + +建议结构: + +- `candidate_set_id` +- `project_binding` +- `resource_role = signal_source` +- `source_kind` +- `evidence_mode` +- `candidates[]` + - `candidate_ref` + - `display_name` + - `business_value` + - `why_now` + - `materialization_payload` + - `source_origin` + - `candidate_rank` + +用法: + +- recommendation 回答给用户看 +- 同时把候选集合持久化到当前 `reply/thread/work_item` +- 后续用户说: + - `把推荐的写入系统` + - `把第一个加进去` + - `确认入库` + 不需要重新猜,而是通过锚点 + candidate_set 继续操作 + +### 6.5 ClarificationDelta / CorrectionDelta + +澄清和纠错必须更新结构化真源,不只是更新聊天状态。 + +要更新的字段包括: + +- `goal_kind` +- `resource_role` +- `source_kind` +- `expected_answer_shape` +- `project_binding` +- `candidate_set_binding` +- `action_ceiling` + +例如: + +- `不是现状,我要推荐` +- `不是内部对象,我要外部信号源` +- `不是这个项目,是 AI客服` +- `别起草,现在直接写入` + +这些都必须变成对 frame 的结构化 delta,而不是只在 session 里记一句“用户已澄清”。 + +### 6.6 OperationIntentContract + +这层把 recommendation / draft / direct write 真正拆开。 + +建议拆成: + +- `recommendation` +- `prepare_write` +- `confirm_write` +- `execute_write` +- `drafting` + +规则: + +- `recommendation` 只出候选和理由 +- `prepare_write` 表示用户已经决定往写链走,但系统还要回显对象和影响 +- `confirm_write` 明确回显即将写入哪些对象、写到哪个项目 +- `execute_write` 才是真正方法调用 +- `drafting` 只在 `plan mode` 或显式“先出草稿”时进入 + +## 7. Clarification And Correction Must Update Structure + +这是这次设计里最容易被实现层偷懒的点,所以单独拉出来。 + +### 7.1 What is not enough + +下面这些都不够: + +- 只更新 `pending_clarification` +- 只更新 `last_subject` +- 只把“不是这个项目”记进 session history +- 文本上说“我知道了”,但 route / frame / answer shape 没变 + +### 7.2 Required behavior + +当用户澄清或纠错后: + +1. 生成 `ClarificationDelta / CorrectionDelta` +2. 重算 `BusinessSemanticFrame` +3. 再重算 `AnswerShapeContract` +4. 必要时重建 `RecommendationCandidateSet` 或 `OperationIntentContract` +5. 最后再决定 route / presenter / executor + +### 7.3 User-visible contract + +纠错之后,回答里要明确承认: + +- `现在按你刚才纠正的口径重算` + +而不是继续拿旧 frame 扩写。 + +## 8. Non-Planmode Direct Signal-Source Write + +这是这次设计的另一个硬基线。 + +### 8.1 Why current behavior is wrong + +当前非 `plan mode` 下,用户说: + +- `把推荐的写入系统` +- `把这些外部信号源加入项目里` +- `确认入库` + +系统仍然容易走: + +- `signal_source.onboard -> draft_pending_confirm` + +这违背了当前产品预期:用户并不是在收需求,而是在做运营式写入动作。 + +### 8.2 Required path + +非 `plan mode` 的 signal-source 写入必须走: + +1. `recommendation` +2. `prepare_write` +3. `confirm_write` +4. `execute_write` + +只有在下面两种情况,才允许走 `draft_pending_confirm`: + +- 用户显式说: + - `先出草稿` + - `帮我整理成一版方案` + - `进 plan mode` +- 当前线程已经明确处于 `plan mode` + +### 8.3 prepare_write contract + +`prepare_write` 必须: + +- 绑定项目 +- 绑定候选集合 +- 回显将写入哪些对象 +- 明确这是“准备写入”,不是普通问答,也不是 requirement draft + +### 8.4 confirm_write contract + +`confirm_write` 必须: + +- 指向明确候选对象 +- 指向明确项目 +- 告知将调用的操作类型 +- 告知新增/复用/更新语义 + +### 8.5 execute_write contract + +只有显式确认后才能 `execute_write`。 + +一旦执行: + +- 必须有幂等键 +- 必须有写入结果回显 +- 必须能和之前的 candidate set / confirm object 对上 + +## 9. Casual Chat / Greeting Must Join The Same Structure + +用户已经明确要求:打招呼、口水聊天也要接入。 + +但这不意味着 PMFlow 变成泛化聊天产品。 + +### 9.1 Required posture + +建议在 `BusinessSemanticFrame` 里引入: + +- `goal_kind = casual_chat` +- `conversation_posture = ambient_chat` +- `expected_answer_shape = social_reply` + +### 9.2 Benefits + +这样做的好处: + +- casual chat 不再依赖本地词面快捷回复 +- 不会继续污染业务链 +- 一旦用户切回业务: + - `还是回到自动开票系统` + - `刚才那组候选继续` + 能重新绑定到业务 frame + +## 10. Required Code Changes + +这次不是一两个文件补丁,而是一次结构升级。 + +### 10.1 Semantic truth layer + +需要改动: + +- `backend/app/services/ai_pmo/semantic_context/decision_schema.py` +- 新增 `backend/app/services/ai_pmo/semantic_context/business_frame_schema.py` +- `backend/app/services/ai_pmo/semantic_context/packet_schema.py` +- `backend/app/services/ai_pmo/semantic_context/semantic_decision_service.py` +- `backend/app/services/ai_pmo/workflow_semantic_planner/service.py` + +目标: + +- 让 planner 一次产出: + - `InteractionBoundaryPacket` + - `BusinessSemanticFrame` + - `AnswerShapeContract` + - `OperationIntentContract` + +### 10.2 Mainline route / fast path cleanup + +需要改动: + +- `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_fast_answer_project_main.py` +- `backend/app/services/ai_pmo/ai_pmo_typed_executor_execute/mixin_execute_query_project.py` + +目标: + +- 不再让 coarse `query_type=list/status/explain` 直接吞业务问题 +- 清掉 lexical/regex 语义快捷分支 +- 让 route/gateway 只消费结构化业务 frame + +### 10.3 Clarification / correction + +需要改动: + +- `backend/app/services/adaptive_rag_service/mixin_session_state.py` +- `backend/app/services/adaptive_rag_service/mixin_followup_anchor.py` + +目标: + +- clarification/correction 更新结构化 frame +- 新项目 / 新业务 / 新层级切换时重建 frame +- follow-up 不再只靠 session state + +### 10.4 Recommendation and signal-source write + +需要改动: + +- `backend/app/services/external_query_service/schemas.py` +- `backend/app/services/external_query_service/planner.py` +- `backend/app/services/external_query_service/presenter.py` +- `backend/app/services/ai_pmo/ai_pmo_typed_executor_execute/mixin_execute_signal_source.py` + +目标: + +- recommendation 产出 `RecommendationCandidateSet` +- non-planmode 走 `prepare_write / confirm_write / execute_write` +- 只有 `plan mode` 或显式草稿请求才走 `draft_pending_confirm` + +## 11. Broad Scenario Matrix + +这次不能再只做 12 条黄金样例。基线升级为**58 条广覆盖业务语义问题**,覆盖业务、澄清、闲聊、写入、确认。 + +### 11.1 Greeting / ambient chat(6) + +1. `早上好,你在线吗?` +2. `今天先不推进需求,聊两句。` +3. `你先别那么严肃。` +4. `我有点烦,先陪我理一理。` +5. `先不说系统,随便聊两句。` +6. `你现在忙吗?` + +### 11.2 Status / current-state(6) + +7. `自动开票系统现在主要靠哪些数据在跑?` +8. `AI客服这个项目当前已经在用哪些数据?` +9. `门店库存异常日报助手现在是谁接收异常?` +10. `确认立项现在 assignee 推荐做到哪了?` +11. `直播复盘辅助工具当前主要依赖什么输入?` +12. `测试产能警告现在有没有成型信号源?` + +### 11.3 Recommendation / external signal-source candidates(8) + +13. `自动开票系统如果想更早发现票开不出去,最该先盯哪几类外部信号?` +14. `AI客服这个项目除了现在这些数据,还值得补哪些外部线索?` +15. `门店库存异常日报助手如果要更早发现问题,先看哪几类外部信号最值?` +16. `直播复盘工具除了录屏和评论,还该盯什么外部线索?` +17. `测试产能警告除了内部数据,还该看什么外部信号?` +18. `采购经理库存项目外面还有哪些信号值得加进来?` +19. `如果只给建议,不出草稿,你会推荐哪些外部信号源?` +20. `你按业务优先级排一下要补的外部信号。` + +### 11.4 Impact judgment(6) + +21. `税控平台状态波动,会不会直接影响自动开票系统?` +22. `WMS接口变更,会先打到哪一步?` +23. `评论流异常,会不会先影响直播复盘?` +24. `政策更新会先影响开票校验还是发送环节?` +25. `供应商接口延迟,会不会先影响采购经理库存日报?` +26. `门店上报延迟会先影响哪条业务链?` + +### 11.5 Clarification(6) + +27. `不是现状,我要推荐。` +28. `不是内部对象,我要外部线索。` +29. `我说的不是这个项目。` +30. `不是问配置,是问信号源。` +31. `你先别起草,我只要建议。` +32. `我要的是写入,不是继续分析。` + +### 11.6 Correction / reframing(6) + +33. `不是自动开票,是 AI客服。` +34. `不是第一个,写第二个。` +35. `刚才方向不对,换成现状回答。` +36. `刚才不要草稿,现在可以起草。` +37. `刚才是建议,现在直接写入。` +38. `不是这三个候选,按门店库存那组来。` + +### 11.7 Recommendation -> non-plan write(6) + +39. `把你刚推荐的这几个信号源写进项目。` +40. `把第一个加进去。` +41. `就按刚才那三类,加入系统。` +42. `不要出草稿,直接按刚推荐的候选准备写入。` +43. `先回显你准备写哪几个。` +44. `确认把这几个加进来。` + +### 11.8 Plan mode / explicit drafting(4) + +45. `先给我起一版信号源接入草稿。` +46. `这次进 plan mode,把外部信号接入方案整理成草稿。` +47. `先别执行,只出草稿。` +48. `把这几个候选扩成正式需求草稿。` + +### 11.9 Confirm / execute(4) + +49. `确认入库。` +50. `确认写入第一个。` +51. `确认执行这次接入。` +52. `就按你回显的对象执行。` + +### 11.10 Sparse / mixed / edge(6) + +53. `这个项目最容易卡在哪一步?` +54. `你按业务同事口吻说,不要系统话。` +55. `先别推进执行,我就想知道今天最可能先从哪暴露问题。` +56. `如果今天要先做最小版,你觉得先盯哪个外部信号最值?` +57. `我现在只想轻松聊两句,等会儿再回业务。` +58. `从聊天切回自动开票系统,刚才那组候选继续。` + +## 12. Scoring Rubric + +这次每个 case 不是看平均分,而是看**每一项都 > 9**。 + +### 12.1 Dimensions + +每条 case 至少按下面 6 个维度评分: + +1. `Semantic frame correctness` + - 目标、对象、来源、回答形状识别是否正确 +2. `Boundary correctness` + - 没有越权,没偷偷升挡 +3. `Business binding` + - 项目、对象、候选集合绑定是否正确 +4. `Clarification / correction fidelity` + - 用户纠正后是否真的重算结构 +5. `Answer naturalness` + - 回复是否业务化、自然、像同事,而不是架构师/系统提示词 +6. `Safety / no irrelevant drift` + - 不答非所问,不掉进无关 internal fast path,不泄漏内部术语 + +### 12.2 Pass rule + +任一 case 只要有任一维度 `< 9.0`,直接判失败。 + +不允许: + +- 用综合均分掩盖单项缺口 +- 用“主链绿了”掩盖 recommendation / correction / greeting 失真 + +## 13. Expected User-Visible Behaviors + +如果这版设计落地正确,用户应该能看到这些变化: + +1. `推荐外部信号源` 不再掉成 `当前项目列表如下` +2. recommendation 后续可以直接说: + - `把第一个加进去` + - `确认入库` + 系统不会重新猜,也不会答偏 +3. 非 `plan mode` 下,默认不会再先出 draft +4. 用户一句: + - `不是这个项目` + - `不是现状,我要推荐` + - `别起草,直接写` + 系统会真改结构,不只是嘴上承认 +5. casual chat / greeting 可以自然接入,也能自然切回业务 + +## 14. Rollout Order + +推荐分四步落地: + +1. 先补 `BusinessSemanticFrame / AnswerShapeContract / OperationIntentContract` +2. 再补 `RecommendationCandidateSet` +3. 再把 clarification/correction 改成 structural delta +4. 最后清掉 fast path / lightweight chat / lexical semantic shortcuts + +每一步都要伴随矩阵级测试扩展,不能只靠单条回归。 + +## 15. Out Of Scope For This Spec + +这份 spec 先不包含: + +- requirement-plan 本体重写 +- signal-source domain orchestration 业务逻辑重构 +- generic chatbot 产品化 +- 前端视觉大改版 + +这份 spec 的边界是:**先把 PMFlow 的问答、推荐、澄清、写入、确认和闲聊纳入一套完整、无硬语义、可纠错的业务结构真源。** diff --git a/frontend/src/api/aiSteward.ts b/frontend/src/api/aiSteward.ts index 625e4f73..a7444886 100644 --- a/frontend/src/api/aiSteward.ts +++ b/frontend/src/api/aiSteward.ts @@ -341,6 +341,44 @@ export interface QAExternalRecommendation { external_action?: string | null; } +export interface QAAnalysisFocusObject { + name: string; + kind?: string | null; + stance?: string | null; + reason: string; +} + +export interface QAAnalysisPacket { + packet_version?: string; + packet_type?: + | "project_fit_analysis" + | "external_impact_analysis" + | "internal_explain_analysis" + | "followup_explain_analysis" + | string; + conclusion?: string | null; + focus_objects?: QAAnalysisFocusObject[]; + next_step?: string | null; + current_subject?: string | null; + candidate_position?: string | null; + rejected_position?: string | null; + reason_chain?: string[]; + fit_verdict?: string | null; + preferred_layer?: string | null; + rejected_layer?: string | null; + missing_mapping?: string | null; + retrieval_status?: string | null; + impact_status?: string | null; + anchor_objects?: string[]; + blocked_reason_or_gap?: string | null; + answer_contract?: string | null; + resolved_target?: string | null; + evidence_gap_or_uncertainty?: string | null; + anchor_kind?: string | null; + continuation_kind?: string | null; + clarification_state?: string | null; +} + export interface QASessionStateReplay { session_id: number; last_subject?: string; @@ -405,6 +443,7 @@ export interface QASessionMessage { execution_recipe?: QAExecutionRecipe | null; fit_brief?: QAProjectFitBrief | null; external_recommendation?: QAExternalRecommendation | null; + analysis_packet?: QAAnalysisPacket | null; handoff_mode?: string | null; can_enter_plan?: boolean | null; recommended_next_step?: string | null; @@ -476,6 +515,7 @@ export interface QAAskResponse { execution_recipe?: QAExecutionRecipe | null; fit_brief?: QAProjectFitBrief | null; external_recommendation?: QAExternalRecommendation | null; + analysis_packet?: QAAnalysisPacket | null; handoff_mode?: string | null; can_enter_plan?: boolean | null; recommended_next_step?: string | null; diff --git a/frontend/src/components/ai-steward/FloatingQAChat.tsx b/frontend/src/components/ai-steward/FloatingQAChat.tsx index 1621dbff..cec6c905 100644 --- a/frontend/src/components/ai-steward/FloatingQAChat.tsx +++ b/frontend/src/components/ai-steward/FloatingQAChat.tsx @@ -74,6 +74,33 @@ type ExternalRecommendation = { affected_signal_sources?: string[]; external_action?: string | null; }; +type AnalysisFocusObject = { + name: string; + kind?: string | null; + stance?: string | null; + reason: string; +}; +type AnalysisPacket = { + packet_version?: string; + packet_type?: string | null; + conclusion?: string | null; + focus_objects?: AnalysisFocusObject[]; + next_step?: string | null; + fit_verdict?: string | null; + preferred_layer?: string | null; + rejected_layer?: string | null; + missing_mapping?: string | null; + retrieval_status?: string | null; + impact_status?: string | null; + anchor_objects?: string[]; + blocked_reason_or_gap?: string | null; + answer_contract?: string | null; + resolved_target?: string | null; + evidence_gap_or_uncertainty?: string | null; + anchor_kind?: string | null; + continuation_kind?: string | null; + clarification_state?: string | null; +}; type SessionStateReplay = { session_id: number; last_subject?: string; @@ -137,6 +164,7 @@ type SessionMessage = { execution_recipe?: ExecutionRecipe | null; fit_brief?: ProjectFitBrief | null; external_recommendation?: ExternalRecommendation | null; + analysis_packet?: AnalysisPacket | null; handoff_mode?: string | null; can_enter_plan?: boolean | null; recommended_next_step?: string | null; @@ -304,6 +332,41 @@ function normalizeExternalRecommendation(value: unknown): ExternalRecommendation }; } +function normalizeAnalysisPacket(value: unknown): AnalysisPacket | null { + if (!value || typeof value !== "object") return null; + const item = value as Record; + const focusObjects = normalizeRecordList(item.focus_objects) + .map((focus) => ({ + name: String(focus.name || "").trim(), + kind: String(focus.kind || "").trim() || null, + stance: String(focus.stance || "").trim() || null, + reason: String(focus.reason || "").trim(), + })) + .filter((focus) => focus.name && focus.reason) + .slice(0, 2); + return { + packet_version: String(item.packet_version || "").trim() || undefined, + packet_type: String(item.packet_type || "").trim() || null, + conclusion: String(item.conclusion || "").trim() || null, + focus_objects: focusObjects, + next_step: String(item.next_step || "").trim() || null, + fit_verdict: String(item.fit_verdict || "").trim() || null, + preferred_layer: String(item.preferred_layer || "").trim() || null, + rejected_layer: String(item.rejected_layer || "").trim() || null, + missing_mapping: String(item.missing_mapping || "").trim() || null, + retrieval_status: String(item.retrieval_status || "").trim().toLowerCase() || null, + impact_status: String(item.impact_status || "").trim().toLowerCase() || null, + anchor_objects: normalizeStringList(item.anchor_objects), + blocked_reason_or_gap: String(item.blocked_reason_or_gap || "").trim() || null, + answer_contract: String(item.answer_contract || "").trim() || null, + resolved_target: String(item.resolved_target || "").trim() || null, + evidence_gap_or_uncertainty: String(item.evidence_gap_or_uncertainty || "").trim() || null, + anchor_kind: String(item.anchor_kind || "").trim() || null, + continuation_kind: String(item.continuation_kind || "").trim() || null, + clarification_state: String(item.clarification_state || "").trim() || null, + }; +} + function normalizeSessionStateReplay(value: unknown): SessionStateReplay | null { if (!value || typeof value !== "object") return null; const item = value as Record; @@ -550,6 +613,7 @@ export function FloatingQAChat() { execution_recipe: normalizeExecutionRecipe(message.execution_recipe), fit_brief: normalizeProjectFitBrief(message.fit_brief), external_recommendation: normalizeExternalRecommendation(message.external_recommendation), + analysis_packet: normalizeAnalysisPacket(message.analysis_packet), 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, @@ -742,6 +806,7 @@ export function FloatingQAChat() { execution_recipe: normalizeExecutionRecipe(data.execution_recipe), fit_brief: normalizeProjectFitBrief(data.fit_brief), external_recommendation: normalizeExternalRecommendation(data.external_recommendation), + analysis_packet: normalizeAnalysisPacket(data.analysis_packet), 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, @@ -1201,6 +1266,7 @@ export function FloatingQAChat() { groundingStatus={msg.grounding_status} sourcesUsed={msg.sources_used} evidencePack={msg.evidence_pack} + analysisPacket={msg.analysis_packet} fitBrief={msg.fit_brief} externalRecommendation={msg.external_recommendation} canEnterPlan={msg.can_enter_plan} diff --git a/frontend/src/components/ai-steward/QAResponseMetaCard.test.tsx b/frontend/src/components/ai-steward/QAResponseMetaCard.test.tsx index 3ddfe7f6..6630318e 100644 --- a/frontend/src/components/ai-steward/QAResponseMetaCard.test.tsx +++ b/frontend/src/components/ai-steward/QAResponseMetaCard.test.tsx @@ -5,6 +5,54 @@ import { QAResponseMetaCard } from "@/components/ai-steward/QAResponseMetaCard"; import { renderWithProviders } from "@/test/renderWithProviders"; describe("QAResponseMetaCard", () => { + it("renders analysis packet as the primary analysis view", () => { + renderWithProviders( + , + ); + + expect(screen.getByText("分析结果")).toBeTruthy(); + expect(screen.getByText(/放到采购经理每日实时库存上报服务里/)).toBeTruthy(); + expect(screen.getByText(/当前主体:采购经理每日实时库存上报服务/)).toBeTruthy(); + expect(screen.getByText(/更像放在:每日固定时段生成并推送结构化摘要卡片/)).toBeTruthy(); + expect(screen.getByText(/不适合放在:WMS库存快照自动拉取与清洗/)).toBeTruthy(); + expect(screen.getByText(/结果要稳定、可复算/)).toBeTruthy(); + expect(screen.getByText(/更接近结果出来之后的解释和追问层/)).toBeTruthy(); + expect(screen.getByText(/还缺它在每日固定时段生成并推送结构化摘要卡片这一步到底承担解释、问答还是编排角色的明确映射/)).toBeTruthy(); + }); + it("renders fit brief, external recommendation, and next-step sections", () => { const onUseRecommendedNextStep = vi.fn(); diff --git a/frontend/src/components/ai-steward/QAResponseMetaCard.tsx b/frontend/src/components/ai-steward/QAResponseMetaCard.tsx index 82aa3971..892a05ad 100644 --- a/frontend/src/components/ai-steward/QAResponseMetaCard.tsx +++ b/frontend/src/components/ai-steward/QAResponseMetaCard.tsx @@ -110,11 +110,108 @@ function resolveExternalRecommendationLabel( return null; } +function normalizeAnalysisPacket( + analysisPacket?: Record | null, +): { + packetType: string | null; + conclusion: string | null; + focusObjects: Array<{ name: string; kind: string | null; stance: string | null; reason: string }>; + nextStep: string | null; + currentSubject: string | null; + candidatePosition: string | null; + rejectedPosition: string | null; + reasonChain: string[]; + fitVerdict: string | null; + preferredLayer: string | null; + rejectedLayer: string | null; + missingMapping: string | null; + retrievalStatus: string | null; + impactStatus: string | null; + anchorObjects: string[]; + blockedReasonOrGap: string | null; + answerContract: string | null; + resolvedTarget: string | null; + evidenceGapOrUncertainty: string | null; + anchorKind: string | null; + continuationKind: string | null; + clarificationState: string | null; +} { + if (!analysisPacket || typeof analysisPacket !== "object") { + return { + packetType: null, + conclusion: null, + focusObjects: [], + nextStep: null, + currentSubject: null, + candidatePosition: null, + rejectedPosition: null, + reasonChain: [], + fitVerdict: null, + preferredLayer: null, + rejectedLayer: null, + missingMapping: null, + retrievalStatus: null, + impactStatus: null, + anchorObjects: [], + blockedReasonOrGap: null, + answerContract: null, + resolvedTarget: null, + evidenceGapOrUncertainty: null, + anchorKind: null, + continuationKind: null, + clarificationState: null, + }; + } + const rawFocus = Array.isArray(analysisPacket.focus_objects) ? analysisPacket.focus_objects : []; + return { + packetType: String(analysisPacket.packet_type || "").trim() || null, + conclusion: String(analysisPacket.conclusion || "").trim() || null, + focusObjects: rawFocus + .filter((item): item is Record => Boolean(item) && typeof item === "object") + .map((item) => ({ + name: String(item.name || "").trim(), + kind: String(item.kind || "").trim() || null, + stance: String(item.stance || "").trim().toLowerCase() || null, + reason: String(item.reason || "").trim(), + })) + .filter((item) => item.name && item.reason) + .slice(0, 2), + nextStep: String(analysisPacket.next_step || "").trim() || null, + currentSubject: String(analysisPacket.current_subject || "").trim() || null, + candidatePosition: String(analysisPacket.candidate_position || "").trim() || null, + rejectedPosition: String(analysisPacket.rejected_position || "").trim() || null, + reasonChain: normalizeStringList(analysisPacket.reason_chain, 3), + fitVerdict: String(analysisPacket.fit_verdict || "").trim().toLowerCase() || null, + preferredLayer: String(analysisPacket.preferred_layer || "").trim() || null, + rejectedLayer: String(analysisPacket.rejected_layer || "").trim() || null, + missingMapping: String(analysisPacket.missing_mapping || "").trim() || null, + retrievalStatus: String(analysisPacket.retrieval_status || "").trim().toLowerCase() || null, + impactStatus: String(analysisPacket.impact_status || "").trim().toLowerCase() || null, + anchorObjects: normalizeStringList(analysisPacket.anchor_objects), + blockedReasonOrGap: String(analysisPacket.blocked_reason_or_gap || "").trim() || null, + answerContract: String(analysisPacket.answer_contract || "").trim() || null, + resolvedTarget: String(analysisPacket.resolved_target || "").trim() || null, + evidenceGapOrUncertainty: String(analysisPacket.evidence_gap_or_uncertainty || "").trim() || null, + anchorKind: String(analysisPacket.anchor_kind || "").trim() || null, + continuationKind: String(analysisPacket.continuation_kind || "").trim() || null, + clarificationState: String(analysisPacket.clarification_state || "").trim() || null, + }; +} + +function resolveAnalysisPacketLabel(packetType: string | null): string | null { + if (packetType === "project_fit_analysis") return "项目适配判断"; + if (packetType === "external_impact_analysis") return "外部影响判断"; + if (packetType === "internal_explain_analysis") return "内部解释"; + if (packetType === "followup_explain_analysis") return "续问上下文"; + return null; +} + interface QAResponseMetaCardProps { routeDecision?: Record | null; groundingStatus?: string | null; sourcesUsed?: Array>; evidencePack?: Record | null; + analysisPacket?: Record | null; fitBrief?: Record | null; externalRecommendation?: Record | null; canEnterPlan?: boolean | null; @@ -127,6 +224,7 @@ export function QAResponseMetaCard({ groundingStatus, sourcesUsed, evidencePack, + analysisPacket, fitBrief, externalRecommendation, canEnterPlan, @@ -138,9 +236,15 @@ export function QAResponseMetaCard({ const sourceSummary = normalizeSourceSummary(sourcesUsed); const conclusion = normalizeConclusion(evidencePack); const uncertainties = normalizeUncertainties(evidencePack); + const normalizedAnalysisPacket = normalizeAnalysisPacket(analysisPacket); + const analysisPacketLabel = resolveAnalysisPacketLabel(normalizedAnalysisPacket.packetType); const fitBriefSummary = normalizeFitBrief(fitBrief); const externalSummary = normalizeExternalRecommendation(externalRecommendation); const externalStatus = resolveExternalRecommendationLabel(externalRecommendation); + const hasAnalysisPacket = + Boolean(normalizedAnalysisPacket.packetType) || + Boolean(normalizedAnalysisPacket.conclusion) || + normalizedAnalysisPacket.focusObjects.length > 0; const hasFitBrief = Boolean(fitBriefSummary.projectSummary) || Boolean(fitBriefSummary.repoSummary) || @@ -167,6 +271,67 @@ export function QAResponseMetaCard({ {uncertainties.length ? (
不确定项:{uncertainties.join(" / ")}
) : null} + {hasAnalysisPacket ? ( +
+
+
分析结果
+ {analysisPacketLabel ? {analysisPacketLabel} : null} +
+ {normalizedAnalysisPacket.conclusion ? ( +
{normalizedAnalysisPacket.conclusion}
+ ) : null} + {normalizedAnalysisPacket.currentSubject ? ( +
当前主体:{normalizedAnalysisPacket.currentSubject}
+ ) : null} + {normalizedAnalysisPacket.candidatePosition ? ( +
更像放在:{normalizedAnalysisPacket.candidatePosition}
+ ) : null} + {normalizedAnalysisPacket.rejectedPosition ? ( +
不适合放在:{normalizedAnalysisPacket.rejectedPosition}
+ ) : null} + {normalizedAnalysisPacket.reasonChain.length ? ( +
+ {normalizedAnalysisPacket.reasonChain.map((item) => ( +
{item}
+ ))} +
+ ) : null} + {normalizedAnalysisPacket.focusObjects.length && normalizedAnalysisPacket.reasonChain.length === 0 ? ( +
+ {normalizedAnalysisPacket.focusObjects.map((item) => ( +
+ {item.name}:{item.reason} +
+ ))} +
+ ) : null} + {normalizedAnalysisPacket.missingMapping ? ( +
+ 还缺映射:{normalizedAnalysisPacket.missingMapping} +
+ ) : null} + {normalizedAnalysisPacket.blockedReasonOrGap ? ( +
+ 当前缺口:{normalizedAnalysisPacket.blockedReasonOrGap} +
+ ) : null} + {normalizedAnalysisPacket.resolvedTarget ? ( +
+ 当前对象:{normalizedAnalysisPacket.resolvedTarget} +
+ ) : null} + {normalizedAnalysisPacket.evidenceGapOrUncertainty ? ( +
+ 解释边界:{normalizedAnalysisPacket.evidenceGapOrUncertainty} +
+ ) : null} + {normalizedAnalysisPacket.nextStep ? ( +
+ 分析下一步:{normalizedAnalysisPacket.nextStep} +
+ ) : null} +
+ ) : null} {hasFitBrief ? (
项目画像