Compare commits
No commits in common. "2eaf8e9debebb15cea3869cb522d63baadd84685" and "3f94cf702c487e720c01f933d71e791a1767f603" have entirely different histories.
2eaf8e9deb
...
3f94cf702c
26
CLAUDE.md
26
CLAUDE.md
|
|
@ -33,7 +33,6 @@ AIProjectManger/
|
||||||
- 详细架构库存已迁到 [`docs/architecture/ARCHITECTURE_INVENTORY.md`](/Users/yuuuu/Documents/AIProjectManger/docs/architecture/ARCHITECTURE_INVENTORY.md)。
|
- 详细架构库存已迁到 [`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/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/` 保存任务包产物与验收截图。
|
- `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/`)
|
## 4. 后端结构(`backend/`)
|
||||||
```text
|
```text
|
||||||
|
|
@ -57,30 +56,19 @@ backend/
|
||||||
- 当前后端重点入口优先看:
|
- 当前后端重点入口优先看:
|
||||||
- `backend/app/api/` 下的 `*_api/` 子包与聚合 router
|
- `backend/app/api/` 下的 `*_api/` 子包与聚合 router
|
||||||
- `backend/app/services/ai_pmo/`(AI PMO 主链、Mounted Runtime、semantic context)
|
- `backend/app/services/ai_pmo/`(AI PMO 主链、Mounted Runtime、semantic context)
|
||||||
- `backend/app/services/ai_pmo/project_operating_context_builder.py` + `backend/app/schemas/project_operating_context.py`(统一 `ProjectOperatingContext / ProjectFitBrief / ExecutionRecipe` 合同;`/rag/ask`、`/ai-pmo/process-text`、kernel ingress 会先补当前项目/仓库/线程锚点,再给 Query Route Gateway、fit brief builder 与 answer payload 消费)
|
- `backend/app/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/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/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 回答生成。当前已额外支持“推荐外部信号源”问法: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/external_query_service/`(external `query.answer` 主链:`External Query Planner -> External Query Gateway -> External Provider Registry -> MCP 优先 -> bounded fallback live search -> verifier -> ExternalContextPacket -> ExternalAnswerPresenter`;其中 `schemas.py` 负责结构化 plan/evidence/context packet,`presenter.py` 负责 external-only 回答生成)
|
||||||
- `backend/app/services/mcp_config/`(MCP 模板接入中心:catalog、bundle 持久化、后端直连 runtime probe / Microsoft Learn 检索桥接、Local Bridge 接入说明与本机确认状态)
|
- `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/services/semantic_fact_service/`(semantic facts 读模型、八类业务对象统一抽取/失活、ORM 提交钩子入队、治理看板查询)
|
||||||
- `backend/app/api/semantic_facts_api/`(semantic facts 治理后台接口:overview / objects / detail / reextract / backfill)
|
- `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/api/mcp_api/`(MCP 模板接入中心后台接口:catalog / servers / validate / confirm-client-apply)
|
||||||
- `backend/app/services/llm_service/`(显式 `routing_meta` 协议)
|
- `backend/app/services/llm_service/`(显式 `routing_meta` 协议)
|
||||||
- `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/integrations/feishu_integration_service/`(飞书入站与卡片回调)
|
||||||
- `backend/app/services/signal/`(信号接入、编排、planner、repair)
|
- `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/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 命中与异步抽取审计。
|
- 本轮新增的语义事实读模型为 `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、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 不再靠硬编码词表”。
|
- 本轮新增的外部查询 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 不再靠硬编码词表”。
|
||||||
- 详细目录迁移、兼容壳清理与新增模块清单见库存文档。
|
- 详细目录迁移、兼容壳清理与新增模块清单见库存文档。
|
||||||
|
|
||||||
## 5. 前端结构(`frontend/`)
|
## 5. 前端结构(`frontend/`)
|
||||||
|
|
@ -104,7 +92,6 @@ frontend/
|
||||||
- `frontend/src/api/`(统一 API 封装)
|
- `frontend/src/api/`(统一 API 封装)
|
||||||
- `frontend/src/pages/inbox/`(消息台)
|
- `frontend/src/pages/inbox/`(消息台)
|
||||||
- `frontend/src/pages/ai-steward/`(AI 管家)
|
- `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/ai-config/mcp-config/`(AI Config -> Governance Tools 下的 MCP 模板接入中心)
|
||||||
- `frontend/src/pages/query-route-governance/`(AI Config -> Governance Tools 下的 route/context 治理页)
|
- `frontend/src/pages/query-route-governance/`(AI Config -> Governance Tools 下的 route/context 治理页)
|
||||||
- `frontend/src/pages/semantic-facts-board/`(AI Config -> Governance Tools 下的 semantic facts 治理页)
|
- `frontend/src/pages/semantic-facts-board/`(AI Config -> Governance Tools 下的 semantic facts 治理页)
|
||||||
|
|
@ -227,15 +214,14 @@ uvicorn app.main:app --reload --host 0.0.0.0 --port 8000
|
||||||
## 12. 技术债务清单
|
## 12. 技术债务清单
|
||||||
- [x] `mixin_auto.py` `_safe_int` NameError:已通过显式导入 `_safe_int` + 回归测试锁定,避免后续共享导出调整导致运行时未定义
|
- [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] 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] 飞书入口层耦合已收敛:飞书真实入口已拆为三段职责链(预处理 `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` 已升级为一等 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] `task.create` 已接入消息级幂等账本:Feishu `source_message_id/feishu_message_id` 作为 `task.create` 账本 key,重复确认只回放同一结果,不再重复落库;回归由 `test_feishu_pipeline_queue_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] `signal_source.onboard` 已接入消息级幂等账本:TypedExecutor 以 `source_system + source_message_id` 作为 onboarding ledger key,重复提交只回放同一结果,不再重复跑信号源接入编排;回归由 `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] 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] 执行层已形成统一执行账本与参数来源约束: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] QA/RAG 路由契约双轨已收敛:`/ai-steward/rag/ask` 兼容入口已移除,问答统一收敛到 `POST /rag/ask`;参数构造与 tenant 解析收口在 `backend/app/api/rag_ask_contract.py`,前端 `FloatingQAChat` 与 Context Cache API 目录均已对齐,`backend/tests/test_rag_route_contract.py` 与 `backend/tests/test_context_cache_profile_catalog.py` 已锁定回归
|
||||||
- [x] 三模式交互上下文基线已显性化:`/rag/ask`、QA session detail 与 session state replay 现统一透出 `route_decision / clarification_code / clarification_candidates / answer_mode / grounding_status / evidence_pack / project_operating_context / execution_recipe / fit_brief / external_recommendation / 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] 三模式交互上下文基线已显性化:`/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] AI 管家浮窗交互已与 Grounding Runtime 对齐:回答区已切到 markdown 渲染,顾问标签升级为显式交互按钮;`consultant_mode_preference` 只用于 `/rag/ask` 内部重答,`允许执行` 明确交接到 `/ai-pmo/process-text`,避免 query/execute 边界回退到旧的一锅煮
|
- [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] 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` 做仓库级守门
|
- [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` 做仓库级守门
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,6 @@ from .helpers_inbox_payload import (
|
||||||
_serialize_work_item_summary,
|
_serialize_work_item_summary,
|
||||||
)
|
)
|
||||||
from .schemas import PMOProcessTextRequest
|
from .schemas import PMOProcessTextRequest
|
||||||
from ...services.ai_pmo.project_operating_context_builder import ProjectOperatingContextBuilder
|
|
||||||
|
|
||||||
@router.post("/process-text")
|
@router.post("/process-text")
|
||||||
async def process_text(
|
async def process_text(
|
||||||
|
|
@ -19,12 +18,6 @@ async def process_text(
|
||||||
tenant_id: int = Depends(get_tenant_id),
|
tenant_id: int = Depends(get_tenant_id),
|
||||||
):
|
):
|
||||||
service = AIPMOKernelService(db, tenant_id=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(
|
result = await service.process_ingress(
|
||||||
source_system="pmflow_manual",
|
source_system="pmflow_manual",
|
||||||
source_message_id=request.source_message_id,
|
source_message_id=request.source_message_id,
|
||||||
|
|
@ -35,9 +28,6 @@ async def process_text(
|
||||||
captured_at=None,
|
captured_at=None,
|
||||||
chat_type="manual",
|
chat_type="manual",
|
||||||
project_hint_id=request.project_id,
|
project_hint_id=request.project_id,
|
||||||
ingress_options={
|
|
||||||
"project_operating_context": project_operating_context.model_dump(),
|
|
||||||
},
|
|
||||||
)
|
)
|
||||||
if not result.get("handled"):
|
if not result.get("handled"):
|
||||||
raise HTTPException(status_code=400, detail=str(result.get("reason") or "AI PMO kernel 未处理该输入"))
|
raise HTTPException(status_code=400, detail=str(result.get("reason") or "AI PMO kernel 未处理该输入"))
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,6 @@ from uuid import uuid4
|
||||||
|
|
||||||
from ..models import User
|
from ..models import User
|
||||||
from ..schemas.rag import RAGAskRequest
|
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:
|
def resolve_effective_tenant_id(current_user: User, fallback_tenant_id: int) -> int:
|
||||||
|
|
@ -75,10 +74,6 @@ 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]:
|
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)
|
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 {
|
return {
|
||||||
"source_system": str(request.asked_via or "pmflow").strip() or "pmflow",
|
"source_system": str(request.asked_via or "pmflow").strip() or "pmflow",
|
||||||
"source_message_id": f"rag-ask-{uuid4().hex}",
|
"source_message_id": f"rag-ask-{uuid4().hex}",
|
||||||
|
|
@ -91,7 +86,6 @@ def build_kernel_ingress_kwargs(request: RAGAskRequest, current_user: User) -> d
|
||||||
"project_hint_id": request.project_id,
|
"project_hint_id": request.project_id,
|
||||||
"ingress_options": {
|
"ingress_options": {
|
||||||
"rag_request_context": rag_request_context,
|
"rag_request_context": rag_request_context,
|
||||||
"project_operating_context": project_operating_context.model_dump(),
|
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -15,8 +15,6 @@ from .ai_prompts_catalog import (
|
||||||
PROMPT_CODE_AUDIT,
|
PROMPT_CODE_AUDIT,
|
||||||
PROMPT_CONTEXT_INSIGHTS,
|
PROMPT_CONTEXT_INSIGHTS,
|
||||||
PROMPT_FEISHU_HUMANIZED_REPLY,
|
PROMPT_FEISHU_HUMANIZED_REPLY,
|
||||||
PROMPT_FEISHU_QA_NATURAL_REPLY,
|
|
||||||
PROMPT_FEISHU_QA_NATURAL_REPLY_REWRITE,
|
|
||||||
PROMPT_FEISHU_PROJECT_SIGNAL_CLASSIFICATION,
|
PROMPT_FEISHU_PROJECT_SIGNAL_CLASSIFICATION,
|
||||||
PROMPT_FEISHU_LEVEL_CLASSIFICATION,
|
PROMPT_FEISHU_LEVEL_CLASSIFICATION,
|
||||||
PROMPT_FEISHU_SIGNAL_EXTRACTION,
|
PROMPT_FEISHU_SIGNAL_EXTRACTION,
|
||||||
|
|
@ -56,8 +54,6 @@ from .ai_prompts_catalog import (
|
||||||
PROMPT_AI_PMO_REQUIREMENT_PLAN_QA,
|
PROMPT_AI_PMO_REQUIREMENT_PLAN_QA,
|
||||||
PROMPT_EXTERNAL_COMPARE_WITH_SOURCES_QA,
|
PROMPT_EXTERNAL_COMPARE_WITH_SOURCES_QA,
|
||||||
PROMPT_EXTERNAL_DIRECT_FACT_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_SUMMARY_WITH_SOURCES_QA,
|
||||||
PROMPT_EXTERNAL_TREND_QA,
|
PROMPT_EXTERNAL_TREND_QA,
|
||||||
PROMPT_INDUSTRY_TREND_QA,
|
PROMPT_INDUSTRY_TREND_QA,
|
||||||
|
|
@ -102,24 +98,7 @@ PMFLOW_SEMANTIC_CONTEXT_DECISION_PROMPT = """
|
||||||
- 如果 packet.ingress.candidate_kind == "active_context_followup":
|
- 如果 packet.ingress.candidate_kind == "active_context_followup":
|
||||||
- 这是“当前会话已经有 active requirement / active draft 时”的上下文优先决策,不是在做通用 intent 分类。
|
- 这是“当前会话已经有 active requirement / active draft 时”的上下文优先决策,不是在做通用 intent 分类。
|
||||||
- 必须先基于当前已绑定的对象来判断动作,不要把这句消息先当成全局 query 或通用新意图。
|
- 必须先基于当前已绑定的对象来判断动作,不要把这句消息先当成全局 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。
|
- 对于只补充当前讨论模块的短追问,默认优先判断为 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":
|
- 如果 active_context_kind == "draft_pending_confirm":
|
||||||
- 用户明确表示确认当前草稿时,输出 followup_scope="current_draft" 且 followup_action="confirm"。
|
- 用户明确表示确认当前草稿时,输出 followup_scope="current_draft" 且 followup_action="confirm"。
|
||||||
- 用户明确表示重写、重出一版、按这版重来、基于当前事实再出一版时,输出 followup_scope="current_draft" 且 followup_action="regenerate"。
|
- 用户明确表示重写、重出一版、按这版重来、基于当前事实再出一版时,输出 followup_scope="current_draft" 且 followup_action="regenerate"。
|
||||||
|
|
@ -137,17 +116,8 @@ PMFLOW_SEMANTIC_CONTEXT_DECISION_PROMPT = """
|
||||||
- 这是“当前消息还没有绑定到明确对象时”的冷启动路由决策。
|
- 这是“当前消息还没有绑定到明确对象时”的冷启动路由决策。
|
||||||
- 你的职责是基于 packet.latest_user_message、packet.topic.metadata、packet.thread、packet.ingress.thread_context 决定应该进入哪些 workflow。
|
- 你的职责是基于 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。
|
- 如果消息本质上是在咨询、分析、聊需求、收敛范围、问建议,而不是立即执行写动作,应输出 query_flow。
|
||||||
- 如果 metadata.active_requirement_plan_present == true,且用户明确是在说“退出需求模式/先暂停这条需求/先不继续这条需求”,scene_metadata.message_intent 应输出 "requirement_mode_exit",workflow 仍然保持 query_flow.answer,不要伪装成 requirement_plan。
|
|
||||||
- 如果消息明确表达了新建项目、创建任务、接入信号源、创建项目信号等独立动作,才输出对应 workflow。
|
- 如果消息明确表达了新建项目、创建任务、接入信号源、创建项目信号等独立动作,才输出对应 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,但只有当消息同时表达了多个明确动作时才这样做。
|
- 允许多 workflow,但只有当消息同时表达了多个明确动作时才这样做。
|
||||||
- scene_metadata 只允许包含:message_intent、review_framework、consultant_mode_preference、force_thinking_mode、advise_subtype、topic_switch。
|
- 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。
|
- workflow_plans 里的 workflow_type 只允许:query_flow、new_project_flow、project_flow、task_flow、signal_source_flow、project_signal_flow、followup_flow。
|
||||||
|
|
@ -179,69 +149,9 @@ JSON schema:
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"scene_metadata": {},
|
"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",
|
"assistant_reply": "string",
|
||||||
"requires_confirmation": true,
|
"requires_confirmation": true,
|
||||||
"requested_actions": ["reply.compose | workflow.bundle.compose | context.suspend_requirement_mode"],
|
"requested_actions": ["reply.compose | workflow.bundle.compose"],
|
||||||
"confidence": 0.0,
|
"confidence": 0.0,
|
||||||
"uncertainties": []
|
"uncertainties": []
|
||||||
}
|
}
|
||||||
|
|
@ -264,10 +174,10 @@ PROMPT_RAG_QUERY_SEMANTICS = """
|
||||||
- source_policy(internal / external / uncertain)
|
- source_policy(internal / external / uncertain)
|
||||||
- effective_source_policy(internal / external / uncertain)
|
- effective_source_policy(internal / external / uncertain)
|
||||||
- retrieval_domains(db / graph / doc / feishu / pmflow / url)
|
- retrieval_domains(db / graph / doc / feishu / pmflow / url)
|
||||||
|
- subject_focus(people / project / task / signal / charter / workload / feature / unknown)
|
||||||
- scope_hint(none / global / project)
|
- scope_hint(none / global / project)
|
||||||
- feature_focus(none / purpose / entry / catalog)
|
- feature_focus(none / purpose / entry / catalog)
|
||||||
- query_profile(needs_private_context / needs_freshness / confidence)
|
- query_profile(needs_private_context / needs_freshness / referenced_objects)
|
||||||
- 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 的内部字段名给用户。
|
- 不要复述 workflow bundle 的内部字段名给用户。
|
||||||
|
|
@ -275,36 +185,20 @@ PROMPT_RAG_QUERY_SEMANTICS = """
|
||||||
- query_type 只用于 Adaptive RAG 的检索与快路径规划,不代表最终回复场景。
|
- query_type 只用于 Adaptive RAG 的检索与快路径规划,不代表最终回复场景。
|
||||||
- query_type 取值只能是:existence、count、list、status、explain、diagnose、compare、unknown。
|
- query_type 取值只能是:existence、count、list、status、explain、diagnose、compare、unknown。
|
||||||
- retrieval_domains 只能从:db、graph、doc、feishu、pmflow、url 中选择。
|
- retrieval_domains 只能从:db、graph、doc、feishu、pmflow、url 中选择。
|
||||||
|
- subject_focus 只能是:people、project、task、signal、charter、workload、feature、unknown。
|
||||||
- scope_hint 只能是:none、global、project。
|
- scope_hint 只能是:none、global、project。
|
||||||
- feature_focus 只能是:none、purpose、entry、catalog。
|
- feature_focus 只能是:none、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。
|
- 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 中选择。
|
||||||
- business_frame.target_scope 只能是:current_project、explicit_project、thread_context、global。
|
- 如果问题是在问人员、成员、负责人清单或人数,subject_focus 应为 people。
|
||||||
- business_frame.resource_role 只能是:signal_source、external_input、project_object、task、signal、config、people、feature、doc、none。
|
- 如果问题是在问功能做什么、入口在哪、模块如何进入,subject_focus 应为 feature,并结合 feature_focus 标明是 purpose / entry / catalog。
|
||||||
- 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=["开票系统"]`。
|
- 如果问题是在问“有没有 / 是否存在 / 系统里有无”这类内部对象存在性,query_type 应为 existence;不要因为对象名里出现“系统 / 平台 / 助手”就改判成 explain。像 `帮我看看系统里面有没有开票系统。`,应输出 `query_type=existence`,并把 `object_mentions=["开票系统"]`。
|
||||||
- 如果问题是在问系统内部项目、任务、信号、配置、文档等私有对象,且回答必须依赖 PMFlow 内部数据,应输出 `source_policy="internal"` 与 `effective_source_policy="internal"`。
|
- 如果问题是在问系统内部项目、任务、信号、配置、文档等私有对象,且回答必须依赖 PMFlow 内部数据,应输出 `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`。
|
这类问句是在问 PMFlow 内部项目对象,应输出 `query_type=existence`、`subject_focus=project`、`object_mentions=["采购经理每日实时库存上报服务"]`、`source_policy="internal"`、`effective_source_policy="internal"`。
|
||||||
- 外部公开事实示例:`当前 codex 最新的模型用的是什么?`
|
- 外部公开事实示例:`当前 codex 最新的模型用的是什么?`
|
||||||
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`;不要因为出现“模型”二字就改判成系统内部配置问题。
|
query_profile 应体现 `needs_freshness=true`、`referenced_objects=["public_model"]`,retrieval_domains 应优先为 ["url"],并输出 `source_policy="external"`、`effective_source_policy="external"`;不要因为出现“模型”二字就改判成系统内部配置问题。
|
||||||
- 内部运行时配置示例:`我们系统当前接的是哪个 codex 模型配置?`
|
- 内部运行时配置示例:`我们系统当前接的是哪个 codex 模型配置?`
|
||||||
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`;这是在问系统配置而不是公开外部事实。
|
query_profile 应体现 `needs_private_context=true`、`referenced_objects=["system_config","model_routing"]`,retrieval_domains 应优先为 ["db","pmflow"],并输出 `source_policy="internal"`、`effective_source_policy="internal"`;这是在问系统配置而不是公开外部事实。
|
||||||
- 内部 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"`。
|
- 如果无法可靠判断 internal 还是 external,才输出 `source_policy="uncertain"` 与 `effective_source_policy="uncertain"`。
|
||||||
- 若证据不足,query_type 输出 unknown,retrieval_domains 选择保守但完整的查询范围。
|
- 若证据不足,query_type 输出 unknown,retrieval_domains 选择保守但完整的查询范围。
|
||||||
- 输出必须是一个 JSON 对象,不要输出 Markdown。
|
- 输出必须是一个 JSON 对象,不要输出 Markdown。
|
||||||
|
|
@ -319,26 +213,15 @@ JSON schema:
|
||||||
"source_policy": "internal | external | uncertain",
|
"source_policy": "internal | external | uncertain",
|
||||||
"effective_source_policy": "internal | external | uncertain",
|
"effective_source_policy": "internal | external | uncertain",
|
||||||
"retrieval_domains": ["db | graph | doc | feishu | pmflow | url"],
|
"retrieval_domains": ["db | graph | doc | feishu | pmflow | url"],
|
||||||
|
"subject_focus": "people | project | task | signal | charter | workload | feature | unknown",
|
||||||
"scope_hint": "none | global | project",
|
"scope_hint": "none | global | project",
|
||||||
"feature_focus": "none | purpose | entry | catalog",
|
"feature_focus": "none | purpose | entry | catalog",
|
||||||
"query_profile": {{
|
"query_profile": {{
|
||||||
"needs_private_context": false,
|
"needs_private_context": false,
|
||||||
"needs_freshness": false,
|
"needs_freshness": false,
|
||||||
|
"referenced_objects": ["unknown"],
|
||||||
"confidence": 0.0
|
"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
|
"confidence": 0.0
|
||||||
}}
|
}}
|
||||||
|
|
||||||
|
|
@ -392,39 +275,6 @@ message:
|
||||||
{message}
|
{message}
|
||||||
""".strip()
|
""".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 = """
|
PROMPT_INTERNAL_TARGET_RESOLUTION = """
|
||||||
你是 PMFlow 内部查询链路的 Target Resolver rerank 层。
|
你是 PMFlow 内部查询链路的 Target Resolver rerank 层。
|
||||||
|
|
||||||
|
|
@ -473,9 +323,6 @@ PROMPT_EXTERNAL_QUERY_PLAN = """
|
||||||
- `answer_contract` 只能是:direct_fact、summary_with_sources、compare_with_sources、clarify。
|
- `answer_contract` 只能是:direct_fact、summary_with_sources、compare_with_sources、clarify。
|
||||||
- `search_queries` 必须是简短、可直接执行的查询短语。
|
- `search_queries` 必须是简短、可直接执行的查询短语。
|
||||||
- `preferred_domains` 只放域名,不要带协议头。
|
- `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`。
|
- 若用户问题明显要求最新、当前、今天、最近等外部信息,`freshness_required=true`。
|
||||||
- 若问题更像对比/比较,`requires_multi_source=true` 且 `answer_contract=compare_with_sources`。
|
- 若问题更像对比/比较,`requires_multi_source=true` 且 `answer_contract=compare_with_sources`。
|
||||||
- 结构化失败时优先保守,不要编造实体。
|
- 结构化失败时优先保守,不要编造实体。
|
||||||
|
|
@ -494,12 +341,7 @@ JSON schema:
|
||||||
"search_queries": ["string"],
|
"search_queries": ["string"],
|
||||||
"result_limit": 3,
|
"result_limit": 3,
|
||||||
"requires_multi_source": false,
|
"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:
|
question:
|
||||||
|
|
@ -508,9 +350,6 @@ question:
|
||||||
query_semantics:
|
query_semantics:
|
||||||
{query_semantics}
|
{query_semantics}
|
||||||
|
|
||||||
fit_brief:
|
|
||||||
{fit_brief}
|
|
||||||
|
|
||||||
fallback_queries:
|
fallback_queries:
|
||||||
{fallback_queries}
|
{fallback_queries}
|
||||||
""".strip()
|
""".strip()
|
||||||
|
|
@ -966,8 +805,6 @@ __all__ = [
|
||||||
"PROMPT_CONTEXT_INSIGHTS",
|
"PROMPT_CONTEXT_INSIGHTS",
|
||||||
"PROMPT_FEISHU_REQUIREMENT_ANALYSIS",
|
"PROMPT_FEISHU_REQUIREMENT_ANALYSIS",
|
||||||
"PROMPT_FEISHU_HUMANIZED_REPLY",
|
"PROMPT_FEISHU_HUMANIZED_REPLY",
|
||||||
"PROMPT_FEISHU_QA_NATURAL_REPLY",
|
|
||||||
"PROMPT_FEISHU_QA_NATURAL_REPLY_REWRITE",
|
|
||||||
"PROMPT_FEISHU_PROJECT_SIGNAL_CLASSIFICATION",
|
"PROMPT_FEISHU_PROJECT_SIGNAL_CLASSIFICATION",
|
||||||
"PROMPT_FEISHU_LEVEL_CLASSIFICATION",
|
"PROMPT_FEISHU_LEVEL_CLASSIFICATION",
|
||||||
"PROMPT_FEISHU_SIGNAL_EXTRACTION",
|
"PROMPT_FEISHU_SIGNAL_EXTRACTION",
|
||||||
|
|
@ -1008,14 +845,11 @@ __all__ = [
|
||||||
"PROMPT_EXTERNAL_DIRECT_FACT_QA",
|
"PROMPT_EXTERNAL_DIRECT_FACT_QA",
|
||||||
"PROMPT_EXTERNAL_SUMMARY_WITH_SOURCES_QA",
|
"PROMPT_EXTERNAL_SUMMARY_WITH_SOURCES_QA",
|
||||||
"PROMPT_EXTERNAL_COMPARE_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_EXTERNAL_TREND_QA",
|
||||||
"PROMPT_INDUSTRY_TREND_QA",
|
"PROMPT_INDUSTRY_TREND_QA",
|
||||||
"PMFLOW_SEMANTIC_CONTEXT_DECISION_PROMPT",
|
"PMFLOW_SEMANTIC_CONTEXT_DECISION_PROMPT",
|
||||||
"PROMPT_RAG_QUERY_SEMANTICS",
|
"PROMPT_RAG_QUERY_SEMANTICS",
|
||||||
"PROMPT_RAG_FOLLOWUP_ANCHOR_SEMANTICS",
|
"PROMPT_RAG_FOLLOWUP_ANCHOR_SEMANTICS",
|
||||||
"PROMPT_RAG_PENDING_CLARIFICATION_SELECTION_SEMANTICS",
|
|
||||||
"PROMPT_INTERNAL_TARGET_RESOLUTION",
|
"PROMPT_INTERNAL_TARGET_RESOLUTION",
|
||||||
"PROMPT_EXTERNAL_QUERY_PLAN",
|
"PROMPT_EXTERNAL_QUERY_PLAN",
|
||||||
"PROMPT_RAG_ANSWER_SCENE_SEMANTICS",
|
"PROMPT_RAG_ANSWER_SCENE_SEMANTICS",
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
from __future__ import annotations
|
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_QA_NATURAL_REPLY, PROMPT_FEISHU_QA_NATURAL_REPLY_REWRITE, 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_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_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_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
|
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,8 +12,6 @@ from .part_scenes_05 import PROMPT_AI_PMO_QA, PROMPT_AI_PMO_REQUIREMENT_PLAN_QA
|
||||||
from .part_scenes_06 import (
|
from .part_scenes_06 import (
|
||||||
PROMPT_EXTERNAL_COMPARE_WITH_SOURCES_QA,
|
PROMPT_EXTERNAL_COMPARE_WITH_SOURCES_QA,
|
||||||
PROMPT_EXTERNAL_DIRECT_FACT_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_SUMMARY_WITH_SOURCES_QA,
|
||||||
PROMPT_EXTERNAL_TREND_QA,
|
PROMPT_EXTERNAL_TREND_QA,
|
||||||
PROMPT_INDUSTRY_TREND_QA,
|
PROMPT_INDUSTRY_TREND_QA,
|
||||||
|
|
@ -27,8 +25,6 @@ __all__ = [
|
||||||
"PROMPT_CODE_AUDIT",
|
"PROMPT_CODE_AUDIT",
|
||||||
"PROMPT_CONTEXT_INSIGHTS",
|
"PROMPT_CONTEXT_INSIGHTS",
|
||||||
"PROMPT_FEISHU_HUMANIZED_REPLY",
|
"PROMPT_FEISHU_HUMANIZED_REPLY",
|
||||||
"PROMPT_FEISHU_QA_NATURAL_REPLY",
|
|
||||||
"PROMPT_FEISHU_QA_NATURAL_REPLY_REWRITE",
|
|
||||||
"PROMPT_FEISHU_PROJECT_SIGNAL_CLASSIFICATION",
|
"PROMPT_FEISHU_PROJECT_SIGNAL_CLASSIFICATION",
|
||||||
"PROMPT_FEISHU_LEVEL_CLASSIFICATION",
|
"PROMPT_FEISHU_LEVEL_CLASSIFICATION",
|
||||||
"PROMPT_FEISHU_SIGNAL_EXTRACTION",
|
"PROMPT_FEISHU_SIGNAL_EXTRACTION",
|
||||||
|
|
@ -71,6 +67,4 @@ __all__ = [
|
||||||
"PROMPT_EXTERNAL_DIRECT_FACT_QA",
|
"PROMPT_EXTERNAL_DIRECT_FACT_QA",
|
||||||
"PROMPT_EXTERNAL_SUMMARY_WITH_SOURCES_QA",
|
"PROMPT_EXTERNAL_SUMMARY_WITH_SOURCES_QA",
|
||||||
"PROMPT_EXTERNAL_COMPARE_WITH_SOURCES_QA",
|
"PROMPT_EXTERNAL_COMPARE_WITH_SOURCES_QA",
|
||||||
"PROMPT_EXTERNAL_SIGNAL_SOURCE_RECOMMENDATION_PACKET",
|
|
||||||
"PROMPT_EXTERNAL_SIGNAL_SOURCE_RECOMMENDATION_QA",
|
|
||||||
]
|
]
|
||||||
|
|
|
||||||
|
|
@ -180,79 +180,6 @@ PROMPT_FEISHU_HUMANIZED_REPLY = _compose_prompt(
|
||||||
TEXT_OUTPUT_GUARDRAIL,
|
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 = """
|
PROMPT_FEISHU_PROJECT_SIGNAL_CLASSIFICATION = """
|
||||||
你是 PMFlow 的项目需求信号分流器(分类层)。请判断消息是否为有效需求,并识别是否能明确归属已有项目。
|
你是 PMFlow 的项目需求信号分流器(分类层)。请判断消息是否为有效需求,并识别是否能明确归属已有项目。
|
||||||
|
|
||||||
|
|
@ -331,8 +258,6 @@ __all__ = [
|
||||||
"PROMPT_CODE_AUDIT",
|
"PROMPT_CODE_AUDIT",
|
||||||
"PROMPT_CONTEXT_INSIGHTS",
|
"PROMPT_CONTEXT_INSIGHTS",
|
||||||
"PROMPT_FEISHU_HUMANIZED_REPLY",
|
"PROMPT_FEISHU_HUMANIZED_REPLY",
|
||||||
"PROMPT_FEISHU_QA_NATURAL_REPLY",
|
|
||||||
"PROMPT_FEISHU_QA_NATURAL_REPLY_REWRITE",
|
|
||||||
"PROMPT_FEISHU_PROJECT_SIGNAL_CLASSIFICATION",
|
"PROMPT_FEISHU_PROJECT_SIGNAL_CLASSIFICATION",
|
||||||
"PROMPT_FEISHU_LEVEL_CLASSIFICATION",
|
"PROMPT_FEISHU_LEVEL_CLASSIFICATION",
|
||||||
]
|
]
|
||||||
|
|
|
||||||
|
|
@ -61,7 +61,6 @@ PROMPT_FEISHU_PROJECT_PREFILL = """
|
||||||
{{
|
{{
|
||||||
"suggested_name": "项目名",
|
"suggested_name": "项目名",
|
||||||
"suggested_description": "1-2句描述",
|
"suggested_description": "1-2句描述",
|
||||||
"suggested_scope": "项目范围/业务边界;不明确时返回 null",
|
|
||||||
"suggested_owner": "负责人名或角色;不明确时返回 null",
|
"suggested_owner": "负责人名或角色;不明确时返回 null",
|
||||||
"suggested_timeline": "截止日期或周期;不明确时返回 null",
|
"suggested_timeline": "截止日期或周期;不明确时返回 null",
|
||||||
"confidence": 0.0,
|
"confidence": 0.0,
|
||||||
|
|
@ -71,9 +70,8 @@ PROMPT_FEISHU_PROJECT_PREFILL = """
|
||||||
约束:
|
约束:
|
||||||
1. reasoning 必须是 3 条简短依据。
|
1. reasoning 必须是 3 条简短依据。
|
||||||
2. 仅在消息明确出现负责人/角色/时间线时填写对应字段。
|
2. 仅在消息明确出现负责人/角色/时间线时填写对应字段。
|
||||||
3. 仅在消息明确看得出项目范围/业务边界时填写 suggested_scope;不明确时返回 null。
|
3. 不要为了填满字段而猜测;不明确时返回 null。
|
||||||
4. 不要为了填满字段而猜测;不明确时返回 null。
|
4. confidence < 0.6 时,suggested_owner 与 suggested_timeline 优先返回 null。
|
||||||
5. confidence < 0.6 时,suggested_owner 与 suggested_timeline 优先返回 null。
|
|
||||||
"""
|
"""
|
||||||
PROMPT_FEISHU_PROJECT_PREFILL = _compose_prompt(
|
PROMPT_FEISHU_PROJECT_PREFILL = _compose_prompt(
|
||||||
PMFLOW_PMO_STYLE_BASE,
|
PMFLOW_PMO_STYLE_BASE,
|
||||||
|
|
|
||||||
|
|
@ -126,86 +126,10 @@ PROMPT_EXTERNAL_COMPARE_WITH_SOURCES_QA = _compose_prompt(
|
||||||
TEXT_OUTPUT_GUARDRAIL,
|
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__ = [
|
__all__ = [
|
||||||
"PROMPT_EXTERNAL_TREND_QA",
|
"PROMPT_EXTERNAL_TREND_QA",
|
||||||
"PROMPT_INDUSTRY_TREND_QA",
|
"PROMPT_INDUSTRY_TREND_QA",
|
||||||
"PROMPT_EXTERNAL_DIRECT_FACT_QA",
|
"PROMPT_EXTERNAL_DIRECT_FACT_QA",
|
||||||
"PROMPT_EXTERNAL_SUMMARY_WITH_SOURCES_QA",
|
"PROMPT_EXTERNAL_SUMMARY_WITH_SOURCES_QA",
|
||||||
"PROMPT_EXTERNAL_COMPARE_WITH_SOURCES_QA",
|
"PROMPT_EXTERNAL_COMPARE_WITH_SOURCES_QA",
|
||||||
"PROMPT_EXTERNAL_SIGNAL_SOURCE_RECOMMENDATION_PACKET",
|
|
||||||
"PROMPT_EXTERNAL_SIGNAL_SOURCE_RECOMMENDATION_QA",
|
|
||||||
]
|
]
|
||||||
|
|
|
||||||
|
|
@ -1,124 +0,0 @@
|
||||||
"""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",
|
|
||||||
]
|
|
||||||
|
|
@ -1,34 +0,0 @@
|
||||||
"""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"
|
|
||||||
|
|
@ -100,11 +100,6 @@ class ActiveProjectSnapshot(TypedDict):
|
||||||
owner_id: Optional[int]
|
owner_id: Optional[int]
|
||||||
|
|
||||||
|
|
||||||
class RepoSnapshot(TypedDict):
|
|
||||||
scope: str
|
|
||||||
summary_mode: str
|
|
||||||
|
|
||||||
|
|
||||||
class DecisionContextSnapshot(TypedDict):
|
class DecisionContextSnapshot(TypedDict):
|
||||||
captured_at: str
|
captured_at: str
|
||||||
project_id: Optional[int]
|
project_id: Optional[int]
|
||||||
|
|
@ -117,8 +112,6 @@ class DecisionContextSnapshot(TypedDict):
|
||||||
class WorldModel(TypedDict):
|
class WorldModel(TypedDict):
|
||||||
sender: str
|
sender: str
|
||||||
resolved_project_id: Optional[int]
|
resolved_project_id: Optional[int]
|
||||||
repo_scope: NotRequired[str]
|
|
||||||
repo_summary: NotRequired[RepoSnapshot]
|
|
||||||
project: Optional[ProjectSnapshot]
|
project: Optional[ProjectSnapshot]
|
||||||
project_domain: Optional[ProjectDomainSnapshot]
|
project_domain: Optional[ProjectDomainSnapshot]
|
||||||
portfolio: PortfolioSummary
|
portfolio: PortfolioSummary
|
||||||
|
|
@ -149,7 +142,6 @@ __all__ = [
|
||||||
"DependencySummary",
|
"DependencySummary",
|
||||||
"RecentSignalItem",
|
"RecentSignalItem",
|
||||||
"ActiveProjectSnapshot",
|
"ActiveProjectSnapshot",
|
||||||
"RepoSnapshot",
|
|
||||||
"DecisionContextSnapshot",
|
"DecisionContextSnapshot",
|
||||||
"WorldModel",
|
"WorldModel",
|
||||||
]
|
]
|
||||||
|
|
|
||||||
|
|
@ -1,222 +0,0 @@
|
||||||
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)
|
|
||||||
|
|
@ -3,7 +3,6 @@ from __future__ import annotations
|
||||||
|
|
||||||
from .shared import *
|
from .shared import *
|
||||||
from .internal_query_context import InternalContextPacket, internal_context_block_reason
|
from .internal_query_context import InternalContextPacket, internal_context_block_reason
|
||||||
from .shared_utils import _structured_runtime_entity_type
|
|
||||||
|
|
||||||
|
|
||||||
class AdaptiveRAGAnswerMainlineMixin:
|
class AdaptiveRAGAnswerMainlineMixin:
|
||||||
|
|
@ -92,6 +91,15 @@ class AdaptiveRAGAnswerMainlineMixin:
|
||||||
semantics["session_followup_context"] = payload
|
semantics["session_followup_context"] = payload
|
||||||
return semantics
|
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(
|
def _build_internal_query_plan(
|
||||||
self,
|
self,
|
||||||
*,
|
*,
|
||||||
|
|
@ -119,11 +127,11 @@ class AdaptiveRAGAnswerMainlineMixin:
|
||||||
or semantics.get("query_type")
|
or semantics.get("query_type")
|
||||||
or QUERY_TYPE_UNKNOWN
|
or QUERY_TYPE_UNKNOWN
|
||||||
).strip().lower() or QUERY_TYPE_UNKNOWN
|
).strip().lower() or QUERY_TYPE_UNKNOWN
|
||||||
entity_type = _structured_runtime_entity_type(
|
subject_focus = self._query_subject_focus(semantics) if hasattr(self, "_query_subject_focus") else "unknown"
|
||||||
query_semantics=semantics,
|
entity_type = str(
|
||||||
operation_contract=operation,
|
target.get("target_entity_type")
|
||||||
target_resolution=target,
|
or self._internal_entity_type_for_subject_focus(subject_focus)
|
||||||
)
|
).strip().lower() or "unknown"
|
||||||
scope_hint = self._query_scope_hint(semantics) if hasattr(self, "_query_scope_hint") else "none"
|
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
|
target_ref = str(target.get("target_ref") or "").strip() or None
|
||||||
|
|
||||||
|
|
@ -525,64 +533,34 @@ class AdaptiveRAGAnswerMainlineMixin:
|
||||||
int(context_session_id) if context_session_id is not None else None
|
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)
|
state_row = await self._ensure_session_state(session_id=state_session_id)
|
||||||
pending_selection = await self._consume_pending_clarification_selection(
|
pending_selection = self._consume_pending_clarification_selection(
|
||||||
question=clean_q,
|
question=clean_q,
|
||||||
session_state=state_row,
|
session_state=state_row,
|
||||||
)
|
)
|
||||||
resolved_question = str(pending_selection.get("effective_question") or clean_q).strip() if isinstance(
|
resolved_question = str(pending_selection.get("effective_question") or clean_q).strip() if isinstance(
|
||||||
pending_selection, dict
|
pending_selection, dict
|
||||||
) else clean_q
|
) 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 = (
|
consumed_source_policy_hint = (
|
||||||
str(pending_selection.get("source_policy_hint") or "").strip().lower() or None
|
str(pending_selection.get("source_policy_hint") or "").strip().lower() or None
|
||||||
if isinstance(pending_selection, dict)
|
if isinstance(pending_selection, dict)
|
||||||
else None
|
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(
|
anchor_resolution = await self._resolve_followup_anchor(
|
||||||
question=resolved_question,
|
question=resolved_question,
|
||||||
session_id=state_session_id,
|
session_id=state_session_id,
|
||||||
context_session_id=context_session_id,
|
context_session_id=context_session_id,
|
||||||
session_state=state_row,
|
session_state=state_row,
|
||||||
selected_anchor_reply_id=selected_anchor_reply_id,
|
|
||||||
conversation_scope_key=external_conversation_key,
|
conversation_scope_key=external_conversation_key,
|
||||||
source_chat_id=source_chat_id,
|
source_chat_id=source_chat_id,
|
||||||
source_sender_open_id=source_sender_open_id,
|
source_sender_open_id=source_sender_open_id,
|
||||||
source_message_id=source_message_id,
|
source_message_id=source_message_id,
|
||||||
thread_context=thread_context,
|
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(
|
effective_q, rewrite_meta = self._rewrite_question_with_state(
|
||||||
question=resolved_question,
|
question=resolved_question,
|
||||||
conversation=conversation,
|
conversation=conversation,
|
||||||
session_state=state_row,
|
session_state=state_row,
|
||||||
anchor_resolution=anchor_resolution,
|
anchor_resolution=anchor_resolution,
|
||||||
rewrite_governance=rewrite_governance,
|
|
||||||
)
|
)
|
||||||
query_semantics = dict(precomputed_query_semantics or {}) if isinstance(precomputed_query_semantics, dict) else {}
|
query_semantics = dict(precomputed_query_semantics or {}) if isinstance(precomputed_query_semantics, dict) else {}
|
||||||
should_refresh_route_preview = bool(
|
should_refresh_route_preview = bool(
|
||||||
|
|
@ -590,7 +568,6 @@ class AdaptiveRAGAnswerMainlineMixin:
|
||||||
and (
|
and (
|
||||||
resolved_question != clean_q
|
resolved_question != clean_q
|
||||||
or consumed_source_policy_hint
|
or consumed_source_policy_hint
|
||||||
or bool(consumed_query_semantics_overrides)
|
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
if should_refresh_route_preview:
|
if should_refresh_route_preview:
|
||||||
|
|
@ -613,59 +590,12 @@ class AdaptiveRAGAnswerMainlineMixin:
|
||||||
route_decision=route_decision,
|
route_decision=route_decision,
|
||||||
),
|
),
|
||||||
"route_decision": self._normalize_query_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 = (
|
query_semantics = (
|
||||||
dict(route_preview.get("query_semantics") or {})
|
dict(route_preview.get("query_semantics") or {})
|
||||||
if isinstance(route_preview.get("query_semantics"), dict)
|
if isinstance(route_preview.get("query_semantics"), dict)
|
||||||
else {}
|
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 = None
|
||||||
operation_contract_builder = getattr(self, "_build_operation_contract", None)
|
operation_contract_builder = getattr(self, "_build_operation_contract", None)
|
||||||
if callable(operation_contract_builder):
|
if callable(operation_contract_builder):
|
||||||
|
|
@ -913,8 +843,6 @@ class AdaptiveRAGAnswerMainlineMixin:
|
||||||
user_id=asked_by,
|
user_id=asked_by,
|
||||||
)
|
)
|
||||||
prefers_external_retrieval = (
|
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()
|
str(query_semantics.get("effective_source_policy") or query_semantics.get("source_policy") or "").strip().lower()
|
||||||
== "external"
|
== "external"
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -1,15 +1,7 @@
|
||||||
"""Adaptive RAG mixin module."""
|
"""Adaptive RAG mixin module."""
|
||||||
from __future__ import annotations
|
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 .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 (
|
from .internal_query_context import (
|
||||||
internal_context_block_reason,
|
internal_context_block_reason,
|
||||||
internal_context_operation_contract,
|
internal_context_operation_contract,
|
||||||
|
|
@ -29,297 +21,6 @@ class AdaptiveRAGAnswerPayloadMixin:
|
||||||
"system": 10,
|
"system": 10,
|
||||||
}
|
}
|
||||||
_INTERNAL_SOURCE_TYPES = {"db", "graph", "doc", "pmflow", "feishu"}
|
_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
|
@staticmethod
|
||||||
def _safe_source_metadata(raw: Any) -> dict[str, Any]:
|
def _safe_source_metadata(raw: Any) -> dict[str, Any]:
|
||||||
|
|
@ -852,12 +553,10 @@ class AdaptiveRAGAnswerPayloadMixin:
|
||||||
query_type: str,
|
query_type: str,
|
||||||
reason_code: Optional[str],
|
reason_code: Optional[str],
|
||||||
missing_fields: Optional[list[str]] = None,
|
missing_fields: Optional[list[str]] = None,
|
||||||
target_resolution: Optional[dict[str, Any]] = None,
|
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
normalized_query_type = str(query_type or QUERY_TYPE_UNKNOWN).strip().lower()
|
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_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()]
|
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:
|
if normalized_reason == "no_total_count_substitution" and normalized_query_type == QUERY_TYPE_EXISTENCE:
|
||||||
answer = "当前内部查询结果与问题类型不一致,已阻止直接返回。你问的是“有没有”,请给我更明确的对象条件后我按存在性重新查询。"
|
answer = "当前内部查询结果与问题类型不一致,已阻止直接返回。你问的是“有没有”,请给我更明确的对象条件后我按存在性重新查询。"
|
||||||
elif normalized_reason == "missing_internal_evidence":
|
elif normalized_reason == "missing_internal_evidence":
|
||||||
|
|
@ -867,14 +566,7 @@ class AdaptiveRAGAnswerPayloadMixin:
|
||||||
elif normalized_reason == "missing_internal_operation_contract":
|
elif normalized_reason == "missing_internal_operation_contract":
|
||||||
answer = "当前内部查询合同不完整,已停止直接回答。请补充更明确的对象或范围后我再查。"
|
answer = "当前内部查询合同不完整,已停止直接回答。请补充更明确的对象或范围后我再查。"
|
||||||
elif normalized_reason == "target_resolution_clarify":
|
elif normalized_reason == "target_resolution_clarify":
|
||||||
candidate_summary = list((target_resolution or {}).get("candidate_summary") or [])
|
answer = "当前内部查询对象仍有歧义,已停止直接回答。请先明确你要查的项目、配置或对象后我再继续。"
|
||||||
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":
|
elif normalized_reason == "missing_internal_query_plan_contract":
|
||||||
answer = "当前内部查询计划不完整,已停止直接回答。请补充更明确的对象或范围后我再查。"
|
answer = "当前内部查询计划不完整,已停止直接回答。请补充更明确的对象或范围后我再查。"
|
||||||
else:
|
else:
|
||||||
|
|
@ -909,7 +601,6 @@ class AdaptiveRAGAnswerPayloadMixin:
|
||||||
"reason_code": normalized_reason,
|
"reason_code": normalized_reason,
|
||||||
"answer_mode": answer_mode,
|
"answer_mode": answer_mode,
|
||||||
"grounding_status": grounding_status,
|
"grounding_status": grounding_status,
|
||||||
"clarification_protocol": clarification_protocol,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
def _build_response_payload(
|
def _build_response_payload(
|
||||||
|
|
@ -955,13 +646,6 @@ class AdaptiveRAGAnswerPayloadMixin:
|
||||||
evidence_pack: Optional[dict[str, Any]] = None,
|
evidence_pack: Optional[dict[str, Any]] = None,
|
||||||
prompt_variant_id: Optional[str] = None,
|
prompt_variant_id: Optional[str] = None,
|
||||||
route_decision: Optional[dict[str, Any]] = 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]:
|
) -> dict[str, Any]:
|
||||||
evidence: list[dict[str, Any]] = []
|
evidence: list[dict[str, Any]] = []
|
||||||
for item in sources[:8]:
|
for item in sources[:8]:
|
||||||
|
|
@ -1011,39 +695,6 @@ class AdaptiveRAGAnswerPayloadMixin:
|
||||||
if hasattr(self, "_normalize_query_route_decision")
|
if hasattr(self, "_normalize_query_route_decision")
|
||||||
else {}
|
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 {
|
return {
|
||||||
"answer": str(answer or "").strip(),
|
"answer": str(answer or "").strip(),
|
||||||
"evidence": evidence,
|
"evidence": evidence,
|
||||||
|
|
@ -1091,15 +742,6 @@ class AdaptiveRAGAnswerPayloadMixin:
|
||||||
"sources_used": normalized_sources_used,
|
"sources_used": normalized_sources_used,
|
||||||
"evidence_pack": normalized_evidence_pack,
|
"evidence_pack": normalized_evidence_pack,
|
||||||
"route_decision": normalized_route_decision,
|
"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
|
@staticmethod
|
||||||
|
|
|
||||||
|
|
@ -279,20 +279,6 @@ class AdaptiveRAGAnswerPrecheckMixin:
|
||||||
original_question=clean_q,
|
original_question=clean_q,
|
||||||
previous_attempts=previous_attempts,
|
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:
|
else:
|
||||||
self._clear_pending_clarification(session_state=state_row)
|
self._clear_pending_clarification(session_state=state_row)
|
||||||
await self._update_session_state_after_turn(
|
await self._update_session_state_after_turn(
|
||||||
|
|
|
||||||
|
|
@ -2,13 +2,10 @@
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from .shared import *
|
from .shared import *
|
||||||
from .shared_utils import _env_bool
|
from .internal_query_context import update_internal_query_context_packet
|
||||||
from .internal_query_context import internal_context_target_resolution, update_internal_query_context_packet
|
|
||||||
|
|
||||||
|
|
||||||
class AdaptiveRAGAnswerRetrievalMixin:
|
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]:
|
async def _execute_external_mcp_search(self, plan) -> dict[str, Any]:
|
||||||
from ..external_query_service.schemas import ExternalAdapterResult, ExternalQueryPlan
|
from ..external_query_service.schemas import ExternalAdapterResult, ExternalQueryPlan
|
||||||
from ..mcp_config import McpConfigService
|
from ..mcp_config import McpConfigService
|
||||||
|
|
@ -76,7 +73,6 @@ class AdaptiveRAGAnswerRetrievalMixin:
|
||||||
*,
|
*,
|
||||||
question: str,
|
question: str,
|
||||||
query_semantics: Optional[dict[str, Any]] = None,
|
query_semantics: Optional[dict[str, Any]] = None,
|
||||||
fit_brief: Optional[dict[str, Any]] = None,
|
|
||||||
result_limit: int = 3,
|
result_limit: int = 3,
|
||||||
max_pages: int = 3,
|
max_pages: int = 3,
|
||||||
) -> tuple[list[RetrievedItem], dict[str, Any]]:
|
) -> tuple[list[RetrievedItem], dict[str, Any]]:
|
||||||
|
|
@ -146,7 +142,6 @@ class AdaptiveRAGAnswerRetrievalMixin:
|
||||||
).execute(
|
).execute(
|
||||||
question=question,
|
question=question,
|
||||||
query_semantics=query_semantics,
|
query_semantics=query_semantics,
|
||||||
fit_brief=fit_brief,
|
|
||||||
)
|
)
|
||||||
context_packet = execution.to_context_packet(question=question).model_dump(mode="json")
|
context_packet = execution.to_context_packet(question=question).model_dump(mode="json")
|
||||||
verifier = dict(execution.verifier or {})
|
verifier = dict(execution.verifier or {})
|
||||||
|
|
@ -327,16 +322,6 @@ class AdaptiveRAGAnswerRetrievalMixin:
|
||||||
route_decision: Optional[dict[str, Any]] = None,
|
route_decision: Optional[dict[str, Any]] = None,
|
||||||
internal_context_packet: Any = None,
|
internal_context_packet: Any = None,
|
||||||
) -> dict[str, Any]:
|
) -> 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()]
|
domains = [str(item).strip().lower() for item in retrieval_domains if str(item or "").strip()]
|
||||||
if not domains:
|
if not domains:
|
||||||
domains = ["db", "graph", "doc", "feishu", "pmflow", "url"]
|
domains = ["db", "graph", "doc", "feishu", "pmflow", "url"]
|
||||||
|
|
@ -348,41 +333,16 @@ class AdaptiveRAGAnswerRetrievalMixin:
|
||||||
if callable(normalize_route_decision)
|
if callable(normalize_route_decision)
|
||||||
else (dict(route_decision or {}) if isinstance(route_decision, dict) else {})
|
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(
|
external_route_only = str(
|
||||||
normalized_route_decision.get("effective_source_policy")
|
normalized_route_decision.get("effective_source_policy")
|
||||||
or query_semantics.get("effective_source_policy")
|
or query_semantics.get("effective_source_policy")
|
||||||
or query_semantics.get("source_policy")
|
or query_semantics.get("source_policy")
|
||||||
or ""
|
or ""
|
||||||
).strip().lower() == "external" or execution_primary_lane == "external"
|
).strip().lower() == "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:
|
if external_route_only:
|
||||||
retrieved, search_meta = await self._execute_external_query_gateway(
|
retrieved, search_meta = await self._execute_external_query_gateway(
|
||||||
question=effective_q,
|
question=effective_q,
|
||||||
query_semantics=query_semantics,
|
query_semantics=query_semantics,
|
||||||
fit_brief=fit_brief,
|
|
||||||
result_limit=3,
|
result_limit=3,
|
||||||
max_pages=3,
|
max_pages=3,
|
||||||
)
|
)
|
||||||
|
|
@ -417,16 +377,6 @@ 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(
|
tool_trace.insert(
|
||||||
0,
|
0,
|
||||||
{
|
{
|
||||||
|
|
@ -444,76 +394,6 @@ class AdaptiveRAGAnswerRetrievalMixin:
|
||||||
if external_route_only and isinstance(search_meta.get("external_context_packet"), dict)
|
if external_route_only and isinstance(search_meta.get("external_context_packet"), dict)
|
||||||
else None
|
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 = (
|
external_verifier = (
|
||||||
dict(external_context_packet.get("verifier") or {})
|
dict(external_context_packet.get("verifier") or {})
|
||||||
if isinstance(external_context_packet, dict)
|
if isinstance(external_context_packet, dict)
|
||||||
|
|
@ -555,21 +435,6 @@ class AdaptiveRAGAnswerRetrievalMixin:
|
||||||
query_semantics=query_semantics,
|
query_semantics=query_semantics,
|
||||||
external_context_packet=external_context_packet,
|
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()
|
prompt_variant_id = self._peek_answer_prompt_variant()
|
||||||
routing_snapshot = self._peek_last_answer_routing_snapshot()
|
routing_snapshot = self._peek_last_answer_routing_snapshot()
|
||||||
used_sources = self._dedupe_sources(active_retrieved)
|
used_sources = self._dedupe_sources(active_retrieved)
|
||||||
|
|
@ -745,21 +610,6 @@ class AdaptiveRAGAnswerRetrievalMixin:
|
||||||
)
|
)
|
||||||
if isinstance(live_meta.get("external_context_packet"), dict):
|
if isinstance(live_meta.get("external_context_packet"), dict):
|
||||||
external_context_packet = dict(live_meta.get("external_context_packet") or {})
|
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_verifier = dict(external_context_packet.get("verifier") or {})
|
||||||
external_missing_fields = [
|
external_missing_fields = [
|
||||||
str(item).strip()
|
str(item).strip()
|
||||||
|
|
@ -960,10 +810,6 @@ class AdaptiveRAGAnswerRetrievalMixin:
|
||||||
query_type=query_type,
|
query_type=query_type,
|
||||||
reason_code=str(contract_verdict.get("reason_code") or ""),
|
reason_code=str(contract_verdict.get("reason_code") or ""),
|
||||||
missing_fields=list(contract_verdict.get("missing_fields") 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:
|
if guard_payload:
|
||||||
answer_text = str(guard_payload.get("answer") or answer_text)
|
answer_text = str(guard_payload.get("answer") or answer_text)
|
||||||
|
|
@ -1053,32 +899,6 @@ class AdaptiveRAGAnswerRetrievalMixin:
|
||||||
tool_trace=tool_trace,
|
tool_trace=tool_trace,
|
||||||
retrieval_metrics=retrieval_metrics,
|
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(
|
qa = QARecord(
|
||||||
tenant_id=self.tenant_id,
|
tenant_id=self.tenant_id,
|
||||||
question=clean_q,
|
question=clean_q,
|
||||||
|
|
@ -1097,7 +917,6 @@ class AdaptiveRAGAnswerRetrievalMixin:
|
||||||
anchor_ref=None,
|
anchor_ref=None,
|
||||||
routing_snapshot=routing_snapshot,
|
routing_snapshot=routing_snapshot,
|
||||||
route_decision=normalized_route_decision,
|
route_decision=normalized_route_decision,
|
||||||
response_payload=response_payload_snapshot,
|
|
||||||
),
|
),
|
||||||
**runtime_fields,
|
**runtime_fields,
|
||||||
)
|
)
|
||||||
|
|
@ -1163,13 +982,4 @@ class AdaptiveRAGAnswerRetrievalMixin:
|
||||||
evidence_pack=runtime_fields["evidence_pack"],
|
evidence_pack=runtime_fields["evidence_pack"],
|
||||||
prompt_variant_id=prompt_variant_id,
|
prompt_variant_id=prompt_variant_id,
|
||||||
route_decision=normalized_route_decision,
|
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,
|
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,6 @@
|
||||||
"""Adaptive RAG mixin module."""
|
"""Adaptive RAG mixin module."""
|
||||||
from __future__ import annotations
|
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 .shared import *
|
||||||
from ..ai_pmo.workflow_semantic_planner import WorkflowSemanticPlanner
|
from ..ai_pmo.workflow_semantic_planner import WorkflowSemanticPlanner
|
||||||
|
|
||||||
|
|
@ -25,11 +20,10 @@ class AdaptiveRAGCoreIntentMixin:
|
||||||
_SEMANTIC_RETRIEVAL_DOMAINS = ("db", "graph", "doc", "feishu", "pmflow", "url")
|
_SEMANTIC_RETRIEVAL_DOMAINS = ("db", "graph", "doc", "feishu", "pmflow", "url")
|
||||||
_INTERNAL_RETRIEVAL_DOMAINS = ("db", "graph", "doc", "feishu", "pmflow")
|
_INTERNAL_RETRIEVAL_DOMAINS = ("db", "graph", "doc", "feishu", "pmflow")
|
||||||
_EXTERNAL_RETRIEVAL_DOMAINS = ("url",)
|
_EXTERNAL_RETRIEVAL_DOMAINS = ("url",)
|
||||||
|
_SEMANTIC_SUBJECT_FOCI = {"people", "project", "task", "signal", "charter", "workload", "feature", "unknown"}
|
||||||
_ALLOWED_SCOPE_VALUES = {"none", "global", "project"}
|
_ALLOWED_SCOPE_VALUES = {"none", "global", "project"}
|
||||||
_SEMANTIC_FEATURE_FOCI = {"none", "purpose", "entry", "catalog"}
|
_SEMANTIC_FEATURE_FOCI = {"none", "purpose", "entry", "catalog"}
|
||||||
_SOURCE_POLICIES = {"internal", "external", "uncertain"}
|
_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:
|
def __init__(self, db: AsyncSession, *, tenant_id: int) -> None:
|
||||||
self.db = db
|
self.db = db
|
||||||
|
|
@ -38,7 +32,6 @@ class AdaptiveRAGCoreIntentMixin:
|
||||||
self.context_cache = context_cache_service
|
self.context_cache = context_cache_service
|
||||||
self._active_external_qa_context: dict[str, Any] = {}
|
self._active_external_qa_context: dict[str, Any] = {}
|
||||||
self._last_answer_prompt_variant_id = "unknown"
|
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]] = {}
|
self._query_semantics_runtime_cache: dict[str, dict[str, Any]] = {}
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
|
|
@ -172,81 +165,7 @@ class AdaptiveRAGCoreIntentMixin:
|
||||||
return dict(payload)
|
return dict(payload)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _normalize_project_operating_context_snapshot(payload: Any) -> dict[str, Any]:
|
def _normalize_routing_snapshot(raw: 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 {}
|
payload = raw if isinstance(raw, dict) else {}
|
||||||
planned_tier = str(payload.get("planned_tier") or "").strip().lower()
|
planned_tier = str(payload.get("planned_tier") or "").strip().lower()
|
||||||
executed_tier = str(payload.get("executed_tier") or "").strip().lower()
|
executed_tier = str(payload.get("executed_tier") or "").strip().lower()
|
||||||
|
|
@ -284,9 +203,6 @@ class AdaptiveRAGCoreIntentMixin:
|
||||||
else {}
|
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}
|
return {key: value for key, value in normalized.items() if value is not None}
|
||||||
|
|
||||||
def _merge_query_route_audit_into_routing_snapshot(
|
def _merge_query_route_audit_into_routing_snapshot(
|
||||||
|
|
@ -294,16 +210,12 @@ class AdaptiveRAGCoreIntentMixin:
|
||||||
*,
|
*,
|
||||||
routing_snapshot: Optional[dict[str, Any]],
|
routing_snapshot: Optional[dict[str, Any]],
|
||||||
route_decision: Optional[dict[str, Any]],
|
route_decision: Optional[dict[str, Any]],
|
||||||
response_payload: Optional[dict[str, Any]] = None,
|
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
normalized_snapshot = self._normalize_routing_snapshot(routing_snapshot)
|
normalized_snapshot = self._normalize_routing_snapshot(routing_snapshot)
|
||||||
if hasattr(self, "_build_query_route_audit_snapshot"):
|
if hasattr(self, "_build_query_route_audit_snapshot"):
|
||||||
query_route = self._build_query_route_audit_snapshot(route_decision=route_decision)
|
query_route = self._build_query_route_audit_snapshot(route_decision=route_decision)
|
||||||
if query_route:
|
if query_route:
|
||||||
normalized_snapshot["query_route"] = 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
|
return normalized_snapshot
|
||||||
|
|
||||||
def _peek_last_answer_routing_snapshot(self) -> dict[str, Any]:
|
def _peek_last_answer_routing_snapshot(self) -> dict[str, Any]:
|
||||||
|
|
@ -324,13 +236,11 @@ class AdaptiveRAGCoreIntentMixin:
|
||||||
anchor_ref: Optional[str] = None,
|
anchor_ref: Optional[str] = None,
|
||||||
routing_snapshot: Optional[dict[str, Any]] = None,
|
routing_snapshot: Optional[dict[str, Any]] = None,
|
||||||
route_decision: Optional[dict[str, Any]] = None,
|
route_decision: Optional[dict[str, Any]] = None,
|
||||||
response_payload: Optional[dict[str, Any]] = None,
|
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
context = self._get_active_external_qa_context()
|
context = self._get_active_external_qa_context()
|
||||||
normalized_snapshot = self._merge_query_route_audit_into_routing_snapshot(
|
normalized_snapshot = self._merge_query_route_audit_into_routing_snapshot(
|
||||||
routing_snapshot=routing_snapshot,
|
routing_snapshot=routing_snapshot,
|
||||||
route_decision=route_decision,
|
route_decision=route_decision,
|
||||||
response_payload=response_payload,
|
|
||||||
) or None
|
) or None
|
||||||
return {
|
return {
|
||||||
"source_message_id": context.get("source_message_id"),
|
"source_message_id": context.get("source_message_id"),
|
||||||
|
|
@ -351,19 +261,6 @@ class AdaptiveRAGCoreIntentMixin:
|
||||||
def _peek_answer_prompt_variant(self) -> str:
|
def _peek_answer_prompt_variant(self) -> str:
|
||||||
return str(getattr(self, "_last_answer_prompt_variant_id", None) or "").strip() or "unknown"
|
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(
|
def _build_policy_version(
|
||||||
self,
|
self,
|
||||||
*,
|
*,
|
||||||
|
|
@ -516,6 +413,13 @@ class AdaptiveRAGCoreIntentMixin:
|
||||||
return uniq
|
return uniq
|
||||||
return list(cls._SEMANTIC_RETRIEVAL_DOMAINS)
|
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
|
@classmethod
|
||||||
def _normalize_semantic_scope_hint(cls, value: Any) -> str:
|
def _normalize_semantic_scope_hint(cls, value: Any) -> str:
|
||||||
normalized = str(value or "").strip().lower()
|
normalized = str(value or "").strip().lower()
|
||||||
|
|
@ -537,9 +441,23 @@ class AdaptiveRAGCoreIntentMixin:
|
||||||
return normalized
|
return normalized
|
||||||
return "uncertain"
|
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
|
@classmethod
|
||||||
def _normalize_query_profile(cls, payload: Any) -> dict[str, Any]:
|
def _normalize_query_profile(cls, payload: Any) -> dict[str, Any]:
|
||||||
raw = payload if isinstance(payload, dict) else {}
|
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:
|
try:
|
||||||
confidence = max(0.0, min(1.0, float(raw.get("confidence") or 0.0)))
|
confidence = max(0.0, min(1.0, float(raw.get("confidence") or 0.0)))
|
||||||
except Exception:
|
except Exception:
|
||||||
|
|
@ -547,18 +465,10 @@ class AdaptiveRAGCoreIntentMixin:
|
||||||
return {
|
return {
|
||||||
"needs_private_context": bool(raw.get("needs_private_context")),
|
"needs_private_context": bool(raw.get("needs_private_context")),
|
||||||
"needs_freshness": bool(raw.get("needs_freshness")),
|
"needs_freshness": bool(raw.get("needs_freshness")),
|
||||||
|
"referenced_objects": referenced_objects,
|
||||||
"confidence": confidence,
|
"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
|
@staticmethod
|
||||||
def _normalize_semantic_object_mentions(payload: Any) -> list[str]:
|
def _normalize_semantic_object_mentions(payload: Any) -> list[str]:
|
||||||
rows = payload if isinstance(payload, list) else []
|
rows = payload if isinstance(payload, list) else []
|
||||||
|
|
@ -703,13 +613,13 @@ class AdaptiveRAGCoreIntentMixin:
|
||||||
query_type = QUERY_TYPE_UNKNOWN
|
query_type = QUERY_TYPE_UNKNOWN
|
||||||
object_mentions = self._normalize_semantic_object_mentions(parsed.get("object_mentions"))
|
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"))
|
scope_hint = self._normalize_semantic_scope_hint(parsed.get("scope_hint"))
|
||||||
feature_focus = self._normalize_semantic_feature_focus(parsed.get("feature_focus"))
|
feature_focus = self._normalize_semantic_feature_focus(parsed.get("feature_focus"))
|
||||||
parsed_domains = self._normalize_semantic_domains(parsed.get("retrieval_domains"))
|
parsed_domains = self._normalize_semantic_domains(parsed.get("retrieval_domains"))
|
||||||
raw_source_policy = self._normalize_source_policy(parsed.get("source_policy"))
|
raw_source_policy = self._normalize_source_policy(parsed.get("source_policy"))
|
||||||
raw_effective_source_policy = self._normalize_source_policy(parsed.get("effective_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"))
|
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)
|
normalized_hint = self._normalize_source_policy(source_policy_hint)
|
||||||
fallback_taken = False
|
fallback_taken = False
|
||||||
if normalized_hint in {"internal", "external"}:
|
if normalized_hint in {"internal", "external"}:
|
||||||
|
|
@ -756,10 +666,10 @@ class AdaptiveRAGCoreIntentMixin:
|
||||||
"query_type": query_type,
|
"query_type": query_type,
|
||||||
"object_mentions": object_mentions,
|
"object_mentions": object_mentions,
|
||||||
"retrieval_domains": retrieval_domains,
|
"retrieval_domains": retrieval_domains,
|
||||||
|
"subject_focus": subject_focus,
|
||||||
"scope_hint": scope_hint,
|
"scope_hint": scope_hint,
|
||||||
"feature_focus": feature_focus,
|
"feature_focus": feature_focus,
|
||||||
"query_profile": query_profile,
|
"query_profile": query_profile,
|
||||||
"business_frame": business_frame,
|
|
||||||
"source_policy": source_policy,
|
"source_policy": source_policy,
|
||||||
"effective_source_policy": effective_source_policy,
|
"effective_source_policy": effective_source_policy,
|
||||||
"source_reason": source_reason,
|
"source_reason": source_reason,
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,6 @@ from .internal_query_context import (
|
||||||
internal_context_semantic_mirror,
|
internal_context_semantic_mirror,
|
||||||
internal_context_target_resolution,
|
internal_context_target_resolution,
|
||||||
)
|
)
|
||||||
from .shared_utils import _structured_project_requested, _structured_runtime_entity_type
|
|
||||||
from .shared import *
|
from .shared import *
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -159,12 +158,21 @@ class AdaptiveRAGDeepGuardAnswerMixin:
|
||||||
).strip().lower()
|
).strip().lower()
|
||||||
if query_type not in {QUERY_TYPE_STATUS, QUERY_TYPE_EXPLAIN}:
|
if query_type not in {QUERY_TYPE_STATUS, QUERY_TYPE_EXPLAIN}:
|
||||||
return None
|
return None
|
||||||
structured_entity_type = _structured_runtime_entity_type(
|
query_profile = (
|
||||||
query_semantics=semantics,
|
route_decision.get("query_profile")
|
||||||
operation_contract=operation_contract,
|
if isinstance(route_decision.get("query_profile"), dict)
|
||||||
internal_context_packet=internal_context_packet,
|
else (
|
||||||
|
semantics.get("query_profile")
|
||||||
|
if isinstance(semantics.get("query_profile"), dict)
|
||||||
|
else {}
|
||||||
|
)
|
||||||
)
|
)
|
||||||
if structured_entity_type != "config":
|
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"}):
|
||||||
return None
|
return None
|
||||||
target_resolution = internal_context_target_resolution(
|
target_resolution = internal_context_target_resolution(
|
||||||
packet=internal_context_packet,
|
packet=internal_context_packet,
|
||||||
|
|
@ -177,50 +185,6 @@ class AdaptiveRAGDeepGuardAnswerMixin:
|
||||||
target_entity_type = str(target_resolution.get("target_entity_type") or "").strip().lower()
|
target_entity_type = str(target_resolution.get("target_entity_type") or "").strip().lower()
|
||||||
if target_entity_type and target_entity_type != "config":
|
if target_entity_type and target_entity_type != "config":
|
||||||
return None
|
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)
|
get_bundle = getattr(self.llm, "_get_or_init_bundle", None)
|
||||||
resolve_active_engine = getattr(self.llm, "_resolve_active_engine_from_bundle", None)
|
resolve_active_engine = getattr(self.llm, "_resolve_active_engine_from_bundle", None)
|
||||||
|
|
@ -643,19 +607,20 @@ class AdaptiveRAGDeepGuardAnswerMixin:
|
||||||
"reason": "source_policy:external",
|
"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()
|
feature_focus = str(semantics.get("feature_focus") or "none").strip().lower()
|
||||||
scope_hint = str(semantics.get("scope_hint") 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()
|
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"])
|
scene_key = str(fallback["scene_key"])
|
||||||
if project_requested and normalized_query_type == QUERY_TYPE_STATUS:
|
if subject_focus == "workload":
|
||||||
|
scene_key = "member_workload"
|
||||||
|
elif subject_focus == "project" and normalized_query_type == QUERY_TYPE_STATUS:
|
||||||
scene_key = "project_status"
|
scene_key = "project_status"
|
||||||
if scene_key not in self._ANSWER_SCENE_KEYS:
|
if scene_key not in self._ANSWER_SCENE_KEYS:
|
||||||
scene_key = str(fallback["scene_key"])
|
scene_key = str(fallback["scene_key"])
|
||||||
|
|
||||||
snapshot_focus = "feature" if (structured_entity_type == "feature" or feature_focus != "none") else str(fallback["snapshot_focus"])
|
snapshot_focus = "feature" if (subject_focus == "feature" or feature_focus != "none") else str(fallback["snapshot_focus"])
|
||||||
if snapshot_focus not in self._SNAPSHOT_FOCI:
|
if snapshot_focus not in self._SNAPSHOT_FOCI:
|
||||||
snapshot_focus = str(fallback["snapshot_focus"])
|
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
|
confidence = 0.82 if scene_key != str(fallback["scene_key"]) or snapshot_focus != str(fallback["snapshot_focus"]) or scope_hint == "project" else 0.6
|
||||||
|
|
@ -1232,9 +1197,6 @@ class AdaptiveRAGDeepGuardAnswerMixin:
|
||||||
) -> str:
|
) -> str:
|
||||||
active_thinking_mode = self._normalize_thinking_mode(thinking_mode)
|
active_thinking_mode = self._normalize_thinking_mode(thinking_mode)
|
||||||
deep_thinking = active_thinking_mode == THINKING_MODE_DEEP
|
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(
|
if self._should_use_external_answer_presenter(
|
||||||
query_semantics=query_semantics,
|
query_semantics=query_semantics,
|
||||||
external_context_packet=external_context_packet,
|
external_context_packet=external_context_packet,
|
||||||
|
|
@ -1249,8 +1211,6 @@ class AdaptiveRAGDeepGuardAnswerMixin:
|
||||||
remember_prompt_variant = getattr(self, "_remember_answer_prompt_variant", None)
|
remember_prompt_variant = getattr(self, "_remember_answer_prompt_variant", None)
|
||||||
if callable(remember_prompt_variant):
|
if callable(remember_prompt_variant):
|
||||||
remember_prompt_variant(str(presentation.get("prompt_variant_id") or "external_unknown"))
|
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]
|
return str(presentation.get("answer_text") or "").strip()[:3200]
|
||||||
context_blocks: list[str] = []
|
context_blocks: list[str] = []
|
||||||
used_tokens = 0
|
used_tokens = 0
|
||||||
|
|
|
||||||
|
|
@ -7,31 +7,24 @@ from .internal_query_context import (
|
||||||
internal_context_semantic_mirror,
|
internal_context_semantic_mirror,
|
||||||
internal_context_target_resolution,
|
internal_context_target_resolution,
|
||||||
)
|
)
|
||||||
from .shared_utils import _structured_project_requested
|
|
||||||
|
|
||||||
|
|
||||||
class AdaptiveRAGDeterministicQueryCoreMixin:
|
class AdaptiveRAGDeterministicQueryCoreMixin:
|
||||||
|
@staticmethod
|
||||||
def _should_defer_project_existence_to_semantic_snapshot(
|
def _should_defer_project_existence_to_semantic_snapshot(
|
||||||
self,
|
|
||||||
*,
|
*,
|
||||||
query_type: str,
|
query_type: str,
|
||||||
query_semantics: Optional[dict[str, Any]] = None,
|
query_semantics: Optional[dict[str, Any]] = None,
|
||||||
internal_context_packet: Any = None,
|
|
||||||
) -> bool:
|
) -> bool:
|
||||||
|
semantics = query_semantics if isinstance(query_semantics, dict) else {}
|
||||||
normalized_query_type = str(query_type or QUERY_TYPE_UNKNOWN).strip().lower()
|
normalized_query_type = str(query_type or QUERY_TYPE_UNKNOWN).strip().lower()
|
||||||
if normalized_query_type not in {QUERY_TYPE_EXISTENCE, QUERY_TYPE_STATUS}:
|
if normalized_query_type not in {QUERY_TYPE_EXISTENCE, QUERY_TYPE_STATUS}:
|
||||||
return False
|
return False
|
||||||
semantics = query_semantics if isinstance(query_semantics, dict) else {}
|
subject_focus = str(semantics.get("subject_focus") or "").strip().lower()
|
||||||
source_policy = str(
|
source_policy = str(
|
||||||
semantics.get("effective_source_policy") or semantics.get("source_policy") or ""
|
semantics.get("effective_source_policy") or semantics.get("source_policy") or ""
|
||||||
).strip().lower()
|
).strip().lower()
|
||||||
return (
|
return subject_focus == "project" and source_policy == "internal"
|
||||||
source_policy == "internal"
|
|
||||||
and _structured_project_requested(
|
|
||||||
query_semantics=semantics,
|
|
||||||
internal_context_packet=internal_context_packet,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
async def _run_deterministic_query(
|
async def _run_deterministic_query(
|
||||||
self,
|
self,
|
||||||
|
|
@ -58,7 +51,6 @@ class AdaptiveRAGDeterministicQueryCoreMixin:
|
||||||
defer_project_existence = self._should_defer_project_existence_to_semantic_snapshot(
|
defer_project_existence = self._should_defer_project_existence_to_semantic_snapshot(
|
||||||
query_type=q_type,
|
query_type=q_type,
|
||||||
query_semantics=query_semantics,
|
query_semantics=query_semantics,
|
||||||
internal_context_packet=internal_context_packet,
|
|
||||||
)
|
)
|
||||||
if q_type == QUERY_TYPE_COUNT:
|
if q_type == QUERY_TYPE_COUNT:
|
||||||
return await self._run_count_query(
|
return await self._run_count_query(
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,6 @@
|
||||||
"""Adaptive RAG mixin module."""
|
"""Adaptive RAG mixin module."""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from .shared_utils import _structured_runtime_entity_type
|
|
||||||
from .shared import *
|
from .shared import *
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -16,15 +15,12 @@ class AdaptiveRAGDeterministicQueryCountMixin:
|
||||||
) -> Optional[dict[str, Any]]:
|
) -> Optional[dict[str, Any]]:
|
||||||
text = str(question or "").lower()
|
text = str(question or "").lower()
|
||||||
started = time.perf_counter()
|
started = time.perf_counter()
|
||||||
entity_type = _structured_runtime_entity_type(
|
subject_focus = self._query_subject_focus(query_semantics)
|
||||||
query_semantics=query_semantics,
|
|
||||||
internal_context_packet=internal_context_packet,
|
|
||||||
)
|
|
||||||
scope_hint = self._query_scope_hint(query_semantics)
|
scope_hint = self._query_scope_hint(query_semantics)
|
||||||
project_scope_required = scope_hint == "project" or project_id is not None
|
project_scope_required = scope_hint == "project" or project_id is not None
|
||||||
|
|
||||||
# 先处理更具体的对象(信号/任务),避免“xxx项目里有多少信号”误落到项目总数分支。
|
# 先处理更具体的对象(信号/任务),避免“xxx项目里有多少信号”误落到项目总数分支。
|
||||||
if entity_type == "signal":
|
if subject_focus == "signal":
|
||||||
scoped_project_id, scoped_project_name, scoped_keyword = await self._resolve_project_scope_for_question(
|
scoped_project_id, scoped_project_name, scoped_keyword = await self._resolve_project_scope_for_question(
|
||||||
question=text,
|
question=text,
|
||||||
project_id=project_id,
|
project_id=project_id,
|
||||||
|
|
@ -80,7 +76,7 @@ class AdaptiveRAGDeterministicQueryCountMixin:
|
||||||
"missing_fields": [],
|
"missing_fields": [],
|
||||||
}
|
}
|
||||||
|
|
||||||
if entity_type == "task":
|
if subject_focus == "task":
|
||||||
scoped_project_id, scoped_project_name, scoped_keyword = await self._resolve_project_scope_for_question(
|
scoped_project_id, scoped_project_name, scoped_keyword = await self._resolve_project_scope_for_question(
|
||||||
question=text,
|
question=text,
|
||||||
project_id=project_id,
|
project_id=project_id,
|
||||||
|
|
@ -136,7 +132,7 @@ class AdaptiveRAGDeterministicQueryCountMixin:
|
||||||
"missing_fields": [],
|
"missing_fields": [],
|
||||||
}
|
}
|
||||||
|
|
||||||
if entity_type == "user":
|
if subject_focus == "people":
|
||||||
scoped_project_id: Optional[int] = None
|
scoped_project_id: Optional[int] = None
|
||||||
scoped_project_name: Optional[str] = None
|
scoped_project_name: Optional[str] = None
|
||||||
scoped_keyword = ""
|
scoped_keyword = ""
|
||||||
|
|
@ -207,7 +203,7 @@ class AdaptiveRAGDeterministicQueryCountMixin:
|
||||||
"missing_fields": [],
|
"missing_fields": [],
|
||||||
}
|
}
|
||||||
|
|
||||||
if entity_type == "project":
|
if subject_focus == "project":
|
||||||
stmt = select(func.count(Project.id)).where(Project.tenant_id == self.tenant_id)
|
stmt = select(func.count(Project.id)).where(Project.tenant_id == self.tenant_id)
|
||||||
if project_id is not None:
|
if project_id is not None:
|
||||||
stmt = stmt.where(Project.id == int(project_id))
|
stmt = stmt.where(Project.id == int(project_id))
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,6 @@
|
||||||
"""Adaptive RAG mixin module."""
|
"""Adaptive RAG mixin module."""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from .shared_utils import _structured_runtime_entity_type
|
|
||||||
from .shared import *
|
from .shared import *
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -16,14 +15,11 @@ class AdaptiveRAGDeterministicQueryListMixin:
|
||||||
) -> Optional[dict[str, Any]]:
|
) -> Optional[dict[str, Any]]:
|
||||||
text = str(question or "").lower()
|
text = str(question or "").lower()
|
||||||
started = time.perf_counter()
|
started = time.perf_counter()
|
||||||
entity_type = _structured_runtime_entity_type(
|
subject_focus = self._query_subject_focus(query_semantics)
|
||||||
query_semantics=query_semantics,
|
|
||||||
internal_context_packet=internal_context_packet,
|
|
||||||
)
|
|
||||||
scope_hint = self._query_scope_hint(query_semantics)
|
scope_hint = self._query_scope_hint(query_semantics)
|
||||||
project_scope_required = scope_hint == "project" or project_id is not None
|
project_scope_required = scope_hint == "project" or project_id is not None
|
||||||
|
|
||||||
if entity_type == "user":
|
if subject_focus == "people":
|
||||||
scoped_project_id: Optional[int] = None
|
scoped_project_id: Optional[int] = None
|
||||||
scoped_project_name: Optional[str] = None
|
scoped_project_name: Optional[str] = None
|
||||||
scoped_keyword = ""
|
scoped_keyword = ""
|
||||||
|
|
@ -162,7 +158,7 @@ class AdaptiveRAGDeterministicQueryListMixin:
|
||||||
"missing_fields": [],
|
"missing_fields": [],
|
||||||
}
|
}
|
||||||
|
|
||||||
if entity_type == "signal":
|
if subject_focus == "signal":
|
||||||
scoped_project_id, scoped_project_name, scoped_keyword = await self._resolve_project_scope_for_question(
|
scoped_project_id, scoped_project_name, scoped_keyword = await self._resolve_project_scope_for_question(
|
||||||
question=text,
|
question=text,
|
||||||
project_id=project_id,
|
project_id=project_id,
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,6 @@ import html
|
||||||
|
|
||||||
from ...config.ai_prompts import PROMPT_PROJECT_WEEKLY_REPORT_JSON
|
from ...config.ai_prompts import PROMPT_PROJECT_WEEKLY_REPORT_JSON
|
||||||
from ..prompt_release import PromptReleaseService
|
from ..prompt_release import PromptReleaseService
|
||||||
from .shared_utils import _structured_project_requested
|
|
||||||
from .shared import *
|
from .shared import *
|
||||||
from .internal_query_context import (
|
from .internal_query_context import (
|
||||||
internal_context_operation_contract,
|
internal_context_operation_contract,
|
||||||
|
|
@ -27,40 +26,14 @@ class AdaptiveRAGFastAnswerProjectMainMixin:
|
||||||
focus = str(semantics.get("status_focus") or semantics.get("status_scene") or "").strip().lower()
|
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"}:
|
if focus in {"created_at", "progress", "snapshot", "risk", "risk_or_blocker"}:
|
||||||
return focus
|
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 ""
|
return ""
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _is_project_status_subject(
|
def _is_project_status_subject(query_semantics: Optional[dict[str, Any]] = None) -> bool:
|
||||||
query_semantics: Optional[dict[str, Any]] = None,
|
|
||||||
*,
|
|
||||||
internal_context_packet: Any = None,
|
|
||||||
) -> bool:
|
|
||||||
semantics = query_semantics if isinstance(query_semantics, dict) else {}
|
semantics = query_semantics if isinstance(query_semantics, dict) else {}
|
||||||
return _structured_project_requested(
|
subject_focus = str(semantics.get("subject_focus") or "").strip().lower()
|
||||||
query_semantics=semantics,
|
target_type = str(semantics.get("target_entity_type") or "").strip().lower()
|
||||||
internal_context_packet=internal_context_packet,
|
return subject_focus in {"project", "system"} or target_type == "project"
|
||||||
)
|
|
||||||
|
|
||||||
@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
|
@staticmethod
|
||||||
def _format_project_report_time(value: Optional[datetime]) -> str:
|
def _format_project_report_time(value: Optional[datetime]) -> str:
|
||||||
|
|
@ -481,7 +454,6 @@ class AdaptiveRAGFastAnswerProjectMainMixin:
|
||||||
snapshot: dict[str, Any],
|
snapshot: dict[str, Any],
|
||||||
fields: dict[str, Any],
|
fields: dict[str, Any],
|
||||||
external_items: list[str],
|
external_items: list[str],
|
||||||
emphasize_direct_impact: bool = False,
|
|
||||||
) -> str:
|
) -> str:
|
||||||
fallback_actions = cls._extract_project_report_actions(fields.get("actions"))
|
fallback_actions = cls._extract_project_report_actions(fields.get("actions"))
|
||||||
action_this_week = fallback_actions.get("this_week") or "先补齐本周必须推进的动作。"
|
action_this_week = fallback_actions.get("this_week") or "先补齐本周必须推进的动作。"
|
||||||
|
|
@ -519,9 +491,6 @@ class AdaptiveRAGFastAnswerProjectMainMixin:
|
||||||
lines.append(f"对项目的直接影响:{external_impact}")
|
lines.append(f"对项目的直接影响:{external_impact}")
|
||||||
else:
|
else:
|
||||||
lines.append("暂无高相关的外部新变化。")
|
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(
|
lines.extend(
|
||||||
[
|
[
|
||||||
"",
|
"",
|
||||||
|
|
@ -546,20 +515,10 @@ class AdaptiveRAGFastAnswerProjectMainMixin:
|
||||||
question = str(clean_q or "").strip()
|
question = str(clean_q or "").strip()
|
||||||
if not question:
|
if not question:
|
||||||
return None
|
return None
|
||||||
if self._should_bypass_project_progress_fast_answer(query_semantics):
|
|
||||||
return None
|
|
||||||
focus = self._normalize_project_status_focus(query_semantics)
|
focus = self._normalize_project_status_focus(query_semantics)
|
||||||
if focus not in {"progress", "snapshot", "risk", "risk_or_blocker"}:
|
if focus not in {"progress", "snapshot", "risk", "risk_or_blocker"}:
|
||||||
return None
|
return None
|
||||||
business_frame = query_semantics.get("business_frame") if isinstance(query_semantics, dict) and isinstance(query_semantics.get("business_frame"), dict) else {}
|
if project_id is None and not self._is_project_status_subject(query_semantics):
|
||||||
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
|
return None
|
||||||
asks_risk_or_blockers = focus in {"risk", "risk_or_blocker"}
|
asks_risk_or_blockers = focus in {"risk", "risk_or_blocker"}
|
||||||
|
|
||||||
|
|
@ -671,7 +630,6 @@ class AdaptiveRAGFastAnswerProjectMainMixin:
|
||||||
snapshot=snapshot,
|
snapshot=snapshot,
|
||||||
fields=merged_fields,
|
fields=merged_fields,
|
||||||
external_items=external_items,
|
external_items=external_items,
|
||||||
emphasize_direct_impact=emphasize_direct_impact,
|
|
||||||
)
|
)
|
||||||
response_time_ms = int((time.perf_counter() - started) * 1000)
|
response_time_ms = int((time.perf_counter() - started) * 1000)
|
||||||
sources = list(db_sources)
|
sources = list(db_sources)
|
||||||
|
|
@ -710,10 +668,7 @@ class AdaptiveRAGFastAnswerProjectMainMixin:
|
||||||
return None
|
return None
|
||||||
if self._normalize_project_status_focus(query_semantics) != "created_at":
|
if self._normalize_project_status_focus(query_semantics) != "created_at":
|
||||||
return None
|
return None
|
||||||
if project_id is None and not self._is_project_status_subject(
|
if project_id is None and not self._is_project_status_subject(query_semantics):
|
||||||
query_semantics,
|
|
||||||
internal_context_packet=internal_context_packet,
|
|
||||||
):
|
|
||||||
return None
|
return None
|
||||||
|
|
||||||
started = time.perf_counter()
|
started = time.perf_counter()
|
||||||
|
|
@ -818,6 +773,7 @@ class AdaptiveRAGFastAnswerProjectMainMixin:
|
||||||
if not question:
|
if not question:
|
||||||
return None
|
return None
|
||||||
semantics = query_semantics if isinstance(query_semantics, dict) else {}
|
semantics = query_semantics if isinstance(query_semantics, dict) else {}
|
||||||
|
subject_focus = self._query_subject_focus(semantics)
|
||||||
scope_hint = self._query_scope_hint(semantics)
|
scope_hint = self._query_scope_hint(semantics)
|
||||||
message_intent = str(semantics.get("message_intent") or semantics.get("intent") or "").strip().lower()
|
message_intent = str(semantics.get("message_intent") or semantics.get("intent") or "").strip().lower()
|
||||||
target_resolution = internal_context_target_resolution(
|
target_resolution = internal_context_target_resolution(
|
||||||
|
|
@ -870,11 +826,7 @@ class AdaptiveRAGFastAnswerProjectMainMixin:
|
||||||
if (
|
if (
|
||||||
project_id is None
|
project_id is None
|
||||||
and message_intent != "project_lookup_question"
|
and message_intent != "project_lookup_question"
|
||||||
and not _structured_project_requested(
|
and subject_focus not in {"project", "unknown"}
|
||||||
query_semantics=semantics,
|
|
||||||
target_resolution=target_resolution,
|
|
||||||
internal_context_packet=internal_context_packet,
|
|
||||||
)
|
|
||||||
and scope_hint != "project"
|
and scope_hint != "project"
|
||||||
):
|
):
|
||||||
return None
|
return None
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ from .shared import *
|
||||||
|
|
||||||
|
|
||||||
class AdaptiveRAGFastAnswerProjectUtilsMixin:
|
class AdaptiveRAGFastAnswerProjectUtilsMixin:
|
||||||
|
_SEMANTIC_SUBJECT_FOCI = {"people", "project", "task", "signal", "charter", "workload", "feature", "unknown"}
|
||||||
_ALLOWED_SCOPE_VALUES = {"none", "global", "project"}
|
_ALLOWED_SCOPE_VALUES = {"none", "global", "project"}
|
||||||
_SEMANTIC_FEATURE_FOCI = {"none", "purpose", "entry", "catalog"}
|
_SEMANTIC_FEATURE_FOCI = {"none", "purpose", "entry", "catalog"}
|
||||||
_LOOKUP_TEXT_TRANSLATION = str.maketrans(
|
_LOOKUP_TEXT_TRANSLATION = str.maketrans(
|
||||||
|
|
@ -216,6 +217,14 @@ class AdaptiveRAGFastAnswerProjectUtilsMixin:
|
||||||
break
|
break
|
||||||
return (deduped, used_keyword)
|
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
|
@classmethod
|
||||||
def _query_scope_hint(cls, query_semantics: Optional[dict[str, Any]]) -> str:
|
def _query_scope_hint(cls, query_semantics: Optional[dict[str, Any]]) -> str:
|
||||||
payload = query_semantics if isinstance(query_semantics, dict) else {}
|
payload = query_semantics if isinstance(query_semantics, dict) else {}
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,6 @@ from .internal_query_context import (
|
||||||
internal_context_query_plan,
|
internal_context_query_plan,
|
||||||
internal_context_semantic_mirror,
|
internal_context_semantic_mirror,
|
||||||
)
|
)
|
||||||
from .shared_utils import _structured_runtime_entity_type
|
|
||||||
from .shared import *
|
from .shared import *
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -112,15 +111,10 @@ class AdaptiveRAGFastAnswerSemanticMixinP1:
|
||||||
key="query_plan",
|
key="query_plan",
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
structured_entity_type = _structured_runtime_entity_type(
|
subject_focus = self._query_subject_focus(query_semantics)
|
||||||
query_semantics=semantics,
|
if subject_focus in {"people", "feature", "workload"}:
|
||||||
operation_contract=operation_contract,
|
|
||||||
query_plan=query_plan,
|
|
||||||
internal_context_packet=internal_context_packet,
|
|
||||||
)
|
|
||||||
if structured_entity_type in {"user", "feature"}:
|
|
||||||
return None
|
return None
|
||||||
if structured_entity_type not in {"project", "task", "signal", "doc", "unknown"}:
|
if subject_focus not in {"project", "task", "signal", "charter", "unknown"}:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
query_type = str(
|
query_type = str(
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from .shared import *
|
from .shared import *
|
||||||
from .internal_query_context import internal_context_target_resolution, update_internal_query_context_packet
|
from .internal_query_context import update_internal_query_context_packet
|
||||||
|
|
||||||
|
|
||||||
class AdaptiveRAGFastAnswerSemanticMixinP2:
|
class AdaptiveRAGFastAnswerSemanticMixinP2:
|
||||||
|
|
@ -29,17 +29,6 @@ class AdaptiveRAGFastAnswerSemanticMixinP2:
|
||||||
internal_context_packet: Any = None,
|
internal_context_packet: Any = None,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
started = time.perf_counter()
|
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 [])
|
sources = list(fast_result.get("sources") or [])
|
||||||
render_with_contract = getattr(self, "_render_answer_with_route_contract", None)
|
render_with_contract = getattr(self, "_render_answer_with_route_contract", None)
|
||||||
answer_text = str(fast_result.get("answer") or "未命中结果")
|
answer_text = str(fast_result.get("answer") or "未命中结果")
|
||||||
|
|
@ -81,10 +70,6 @@ class AdaptiveRAGFastAnswerSemanticMixinP2:
|
||||||
query_type=final_query_type,
|
query_type=final_query_type,
|
||||||
reason_code=str(contract_verdict.get("reason_code") or ""),
|
reason_code=str(contract_verdict.get("reason_code") or ""),
|
||||||
missing_fields=list(contract_verdict.get("missing_fields") 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)
|
answer_text = str(guard_payload.get("answer") or answer_text)
|
||||||
sources = list(guard_payload.get("sources") or [])
|
sources = list(guard_payload.get("sources") or [])
|
||||||
|
|
@ -140,52 +125,6 @@ class AdaptiveRAGFastAnswerSemanticMixinP2:
|
||||||
reason_code=effective_reason_code,
|
reason_code=effective_reason_code,
|
||||||
retrieval_metrics=retrieval_metrics,
|
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(
|
qa = QARecord(
|
||||||
tenant_id=self.tenant_id,
|
tenant_id=self.tenant_id,
|
||||||
|
|
@ -200,10 +139,7 @@ class AdaptiveRAGFastAnswerSemanticMixinP2:
|
||||||
project_id=project_id,
|
project_id=project_id,
|
||||||
session_id=active_session_id_int,
|
session_id=active_session_id_int,
|
||||||
turn_index=active_turn_index,
|
turn_index=active_turn_index,
|
||||||
**self._build_qa_record_external_fields(
|
**self._build_qa_record_external_fields(route_decision=route_decision),
|
||||||
route_decision=route_decision,
|
|
||||||
response_payload=response_payload_snapshot,
|
|
||||||
),
|
|
||||||
**runtime_fields,
|
**runtime_fields,
|
||||||
)
|
)
|
||||||
self.db.add(qa)
|
self.db.add(qa)
|
||||||
|
|
@ -267,8 +203,4 @@ class AdaptiveRAGFastAnswerSemanticMixinP2:
|
||||||
prompt_variant_id=prompt_variant_id,
|
prompt_variant_id=prompt_variant_id,
|
||||||
reason_code=effective_reason_code,
|
reason_code=effective_reason_code,
|
||||||
route_decision=route_decision,
|
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,
|
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ from __future__ import annotations
|
||||||
|
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from datetime import timedelta
|
from datetime import timedelta
|
||||||
|
import unicodedata
|
||||||
|
|
||||||
from .shared import *
|
from .shared import *
|
||||||
|
|
||||||
|
|
@ -51,6 +52,32 @@ class AdaptiveRAGFollowupAnchorMixin:
|
||||||
return shifted
|
return shifted
|
||||||
return normalized
|
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
|
@staticmethod
|
||||||
def _normalize_followup_preview(text: str) -> str:
|
def _normalize_followup_preview(text: str) -> str:
|
||||||
cleaned = re.sub(r"<at\b[^>]*>.*?</at>", "", str(text or ""), flags=re.IGNORECASE)
|
cleaned = re.sub(r"<at\b[^>]*>.*?</at>", "", str(text or ""), flags=re.IGNORECASE)
|
||||||
|
|
@ -176,6 +203,7 @@ class AdaptiveRAGFollowupAnchorMixin:
|
||||||
"anchor_reply_preview": answer_preview,
|
"anchor_reply_preview": answer_preview,
|
||||||
"created_at": created_at.isoformat() if created_at else None,
|
"created_at": created_at.isoformat() if created_at else None,
|
||||||
"relative_time": self._candidate_relative_time(created_at),
|
"relative_time": self._candidate_relative_time(created_at),
|
||||||
|
"match_text": self._normalize_followup_selection_text(label or question),
|
||||||
}
|
}
|
||||||
|
|
||||||
async def _load_explicit_reply_anchor(
|
async def _load_explicit_reply_anchor(
|
||||||
|
|
@ -246,6 +274,7 @@ class AdaptiveRAGFollowupAnchorMixin:
|
||||||
"anchor_reply_preview": preview[:56] + ("..." if len(preview) > 56 else ""),
|
"anchor_reply_preview": preview[:56] + ("..." if len(preview) > 56 else ""),
|
||||||
"created_at": created_at.isoformat() if created_at else None,
|
"created_at": created_at.isoformat() if created_at else None,
|
||||||
"relative_time": self._candidate_relative_time(created_at),
|
"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,
|
"source_message_id": source_message_id or None,
|
||||||
"sent_message_id": str(outbox.sent_message_id or "").strip() or None,
|
"sent_message_id": str(outbox.sent_message_id or "").strip() or None,
|
||||||
"anchor_kind": "feishu_reply_outbox",
|
"anchor_kind": "feishu_reply_outbox",
|
||||||
|
|
@ -312,6 +341,7 @@ class AdaptiveRAGFollowupAnchorMixin:
|
||||||
"anchor_reply_preview": preview[:56] + ("..." if len(preview) > 56 else ""),
|
"anchor_reply_preview": preview[:56] + ("..." if len(preview) > 56 else ""),
|
||||||
"created_at": created_at.isoformat() if created_at else None,
|
"created_at": created_at.isoformat() if created_at else None,
|
||||||
"relative_time": self._candidate_relative_time(created_at),
|
"relative_time": self._candidate_relative_time(created_at),
|
||||||
|
"match_text": self._normalize_followup_selection_text(previous_question or label),
|
||||||
"source_message_id": event_key,
|
"source_message_id": event_key,
|
||||||
"sent_message_id": None,
|
"sent_message_id": None,
|
||||||
"anchor_kind": "ingest_closure_turns",
|
"anchor_kind": "ingest_closure_turns",
|
||||||
|
|
@ -380,6 +410,7 @@ class AdaptiveRAGFollowupAnchorMixin:
|
||||||
"index": int(candidate.get("index") or 0),
|
"index": int(candidate.get("index") or 0),
|
||||||
"qa_id": int(candidate.get("qa_id") or 0),
|
"qa_id": int(candidate.get("qa_id") or 0),
|
||||||
"label": str(candidate.get("label") or "").strip()[:80],
|
"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],
|
"question": str(candidate.get("question") or "").strip()[:180],
|
||||||
}
|
}
|
||||||
for candidate in candidates[:FOLLOWUP_CANDIDATE_LIMIT]
|
for candidate in candidates[:FOLLOWUP_CANDIDATE_LIMIT]
|
||||||
|
|
@ -416,56 +447,6 @@ class AdaptiveRAGFollowupAnchorMixin:
|
||||||
{
|
{
|
||||||
"key": str(item.get("key") or "").strip().upper(),
|
"key": str(item.get("key") or "").strip().upper(),
|
||||||
"label": str(item.get("label") or "").strip()[:80],
|
"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
|
for item in options
|
||||||
if isinstance(item, dict)
|
if isinstance(item, dict)
|
||||||
|
|
@ -489,86 +470,7 @@ class AdaptiveRAGFollowupAnchorMixin:
|
||||||
return None
|
return None
|
||||||
return payload
|
return payload
|
||||||
|
|
||||||
async def _resolve_pending_clarification_selection_semantics(
|
def _consume_pending_clarification_selection(
|
||||||
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,
|
self,
|
||||||
*,
|
*,
|
||||||
question: str,
|
question: str,
|
||||||
|
|
@ -579,51 +481,40 @@ class AdaptiveRAGFollowupAnchorMixin:
|
||||||
return None
|
return None
|
||||||
text = str(question or "").strip()
|
text = str(question or "").strip()
|
||||||
kind = str(payload.get("kind") or "").strip().lower()
|
kind = str(payload.get("kind") or "").strip().lower()
|
||||||
semantics = await self._resolve_pending_clarification_selection_semantics(
|
if kind == "query_route_selection":
|
||||||
question=text,
|
option_map = payload.get("option_map") if isinstance(payload.get("option_map"), dict) else {}
|
||||||
payload=payload,
|
options = payload.get("options") if isinstance(payload.get("options"), list) else []
|
||||||
)
|
normalized = self._normalize_followup_selection_text(text)
|
||||||
if str(semantics.get("selection_status") or "").strip().lower() == "new_topic":
|
selected_hint = ""
|
||||||
self._clear_pending_clarification(session_state=session_state)
|
if len(text) == 1:
|
||||||
return {
|
selected_hint = str(option_map.get(text.upper()) or "").strip().lower()
|
||||||
"effective_question": text,
|
if not selected_hint:
|
||||||
"clear_pending_only": True,
|
matched: list[str] = []
|
||||||
}
|
for option in options:
|
||||||
option_map = payload.get("option_map") if isinstance(payload.get("option_map"), dict) else {}
|
if not isinstance(option, dict):
|
||||||
options = payload.get("options") if isinstance(payload.get("options"), list) else []
|
continue
|
||||||
if option_map and options:
|
key = str(option.get("key") or "").strip().upper()
|
||||||
selected_key = str(semantics.get("selected_key") or "").strip().upper()
|
label = str(option.get("label") or "").strip()
|
||||||
selected_value = str(option_map.get(selected_key) or "").strip()
|
normalized_label = self._normalize_followup_selection_text(label)
|
||||||
if selected_value:
|
if not normalized or not normalized_label:
|
||||||
selected_option = next(
|
continue
|
||||||
(
|
if normalized in normalized_label or normalized_label in normalized:
|
||||||
option
|
mapped = str(option_map.get(key) or "").strip().lower()
|
||||||
for option in options
|
if mapped and mapped not in matched:
|
||||||
if isinstance(option, dict) and str(option.get("key") or "").strip().upper() == selected_key
|
matched.append(mapped)
|
||||||
),
|
if len(matched) == 1:
|
||||||
{},
|
selected_hint = matched[0]
|
||||||
)
|
if selected_hint in {"internal", "external"}:
|
||||||
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)
|
self._clear_pending_clarification(session_state=session_state)
|
||||||
return {
|
return {
|
||||||
"effective_question": str(payload.get("original_question") or "").strip() or text,
|
"effective_question": str(payload.get("original_question") or "").strip() or text,
|
||||||
"source_policy_hint": selected_hint if selected_hint in {"internal", "external"} else None,
|
"source_policy_hint": selected_hint,
|
||||||
"query_semantics_overrides": overrides,
|
|
||||||
}
|
}
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
index = self._parse_followup_choice_index(text)
|
||||||
candidates = payload.get("candidates") if isinstance(payload.get("candidates"), list) else []
|
candidates = payload.get("candidates") if isinstance(payload.get("candidates"), list) else []
|
||||||
index = self._normalize_followup_candidate_index(semantics.get("selected_index"))
|
normalized = self._normalize_followup_selection_text(text)
|
||||||
selected: Optional[dict[str, Any]] = None
|
selected: Optional[dict[str, Any]] = None
|
||||||
for candidate in candidates:
|
for candidate in candidates:
|
||||||
if not isinstance(candidate, dict):
|
if not isinstance(candidate, dict):
|
||||||
|
|
@ -632,6 +523,10 @@ class AdaptiveRAGFollowupAnchorMixin:
|
||||||
if index and candidate_index == index:
|
if index and candidate_index == index:
|
||||||
selected = candidate
|
selected = candidate
|
||||||
break
|
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:
|
if selected is None:
|
||||||
return None
|
return None
|
||||||
self._clear_pending_clarification(session_state=session_state)
|
self._clear_pending_clarification(session_state=session_state)
|
||||||
|
|
@ -647,7 +542,6 @@ class AdaptiveRAGFollowupAnchorMixin:
|
||||||
session_id: Optional[int],
|
session_id: Optional[int],
|
||||||
context_session_id: Optional[int],
|
context_session_id: Optional[int],
|
||||||
session_state: Optional[QASessionState],
|
session_state: Optional[QASessionState],
|
||||||
selected_anchor_reply_id: Optional[int] = None,
|
|
||||||
conversation_scope_key: Optional[str] = None,
|
conversation_scope_key: Optional[str] = None,
|
||||||
source_chat_id: Optional[str] = None,
|
source_chat_id: Optional[str] = None,
|
||||||
source_sender_open_id: Optional[str] = None,
|
source_sender_open_id: Optional[str] = None,
|
||||||
|
|
@ -700,27 +594,22 @@ class AdaptiveRAGFollowupAnchorMixin:
|
||||||
is_referential_followup = True
|
is_referential_followup = True
|
||||||
anchor_strategy = "explicit"
|
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":
|
if followup_mode == "none":
|
||||||
return FollowupAnchorResolution()
|
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"}:
|
if explicit_anchor is not None and anchor_strategy in {"explicit", "implicit_recent"}:
|
||||||
return FollowupAnchorResolution(
|
return FollowupAnchorResolution(
|
||||||
followup_mode=followup_mode,
|
followup_mode=followup_mode,
|
||||||
|
|
@ -733,7 +622,19 @@ class AdaptiveRAGFollowupAnchorMixin:
|
||||||
anchor_reply=explicit_anchor,
|
anchor_reply=explicit_anchor,
|
||||||
)
|
)
|
||||||
|
|
||||||
if is_meta_followup and explicit_anchor is None and not candidates:
|
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:
|
||||||
return FollowupAnchorResolution()
|
return FollowupAnchorResolution()
|
||||||
|
|
||||||
if not candidates:
|
if not candidates:
|
||||||
|
|
@ -774,10 +675,13 @@ class AdaptiveRAGFollowupAnchorMixin:
|
||||||
anchor_reply=chosen,
|
anchor_reply=chosen,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if is_meta_followup:
|
||||||
|
return FollowupAnchorResolution()
|
||||||
|
|
||||||
return FollowupAnchorResolution(
|
return FollowupAnchorResolution(
|
||||||
followup_mode=followup_mode,
|
followup_mode=followup_mode,
|
||||||
anchor_mode="ambiguous_candidates",
|
anchor_mode="ambiguous_candidates",
|
||||||
anchor_kind="meta_recent_reply" if is_meta_followup else "recent_scope_reply",
|
anchor_kind="recent_scope_reply",
|
||||||
anchor_session_id=active_session_id,
|
anchor_session_id=active_session_id,
|
||||||
candidate_replies=candidates,
|
candidate_replies=candidates,
|
||||||
is_meta_followup=is_meta_followup,
|
is_meta_followup=is_meta_followup,
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,6 @@
|
||||||
"""Adaptive RAG mixin module."""
|
"""Adaptive RAG mixin module."""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from .shared_utils import _structured_runtime_entity_type
|
|
||||||
from .shared import *
|
from .shared import *
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -19,8 +18,9 @@ class AdaptiveRAGManifestFeatureMixinP2:
|
||||||
normalized_scene = scene_semantics if isinstance(scene_semantics, dict) else {}
|
normalized_scene = scene_semantics if isinstance(scene_semantics, dict) else {}
|
||||||
normalized_query = query_semantics if isinstance(query_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()
|
snapshot_focus = str(normalized_scene.get("snapshot_focus") or "none").strip().lower()
|
||||||
structured_entity_type = _structured_runtime_entity_type(query_semantics=normalized_query)
|
subject_focus = self._query_subject_focus(normalized_query)
|
||||||
if snapshot_focus != "feature" and structured_entity_type != "feature":
|
feature_focus = self._query_feature_focus(normalized_query)
|
||||||
|
if snapshot_focus != "feature" and subject_focus != "feature" and feature_focus == "none":
|
||||||
return answer_text
|
return answer_text
|
||||||
|
|
||||||
evidence_rows = self._collect_feature_evidence(
|
evidence_rows = self._collect_feature_evidence(
|
||||||
|
|
|
||||||
|
|
@ -1,290 +0,0 @@
|
||||||
"""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()
|
|
||||||
|
|
@ -168,7 +168,6 @@ class AdaptiveRAGQASessionListOpsMixinP1:
|
||||||
fb = feedback_map.get(int(row.id))
|
fb = feedback_map.get(int(row.id))
|
||||||
ack_meta = self._extract_ack_metadata_from_sources(row.sources, answer=row.answer)
|
ack_meta = self._extract_ack_metadata_from_sources(row.sources, answer=row.answer)
|
||||||
routing_snapshot = self._normalize_message_dict(row.routing_snapshot)
|
routing_snapshot = self._normalize_message_dict(row.routing_snapshot)
|
||||||
response_payload = self._normalize_message_dict(routing_snapshot.get("response_payload"))
|
|
||||||
if fb:
|
if fb:
|
||||||
rating = str(fb.rating or "").strip().lower()
|
rating = str(fb.rating or "").strip().lower()
|
||||||
if rating == "positive":
|
if rating == "positive":
|
||||||
|
|
@ -197,22 +196,6 @@ class AdaptiveRAGQASessionListOpsMixinP1:
|
||||||
"citations": self._normalize_message_list(row.citations),
|
"citations": self._normalize_message_list(row.citations),
|
||||||
"sources_used": self._normalize_message_list(row.sources_used),
|
"sources_used": self._normalize_message_list(row.sources_used),
|
||||||
"evidence_pack": self._normalize_message_dict(row.evidence_pack),
|
"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),
|
"turn_index": int(row.turn_index or 1),
|
||||||
"created_at": row.created_at.isoformat() if row.created_at else None,
|
"created_at": row.created_at.isoformat() if row.created_at else None,
|
||||||
"feedback": {
|
"feedback": {
|
||||||
|
|
|
||||||
|
|
@ -1,132 +1,13 @@
|
||||||
"""Adaptive RAG query route gateway helpers."""
|
"""Adaptive RAG query route gateway helpers."""
|
||||||
from __future__ import annotations
|
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 import *
|
||||||
from .shared_utils import _env_bool
|
|
||||||
|
|
||||||
|
|
||||||
class AdaptiveRAGQueryRouteGatewayMixin:
|
class AdaptiveRAGQueryRouteGatewayMixin:
|
||||||
_QUERY_ROUTE_MODES = {"internal", "external", "uncertain"}
|
_QUERY_ROUTE_MODES = {"internal", "external", "uncertain"}
|
||||||
_INTERNAL_ALLOWED_SOURCES = {"db", "graph", "doc", "pmflow", "feishu"}
|
_INTERNAL_ALLOWED_SOURCES = {"db", "graph", "doc", "pmflow", "feishu"}
|
||||||
_EXTERNAL_ALLOWED_SOURCES = {"url", "mcp_search", "system"}
|
_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]:
|
def _normalize_query_route_decision(self, payload: Optional[dict[str, Any]]) -> dict[str, Any]:
|
||||||
raw = payload if isinstance(payload, dict) else {}
|
raw = payload if isinstance(payload, dict) else {}
|
||||||
|
|
@ -160,9 +41,17 @@ class AdaptiveRAGQueryRouteGatewayMixin:
|
||||||
@classmethod
|
@classmethod
|
||||||
def _normalize_query_profile(cls, payload: Optional[dict[str, Any]]) -> dict[str, Any]:
|
def _normalize_query_profile(cls, payload: Optional[dict[str, Any]]) -> dict[str, Any]:
|
||||||
rows = payload if isinstance(payload, dict) else {}
|
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 {
|
return {
|
||||||
"needs_private_context": bool(rows.get("needs_private_context")),
|
"needs_private_context": bool(rows.get("needs_private_context")),
|
||||||
"needs_freshness": bool(rows.get("needs_freshness")),
|
"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))),
|
"confidence": max(0.0, min(1.0, float(rows.get("confidence") or 0.0))),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -206,26 +95,8 @@ class AdaptiveRAGQueryRouteGatewayMixin:
|
||||||
@classmethod
|
@classmethod
|
||||||
def _build_query_route_clarification_protocol(cls) -> dict[str, Any]:
|
def _build_query_route_clarification_protocol(cls) -> dict[str, Any]:
|
||||||
options = [
|
options = [
|
||||||
{
|
{"key": "A", "label": "外部公开事实"},
|
||||||
"key": "A",
|
{"key": "B", "label": "系统内部配置/项目对象"},
|
||||||
"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 = {
|
option_map = {
|
||||||
"A": "external",
|
"A": "external",
|
||||||
|
|
@ -346,24 +217,12 @@ class AdaptiveRAGQueryRouteGatewayMixin:
|
||||||
asked_via: str,
|
asked_via: str,
|
||||||
project_id: Optional[int],
|
project_id: Optional[int],
|
||||||
source_chat_id: Optional[str],
|
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]],
|
query_semantics: Optional[dict[str, Any]],
|
||||||
source_policy_hint: Optional[str],
|
source_policy_hint: Optional[str],
|
||||||
project_operating_context: Optional[dict[str, Any]] = None,
|
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
_ = (question, asked_via, source_message_id, conversation_scope_key)
|
_ = (question, asked_via, project_id, source_chat_id)
|
||||||
semantics = dict(query_semantics or {})
|
semantics = dict(query_semantics or {})
|
||||||
query_profile = self._normalize_query_profile(semantics.get("query_profile"))
|
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(
|
contract = self._resolve_route_contract(
|
||||||
query_semantics=semantics,
|
query_semantics=semantics,
|
||||||
source_policy_hint=source_policy_hint,
|
source_policy_hint=source_policy_hint,
|
||||||
|
|
@ -383,12 +242,6 @@ class AdaptiveRAGQueryRouteGatewayMixin:
|
||||||
"decision_origin": decision_origin,
|
"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 {
|
return {
|
||||||
"route_mode": route_mode,
|
"route_mode": route_mode,
|
||||||
"effective_source_policy": effective_source_policy,
|
"effective_source_policy": effective_source_policy,
|
||||||
|
|
@ -408,8 +261,6 @@ class AdaptiveRAGQueryRouteGatewayMixin:
|
||||||
effective_source_policy=effective_source_policy,
|
effective_source_policy=effective_source_policy,
|
||||||
decision_origin=decision_origin,
|
decision_origin=decision_origin,
|
||||||
),
|
),
|
||||||
"project_operating_context": normalized_project_operating_context,
|
|
||||||
"execution_recipe": execution_recipe,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async def preview_query_route_decision(
|
async def preview_query_route_decision(
|
||||||
|
|
@ -425,16 +276,8 @@ class AdaptiveRAGQueryRouteGatewayMixin:
|
||||||
root_message_id: Optional[str] = None,
|
root_message_id: Optional[str] = None,
|
||||||
source_policy_hint: Optional[str] = None,
|
source_policy_hint: Optional[str] = None,
|
||||||
query_semantics: Optional[dict[str, Any]] = None,
|
query_semantics: Optional[dict[str, Any]] = None,
|
||||||
project_operating_context: Optional[dict[str, Any]] = None,
|
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
semantics = dict(query_semantics or {})
|
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:
|
if not semantics:
|
||||||
semantics = await self._resolve_query_semantics(
|
semantics = await self._resolve_query_semantics(
|
||||||
message=question,
|
message=question,
|
||||||
|
|
@ -446,7 +289,6 @@ class AdaptiveRAGQueryRouteGatewayMixin:
|
||||||
"conversation_scope_key": conversation_scope_key,
|
"conversation_scope_key": conversation_scope_key,
|
||||||
"reply_to_message_id": reply_to_message_id,
|
"reply_to_message_id": reply_to_message_id,
|
||||||
"root_message_id": root_message_id,
|
"root_message_id": root_message_id,
|
||||||
"project_operating_context": normalized_project_operating_context,
|
|
||||||
},
|
},
|
||||||
source_policy_hint=source_policy_hint,
|
source_policy_hint=source_policy_hint,
|
||||||
)
|
)
|
||||||
|
|
@ -456,31 +298,17 @@ class AdaptiveRAGQueryRouteGatewayMixin:
|
||||||
asked_via=asked_via,
|
asked_via=asked_via,
|
||||||
project_id=project_id,
|
project_id=project_id,
|
||||||
source_chat_id=source_chat_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,
|
query_semantics=semantics,
|
||||||
source_policy_hint=source_policy_hint or semantics.get("source_policy"),
|
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 = self._normalize_query_route_decision(decision)
|
||||||
normalized_decision["probe_snapshot"] = dict(decision.get("probe_snapshot") or {})
|
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 {
|
return {
|
||||||
"query_semantics": self._apply_route_decision_to_query_semantics(
|
"query_semantics": self._apply_route_decision_to_query_semantics(
|
||||||
query_semantics=semantics,
|
query_semantics=semantics,
|
||||||
route_decision=normalized_decision,
|
route_decision=normalized_decision,
|
||||||
),
|
),
|
||||||
"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(
|
async def _persist_query_route_memory(
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,6 @@
|
||||||
"""Adaptive RAG mixin module."""
|
"""Adaptive RAG mixin module."""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from .shared_utils import _structured_project_requested, _structured_runtime_entity_type
|
|
||||||
from .shared import *
|
from .shared import *
|
||||||
from .internal_query_context import (
|
from .internal_query_context import (
|
||||||
internal_context_operation_contract,
|
internal_context_operation_contract,
|
||||||
|
|
@ -203,6 +202,7 @@ class AdaptiveRAGRetrievalRouterDBMixin:
|
||||||
) -> list[RetrievedItem]:
|
) -> list[RetrievedItem]:
|
||||||
text = question.lower()
|
text = question.lower()
|
||||||
rows: list[RetrievedItem] = []
|
rows: list[RetrievedItem] = []
|
||||||
|
subject_focus = self._query_subject_focus(query_semantics)
|
||||||
scope_hint = self._query_scope_hint(query_semantics)
|
scope_hint = self._query_scope_hint(query_semantics)
|
||||||
target_resolution = internal_context_target_resolution(
|
target_resolution = internal_context_target_resolution(
|
||||||
packet=internal_context_packet,
|
packet=internal_context_packet,
|
||||||
|
|
@ -221,20 +221,22 @@ class AdaptiveRAGRetrievalRouterDBMixin:
|
||||||
key="query_plan",
|
key="query_plan",
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
entity_type = _structured_runtime_entity_type(
|
planned_entity_type = str(query_plan.get("entity_type") or "").strip().lower()
|
||||||
query_semantics=query_semantics,
|
project_subject_requested = subject_focus == "project" or target_entity_type == "project" or planned_entity_type == "project"
|
||||||
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
|
project_scope_required = scope_hint == "project" or project_id is not None
|
||||||
semantic_fact_entity_type = entity_type
|
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"
|
||||||
if semantic_fact_entity_type and semantic_fact_entity_type != "project":
|
if semantic_fact_entity_type and semantic_fact_entity_type != "project":
|
||||||
rows.extend(
|
rows.extend(
|
||||||
await self._retrieve_generic_semantic_fact_rows(
|
await self._retrieve_generic_semantic_fact_rows(
|
||||||
|
|
@ -245,7 +247,7 @@ class AdaptiveRAGRetrievalRouterDBMixin:
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
if entity_type == "user":
|
if subject_focus == "people":
|
||||||
scoped_project_id: Optional[int] = None
|
scoped_project_id: Optional[int] = None
|
||||||
scoped_project_name: Optional[str] = None
|
scoped_project_name: Optional[str] = None
|
||||||
if project_scope_required:
|
if project_scope_required:
|
||||||
|
|
@ -338,7 +340,7 @@ class AdaptiveRAGRetrievalRouterDBMixin:
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
if entity_type == "signal":
|
if subject_focus == "signal":
|
||||||
signal_stmt = select(Signal).where(Signal.tenant_id == self.tenant_id)
|
signal_stmt = select(Signal).where(Signal.tenant_id == self.tenant_id)
|
||||||
if project_id:
|
if project_id:
|
||||||
signal_stmt = signal_stmt.where(
|
signal_stmt = signal_stmt.where(
|
||||||
|
|
@ -371,7 +373,7 @@ class AdaptiveRAGRetrievalRouterDBMixin:
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
if entity_type == "task":
|
if subject_focus == "task":
|
||||||
task_stmt = select(Task).where(Task.tenant_id == self.tenant_id)
|
task_stmt = select(Task).where(Task.tenant_id == self.tenant_id)
|
||||||
if project_id:
|
if project_id:
|
||||||
task_stmt = task_stmt.where(Task.project_id == project_id)
|
task_stmt = task_stmt.where(Task.project_id == project_id)
|
||||||
|
|
@ -401,7 +403,7 @@ class AdaptiveRAGRetrievalRouterDBMixin:
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
if entity_type == "requirement":
|
if subject_focus == "charter":
|
||||||
charter_stmt = select(TruthNode).where(TruthNode.tenant_id == self.tenant_id)
|
charter_stmt = select(TruthNode).where(TruthNode.tenant_id == self.tenant_id)
|
||||||
if project_id:
|
if project_id:
|
||||||
charter_stmt = charter_stmt.where(TruthNode.project_id == project_id)
|
charter_stmt = charter_stmt.where(TruthNode.project_id == project_id)
|
||||||
|
|
@ -428,7 +430,7 @@ class AdaptiveRAGRetrievalRouterDBMixin:
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
if entity_type == "user_workload":
|
if subject_focus == "workload":
|
||||||
workload_stmt = select(UserWorkload).where(UserWorkload.tenant_id == self.tenant_id)
|
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()
|
workloads = (await self.db.execute(workload_stmt.order_by(UserWorkload.updated_at.desc()).limit(10))).scalars().all()
|
||||||
if workloads:
|
if workloads:
|
||||||
|
|
|
||||||
|
|
@ -5,203 +5,6 @@ from .shared import *
|
||||||
|
|
||||||
|
|
||||||
class AdaptiveRAGSessionStateMixin:
|
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]:
|
async def _ensure_session_state(self, *, session_id: Optional[int]) -> Optional[QASessionState]:
|
||||||
if session_id is None:
|
if session_id is None:
|
||||||
return None
|
return None
|
||||||
|
|
@ -238,7 +41,6 @@ class AdaptiveRAGSessionStateMixin:
|
||||||
conversation: list[dict[str, str]],
|
conversation: list[dict[str, str]],
|
||||||
session_state: Optional[QASessionState],
|
session_state: Optional[QASessionState],
|
||||||
anchor_resolution: Optional[FollowupAnchorResolution] = None,
|
anchor_resolution: Optional[FollowupAnchorResolution] = None,
|
||||||
rewrite_governance: Optional[dict[str, Any]] = None,
|
|
||||||
) -> tuple[str, dict[str, Any]]:
|
) -> tuple[str, dict[str, Any]]:
|
||||||
_ = conversation
|
_ = conversation
|
||||||
current = str(question or "").strip()
|
current = str(question or "").strip()
|
||||||
|
|
@ -248,14 +50,6 @@ class AdaptiveRAGSessionStateMixin:
|
||||||
resolution = anchor_resolution or FollowupAnchorResolution()
|
resolution = anchor_resolution or FollowupAnchorResolution()
|
||||||
if str(resolution.followup_mode or "").strip().lower() != "referential":
|
if str(resolution.followup_mode or "").strip().lower() != "referential":
|
||||||
return current, {"mode": "direct"}
|
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()
|
explicit_anchor_mode = str(resolution.anchor_mode or "").strip().lower()
|
||||||
if explicit_anchor_mode == "explicit":
|
if explicit_anchor_mode == "explicit":
|
||||||
|
|
|
||||||
|
|
@ -7,35 +7,11 @@ from .internal_query_context import (
|
||||||
internal_context_query_plan,
|
internal_context_query_plan,
|
||||||
internal_context_target_resolution,
|
internal_context_target_resolution,
|
||||||
)
|
)
|
||||||
from .internal_domain_catalog import config_target_catalog, internal_domain_target
|
|
||||||
|
|
||||||
|
|
||||||
class AdaptiveRAGTargetResolutionMixin:
|
class AdaptiveRAGTargetResolutionMixin:
|
||||||
_TARGET_ENTITY_TYPES = {"project", "task", "feature", "config", "doc", "person", "user", "signal", "unknown"}
|
_TARGET_ENTITY_TYPES = {"project", "feature", "config", "doc", "person", "signal", "unknown"}
|
||||||
_TARGET_RESOLUTION_MODES = {"deterministic", "llm_rerank", "semantic_filter", "clarify", "none"}
|
_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
|
@classmethod
|
||||||
def _normalize_object_mentions(
|
def _normalize_object_mentions(
|
||||||
|
|
@ -62,19 +38,21 @@ class AdaptiveRAGTargetResolutionMixin:
|
||||||
_ = question
|
_ = question
|
||||||
semantics = query_semantics if isinstance(query_semantics, dict) else {}
|
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
|
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 {}
|
query_profile = dict(semantics.get("query_profile") or {}) if isinstance(semantics.get("query_profile"), dict) else {}
|
||||||
config_bound = (
|
referenced_objects = {
|
||||||
str(business_frame.get("resource_role") or "").strip().lower() == "config"
|
str(item or "").strip().lower()
|
||||||
or str(business_frame.get("object_binding") or "").strip().lower().startswith("config:")
|
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 config_bound:
|
}
|
||||||
|
if query_type in {QUERY_TYPE_EXISTENCE, QUERY_TYPE_UNKNOWN} and referenced_objects.intersection(
|
||||||
|
{"model_routing", "system_config"}
|
||||||
|
):
|
||||||
query_type = QUERY_TYPE_STATUS
|
query_type = QUERY_TYPE_STATUS
|
||||||
object_mentions = self._normalize_object_mentions(
|
object_mentions = self._normalize_object_mentions(
|
||||||
semantics.get("object_mentions"),
|
semantics.get("object_mentions"),
|
||||||
question=question,
|
question=question,
|
||||||
)
|
)
|
||||||
contract = {
|
return {
|
||||||
"query_type": query_type,
|
"query_type": query_type,
|
||||||
"answer_contract": (
|
"answer_contract": (
|
||||||
self._internal_answer_contract_for_query_type(query_type)
|
self._internal_answer_contract_for_query_type(query_type)
|
||||||
|
|
@ -85,41 +63,16 @@ class AdaptiveRAGTargetResolutionMixin:
|
||||||
"object_mentions": object_mentions,
|
"object_mentions": object_mentions,
|
||||||
"clarify_policy": "ask_when_target_ambiguous",
|
"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
|
|
||||||
|
|
||||||
@classmethod
|
@staticmethod
|
||||||
def _coalesce_business_frame(
|
def _referenced_objects(query_semantics: Optional[dict[str, Any]]) -> set[str]:
|
||||||
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 {}
|
semantics = query_semantics if isinstance(query_semantics, dict) else {}
|
||||||
contract = operation_contract if isinstance(operation_contract, dict) else {}
|
query_profile = semantics.get("query_profile") if isinstance(semantics.get("query_profile"), dict) else {}
|
||||||
rows: list[dict[str, Any]] = []
|
return {
|
||||||
if isinstance(semantics.get("business_frame"), dict):
|
str(item or "").strip().lower()
|
||||||
rows.append(dict(semantics.get("business_frame") or {}))
|
for item in list(query_profile.get("referenced_objects") or [])
|
||||||
rows.append(dict(semantics))
|
if str(item or "").strip()
|
||||||
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(
|
def _build_session_followup_target(
|
||||||
self,
|
self,
|
||||||
|
|
@ -146,237 +99,32 @@ class AdaptiveRAGTargetResolutionMixin:
|
||||||
if len(entity_ids) != 1:
|
if len(entity_ids) != 1:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
business_frame = self._coalesce_business_frame(
|
subject_focus = str(semantics.get("subject_focus") or "unknown").strip().lower()
|
||||||
query_semantics=semantics,
|
referenced_objects = self._referenced_objects(semantics)
|
||||||
operation_contract=operation_contract,
|
if subject_focus != "project" and "project_object" not in referenced_objects:
|
||||||
)
|
|
||||||
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
|
return None
|
||||||
|
|
||||||
mentions = list(operation_contract.get("object_mentions") or [])
|
mentions = list(operation_contract.get("object_mentions") or [])
|
||||||
resolved_label = (
|
resolved_label = (
|
||||||
str(business_frame.get("subject_binding") or "").strip()
|
str(followup_context.get("last_subject") or "").strip()
|
||||||
or str(followup_context.get("last_subject") or "").strip()
|
|
||||||
or (str(mentions[0]).strip() if mentions else "")
|
or (str(mentions[0]).strip() if mentions else "")
|
||||||
or None
|
or None
|
||||||
)
|
)
|
||||||
resolved_project_id = entity_ids[0]
|
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 {
|
return {
|
||||||
"target_entity_type": "project",
|
"target_entity_type": "project",
|
||||||
"target_ref": f"project:{project_binding}",
|
"target_ref": f"project:{resolved_project_id}",
|
||||||
"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,
|
"resolved_label": resolved_label,
|
||||||
"resolution_mode": "semantic_filter",
|
"resolution_mode": "deterministic",
|
||||||
"binding_source": binding_source,
|
"binding_source": "session_followup_context",
|
||||||
"confidence": round(max(0.0, min(1.0, float(confidence))), 4),
|
"confidence": 0.93 if rewrite_mode == "state_subject" else 0.88,
|
||||||
"candidate_summary": [],
|
"candidate_summary": [],
|
||||||
}
|
}
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def _normalize_target_candidate(cls, payload: Optional[dict[str, Any]]) -> Optional[dict[str, Any]]:
|
def _normalize_target_candidate(cls, payload: Optional[dict[str, Any]]) -> Optional[dict[str, Any]]:
|
||||||
row = payload if isinstance(payload, dict) else {}
|
row = payload if isinstance(payload, dict) else {}
|
||||||
entity_type = cls._normalize_target_entity_type_name(
|
entity_type = str(row.get("target_entity_type") or row.get("entity_type") or "").strip().lower()
|
||||||
row.get("target_entity_type") or row.get("entity_type")
|
|
||||||
)
|
|
||||||
if entity_type not in cls._TARGET_ENTITY_TYPES:
|
if entity_type not in cls._TARGET_ENTITY_TYPES:
|
||||||
return None
|
return None
|
||||||
score = max(0.0, min(1.0, float(row.get("score") or 0.0)))
|
score = max(0.0, min(1.0, float(row.get("score") or 0.0)))
|
||||||
|
|
@ -595,46 +343,39 @@ class AdaptiveRAGTargetResolutionMixin:
|
||||||
query_semantics: Optional[dict[str, Any]],
|
query_semantics: Optional[dict[str, Any]],
|
||||||
operation_contract: dict[str, Any],
|
operation_contract: dict[str, Any],
|
||||||
) -> list[dict[str, Any]]:
|
) -> list[dict[str, Any]]:
|
||||||
target_ref = self._config_target_ref_from_business_frame(
|
semantics = query_semantics if isinstance(query_semantics, dict) else {}
|
||||||
query_semantics=query_semantics,
|
query_profile = semantics.get("query_profile") if isinstance(semantics.get("query_profile"), dict) else {}
|
||||||
operation_contract=operation_contract,
|
referenced_objects = {
|
||||||
)
|
str(item or "").strip().lower()
|
||||||
requested_config_domain = self._config_domain_requested(
|
for item in list(query_profile.get("referenced_objects") or [])
|
||||||
query_semantics=query_semantics,
|
if str(item or "").strip()
|
||||||
operation_contract=operation_contract,
|
}
|
||||||
)
|
if not {"model_routing", "system_config"}.intersection(referenced_objects):
|
||||||
if not target_ref and not requested_config_domain:
|
|
||||||
return []
|
return []
|
||||||
mentions = list(operation_contract.get("object_mentions") or [])
|
mentions = list(operation_contract.get("object_mentions") or [])
|
||||||
rows: list[dict[str, Any]] = []
|
rows: list[dict[str, Any]] = []
|
||||||
if target_ref:
|
if "model_routing" in referenced_objects:
|
||||||
rows.append(
|
rows.append(
|
||||||
{
|
{
|
||||||
"target_entity_type": "config",
|
"target_entity_type": "config",
|
||||||
"target_ref": target_ref,
|
"target_ref": "config:model_routing",
|
||||||
"resolved_label": self._config_target_label(target_ref, mentions[0] if mentions else None),
|
"resolved_label": mentions[0] if mentions else "模型配置",
|
||||||
"score": 0.9 if target_ref.endswith("model_routing") else 0.88 if target_ref.endswith("mcp_template_center") else 0.86,
|
"score": 0.9,
|
||||||
"source_kind": "system_config",
|
"source_kind": "system_config",
|
||||||
"match_label": mentions[0] if mentions else self._config_target_label(target_ref),
|
"match_label": mentions[0] if mentions else "模型配置",
|
||||||
"source_confidence": 0.9 if target_ref.endswith("model_routing") else 0.88 if target_ref.endswith("mcp_template_center") else 0.86,
|
"source_confidence": 0.9,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
return rows
|
elif "system_config" in referenced_objects:
|
||||||
|
|
||||||
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(
|
rows.append(
|
||||||
{
|
{
|
||||||
"target_entity_type": "config",
|
"target_entity_type": "config",
|
||||||
"target_ref": candidate_ref,
|
"target_ref": "config:system",
|
||||||
"resolved_label": label,
|
"resolved_label": mentions[0] if mentions else "系统配置",
|
||||||
"score": 0.88 if candidate_ref.endswith("model_routing") else 0.87,
|
"score": 0.86,
|
||||||
"source_kind": "system_config",
|
"source_kind": "system_config",
|
||||||
"match_label": mentions[0] if mentions else label,
|
"match_label": mentions[0] if mentions else "系统配置",
|
||||||
"source_confidence": 0.88 if candidate_ref.endswith("model_routing") else 0.87,
|
"source_confidence": 0.86,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
return rows
|
return rows
|
||||||
|
|
@ -756,25 +497,13 @@ class AdaptiveRAGTargetResolutionMixin:
|
||||||
if normalized is not None:
|
if normalized is not None:
|
||||||
rows.append(normalized)
|
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]] = {}
|
deduped: dict[str, dict[str, Any]] = {}
|
||||||
for row in rows:
|
for row in rows:
|
||||||
key = self._target_candidate_identity(row)
|
key = self._target_candidate_identity(row)
|
||||||
existing = deduped.get(key)
|
existing = deduped.get(key)
|
||||||
if existing is None or float(row.get("score") or 0.0) > float(existing.get("score") or 0.0):
|
if existing is None or float(row.get("score") or 0.0) > float(existing.get("score") or 0.0):
|
||||||
deduped[key] = row
|
deduped[key] = row
|
||||||
filtered = list(deduped.values())
|
return sorted(deduped.values(), key=lambda item: float(item.get("score") or 0.0), reverse=True)[:8]
|
||||||
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(
|
async def _rerank_internal_target_candidates(
|
||||||
self,
|
self,
|
||||||
|
|
@ -851,46 +580,27 @@ class AdaptiveRAGTargetResolutionMixin:
|
||||||
operation_contract: dict[str, Any],
|
operation_contract: dict[str, Any],
|
||||||
) -> Optional[dict[str, Any]]:
|
) -> Optional[dict[str, Any]]:
|
||||||
semantics = query_semantics if isinstance(query_semantics, dict) else {}
|
semantics = query_semantics if isinstance(query_semantics, dict) else {}
|
||||||
business_frame = self._coalesce_business_frame(
|
subject_focus = str(semantics.get("subject_focus") or "unknown").strip().lower()
|
||||||
query_semantics=semantics,
|
query_profile = semantics.get("query_profile") if isinstance(semantics.get("query_profile"), dict) else {}
|
||||||
operation_contract=operation_contract,
|
mentions = list(operation_contract.get("object_mentions") or [])
|
||||||
)
|
resolved_label = mentions[0] if mentions else None
|
||||||
project_bound = self._build_project_target_from_business_frame(
|
referenced_objects = {
|
||||||
query_semantics=semantics,
|
str(item or "").strip().lower()
|
||||||
operation_contract=operation_contract,
|
for item in list(query_profile.get("referenced_objects") or [])
|
||||||
)
|
if str(item or "").strip()
|
||||||
if project_bound is not None:
|
}
|
||||||
return project_bound
|
|
||||||
config_ref = self._config_target_ref_from_business_frame(
|
if {"model_routing", "system_config"}.intersection(referenced_objects):
|
||||||
query_semantics=semantics,
|
target_ref = "config:model_routing" if "model_routing" in referenced_objects else "config:system"
|
||||||
operation_contract=operation_contract,
|
|
||||||
)
|
|
||||||
if config_ref:
|
|
||||||
mentions = list(operation_contract.get("object_mentions") or [])
|
|
||||||
return {
|
return {
|
||||||
"target_entity_type": "config",
|
"target_entity_type": "config",
|
||||||
"target_ref": config_ref,
|
"target_ref": target_ref,
|
||||||
"resolved_label": self._config_target_label(config_ref, mentions[0] if mentions else None),
|
"resolved_label": resolved_label or ("模型配置" if target_ref.endswith("model_routing") else "系统配置"),
|
||||||
"resolution_mode": "deterministic",
|
"resolution_mode": "deterministic",
|
||||||
"confidence": 0.84,
|
"confidence": 0.84,
|
||||||
"candidate_summary": [],
|
"candidate_summary": [],
|
||||||
}
|
}
|
||||||
mentions = list(operation_contract.get("object_mentions") or [])
|
if subject_focus == "project":
|
||||||
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 {
|
return {
|
||||||
"target_entity_type": "project",
|
"target_entity_type": "project",
|
||||||
"target_ref": None,
|
"target_ref": None,
|
||||||
|
|
@ -899,6 +609,15 @@ class AdaptiveRAGTargetResolutionMixin:
|
||||||
"confidence": 0.78 if resolved_label else 0.64,
|
"confidence": 0.78 if resolved_label else 0.64,
|
||||||
"candidate_summary": [],
|
"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
|
return None
|
||||||
|
|
||||||
async def _resolve_internal_target(
|
async def _resolve_internal_target(
|
||||||
|
|
@ -969,22 +688,6 @@ class AdaptiveRAGTargetResolutionMixin:
|
||||||
if reranked is not None:
|
if reranked is not None:
|
||||||
return reranked
|
return reranked
|
||||||
if float(top.get("score") or 0.0) >= 0.88 or margin >= 0.12 or second is None:
|
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 {
|
return {
|
||||||
"target_entity_type": str(top.get("target_entity_type") or "unknown"),
|
"target_entity_type": str(top.get("target_entity_type") or "unknown"),
|
||||||
"target_ref": top.get("target_ref"),
|
"target_ref": top.get("target_ref"),
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,6 @@ from .mixin_answer_mainline import AdaptiveRAGAnswerMainlineMixin
|
||||||
from .mixin_answer_precheck import AdaptiveRAGAnswerPrecheckMixin
|
from .mixin_answer_precheck import AdaptiveRAGAnswerPrecheckMixin
|
||||||
from .mixin_answer_retrieval import AdaptiveRAGAnswerRetrievalMixin
|
from .mixin_answer_retrieval import AdaptiveRAGAnswerRetrievalMixin
|
||||||
from .mixin_answer_payload import AdaptiveRAGAnswerPayloadMixin
|
from .mixin_answer_payload import AdaptiveRAGAnswerPayloadMixin
|
||||||
from .mixin_project_fit_brief import AdaptiveRAGProjectFitBriefMixin
|
|
||||||
from .mixin_requirement_ack_core import AdaptiveRAGRequirementAckCoreMixin
|
from .mixin_requirement_ack_core import AdaptiveRAGRequirementAckCoreMixin
|
||||||
from .mixin_requirement_ack_flow import AdaptiveRAGRequirementAckFlowMixin
|
from .mixin_requirement_ack_flow import AdaptiveRAGRequirementAckFlowMixin
|
||||||
from .mixin_deterministic_query_core import AdaptiveRAGDeterministicQueryCoreMixin
|
from .mixin_deterministic_query_core import AdaptiveRAGDeterministicQueryCoreMixin
|
||||||
|
|
@ -54,7 +53,6 @@ class AdaptiveRAGService(
|
||||||
AdaptiveRAGAnswerPrecheckMixin,
|
AdaptiveRAGAnswerPrecheckMixin,
|
||||||
AdaptiveRAGAnswerRetrievalMixin,
|
AdaptiveRAGAnswerRetrievalMixin,
|
||||||
AdaptiveRAGAnswerPayloadMixin,
|
AdaptiveRAGAnswerPayloadMixin,
|
||||||
AdaptiveRAGProjectFitBriefMixin,
|
|
||||||
AdaptiveRAGRequirementAckCoreMixin,
|
AdaptiveRAGRequirementAckCoreMixin,
|
||||||
AdaptiveRAGRequirementAckFlowMixin,
|
AdaptiveRAGRequirementAckFlowMixin,
|
||||||
AdaptiveRAGDeterministicQueryCoreMixin,
|
AdaptiveRAGDeterministicQueryCoreMixin,
|
||||||
|
|
|
||||||
|
|
@ -2,50 +2,6 @@
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from .shared_imports import *
|
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:
|
def _env_int(name: str, default: int, *, minimum: int, maximum: int) -> int:
|
||||||
raw = str(os.getenv(name, "") or "").strip()
|
raw = str(os.getenv(name, "") or "").strip()
|
||||||
|
|
@ -58,179 +14,6 @@ def _env_int(name: str, default: int, *, minimum: int, maximum: int) -> int:
|
||||||
value = int(default)
|
value = int(default)
|
||||||
return max(minimum, min(maximum, value))
|
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",
|
|
||||||
)
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
frame: dict[str, Any] = {}
|
__all__ = ["_env_int"]
|
||||||
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",
|
|
||||||
]
|
|
||||||
|
|
|
||||||
|
|
@ -528,52 +528,21 @@ class ReplyRenderer:
|
||||||
if mutation_success is not None:
|
if mutation_success is not None:
|
||||||
return mutation_success
|
return mutation_success
|
||||||
if intent.name == INTENT_SIGNAL_SOURCE_ONBOARD:
|
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"))
|
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 {}
|
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()
|
status = str(workflow.get("status") or (execution_result.get("signal_source_orchestration") or {}).get("status") or "").strip()
|
||||||
post_process_status = str(execution_result.get("post_process_status") or "").strip().lower()
|
if status == "pending_manual":
|
||||||
if work_state == self.state_waiting_manual:
|
|
||||||
return {
|
return {
|
||||||
"receipt_phase": self.receipt_phase_pending,
|
"receipt_phase": self.receipt_phase_pending,
|
||||||
"template_key": "pending_manual",
|
"template_key": "pending_manual",
|
||||||
"payload": {"intent": intent.name, "signal_id": primary_signal_id, "signal_ids": signal_ids, "status": status},
|
"payload": {"intent": intent.name, "signal_id": signal_id, "status": status},
|
||||||
"text": f"已记录信号源接入请求,当前为待人工确认。信号#{primary_signal_id} 已入库。"
|
"text": f"已记录信号源接入请求,当前为待人工确认。信号#{signal_id} 已入库。" if signal_id else "已记录信号源接入请求,当前为待人工确认。",
|
||||||
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 {
|
return {
|
||||||
"receipt_phase": self.receipt_phase_action,
|
"receipt_phase": self.receipt_phase_action,
|
||||||
"template_key": "action_succeeded",
|
"template_key": "action_succeeded",
|
||||||
"payload": {
|
"payload": {"intent": intent.name, "signal_id": signal_id, "status": status},
|
||||||
"intent": intent.name,
|
"text": f"已接收入库并启动信号源接入流程。信号#{signal_id},状态:{status or 'running'}。",
|
||||||
"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()
|
workflow_route = str(execution_result.get("workflow_route") or "").strip().lower()
|
||||||
followup_text = str(execution_result.get("followup_reply") or "").strip()
|
followup_text = str(execution_result.get("followup_reply") or "").strip()
|
||||||
|
|
|
||||||
|
|
@ -25,67 +25,20 @@ class TypedExecutorParseMixin:
|
||||||
*,
|
*,
|
||||||
intent: CanonicalIntent,
|
intent: CanonicalIntent,
|
||||||
message_text: str,
|
message_text: str,
|
||||||
candidate_materialization: Optional[dict[str, Any]] = None,
|
|
||||||
candidate_set_binding: Optional[str] = None,
|
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
metadata = intent.metadata if isinstance(intent.metadata, dict) else {}
|
metadata = intent.metadata if isinstance(intent.metadata, dict) else {}
|
||||||
modules = metadata.get("closure_modules") if isinstance(metadata.get("closure_modules"), list) 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]
|
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(
|
summary = cls._normalize_short_text(
|
||||||
candidate_business_value
|
metadata.get("closure_summary") or intent.signal_summary or message_text,
|
||||||
or materialization_payload.get("summary")
|
|
||||||
or metadata.get("closure_summary")
|
|
||||||
or intent.signal_summary
|
|
||||||
or message_text,
|
|
||||||
max_len=300,
|
max_len=300,
|
||||||
)
|
)
|
||||||
dialogue = cls._normalize_short_text(
|
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,
|
max_len=1800,
|
||||||
)
|
)
|
||||||
title_seed = (
|
title_seed = str(intent.signal_title or "").strip() or str(message_text or "").strip() or "信号源接入请求"
|
||||||
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 统一入库。"]
|
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:
|
if normalized_modules:
|
||||||
reasoning_bullets.append(f"闭环提炼模块:{'、'.join(normalized_modules[:4])}")
|
reasoning_bullets.append(f"闭环提炼模块:{'、'.join(normalized_modules[:4])}")
|
||||||
if summary:
|
if summary:
|
||||||
|
|
@ -94,10 +47,6 @@ class TypedExecutorParseMixin:
|
||||||
"source_context": {
|
"source_context": {
|
||||||
"conversation_closure": dialogue or None,
|
"conversation_closure": dialogue or None,
|
||||||
"implementation_modules": normalized_modules,
|
"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": {
|
"ai_analysis": {
|
||||||
"summary": summary or None,
|
"summary": summary or None,
|
||||||
|
|
@ -195,37 +144,51 @@ class TypedExecutorParseMixin:
|
||||||
return payload if isinstance(payload, dict) else {}
|
return payload if isinstance(payload, dict) else {}
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _resolve_structured_requested_count(metadata: dict[str, Any]) -> Optional[int]:
|
def _parse_chinese_count_token(token: str) -> Optional[int]:
|
||||||
if not isinstance(metadata, dict):
|
cleaned = str(token or "").strip()
|
||||||
|
if not cleaned:
|
||||||
return None
|
return None
|
||||||
direct = _safe_int(metadata.get("requested_count"))
|
map_digit = {
|
||||||
if direct is not None and direct > 0:
|
"零": 0,
|
||||||
return direct
|
"一": 1,
|
||||||
operation_contract = metadata.get("operation_intent_contract")
|
"二": 2,
|
||||||
if isinstance(operation_contract, dict):
|
"两": 2,
|
||||||
contract_direct = _safe_int(operation_contract.get("requested_count"))
|
"三": 3,
|
||||||
if contract_direct is not None and contract_direct > 0:
|
"四": 4,
|
||||||
return contract_direct
|
"五": 5,
|
||||||
contract_meta = operation_contract.get("metadata")
|
"六": 6,
|
||||||
if isinstance(contract_meta, dict):
|
"七": 7,
|
||||||
contract_meta_count = _safe_int(contract_meta.get("requested_count"))
|
"八": 8,
|
||||||
if contract_meta_count is not None and contract_meta_count > 0:
|
"九": 9,
|
||||||
return contract_meta_count
|
"十": 10,
|
||||||
business_frame = metadata.get("business_frame")
|
}
|
||||||
if isinstance(business_frame, dict):
|
if cleaned in map_digit:
|
||||||
frame_meta = business_frame.get("metadata")
|
return map_digit[cleaned]
|
||||||
if isinstance(frame_meta, dict):
|
if len(cleaned) == 2 and cleaned[0] == "十" and cleaned[1] in map_digit:
|
||||||
frame_count = _safe_int(frame_meta.get("requested_count"))
|
return 10 + map_digit[cleaned[1]]
|
||||||
if frame_count is not None and frame_count > 0:
|
if len(cleaned) == 2 and cleaned[1] == "十" and cleaned[0] in map_digit:
|
||||||
return frame_count
|
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]]
|
||||||
return None
|
return None
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def _extract_requested_signal_count(cls, *, message_text: str, metadata: dict[str, Any]) -> int:
|
def _extract_requested_signal_count(cls, *, message_text: str, metadata: dict[str, Any]) -> int:
|
||||||
_ = message_text
|
metadata_count = _safe_int(metadata.get("requested_count")) if isinstance(metadata, dict) else None
|
||||||
metadata_count = cls._resolve_structured_requested_count(metadata if isinstance(metadata, dict) else {})
|
|
||||||
if metadata_count is not None and metadata_count > 0:
|
if metadata_count is not None and metadata_count > 0:
|
||||||
return min(max(metadata_count, 1), 5)
|
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
|
return 1
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
|
|
@ -234,39 +197,15 @@ class TypedExecutorParseMixin:
|
||||||
*,
|
*,
|
||||||
message_text: str,
|
message_text: str,
|
||||||
requested_count: int,
|
requested_count: int,
|
||||||
metadata: Optional[dict[str, Any]] = None,
|
|
||||||
) -> bool:
|
) -> bool:
|
||||||
_ = (message_text, requested_count)
|
normalized = re.sub(r"\s+", "", str(message_text or "")).lower()
|
||||||
payload = metadata if isinstance(metadata, dict) else {}
|
if not normalized:
|
||||||
direct_mode = str(payload.get("analysis_mode") or "").strip().lower()
|
return False
|
||||||
if direct_mode in {"strict", "analyze_first"}:
|
if re.search(r"(先(?:自行)?分析(?:再|后)|先分析后入库|自行分析)", normalized, re.IGNORECASE):
|
||||||
return True
|
return True
|
||||||
if bool(payload.get("strict_signal_analysis")):
|
if re.search(r"(分析|拆分).{0,12}(后|完).{0,8}(入库|新增|创建|加入|录入)", normalized, re.IGNORECASE):
|
||||||
return True
|
return True
|
||||||
operation_contract = payload.get("operation_intent_contract")
|
return requested_count > 1 and "分析" in normalized and any(token in normalized for token in ("信号", "需求"))
|
||||||
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
|
@classmethod
|
||||||
def _normalize_signal_batch_items(
|
def _normalize_signal_batch_items(
|
||||||
|
|
|
||||||
|
|
@ -53,59 +53,6 @@ class KernelFollowupProjectSignalResumeMixin:
|
||||||
"closure_modules": payload.get("closure_modules"),
|
"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 {
|
if target_intent.name not in FOLLOWUP_TARGET_INTENTS and workflow_route.family not in {
|
||||||
ROUTE_FAMILY_PROJECT_SIGNAL,
|
ROUTE_FAMILY_PROJECT_SIGNAL,
|
||||||
ROUTE_FAMILY_SIGNAL_SOURCE,
|
ROUTE_FAMILY_SIGNAL_SOURCE,
|
||||||
|
|
|
||||||
|
|
@ -11,27 +11,6 @@ for _name, _value in vars(_common).items():
|
||||||
|
|
||||||
|
|
||||||
class KernelFollowupVerificationMixin:
|
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(
|
async def _verify_execution(
|
||||||
self,
|
self,
|
||||||
*,
|
*,
|
||||||
|
|
@ -42,10 +21,7 @@ class KernelFollowupVerificationMixin:
|
||||||
normalized_status = str(execution_result.get("status") or "").strip().lower()
|
normalized_status = str(execution_result.get("status") or "").strip().lower()
|
||||||
if normalized_status == "failed_safe":
|
if normalized_status == "failed_safe":
|
||||||
return {"verified": False, "issues": ["executor_failed"]}
|
return {"verified": False, "issues": ["executor_failed"]}
|
||||||
if normalized_status in {"pending_manual", "pending_confirmation"} and not (
|
if normalized_status in {"pending_manual", "pending_confirmation"}:
|
||||||
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"]}
|
return {"verified": False, "issues": ["pending_manual"]}
|
||||||
if normalized_status == "cancelled":
|
if normalized_status == "cancelled":
|
||||||
return {"verified": True, "issues": []}
|
return {"verified": True, "issues": []}
|
||||||
|
|
@ -128,10 +104,7 @@ class KernelFollowupVerificationMixin:
|
||||||
return {"verified": False, "issues": ["signal_missing"]}
|
return {"verified": False, "issues": ["signal_missing"]}
|
||||||
if intent.name == INTENT_SIGNAL_SOURCE_ONBOARD:
|
if intent.name == INTENT_SIGNAL_SOURCE_ONBOARD:
|
||||||
workflow = execution_result.get("signal_source_workflow") if isinstance(execution_result.get("signal_source_workflow"), dict) else {}
|
workflow = execution_result.get("signal_source_workflow") if isinstance(execution_result.get("signal_source_workflow"), dict) else {}
|
||||||
if (
|
if str(workflow.get("status") or "").lower() != "completed":
|
||||||
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"]}
|
return {"verified": False, "issues": ["signal_source_pending_manual"]}
|
||||||
if intent.project_id is None:
|
if intent.project_id is None:
|
||||||
return {"verified": True, "issues": []}
|
return {"verified": True, "issues": []}
|
||||||
|
|
|
||||||
|
|
@ -128,21 +128,6 @@ class KernelIngressStorageMixin:
|
||||||
source_message_id: str,
|
source_message_id: str,
|
||||||
channel_ref: Optional[str],
|
channel_ref: Optional[str],
|
||||||
) -> dict[str, Any]:
|
) -> 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 {}
|
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 []
|
actions = execution_plan.get("actions") if isinstance(execution_plan.get("actions"), list) else []
|
||||||
route_key = str(getattr(workflow_route, "key", "") or "").strip().lower()
|
route_key = str(getattr(workflow_route, "key", "") or "").strip().lower()
|
||||||
|
|
@ -184,21 +169,6 @@ class KernelIngressStorageMixin:
|
||||||
"signal_ids": signal_ids,
|
"signal_ids": signal_ids,
|
||||||
"task_id": _safe_int(execution_result.get("task_id")),
|
"task_id": _safe_int(execution_result.get("task_id")),
|
||||||
"target_work_item_id": _safe_int(execution_result.get("target_work_item_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_system": str(source_system or "").strip() or None,
|
||||||
"source_message_id": str(source_message_id or "").strip() or None,
|
"source_message_id": str(source_message_id or "").strip() or None,
|
||||||
"channel_ref": str(channel_ref or "").strip() or None,
|
"channel_ref": str(channel_ref or "").strip() or None,
|
||||||
|
|
@ -215,9 +185,6 @@ class KernelIngressStorageMixin:
|
||||||
"workflow_action": str(getattr(workflow_route, "action", "") or "").strip().lower() or None,
|
"workflow_action": str(getattr(workflow_route, "action", "") or "").strip().lower() or None,
|
||||||
"param_source": "decision.execution_plan.actions+workflow_route+execution_result",
|
"param_source": "decision.execution_plan.actions+workflow_route+execution_result",
|
||||||
"params": normalized_params,
|
"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(
|
def _apply_execution_action_contract(
|
||||||
|
|
|
||||||
|
|
@ -2,9 +2,6 @@
|
||||||
|
|
||||||
from __future__ import annotations
|
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 .... import ai_pmo_kernel_common_core as _common
|
||||||
from ....semantic_context.semantic_decision_service import SemanticDecisionService
|
from ....semantic_context.semantic_decision_service import SemanticDecisionService
|
||||||
from ....workflow_semantic_planner import WorkflowSemanticPlanner
|
from ....workflow_semantic_planner import WorkflowSemanticPlanner
|
||||||
|
|
@ -15,97 +12,6 @@ for _name, _value in vars(_common).items():
|
||||||
|
|
||||||
|
|
||||||
class KernelIntentResolveMixin:
|
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
|
@staticmethod
|
||||||
def _infer_workflow_source_system(
|
def _infer_workflow_source_system(
|
||||||
*,
|
*,
|
||||||
|
|
@ -308,11 +214,6 @@ class KernelIntentResolveMixin:
|
||||||
session=session,
|
session=session,
|
||||||
)
|
)
|
||||||
fallback_project_id = _safe_int(getattr(session, "current_project_id", None)) if inherit_session_context else None
|
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)
|
workflow_bundle_override = WorkflowSemanticPlanner.deserialize_bundle(semantic_bundle_override)
|
||||||
if workflow_bundle_override is not None:
|
if workflow_bundle_override is not None:
|
||||||
return (
|
return (
|
||||||
|
|
@ -325,25 +226,18 @@ class KernelIntentResolveMixin:
|
||||||
"workflow_bundle",
|
"workflow_bundle",
|
||||||
)
|
)
|
||||||
planner_metadata = {
|
planner_metadata = {
|
||||||
"project_id": _safe_int(world_model.get("resolved_project_id")) or anchor_project_id,
|
"project_id": _safe_int(world_model.get("resolved_project_id")),
|
||||||
"world_model_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")),
|
||||||
"session_last_intent": (
|
"session_last_intent": (
|
||||||
str(getattr(session, "last_intent", "") or "").strip() if inherit_session_context else ""
|
str(getattr(session, "last_intent", "") or "").strip() if inherit_session_context else ""
|
||||||
),
|
),
|
||||||
"session_project_id": fallback_project_id or anchor_project_id,
|
"session_project_id": fallback_project_id,
|
||||||
"reply_to_message_id": (
|
"reply_to_message_id": (
|
||||||
str(thread_context.get("reply_to_message_id") or "").strip()
|
str(thread_context.get("reply_to_message_id") or "").strip()
|
||||||
if isinstance(thread_context, dict)
|
if isinstance(thread_context, dict)
|
||||||
else ""
|
else ""
|
||||||
),
|
),
|
||||||
"thread_context": {
|
"thread_context": dict(thread_context) if isinstance(thread_context, dict) else {},
|
||||||
**(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",
|
"source_message_context": "reply_anchor" if reply_anchor_present else "cold_start",
|
||||||
}
|
}
|
||||||
try:
|
try:
|
||||||
|
|
|
||||||
|
|
@ -12,18 +12,6 @@ for _name, _value in vars(_common).items():
|
||||||
|
|
||||||
|
|
||||||
class KernelWorldModelSessionMixin:
|
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
|
@staticmethod
|
||||||
def _within_recent_window(last_active_at: Optional[datetime], *, window_seconds: int) -> bool:
|
def _within_recent_window(last_active_at: Optional[datetime], *, window_seconds: int) -> bool:
|
||||||
if not isinstance(last_active_at, datetime):
|
if not isinstance(last_active_at, datetime):
|
||||||
|
|
|
||||||
|
|
@ -4,14 +4,6 @@ from .shared import *
|
||||||
from ..ai_pmo_kernel_common_core.helpers import _safe_int as _safe_int
|
from ..ai_pmo_kernel_common_core.helpers import _safe_int as _safe_int
|
||||||
|
|
||||||
class KernelIngressFlowAutoMixin:
|
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(
|
async def _execute_auto_ingress_flow(
|
||||||
self,
|
self,
|
||||||
*,
|
*,
|
||||||
|
|
@ -435,11 +427,6 @@ class KernelIngressFlowAutoMixin:
|
||||||
return False
|
return False
|
||||||
if str(getattr(workflow_route, "family", "") or "") == ROUTE_FAMILY_PROJECT_SIGNAL:
|
if str(getattr(workflow_route, "family", "") or "") == ROUTE_FAMILY_PROJECT_SIGNAL:
|
||||||
return str(getattr(workflow_route, "action", "") or "") == ROUTE_ACTION_CREATE
|
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
|
return True
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
|
|
|
||||||
|
|
@ -16,7 +16,6 @@ from app.services.ai_pmo.workflow_semantic_planner.constants import (
|
||||||
QUERY_SCENE_CORRECTION_ESCALATION,
|
QUERY_SCENE_CORRECTION_ESCALATION,
|
||||||
QUERY_SCENE_REQUIREMENT_PLAN,
|
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
|
from app.services.mounted_ai_runtime_write_service import MountedAIRuntimeWriteService
|
||||||
|
|
||||||
class KernelIngressFlowEntryMixin:
|
class KernelIngressFlowEntryMixin:
|
||||||
|
|
@ -42,64 +41,6 @@ class KernelIngressFlowEntryMixin:
|
||||||
def _normalize_compact_text(value: Any) -> str:
|
def _normalize_compact_text(value: Any) -> str:
|
||||||
return re.sub(r"\s+", "", str(value or "")).strip().lower()
|
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
|
@classmethod
|
||||||
def _payload_has_external_freshness_guard_source(cls, value: Any) -> bool:
|
def _payload_has_external_freshness_guard_source(cls, value: Any) -> bool:
|
||||||
if isinstance(value, str):
|
if isinstance(value, str):
|
||||||
|
|
@ -252,62 +193,6 @@ class KernelIngressFlowEntryMixin:
|
||||||
compact = cls._normalize_compact_text(message_text)
|
compact = cls._normalize_compact_text(message_text)
|
||||||
return bool(compact) and len(compact) <= 24
|
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
|
@staticmethod
|
||||||
def _is_query_pending_clarification_expired(expires_at: Any) -> bool:
|
def _is_query_pending_clarification_expired(expires_at: Any) -> bool:
|
||||||
if expires_at is None:
|
if expires_at is None:
|
||||||
|
|
@ -490,14 +375,6 @@ class KernelIngressFlowEntryMixin:
|
||||||
return None
|
return None
|
||||||
if pending_context is None:
|
if pending_context is None:
|
||||||
return 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_service = AdaptiveRAGService(self.db, tenant_id=self.tenant_id)
|
||||||
rag_kwargs = {
|
rag_kwargs = {
|
||||||
|
|
@ -708,22 +585,6 @@ class KernelIngressFlowEntryMixin:
|
||||||
for key in ("reply_to_message_id", "root_message_id", "thread_id")
|
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
|
@staticmethod
|
||||||
def _build_semantic_shadow_decision_from_bundle(
|
def _build_semantic_shadow_decision_from_bundle(
|
||||||
*,
|
*,
|
||||||
|
|
@ -998,38 +859,20 @@ class KernelIngressFlowEntryMixin:
|
||||||
return normalized_text, {}
|
return normalized_text, {}
|
||||||
if self._should_skip_mixed_sidecar_classify_for_bundle(semantic_bundle):
|
if self._should_skip_mixed_sidecar_classify_for_bundle(semantic_bundle):
|
||||||
return normalized_text, {}
|
return normalized_text, {}
|
||||||
if self._has_explicit_thread_anchor(thread_context) and self._semantic_bundle_requests_followup_flow(semantic_bundle):
|
if self._has_explicit_thread_anchor(thread_context):
|
||||||
return normalized_text, {}
|
try:
|
||||||
if self._has_explicit_thread_anchor(thread_context) and str(source_system or "").strip().lower() == "feishu":
|
from ..ai_pmo_typed_executor_execute.mixin_execute_query_project import ExecuteQueryProjectMixin
|
||||||
active_requirement_finder = getattr(self, "_find_active_requirement_plan_workspace", None)
|
|
||||||
if callable(active_requirement_finder):
|
if ExecuteQueryProjectMixin._parse_requirement_plan_choice_protocol(normalized_text):
|
||||||
conversation_key = source_message_id
|
return normalized_text, {}
|
||||||
if str(source_system or "").strip().lower() == "feishu":
|
except Exception as exc:
|
||||||
conversation_key = build_feishu_conversation_scope_key(
|
logger.warning(
|
||||||
source_chat_id=channel_ref,
|
"ai_pmo_requirement_choice_protocol_probe_failed",
|
||||||
source_sender_open_id=source_sender_open_id,
|
tenant_id=self.tenant_id,
|
||||||
sender=None,
|
source_system=source_system,
|
||||||
) or source_message_id
|
source_message_id=source_message_id,
|
||||||
try:
|
error=str(exc),
|
||||||
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)
|
rag_service = AdaptiveRAGService(self.db, tenant_id=self.tenant_id)
|
||||||
try:
|
try:
|
||||||
|
|
@ -1419,12 +1262,6 @@ class KernelIngressFlowEntryMixin:
|
||||||
operator = ingress_prepare["operator"]
|
operator = ingress_prepare["operator"]
|
||||||
work_item = ingress_prepare["work_item"]
|
work_item = ingress_prepare["work_item"]
|
||||||
session = ingress_prepare["session"]
|
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(
|
await _safe_begin_mounted_runtime(
|
||||||
runtime_message_id=synthetic_message_id,
|
runtime_message_id=synthetic_message_id,
|
||||||
runtime_message_text=clean_text,
|
runtime_message_text=clean_text,
|
||||||
|
|
@ -1594,13 +1431,6 @@ class KernelIngressFlowEntryMixin:
|
||||||
project_hint_id=project_hint_id,
|
project_hint_id=project_hint_id,
|
||||||
session=session,
|
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)
|
_log_perf("_build_world_model", stage_started_at)
|
||||||
self._schedule_portfolio_snapshot_persist(world_model=world_model)
|
self._schedule_portfolio_snapshot_persist(world_model=world_model)
|
||||||
work_item.state = STATE_MODELED
|
work_item.state = STATE_MODELED
|
||||||
|
|
@ -1609,11 +1439,6 @@ class KernelIngressFlowEntryMixin:
|
||||||
if isinstance(ingress_options, dict)
|
if isinstance(ingress_options, dict)
|
||||||
else None
|
else None
|
||||||
)
|
)
|
||||||
project_operating_context = (
|
|
||||||
ingress_options.get("project_operating_context")
|
|
||||||
if isinstance(ingress_options, dict)
|
|
||||||
else None
|
|
||||||
)
|
|
||||||
stage_started_at = time.perf_counter()
|
stage_started_at = time.perf_counter()
|
||||||
intent, intent_context_source = await self._resolve_intent(
|
intent, intent_context_source = await self._resolve_intent(
|
||||||
message_text=clean_text,
|
message_text=clean_text,
|
||||||
|
|
@ -1647,11 +1472,6 @@ class KernelIngressFlowEntryMixin:
|
||||||
**(intent.metadata or {}),
|
**(intent.metadata or {}),
|
||||||
"rag_request_context": dict(rag_request_context),
|
"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_context_metadata = {
|
||||||
"source_sender_open_id": str(source_sender_open_id or "").strip() or None,
|
"source_sender_open_id": str(source_sender_open_id or "").strip() or None,
|
||||||
"source_chat_id": str(channel_ref or "").strip() or None,
|
"source_chat_id": str(channel_ref or "").strip() or None,
|
||||||
|
|
@ -2146,15 +1966,6 @@ class KernelIngressFlowEntryMixin:
|
||||||
normalized[key] = value[:128]
|
normalized[key] = value[:128]
|
||||||
return normalized
|
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
|
@classmethod
|
||||||
def _has_explicit_draft_reply_anchor(
|
def _has_explicit_draft_reply_anchor(
|
||||||
cls,
|
cls,
|
||||||
|
|
@ -2219,10 +2030,7 @@ class KernelIngressFlowEntryMixin:
|
||||||
return False
|
return False
|
||||||
if str(getattr(session, "last_intent", "") or "").strip() != STATE_DRAFT_PENDING_CONFIRM:
|
if str(getattr(session, "last_intent", "") or "").strip() != STATE_DRAFT_PENDING_CONFIRM:
|
||||||
return False
|
return False
|
||||||
semantic_bundle = cls._extract_semantic_bundle_from_ingress_options(ingress_options)
|
return DraftConfirmHandler.is_explicit_protocol_followup_text(message_text)
|
||||||
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(
|
async def _should_route_to_draft_confirmation_session(
|
||||||
self,
|
self,
|
||||||
|
|
@ -2239,7 +2047,6 @@ class KernelIngressFlowEntryMixin:
|
||||||
|
|
||||||
handler = DraftConfirmHandler(self)
|
handler = DraftConfirmHandler(self)
|
||||||
thread_context = self._extract_thread_context_from_ingress_options(ingress_options)
|
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()
|
normalized_text = str(message_text or "").strip()
|
||||||
has_explicit_thread_anchor = self._has_explicit_thread_anchor(thread_context)
|
has_explicit_thread_anchor = self._has_explicit_thread_anchor(thread_context)
|
||||||
if self._should_route_to_draft_confirmation(
|
if self._should_route_to_draft_confirmation(
|
||||||
|
|
@ -2260,9 +2067,7 @@ class KernelIngressFlowEntryMixin:
|
||||||
source_sender_open_id=source_sender_open_id,
|
source_sender_open_id=source_sender_open_id,
|
||||||
thread_context=thread_context,
|
thread_context=thread_context,
|
||||||
)
|
)
|
||||||
if bool(has_active_draft_session) and (
|
if bool(has_active_draft_session):
|
||||||
semantic_bundle is None or self._semantic_bundle_requests_followup_flow(semantic_bundle)
|
|
||||||
):
|
|
||||||
return True
|
return True
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
@ -2488,6 +2293,24 @@ class KernelIngressFlowEntryMixin:
|
||||||
return "current_requirement"
|
return "current_requirement"
|
||||||
return normalized_scope
|
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(
|
async def _build_active_requirement_mode_exit_result(
|
||||||
self,
|
self,
|
||||||
*,
|
*,
|
||||||
|
|
@ -2529,7 +2352,6 @@ class KernelIngressFlowEntryMixin:
|
||||||
source_sender_open_id: Optional[str],
|
source_sender_open_id: Optional[str],
|
||||||
thread_context: Optional[dict[str, Any]],
|
thread_context: Optional[dict[str, Any]],
|
||||||
conversation_key: Optional[str],
|
conversation_key: Optional[str],
|
||||||
requirement_plan_protocol: Optional[dict[str, Any]] = None,
|
|
||||||
) -> Optional[dict[str, Any]]:
|
) -> Optional[dict[str, Any]]:
|
||||||
builder = getattr(self, "_build_requirement_planning_direct_result", None)
|
builder = getattr(self, "_build_requirement_planning_direct_result", None)
|
||||||
if not callable(builder):
|
if not callable(builder):
|
||||||
|
|
@ -2547,7 +2369,6 @@ class KernelIngressFlowEntryMixin:
|
||||||
thread_context=thread_context,
|
thread_context=thread_context,
|
||||||
conversation_key=conversation_key,
|
conversation_key=conversation_key,
|
||||||
topic_switch=None,
|
topic_switch=None,
|
||||||
requirement_plan_protocol=requirement_plan_protocol,
|
|
||||||
)
|
)
|
||||||
answer = self._extract_direct_answer_text(direct_result)
|
answer = self._extract_direct_answer_text(direct_result)
|
||||||
if not answer:
|
if not answer:
|
||||||
|
|
@ -2607,6 +2428,30 @@ class KernelIngressFlowEntryMixin:
|
||||||
if bound_context is None:
|
if bound_context is None:
|
||||||
return None
|
return None
|
||||||
topic, draft, active_context_kind = bound_context
|
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:
|
try:
|
||||||
packet = await SemanticContextAssembler(self.db).assemble(
|
packet = await SemanticContextAssembler(self.db).assemble(
|
||||||
tenant_id=int(self.tenant_id),
|
tenant_id=int(self.tenant_id),
|
||||||
|
|
@ -2650,14 +2495,6 @@ class KernelIngressFlowEntryMixin:
|
||||||
for item in (getattr(gate_result, "allowed_actions", None) or [])
|
for item in (getattr(gate_result, "allowed_actions", None) or [])
|
||||||
if str(item or "").strip()
|
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 = self._resolve_active_context_followup_scope(
|
||||||
followup_scope=str(getattr(decision, "followup_scope", "") or ""),
|
followup_scope=str(getattr(decision, "followup_scope", "") or ""),
|
||||||
active_context_kind=active_context_kind,
|
active_context_kind=active_context_kind,
|
||||||
|
|
@ -2669,14 +2506,6 @@ class KernelIngressFlowEntryMixin:
|
||||||
if followup_scope == "current_requirement":
|
if followup_scope == "current_requirement":
|
||||||
if "context.followup.current_requirement" not in allowed_actions:
|
if "context.followup.current_requirement" not in allowed_actions:
|
||||||
return None
|
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(
|
direct_result = await self._build_active_requirement_followup_result(
|
||||||
topic_workspace_id=_safe_int(getattr(topic, "id", None)),
|
topic_workspace_id=_safe_int(getattr(topic, "id", None)),
|
||||||
message_text=message_text,
|
message_text=message_text,
|
||||||
|
|
@ -2686,7 +2515,6 @@ class KernelIngressFlowEntryMixin:
|
||||||
source_sender_open_id=source_sender_open_id,
|
source_sender_open_id=source_sender_open_id,
|
||||||
thread_context=thread_context,
|
thread_context=thread_context,
|
||||||
conversation_key=conversation_key,
|
conversation_key=conversation_key,
|
||||||
requirement_plan_protocol=requirement_plan_protocol,
|
|
||||||
)
|
)
|
||||||
if direct_result is not None:
|
if direct_result is not None:
|
||||||
return direct_result
|
return direct_result
|
||||||
|
|
|
||||||
|
|
@ -2,16 +2,10 @@
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
from .shared import * # noqa: F401, F403
|
from .shared import * # noqa: F401, F403
|
||||||
|
|
||||||
|
|
||||||
class KernelWorldModelCoreBuilderMixin:
|
class KernelWorldModelCoreBuilderMixin:
|
||||||
@staticmethod
|
|
||||||
def _build_repo_scope() -> str:
|
|
||||||
return Path(__file__).resolve().parents[4].name
|
|
||||||
|
|
||||||
async def build_world_model(
|
async def build_world_model(
|
||||||
self,
|
self,
|
||||||
*,
|
*,
|
||||||
|
|
@ -114,15 +108,9 @@ class KernelWorldModelCoreBuilderMixin:
|
||||||
for row in active_projects_rows
|
for row in active_projects_rows
|
||||||
]
|
]
|
||||||
|
|
||||||
repo_scope = self._build_repo_scope()
|
|
||||||
world_model: WorldModel = {
|
world_model: WorldModel = {
|
||||||
"sender": sender,
|
"sender": sender,
|
||||||
"resolved_project_id": resolved_project_id,
|
"resolved_project_id": resolved_project_id,
|
||||||
"repo_scope": repo_scope,
|
|
||||||
"repo_summary": {
|
|
||||||
"scope": repo_scope,
|
|
||||||
"summary_mode": "backend_snapshot",
|
|
||||||
},
|
|
||||||
"project": project_snapshot,
|
"project": project_snapshot,
|
||||||
"project_domain": domain,
|
"project_domain": domain,
|
||||||
"portfolio": portfolio_summary,
|
"portfolio": portfolio_summary,
|
||||||
|
|
|
||||||
|
|
@ -18,7 +18,6 @@ class DiscoveryAnalysisMixin:
|
||||||
strict_analysis_required = self._requires_strict_signal_analysis(
|
strict_analysis_required = self._requires_strict_signal_analysis(
|
||||||
message_text=message_text,
|
message_text=message_text,
|
||||||
requested_count=requested_count,
|
requested_count=requested_count,
|
||||||
metadata=intent.metadata if isinstance(intent.metadata, dict) else {},
|
|
||||||
)
|
)
|
||||||
prompt = PROMPT_AI_PMO_SIGNAL_BATCH_ANALYSIS.format(
|
prompt = PROMPT_AI_PMO_SIGNAL_BATCH_ANALYSIS.format(
|
||||||
message_text=str(message_text or "")[:1600],
|
message_text=str(message_text or "")[:1600],
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -6,170 +6,11 @@ from sqlalchemy.exc import IntegrityError
|
||||||
|
|
||||||
from ....models import OperationExecutionLedger
|
from ....models import OperationExecutionLedger
|
||||||
from .shared import * # noqa: F401, F403
|
from .shared import * # noqa: F401, F403
|
||||||
from .shared import _safe_int
|
|
||||||
from ...context_cache_service import context_cache_service
|
|
||||||
|
|
||||||
|
|
||||||
class ExecuteSignalSourceMixin:
|
class ExecuteSignalSourceMixin:
|
||||||
"""Handle signal source onboard intent."""
|
"""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(
|
async def _execute_signal_source_onboard_intent(
|
||||||
self,
|
self,
|
||||||
*,
|
*,
|
||||||
|
|
@ -239,121 +80,98 @@ class ExecuteSignalSourceMixin:
|
||||||
await self.db.commit()
|
await self.db.commit()
|
||||||
|
|
||||||
try:
|
try:
|
||||||
operation_intent_contract = self._resolve_operation_intent_contract(intent=intent)
|
semantic_payload = self._build_signal_source_semantic_payload(
|
||||||
reply_anchor_qa_context = self._resolve_reply_anchor_qa_context(intent=intent)
|
intent=intent,
|
||||||
operation_phase = str(operation_intent_contract.get("intent_phase") or "").strip().lower()
|
message_text=message_text,
|
||||||
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)
|
|
||||||
)
|
)
|
||||||
effective_project_id = project_binding or _safe_int(intent.project_id)
|
signal_summary = str(semantic_payload.get("summary") or intent.signal_summary or message_text)
|
||||||
run_results: list[dict[str, Any]] = []
|
signal_content = str(semantic_payload.get("content") or message_text)
|
||||||
if candidate_materializations:
|
initial_title_seed = self._normalize_title_candidate(
|
||||||
for candidate in candidate_materializations:
|
str(semantic_payload.get("title") or intent.signal_title or "")
|
||||||
semantic_payload = self._build_signal_source_semantic_payload(
|
)
|
||||||
intent=intent,
|
initial_signal_title = (
|
||||||
message_text=message_text,
|
initial_title_seed
|
||||||
candidate_materialization=candidate,
|
if self._is_acceptable_title(initial_title_seed)
|
||||||
candidate_set_binding=candidate_set_binding,
|
else "信号源接入请求"
|
||||||
)
|
)
|
||||||
run_results.append(
|
strategic_payload = (
|
||||||
await self._run_signal_source_onboard_candidate(
|
semantic_payload.get("strategic_analysis")
|
||||||
project_id=int(effective_project_id or 0),
|
if isinstance(semantic_payload.get("strategic_analysis"), dict)
|
||||||
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 {}
|
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 = {
|
result = {
|
||||||
"status": result_status,
|
"status": (
|
||||||
"signal_id": signal_ids[0] if signal_ids else None,
|
"succeeded"
|
||||||
"signal_ids": signal_ids,
|
if str(domain_workflow.get("status") or "").lower() == "completed"
|
||||||
"requested_count": len(run_results),
|
else (
|
||||||
"project_id": effective_project_id,
|
"pending_manual"
|
||||||
"candidate_set_binding": candidate_set_binding,
|
if str(domain_workflow.get("status") or "").lower() == "pending_manual"
|
||||||
"candidate_refs": candidate_refs,
|
else "failed_safe"
|
||||||
"reply_anchor_qa_context": reply_anchor_qa_context,
|
)
|
||||||
"signal_source_workflow": {
|
),
|
||||||
"status": workflow_status,
|
"signal_id": int(signal.id),
|
||||||
"batch_count": len(run_results),
|
"project_id": intent.project_id,
|
||||||
"candidate_set_id": candidate_set_binding,
|
"signal_source_workflow": domain_workflow,
|
||||||
},
|
"signal_source_orchestration": orchestration,
|
||||||
"signal_source_workflows": [item.get("workflow") for item in run_results if isinstance(item.get("workflow"), dict)],
|
"auto_tasks": self._extract_auto_tasks(
|
||||||
"signal_source_orchestration": {
|
orchestration.get("execution")
|
||||||
"candidate_set_id": candidate_set_binding,
|
if isinstance(orchestration.get("execution"), dict)
|
||||||
"items": [item.get("orchestration") for item in run_results if isinstance(item.get("orchestration"), dict)],
|
else orchestration.get("control_plane")
|
||||||
},
|
),
|
||||||
"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(
|
await self._finalize_signal_source_onboard_ledger(
|
||||||
ledger=ledger,
|
ledger=ledger,
|
||||||
status=result["status"],
|
status=result["status"],
|
||||||
result=result,
|
result=result,
|
||||||
signal_source_payload=signal_source_payload,
|
signal_source_payload=signal_source_payload,
|
||||||
signal_id=signal_ids[0] if signal_ids else None,
|
signal_id=int(signal.id),
|
||||||
)
|
)
|
||||||
await self.db.commit()
|
await self.db.commit()
|
||||||
await context_cache_service.invalidate_system_context(tenant_id=self.tenant_id)
|
await context_cache_service.invalidate_system_context(tenant_id=self.tenant_id)
|
||||||
|
|
|
||||||
|
|
@ -38,10 +38,50 @@ class DraftConfirmHandler:
|
||||||
_ACTION_CANCEL,
|
_ACTION_CANCEL,
|
||||||
_ACTION_ANSWER_ONLY,
|
_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 = (
|
_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:
|
def __init__(self, kernel: Any) -> None:
|
||||||
self.kernel = kernel
|
self.kernel = kernel
|
||||||
self.db = kernel.db
|
self.db = kernel.db
|
||||||
|
|
@ -64,6 +104,7 @@ class DraftConfirmHandler:
|
||||||
explicit_work_item_id = self._extract_work_item_id(message_text)
|
explicit_work_item_id = self._extract_work_item_id(message_text)
|
||||||
normalized_thread_context = self._normalize_thread_context(thread_context)
|
normalized_thread_context = self._normalize_thread_context(thread_context)
|
||||||
has_reply_anchor = bool(str(normalized_thread_context.get("reply_to_message_id") or "").strip())
|
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(
|
target = await self._resolve_pending_draft(
|
||||||
sender=sender,
|
sender=sender,
|
||||||
source_system=source_system,
|
source_system=source_system,
|
||||||
|
|
@ -73,7 +114,7 @@ class DraftConfirmHandler:
|
||||||
thread_context=thread_context,
|
thread_context=thread_context,
|
||||||
)
|
)
|
||||||
if target is None:
|
if target is None:
|
||||||
if explicit_work_item_id is not None:
|
if explicit_work_item_id is not None or (has_reply_anchor and self.is_explicit_protocol_followup_text(normalized_text)):
|
||||||
return {
|
return {
|
||||||
"handled": True,
|
"handled": True,
|
||||||
"status": "failed_safe",
|
"status": "failed_safe",
|
||||||
|
|
@ -139,17 +180,7 @@ class DraftConfirmHandler:
|
||||||
"workflow_route": RouteResolver.normalize_route_key(str(target_work_item.intent or "")),
|
"workflow_route": RouteResolver.normalize_route_key(str(target_work_item.intent or "")),
|
||||||
}
|
}
|
||||||
if action == self._ACTION_UNKNOWN:
|
if action == self._ACTION_UNKNOWN:
|
||||||
return {
|
action = self._resolve_explicit_protocol_action(message_text)
|
||||||
"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:
|
if action == self._ACTION_CONFIRM:
|
||||||
return await self.confirm_draft(
|
return await self.confirm_draft(
|
||||||
target_work_item=target_work_item,
|
target_work_item=target_work_item,
|
||||||
|
|
@ -179,7 +210,17 @@ class DraftConfirmHandler:
|
||||||
target_work_item=target_work_item,
|
target_work_item=target_work_item,
|
||||||
reason="用户取消草稿",
|
reason="用户取消草稿",
|
||||||
)
|
)
|
||||||
raise AssertionError(f"unexpected draft followup action: {action}")
|
return {
|
||||||
|
"handled": True,
|
||||||
|
"status": "pending_manual",
|
||||||
|
"followup_reply": "当前正在处理这版待确认草稿。请回复“确认”、“修改 + 你的意见”、“重出一版”或“取消”来处理当前草稿。",
|
||||||
|
"target_work_item_id": int(target_work_item.id),
|
||||||
|
"target_state": str(target_work_item.state or "").strip() or None,
|
||||||
|
"target_intent": str(target_work_item.intent or "").strip() or None,
|
||||||
|
"project_id": _safe_int(target_work_item.project_id),
|
||||||
|
"signal_id": _safe_int(target_work_item.signal_id),
|
||||||
|
"workflow_route": RouteResolver.normalize_route_key(str(target_work_item.intent or "")),
|
||||||
|
}
|
||||||
|
|
||||||
async def _resolve_target_mounted_draft(
|
async def _resolve_target_mounted_draft(
|
||||||
self,
|
self,
|
||||||
|
|
|
||||||
|
|
@ -1,106 +0,0 @@
|
||||||
"""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,
|
|
||||||
)
|
|
||||||
|
|
@ -1,19 +1,5 @@
|
||||||
from .assembler import SemanticContextAssembler
|
from .assembler import SemanticContextAssembler
|
||||||
from .business_frame_schema import (
|
from .decision_schema import DecisionGateResult, ModeCard, SemanticDecision
|
||||||
AnswerShapeContract,
|
|
||||||
BusinessSemanticFrame,
|
|
||||||
ClarificationDelta,
|
|
||||||
CorrectionDelta,
|
|
||||||
OperationIntentContract,
|
|
||||||
)
|
|
||||||
from .decision_schema import (
|
|
||||||
AnswerControlDecision,
|
|
||||||
DecisionGateResult,
|
|
||||||
InteractionBoundaryPacket,
|
|
||||||
ModeCard,
|
|
||||||
SemanticDecision,
|
|
||||||
SelfReviewResult,
|
|
||||||
)
|
|
||||||
from .gatekeeper import SemanticDecisionGatekeeper
|
from .gatekeeper import SemanticDecisionGatekeeper
|
||||||
from .mode_card_service import build_mode_card
|
from .mode_card_service import build_mode_card
|
||||||
from .packet_schema import (
|
from .packet_schema import (
|
||||||
|
|
@ -25,15 +11,8 @@ from .packet_schema import (
|
||||||
)
|
)
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"AnswerShapeContract",
|
|
||||||
"AnswerControlDecision",
|
|
||||||
"BusinessSemanticFrame",
|
|
||||||
"ClarificationDelta",
|
|
||||||
"CorrectionDelta",
|
|
||||||
"DecisionGateResult",
|
"DecisionGateResult",
|
||||||
"InteractionBoundaryPacket",
|
|
||||||
"ModeCard",
|
"ModeCard",
|
||||||
"OperationIntentContract",
|
|
||||||
"SemanticContextAssembler",
|
"SemanticContextAssembler",
|
||||||
"SemanticContextDraftSnapshot",
|
"SemanticContextDraftSnapshot",
|
||||||
"SemanticContextPacket",
|
"SemanticContextPacket",
|
||||||
|
|
@ -42,6 +21,5 @@ __all__ = [
|
||||||
"SemanticContextTurn",
|
"SemanticContextTurn",
|
||||||
"SemanticDecision",
|
"SemanticDecision",
|
||||||
"SemanticDecisionGatekeeper",
|
"SemanticDecisionGatekeeper",
|
||||||
"SelfReviewResult",
|
|
||||||
"build_mode_card",
|
"build_mode_card",
|
||||||
]
|
]
|
||||||
|
|
|
||||||
|
|
@ -1,113 +0,0 @@
|
||||||
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)
|
|
||||||
|
|
@ -4,14 +4,6 @@ from typing import Any, Literal
|
||||||
|
|
||||||
from pydantic import BaseModel, ConfigDict, Field
|
from pydantic import BaseModel, ConfigDict, Field
|
||||||
|
|
||||||
from .business_frame_schema import (
|
|
||||||
AnswerShapeContract,
|
|
||||||
BusinessSemanticFrame,
|
|
||||||
OperationIntentContract,
|
|
||||||
)
|
|
||||||
|
|
||||||
BoundaryLevel = Literal["status", "recommendation", "draft", "execute"]
|
|
||||||
|
|
||||||
|
|
||||||
class ModeCard(BaseModel):
|
class ModeCard(BaseModel):
|
||||||
model_config = ConfigDict(extra="ignore")
|
model_config = ConfigDict(extra="ignore")
|
||||||
|
|
@ -44,55 +36,6 @@ class DecisionWorkflowPlan(BaseModel):
|
||||||
metadata: dict[str, Any] = Field(default_factory=dict)
|
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):
|
class SemanticDecision(BaseModel):
|
||||||
model_config = ConfigDict(extra="ignore")
|
model_config = ConfigDict(extra="ignore")
|
||||||
|
|
||||||
|
|
@ -104,11 +47,6 @@ class SemanticDecision(BaseModel):
|
||||||
draft_patch: dict[str, Any] = Field(default_factory=dict)
|
draft_patch: dict[str, Any] = Field(default_factory=dict)
|
||||||
workflow_plans: list[DecisionWorkflowPlan] = Field(default_factory=list)
|
workflow_plans: list[DecisionWorkflowPlan] = Field(default_factory=list)
|
||||||
scene_metadata: dict[str, Any] = Field(default_factory=dict)
|
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 = ""
|
assistant_reply: str = ""
|
||||||
requires_confirmation: bool = False
|
requires_confirmation: bool = False
|
||||||
requested_actions: list[str] = Field(default_factory=list)
|
requested_actions: list[str] = Field(default_factory=list)
|
||||||
|
|
|
||||||
|
|
@ -19,7 +19,6 @@ def build_mode_card(*, topic_mode: str | None, plan_status: str | None) -> ModeC
|
||||||
"reply.compose",
|
"reply.compose",
|
||||||
"draft.patch",
|
"draft.patch",
|
||||||
"clarify.ask",
|
"clarify.ask",
|
||||||
"context.suspend_requirement_mode",
|
|
||||||
"topic.continue_or_split",
|
"topic.continue_or_split",
|
||||||
"plan.mark_ready_for_confirmation",
|
"plan.mark_ready_for_confirmation",
|
||||||
],
|
],
|
||||||
|
|
|
||||||
|
|
@ -5,14 +5,7 @@ import hashlib
|
||||||
import re
|
import re
|
||||||
from typing import Any, Iterable, Optional
|
from typing import Any, Iterable, Optional
|
||||||
|
|
||||||
from ..semantic_context.decision_schema import (
|
from ..semantic_context.decision_schema import DecisionWorkflowPlan, ModeCard, SemanticDecision
|
||||||
AnswerControlDecision,
|
|
||||||
DecisionWorkflowPlan,
|
|
||||||
InteractionBoundaryPacket,
|
|
||||||
ModeCard,
|
|
||||||
SemanticDecision,
|
|
||||||
SelfReviewResult,
|
|
||||||
)
|
|
||||||
from ..semantic_context.packet_schema import (
|
from ..semantic_context.packet_schema import (
|
||||||
SemanticContextIngress,
|
SemanticContextIngress,
|
||||||
SemanticContextPacket,
|
SemanticContextPacket,
|
||||||
|
|
@ -51,7 +44,6 @@ class WorkflowSemanticPlanner:
|
||||||
"requirements_analysis": "structured_analysis_first",
|
"requirements_analysis": "structured_analysis_first",
|
||||||
"collaborative": "exploratory",
|
"collaborative": "exploratory",
|
||||||
}
|
}
|
||||||
_QUERY_ONLY_LEVELS = {"status", "recommendation", "clarify"}
|
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _normalize_text(text: str) -> str:
|
def _normalize_text(text: str) -> str:
|
||||||
|
|
@ -80,13 +72,6 @@ class WorkflowSemanticPlanner:
|
||||||
cls,
|
cls,
|
||||||
*,
|
*,
|
||||||
scene_metadata: Optional[dict[str, Any]] = None,
|
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]:
|
) -> dict[str, Any]:
|
||||||
diagnostics: dict[str, Any] = {}
|
diagnostics: dict[str, Any] = {}
|
||||||
payload = scene_metadata if isinstance(scene_metadata, dict) else {}
|
payload = scene_metadata if isinstance(scene_metadata, dict) else {}
|
||||||
|
|
@ -95,333 +80,8 @@ class WorkflowSemanticPlanner:
|
||||||
if value in (None, ""):
|
if value in (None, ""):
|
||||||
continue
|
continue
|
||||||
diagnostics[key] = value
|
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
|
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
|
@classmethod
|
||||||
def _build_cold_start_packet(
|
def _build_cold_start_packet(
|
||||||
cls,
|
cls,
|
||||||
|
|
@ -508,7 +168,6 @@ class WorkflowSemanticPlanner:
|
||||||
decision: SemanticDecision,
|
decision: SemanticDecision,
|
||||||
source_system: str,
|
source_system: str,
|
||||||
message_text: str,
|
message_text: str,
|
||||||
metadata: Optional[dict[str, Any]] = None,
|
|
||||||
) -> WorkflowBundle:
|
) -> WorkflowBundle:
|
||||||
workflows: list[WorkflowPlan] = []
|
workflows: list[WorkflowPlan] = []
|
||||||
for item in decision.workflow_plans:
|
for item in decision.workflow_plans:
|
||||||
|
|
@ -527,19 +186,6 @@ class WorkflowSemanticPlanner:
|
||||||
)
|
)
|
||||||
if not workflows:
|
if not workflows:
|
||||||
raise ValueError("cold_start_route bundle has no 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
|
primary_workflow = workflows[0].workflow_type
|
||||||
return WorkflowBundle(
|
return WorkflowBundle(
|
||||||
bundle_id=cls._bundle_id(
|
bundle_id=cls._bundle_id(
|
||||||
|
|
@ -558,13 +204,6 @@ class WorkflowSemanticPlanner:
|
||||||
),
|
),
|
||||||
diagnostics=cls._build_query_bundle_diagnostics(
|
diagnostics=cls._build_query_bundle_diagnostics(
|
||||||
scene_metadata=dict(decision.scene_metadata or {}),
|
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,
|
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -585,7 +224,6 @@ class WorkflowSemanticPlanner:
|
||||||
decision=decision,
|
decision=decision,
|
||||||
source_system=source_system,
|
source_system=source_system,
|
||||||
message_text=str(message_text or "").strip(),
|
message_text=str(message_text or "").strip(),
|
||||||
metadata=metadata,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -1,130 +0,0 @@
|
||||||
"""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"]
|
|
||||||
|
|
@ -1,6 +1,5 @@
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import inspect
|
|
||||||
from typing import Any
|
from typing import Any
|
||||||
from urllib.parse import urlparse
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
|
|
@ -23,24 +22,11 @@ class ExternalQueryGateway:
|
||||||
*,
|
*,
|
||||||
question: str,
|
question: str,
|
||||||
query_semantics: dict[str, Any] | None = None,
|
query_semantics: dict[str, Any] | None = None,
|
||||||
fit_brief: dict[str, Any] | None = None,
|
|
||||||
) -> ExternalQueryExecution:
|
) -> ExternalQueryExecution:
|
||||||
build_plan = getattr(self.planner, "build_plan")
|
planned = await self.planner.build_plan(
|
||||||
try:
|
question=question,
|
||||||
parameters = inspect.signature(build_plan).parameters
|
query_semantics=query_semantics,
|
||||||
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] = {}
|
planner_meta: dict[str, Any] = {}
|
||||||
if isinstance(planned, tuple) and len(planned) == 2:
|
if isinstance(planned, tuple) and len(planned) == 2:
|
||||||
plan, planner_meta = planned
|
plan, planner_meta = planned
|
||||||
|
|
@ -80,7 +66,6 @@ class ExternalQueryGateway:
|
||||||
meta = dict(selected.meta or {})
|
meta = dict(selected.meta or {})
|
||||||
meta["fallback_used"] = bool(fallback_used)
|
meta["fallback_used"] = bool(fallback_used)
|
||||||
meta["adapter_attempts"] = adapter_attempts
|
meta["adapter_attempts"] = adapter_attempts
|
||||||
meta["selected_status"] = str(selected.status or "").strip() or "empty"
|
|
||||||
return ExternalQueryExecution(
|
return ExternalQueryExecution(
|
||||||
plan=plan,
|
plan=plan,
|
||||||
adapter_name=selected.adapter_name,
|
adapter_name=selected.adapter_name,
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,6 @@ import re
|
||||||
from typing import Any, Callable
|
from typing import Any, Callable
|
||||||
|
|
||||||
from ...config.ai_prompts import PROMPT_EXTERNAL_QUERY_PLAN
|
from ...config.ai_prompts import PROMPT_EXTERNAL_QUERY_PLAN
|
||||||
from ...schemas.project_operating_context import ProjectFitBrief
|
|
||||||
from .schemas import ExternalQueryPlan
|
from .schemas import ExternalQueryPlan
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -30,26 +29,18 @@ class ExternalQueryPlanner:
|
||||||
*,
|
*,
|
||||||
question: str,
|
question: str,
|
||||||
query_semantics: dict[str, Any] | None = None,
|
query_semantics: dict[str, Any] | None = None,
|
||||||
fit_brief: dict[str, Any] | None = None,
|
|
||||||
) -> tuple[ExternalQueryPlan, dict[str, Any]]:
|
) -> tuple[ExternalQueryPlan, dict[str, Any]]:
|
||||||
fallback_plan = self._build_fallback_plan(
|
fallback_plan = self._build_fallback_plan(
|
||||||
question=question,
|
question=question,
|
||||||
query_semantics=query_semantics,
|
query_semantics=query_semantics,
|
||||||
fit_brief=fit_brief,
|
|
||||||
)
|
)
|
||||||
generator = getattr(self.llm_service, "generate_structured_object", None)
|
generator = getattr(self.llm_service, "generate_structured_object", None)
|
||||||
if not callable(generator):
|
if not callable(generator):
|
||||||
return fallback_plan, {
|
return fallback_plan, {"planner": "heuristic", "fallback_taken": True}
|
||||||
"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(
|
prompt = PROMPT_EXTERNAL_QUERY_PLAN.format(
|
||||||
question=str(question or "").strip()[:1200],
|
question=str(question or "").strip()[:1200],
|
||||||
query_semantics=json.dumps(query_semantics or {}, ensure_ascii=False)[:4000],
|
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),
|
fallback_queries=json.dumps(fallback_plan.search_queries, ensure_ascii=False),
|
||||||
).strip()
|
).strip()
|
||||||
try:
|
try:
|
||||||
|
|
@ -73,32 +64,18 @@ class ExternalQueryPlanner:
|
||||||
continue
|
continue
|
||||||
merged[key] = value
|
merged[key] = value
|
||||||
plan = ExternalQueryPlan.model_validate(merged)
|
plan = ExternalQueryPlan.model_validate(merged)
|
||||||
recommendation_queries = self._build_signal_source_recommendation_queries(
|
if not plan.search_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)
|
plan.search_queries = list(fallback_plan.search_queries)
|
||||||
if not plan.preferred_domains:
|
if not plan.preferred_domains:
|
||||||
plan.preferred_domains = list(fallback_plan.preferred_domains)
|
plan.preferred_domains = list(fallback_plan.preferred_domains)
|
||||||
if not plan.topic:
|
if not plan.topic:
|
||||||
plan.topic = fallback_plan.topic
|
plan.topic = fallback_plan.topic
|
||||||
return plan, {
|
return plan, {"planner": "llm_structured", "fallback_taken": False}
|
||||||
"planner": "llm_structured",
|
|
||||||
"fallback_taken": False,
|
|
||||||
"fit_brief_summary": plan.fit_brief_summary or fallback_plan.fit_brief_summary,
|
|
||||||
}
|
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
return fallback_plan, {
|
return fallback_plan, {
|
||||||
"planner": "heuristic",
|
"planner": "heuristic",
|
||||||
"fallback_taken": True,
|
"fallback_taken": True,
|
||||||
"reason": str(exc)[:200],
|
"reason": str(exc)[:200],
|
||||||
"fit_brief_summary": fallback_plan.fit_brief_summary,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
def _build_fallback_plan(
|
def _build_fallback_plan(
|
||||||
|
|
@ -106,13 +83,10 @@ class ExternalQueryPlanner:
|
||||||
*,
|
*,
|
||||||
question: str,
|
question: str,
|
||||||
query_semantics: dict[str, Any] | None = None,
|
query_semantics: dict[str, Any] | None = None,
|
||||||
fit_brief: dict[str, Any] | None = None,
|
|
||||||
) -> ExternalQueryPlan:
|
) -> ExternalQueryPlan:
|
||||||
text = re.sub(r"\s+", " ", str(question or "")).strip()
|
text = re.sub(r"\s+", " ", str(question or "")).strip()
|
||||||
semantics = query_semantics if isinstance(query_semantics, dict) else {}
|
semantics = query_semantics if isinstance(query_semantics, dict) else {}
|
||||||
query_profile = semantics.get("query_profile") if isinstance(semantics.get("query_profile"), 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 = [
|
object_mentions = [
|
||||||
str(item or "").strip()
|
str(item or "").strip()
|
||||||
for item in list(semantics.get("object_mentions") or [])
|
for item in list(semantics.get("object_mentions") or [])
|
||||||
|
|
@ -128,10 +102,7 @@ class ExternalQueryPlanner:
|
||||||
answer_contract = "direct_fact"
|
answer_contract = "direct_fact"
|
||||||
|
|
||||||
topic = text[:120] or "外部查询"
|
topic = text[:120] or "外部查询"
|
||||||
search_queries = self._build_search_queries(
|
search_queries = self._build_search_queries(text)
|
||||||
text,
|
|
||||||
fit_brief=normalized_fit_brief,
|
|
||||||
)
|
|
||||||
preferred_domains = self._infer_preferred_domains(text)
|
preferred_domains = self._infer_preferred_domains(text)
|
||||||
subject_entities = object_mentions[:6]
|
subject_entities = object_mentions[:6]
|
||||||
location_hint = None
|
location_hint = None
|
||||||
|
|
@ -139,13 +110,10 @@ class ExternalQueryPlanner:
|
||||||
requires_multi_source = answer_contract == "compare_with_sources"
|
requires_multi_source = answer_contract == "compare_with_sources"
|
||||||
clarify_on_ambiguity = False
|
clarify_on_ambiguity = False
|
||||||
result_limit = 3 if requires_multi_source or bool(query_profile.get("needs_freshness")) else 2
|
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(
|
return ExternalQueryPlan(
|
||||||
plan_version="v1",
|
plan_version="v1",
|
||||||
query_kind="generic_web" if text else "unknown",
|
query_kind="generic_web" if text else "unknown",
|
||||||
interaction_level=interaction_level,
|
|
||||||
answer_contract=answer_contract,
|
answer_contract=answer_contract,
|
||||||
freshness_required=bool(query_profile.get("needs_freshness")),
|
freshness_required=bool(query_profile.get("needs_freshness")),
|
||||||
topic=topic,
|
topic=topic,
|
||||||
|
|
@ -157,41 +125,19 @@ class ExternalQueryPlanner:
|
||||||
result_limit=result_limit,
|
result_limit=result_limit,
|
||||||
requires_multi_source=requires_multi_source,
|
requires_multi_source=requires_multi_source,
|
||||||
clarify_on_ambiguity=clarify_on_ambiguity,
|
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(
|
def _build_search_queries(self, question: str) -> list[str]:
|
||||||
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):
|
if callable(self.query_builder):
|
||||||
try:
|
try:
|
||||||
queries = self.query_builder(question)
|
queries = self.query_builder(question)
|
||||||
normalized = [str(item or "").strip() for item in list(queries or []) if str(item or "").strip()]
|
normalized = [str(item or "").strip() for item in list(queries or []) if str(item or "").strip()]
|
||||||
if normalized:
|
if normalized:
|
||||||
return self._merge_fit_brief_queries(
|
return normalized[:5]
|
||||||
self._prepend_priority_queries(recommendation_queries, normalized),
|
|
||||||
fit_brief=fit_brief,
|
|
||||||
)
|
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
text = re.sub(r"\s+", " ", str(question or "")).strip()
|
text = re.sub(r"\s+", " ", str(question or "")).strip()
|
||||||
queries = self._prepend_priority_queries(recommendation_queries, [text] if text else [])
|
return [text] if text else []
|
||||||
return self._merge_fit_brief_queries(queries, fit_brief=fit_brief)
|
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _infer_preferred_domains(question: str) -> list[str]:
|
def _infer_preferred_domains(question: str) -> list[str]:
|
||||||
|
|
@ -215,196 +161,3 @@ class ExternalQueryPlanner:
|
||||||
if match:
|
if match:
|
||||||
return str(match.group(0) or "").strip() or None
|
return str(match.group(0) or "").strip() or None
|
||||||
return 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]
|
|
||||||
|
|
|
||||||
|
|
@ -1,22 +1,14 @@
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import re
|
|
||||||
from uuid import uuid4
|
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from ...config.ai_prompts import (
|
from ...config.ai_prompts import (
|
||||||
PROMPT_EXTERNAL_COMPARE_WITH_SOURCES_QA,
|
PROMPT_EXTERNAL_COMPARE_WITH_SOURCES_QA,
|
||||||
PROMPT_EXTERNAL_DIRECT_FACT_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_SUMMARY_WITH_SOURCES_QA,
|
||||||
)
|
)
|
||||||
from ..prompt_release import PromptReleaseService
|
from ..prompt_release import PromptReleaseService
|
||||||
from .schemas import (
|
from .schemas import ExternalContextPacket
|
||||||
ExternalContextPacket,
|
|
||||||
SignalSourceRecommendationCandidate,
|
|
||||||
SignalSourceRecommendationPacket,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class ExternalAnswerPresenter:
|
class ExternalAnswerPresenter:
|
||||||
|
|
@ -96,415 +88,8 @@ class ExternalAnswerPresenter:
|
||||||
rows.append(f"地点: {plan.location_hint}")
|
rows.append(f"地点: {plan.location_hint}")
|
||||||
if plan.subject_entities:
|
if plan.subject_entities:
|
||||||
rows.append(f"对象: {'、'.join([str(item).strip() for item in plan.subject_entities[:5] if str(item).strip()])}")
|
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)
|
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(
|
async def _render_prompt(
|
||||||
self,
|
self,
|
||||||
*,
|
*,
|
||||||
|
|
@ -534,27 +119,6 @@ class ExternalAnswerPresenter:
|
||||||
if isinstance(context_packet, ExternalContextPacket)
|
if isinstance(context_packet, ExternalContextPacket)
|
||||||
else ExternalContextPacket.model_validate(context_packet)
|
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 {})
|
verifier = dict(packet.verifier or {})
|
||||||
if not packet.verified_evidence or not bool(verifier.get("ok")):
|
if not packet.verified_evidence or not bool(verifier.get("ok")):
|
||||||
return {
|
return {
|
||||||
|
|
|
||||||
|
|
@ -2,14 +2,11 @@ from __future__ import annotations
|
||||||
|
|
||||||
from typing import Any, Literal
|
from typing import Any, Literal
|
||||||
|
|
||||||
from pydantic import BaseModel, ConfigDict, Field, model_validator
|
from pydantic import BaseModel, ConfigDict, Field
|
||||||
|
|
||||||
|
|
||||||
ExternalQueryKind = Literal["weather", "news", "company", "model", "market", "generic_web", "unknown"]
|
ExternalQueryKind = Literal["weather", "news", "company", "model", "market", "generic_web", "unknown"]
|
||||||
ExternalAnswerContract = Literal["direct_fact", "summary_with_sources", "compare_with_sources", "clarify"]
|
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):
|
class ExternalQueryPlan(BaseModel):
|
||||||
|
|
@ -17,7 +14,6 @@ class ExternalQueryPlan(BaseModel):
|
||||||
|
|
||||||
plan_version: str = "v1"
|
plan_version: str = "v1"
|
||||||
query_kind: ExternalQueryKind = "unknown"
|
query_kind: ExternalQueryKind = "unknown"
|
||||||
interaction_level: Literal["status", "recommendation", "draft", "execute", "clarify"] | None = None
|
|
||||||
answer_contract: ExternalAnswerContract = "summary_with_sources"
|
answer_contract: ExternalAnswerContract = "summary_with_sources"
|
||||||
freshness_required: bool = False
|
freshness_required: bool = False
|
||||||
topic: str = ""
|
topic: str = ""
|
||||||
|
|
@ -29,11 +25,6 @@ class ExternalQueryPlan(BaseModel):
|
||||||
result_limit: int = Field(default=3, ge=1, le=5)
|
result_limit: int = Field(default=3, ge=1, le=5)
|
||||||
requires_multi_source: bool = False
|
requires_multi_source: bool = False
|
||||||
clarify_on_ambiguity: 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):
|
class ExternalEvidenceItem(BaseModel):
|
||||||
|
|
@ -70,30 +61,6 @@ class ExternalQueryExecution(BaseModel):
|
||||||
planner_meta: dict[str, Any] = Field(default_factory=dict)
|
planner_meta: dict[str, Any] = Field(default_factory=dict)
|
||||||
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":
|
def to_context_packet(self, *, question: str) -> "ExternalContextPacket":
|
||||||
verifier = dict(self.verifier or {})
|
verifier = dict(self.verifier or {})
|
||||||
uncertainties: list[str] = []
|
uncertainties: list[str] = []
|
||||||
|
|
@ -111,9 +78,6 @@ class ExternalQueryExecution(BaseModel):
|
||||||
verified_evidence=list(self.evidence or []) if bool(verifier.get("ok")) else [],
|
verified_evidence=list(self.evidence or []) if bool(verifier.get("ok")) else [],
|
||||||
verifier=verifier,
|
verifier=verifier,
|
||||||
uncertainties=uncertainties,
|
uncertainties=uncertainties,
|
||||||
retrieval_status=self.derive_retrieval_status(),
|
|
||||||
impact_status="no_direct_impact",
|
|
||||||
affected_signal_sources=[],
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -126,63 +90,3 @@ class ExternalContextPacket(BaseModel):
|
||||||
verified_evidence: list[ExternalEvidenceItem] = Field(default_factory=list)
|
verified_evidence: list[ExternalEvidenceItem] = Field(default_factory=list)
|
||||||
verifier: dict[str, Any] = Field(default_factory=dict)
|
verifier: dict[str, Any] = Field(default_factory=dict)
|
||||||
uncertainties: list[str] = Field(default_factory=list)
|
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 {}
|
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -543,9 +543,6 @@ class FeishuReplyOutboxMixin:
|
||||||
raw = str(value or "").strip()
|
raw = str(value or "").strip()
|
||||||
if not raw:
|
if not raw:
|
||||||
return ""
|
return ""
|
||||||
preserve_handoff_next_step = "下一步:" in raw and any(
|
|
||||||
marker in raw for marker in ("当前项目:", "外部判断:")
|
|
||||||
)
|
|
||||||
|
|
||||||
label_map = {
|
label_map = {
|
||||||
"当前我更推荐:": "⭐ 当前我更推荐:",
|
"当前我更推荐:": "⭐ 当前我更推荐:",
|
||||||
|
|
@ -567,14 +564,7 @@ class FeishuReplyOutboxMixin:
|
||||||
if beautified and beautified[-1] != "":
|
if beautified and beautified[-1] != "":
|
||||||
beautified.append("")
|
beautified.append("")
|
||||||
continue
|
continue
|
||||||
line_prefix_map = legacy_prefix_map
|
for legacy, replacement in legacy_prefix_map.items():
|
||||||
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):
|
if stripped.startswith(legacy):
|
||||||
stripped = f"{replacement}{stripped[len(legacy):]}".strip()
|
stripped = f"{replacement}{stripped[len(legacy):]}".strip()
|
||||||
break
|
break
|
||||||
|
|
|
||||||
|
|
@ -31,7 +31,6 @@ from sqlalchemy.future import select
|
||||||
from ....models import (
|
from ....models import (
|
||||||
User,
|
User,
|
||||||
Project,
|
Project,
|
||||||
Task,
|
|
||||||
Signal,
|
Signal,
|
||||||
ProjectSignalLink,
|
ProjectSignalLink,
|
||||||
Integration,
|
Integration,
|
||||||
|
|
@ -39,11 +38,7 @@ from ....models import (
|
||||||
AIProcessingLog,
|
AIProcessingLog,
|
||||||
)
|
)
|
||||||
from ....core.database import AsyncSessionLocal
|
from ....core.database import AsyncSessionLocal
|
||||||
from ....config.ai_prompts import (
|
from ....config.ai_prompts import PROMPT_FEISHU_HUMANIZED_REPLY
|
||||||
PROMPT_FEISHU_HUMANIZED_REPLY,
|
|
||||||
PROMPT_FEISHU_QA_NATURAL_REPLY,
|
|
||||||
PROMPT_FEISHU_QA_NATURAL_REPLY_REWRITE,
|
|
||||||
)
|
|
||||||
from ...notification_service import NotificationService
|
from ...notification_service import NotificationService
|
||||||
from ...signal.signal_capture_service import ProactiveSignalCaptureService
|
from ...signal.signal_capture_service import ProactiveSignalCaptureService
|
||||||
from ...llm_service import LLMService
|
from ...llm_service import LLMService
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,60 @@ from .shared import *
|
||||||
|
|
||||||
|
|
||||||
class FeishuPipelineContextPrefillAMixin:
|
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
|
@staticmethod
|
||||||
def _is_unusable_project_context(text: str) -> bool:
|
def _is_unusable_project_context(text: str) -> bool:
|
||||||
normalized = re.sub(r"\s+", "", str(text or "")).lower()
|
normalized = re.sub(r"\s+", "", str(text or "")).lower()
|
||||||
|
|
@ -27,45 +81,81 @@ class FeishuPipelineContextPrefillAMixin:
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
@classmethod
|
@staticmethod
|
||||||
def _merge_project_prefill_fields(
|
def _guess_project_title_from_text(text: str) -> str:
|
||||||
cls,
|
cleaned = re.sub(r"\s+", " ", str(text or "")).strip()
|
||||||
*,
|
if not cleaned:
|
||||||
fields: dict[str, Any],
|
return ""
|
||||||
project_info: dict[str, Any],
|
normalized = re.sub(r"\s+", "", cleaned).lower()
|
||||||
description_fallback: str = "",
|
if "十五五" in cleaned and any(token in cleaned for token in ("医药", "医疗", "器械", "中医药")):
|
||||||
scope_fallback: str = "",
|
return "十五五医药方向研究"
|
||||||
) -> dict[str, Any]:
|
if "医药" in cleaned and any(
|
||||||
title = str(fields.get("title") or "").strip() or normalize_project_title_candidate(
|
token in normalized
|
||||||
str(project_info.get("suggested_name") or "").strip()
|
for token in ("方向", "值得关注", "创新药", "中医药", "老龄化", "供应链安全", "出海")
|
||||||
)
|
):
|
||||||
description = (
|
return "医药方向研究"
|
||||||
str(fields.get("description") or "").strip()
|
if "医疗器械" in cleaned and "国产替代" in cleaned:
|
||||||
or str(project_info.get("suggested_description") or "").strip()[:4000]
|
return "医疗器械国产替代研究"
|
||||||
or str(description_fallback or "").strip()[:4000]
|
return ""
|
||||||
)
|
|
||||||
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]
|
|
||||||
)
|
|
||||||
|
|
||||||
missing_fields: list[str] = []
|
@staticmethod
|
||||||
if not title:
|
def _extract_embedded_project_context(message: str) -> str:
|
||||||
missing_fields.append("title")
|
cleaned = re.sub(r"\s+", " ", str(message or "")).strip()
|
||||||
if not description:
|
if len(cleaned) < 80:
|
||||||
missing_fields.append("description")
|
return ""
|
||||||
if not project_scope:
|
markers = []
|
||||||
missing_fields.append("project_scope")
|
for pattern in (
|
||||||
return {
|
r"(?:根据这个|根据上面|基于这个|基于上面|按这个|按上面|汇总以上内容|汇总上面内容|汇总一下|这个汇总|这个总结).{0,40}(?:创建|新建|建立|建个|建一个).{0,8}项目",
|
||||||
"title": title,
|
r"(?:给我|帮我|请|麻烦)?(?:创建|新建|建立|建个|建一个|做个|做一个)(?:一个|个)?项目(?:在|进|到)?(?:pmflow|项目列表)",
|
||||||
"description": description,
|
):
|
||||||
"project_scope": project_scope,
|
match = re.search(pattern, cleaned, re.IGNORECASE)
|
||||||
"missing_fields": missing_fields,
|
if match:
|
||||||
"ready": not missing_fields,
|
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 ""
|
||||||
|
|
||||||
async def _load_recent_chat_turns(
|
async def _load_recent_chat_turns(
|
||||||
self,
|
self,
|
||||||
|
|
@ -132,8 +222,61 @@ class FeishuPipelineContextPrefillAMixin:
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
if fields.get("ready"):
|
if fields.get("ready"):
|
||||||
return fields
|
return fields
|
||||||
cleaned_message = str(message or "").strip()
|
|
||||||
if not cleaned_message or self._is_unusable_project_context(cleaned_message):
|
embedded_context = self._extract_embedded_project_context(message)
|
||||||
|
if not embedded_context or self._is_unusable_project_context(embedded_context):
|
||||||
return fields
|
return fields
|
||||||
project_info = await self._extract_project_prefill(cleaned_message)
|
|
||||||
return self._merge_project_prefill_fields(fields=fields, project_info=project_info)
|
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,
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -18,7 +18,7 @@ class FeishuPipelineContextPrefillBMixin:
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
if fields.get("ready"):
|
if fields.get("ready"):
|
||||||
return fields
|
return fields
|
||||||
if not source_chat_id:
|
if not source_chat_id or not self._is_contextual_project_request(message):
|
||||||
return fields
|
return fields
|
||||||
|
|
||||||
turns = await self._load_recent_chat_turns(
|
turns = await self._load_recent_chat_turns(
|
||||||
|
|
@ -47,20 +47,83 @@ class FeishuPipelineContextPrefillBMixin:
|
||||||
return fields
|
return fields
|
||||||
|
|
||||||
base_description = str(fields.get("description") or "").strip()
|
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:
|
if not answer_hint and not base_description:
|
||||||
return fields
|
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 = (
|
prefill_source = (
|
||||||
f"当前建项目请求:{message}\n\n"
|
f"当前建项目请求:{message}\n\n"
|
||||||
+ "\n\n".join(context_blocks)
|
+ "\n\n".join(context_blocks)
|
||||||
+ "\n\n请基于最近上下文补全项目名称、项目描述、项目范围。"
|
+ "\n\n请基于最近上下文补全项目名称、项目描述、项目范围。"
|
||||||
)
|
)
|
||||||
project_info = await self._extract_project_prefill(prefill_source)
|
project_info = await self._extract_project_prefill(prefill_source)
|
||||||
return self._merge_project_prefill_fields(
|
current_description = str(fields.get("description") or "").strip()
|
||||||
fields=fields,
|
if (
|
||||||
project_info=project_info,
|
self._is_placeholder_project_text(current_description)
|
||||||
description_fallback=base_description or answer_hint[:4000],
|
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 ""))
|
||||||
)
|
)
|
||||||
|
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(
|
def _resolve_project_candidate_fields(
|
||||||
self,
|
self,
|
||||||
|
|
@ -80,12 +143,17 @@ class FeishuPipelineContextPrefillBMixin:
|
||||||
or operation_payload.get("description")
|
or operation_payload.get("description")
|
||||||
or ""
|
or ""
|
||||||
).strip()
|
).strip()
|
||||||
ai_output = _safe_dict(item.ai_output)
|
project_scope = str(payload.get("project_scope") or "").strip()
|
||||||
project_prefill = _safe_dict(ai_output.get("project_prefill"))
|
if not title or not description:
|
||||||
project_scope = (
|
extracted = self._extract_project_intent_fields(item.raw_message or "")
|
||||||
str(payload.get("project_scope") or "").strip()
|
if not title:
|
||||||
or str(project_prefill.get("suggested_scope") or "").strip()
|
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)
|
||||||
|
|
||||||
missing_fields: list[str] = []
|
missing_fields: list[str] = []
|
||||||
if not title:
|
if not title:
|
||||||
|
|
@ -122,12 +190,12 @@ class FeishuPipelineContextPrefillBMixin:
|
||||||
project_scope: str,
|
project_scope: str,
|
||||||
) -> str:
|
) -> str:
|
||||||
normalized_title = normalize_project_title_candidate(title)
|
normalized_title = normalize_project_title_candidate(title)
|
||||||
if normalized_title:
|
if normalized_title and not cls._is_placeholder_project_text(normalized_title):
|
||||||
return normalized_title
|
return normalized_title
|
||||||
|
|
||||||
for candidate in (project_scope, description):
|
for candidate in (project_scope, description):
|
||||||
cleaned = str(candidate or "").strip()
|
cleaned = str(candidate or "").strip()
|
||||||
if not cleaned:
|
if not cleaned or cls._is_placeholder_project_text(cleaned):
|
||||||
continue
|
continue
|
||||||
normalized = normalize_project_title_candidate(cleaned[:48])
|
normalized = normalize_project_title_candidate(cleaned[:48])
|
||||||
if normalized:
|
if normalized:
|
||||||
|
|
@ -144,7 +212,11 @@ class FeishuPipelineContextPrefillBMixin:
|
||||||
fallback_text: str,
|
fallback_text: str,
|
||||||
) -> str:
|
) -> str:
|
||||||
normalized_description = str(description or "").strip()
|
normalized_description = str(description or "").strip()
|
||||||
|
if cls._is_placeholder_project_text(normalized_description):
|
||||||
|
normalized_description = ""
|
||||||
normalized_scope = str(project_scope or "").strip()
|
normalized_scope = str(project_scope or "").strip()
|
||||||
|
if cls._is_placeholder_project_text(normalized_scope):
|
||||||
|
normalized_scope = ""
|
||||||
|
|
||||||
fragments: list[str] = []
|
fragments: list[str] = []
|
||||||
if normalized_description:
|
if normalized_description:
|
||||||
|
|
|
||||||
|
|
@ -61,6 +61,14 @@ class FeishuPipelineCoreBaseMixin:
|
||||||
text = str(message or "").strip()
|
text = str(message or "").strip()
|
||||||
if not text:
|
if not text:
|
||||||
return "generic"
|
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)
|
fragment = cls._normalize_key_fragment(text, max_len=64)
|
||||||
return fragment or "generic"
|
return fragment or "generic"
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -21,16 +21,14 @@ class FeishuPipelineExtractionMixin:
|
||||||
return _validate_schema(_ProjectPrefillSchema, parsed, context="project_prefill")
|
return _validate_schema(_ProjectPrefillSchema, parsed, context="project_prefill")
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logger.warning(f"feishu_project_prefill_fallback: error={exc}")
|
logger.warning(f"feishu_project_prefill_fallback: error={exc}")
|
||||||
fallback = self._build_project_prefill_degraded(message=message)
|
fallback = self._heuristic_project_prefill_fallback(message)
|
||||||
return _validate_schema(_ProjectPrefillSchema, fallback, context="project_prefill_fallback")
|
return _validate_schema(_ProjectPrefillSchema, fallback, context="project_prefill_fallback")
|
||||||
|
|
||||||
async def _extract_signal(
|
async def _extract_signal(self, message: str, *, projects: list[dict[str, Any]]) -> dict[str, Any]:
|
||||||
self,
|
if self._is_simple_signal_source_request(message):
|
||||||
message: str,
|
fallback = self._heuristic_signal_fallback(message=message, projects=projects)
|
||||||
*,
|
return _validate_schema(_SignalExtractionSchema, fallback, context="signal_extraction_fastpath")
|
||||||
projects: list[dict[str, Any]],
|
|
||||||
signal_intake_hint: bool = False,
|
|
||||||
) -> dict[str, Any]:
|
|
||||||
prompt = PROMPT_FEISHU_SIGNAL_EXTRACTION.format(
|
prompt = PROMPT_FEISHU_SIGNAL_EXTRACTION.format(
|
||||||
message=message[:1500],
|
message=message[:1500],
|
||||||
projects_json=json.dumps(projects, ensure_ascii=False),
|
projects_json=json.dumps(projects, ensure_ascii=False),
|
||||||
|
|
@ -49,19 +47,14 @@ class FeishuPipelineExtractionMixin:
|
||||||
return _validate_schema(_SignalExtractionSchema, parsed, context="signal_extraction")
|
return _validate_schema(_SignalExtractionSchema, parsed, context="signal_extraction")
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logger.warning(f"feishu_signal_extraction_fallback: error={exc}")
|
logger.warning(f"feishu_signal_extraction_fallback: error={exc}")
|
||||||
fallback = self._build_signal_extraction_degraded(
|
fallback = self._heuristic_signal_fallback(message=message, projects=projects)
|
||||||
message=message,
|
|
||||||
signal_intake_hint=signal_intake_hint,
|
|
||||||
)
|
|
||||||
return _validate_schema(_SignalExtractionSchema, fallback, context="signal_extraction_fallback")
|
return _validate_schema(_SignalExtractionSchema, fallback, context="signal_extraction_fallback")
|
||||||
|
|
||||||
async def _classify_level(
|
async def _classify_level(self, *, message: str, projects: list[dict[str, Any]]) -> dict[str, Any]:
|
||||||
self,
|
if self._is_simple_signal_source_request(message):
|
||||||
*,
|
fallback = self._heuristic_level_fallback(message)
|
||||||
message: str,
|
return _validate_schema(_LevelClassificationSchema, fallback, context="level_classification_fastpath")
|
||||||
projects: list[dict[str, Any]],
|
|
||||||
signal_intake_hint: bool = False,
|
|
||||||
) -> dict[str, Any]:
|
|
||||||
prompt = PROMPT_FEISHU_LEVEL_CLASSIFICATION.format(
|
prompt = PROMPT_FEISHU_LEVEL_CLASSIFICATION.format(
|
||||||
message=message[:1500],
|
message=message[:1500],
|
||||||
projects_json=json.dumps(projects, ensure_ascii=False),
|
projects_json=json.dumps(projects, ensure_ascii=False),
|
||||||
|
|
@ -131,48 +124,126 @@ class FeishuPipelineExtractionMixin:
|
||||||
"should_split": _safe_bool(parsed.get("should_split"), False),
|
"should_split": _safe_bool(parsed.get("should_split"), False),
|
||||||
"split_items": normalized_split_items,
|
"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")
|
return _validate_schema(_LevelClassificationSchema, payload, context="level_classification")
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logger.warning(f"feishu_level_classification_fallback: error={exc}")
|
logger.warning(f"feishu_level_classification_fallback: error={exc}")
|
||||||
fallback = self._build_level_classification_degraded(signal_intake_hint=signal_intake_hint)
|
fallback = self._heuristic_level_fallback(message)
|
||||||
return _validate_schema(_LevelClassificationSchema, fallback, context="level_classification_fallback")
|
return _validate_schema(_LevelClassificationSchema, fallback, context="level_classification_fallback")
|
||||||
|
|
||||||
@staticmethod
|
def _heuristic_level_fallback(self, message: str) -> dict[str, Any]:
|
||||||
def _build_level_classification_degraded(*, signal_intake_hint: bool) -> dict[str, Any]:
|
text = message or ""
|
||||||
project_score = 0.2 if signal_intake_hint else 0.3
|
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 = "基于关键词与上下文规则的兜底判定。"
|
||||||
return {
|
return {
|
||||||
"build_intent_score": 0.0,
|
"build_intent_score": build_intent_score,
|
||||||
"scope_score": 0.0,
|
"scope_score": scope_score,
|
||||||
"timeline_score": 0.0,
|
"timeline_score": timeline_score,
|
||||||
"owner_score": 0.0,
|
"owner_score": owner_score,
|
||||||
"project_score": project_score,
|
"project_score": project_score,
|
||||||
"level": "signal",
|
"level": level,
|
||||||
"level_confidence": 0.35 if signal_intake_hint else 0.25,
|
"level_confidence": max(project_score, 1 - project_score),
|
||||||
"level_reasoning": "结构化分类暂时不可用,当前先按低置信信号待确认处理。",
|
"level_reasoning": level_reasoning,
|
||||||
"should_split": False,
|
"should_split": should_split,
|
||||||
"split_items": [],
|
"split_items": (
|
||||||
|
[
|
||||||
|
{"level": "project", "content": text},
|
||||||
|
{"level": "signal", "content": text},
|
||||||
|
]
|
||||||
|
if should_split
|
||||||
|
else []
|
||||||
|
),
|
||||||
}
|
}
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _build_signal_extraction_degraded(*, message: str, signal_intake_hint: bool) -> dict[str, Any]:
|
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]:
|
||||||
text = str(message or "").strip()
|
text = str(message or "").strip()
|
||||||
|
normalized = text.lower()
|
||||||
cleaned = re.sub(r"\s+", " ", text).strip()
|
cleaned = re.sub(r"\s+", " ", text).strip()
|
||||||
summary = cleaned[:280] if cleaned else "原始飞书消息暂未解析出稳定信号结构。"
|
summary = cleaned[:280] if cleaned else "飞书消息(空内容)"
|
||||||
title = (cleaned[:48] or "飞书需求信号").strip() or "飞书需求信号"
|
|
||||||
|
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
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"title": title[:200],
|
"title": title,
|
||||||
"type": "新需求",
|
"type": signal_type,
|
||||||
"priority": "P2",
|
"priority": priority,
|
||||||
"matched_project_id": None,
|
"matched_project_id": self._guess_matched_project_id_from_text(text, projects),
|
||||||
"summary": summary,
|
"summary": summary,
|
||||||
"reasoning": ["结构化信号提取失败,当前仅保留原始消息,待确认后再绑定正式对象。"],
|
"reasoning": ["LLM 返回异常,已按关键词规则生成兜底信号结构。"],
|
||||||
"confidence": 0.35 if signal_intake_hint else 0.3,
|
"confidence": confidence,
|
||||||
"recommended_action": "enter_only",
|
"recommended_action": recommended_action,
|
||||||
}
|
}
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _build_project_prefill_degraded(*, message: str) -> dict[str, Any]:
|
def _heuristic_project_prefill_fallback(message: str) -> dict[str, Any]:
|
||||||
text = str(message or "").strip()
|
text = str(message or "").strip()
|
||||||
cleaned = re.sub(r"\s+", " ", text).strip()
|
cleaned = re.sub(r"\s+", " ", text).strip()
|
||||||
suggested_name = cleaned[:30] if cleaned else "飞书新需求项目"
|
suggested_name = cleaned[:30] if cleaned else "飞书新需求项目"
|
||||||
|
|
@ -186,12 +257,11 @@ class FeishuPipelineExtractionMixin:
|
||||||
return {
|
return {
|
||||||
"suggested_name": suggested_name,
|
"suggested_name": suggested_name,
|
||||||
"suggested_description": suggested_description,
|
"suggested_description": suggested_description,
|
||||||
"suggested_scope": None,
|
|
||||||
"suggested_owner": None,
|
"suggested_owner": None,
|
||||||
"suggested_timeline": None,
|
"suggested_timeline": None,
|
||||||
"confidence": 0.35,
|
"confidence": 0.35,
|
||||||
"reasoning": [
|
"reasoning": [
|
||||||
"结构化项目预填失败,当前仅保留原始描述,待补充标题、范围和负责人。",
|
"LLM 返回异常,已按规则生成项目预填草案。",
|
||||||
"负责人和时间线仅在消息明确给出时才填写。",
|
"负责人和时间线仅在消息明确给出时才填写。",
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -118,7 +118,7 @@ class FeishuPipelineIntentGateCoreMixin:
|
||||||
return gate
|
return gate
|
||||||
|
|
||||||
if explicit_project_request:
|
if explicit_project_request:
|
||||||
fields = await self._extract_structured_project_fields(text)
|
fields = self._extract_project_intent_fields(text)
|
||||||
fields = await self._maybe_prefill_project_fields_from_embedded_content(
|
fields = await self._maybe_prefill_project_fields_from_embedded_content(
|
||||||
message=text,
|
message=text,
|
||||||
fields=fields,
|
fields=fields,
|
||||||
|
|
@ -200,11 +200,43 @@ class FeishuPipelineIntentGateCoreMixin:
|
||||||
"correction_match": correction_match,
|
"correction_match": correction_match,
|
||||||
}
|
}
|
||||||
|
|
||||||
async def _extract_structured_project_fields(self, message: str) -> dict[str, Any]:
|
@staticmethod
|
||||||
project_info = await self._extract_project_prefill(message)
|
def _extract_project_scope(text: str, *, description: str) -> str:
|
||||||
title = normalize_project_title_candidate(str(project_info.get("suggested_name") or "").strip())
|
cleaned = re.sub(r"\s+", " ", str(text or "")).strip()
|
||||||
description = str(project_info.get("suggested_description") or "").strip()[:4000]
|
for pattern in (
|
||||||
project_scope = str(project_info.get("suggested_scope") or "").strip()[:400]
|
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)
|
||||||
missing_fields: list[str] = []
|
missing_fields: list[str] = []
|
||||||
if not title:
|
if not title:
|
||||||
missing_fields.append("title")
|
missing_fields.append("title")
|
||||||
|
|
@ -220,6 +252,30 @@ class FeishuPipelineIntentGateCoreMixin:
|
||||||
"ready": not missing_fields,
|
"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
|
@staticmethod
|
||||||
def _strip_feishu_mention_prefix(text: str) -> str:
|
def _strip_feishu_mention_prefix(text: str) -> str:
|
||||||
cleaned = re.sub(r"^<at[^>]*>[^<]*</at>\s*", "", str(text or ""), flags=re.IGNORECASE).strip()
|
cleaned = re.sub(r"^<at[^>]*>[^<]*</at>\s*", "", str(text or ""), flags=re.IGNORECASE).strip()
|
||||||
|
|
|
||||||
|
|
@ -23,7 +23,7 @@ class FeishuPipelineItemFinalizeRoutingMixin:
|
||||||
signal_auto_enter_threshold: float,
|
signal_auto_enter_threshold: float,
|
||||||
chat_context_resolution: dict[str, Any],
|
chat_context_resolution: dict[str, Any],
|
||||||
inferred_project_id: Optional[int],
|
inferred_project_id: Optional[int],
|
||||||
signal_intake_hint: bool,
|
simple_signal_request: bool,
|
||||||
clarification_message: Optional[str],
|
clarification_message: Optional[str],
|
||||||
item_started_at: float,
|
item_started_at: float,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
|
|
@ -84,7 +84,7 @@ class FeishuPipelineItemFinalizeRoutingMixin:
|
||||||
{
|
{
|
||||||
"level_classification": level_result,
|
"level_classification": level_result,
|
||||||
"signal_extraction": signal_info,
|
"signal_extraction": signal_info,
|
||||||
"signal_intake_hint": signal_intake_hint,
|
"simple_signal_request": simple_signal_request,
|
||||||
"chat_context_inferred_project_id": inferred_project_id,
|
"chat_context_inferred_project_id": inferred_project_id,
|
||||||
"chat_context_resolution": chat_context_resolution,
|
"chat_context_resolution": chat_context_resolution,
|
||||||
"clarification_message": clarification_message,
|
"clarification_message": clarification_message,
|
||||||
|
|
|
||||||
|
|
@ -28,7 +28,6 @@ class FeishuPipelineItemProcessingMixin:
|
||||||
split_from_item_id: Optional[int],
|
split_from_item_id: Optional[int],
|
||||||
chat_type: str,
|
chat_type: str,
|
||||||
chat_context_resolution: dict[str, Any],
|
chat_context_resolution: dict[str, Any],
|
||||||
signal_intake_hint: bool,
|
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
item_started_at = time.perf_counter()
|
item_started_at = time.perf_counter()
|
||||||
level_val = "project" if level == "project" else "signal"
|
level_val = "project" if level == "project" else "signal"
|
||||||
|
|
@ -120,11 +119,8 @@ class FeishuPipelineItemProcessingMixin:
|
||||||
item_started_at=item_started_at,
|
item_started_at=item_started_at,
|
||||||
)
|
)
|
||||||
|
|
||||||
signal_info = await self._extract_signal(
|
simple_signal_request = self._is_simple_signal_source_request(content)
|
||||||
content,
|
signal_info = await self._extract_signal(content, projects=projects)
|
||||||
projects=projects,
|
|
||||||
signal_intake_hint=signal_intake_hint,
|
|
||||||
)
|
|
||||||
matched_project_id = signal_info.get("matched_project_id")
|
matched_project_id = signal_info.get("matched_project_id")
|
||||||
if matched_project_id is not None:
|
if matched_project_id is not None:
|
||||||
try:
|
try:
|
||||||
|
|
@ -133,11 +129,11 @@ class FeishuPipelineItemProcessingMixin:
|
||||||
matched_project_id = None
|
matched_project_id = None
|
||||||
|
|
||||||
confidence = _safe_float(signal_info.get("confidence"), max(0.55, 1 - project_score))
|
confidence = _safe_float(signal_info.get("confidence"), max(0.55, 1 - project_score))
|
||||||
if signal_intake_hint:
|
if simple_signal_request:
|
||||||
confidence = max(confidence, min(0.95, signal_auto_enter_threshold + 0.05))
|
confidence = max(confidence, min(0.95, signal_auto_enter_threshold + 0.05))
|
||||||
inferred_project_id: Optional[int] = None
|
inferred_project_id: Optional[int] = None
|
||||||
clarification_message: Optional[str] = None
|
clarification_message: Optional[str] = None
|
||||||
if (chat_context_auto_link_enabled or signal_intake_hint) and matched_project_id is None and source_chat_id:
|
if (chat_context_auto_link_enabled or simple_signal_request) and matched_project_id is None and source_chat_id:
|
||||||
inferred_project_id = _safe_int(chat_context_resolution.get("project_id"))
|
inferred_project_id = _safe_int(chat_context_resolution.get("project_id"))
|
||||||
if inferred_project_id is not None:
|
if inferred_project_id is not None:
|
||||||
matched_project_id = int(inferred_project_id)
|
matched_project_id = int(inferred_project_id)
|
||||||
|
|
@ -154,7 +150,7 @@ class FeishuPipelineItemProcessingMixin:
|
||||||
signal_info=signal_info,
|
signal_info=signal_info,
|
||||||
confidence=confidence,
|
confidence=confidence,
|
||||||
signal_auto_enter_threshold=signal_auto_enter_threshold,
|
signal_auto_enter_threshold=signal_auto_enter_threshold,
|
||||||
signal_intake_hint=signal_intake_hint,
|
simple_signal_request=simple_signal_request,
|
||||||
):
|
):
|
||||||
promoted_project_score = max(float(project_score or 0.0), float(project_uncertain_threshold))
|
promoted_project_score = max(float(project_score or 0.0), float(project_uncertain_threshold))
|
||||||
item.level = "project"
|
item.level = "project"
|
||||||
|
|
@ -186,7 +182,7 @@ class FeishuPipelineItemProcessingMixin:
|
||||||
extra_ai_fields={
|
extra_ai_fields={
|
||||||
"signal_extraction": signal_info,
|
"signal_extraction": signal_info,
|
||||||
"promoted_to_project_candidate": True,
|
"promoted_to_project_candidate": True,
|
||||||
"signal_intake_hint": signal_intake_hint,
|
"simple_signal_request": simple_signal_request,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -206,7 +202,7 @@ class FeishuPipelineItemProcessingMixin:
|
||||||
signal_auto_enter_threshold=signal_auto_enter_threshold,
|
signal_auto_enter_threshold=signal_auto_enter_threshold,
|
||||||
chat_context_resolution=chat_context_resolution,
|
chat_context_resolution=chat_context_resolution,
|
||||||
inferred_project_id=inferred_project_id,
|
inferred_project_id=inferred_project_id,
|
||||||
signal_intake_hint=signal_intake_hint,
|
simple_signal_request=simple_signal_request,
|
||||||
clarification_message=clarification_message,
|
clarification_message=clarification_message,
|
||||||
item_started_at=item_started_at,
|
item_started_at=item_started_at,
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,11 @@ from .shared import *
|
||||||
|
|
||||||
|
|
||||||
class FeishuPipelineLookupAndBlockMixin:
|
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(
|
async def _maybe_block_legacy_command_request(
|
||||||
self,
|
self,
|
||||||
*,
|
*,
|
||||||
|
|
@ -31,6 +36,81 @@ class FeishuPipelineLookupAndBlockMixin:
|
||||||
)
|
)
|
||||||
return None
|
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(
|
async def _build_legacy_command_blocked_capture(
|
||||||
self,
|
self,
|
||||||
*,
|
*,
|
||||||
|
|
@ -74,6 +154,36 @@ class FeishuPipelineLookupAndBlockMixin:
|
||||||
"command_result": None,
|
"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
|
@classmethod
|
||||||
def _should_hide_pending_item_from_inbox(cls, item: FeishuItem) -> bool:
|
def _should_hide_pending_item_from_inbox(cls, item: FeishuItem) -> bool:
|
||||||
if str(item.decision_reason or "").strip() == "missing_required_fields":
|
if str(item.decision_reason or "").strip() == "missing_required_fields":
|
||||||
|
|
|
||||||
|
|
@ -229,9 +229,26 @@ class FeishuPipelineMessageUtilsBMixin:
|
||||||
signal_info: dict[str, Any],
|
signal_info: dict[str, Any],
|
||||||
confidence: float,
|
confidence: float,
|
||||||
signal_auto_enter_threshold: float,
|
signal_auto_enter_threshold: float,
|
||||||
signal_intake_hint: bool,
|
simple_signal_request: bool,
|
||||||
) -> bool:
|
) -> bool:
|
||||||
_ = (content, signal_info, confidence, signal_auto_enter_threshold, signal_intake_hint)
|
_ = (content, signal_info, confidence, signal_auto_enter_threshold, simple_signal_request)
|
||||||
# 项目创建只能通过 intent gate:create_project + 受控 operation executor 落库;
|
# 项目创建只能通过 intent gate:create_project + 受控 operation executor 落库;
|
||||||
# 不再把 signal 自由升级为 project candidate。
|
# 不再把 signal 自由升级为 project candidate。
|
||||||
return False
|
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)
|
||||||
|
)
|
||||||
|
|
|
||||||
|
|
@ -160,6 +160,23 @@ class FeishuPipelineProcessMessageCoreMixin:
|
||||||
intent_gate=intent_gate,
|
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(
|
gate_capture = await self._maybe_handle_intent_gate_capture(
|
||||||
intent_gate=intent_gate,
|
intent_gate=intent_gate,
|
||||||
message=message,
|
message=message,
|
||||||
|
|
@ -190,6 +207,5 @@ class FeishuPipelineProcessMessageCoreMixin:
|
||||||
process_started_at=process_started_at,
|
process_started_at=process_started_at,
|
||||||
normalized_chat_type=normalized_chat_type,
|
normalized_chat_type=normalized_chat_type,
|
||||||
chat_context_resolution=chat_context_resolution,
|
chat_context_resolution=chat_context_resolution,
|
||||||
intent_gate=intent_gate,
|
|
||||||
thread_context=thread_context,
|
thread_context=thread_context,
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -20,7 +20,6 @@ class FeishuPipelineProcessMessageDefaultMixin:
|
||||||
process_started_at: float,
|
process_started_at: float,
|
||||||
normalized_chat_type: str,
|
normalized_chat_type: str,
|
||||||
chat_context_resolution: dict[str, Any],
|
chat_context_resolution: dict[str, Any],
|
||||||
intent_gate: Optional[dict[str, Any]] = None,
|
|
||||||
thread_context: Optional[dict[str, Any]] = None,
|
thread_context: Optional[dict[str, Any]] = None,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
rag_service = AdaptiveRAGService(self.db, tenant_id=self.tenant_id)
|
rag_service = AdaptiveRAGService(self.db, tenant_id=self.tenant_id)
|
||||||
|
|
@ -42,9 +41,6 @@ class FeishuPipelineProcessMessageDefaultMixin:
|
||||||
intent_for_response = intent
|
intent_for_response = intent
|
||||||
rag_question_part = str(intent_result.get("question_part") or "").strip() or message
|
rag_question_part = str(intent_result.get("question_part") or "").strip() or message
|
||||||
rag_statement_part = str(intent_result.get("statement_part") or "").strip()
|
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 intent == "question":
|
||||||
if semantic_message_intent in {
|
if semantic_message_intent in {
|
||||||
|
|
@ -120,12 +116,10 @@ class FeishuPipelineProcessMessageDefaultMixin:
|
||||||
sidecar_rag_result = mixed_rag_result
|
sidecar_rag_result = mixed_rag_result
|
||||||
|
|
||||||
try:
|
try:
|
||||||
signal_intake_hint = bool(force_signal_fastpath or structured_signal_intake)
|
if force_signal_fastpath:
|
||||||
level_result = await self._classify_level(
|
level_result = self._heuristic_level_fallback(message)
|
||||||
message=message,
|
else:
|
||||||
projects=projects,
|
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)
|
project_score = _safe_float(level_result.get("project_score"), 0.0)
|
||||||
level = _normalize_level_value(
|
level = _normalize_level_value(
|
||||||
level_result.get("level"),
|
level_result.get("level"),
|
||||||
|
|
@ -182,7 +176,6 @@ class FeishuPipelineProcessMessageDefaultMixin:
|
||||||
split_from_item_id=parent_item_id if idx > 0 else None,
|
split_from_item_id=parent_item_id if idx > 0 else None,
|
||||||
chat_type=normalized_chat_type,
|
chat_type=normalized_chat_type,
|
||||||
chat_context_resolution=chat_context_resolution,
|
chat_context_resolution=chat_context_resolution,
|
||||||
signal_intake_hint=signal_intake_hint,
|
|
||||||
)
|
)
|
||||||
if idx == 0 and item_result.get("item_id"):
|
if idx == 0 and item_result.get("item_id"):
|
||||||
parent_item_id = int(item_result["item_id"])
|
parent_item_id = int(item_result["item_id"])
|
||||||
|
|
|
||||||
|
|
@ -67,7 +67,11 @@ class FeishuPipelineSerializationMixin:
|
||||||
"missing_fields": [],
|
"missing_fields": [],
|
||||||
"ready": False,
|
"ready": False,
|
||||||
}
|
}
|
||||||
if not all(str(project_fields.get(field) or "").strip() for field in ("title", "description", "project_scope")):
|
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"])
|
||||||
|
):
|
||||||
project_fields = await self._maybe_prefill_project_fields_from_context(
|
project_fields = await self._maybe_prefill_project_fields_from_context(
|
||||||
message=str(item.raw_message or ""),
|
message=str(item.raw_message or ""),
|
||||||
source_chat_id=str(item.source_chat_id or ""),
|
source_chat_id=str(item.source_chat_id or ""),
|
||||||
|
|
|
||||||
|
|
@ -41,7 +41,6 @@ class _SignalExtractionSchema(BaseModel):
|
||||||
class _ProjectPrefillSchema(BaseModel):
|
class _ProjectPrefillSchema(BaseModel):
|
||||||
suggested_name: str = Field(min_length=1, max_length=255)
|
suggested_name: str = Field(min_length=1, max_length=255)
|
||||||
suggested_description: str = Field(min_length=1, max_length=4000)
|
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_owner: Optional[str] = Field(default=None, max_length=120)
|
||||||
suggested_timeline: 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)
|
confidence: float = Field(ge=0.0, le=1.0)
|
||||||
|
|
|
||||||
|
|
@ -11,25 +11,28 @@ class SignalCaptureEntryMixin:
|
||||||
self.db = db
|
self.db = db
|
||||||
self.llm = LLMService()
|
self.llm = LLMService()
|
||||||
|
|
||||||
@staticmethod
|
@classmethod
|
||||||
def _semantic_bundle_requests_group_followup(
|
def _matches_active_group_protocol_followup(
|
||||||
semantic_bundle: Optional[Any],
|
cls,
|
||||||
|
*,
|
||||||
|
active_context_kind: Optional[str],
|
||||||
|
message_text: str,
|
||||||
) -> bool:
|
) -> bool:
|
||||||
if not isinstance(semantic_bundle, dict):
|
normalized_kind = str(active_context_kind or "").strip().lower()
|
||||||
return False
|
if normalized_kind == "requirement_plan":
|
||||||
primary_workflow = str(semantic_bundle.get("primary_workflow") or "").strip().lower()
|
try:
|
||||||
if primary_workflow == "followup_flow":
|
from ...ai_pmo.ai_pmo_typed_executor_execute.mixin_execute_query_project import ExecuteQueryProjectMixin
|
||||||
return True
|
|
||||||
for item in semantic_bundle.get("workflows") or []:
|
return ExecuteQueryProjectMixin._parse_requirement_plan_choice_protocol(message_text) is not None
|
||||||
if not isinstance(item, dict) and not hasattr(item, "workflow_type"):
|
except Exception:
|
||||||
continue
|
return False
|
||||||
workflow_type = (
|
if normalized_kind == "draft_pending_confirm":
|
||||||
str(item.get("workflow_type") or "").strip().lower()
|
try:
|
||||||
if isinstance(item, dict)
|
from ...ai_pmo.draft_confirm_handler import DraftConfirmHandler
|
||||||
else str(getattr(item, "workflow_type", "") or "").strip().lower()
|
|
||||||
)
|
return DraftConfirmHandler.is_explicit_protocol_followup_text(message_text)
|
||||||
if workflow_type == "followup_flow":
|
except Exception:
|
||||||
return True
|
return False
|
||||||
return False
|
return False
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
|
|
@ -60,40 +63,49 @@ class SignalCaptureEntryMixin:
|
||||||
source_message_id: str,
|
source_message_id: str,
|
||||||
thread_context: Optional[dict[str, Any]],
|
thread_context: Optional[dict[str, Any]],
|
||||||
message_text: str,
|
message_text: str,
|
||||||
semantic_bundle: Optional[dict[str, Any]],
|
|
||||||
) -> Optional[dict[str, Any]]:
|
) -> Optional[dict[str, Any]]:
|
||||||
conversation_key = build_feishu_conversation_scope_key(
|
conversation_key = build_feishu_conversation_scope_key(
|
||||||
source_chat_id=group_chat_id,
|
source_chat_id=group_chat_id,
|
||||||
source_sender_open_id=proposer_open_id,
|
source_sender_open_id=proposer_open_id,
|
||||||
sender=proposer_name,
|
sender=proposer_name,
|
||||||
) or source_message_id
|
) or source_message_id
|
||||||
finder = getattr(kernel, "_find_active_requirement_plan_workspace", None)
|
try:
|
||||||
if not callable(finder):
|
from ...ai_pmo.ai_pmo_typed_executor_execute.mixin_execute_query_project import ExecuteQueryProjectMixin
|
||||||
finder = getattr(getattr(kernel, "executor", None), "_find_active_requirement_plan_workspace", None)
|
|
||||||
derive_kind = getattr(kernel, "_derive_active_context_kind", None)
|
parsed_choice = ExecuteQueryProjectMixin._parse_requirement_plan_choice_protocol(message_text)
|
||||||
if callable(finder) and callable(derive_kind):
|
except Exception:
|
||||||
try:
|
parsed_choice = None
|
||||||
topic, draft = await finder(
|
if isinstance(parsed_choice, dict):
|
||||||
source_system="feishu",
|
finder = getattr(kernel, "_find_active_requirement_plan_workspace", None)
|
||||||
source_message_id=source_message_id,
|
if not callable(finder):
|
||||||
source_chat_id=group_chat_id,
|
finder = getattr(getattr(kernel, "executor", None), "_find_active_requirement_plan_workspace", None)
|
||||||
sender=None,
|
derive_kind = getattr(kernel, "_derive_active_context_kind", None)
|
||||||
source_sender_open_id=proposer_open_id,
|
if callable(finder) and callable(derive_kind):
|
||||||
thread_context=thread_context,
|
try:
|
||||||
conversation_key=conversation_key,
|
topic, draft = await finder(
|
||||||
)
|
source_system="feishu",
|
||||||
except Exception:
|
source_message_id=source_message_id,
|
||||||
topic, draft = None, None
|
source_chat_id=group_chat_id,
|
||||||
if topic is not None and draft is not None:
|
sender=None,
|
||||||
active_context_kind = derive_kind(topic=topic, draft=draft)
|
source_sender_open_id=proposer_open_id,
|
||||||
if str(active_context_kind or "").strip().lower() in {"requirement_plan", "draft_pending_confirm"}:
|
thread_context=thread_context,
|
||||||
return {
|
conversation_key=conversation_key,
|
||||||
"active_context_kind": active_context_kind,
|
)
|
||||||
"thread_context": self._build_group_protocol_followup_thread_context(
|
except Exception:
|
||||||
topic=topic,
|
topic, draft = None, None
|
||||||
thread_context=thread_context,
|
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,
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
binder = getattr(kernel, "_bind_active_context_target", None)
|
binder = getattr(kernel, "_bind_active_context_target", None)
|
||||||
if not callable(binder):
|
if not callable(binder):
|
||||||
|
|
@ -114,7 +126,10 @@ class SignalCaptureEntryMixin:
|
||||||
if not isinstance(active_target, tuple) or len(active_target) < 3:
|
if not isinstance(active_target, tuple) or len(active_target) < 3:
|
||||||
return None
|
return None
|
||||||
topic, _, active_context_kind = active_target
|
topic, _, active_context_kind = active_target
|
||||||
if str(active_context_kind or "").strip().lower() not in {"requirement_plan", "draft_pending_confirm"}:
|
if not self._matches_active_group_protocol_followup(
|
||||||
|
active_context_kind=active_context_kind,
|
||||||
|
message_text=message_text,
|
||||||
|
):
|
||||||
return None
|
return None
|
||||||
return {
|
return {
|
||||||
"active_context_kind": active_context_kind,
|
"active_context_kind": active_context_kind,
|
||||||
|
|
@ -124,6 +139,28 @@ 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
|
@classmethod
|
||||||
async def _should_process_feishu_high_accuracy(
|
async def _should_process_feishu_high_accuracy(
|
||||||
cls,
|
cls,
|
||||||
|
|
@ -264,6 +301,9 @@ class SignalCaptureEntryMixin:
|
||||||
kernel = AIPMOKernelService(self.db, tenant_id=tenant_id)
|
kernel = AIPMOKernelService(self.db, tenant_id=tenant_id)
|
||||||
try:
|
try:
|
||||||
normalized_chat_type = str(chat_type or "").strip().lower()
|
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)
|
should_sync_process = bool(force_sync)
|
||||||
sync_reason = "force_sync" if should_sync_process else ""
|
sync_reason = "force_sync" if should_sync_process else ""
|
||||||
high_accuracy_bundle = None
|
high_accuracy_bundle = None
|
||||||
|
|
@ -273,20 +313,12 @@ class SignalCaptureEntryMixin:
|
||||||
)
|
)
|
||||||
|
|
||||||
protocol_followup_context = None
|
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 = (
|
should_probe_group_protocol_followup = (
|
||||||
normalized_chat_type == "group"
|
normalized_chat_type == "group"
|
||||||
and (semantic_followup_probe or has_explicit_reply_anchor)
|
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)
|
||||||
|
)
|
||||||
)
|
)
|
||||||
if should_probe_group_protocol_followup:
|
if should_probe_group_protocol_followup:
|
||||||
protocol_followup_context = await self._resolve_group_protocol_followup_without_explicit_trigger(
|
protocol_followup_context = await self._resolve_group_protocol_followup_without_explicit_trigger(
|
||||||
|
|
@ -297,7 +329,6 @@ class SignalCaptureEntryMixin:
|
||||||
source_message_id=source_message_id,
|
source_message_id=source_message_id,
|
||||||
thread_context=thread_context,
|
thread_context=thread_context,
|
||||||
message_text=cleaned_message,
|
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):
|
if isinstance(protocol_followup_context, dict):
|
||||||
should_sync_process = True
|
should_sync_process = True
|
||||||
|
|
@ -309,7 +340,10 @@ class SignalCaptureEntryMixin:
|
||||||
)
|
)
|
||||||
if normalized_thread_context:
|
if normalized_thread_context:
|
||||||
thread_context = normalized_thread_context
|
thread_context = normalized_thread_context
|
||||||
elif not has_explicit_group_trigger:
|
elif (
|
||||||
|
not has_explicit_group_trigger
|
||||||
|
and str(sync_reason or "").strip().lower() in {"semantic:query_flow", "semantic:followup_flow"}
|
||||||
|
):
|
||||||
self._emit_ai_think(
|
self._emit_ai_think(
|
||||||
"capture_skip_group_explicit_trigger_required",
|
"capture_skip_group_explicit_trigger_required",
|
||||||
trace_id=trace_id,
|
trace_id=trace_id,
|
||||||
|
|
@ -328,29 +362,6 @@ class SignalCaptureEntryMixin:
|
||||||
"direct_reply_required": False,
|
"direct_reply_required": False,
|
||||||
"direct_reply_text": "",
|
"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
|
enqueue_session = None
|
||||||
if not should_sync_process:
|
if not should_sync_process:
|
||||||
|
|
@ -681,7 +692,7 @@ class SignalCaptureEntryMixin:
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"direct_reply_required": True,
|
"direct_reply_required": True,
|
||||||
"direct_reply_text": "⏳ 已完成入库,当前进入待确认队列。",
|
"direct_reply_text": "已收到,正在处理。",
|
||||||
"reply_template_key": "async_processing_ack",
|
"reply_template_key": "async_processing_ack",
|
||||||
"work_item_id": None,
|
"work_item_id": None,
|
||||||
"decision_level": None,
|
"decision_level": None,
|
||||||
|
|
|
||||||
|
|
@ -145,42 +145,6 @@ class _Harness(
|
||||||
"confidence": 0.9,
|
"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
|
@pytest.mark.asyncio
|
||||||
async def test_external_conversation_key_creates_and_reuses_real_qa_session(db_session) -> None:
|
async def test_external_conversation_key_creates_and_reuses_real_qa_session(db_session) -> None:
|
||||||
|
|
@ -422,7 +386,7 @@ async def test_pending_clarification_selection_restores_original_question(db_ses
|
||||||
}
|
}
|
||||||
await db_session.commit()
|
await db_session.commit()
|
||||||
|
|
||||||
resolved = await service._consume_pending_clarification_selection(
|
resolved = service._consume_pending_clarification_selection(
|
||||||
question="第二条",
|
question="第二条",
|
||||||
session_state=state,
|
session_state=state,
|
||||||
)
|
)
|
||||||
|
|
@ -432,72 +396,6 @@ async def test_pending_clarification_selection_restores_original_question(db_ses
|
||||||
assert resolved["effective_question"] == "我问你这个是哪个模型执行回复的?"
|
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
|
@pytest.mark.asyncio
|
||||||
async def test_no_anchor_clarification_prefers_reply_or_object_id_guidance(db_session) -> None:
|
async def test_no_anchor_clarification_prefers_reply_or_object_id_guidance(db_session) -> None:
|
||||||
service = _Harness(db_session)
|
service = _Harness(db_session)
|
||||||
|
|
@ -938,191 +836,6 @@ async def test_standalone_project_lookup_is_not_misclassified_as_followup_anchor
|
||||||
assert resolution.candidate_replies == []
|
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
|
@pytest.mark.asyncio
|
||||||
async def test_meta_followup_model_identity_reads_routing_snapshot_and_hides_real_model(db_session) -> None:
|
async def test_meta_followup_model_identity_reads_routing_snapshot_and_hides_real_model(db_session) -> None:
|
||||||
service = _Harness(db_session)
|
service = _Harness(db_session)
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
|
|
@ -155,176 +155,6 @@ async def test_http_rag_ask_mixed_mainline_contract(monkeypatch) -> None:
|
||||||
assert rag_request_context["mixed_signal_part"] == "新增任务确认流"
|
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
|
@pytest.mark.asyncio
|
||||||
async def test_http_ai_pmo_process_text_draft_pending_confirm_mainline(monkeypatch) -> None:
|
async def test_http_ai_pmo_process_text_draft_pending_confirm_mainline(monkeypatch) -> None:
|
||||||
captured: dict[str, object] = {}
|
captured: dict[str, object] = {}
|
||||||
|
|
@ -371,47 +201,6 @@ async def test_http_ai_pmo_process_text_draft_pending_confirm_mainline(monkeypat
|
||||||
assert kwargs["project_hint_id"] == 12
|
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
|
@pytest.mark.asyncio
|
||||||
async def test_http_ai_pmo_work_items_list_smoke(monkeypatch) -> None:
|
async def test_http_ai_pmo_work_items_list_smoke(monkeypatch) -> None:
|
||||||
class FakeResult:
|
class FakeResult:
|
||||||
|
|
|
||||||
|
|
@ -1,26 +1,11 @@
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from types import SimpleNamespace
|
|
||||||
from unittest.mock import AsyncMock
|
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from app.services.ai_pmo.ai_pmo_intent import CanonicalIntent
|
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 (
|
from app.services.ai_pmo.ai_pmo_typed_executor_discovery.mixin_discover import (
|
||||||
DiscoveryFlowMixin,
|
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 (
|
from app.services.ai_pmo.ai_pmo_typed_executor_execute.mixin_execute_signal_source import (
|
||||||
ExecuteSignalSourceMixin,
|
ExecuteSignalSourceMixin,
|
||||||
)
|
)
|
||||||
|
|
@ -132,427 +117,6 @@ async def test_signal_source_onboard_replays_existing_ledger_without_reexecution
|
||||||
assert kernel.signal_source_workflow.called is False
|
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
|
@pytest.mark.asyncio
|
||||||
async def test_project_signal_discovery_uses_project_id_without_name_error() -> None:
|
async def test_project_signal_discovery_uses_project_id_without_name_error() -> None:
|
||||||
class _FakeDiscovery(DiscoveryFlowMixin):
|
class _FakeDiscovery(DiscoveryFlowMixin):
|
||||||
|
|
@ -646,69 +210,3 @@ def test_signal_source_kind_uses_channel_config_without_hint_table() -> None:
|
||||||
)
|
)
|
||||||
== "feishu"
|
== "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"
|
|
||||||
|
|
|
||||||
|
|
@ -1,984 +0,0 @@
|
||||||
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"
|
|
||||||
|
|
@ -1,407 +0,0 @@
|
||||||
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
|
|
||||||
|
|
@ -1,184 +0,0 @@
|
||||||
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"] == {}
|
|
||||||
|
|
@ -1,81 +0,0 @@
|
||||||
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
|
|
||||||
|
|
@ -2192,17 +2192,18 @@ async def test_draft_confirm_handler_target_bound_without_semantic_action_return
|
||||||
|
|
||||||
assert result["handled"] is True
|
assert result["handled"] is True
|
||||||
assert result["status"] == "pending_manual"
|
assert result["status"] == "pending_manual"
|
||||||
assert "继续说明你要确认、修改、重出还是取消" in result["followup_reply"]
|
assert "请回复“确认”、“修改 + 你的意见”、“重出一版”或“取消”" in result["followup_reply"]
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
@pytest.mark.parametrize("message_text", ["确认", "确认入库", "确认入库。"])
|
@pytest.mark.parametrize("message_text", ["确认", "确认入库", "确认入库。"])
|
||||||
async def test_draft_confirm_handler_confirm_text_without_semantic_action_returns_pending_manual(
|
async def test_draft_confirm_handler_explicit_confirm_without_semantic_action_routes_to_confirm(
|
||||||
monkeypatch,
|
monkeypatch,
|
||||||
message_text: str,
|
message_text: str,
|
||||||
) -> None:
|
) -> None:
|
||||||
kernel = type("Kernel", (), {"db": object(), "tenant_id": 1})()
|
kernel = type("Kernel", (), {"db": object(), "tenant_id": 1})()
|
||||||
handler = DraftConfirmHandler(kernel)
|
handler = DraftConfirmHandler(kernel)
|
||||||
|
captured: dict[str, object] = {}
|
||||||
|
|
||||||
async def _fake_resolve_pending_draft(**kwargs):
|
async def _fake_resolve_pending_draft(**kwargs):
|
||||||
_ = kwargs
|
_ = kwargs
|
||||||
|
|
@ -2226,7 +2227,18 @@ async def test_draft_confirm_handler_confirm_text_without_semantic_action_return
|
||||||
)
|
)
|
||||||
|
|
||||||
async def _fake_confirm_draft(**kwargs):
|
async def _fake_confirm_draft(**kwargs):
|
||||||
raise AssertionError(f"confirm_draft should not run without structured followup action: {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",
|
||||||
|
}
|
||||||
|
|
||||||
async def _unexpected_cancel(**kwargs): # noqa: ANN001
|
async def _unexpected_cancel(**kwargs): # noqa: ANN001
|
||||||
raise AssertionError(f"cancel_draft should not run here: {kwargs}")
|
raise AssertionError(f"cancel_draft should not run here: {kwargs}")
|
||||||
|
|
@ -2252,18 +2264,21 @@ async def test_draft_confirm_handler_confirm_text_without_semantic_action_return
|
||||||
)
|
)
|
||||||
|
|
||||||
assert result["handled"] is True
|
assert result["handled"] is True
|
||||||
assert result["status"] == "pending_manual"
|
assert result["status"] == "succeeded"
|
||||||
assert "继续说明你要确认、修改、重出还是取消" in result["followup_reply"]
|
assert result["followup_reply"] == "✅ 已确认入库"
|
||||||
|
assert captured["confirm_kwargs"]["message_text"] == message_text
|
||||||
|
assert captured["confirm_kwargs"]["source_message_id"] == "msg-confirm-1"
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
@pytest.mark.parametrize("message_text", ["修改", "修改。", "修改 + 你的意见"])
|
@pytest.mark.parametrize("message_text", ["修改", "修改。", "修改 + 你的意见"])
|
||||||
async def test_draft_confirm_handler_revise_text_without_semantic_action_returns_pending_manual(
|
async def test_draft_confirm_handler_explicit_revise_without_semantic_action_routes_to_revise(
|
||||||
monkeypatch,
|
monkeypatch,
|
||||||
message_text: str,
|
message_text: str,
|
||||||
) -> None:
|
) -> None:
|
||||||
kernel = type("Kernel", (), {"db": object(), "tenant_id": 1})()
|
kernel = type("Kernel", (), {"db": object(), "tenant_id": 1})()
|
||||||
handler = DraftConfirmHandler(kernel)
|
handler = DraftConfirmHandler(kernel)
|
||||||
|
captured: dict[str, object] = {}
|
||||||
|
|
||||||
async def _fake_resolve_pending_draft(**kwargs):
|
async def _fake_resolve_pending_draft(**kwargs):
|
||||||
_ = kwargs
|
_ = kwargs
|
||||||
|
|
@ -2287,7 +2302,12 @@ async def test_draft_confirm_handler_revise_text_without_semantic_action_returns
|
||||||
)
|
)
|
||||||
|
|
||||||
async def _fake_revise_draft(**kwargs):
|
async def _fake_revise_draft(**kwargs):
|
||||||
raise AssertionError(f"revise_draft should not run without structured followup action: {kwargs}")
|
captured["revise_kwargs"] = kwargs
|
||||||
|
return {
|
||||||
|
"handled": True,
|
||||||
|
"status": "draft_pending_confirm",
|
||||||
|
"followup_reply": "我先按当前草稿补一版。",
|
||||||
|
}
|
||||||
|
|
||||||
async def _unexpected_confirm(**kwargs): # noqa: ANN001
|
async def _unexpected_confirm(**kwargs): # noqa: ANN001
|
||||||
raise AssertionError(f"confirm_draft should not run here: {kwargs}")
|
raise AssertionError(f"confirm_draft should not run here: {kwargs}")
|
||||||
|
|
@ -2317,18 +2337,21 @@ async def test_draft_confirm_handler_revise_text_without_semantic_action_returns
|
||||||
)
|
)
|
||||||
|
|
||||||
assert result["handled"] is True
|
assert result["handled"] is True
|
||||||
assert result["status"] == "pending_manual"
|
assert result["status"] == "draft_pending_confirm"
|
||||||
assert "继续说明你要确认、修改、重出还是取消" in result["followup_reply"]
|
assert "补一版" in result["followup_reply"]
|
||||||
|
assert captured["revise_kwargs"]["revision_note"] == ""
|
||||||
|
assert captured["revise_kwargs"]["revision_note"] == ""
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
@pytest.mark.parametrize("message_text", ["取消", "取消。", "取消本版。"])
|
@pytest.mark.parametrize("message_text", ["取消", "取消。", "取消本版。"])
|
||||||
async def test_draft_confirm_handler_cancel_text_without_semantic_action_returns_pending_manual(
|
async def test_draft_confirm_handler_explicit_cancel_without_semantic_action_routes_to_cancel(
|
||||||
monkeypatch,
|
monkeypatch,
|
||||||
message_text: str,
|
message_text: str,
|
||||||
) -> None:
|
) -> None:
|
||||||
kernel = type("Kernel", (), {"db": object(), "tenant_id": 1})()
|
kernel = type("Kernel", (), {"db": object(), "tenant_id": 1})()
|
||||||
handler = DraftConfirmHandler(kernel)
|
handler = DraftConfirmHandler(kernel)
|
||||||
|
captured: dict[str, object] = {}
|
||||||
|
|
||||||
async def _fake_resolve_pending_draft(**kwargs):
|
async def _fake_resolve_pending_draft(**kwargs):
|
||||||
_ = kwargs
|
_ = kwargs
|
||||||
|
|
@ -2352,7 +2375,18 @@ async def test_draft_confirm_handler_cancel_text_without_semantic_action_returns
|
||||||
)
|
)
|
||||||
|
|
||||||
async def _fake_cancel_draft(**kwargs):
|
async def _fake_cancel_draft(**kwargs):
|
||||||
raise AssertionError(f"cancel_draft should not run without structured followup action: {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",
|
||||||
|
}
|
||||||
|
|
||||||
async def _unexpected_confirm(**kwargs): # noqa: ANN001
|
async def _unexpected_confirm(**kwargs): # noqa: ANN001
|
||||||
raise AssertionError(f"confirm_draft should not run here: {kwargs}")
|
raise AssertionError(f"confirm_draft should not run here: {kwargs}")
|
||||||
|
|
@ -2378,14 +2412,16 @@ async def test_draft_confirm_handler_cancel_text_without_semantic_action_returns
|
||||||
)
|
)
|
||||||
|
|
||||||
assert result["handled"] is True
|
assert result["handled"] is True
|
||||||
assert result["status"] == "pending_manual"
|
assert result["status"] == "cancelled"
|
||||||
assert "继续说明你要确认、修改、重出还是取消" in result["followup_reply"]
|
assert result["followup_reply"] == "已取消,如需重新提交请再次发送。"
|
||||||
|
assert captured["cancel_kwargs"]["reason"] == "用户取消草稿"
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_draft_confirm_handler_regenerate_text_without_semantic_action_returns_pending_manual(monkeypatch) -> None:
|
async def test_draft_confirm_handler_explicit_regenerate_without_semantic_action_routes_to_regenerate(monkeypatch) -> None:
|
||||||
kernel = type("Kernel", (), {"db": object(), "tenant_id": 1})()
|
kernel = type("Kernel", (), {"db": object(), "tenant_id": 1})()
|
||||||
handler = DraftConfirmHandler(kernel)
|
handler = DraftConfirmHandler(kernel)
|
||||||
|
captured: dict[str, object] = {}
|
||||||
|
|
||||||
async def _fake_resolve_pending_draft(**kwargs):
|
async def _fake_resolve_pending_draft(**kwargs):
|
||||||
_ = kwargs
|
_ = kwargs
|
||||||
|
|
@ -2409,7 +2445,12 @@ async def test_draft_confirm_handler_regenerate_text_without_semantic_action_ret
|
||||||
)
|
)
|
||||||
|
|
||||||
async def _fake_regenerate_draft(**kwargs):
|
async def _fake_regenerate_draft(**kwargs):
|
||||||
raise AssertionError(f"regenerate_draft should not run without structured followup action: {kwargs}")
|
captured["regenerate_kwargs"] = kwargs
|
||||||
|
return {
|
||||||
|
"handled": True,
|
||||||
|
"status": "draft_pending_confirm",
|
||||||
|
"followup_reply": "我先按当前草稿重出一版。",
|
||||||
|
}
|
||||||
|
|
||||||
async def _unexpected_confirm(**kwargs): # noqa: ANN001
|
async def _unexpected_confirm(**kwargs): # noqa: ANN001
|
||||||
raise AssertionError(f"confirm_draft should not run here: {kwargs}")
|
raise AssertionError(f"confirm_draft should not run here: {kwargs}")
|
||||||
|
|
@ -2439,8 +2480,9 @@ async def test_draft_confirm_handler_regenerate_text_without_semantic_action_ret
|
||||||
)
|
)
|
||||||
|
|
||||||
assert result["handled"] is True
|
assert result["handled"] is True
|
||||||
assert result["status"] == "pending_manual"
|
assert result["status"] == "draft_pending_confirm"
|
||||||
assert "继续说明你要确认、修改、重出还是取消" in result["followup_reply"]
|
assert "重出一版" in result["followup_reply"]
|
||||||
|
assert captured["regenerate_kwargs"]["message_text"] == "重出一版。"
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
|
|
@ -2491,7 +2533,7 @@ async def test_draft_confirm_handler_target_bound_without_anchor_and_without_sem
|
||||||
|
|
||||||
assert result["handled"] is True
|
assert result["handled"] is True
|
||||||
assert result["status"] == "pending_manual"
|
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:
|
def test_kernel_ingress_mixin_auto_safe_int_regression() -> None:
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,4 @@
|
||||||
from types import SimpleNamespace
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
from app.services.external_query_service.planner import ExternalQueryPlanner
|
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:
|
def test_fallback_plan_stays_generic_for_fresh_weather_semantics() -> None:
|
||||||
|
|
@ -54,907 +49,3 @@ def test_fallback_plan_uses_compare_contract_without_object_kind_mapping() -> No
|
||||||
assert plan.answer_contract == "compare_with_sources"
|
assert plan.answer_contract == "compare_with_sources"
|
||||||
assert plan.requires_multi_source is True
|
assert plan.requires_multi_source is True
|
||||||
assert plan.result_limit == 3
|
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
|
|
||||||
|
|
|
||||||
|
|
@ -139,789 +139,6 @@ def test_build_action_card_for_requirement_plan_followup() -> None:
|
||||||
assert [((btn.get("value") or {}).get("synthetic_text")) for btn in row_2_actions] == ["继续出草稿", "重出一版"]
|
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:
|
def test_build_execution_receipt_reply_prefers_compact_multiline_summary() -> None:
|
||||||
reply = FeishuReplyTemplatesMixin._build_execution_receipt_reply(
|
reply = FeishuReplyTemplatesMixin._build_execution_receipt_reply(
|
||||||
message_content="请处理这条信号",
|
message_content="请处理这条信号",
|
||||||
|
|
@ -1162,83 +379,3 @@ async def test_route_scene_reply_sends_requirement_plan_followup_as_card(monkeyp
|
||||||
assert "reply_text" not in calls
|
assert "reply_text" not in calls
|
||||||
card_content = calls["reply_card"]["card_content"] if isinstance(calls["reply_card"], dict) else {}
|
card_content = calls["reply_card"]["card_content"] if isinstance(calls["reply_card"], dict) else {}
|
||||||
assert card_content["header"]["title"]["content"] == "AI PMO 需求收敛"
|
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
|
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,6 @@ from types import SimpleNamespace
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from app.services.ai_pmo.draft_confirm_handler import DraftConfirmHandler
|
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 (
|
from app.services.integrations.feishu_signal_pipeline.mixin_gate_project import (
|
||||||
FeishuPipelineGateProjectMixin,
|
FeishuPipelineGateProjectMixin,
|
||||||
)
|
)
|
||||||
|
|
@ -48,7 +47,6 @@ 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.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_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 import mixin_capture_entry
|
||||||
from app.services.signal.signal_capture_service.service import ProactiveSignalCaptureService
|
from app.services.signal.signal_capture_service.service import ProactiveSignalCaptureService
|
||||||
from app.services.signal.signal_capture_service.mixin_task_orchestration_main import (
|
from app.services.signal.signal_capture_service.mixin_task_orchestration_main import (
|
||||||
|
|
@ -224,20 +222,6 @@ def test_process_message_no_longer_calls_legacy_queue_followup() -> None:
|
||||||
assert "_maybe_handle_queue_followup" not in source
|
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:
|
def test_feishu_signal_pipeline_service_runtime_excludes_legacy_followup_mixins() -> None:
|
||||||
source = inspect.getsource(FeishuSignalPipelineService)
|
source = inspect.getsource(FeishuSignalPipelineService)
|
||||||
|
|
||||||
|
|
@ -268,16 +252,6 @@ def test_decorate_capture_result_adds_queue_action_card_metadata() -> None:
|
||||||
assert result["queue_item_id"] == 301
|
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:
|
def test_normalize_direct_reply_text_strips_internal_debug_lines() -> None:
|
||||||
raw = """
|
raw = """
|
||||||
结论:这类问题应该先按外部事实问答处理。
|
结论:这类问题应该先按外部事实问答处理。
|
||||||
|
|
@ -436,27 +410,6 @@ async def test_feishu_question_like_messages_use_sync_high_accuracy_path(monkeyp
|
||||||
self.workflows = [SimpleNamespace(workflow_type="query_flow", workflow_action="answer")]
|
self.workflows = [SimpleNamespace(workflow_type="query_flow", workflow_action="answer")]
|
||||||
self.diagnostics = {}
|
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]:
|
def to_dict(self) -> dict[str, object]:
|
||||||
return {
|
return {
|
||||||
"bundle_id": "semantic-query",
|
"bundle_id": "semantic-query",
|
||||||
|
|
@ -559,13 +512,6 @@ async def test_feishu_question_like_messages_require_explicit_group_trigger(monk
|
||||||
self.workflows = [SimpleNamespace(workflow_type="query_flow", workflow_action="answer")]
|
self.workflows = [SimpleNamespace(workflow_type="query_flow", workflow_action="answer")]
|
||||||
self.diagnostics = {}
|
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:
|
class _FakeKernel:
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _build_source_message_id(*, source_system: str, source_message_id, channel_ref: str, sender: str, message_text: str):
|
def _build_source_message_id(*, source_system: str, source_message_id, channel_ref: str, sender: str, message_text: str):
|
||||||
|
|
@ -1057,17 +1003,6 @@ async def test_capture_from_feishu_skips_when_source_activation_whitelist_misses
|
||||||
def __init__(self, db):
|
def __init__(self, db):
|
||||||
_ = 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(
|
async def resolve_source_activation_scope(
|
||||||
self,
|
self,
|
||||||
*,
|
*,
|
||||||
|
|
@ -1116,14 +1051,12 @@ async def test_should_process_feishu_high_accuracy_treats_requirement_plan_as_sy
|
||||||
classmethod(lambda cls, **kwargs: asyncio.sleep(0, result=_FakeBundle())),
|
classmethod(lambda cls, **kwargs: asyncio.sleep(0, result=_FakeBundle())),
|
||||||
)
|
)
|
||||||
|
|
||||||
should_sync, reason, bundle = await mixin_capture_entry.SignalCaptureEntryMixin._should_process_feishu_high_accuracy(
|
should_sync, reason = await mixin_capture_entry.SignalCaptureEntryMixin._should_process_feishu_high_accuracy(
|
||||||
message_text="@PMFlow 想跟你聊聊需求,做一个自动给每日的大A趋势打分的小工具,先不创建,先收敛第一版。",
|
message_text="@PMFlow 想跟你聊聊需求,做一个自动给每日的大A趋势打分的小工具,先不创建,先收敛第一版。",
|
||||||
)
|
)
|
||||||
|
|
||||||
assert should_sync is True
|
assert should_sync is True
|
||||||
assert reason == "semantic:requirement_plan"
|
assert reason == "semantic:requirement_plan"
|
||||||
assert bundle is not None
|
|
||||||
assert getattr(bundle, "diagnostics", {}).get("message_intent") == "requirement_plan"
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
|
|
@ -1139,14 +1072,12 @@ async def test_should_process_feishu_high_accuracy_treats_requirement_plan_slot_
|
||||||
classmethod(lambda cls, **kwargs: asyncio.sleep(0, result=_FakeBundle())),
|
classmethod(lambda cls, **kwargs: asyncio.sleep(0, result=_FakeBundle())),
|
||||||
)
|
)
|
||||||
|
|
||||||
should_sync, reason, bundle = await mixin_capture_entry.SignalCaptureEntryMixin._should_process_feishu_high_accuracy(
|
should_sync, reason = await mixin_capture_entry.SignalCaptureEntryMixin._should_process_feishu_high_accuracy(
|
||||||
message_text="@PMFlow 先按技术指标来,主要给投顾看。",
|
message_text="@PMFlow 先按技术指标来,主要给投顾看。",
|
||||||
)
|
)
|
||||||
|
|
||||||
assert should_sync is True
|
assert should_sync is True
|
||||||
assert reason == "semantic:requirement_plan"
|
assert reason == "semantic:requirement_plan"
|
||||||
assert bundle is not None
|
|
||||||
assert getattr(bundle, "diagnostics", {}).get("message_intent") == "requirement_plan"
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
|
|
@ -1162,13 +1093,12 @@ async def test_should_process_feishu_high_accuracy_no_longer_uses_question_suffi
|
||||||
classmethod(lambda cls, **kwargs: asyncio.sleep(0, result=_FakeBundle())),
|
classmethod(lambda cls, **kwargs: asyncio.sleep(0, result=_FakeBundle())),
|
||||||
)
|
)
|
||||||
|
|
||||||
should_sync, reason, bundle = await mixin_capture_entry.SignalCaptureEntryMixin._should_process_feishu_high_accuracy(
|
should_sync, reason = await mixin_capture_entry.SignalCaptureEntryMixin._should_process_feishu_high_accuracy(
|
||||||
message_text="@PMFlow 现在几点?",
|
message_text="@PMFlow 现在几点?",
|
||||||
)
|
)
|
||||||
|
|
||||||
assert should_sync is False
|
assert should_sync is False
|
||||||
assert reason == "async_queue"
|
assert reason == "async_queue"
|
||||||
assert bundle is None
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
|
|
@ -1230,17 +1160,6 @@ async def test_capture_from_feishu_requirement_plan_skips_async_ack_and_forwards
|
||||||
def __init__(self, db):
|
def __init__(self, db):
|
||||||
_ = 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(
|
async def resolve_source_activation_scope(
|
||||||
self,
|
self,
|
||||||
*,
|
*,
|
||||||
|
|
@ -1342,17 +1261,6 @@ async def test_capture_from_feishu_requirement_plan_slot_answer_stays_sync_witho
|
||||||
def __init__(self, db):
|
def __init__(self, db):
|
||||||
_ = 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(
|
async def resolve_source_activation_scope(
|
||||||
self,
|
self,
|
||||||
*,
|
*,
|
||||||
|
|
@ -1399,14 +1307,14 @@ async def test_capture_from_feishu_allows_group_choice_protocol_without_repeat_m
|
||||||
captured: dict[str, object] = {}
|
captured: dict[str, object] = {}
|
||||||
|
|
||||||
class _FakeBundle:
|
class _FakeBundle:
|
||||||
primary_workflow = "followup_flow"
|
primary_workflow = "query_flow"
|
||||||
workflows = [SimpleNamespace(workflow_type="followup_flow", workflow_action="confirm")]
|
workflows = [SimpleNamespace(workflow_type="query_flow", workflow_action="answer")]
|
||||||
diagnostics = {}
|
diagnostics = {}
|
||||||
|
|
||||||
def to_dict(self) -> dict[str, object]:
|
def to_dict(self) -> dict[str, object]:
|
||||||
return {
|
return {
|
||||||
"primary_workflow": "followup_flow",
|
"primary_workflow": "query_flow",
|
||||||
"workflows": [{"workflow_type": "followup_flow", "workflow_action": "confirm"}],
|
"workflows": [{"workflow_type": "query_flow", "workflow_action": "answer"}],
|
||||||
"diagnostics": {},
|
"diagnostics": {},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1453,17 +1361,6 @@ async def test_capture_from_feishu_allows_group_choice_protocol_without_repeat_m
|
||||||
def __init__(self, db):
|
def __init__(self, db):
|
||||||
_ = 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(
|
async def resolve_source_activation_scope(
|
||||||
self,
|
self,
|
||||||
*,
|
*,
|
||||||
|
|
@ -1514,14 +1411,14 @@ async def test_capture_from_feishu_allows_group_confirm_without_repeat_mention_w
|
||||||
captured: dict[str, object] = {}
|
captured: dict[str, object] = {}
|
||||||
|
|
||||||
class _FakeBundle:
|
class _FakeBundle:
|
||||||
primary_workflow = "followup_flow"
|
primary_workflow = "query_flow"
|
||||||
workflows = [SimpleNamespace(workflow_type="followup_flow", workflow_action="confirm")]
|
workflows = [SimpleNamespace(workflow_type="query_flow", workflow_action="answer")]
|
||||||
diagnostics = {}
|
diagnostics = {}
|
||||||
|
|
||||||
def to_dict(self) -> dict[str, object]:
|
def to_dict(self) -> dict[str, object]:
|
||||||
return {
|
return {
|
||||||
"primary_workflow": "followup_flow",
|
"primary_workflow": "query_flow",
|
||||||
"workflows": [{"workflow_type": "followup_flow", "workflow_action": "confirm"}],
|
"workflows": [{"workflow_type": "query_flow", "workflow_action": "answer"}],
|
||||||
"diagnostics": {},
|
"diagnostics": {},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1568,17 +1465,6 @@ async def test_capture_from_feishu_allows_group_confirm_without_repeat_mention_w
|
||||||
def __init__(self, db):
|
def __init__(self, db):
|
||||||
_ = 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(
|
async def resolve_source_activation_scope(
|
||||||
self,
|
self,
|
||||||
*,
|
*,
|
||||||
|
|
@ -1703,14 +1589,14 @@ async def test_capture_from_feishu_group_choice_protocol_prefers_active_requirem
|
||||||
captured: dict[str, object] = {}
|
captured: dict[str, object] = {}
|
||||||
|
|
||||||
class _FakeBundle:
|
class _FakeBundle:
|
||||||
primary_workflow = "followup_flow"
|
primary_workflow = "query_flow"
|
||||||
workflows = [SimpleNamespace(workflow_type="followup_flow", workflow_action="confirm")]
|
workflows = [SimpleNamespace(workflow_type="query_flow", workflow_action="answer")]
|
||||||
diagnostics = {}
|
diagnostics = {}
|
||||||
|
|
||||||
def to_dict(self) -> dict[str, object]:
|
def to_dict(self) -> dict[str, object]:
|
||||||
return {
|
return {
|
||||||
"primary_workflow": "followup_flow",
|
"primary_workflow": "query_flow",
|
||||||
"workflows": [{"workflow_type": "followup_flow", "workflow_action": "confirm"}],
|
"workflows": [{"workflow_type": "query_flow", "workflow_action": "answer"}],
|
||||||
"diagnostics": {},
|
"diagnostics": {},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1766,17 +1652,6 @@ async def test_capture_from_feishu_group_choice_protocol_prefers_active_requirem
|
||||||
def __init__(self, db):
|
def __init__(self, db):
|
||||||
_ = 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(
|
async def resolve_source_activation_scope(
|
||||||
self,
|
self,
|
||||||
*,
|
*,
|
||||||
|
|
@ -1791,18 +1666,9 @@ async def test_capture_from_feishu_group_choice_protocol_prefers_active_requirem
|
||||||
async def _noop_sync_recent_cache(self, **kwargs):
|
async def _noop_sync_recent_cache(self, **kwargs):
|
||||||
_ = (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, "AIPMOKernelService", _FakeKernel)
|
||||||
monkeypatch.setattr(mixin_capture_entry, "MountedAIRuntimeWriteService", _FakeMountedRuntimeWriteService)
|
monkeypatch.setattr(mixin_capture_entry, "MountedAIRuntimeWriteService", _FakeMountedRuntimeWriteService)
|
||||||
monkeypatch.setattr(ProactiveSignalCaptureService, "_sync_feishu_recent_cache", _noop_sync_recent_cache)
|
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(
|
monkeypatch.setattr(
|
||||||
mixin_capture_entry.WorkflowSemanticPlanner,
|
mixin_capture_entry.WorkflowSemanticPlanner,
|
||||||
"aplan_message",
|
"aplan_message",
|
||||||
|
|
@ -1835,14 +1701,14 @@ async def test_capture_from_feishu_group_choice_protocol_uses_executor_requireme
|
||||||
captured: dict[str, object] = {}
|
captured: dict[str, object] = {}
|
||||||
|
|
||||||
class _FakeBundle:
|
class _FakeBundle:
|
||||||
primary_workflow = "followup_flow"
|
primary_workflow = "query_flow"
|
||||||
workflows = [SimpleNamespace(workflow_type="followup_flow", workflow_action="confirm")]
|
workflows = [SimpleNamespace(workflow_type="query_flow", workflow_action="answer")]
|
||||||
diagnostics = {}
|
diagnostics = {}
|
||||||
|
|
||||||
def to_dict(self) -> dict[str, object]:
|
def to_dict(self) -> dict[str, object]:
|
||||||
return {
|
return {
|
||||||
"primary_workflow": "followup_flow",
|
"primary_workflow": "query_flow",
|
||||||
"workflows": [{"workflow_type": "followup_flow", "workflow_action": "confirm"}],
|
"workflows": [{"workflow_type": "query_flow", "workflow_action": "answer"}],
|
||||||
"diagnostics": {},
|
"diagnostics": {},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1900,17 +1766,6 @@ async def test_capture_from_feishu_group_choice_protocol_uses_executor_requireme
|
||||||
def __init__(self, db):
|
def __init__(self, db):
|
||||||
_ = 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(
|
async def resolve_source_activation_scope(
|
||||||
self,
|
self,
|
||||||
*,
|
*,
|
||||||
|
|
@ -2026,17 +1881,6 @@ async def test_capture_from_feishu_explicit_reply_choice_protocol_recovers_activ
|
||||||
def __init__(self, db):
|
def __init__(self, db):
|
||||||
_ = 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(
|
async def resolve_source_activation_scope(
|
||||||
self,
|
self,
|
||||||
*,
|
*,
|
||||||
|
|
@ -2153,17 +1997,6 @@ async def test_capture_from_feishu_explicit_reply_choice_protocol_canonicalizes_
|
||||||
def __init__(self, db):
|
def __init__(self, db):
|
||||||
_ = 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(
|
async def resolve_source_activation_scope(
|
||||||
self,
|
self,
|
||||||
*,
|
*,
|
||||||
|
|
@ -2312,17 +2145,6 @@ async def test_capture_from_feishu_explicit_reply_choice_protocol_forces_sync_ev
|
||||||
def __init__(self, db):
|
def __init__(self, db):
|
||||||
_ = 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(
|
async def resolve_source_activation_scope(
|
||||||
self,
|
self,
|
||||||
*,
|
*,
|
||||||
|
|
@ -2374,6 +2196,10 @@ async def test_project_context_prefill_skips_chatwide_reply_fallback(monkeypatch
|
||||||
self.db = object()
|
self.db = object()
|
||||||
self.tenant_id = 1
|
self.tenant_id = 1
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _is_contextual_project_request(message: str) -> bool:
|
||||||
|
return True
|
||||||
|
|
||||||
async def _load_recent_chat_turns(self, **kwargs):
|
async def _load_recent_chat_turns(self, **kwargs):
|
||||||
_ = kwargs
|
_ = kwargs
|
||||||
return []
|
return []
|
||||||
|
|
@ -2423,56 +2249,8 @@ async def test_project_prefill_fallback_leaves_owner_and_timeline_empty() -> Non
|
||||||
|
|
||||||
assert result["suggested_owner"] is None
|
assert result["suggested_owner"] is None
|
||||||
assert result["suggested_timeline"] is None
|
assert result["suggested_timeline"] is None
|
||||||
assert result["suggested_scope"] is None
|
|
||||||
assert float(result["confidence"]) <= 0.35
|
assert float(result["confidence"]) <= 0.35
|
||||||
assert len(result["reasoning"]) >= 1
|
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
|
@pytest.mark.asyncio
|
||||||
|
|
|
||||||
|
|
@ -1,67 +0,0 @@
|
||||||
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 == [
|
|
||||||
"设计产能监控逻辑与数据采集:编写脚本收集测试队列与资源使用数据",
|
|
||||||
"实现产能警告触发服务:实时比对数据与阈值",
|
|
||||||
]
|
|
||||||
|
|
@ -109,27 +109,6 @@ def test_build_kernel_ingress_kwargs_preserves_explicit_anchor_fields() -> None:
|
||||||
assert kwargs["ingress_options"]["rag_request_context"]["source_chat_id"] == "pmflow-project-17"
|
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
|
@pytest.mark.asyncio
|
||||||
async def test_rag_ask_route_forwards_contract_fields(monkeypatch) -> None:
|
async def test_rag_ask_route_forwards_contract_fields(monkeypatch) -> None:
|
||||||
captured: dict[str, object] = {}
|
captured: dict[str, object] = {}
|
||||||
|
|
@ -182,10 +161,6 @@ async def test_rag_ask_route_forwards_contract_fields(monkeypatch) -> None:
|
||||||
assert rag_request_context["message_intent"] == "mixed"
|
assert rag_request_context["message_intent"] == "mixed"
|
||||||
assert rag_request_context["mixed_signal_part"] == "新增任务确认流"
|
assert rag_request_context["mixed_signal_part"] == "新增任务确认流"
|
||||||
assert rag_request_context["consultant_mode_preference"] == "clarify"
|
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:
|
def test_ai_steward_router_does_not_expose_legacy_rag_ask_route() -> None:
|
||||||
|
|
@ -240,23 +215,24 @@ async def test_rag_ask_route_uses_shared_contract_builder(monkeypatch) -> None:
|
||||||
assert len(captured_kwargs) == 1
|
assert len(captured_kwargs) == 1
|
||||||
forwarded = captured_kwargs[0]
|
forwarded = captured_kwargs[0]
|
||||||
assert str(forwarded["source_message_id"]).startswith("rag-ask-")
|
assert str(forwarded["source_message_id"]).startswith("rag-ask-")
|
||||||
assert forwarded["source_system"] == "pmflow"
|
assert forwarded == {
|
||||||
assert forwarded["message_text"] == "q1"
|
"source_system": "pmflow",
|
||||||
assert forwarded["sender"] == "PMFlow用户"
|
"source_message_id": forwarded["source_message_id"],
|
||||||
assert forwarded["channel_ref"] is None
|
"message_text": "q1",
|
||||||
assert forwarded["source_sender_open_id"] is None
|
"sender": "PMFlow用户",
|
||||||
assert forwarded["captured_at"] is None
|
"channel_ref": None,
|
||||||
assert forwarded["chat_type"] == "manual"
|
"source_sender_open_id": None,
|
||||||
assert forwarded["project_hint_id"] is None
|
"captured_at": None,
|
||||||
assert forwarded["ingress_options"]["rag_request_context"] == {
|
"chat_type": "manual",
|
||||||
"question": "q1",
|
"project_hint_id": None,
|
||||||
"asked_by": 1,
|
"ingress_options": {
|
||||||
"asked_via": "shared-builder",
|
"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:
|
def test_build_rag_response_payload_preserves_rag_fields() -> None:
|
||||||
|
|
|
||||||
|
|
@ -24,14 +24,6 @@ def test_runtime_modules_no_longer_keep_legacy_business_heuristic_helpers() -> N
|
||||||
"app/services/integrations/feishu_signal_pipeline/mixin_lookup_and_block.py": (
|
"app/services/integrations/feishu_signal_pipeline/mixin_lookup_and_block.py": (
|
||||||
"_is_project_lookup_question",
|
"_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": (
|
"app/services/ai_pmo/ai_pmo_kernel/mixins/intent/ai_pmo_kernel_mixins_intent_rules.py": (
|
||||||
"_has_question_structure",
|
"_has_question_structure",
|
||||||
),
|
),
|
||||||
|
|
@ -41,78 +33,6 @@ def test_runtime_modules_no_longer_keep_legacy_business_heuristic_helpers() -> N
|
||||||
"app/services/integrations/feishu_integration_service/mixin_reply_llm_core.py": (
|
"app/services/integrations/feishu_integration_service/mixin_reply_llm_core.py": (
|
||||||
"_is_capability_question",
|
"_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():
|
for relative_path, forbidden_tokens in expectations.items():
|
||||||
|
|
|
||||||
|
|
@ -1,15 +1,8 @@
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from app.config.ai_prompts import PMFLOW_SEMANTIC_CONTEXT_DECISION_PROMPT
|
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 (
|
from app.services.ai_pmo.semantic_context.decision_schema import (
|
||||||
AnswerControlDecision,
|
|
||||||
DecisionGateResult,
|
DecisionGateResult,
|
||||||
InteractionBoundaryPacket,
|
|
||||||
ModeCard,
|
ModeCard,
|
||||||
SemanticDecision,
|
SemanticDecision,
|
||||||
)
|
)
|
||||||
|
|
@ -81,103 +74,3 @@ def test_semantic_context_decision_prompt_avoids_business_semantic_examples() ->
|
||||||
|
|
||||||
for fragment in forbidden_fragments:
|
for fragment in forbidden_fragments:
|
||||||
assert fragment not in PMFLOW_SEMANTIC_CONTEXT_DECISION_PROMPT
|
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"
|
|
||||||
|
|
|
||||||
|
|
@ -117,314 +117,3 @@ def test_workflow_semantic_planner_no_longer_exposes_legacy_semantic_helpers() -
|
||||||
|
|
||||||
for name in legacy_names:
|
for name in legacy_names:
|
||||||
assert not hasattr(WorkflowSemanticPlanner, name), name
|
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"
|
|
||||||
|
|
|
||||||
|
|
@ -45,7 +45,6 @@
|
||||||
### 4.2 AI PMO 与语义主链
|
### 4.2 AI PMO 与语义主链
|
||||||
- `backend/app/services/ai_pmo/ai_pmo_kernel/`
|
- `backend/app/services/ai_pmo/ai_pmo_kernel/`
|
||||||
- `backend/app/services/ai_pmo/ai_pmo_kernel_ingress_flow/`
|
- `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_execute/`
|
||||||
- `backend/app/services/ai_pmo/ai_pmo_typed_executor_discovery/`
|
- `backend/app/services/ai_pmo/ai_pmo_typed_executor_discovery/`
|
||||||
- `backend/app/services/ai_pmo/semantic_context/`
|
- `backend/app/services/ai_pmo/semantic_context/`
|
||||||
|
|
@ -55,10 +54,8 @@
|
||||||
### 4.3 问答、模型与生成
|
### 4.3 问答、模型与生成
|
||||||
- `backend/app/services/adaptive_rag_service/`
|
- `backend/app/services/adaptive_rag_service/`
|
||||||
- `backend/app/services/adaptive_rag_service/internal_query_context.py`
|
- `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_gateway.py`
|
||||||
- `backend/app/services/adaptive_rag_service/mixin_query_route_governance.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/external_query_service/`
|
||||||
- `backend/app/services/semantic_fact_service/`
|
- `backend/app/services/semantic_fact_service/`
|
||||||
- `backend/app/services/semantic_fact_service/hooks.py`
|
- `backend/app/services/semantic_fact_service/hooks.py`
|
||||||
|
|
@ -102,31 +99,11 @@
|
||||||
- `backend/app/services/` 根目录已目录化收敛,新增服务优先进入对应子包。
|
- `backend/app/services/` 根目录已目录化收敛,新增服务优先进入对应子包。
|
||||||
- API canonical 入口优先使用 `*_api/` 子包,不再优先引用顶层兼容壳。
|
- API canonical 入口优先使用 `*_api/` 子包,不再优先引用顶层兼容壳。
|
||||||
- 运行时业务动作归因优先走“对象/上下文绑定 -> `SemanticContextPacket` -> `SemanticDecision` -> gatekeeper”。
|
- 运行时业务动作归因优先走“对象/上下文绑定 -> `SemanticContextPacket` -> `SemanticDecision` -> gatekeeper”。
|
||||||
- non-plan `query.answer` 现已在 `AdaptiveRAG` 内增加 `Query Route Gateway`:只消费上游单次结构化 `query_profile/source_policy/effective_source_policy` 与显式 hint,结合 `ProjectOperatingContext` 归一化产出 `route_mode / decision_origin / reply_contract / probe_snapshot(contract audit) / execution_recipe`,不再在运行时执行 internal/external probe 或 route memory 强拍板,再进入检索与回答主链。
|
- 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 强拍板,再进入检索与回答主链。
|
||||||
- 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 `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` 的镜像也已收口到单点兼容写回。
|
- internal 主链现已补上 packet 预检:`mixin_answer_mainline.py` 在组完 `InternalContextPacket` 后,会先拦截 `missing packet / missing operation contract / target clarify / missing query plan contract` 这类上下文不完整场景,再决定是否进入 deterministic fast path、internal model-config fast path、GLM semantic fast path 或 retrieval;`query_semantics` 对 `operation_contract / target_resolution / query_plan` 的镜像也已收口到单点兼容写回。
|
||||||
- external `query.answer` 已完成 second-pass 收口:`Query Route Gateway` 之后统一进入 `backend/app/services/external_query_service/`,按 `ProjectFitBrief -> External Query Planner -> External Query Gateway -> External Provider Registry -> MCP adapter 优先 -> bounded fallback live search -> external answer verifier -> ExternalContextPacket -> ExternalAnswerPresenter` 执行;planner 会显式消费 `fit_brief_summary / project_scope_hint / recommendation_goal / project_object_anchors / 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`。
|
- 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`。
|
||||||
- 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`。
|
- `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_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,不再开旁路。
|
- 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`。
|
- 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`。
|
||||||
|
|
@ -172,8 +149,6 @@
|
||||||
- `frontend/src/components/ai-steward/`
|
- `frontend/src/components/ai-steward/`
|
||||||
- `frontend/src/components/ai-steward/QAConversationStatusBar.tsx`
|
- `frontend/src/components/ai-steward/QAConversationStatusBar.tsx`
|
||||||
- `frontend/src/components/ai-steward/QAResponseMetaCard.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/taskBriefs.ts`
|
||||||
- `frontend/src/api/modelRouting.ts`
|
- `frontend/src/api/modelRouting.ts`
|
||||||
- `frontend/src/api/mcp.ts`
|
- `frontend/src/api/mcp.ts`
|
||||||
|
|
|
||||||
|
|
@ -31,30 +31,9 @@
|
||||||
### 2.3 RAG / AI PMO / Knowledge Graph
|
### 2.3 RAG / AI PMO / Knowledge Graph
|
||||||
- RAG 问答主入口:参考 `backend/app/api/rag_api/` 与 `backend/app/api/rag_ask_contract.py`
|
- 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`
|
- `/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 路由网关与显式来源回复合同:参考 `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`
|
- 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/`);`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,而不是继续把高确定性或架构腔文本吐给用户
|
- External Query Service 主链:参考 `backend/app/services/external_query_service/`(`planner.py`、`gateway.py`、`registry.py`、`schemas.py`、`presenter.py`、`adapters/`)
|
||||||
- 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`
|
- 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`
|
- 运行时语义守门与回归:参考 `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`
|
- MCP backend-direct runtime / Microsoft Learn bridge:参考 `backend/app/services/mcp_config/runtime.py` 与 `backend/app/services/mcp_config/service.py`
|
||||||
|
|
@ -72,7 +51,6 @@
|
||||||
- AI PMO 审批接口:参考 `POST /ai-pmo/work-items/{work_item_id}/decision`
|
- AI PMO 审批接口:参考 `POST /ai-pmo/work-items/{work_item_id}/decision`
|
||||||
- AI PMO 消息台列表接口:参考 `GET /ai-pmo/work-items`
|
- 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`
|
- 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
|
### 2.4 Signal / Project / SSTC
|
||||||
- 需求点落信号源:参考 `backend/app/services/signal/signal_source_orchestration/mixin_registry_execute.py`
|
- 需求点落信号源:参考 `backend/app/services/signal/signal_source_orchestration/mixin_registry_execute.py`
|
||||||
|
|
@ -94,8 +72,6 @@
|
||||||
- 新增前端 API 调用:参考 `frontend/src/api/sstc.ts`
|
- 新增前端 API 调用:参考 `frontend/src/api/sstc.ts`
|
||||||
- AI Steward 页面入口:参考 `frontend/src/pages/ai-steward/`
|
- 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 问答浮窗与上下文显性化组件:参考 `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 / QA session 前端 contract:参考 `frontend/src/api/aiSteward.ts`
|
||||||
- 网页问答显式锚点与 AI Steward URL 构造:参考 `frontend/src/components/ai-steward/qaPageAnchor.ts`
|
- 网页问答显式锚点与 AI Steward URL 构造:参考 `frontend/src/components/ai-steward/qaPageAnchor.ts`
|
||||||
- Inbox 页面入口:参考 `frontend/src/pages/inbox/`
|
- Inbox 页面入口:参考 `frontend/src/pages/inbox/`
|
||||||
|
|
|
||||||
|
|
@ -49,10 +49,6 @@
|
||||||
- 新增 AI 前台能力时,必须优先接入“消息/上下文 -> 语义识别 -> 业务对象/草稿 -> 审计/幂等/确认 -> 入库/执行”链路;不得只停留在对话文本层或仅新增孤立聊天能力而不进入正式业务闭环。
|
- 新增 AI 前台能力时,必须优先接入“消息/上下文 -> 语义识别 -> 业务对象/草稿 -> 审计/幂等/确认 -> 入库/执行”链路;不得只停留在对话文本层或仅新增孤立聊天能力而不进入正式业务闭环。
|
||||||
- `backend/app` 运行时代码不得再沉淀会直接拍板 `project.create / signal_source.onboard / followup.* / query vs execute` 的中文关键词、正则、`looks_like_*`、`*_HINTS/*_KEYWORDS` 规则仓。
|
- `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` 不能再由关键词最终拍板。
|
- 协议级锚点(`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` 或直接澄清,禁止执行层二次夺权。
|
- `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 放行条件。
|
- 若问题根因属于“动作路由错误 / 上下文劫持 / 独立业务流误触发”,主修法必须优先落在 ingress 分流、active topic/draft 检测、`SemanticContextPacket` 组包、`SemanticDecision` 结构化判断与 gatekeeper 放行条件。
|
||||||
- `workflow_semantic_planner` 当前职责是 packet / bundle 组装、兼容层收口与冷启动接入,不再作为新的关键词仓。
|
- `workflow_semantic_planner` 当前职责是 packet / bundle 组装、兼容层收口与冷启动接入,不再作为新的关键词仓。
|
||||||
|
|
@ -74,20 +70,14 @@
|
||||||
- `MountedTopicWorkspace.plan_status` 必须显式落库区分 `idle / collecting / options_ready / plan_ready / awaiting_execute_confirm / suspended`。
|
- `MountedTopicWorkspace.plan_status` 必须显式落库区分 `idle / collecting / options_ready / plan_ready / awaiting_execute_confirm / suspended`。
|
||||||
- `requirement_plan` 的 A/B/C 可以固定为用户可见骨架,但业务选项、推荐项、理由和代价必须走“上下文包 -> 模型结构化决策 -> gatekeeper -> 渲染”。
|
- `requirement_plan` 的 A/B/C 可以固定为用户可见骨架,但业务选项、推荐项、理由和代价必须走“上下文包 -> 模型结构化决策 -> gatekeeper -> 渲染”。
|
||||||
- 正在收敛中的 `requirement_plan` 必须支持碎片化回复,不能因缺少新的 `semantic_bundle` 回落到普通 query。
|
- 正在收敛中的 `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`。
|
- 同一 `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 猜退出。
|
- 已绑定 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。
|
- 同一 `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`。
|
- 用户显式说“聊个新需求 / 另一个需求 / 跟上一个无关”时,必须先清掉继承的旧 `current_project_id`。
|
||||||
- 裸 `A/B/C/确认/你自行推荐` 这类低信息量回复,如果没有命中当前 active requirement session 或待确认草稿,必须明确提示当前没有进行中的收敛轮次或草稿。
|
- 裸 `A/B/C/确认/你自行推荐` 这类低信息量回复,如果没有命中当前 active requirement session 或待确认草稿,必须明确提示当前没有进行中的收敛轮次或草稿。
|
||||||
- `draft_pending_confirm` 草稿确认链只允许由显式锚点进入:`reply/thread`、卡片按钮或显式 `work_item_id`。无锚点普通消息、闲聊、新需求或独立事实问句禁止再按“同群/同人”回捞旧 pending draft。
|
- `draft_pending_confirm` 草稿确认链只允许由显式锚点进入:`reply/thread`、卡片按钮或显式 `work_item_id`。无锚点普通消息、闲聊、新需求或独立事实问句禁止再按“同群/同人”回捞旧 pending draft。
|
||||||
- 同一 `conversation_scope` 一旦存在 active `draft_pending_confirm` workspace,命中上述显式锚点的后续消息必须优先回到当前草稿确认链处理,支持确认/修改/重出/取消与展示反馈;即使动作未识别,也必须显式提示“当前正在处理待确认草稿”,禁止再新开 topic 或落回 `query.answer`。
|
- 同一 `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` 命中显式 `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 继续吸收普通问句。
|
- 当同一 `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` 某条已绑定回复并要求“基于当前信息出草稿”时,主链必须先尝试从该 reply 命中的 mounted context 升格生成 requirement-plan 草稿;若上下文不足,必须显式失败,禁止回落到 `query.answer` 冒充“草稿已生成”。
|
||||||
- 用户在显式 `reply/thread` 的同一需求线程里已经完成 `A/B/C` 选项收敛后,后续碎片化追问必须先尝试把该 reply 命中的 chat context 升格成 requirement-plan workspace,再走 `requirement_plan` 主链;禁止继续落到通用 `query.answer` 并把代码路径、内部函数名或工程证据泄漏到用户可见答案里。
|
- 用户在显式 `reply/thread` 的同一需求线程里已经完成 `A/B/C` 选项收敛后,后续碎片化追问必须先尝试把该 reply 命中的 chat context 升格成 requirement-plan workspace,再走 `requirement_plan` 主链;禁止继续落到通用 `query.answer` 并把代码路径、内部函数名或工程证据泄漏到用户可见答案里。
|
||||||
|
|
@ -118,31 +108,8 @@
|
||||||
- 飞书与 AI PMO 入口(含 `message_intent=mixed`)问答链路必须绕过 deterministic/snapshot 快路径,统一落到场景化 Prompt 生成。
|
- 飞书与 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 同时混搜内部知识库与外部网页,必须先定来源再检索。
|
- `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` 必须直接澄清,禁止混合答案。
|
- non-plan `query.answer` 的来源裁决统一由 `Query Route Gateway` 执行,输出必须至少包含 `route_mode / decision_origin / reply_contract / probe_snapshot(contract audit)`;回复正文必须遵守显式来源合同(`内部知识库查询:` / `外部实时查询:`),`uncertain + effective_source_policy=uncertain` 必须直接澄清,禁止混合答案。
|
||||||
- query-route 澄清协议也属于受控 state machine:若线程上仍挂着 `pending_clarification(kind=query_route_selection)`,但当前短消息通过 `WorkflowSemanticPlanner` 给出显式 `topic_switch=new` 或命中新话题 workflow,ingress 必须先让旧澄清立即失效,再回到新的 `query.answer` 主链;禁止因为“短句”条件成立就继续消费旧 option map。
|
- `/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。
|
||||||
- `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 推断器”。
|
- 网页 `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`,禁止先执行双探针再回写路由结论。
|
- `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 或直接输出未取证的内部结论。
|
- 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 注入链路当作真源读取。
|
- `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 注入链路当作真源读取。
|
||||||
|
|
@ -160,13 +127,6 @@
|
||||||
- 通用行业趋势/外部品牌模型 freshness 问答,禁止把项目图谱、Feature Catalog、内部文档或用户历史冒充成“外部事实”;若命中 live external `url/http(s)` 证据,必须优先基于外部结果回答;若实时外部检索为空或不足,才允许显式返回“当前未接入实时外部数据/暂无法确认最新结论”。
|
- 通用行业趋势/外部品牌模型 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` 在 `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 混答。
|
- 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。
|
- `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、内部检索结果或项目上下文补写结论。
|
- 若 `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。
|
- `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。
|
||||||
|
|
|
||||||
|
|
@ -1,883 +0,0 @@
|
||||||
# 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
Loading…
Reference in New Issue