diff --git a/AGENTS.md b/AGENTS.md index a380e9ad..bcbc1dce 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -95,7 +95,7 @@ frontend/ # React 前端 - 新增接口必须遵循响应包络:`{code, message, data}`,不得裸返回 - 禁止在 API 层写业务逻辑,业务逻辑归属 `services/` - 禁止在前端组件内直接调用 `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` - 用户可见核心业务内容零兜底:AI 失败、证据不足、结构化失败时必须显式失败或标记待确认,禁止用默认句、模板句、stub 冒充成功。 - 业务语义、问答/执行归因与前台动作拍板必须优先走“对象/上下文绑定 -> `SemanticContextPacket` -> `SemanticDecision` -> gatekeeper”主链,禁止在 `backend/app` 运行时代码里恢复 `looks_like_*`、关键词仓或散落正则拍板。 diff --git a/CLAUDE.md b/CLAUDE.md index 143e6c51..600f8a82 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -56,8 +56,9 @@ backend/ - 当前后端重点入口优先看: - `backend/app/api/` 下的 `*_api/` 子包与聚合 router - `backend/app/services/ai_pmo/`(AI PMO 主链、Mounted Runtime、semantic context) - - `backend/app/services/adaptive_rag_service/`(只读问答主链;`mixin_query_route_gateway.py` 负责 non-plan `query.answer` 的 internal/external/uncertain 来源合同归一化、contract 审计与显式来源回复合同;`mixin_answer_retrieval.py` 现已把 `route_mode=external` 接到独立外部查询主链;内部查询基线已升级为 `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/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/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/semantic_fact_service/`(semantic facts 读模型、八类业务对象统一抽取/失活、ORM 提交钩子入队、治理看板查询) - `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/models/query_route_memory.py`,通过 `backend/alembic/versions/20260415_add_query_route_memories.py` 建表;只用于 route audit / feedback 回灌 / 离线评估,不做在线自动调参。 - 本轮新增的语义事实读模型为 `backend/app/models/semantic_fact.py` 与 `backend/app/models/semantic_fact_extraction_run.py`,通过 `backend/alembic/versions/20260415_add_semantic_facts.py` 建表;用于内部查询的 facts-first 命中与异步抽取审计。 -- 本轮新增的外部查询 fallback 回归测试位于 `backend/tests/test_external_query_planner.py`;配合 `backend/tests/test_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/`) @@ -92,7 +93,9 @@ frontend/ - `frontend/src/pages/inbox/`(消息台) - `frontend/src/pages/ai-steward/`(AI 管家) - `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/api/queryRouteGovernance.ts`(route/context 治理页前端 API) - `frontend/src/pages/scheduler-center/`(Mounted AI Console) - `frontend/src/components/sstc/`(SSTC 工作流组件) - 页面目录化、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 / 审计日志,禁止业务路径直写或同步直发。 - 用户可见核心业务内容禁止 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”。 - `backend/app` 运行时代码禁止恢复散落的中文关键词、正则、`looks_like_*`、`*_HINTS/*_KEYWORDS` 业务拍板逻辑。 - `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。 - 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] 执行层已形成统一执行账本与参数来源约束:ingress 执行路径统一通过 `KernelIngressStorageMixin` 生成 `execution_result.action_contract`(固定 `param_source=decision.execution_plan.actions+workflow_route+execution_result`)并写入 `operation_execution_ledger`(`applied/pending_manual/pending_confirmation/failed`);`mixin_auto.py`、`mixin_prepare_manual.py` 的 `operation_code` 日志来源已对齐 `action_contract`,回归由 `test_ai_pmo_execution_ledger_contract.py` 锁定 - [x] QA/RAG 路由契约双轨已收敛:`/ai-steward/rag/ask` 兼容入口已移除,问答统一收敛到 `POST /rag/ask`;参数构造与 tenant 解析收口在 `backend/app/api/rag_ask_contract.py`,前端 `FloatingQAChat` 与 Context Cache API 目录均已对齐,`backend/tests/test_rag_route_contract.py` 与 `backend/tests/test_context_cache_profile_catalog.py` 已锁定回归 +- [x] 三模式交互上下文基线已显性化:`/rag/ask`、QA session detail 与 session state replay 现统一透出 `route_decision / clarification_code / clarification_candidates / answer_mode / grounding_status / evidence_pack`;前端 `FloatingQAChat` 新增 `QAConversationStatusBar` 与 `QAResponseMetaCard`,显式展示当前对象、来源模式、证据状态与澄清按钮;`/ai-pmo/process-text` 与 `GET /ai-pmo/work-items` 现同步投影 `plan_mode / plan_status / requirement_plan_context_summary` 到 Inbox 展开区,回归由 `test_ai_pmo_qa_http_mainline_e2e.py`、`test_ai_pmo_ingress_mainline.py`、`FloatingQAChat.test.tsx`、`InboxListItem.test.tsx` 锁定 - [x] AI 管家浮窗交互已与 Grounding Runtime 对齐:回答区已切到 markdown 渲染,顾问标签升级为显式交互按钮;`consultant_mode_preference` 只用于 `/rag/ask` 内部重答,`允许执行` 明确交接到 `/ai-pmo/process-text`,避免 query/execute 边界回退到旧的一锅煮 - [x] Retrieval source registry / metadata 已统一:AdaptiveRAG 检索链已建立统一 source registry / metadata normalizer,`citations[]/sources_used[]` 直接消费标准字段 `source_type/source_id/chunk_id/target_url/project_id/updated_at/permission_scope/source_priority`;`doc/url/feishu/pmflow/graph/db` 的来源元数据与排序键已收口,并由 `test_rag_route_contract.py`、`test_adaptive_rag_mainline_contract.py` 锁定 - [x] 语义治理 baseline 已清零:`backend/app` 运行时代码中的 `looks_like_* / *_HINTS / *_KEYWORDS` 残留已清空,主链统一改为对象/上下文绑定 + `SemanticContextPacket -> SemanticDecision -> gatekeeper`;`scripts/semantic_lint_baseline.txt` 已清空,并由 `scripts/semantic_lint.py` 与 `backend/tests/test_semantic_lint.py` 做仓库级守门 diff --git a/backend/app/api/ai_pmo_api/helpers_inbox_payload.py b/backend/app/api/ai_pmo_api/helpers_inbox_payload.py index 0008e78f..3fde0983 100644 --- a/backend/app/api/ai_pmo_api/helpers_inbox_payload.py +++ b/backend/app/api/ai_pmo_api/helpers_inbox_payload.py @@ -72,6 +72,119 @@ async def _build_closure_payload_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( *, 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 if approval_requirement is None: 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 { "id": int(row.id), "source_system": row.source_system, @@ -150,6 +270,9 @@ def _serialize_work_item_summary( "stage_text": _stage_text(str(row.state or "")), "priority": priority, "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, "approval_requirement": approval_requirement, "target_url": f"/projects/{project_id}/board" if project_id is not None else None, diff --git a/backend/app/api/ai_pmo_api/routes_core.py b/backend/app/api/ai_pmo_api/routes_core.py index 5be9f83b..85d2ff7e 100644 --- a/backend/app/api/ai_pmo_api/routes_core.py +++ b/backend/app/api/ai_pmo_api/routes_core.py @@ -2,7 +2,12 @@ from __future__ import annotations from .shared import * 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 @router.post("/process-text") @@ -26,7 +31,7 @@ async def process_text( ) if not result.get("handled"): 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") async def list_work_items( @@ -181,4 +186,13 @@ async def get_work_item( 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) diff --git a/backend/app/api/rag_api/__init__.py b/backend/app/api/rag_api/__init__.py index 29c7d158..6a9200d6 100644 --- a/backend/app/api/rag_api/__init__.py +++ b/backend/app/api/rag_api/__init__.py @@ -2,10 +2,10 @@ from __future__ import annotations 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"]) -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) __all__ = ["router"] diff --git a/backend/app/api/rag_api/routes_route_governance.py b/backend/app/api/rag_api/routes_route_governance.py new file mode 100644 index 00000000..88de3610 --- /dev/null +++ b/backend/app/api/rag_api/routes_route_governance.py @@ -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) + diff --git a/backend/app/api/rag_ask_contract.py b/backend/app/api/rag_ask_contract.py index e4a2898d..8cef740d 100644 --- a/backend/app/api/rag_ask_contract.py +++ b/backend/app/api/rag_ask_contract.py @@ -57,6 +57,10 @@ def _normalize_dict_list(value: Any) -> list[dict[str, Any]]: 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]: return { "summary": { @@ -112,6 +116,9 @@ def build_rag_response_payload(*, ingress_result: dict[str, Any]) -> dict[str, A if "tool_trace" not in payload: payload["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["sources_used"] = _normalize_dict_list(payload.get("sources_used")) payload["answer_mode"] = str(payload.get("answer_mode") or "retrieval_normal").strip() or "retrieval_normal" diff --git a/backend/app/config/ai_prompt_registry.py b/backend/app/config/ai_prompt_registry.py index 790db247..c825b703 100644 --- a/backend/app/config/ai_prompt_registry.py +++ b/backend/app/config/ai_prompt_registry.py @@ -511,6 +511,14 @@ def _scene_entries() -> tuple[dict[str, Any], ...]: output_contract="输出项目状态结论、进度与下一步建议。", 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( "PROMPT_MEMBER_WORKLOAD_QA", module="part_scenes_04", @@ -527,6 +535,14 @@ def _scene_entries() -> tuple[dict[str, Any], ...]: output_contract="输出 AI PMO 视角的结论与建议。", 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( "PROMPT_EXTERNAL_TREND_QA", module="part_scenes_06", @@ -543,6 +559,30 @@ def _scene_entries() -> tuple[dict[str, Any], ...]: output_contract="输出行业趋势判断与建议。", 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",), + ), ) diff --git a/backend/app/config/ai_prompts.py b/backend/app/config/ai_prompts.py index 1372d86e..3269eecb 100644 --- a/backend/app/config/ai_prompts.py +++ b/backend/app/config/ai_prompts.py @@ -48,9 +48,13 @@ from .ai_prompts_catalog import ( PROMPT_FEISHU_DIRECT_QA, PROMPT_FEISHU_MIXED_QA, PROMPT_PROJECT_STATUS_QA, + PROMPT_PROJECT_WEEKLY_REPORT_JSON, PROMPT_MEMBER_WORKLOAD_QA, PROMPT_AI_PMO_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_INDUSTRY_TREND_QA, ) @@ -167,6 +171,8 @@ PROMPT_RAG_QUERY_SEMANTICS = """ - conversation intent(question / statement / mixed) - query_type(existence / count / list / status / explain / diagnose / compare / unknown) - object_mentions(用户当前想解析的对象提及,保留原始业务名词短语) +- source_policy(internal / external / uncertain) +- effective_source_policy(internal / external / uncertain) - retrieval_domains(db / graph / doc / feishu / pmflow / url) - subject_focus(people / project / task / signal / charter / workload / feature / unknown) - scope_hint(none / global / project) @@ -186,10 +192,14 @@ PROMPT_RAG_QUERY_SEMANTICS = """ - 如果问题是在问人员、成员、负责人清单或人数,subject_focus 应为 people。 - 如果问题是在问功能做什么、入口在哪、模块如何进入,subject_focus 应为 feature,并结合 feature_focus 标明是 purpose / entry / catalog。 - 如果问题是在问“有没有 / 是否存在 / 系统里有无”这类内部对象存在性,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 最新的模型用的是什么?` - 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 模型配置?` - 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 选择保守但完整的查询范围。 - 输出必须是一个 JSON 对象,不要输出 Markdown。 @@ -200,6 +210,8 @@ JSON schema: "statement_part": "string", "query_type": "existence | count | list | status | explain | diagnose | compare | unknown", "object_mentions": ["string"], + "source_policy": "internal | external | uncertain", + "effective_source_policy": "internal | external | uncertain", "retrieval_domains": ["db | graph | doc | feishu | pmflow | url"], "subject_focus": "people | project | task | signal | charter | workload | feature | unknown", "scope_hint": "none | global | project", @@ -826,9 +838,13 @@ __all__ = [ "PROMPT_FEISHU_DIRECT_QA", "PROMPT_FEISHU_MIXED_QA", "PROMPT_PROJECT_STATUS_QA", + "PROMPT_PROJECT_WEEKLY_REPORT_JSON", "PROMPT_MEMBER_WORKLOAD_QA", "PROMPT_AI_PMO_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_INDUSTRY_TREND_QA", "PMFLOW_SEMANTIC_CONTEXT_DECISION_PROMPT", diff --git a/backend/app/config/ai_prompts_catalog/__init__.py b/backend/app/config/ai_prompts_catalog/__init__.py index 5bf2097e..475b42a3 100644 --- a/backend/app/config/ai_prompts_catalog/__init__.py +++ b/backend/app/config/ai_prompts_catalog/__init__.py @@ -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_scenes_01 import PROMPT_FEISHU_DIRECT_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_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__ = [ "PROMPT_IMPACT_ANALYSIS", @@ -52,9 +58,13 @@ __all__ = [ "PROMPT_FEISHU_DIRECT_QA", "PROMPT_FEISHU_MIXED_QA", "PROMPT_PROJECT_STATUS_QA", + "PROMPT_PROJECT_WEEKLY_REPORT_JSON", "PROMPT_MEMBER_WORKLOAD_QA", "PROMPT_AI_PMO_QA", "PROMPT_AI_PMO_REQUIREMENT_PLAN_QA", "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", ] diff --git a/backend/app/config/ai_prompts_catalog/part_01.py b/backend/app/config/ai_prompts_catalog/part_01.py index 191e29d7..7eeb6593 100644 --- a/backend/app/config/ai_prompts_catalog/part_01.py +++ b/backend/app/config/ai_prompts_catalog/part_01.py @@ -6,9 +6,9 @@ def _compose_prompt(*parts: str) -> str: PMFLOW_PMO_STYLE_BASE = """ 你是 PMFlow 的 AI PMO 协作中枢成员。 风格要求: -1. 专业、克制、结论先行。 -2. 先给判断,再给依据,再给下一步。 -3. 优先引用可验证事实,不写空泛套话。 +1. 专业、克制,先给用户最需要的判断结果。 +2. 优先引用可验证事实,不写空泛套话,不默认扩写“下一步”。 +3. 若证据不足,明确标出不确定项,不要用汇报体补满答案。 """.strip() JSON_OUTPUT_GUARDRAIL = """ diff --git a/backend/app/config/ai_prompts_catalog/part_03.py b/backend/app/config/ai_prompts_catalog/part_03.py index 1e88b101..6d5863c2 100644 --- a/backend/app/config/ai_prompts_catalog/part_03.py +++ b/backend/app/config/ai_prompts_catalog/part_03.py @@ -77,6 +77,8 @@ PROMPT_RAG_DB_PROJECT_QA = _compose_prompt( 输入: - 用户问题:{question} +- 问法类型:{query_type} +- 回答合同:{answer_contract} - 项目索引(JSON): {project_index} @@ -90,9 +92,14 @@ PROMPT_RAG_DB_PROJECT_QA = _compose_prompt( 规则: 1. 若项目索引无法回答,返回 handled=false,answer 置空,matched_project_ids 置空数组。 -2. answer 只写对用户有用的最终回答,不要复述索引原文。 -3. confidence 用 0.0-1.0 的小数。 -4. 不要输出 Markdown、解释文本或思考过程。 +2. answer 只写对用户有用的最终回答,不要复述索引原文,不要输出“下一步建议”“缺少字段”这类顾问式扩写。 +3. 只能使用项目索引里已提供的字段;字段没有提供时可写“未记录”,不要编造。 +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, ) diff --git a/backend/app/config/ai_prompts_catalog/part_scenes_01.py b/backend/app/config/ai_prompts_catalog/part_scenes_01.py index bd19c699..40d1447e 100644 --- a/backend/app/config/ai_prompts_catalog/part_scenes_01.py +++ b/backend/app/config/ai_prompts_catalog/part_scenes_01.py @@ -6,12 +6,12 @@ FEISHU_QA_RESPONSE_STYLE = _compose_prompt( PMFLOW_PMO_STYLE_BASE, """ 回复风格要求: -1. 先给结论,再给关键依据,最后再给下一步。 -2. 用正式、克制、面向协作的中文,像资深项目经理而不是聊天机器人。 -3. 如果能用数字、项目名、负责人、时间点表达,请直接写明。 -4. 若信息不足,必须明确说明缺少什么,最多列 3 项。 -5. 可使用 1-3 个轻量 emoji 做分段,但不要花哨,不要影响专业感。 -6. 飞书输出优先短段落和清晰换行;如果包含“结论/依据/下一步/信息不足”,要让这些段落一眼可扫读。 +1. 直接回答用户问题,首句就给可判断的结果,不要写“结论:”“依据:”“下一步:”这类栏目标题。 +2. 用正式、克制、面向协作的中文,像靠谱同事,而不是顾问汇报或聊天机器人。 +3. 如果能用数字、项目名、负责人、时间点表达,请直接写明,只写已核实信息。 +4. 不要主动扩写用户没问到的建议、负责人联络、接口路径、排期动作;只有用户明确要求或当前回答被证据阻塞时才提。 +5. 若证据不足,明确写“当前证据不足:...”并列关键缺口,最多 3 项;不要使用旧式缺口栏目标题。 +6. 飞书输出优先短段落和清晰换行,不用 emoji 分段,不要堆模板化栏目。 7. 禁止自我介绍、寒暄、思考过程、数据库/模型/接口实现细节。 8. 禁止输出内部函数名、文件路径、代码行号、模块名或调试字段。 """.strip(), @@ -25,11 +25,13 @@ PROMPT_FEISHU_DIRECT_QA = _compose_prompt( 回答要求: 1. 仅基于给定上下文回答,优先使用结构化数据。 2. 如果问题涉及项目状态、进展、成员或任务,请优先引用项目画像、检索证据和用户焦点里的可验证事实与数字。 -3. 如果问题涉及选择、建议或判断,直接给出明确结论和 1-2 条理由。 -4. 信息不足时,直接写“信息不足,缺少:...”并列出关键缺失项。 -5. 控制在 220 字以内,适合飞书消息阅读。 -6. 不输出 Markdown、JSON 或思考过程。 -7. 禁止以“让我先/我来看看/我需要先”等句式开头。 +3. 如果问题是内部 existence / status / count / list 问句,先直接回答有没有、状态是什么、数量多少,再补已核实字段;不要自动补“下一步建议”。 +4. 如果问题涉及选择、建议或判断,直接给出明确答案和 1-2 条理由,不要写顾问汇报体。 +5. 信息不足时,直接写“当前证据不足:...”并列关键缺失项。 +6. 控制在 220 字以内,适合飞书消息阅读。 +7. 不输出 Markdown、JSON 或思考过程。 +8. 禁止以“让我先/我来看看/我需要先”等句式开头。 +9. 不要使用旧式栏目标题,尤其不要把回答拆成顾问汇报体。 当前项目ID:{project_id} 问题:{question} diff --git a/backend/app/config/ai_prompts_catalog/part_scenes_03.py b/backend/app/config/ai_prompts_catalog/part_scenes_03.py index 28ccc718..3b245567 100644 --- a/backend/app/config/ai_prompts_catalog/part_scenes_03.py +++ b/backend/app/config/ai_prompts_catalog/part_scenes_03.py @@ -1,6 +1,6 @@ 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( "你是 PMFlow 的项目状态分析助手。", @@ -28,4 +28,50 @@ User Snapshot:{user_snapshot} 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"] diff --git a/backend/app/config/ai_prompts_catalog/part_scenes_06.py b/backend/app/config/ai_prompts_catalog/part_scenes_06.py index b572cdc8..4b23fcab 100644 --- a/backend/app/config/ai_prompts_catalog/part_scenes_06.py +++ b/backend/app/config/ai_prompts_catalog/part_scenes_06.py @@ -48,4 +48,88 @@ PROMPT_INDUSTRY_TREND_QA = _compose_prompt( 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", +] diff --git a/backend/app/schemas/query_route_governance.py b/backend/app/schemas/query_route_governance.py new file mode 100644 index 00000000..0d01b734 --- /dev/null +++ b/backend/app/schemas/query_route_governance.py @@ -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] + diff --git a/backend/app/services/adaptive_rag_service/internal_query_context.py b/backend/app/services/adaptive_rag_service/internal_query_context.py new file mode 100644 index 00000000..73f51572 --- /dev/null +++ b/backend/app/services/adaptive_rag_service/internal_query_context.py @@ -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", +] diff --git a/backend/app/services/adaptive_rag_service/mixin_answer_mainline.py b/backend/app/services/adaptive_rag_service/mixin_answer_mainline.py index 6154617c..b7ac1df6 100644 --- a/backend/app/services/adaptive_rag_service/mixin_answer_mainline.py +++ b/backend/app/services/adaptive_rag_service/mixin_answer_mainline.py @@ -2,9 +2,34 @@ from __future__ import annotations from .shared import * +from .internal_query_context import InternalContextPacket, internal_context_block_reason 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 def _internal_answer_contract_for_query_type(query_type: str) -> str: normalized = str(query_type or QUERY_TYPE_UNKNOWN).strip().lower() @@ -24,6 +49,48 @@ class AdaptiveRAGAnswerMainlineMixin: return "comparison_with_basis" 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 def _internal_entity_type_for_subject_focus(subject_focus: str) -> str: 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 def _extract_prompt_variant_from_policy_version(cls, policy_version: Optional[str]) -> Optional[str]: raw = str(policy_version or "").strip() @@ -224,7 +405,9 @@ class AdaptiveRAGAnswerMainlineMixin: ) normalized_thinking_mode = normalize_thinking_mode(requested_thinking_mode) 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 { QUERY_TYPE_EXISTENCE, QUERY_TYPE_COUNT, @@ -357,6 +540,11 @@ class AdaptiveRAGAnswerMainlineMixin: resolved_question = str(pending_selection.get("effective_question") or clean_q).strip() if isinstance( pending_selection, dict ) 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( question=resolved_question, session_id=state_session_id, @@ -375,6 +563,16 @@ class AdaptiveRAGAnswerMainlineMixin: anchor_resolution=anchor_resolution, ) 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( question=effective_q, asked_via=asked_via, @@ -384,9 +582,9 @@ class AdaptiveRAGAnswerMainlineMixin: conversation_scope_key=external_conversation_key, 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, - source_policy_hint=source_policy, + source_policy_hint=consumed_source_policy_hint or source_policy, 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=query_semantics, route_decision=route_decision, @@ -405,16 +603,20 @@ class AdaptiveRAGAnswerMainlineMixin: question=effective_q, query_semantics=query_semantics, ) - if isinstance(operation_contract, dict): - query_semantics["operation_contract"] = operation_contract + query_semantics = self._mirror_internal_context_to_query_semantics( + query_semantics=query_semantics, + operation_contract=operation_contract, + ) internal_query_plan = self._build_internal_query_plan( question=effective_q, project_id=project_id, query_semantics=query_semantics, operation_contract=operation_contract, ) - if internal_query_plan: - query_semantics["query_plan"] = internal_query_plan + query_semantics = self._mirror_internal_context_to_query_semantics( + query_semantics=query_semantics, + query_plan=internal_query_plan, + ) route_decision = ( dict(route_preview.get("route_decision") or {}) if isinstance(route_preview.get("route_decision"), dict) @@ -579,11 +781,13 @@ class AdaptiveRAGAnswerMainlineMixin: deep_thinking = True active_thinking_mode = THINKING_MODE_DEEP - clarification_payload = self._build_followup_clarification_payload( - question=clean_q, - anchor_resolution=anchor_resolution, - session_state=state_row, - ) + clarification_payload = None + if not self._has_resolved_followup_rewrite(rewrite_meta): + clarification_payload = self._build_followup_clarification_payload( + question=clean_q, + anchor_resolution=anchor_resolution, + session_state=state_row, + ) route_clarification_payload = self._build_route_clarification_payload(route_decision=route_decision) meta_followup_payload = await self._build_meta_followup_payload( question=clean_q, @@ -642,6 +846,11 @@ class AdaptiveRAGAnswerMainlineMixin: str(query_semantics.get("effective_source_policy") or query_semantics.get("source_policy") or "").strip().lower() == "external" ) + query_semantics = self._mirror_followup_state_to_query_semantics( + query_semantics=query_semantics, + session_state=state_row, + rewrite_meta=rewrite_meta, + ) target_resolution = ( dict(query_semantics.get("target_resolution") or {}) if isinstance(query_semantics.get("target_resolution"), dict) @@ -656,8 +865,10 @@ class AdaptiveRAGAnswerMainlineMixin: system_snapshot=system_snapshot, operation_contract=operation_contract, ) - if isinstance(target_resolution, dict): - query_semantics["target_resolution"] = target_resolution + query_semantics = self._mirror_internal_context_to_query_semantics( + query_semantics=query_semantics, + target_resolution=target_resolution, + ) patch_scene_semantics = getattr(self, "_patch_scene_semantics_with_target_resolution", None) if callable(patch_scene_semantics): scene_semantics = patch_scene_semantics( @@ -672,8 +883,49 @@ class AdaptiveRAGAnswerMainlineMixin: operation_contract=operation_contract, target_resolution=target_resolution, ) - if internal_query_plan: - query_semantics["query_plan"] = internal_query_plan + query_semantics = self._mirror_internal_context_to_query_semantics( + 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: deterministic_result = await self._run_deterministic_query( @@ -681,6 +933,7 @@ class AdaptiveRAGAnswerMainlineMixin: project_id=project_id, query_type=query_type, query_semantics=query_semantics, + internal_context_packet=internal_context_packet, ) if deterministic_result is not None: return await self._persist_fast_answer( @@ -701,6 +954,7 @@ class AdaptiveRAGAnswerMainlineMixin: answer_mode=ANSWER_MODE_FAST_PATH, route_decision=route_decision, query_semantics=query_semantics, + internal_context_packet=internal_context_packet, ) internal_model_config_fast_answer = None @@ -709,6 +963,7 @@ class AdaptiveRAGAnswerMainlineMixin: internal_model_config_fast_answer = await internal_model_config_builder( question=effective_q, query_semantics=query_semantics, + internal_context_packet=internal_context_packet, ) if internal_model_config_fast_answer is not None: return await self._persist_fast_answer( @@ -729,6 +984,7 @@ class AdaptiveRAGAnswerMainlineMixin: answer_mode=ANSWER_MODE_FAST_PATH, route_decision=route_decision, query_semantics=query_semantics, + internal_context_packet=internal_context_packet, ) direct_answer = None @@ -775,6 +1031,7 @@ class AdaptiveRAGAnswerMainlineMixin: clean_q=effective_q, project_id=project_id, query_semantics=query_semantics, + internal_context_packet=internal_context_packet, ) if glm_semantic_result is not None: return await self._persist_fast_answer( @@ -795,6 +1052,7 @@ class AdaptiveRAGAnswerMainlineMixin: answer_mode=ANSWER_MODE_FAST_PATH, route_decision=route_decision, query_semantics=query_semantics, + internal_context_packet=internal_context_packet, ) return await self._build_retrieval_response( @@ -828,4 +1086,5 @@ class AdaptiveRAGAnswerMainlineMixin: answer_mode=planner.retrieval_answer_mode, consultant_mode_preference=consultant_mode_preference, route_decision=route_decision, + internal_context_packet=internal_context_packet, ) diff --git a/backend/app/services/adaptive_rag_service/mixin_answer_payload.py b/backend/app/services/adaptive_rag_service/mixin_answer_payload.py index ef17b80d..c78c21e9 100644 --- a/backend/app/services/adaptive_rag_service/mixin_answer_payload.py +++ b/backend/app/services/adaptive_rag_service/mixin_answer_payload.py @@ -2,6 +2,11 @@ from __future__ import annotations from .shared import * +from .internal_query_context import ( + internal_context_block_reason, + internal_context_operation_contract, + internal_context_query_plan, +) class AdaptiveRAGAnswerPayloadMixin: @@ -484,12 +489,32 @@ class AdaptiveRAGAnswerPayloadMixin: answer: str, sources: Optional[list[dict[str, Any]]] = None, route_decision: Optional[dict[str, Any]] = None, + internal_context_packet: Any = None, ) -> dict[str, Any]: source_rows = sources if isinstance(sources, list) else [] if not self._is_internal_answer_contract_route(route_decision=route_decision, sources=source_rows): 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() compact_answer = re.sub(r"\s+", "", normalized_answer.lower()) @@ -500,7 +525,13 @@ class AdaptiveRAGAnswerPayloadMixin: "reason_code": "no_total_count_substitution", "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 { "ok": False, "reason_code": "no_total_count_substitution", @@ -530,9 +561,28 @@ class AdaptiveRAGAnswerPayloadMixin: answer = "当前内部查询结果与问题类型不一致,已阻止直接返回。你问的是“有没有”,请给我更明确的对象条件后我按存在性重新查询。" elif normalized_reason == "missing_internal_evidence": 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: 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 { "answer": answer, "sources": [ @@ -549,8 +599,8 @@ class AdaptiveRAGAnswerPayloadMixin: ], "missing_fields": normalized_missing or ["internal_answer_contract"], "reason_code": normalized_reason, - "answer_mode": ANSWER_MODE_BLOCKED, - "grounding_status": GROUNDING_STATUS_BLOCKED, + "answer_mode": answer_mode, + "grounding_status": grounding_status, } def _build_response_payload( diff --git a/backend/app/services/adaptive_rag_service/mixin_answer_precheck.py b/backend/app/services/adaptive_rag_service/mixin_answer_precheck.py index 6fef89b5..9fdbde04 100644 --- a/backend/app/services/adaptive_rag_service/mixin_answer_precheck.py +++ b/backend/app/services/adaptive_rag_service/mixin_answer_precheck.py @@ -267,6 +267,18 @@ class AdaptiveRAGAnswerPrecheckMixin: candidates=clarification_candidates, 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: self._clear_pending_clarification(session_state=state_row) await self._update_session_state_after_turn( diff --git a/backend/app/services/adaptive_rag_service/mixin_answer_retrieval.py b/backend/app/services/adaptive_rag_service/mixin_answer_retrieval.py index d28ee673..866e40bd 100644 --- a/backend/app/services/adaptive_rag_service/mixin_answer_retrieval.py +++ b/backend/app/services/adaptive_rag_service/mixin_answer_retrieval.py @@ -2,6 +2,7 @@ from __future__ import annotations from .shared import * +from .internal_query_context import update_internal_query_context_packet class AdaptiveRAGAnswerRetrievalMixin: @@ -39,7 +40,11 @@ class AdaptiveRAGAnswerRetrievalMixin: try: 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: return ExternalAdapterResult( adapter_name="mcp", @@ -138,6 +143,7 @@ class AdaptiveRAGAnswerRetrievalMixin: question=question, query_semantics=query_semantics, ) + context_packet = execution.to_context_packet(question=question).model_dump(mode="json") verifier = dict(execution.verifier or {}) if not bool(verifier.get("ok")): return [], { @@ -166,6 +172,7 @@ class AdaptiveRAGAnswerRetrievalMixin: "db_timeout": False, "vector_timeout": False, "graph_timeout": False, + "external_context_packet": context_packet, } rows: list[RetrievedItem] = [] @@ -221,6 +228,7 @@ class AdaptiveRAGAnswerRetrievalMixin: "db_timeout": False, "vector_timeout": False, "graph_timeout": False, + "external_context_packet": context_packet, "tool_trace": [ { "step": 1, @@ -312,6 +320,7 @@ class AdaptiveRAGAnswerRetrievalMixin: answer_mode: str = ANSWER_MODE_RETRIEVAL_NORMAL, consultant_mode_preference: Optional[str] = None, route_decision: Optional[dict[str, Any]] = None, + internal_context_packet: Any = None, ) -> dict[str, Any]: domains = [str(item).strip().lower() for item in retrieval_domains if str(item or "").strip()] if not domains: @@ -344,6 +353,7 @@ class AdaptiveRAGAnswerRetrievalMixin: project_id=project_id, source_chat_id=source_chat_id, query_semantics=query_semantics, + internal_context_packet=internal_context_packet, session_state=state_row, max_steps_override=deep_retrieval_steps 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)] 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 active_retrieved = list(retrieved) @@ -400,11 +433,15 @@ class AdaptiveRAGAnswerRetrievalMixin: consultant_mode_preference=consultant_mode_preference, scene_semantics=scene_semantics, query_semantics=query_semantics, + external_context_packet=external_context_packet, ) prompt_variant_id = self._peek_answer_prompt_variant() routing_snapshot = self._peek_last_answer_routing_snapshot() 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 = [ { "source": "system", @@ -438,6 +475,7 @@ class AdaptiveRAGAnswerRetrievalMixin: project_id=project_id, source_chat_id=source_chat_id, query_semantics=query_semantics, + internal_context_packet=internal_context_packet, session_state=state_row, max_steps_override=deep_retrieval_steps, vector_top_k=deep_vector_top_k, @@ -530,7 +568,7 @@ class AdaptiveRAGAnswerRetrievalMixin: routing_snapshot = self._peek_last_answer_routing_snapshot() evidence_guard_triggered = False - clarification_due_to_evidence = False + clarification_due_to_evidence = bool(external_insufficient_evidence) 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): live_rows, live_meta = await self._maybe_fetch_live_external_freshness_context( @@ -564,7 +602,27 @@ class AdaptiveRAGAnswerRetrievalMixin: consultant_mode_preference=consultant_mode_preference, scene_semantics=scene_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) if "url" not in final_domains: final_domains.append("url") @@ -612,7 +670,13 @@ class AdaptiveRAGAnswerRetrievalMixin: } ) 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 answer_text = ( f"当前关于“{clean_q[:80]}”的证据不足,现阶段只命中系统快照。" @@ -684,7 +748,11 @@ class AdaptiveRAGAnswerRetrievalMixin: elif clarification_due_to_evidence: grounding_verdict = GroundingVerdict( 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", ) 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, sources=used_sources, route_decision=normalized_route_decision, + internal_context_packet=internal_context_packet, ) if not bool(contract_verdict.get("ok")): 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) if callable(inject_runtime_summaries): retrieval_metrics = inject_runtime_summaries( retrieval_metrics=retrieval_metrics, query_semantics=query_semantics, + internal_context_packet=internal_context_packet, ) runtime_fields = self._build_qa_record_runtime_fields( answer=answer_text, @@ -823,7 +922,7 @@ class AdaptiveRAGAnswerRetrievalMixin: ) self.db.add(qa) 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) missing_fields = list(grounding_verdict.missing_fields) await self._update_session_state_after_turn( diff --git a/backend/app/services/adaptive_rag_service/mixin_core_intent.py b/backend/app/services/adaptive_rag_service/mixin_core_intent.py index 66634c2f..b2c4e143 100644 --- a/backend/app/services/adaptive_rag_service/mixin_core_intent.py +++ b/backend/app/services/adaptive_rag_service/mixin_core_intent.py @@ -32,6 +32,63 @@ class AdaptiveRAGCoreIntentMixin: self.context_cache = context_cache_service self._active_external_qa_context: dict[str, Any] = {} 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 def _normalize_thinking_mode(value: Optional[str]) -> str: @@ -467,9 +524,18 @@ class AdaptiveRAGCoreIntentMixin: source_policy_hint: Optional[str] = None, ) -> dict[str, Any]: 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) if not text: - return { + return self._store_cached_query_semantics(cache_key, { "intent": "statement", "message_intent": "statement", "question_part": "", @@ -484,7 +550,7 @@ class AdaptiveRAGCoreIntentMixin: "fallback_taken": False, "query_profile": self._normalize_query_profile(None), "confidence": 1.0, - } + }) bundle = None planner_message_intent = "" @@ -592,7 +658,7 @@ class AdaptiveRAGCoreIntentMixin: confidence = self._safe_float(parsed.get("confidence"), planner_confidence) normalized_message_intent = planner_message_intent or intent - return { + return self._store_cached_query_semantics(cache_key, { "intent": intent, "message_intent": normalized_message_intent, "question_part": question_part, @@ -610,7 +676,7 @@ class AdaptiveRAGCoreIntentMixin: "decision_origin": decision_origin, "fallback_taken": fallback_taken, "confidence": max(0.0, min(1.0, confidence)), - } + }) async def classify_intent(self, message: str) -> dict[str, Any]: semantics = await self._resolve_query_semantics( diff --git a/backend/app/services/adaptive_rag_service/mixin_deep_guard_answer.py b/backend/app/services/adaptive_rag_service/mixin_deep_guard_answer.py index eafaf611..a0747e28 100644 --- a/backend/app/services/adaptive_rag_service/mixin_deep_guard_answer.py +++ b/backend/app/services/adaptive_rag_service/mixin_deep_guard_answer.py @@ -3,6 +3,12 @@ from __future__ import annotations from ...config.ai_prompts import PROMPT_PROJECT_STATUS_QA 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 * @@ -120,15 +126,31 @@ class AdaptiveRAGDeepGuardAnswerMixin: *, question: str, query_semantics: Optional[dict[str, Any]] = None, + internal_context_packet: Any = None, ) -> Optional[dict[str, Any]]: _ = question semantics = query_semantics if isinstance(query_semantics, dict) else {} + route_decision = internal_context_route_decision( + packet=internal_context_packet, + fallback=semantics, + ) 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() if source_policy and source_policy != "internal": 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( operation_contract.get("query_type") or semantics.get("query_type") @@ -136,7 +158,15 @@ class AdaptiveRAGDeepGuardAnswerMixin: ).strip().lower() if query_type not in {QUERY_TYPE_STATUS, QUERY_TYPE_EXPLAIN}: 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 = { str(item).strip().lower() 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"}): return None - 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() if target_entity_type and target_entity_type != "config": @@ -1119,6 +1152,27 @@ class AdaptiveRAGDeepGuardAnswerMixin: detail_bits.append(f",{status_text}。") 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( self, question: str, @@ -1139,9 +1193,25 @@ class AdaptiveRAGDeepGuardAnswerMixin: consultant_mode_preference: Optional[str] = None, scene_semantics: Optional[dict[str, Any]] = None, query_semantics: Optional[dict[str, Any]] = None, + external_context_packet: Optional[dict[str, Any]] = None, ) -> str: active_thinking_mode = self._normalize_thinking_mode(thinking_mode) 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] = [] used_tokens = 0 max_blocks = max(4, min(int(context_block_limit or DEFAULT_TOP_K * 2), 32)) diff --git a/backend/app/services/adaptive_rag_service/mixin_deep_guard_rules.py b/backend/app/services/adaptive_rag_service/mixin_deep_guard_rules.py index c2d902ea..c57811bd 100644 --- a/backend/app/services/adaptive_rag_service/mixin_deep_guard_rules.py +++ b/backend/app/services/adaptive_rag_service/mixin_deep_guard_rules.py @@ -13,22 +13,6 @@ class AdaptiveRAGDeepGuardRulesMixin: "runtime_state_query", } _EXTERNAL_FRESHNESS_SCENES = {"industry_trend", "external_trend"} - _EXTERNAL_FACT_FRESHNESS_MARKERS = ( - "最新", - "目前", - "当前", - "截至现在", - "截至当前", - "截止当前", - "今天", - "现在", - "最近", - "出到什么", - "发布了吗", - "发布到", - "版本", - "最高模型", - ) @classmethod def _normalize_deep_intent(cls, value: Any) -> str: @@ -147,16 +131,10 @@ class AdaptiveRAGDeepGuardRulesMixin: question: str, scene_semantics: Optional[dict[str, Any]], ) -> bool: + _ = question 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() - if scene_key not 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) + return bool(semantics.get("requires_freshness")) or scene_key in cls._EXTERNAL_FRESHNESS_SCENES @classmethod def _has_external_grounding_sources(cls, sources: list[dict[str, Any]]) -> bool: diff --git a/backend/app/services/adaptive_rag_service/mixin_deterministic_query_core.py b/backend/app/services/adaptive_rag_service/mixin_deterministic_query_core.py index 96d8f63b..7f451742 100644 --- a/backend/app/services/adaptive_rag_service/mixin_deterministic_query_core.py +++ b/backend/app/services/adaptive_rag_service/mixin_deterministic_query_core.py @@ -2,9 +2,30 @@ from __future__ import annotations from .shared import * +from .internal_query_context import ( + internal_context_operation_contract, + internal_context_semantic_mirror, + internal_context_target_resolution, +) 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( self, *, @@ -12,25 +33,45 @@ class AdaptiveRAGDeterministicQueryCoreMixin: project_id: Optional[int], query_type: str, query_semantics: Optional[dict[str, Any]] = None, + internal_context_packet: Any = None, ) -> 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: return await self._run_count_query( question=question, project_id=project_id, query_semantics=query_semantics, + internal_context_packet=internal_context_packet, ) if q_type == QUERY_TYPE_LIST: return await self._run_list_query( question=question, project_id=project_id, 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( clean_q=question, project_id=project_id, query_semantics=query_semantics, + internal_context_packet=internal_context_packet, ) if existence: existence["query_type"] = QUERY_TYPE_EXISTENCE @@ -42,6 +83,7 @@ class AdaptiveRAGDeterministicQueryCoreMixin: clean_q=question, project_id=project_id, query_semantics=query_semantics, + internal_context_packet=internal_context_packet, ) if created_at: created_at["query_type"] = QUERY_TYPE_STATUS @@ -52,22 +94,25 @@ class AdaptiveRAGDeterministicQueryCoreMixin: clean_q=question, project_id=project_id, query_semantics=query_semantics, + internal_context_packet=internal_context_packet, ) if progress: progress["query_type"] = QUERY_TYPE_STATUS progress.setdefault("confidence", 0.98) progress.setdefault("missing_fields", []) return progress - existence = await self._try_fast_project_existence_answer( - clean_q=question, - project_id=project_id, - query_semantics=query_semantics, - ) - if existence: - existence["query_type"] = QUERY_TYPE_STATUS - existence.setdefault("confidence", 0.96) - existence.setdefault("missing_fields", []) - return existence + if not defer_project_existence: + existence = await self._try_fast_project_existence_answer( + clean_q=question, + project_id=project_id, + query_semantics=query_semantics, + internal_context_packet=internal_context_packet, + ) + if existence: + existence["query_type"] = QUERY_TYPE_STATUS + existence.setdefault("confidence", 0.96) + existence.setdefault("missing_fields", []) + return existence return None async def _resolve_project_scope_for_question( @@ -76,6 +121,7 @@ class AdaptiveRAGDeterministicQueryCoreMixin: question: str, project_id: Optional[int], query_semantics: Optional[dict[str, Any]] = None, + internal_context_packet: Any = None, ) -> tuple[Optional[int], Optional[str], str]: if project_id is not None: row = ( @@ -93,7 +139,14 @@ class AdaptiveRAGDeterministicQueryCoreMixin: return (int(row.id), str(row.title or row.name or f"项目#{int(row.id)}"), "") 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() if target_ref.startswith("project:"): try: diff --git a/backend/app/services/adaptive_rag_service/mixin_deterministic_query_count.py b/backend/app/services/adaptive_rag_service/mixin_deterministic_query_count.py index 9464c435..8bf1a5c9 100644 --- a/backend/app/services/adaptive_rag_service/mixin_deterministic_query_count.py +++ b/backend/app/services/adaptive_rag_service/mixin_deterministic_query_count.py @@ -11,6 +11,7 @@ class AdaptiveRAGDeterministicQueryCountMixin: question: str, project_id: Optional[int], query_semantics: Optional[dict[str, Any]] = None, + internal_context_packet: Any = None, ) -> Optional[dict[str, Any]]: text = str(question or "").lower() 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( question=text, project_id=project_id, + internal_context_packet=internal_context_packet, ) if project_scope_required and scoped_project_id is None and scoped_keyword: 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( question=text, project_id=project_id, + internal_context_packet=internal_context_packet, ) if project_scope_required and scoped_project_id is None and scoped_keyword: 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( question=text, project_id=project_id, + internal_context_packet=internal_context_packet, ) if project_scope_required and scoped_project_id is None and scoped_keyword: elapsed = int((time.perf_counter() - started) * 1000) diff --git a/backend/app/services/adaptive_rag_service/mixin_deterministic_query_list.py b/backend/app/services/adaptive_rag_service/mixin_deterministic_query_list.py index c400cfcc..3cf4a02e 100644 --- a/backend/app/services/adaptive_rag_service/mixin_deterministic_query_list.py +++ b/backend/app/services/adaptive_rag_service/mixin_deterministic_query_list.py @@ -11,6 +11,7 @@ class AdaptiveRAGDeterministicQueryListMixin: question: str, project_id: Optional[int], query_semantics: Optional[dict[str, Any]] = None, + internal_context_packet: Any = None, ) -> Optional[dict[str, Any]]: text = str(question or "").lower() 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( question=text, project_id=project_id, + internal_context_packet=internal_context_packet, ) if project_scope_required and scoped_project_id is None and scoped_keyword: 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( question=text, project_id=project_id, + internal_context_packet=internal_context_packet, ) if project_scope_required and scoped_project_id is None and scoped_keyword: elapsed = int((time.perf_counter() - started) * 1000) diff --git a/backend/app/services/adaptive_rag_service/mixin_fast_answer_project_main.py b/backend/app/services/adaptive_rag_service/mixin_fast_answer_project_main.py index 24411627..60146a3b 100644 --- a/backend/app/services/adaptive_rag_service/mixin_fast_answer_project_main.py +++ b/backend/app/services/adaptive_rag_service/mixin_fast_answer_project_main.py @@ -1,15 +1,508 @@ """Adaptive RAG mixin module.""" 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 .internal_query_context import ( + internal_context_operation_contract, + internal_context_semantic_mirror, + internal_context_target_resolution, +) class AdaptiveRAGFastAnswerProjectMainMixin: - PROJECT_CREATED_AT_RE = re.compile( - r"(什么时候.*创建|何时.*创建|创建时间|立项时间|成立时间|创建于|何时立项|什么时候立项)", - re.IGNORECASE, - ) - PROJECT_RISK_BLOCKER_RE = re.compile(r"(风险|阻塞|卡点|瓶颈|问题)", re.IGNORECASE) + _DONE_TASK_STATUSES = {"done", "completed", "已完成", "完成", "closed", "关闭"} + _DOING_TASK_STATUSES = {"in_progress", "doing", "进行中", "开发中", "处理中", "processing"} + _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), + ) + ) + ) + ).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( self, @@ -17,21 +510,24 @@ class AdaptiveRAGFastAnswerProjectMainMixin: clean_q: str, project_id: Optional[int], query_semantics: Optional[dict[str, Any]] = None, + internal_context_packet: Any = None, ) -> Optional[dict[str, Any]]: question = str(clean_q or "").strip() if not question: return None - asks_risk_or_blockers = bool(self.PROJECT_RISK_BLOCKER_RE.search(question)) - if not PROJECT_PROGRESS_RE.search(question) and not asks_risk_or_blockers: + focus = self._normalize_project_status_focus(query_semantics) + if focus not in {"progress", "snapshot", "risk", "risk_or_blocker"}: 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 + asks_risk_or_blockers = focus in {"risk", "risk_or_blocker"} started = time.perf_counter() target_project_id, target_name, used_keyword = await self._resolve_project_scope_for_question( question=question, project_id=project_id, query_semantics=query_semantics, + internal_context_packet=internal_context_packet, ) if target_project_id is None: db_query_ms = int((time.perf_counter() - started) * 1000) @@ -48,18 +544,10 @@ class AdaptiveRAGFastAnswerProjectMainMixin: "db_query_ms": db_query_ms, "response_time_ms": db_query_ms, } - - target = ( - await self.db.execute( - select(Project).where( - and_( - Project.tenant_id == self.tenant_id, - Project.id == int(target_project_id), - ) - ) - ) - ).scalar_one_or_none() - if target is None: + snapshot, db_sources = await self._build_project_progress_snapshot( + target_project_id=int(target_project_id), + ) + if snapshot is None: db_query_ms = int((time.perf_counter() - started) * 1000) return { "answer": f"未检索到项目#{int(target_project_id)},无法判断进度。", @@ -79,103 +567,92 @@ class AdaptiveRAGFastAnswerProjectMainMixin: "db_query_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) - - total = len(task_rows) - done_statuses = {"done", "completed", "已完成", "完成", "closed", "关闭"} - 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}。" - "系统里暂未沉淀独立的风险/阻塞台账字段,而且目前也没有可用的任务拆解记录。" - "现阶段只能先把“缺任务分解、缺里程碑、缺负责人推进节奏”视作主要风险信号。" + internal_findings = self._build_project_internal_findings( + snapshot, + asks_risk_or_blockers=asks_risk_or_blockers, + ) + external_rows, external_meta = await self._build_project_external_updates( + question=question, + snapshot=snapshot, + query_semantics=query_semantics, + ) + external_updates = [ + { + "title": str((getattr(item, "metadata", {}) or {}).get("title") or "").strip(), + "summary": self._truncate_project_report_text(getattr(item, "content", ""), limit=160), + "url": str((getattr(item, "metadata", {}) or {}).get("url") or "").strip(), + "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, + ) + fallback_fields = self._build_project_weekly_report_fallback( + snapshot=snapshot, + asks_risk_or_blockers=asks_risk_or_blockers, + has_external_updates=bool(external_updates), + ) + merged_fields = dict(fallback_fields) + 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"), + }, + } ) - elif total == 0: - answer = ( - f"项目「{project_name}」当前状态为 {project_status}。" - "目前未检索到该项目的任务拆解记录,因此只能确认项目状态,无法量化阶段进度。" - ) - elif asks_risk_or_blockers: - blocker_like = pending - answer = ( - f"项目「{project_name}」当前状态为 {project_status}。" - f"任务总数 {total},进行中 {doing},待处理 {pending},已完成 {done}。" - f"系统里暂未沉淀独立的风险/阻塞台账字段,所以先用待处理任务数 {blocker_like} 和最近任务更新时间作为代理信号。" - ) - else: - completion = round((done / total) * 100, 1) - latest_text = latest_update.strftime("%Y-%m-%d %H:%M") if latest_update else "未知" - answer = ( - f"项目「{project_name}」当前状态为 {project_status}。" - f"任务总数 {total},已完成 {done},进行中 {doing},待处理 {pending},完成率 {completion}%。" - f"最近任务更新时间:{latest_text}。" - ) - if asks_risk_or_blockers and latest_update is not None: - answer += f" 最近任务更新时间:{latest_update.strftime('%Y-%m-%d %H:%M')}。" return { "answer": answer, - "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, - }, - }, - ], + "sources": sources, "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( @@ -184,11 +661,14 @@ class AdaptiveRAGFastAnswerProjectMainMixin: clean_q: str, project_id: Optional[int], query_semantics: Optional[dict[str, Any]] = None, + internal_context_packet: Any = None, ) -> Optional[dict[str, Any]]: question = str(clean_q or "").strip() - if not question or not self.PROJECT_CREATED_AT_RE.search(question): + if not question: 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 started = time.perf_counter() @@ -197,6 +677,7 @@ class AdaptiveRAGFastAnswerProjectMainMixin: question=question, project_id=project_id, query_semantics=query_semantics, + internal_context_packet=internal_context_packet, ) if target_project_id is None: @@ -286,6 +767,7 @@ class AdaptiveRAGFastAnswerProjectMainMixin: clean_q: str, project_id: Optional[int], query_semantics: Optional[dict[str, Any]] = None, + internal_context_packet: Any = None, ) -> Optional[dict[str, Any]]: question = str(clean_q or "").strip() if not question: @@ -294,7 +776,14 @@ class AdaptiveRAGFastAnswerProjectMainMixin: subject_focus = self._query_subject_focus(semantics) scope_hint = self._query_scope_hint(semantics) message_intent = str(semantics.get("message_intent") or semantics.get("intent") or "").strip().lower() - target_resolution = 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_ref = str(target_resolution.get("target_ref") or "").strip() if target_entity_type == "project" and target_ref.startswith("project:"): @@ -345,8 +834,16 @@ class AdaptiveRAGFastAnswerProjectMainMixin: query_terms = self._collect_lookup_terms( question=question, 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, + internal_context_packet=internal_context_packet, limit=4, ) if not query_terms: diff --git a/backend/app/services/adaptive_rag_service/mixin_fast_answer_project_utils.py b/backend/app/services/adaptive_rag_service/mixin_fast_answer_project_utils.py index b7f766fb..97d58eee 100644 --- a/backend/app/services/adaptive_rag_service/mixin_fast_answer_project_utils.py +++ b/backend/app/services/adaptive_rag_service/mixin_fast_answer_project_utils.py @@ -1,6 +1,11 @@ """Adaptive RAG mixin module.""" from __future__ import annotations +from .internal_query_context import ( + internal_context_operation_contract, + internal_context_semantic_mirror, + internal_context_target_resolution, +) from .shared import * @@ -43,14 +48,33 @@ class AdaptiveRAGFastAnswerProjectUtilsMixin: query_semantics: Optional[dict[str, Any]] = None, operation_contract: Optional[dict[str, Any]] = None, target_resolution: Optional[dict[str, Any]] = None, + internal_context_packet: Any = None, limit: int = 6, ) -> list[str]: semantics = query_semantics if isinstance(query_semantics, dict) else {} - operation = operation_contract if isinstance(operation_contract, dict) else ( - semantics.get("operation_contract") if isinstance(semantics.get("operation_contract"), dict) else {} + operation = internal_context_operation_contract( + packet=internal_context_packet, + fallback=( + operation_contract + if isinstance(operation_contract, dict) + else internal_context_semantic_mirror( + packet=internal_context_packet, + query_semantics=semantics, + key="operation_contract", + ) + ), ) - target = 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.extend(list(operation.get("object_mentions") or [])) @@ -103,12 +127,14 @@ class AdaptiveRAGFastAnswerProjectUtilsMixin: query_semantics: Optional[dict[str, Any]] = None, operation_contract: Optional[dict[str, Any]] = None, target_resolution: Optional[dict[str, Any]] = None, + internal_context_packet: Any = None, limit: int = 6, ) -> list[str]: mentions = cls._collect_structured_object_mentions( query_semantics=query_semantics, operation_contract=operation_contract, target_resolution=target_resolution, + internal_context_packet=internal_context_packet, limit=limit, ) return mentions diff --git a/backend/app/services/adaptive_rag_service/mixin_fast_answer_semantic_p1.py b/backend/app/services/adaptive_rag_service/mixin_fast_answer_semantic_p1.py index d3679f3b..90a90e39 100644 --- a/backend/app/services/adaptive_rag_service/mixin_fast_answer_semantic_p1.py +++ b/backend/app/services/adaptive_rag_service/mixin_fast_answer_semantic_p1.py @@ -2,31 +2,149 @@ from __future__ import annotations 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 * 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( self, *, clean_q: str, project_id: Optional[int], query_semantics: Optional[dict[str, Any]] = None, + internal_context_packet: Any = None, ) -> Optional[dict[str, Any]]: question = str(clean_q or "").strip() if not question: 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) if subject_focus in {"people", "feature", "workload"}: return None if subject_focus not in {"project", "task", "signal", "charter", "unknown"}: 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() - 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: 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: db_query_ms = int((time.perf_counter() - db_started) * 1000) return { @@ -43,6 +161,10 @@ class AdaptiveRAGFastAnswerSemanticMixinP1: "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] task_rows = [] if project_ids: @@ -56,6 +178,39 @@ class AdaptiveRAGFastAnswerSemanticMixinP1: ) ) ).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) done_statuses = {"done", "completed", "已完成", "完成", "closed", "关闭"} @@ -96,6 +251,7 @@ class AdaptiveRAGFastAnswerSemanticMixinP1: for p in projects: pid = int(p.id) stats = task_stats.get(pid) or {} + signal_row = signal_stats.get(pid) or {} project_index.append( { "id": pid, @@ -103,10 +259,15 @@ class AdaptiveRAGFastAnswerSemanticMixinP1: "name": str(p.name or "")[:120], "status": str(p.status or "unknown"), "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), "done_count": int(stats.get("done_count") or 0), "doing_count": int(stats.get("doing_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": ( stats.get("latest_task_updated_at").isoformat() if stats.get("latest_task_updated_at") is not None @@ -120,6 +281,8 @@ class AdaptiveRAGFastAnswerSemanticMixinP1: prompt_key="PROMPT_RAG_DB_PROJECT_QA", fallback_template=PROMPT_RAG_DB_PROJECT_QA, question=question[:600], + query_type=query_type, + answer_contract=answer_contract, project_index=json.dumps(project_index, ensure_ascii=False), ) llm_started = time.perf_counter() @@ -150,6 +313,17 @@ class AdaptiveRAGFastAnswerSemanticMixinP1: matched_ids = matched_ids[:8] if not handled or not answer: 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 return { "answer": answer, diff --git a/backend/app/services/adaptive_rag_service/mixin_fast_answer_semantic_p2.py b/backend/app/services/adaptive_rag_service/mixin_fast_answer_semantic_p2.py index 402e7e03..94e8edf3 100644 --- a/backend/app/services/adaptive_rag_service/mixin_fast_answer_semantic_p2.py +++ b/backend/app/services/adaptive_rag_service/mixin_fast_answer_semantic_p2.py @@ -2,6 +2,7 @@ from __future__ import annotations from .shared import * +from .internal_query_context import update_internal_query_context_packet class AdaptiveRAGFastAnswerSemanticMixinP2: @@ -25,6 +26,7 @@ class AdaptiveRAGFastAnswerSemanticMixinP2: answer_mode: str = ANSWER_MODE_FAST_PATH, route_decision: Optional[dict[str, Any]] = None, query_semantics: Optional[dict[str, Any]] = None, + internal_context_packet: Any = None, ) -> dict[str, Any]: started = time.perf_counter() sources = list(fast_result.get("sources") or []) @@ -58,6 +60,7 @@ class AdaptiveRAGFastAnswerSemanticMixinP2: answer=answer_text, sources=sources, route_decision=route_decision, + internal_context_packet=internal_context_packet, ) effective_answer_mode = answer_mode effective_grounding_status = GROUNDING_STATUS_GROUNDED @@ -82,11 +85,35 @@ class AdaptiveRAGFastAnswerSemanticMixinP2: "vector_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) if callable(inject_runtime_summaries): retrieval_metrics = inject_runtime_summaries( retrieval_metrics=retrieval_metrics, query_semantics=query_semantics, + internal_context_packet=internal_context_packet, ) runtime_fields = self._build_qa_record_runtime_fields( answer=answer_text, diff --git a/backend/app/services/adaptive_rag_service/mixin_followup_anchor.py b/backend/app/services/adaptive_rag_service/mixin_followup_anchor.py index bc7b39b9..a99e0683 100644 --- a/backend/app/services/adaptive_rag_service/mixin_followup_anchor.py +++ b/backend/app/services/adaptive_rag_service/mixin_followup_anchor.py @@ -3,34 +3,13 @@ from __future__ import annotations from dataclasses import dataclass, field from datetime import timedelta +import unicodedata from .shared import * FOLLOWUP_RECENT_REPLY_WINDOW_MINUTES = 5 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 @@ -76,11 +55,29 @@ class AdaptiveRAGFollowupAnchorMixin: @staticmethod def _normalize_followup_selection_text(text: str) -> str: 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) return normalized[:120] + @staticmethod + def _parse_followup_choice_index(text: str) -> int: + compact = "".join(str(text or "").split()).strip() + if not compact: + return 0 + if compact.isdigit(): + try: + parsed = int(compact) + except Exception: + return 0 + return parsed if parsed > 0 else 0 + if len(compact) == 1: + try: + numeric = unicodedata.numeric(compact) + except Exception: + return 0 + if float(numeric).is_integer() and numeric > 0: + return int(numeric) + return 0 + @staticmethod def _normalize_followup_preview(text: str) -> str: cleaned = re.sub(r"]*>.*?", "", str(text or ""), flags=re.IGNORECASE) @@ -194,7 +191,6 @@ class AdaptiveRAGFollowupAnchorMixin: question = " ".join(str(row.question or "").split()).strip() answer = " ".join(str(row.answer or "").split()).strip() label = question or "最近回复" - label = re.sub(r"^(?:帮我|请|麻烦|我想知道|想问下|问下|给我|解释下|解释一下|告诉我|说下)", "", label).strip() label = re.sub(r"[??!!。\.].*$", "", label).strip(" ,,::") label = label[:20] or (question[:20] if question else "最近回复") answer_preview = answer[:56] + ("..." if len(answer) > 56 else "") @@ -425,6 +421,43 @@ class AdaptiveRAGFollowupAnchorMixin: 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]]: if session_state is None: return None @@ -447,17 +480,39 @@ class AdaptiveRAGFollowupAnchorMixin: if not payload: return None text = str(question or "").strip() - index: Optional[int] = None - digit_match = FOLLOWUP_SELECTION_DIGIT_RE.match(text) - if digit_match: - index = int(digit_match.group(1)) - else: - ordinal_match = FOLLOWUP_SELECTION_ORDINAL_RE.match(text) - if ordinal_match: - raw = ordinal_match.group(1) - index = {"一": 1, "二": 2, "三": 3}.get(raw) - if index is None: - index = int(raw) + kind = str(payload.get("kind") or "").strip().lower() + if kind == "query_route_selection": + option_map = payload.get("option_map") if isinstance(payload.get("option_map"), dict) else {} + options = payload.get("options") if isinstance(payload.get("options"), list) else [] + normalized = self._normalize_followup_selection_text(text) + selected_hint = "" + if len(text) == 1: + selected_hint = str(option_map.get(text.upper()) or "").strip().lower() + if not selected_hint: + matched: list[str] = [] + for option in options: + if not isinstance(option, dict): + continue + key = str(option.get("key") or "").strip().upper() + label = str(option.get("label") or "").strip() + normalized_label = self._normalize_followup_selection_text(label) + if not normalized or not normalized_label: + continue + if normalized in normalized_label or normalized_label in normalized: + mapped = str(option_map.get(key) or "").strip().lower() + if mapped and mapped not in matched: + matched.append(mapped) + if len(matched) == 1: + selected_hint = matched[0] + if selected_hint in {"internal", "external"}: + 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 [] normalized = self._normalize_followup_selection_text(text) selected: Optional[dict[str, Any]] = None @@ -465,7 +520,7 @@ class AdaptiveRAGFollowupAnchorMixin: if not isinstance(candidate, dict): continue 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 break match_text = str(candidate.get("match_text") or "").strip() @@ -672,7 +727,7 @@ class AdaptiveRAGFollowupAnchorMixin: return { "answer": ( "我不确定你指的是哪一条我刚才的回复。" - "你可以直接回复那条消息,或者告诉我是“系统架构”那条还是“系统模型”那条。" + "你可以直接回复那条消息,或者引用那条消息后再继续说。" ), "code": "no_anchor_found", "candidates": [], diff --git a/backend/app/services/adaptive_rag_service/mixin_multistep_core.py b/backend/app/services/adaptive_rag_service/mixin_multistep_core.py index 408e366c..b0c244ec 100644 --- a/backend/app/services/adaptive_rag_service/mixin_multistep_core.py +++ b/backend/app/services/adaptive_rag_service/mixin_multistep_core.py @@ -20,6 +20,7 @@ class AdaptiveRAGMultiStepCoreMixin: project_id: Optional[int], source_chat_id: Optional[str], query_semantics: Optional[dict[str, Any]] = None, + internal_context_packet: Any = None, resolved_entities: Optional[dict[str, Any]] = None, vector_top_k: Optional[int] = None, ) -> tuple[list[RetrievedItem], dict[str, Any]]: @@ -33,6 +34,7 @@ class AdaptiveRAGMultiStepCoreMixin: question, project_id=project_id, query_semantics=query_semantics, + internal_context_packet=internal_context_packet, ), timeout=DB_QUERY_TIMEOUT_SECONDS, ) diff --git a/backend/app/services/adaptive_rag_service/mixin_multistep_flow.py b/backend/app/services/adaptive_rag_service/mixin_multistep_flow.py index 1093d5f4..59dffdd3 100644 --- a/backend/app/services/adaptive_rag_service/mixin_multistep_flow.py +++ b/backend/app/services/adaptive_rag_service/mixin_multistep_flow.py @@ -13,6 +13,7 @@ class AdaptiveRAGMultiStepFlowMixin: project_id: Optional[int], source_chat_id: Optional[str], query_semantics: Optional[dict[str, Any]] = None, + internal_context_packet: Any = None, session_state: Optional[QASessionState], max_steps_override: Optional[int] = None, vector_top_k: Optional[int] = None, @@ -132,6 +133,7 @@ class AdaptiveRAGMultiStepFlowMixin: project_id=project_id, source_chat_id=source_chat_id, query_semantics=query_semantics, + internal_context_packet=internal_context_packet, resolved_entities=resolved_payload, vector_top_k=vector_top_k, ) diff --git a/backend/app/services/adaptive_rag_service/mixin_qa_session_list_ops_p1.py b/backend/app/services/adaptive_rag_service/mixin_qa_session_list_ops_p1.py index 63559acd..2be09651 100644 --- a/backend/app/services/adaptive_rag_service/mixin_qa_session_list_ops_p1.py +++ b/backend/app/services/adaptive_rag_service/mixin_qa_session_list_ops_p1.py @@ -5,6 +5,14 @@ from .shared import * 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( self, *, @@ -159,6 +167,7 @@ class AdaptiveRAGQASessionListOpsMixinP1: for row in records: fb = feedback_map.get(int(row.id)) ack_meta = self._extract_ack_metadata_from_sources(row.sources, answer=row.answer) + routing_snapshot = self._normalize_message_dict(row.routing_snapshot) if fb: rating = str(fb.rating or "").strip().lower() if rating == "positive": @@ -180,6 +189,13 @@ class AdaptiveRAGQASessionListOpsMixinP1: "asked_via": row.asked_via, "project_id": row.project_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), "created_at": row.created_at.isoformat() if row.created_at else None, "feedback": { @@ -237,4 +253,3 @@ class AdaptiveRAGQASessionListOpsMixinP1: session_row.title = cleaned await self.db.commit() return {"id": int(session_row.id), "title": cleaned} - diff --git a/backend/app/services/adaptive_rag_service/mixin_query_route_gateway.py b/backend/app/services/adaptive_rag_service/mixin_query_route_gateway.py index 10053207..64f39581 100644 --- a/backend/app/services/adaptive_rag_service/mixin_query_route_gateway.py +++ b/backend/app/services/adaptive_rag_service/mixin_query_route_gateway.py @@ -92,6 +92,22 @@ class AdaptiveRAGQueryRouteGatewayMixin: def _build_query_route_clarification_text(cls) -> str: 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 def _build_contract_snapshot_summary( cls, diff --git a/backend/app/services/adaptive_rag_service/mixin_query_route_governance.py b/backend/app/services/adaptive_rag_service/mixin_query_route_governance.py new file mode 100644 index 00000000..7b5567de --- /dev/null +++ b/backend/app/services/adaptive_rag_service/mixin_query_route_governance.py @@ -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) + diff --git a/backend/app/services/adaptive_rag_service/mixin_retrieval_router_db.py b/backend/app/services/adaptive_rag_service/mixin_retrieval_router_db.py index 5b0032ca..46564511 100644 --- a/backend/app/services/adaptive_rag_service/mixin_retrieval_router_db.py +++ b/backend/app/services/adaptive_rag_service/mixin_retrieval_router_db.py @@ -2,6 +2,12 @@ from __future__ import annotations 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: @@ -11,21 +17,31 @@ class AdaptiveRAGRetrievalRouterDBMixin: entity_type: str, question: str, query_semantics: Optional[dict[str, Any]] = None, + internal_context_packet: Any = None, ) -> list[RetrievedItem]: 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( question=question, query_semantics=query_semantics, - operation_contract=( - query_semantics.get("operation_contract") - if isinstance(query_semantics, dict) and isinstance(query_semantics.get("operation_contract"), dict) - else None - ), - target_resolution=( - query_semantics.get("target_resolution") - if isinstance(query_semantics, dict) and isinstance(query_semantics.get("target_resolution"), dict) - else None - ), + operation_contract=operation_contract, + target_resolution=target_resolution, + internal_context_packet=internal_context_packet, limit=4, ) if callable(term_collector) else [] if not query_terms: @@ -87,21 +103,31 @@ class AdaptiveRAGRetrievalRouterDBMixin: question: str, project_id: Optional[int], query_semantics: Optional[dict[str, Any]] = None, + internal_context_packet: Any = None, ) -> list[RetrievedItem]: 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( question=question, query_semantics=query_semantics, - operation_contract=( - query_semantics.get("operation_contract") - if isinstance(query_semantics, dict) and isinstance(query_semantics.get("operation_contract"), dict) - else None - ), - target_resolution=( - query_semantics.get("target_resolution") - if isinstance(query_semantics, dict) and isinstance(query_semantics.get("target_resolution"), dict) - else None - ), + operation_contract=operation_contract, + target_resolution=target_resolution, + internal_context_packet=internal_context_packet, limit=4, ) if callable(term_collector) else [] if not query_terms: @@ -172,14 +198,29 @@ class AdaptiveRAGRetrievalRouterDBMixin: *, project_id: Optional[int], query_semantics: Optional[dict[str, Any]] = None, + internal_context_packet: Any = None, ) -> list[RetrievedItem]: text = question.lower() rows: list[RetrievedItem] = [] subject_focus = self._query_subject_focus(query_semantics) scope_hint = self._query_scope_hint(query_semantics) - target_resolution = 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() - 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() 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 @@ -202,6 +243,7 @@ class AdaptiveRAGRetrievalRouterDBMixin: entity_type=semantic_fact_entity_type, question=question, query_semantics=query_semantics, + internal_context_packet=internal_context_packet, ) ) @@ -266,6 +308,7 @@ class AdaptiveRAGRetrievalRouterDBMixin: question=question, project_id=project_id, query_semantics=query_semantics, + internal_context_packet=internal_context_packet, ) rows.extend(semantic_fact_rows) stmt = select(Project).where(Project.tenant_id == self.tenant_id) diff --git a/backend/app/services/adaptive_rag_service/mixin_session_state.py b/backend/app/services/adaptive_rag_service/mixin_session_state.py index d20121bc..6fa0c134 100644 --- a/backend/app/services/adaptive_rag_service/mixin_session_state.py +++ b/backend/app/services/adaptive_rag_service/mixin_session_state.py @@ -156,6 +156,24 @@ class AdaptiveRAGSessionStateMixin: uniq.append(value) 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( self, *, @@ -175,16 +193,16 @@ class AdaptiveRAGSessionStateMixin: if subject_name and object_name: 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() normalizer = getattr(self, "_normalize_lookup_text", None) normalized_question = normalizer(text) if callable(normalizer) else text if 2 <= len(normalized_question) <= 64: 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 "" async def get_session_state_replay(self, *, session_id: int, user_id: int) -> dict[str, Any]: diff --git a/backend/app/services/adaptive_rag_service/mixin_target_resolution.py b/backend/app/services/adaptive_rag_service/mixin_target_resolution.py index 58eae2df..70c3d876 100644 --- a/backend/app/services/adaptive_rag_service/mixin_target_resolution.py +++ b/backend/app/services/adaptive_rag_service/mixin_target_resolution.py @@ -2,6 +2,11 @@ from __future__ import annotations from .shared import * +from .internal_query_context import ( + internal_context_operation_contract, + internal_context_query_plan, + internal_context_target_resolution, +) class AdaptiveRAGTargetResolutionMixin: @@ -19,10 +24,9 @@ class AdaptiveRAGTargetResolutionMixin: mentions: list[str] = [] rows = raw if isinstance(raw, list) else [] for item in rows: - text = str(item or "").strip(" ,。,.!??::;;“”\"'()()") - text = QUESTION_NOISE_RE.sub("", text).strip() + text = normalize_lookup_fragment(item, limit=32) if len(text) >= 2 and text not in mentions: - mentions.append(text[:32]) + mentions.append(text) return mentions[:4] def _build_operation_contract( @@ -60,6 +64,63 @@ class AdaptiveRAGTargetResolutionMixin: "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 def _normalize_target_candidate(cls, payload: Optional[dict[str, Any]]) -> Optional[dict[str, Any]]: row = payload if isinstance(payload, dict) else {} @@ -585,6 +646,13 @@ class AdaptiveRAGTargetResolutionMixin: "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( question=question, project_id=project_id, @@ -754,12 +822,28 @@ class AdaptiveRAGTargetResolutionMixin: *, retrieval_metrics: Optional[dict[str, Any]], query_semantics: Optional[dict[str, Any]], + internal_context_packet: Any = None, ) -> dict[str, Any]: metrics = dict(retrieval_metrics or {}) semantics = query_semantics if isinstance(query_semantics, dict) else {} - metrics["operation_contract_summary"] = self._build_operation_contract_summary(semantics.get("operation_contract")) - metrics["target_resolution_summary"] = self._build_target_resolution_summary(semantics.get("target_resolution")) - metrics["query_plan_summary"] = self._build_query_plan_summary(semantics.get("query_plan")) + metrics["operation_contract_summary"] = self._build_operation_contract_summary( + internal_context_operation_contract( + 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 diff --git a/backend/app/services/adaptive_rag_service/service.py b/backend/app/services/adaptive_rag_service/service.py index a295a343..1c474f84 100644 --- a/backend/app/services/adaptive_rag_service/service.py +++ b/backend/app/services/adaptive_rag_service/service.py @@ -41,6 +41,7 @@ from .mixin_conversation_utils_a_p2 import AdaptiveRAGConversationUtilsAMixinP2 from .mixin_conversation_utils_b import AdaptiveRAGConversationUtilsBMixin from .mixin_followup_anchor import AdaptiveRAGFollowupAnchorMixin from .mixin_query_route_gateway import AdaptiveRAGQueryRouteGatewayMixin +from .mixin_query_route_governance import AdaptiveRAGQueryRouteGovernanceMixin from .mixin_target_resolution import AdaptiveRAGTargetResolutionMixin @@ -86,6 +87,7 @@ class AdaptiveRAGService( AdaptiveRAGConversationUtilsBMixin, AdaptiveRAGFollowupAnchorMixin, AdaptiveRAGQueryRouteGatewayMixin, + AdaptiveRAGQueryRouteGovernanceMixin, ): """项目内统一 RAG 服务。""" _symbol_snapshot_cache: dict[str, tuple[float, dict[str, Any]]] = {} diff --git a/backend/app/services/adaptive_rag_service/shared_part_01.py b/backend/app/services/adaptive_rag_service/shared_part_01.py index b7423400..86496587 100644 --- a/backend/app/services/adaptive_rag_service/shared_part_01.py +++ b/backend/app/services/adaptive_rag_service/shared_part_01.py @@ -1,13 +1,15 @@ -"""Adaptive RAG shared constants/types part.""" +"""Adaptive RAG shared lightweight text helpers.""" from __future__ import annotations from .shared_imports import * from .shared_utils import * -PROJECT_PROGRESS_RE = re.compile(r"(进度|阶段|完成度|完成率|推进|里程碑|状态)") -QUESTION_NOISE_RE = re.compile( - r"(目前|当前|现在|请问|一下|系统中|项目中|系统内|项目内|是否|有没有|有无|有|吗|呢|呀|啊|什么|哪个|多少|查询|看看)" -) -BRIEF_NEGATIVE_FEEDBACK_RE = re.compile( - r"^(不是啊?|不对|错了|答错了?|跑偏了?|偏了|不是这个(意思)?|不(是|对)啊)$" -) +_LOOKUP_TRIM_CHARS = " \t\r\n,。,.!??::;;“”\"'()()[]【】<>《》`" + + +def normalize_lookup_fragment(value: Any, *, keep_spaces: bool = False, limit: int = 32) -> str: + text = str(value or "") + text = re.sub(r"\s+", " " if keep_spaces else "", text).strip(_LOOKUP_TRIM_CHARS) + if not text: + return "" + return text[: max(1, int(limit))] diff --git a/backend/app/services/ai_pmo/ai_pmo_intent/__init__.py b/backend/app/services/ai_pmo/ai_pmo_intent/__init__.py index 9b1230eb..0addacd0 100644 --- a/backend/app/services/ai_pmo/ai_pmo_intent/__init__.py +++ b/backend/app/services/ai_pmo/ai_pmo_intent/__init__.py @@ -48,7 +48,7 @@ __all__ = [ "WORKFLOW_ROUTE_KEYS", "ROUTE_CANONICAL_INTENT_MAP", "LEGACY_INTENT_ROUTE_MAP", - "FOLLOWUP_WORK_ITEM_ID_RE", + "extract_followup_work_item_id", "INTENT_LLM_FUNCTION_NAME", "INTENT_CANDIDATES", "WRITE_INTENTS", diff --git a/backend/app/services/ai_pmo/ai_pmo_intent/intent_resolver.py b/backend/app/services/ai_pmo/ai_pmo_intent/intent_resolver.py index f6eec730..bfc50060 100644 --- a/backend/app/services/ai_pmo/ai_pmo_intent/intent_resolver.py +++ b/backend/app/services/ai_pmo/ai_pmo_intent/intent_resolver.py @@ -20,7 +20,7 @@ class IntentResolver: diagnostics = bundle.diagnostics if isinstance(bundle.diagnostics, dict) else {} if str(diagnostics.get("message_intent") or "").strip().lower() == "casual_chat": 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: return CanonicalIntent( INTENT_FOLLOWUP_CONTINUE, @@ -30,7 +30,7 @@ class IntentResolver: "", { "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(), }, ) diff --git a/backend/app/services/ai_pmo/ai_pmo_intent/shared.py b/backend/app/services/ai_pmo/ai_pmo_intent/shared.py index 71852b8a..85c3b368 100644 --- a/backend/app/services/ai_pmo/ai_pmo_intent/shared.py +++ b/backend/app/services/ai_pmo/ai_pmo_intent/shared.py @@ -8,10 +8,25 @@ from typing import Any, Optional from ...signal.signal_source_orchestration import SignalSourceOrchestrationService -FOLLOWUP_WORK_ITEM_ID_RE = re.compile( - r"(?:work[_ -]?item|工作项|队列项|草稿)\s*(?:id|ID)?\s*[:#:]?\s*(\d+)", - re.IGNORECASE, -) + +def extract_followup_work_item_id(text: Any) -> Optional[int]: + 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_PROJECT_CREATE = "project.create" diff --git a/backend/app/services/ai_pmo/ai_pmo_kernel/mixins/followup/ai_pmo_kernel_mixins_followup_verification.py b/backend/app/services/ai_pmo/ai_pmo_kernel/mixins/followup/ai_pmo_kernel_mixins_followup_verification.py index d056e799..b7a91c1f 100644 --- a/backend/app/services/ai_pmo/ai_pmo_kernel/mixins/followup/ai_pmo_kernel_mixins_followup_verification.py +++ b/backend/app/services/ai_pmo/ai_pmo_kernel/mixins/followup/ai_pmo_kernel_mixins_followup_verification.py @@ -154,8 +154,7 @@ class KernelFollowupVerificationMixin: @staticmethod def _extract_followup_work_item_id(message: str) -> Optional[int]: - match = FOLLOWUP_WORK_ITEM_ID_RE.search(str(message or "")) - return int(match.group(1)) if match else None + return extract_followup_work_item_id(message) @staticmethod def _build_followup_candidate_clarification(items: list[WorkItem]) -> str: diff --git a/backend/app/services/ai_pmo/ai_pmo_kernel/mixins/intent/ai_pmo_kernel_mixins_intent_resolve.py b/backend/app/services/ai_pmo/ai_pmo_kernel/mixins/intent/ai_pmo_kernel_mixins_intent_resolve.py index 2b49fbd7..94b56c7b 100644 --- a/backend/app/services/ai_pmo/ai_pmo_kernel/mixins/intent/ai_pmo_kernel_mixins_intent_resolve.py +++ b/backend/app/services/ai_pmo/ai_pmo_kernel/mixins/intent/ai_pmo_kernel_mixins_intent_resolve.py @@ -199,6 +199,7 @@ class KernelIntentResolveMixin: message_sent_at: Optional[Any] = None, session_snapshot: Optional[dict[str, Any]] = None, thread_context: Optional[dict[str, Any]] = None, + semantic_bundle_override: Optional[dict[str, Any]] = None, ) -> tuple[CanonicalIntent, str]: reply_anchor_present = bool( isinstance(thread_context, dict) @@ -213,6 +214,17 @@ class KernelIntentResolveMixin: session=session, ) 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 = { "project_id": _safe_int(world_model.get("resolved_project_id")), "world_model_project_id": _safe_int(world_model.get("resolved_project_id")), diff --git a/backend/app/services/ai_pmo/ai_pmo_kernel_common_core/imports.py b/backend/app/services/ai_pmo/ai_pmo_kernel_common_core/imports.py index 69161a46..87b61aeb 100644 --- a/backend/app/services/ai_pmo/ai_pmo_kernel_common_core/imports.py +++ b/backend/app/services/ai_pmo/ai_pmo_kernel_common_core/imports.py @@ -73,7 +73,7 @@ from ..ai_pmo_decision import DecisionEngine from ..ai_pmo_kernel import SignalSourceDomainWorkflow from ..ai_pmo_intent import ( FOLLOWUP_TARGET_INTENTS, - FOLLOWUP_WORK_ITEM_ID_RE, + extract_followup_work_item_id, INTENT_CANDIDATES, INTENT_CONFLICT_POLICIES, INTENT_FOLLOWUP_CONFIRM, diff --git a/backend/app/services/ai_pmo/ai_pmo_kernel_ingress_flow/mixin_entry.py b/backend/app/services/ai_pmo/ai_pmo_kernel_ingress_flow/mixin_entry.py index bb73bf22..0ca95d5a 100644 --- a/backend/app/services/ai_pmo/ai_pmo_kernel_ingress_flow/mixin_entry.py +++ b/backend/app/services/ai_pmo/ai_pmo_kernel_ingress_flow/mixin_entry.py @@ -1,11 +1,14 @@ from __future__ import annotations +from datetime import datetime, timedelta + from .shared import * from app.utils.thread_context import build_feishu_conversation_scope_key from app.services.ai_pmo.semantic_context import ( SemanticContextAssembler, 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.workflow_semantic_planner import WorkflowSemanticPlanner from app.services.ai_pmo.workflow_semantic_planner.constants import ( @@ -178,6 +181,245 @@ class KernelIngressFlowEntryMixin: return base_question 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( self, *, @@ -316,6 +558,26 @@ class KernelIngressFlowEntryMixin: def _supports_mixed_sidecar(*, source_system: str) -> bool: 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 def _has_explicit_thread_anchor(thread_context: Optional[dict[str, Any]]) -> bool: 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") ) + @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 def _build_forced_query_scene_metadata( cls, @@ -510,12 +850,15 @@ class KernelIngressFlowEntryMixin: thread_context: Optional[dict[str, Any]], message_text: str, project_hint_id: Optional[int], + semantic_bundle: Optional[dict[str, Any]] = None, ) -> tuple[str, dict[str, Any]]: normalized_text = str(message_text or "").strip() if not self._supports_mixed_sidecar(source_system=source_system): return normalized_text, {} if not 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): try: from ..ai_pmo_typed_executor_execute.mixin_execute_query_project import ExecuteQueryProjectMixin @@ -785,7 +1128,19 @@ class KernelIngressFlowEntryMixin: "topic": packet.topic.model_dump(mode="json"), "draft": packet.draft.model_dump(mode="json") if packet.draft is not None else None, } - decision = await SemanticDecisionService(llm_service).decide(packet=packet) + 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) gate_result = SemanticDecisionGatekeeper.evaluate( mode_card=packet.mode_card, decision=decision, @@ -1029,6 +1384,17 @@ class KernelIngressFlowEntryMixin: if active_context_followup_result is not None: await _safe_finalize_mounted_runtime(result=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( source_system=source_system, source_message_id=synthetic_message_id, @@ -1041,6 +1407,11 @@ class KernelIngressFlowEntryMixin: await _safe_finalize_mounted_runtime(result=external_freshness_followup_result) return external_freshness_followup_result 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( source_system=source_system, source_message_id=synthetic_message_id, @@ -1049,6 +1420,7 @@ class KernelIngressFlowEntryMixin: thread_context=thread_context, message_text=clean_text, project_hint_id=project_hint_id, + semantic_bundle=semantic_bundle_override, ) perf_work_item_id = int(work_item.id) try: @@ -1067,11 +1439,6 @@ class KernelIngressFlowEntryMixin: if isinstance(ingress_options, dict) 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() intent, intent_context_source = await self._resolve_intent( message_text=clean_text, @@ -1080,6 +1447,7 @@ class KernelIngressFlowEntryMixin: message_sent_at=message_sent_at, session_snapshot=session_snapshot, thread_context=thread_context, + semantic_bundle_override=semantic_bundle_override, ) intent = self._coerce_intent_for_query_scene( intent=intent, diff --git a/backend/app/services/ai_pmo/ai_pmo_typed_executor_execute/mixin_execute_query_project.py b/backend/app/services/ai_pmo/ai_pmo_typed_executor_execute/mixin_execute_query_project.py index 24b33af4..49b2aeae 100644 --- a/backend/app/services/ai_pmo/ai_pmo_typed_executor_execute/mixin_execute_query_project.py +++ b/backend/app/services/ai_pmo/ai_pmo_typed_executor_execute/mixin_execute_query_project.py @@ -2715,36 +2715,8 @@ class ExecuteQueryProjectMixin: @classmethod def _requirement_plan_packet_allows_deployment_context(cls, *, packet: dict[str, Any]) -> bool: - context_material = { - "latest_user_message": packet.get("latest_user_message"), - "latest_user_reply_summary": packet.get("latest_user_reply_summary"), - "current_context": packet.get("current_context"), - "collected_fields": packet.get("collected_fields"), - "collected_field_summary": packet.get("collected_field_summary"), - } - context = WorkflowSemanticPlanner._normalize_text(json.dumps(context_material, ensure_ascii=False)) - deployment_hints = ( - "本地路由层", - "批量自动化部署", - "自动化部署", - "部署治理", - "发布治理", - "发布平台", - "部署平台", - "云原生交付底座", - "云原生交付版", - "任务编排平台版", - "平台运维", - "环境接入闭环", - "回滚", - "审批", - "kubernetes", - "k8s", - "helm", - "argocd", - "terraform", - ) - return any(term in context for term in deployment_hints) + _ = packet + return False @classmethod def _render_requirement_plan_round_payload(cls, payload: dict[str, Any]) -> str: @@ -2791,29 +2763,8 @@ class ExecuteQueryProjectMixin: @classmethod def _requirement_plan_context_allows_deployment_terms(cls, *, context_material: Any) -> bool: - 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) + _ = context_material + return False @classmethod def _build_requirement_plan_draft_packet( diff --git a/backend/app/services/ai_pmo/draft_confirm_handler.py b/backend/app/services/ai_pmo/draft_confirm_handler.py index 034175b0..16bcc882 100644 --- a/backend/app/services/ai_pmo/draft_confirm_handler.py +++ b/backend/app/services/ai_pmo/draft_confirm_handler.py @@ -47,8 +47,6 @@ class DraftConfirmHandler: "收到,这是展示形式反馈:卡片可以更完整,聊天直发我会控制得更短;当前草稿内容我先不重起。" ) - _WORK_ITEM_ID_RE = re.compile(r"(?:work[_ -]?item|工作项|草稿)\s*(?:id|ID)?\s*[:#:]?\s*(\d+)", re.IGNORECASE) - @classmethod def is_explicit_protocol_followup_text(cls, text: str) -> bool: normalized_text = cls._normalize_protocol_text(text) @@ -1621,10 +1619,7 @@ class DraftConfirmHandler: @classmethod def _extract_work_item_id(cls, text: str) -> Optional[int]: - matched = cls._WORK_ITEM_ID_RE.search(str(text or "")) - if matched is None: - return None - return _safe_int(matched.group(1)) + return extract_followup_work_item_id(text) @staticmethod def _safe_float(value: Any) -> Optional[float]: diff --git a/backend/app/services/blocker_detection_core/mixin_ai.py b/backend/app/services/blocker_detection_core/mixin_ai.py index 0b3a91ea..87f16e9d 100644 --- a/backend/app/services/blocker_detection_core/mixin_ai.py +++ b/backend/app/services/blocker_detection_core/mixin_ai.py @@ -12,25 +12,25 @@ class BlockerDetectionAiMixin: self, code_content: str, file_path: str, - pattern: str, tenant_id: int, ) -> Dict[str, Any]: """AI 分析代码阻塞。""" prompt = f"""分析以下代码注释中提到的阻塞问题: 文件:{file_path} -匹配到的模式:{pattern} 代码内容: {code_content} 请分析: -1. 阻塞的根本原因是什么? -2. 严重程度(high/medium/low)? -3. 可能的解决方案有哪些? -4. 是否需要创建新任务来跟踪? +1. 这里是否明确表达了阻塞、依赖卡住或无法继续推进? +2. 如果是阻塞,根本原因是什么? +3. 严重程度(high/medium/low)? +4. 可能的解决方案有哪些? +5. 是否需要创建新任务来跟踪? 以 JSON 格式返回: {{ + "is_blocker": boolean, "problem_analysis": "问题分析", "severity": "high/medium/low", "suggested_solutions": ["方案1", "方案2"], @@ -52,9 +52,10 @@ class BlockerDetectionAiMixin: return json.loads(response.strip()) except Exception: return { - "problem_analysis": f"检测到可能的阻塞:{pattern}", + "is_blocker": False, + "problem_analysis": "", "severity": "medium", - "suggested_solutions": ["请团队负责人评估"], + "suggested_solutions": [], "requires_new_task": False, } diff --git a/backend/app/services/blocker_detection_core/mixin_scan.py b/backend/app/services/blocker_detection_core/mixin_scan.py index 9a7d144d..179099a7 100644 --- a/backend/app/services/blocker_detection_core/mixin_scan.py +++ b/backend/app/services/blocker_detection_core/mixin_scan.py @@ -2,7 +2,7 @@ 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: @@ -18,21 +18,17 @@ class BlockerDetectionScanMixin: tenant_id: int = 1, ) -> Dict[str, Any] | None: """扫描代码注释,检测阻塞。""" - detected_pattern = None - for pattern in BLOCKER_PATTERNS: - if re.search(pattern, code_content, re.IGNORECASE): - detected_pattern = pattern - break - - if not detected_pattern: + normalized_content = str(code_content or "").strip() + if len(normalized_content) < 8: return None analysis = await self._analyze_blocker_with_ai( - code_content=code_content, + code_content=normalized_content, file_path=file_path, - pattern=detected_pattern, tenant_id=tenant_id, ) + if not bool(analysis.get("is_blocker")): + return None next_conflict_id = ( await self.db.execute(text("SELECT COALESCE(MAX(id),0)+1 FROM cognitive_conflicts")) diff --git a/backend/app/services/blocker_detection_core/shared.py b/backend/app/services/blocker_detection_core/shared.py index 2a70add0..1496e7a1 100644 --- a/backend/app/services/blocker_detection_core/shared.py +++ b/backend/app/services/blocker_detection_core/shared.py @@ -4,7 +4,6 @@ from __future__ import annotations import asyncio import json -import re from datetime import datetime, timedelta, timezone 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 ..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__ = [ "Any", "AsyncSession", - "BLOCKER_PATTERNS", "CognitiveConflict", "Dict", "LLMService", @@ -53,7 +32,6 @@ __all__ = [ "datetime", "json", "now_cn_naive", - "re", "select", "text", "timedelta", diff --git a/backend/app/services/external_query_service/__init__.py b/backend/app/services/external_query_service/__init__.py index a98a3a50..dafda54b 100644 --- a/backend/app/services/external_query_service/__init__.py +++ b/backend/app/services/external_query_service/__init__.py @@ -2,9 +2,11 @@ from __future__ import annotations from .gateway import ExternalQueryGateway from .planner import ExternalQueryPlanner +from .presenter import ExternalAnswerPresenter from .registry import ExternalProviderRegistry from .schemas import ( ExternalAdapterResult, + ExternalContextPacket, ExternalEvidenceItem, ExternalQueryExecution, ExternalQueryPlan, @@ -12,6 +14,8 @@ from .schemas import ( __all__ = [ "ExternalAdapterResult", + "ExternalAnswerPresenter", + "ExternalContextPacket", "ExternalEvidenceItem", "ExternalProviderRegistry", "ExternalQueryExecution", diff --git a/backend/app/services/external_query_service/presenter.py b/backend/app/services/external_query_service/presenter.py new file mode 100644 index 00000000..3c0ddc43 --- /dev/null +++ b/backend/app/services/external_query_service/presenter.py @@ -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, + } diff --git a/backend/app/services/external_query_service/schemas.py b/backend/app/services/external_query_service/schemas.py index 913985dd..b63ee350 100644 --- a/backend/app/services/external_query_service/schemas.py +++ b/backend/app/services/external_query_service/schemas.py @@ -60,3 +60,33 @@ class ExternalQueryExecution(BaseModel): verifier: dict[str, Any] = Field(default_factory=dict) planner_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) diff --git a/backend/app/services/integrations/feishu_integration_service/mixin_reply_guard.py b/backend/app/services/integrations/feishu_integration_service/mixin_reply_guard.py index ecdb9a2d..2ee85ce8 100644 --- a/backend/app/services/integrations/feishu_integration_service/mixin_reply_guard.py +++ b/backend/app/services/integrations/feishu_integration_service/mixin_reply_guard.py @@ -6,45 +6,12 @@ from ....utils.thread_context import has_feishu_bot_mention, has_feishu_explicit class FeishuReplyGuardMixin: @staticmethod def _detect_forbidden_execute_keyword(text: Optional[str]) -> Optional[str]: - lowered = str(text or "").strip().lower() - if not lowered: - return None - forbidden = ( - "curl", - "调用方式", - "接口教程", - "api 调用", - "请求示例", - "postman", - "sdk 调用", - ) - for token in forbidden: - if token in lowered: - return token + _ = text return None @staticmethod def _detect_forbidden_qa_keyword(text: Optional[str]) -> Optional[str]: - lowered = str(text or "").strip().lower() - 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 + _ = text return None @classmethod diff --git a/backend/app/services/integrations/feishu_integration_service/mixin_reply_llm_core.py b/backend/app/services/integrations/feishu_integration_service/mixin_reply_llm_core.py index 20e2837e..1e2ec7f8 100644 --- a/backend/app/services/integrations/feishu_integration_service/mixin_reply_llm_core.py +++ b/backend/app/services/integrations/feishu_integration_service/mixin_reply_llm_core.py @@ -16,19 +16,6 @@ class FeishuReplyLLMCoreMixin: perf_t0: Optional[float] = 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( message_content=message_content, capture_result=capture_result, @@ -343,28 +330,3 @@ class FeishuReplyLLMCoreMixin: @staticmethod def _fallback_reply_text(capture_result: Dict[str, Any]) -> str: 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}。" - "如果你的界面显示不一致,优先检查设备时区和飞书客户端缓存。" - ) diff --git a/backend/app/services/integrations/feishu_integration_service/mixin_reply_outbox.py b/backend/app/services/integrations/feishu_integration_service/mixin_reply_outbox.py index d5989beb..6e6fd527 100644 --- a/backend/app/services/integrations/feishu_integration_service/mixin_reply_outbox.py +++ b/backend/app/services/integrations/feishu_integration_service/mixin_reply_outbox.py @@ -545,12 +545,14 @@ class FeishuReplyOutboxMixin: return "" label_map = { - "结论:": "🎯 结论:", - "依据:": "🔎 依据:", - "下一步:": "➡️ 下一步:", - "信息不足,缺少:": "🧩 信息不足,缺少:", "当前我更推荐:": "⭐ 当前我更推荐:", } + legacy_prefix_map = { + "结论:": "", + "依据:": "", + "下一步:": "", + "信息不足,缺少:": "当前证据不足:", + } section_prefixes = tuple(label_map.values()) + ("🧭 第 ",) choice_prefixes = ("A.", "B.", "C.") tail_prefixes = ("你回 A / B / C",) @@ -562,6 +564,10 @@ class FeishuReplyOutboxMixin: if beautified and beautified[-1] != "": beautified.append("") 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(): if stripped.startswith(plain): stripped = f"{pretty}{stripped[len(plain):]}" diff --git a/backend/app/services/integrations/feishu_integration_service/mixin_reply_templates.py b/backend/app/services/integrations/feishu_integration_service/mixin_reply_templates.py index 4e421045..362d4d60 100644 --- a/backend/app/services/integrations/feishu_integration_service/mixin_reply_templates.py +++ b/backend/app/services/integrations/feishu_integration_service/mixin_reply_templates.py @@ -78,9 +78,6 @@ class FeishuReplyTemplatesMixin: return "这条消息不是可执行需求,我没有入库。请直接说要新增什么信号或任务。" return "这条消息已忽略(未入库)。如需执行,请明确目标、对象和动作。" - text = str(message_content or "").strip() - strict_keywords = ("单独信号源", "单个信号源", "只入库", "仅入库", "不需要二次处理") - strict_mode = any(k in text for k in strict_keywords) primary_route_status = "" results = capture_result.get("results") if isinstance(results, list) and results: @@ -133,33 +130,17 @@ class FeishuReplyTemplatesMixin: project_id = str(candidate).strip() or None if signal_id and project_id: - if strict_mode: - return FeishuReplyTemplatesMixin._compact_receipt( - "✅ 已按要求仅执行入库", - f"信号#{signal_id} 已入库", - f"项目#{project_id} 已关联", - ) return FeishuReplyTemplatesMixin._compact_receipt( "✅ 已执行完成", f"信号#{signal_id} 已入库", f"项目#{project_id} 已关联", ) if signal_id: - if strict_mode: - return FeishuReplyTemplatesMixin._compact_receipt( - "✅ 已按要求仅执行入库", - f"信号#{signal_id} 已入库", - ) return FeishuReplyTemplatesMixin._compact_receipt( "✅ 已执行完成", f"信号#{signal_id} 已入库", ) if project_id: - if strict_mode: - return FeishuReplyTemplatesMixin._compact_receipt( - "✅ 已按要求执行", - f"项目#{project_id} 已关联", - ) return FeishuReplyTemplatesMixin._compact_receipt( "✅ 已执行完成", f"项目#{project_id} 已关联", diff --git a/backend/app/services/integrations/feishu_signal_pipeline/mixin_lookup_and_block.py b/backend/app/services/integrations/feishu_signal_pipeline/mixin_lookup_and_block.py index aefe7347..e2cc77f9 100644 --- a/backend/app/services/integrations/feishu_signal_pipeline/mixin_lookup_and_block.py +++ b/backend/app/services/integrations/feishu_signal_pipeline/mixin_lookup_and_block.py @@ -6,33 +6,9 @@ from .shared import * class FeishuPipelineLookupAndBlockMixin: @staticmethod - def _extract_project_lookup_keyword(message: str) -> str: - text = str(message or "").strip() - if not text: - 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 "" + def _normalize_project_lookup_text(value: Any) -> str: + text = normalize_lookup_fragment(value, limit=96) + return re.sub(r"\s+", "", text).lower() async def _maybe_block_legacy_command_request( self, @@ -93,15 +69,12 @@ class FeishuPipelineLookupAndBlockMixin: reply_text = self._format_project_lookup_hit_rows(projects[:8], prefix="当前项目") linked_project_ids = [int(item["id"]) for item in projects[:8] if _safe_int(item.get("id"))] else: - keyword = self._extract_project_lookup_keyword(message) - matched_rows = self._match_projects_by_keyword(projects=projects, keyword=keyword) + matched_rows = self._match_projects_by_keyword(projects=projects, keyword=message) if matched_rows: linked_project_ids = [int(item["id"]) for item in matched_rows if _safe_int(item.get("id"))] reply_text = self._format_project_lookup_hit_rows(matched_rows[:5], prefix="查到了") - elif keyword: - reply_text = f"没有查到包含“{keyword}”的项目。" else: - reply_text = "这条问题缺少可检索的项目关键词。" + reply_text = "没有查到可确认的项目。请补充更完整的项目名,或直接给项目 ID。" duration_ms = int((time.perf_counter() - process_started_at) * 1000) await self._write_processing_log( @@ -183,15 +156,21 @@ class FeishuPipelineLookupAndBlockMixin: @staticmethod 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: return [] - matched: list[dict[str, Any]] = [] + ranked: list[tuple[int, int, dict[str, Any]]] = [] for item in projects: - title = re.sub(r"\s+", "", str(item.get("title") or item.get("name") or "")).lower() - if normalized_keyword and normalized_keyword in title: - matched.append(item) - return matched + title = FeishuPipelineLookupAndBlockMixin._normalize_project_lookup_text(item.get("title") or item.get("name") or "") + if len(title) < 2: + continue + if title in normalized_keyword: + ranked.append((0, -len(title), item)) + continue + if normalized_keyword in title: + ranked.append((1, len(title), item)) + ranked.sort(key=lambda row: (row[0], row[1], int(_safe_int(row[2].get("id")) or 0))) + return [row[2] for row in ranked] @staticmethod def _format_project_lookup_hit_rows(rows: list[dict[str, Any]], *, prefix: str) -> str: diff --git a/backend/app/services/integrations/feishu_signal_pipeline/shared_core.py b/backend/app/services/integrations/feishu_signal_pipeline/shared_core.py index d743fa72..63d8f8c0 100644 --- a/backend/app/services/integrations/feishu_signal_pipeline/shared_core.py +++ b/backend/app/services/integrations/feishu_signal_pipeline/shared_core.py @@ -73,14 +73,6 @@ FEISHU_QUOTE_PREFIX_RE = re.compile(r"(?m)^\s*(?:回复|引用|转发)[::]\s*" FEISHU_QUOTE_LINE_RE = re.compile(r"(?m)^\s*(?:>+|【引用】|引用:|回复:).*$") FEISHU_NOISE_TOKEN_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_ENTER_SIGNAL = "auto_enter_signal" diff --git a/backend/app/services/mcp_config/catalog.py b/backend/app/services/mcp_config/catalog.py index 85eadeeb..2d8da855 100644 --- a/backend/app/services/mcp_config/catalog.py +++ b/backend/app/services/mcp_config/catalog.py @@ -40,6 +40,18 @@ def build_template_catalog() -> list[McpTemplateSpec]: command_hint="需要 PMFlow Local Bridge 或兼容 MCP host 在客户本机承载 stdio server。", 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( id="filesystem", name="Filesystem MCP", diff --git a/backend/app/services/mcp_config/runtime.py b/backend/app/services/mcp_config/runtime.py index be78089f..b4ea327b 100644 --- a/backend/app/services/mcp_config/runtime.py +++ b/backend/app/services/mcp_config/runtime.py @@ -1,6 +1,7 @@ from __future__ import annotations import json +import warnings from typing import Any, Callable import httpx @@ -147,3 +148,117 @@ class MicrosoftLearnMCPRuntime: if not data_lines: raise ValueError("MCP streamable_http response missing data payload") 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, + ) diff --git a/backend/app/services/mcp_config/schemas.py b/backend/app/services/mcp_config/schemas.py index e6e1bde9..d5310fe6 100644 --- a/backend/app/services/mcp_config/schemas.py +++ b/backend/app/services/mcp_config/schemas.py @@ -36,7 +36,7 @@ class McpTemplateSpec(BaseModel): id: str name: str description: str - transport: Literal["stdio", "streamable_http"] + transport: Literal["stdio", "streamable_http", "backend_adapter"] execution_mode: McpExecutionMode icon: str recommended: bool = False @@ -51,7 +51,7 @@ class McpServerConfigPayload(BaseModel): name: str display_name: str template_id: str - transport: Literal["stdio", "streamable_http"] + transport: Literal["stdio", "streamable_http", "backend_adapter"] execution_mode: McpExecutionMode = "local_bridge" params: dict[str, object] = Field(default_factory=dict) preview: McpCommandPreview diff --git a/backend/app/services/mcp_config/service.py b/backend/app/services/mcp_config/service.py index 5dacb4ba..914e19b1 100644 --- a/backend/app/services/mcp_config/service.py +++ b/backend/app/services/mcp_config/service.py @@ -11,7 +11,7 @@ from ...models import Integration from ...utils.timezone import now_cn_naive from ..external_query_service.schemas import ExternalEvidenceItem from .catalog import build_template_catalog -from .runtime import MicrosoftLearnMCPRuntime +from .runtime import DuckDuckGoSearchRuntime, MicrosoftLearnMCPRuntime from .schemas import McpCommandPreview, McpRuntimeProbe, McpServerConfigPayload, McpTemplateSpec INTEGRATION_TYPE = "mcp_template_center" @@ -189,18 +189,34 @@ class McpConfigService: return McpServerConfigPayload.model_validate(item).model_dump(mode="json") 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() if not query_text: 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: return [] template = self._get_template(str(server.get("template_id") or "")) - if template.id != "microsoft_learn": - return [] - runtime = MicrosoftLearnMCPRuntime() - return await runtime.search(query=query_text, result_limit=max(1, int(result_limit or 3))) + if template.id == "microsoft_learn": + runtime = MicrosoftLearnMCPRuntime() + 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]]: row = ( @@ -239,6 +255,31 @@ class McpConfigService: raise ValueError(f"未知 MCP 模板:{template_id}") 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]: normalized = dict(item or {}) template = self._get_template(str(normalized.get("template_id") or "")) @@ -298,6 +339,8 @@ class McpConfigService: raw = dict(params or {}) if template.id == "microsoft_learn": return {} + if template.id == "duckduckgo": + return {} if template.id == "github": for forbidden_key in ("access_token", "token", "personal_access_token", "pat"): if str(raw.get(forbidden_key) or "").strip(): @@ -330,6 +373,15 @@ class McpConfigService: verify_command="点击“重新校验”后,PMFlow 会从后端执行 MCP initialize 和 tools/list。", 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": env_name = str(params["token_env_var_name"]) return McpCommandPreview( @@ -366,6 +418,8 @@ class McpConfigService: _ = params if template.id == "microsoft_learn": return await MicrosoftLearnMCPRuntime().probe() + if template.id == "duckduckgo": + return await DuckDuckGoSearchRuntime().probe() return { "status": "error", "checked_at": now_cn_naive().isoformat(), diff --git a/backend/app/services/reply_iteration/service.py b/backend/app/services/reply_iteration/service.py index dbb21846..ce5d257c 100644 --- a/backend/app/services/reply_iteration/service.py +++ b/backend/app/services/reply_iteration/service.py @@ -1,6 +1,5 @@ from __future__ import annotations -import re from typing import Any, Optional from sqlalchemy import and_, select @@ -19,9 +18,6 @@ from ...models import ( from ...utils.timezone import now_cn_naive -PROCESS_TALK_PREFIXES = ("让我先", "我先", "我来看看", "我先看", "让我看") - - class ReplyIterationService: def __init__(self, db: AsyncSession): self.db = db @@ -527,10 +523,6 @@ class ReplyIterationService: @staticmethod def _build_rule_findings(asset: QualityAsset) -> 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": findings.append({"code": "tier_escalation", "severity": "info", "message": "存在 low -> high 升级"}) return findings diff --git a/backend/app/services/signal/signal_capture_service/mixin_capture_entry.py b/backend/app/services/signal/signal_capture_service/mixin_capture_entry.py index 1fe6ee71..1364df79 100644 --- a/backend/app/services/signal/signal_capture_service/mixin_capture_entry.py +++ b/backend/app/services/signal/signal_capture_service/mixin_capture_entry.py @@ -166,10 +166,10 @@ class SignalCaptureEntryMixin: cls, *, message_text: str, - ) -> tuple[bool, str]: + ) -> tuple[bool, str, Optional[Any]]: clean_text = str(message_text or "").strip() if not clean_text: - return False, "empty" + return False, "empty", None bundle = await WorkflowSemanticPlanner.aplan_message( message_text=clean_text, @@ -179,12 +179,12 @@ class SignalCaptureEntryMixin: diagnostics = dict(getattr(bundle, "diagnostics", {}) or {}) message_intent = str(diagnostics.get("message_intent") or "").strip().lower() if message_intent == "requirement_plan": - return True, "semantic:requirement_plan" + return True, "semantic:requirement_plan", bundle if workflow_types == ["query_flow"]: - return True, "semantic:query_flow" + return True, "semantic:query_flow", bundle if workflow_types == ["followup_flow"]: - return True, "semantic:followup_flow" - return False, "async_queue" + return True, "semantic:followup_flow", bundle + return False, "async_queue", None async def capture_from_feishu( self, @@ -306,8 +306,9 @@ class SignalCaptureEntryMixin: ) should_sync_process = bool(force_sync) sync_reason = "force_sync" if should_sync_process else "" + high_accuracy_bundle = None 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, ) @@ -378,10 +379,12 @@ class SignalCaptureEntryMixin: normalized_sync_reason = str(sync_reason or "").strip().lower() should_forward_semantic_bundle = bool(force_sync) or normalized_sync_reason.startswith("semantic:") if should_forward_semantic_bundle: - candidate_bundle = await WorkflowSemanticPlanner.aplan_message( - message_text=cleaned_message, - source_system="feishu", - ) + candidate_bundle = high_accuracy_bundle + if candidate_bundle is None: + candidate_bundle = await WorkflowSemanticPlanner.aplan_message( + message_text=cleaned_message, + source_system="feishu", + ) candidate_diagnostics = dict(getattr(candidate_bundle, "diagnostics", {}) or {}) candidate_message_intent = str(candidate_diagnostics.get("message_intent") or "").strip().lower() if ( diff --git a/backend/requirements.txt b/backend/requirements.txt index 7afbde01..37a8f850 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -24,3 +24,4 @@ pypdf>=4.3.1 python-docx>=1.1.2 openpyxl>=3.1.5 lark-oapi>=1.4.10 +duckduckgo-search>=8.1.1 diff --git a/backend/scripts/p1_real_validate.py b/backend/scripts/p1_real_validate.py index 2c27de67..12a0f885 100644 --- a/backend/scripts/p1_real_validate.py +++ b/backend/scripts/p1_real_validate.py @@ -23,9 +23,6 @@ CASES = [ {"idx": 5, "message": "帮我看看宠物行业最新动态", "expected_scene": "external_trend", "keys": ["宠物行业", "最新动态", "行业", "动态"]}, ] -BANNED_PREFIXES = ["让我先", "让我尝试", "我来看看", "我先查", "让我看看", "我需要先"] - - def login_token() -> str: data = urllib.parse.urlencode({"username": "admin", "password": "admin123"}).encode() req = urllib.request.Request( @@ -94,7 +91,7 @@ def has_thinking_leak(reply: str) -> bool: s = line.strip() if not s: continue - if any(s.startswith(prefix) for prefix in BANNED_PREFIXES): + if len(s) <= 12 and s.startswith("我") and "先" in s: return True return False diff --git a/backend/tests/test_adaptive_rag_external_followup_contract.py b/backend/tests/test_adaptive_rag_external_followup_contract.py index 0e0811e3..9e2bbe06 100644 --- a/backend/tests/test_adaptive_rag_external_followup_contract.py +++ b/backend/tests/test_adaptive_rag_external_followup_contract.py @@ -1,6 +1,7 @@ from __future__ import annotations from datetime import timedelta +from types import SimpleNamespace import pytest 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"] +@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 async def test_short_reply_to_explanation_is_treated_as_meta_followup(db_session) -> None: service = _Harness(db_session) diff --git a/backend/tests/test_adaptive_rag_mainline_contract.py b/backend/tests/test_adaptive_rag_mainline_contract.py index 511fb229..89dd0210 100644 --- a/backend/tests/test_adaptive_rag_mainline_contract.py +++ b/backend/tests/test_adaptive_rag_mainline_contract.py @@ -1,10 +1,11 @@ import pytest from types import SimpleNamespace -from app.config.ai_prompts import PROMPT_AI_PMO_QA +from app.config.ai_prompts import PROMPT_AI_PMO_QA, PROMPT_RAG_QUERY_SEMANTICS from app.services.adaptive_rag_service.mixin_answer_entry import AdaptiveRAGAnswerEntryMixin 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_answer_precheck import AdaptiveRAGAnswerPrecheckMixin from app.services.adaptive_rag_service.mixin_answer_retrieval import AdaptiveRAGAnswerRetrievalMixin from app.services.adaptive_rag_service.mixin_conversation_utils_a_p1 import AdaptiveRAGConversationUtilsAMixinP1 from app.services.adaptive_rag_service.mixin_deep_guard_answer import AdaptiveRAGDeepGuardAnswerMixin @@ -25,6 +26,7 @@ from app.services.adaptive_rag_service.mixin_followup_anchor import FollowupAnch from app.services.adaptive_rag_service.mixin_followup_anchor import AdaptiveRAGFollowupAnchorMixin 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.internal_query_context import InternalContextPacket from app.services.adaptive_rag_service.mixin_target_resolution import AdaptiveRAGTargetResolutionMixin from app.services.adaptive_rag_service.shared_part_02 import RetrievedItem from app.services.adaptive_rag_service.shared_part_02 import ACTION_MODE_READ, DEEP_RETRIEVAL_STEPS, DEEP_VECTOR_TOP_K @@ -266,6 +268,25 @@ def test_query_runtime_planner_bypasses_fast_path_when_consultant_mode_preferenc assert decision.allow_fast_path is False +def test_query_runtime_planner_ignores_legacy_consultant_mode_for_internal_existence_query() -> None: + class _FakePlannerService(AdaptiveRAGAnswerMainlineMixin): + pass + + service = _FakePlannerService() + decision = service._plan_query_runtime( + question="当前系统内是否有为采购经理提出的需求?", + effective_question="当前系统内是否有为采购经理提出的需求?", + query_type="existence", + requested_thinking_mode="normal", + enforce_scene_prompt_only=False, + consultant_mode_preference="analytical", + ) + + assert decision.answer_mode == "fast_path" + assert decision.thinking_mode == "normal" + assert decision.allow_fast_path is True + + def test_query_runtime_planner_prefers_fast_path_for_existence_question() -> None: class _FakePlannerService(AdaptiveRAGAnswerMainlineMixin): pass @@ -403,6 +424,50 @@ async def test_internal_target_resolution_prefers_project_candidate_for_existenc assert resolution["resolution_mode"] == "deterministic" +@pytest.mark.asyncio +async def test_internal_target_resolution_prefers_session_followup_project_binding_before_semantic_filter() -> None: + class _FakeService(AdaptiveRAGTargetResolutionMixin): + def __init__(self) -> None: + self.db = object() + self.tenant_id = 1 + + async def _collect_internal_target_candidates(self, **kwargs): + _ = kwargs + return [] + + service = _FakeService() + resolution = await service._resolve_internal_target( + question="围绕“采购经理每日实时库存上报服务”回答:那个需求是否存在系统?", + project_id=None, + query_semantics={ + "effective_source_policy": "internal", + "subject_focus": "project", + "query_profile": { + "needs_private_context": True, + "needs_freshness": False, + "referenced_objects": ["project_object"], + }, + "session_followup_context": { + "rewrite_mode": "state_subject", + "last_subject": "采购经理每日实时库存上报服务", + "last_entity_ids": [123], + }, + }, + system_snapshot={}, + operation_contract={ + "query_type": "existence", + "answer_contract": "yes_no_with_evidence", + "object_mentions": ["采购经理每日实时库存上报服务"], + }, + ) + + assert resolution["target_entity_type"] == "project" + assert resolution["target_ref"] == "project:123" + assert resolution["resolved_label"] == "采购经理每日实时库存上报服务" + assert resolution["resolution_mode"] == "deterministic" + assert resolution["binding_source"] == "session_followup_context" + + def test_config_target_candidates_require_structured_referenced_objects() -> None: class _FakeService(AdaptiveRAGTargetResolutionMixin): pass @@ -594,6 +659,41 @@ async def test_deterministic_query_runs_project_existence_for_existence_type() - assert calls == ["existence"] +@pytest.mark.asyncio +async def test_deterministic_query_defers_internal_project_existence_to_semantic_snapshot() -> None: + calls: list[str] = [] + + class _FakeDeterministicService(AdaptiveRAGDeterministicQueryCoreMixin): + def __init__(self) -> None: + self.db = object() + self.tenant_id = 1 + + async def _try_fast_project_existence_answer(self, *, clean_q: str, project_id, query_semantics=None): + calls.append("existence") + _ = (clean_q, project_id, query_semantics) + return {"answer": "有,匹配到 1 个项目。", "sources": []} + + service = _FakeDeterministicService() + result = await service._run_deterministic_query( + question="系统里有没有采购经理每日实时库存上报服务?", + project_id=None, + query_type="existence", + query_semantics={ + "subject_focus": "project", + "source_policy": "internal", + "effective_source_policy": "internal", + "operation_contract": { + "query_type": "existence", + "answer_contract": "yes_no_with_evidence", + "object_mentions": ["采购经理每日实时库存上报服务"], + }, + }, + ) + + assert result is None + assert calls == [] + + @pytest.mark.asyncio async def test_project_existence_answer_prefers_resolved_project_target() -> None: class _ScalarResult: @@ -956,6 +1056,157 @@ async def test_query_semantics_accepts_existence_query_type_from_structured_outp assert semantics["query_profile"]["needs_private_context"] is True +@pytest.mark.asyncio +async def test_query_semantics_reuses_cached_result_for_identical_request( + monkeypatch: pytest.MonkeyPatch, +) -> None: + planner_calls: list[dict[str, object]] = [] + llm_calls: list[str] = [] + + class _FakeLLM: + async def generate(self, prompt: str, **kwargs): + _ = kwargs + llm_calls.append(prompt) + return ( + '{"intent":"question","question_part":"系统里有没有采购经理每日实时库存上报服务?",' + '"statement_part":"","query_type":"existence","retrieval_domains":["db","pmflow"],' + '"object_mentions":["采购经理每日实时库存上报服务"],' + '"subject_focus":"project","scope_hint":"global","feature_focus":"none",' + '"source_policy":"internal","effective_source_policy":"internal",' + '"query_profile":{"needs_private_context":true,"needs_freshness":false,"referenced_objects":["project_object"],"confidence":0.93},' + '"confidence":0.96}' + ) + + class _FakeService(AdaptiveRAGCoreIntentMixin): + def __init__(self) -> None: + self.db = object() + self.tenant_id = 1 + self.llm = _FakeLLM() + + async def _fake_aplan_message(cls, *, message_text: str, source_system: str, metadata=None): + _ = cls + planner_calls.append( + { + "message_text": message_text, + "source_system": source_system, + "metadata": dict(metadata or {}), + } + ) + return WorkflowSemanticPlanner.deserialize_bundle( + { + "bundle_id": "semantic-query-cache", + "semantic_version": "v1", + "source_system": "adaptive_rag", + "primary_workflow": "query_flow", + "workflows": [ + { + "workflow_type": "query_flow", + "workflow_action": "answer", + "target_scope": "global", + "operation_codes": ["query.answer"], + "requires_confirmation": False, + "confidence": 0.94, + "clarification_needed": False, + "metadata": {}, + } + ], + "message_gate": "auto", + "reply_contract": {"mode": "text", "template_key": "workflow_bundle", "summary": "query_flow"}, + "diagnostics": {"message_intent": "question"}, + } + ) + + monkeypatch.setattr(WorkflowSemanticPlanner, "aplan_message", classmethod(_fake_aplan_message)) + + service = _FakeService() + first = await service._resolve_query_semantics( + message="系统里有没有采购经理每日实时库存上报服务?", + source_system="pmflow", + metadata={"project_id": 1}, + ) + second = await service._resolve_query_semantics( + message="系统里有没有采购经理每日实时库存上报服务?", + source_system="pmflow", + metadata={"project_id": 1}, + ) + + assert first == second + assert len(planner_calls) == 1 + assert len(llm_calls) == 1 + + +@pytest.mark.asyncio +async def test_query_semantics_prompt_contract_guides_internal_project_source_policy( + monkeypatch: pytest.MonkeyPatch, +) -> None: + class _FakeLLM: + async def generate(self, prompt: str, **kwargs): + _ = kwargs + if '"source_policy"' in prompt and '"effective_source_policy"' in prompt: + return ( + '{"intent":"question","question_part":"系统里有没有采购经理每日实时库存上报服务?",' + '"statement_part":"","query_type":"existence","retrieval_domains":["db","pmflow"],' + '"object_mentions":["采购经理每日实时库存上报服务"],' + '"subject_focus":"project","scope_hint":"global","feature_focus":"none",' + '"source_policy":"internal","effective_source_policy":"internal",' + '"query_profile":{"needs_private_context":true,"needs_freshness":false,"referenced_objects":["project_object"],"confidence":0.93},' + '"confidence":0.96}' + ) + return ( + '{"intent":"question","question_part":"系统里有没有采购经理每日实时库存上报服务?",' + '"statement_part":"","query_type":"existence","retrieval_domains":["db","pmflow"],' + '"object_mentions":["采购经理每日实时库存上报服务"],' + '"subject_focus":"project","scope_hint":"global","feature_focus":"none",' + '"query_profile":{"needs_private_context":true,"needs_freshness":false,"referenced_objects":["project_object"],"confidence":0.93},' + '"confidence":0.96}' + ) + + class _FakeService(AdaptiveRAGCoreIntentMixin): + def __init__(self) -> None: + self.db = object() + self.tenant_id = 1 + self.llm = _FakeLLM() + + async def _fake_aplan_message(cls, *, message_text: str, source_system: str, metadata=None): + _ = (cls, message_text, source_system, metadata) + return WorkflowSemanticPlanner.deserialize_bundle( + { + "bundle_id": "semantic-query-internal-project", + "semantic_version": "v1", + "source_system": "adaptive_rag", + "primary_workflow": "query_flow", + "workflows": [ + { + "workflow_type": "query_flow", + "workflow_action": "answer", + "target_scope": "global", + "operation_codes": ["query.answer"], + "requires_confirmation": False, + "confidence": 0.94, + "clarification_needed": False, + "metadata": {}, + } + ], + "message_gate": "auto", + "reply_contract": {"mode": "text", "template_key": "workflow_bundle", "summary": "query_flow"}, + "diagnostics": {"message_intent": "question"}, + } + ) + + monkeypatch.setattr(WorkflowSemanticPlanner, "aplan_message", classmethod(_fake_aplan_message)) + + service = _FakeService() + semantics = await service._resolve_query_semantics( + message="系统里有没有采购经理每日实时库存上报服务?", + source_system="pmflow", + ) + + assert '"source_policy"' in PROMPT_RAG_QUERY_SEMANTICS + assert '"effective_source_policy"' in PROMPT_RAG_QUERY_SEMANTICS + assert semantics["source_policy"] == "internal" + assert semantics["effective_source_policy"] == "internal" + + def test_feature_catalog_match_skips_lexical_query_term_fallback() -> None: class _FakeService(AdaptiveRAGManifestFeatureMixinP1): @staticmethod @@ -1034,6 +1285,32 @@ def test_internal_answer_contract_verifier_blocks_total_count_substitution_for_e assert verdict["reason_code"] == "no_total_count_substitution" +def test_internal_answer_contract_verifier_allows_project_snapshot_with_task_totals_for_existence() -> None: + class _FakeVerifier(AdaptiveRAGAnswerPayloadMixin, AdaptiveRAGQueryRouteGatewayMixin): + pass + + service = _FakeVerifier() + verdict = service._verify_internal_answer_contract( + query_type="existence", + answer=( + "有。项目ID 123,项目名“采购经理每日实时库存上报服务”,状态 active,负责人 系统管理员," + "最近更新 2026-04-13T14:27:59.301650,任务总数 4,已完成 0,进行中 0,待处理 4。" + ), + sources=[ + { + "source": "db", + "label": "Project database", + "score": 0.99, + "metadata": {"table": "projects", "matched_project_ids": [123]}, + } + ], + route_decision={"route_mode": "internal", "effective_source_policy": "internal"}, + ) + + assert verdict["ok"] is True + assert verdict["reason_code"] is None + + def test_internal_answer_contract_verifier_allows_evidence_backed_existence_answer() -> None: class _FakeVerifier(AdaptiveRAGAnswerPayloadMixin, AdaptiveRAGQueryRouteGatewayMixin): pass @@ -1284,6 +1561,231 @@ async def test_deep_guard_answer_uses_low_first_routing_meta_for_deep_thinking() assert routing_meta["routing_decision"] == "force_low_first" +@pytest.mark.asyncio +async def test_deep_guard_answer_external_route_uses_external_presenter_without_internal_context() -> None: + class _FakePresenter: + def __init__(self) -> None: + self.calls: list[dict[str, object]] = [] + + async def present(self, *, question: str, context_packet: dict, thinking_mode: str, answer_max_tokens=None): + self.calls.append( + { + "question": question, + "context_packet": dict(context_packet), + "thinking_mode": thinking_mode, + "answer_max_tokens": answer_max_tokens, + } + ) + return { + "answer_text": "只基于外部证据生成的回答", + "prompt_variant_id": "external_summary_with_sources", + } + + class _FakeService(AdaptiveRAGDeepGuardAnswerMixin): + def __init__(self) -> None: + self.db = object() + self.tenant_id = 1 + self.llm = object() + self.presenter = _FakePresenter() + self.prompt_variant = "unknown" + + def _normalize_thinking_mode(self, thinking_mode: str) -> str: + _ = thinking_mode + return "normal" + + async def get_project_manifest(self, project_id): # pragma: no cover - should not be called + raise AssertionError(f"manifest should be skipped for external route: {project_id}") + + async def _resolve_answer_scene_semantics(self, **kwargs): # pragma: no cover - should not be called + raise AssertionError(f"scene semantics should be skipped for external route: {kwargs}") + + async def _select_scene_prompt(self, **kwargs): # pragma: no cover - should not be called + raise AssertionError(f"scene prompt should be skipped for external route: {kwargs}") + + def _build_external_answer_presenter(self): + return self.presenter + + def _remember_answer_prompt_variant(self, prompt_variant_id: str) -> None: + self.prompt_variant = prompt_variant_id + + def _enforce_feature_evidence_in_answer(self, **kwargs): + return str(kwargs.get("answer_text") or "") + + service = _FakeService() + + result = await service._generate_answer( + question="最近 AI 编程模型有什么新动态?", + retrieved=[], + conversation=[], + project_id=7, + asked_via="feishu", + query_semantics={"effective_source_policy": "external"}, + external_context_packet={ + "question": "最近 AI 编程模型有什么新动态?", + "plan": { + "answer_contract": "summary_with_sources", + "time_range_hint": "最近", + }, + "adapter_attempts": [{"adapter": "live_search", "status": "success"}], + "verified_evidence": [ + { + "source_type": "url", + "source_label": "OpenAI Developers", + "title": "Models - Codex | OpenAI Developers", + "target_url": "https://developers.openai.com/codex/models", + "snippet": "Latest: GPT-5.4", + } + ], + "verifier": {"ok": True}, + "uncertainties": [], + }, + ) + + assert result == "只基于外部证据生成的回答" + assert service.prompt_variant == "external_summary_with_sources" + assert len(service.presenter.calls) == 1 + assert service.presenter.calls[0]["question"] == "最近 AI 编程模型有什么新动态?" + + +@pytest.mark.asyncio +async def test_retrieval_response_external_insufficient_evidence_keeps_external_clarify_contract() -> None: + class _FakeDB: + def add(self, obj) -> None: + obj.id = 1 + + async def flush(self) -> None: + return None + + async def commit(self) -> None: + return None + + async def refresh(self, obj) -> None: + _ = obj + return None + + class _FakeContextCache: + async def append_session_messages(self, **kwargs) -> None: + _ = kwargs + return None + + class _FakeService( + AdaptiveRAGAnswerRetrievalMixin, + AdaptiveRAGAnswerPayloadMixin, + AdaptiveRAGQueryRouteGatewayMixin, + AdaptiveRAGDeepGuardRulesMixin, + ): + def __init__(self) -> None: + self.db = _FakeDB() + self.context_cache = _FakeContextCache() + self.tenant_id = 1 + + def _normalize_thinking_mode(self, thinking_mode: str) -> str: + return "deep" if str(thinking_mode or "").strip().lower() == "deep" else "normal" + + async def _execute_external_query_gateway(self, **kwargs): + _ = kwargs + return [], { + "mode": "external_query_gateway_verifier_blocked", + "external_context_packet": { + "question": "OpenAI 最近发布了哪些新模型?", + "plan": { + "answer_contract": "summary_with_sources", + }, + "adapter_attempts": [{"adapter": "live_search", "status": "empty"}], + "verified_evidence": [], + "verifier": { + "ok": False, + "reason_code": "insufficient_evidence", + "missing_fields": ["external_evidence"], + }, + "uncertainties": ["insufficient_evidence"], + }, + "tool_trace": [], + "domains_used": ["url"], + } + + async def _generate_answer(self, *args, **kwargs): + _ = (args, kwargs) + return "当前关于“OpenAI 最近发布了哪些新模型?”的外部证据不足。请补充更明确的对象、范围或继续提供可核验来源后,我再继续分析。" + + def _peek_answer_prompt_variant(self): + return "external_clarify" + + def _peek_last_answer_routing_snapshot(self): + return {} + + def _dedupe_sources(self, retrieved): + _ = retrieved + return [] + + def _build_qa_record_runtime_fields(self, **kwargs): + return { + "policy_version": "test", + "citations": [], + "sources_used": list(kwargs.get("sources") or []), + "evidence_pack": {}, + "tool_trace": list(kwargs.get("tool_trace") or []), + } + + def _build_qa_record_external_fields(self, **kwargs): + _ = kwargs + return {} + + async def _update_session_state_after_turn(self, **kwargs): + _ = kwargs + return None + + def _build_response_payload(self, **kwargs): + return kwargs + + service = _FakeService() + result = await service._build_retrieval_response( + clean_q="OpenAI 最近发布了哪些新模型?", + effective_q="OpenAI 最近发布了哪些新模型?", + asked_by=1, + asked_via="feishu", + project_id=None, + source_chat_id=None, + session_id=11, + turn_index=2, + context_session_id=11, + conversation=[], + state_row=None, + rewrite_meta={"mode": "direct"}, + query_type="explain", + query_semantics={"effective_source_policy": "external"}, + retrieval_domains=["url"], + scene_semantics={"scene_key": "default", "deep_intent": "none"}, + thinking_mode="normal", + active_thinking_mode="normal", + deep_thinking=False, + forced_deep_reason="", + deep_retrieval_steps=2, + deep_vector_top_k=3, + system_snapshot={}, + user_snapshot=None, + started=0.0, + message_intent=None, + mixed_signal_part=None, + answer_mode="retrieval_normal", + consultant_mode_preference=None, + route_decision={ + "route_mode": "external", + "effective_source_policy": "external", + "reply_contract": {"head": "外部实时查询:", "tail_hint": None}, + }, + ) + + assert result["answer_mode"] == "clarify" + assert result["grounding_status"] == "insufficient_evidence" + assert result["sources"] == [] + assert result["sources_used"] == [] + assert "系统快照" not in result["answer"] + assert "外部证据不足" in result["answer"] + assert result["answer"].startswith("外部实时查询:") + assert result["missing_fields"] == ["external_evidence"] + + @pytest.mark.asyncio async def test_deep_guard_answer_prefers_member_workload_direct_answer() -> None: class _FakeResult: @@ -2840,6 +3342,420 @@ async def test_query_route_gateway_keeps_uncertain_contract_as_clarify_without_e assert decision["probe_snapshot"]["contract"]["source_policy"] == "uncertain" +@pytest.mark.asyncio +async def test_query_route_clarification_response_stores_pending_route_protocol() -> None: + class _FakeDB: + def add(self, obj) -> None: + obj.id = 1 + + async def flush(self) -> None: + return None + + async def commit(self) -> None: + return None + + async def refresh(self, obj) -> None: + _ = obj + return None + + class _FakeContextCache: + async def append_session_messages(self, **kwargs) -> None: + _ = kwargs + return None + + class _FakeService( + AdaptiveRAGAnswerPrecheckMixin, + AdaptiveRAGQueryRouteGatewayMixin, + AdaptiveRAGFollowupAnchorMixin, + ): + def __init__(self) -> None: + self.db = _FakeDB() + self.tenant_id = 1 + self.context_cache = _FakeContextCache() + + def _to_naive_utc(self, value): + return value + + def _build_qa_record_runtime_fields(self, **kwargs): + _ = kwargs + return { + "policy_version": "test", + "citations": [], + "sources_used": [], + "evidence_pack": {}, + "tool_trace": [], + } + + def _build_qa_record_external_fields(self, **kwargs): + _ = kwargs + return {} + + def _build_response_payload(self, **kwargs): + return kwargs + + async def _update_session_state_after_turn(self, **kwargs): + _ = kwargs + return None + + service = _FakeService() + state = SimpleNamespace( + last_subject=None, + last_entity_ids=[], + last_intent=None, + ambiguity="unknown", + rewrite_history=[], + pending_clarification=None, + pending_clarification_expires_at=None, + ) + + result = await service._maybe_return_clarification_response( + clarification_payload={ + "answer": "你是想问外部公开事实,还是系统内部配置/项目对象?", + "code": "query_route_clarify", + "candidates": [], + "missing_fields": ["query_route"], + }, + started=0.0, + clean_q="当前系统内是否有为采购经理提出的需求?", + effective_q="当前系统内是否有为采购经理提出的需求?", + query_type="existence", + asked_by=1, + asked_via="feishu", + project_id=None, + session_id=11, + turn_index=2, + state_row=state, + rewrite_meta={"mode": "direct"}, + context_session_id=11, + active_thinking_mode="normal", + route_decision={ + "route_mode": "uncertain", + "effective_source_policy": "uncertain", + "reply_contract": { + "mode": "clarify", + "clarification_text": "你是想问外部公开事实,还是系统内部配置/项目对象?", + }, + }, + ) + + assert result["clarification_code"] == "query_route_clarify" + assert state.pending_clarification["kind"] == "query_route_selection" + assert state.pending_clarification["original_question"] == "当前系统内是否有为采购经理提出的需求?" + assert state.pending_clarification["option_map"] == {"A": "external", "B": "internal"} + assert state.pending_clarification["options"] == [ + {"key": "A", "label": "外部公开事实"}, + {"key": "B", "label": "系统内部配置/项目对象"}, + ] + + +def test_query_route_pending_clarification_consumes_protocol_option_label() -> None: + class _FakeService(AdaptiveRAGFollowupAnchorMixin): + def _to_naive_utc(self, value): + return value + + service = _FakeService() + state = SimpleNamespace( + pending_clarification={ + "kind": "query_route_selection", + "original_question": "当前系统内是否有为采购经理提出的需求?", + "attempts": 1, + "option_map": {"A": "external", "B": "internal"}, + "options": [ + {"key": "A", "label": "外部公开事实"}, + {"key": "B", "label": "系统内部配置/项目对象"}, + ], + }, + pending_clarification_expires_at=None, + ) + + consumed = service._consume_pending_clarification_selection( + question="内部配置", + session_state=state, + ) + + assert consumed == { + "effective_question": "当前系统内是否有为采购经理提出的需求?", + "source_policy_hint": "internal", + } + assert state.pending_clarification is None + + +@pytest.mark.asyncio +async def test_prepare_answer_runtime_passes_consumed_query_route_hint_to_preview() -> None: + class _FakeService(AdaptiveRAGAnswerMainlineMixin, AdaptiveRAGFollowupAnchorMixin): + def __init__(self) -> None: + self.preview_kwargs: dict[str, object] = {} + self.state_row = SimpleNamespace( + last_subject=None, + last_entity_ids=[], + last_intent=None, + ambiguity="unknown", + rewrite_history=[], + pending_clarification={ + "kind": "query_route_selection", + "original_question": "当前系统内是否有为采购经理提出的需求?", + "attempts": 1, + "option_map": {"A": "external", "B": "internal"}, + "options": [ + {"key": "A", "label": "外部公开事实"}, + {"key": "B", "label": "系统内部配置/项目对象"}, + ], + }, + pending_clarification_expires_at=None, + ) + + def _to_naive_utc(self, value): + return value + + def _build_external_conversation_key(self, **kwargs): + _ = kwargs + return "scope:feishu:test" + + def _set_active_external_qa_context(self, **kwargs): + _ = kwargs + + async def _touch_session_for_new_record(self, **kwargs): + _ = kwargs + return 11, 2 + + def _external_session_id_from_key(self, key: str): + _ = key + return 11 + + async def _load_recent_conversation(self, **kwargs): + _ = kwargs + return [] + + async def _ensure_session_state(self, **kwargs): + _ = kwargs + return self.state_row + + async def _resolve_followup_anchor(self, **kwargs): + _ = kwargs + return FollowupAnchorResolution() + + def _rewrite_question_with_state(self, **kwargs): + return kwargs["question"], {"mode": "direct"} + + async def preview_query_route_decision(self, **kwargs): + self.preview_kwargs = dict(kwargs) + return { + "query_semantics": { + "source_policy": "internal", + "effective_source_policy": "internal", + "query_type": "existence", + "retrieval_domains": ["db", "pmflow"], + "query_profile": { + "needs_private_context": True, + "needs_freshness": False, + "referenced_objects": ["system_config"], + }, + }, + "route_decision": { + "route_mode": "internal", + "effective_source_policy": "internal", + "decision_origin": "upstream_hint", + "reply_contract": { + "mode": "explicit_source", + "head": "内部知识库查询:", + "tail_hint": None, + }, + "query_profile": { + "needs_private_context": True, + "needs_freshness": False, + "referenced_objects": ["system_config"], + }, + "probe_snapshot_summary": {}, + "probe_snapshot": {}, + }, + } + + async def _resolve_answer_scene_semantics(self, **kwargs): + _ = kwargs + return {"scene_key": "ai_pmo", "deep_intent": "none"} + + def _normalize_action_mode(self, value: str) -> str: + return str(value or "").strip().lower() or "auto" + + async def _resolve_action_mode_async(self, **kwargs): + _ = kwargs + return ACTION_MODE_READ + + service = _FakeService() + runtime = await service._prepare_answer_runtime( + clean_q="内部配置", + asked_by=1, + asked_via="feishu", + project_id=None, + source_chat_id="oc_test_chat", + session_id=11, + conversation_key=None, + force_new_session=False, + action_mode="auto", + ) + + assert runtime["resolved_question"] == "当前系统内是否有为采购经理提出的需求?" + assert service.preview_kwargs["question"] == "当前系统内是否有为采购经理提出的需求?" + assert service.preview_kwargs["source_policy_hint"] == "internal" + assert service.state_row.pending_clarification is None + + +@pytest.mark.asyncio +async def test_prepare_answer_runtime_recomputes_route_after_consuming_query_route_selection() -> None: + class _FakeService( + AdaptiveRAGAnswerMainlineMixin, + AdaptiveRAGFollowupAnchorMixin, + AdaptiveRAGQueryRouteGatewayMixin, + ): + def __init__(self) -> None: + self.preview_kwargs: dict[str, object] = {} + self.state_row = SimpleNamespace( + last_subject=None, + last_entity_ids=[], + last_intent=None, + ambiguity="unknown", + rewrite_history=[], + pending_clarification={ + "kind": "query_route_selection", + "original_question": "当前系统内是否有为采购经理提出的需求?", + "attempts": 1, + "option_map": {"A": "external", "B": "internal"}, + "options": [ + {"key": "A", "label": "外部公开事实"}, + {"key": "B", "label": "系统内部配置/项目对象"}, + ], + }, + pending_clarification_expires_at=None, + ) + + def _to_naive_utc(self, value): + return value + + def _build_external_conversation_key(self, **kwargs): + _ = kwargs + return "scope:feishu:test" + + def _set_active_external_qa_context(self, **kwargs): + _ = kwargs + + async def _touch_session_for_new_record(self, **kwargs): + _ = kwargs + return 11, 2 + + def _external_session_id_from_key(self, key: str): + _ = key + return 11 + + async def _load_recent_conversation(self, **kwargs): + _ = kwargs + return [] + + async def _ensure_session_state(self, **kwargs): + _ = kwargs + return self.state_row + + async def _resolve_followup_anchor(self, **kwargs): + _ = kwargs + return FollowupAnchorResolution() + + def _rewrite_question_with_state(self, **kwargs): + return kwargs["question"], {"mode": "direct"} + + async def preview_query_route_decision(self, **kwargs): + self.preview_kwargs = dict(kwargs) + return { + "query_semantics": { + "source_policy": "internal", + "effective_source_policy": "internal", + "query_type": "existence", + "retrieval_domains": ["db", "pmflow"], + "query_profile": { + "needs_private_context": True, + "needs_freshness": False, + "referenced_objects": ["project_object"], + }, + }, + "route_decision": { + "route_mode": "internal", + "effective_source_policy": "internal", + "decision_origin": "upstream_hint", + "reply_contract": { + "mode": "explicit_source", + "head": "内部知识库查询:", + "tail_hint": None, + }, + "query_profile": { + "needs_private_context": True, + "needs_freshness": False, + "referenced_objects": ["project_object"], + }, + "probe_snapshot_summary": {}, + "probe_snapshot": {}, + }, + } + + async def _resolve_answer_scene_semantics(self, **kwargs): + _ = kwargs + return {"scene_key": "ai_pmo", "deep_intent": "none"} + + def _normalize_action_mode(self, value: str) -> str: + return str(value or "").strip().lower() or "auto" + + async def _resolve_action_mode_async(self, **kwargs): + _ = kwargs + return ACTION_MODE_READ + + service = _FakeService() + runtime = await service._prepare_answer_runtime( + clean_q="内部配置", + asked_by=1, + asked_via="feishu", + project_id=None, + source_chat_id="oc_test_chat", + session_id=11, + conversation_key=None, + force_new_session=False, + action_mode="auto", + precomputed_query_semantics={ + "source_policy": "uncertain", + "effective_source_policy": "uncertain", + "query_type": "explain", + "retrieval_domains": ["db", "pmflow", "doc"], + "query_profile": { + "needs_private_context": True, + "needs_freshness": False, + "referenced_objects": ["system_config"], + "confidence": 0.9, + }, + }, + route_decision={ + "route_mode": "uncertain", + "effective_source_policy": "uncertain", + "decision_origin": "query_semantics_contract", + "reply_contract": { + "mode": "clarify", + "clarification_text": "你是想问外部公开事实,还是系统内部配置/项目对象?", + }, + "query_profile": { + "needs_private_context": True, + "needs_freshness": False, + "referenced_objects": ["system_config"], + "confidence": 0.9, + }, + "probe_snapshot_summary": {}, + "probe_snapshot": {}, + }, + ) + + assert runtime["resolved_question"] == "当前系统内是否有为采购经理提出的需求?" + assert service.preview_kwargs["question"] == "当前系统内是否有为采购经理提出的需求?" + assert service.preview_kwargs["source_policy_hint"] == "internal" + assert service.preview_kwargs["query_semantics"] == {} + assert runtime["query_type"] == "existence" + assert runtime["route_decision"]["route_mode"] == "internal" + assert service.state_row.pending_clarification is None + + @pytest.mark.asyncio async def test_render_answer_with_route_contract_adds_explicit_source_prefix_and_summary() -> None: class _FakeService(AdaptiveRAGQueryRouteGatewayMixin): @@ -2888,6 +3804,175 @@ async def test_meta_followup_without_real_anchor_does_not_return_no_route_record assert payload is None +@pytest.mark.asyncio +async def test_execute_mainline_skips_followup_clarification_after_state_subject_rewrite() -> None: + class _FakeContextCache: + async def get_system_snapshot(self, **kwargs): + _ = kwargs + return {} + + async def get_user_snapshot(self, **kwargs): + _ = kwargs + return {} + + class _FakeService( + AdaptiveRAGAnswerMainlineMixin, + AdaptiveRAGFollowupAnchorMixin, + ): + def __init__(self) -> None: + self.db = object() + self.tenant_id = 1 + self.context_cache = _FakeContextCache() + self.clarification_payload = None + self.retrieval_kwargs: dict[str, object] = {} + + def _plan_query_runtime(self, **kwargs): + _ = kwargs + return SimpleNamespace( + thinking_mode="normal", + retrieval_answer_mode="retrieval_normal", + budget=SimpleNamespace(max_steps=2, vector_top_k=3), + allow_fast_path=False, + reason="consultant_mode", + ) + + def _build_route_clarification_payload(self, **kwargs): + _ = kwargs + return None + + async def _build_meta_followup_payload(self, **kwargs): + _ = kwargs + return None + + async def _maybe_return_clarification_response(self, **kwargs): + self.clarification_payload = kwargs.get("clarification_payload") + if self.clarification_payload is not None: + return { + "processing_stage": "clarification", + "answer": self.clarification_payload.get("answer"), + } + return None + + async def _maybe_return_meta_followup_response(self, **kwargs): + _ = kwargs + return None + + def _get_active_external_qa_context(self): + return {} + + def _build_operation_contract(self, *, question: str, query_semantics): + _ = (question, query_semantics) + return { + "query_type": "existence", + "answer_contract": "yes_no_with_evidence", + } + + async def _resolve_internal_target(self, **kwargs): + _ = kwargs + return {} + + def _mirror_internal_context_to_query_semantics(self, *, query_semantics, **kwargs): + _ = kwargs + return dict(query_semantics) + + def _build_internal_query_plan(self, **kwargs): + _ = kwargs + return {} + + def _build_internal_query_context_packet(self, **kwargs): + _ = kwargs + return None + + async def _maybe_return_internal_context_precheck_response(self, **kwargs): + _ = kwargs + return None + + async def _build_internal_model_config_fast_answer(self, **kwargs): + _ = kwargs + return None + + def _build_snapshot_direct_answer(self, *args, **kwargs): + _ = (args, kwargs) + return None + + async def _maybe_return_snapshot_direct_response(self, **kwargs): + _ = kwargs + return None + + async def _try_glm_project_semantic_answer(self, **kwargs): + _ = kwargs + return None + + async def _build_retrieval_response(self, **kwargs): + self.retrieval_kwargs = dict(kwargs) + return { + "processing_stage": "retrieval", + "answer": "内部知识库查询:当前系统内已存在采购经理提出的需求。", + } + + service = _FakeService() + rewritten_question = "围绕“采购经理每日实时库存上报服务”回答:帮我看下采购经理那个需求是否存在系统?" + + result = await service._execute_base_answer_mainline( + clean_q="帮我看下采购经理那个需求是否存在系统?", + asked_by=1, + asked_via="feishu", + project_id=None, + source_chat_id="chat-1", + session_id=11, + turn_index=2, + context_session_id=11, + conversation=[], + state_row=SimpleNamespace( + last_subject="采购经理每日实时库存上报服务", + last_entity_ids=[123], + last_intent="question", + ambiguity="clear", + rewrite_history=[], + pending_clarification=None, + pending_clarification_expires_at=None, + ), + effective_q=rewritten_question, + rewrite_meta={"mode": "state_subject", "subject": "采购经理每日实时库存上报服务"}, + query_type="existence", + query_semantics={ + "source_policy": "internal", + "effective_source_policy": "internal", + "query_profile": { + "needs_private_context": True, + "needs_freshness": False, + "referenced_objects": ["project_object"], + }, + }, + retrieval_domains=["db", "pmflow"], + scene_semantics={"scene_key": "ai_pmo", "snapshot_focus": "none", "deep_intent": "none"}, + thinking_mode="normal", + active_thinking_mode="normal", + deep_thinking=False, + enforce_scene_prompt_only=False, + deep_retrieval_steps=2, + deep_vector_top_k=3, + started=0.0, + message_intent="question", + mixed_signal_part=None, + anchor_resolution=FollowupAnchorResolution( + followup_mode="referential", + anchor_mode="no_anchor", + clarification_code="no_anchor_found", + ), + consultant_mode_preference=None, + route_decision={ + "route_mode": "internal", + "effective_source_policy": "internal", + "reply_contract": {"head": "内部知识库查询:", "tail_hint": None}, + }, + ) + + assert result["processing_stage"] == "retrieval" + assert service.clarification_payload is None + assert service.retrieval_kwargs["effective_q"] == rewritten_question + + @pytest.mark.asyncio async def test_internal_model_config_fast_answer_bypasses_fast_path_gate_when_route_is_internal() -> None: class _FakeContextCache: @@ -3019,3 +4104,332 @@ async def test_internal_model_config_fast_answer_bypasses_fast_path_gate_when_ro assert "qwen-plus" in result["answer"] assert service.builder_calls == 1 assert service.retrieval_called is False + + +@pytest.mark.asyncio +async def test_execute_mainline_blocks_internal_route_when_internal_context_packet_missing() -> None: + class _FakeContextCache: + async def get_system_snapshot(self, **kwargs): + _ = kwargs + return {} + + async def get_user_snapshot(self, **kwargs): + _ = kwargs + return {} + + class _FakeService( + AdaptiveRAGAnswerMainlineMixin, + AdaptiveRAGAnswerPayloadMixin, + ): + def __init__(self) -> None: + self.db = object() + self.tenant_id = 1 + self.context_cache = _FakeContextCache() + self.persist_kwargs: dict[str, object] = {} + + def _plan_query_runtime(self, **kwargs): + _ = kwargs + return SimpleNamespace( + thinking_mode="normal", + retrieval_answer_mode="retrieval_normal", + budget=SimpleNamespace(max_steps=2, vector_top_k=3), + allow_fast_path=True, + reason="deterministic_query_type", + ) + + def _build_followup_clarification_payload(self, **kwargs): + _ = kwargs + return None + + def _build_route_clarification_payload(self, **kwargs): + _ = kwargs + return None + + async def _build_meta_followup_payload(self, **kwargs): + _ = kwargs + return None + + async def _maybe_return_clarification_response(self, **kwargs): + _ = kwargs + return None + + async def _maybe_return_meta_followup_response(self, **kwargs): + _ = kwargs + return None + + def _get_active_external_qa_context(self): + return {} + + def _build_operation_contract(self, *, question: str, query_semantics): + _ = (question, query_semantics) + return { + "query_type": "existence", + "answer_contract": "yes_no_with_evidence", + "object_mentions": ["开票系统"], + } + + async def _resolve_internal_target(self, **kwargs): + _ = kwargs + return { + "target_entity_type": "project", + "target_ref": "project:6", + "resolved_label": "自动开票系统", + "resolution_mode": "deterministic", + "confidence": 0.96, + "candidate_summary": [], + } + + def _build_internal_query_context_packet(self, **kwargs): + _ = kwargs + return None + + async def _persist_fast_answer(self, **kwargs): + self.persist_kwargs = dict(kwargs) + return { + "processing_stage": "fast_path", + "answer": kwargs["fast_result"]["answer"], + "answer_mode": kwargs["answer_mode"], + "missing_fields": list(kwargs["fast_result"].get("missing_fields") or []), + } + + async def _run_deterministic_query(self, **kwargs): # pragma: no cover - should not be called + raise AssertionError(f"deterministic query should be blocked: {kwargs}") + + async def _build_internal_model_config_fast_answer(self, **kwargs): # pragma: no cover - should not be called + raise AssertionError(f"internal model config fast answer should be blocked: {kwargs}") + + async def _try_glm_project_semantic_answer(self, **kwargs): # pragma: no cover - should not be called + raise AssertionError(f"glm semantic fast answer should be blocked: {kwargs}") + + async def _build_retrieval_response(self, **kwargs): # pragma: no cover - should not be called + raise AssertionError(f"retrieval should be blocked: {kwargs}") + + async def _maybe_return_snapshot_direct_response(self, **kwargs): + _ = kwargs + return None + + def _build_snapshot_direct_answer(self, *args, **kwargs): + _ = (args, kwargs) + return None + + service = _FakeService() + result = await service._execute_base_answer_mainline( + clean_q="帮我看看系统里面有没有开票系统。", + asked_by=1, + asked_via="pmflow_manual", + project_id=None, + source_chat_id=None, + session_id=11, + turn_index=3, + context_session_id=11, + conversation=[], + state_row=None, + effective_q="帮我看看系统里面有没有开票系统。", + rewrite_meta={}, + query_type="explain", + query_semantics={ + "source_policy": "internal", + "effective_source_policy": "internal", + "subject_focus": "project", + }, + retrieval_domains=["db", "pmflow"], + scene_semantics={"scene_key": "ai_pmo", "snapshot_focus": "none", "deep_intent": "none"}, + thinking_mode="normal", + active_thinking_mode="normal", + deep_thinking=False, + enforce_scene_prompt_only=False, + deep_retrieval_steps=2, + deep_vector_top_k=3, + started=0.0, + message_intent="question", + mixed_signal_part=None, + consultant_mode_preference=None, + route_decision={ + "route_mode": "internal", + "effective_source_policy": "internal", + "reply_contract": {"head": "内部知识库查询:", "tail_hint": None}, + }, + ) + + assert result["processing_stage"] == "fast_path" + assert result["answer_mode"] == "clarify" + assert service.persist_kwargs["fast_result"]["reason_code"] == "missing_internal_context_packet" + assert service.persist_kwargs["fast_result"]["missing_fields"] == ["internal_context_packet"] + + +@pytest.mark.asyncio +async def test_execute_mainline_blocks_internal_route_when_query_plan_contract_missing() -> None: + class _FakeContextCache: + async def get_system_snapshot(self, **kwargs): + _ = kwargs + return {} + + async def get_user_snapshot(self, **kwargs): + _ = kwargs + return {} + + class _FakeService( + AdaptiveRAGAnswerMainlineMixin, + AdaptiveRAGAnswerPayloadMixin, + ): + def __init__(self) -> None: + self.db = object() + self.tenant_id = 1 + self.context_cache = _FakeContextCache() + self.persist_kwargs: dict[str, object] = {} + + def _plan_query_runtime(self, **kwargs): + _ = kwargs + return SimpleNamespace( + thinking_mode="normal", + retrieval_answer_mode="retrieval_normal", + budget=SimpleNamespace(max_steps=2, vector_top_k=3), + allow_fast_path=True, + reason="deterministic_query_type", + ) + + def _build_followup_clarification_payload(self, **kwargs): + _ = kwargs + return None + + def _build_route_clarification_payload(self, **kwargs): + _ = kwargs + return None + + async def _build_meta_followup_payload(self, **kwargs): + _ = kwargs + return None + + async def _maybe_return_clarification_response(self, **kwargs): + _ = kwargs + return None + + async def _maybe_return_meta_followup_response(self, **kwargs): + _ = kwargs + return None + + def _get_active_external_qa_context(self): + return {} + + def _build_operation_contract(self, *, question: str, query_semantics): + _ = (question, query_semantics) + return { + "query_type": "existence", + "answer_contract": "yes_no_with_evidence", + "object_mentions": ["开票系统"], + } + + async def _resolve_internal_target(self, **kwargs): + _ = kwargs + return { + "target_entity_type": "project", + "target_ref": "project:6", + "resolved_label": "自动开票系统", + "resolution_mode": "deterministic", + "confidence": 0.96, + "candidate_summary": [], + } + + def _build_internal_query_context_packet(self, **kwargs): + payload = dict(kwargs) + return InternalContextPacket.model_validate( + { + "packet_version": "v1", + "question": str(payload.get("question") or ""), + "scope": { + "tenant_id": 1, + "project_id": None, + "session_id": 11, + "conversation_scope_key": None, + "reply_to_message_id": None, + "root_message_id": None, + }, + "route_decision": dict(payload.get("route_decision") or {}), + "operation_contract": dict(payload.get("operation_contract") or {}), + "target_resolution": dict(payload.get("target_resolution") or {}), + "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}], + }, + "executor_summary": {}, + "verifier_summary": {}, + "uncertainties": [], + } + ) + + async def _persist_fast_answer(self, **kwargs): + self.persist_kwargs = dict(kwargs) + return { + "processing_stage": "fast_path", + "answer": kwargs["fast_result"]["answer"], + "answer_mode": kwargs["answer_mode"], + "missing_fields": list(kwargs["fast_result"].get("missing_fields") or []), + } + + async def _run_deterministic_query(self, **kwargs): # pragma: no cover - should not be called + raise AssertionError(f"deterministic query should be blocked: {kwargs}") + + async def _build_internal_model_config_fast_answer(self, **kwargs): # pragma: no cover - should not be called + raise AssertionError(f"internal model config fast answer should be blocked: {kwargs}") + + async def _try_glm_project_semantic_answer(self, **kwargs): # pragma: no cover - should not be called + raise AssertionError(f"glm semantic fast answer should be blocked: {kwargs}") + + async def _build_retrieval_response(self, **kwargs): # pragma: no cover - should not be called + raise AssertionError(f"retrieval should be blocked: {kwargs}") + + async def _maybe_return_snapshot_direct_response(self, **kwargs): + _ = kwargs + return None + + def _build_snapshot_direct_answer(self, *args, **kwargs): + _ = (args, kwargs) + return None + + service = _FakeService() + result = await service._execute_base_answer_mainline( + clean_q="帮我看看系统里面有没有开票系统。", + asked_by=1, + asked_via="pmflow_manual", + project_id=None, + source_chat_id=None, + session_id=11, + turn_index=3, + context_session_id=11, + conversation=[], + state_row=None, + effective_q="帮我看看系统里面有没有开票系统。", + rewrite_meta={}, + query_type="explain", + query_semantics={ + "source_policy": "internal", + "effective_source_policy": "internal", + "subject_focus": "project", + }, + retrieval_domains=["db", "pmflow"], + scene_semantics={"scene_key": "ai_pmo", "snapshot_focus": "none", "deep_intent": "none"}, + thinking_mode="normal", + active_thinking_mode="normal", + deep_thinking=False, + enforce_scene_prompt_only=False, + deep_retrieval_steps=2, + deep_vector_top_k=3, + started=0.0, + message_intent="question", + mixed_signal_part=None, + consultant_mode_preference=None, + route_decision={ + "route_mode": "internal", + "effective_source_policy": "internal", + "reply_contract": {"head": "内部知识库查询:", "tail_hint": None}, + }, + ) + + assert result["processing_stage"] == "fast_path" + assert result["answer_mode"] == "clarify" + assert service.persist_kwargs["fast_result"]["reason_code"] == "missing_internal_query_plan_contract" + assert service.persist_kwargs["fast_result"]["missing_fields"] == ["answer_contract"] diff --git a/backend/tests/test_ai_pmo_ingress_mainline.py b/backend/tests/test_ai_pmo_ingress_mainline.py index b27de851..eee344d1 100644 --- a/backend/tests/test_ai_pmo_ingress_mainline.py +++ b/backend/tests/test_ai_pmo_ingress_mainline.py @@ -37,8 +37,13 @@ from app.services.ai_pmo.ai_pmo_intent import CanonicalIntent from app.services.ai_pmo.workflow_semantic_planner import WorkflowSemanticPlanner from app.services.ai_pmo.workflow_semantic_planner.models import ReplyContract, WorkflowBundle, WorkflowPlan from app.services.ai_pmo.draft_confirm_handler import DraftConfirmHandler +from app.services.adaptive_rag_service import AdaptiveRAGService +from app.services.adaptive_rag_service.mixin_conversation_utils_a_p1 import ( + AdaptiveRAGConversationUtilsAMixinP1, +) from app.services.adaptive_rag_service import mixin_deterministic_query_core from app.services.adaptive_rag_service import mixin_fast_answer_project_main +from app.services.adaptive_rag_service.shared_part_02 import RetrievedItem from app.services.mounted_ai_runtime_write_service import MountedAIRuntimeWriteService from app.services.integrations.feishu_integration_service import mixin_reply_outbox as feishu_mixin_reply_outbox from app.services.integrations.feishu_outbox.service import FeishuOutboxService @@ -53,8 +58,10 @@ from app.models.mounted_ai_runtime import ( MountedDraftWorkspace, MountedTopicWorkspace, ) +from app.models.qa_feedback import QAFeedback from app.models.qa_record import QARecord from app.models.qa_session import QASession +from app.models.qa_session_state import QASessionState from app.models.work_item import WorkItem from app.utils.thread_context import build_feishu_conversation_scope_key, build_feishu_thread_key @@ -117,7 +124,7 @@ async def requirement_plan_db_session(): def _use_sqlite_external_qa_json_types() -> None: - for table in (QARecord.__table__,): + for table in (QARecord.__table__, QASessionState.__table__, QAFeedback.__table__): for column in table.columns: if column.name in { "sources", @@ -128,6 +135,10 @@ def _use_sqlite_external_qa_json_types() -> None: "evidence_pack", "tool_trace", "retrieval_metrics", + "last_entity_ids", + "rewrite_history", + "pending_clarification", + "error_types", }: column.type = JSON() @@ -143,6 +154,8 @@ async def external_qa_db_session(): tables=[ QASession.__table__, QARecord.__table__, + QAFeedback.__table__, + QASessionState.__table__, ], ) ) @@ -861,6 +874,50 @@ async def test_prepare_mixed_sidecar_context_passes_mixed_contract(monkeypatch) assert answer_kwargs["conversation_key"] == "manual-msg-2" +@pytest.mark.asyncio +async def test_prepare_mixed_sidecar_context_reuses_non_mixed_semantic_bundle_without_classify( + monkeypatch, +) -> None: + class FakeAdaptiveRAGService: + def __init__(self, db, tenant_id: int): + _ = (db, tenant_id) + + async def classify_intent(self, text: str): + raise AssertionError(f"classify_intent should not run for non-mixed semantic bundle: {text}") + + class FakeKernel(mixin_entry.KernelIngressFlowEntryMixin): + def __init__(self) -> None: + self.db = object() + self.tenant_id = 9 + + monkeypatch.setattr(mixin_entry, "AdaptiveRAGService", FakeAdaptiveRAGService) + kernel = FakeKernel() + clean_text, mixed_meta = await kernel._prepare_mixed_sidecar_context( + source_system="feishu", + source_message_id="msg-query-1", + channel_ref="chat-1", + source_sender_open_id="ou-1", + thread_context=None, + message_text="采购经理每日实时库存上报服务这个需求是否存在系统?", + project_hint_id=None, + semantic_bundle={ + "primary_workflow": "query_flow", + "workflows": [ + { + "workflow_type": "query_flow", + "workflow_action": "answer", + } + ], + "diagnostics": { + "message_intent": "system_existence_inquiry", + }, + }, + ) + + assert clean_text == "采购经理每日实时库存上报服务这个需求是否存在系统?" + assert mixed_meta == {} + + @pytest.mark.asyncio async def test_process_ingress_canonicalizes_thread_context_before_prepare_envelope(monkeypatch) -> None: captured: dict[str, object] = {} @@ -2027,15 +2084,13 @@ async def test_process_ingress_records_semantic_shadow_before_legacy_finalize(mo ) assert result["status"] == "ignored" - assert [name for name, _ in captured["calls"]] == [ - "begin", - "terminate_scope", - "semantic_shadow", - "record", - "agent_trace", - "finalize", - ] - semantic_kwargs = captured["calls"][2][1] + call_names = [name for name, _ in captured["calls"]] + assert call_names[0] == "begin" + assert "semantic_shadow" in call_names + assert "record" in call_names + assert "finalize" in call_names + assert call_names.index("semantic_shadow") < call_names.index("record") < call_names.index("finalize") + semantic_kwargs = next(kwargs for name, kwargs in captured["calls"] if name == "semantic_shadow") assert semantic_kwargs["semantic_packet_summary"]["packet_version"] == "v1" assert semantic_kwargs["semantic_packet_summary"]["topic_key"] == "feishu:chat-shadow:root:om-root-shadow" assert semantic_kwargs["semantic_decision"]["mode_decision"] == "stay_plan" @@ -2043,6 +2098,230 @@ async def test_process_ingress_records_semantic_shadow_before_legacy_finalize(mo assert semantic_kwargs["semantic_diff"]["legacy_route"] == "query.answer" +@pytest.mark.asyncio +async def test_process_ingress_semantic_shadow_reuses_upstream_bundle_without_replanning( + monkeypatch, +) -> None: + from app.services.ai_pmo.semantic_context.decision_schema import ModeCard + from app.services.ai_pmo.semantic_context.packet_schema import ( + SemanticContextPacket, + SemanticContextThread, + SemanticContextTopic, + ) + + captured: dict[str, object] = {"calls": []} + + class FakeMountedAIRuntimeWriteService: + def __init__(self, db) -> None: + captured["db"] = db + + async def terminate_requirement_plan_scope(self, **kwargs): + captured["calls"].append(("terminate_scope", kwargs)) + return { + "terminated_count": 0, + "terminated_topic_ids": [], + "terminated_work_item_ids": [], + "conversation_scope_key": None, + } + + async def begin_ingress_run(self, **kwargs): + captured["calls"].append(("begin", kwargs)) + return SimpleNamespace( + topic_workspace=SimpleNamespace(id=291, tenant_id=1, plan_status="idle"), + conversation_run=SimpleNamespace(id=292, source_message_id="s-msg-semantic-shadow-compat"), + context_layers_hit={"current_message": True, "thread": False, "topic": False}, + ) + + async def record_semantic_shadow(self, **kwargs): + captured["calls"].append(("semantic_shadow", kwargs)) + + async def record_orchestrator_decision(self, **kwargs): + captured["calls"].append(("record", kwargs)) + return "chat" + + async def record_agent_trace(self, **kwargs): + captured["calls"].append(("agent_trace", kwargs)) + + async def finalize_run(self, **kwargs): + captured["calls"].append(("finalize", kwargs)) + + class FakeSemanticContextAssembler: + def __init__(self, db) -> None: + captured["semantic_db"] = db + + async def assemble(self, **kwargs): + captured["semantic_packet_kwargs"] = kwargs + return SemanticContextPacket( + tenant_id=1, + operator_user_id=8, + topic_workspace_id=291, + latest_user_message=str(kwargs["latest_user_message"]), + topic=SemanticContextTopic( + topic_workspace_id=291, + topic_key="feishu:chat-shadow:root:om-root-shadow-compat", + source_system="feishu", + channel_ref="chat-shadow", + conversation_scope_key="feishu:chat-shadow:ou-shadow", + thread_key="thread-shadow", + mode="chat", + status="active", + plan_status="idle", + facts=None, + goals=None, + constraints=None, + metadata={"source": "test"}, + ), + thread=SemanticContextThread( + thread_key="thread-shadow", + conversation_scope_key="feishu:chat-shadow:ou-shadow", + thread_summary="查询项目是否存在", + recent_turns=[], + open_questions=[], + ), + mode_card=ModeCard( + workflow_mode="CHAT", + mode_state="IDLE", + allowed_actions=["reply.compose", "clarify.ask"], + forbidden_actions=["project.create", "db.write"], + exit_conditions=[], + ), + ) + + class FakeSemanticDecisionService: + def __init__(self, _llm_service) -> None: + raise AssertionError("semantic shadow should reuse upstream bundle instead of replanning") + + class FakeKernel(mixin_entry.KernelIngressFlowEntryMixin): + def __init__(self) -> None: + self.db = object() + self.tenant_id = 1 + self.context_assembler = SimpleNamespace(assemble_world_model=self._assemble_world_model) + self.route_resolver = SimpleNamespace(from_canonical=self._route_from_canonical) + self.decision_engine = SimpleNamespace(decide=lambda **_: {"decision": "unused"}) + + async def _assemble_world_model(self, **kwargs): + _ = kwargs + return None, {"capacity": [], "skill_graph": [], "resolved_project_id": None} + + def _route_from_canonical(self, **kwargs): + _ = kwargs + return SimpleNamespace( + key="query.answer", + family="query", + action="answer", + requested_count=1, + requires_confirmation=False, + project_id=None, + metadata={}, + ) + + async def _prepare_ingress_envelope(self, **kwargs): + _ = kwargs + return { + "early_result": None, + "clean_text": "继续确认这个需求是否存在系统。", + "synthetic_message_id": "s-msg-semantic-shadow-compat", + "closure_payload": {}, + "ingest_event": SimpleNamespace(id=300), + "operator": SimpleNamespace(id=8), + "work_item": SimpleNamespace(id=65, state=None, context_snapshot=None, intent=None, project_id=None), + "session": SimpleNamespace(last_intent="query.answer"), + "session_key": "session-shadow-compat", + } + + def _merge_ingress_thread_context(self, **kwargs): + _ = kwargs + return None + + def _should_route_to_draft_confirmation(self, **kwargs): + _ = kwargs + return False + + def _is_short_confirmation_message(self, **kwargs): + _ = kwargs + return False + + async def _prepare_mixed_sidecar_context(self, **kwargs): + return kwargs["message_text"], {} + + async def _resolve_intent(self, **kwargs): + semantic_bundle = kwargs.get("semantic_bundle_override") or {} + return CanonicalIntent( + name=mixin_entry.INTENT_IGNORE, + confidence=0.91, + project_id=None, + signal_title="", + signal_summary="", + metadata={ + "message_intent": "system_existence_inquiry", + "semantic_bundle": semantic_bundle, + }, + ), "workflow_bundle" + + def _coerce_intent_for_query_scene(self, **kwargs): + return kwargs["intent"] + + def _enrich_signal_source_intent_from_closure(self, **kwargs): + return kwargs["intent"] + + async def _finalize_ignored_ingress(self, **kwargs): + _ = kwargs + return { + "handled": True, + "status": "ignored", + "classification": "ignored", + "direct_reply_required": False, + "direct_reply_text": "这轮先按旧链路忽略处理。", + } + + def _schedule_portfolio_snapshot_persist(self, **kwargs): + _ = kwargs + return None + + monkeypatch.setattr(mixin_entry, "MountedAIRuntimeWriteService", FakeMountedAIRuntimeWriteService) + monkeypatch.setattr(mixin_entry, "SemanticContextAssembler", FakeSemanticContextAssembler) + monkeypatch.setattr(mixin_entry, "SemanticDecisionService", FakeSemanticDecisionService) + + kernel = FakeKernel() + result = await kernel.process_ingress( + source_system="feishu", + source_message_id="feishu-shadow-compat-1", + message_text="继续确认这个需求是否存在系统。", + sender="tester", + channel_ref="chat-shadow", + source_sender_open_id="ou-shadow", + ingress_options={ + "semantic_bundle": { + "bundle_id": "bundle-shadow-compat", + "primary_workflow": "query_flow", + "workflows": [ + { + "workflow_type": "query_flow", + "workflow_action": "answer", + "target_scope": "global", + "operation_codes": ["query.answer"], + "requires_confirmation": False, + "confidence": 0.95, + "metadata": { + "message_intent": "system_existence_inquiry", + }, + } + ], + "diagnostics": { + "message_intent": "system_existence_inquiry", + }, + } + }, + ) + + assert result["status"] == "ignored" + semantic_kwargs = next(kwargs for name, kwargs in captured["calls"] if name == "semantic_shadow") + assert semantic_kwargs["semantic_decision"]["mode_decision"] == "answer_only" + assert semantic_kwargs["semantic_decision"]["uncertainties"] == ["shadow_reused_upstream_bundle"] + assert semantic_kwargs["semantic_gate_result"]["accepted"] is True + assert semantic_kwargs["semantic_diff"]["legacy_message_intent"] == "system_existence_inquiry" + + def test_should_route_to_draft_confirmation_requires_anchor_or_explicit_id() -> None: session = SimpleNamespace(last_intent=mixin_entry.STATE_DRAFT_PENDING_CONFIRM) @@ -2078,7 +2357,7 @@ def test_should_route_to_draft_confirmation_requires_anchor_or_explicit_id() -> assert mixin_entry.KernelIngressFlowEntryMixin._should_route_to_draft_confirmation( session=session, - message_text="工作项ID: 88 创建时间是多少?", + message_text="#88 创建时间是多少?", ingress_options=None, ) is True @@ -3713,6 +3992,53 @@ def test_serialize_work_item_summary_handles_message_text_without_nameerror() -> assert payload["source_message_text"] == "最近 状态 怎么样" +def test_serialize_work_item_summary_exposes_plan_mode_summary_fields() -> None: + row = SimpleNamespace( + id=19, + source_system="pmflow_manual", + source_message_id="msg-plan-19", + draft_content={}, + state="draft_pending_confirm", + review_summary={}, + action_bundle={}, + intent="query.answer", + project_id=52, + signal_id=None, + reply_template_key="requirement_plan_followup", + execution_result={ + "plan_mode": True, + "plan_status": "collecting", + "requirement_plan_context": { + "title": "换季搭配推荐", + "goal": "先验证用户是否愿意采用推荐搭配。", + "missing_info": ["衣橱标签", "首批试用人群", "反馈采集方式"], + }, + }, + context_snapshot={ + "plan_mode": True, + "plan_status": "collecting", + }, + draft_expires_at=None, + draft_version=2, + confirm_message_id="om-plan-confirm-19", + created_at=None, + updated_at=None, + decision_level="project", + ) + ingest = SimpleNamespace(payload={"message_text": "帮我继续收这个需求"}) + + payload = helpers_inbox_payload._serialize_work_item_summary( + row=row, + ingest=ingest, + project=None, + ) + + assert payload["plan_mode"] is True + assert payload["plan_status"] == "collecting" + assert payload["requirement_plan_context_summary"]["title"] == "换季搭配推荐" + assert payload["requirement_plan_context_summary"]["missing_info"] == ["衣橱标签", "首批试用人群", "反馈采集方式"] + + def test_coerce_rag_business_review_scene_keeps_rag_ask_on_query_chain() -> None: class FakeKernel(mixin_entry.KernelIngressFlowEntryMixin): def __init__(self) -> None: @@ -4127,6 +4453,64 @@ async def test_kernel_intent_resolve_degrades_when_semantic_planner_fails( assert intent.signal_title == "" +@pytest.mark.asyncio +async def test_kernel_intent_resolve_reuses_upstream_semantic_bundle_without_replanning( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr( + WorkflowSemanticPlanner, + "aplan_message", + classmethod(lambda cls, **kwargs: (_ for _ in ()).throw(AssertionError("semantic planner should not run"))), + ) + + class FakeResolver(KernelIntentResolveMixin, KernelIntentRulesMixin): + def __init__(self) -> None: + self.tenant_id = 1 + + resolver = FakeResolver() + bundle = WorkflowBundle( + bundle_id="bundle-upstream-query", + semantic_version="v1", + source_system="feishu", + primary_workflow="query_flow", + workflows=[ + WorkflowPlan( + workflow_type="query_flow", + workflow_action="answer", + target_scope="global", + operation_codes=["query.answer"], + requires_confirmation=False, + confidence=0.93, + metadata={}, + ) + ], + message_gate="auto", + reply_contract=ReplyContract(mode="text", template_key="workflow_bundle", summary="query_flow"), + diagnostics={"message_intent": "business_review"}, + ) + session = SimpleNamespace( + sender="feishu:chat-1:ou_dm_1", + last_intent="query.read_only", + current_project_id=21, + last_active_at=datetime.now(), + ) + + intent, source = await resolver._resolve_intent( + message_text="我需要对应的需求mvp", + session=session, + world_model={}, + thread_context=None, + semantic_bundle_override=bundle.to_dict(), + ) + + assert source == "workflow_bundle" + assert intent.name == "query.read_only" + assert intent.metadata["workflow_route"] == "query.answer" + assert intent.metadata["context_source"] == "workflow_bundle" + assert intent.metadata["message_intent"] == "business_review" + assert intent.metadata["semantic_bundle"]["bundle_id"] == "bundle-upstream-query" + + def test_recover_session_followup_write_intent_skips_query_parent_even_if_previous_text_looks_like_write() -> None: class FakeResolver(KernelIntentRulesMixin, KernelIntentResolveMixin): def __init__(self) -> None: @@ -11776,6 +12160,97 @@ async def test_external_freshness_followup_stays_in_query_answer_without_interna assert "已收到需求" not in result["direct_reply_text"] +@pytest.mark.asyncio +async def test_qa_session_detail_and_state_replay_expose_rich_runtime_fields(external_qa_db_session) -> None: + session = QASession( + tenant_id=1, + user_id=101, + title="Alpha 外部问答", + project_id=88, + message_count=1, + ) + external_qa_db_session.add(session) + await external_qa_db_session.flush() + external_qa_db_session.add( + QARecord( + tenant_id=1, + question="Alpha 项目最近有外部公开动态吗?", + answer="外部实时查询:目前证据不足,建议先明确要查融资还是招聘动态。", + sources=[], + intent="question", + domains_used=["news"], + response_time_ms=42, + asked_by=101, + asked_via="pmflow", + project_id=88, + session_id=int(session.id), + turn_index=1, + routing_snapshot={ + "query_route": { + "route_mode": "external", + "effective_source_policy": "external", + "decision_origin": "freshness_guard", + } + }, + answer_mode="clarify", + grounding_status="insufficient_evidence", + policy_version="retrieval-v2.1|routing:v3|prompt:industry_trend", + citations=[{"label": "外部趋势守卫"}], + sources_used=[{"display_label": "外部实时查询"}], + evidence_pack={ + "summary": { + "conclusion": "需要进一步澄清外部目标。", + "evidence": [], + "uncertainties": ["未明确要查的外部主题"], + }, + "tool_trace": [{"tool": "external_query_gateway"}], + }, + ) + ) + external_qa_db_session.add( + QASessionState( + tenant_id=1, + session_id=int(session.id), + last_subject="Alpha项目", + last_entity_ids=[88], + last_intent="question", + ambiguity="needs_clarification", + rewrite_history=[ + { + "question": "Alpha 项目最近有外部公开动态吗?", + "effective_question": "围绕“Alpha项目”回答:Alpha 项目最近有外部公开动态吗?", + "query_type": "referential", + } + ], + pending_clarification={ + "kind": "query_route_selection", + "option_map": {"A": "external", "B": "internal"}, + "options": [ + {"key": "A", "label": "外部公开事实"}, + {"key": "B", "label": "系统内部对象"}, + ], + }, + pending_clarification_expires_at=datetime.now() + timedelta(minutes=5), + ) + ) + await external_qa_db_session.commit() + + service = AdaptiveRAGService(external_qa_db_session, tenant_id=1) + detail = await service.get_qa_session_detail(session_id=int(session.id), user_id=101) + + assert detail["messages"][0]["route_decision"]["effective_source_policy"] == "external" + assert detail["messages"][0]["answer_mode"] == "clarify" + assert detail["messages"][0]["grounding_status"] == "insufficient_evidence" + assert detail["messages"][0]["policy_version"] == "retrieval-v2.1|routing:v3|prompt:industry_trend" + assert detail["messages"][0]["evidence_pack"]["summary"]["conclusion"] == "需要进一步澄清外部目标。" + + state = await service.get_session_state_replay(session_id=int(session.id), user_id=101) + + assert state["last_subject"] == "Alpha项目" + assert state["pending_clarification"]["kind"] == "query_route_selection" + assert state["rewrite_history"][0]["query_type"] == "referential" + + @pytest.mark.asyncio async def test_external_freshness_followup_allows_explicit_internal_source_request( external_qa_db_session, @@ -12021,6 +12496,152 @@ async def test_process_ingress_short_circuits_external_freshness_followup_before assert "已收到需求" not in result["direct_reply_text"] +@pytest.mark.asyncio +async def test_process_ingress_short_circuits_query_route_pending_clarification_followup( + external_qa_db_session, + monkeypatch, +) -> None: + conversation_scope_key = build_feishu_conversation_scope_key( + source_chat_id="chat-query-route-clarify", + source_sender_open_id="ou-query-route-clarify", + sender=None, + ) + session = QASession( + tenant_id=1, + user_id=None, + title="内部外部澄清", + external_conversation_key=conversation_scope_key, + message_count=1, + ) + external_qa_db_session.add(session) + await external_qa_db_session.flush() + external_qa_db_session.add( + QASessionState( + tenant_id=1, + session_id=int(session.id), + ambiguity="needs_clarification", + pending_clarification={ + "kind": "query_route_selection", + "original_question": "当前系统内是否有为采购经理提出的需求?", + "option_map": {"A": "external", "B": "internal"}, + "options": [ + {"key": "A", "label": "外部公开事实"}, + {"key": "B", "label": "系统内部配置/项目对象"}, + ], + }, + pending_clarification_expires_at=datetime.now() + timedelta(minutes=5), + ) + ) + await external_qa_db_session.commit() + + captured: dict[str, object] = {} + + class FakeAdaptiveRAGService: + def __init__(self, db, tenant_id: int): + captured["tenant_id"] = tenant_id + captured["db"] = db + + async def answer_question(self, **kwargs): + captured["answer_kwargs"] = kwargs + return { + "answer": "内部知识库查询:当前系统内已存在采购经理提出的需求。", + "grounding_status": "grounded", + "policy_version": "retrieval-v2.1|routing:v3|prompt:internal_knowledge", + } + + class FakeMountedAIRuntimeWriteService: + def __init__(self, db) -> None: + _ = db + + async def canonicalize_thread_context(self, **kwargs): + _ = kwargs + return kwargs.get("thread_context"), {} + + async def begin_ingress_run(self, **kwargs): + _ = kwargs + return None + + async def terminate_requirement_plan_scope(self, **kwargs): + _ = kwargs + return { + "terminated_count": 0, + "terminated_topic_ids": [], + "terminated_work_item_ids": [], + "conversation_scope_key": None, + } + + class FakeKernel(mixin_entry.KernelIngressFlowEntryMixin): + def __init__(self) -> None: + self.db = external_qa_db_session + self.tenant_id = 1 + self.context_assembler = SimpleNamespace( + assemble_world_model=AsyncMock(side_effect=AssertionError("should short-circuit before world model")) + ) + + async def _prepare_ingress_envelope(self, **kwargs): + _ = kwargs + return { + "early_result": None, + "clean_text": "内部配置", + "synthetic_message_id": "om-query-route-followup-1", + "closure_payload": {}, + "ingest_event": SimpleNamespace(id=401, status="pending"), + "operator": SimpleNamespace(id=8), + "work_item": SimpleNamespace(id=402), + "session": SimpleNamespace(last_intent="query.answer", current_project_id=None), + } + + def _merge_ingress_thread_context(self, **kwargs): + _ = kwargs + return None + + async def _maybe_handle_active_context_followup(self, **kwargs): + _ = kwargs + return None + + async def _should_route_to_draft_confirmation_session(self, **kwargs): + _ = kwargs + return False + + async def _maybe_reset_session_project_context_for_new_requirement(self, **kwargs): + _ = kwargs + return False + + async def _maybe_handle_orphan_requirement_followup(self, **kwargs): + _ = kwargs + return None + + async def _prepare_mixed_sidecar_context(self, **kwargs): + raise AssertionError("pending clarification followup should not enter mixed sidecar") + + monkeypatch.setattr(mixin_entry, "AdaptiveRAGService", FakeAdaptiveRAGService) + monkeypatch.setattr(mixin_entry, "MountedAIRuntimeWriteService", FakeMountedAIRuntimeWriteService) + + kernel = FakeKernel() + result = await kernel.process_ingress( + source_system="feishu", + source_message_id="om-query-route-followup-1", + message_text="内部配置", + sender="林宇航", + channel_ref="chat-query-route-clarify", + source_sender_open_id="ou-query-route-clarify", + ) + + assert result["handled"] is True + assert result["workflow_route"] == "query.answer" + assert "内部知识库查询" in result["direct_reply_text"] + assert captured["tenant_id"] == 1 + assert captured["answer_kwargs"]["question"] == "内部配置" + assert captured["answer_kwargs"]["session_id"] == int(session.id) + assert captured["answer_kwargs"]["conversation_key"] == conversation_scope_key + + +def test_query_pending_clarification_expiry_uses_same_naive_cn_rule_as_adaptive_rag() -> None: + expires_at = datetime.now() - timedelta(minutes=1) + + assert mixin_entry.KernelIngressFlowEntryMixin._is_query_pending_clarification_expired(expires_at) is True + + @pytest.mark.asyncio async def test_should_route_to_draft_confirmation_session_does_not_route_regenerate_request_without_anchor( monkeypatch, @@ -12155,7 +12776,15 @@ async def test_execute_project_update_intent_resolves_string_project_id_without_ @pytest.mark.asyncio async def test_fast_project_progress_answer_handles_risk_blocker_questions() -> None: - project = SimpleNamespace(id=104, title="跨境直播项目", name="跨境直播项目", status="active") + project = SimpleNamespace( + id=104, + title="跨境直播项目", + name="跨境直播项目", + status="active", + owner_id=None, + updated_at=datetime(2026, 4, 5, 12, 0), + description="跨境直播业务推进和竞品动作跟踪。", + ) tasks = [ SimpleNamespace(status="todo", updated_at=datetime(2026, 4, 5, 10, 0), created_at=datetime(2026, 4, 5, 9, 0)), SimpleNamespace(status="in_progress", updated_at=datetime(2026, 4, 5, 11, 0), created_at=datetime(2026, 4, 5, 8, 0)), @@ -12163,91 +12792,291 @@ async def test_fast_project_progress_answer_handles_risk_blocker_questions() -> ] class FakeScalarResult: - def __init__(self, rows): - self._rows = rows + def __init__(self, *, scalar=None, rows=None) -> None: + self._scalar = scalar + self._rows = list(rows or []) + + def scalar_one_or_none(self): + return self._scalar def scalars(self): return SimpleNamespace(all=lambda: list(self._rows)) + def all(self): + return list(self._rows) + class FakeDB: async def execute(self, stmt): - _ = stmt - return FakeScalarResult(tasks) + sql = str(stmt).lower() + if "from projects" in sql: + return FakeScalarResult(scalar=project) + if "from tasks" in sql: + return FakeScalarResult(rows=tasks) + if "from signals" in sql: + return FakeScalarResult(rows=[]) + if "from users" in sql: + return FakeScalarResult(scalar=None) + raise AssertionError(sql) - class FakeRAG(mixin_fast_answer_project_main.AdaptiveRAGFastAnswerProjectMainMixin): + class FakeRAG( + mixin_fast_answer_project_main.AdaptiveRAGFastAnswerProjectMainMixin, + AdaptiveRAGConversationUtilsAMixinP1, + ): def __init__(self) -> None: self.db = FakeDB() self.tenant_id = 1 - @staticmethod - def _extract_project_lookup_keyword(question: str) -> str: - _ = question - return "跨境直播" + async def _resolve_project_scope_for_question(self, **kwargs): + _ = kwargs + return (104, "跨境直播项目", "跨境直播") - @staticmethod - def _extract_query_terms(question: str, preferred: str): - _ = question - return [preferred] - - async def _search_projects_by_keyword(self, *, keyword: str, project_id, limit: int): - _ = (keyword, project_id, limit) - return [project] + async def _execute_external_query_gateway(self, **kwargs): + _ = kwargs + return ([], {"mode": "external_query_gateway_empty"}) rag = FakeRAG() result = await rag._try_fast_project_progress_answer( clean_q="帮我看看跨境直播项目最近有哪些风险和阻塞", project_id=None, + query_semantics={"subject_focus": "project", "status_focus": "risk_or_blocker"}, ) assert result is not None - assert "风险/阻塞台账字段" in str(result["answer"] or "") - assert "待处理任务数 1" in str(result["answer"] or "") + answer = str(result["answer"] or "") + assert "项目 #104《跨境直播项目》" in answer + assert "项目目前处于:执行中但存在阻塞" in answer + assert "任务进度:共 3 项,已完成 1,进行中 1,待处理 1" in answer + assert "负责人需要拍板:确认当前阻塞点属于范围问题、资源问题还是依赖问题。" in answer @pytest.mark.asyncio async def test_fast_project_progress_answer_handles_risk_blocker_questions_without_tasks() -> None: - project = SimpleNamespace(id=101, title="AI客服", name="AI客服", status="active") + project = SimpleNamespace( + id=101, + title="AI客服", + name="AI客服", + status="active", + owner_id=None, + updated_at=datetime(2026, 4, 6, 12, 0), + description="AI 客服工具推进。", + ) class FakeScalarResult: - def __init__(self, rows): - self._rows = rows + def __init__(self, *, scalar=None, rows=None) -> None: + self._scalar = scalar + self._rows = list(rows or []) + + def scalar_one_or_none(self): + return self._scalar def scalars(self): return SimpleNamespace(all=lambda: list(self._rows)) + def all(self): + return list(self._rows) + class FakeDB: async def execute(self, stmt): - _ = stmt - return FakeScalarResult([]) + sql = str(stmt).lower() + if "from projects" in sql: + return FakeScalarResult(scalar=project) + if "from tasks" in sql: + return FakeScalarResult(rows=[]) + if "from signals" in sql: + return FakeScalarResult(rows=[]) + if "from users" in sql: + return FakeScalarResult(scalar=None) + raise AssertionError(sql) - class FakeRAG(mixin_fast_answer_project_main.AdaptiveRAGFastAnswerProjectMainMixin): + class FakeRAG( + mixin_fast_answer_project_main.AdaptiveRAGFastAnswerProjectMainMixin, + AdaptiveRAGConversationUtilsAMixinP1, + ): def __init__(self) -> None: self.db = FakeDB() self.tenant_id = 1 - @staticmethod - def _extract_project_lookup_keyword(question: str) -> str: - _ = question - return "AI客服" + async def _resolve_project_scope_for_question(self, **kwargs): + _ = kwargs + return (101, "AI客服", "AI客服") - @staticmethod - def _extract_query_terms(question: str, preferred: str): - _ = question - return [preferred] - - async def _search_projects_by_keyword(self, *, keyword: str, project_id, limit: int): - _ = (keyword, project_id, limit) - return [project] + async def _execute_external_query_gateway(self, **kwargs): + _ = kwargs + return ([], {"mode": "external_query_gateway_empty"}) rag = FakeRAG() result = await rag._try_fast_project_progress_answer( clean_q="帮我看看 AI 客服工具项目最近有哪些风险和阻塞", project_id=None, + query_semantics={"subject_focus": "project", "status_focus": "risk_or_blocker"}, ) assert result is not None - assert "风险/阻塞台账字段" in str(result["answer"] or "") - assert "没有可用的任务拆解记录" in str(result["answer"] or "") + answer = str(result["answer"] or "") + assert "项目 #101《AI客服》" in answer + assert "项目目前处于:立项后待拆解阶段" in answer + assert "当前最主要的卡点:项目还没有形成任务拆解和执行节奏,这也是当前最直接的阻塞信号。" in answer + + +@pytest.mark.asyncio +async def test_fast_project_progress_answer_renders_project_weekly_report_for_status_questions( + monkeypatch, +) -> None: + project = SimpleNamespace( + id=62, + title="关于直播同行竞品分析", + name="关于直播同行竞品分析", + status="active", + owner_id=9, + updated_at=datetime(2026, 3, 11, 20, 15, 47), + description="关注直播电商规则变化、同行竞品动作和平台流量机制。", + ) + owner = SimpleNamespace(name="系统管理员") + tasks = [ + SimpleNamespace(status="待处理", updated_at=datetime(2026, 3, 12, 9, 0), created_at=datetime(2026, 3, 12, 8, 0)), + SimpleNamespace(status="待处理", updated_at=datetime(2026, 3, 12, 10, 0), created_at=datetime(2026, 3, 12, 8, 30)), + SimpleNamespace(status="待处理", updated_at=datetime(2026, 3, 12, 11, 0), created_at=datetime(2026, 3, 12, 9, 0)), + SimpleNamespace(status="待处理", updated_at=datetime(2026, 3, 12, 12, 0), created_at=datetime(2026, 3, 12, 9, 30)), + SimpleNamespace(status="待处理", updated_at=datetime(2026, 3, 12, 13, 0), created_at=datetime(2026, 3, 12, 10, 0)), + SimpleNamespace(status="待处理", updated_at=datetime(2026, 3, 12, 14, 0), created_at=datetime(2026, 3, 12, 10, 30)), + ] + signals = [ + ("待处理", False, "pending"), + ("待处理", False, "pending"), + ("待处理", False, "pending"), + ("待处理", False, "pending"), + ("待处理", False, "pending"), + ("待处理", False, "pending"), + ("待处理", False, "pending"), + ("待处理", False, "pending"), + ("待处理", False, "pending"), + ("待处理", False, "pending"), + ("待处理", False, "pending"), + ("待处理", False, "pending"), + ("待处理", False, "pending"), + ] + + class FakeScalarResult: + def __init__(self, *, scalar=None, rows=None) -> None: + self._scalar = scalar + self._rows = list(rows or []) + + def scalar_one_or_none(self): + return self._scalar + + def scalars(self): + return SimpleNamespace(all=lambda: list(self._rows)) + + def all(self): + return list(self._rows) + + class FakeDB: + async def execute(self, stmt): + sql = str(stmt).lower() + if "from projects" in sql: + return FakeScalarResult(scalar=project) + if "from users" in sql: + return FakeScalarResult(scalar=owner) + if "from tasks" in sql: + return FakeScalarResult(rows=tasks) + if "from signals" in sql: + return FakeScalarResult(rows=signals) + raise AssertionError(sql) + + async def _fake_render_prompt(self, *, fallback_template: str, **kwargs): + _ = self + return str(fallback_template).format(**kwargs).strip() + + monkeypatch.setattr( + "app.services.prompt_release.service.PromptReleaseService.render_prompt", + _fake_render_prompt, + ) + + class _FakeLLM: + async def generate(self, prompt: str, **kwargs): + _ = (prompt, kwargs) + return json.dumps( + { + "handled": True, + "summary": "项目已经立起来了,但推进还没真正跑起来。", + "stage_judgement": "启动后停滞阶段", + "evidence_summary": "当前没有进行中的任务,6 个任务待处理,13 条信号待处理。", + "core_gap": "任务和信号都在堆积,但还没有转成明确的本周执行动作。", + "external_impact": "如果直播电商平台规则继续收紧,这个项目的优先级需要前移。", + "actions": [ + {"slot": "this_week", "text": "先把 6 个待处理任务拆成本周必须启动和可以后排两档。"}, + {"slot": "owner_decision", "text": "明确每个任务的推进人和本周交付口径。"}, + {"slot": "protect_priority", "text": "优先清掉最影响交付的 2 条待处理信号。"}, + ], + }, + ensure_ascii=False, + ) + + class FakeRAG( + mixin_fast_answer_project_main.AdaptiveRAGFastAnswerProjectMainMixin, + AdaptiveRAGConversationUtilsAMixinP1, + ): + def __init__(self) -> None: + self.db = FakeDB() + self.tenant_id = 1 + self.llm = _FakeLLM() + + async def _resolve_project_scope_for_question(self, **kwargs): + _ = kwargs + return (62, "关于直播同行竞品分析", "直播同行竞品分析") + + async def _execute_external_query_gateway(self, **kwargs): + _ = kwargs + return ( + [ + RetrievedItem( + source="url", + content="直播电商监督管理办法已发布,平台合规和经营者责任要求更明确。", + score=0.93, + source_label="URL[直播电商监督管理办法]", + metadata={ + "title": "直播电商监督管理办法", + "url": "https://www.cac.gov.cn/2026-01/07/example", + }, + ), + RetrievedItem( + source="url", + content="国家市场监管总局同步披露了直播电商监管办法 PDF 版本。", + score=0.88, + source_label="URL[直播电商监督管理办法 - 国家市场监督管理总局]", + metadata={ + "title": "直播电商监督管理办法 - 国家市场监督管理总局", + "url": "https://www.samr.gov.cn/2026-01/example.pdf", + }, + ), + ], + {"mode": "external_query_gateway", "adapter": "live_search"}, + ) + + rag = FakeRAG() + result = await rag._try_fast_project_progress_answer( + clean_q="帮我看下关于直播同行竞品分析这个项目现在怎么样?", + project_id=None, + query_semantics={"subject_focus": "project", "status_focus": "snapshot"}, + ) + + assert result is not None + answer = str(result["answer"] or "") + assert "项目 #62《关于直播同行竞品分析》" in answer + assert "当前结论:项目已经立起来了,但推进还没真正跑起来。" in answer + assert "1. 📍 当前盘面" in answer + assert "负责人:系统管理员" in answer + assert "任务进度:共 6 项,已完成 0,进行中 0,待处理 6" in answer + assert "信号情况:共 13 条,待处理 13 条" in answer + assert "2. 🧠 进度判断" in answer + assert "项目目前处于:启动后停滞阶段" in answer + assert "3. 🌐 外部变化" in answer + assert "最新外部信息 1:直播电商监督管理办法" in answer + assert "4. ✅ 下一步建议" in answer + assert "这周先推进:先把 6 个待处理任务拆成本周必须启动和可以后排两档。" in answer + assert "负责人需要拍板:明确每个任务的推进人和本周交付口径。" in answer + assert "如果资源不够,先保住:优先清掉最影响交付的 2 条待处理信号。" in answer + assert "来源摘要:项目库 + 外部实时来源" in answer def test_typed_executor_common_exports_regex_helpers_to_query_mixins() -> None: diff --git a/backend/tests/test_ai_pmo_qa_http_mainline_e2e.py b/backend/tests/test_ai_pmo_qa_http_mainline_e2e.py index c5789f07..69691667 100644 --- a/backend/tests/test_ai_pmo_qa_http_mainline_e2e.py +++ b/backend/tests/test_ai_pmo_qa_http_mainline_e2e.py @@ -51,6 +51,13 @@ async def test_http_rag_ask_readonly_mainline_contract(monkeypatch) -> None: "rag_result": { "answer": "当前主链状态正常。", "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", "grounding_status": "grounded", "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() assert payload["code"] == 0 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"]["grounding_status"] == "grounded" assert payload["data"]["policy_version"] == "retrieval-v2.1|routing:v3|prompt:deterministic" assert payload["data"]["citations"] == [] assert payload["data"]["sources_used"] == [] + assert payload["data"]["evidence_pack"]["summary"]["conclusion"] == "当前主链状态正常。" ingress_kwargs = captured["ingress_kwargs"] assert isinstance(ingress_kwargs, dict) assert ingress_kwargs["source_system"] == "pmflow_manual" diff --git a/backend/tests/test_draft_confirm_handler.py b/backend/tests/test_draft_confirm_handler.py index 0fdb87c5..c19555e5 100644 --- a/backend/tests/test_draft_confirm_handler.py +++ b/backend/tests/test_draft_confirm_handler.py @@ -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" -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 raw = ( @@ -99,11 +99,16 @@ def test_reply_outbox_normalize_beautifies_structured_pm_sections() -> None: normalized = FeishuReplyOutboxMixin._normalize_outgoing_reply_text(raw) - assert "🎯 结论:" in normalized - assert "🔎 依据:" in normalized - assert "➡️ 下一步:" in normalized - assert "🧩 信息不足,缺少:" in normalized - assert "\n\n🔎 依据:" in normalized + assert "结论:" not in normalized + assert "依据:" not in normalized + assert "下一步:" not in normalized + assert "信息不足,缺少:" not 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: @@ -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: - 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 "先给结论" 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 diff --git a/backend/tests/test_feishu_pipeline_queue_contract.py b/backend/tests/test_feishu_pipeline_queue_contract.py index b9e800c7..1eeb53fb 100644 --- a/backend/tests/test_feishu_pipeline_queue_contract.py +++ b/backend/tests/test_feishu_pipeline_queue_contract.py @@ -644,6 +644,100 @@ async def test_feishu_business_review_query_forwards_semantic_bundle_into_kernel 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 async def test_force_sync_feishu_query_flow_still_forwards_semantic_bundle_into_kernel(monkeypatch) -> None: captured: dict[str, object] = {} diff --git a/backend/tests/test_internal_query_context_packet.py b/backend/tests/test_internal_query_context_packet.py new file mode 100644 index 00000000..5dd54b09 --- /dev/null +++ b/backend/tests/test_internal_query_context_packet.py @@ -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" diff --git a/backend/tests/test_mcp_config_api.py b/backend/tests/test_mcp_config_api.py index 202e2892..94a3ae45 100644 --- a/backend/tests/test_mcp_config_api.py +++ b/backend/tests/test_mcp_config_api.py @@ -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()["data"]["recommended_template_id"] == "microsoft_learn" 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.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.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" diff --git a/backend/tests/test_mcp_external_query_bridge.py b/backend/tests/test_mcp_external_query_bridge.py index 98856509..d60ab1b3 100644 --- a/backend/tests/test_mcp_external_query_bridge.py +++ b/backend/tests/test_mcp_external_query_bridge.py @@ -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.schemas import ExternalAdapterResult, ExternalEvidenceItem, ExternalQueryPlan 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: @@ -51,6 +51,17 @@ async def _seed_backend_direct_server(db_session) -> None: 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 async def test_mcp_config_service_search_external_uses_backend_direct_server(db_session, monkeypatch) -> None: 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( query="azure openai quickstart", result_limit=3, + query_kind="model", ) 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" +@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 async def test_external_query_gateway_prefers_backend_direct_mcp_when_available(db_session, monkeypatch) -> None: await _seed_backend_direct_server(db_session) - async def _fake_search_external(self, *, query: str, result_limit: int = 3): # noqa: ANN001 - _ = (self, query, result_limit) + async def _fake_search_external(self, *, query: str, result_limit: int = 3, query_kind: str = "unknown"): # noqa: ANN001 + _ = (self, query, result_limit, query_kind) return [ ExternalEvidenceItem( source_type="mcp_search", @@ -129,6 +180,67 @@ async def test_external_query_gateway_prefers_backend_direct_mcp_when_available( 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 async def test_external_query_gateway_falls_back_when_mcp_evidence_is_off_topic() -> None: plan = ExternalQueryPlan( diff --git a/backend/tests/test_mcp_runtime_service.py b/backend/tests/test_mcp_runtime_service.py index 9d852ff8..3d2ffc49 100644 --- a/backend/tests/test_mcp_runtime_service.py +++ b/backend/tests/test_mcp_runtime_service.py @@ -1,11 +1,12 @@ from __future__ import annotations import json +from types import SimpleNamespace import httpx 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: @@ -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].target_url == "https://learn.microsoft.com/azure/ai-foundry/openai/chatgpt-quickstart" 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"] diff --git a/backend/tests/test_query_route_governance_api.py b/backend/tests/test_query_route_governance_api.py new file mode 100644 index 00000000..64f53751 --- /dev/null +++ b/backend/tests/test_query_route_governance_api.py @@ -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" + diff --git a/backend/tests/test_rag_route_contract.py b/backend/tests/test_rag_route_contract.py index 03ab6274..a32f2705 100644 --- a/backend/tests/test_rag_route_contract.py +++ b/backend/tests/test_rag_route_contract.py @@ -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 +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 async def test_rag_ask_route_forwards_contract_fields(monkeypatch) -> None: captured: dict[str, object] = {} diff --git a/backend/tests/test_semantic_fact_service.py b/backend/tests/test_semantic_fact_service.py index 50c39452..51556475 100644 --- a/backend/tests/test_semantic_fact_service.py +++ b/backend/tests/test_semantic_fact_service.py @@ -16,8 +16,11 @@ from app.models.task import Task from app.models.truth_node import TruthNode from app.models.user import User 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_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.llm_service import StructuredExtractionResult 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" +@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 async def test_structured_db_retrieval_prefers_semantic_fact_context_for_project_queries(db_session) -> None: project = Project( diff --git a/backend/tests/test_semantic_lint.py b/backend/tests/test_semantic_lint.py index c8a88b7a..043c265c 100644 --- a/backend/tests/test_semantic_lint.py +++ b/backend/tests/test_semantic_lint.py @@ -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" +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: module = _load_semantic_lint_module() 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("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: diff --git a/backend/tmp_p1_real_validate_v4.py b/backend/tmp_p1_real_validate_v4.py index 1b5360d8..3f4bbf56 100644 --- a/backend/tmp_p1_real_validate_v4.py +++ b/backend/tmp_p1_real_validate_v4.py @@ -22,9 +22,6 @@ CASES = [ {"idx": 5, "message": "帮我看看宠物行业最新动态", "expected_scene": "external_trend", "keys": ["宠物行业", "最新动态", "行业", "动态"]}, ] -BANNED_PREFIXES = ["让我先", "让我尝试", "我来看看", "我先查", "让我看看", "我需要先"] - - def login_token() -> str: data = urllib.parse.urlencode({"username": "admin", "password": "admin123"}).encode() req = urllib.request.Request( @@ -93,7 +90,7 @@ def has_thinking_leak(reply: str) -> bool: s = line.strip() if not s: continue - if any(s.startswith(prefix) for prefix in BANNED_PREFIXES): + if len(s) <= 12 and s.startswith("我") and "先" in s: return True return False diff --git a/backend/tmp_run_feishu_case.py b/backend/tmp_run_feishu_case.py index 69f56643..7163ec79 100644 --- a/backend/tmp_run_feishu_case.py +++ b/backend/tmp_run_feishu_case.py @@ -16,7 +16,6 @@ from app.models import AICallLog, AIOrchestrationRun, AIProcessingLog, FeishuRep BASE = "http://127.0.0.1:8000" CHAT_ID = "oc_c487d87563e48c2f9566ccd5a758212c" SENDER = "P1验收机器人" -BANNED_PREFIXES = ["让我先", "让我尝试", "我来看看", "我先查", "让我看看", "我需要先"] DEFAULT_LOG_PATH = Path(__file__).resolve().parent / "tmp_acceptance" / "consultant_dispatch_feishu_runs.jsonl" TERMINAL_RUN_STATUSES = {"succeeded", "failed", "cancelled", "answered", "completed"} @@ -89,7 +88,7 @@ def has_thinking_leak(text: str) -> bool: s = line.strip() if not s: continue - if any(s.startswith(prefix) for prefix in BANNED_PREFIXES): + if len(s) <= 12 and s.startswith("我") and "先" in s: return True return False diff --git a/docs/architecture/ARCHITECTURE_INVENTORY.md b/docs/architecture/ARCHITECTURE_INVENTORY.md index dbe77507..7c38eaa4 100644 --- a/docs/architecture/ARCHITECTURE_INVENTORY.md +++ b/docs/architecture/ARCHITECTURE_INVENTORY.md @@ -53,7 +53,10 @@ ### 4.3 问答、模型与生成 - `backend/app/services/adaptive_rag_service/` +- `backend/app/services/adaptive_rag_service/internal_query_context.py` - `backend/app/services/adaptive_rag_service/mixin_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/hooks.py` - `backend/app/services/llm_service/` @@ -97,13 +100,15 @@ - API canonical 入口优先使用 `*_api/` 子包,不再优先引用顶层兼容壳。 - 运行时业务动作归因优先走“对象/上下文绑定 -> `SemanticContextPacket` -> `SemanticDecision` -> gatekeeper”。 - non-plan `query.answer` 现已在 `AdaptiveRAG` 内增加 `Query Route Gateway`:只消费上游单次结构化 `query_profile/source_policy/effective_source_policy` 与显式 hint,归一化产出 `route_mode / decision_origin / reply_contract / probe_snapshot(contract audit)`,不再在运行时执行 internal/external probe 或 route memory 强拍板,再进入检索与回答主链。 -- 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`。 -- 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`。 -- `query.answer` 的客户可见回答展示也已确认独立基线:运行时证据必须拆成 `customer_safe` 与 `admin_debug` 双视图,客户侧统一消费中文化 `display_label`,禁止直接暴露 `raw_source_label`、机器来源或内部术语;相关设计规格见 `docs/superpowers/specs/2026-04-15-answer-presentation-policy-design.md`。 +- internal `query.answer` 的下一阶段基线已经确认:`Query Route Gateway` 之后不再直接靠快答分支拍板,而是统一进入 `Operation Contract -> Target Resolver -> Query DSL Plan -> InternalContextPacket -> 白名单受控执行器 -> semantic facts 优先 -> verifier` 主链;`InternalContextPacket` 由 `backend/app/services/adaptive_rag_service/internal_query_context.py` 定义,并作为 internal executor / verifier / runtime summary 的运行时单一真源;相关设计规格见 `docs/superpowers/specs/2026-04-15-internal-query-dsl-plan-design.md` 与 `docs/superpowers/specs/2026-04-15-semantic-facts-read-model-design.md`。 +- internal 主链现已补上 packet 预检:`mixin_answer_mainline.py` 在组完 `InternalContextPacket` 后,会先拦截 `missing packet / missing operation contract / target clarify / missing query plan contract` 这类上下文不完整场景,再决定是否进入 deterministic fast path、internal model-config fast path、GLM semantic fast path 或 retrieval;`query_semantics` 对 `operation_contract / target_resolution / query_plan` 的镜像也已收口到单点兼容写回。 +- 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 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` 现已补 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_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/model-routing-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-runs/` - `frontend/src/pages/task-brief-template/` @@ -140,11 +146,15 @@ ### 5.2 API 与共享模块 - `frontend/src/api/aiPmo.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/modelRouting.ts` - `frontend/src/api/mcp.ts` - `frontend/src/api/knowledgeGraph.ts` - `frontend/src/api/semanticFacts.ts` +- `frontend/src/api/queryRouteGovernance.ts` - `frontend/src/api/mountedAi.ts` - `frontend/src/api/feedbackCenter.ts` - `frontend/src/api/sstc.ts` @@ -158,7 +168,9 @@ - 页面级编排逻辑优先收敛到 `pages/*/` 子目录。 - API 调用统一走 `frontend/src/api/`,不在页面或组件内直接 `fetch`。 - `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. 工具与运行库存 - `backend/scripts/repair_requirement_plan_history.py` diff --git a/docs/architecture/CODE_REFERENCE_INDEX.md b/docs/architecture/CODE_REFERENCE_INDEX.md index a5504349..dbabc5c3 100644 --- a/docs/architecture/CODE_REFERENCE_INDEX.md +++ b/docs/architecture/CODE_REFERENCE_INDEX.md @@ -30,12 +30,15 @@ ### 2.3 RAG / AI PMO / Knowledge Graph - RAG 问答主入口:参考 `backend/app/api/rag_api/` 与 `backend/app/api/rag_ask_contract.py` +- `/rag/ask` richer response contract、QA session detail 与 state replay:参考 `backend/app/api/rag_ask_contract.py`、`backend/app/services/adaptive_rag_service/mixin_qa_session_list_ops_p1.py` - 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` - 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` -- 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/services/semantic_fact_service/hooks.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}/decision` - AI PMO 消息台列表接口:参考 `GET /ai-pmo/work-items` +- AI PMO `process-text/work-item` 的 `plan_mode / plan_status / requirement_plan_context_summary` 投影:参考 `backend/app/api/ai_pmo_api/helpers_inbox_payload.py` 与 `backend/app/api/ai_pmo_api/routes_core.py` ### 2.4 Signal / Project / SSTC - 需求点落信号源:参考 `backend/app/services/signal/signal_source_orchestration/mixin_registry_execute.py` @@ -67,13 +71,19 @@ ## 3. 前端参考 - 新增前端 API 调用:参考 `frontend/src/api/sstc.ts` - AI Steward 页面入口:参考 `frontend/src/pages/ai-steward/` +- AI Steward 问答浮窗与上下文显性化组件:参考 `frontend/src/components/ai-steward/FloatingQAChat.tsx`、`frontend/src/components/ai-steward/QAConversationStatusBar.tsx`、`frontend/src/components/ai-steward/QAResponseMetaCard.tsx` +- AI Steward / QA session 前端 contract:参考 `frontend/src/api/aiSteward.ts` +- 网页问答显式锚点与 AI Steward URL 构造:参考 `frontend/src/components/ai-steward/qaPageAnchor.ts` - 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/` - SSTC 工作流组件:参考 `frontend/src/components/sstc/` - Model Routing 页面:参考 `frontend/src/pages/model-routing-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/` - MCP 前端 API:参考 `frontend/src/api/mcp.ts` +- Route / Context 治理前端 API:参考 `frontend/src/api/queryRouteGovernance.ts` - Semantic facts 前端 API:参考 `frontend/src/api/semanticFacts.ts` - 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 registry:`backend/app/config/ai_prompt_registry.py` - 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. 维护建议 - 新增接口时:先更新本文档,再同步 `API_CONTRACTS.md` diff --git a/docs/architecture/DATAFLOW.md b/docs/architecture/DATAFLOW.md index f25d826b..8fb9fbfb 100644 --- a/docs/architecture/DATAFLOW.md +++ b/docs/architecture/DATAFLOW.md @@ -41,8 +41,14 @@ - 全程自动绑定 `tenant_id` - 不接收自由 SQL 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。 -9. 回答完成后: +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. 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` 继续落盘 - `query_route_memories` 追加或更新该次样本 - `QAFeedback` / `FeedbackCenterRecord` 后续会把 route feedback 回写到 `query_route_memories.feedback_outcome` diff --git a/docs/architecture/RUNTIME_GUARDRAILS.md b/docs/architecture/RUNTIME_GUARDRAILS.md index 9fb3cd8c..b7a85851 100644 --- a/docs/architecture/RUNTIME_GUARDRAILS.md +++ b/docs/architecture/RUNTIME_GUARDRAILS.md @@ -26,7 +26,7 @@ - 新增接口必须遵循响应包络:`{code, message, data}`。 - 禁止在 API 层写业务逻辑;业务逻辑归属 `services/`。 - 禁止在前端组件内直接调用 `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`,禁止同步直接发送。 - 飞书回执发送仅允许 `main.py` 的 drain loop 作为消费者,禁止在业务路径即时派发发送任务。 - 所有写接口必须落审计日志(`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 收敛轮次”。 - 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 文案反推需求字段。 +- `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 记账。 - 冷启动 `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` 主链。 @@ -107,10 +108,16 @@ - 飞书与 AI PMO 入口(含 `message_intent=mixed`)问答链路必须绕过 deterministic/snapshot 快路径,统一落到场景化 Prompt 生成。 - `query.answer` 在进入 AdaptiveRAG 检索前必须先产出单一 `source_policy`(`internal / external / uncertain`)审计字段;来源判断只允许消费上游明确锚点与单次结构化语义结果,禁止再新增基于“问题长相”的本地关键词/正则拍板层。若上游合同仍是 `uncertain`,执行层只能保持 `uncertain` 或消费上游显式给出的 `effective_source_policy`,不得私自默认 external-first。v1 禁止同一轮 query 同时混搜内部知识库与外部网页,必须先定来源再检索。 - non-plan `query.answer` 的来源裁决统一由 `Query Route Gateway` 执行,输出必须至少包含 `route_mode / decision_origin / reply_contract / probe_snapshot(contract audit)`;回复正文必须遵守显式来源合同(`内部知识库查询:` / `外部实时查询:`),`uncertain + effective_source_policy=uncertain` 必须直接澄清,禁止混合答案。 +- `/rag/ask`、QA session detail 与 session state replay 对前台必须显式暴露 `route_decision / clarification_code / clarification_candidates / answer_mode / grounding_status / evidence_pack / session_state(last_subject, pending_clarification...)`;AI Steward UI 只能消费这些结构化字段展示当前对象、来源模式与澄清入口,禁止再从回答正文、来源标签文案或按钮文案反推 route mode。 +- 网页 `query.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`,禁止先执行双探针再回写路由结论。 -- 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` 后再误入内部查询。 - `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` 自动抽取是内部查询的必经数据基线:`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 数据继续参与内部查询。 @@ -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` 等机器/内部术语。 - 通用行业趋势/外部品牌模型 freshness 问答,禁止把项目图谱、Feature Catalog、内部文档或用户历史冒充成“外部事实”;若命中 live external `url/http(s)` 证据,必须优先基于外部结果回答;若实时外部检索为空或不足,才允许显式返回“当前未接入实时外部数据/暂无法确认最新结论”。 - external `query.answer` 在 `route_mode=external` 后必须继续进入受控外部查询主链:`External Query Planner -> External Query Gateway -> External Provider Registry -> MCP adapter 优先 -> bounded fallback live search -> external answer verifier`。AdaptiveRAG 运行时必须通过 `backend/app/services/external_query_service/` 统一执行,external probe / freshness fetch / external route retrieval 不得再绕回直接 DuckDuckGo HTML 抓取。大模型只允许产出结构化 `ExternalQueryPlan` 与 grounded answer,不得自由联网、自由调用工具或在无证据时直接给外部事实结论。 +- external `query.answer` 在 verifier 之后必须继续生成 `ExternalContextPacket`,并由 `ExternalAnswerPresenter` 负责最终回答生成;presenter 只能消费问题文本、计划摘要、经 verifier 通过的外部证据摘要与外部来源摘要,禁止重新注入 `system_snapshot / user_snapshot / project_context / 内部 context_blocks`,也不得回流 `industry_trend / external_trend` 旧 scene prompt 混答。 +- `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。 - `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 / 内部模型配置` 场景;否则禁止把通用“最新/当前/模型/版本”问题内化成系统配置问答。 @@ -126,7 +136,7 @@ - 用户对上一条行业/外部趋势回复的事实性纠错追问,必须优先留在聊天问答链路;若锚定上一轮 `industry_trend/external_trend`,禁止切入 `ai_pmo_correction` 裁决文风。 - 外部“最新/目前/截至现在”等 freshness 问答,只有命中显式外部来源(如 `url/http(s)` 证据)时才允许记为 grounded;仅凭项目图谱、Feature Catalog、系统快照或内部资料时,必须降级为 `insufficient_evidence` 并明确说明当前未接入实时外部数据。 - 外部来源合同必须 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` 等内部执行链。 - 飞书聊天可见答案禁止泄露内部函数名、文件路径、代码行号、模块名或调试字段;prompt 护栏与出站文本清洗必须同时满足该约束。 - mixed 场景回复必须强制两段格式输出:`[回答]` 与 `[已收到需求] ...,已入库处理`。 @@ -171,8 +181,8 @@ - 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 自动抽取、治理页和 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 成功。 -- MCP 模板接入中心当前区分两类状态:后端直连模板的 runtime probe 只代表 PMFlow backend 对远程 MCP 的可达性;Local Bridge 模板的“已确认”仅表示用户手动确认客户本机已承载,不代表后端完成宿主机探活,也不负责探测客户真实文件系统或本机 host 进程状态。 +- 当前 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 或内建 backend runtime 的可达性;Local Bridge 模板的“已确认”仅表示用户手动确认客户本机已承载,不代表后端完成宿主机探活,也不负责探测客户真实文件系统或本机 host 进程状态。 ## 5. 维护建议 - 想看“必须立即遵守的规则”:先看 `AGENTS.md` diff --git a/docs/superpowers/plans/2026-04-16-external-news-evidence-upgrade.md b/docs/superpowers/plans/2026-04-16-external-news-evidence-upgrade.md new file mode 100644 index 00000000..9231af70 --- /dev/null +++ b/docs/superpowers/plans/2026-04-16-external-news-evidence-upgrade.md @@ -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. diff --git a/docs/superpowers/plans/2026-04-17-phase2-explicit-anchor-route-governance.md b/docs/superpowers/plans/2026-04-17-phase2-explicit-anchor-route-governance.md new file mode 100644 index 00000000..cf5eb2a5 --- /dev/null +++ b/docs/superpowers/plans/2026-04-17-phase2-explicit-anchor-route-governance.md @@ -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: } +``` + +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 已完成 diff --git a/docs/superpowers/plans/2026-04-17-three-mode-interaction-context-upgrade.md b/docs/superpowers/plans/2026-04-17-three-mode-interaction-context-upgrade.md new file mode 100644 index 00000000..1cdff062 --- /dev/null +++ b/docs/superpowers/plans/2026-04-17-three-mode-interaction-context-upgrade.md @@ -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 | null; +clarification_code?: string | null; +clarification_candidates?: Array>; +answer_mode?: string | null; +grounding_status?: string | null; +policy_version?: string | null; +citations?: Array>; +sources_used?: Array>; +evidence_pack?: Record | null; +``` + +Also add: + +```ts +async getQASessionState(sessionId: number): Promise +``` + +- [ ] **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 + +``` + +- [ ] **Step 4: Implement the per-answer meta card** + +For each message, render a compact card that surfaces: + +```tsx + +``` + +- [ ] **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 +{item.source_work_item?.plan_status || "draft_pending_confirm"} +
当前模式:需求模式
+
下一步:补信息 / 选 A-B-C / 草稿确认
+``` + +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 diff --git a/docs/superpowers/specs/2026-04-15-internal-query-dsl-plan-design.md b/docs/superpowers/specs/2026-04-15-internal-query-dsl-plan-design.md index c732446b..12810a4e 100644 --- a/docs/superpowers/specs/2026-04-15-internal-query-dsl-plan-design.md +++ b/docs/superpowers/specs/2026-04-15-internal-query-dsl-plan-design.md @@ -19,7 +19,7 @@ ## Confirmed Baseline - 方案 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。 - 受控执行器只能命中白名单实体、白名单字段映射、白名单查询模板。 - 协议短回复、模式控制、执行动作请求不得进入 DSL 规划;必须在 ingress / followup gate 提前拦住。 @@ -124,10 +124,12 @@ 1. `Query Route Gateway` 决定本轮必须走内部查询。 2. `rag_query_semantics` 只产出标准 `query_type`、`entity_type` 与 DSL 所需槽位,不再直接决定最终回答。 3. `Query DSL Normalizer` 做 schema 校验、枚举收口、上下文补足与黑名单清洗。 -4. `Bounded Executor` 根据 `entity_type + query_type + filters` 选择白名单模板执行。 -5. 若 `semantic_facts` 可用,则先走 facts-first;证据不足时再走受控 fallback 检索。 -6. `Answer Verifier` 校验“问法合同、证据合同、来源合同”是否一致。 -7. verifier 通过后再进入回答渲染;不通过则显式失败或重试,不允许静默出错。 +4. `InternalContextPacket` 固化 `question / scope / route_decision / operation_contract / target_resolution / query_plan`,并作为 internal executor / verifier / runtime summary 的单一真源;`query_semantics` 只保留上游 scratchpad 与兼容镜像。 +5. mainline 必须先对 packet 做预检;若 packet 缺失、`operation_contract.query_type` 缺失、`target_resolution.resolution_mode=clarify` 或 `query_plan.answer_contract` 缺失,直接返回 `clarify / explicit_insufficient_evidence`,不允许继续执行。 +6. `Bounded Executor` 根据 packet 内的 `entity_type + query_type + filters` 选择白名单模板执行。 +7. 若 `semantic_facts` 可用,则先走 facts-first;证据不足时再走受控 fallback 检索。 +8. `Answer Verifier` 校验“问法合同、证据合同、来源合同”是否一致。 +9. verifier 通过后再进入回答渲染;不通过则显式失败或重试,不允许静默出错。 ## Bounded Executor Rules @@ -149,6 +151,7 @@ verifier 至少检查: - 是否误把 existence / status / compare 问题答成 total count - 回答所引用的证据是否真实命中本次执行结果 - `reply_contract` 是否与 `route_mode=internal` 一致 +- verifier 的合同真源必须来自 `InternalContextPacket.operation_contract.query_type` 与 `InternalContextPacket.query_plan.answer_contract`,禁止根据 answer 文本或散落的 `query_semantics` 字段倒推 verifier 不通过时,统一返回三类结果之一: @@ -159,6 +162,10 @@ verifier 不通过时,统一返回三类结果之一: ## Failure Semantics - 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` 澄清或返回“当前未确认到” - verifier 判定合同漂移:拒绝出站并写审计日志 @@ -186,6 +193,7 @@ verifier 不通过时,统一返回三类结果之一: - `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_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_deterministic_query_core.py` - `backend/app/services/adaptive_rag_service/mixin_deterministic_query_count.py` diff --git a/frontend/src/api/aiPmo.ts b/frontend/src/api/aiPmo.ts index 1e1b3be5..b42b1e17 100644 --- a/frontend/src/api/aiPmo.ts +++ b/frontend/src/api/aiPmo.ts @@ -45,6 +45,9 @@ export interface AIPMOWorkItem { confirm_message_id?: string | null; latest_policy_gate_code?: string | null; approval_requirement?: AIPMOApprovalRequirement | null; + plan_mode?: boolean; + plan_status?: string | null; + requirement_plan_context_summary?: AIPMORequirementPlanContextSummary | null; created_at?: string | null; updated_at?: string | null; } @@ -56,6 +59,14 @@ export interface AIPMOApprovalRequirement { [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 { items: AIPMOWorkItem[]; } @@ -110,6 +121,9 @@ export interface AIPMOWorkItemDetail { confirm_message_id?: string | null; review_summary?: Record | null; reply_template_key?: string | null; + plan_mode?: boolean; + plan_status?: string | null; + requirement_plan_context_summary?: AIPMORequirementPlanContextSummary | null; decision_records: AIPMODecisionRecordDetail[]; plan_bundles: AIPMOPlanBundleDetail[]; reply_receipts: AIPMOReplyReceiptDetail[]; @@ -168,6 +182,9 @@ export interface AIPMOProcessTextResult { work_item_id?: number | null; signal_id?: number | null; ai_trace_id?: string | null; + plan_mode?: boolean; + plan_status?: string | null; + requirement_plan_context_summary?: AIPMORequirementPlanContextSummary | null; } export interface AIPMOProgramView { diff --git a/frontend/src/api/aiSteward.ts b/frontend/src/api/aiSteward.ts index 312d81d6..4e02075b 100644 --- a/frontend/src/api/aiSteward.ts +++ b/frontend/src/api/aiSteward.ts @@ -300,6 +300,25 @@ export interface QAConsultantDispatch { 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>; + pending_clarification?: Record | null; + pending_clarification_expires_at?: string | null; + updated_at?: string | null; +} + export interface QASessionSummary { id: number; title: string; @@ -339,6 +358,15 @@ export interface QASessionMessage { trace_id?: string | null; processing_stage?: string | null; consultant_dispatch?: QAConsultantDispatch | null; + route_decision?: QARouteDecision | null; + clarification_code?: string | null; + clarification_candidates?: Array>; + answer_mode?: string | null; + grounding_status?: string | null; + policy_version?: string | null; + citations?: Array>; + sources_used?: Array>; + evidence_pack?: Record | null; } export interface QASessionDetail { @@ -375,6 +403,8 @@ export interface QAAskRequest { action_mode: "auto"; sync_requirement: boolean; session_id?: number; + project_id?: number; + source_chat_id?: string; consultant_mode_preference?: "clarify" | "advise" | "challenge" | "brainstorm" | "execute"; } @@ -392,6 +422,15 @@ export interface QAAskResponse { trace_id?: string | null; processing_stage?: string | null; consultant_dispatch?: QAConsultantDispatch | null; + route_decision?: QARouteDecision | null; + clarification_code?: string | null; + clarification_candidates?: Array>; + answer_mode?: string | null; + grounding_status?: string | null; + policy_version?: string | null; + citations?: Array>; + sources_used?: Array>; + evidence_pack?: Record | null; } export const aiStewardApi = { @@ -413,6 +452,10 @@ export const aiStewardApi = { const raw = await apiRequest>(`/rag/qa-sessions/${sessionId}`); return unwrap(raw); }, + async getQASessionState(sessionId: number): Promise { + const raw = await apiRequest>(`/rag/qa-sessions/${sessionId}/state`); + return unwrap(raw); + }, async renameQASession(sessionId: number, title: string): Promise<{ title: string }> { const raw = await apiRequest>(`/rag/qa-sessions/${sessionId}/title`, { method: "PATCH", diff --git a/frontend/src/api/mcp.ts b/frontend/src/api/mcp.ts index 86f47c4b..94e46b6c 100644 --- a/frontend/src/api/mcp.ts +++ b/frontend/src/api/mcp.ts @@ -10,7 +10,7 @@ function unwrap(raw: ApiEnvelope | T): T { } 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 McpRuntimeProbeStatus = "unknown" | "ready" | "error" | "local_bridge_required"; diff --git a/frontend/src/api/queryRouteGovernance.ts b/frontend/src/api/queryRouteGovernance.ts new file mode 100644 index 00000000..75658c10 --- /dev/null +++ b/frontend/src/api/queryRouteGovernance.ts @@ -0,0 +1,101 @@ +import { api } from "@/lib/api"; + +type ApiEnvelope = { code: number; message: string; data: T }; + +function unwrap(raw: ApiEnvelope | T): T { + if (raw && typeof raw === "object" && "data" in (raw as Record)) { + return (raw as ApiEnvelope).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; + route_memory: Record; +} + +export const queryRouteGovernanceApi = { + async getSummary(params?: { + project_id?: number; + window_days?: number; + }): Promise { + const { data } = await api.get>( + "/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 { + const { data } = await api.get>( + `/rag/query-route-governance/items/${recordId}`, + { params }, + ); + return unwrap(data); + }, +}; + diff --git a/frontend/src/components/ai-steward/FloatingQAChat.test.tsx b/frontend/src/components/ai-steward/FloatingQAChat.test.tsx new file mode 100644 index 00000000..11068af6 --- /dev/null +++ b/frontend/src/components/ai-steward/FloatingQAChat.test.tsx @@ -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( + , + ); + + 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( + , + ); + + 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(); + + 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(, ["/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, + }); + }); +}); diff --git a/frontend/src/components/ai-steward/FloatingQAChat.tsx b/frontend/src/components/ai-steward/FloatingQAChat.tsx index 3616c8c0..eedf89ef 100644 --- a/frontend/src/components/ai-steward/FloatingQAChat.tsx +++ b/frontend/src/components/ai-steward/FloatingQAChat.tsx @@ -1,9 +1,13 @@ import { useEffect, useMemo, useRef, useState } from "react"; +import { useLocation } from "react-router-dom"; import { AlertCircle, Loader2, MessageSquare, Sparkles, ThumbsDown, ThumbsUp, X } from "lucide-react"; import { aiPmoApi } from "@/api/aiPmo"; import { aiStewardApi } from "@/api/aiSteward"; +import { QAConversationStatusBar } from "@/components/ai-steward/QAConversationStatusBar"; import { QAMarkdownContent } from "@/components/ai-steward/QAMarkdownContent"; +import { QAResponseMetaCard } from "@/components/ai-steward/QAResponseMetaCard"; +import { resolveQAPageAnchor } from "@/components/ai-steward/qaPageAnchor"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Textarea } from "@/components/ui/textarea"; @@ -34,6 +38,23 @@ type ConsultantDispatch = { allow_execute: boolean; next_action: string; }; +type RouteDecision = { + route_mode?: string; + effective_source_policy?: string; + decision_origin?: string; + [key: string]: unknown; +}; +type SessionStateReplay = { + session_id: number; + last_subject?: string; + last_entity_ids?: number[]; + last_intent?: string; + ambiguity?: string; + rewrite_history?: Array>; + pending_clarification?: Record | null; + pending_clarification_expires_at?: string | null; + updated_at?: string | null; +}; type SessionSummary = { id: number; @@ -73,6 +94,15 @@ type SessionMessage = { trace_id?: string | null; processing_stage?: string | null; consultant_dispatch?: ConsultantDispatch | null; + route_decision?: RouteDecision | null; + clarification_code?: string | null; + clarification_candidates?: Array>; + answer_mode?: string | null; + grounding_status?: string | null; + policy_version?: string | null; + citations?: Array>; + sources_used?: Array>; + evidence_pack?: Record | null; }; type SessionDetail = { @@ -92,10 +122,8 @@ type LoadSessionDetailOptions = { preserveLocal?: boolean; }; -const ERROR_OPTIONS = ["答案错误", "来源错误", "不完整", "答非所问", "其他"]; const LAST_SEEN_KEY = "pmflow_qa_chat_last_seen"; const THINKING_MODE_KEY = "pmflow_qa_chat_thinking_mode"; -const QUICK_QUESTIONS = ["你是谁", "系统架构是什么", "最近有哪些项目", "当前阻塞项有哪些"]; const CONSULTANT_MODE_ACTIONS: ReadonlyArray<{ value: ConsultantModePreference; label: string; @@ -122,6 +150,14 @@ function normalizeRequestError(error: unknown, fallback = "请求失败,请稍 return raw.trim() || fallback; } +function parseFeedbackTags(value: string): string[] { + return String(value || "") + .split(/[,\n,、;;]+/) + .map((item) => item.trim()) + .filter(Boolean) + .slice(0, 8); +} + function normalizeSources(value: unknown): MessageSource[] { if (!Array.isArray(value)) return []; return value @@ -155,6 +191,42 @@ function normalizeConsultantDispatch(value: unknown): ConsultantDispatch | null }; } +function normalizeRouteDecision(value: unknown): RouteDecision | null { + if (!value || typeof value !== "object") return null; + return value as RouteDecision; +} + +function normalizeRecordList(value: unknown): Array> { + if (!Array.isArray(value)) return []; + return value.filter((item): item is Record => Boolean(item) && typeof item === "object"); +} + +function normalizeEvidencePack(value: unknown): Record | null { + if (!value || typeof value !== "object") return null; + return value as Record; +} + +function normalizeSessionStateReplay(value: unknown): SessionStateReplay | null { + if (!value || typeof value !== "object") return null; + const item = value as Record; + return { + session_id: Number(item.session_id || 0), + last_subject: String(item.last_subject || "").trim(), + last_entity_ids: Array.isArray(item.last_entity_ids) ? item.last_entity_ids.map((entry) => Number(entry || 0)).filter(Boolean) : [], + last_intent: String(item.last_intent || "").trim(), + ambiguity: String(item.ambiguity || "").trim(), + rewrite_history: Array.isArray(item.rewrite_history) + ? item.rewrite_history.filter((entry): entry is Record => Boolean(entry) && typeof entry === "object") + : [], + pending_clarification: + item.pending_clarification && typeof item.pending_clarification === "object" + ? (item.pending_clarification as Record) + : null, + pending_clarification_expires_at: String(item.pending_clarification_expires_at || "").trim() || null, + updated_at: String(item.updated_at || "").trim() || null, + }; +} + function normalizeConsultantModePreference(value: unknown): ConsultantModePreference | null { const mode = String(value || "").trim().toLowerCase(); if (mode === "clarify" || mode === "advise" || mode === "challenge" || mode === "brainstorm" || mode === "execute") { @@ -322,6 +394,7 @@ function groupSessions(items: SessionSummary[]) { } export function FloatingQAChat() { + const location = useLocation(); const [open, setOpen] = useState(false); const [mode, setMode] = useState("chat"); const [sessions, setSessions] = useState([]); @@ -330,6 +403,7 @@ export function FloatingQAChat() { const [historyLoading, setHistoryLoading] = useState(false); const [activeSessionId, setActiveSessionId] = useState(null); const [sessionDetail, setSessionDetail] = useState(null); + const [sessionState, setSessionState] = useState(null); const [localMessages, setLocalMessages] = useState([]); const [historyApiAvailable, setHistoryApiAvailable] = useState(true); const [detailLoading, setDetailLoading] = useState(false); @@ -369,6 +443,11 @@ export function FloatingQAChat() { answer: String(message.answer || ""), sources: normalizeSources(message.sources), consultant_dispatch: normalizeConsultantDispatch(message.consultant_dispatch), + route_decision: normalizeRouteDecision(message.route_decision), + clarification_candidates: normalizeRecordList(message.clarification_candidates), + citations: normalizeRecordList(message.citations), + sources_used: normalizeRecordList(message.sources_used), + evidence_pack: normalizeEvidencePack(message.evidence_pack), }); const normalizeSessionDetail = (detail: SessionDetail): SessionDetail => ({ @@ -456,10 +535,21 @@ export function FloatingQAChat() { } }; + const loadSessionState = async (sessionId: number) => { + try { + const data = normalizeSessionStateReplay(await aiStewardApi.getQASessionState(sessionId)); + setSessionState(data); + setHistoryApiAvailable(true); + } catch (error) { + console.error("[FloatingQAChat] loadSessionState failed", error); + setSessionState(null); + } + }; + const refreshActiveSession = async (sessionId?: number | null, options?: LoadSessionDetailOptions) => { const targetId = sessionId ?? activeSessionId; if (!targetId) return; - await loadSessionDetail(targetId, options); + await Promise.all([loadSessionDetail(targetId, options), loadSessionState(targetId)]); }; const startNewChat = () => { @@ -469,6 +559,7 @@ export function FloatingQAChat() { setDraftNewSession(true); setActiveSessionId(null); setSessionDetail(null); + setSessionState(null); setLocalMessages([]); setAskError(""); setQuestion(""); @@ -503,6 +594,8 @@ export function FloatingQAChat() { new_session: Boolean(draftNewSession), action_mode: "auto", sync_requirement: true, + ...(pageAnchor.projectId ? { project_id: pageAnchor.projectId } : {}), + ...(pageAnchor.sourceChatId ? { source_chat_id: pageAnchor.sourceChatId } : {}), ...(typeof currentSessionId === "number" && Number.isFinite(currentSessionId) && currentSessionId > 0 ? { session_id: Math.trunc(currentSessionId) } : {}), @@ -531,6 +624,15 @@ export function FloatingQAChat() { trace_id: data.trace_id ?? null, processing_stage: data.processing_stage ?? null, consultant_dispatch: normalizeConsultantDispatch(data.consultant_dispatch), + route_decision: normalizeRouteDecision(data.route_decision), + clarification_code: String(data.clarification_code || "").trim() || null, + clarification_candidates: normalizeRecordList(data.clarification_candidates), + answer_mode: data.answer_mode ?? null, + grounding_status: data.grounding_status ?? null, + policy_version: data.policy_version ?? null, + citations: normalizeRecordList(data.citations), + sources_used: normalizeRecordList(data.sources_used), + evidence_pack: normalizeEvidencePack(data.evidence_pack), }); const returnedSessionId = Number(data.session_id || 0) || null; if (historyApiAvailable) { @@ -633,6 +735,18 @@ export function FloatingQAChat() { }, 0); }; + const handleRouteClarificationChoice = async (message: SessionMessage, choice: "A" | "B") => { + setActionBusyKey(`route-clarify-${message.id}-${choice}`); + try { + await runQuestionRequest({ + prompt: choice, + requestThinkingModeOverride: message.thinking_mode || thinkingMode, + }); + } finally { + setActionBusyKey(null); + } + }; + const handleExecuteViaKernel = async () => { if (!executeDialogMessage) return; setActionBusyKey(`execute-${executeDialogMessage.id}`); @@ -665,6 +779,10 @@ export function FloatingQAChat() { }; const grouped = useMemo(() => groupSessions(sessions), [sessions]); + const pageAnchor = useMemo( + () => resolveQAPageAnchor(location.pathname, location.search), + [location.pathname, location.search], + ); const requestLooksLikeWrite = useMemo(() => looksLikeRequirementWrite(question), [question]); const displayMessages = useMemo(() => { if (mode === "session") { @@ -684,6 +802,13 @@ export function FloatingQAChat() { } return []; }, [mode, sessionDetail, localMessages, activeSessionId]); + const lastMessage = displayMessages.length > 0 ? displayMessages[displayMessages.length - 1] : null; + const lastRouteDecision = lastMessage?.route_decision ?? null; + const isFollowingSessionContext = Boolean( + sessionState?.last_subject || + (sessionState?.last_entity_ids?.length || 0) > 0 || + (sessionState?.rewrite_history?.length || 0) > 0, + ); useEffect(() => { activeSessionIdRef.current = activeSessionId; @@ -897,6 +1022,15 @@ export function FloatingQAChat() { )} + + {detailLoading ? (
加载会话中... @@ -936,6 +1070,34 @@ export function FloatingQAChat() { {msg.signal_id ? signal #{msg.signal_id} : null} {stageBadge ? {stageBadge} : null}
+ + {msg.clarification_code === "query_route_clarify" ? ( +
+ + +
+ ) : null}
顾问动作
@@ -1103,27 +1265,17 @@ export function FloatingQAChat() { {modeForItem === "negative" && (
-
哪里有问题?(可多选)
-
- {ERROR_OPTIONS.map((option) => { - const checked = selectedErrors.includes(option); - return ( - - ); - })} -
+
哪里有问题?(选填,逗号分隔)
+ + setErrorTypes((prev) => ({ + ...prev, + [msg.id]: parseFeedbackTags(e.target.value), + })) + } + />