Compare commits

...

2 Commits

116 changed files with 26292 additions and 1708 deletions

View File

@ -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
@ -56,19 +57,30 @@ backend/
- 当前后端重点入口优先看:
- `backend/app/api/` 下的 `*_api/` 子包与聚合 router
- `backend/app/services/ai_pmo/`(AI PMO 主链、Mounted Runtime、semantic context)
- `backend/app/services/adaptive_rag_service/`(只读问答主链;`mixin_query_route_gateway.py` 负责 non-plan `query.answer` 的 internal/external/uncertain 来源合同归一化、contract 审计与显式来源回复合同;`mixin_answer_retrieval.py` 现已把 `route_mode=external` 接到独立外部查询主链;internal 路径新增 `internal_query_context.py` 里的 `InternalContextPacket` 作为 `Operation Contract -> Target Resolver -> Query DSL Plan -> 受控执行器 -> semantic facts 优先 -> verifier` 的运行时单一真源)
- `backend/app/services/ai_pmo/project_operating_context_builder.py` + `backend/app/schemas/project_operating_context.py`(统一 `ProjectOperatingContext / ProjectFitBrief / ExecutionRecipe` 合同;`/rag/ask`、`/ai-pmo/process-text`、kernel ingress 会先补当前项目/仓库/线程锚点,再给 Query Route Gateway、fit brief builder 与 answer payload 消费)
- `backend/app/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 命中与异步抽取审计。
- 本轮新增的外部查询 fallback 与展示回归测试位于 `backend/tests/test_external_query_planner.py`、`backend/tests/test_mcp_external_query_bridge.py` 与 `backend/tests/test_adaptive_rag_mainline_contract.py`;它们一起锁定“Query Route Gateway 不再跑 runtime probes、External Query Planner fallback 不再按对象重分类、external answer presenter 不再回流 `industry_trend/external_trend`、external freshness follow-up 不再靠硬编码词表”。
- 本轮新增的外部查询 fallback、fit brief 与展示回归测试位于 `backend/tests/test_external_query_planner.py`、`backend/tests/test_mcp_external_query_bridge.py`、`backend/tests/test_internal_query_context_packet.py` 与 `backend/tests/test_adaptive_rag_mainline_contract.py`;它们一起锁定“Query Route Gateway 不再跑 runtime probes、External Query Planner fallback 不再按对象重分类、fit brief 会显式进入 external planner、external answer presenter 必须区分 `verifier_blocked / no_relevant_results / retrieval_failed / indirect_only`、external freshness follow-up 不再靠硬编码词表”。
- 详细目录迁移、兼容壳清理与新增模块清单见库存文档。
## 5. 前端结构(`frontend/`)
@ -92,6 +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 / 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 治理页)
@ -214,14 +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 / 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`;前端 `FloatingQAChat` 新增 `QAConversationStatusBar` 与 `QAResponseMetaCard`,显式展示当前对象、来源模式、证据状态与澄清按钮;`/ai-pmo/process-text` 与 `GET /ai-pmo/work-items` 现同步投影 `plan_mode / plan_status / requirement_plan_context_summary` 到 Inbox 展开区,回归由 `test_ai_pmo_qa_http_mainline_e2e.py`、`test_ai_pmo_ingress_mainline.py`、`FloatingQAChat.test.tsx`、`InboxListItem.test.tsx` 锁定
- [x] 三模式交互上下文基线已显性化:`/rag/ask`、QA session detail 与 session state replay 现统一透出 `route_decision / clarification_code / clarification_candidates / answer_mode / grounding_status / evidence_pack / project_operating_context / execution_recipe / fit_brief / external_recommendation / 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` 做仓库级守门

View File

@ -9,6 +9,7 @@ from .helpers_inbox_payload import (
_serialize_work_item_summary,
)
from .schemas import PMOProcessTextRequest
from ...services.ai_pmo.project_operating_context_builder import ProjectOperatingContextBuilder
@router.post("/process-text")
async def process_text(
@ -18,6 +19,12 @@ async def process_text(
tenant_id: int = Depends(get_tenant_id),
):
service = AIPMOKernelService(db, tenant_id=tenant_id)
project_operating_context = ProjectOperatingContextBuilder().build_from_ingress(
project_hint_id=request.project_id,
rag_request_context={
"project_id": request.project_id,
},
)
result = await service.process_ingress(
source_system="pmflow_manual",
source_message_id=request.source_message_id,
@ -28,6 +35,9 @@ async def process_text(
captured_at=None,
chat_type="manual",
project_hint_id=request.project_id,
ingress_options={
"project_operating_context": project_operating_context.model_dump(),
},
)
if not result.get("handled"):
raise HTTPException(status_code=400, detail=str(result.get("reason") or "AI PMO kernel 未处理该输入"))

View File

@ -5,6 +5,7 @@ from uuid import uuid4
from ..models import User
from ..schemas.rag import RAGAskRequest
from ..services.ai_pmo.project_operating_context_builder import ProjectOperatingContextBuilder
def resolve_effective_tenant_id(current_user: User, fallback_tenant_id: int) -> int:
@ -74,6 +75,10 @@ def _default_evidence_pack(*, answer: str, tool_trace: list[dict[str, Any]]) ->
def build_kernel_ingress_kwargs(request: RAGAskRequest, current_user: User) -> dict[str, Any]:
rag_request_context = build_answer_question_kwargs(request=request, current_user=current_user)
project_operating_context = ProjectOperatingContextBuilder().build_from_ingress(
project_hint_id=request.project_id,
rag_request_context=rag_request_context,
)
return {
"source_system": str(request.asked_via or "pmflow").strip() or "pmflow",
"source_message_id": f"rag-ask-{uuid4().hex}",
@ -86,6 +91,7 @@ def build_kernel_ingress_kwargs(request: RAGAskRequest, current_user: User) -> d
"project_hint_id": request.project_id,
"ingress_options": {
"rag_request_context": rag_request_context,
"project_operating_context": project_operating_context.model_dump(),
},
}

View File

@ -15,6 +15,8 @@ from .ai_prompts_catalog import (
PROMPT_CODE_AUDIT,
PROMPT_CONTEXT_INSIGHTS,
PROMPT_FEISHU_HUMANIZED_REPLY,
PROMPT_FEISHU_QA_NATURAL_REPLY,
PROMPT_FEISHU_QA_NATURAL_REPLY_REWRITE,
PROMPT_FEISHU_PROJECT_SIGNAL_CLASSIFICATION,
PROMPT_FEISHU_LEVEL_CLASSIFICATION,
PROMPT_FEISHU_SIGNAL_EXTRACTION,
@ -54,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,
@ -98,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"。
@ -116,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。
@ -149,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": []
}
@ -174,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 的内部字段名给用户。
@ -185,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。
@ -213,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
}}
@ -275,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 层。
@ -323,6 +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 / 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`。
- 结构化失败时优先保守,不要编造实体。
@ -341,7 +494,12 @@ JSON schema:
"search_queries": ["string"],
"result_limit": 3,
"requires_multi_source": false,
"clarify_on_ambiguity": false
"clarify_on_ambiguity": false,
"fit_brief_summary": "string",
"project_scope_hint": "string | null",
"recommendation_goal": "string | null",
"project_object_anchors": ["string"],
"signal_source_objectives": ["string"]
}}
question:
@ -350,6 +508,9 @@ question:
query_semantics:
{query_semantics}
fit_brief:
{fit_brief}
fallback_queries:
{fallback_queries}
""".strip()
@ -805,6 +966,8 @@ __all__ = [
"PROMPT_CONTEXT_INSIGHTS",
"PROMPT_FEISHU_REQUIREMENT_ANALYSIS",
"PROMPT_FEISHU_HUMANIZED_REPLY",
"PROMPT_FEISHU_QA_NATURAL_REPLY",
"PROMPT_FEISHU_QA_NATURAL_REPLY_REWRITE",
"PROMPT_FEISHU_PROJECT_SIGNAL_CLASSIFICATION",
"PROMPT_FEISHU_LEVEL_CLASSIFICATION",
"PROMPT_FEISHU_SIGNAL_EXTRACTION",
@ -845,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",

View File

@ -1,6 +1,6 @@
from __future__ import annotations
from .part_01 import PROMPT_IMPACT_ANALYSIS, PROMPT_STRATEGIC_ANALYSIS_HEADER, PROMPT_RAG_CONTEXT_HEADER, PROMPT_RAG_CONTEXT_ITEM, PROMPT_CODE_AUDIT, PROMPT_CONTEXT_INSIGHTS, PROMPT_FEISHU_HUMANIZED_REPLY, PROMPT_FEISHU_PROJECT_SIGNAL_CLASSIFICATION, PROMPT_FEISHU_LEVEL_CLASSIFICATION
from .part_01 import PROMPT_IMPACT_ANALYSIS, PROMPT_STRATEGIC_ANALYSIS_HEADER, PROMPT_RAG_CONTEXT_HEADER, PROMPT_RAG_CONTEXT_ITEM, PROMPT_CODE_AUDIT, PROMPT_CONTEXT_INSIGHTS, PROMPT_FEISHU_HUMANIZED_REPLY, PROMPT_FEISHU_QA_NATURAL_REPLY, PROMPT_FEISHU_QA_NATURAL_REPLY_REWRITE, PROMPT_FEISHU_PROJECT_SIGNAL_CLASSIFICATION, PROMPT_FEISHU_LEVEL_CLASSIFICATION
from .part_02 import PROMPT_FEISHU_SIGNAL_EXTRACTION, PROMPT_FEISHU_PROJECT_PREFILL, PROMPT_PROJECT_REQUIREMENT_EXTRACTION, PROMPT_FEISHU_TASK_ORCHESTRATION, PROMPT_SIGNAL_SOURCE_ONBOARDING, PROMPT_AI_WORKER_SIGNAL_SOURCE_EXECUTION, PROMPT_AI_PMO_INTENT_CLASSIFICATION, PROMPT_AI_PMO_SIGNAL_BATCH_ANALYSIS, PROMPT_DRAFT_GENERATOR, PROMPT_RAG_INTENT_CLASSIFICATION
from .part_03 import PROMPT_RAG_ANSWER, PROMPT_DECISION_QA, PROMPT_RAG_DB_PROJECT_QA, PROMPT_GENERATE_CHARTER, PROMPT_DECOMPOSE_TASKS, PROMPT_RECOMMEND_ASSIGNEES, PROMPT_ALIGNMENT_SCORE, PROMPT_COGNITIVE_CONFLICTS, PROMPT_EVALUATE_MODEL_REQUIREMENT, PROMPT_ANALYZE_CAPABILITY_GAP, PROMPT_ANALYZE_COST_ROI
from .part_04 import PROMPT_ANALYZE_COST_OPTIMIZATION, PROMPT_ASSESS_MODEL_UPDATE_IMPACT, PROMPT_EXPERIMENT_SYNC_DECISION, PROMPT_SKILL_SCORE_UPDATE, PROMPT_WORKLOAD_OPTIMIZATION, PROMPT_ANALYZE_FOOD_CALORIES, PROMPT_SIGNAL_PLANNER
@ -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,
@ -25,6 +27,8 @@ __all__ = [
"PROMPT_CODE_AUDIT",
"PROMPT_CONTEXT_INSIGHTS",
"PROMPT_FEISHU_HUMANIZED_REPLY",
"PROMPT_FEISHU_QA_NATURAL_REPLY",
"PROMPT_FEISHU_QA_NATURAL_REPLY_REWRITE",
"PROMPT_FEISHU_PROJECT_SIGNAL_CLASSIFICATION",
"PROMPT_FEISHU_LEVEL_CLASSIFICATION",
"PROMPT_FEISHU_SIGNAL_EXTRACTION",
@ -67,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",
]

View File

@ -180,6 +180,79 @@ PROMPT_FEISHU_HUMANIZED_REPLY = _compose_prompt(
TEXT_OUTPUT_GUARDRAIL,
)
PROMPT_FEISHU_QA_NATURAL_REPLY = """
你要先为一条飞书 QA 结果产出一个很小的结构化分析包,不要直接写最终回复。
输入信息:
- 用户原问题:{question}
- 当前回答结论:{answer_summary}
- 当前项目摘要:{project_summary}
- 可选项目实体(JSON):{project_entities}
- 外部判断:{external_assessment}
- 建议下一步:{next_step}
输出 JSON:
{{
"conclusion": "一句人话结论,不超过40字",
"focus_entities": [
{{
"name": "必须从可选项目实体中选择",
"stance": "better_fit|not_fit|anchor|dependent|blocked|relevant",
"why": "它为什么和当前判断直接相关,不超过50字"
}}
],
"missing_mapping": "还缺哪一层映射,不超过60字",
"next_step": "若仍值得继续,给一句自然下一步;否则输出空字符串"
}}
硬性禁令:
1. `focus_entities` 最多 2 个,至少 1 个。
2. `name` 只能从“可选项目实体”里选,禁止编造、扩写、改写或补充新实体。
3. 优先选最能支撑判断的 1-2 个实体,不要把项目里所有任务和信号源都列出来。
4. 不允许脑补项目目标、方案层次、执行动作或系统接口。
5. 只输出 JSON,不要输出解释、Markdown、标题或正文。
"""
PROMPT_FEISHU_QA_NATURAL_REPLY = _compose_prompt(
PMFLOW_PMO_STYLE_BASE,
PROMPT_FEISHU_QA_NATURAL_REPLY,
TEXT_OUTPUT_GUARDRAIL,
)
PROMPT_FEISHU_QA_NATURAL_REPLY_REWRITE = """
你现在已经拿到一个受控分析包,只负责把它改写成自然、克制、像同事在解释判断结果的中文回复。
输入信息:
- 用户原问题:{question}
- 分析包(JSON):{analysis_packet}
要求:
1. 输出 2-3 段自然中文。
2. 第一段直接进入判断,不要固定抬头。
3. 只能围绕分析包里的 `focus_entities` 来写,不允许增加新实体,不允许把实体写成清单。
4. 如果分析包里有 `current_subject`、`candidate_position`、`rejected_position` 或 `reason_chain`,优先按这些字段组织表达,不要自己重组出另一套分析。
5. `missing_mapping` 必须明确说出来;如果 `next_step` 为空,就不要强行补下一步。
6. 结论必须和分析包逐项对应,不能发散、不能补戏、不能装懂。
硬性禁令:
1. 禁止固定开头,例如:
- 先说我的判断
- 我先说结论
- 这事可以继续看
- 如果你要,我可以
2. 禁止使用标签句式:
- 当前项目:
- 外部判断:
- 下一步:
3. 禁止空话套话,例如“从某种意义上说”“整体来看”“综合来看”“更稳妥的做法”。
4. 禁止把项目里所有任务、信号源平铺展开。
5. 只输出最终回复正文,不要解释写法,不要输出 Markdown 标题或列表。
"""
PROMPT_FEISHU_QA_NATURAL_REPLY_REWRITE = _compose_prompt(
PMFLOW_PMO_STYLE_BASE,
PROMPT_FEISHU_QA_NATURAL_REPLY_REWRITE,
TEXT_OUTPUT_GUARDRAIL,
)
PROMPT_FEISHU_PROJECT_SIGNAL_CLASSIFICATION = """
你是 PMFlow 的项目需求信号分流器(分类层)。请判断消息是否为有效需求,并识别是否能明确归属已有项目。
@ -258,6 +331,8 @@ __all__ = [
"PROMPT_CODE_AUDIT",
"PROMPT_CONTEXT_INSIGHTS",
"PROMPT_FEISHU_HUMANIZED_REPLY",
"PROMPT_FEISHU_QA_NATURAL_REPLY",
"PROMPT_FEISHU_QA_NATURAL_REPLY_REWRITE",
"PROMPT_FEISHU_PROJECT_SIGNAL_CLASSIFICATION",
"PROMPT_FEISHU_LEVEL_CLASSIFICATION",
]

View File

@ -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,

View File

@ -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",
]

View File

@ -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",
]

View File

@ -0,0 +1,34 @@
"""Unified project operating context contracts."""
from __future__ import annotations
from typing import Any, Literal
from pydantic import BaseModel, Field
class ProjectOperatingContext(BaseModel):
context_version: str = "v1"
current_project_id: int | None = None
current_repo_scope: str | None = None
page_anchor: dict[str, Any] = Field(default_factory=dict)
thread_anchor: dict[str, Any] = Field(default_factory=dict)
fit_scope: Literal["current_project_and_repo"] = "current_project_and_repo"
decision_strategy: Literal["answer_then_offer_plan"] = "answer_then_offer_plan"
class ProjectFitBrief(BaseModel):
project_summary: str | None = None
repo_summary: str | None = None
current_capabilities: list[str] = Field(default_factory=list)
signal_source_anchors: list[str] = Field(default_factory=list)
signal_source_objectives: list[str] = Field(default_factory=list)
constraints: list[str] = Field(default_factory=list)
recommendation_focus: list[str] = Field(default_factory=list)
class ExecutionRecipe(BaseModel):
primary_lane: Literal["internal", "external", "plan"] = "internal"
fit_brief_required: bool = False
external_evidence_required: bool = False
plan_handoff_mode: Literal["none", "offer_after_answer", "explicit_plan_now"] = "none"

View File

@ -100,6 +100,11 @@ class ActiveProjectSnapshot(TypedDict):
owner_id: Optional[int]
class RepoSnapshot(TypedDict):
scope: str
summary_mode: str
class DecisionContextSnapshot(TypedDict):
captured_at: str
project_id: Optional[int]
@ -112,6 +117,8 @@ class DecisionContextSnapshot(TypedDict):
class WorldModel(TypedDict):
sender: str
resolved_project_id: Optional[int]
repo_scope: NotRequired[str]
repo_summary: NotRequired[RepoSnapshot]
project: Optional[ProjectSnapshot]
project_domain: Optional[ProjectDomainSnapshot]
portfolio: PortfolioSummary
@ -142,6 +149,7 @@ __all__ = [
"DependencySummary",
"RecentSignalItem",
"ActiveProjectSnapshot",
"RepoSnapshot",
"DecisionContextSnapshot",
"WorldModel",
]

View File

@ -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)

View File

@ -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:
@ -590,12 +613,59 @@ class AdaptiveRAGAnswerMainlineMixin:
route_decision=route_decision,
),
"route_decision": self._normalize_query_route_decision(route_decision),
"project_operating_context": self._build_project_operating_context_packet(
project_id=project_id,
source_chat_id=source_chat_id,
reply_to_message_id=str((thread_context or {}).get("reply_to_message_id") or "").strip() or None,
root_message_id=str((thread_context or {}).get("root_message_id") or "").strip() or None,
) if hasattr(self, "_build_project_operating_context_packet") else {},
"execution_recipe": self._build_execution_recipe(
query_semantics=query_semantics,
route_mode=str((route_decision or {}).get("route_mode") or ""),
effective_source_policy=str((route_decision or {}).get("effective_source_policy") or ""),
project_operating_context=(
self._build_project_operating_context_packet(
project_id=project_id,
source_chat_id=source_chat_id,
reply_to_message_id=str((thread_context or {}).get("reply_to_message_id") or "").strip() or None,
root_message_id=str((thread_context or {}).get("root_message_id") or "").strip() or None,
)
if hasattr(self, "_build_project_operating_context_packet")
else {}
),
) if hasattr(self, "_build_execution_recipe") else {},
}
query_semantics = (
dict(route_preview.get("query_semantics") or {})
if isinstance(route_preview.get("query_semantics"), dict)
else {}
)
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)
else {}
)
execution_recipe = (
dict(route_preview.get("execution_recipe") or {})
if isinstance(route_preview.get("execution_recipe"), dict)
else {}
)
if project_operating_context:
query_semantics["project_operating_context"] = project_operating_context
if execution_recipe:
query_semantics["execution_recipe"] = execution_recipe
operation_contract = None
operation_contract_builder = getattr(self, "_build_operation_contract", None)
if callable(operation_contract_builder):
@ -843,6 +913,8 @@ class AdaptiveRAGAnswerMainlineMixin:
user_id=asked_by,
)
prefers_external_retrieval = (
str((query_semantics.get("execution_recipe") or {}).get("primary_lane") or "").strip().lower() == "external"
or
str(query_semantics.get("effective_source_policy") or query_semantics.get("source_policy") or "").strip().lower()
== "external"
)

View File

@ -1,7 +1,15 @@
"""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,
internal_context_operation_contract,
@ -21,6 +29,297 @@ class AdaptiveRAGAnswerPayloadMixin:
"system": 10,
}
_INTERNAL_SOURCE_TYPES = {"db", "graph", "doc", "pmflow", "feishu"}
_EXTERNAL_RETRIEVAL_STATUSES = {
"evidence_found",
"verifier_blocked",
"no_relevant_results",
"retrieval_failed",
}
_EXTERNAL_IMPACT_STATUSES = {
"direct_match",
"indirect_only",
"no_direct_impact",
}
_ENV_ENABLE_ANALYSIS_PACKET_FAMILY = "PMFLOW_ENABLE_ANALYSIS_PACKET_FAMILY"
_ENV_ENABLE_DEFAULT_PLAN_HANDOFF_RENDERING = "PMFLOW_ENABLE_DEFAULT_PLAN_HANDOFF_RENDERING"
@staticmethod
def _normalize_project_operating_context(payload: Optional[dict[str, Any]]) -> dict[str, Any]:
if not isinstance(payload, dict):
return {}
try:
return ProjectOperatingContext.model_validate(payload).model_dump()
except Exception:
return {}
@staticmethod
def _normalize_execution_recipe(payload: Optional[dict[str, Any]]) -> dict[str, Any]:
if not isinstance(payload, dict):
return {}
try:
return ExecutionRecipe.model_validate(payload).model_dump()
except Exception:
return {}
@staticmethod
def _normalize_fit_brief(payload: Optional[dict[str, Any]]) -> dict[str, Any]:
if not isinstance(payload, dict) or not payload:
return {}
try:
return ProjectFitBrief.model_validate(payload).model_dump()
except Exception:
return {}
@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:
return {}
retrieval_status = str(payload.get("retrieval_status") or "").strip().lower()
impact_status = str(payload.get("impact_status") or "").strip().lower()
if retrieval_status not in cls._EXTERNAL_RETRIEVAL_STATUSES:
retrieval_status = "retrieval_failed"
if impact_status not in cls._EXTERNAL_IMPACT_STATUSES:
impact_status = "no_direct_impact"
return {
"retrieval_status": retrieval_status,
"impact_status": impact_status,
"external_summary": str(payload.get("external_summary") or "").strip(),
"external_impact": str(payload.get("external_impact") or "").strip(),
"affected_signal_sources": [
str(item).strip()
for item in list(payload.get("affected_signal_sources") or [])
if str(item).strip()
][:6],
"external_action": str(payload.get("external_action") or "").strip(),
}
@classmethod
def _normalize_external_anchor_text(cls, value: Any) -> str:
raw = str(value or "").strip().lower()
if not raw:
return ""
chars: list[str] = []
for char in raw:
if char.isalnum() or "\u4e00" <= char <= "\u9fff":
chars.append(char)
return "".join(chars)
@classmethod
def _derive_external_retrieval_status(cls, packet: Optional[dict[str, Any]]) -> str:
normalized = packet if isinstance(packet, dict) else {}
explicit = str(normalized.get("retrieval_status") or "").strip().lower()
if explicit in cls._EXTERNAL_RETRIEVAL_STATUSES:
return explicit
verifier = normalized.get("verifier") if isinstance(normalized.get("verifier"), dict) else {}
verified_evidence = list(normalized.get("verified_evidence") or [])
if verified_evidence and bool(verifier.get("ok")):
return "evidence_found"
if verified_evidence and not bool(verifier.get("ok")):
return "verifier_blocked"
attempts = [item for item in list(normalized.get("adapter_attempts") or []) if isinstance(item, dict)]
if any(int(item.get("evidence_count") or 0) > 0 and not bool(item.get("verifier_ok")) for item in attempts):
return "verifier_blocked"
statuses = {
str(item.get("status") or "").strip().lower()
for item in attempts
if str(item.get("status") or "").strip()
}
if any(status in {"success", "empty"} for status in statuses):
return "no_relevant_results"
return "retrieval_failed"
@classmethod
def _collect_fit_brief_signal_source_anchors(cls, fit_brief: Optional[dict[str, Any]]) -> list[str]:
normalized_fit_brief = cls._normalize_fit_brief(fit_brief)
anchors: list[str] = []
for item in list(normalized_fit_brief.get("signal_source_anchors") or []):
normalized = str(item or "").strip()
compact = cls._normalize_external_anchor_text(normalized)
if len(compact) >= 2 and normalized not in anchors:
anchors.append(normalized)
return anchors[:6]
@classmethod
def _collect_external_evidence_texts(cls, packet: Optional[dict[str, Any]]) -> list[str]:
normalized = packet if isinstance(packet, dict) else {}
texts: list[str] = []
for item in list(normalized.get("verified_evidence") or []):
if not isinstance(item, dict):
continue
for value in (
item.get("source_label"),
item.get("title"),
item.get("snippet"),
item.get("target_url"),
):
text = str(value or "").strip()
if text:
texts.append(text)
return texts
@classmethod
def _build_external_recommendation(
cls,
*,
external_context_packet: Optional[dict[str, Any]],
fit_brief: Optional[dict[str, Any]],
) -> dict[str, Any]:
packet = dict(external_context_packet or {}) if isinstance(external_context_packet, dict) else {}
retrieval_status = cls._derive_external_retrieval_status(packet)
if retrieval_status != "evidence_found":
return {
"retrieval_status": retrieval_status,
"impact_status": "no_direct_impact",
"external_summary": "",
"external_impact": "",
"affected_signal_sources": [],
"external_action": "",
}
anchors = cls._collect_fit_brief_signal_source_anchors(fit_brief)
evidence_texts = [cls._normalize_external_anchor_text(item) for item in cls._collect_external_evidence_texts(packet)]
affected_signal_sources: list[str] = []
for anchor in anchors:
compact_anchor = cls._normalize_external_anchor_text(anchor)
if len(compact_anchor) < 2:
continue
if any(compact_anchor in text for text in evidence_texts if text):
affected_signal_sources.append(anchor)
first_evidence = next(
(item for item in list(packet.get("verified_evidence") or []) if isinstance(item, dict)),
{},
)
external_summary = str(
first_evidence.get("snippet")
or first_evidence.get("title")
or first_evidence.get("source_label")
or ""
).strip()[:240]
if affected_signal_sources:
return {
"retrieval_status": "evidence_found",
"impact_status": "direct_match",
"external_summary": external_summary,
"external_impact": f"外部证据已和当前项目对象建立直接映射:{'、'.join(affected_signal_sources[:3])}",
"affected_signal_sources": affected_signal_sources[:6],
"external_action": "先做映射验证,再决定是否把这条外部变化正式纳入当前项目计划。",
}
return {
"retrieval_status": "evidence_found",
"impact_status": "indirect_only",
"external_summary": external_summary,
"external_impact": "当前外部证据只支持背景相关性,暂不能直接映射到当前项目对象。",
"affected_signal_sources": [],
"external_action": "先做映射验证,确认这些外部变化是否真的影响当前项目。",
}
def _build_plan_handoff_fields(
self,
*,
execution_recipe: Optional[dict[str, Any]],
project_operating_context: Optional[dict[str, Any]],
) -> dict[str, Any]:
normalized_recipe = self._normalize_execution_recipe(execution_recipe)
normalized_context = self._normalize_project_operating_context(project_operating_context)
handoff_mode = str(normalized_recipe.get("plan_handoff_mode") or "").strip().lower() or None
if (
handoff_mode == "offer_after_answer"
and not _env_bool(self._ENV_ENABLE_DEFAULT_PLAN_HANDOFF_RENDERING, True)
):
handoff_mode = None
can_enter_plan = handoff_mode in {"offer_after_answer", "explicit_plan_now"}
recommended_next_step = None
if handoff_mode == "offer_after_answer":
if normalized_context.get("current_project_id") is not None:
recommended_next_step = "如果你要,我可以基于当前结论进入 plan,继续把需求范围和下一步收清楚。"
else:
recommended_next_step = "如果你要,我可以基于当前结论继续进入 plan。"
elif handoff_mode == "explicit_plan_now":
recommended_next_step = "当前已命中明确的计划上下文,可以直接进入 plan 继续推进。"
return {
"handoff_mode": handoff_mode,
"can_enter_plan": can_enter_plan,
"recommended_next_step": recommended_next_step,
}
@staticmethod
def _safe_source_metadata(raw: Any) -> dict[str, Any]:
@ -553,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":
@ -566,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:
@ -601,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(
@ -646,6 +955,13 @@ class AdaptiveRAGAnswerPayloadMixin:
evidence_pack: Optional[dict[str, Any]] = None,
prompt_variant_id: Optional[str] = None,
route_decision: Optional[dict[str, Any]] = None,
project_operating_context: Optional[dict[str, Any]] = None,
execution_recipe: Optional[dict[str, Any]] = None,
fit_brief: Optional[dict[str, Any]] = None,
external_recommendation: Optional[dict[str, Any]] = None,
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]:
@ -695,6 +1011,39 @@ class AdaptiveRAGAnswerPayloadMixin:
if hasattr(self, "_normalize_query_route_decision")
else {}
)
normalized_project_operating_context = self._normalize_project_operating_context(
project_operating_context
)
normalized_execution_recipe = self._normalize_execution_recipe(execution_recipe)
normalized_fit_brief = self._normalize_fit_brief(fit_brief)
normalized_external_recommendation = self._normalize_external_recommendation(external_recommendation)
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,
@ -742,6 +1091,15 @@ class AdaptiveRAGAnswerPayloadMixin:
"sources_used": normalized_sources_used,
"evidence_pack": normalized_evidence_pack,
"route_decision": normalized_route_decision,
"project_operating_context": normalized_project_operating_context,
"execution_recipe": normalized_execution_recipe,
"fit_brief": normalized_fit_brief,
"external_recommendation": normalized_external_recommendation,
"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"],
}
@staticmethod

View File

@ -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(

View File

@ -2,10 +2,13 @@
from __future__ import annotations
from .shared import *
from .internal_query_context import update_internal_query_context_packet
from .shared_utils import _env_bool
from .internal_query_context import internal_context_target_resolution, update_internal_query_context_packet
class AdaptiveRAGAnswerRetrievalMixin:
_ENV_ENABLE_FIT_BRIEF_EXTERNAL_RECOMMENDATION = "PMFLOW_ENABLE_FIT_BRIEF_EXTERNAL_RECOMMENDATION"
async def _execute_external_mcp_search(self, plan) -> dict[str, Any]:
from ..external_query_service.schemas import ExternalAdapterResult, ExternalQueryPlan
from ..mcp_config import McpConfigService
@ -73,6 +76,7 @@ class AdaptiveRAGAnswerRetrievalMixin:
*,
question: str,
query_semantics: Optional[dict[str, Any]] = None,
fit_brief: Optional[dict[str, Any]] = None,
result_limit: int = 3,
max_pages: int = 3,
) -> tuple[list[RetrievedItem], dict[str, Any]]:
@ -142,6 +146,7 @@ class AdaptiveRAGAnswerRetrievalMixin:
).execute(
question=question,
query_semantics=query_semantics,
fit_brief=fit_brief,
)
context_packet = execution.to_context_packet(question=question).model_dump(mode="json")
verifier = dict(execution.verifier or {})
@ -322,6 +327,16 @@ class AdaptiveRAGAnswerRetrievalMixin:
route_decision: Optional[dict[str, Any]] = None,
internal_context_packet: Any = None,
) -> dict[str, Any]:
project_operating_context = (
query_semantics.get("project_operating_context")
if isinstance(query_semantics.get("project_operating_context"), dict)
else {}
)
execution_recipe = (
query_semantics.get("execution_recipe")
if isinstance(query_semantics.get("execution_recipe"), dict)
else {}
)
domains = [str(item).strip().lower() for item in retrieval_domains if str(item or "").strip()]
if not domains:
domains = ["db", "graph", "doc", "feishu", "pmflow", "url"]
@ -333,16 +348,41 @@ class AdaptiveRAGAnswerRetrievalMixin:
if callable(normalize_route_decision)
else (dict(route_decision or {}) if isinstance(route_decision, dict) else {})
)
execution_primary_lane = str(execution_recipe.get("primary_lane") or "").strip().lower()
external_route_only = str(
normalized_route_decision.get("effective_source_policy")
or query_semantics.get("effective_source_policy")
or query_semantics.get("source_policy")
or ""
).strip().lower() == "external"
).strip().lower() == "external" or execution_primary_lane == "external"
fit_brief_external_recommendation_enabled = _env_bool(
self._ENV_ENABLE_FIT_BRIEF_EXTERNAL_RECOMMENDATION,
True,
)
fit_brief: dict[str, Any] = {}
build_project_fit_brief = getattr(self, "_build_project_fit_brief", None)
if (
fit_brief_external_recommendation_enabled
and external_route_only
and bool(execution_recipe.get("fit_brief_required"))
and callable(build_project_fit_brief)
):
try:
fit_brief = (
await build_project_fit_brief(
question=effective_q or clean_q,
project_operating_context=project_operating_context,
system_snapshot=system_snapshot,
user_snapshot=user_snapshot,
)
) or {}
except Exception:
fit_brief = {}
if external_route_only:
retrieved, search_meta = await self._execute_external_query_gateway(
question=effective_q,
query_semantics=query_semantics,
fit_brief=fit_brief,
result_limit=3,
max_pages=3,
)
@ -377,6 +417,16 @@ class AdaptiveRAGAnswerRetrievalMixin:
),
},
)
tool_trace.insert(
0,
{
"step": 0,
"tool": "execution_recipe",
"primary_lane": execution_recipe.get("primary_lane"),
"plan_handoff_mode": execution_recipe.get("plan_handoff_mode"),
"fit_brief_required": bool(execution_recipe.get("fit_brief_required")),
},
)
tool_trace.insert(
0,
{
@ -394,6 +444,76 @@ class AdaptiveRAGAnswerRetrievalMixin:
if external_route_only and isinstance(search_meta.get("external_context_packet"), dict)
else None
)
build_external_recommendation = getattr(self, "_build_external_recommendation", None)
external_recommendation = (
build_external_recommendation(
external_context_packet=external_context_packet,
fit_brief=fit_brief,
)
if (
fit_brief_external_recommendation_enabled
and external_route_only
and isinstance(external_context_packet, dict)
and callable(build_external_recommendation)
)
else {}
)
if external_route_only and isinstance(external_context_packet, dict) and isinstance(external_recommendation, dict):
external_context_packet = {
**external_context_packet,
"retrieval_status": external_recommendation.get("retrieval_status"),
"impact_status": external_recommendation.get("impact_status"),
"affected_signal_sources": list(external_recommendation.get("affected_signal_sources") or []),
}
build_plan_handoff_fields = getattr(self, "_build_plan_handoff_fields", None)
plan_handoff_fields = (
build_plan_handoff_fields(
execution_recipe=execution_recipe,
project_operating_context=project_operating_context,
)
if callable(build_plan_handoff_fields)
else {}
)
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"),
}
external_verifier = (
dict(external_context_packet.get("verifier") or {})
if isinstance(external_context_packet, dict)
@ -435,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)
@ -610,6 +745,21 @@ class AdaptiveRAGAnswerRetrievalMixin:
)
if isinstance(live_meta.get("external_context_packet"), dict):
external_context_packet = dict(live_meta.get("external_context_packet") or {})
external_recommendation = (
build_external_recommendation(
external_context_packet=external_context_packet,
fit_brief=fit_brief,
)
if fit_brief_external_recommendation_enabled and callable(build_external_recommendation)
else {}
)
if isinstance(external_recommendation, dict):
external_context_packet = {
**external_context_packet,
"retrieval_status": external_recommendation.get("retrieval_status"),
"impact_status": external_recommendation.get("impact_status"),
"affected_signal_sources": list(external_recommendation.get("affected_signal_sources") or []),
}
external_verifier = dict(external_context_packet.get("verifier") or {})
external_missing_fields = [
str(item).strip()
@ -810,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)
@ -899,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,
@ -917,6 +1097,7 @@ class AdaptiveRAGAnswerRetrievalMixin:
anchor_ref=None,
routing_snapshot=routing_snapshot,
route_decision=normalized_route_decision,
response_payload=response_payload_snapshot,
),
**runtime_fields,
)
@ -982,4 +1163,13 @@ class AdaptiveRAGAnswerRetrievalMixin:
evidence_pack=runtime_fields["evidence_pack"],
prompt_variant_id=prompt_variant_id,
route_decision=normalized_route_decision,
project_operating_context=project_operating_context,
execution_recipe=execution_recipe,
fit_brief=fit_brief,
external_recommendation=external_recommendation,
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,
)

View File

@ -1,6 +1,11 @@
"""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
@ -20,10 +25,11 @@ 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"}
_EXTERNAL_RETRIEVAL_STATUSES = {"evidence_found", "verifier_blocked", "no_relevant_results", "retrieval_failed"}
_EXTERNAL_IMPACT_STATUSES = {"direct_match", "indirect_only", "no_direct_impact"}
def __init__(self, db: AsyncSession, *, tenant_id: int) -> None:
self.db = db
@ -32,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
@ -165,7 +172,81 @@ class AdaptiveRAGCoreIntentMixin:
return dict(payload)
@staticmethod
def _normalize_routing_snapshot(raw: Any) -> dict[str, Any]:
def _normalize_project_operating_context_snapshot(payload: Any) -> dict[str, Any]:
if not isinstance(payload, dict):
return {}
try:
return ProjectOperatingContext.model_validate(payload).model_dump()
except Exception:
return {}
@staticmethod
def _normalize_execution_recipe_snapshot(payload: Any) -> dict[str, Any]:
if not isinstance(payload, dict):
return {}
try:
return ExecutionRecipe.model_validate(payload).model_dump()
except Exception:
return {}
@staticmethod
def _normalize_fit_brief_snapshot(payload: Any) -> dict[str, Any]:
if not isinstance(payload, dict):
return {}
try:
return ProjectFitBrief.model_validate(payload).model_dump()
except Exception:
return {}
def _normalize_external_recommendation_snapshot(self, payload: Any) -> dict[str, Any]:
if not isinstance(payload, dict):
return {}
retrieval_status = str(payload.get("retrieval_status") or "").strip().lower()
impact_status = str(payload.get("impact_status") or "").strip().lower()
if retrieval_status not in self._EXTERNAL_RETRIEVAL_STATUSES:
retrieval_status = "retrieval_failed"
if impact_status not in self._EXTERNAL_IMPACT_STATUSES:
impact_status = "no_direct_impact"
return {
"retrieval_status": retrieval_status,
"impact_status": impact_status,
"external_summary": str(payload.get("external_summary") or "").strip(),
"external_impact": str(payload.get("external_impact") or "").strip(),
"affected_signal_sources": [
str(item).strip()
for item in list(payload.get("affected_signal_sources") or [])
if str(item).strip()
][:6],
"external_action": str(payload.get("external_action") or "").strip(),
}
def _normalize_routing_response_payload(self, payload: Any) -> dict[str, Any]:
if not isinstance(payload, dict):
return {}
normalized = {
"project_operating_context": self._normalize_project_operating_context_snapshot(
payload.get("project_operating_context")
),
"execution_recipe": self._normalize_execution_recipe_snapshot(payload.get("execution_recipe")),
"fit_brief": self._normalize_fit_brief_snapshot(payload.get("fit_brief")),
"external_recommendation": self._normalize_external_recommendation_snapshot(
payload.get("external_recommendation")
),
"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,
}
return {
key: value
for key, value in normalized.items()
if value not in (None, {}) and value != []
}
def _normalize_routing_snapshot(self, raw: Any) -> dict[str, Any]:
payload = raw if isinstance(raw, dict) else {}
planned_tier = str(payload.get("planned_tier") or "").strip().lower()
executed_tier = str(payload.get("executed_tier") or "").strip().lower()
@ -203,6 +284,9 @@ class AdaptiveRAGCoreIntentMixin:
else {}
),
}
response_payload = self._normalize_routing_response_payload(payload.get("response_payload"))
if response_payload:
normalized["response_payload"] = response_payload
return {key: value for key, value in normalized.items() if value is not None}
def _merge_query_route_audit_into_routing_snapshot(
@ -210,12 +294,16 @@ class AdaptiveRAGCoreIntentMixin:
*,
routing_snapshot: Optional[dict[str, Any]],
route_decision: Optional[dict[str, Any]],
response_payload: Optional[dict[str, Any]] = None,
) -> dict[str, Any]:
normalized_snapshot = self._normalize_routing_snapshot(routing_snapshot)
if hasattr(self, "_build_query_route_audit_snapshot"):
query_route = self._build_query_route_audit_snapshot(route_decision=route_decision)
if query_route:
normalized_snapshot["query_route"] = query_route
normalized_response_payload = self._normalize_routing_response_payload(response_payload)
if normalized_response_payload:
normalized_snapshot["response_payload"] = normalized_response_payload
return normalized_snapshot
def _peek_last_answer_routing_snapshot(self) -> dict[str, Any]:
@ -236,11 +324,13 @@ class AdaptiveRAGCoreIntentMixin:
anchor_ref: Optional[str] = None,
routing_snapshot: Optional[dict[str, Any]] = None,
route_decision: Optional[dict[str, Any]] = None,
response_payload: Optional[dict[str, Any]] = None,
) -> dict[str, Any]:
context = self._get_active_external_qa_context()
normalized_snapshot = self._merge_query_route_audit_into_routing_snapshot(
routing_snapshot=routing_snapshot,
route_decision=route_decision,
response_payload=response_payload,
) or None
return {
"source_message_id": context.get("source_message_id"),
@ -261,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,
*,
@ -413,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()
@ -441,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:
@ -465,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 []
@ -613,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"}:
@ -666,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,

View File

@ -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

View File

@ -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(

View File

@ -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))

View File

@ -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,

View File

@ -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

View File

@ -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 {}

View File

@ -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(

View File

@ -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:
@ -29,6 +29,17 @@ class AdaptiveRAGFastAnswerSemanticMixinP2:
internal_context_packet: Any = None,
) -> dict[str, Any]:
started = time.perf_counter()
normalized_query_semantics = dict(query_semantics or {}) if isinstance(query_semantics, dict) else {}
project_operating_context = (
normalized_query_semantics.get("project_operating_context")
if isinstance(normalized_query_semantics.get("project_operating_context"), dict)
else {}
)
execution_recipe = (
normalized_query_semantics.get("execution_recipe")
if isinstance(normalized_query_semantics.get("execution_recipe"), dict)
else {}
)
sources = list(fast_result.get("sources") or [])
render_with_contract = getattr(self, "_render_answer_with_route_contract", None)
answer_text = str(fast_result.get("answer") or "未命中结果")
@ -70,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 [])
@ -125,6 +140,52 @@ class AdaptiveRAGFastAnswerSemanticMixinP2:
reason_code=effective_reason_code,
retrieval_metrics=retrieval_metrics,
)
build_plan_handoff_fields = getattr(self, "_build_plan_handoff_fields", None)
plan_handoff_fields = (
build_plan_handoff_fields(
execution_recipe=execution_recipe,
project_operating_context=project_operating_context,
)
if callable(build_plan_handoff_fields)
else {}
)
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"),
}
qa = QARecord(
tenant_id=self.tenant_id,
@ -139,7 +200,10 @@ class AdaptiveRAGFastAnswerSemanticMixinP2:
project_id=project_id,
session_id=active_session_id_int,
turn_index=active_turn_index,
**self._build_qa_record_external_fields(route_decision=route_decision),
**self._build_qa_record_external_fields(
route_decision=route_decision,
response_payload=response_payload_snapshot,
),
**runtime_fields,
)
self.db.add(qa)
@ -203,4 +267,8 @@ class AdaptiveRAGFastAnswerSemanticMixinP2:
prompt_variant_id=prompt_variant_id,
reason_code=effective_reason_code,
route_decision=route_decision,
project_operating_context=project_operating_context,
execution_recipe=execution_recipe,
analysis_packet=response_payload_snapshot.get("analysis_packet") if isinstance(response_payload_snapshot, dict) else None,
internal_context_packet=internal_context_packet,
)

View File

@ -3,7 +3,6 @@ from __future__ import annotations
from dataclasses import dataclass, field
from datetime import timedelta
import unicodedata
from .shared import *
@ -52,32 +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.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"<at\b[^>]*>.*?</at>", "", str(text or ""), flags=re.IGNORECASE)
@ -203,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(
@ -274,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",
@ -341,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",
@ -410,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]
@ -447,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)
@ -470,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,
@ -481,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):
@ -523,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)
@ -542,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,
@ -594,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,
@ -622,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:
@ -675,13 +774,10 @@ class AdaptiveRAGFollowupAnchorMixin:
anchor_reply=chosen,
)
if is_meta_followup:
return FollowupAnchorResolution()
return FollowupAnchorResolution(
followup_mode=followup_mode,
anchor_mode="ambiguous_candidates",
anchor_kind="recent_scope_reply",
anchor_kind="meta_recent_reply" if is_meta_followup else "recent_scope_reply",
anchor_session_id=active_session_id,
candidate_replies=candidates,
is_meta_followup=is_meta_followup,

View File

@ -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(

View File

@ -0,0 +1,290 @@
"""Adaptive RAG project fit brief helpers."""
from __future__ import annotations
from app.models import SignalSourceRegistry
from app.schemas.project_operating_context import ProjectFitBrief, ProjectOperatingContext
from .shared import *
class AdaptiveRAGProjectFitBriefMixin:
@staticmethod
def _normalize_project_operating_context_for_fit_brief(payload: Optional[dict[str, Any]]) -> dict[str, Any]:
if not isinstance(payload, dict):
return {}
try:
return ProjectOperatingContext.model_validate(payload).model_dump()
except Exception:
return {}
@staticmethod
def _normalize_project_fit_brief_payload(payload: Optional[dict[str, Any]]) -> dict[str, Any]:
if not isinstance(payload, dict):
return {}
try:
return ProjectFitBrief.model_validate(payload).model_dump()
except Exception:
return {}
def _collect_fit_brief_capabilities(
self,
*,
system_snapshot: Optional[dict[str, Any]],
limit: int = 4,
) -> list[str]:
feature_catalog: list[dict[str, Any]] = []
extractor = getattr(self, "_feature_catalog_from_snapshot", None)
if callable(extractor):
try:
feature_catalog = extractor(system_snapshot)
except Exception:
feature_catalog = []
elif isinstance(system_snapshot, dict):
platform = system_snapshot.get("platform_profile") if isinstance(system_snapshot.get("platform_profile"), dict) else {}
feature_catalog = platform.get("feature_catalog") if isinstance(platform.get("feature_catalog"), list) else []
capabilities: list[str] = []
for item in feature_catalog:
if not isinstance(item, dict):
continue
name = str(item.get("name") or "").strip()
if name and name not in capabilities:
capabilities.append(name)
return capabilities[: max(1, int(limit))]
async def _collect_fit_brief_signal_source_anchors(
self,
*,
current_project_id: Optional[int],
user_snapshot: Optional[dict[str, Any]],
limit: int = 4,
) -> list[str]:
anchors: list[str] = []
max_items = max(1, int(limit))
if current_project_id is None:
return []
if getattr(self, "db", None) is not None:
try:
rows = (
await self.db.execute(
select(
SignalSourceRegistry.source_name,
SignalSourceRegistry.source_identifier,
).where(
and_(
SignalSourceRegistry.tenant_id == getattr(self, "tenant_id", 1),
SignalSourceRegistry.project_id == int(current_project_id),
SignalSourceRegistry.status == "active",
)
)
.order_by(SignalSourceRegistry.created_at.desc(), SignalSourceRegistry.id.desc())
.limit(max_items)
)
).all()
except Exception:
rows = []
for row in rows:
candidate = str(row.source_name or row.source_identifier or "").strip()
if candidate and candidate not in anchors:
anchors.append(candidate)
if anchors:
return anchors[:max_items]
if isinstance(user_snapshot, dict):
for project in list(user_snapshot.get("projects") or []):
if not isinstance(project, dict):
continue
if int(project.get("id") or 0) != int(current_project_id):
continue
for item in list(project.get("signal_sources") or []):
if not isinstance(item, dict):
continue
candidate = str(item.get("source_name") or item.get("technical_identifier") or "").strip()
if candidate and candidate not in anchors:
anchors.append(candidate)
break
return anchors[:max_items]
@staticmethod
def _build_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(
*,
system_snapshot: Optional[dict[str, Any]],
repo_scope: Optional[str],
) -> str | None:
system_data = system_snapshot if isinstance(system_snapshot, dict) else {}
platform = system_data.get("platform_profile") if isinstance(system_data.get("platform_profile"), dict) else {}
backend = platform.get("backend") if isinstance(platform.get("backend"), dict) else {}
frontend = platform.get("frontend") if isinstance(platform.get("frontend"), dict) else {}
feature_catalog = platform.get("feature_catalog") if isinstance(platform.get("feature_catalog"), list) else []
module_counts = backend.get("module_counts") if isinstance(backend.get("module_counts"), dict) else {}
parts: list[str] = []
scope = str(repo_scope or "").strip()
if scope:
parts.append(f"repo={scope}")
if backend:
parts.append(
"后端 "
f"{str(backend.get('framework') or 'unknown')},"
f"services={int(module_counts.get('services', 0))},"
f"api={int(module_counts.get('api', 0))}"
)
if frontend:
parts.append(
"前端 "
f"{str(frontend.get('framework') or 'unknown')},"
f"route≈{int(frontend.get('route_count') or 0)}"
)
if feature_catalog:
parts.append(f"功能目录 {len(feature_catalog)} 个")
return ";".join(parts)[:320] if parts else None
@staticmethod
def _build_recommendation_focus(question: str, *, current_project_id: Optional[int]) -> list[str]:
text = str(question or "").strip()
focus = []
if text:
focus.append(f"判断“{text[:80]}”是否适合当前项目接入")
if current_project_id is not None:
focus.append(f"优先绑定当前项目#{int(current_project_id)}的正式对象,不做泛化建议")
focus.append("先回答外部适配性,再决定是否进入 plan")
return focus[:3]
async def _build_project_fit_brief(
self,
*,
question: str,
project_operating_context: Optional[dict[str, Any]],
system_snapshot: Optional[dict[str, Any]],
user_snapshot: Optional[dict[str, Any]],
) -> dict[str, Any]:
normalized_context = self._normalize_project_operating_context_for_fit_brief(project_operating_context)
current_project_id = normalized_context.get("current_project_id")
current_repo_scope = str(normalized_context.get("current_repo_scope") or "").strip() or None
project_summary = None
if current_project_id is not None and getattr(self, "db", None) is not None:
row = (
await self.db.execute(
select(Project.id, Project.title, Project.name, Project.status).where(
and_(
Project.tenant_id == getattr(self, "tenant_id", 1),
Project.id == int(current_project_id),
)
)
)
).first()
if row is not None:
project_summary = (
f"当前项目:#{int(row.id)} "
f"{str(row.title or row.name or f'项目#{int(row.id)}')} "
f"(status={str(row.status or 'unknown')})"
)
if project_summary is None and isinstance(user_snapshot, dict) and current_project_id is not None:
for item in list(user_snapshot.get("projects") or []):
if not isinstance(item, dict):
continue
if int(item.get("id") or 0) != int(current_project_id):
continue
project_summary = (
f"当前项目:#{int(current_project_id)} "
f"{str(item.get('name') or f'项目#{int(current_project_id)}')} "
f"(status={str(item.get('status') or 'unknown')})"
)
break
capabilities = self._collect_fit_brief_capabilities(
system_snapshot=system_snapshot,
limit=4,
)
signal_source_anchors = await self._collect_fit_brief_signal_source_anchors(
current_project_id=int(current_project_id) if current_project_id is not None else None,
user_snapshot=user_snapshot,
limit=4,
)
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 = [
"只允许基于可核验外部公开证据判断适配性。",
"结论必须优先绑定当前项目与当前仓库上下文。",
]
if current_repo_scope:
constraints.append(f"当前 repo scope={current_repo_scope}")
if current_project_id is not None:
constraints.append(f"优先判断对当前项目#{int(current_project_id)}的直接影响。")
payload = ProjectFitBrief(
project_summary=project_summary,
repo_summary=self._build_repo_summary(
system_snapshot=system_snapshot,
repo_scope=current_repo_scope,
),
current_capabilities=capabilities,
signal_source_anchors=signal_source_anchors,
signal_source_objectives=signal_source_objectives,
constraints=constraints[:4],
recommendation_focus=self._build_recommendation_focus(
question,
current_project_id=int(current_project_id) if current_project_id is not None else None,
),
)
return payload.model_dump()

View File

@ -168,6 +168,7 @@ class AdaptiveRAGQASessionListOpsMixinP1:
fb = feedback_map.get(int(row.id))
ack_meta = self._extract_ack_metadata_from_sources(row.sources, answer=row.answer)
routing_snapshot = self._normalize_message_dict(row.routing_snapshot)
response_payload = self._normalize_message_dict(routing_snapshot.get("response_payload"))
if fb:
rating = str(fb.rating or "").strip().lower()
if rating == "positive":
@ -196,6 +197,22 @@ class AdaptiveRAGQASessionListOpsMixinP1:
"citations": self._normalize_message_list(row.citations),
"sources_used": self._normalize_message_list(row.sources_used),
"evidence_pack": self._normalize_message_dict(row.evidence_pack),
"project_operating_context": self._normalize_message_dict(
response_payload.get("project_operating_context")
),
"execution_recipe": self._normalize_message_dict(response_payload.get("execution_recipe")),
"fit_brief": self._normalize_message_dict(response_payload.get("fit_brief")),
"external_recommendation": self._normalize_message_dict(
response_payload.get("external_recommendation")
),
"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"))
if "can_enter_plan" in response_payload
else None
),
"recommended_next_step": str(response_payload.get("recommended_next_step") or "").strip() or None,
"turn_index": int(row.turn_index or 1),
"created_at": row.created_at.isoformat() if row.created_at else None,
"feedback": {

View File

@ -1,13 +1,132 @@
"""Adaptive RAG query route gateway helpers."""
from __future__ import annotations
from app.schemas.project_operating_context import ExecutionRecipe, ProjectOperatingContext
from app.services.ai_pmo.project_operating_context_builder import ProjectOperatingContextBuilder
from .shared import *
from .shared_utils import _env_bool
class AdaptiveRAGQueryRouteGatewayMixin:
_QUERY_ROUTE_MODES = {"internal", "external", "uncertain"}
_INTERNAL_ALLOWED_SOURCES = {"db", "graph", "doc", "pmflow", "feishu"}
_EXTERNAL_ALLOWED_SOURCES = {"url", "mcp_search", "system"}
_PLAN_MESSAGE_INTENTS = {"requirement_plan", "requirement_gathering", "requirement_convergence"}
_ENV_ENABLE_UNIFIED_EXECUTION_RECIPE = "PMFLOW_ENABLE_UNIFIED_EXECUTION_RECIPE"
@staticmethod
def _normalize_project_operating_context(payload: Optional[dict[str, Any]]) -> dict[str, Any]:
if not isinstance(payload, dict):
return {}
try:
return ProjectOperatingContext.model_validate(payload).model_dump()
except Exception:
return {}
@staticmethod
def _normalize_execution_recipe(payload: Optional[dict[str, Any]]) -> dict[str, Any]:
if not isinstance(payload, dict):
return {}
try:
return ExecutionRecipe.model_validate(payload).model_dump()
except Exception:
return {}
def _build_project_operating_context_packet(
self,
*,
project_id: Optional[int],
source_chat_id: Optional[str],
reply_to_message_id: Optional[str] = None,
root_message_id: Optional[str] = None,
project_operating_context: Optional[dict[str, Any]] = None,
) -> dict[str, Any]:
builder = ProjectOperatingContextBuilder(
repo_scope=str((project_operating_context or {}).get("current_repo_scope") or "").strip() or None
)
packet = builder.build_from_ingress(
project_hint_id=project_id,
rag_request_context={
"project_id": project_id,
"source_chat_id": source_chat_id,
},
thread_context={
"reply_to_message_id": reply_to_message_id,
"root_message_id": root_message_id,
},
existing_packet=project_operating_context,
)
return packet.model_dump()
def _build_execution_recipe(
self,
*,
query_semantics: Optional[dict[str, Any]],
route_mode: str,
effective_source_policy: str,
project_operating_context: Optional[dict[str, Any]],
) -> dict[str, Any]:
semantics = query_semantics if isinstance(query_semantics, dict) else {}
context = self._normalize_project_operating_context(project_operating_context)
message_intent = str(
semantics.get("message_intent")
or semantics.get("intent")
or ""
).strip().lower()
query_type = str(semantics.get("query_type") or "").strip().lower()
try:
has_current_project = int(context.get("current_project_id")) >= 0
except Exception:
has_current_project = False
decision_strategy = str(context.get("decision_strategy") or "").strip().lower()
unified_execution_recipe_enabled = _env_bool(
self._ENV_ENABLE_UNIFIED_EXECUTION_RECIPE,
True,
)
if message_intent in self._PLAN_MESSAGE_INTENTS or query_type == "plan":
return ExecutionRecipe(
primary_lane="plan",
fit_brief_required=False,
external_evidence_required=False,
plan_handoff_mode="explicit_plan_now",
).model_dump()
normalized_effective = self._normalize_query_route_mode(effective_source_policy)
normalized_route = self._normalize_query_route_mode(route_mode)
if not unified_execution_recipe_enabled:
if normalized_effective == "external" or normalized_route == "external":
return ExecutionRecipe(
primary_lane="external",
fit_brief_required=False,
external_evidence_required=True,
plan_handoff_mode="none",
).model_dump()
return ExecutionRecipe(
primary_lane="internal",
fit_brief_required=False,
external_evidence_required=False,
plan_handoff_mode="none",
).model_dump()
if normalized_effective == "external" or normalized_route == "external":
return ExecutionRecipe(
primary_lane="external",
fit_brief_required=has_current_project,
external_evidence_required=True,
plan_handoff_mode=(
"offer_after_answer"
if has_current_project and decision_strategy == "answer_then_offer_plan"
else "none"
),
).model_dump()
return ExecutionRecipe(
primary_lane="internal",
fit_brief_required=False,
external_evidence_required=False,
plan_handoff_mode="none",
).model_dump()
def _normalize_query_route_decision(self, payload: Optional[dict[str, Any]]) -> dict[str, Any]:
raw = payload if isinstance(payload, dict) else {}
@ -41,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))),
}
@ -95,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",
@ -217,12 +346,24 @@ class AdaptiveRAGQueryRouteGatewayMixin:
asked_via: str,
project_id: Optional[int],
source_chat_id: Optional[str],
source_message_id: Optional[str] = None,
conversation_scope_key: Optional[str] = None,
reply_to_message_id: Optional[str] = None,
root_message_id: Optional[str] = None,
query_semantics: Optional[dict[str, Any]],
source_policy_hint: Optional[str],
project_operating_context: Optional[dict[str, Any]] = None,
) -> dict[str, Any]:
_ = (question, asked_via, project_id, source_chat_id)
_ = (question, asked_via, source_message_id, conversation_scope_key)
semantics = dict(query_semantics or {})
query_profile = self._normalize_query_profile(semantics.get("query_profile"))
normalized_project_operating_context = self._build_project_operating_context_packet(
project_id=project_id,
source_chat_id=source_chat_id,
reply_to_message_id=reply_to_message_id,
root_message_id=root_message_id,
project_operating_context=project_operating_context,
)
contract = self._resolve_route_contract(
query_semantics=semantics,
source_policy_hint=source_policy_hint,
@ -242,6 +383,12 @@ class AdaptiveRAGQueryRouteGatewayMixin:
"decision_origin": decision_origin,
}
}
execution_recipe = self._build_execution_recipe(
query_semantics=semantics,
route_mode=route_mode,
effective_source_policy=effective_source_policy,
project_operating_context=normalized_project_operating_context,
)
return {
"route_mode": route_mode,
"effective_source_policy": effective_source_policy,
@ -261,6 +408,8 @@ class AdaptiveRAGQueryRouteGatewayMixin:
effective_source_policy=effective_source_policy,
decision_origin=decision_origin,
),
"project_operating_context": normalized_project_operating_context,
"execution_recipe": execution_recipe,
}
async def preview_query_route_decision(
@ -276,8 +425,16 @@ class AdaptiveRAGQueryRouteGatewayMixin:
root_message_id: Optional[str] = None,
source_policy_hint: Optional[str] = None,
query_semantics: Optional[dict[str, Any]] = None,
project_operating_context: Optional[dict[str, Any]] = None,
) -> dict[str, Any]:
semantics = dict(query_semantics or {})
normalized_project_operating_context = self._build_project_operating_context_packet(
project_id=project_id,
source_chat_id=source_chat_id,
reply_to_message_id=reply_to_message_id,
root_message_id=root_message_id,
project_operating_context=project_operating_context,
)
if not semantics:
semantics = await self._resolve_query_semantics(
message=question,
@ -289,6 +446,7 @@ class AdaptiveRAGQueryRouteGatewayMixin:
"conversation_scope_key": conversation_scope_key,
"reply_to_message_id": reply_to_message_id,
"root_message_id": root_message_id,
"project_operating_context": normalized_project_operating_context,
},
source_policy_hint=source_policy_hint,
)
@ -298,17 +456,31 @@ class AdaptiveRAGQueryRouteGatewayMixin:
asked_via=asked_via,
project_id=project_id,
source_chat_id=source_chat_id,
source_message_id=source_message_id,
conversation_scope_key=conversation_scope_key,
reply_to_message_id=reply_to_message_id,
root_message_id=root_message_id,
query_semantics=semantics,
source_policy_hint=source_policy_hint or semantics.get("source_policy"),
project_operating_context=normalized_project_operating_context,
)
normalized_decision = self._normalize_query_route_decision(decision)
normalized_decision["probe_snapshot"] = dict(decision.get("probe_snapshot") or {})
normalized_execution_recipe = self._normalize_execution_recipe(
decision.get("execution_recipe") if isinstance(decision.get("execution_recipe"), dict) else None
)
if normalized_project_operating_context:
semantics["project_operating_context"] = dict(normalized_project_operating_context)
if normalized_execution_recipe:
semantics["execution_recipe"] = dict(normalized_execution_recipe)
return {
"query_semantics": self._apply_route_decision_to_query_semantics(
query_semantics=semantics,
route_decision=normalized_decision,
),
"route_decision": normalized_decision,
"project_operating_context": normalized_project_operating_context,
"execution_recipe": normalized_execution_recipe,
}
async def _persist_query_route_memory(

View File

@ -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:

View File

@ -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":

View File

@ -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"),

View File

@ -7,6 +7,7 @@ from .mixin_answer_mainline import AdaptiveRAGAnswerMainlineMixin
from .mixin_answer_precheck import AdaptiveRAGAnswerPrecheckMixin
from .mixin_answer_retrieval import AdaptiveRAGAnswerRetrievalMixin
from .mixin_answer_payload import AdaptiveRAGAnswerPayloadMixin
from .mixin_project_fit_brief import AdaptiveRAGProjectFitBriefMixin
from .mixin_requirement_ack_core import AdaptiveRAGRequirementAckCoreMixin
from .mixin_requirement_ack_flow import AdaptiveRAGRequirementAckFlowMixin
from .mixin_deterministic_query_core import AdaptiveRAGDeterministicQueryCoreMixin
@ -53,6 +54,7 @@ class AdaptiveRAGService(
AdaptiveRAGAnswerPrecheckMixin,
AdaptiveRAGAnswerRetrievalMixin,
AdaptiveRAGAnswerPayloadMixin,
AdaptiveRAGProjectFitBriefMixin,
AdaptiveRAGRequirementAckCoreMixin,
AdaptiveRAGRequirementAckFlowMixin,
AdaptiveRAGDeterministicQueryCoreMixin,

View File

@ -2,6 +2,50 @@
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()
if not raw:
return bool(default)
if raw in {"1", "true", "yes", "on"}:
return True
if raw in {"0", "false", "no", "off"}:
return False
return bool(default)
def _env_int(name: str, default: int, *, minimum: int, maximum: int) -> int:
raw = str(os.getenv(name, "") or "").strip()
@ -14,6 +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_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",
]

View File

@ -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()

View File

@ -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(

View File

@ -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,

View File

@ -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": []}

View File

@ -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(

View File

@ -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:

View File

@ -12,6 +12,18 @@ for _name, _value in vars(_common).items():
class KernelWorldModelSessionMixin:
@staticmethod
def _project_operating_context_defaults_from_session(
*,
session: Optional[ConversationSession],
) -> dict[str, Any]:
if session is None:
return {}
current_project_id = _common._safe_int(getattr(session, "current_project_id", None))
if current_project_id is None:
return {}
return {"current_project_id": current_project_id}
@staticmethod
def _within_recent_window(last_active_at: Optional[datetime], *, window_seconds: int) -> bool:
if not isinstance(last_active_at, datetime):

View File

@ -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

View File

@ -16,6 +16,7 @@ from app.services.ai_pmo.workflow_semantic_planner.constants import (
QUERY_SCENE_CORRECTION_ESCALATION,
QUERY_SCENE_REQUIREMENT_PLAN,
)
from app.services.ai_pmo.project_operating_context_builder import ProjectOperatingContextBuilder
from app.services.mounted_ai_runtime_write_service import MountedAIRuntimeWriteService
class KernelIngressFlowEntryMixin:
@ -41,6 +42,64 @@ 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,
*,
ingress_options: Optional[dict[str, Any]],
project_hint_id: Optional[int],
world_model: Optional[dict[str, Any]] = None,
thread_context: Optional[dict[str, Any]] = None,
session: Optional[ConversationSession] = None,
) -> dict[str, Any]:
normalized_options = dict(ingress_options or {})
rag_request_context = (
normalized_options.get("rag_request_context")
if isinstance(normalized_options.get("rag_request_context"), dict)
else None
)
existing_packet = (
normalized_options.get("project_operating_context")
if isinstance(normalized_options.get("project_operating_context"), dict)
else None
)
session_defaults_builder = getattr(self, "_project_operating_context_defaults_from_session", None)
session_defaults = (
session_defaults_builder(session=session)
if callable(session_defaults_builder)
else {}
)
builder = ProjectOperatingContextBuilder(
repo_scope=str(
(world_model or {}).get("repo_scope")
or (existing_packet or {}).get("current_repo_scope")
or ""
).strip()
or None
)
packet = builder.build_from_ingress(
project_hint_id=project_hint_id,
rag_request_context=rag_request_context,
thread_context=thread_context,
world_model=world_model,
session_project_id=_safe_int(session_defaults.get("current_project_id")),
existing_packet=existing_packet,
)
normalized_options["project_operating_context"] = packet.model_dump()
return normalized_options
@classmethod
def _payload_has_external_freshness_guard_source(cls, value: Any) -> bool:
if isinstance(value, str):
@ -193,6 +252,62 @@ class KernelIngressFlowEntryMixin:
compact = cls._normalize_compact_text(message_text)
return bool(compact) and len(compact) <= 24
@staticmethod
async def _should_skip_query_pending_clarification_followup_by_bundle(
*,
message_text: str,
source_system: str,
) -> bool:
text = str(message_text or "").strip()
if not text:
return False
try:
bundle = await WorkflowSemanticPlanner.aplan_message(
message_text=text,
source_system=str(source_system or "").strip() or "unknown",
metadata={},
)
except Exception:
return False
workflows = list(getattr(bundle, "workflows", []) or [])
if any(
str(getattr(item, "workflow_type", "") or "").strip()
in {"new_project_flow", "project_flow", "task_flow", "signal_source_flow", "project_signal_flow"}
for item in workflows
):
return True
diagnostics = getattr(bundle, "diagnostics", None)
if not isinstance(diagnostics, dict):
return False
return str(diagnostics.get("topic_switch") or "").strip().lower() == "new"
async def _clear_query_pending_clarification_context(
self,
*,
session_id: Optional[int],
) -> None:
normalized_session_id = _safe_int(session_id)
if normalized_session_id is None or not hasattr(self.db, "execute"):
return
from app.models.qa_session_state import QASessionState
state_row = (
await self.db.execute(
select(QASessionState).where(
and_(
QASessionState.tenant_id == int(self.tenant_id),
QASessionState.session_id == int(normalized_session_id),
)
).limit(1)
)
).scalars().first()
if state_row is None:
return
state_row.pending_clarification = None
state_row.pending_clarification_expires_at = None
if hasattr(self.db, "flush"):
await self.db.flush()
@staticmethod
def _is_query_pending_clarification_expired(expires_at: Any) -> bool:
if expires_at is None:
@ -375,6 +490,14 @@ class KernelIngressFlowEntryMixin:
return None
if pending_context is None:
return None
if not self._has_explicit_thread_anchor(thread_context) and await self._should_skip_query_pending_clarification_followup_by_bundle(
message_text=message_text,
source_system=source_system,
):
await self._clear_query_pending_clarification_context(
session_id=_safe_int(pending_context.get("session_id")),
)
return None
rag_service = AdaptiveRAGService(self.db, tenant_id=self.tenant_id)
rag_kwargs = {
@ -585,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(
*,
@ -859,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:
@ -1262,6 +1419,12 @@ class KernelIngressFlowEntryMixin:
operator = ingress_prepare["operator"]
work_item = ingress_prepare["work_item"]
session = ingress_prepare["session"]
ingress_options = self._ensure_project_operating_context(
ingress_options=ingress_options,
project_hint_id=project_hint_id,
thread_context=thread_context,
session=session,
)
await _safe_begin_mounted_runtime(
runtime_message_id=synthetic_message_id,
runtime_message_text=clean_text,
@ -1431,6 +1594,13 @@ class KernelIngressFlowEntryMixin:
project_hint_id=project_hint_id,
session=session,
)
ingress_options = self._ensure_project_operating_context(
ingress_options=ingress_options,
project_hint_id=project_hint_id,
world_model=world_model,
thread_context=thread_context,
session=session,
)
_log_perf("_build_world_model", stage_started_at)
self._schedule_portfolio_snapshot_persist(world_model=world_model)
work_item.state = STATE_MODELED
@ -1439,6 +1609,11 @@ class KernelIngressFlowEntryMixin:
if isinstance(ingress_options, dict)
else None
)
project_operating_context = (
ingress_options.get("project_operating_context")
if isinstance(ingress_options, dict)
else None
)
stage_started_at = time.perf_counter()
intent, intent_context_source = await self._resolve_intent(
message_text=clean_text,
@ -1472,6 +1647,11 @@ class KernelIngressFlowEntryMixin:
**(intent.metadata or {}),
"rag_request_context": dict(rag_request_context),
}
if isinstance(project_operating_context, dict):
intent.metadata = {
**(intent.metadata or {}),
"project_operating_context": dict(project_operating_context),
}
source_context_metadata = {
"source_sender_open_id": str(source_sender_open_id or "").strip() or None,
"source_chat_id": str(channel_ref or "").strip() or None,
@ -1966,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,
@ -2030,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,
@ -2047,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(
@ -2067,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
@ -2293,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,
*,
@ -2352,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):
@ -2369,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:
@ -2428,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),
@ -2495,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,
@ -2506,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,
@ -2515,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

View File

@ -2,10 +2,16 @@
from __future__ import annotations
from pathlib import Path
from .shared import * # noqa: F401, F403
class KernelWorldModelCoreBuilderMixin:
@staticmethod
def _build_repo_scope() -> str:
return Path(__file__).resolve().parents[4].name
async def build_world_model(
self,
*,
@ -108,9 +114,15 @@ class KernelWorldModelCoreBuilderMixin:
for row in active_projects_rows
]
repo_scope = self._build_repo_scope()
world_model: WorldModel = {
"sender": sender,
"resolved_project_id": resolved_project_id,
"repo_scope": repo_scope,
"repo_summary": {
"scope": repo_scope,
"summary_mode": "backend_snapshot",
},
"project": project_snapshot,
"project_domain": domain,
"portfolio": portfolio_summary,

View File

@ -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],

View File

@ -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)

View File

@ -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,

View File

@ -0,0 +1,106 @@
"""Build a minimal unified project operating context packet."""
from __future__ import annotations
from pathlib import Path
from typing import Any, Optional
from app.schemas.project_operating_context import ProjectOperatingContext
def _safe_int(value: Any) -> Optional[int]:
try:
if value is None:
return None
return int(value)
except Exception:
return None
class ProjectOperatingContextBuilder:
def __init__(self, repo_scope: str | None = None) -> None:
self.repo_scope = str(repo_scope or "").strip() or self._default_repo_scope()
@staticmethod
def _default_repo_scope() -> str:
return Path(__file__).resolve().parents[4].name
@staticmethod
def _normalize_anchor(value: Any) -> dict[str, Any]:
if not isinstance(value, dict):
return {}
normalized: dict[str, Any] = {}
for key, raw in value.items():
if not str(key or "").strip():
continue
if raw in (None, "", [], {}):
continue
normalized[str(key).strip()] = raw
return normalized
def build(
self,
*,
current_project_id: int | None = None,
page_anchor: dict[str, Any] | None = None,
thread_anchor: dict[str, Any] | None = None,
) -> ProjectOperatingContext:
return ProjectOperatingContext(
current_project_id=_safe_int(current_project_id),
current_repo_scope=self.repo_scope,
page_anchor=self._normalize_anchor(page_anchor),
thread_anchor=self._normalize_anchor(thread_anchor),
)
def build_from_ingress(
self,
*,
project_hint_id: int | None = None,
rag_request_context: dict[str, Any] | None = None,
thread_context: dict[str, Any] | None = None,
world_model: dict[str, Any] | None = None,
session_project_id: int | None = None,
existing_packet: dict[str, Any] | None = None,
) -> ProjectOperatingContext:
context = rag_request_context if isinstance(rag_request_context, dict) else {}
world = world_model if isinstance(world_model, dict) else {}
existing = existing_packet if isinstance(existing_packet, dict) else {}
page_anchor = self._normalize_anchor(existing.get("page_anchor"))
if not page_anchor:
requested_project_id = _safe_int(context.get("project_id")) or _safe_int(project_hint_id)
source_chat_id = str(context.get("source_chat_id") or "").strip()
if requested_project_id is not None:
page_anchor["project_id"] = requested_project_id
if source_chat_id:
page_anchor["source_chat_id"] = source_chat_id
thread_anchor = self._normalize_anchor(existing.get("thread_anchor"))
if not thread_anchor:
raw_thread = thread_context if isinstance(thread_context, dict) else {}
reply_to_message_id = str(raw_thread.get("reply_to_message_id") or "").strip()
root_message_id = str(raw_thread.get("root_message_id") or "").strip()
if reply_to_message_id:
thread_anchor["reply_to_message_id"] = reply_to_message_id
if root_message_id:
thread_anchor["root_message_id"] = root_message_id
current_project_id = (
_safe_int(world.get("resolved_project_id"))
or _safe_int(existing.get("current_project_id"))
or _safe_int(session_project_id)
or _safe_int(context.get("project_id"))
or _safe_int(project_hint_id)
)
repo_scope = str(
world.get("repo_scope")
or existing.get("current_repo_scope")
or self.repo_scope
or ""
).strip() or self.repo_scope
return ProjectOperatingContextBuilder(repo_scope=repo_scope).build(
current_project_id=current_project_id,
page_anchor=page_anchor,
thread_anchor=thread_anchor,
)

View File

@ -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",
]

View File

@ -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)

View File

@ -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)

View File

@ -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",
],

View File

@ -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

File diff suppressed because it is too large Load Diff

View File

@ -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"]

View File

@ -1,5 +1,6 @@
from __future__ import annotations
import inspect
from typing import Any
from urllib.parse import urlparse
@ -22,11 +23,24 @@ class ExternalQueryGateway:
*,
question: str,
query_semantics: dict[str, Any] | None = None,
fit_brief: dict[str, Any] | None = None,
) -> ExternalQueryExecution:
planned = await self.planner.build_plan(
question=question,
query_semantics=query_semantics,
)
build_plan = getattr(self.planner, "build_plan")
try:
parameters = inspect.signature(build_plan).parameters
except Exception:
parameters = {}
if "fit_brief" in parameters:
planned = await build_plan(
question=question,
query_semantics=query_semantics,
fit_brief=fit_brief,
)
else:
planned = await build_plan(
question=question,
query_semantics=query_semantics,
)
planner_meta: dict[str, Any] = {}
if isinstance(planned, tuple) and len(planned) == 2:
plan, planner_meta = planned
@ -66,6 +80,7 @@ class ExternalQueryGateway:
meta = dict(selected.meta or {})
meta["fallback_used"] = bool(fallback_used)
meta["adapter_attempts"] = adapter_attempts
meta["selected_status"] = str(selected.status or "").strip() or "empty"
return ExternalQueryExecution(
plan=plan,
adapter_name=selected.adapter_name,

View File

@ -5,6 +5,7 @@ import re
from typing import Any, Callable
from ...config.ai_prompts import PROMPT_EXTERNAL_QUERY_PLAN
from ...schemas.project_operating_context import ProjectFitBrief
from .schemas import ExternalQueryPlan
@ -29,18 +30,26 @@ class ExternalQueryPlanner:
*,
question: str,
query_semantics: dict[str, Any] | None = None,
fit_brief: dict[str, Any] | None = None,
) -> tuple[ExternalQueryPlan, dict[str, Any]]:
fallback_plan = self._build_fallback_plan(
question=question,
query_semantics=query_semantics,
fit_brief=fit_brief,
)
generator = getattr(self.llm_service, "generate_structured_object", None)
if not callable(generator):
return fallback_plan, {"planner": "heuristic", "fallback_taken": True}
return fallback_plan, {
"planner": "heuristic",
"fallback_taken": True,
"fit_brief_summary": fallback_plan.fit_brief_summary,
}
normalized_fit_brief = self._normalize_fit_brief(fit_brief)
prompt = PROMPT_EXTERNAL_QUERY_PLAN.format(
question=str(question or "").strip()[:1200],
query_semantics=json.dumps(query_semantics or {}, ensure_ascii=False)[:4000],
fit_brief=json.dumps(normalized_fit_brief, ensure_ascii=False)[:2500],
fallback_queries=json.dumps(fallback_plan.search_queries, ensure_ascii=False),
).strip()
try:
@ -64,18 +73,32 @@ 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)
if not plan.topic:
plan.topic = fallback_plan.topic
return plan, {"planner": "llm_structured", "fallback_taken": False}
return plan, {
"planner": "llm_structured",
"fallback_taken": False,
"fit_brief_summary": plan.fit_brief_summary or fallback_plan.fit_brief_summary,
}
except Exception as exc:
return fallback_plan, {
"planner": "heuristic",
"fallback_taken": True,
"reason": str(exc)[:200],
"fit_brief_summary": fallback_plan.fit_brief_summary,
}
def _build_fallback_plan(
@ -83,10 +106,13 @@ class ExternalQueryPlanner:
*,
question: str,
query_semantics: dict[str, Any] | None = None,
fit_brief: dict[str, Any] | None = None,
) -> ExternalQueryPlan:
text = re.sub(r"\s+", " ", str(question or "")).strip()
semantics = query_semantics if isinstance(query_semantics, dict) else {}
query_profile = semantics.get("query_profile") if isinstance(semantics.get("query_profile"), dict) else {}
interaction_level = self._resolve_interaction_level(semantics)
normalized_fit_brief = self._normalize_fit_brief(fit_brief)
object_mentions = [
str(item or "").strip()
for item in list(semantics.get("object_mentions") or [])
@ -102,7 +128,10 @@ class ExternalQueryPlanner:
answer_contract = "direct_fact"
topic = text[:120] or "外部查询"
search_queries = self._build_search_queries(text)
search_queries = self._build_search_queries(
text,
fit_brief=normalized_fit_brief,
)
preferred_domains = self._infer_preferred_domains(text)
subject_entities = object_mentions[:6]
location_hint = None
@ -110,10 +139,13 @@ class ExternalQueryPlanner:
requires_multi_source = answer_contract == "compare_with_sources"
clarify_on_ambiguity = False
result_limit = 3 if requires_multi_source or bool(query_profile.get("needs_freshness")) else 2
fit_brief_summary = self._summarize_fit_brief(normalized_fit_brief)
project_object_anchors = self._collect_project_object_anchors(normalized_fit_brief)
return ExternalQueryPlan(
plan_version="v1",
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,
@ -125,19 +157,41 @@ class ExternalQueryPlanner:
result_limit=result_limit,
requires_multi_source=requires_multi_source,
clarify_on_ambiguity=clarify_on_ambiguity,
fit_brief_summary=fit_brief_summary,
project_scope_hint="current_project_and_repo" if fit_brief_summary else None,
recommendation_goal=self._build_recommendation_goal(
question=text,
fit_brief=normalized_fit_brief,
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(self, question: str) -> list[str]:
def _build_search_queries(
self,
question: str,
*,
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 normalized[:5]
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()
return [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
def _infer_preferred_domains(question: str) -> list[str]:
@ -161,3 +215,196 @@ class ExternalQueryPlanner:
if match:
return str(match.group(0) or "").strip() or None
return None
@staticmethod
def _normalize_fit_brief(payload: dict[str, Any] | None) -> dict[str, Any]:
if not isinstance(payload, dict):
return {}
try:
return ProjectFitBrief.model_validate(payload).model_dump()
except Exception:
return {}
@staticmethod
def _summarize_fit_brief(fit_brief: dict[str, Any] | None) -> str:
brief = fit_brief if isinstance(fit_brief, dict) else {}
parts: list[str] = []
project_summary = str(brief.get("project_summary") or "").strip()
repo_summary = str(brief.get("repo_summary") or "").strip()
capabilities = [
str(item or "").strip()
for item in list(brief.get("current_capabilities") or [])
if str(item or "").strip()
]
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", "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]
@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 [])
if str(item or "").strip()
]
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],
*,
fit_brief: dict[str, Any] | None = None,
) -> list[str]:
normalized = [str(item or "").strip() for item in list(queries or []) if str(item or "").strip()]
if not normalized:
return []
anchors = self._collect_project_object_anchors(fit_brief)
if anchors:
anchor = anchors[0]
candidate = f"{normalized[0]} {anchor}".strip()
if candidate and candidate not in normalized:
normalized.append(candidate)
return normalized[:5]

View File

@ -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,415 @@ 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]
if retrieval_status == "verifier_blocked":
return {
"answer_text": (
f"关于“{short_question}”,我拿到过外部候选结果,但它们没有通过核验,"
"所以先不给你直接结论。你如果愿意,我可以继续按更明确的对象或范围再检一次。"
),
"prompt_variant_id": "external_verifier_blocked",
}
if retrieval_status == "no_relevant_results":
return {
"answer_text": (
f"关于“{short_question}”,外部检索已经执行完成,但没有找到足够相关的外部公开证据。"
"现阶段我不建议把它直接当成当前项目的有效外部变化。"
),
"prompt_variant_id": "external_no_relevant_results",
}
return {
"answer_text": (
f"关于“{short_question}”,外部检索这次执行失败,没有产出可用的外部公开证据。"
"你可以稍后重试,或把要核验的公开对象说得更具体一些。"
),
"prompt_variant_id": "external_retrieval_failed",
}
@classmethod
def _build_indirect_only_response(cls, *, question: str, packet: ExternalContextPacket) -> dict[str, str]:
short_question = str(question or "").strip()[:80]
source_summaries = cls._build_source_summaries(packet)
return {
"answer_text": (
f"关于“{short_question}”,当前外部证据只能说明它和当前项目有背景相关性,"
"不能直接说明它已经命中当前项目对象。"
f"现有来源:{source_summaries}。如果你要,我可以继续把它整理成一份映射验证清单。"
),
"prompt_variant_id": "external_indirect_only",
}
async def _render_prompt(
self,
*,
@ -119,6 +534,27 @@ 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":
return self._build_failure_response(
question=question,
retrieval_status=retrieval_status,
)
if impact_status == "indirect_only":
return self._build_indirect_only_response(
question=question,
packet=packet,
)
verifier = dict(packet.verifier or {})
if not packet.verified_evidence or not bool(verifier.get("ok")):
return {

View File

@ -2,11 +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):
@ -14,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 = ""
@ -25,6 +29,11 @@ class ExternalQueryPlan(BaseModel):
result_limit: int = Field(default=3, ge=1, le=5)
requires_multi_source: bool = False
clarify_on_ambiguity: bool = False
fit_brief_summary: str = ""
project_scope_hint: str | None = None
recommendation_goal: str | None = None
project_object_anchors: list[str] = Field(default_factory=list)
signal_source_objectives: list[str] = Field(default_factory=list)
class ExternalEvidenceItem(BaseModel):
@ -61,6 +70,30 @@ class ExternalQueryExecution(BaseModel):
planner_meta: dict[str, Any] = Field(default_factory=dict)
meta: dict[str, Any] = Field(default_factory=dict)
def derive_retrieval_status(self) -> ExternalRetrievalStatus:
verifier = dict(self.verifier or {})
attempts = list((self.meta or {}).get("adapter_attempts") or [])
if self.evidence and bool(verifier.get("ok")):
return "evidence_found"
if self.evidence and not bool(verifier.get("ok")):
return "verifier_blocked"
if any(
int(item.get("evidence_count") or 0) > 0 and not bool(item.get("verifier_ok"))
for item in attempts
if isinstance(item, dict)
):
return "verifier_blocked"
statuses = {
str(item.get("status") or "").strip().lower()
for item in attempts
if isinstance(item, dict) and str(item.get("status") or "").strip()
}
if any(status in {"success", "empty"} for status in statuses):
return "no_relevant_results"
return "retrieval_failed"
def to_context_packet(self, *, question: str) -> "ExternalContextPacket":
verifier = dict(self.verifier or {})
uncertainties: list[str] = []
@ -78,6 +111,9 @@ class ExternalQueryExecution(BaseModel):
verified_evidence=list(self.evidence or []) if bool(verifier.get("ok")) else [],
verifier=verifier,
uncertainties=uncertainties,
retrieval_status=self.derive_retrieval_status(),
impact_status="no_direct_impact",
affected_signal_sources=[],
)
@ -90,3 +126,63 @@ class ExternalContextPacket(BaseModel):
verified_evidence: list[ExternalEvidenceItem] = Field(default_factory=list)
verifier: dict[str, Any] = Field(default_factory=dict)
uncertainties: list[str] = Field(default_factory=list)
retrieval_status: ExternalRetrievalStatus = "retrieval_failed"
impact_status: ExternalImpactStatus = "no_direct_impact"
affected_signal_sources: list[str] = Field(default_factory=list)
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 {}

View File

@ -543,6 +543,9 @@ class FeishuReplyOutboxMixin:
raw = str(value or "").strip()
if not raw:
return ""
preserve_handoff_next_step = "下一步:" in raw and any(
marker in raw for marker in ("当前项目:", "外部判断:")
)
label_map = {
"当前我更推荐:": "⭐ 当前我更推荐:",
@ -564,7 +567,14 @@ class FeishuReplyOutboxMixin:
if beautified and beautified[-1] != "":
beautified.append("")
continue
for legacy, replacement in legacy_prefix_map.items():
line_prefix_map = legacy_prefix_map
if preserve_handoff_next_step and stripped.startswith("下一步:"):
line_prefix_map = {
legacy: replacement
for legacy, replacement in legacy_prefix_map.items()
if legacy != "下一步:"
}
for legacy, replacement in line_prefix_map.items():
if stripped.startswith(legacy):
stripped = f"{replacement}{stripped[len(legacy):]}".strip()
break

View File

@ -31,6 +31,7 @@ from sqlalchemy.future import select
from ....models import (
User,
Project,
Task,
Signal,
ProjectSignalLink,
Integration,
@ -38,7 +39,11 @@ from ....models import (
AIProcessingLog,
)
from ....core.database import AsyncSessionLocal
from ....config.ai_prompts import PROMPT_FEISHU_HUMANIZED_REPLY
from ....config.ai_prompts import (
PROMPT_FEISHU_HUMANIZED_REPLY,
PROMPT_FEISHU_QA_NATURAL_REPLY,
PROMPT_FEISHU_QA_NATURAL_REPLY_REWRITE,
)
from ...notification_service import NotificationService
from ...signal.signal_capture_service import ProactiveSignalCaptureService
from ...llm_service import LLMService

View File

@ -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)

View File

@ -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:

View File

@ -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"

View File

@ -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 返回异常,已按规则生成项目预填草案。",
"结构化项目预填失败,当前仅保留原始描述,待补充标题、范围和负责人。",
"负责人和时间线仅在消息明确给出时才填写。",
],
}

View File

@ -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"^<at[^>]*>[^<]*</at>\s*", "", str(text or ""), flags=re.IGNORECASE).strip()

View File

@ -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,

View File

@ -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,
)

View File

@ -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":

View File

@ -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)
)

View File

@ -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,
)

View File

@ -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"])

View File

@ -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 ""),

View File

@ -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)

View File

@ -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,

View File

@ -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)

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -155,6 +155,176 @@ async def test_http_rag_ask_mixed_mainline_contract(monkeypatch) -> None:
assert rag_request_context["mixed_signal_part"] == "新增任务确认流"
@pytest.mark.asyncio
async def test_http_rag_ask_should_attach_project_operating_context_for_current_project(monkeypatch) -> None:
captured: dict[str, object] = {}
class FakeKernel:
def __init__(self, db, tenant_id: int):
_ = db
captured["tenant_id"] = tenant_id
async def process_ingress(self, **kwargs):
captured["ingress_kwargs"] = kwargs
return {
"handled": True,
"status": "answered",
"intent": "query.read_only",
"direct_reply_required": True,
"direct_reply_text": "先给判断,再给下一步。",
"rag_result": {
"answer": "先给判断,再给下一步。",
"route_decision": {
"route_mode": "external",
"effective_source_policy": "external",
"decision_origin": "query_route_gateway",
},
},
}
monkeypatch.setattr(routes_query, "AIPMOKernelService", FakeKernel)
app = _build_test_app()
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as client:
response = await client.post(
"/rag/ask",
json={
"question": "OpenAI Docs 这种能力适不适合我们当前项目?",
"asked_via": "feishu",
"project_id": 12,
"source_chat_id": "chat-12",
"action_mode": "read",
},
)
assert response.status_code == 200
ingress_options = captured["ingress_kwargs"]["ingress_options"]
packet = ingress_options["project_operating_context"]
assert packet["current_project_id"] == 12
assert packet["current_repo_scope"]
assert packet["fit_scope"] == "current_project_and_repo"
assert packet["decision_strategy"] == "answer_then_offer_plan"
@pytest.mark.asyncio
async def test_http_rag_ask_returns_offer_after_answer_handoff_metadata_for_current_project_fit_question(
monkeypatch,
) -> None:
class FakeKernel:
def __init__(self, db, tenant_id: int):
_ = (db, tenant_id)
async def process_ingress(self, **kwargs):
_ = kwargs
return {
"handled": True,
"status": "answered",
"intent": "query.read_only",
"direct_reply_required": True,
"direct_reply_text": "外部实时查询:目前证据不足,建议先明确要查融资还是招聘动态。",
"rag_result": {
"answer": "外部实时查询:目前证据不足,建议先明确要查融资还是招聘动态。",
"route_decision": {
"route_mode": "external",
"effective_source_policy": "external",
"decision_origin": "query_route_gateway",
},
"project_operating_context": {
"context_version": "v1",
"current_project_id": 12,
"current_repo_scope": "alpha-repo",
"page_anchor": {"type": "project_board", "ref": "project:12"},
"thread_anchor": {"source": "pmflow"},
"fit_scope": "current_project_and_repo",
"decision_strategy": "answer_then_offer_plan",
},
"execution_recipe": {
"primary_lane": "external",
"fit_brief_required": True,
"external_evidence_required": True,
"plan_handoff_mode": "offer_after_answer",
},
"fit_brief": {
"project_summary": "Alpha 是一个 AI 项目管理工作台。",
"repo_summary": "当前仓库负责统一项目上下文与 AI handoff。",
},
"external_recommendation": {
"retrieval_status": "evidence_found",
"impact_status": "indirect_only",
"external_impact": "目前更像背景相关,还不能直接映射到当前正式对象。",
},
"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,继续把需求范围和下一步收清楚。",
},
}
monkeypatch.setattr(routes_query, "AIPMOKernelService", FakeKernel)
app = _build_test_app()
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as client:
response = await client.post(
"/rag/ask",
json={
"question": "OpenAI Responses API 放到当前库存上报项目里,适合放在哪一层?",
"asked_via": "pmflow_manual",
"project_id": 12,
"source_chat_id": "chat-12",
"action_mode": "read",
},
)
assert response.status_code == 200
payload = response.json()
assert payload["code"] == 0
data = payload["data"]
assert data["route_decision"]["route_mode"] == "external"
assert data["project_operating_context"]["current_project_id"] == 12
assert data["execution_recipe"]["plan_handoff_mode"] == "offer_after_answer"
assert data["fit_brief"]["project_summary"] == "Alpha 是一个 AI 项目管理工作台。"
assert data["external_recommendation"]["impact_status"] == "indirect_only"
assert data["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"]
@pytest.mark.asyncio
async def test_http_ai_pmo_process_text_draft_pending_confirm_mainline(monkeypatch) -> None:
captured: dict[str, object] = {}
@ -201,6 +371,47 @@ async def test_http_ai_pmo_process_text_draft_pending_confirm_mainline(monkeypat
assert kwargs["project_hint_id"] == 12
@pytest.mark.asyncio
async def test_http_ai_pmo_process_text_should_attach_project_operating_context(monkeypatch) -> None:
captured: dict[str, object] = {}
class FakeKernel:
def __init__(self, db, tenant_id: int):
_ = db
captured["tenant_id"] = tenant_id
async def process_ingress(self, **kwargs):
captured["ingress_kwargs"] = kwargs
return {
"handled": True,
"status": "answered",
"classification": "query",
"direct_reply_required": True,
"direct_reply_text": "这个方向先判断,再决定是否进入计划。",
}
monkeypatch.setattr(routes_core, "AIPMOKernelService", FakeKernel)
app = _build_test_app()
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as client:
response = await client.post(
"/ai-pmo/process-text",
json={
"message_text": "这个方案适不适合我们当前项目?",
"sender": "产品经理A",
"source_message_id": "manual-msg-200",
"project_id": 12,
},
)
assert response.status_code == 200
ingress_options = captured["ingress_kwargs"]["ingress_options"]
packet = ingress_options["project_operating_context"]
assert packet["current_project_id"] == 12
assert packet["fit_scope"] == "current_project_and_repo"
assert packet["decision_strategy"] == "answer_then_offer_plan"
@pytest.mark.asyncio
async def test_http_ai_pmo_work_items_list_smoke(monkeypatch) -> None:
class FakeResult:

View File

@ -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"

View File

@ -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"

View File

@ -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

View File

@ -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"] == {}

View File

@ -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

View File

@ -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:

View File

@ -1,4 +1,9 @@
from types import SimpleNamespace
import pytest
from app.services.external_query_service.planner import ExternalQueryPlanner
from app.services.external_query_service.presenter import ExternalAnswerPresenter
def test_fallback_plan_stays_generic_for_fresh_weather_semantics() -> None:
@ -49,3 +54,907 @@ def test_fallback_plan_uses_compare_contract_without_object_kind_mapping() -> No
assert plan.answer_contract == "compare_with_sources"
assert plan.requires_multi_source is True
assert plan.result_limit == 3
@pytest.mark.asyncio
async def test_build_plan_threads_fit_brief_guidance_into_prompt_and_plan() -> None:
class _FakeLLM:
def __init__(self) -> None:
self.prompt = ""
async def generate_structured_object(self, **kwargs):
self.prompt = str(kwargs.get("prompt") or "")
return SimpleNamespace(
payload={
"topic": "Responses API 适配 PMFlow",
"search_queries": ["OpenAI Responses API project management workflow"],
}
)
fake_llm = _FakeLLM()
planner = ExternalQueryPlanner(
llm_service=fake_llm,
db=object(),
tenant_id=1,
)
fit_brief = {
"project_summary": "当前项目:PMFlow AI 管家,聚焦项目内问答与需求流转。",
"repo_summary": "当前仓库:FastAPI 后端 + React 前端。",
"current_capabilities": ["AI 管家", "智能配置"],
"constraints": ["只允许基于外部公开证据给出推荐。"],
"recommendation_focus": ["判断 Responses API 是否适合当前项目接入"],
}
plan, meta = await planner.build_plan(
question="这个项目适不适合接 OpenAI Responses API?",
query_semantics={
"query_profile": {"needs_freshness": True},
"object_mentions": ["OpenAI Responses API"],
},
fit_brief=fit_brief,
)
assert meta["planner"] == "llm_structured"
assert "PMFlow AI 管家" in fake_llm.prompt
assert "智能配置" in fake_llm.prompt
assert plan.fit_brief_summary
assert plan.project_scope_hint == "current_project_and_repo"
assert "适合当前项目接入" in str(plan.recommendation_goal)
assert "AI 管家" in plan.project_object_anchors
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"),
[
("verifier_blocked", "external_verifier_blocked", "没有通过核验"),
("no_relevant_results", "external_no_relevant_results", "没有找到足够相关的外部公开证据"),
("retrieval_failed", "external_retrieval_failed", "外部检索这次执行失败"),
],
)
async def test_presenter_distinguishes_external_failure_states(
retrieval_status: str,
expected_variant: str,
expected_phrase: str,
) -> None:
class _FakeLLM:
def __init__(self) -> None:
self.calls = 0
async def generate(self, *args, **kwargs):
_ = (args, kwargs)
self.calls += 1
return "should not be used"
fake_llm = _FakeLLM()
presenter = ExternalAnswerPresenter(
llm_service=fake_llm,
db=object(),
tenant_id=1,
)
result = await presenter.present(
question="这个项目适不适合接某个外部能力?",
context_packet={
"question": "这个项目适不适合接某个外部能力?",
"plan": {
"answer_contract": "summary_with_sources",
"topic": "外部能力适配判断",
},
"adapter_attempts": [{"adapter": "live_search", "status": "empty"}],
"verified_evidence": [],
"verifier": {"ok": False, "reason_code": "external_evidence_off_topic"},
"uncertainties": ["external_evidence_off_topic"],
"retrieval_status": retrieval_status,
"impact_status": "no_direct_impact",
},
thinking_mode="normal",
)
assert result["prompt_variant_id"] == expected_variant
assert expected_phrase in result["answer_text"]
assert fake_llm.calls == 0
@pytest.mark.asyncio
async def test_presenter_distinguishes_indirect_only_without_overclaiming_direct_match() -> None:
class _FakeLLM:
def __init__(self) -> None:
self.calls = 0
async def generate(self, *args, **kwargs):
_ = (args, kwargs)
self.calls += 1
return "should not be used"
fake_llm = _FakeLLM()
presenter = ExternalAnswerPresenter(
llm_service=fake_llm,
db=object(),
tenant_id=1,
)
result = await presenter.present(
question="这个项目要不要跟进 OpenAI Responses API?",
context_packet={
"question": "这个项目要不要跟进 OpenAI Responses API?",
"plan": {
"answer_contract": "summary_with_sources",
"topic": "Responses API 适配性",
},
"adapter_attempts": [{"adapter": "live_search", "status": "success"}],
"verified_evidence": [
{
"source_type": "url",
"source_label": "OpenAI Docs",
"title": "Responses API guide",
"target_url": "https://platform.openai.com/docs/guides/responses",
"snippet": "Responses API can orchestrate tool calls and structured outputs.",
}
],
"verifier": {"ok": True},
"uncertainties": [],
"retrieval_status": "evidence_found",
"impact_status": "indirect_only",
},
thinking_mode="normal",
)
assert result["prompt_variant_id"] == "external_indirect_only"
assert "只能说明它和当前项目有背景相关性" in result["answer_text"]
assert "不能直接说明它已经命中当前项目对象" in result["answer_text"]
assert fake_llm.calls == 0
@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

View File

@ -139,6 +139,789 @@ def test_build_action_card_for_requirement_plan_followup() -> None:
assert [((btn.get("value") or {}).get("synthetic_text")) for btn in row_2_actions] == ["继续出草稿", "重出一版"]
@pytest.mark.parametrize(
("external_recommendation", "expected_fragment"),
[
(
{
"retrieval_status": "evidence_found",
"impact_status": "indirect_only",
},
"目前更像背景相关",
),
(
{
"retrieval_status": "verifier_blocked",
"impact_status": "uncertain",
},
"校验未通过",
),
(
{
"retrieval_status": "no_relevant_results",
"impact_status": "no_direct_match",
},
"暂未找到与当前项目直接相关的公开证据",
),
(
{
"retrieval_status": "retrieval_failed",
"impact_status": "uncertain",
},
"这次外部检索失败",
),
],
)
def test_build_feishu_external_summary_line_preserves_distinct_states(
external_recommendation: dict[str, str],
expected_fragment: str,
) -> None:
line = FeishuReceiveRoutingMixin._build_feishu_external_summary_line(external_recommendation)
assert line.startswith("外部判断:")
assert expected_fragment in line
def test_should_accept_feishu_qa_natural_reply_rejects_fixed_opener_without_project_entities() -> None:
accepted = FeishuReceiveRoutingMixin._should_accept_feishu_qa_natural_reply(
"这事可以继续看,但现在还不能直接说它适合这个项目。",
selected_entity_candidates=[
"采购经理每日实时库存上报服务",
"WMS库存快照自动拉取与清洗",
"每日固定时段生成并推送结构化摘要卡片",
],
blocked_entity_candidates=[],
)
assert accepted is False
def test_should_accept_feishu_qa_natural_reply_accepts_project_entity_analysis() -> None:
accepted = FeishuReceiveRoutingMixin._should_accept_feishu_qa_natural_reply(
(
"Responses API 不适合放在 WMS库存快照自动拉取或者 TOP10缺货排序这两层,"
"更像放在每日固定时段生成并推送结构化摘要卡片之后的解释和追问层。"
),
selected_entity_candidates=[
"采购经理每日实时库存上报服务",
"WMS库存快照自动拉取与清洗",
"TOP10商品缺货排序逻辑实现(基于当前库存与安全库存差值)",
"每日固定时段生成并推送结构化摘要卡片",
],
blocked_entity_candidates=[],
)
assert accepted is True
def test_should_accept_feishu_qa_natural_reply_rejects_unplanned_project_entities() -> None:
accepted = FeishuReceiveRoutingMixin._should_accept_feishu_qa_natural_reply(
(
"Responses API 更像放在每日固定时段生成并推送结构化摘要卡片之后的解释层,"
"不适合直接放进 WMS库存快照自动拉取与清洗 主链。"
"但它和外部信号源 - WMS实时库存接口 也许也能直接打通。"
),
selected_entity_candidates=[
"WMS库存快照自动拉取与清洗",
"每日固定时段生成并推送结构化摘要卡片",
],
blocked_entity_candidates=[
"外部信号源 - WMS实时库存接口",
"TOP10商品缺货排序逻辑实现(基于当前库存与安全库存差值)",
],
)
assert accepted is False
def test_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={
"conclusion": "Responses API 更像摘要卡片之后的解释层。",
"focus_entities": [
{
"name": "每日固定时段生成并推送结构化摘要卡片",
"stance": "better_fit",
"why": "这里更适合承接解释和追问。",
},
{
"name": "WMS库存快照自动拉取与清洗",
"stance": "not_fit",
"why": "这是确定性取数链路,不适合塞进生成能力。",
},
{
"name": "外部并不存在的对象",
"stance": "not_fit",
"why": "这条应该被过滤掉。",
},
],
"missing_mapping": "当前没有证据说明它已接入现有信号源或主计算链。",
"next_step": "先确认它到底承担哪个节点的角色。",
},
entity_catalog=[
{
"name": "每日固定时段生成并推送结构化摘要卡片",
"kind": "task",
},
{
"name": "WMS库存快照自动拉取与清洗",
"kind": "task",
},
{
"name": "外部信号源 - WMS实时库存接口",
"kind": "signal",
},
],
fallback_next_step="继续确认它到底落在这个项目的哪一层,再决定要不要进入 plan。",
)
assert plan["conclusion"] == "Responses API 更像摘要卡片之后的解释层。"
assert [item["name"] for item in plan["focus_entities"]] == [
"每日固定时段生成并推送结构化摘要卡片",
"WMS库存快照自动拉取与清洗",
]
assert [item["stance"] for item in plan["focus_entities"]] == ["better_fit", "not_fit"]
assert all(item["name"] != "外部并不存在的对象" for item in plan["focus_entities"])
assert plan["missing_mapping"] == "当前没有证据说明它已接入现有信号源或主计算链。"
def test_build_feishu_qa_analysis_fallback_plan_uses_deterministic_catalog_order_for_indirect_only_without_structured_focus() -> None:
plan = FeishuReceiveRoutingMixin._build_feishu_qa_analysis_fallback_plan(
result={
"rag_result": {
"external_recommendation": {
"retrieval_status": "evidence_found",
"impact_status": "indirect_only",
},
"recommended_next_step": "继续确认它到底落在这个项目的哪一层,再决定要不要进入 plan。",
},
},
entity_catalog=[
{"name": "WMS库存快照自动拉取与清洗", "kind": "task"},
{"name": "TOP10商品缺货排序逻辑实现(基于当前库存与安全库存差值)", "kind": "task"},
{"name": "每日固定时段生成并推送结构化摘要卡片", "kind": "task"},
{"name": "试点小组成员接收通道配置与权限开通", "kind": "task"},
],
)
assert plan["conclusion"] == "目前还不能直接说它已经适合当前项目。"
assert [item["name"] for item in plan["focus_entities"]] == [
"WMS库存快照自动拉取与清洗",
"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库存快照自动拉取与清洗",
]
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:
reply = FeishuReceiveRoutingMixin._build_feishu_qa_analysis_plan_reply_text(
{
"conclusion": "目前还不能直接说它已经适合当前项目。",
"focus_entities": [
{
"name": "每日固定时段生成并推送结构化摘要卡片",
"stance": "better_fit",
"why": "这里更像结果出来之后的解释和追问层。",
},
{
"name": "WMS库存快照自动拉取与清洗",
"stance": "not_fit",
"why": "这是稳定取数的主链,不适合直接塞进生成能力。",
},
],
"missing_mapping": "还缺它在当前项目里到底承接哪个节点的明确映射。",
"next_step": "先确认它是不是只用在摘要卡片之后。",
}
)
expected = (
"目前还不能直接说它已经适合当前项目。\n\n"
"如果真要放进这个项目,我会先放在每日固定时段生成并推送结构化摘要卡片这一步,"
"不太会直接改WMS库存快照自动拉取与清洗这条主流程。\n\n"
"还缺它在当前项目里到底承接哪个节点的明确映射。\n\n"
"先确认它是不是只用在摘要卡片之后。"
)
assert reply == expected
def test_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={
"conclusion": "目前还不能直接说它已经适合当前项目。",
"focus_entities": [
{
"name": "WMS库存快照自动拉取与清洗",
"stance": "relevant",
"why": "该任务聚焦内部WMS数据接入。",
},
{
"name": "外部信号源 - WMS实时库存接口",
"stance": "relevant",
"why": "同为外部API调用场景。",
},
],
"missing_mapping": "缺少具体作用层映射。",
"next_step": "确认它是不是用于摘要生成环节。",
},
fallback_plan={
"conclusion": "目前还不能直接说它已经适合当前项目。",
"focus_entities": [
{
"name": "每日固定时段生成并推送结构化摘要卡片",
"stance": "better_fit",
"why": "这一步更像结果出来之后的解释和追问层。",
},
{
"name": "WMS库存快照自动拉取与清洗",
"stance": "not_fit",
"why": "这一步属于确定性取数或计算主链,不适合直接塞进生成能力。",
},
],
"missing_mapping": "还缺它在当前项目里到底承接哪个节点的明确映射。",
"next_step": "继续确认它到底落在这个项目的哪一层,再决定要不要进入 plan。",
},
retrieval_status="evidence_found",
impact_status="indirect_only",
)
assert [item["name"] for item in chosen["focus_entities"]] == [
"每日固定时段生成并推送结构化摘要卡片",
"WMS库存快照自动拉取与清洗",
]
def test_build_execution_receipt_reply_prefers_compact_multiline_summary() -> None:
reply = FeishuReplyTemplatesMixin._build_execution_receipt_reply(
message_content="请处理这条信号",
@ -379,3 +1162,83 @@ async def test_route_scene_reply_sends_requirement_plan_followup_as_card(monkeyp
assert "reply_text" not in calls
card_content = calls["reply_card"]["card_content"] if isinstance(calls["reply_card"], dict) else {}
assert card_content["header"]["title"]["content"] == "AI PMO 需求收敛"
@pytest.mark.asyncio
async def test_route_scene_reply_sends_rich_qa_direct_text_for_handoff_metadata() -> None:
calls: dict[str, object] = {}
class FakeRouting(FeishuReceiveRoutingMixin):
def _build_capability_reply(self, message_content: str): # noqa: ANN001
_ = message_content
return None
def _build_action_card_from_capture_result(self, *, capture_result, mention_open_id, mention_name): # noqa: ANN001
_ = (capture_result, mention_open_id, mention_name)
return None
async def _safe_reply_text(self, **kwargs): # noqa: ANN001
calls["reply_text"] = kwargs
def _emit_state_transition(self, **kwargs): # noqa: ANN001
calls["state_transition"] = kwargs
def _should_allow_group_qa_reply(self, **kwargs): # noqa: ANN001
_ = kwargs
return True
async def _render_feishu_qa_direct_reply_text(self, *, result, message_content): # noqa: ANN001
_ = (result, message_content)
return (
"Responses API 不适合放在 WMS库存快照自动拉取或者 TOP10缺货排序这两层,"
"更像放在每日结构化摘要卡片之后的解释和追问层。\n\n"
"这个项目当前在做的是先把 WMS库存快照拉进来,再完成 TOP10缺货排序,"
"最后按固定时段推送结构化摘要卡片。前两层都属于取数和计算主链,应该保持确定、可复算,"
"不适合把 Responses API 放进去做主判断。\n\n"
"它真正更可能有价值的地方,是采购经理收到摘要卡片之后继续追问的时候。"
)
routing = FakeRouting()
result = await routing._route_scene_reply(
scene="qa",
result={
"direct_reply_required": True,
"direct_reply_text": "外部实时查询:目前证据不足,建议先明确要查融资还是招聘动态。",
"rag_result": {
"fit_brief": {
"project_summary": "Alpha 是一个 AI 项目管理工作台。",
"repo_summary": "当前仓库负责统一项目上下文与 AI handoff。",
},
"external_recommendation": {
"retrieval_status": "evidence_found",
"impact_status": "indirect_only",
"external_impact": "目前更像背景相关,还不能直接映射到当前正式对象。",
},
"recommended_next_step": "如果你要,我可以基于当前结论进入 plan,继续把需求范围和下一步收清楚。",
},
},
message_content="这个适合吗",
chat_id="chat-rich-qa",
chat_type="group",
sender_open_id="ou-rich-qa",
sender_name="测试用户",
mentioned_bot_names=[],
thread_context={"reply_to_message_id": "om-rich-qa"},
trace_id="trace-rich-qa",
message_id="msg-rich-qa",
perf_t0=None,
)
assert result == "qa_direct"
reply_text = calls["reply_text"]["text"] if isinstance(calls.get("reply_text"), dict) else ""
expected = (
"Responses API 不适合放在 WMS库存快照自动拉取或者 TOP10缺货排序这两层,"
"更像放在每日结构化摘要卡片之后的解释和追问层。\n\n"
"这个项目当前在做的是先把 WMS库存快照拉进来,再完成 TOP10缺货排序,"
"最后按固定时段推送结构化摘要卡片。前两层都属于取数和计算主链,应该保持确定、可复算,"
"不适合把 Responses API 放进去做主判断。\n\n"
"它真正更可能有价值的地方,是采购经理收到摘要卡片之后继续追问的时候。"
)
assert reply_text == expected
assert "这事可以继续看" not in reply_text
assert "先说我的判断" not in reply_text

View File

@ -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,
*,
@ -1051,12 +1116,14 @@ async def test_should_process_feishu_high_accuracy_treats_requirement_plan_as_sy
classmethod(lambda cls, **kwargs: asyncio.sleep(0, result=_FakeBundle())),
)
should_sync, reason = await mixin_capture_entry.SignalCaptureEntryMixin._should_process_feishu_high_accuracy(
should_sync, reason, bundle = await mixin_capture_entry.SignalCaptureEntryMixin._should_process_feishu_high_accuracy(
message_text="@PMFlow 想跟你聊聊需求,做一个自动给每日的大A趋势打分的小工具,先不创建,先收敛第一版。",
)
assert should_sync is True
assert reason == "semantic:requirement_plan"
assert bundle is not None
assert getattr(bundle, "diagnostics", {}).get("message_intent") == "requirement_plan"
@pytest.mark.asyncio
@ -1072,12 +1139,14 @@ async def test_should_process_feishu_high_accuracy_treats_requirement_plan_slot_
classmethod(lambda cls, **kwargs: asyncio.sleep(0, result=_FakeBundle())),
)
should_sync, reason = await mixin_capture_entry.SignalCaptureEntryMixin._should_process_feishu_high_accuracy(
should_sync, reason, bundle = await mixin_capture_entry.SignalCaptureEntryMixin._should_process_feishu_high_accuracy(
message_text="@PMFlow 先按技术指标来,主要给投顾看。",
)
assert should_sync is True
assert reason == "semantic:requirement_plan"
assert bundle is not None
assert getattr(bundle, "diagnostics", {}).get("message_intent") == "requirement_plan"
@pytest.mark.asyncio
@ -1093,12 +1162,13 @@ async def test_should_process_feishu_high_accuracy_no_longer_uses_question_suffi
classmethod(lambda cls, **kwargs: asyncio.sleep(0, result=_FakeBundle())),
)
should_sync, reason = await mixin_capture_entry.SignalCaptureEntryMixin._should_process_feishu_high_accuracy(
should_sync, reason, bundle = await mixin_capture_entry.SignalCaptureEntryMixin._should_process_feishu_high_accuracy(
message_text="@PMFlow 现在几点?",
)
assert should_sync is False
assert reason == "async_queue"
assert bundle is None
@pytest.mark.asyncio
@ -1160,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,
*,
@ -1261,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,
*,
@ -1307,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": {},
}
@ -1361,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,
*,
@ -1411,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": {},
}
@ -1465,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,
*,
@ -1589,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": {},
}
@ -1652,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,
*,
@ -1666,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",
@ -1701,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": {},
}
@ -1766,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,
*,
@ -1881,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,
*,
@ -1997,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,
*,
@ -2145,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,
*,
@ -2196,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 []
@ -2249,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

View File

@ -0,0 +1,67 @@
from app.schemas.project_operating_context import ExecutionRecipe, ProjectFitBrief, ProjectOperatingContext
from app.services.ai_pmo.project_operating_context_builder import ProjectOperatingContextBuilder
def test_project_operating_context_schema_contract() -> None:
context = ProjectOperatingContext(
current_project_id=17,
current_repo_scope="pmflow-main",
page_anchor={"project_id": 17, "origin": "pathname"},
thread_anchor={"reply_to_message_id": "om_xxx"},
)
assert context.context_version == "v1"
assert context.current_project_id == 17
assert context.current_repo_scope == "pmflow-main"
assert context.page_anchor["project_id"] == 17
assert context.thread_anchor["reply_to_message_id"] == "om_xxx"
assert context.fit_scope == "current_project_and_repo"
assert context.decision_strategy == "answer_then_offer_plan"
def test_project_operating_context_builder_collects_project_repo_and_anchors() -> None:
builder = ProjectOperatingContextBuilder(repo_scope="pmflow-main")
context = builder.build(
current_project_id=17,
page_anchor={"project_id": 17, "origin": "rag_request_context"},
thread_anchor={"reply_to_message_id": "om_xxx", "root_message_id": "om_root"},
)
assert context.current_project_id == 17
assert context.current_repo_scope == "pmflow-main"
assert context.page_anchor == {"project_id": 17, "origin": "rag_request_context"}
assert context.thread_anchor == {"reply_to_message_id": "om_xxx", "root_message_id": "om_root"}
assert context.fit_scope == "current_project_and_repo"
assert context.decision_strategy == "answer_then_offer_plan"
def test_execution_recipe_schema_contract() -> None:
recipe = ExecutionRecipe(
primary_lane="external",
fit_brief_required=True,
external_evidence_required=True,
plan_handoff_mode="offer_after_answer",
)
assert recipe.primary_lane == "external"
assert recipe.fit_brief_required is True
assert recipe.external_evidence_required is True
assert recipe.plan_handoff_mode == "offer_after_answer"
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 == [
"设计产能监控逻辑与数据采集:编写脚本收集测试队列与资源使用数据",
"实现产能警告触发服务:实时比对数据与阈值",
]

View File

@ -109,6 +109,27 @@ def test_build_kernel_ingress_kwargs_preserves_explicit_anchor_fields() -> None:
assert kwargs["ingress_options"]["rag_request_context"]["source_chat_id"] == "pmflow-project-17"
def test_build_kernel_ingress_kwargs_attaches_project_operating_context() -> None:
request = RAGAskRequest(
question="OpenAI Docs 这种能力适不适合我们当前项目?",
project_id=17,
source_chat_id="pmflow-project-17",
asked_via="feishu",
)
kwargs = rag_ask_contract.build_kernel_ingress_kwargs(
request=request,
current_user=SimpleNamespace(id=1, tenant_id=1, username="admin"),
)
packet = kwargs["ingress_options"]["project_operating_context"]
assert packet["current_project_id"] == 17
assert packet["current_repo_scope"]
assert packet["page_anchor"]["project_id"] == 17
assert packet["fit_scope"] == "current_project_and_repo"
assert packet["decision_strategy"] == "answer_then_offer_plan"
@pytest.mark.asyncio
async def test_rag_ask_route_forwards_contract_fields(monkeypatch) -> None:
captured: dict[str, object] = {}
@ -161,6 +182,10 @@ async def test_rag_ask_route_forwards_contract_fields(monkeypatch) -> None:
assert rag_request_context["message_intent"] == "mixed"
assert rag_request_context["mixed_signal_part"] == "新增任务确认流"
assert rag_request_context["consultant_mode_preference"] == "clarify"
project_operating_context = ingress_kwargs["ingress_options"]["project_operating_context"]
assert project_operating_context["current_repo_scope"]
assert project_operating_context["fit_scope"] == "current_project_and_repo"
assert project_operating_context["decision_strategy"] == "answer_then_offer_plan"
def test_ai_steward_router_does_not_expose_legacy_rag_ask_route() -> None:
@ -215,24 +240,23 @@ async def test_rag_ask_route_uses_shared_contract_builder(monkeypatch) -> None:
assert len(captured_kwargs) == 1
forwarded = captured_kwargs[0]
assert str(forwarded["source_message_id"]).startswith("rag-ask-")
assert forwarded == {
"source_system": "pmflow",
"source_message_id": forwarded["source_message_id"],
"message_text": "q1",
"sender": "PMFlow用户",
"channel_ref": None,
"source_sender_open_id": None,
"captured_at": None,
"chat_type": "manual",
"project_hint_id": None,
"ingress_options": {
"rag_request_context": {
"question": "q1",
"asked_by": 1,
"asked_via": "shared-builder",
}
},
assert forwarded["source_system"] == "pmflow"
assert forwarded["message_text"] == "q1"
assert forwarded["sender"] == "PMFlow用户"
assert forwarded["channel_ref"] is None
assert forwarded["source_sender_open_id"] is None
assert forwarded["captured_at"] is None
assert forwarded["chat_type"] == "manual"
assert forwarded["project_hint_id"] is None
assert forwarded["ingress_options"]["rag_request_context"] == {
"question": "q1",
"asked_by": 1,
"asked_via": "shared-builder",
}
assert forwarded["ingress_options"]["project_operating_context"]["current_project_id"] is None
assert forwarded["ingress_options"]["project_operating_context"]["current_repo_scope"]
assert forwarded["ingress_options"]["project_operating_context"]["fit_scope"] == "current_project_and_repo"
assert forwarded["ingress_options"]["project_operating_context"]["decision_strategy"] == "answer_then_offer_plan"
def test_build_rag_response_payload_preserves_rag_fields() -> None:

View File

@ -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():

View File

@ -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"

View File

@ -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"

View File

@ -45,6 +45,7 @@
### 4.2 AI PMO 与语义主链
- `backend/app/services/ai_pmo/ai_pmo_kernel/`
- `backend/app/services/ai_pmo/ai_pmo_kernel_ingress_flow/`
- `backend/app/services/ai_pmo/project_operating_context_builder.py`
- `backend/app/services/ai_pmo/ai_pmo_typed_executor_execute/`
- `backend/app/services/ai_pmo/ai_pmo_typed_executor_discovery/`
- `backend/app/services/ai_pmo/semantic_context/`
@ -54,8 +55,10 @@
### 4.3 问答、模型与生成
- `backend/app/services/adaptive_rag_service/`
- `backend/app/services/adaptive_rag_service/internal_query_context.py`
- `backend/app/services/adaptive_rag_service/mixin_project_fit_brief.py`
- `backend/app/services/adaptive_rag_service/mixin_query_route_gateway.py`
- `backend/app/services/adaptive_rag_service/mixin_query_route_governance.py`
- `backend/app/schemas/project_operating_context.py`
- `backend/app/services/external_query_service/`
- `backend/app/services/semantic_fact_service/`
- `backend/app/services/semantic_fact_service/hooks.py`
@ -99,11 +102,31 @@
- `backend/app/services/` 根目录已目录化收敛,新增服务优先进入对应子包。
- API canonical 入口优先使用 `*_api/` 子包,不再优先引用顶层兼容壳。
- 运行时业务动作归因优先走“对象/上下文绑定 -> `SemanticContextPacket` -> `SemanticDecision` -> gatekeeper”。
- non-plan `query.answer` 现已在 `AdaptiveRAG` 内增加 `Query Route Gateway`:只消费上游单次结构化 `query_profile/source_policy/effective_source_policy` 与显式 hint,归一化产出 `route_mode / decision_origin / reply_contract / probe_snapshot(contract audit)`,不再在运行时执行 internal/external probe 或 route memory 强拍板,再进入检索与回答主链。
- non-plan `query.answer` 现已在 `AdaptiveRAG` 内增加 `Query Route Gateway`:只消费上游单次结构化 `query_profile/source_policy/effective_source_policy` 与显式 hint,结合 `ProjectOperatingContext` 归一化产出 `route_mode / decision_origin / reply_contract / probe_snapshot(contract audit) / execution_recipe`,不再在运行时执行 internal/external probe 或 route memory 强拍板,再进入检索与回答主链。
- 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` 已完成 first-pass 收口:`Query Route Gateway` 之后统一进入 `backend/app/services/external_query_service/`,按 `External Query Planner -> External Query Gateway -> External Provider Registry -> MCP adapter 优先 -> bounded fallback live search -> external answer verifier -> ExternalContextPacket -> ExternalAnswerPresenter` 执行;AdaptiveRAG external probe、freshness fetch 与 external route retrieval 已接到这条链上,并对 external sources 执行 fail-closed 过滤;当前 `presenter.py` 使用独立 external-only prompt 家族,不再回流 `industry_trend/external_trend` scene prompt;相关设计规格见 `docs/superpowers/specs/2026-04-15-external-query-gateway-design.md`。
- external `query.answer` 已完成 second-pass 收口:`Query Route Gateway` 之后统一进入 `backend/app/services/external_query_service/`,按 `ProjectFitBrief -> External Query Planner -> External Query Gateway -> External Provider Registry -> MCP adapter 优先 -> bounded fallback live search -> external answer verifier -> ExternalContextPacket -> ExternalAnswerPresenter` 执行;planner 会显式消费 `fit_brief_summary / project_scope_hint / recommendation_goal / project_object_anchors / 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`。
@ -149,6 +172,8 @@
- `frontend/src/components/ai-steward/`
- `frontend/src/components/ai-steward/QAConversationStatusBar.tsx`
- `frontend/src/components/ai-steward/QAResponseMetaCard.tsx`
- `frontend/src/components/ai-steward/QAResponseMetaCard.test.tsx`
- AI Steward 问答浮窗现分三层显性化:`FloatingQAChat.tsx` 负责即时回答/历史回放归一化,`QAConversationStatusBar.tsx` 展示对象/来源/仓库/执行通道/plan handoff,`QAResponseMetaCard.tsx` 展示 `fit_brief / external_recommendation / recommended_next_step`,对应前端回归由 `FloatingQAChat.test.tsx` 与 `QAResponseMetaCard.test.tsx` 锁定。
- `frontend/src/api/taskBriefs.ts`
- `frontend/src/api/modelRouting.ts`
- `frontend/src/api/mcp.ts`

View File

@ -31,9 +31,30 @@
### 2.3 RAG / AI PMO / Knowledge Graph
- RAG 问答主入口:参考 `backend/app/api/rag_api/` 与 `backend/app/api/rag_ask_contract.py`
- `/rag/ask` richer response contract、QA session detail 与 state replay:参考 `backend/app/api/rag_ask_contract.py`、`backend/app/services/adaptive_rag_service/mixin_qa_session_list_ops_p1.py`
- Unified Project Operating Context 合同与 builder:参考 `backend/app/schemas/project_operating_context.py`、`backend/app/services/ai_pmo/project_operating_context_builder.py`
- 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`
- 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`;当前实现为“共享 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`
@ -51,6 +72,7 @@
- AI PMO 审批接口:参考 `POST /ai-pmo/work-items/{work_item_id}/decision`
- AI PMO 消息台列表接口:参考 `GET /ai-pmo/work-items`
- AI PMO `process-text/work-item` 的 `plan_mode / plan_status / requirement_plan_context_summary` 投影:参考 `backend/app/api/ai_pmo_api/helpers_inbox_payload.py` 与 `backend/app/api/ai_pmo_api/routes_core.py`
- `/rag/ask` 与 `/ai-pmo/process-text` 注入 `project_operating_context`:参考 `backend/app/api/rag_ask_contract.py` 与 `backend/app/api/ai_pmo_api/routes_core.py`
### 2.4 Signal / Project / SSTC
- 需求点落信号源:参考 `backend/app/services/signal/signal_source_orchestration/mixin_registry_execute.py`
@ -72,6 +94,8 @@
- 新增前端 API 调用:参考 `frontend/src/api/sstc.ts`
- AI Steward 页面入口:参考 `frontend/src/pages/ai-steward/`
- AI Steward 问答浮窗与上下文显性化组件:参考 `frontend/src/components/ai-steward/FloatingQAChat.tsx`、`frontend/src/components/ai-steward/QAConversationStatusBar.tsx`、`frontend/src/components/ai-steward/QAResponseMetaCard.tsx`
- AI Steward QA contract 前端类型(含 `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`
- Inbox 页面入口:参考 `frontend/src/pages/inbox/`

View File

@ -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` 并把代码路径、内部函数名或工程证据泄漏到用户可见答案里。
@ -108,8 +118,31 @@
- 飞书与 AI PMO 入口(含 `message_intent=mixed`)问答链路必须绕过 deterministic/snapshot 快路径,统一落到场景化 Prompt 生成。
- `query.answer` 在进入 AdaptiveRAG 检索前必须先产出单一 `source_policy`(`internal / external / uncertain`)审计字段;来源判断只允许消费上游明确锚点与单次结构化语义结果,禁止再新增基于“问题长相”的本地关键词/正则拍板层。若上游合同仍是 `uncertain`,执行层只能保持 `uncertain` 或消费上游显式给出的 `effective_source_policy`,不得私自默认 external-first。v1 禁止同一轮 query 同时混搜内部知识库与外部网页,必须先定来源再检索。
- non-plan `query.answer` 的来源裁决统一由 `Query Route Gateway` 执行,输出必须至少包含 `route_mode / decision_origin / reply_contract / probe_snapshot(contract audit)`;回复正文必须遵守显式来源合同(`内部知识库查询:` / `外部实时查询:`),`uncertain + effective_source_policy=uncertain` 必须直接澄清,禁止混合答案。
- `/rag/ask`、QA session detail 与 session state replay 对前台必须显式暴露 `route_decision / clarification_code / clarification_candidates / answer_mode / grounding_status / evidence_pack / session_state(last_subject, pending_clarification...)`;AI Steward UI 只能消费这些结构化字段展示当前对象、来源模式与澄清入口,禁止再从回答正文、来源标签文案或按钮文案反推 route mode。
- query-route 澄清协议也属于受控 state machine:若线程上仍挂着 `pending_clarification(kind=query_route_selection)`,但当前短消息通过 `WorkflowSemanticPlanner` 给出显式 `topic_switch=new` 或命中新话题 workflow,ingress 必须先让旧澄清立即失效,再回到新的 `query.answer` 主链;禁止因为“短句”条件成立就继续消费旧 option map。
- `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`,禁止冒充“已命中当前项目推荐”。
- 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.
- `Query Route Gateway` 只能汇总上游显式语义、审计锚点与执行结果,不得先并行跑 internal/external 语义探针再做“strong/weak/probe_conflict”裁决。internal / external 的执行分叉必须发生在来源合同已经确定之后;若合同不确定,只能停在 `uncertain/clarify`,禁止先执行双探针再回写路由结论。
- internal `query.answer` 在 `route_mode=internal` 后必须继续经过受控内部查询主链:`rag_query_semantics -> Operation Contract -> Target Resolver -> Query DSL Plan -> InternalContextPacket -> DSL normalizer -> 白名单执行器 -> semantic facts 优先(可用时)-> bounded fallback retrieval -> answer verifier`。`Operation Contract` 只负责回答合同与对象 mention,`Target Resolver` 只负责对象仲裁;planner 只允许输出结构化计划,禁止让 LLM 直接写 SQL、决定自由 join 或直接输出未取证的内部结论。
- `InternalContextPacket` 是 internal-only 运行时单一真源,至少固定携带 `packet_version / question / scope / route_decision / operation_contract / target_resolution / query_plan / executor_summary / verifier_summary / uncertainties`。`query_semantics["operation_contract"] / ["target_resolution"] / ["query_plan"]` 只允许作为上游 scratchpad 或同请求兼容镜像,不得再被 internal executor、internal verifier 或 runtime summary 注入链路当作真源读取。
@ -127,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。

View File

@ -0,0 +1,883 @@
# Unified Project Operating Context And Three-Lane Handoff Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** 先补一层统一的 `Project Operating Context`,再把 `internal / external / planmode` 收口成一个统一入口下的三条执行能力,让 PMFlow 变成“懂当前项目、能取外部资料、能继续推进计划”的项目型 AI,而不是三套互相打架的聊天模式。
**Architecture:** 本次不把三条链继续拆成三个彼此孤立的产品模式,而是在 ingress 先建立统一上下文层,再产出单次 `execution_recipe`。`internal` 负责内部对象事实与项目适配画像,`external` 负责外部可核验资料与推荐,`planmode` 只作为后续 handoff 能力接棒,默认策略固定为“先给判断结论,再明确给一个进入 plan 的下一步”。这里的 `planmode` 只是对话层简称;落到 repo 真名时,统一指 `plan_mode` 顶层 gate 以及其后的 `requirement_plan / draft_pending_confirm` 主链,不额外引入第四个 runtime route 名。外部推荐允许消费内部 `fit brief` 生成检索计划,但最终外部答案正文仍必须只由外部证据支撑。
**Tech Stack:** FastAPI, Pydantic, SQLAlchemy 2.0, AdaptiveRAG, AI PMO Kernel, External Query Gateway, React 18, TypeScript, React Query, Vitest, pytest
---
## Scope And Guardrails
- 本计划在真正实现前,必须先补一份 `Feishu 问题清单 + 黄金对话样例`,把用户已知痛点收成可回归样本;没有这一步,不允许直接进入三路整合实现。
- 本计划的统一上下文默认范围固定为:
- `当前项目`
- `当前仓库摘要`
- `当前线程 / 页面 / reply` 强锚点
- 这里的“当前仓库”按 repo 现状必须理解成“运行时可取得的 codebase snapshot / repo summary”,不是默认已经具备全 workspace、全语言、全目录的完整理解;当前 codebase overview 仍以 backend snapshot 为主,若后续扩展前端或全仓库画像,应作为显式增量实现。
- v1 不默认扩到“用户名下所有项目”或“整个租户 portfolio”,避免重新回到全局聊天串线。
- 默认多意图策略固定为:
- `A`:先给基于项目的判断结论
- 再明确给一个“要不要基于这个进入 plan”的下一步
- 强锚点仍优先于旧 session 和记忆续写:
- `reply/thread`
- 页面 `project_id`
- 显式 `work_item_id`
- 已绑定 active requirement / draft context
- 默认 `A` 只适用于未命中显式 `plan_mode` gate 的 recommendation / 判断题;一旦命中 active requirement context、draft confirm、显式 `reply/thread` 或 `work_item_id` 强锚点,必须继续留在 repo 既有的 `requirement_plan / draft_pending_confirm` 主链,不能被“先答再给 plan 入口”抢走。
- 严禁回到散落运行时语义拍板,这是一条硬约束:
- 不准新增关键词仓
- 不准新增正则拍板 helper
- 不准新增“看到某些词就切 internal / external / plan”的硬编码语义分流
- 不准在执行层靠“问题长相”重判 internal / external / plan
- 不准把飞书问题修成一堆 bypass 规则
- repo 里其它路径仍可能残留历史 keyword / lookup 工具;这轮 unified ingress 和三路 handoff 不得复用它们来做来源裁决、plan 夺权或飞书短句分流。
- external 推荐允许使用 internal 侧生成的 `fit brief` 作为“检索约束输入”,但 final external answer 仍必须遵守 external-only 展示合同:
- `fit brief` 只属于 pre-retrieval planning input,不放宽 repo 现有的单一 `source_policy` 合同,也不允许同一轮 query 混搜 internal + external 证据
- presenter 不重新注入 `system_snapshot / user_snapshot / project_context / internal context_blocks`
- 客户可见正文不混写内部事实充当外部证据,且最终展示仍必须遵守 `customer_safe / admin_debug` 双视图边界
- external recommendation 必须显式区分外部证据状态:
- `evidence_found`
- `verifier_blocked`
- `no_relevant_results`
- `retrieval_failed`
- 下面这些 `impact/action` 约束属于这份计划的 v1 product guardrail,不应被误读成 repo 现状已经完整落地的 runtime contract;实现时若与现有对象模型冲突,应先回到正式业务对象和 guardrail 真源校正。
- external recommendation 的“影响判断”必须独立于“检索状态”表达:
- `direct_match`
- `indirect_only`
- `no_direct_impact`
- 外部影响默认优先绑定正式项目对象;若当前项目存在正式 `signal source` / capability 锚点,则优先绑定这些对象。
- 若外部证据只能说明行业背景、技术可行性或泛市场趋势,而不能精确命中当前项目正式对象,只能返回 `indirect_only`,禁止伪装成“已命中项目推荐”。
- 外部动作只能落在:
- 范围调整
- 规则/口径核验
- 接口兼容性验证
- 监控补点
- 数据映射补齐
- 外部动作禁止涉及:
- 招聘
- 人员分配
- 负责人指派
- 组织调整
- 直接修改项目/信号源状态
- planmode 不再作为第一反应默认吞掉“适不适合 / 推荐什么 / 要不要接”这类问题;只有显式强动作锚点或统一 ingress recipe 判定为 `explicit_plan_now` 时才直接接管。
## Non-Goals For This Plan
- 不做 portfolio-wide 全局 AI 助手
- 不做“只要像建议题就自动进 plan”的激进策略
- 不做在线自动学习路由阈值
- 不在 v1 里引入新的自由工具调用或自由联网 agent
- 不把 external answer presenter 改成混合 internal + external 的单 prompt
- 不以关键词/正则/硬编码语义规则作为飞书问题的修复手段
## Baseline File Map
### Context Layer
- Create: `backend/app/schemas/project_operating_context.py`
- 统一定义 `ProjectOperatingContext`、`ProjectFitBrief`、`ExecutionRecipe` 等结构化合同。
- Create: `backend/app/services/ai_pmo/project_operating_context_builder.py`
- 从 `current_project_id / current repo snapshot / page anchor / thread anchor / world model` 组装统一上下文包。
- Modify: `backend/app/services/ai_pmo/ai_pmo_kernel_ingress_flow/mixin_entry.py`
- 在 ingress 入口建立并透传统一上下文,而不是先各走各的语义判断。
- Modify: `backend/app/services/ai_pmo/ai_pmo_kernel_world_model_core/mixin_builder.py`
- 暴露 `resolved_project_id` 之外的项目运行态摘要,供统一上下文层使用。
- Modify: `backend/app/services/ai_pmo/ai_pmo_kernel/mixins/world_model/ai_pmo_kernel_mixins_world_model_session.py`
- 持久化最小必要的 project operating context snapshot,避免 session 只记 `current_project_id` 太粗。
### Unified Query Handoff
- Modify: `backend/app/services/adaptive_rag_service/mixin_query_route_gateway.py`
- 在保留 `route_mode / effective_source_policy / reply_contract / probe_snapshot` 合同的前提下,叠加更高层的 `execution_recipe` 输出。
- Modify: `backend/app/services/adaptive_rag_service/mixin_core_intent.py`
- 让 query semantics 可消费统一上下文和 handoff hint,而不是只看问题文本。
- Modify: `backend/app/services/adaptive_rag_service/mixin_answer_mainline.py`
- 按 `execution_recipe` 接棒 internal / external / plan-offer,而不是三路各自隐式争抢。
- Modify: `backend/app/services/adaptive_rag_service/mixin_followup_anchor.py`
- follow-up 续接优先基于统一上下文,而不是仅靠 session state。
### Internal Fit Brief
- Create: `backend/app/services/adaptive_rag_service/mixin_project_fit_brief.py`
- 负责从项目对象、功能目录、repo 概览、当前能力缺口生成 customer-safe `fit brief`。
- Modify: `backend/app/services/adaptive_rag_service/service.py`
- 接入新的 fit-brief mixin。
- Modify: `backend/app/services/adaptive_rag_service/mixin_manifest_feature_p1.py`
- 暴露 feature catalog 命中摘要给 fit brief 使用。
- Modify: `backend/app/services/project_knowledge_graph_service/mixin_codebase_overview.py`
- 把 codebase overview 从单纯 backend Python 统计扩成可用于“当前仓库摘要”的最小基线。
- Modify: `backend/app/services/context_cache_service/mixin_user_snapshot.py`
- 只补 customer-safe 的当前项目参与状态,不扩成 portfolio 大杂烩。
### External Recommendation
- Modify: `backend/app/services/external_query_service/schemas.py`
- 给 external plan / execution 增加 `fit_brief_summary / recommendation_goal / project_scope_hint` 等检索约束字段。
- Modify: `backend/app/services/external_query_service/planner.py`
- 让 planner 可以吃 `fit brief`,生成更贴项目的 query plan。
- Modify: `backend/app/services/external_query_service/gateway.py`
- 保留 adapter/verifier 主链,同时把 `fit_brief` 作为 planner meta 一部分审计。
- Modify: `backend/app/services/external_query_service/presenter.py`
- 明确拒绝消费 internal evidence;只消费 external packet,但支持输出“适配判断”模板位。
- Modify: `backend/app/services/adaptive_rag_service/mixin_answer_payload.py`
- 把 `fit brief` 和 `external evidence` 拆成不同 payload 区块,而不是混成一段答案。
### Plan Handoff And UX
- Modify: `backend/app/services/adaptive_rag_service/mixin_answer_payload.py`
- 在回答 payload 里增加 `recommended_next_step / can_enter_plan / handoff_mode`。
- Modify: `backend/app/services/integrations/feishu_signal_pipeline/mixin_process_message_default.py`
- 飞书默认按 `A` 策略:先答,再给一个明确的 plan handoff 入口。
- Modify: `backend/app/services/integrations/feishu_signal_pipeline/mixin_context_prefill_a.py`
- 把当前项目 / 当前线程 / 当前 repo 级别提示收进统一上下文,不在飞书旁路拼接脏 context。
- Modify: `frontend/src/components/ai-steward/FloatingQAChat.tsx`
- 展示 unified context、fit brief、外部推荐结果和“进入 plan”下一步。
- Modify: `frontend/src/components/ai-steward/QAConversationStatusBar.tsx`
- 展示当前 operating context,而不是只展示“内部还是外部”。
- Modify: `frontend/src/components/ai-steward/QAResponseMetaCard.tsx`
- 新增“项目画像 / 外部证据 / 下一步”三段式元信息卡。
### Tests
- Create: `backend/tests/test_project_operating_context_contract.py`
- Modify: `backend/tests/test_ai_pmo_ingress_mainline.py`
- Modify: `backend/tests/test_rag_route_contract.py`
- Modify: `backend/tests/test_adaptive_rag_mainline_contract.py`
- Modify: `backend/tests/test_adaptive_rag_external_followup_contract.py`
- Modify: `backend/tests/test_external_query_planner.py`
- Modify: `backend/tests/test_internal_query_context_packet.py`
- Modify: `backend/tests/test_ai_pmo_qa_http_mainline_e2e.py`
- Modify: `backend/tests/test_feishu_pipeline_queue_contract.py`
- Create: `frontend/src/components/ai-steward/QAResponseMetaCard.test.tsx`
- Modify: `frontend/src/components/ai-steward/FloatingQAChat.test.tsx`
### Docs
- Modify: `CLAUDE.md`
- Modify: `docs/architecture/ARCHITECTURE_INVENTORY.md`
- Modify: `docs/architecture/CODE_REFERENCE_INDEX.md`
- Modify: `docs/architecture/RUNTIME_GUARDRAILS.md`
- Create: `docs/superpowers/specs/2026-04-17-feishu-problem-inventory.md`
---
### Task 0: Build The Feishu Problem Inventory Before Any Runtime Changes
**Files:**
- Create: `docs/superpowers/specs/2026-04-17-feishu-problem-inventory.md`
- Modify: `backend/tests/test_feishu_pipeline_queue_contract.py`
- Modify: `backend/tests/test_ai_pmo_qa_http_mainline_e2e.py`
- Modify: `backend/tests/test_adaptive_rag_external_followup_contract.py`
- Modify: `docs/architecture/RUNTIME_GUARDRAILS.md`
- [ ] **Step 1: Collect the current Feishu failure classes from existing code, tests, and guardrails**
The inventory must explicitly classify at least these buckets:
```text
1. reply/thread 锚点丢失或绑错
2. internal / external / planmode 误分流
3. 外部问答后续一句话错误掉进 internal 或 plan
4. “适不适合 / 推荐什么 / 怎么推进” 这类复合问题被拆错
5. 同群同人 scope 串线
6. 旧 active context 劫持新问题
7. 明明该继续 plan,却掉回 query.answer
8. 明明只是问结论,却被直接拉进 plan
9. 卡片动作和纯文本回复行为不一致
10. Web 与 Feishu 对同一句话给出不同链路
```
- [ ] **Step 2: Write 10-15 gold conversation cases in the inventory doc**
Each case must include:
```markdown
- 场景名
- 初始上下文
- 用户原话
- 当前错误行为
- 目标行为
- 应命中的 lane / handoff
- 验证方式
```
At least one case must cover each of:
- 纯内部系统问题
- 纯外部事实问题
- “先判断,再给 plan 入口”
- 显式强锚点直接进 plan
- reply/thread 明确续问
- 同群同人但切新话题
- [ ] **Step 3: Convert the highest-value cases into failing regression tests**
Required minimum:
- 2 cases in `test_feishu_pipeline_queue_contract.py`
- 2 cases in `test_ai_pmo_qa_http_mainline_e2e.py`
- 1 case in `test_adaptive_rag_external_followup_contract.py`
These tests should fail under the current runtime if the problem still exists, or be marked as expected coverage gaps if the exact behavior is not yet implemented.
- [ ] **Step 4: Record explicit acceptance criteria in `RUNTIME_GUARDRAILS.md`**
Add a short section saying the unified context / three-lane handoff work must be accepted against the Feishu problem inventory, not just architecture conformance.
Also add an explicit line:
```text
This work is not allowed to introduce new keyword routing, regex routing, or hard-coded semantic split helpers.
```
- [ ] **Step 5: Run the inventory-aligned tests and capture the baseline**
Run:
```bash
cd /Users/yuuuu/Documents/AIProjectManger && PYTHONPATH=backend backend/venv/bin/python -m pytest \
backend/tests/test_feishu_pipeline_queue_contract.py \
backend/tests/test_ai_pmo_qa_http_mainline_e2e.py \
backend/tests/test_adaptive_rag_external_followup_contract.py -v
```
Expected:
- either explicit FAIL on known bad cases
- or explicit PASS with coverage notes in the inventory doc
This baseline becomes the “before” snapshot for the rest of the plan.
### Task 1: Lock The Unified Project Operating Context Contract
**Files:**
- Create: `backend/tests/test_project_operating_context_contract.py`
- Modify: `backend/tests/test_ai_pmo_ingress_mainline.py`
- Modify: `backend/tests/test_rag_route_contract.py`
- [ ] **Step 1: Write the failing contract tests for the new context packet**
Add tests that lock a minimal packet such as:
```python
context = ProjectOperatingContext(
current_project_id=17,
current_repo_scope="pmflow-main",
page_anchor={"project_id": 17, "origin": "pathname"},
thread_anchor={"reply_to_message_id": "om_xxx"},
fit_scope="current_project_and_repo",
decision_strategy="answer_then_offer_plan",
)
assert context.current_project_id == 17
assert context.current_repo_scope == "pmflow-main"
assert context.decision_strategy == "answer_then_offer_plan"
```
- [ ] **Step 2: Add ingress tests that require the packet to be attached before query execution**
Lock that `AIPMOKernelService.process_ingress()` or its contract builder must produce:
```python
assert ingress_options["project_operating_context"]["current_project_id"] == 17
assert ingress_options["project_operating_context"]["fit_scope"] == "current_project_and_repo"
```
- [ ] **Step 3: Lock route-contract tests so `/rag/ask` receives the unified packet**
The HTTP-facing RAG contract should prove:
```python
assert payload["project_operating_context"]["current_project_id"] == 17
assert payload["project_operating_context"]["decision_strategy"] == "answer_then_offer_plan"
```
- [ ] **Step 4: Run focused tests and confirm they fail before implementation**
Run:
```bash
cd /Users/yuuuu/Documents/AIProjectManger && PYTHONPATH=backend backend/venv/bin/python -m pytest \
backend/tests/test_project_operating_context_contract.py \
backend/tests/test_ai_pmo_ingress_mainline.py \
backend/tests/test_rag_route_contract.py -v
```
Expected: FAIL because the packet/schema/builder do not exist yet.
### Task 2: Build And Persist Project Operating Context At Ingress
**Files:**
- Create: `backend/app/schemas/project_operating_context.py`
- Create: `backend/app/services/ai_pmo/project_operating_context_builder.py`
- Modify: `backend/app/services/ai_pmo/ai_pmo_kernel_ingress_flow/mixin_entry.py`
- Modify: `backend/app/services/ai_pmo/ai_pmo_kernel_world_model_core/mixin_builder.py`
- Modify: `backend/app/services/ai_pmo/ai_pmo_kernel/mixins/world_model/ai_pmo_kernel_mixins_world_model_session.py`
- Test: `backend/tests/test_project_operating_context_contract.py`
- Test: `backend/tests/test_ai_pmo_ingress_mainline.py`
- [ ] **Step 1: Add the minimal Pydantic schema**
Create a focused schema module:
```python
class ProjectOperatingContext(BaseModel):
context_version: str = "v1"
current_project_id: int | None = None
current_repo_scope: str | None = None
page_anchor: dict[str, Any] = Field(default_factory=dict)
thread_anchor: dict[str, Any] = Field(default_factory=dict)
fit_scope: Literal["current_project_and_repo"] = "current_project_and_repo"
decision_strategy: Literal["answer_then_offer_plan"] = "answer_then_offer_plan"
```
- [ ] **Step 2: Implement a small builder that composes current project, repo, and anchors**
The builder should only consume stable inputs already present in runtime:
- `resolved_project_id`
- current repo / codebase scope
- explicit page anchor
- explicit thread anchor
Do not infer portfolio scope or global user history here.
- [ ] **Step 3: Attach the packet in kernel ingress before any query route or plan handoff logic**
Make `mixin_entry.py` the single place that builds and stores the packet in `ingress_options`.
- [ ] **Step 4: Persist only the minimal session snapshot**
Store only:
- `current_project_id`
- `current_repo_scope`
- `last_page_anchor`
- `last_thread_anchor`
Do not persist a giant free-form context blob.
- [ ] **Step 5: Re-run focused tests and confirm they pass**
Run:
```bash
cd /Users/yuuuu/Documents/AIProjectManger && PYTHONPATH=backend backend/venv/bin/python -m pytest \
backend/tests/test_project_operating_context_contract.py \
backend/tests/test_ai_pmo_ingress_mainline.py -v
```
Expected: PASS
- [ ] **Step 6: Commit**
```bash
git add backend/app/schemas/project_operating_context.py \
backend/app/services/ai_pmo/project_operating_context_builder.py \
backend/app/services/ai_pmo/ai_pmo_kernel_ingress_flow/mixin_entry.py \
backend/app/services/ai_pmo/ai_pmo_kernel_world_model_core/mixin_builder.py \
backend/app/services/ai_pmo/ai_pmo_kernel/mixins/world_model/ai_pmo_kernel_mixins_world_model_session.py \
backend/tests/test_project_operating_context_contract.py \
backend/tests/test_ai_pmo_ingress_mainline.py
git commit -m "feat: add unified project operating context"
```
### Task 3: Replace Hard Split Mode Guessing With A Unified Execution Recipe
**Files:**
- Modify: `backend/app/services/adaptive_rag_service/mixin_query_route_gateway.py`
- Modify: `backend/app/services/adaptive_rag_service/mixin_core_intent.py`
- Modify: `backend/app/services/adaptive_rag_service/mixin_answer_mainline.py`
- Modify: `backend/app/services/adaptive_rag_service/mixin_followup_anchor.py`
- Modify: `backend/tests/test_adaptive_rag_mainline_contract.py`
- Modify: `backend/tests/test_rag_route_contract.py`
- [ ] **Step 1: Write the failing tests for `execution_recipe`**
Lock a composed decision like:
```python
assert recipe == {
"primary_lane": "external",
"fit_brief_required": True,
"external_evidence_required": True,
"plan_handoff_mode": "offer_after_answer",
}
```
Use a scenario like:
- question asks “这个方案适不适合我们项目”
- current project exists
- no explicit draft / requirement strong anchor
- [ ] **Step 2: Extend query route gateway output from `route_mode` to `execution_recipe`**
Keep `route_mode / effective_source_policy / reply_contract` for compatibility, but add a higher-level recipe:
```python
{
"primary_lane": "internal" | "external" | "plan",
"fit_brief_required": bool,
"external_evidence_required": bool,
"plan_handoff_mode": "none" | "offer_after_answer" | "explicit_plan_now",
}
```
- [ ] **Step 3: Make answer mainline follow the recipe instead of local mode guesses**
Required behavior:
- explicit plan anchor -> `explicit_plan_now`
- pure internal question -> `internal`
- pure external fact question -> `external`
- “适不适合当前项目 / 推荐什么 / 要不要接” -> `external + fit_brief + offer_after_answer`
- [ ] **Step 4: Keep follow-up rewrite anchored to the unified packet**
Follow-up should first look at:
- explicit anchor
- project operating context
- prior handoff state
Only then fall back to session subject.
- [ ] **Step 5: Run the query mainline tests**
Run:
```bash
cd /Users/yuuuu/Documents/AIProjectManger && PYTHONPATH=backend backend/venv/bin/python -m pytest \
backend/tests/test_rag_route_contract.py \
backend/tests/test_adaptive_rag_mainline_contract.py -v
```
Expected: PASS
- [ ] **Step 6: Commit**
```bash
git add backend/app/services/adaptive_rag_service/mixin_query_route_gateway.py \
backend/app/services/adaptive_rag_service/mixin_core_intent.py \
backend/app/services/adaptive_rag_service/mixin_answer_mainline.py \
backend/app/services/adaptive_rag_service/mixin_followup_anchor.py \
backend/tests/test_adaptive_rag_mainline_contract.py \
backend/tests/test_rag_route_contract.py
git commit -m "feat: add unified execution recipe for project ai"
```
### Task 4: Add Project Fit Brief Without Breaking External-Only Answer Contracts
**Files:**
- Create: `backend/app/services/adaptive_rag_service/mixin_project_fit_brief.py`
- Modify: `backend/app/services/adaptive_rag_service/service.py`
- Modify: `backend/app/services/adaptive_rag_service/mixin_manifest_feature_p1.py`
- Modify: `backend/app/services/project_knowledge_graph_service/mixin_codebase_overview.py`
- Modify: `backend/app/services/context_cache_service/mixin_user_snapshot.py`
- Modify: `backend/app/services/external_query_service/schemas.py`
- Modify: `backend/app/services/external_query_service/planner.py`
- Modify: `backend/app/services/external_query_service/gateway.py`
- Modify: `backend/app/services/external_query_service/presenter.py`
- Modify: `backend/app/services/adaptive_rag_service/mixin_answer_payload.py`
- Modify: `backend/tests/test_external_query_planner.py`
- Modify: `backend/tests/test_internal_query_context_packet.py`
- Modify: `backend/tests/test_adaptive_rag_external_followup_contract.py`
- [ ] **Step 1: Write failing tests for a customer-safe fit brief**
Lock a brief such as:
```python
brief = {
"project_summary": "当前项目是 React + FastAPI 的项目管理系统",
"repo_summary": "当前仓库同时包含 frontend/backend",
"current_capabilities": ["feature_catalog", "feishu", "adaptive_rag"],
"constraints": ["必须绑定正式业务对象", "不能做泛化聊天"],
"recommendation_focus": ["适合当前项目", "接入成本", "对现有架构影响"],
}
```
Also lock the external recommendation contract:
```python
assert payload["external_recommendation"]["retrieval_status"] in {
"evidence_found",
"verifier_blocked",
"no_relevant_results",
"retrieval_failed",
}
assert payload["external_recommendation"]["impact_status"] in {
"direct_match",
"indirect_only",
"no_direct_impact",
}
```
- [ ] **Step 2: Implement the fit-brief builder from internal project and repo signals**
Allowed inputs:
- current project summary
- feature catalog
- codebase overview
- customer-safe current user/project participation snapshot
Not allowed:
- raw internal source labels in final external answer prompt
- giant free-form repo dump
- silent fallback that collapses `verifier_blocked / no_relevant_results / retrieval_failed` into one generic “暂无外部变化”
- [ ] **Step 3: Extend external planner to consume the brief as search guidance**
The planner may use:
- `fit_brief_summary`
- `project_scope_hint`
- `recommendation_goal`
- formal project object anchors such as `signal_sources` / current capability names as query constraints
But `presenter` must still only consume verified external evidence and plan summary.
- [ ] **Step 3.5: Add object-binding and impact-tier rules before final external recommendation**
Required behavior:
- `retrieval_status` is system-owned and passed through to payload/presenter; the model must not guess it.
- if `retrieval_status != evidence_found`, force:
- `impact_status=no_direct_impact`
- empty `external_summary`
- empty `external_impact`
- empty `affected_signal_sources`
- empty `external_action`
- if evidence is real but only loosely related to the project, force `impact_status=indirect_only`
- only allow `impact_status=direct_match` when external evidence can be mapped to at least one formal current-project object, preferably a formal `signal source`
- if current project has formal `signal_sources`, the recommendation layer should prefer binding to those sources before falling back to abstract capability labels
- `external_action` must be a validation/mapping/monitoring action, never a staffing or ownership action
- [ ] **Step 3.6: Lock presenter wording for failure and indirect-only states**
Web/Feishu presentation must explicitly distinguish:
- `verifier_blocked`: searched candidates existed but did not pass grounding / verifier checks
- `no_relevant_results`: search completed but no sufficiently relevant external evidence was found
- `retrieval_failed`: external retrieval failed and no usable external evidence was produced
- `indirect_only`: evidence exists but only supports broad market/background relevance, not a direct project-object match
- [ ] **Step 4: Emit fit brief as a separate payload block**
The answer payload should look more like:
```json
{
"fit_brief": {...},
"external_recommendation": {
"retrieval_status": "evidence_found | verifier_blocked | no_relevant_results | retrieval_failed",
"impact_status": "direct_match | indirect_only | no_direct_impact",
"affected_signal_sources": [...]
},
"recommended_next_step": {...}
}
```
Do not collapse all three into one answer string.
Do not collapse retrieval state, impact state, and next-step CTA into one generic prose paragraph.
- [ ] **Step 5: Run focused fit-brief and external planner tests**
Run:
```bash
cd /Users/yuuuu/Documents/AIProjectManger && PYTHONPATH=backend backend/venv/bin/python -m pytest \
backend/tests/test_external_query_planner.py \
backend/tests/test_internal_query_context_packet.py \
backend/tests/test_adaptive_rag_external_followup_contract.py -v
```
Expected: PASS
- [ ] **Step 6: Commit**
```bash
git add backend/app/services/adaptive_rag_service/mixin_project_fit_brief.py \
backend/app/services/adaptive_rag_service/service.py \
backend/app/services/adaptive_rag_service/mixin_manifest_feature_p1.py \
backend/app/services/project_knowledge_graph_service/mixin_codebase_overview.py \
backend/app/services/context_cache_service/mixin_user_snapshot.py \
backend/app/services/external_query_service/schemas.py \
backend/app/services/external_query_service/planner.py \
backend/app/services/external_query_service/gateway.py \
backend/app/services/external_query_service/presenter.py \
backend/app/services/adaptive_rag_service/mixin_answer_payload.py \
backend/tests/test_external_query_planner.py \
backend/tests/test_internal_query_context_packet.py \
backend/tests/test_adaptive_rag_external_followup_contract.py
git commit -m "feat: add project fit brief for external recommendation"
```
### Task 5: Implement Default A Handoff In Web And Feishu
**Files:**
- Modify: `backend/app/services/adaptive_rag_service/mixin_answer_payload.py`
- Modify: `backend/app/services/ai_pmo/ai_pmo_kernel_ingress_flow/mixin_entry.py`
- Modify: `backend/app/services/integrations/feishu_signal_pipeline/mixin_process_message_default.py`
- Modify: `backend/app/services/integrations/feishu_signal_pipeline/mixin_context_prefill_a.py`
- Modify: `backend/tests/test_ai_pmo_qa_http_mainline_e2e.py`
- Modify: `backend/tests/test_feishu_pipeline_queue_contract.py`
- Modify: `frontend/src/components/ai-steward/FloatingQAChat.tsx`
- Modify: `frontend/src/components/ai-steward/QAConversationStatusBar.tsx`
- Modify: `frontend/src/components/ai-steward/QAResponseMetaCard.tsx`
- Create: `frontend/src/components/ai-steward/QAResponseMetaCard.test.tsx`
- Modify: `frontend/src/components/ai-steward/FloatingQAChat.test.tsx`
- [ ] **Step 1: Write failing end-to-end tests for the default A behavior**
Lock a case where the user asks:
```text
这个方案适不适合我们当前项目?
```
Expected response contract:
- first returns a judgment
- includes fit brief summary
- includes external evidence summary
- includes one clear “是否进入 plan” next step
- does not directly jump into requirement-plan
- if external recommendation is `verifier_blocked / no_relevant_results / retrieval_failed / indirect_only`, the UI copy must preserve that distinction instead of rendering a single generic empty-state sentence
- [ ] **Step 2: Add payload fields for handoff and next-step CTA**
Minimum fields:
```json
{
"recommended_next_step": {
"mode": "offer_plan",
"label": "基于这个结论进入计划",
"allowed": true
}
}
```
- [ ] **Step 3: Render the same structure in web and Feishu**
Web should show three regions:
- 当前项目画像
- 外部推荐与证据
- 下一步
Feishu should keep a simpler rendering:
- 先给结论
- 再给一行“如果你要,我可以基于这个结论继续帮你出计划”
Feishu 验收时不要只看内部 handoff payload;必须以“后端最终准备发给飞书的格式”为准:
- `qa_direct` 文本:看 `FeishuReceiveRoutingMixin._build_feishu_qa_direct_reply_text()`
- `requirement_plan_followup / draft_pending_confirm / pending_manual`:看 `FeishuCardActionMixin._build_action_card_from_capture_result()`
- [ ] **Step 3.5: Render explicit external state variants instead of a single empty fallback**
Web must distinguish at least:
- `evidence_found + direct_match`
- `evidence_found + indirect_only`
- `verifier_blocked`
- `no_relevant_results`
- `retrieval_failed`
Feishu may be shorter, but must still avoid collapsing the above states into one generic “暂无外部变化/暂无推荐”.
- [ ] **Step 4: Keep explicit plan anchors stronger than the default A fallback**
If the message already contains a strong plan anchor:
- skip the offer
- enter plan immediately
This prevents regression for active requirement / draft threads.
- [ ] **Step 5: Run web and Feishu integration tests**
Run:
```bash
cd /Users/yuuuu/Documents/AIProjectManger && PYTHONPATH=backend backend/venv/bin/python -m pytest \
backend/tests/test_ai_pmo_qa_http_mainline_e2e.py \
backend/tests/test_feishu_pipeline_queue_contract.py -v
cd /Users/yuuuu/Documents/AIProjectManger/frontend && npx vitest run \
src/components/ai-steward/FloatingQAChat.test.tsx \
src/components/ai-steward/QAResponseMetaCard.test.tsx
```
Expected: PASS
- [ ] **Step 6: Commit**
```bash
git add backend/app/services/adaptive_rag_service/mixin_answer_payload.py \
backend/app/services/ai_pmo/ai_pmo_kernel_ingress_flow/mixin_entry.py \
backend/app/services/integrations/feishu_signal_pipeline/mixin_process_message_default.py \
backend/app/services/integrations/feishu_signal_pipeline/mixin_context_prefill_a.py \
backend/tests/test_ai_pmo_qa_http_mainline_e2e.py \
backend/tests/test_feishu_pipeline_queue_contract.py \
frontend/src/components/ai-steward/FloatingQAChat.tsx \
frontend/src/components/ai-steward/QAConversationStatusBar.tsx \
frontend/src/components/ai-steward/QAResponseMetaCard.tsx \
frontend/src/components/ai-steward/QAResponseMetaCard.test.tsx \
frontend/src/components/ai-steward/FloatingQAChat.test.tsx
git commit -m "feat: add unified plan handoff for project ai answers"
```
### Task 6: Update Docs And Run Final Regression
**Files:**
- Modify: `CLAUDE.md`
- Modify: `docs/architecture/ARCHITECTURE_INVENTORY.md`
- Modify: `docs/architecture/CODE_REFERENCE_INDEX.md`
- Modify: `docs/architecture/RUNTIME_GUARDRAILS.md`
- [ ] **Step 1: Update runtime guardrails**
Document these new stable rules:
- unified `Project Operating Context`
- default scope is `current project + current repo`
- default multi-intent strategy is `answer_then_offer_plan`
- external planner may consume `fit brief`, but external presenter remains external-only
- [ ] **Step 2: Update inventory and code reference docs**
Add:
- new schema/module paths
- ingress builder path
- fit brief builder path
- handoff payload path
- external recommendation retrieval/impact state contract
- explicit object-binding preference for formal `signal_sources`
- [ ] **Step 3: Run backend compile, tests, and deployment restart**
Run:
```bash
cd /Users/yuuuu/Documents/AIProjectManger && backend/venv/bin/python -m py_compile \
backend/app/schemas/project_operating_context.py \
backend/app/services/ai_pmo/project_operating_context_builder.py \
backend/app/services/adaptive_rag_service/*.py \
backend/app/services/external_query_service/*.py
cd /Users/yuuuu/Documents/AIProjectManger && PYTHONPATH=backend backend/venv/bin/python -m pytest \
backend/tests/test_project_operating_context_contract.py \
backend/tests/test_ai_pmo_ingress_mainline.py \
backend/tests/test_rag_route_contract.py \
backend/tests/test_adaptive_rag_mainline_contract.py \
backend/tests/test_adaptive_rag_external_followup_contract.py \
backend/tests/test_external_query_planner.py \
backend/tests/test_internal_query_context_packet.py \
backend/tests/test_ai_pmo_qa_http_mainline_e2e.py \
backend/tests/test_feishu_pipeline_queue_contract.py -v
cd /Users/yuuuu/Documents/AIProjectManger/frontend && npx tsc --noEmit
cd /Users/yuuuu/Documents/AIProjectManger/frontend && npm run build
cd /Users/yuuuu/Documents/AIProjectManger && docker compose up -d --build backend
```
Expected:
- `py_compile` success
- targeted pytest all PASS
- `tsc --noEmit` success
- `npm run build` success
- backend container returns `healthy`
- [ ] **Step 3.5: Add runtime feature flags / rollback guards**
At minimum, the implementation should support disabling independently:
- unified execution recipe
- fit-brief-driven external recommendation
- default `answer_then_offer_plan` handoff rendering
The rollback path must not require reverting unrelated ingress/context changes.
- [ ] **Step 3.6: Run real-chain verification on at least three external recommendation states**
Before final sign-off, capture real examples for:
- `verifier_blocked`
- `evidence_found + indirect_only`
- `evidence_found + direct_match`
For each example, confirm:
- external payload state is correct
- presenter wording matches the state
- no generic empty fallback sentence hides the real reason
- `direct_match` only appears when a formal project object is actually bound
- [ ] **Step 4: Commit docs and verification updates**
```bash
git add CLAUDE.md \
docs/architecture/ARCHITECTURE_INVENTORY.md \
docs/architecture/CODE_REFERENCE_INDEX.md \
docs/architecture/RUNTIME_GUARDRAILS.md
git commit -m "docs: document unified project ai context and handoff"
```
## Final Verification Checklist
- `Project Operating Context` is present in ingress before any route or plan handoff
- external recommendation can consume internal fit brief without mixing internal evidence into final external answer
- external recommendation explicitly separates retrieval state from impact state
- external recommendation prefers binding to formal current-project objects, especially `signal_sources`
- `direct_match` is only used when external evidence truly maps to a formal project object
- `indirect_only` is used for broad market/background relevance instead of pretending the recommendation is project-bound
- external actions never include staffing/ownership changes or direct business-status mutation
- multi-intent recommendation questions default to “answer first, then offer plan”
- strong plan anchors still override the offer and enter plan directly
- web and Feishu both display the same handoff logic
- no new runtime keyword/regex route-takeover helpers were introduced
- implementation diff does not reintroduce hard semantic routing under another name
## Execution Notes
- Task order matters:
- Task 1 and Task 2 first, otherwise later tasks will keep smuggling context through ad-hoc fields
- Task 3 before Task 4, otherwise fit brief will bolt onto the old route split
- Task 5 only after the recipe and fit-brief payload exist
- Keep commits small and reviewable; do not batch all six tasks into one mega-commit.
- If the implementation reveals that full repo understanding needs a separate ingestion pipeline beyond current `codebase snapshot`, split that work out as a follow-up plan instead of bloating this one.
## Final Architecture Baseline
This plan must land on the following stable architecture baseline and should not drift after implementation starts.
1. PMFlow must end up with `one unified project AI ingress`, not three isolated user-facing modes.
2. The first runtime layer is always `Project Operating Context`.
Default scope is fixed to:
- current project
- current repo summary when runtime snapshot is available
- explicit thread / page / reply anchors
3. After the top-level `plan_mode` gate, non-plan recommendation / query flows should produce a single `execution_recipe`.
That recipe layers on top of the existing `route_mode / effective_source_policy / reply_contract / probe_snapshot` contract and decides:
- internal execution
- external execution
- post-answer handoff to `plan_mode` downstream capability
4. `internal` is responsible for:
- internal object facts
- project state
- project fit brief
- bounded compatibility / capability constraints
5. `external` is responsible for:
- external evidence
- grounded recommendations
- impact assessment
- explicit retrieval / verifier state
- customer-safe presentation on top of external-only evidence
6. Here `planmode` means the repo's `plan_mode` gate plus `requirement_plan / draft_pending_confirm` mainline, not a separate free-floating route name.
7. `planmode` is a downstream capability for recommendation questions, not the default first response.
Default strategy remains:
- answer first
- then offer plan handoff
8. External recommendation may consume internal `fit brief`, but the final external answer must remain external-only and single-source-policy compliant.
9. Strong anchors override the default answer-then-offer-plan strategy:
- explicit reply/thread
- explicit work item
- active requirement/draft context
10. Feishu is a first-class acceptance surface.
The work is not complete until the Feishu problem inventory and golden cases pass.
11. The implementation is forbidden from reintroducing hard semantic routing:
- no keyword warehouse
- no regex routing helpers
- no “looks like this kind of question” split logic in execution code
Target end-state in one sentence:
`Understand the current PMFlow project first, then decide how internal evidence, external evidence, and plan handoff should cooperate under one ingress.`

Some files were not shown because too many files have changed in this diff Show More