Add explicit QA anchors and route governance pages

This commit is contained in:
Yuuuu 2026-04-17 14:01:53 +08:00
parent 4720c8117d
commit 3f94cf702c
123 changed files with 10585 additions and 869 deletions

View File

@ -95,7 +95,7 @@ frontend/ # React 前端
- 新增接口必须遵循响应包络:`{code, message, data}`,不得裸返回 - 新增接口必须遵循响应包络:`{code, message, data}`,不得裸返回
- 禁止在 API 层写业务逻辑,业务逻辑归属 `services/` - 禁止在 API 层写业务逻辑,业务逻辑归属 `services/`
- 禁止在前端组件内直接调用 `fetch`,必须通过 `frontend/src/api/` 封装层 - 禁止在前端组件内直接调用 `fetch`,必须通过 `frontend/src/api/` 封装层
- MCP 模板接入中心只允许保存模板参数、命令预览、runtime probe 与“本机已执行”确认状态;远程 `streamable_http` 模板允许由 PMFlow 后端直连校验/调用,本地 `stdio` 模板只能走 PMFlow Local Bridge 或兼容 MCP host;禁止从服务端直接写宿主机 `~/.codex/config.toml`、禁止替客户执行本机命令,GitHub 类模板只允许保存 env var 名称或占位符,不得落明文 token。 - MCP 模板接入中心只允许保存模板参数、命令预览、runtime probe 与“本机已执行”确认状态;后端 `backend_direct` 模板可由 PMFlow backend 直接校验/调用(包括远程 `streamable_http` MCP 与内建 `backend_adapter` runtime),本地 `stdio` 模板只能走 PMFlow Local Bridge 或兼容 MCP host;禁止从服务端直接写宿主机 `~/.codex/config.toml`、禁止替客户执行本机命令,GitHub 类模板只允许保存 env var 名称或占位符,不得落明文 token。
- 前端状态管理:服务端数据用 React Query,本地 UI 状态用 `useState` - 前端状态管理:服务端数据用 React Query,本地 UI 状态用 `useState`
- 用户可见核心业务内容零兜底:AI 失败、证据不足、结构化失败时必须显式失败或标记待确认,禁止用默认句、模板句、stub 冒充成功。 - 用户可见核心业务内容零兜底:AI 失败、证据不足、结构化失败时必须显式失败或标记待确认,禁止用默认句、模板句、stub 冒充成功。
- 业务语义、问答/执行归因与前台动作拍板必须优先走“对象/上下文绑定 -> `SemanticContextPacket` -> `SemanticDecision` -> gatekeeper”主链,禁止在 `backend/app` 运行时代码里恢复 `looks_like_*`、关键词仓或散落正则拍板。 - 业务语义、问答/执行归因与前台动作拍板必须优先走“对象/上下文绑定 -> `SemanticContextPacket` -> `SemanticDecision` -> gatekeeper”主链,禁止在 `backend/app` 运行时代码里恢复 `looks_like_*`、关键词仓或散落正则拍板。

View File

@ -56,8 +56,9 @@ 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/adaptive_rag_service/`(只读问答主链;`mixin_query_route_gateway.py` 负责 non-plan `query.answer` 的 internal/external/uncertain 来源合同归一化、contract 审计与显式来源回复合同;`mixin_answer_retrieval.py` 现已把 `route_mode=external` 接到独立外部查询主链;内部查询基线已升级为 `Operation Contract -> Target Resolver -> Query DSL Plan -> 受控执行器 -> semantic facts 优先 -> verifier`) - `backend/app/services/adaptive_rag_service/`(只读问答主链;`mixin_query_route_gateway.py` 负责 non-plan `query.answer` 的 internal/external/uncertain 来源合同归一化、contract 审计与显式来源回复合同;`mixin_answer_retrieval.py` 现已把 `route_mode=external` 接到独立外部查询主链;internal 路径新增 `internal_query_context.py` 里的 `InternalContextPacket` 作为 `Operation Contract -> Target Resolver -> Query DSL Plan -> 受控执行器 -> semantic facts 优先 -> verifier` 的运行时单一真源)
- `backend/app/services/external_query_service/`(external `query.answer` 主链:`External Query Planner -> External Query Gateway -> External Provider Registry -> MCP 优先 -> bounded fallback live search -> verifier`) - `backend/app/api/rag_api/routes_route_governance.py`(manager-only 的 route/context 治理接口:`/rag/query-route-governance/summary|items|items/{id}`,只读暴露 `query_route_memories` 的 summary/list/detail)
- `backend/app/services/external_query_service/`(external `query.answer` 主链:`External Query Planner -> External Query Gateway -> External Provider Registry -> MCP 优先 -> bounded fallback live search -> verifier -> ExternalContextPacket -> ExternalAnswerPresenter`;其中 `schemas.py` 负责结构化 plan/evidence/context packet,`presenter.py` 负责 external-only 回答生成)
- `backend/app/services/mcp_config/`(MCP 模板接入中心:catalog、bundle 持久化、后端直连 runtime probe / Microsoft Learn 检索桥接、Local Bridge 接入说明与本机确认状态) - `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)
@ -67,7 +68,7 @@ backend/
- `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 回归测试位于 `backend/tests/test_external_query_planner.py`;配合 `backend/tests/test_adaptive_rag_mainline_contract.py`、`backend/tests/test_ai_pmo_ingress_mainline.py` 与 `backend/tests/test_semantic_lint.py` 一起锁定“Query Route Gateway 不再跑 runtime probes、External Query Planner fallback 不再按对象重分类、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/`)
@ -92,7 +93,9 @@ frontend/
- `frontend/src/pages/inbox/`(消息台) - `frontend/src/pages/inbox/`(消息台)
- `frontend/src/pages/ai-steward/`(AI 管家) - `frontend/src/pages/ai-steward/`(AI 管家)
- `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/semantic-facts-board/`(AI Config -> Governance Tools 下的 semantic facts 治理页) - `frontend/src/pages/semantic-facts-board/`(AI Config -> Governance Tools 下的 semantic facts 治理页)
- `frontend/src/api/queryRouteGovernance.ts`(route/context 治理页前端 API)
- `frontend/src/pages/scheduler-center/`(Mounted AI Console) - `frontend/src/pages/scheduler-center/`(Mounted AI Console)
- `frontend/src/components/sstc/`(SSTC 工作流组件) - `frontend/src/components/sstc/`(SSTC 工作流组件)
- 页面目录化、Mounted AI Console、Inbox、AI Steward 与 Recipes/Projects/Users 等模块化拆分的详细库存见库存文档。 - 页面目录化、Mounted AI Console、Inbox、AI Steward 与 Recipes/Projects/Users 等模块化拆分的详细库存见库存文档。
@ -176,11 +179,11 @@ uvicorn app.main:app --reload --host 0.0.0.0 --port 8000
- 当前最重要的摘要只保留: - 当前最重要的摘要只保留:
- 写操作必须经 Alembic / 受控执行器 / outbox / 审计日志,禁止业务路径直写或同步直发。 - 写操作必须经 Alembic / 受控执行器 / outbox / 审计日志,禁止业务路径直写或同步直发。
- 用户可见核心业务内容禁止 fallback、stub、模板句冒充成功;失败必须显式失败或标记待确认。 - 用户可见核心业务内容禁止 fallback、stub、模板句冒充成功;失败必须显式失败或标记待确认。
- MCP 模板接入中心只保存模板参数、命令预览、runtime probe 和手动确认状态;远程 `streamable_http` 模板可由 PMFlow 后端直连,`stdio` 模板只能走 Local Bridge / 兼容 MCP host;禁止从服务端直写宿主机 `~/.codex/config.toml` 或替客户执行本机命令。 - MCP 模板接入中心只保存模板参数、命令预览、runtime probe 和手动确认状态;`backend_direct` 模板可由 PMFlow 后端直连(远程 `streamable_http` MCP 或内建 `backend_adapter` runtime),`stdio` 模板只能走 Local Bridge / 兼容 MCP host;禁止从服务端直写宿主机 `~/.codex/config.toml` 或替客户执行本机命令。
- 运行时业务动作归因统一走“对象/上下文绑定 -> `SemanticContextPacket` -> `SemanticDecision` -> gatekeeper”。 - 运行时业务动作归因统一走“对象/上下文绑定 -> `SemanticContextPacket` -> `SemanticDecision` -> gatekeeper”。
- `backend/app` 运行时代码禁止恢复散落的中文关键词、正则、`looks_like_*`、`*_HINTS/*_KEYWORDS` 业务拍板逻辑。 - `backend/app` 运行时代码禁止恢复散落的中文关键词、正则、`looks_like_*`、`*_HINTS/*_KEYWORDS` 业务拍板逻辑。
- `llm_service` 只认显式 `routing_meta` 协议,不再靠函数名或 hint 推断 tier / contract / fallback。 - `llm_service` 只认显式 `routing_meta` 协议,不再靠函数名或 hint 推断 tier / contract / fallback。
- 内部只读查询必须走 `Operation Contract -> Target Resolver -> Query DSL Plan -> 白名单受控执行器 -> semantic facts 优先 -> verifier`,禁止自由 SQL 和无证据脑补。 - 内部只读查询必须走 `Operation Contract -> Target Resolver -> Query DSL Plan -> InternalContextPacket -> 白名单受控执行器 -> semantic facts 优先 -> verifier`,其中 mainline 要先做 packet 预检,`query_semantics` 只作上游 scratchpad/兼容镜像并且只允许单点兼容写回;禁止继续充当 internal executor 或 verifier 的运行时真源,同时禁止自由 SQL 和无证据脑补。
- `semantic_facts` 自动抽取统一走 ORM 提交钩子 -> `ingest_events(channel=semantic_fact_extract)` -> 统一 worker,支持 `project/task/signal/document/user/requirement/release/review` 八类对象;禁止在各路由散落手工 enqueue。 - `semantic_facts` 自动抽取统一走 ORM 提交钩子 -> `ingest_events(channel=semantic_fact_extract)` -> 统一 worker,支持 `project/task/signal/document/user/requirement/release/review` 八类对象;禁止在各路由散落手工 enqueue。
- AI Steward、Requirement Plan、Signal Source、AdaptiveRAG 等核心链路的详细 guardrails 统一以 `RUNTIME_GUARDRAILS.md` 为准。 - AI Steward、Requirement Plan、Signal Source、AdaptiveRAG 等核心链路的详细 guardrails 统一以 `RUNTIME_GUARDRAILS.md` 为准。
@ -218,6 +221,7 @@ uvicorn app.main:app --reload --host 0.0.0.0 --port 8000
- [x] AdaptiveRAG 职责已完成首轮收敛:`answer_question` 入口已分层为 `prepare runtime` 与 `base mainline` 两段,写确认逻辑通过 `_should_trigger_write_ack_gate` 独立门控;基础问答主路径不再与写确认判断混写在同一函数体。分层契约由 `test_adaptive_rag_mainline_contract.py` 锁定(read 绕过 ack、write 命中 ack、feishu write 维持主链) - [x] 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`;前端 `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` 做仓库级守门

View File

@ -72,6 +72,119 @@ async def _build_closure_payload_overrides(
} }
return overrides return overrides
def _normalize_plan_status(value: Any) -> Optional[str]:
status = str(value or "").strip()
return status or None
def _normalize_requirement_plan_context_summary(value: Any) -> Optional[dict[str, Any]]:
context = value if isinstance(value, dict) else {}
if not context:
return None
missing_info = []
for item in context.get("missing_info") if isinstance(context.get("missing_info"), list) else []:
compact = _compact_text(item, 48)
if compact:
missing_info.append(compact)
last_round_options = context.get("last_round_options") if isinstance(context.get("last_round_options"), dict) else {}
last_round_field = str(last_round_options.get("field") or "").strip() or None
summary = {
"title": _compact_text(context.get("title"), 80) or None,
"goal": _compact_text(context.get("goal"), 160) or None,
"missing_info": missing_info[:3],
"question_round": _safe_int(context.get("question_round")),
"last_round_field": last_round_field,
}
if not any(
(
summary["title"],
summary["goal"],
summary["missing_info"],
summary["question_round"],
summary["last_round_field"],
)
):
return None
return summary
def _build_plan_mode_projection(
*,
reply_template_key: Optional[str],
state: Optional[str],
payload: Any,
context_snapshot: Any,
execution_result: Any,
) -> dict[str, Any]:
payload_dict = payload if isinstance(payload, dict) else {}
snapshot = context_snapshot if isinstance(context_snapshot, dict) else {}
execution = execution_result if isinstance(execution_result, dict) else {}
explicit_plan_mode = next(
(
candidate
for candidate in (
execution.get("plan_mode"),
snapshot.get("plan_mode"),
payload_dict.get("plan_mode"),
)
if isinstance(candidate, bool)
),
None,
)
requirement_plan_context = next(
(
dict(candidate)
for candidate in (
execution.get("requirement_plan_context"),
snapshot.get("requirement_plan_context"),
payload_dict.get("requirement_plan_context"),
)
if isinstance(candidate, dict)
),
None,
)
normalized_reply_template = str(reply_template_key or "").strip().lower()
normalized_state = str(state or "").strip().lower()
fallback_plan_mode = normalized_reply_template == "requirement_plan_followup" or normalized_state == "draft_pending_confirm"
plan_mode = explicit_plan_mode if explicit_plan_mode is not None else fallback_plan_mode
plan_status = next(
(
normalized
for normalized in (
_normalize_plan_status(execution.get("plan_status")),
_normalize_plan_status(snapshot.get("plan_status")),
_normalize_plan_status(payload_dict.get("plan_status")),
)
if normalized
),
None,
)
if plan_status is None:
if normalized_reply_template == "requirement_mode_suspended":
plan_status = "suspended"
elif plan_mode and normalized_state:
plan_status = normalized_state
return {
"plan_mode": bool(plan_mode),
"plan_status": plan_status,
"requirement_plan_context_summary": _normalize_requirement_plan_context_summary(requirement_plan_context),
}
def _serialize_process_text_result(result: dict[str, Any]) -> dict[str, Any]:
payload = dict(result) if isinstance(result, dict) else {}
plan_projection = _build_plan_mode_projection(
reply_template_key=payload.get("reply_template_key"),
state=payload.get("status"),
payload=payload,
context_snapshot=payload.get("context_snapshot"),
execution_result=payload.get("execution_result"),
)
payload.update(plan_projection)
return payload
def _serialize_work_item_summary( def _serialize_work_item_summary(
*, *,
row: WorkItem, row: WorkItem,
@ -123,6 +236,13 @@ def _serialize_work_item_summary(
latest_policy_gate_code = str(fallback_projection.get("latest_policy_gate_code") or "").strip() or None latest_policy_gate_code = str(fallback_projection.get("latest_policy_gate_code") or "").strip() or None
if approval_requirement is None: if approval_requirement is None:
approval_requirement = _normalize_approval_requirement(fallback_projection.get("approval_requirement")) approval_requirement = _normalize_approval_requirement(fallback_projection.get("approval_requirement"))
plan_projection = _build_plan_mode_projection(
reply_template_key=getattr(row, "reply_template_key", None),
state=getattr(row, "state", None),
payload=payload,
context_snapshot=getattr(row, "context_snapshot", None),
execution_result=getattr(row, "execution_result", None),
)
return { return {
"id": int(row.id), "id": int(row.id),
"source_system": row.source_system, "source_system": row.source_system,
@ -150,6 +270,9 @@ def _serialize_work_item_summary(
"stage_text": _stage_text(str(row.state or "")), "stage_text": _stage_text(str(row.state or "")),
"priority": priority, "priority": priority,
"priority_rank": priority_rank, "priority_rank": priority_rank,
"plan_mode": plan_projection["plan_mode"],
"plan_status": plan_projection["plan_status"],
"requirement_plan_context_summary": plan_projection["requirement_plan_context_summary"],
"latest_policy_gate_code": latest_policy_gate_code, "latest_policy_gate_code": latest_policy_gate_code,
"approval_requirement": approval_requirement, "approval_requirement": approval_requirement,
"target_url": f"/projects/{project_id}/board" if project_id is not None else None, "target_url": f"/projects/{project_id}/board" if project_id is not None else None,

View File

@ -2,7 +2,12 @@ from __future__ import annotations
from .shared import * from .shared import *
from .helpers_inbox_filters import _build_latest_decision_projection, _should_hide_work_item_from_inbox, _safe_int from .helpers_inbox_filters import _build_latest_decision_projection, _should_hide_work_item_from_inbox, _safe_int
from .helpers_inbox_payload import _build_closure_payload_overrides, _serialize_work_item_summary from .helpers_inbox_payload import (
_build_closure_payload_overrides,
_build_plan_mode_projection,
_serialize_process_text_result,
_serialize_work_item_summary,
)
from .schemas import PMOProcessTextRequest from .schemas import PMOProcessTextRequest
@router.post("/process-text") @router.post("/process-text")
@ -26,7 +31,7 @@ async def process_text(
) )
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 未处理该输入"))
return success_response(result) return success_response(_serialize_process_text_result(result))
@router.get("/work-items") @router.get("/work-items")
async def list_work_items( async def list_work_items(
@ -181,4 +186,13 @@ async def get_work_item(
for item in reply_rows for item in reply_rows
], ],
} }
payload.update(
_build_plan_mode_projection(
reply_template_key=row.reply_template_key,
state=row.state,
payload={},
context_snapshot=row.context_snapshot,
execution_result=row.execution_result,
)
)
return success_response(payload) return success_response(payload)

View File

@ -2,10 +2,10 @@ from __future__ import annotations
from fastapi import APIRouter from fastapi import APIRouter
from . import routes_knowledge, routes_qa, routes_query from . import routes_knowledge, routes_qa, routes_query, routes_route_governance
router = APIRouter(tags=["Adaptive RAG"]) router = APIRouter(tags=["Adaptive RAG"])
for _module in [routes_query, routes_knowledge, routes_qa]: for _module in [routes_query, routes_knowledge, routes_qa, routes_route_governance]:
router.include_router(_module.router) router.include_router(_module.router)
__all__ = ["router"] __all__ = ["router"]

View File

@ -0,0 +1,82 @@
from __future__ import annotations
from fastapi import Depends, HTTPException, Query
from sqlalchemy.ext.asyncio import AsyncSession
from .helpers import _resolve_tenant_id
from .shared import *
from ...dependencies.permissions import require_manager_user
from ...services.adaptive_rag_service import AdaptiveRAGService
@router.get("/query-route-governance/summary")
async def get_query_route_governance_summary(
project_id: int | None = Query(default=None),
window_days: int | None = Query(default=None),
current_user: User = Depends(require_manager_user),
db: AsyncSession = Depends(get_db),
tenant_id: int = Depends(get_tenant_id),
):
service = AdaptiveRAGService(db, tenant_id=_resolve_tenant_id(current_user, tenant_id))
try:
data = await service.query_route_governance_summary(
project_id=project_id,
window_days=window_days,
)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
return success_response(data)
@router.get("/query-route-governance/items")
async def list_query_route_governance_records(
project_id: int | None = Query(default=None),
window_days: int | None = Query(default=None),
route_mode: str | None = Query(default=None),
decision_origin: str | None = Query(default=None),
rating: str | None = Query(default=None),
mismatch_only: bool = Query(default=False),
clarify_only: bool = Query(default=False),
search: str | None = Query(default=None),
limit: int = Query(default=20, ge=1, le=100),
offset: int = Query(default=0, ge=0),
current_user: User = Depends(require_manager_user),
db: AsyncSession = Depends(get_db),
tenant_id: int = Depends(get_tenant_id),
):
service = AdaptiveRAGService(db, tenant_id=_resolve_tenant_id(current_user, tenant_id))
try:
data = await service.list_query_route_governance_records(
project_id=project_id,
window_days=window_days,
route_mode=route_mode,
decision_origin=decision_origin,
rating=rating,
mismatch_only=mismatch_only,
clarify_only=clarify_only,
search=search,
limit=limit,
offset=offset,
)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
return success_response(data)
@router.get("/query-route-governance/items/{record_id}")
async def get_query_route_governance_detail(
record_id: int,
project_id: int | None = Query(default=None),
current_user: User = Depends(require_manager_user),
db: AsyncSession = Depends(get_db),
tenant_id: int = Depends(get_tenant_id),
):
service = AdaptiveRAGService(db, tenant_id=_resolve_tenant_id(current_user, tenant_id))
data = await service.get_query_route_governance_detail(
record_id=record_id,
project_id=project_id,
)
if data is None:
raise HTTPException(status_code=404, detail="query route record not found")
return success_response(data)

View File

@ -57,6 +57,10 @@ def _normalize_dict_list(value: Any) -> list[dict[str, Any]]:
return [item for item in value if isinstance(item, dict)] return [item for item in value if isinstance(item, dict)]
def _normalize_optional_dict(value: Any) -> dict[str, Any]:
return dict(value) if isinstance(value, dict) else {}
def _default_evidence_pack(*, answer: str, tool_trace: list[dict[str, Any]]) -> dict[str, Any]: def _default_evidence_pack(*, answer: str, tool_trace: list[dict[str, Any]]) -> dict[str, Any]:
return { return {
"summary": { "summary": {
@ -112,6 +116,9 @@ def build_rag_response_payload(*, ingress_result: dict[str, Any]) -> dict[str, A
if "tool_trace" not in payload: if "tool_trace" not in payload:
payload["tool_trace"] = [] payload["tool_trace"] = []
payload["tool_trace"] = _normalize_dict_list(payload.get("tool_trace")) payload["tool_trace"] = _normalize_dict_list(payload.get("tool_trace"))
payload["route_decision"] = _normalize_optional_dict(payload.get("route_decision"))
payload["clarification_code"] = str(payload.get("clarification_code") or "").strip() or None
payload["clarification_candidates"] = _normalize_dict_list(payload.get("clarification_candidates"))
payload["citations"] = _normalize_dict_list(payload.get("citations")) payload["citations"] = _normalize_dict_list(payload.get("citations"))
payload["sources_used"] = _normalize_dict_list(payload.get("sources_used")) payload["sources_used"] = _normalize_dict_list(payload.get("sources_used"))
payload["answer_mode"] = str(payload.get("answer_mode") or "retrieval_normal").strip() or "retrieval_normal" payload["answer_mode"] = str(payload.get("answer_mode") or "retrieval_normal").strip() or "retrieval_normal"

View File

@ -511,6 +511,14 @@ def _scene_entries() -> tuple[dict[str, Any], ...]:
output_contract="输出项目状态结论、进度与下一步建议。", output_contract="输出项目状态结论、进度与下一步建议。",
call_sites=DEEP_GUARD_CALL_SITES, call_sites=DEEP_GUARD_CALL_SITES,
), ),
_entry(
"PROMPT_PROJECT_WEEKLY_REPORT_JSON",
module="part_scenes_03",
business_scenario="项目周报结构化输出",
input_contract="输入 question / asks_risk_or_blockers / project_snapshot / internal_findings / external_updates。",
output_contract="输出 JSON 对象,包含 summary / stage_judgement / evidence_summary / core_gap / external_impact / actions。",
call_sites=("backend/app/services/adaptive_rag_service/mixin_fast_answer_project_main.py",),
),
_entry( _entry(
"PROMPT_MEMBER_WORKLOAD_QA", "PROMPT_MEMBER_WORKLOAD_QA",
module="part_scenes_04", module="part_scenes_04",
@ -527,6 +535,14 @@ def _scene_entries() -> tuple[dict[str, Any], ...]:
output_contract="输出 AI PMO 视角的结论与建议。", output_contract="输出 AI PMO 视角的结论与建议。",
call_sites=DEEP_GUARD_CALL_SITES, call_sites=DEEP_GUARD_CALL_SITES,
), ),
_entry(
"PROMPT_AI_PMO_REQUIREMENT_PLAN_QA",
module="part_scenes_05",
business_scenario="AI PMO 需求收敛问答",
input_contract="输入 question / project_id / world_model / conversation / context_blocks。",
output_contract="输出需求收敛、选项澄清与下一步建议。",
call_sites=DEEP_GUARD_CALL_SITES,
),
_entry( _entry(
"PROMPT_EXTERNAL_TREND_QA", "PROMPT_EXTERNAL_TREND_QA",
module="part_scenes_06", module="part_scenes_06",
@ -543,6 +559,30 @@ def _scene_entries() -> tuple[dict[str, Any], ...]:
output_contract="输出行业趋势判断与建议。", output_contract="输出行业趋势判断与建议。",
call_sites=DEEP_GUARD_CALL_SITES, call_sites=DEEP_GUARD_CALL_SITES,
), ),
_entry(
"PROMPT_EXTERNAL_DIRECT_FACT_QA",
module="part_scenes_06",
business_scenario="外部事实直答",
input_contract="输入 question / plan_summary / evidence_blocks / source_summaries / time_range / uncertainties。",
output_contract="输出仅基于外部证据的事实结论。",
call_sites=("backend/app/services/external_query_service/presenter.py",),
),
_entry(
"PROMPT_EXTERNAL_SUMMARY_WITH_SOURCES_QA",
module="part_scenes_06",
business_scenario="外部事实总结",
input_contract="输入 question / plan_summary / evidence_blocks / source_summaries / time_range / uncertainties。",
output_contract="输出仅基于外部证据的总结性回答。",
call_sites=("backend/app/services/external_query_service/presenter.py",),
),
_entry(
"PROMPT_EXTERNAL_COMPARE_WITH_SOURCES_QA",
module="part_scenes_06",
business_scenario="外部事实对比",
input_contract="输入 question / plan_summary / evidence_blocks / source_summaries / time_range / uncertainties。",
output_contract="输出仅基于外部证据的对比回答。",
call_sites=("backend/app/services/external_query_service/presenter.py",),
),
) )

View File

@ -48,9 +48,13 @@ from .ai_prompts_catalog import (
PROMPT_FEISHU_DIRECT_QA, PROMPT_FEISHU_DIRECT_QA,
PROMPT_FEISHU_MIXED_QA, PROMPT_FEISHU_MIXED_QA,
PROMPT_PROJECT_STATUS_QA, PROMPT_PROJECT_STATUS_QA,
PROMPT_PROJECT_WEEKLY_REPORT_JSON,
PROMPT_MEMBER_WORKLOAD_QA, PROMPT_MEMBER_WORKLOAD_QA,
PROMPT_AI_PMO_QA, PROMPT_AI_PMO_QA,
PROMPT_AI_PMO_REQUIREMENT_PLAN_QA, PROMPT_AI_PMO_REQUIREMENT_PLAN_QA,
PROMPT_EXTERNAL_COMPARE_WITH_SOURCES_QA,
PROMPT_EXTERNAL_DIRECT_FACT_QA,
PROMPT_EXTERNAL_SUMMARY_WITH_SOURCES_QA,
PROMPT_EXTERNAL_TREND_QA, PROMPT_EXTERNAL_TREND_QA,
PROMPT_INDUSTRY_TREND_QA, PROMPT_INDUSTRY_TREND_QA,
) )
@ -167,6 +171,8 @@ PROMPT_RAG_QUERY_SEMANTICS = """
- conversation intent(question / statement / mixed) - conversation intent(question / statement / mixed)
- query_type(existence / count / list / status / explain / diagnose / compare / unknown) - query_type(existence / count / list / status / explain / diagnose / compare / unknown)
- object_mentions(用户当前想解析的对象提及,保留原始业务名词短语) - object_mentions(用户当前想解析的对象提及,保留原始业务名词短语)
- 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) - subject_focus(people / project / task / signal / charter / workload / feature / unknown)
- scope_hint(none / global / project) - scope_hint(none / global / project)
@ -186,10 +192,14 @@ PROMPT_RAG_QUERY_SEMANTICS = """
- 如果问题是在问人员、成员、负责人清单或人数,subject_focus 应为 people。 - 如果问题是在问人员、成员、负责人清单或人数,subject_focus 应为 people。
- 如果问题是在问功能做什么、入口在哪、模块如何进入,subject_focus 应为 feature,并结合 feature_focus 标明是 purpose / entry / catalog。 - 如果问题是在问功能做什么、入口在哪、模块如何进入,subject_focus 应为 feature,并结合 feature_focus 标明是 purpose / entry / catalog。
- 如果问题是在问“有没有 / 是否存在 / 系统里有无”这类内部对象存在性,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 内部项目对象,应输出 `query_type=existence`、`subject_focus=project`、`object_mentions=["采购经理每日实时库存上报服务"]`、`source_policy="internal"`、`effective_source_policy="internal"`。
- 外部公开事实示例:`当前 codex 最新的模型用的是什么?` - 外部公开事实示例:`当前 codex 最新的模型用的是什么?`
query_profile 应体现 `needs_freshness=true`、`referenced_objects=["public_model"]`,retrieval_domains 应优先为 ["url"],不要因为出现“模型”二字就改判成系统内部配置问题。 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`、`referenced_objects=["system_config","model_routing"]`,retrieval_domains 应优先为 ["db","pmflow"],这是在问系统配置而不是公开外部事实。 query_profile 应体现 `needs_private_context=true`、`referenced_objects=["system_config","model_routing"]`,retrieval_domains 应优先为 ["db","pmflow"],并输出 `source_policy="internal"`、`effective_source_policy="internal"`;这是在问系统配置而不是公开外部事实。
- 如果无法可靠判断 internal 还是 external,才输出 `source_policy="uncertain"` 与 `effective_source_policy="uncertain"`。
- 若证据不足,query_type 输出 unknown,retrieval_domains 选择保守但完整的查询范围。 - 若证据不足,query_type 输出 unknown,retrieval_domains 选择保守但完整的查询范围。
- 输出必须是一个 JSON 对象,不要输出 Markdown。 - 输出必须是一个 JSON 对象,不要输出 Markdown。
@ -200,6 +210,8 @@ JSON schema:
"statement_part": "string", "statement_part": "string",
"query_type": "existence | count | list | status | explain | diagnose | compare | unknown", "query_type": "existence | count | list | status | explain | diagnose | compare | unknown",
"object_mentions": ["string"], "object_mentions": ["string"],
"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", "subject_focus": "people | project | task | signal | charter | workload | feature | unknown",
"scope_hint": "none | global | project", "scope_hint": "none | global | project",
@ -826,9 +838,13 @@ __all__ = [
"PROMPT_FEISHU_DIRECT_QA", "PROMPT_FEISHU_DIRECT_QA",
"PROMPT_FEISHU_MIXED_QA", "PROMPT_FEISHU_MIXED_QA",
"PROMPT_PROJECT_STATUS_QA", "PROMPT_PROJECT_STATUS_QA",
"PROMPT_PROJECT_WEEKLY_REPORT_JSON",
"PROMPT_MEMBER_WORKLOAD_QA", "PROMPT_MEMBER_WORKLOAD_QA",
"PROMPT_AI_PMO_QA", "PROMPT_AI_PMO_QA",
"PROMPT_AI_PMO_REQUIREMENT_PLAN_QA", "PROMPT_AI_PMO_REQUIREMENT_PLAN_QA",
"PROMPT_EXTERNAL_DIRECT_FACT_QA",
"PROMPT_EXTERNAL_SUMMARY_WITH_SOURCES_QA",
"PROMPT_EXTERNAL_COMPARE_WITH_SOURCES_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",

View File

@ -6,10 +6,16 @@ from .part_03 import PROMPT_RAG_ANSWER, PROMPT_DECISION_QA, PROMPT_RAG_DB_PROJEC
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
from .part_scenes_01 import PROMPT_FEISHU_DIRECT_QA from .part_scenes_01 import PROMPT_FEISHU_DIRECT_QA
from .part_scenes_02 import PROMPT_FEISHU_MIXED_QA from .part_scenes_02 import PROMPT_FEISHU_MIXED_QA
from .part_scenes_03 import PROMPT_PROJECT_STATUS_QA from .part_scenes_03 import PROMPT_PROJECT_STATUS_QA, PROMPT_PROJECT_WEEKLY_REPORT_JSON
from .part_scenes_04 import PROMPT_MEMBER_WORKLOAD_QA from .part_scenes_04 import PROMPT_MEMBER_WORKLOAD_QA
from .part_scenes_05 import PROMPT_AI_PMO_QA, PROMPT_AI_PMO_REQUIREMENT_PLAN_QA from .part_scenes_05 import PROMPT_AI_PMO_QA, PROMPT_AI_PMO_REQUIREMENT_PLAN_QA
from .part_scenes_06 import PROMPT_EXTERNAL_TREND_QA, PROMPT_INDUSTRY_TREND_QA from .part_scenes_06 import (
PROMPT_EXTERNAL_COMPARE_WITH_SOURCES_QA,
PROMPT_EXTERNAL_DIRECT_FACT_QA,
PROMPT_EXTERNAL_SUMMARY_WITH_SOURCES_QA,
PROMPT_EXTERNAL_TREND_QA,
PROMPT_INDUSTRY_TREND_QA,
)
__all__ = [ __all__ = [
"PROMPT_IMPACT_ANALYSIS", "PROMPT_IMPACT_ANALYSIS",
@ -52,9 +58,13 @@ __all__ = [
"PROMPT_FEISHU_DIRECT_QA", "PROMPT_FEISHU_DIRECT_QA",
"PROMPT_FEISHU_MIXED_QA", "PROMPT_FEISHU_MIXED_QA",
"PROMPT_PROJECT_STATUS_QA", "PROMPT_PROJECT_STATUS_QA",
"PROMPT_PROJECT_WEEKLY_REPORT_JSON",
"PROMPT_MEMBER_WORKLOAD_QA", "PROMPT_MEMBER_WORKLOAD_QA",
"PROMPT_AI_PMO_QA", "PROMPT_AI_PMO_QA",
"PROMPT_AI_PMO_REQUIREMENT_PLAN_QA", "PROMPT_AI_PMO_REQUIREMENT_PLAN_QA",
"PROMPT_EXTERNAL_TREND_QA", "PROMPT_EXTERNAL_TREND_QA",
"PROMPT_INDUSTRY_TREND_QA", "PROMPT_INDUSTRY_TREND_QA",
"PROMPT_EXTERNAL_DIRECT_FACT_QA",
"PROMPT_EXTERNAL_SUMMARY_WITH_SOURCES_QA",
"PROMPT_EXTERNAL_COMPARE_WITH_SOURCES_QA",
] ]

View File

@ -6,9 +6,9 @@ def _compose_prompt(*parts: str) -> str:
PMFLOW_PMO_STYLE_BASE = """ PMFLOW_PMO_STYLE_BASE = """
你是 PMFlow 的 AI PMO 协作中枢成员。 你是 PMFlow 的 AI PMO 协作中枢成员。
风格要求: 风格要求:
1. 专业、克制、结论先行。 1. 专业、克制,先给用户最需要的判断结果。
2. 先给判断,再给依据,再给下一步。 2. 优先引用可验证事实,不写空泛套话,不默认扩写“下一步”。
3. 优先引用可验证事实,不写空泛套话。 3. 若证据不足,明确标出不确定项,不要用汇报体补满答案。
""".strip() """.strip()
JSON_OUTPUT_GUARDRAIL = """ JSON_OUTPUT_GUARDRAIL = """

View File

@ -77,6 +77,8 @@ PROMPT_RAG_DB_PROJECT_QA = _compose_prompt(
输入: 输入:
- 用户问题:{question} - 用户问题:{question}
- 问法类型:{query_type}
- 回答合同:{answer_contract}
- 项目索引(JSON): - 项目索引(JSON):
{project_index} {project_index}
@ -90,9 +92,14 @@ PROMPT_RAG_DB_PROJECT_QA = _compose_prompt(
规则: 规则:
1. 若项目索引无法回答,返回 handled=false,answer 置空,matched_project_ids 置空数组。 1. 若项目索引无法回答,返回 handled=false,answer 置空,matched_project_ids 置空数组。
2. answer 只写对用户有用的最终回答,不要复述索引原文。 2. answer 只写对用户有用的最终回答,不要复述索引原文,不要输出“下一步建议”“缺少字段”这类顾问式扩写。
3. confidence 用 0.0-1.0 的小数。 3. 只能使用项目索引里已提供的字段;字段没有提供时可写“未记录”,不要编造。
4. 不要输出 Markdown、解释文本或思考过程。 4. 若 query_type=existence,answer 首句必须先直接回答“有 / 没有 / 命中 N 个项目”。
5. 若命中了 1-3 个项目,answer 需优先列出每个命中项目的已核实字段,优先顺序:
项目ID、项目名、状态、负责人、最近更新、任务总数、已完成、进行中、待处理。
6. 若 owner_name 已提供且不为空,不要忽略负责人字段。
7. confidence 用 0.0-1.0 的小数。
8. 不要输出 Markdown、解释文本或思考过程。
""", """,
JSON_OUTPUT_GUARDRAIL, JSON_OUTPUT_GUARDRAIL,
) )

View File

@ -6,12 +6,12 @@ FEISHU_QA_RESPONSE_STYLE = _compose_prompt(
PMFLOW_PMO_STYLE_BASE, PMFLOW_PMO_STYLE_BASE,
""" """
回复风格要求: 回复风格要求:
1. 先给结论,再给关键依据,最后再给下一步。 1. 直接回答用户问题,首句就给可判断的结果,不要写“结论:”“依据:”“下一步:”这类栏目标题。
2. 用正式、克制、面向协作的中文,像资深项目经理而不是聊天机器人。 2. 用正式、克制、面向协作的中文,像靠谱同事,而不是顾问汇报或聊天机器人。
3. 如果能用数字、项目名、负责人、时间点表达,请直接写明。 3. 如果能用数字、项目名、负责人、时间点表达,请直接写明,只写已核实信息。
4. 若信息不足,必须明确说明缺少什么,最多列 3 项。 4. 不要主动扩写用户没问到的建议、负责人联络、接口路径、排期动作;只有用户明确要求或当前回答被证据阻塞时才提。
5. 可使用 1-3 个轻量 emoji 做分段,但不要花哨,不要影响专业感。 5. 若证据不足,明确写“当前证据不足:...”并列关键缺口,最多 3 项;不要使用旧式缺口栏目标题。
6. 飞书输出优先短段落和清晰换行;如果包含“结论/依据/下一步/信息不足”,要让这些段落一眼可扫读。 6. 飞书输出优先短段落和清晰换行,不用 emoji 分段,不要堆模板化栏目。
7. 禁止自我介绍、寒暄、思考过程、数据库/模型/接口实现细节。 7. 禁止自我介绍、寒暄、思考过程、数据库/模型/接口实现细节。
8. 禁止输出内部函数名、文件路径、代码行号、模块名或调试字段。 8. 禁止输出内部函数名、文件路径、代码行号、模块名或调试字段。
""".strip(), """.strip(),
@ -25,11 +25,13 @@ PROMPT_FEISHU_DIRECT_QA = _compose_prompt(
回答要求: 回答要求:
1. 仅基于给定上下文回答,优先使用结构化数据。 1. 仅基于给定上下文回答,优先使用结构化数据。
2. 如果问题涉及项目状态、进展、成员或任务,请优先引用项目画像、检索证据和用户焦点里的可验证事实与数字。 2. 如果问题涉及项目状态、进展、成员或任务,请优先引用项目画像、检索证据和用户焦点里的可验证事实与数字。
3. 如果问题涉及选择、建议或判断,直接给出明确结论和 1-2 条理由。 3. 如果问题是内部 existence / status / count / list 问句,先直接回答有没有、状态是什么、数量多少,再补已核实字段;不要自动补“下一步建议”。
4. 信息不足时,直接写“信息不足,缺少:...”并列出关键缺失项。 4. 如果问题涉及选择、建议或判断,直接给出明确答案和 1-2 条理由,不要写顾问汇报体。
5. 控制在 220 字以内,适合飞书消息阅读。 5. 信息不足时,直接写“当前证据不足:...”并列关键缺失项。
6. 不输出 Markdown、JSON 或思考过程。 6. 控制在 220 字以内,适合飞书消息阅读。
7. 禁止以“让我先/我来看看/我需要先”等句式开头。 7. 不输出 Markdown、JSON 或思考过程。
8. 禁止以“让我先/我来看看/我需要先”等句式开头。
9. 不要使用旧式栏目标题,尤其不要把回答拆成顾问汇报体。
当前项目ID:{project_id} 当前项目ID:{project_id}
问题:{question} 问题:{question}

View File

@ -1,6 +1,6 @@
from __future__ import annotations from __future__ import annotations
from .part_01 import TEXT_OUTPUT_GUARDRAIL, _compose_prompt from .part_01 import JSON_OUTPUT_GUARDRAIL, TEXT_OUTPUT_GUARDRAIL, _compose_prompt
PROMPT_PROJECT_STATUS_QA = _compose_prompt( PROMPT_PROJECT_STATUS_QA = _compose_prompt(
"你是 PMFlow 的项目状态分析助手。", "你是 PMFlow 的项目状态分析助手。",
@ -28,4 +28,50 @@ User Snapshot:{user_snapshot}
TEXT_OUTPUT_GUARDRAIL, TEXT_OUTPUT_GUARDRAIL,
) )
__all__ = ["PROMPT_PROJECT_STATUS_QA"] PROMPT_PROJECT_WEEKLY_REPORT_JSON = _compose_prompt(
"你是 PMFlow 的项目周报生成助手。",
"""
你会收到系统整理好的内部项目事实和外部变化,不需要自行检索。
输入:
- 用户问题:{question}
- 是否重点追问风险/阻塞:{asks_risk_or_blockers}
- 项目快照(JSON):
{project_snapshot}
- 内部判断辅助(JSON):
{internal_findings}
- 外部变化(JSON):
{external_updates}
输出 JSON:
{{
"handled": true,
"summary": "一句话项目结论",
"stage_judgement": "项目目前处于什么阶段",
"evidence_summary": "支撑判断的核心证据",
"core_gap": "当前最主要的缺口",
"external_impact": "外部变化对项目的直接影响,没有则留空字符串",
"actions": [
{{"slot": "this_week", "text": "这周先推进什么"}},
{{"slot": "owner_decision", "text": "负责人需要拍板什么"}},
{{"slot": "protect_priority", "text": "资源不够时先保什么"}}
]
}}
规则:
1. 只基于输入事实输出,不得补编不存在的项目进展、负责人动作或外部事件。
2. summary 必须先给判断,不能只是复述字段。
3. stage_judgement 只能写阶段判断,例如“启动后停滞阶段”“执行中但存在阻塞”“接近交付阶段”。
4. evidence_summary 必须引用项目快照里的任务、信号、更新时间、负责人或外部变化中的至少一类事实。
5. core_gap 只能写一个最主要问题,不要堆三四个问题。
6. actions 必须是可执行动作,禁止“建议关注”“建议沟通”“建议推进”这类空话。
7. 每条 action 必须同时包含动作对象或动作范围。
8. 外部变化栏会由系统直接渲染,你只负责输出 external_impact,不要在 actions 或 summary 里机械复述外部原文标题。
9. 如果外部变化为空数组,external_impact 返回空字符串。
10. 语气像项目负责人在写周报,不要像 AI 助手播报字段。
11. 只输出 JSON,不要输出 Markdown、解释或思考过程。
""".strip(),
JSON_OUTPUT_GUARDRAIL,
)
__all__ = ["PROMPT_PROJECT_STATUS_QA", "PROMPT_PROJECT_WEEKLY_REPORT_JSON"]

View File

@ -48,4 +48,88 @@ PROMPT_INDUSTRY_TREND_QA = _compose_prompt(
TEXT_OUTPUT_GUARDRAIL, TEXT_OUTPUT_GUARDRAIL,
) )
__all__ = ["PROMPT_EXTERNAL_TREND_QA", "PROMPT_INDUSTRY_TREND_QA"] PROMPT_EXTERNAL_DIRECT_FACT_QA = _compose_prompt(
"你是 PMFlow 的外部事实回答器。",
"""
用户在询问一个需要外部证据支撑的公开事实问题。
规则:
1. 只能依据提供的外部证据回答,禁止引用项目资料、系统快照、用户历史或内部检索内容。
2. 如果证据只够支撑部分结论,要明确写出“已确认”和“待确认”。
3. 优先直接回答问题,不要写行业分析腔。
4. 回答控制在 300 字内,适合飞书阅读。
5. 禁止输出思考过程、内部函数名、文件路径、代码行号、模块名或调试字段。
问题:{question}
取证计划:
{plan_summary}
外部证据:
{evidence_blocks}
来源摘要:{source_summaries}
时间范围:{time_range}
不确定项:{uncertainties}
""".strip(),
TEXT_OUTPUT_GUARDRAIL,
)
PROMPT_EXTERNAL_SUMMARY_WITH_SOURCES_QA = _compose_prompt(
"你是 PMFlow 的外部事实总结助手。",
"""
用户在询问一个需要基于外部来源综合说明的问题。
规则:
1. 只能依据提供的外部证据总结,不要混入项目资料、系统快照、用户历史或内部检索内容。
2. 先给结论,再给 2-3 条证据支撑;证据不足时必须明确写出不确定项。
3. 结论要克制,不要把推断包装成已核实事实。
4. 回答控制在 500 字内,适合飞书阅读。
5. 禁止输出思考过程、内部函数名、文件路径、代码行号、模块名或调试字段。
问题:{question}
取证计划:
{plan_summary}
外部证据:
{evidence_blocks}
来源摘要:{source_summaries}
时间范围:{time_range}
不确定项:{uncertainties}
""".strip(),
TEXT_OUTPUT_GUARDRAIL,
)
PROMPT_EXTERNAL_COMPARE_WITH_SOURCES_QA = _compose_prompt(
"你是 PMFlow 的外部对比分析助手。",
"""
用户在请求对多个外部对象进行对比。
规则:
1. 只能依据提供的外部证据对比,不要混入项目资料、系统快照、用户历史或内部检索内容。
2. 先给总判断,再按“对比维度 -> 证据 -> 结论”组织内容。
3. 如果某个维度证据不足,要单独指出,不要硬补。
4. 回答控制在 600 字内,适合飞书阅读。
5. 禁止输出思考过程、内部函数名、文件路径、代码行号、模块名或调试字段。
问题:{question}
取证计划:
{plan_summary}
外部证据:
{evidence_blocks}
来源摘要:{source_summaries}
时间范围:{time_range}
不确定项:{uncertainties}
""".strip(),
TEXT_OUTPUT_GUARDRAIL,
)
__all__ = [
"PROMPT_EXTERNAL_TREND_QA",
"PROMPT_INDUSTRY_TREND_QA",
"PROMPT_EXTERNAL_DIRECT_FACT_QA",
"PROMPT_EXTERNAL_SUMMARY_WITH_SOURCES_QA",
"PROMPT_EXTERNAL_COMPARE_WITH_SOURCES_QA",
]

View File

@ -0,0 +1,68 @@
"""Query route governance read schemas."""
from __future__ import annotations
from typing import Any, Optional
from pydantic import BaseModel, Field
class QueryRouteGovernanceBucketRead(BaseModel):
key: str = Field(..., min_length=1, max_length=64)
label: str = Field(..., min_length=1, max_length=120)
count: int = Field(default=0, ge=0)
class QueryRouteGovernanceKPIRead(BaseModel):
total_records: int = Field(default=0, ge=0)
mismatch_count: int = Field(default=0, ge=0)
clarify_count: int = Field(default=0, ge=0)
negative_feedback_count: int = Field(default=0, ge=0)
internal_count: int = Field(default=0, ge=0)
external_count: int = Field(default=0, ge=0)
uncertain_count: int = Field(default=0, ge=0)
class QueryRouteGovernanceSummaryRead(BaseModel):
window_days: int = Field(..., ge=1, le=90)
project_id: Optional[int] = None
kpis: QueryRouteGovernanceKPIRead
route_modes: list[QueryRouteGovernanceBucketRead] = Field(default_factory=list)
decision_origins: list[QueryRouteGovernanceBucketRead] = Field(default_factory=list)
class QueryRouteGovernanceRecordRead(BaseModel):
id: int
qa_record_id: int
project_id: Optional[int] = None
session_id: Optional[int] = None
session_title: Optional[str] = None
question_text: str
route_mode_predicted: str
route_mode_effective: str
decision_origin: Optional[str] = None
reply_mode: Optional[str] = None
feedback_rating: Optional[str] = None
feedback_helpfulness: Optional[str] = None
is_route_mismatch: bool = False
is_clarify: bool = False
anchor_summary: Optional[str] = None
answer_mode: Optional[str] = None
grounding_status: Optional[str] = None
asked_via: Optional[str] = None
created_at: Optional[str] = None
updated_at: Optional[str] = None
class QueryRouteGovernanceListRead(BaseModel):
items: list[QueryRouteGovernanceRecordRead] = Field(default_factory=list)
total: int = Field(default=0, ge=0)
limit: int = Field(default=20, ge=1, le=100)
offset: int = Field(default=0, ge=0)
class QueryRouteGovernanceDetailRead(BaseModel):
record: QueryRouteGovernanceRecordRead
qa_record: dict[str, Any]
route_memory: dict[str, Any]

View File

@ -0,0 +1,189 @@
from __future__ import annotations
from typing import Any
from pydantic import BaseModel, ConfigDict, Field
class InternalContextPacket(BaseModel):
model_config = ConfigDict(extra="ignore")
packet_version: str = "v1"
question: str = ""
scope: dict[str, Any] = Field(default_factory=dict)
route_decision: dict[str, Any] = Field(default_factory=dict)
operation_contract: dict[str, Any] = Field(default_factory=dict)
target_resolution: dict[str, Any] = Field(default_factory=dict)
query_plan: dict[str, Any] = Field(default_factory=dict)
executor_summary: dict[str, Any] = Field(default_factory=dict)
verifier_summary: dict[str, Any] = Field(default_factory=dict)
uncertainties: list[str] = Field(default_factory=list)
def coerce_internal_query_context_packet(value: Any) -> InternalContextPacket | None:
if isinstance(value, InternalContextPacket):
return value
if isinstance(value, dict):
try:
return InternalContextPacket.model_validate(value)
except Exception:
return None
return None
def _safe_mapping(value: Any) -> dict[str, Any]:
return dict(value) if isinstance(value, dict) else {}
def _normalized_internal_route_mode(route_decision: Any) -> str:
payload = _safe_mapping(route_decision)
return str(
payload.get("effective_source_policy")
or payload.get("route_mode")
or payload.get("source_policy")
or ""
).strip().lower()
def internal_context_route_decision(
*,
packet: Any = None,
fallback: Any = None,
) -> dict[str, Any]:
normalized = coerce_internal_query_context_packet(packet)
if normalized is not None and isinstance(normalized.route_decision, dict):
return dict(normalized.route_decision)
return _safe_mapping(fallback)
def internal_context_requires_packet(
*,
packet: Any = None,
route_decision: Any = None,
) -> bool:
normalized_route = internal_context_route_decision(packet=packet, fallback=route_decision)
return _normalized_internal_route_mode(normalized_route) == "internal"
def internal_context_semantic_mirror(
*,
packet: Any = None,
query_semantics: Any = None,
key: str,
) -> dict[str, Any] | None:
if internal_context_requires_packet(packet=packet, route_decision=query_semantics):
return None
semantics = _safe_mapping(query_semantics)
value = semantics.get(str(key or "").strip())
return dict(value) if isinstance(value, dict) else None
def internal_context_operation_contract(
*,
packet: Any = None,
fallback: Any = None,
) -> dict[str, Any]:
normalized = coerce_internal_query_context_packet(packet)
if normalized is not None and isinstance(normalized.operation_contract, dict):
return dict(normalized.operation_contract)
return _safe_mapping(fallback)
def internal_context_target_resolution(
*,
packet: Any = None,
fallback: Any = None,
) -> dict[str, Any]:
normalized = coerce_internal_query_context_packet(packet)
if normalized is not None and isinstance(normalized.target_resolution, dict):
return dict(normalized.target_resolution)
return _safe_mapping(fallback)
def internal_context_query_plan(
*,
packet: Any = None,
fallback: Any = None,
) -> dict[str, Any]:
normalized = coerce_internal_query_context_packet(packet)
if normalized is not None and isinstance(normalized.query_plan, dict):
return dict(normalized.query_plan)
return _safe_mapping(fallback)
def internal_context_block_reason(
packet: Any,
*,
route_decision: Any = None,
) -> tuple[str, list[str]] | None:
normalized = coerce_internal_query_context_packet(packet)
if internal_context_requires_packet(packet=normalized, route_decision=route_decision) and normalized is None:
return ("missing_internal_context_packet", ["internal_context_packet"])
if normalized is None:
return None
operation_contract = internal_context_operation_contract(packet=normalized)
if not str(operation_contract.get("query_type") or "").strip():
return ("missing_internal_operation_contract", ["query_type"])
target_resolution = internal_context_target_resolution(packet=normalized)
resolution_mode = str(target_resolution.get("resolution_mode") or "").strip().lower()
if resolution_mode == "clarify":
return ("target_resolution_clarify", ["target_resolution"])
query_plan = internal_context_query_plan(packet=normalized)
if not str(query_plan.get("answer_contract") or "").strip():
return ("missing_internal_query_plan_contract", ["answer_contract"])
return None
def update_internal_query_context_packet(
packet: Any,
*,
executor_summary: dict[str, Any] | None = None,
verifier_summary: dict[str, Any] | None = None,
uncertainties: list[str] | None = None,
) -> InternalContextPacket | None:
normalized = coerce_internal_query_context_packet(packet)
if normalized is None:
return None
next_uncertainties = [
str(item).strip()
for item in list(normalized.uncertainties or [])
if str(item).strip()
]
for item in uncertainties or []:
text = str(item).strip()
if text and text not in next_uncertainties:
next_uncertainties.append(text)
return normalized.model_copy(
update={
"executor_summary": (
dict(executor_summary)
if isinstance(executor_summary, dict)
else dict(normalized.executor_summary or {})
),
"verifier_summary": (
dict(verifier_summary)
if isinstance(verifier_summary, dict)
else dict(normalized.verifier_summary or {})
),
"uncertainties": next_uncertainties,
}
)
__all__ = [
"InternalContextPacket",
"coerce_internal_query_context_packet",
"internal_context_block_reason",
"internal_context_operation_contract",
"internal_context_query_plan",
"internal_context_route_decision",
"internal_context_requires_packet",
"internal_context_semantic_mirror",
"internal_context_target_resolution",
"update_internal_query_context_packet",
]

View File

@ -2,9 +2,34 @@
from __future__ import annotations from __future__ import annotations
from .shared import * from .shared import *
from .internal_query_context import InternalContextPacket, internal_context_block_reason
class AdaptiveRAGAnswerMainlineMixin: class AdaptiveRAGAnswerMainlineMixin:
@staticmethod
def _mirror_internal_context_to_query_semantics(
*,
query_semantics: Optional[dict[str, Any]],
operation_contract: Optional[dict[str, Any]] = None,
target_resolution: Optional[dict[str, Any]] = None,
query_plan: Optional[dict[str, Any]] = None,
) -> dict[str, Any]:
mirrored = dict(query_semantics or {}) if isinstance(query_semantics, dict) else {}
if isinstance(operation_contract, dict):
mirrored["operation_contract"] = dict(operation_contract)
if isinstance(target_resolution, dict):
mirrored["target_resolution"] = dict(target_resolution)
if isinstance(query_plan, dict):
mirrored["query_plan"] = dict(query_plan)
return mirrored
@staticmethod
def _normalize_runtime_consultant_mode_preference(value: Optional[str]) -> Optional[str]:
text = str(value or "").strip().lower()
if text in {"clarify", "advise", "challenge", "brainstorm", "execute"}:
return text
return None
@staticmethod @staticmethod
def _internal_answer_contract_for_query_type(query_type: str) -> str: def _internal_answer_contract_for_query_type(query_type: str) -> str:
normalized = str(query_type or QUERY_TYPE_UNKNOWN).strip().lower() normalized = str(query_type or QUERY_TYPE_UNKNOWN).strip().lower()
@ -24,6 +49,48 @@ class AdaptiveRAGAnswerMainlineMixin:
return "comparison_with_basis" return "comparison_with_basis"
return "clarify_or_fail" return "clarify_or_fail"
@staticmethod
def _has_resolved_followup_rewrite(rewrite_meta: Optional[dict[str, Any]]) -> bool:
if not isinstance(rewrite_meta, dict):
return False
mode = str(rewrite_meta.get("mode") or "").strip().lower()
return mode in {
"state_subject",
"state_entities",
"followup_anchor_recent_reply",
"followup_anchor_meta",
}
@staticmethod
def _mirror_followup_state_to_query_semantics(
*,
query_semantics: Optional[dict[str, Any]],
session_state: Optional[QASessionState],
rewrite_meta: Optional[dict[str, Any]],
) -> dict[str, Any]:
semantics = dict(query_semantics or {}) if isinstance(query_semantics, dict) else {}
if not isinstance(rewrite_meta, dict):
return semantics
rewrite_mode = str(rewrite_meta.get("mode") or "").strip().lower()
if rewrite_mode not in {"state_subject", "state_entities"}:
return semantics
entity_ids: list[int] = []
if isinstance(getattr(session_state, "last_entity_ids", None), list):
for value in list(getattr(session_state, "last_entity_ids", None) or []):
try:
entity_ids.append(int(value))
except Exception:
continue
payload = {
"rewrite_mode": rewrite_mode,
"last_subject": str(getattr(session_state, "last_subject", "") or "").strip() or None,
"last_entity_ids": entity_ids[:6],
}
semantics["session_followup_context"] = payload
return semantics
@staticmethod @staticmethod
def _internal_entity_type_for_subject_focus(subject_focus: str) -> str: def _internal_entity_type_for_subject_focus(subject_focus: str) -> str:
normalized = str(subject_focus or "").strip().lower() normalized = str(subject_focus or "").strip().lower()
@ -150,6 +217,120 @@ class AdaptiveRAGAnswerMainlineMixin:
], ],
} }
def _build_internal_query_context_packet(
self,
*,
question: str,
project_id: Optional[int],
session_id: Optional[int],
conversation_scope_key: Optional[str],
reply_to_message_id: Optional[str],
root_message_id: Optional[str],
route_decision: Optional[dict[str, Any]],
operation_contract: Optional[dict[str, Any]],
target_resolution: Optional[dict[str, Any]],
query_plan: Optional[dict[str, Any]],
) -> InternalContextPacket | None:
normalize_route_decision = getattr(self, "_normalize_query_route_decision", None)
normalized_route = (
normalize_route_decision(route_decision)
if callable(normalize_route_decision)
else (dict(route_decision or {}) if isinstance(route_decision, dict) else {})
)
if str(normalized_route.get("effective_source_policy") or "").strip().lower() != "internal":
return None
return InternalContextPacket.model_validate(
{
"packet_version": "v1",
"question": str(question or "").strip(),
"scope": {
"tenant_id": int(getattr(self, "tenant_id", 0) or 0),
"project_id": int(project_id) if project_id is not None else None,
"session_id": int(session_id) if session_id is not None else None,
"conversation_scope_key": str(conversation_scope_key or "").strip() or None,
"reply_to_message_id": str(reply_to_message_id or "").strip() or None,
"root_message_id": str(root_message_id or "").strip() or None,
},
"route_decision": dict(normalized_route),
"operation_contract": dict(operation_contract or {}) if isinstance(operation_contract, dict) else {},
"target_resolution": dict(target_resolution or {}) if isinstance(target_resolution, dict) else {},
"query_plan": dict(query_plan or {}) if isinstance(query_plan, dict) else {},
"executor_summary": {},
"verifier_summary": {},
"uncertainties": [],
}
)
async def _maybe_return_internal_context_precheck_response(
self,
*,
question: str,
effective_question: str,
asked_by: Optional[int],
asked_via: str,
project_id: Optional[int],
session_id: Optional[int],
context_session_id: Optional[int],
turn_index: int,
state_row: Optional[QASessionState],
rewrite_meta: Optional[dict[str, Any]],
thinking_mode: str,
query_type: str,
route_decision: Optional[dict[str, Any]],
query_semantics: Optional[dict[str, Any]],
internal_context_packet: Any,
) -> Optional[dict[str, Any]]:
packet_block = internal_context_block_reason(
internal_context_packet,
route_decision=route_decision,
)
if packet_block is None:
return None
reason_code, missing_fields = packet_block
guard_builder = getattr(self, "_build_internal_answer_contract_guard_payload", None)
if not callable(guard_builder):
return None
guard_payload = guard_builder(
query_type=query_type,
reason_code=reason_code,
missing_fields=list(missing_fields or []),
)
persist_fast_answer = getattr(self, "_persist_fast_answer", None)
if callable(persist_fast_answer):
return await persist_fast_answer(
question=question,
asked_by=asked_by,
asked_via=asked_via,
project_id=project_id,
fast_result={
"answer": str(guard_payload.get("answer") or "").strip(),
"sources": list(guard_payload.get("sources") or []),
"missing_fields": list(guard_payload.get("missing_fields") or []),
"reason_code": str(guard_payload.get("reason_code") or "").strip() or reason_code,
"query_type": str(query_type or QUERY_TYPE_UNKNOWN).strip() or QUERY_TYPE_UNKNOWN,
"confidence": 0.0,
"db_query_ms": 0,
"response_time_ms": 0,
"prompt_variant_id": "internal_context_guard",
},
session_id=session_id,
external_session_id=context_session_id,
turn_index=turn_index,
query_type=query_type,
missing_fields=list(guard_payload.get("missing_fields") or []),
state_row=state_row,
effective_question=effective_question,
rewrite_meta=rewrite_meta or {},
thinking_mode=thinking_mode,
answer_mode=str(guard_payload.get("answer_mode") or ANSWER_MODE_CLARIFY),
route_decision=route_decision,
query_semantics=query_semantics,
internal_context_packet=internal_context_packet,
)
return dict(guard_payload or {})
@classmethod @classmethod
def _extract_prompt_variant_from_policy_version(cls, policy_version: Optional[str]) -> Optional[str]: def _extract_prompt_variant_from_policy_version(cls, policy_version: Optional[str]) -> Optional[str]:
raw = str(policy_version or "").strip() raw = str(policy_version or "").strip()
@ -224,7 +405,9 @@ class AdaptiveRAGAnswerMainlineMixin:
) )
normalized_thinking_mode = normalize_thinking_mode(requested_thinking_mode) normalized_thinking_mode = normalize_thinking_mode(requested_thinking_mode)
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_consultant_mode = str(consultant_mode_preference or "").strip().lower() normalized_consultant_mode = self._normalize_runtime_consultant_mode_preference(
consultant_mode_preference
)
fast_path_candidate = normalized_query_type in { fast_path_candidate = normalized_query_type in {
QUERY_TYPE_EXISTENCE, QUERY_TYPE_EXISTENCE,
QUERY_TYPE_COUNT, QUERY_TYPE_COUNT,
@ -357,6 +540,11 @@ class AdaptiveRAGAnswerMainlineMixin:
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
consumed_source_policy_hint = (
str(pending_selection.get("source_policy_hint") or "").strip().lower() or None
if isinstance(pending_selection, dict)
else None
)
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,
@ -375,6 +563,16 @@ class AdaptiveRAGAnswerMainlineMixin:
anchor_resolution=anchor_resolution, anchor_resolution=anchor_resolution,
) )
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(
isinstance(pending_selection, dict)
and (
resolved_question != clean_q
or consumed_source_policy_hint
)
)
if should_refresh_route_preview:
query_semantics = {}
route_decision = None
route_preview = await self.preview_query_route_decision( route_preview = await self.preview_query_route_decision(
question=effective_q, question=effective_q,
asked_via=asked_via, asked_via=asked_via,
@ -384,9 +582,9 @@ class AdaptiveRAGAnswerMainlineMixin:
conversation_scope_key=external_conversation_key, conversation_scope_key=external_conversation_key,
reply_to_message_id=str((thread_context or {}).get("reply_to_message_id") or "").strip() or None, 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, root_message_id=str((thread_context or {}).get("root_message_id") or "").strip() or None,
source_policy_hint=source_policy, source_policy_hint=consumed_source_policy_hint or source_policy,
query_semantics=query_semantics, query_semantics=query_semantics,
) if not isinstance(route_decision, dict) or not query_semantics else { ) if should_refresh_route_preview or not isinstance(route_decision, dict) or not query_semantics else {
"query_semantics": self._apply_route_decision_to_query_semantics( "query_semantics": self._apply_route_decision_to_query_semantics(
query_semantics=query_semantics, query_semantics=query_semantics,
route_decision=route_decision, route_decision=route_decision,
@ -405,16 +603,20 @@ class AdaptiveRAGAnswerMainlineMixin:
question=effective_q, question=effective_q,
query_semantics=query_semantics, query_semantics=query_semantics,
) )
if isinstance(operation_contract, dict): query_semantics = self._mirror_internal_context_to_query_semantics(
query_semantics["operation_contract"] = operation_contract query_semantics=query_semantics,
operation_contract=operation_contract,
)
internal_query_plan = self._build_internal_query_plan( internal_query_plan = self._build_internal_query_plan(
question=effective_q, question=effective_q,
project_id=project_id, project_id=project_id,
query_semantics=query_semantics, query_semantics=query_semantics,
operation_contract=operation_contract, operation_contract=operation_contract,
) )
if internal_query_plan: query_semantics = self._mirror_internal_context_to_query_semantics(
query_semantics["query_plan"] = internal_query_plan query_semantics=query_semantics,
query_plan=internal_query_plan,
)
route_decision = ( route_decision = (
dict(route_preview.get("route_decision") or {}) dict(route_preview.get("route_decision") or {})
if isinstance(route_preview.get("route_decision"), dict) if isinstance(route_preview.get("route_decision"), dict)
@ -579,6 +781,8 @@ class AdaptiveRAGAnswerMainlineMixin:
deep_thinking = True deep_thinking = True
active_thinking_mode = THINKING_MODE_DEEP active_thinking_mode = THINKING_MODE_DEEP
clarification_payload = None
if not self._has_resolved_followup_rewrite(rewrite_meta):
clarification_payload = self._build_followup_clarification_payload( clarification_payload = self._build_followup_clarification_payload(
question=clean_q, question=clean_q,
anchor_resolution=anchor_resolution, anchor_resolution=anchor_resolution,
@ -642,6 +846,11 @@ class AdaptiveRAGAnswerMainlineMixin:
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"
) )
query_semantics = self._mirror_followup_state_to_query_semantics(
query_semantics=query_semantics,
session_state=state_row,
rewrite_meta=rewrite_meta,
)
target_resolution = ( target_resolution = (
dict(query_semantics.get("target_resolution") or {}) dict(query_semantics.get("target_resolution") or {})
if isinstance(query_semantics.get("target_resolution"), dict) if isinstance(query_semantics.get("target_resolution"), dict)
@ -656,8 +865,10 @@ class AdaptiveRAGAnswerMainlineMixin:
system_snapshot=system_snapshot, system_snapshot=system_snapshot,
operation_contract=operation_contract, operation_contract=operation_contract,
) )
if isinstance(target_resolution, dict): query_semantics = self._mirror_internal_context_to_query_semantics(
query_semantics["target_resolution"] = target_resolution query_semantics=query_semantics,
target_resolution=target_resolution,
)
patch_scene_semantics = getattr(self, "_patch_scene_semantics_with_target_resolution", None) patch_scene_semantics = getattr(self, "_patch_scene_semantics_with_target_resolution", None)
if callable(patch_scene_semantics): if callable(patch_scene_semantics):
scene_semantics = patch_scene_semantics( scene_semantics = patch_scene_semantics(
@ -672,8 +883,49 @@ class AdaptiveRAGAnswerMainlineMixin:
operation_contract=operation_contract, operation_contract=operation_contract,
target_resolution=target_resolution, target_resolution=target_resolution,
) )
if internal_query_plan: query_semantics = self._mirror_internal_context_to_query_semantics(
query_semantics["query_plan"] = internal_query_plan query_semantics=query_semantics,
query_plan=internal_query_plan,
)
active_context = (
self._get_active_external_qa_context()
if hasattr(self, "_get_active_external_qa_context")
else {}
)
# Keep query_semantics as a compatibility mirror only; internal execution truth moves to the packet.
internal_context_packet = self._build_internal_query_context_packet(
question=effective_q,
project_id=project_id,
session_id=session_id,
conversation_scope_key=str(active_context.get("conversation_scope_key") or "").strip() or None,
reply_to_message_id=str(active_context.get("reply_to_message_id") or "").strip() or None,
root_message_id=str(active_context.get("root_message_id") or "").strip() or None,
route_decision=route_decision,
operation_contract=operation_contract,
target_resolution=target_resolution,
query_plan=internal_query_plan,
)
precheck_response = await self._maybe_return_internal_context_precheck_response(
question=clean_q,
effective_question=effective_q,
asked_by=asked_by,
asked_via=asked_via,
project_id=project_id,
session_id=session_id,
context_session_id=context_session_id,
turn_index=turn_index,
state_row=state_row,
rewrite_meta=rewrite_meta,
thinking_mode=active_thinking_mode,
query_type=query_type,
route_decision=route_decision,
query_semantics=query_semantics,
internal_context_packet=internal_context_packet,
)
if precheck_response is not None:
return precheck_response
if planner.allow_fast_path and not prefers_external_retrieval: if planner.allow_fast_path and not prefers_external_retrieval:
deterministic_result = await self._run_deterministic_query( deterministic_result = await self._run_deterministic_query(
@ -681,6 +933,7 @@ class AdaptiveRAGAnswerMainlineMixin:
project_id=project_id, project_id=project_id,
query_type=query_type, query_type=query_type,
query_semantics=query_semantics, query_semantics=query_semantics,
internal_context_packet=internal_context_packet,
) )
if deterministic_result is not None: if deterministic_result is not None:
return await self._persist_fast_answer( return await self._persist_fast_answer(
@ -701,6 +954,7 @@ class AdaptiveRAGAnswerMainlineMixin:
answer_mode=ANSWER_MODE_FAST_PATH, answer_mode=ANSWER_MODE_FAST_PATH,
route_decision=route_decision, route_decision=route_decision,
query_semantics=query_semantics, query_semantics=query_semantics,
internal_context_packet=internal_context_packet,
) )
internal_model_config_fast_answer = None internal_model_config_fast_answer = None
@ -709,6 +963,7 @@ class AdaptiveRAGAnswerMainlineMixin:
internal_model_config_fast_answer = await internal_model_config_builder( internal_model_config_fast_answer = await internal_model_config_builder(
question=effective_q, question=effective_q,
query_semantics=query_semantics, query_semantics=query_semantics,
internal_context_packet=internal_context_packet,
) )
if internal_model_config_fast_answer is not None: if internal_model_config_fast_answer is not None:
return await self._persist_fast_answer( return await self._persist_fast_answer(
@ -729,6 +984,7 @@ class AdaptiveRAGAnswerMainlineMixin:
answer_mode=ANSWER_MODE_FAST_PATH, answer_mode=ANSWER_MODE_FAST_PATH,
route_decision=route_decision, route_decision=route_decision,
query_semantics=query_semantics, query_semantics=query_semantics,
internal_context_packet=internal_context_packet,
) )
direct_answer = None direct_answer = None
@ -775,6 +1031,7 @@ class AdaptiveRAGAnswerMainlineMixin:
clean_q=effective_q, clean_q=effective_q,
project_id=project_id, project_id=project_id,
query_semantics=query_semantics, query_semantics=query_semantics,
internal_context_packet=internal_context_packet,
) )
if glm_semantic_result is not None: if glm_semantic_result is not None:
return await self._persist_fast_answer( return await self._persist_fast_answer(
@ -795,6 +1052,7 @@ class AdaptiveRAGAnswerMainlineMixin:
answer_mode=ANSWER_MODE_FAST_PATH, answer_mode=ANSWER_MODE_FAST_PATH,
route_decision=route_decision, route_decision=route_decision,
query_semantics=query_semantics, query_semantics=query_semantics,
internal_context_packet=internal_context_packet,
) )
return await self._build_retrieval_response( return await self._build_retrieval_response(
@ -828,4 +1086,5 @@ class AdaptiveRAGAnswerMainlineMixin:
answer_mode=planner.retrieval_answer_mode, answer_mode=planner.retrieval_answer_mode,
consultant_mode_preference=consultant_mode_preference, consultant_mode_preference=consultant_mode_preference,
route_decision=route_decision, route_decision=route_decision,
internal_context_packet=internal_context_packet,
) )

View File

@ -2,6 +2,11 @@
from __future__ import annotations from __future__ import annotations
from .shared import * from .shared import *
from .internal_query_context import (
internal_context_block_reason,
internal_context_operation_contract,
internal_context_query_plan,
)
class AdaptiveRAGAnswerPayloadMixin: class AdaptiveRAGAnswerPayloadMixin:
@ -484,12 +489,32 @@ class AdaptiveRAGAnswerPayloadMixin:
answer: str, answer: str,
sources: Optional[list[dict[str, Any]]] = None, sources: Optional[list[dict[str, Any]]] = None,
route_decision: Optional[dict[str, Any]] = None, route_decision: Optional[dict[str, Any]] = None,
internal_context_packet: Any = None,
) -> dict[str, Any]: ) -> dict[str, Any]:
source_rows = sources if isinstance(sources, list) else [] source_rows = sources if isinstance(sources, list) else []
if not self._is_internal_answer_contract_route(route_decision=route_decision, sources=source_rows): if not self._is_internal_answer_contract_route(route_decision=route_decision, sources=source_rows):
return {"ok": True, "reason_code": None, "missing_fields": []} return {"ok": True, "reason_code": None, "missing_fields": []}
normalized_query_type = str(query_type or QUERY_TYPE_UNKNOWN).strip().lower() packet_block = internal_context_block_reason(
internal_context_packet,
route_decision=route_decision,
)
if packet_block is not None:
reason_code, missing_fields = packet_block
return {
"ok": False,
"reason_code": reason_code,
"missing_fields": missing_fields,
}
operation_contract = internal_context_operation_contract(packet=internal_context_packet)
query_plan = internal_context_query_plan(packet=internal_context_packet)
normalized_query_type = str(
operation_contract.get("query_type")
or query_plan.get("query_type")
or query_type
or QUERY_TYPE_UNKNOWN
).strip().lower()
normalized_answer = str(answer or "").strip() normalized_answer = str(answer or "").strip()
compact_answer = re.sub(r"\s+", "", normalized_answer.lower()) compact_answer = re.sub(r"\s+", "", normalized_answer.lower())
@ -500,7 +525,13 @@ class AdaptiveRAGAnswerPayloadMixin:
"reason_code": "no_total_count_substitution", "reason_code": "no_total_count_substitution",
"missing_fields": ["matched_evidence", "existence_contract"], "missing_fields": ["matched_evidence", "existence_contract"],
} }
if "总数" in compact_answer and "匹配到" not in normalized_answer and "未检索到" not in normalized_answer: if (
"总数" in compact_answer
and "匹配到" not in normalized_answer
and "未检索到" not in normalized_answer
and "任务总数" not in normalized_answer
and "信号总数" not in normalized_answer
):
return { return {
"ok": False, "ok": False,
"reason_code": "no_total_count_substitution", "reason_code": "no_total_count_substitution",
@ -530,9 +561,28 @@ class AdaptiveRAGAnswerPayloadMixin:
answer = "当前内部查询结果与问题类型不一致,已阻止直接返回。你问的是“有没有”,请给我更明确的对象条件后我按存在性重新查询。" answer = "当前内部查询结果与问题类型不一致,已阻止直接返回。你问的是“有没有”,请给我更明确的对象条件后我按存在性重新查询。"
elif normalized_reason == "missing_internal_evidence": elif normalized_reason == "missing_internal_evidence":
answer = "当前内部查询证据不足,已阻止直接返回结论。请补充更明确的对象或范围后我再查。" answer = "当前内部查询证据不足,已阻止直接返回结论。请补充更明确的对象或范围后我再查。"
elif normalized_reason == "missing_internal_context_packet":
answer = "当前内部查询上下文未完成,已停止直接回答。请稍后重试,或补充更明确的对象或范围后我再查。"
elif normalized_reason == "missing_internal_operation_contract":
answer = "当前内部查询合同不完整,已停止直接回答。请补充更明确的对象或范围后我再查。"
elif normalized_reason == "target_resolution_clarify":
answer = "当前内部查询对象仍有歧义,已停止直接回答。请先明确你要查的项目、配置或对象后我再继续。"
elif normalized_reason == "missing_internal_query_plan_contract":
answer = "当前内部查询计划不完整,已停止直接回答。请补充更明确的对象或范围后我再查。"
else: else:
answer = "当前内部查询结果未通过回答合同校验,已阻止直接返回。请补充更明确的对象或范围后我再查。" answer = "当前内部查询结果未通过回答合同校验,已阻止直接返回。请补充更明确的对象或范围后我再查。"
answer_mode = ANSWER_MODE_BLOCKED
grounding_status = GROUNDING_STATUS_BLOCKED
if normalized_reason in {
"missing_internal_context_packet",
"missing_internal_operation_contract",
"target_resolution_clarify",
"missing_internal_query_plan_contract",
}:
answer_mode = ANSWER_MODE_CLARIFY
grounding_status = GROUNDING_STATUS_INSUFFICIENT_EVIDENCE
return { return {
"answer": answer, "answer": answer,
"sources": [ "sources": [
@ -549,8 +599,8 @@ class AdaptiveRAGAnswerPayloadMixin:
], ],
"missing_fields": normalized_missing or ["internal_answer_contract"], "missing_fields": normalized_missing or ["internal_answer_contract"],
"reason_code": normalized_reason, "reason_code": normalized_reason,
"answer_mode": ANSWER_MODE_BLOCKED, "answer_mode": answer_mode,
"grounding_status": GROUNDING_STATUS_BLOCKED, "grounding_status": grounding_status,
} }
def _build_response_payload( def _build_response_payload(

View File

@ -267,6 +267,18 @@ class AdaptiveRAGAnswerPrecheckMixin:
candidates=clarification_candidates, candidates=clarification_candidates,
previous_attempts=previous_attempts, previous_attempts=previous_attempts,
) )
elif clarification_code == "query_route_clarify":
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() == "query_route_selection"
else 0
)
self._store_pending_query_route_clarification(
session_state=state_row,
original_question=clean_q,
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(

View File

@ -2,6 +2,7 @@
from __future__ import annotations from __future__ import annotations
from .shared import * from .shared import *
from .internal_query_context import update_internal_query_context_packet
class AdaptiveRAGAnswerRetrievalMixin: class AdaptiveRAGAnswerRetrievalMixin:
@ -39,7 +40,11 @@ class AdaptiveRAGAnswerRetrievalMixin:
try: try:
service = McpConfigService(db, tenant_id=int(getattr(self, "tenant_id", 1) or 1)) service = McpConfigService(db, tenant_id=int(getattr(self, "tenant_id", 1) or 1))
evidence = await service.search_external(query=query, result_limit=normalized_plan.result_limit) evidence = await service.search_external(
query=query,
result_limit=normalized_plan.result_limit,
query_kind=normalized_plan.query_kind,
)
except Exception as exc: except Exception as exc:
return ExternalAdapterResult( return ExternalAdapterResult(
adapter_name="mcp", adapter_name="mcp",
@ -138,6 +143,7 @@ class AdaptiveRAGAnswerRetrievalMixin:
question=question, question=question,
query_semantics=query_semantics, query_semantics=query_semantics,
) )
context_packet = execution.to_context_packet(question=question).model_dump(mode="json")
verifier = dict(execution.verifier or {}) verifier = dict(execution.verifier or {})
if not bool(verifier.get("ok")): if not bool(verifier.get("ok")):
return [], { return [], {
@ -166,6 +172,7 @@ class AdaptiveRAGAnswerRetrievalMixin:
"db_timeout": False, "db_timeout": False,
"vector_timeout": False, "vector_timeout": False,
"graph_timeout": False, "graph_timeout": False,
"external_context_packet": context_packet,
} }
rows: list[RetrievedItem] = [] rows: list[RetrievedItem] = []
@ -221,6 +228,7 @@ class AdaptiveRAGAnswerRetrievalMixin:
"db_timeout": False, "db_timeout": False,
"vector_timeout": False, "vector_timeout": False,
"graph_timeout": False, "graph_timeout": False,
"external_context_packet": context_packet,
"tool_trace": [ "tool_trace": [
{ {
"step": 1, "step": 1,
@ -312,6 +320,7 @@ class AdaptiveRAGAnswerRetrievalMixin:
answer_mode: str = ANSWER_MODE_RETRIEVAL_NORMAL, answer_mode: str = ANSWER_MODE_RETRIEVAL_NORMAL,
consultant_mode_preference: Optional[str] = None, consultant_mode_preference: Optional[str] = None,
route_decision: Optional[dict[str, Any]] = None, route_decision: Optional[dict[str, Any]] = None,
internal_context_packet: Any = None,
) -> dict[str, Any]: ) -> dict[str, Any]:
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:
@ -344,6 +353,7 @@ class AdaptiveRAGAnswerRetrievalMixin:
project_id=project_id, project_id=project_id,
source_chat_id=source_chat_id, source_chat_id=source_chat_id,
query_semantics=query_semantics, query_semantics=query_semantics,
internal_context_packet=internal_context_packet,
session_state=state_row, session_state=state_row,
max_steps_override=deep_retrieval_steps if deep_thinking else None, max_steps_override=deep_retrieval_steps if deep_thinking else None,
vector_top_k=deep_vector_top_k if deep_thinking else None, vector_top_k=deep_vector_top_k if deep_thinking else None,
@ -379,6 +389,29 @@ class AdaptiveRAGAnswerRetrievalMixin:
) )
final_domains = [str(x) for x in (search_meta.get("domains_used") or domains)] final_domains = [str(x) for x in (search_meta.get("domains_used") or domains)]
prompt_variant_id = self._peek_answer_prompt_variant() prompt_variant_id = self._peek_answer_prompt_variant()
external_context_packet = (
dict(search_meta.get("external_context_packet") or {})
if external_route_only and isinstance(search_meta.get("external_context_packet"), dict)
else None
)
external_verifier = (
dict(external_context_packet.get("verifier") or {})
if isinstance(external_context_packet, dict)
else {}
)
external_missing_fields = [
str(item).strip()
for item in list(external_verifier.get("missing_fields") or [])
if str(item).strip()
]
external_insufficient_evidence = bool(
external_route_only
and isinstance(external_context_packet, dict)
and (
not bool(external_verifier.get("ok"))
or not list(external_context_packet.get("verified_evidence") or [])
)
)
max_score = retrieved[0].score if retrieved else 0.0 max_score = retrieved[0].score if retrieved else 0.0
active_retrieved = list(retrieved) active_retrieved = list(retrieved)
@ -400,11 +433,15 @@ class AdaptiveRAGAnswerRetrievalMixin:
consultant_mode_preference=consultant_mode_preference, consultant_mode_preference=consultant_mode_preference,
scene_semantics=scene_semantics, scene_semantics=scene_semantics,
query_semantics=query_semantics, query_semantics=query_semantics,
external_context_packet=external_context_packet,
) )
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)
if not used_sources: if external_insufficient_evidence:
answer_mode = ANSWER_MODE_CLARIFY
max_score = min(float(max_score or 0.0), 0.4)
if not used_sources and not external_insufficient_evidence:
used_sources = [ used_sources = [
{ {
"source": "system", "source": "system",
@ -438,6 +475,7 @@ class AdaptiveRAGAnswerRetrievalMixin:
project_id=project_id, project_id=project_id,
source_chat_id=source_chat_id, source_chat_id=source_chat_id,
query_semantics=query_semantics, query_semantics=query_semantics,
internal_context_packet=internal_context_packet,
session_state=state_row, session_state=state_row,
max_steps_override=deep_retrieval_steps, max_steps_override=deep_retrieval_steps,
vector_top_k=deep_vector_top_k, vector_top_k=deep_vector_top_k,
@ -530,7 +568,7 @@ class AdaptiveRAGAnswerRetrievalMixin:
routing_snapshot = self._peek_last_answer_routing_snapshot() routing_snapshot = self._peek_last_answer_routing_snapshot()
evidence_guard_triggered = False evidence_guard_triggered = False
clarification_due_to_evidence = False clarification_due_to_evidence = bool(external_insufficient_evidence)
freshness_guard_triggered = False freshness_guard_triggered = False
if self._requires_external_freshness(question=clean_q, scene_semantics=scene_semantics) and not self._has_external_grounding_sources(used_sources): if self._requires_external_freshness(question=clean_q, scene_semantics=scene_semantics) and not self._has_external_grounding_sources(used_sources):
live_rows, live_meta = await self._maybe_fetch_live_external_freshness_context( live_rows, live_meta = await self._maybe_fetch_live_external_freshness_context(
@ -564,6 +602,26 @@ class AdaptiveRAGAnswerRetrievalMixin:
consultant_mode_preference=consultant_mode_preference, consultant_mode_preference=consultant_mode_preference,
scene_semantics=scene_semantics, scene_semantics=scene_semantics,
query_semantics=query_semantics, query_semantics=query_semantics,
external_context_packet=(
dict(live_meta.get("external_context_packet") or {})
if isinstance(live_meta.get("external_context_packet"), dict)
else external_context_packet
),
)
if isinstance(live_meta.get("external_context_packet"), dict):
external_context_packet = dict(live_meta.get("external_context_packet") or {})
external_verifier = dict(external_context_packet.get("verifier") or {})
external_missing_fields = [
str(item).strip()
for item in list(external_verifier.get("missing_fields") or [])
if str(item).strip()
]
external_insufficient_evidence = bool(
external_route_only
and (
not bool(external_verifier.get("ok"))
or not list(external_context_packet.get("verified_evidence") or [])
)
) )
used_sources = self._dedupe_sources(active_retrieved) used_sources = self._dedupe_sources(active_retrieved)
if "url" not in final_domains: if "url" not in final_domains:
@ -612,7 +670,13 @@ class AdaptiveRAGAnswerRetrievalMixin:
} }
) )
answer_mode = ANSWER_MODE_CLARIFY answer_mode = ANSWER_MODE_CLARIFY
if not freshness_guard_triggered and not evidence_guard_triggered and self._is_snapshot_only_sources(used_sources) and float(max_score or 0.0) < 0.45: if (
not external_insufficient_evidence
and not freshness_guard_triggered
and not evidence_guard_triggered
and self._is_snapshot_only_sources(used_sources)
and float(max_score or 0.0) < 0.45
):
clarification_due_to_evidence = True clarification_due_to_evidence = True
answer_text = ( answer_text = (
f"当前关于“{clean_q[:80]}”的证据不足,现阶段只命中系统快照。" f"当前关于“{clean_q[:80]}”的证据不足,现阶段只命中系统快照。"
@ -684,7 +748,11 @@ class AdaptiveRAGAnswerRetrievalMixin:
elif clarification_due_to_evidence: elif clarification_due_to_evidence:
grounding_verdict = GroundingVerdict( grounding_verdict = GroundingVerdict(
status=GROUNDING_STATUS_INSUFFICIENT_EVIDENCE, status=GROUNDING_STATUS_INSUFFICIENT_EVIDENCE,
missing_fields=["external_freshness_evidence"] if freshness_guard_triggered else ["high_relevance_evidence"], missing_fields=(
["external_freshness_evidence"]
if freshness_guard_triggered
else (external_missing_fields or ["high_relevance_evidence"])
),
reason_code="insufficient_evidence", reason_code="insufficient_evidence",
) )
elif self._is_snapshot_only_sources(used_sources) or float(max_score or 0.0) < 0.45: elif self._is_snapshot_only_sources(used_sources) or float(max_score or 0.0) < 0.45:
@ -735,6 +803,7 @@ class AdaptiveRAGAnswerRetrievalMixin:
answer=answer_text, answer=answer_text,
sources=used_sources, sources=used_sources,
route_decision=normalized_route_decision, route_decision=normalized_route_decision,
internal_context_packet=internal_context_packet,
) )
if not bool(contract_verdict.get("ok")): if not bool(contract_verdict.get("ok")):
guard_payload = self._build_internal_answer_contract_guard_payload( guard_payload = self._build_internal_answer_contract_guard_payload(
@ -782,11 +851,41 @@ class AdaptiveRAGAnswerRetrievalMixin:
) )
), ),
) )
internal_context_packet = update_internal_query_context_packet(
internal_context_packet,
executor_summary={
"answer_mode": str(answer_mode or "").strip() or ANSWER_MODE_RETRIEVAL_NORMAL,
"retrieval_domains": list(final_domains or []),
"db_query_ms": int(db_query_ms or 0),
"vector_search_ms": int(vector_search_ms or 0),
"graph_query_ms": int(graph_query_ms or 0),
},
verifier_summary={
"ok": bool(contract_verdict.get("ok")),
"reason_code": str(contract_verdict.get("reason_code") or "").strip() or None,
"missing_fields": [
str(item).strip()
for item in list(contract_verdict.get("missing_fields") or [])
if str(item).strip()
],
},
uncertainties=[
str(grounding_verdict.reason_code or "").strip()
if str(grounding_verdict.reason_code or "").strip()
else "",
*[
str(item).strip()
for item in list(grounding_verdict.missing_fields or [])
if str(item).strip()
],
],
)
inject_runtime_summaries = getattr(self, "_inject_internal_query_runtime_summaries", None) inject_runtime_summaries = getattr(self, "_inject_internal_query_runtime_summaries", None)
if callable(inject_runtime_summaries): if callable(inject_runtime_summaries):
retrieval_metrics = inject_runtime_summaries( retrieval_metrics = inject_runtime_summaries(
retrieval_metrics=retrieval_metrics, retrieval_metrics=retrieval_metrics,
query_semantics=query_semantics, query_semantics=query_semantics,
internal_context_packet=internal_context_packet,
) )
runtime_fields = self._build_qa_record_runtime_fields( runtime_fields = self._build_qa_record_runtime_fields(
answer=answer_text, answer=answer_text,
@ -823,7 +922,7 @@ class AdaptiveRAGAnswerRetrievalMixin:
) )
self.db.add(qa) self.db.add(qa)
confidence = round(max(0.2, min(1.0, max_score if max_score > 0 else 0.55)), 4) confidence = round(max(0.2, min(1.0, max_score if max_score > 0 else 0.55)), 4)
if evidence_guard_triggered: if evidence_guard_triggered or answer_mode == ANSWER_MODE_CLARIFY:
confidence = min(confidence, 0.35) confidence = min(confidence, 0.35)
missing_fields = list(grounding_verdict.missing_fields) missing_fields = list(grounding_verdict.missing_fields)
await self._update_session_state_after_turn( await self._update_session_state_after_turn(

View File

@ -32,6 +32,63 @@ 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._query_semantics_runtime_cache: dict[str, dict[str, Any]] = {}
@staticmethod
def _normalize_query_semantics_cache_key_payload(payload: Any) -> Any:
if isinstance(payload, dict):
return {
str(key): AdaptiveRAGCoreIntentMixin._normalize_query_semantics_cache_key_payload(value)
for key, value in sorted(payload.items(), key=lambda item: str(item[0]))
}
if isinstance(payload, list):
return [
AdaptiveRAGCoreIntentMixin._normalize_query_semantics_cache_key_payload(item)
for item in payload
]
if payload is None or isinstance(payload, (str, int, float, bool)):
return payload
return str(payload)
def _build_query_semantics_cache_key(
self,
*,
message: str,
source_system: str,
metadata: Optional[dict[str, Any]],
source_policy_hint: Optional[str],
) -> str:
return json.dumps(
self._normalize_query_semantics_cache_key_payload(
{
"message": str(message or "").strip(),
"source_system": str(source_system or "").strip().lower(),
"metadata": dict(metadata or {}) if isinstance(metadata, dict) else {},
"source_policy_hint": str(source_policy_hint or "").strip().lower() or None,
}
),
ensure_ascii=False,
sort_keys=True,
)
def _get_cached_query_semantics(self, cache_key: str) -> Optional[dict[str, Any]]:
cache = getattr(self, "_query_semantics_runtime_cache", None)
if not isinstance(cache, dict):
cache = {}
self._query_semantics_runtime_cache = cache
cached = cache.get(cache_key)
return dict(cached) if isinstance(cached, dict) else None
def _store_cached_query_semantics(self, cache_key: str, payload: dict[str, Any]) -> dict[str, Any]:
cache = getattr(self, "_query_semantics_runtime_cache", None)
if not isinstance(cache, dict):
cache = {}
self._query_semantics_runtime_cache = cache
cache[cache_key] = dict(payload)
while len(cache) > 16:
oldest_key = next(iter(cache))
cache.pop(oldest_key, None)
return dict(payload)
@staticmethod @staticmethod
def _normalize_thinking_mode(value: Optional[str]) -> str: def _normalize_thinking_mode(value: Optional[str]) -> str:
@ -467,9 +524,18 @@ class AdaptiveRAGCoreIntentMixin:
source_policy_hint: Optional[str] = None, source_policy_hint: Optional[str] = None,
) -> dict[str, Any]: ) -> dict[str, Any]:
text = str(message or "").strip() text = str(message or "").strip()
cache_key = self._build_query_semantics_cache_key(
message=text,
source_system=source_system,
metadata=metadata,
source_policy_hint=source_policy_hint,
)
cached = self._get_cached_query_semantics(cache_key)
if cached is not None:
return cached
default_domains = list(self._SEMANTIC_RETRIEVAL_DOMAINS) default_domains = list(self._SEMANTIC_RETRIEVAL_DOMAINS)
if not text: if not text:
return { return self._store_cached_query_semantics(cache_key, {
"intent": "statement", "intent": "statement",
"message_intent": "statement", "message_intent": "statement",
"question_part": "", "question_part": "",
@ -484,7 +550,7 @@ class AdaptiveRAGCoreIntentMixin:
"fallback_taken": False, "fallback_taken": False,
"query_profile": self._normalize_query_profile(None), "query_profile": self._normalize_query_profile(None),
"confidence": 1.0, "confidence": 1.0,
} })
bundle = None bundle = None
planner_message_intent = "" planner_message_intent = ""
@ -592,7 +658,7 @@ class AdaptiveRAGCoreIntentMixin:
confidence = self._safe_float(parsed.get("confidence"), planner_confidence) confidence = self._safe_float(parsed.get("confidence"), planner_confidence)
normalized_message_intent = planner_message_intent or intent normalized_message_intent = planner_message_intent or intent
return { return self._store_cached_query_semantics(cache_key, {
"intent": intent, "intent": intent,
"message_intent": normalized_message_intent, "message_intent": normalized_message_intent,
"question_part": question_part, "question_part": question_part,
@ -610,7 +676,7 @@ class AdaptiveRAGCoreIntentMixin:
"decision_origin": decision_origin, "decision_origin": decision_origin,
"fallback_taken": fallback_taken, "fallback_taken": fallback_taken,
"confidence": max(0.0, min(1.0, confidence)), "confidence": max(0.0, min(1.0, confidence)),
} })
async def classify_intent(self, message: str) -> dict[str, Any]: async def classify_intent(self, message: str) -> dict[str, Any]:
semantics = await self._resolve_query_semantics( semantics = await self._resolve_query_semantics(

View File

@ -3,6 +3,12 @@ from __future__ import annotations
from ...config.ai_prompts import PROMPT_PROJECT_STATUS_QA from ...config.ai_prompts import PROMPT_PROJECT_STATUS_QA
from ..prompt_release import PromptReleaseService from ..prompt_release import PromptReleaseService
from .internal_query_context import (
internal_context_operation_contract,
internal_context_route_decision,
internal_context_semantic_mirror,
internal_context_target_resolution,
)
from .shared import * from .shared import *
@ -120,15 +126,31 @@ class AdaptiveRAGDeepGuardAnswerMixin:
*, *,
question: str, question: str,
query_semantics: Optional[dict[str, Any]] = None, query_semantics: Optional[dict[str, Any]] = None,
internal_context_packet: Any = None,
) -> Optional[dict[str, Any]]: ) -> Optional[dict[str, Any]]:
_ = question _ = question
semantics = query_semantics if isinstance(query_semantics, dict) else {} semantics = query_semantics if isinstance(query_semantics, dict) else {}
route_decision = internal_context_route_decision(
packet=internal_context_packet,
fallback=semantics,
)
source_policy = str( source_policy = str(
semantics.get("effective_source_policy") or semantics.get("source_policy") or "" route_decision.get("effective_source_policy")
or route_decision.get("source_policy")
or semantics.get("effective_source_policy")
or semantics.get("source_policy")
or ""
).strip().lower() ).strip().lower()
if source_policy and source_policy != "internal": if source_policy and source_policy != "internal":
return None return None
operation_contract = semantics.get("operation_contract") if isinstance(semantics.get("operation_contract"), dict) else {} operation_contract = internal_context_operation_contract(
packet=internal_context_packet,
fallback=internal_context_semantic_mirror(
packet=internal_context_packet,
query_semantics=semantics,
key="operation_contract",
),
)
query_type = str( query_type = str(
operation_contract.get("query_type") operation_contract.get("query_type")
or semantics.get("query_type") or semantics.get("query_type")
@ -136,7 +158,15 @@ 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
query_profile = semantics.get("query_profile") if isinstance(semantics.get("query_profile"), dict) else {} query_profile = (
route_decision.get("query_profile")
if isinstance(route_decision.get("query_profile"), dict)
else (
semantics.get("query_profile")
if isinstance(semantics.get("query_profile"), dict)
else {}
)
)
referenced_objects = { referenced_objects = {
str(item).strip().lower() str(item).strip().lower()
for item in list(query_profile.get("referenced_objects") or []) for item in list(query_profile.get("referenced_objects") or [])
@ -144,10 +174,13 @@ class AdaptiveRAGDeepGuardAnswerMixin:
} }
if not referenced_objects.intersection({"system_config", "model_routing"}): if not referenced_objects.intersection({"system_config", "model_routing"}):
return None return None
target_resolution = ( target_resolution = internal_context_target_resolution(
semantics.get("target_resolution") packet=internal_context_packet,
if isinstance(semantics.get("target_resolution"), dict) fallback=internal_context_semantic_mirror(
else {} packet=internal_context_packet,
query_semantics=semantics,
key="target_resolution",
),
) )
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":
@ -1119,6 +1152,27 @@ class AdaptiveRAGDeepGuardAnswerMixin:
detail_bits.append(f",{status_text}。") detail_bits.append(f",{status_text}。")
return "".join(detail_bits) return "".join(detail_bits)
def _should_use_external_answer_presenter(
self,
*,
query_semantics: Optional[dict[str, Any]],
external_context_packet: Optional[dict[str, Any]],
) -> bool:
semantics = query_semantics if isinstance(query_semantics, dict) else {}
effective_source_policy = str(
semantics.get("effective_source_policy") or semantics.get("source_policy") or ""
).strip().lower()
return effective_source_policy == "external" and isinstance(external_context_packet, dict) and bool(external_context_packet)
def _build_external_answer_presenter(self):
from ..external_query_service import ExternalAnswerPresenter
return ExternalAnswerPresenter(
llm_service=self.llm,
db=self.db,
tenant_id=self.tenant_id,
)
async def _generate_answer( async def _generate_answer(
self, self,
question: str, question: str,
@ -1139,9 +1193,25 @@ class AdaptiveRAGDeepGuardAnswerMixin:
consultant_mode_preference: Optional[str] = None, consultant_mode_preference: Optional[str] = None,
scene_semantics: Optional[dict[str, Any]] = None, scene_semantics: Optional[dict[str, Any]] = None,
query_semantics: Optional[dict[str, Any]] = None, query_semantics: Optional[dict[str, Any]] = None,
external_context_packet: Optional[dict[str, Any]] = None,
) -> 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
if self._should_use_external_answer_presenter(
query_semantics=query_semantics,
external_context_packet=external_context_packet,
):
presenter = self._build_external_answer_presenter()
presentation = await presenter.present(
question=question,
context_packet=dict(external_context_packet or {}),
thinking_mode=active_thinking_mode,
answer_max_tokens=answer_max_tokens,
)
remember_prompt_variant = getattr(self, "_remember_answer_prompt_variant", None)
if callable(remember_prompt_variant):
remember_prompt_variant(str(presentation.get("prompt_variant_id") or "external_unknown"))
return str(presentation.get("answer_text") or "").strip()[:3200]
context_blocks: list[str] = [] context_blocks: list[str] = []
used_tokens = 0 used_tokens = 0
max_blocks = max(4, min(int(context_block_limit or DEFAULT_TOP_K * 2), 32)) max_blocks = max(4, min(int(context_block_limit or DEFAULT_TOP_K * 2), 32))

View File

@ -13,22 +13,6 @@ class AdaptiveRAGDeepGuardRulesMixin:
"runtime_state_query", "runtime_state_query",
} }
_EXTERNAL_FRESHNESS_SCENES = {"industry_trend", "external_trend"} _EXTERNAL_FRESHNESS_SCENES = {"industry_trend", "external_trend"}
_EXTERNAL_FACT_FRESHNESS_MARKERS = (
"最新",
"目前",
"当前",
"截至现在",
"截至当前",
"截止当前",
"今天",
"现在",
"最近",
"出到什么",
"发布了吗",
"发布到",
"版本",
"最高模型",
)
@classmethod @classmethod
def _normalize_deep_intent(cls, value: Any) -> str: def _normalize_deep_intent(cls, value: Any) -> str:
@ -147,16 +131,10 @@ class AdaptiveRAGDeepGuardRulesMixin:
question: str, question: str,
scene_semantics: Optional[dict[str, Any]], scene_semantics: Optional[dict[str, Any]],
) -> bool: ) -> bool:
_ = question
semantics = scene_semantics if isinstance(scene_semantics, dict) else {} semantics = scene_semantics if isinstance(scene_semantics, dict) else {}
if bool(semantics.get("requires_freshness")):
return True
scene_key = str(semantics.get("scene_key") or "").strip().lower() scene_key = str(semantics.get("scene_key") or "").strip().lower()
if scene_key not in cls._EXTERNAL_FRESHNESS_SCENES: return bool(semantics.get("requires_freshness")) or scene_key in cls._EXTERNAL_FRESHNESS_SCENES
return False
normalized = re.sub(r"\s+", "", str(question or "")).strip().lower()
if not normalized:
return False
return any(marker in normalized for marker in cls._EXTERNAL_FACT_FRESHNESS_MARKERS)
@classmethod @classmethod
def _has_external_grounding_sources(cls, sources: list[dict[str, Any]]) -> bool: def _has_external_grounding_sources(cls, sources: list[dict[str, Any]]) -> bool:

View File

@ -2,9 +2,30 @@
from __future__ import annotations from __future__ import annotations
from .shared import * from .shared import *
from .internal_query_context import (
internal_context_operation_contract,
internal_context_semantic_mirror,
internal_context_target_resolution,
)
class AdaptiveRAGDeterministicQueryCoreMixin: class AdaptiveRAGDeterministicQueryCoreMixin:
@staticmethod
def _should_defer_project_existence_to_semantic_snapshot(
*,
query_type: str,
query_semantics: Optional[dict[str, Any]] = None,
) -> bool:
semantics = query_semantics if isinstance(query_semantics, dict) else {}
normalized_query_type = str(query_type or QUERY_TYPE_UNKNOWN).strip().lower()
if normalized_query_type not in {QUERY_TYPE_EXISTENCE, QUERY_TYPE_STATUS}:
return False
subject_focus = str(semantics.get("subject_focus") or "").strip().lower()
source_policy = str(
semantics.get("effective_source_policy") or semantics.get("source_policy") or ""
).strip().lower()
return subject_focus == "project" and source_policy == "internal"
async def _run_deterministic_query( async def _run_deterministic_query(
self, self,
*, *,
@ -12,25 +33,45 @@ class AdaptiveRAGDeterministicQueryCoreMixin:
project_id: Optional[int], project_id: Optional[int],
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,
) -> Optional[dict[str, Any]]: ) -> Optional[dict[str, Any]]:
q_type = str(query_type or QUERY_TYPE_UNKNOWN) operation_contract = internal_context_operation_contract(
packet=internal_context_packet,
fallback=internal_context_semantic_mirror(
packet=internal_context_packet,
query_semantics=query_semantics,
key="operation_contract",
),
)
q_type = str(
operation_contract.get("query_type")
or query_type
or QUERY_TYPE_UNKNOWN
).strip().lower() or QUERY_TYPE_UNKNOWN
defer_project_existence = self._should_defer_project_existence_to_semantic_snapshot(
query_type=q_type,
query_semantics=query_semantics,
)
if q_type == QUERY_TYPE_COUNT: if q_type == QUERY_TYPE_COUNT:
return await self._run_count_query( return await self._run_count_query(
question=question, question=question,
project_id=project_id, project_id=project_id,
query_semantics=query_semantics, query_semantics=query_semantics,
internal_context_packet=internal_context_packet,
) )
if q_type == QUERY_TYPE_LIST: if q_type == QUERY_TYPE_LIST:
return await self._run_list_query( return await self._run_list_query(
question=question, question=question,
project_id=project_id, project_id=project_id,
query_semantics=query_semantics, query_semantics=query_semantics,
internal_context_packet=internal_context_packet,
) )
if q_type == QUERY_TYPE_EXISTENCE: if q_type == QUERY_TYPE_EXISTENCE and not defer_project_existence:
existence = await self._try_fast_project_existence_answer( existence = await self._try_fast_project_existence_answer(
clean_q=question, clean_q=question,
project_id=project_id, project_id=project_id,
query_semantics=query_semantics, query_semantics=query_semantics,
internal_context_packet=internal_context_packet,
) )
if existence: if existence:
existence["query_type"] = QUERY_TYPE_EXISTENCE existence["query_type"] = QUERY_TYPE_EXISTENCE
@ -42,6 +83,7 @@ class AdaptiveRAGDeterministicQueryCoreMixin:
clean_q=question, clean_q=question,
project_id=project_id, project_id=project_id,
query_semantics=query_semantics, query_semantics=query_semantics,
internal_context_packet=internal_context_packet,
) )
if created_at: if created_at:
created_at["query_type"] = QUERY_TYPE_STATUS created_at["query_type"] = QUERY_TYPE_STATUS
@ -52,16 +94,19 @@ class AdaptiveRAGDeterministicQueryCoreMixin:
clean_q=question, clean_q=question,
project_id=project_id, project_id=project_id,
query_semantics=query_semantics, query_semantics=query_semantics,
internal_context_packet=internal_context_packet,
) )
if progress: if progress:
progress["query_type"] = QUERY_TYPE_STATUS progress["query_type"] = QUERY_TYPE_STATUS
progress.setdefault("confidence", 0.98) progress.setdefault("confidence", 0.98)
progress.setdefault("missing_fields", []) progress.setdefault("missing_fields", [])
return progress return progress
if not defer_project_existence:
existence = await self._try_fast_project_existence_answer( existence = await self._try_fast_project_existence_answer(
clean_q=question, clean_q=question,
project_id=project_id, project_id=project_id,
query_semantics=query_semantics, query_semantics=query_semantics,
internal_context_packet=internal_context_packet,
) )
if existence: if existence:
existence["query_type"] = QUERY_TYPE_STATUS existence["query_type"] = QUERY_TYPE_STATUS
@ -76,6 +121,7 @@ class AdaptiveRAGDeterministicQueryCoreMixin:
question: str, question: str,
project_id: Optional[int], project_id: Optional[int],
query_semantics: Optional[dict[str, Any]] = None, query_semantics: Optional[dict[str, Any]] = None,
internal_context_packet: Any = None,
) -> tuple[Optional[int], Optional[str], str]: ) -> tuple[Optional[int], Optional[str], str]:
if project_id is not None: if project_id is not None:
row = ( row = (
@ -93,7 +139,14 @@ class AdaptiveRAGDeterministicQueryCoreMixin:
return (int(row.id), str(row.title or row.name or f"项目#{int(row.id)}"), "") return (int(row.id), str(row.title or row.name or f"项目#{int(row.id)}"), "")
semantics = query_semantics if isinstance(query_semantics, dict) else {} semantics = query_semantics if isinstance(query_semantics, dict) else {}
target_resolution = semantics.get("target_resolution") if isinstance(semantics.get("target_resolution"), dict) else {} target_resolution = internal_context_target_resolution(
packet=internal_context_packet,
fallback=internal_context_semantic_mirror(
packet=internal_context_packet,
query_semantics=semantics,
key="target_resolution",
),
)
target_ref = str(target_resolution.get("target_ref") or "").strip() target_ref = str(target_resolution.get("target_ref") or "").strip()
if target_ref.startswith("project:"): if target_ref.startswith("project:"):
try: try:

View File

@ -11,6 +11,7 @@ class AdaptiveRAGDeterministicQueryCountMixin:
question: str, question: str,
project_id: Optional[int], project_id: Optional[int],
query_semantics: Optional[dict[str, Any]] = None, query_semantics: Optional[dict[str, Any]] = None,
internal_context_packet: Any = None,
) -> 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()
@ -23,6 +24,7 @@ class AdaptiveRAGDeterministicQueryCountMixin:
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,
internal_context_packet=internal_context_packet,
) )
if project_scope_required and scoped_project_id is None and scoped_keyword: if project_scope_required and scoped_project_id is None and scoped_keyword:
elapsed = int((time.perf_counter() - started) * 1000) elapsed = int((time.perf_counter() - started) * 1000)
@ -78,6 +80,7 @@ class AdaptiveRAGDeterministicQueryCountMixin:
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,
internal_context_packet=internal_context_packet,
) )
if project_scope_required and scoped_project_id is None and scoped_keyword: if project_scope_required and scoped_project_id is None and scoped_keyword:
elapsed = int((time.perf_counter() - started) * 1000) elapsed = int((time.perf_counter() - started) * 1000)
@ -137,6 +140,7 @@ class AdaptiveRAGDeterministicQueryCountMixin:
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,
internal_context_packet=internal_context_packet,
) )
if project_scope_required and scoped_project_id is None and scoped_keyword: if project_scope_required and scoped_project_id is None and scoped_keyword:
elapsed = int((time.perf_counter() - started) * 1000) elapsed = int((time.perf_counter() - started) * 1000)

View File

@ -11,6 +11,7 @@ class AdaptiveRAGDeterministicQueryListMixin:
question: str, question: str,
project_id: Optional[int], project_id: Optional[int],
query_semantics: Optional[dict[str, Any]] = None, query_semantics: Optional[dict[str, Any]] = None,
internal_context_packet: Any = None,
) -> 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()
@ -26,6 +27,7 @@ class AdaptiveRAGDeterministicQueryListMixin:
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,
internal_context_packet=internal_context_packet,
) )
if project_scope_required and scoped_project_id is None and scoped_keyword: if project_scope_required and scoped_project_id is None and scoped_keyword:
elapsed = int((time.perf_counter() - started) * 1000) elapsed = int((time.perf_counter() - started) * 1000)
@ -160,6 +162,7 @@ class AdaptiveRAGDeterministicQueryListMixin:
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,
internal_context_packet=internal_context_packet,
) )
if project_scope_required and scoped_project_id is None and scoped_keyword: if project_scope_required and scoped_project_id is None and scoped_keyword:
elapsed = int((time.perf_counter() - started) * 1000) elapsed = int((time.perf_counter() - started) * 1000)

View File

@ -1,15 +1,508 @@
"""Adaptive RAG mixin module.""" """Adaptive RAG mixin module."""
from __future__ import annotations from __future__ import annotations
import html
from ...config.ai_prompts import PROMPT_PROJECT_WEEKLY_REPORT_JSON
from ..prompt_release import PromptReleaseService
from .shared import * from .shared import *
from .internal_query_context import (
internal_context_operation_contract,
internal_context_semantic_mirror,
internal_context_target_resolution,
)
class AdaptiveRAGFastAnswerProjectMainMixin: class AdaptiveRAGFastAnswerProjectMainMixin:
PROJECT_CREATED_AT_RE = re.compile( _DONE_TASK_STATUSES = {"done", "completed", "已完成", "完成", "closed", "关闭"}
r"(什么时候.*创建|何时.*创建|创建时间|立项时间|成立时间|创建于|何时立项|什么时候立项)", _DOING_TASK_STATUSES = {"in_progress", "doing", "进行中", "开发中", "处理中", "processing"}
re.IGNORECASE, _PENDING_TASK_STATUSES = {"todo", "pending", "待处理", "未开始", "open"}
_PENDING_SIGNAL_STATUSES = {"pending", "todo", "open", "待处理", "待确认"}
_REPORT_ACTION_SLOTS = ("this_week", "owner_decision", "protect_priority")
@staticmethod
def _normalize_project_status_focus(query_semantics: Optional[dict[str, Any]] = None) -> str:
semantics = query_semantics if isinstance(query_semantics, dict) else {}
focus = str(semantics.get("status_focus") or semantics.get("status_scene") or "").strip().lower()
if focus in {"created_at", "progress", "snapshot", "risk", "risk_or_blocker"}:
return focus
return ""
@staticmethod
def _is_project_status_subject(query_semantics: Optional[dict[str, Any]] = None) -> bool:
semantics = query_semantics if isinstance(query_semantics, dict) else {}
subject_focus = str(semantics.get("subject_focus") or "").strip().lower()
target_type = str(semantics.get("target_entity_type") or "").strip().lower()
return subject_focus in {"project", "system"} or target_type == "project"
@staticmethod
def _format_project_report_time(value: Optional[datetime]) -> str:
if value is None:
return "未记录"
normalized = value
if normalized.tzinfo is not None:
normalized = normalized.astimezone(CN_TZ)
return normalized.strftime("%Y-%m-%d %H:%M")
@staticmethod
def _truncate_project_report_text(value: Any, *, limit: int = 90) -> str:
text = html.unescape(re.sub(r"\s+", " ", str(value or ""))).strip()
if not text:
return ""
if len(text) <= int(limit):
return text
return text[: max(8, int(limit) - 1)].rstrip() + "…"
@staticmethod
def _is_human_project_report_text(value: Any) -> bool:
text = str(value or "").strip()
if not text:
return False
if re.search(r"\b[a-z_]{2,}\s*=", text):
return False
if re.search(r"\b(?:true|false|null)\b", text, re.IGNORECASE):
return False
return True
async def _build_project_progress_snapshot(
self,
*,
target_project_id: int,
) -> tuple[Optional[dict[str, Any]], list[dict[str, Any]]]:
target = (
await self.db.execute(
select(Project).where(
and_(
Project.tenant_id == self.tenant_id,
Project.id == int(target_project_id),
) )
PROJECT_RISK_BLOCKER_RE = re.compile(r"(风险|阻塞|卡点|瓶颈|问题)", re.IGNORECASE) )
)
).scalar_one_or_none()
if target is None:
return None, []
owner_name: Optional[str] = None
if getattr(target, "owner_id", None) is not None:
owner_row = (
await self.db.execute(
select(User.name).where(
and_(
User.tenant_id == self.tenant_id,
User.id == int(target.owner_id),
)
)
)
).scalar_one_or_none()
owner_name = str(getattr(owner_row, "name", owner_row) or "").strip() or None
task_rows = (
await self.db.execute(
select(Task).where(
and_(
Task.tenant_id == self.tenant_id,
Task.project_id == int(target.id),
)
)
)
).scalars().all()
signal_rows = (
await self.db.execute(
select(
Signal.status,
Signal.processed,
Signal.planner_status,
).where(
and_(
Signal.tenant_id == self.tenant_id,
Signal.matched_project_id == int(target.id),
)
)
)
).all()
done = 0
doing = 0
pending = 0
latest_task_updated_at: Optional[datetime] = None
for row in task_rows:
status = str(getattr(row, "status", "") or "").strip().lower()
if status in self._DONE_TASK_STATUSES:
done += 1
elif status in self._DOING_TASK_STATUSES:
doing += 1
elif status in self._PENDING_TASK_STATUSES:
pending += 1
else:
pending += 1
row_time = getattr(row, "updated_at", None) or getattr(row, "created_at", None)
if row_time and (latest_task_updated_at is None or row_time > latest_task_updated_at):
latest_task_updated_at = row_time
signal_total = 0
pending_signal_count = 0
for status, processed, planner_status in signal_rows:
signal_total += 1
normalized_status = str(status or "").strip().lower()
normalized_planner_status = str(planner_status or "").strip().lower()
is_pending = (not bool(processed)) or (
normalized_status in self._PENDING_SIGNAL_STATUSES
) or normalized_planner_status == "pending"
if is_pending:
pending_signal_count += 1
latest_update = getattr(target, "updated_at", None) or latest_task_updated_at
snapshot = {
"project_id": int(target.id),
"project_name": str(target.title or target.name or f"项目#{int(target.id)}").strip(),
"status": str(target.status or "unknown").strip() or "unknown",
"owner_name": owner_name,
"latest_update": latest_update,
"task_total": len(task_rows),
"done_count": done,
"doing_count": doing,
"pending_count": pending,
"signal_total": signal_total,
"pending_signal_count": pending_signal_count,
"description": str(getattr(target, "description", "") or "").strip(),
}
sources = [
{
"source": "db",
"label": "Project database",
"score": 0.99,
"metadata": {
"table": "projects",
"project_id": int(target.id),
"owner_name": owner_name,
"latest_update": latest_update.isoformat() if latest_update is not None else None,
},
},
{
"source": "db",
"label": "Project database",
"score": 0.98,
"metadata": {
"table": "tasks",
"project_id": int(target.id),
"task_total": len(task_rows),
"done_count": done,
"doing_count": doing,
"pending_count": pending,
"latest_task_updated_at": (
latest_task_updated_at.isoformat() if latest_task_updated_at is not None else None
),
},
},
{
"source": "db",
"label": "Project database",
"score": 0.97,
"metadata": {
"table": "signals",
"project_id": int(target.id),
"signal_total": signal_total,
"pending_signal_count": pending_signal_count,
},
},
]
return snapshot, sources
@staticmethod
def _build_project_internal_findings(
snapshot: dict[str, Any],
*,
asks_risk_or_blockers: bool,
) -> dict[str, Any]:
task_total = int(snapshot.get("task_total") or 0)
done_count = int(snapshot.get("done_count") or 0)
doing_count = int(snapshot.get("doing_count") or 0)
pending_count = int(snapshot.get("pending_count") or 0)
pending_signal_count = int(snapshot.get("pending_signal_count") or 0)
completion_rate = round((done_count / task_total) * 100, 1) if task_total > 0 else 0.0
return {
"asks_risk_or_blockers": bool(asks_risk_or_blockers),
"has_execution_momentum": doing_count > 0,
"has_task_backlog": pending_count > 0,
"has_signal_backlog": pending_signal_count > 0,
"missing_task_breakdown": task_total == 0,
"completion_rate": completion_rate,
}
async def _build_project_external_updates(
self,
*,
question: str,
snapshot: dict[str, Any],
query_semantics: Optional[dict[str, Any]] = None,
) -> tuple[list[RetrievedItem], dict[str, Any]]:
execute_gateway = getattr(self, "_execute_external_query_gateway", None)
if not callable(execute_gateway):
return [], {"mode": "external_query_unavailable"}
project_name = str(snapshot.get("project_name") or "").strip()
description = self._truncate_project_report_text(snapshot.get("description"), limit=120)
external_question = (
f"围绕项目《{project_name}》,检索最近外部变化,重点关注项目所在领域的政策、行业动态、上游接口规则、竞品动作。"
)
if description:
external_question += f" 项目描述:{description}"
merged_semantics = dict(query_semantics or {})
merged_semantics["source_policy"] = "external"
try:
rows, meta = await execute_gateway(
question=external_question,
query_semantics=merged_semantics,
result_limit=2,
max_pages=2,
)
except Exception as exc:
return [], {"mode": "external_query_error", "error": str(exc)}
return list(rows or [])[:2], dict(meta or {})
def _build_project_weekly_report_fallback(
self,
*,
snapshot: dict[str, Any],
asks_risk_or_blockers: bool,
has_external_updates: bool,
) -> dict[str, Any]:
task_total = int(snapshot.get("task_total") or 0)
done_count = int(snapshot.get("done_count") or 0)
doing_count = int(snapshot.get("doing_count") or 0)
pending_count = int(snapshot.get("pending_count") or 0)
pending_signal_count = int(snapshot.get("pending_signal_count") or 0)
if task_total <= 0:
summary = "项目已经立起来了,但还没有进入可验证的执行期。"
stage_judgement = "立项后待拆解阶段"
evidence_summary = "当前还没有任务拆解记录,项目状态只能确认到立项已存在。"
core_gap = "项目还没有形成任务拆解和执行节奏。"
action_this_week = "先补出首轮任务拆解,至少明确第一版要交付的 3 个动作。"
action_owner = "明确这轮任务拆解的推进人和验收口径,避免继续停留在项目标题层。"
action_protect = "先处理最影响范围判断的待处理信号,别让输入继续堆积。"
elif doing_count <= 0 and pending_count > 0:
summary = "项目已经立起来了,但推进还没真正跑起来。"
stage_judgement = "启动后停滞阶段"
evidence_summary = (
f"当前没有进行中的任务,{pending_count} 个任务待处理,{pending_signal_count} 条信号待处理。"
)
core_gap = "任务和信号都在堆积,但还没有转成明确的本周执行动作。"
action_this_week = (
f"先把 {pending_count} 个待处理任务拆成本周必须启动和可以后排两档。"
)
action_owner = "明确每个任务的推进人和本周交付口径。"
action_protect = (
f"优先清掉最影响交付的 {min(max(pending_signal_count, 1), 2)} 条待处理信号。"
)
elif doing_count > 0 and pending_count > 0:
summary = "项目已经进入执行期,但当前推进被积压事项拖慢了。"
stage_judgement = "执行中但存在阻塞"
evidence_summary = (
f"当前有 {doing_count} 个任务进行中,同时还有 {pending_count} 个待处理任务和"
f" {pending_signal_count} 条待处理信号。"
)
core_gap = "执行面已经启动,但待处理项没有及时消化。"
action_this_week = "先把进行中的任务压缩到能在本周交付的最小闭环。"
action_owner = "确认当前阻塞点属于范围问题、资源问题还是依赖问题。"
action_protect = "资源不够时先保住进行中的主线任务,不要继续分散。"
elif task_total > 0 and done_count >= task_total:
summary = "项目主体任务已经完成,当前更像收尾和复盘阶段。"
stage_judgement = "接近交付阶段"
evidence_summary = f"当前任务总数 {task_total},已完成 {done_count},没有明显的待处理任务积压。"
core_gap = "当前最缺的是收口动作和复盘闭环,而不是继续扩新需求。"
action_this_week = "先补齐交付确认和复盘记录,明确这轮产出是否达到预期。"
action_owner = "确认是否进入下一阶段,还是先冻结范围做复盘。"
action_protect = "资源不够时先保住交付确认和关键复盘,不要马上开新分支。"
else:
summary = "项目在推进中,但节奏还不够稳。"
stage_judgement = "执行推进阶段"
evidence_summary = (
f"当前任务总数 {task_total},已完成 {done_count},进行中 {doing_count},待处理 {pending_count}。"
)
core_gap = "执行动作已经有了,但优先级和节奏还需要再收紧。"
action_this_week = "先把本周必须交付的任务和可以后排的任务彻底分开。"
action_owner = "确认本周最重要的交付口径,不要同时拉太多目标。"
action_protect = "资源不够时先保住最接近交付的任务。"
external_impact = ""
if has_external_updates:
external_impact = (
"外部环境已有新变化,建议把本周优先级和范围判断与这些变化重新对齐。"
)
if asks_risk_or_blockers and "阻塞" not in stage_judgement and "风险" not in core_gap:
core_gap = f"{core_gap.rstrip('。')},这也是当前最直接的阻塞信号。"
return {
"summary": summary,
"stage_judgement": stage_judgement,
"evidence_summary": evidence_summary,
"core_gap": core_gap,
"external_impact": external_impact,
"actions": [
{"slot": "this_week", "text": action_this_week},
{"slot": "owner_decision", "text": action_owner},
{"slot": "protect_priority", "text": action_protect},
],
}
async def _generate_project_weekly_report_fields(
self,
*,
question: str,
snapshot: dict[str, Any],
internal_findings: dict[str, Any],
external_updates: list[dict[str, Any]],
) -> dict[str, Any]:
llm = getattr(self, "llm", None)
if llm is None or not callable(getattr(llm, "generate", None)):
return {}
try:
prompt = await PromptReleaseService(self.db).render_prompt(
tenant_id=self.tenant_id,
prompt_key="PROMPT_PROJECT_WEEKLY_REPORT_JSON",
fallback_template=PROMPT_PROJECT_WEEKLY_REPORT_JSON,
question=question[:600],
asks_risk_or_blockers="true" if internal_findings.get("asks_risk_or_blockers") else "false",
project_snapshot=json.dumps(snapshot, ensure_ascii=False, default=str),
internal_findings=json.dumps(internal_findings, ensure_ascii=False, default=str),
external_updates=json.dumps(external_updates, ensure_ascii=False, default=str),
)
raw = await llm.generate(
prompt,
db=self.db,
tenant_id=self.tenant_id,
function_name="rag_db_project_semantic_retrieval",
temperature=0.0,
max_tokens=720,
use_prompt_profile=False,
)
except Exception:
return {}
parsed = self._parse_json_object(raw)
return parsed if isinstance(parsed, dict) else {}
@classmethod
def _extract_project_report_actions(cls, actions: Any) -> dict[str, str]:
normalized: dict[str, str] = {}
if not isinstance(actions, list):
return normalized
for item in actions:
if not isinstance(item, dict):
continue
slot = str(item.get("slot") or "").strip().lower()
text = str(item.get("text") or "").strip()
if slot and text:
normalized[slot] = text
return normalized
@classmethod
def _format_project_external_item(cls, item: RetrievedItem) -> str:
metadata = dict(getattr(item, "metadata", {}) or {})
raw_title = metadata.get("title") or str(getattr(item, "source_label", "") or "")
raw_title = str(raw_title).replace("URL[", "").replace("MCP[", "").rstrip("]")
title = cls._truncate_project_report_text(raw_title, limit=64)
if " - " in title:
primary_title = title.split(" - ", 1)[0].strip()
if primary_title:
title = primary_title
snippet = cls._truncate_project_report_text(getattr(item, "content", ""), limit=68)
if title and snippet:
normalized_title = re.sub(r'["“”\'\s_]+', "", title)
normalized_snippet = re.sub(r'["“”\'\s_]+', "", snippet)
if snippet.startswith(title) or (
normalized_title and normalized_title[:12] and normalized_title[:12] in normalized_snippet[:24]
):
return title
return f"{title}:{snippet}"
return title or snippet or "外部实时来源暂无可展示摘要。"
async def _build_single_project_existence_followup(
self,
*,
project_id: int,
) -> str:
snapshot, _ = await self._build_project_progress_snapshot(target_project_id=int(project_id))
if not isinstance(snapshot, dict):
return ""
fallback_fields = self._build_project_weekly_report_fallback(
snapshot=snapshot,
asks_risk_or_blockers=False,
has_external_updates=False,
)
actions = self._extract_project_report_actions(fallback_fields.get("actions"))
panel = (
f"状态 {str(snapshot.get('status') or 'unknown')};"
f"负责人 {str(snapshot.get('owner_name') or '未记录')};"
f"最近更新 {self._format_project_report_time(snapshot.get('latest_update'))};"
f"任务共 {int(snapshot.get('task_total') or 0)} 项,已完成 {int(snapshot.get('done_count') or 0)},"
f"进行中 {int(snapshot.get('doing_count') or 0)},待处理 {int(snapshot.get('pending_count') or 0)};"
f"待处理信号 {int(snapshot.get('pending_signal_count') or 0)} 条"
)
followup_lines = [
f"当前判断:{str(fallback_fields.get('summary') or '').strip()}",
f"当前盘面:{panel}",
]
action_text = str(actions.get("this_week") or "").strip()
if action_text:
followup_lines.append(f"建议动作:{action_text}")
return "\n".join(line for line in followup_lines if str(line).strip())
@classmethod
def _render_project_weekly_report(
cls,
*,
snapshot: dict[str, Any],
fields: dict[str, Any],
external_items: list[str],
) -> str:
fallback_actions = cls._extract_project_report_actions(fields.get("actions"))
action_this_week = fallback_actions.get("this_week") or "先补齐本周必须推进的动作。"
action_owner = fallback_actions.get("owner_decision") or "明确负责人本周需要拍板的范围和优先级。"
action_protect = fallback_actions.get("protect_priority") or "资源不够时先保住当前主线交付。"
lines = [
f"项目 #{int(snapshot.get('project_id') or 0)}《{str(snapshot.get('project_name') or '未命名项目')}》",
f"当前结论:{str(fields.get('summary') or '暂时无法形成稳定判断。').strip()}",
"",
"1. 📍 当前盘面",
f"项目状态:{str(snapshot.get('status') or 'unknown')}",
f"负责人:{str(snapshot.get('owner_name') or '未记录')}",
f"最近更新:{cls._format_project_report_time(snapshot.get('latest_update'))}",
(
f"任务进度:共 {int(snapshot.get('task_total') or 0)} 项,已完成 {int(snapshot.get('done_count') or 0)},"
f"进行中 {int(snapshot.get('doing_count') or 0)},待处理 {int(snapshot.get('pending_count') or 0)}"
),
(
f"信号情况:共 {int(snapshot.get('signal_total') or 0)} 条,"
f"待处理 {int(snapshot.get('pending_signal_count') or 0)} 条"
),
"",
"2. 🧠 进度判断",
f"项目目前处于:{str(fields.get('stage_judgement') or '信息待确认').strip()}",
f"这样判断的依据:{str(fields.get('evidence_summary') or '当前证据不足。').strip()}",
f"当前最主要的卡点:{str(fields.get('core_gap') or '当前关键缺口待确认。').strip()}",
"",
"3. 🌐 外部变化",
]
if external_items:
for idx, item in enumerate(external_items[:2], start=1):
lines.append(f"最新外部信息 {idx}:{item}")
external_impact = str(fields.get("external_impact") or "").strip()
if external_impact:
lines.append(f"对项目的直接影响:{external_impact}")
else:
lines.append("暂无高相关的外部新变化。")
lines.extend(
[
"",
"4. ✅ 下一步建议",
f"这周先推进:{action_this_week}",
f"负责人需要拍板:{action_owner}",
f"如果资源不够,先保住:{action_protect}",
"",
f"来源摘要:{'项目库 + 外部实时来源' if external_items else '项目库'}",
]
)
return "\n".join(lines)
async def _try_fast_project_progress_answer( async def _try_fast_project_progress_answer(
self, self,
@ -17,21 +510,24 @@ class AdaptiveRAGFastAnswerProjectMainMixin:
clean_q: str, clean_q: str,
project_id: Optional[int], project_id: Optional[int],
query_semantics: Optional[dict[str, Any]] = None, query_semantics: Optional[dict[str, Any]] = None,
internal_context_packet: Any = None,
) -> Optional[dict[str, Any]]: ) -> Optional[dict[str, Any]]:
question = str(clean_q or "").strip() question = str(clean_q or "").strip()
if not question: if not question:
return None return None
asks_risk_or_blockers = bool(self.PROJECT_RISK_BLOCKER_RE.search(question)) focus = self._normalize_project_status_focus(query_semantics)
if not PROJECT_PROGRESS_RE.search(question) and not asks_risk_or_blockers: if focus not in {"progress", "snapshot", "risk", "risk_or_blocker"}:
return None return None
if "项目" not in question and "系统" not in question: if project_id is None and not self._is_project_status_subject(query_semantics):
return None return None
asks_risk_or_blockers = focus in {"risk", "risk_or_blocker"}
started = time.perf_counter() started = time.perf_counter()
target_project_id, target_name, used_keyword = await self._resolve_project_scope_for_question( target_project_id, target_name, used_keyword = await self._resolve_project_scope_for_question(
question=question, question=question,
project_id=project_id, project_id=project_id,
query_semantics=query_semantics, query_semantics=query_semantics,
internal_context_packet=internal_context_packet,
) )
if target_project_id is None: if target_project_id is None:
db_query_ms = int((time.perf_counter() - started) * 1000) db_query_ms = int((time.perf_counter() - started) * 1000)
@ -48,18 +544,10 @@ class AdaptiveRAGFastAnswerProjectMainMixin:
"db_query_ms": db_query_ms, "db_query_ms": db_query_ms,
"response_time_ms": db_query_ms, "response_time_ms": db_query_ms,
} }
snapshot, db_sources = await self._build_project_progress_snapshot(
target = ( target_project_id=int(target_project_id),
await self.db.execute(
select(Project).where(
and_(
Project.tenant_id == self.tenant_id,
Project.id == int(target_project_id),
) )
) if snapshot is None:
)
).scalar_one_or_none()
if target is None:
db_query_ms = int((time.perf_counter() - started) * 1000) db_query_ms = int((time.perf_counter() - started) * 1000)
return { return {
"answer": f"未检索到项目#{int(target_project_id)},无法判断进度。", "answer": f"未检索到项目#{int(target_project_id)},无法判断进度。",
@ -79,103 +567,92 @@ class AdaptiveRAGFastAnswerProjectMainMixin:
"db_query_ms": db_query_ms, "db_query_ms": db_query_ms,
"response_time_ms": db_query_ms, "response_time_ms": db_query_ms,
} }
task_rows = (
await self.db.execute(
select(Task).where(
and_(
Task.tenant_id == self.tenant_id,
Task.project_id == int(target.id),
)
)
)
).scalars().all()
db_query_ms = int((time.perf_counter() - started) * 1000) db_query_ms = int((time.perf_counter() - started) * 1000)
internal_findings = self._build_project_internal_findings(
total = len(task_rows) snapshot,
done_statuses = {"done", "completed", "已完成", "完成", "closed", "关闭"} asks_risk_or_blockers=asks_risk_or_blockers,
doing_statuses = {"in_progress", "doing", "进行中", "开发中", "处理中", "processing"}
pending_statuses = {"todo", "pending", "待处理", "未开始", "open"}
done = 0
doing = 0
pending = 0
latest_update: Optional[datetime] = None
for row in task_rows:
status = str(row.status or "").strip().lower()
if status in done_statuses:
done += 1
elif status in doing_statuses:
doing += 1
elif status in pending_statuses:
pending += 1
else:
pending += 1
row_time = row.updated_at or row.created_at
if row_time and (latest_update is None or row_time > latest_update):
latest_update = row_time
project_name = str(target.title or target.name or f"项目#{int(target.id)}")
project_status = str(target.status or "unknown")
if total == 0 and asks_risk_or_blockers:
answer = (
f"项目「{project_name}」当前状态为 {project_status}。"
"系统里暂未沉淀独立的风险/阻塞台账字段,而且目前也没有可用的任务拆解记录。"
"现阶段只能先把“缺任务分解、缺里程碑、缺负责人推进节奏”视作主要风险信号。"
) )
elif total == 0: external_rows, external_meta = await self._build_project_external_updates(
answer = ( question=question,
f"项目「{project_name}」当前状态为 {project_status}。" snapshot=snapshot,
"目前未检索到该项目的任务拆解记录,因此只能确认项目状态,无法量化阶段进度。" query_semantics=query_semantics,
) )
elif asks_risk_or_blockers: external_updates = [
blocker_like = pending {
answer = ( "title": str((getattr(item, "metadata", {}) or {}).get("title") or "").strip(),
f"项目「{project_name}」当前状态为 {project_status}。" "summary": self._truncate_project_report_text(getattr(item, "content", ""), limit=160),
f"任务总数 {total},进行中 {doing},待处理 {pending},已完成 {done}。" "url": str((getattr(item, "metadata", {}) or {}).get("url") or "").strip(),
f"系统里暂未沉淀独立的风险/阻塞台账字段,所以先用待处理任务数 {blocker_like} 和最近任务更新时间作为代理信号。" "source_label": str(getattr(item, "source_label", "") or "").strip(),
}
for item in external_rows[:2]
]
generated_fields = await self._generate_project_weekly_report_fields(
question=question,
snapshot={
**snapshot,
"latest_update": (
snapshot.get("latest_update").isoformat() if snapshot.get("latest_update") is not None else None
),
},
internal_findings=internal_findings,
external_updates=external_updates,
) )
else: fallback_fields = self._build_project_weekly_report_fallback(
completion = round((done / total) * 100, 1) snapshot=snapshot,
latest_text = latest_update.strftime("%Y-%m-%d %H:%M") if latest_update else "未知" asks_risk_or_blockers=asks_risk_or_blockers,
answer = ( has_external_updates=bool(external_updates),
f"项目「{project_name}」当前状态为 {project_status}。" )
f"任务总数 {total},已完成 {done},进行中 {doing},待处理 {pending},完成率 {completion}%。" merged_fields = dict(fallback_fields)
f"最近任务更新时间:{latest_text}。" if bool(generated_fields.get("handled")):
for key in ("summary", "stage_judgement", "evidence_summary", "core_gap", "external_impact"):
value = str(generated_fields.get(key) or "").strip()
if value and self._is_human_project_report_text(value):
merged_fields[key] = value
generated_actions = self._extract_project_report_actions(generated_fields.get("actions"))
if generated_actions:
fallback_action_map = self._extract_project_report_actions(fallback_fields.get("actions"))
fallback_action_map.update(generated_actions)
merged_fields["actions"] = [
{"slot": slot, "text": fallback_action_map[slot]}
for slot in self._REPORT_ACTION_SLOTS
if fallback_action_map.get(slot)
]
external_items: list[str] = []
seen_external_items: set[str] = set()
for item in external_rows[:2]:
formatted_item = self._format_project_external_item(item)
normalized_item = re.sub(r"\s+", "", formatted_item)
if not normalized_item or normalized_item in seen_external_items:
continue
seen_external_items.add(normalized_item)
external_items.append(formatted_item)
answer = self._render_project_weekly_report(
snapshot=snapshot,
fields=merged_fields,
external_items=external_items,
)
response_time_ms = int((time.perf_counter() - started) * 1000)
sources = list(db_sources)
for item in external_rows[:2]:
metadata = dict(getattr(item, "metadata", {}) or {})
sources.append(
{
"source": str(getattr(item, "source", "url") or "url"),
"label": str(metadata.get("title") or getattr(item, "source_label", "External realtime source") or "External realtime source"),
"score": round(float(getattr(item, "score", 0.55) or 0.55), 4),
"metadata": {
"title": metadata.get("title"),
"url": metadata.get("url") or metadata.get("target_url"),
"retrieved_via": str(external_meta.get("adapter") or metadata.get("retrieved_via") or "external_query_gateway"),
},
}
) )
if asks_risk_or_blockers and latest_update is not None:
answer += f" 最近任务更新时间:{latest_update.strftime('%Y-%m-%d %H:%M')}。"
return { return {
"answer": answer, "answer": answer,
"sources": [ "sources": sources,
{
"source": "db",
"label": "Project database",
"score": 0.99,
"metadata": {
"table": "projects",
"project_id": int(target.id),
"query_keyword": used_keyword,
"match_count": len(matched),
},
},
{
"source": "db",
"label": "Project database",
"score": 0.98,
"metadata": {
"table": "tasks",
"project_id": int(target.id),
"task_total": total,
"done_count": done,
"doing_count": doing,
"pending_count": pending,
},
},
],
"db_query_ms": db_query_ms, "db_query_ms": db_query_ms,
"response_time_ms": db_query_ms, "response_time_ms": response_time_ms,
} }
async def _try_fast_project_created_at_answer( async def _try_fast_project_created_at_answer(
@ -184,11 +661,14 @@ class AdaptiveRAGFastAnswerProjectMainMixin:
clean_q: str, clean_q: str,
project_id: Optional[int], project_id: Optional[int],
query_semantics: Optional[dict[str, Any]] = None, query_semantics: Optional[dict[str, Any]] = None,
internal_context_packet: Any = None,
) -> Optional[dict[str, Any]]: ) -> Optional[dict[str, Any]]:
question = str(clean_q or "").strip() question = str(clean_q or "").strip()
if not question or not self.PROJECT_CREATED_AT_RE.search(question): if not question:
return None return None
if "项目" not in question and "系统" not in question: if self._normalize_project_status_focus(query_semantics) != "created_at":
return None
if project_id is None and not self._is_project_status_subject(query_semantics):
return None return None
started = time.perf_counter() started = time.perf_counter()
@ -197,6 +677,7 @@ class AdaptiveRAGFastAnswerProjectMainMixin:
question=question, question=question,
project_id=project_id, project_id=project_id,
query_semantics=query_semantics, query_semantics=query_semantics,
internal_context_packet=internal_context_packet,
) )
if target_project_id is None: if target_project_id is None:
@ -286,6 +767,7 @@ class AdaptiveRAGFastAnswerProjectMainMixin:
clean_q: str, clean_q: str,
project_id: Optional[int], project_id: Optional[int],
query_semantics: Optional[dict[str, Any]] = None, query_semantics: Optional[dict[str, Any]] = None,
internal_context_packet: Any = None,
) -> Optional[dict[str, Any]]: ) -> Optional[dict[str, Any]]:
question = str(clean_q or "").strip() question = str(clean_q or "").strip()
if not question: if not question:
@ -294,7 +776,14 @@ class AdaptiveRAGFastAnswerProjectMainMixin:
subject_focus = self._query_subject_focus(semantics) 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 = semantics.get("target_resolution") if isinstance(semantics.get("target_resolution"), dict) else {} target_resolution = internal_context_target_resolution(
packet=internal_context_packet,
fallback=internal_context_semantic_mirror(
packet=internal_context_packet,
query_semantics=semantics,
key="target_resolution",
),
)
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()
target_ref = str(target_resolution.get("target_ref") or "").strip() target_ref = str(target_resolution.get("target_ref") or "").strip()
if target_entity_type == "project" and target_ref.startswith("project:"): if target_entity_type == "project" and target_ref.startswith("project:"):
@ -345,8 +834,16 @@ class AdaptiveRAGFastAnswerProjectMainMixin:
query_terms = self._collect_lookup_terms( query_terms = self._collect_lookup_terms(
question=question, question=question,
query_semantics=semantics, query_semantics=semantics,
operation_contract=semantics.get("operation_contract") if isinstance(semantics.get("operation_contract"), dict) else None, operation_contract=internal_context_operation_contract(
packet=internal_context_packet,
fallback=internal_context_semantic_mirror(
packet=internal_context_packet,
query_semantics=semantics,
key="operation_contract",
),
),
target_resolution=target_resolution, target_resolution=target_resolution,
internal_context_packet=internal_context_packet,
limit=4, limit=4,
) )
if not query_terms: if not query_terms:

View File

@ -1,6 +1,11 @@
"""Adaptive RAG mixin module.""" """Adaptive RAG mixin module."""
from __future__ import annotations from __future__ import annotations
from .internal_query_context import (
internal_context_operation_contract,
internal_context_semantic_mirror,
internal_context_target_resolution,
)
from .shared import * from .shared import *
@ -43,14 +48,33 @@ class AdaptiveRAGFastAnswerProjectUtilsMixin:
query_semantics: Optional[dict[str, Any]] = None, query_semantics: Optional[dict[str, Any]] = None,
operation_contract: Optional[dict[str, Any]] = None, operation_contract: Optional[dict[str, Any]] = None,
target_resolution: Optional[dict[str, Any]] = None, target_resolution: Optional[dict[str, Any]] = None,
internal_context_packet: Any = None,
limit: int = 6, limit: int = 6,
) -> list[str]: ) -> list[str]:
semantics = query_semantics if isinstance(query_semantics, dict) else {} semantics = query_semantics if isinstance(query_semantics, dict) else {}
operation = operation_contract if isinstance(operation_contract, dict) else ( operation = internal_context_operation_contract(
semantics.get("operation_contract") if isinstance(semantics.get("operation_contract"), dict) else {} 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 = target_resolution if isinstance(target_resolution, dict) else ( ),
semantics.get("target_resolution") if isinstance(semantics.get("target_resolution"), 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",
)
),
) )
raw_rows: list[Any] = [] raw_rows: list[Any] = []
raw_rows.extend(list(operation.get("object_mentions") or [])) raw_rows.extend(list(operation.get("object_mentions") or []))
@ -103,12 +127,14 @@ class AdaptiveRAGFastAnswerProjectUtilsMixin:
query_semantics: Optional[dict[str, Any]] = None, query_semantics: Optional[dict[str, Any]] = None,
operation_contract: Optional[dict[str, Any]] = None, operation_contract: Optional[dict[str, Any]] = None,
target_resolution: Optional[dict[str, Any]] = None, target_resolution: Optional[dict[str, Any]] = None,
internal_context_packet: Any = None,
limit: int = 6, limit: int = 6,
) -> list[str]: ) -> list[str]:
mentions = cls._collect_structured_object_mentions( mentions = cls._collect_structured_object_mentions(
query_semantics=query_semantics, query_semantics=query_semantics,
operation_contract=operation_contract, operation_contract=operation_contract,
target_resolution=target_resolution, target_resolution=target_resolution,
internal_context_packet=internal_context_packet,
limit=limit, limit=limit,
) )
return mentions return mentions

View File

@ -2,31 +2,149 @@
from __future__ import annotations from __future__ import annotations
from ..prompt_release import PromptReleaseService from ..prompt_release import PromptReleaseService
from .internal_query_context import (
internal_context_operation_contract,
internal_context_query_plan,
internal_context_semantic_mirror,
)
from .shared import * from .shared import *
class AdaptiveRAGFastAnswerSemanticMixinP1: class AdaptiveRAGFastAnswerSemanticMixinP1:
_PENDING_SIGNAL_STATUSES = {"pending", "todo", "open", "待处理", "待确认"}
@classmethod
def _build_project_attention_points(cls, project_snapshot: dict[str, Any]) -> list[str]:
if not isinstance(project_snapshot, dict):
return []
project_id = project_snapshot.get("id")
try:
project_id_int = int(project_id)
except Exception:
project_id_int = None
project_title = str(
project_snapshot.get("title")
or project_snapshot.get("name")
or (f"项目#{project_id_int}" if project_id_int is not None else "项目")
).strip()
project_ref = (
f"项目#{project_id_int}「{project_title}」"
if project_id_int is not None
else f"项目「{project_title}」"
)
points: list[str] = []
owner_name = str(project_snapshot.get("owner_name") or "").strip()
if owner_name:
points.append(f"{project_ref} 可直接联系负责人{owner_name}确认当前节奏。")
else:
points.append(f"{project_ref} 当前未记录负责人,建议先补齐负责人。")
pending_signal_count = int(project_snapshot.get("pending_signal_count") or 0)
if pending_signal_count > 0:
points.append(f"{project_ref} 当前还有 {pending_signal_count} 条待处理信号,建议先查看相关信号。")
task_total = int(project_snapshot.get("task_total") or 0)
pending_count = int(project_snapshot.get("pending_count") or 0)
if task_total <= 0:
points.append(f"{project_ref} 当前还没有任务拆解记录,建议先补任务分解。")
elif pending_count > 0:
points.append(f"{project_ref} 当前有 {pending_count} 个待处理任务,建议先确认优先级和推进人。")
return points
@classmethod
def _append_project_attention_points(
cls,
*,
answer: str,
matched_projects: list[dict[str, Any]],
) -> str:
base_answer = str(answer or "").strip()
if not base_answer or "建议关注:" in base_answer:
return base_answer
attention_points: list[str] = []
seen: set[str] = set()
for project_snapshot in matched_projects[:3]:
for point in cls._build_project_attention_points(project_snapshot):
normalized = re.sub(r"\s+", "", str(point or ""))
if not normalized or normalized in seen:
continue
seen.add(normalized)
attention_points.append(point)
if len(attention_points) >= 3:
break
if len(attention_points) >= 3:
break
if not attention_points:
return base_answer
lines = [f"{idx}. {point}" for idx, point in enumerate(attention_points, start=1)]
return f"{base_answer}\n\n建议关注:\n" + "\n".join(lines)
async def _try_glm_project_semantic_answer( async def _try_glm_project_semantic_answer(
self, self,
*, *,
clean_q: str, clean_q: str,
project_id: Optional[int], project_id: Optional[int],
query_semantics: Optional[dict[str, Any]] = None, query_semantics: Optional[dict[str, Any]] = None,
internal_context_packet: Any = None,
) -> Optional[dict[str, Any]]: ) -> Optional[dict[str, Any]]:
question = str(clean_q or "").strip() question = str(clean_q or "").strip()
if not question: if not question:
return None return None
semantics = query_semantics if isinstance(query_semantics, dict) else {}
operation_contract = internal_context_operation_contract(
packet=internal_context_packet,
fallback=internal_context_semantic_mirror(
packet=internal_context_packet,
query_semantics=semantics,
key="operation_contract",
),
)
query_plan = internal_context_query_plan(
packet=internal_context_packet,
fallback=internal_context_semantic_mirror(
packet=internal_context_packet,
query_semantics=semantics,
key="query_plan",
),
)
subject_focus = self._query_subject_focus(query_semantics) subject_focus = self._query_subject_focus(query_semantics)
if subject_focus in {"people", "feature", "workload"}: if subject_focus in {"people", "feature", "workload"}:
return None return None
if subject_focus not in {"project", "task", "signal", "charter", "unknown"}: if subject_focus not in {"project", "task", "signal", "charter", "unknown"}:
return None return None
query_type = str(
operation_contract.get("query_type")
or query_plan.get("query_type")
or semantics.get("query_type")
or "unknown"
).strip().lower() or "unknown"
answer_contract = str(
query_plan.get("answer_contract")
or operation_contract.get("answer_contract")
or ""
).strip().lower() or "unspecified"
db_started = time.perf_counter() db_started = time.perf_counter()
stmt = select(Project).where(Project.tenant_id == self.tenant_id) stmt = (
select(Project, User.name.label("owner_name"))
.outerjoin(
User,
and_(
User.tenant_id == self.tenant_id,
User.id == Project.owner_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))
projects = (await self.db.execute(stmt.order_by(Project.updated_at.desc()).limit(120))).scalars().all() project_rows = (await self.db.execute(stmt.order_by(Project.updated_at.desc()).limit(120))).all()
projects = [row[0] for row in project_rows]
if not projects: if not projects:
db_query_ms = int((time.perf_counter() - db_started) * 1000) db_query_ms = int((time.perf_counter() - db_started) * 1000)
return { return {
@ -43,6 +161,10 @@ class AdaptiveRAGFastAnswerSemanticMixinP1:
"response_time_ms": db_query_ms, "response_time_ms": db_query_ms,
} }
owner_name_by_project_id = {
int(project.id): (str(owner_name or "").strip() or None)
for project, owner_name in project_rows
}
project_ids = [int(p.id) for p in projects] project_ids = [int(p.id) for p in projects]
task_rows = [] task_rows = []
if project_ids: if project_ids:
@ -56,6 +178,39 @@ class AdaptiveRAGFastAnswerSemanticMixinP1:
) )
) )
).scalars().all() ).scalars().all()
signal_stats: dict[int, dict[str, int]] = {
int(pid): {"signal_total": 0, "pending_signal_count": 0}
for pid in project_ids
}
if project_ids:
signal_rows = (
await self.db.execute(
select(
Signal.matched_project_id,
Signal.status,
Signal.processed,
Signal.planner_status,
).where(
and_(
Signal.tenant_id == self.tenant_id,
Signal.matched_project_id.in_(project_ids),
)
)
)
).all()
for matched_project_id, status, processed, planner_status in signal_rows:
if matched_project_id is None:
continue
pid = int(matched_project_id)
stats = signal_stats.setdefault(pid, {"signal_total": 0, "pending_signal_count": 0})
stats["signal_total"] += 1
normalized_status = str(status or "").strip().lower()
normalized_planner_status = str(planner_status or "").strip().lower()
is_pending = (not bool(processed)) or (
normalized_status in self._PENDING_SIGNAL_STATUSES
) or normalized_planner_status == "pending"
if is_pending:
stats["pending_signal_count"] += 1
db_query_ms = int((time.perf_counter() - db_started) * 1000) db_query_ms = int((time.perf_counter() - db_started) * 1000)
done_statuses = {"done", "completed", "已完成", "完成", "closed", "关闭"} done_statuses = {"done", "completed", "已完成", "完成", "closed", "关闭"}
@ -96,6 +251,7 @@ class AdaptiveRAGFastAnswerSemanticMixinP1:
for p in projects: for p in projects:
pid = int(p.id) pid = int(p.id)
stats = task_stats.get(pid) or {} stats = task_stats.get(pid) or {}
signal_row = signal_stats.get(pid) or {}
project_index.append( project_index.append(
{ {
"id": pid, "id": pid,
@ -103,10 +259,15 @@ class AdaptiveRAGFastAnswerSemanticMixinP1:
"name": str(p.name or "")[:120], "name": str(p.name or "")[:120],
"status": str(p.status or "unknown"), "status": str(p.status or "unknown"),
"description": str(p.description or "")[:220], "description": str(p.description or "")[:220],
"owner_id": int(p.owner_id) if p.owner_id is not None else None,
"owner_name": owner_name_by_project_id.get(pid),
"updated_at": p.updated_at.isoformat() if p.updated_at is not None else None,
"task_total": int(stats.get("task_total") or 0), "task_total": int(stats.get("task_total") or 0),
"done_count": int(stats.get("done_count") or 0), "done_count": int(stats.get("done_count") or 0),
"doing_count": int(stats.get("doing_count") or 0), "doing_count": int(stats.get("doing_count") or 0),
"pending_count": int(stats.get("pending_count") or 0), "pending_count": int(stats.get("pending_count") or 0),
"signal_total": int(signal_row.get("signal_total") or 0),
"pending_signal_count": int(signal_row.get("pending_signal_count") or 0),
"latest_task_updated_at": ( "latest_task_updated_at": (
stats.get("latest_task_updated_at").isoformat() stats.get("latest_task_updated_at").isoformat()
if stats.get("latest_task_updated_at") is not None if stats.get("latest_task_updated_at") is not None
@ -120,6 +281,8 @@ class AdaptiveRAGFastAnswerSemanticMixinP1:
prompt_key="PROMPT_RAG_DB_PROJECT_QA", prompt_key="PROMPT_RAG_DB_PROJECT_QA",
fallback_template=PROMPT_RAG_DB_PROJECT_QA, fallback_template=PROMPT_RAG_DB_PROJECT_QA,
question=question[:600], question=question[:600],
query_type=query_type,
answer_contract=answer_contract,
project_index=json.dumps(project_index, ensure_ascii=False), project_index=json.dumps(project_index, ensure_ascii=False),
) )
llm_started = time.perf_counter() llm_started = time.perf_counter()
@ -150,6 +313,17 @@ class AdaptiveRAGFastAnswerSemanticMixinP1:
matched_ids = matched_ids[:8] matched_ids = matched_ids[:8]
if not handled or not answer: if not handled or not answer:
return None return None
if str(query_type or "").strip().lower() == "existence" and len(matched_ids) == 1:
enrich_single_hit = getattr(self, "_build_single_project_existence_followup", None)
if callable(enrich_single_hit):
try:
supplement = str(
await enrich_single_hit(project_id=int(matched_ids[0]))
).strip()
except Exception:
supplement = ""
if supplement and supplement not in answer:
answer = f"{answer}\n{supplement}"
response_ms = int((time.perf_counter() - llm_started) * 1000) + db_query_ms response_ms = int((time.perf_counter() - llm_started) * 1000) + db_query_ms
return { return {
"answer": answer, "answer": answer,

View File

@ -2,6 +2,7 @@
from __future__ import annotations from __future__ import annotations
from .shared import * from .shared import *
from .internal_query_context import update_internal_query_context_packet
class AdaptiveRAGFastAnswerSemanticMixinP2: class AdaptiveRAGFastAnswerSemanticMixinP2:
@ -25,6 +26,7 @@ class AdaptiveRAGFastAnswerSemanticMixinP2:
answer_mode: str = ANSWER_MODE_FAST_PATH, answer_mode: str = ANSWER_MODE_FAST_PATH,
route_decision: Optional[dict[str, Any]] = None, route_decision: Optional[dict[str, Any]] = None,
query_semantics: Optional[dict[str, Any]] = None, query_semantics: Optional[dict[str, Any]] = None,
internal_context_packet: Any = None,
) -> dict[str, Any]: ) -> dict[str, Any]:
started = time.perf_counter() started = time.perf_counter()
sources = list(fast_result.get("sources") or []) sources = list(fast_result.get("sources") or [])
@ -58,6 +60,7 @@ class AdaptiveRAGFastAnswerSemanticMixinP2:
answer=answer_text, answer=answer_text,
sources=sources, sources=sources,
route_decision=route_decision, route_decision=route_decision,
internal_context_packet=internal_context_packet,
) )
effective_answer_mode = answer_mode effective_answer_mode = answer_mode
effective_grounding_status = GROUNDING_STATUS_GROUNDED effective_grounding_status = GROUNDING_STATUS_GROUNDED
@ -82,11 +85,35 @@ class AdaptiveRAGFastAnswerSemanticMixinP2:
"vector_timeout": False, "vector_timeout": False,
"graph_timeout": False, "graph_timeout": False,
} }
internal_context_packet = update_internal_query_context_packet(
internal_context_packet,
executor_summary={
"answer_mode": str(effective_answer_mode or "").strip() or ANSWER_MODE_FAST_PATH,
"retrieval_domains": ["db"],
"db_query_ms": int(fast_result.get("db_query_ms") or 0),
"vector_search_ms": 0,
"graph_query_ms": 0,
},
verifier_summary={
"ok": bool(contract_verdict.get("ok")),
"reason_code": str(contract_verdict.get("reason_code") or "").strip() or None,
"missing_fields": [
str(item).strip()
for item in list(contract_verdict.get("missing_fields") or [])
if str(item).strip()
],
},
uncertainties=[
str(effective_reason_code or "").strip() if str(effective_reason_code or "").strip() else "",
*[str(item).strip() for item in list(missing_fields or []) if str(item).strip()],
],
)
inject_runtime_summaries = getattr(self, "_inject_internal_query_runtime_summaries", None) inject_runtime_summaries = getattr(self, "_inject_internal_query_runtime_summaries", None)
if callable(inject_runtime_summaries): if callable(inject_runtime_summaries):
retrieval_metrics = inject_runtime_summaries( retrieval_metrics = inject_runtime_summaries(
retrieval_metrics=retrieval_metrics, retrieval_metrics=retrieval_metrics,
query_semantics=query_semantics, query_semantics=query_semantics,
internal_context_packet=internal_context_packet,
) )
runtime_fields = self._build_qa_record_runtime_fields( runtime_fields = self._build_qa_record_runtime_fields(
answer=answer_text, answer=answer_text,

View File

@ -3,34 +3,13 @@ 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 *
FOLLOWUP_RECENT_REPLY_WINDOW_MINUTES = 5 FOLLOWUP_RECENT_REPLY_WINDOW_MINUTES = 5
FOLLOWUP_CANDIDATE_LIMIT = 3 FOLLOWUP_CANDIDATE_LIMIT = 3
FOLLOWUP_SELECTION_DIGIT_RE = re.compile(r"^\s*([1-3])\s*$")
FOLLOWUP_SELECTION_ORDINAL_RE = re.compile(r"^\s*第?([一二三123])(?:条|个|项)?\s*$")
FOLLOWUP_REMOVABLE_TOKENS = (
"上一条",
"上一句",
"刚才",
"那条",
"这条",
"那个",
"这个",
"回复",
"回答",
"一下",
"帮我",
"请",
"解释",
"说下",
"说说",
"那句",
"这句",
"的",
)
@dataclass @dataclass
@ -76,11 +55,29 @@ class AdaptiveRAGFollowupAnchorMixin:
@staticmethod @staticmethod
def _normalize_followup_selection_text(text: str) -> str: def _normalize_followup_selection_text(text: str) -> str:
normalized = re.sub(r"\s+", "", str(text or "")).strip().lower() normalized = re.sub(r"\s+", "", str(text or "")).strip().lower()
for token in FOLLOWUP_REMOVABLE_TOKENS:
normalized = normalized.replace(token, "")
normalized = re.sub(r"[^\w\u4e00-\u9fff]", "", normalized) normalized = re.sub(r"[^\w\u4e00-\u9fff]", "", normalized)
return normalized[:120] 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)
@ -194,7 +191,6 @@ class AdaptiveRAGFollowupAnchorMixin:
question = " ".join(str(row.question or "").split()).strip() question = " ".join(str(row.question or "").split()).strip()
answer = " ".join(str(row.answer or "").split()).strip() answer = " ".join(str(row.answer or "").split()).strip()
label = question or "最近回复" label = question or "最近回复"
label = re.sub(r"^(?:帮我|请|麻烦|我想知道|想问下|问下|给我|解释下|解释一下|告诉我|说下)", "", label).strip()
label = re.sub(r"[??!!。\.].*$", "", label).strip(" ,,::") label = re.sub(r"[??!!。\.].*$", "", label).strip(" ,,::")
label = label[:20] or (question[:20] if question else "最近回复") label = label[:20] or (question[:20] if question else "最近回复")
answer_preview = answer[:56] + ("..." if len(answer) > 56 else "") answer_preview = answer[:56] + ("..." if len(answer) > 56 else "")
@ -425,6 +421,43 @@ class AdaptiveRAGFollowupAnchorMixin:
minutes=FOLLOWUP_RECENT_REPLY_WINDOW_MINUTES minutes=FOLLOWUP_RECENT_REPLY_WINDOW_MINUTES
) )
def _store_pending_query_route_clarification(
self,
*,
session_state: Optional[QASessionState],
original_question: str,
previous_attempts: int = 0,
) -> None:
if session_state is None:
return
protocol_builder = getattr(self, "_build_query_route_clarification_protocol", None)
protocol = protocol_builder() if callable(protocol_builder) else {}
option_map = protocol.get("option_map") if isinstance(protocol.get("option_map"), dict) else {}
options = protocol.get("options") if isinstance(protocol.get("options"), list) else []
session_state.pending_clarification = {
"kind": "query_route_selection",
"original_question": str(original_question or "").strip()[:500],
"attempts": int(previous_attempts) + 1,
"option_map": {
str(key or "").strip().upper(): str(value or "").strip().lower()
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],
}
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 _get_pending_clarification(self, *, session_state: Optional[QASessionState]) -> Optional[dict[str, Any]]: def _get_pending_clarification(self, *, session_state: Optional[QASessionState]) -> Optional[dict[str, Any]]:
if session_state is None: if session_state is None:
return None return None
@ -447,17 +480,39 @@ class AdaptiveRAGFollowupAnchorMixin:
if not payload: if not payload:
return None return None
text = str(question or "").strip() text = str(question or "").strip()
index: Optional[int] = None kind = str(payload.get("kind") or "").strip().lower()
digit_match = FOLLOWUP_SELECTION_DIGIT_RE.match(text) if kind == "query_route_selection":
if digit_match: option_map = payload.get("option_map") if isinstance(payload.get("option_map"), dict) else {}
index = int(digit_match.group(1)) options = payload.get("options") if isinstance(payload.get("options"), list) else []
else: normalized = self._normalize_followup_selection_text(text)
ordinal_match = FOLLOWUP_SELECTION_ORDINAL_RE.match(text) selected_hint = ""
if ordinal_match: if len(text) == 1:
raw = ordinal_match.group(1) selected_hint = str(option_map.get(text.upper()) or "").strip().lower()
index = {"一": 1, "二": 2, "三": 3}.get(raw) if not selected_hint:
if index is None: matched: list[str] = []
index = int(raw) for option in options:
if not isinstance(option, dict):
continue
key = str(option.get("key") or "").strip().upper()
label = str(option.get("label") or "").strip()
normalized_label = self._normalize_followup_selection_text(label)
if not normalized or not normalized_label:
continue
if normalized in normalized_label or normalized_label in normalized:
mapped = str(option_map.get(key) or "").strip().lower()
if mapped and mapped not in matched:
matched.append(mapped)
if len(matched) == 1:
selected_hint = matched[0]
if selected_hint in {"internal", "external"}:
self._clear_pending_clarification(session_state=session_state)
return {
"effective_question": str(payload.get("original_question") or "").strip() or text,
"source_policy_hint": selected_hint,
}
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 []
normalized = self._normalize_followup_selection_text(text) normalized = self._normalize_followup_selection_text(text)
selected: Optional[dict[str, Any]] = None selected: Optional[dict[str, Any]] = None
@ -465,7 +520,7 @@ class AdaptiveRAGFollowupAnchorMixin:
if not isinstance(candidate, dict): if not isinstance(candidate, dict):
continue continue
candidate_index = int(candidate.get("index") or 0) candidate_index = int(candidate.get("index") or 0)
if index is not None 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() match_text = str(candidate.get("match_text") or "").strip()
@ -672,7 +727,7 @@ class AdaptiveRAGFollowupAnchorMixin:
return { return {
"answer": ( "answer": (
"我不确定你指的是哪一条我刚才的回复。" "我不确定你指的是哪一条我刚才的回复。"
"你可以直接回复那条消息,或者告诉我是“系统架构”那条还是“系统模型”那条。" "你可以直接回复那条消息,或者引用那条消息后再继续说。"
), ),
"code": "no_anchor_found", "code": "no_anchor_found",
"candidates": [], "candidates": [],

View File

@ -20,6 +20,7 @@ class AdaptiveRAGMultiStepCoreMixin:
project_id: Optional[int], project_id: Optional[int],
source_chat_id: Optional[str], source_chat_id: Optional[str],
query_semantics: Optional[dict[str, Any]] = None, query_semantics: Optional[dict[str, Any]] = None,
internal_context_packet: Any = None,
resolved_entities: Optional[dict[str, Any]] = None, resolved_entities: Optional[dict[str, Any]] = None,
vector_top_k: Optional[int] = None, vector_top_k: Optional[int] = None,
) -> tuple[list[RetrievedItem], dict[str, Any]]: ) -> tuple[list[RetrievedItem], dict[str, Any]]:
@ -33,6 +34,7 @@ class AdaptiveRAGMultiStepCoreMixin:
question, question,
project_id=project_id, project_id=project_id,
query_semantics=query_semantics, query_semantics=query_semantics,
internal_context_packet=internal_context_packet,
), ),
timeout=DB_QUERY_TIMEOUT_SECONDS, timeout=DB_QUERY_TIMEOUT_SECONDS,
) )

View File

@ -13,6 +13,7 @@ class AdaptiveRAGMultiStepFlowMixin:
project_id: Optional[int], project_id: Optional[int],
source_chat_id: Optional[str], source_chat_id: Optional[str],
query_semantics: Optional[dict[str, Any]] = None, query_semantics: Optional[dict[str, Any]] = None,
internal_context_packet: Any = None,
session_state: Optional[QASessionState], session_state: Optional[QASessionState],
max_steps_override: Optional[int] = None, max_steps_override: Optional[int] = None,
vector_top_k: Optional[int] = None, vector_top_k: Optional[int] = None,
@ -132,6 +133,7 @@ class AdaptiveRAGMultiStepFlowMixin:
project_id=project_id, project_id=project_id,
source_chat_id=source_chat_id, source_chat_id=source_chat_id,
query_semantics=query_semantics, query_semantics=query_semantics,
internal_context_packet=internal_context_packet,
resolved_entities=resolved_payload, resolved_entities=resolved_payload,
vector_top_k=vector_top_k, vector_top_k=vector_top_k,
) )

View File

@ -5,6 +5,14 @@ from .shared import *
class AdaptiveRAGQASessionListOpsMixinP1: class AdaptiveRAGQASessionListOpsMixinP1:
@staticmethod
def _normalize_message_dict(value: Any) -> dict[str, Any]:
return dict(value) if isinstance(value, dict) else {}
@staticmethod
def _normalize_message_list(value: Any) -> list[Any]:
return list(value) if isinstance(value, list) else []
async def list_qa_sessions( async def list_qa_sessions(
self, self,
*, *,
@ -159,6 +167,7 @@ class AdaptiveRAGQASessionListOpsMixinP1:
for row in records: for row in records:
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)
if fb: if fb:
rating = str(fb.rating or "").strip().lower() rating = str(fb.rating or "").strip().lower()
if rating == "positive": if rating == "positive":
@ -180,6 +189,13 @@ class AdaptiveRAGQASessionListOpsMixinP1:
"asked_via": row.asked_via, "asked_via": row.asked_via,
"project_id": row.project_id, "project_id": row.project_id,
"session_id": row.session_id, "session_id": row.session_id,
"route_decision": self._normalize_message_dict(routing_snapshot.get("query_route")),
"answer_mode": str(row.answer_mode or "").strip() or None,
"grounding_status": str(row.grounding_status or "").strip() or None,
"policy_version": str(row.policy_version or "").strip() or None,
"citations": self._normalize_message_list(row.citations),
"sources_used": self._normalize_message_list(row.sources_used),
"evidence_pack": self._normalize_message_dict(row.evidence_pack),
"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": {
@ -237,4 +253,3 @@ class AdaptiveRAGQASessionListOpsMixinP1:
session_row.title = cleaned session_row.title = cleaned
await self.db.commit() await self.db.commit()
return {"id": int(session_row.id), "title": cleaned} return {"id": int(session_row.id), "title": cleaned}

View File

@ -92,6 +92,22 @@ class AdaptiveRAGQueryRouteGatewayMixin:
def _build_query_route_clarification_text(cls) -> str: def _build_query_route_clarification_text(cls) -> str:
return "你是想问外部公开事实,还是系统内部配置/项目对象?" return "你是想问外部公开事实,还是系统内部配置/项目对象?"
@classmethod
def _build_query_route_clarification_protocol(cls) -> dict[str, Any]:
options = [
{"key": "A", "label": "外部公开事实"},
{"key": "B", "label": "系统内部配置/项目对象"},
]
option_map = {
"A": "external",
"B": "internal",
}
return {
"kind": "query_route_selection",
"option_map": option_map,
"options": options,
}
@classmethod @classmethod
def _build_contract_snapshot_summary( def _build_contract_snapshot_summary(
cls, cls,

View File

@ -0,0 +1,434 @@
"""Route and context governance read helpers."""
from __future__ import annotations
from dataclasses import dataclass
from datetime import timedelta
from typing import Any, Optional
from ...schemas.query_route_governance import (
QueryRouteGovernanceBucketRead,
QueryRouteGovernanceDetailRead,
QueryRouteGovernanceKPIRead,
QueryRouteGovernanceListRead,
QueryRouteGovernanceRecordRead,
QueryRouteGovernanceSummaryRead,
)
from .shared import *
WINDOW_DAYS_VALUES = {7, 30}
ROUTE_MODE_FILTER_VALUES = {"all", "internal", "external", "uncertain"}
RATING_FILTER_VALUES = {"all", "positive", "negative"}
def _normalize_jsonable(value: Any) -> Any:
if isinstance(value, dict):
return {str(key): _normalize_jsonable(item) for key, item in value.items()}
if isinstance(value, (list, tuple)):
return [_normalize_jsonable(item) for item in value]
if hasattr(value, "model_dump"):
return _normalize_jsonable(value.model_dump(mode="json"))
if hasattr(value, "isoformat"):
try:
return value.isoformat()
except Exception:
return str(value)
return value
def _normalize_window_days(value: Any) -> int:
if value is None:
return 30
normalized = int(value)
if normalized not in WINDOW_DAYS_VALUES:
raise ValueError(f"unsupported window_days: {normalized}")
return normalized
def _normalize_route_mode_filter(value: Any) -> str:
normalized = str(value or "all").strip().lower()
if normalized not in ROUTE_MODE_FILTER_VALUES:
raise ValueError(f"unsupported route_mode: {normalized}")
return normalized
def _normalize_rating_filter(value: Any) -> str:
normalized = str(value or "all").strip().lower()
if normalized not in RATING_FILTER_VALUES:
raise ValueError(f"unsupported rating: {normalized}")
return normalized
def _normalize_text(value: Any) -> str:
return str(value or "").strip()
@dataclass(slots=True)
class _QueryRouteGovernanceRow:
route_memory: QueryRouteMemory
qa_record: QARecord | None
session: QASession | None
class AdaptiveRAGQueryRouteGovernanceMixin:
@staticmethod
def _feedback_rating(feedback_outcome: Any) -> str | None:
if not isinstance(feedback_outcome, dict):
return None
value = str(feedback_outcome.get("rating") or "").strip().lower()
return value or None
@staticmethod
def _feedback_helpfulness(feedback_outcome: Any) -> str | None:
if not isinstance(feedback_outcome, dict):
return None
value = str(feedback_outcome.get("helpfulness") or "").strip().lower()
return value or None
@staticmethod
def _reply_mode(reply_contract: Any) -> str | None:
if not isinstance(reply_contract, dict):
return None
value = str(reply_contract.get("mode") or "").strip().lower()
return value or None
def _is_route_mismatch(self, row: _QueryRouteGovernanceRow) -> bool:
return str(row.route_memory.route_mode_predicted or "").strip().lower() != str(
row.route_memory.route_mode_effective or ""
).strip().lower()
def _is_clarify(self, row: _QueryRouteGovernanceRow) -> bool:
reply_mode = self._reply_mode(row.route_memory.reply_contract)
effective_mode = str(row.route_memory.route_mode_effective or "").strip().lower()
return reply_mode == "clarify" or effective_mode == "uncertain"
@staticmethod
def _resolved_project_id(row: _QueryRouteGovernanceRow) -> int | None:
project_id = row.route_memory.project_id
if project_id is None and row.qa_record is not None:
project_id = row.qa_record.project_id
if project_id is None and row.session is not None:
project_id = row.session.project_id
return int(project_id) if project_id is not None else None
@staticmethod
def _anchor_summary(row: _QueryRouteGovernanceRow) -> str | None:
qa_record = row.qa_record
if qa_record is None:
return None
anchor_kind = _normalize_text(getattr(qa_record, "anchor_kind", None))
anchor_ref = _normalize_text(getattr(qa_record, "anchor_ref", None))
source_chat_id = _normalize_text(getattr(qa_record, "source_chat_id", None))
if anchor_kind and anchor_ref:
return f"{anchor_kind}:{anchor_ref}"
if anchor_kind:
return anchor_kind
if source_chat_id:
return f"source_chat:{source_chat_id}"
return None
@staticmethod
def _record_timestamp(row: _QueryRouteGovernanceRow, field_name: str) -> str | None:
for target in (row.route_memory, row.qa_record, row.session):
if target is None:
continue
value = getattr(target, field_name, None)
if value is not None:
try:
return value.isoformat()
except Exception:
return str(value)
return None
def _serialize_governance_record(self, row: _QueryRouteGovernanceRow) -> dict[str, Any]:
qa_record = row.qa_record
session = row.session
payload = QueryRouteGovernanceRecordRead(
id=int(row.route_memory.id),
qa_record_id=int(row.route_memory.qa_record_id),
project_id=self._resolved_project_id(row),
session_id=int(qa_record.session_id) if qa_record and qa_record.session_id is not None else None,
session_title=_normalize_text(session.title) or None if session is not None else None,
question_text=_normalize_text(row.route_memory.question_text),
route_mode_predicted=_normalize_text(row.route_memory.route_mode_predicted),
route_mode_effective=_normalize_text(row.route_memory.route_mode_effective),
decision_origin=_normalize_text(row.route_memory.decision_origin) or None,
reply_mode=self._reply_mode(row.route_memory.reply_contract),
feedback_rating=self._feedback_rating(row.route_memory.feedback_outcome),
feedback_helpfulness=self._feedback_helpfulness(row.route_memory.feedback_outcome),
is_route_mismatch=self._is_route_mismatch(row),
is_clarify=self._is_clarify(row),
anchor_summary=self._anchor_summary(row),
answer_mode=_normalize_text(getattr(qa_record, "answer_mode", None)) or None,
grounding_status=_normalize_text(getattr(qa_record, "grounding_status", None)) or None,
asked_via=_normalize_text(getattr(qa_record, "asked_via", None)) or None,
created_at=self._record_timestamp(row, "created_at"),
updated_at=self._record_timestamp(row, "updated_at"),
)
return payload.model_dump(mode="json")
def _serialize_governance_detail(self, row: _QueryRouteGovernanceRow) -> dict[str, Any]:
qa_record = row.qa_record
detail = QueryRouteGovernanceDetailRead(
record=QueryRouteGovernanceRecordRead.model_validate(self._serialize_governance_record(row)),
qa_record={
"id": int(qa_record.id) if qa_record is not None else None,
"session_id": int(qa_record.session_id) if qa_record and qa_record.session_id is not None else None,
"session_title": _normalize_text(row.session.title) or None if row.session is not None else None,
"question": _normalize_text(getattr(qa_record, "question", None)) or None,
"answer": _normalize_text(getattr(qa_record, "answer", None)) or None,
"asked_via": _normalize_text(getattr(qa_record, "asked_via", None)) or None,
"answer_mode": _normalize_text(getattr(qa_record, "answer_mode", None)) or None,
"grounding_status": _normalize_text(getattr(qa_record, "grounding_status", None)) or None,
"source_chat_id": _normalize_text(getattr(qa_record, "source_chat_id", None)) or None,
"anchor_kind": _normalize_text(getattr(qa_record, "anchor_kind", None)) or None,
"anchor_ref": _normalize_text(getattr(qa_record, "anchor_ref", None)) or None,
"routing_snapshot": _normalize_jsonable(getattr(qa_record, "routing_snapshot", None) or {}),
"evidence_pack": _normalize_jsonable(getattr(qa_record, "evidence_pack", None) or {}),
"created_at": self._record_timestamp(row, "created_at"),
},
route_memory={
"reply_contract": _normalize_jsonable(row.route_memory.reply_contract or {}),
"probe_snapshot": _normalize_jsonable(row.route_memory.probe_snapshot or {}),
"query_profile": _normalize_jsonable(row.route_memory.query_profile or {}),
"feedback_outcome": _normalize_jsonable(row.route_memory.feedback_outcome or {}),
"decision_origin": _normalize_text(row.route_memory.decision_origin) or None,
},
)
return detail.model_dump(mode="json")
async def _load_query_route_governance_rows(
self,
*,
project_id: int | None,
window_days: int,
) -> list[_QueryRouteGovernanceRow]:
cutoff = now_cn_naive() - timedelta(days=int(window_days))
stmt = (
select(QueryRouteMemory, QARecord, QASession)
.outerjoin(
QARecord,
and_(
QARecord.tenant_id == QueryRouteMemory.tenant_id,
QARecord.id == QueryRouteMemory.qa_record_id,
),
)
.outerjoin(
QASession,
and_(
QASession.tenant_id == QueryRouteMemory.tenant_id,
QASession.id == QARecord.session_id,
),
)
.where(
QueryRouteMemory.tenant_id == int(self.tenant_id),
QueryRouteMemory.created_at >= cutoff,
)
.order_by(QueryRouteMemory.created_at.desc(), QueryRouteMemory.id.desc())
)
if project_id is not None:
project_id = int(project_id)
stmt = stmt.where(
or_(
QueryRouteMemory.project_id == project_id,
QARecord.project_id == project_id,
QASession.project_id == project_id,
)
)
result = await self.db.execute(stmt)
return [
_QueryRouteGovernanceRow(route_memory=route_memory, qa_record=qa_record, session=session)
for route_memory, qa_record, session in result.all()
]
def _filter_query_route_governance_rows(
self,
rows: list[_QueryRouteGovernanceRow],
*,
route_mode: str,
decision_origin: str | None,
rating: str,
mismatch_only: bool,
clarify_only: bool,
search: str | None,
) -> list[_QueryRouteGovernanceRow]:
normalized_search = _normalize_text(search).lower()
normalized_origin = _normalize_text(decision_origin).lower()
filtered: list[_QueryRouteGovernanceRow] = []
for row in rows:
effective_mode = _normalize_text(row.route_memory.route_mode_effective).lower()
if route_mode != "all" and effective_mode != route_mode:
continue
if normalized_origin and _normalize_text(row.route_memory.decision_origin).lower() != normalized_origin:
continue
feedback_rating = self._feedback_rating(row.route_memory.feedback_outcome)
if rating != "all" and feedback_rating != rating:
continue
if mismatch_only and not self._is_route_mismatch(row):
continue
if clarify_only and not self._is_clarify(row):
continue
if normalized_search:
haystack = " ".join(
part
for part in (
_normalize_text(row.route_memory.question_text),
_normalize_text(getattr(row.qa_record, "answer", None)),
_normalize_text(getattr(row.session, "title", None)),
)
if part
).lower()
if normalized_search not in haystack:
continue
filtered.append(row)
return filtered
async def query_route_governance_summary(
self,
*,
project_id: int | None = None,
window_days: int | None = None,
) -> dict[str, Any]:
effective_window_days = _normalize_window_days(window_days)
rows = await self._load_query_route_governance_rows(
project_id=project_id,
window_days=effective_window_days,
)
route_mode_counts: dict[str, int] = {"internal": 0, "external": 0, "uncertain": 0}
decision_origin_counts: dict[str, int] = {}
mismatch_count = 0
clarify_count = 0
negative_feedback_count = 0
for row in rows:
effective_mode = _normalize_text(row.route_memory.route_mode_effective).lower() or "uncertain"
if effective_mode not in route_mode_counts:
route_mode_counts[effective_mode] = 0
route_mode_counts[effective_mode] += 1
if self._is_route_mismatch(row):
mismatch_count += 1
if self._is_clarify(row):
clarify_count += 1
if self._feedback_rating(row.route_memory.feedback_outcome) == "negative":
negative_feedback_count += 1
origin_key = _normalize_text(row.route_memory.decision_origin) or "unknown"
decision_origin_counts[origin_key] = decision_origin_counts.get(origin_key, 0) + 1
summary = QueryRouteGovernanceSummaryRead(
window_days=effective_window_days,
project_id=int(project_id) if project_id is not None else None,
kpis=QueryRouteGovernanceKPIRead(
total_records=len(rows),
mismatch_count=mismatch_count,
clarify_count=clarify_count,
negative_feedback_count=negative_feedback_count,
internal_count=route_mode_counts.get("internal", 0),
external_count=route_mode_counts.get("external", 0),
uncertain_count=route_mode_counts.get("uncertain", 0),
),
route_modes=[
QueryRouteGovernanceBucketRead(
key=mode_key,
label={
"internal": "内部知识库查询",
"external": "外部实时查询",
"uncertain": "待澄清 / uncertain",
}.get(mode_key, mode_key),
count=count,
)
for mode_key, count in route_mode_counts.items()
if int(count or 0) > 0
],
decision_origins=[
QueryRouteGovernanceBucketRead(
key=origin_key,
label=origin_key,
count=count,
)
for origin_key, count in sorted(
decision_origin_counts.items(),
key=lambda item: (-int(item[1] or 0), str(item[0])),
)
],
)
return summary.model_dump(mode="json")
async def list_query_route_governance_records(
self,
*,
project_id: int | None = None,
window_days: int | None = None,
route_mode: str | None = None,
decision_origin: str | None = None,
rating: str | None = None,
mismatch_only: bool = False,
clarify_only: bool = False,
search: str | None = None,
limit: int = 20,
offset: int = 0,
) -> dict[str, Any]:
effective_window_days = _normalize_window_days(window_days)
effective_route_mode = _normalize_route_mode_filter(route_mode)
effective_rating = _normalize_rating_filter(rating)
rows = await self._load_query_route_governance_rows(
project_id=project_id,
window_days=effective_window_days,
)
filtered = self._filter_query_route_governance_rows(
rows,
route_mode=effective_route_mode,
decision_origin=decision_origin,
rating=effective_rating,
mismatch_only=bool(mismatch_only),
clarify_only=bool(clarify_only),
search=search,
)
page = filtered[int(offset): int(offset) + int(limit)]
payload = QueryRouteGovernanceListRead(
items=[QueryRouteGovernanceRecordRead.model_validate(self._serialize_governance_record(row)) for row in page],
total=len(filtered),
limit=int(limit),
offset=int(offset),
)
return payload.model_dump(mode="json")
async def get_query_route_governance_detail(
self,
*,
record_id: int,
project_id: int | None = None,
) -> dict[str, Any] | None:
stmt = (
select(QueryRouteMemory, QARecord, QASession)
.outerjoin(
QARecord,
and_(
QARecord.tenant_id == QueryRouteMemory.tenant_id,
QARecord.id == QueryRouteMemory.qa_record_id,
),
)
.outerjoin(
QASession,
and_(
QASession.tenant_id == QueryRouteMemory.tenant_id,
QASession.id == QARecord.session_id,
),
)
.where(
QueryRouteMemory.tenant_id == int(self.tenant_id),
QueryRouteMemory.id == int(record_id),
)
)
if project_id is not None:
project_id = int(project_id)
stmt = stmt.where(
or_(
QueryRouteMemory.project_id == project_id,
QARecord.project_id == project_id,
QASession.project_id == project_id,
)
)
row = (await self.db.execute(stmt)).one_or_none()
if row is None:
return None
joined = _QueryRouteGovernanceRow(route_memory=row[0], qa_record=row[1], session=row[2])
return self._serialize_governance_detail(joined)

View File

@ -2,6 +2,12 @@
from __future__ import annotations from __future__ import annotations
from .shared import * from .shared import *
from .internal_query_context import (
internal_context_operation_contract,
internal_context_query_plan,
internal_context_semantic_mirror,
internal_context_target_resolution,
)
class AdaptiveRAGRetrievalRouterDBMixin: class AdaptiveRAGRetrievalRouterDBMixin:
@ -11,21 +17,31 @@ class AdaptiveRAGRetrievalRouterDBMixin:
entity_type: str, entity_type: str,
question: str, question: str,
query_semantics: Optional[dict[str, Any]] = None, query_semantics: Optional[dict[str, Any]] = None,
internal_context_packet: Any = None,
) -> list[RetrievedItem]: ) -> list[RetrievedItem]:
term_collector = getattr(self, "_collect_lookup_terms", None) term_collector = getattr(self, "_collect_lookup_terms", None)
operation_contract = internal_context_operation_contract(
packet=internal_context_packet,
fallback=internal_context_semantic_mirror(
packet=internal_context_packet,
query_semantics=query_semantics,
key="operation_contract",
),
)
target_resolution = internal_context_target_resolution(
packet=internal_context_packet,
fallback=internal_context_semantic_mirror(
packet=internal_context_packet,
query_semantics=query_semantics,
key="target_resolution",
),
)
query_terms = term_collector( query_terms = term_collector(
question=question, question=question,
query_semantics=query_semantics, query_semantics=query_semantics,
operation_contract=( operation_contract=operation_contract,
query_semantics.get("operation_contract") target_resolution=target_resolution,
if isinstance(query_semantics, dict) and isinstance(query_semantics.get("operation_contract"), dict) internal_context_packet=internal_context_packet,
else None
),
target_resolution=(
query_semantics.get("target_resolution")
if isinstance(query_semantics, dict) and isinstance(query_semantics.get("target_resolution"), dict)
else None
),
limit=4, limit=4,
) if callable(term_collector) else [] ) if callable(term_collector) else []
if not query_terms: if not query_terms:
@ -87,21 +103,31 @@ class AdaptiveRAGRetrievalRouterDBMixin:
question: str, question: str,
project_id: Optional[int], project_id: Optional[int],
query_semantics: Optional[dict[str, Any]] = None, query_semantics: Optional[dict[str, Any]] = None,
internal_context_packet: Any = None,
) -> list[RetrievedItem]: ) -> list[RetrievedItem]:
term_collector = getattr(self, "_collect_lookup_terms", None) term_collector = getattr(self, "_collect_lookup_terms", None)
operation_contract = internal_context_operation_contract(
packet=internal_context_packet,
fallback=internal_context_semantic_mirror(
packet=internal_context_packet,
query_semantics=query_semantics,
key="operation_contract",
),
)
target_resolution = internal_context_target_resolution(
packet=internal_context_packet,
fallback=internal_context_semantic_mirror(
packet=internal_context_packet,
query_semantics=query_semantics,
key="target_resolution",
),
)
query_terms = term_collector( query_terms = term_collector(
question=question, question=question,
query_semantics=query_semantics, query_semantics=query_semantics,
operation_contract=( operation_contract=operation_contract,
query_semantics.get("operation_contract") target_resolution=target_resolution,
if isinstance(query_semantics, dict) and isinstance(query_semantics.get("operation_contract"), dict) internal_context_packet=internal_context_packet,
else None
),
target_resolution=(
query_semantics.get("target_resolution")
if isinstance(query_semantics, dict) and isinstance(query_semantics.get("target_resolution"), dict)
else None
),
limit=4, limit=4,
) if callable(term_collector) else [] ) if callable(term_collector) else []
if not query_terms: if not query_terms:
@ -172,14 +198,29 @@ class AdaptiveRAGRetrievalRouterDBMixin:
*, *,
project_id: Optional[int], project_id: Optional[int],
query_semantics: Optional[dict[str, Any]] = None, query_semantics: Optional[dict[str, Any]] = None,
internal_context_packet: Any = None,
) -> list[RetrievedItem]: ) -> list[RetrievedItem]:
text = question.lower() text = question.lower()
rows: list[RetrievedItem] = [] rows: list[RetrievedItem] = []
subject_focus = self._query_subject_focus(query_semantics) 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 = query_semantics.get("target_resolution") if isinstance(query_semantics, dict) and isinstance(query_semantics.get("target_resolution"), dict) else {} target_resolution = internal_context_target_resolution(
packet=internal_context_packet,
fallback=internal_context_semantic_mirror(
packet=internal_context_packet,
query_semantics=query_semantics,
key="target_resolution",
),
)
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()
query_plan = query_semantics.get("query_plan") if isinstance(query_semantics, dict) and isinstance(query_semantics.get("query_plan"), dict) else {} query_plan = internal_context_query_plan(
packet=internal_context_packet,
fallback=internal_context_semantic_mirror(
packet=internal_context_packet,
query_semantics=query_semantics,
key="query_plan",
),
)
planned_entity_type = str(query_plan.get("entity_type") or "").strip().lower() planned_entity_type = str(query_plan.get("entity_type") or "").strip().lower()
project_subject_requested = subject_focus == "project" or target_entity_type == "project" or planned_entity_type == "project" project_subject_requested = subject_focus == "project" or target_entity_type == "project" or planned_entity_type == "project"
project_scope_required = scope_hint == "project" or project_id is not None project_scope_required = scope_hint == "project" or project_id is not None
@ -202,6 +243,7 @@ class AdaptiveRAGRetrievalRouterDBMixin:
entity_type=semantic_fact_entity_type, entity_type=semantic_fact_entity_type,
question=question, question=question,
query_semantics=query_semantics, query_semantics=query_semantics,
internal_context_packet=internal_context_packet,
) )
) )
@ -266,6 +308,7 @@ class AdaptiveRAGRetrievalRouterDBMixin:
question=question, question=question,
project_id=project_id, project_id=project_id,
query_semantics=query_semantics, query_semantics=query_semantics,
internal_context_packet=internal_context_packet,
) )
rows.extend(semantic_fact_rows) rows.extend(semantic_fact_rows)
stmt = select(Project).where(Project.tenant_id == self.tenant_id) stmt = select(Project).where(Project.tenant_id == self.tenant_id)

View File

@ -156,6 +156,24 @@ class AdaptiveRAGSessionStateMixin:
uniq.append(value) uniq.append(value)
return uniq[:20] return uniq[:20]
@staticmethod
def _extract_project_subject_from_answer(answer: str) -> str:
text = str(answer or "").strip()
if not text:
return ""
patterns = (
r"项目名(?:称)?[:: ]*[“\"]?([^”\",。;、\n]{2,80})[”\"]?",
r"项目名[“\"]([^”\"\n]{2,80})[”\"]",
r"项目\s*#\d+[《“\"]([^》”\"\n]{2,80})[》”\"]",
r"#\d+\s+([^\n((]{2,80})[((]",
r"项目[《“\"]([^》”\"\n]{2,80})[》”\"]",
)
for pattern in patterns:
match = re.search(pattern, text)
if match:
return str(match.group(1)).strip()[:255]
return ""
def _extract_state_subject( def _extract_state_subject(
self, self,
*, *,
@ -175,16 +193,16 @@ class AdaptiveRAGSessionStateMixin:
if subject_name and object_name: if subject_name and object_name:
return f"{subject_name}->{object_name}"[:255] return f"{subject_name}->{object_name}"[:255]
answer_subject = self._extract_project_subject_from_answer(answer)
if answer_subject:
return answer_subject[:255]
text = str(question or "").strip() text = str(question or "").strip()
normalizer = getattr(self, "_normalize_lookup_text", None) normalizer = getattr(self, "_normalize_lookup_text", None)
normalized_question = normalizer(text) if callable(normalizer) else text normalized_question = normalizer(text) if callable(normalizer) else text
if 2 <= len(normalized_question) <= 64: if 2 <= len(normalized_question) <= 64:
return normalized_question[:255] return normalized_question[:255]
answer_text = str(answer or "").strip()
m = re.search(r"项目[「\"]?([^」\"\n]{2,40})", answer_text)
if m:
return str(m.group(1)).strip()[:255]
return "" return ""
async def get_session_state_replay(self, *, session_id: int, user_id: int) -> dict[str, Any]: async def get_session_state_replay(self, *, session_id: int, user_id: int) -> dict[str, Any]:

View File

@ -2,6 +2,11 @@
from __future__ import annotations from __future__ import annotations
from .shared import * from .shared import *
from .internal_query_context import (
internal_context_operation_contract,
internal_context_query_plan,
internal_context_target_resolution,
)
class AdaptiveRAGTargetResolutionMixin: class AdaptiveRAGTargetResolutionMixin:
@ -19,10 +24,9 @@ class AdaptiveRAGTargetResolutionMixin:
mentions: list[str] = [] mentions: list[str] = []
rows = raw if isinstance(raw, list) else [] rows = raw if isinstance(raw, list) else []
for item in rows: for item in rows:
text = str(item or "").strip(" ,。,.!??::;;“”\"'()()") text = normalize_lookup_fragment(item, limit=32)
text = QUESTION_NOISE_RE.sub("", text).strip()
if len(text) >= 2 and text not in mentions: if len(text) >= 2 and text not in mentions:
mentions.append(text[:32]) mentions.append(text)
return mentions[:4] return mentions[:4]
def _build_operation_contract( def _build_operation_contract(
@ -60,6 +64,63 @@ class AdaptiveRAGTargetResolutionMixin:
"clarify_policy": "ask_when_target_ambiguous", "clarify_policy": "ask_when_target_ambiguous",
} }
@staticmethod
def _referenced_objects(query_semantics: Optional[dict[str, Any]]) -> set[str]:
semantics = query_semantics if isinstance(query_semantics, dict) else {}
query_profile = semantics.get("query_profile") if isinstance(semantics.get("query_profile"), dict) else {}
return {
str(item or "").strip().lower()
for item in list(query_profile.get("referenced_objects") or [])
if str(item or "").strip()
}
def _build_session_followup_target(
self,
*,
query_semantics: Optional[dict[str, Any]],
operation_contract: dict[str, Any],
) -> Optional[dict[str, Any]]:
semantics = query_semantics if isinstance(query_semantics, dict) else {}
followup_context = (
semantics.get("session_followup_context")
if isinstance(semantics.get("session_followup_context"), dict)
else {}
)
rewrite_mode = str(followup_context.get("rewrite_mode") or "").strip().lower()
if rewrite_mode not in {"state_subject", "state_entities"}:
return None
entity_ids: list[int] = []
for value in list(followup_context.get("last_entity_ids") or []):
try:
entity_ids.append(int(value))
except Exception:
continue
if len(entity_ids) != 1:
return None
subject_focus = str(semantics.get("subject_focus") or "unknown").strip().lower()
referenced_objects = self._referenced_objects(semantics)
if subject_focus != "project" and "project_object" not in referenced_objects:
return None
mentions = list(operation_contract.get("object_mentions") or [])
resolved_label = (
str(followup_context.get("last_subject") or "").strip()
or (str(mentions[0]).strip() if mentions else "")
or None
)
resolved_project_id = entity_ids[0]
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": [],
}
@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 {}
@ -585,6 +646,13 @@ class AdaptiveRAGTargetResolutionMixin:
"candidate_summary": [], "candidate_summary": [],
} }
session_bound_target = self._build_session_followup_target(
query_semantics=semantics,
operation_contract=operation,
)
if session_bound_target is not None:
return session_bound_target
candidates = await self._collect_internal_target_candidates( candidates = await self._collect_internal_target_candidates(
question=question, question=question,
project_id=project_id, project_id=project_id,
@ -754,12 +822,28 @@ class AdaptiveRAGTargetResolutionMixin:
*, *,
retrieval_metrics: Optional[dict[str, Any]], retrieval_metrics: Optional[dict[str, Any]],
query_semantics: Optional[dict[str, Any]], query_semantics: Optional[dict[str, Any]],
internal_context_packet: Any = None,
) -> dict[str, Any]: ) -> dict[str, Any]:
metrics = dict(retrieval_metrics or {}) metrics = dict(retrieval_metrics or {})
semantics = query_semantics if isinstance(query_semantics, dict) else {} semantics = query_semantics if isinstance(query_semantics, dict) else {}
metrics["operation_contract_summary"] = self._build_operation_contract_summary(semantics.get("operation_contract")) metrics["operation_contract_summary"] = self._build_operation_contract_summary(
metrics["target_resolution_summary"] = self._build_target_resolution_summary(semantics.get("target_resolution")) internal_context_operation_contract(
metrics["query_plan_summary"] = self._build_query_plan_summary(semantics.get("query_plan")) packet=internal_context_packet,
fallback=semantics.get("operation_contract"),
)
)
metrics["target_resolution_summary"] = self._build_target_resolution_summary(
internal_context_target_resolution(
packet=internal_context_packet,
fallback=semantics.get("target_resolution"),
)
)
metrics["query_plan_summary"] = self._build_query_plan_summary(
internal_context_query_plan(
packet=internal_context_packet,
fallback=semantics.get("query_plan"),
)
)
return metrics return metrics

View File

@ -41,6 +41,7 @@ from .mixin_conversation_utils_a_p2 import AdaptiveRAGConversationUtilsAMixinP2
from .mixin_conversation_utils_b import AdaptiveRAGConversationUtilsBMixin from .mixin_conversation_utils_b import AdaptiveRAGConversationUtilsBMixin
from .mixin_followup_anchor import AdaptiveRAGFollowupAnchorMixin from .mixin_followup_anchor import AdaptiveRAGFollowupAnchorMixin
from .mixin_query_route_gateway import AdaptiveRAGQueryRouteGatewayMixin from .mixin_query_route_gateway import AdaptiveRAGQueryRouteGatewayMixin
from .mixin_query_route_governance import AdaptiveRAGQueryRouteGovernanceMixin
from .mixin_target_resolution import AdaptiveRAGTargetResolutionMixin from .mixin_target_resolution import AdaptiveRAGTargetResolutionMixin
@ -86,6 +87,7 @@ class AdaptiveRAGService(
AdaptiveRAGConversationUtilsBMixin, AdaptiveRAGConversationUtilsBMixin,
AdaptiveRAGFollowupAnchorMixin, AdaptiveRAGFollowupAnchorMixin,
AdaptiveRAGQueryRouteGatewayMixin, AdaptiveRAGQueryRouteGatewayMixin,
AdaptiveRAGQueryRouteGovernanceMixin,
): ):
"""项目内统一 RAG 服务。""" """项目内统一 RAG 服务。"""
_symbol_snapshot_cache: dict[str, tuple[float, dict[str, Any]]] = {} _symbol_snapshot_cache: dict[str, tuple[float, dict[str, Any]]] = {}

View File

@ -1,13 +1,15 @@
"""Adaptive RAG shared constants/types part.""" """Adaptive RAG shared lightweight text helpers."""
from __future__ import annotations from __future__ import annotations
from .shared_imports import * from .shared_imports import *
from .shared_utils import * from .shared_utils import *
PROJECT_PROGRESS_RE = re.compile(r"(进度|阶段|完成度|完成率|推进|里程碑|状态)") _LOOKUP_TRIM_CHARS = " \t\r\n,。,.!??::;;“”\"'()()[]【】<>《》`"
QUESTION_NOISE_RE = re.compile(
r"(目前|当前|现在|请问|一下|系统中|项目中|系统内|项目内|是否|有没有|有无|有|吗|呢|呀|啊|什么|哪个|多少|查询|看看)"
) def normalize_lookup_fragment(value: Any, *, keep_spaces: bool = False, limit: int = 32) -> str:
BRIEF_NEGATIVE_FEEDBACK_RE = re.compile( text = str(value or "")
r"^(不是啊?|不对|错了|答错了?|跑偏了?|偏了|不是这个(意思)?|不(是|对)啊)$" text = re.sub(r"\s+", " " if keep_spaces else "", text).strip(_LOOKUP_TRIM_CHARS)
) if not text:
return ""
return text[: max(1, int(limit))]

View File

@ -48,7 +48,7 @@ __all__ = [
"WORKFLOW_ROUTE_KEYS", "WORKFLOW_ROUTE_KEYS",
"ROUTE_CANONICAL_INTENT_MAP", "ROUTE_CANONICAL_INTENT_MAP",
"LEGACY_INTENT_ROUTE_MAP", "LEGACY_INTENT_ROUTE_MAP",
"FOLLOWUP_WORK_ITEM_ID_RE", "extract_followup_work_item_id",
"INTENT_LLM_FUNCTION_NAME", "INTENT_LLM_FUNCTION_NAME",
"INTENT_CANDIDATES", "INTENT_CANDIDATES",
"WRITE_INTENTS", "WRITE_INTENTS",

View File

@ -20,7 +20,7 @@ class IntentResolver:
diagnostics = bundle.diagnostics if isinstance(bundle.diagnostics, dict) else {} diagnostics = bundle.diagnostics if isinstance(bundle.diagnostics, dict) else {}
if str(diagnostics.get("message_intent") or "").strip().lower() == "casual_chat": if str(diagnostics.get("message_intent") or "").strip().lower() == "casual_chat":
return CanonicalIntent(INTENT_IGNORE, 0.95, project_id, "", "", {"reason": "smalltalk"}) return CanonicalIntent(INTENT_IGNORE, 0.95, project_id, "", "", {"reason": "smalltalk"})
explicit_followup_target = FOLLOWUP_WORK_ITEM_ID_RE.search(pretty) explicit_followup_target = extract_followup_work_item_id(pretty)
if explicit_followup_target is not None: if explicit_followup_target is not None:
return CanonicalIntent( return CanonicalIntent(
INTENT_FOLLOWUP_CONTINUE, INTENT_FOLLOWUP_CONTINUE,
@ -30,7 +30,7 @@ class IntentResolver:
"", "",
{ {
"followup_by_work_item_id": True, "followup_by_work_item_id": True,
"target_work_item_id": _safe_int(explicit_followup_target.group(1)), "target_work_item_id": explicit_followup_target,
"semantic_bundle": bundle.to_dict(), "semantic_bundle": bundle.to_dict(),
}, },
) )

View File

@ -8,10 +8,25 @@ from typing import Any, Optional
from ...signal.signal_source_orchestration import SignalSourceOrchestrationService from ...signal.signal_source_orchestration import SignalSourceOrchestrationService
FOLLOWUP_WORK_ITEM_ID_RE = re.compile(
r"(?:work[_ -]?item|工作项|队列项|草稿)\s*(?:id|ID)?\s*[:#:]?\s*(\d+)", def extract_followup_work_item_id(text: Any) -> Optional[int]:
re.IGNORECASE, raw = str(text or "").strip()
) if not raw:
return None
hash_matches = re.findall(r"#\s*(\d{1,10})(?!\d)", raw)
if hash_matches:
unique = {int(item) for item in hash_matches}
return next(iter(unique)) if len(unique) == 1 else None
compact = re.sub(r"\s+", "", raw)
direct_match = re.fullmatch(r"(?:id[::#-]?)?(\d{1,10})", compact, flags=re.IGNORECASE)
if direct_match is None:
return None
try:
return int(direct_match.group(1))
except (TypeError, ValueError):
return None
INTENT_QUERY = "query.read_only" INTENT_QUERY = "query.read_only"
INTENT_PROJECT_CREATE = "project.create" INTENT_PROJECT_CREATE = "project.create"

View File

@ -154,8 +154,7 @@ class KernelFollowupVerificationMixin:
@staticmethod @staticmethod
def _extract_followup_work_item_id(message: str) -> Optional[int]: def _extract_followup_work_item_id(message: str) -> Optional[int]:
match = FOLLOWUP_WORK_ITEM_ID_RE.search(str(message or "")) return extract_followup_work_item_id(message)
return int(match.group(1)) if match else None
@staticmethod @staticmethod
def _build_followup_candidate_clarification(items: list[WorkItem]) -> str: def _build_followup_candidate_clarification(items: list[WorkItem]) -> str:

View File

@ -199,6 +199,7 @@ class KernelIntentResolveMixin:
message_sent_at: Optional[Any] = None, message_sent_at: Optional[Any] = None,
session_snapshot: Optional[dict[str, Any]] = None, session_snapshot: Optional[dict[str, Any]] = None,
thread_context: Optional[dict[str, Any]] = None, thread_context: Optional[dict[str, Any]] = None,
semantic_bundle_override: Optional[dict[str, Any]] = None,
) -> tuple[CanonicalIntent, str]: ) -> tuple[CanonicalIntent, str]:
reply_anchor_present = bool( reply_anchor_present = bool(
isinstance(thread_context, dict) isinstance(thread_context, dict)
@ -213,6 +214,17 @@ 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
workflow_bundle_override = WorkflowSemanticPlanner.deserialize_bundle(semantic_bundle_override)
if workflow_bundle_override is not None:
return (
self._build_intent_from_workflow_bundle(
workflow_bundle=workflow_bundle_override,
message_text=message_text,
world_model=world_model,
fallback_project_id=fallback_project_id,
),
"workflow_bundle",
)
planner_metadata = { planner_metadata = {
"project_id": _safe_int(world_model.get("resolved_project_id")), "project_id": _safe_int(world_model.get("resolved_project_id")),
"world_model_project_id": _safe_int(world_model.get("resolved_project_id")), "world_model_project_id": _safe_int(world_model.get("resolved_project_id")),

View File

@ -73,7 +73,7 @@ from ..ai_pmo_decision import DecisionEngine
from ..ai_pmo_kernel import SignalSourceDomainWorkflow from ..ai_pmo_kernel import SignalSourceDomainWorkflow
from ..ai_pmo_intent import ( from ..ai_pmo_intent import (
FOLLOWUP_TARGET_INTENTS, FOLLOWUP_TARGET_INTENTS,
FOLLOWUP_WORK_ITEM_ID_RE, extract_followup_work_item_id,
INTENT_CANDIDATES, INTENT_CANDIDATES,
INTENT_CONFLICT_POLICIES, INTENT_CONFLICT_POLICIES,
INTENT_FOLLOWUP_CONFIRM, INTENT_FOLLOWUP_CONFIRM,

View File

@ -1,11 +1,14 @@
from __future__ import annotations from __future__ import annotations
from datetime import datetime, timedelta
from .shared import * from .shared import *
from app.utils.thread_context import build_feishu_conversation_scope_key from app.utils.thread_context import build_feishu_conversation_scope_key
from app.services.ai_pmo.semantic_context import ( from app.services.ai_pmo.semantic_context import (
SemanticContextAssembler, SemanticContextAssembler,
SemanticDecisionGatekeeper, SemanticDecisionGatekeeper,
) )
from app.services.ai_pmo.semantic_context.decision_schema import SemanticDecision
from app.services.ai_pmo.semantic_context.semantic_decision_service import SemanticDecisionService from app.services.ai_pmo.semantic_context.semantic_decision_service import SemanticDecisionService
from app.services.ai_pmo.workflow_semantic_planner import WorkflowSemanticPlanner from app.services.ai_pmo.workflow_semantic_planner import WorkflowSemanticPlanner
from app.services.ai_pmo.workflow_semantic_planner.constants import ( from app.services.ai_pmo.workflow_semantic_planner.constants import (
@ -178,6 +181,245 @@ class KernelIngressFlowEntryMixin:
return base_question return base_question
return f"{base_question}。补充要求:{followup_text}"[:1200] return f"{base_question}。补充要求:{followup_text}"[:1200]
@classmethod
def _should_short_circuit_query_pending_clarification_followup(
cls,
*,
message_text: str,
thread_context: Optional[dict[str, Any]],
) -> bool:
if cls._has_explicit_thread_anchor(thread_context):
return True
compact = cls._normalize_compact_text(message_text)
return bool(compact) and len(compact) <= 24
@staticmethod
def _is_query_pending_clarification_expired(expires_at: Any) -> bool:
if expires_at is None:
return False
if isinstance(expires_at, datetime):
normalized = (
expires_at.astimezone(CN_TZ).replace(tzinfo=None)
if expires_at.tzinfo is not None
else expires_at
)
else:
return False
return normalized < now_cn_naive()
async def _load_query_pending_clarification_context(
self,
*,
source_system: str,
channel_ref: Optional[str],
source_sender_open_id: Optional[str],
thread_context: Optional[dict[str, Any]],
) -> Optional[dict[str, Any]]:
if not hasattr(self.db, "execute"):
return None
from app.models.feishu_reply_outbox import FeishuReplyOutbox
from app.models.qa_record import QARecord
from app.models.qa_session import QASession
from app.models.qa_session_state import QASessionState
normalized_source = str(source_system or "").strip().lower()
conversation_key = (
build_feishu_conversation_scope_key(
source_chat_id=channel_ref,
source_sender_open_id=source_sender_open_id,
sender=None,
)
if normalized_source == "feishu"
else None
)
anchored_session_id: Optional[int] = None
reply_to_message_id = (
str((thread_context or {}).get("reply_to_message_id") or "").strip()
if isinstance(thread_context, dict)
else ""
)
if reply_to_message_id:
anchor_message_ids = {reply_to_message_id}
outbox_row = (
await self.db.execute(
select(FeishuReplyOutbox)
.where(
and_(
FeishuReplyOutbox.tenant_id == int(self.tenant_id),
or_(
FeishuReplyOutbox.sent_message_id == reply_to_message_id,
FeishuReplyOutbox.source_message_id == reply_to_message_id,
),
)
)
.order_by(FeishuReplyOutbox.id.desc())
.limit(1)
)
).scalars().first()
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)
anchor_record = (
await self.db.execute(
select(QARecord)
.where(
and_(
QARecord.tenant_id == int(self.tenant_id),
QARecord.source_message_id.in_(list(anchor_message_ids)),
QARecord.session_id.is_not(None),
)
)
.order_by(QARecord.updated_at.desc(), QARecord.id.desc())
.limit(1)
)
).scalars().first()
if anchor_record is not None and getattr(anchor_record, "session_id", None) is not None:
anchored_session_id = int(anchor_record.session_id)
state_row = None
session_row = None
if anchored_session_id is not None:
joined = (
await self.db.execute(
select(QASessionState, QASession)
.join(QASession, QASession.id == QASessionState.session_id)
.where(
and_(
QASessionState.tenant_id == int(self.tenant_id),
QASession.id == int(anchored_session_id),
)
)
.limit(1)
)
).first()
if joined is not None:
state_row, session_row = joined
if state_row is None and conversation_key:
joined = (
await self.db.execute(
select(QASessionState, QASession)
.join(QASession, QASession.id == QASessionState.session_id)
.where(
and_(
QASessionState.tenant_id == int(self.tenant_id),
QASession.tenant_id == int(self.tenant_id),
QASession.external_conversation_key == conversation_key,
)
)
.limit(1)
)
).first()
if joined is not None:
state_row, session_row = joined
if state_row is None or session_row is None:
return None
pending = (
state_row.pending_clarification
if isinstance(getattr(state_row, "pending_clarification", None), dict)
else None
)
if self._is_query_pending_clarification_expired(
getattr(state_row, "pending_clarification_expires_at", None)
):
return None
if not pending or str(pending.get("kind") or "").strip().lower() != "query_route_selection":
return None
return {
"session_id": int(getattr(session_row, "id", 0) or 0) or None,
"conversation_key": str(
getattr(session_row, "external_conversation_key", "") or conversation_key or ""
).strip()
or conversation_key,
"pending_clarification": pending,
}
async def _maybe_handle_query_pending_clarification_followup(
self,
*,
source_system: str,
source_message_id: str,
channel_ref: Optional[str],
source_sender_open_id: Optional[str],
message_text: str,
thread_context: Optional[dict[str, Any]],
) -> Optional[dict[str, Any]]:
if not self._should_short_circuit_query_pending_clarification_followup(
message_text=message_text,
thread_context=thread_context,
):
return None
try:
pending_context = await self._load_query_pending_clarification_context(
source_system=source_system,
channel_ref=channel_ref,
source_sender_open_id=source_sender_open_id,
thread_context=thread_context,
)
except Exception as exc:
logger.warning(
"query_pending_clarification_context_lookup_failed",
tenant_id=self.tenant_id,
source_system=source_system,
source_message_id=source_message_id,
error=str(exc),
)
return None
if pending_context is None:
return None
rag_service = AdaptiveRAGService(self.db, tenant_id=self.tenant_id)
rag_kwargs = {
"question": str(message_text or "").strip(),
"asked_by": None,
"asked_via": source_system,
"project_id": None,
"source_chat_id": channel_ref,
"source_message_id": source_message_id,
"conversation_key": pending_context.get("conversation_key"),
"source_sender_open_id": source_sender_open_id,
"thread_context": thread_context,
"action_mode": "read",
"sync_requirement": False,
}
if pending_context.get("session_id") is not None:
rag_kwargs["session_id"] = int(pending_context["session_id"])
try:
rag_result = await rag_service.answer_question(**rag_kwargs)
except Exception as exc:
logger.warning(
"query_pending_clarification_followup_failed",
tenant_id=self.tenant_id,
source_system=source_system,
source_message_id=source_message_id,
error=str(exc),
)
return None
answer_text = str(rag_result.get("answer") or "").strip()
if not answer_text:
return None
return self._build_active_context_direct_reply_result(
answer=answer_text,
reason_code="query_pending_clarification_followup",
payload={
"answer": answer_text,
"grounding_status": str(rag_result.get("grounding_status") or "").strip() or None,
"policy_version": str(rag_result.get("policy_version") or "").strip() or None,
"sources_used": rag_result.get("sources_used"),
"citations": rag_result.get("citations"),
"rag_result": rag_result,
},
)
async def _maybe_handle_external_freshness_followup( async def _maybe_handle_external_freshness_followup(
self, self,
*, *,
@ -316,6 +558,26 @@ class KernelIngressFlowEntryMixin:
def _supports_mixed_sidecar(*, source_system: str) -> bool: def _supports_mixed_sidecar(*, source_system: str) -> bool:
return str(source_system or "").strip().lower() in {"feishu", "pmflow_manual", "ai_pmo_ui"} return str(source_system or "").strip().lower() in {"feishu", "pmflow_manual", "ai_pmo_ui"}
@staticmethod
def _should_skip_mixed_sidecar_classify_for_bundle(
semantic_bundle: Optional[dict[str, Any]],
) -> bool:
if not isinstance(semantic_bundle, dict) or not semantic_bundle:
return False
diagnostics = semantic_bundle.get("diagnostics") if isinstance(semantic_bundle.get("diagnostics"), dict) else {}
message_intent = str(diagnostics.get("message_intent") or "").strip().lower()
if message_intent == "mixed":
return False
workflow_types = [
str(item.get("workflow_type") or "").strip().lower()
for item in (semantic_bundle.get("workflows") or [])
if isinstance(item, dict) and str(item.get("workflow_type") or "").strip()
]
if len(set(workflow_types)) > 1:
return False
primary_workflow = str(semantic_bundle.get("primary_workflow") or "").strip().lower()
return bool(message_intent or workflow_types or primary_workflow)
@staticmethod @staticmethod
def _has_explicit_thread_anchor(thread_context: Optional[dict[str, Any]]) -> bool: def _has_explicit_thread_anchor(thread_context: Optional[dict[str, Any]]) -> bool:
return isinstance(thread_context, dict) and any( return isinstance(thread_context, dict) and any(
@ -323,6 +585,84 @@ 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 _build_semantic_shadow_decision_from_bundle(
*,
workflow_route: str,
route_metadata: Optional[dict[str, Any]],
semantic_bundle: Optional[dict[str, Any]],
) -> SemanticDecision:
metadata = dict(route_metadata or {}) if isinstance(route_metadata, dict) else {}
bundle = dict(semantic_bundle or {}) if isinstance(semantic_bundle, dict) else {}
workflow_plans: list[dict[str, Any]] = []
for item in (bundle.get("workflows") or []):
if not isinstance(item, dict):
continue
workflow_type = str(item.get("workflow_type") or "").strip().lower()
workflow_action = str(item.get("workflow_action") or "").strip().lower()
target_scope = str(item.get("target_scope") or "global").strip().lower() or "global"
if workflow_type not in {
"query_flow",
"new_project_flow",
"project_flow",
"task_flow",
"signal_source_flow",
"project_signal_flow",
"followup_flow",
}:
continue
if target_scope not in {"global", "project", "thread"}:
target_scope = "global"
workflow_plans.append(
{
"workflow_type": workflow_type,
"workflow_action": workflow_action or "answer",
"target_scope": target_scope,
"operation_codes": list(item.get("operation_codes") or []),
"requires_confirmation": bool(item.get("requires_confirmation")),
"confidence": float(item.get("confidence") or 0.0),
"clarification_needed": bool(item.get("clarification_needed")),
"metadata": dict(item.get("metadata") or {}) if isinstance(item.get("metadata"), dict) else {},
}
)
scene_metadata = {
key: value
for key in (
"message_intent",
"review_framework",
"consultant_mode_preference",
"force_thinking_mode",
"advise_subtype",
"topic_switch",
)
if (value := metadata.get(key)) not in (None, "")
}
normalized_route = str(workflow_route or "").strip().lower()
mode_decision = "stay_plan"
requested_actions: list[str] = []
if normalized_route.startswith("query."):
mode_decision = "answer_only"
requested_actions = ["reply.compose"]
elif bool(metadata.get("requires_confirmation")):
mode_decision = "ready_confirm"
requested_actions = ["workflow.bundle.compose"]
return SemanticDecision(
topic_relation="continue_current_topic",
mode_decision=mode_decision,
workflow_plans=workflow_plans,
scene_metadata=scene_metadata,
assistant_reply="",
requires_confirmation=bool(metadata.get("requires_confirmation")),
requested_actions=requested_actions,
confidence=max(
float(metadata.get("confidence") or 0.0),
max((float(item.get("confidence") or 0.0) for item in workflow_plans), default=0.0),
),
uncertainties=["shadow_reused_upstream_bundle"],
)
@classmethod @classmethod
def _build_forced_query_scene_metadata( def _build_forced_query_scene_metadata(
cls, cls,
@ -510,12 +850,15 @@ class KernelIngressFlowEntryMixin:
thread_context: Optional[dict[str, Any]], thread_context: Optional[dict[str, Any]],
message_text: str, message_text: str,
project_hint_id: Optional[int], project_hint_id: Optional[int],
semantic_bundle: Optional[dict[str, Any]] = None,
) -> tuple[str, dict[str, Any]]: ) -> tuple[str, dict[str, Any]]:
normalized_text = str(message_text or "").strip() normalized_text = str(message_text or "").strip()
if not self._supports_mixed_sidecar(source_system=source_system): if not self._supports_mixed_sidecar(source_system=source_system):
return normalized_text, {} return normalized_text, {}
if not normalized_text: if not normalized_text:
return normalized_text, {} return normalized_text, {}
if self._should_skip_mixed_sidecar_classify_for_bundle(semantic_bundle):
return normalized_text, {}
if self._has_explicit_thread_anchor(thread_context): if self._has_explicit_thread_anchor(thread_context):
try: try:
from ..ai_pmo_typed_executor_execute.mixin_execute_query_project import ExecuteQueryProjectMixin from ..ai_pmo_typed_executor_execute.mixin_execute_query_project import ExecuteQueryProjectMixin
@ -785,6 +1128,18 @@ class KernelIngressFlowEntryMixin:
"topic": packet.topic.model_dump(mode="json"), "topic": packet.topic.model_dump(mode="json"),
"draft": packet.draft.model_dump(mode="json") if packet.draft is not None else None, "draft": packet.draft.model_dump(mode="json") if packet.draft is not None else None,
} }
semantic_bundle = (
route_metadata.get("semantic_bundle")
if isinstance(route_metadata, dict) and isinstance(route_metadata.get("semantic_bundle"), dict)
else None
)
if isinstance(semantic_bundle, dict) and semantic_bundle:
decision = self._build_semantic_shadow_decision_from_bundle(
workflow_route=workflow_route,
route_metadata=route_metadata,
semantic_bundle=semantic_bundle,
)
else:
decision = await SemanticDecisionService(llm_service).decide(packet=packet) decision = await SemanticDecisionService(llm_service).decide(packet=packet)
gate_result = SemanticDecisionGatekeeper.evaluate( gate_result = SemanticDecisionGatekeeper.evaluate(
mode_card=packet.mode_card, mode_card=packet.mode_card,
@ -1029,6 +1384,17 @@ class KernelIngressFlowEntryMixin:
if active_context_followup_result is not None: if active_context_followup_result is not None:
await _safe_finalize_mounted_runtime(result=active_context_followup_result) await _safe_finalize_mounted_runtime(result=active_context_followup_result)
return active_context_followup_result return active_context_followup_result
pending_query_clarification_result = await self._maybe_handle_query_pending_clarification_followup(
source_system=source_system,
source_message_id=synthetic_message_id,
channel_ref=channel_ref,
source_sender_open_id=source_sender_open_id,
message_text=clean_text,
thread_context=thread_context,
)
if pending_query_clarification_result is not None:
await _safe_finalize_mounted_runtime(result=pending_query_clarification_result)
return pending_query_clarification_result
external_freshness_followup_result = await self._maybe_handle_external_freshness_followup( external_freshness_followup_result = await self._maybe_handle_external_freshness_followup(
source_system=source_system, source_system=source_system,
source_message_id=synthetic_message_id, source_message_id=synthetic_message_id,
@ -1041,6 +1407,11 @@ class KernelIngressFlowEntryMixin:
await _safe_finalize_mounted_runtime(result=external_freshness_followup_result) await _safe_finalize_mounted_runtime(result=external_freshness_followup_result)
return external_freshness_followup_result return external_freshness_followup_result
mixed_meta: dict[str, Any] = {} mixed_meta: dict[str, Any] = {}
semantic_bundle_override = (
ingress_options.get("semantic_bundle")
if isinstance(ingress_options, dict) and isinstance(ingress_options.get("semantic_bundle"), dict)
else None
)
clean_text, mixed_meta = await self._prepare_mixed_sidecar_context( clean_text, mixed_meta = await self._prepare_mixed_sidecar_context(
source_system=source_system, source_system=source_system,
source_message_id=synthetic_message_id, source_message_id=synthetic_message_id,
@ -1049,6 +1420,7 @@ class KernelIngressFlowEntryMixin:
thread_context=thread_context, thread_context=thread_context,
message_text=clean_text, message_text=clean_text,
project_hint_id=project_hint_id, project_hint_id=project_hint_id,
semantic_bundle=semantic_bundle_override,
) )
perf_work_item_id = int(work_item.id) perf_work_item_id = int(work_item.id)
try: try:
@ -1067,11 +1439,6 @@ class KernelIngressFlowEntryMixin:
if isinstance(ingress_options, dict) if isinstance(ingress_options, dict)
else None else None
) )
semantic_bundle_override = (
ingress_options.get("semantic_bundle")
if isinstance(ingress_options, dict) and isinstance(ingress_options.get("semantic_bundle"), 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,
@ -1080,6 +1447,7 @@ class KernelIngressFlowEntryMixin:
message_sent_at=message_sent_at, message_sent_at=message_sent_at,
session_snapshot=session_snapshot, session_snapshot=session_snapshot,
thread_context=thread_context, thread_context=thread_context,
semantic_bundle_override=semantic_bundle_override,
) )
intent = self._coerce_intent_for_query_scene( intent = self._coerce_intent_for_query_scene(
intent=intent, intent=intent,

View File

@ -2715,36 +2715,8 @@ class ExecuteQueryProjectMixin:
@classmethod @classmethod
def _requirement_plan_packet_allows_deployment_context(cls, *, packet: dict[str, Any]) -> bool: def _requirement_plan_packet_allows_deployment_context(cls, *, packet: dict[str, Any]) -> bool:
context_material = { _ = packet
"latest_user_message": packet.get("latest_user_message"), return False
"latest_user_reply_summary": packet.get("latest_user_reply_summary"),
"current_context": packet.get("current_context"),
"collected_fields": packet.get("collected_fields"),
"collected_field_summary": packet.get("collected_field_summary"),
}
context = WorkflowSemanticPlanner._normalize_text(json.dumps(context_material, ensure_ascii=False))
deployment_hints = (
"本地路由层",
"批量自动化部署",
"自动化部署",
"部署治理",
"发布治理",
"发布平台",
"部署平台",
"云原生交付底座",
"云原生交付版",
"任务编排平台版",
"平台运维",
"环境接入闭环",
"回滚",
"审批",
"kubernetes",
"k8s",
"helm",
"argocd",
"terraform",
)
return any(term in context for term in deployment_hints)
@classmethod @classmethod
def _render_requirement_plan_round_payload(cls, payload: dict[str, Any]) -> str: def _render_requirement_plan_round_payload(cls, payload: dict[str, Any]) -> str:
@ -2791,29 +2763,8 @@ class ExecuteQueryProjectMixin:
@classmethod @classmethod
def _requirement_plan_context_allows_deployment_terms(cls, *, context_material: Any) -> bool: def _requirement_plan_context_allows_deployment_terms(cls, *, context_material: Any) -> bool:
context = WorkflowSemanticPlanner._normalize_text(json.dumps(context_material, ensure_ascii=False)) _ = context_material
deployment_hints = ( return False
"本地路由层",
"批量自动化部署",
"自动化部署",
"部署治理",
"发布治理",
"发布平台",
"部署平台",
"云原生交付底座",
"云原生交付版",
"任务编排平台版",
"平台运维",
"环境接入闭环",
"回滚",
"审批",
"kubernetes",
"k8s",
"helm",
"argocd",
"terraform",
)
return any(term in context for term in deployment_hints)
@classmethod @classmethod
def _build_requirement_plan_draft_packet( def _build_requirement_plan_draft_packet(

View File

@ -47,8 +47,6 @@ class DraftConfirmHandler:
"收到,这是展示形式反馈:卡片可以更完整,聊天直发我会控制得更短;当前草稿内容我先不重起。" "收到,这是展示形式反馈:卡片可以更完整,聊天直发我会控制得更短;当前草稿内容我先不重起。"
) )
_WORK_ITEM_ID_RE = re.compile(r"(?:work[_ -]?item|工作项|草稿)\s*(?:id|ID)?\s*[:#:]?\s*(\d+)", re.IGNORECASE)
@classmethod @classmethod
def is_explicit_protocol_followup_text(cls, text: str) -> bool: def is_explicit_protocol_followup_text(cls, text: str) -> bool:
normalized_text = cls._normalize_protocol_text(text) normalized_text = cls._normalize_protocol_text(text)
@ -1621,10 +1619,7 @@ class DraftConfirmHandler:
@classmethod @classmethod
def _extract_work_item_id(cls, text: str) -> Optional[int]: def _extract_work_item_id(cls, text: str) -> Optional[int]:
matched = cls._WORK_ITEM_ID_RE.search(str(text or "")) return extract_followup_work_item_id(text)
if matched is None:
return None
return _safe_int(matched.group(1))
@staticmethod @staticmethod
def _safe_float(value: Any) -> Optional[float]: def _safe_float(value: Any) -> Optional[float]:

View File

@ -12,25 +12,25 @@ class BlockerDetectionAiMixin:
self, self,
code_content: str, code_content: str,
file_path: str, file_path: str,
pattern: str,
tenant_id: int, tenant_id: int,
) -> Dict[str, Any]: ) -> Dict[str, Any]:
"""AI 分析代码阻塞。""" """AI 分析代码阻塞。"""
prompt = f"""分析以下代码注释中提到的阻塞问题: prompt = f"""分析以下代码注释中提到的阻塞问题:
文件:{file_path} 文件:{file_path}
匹配到的模式:{pattern}
代码内容: 代码内容:
{code_content} {code_content}
请分析: 请分析:
1. 阻塞的根本原因是什么? 1. 这里是否明确表达了阻塞、依赖卡住或无法继续推进?
2. 严重程度(high/medium/low)? 2. 如果是阻塞,根本原因是什么?
3. 可能的解决方案有哪些? 3. 严重程度(high/medium/low)?
4. 是否需要创建新任务来跟踪? 4. 可能的解决方案有哪些?
5. 是否需要创建新任务来跟踪?
以 JSON 格式返回: 以 JSON 格式返回:
{{ {{
"is_blocker": boolean,
"problem_analysis": "问题分析", "problem_analysis": "问题分析",
"severity": "high/medium/low", "severity": "high/medium/low",
"suggested_solutions": ["方案1", "方案2"], "suggested_solutions": ["方案1", "方案2"],
@ -52,9 +52,10 @@ class BlockerDetectionAiMixin:
return json.loads(response.strip()) return json.loads(response.strip())
except Exception: except Exception:
return { return {
"problem_analysis": f"检测到可能的阻塞:{pattern}", "is_blocker": False,
"problem_analysis": "",
"severity": "medium", "severity": "medium",
"suggested_solutions": ["请团队负责人评估"], "suggested_solutions": [],
"requires_new_task": False, "requires_new_task": False,
} }

View File

@ -2,7 +2,7 @@
from __future__ import annotations from __future__ import annotations
from .shared import Any, BLOCKER_PATTERNS, CognitiveConflict, Dict, List, Task, json, now_cn_naive, re, select, text from .shared import Any, CognitiveConflict, Dict, List, Task, json, now_cn_naive, select, text
class BlockerDetectionScanMixin: class BlockerDetectionScanMixin:
@ -18,21 +18,17 @@ class BlockerDetectionScanMixin:
tenant_id: int = 1, tenant_id: int = 1,
) -> Dict[str, Any] | None: ) -> Dict[str, Any] | None:
"""扫描代码注释,检测阻塞。""" """扫描代码注释,检测阻塞。"""
detected_pattern = None normalized_content = str(code_content or "").strip()
for pattern in BLOCKER_PATTERNS: if len(normalized_content) < 8:
if re.search(pattern, code_content, re.IGNORECASE):
detected_pattern = pattern
break
if not detected_pattern:
return None return None
analysis = await self._analyze_blocker_with_ai( analysis = await self._analyze_blocker_with_ai(
code_content=code_content, code_content=normalized_content,
file_path=file_path, file_path=file_path,
pattern=detected_pattern,
tenant_id=tenant_id, tenant_id=tenant_id,
) )
if not bool(analysis.get("is_blocker")):
return None
next_conflict_id = ( next_conflict_id = (
await self.db.execute(text("SELECT COALESCE(MAX(id),0)+1 FROM cognitive_conflicts")) await self.db.execute(text("SELECT COALESCE(MAX(id),0)+1 FROM cognitive_conflicts"))

View File

@ -4,7 +4,6 @@ from __future__ import annotations
import asyncio import asyncio
import json import json
import re
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
from typing import Any, Dict, List, Optional from typing import Any, Dict, List, Optional
@ -16,29 +15,9 @@ from ...models import CognitiveConflict, Project, Signal, Task, TruthNode, User
from ...utils.timezone import now_cn_naive from ...utils.timezone import now_cn_naive
from ..llm_service import LLMService from ..llm_service import LLMService
BLOCKER_PATTERNS = [
r"等待.*接口",
r"等待.*API",
r"等待.*确定",
r"等待.*完成",
r"blocked by",
r"blocked due to",
r"can't proceed until",
r"暂时跳过",
r"TODO.*等待",
r"FIXME.*等待",
r"HACK.*临时",
r"达不到效果",
r"无法.*因为",
r"pending.*approval",
r"waiting for",
r"blocked on",
]
__all__ = [ __all__ = [
"Any", "Any",
"AsyncSession", "AsyncSession",
"BLOCKER_PATTERNS",
"CognitiveConflict", "CognitiveConflict",
"Dict", "Dict",
"LLMService", "LLMService",
@ -53,7 +32,6 @@ __all__ = [
"datetime", "datetime",
"json", "json",
"now_cn_naive", "now_cn_naive",
"re",
"select", "select",
"text", "text",
"timedelta", "timedelta",

View File

@ -2,9 +2,11 @@ from __future__ import annotations
from .gateway import ExternalQueryGateway from .gateway import ExternalQueryGateway
from .planner import ExternalQueryPlanner from .planner import ExternalQueryPlanner
from .presenter import ExternalAnswerPresenter
from .registry import ExternalProviderRegistry from .registry import ExternalProviderRegistry
from .schemas import ( from .schemas import (
ExternalAdapterResult, ExternalAdapterResult,
ExternalContextPacket,
ExternalEvidenceItem, ExternalEvidenceItem,
ExternalQueryExecution, ExternalQueryExecution,
ExternalQueryPlan, ExternalQueryPlan,
@ -12,6 +14,8 @@ from .schemas import (
__all__ = [ __all__ = [
"ExternalAdapterResult", "ExternalAdapterResult",
"ExternalAnswerPresenter",
"ExternalContextPacket",
"ExternalEvidenceItem", "ExternalEvidenceItem",
"ExternalProviderRegistry", "ExternalProviderRegistry",
"ExternalQueryExecution", "ExternalQueryExecution",

View File

@ -0,0 +1,184 @@
from __future__ import annotations
from typing import Any
from ...config.ai_prompts import (
PROMPT_EXTERNAL_COMPARE_WITH_SOURCES_QA,
PROMPT_EXTERNAL_DIRECT_FACT_QA,
PROMPT_EXTERNAL_SUMMARY_WITH_SOURCES_QA,
)
from ..prompt_release import PromptReleaseService
from .schemas import ExternalContextPacket
class ExternalAnswerPresenter:
_PROMPT_CONFIG = {
"direct_fact": (
"PROMPT_EXTERNAL_DIRECT_FACT_QA",
PROMPT_EXTERNAL_DIRECT_FACT_QA,
"external_direct_fact",
),
"summary_with_sources": (
"PROMPT_EXTERNAL_SUMMARY_WITH_SOURCES_QA",
PROMPT_EXTERNAL_SUMMARY_WITH_SOURCES_QA,
"external_summary_with_sources",
),
"compare_with_sources": (
"PROMPT_EXTERNAL_COMPARE_WITH_SOURCES_QA",
PROMPT_EXTERNAL_COMPARE_WITH_SOURCES_QA,
"external_compare_with_sources",
),
}
def __init__(
self,
*,
llm_service: Any,
db: Any,
tenant_id: int,
) -> None:
self.llm_service = llm_service
self.db = db
self.tenant_id = int(tenant_id or 1)
@classmethod
def _normalize_contract(cls, value: Any) -> str:
normalized = str(value or "").strip().lower()
if normalized in cls._PROMPT_CONFIG:
return normalized
return "summary_with_sources"
@staticmethod
def _build_evidence_blocks(packet: ExternalContextPacket) -> str:
blocks: list[str] = []
for idx, item in enumerate(list(packet.verified_evidence or [])[:5], start=1):
source_label = str(item.source_label or item.title or item.target_url or "").strip()
title = str(item.title or "").strip()
snippet = str(item.snippet or "").strip()
target_url = str(item.target_url or "").strip()
block_lines = [f"[{idx}] 来源: {source_label or '外部来源'}"]
if title and title != source_label:
block_lines.append(f"标题: {title}")
if snippet:
block_lines.append(f"证据: {snippet}")
if target_url:
block_lines.append(f"链接: {target_url}")
blocks.append("\n".join(block_lines))
return "\n\n".join(blocks).strip() or "无"
@staticmethod
def _build_source_summaries(packet: ExternalContextPacket) -> str:
labels: list[str] = []
for item in list(packet.verified_evidence or [])[:5]:
label = str(item.source_label or item.title or item.target_url or "").strip()
if label and label not in labels:
labels.append(label)
return ";".join(labels[:5]) if labels else "无"
@staticmethod
def _build_plan_summary(packet: ExternalContextPacket) -> str:
plan = packet.plan
rows = [
f"主题: {str(plan.topic or '').strip() or '未指定'}",
f"回答合同: {str(plan.answer_contract or '').strip() or 'summary_with_sources'}",
]
if plan.time_range_hint:
rows.append(f"时间范围: {plan.time_range_hint}")
if plan.location_hint:
rows.append(f"地点: {plan.location_hint}")
if plan.subject_entities:
rows.append(f"对象: {'、'.join([str(item).strip() for item in plan.subject_entities[:5] if str(item).strip()])}")
return "\n".join(rows)
async def _render_prompt(
self,
*,
prompt_key: str,
fallback_template: str,
**kwargs: Any,
) -> str:
if not hasattr(self.db, "execute"):
return str(fallback_template).format(**kwargs).strip()
return await PromptReleaseService(self.db).render_prompt(
tenant_id=self.tenant_id,
prompt_key=prompt_key,
fallback_template=fallback_template,
**kwargs,
)
async def present(
self,
*,
question: str,
context_packet: dict[str, Any] | ExternalContextPacket,
thinking_mode: str,
answer_max_tokens: int | None = None,
) -> dict[str, str]:
packet = (
context_packet
if isinstance(context_packet, ExternalContextPacket)
else ExternalContextPacket.model_validate(context_packet)
)
verifier = dict(packet.verifier or {})
if not packet.verified_evidence or not bool(verifier.get("ok")):
return {
"answer_text": (
f"当前关于“{str(question or '').strip()[:80]}”的外部证据不足。"
"请补充更明确的对象、范围或继续提供可核验来源后,我再继续分析。"
),
"prompt_variant_id": "external_clarify",
}
contract = self._normalize_contract(packet.plan.answer_contract)
prompt_key, fallback_template, prompt_variant_id = self._PROMPT_CONFIG[contract]
prompt = await self._render_prompt(
prompt_key=prompt_key,
fallback_template=fallback_template,
question=str(question or "").strip(),
plan_summary=self._build_plan_summary(packet),
evidence_blocks=self._build_evidence_blocks(packet),
source_summaries=self._build_source_summaries(packet),
time_range=str(packet.plan.time_range_hint or "").strip() or "未指定",
uncertainties=";".join([str(item).strip() for item in list(packet.uncertainties or []) if str(item).strip()]) or "无",
)
deep_thinking = str(thinking_mode or "").strip().lower() == "deep"
if deep_thinking:
prompt = (
f"{prompt}\n\n"
"补充要求(深入思考模式):\n"
"1. 先给结论,再给证据链;\n"
"2. 明确区分已验证事实与推断;\n"
"3. 若存在不确定项,给出待确认清单。"
)
effective_max_tokens = max(256, min(int(answer_max_tokens or (1400 if deep_thinking else 900)), 3000))
routing_meta = None
function_name = "adaptive_rag_answer_deep" if deep_thinking else "adaptive_rag_answer_normal"
if deep_thinking:
routing_meta = {
"tier_alias": "low",
"planned_tier": "low",
"executed_tier": "low",
"response_contract": "complete",
"fallback_policy": "low_to_high",
"execution_mode": "query",
"routing_decision": "force_low_first",
}
answer = await self.llm_service.generate(
prompt,
db=self.db,
tenant_id=self.tenant_id,
function_name=function_name,
temperature=0.15 if deep_thinking else 0.2,
max_tokens=effective_max_tokens,
use_prompt_profile=False,
routing_meta=routing_meta,
)
text = str(answer or "").strip()
if not text:
raise ValueError("external_answer_presenter_empty_response")
return {
"answer_text": text,
"prompt_variant_id": prompt_variant_id,
}

View File

@ -60,3 +60,33 @@ class ExternalQueryExecution(BaseModel):
verifier: dict[str, Any] = Field(default_factory=dict) verifier: dict[str, Any] = Field(default_factory=dict)
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 to_context_packet(self, *, question: str) -> "ExternalContextPacket":
verifier = dict(self.verifier or {})
uncertainties: list[str] = []
reason_code = str(verifier.get("reason_code") or "").strip()
if reason_code:
uncertainties.append(reason_code)
for item in list(verifier.get("missing_fields") or []):
normalized = str(item or "").strip()
if normalized and normalized not in uncertainties:
uncertainties.append(normalized)
return ExternalContextPacket(
question=str(question or "").strip(),
plan=self.plan,
adapter_attempts=list((self.meta or {}).get("adapter_attempts") or []),
verified_evidence=list(self.evidence or []) if bool(verifier.get("ok")) else [],
verifier=verifier,
uncertainties=uncertainties,
)
class ExternalContextPacket(BaseModel):
model_config = ConfigDict(extra="ignore")
question: str = ""
plan: ExternalQueryPlan
adapter_attempts: list[dict[str, Any]] = Field(default_factory=list)
verified_evidence: list[ExternalEvidenceItem] = Field(default_factory=list)
verifier: dict[str, Any] = Field(default_factory=dict)
uncertainties: list[str] = Field(default_factory=list)

View File

@ -6,45 +6,12 @@ from ....utils.thread_context import has_feishu_bot_mention, has_feishu_explicit
class FeishuReplyGuardMixin: class FeishuReplyGuardMixin:
@staticmethod @staticmethod
def _detect_forbidden_execute_keyword(text: Optional[str]) -> Optional[str]: def _detect_forbidden_execute_keyword(text: Optional[str]) -> Optional[str]:
lowered = str(text or "").strip().lower() _ = text
if not lowered:
return None
forbidden = (
"curl",
"调用方式",
"接口教程",
"api 调用",
"请求示例",
"postman",
"sdk 调用",
)
for token in forbidden:
if token in lowered:
return token
return None return None
@staticmethod @staticmethod
def _detect_forbidden_qa_keyword(text: Optional[str]) -> Optional[str]: def _detect_forbidden_qa_keyword(text: Optional[str]) -> Optional[str]:
lowered = str(text or "").strip().lower() _ = text
if not lowered:
return None
forbidden = (
"模型失败了所以没分析",
"让我直接处理",
"直接操作数据库",
"先检查数据库",
"检查数据库迁移",
"数据库迁移状态",
"alembic",
"ddl",
"alter table",
"drop table",
"create table",
"truncate table",
)
for token in forbidden:
if token in lowered:
return token
return None return None
@classmethod @classmethod

View File

@ -16,19 +16,6 @@ class FeishuReplyLLMCoreMixin:
perf_t0: Optional[float] = None, perf_t0: Optional[float] = None,
) -> None: ) -> None:
"""被 @ 时回复一条有人味的执行反馈。""" """被 @ 时回复一条有人味的执行反馈。"""
deterministic_time_reply = self._build_time_truth_reply(message_content)
if deterministic_time_reply:
await self._safe_reply_text(
chat_id=chat_id,
mention_open_id=sender_open_id,
mention_name=sender_name,
text=deterministic_time_reply,
trace_id=trace_id,
source_message_id=source_message_id,
perf_t0=perf_t0,
)
return
execution_receipt = self._build_execution_receipt_reply( execution_receipt = self._build_execution_receipt_reply(
message_content=message_content, message_content=message_content,
capture_result=capture_result, capture_result=capture_result,
@ -343,28 +330,3 @@ class FeishuReplyLLMCoreMixin:
@staticmethod @staticmethod
def _fallback_reply_text(capture_result: Dict[str, Any]) -> str: def _fallback_reply_text(capture_result: Dict[str, Any]) -> str:
return FeishuReplyLLMCoreMixin._build_llm_failure_reply_text(capture_result) return FeishuReplyLLMCoreMixin._build_llm_failure_reply_text(capture_result)
@staticmethod
def _build_time_truth_reply(message_content: Optional[str]) -> Optional[str]:
text = str(message_content or "").strip().lower()
if not text:
return None
time_keywords = ("今天", "日期", "时间", "几号", "几点", "星期", "周几", "当前日期")
if not any(keyword in text for keyword in time_keywords):
return None
now_cn = datetime.now(CN_TZ)
today_text = (
f"{now_cn.year}年{now_cn.month}月{now_cn.day}日"
f"({WEEKDAY_CN[now_cn.weekday()]}){now_cn.strftime('%H:%M:%S')}"
)
if "2023年11月7日" in text or "2023-11-07" in text:
return (
f"我这边已校准为北京时间 {today_text}。"
"你看到“2023年11月7日”通常是客户端缓存或本机时区异常,"
"建议刷新会话并检查系统时区是否为 Asia/Shanghai。"
)
return (
f"我这边当前北京时间是 {today_text}。"
"如果你的界面显示不一致,优先检查设备时区和飞书客户端缓存。"
)

View File

@ -545,12 +545,14 @@ class FeishuReplyOutboxMixin:
return "" return ""
label_map = { label_map = {
"结论:": "🎯 结论:",
"依据:": "🔎 依据:",
"下一步:": "➡️ 下一步:",
"信息不足,缺少:": "🧩 信息不足,缺少:",
"当前我更推荐:": "⭐ 当前我更推荐:", "当前我更推荐:": "⭐ 当前我更推荐:",
} }
legacy_prefix_map = {
"结论:": "",
"依据:": "",
"下一步:": "",
"信息不足,缺少:": "当前证据不足:",
}
section_prefixes = tuple(label_map.values()) + ("🧭 第 ",) section_prefixes = tuple(label_map.values()) + ("🧭 第 ",)
choice_prefixes = ("A.", "B.", "C.") choice_prefixes = ("A.", "B.", "C.")
tail_prefixes = ("你回 A / B / C",) tail_prefixes = ("你回 A / B / C",)
@ -562,6 +564,10 @@ class FeishuReplyOutboxMixin:
if beautified and beautified[-1] != "": if beautified and beautified[-1] != "":
beautified.append("") beautified.append("")
continue continue
for legacy, replacement in legacy_prefix_map.items():
if stripped.startswith(legacy):
stripped = f"{replacement}{stripped[len(legacy):]}".strip()
break
for plain, pretty in label_map.items(): for plain, pretty in label_map.items():
if stripped.startswith(plain): if stripped.startswith(plain):
stripped = f"{pretty}{stripped[len(plain):]}" stripped = f"{pretty}{stripped[len(plain):]}"

View File

@ -78,9 +78,6 @@ class FeishuReplyTemplatesMixin:
return "这条消息不是可执行需求,我没有入库。请直接说要新增什么信号或任务。" return "这条消息不是可执行需求,我没有入库。请直接说要新增什么信号或任务。"
return "这条消息已忽略(未入库)。如需执行,请明确目标、对象和动作。" return "这条消息已忽略(未入库)。如需执行,请明确目标、对象和动作。"
text = str(message_content or "").strip()
strict_keywords = ("单独信号源", "单个信号源", "只入库", "仅入库", "不需要二次处理")
strict_mode = any(k in text for k in strict_keywords)
primary_route_status = "" primary_route_status = ""
results = capture_result.get("results") results = capture_result.get("results")
if isinstance(results, list) and results: if isinstance(results, list) and results:
@ -133,33 +130,17 @@ class FeishuReplyTemplatesMixin:
project_id = str(candidate).strip() or None project_id = str(candidate).strip() or None
if signal_id and project_id: if signal_id and project_id:
if strict_mode:
return FeishuReplyTemplatesMixin._compact_receipt(
"✅ 已按要求仅执行入库",
f"信号#{signal_id} 已入库",
f"项目#{project_id} 已关联",
)
return FeishuReplyTemplatesMixin._compact_receipt( return FeishuReplyTemplatesMixin._compact_receipt(
"✅ 已执行完成", "✅ 已执行完成",
f"信号#{signal_id} 已入库", f"信号#{signal_id} 已入库",
f"项目#{project_id} 已关联", f"项目#{project_id} 已关联",
) )
if signal_id: if signal_id:
if strict_mode:
return FeishuReplyTemplatesMixin._compact_receipt(
"✅ 已按要求仅执行入库",
f"信号#{signal_id} 已入库",
)
return FeishuReplyTemplatesMixin._compact_receipt( return FeishuReplyTemplatesMixin._compact_receipt(
"✅ 已执行完成", "✅ 已执行完成",
f"信号#{signal_id} 已入库", f"信号#{signal_id} 已入库",
) )
if project_id: if project_id:
if strict_mode:
return FeishuReplyTemplatesMixin._compact_receipt(
"✅ 已按要求执行",
f"项目#{project_id} 已关联",
)
return FeishuReplyTemplatesMixin._compact_receipt( return FeishuReplyTemplatesMixin._compact_receipt(
"✅ 已执行完成", "✅ 已执行完成",
f"项目#{project_id} 已关联", f"项目#{project_id} 已关联",

View File

@ -6,33 +6,9 @@ from .shared import *
class FeishuPipelineLookupAndBlockMixin: class FeishuPipelineLookupAndBlockMixin:
@staticmethod @staticmethod
def _extract_project_lookup_keyword(message: str) -> str: def _normalize_project_lookup_text(value: Any) -> str:
text = str(message or "").strip() text = normalize_lookup_fragment(value, limit=96)
if not text: return re.sub(r"\s+", "", text).lower()
return ""
text = text.replace("?", "?")
direct_patterns = [
r"(?:是否有|有没有|有无)([\u4e00-\u9fffA-Za-z0-9_-]{1,24})(?:项目|系统)?",
r"(?:是否存在|存在)([\u4e00-\u9fffA-Za-z0-9_-]{1,24})(?:项目|系统)?",
]
for pattern in direct_patterns:
match = re.search(pattern, text)
if not match:
continue
value = str(match.group(1) or "").strip(" ,。,.!??::;;“”\"'()()")
value = QUESTION_NOISE_RE.sub("", value).strip()
if len(value) >= 2:
return value[:24]
anchored_matches = list(re.finditer(r"([\u4e00-\u9fffA-Za-z0-9_-]{1,24})(?:项目|系统)", text))
for match in reversed(anchored_matches):
value = str(match.group(1) or "").strip(" ,。,.!??::;;“”\"'()()")
value = QUESTION_NOISE_RE.sub("", value).strip()
if len(value) >= 2:
return value[:24]
return ""
async def _maybe_block_legacy_command_request( async def _maybe_block_legacy_command_request(
self, self,
@ -93,15 +69,12 @@ class FeishuPipelineLookupAndBlockMixin:
reply_text = self._format_project_lookup_hit_rows(projects[:8], prefix="当前项目") 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"))] linked_project_ids = [int(item["id"]) for item in projects[:8] if _safe_int(item.get("id"))]
else: else:
keyword = self._extract_project_lookup_keyword(message) matched_rows = self._match_projects_by_keyword(projects=projects, keyword=message)
matched_rows = self._match_projects_by_keyword(projects=projects, keyword=keyword)
if matched_rows: if matched_rows:
linked_project_ids = [int(item["id"]) for item in matched_rows if _safe_int(item.get("id"))] 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="查到了") reply_text = self._format_project_lookup_hit_rows(matched_rows[:5], prefix="查到了")
elif keyword:
reply_text = f"没有查到包含“{keyword}”的项目。"
else: else:
reply_text = "这条问题缺少可检索的项目关键词。" reply_text = "没有查到可确认的项目。请补充更完整的项目名,或直接给项目 ID。"
duration_ms = int((time.perf_counter() - process_started_at) * 1000) duration_ms = int((time.perf_counter() - process_started_at) * 1000)
await self._write_processing_log( await self._write_processing_log(
@ -183,15 +156,21 @@ class FeishuPipelineLookupAndBlockMixin:
@staticmethod @staticmethod
def _match_projects_by_keyword(*, projects: list[dict[str, Any]], keyword: str) -> list[dict[str, Any]]: def _match_projects_by_keyword(*, projects: list[dict[str, Any]], keyword: str) -> list[dict[str, Any]]:
normalized_keyword = re.sub(r"\s+", "", str(keyword or "")).lower() normalized_keyword = FeishuPipelineLookupAndBlockMixin._normalize_project_lookup_text(keyword)
if len(normalized_keyword) < 2: if len(normalized_keyword) < 2:
return [] return []
matched: list[dict[str, Any]] = [] ranked: list[tuple[int, int, dict[str, Any]]] = []
for item in projects: for item in projects:
title = re.sub(r"\s+", "", str(item.get("title") or item.get("name") or "")).lower() title = FeishuPipelineLookupAndBlockMixin._normalize_project_lookup_text(item.get("title") or item.get("name") or "")
if normalized_keyword and normalized_keyword in title: if len(title) < 2:
matched.append(item) continue
return matched 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 @staticmethod
def _format_project_lookup_hit_rows(rows: list[dict[str, Any]], *, prefix: str) -> str: def _format_project_lookup_hit_rows(rows: list[dict[str, Any]], *, prefix: str) -> str:

View File

@ -73,14 +73,6 @@ FEISHU_QUOTE_PREFIX_RE = re.compile(r"(?m)^\s*(?:回复|引用|转发)[::]\s*"
FEISHU_QUOTE_LINE_RE = re.compile(r"(?m)^\s*(?:>+|【引用】|引用:|回复:).*$") FEISHU_QUOTE_LINE_RE = re.compile(r"(?m)^\s*(?:>+|【引用】|引用:|回复:).*$")
FEISHU_NOISE_TOKEN_RE = re.compile(r"\[(?:附带图片|图片|表情|文件|语音|消息卡片)\]", re.IGNORECASE) FEISHU_NOISE_TOKEN_RE = re.compile(r"\[(?:附带图片|图片|表情|文件|语音|消息卡片)\]", re.IGNORECASE)
CROSS_SYSTEM_RE = re.compile(r"(跨系统|跨平台|跨团队|外部系统|第三方系统)", re.IGNORECASE) CROSS_SYSTEM_RE = re.compile(r"(跨系统|跨平台|跨团队|外部系统|第三方系统)", re.IGNORECASE)
TASK_CREATE_RE = re.compile(
r"(新增|新建|创建|加一个|加个|补一个|补个).{0,8}任务",
re.IGNORECASE,
)
PROJECT_CREATE_GATE_RE = re.compile(
r"(新增|新建|创建|建立|立项|放到|放进|加入|录入|进).{0,18}(pmflow|项目列表|项目库|项目台账|项目主数据|一个项目|项目)",
re.IGNORECASE,
)
ROUTING_AUTO_CREATE_PROJECT = "auto_create_project" ROUTING_AUTO_CREATE_PROJECT = "auto_create_project"
ROUTING_AUTO_ENTER_SIGNAL = "auto_enter_signal" ROUTING_AUTO_ENTER_SIGNAL = "auto_enter_signal"

View File

@ -40,6 +40,18 @@ def build_template_catalog() -> list[McpTemplateSpec]:
command_hint="需要 PMFlow Local Bridge 或兼容 MCP host 在客户本机承载 stdio server。", command_hint="需要 PMFlow Local Bridge 或兼容 MCP host 在客户本机承载 stdio server。",
verify_hint="确认本机 bridge/host 已接入后,再回到这里做手动确认。", verify_hint="确认本机 bridge/host 已接入后,再回到这里做手动确认。",
), ),
McpTemplateSpec(
id="duckduckgo",
name="DuckDuckGo MCP",
description="DuckDuckGo 后端直连搜索运行时,适合免费通用网页 / 新闻检索试用。",
transport="backend_adapter",
execution_mode="backend_direct",
icon="search",
docs_url="https://pypi.org/project/duckduckgo-search/",
fields=[],
command_hint="保存后由 PMFlow 后端直接使用 DuckDuckGo 搜索运行时,不要求客户机器安装额外 MCP host。",
verify_hint="重新校验时,PMFlow 会从后端检查 DuckDuckGo 搜索运行时并返回可用工具。",
),
McpTemplateSpec( McpTemplateSpec(
id="filesystem", id="filesystem",
name="Filesystem MCP", name="Filesystem MCP",

View File

@ -1,6 +1,7 @@
from __future__ import annotations from __future__ import annotations
import json import json
import warnings
from typing import Any, Callable from typing import Any, Callable
import httpx import httpx
@ -147,3 +148,117 @@ class MicrosoftLearnMCPRuntime:
if not data_lines: if not data_lines:
raise ValueError("MCP streamable_http response missing data payload") raise ValueError("MCP streamable_http response missing data payload")
return json.loads("\n".join(data_lines)) return json.loads("\n".join(data_lines))
class DuckDuckGoSearchRuntime:
def __init__(
self,
*,
ddgs_factory: Callable[[], Any] | None = None,
) -> None:
self.ddgs_factory = ddgs_factory or self._build_default_ddgs
async def probe(self) -> dict[str, Any]:
checked_at = now_cn_naive().isoformat()
try:
self.ddgs_factory()
except Exception as exc:
return {
"status": "error",
"checked_at": checked_at,
"message": f"PMFlow backend probe failed: {exc}",
"tool_names": [],
}
return {
"status": "ready",
"checked_at": checked_at,
"message": "PMFlow backend can directly reach DuckDuckGo search runtime.",
"tool_names": ["ddg-text-search", "ddg-news-search"],
}
async def search(
self,
*,
query: str,
result_limit: int = 3,
query_kind: str = "unknown",
) -> list[ExternalEvidenceItem]:
query_text = str(query or "").strip()
if not query_text:
return []
client = self.ddgs_factory()
limit = max(1, int(result_limit or 3))
normalized_kind = str(query_kind or "unknown").strip().lower()
search_kind = "news" if normalized_kind in {"news", "market"} else "text"
retrieved_at = now_cn_naive().isoformat()
try:
raw_results = self._run_search(client=client, query=query_text, limit=limit, search_kind=search_kind)
except Exception:
if search_kind != "news":
return []
search_kind = "text"
try:
raw_results = self._run_search(client=client, query=query_text, limit=limit, search_kind=search_kind)
except Exception:
return []
evidence: list[ExternalEvidenceItem] = []
grounded_field = "ddg-news-search" if search_kind == "news" else "ddg-text-search"
freshness = 0.82 if search_kind == "news" else 0.74
authority = 0.62
for raw in list(raw_results or [])[:limit]:
target_url = str(raw.get("url") or raw.get("href") or "").strip()
if not target_url.startswith("http://") and not target_url.startswith("https://"):
continue
title = str(raw.get("title") or "").strip()
snippet = str(raw.get("body") or raw.get("description") or "").strip()
evidence.append(
ExternalEvidenceItem(
source_type="mcp_search",
source_label="DuckDuckGo Search",
title=title[:255],
target_url=target_url,
snippet=snippet[:1800],
retrieved_at=retrieved_at,
freshness_score=freshness,
authority_score=authority,
grounded_fields=[grounded_field],
raw_metadata={
"provider": "duckduckgo_backend_direct",
"query_kind": normalized_kind,
"source_name": str(raw.get("source") or "").strip() or None,
"published_at": str(raw.get("date") or "").strip() or None,
},
)
)
return evidence
@staticmethod
def _build_default_ddgs() -> Any:
with warnings.catch_warnings():
warnings.filterwarnings(
"ignore",
message=r".*duckduckgo_search.*renamed to `ddgs`.*",
category=RuntimeWarning,
)
from duckduckgo_search import DDGS
return DDGS()
@staticmethod
def _run_search(*, client: Any, query: str, limit: int, search_kind: str) -> Any:
if search_kind == "news":
return client.news(
keywords=query,
region="wt-wt",
safesearch="moderate",
timelimit="d",
max_results=limit,
)
return client.text(
keywords=query,
region="wt-wt",
safesearch="moderate",
timelimit="w",
max_results=limit,
)

View File

@ -36,7 +36,7 @@ class McpTemplateSpec(BaseModel):
id: str id: str
name: str name: str
description: str description: str
transport: Literal["stdio", "streamable_http"] transport: Literal["stdio", "streamable_http", "backend_adapter"]
execution_mode: McpExecutionMode execution_mode: McpExecutionMode
icon: str icon: str
recommended: bool = False recommended: bool = False
@ -51,7 +51,7 @@ class McpServerConfigPayload(BaseModel):
name: str name: str
display_name: str display_name: str
template_id: str template_id: str
transport: Literal["stdio", "streamable_http"] transport: Literal["stdio", "streamable_http", "backend_adapter"]
execution_mode: McpExecutionMode = "local_bridge" execution_mode: McpExecutionMode = "local_bridge"
params: dict[str, object] = Field(default_factory=dict) params: dict[str, object] = Field(default_factory=dict)
preview: McpCommandPreview preview: McpCommandPreview

View File

@ -11,7 +11,7 @@ from ...models import Integration
from ...utils.timezone import now_cn_naive from ...utils.timezone import now_cn_naive
from ..external_query_service.schemas import ExternalEvidenceItem from ..external_query_service.schemas import ExternalEvidenceItem
from .catalog import build_template_catalog from .catalog import build_template_catalog
from .runtime import MicrosoftLearnMCPRuntime from .runtime import DuckDuckGoSearchRuntime, MicrosoftLearnMCPRuntime
from .schemas import McpCommandPreview, McpRuntimeProbe, McpServerConfigPayload, McpTemplateSpec from .schemas import McpCommandPreview, McpRuntimeProbe, McpServerConfigPayload, McpTemplateSpec
INTEGRATION_TYPE = "mcp_template_center" INTEGRATION_TYPE = "mcp_template_center"
@ -189,18 +189,34 @@ class McpConfigService:
return McpServerConfigPayload.model_validate(item).model_dump(mode="json") return McpServerConfigPayload.model_validate(item).model_dump(mode="json")
return None return None
async def search_external(self, *, query: str, result_limit: int = 3) -> list[ExternalEvidenceItem]: async def search_external(
self,
*,
query: str,
result_limit: int = 3,
query_kind: str = "unknown",
) -> list[ExternalEvidenceItem]:
query_text = str(query or "").strip() query_text = str(query or "").strip()
if not query_text: if not query_text:
return [] return []
server = await self.get_preferred_backend_direct_server(template_id="microsoft_learn") template_ids = self._preferred_backend_direct_template_ids(query_kind=query_kind)
if not template_ids:
return []
server = await self._get_preferred_backend_direct_server_by_templates(template_ids=template_ids)
if not server: if not server:
return [] return []
template = self._get_template(str(server.get("template_id") or "")) template = self._get_template(str(server.get("template_id") or ""))
if template.id != "microsoft_learn": if template.id == "microsoft_learn":
return []
runtime = MicrosoftLearnMCPRuntime() runtime = MicrosoftLearnMCPRuntime()
return await runtime.search(query=query_text, result_limit=max(1, int(result_limit or 3))) return await runtime.search(query=query_text, result_limit=max(1, int(result_limit or 3)))
if template.id == "duckduckgo":
runtime = DuckDuckGoSearchRuntime()
return await runtime.search(
query=query_text,
result_limit=max(1, int(result_limit or 3)),
query_kind=query_kind,
)
return []
async def _get_or_init_bundle(self) -> tuple[Integration, dict[str, Any]]: async def _get_or_init_bundle(self) -> tuple[Integration, dict[str, Any]]:
row = ( row = (
@ -239,6 +255,31 @@ class McpConfigService:
raise ValueError(f"未知 MCP 模板:{template_id}") raise ValueError(f"未知 MCP 模板:{template_id}")
return template return template
async def _get_preferred_backend_direct_server_by_templates(self, *, template_ids: list[str]) -> dict[str, Any] | None:
preferred = [str(item or "").strip() for item in list(template_ids or []) if str(item or "").strip()]
if not preferred:
return None
_, bundle = await self._get_or_init_bundle()
servers = [self._normalize_server_record(item) for item in bundle.get("servers", []) if isinstance(item, dict)]
ordered = sorted(servers, key=lambda item: str(item.get("updated_at") or ""), reverse=True)
for template_id in preferred:
for item in ordered:
if str(item.get("execution_mode") or "") != "backend_direct":
continue
if str(item.get("template_id") or "") != template_id:
continue
return McpServerConfigPayload.model_validate(item).model_dump(mode="json")
return None
@staticmethod
def _preferred_backend_direct_template_ids(*, query_kind: str) -> list[str]:
normalized = str(query_kind or "unknown").strip().lower() or "unknown"
if normalized == "model":
return ["microsoft_learn", "duckduckgo"]
if normalized in {"generic_web", "unknown", "company", "news", "market"}:
return ["duckduckgo"]
return []
def _normalize_server_record(self, item: dict[str, Any]) -> dict[str, Any]: def _normalize_server_record(self, item: dict[str, Any]) -> dict[str, Any]:
normalized = dict(item or {}) normalized = dict(item or {})
template = self._get_template(str(normalized.get("template_id") or "")) template = self._get_template(str(normalized.get("template_id") or ""))
@ -298,6 +339,8 @@ class McpConfigService:
raw = dict(params or {}) raw = dict(params or {})
if template.id == "microsoft_learn": if template.id == "microsoft_learn":
return {} return {}
if template.id == "duckduckgo":
return {}
if template.id == "github": if template.id == "github":
for forbidden_key in ("access_token", "token", "personal_access_token", "pat"): for forbidden_key in ("access_token", "token", "personal_access_token", "pat"):
if str(raw.get(forbidden_key) or "").strip(): if str(raw.get(forbidden_key) or "").strip():
@ -330,6 +373,15 @@ class McpConfigService:
verify_command="点击“重新校验”后,PMFlow 会从后端执行 MCP initialize 和 tools/list。", verify_command="点击“重新校验”后,PMFlow 会从后端执行 MCP initialize 和 tools/list。",
expected_output="校验成功后,你会看到后端可用工具,例如 microsoft_docs_search、microsoft_docs_fetch。", expected_output="校验成功后,你会看到后端可用工具,例如 microsoft_docs_search、microsoft_docs_fetch。",
) )
if template.id == "duckduckgo":
return McpCommandPreview(
add_command="无需本机命令。保存后由 PMFlow 后端直接使用 DuckDuckGo 搜索运行时。",
verify_command="点击“重新校验”后,PMFlow 会从后端检查 DuckDuckGo 搜索运行时是否可用。",
expected_output=(
"校验成功后,你会看到后端可用工具,例如 ddg-text-search、"
"ddg-news-search。"
),
)
if template.id == "github": if template.id == "github":
env_name = str(params["token_env_var_name"]) env_name = str(params["token_env_var_name"])
return McpCommandPreview( return McpCommandPreview(
@ -366,6 +418,8 @@ class McpConfigService:
_ = params _ = params
if template.id == "microsoft_learn": if template.id == "microsoft_learn":
return await MicrosoftLearnMCPRuntime().probe() return await MicrosoftLearnMCPRuntime().probe()
if template.id == "duckduckgo":
return await DuckDuckGoSearchRuntime().probe()
return { return {
"status": "error", "status": "error",
"checked_at": now_cn_naive().isoformat(), "checked_at": now_cn_naive().isoformat(),

View File

@ -1,6 +1,5 @@
from __future__ import annotations from __future__ import annotations
import re
from typing import Any, Optional from typing import Any, Optional
from sqlalchemy import and_, select from sqlalchemy import and_, select
@ -19,9 +18,6 @@ from ...models import (
from ...utils.timezone import now_cn_naive from ...utils.timezone import now_cn_naive
PROCESS_TALK_PREFIXES = ("让我先", "我先", "我来看看", "我先看", "让我看")
class ReplyIterationService: class ReplyIterationService:
def __init__(self, db: AsyncSession): def __init__(self, db: AsyncSession):
self.db = db self.db = db
@ -527,10 +523,6 @@ class ReplyIterationService:
@staticmethod @staticmethod
def _build_rule_findings(asset: QualityAsset) -> list[dict[str, Any]]: def _build_rule_findings(asset: QualityAsset) -> list[dict[str, Any]]:
findings: list[dict[str, Any]] = [] findings: list[dict[str, Any]] = []
answer = str(asset.answer_text or "").strip()
normalized = re.sub(r"\s+", "", answer)
if any(answer.startswith(prefix) for prefix in PROCESS_TALK_PREFIXES):
findings.append({"code": "process_talk_prefix", "severity": "warning", "message": "存在过程话术前缀"})
if str(asset.planned_tier or "").strip() == "low" and str(asset.executed_tier or "").strip() == "high": if str(asset.planned_tier or "").strip() == "low" and str(asset.executed_tier or "").strip() == "high":
findings.append({"code": "tier_escalation", "severity": "info", "message": "存在 low -> high 升级"}) findings.append({"code": "tier_escalation", "severity": "info", "message": "存在 low -> high 升级"})
return findings return findings

View File

@ -166,10 +166,10 @@ class SignalCaptureEntryMixin:
cls, cls,
*, *,
message_text: str, message_text: str,
) -> tuple[bool, str]: ) -> tuple[bool, str, Optional[Any]]:
clean_text = str(message_text or "").strip() clean_text = str(message_text or "").strip()
if not clean_text: if not clean_text:
return False, "empty" return False, "empty", None
bundle = await WorkflowSemanticPlanner.aplan_message( bundle = await WorkflowSemanticPlanner.aplan_message(
message_text=clean_text, message_text=clean_text,
@ -179,12 +179,12 @@ class SignalCaptureEntryMixin:
diagnostics = dict(getattr(bundle, "diagnostics", {}) or {}) diagnostics = dict(getattr(bundle, "diagnostics", {}) or {})
message_intent = str(diagnostics.get("message_intent") or "").strip().lower() message_intent = str(diagnostics.get("message_intent") or "").strip().lower()
if message_intent == "requirement_plan": if message_intent == "requirement_plan":
return True, "semantic:requirement_plan" return True, "semantic:requirement_plan", bundle
if workflow_types == ["query_flow"]: if workflow_types == ["query_flow"]:
return True, "semantic:query_flow" return True, "semantic:query_flow", bundle
if workflow_types == ["followup_flow"]: if workflow_types == ["followup_flow"]:
return True, "semantic:followup_flow" return True, "semantic:followup_flow", bundle
return False, "async_queue" return False, "async_queue", None
async def capture_from_feishu( async def capture_from_feishu(
self, self,
@ -306,8 +306,9 @@ class SignalCaptureEntryMixin:
) )
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
if not should_sync_process: if not should_sync_process:
should_sync_process, sync_reason = await self._should_process_feishu_high_accuracy( should_sync_process, sync_reason, high_accuracy_bundle = await self._should_process_feishu_high_accuracy(
message_text=cleaned_message, message_text=cleaned_message,
) )
@ -378,6 +379,8 @@ class SignalCaptureEntryMixin:
normalized_sync_reason = str(sync_reason or "").strip().lower() normalized_sync_reason = str(sync_reason or "").strip().lower()
should_forward_semantic_bundle = bool(force_sync) or normalized_sync_reason.startswith("semantic:") should_forward_semantic_bundle = bool(force_sync) or normalized_sync_reason.startswith("semantic:")
if should_forward_semantic_bundle: if should_forward_semantic_bundle:
candidate_bundle = high_accuracy_bundle
if candidate_bundle is None:
candidate_bundle = await WorkflowSemanticPlanner.aplan_message( candidate_bundle = await WorkflowSemanticPlanner.aplan_message(
message_text=cleaned_message, message_text=cleaned_message,
source_system="feishu", source_system="feishu",

View File

@ -24,3 +24,4 @@ pypdf>=4.3.1
python-docx>=1.1.2 python-docx>=1.1.2
openpyxl>=3.1.5 openpyxl>=3.1.5
lark-oapi>=1.4.10 lark-oapi>=1.4.10
duckduckgo-search>=8.1.1

View File

@ -23,9 +23,6 @@ CASES = [
{"idx": 5, "message": "帮我看看宠物行业最新动态", "expected_scene": "external_trend", "keys": ["宠物行业", "最新动态", "行业", "动态"]}, {"idx": 5, "message": "帮我看看宠物行业最新动态", "expected_scene": "external_trend", "keys": ["宠物行业", "最新动态", "行业", "动态"]},
] ]
BANNED_PREFIXES = ["让我先", "让我尝试", "我来看看", "我先查", "让我看看", "我需要先"]
def login_token() -> str: def login_token() -> str:
data = urllib.parse.urlencode({"username": "admin", "password": "admin123"}).encode() data = urllib.parse.urlencode({"username": "admin", "password": "admin123"}).encode()
req = urllib.request.Request( req = urllib.request.Request(
@ -94,7 +91,7 @@ def has_thinking_leak(reply: str) -> bool:
s = line.strip() s = line.strip()
if not s: if not s:
continue continue
if any(s.startswith(prefix) for prefix in BANNED_PREFIXES): if len(s) <= 12 and s.startswith("我") and "先" in s:
return True return True
return False return False

View File

@ -1,6 +1,7 @@
from __future__ import annotations from __future__ import annotations
from datetime import timedelta from datetime import timedelta
from types import SimpleNamespace
import pytest import pytest
import pytest_asyncio import pytest_asyncio
@ -528,6 +529,94 @@ async def test_explicit_reply_falls_back_to_current_ingest_closure_turns_when_ou
assert "前后端分离架构" in resolution.anchor_reply["anchor_reply_preview"] assert "前后端分离架构" in resolution.anchor_reply["anchor_reply_preview"]
@pytest.mark.asyncio
async def test_project_followup_state_prefers_answer_project_name_over_raw_question(db_session) -> None:
service = _Harness(db_session)
state = SimpleNamespace(
last_subject=None,
last_entity_ids=[],
last_intent="question",
ambiguity="clear",
rewrite_history=[],
pending_clarification=None,
pending_clarification_expires_at=None,
)
await service._update_session_state_after_turn(
session_state=state,
question="帮我看下采购经理那个需求是否存在系统?",
effective_question="帮我看下采购经理那个需求是否存在系统?",
answer=(
"有,命中1个项目:项目ID 123,项目名“采购经理每日实时库存上报服务”,"
"状态 active,负责人 系统管理员。"
),
query_type="existence",
ambiguity="clear",
rewrite_meta={"mode": "direct"},
sources=[
{
"source": "db",
"label": "Project database",
"score": 0.99,
"metadata": {"table": "projects", "matched_project_ids": [123]},
}
],
)
assert state.last_subject == "采购经理每日实时库存上报服务"
assert state.last_entity_ids == [123]
rewritten, rewrite_meta = service._rewrite_question_with_state(
question="这个项目怎么样?",
conversation=[],
session_state=state,
anchor_resolution=FollowupAnchorResolution(
followup_mode="referential",
anchor_mode="implicit_recent",
),
)
assert rewritten == "围绕“采购经理每日实时库存上报服务”回答:这个项目怎么样?"
assert rewrite_meta["mode"] == "state_subject"
@pytest.mark.asyncio
async def test_project_followup_state_extracts_unquoted_project_name_from_answer(db_session) -> None:
service = _Harness(db_session)
state = SimpleNamespace(
last_subject=None,
last_entity_ids=[],
last_intent="question",
ambiguity="clear",
rewrite_history=[],
pending_clarification=None,
pending_clarification_expires_at=None,
)
await service._update_session_state_after_turn(
session_state=state,
question="帮我看下采购经理那个需求是否存在系统?",
effective_question="帮我看下采购经理那个需求是否存在系统?",
answer=(
"有,命中 1 个项目:项目ID 123、项目名 采购经理每日实时库存上报服务、"
"状态 active、负责人 系统管理员。"
),
query_type="existence",
ambiguity="clear",
rewrite_meta={"mode": "direct"},
sources=[
{
"source": "db",
"label": "Project database",
"score": 0.99,
"metadata": {"table": "projects", "matched_project_ids": [123]},
}
],
)
assert state.last_subject == "采购经理每日实时库存上报服务"
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_short_reply_to_explanation_is_treated_as_meta_followup(db_session) -> None: async def test_short_reply_to_explanation_is_treated_as_meta_followup(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

View File

@ -51,6 +51,13 @@ async def test_http_rag_ask_readonly_mainline_contract(monkeypatch) -> None:
"rag_result": { "rag_result": {
"answer": "当前主链状态正常。", "answer": "当前主链状态正常。",
"processing_stage": "qa", "processing_stage": "qa",
"route_decision": {
"route_mode": "internal",
"effective_source_policy": "internal",
"decision_origin": "reply_anchor",
},
"clarification_code": None,
"clarification_candidates": [],
"answer_mode": "fast_path", "answer_mode": "fast_path",
"grounding_status": "grounded", "grounding_status": "grounded",
"policy_version": "retrieval-v2.1|routing:v3|prompt:deterministic", "policy_version": "retrieval-v2.1|routing:v3|prompt:deterministic",
@ -84,11 +91,15 @@ async def test_http_rag_ask_readonly_mainline_contract(monkeypatch) -> None:
payload = response.json() payload = response.json()
assert payload["code"] == 0 assert payload["code"] == 0
assert "当前主链状态正常" in str((payload.get("data") or {}).get("answer") or "") assert "当前主链状态正常" in str((payload.get("data") or {}).get("answer") or "")
assert payload["data"]["route_decision"]["effective_source_policy"] == "internal"
assert payload["data"]["clarification_code"] is None
assert payload["data"]["clarification_candidates"] == []
assert payload["data"]["answer_mode"] == "fast_path" assert payload["data"]["answer_mode"] == "fast_path"
assert payload["data"]["grounding_status"] == "grounded" assert payload["data"]["grounding_status"] == "grounded"
assert payload["data"]["policy_version"] == "retrieval-v2.1|routing:v3|prompt:deterministic" assert payload["data"]["policy_version"] == "retrieval-v2.1|routing:v3|prompt:deterministic"
assert payload["data"]["citations"] == [] assert payload["data"]["citations"] == []
assert payload["data"]["sources_used"] == [] assert payload["data"]["sources_used"] == []
assert payload["data"]["evidence_pack"]["summary"]["conclusion"] == "当前主链状态正常。"
ingress_kwargs = captured["ingress_kwargs"] ingress_kwargs = captured["ingress_kwargs"]
assert isinstance(ingress_kwargs, dict) assert isinstance(ingress_kwargs, dict)
assert ingress_kwargs["source_system"] == "pmflow_manual" assert ingress_kwargs["source_system"] == "pmflow_manual"

View File

@ -87,7 +87,7 @@ def test_reply_outbox_extract_sent_message_id() -> None:
assert FeishuReplyOutboxMixin._extract_sent_message_id({"message_id": "msg_456"}) == "msg_456" assert FeishuReplyOutboxMixin._extract_sent_message_id({"message_id": "msg_456"}) == "msg_456"
def test_reply_outbox_normalize_beautifies_structured_pm_sections() -> None: def test_reply_outbox_normalize_strips_legacy_pm_sections() -> None:
from app.services.integrations.feishu_integration_service.mixin_reply_outbox import FeishuReplyOutboxMixin from app.services.integrations.feishu_integration_service.mixin_reply_outbox import FeishuReplyOutboxMixin
raw = ( raw = (
@ -99,11 +99,16 @@ def test_reply_outbox_normalize_beautifies_structured_pm_sections() -> None:
normalized = FeishuReplyOutboxMixin._normalize_outgoing_reply_text(raw) normalized = FeishuReplyOutboxMixin._normalize_outgoing_reply_text(raw)
assert "🎯 结论:" in normalized assert "结论:" not in normalized
assert "🔎 依据:" in normalized assert "依据:" not in normalized
assert "➡️ 下一步:" in normalized assert "下一步:" not in normalized
assert "🧩 信息不足,缺少:" in normalized assert "信息不足,缺少:" not in normalized
assert "\n\n🔎 依据:" in normalized assert "🎯" not in normalized
assert "🔎" not in normalized
assert "➡️" not in normalized
assert "🧩" not in normalized
assert "项目 #104 当前还不能直接判断是否启动后端。" in normalized
assert "当前只命中静态代码索引,缺少真实归属和负责人信息。" in normalized
def test_reply_outbox_normalize_strips_internal_feature_catalog_block() -> None: def test_reply_outbox_normalize_strips_internal_feature_catalog_block() -> None:
@ -3005,13 +3010,16 @@ async def test_adaptive_rag_select_scene_prompt_feishu_direct_compacts_snapshot_
def test_feishu_qa_prompts_prefer_professional_answer_style() -> None: def test_feishu_qa_prompts_prefer_professional_answer_style() -> None:
assert "先给结论" in PROMPT_FEISHU_DIRECT_QA assert "直接回答用户问题" in PROMPT_FEISHU_DIRECT_QA
assert "信息不足,缺少" in PROMPT_FEISHU_DIRECT_QA assert "旧式栏目标题" in PROMPT_FEISHU_DIRECT_QA
assert "当前证据不足" in PROMPT_FEISHU_DIRECT_QA
assert "适合飞书消息阅读" in PROMPT_FEISHU_DIRECT_QA assert "适合飞书消息阅读" in PROMPT_FEISHU_DIRECT_QA
assert "轻量 emoji" in PROMPT_FEISHU_DIRECT_QA
assert "项目画像" in PROMPT_FEISHU_DIRECT_QA assert "项目画像" in PROMPT_FEISHU_DIRECT_QA
assert "系统摘要" in PROMPT_FEISHU_DIRECT_QA assert "系统摘要" in PROMPT_FEISHU_DIRECT_QA
assert "用户焦点" in PROMPT_FEISHU_DIRECT_QA assert "用户焦点" in PROMPT_FEISHU_DIRECT_QA
assert "先给结论" not in PROMPT_FEISHU_DIRECT_QA
assert "信息不足,缺少" not in PROMPT_FEISHU_DIRECT_QA
assert "轻量 emoji" not in PROMPT_FEISHU_DIRECT_QA
assert "先回答问题部分" in PROMPT_FEISHU_MIXED_QA assert "先回答问题部分" in PROMPT_FEISHU_MIXED_QA
assert "[已收到需求]" in PROMPT_FEISHU_MIXED_QA assert "[已收到需求]" in PROMPT_FEISHU_MIXED_QA
assert "专业" in PROMPT_FEISHU_MIXED_QA assert "专业" in PROMPT_FEISHU_MIXED_QA

View File

@ -644,6 +644,100 @@ async def test_feishu_business_review_query_forwards_semantic_bundle_into_kernel
assert semantic_bundle["diagnostics"]["message_intent"] == "business_review" assert semantic_bundle["diagnostics"]["message_intent"] == "business_review"
@pytest.mark.asyncio
async def test_feishu_business_review_query_reuses_semantic_bundle_without_second_planner_call(monkeypatch) -> None:
captured: dict[str, object] = {}
planner_calls: list[dict[str, object]] = []
class _FakeBundle:
def __init__(self) -> None:
self.primary_workflow = "query_flow"
self.workflows = [SimpleNamespace(workflow_type="query_flow", workflow_action="answer")]
self.diagnostics = {
"message_intent": "business_review",
"consultant_mode_preference": "clarify",
}
def to_dict(self) -> dict[str, object]:
return {
"primary_workflow": "query_flow",
"workflows": [
{
"workflow_type": "query_flow",
"workflow_action": "answer",
}
],
"diagnostics": dict(self.diagnostics),
}
class _FakeKernel:
@staticmethod
def _build_source_message_id(*, source_system: str, source_message_id, channel_ref: str, sender: str, message_text: str):
_ = (source_system, source_message_id, channel_ref, sender, message_text)
return "msg-business-review-reuse"
def __init__(self, db, tenant_id: int):
_ = (db, tenant_id)
async def _get_or_create_session(self, **kwargs):
_ = kwargs
raise AssertionError("business review sync path should not create async queue session")
def _is_short_confirmation_message(self, **kwargs):
_ = kwargs
return False
async def process_feishu_message(self, **kwargs):
captured["kwargs"] = kwargs
return {
"handled": True,
"status": "answered",
"classification": "question",
"linked_project_ids": [],
"signal_id": None,
"created_project_id": None,
"auto_tasks": [],
"ai_trace_id": "trace-br-reuse",
"direct_reply_required": True,
"direct_reply_text": "五大口径评审中。",
}
async def _fake_aplan_message(cls, **kwargs):
_ = cls
planner_calls.append(dict(kwargs))
return _FakeBundle()
async def _noop_sync_recent_cache(self, **kwargs):
_ = (self, kwargs)
monkeypatch.setattr(mixin_capture_entry, "AIPMOKernelService", _FakeKernel)
monkeypatch.setattr(ProactiveSignalCaptureService, "_sync_feishu_recent_cache", _noop_sync_recent_cache)
monkeypatch.setattr(
mixin_capture_entry.WorkflowSemanticPlanner,
"aplan_message",
classmethod(_fake_aplan_message),
)
service = ProactiveSignalCaptureService(object())
result = await service.capture_from_feishu(
group_chat_id="chat-br-reuse",
message_content="我现在要创建需求为一个大模型api聚合平台,你帮我分析下要怎么做,",
sender="测试用户",
tenant_id=1,
mentioned_bot_names=["PMFlow"],
feishu_message_id="msg-business-review-reuse",
chat_type="group",
)
assert result["status"] == "answered"
assert len(planner_calls) == 1
kwargs = captured["kwargs"]
assert isinstance(kwargs, dict)
semantic_bundle = kwargs["semantic_bundle"]
assert semantic_bundle["primary_workflow"] == "query_flow"
assert semantic_bundle["diagnostics"]["message_intent"] == "business_review"
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_force_sync_feishu_query_flow_still_forwards_semantic_bundle_into_kernel(monkeypatch) -> None: async def test_force_sync_feishu_query_flow_still_forwards_semantic_bundle_into_kernel(monkeypatch) -> None:
captured: dict[str, object] = {} captured: dict[str, object] = {}

View File

@ -0,0 +1,466 @@
import pytest
from app.services.adaptive_rag_service.mixin_answer_mainline import AdaptiveRAGAnswerMainlineMixin
from app.services.adaptive_rag_service.mixin_answer_payload import AdaptiveRAGAnswerPayloadMixin
from app.services.adaptive_rag_service.mixin_deep_guard_answer import AdaptiveRAGDeepGuardAnswerMixin
from app.services.adaptive_rag_service.mixin_fast_answer_project_utils import AdaptiveRAGFastAnswerProjectUtilsMixin
from app.services.adaptive_rag_service.mixin_query_route_gateway import AdaptiveRAGQueryRouteGatewayMixin
from app.services.adaptive_rag_service.mixin_target_resolution import AdaptiveRAGTargetResolutionMixin
from app.services.adaptive_rag_service.internal_query_context import InternalContextPacket
class _PacketBuilderService(
AdaptiveRAGAnswerMainlineMixin,
AdaptiveRAGQueryRouteGatewayMixin,
):
tenant_id = 9
class _VerifierService(
AdaptiveRAGAnswerPayloadMixin,
AdaptiveRAGQueryRouteGatewayMixin,
):
pass
class _RuntimeSummaryService(AdaptiveRAGTargetResolutionMixin):
pass
class _ProjectUtilsService(AdaptiveRAGFastAnswerProjectUtilsMixin):
pass
def _build_internal_packet(**overrides) -> InternalContextPacket:
payload = {
"packet_version": "v1",
"question": "系统里面有没有开票系统?",
"scope": {
"tenant_id": 9,
"project_id": 6,
"session_id": 101,
"conversation_scope_key": "scope:chat:1",
"reply_to_message_id": "om_reply_1",
"root_message_id": "om_root_1",
},
"route_decision": {
"route_mode": "internal",
"effective_source_policy": "internal",
"decision_origin": "query_semantics_contract",
"reply_contract": {"mode": "explicit_source", "head": "内部知识库查询:"},
"query_profile": {
"needs_private_context": True,
"needs_freshness": False,
"referenced_objects": ["project_object"],
"confidence": 0.92,
},
},
"operation_contract": {
"query_type": "existence",
"answer_contract": "yes_no_with_evidence",
"object_mentions": ["开票系统"],
"clarify_policy": "ask_when_target_ambiguous",
},
"target_resolution": {
"target_entity_type": "project",
"target_ref": "project:6",
"resolved_label": "自动开票系统",
"resolution_mode": "deterministic",
"confidence": 0.96,
"candidate_summary": [],
},
"query_plan": {
"plan_version": "v2",
"query_type": "existence",
"entity_type": "project",
"answer_contract": "yes_no_with_evidence",
"target_ref": "project:6",
"filters": [{"field": "project_id", "operator": "equals", "value": 6}],
"scope": {"tenant_bound": True, "project_id": 6, "scope_hint": "project"},
"retrieval_policy": {"facts_first": True, "allow_fallback_text": True},
"verifier_checks": ["contract_matches_query_type", "evidence_backed"],
},
"executor_summary": {},
"verifier_summary": {},
"uncertainties": [],
}
payload.update(overrides)
return InternalContextPacket.model_validate(payload)
def test_build_internal_query_context_packet_returns_packet_for_internal_route() -> None:
service = _PacketBuilderService()
packet = service._build_internal_query_context_packet(
question="系统里面有没有开票系统?",
project_id=6,
session_id=101,
conversation_scope_key="scope:chat:1",
reply_to_message_id="om_reply_1",
root_message_id="om_root_1",
route_decision={
"route_mode": "internal",
"effective_source_policy": "internal",
"decision_origin": "query_semantics_contract",
"reply_contract": {"mode": "explicit_source", "head": "内部知识库查询:"},
"query_profile": {
"needs_private_context": True,
"needs_freshness": False,
"referenced_objects": ["project_object"],
"confidence": 0.92,
},
},
operation_contract={
"query_type": "existence",
"answer_contract": "yes_no_with_evidence",
"object_mentions": ["开票系统"],
},
target_resolution={
"target_entity_type": "project",
"target_ref": "project:6",
"resolved_label": "自动开票系统",
"resolution_mode": "deterministic",
"confidence": 0.96,
},
query_plan={
"plan_version": "v2",
"query_type": "existence",
"entity_type": "project",
"answer_contract": "yes_no_with_evidence",
"target_ref": "project:6",
"filters": [{"field": "project_id", "operator": "equals", "value": 6}],
},
)
assert isinstance(packet, InternalContextPacket)
assert packet.scope["tenant_id"] == 9
assert packet.scope["project_id"] == 6
assert packet.route_decision["effective_source_policy"] == "internal"
assert packet.operation_contract["query_type"] == "existence"
assert packet.target_resolution["target_ref"] == "project:6"
assert packet.query_plan["answer_contract"] == "yes_no_with_evidence"
def test_build_internal_query_context_packet_skips_external_route() -> None:
service = _PacketBuilderService()
packet = service._build_internal_query_context_packet(
question="OpenAI 最新模型是什么?",
project_id=None,
session_id=101,
conversation_scope_key="scope:chat:2",
reply_to_message_id=None,
root_message_id=None,
route_decision={
"route_mode": "external",
"effective_source_policy": "external",
"decision_origin": "query_semantics_contract",
"reply_contract": {"mode": "explicit_source", "head": "外部实时查询:"},
"query_profile": {
"needs_private_context": False,
"needs_freshness": True,
"referenced_objects": ["public_model"],
"confidence": 0.95,
},
},
operation_contract={},
target_resolution={},
query_plan=None,
)
assert packet is None
def test_internal_query_runtime_metrics_prefer_internal_context_packet() -> None:
service = _RuntimeSummaryService()
packet = _build_internal_packet()
metrics = service._inject_internal_query_runtime_summaries(
retrieval_metrics={"db_query_ms": 12},
query_semantics={
"operation_contract": {
"query_type": "count",
"answer_contract": "count_scalar",
"object_mentions": ["错误镜像"],
},
"target_resolution": {
"target_entity_type": "project",
"target_ref": "project:999",
"resolved_label": "错误项目",
"resolution_mode": "deterministic",
"confidence": 0.5,
},
"query_plan": {
"plan_version": "v0",
"query_type": "count",
"entity_type": "project",
"target_ref": "project:999",
"answer_contract": "count_scalar",
"filters": [],
},
},
internal_context_packet=packet,
)
assert metrics["db_query_ms"] == 12
assert metrics["operation_contract_summary"]["query_type"] == "existence"
assert metrics["target_resolution_summary"]["target_ref"] == "project:6"
assert metrics["query_plan_summary"]["plan_version"] == "v2"
def test_internal_answer_contract_verifier_prefers_internal_context_packet_contract() -> None:
service = _VerifierService()
packet = _build_internal_packet()
verdict = service._verify_internal_answer_contract(
query_type="count",
answer="当前系统里项目总数为 42 个。",
sources=[
{
"source": "db",
"label": "Project database",
"score": 0.99,
"metadata": {"table": "projects"},
}
],
route_decision={"route_mode": "internal", "effective_source_policy": "internal"},
internal_context_packet=packet,
)
assert verdict["ok"] is False
assert verdict["reason_code"] == "no_total_count_substitution"
def test_internal_answer_contract_verifier_blocks_target_resolution_clarify() -> None:
service = _VerifierService()
packet = _build_internal_packet(
target_resolution={
"target_entity_type": "unknown",
"target_ref": None,
"resolved_label": None,
"resolution_mode": "clarify",
"confidence": 0.41,
"candidate_summary": [
{
"target_entity_type": "project",
"target_ref": "project:6",
"resolved_label": "自动开票系统",
"score": 0.62,
"source_kind": "semantic_facts",
}
],
}
)
verdict = service._verify_internal_answer_contract(
query_type="existence",
answer="有,当前匹配到 1 个相关项目:#6 自动开票系统(状态:active)",
sources=[
{
"source": "db",
"label": "Project database",
"score": 0.99,
"metadata": {"table": "projects", "project_id": 6},
}
],
route_decision={"route_mode": "internal", "effective_source_policy": "internal"},
internal_context_packet=packet,
)
assert verdict["ok"] is False
assert verdict["reason_code"] == "target_resolution_clarify"
def test_internal_answer_contract_verifier_blocks_missing_internal_context_packet_for_internal_route() -> None:
service = _VerifierService()
verdict = service._verify_internal_answer_contract(
query_type="existence",
answer="有,当前匹配到 1 个相关项目:#6 自动开票系统(状态:active)",
sources=[
{
"source": "db",
"label": "Project database",
"score": 0.99,
"metadata": {"table": "projects", "project_id": 6},
}
],
route_decision={"route_mode": "internal", "effective_source_policy": "internal"},
internal_context_packet=None,
)
assert verdict["ok"] is False
assert verdict["reason_code"] == "missing_internal_context_packet"
def test_internal_answer_contract_verifier_blocks_missing_query_plan_contract() -> None:
service = _VerifierService()
packet = _build_internal_packet(
query_plan={
"plan_version": "v2",
"query_type": "existence",
"entity_type": "project",
"answer_contract": "",
"target_ref": "project:6",
"filters": [{"field": "project_id", "operator": "equals", "value": 6}],
}
)
verdict = service._verify_internal_answer_contract(
query_type="existence",
answer="有,当前匹配到 1 个相关项目:#6 自动开票系统(状态:active)",
sources=[
{
"source": "db",
"label": "Project database",
"score": 0.99,
"metadata": {"table": "projects", "project_id": 6},
}
],
route_decision={"route_mode": "internal", "effective_source_policy": "internal"},
internal_context_packet=packet,
)
assert verdict["ok"] is False
assert verdict["reason_code"] == "missing_internal_query_plan_contract"
def test_collect_structured_object_mentions_prefers_internal_context_packet() -> None:
packet = _build_internal_packet(
operation_contract={
"query_type": "existence",
"answer_contract": "yes_no_with_evidence",
"object_mentions": ["真实对象"],
},
target_resolution={
"target_entity_type": "project",
"target_ref": "project:6",
"resolved_label": "真实项目",
"resolution_mode": "deterministic",
"confidence": 0.96,
"candidate_summary": [],
},
)
mentions = _ProjectUtilsService._collect_structured_object_mentions(
query_semantics={
"operation_contract": {
"query_type": "count",
"answer_contract": "count_scalar",
"object_mentions": ["错误对象"],
},
"target_resolution": {
"target_entity_type": "project",
"target_ref": "project:999",
"resolved_label": "错误项目",
"resolution_mode": "deterministic",
},
},
internal_context_packet=packet,
)
assert mentions[:2] == ["真实对象", "真实项目"]
@pytest.mark.asyncio
async def test_internal_model_config_fast_answer_prefers_internal_context_packet() -> None:
class _FakeLLM:
async def _get_or_init_bundle(self, db, *, tenant_id: int):
_ = (db, tenant_id)
return object(), {
"active_engine_id": "low-1",
"engines": [
{
"id": "low-1",
"name": "Qwen 低阶引擎",
"provider": "glm",
"model": "qwen-plus",
}
],
"settings": {
"enable_local_kb_qa": False,
},
}
def _resolve_active_engine_from_bundle(self, bundle: dict[str, object]):
engines = list(bundle.get("engines") or [])
return dict(engines[0])
async def _resolve_engine_for_call(self, db, *, tenant_id: int, function_name: str, routing_meta=None):
_ = (db, tenant_id, function_name, routing_meta)
return {
"name": "Qwen 低阶引擎",
"provider": "glm",
"model": "qwen-plus",
"_routing_meta": {"planned_tier": "low", "executed_tier": "low"},
}
class _FakeService(AdaptiveRAGDeepGuardAnswerMixin):
def __init__(self) -> None:
self.db = object()
self.tenant_id = 1
self.llm = _FakeLLM()
service = _FakeService()
packet = _build_internal_packet(
question="我们系统当前接的是哪个模型配置?",
route_decision={
"route_mode": "internal",
"effective_source_policy": "internal",
"decision_origin": "query_semantics_contract",
"reply_contract": {"mode": "explicit_source", "head": "内部知识库查询:"},
"query_profile": {
"needs_private_context": True,
"needs_freshness": False,
"referenced_objects": ["system_config", "model_routing"],
"confidence": 0.92,
},
},
operation_contract={
"query_type": "status",
"answer_contract": "entity_snapshot",
"object_mentions": ["当前模型配置"],
},
target_resolution={
"target_entity_type": "config",
"target_ref": "config:model_routing",
"resolved_label": "当前模型配置",
"resolution_mode": "deterministic",
"confidence": 0.98,
"candidate_summary": [],
},
query_plan={
"plan_version": "v2",
"query_type": "status",
"entity_type": "config",
"answer_contract": "entity_snapshot",
"target_ref": "config:model_routing",
"filters": [],
},
)
fast_result = await service._build_internal_model_config_fast_answer(
question="我们系统当前接的是哪个模型配置?",
query_semantics={
"effective_source_policy": "internal",
"query_type": "existence",
"operation_contract": {
"query_type": "existence",
"answer_contract": "yes_no_with_evidence",
},
"query_profile": {
"needs_private_context": True,
"needs_freshness": False,
"referenced_objects": [],
},
"target_resolution": {
"target_entity_type": "project",
"target_ref": "project:72",
},
},
internal_context_packet=packet,
)
assert isinstance(fast_result, dict)
assert "qwen-plus" in str(fast_result["answer"])
assert fast_result["query_type"] == "status"

View File

@ -110,6 +110,7 @@ async def test_mcp_admin_catalog_and_server_lifecycle(db_session, monkeypatch) -
assert catalog_response.json()["code"] == 0 assert catalog_response.json()["code"] == 0
assert catalog_response.json()["data"]["recommended_template_id"] == "microsoft_learn" assert catalog_response.json()["data"]["recommended_template_id"] == "microsoft_learn"
assert catalog_response.json()["data"]["items"][0]["execution_mode"] == "backend_direct" assert catalog_response.json()["data"]["items"][0]["execution_mode"] == "backend_direct"
assert any(item["id"] == "duckduckgo" for item in catalog_response.json()["data"]["items"])
assert create_response.status_code == 200 assert create_response.status_code == 200
assert create_response.json()["code"] == 0 assert create_response.json()["code"] == 0
@ -267,3 +268,43 @@ async def test_local_bridge_template_uses_manual_instructions_and_can_be_confirm
assert confirm_response.status_code == 200 assert confirm_response.status_code == 200
assert confirm_response.json()["data"]["client_apply_state"] == "user_confirmed" assert confirm_response.json()["data"]["client_apply_state"] == "user_confirmed"
@pytest.mark.asyncio
async def test_duckduckgo_template_uses_backend_direct_preview_without_extra_params(db_session, monkeypatch) -> None:
async def _fake_probe(self, *, template, params): # noqa: ANN001
_ = (self, params)
assert template.id == "duckduckgo"
return {
"status": "ready",
"checked_at": "2026-04-16T20:01:00+08:00",
"message": "PMFlow backend can directly reach DuckDuckGo search runtime.",
"tool_names": ["ddg-text-search", "ddg-news-search"],
}
monkeypatch.setattr(McpConfigService, "_probe_backend_runtime", _fake_probe)
app = _build_app(db_session, role="ADMIN")
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as client:
create_response = await client.post(
"/mcp/admin/servers",
json={
"name": "duckduckgo",
"display_name": "DuckDuckGo MCP",
"template_id": "duckduckgo",
"params": {},
},
)
created = create_response.json()["data"]
validate_response = await client.post(f"/mcp/admin/servers/{created['id']}/validate")
assert create_response.status_code == 200
assert created["execution_mode"] == "backend_direct"
assert created["client_apply_state"] == "not_required"
assert created["params"] == {}
assert "无需本机命令" in created["preview"]["add_command"]
assert "ddg-text-search" in created["preview"]["expected_output"]
assert validate_response.status_code == 200
assert validate_response.json()["data"]["runtime_probe"]["status"] == "ready"

View File

@ -17,7 +17,7 @@ from app.services.external_query_service.gateway import ExternalQueryGateway
from app.services.external_query_service.registry import ExternalProviderRegistry from app.services.external_query_service.registry import ExternalProviderRegistry
from app.services.external_query_service.schemas import ExternalAdapterResult, ExternalEvidenceItem, ExternalQueryPlan from app.services.external_query_service.schemas import ExternalAdapterResult, ExternalEvidenceItem, ExternalQueryPlan
from app.services.mcp_config import McpConfigService from app.services.mcp_config import McpConfigService
from app.services.mcp_config.runtime import MicrosoftLearnMCPRuntime from app.services.mcp_config.runtime import DuckDuckGoSearchRuntime, MicrosoftLearnMCPRuntime
def _use_sqlite_json_types(*tables) -> None: def _use_sqlite_json_types(*tables) -> None:
@ -51,6 +51,17 @@ async def _seed_backend_direct_server(db_session) -> None:
await db_session.commit() await db_session.commit()
async def _seed_duckduckgo_backend_direct_server(db_session) -> None:
service = McpConfigService(db_session, tenant_id=1)
await service.create_server(
name="duckduckgo",
display_name="DuckDuckGo MCP",
template_id="duckduckgo",
params={},
)
await db_session.commit()
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_mcp_config_service_search_external_uses_backend_direct_server(db_session, monkeypatch) -> None: async def test_mcp_config_service_search_external_uses_backend_direct_server(db_session, monkeypatch) -> None:
await _seed_backend_direct_server(db_session) await _seed_backend_direct_server(db_session)
@ -77,6 +88,7 @@ async def test_mcp_config_service_search_external_uses_backend_direct_server(db_
evidence = await McpConfigService(db_session, tenant_id=1).search_external( evidence = await McpConfigService(db_session, tenant_id=1).search_external(
query="azure openai quickstart", query="azure openai quickstart",
result_limit=3, result_limit=3,
query_kind="model",
) )
assert len(evidence) == 1 assert len(evidence) == 1
@ -84,12 +96,51 @@ async def test_mcp_config_service_search_external_uses_backend_direct_server(db_
assert evidence[0].target_url == "https://learn.microsoft.com/azure/ai-foundry/openai/chatgpt-quickstart" assert evidence[0].target_url == "https://learn.microsoft.com/azure/ai-foundry/openai/chatgpt-quickstart"
@pytest.mark.asyncio
async def test_mcp_config_service_search_external_uses_duckduckgo_backend_direct_for_generic_web(
db_session,
monkeypatch,
) -> None:
await _seed_duckduckgo_backend_direct_server(db_session)
async def _fake_search(self, *, query: str, result_limit: int = 3, query_kind: str = "unknown"): # noqa: ANN001
_ = (self, result_limit)
assert query == "OpenAI latest news"
assert query_kind == "generic_web"
return [
ExternalEvidenceItem(
source_type="mcp_search",
source_label="DuckDuckGo Search",
title="OpenAI ships new release",
target_url="https://news.example/openai-release",
snippet="Latest OpenAI release details.",
retrieved_at="2026-04-16T20:10:00+08:00",
freshness_score=0.8,
authority_score=0.62,
grounded_fields=["ddg-text-search"],
raw_metadata={"provider": "duckduckgo_backend_direct"},
)
]
monkeypatch.setattr(DuckDuckGoSearchRuntime, "search", _fake_search)
evidence = await McpConfigService(db_session, tenant_id=1).search_external(
query="OpenAI latest news",
result_limit=3,
query_kind="generic_web",
)
assert len(evidence) == 1
assert evidence[0].source_label == "DuckDuckGo Search"
assert evidence[0].target_url == "https://news.example/openai-release"
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_external_query_gateway_prefers_backend_direct_mcp_when_available(db_session, monkeypatch) -> None: async def test_external_query_gateway_prefers_backend_direct_mcp_when_available(db_session, monkeypatch) -> None:
await _seed_backend_direct_server(db_session) await _seed_backend_direct_server(db_session)
async def _fake_search_external(self, *, query: str, result_limit: int = 3): # noqa: ANN001 async def _fake_search_external(self, *, query: str, result_limit: int = 3, query_kind: str = "unknown"): # noqa: ANN001
_ = (self, query, result_limit) _ = (self, query, result_limit, query_kind)
return [ return [
ExternalEvidenceItem( ExternalEvidenceItem(
source_type="mcp_search", source_type="mcp_search",
@ -129,6 +180,67 @@ async def test_external_query_gateway_prefers_backend_direct_mcp_when_available(
assert meta["adapter"] == "mcp" assert meta["adapter"] == "mcp"
@pytest.mark.asyncio
async def test_external_query_gateway_returns_external_context_packet_in_meta() -> None:
class _FakeService(AdaptiveRAGAnswerRetrievalMixin, AdaptiveRAGDeepGuardRulesMixin):
def __init__(self) -> None:
self.db = object()
self.tenant_id = 1
self.llm = None
async def _execute_external_mcp_search(self, plan): # noqa: ANN001
_ = plan
return {
"adapter_name": "mcp",
"status": "unavailable",
"evidence": [],
"meta": {"reason": "skip_mcp"},
}
def _build_live_external_search_queries(self, question: str) -> list[str]:
return [question]
async def _search_live_external_results(self, *, query: str, max_results: int = 5): # noqa: ANN001
_ = (query, max_results)
return [{"url": "https://weather.example/zhanjiang-5day", "title": "湛江未来五天天气"}]
async def _fetch_url_text(self, url: str) -> str:
assert url == "https://weather.example/zhanjiang-5day"
return "湛江未来5天有阵雨转多云,最高温33度,最低温26度。"
def _chunk_text(self, text: str, *, chunk_size: int = 900, overlap: int = 120) -> list[str]:
_ = (chunk_size, overlap)
return [text]
def _score_live_external_search_result(self, *, question: str, title: str, url: str) -> float:
_ = (question, title, url)
return 0.92
def _score_live_external_chunk(self, *, question: str, title: str, content: str, url: str) -> float:
_ = (question, title, url)
return 0.94 if "湛江未来5天" in content else 0.1
service = _FakeService()
rows, meta = await service._execute_external_query_gateway(
question="湛江市未来五天的天气",
query_semantics={
"query_profile": {"needs_freshness": True},
"object_mentions": ["湛江市"],
},
result_limit=3,
max_pages=1,
)
assert len(rows) == 1
packet = meta.get("external_context_packet")
assert isinstance(packet, dict)
assert packet["question"] == "湛江市未来五天的天气"
assert packet["plan"]["answer_contract"] == "summary_with_sources"
assert packet["verified_evidence"]
assert packet["adapter_attempts"]
assert packet["adapter_attempts"][-1]["adapter"] == "live_search"
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_external_query_gateway_falls_back_when_mcp_evidence_is_off_topic() -> None: async def test_external_query_gateway_falls_back_when_mcp_evidence_is_off_topic() -> None:
plan = ExternalQueryPlan( plan = ExternalQueryPlan(

View File

@ -1,11 +1,12 @@
from __future__ import annotations from __future__ import annotations
import json import json
from types import SimpleNamespace
import httpx import httpx
import pytest import pytest
from app.services.mcp_config.runtime import MicrosoftLearnMCPRuntime from app.services.mcp_config.runtime import DuckDuckGoSearchRuntime, MicrosoftLearnMCPRuntime
def _sse(payload: dict) -> httpx.Response: def _sse(payload: dict) -> httpx.Response:
@ -118,3 +119,75 @@ async def test_microsoft_learn_runtime_search_returns_external_evidence() -> Non
assert evidence[0].title == "Quickstart: Azure OpenAI" assert evidence[0].title == "Quickstart: Azure OpenAI"
assert evidence[0].target_url == "https://learn.microsoft.com/azure/ai-foundry/openai/chatgpt-quickstart" assert evidence[0].target_url == "https://learn.microsoft.com/azure/ai-foundry/openai/chatgpt-quickstart"
assert evidence[0].authority_score >= 0.9 assert evidence[0].authority_score >= 0.9
@pytest.mark.asyncio
async def test_duckduckgo_runtime_probe_reports_text_and_news_tools() -> None:
runtime = DuckDuckGoSearchRuntime(ddgs_factory=lambda: SimpleNamespace())
probe = await runtime.probe()
assert probe["status"] == "ready"
assert probe["tool_names"] == ["ddg-text-search", "ddg-news-search"]
@pytest.mark.asyncio
async def test_duckduckgo_runtime_search_returns_news_evidence() -> None:
class _FakeDDGS:
def news(self, *, keywords: str, region: str, safesearch: str, timelimit: str | None, max_results: int): # noqa: ANN001
assert keywords == "OpenAI latest news"
assert region == "wt-wt"
assert safesearch == "moderate"
assert timelimit == "d"
assert max_results == 3
return [
{
"title": "OpenAI ships new release",
"url": "https://news.example/openai-release",
"body": "Latest OpenAI release details.",
"date": "2026-04-16T10:14:00+00:00",
"source": "Example News",
}
]
runtime = DuckDuckGoSearchRuntime(ddgs_factory=lambda: _FakeDDGS())
evidence = await runtime.search(query="OpenAI latest news", result_limit=3, query_kind="news")
assert len(evidence) == 1
assert evidence[0].source_type == "mcp_search"
assert evidence[0].source_label == "DuckDuckGo Search"
assert evidence[0].title == "OpenAI ships new release"
assert evidence[0].target_url == "https://news.example/openai-release"
assert evidence[0].raw_metadata["provider"] == "duckduckgo_backend_direct"
@pytest.mark.asyncio
async def test_duckduckgo_runtime_search_falls_back_to_text_when_news_is_rate_limited() -> None:
class _FakeDDGS:
def news(self, **kwargs): # noqa: ANN003
_ = kwargs
raise RuntimeError("403 Ratelimit")
def text(self, *, keywords: str, region: str, safesearch: str, timelimit: str | None, max_results: int): # noqa: ANN001
assert keywords == "OpenAI latest news"
assert region == "wt-wt"
assert safesearch == "moderate"
assert timelimit == "w"
assert max_results == 2
return [
{
"title": "OpenAI text fallback result",
"href": "https://example.com/openai-fallback",
"body": "Fallback web result after news rate limit.",
}
]
runtime = DuckDuckGoSearchRuntime(ddgs_factory=lambda: _FakeDDGS())
evidence = await runtime.search(query="OpenAI latest news", result_limit=2, query_kind="news")
assert len(evidence) == 1
assert evidence[0].title == "OpenAI text fallback result"
assert evidence[0].target_url == "https://example.com/openai-fallback"
assert evidence[0].grounded_fields == ["ddg-text-search"]

View File

@ -0,0 +1,191 @@
from types import SimpleNamespace
import pytest
from fastapi import FastAPI
from httpx import ASGITransport, AsyncClient
from app.api.rag_api import routes_route_governance
from app.api.router import api_router
from app.core.database import get_db
from app.dependencies.auth import get_current_user
from app.dependencies.tenant import get_tenant_id
def _build_test_app(*, role: str = "ADMIN") -> FastAPI:
app = FastAPI()
app.include_router(routes_route_governance.router)
async def _fake_get_db():
yield object()
app.dependency_overrides[get_db] = _fake_get_db
app.dependency_overrides[get_tenant_id] = lambda: 9
app.dependency_overrides[get_current_user] = lambda: SimpleNamespace(
id=101,
tenant_id=9,
username="tester",
role=role,
)
return app
def test_rag_api_router_exposes_query_route_governance_routes() -> None:
route_paths = {str(getattr(route, "path", "")).strip() for route in api_router.routes}
assert "/rag/query-route-governance/summary" in route_paths
assert "/rag/query-route-governance/items" in route_paths
@pytest.mark.asyncio
async def test_query_route_governance_summary_requires_manager_role() -> None:
app = _build_test_app(role="MEMBER")
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as client:
response = await client.get("/rag/query-route-governance/summary")
assert response.status_code == 403
assert response.json()["detail"] == "Manager permission required"
@pytest.mark.asyncio
async def test_query_route_governance_summary_returns_manager_payload(monkeypatch) -> None:
captured: dict[str, object] = {}
class FakeService:
def __init__(self, db, tenant_id: int):
_ = db
captured["tenant_id"] = tenant_id
async def query_route_governance_summary(self, **kwargs):
captured["summary_kwargs"] = kwargs
return {
"window_days": 30,
"project_id": 17,
"kpis": {
"total_records": 4,
"mismatch_count": 1,
"clarify_count": 2,
"negative_feedback_count": 1,
"internal_count": 1,
"external_count": 2,
"uncertain_count": 1,
},
"route_modes": [{"key": "external", "label": "外部实时查询", "count": 2}],
"decision_origins": [{"key": "reply_anchor", "label": "reply_anchor", "count": 2}],
}
monkeypatch.setattr(routes_route_governance, "AdaptiveRAGService", FakeService)
app = _build_test_app(role="ADMIN")
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as client:
response = await client.get("/rag/query-route-governance/summary", params={"project_id": 17, "window_days": 30})
assert response.status_code == 200
payload = response.json()
assert payload["code"] == 0
assert payload["data"]["kpis"]["mismatch_count"] == 1
assert captured["tenant_id"] == 9
assert captured["summary_kwargs"] == {"project_id": 17, "window_days": 30}
@pytest.mark.asyncio
async def test_query_route_governance_items_and_detail_return_contract(monkeypatch) -> None:
class FakeService:
def __init__(self, db, tenant_id: int):
_ = db, tenant_id
async def list_query_route_governance_records(self, **kwargs):
assert kwargs["mismatch_only"] is True
return {
"items": [
{
"id": 7,
"qa_record_id": 70,
"project_id": 17,
"session_id": 12,
"session_title": "Alpha 会话",
"question_text": "这个项目怎么样?",
"route_mode_predicted": "internal",
"route_mode_effective": "external",
"decision_origin": "reply_anchor",
"reply_mode": "clarify",
"feedback_rating": "negative",
"feedback_helpfulness": "could_be_better",
"is_route_mismatch": True,
"is_clarify": True,
"anchor_summary": "source_chat:pmflow-project-17",
"answer_mode": "clarify",
"grounding_status": "insufficient_evidence",
"asked_via": "pmflow_manual",
"created_at": "2026-04-17T12:00:00+08:00",
"updated_at": "2026-04-17T12:00:00+08:00",
}
],
"total": 1,
"limit": 20,
"offset": 0,
}
async def get_query_route_governance_detail(self, **kwargs):
assert kwargs == {"record_id": 7, "project_id": 17}
return {
"record": {
"id": 7,
"qa_record_id": 70,
"project_id": 17,
"session_id": 12,
"session_title": "Alpha 会话",
"question_text": "这个项目怎么样?",
"route_mode_predicted": "internal",
"route_mode_effective": "external",
"decision_origin": "reply_anchor",
"reply_mode": "clarify",
"feedback_rating": "negative",
"feedback_helpfulness": "could_be_better",
"is_route_mismatch": True,
"is_clarify": True,
"anchor_summary": "source_chat:pmflow-project-17",
"answer_mode": "clarify",
"grounding_status": "insufficient_evidence",
"asked_via": "pmflow_manual",
"created_at": "2026-04-17T12:00:00+08:00",
"updated_at": "2026-04-17T12:00:00+08:00",
},
"qa_record": {
"id": 70,
"question": "这个项目怎么样?",
"answer": "外部实时查询:证据不足。",
},
"route_memory": {
"reply_contract": {"mode": "clarify"},
"probe_snapshot": {"contract": {"source_policy": "uncertain"}},
"query_profile": {"subject_focus": "project"},
"feedback_outcome": {"rating": "negative"},
"decision_origin": "reply_anchor",
},
}
monkeypatch.setattr(routes_route_governance, "AdaptiveRAGService", FakeService)
app = _build_test_app(role="ADMIN")
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as client:
list_response = await client.get(
"/rag/query-route-governance/items",
params={"project_id": 17, "mismatch_only": True, "limit": 20, "offset": 0},
)
detail_response = await client.get(
"/rag/query-route-governance/items/7",
params={"project_id": 17},
)
assert list_response.status_code == 200
list_payload = list_response.json()
assert list_payload["code"] == 0
assert list_payload["data"]["total"] == 1
assert list_payload["data"]["items"][0]["is_route_mismatch"] is True
assert detail_response.status_code == 200
detail_payload = detail_response.json()
assert detail_payload["code"] == 0
assert detail_payload["data"]["route_memory"]["feedback_outcome"]["rating"] == "negative"

View File

@ -91,6 +91,24 @@ def test_build_kernel_ingress_kwargs_assigns_unique_source_message_id_for_rag()
assert source_message_id_a != source_message_id_b assert source_message_id_a != source_message_id_b
def test_build_kernel_ingress_kwargs_preserves_explicit_anchor_fields() -> None:
request = RAGAskRequest(
question="这个项目怎么样",
project_id=17,
source_chat_id="pmflow-project-17",
)
kwargs = rag_ask_contract.build_kernel_ingress_kwargs(
request=request,
current_user=SimpleNamespace(id=1, tenant_id=1, username="admin"),
)
assert kwargs["project_hint_id"] == 17
assert kwargs["channel_ref"] == "pmflow-project-17"
assert kwargs["ingress_options"]["rag_request_context"]["project_id"] == 17
assert kwargs["ingress_options"]["rag_request_context"]["source_chat_id"] == "pmflow-project-17"
@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] = {}

View File

@ -16,8 +16,11 @@ from app.models.task import Task
from app.models.truth_node import TruthNode from app.models.truth_node import TruthNode
from app.models.user import User from app.models.user import User
from app.services.adaptive_rag_service.mixin_answer_payload import AdaptiveRAGAnswerPayloadMixin from app.services.adaptive_rag_service.mixin_answer_payload import AdaptiveRAGAnswerPayloadMixin
from app.services.adaptive_rag_service.mixin_conversation_utils_a_p1 import AdaptiveRAGConversationUtilsAMixinP1
from app.services.adaptive_rag_service.mixin_fast_answer_semantic_p1 import AdaptiveRAGFastAnswerSemanticMixinP1
from app.services.adaptive_rag_service.mixin_fast_answer_project_main import AdaptiveRAGFastAnswerProjectMainMixin from app.services.adaptive_rag_service.mixin_fast_answer_project_main import AdaptiveRAGFastAnswerProjectMainMixin
from app.services.adaptive_rag_service.mixin_fast_answer_project_utils import AdaptiveRAGFastAnswerProjectUtilsMixin from app.services.adaptive_rag_service.mixin_fast_answer_project_utils import AdaptiveRAGFastAnswerProjectUtilsMixin
from app.services.adaptive_rag_service.internal_query_context import InternalContextPacket
from app.services.adaptive_rag_service.mixin_retrieval_router_db import AdaptiveRAGRetrievalRouterDBMixin from app.services.adaptive_rag_service.mixin_retrieval_router_db import AdaptiveRAGRetrievalRouterDBMixin
from app.services.llm_service import StructuredExtractionResult from app.services.llm_service import StructuredExtractionResult
from app.services.semantic_fact_service.service import SemanticFactService from app.services.semantic_fact_service.service import SemanticFactService
@ -398,6 +401,296 @@ async def test_project_existence_answer_prefers_semantic_facts_before_text_fallb
assert str(result["sources"][0]["metadata"]["table"]) == "semantic_facts" assert str(result["sources"][0]["metadata"]["table"]) == "semantic_facts"
@pytest.mark.asyncio
async def test_glm_project_semantic_answer_passes_owner_fields_into_prompt(db_session, monkeypatch) -> None:
owner = User(
tenant_id=1,
username="project_owner",
email="project_owner@example.com",
password_hash="x",
name="李雷",
role="ADMIN",
assigned_domains=[],
workload=0,
status="active",
is_active=True,
)
db_session.add(owner)
await db_session.flush()
project = Project(
tenant_id=1,
name="inventory-reporting",
title="采购经理每日实时库存上报服务",
description="采购经理每日查看库存并生成上报。",
owner_id=int(owner.id),
status="active",
assigned_role="backend",
)
db_session.add(project)
await db_session.flush()
captured: dict[str, str] = {}
async def _fake_render_prompt(self, *, fallback_template: str, **kwargs):
_ = self
return str(fallback_template).format(**kwargs).strip()
monkeypatch.setattr(
"app.services.adaptive_rag_service.mixin_fast_answer_semantic_p1.PromptReleaseService.render_prompt",
_fake_render_prompt,
)
class _FakeLLM:
async def generate(self, prompt: str, **kwargs):
_ = kwargs
captured["prompt"] = prompt
return (
'{"handled": true, "answer": "有,负责人:李雷。", '
f'"matched_project_ids": [{int(project.id)}], "confidence": 0.97}}'
)
class _FakeService(
AdaptiveRAGFastAnswerSemanticMixinP1,
AdaptiveRAGConversationUtilsAMixinP1,
AdaptiveRAGFastAnswerProjectMainMixin,
AdaptiveRAGFastAnswerProjectUtilsMixin,
):
def __init__(self) -> None:
self.db = db_session
self.tenant_id = 1
self.llm = _FakeLLM()
service = _FakeService()
result = await service._try_glm_project_semantic_answer(
clean_q="系统里有没有采购经理每日实时库存上报服务?",
project_id=None,
query_semantics={
"subject_focus": "project",
"query_type": "existence",
"operation_contract": {
"query_type": "existence",
"answer_contract": "yes_no_with_evidence",
"object_mentions": ["采购经理每日实时库存上报服务"],
},
},
)
assert result is not None
assert "负责人:李雷" in str(result["answer"])
assert "owner_name" in captured["prompt"]
assert "李雷" in captured["prompt"]
assert "yes_no_with_evidence" in captured["prompt"]
@pytest.mark.asyncio
async def test_glm_project_semantic_answer_appends_deterministic_attention_points(db_session, monkeypatch) -> None:
owner = User(
tenant_id=1,
username="project_owner_attention",
email="project_owner_attention@example.com",
password_hash="x",
name="李雷",
role="ADMIN",
assigned_domains=[],
workload=0,
status="active",
is_active=True,
)
db_session.add(owner)
await db_session.flush()
project = Project(
tenant_id=1,
name="inventory-attention",
title="采购经理库存预警服务",
description="采购经理查看库存预警并推进处理。",
owner_id=int(owner.id),
status="active",
assigned_role="backend",
)
db_session.add(project)
await db_session.flush()
db_session.add(
Signal(
tenant_id=1,
source="feishu",
title="库存异常待确认",
summary="库存异常待处理",
content="库存异常待处理",
status="待处理",
processed=False,
planner_status="pending",
matched_project_id=int(project.id),
)
)
await db_session.flush()
async def _fake_render_prompt(self, *, fallback_template: str, **kwargs):
_ = self
return str(fallback_template).format(**kwargs).strip()
monkeypatch.setattr(
"app.services.adaptive_rag_service.mixin_fast_answer_semantic_p1.PromptReleaseService.render_prompt",
_fake_render_prompt,
)
class _FakeLLM:
async def generate(self, prompt: str, **kwargs):
_ = (prompt, kwargs)
return (
'{"handled": true, "answer": "有,命中 1 个项目。", '
f'"matched_project_ids": [{int(project.id)}], "confidence": 0.97}}'
)
class _FakeService(
AdaptiveRAGFastAnswerSemanticMixinP1,
AdaptiveRAGConversationUtilsAMixinP1,
AdaptiveRAGFastAnswerProjectMainMixin,
AdaptiveRAGFastAnswerProjectUtilsMixin,
):
def __init__(self) -> None:
self.db = db_session
self.tenant_id = 1
self.llm = _FakeLLM()
service = _FakeService()
result = await service._try_glm_project_semantic_answer(
clean_q="系统里有没有采购经理库存预警服务?",
project_id=None,
query_semantics={
"subject_focus": "project",
"query_type": "existence",
"operation_contract": {
"query_type": "existence",
"answer_contract": "yes_no_with_evidence",
"object_mentions": ["采购经理库存预警服务"],
},
},
)
assert result is not None
answer = str(result["answer"])
assert "有,命中 1 个项目。" in answer
assert "当前判断:" in answer
assert "当前盘面:" in answer
assert "建议动作:" in answer
assert "负责人 李雷" in answer
@pytest.mark.asyncio
async def test_glm_project_semantic_answer_prefers_internal_context_packet_contract(db_session, monkeypatch) -> None:
project = Project(
tenant_id=1,
name="inventory-packet",
title="采购经理库存预警服务",
description="采购经理查看库存预警并推进处理。",
owner_id=None,
status="active",
assigned_role="backend",
)
db_session.add(project)
await db_session.flush()
captured: dict[str, str] = {}
async def _fake_render_prompt(self, *, fallback_template: str, **kwargs):
_ = self
rendered = str(fallback_template).format(**kwargs).strip()
captured["prompt"] = rendered
return rendered
monkeypatch.setattr(
"app.services.adaptive_rag_service.mixin_fast_answer_semantic_p1.PromptReleaseService.render_prompt",
_fake_render_prompt,
)
class _FakeLLM:
async def generate(self, prompt: str, **kwargs):
_ = kwargs
captured["llm_prompt"] = prompt
return (
'{"handled": true, "answer": "有,命中 1 个项目。", '
f'"matched_project_ids": [{int(project.id)}], "confidence": 0.97}}'
)
class _FakeService(
AdaptiveRAGFastAnswerSemanticMixinP1,
AdaptiveRAGConversationUtilsAMixinP1,
AdaptiveRAGFastAnswerProjectMainMixin,
AdaptiveRAGFastAnswerProjectUtilsMixin,
):
def __init__(self) -> None:
self.db = db_session
self.tenant_id = 1
self.llm = _FakeLLM()
service = _FakeService()
packet = InternalContextPacket.model_validate(
{
"packet_version": "v1",
"question": "系统里有没有采购经理库存预警服务?",
"scope": {
"tenant_id": 1,
"project_id": None,
"session_id": 101,
"conversation_scope_key": "scope:chat:semantic",
"reply_to_message_id": None,
"root_message_id": None,
},
"route_decision": {
"route_mode": "internal",
"effective_source_policy": "internal",
"decision_origin": "query_semantics_contract",
"reply_contract": {"mode": "explicit_source", "head": "内部知识库查询:"},
"query_profile": {
"needs_private_context": True,
"needs_freshness": False,
"referenced_objects": ["project_object"],
"confidence": 0.95,
},
},
"operation_contract": {
"query_type": "existence",
"answer_contract": "yes_no_with_evidence",
"object_mentions": ["采购经理库存预警服务"],
},
"target_resolution": {},
"query_plan": {
"plan_version": "v2",
"query_type": "existence",
"entity_type": "project",
"answer_contract": "yes_no_with_evidence",
"target_ref": None,
"filters": [],
},
"executor_summary": {},
"verifier_summary": {},
"uncertainties": [],
}
)
result = await service._try_glm_project_semantic_answer(
clean_q="系统里有没有采购经理库存预警服务?",
project_id=None,
query_semantics={
"subject_focus": "project",
"query_type": "status",
"operation_contract": {
"query_type": "status",
"answer_contract": "entity_snapshot",
"object_mentions": ["错误镜像"],
},
},
internal_context_packet=packet,
)
assert result is not None
assert "有,命中 1 个项目" in str(result["answer"])
assert "yes_no_with_evidence" in captured["prompt"]
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_structured_db_retrieval_prefers_semantic_fact_context_for_project_queries(db_session) -> None: async def test_structured_db_retrieval_prefers_semantic_fact_context_for_project_queries(db_session) -> None:
project = Project( project = Project(

View File

@ -99,11 +99,80 @@ def test_semantic_lint_flags_external_object_kind_mapping(tmp_path) -> None:
assert problems[0].rule_id == "semantic_object_kind_mapping" assert problems[0].rule_id == "semantic_object_kind_mapping"
def test_semantic_lint_flags_runtime_marker_tables(tmp_path) -> None:
module = _load_semantic_lint_module()
target = tmp_path / "backend" / "app" / "services" / "adaptive_rag_service" / "guards.py"
target.parent.mkdir(parents=True, exist_ok=True)
target.write_text('EXTERNAL_FACT_FRESHNESS_MARKERS = ("最新", "当前")\n', encoding="utf-8")
problems = module.collect_problems(
root=tmp_path,
scan_roots=(Path("backend/app/services/adaptive_rag_service"),),
baseline_path=Path("scripts/semantic_lint_baseline.txt"),
)
assert len(problems) == 1
assert problems[0].rule_id == "semantic_term_constant"
def test_semantic_lint_flags_lowercase_keyword_tables(tmp_path) -> None:
module = _load_semantic_lint_module()
target = tmp_path / "backend" / "app" / "services" / "integrations" / "reply_guard.py"
target.parent.mkdir(parents=True, exist_ok=True)
target.write_text('strict_keywords = ("只入库", "仅入库")\n', encoding="utf-8")
problems = module.collect_problems(
root=tmp_path,
scan_roots=(Path("backend/app/services/integrations"),),
baseline_path=Path("scripts/semantic_lint_baseline.txt"),
)
assert len(problems) == 1
assert problems[0].rule_id == "semantic_lowercase_term_table"
def test_semantic_lint_flags_followup_selection_regex_constants(tmp_path) -> None:
module = _load_semantic_lint_module()
target = tmp_path / "backend" / "app" / "services" / "adaptive_rag_service" / "followup.py"
target.parent.mkdir(parents=True, exist_ok=True)
target.write_text(
'FOLLOWUP_SELECTION_DIGIT_RE = re.compile(r"^\\\\s*([1-3])\\\\s*$")\n',
encoding="utf-8",
)
problems = module.collect_problems(
root=tmp_path,
scan_roots=(Path("backend/app/services/adaptive_rag_service"),),
baseline_path=Path("scripts/semantic_lint_baseline.txt"),
)
assert len(problems) == 1
assert problems[0].rule_id == "semantic_regex_constant"
def test_semantic_lint_flags_frontend_command_keyword_arrays(tmp_path) -> None:
module = _load_semantic_lint_module()
target = tmp_path / "frontend" / "src" / "components" / "command" / "items.ts"
target.parent.mkdir(parents=True, exist_ok=True)
target.write_text("const items = [{ keywords: ['dashboard', '首页'] }]\n", encoding="utf-8")
problems = module.collect_problems(
root=tmp_path,
scan_roots=(Path("frontend/src"),),
baseline_path=Path("scripts/semantic_lint_baseline.txt"),
)
assert len(problems) == 1
assert problems[0].rule_id == "semantic_keywords_property"
def test_semantic_lint_default_roots_cover_adaptive_rag_and_external_query() -> None: def test_semantic_lint_default_roots_cover_adaptive_rag_and_external_query() -> None:
module = _load_semantic_lint_module() module = _load_semantic_lint_module()
assert Path("backend/app/services/adaptive_rag_service") in module.DEFAULT_SCAN_ROOTS assert Path("backend/app/services/adaptive_rag_service") in module.DEFAULT_SCAN_ROOTS
assert Path("backend/app/services/external_query_service") in module.DEFAULT_SCAN_ROOTS assert Path("backend/app/services/external_query_service") in module.DEFAULT_SCAN_ROOTS
assert Path("frontend/src") in module.DEFAULT_SCAN_ROOTS
assert Path("scripts") in module.DEFAULT_SCAN_ROOTS
def test_semantic_lint_repo_has_no_compat_baseline_exceptions() -> None: def test_semantic_lint_repo_has_no_compat_baseline_exceptions() -> None:

View File

@ -22,9 +22,6 @@ CASES = [
{"idx": 5, "message": "帮我看看宠物行业最新动态", "expected_scene": "external_trend", "keys": ["宠物行业", "最新动态", "行业", "动态"]}, {"idx": 5, "message": "帮我看看宠物行业最新动态", "expected_scene": "external_trend", "keys": ["宠物行业", "最新动态", "行业", "动态"]},
] ]
BANNED_PREFIXES = ["让我先", "让我尝试", "我来看看", "我先查", "让我看看", "我需要先"]
def login_token() -> str: def login_token() -> str:
data = urllib.parse.urlencode({"username": "admin", "password": "admin123"}).encode() data = urllib.parse.urlencode({"username": "admin", "password": "admin123"}).encode()
req = urllib.request.Request( req = urllib.request.Request(
@ -93,7 +90,7 @@ def has_thinking_leak(reply: str) -> bool:
s = line.strip() s = line.strip()
if not s: if not s:
continue continue
if any(s.startswith(prefix) for prefix in BANNED_PREFIXES): if len(s) <= 12 and s.startswith("我") and "先" in s:
return True return True
return False return False

View File

@ -16,7 +16,6 @@ from app.models import AICallLog, AIOrchestrationRun, AIProcessingLog, FeishuRep
BASE = "http://127.0.0.1:8000" BASE = "http://127.0.0.1:8000"
CHAT_ID = "oc_c487d87563e48c2f9566ccd5a758212c" CHAT_ID = "oc_c487d87563e48c2f9566ccd5a758212c"
SENDER = "P1验收机器人" SENDER = "P1验收机器人"
BANNED_PREFIXES = ["让我先", "让我尝试", "我来看看", "我先查", "让我看看", "我需要先"]
DEFAULT_LOG_PATH = Path(__file__).resolve().parent / "tmp_acceptance" / "consultant_dispatch_feishu_runs.jsonl" DEFAULT_LOG_PATH = Path(__file__).resolve().parent / "tmp_acceptance" / "consultant_dispatch_feishu_runs.jsonl"
TERMINAL_RUN_STATUSES = {"succeeded", "failed", "cancelled", "answered", "completed"} TERMINAL_RUN_STATUSES = {"succeeded", "failed", "cancelled", "answered", "completed"}
@ -89,7 +88,7 @@ def has_thinking_leak(text: str) -> bool:
s = line.strip() s = line.strip()
if not s: if not s:
continue continue
if any(s.startswith(prefix) for prefix in BANNED_PREFIXES): if len(s) <= 12 and s.startswith("我") and "先" in s:
return True return True
return False return False

View File

@ -53,7 +53,10 @@
### 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/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/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`
- `backend/app/services/llm_service/` - `backend/app/services/llm_service/`
@ -97,13 +100,15 @@
- 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,归一化产出 `route_mode / decision_origin / reply_contract / probe_snapshot(contract audit)`,不再在运行时执行 internal/external probe 或 route memory 强拍板,再进入检索与回答主链。 - non-plan `query.answer` 现已在 `AdaptiveRAG` 内增加 `Query Route Gateway`:只消费上游单次结构化 `query_profile/source_policy/effective_source_policy` 与显式 hint,归一化产出 `route_mode / decision_origin / reply_contract / probe_snapshot(contract audit)`,不再在运行时执行 internal/external probe 或 route memory 强拍板,再进入检索与回答主链。
- internal `query.answer` 的下一阶段基线已经确认:`Query Route Gateway` 之后不再直接靠快答分支拍板,而是统一进入 `Operation Contract -> Target Resolver -> Query DSL Plan -> 白名单受控执行器 -> semantic facts 优先 -> verifier` 主链;相关设计规格见 `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`。
- 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` 执行;AdaptiveRAG external probe、freshness fetch 与 external route retrieval 已接到这条链上,并对 external sources 执行 fail-closed 过滤;相关设计规格见 `docs/superpowers/specs/2026-04-15-external-query-gateway-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` 的镜像也已收口到单点兼容写回。
- `query.answer` 的客户可见回答展示也已确认独立基线:运行时证据必须拆成 `customer_safe` 与 `admin_debug` 双视图,客户侧统一消费中文化 `display_label`,禁止直接暴露 `raw_source_label`、机器来源或内部术语;相关设计规格见 `docs/superpowers/specs/2026-04-15-answer-presentation-policy-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`。
- `query.answer` 的客户可见回答展示也已确认独立基线:运行时证据必须拆成 `customer_safe` 与 `admin_debug` 双视图,客户侧统一消费中文化 `display_label`,禁止直接暴露 `raw_source_label`、机器来源或内部术语;external answer presenter 的 prompt 输入也必须保持 external-only,仅允许消费经 verifier 通过的外部证据摘要与计划摘要;相关设计规格见 `docs/superpowers/specs/2026-04-15-answer-presentation-policy-design.md`。
- `semantic_fact_service` 已升级为统一基线:`semantic_facts / semantic_fact_extraction_runs` 读模型、ORM 提交钩子自动收集对象变更并 upsert `ingest_events(channel=semantic_fact_extract)`、统一 worker 抽取/失活,以及 internal query 的 facts-first 主路径。 - `semantic_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` 状态;远程 `streamable_http` 模板可由 PMFlow 后端直连,本地 `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`。
- `query_route_memories` 作为独立路由记忆表,承接 `QARecord` 的 route audit、feedback 回灌和离线评估样本,不复用原 answer-quality 资产表。 - `query_route_memories` 作为独立路由记忆表,承接 `QARecord` 的 route audit、feedback 回灌和离线评估样本,不复用原 answer-quality 资产表。
- `query_route_memories` 现已补 manager-only 只读治理入口:`backend/app/api/rag_api/routes_route_governance.py` + `mixin_query_route_governance.py` 负责 `/rag/query-route-governance/summary|items|items/{id}`,统一暴露 `reply_contract / probe_snapshot / query_profile / feedback_outcome` 审计快照,但 v1 不做在线自动调路由。
- `backend/tests/test_runtime_structured_semantics_contract.py` 与 `backend/tests/test_feishu_pipeline_queue_contract.py` 共同守门 runtime 结构化语义收口,锁定飞书入口、signal capture、reply/runtime routing 不得回流旧词法拍板 helper。 - `backend/tests/test_runtime_structured_semantics_contract.py` 与 `backend/tests/test_feishu_pipeline_queue_contract.py` 共同守门 runtime 结构化语义收口,锁定飞书入口、signal capture、reply/runtime routing 不得回流旧词法拍板 helper。
- `backend/tests/test_external_query_planner.py` 现已补进 external fallback 回归,锁定 planner fallback 只能产出 generic external plan,不得再根据 `weather/news/public_model/...` 之类对象映射在运行时硬分 query_kind。 - `backend/tests/test_external_query_planner.py` 现已补进 external fallback 回归,锁定 planner fallback 只能产出 generic external plan,不得再根据 `weather/news/public_model/...` 之类对象映射在运行时硬分 query_kind。
@ -121,6 +126,7 @@
- `frontend/src/pages/ai-config/` - `frontend/src/pages/ai-config/`
- `frontend/src/pages/model-routing-config/` - `frontend/src/pages/model-routing-config/`
- `frontend/src/pages/ai-config/mcp-config/` - `frontend/src/pages/ai-config/mcp-config/`
- `frontend/src/pages/query-route-governance/`
- `frontend/src/pages/task-brief-center/` - `frontend/src/pages/task-brief-center/`
- `frontend/src/pages/task-brief-runs/` - `frontend/src/pages/task-brief-runs/`
- `frontend/src/pages/task-brief-template/` - `frontend/src/pages/task-brief-template/`
@ -140,11 +146,15 @@
### 5.2 API 与共享模块 ### 5.2 API 与共享模块
- `frontend/src/api/aiPmo.ts` - `frontend/src/api/aiPmo.ts`
- `frontend/src/api/aiSteward.ts` - `frontend/src/api/aiSteward.ts`
- `frontend/src/components/ai-steward/`
- `frontend/src/components/ai-steward/QAConversationStatusBar.tsx`
- `frontend/src/components/ai-steward/QAResponseMetaCard.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`
- `frontend/src/api/knowledgeGraph.ts` - `frontend/src/api/knowledgeGraph.ts`
- `frontend/src/api/semanticFacts.ts` - `frontend/src/api/semanticFacts.ts`
- `frontend/src/api/queryRouteGovernance.ts`
- `frontend/src/api/mountedAi.ts` - `frontend/src/api/mountedAi.ts`
- `frontend/src/api/feedbackCenter.ts` - `frontend/src/api/feedbackCenter.ts`
- `frontend/src/api/sstc.ts` - `frontend/src/api/sstc.ts`
@ -158,7 +168,9 @@
- 页面级编排逻辑优先收敛到 `pages/*/` 子目录。 - 页面级编排逻辑优先收敛到 `pages/*/` 子目录。
- API 调用统一走 `frontend/src/api/`,不在页面或组件内直接 `fetch`。 - API 调用统一走 `frontend/src/api/`,不在页面或组件内直接 `fetch`。
- `frontend/src/components/sstc/` 继续作为 SSTC 共享组件目录维护。 - `frontend/src/components/sstc/` 继续作为 SSTC 共享组件目录维护。
- `AI Config -> Governance Tools` 现新增 `/ai-config/mcp-config` 与 `/ai-config/semantic-facts-board` 入口;两类治理页都只对 `BOSS/ADMIN` 开放。MCP 页负责模板化配置、后端直连/Local Bridge 状态展示、runtime probe、命令预览与本机确认;semantic facts 页负责 KPI、队列、对象表与详情抽屉。 - AI Steward 浮窗的“当前对象/来源模式/证据状态”必须直接消费 `/rag/ask` 与 QA session API 的显式 contract 字段,禁止在前端靠回答正文反推 internal/external 或 plan 状态。
- 网页问答显式锚点 v1 只能消费稳定强锚点:项目页路径中的 `project_id` 与 URL query 中已显式存在的 `project_id/source_chat_id`;前端禁止自行猜测 `source_chat_id`。
- `AI Config -> Governance Tools` 现新增 `/ai-config/mcp-config`、`/ai-config/query-route-governance` 与 `/ai-config/semantic-facts-board` 入口;三类治理页都只对 `BOSS/ADMIN` 开放。MCP 页负责模板化配置、后端直连/Local Bridge 状态展示、runtime probe、命令预览与本机确认;route/context 页负责 mismatch、clarify-heavy 与 negative feedback 样本排查;semantic facts 页负责 KPI、队列、对象表与详情抽屉。
## 6. 工具与运行库存 ## 6. 工具与运行库存
- `backend/scripts/repair_requirement_plan_history.py` - `backend/scripts/repair_requirement_plan_history.py`

View File

@ -30,12 +30,15 @@
### 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`
- Query 路由网关与显式来源回复合同:参考 `backend/app/services/adaptive_rag_service/mixin_query_route_gateway.py` - Query 路由网关与显式来源回复合同:参考 `backend/app/services/adaptive_rag_service/mixin_query_route_gateway.py`
- External Query Service 主链:参考 `backend/app/services/external_query_service/`(`planner.py`、`gateway.py`、`registry.py`、`adapters/`) - Query route governance 只读审计接口:参考 `backend/app/api/rag_api/routes_route_governance.py` 与 `backend/app/services/adaptive_rag_service/mixin_query_route_governance.py`
- External Query Service 主链:参考 `backend/app/services/external_query_service/`(`planner.py`、`gateway.py`、`registry.py`、`schemas.py`、`presenter.py`、`adapters/`)
- external route 回答接缝与回归:参考 `backend/app/services/adaptive_rag_service/mixin_answer_retrieval.py`、`mixin_deep_guard_answer.py`、`backend/tests/test_mcp_external_query_bridge.py`、`backend/tests/test_adaptive_rag_mainline_contract.py`
- 运行时语义守门与回归:参考 `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`
- 路由记忆样本模型:参考 `backend/app/models/query_route_memory.py` - 路由记忆样本模型:参考 `backend/app/models/query_route_memory.py`
- Internal Query `Operation Contract / Target Resolver / DSL / verifier` 主链:参考 `backend/app/services/adaptive_rag_service/mixin_target_resolution.py`、`mixin_answer_mainline.py`、`mixin_deterministic_query_core.py`、`mixin_answer_payload.py` - Internal Query `Operation Contract / Target Resolver / DSL / InternalContextPacket / verifier` 主链:参考 `backend/app/services/adaptive_rag_service/internal_query_context.py`、`mixin_target_resolution.py`、`mixin_answer_mainline.py`、`mixin_deterministic_query_core.py`、`mixin_answer_payload.py`
- Semantic facts 读模型与抽取服务:参考 `backend/app/models/semantic_fact.py`、`backend/app/models/semantic_fact_extraction_run.py`、`backend/app/services/semantic_fact_service/` - Semantic facts 读模型与抽取服务:参考 `backend/app/models/semantic_fact.py`、`backend/app/models/semantic_fact_extraction_run.py`、`backend/app/services/semantic_fact_service/`
- Semantic facts 自动入队钩子:参考 `backend/app/services/semantic_fact_service/hooks.py` - Semantic facts 自动入队钩子:参考 `backend/app/services/semantic_fact_service/hooks.py`
- Semantic facts 异步事件消费:参考 `backend/app/services/project_knowledge_graph_service/mixin_event_queue.py` - Semantic facts 异步事件消费:参考 `backend/app/services/project_knowledge_graph_service/mixin_event_queue.py`
@ -47,6 +50,7 @@
- AI PMO 草稿决策接口:参考 `POST /ai-pmo/work-items/{work_item_id}/draft-decision` - AI PMO 草稿决策接口:参考 `POST /ai-pmo/work-items/{work_item_id}/draft-decision`
- 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`
### 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`
@ -67,13 +71,19 @@
## 3. 前端参考 ## 3. 前端参考
- 新增前端 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 / QA session 前端 contract:参考 `frontend/src/api/aiSteward.ts`
- 网页问答显式锚点与 AI Steward URL 构造:参考 `frontend/src/components/ai-steward/qaPageAnchor.ts`
- Inbox 页面入口:参考 `frontend/src/pages/inbox/` - Inbox 页面入口:参考 `frontend/src/pages/inbox/`
- Inbox `plan_mode` 展开卡片与映射:参考 `frontend/src/components/inbox/InboxListItem.tsx`、`frontend/src/lib/inbox.ts`、`frontend/src/api/aiPmo.ts`
- Scheduler Center / Mounted AI Console:参考 `frontend/src/pages/scheduler-center/` - Scheduler Center / Mounted AI Console:参考 `frontend/src/pages/scheduler-center/`
- SSTC 工作流组件:参考 `frontend/src/components/sstc/` - SSTC 工作流组件:参考 `frontend/src/components/sstc/`
- Model Routing 页面:参考 `frontend/src/pages/model-routing-config/` - Model Routing 页面:参考 `frontend/src/pages/model-routing-config/`
- MCP 模板接入页:参考 `frontend/src/pages/ai-config/mcp-config/` - MCP 模板接入页:参考 `frontend/src/pages/ai-config/mcp-config/`
- Route / Context 治理页:参考 `frontend/src/pages/query-route-governance/`
- Semantic facts 治理页:参考 `frontend/src/pages/semantic-facts-board/` - Semantic facts 治理页:参考 `frontend/src/pages/semantic-facts-board/`
- MCP 前端 API:参考 `frontend/src/api/mcp.ts` - MCP 前端 API:参考 `frontend/src/api/mcp.ts`
- Route / Context 治理前端 API:参考 `frontend/src/api/queryRouteGovernance.ts`
- Semantic facts 前端 API:参考 `frontend/src/api/semanticFacts.ts` - Semantic facts 前端 API:参考 `frontend/src/api/semanticFacts.ts`
- AI Config 治理工具入口:参考 `frontend/src/pages/ai-config/constants.ts` 与 `frontend/src/router/index.tsx` - AI Config 治理工具入口:参考 `frontend/src/pages/ai-config/constants.ts` 与 `frontend/src/router/index.tsx`
@ -84,6 +94,7 @@
- Prompt 场景化分片:`backend/app/config/ai_prompts_catalog/` - Prompt 场景化分片:`backend/app/config/ai_prompts_catalog/`
- Prompt registry:`backend/app/config/ai_prompt_registry.py` - Prompt registry:`backend/app/config/ai_prompt_registry.py`
- External Query Planner Prompt:`backend/app/config/ai_prompts.py` 中的 `PROMPT_EXTERNAL_QUERY_PLAN` - External Query Planner Prompt:`backend/app/config/ai_prompts.py` 中的 `PROMPT_EXTERNAL_QUERY_PLAN`
- External Answer Presenter Prompts:`backend/app/config/ai_prompts.py` 中的 `PROMPT_EXTERNAL_DIRECT_FACT_QA`、`PROMPT_EXTERNAL_SUMMARY_WITH_SOURCES_QA`、`PROMPT_EXTERNAL_COMPARE_WITH_SOURCES_QA`
## 5. 维护建议 ## 5. 维护建议
- 新增接口时:先更新本文档,再同步 `API_CONTRACTS.md` - 新增接口时:先更新本文档,再同步 `API_CONTRACTS.md`

View File

@ -41,8 +41,14 @@
- 全程自动绑定 `tenant_id` - 全程自动绑定 `tenant_id`
- 不接收自由 SQL - 不接收自由 SQL
7. `Answer Verifier` 在出站前检查“问法合同、证据合同、来源合同”是否一致;若发现 existence/status/compare 被答成 total count、或证据与结果不符,必须重试或显式失败。 7. `Answer Verifier` 在出站前检查“问法合同、证据合同、来源合同”是否一致;若发现 existence/status/compare 被答成 total count、或证据与结果不符,必须重试或显式失败。
8. `AdaptiveRAG.answer_question()` 只消费已经收口好的 route 决策和对应结果:`internal` 只走受控内部查询主链;`external` 统一走 `External Query Planner -> External Query Gateway -> External Provider Registry -> MCP adapter 优先(当前已接通 Microsoft Learn backend-direct runtime)-> bounded fallback live search -> external answer verifier`,禁止再直接混入内部检索结果;`uncertain` 仅允许 `external-first` 或 clarification。 8. `AdaptiveRAG.answer_question()` 只消费已经收口好的 route 决策和对应结果:`internal` 只走受控内部查询主链;`external` 统一走 `External Query Planner -> External Query Gateway -> External Provider Registry -> MCP adapter 优先(当前已接通 Microsoft Learn backend-direct runtime)-> bounded fallback live search -> external answer verifier -> ExternalContextPacket -> ExternalAnswerPresenter`,禁止再直接混入内部检索结果,也禁止回流旧的 `industry_trend/external_trend` scene prompt;`uncertain` 仅允许 `external-first` 或 clarification。
9. 回答完成后: 9. external presenter 只读取 external-only 输入:
- question
- plan summary
- verified external evidence
- source summaries
若 verifier 未通过或 evidence 为空,则直接返回“证据不足/需澄清”,不得混入 `system_snapshot / user_snapshot / project_context / internal retrieval` 补答案。
10. 回答完成后:
- `QARecord.routing_snapshot`、`citations`、`sources_used` 继续落盘 - `QARecord.routing_snapshot`、`citations`、`sources_used` 继续落盘
- `query_route_memories` 追加或更新该次样本 - `query_route_memories` 追加或更新该次样本
- `QAFeedback` / `FeedbackCenterRecord` 后续会把 route feedback 回写到 `query_route_memories.feedback_outcome` - `QAFeedback` / `FeedbackCenterRecord` 后续会把 route feedback 回写到 `query_route_memories.feedback_outcome`

View File

@ -26,7 +26,7 @@
- 新增接口必须遵循响应包络:`{code, message, data}`。 - 新增接口必须遵循响应包络:`{code, message, data}`。
- 禁止在 API 层写业务逻辑;业务逻辑归属 `services/`。 - 禁止在 API 层写业务逻辑;业务逻辑归属 `services/`。
- 禁止在前端组件内直接调用 `fetch`,必须通过 `frontend/src/api/` 封装层。 - 禁止在前端组件内直接调用 `fetch`,必须通过 `frontend/src/api/` 封装层。
- MCP 模板接入中心只允许保存模板参数、命令预览、runtime probe 和手动确认状态;远程 `streamable_http` 模板允许由 PMFlow 后端直连校验/调用,本地 `stdio` 模板只能通过 PMFlow Local Bridge 或兼容 MCP host 在客户本机承载。服务端不得直接写宿主机 `~/.codex/config.toml`、不得代替客户执行本机命令。GitHub 类模板只允许保存 env var 名称或占位符,禁止落明文 token。 - MCP 模板接入中心只允许保存模板参数、命令预览、runtime probe 和手动确认状态;`backend_direct` 模板允许由 PMFlow 后端直连校验/调用,包括远程 `streamable_http` MCP 与后端内建 `backend_adapter` runtime;本地 `stdio` 模板只能通过 PMFlow Local Bridge 或兼容 MCP host 在客户本机承载。服务端不得直接写宿主机 `~/.codex/config.toml`、不得代替客户执行本机命令。GitHub 类模板只允许保存 env var 名称或占位符,禁止落明文 token。
- 飞书回复必须经过 `feishu_reply_outbox`,禁止同步直接发送。 - 飞书回复必须经过 `feishu_reply_outbox`,禁止同步直接发送。
- 飞书回执发送仅允许 `main.py` 的 drain loop 作为消费者,禁止在业务路径即时派发发送任务。 - 飞书回执发送仅允许 `main.py` 的 drain loop 作为消费者,禁止在业务路径即时派发发送任务。
- 所有写接口必须落审计日志(`operation_logs`),至少包含 `trace_id/interface_path/caller`。 - 所有写接口必须落审计日志(`operation_logs`),至少包含 `trace_id/interface_path/caller`。
@ -92,6 +92,7 @@
- orphan requirement-plan followup 在显式 `reply/thread` 存在时,必须先尝试从 reply 命中的 chat context 恢复或升格 requirement workspace;只有恢复失败时才允许回“当前没有进行中的 A/B/C 收敛轮次”。 - orphan requirement-plan followup 在显式 `reply/thread` 存在时,必须先尝试从 reply 命中的 chat context 恢复或升格 requirement workspace;只有恢复失败时才允许回“当前没有进行中的 A/B/C 收敛轮次”。
- Feishu 显式 `reply/thread` 到机器人消息时,ingress 与 mounted runtime 必须先通过 `feishu_reply_outbox.sent_message_id -> source_message_id` 归一化 `root_message_id/reply_to_message_id`,并在后续 active-context / requirement-plan / draft 主链里复用同一份 canonical thread context;禁止直接用机器人 `sent_message_id` 新开 chat topic,导致 requirement-plan / draft 线程分叉。 - Feishu 显式 `reply/thread` 到机器人消息时,ingress 与 mounted runtime 必须先通过 `feishu_reply_outbox.sent_message_id -> source_message_id` 归一化 `root_message_id/reply_to_message_id`,并在后续 active-context / requirement-plan / draft 主链里复用同一份 canonical thread context;禁止直接用机器人 `sent_message_id` 新开 chat topic,导致 requirement-plan / draft 线程分叉。
- requirement-plan 的唯一业务真源固定为 `MountedDraftWorkspace.requirement_card`:收敛轮次、已确认字段、`round_option_maps`、`last_round_options`、`source_material` 等业务状态只能从 draft 或结构化 `requirement_plan_context` 恢复;`mounted_topic_workspaces.facts_json` 只允许保留线程索引级最小字段(当前限定为 `title / plan_profile`),`work_items.execution_result` 与 `mounted_conversation_runs.final_reply` 仅作审计/回放,禁止再从 assistant prose、topic facts 或 run 文案反推需求字段。 - requirement-plan 的唯一业务真源固定为 `MountedDraftWorkspace.requirement_card`:收敛轮次、已确认字段、`round_option_maps`、`last_round_options`、`source_material` 等业务状态只能从 draft 或结构化 `requirement_plan_context` 恢复;`mounted_topic_workspaces.facts_json` 只允许保留线程索引级最小字段(当前限定为 `title / plan_profile`),`work_items.execution_result` 与 `mounted_conversation_runs.final_reply` 仅作审计/回放,禁止再从 assistant prose、topic facts 或 run 文案反推需求字段。
- `POST /ai-pmo/process-text` 与 `GET /ai-pmo/work-items` 对 requirement-plan 的前台可见状态必须直接投影 `plan_mode / plan_status / requirement_plan_context_summary`;Inbox 与其它前端入口不得再靠 `reply_template_key`、草稿 prose 或 stage 文案自行猜测当前是否仍在需求模式。
- requirement-plan 的运行时路由命名也必须单独收口:收敛轮次统一写 `requirement_plan.collect`,草稿确认前的结构化草稿统一写 `requirement_plan.draft`;`query.answer` 只保留给通用问答,禁止在 requirement-plan 主链里继续借用旧 query route 记账。 - requirement-plan 的运行时路由命名也必须单独收口:收敛轮次统一写 `requirement_plan.collect`,草稿确认前的结构化草稿统一写 `requirement_plan.draft`;`query.answer` 只保留给通用问答,禁止在 requirement-plan 主链里继续借用旧 query route 记账。
- 冷启动 `WorkflowSemanticPlanner` / `pmflow_semantic_context_decision` 若因 provider 异常、空结构化结果或网络抖动失败,`_resolve_intent` 不得直接把首轮线程打成 `error_template`;必须集中降级为带 `semantic_planner_degraded=true` 审计标记的中性 `query.answer` 场景。后续运行时必须优先重试 semantic backfill;若命中显式 active requirement context / reply anchor,仍留在 requirement-plan 主链,否则继续按普通 QA 处理,禁止无锚点把事实问句硬降级成 requirement consultation。 - 冷启动 `WorkflowSemanticPlanner` / `pmflow_semantic_context_decision` 若因 provider 异常、空结构化结果或网络抖动失败,`_resolve_intent` 不得直接把首轮线程打成 `error_template`;必须集中降级为带 `semantic_planner_degraded=true` 审计标记的中性 `query.answer` 场景。后续运行时必须优先重试 semantic backfill;若命中显式 active requirement context / reply anchor,仍留在 requirement-plan 主链,否则继续按普通 QA 处理,禁止无锚点把事实问句硬降级成 requirement consultation。
- 飞书历史队列协议已停用:卡片动作与文本 followup 只允许走 `work_item_id + work_item.resume/draft_confirm` 主链。 - 飞书历史队列协议已停用:卡片动作与文本 followup 只允许走 `work_item_id + work_item.resume/draft_confirm` 主链。
@ -107,10 +108,16 @@
- 飞书与 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` 必须直接澄清,禁止混合答案。
- `/rag/ask`、QA session detail 与 session state replay 对前台必须显式暴露 `route_decision / clarification_code / clarification_candidates / answer_mode / grounding_status / evidence_pack / session_state(last_subject, pending_clarification...)`;AI Steward UI 只能消费这些结构化字段展示当前对象、来源模式与澄清入口,禁止再从回答正文、来源标签文案或按钮文案反推 route mode。
- 网页 `query.answer` 入口的显式锚点 v1 只允许消费稳定强锚点:项目页路径里的 `project_id`,以及 URL query 中已经显式存在的 `project_id / source_chat_id`;全局浮窗与项目页入口必须原样透传这些字段到 `/rag/ask`,禁止在前端或运行时新增“猜测型 source_chat_id 推断器”。
- `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 -> 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 注入链路当作真源读取。
- internal 主链在真正进入 fast path / bounded executor / retrieval 前,必须先完成 `InternalContextPacket` 预检;若 packet 缺失、`operation_contract.query_type` 缺失、`target_resolution.resolution_mode=clarify` 或 `query_plan.answer_contract` 缺失,只能显式返回 `clarify / explicit_insufficient_evidence`,不得继续执行 internal fast answer、deterministic query 或 retrieval。
- `query_semantics` 对 `operation_contract / target_resolution / query_plan` 的镜像写回只允许出现在单点兼容适配层;禁止在多个 internal helper 里散落回写这些字段再互相读取,避免 packet 真源再次漂移回多真源。
- `rag_query_semantics` 的内部只读 `query_type` 基线统一收口为 `existence / count / list / status / explain / diagnose / compare / unknown`;`A/B/C`、`确认`、`退出需求模式`、`继续核实` 等协议/模式/执行动作不属于 `query_type`,必须在 ingress / followup gate 提前处理,禁止掉进 `unknown` 后再误入内部查询。 - `rag_query_semantics` 的内部只读 `query_type` 基线统一收口为 `existence / count / list / status / explain / diagnose / compare / unknown`;`A/B/C`、`确认`、`退出需求模式`、`继续核实` 等协议/模式/执行动作不属于 `query_type`,必须在 ingress / followup gate 提前处理,禁止掉进 `unknown` 后再误入内部查询。
- `Query DSL Plan` 必须显式携带 `query_type / entity_type / answer_contract / filters / scope / retrieval_policy / verifier_checks`。执行层只能消费该结构化计划,不得再在下游根据“问题长相”二次拍板。 - `Query DSL Plan` 必须显式携带 `query_type / entity_type / answer_contract / filters / scope / retrieval_policy / verifier_checks`。执行层只能消费该结构化计划,不得再在下游根据“问题长相”二次拍板。
- `target_resolution.resolution_mode=clarify`、`query_plan.answer_contract` 缺失或 `InternalContextPacket` 缺关键字段时,internal 路径只能显式返回 `clarify / explicit_insufficient_evidence`,禁止掉回自由问答或靠 answer 文本倒推合同。
- `semantic_facts` 是内部查询的辅助读模型,不是业务写入真源;当事实数据可用时,内部查询必须优先查 facts,再决定是否回退到受控文本/图谱/配置检索。facts 必须带 `confidence / evidence / extract_version / tenant_id`,证据不足时禁止包装成确定性内部结论。 - `semantic_facts` 是内部查询的辅助读模型,不是业务写入真源;当事实数据可用时,内部查询必须优先查 facts,再决定是否回退到受控文本/图谱/配置检索。facts 必须带 `confidence / evidence / extract_version / tenant_id`,证据不足时禁止包装成确定性内部结论。
- `semantic_facts` 自动抽取是内部查询的必经数据基线:`project / task / signal / document / user / requirement(charter) / release / review` 八类对象的 create/update 必须统一经 ORM 提交钩子收集变更,并 upsert `ingest_events(channel=semantic_fact_extract, event_key={entity_type}:{entity_id})`;禁止在各路由、controller 或零散 service 里继续手工 enqueue。 - `semantic_facts` 自动抽取是内部查询的必经数据基线:`project / task / signal / document / user / requirement(charter) / release / review` 八类对象的 create/update 必须统一经 ORM 提交钩子收集变更,并 upsert `ingest_events(channel=semantic_fact_extract, event_key={entity_type}:{entity_id})`;禁止在各路由、controller 或零散 service 里继续手工 enqueue。
- `semantic_facts` 的 delete/archive/disable 场景只能走 `operation=deactivate` 失活旧 active facts;extract 重跑前也必须先失活旧 facts,再写入新 facts,禁止保留脏 active 数据继续参与内部查询。 - `semantic_facts` 的 delete/archive/disable 场景只能走 `operation=deactivate` 失活旧 active facts;extract 重跑前也必须先失活旧 facts,再写入新 facts,禁止保留脏 active 数据继续参与内部查询。
@ -119,6 +126,9 @@
- `query.answer` 的客户可见回答必须再经过 `Answer Presentation Policy`:运行时证据统一拆为 `customer_safe` 与 `admin_debug` 双视图。客户视图只允许消费中文化 `display_label/display_summary`,禁止直接暴露 `raw_source_label`、表名、chunk id、tool trace 或 `Project database / Semantic facts / Knowledge Graph / Feishu history` 等机器/内部术语。 - `query.answer` 的客户可见回答必须再经过 `Answer Presentation Policy`:运行时证据统一拆为 `customer_safe` 与 `admin_debug` 双视图。客户视图只允许消费中文化 `display_label/display_summary`,禁止直接暴露 `raw_source_label`、表名、chunk id、tool trace 或 `Project database / Semantic facts / Knowledge Graph / Feishu history` 等机器/内部术语。
- 通用行业趋势/外部品牌模型 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 混答。
- `query_route_memories` 的 manager-only summary/list/detail 治理接口只允许做只读审计和人工排查:可以暴露 `reply_contract / probe_snapshot / query_profile / feedback_outcome` 等调试快照,但不得在线自动修改 route decision、阈值或 prompt。
- 若 `ExternalContextPacket` 内 `verified_evidence` 为空,或 verifier 未通过,外部回答必须显式返回“证据不足/需要澄清”语义;禁止为了给出完整回答而退回 legacy external trend prompt、内部检索结果或项目上下文补写结论。
- `External Query Planner` 的 fallback 只允许补齐 `search_queries / preferred_domains / location_hint / time_range_hint / result_limit` 等执行参数,不得再根据 `referenced_objects / subject_focus / needs_freshness` 在运行时把问题硬分成 `weather/news/company/market/model` 等语义类别;对象归类必须来自上游单次结构化语义或显式用户对象,若没有就保持通用 external 计划并把不确定性显式带入 verifier。 - `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。
- `scripts/semantic_lint.py` 必须把上述“对象词表 / probe helper / object->query_kind 映射”视为运行时违规形态;默认扫描范围至少覆盖 `backend/app/services/ai_pmo`、`backend/app/services/adaptive_rag_service`、`backend/app/services/external_query_service`、`backend/app/services/integrations`、`backend/app/services/signal` 与 `backend/app/api/ai_pmo_api`。新增或改名的同义写法也必须纳入 lint 规则,禁止只防 `*_HINTS/*_KEYWORDS/looks_like_*` 这种旧形式。 - `scripts/semantic_lint.py` 必须把上述“对象词表 / probe helper / object->query_kind 映射”视为运行时违规形态;默认扫描范围至少覆盖 `backend/app/services/ai_pmo`、`backend/app/services/adaptive_rag_service`、`backend/app/services/external_query_service`、`backend/app/services/integrations`、`backend/app/services/signal` 与 `backend/app/api/ai_pmo_api`。新增或改名的同义写法也必须纳入 lint 规则,禁止只防 `*_HINTS/*_KEYWORDS/looks_like_*` 这种旧形式。
- 涉及模型事实的聊天问答,只有用户显式询问系统配置、路由、接入状态、运行时参数或 `ai-config` 等内部对象时,才允许落入 `ai_pmo / snapshot identity / 内部模型配置` 场景;否则禁止把通用“最新/当前/模型/版本”问题内化成系统配置问答。 - 涉及模型事实的聊天问答,只有用户显式询问系统配置、路由、接入状态、运行时参数或 `ai-config` 等内部对象时,才允许落入 `ai_pmo / snapshot identity / 内部模型配置` 场景;否则禁止把通用“最新/当前/模型/版本”问题内化成系统配置问答。
@ -126,7 +136,7 @@
- 用户对上一条行业/外部趋势回复的事实性纠错追问,必须优先留在聊天问答链路;若锚定上一轮 `industry_trend/external_trend`,禁止切入 `ai_pmo_correction` 裁决文风。 - 用户对上一条行业/外部趋势回复的事实性纠错追问,必须优先留在聊天问答链路;若锚定上一轮 `industry_trend/external_trend`,禁止切入 `ai_pmo_correction` 裁决文风。
- 外部“最新/目前/截至现在”等 freshness 问答,只有命中显式外部来源(如 `url/http(s)` 证据)时才允许记为 grounded;仅凭项目图谱、Feature Catalog、系统快照或内部资料时,必须降级为 `insufficient_evidence` 并明确说明当前未接入实时外部数据。 - 外部“最新/目前/截至现在”等 freshness 问答,只有命中显式外部来源(如 `url/http(s)` 证据)时才允许记为 grounded;仅凭项目图谱、Feature Catalog、系统快照或内部资料时,必须降级为 `insufficient_evidence` 并明确说明当前未接入实时外部数据。
- 外部来源合同必须 fail-closed:若 external source filter 过滤后为空,只能返回空 sources 或 freshness guard,禁止把 `Feishu history / doc / pmflow / graph` 等内部来源兜回 external 回答的 `来源摘要`。 - 外部来源合同必须 fail-closed:若 external source filter 过滤后为空,只能返回空 sources 或 freshness guard,禁止把 `Feishu history / doc / pmflow / graph` 等内部来源兜回 external 回答的 `来源摘要`。
- prompt context hygiene 也属于展示契约的一部分:生成式回答 prompt 的 `context_blocks` 只能喂 `display_label`,不得再把 `raw_source_label` 直接喂给模型,避免模型正文复述英文机器标签或内部术语。 - prompt context hygiene 也属于展示契约的一部分:生成式回答 prompt 的 `context_blocks` 只能喂 `display_label`,不得再把 `raw_source_label` 直接喂给模型,避免模型正文复述英文机器标签或内部术语。external answer presenter 的 prompt 也必须遵守同一规则,只允许消费 external-only 的展示字段和证据摘要。
- 外部 freshness 问答的后续一句话跟进(如“继续核实”“联网查一下”“接入外部来源继续核实”)若锚定上一轮 `insufficient_evidence + External Freshness Guard` 回复,必须优先留在 `query.answer` 问答链,并先尝试 live external 检索;只有当实时外部检索仍为空或失败时,才允许继续返回 freshness guard。只有用户显式点明系统/项目/配置等内部对象并提出接入动作时,才允许继续落入 `signal_source_flow / internal onboard / draft` 等内部执行链。 - 外部 freshness 问答的后续一句话跟进(如“继续核实”“联网查一下”“接入外部来源继续核实”)若锚定上一轮 `insufficient_evidence + External Freshness Guard` 回复,必须优先留在 `query.answer` 问答链,并先尝试 live external 检索;只有当实时外部检索仍为空或失败时,才允许继续返回 freshness guard。只有用户显式点明系统/项目/配置等内部对象并提出接入动作时,才允许继续落入 `signal_source_flow / internal onboard / draft` 等内部执行链。
- 飞书聊天可见答案禁止泄露内部函数名、文件路径、代码行号、模块名或调试字段;prompt 护栏与出站文本清洗必须同时满足该约束。 - 飞书聊天可见答案禁止泄露内部函数名、文件路径、代码行号、模块名或调试字段;prompt 护栏与出站文本清洗必须同时满足该约束。
- mixed 场景回复必须强制两段格式输出:`[回答]` 与 `[已收到需求] ...,已入库处理`。 - mixed 场景回复必须强制两段格式输出:`[回答]` 与 `[已收到需求] ...,已入库处理`。
@ -171,8 +181,8 @@
- IT 五轮 requirement plan 现以 `MountedDraftWorkspace.active_work_item_id + latest_*_message_id` 锚定当前 active draft;历史未绑定这些字段的旧草稿不会自动认领。 - IT 五轮 requirement plan 现以 `MountedDraftWorkspace.active_work_item_id + latest_*_message_id` 锚定当前 active draft;历史未绑定这些字段的旧草稿不会自动认领。
- semantic facts 治理页与管理接口(overview / objects / detail / reextract / backfill)只对 `BOSS/ADMIN` 开放;非管理员不得看到队列、facts 内容或手动治理操作入口。 - semantic facts 治理页与管理接口(overview / objects / detail / reextract / backfill)只对 `BOSS/ADMIN` 开放;非管理员不得看到队列、facts 内容或手动治理操作入口。
- semantic facts 自动抽取、治理页和 internal existence/facts-first 查询都不以前置向量库为依赖;向量检索可作为 bounded fallback,但不得取代 facts 基线。 - semantic facts 自动抽取、治理页和 internal existence/facts-first 查询都不以前置向量库为依赖;向量检索可作为 bounded fallback,但不得取代 facts 基线。
- 当前 repo 已接入首个可复用 backend MCP 外部检索 bridge:`microsoft_learn` 通过 `backend/app/services/mcp_config/runtime.py` 以 `streamable_http` 由 PMFlow 后端直连,供 external query gateway 优先调用;其余模板若无 backend-direct runtime,仍必须显式降到 bounded fallback live search,禁止静默冒充 MCP 成功。 - 当前 repo 已接入两类可复用 backend 外部检索 bridge:`microsoft_learn` 通过 `backend/app/services/mcp_config/runtime.py` 以 `streamable_http` 由 PMFlow 后端直连;`duckduckgo` 通过同文件内的 `backend_adapter` runtime 由 PMFlow 后端直接执行搜索。其余模板若无 backend-direct runtime,仍必须显式降到 bounded fallback live search,禁止静默冒充 MCP 成功。
- MCP 模板接入中心当前区分两类状态:后端直连模板的 runtime probe 只代表 PMFlow backend 对远程 MCP 的可达性;Local Bridge 模板的“已确认”仅表示用户手动确认客户本机已承载,不代表后端完成宿主机探活,也不负责探测客户真实文件系统或本机 host 进程状态。 - MCP 模板接入中心当前区分两类状态:后端直连模板的 runtime probe 只代表 PMFlow backend 对远程 MCP 或内建 backend runtime 的可达性;Local Bridge 模板的“已确认”仅表示用户手动确认客户本机已承载,不代表后端完成宿主机探活,也不负责探测客户真实文件系统或本机 host 进程状态。
## 5. 维护建议 ## 5. 维护建议
- 想看“必须立即遵守的规则”:先看 `AGENTS.md` - 想看“必须立即遵守的规则”:先看 `AGENTS.md`

View File

@ -0,0 +1,138 @@
# External News Evidence Upgrade Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Make external freshness/news questions answerable with article-grade evidence, while keeping the current fail-closed external contract and without adding runtime keyword tables or business regex routing.
**Architecture:** Keep `route_mode=external` and the current verifier/presenter boundary, but strengthen the evidence acquisition stage. The next pass should expand search candidates into real article pages, normalize article metadata into structured evidence, separate `attempted_sources` from `answer_evidence`, and only let the presenter see customer-safe, article-backed sources.
**Tech Stack:** Python 3.11, FastAPI, PMFlow AdaptiveRAG, Pydantic, pytest, httpx, HTMLParser/JSON-LD metadata
---
### Task 1: Lock The Next-Stage News Retrieval Contract With Tests
**Files:**
- Modify: `backend/tests/test_mcp_external_query_bridge.py`
- Modify: `backend/tests/test_external_answer_presenter_contract.py`
- Modify: `backend/tests/test_adaptive_rag_mainline_contract.py`
- [ ] **Step 1: Add a failing test for live-search candidate expansion into article pages**
- Simulate a search result that points to a category page plus one resolvable article page.
- Expect the adapter/gateway to keep only the article page as `verified_evidence`.
- [ ] **Step 2: Add a failing test that `sources_used` only includes answer evidence**
- Feed one article page and two discarded candidates.
- Expect customer-visible `sources_used` / `来源摘要` to exclude discarded candidates.
- [ ] **Step 3: Add a failing test for absolute-date news output**
- Feed `published_at`.
- Expect the presenter prompt/output contract to use `YYYY-MM-DD` or `日期未明确`, never relative time like `3天前`.
- [ ] **Step 4: Run the targeted tests to confirm they fail for the right reason**
- Run: `PYTHONPATH=backend backend/venv/bin/python -m pytest backend/tests/test_mcp_external_query_bridge.py -v`
- Run: `PYTHONPATH=backend backend/venv/bin/python -m pytest backend/tests/test_external_answer_presenter_contract.py -v`
- Run: `PYTHONPATH=backend backend/venv/bin/python -m pytest backend/tests/test_adaptive_rag_mainline_contract.py -v`
### Task 2: Add Article Expansion And Structured Candidate Hydration
**Files:**
- Create: `backend/app/services/external_query_service/article_expander.py`
- Modify: `backend/app/services/external_query_service/page_metadata.py`
- Modify: `backend/app/services/external_query_service/adapters/live_search.py`
- Modify: `backend/app/services/external_query_service/adapters/mcp.py`
- Modify: `backend/app/services/external_query_service/schemas.py`
- Modify: `backend/app/services/adaptive_rag_service/mixin_conversation_utils_a_p1.py`
- [ ] **Step 1: Add a focused article-expansion helper**
- Accept one fetched page payload.
- Extract canonical/linked article candidates from structured HTML signals only (`link rel=canonical`, JSON-LD, standard anchors), not business keyword heuristics.
- [ ] **Step 2: Extend evidence schemas for staged acquisition**
- Add fields such as `evidence_stage`, `attempted_url`, `canonical_url`, `article_url`, and `selection_reason`.
- Keep `display_label/display_summary/page_kind/published_at` as the customer-safe surface.
- [ ] **Step 3: Teach live-search hydration to do a second hop when first-page evidence is landing/category**
- Fetch the top candidate page.
- If it is not article-grade, expand one bounded hop to article candidates and fetch those instead.
- Keep the fetch count bounded.
- [ ] **Step 4: Reuse the same bounded second-hop logic for MCP target URLs**
- MCP search can still return landing/detail wrappers.
- Hydration should converge on article-grade evidence without changing upstream semantics.
- [ ] **Step 5: Re-run the targeted news retrieval tests**
- Run: `PYTHONPATH=backend backend/venv/bin/python -m pytest backend/tests/test_mcp_external_query_bridge.py -v`
### Task 3: Separate Attempted Sources From Answer Evidence End-To-End
**Files:**
- Modify: `backend/app/services/external_query_service/gateway.py`
- Modify: `backend/app/services/adaptive_rag_service/mixin_answer_retrieval.py`
- Modify: `backend/app/services/adaptive_rag_service/mixin_answer_payload.py`
- Modify: `backend/app/services/adaptive_rag_service/mixin_query_route_gateway.py`
- [ ] **Step 1: Persist `adapter_attempts` and discarded candidates as debug-only metadata**
- Do not expose them in customer-visible `sources_used`.
- [ ] **Step 2: Make `execution.evidence` represent only selected answer evidence**
- Keep attempted/discarded candidates in `meta`.
- Preserve current fail-closed semantics when no answer evidence remains.
- [ ] **Step 3: Build customer-visible sources from `display_label` only**
- `sources_used` / `来源摘要` should come from selected answer evidence.
- `raw_source_label` stays admin/debug only.
- [ ] **Step 4: Re-run the answer payload and route-contract tests**
- Run: `PYTHONPATH=backend backend/venv/bin/python -m pytest backend/tests/test_adaptive_rag_mainline_contract.py -v`
### Task 4: Tighten The News Presentation Contract Without Relaxing Safety
**Files:**
- Modify: `backend/app/services/external_query_service/presenter.py`
- Modify: `backend/app/config/ai_prompts_catalog/part_scenes_06.py`
- Modify: `backend/app/config/ai_prompts.py`
- Modify: `backend/app/config/ai_prompt_registry.py`
- [ ] **Step 1: Keep `external_news_brief` as the only news presenter contract**
- Do not fall back to generic summary style for news/freshness questions.
- [ ] **Step 2: Force date rendering through structured fields**
- Use `published_at -> YYYY-MM-DD`.
- If absent, explicitly render `日期未明确`.
- [ ] **Step 3: Keep the prompt surface customer-safe**
- Evidence blocks may include `display_label`, `display_summary`, `title`, `published_at`, `target_url`.
- Never feed `URL[...]`, `MCP[...]`, or `raw_source_label`.
- [ ] **Step 4: Re-run presenter and prompt-registry tests**
- Run: `PYTHONPATH=backend backend/venv/bin/python -m pytest backend/tests/test_external_answer_presenter_contract.py -v`
- Run: `PYTHONPATH=backend backend/venv/bin/python -m pytest backend/tests/test_ai_prompt_registry_contract.py -v`
### Task 5: Verify On The Real Backend And Sync Runtime Docs
**Files:**
- Modify: `CLAUDE.md`
- Modify: `docs/architecture/ARCHITECTURE_INVENTORY.md`
- Modify: `docs/architecture/CODE_REFERENCE_INDEX.md`
- Modify: `docs/architecture/RUNTIME_GUARDRAILS.md`
- Modify: `docs/architecture/DATAFLOW.md`
- [ ] **Step 1: Update docs for second-hop article expansion and answer-evidence separation**
- Document that `news` grounding now requires article-grade evidence.
- Document that attempted sources and answer sources are different surfaces.
- [ ] **Step 2: Run compile + targeted regression**
- Run: `PYTHONPATH=backend /opt/homebrew/bin/python3 -m py_compile backend/app/services/external_query_service/article_expander.py backend/app/services/external_query_service/page_metadata.py backend/app/services/external_query_service/adapters/live_search.py backend/app/services/external_query_service/adapters/mcp.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_retrieval.py backend/app/services/adaptive_rag_service/mixin_answer_payload.py backend/app/services/adaptive_rag_service/mixin_query_route_gateway.py`
- [ ] **Step 3: Rebuild the backend container so Python changes really load**
- Run: `docker compose up -d --build backend`
- [ ] **Step 4: Live-check a real news query and capture the raw JSON**
- Ask: `最近 AI 行业有哪些值得关注的新闻?`
- Expected:
- Best case: grounded answer with article-grade sources and customer-safe labels.
- Safe fallback: explicit `insufficient_evidence`, but no machine labels and no landing-page hallucination.
- [ ] **Step 5: Record any remaining provider-gap separately**
- If live-search still cannot surface article-grade pages reliably, document the exact gap before adding a new provider.

View File

@ -0,0 +1,544 @@
# Phase 2 Explicit Anchor And Route Governance 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:** 把网页问答入口真正绑定到当前页面业务对象,并补一页 manager-only 的 route/context 治理看板,让 PMFlow 能同时做到“前台少串对象、后台能看误路由样本”。
**Architecture:** Phase 2A 不改 AdaptiveRAG 的语义边界,只把现有 `project_id / source_chat_id` 从稳定网页入口显式送进 `/rag/ask`,并在全局浮窗展示“当前页面锚点”。Phase 2B 在不引入在线自动调参的前提下,围绕 `query_route_memories` 新增只读 summary/list/detail API 和一页治理 UI,优先解决“看不见 misroute / clarify-heavy / negative feedback 样本”的问题。
**Tech Stack:** FastAPI, Pydantic, SQLAlchemy 2.0, AdaptiveRAG, React 18, TypeScript, React Query, Vitest, pytest
---
## Scope And Guardrails
- v1 显式锚点优先做稳定强锚点:
- 项目看板 / 宪章页路径里的 `project_id`
- `Inbox` / `AI Steward` 页面 URL query 中已存在的 `project_id`
- URL query 中已显式存在的 `source_chat_id`
- v1 不在网页里新增“猜测型 source_chat_id 推断器”;只有真实入口已携带时才透传,避免把锚点系统重新做成隐式猜测。
- v1 治理页只做只读审计与人工排查支持:
- `route_mode_predicted != route_mode_effective`
- `reply_contract.mode=clarify` 或 `effective_source_policy=uncertain`
- `feedback_outcome.rating=negative`
- detail drawer 中暴露 `probe_snapshot / query_profile / feedback_outcome`
- v1 不做在线阈值自调、不自动改路由、不引入新的 route memory 打分逻辑。
## Baseline File Map
### Phase 2A: Explicit Web Anchor
- Modify: `backend/tests/test_rag_route_contract.py`
- 锁 `/rag/ask` 在 request 里显式传入 `project_id / source_chat_id` 时必须原样进入 ingress contract。
- Create: `frontend/src/components/ai-steward/qaPageAnchor.ts`
- 统一从 `pathname + searchParams` 解析全局网页问答锚点,输出 `projectId / sourceChatId / anchorType / anchorLabel / origin`。
- Create: `frontend/src/components/ai-steward/qaPageAnchor.test.ts`
- 锁不同页面路径和 query param 的锚点解析规则。
- Modify: `frontend/src/components/ai-steward/FloatingQAChat.tsx`
- 接入页面锚点解析,把 `project_id / source_chat_id` 真正带进 `askRag()` 请求,并在状态条展示“当前页面锚点”。
- Modify: `frontend/src/components/ai-steward/QAConversationStatusBar.tsx`
- 增加页面锚点文案和“当前页面已锁定项目/会话”的显式状态。
- Modify: `frontend/src/components/ai-steward/FloatingQAChat.test.tsx`
- 锁全局浮窗在项目页 / inbox query 下会带锚点发问。
- Modify: `frontend/src/pages/project-board/ProjectBoardPage.tsx`
- 项目页“打开 AI”入口改成带 `project_id` 跳转,确保页面级强锚点入口真正可达。
### Phase 2B: Route And Context Governance Page
- Create: `backend/app/schemas/query_route_governance.py`
- 定义 summary/list/detail 的 response schema。
- Create: `backend/app/services/adaptive_rag_service/mixin_query_route_governance.py`
- 新增 `summary / list / detail` 只读查询能力,封装 `query_route_memories` 的聚合、筛选和详情序列化。
- Modify: `backend/app/services/adaptive_rag_service/service.py`
- 把治理 mixin 接入 `AdaptiveRAGService`。
- Create: `backend/app/api/rag_api/routes_route_governance.py`
- 提供 manager-only 的 `/rag/query-route-governance/*` API。
- Modify: `backend/app/api/rag_api/__init__.py`
- 挂载新路由。
- Create: `backend/tests/test_query_route_governance_api.py`
- 锁 summary/list/detail 合同和权限边界。
- Create: `frontend/src/api/queryRouteGovernance.ts`
- 前端 API 封装和类型定义。
- Create: `frontend/src/pages/query-route-governance/QueryRouteGovernancePage.tsx`
- 治理页编排容器,沿用 manager-only 子页模式。
- Create: `frontend/src/pages/query-route-governance/components/QueryRouteGovernanceOverviewCards.tsx`
- 顶部 KPI 卡。
- Create: `frontend/src/pages/query-route-governance/components/QueryRouteGovernanceTable.tsx`
- 样本列表与筛选器。
- Create: `frontend/src/pages/query-route-governance/components/QueryRouteGovernanceDetailDialog.tsx`
- detail drawer,展示 `reply_contract / probe_snapshot / query_profile / feedback_outcome`。
- Create: `frontend/src/pages/QueryRouteGovernancePage.tsx`
- 页面 barrel export。
- Create: `frontend/src/pages/query-route-governance/QueryRouteGovernancePage.test.tsx`
- 锁 manager-only UI、KPI、表格和 detail dialog。
- Modify: `frontend/src/router/index.tsx`
- 挂载 `/ai-config/query-route-governance`,并放进 manager-only guard。
- Modify: `frontend/src/pages/ai-config/constants.ts`
- 把治理页挂到 AI Config 治理工具入口。
### Docs And Inventory
- Modify: `CLAUDE.md`
- Modify: `docs/architecture/ARCHITECTURE_INVENTORY.md`
- Modify: `docs/architecture/CODE_REFERENCE_INDEX.md`
- Modify: `docs/architecture/RUNTIME_GUARDRAILS.md`
---
### Task 1: Lock Explicit Anchor Contract Before UI Changes
**Files:**
- Modify: `backend/tests/test_rag_route_contract.py`
- Create: `frontend/src/components/ai-steward/qaPageAnchor.test.ts`
- [ ] **Step 1: Add the failing backend contract test for explicit anchor forwarding**
Add a route-contract test that proves:
```python
request = RAGAskRequest(
question="这个项目怎么样",
project_id=17,
source_chat_id="pmflow-project-17",
)
kwargs = rag_ask_contract.build_kernel_ingress_kwargs(
request=request,
current_user=SimpleNamespace(id=1, tenant_id=1),
)
assert kwargs["project_hint_id"] == 17
assert kwargs["channel_ref"] == "pmflow-project-17"
assert kwargs["ingress_options"]["rag_request_context"]["project_id"] == 17
assert kwargs["ingress_options"]["rag_request_context"]["source_chat_id"] == "pmflow-project-17"
```
- [ ] **Step 2: Add the failing front-end page anchor parser tests**
Cover at least these cases:
```ts
expect(resolveQAPageAnchor("/projects/17/board", "")).toMatchObject({
projectId: 17,
anchorType: "project",
});
expect(resolveQAPageAnchor("/inbox", "?project_id=19")).toMatchObject({
projectId: 19,
origin: "search_param",
});
expect(resolveQAPageAnchor("/ai-steward", "?tab=qa&project_id=23&source_chat_id=chat-23")).toMatchObject({
projectId: 23,
sourceChatId: "chat-23",
});
```
- [ ] **Step 3: Run the focused tests and confirm they fail**
Run:
```bash
cd /Users/yuuuu/Documents/AIProjectManger && PYTHONPATH=backend backend/venv/bin/python -m pytest backend/tests/test_rag_route_contract.py -k "project_id or source_chat_id" -v
cd /Users/yuuuu/Documents/AIProjectManger/frontend && npx vitest run src/components/ai-steward/qaPageAnchor.test.ts
```
Expected: FAIL because the parser file does not exist yet and the forwarding assertions are not locked.
### Task 2: Implement Stable Web Anchor Resolution In The Global Floating Chat
**Files:**
- Create: `frontend/src/components/ai-steward/qaPageAnchor.ts`
- Modify: `frontend/src/components/ai-steward/FloatingQAChat.tsx`
- Modify: `frontend/src/components/ai-steward/QAConversationStatusBar.tsx`
- Modify: `frontend/src/components/ai-steward/FloatingQAChat.test.tsx`
- Test: `frontend/src/components/ai-steward/qaPageAnchor.test.ts`
- Test: `frontend/src/components/ai-steward/FloatingQAChat.test.tsx`
- [ ] **Step 1: Implement the minimal page-anchor resolver**
Add a helper that only consumes stable web hints:
```ts
export interface QAPageAnchor {
projectId: number | null;
sourceChatId: string | null;
anchorType: "project" | "project_filter" | "chat_scope" | "none";
anchorLabel: string | null;
origin: "pathname" | "search_param" | "none";
}
```
Parsing rules:
- `/projects/:id/board` and `/projects/:id/charter/:charterId` -> `projectId`
- query `project_id` -> `projectId`
- query `source_chat_id` -> `sourceChatId`
- no strong anchor -> `anchorType="none"`
- [ ] **Step 2: Wire `FloatingQAChat` to send explicit anchors**
When submitting a question:
```ts
const pageAnchor = resolveQAPageAnchor(location.pathname, location.search);
await aiStewardApi.askRag({
...body,
project_id: pageAnchor.projectId ?? undefined,
source_chat_id: pageAnchor.sourceChatId ?? undefined,
});
```
Also keep the current session-state logic intact; page anchor only supplements request scope and UI display.
- [ ] **Step 3: Extend the status bar with explicit page-anchor state**
Render a compact line such as:
```tsx
当前页面锚点:项目 #17
```
Only show it when `pageAnchor.anchorType !== "none"`.
- [ ] **Step 4: Update floating-chat tests to assert anchor forwarding**
Add a case that mounts `FloatingQAChat` under a project-board route and asserts:
```ts
expect(aiStewardApiMock.askRag).toHaveBeenCalledWith(
expect.objectContaining({ project_id: 17 })
);
```
- [ ] **Step 5: Re-run the focused front-end tests**
Run:
```bash
cd /Users/yuuuu/Documents/AIProjectManger/frontend && npx vitest run src/components/ai-steward/qaPageAnchor.test.ts src/components/ai-steward/FloatingQAChat.test.tsx
```
Expected: PASS
### Task 3: Route Existing Project Entry Points Into Anchor-Bearing AI URLs
**Files:**
- Modify: `frontend/src/pages/project-board/ProjectBoardPage.tsx`
- Modify: `frontend/src/components/ai-steward/FloatingQAChat.test.tsx`
- [ ] **Step 1: Write the failing project-entry test**
Lock that project-board “打开 AI” jumps to an anchored URL:
```ts
expect(navigate).toHaveBeenCalledWith("/ai-steward?tab=qa&project_id=17");
```
- [ ] **Step 2: Run the focused test to confirm it fails**
Run:
```bash
cd /Users/yuuuu/Documents/AIProjectManger/frontend && npx vitest run src/components/ai-steward/FloatingQAChat.test.tsx
```
Expected: FAIL because project-board still routes to bare `/ai-steward`.
- [ ] **Step 3: Update project-board AI entrypoints**
Change the existing project-board entry from:
```ts
navigate("/ai-steward")
```
to:
```ts
navigate(`/ai-steward?tab=qa&project_id=${id}`)
```
- [ ] **Step 4: Re-run the affected test**
Run:
```bash
cd /Users/yuuuu/Documents/AIProjectManger/frontend && npx vitest run src/components/ai-steward/FloatingQAChat.test.tsx
```
Expected: PASS
### Task 4: Lock Route Governance API Contract
**Files:**
- Create: `backend/tests/test_query_route_governance_api.py`
- [ ] **Step 1: Write the failing summary/list/detail API tests**
Seed `query_route_memories` rows that cover:
- `route_mode_predicted != route_mode_effective`
- `feedback_outcome.rating == "negative"`
- `reply_contract.mode == "clarify"`
Assert summary returns:
```python
assert payload["data"]["kpis"]["sample_total"] == 3
assert payload["data"]["kpis"]["route_mismatch_total"] == 1
assert payload["data"]["kpis"]["negative_feedback_total"] == 1
assert payload["data"]["kpis"]["clarify_total"] == 1
```
Assert list returns rows with:
```python
assert item["route_mode_predicted"] == "internal"
assert item["route_mode_effective"] == "external"
assert item["feedback_rating"] == "negative"
assert item["is_clarify"] is True
```
Assert detail returns:
```python
assert detail["probe_snapshot"]["effective_source_policy"] == "external"
assert detail["query_profile"]["source_policy"] == "internal"
assert detail["reply_contract"]["mode"] == "clarify"
```
- [ ] **Step 2: Add the failing manager-only permission test**
Assert non-manager gets `403`, manager gets `200`.
- [ ] **Step 3: Run the backend test and confirm it fails**
Run:
```bash
cd /Users/yuuuu/Documents/AIProjectManger && PYTHONPATH=backend backend/venv/bin/python -m pytest backend/tests/test_query_route_governance_api.py -v
```
Expected: FAIL because the API and service methods do not exist yet.
### Task 5: Implement Read-Only Route Governance Backend
**Files:**
- Create: `backend/app/schemas/query_route_governance.py`
- Create: `backend/app/services/adaptive_rag_service/mixin_query_route_governance.py`
- Modify: `backend/app/services/adaptive_rag_service/service.py`
- Create: `backend/app/api/rag_api/routes_route_governance.py`
- Modify: `backend/app/api/rag_api/__init__.py`
- Test: `backend/tests/test_query_route_governance_api.py`
- [ ] **Step 1: Add the minimal governance schemas**
Define Pydantic models for:
- `QueryRouteGovernanceSummaryResponse`
- `QueryRouteGovernanceListItem`
- `QueryRouteGovernanceDetailResponse`
Keep the frontend-safe shape small; do not expose `question_embedding`.
- [ ] **Step 2: Implement summary/list/detail service methods**
Add read-only methods with project filter and status filters:
```python
async def get_query_route_governance_summary(...)
async def list_query_route_governance_items(...)
async def get_query_route_governance_detail(...)
```
Derived fields:
- `is_route_mismatch = route_mode_predicted != route_mode_effective`
- `feedback_rating = feedback_outcome["rating"]`
- `is_clarify = reply_contract["mode"] == "clarify"`
- [ ] **Step 3: Expose manager-only API routes**
Add:
- `GET /rag/query-route-governance/summary`
- `GET /rag/query-route-governance/items`
- `GET /rag/query-route-governance/items/{route_memory_id}`
Use `require_roles("ADMIN", "BOSS")`.
- [ ] **Step 4: Re-run the governance API test**
Run:
```bash
cd /Users/yuuuu/Documents/AIProjectManger && PYTHONPATH=backend backend/venv/bin/python -m pytest backend/tests/test_query_route_governance_api.py -v
```
Expected: PASS
### Task 6: Build The Manager-Only Route Governance Page
**Files:**
- Create: `frontend/src/api/queryRouteGovernance.ts`
- Create: `frontend/src/pages/query-route-governance/QueryRouteGovernancePage.tsx`
- Create: `frontend/src/pages/query-route-governance/components/QueryRouteGovernanceOverviewCards.tsx`
- Create: `frontend/src/pages/query-route-governance/components/QueryRouteGovernanceTable.tsx`
- Create: `frontend/src/pages/query-route-governance/components/QueryRouteGovernanceDetailDialog.tsx`
- Create: `frontend/src/pages/QueryRouteGovernancePage.tsx`
- Create: `frontend/src/pages/query-route-governance/QueryRouteGovernancePage.test.tsx`
- Modify: `frontend/src/router/index.tsx`
- Modify: `frontend/src/pages/ai-config/constants.ts`
- [ ] **Step 1: Write the failing page test**
Cover these cases:
```tsx
it("renders manager-only guard copy for non-manager users", ...)
it("renders KPI cards and table rows from governance summary/list data", ...)
it("opens detail dialog and shows probe snapshot / feedback outcome", ...)
```
- [ ] **Step 2: Run the front-end test to confirm it fails**
Run:
```bash
cd /Users/yuuuu/Documents/AIProjectManger/frontend && npx vitest run src/pages/query-route-governance/QueryRouteGovernancePage.test.tsx
```
Expected: FAIL because the page and API wrapper do not exist yet.
- [ ] **Step 3: Add the front-end API wrapper**
Expose:
```ts
getSummary(params?)
listItems(params?)
getDetail(routeMemoryId)
```
- [ ] **Step 4: Build the governance page using the semantic-facts board pattern**
Page layout:
- top header + feature guide
- KPI cards
- filterable table
- detail drawer
Suggested table columns:
- question text
- predicted/effective route
- decision origin
- feedback rating
- flags (`mismatch`, `clarify`, `negative`)
- created_at
- [ ] **Step 5: Add manager-only route and AI Config entry**
Router:
```tsx
{ path: "ai-config/query-route-governance", element: <ManagerOnlyGuard><QueryRouteGovernancePage /></ManagerOnlyGuard> }
```
Governance tools card:
```ts
{
id: "query-route-governance",
name: "Route 治理看板",
to: "/ai-config/query-route-governance",
}
```
- [ ] **Step 6: Re-run the front-end page test**
Run:
```bash
cd /Users/yuuuu/Documents/AIProjectManger/frontend && npx vitest run src/pages/query-route-governance/QueryRouteGovernancePage.test.tsx
```
Expected: PASS
### Task 7: Docs Sync And Final Verification
**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: Sync docs with new page, route, and anchor rules**
Document:
- explicit web-anchor baseline (`project_id/source_chat_id` from stable page contexts)
- manager-only route governance page and API
- “治理页只读、不自动改路由”的 guardrail
- [ ] **Step 2: Run the focused backend verification**
Run:
```bash
cd /Users/yuuuu/Documents/AIProjectManger && backend/venv/bin/python -m py_compile \
backend/app/schemas/query_route_governance.py \
backend/app/services/adaptive_rag_service/mixin_query_route_governance.py \
backend/app/api/rag_api/routes_route_governance.py
cd /Users/yuuuu/Documents/AIProjectManger && PYTHONPATH=backend backend/venv/bin/python -m pytest \
backend/tests/test_rag_route_contract.py -k "project_id or source_chat_id" -v
cd /Users/yuuuu/Documents/AIProjectManger && PYTHONPATH=backend backend/venv/bin/python -m pytest \
backend/tests/test_query_route_governance_api.py -v
```
Expected: PASS
- [ ] **Step 3: Run the focused front-end verification**
Run:
```bash
cd /Users/yuuuu/Documents/AIProjectManger/frontend && npx vitest run \
src/components/ai-steward/qaPageAnchor.test.ts \
src/components/ai-steward/FloatingQAChat.test.tsx \
src/pages/query-route-governance/QueryRouteGovernancePage.test.tsx
cd /Users/yuuuu/Documents/AIProjectManger/frontend && npx tsc --noEmit
cd /Users/yuuuu/Documents/AIProjectManger/frontend && npm run build
```
Expected: PASS
- [ ] **Step 4: Restart backend and confirm runtime recovery**
Run:
```bash
cd /Users/yuuuu/Documents/AIProjectManger && docker compose restart backend
cd /Users/yuuuu/Documents/AIProjectManger && curl -sS -o /dev/null -w '%{http_code}\n' http://127.0.0.1:8000/docs
cd /Users/yuuuu/Documents/AIProjectManger && docker compose ps backend
```
Expected:
- `/docs` returns `200`
- `pmflow-backend` returns to `healthy`
---
## Acceptance Checklist
- 页面级全局浮窗在项目页、Inbox `project_id` 过滤页、AI Steward `project_id` query 下都会把锚点显式带进 `/rag/ask`
- 项目页进入 AI 的入口不再裸跳 `/ai-steward`
- `QAConversationStatusBar` 能让用户看见当前页面锚点
- manager-only `Route 治理看板` 已可查看 summary/list/detail
- 治理页能直接筛出 `mismatch / clarify / negative feedback` 样本
- 文档、测试、build、backend restart 已完成

View File

@ -0,0 +1,372 @@
# Three-Mode Interaction And Context Upgrade Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** 让 PMFlow 的三类用户可见模式在同一套交互骨架下可见、可控、可纠偏:`planmode(requirement_plan/draft)` 先做顶层状态显化,`query.answer` 再按 `internal / external` 给出显式来源合同与上下文续写提示。
**Architecture:** 先锁共享基线,不改语义边界:保留 `plan_mode gate -> non-plan query.answer -> internal/external split` 的现有层级,只把后端已经存在的 `route_decision / routing_snapshot / session_state / requirement plan status` 暴露给前端。前端新增一套轻量状态条和回答元信息卡,查询侧挂在 `FloatingQAChat`,planmode 侧挂在 `Inbox` 与 AI PMO handoff 结果,不另起一套聊天壳。
**Tech Stack:** FastAPI, SQLAlchemy 2.0, PMFlow AdaptiveRAG, AI PMO kernel, React 18, TypeScript, Vitest, pytest
---
## Baseline File Map
### Shared Query Contract
- Modify: `backend/app/api/rag_ask_contract.py`
- 保持 `/rag/ask` 现有包络不变,补齐给前台可消费的 `route_decision / clarification_code / clarification_candidates / answer_mode / grounding_status / policy_version / citations / sources_used / evidence_pack` 规范化输出。
- Modify: `backend/app/services/adaptive_rag_service/mixin_qa_session_list_ops_p1.py`
- 让会话详情把每轮回答的 route / evidence / answer mode 元信息带出来,不再只有 `question / answer / sources`。
- Modify: `frontend/src/api/aiSteward.ts`
- 前端类型补齐 richer QA payload,并新增 `getQASessionState(sessionId)` 读取 `last_subject / pending_clarification / rewrite_history`。
- Modify: `backend/tests/test_ai_pmo_qa_http_mainline_e2e.py`
- 锁 `/rag/ask` HTTP 响应合同。
- Modify: `backend/tests/test_ai_pmo_ingress_mainline.py`
- 锁 QA session detail / state replay 合同。
### Query UI Shell
- Create: `frontend/src/components/ai-steward/QAConversationStatusBar.tsx`
- 显示当前对象、当前来源模式、待澄清状态、是否仍在继承上一轮上下文。
- Create: `frontend/src/components/ai-steward/QAResponseMetaCard.tsx`
- 每条回答下显示 `内部/外部/待澄清`、grounding 状态、来源摘要、推荐下一步。
- Modify: `frontend/src/components/ai-steward/FloatingQAChat.tsx`
- 接入 status bar、message-level meta card、route clarify 快捷动作、session state 拉取与“清空上下文/新话题”入口。
- Create: `frontend/src/components/ai-steward/FloatingQAChat.test.tsx`
- 锁状态条、route clarify 动作、元信息卡渲染。
### Plan Mode Surface
- Modify: `backend/app/api/ai_pmo_api/helpers_inbox_payload.py`
- 给 inbox work item summary 增加 planmode 相关轻量字段,例如 `plan_mode / plan_status / requirement_plan_context_summary`。
- Modify: `backend/app/api/ai_pmo_api/routes_core.py`
- 统一 `process-text` 与 `work-items` 输出里的 planmode 相关字段,避免前端靠 `reply_template_key` 猜。
- Modify: `frontend/src/api/aiPmo.ts`
- 补齐 AI PMO result / work item summary 的 planmode 类型。
- Modify: `frontend/src/types/inbox.ts`
- 让 inbox item view model 能承载 `plan_status / plan_mode / requirement_plan_context_summary`。
- Modify: `frontend/src/components/inbox/InboxListItem.tsx`
- 在详情卡里显式展示“当前需求模式状态 / 下一步动作 / 草稿待确认”。
- Modify: `frontend/src/pages/inbox/InboxPage.tsx`
- 把 mounted run / work item 映射到统一的 planmode 视图文案。
- Create: `frontend/src/components/inbox/InboxListItem.test.tsx`
- 锁 planmode 状态渲染与 CTA 文案。
### Docs And Inventory
- Modify: `CLAUDE.md`
- Modify: `docs/architecture/ARCHITECTURE_INVENTORY.md`
- Modify: `docs/architecture/CODE_REFERENCE_INDEX.md`
- Modify: `docs/architecture/RUNTIME_GUARDRAILS.md`
---
### Task 1: Lock The Shared QA Contract
**Files:**
- Modify: `backend/tests/test_ai_pmo_qa_http_mainline_e2e.py`
- Modify: `backend/tests/test_ai_pmo_ingress_mainline.py`
- Modify: `frontend/src/api/aiSteward.ts`
- [ ] **Step 1: Write the failing `/rag/ask` contract test**
Add an HTTP contract assertion that `/rag/ask` returns:
```python
assert payload["data"]["route_decision"]["effective_source_policy"] == "internal"
assert payload["data"]["clarification_code"] is None
assert payload["data"]["answer_mode"] == "fast_path"
assert payload["data"]["evidence_pack"]["summary"]["conclusion"] == "当前主链状态正常。"
```
- [ ] **Step 2: Write the failing session detail/state test**
Add sqlite-backed tests that prove:
```python
detail = await service.get_qa_session_detail(session_id=session_id, user_id=101)
assert detail["messages"][0]["route_decision"]["effective_source_policy"] == "external"
assert detail["messages"][0]["grounding_status"] == "insufficient_evidence"
state = await service.get_session_state_replay(session_id=session_id, user_id=101)
assert state["last_subject"] == "Alpha项目"
assert state["pending_clarification"]["kind"] == "query_route_selection"
```
- [ ] **Step 3: Run the focused backend tests to confirm they fail**
Run:
```bash
cd /Users/yuuuu/Documents/AIProjectManger && backend/venv/bin/python -m pytest backend/tests/test_ai_pmo_qa_http_mainline_e2e.py -k "rag_ask" -v
cd /Users/yuuuu/Documents/AIProjectManger && backend/venv/bin/python -m pytest backend/tests/test_ai_pmo_ingress_mainline.py -k "qa_session_detail or session_state_replay" -v
```
Expected: FAIL because the richer QA/session contract is not exposed yet.
### Task 2: Implement The Shared QA Metadata Baseline
**Files:**
- Modify: `backend/app/api/rag_ask_contract.py`
- Modify: `backend/app/services/adaptive_rag_service/mixin_qa_session_list_ops_p1.py`
- Modify: `frontend/src/api/aiSteward.ts`
- Test: `backend/tests/test_ai_pmo_qa_http_mainline_e2e.py`
- Test: `backend/tests/test_ai_pmo_ingress_mainline.py`
- [ ] **Step 1: Normalize `/rag/ask` payload fields**
Keep the existing response envelope, but explicitly preserve:
```python
payload["route_decision"] = dict(payload.get("route_decision") or {})
payload["clarification_code"] = str(payload.get("clarification_code") or "").strip() or None
payload["clarification_candidates"] = _normalize_dict_list(payload.get("clarification_candidates"))
```
- [ ] **Step 2: Extend QA session detail payload**
When serializing session messages, add:
```python
"route_decision": row.routing_snapshot.get("query_route") if isinstance(row.routing_snapshot, dict) else {},
"answer_mode": row.answer_mode,
"grounding_status": row.grounding_status,
"policy_version": row.policy_version,
"citations": row.citations or [],
"sources_used": row.sources_used or [],
"evidence_pack": row.evidence_pack or {},
```
- [ ] **Step 3: Update front-end request/response types**
Extend `QAAskResponse` and `QASessionMessage` to accept:
```ts
route_decision?: Record<string, unknown> | null;
clarification_code?: string | null;
clarification_candidates?: Array<Record<string, unknown>>;
answer_mode?: string | null;
grounding_status?: string | null;
policy_version?: string | null;
citations?: Array<Record<string, unknown>>;
sources_used?: Array<Record<string, unknown>>;
evidence_pack?: Record<string, unknown> | null;
```
Also add:
```ts
async getQASessionState(sessionId: number): Promise<QASessionStateReplay>
```
- [ ] **Step 4: Re-run the focused backend tests**
Run:
```bash
cd /Users/yuuuu/Documents/AIProjectManger && backend/venv/bin/python -m pytest backend/tests/test_ai_pmo_qa_http_mainline_e2e.py -k "rag_ask" -v
cd /Users/yuuuu/Documents/AIProjectManger && backend/venv/bin/python -m pytest backend/tests/test_ai_pmo_ingress_mainline.py -k "qa_session_detail or session_state_replay" -v
```
Expected: PASS
### Task 3: Build The Query Status Shell
**Files:**
- Create: `frontend/src/components/ai-steward/QAConversationStatusBar.tsx`
- Create: `frontend/src/components/ai-steward/QAResponseMetaCard.tsx`
- Modify: `frontend/src/components/ai-steward/FloatingQAChat.tsx`
- Create: `frontend/src/components/ai-steward/FloatingQAChat.test.tsx`
- Test: `frontend/src/components/ai-steward/FloatingQAChat.test.tsx`
- [ ] **Step 1: Write the failing front-end tests**
Cover these cases:
```tsx
it("shows current subject and source mode in the status bar", ...)
it("renders route clarify actions when clarification_code=query_route_clarify", ...)
it("renders answer meta card with grounding and source summary", ...)
```
- [ ] **Step 2: Run the front-end test to verify it fails**
Run:
```bash
cd /Users/yuuuu/Documents/AIProjectManger/frontend && npx vitest run src/components/ai-steward/FloatingQAChat.test.tsx
```
Expected: FAIL because the new status shell does not exist yet.
- [ ] **Step 3: Implement the session-level status bar**
Render a compact bar above the message list with:
```tsx
<QAConversationStatusBar
currentSubject={sessionState.last_subject}
effectiveSourcePolicy={lastRouteDecision?.effective_source_policy}
pendingClarification={sessionState.pending_clarification}
onClearContext={startNewChat}
/>
```
- [ ] **Step 4: Implement the per-answer meta card**
For each message, render a compact card that surfaces:
```tsx
<QAResponseMetaCard
routeDecision={msg.route_decision}
groundingStatus={msg.grounding_status}
sourcesUsed={msg.sources_used}
evidencePack={msg.evidence_pack}
/>
```
- [ ] **Step 5: Wire route clarify actions**
When the last answer returns `clarification_code === "query_route_clarify"`, show two quick actions that submit the existing protocol text:
```tsx
onChooseInternal={() => runQuestionRequest({ prompt: "B" })}
onChooseExternal={() => runQuestionRequest({ prompt: "A" })}
```
- [ ] **Step 6: Re-run the front-end test**
Run:
```bash
cd /Users/yuuuu/Documents/AIProjectManger/frontend && npx vitest run src/components/ai-steward/FloatingQAChat.test.tsx
```
Expected: PASS
### Task 4: Surface Plan Mode In AI PMO And Inbox
**Files:**
- Modify: `backend/tests/test_ai_pmo_ingress_mainline.py`
- Modify: `backend/app/api/ai_pmo_api/helpers_inbox_payload.py`
- Modify: `backend/app/api/ai_pmo_api/routes_core.py`
- Modify: `frontend/src/api/aiPmo.ts`
- Modify: `frontend/src/types/inbox.ts`
- Modify: `frontend/src/components/inbox/InboxListItem.tsx`
- Modify: `frontend/src/pages/inbox/InboxPage.tsx`
- Create: `frontend/src/components/inbox/InboxListItem.test.tsx`
- Test: `frontend/src/components/inbox/InboxListItem.test.tsx`
- [ ] **Step 1: Write the failing backend test for planmode summary fields**
Add coverage that proves work item summary/detail exposes:
```python
assert item["plan_mode"] is True
assert item["plan_status"] == "collecting"
assert item["requirement_plan_context_summary"]["title"] == "换季搭配推荐"
```
- [ ] **Step 2: Run the targeted backend test to verify it fails**
Run:
```bash
cd /Users/yuuuu/Documents/AIProjectManger && backend/venv/bin/python -m pytest backend/tests/test_ai_pmo_ingress_mainline.py -k "plan_mode or requirement_plan_context_summary" -v
```
Expected: FAIL because inbox/process-text payloads do not expose these fields yet.
- [ ] **Step 3: Serialize planmode summary fields in AI PMO payloads**
Normalize a small summary shape instead of leaking full runtime packet:
```python
"plan_mode": bool(summary.get("plan_mode")),
"plan_status": normalized_plan_status,
"requirement_plan_context_summary": {
"title": card.get("title"),
"goal": card.get("goal"),
"missing_info": card.get("missing_info", [])[:3],
}
```
- [ ] **Step 4: Update front-end inbox types and rendering**
Show:
```tsx
<Badge variant="outline">{item.source_work_item?.plan_status || "draft_pending_confirm"}</Badge>
<div>当前模式:需求模式</div>
<div>下一步:补信息 / 选 A-B-C / 草稿确认</div>
```
Keep the existing draft actions intact.
- [ ] **Step 5: Write and run the front-end inbox test**
Run:
```bash
cd /Users/yuuuu/Documents/AIProjectManger/frontend && npx vitest run src/components/inbox/InboxListItem.test.tsx
```
Expected: FAIL first, then PASS after the new planmode summary UI is wired.
### Task 5: Verification And Docs Sync
**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 docs for the new three-mode front-end contract**
Record:
- `plan_mode` is still the top-level gate
- `internal / external` remain submodes under `query.answer`
- `FloatingQAChat` now shows context/source status instead of only answer text
- inbox exposes requirement-plan summary state
- [ ] **Step 2: Run focused backend verification**
Run:
```bash
cd /Users/yuuuu/Documents/AIProjectManger && backend/venv/bin/python -m pytest \
backend/tests/test_ai_pmo_qa_http_mainline_e2e.py \
backend/tests/test_ai_pmo_ingress_mainline.py \
backend/tests/test_adaptive_rag_mainline_contract.py -v
```
Expected: PASS
- [ ] **Step 3: Run backend compile verification**
Run:
```bash
cd /Users/yuuuu/Documents/AIProjectManger && backend/venv/bin/python -m py_compile \
backend/app/api/rag_ask_contract.py \
backend/app/services/adaptive_rag_service/mixin_qa_session_list_ops_p1.py \
backend/app/api/ai_pmo_api/helpers_inbox_payload.py \
backend/app/api/ai_pmo_api/routes_core.py
```
Expected: exit 0
- [ ] **Step 4: Run front-end verification**
Run:
```bash
cd /Users/yuuuu/Documents/AIProjectManger/frontend && npx vitest run \
src/components/ai-steward/FloatingQAChat.test.tsx \
src/components/inbox/InboxListItem.test.tsx
cd /Users/yuuuu/Documents/AIProjectManger/frontend && npx tsc --noEmit
cd /Users/yuuuu/Documents/AIProjectManger/frontend && npm run build
```
Expected: PASS

View File

@ -19,7 +19,7 @@
## Confirmed Baseline ## Confirmed Baseline
- 方案 1 是内部查询的第一道必经步骤。 - 方案 1 是内部查询的第一道必经步骤。
- `route_mode=internal` 的 non-plan `query.answer` 在真正查库前,必须先生成 `Query DSL Plan`。 - `route_mode=internal` 的 non-plan `query.answer` 在真正查库前,必须先生成 `Query DSL Plan`,并在计划定稿后组装 `InternalContextPacket` 作为 internal-only 运行时单一真源。
- 大模型禁止自由写 SQL;只能输出受控 DSL。 - 大模型禁止自由写 SQL;只能输出受控 DSL。
- 受控执行器只能命中白名单实体、白名单字段映射、白名单查询模板。 - 受控执行器只能命中白名单实体、白名单字段映射、白名单查询模板。
- 协议短回复、模式控制、执行动作请求不得进入 DSL 规划;必须在 ingress / followup gate 提前拦住。 - 协议短回复、模式控制、执行动作请求不得进入 DSL 规划;必须在 ingress / followup gate 提前拦住。
@ -124,10 +124,12 @@
1. `Query Route Gateway` 决定本轮必须走内部查询。 1. `Query Route Gateway` 决定本轮必须走内部查询。
2. `rag_query_semantics` 只产出标准 `query_type`、`entity_type` 与 DSL 所需槽位,不再直接决定最终回答。 2. `rag_query_semantics` 只产出标准 `query_type`、`entity_type` 与 DSL 所需槽位,不再直接决定最终回答。
3. `Query DSL Normalizer` 做 schema 校验、枚举收口、上下文补足与黑名单清洗。 3. `Query DSL Normalizer` 做 schema 校验、枚举收口、上下文补足与黑名单清洗。
4. `Bounded Executor` 根据 `entity_type + query_type + filters` 选择白名单模板执行。 4. `InternalContextPacket` 固化 `question / scope / route_decision / operation_contract / target_resolution / query_plan`,并作为 internal executor / verifier / runtime summary 的单一真源;`query_semantics` 只保留上游 scratchpad 与兼容镜像。
5. 若 `semantic_facts` 可用,则先走 facts-first;证据不足时再走受控 fallback 检索。 5. mainline 必须先对 packet 做预检;若 packet 缺失、`operation_contract.query_type` 缺失、`target_resolution.resolution_mode=clarify` 或 `query_plan.answer_contract` 缺失,直接返回 `clarify / explicit_insufficient_evidence`,不允许继续执行。
6. `Answer Verifier` 校验“问法合同、证据合同、来源合同”是否一致。 6. `Bounded Executor` 根据 packet 内的 `entity_type + query_type + filters` 选择白名单模板执行。
7. verifier 通过后再进入回答渲染;不通过则显式失败或重试,不允许静默出错。 7. 若 `semantic_facts` 可用,则先走 facts-first;证据不足时再走受控 fallback 检索。
8. `Answer Verifier` 校验“问法合同、证据合同、来源合同”是否一致。
9. verifier 通过后再进入回答渲染;不通过则显式失败或重试,不允许静默出错。
## Bounded Executor Rules ## Bounded Executor Rules
@ -149,6 +151,7 @@ verifier 至少检查:
- 是否误把 existence / status / compare 问题答成 total count - 是否误把 existence / status / compare 问题答成 total count
- 回答所引用的证据是否真实命中本次执行结果 - 回答所引用的证据是否真实命中本次执行结果
- `reply_contract` 是否与 `route_mode=internal` 一致 - `reply_contract` 是否与 `route_mode=internal` 一致
- verifier 的合同真源必须来自 `InternalContextPacket.operation_contract.query_type` 与 `InternalContextPacket.query_plan.answer_contract`,禁止根据 answer 文本或散落的 `query_semantics` 字段倒推
verifier 不通过时,统一返回三类结果之一: verifier 不通过时,统一返回三类结果之一:
@ -159,6 +162,10 @@ verifier 不通过时,统一返回三类结果之一:
## Failure Semantics ## Failure Semantics
- DSL 结构化失败:显式失败,不回退自由回答 - DSL 结构化失败:显式失败,不回退自由回答
- `InternalContextPacket` 缺失:直接 `clarify / explicit_insufficient_evidence`
- `operation_contract.query_type` 缺失:直接 `clarify / explicit_insufficient_evidence`
- `target_resolution.resolution_mode=clarify`:直接澄清,不回退自由回答
- `query_plan.answer_contract` 缺失:直接 `explicit_insufficient_evidence`
- 执行器无模板支持:显式返回“当前内部查询模板未覆盖” - 执行器无模板支持:显式返回“当前内部查询模板未覆盖”
- 命中为空但证据不足:按 `clarify_on_empty` 澄清或返回“当前未确认到” - 命中为空但证据不足:按 `clarify_on_empty` 澄清或返回“当前未确认到”
- verifier 判定合同漂移:拒绝出站并写审计日志 - verifier 判定合同漂移:拒绝出站并写审计日志
@ -186,6 +193,7 @@ verifier 不通过时,统一返回三类结果之一:
- `backend/app/services/adaptive_rag_service/mixin_core_intent.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_query_route_gateway.py`
- `backend/app/services/adaptive_rag_service/mixin_answer_mainline.py` - `backend/app/services/adaptive_rag_service/mixin_answer_mainline.py`
- `backend/app/services/adaptive_rag_service/internal_query_context.py`
- `backend/app/services/adaptive_rag_service/mixin_answer_payload.py` - `backend/app/services/adaptive_rag_service/mixin_answer_payload.py`
- `backend/app/services/adaptive_rag_service/mixin_deterministic_query_core.py` - `backend/app/services/adaptive_rag_service/mixin_deterministic_query_core.py`
- `backend/app/services/adaptive_rag_service/mixin_deterministic_query_count.py` - `backend/app/services/adaptive_rag_service/mixin_deterministic_query_count.py`

View File

@ -45,6 +45,9 @@ export interface AIPMOWorkItem {
confirm_message_id?: string | null; confirm_message_id?: string | null;
latest_policy_gate_code?: string | null; latest_policy_gate_code?: string | null;
approval_requirement?: AIPMOApprovalRequirement | null; approval_requirement?: AIPMOApprovalRequirement | null;
plan_mode?: boolean;
plan_status?: string | null;
requirement_plan_context_summary?: AIPMORequirementPlanContextSummary | null;
created_at?: string | null; created_at?: string | null;
updated_at?: string | null; updated_at?: string | null;
} }
@ -56,6 +59,14 @@ export interface AIPMOApprovalRequirement {
[key: string]: unknown; [key: string]: unknown;
} }
export interface AIPMORequirementPlanContextSummary {
title?: string | null;
goal?: string | null;
missing_info?: string[];
question_round?: number | null;
last_round_field?: string | null;
}
export interface AIPMOWorkItemListPayload { export interface AIPMOWorkItemListPayload {
items: AIPMOWorkItem[]; items: AIPMOWorkItem[];
} }
@ -110,6 +121,9 @@ export interface AIPMOWorkItemDetail {
confirm_message_id?: string | null; confirm_message_id?: string | null;
review_summary?: Record<string, unknown> | null; review_summary?: Record<string, unknown> | null;
reply_template_key?: string | null; reply_template_key?: string | null;
plan_mode?: boolean;
plan_status?: string | null;
requirement_plan_context_summary?: AIPMORequirementPlanContextSummary | null;
decision_records: AIPMODecisionRecordDetail[]; decision_records: AIPMODecisionRecordDetail[];
plan_bundles: AIPMOPlanBundleDetail[]; plan_bundles: AIPMOPlanBundleDetail[];
reply_receipts: AIPMOReplyReceiptDetail[]; reply_receipts: AIPMOReplyReceiptDetail[];
@ -168,6 +182,9 @@ export interface AIPMOProcessTextResult {
work_item_id?: number | null; work_item_id?: number | null;
signal_id?: number | null; signal_id?: number | null;
ai_trace_id?: string | null; ai_trace_id?: string | null;
plan_mode?: boolean;
plan_status?: string | null;
requirement_plan_context_summary?: AIPMORequirementPlanContextSummary | null;
} }
export interface AIPMOProgramView { export interface AIPMOProgramView {

View File

@ -300,6 +300,25 @@ export interface QAConsultantDispatch {
next_action: string; next_action: string;
} }
export interface QARouteDecision {
route_mode?: string;
effective_source_policy?: string;
decision_origin?: string;
[key: string]: unknown;
}
export interface QASessionStateReplay {
session_id: number;
last_subject?: string;
last_entity_ids?: number[];
last_intent?: string;
ambiguity?: string;
rewrite_history?: Array<Record<string, unknown>>;
pending_clarification?: Record<string, unknown> | null;
pending_clarification_expires_at?: string | null;
updated_at?: string | null;
}
export interface QASessionSummary { export interface QASessionSummary {
id: number; id: number;
title: string; title: string;
@ -339,6 +358,15 @@ export interface QASessionMessage {
trace_id?: string | null; trace_id?: string | null;
processing_stage?: string | null; processing_stage?: string | null;
consultant_dispatch?: QAConsultantDispatch | null; consultant_dispatch?: QAConsultantDispatch | null;
route_decision?: QARouteDecision | null;
clarification_code?: string | null;
clarification_candidates?: Array<Record<string, unknown>>;
answer_mode?: string | null;
grounding_status?: string | null;
policy_version?: string | null;
citations?: Array<Record<string, unknown>>;
sources_used?: Array<Record<string, unknown>>;
evidence_pack?: Record<string, unknown> | null;
} }
export interface QASessionDetail { export interface QASessionDetail {
@ -375,6 +403,8 @@ export interface QAAskRequest {
action_mode: "auto"; action_mode: "auto";
sync_requirement: boolean; sync_requirement: boolean;
session_id?: number; session_id?: number;
project_id?: number;
source_chat_id?: string;
consultant_mode_preference?: "clarify" | "advise" | "challenge" | "brainstorm" | "execute"; consultant_mode_preference?: "clarify" | "advise" | "challenge" | "brainstorm" | "execute";
} }
@ -392,6 +422,15 @@ export interface QAAskResponse {
trace_id?: string | null; trace_id?: string | null;
processing_stage?: string | null; processing_stage?: string | null;
consultant_dispatch?: QAConsultantDispatch | null; consultant_dispatch?: QAConsultantDispatch | null;
route_decision?: QARouteDecision | null;
clarification_code?: string | null;
clarification_candidates?: Array<Record<string, unknown>>;
answer_mode?: string | null;
grounding_status?: string | null;
policy_version?: string | null;
citations?: Array<Record<string, unknown>>;
sources_used?: Array<Record<string, unknown>>;
evidence_pack?: Record<string, unknown> | null;
} }
export const aiStewardApi = { export const aiStewardApi = {
@ -413,6 +452,10 @@ export const aiStewardApi = {
const raw = await apiRequest<ApiEnvelope<QASessionDetail>>(`/rag/qa-sessions/${sessionId}`); const raw = await apiRequest<ApiEnvelope<QASessionDetail>>(`/rag/qa-sessions/${sessionId}`);
return unwrap(raw); return unwrap(raw);
}, },
async getQASessionState(sessionId: number): Promise<QASessionStateReplay> {
const raw = await apiRequest<ApiEnvelope<QASessionStateReplay>>(`/rag/qa-sessions/${sessionId}/state`);
return unwrap(raw);
},
async renameQASession(sessionId: number, title: string): Promise<{ title: string }> { async renameQASession(sessionId: number, title: string): Promise<{ title: string }> {
const raw = await apiRequest<ApiEnvelope<{ title: string }>>(`/rag/qa-sessions/${sessionId}/title`, { const raw = await apiRequest<ApiEnvelope<{ title: string }>>(`/rag/qa-sessions/${sessionId}/title`, {
method: "PATCH", method: "PATCH",

View File

@ -10,7 +10,7 @@ function unwrap<T>(raw: ApiEnvelope<T> | T): T {
} }
export type McpClientApplyState = "pending_local_apply" | "user_confirmed" | "not_required"; export type McpClientApplyState = "pending_local_apply" | "user_confirmed" | "not_required";
export type McpTransport = "stdio" | "streamable_http"; export type McpTransport = "stdio" | "streamable_http" | "backend_adapter";
export type McpExecutionMode = "backend_direct" | "local_bridge"; export type McpExecutionMode = "backend_direct" | "local_bridge";
export type McpRuntimeProbeStatus = "unknown" | "ready" | "error" | "local_bridge_required"; export type McpRuntimeProbeStatus = "unknown" | "ready" | "error" | "local_bridge_required";

View File

@ -0,0 +1,101 @@
import { api } from "@/lib/api";
type ApiEnvelope<T> = { code: number; message: string; data: T };
function unwrap<T>(raw: ApiEnvelope<T> | T): T {
if (raw && typeof raw === "object" && "data" in (raw as Record<string, unknown>)) {
return (raw as ApiEnvelope<T>).data;
}
return raw as T;
}
export interface QueryRouteGovernanceBucket {
key: string;
label: string;
count: number;
}
export interface QueryRouteGovernanceSummary {
window_days: number;
project_id?: number | null;
kpis: {
total_records: number;
mismatch_count: number;
clarify_count: number;
negative_feedback_count: number;
internal_count: number;
external_count: number;
uncertain_count: number;
};
route_modes: QueryRouteGovernanceBucket[];
decision_origins: QueryRouteGovernanceBucket[];
}
export interface QueryRouteGovernanceRecord {
id: number;
qa_record_id: number;
project_id?: number | null;
session_id?: number | null;
session_title?: string | null;
question_text: string;
route_mode_predicted: string;
route_mode_effective: string;
decision_origin?: string | null;
reply_mode?: string | null;
feedback_rating?: string | null;
feedback_helpfulness?: string | null;
is_route_mismatch: boolean;
is_clarify: boolean;
anchor_summary?: string | null;
answer_mode?: string | null;
grounding_status?: string | null;
asked_via?: string | null;
created_at?: string | null;
updated_at?: string | null;
}
export interface QueryRouteGovernanceDetail {
record: QueryRouteGovernanceRecord;
qa_record: Record<string, unknown>;
route_memory: Record<string, unknown>;
}
export const queryRouteGovernanceApi = {
async getSummary(params?: {
project_id?: number;
window_days?: number;
}): Promise<QueryRouteGovernanceSummary> {
const { data } = await api.get<ApiEnvelope<QueryRouteGovernanceSummary>>(
"/rag/query-route-governance/summary",
{ params },
);
return unwrap(data);
},
async listRecords(params?: {
project_id?: number;
window_days?: number;
route_mode?: string;
decision_origin?: string;
rating?: string;
mismatch_only?: boolean;
clarify_only?: boolean;
search?: string;
limit?: number;
offset?: number;
}): Promise<{ items: QueryRouteGovernanceRecord[]; total: number; limit: number; offset: number }> {
const { data } = await api.get<
ApiEnvelope<{ items: QueryRouteGovernanceRecord[]; total: number; limit: number; offset: number }>
>("/rag/query-route-governance/items", { params });
return unwrap(data);
},
async getDetail(recordId: number, params?: { project_id?: number }): Promise<QueryRouteGovernanceDetail> {
const { data } = await api.get<ApiEnvelope<QueryRouteGovernanceDetail>>(
`/rag/query-route-governance/items/${recordId}`,
{ params },
);
return unwrap(data);
},
};

View File

@ -0,0 +1,190 @@
import { cleanup, fireEvent, screen, waitFor } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { FloatingQAChat } from "@/components/ai-steward/FloatingQAChat";
import { QAConversationStatusBar } from "@/components/ai-steward/QAConversationStatusBar";
import { QAResponseMetaCard } from "@/components/ai-steward/QAResponseMetaCard";
import { renderWithProviders } from "@/test/renderWithProviders";
const aiStewardApiMock = vi.hoisted(() => ({
askRag: vi.fn(),
listQASessions: vi.fn(),
getQASession: vi.fn(),
getQASessionState: vi.fn(),
renameQASession: vi.fn(),
submitQAFeedback: vi.fn(),
}));
const aiPmoApiMock = vi.hoisted(() => ({
processText: vi.fn(),
}));
vi.mock("@/api/aiSteward", () => ({
aiStewardApi: aiStewardApiMock,
}));
vi.mock("@/api/aiPmo", () => ({
aiPmoApi: aiPmoApiMock,
}));
describe("FloatingQAChat", () => {
afterEach(() => {
cleanup();
});
beforeEach(() => {
vi.clearAllMocks();
localStorage.clear();
Object.defineProperty(window.HTMLElement.prototype, "scrollIntoView", {
configurable: true,
value: vi.fn(),
});
Object.defineProperty(window.HTMLElement.prototype, "scrollTo", {
configurable: true,
value: vi.fn(),
});
aiStewardApiMock.listQASessions.mockResolvedValue({
items: [],
page: 1,
total_pages: 0,
});
aiStewardApiMock.getQASession.mockResolvedValue({
session: {
id: 11,
title: "Alpha 会话",
message_count: 1,
last_message_at: "2026-04-17T10:00:00+08:00",
},
messages: [
{
id: 91,
question: "Alpha 项目现在怎么样?",
answer: "外部实时查询:目前证据不足。",
sources: [],
route_decision: {
effective_source_policy: "external",
},
grounding_status: "insufficient_evidence",
answer_mode: "clarify",
evidence_pack: {
summary: {
conclusion: "还需要你明确要查外部哪一类动态。",
uncertainties: ["未明确外部主题"],
},
},
turn_index: 1,
},
],
});
aiStewardApiMock.getQASessionState.mockResolvedValue({
session_id: 11,
last_subject: "Alpha项目",
last_entity_ids: [88],
last_intent: "question",
ambiguity: "needs_clarification",
rewrite_history: [],
pending_clarification: {
kind: "query_route_selection",
},
pending_clarification_expires_at: null,
updated_at: "2026-04-17T10:00:00+08:00",
});
aiStewardApiMock.askRag.mockResolvedValue({
answer: "你是要查外部公开事实,还是系统内部对象?",
clarification_code: "query_route_clarify",
clarification_candidates: [
{ key: "A", label: "外部公开事实" },
{ key: "B", label: "系统内部对象" },
],
route_decision: {
effective_source_policy: "external_first",
},
grounding_status: "insufficient_evidence",
answer_mode: "clarify",
evidence_pack: {
summary: {
conclusion: "需要先确认查询来源。",
uncertainties: ["内部还是外部"],
},
},
sources: [],
});
aiPmoApiMock.processText.mockResolvedValue({});
});
it("shows current subject and source mode in the status bar", () => {
renderWithProviders(
<QAConversationStatusBar
currentSubject="Alpha项目"
effectiveSourcePolicy="external"
pendingClarification={{ kind: "query_route_selection" }}
isFollowingSessionContext
pageAnchorLabel="项目 #17"
onClearContext={vi.fn()}
/>,
);
expect(screen.getByText(/当前对象/i).textContent ?? "").toContain("Alpha项目");
expect(screen.getByText(/当前来源/i).textContent ?? "").toContain("外部公开事实");
expect(screen.getByText(/页面锚点/i).textContent ?? "").toContain("项目 #17");
expect(screen.queryByText(/正在继承上一轮上下文/)).toBeTruthy();
});
it("renders answer meta card with grounding and source summary", () => {
renderWithProviders(
<QAResponseMetaCard
routeDecision={{ effective_source_policy: "internal" }}
groundingStatus="grounded"
sourcesUsed={[{ display_label: "项目数据库" }, { display_label: "知识图谱" }]}
evidencePack={{
summary: {
conclusion: "内部知识已足够回答。",
uncertainties: [],
},
}}
/>,
);
expect(screen.queryByText("内部知识库查询")).toBeTruthy();
expect(screen.queryByText("证据充分")).toBeTruthy();
expect(screen.queryByText(/项目数据库 \/ 知识图谱/)).toBeTruthy();
expect(screen.queryByText(/内部知识已足够回答/)).toBeTruthy();
});
it("renders route clarify actions when clarification_code=query_route_clarify", async () => {
renderWithProviders(<FloatingQAChat />);
fireEvent.click(screen.getByRole("button", { name: "打开 AI 聊天" }));
fireEvent.change(await screen.findByPlaceholderText("输入问题..."), {
target: { value: "这个需求该查哪边?" },
});
fireEvent.click(screen.getByRole("button", { name: "提问" }));
expect(await screen.findByRole("button", { name: "查外部公开事实" })).toBeTruthy();
expect(screen.getByRole("button", { name: "查系统内部对象" })).toBeTruthy();
fireEvent.click(screen.getByRole("button", { name: "查系统内部对象" }));
await waitFor(() => expect(aiStewardApiMock.askRag).toHaveBeenCalledTimes(2));
expect(aiStewardApiMock.askRag.mock.calls[1][0]).toMatchObject({
question: "B",
});
});
it("forwards project anchor from project board route when asking", async () => {
renderWithProviders(<FloatingQAChat />, ["/projects/17/board"]);
const openButtons = screen.getAllByRole("button", { name: "打开 AI 聊天" });
fireEvent.click(openButtons[openButtons.length - 1]);
fireEvent.change(await screen.findByPlaceholderText("输入问题..."), {
target: { value: "这个项目怎么样?" },
});
fireEvent.click(screen.getByRole("button", { name: "提问" }));
await waitFor(() => expect(aiStewardApiMock.askRag).toHaveBeenCalledTimes(1));
expect(aiStewardApiMock.askRag.mock.calls[0][0]).toMatchObject({
question: "这个项目怎么样?",
project_id: 17,
});
});
});

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