Add explicit QA anchors and route governance pages
This commit is contained in:
parent
4720c8117d
commit
3f94cf702c
|
|
@ -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_*`、关键词仓或散落正则拍板。
|
||||
|
|
|
|||
14
CLAUDE.md
14
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` 做仓库级守门
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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"]
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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",),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
]
|
||||
|
|
|
|||
|
|
@ -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 = """
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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"]
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
]
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
||||
|
|
@ -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",
|
||||
]
|
||||
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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"<at\b[^>]*>.*?</at>", "", 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": [],
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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]:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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]]] = {}
|
||||
|
|
|
|||
|
|
@ -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))]
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
},
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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")),
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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]:
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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"))
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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}。"
|
||||
"如果你的界面显示不一致,优先检查设备时区和飞书客户端缓存。"
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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):]}"
|
||||
|
|
|
|||
|
|
@ -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} 已关联",
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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] = {}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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"]
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
||||
|
|
@ -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] = {}
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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`
|
||||
|
|
|
|||
|
|
@ -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`
|
||||
|
|
|
|||
|
|
@ -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`
|
||||
|
|
|
|||
|
|
@ -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`
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
@ -0,0 +1,544 @@
|
|||
# Phase 2 Explicit Anchor And Route Governance Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** 把网页问答入口真正绑定到当前页面业务对象,并补一页 manager-only 的 route/context 治理看板,让 PMFlow 能同时做到“前台少串对象、后台能看误路由样本”。
|
||||
|
||||
**Architecture:** Phase 2A 不改 AdaptiveRAG 的语义边界,只把现有 `project_id / source_chat_id` 从稳定网页入口显式送进 `/rag/ask`,并在全局浮窗展示“当前页面锚点”。Phase 2B 在不引入在线自动调参的前提下,围绕 `query_route_memories` 新增只读 summary/list/detail API 和一页治理 UI,优先解决“看不见 misroute / clarify-heavy / negative feedback 样本”的问题。
|
||||
|
||||
**Tech Stack:** FastAPI, Pydantic, SQLAlchemy 2.0, AdaptiveRAG, React 18, TypeScript, React Query, Vitest, pytest
|
||||
|
||||
---
|
||||
|
||||
## Scope And Guardrails
|
||||
|
||||
- v1 显式锚点优先做稳定强锚点:
|
||||
- 项目看板 / 宪章页路径里的 `project_id`
|
||||
- `Inbox` / `AI Steward` 页面 URL query 中已存在的 `project_id`
|
||||
- URL query 中已显式存在的 `source_chat_id`
|
||||
- v1 不在网页里新增“猜测型 source_chat_id 推断器”;只有真实入口已携带时才透传,避免把锚点系统重新做成隐式猜测。
|
||||
- v1 治理页只做只读审计与人工排查支持:
|
||||
- `route_mode_predicted != route_mode_effective`
|
||||
- `reply_contract.mode=clarify` 或 `effective_source_policy=uncertain`
|
||||
- `feedback_outcome.rating=negative`
|
||||
- detail drawer 中暴露 `probe_snapshot / query_profile / feedback_outcome`
|
||||
- v1 不做在线阈值自调、不自动改路由、不引入新的 route memory 打分逻辑。
|
||||
|
||||
## Baseline File Map
|
||||
|
||||
### Phase 2A: Explicit Web Anchor
|
||||
|
||||
- Modify: `backend/tests/test_rag_route_contract.py`
|
||||
- 锁 `/rag/ask` 在 request 里显式传入 `project_id / source_chat_id` 时必须原样进入 ingress contract。
|
||||
- Create: `frontend/src/components/ai-steward/qaPageAnchor.ts`
|
||||
- 统一从 `pathname + searchParams` 解析全局网页问答锚点,输出 `projectId / sourceChatId / anchorType / anchorLabel / origin`。
|
||||
- Create: `frontend/src/components/ai-steward/qaPageAnchor.test.ts`
|
||||
- 锁不同页面路径和 query param 的锚点解析规则。
|
||||
- Modify: `frontend/src/components/ai-steward/FloatingQAChat.tsx`
|
||||
- 接入页面锚点解析,把 `project_id / source_chat_id` 真正带进 `askRag()` 请求,并在状态条展示“当前页面锚点”。
|
||||
- Modify: `frontend/src/components/ai-steward/QAConversationStatusBar.tsx`
|
||||
- 增加页面锚点文案和“当前页面已锁定项目/会话”的显式状态。
|
||||
- Modify: `frontend/src/components/ai-steward/FloatingQAChat.test.tsx`
|
||||
- 锁全局浮窗在项目页 / inbox query 下会带锚点发问。
|
||||
- Modify: `frontend/src/pages/project-board/ProjectBoardPage.tsx`
|
||||
- 项目页“打开 AI”入口改成带 `project_id` 跳转,确保页面级强锚点入口真正可达。
|
||||
|
||||
### Phase 2B: Route And Context Governance Page
|
||||
|
||||
- Create: `backend/app/schemas/query_route_governance.py`
|
||||
- 定义 summary/list/detail 的 response schema。
|
||||
- Create: `backend/app/services/adaptive_rag_service/mixin_query_route_governance.py`
|
||||
- 新增 `summary / list / detail` 只读查询能力,封装 `query_route_memories` 的聚合、筛选和详情序列化。
|
||||
- Modify: `backend/app/services/adaptive_rag_service/service.py`
|
||||
- 把治理 mixin 接入 `AdaptiveRAGService`。
|
||||
- Create: `backend/app/api/rag_api/routes_route_governance.py`
|
||||
- 提供 manager-only 的 `/rag/query-route-governance/*` API。
|
||||
- Modify: `backend/app/api/rag_api/__init__.py`
|
||||
- 挂载新路由。
|
||||
- Create: `backend/tests/test_query_route_governance_api.py`
|
||||
- 锁 summary/list/detail 合同和权限边界。
|
||||
- Create: `frontend/src/api/queryRouteGovernance.ts`
|
||||
- 前端 API 封装和类型定义。
|
||||
- Create: `frontend/src/pages/query-route-governance/QueryRouteGovernancePage.tsx`
|
||||
- 治理页编排容器,沿用 manager-only 子页模式。
|
||||
- Create: `frontend/src/pages/query-route-governance/components/QueryRouteGovernanceOverviewCards.tsx`
|
||||
- 顶部 KPI 卡。
|
||||
- Create: `frontend/src/pages/query-route-governance/components/QueryRouteGovernanceTable.tsx`
|
||||
- 样本列表与筛选器。
|
||||
- Create: `frontend/src/pages/query-route-governance/components/QueryRouteGovernanceDetailDialog.tsx`
|
||||
- detail drawer,展示 `reply_contract / probe_snapshot / query_profile / feedback_outcome`。
|
||||
- Create: `frontend/src/pages/QueryRouteGovernancePage.tsx`
|
||||
- 页面 barrel export。
|
||||
- Create: `frontend/src/pages/query-route-governance/QueryRouteGovernancePage.test.tsx`
|
||||
- 锁 manager-only UI、KPI、表格和 detail dialog。
|
||||
- Modify: `frontend/src/router/index.tsx`
|
||||
- 挂载 `/ai-config/query-route-governance`,并放进 manager-only guard。
|
||||
- Modify: `frontend/src/pages/ai-config/constants.ts`
|
||||
- 把治理页挂到 AI Config 治理工具入口。
|
||||
|
||||
### Docs And Inventory
|
||||
|
||||
- Modify: `CLAUDE.md`
|
||||
- Modify: `docs/architecture/ARCHITECTURE_INVENTORY.md`
|
||||
- Modify: `docs/architecture/CODE_REFERENCE_INDEX.md`
|
||||
- Modify: `docs/architecture/RUNTIME_GUARDRAILS.md`
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Lock Explicit Anchor Contract Before UI Changes
|
||||
|
||||
**Files:**
|
||||
- Modify: `backend/tests/test_rag_route_contract.py`
|
||||
- Create: `frontend/src/components/ai-steward/qaPageAnchor.test.ts`
|
||||
|
||||
- [ ] **Step 1: Add the failing backend contract test for explicit anchor forwarding**
|
||||
|
||||
Add a route-contract test that proves:
|
||||
|
||||
```python
|
||||
request = RAGAskRequest(
|
||||
question="这个项目怎么样",
|
||||
project_id=17,
|
||||
source_chat_id="pmflow-project-17",
|
||||
)
|
||||
kwargs = rag_ask_contract.build_kernel_ingress_kwargs(
|
||||
request=request,
|
||||
current_user=SimpleNamespace(id=1, tenant_id=1),
|
||||
)
|
||||
|
||||
assert kwargs["project_hint_id"] == 17
|
||||
assert kwargs["channel_ref"] == "pmflow-project-17"
|
||||
assert kwargs["ingress_options"]["rag_request_context"]["project_id"] == 17
|
||||
assert kwargs["ingress_options"]["rag_request_context"]["source_chat_id"] == "pmflow-project-17"
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Add the failing front-end page anchor parser tests**
|
||||
|
||||
Cover at least these cases:
|
||||
|
||||
```ts
|
||||
expect(resolveQAPageAnchor("/projects/17/board", "")).toMatchObject({
|
||||
projectId: 17,
|
||||
anchorType: "project",
|
||||
});
|
||||
|
||||
expect(resolveQAPageAnchor("/inbox", "?project_id=19")).toMatchObject({
|
||||
projectId: 19,
|
||||
origin: "search_param",
|
||||
});
|
||||
|
||||
expect(resolveQAPageAnchor("/ai-steward", "?tab=qa&project_id=23&source_chat_id=chat-23")).toMatchObject({
|
||||
projectId: 23,
|
||||
sourceChatId: "chat-23",
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Run the focused tests and confirm they fail**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
cd /Users/yuuuu/Documents/AIProjectManger && PYTHONPATH=backend backend/venv/bin/python -m pytest backend/tests/test_rag_route_contract.py -k "project_id or source_chat_id" -v
|
||||
cd /Users/yuuuu/Documents/AIProjectManger/frontend && npx vitest run src/components/ai-steward/qaPageAnchor.test.ts
|
||||
```
|
||||
|
||||
Expected: FAIL because the parser file does not exist yet and the forwarding assertions are not locked.
|
||||
|
||||
### Task 2: Implement Stable Web Anchor Resolution In The Global Floating Chat
|
||||
|
||||
**Files:**
|
||||
- Create: `frontend/src/components/ai-steward/qaPageAnchor.ts`
|
||||
- Modify: `frontend/src/components/ai-steward/FloatingQAChat.tsx`
|
||||
- Modify: `frontend/src/components/ai-steward/QAConversationStatusBar.tsx`
|
||||
- Modify: `frontend/src/components/ai-steward/FloatingQAChat.test.tsx`
|
||||
- Test: `frontend/src/components/ai-steward/qaPageAnchor.test.ts`
|
||||
- Test: `frontend/src/components/ai-steward/FloatingQAChat.test.tsx`
|
||||
|
||||
- [ ] **Step 1: Implement the minimal page-anchor resolver**
|
||||
|
||||
Add a helper that only consumes stable web hints:
|
||||
|
||||
```ts
|
||||
export interface QAPageAnchor {
|
||||
projectId: number | null;
|
||||
sourceChatId: string | null;
|
||||
anchorType: "project" | "project_filter" | "chat_scope" | "none";
|
||||
anchorLabel: string | null;
|
||||
origin: "pathname" | "search_param" | "none";
|
||||
}
|
||||
```
|
||||
|
||||
Parsing rules:
|
||||
|
||||
- `/projects/:id/board` and `/projects/:id/charter/:charterId` -> `projectId`
|
||||
- query `project_id` -> `projectId`
|
||||
- query `source_chat_id` -> `sourceChatId`
|
||||
- no strong anchor -> `anchorType="none"`
|
||||
|
||||
- [ ] **Step 2: Wire `FloatingQAChat` to send explicit anchors**
|
||||
|
||||
When submitting a question:
|
||||
|
||||
```ts
|
||||
const pageAnchor = resolveQAPageAnchor(location.pathname, location.search);
|
||||
await aiStewardApi.askRag({
|
||||
...body,
|
||||
project_id: pageAnchor.projectId ?? undefined,
|
||||
source_chat_id: pageAnchor.sourceChatId ?? undefined,
|
||||
});
|
||||
```
|
||||
|
||||
Also keep the current session-state logic intact; page anchor only supplements request scope and UI display.
|
||||
|
||||
- [ ] **Step 3: Extend the status bar with explicit page-anchor state**
|
||||
|
||||
Render a compact line such as:
|
||||
|
||||
```tsx
|
||||
当前页面锚点:项目 #17
|
||||
```
|
||||
|
||||
Only show it when `pageAnchor.anchorType !== "none"`.
|
||||
|
||||
- [ ] **Step 4: Update floating-chat tests to assert anchor forwarding**
|
||||
|
||||
Add a case that mounts `FloatingQAChat` under a project-board route and asserts:
|
||||
|
||||
```ts
|
||||
expect(aiStewardApiMock.askRag).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ project_id: 17 })
|
||||
);
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Re-run the focused front-end tests**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
cd /Users/yuuuu/Documents/AIProjectManger/frontend && npx vitest run src/components/ai-steward/qaPageAnchor.test.ts src/components/ai-steward/FloatingQAChat.test.tsx
|
||||
```
|
||||
|
||||
Expected: PASS
|
||||
|
||||
### Task 3: Route Existing Project Entry Points Into Anchor-Bearing AI URLs
|
||||
|
||||
**Files:**
|
||||
- Modify: `frontend/src/pages/project-board/ProjectBoardPage.tsx`
|
||||
- Modify: `frontend/src/components/ai-steward/FloatingQAChat.test.tsx`
|
||||
|
||||
- [ ] **Step 1: Write the failing project-entry test**
|
||||
|
||||
Lock that project-board “打开 AI” jumps to an anchored URL:
|
||||
|
||||
```ts
|
||||
expect(navigate).toHaveBeenCalledWith("/ai-steward?tab=qa&project_id=17");
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run the focused test to confirm it fails**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
cd /Users/yuuuu/Documents/AIProjectManger/frontend && npx vitest run src/components/ai-steward/FloatingQAChat.test.tsx
|
||||
```
|
||||
|
||||
Expected: FAIL because project-board still routes to bare `/ai-steward`.
|
||||
|
||||
- [ ] **Step 3: Update project-board AI entrypoints**
|
||||
|
||||
Change the existing project-board entry from:
|
||||
|
||||
```ts
|
||||
navigate("/ai-steward")
|
||||
```
|
||||
|
||||
to:
|
||||
|
||||
```ts
|
||||
navigate(`/ai-steward?tab=qa&project_id=${id}`)
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Re-run the affected test**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
cd /Users/yuuuu/Documents/AIProjectManger/frontend && npx vitest run src/components/ai-steward/FloatingQAChat.test.tsx
|
||||
```
|
||||
|
||||
Expected: PASS
|
||||
|
||||
### Task 4: Lock Route Governance API Contract
|
||||
|
||||
**Files:**
|
||||
- Create: `backend/tests/test_query_route_governance_api.py`
|
||||
|
||||
- [ ] **Step 1: Write the failing summary/list/detail API tests**
|
||||
|
||||
Seed `query_route_memories` rows that cover:
|
||||
|
||||
- `route_mode_predicted != route_mode_effective`
|
||||
- `feedback_outcome.rating == "negative"`
|
||||
- `reply_contract.mode == "clarify"`
|
||||
|
||||
Assert summary returns:
|
||||
|
||||
```python
|
||||
assert payload["data"]["kpis"]["sample_total"] == 3
|
||||
assert payload["data"]["kpis"]["route_mismatch_total"] == 1
|
||||
assert payload["data"]["kpis"]["negative_feedback_total"] == 1
|
||||
assert payload["data"]["kpis"]["clarify_total"] == 1
|
||||
```
|
||||
|
||||
Assert list returns rows with:
|
||||
|
||||
```python
|
||||
assert item["route_mode_predicted"] == "internal"
|
||||
assert item["route_mode_effective"] == "external"
|
||||
assert item["feedback_rating"] == "negative"
|
||||
assert item["is_clarify"] is True
|
||||
```
|
||||
|
||||
Assert detail returns:
|
||||
|
||||
```python
|
||||
assert detail["probe_snapshot"]["effective_source_policy"] == "external"
|
||||
assert detail["query_profile"]["source_policy"] == "internal"
|
||||
assert detail["reply_contract"]["mode"] == "clarify"
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Add the failing manager-only permission test**
|
||||
|
||||
Assert non-manager gets `403`, manager gets `200`.
|
||||
|
||||
- [ ] **Step 3: Run the backend test and confirm it fails**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
cd /Users/yuuuu/Documents/AIProjectManger && PYTHONPATH=backend backend/venv/bin/python -m pytest backend/tests/test_query_route_governance_api.py -v
|
||||
```
|
||||
|
||||
Expected: FAIL because the API and service methods do not exist yet.
|
||||
|
||||
### Task 5: Implement Read-Only Route Governance Backend
|
||||
|
||||
**Files:**
|
||||
- Create: `backend/app/schemas/query_route_governance.py`
|
||||
- Create: `backend/app/services/adaptive_rag_service/mixin_query_route_governance.py`
|
||||
- Modify: `backend/app/services/adaptive_rag_service/service.py`
|
||||
- Create: `backend/app/api/rag_api/routes_route_governance.py`
|
||||
- Modify: `backend/app/api/rag_api/__init__.py`
|
||||
- Test: `backend/tests/test_query_route_governance_api.py`
|
||||
|
||||
- [ ] **Step 1: Add the minimal governance schemas**
|
||||
|
||||
Define Pydantic models for:
|
||||
|
||||
- `QueryRouteGovernanceSummaryResponse`
|
||||
- `QueryRouteGovernanceListItem`
|
||||
- `QueryRouteGovernanceDetailResponse`
|
||||
|
||||
Keep the frontend-safe shape small; do not expose `question_embedding`.
|
||||
|
||||
- [ ] **Step 2: Implement summary/list/detail service methods**
|
||||
|
||||
Add read-only methods with project filter and status filters:
|
||||
|
||||
```python
|
||||
async def get_query_route_governance_summary(...)
|
||||
async def list_query_route_governance_items(...)
|
||||
async def get_query_route_governance_detail(...)
|
||||
```
|
||||
|
||||
Derived fields:
|
||||
|
||||
- `is_route_mismatch = route_mode_predicted != route_mode_effective`
|
||||
- `feedback_rating = feedback_outcome["rating"]`
|
||||
- `is_clarify = reply_contract["mode"] == "clarify"`
|
||||
|
||||
- [ ] **Step 3: Expose manager-only API routes**
|
||||
|
||||
Add:
|
||||
|
||||
- `GET /rag/query-route-governance/summary`
|
||||
- `GET /rag/query-route-governance/items`
|
||||
- `GET /rag/query-route-governance/items/{route_memory_id}`
|
||||
|
||||
Use `require_roles("ADMIN", "BOSS")`.
|
||||
|
||||
- [ ] **Step 4: Re-run the governance API test**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
cd /Users/yuuuu/Documents/AIProjectManger && PYTHONPATH=backend backend/venv/bin/python -m pytest backend/tests/test_query_route_governance_api.py -v
|
||||
```
|
||||
|
||||
Expected: PASS
|
||||
|
||||
### Task 6: Build The Manager-Only Route Governance Page
|
||||
|
||||
**Files:**
|
||||
- Create: `frontend/src/api/queryRouteGovernance.ts`
|
||||
- Create: `frontend/src/pages/query-route-governance/QueryRouteGovernancePage.tsx`
|
||||
- Create: `frontend/src/pages/query-route-governance/components/QueryRouteGovernanceOverviewCards.tsx`
|
||||
- Create: `frontend/src/pages/query-route-governance/components/QueryRouteGovernanceTable.tsx`
|
||||
- Create: `frontend/src/pages/query-route-governance/components/QueryRouteGovernanceDetailDialog.tsx`
|
||||
- Create: `frontend/src/pages/QueryRouteGovernancePage.tsx`
|
||||
- Create: `frontend/src/pages/query-route-governance/QueryRouteGovernancePage.test.tsx`
|
||||
- Modify: `frontend/src/router/index.tsx`
|
||||
- Modify: `frontend/src/pages/ai-config/constants.ts`
|
||||
|
||||
- [ ] **Step 1: Write the failing page test**
|
||||
|
||||
Cover these cases:
|
||||
|
||||
```tsx
|
||||
it("renders manager-only guard copy for non-manager users", ...)
|
||||
it("renders KPI cards and table rows from governance summary/list data", ...)
|
||||
it("opens detail dialog and shows probe snapshot / feedback outcome", ...)
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run the front-end test to confirm it fails**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
cd /Users/yuuuu/Documents/AIProjectManger/frontend && npx vitest run src/pages/query-route-governance/QueryRouteGovernancePage.test.tsx
|
||||
```
|
||||
|
||||
Expected: FAIL because the page and API wrapper do not exist yet.
|
||||
|
||||
- [ ] **Step 3: Add the front-end API wrapper**
|
||||
|
||||
Expose:
|
||||
|
||||
```ts
|
||||
getSummary(params?)
|
||||
listItems(params?)
|
||||
getDetail(routeMemoryId)
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Build the governance page using the semantic-facts board pattern**
|
||||
|
||||
Page layout:
|
||||
|
||||
- top header + feature guide
|
||||
- KPI cards
|
||||
- filterable table
|
||||
- detail drawer
|
||||
|
||||
Suggested table columns:
|
||||
|
||||
- question text
|
||||
- predicted/effective route
|
||||
- decision origin
|
||||
- feedback rating
|
||||
- flags (`mismatch`, `clarify`, `negative`)
|
||||
- created_at
|
||||
|
||||
- [ ] **Step 5: Add manager-only route and AI Config entry**
|
||||
|
||||
Router:
|
||||
|
||||
```tsx
|
||||
{ path: "ai-config/query-route-governance", element: <ManagerOnlyGuard><QueryRouteGovernancePage /></ManagerOnlyGuard> }
|
||||
```
|
||||
|
||||
Governance tools card:
|
||||
|
||||
```ts
|
||||
{
|
||||
id: "query-route-governance",
|
||||
name: "Route 治理看板",
|
||||
to: "/ai-config/query-route-governance",
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 6: Re-run the front-end page test**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
cd /Users/yuuuu/Documents/AIProjectManger/frontend && npx vitest run src/pages/query-route-governance/QueryRouteGovernancePage.test.tsx
|
||||
```
|
||||
|
||||
Expected: PASS
|
||||
|
||||
### Task 7: Docs Sync And Final Verification
|
||||
|
||||
**Files:**
|
||||
- Modify: `CLAUDE.md`
|
||||
- Modify: `docs/architecture/ARCHITECTURE_INVENTORY.md`
|
||||
- Modify: `docs/architecture/CODE_REFERENCE_INDEX.md`
|
||||
- Modify: `docs/architecture/RUNTIME_GUARDRAILS.md`
|
||||
|
||||
- [ ] **Step 1: Sync docs with new page, route, and anchor rules**
|
||||
|
||||
Document:
|
||||
|
||||
- explicit web-anchor baseline (`project_id/source_chat_id` from stable page contexts)
|
||||
- manager-only route governance page and API
|
||||
- “治理页只读、不自动改路由”的 guardrail
|
||||
|
||||
- [ ] **Step 2: Run the focused backend verification**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
cd /Users/yuuuu/Documents/AIProjectManger && backend/venv/bin/python -m py_compile \
|
||||
backend/app/schemas/query_route_governance.py \
|
||||
backend/app/services/adaptive_rag_service/mixin_query_route_governance.py \
|
||||
backend/app/api/rag_api/routes_route_governance.py
|
||||
|
||||
cd /Users/yuuuu/Documents/AIProjectManger && PYTHONPATH=backend backend/venv/bin/python -m pytest \
|
||||
backend/tests/test_rag_route_contract.py -k "project_id or source_chat_id" -v
|
||||
|
||||
cd /Users/yuuuu/Documents/AIProjectManger && PYTHONPATH=backend backend/venv/bin/python -m pytest \
|
||||
backend/tests/test_query_route_governance_api.py -v
|
||||
```
|
||||
|
||||
Expected: PASS
|
||||
|
||||
- [ ] **Step 3: Run the focused front-end verification**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
cd /Users/yuuuu/Documents/AIProjectManger/frontend && npx vitest run \
|
||||
src/components/ai-steward/qaPageAnchor.test.ts \
|
||||
src/components/ai-steward/FloatingQAChat.test.tsx \
|
||||
src/pages/query-route-governance/QueryRouteGovernancePage.test.tsx
|
||||
|
||||
cd /Users/yuuuu/Documents/AIProjectManger/frontend && npx tsc --noEmit
|
||||
cd /Users/yuuuu/Documents/AIProjectManger/frontend && npm run build
|
||||
```
|
||||
|
||||
Expected: PASS
|
||||
|
||||
- [ ] **Step 4: Restart backend and confirm runtime recovery**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
cd /Users/yuuuu/Documents/AIProjectManger && docker compose restart backend
|
||||
cd /Users/yuuuu/Documents/AIProjectManger && curl -sS -o /dev/null -w '%{http_code}\n' http://127.0.0.1:8000/docs
|
||||
cd /Users/yuuuu/Documents/AIProjectManger && docker compose ps backend
|
||||
```
|
||||
|
||||
Expected:
|
||||
|
||||
- `/docs` returns `200`
|
||||
- `pmflow-backend` returns to `healthy`
|
||||
|
||||
---
|
||||
|
||||
## Acceptance Checklist
|
||||
|
||||
- 页面级全局浮窗在项目页、Inbox `project_id` 过滤页、AI Steward `project_id` query 下都会把锚点显式带进 `/rag/ask`
|
||||
- 项目页进入 AI 的入口不再裸跳 `/ai-steward`
|
||||
- `QAConversationStatusBar` 能让用户看见当前页面锚点
|
||||
- manager-only `Route 治理看板` 已可查看 summary/list/detail
|
||||
- 治理页能直接筛出 `mismatch / clarify / negative feedback` 样本
|
||||
- 文档、测试、build、backend restart 已完成
|
||||
|
|
@ -0,0 +1,372 @@
|
|||
# Three-Mode Interaction And Context Upgrade Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** 让 PMFlow 的三类用户可见模式在同一套交互骨架下可见、可控、可纠偏:`planmode(requirement_plan/draft)` 先做顶层状态显化,`query.answer` 再按 `internal / external` 给出显式来源合同与上下文续写提示。
|
||||
|
||||
**Architecture:** 先锁共享基线,不改语义边界:保留 `plan_mode gate -> non-plan query.answer -> internal/external split` 的现有层级,只把后端已经存在的 `route_decision / routing_snapshot / session_state / requirement plan status` 暴露给前端。前端新增一套轻量状态条和回答元信息卡,查询侧挂在 `FloatingQAChat`,planmode 侧挂在 `Inbox` 与 AI PMO handoff 结果,不另起一套聊天壳。
|
||||
|
||||
**Tech Stack:** FastAPI, SQLAlchemy 2.0, PMFlow AdaptiveRAG, AI PMO kernel, React 18, TypeScript, Vitest, pytest
|
||||
|
||||
---
|
||||
|
||||
## Baseline File Map
|
||||
|
||||
### Shared Query Contract
|
||||
|
||||
- Modify: `backend/app/api/rag_ask_contract.py`
|
||||
- 保持 `/rag/ask` 现有包络不变,补齐给前台可消费的 `route_decision / clarification_code / clarification_candidates / answer_mode / grounding_status / policy_version / citations / sources_used / evidence_pack` 规范化输出。
|
||||
- Modify: `backend/app/services/adaptive_rag_service/mixin_qa_session_list_ops_p1.py`
|
||||
- 让会话详情把每轮回答的 route / evidence / answer mode 元信息带出来,不再只有 `question / answer / sources`。
|
||||
- Modify: `frontend/src/api/aiSteward.ts`
|
||||
- 前端类型补齐 richer QA payload,并新增 `getQASessionState(sessionId)` 读取 `last_subject / pending_clarification / rewrite_history`。
|
||||
- Modify: `backend/tests/test_ai_pmo_qa_http_mainline_e2e.py`
|
||||
- 锁 `/rag/ask` HTTP 响应合同。
|
||||
- Modify: `backend/tests/test_ai_pmo_ingress_mainline.py`
|
||||
- 锁 QA session detail / state replay 合同。
|
||||
|
||||
### Query UI Shell
|
||||
|
||||
- Create: `frontend/src/components/ai-steward/QAConversationStatusBar.tsx`
|
||||
- 显示当前对象、当前来源模式、待澄清状态、是否仍在继承上一轮上下文。
|
||||
- Create: `frontend/src/components/ai-steward/QAResponseMetaCard.tsx`
|
||||
- 每条回答下显示 `内部/外部/待澄清`、grounding 状态、来源摘要、推荐下一步。
|
||||
- Modify: `frontend/src/components/ai-steward/FloatingQAChat.tsx`
|
||||
- 接入 status bar、message-level meta card、route clarify 快捷动作、session state 拉取与“清空上下文/新话题”入口。
|
||||
- Create: `frontend/src/components/ai-steward/FloatingQAChat.test.tsx`
|
||||
- 锁状态条、route clarify 动作、元信息卡渲染。
|
||||
|
||||
### Plan Mode Surface
|
||||
|
||||
- Modify: `backend/app/api/ai_pmo_api/helpers_inbox_payload.py`
|
||||
- 给 inbox work item summary 增加 planmode 相关轻量字段,例如 `plan_mode / plan_status / requirement_plan_context_summary`。
|
||||
- Modify: `backend/app/api/ai_pmo_api/routes_core.py`
|
||||
- 统一 `process-text` 与 `work-items` 输出里的 planmode 相关字段,避免前端靠 `reply_template_key` 猜。
|
||||
- Modify: `frontend/src/api/aiPmo.ts`
|
||||
- 补齐 AI PMO result / work item summary 的 planmode 类型。
|
||||
- Modify: `frontend/src/types/inbox.ts`
|
||||
- 让 inbox item view model 能承载 `plan_status / plan_mode / requirement_plan_context_summary`。
|
||||
- Modify: `frontend/src/components/inbox/InboxListItem.tsx`
|
||||
- 在详情卡里显式展示“当前需求模式状态 / 下一步动作 / 草稿待确认”。
|
||||
- Modify: `frontend/src/pages/inbox/InboxPage.tsx`
|
||||
- 把 mounted run / work item 映射到统一的 planmode 视图文案。
|
||||
- Create: `frontend/src/components/inbox/InboxListItem.test.tsx`
|
||||
- 锁 planmode 状态渲染与 CTA 文案。
|
||||
|
||||
### Docs And Inventory
|
||||
|
||||
- Modify: `CLAUDE.md`
|
||||
- Modify: `docs/architecture/ARCHITECTURE_INVENTORY.md`
|
||||
- Modify: `docs/architecture/CODE_REFERENCE_INDEX.md`
|
||||
- Modify: `docs/architecture/RUNTIME_GUARDRAILS.md`
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Lock The Shared QA Contract
|
||||
|
||||
**Files:**
|
||||
- Modify: `backend/tests/test_ai_pmo_qa_http_mainline_e2e.py`
|
||||
- Modify: `backend/tests/test_ai_pmo_ingress_mainline.py`
|
||||
- Modify: `frontend/src/api/aiSteward.ts`
|
||||
|
||||
- [ ] **Step 1: Write the failing `/rag/ask` contract test**
|
||||
|
||||
Add an HTTP contract assertion that `/rag/ask` returns:
|
||||
|
||||
```python
|
||||
assert payload["data"]["route_decision"]["effective_source_policy"] == "internal"
|
||||
assert payload["data"]["clarification_code"] is None
|
||||
assert payload["data"]["answer_mode"] == "fast_path"
|
||||
assert payload["data"]["evidence_pack"]["summary"]["conclusion"] == "当前主链状态正常。"
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Write the failing session detail/state test**
|
||||
|
||||
Add sqlite-backed tests that prove:
|
||||
|
||||
```python
|
||||
detail = await service.get_qa_session_detail(session_id=session_id, user_id=101)
|
||||
assert detail["messages"][0]["route_decision"]["effective_source_policy"] == "external"
|
||||
assert detail["messages"][0]["grounding_status"] == "insufficient_evidence"
|
||||
|
||||
state = await service.get_session_state_replay(session_id=session_id, user_id=101)
|
||||
assert state["last_subject"] == "Alpha项目"
|
||||
assert state["pending_clarification"]["kind"] == "query_route_selection"
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Run the focused backend tests to confirm they fail**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
cd /Users/yuuuu/Documents/AIProjectManger && backend/venv/bin/python -m pytest backend/tests/test_ai_pmo_qa_http_mainline_e2e.py -k "rag_ask" -v
|
||||
cd /Users/yuuuu/Documents/AIProjectManger && backend/venv/bin/python -m pytest backend/tests/test_ai_pmo_ingress_mainline.py -k "qa_session_detail or session_state_replay" -v
|
||||
```
|
||||
|
||||
Expected: FAIL because the richer QA/session contract is not exposed yet.
|
||||
|
||||
### Task 2: Implement The Shared QA Metadata Baseline
|
||||
|
||||
**Files:**
|
||||
- Modify: `backend/app/api/rag_ask_contract.py`
|
||||
- Modify: `backend/app/services/adaptive_rag_service/mixin_qa_session_list_ops_p1.py`
|
||||
- Modify: `frontend/src/api/aiSteward.ts`
|
||||
- Test: `backend/tests/test_ai_pmo_qa_http_mainline_e2e.py`
|
||||
- Test: `backend/tests/test_ai_pmo_ingress_mainline.py`
|
||||
|
||||
- [ ] **Step 1: Normalize `/rag/ask` payload fields**
|
||||
|
||||
Keep the existing response envelope, but explicitly preserve:
|
||||
|
||||
```python
|
||||
payload["route_decision"] = dict(payload.get("route_decision") or {})
|
||||
payload["clarification_code"] = str(payload.get("clarification_code") or "").strip() or None
|
||||
payload["clarification_candidates"] = _normalize_dict_list(payload.get("clarification_candidates"))
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Extend QA session detail payload**
|
||||
|
||||
When serializing session messages, add:
|
||||
|
||||
```python
|
||||
"route_decision": row.routing_snapshot.get("query_route") if isinstance(row.routing_snapshot, dict) else {},
|
||||
"answer_mode": row.answer_mode,
|
||||
"grounding_status": row.grounding_status,
|
||||
"policy_version": row.policy_version,
|
||||
"citations": row.citations or [],
|
||||
"sources_used": row.sources_used or [],
|
||||
"evidence_pack": row.evidence_pack or {},
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Update front-end request/response types**
|
||||
|
||||
Extend `QAAskResponse` and `QASessionMessage` to accept:
|
||||
|
||||
```ts
|
||||
route_decision?: Record<string, unknown> | null;
|
||||
clarification_code?: string | null;
|
||||
clarification_candidates?: Array<Record<string, unknown>>;
|
||||
answer_mode?: string | null;
|
||||
grounding_status?: string | null;
|
||||
policy_version?: string | null;
|
||||
citations?: Array<Record<string, unknown>>;
|
||||
sources_used?: Array<Record<string, unknown>>;
|
||||
evidence_pack?: Record<string, unknown> | null;
|
||||
```
|
||||
|
||||
Also add:
|
||||
|
||||
```ts
|
||||
async getQASessionState(sessionId: number): Promise<QASessionStateReplay>
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Re-run the focused backend tests**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
cd /Users/yuuuu/Documents/AIProjectManger && backend/venv/bin/python -m pytest backend/tests/test_ai_pmo_qa_http_mainline_e2e.py -k "rag_ask" -v
|
||||
cd /Users/yuuuu/Documents/AIProjectManger && backend/venv/bin/python -m pytest backend/tests/test_ai_pmo_ingress_mainline.py -k "qa_session_detail or session_state_replay" -v
|
||||
```
|
||||
|
||||
Expected: PASS
|
||||
|
||||
### Task 3: Build The Query Status Shell
|
||||
|
||||
**Files:**
|
||||
- Create: `frontend/src/components/ai-steward/QAConversationStatusBar.tsx`
|
||||
- Create: `frontend/src/components/ai-steward/QAResponseMetaCard.tsx`
|
||||
- Modify: `frontend/src/components/ai-steward/FloatingQAChat.tsx`
|
||||
- Create: `frontend/src/components/ai-steward/FloatingQAChat.test.tsx`
|
||||
- Test: `frontend/src/components/ai-steward/FloatingQAChat.test.tsx`
|
||||
|
||||
- [ ] **Step 1: Write the failing front-end tests**
|
||||
|
||||
Cover these cases:
|
||||
|
||||
```tsx
|
||||
it("shows current subject and source mode in the status bar", ...)
|
||||
it("renders route clarify actions when clarification_code=query_route_clarify", ...)
|
||||
it("renders answer meta card with grounding and source summary", ...)
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run the front-end test to verify it fails**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
cd /Users/yuuuu/Documents/AIProjectManger/frontend && npx vitest run src/components/ai-steward/FloatingQAChat.test.tsx
|
||||
```
|
||||
|
||||
Expected: FAIL because the new status shell does not exist yet.
|
||||
|
||||
- [ ] **Step 3: Implement the session-level status bar**
|
||||
|
||||
Render a compact bar above the message list with:
|
||||
|
||||
```tsx
|
||||
<QAConversationStatusBar
|
||||
currentSubject={sessionState.last_subject}
|
||||
effectiveSourcePolicy={lastRouteDecision?.effective_source_policy}
|
||||
pendingClarification={sessionState.pending_clarification}
|
||||
onClearContext={startNewChat}
|
||||
/>
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Implement the per-answer meta card**
|
||||
|
||||
For each message, render a compact card that surfaces:
|
||||
|
||||
```tsx
|
||||
<QAResponseMetaCard
|
||||
routeDecision={msg.route_decision}
|
||||
groundingStatus={msg.grounding_status}
|
||||
sourcesUsed={msg.sources_used}
|
||||
evidencePack={msg.evidence_pack}
|
||||
/>
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Wire route clarify actions**
|
||||
|
||||
When the last answer returns `clarification_code === "query_route_clarify"`, show two quick actions that submit the existing protocol text:
|
||||
|
||||
```tsx
|
||||
onChooseInternal={() => runQuestionRequest({ prompt: "B" })}
|
||||
onChooseExternal={() => runQuestionRequest({ prompt: "A" })}
|
||||
```
|
||||
|
||||
- [ ] **Step 6: Re-run the front-end test**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
cd /Users/yuuuu/Documents/AIProjectManger/frontend && npx vitest run src/components/ai-steward/FloatingQAChat.test.tsx
|
||||
```
|
||||
|
||||
Expected: PASS
|
||||
|
||||
### Task 4: Surface Plan Mode In AI PMO And Inbox
|
||||
|
||||
**Files:**
|
||||
- Modify: `backend/tests/test_ai_pmo_ingress_mainline.py`
|
||||
- Modify: `backend/app/api/ai_pmo_api/helpers_inbox_payload.py`
|
||||
- Modify: `backend/app/api/ai_pmo_api/routes_core.py`
|
||||
- Modify: `frontend/src/api/aiPmo.ts`
|
||||
- Modify: `frontend/src/types/inbox.ts`
|
||||
- Modify: `frontend/src/components/inbox/InboxListItem.tsx`
|
||||
- Modify: `frontend/src/pages/inbox/InboxPage.tsx`
|
||||
- Create: `frontend/src/components/inbox/InboxListItem.test.tsx`
|
||||
- Test: `frontend/src/components/inbox/InboxListItem.test.tsx`
|
||||
|
||||
- [ ] **Step 1: Write the failing backend test for planmode summary fields**
|
||||
|
||||
Add coverage that proves work item summary/detail exposes:
|
||||
|
||||
```python
|
||||
assert item["plan_mode"] is True
|
||||
assert item["plan_status"] == "collecting"
|
||||
assert item["requirement_plan_context_summary"]["title"] == "换季搭配推荐"
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run the targeted backend test to verify it fails**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
cd /Users/yuuuu/Documents/AIProjectManger && backend/venv/bin/python -m pytest backend/tests/test_ai_pmo_ingress_mainline.py -k "plan_mode or requirement_plan_context_summary" -v
|
||||
```
|
||||
|
||||
Expected: FAIL because inbox/process-text payloads do not expose these fields yet.
|
||||
|
||||
- [ ] **Step 3: Serialize planmode summary fields in AI PMO payloads**
|
||||
|
||||
Normalize a small summary shape instead of leaking full runtime packet:
|
||||
|
||||
```python
|
||||
"plan_mode": bool(summary.get("plan_mode")),
|
||||
"plan_status": normalized_plan_status,
|
||||
"requirement_plan_context_summary": {
|
||||
"title": card.get("title"),
|
||||
"goal": card.get("goal"),
|
||||
"missing_info": card.get("missing_info", [])[:3],
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Update front-end inbox types and rendering**
|
||||
|
||||
Show:
|
||||
|
||||
```tsx
|
||||
<Badge variant="outline">{item.source_work_item?.plan_status || "draft_pending_confirm"}</Badge>
|
||||
<div>当前模式:需求模式</div>
|
||||
<div>下一步:补信息 / 选 A-B-C / 草稿确认</div>
|
||||
```
|
||||
|
||||
Keep the existing draft actions intact.
|
||||
|
||||
- [ ] **Step 5: Write and run the front-end inbox test**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
cd /Users/yuuuu/Documents/AIProjectManger/frontend && npx vitest run src/components/inbox/InboxListItem.test.tsx
|
||||
```
|
||||
|
||||
Expected: FAIL first, then PASS after the new planmode summary UI is wired.
|
||||
|
||||
### Task 5: Verification And Docs Sync
|
||||
|
||||
**Files:**
|
||||
- Modify: `CLAUDE.md`
|
||||
- Modify: `docs/architecture/ARCHITECTURE_INVENTORY.md`
|
||||
- Modify: `docs/architecture/CODE_REFERENCE_INDEX.md`
|
||||
- Modify: `docs/architecture/RUNTIME_GUARDRAILS.md`
|
||||
|
||||
- [ ] **Step 1: Update docs for the new three-mode front-end contract**
|
||||
|
||||
Record:
|
||||
- `plan_mode` is still the top-level gate
|
||||
- `internal / external` remain submodes under `query.answer`
|
||||
- `FloatingQAChat` now shows context/source status instead of only answer text
|
||||
- inbox exposes requirement-plan summary state
|
||||
|
||||
- [ ] **Step 2: Run focused backend verification**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
cd /Users/yuuuu/Documents/AIProjectManger && backend/venv/bin/python -m pytest \
|
||||
backend/tests/test_ai_pmo_qa_http_mainline_e2e.py \
|
||||
backend/tests/test_ai_pmo_ingress_mainline.py \
|
||||
backend/tests/test_adaptive_rag_mainline_contract.py -v
|
||||
```
|
||||
|
||||
Expected: PASS
|
||||
|
||||
- [ ] **Step 3: Run backend compile verification**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
cd /Users/yuuuu/Documents/AIProjectManger && backend/venv/bin/python -m py_compile \
|
||||
backend/app/api/rag_ask_contract.py \
|
||||
backend/app/services/adaptive_rag_service/mixin_qa_session_list_ops_p1.py \
|
||||
backend/app/api/ai_pmo_api/helpers_inbox_payload.py \
|
||||
backend/app/api/ai_pmo_api/routes_core.py
|
||||
```
|
||||
|
||||
Expected: exit 0
|
||||
|
||||
- [ ] **Step 4: Run front-end verification**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
cd /Users/yuuuu/Documents/AIProjectManger/frontend && npx vitest run \
|
||||
src/components/ai-steward/FloatingQAChat.test.tsx \
|
||||
src/components/inbox/InboxListItem.test.tsx
|
||||
cd /Users/yuuuu/Documents/AIProjectManger/frontend && npx tsc --noEmit
|
||||
cd /Users/yuuuu/Documents/AIProjectManger/frontend && npm run build
|
||||
```
|
||||
|
||||
Expected: PASS
|
||||
|
|
@ -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`
|
||||
|
|
|
|||
|
|
@ -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<string, unknown> | 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 {
|
||||
|
|
|
|||
|
|
@ -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<Record<string, unknown>>;
|
||||
pending_clarification?: Record<string, unknown> | 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<Record<string, unknown>>;
|
||||
answer_mode?: string | null;
|
||||
grounding_status?: string | null;
|
||||
policy_version?: string | null;
|
||||
citations?: Array<Record<string, unknown>>;
|
||||
sources_used?: Array<Record<string, unknown>>;
|
||||
evidence_pack?: Record<string, unknown> | null;
|
||||
}
|
||||
|
||||
export interface QASessionDetail {
|
||||
|
|
@ -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<Record<string, unknown>>;
|
||||
answer_mode?: string | null;
|
||||
grounding_status?: string | null;
|
||||
policy_version?: string | null;
|
||||
citations?: Array<Record<string, unknown>>;
|
||||
sources_used?: Array<Record<string, unknown>>;
|
||||
evidence_pack?: Record<string, unknown> | null;
|
||||
}
|
||||
|
||||
export const aiStewardApi = {
|
||||
|
|
@ -413,6 +452,10 @@ export const aiStewardApi = {
|
|||
const raw = await apiRequest<ApiEnvelope<QASessionDetail>>(`/rag/qa-sessions/${sessionId}`);
|
||||
return unwrap(raw);
|
||||
},
|
||||
async getQASessionState(sessionId: number): Promise<QASessionStateReplay> {
|
||||
const raw = await apiRequest<ApiEnvelope<QASessionStateReplay>>(`/rag/qa-sessions/${sessionId}/state`);
|
||||
return unwrap(raw);
|
||||
},
|
||||
async renameQASession(sessionId: number, title: string): Promise<{ title: string }> {
|
||||
const raw = await apiRequest<ApiEnvelope<{ title: string }>>(`/rag/qa-sessions/${sessionId}/title`, {
|
||||
method: "PATCH",
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ function unwrap<T>(raw: ApiEnvelope<T> | 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";
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,101 @@
|
|||
import { api } from "@/lib/api";
|
||||
|
||||
type ApiEnvelope<T> = { code: number; message: string; data: T };
|
||||
|
||||
function unwrap<T>(raw: ApiEnvelope<T> | T): T {
|
||||
if (raw && typeof raw === "object" && "data" in (raw as Record<string, unknown>)) {
|
||||
return (raw as ApiEnvelope<T>).data;
|
||||
}
|
||||
return raw as T;
|
||||
}
|
||||
|
||||
export interface QueryRouteGovernanceBucket {
|
||||
key: string;
|
||||
label: string;
|
||||
count: number;
|
||||
}
|
||||
|
||||
export interface QueryRouteGovernanceSummary {
|
||||
window_days: number;
|
||||
project_id?: number | null;
|
||||
kpis: {
|
||||
total_records: number;
|
||||
mismatch_count: number;
|
||||
clarify_count: number;
|
||||
negative_feedback_count: number;
|
||||
internal_count: number;
|
||||
external_count: number;
|
||||
uncertain_count: number;
|
||||
};
|
||||
route_modes: QueryRouteGovernanceBucket[];
|
||||
decision_origins: QueryRouteGovernanceBucket[];
|
||||
}
|
||||
|
||||
export interface QueryRouteGovernanceRecord {
|
||||
id: number;
|
||||
qa_record_id: number;
|
||||
project_id?: number | null;
|
||||
session_id?: number | null;
|
||||
session_title?: string | null;
|
||||
question_text: string;
|
||||
route_mode_predicted: string;
|
||||
route_mode_effective: string;
|
||||
decision_origin?: string | null;
|
||||
reply_mode?: string | null;
|
||||
feedback_rating?: string | null;
|
||||
feedback_helpfulness?: string | null;
|
||||
is_route_mismatch: boolean;
|
||||
is_clarify: boolean;
|
||||
anchor_summary?: string | null;
|
||||
answer_mode?: string | null;
|
||||
grounding_status?: string | null;
|
||||
asked_via?: string | null;
|
||||
created_at?: string | null;
|
||||
updated_at?: string | null;
|
||||
}
|
||||
|
||||
export interface QueryRouteGovernanceDetail {
|
||||
record: QueryRouteGovernanceRecord;
|
||||
qa_record: Record<string, unknown>;
|
||||
route_memory: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export const queryRouteGovernanceApi = {
|
||||
async getSummary(params?: {
|
||||
project_id?: number;
|
||||
window_days?: number;
|
||||
}): Promise<QueryRouteGovernanceSummary> {
|
||||
const { data } = await api.get<ApiEnvelope<QueryRouteGovernanceSummary>>(
|
||||
"/rag/query-route-governance/summary",
|
||||
{ params },
|
||||
);
|
||||
return unwrap(data);
|
||||
},
|
||||
|
||||
async listRecords(params?: {
|
||||
project_id?: number;
|
||||
window_days?: number;
|
||||
route_mode?: string;
|
||||
decision_origin?: string;
|
||||
rating?: string;
|
||||
mismatch_only?: boolean;
|
||||
clarify_only?: boolean;
|
||||
search?: string;
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
}): Promise<{ items: QueryRouteGovernanceRecord[]; total: number; limit: number; offset: number }> {
|
||||
const { data } = await api.get<
|
||||
ApiEnvelope<{ items: QueryRouteGovernanceRecord[]; total: number; limit: number; offset: number }>
|
||||
>("/rag/query-route-governance/items", { params });
|
||||
return unwrap(data);
|
||||
},
|
||||
|
||||
async getDetail(recordId: number, params?: { project_id?: number }): Promise<QueryRouteGovernanceDetail> {
|
||||
const { data } = await api.get<ApiEnvelope<QueryRouteGovernanceDetail>>(
|
||||
`/rag/query-route-governance/items/${recordId}`,
|
||||
{ params },
|
||||
);
|
||||
return unwrap(data);
|
||||
},
|
||||
};
|
||||
|
||||
|
|
@ -0,0 +1,190 @@
|
|||
import { cleanup, fireEvent, screen, waitFor } from "@testing-library/react";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { FloatingQAChat } from "@/components/ai-steward/FloatingQAChat";
|
||||
import { QAConversationStatusBar } from "@/components/ai-steward/QAConversationStatusBar";
|
||||
import { QAResponseMetaCard } from "@/components/ai-steward/QAResponseMetaCard";
|
||||
import { renderWithProviders } from "@/test/renderWithProviders";
|
||||
|
||||
const aiStewardApiMock = vi.hoisted(() => ({
|
||||
askRag: vi.fn(),
|
||||
listQASessions: vi.fn(),
|
||||
getQASession: vi.fn(),
|
||||
getQASessionState: vi.fn(),
|
||||
renameQASession: vi.fn(),
|
||||
submitQAFeedback: vi.fn(),
|
||||
}));
|
||||
|
||||
const aiPmoApiMock = vi.hoisted(() => ({
|
||||
processText: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/api/aiSteward", () => ({
|
||||
aiStewardApi: aiStewardApiMock,
|
||||
}));
|
||||
|
||||
vi.mock("@/api/aiPmo", () => ({
|
||||
aiPmoApi: aiPmoApiMock,
|
||||
}));
|
||||
|
||||
describe("FloatingQAChat", () => {
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
localStorage.clear();
|
||||
Object.defineProperty(window.HTMLElement.prototype, "scrollIntoView", {
|
||||
configurable: true,
|
||||
value: vi.fn(),
|
||||
});
|
||||
Object.defineProperty(window.HTMLElement.prototype, "scrollTo", {
|
||||
configurable: true,
|
||||
value: vi.fn(),
|
||||
});
|
||||
aiStewardApiMock.listQASessions.mockResolvedValue({
|
||||
items: [],
|
||||
page: 1,
|
||||
total_pages: 0,
|
||||
});
|
||||
aiStewardApiMock.getQASession.mockResolvedValue({
|
||||
session: {
|
||||
id: 11,
|
||||
title: "Alpha 会话",
|
||||
message_count: 1,
|
||||
last_message_at: "2026-04-17T10:00:00+08:00",
|
||||
},
|
||||
messages: [
|
||||
{
|
||||
id: 91,
|
||||
question: "Alpha 项目现在怎么样?",
|
||||
answer: "外部实时查询:目前证据不足。",
|
||||
sources: [],
|
||||
route_decision: {
|
||||
effective_source_policy: "external",
|
||||
},
|
||||
grounding_status: "insufficient_evidence",
|
||||
answer_mode: "clarify",
|
||||
evidence_pack: {
|
||||
summary: {
|
||||
conclusion: "还需要你明确要查外部哪一类动态。",
|
||||
uncertainties: ["未明确外部主题"],
|
||||
},
|
||||
},
|
||||
turn_index: 1,
|
||||
},
|
||||
],
|
||||
});
|
||||
aiStewardApiMock.getQASessionState.mockResolvedValue({
|
||||
session_id: 11,
|
||||
last_subject: "Alpha项目",
|
||||
last_entity_ids: [88],
|
||||
last_intent: "question",
|
||||
ambiguity: "needs_clarification",
|
||||
rewrite_history: [],
|
||||
pending_clarification: {
|
||||
kind: "query_route_selection",
|
||||
},
|
||||
pending_clarification_expires_at: null,
|
||||
updated_at: "2026-04-17T10:00:00+08:00",
|
||||
});
|
||||
aiStewardApiMock.askRag.mockResolvedValue({
|
||||
answer: "你是要查外部公开事实,还是系统内部对象?",
|
||||
clarification_code: "query_route_clarify",
|
||||
clarification_candidates: [
|
||||
{ key: "A", label: "外部公开事实" },
|
||||
{ key: "B", label: "系统内部对象" },
|
||||
],
|
||||
route_decision: {
|
||||
effective_source_policy: "external_first",
|
||||
},
|
||||
grounding_status: "insufficient_evidence",
|
||||
answer_mode: "clarify",
|
||||
evidence_pack: {
|
||||
summary: {
|
||||
conclusion: "需要先确认查询来源。",
|
||||
uncertainties: ["内部还是外部"],
|
||||
},
|
||||
},
|
||||
sources: [],
|
||||
});
|
||||
aiPmoApiMock.processText.mockResolvedValue({});
|
||||
});
|
||||
|
||||
it("shows current subject and source mode in the status bar", () => {
|
||||
renderWithProviders(
|
||||
<QAConversationStatusBar
|
||||
currentSubject="Alpha项目"
|
||||
effectiveSourcePolicy="external"
|
||||
pendingClarification={{ kind: "query_route_selection" }}
|
||||
isFollowingSessionContext
|
||||
pageAnchorLabel="项目 #17"
|
||||
onClearContext={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText(/当前对象/i).textContent ?? "").toContain("Alpha项目");
|
||||
expect(screen.getByText(/当前来源/i).textContent ?? "").toContain("外部公开事实");
|
||||
expect(screen.getByText(/页面锚点/i).textContent ?? "").toContain("项目 #17");
|
||||
expect(screen.queryByText(/正在继承上一轮上下文/)).toBeTruthy();
|
||||
});
|
||||
|
||||
it("renders answer meta card with grounding and source summary", () => {
|
||||
renderWithProviders(
|
||||
<QAResponseMetaCard
|
||||
routeDecision={{ effective_source_policy: "internal" }}
|
||||
groundingStatus="grounded"
|
||||
sourcesUsed={[{ display_label: "项目数据库" }, { display_label: "知识图谱" }]}
|
||||
evidencePack={{
|
||||
summary: {
|
||||
conclusion: "内部知识已足够回答。",
|
||||
uncertainties: [],
|
||||
},
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.queryByText("内部知识库查询")).toBeTruthy();
|
||||
expect(screen.queryByText("证据充分")).toBeTruthy();
|
||||
expect(screen.queryByText(/项目数据库 \/ 知识图谱/)).toBeTruthy();
|
||||
expect(screen.queryByText(/内部知识已足够回答/)).toBeTruthy();
|
||||
});
|
||||
|
||||
it("renders route clarify actions when clarification_code=query_route_clarify", async () => {
|
||||
renderWithProviders(<FloatingQAChat />);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "打开 AI 聊天" }));
|
||||
fireEvent.change(await screen.findByPlaceholderText("输入问题..."), {
|
||||
target: { value: "这个需求该查哪边?" },
|
||||
});
|
||||
fireEvent.click(screen.getByRole("button", { name: "提问" }));
|
||||
|
||||
expect(await screen.findByRole("button", { name: "查外部公开事实" })).toBeTruthy();
|
||||
expect(screen.getByRole("button", { name: "查系统内部对象" })).toBeTruthy();
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "查系统内部对象" }));
|
||||
|
||||
await waitFor(() => expect(aiStewardApiMock.askRag).toHaveBeenCalledTimes(2));
|
||||
expect(aiStewardApiMock.askRag.mock.calls[1][0]).toMatchObject({
|
||||
question: "B",
|
||||
});
|
||||
});
|
||||
|
||||
it("forwards project anchor from project board route when asking", async () => {
|
||||
renderWithProviders(<FloatingQAChat />, ["/projects/17/board"]);
|
||||
|
||||
const openButtons = screen.getAllByRole("button", { name: "打开 AI 聊天" });
|
||||
fireEvent.click(openButtons[openButtons.length - 1]);
|
||||
fireEvent.change(await screen.findByPlaceholderText("输入问题..."), {
|
||||
target: { value: "这个项目怎么样?" },
|
||||
});
|
||||
fireEvent.click(screen.getByRole("button", { name: "提问" }));
|
||||
|
||||
await waitFor(() => expect(aiStewardApiMock.askRag).toHaveBeenCalledTimes(1));
|
||||
expect(aiStewardApiMock.askRag.mock.calls[0][0]).toMatchObject({
|
||||
question: "这个项目怎么样?",
|
||||
project_id: 17,
|
||||
});
|
||||
});
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue