Refine AI PMO grounding runtime and consultant flow

This commit is contained in:
Yuuuu 2026-04-01 08:50:39 +08:00
parent 6bb8354b8d
commit 9ab2e14838
39 changed files with 3792 additions and 314 deletions

View File

@ -104,6 +104,7 @@ backend/
- 新增:`backend/alembic/versions/20260311_shift_all_timestamps_to_beijing.py`(全库历史时间 +8h,数据库默认时区切到 Asia/Shanghai)
- 新增:`backend/alembic/versions/20260327_add_external_qa_followup_state.py`(`qa_sessions` 增加 `external_conversation_key`,`qa_session_states` 增加外部追问待澄清状态字段)
- 新增:`backend/alembic/versions/20260327_add_qa_record_followup_anchor_fields.py`(`qa_records` 增加外部会话 scope / reply anchor / 路由快照字段,支撑跨 session 追问锚定与元追问追溯)
- 新增:`backend/alembic/versions/20260331_add_qa_record_runtime_fields.py`(`qa_records` 增加 `answer_mode/grounding_status/policy_version/citations/sources_used/evidence_pack/tool_trace/retrieval_metrics`,用于 Grounding Runtime 持久化与回放)
- 新增:`backend/alembic/versions/20260312_add_signal_planner_fields.py`(signals 增加 planner 状态字段与索引)
- 新增:`backend/app/services/signal/signal_planner/signal_strategic_analysis_service.py`(Signal Planner 写回收口)
- 新增:`backend/app/services/integrations/feishu_outbox/service.py`(复用 reply_hash 的 outbox 入队收口)
@ -127,6 +128,7 @@ backend/
- 新增:`backend/tests/test_ai_pmo_task_intent_contract.py`(AI PMO `task.create` 一等 typed intent 契约回归:意图识别、路由归一与执行器分派)
- 新增:`backend/tests/test_ai_pmo_ingress_mainline.py`(AI PMO 主链入口回归:`/ai-pmo/process-text` 手工问答入口、mixed sidecar 透传、`draft_pending_confirm` 优先分支)
- 新增:`backend/tests/test_ai_pmo_qa_http_mainline_e2e.py`(AI PMO/QA HTTP 主链集成回归:`/rag/ask` 只读问答、mixed 两段输出、`/ai-pmo/process-text` 草稿待确认)
- 新增:`backend/tests/test_qa_record_runtime_fields_migration.py`(QA Runtime migration 回归:`qa_records` runtime/evidence 字段新增与回滚)
- 新增:`backend/tests/test_context_cache_profile_catalog.py`(Context Cache 功能目录契约回归:AI Steward 统一指向 `/rag/ask`)
- 新增:`backend/tests/test_ai_pmo_tactical_brain_contract.py`(TacticalBrain 执行动作契约回归:`query.answer`/`work_item.resume`/`project_signal.*` 映射)
- 新增:`backend/app/services/adaptive_rag_service/mixin_answer_mainline.py`(AdaptiveRAG 基础问答主链编排:运行时准备 + 只读主路径执行)
@ -363,6 +365,11 @@ frontend/
- 新增:`frontend/src/api/modelRouting.ts`(模型路由 API 封装)
- 新增:`frontend/src/api/knowledgeGraph.ts`(KG 能力汇总与 low/high 消费验证 API)
- 更新:`frontend/src/api/aiSteward.ts`(移除 OpenClaw dispatch 封装,新增 LLM 引擎 CRUD / settings 封装)
- 更新:`frontend/src/api/aiSteward.ts`(新增 AI 管家 QA 封装:`/rag/ask`、QA sessions、feedback、会话改名;浮窗不再直接 `fetch`)
- 更新:`frontend/src/api/aiPmo.ts`(新增 `processText` 封装,供 AI 管家浮窗把“允许执行”显式交接到 AI PMO Kernel)
- 新增:`frontend/src/components/ai-steward/QAMarkdownContent.tsx`(AI 管家问答 markdown 渲染组件:统一列表/引用/代码块/长路径换行)
- 更新:`frontend/src/components/ai-steward/FloatingQAChat.tsx`(回答区改为 markdown 渲染;浮窗顾问动作升级为整组可交互 chips:`深入思考`、`给建议`、`先澄清`、`一起想`、`先劝退`、风险解释、`允许执行` 确认交接、`先别执行` 回填 `next_action`)
- 更新:`frontend/package.json`(新增 `react-markdown`、`remark-gfm`、`remark-breaks` 依赖,供 AI 管家问答渲染)
- 更新:`frontend/src/pages/CharterDetailPage.tsx`、`ContractSyncBoardPage.tsx`、`DashboardPage.tsx`、`KnowledgeGraphContextBoardPage.tsx`、`LoginPage.tsx`、`ModelRoutingConfigPage.tsx`、`SchedulerCenterPage.tsx`、`SkillTasksPage.tsx`、`SkillsPage.tsx`、`TaskBriefCenterPage.tsx`、`TaskBriefRunsPage.tsx`、`TaskBriefTemplatePage.tsx`、`UserProfilePage.tsx`、`UserSkillsPage.tsx`、`WorkloadDetailPage.tsx`、`WorkloadsPage.tsx`(统一降级为轻量入口,转发到 `pages/*/` 子目录)
- 新增:`frontend/src/pages/charter-detail/CharterDetailPage.tsx`、`contract-sync-board/ContractSyncBoardPage.tsx`、`dashboard/DashboardPage.tsx`、`knowledge-graph-context-board/KnowledgeGraphContextBoardPage.tsx`、`login/LoginPage.tsx`、`model-routing-config/ModelRoutingConfigPage.tsx`(剩余核心页面目录化收敛)
- 新增:`frontend/src/pages/scheduler-center/SchedulerCenterPage.tsx`、`skill-tasks/SkillTasksPage.tsx`、`skills/SkillsPage.tsx`、`task-brief-center/TaskBriefCenterPage.tsx`、`task-brief-runs/TaskBriefRunsPage.tsx`、`task-brief-template/TaskBriefTemplatePage.tsx`(任务/调度/技能页面目录化收敛)
@ -570,8 +577,11 @@ uvicorn app.main:app --reload --host 0.0.0.0 --port 8000
- AI PMO 恢复接口(`POST /ai-pmo/work-items/{work_item_id}/resume`):支持 `action=continue|commit|refresh|cancel`,复用同一闭环恢复入口
- AI PMO 草稿决策接口(`POST /ai-pmo/work-items/{work_item_id}/draft-decision`):支持 `action=confirm|revise|cancel`,与飞书“确认/修改/取消”行为一致
- AI PMO 路由包(`backend/app/api/ai_pmo_api/__init__.py`)只负责聚合与 tags,`/ai-pmo` 前缀只能由子模块共享 router 挂一次,禁止包级与模块级重复叠加前缀导致 `/ai-pmo/ai-pmo/...` 路径
- RAG 问答主入口契约(`POST /rag/ask`):HTTP 入口已收敛到 `AIPMOKernelService.process_ingress` 主链;请求侧统一透传 `asked_via/action_mode/message_intent/mixed_signal_part`,由 `ingress_options.rag_request_context` 下传到 query executor 并映射到 `AdaptiveRAGService.answer_question`,返回侧从 ingress `rag_result`/`direct_reply_text` 组装兼容响应
- RAG 问答主入口契约(`POST /rag/ask`):HTTP 入口已收敛到 `AIPMOKernelService.process_ingress` 主链;请求侧统一透传 `asked_via/action_mode/message_intent/mixed_signal_part/consultant_mode_preference`,由 `ingress_options.rag_request_context` 下传到 query executor 并映射到 `AdaptiveRAGService.answer_question`,返回侧从 ingress `rag_result`/`direct_reply_text` 组装兼容响应
- RAG 空回答保护:`POST /rag/ask` 若 `answer` 与 `reply_text` 同时为空,必须返回失败包络,禁止再用“当前暂无可用回答。”之类兜底文案冒充成功结果
- RAG Grounding Runtime 合同补充:`POST /rag/ask` 的成功返回统一透传 `answer_mode/grounding_status/policy_version/citations/sources_used/evidence_pack/tool_trace/retrieval_metrics`;`AdaptiveRAG` 内部允许通过兼容层把异构 `sources` 映射成稳定 citation 结构,但业务语义判断仍只允许放在 `workflow_semantic_planner`
- RAG / AI 管家顾问合同:`POST /rag/ask` 返回体会透传 `consultant_dispatch={domain,mode,risk_level,missing_fields,allow_execute,next_action}`;该合同由 `backend/app/services/ai_pmo/consultant_dispatch.py` 统一生成,飞书 mention 回复与 AI 管家问答共享同一份模式判断
- RAG 顾问模式偏好约束:`consultant_mode_preference` 只允许取 `clarify/advise/challenge/brainstorm/execute`,且仅影响回答风格与 prompt wrapper;它不能替代 `workflow_semantic_planner` 去决定 `query vs execute`
- RAG / AI Steward 路由包(`backend/app/api/rag_api/__init__.py`、`backend/app/api/ai_steward_api/__init__.py`)不再重复挂包级 prefix;路由 canonical 由 `shared.py` 内的共享 router 统一承担,避免运行中出现 `/rag/rag/*`、`/ai-steward/ai-steward/*`
- AI PMO 消息台列表接口(`GET /ai-pmo/work-items`):返回 `latest_policy_gate_code` 与规范化 `approval_requirement{required,reason,message}`(来源于最新 `decision_records` 投影)
- AI PMO 消息台列表接口补充:返回草稿字段 `draft_content/draft_expires_at/draft_version/confirm_message_id`,用于草稿确认卡片渲染
@ -650,6 +660,7 @@ uvicorn app.main:app --reload --host 0.0.0.0 --port 8000
- 外部 QA 若存在显式 `reply_to_message_id`,必须优先按消息级锚定处理:先反查 `feishu_reply_outbox.sent_message_id` 还原被引用的 PMFlow 回复;若消息 ID 无法映射,也要回退到当前入站消息携带的 `closure_turns/closure_dialogue` 恢复上一轮机器人回复,再回退到 session/recent candidates;禁止把“直接引用机器人回复”的消息仍判成无锚点
- 外部 QA 的“追问歧义”与“模型/上游失败”必须分开:前者返回 `clarification_code`(如 `ambiguous_recent_reply` / `no_anchor_found`)及候选列表,后者继续保留真实 provider/空响应错误,禁止共用模糊兜底文案
- 外部 QA 的 `qa_records` 必须持久化 `conversation_scope_key/source_message_id/source_chat_id/source_sender_open_id/reply_to_message_id/root_message_id/anchor_kind/anchor_ref/routing_snapshot`,用于回答“为什么没接上上一条”“为什么走了候选澄清”“上一条回复走的是 low 还是 high”
- `qa_records` 现阶段还必须持久化 Grounding Runtime 字段:`answer_mode/grounding_status/policy_version/citations/sources_used/evidence_pack/tool_trace/retrieval_metrics`;旧 `sources/domains_used/routing_snapshot` 继续保留兼容,禁止只改接口不落库
- 元追问(如“哪个模型回复的”“为什么那条报错”“你刚才那句什么意思”)必须优先锚定最近一条 PMFlow 回复;对外只允许返回 `planned_tier/executed_tier/route_source/route_rule_name/routing_decision` 这类 low/high 路由语义,禁止暴露真实 provider/model 名称
- Prompt release 目前只接入只读回复链路(AdaptiveRAG / query.read_only);写操作 workflow 仍使用代码 prompt 与显式人工 gate,避免训练试验误触执行副作用
- 训练自动化/影子回放当前只允许覆盖只读问答链路;`project.create/signal.create/signal_source.onboard/task.create` 等写操作 workflow 继续排除在自动训练与自动试验之外
@ -664,8 +675,10 @@ uvicorn app.main:app --reload --host 0.0.0.0 --port 8000
- 飞书入口已按职责拆分为“预处理/捕获执行/结果回执”三层:`mixin_receive.py` 仅保留编排,`mixin_receive_prepare.py` 负责传输解析与上下文抽取,`mixin_receive_capture_flow.py` 负责 capture 持久化与回执收尾
- `ai_orchestration_runs.project_id` 允许为空:用于承载尚未完成项目归属判定的入站异步任务;既有 `signal_source_onboarding` 运行仍按显式 `project_id` 写入
- `/ai-pmo/process-text` 问答链路在 AdaptiveRAG 内通过 `asked_via=pmflow_manual` 识别为 AI PMO 入口;mixed 场景通过 `message_intent=mixed` 透传触发专用 Prompt 与两段输出格式化
- `/rag/ask` 已改为经 AI PMO kernel ingress 主链执行(与飞书长连接、`/ai-pmo/process-text` 同链路);query 执行阶段通过 `rag_request_context` 保留 `session_id/new_session/thinking_mode/action_mode/sync_requirement/message_intent/mixed_signal_part`
- `/rag/ask` 已改为经 AI PMO kernel ingress 主链执行(与飞书长连接、`/ai-pmo/process-text` 同链路);query 执行阶段通过 `rag_request_context` 保留 `session_id/new_session/thinking_mode/action_mode/sync_requirement/message_intent/mixed_signal_part/consultant_mode_preference`
- AdaptiveRAG `answer_question` 入口已拆为“双阶段”:`_prepare_answer_runtime`(会话/上下文/动作模式解析)+ `_execute_base_answer_mainline`(基础问答主路径);写确认 `ack` gate 仅在 `sync_requirement=true && action_mode in write/read_write && 非 feishu` 时触发
- 高准确通道不再按 `asked_via` 一刀切强制 deep:`count/list/status/existence` 由 `AdaptiveRAG` retrieval planner 优先回落到 `fast_path`,`snapshot-only + weak evidence` 则要求进入 `clarify` 或 `blocked`;该 planner 只负责 retrieval/runtime 决策,禁止接管 `query vs execute / project.create / signal_source.onboard / followup.*`
- AI 管家浮窗顾问按钮边界:`深入思考` 与整组 `给建议/先澄清/一起想/先劝退` chips 都属于 `/rag/ask` 内部重答;`允许执行` 必须先确认,再显式交接到 `/ai-pmo/process-text`,禁止把执行动作继续伪装成 RAG 重答;`先别执行` 只允许回填 `next_action` 到输入框,不得偷偷触发写操作
- AI PMO ingress 执行路径(`waiting_manual/draft_pending_confirm/auto execute`)已统一写入 `operation_execution_ledger`,`operation_code` 与 `execution_result.action_contract` 一致;账本状态统一映射为 `applied/pending_manual/pending_confirmation/failed`
- `PROMPT_DECOMPOSE_TASKS` 技能标签来源已改为运行时动态注入:优先读取 `skill_tags`(按 `sort_order/name`),为空时才回退默认技能集合
@ -679,6 +692,13 @@ 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] AI 管家浮窗交互已与 Grounding Runtime 对齐:回答区已切到 markdown 渲染,顾问标签升级为显式交互按钮;`consultant_mode_preference` 只用于 `/rag/ask` 内部重答,`允许执行` 明确交接到 `/ai-pmo/process-text`,避免 query/execute 边界回退到旧的一锅煮
- [ ] Retrieval source registry / metadata 统一仍在技术债:当前 `doc/url`、`feishu`、`pmflow`、`graph/db` 继续保留异构 metadata,`citations[]/sources_used[]` 先由 response-layer 兼容适配器统一;后续仍需补齐原生 `source_id/chunk_id/permission_scope/updated_at`、统一 source priority 配置并为 reranker 预留接口
- [ ] 语义治理 baseline 尚未清零:`scripts/semantic_lint_baseline.txt` 仍豁免 `backend/app/services/integrations/feishu_signal_pipeline/shared_core.py`、`backend/app/services/signal/signal_capture_service/shared.py`、`backend/app/api/ai_pmo_api/shared.py` 中的 `*_HINTS/*_RE` 规则常量;`backend/tests/test_semantic_lint.py` 目前只阻止新增散落语义,尚未推动存量归零。后续需把这些兼容残留迁回 `workflow_semantic_planner` 或 prompt catalog,并逐项删除 baseline
- [ ] 历史 Feishu 队列 / `feishu_items` 兼容链仍未退场:`FeishuSignalPipelineService` 已不再承担线上主入口,但离线回放、单测基座、`ai_logs/project_knowledge_graph` 历史过滤,以及 Alembic / 启动自检仍依赖 `feishu_items`。后续需先迁走历史读链路与回放基座,再为该链路补明确的 deprecated / 下线计划
- [ ] AdaptiveRAG 向量检索仍保留 legacy fallback:`AdaptiveRAGRetrievalVectorMixin._retrieve_vector_context()` 在非 PostgreSQL 或 PG 查询失败时会回退 `_retrieve_vector_context_legacy()`,对 `knowledge_base_chunks` 做 Python 侧扫描与打分;这使 pgvector 主路径与 legacy 主路径长期双轨。后续需决定是正式声明“只支持 PostgreSQL/pgvector”,还是补齐统一 retrieval contract 与测试矩阵
- [ ] 历史 Alembic 链幂等性缺陷仍未修复:仓库里已确认 `add_users_table_rbac.py` 在 PostgreSQL 可能重复创建 `userrole` ENUM;当前运行时仍通过直接 `uvicorn` 启动 + `AUTO_CREATE_TABLES=true` 规避全量 upgrade。后续需修补旧 migration,保证 PostgreSQL 上的 Alembic upgrade 可重复执行
- [ ] 食谱图片上传仍是示例桩实现:`backend/app/services/recipe/service.py` 仍以模拟 URL 代替对象存储,`POST /recipes/upload-image` 也还停留在示例接口形态;若该模块继续保留,需要补齐真实对象存储接入、租户隔离与统一 `{code,message,data}` 响应包络,否则应明确标注为非核心示例模块
- [x] LLM 运行时仍未完全收敛:`ai_service_core._invoke_with_retry` 与 `stream_chat` 已统一走 `llm_service`;旧 `_get_llm` 路径已显式禁用,并补回归测试锁定
- [x] QA 主链自动化测试覆盖已补齐:`backend/tests/` 已覆盖问答/RAG 主路径与关键场景;新增 `test_ai_pmo_qa_http_mainline_e2e.py` 通过 HTTP 层锁定 `/rag/ask` 的 `qa_readonly/mixed` 契约以及 `/ai-pmo/process-text` 的 `draft_pending_confirm` 主链返回,配合 `test_ai_pmo_ingress_mainline.py`、`test_ai_pmo_task_intent_contract.py`、`test_ai_pmo_tactical_brain_contract.py` 形成稳定回归基线
- [x] 飞书交互闭环已补齐:执行场景回执在 `draft_pending_confirm/pending_manual` 下优先生成按钮卡片并经 outbox 异步发送;`card.action.trigger` 已覆盖 `confirm/revise/cancel/continue` 回调分发,分别复用 `DraftConfirmHandler.handle_api_decision` 与 AI PMO `process_ingress` 主链继续执行;回调 HTTP 现在会同步返回 toast 反馈,确保点击按钮后至少有可见结果;`backend/tests/test_feishu_card_action_contract.py` 已补卡片构造与 `continue` 回归契约

View File

@ -0,0 +1,83 @@
"""add qa record runtime grounding fields
Revision ID: 20260331_add_qa_record_runtime_fields
Revises: 20260327_add_reply_iteration_training_tables
Create Date: 2026-03-31
"""
from __future__ import annotations
from alembic import op
import sqlalchemy as sa
try:
from sqlalchemy.dialects.postgresql import JSONB
except Exception: # pragma: no cover
JSONB = sa.JSON
revision = "20260331_add_qa_record_runtime_fields"
down_revision = "20260327_add_reply_iteration_training_tables"
branch_labels = None
depends_on = None
def _json_type(bind):
return JSONB() if bind.dialect.name == "postgresql" else sa.JSON()
def _table_exists(bind, table_name: str) -> bool:
return sa.inspect(bind).has_table(table_name)
def _column_exists(bind, table_name: str, column_name: str) -> bool:
inspector = sa.inspect(bind)
try:
columns = inspector.get_columns(table_name)
except Exception:
return False
return any(str(column.get("name")) == column_name for column in columns)
def upgrade() -> None:
bind = op.get_bind()
if not _table_exists(bind, "qa_records"):
return
with op.batch_alter_table("qa_records") as batch_op:
if not _column_exists(bind, "qa_records", "answer_mode"):
batch_op.add_column(sa.Column("answer_mode", sa.String(length=32), nullable=True))
if not _column_exists(bind, "qa_records", "grounding_status"):
batch_op.add_column(sa.Column("grounding_status", sa.String(length=32), nullable=True))
if not _column_exists(bind, "qa_records", "policy_version"):
batch_op.add_column(sa.Column("policy_version", sa.String(length=160), nullable=True))
if not _column_exists(bind, "qa_records", "citations"):
batch_op.add_column(sa.Column("citations", _json_type(bind), nullable=True))
if not _column_exists(bind, "qa_records", "sources_used"):
batch_op.add_column(sa.Column("sources_used", _json_type(bind), nullable=True))
if not _column_exists(bind, "qa_records", "evidence_pack"):
batch_op.add_column(sa.Column("evidence_pack", _json_type(bind), nullable=True))
if not _column_exists(bind, "qa_records", "tool_trace"):
batch_op.add_column(sa.Column("tool_trace", _json_type(bind), nullable=True))
if not _column_exists(bind, "qa_records", "retrieval_metrics"):
batch_op.add_column(sa.Column("retrieval_metrics", _json_type(bind), nullable=True))
def downgrade() -> None:
bind = op.get_bind()
if not _table_exists(bind, "qa_records"):
return
with op.batch_alter_table("qa_records") as batch_op:
for column_name in (
"retrieval_metrics",
"tool_trace",
"evidence_pack",
"sources_used",
"citations",
"policy_version",
"grounding_status",
"answer_mode",
):
if _column_exists(bind, "qa_records", column_name):
batch_op.drop_column(column_name)

View File

@ -1,8 +1,11 @@
from __future__ import annotations
from fastapi.responses import JSONResponse
from .shared import *
from .helpers import _resolve_tenant_id
from .. import rag_ask_contract
from ...core.response import ErrorCode, error_response
from ...services.ai_pmo.ai_pmo_kernel import AIPMOKernelService
@router.post("/ask")
@ -27,9 +30,13 @@ async def rag_ask(
status_code=400,
detail=str(ingress_result.get("reason") or "ai_pmo_kernel_not_handled"),
)
return success_response(
rag_ask_contract.build_rag_response_payload(ingress_result=ingress_result),
payload = rag_ask_contract.build_rag_response_payload(ingress_result=ingress_result)
if not str(payload.get("answer") or "").strip() and not str(payload.get("reply_text") or "").strip():
return JSONResponse(
status_code=500,
content=error_response(ErrorCode.INTERNAL_ERROR, "RAG 返回空回答,未生成有效响应。"),
)
return success_response(payload)
@router.get("/manifest")
async def rag_manifest(

View File

@ -1,6 +1,7 @@
from __future__ import annotations
from typing import Any
from uuid import uuid4
from ..models import User
from ..schemas.rag import RAGAskRequest
@ -28,6 +29,7 @@ def build_answer_question_kwargs(request: RAGAskRequest, current_user: User) ->
"sync_requirement": bool(request.sync_requirement),
"message_intent": request.message_intent,
"mixed_signal_part": request.mixed_signal_part,
"consultant_mode_preference": request.consultant_mode_preference,
}
@ -49,11 +51,28 @@ def _resolve_sender_name(current_user: User) -> str:
return sender or "PMFlow用户"
def _normalize_dict_list(value: Any) -> list[dict[str, Any]]:
if not isinstance(value, list):
return []
return [item for item in value if isinstance(item, dict)]
def _default_evidence_pack(*, answer: str, tool_trace: list[dict[str, Any]]) -> dict[str, Any]:
return {
"summary": {
"conclusion": str(answer or "").strip(),
"evidence": [],
"uncertainties": [],
},
"tool_trace": tool_trace,
}
def build_kernel_ingress_kwargs(request: RAGAskRequest, current_user: User) -> dict[str, Any]:
rag_request_context = build_answer_question_kwargs(request=request, current_user=current_user)
return {
"source_system": str(request.asked_via or "pmflow").strip() or "pmflow",
"source_message_id": None,
"source_message_id": f"rag-ask-{uuid4().hex}",
"message_text": request.question,
"sender": _resolve_sender_name(current_user),
"channel_ref": request.source_chat_id,
@ -80,7 +99,7 @@ def build_rag_response_payload(*, ingress_result: dict[str, Any]) -> dict[str, A
payload["consultant_dispatch"] = consultant_dispatch
answer = str(payload.get("answer") or result.get("direct_reply_text") or "").strip()
payload["answer"] = answer or "当前暂无可用回答。"
payload["answer"] = answer
if "reply_required" not in payload:
payload["reply_required"] = bool(result.get("direct_reply_required"))
@ -90,6 +109,22 @@ def build_rag_response_payload(*, ingress_result: dict[str, Any]) -> dict[str, A
payload["trace_id"] = str(result.get("ai_trace_id") or "").strip() or None
if "processing_stage" not in payload:
payload["processing_stage"] = "qa"
if "tool_trace" not in payload:
payload["tool_trace"] = []
payload["tool_trace"] = _normalize_dict_list(payload.get("tool_trace"))
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"
payload["grounding_status"] = str(payload.get("grounding_status") or "partially_grounded").strip() or "partially_grounded"
payload["policy_version"] = (
str(payload.get("policy_version") or "retrieval-v2.1|routing:unknown|prompt:unknown").strip()
or "retrieval-v2.1|routing:unknown|prompt:unknown"
)
evidence_pack = payload.get("evidence_pack") if isinstance(payload.get("evidence_pack"), dict) else None
payload["evidence_pack"] = evidence_pack or _default_evidence_pack(
answer=answer,
tool_trace=payload["tool_trace"],
)
payload["work_item_id"] = _safe_int(result.get("work_item_id"))
payload["ingress_status"] = str(result.get("status") or "").strip() or None

View File

@ -40,3 +40,11 @@ class QARecord(Base):
anchor_kind: Mapped[Optional[str]] = mapped_column(VARCHAR(32), nullable=True)
anchor_ref: Mapped[Optional[str]] = mapped_column(VARCHAR(128), nullable=True)
routing_snapshot: Mapped[Optional[Any]] = mapped_column(JSONB, nullable=True)
answer_mode: Mapped[Optional[str]] = mapped_column(VARCHAR(32), nullable=True)
grounding_status: Mapped[Optional[str]] = mapped_column(VARCHAR(32), nullable=True)
policy_version: Mapped[Optional[str]] = mapped_column(VARCHAR(160), nullable=True)
citations: Mapped[Optional[Any]] = mapped_column(JSONB, nullable=True)
sources_used: Mapped[Optional[Any]] = mapped_column(JSONB, nullable=True)
evidence_pack: Mapped[Optional[Any]] = mapped_column(JSONB, nullable=True)
tool_trace: Mapped[Optional[Any]] = mapped_column(JSONB, nullable=True)
retrieval_metrics: Mapped[Optional[Any]] = mapped_column(JSONB, nullable=True)

View File

@ -24,6 +24,13 @@ class RAGAskRequest(BaseModel):
_ASKED_VIA_ALIAS: ClassVar[dict[str, str]] = {
"ai-pmo-process-text": "/ai-pmo/process-text",
}
_ALLOWED_CONSULTANT_MODES: ClassVar[set[str]] = {
"clarify",
"advise",
"challenge",
"brainstorm",
"execute",
}
question: str = Field(..., min_length=1, max_length=4000)
project_id: Optional[int] = Field(default=None)
asked_via: str = Field(default="pmflow", min_length=1, max_length=32)
@ -35,6 +42,7 @@ class RAGAskRequest(BaseModel):
sync_requirement: bool = Field(default=True)
message_intent: Optional[str] = Field(default=None, max_length=32)
mixed_signal_part: Optional[str] = Field(default=None, max_length=2000)
consultant_mode_preference: Optional[str] = Field(default=None, max_length=16)
@field_validator("asked_via", mode="before")
@classmethod
@ -64,6 +72,19 @@ class RAGAskRequest(BaseModel):
text = str(value).strip()
return text or None
@field_validator("consultant_mode_preference", mode="before")
@classmethod
def _normalize_consultant_mode_preference(cls, value: object) -> Optional[str]:
if value is None:
return None
text = str(value).strip().lower()
if not text:
return None
if text not in cls._ALLOWED_CONSULTANT_MODES:
allowed = ", ".join(sorted(cls._ALLOWED_CONSULTANT_MODES))
raise ValueError(f"consultant_mode_preference must be one of: {allowed}")
return text
class KnowledgeDocCreateRequest(BaseModel):
title: str = Field(..., min_length=1, max_length=255)

View File

@ -29,20 +29,7 @@ class AdaptiveRAGAnswerEntryMixin:
message_intent: Optional[str],
) -> bool:
intent = str(message_intent or "").strip().lower()
if intent == "mixed":
return True
channel = str(asked_via or "").strip().lower()
return channel in {
"feishu",
"lark",
"feishu_group",
"feishu_dm",
"pmflow_manual",
"/ai-pmo/process-text",
"ai-pmo-process-text",
"ai_pmo",
"ai_pmo_ui",
}
return intent == "mixed"
@staticmethod
def _should_trigger_write_ack_gate(
@ -80,12 +67,11 @@ class AdaptiveRAGAnswerEntryMixin:
sync_requirement: bool = True,
message_intent: Optional[str] = None,
mixed_signal_part: Optional[str] = None,
consultant_mode_preference: Optional[str] = None,
) -> dict[str, Any]:
started = time.perf_counter()
clean_q = str(question or "").strip()
active_thinking_mode = self._normalize_thinking_mode(thinking_mode)
if self._is_high_accuracy_channel(asked_via):
active_thinking_mode = THINKING_MODE_DEEP
deep_thinking = active_thinking_mode == THINKING_MODE_DEEP
enforce_scene_prompt_only = self._should_enforce_scene_prompt_only(
asked_via=asked_via,
@ -186,4 +172,5 @@ class AdaptiveRAGAnswerEntryMixin:
message_intent=message_intent,
mixed_signal_part=mixed_signal_part,
anchor_resolution=anchor_resolution,
consultant_mode_preference=consultant_mode_preference,
)

View File

@ -5,6 +5,80 @@ from .shared import *
class AdaptiveRAGAnswerMainlineMixin:
def _plan_query_runtime(
self,
*,
question: str,
effective_question: str,
query_type: str,
requested_thinking_mode: str,
enforce_scene_prompt_only: bool,
consultant_mode_preference: Optional[str] = None,
) -> QueryPlannerDecision:
normalize_thinking_mode = getattr(
self,
"_normalize_thinking_mode",
lambda value: THINKING_MODE_DEEP if str(value or "").strip().lower() == THINKING_MODE_DEEP else THINKING_MODE_NORMAL,
)
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()
fast_path_candidate = normalized_query_type in {QUERY_TYPE_COUNT, QUERY_TYPE_LIST, QUERY_TYPE_STATUS}
normalized_question = str(question or "").strip()
normalized_effective = str(effective_question or "").strip()
combined = f"{normalized_question} {normalized_effective}".strip()
is_existence_query = any(token in combined for token in ("有没有", "是否存在", "有无", "存在"))
if is_existence_query:
fast_path_candidate = True
is_engine_query = bool(getattr(self, "_is_engine_process_query", lambda _text: False)(combined))
is_explicit_deep = bool(getattr(self, "_is_explicit_deep_request", lambda _text: False)(combined))
if enforce_scene_prompt_only:
retrieval_mode = ANSWER_MODE_RETRIEVAL_DEEP if normalized_thinking_mode == THINKING_MODE_DEEP else ANSWER_MODE_RETRIEVAL_NORMAL
return QueryPlannerDecision(
answer_mode=retrieval_mode,
thinking_mode=THINKING_MODE_DEEP if retrieval_mode == ANSWER_MODE_RETRIEVAL_DEEP else THINKING_MODE_NORMAL,
allow_fast_path=False,
retrieval_answer_mode=retrieval_mode,
budget=RetrievalBudget(
max_steps=DEEP_RETRIEVAL_STEPS if retrieval_mode == ANSWER_MODE_RETRIEVAL_DEEP else AGENT_MAX_RETRIEVAL_STEPS,
vector_top_k=DEEP_VECTOR_TOP_K if retrieval_mode == ANSWER_MODE_RETRIEVAL_DEEP else DEFAULT_TOP_K,
),
reason="scene_prompt_only",
)
if fast_path_candidate and not is_engine_query and not is_explicit_deep:
if normalized_consultant_mode:
return QueryPlannerDecision(
answer_mode=ANSWER_MODE_RETRIEVAL_NORMAL,
thinking_mode=THINKING_MODE_NORMAL,
allow_fast_path=False,
retrieval_answer_mode=ANSWER_MODE_RETRIEVAL_NORMAL,
budget=RetrievalBudget(max_steps=AGENT_MAX_RETRIEVAL_STEPS, vector_top_k=DEFAULT_TOP_K),
reason=f"consultant_mode:{normalized_consultant_mode}",
)
return QueryPlannerDecision(
answer_mode=ANSWER_MODE_FAST_PATH,
thinking_mode=THINKING_MODE_NORMAL,
allow_fast_path=True,
retrieval_answer_mode=ANSWER_MODE_RETRIEVAL_NORMAL,
budget=RetrievalBudget(max_steps=AGENT_MAX_RETRIEVAL_STEPS, vector_top_k=DEFAULT_TOP_K),
reason="deterministic_query_type",
)
retrieval_mode = ANSWER_MODE_RETRIEVAL_DEEP if (normalized_thinking_mode == THINKING_MODE_DEEP or is_engine_query or is_explicit_deep) else ANSWER_MODE_RETRIEVAL_NORMAL
return QueryPlannerDecision(
answer_mode=retrieval_mode,
thinking_mode=THINKING_MODE_DEEP if retrieval_mode == ANSWER_MODE_RETRIEVAL_DEEP else THINKING_MODE_NORMAL,
allow_fast_path=False,
retrieval_answer_mode=retrieval_mode,
budget=RetrievalBudget(
max_steps=DEEP_RETRIEVAL_STEPS if retrieval_mode == ANSWER_MODE_RETRIEVAL_DEEP else AGENT_MAX_RETRIEVAL_STEPS,
vector_top_k=DEEP_VECTOR_TOP_K if retrieval_mode == ANSWER_MODE_RETRIEVAL_DEEP else DEFAULT_TOP_K,
),
reason="requested_thinking_mode" if retrieval_mode == ANSWER_MODE_RETRIEVAL_DEEP else "default_retrieval",
)
async def _prepare_answer_runtime(
self,
*,
@ -135,9 +209,23 @@ class AdaptiveRAGAnswerMainlineMixin:
message_intent: Optional[str],
mixed_signal_part: Optional[str],
anchor_resolution: Optional[FollowupAnchorResolution] = None,
consultant_mode_preference: Optional[str] = None,
) -> dict[str, Any]:
planner = self._plan_query_runtime(
question=clean_q,
effective_question=effective_q,
query_type=query_type,
requested_thinking_mode=active_thinking_mode,
enforce_scene_prompt_only=enforce_scene_prompt_only,
consultant_mode_preference=consultant_mode_preference,
)
active_thinking_mode = planner.thinking_mode
deep_thinking = planner.retrieval_answer_mode == ANSWER_MODE_RETRIEVAL_DEEP
deep_retrieval_steps = int(planner.budget.max_steps or deep_retrieval_steps)
deep_vector_top_k = int(planner.budget.vector_top_k or deep_vector_top_k)
forced_deep_reason = ""
if not deep_thinking:
if not deep_thinking and not planner.allow_fast_path:
forced_deep_reason = self._forced_deep_reason(
question=clean_q,
effective_question=effective_q,
@ -211,7 +299,7 @@ class AdaptiveRAGAnswerMainlineMixin:
user_id=asked_by,
)
if not deep_thinking and not enforce_scene_prompt_only:
if planner.allow_fast_path:
deterministic_result = await self._run_deterministic_query(
question=effective_q,
project_id=project_id,
@ -233,10 +321,11 @@ class AdaptiveRAGAnswerMainlineMixin:
effective_question=effective_q,
rewrite_meta=rewrite_meta,
thinking_mode=active_thinking_mode,
answer_mode=ANSWER_MODE_FAST_PATH,
)
direct_answer = None
if not deep_thinking and not enforce_scene_prompt_only:
if planner.allow_fast_path:
direct_answer = self._build_snapshot_direct_answer(
effective_q,
system_snapshot=system_snapshot,
@ -262,7 +351,7 @@ class AdaptiveRAGAnswerMainlineMixin:
return direct_result
is_snapshot_direct_question = self._is_snapshot_direct_question(effective_q)
if not deep_thinking and not enforce_scene_prompt_only and not is_snapshot_direct_question:
if planner.allow_fast_path and not is_snapshot_direct_question:
glm_semantic_result = await self._try_glm_project_semantic_answer(clean_q=effective_q, project_id=project_id)
if glm_semantic_result is not None:
return await self._persist_fast_answer(
@ -280,6 +369,7 @@ class AdaptiveRAGAnswerMainlineMixin:
effective_question=effective_q,
rewrite_meta=rewrite_meta,
thinking_mode=active_thinking_mode,
answer_mode=ANSWER_MODE_FAST_PATH,
)
return await self._build_retrieval_response(
@ -299,7 +389,7 @@ class AdaptiveRAGAnswerMainlineMixin:
thinking_mode=thinking_mode,
active_thinking_mode=active_thinking_mode,
deep_thinking=deep_thinking,
forced_deep_reason=forced_deep_reason,
forced_deep_reason=forced_deep_reason or str(planner.reason or ""),
deep_retrieval_steps=deep_retrieval_steps,
deep_vector_top_k=deep_vector_top_k,
system_snapshot=system_snapshot,
@ -307,4 +397,6 @@ class AdaptiveRAGAnswerMainlineMixin:
started=started,
message_intent=message_intent,
mixed_signal_part=mixed_signal_part,
answer_mode=planner.retrieval_answer_mode,
consultant_mode_preference=consultant_mode_preference,
)

View File

@ -5,6 +5,194 @@ from .shared import *
class AdaptiveRAGAnswerPayloadMixin:
@staticmethod
def _safe_source_metadata(raw: Any) -> dict[str, Any]:
return raw if isinstance(raw, dict) else {}
@staticmethod
def _source_ref_value(value: Any) -> Any:
if value is None:
return None
if isinstance(value, (str, int, float, bool)):
text = str(value).strip()
return text or None
if isinstance(value, list):
rows = [str(item).strip() for item in value if str(item).strip()]
return rows or None
if isinstance(value, dict):
try:
return json.loads(json.dumps(value, ensure_ascii=False))
except Exception:
return dict(value)
text = str(value).strip()
return text or None
@staticmethod
def _normalize_answer_mode(value: Optional[str]) -> str:
raw = str(value or "").strip().lower()
if raw in {
ANSWER_MODE_FAST_PATH,
ANSWER_MODE_RETRIEVAL_NORMAL,
ANSWER_MODE_RETRIEVAL_DEEP,
ANSWER_MODE_CLARIFY,
ANSWER_MODE_BLOCKED,
}:
return raw
return ANSWER_MODE_RETRIEVAL_NORMAL
def _normalize_grounding_status(
self,
value: Optional[str],
*,
sources: Optional[list[dict[str, Any]]] = None,
missing_fields: Optional[list[str]] = None,
answer_mode: Optional[str] = None,
reason_code: Optional[str] = None,
) -> str:
raw = str(value or "").strip().lower()
if raw in {
GROUNDING_STATUS_GROUNDED,
GROUNDING_STATUS_PARTIALLY_GROUNDED,
GROUNDING_STATUS_INSUFFICIENT_EVIDENCE,
GROUNDING_STATUS_BLOCKED,
}:
return raw
normalized_mode = self._normalize_answer_mode(answer_mode)
if normalized_mode == ANSWER_MODE_BLOCKED:
return GROUNDING_STATUS_BLOCKED
if normalized_mode == ANSWER_MODE_CLARIFY:
return GROUNDING_STATUS_INSUFFICIENT_EVIDENCE
source_rows = sources if isinstance(sources, list) else []
snapshot_only = False
if hasattr(self, "_is_snapshot_only_sources"):
try:
snapshot_only = bool(self._is_snapshot_only_sources(source_rows))
except Exception:
snapshot_only = False
if not snapshot_only and source_rows:
snapshot_only = all(str(item.get("source") or "").strip().lower() == "system" for item in source_rows if isinstance(item, dict))
missing = [str(item).strip() for item in (missing_fields or []) if str(item).strip()]
normalized_reason = str(reason_code or "").strip().lower()
if normalized_reason == "insufficient_evidence":
return GROUNDING_STATUS_INSUFFICIENT_EVIDENCE
if snapshot_only or missing:
return GROUNDING_STATUS_PARTIALLY_GROUNDED
if source_rows:
return GROUNDING_STATUS_GROUNDED
return GROUNDING_STATUS_PARTIALLY_GROUNDED
def _build_citations_from_sources(self, sources: list[dict[str, Any]]) -> list[dict[str, Any]]:
normalized: list[dict[str, Any]] = []
seen: set[tuple[str, str, str, str]] = set()
for item in (sources or [])[:8]:
if not isinstance(item, dict):
continue
metadata = self._safe_source_metadata(item.get("metadata"))
source_type = self._source_ref_value(
metadata.get("source_type")
or item.get("source")
or metadata.get("table")
or metadata.get("mode")
)
source_label = self._source_ref_value(item.get("label") or metadata.get("source_label"))
source_id = self._source_ref_value(
metadata.get("source_id")
or metadata.get("doc_id")
or metadata.get("knowledge_doc_id")
or metadata.get("message_id")
or metadata.get("url")
or metadata.get("table")
or metadata.get("matched_project_ids")
)
chunk_id = self._source_ref_value(metadata.get("chunk_id") or metadata.get("chunk_index"))
target_url = self._source_ref_value(
metadata.get("target_url")
or metadata.get("doc_url")
or metadata.get("url")
or metadata.get("permalink")
)
project_id = self._source_ref_value(metadata.get("project_id"))
updated_at = self._source_ref_value(
metadata.get("updated_at")
or metadata.get("doc_updated_at")
or metadata.get("source_updated_at")
)
citation = {
"source_type": source_type,
"source_label": source_label,
"source_id": source_id,
"chunk_id": chunk_id,
"target_url": target_url,
"project_id": project_id,
"updated_at": updated_at,
"confidence": round(self._to_float(item.get("score"), 0.0), 4),
}
key = (
str(source_type or ""),
str(source_id or ""),
str(chunk_id or ""),
str(target_url or ""),
)
if key in seen:
continue
seen.add(key)
normalized.append(citation)
return normalized
@staticmethod
def _build_sources_used_from_citations(citations: list[dict[str, Any]]) -> list[dict[str, Any]]:
rows: list[dict[str, Any]] = []
for item in citations:
if not isinstance(item, dict):
continue
rows.append(
{
"source_type": item.get("source_type"),
"source_label": item.get("source_label"),
"source_id": item.get("source_id"),
"chunk_id": item.get("chunk_id"),
"target_url": item.get("target_url"),
"project_id": item.get("project_id"),
"updated_at": item.get("updated_at"),
}
)
return rows
def _build_evidence_pack(
self,
*,
answer: str,
citations: list[dict[str, Any]],
tool_trace: list[dict[str, Any]],
missing_fields: Optional[list[str]] = None,
grounding_status: Optional[str] = None,
reason_code: Optional[str] = None,
) -> dict[str, Any]:
evidence_labels: list[str] = []
for item in citations[:5]:
if not isinstance(item, dict):
continue
label = str(item.get("source_label") or item.get("source_id") or item.get("source_type") or "").strip()
if label:
evidence_labels.append(label)
uncertainties = [str(item).strip() for item in (missing_fields or []) if str(item).strip()]
normalized_grounding = str(grounding_status or "").strip().lower()
normalized_reason = str(reason_code or "").strip()
if normalized_grounding in {GROUNDING_STATUS_INSUFFICIENT_EVIDENCE, GROUNDING_STATUS_BLOCKED} and normalized_reason:
if normalized_reason not in uncertainties:
uncertainties.append(normalized_reason)
return {
"summary": {
"conclusion": str(answer or "").strip(),
"evidence": evidence_labels,
"uncertainties": uncertainties,
},
"tool_trace": tool_trace if isinstance(tool_trace, list) else [],
}
def _build_response_payload(
self,
*,
@ -40,6 +228,13 @@ class AdaptiveRAGAnswerPayloadMixin:
anchor_kind: Optional[str] = None,
meta_followup_kind: Optional[str] = None,
reason_code: Optional[str] = None,
answer_mode: Optional[str] = None,
grounding_status: Optional[str] = None,
policy_version: Optional[str] = None,
citations: Optional[list[dict[str, Any]]] = None,
sources_used: Optional[list[dict[str, Any]]] = None,
evidence_pack: Optional[dict[str, Any]] = None,
prompt_variant_id: Optional[str] = None,
) -> dict[str, Any]:
evidence: list[dict[str, Any]] = []
for item in sources[:8]:
@ -53,6 +248,37 @@ class AdaptiveRAGAnswerPayloadMixin:
"metadata": item.get("metadata") if isinstance(item.get("metadata"), dict) else {},
}
)
normalized_tool_trace = tool_trace if isinstance(tool_trace, list) else []
normalized_citations = citations if isinstance(citations, list) else self._build_citations_from_sources(sources)
normalized_sources_used = (
sources_used
if isinstance(sources_used, list)
else self._build_sources_used_from_citations(normalized_citations)
)
normalized_answer_mode = self._normalize_answer_mode(answer_mode)
normalized_grounding_status = self._normalize_grounding_status(
grounding_status,
sources=sources,
missing_fields=missing_fields,
answer_mode=normalized_answer_mode,
reason_code=reason_code,
)
normalized_policy_version = str(
policy_version
or self._build_policy_version(prompt_variant_id=prompt_variant_id)
).strip() or self._build_policy_version(prompt_variant_id=prompt_variant_id)
normalized_evidence_pack = (
evidence_pack
if isinstance(evidence_pack, dict)
else self._build_evidence_pack(
answer=answer,
citations=normalized_citations,
tool_trace=normalized_tool_trace,
missing_fields=missing_fields,
grounding_status=normalized_grounding_status,
reason_code=reason_code,
)
)
return {
"answer": str(answer or "").strip(),
"evidence": evidence,
@ -71,7 +297,7 @@ class AdaptiveRAGAnswerPayloadMixin:
"vector_timeout": bool(vector_timeout),
"graph_timeout": bool(graph_timeout),
},
"tool_trace": tool_trace if isinstance(tool_trace, list) else [],
"tool_trace": normalized_tool_trace,
"qa_id": int(qa_id) if qa_id is not None else None,
"session_id": int(session_id) if session_id is not None else None,
"turn_index": int(turn_index or 1),
@ -93,6 +319,12 @@ class AdaptiveRAGAnswerPayloadMixin:
"anchor_kind": str(anchor_kind or "").strip() or None,
"meta_followup_kind": str(meta_followup_kind or "").strip() or None,
"reason_code": str(reason_code or "").strip() or None,
"answer_mode": normalized_answer_mode,
"grounding_status": normalized_grounding_status,
"policy_version": normalized_policy_version,
"citations": normalized_citations,
"sources_used": normalized_sources_used,
"evidence_pack": normalized_evidence_pack,
}
@staticmethod

View File

@ -52,6 +52,24 @@ class AdaptiveRAGAnswerPrecheckMixin:
},
}
]
runtime_fields = self._build_qa_record_runtime_fields(
answer=answer,
sources=direct_sources,
missing_fields=[],
answer_mode=ANSWER_MODE_FAST_PATH,
grounding_status=GROUNDING_STATUS_GROUNDED,
prompt_variant_id="meta_followup",
reason_code=reason_code,
routing_snapshot=routing_snapshot,
retrieval_metrics={
"db_query_ms": 0,
"vector_search_ms": 0,
"graph_query_ms": 0,
"db_timeout": False,
"vector_timeout": False,
"graph_timeout": False,
},
)
active_session_id = int(session_id) if session_id is not None else (
int(context_session_id) if context_session_id is not None else None
)
@ -73,6 +91,7 @@ class AdaptiveRAGAnswerPrecheckMixin:
anchor_ref=str(meta_followup_payload.get("anchor_ref") or "").strip() or None,
routing_snapshot=routing_snapshot,
),
**runtime_fields,
)
self.db.add(qa)
await self._update_session_state_after_turn(
@ -114,6 +133,14 @@ class AdaptiveRAGAnswerPrecheckMixin:
anchor_kind=anchor_kind,
meta_followup_kind=meta_followup_kind,
reason_code=reason_code,
answer_mode=ANSWER_MODE_FAST_PATH,
grounding_status=GROUNDING_STATUS_GROUNDED,
policy_version=runtime_fields["policy_version"],
citations=runtime_fields["citations"],
sources_used=runtime_fields["sources_used"],
evidence_pack=runtime_fields["evidence_pack"],
tool_trace=runtime_fields["tool_trace"],
prompt_variant_id="meta_followup",
)
async def _maybe_return_clarification_response(
@ -164,6 +191,23 @@ class AdaptiveRAGAnswerPrecheckMixin:
"metadata": {"mode": "clarification", "reason": clarification_code},
}
]
runtime_fields = self._build_qa_record_runtime_fields(
answer=clarification_answer,
sources=clarify_sources,
missing_fields=missing_fields,
answer_mode=ANSWER_MODE_CLARIFY,
grounding_status=GROUNDING_STATUS_INSUFFICIENT_EVIDENCE,
prompt_variant_id="clarification",
reason_code=clarification_code,
retrieval_metrics={
"db_query_ms": 0,
"vector_search_ms": 0,
"graph_query_ms": 0,
"db_timeout": False,
"vector_timeout": False,
"graph_timeout": False,
},
)
active_session_id = int(session_id) if session_id is not None else (
int(context_session_id) if context_session_id is not None else None
)
@ -181,6 +225,7 @@ class AdaptiveRAGAnswerPrecheckMixin:
session_id=active_session_id,
turn_index=turn_index,
**self._build_qa_record_external_fields(),
**runtime_fields,
)
self.db.add(qa)
if clarification_code == "ambiguous_recent_reply" and clarification_candidates:
@ -233,6 +278,14 @@ class AdaptiveRAGAnswerPrecheckMixin:
clarification_code=clarification_code,
clarification_candidates=clarification_candidates,
reason_code=clarification_code,
answer_mode=ANSWER_MODE_CLARIFY,
grounding_status=GROUNDING_STATUS_INSUFFICIENT_EVIDENCE,
policy_version=runtime_fields["policy_version"],
citations=runtime_fields["citations"],
sources_used=runtime_fields["sources_used"],
evidence_pack=runtime_fields["evidence_pack"],
tool_trace=runtime_fields["tool_trace"],
prompt_variant_id="clarification",
)
async def _maybe_return_snapshot_direct_response(
@ -274,6 +327,22 @@ class AdaptiveRAGAnswerPrecheckMixin:
"metadata": {"mode": "snapshot_direct", "topic": "current_user"},
}
)
runtime_fields = self._build_qa_record_runtime_fields(
answer=direct_answer,
sources=direct_sources,
missing_fields=[],
answer_mode=ANSWER_MODE_FAST_PATH,
grounding_status=GROUNDING_STATUS_PARTIALLY_GROUNDED,
prompt_variant_id="snapshot_direct",
retrieval_metrics={
"db_query_ms": 0,
"vector_search_ms": 0,
"graph_query_ms": 0,
"db_timeout": False,
"vector_timeout": False,
"graph_timeout": False,
},
)
qa = QARecord(
tenant_id=self.tenant_id,
@ -289,6 +358,7 @@ class AdaptiveRAGAnswerPrecheckMixin:
session_id=int(session_id) if session_id is not None else (int(context_session_id) if context_session_id is not None else None),
turn_index=turn_index,
**self._build_qa_record_external_fields(),
**runtime_fields,
)
self.db.add(qa)
await self._update_session_state_after_turn(
@ -326,4 +396,12 @@ class AdaptiveRAGAnswerPrecheckMixin:
confidence=0.99,
missing_fields=[],
thinking_mode=active_thinking_mode,
answer_mode=ANSWER_MODE_FAST_PATH,
grounding_status=GROUNDING_STATUS_PARTIALLY_GROUNDED,
policy_version=runtime_fields["policy_version"],
citations=runtime_fields["citations"],
sources_used=runtime_fields["sources_used"],
evidence_pack=runtime_fields["evidence_pack"],
tool_trace=runtime_fields["tool_trace"],
prompt_variant_id="snapshot_direct",
)

View File

@ -32,6 +32,8 @@ class AdaptiveRAGAnswerRetrievalMixin:
started: float,
message_intent: Optional[str] = None,
mixed_signal_part: Optional[str] = None,
answer_mode: str = ANSWER_MODE_RETRIEVAL_NORMAL,
consultant_mode_preference: Optional[str] = None,
) -> dict[str, Any]:
domains = self._route_domains(effective_q)
if str(asked_via or "").strip().lower() == "feishu" and source_chat_id and "feishu" not in domains:
@ -65,6 +67,7 @@ class AdaptiveRAGAnswerRetrievalMixin:
},
)
final_domains = [str(x) for x in (search_meta.get("domains_used") or domains)]
prompt_variant_id = self._peek_answer_prompt_variant()
max_score = retrieved[0].score if retrieved else 0.0
answer_text = await self._generate_answer(
@ -82,7 +85,9 @@ class AdaptiveRAGAnswerRetrievalMixin:
message_intent=message_intent,
mixed_signal_part=mixed_signal_part,
query_type=query_type,
consultant_mode_preference=consultant_mode_preference,
)
prompt_variant_id = self._peek_answer_prompt_variant()
routing_snapshot = self._peek_last_answer_routing_snapshot()
used_sources = self._dedupe_sources(retrieved)
if not used_sources:
@ -145,6 +150,7 @@ class AdaptiveRAGAnswerRetrievalMixin:
message_intent=message_intent,
mixed_signal_part=mixed_signal_part,
query_type=query_type,
consultant_mode_preference=consultant_mode_preference,
)
deep_sources = self._dedupe_sources(deep_retrieved)
if not deep_sources:
@ -203,6 +209,37 @@ class AdaptiveRAGAnswerRetrievalMixin:
used_sources = deep_sources
deep_thinking = True
active_thinking_mode = THINKING_MODE_DEEP
answer_mode = ANSWER_MODE_RETRIEVAL_DEEP
prompt_variant_id = self._peek_answer_prompt_variant()
routing_snapshot = self._peek_last_answer_routing_snapshot()
evidence_guard_triggered = False
clarification_due_to_evidence = False
if 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]}”的证据不足,现阶段只命中系统快照。"
"请补充更明确的对象、范围或文档入口,我再给你可核验的结论。"
)
used_sources = [
{
"source": "system",
"label": "Evidence Guard",
"score": 0.91,
"metadata": {"mode": "clarification", "reason": "insufficient_evidence"},
}
]
max_score = min(float(max_score or 0.0), 0.4)
if "system_context" not in final_domains:
final_domains.append("system_context")
tool_trace.append(
{
"step": 97,
"tool": "clarification_guard",
"reason": "snapshot_only_low_relevance",
}
)
answer_mode = ANSWER_MODE_CLARIFY
evidence_guard_triggered = self._should_block_answer_with_guard(
question=clean_q,
@ -230,8 +267,53 @@ class AdaptiveRAGAnswerRetrievalMixin:
"reason": "insufficient_call_chain_evidence",
}
)
answer_mode = ANSWER_MODE_BLOCKED
elapsed = int((time.perf_counter() - started) * 1000)
grounding_verdict = GroundingVerdict(
status=GROUNDING_STATUS_GROUNDED,
missing_fields=[],
reason_code=None,
)
if evidence_guard_triggered:
grounding_verdict = GroundingVerdict(
status=GROUNDING_STATUS_BLOCKED,
missing_fields=["call_chain_evidence", "target_scope"],
reason_code="insufficient_evidence",
)
elif clarification_due_to_evidence:
grounding_verdict = GroundingVerdict(
status=GROUNDING_STATUS_INSUFFICIENT_EVIDENCE,
missing_fields=["high_relevance_evidence"],
reason_code="insufficient_evidence",
)
elif self._is_snapshot_only_sources(used_sources) or float(max_score or 0.0) < 0.45:
grounding_verdict = GroundingVerdict(
status=GROUNDING_STATUS_PARTIALLY_GROUNDED,
missing_fields=["high_relevance_evidence"] if float(max_score or 0.0) < 0.45 else [],
reason_code=None,
)
retrieval_metrics = {
"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),
"db_timeout": bool(db_timeout),
"vector_timeout": bool(vector_timeout),
"graph_timeout": bool(graph_timeout),
}
runtime_fields = self._build_qa_record_runtime_fields(
answer=answer_text,
sources=used_sources,
missing_fields=grounding_verdict.missing_fields,
answer_mode=answer_mode,
grounding_status=grounding_verdict.status,
prompt_variant_id=prompt_variant_id,
reason_code=grounding_verdict.reason_code,
routing_snapshot=routing_snapshot,
tool_trace=tool_trace,
retrieval_metrics=retrieval_metrics,
)
qa = QARecord(
tenant_id=self.tenant_id,
question=clean_q,
@ -250,16 +332,13 @@ class AdaptiveRAGAnswerRetrievalMixin:
anchor_ref=None,
routing_snapshot=routing_snapshot,
),
**runtime_fields,
)
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:
confidence = min(confidence, 0.35)
missing_fields = []
if max_score < 0.45:
missing_fields = ["high_relevance_evidence"]
if evidence_guard_triggered:
missing_fields = sorted(set([*missing_fields, "call_chain_evidence", "target_scope"]))
missing_fields = list(grounding_verdict.missing_fields)
await self._update_session_state_after_turn(
session_state=state_row,
question=clean_q,
@ -290,7 +369,7 @@ class AdaptiveRAGAnswerRetrievalMixin:
db_timeout=db_timeout,
vector_timeout=vector_timeout,
graph_timeout=graph_timeout,
tool_trace=tool_trace,
tool_trace=runtime_fields["tool_trace"],
qa_id=int(qa.id),
session_id=int(session_id) if session_id is not None else None,
turn_index=int(turn_index),
@ -299,5 +378,12 @@ class AdaptiveRAGAnswerRetrievalMixin:
confidence=confidence,
missing_fields=missing_fields,
thinking_mode=active_thinking_mode,
reason_code="answer_generated",
reason_code=grounding_verdict.reason_code or ("answer_generated" if answer_mode not in {ANSWER_MODE_CLARIFY, ANSWER_MODE_BLOCKED} else "insufficient_evidence"),
answer_mode=answer_mode,
grounding_status=grounding_verdict.status,
policy_version=runtime_fields["policy_version"],
citations=runtime_fields["citations"],
sources_used=runtime_fields["sources_used"],
evidence_pack=runtime_fields["evidence_pack"],
prompt_variant_id=prompt_variant_id,
)

View File

@ -11,6 +11,7 @@ class AdaptiveRAGCoreIntentMixin:
self.llm = LLMService()
self.context_cache = context_cache_service
self._active_external_qa_context: dict[str, Any] = {}
self._last_answer_prompt_variant_id = "unknown"
@staticmethod
def _normalize_thinking_mode(value: Optional[str]) -> str:
@ -94,12 +95,14 @@ class AdaptiveRAGCoreIntentMixin:
route_source = str(payload.get("route_source") or "").strip().lower()
route_rule_name = str(payload.get("route_rule_name") or "").strip()
routing_decision = str(payload.get("routing_decision") or "").strip()
routing_config_version = str(payload.get("routing_config_version") or "").strip()
normalized = {
"planned_tier": planned_tier if planned_tier in {"low", "high"} else None,
"executed_tier": executed_tier if executed_tier in {"low", "high"} else None,
"route_source": route_source or None,
"route_rule_name": route_rule_name or None,
"routing_decision": routing_decision or None,
"routing_config_version": routing_config_version or None,
}
return {key: value for key, value in normalized.items() if value is not None}
@ -135,6 +138,97 @@ class AdaptiveRAGCoreIntentMixin:
"routing_snapshot": normalized_snapshot,
}
def _remember_answer_prompt_variant(self, prompt_variant_id: Optional[str]) -> None:
normalized = str(prompt_variant_id or "").strip() or "unknown"
self._last_answer_prompt_variant_id = normalized[:64]
def _peek_answer_prompt_variant(self) -> str:
return str(getattr(self, "_last_answer_prompt_variant_id", None) or "").strip() or "unknown"
def _build_policy_version(
self,
*,
routing_snapshot: Optional[dict[str, Any]] = None,
prompt_variant_id: Optional[str] = None,
retrieval_policy_version: Optional[str] = None,
) -> str:
snapshot = self._normalize_routing_snapshot(routing_snapshot or self._peek_last_answer_routing_snapshot())
routing_version = str(snapshot.get("routing_config_version") or "").strip() or "unknown"
prompt_variant = str(prompt_variant_id or self._peek_answer_prompt_variant() or "").strip() or "unknown"
retrieval_version = str(retrieval_policy_version or RETRIEVAL_POLICY_VERSION).strip() or RETRIEVAL_POLICY_VERSION
return f"{retrieval_version}|routing:{routing_version}|prompt:{prompt_variant}"
def _build_qa_record_runtime_fields(
self,
*,
answer: str,
sources: list[dict[str, Any]],
missing_fields: Optional[list[str]] = None,
answer_mode: Optional[str] = None,
grounding_status: Optional[str] = None,
policy_version: Optional[str] = None,
prompt_variant_id: Optional[str] = None,
citations: Optional[list[dict[str, Any]]] = None,
sources_used: Optional[list[dict[str, Any]]] = None,
evidence_pack: Optional[dict[str, Any]] = None,
tool_trace: Optional[list[dict[str, Any]]] = None,
retrieval_metrics: Optional[dict[str, Any]] = None,
reason_code: Optional[str] = None,
routing_snapshot: Optional[dict[str, Any]] = None,
) -> dict[str, Any]:
normalized_citations = (
citations
if isinstance(citations, list)
else self._build_citations_from_sources(sources)
)
normalized_sources_used = (
sources_used
if isinstance(sources_used, list)
else self._build_sources_used_from_citations(normalized_citations)
)
normalized_tool_trace = tool_trace if isinstance(tool_trace, list) else []
normalized_metrics = retrieval_metrics if isinstance(retrieval_metrics, dict) else {}
normalized_answer_mode = self._normalize_answer_mode(answer_mode)
normalized_grounding_status = self._normalize_grounding_status(
grounding_status,
sources=sources,
missing_fields=missing_fields,
answer_mode=normalized_answer_mode,
reason_code=reason_code,
)
normalized_policy_version = str(
policy_version
or self._build_policy_version(
routing_snapshot=routing_snapshot,
prompt_variant_id=prompt_variant_id,
)
).strip() or self._build_policy_version(
routing_snapshot=routing_snapshot,
prompt_variant_id=prompt_variant_id,
)
normalized_evidence_pack = (
evidence_pack
if isinstance(evidence_pack, dict)
else self._build_evidence_pack(
answer=answer,
citations=normalized_citations,
tool_trace=normalized_tool_trace,
missing_fields=missing_fields,
grounding_status=normalized_grounding_status,
reason_code=reason_code,
)
)
return {
"answer_mode": normalized_answer_mode,
"grounding_status": normalized_grounding_status,
"policy_version": normalized_policy_version,
"citations": normalized_citations,
"sources_used": normalized_sources_used,
"evidence_pack": normalized_evidence_pack,
"tool_trace": normalized_tool_trace,
"retrieval_metrics": normalized_metrics,
}
@staticmethod
def _external_session_id_from_key(conversation_key: str) -> Optional[int]:
key = str(conversation_key or "").strip()

View File

@ -7,6 +7,50 @@ from .shared import *
class AdaptiveRAGDeepGuardAnswerMixin:
@staticmethod
def _normalize_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
@classmethod
def _apply_consultant_mode_preference(
cls,
*,
scene_key: str,
prompt: str,
consultant_mode_preference: Optional[str],
) -> tuple[str, str]:
mode = cls._normalize_consultant_mode_preference(consultant_mode_preference)
if not mode:
return scene_key, prompt
label_map = {
"clarify": "先澄清",
"advise": "给建议",
"challenge": "先劝退",
"brainstorm": "一起想",
"execute": "可执行",
}
instruction_map = {
"clarify": "请优先指出证据不足或边界不清的地方,并给出最多 3 个必须先补充的澄清问题。",
"advise": "请优先给出稳妥建议,明确推荐方案、原因、取舍和下一步。",
"challenge": "请优先挑战隐含假设、范围膨胀和高风险点,不要把推断包装成已验证事实。",
"brainstorm": "请优先给出 2-3 个可选方向,分别说明适用场景、代价和收敛建议。",
"execute": "请只说明可执行前提、风险和下一步,不得把未发生的写操作表述为已完成。",
}
label = label_map.get(mode, mode)
supplement = instruction_map.get(mode, "")
wrapped_prompt = str(prompt or "").rstrip()
if supplement:
wrapped_prompt = (
f"{wrapped_prompt}\n\n"
f"补充要求(顾问模式:{label}):\n"
f"{supplement}"
)
return f"{scene_key}_consultant_{mode}", wrapped_prompt
@staticmethod
def _compact_member_workload_user_snapshot(user_snapshot: Optional[dict[str, Any]]) -> dict[str, Any]:
if not isinstance(user_snapshot, dict):
@ -171,6 +215,7 @@ class AdaptiveRAGDeepGuardAnswerMixin:
user_snapshot_text: str,
relevance_max: float,
query_type: Optional[str] = None,
consultant_mode_preference: Optional[str] = None,
) -> tuple[str, str]:
normalized_question = re.sub(r"\s+", "", str(question or "").lower())
member_keywords = ("谁", "成员", "负责人", "负载")
@ -180,7 +225,7 @@ class AdaptiveRAGDeepGuardAnswerMixin:
filtered_context_blocks = self._filter_member_workload_context_blocks(context_blocks_text)
compact_user_snapshot = self._compact_member_workload_user_snapshot(user_snapshot)
compact_user_snapshot_text = json.dumps(compact_user_snapshot or {}, ensure_ascii=False)[:6000]
return (
scene_key, prompt = (
"member_workload",
await self._render_scene_prompt(
prompt_key="PROMPT_MEMBER_WORKLOAD_QA",
@ -191,11 +236,16 @@ class AdaptiveRAGDeepGuardAnswerMixin:
context_blocks=filtered_context_blocks,
),
)
return self._apply_consultant_mode_preference(
scene_key=scene_key,
prompt=prompt,
consultant_mode_preference=consultant_mode_preference,
)
if str(message_intent or "").strip().lower() == "mixed":
safe_signal_part = str(mixed_signal_part or "").strip() or "需求信息待补充"
merged_message = f"{question}\n{safe_signal_part}".strip()
return (
scene_key, prompt = (
"mixed",
await self._render_scene_prompt(
prompt_key="PROMPT_FEISHU_MIXED_QA",
@ -208,9 +258,14 @@ class AdaptiveRAGDeepGuardAnswerMixin:
context_blocks=context_blocks_text or "无",
),
)
return self._apply_consultant_mode_preference(
scene_key=scene_key,
prompt=prompt,
consultant_mode_preference=consultant_mode_preference,
)
if self._is_project_status_question(question, query_type):
return (
scene_key, prompt = (
"project_status",
await self._render_scene_prompt(
prompt_key="PROMPT_PROJECT_STATUS_QA",
@ -222,6 +277,11 @@ class AdaptiveRAGDeepGuardAnswerMixin:
context_blocks=context_blocks_text or "无",
),
)
return self._apply_consultant_mode_preference(
scene_key=scene_key,
prompt=prompt,
consultant_mode_preference=consultant_mode_preference,
)
if self._contains_any_keyword(normalized_question, trend_keywords):
explicit_project_context_markers = (
@ -250,7 +310,7 @@ class AdaptiveRAGDeepGuardAnswerMixin:
},
ensure_ascii=False,
)[:16000]
return (
scene_key, prompt = (
"external_trend",
await self._render_scene_prompt(
prompt_key="PROMPT_EXTERNAL_TREND_QA",
@ -260,7 +320,12 @@ class AdaptiveRAGDeepGuardAnswerMixin:
external_results=context_blocks_text or "无",
),
)
return (
return self._apply_consultant_mode_preference(
scene_key=scene_key,
prompt=prompt,
consultant_mode_preference=consultant_mode_preference,
)
scene_key, prompt = (
"industry_trend",
await self._render_scene_prompt(
prompt_key="PROMPT_INDUSTRY_TREND_QA",
@ -268,9 +333,14 @@ class AdaptiveRAGDeepGuardAnswerMixin:
question=question,
),
)
return self._apply_consultant_mode_preference(
scene_key=scene_key,
prompt=prompt,
consultant_mode_preference=consultant_mode_preference,
)
if self._is_decision_choice_question(question):
return (
scene_key, prompt = (
"decision_choice",
await self._render_scene_prompt(
prompt_key="PROMPT_DECISION_QA",
@ -278,6 +348,11 @@ class AdaptiveRAGDeepGuardAnswerMixin:
question=question,
),
)
return self._apply_consultant_mode_preference(
scene_key=scene_key,
prompt=prompt,
consultant_mode_preference=consultant_mode_preference,
)
if self._is_ai_pmo_process_entry(asked_via):
world_model_text = json.dumps(
@ -287,7 +362,7 @@ class AdaptiveRAGDeepGuardAnswerMixin:
},
ensure_ascii=False,
)[:18000]
return (
scene_key, prompt = (
"ai_pmo",
await self._render_scene_prompt(
prompt_key="PROMPT_AI_PMO_QA",
@ -298,10 +373,15 @@ class AdaptiveRAGDeepGuardAnswerMixin:
context_blocks=context_blocks_text or "无",
),
)
return self._apply_consultant_mode_preference(
scene_key=scene_key,
prompt=prompt,
consultant_mode_preference=consultant_mode_preference,
)
normalized_asked_via = str(asked_via or "").strip().lower()
if normalized_asked_via in {"feishu", "lark", "feishu_group", "feishu_dm"}:
return (
scene_key, prompt = (
"feishu_direct",
await self._render_scene_prompt(
prompt_key="PROMPT_FEISHU_DIRECT_QA",
@ -314,8 +394,13 @@ class AdaptiveRAGDeepGuardAnswerMixin:
context_blocks=context_blocks_text or "无",
),
)
return self._apply_consultant_mode_preference(
scene_key=scene_key,
prompt=prompt,
consultant_mode_preference=consultant_mode_preference,
)
return (
scene_key, prompt = (
"default",
await self._render_scene_prompt(
prompt_key="PROMPT_RAG_ANSWER",
@ -329,6 +414,11 @@ class AdaptiveRAGDeepGuardAnswerMixin:
relevance_max=f"{max(0.0, float(relevance_max or 0.0)):.3f}",
),
)
return self._apply_consultant_mode_preference(
scene_key=scene_key,
prompt=prompt,
consultant_mode_preference=consultant_mode_preference,
)
def _build_snapshot_direct_answer(
self,
@ -534,6 +624,7 @@ class AdaptiveRAGDeepGuardAnswerMixin:
message_intent: Optional[str] = None,
mixed_signal_part: Optional[str] = None,
query_type: Optional[str] = None,
consultant_mode_preference: Optional[str] = None,
) -> str:
active_thinking_mode = self._normalize_thinking_mode(thinking_mode)
deep_thinking = active_thinking_mode == THINKING_MODE_DEEP
@ -582,7 +673,11 @@ class AdaptiveRAGDeepGuardAnswerMixin:
user_snapshot_text=user_snapshot_text,
relevance_max=relevance_max,
query_type=query_type,
consultant_mode_preference=consultant_mode_preference,
)
remember_prompt_variant = getattr(self, "_remember_answer_prompt_variant", None)
if callable(remember_prompt_variant):
remember_prompt_variant(scene_key)
if deep_thinking:
prompt = (
f"{prompt}\n\n"
@ -613,7 +708,7 @@ class AdaptiveRAGDeepGuardAnswerMixin:
text = str(answer or "").strip()
if not text:
raise ValueError("adaptive_rag_answer_empty_response")
if scene_key == "mixed":
if scene_key == "mixed" or str(scene_key).startswith("mixed_consultant_"):
return self._format_mixed_two_block_output(
answer_text=text,
question_part=question,

View File

@ -166,6 +166,7 @@ class AdaptiveRAGFastAnswerSemanticMixinP1:
],
"db_query_ms": db_query_ms,
"response_time_ms": response_ms,
"prompt_variant_id": "glm_semantic",
}
except Exception:
return None

View File

@ -22,6 +22,7 @@ class AdaptiveRAGFastAnswerSemanticMixinP2:
effective_question: Optional[str] = None,
rewrite_meta: Optional[dict[str, Any]] = None,
thinking_mode: str = THINKING_MODE_NORMAL,
answer_mode: str = ANSWER_MODE_FAST_PATH,
) -> dict[str, Any]:
started = time.perf_counter()
answer_text = str(fast_result.get("answer") or "未命中结果")
@ -42,6 +43,24 @@ class AdaptiveRAGFastAnswerSemanticMixinP2:
if missing_fields is None:
missing_fields = list(fast_result.get("missing_fields") or [])
final_query_type = str(fast_result.get("query_type") or query_type or QUERY_TYPE_EXPLAIN)
prompt_variant_id = str(fast_result.get("prompt_variant_id") or "deterministic").strip() or "deterministic"
runtime_fields = self._build_qa_record_runtime_fields(
answer=answer_text,
sources=sources,
missing_fields=missing_fields,
answer_mode=answer_mode,
grounding_status=GROUNDING_STATUS_GROUNDED,
prompt_variant_id=prompt_variant_id,
reason_code=str(fast_result.get("reason_code") or "").strip() or None,
retrieval_metrics={
"db_query_ms": int(fast_result.get("db_query_ms") or 0),
"vector_search_ms": 0,
"graph_query_ms": 0,
"db_timeout": False,
"vector_timeout": False,
"graph_timeout": False,
},
)
qa = QARecord(
tenant_id=self.tenant_id,
@ -57,6 +76,7 @@ class AdaptiveRAGFastAnswerSemanticMixinP2:
session_id=active_session_id_int,
turn_index=active_turn_index,
**self._build_qa_record_external_fields(),
**runtime_fields,
)
self.db.add(qa)
if state_row is None and active_session_id_int is not None:
@ -100,4 +120,12 @@ class AdaptiveRAGFastAnswerSemanticMixinP2:
confidence=confidence,
missing_fields=list(missing_fields or []),
thinking_mode=thinking_mode,
answer_mode=answer_mode,
grounding_status=GROUNDING_STATUS_GROUNDED,
policy_version=runtime_fields["policy_version"],
citations=runtime_fields["citations"],
sources_used=runtime_fields["sources_used"],
evidence_pack=runtime_fields["evidence_pack"],
tool_trace=runtime_fields["tool_trace"],
prompt_variant_id=prompt_variant_id,
)

View File

@ -102,7 +102,26 @@ class AdaptiveRAGRequirementAckFlowMixin:
]
qa_id: Optional[int] = None
missing_fields = [] if resolved_project_id is not None else ["project_id"]
try:
runtime_fields = self._build_qa_record_runtime_fields(
answer=answer_text,
sources=sources,
missing_fields=missing_fields,
answer_mode=ANSWER_MODE_FAST_PATH,
grounding_status=GROUNDING_STATUS_GROUNDED if action_taken in {"signal_created", "signal_deduped"} else GROUNDING_STATUS_PARTIALLY_GROUNDED,
prompt_variant_id="requirement_ack",
reason_code=str(action_taken or "").strip() or None,
retrieval_metrics={
"db_query_ms": 0,
"vector_search_ms": 0,
"graph_query_ms": 0,
"db_timeout": False,
"vector_timeout": False,
"graph_timeout": False,
},
tool_trace=[],
)
qa = QARecord(
tenant_id=self.tenant_id,
question=question,
@ -117,6 +136,7 @@ class AdaptiveRAGRequirementAckFlowMixin:
session_id=session_id,
turn_index=int(turn_index or 1),
**self._build_qa_record_external_fields(),
**runtime_fields,
)
self.db.add(qa)
await self._update_session_state_after_turn(
@ -146,7 +166,6 @@ class AdaptiveRAGRequirementAckFlowMixin:
pass
confidence = 0.94 if action_taken in {"signal_created", "signal_deduped"} else 0.35
missing_fields = [] if resolved_project_id is not None else ["project_id"]
return self._build_response_payload(
answer=answer_text,
sources=sources,
@ -171,4 +190,8 @@ class AdaptiveRAGRequirementAckFlowMixin:
ack_latency_ms=ack_latency_ms,
trace_id=trace_id,
processing_stage="ack_only",
answer_mode=ANSWER_MODE_FAST_PATH,
grounding_status=GROUNDING_STATUS_GROUNDED if action_taken in {"signal_created", "signal_deduped"} else GROUNDING_STATUS_PARTIALLY_GROUNDED,
policy_version=self._build_policy_version(prompt_variant_id="requirement_ack"),
prompt_variant_id="requirement_ack",
)

View File

@ -54,6 +54,16 @@ QUERY_TYPE_LIST = "list"
QUERY_TYPE_STATUS = "status"
QUERY_TYPE_EXPLAIN = "explain"
QUERY_TYPE_UNKNOWN = "unknown"
ANSWER_MODE_FAST_PATH = "fast_path"
ANSWER_MODE_RETRIEVAL_NORMAL = "retrieval_normal"
ANSWER_MODE_RETRIEVAL_DEEP = "retrieval_deep"
ANSWER_MODE_CLARIFY = "clarify"
ANSWER_MODE_BLOCKED = "blocked"
GROUNDING_STATUS_GROUNDED = "grounded"
GROUNDING_STATUS_PARTIALLY_GROUNDED = "partially_grounded"
GROUNDING_STATUS_INSUFFICIENT_EVIDENCE = "insufficient_evidence"
GROUNDING_STATUS_BLOCKED = "blocked"
RETRIEVAL_POLICY_VERSION = "retrieval-v2.1"
ACTION_MODE_AUTO = "auto"
ACTION_MODE_READ = "read"
ACTION_MODE_WRITE = "write"
@ -147,3 +157,26 @@ class RetrievedItem:
score: float
source_label: str
metadata: dict[str, Any]
@dataclass
class RetrievalBudget:
max_steps: int
vector_top_k: int
@dataclass
class QueryPlannerDecision:
answer_mode: str
thinking_mode: str
allow_fast_path: bool
retrieval_answer_mode: str
budget: RetrievalBudget
reason: Optional[str] = None
@dataclass
class GroundingVerdict:
status: str
missing_fields: list[str]
reason_code: Optional[str] = None

View File

@ -4,6 +4,7 @@ from __future__ import annotations
import hashlib
import json
import re
from datetime import timedelta
from typing import Any, Optional

View File

@ -43,6 +43,7 @@ class ExecuteQueryProjectMixin:
sync_requirement = bool(rag_request_context.get("sync_requirement"))
message_intent = str(rag_request_context.get("message_intent") or "").strip() or None
mixed_signal_part = str(rag_request_context.get("mixed_signal_part") or "").strip() or None
consultant_mode_preference = str(rag_request_context.get("consultant_mode_preference") or "").strip().lower() or None
else:
thread_context = metadata.get("thread_context") if isinstance(metadata.get("thread_context"), dict) else None
source_sender_open_id = str(metadata.get("source_sender_open_id") or "").strip() or None
@ -71,6 +72,7 @@ class ExecuteQueryProjectMixin:
sync_requirement = False
message_intent = None
mixed_signal_part = None
consultant_mode_preference = None
if isinstance(rag_request_context, dict):
source_sender_open_id = str(rag_request_context.get("source_sender_open_id") or "").strip() or None
thread_context = rag_request_context.get("thread_context") if isinstance(rag_request_context.get("thread_context"), dict) else None
@ -88,6 +90,7 @@ class ExecuteQueryProjectMixin:
"sync_requirement": sync_requirement,
"message_intent": message_intent,
"mixed_signal_part": mixed_signal_part,
"consultant_mode_preference": consultant_mode_preference,
}
if session_id is not None:
rag_kwargs["session_id"] = int(session_id)
@ -98,6 +101,17 @@ class ExecuteQueryProjectMixin:
try:
rag_result = await rag_service.answer_question(**rag_kwargs)
except Exception as exc:
rollback = getattr(self.db, "rollback", None)
if callable(rollback):
try:
await rollback()
except Exception:
logger.warning(
"ai_pmo_query_intent_rollback_failed",
tenant_id=self.tenant_id,
source_system=source_system,
source_message_id=source_message_id,
)
return {
"status": "failed_safe",
"error_message": self._build_query_error_message(exc),

View File

@ -63,6 +63,48 @@ _MISSING_FIELD_LABELS = {
"project_scope": "项目范围",
}
_LONG_REPLY_SECTION_HINTS = (
"结论:",
"依据:",
"不确定项:",
"事实:",
"建议:",
"待确认项:",
)
_DOMAIN_NEXT_ACTION_FALLBACKS: dict[str, dict[str, str]] = {
DOMAIN_RAG: {
MODE_ADVISE: "继续追问具体模块、入口函数或文件路径,我会把调用链收窄到那一段。",
MODE_CLARIFY: "请先限定范围:前端入口、/rag/ask、Kernel 或 AdaptiveRAG 其中一段。",
MODE_CHALLENGE: "先确认你要的是 RAG 链路、业务执行链路,还是完整系统拓扑,再继续追查。",
MODE_BRAINSTORM: "告诉我你最想看的那一段,我给你 2-3 条追查路径。",
},
DOMAIN_PROJECT: {
MODE_ADVISE: "继续补充项目目标、时间边界或负责人,我会把建议收敛成更具体方案。",
MODE_CLARIFY: "请先补充归属项目、目标范围或关键约束,我再继续推进。",
MODE_CHALLENGE: "先确认项目范围和优先级,避免一次推进过多内容。",
MODE_BRAINSTORM: "告诉我目标、约束和时间窗口,我给你 2-3 个项目推进方向。",
},
DOMAIN_TASK: {
MODE_ADVISE: "继续补充任务目标、负责人或截止时间,我会给出更具体建议。",
MODE_CLARIFY: "请先补充任务标题、归属项目或截止时间,我再继续推进。",
MODE_CHALLENGE: "先确认任务边界和优先级,避免一次塞进过多要求。",
MODE_BRAINSTORM: "告诉我任务目标和约束,我给你拆成 2-3 条可执行路径。",
},
DOMAIN_SIGNAL: {
MODE_ADVISE: "继续补充信号背景、归属项目或影响范围,我会给出更具体建议。",
MODE_CLARIFY: "请先补充归属项目、标题或信号背景,我再继续推进。",
MODE_CHALLENGE: "先确认这个信号是否值得入库,再决定是否继续推进执行。",
MODE_BRAINSTORM: "告诉我信号来源、影响对象和目标,我给你列 2-3 种处理路径。",
},
DOMAIN_GENERIC: {
MODE_ADVISE: "继续补充你最关心的对象、范围或约束,我会给出更具体建议。",
MODE_CLARIFY: "请先补充关键对象、范围或约束,我再继续推进。",
MODE_CHALLENGE: "先确认目标边界和优先级,再决定是否继续推进。",
MODE_BRAINSTORM: "告诉我目标和约束,我给你列 2-3 个可选方向。",
},
}
def _normalize_missing_fields(value: Any) -> list[str]:
if not isinstance(value, list):
@ -75,6 +117,27 @@ def _normalize_missing_fields(value: Any) -> list[str]:
return fields
def _normalize_action_text(text: str) -> str:
return re.sub(r"\s+", " ", str(text or "")).strip()
def _looks_like_long_answer(text: str) -> bool:
raw = str(text or "")
normalized = _normalize_action_text(raw)
if not normalized:
return False
if "\n" in raw or len(normalized) > 120:
return True
return any(token in normalized for token in _LONG_REPLY_SECTION_HINTS)
def _domain_next_action(domain: str, mode: str) -> str:
normalized_domain = str(domain or "").strip().lower() or DOMAIN_GENERIC
normalized_mode = str(mode or "").strip().lower() or MODE_ADVISE
domain_map = _DOMAIN_NEXT_ACTION_FALLBACKS.get(normalized_domain) or _DOMAIN_NEXT_ACTION_FALLBACKS[DOMAIN_GENERIC]
return str(domain_map.get(normalized_mode) or _DOMAIN_NEXT_ACTION_FALLBACKS[DOMAIN_GENERIC][MODE_ADVISE]).strip()
def _resolve_domain(*, intent_name: str, primary_operation_code: Optional[str], classification: str) -> str:
normalized_op = str(primary_operation_code or "").strip().lower()
normalized_intent = str(intent_name or "").strip().lower()
@ -162,11 +225,11 @@ def _derive_mode(
def _build_clarify_next_action(*, missing_fields: list[str], direct_reply_text: str) -> str:
if direct_reply_text:
return direct_reply_text[:240]
if direct_reply_text and not _looks_like_long_answer(direct_reply_text):
return _normalize_action_text(direct_reply_text)[:120]
labels = [_MISSING_FIELD_LABELS.get(item, item) for item in missing_fields]
suffix = "、".join([label for label in labels if label]) or "关键信息"
return f"请先补充 {suffix},我再继续推进。"
return f"请先补充 {suffix},我再继续推进。"[:120]
def _build_next_action(
@ -183,13 +246,13 @@ def _build_next_action(
direct_reply_text=direct_reply_text,
)
if mode == MODE_CHALLENGE:
if direct_reply_text:
return direct_reply_text[:240]
if direct_reply_text and not _looks_like_long_answer(direct_reply_text):
return _normalize_action_text(direct_reply_text)[:120]
if str(risk_level or "").strip().lower() == "high":
return "建议先拆分范围,再确认是否继续推进高风险动作。"
return "建议先确认目标边界与优先级,再决定是否继续推进。"
return "建议先拆分范围,再确认是否继续推进高风险动作。"[:120]
return _domain_next_action(domain=domain, mode=mode)
if mode == MODE_BRAINSTORM:
return "请补充目标用户、核心场景和约束,我会继续给出 2-3 个可选方向。"
return _domain_next_action(domain=domain, mode=mode)
if mode == MODE_EXECUTE:
if domain == DOMAIN_PROJECT:
return "可继续查看项目结果,或补充负责人与排期信息。"
@ -198,9 +261,9 @@ def _build_next_action(
if domain == DOMAIN_SIGNAL:
return "可继续确认归属项目,或查看后续自动处理结果。"
return "可继续查看结果,或补充更多执行细节。"
if direct_reply_text:
return direct_reply_text[:240]
return "你可以继续追问细节,或让我给出更具体的建议。"
if direct_reply_text and not _looks_like_long_answer(direct_reply_text):
return _normalize_action_text(direct_reply_text)[:120]
return _domain_next_action(domain=domain, mode=mode)
def build_consultant_dispatch_contract(

View File

@ -198,6 +198,8 @@ def map_qa_feedback_to_record_payload(*, qa_feedback: Any, qa_record: Any) -> di
"question": getattr(qa_record, "question", None),
"answer": getattr(qa_record, "answer", None),
"sources": _jsonable(getattr(qa_record, "sources", None) or []),
"citations": _jsonable(getattr(qa_record, "citations", None) or []),
"sources_used": _jsonable(getattr(qa_record, "sources_used", None) or []),
}
after_value = {
"rating": rating,
@ -228,6 +230,14 @@ def map_qa_feedback_to_record_payload(*, qa_feedback: Any, qa_record: Any) -> di
"answer": getattr(qa_record, "answer", None),
"sources": _jsonable(getattr(qa_record, "sources", None) or []),
"domains_used": _jsonable(getattr(qa_record, "domains_used", None) or []),
"answer_mode": getattr(qa_record, "answer_mode", None),
"grounding_status": getattr(qa_record, "grounding_status", None),
"policy_version": getattr(qa_record, "policy_version", None),
"citations": _jsonable(getattr(qa_record, "citations", None) or []),
"sources_used": _jsonable(getattr(qa_record, "sources_used", None) or []),
"evidence_pack": _jsonable(getattr(qa_record, "evidence_pack", None) or {}),
"tool_trace": _jsonable(getattr(qa_record, "tool_trace", None) or []),
"retrieval_metrics": _jsonable(getattr(qa_record, "retrieval_metrics", None) or {}),
},
"ai_confidence": None,
"reason_candidates": reason_candidates,

View File

@ -95,6 +95,62 @@ _REASON_DEFINITIONS: list[dict[str, Any]] = [
"field_names": {"qa_feedback"},
"confidence": 0.86,
},
{
"code": "wrong_fast_path",
"label": "误走快速路径",
"description": "问题本应进入更重的检索路径,却被过早走了 fast path。",
"source_types": {"qa_feedback"},
"field_names": {"qa_feedback"},
"confidence": 0.9,
},
{
"code": "should_have_gone_deep",
"label": "应该进入深检索",
"description": "当前问题需要更深层的证据检索与合成,但 runtime 未升级。",
"source_types": {"qa_feedback"},
"field_names": {"qa_feedback"},
"confidence": 0.91,
},
{
"code": "wrong_source_priority",
"label": "来源优先级错误",
"description": "检索时对事实源的优先级排序不合理,导致引用偏差。",
"source_types": {"qa_feedback"},
"field_names": {"qa_feedback"},
"confidence": 0.88,
},
{
"code": "insufficient_evidence",
"label": "证据不足",
"description": "回答缺少足够内部证据支撑,应澄清或拒答。",
"source_types": {"qa_feedback"},
"field_names": {"qa_feedback"},
"confidence": 0.94,
},
{
"code": "good_retrieval_bad_synthesis",
"label": "检索好但合成差",
"description": "证据命中基本正确,但最终答案组织、归纳或结论表达有偏差。",
"source_types": {"qa_feedback"},
"field_names": {"qa_feedback"},
"confidence": 0.83,
},
{
"code": "over_deep_latency",
"label": "过度深检索延迟",
"description": "问题本可快速回答,但进入过深链路导致延迟偏高。",
"source_types": {"qa_feedback"},
"field_names": {"qa_feedback"},
"confidence": 0.84,
},
{
"code": "permission_scope_miss",
"label": "权限范围漏检",
"description": "检索没有正确命中用户应有权限范围内的事实源。",
"source_types": {"qa_feedback"},
"field_names": {"qa_feedback"},
"confidence": 0.87,
},
{
"code": "strategic_judgement_mismatch",
"label": "战略判断偏差",
@ -325,6 +381,22 @@ def build_reason_candidates(
0.78,
"回答缺少关键信息或上下文不够完整。",
)
grounding_reason_map = {
"wrong_fast_path": ("wrong_fast_path", "误走快速路径", 0.91, "问题本应进入更重的检索路径,却被过早走了 fast path。"),
"fast_path": ("wrong_fast_path", "误走快速路径", 0.89, "问题本应进入更重的检索路径,却被过早走了 fast path。"),
"should_have_gone_deep": ("should_have_gone_deep", "应该进入深检索", 0.92, "当前问题需要更深层的证据检索与合成,但 runtime 未升级。"),
"deep": ("should_have_gone_deep", "应该进入深检索", 0.88, "当前问题需要更深层的证据检索与合成,但 runtime 未升级。"),
"wrong_source_priority": ("wrong_source_priority", "来源优先级错误", 0.88, "检索时对事实源的优先级排序不合理。"),
"insufficient_evidence": ("insufficient_evidence", "证据不足", 0.94, "回答缺少足够内部证据支撑,应澄清或拒答。"),
"good_retrieval_bad_synthesis": ("good_retrieval_bad_synthesis", "检索好但合成差", 0.83, "证据命中基本正确,但最终答案组织或结论表达有偏差。"),
"over_deep_latency": ("over_deep_latency", "过度深检索延迟", 0.84, "问题本可快速回答,但进入过深链路导致延迟偏高。"),
"permission_scope_miss": ("permission_scope_miss", "权限范围漏检", 0.87, "检索没有正确命中用户应有权限范围内的事实源。"),
}
for token in error_types:
mapping = grounding_reason_map.get(token)
if mapping is None:
continue
add(mapping[0], mapping[1], mapping[2], mapping[3])
else:
add("manual_review_needed", "需要人工复核", 0.72, "QA 反馈建议保留人工复核以确认样本质量。")
elif source_key == "signal_feedback":

View File

@ -177,6 +177,7 @@ class LLMPromptProfileRuntimeMixin:
"route_source": meta.get("route_source") or None,
"route_rule_name": meta.get("route_rule_name") or None,
"routing_decision": meta.get("routing_decision") or None,
"routing_config_version": meta.get("routing_config_version") or None,
}
async with AsyncSessionLocal() as session:
for attempt in range(2):

View File

@ -29,6 +29,11 @@ def _use_sqlite_json_types() -> None:
if column.name in {
"sources",
"domains_used",
"citations",
"sources_used",
"evidence_pack",
"tool_trace",
"retrieval_metrics",
"last_entity_ids",
"rewrite_history",
"pending_clarification",

View File

@ -33,10 +33,12 @@ class _FakeAdaptiveRAGService(
"context_session_id": 101,
"conversation": [],
"state_row": None,
"resolved_question": clean_q,
"effective_q": clean_q,
"rewrite_meta": {"mode": "direct"},
"query_type": "explain",
"effective_action_mode": action_mode,
"anchor_resolution": None,
}
async def _handle_ack_first_requirement(self, **kwargs):
@ -85,25 +87,43 @@ async def test_answer_entry_write_mode_uses_requirement_ack_gate() -> None:
@pytest.mark.asyncio
async def test_answer_entry_high_accuracy_channel_forces_deep_mode() -> None:
async def test_answer_entry_high_accuracy_channel_respects_requested_normal_mode() -> None:
service = _FakeAdaptiveRAGService()
result = await service.answer_question(
question="最近有哪些项目?",
asked_by=1,
asked_via="pmflow",
thinking_mode="normal",
sync_requirement=True,
)
assert result["processing_stage"] == "qa"
assert service.ack_calls == 0
assert service.base_calls == 1
assert service.base_kwargs["active_thinking_mode"] == "deep"
assert service.base_kwargs["deep_thinking"] is True
assert service.base_kwargs["active_thinking_mode"] == "normal"
assert service.base_kwargs["deep_thinking"] is False
assert int(service.base_kwargs["deep_retrieval_steps"]) == DEEP_RETRIEVAL_STEPS
assert int(service.base_kwargs["deep_vector_top_k"]) == DEEP_VECTOR_TOP_K
@pytest.mark.asyncio
async def test_answer_entry_forwards_consultant_mode_preference_to_mainline() -> None:
service = _FakeAdaptiveRAGService()
result = await service.answer_question(
question="这个方案怎么做更稳妥?",
asked_by=1,
asked_via="pmflow",
thinking_mode="normal",
consultant_mode_preference="advise",
)
assert result["processing_stage"] == "qa"
assert service.base_calls == 1
assert service.base_kwargs["consultant_mode_preference"] == "advise"
@pytest.mark.asyncio
async def test_answer_entry_mixed_request_skips_requirement_ack_gate() -> None:
service = _FakeAdaptiveRAGService()
@ -120,8 +140,8 @@ async def test_answer_entry_mixed_request_skips_requirement_ack_gate() -> None:
assert result["processing_stage"] == "qa"
assert service.ack_calls == 0
assert service.base_calls == 1
assert service.base_kwargs["active_thinking_mode"] == "deep"
assert service.base_kwargs["deep_thinking"] is True
assert service.base_kwargs["active_thinking_mode"] == "normal"
assert service.base_kwargs["deep_thinking"] is False
@pytest.mark.asyncio
@ -139,12 +159,49 @@ async def test_answer_entry_feishu_write_mode_still_goes_base_mainline() -> None
assert result["processing_stage"] == "qa"
assert service.ack_calls == 0
assert service.base_calls == 1
assert service.base_kwargs["active_thinking_mode"] == "deep"
assert service.base_kwargs["deep_thinking"] is True
assert service.base_kwargs["active_thinking_mode"] == "normal"
assert service.base_kwargs["deep_thinking"] is False
assert int(service.base_kwargs["deep_retrieval_steps"]) == DEEP_RETRIEVAL_STEPS
assert int(service.base_kwargs["deep_vector_top_k"]) == DEEP_VECTOR_TOP_K
def test_query_runtime_planner_prefers_fast_path_for_list_question_even_if_requested_deep() -> None:
class _FakePlannerService(AdaptiveRAGAnswerMainlineMixin):
pass
service = _FakePlannerService()
decision = service._plan_query_runtime(
question="现在有哪些项目?",
effective_question="现在有哪些项目?",
query_type="list",
requested_thinking_mode="deep",
enforce_scene_prompt_only=False,
)
assert decision.answer_mode == "fast_path"
assert decision.thinking_mode == "normal"
assert decision.allow_fast_path is True
def test_query_runtime_planner_bypasses_fast_path_when_consultant_mode_preference_present() -> None:
class _FakePlannerService(AdaptiveRAGAnswerMainlineMixin):
pass
service = _FakePlannerService()
decision = service._plan_query_runtime(
question="现在有哪些项目?",
effective_question="现在有哪些项目?",
query_type="list",
requested_thinking_mode="normal",
enforce_scene_prompt_only=False,
consultant_mode_preference="advise",
)
assert decision.answer_mode == "retrieval_normal"
assert decision.thinking_mode == "normal"
assert decision.allow_fast_path is False
@pytest.mark.asyncio
async def test_deterministic_query_prefers_project_created_at_answer() -> None:
calls: list[str] = []
@ -207,7 +264,7 @@ async def test_deep_guard_answer_raises_on_empty_llm_response() -> None:
_ = project_id
return {}
def _select_scene_prompt(self, **kwargs):
async def _select_scene_prompt(self, **kwargs):
_ = kwargs
return "external_trend", "请回答问题"
@ -289,3 +346,38 @@ async def test_deep_guard_answer_prefers_member_workload_direct_answer() -> None
assert "杨宇当前负载265.56%" in result
assert "严重超载" in result
@pytest.mark.asyncio
async def test_select_scene_prompt_applies_consultant_mode_preference_wrapper() -> None:
class _FakeService(AdaptiveRAGDeepGuardAnswerMixin):
def __init__(self) -> None:
self.db = object()
self.tenant_id = 1
async def _render_scene_prompt(self, *, prompt_key: str, fallback_template: str, **kwargs):
_ = (fallback_template, kwargs)
return f"PROMPT::{prompt_key}"
service = _FakeService()
scene_key, prompt = await service._select_scene_prompt(
question="这个方案怎么做更稳妥?",
project_id=7,
asked_via="pmflow",
message_intent=None,
mixed_signal_part=None,
conversation_text="无",
context_blocks_text="无",
system_snapshot={},
user_snapshot={},
system_snapshot_text="{}",
user_snapshot_text="{}",
relevance_max=0.61,
query_type="explain",
consultant_mode_preference="advise",
)
assert scene_key == "default_consultant_advise"
assert "PROMPT::PROMPT_RAG_ANSWER" in prompt
assert "稳妥建议" in prompt

View File

@ -6,11 +6,13 @@ from fastapi import HTTPException
from app.api.ai_pmo_api import routes_core
from app.api.ai_pmo_api.schemas import PMOProcessTextRequest
from app.services.ai_pmo.consultant_dispatch import build_consultant_dispatch_contract
from app.services.ai_pmo.ai_pmo_kernel.mixins.intent.ai_pmo_kernel_mixins_intent_rules import (
KernelIntentRulesMixin,
)
from app.services.ai_pmo.ai_pmo_kernel.ai_agent_kernel import gate_execution
from app.services.ai_pmo.ai_pmo_kernel.ai_pmo_reply_renderer import ReplyRenderer
from app.services.ai_pmo.ai_pmo_kernel import ai_pmo_typed_executor_mixins_parse
from app.services.ai_pmo.ai_pmo_kernel_ingress_flow import mixin_entry
from app.services.ai_pmo.ai_pmo_kernel_ingress_flow import mixin_prepare_manual
from app.services.ai_pmo.ai_pmo_typed_executor_execute import mixin_execute_query_project
@ -499,6 +501,7 @@ async def test_execute_query_intent_honors_rag_request_context(monkeypatch) -> N
"sync_requirement": True,
"message_intent": "mixed",
"mixed_signal_part": "新增任务确认流",
"consultant_mode_preference": "challenge",
}
},
)
@ -525,6 +528,7 @@ async def test_execute_query_intent_honors_rag_request_context(monkeypatch) -> N
assert answer_kwargs["sync_requirement"] is True
assert answer_kwargs["message_intent"] == "mixed"
assert answer_kwargs["mixed_signal_part"] == "新增任务确认流"
assert answer_kwargs["consultant_mode_preference"] == "challenge"
@pytest.mark.asyncio
@ -577,6 +581,91 @@ async def test_execute_query_intent_feishu_fallback_uses_thread_key(monkeypatch)
)
@pytest.mark.asyncio
async def test_execute_query_intent_rolls_back_failed_rag_transaction(monkeypatch) -> None:
class FakeAdaptiveRAGService:
def __init__(self, db, tenant_id: int):
_ = (db, tenant_id)
async def answer_question(self, **kwargs):
_ = kwargs
raise RuntimeError("rag failed before response")
class FakeDB:
def __init__(self) -> None:
self.rollback_calls = 0
async def rollback(self) -> None:
self.rollback_calls += 1
class FakeExecutor(mixin_execute_query_project.ExecuteQueryProjectMixin):
def __init__(self) -> None:
self.db = FakeDB()
self.tenant_id = 3
@staticmethod
def _build_query_error_message(exc: Exception) -> str:
return f"query failed: {exc}"
monkeypatch.setattr(mixin_execute_query_project, "AdaptiveRAGService", FakeAdaptiveRAGService)
executor = FakeExecutor()
intent = SimpleNamespace(project_id=12, metadata={})
result = await executor._execute_query_intent(
intent=intent,
message_text="现在完整调用链是什么?",
operator_user_id=999,
source_system="pmflow_manual",
source_message_id="msg-rollback-1",
)
assert result["status"] == "failed_safe"
assert "rag failed" in str(result["error_message"] or "")
assert executor.db.rollback_calls == 1
def test_typed_executor_common_exports_regex_helpers_to_query_mixins() -> None:
assert mixin_execute_query_project.ExecuteQueryProjectMixin._extract_task_priority("p0 紧急任务") == "high"
assert (
ai_pmo_typed_executor_mixins_parse.TypedExecutorParseMixin._extract_requested_signal_count(
message_text="请帮我分析 3 个信号",
metadata={},
)
== 3
)
assert (
ai_pmo_typed_executor_mixins_parse.TypedExecutorParseMixin._requires_strict_signal_analysis(
message_text="先分析后入库 2 个信号",
requested_count=2,
)
is True
)
def test_consultant_dispatch_uses_short_next_action_for_query_answers() -> None:
dispatch = build_consultant_dispatch_contract(
intent_name="query.answer",
message_text="PMFlow 现在完整调用链是什么?",
source_system="pmflow_manual",
status="answered",
classification="question",
primary_operation_code="query.answer",
risk_level="low",
missing_fields=[],
direct_reply_text=(
"结论:PMFlow 当前完整调用链无法完整还原。\n\n"
"依据:前端 `/ai-steward` 页面通过 `/rag/ask` 发起请求,"
"并继续进入 `AdaptiveRAGService.upsert_pmflow_record_async`。"
),
)
assert dispatch["allow_execute"] is False
assert dispatch["mode"] == "advise"
assert dispatch["next_action"] == "继续追问具体模块、入口函数或文件路径,我会把调用链收窄到那一段。"
assert "结论:" not in dispatch["next_action"]
assert "/rag/ask" not in dispatch["next_action"]
@pytest.mark.asyncio
async def test_low_tier_rag_failure_retries_high_tier(monkeypatch) -> None:
captured: dict[str, object] = {"logs": []}

View File

@ -48,7 +48,23 @@ async def test_http_rag_ask_readonly_mainline_contract(monkeypatch) -> None:
"intent": "query.read_only",
"direct_reply_required": True,
"direct_reply_text": "当前主链状态正常。",
"rag_result": {"answer": "当前主链状态正常。", "processing_stage": "qa"},
"rag_result": {
"answer": "当前主链状态正常。",
"processing_stage": "qa",
"answer_mode": "fast_path",
"grounding_status": "grounded",
"policy_version": "retrieval-v2.1|routing:v3|prompt:deterministic",
"citations": [],
"sources_used": [],
"evidence_pack": {
"summary": {
"conclusion": "当前主链状态正常。",
"evidence": [],
"uncertainties": [],
},
"tool_trace": [],
},
},
}
monkeypatch.setattr(routes_query, "AIPMOKernelService", FakeKernel)
@ -68,6 +84,11 @@ 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"]["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"] == []
ingress_kwargs = captured["ingress_kwargs"]
assert isinstance(ingress_kwargs, dict)
assert ingress_kwargs["source_system"] == "pmflow_manual"

View File

@ -6,7 +6,7 @@ from types import SimpleNamespace
import pytest
import pytest_asyncio
from sqlalchemy import select
from sqlalchemy import JSON, select
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
os.environ["DATABASE_URL"] = "sqlite+aiosqlite:///:memory:"
@ -18,14 +18,29 @@ from app.services.feedback_center.mappers import (
map_signal_feedback_to_record_payload,
map_task_override_to_record_payloads,
)
from app.services.feedback_center.reason_catalog import build_reason_candidates
from app.services.feedback_center.reason_catalog import build_reason_candidates, list_reason_catalog
import app.services.feedback_center.service as feedback_center_service_module
from app.services.feedback_center.service import FeedbackCenterService
def _use_sqlite_json_types() -> None:
for table in (FeedbackCenterRecord.__table__, FeedbackCenterRecordRevision.__table__):
for column in table.columns:
if column.name in {
"before_value",
"after_value",
"ai_snapshot",
"reason_candidates",
"before_snapshot",
"after_snapshot",
}:
column.type = JSON()
@pytest_asyncio.fixture
async def db_session():
engine = create_async_engine("sqlite+aiosqlite:///:memory:")
_use_sqlite_json_types()
async with engine.begin() as conn:
await conn.run_sync(
lambda sync_conn: Base.metadata.create_all(
@ -268,6 +283,19 @@ def test_reason_catalog_is_deterministic_for_field_diff() -> None:
assert first[0]["code"] == "effort_estimation_bias"
def test_reason_catalog_exposes_grounding_retrieval_codes_for_qa_feedback() -> None:
catalog = list_reason_catalog(source_type="qa_feedback")
codes = {item["code"] for item in catalog}
assert "wrong_fast_path" in codes
assert "should_have_gone_deep" in codes
assert "wrong_source_priority" in codes
assert "insufficient_evidence" in codes
assert "good_retrieval_bad_synthesis" in codes
assert "over_deep_latency" in codes
assert "permission_scope_miss" in codes
def test_source_mappers_generate_unified_payloads() -> None:
qa_payload = map_qa_feedback_to_record_payload(
qa_feedback=SimpleNamespace(

View File

@ -0,0 +1,93 @@
from __future__ import annotations
import importlib.util
from pathlib import Path
import sqlalchemy as sa
from alembic.migration import MigrationContext
from alembic.operations import Operations
def _load_migration_module():
path = (
Path(__file__).resolve().parents[1]
/ "alembic"
/ "versions"
/ "20260331_add_qa_record_runtime_fields.py"
)
spec = importlib.util.spec_from_file_location("qa_record_runtime_fields_migration", path)
module = importlib.util.module_from_spec(spec)
assert spec is not None
assert spec.loader is not None
spec.loader.exec_module(module)
return module
def _create_qa_record_table(metadata: sa.MetaData) -> None:
sa.Table(
"qa_records",
metadata,
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
sa.Column("tenant_id", sa.BigInteger(), nullable=False),
sa.Column("created_at", sa.TIMESTAMP(), nullable=False),
sa.Column("updated_at", sa.TIMESTAMP(), nullable=False),
sa.Column("question", sa.Text(), nullable=False),
sa.Column("answer", sa.Text(), nullable=False),
sa.Column("sources", sa.JSON(), nullable=True),
sa.Column("intent", sa.String(length=20), nullable=False),
sa.Column("domains_used", sa.JSON(), nullable=True),
sa.Column("response_time_ms", sa.Integer(), nullable=True),
sa.Column("asked_by", sa.BigInteger(), nullable=True),
sa.Column("asked_via", sa.String(length=20), nullable=False),
sa.Column("project_id", sa.BigInteger(), nullable=True),
sa.Column("session_id", sa.Integer(), nullable=True),
sa.Column("turn_index", sa.Integer(), nullable=False),
sa.Column("source_message_id", sa.String(length=128), nullable=True),
sa.Column("source_chat_id", sa.String(length=128), nullable=True),
sa.Column("source_sender_open_id", sa.String(length=128), nullable=True),
sa.Column("reply_to_message_id", sa.String(length=128), nullable=True),
sa.Column("root_message_id", sa.String(length=128), nullable=True),
sa.Column("conversation_scope_key", sa.String(length=160), nullable=True),
sa.Column("anchor_kind", sa.String(length=32), nullable=True),
sa.Column("anchor_ref", sa.String(length=128), nullable=True),
sa.Column("routing_snapshot", sa.JSON(), nullable=True),
)
def test_qa_record_runtime_fields_migration_adds_and_removes_columns() -> None:
migration = _load_migration_module()
engine = sa.create_engine("sqlite:///:memory:")
metadata = sa.MetaData()
_create_qa_record_table(metadata)
metadata.create_all(engine)
with engine.begin() as conn:
context = MigrationContext.configure(conn)
migration.op = Operations(context)
migration.upgrade()
inspector = sa.inspect(conn)
record_columns = {column["name"] for column in inspector.get_columns("qa_records")}
assert "answer_mode" in record_columns
assert "grounding_status" in record_columns
assert "policy_version" in record_columns
assert "citations" in record_columns
assert "sources_used" in record_columns
assert "evidence_pack" in record_columns
assert "tool_trace" in record_columns
assert "retrieval_metrics" in record_columns
migration.downgrade()
inspector = sa.inspect(conn)
record_columns = {column["name"] for column in inspector.get_columns("qa_records")}
assert "answer_mode" not in record_columns
assert "grounding_status" not in record_columns
assert "policy_version" not in record_columns
assert "citations" not in record_columns
assert "sources_used" not in record_columns
assert "evidence_pack" not in record_columns
assert "tool_trace" not in record_columns
assert "retrieval_metrics" not in record_columns

View File

@ -1,3 +1,4 @@
import json
from types import SimpleNamespace
import pytest
@ -18,11 +19,13 @@ def test_rag_ask_request_normalizes_contract_fields() -> None:
message_intent="MIXED",
mixed_signal_part="新增任务确认流",
action_mode="read",
consultant_mode_preference="ADVise",
)
assert req.asked_via == "/ai-pmo/process-text"
assert req.message_intent == "mixed"
assert req.mixed_signal_part == "新增任务确认流"
assert req.action_mode == "read"
assert req.consultant_mode_preference == "advise"
def test_rag_ask_request_rejects_unknown_asked_via() -> None:
@ -44,6 +47,7 @@ def test_build_answer_question_kwargs_maps_fields() -> None:
sync_requirement=0,
message_intent="mixed",
mixed_signal_part="新增任务确认流",
consultant_mode_preference="challenge",
)
kwargs = rag_ask_contract.build_answer_question_kwargs(
request=request,
@ -63,9 +67,29 @@ def test_build_answer_question_kwargs_maps_fields() -> None:
"sync_requirement": False,
"message_intent": "mixed",
"mixed_signal_part": "新增任务确认流",
"consultant_mode_preference": "challenge",
}
def test_build_kernel_ingress_kwargs_assigns_unique_source_message_id_for_rag() -> None:
request = RAGAskRequest(question="现在进展怎么样")
kwargs_a = rag_ask_contract.build_kernel_ingress_kwargs(
request=request,
current_user=SimpleNamespace(id=1, tenant_id=1, username="admin"),
)
kwargs_b = rag_ask_contract.build_kernel_ingress_kwargs(
request=request,
current_user=SimpleNamespace(id=1, tenant_id=1, username="admin"),
)
source_message_id_a = str(kwargs_a["source_message_id"] or "")
source_message_id_b = str(kwargs_b["source_message_id"] or "")
assert source_message_id_a.startswith("rag-ask-")
assert source_message_id_b.startswith("rag-ask-")
assert source_message_id_a != source_message_id_b
@pytest.mark.asyncio
async def test_rag_ask_route_forwards_contract_fields(monkeypatch) -> None:
captured: dict[str, object] = {}
@ -94,6 +118,7 @@ async def test_rag_ask_route_forwards_contract_fields(monkeypatch) -> None:
action_mode="read",
message_intent="mixed",
mixed_signal_part="新增任务确认流",
consultant_mode_preference="clarify",
)
current_user = SimpleNamespace(id=101, tenant_id=9)
response = await routes_query.rag_ask(
@ -116,6 +141,7 @@ async def test_rag_ask_route_forwards_contract_fields(monkeypatch) -> None:
assert rag_request_context["action_mode"] == "read"
assert rag_request_context["message_intent"] == "mixed"
assert rag_request_context["mixed_signal_part"] == "新增任务确认流"
assert rag_request_context["consultant_mode_preference"] == "clarify"
def test_ai_steward_router_does_not_expose_legacy_rag_ask_route() -> None:
@ -167,10 +193,12 @@ async def test_rag_ask_route_uses_shared_contract_builder(monkeypatch) -> None:
assert response_rag["code"] == 0
assert captured_tenants == [88]
assert captured_kwargs == [
{
assert len(captured_kwargs) == 1
forwarded = captured_kwargs[0]
assert str(forwarded["source_message_id"]).startswith("rag-ask-")
assert forwarded == {
"source_system": "pmflow",
"source_message_id": None,
"source_message_id": forwarded["source_message_id"],
"message_text": "q1",
"sender": "PMFlow用户",
"channel_ref": None,
@ -186,7 +214,6 @@ async def test_rag_ask_route_uses_shared_contract_builder(monkeypatch) -> None:
}
},
}
]
def test_build_rag_response_payload_preserves_rag_fields() -> None:
@ -202,6 +229,19 @@ def test_build_rag_response_payload_preserves_rag_fields() -> None:
"answer": "final answer",
"session_id": 18,
"sources": [{"source": "snapshot"}],
"answer_mode": "retrieval_deep",
"grounding_status": "grounded",
"policy_version": "gr-v2.1|routing-v3|prompt:feishu_direct",
"citations": [{"source_type": "doc", "source_id": "doc-1"}],
"sources_used": [{"source_type": "doc", "source_id": "doc-1"}],
"evidence_pack": {
"summary": {
"conclusion": "final answer",
"evidence": ["doc-1"],
"uncertainties": [],
},
"tool_trace": [{"tool": "vector_search"}],
},
"processing_stage": "qa",
},
}
@ -213,6 +253,40 @@ def test_build_rag_response_payload_preserves_rag_fields() -> None:
assert payload["trace_id"] == "ai-pmo-321"
assert payload["reply_required"] is True
assert payload["reply_text"] == "fallback"
assert payload["answer_mode"] == "retrieval_deep"
assert payload["grounding_status"] == "grounded"
assert payload["policy_version"] == "gr-v2.1|routing-v3|prompt:feishu_direct"
assert payload["citations"] == [{"source_type": "doc", "source_id": "doc-1"}]
assert payload["sources_used"] == [{"source_type": "doc", "source_id": "doc-1"}]
assert payload["evidence_pack"]["summary"]["conclusion"] == "final answer"
assert payload["evidence_pack"]["tool_trace"] == [{"tool": "vector_search"}]
def test_build_rag_response_payload_adds_grounding_contract_defaults() -> None:
payload = rag_ask_contract.build_rag_response_payload(
ingress_result={
"status": "answered",
"intent": "query.read_only",
"direct_reply_text": "fallback",
"rag_result": {
"answer": "final answer",
},
}
)
assert payload["answer_mode"] == "retrieval_normal"
assert payload["grounding_status"] == "partially_grounded"
assert payload["policy_version"] == "retrieval-v2.1|routing:unknown|prompt:unknown"
assert payload["citations"] == []
assert payload["sources_used"] == []
assert payload["evidence_pack"] == {
"summary": {
"conclusion": "final answer",
"evidence": [],
"uncertainties": [],
},
"tool_trace": [],
}
def test_build_rag_response_payload_falls_back_to_top_level_consultant_dispatch() -> None:
@ -239,3 +313,55 @@ def test_build_rag_response_payload_falls_back_to_top_level_consultant_dispatch(
)
assert payload["consultant_dispatch"] == consultant_dispatch
def test_build_rag_response_payload_does_not_inject_fake_answer_text() -> None:
payload = rag_ask_contract.build_rag_response_payload(
ingress_result={
"status": "answered",
"intent": "query.read_only",
"direct_reply_text": "",
"rag_result": {
"processing_stage": "qa",
},
}
)
assert payload["answer"] == ""
@pytest.mark.asyncio
async def test_rag_ask_route_rejects_empty_rag_answer(monkeypatch) -> None:
class FakeKernel:
def __init__(self, db, tenant_id: int):
_ = db
_ = tenant_id
async def process_ingress(self, **kwargs):
_ = kwargs
return {
"handled": True,
"status": "answered",
"intent": "query.read_only",
"direct_reply_text": "",
"rag_result": {
"processing_stage": "qa",
},
}
monkeypatch.setattr(routes_query, "AIPMOKernelService", FakeKernel)
response = await routes_query.rag_ask(
request=RAGAskRequest(question="空回答测试"),
current_user=SimpleNamespace(id=1, tenant_id=1),
db=object(),
tenant_id=1,
)
assert getattr(response, "status_code", None) == 500
body = json.loads(response.body.decode("utf-8"))
assert body == {
"code": 500,
"message": "RAG 返回空回答,未生成有效响应。",
"data": None,
}

View File

@ -65,6 +65,11 @@ def _use_sqlite_json_types() -> None:
"sources",
"domains_used",
"routing_snapshot",
"citations",
"sources_used",
"evidence_pack",
"tool_trace",
"retrieval_metrics",
"evidence",
"source_snapshot",
"tags",

View File

@ -44,6 +44,11 @@ def _use_sqlite_json_types() -> None:
"sources",
"domains_used",
"routing_snapshot",
"citations",
"sources_used",
"evidence_pack",
"tool_trace",
"retrieval_metrics",
"evidence",
"source_snapshot",
"tags",

File diff suppressed because it is too large Load Diff

View File

@ -22,8 +22,11 @@
"lucide-react": "^0.460.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-markdown": "^10.1.0",
"react-router-dom": "^6.28.0",
"recharts": "^2.13.0",
"remark-breaks": "^4.0.0",
"remark-gfm": "^4.0.1",
"tailwind-merge": "^2.5.0",
"tailwindcss-animate": "^1.0.7",
"zustand": "^4.5.0"

View File

@ -150,6 +150,26 @@ export interface AIPMOCadenceSnapshot {
created_at?: string | null;
}
export interface AIPMOProcessTextRequest {
message_text: string;
sender?: string | null;
source_message_id?: string | null;
project_id?: number | null;
}
export interface AIPMOProcessTextResult {
handled?: boolean;
status?: string | null;
intent?: string | null;
direct_reply_required?: boolean;
direct_reply_text?: string | null;
followup_reply?: string | null;
consultant_dispatch?: Record<string, unknown> | null;
work_item_id?: number | null;
signal_id?: number | null;
ai_trace_id?: string | null;
}
export interface AIPMOProgramView {
summary: {
program_count: number;
@ -169,6 +189,12 @@ export interface AIPMOProgramView {
}
export const aiPmoApi = {
async processText(body: AIPMOProcessTextRequest): Promise<AIPMOProcessTextResult> {
const { data } = await api.post<ApiEnvelope<AIPMOProcessTextResult>>("/ai-pmo/process-text", body, {
timeout: 15000,
});
return unwrap(data);
},
async listWorkItems(params?: { limit?: number; state?: string; projectId?: number }): Promise<AIPMOWorkItemListPayload> {
const { data } = await api.get<ApiEnvelope<AIPMOWorkItemListPayload>>("/ai-pmo/work-items", {
params: {

View File

@ -279,7 +279,162 @@ export interface LLMEngineConnectionTestResponse {
response_preview: string;
}
export type QAFeedbackRating = "positive" | "negative";
export type QAHelpfulness = "fully" | "partially" | "could_be_better";
export type QAThinkingMode = "normal" | "deep";
export type QAActionTaken = "signal_created" | "signal_deduped" | "signal_failed";
export interface QAMessageSource {
source: string;
label: string;
score: number;
metadata?: Record<string, unknown>;
}
export interface QAConsultantDispatch {
domain: string;
mode: string;
risk_level: string;
missing_fields: string[];
allow_execute: boolean;
next_action: string;
}
export interface QASessionSummary {
id: number;
title: string;
message_count: number;
last_message_at?: string | null;
rating_summary?: {
positive: number;
negative: number;
unrated: number;
corrected: number;
};
last_asked_via?: "pmflow" | "feishu";
preview_question?: string;
}
export interface QASessionMessage {
id: number;
question: string;
answer: string;
sources: QAMessageSource[];
response_time_ms?: number;
turn_index: number;
created_at?: string | null;
feedback?: {
id: number;
rating: QAFeedbackRating;
helpfulness?: QAHelpfulness;
error_types?: string[];
correct_answer?: string;
} | null;
thinking_mode?: QAThinkingMode;
action_taken?: QAActionTaken | null;
signal_id?: number | null;
reply_required?: boolean;
reply_text?: string | null;
ack_latency_ms?: number | null;
trace_id?: string | null;
processing_stage?: string | null;
consultant_dispatch?: QAConsultantDispatch | null;
}
export interface QASessionDetail {
session: {
id: number;
title: string;
message_count: number;
started_at?: string | null;
last_message_at?: string | null;
};
messages: QASessionMessage[];
}
export interface QAListSessionsResponse {
items: QASessionSummary[];
page: number;
total_pages: number;
}
export interface QAStats {
total_sessions: number;
total_qa: number;
rated_count: number;
rated_ratio: number;
average_helpfulness: number;
exportable_pairs: number;
}
export interface QAAskRequest {
question: string;
asked_via: "pmflow";
thinking_mode: QAThinkingMode;
new_session: boolean;
action_mode: "auto";
sync_requirement: boolean;
session_id?: number;
consultant_mode_preference?: "clarify" | "advise" | "challenge" | "brainstorm" | "execute";
}
export interface QAAskResponse {
answer: string;
session_id?: number | null;
thinking_mode?: QAThinkingMode;
sources?: QAMessageSource[];
response_time_ms?: number | null;
action_taken?: QAActionTaken | null;
signal_id?: number | null;
reply_required?: boolean;
reply_text?: string | null;
ack_latency_ms?: number | null;
trace_id?: string | null;
processing_stage?: string | null;
consultant_dispatch?: QAConsultantDispatch | null;
}
export const aiStewardApi = {
async askRag(body: QAAskRequest): Promise<QAAskResponse> {
const raw = await apiRequest<ApiEnvelope<QAAskResponse>>("/rag/ask", {
method: "POST",
body: JSON.stringify(body),
});
return unwrap(raw);
},
async listQASessions(params?: { page?: number; pageSize?: number }): Promise<QAListSessionsResponse> {
const query = new URLSearchParams();
query.set("page", String(params?.page ?? 1));
query.set("page_size", String(params?.pageSize ?? 20));
const raw = await apiRequest<ApiEnvelope<QAListSessionsResponse>>(`/rag/qa-sessions?${query.toString()}`);
return unwrap(raw);
},
async getQASession(sessionId: number): Promise<QASessionDetail> {
const raw = await apiRequest<ApiEnvelope<QASessionDetail>>(`/rag/qa-sessions/${sessionId}`);
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",
body: JSON.stringify({ title }),
});
return unwrap(raw);
},
async submitQAFeedback(
qaRecordId: number,
body: {
rating: QAFeedbackRating;
helpfulness?: QAHelpfulness;
error_types?: string[];
correct_answer?: string;
},
): Promise<Record<string, unknown>> {
const raw = await apiRequest<ApiEnvelope<Record<string, unknown>>>(`/rag/qa-records/${qaRecordId}/feedback`, {
method: "POST",
body: JSON.stringify(body),
});
return unwrap(raw);
},
async listCognitiveConflicts(projectId?: number): Promise<CognitiveConflict[]> {
const query = typeof projectId === "number" ? `?project_id=${projectId}` : "";
return await apiRequest<CognitiveConflict[]>(`/ai-steward/cognitive-conflicts${query}`);

View File

@ -1,18 +1,30 @@
import { useEffect, useMemo, useRef, useState } from "react";
import { AlertCircle, Loader2, MessageSquare, Sparkles, ThumbsDown, ThumbsUp, X } from "lucide-react";
import { aiPmoApi } from "@/api/aiPmo";
import { aiStewardApi } from "@/api/aiSteward";
import { QAMarkdownContent } from "@/components/ai-steward/QAMarkdownContent";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { Badge } from "@/components/ui/badge";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Switch } from "@/components/ui/switch";
import { apiBaseURL, getAuthToken } from "@/lib/api";
import { formatBeijingDate, formatBeijingTime, parseBackendDate } from "@/lib/datetime";
import { cn } from "@/lib/utils";
type FeedbackRating = "positive" | "negative";
type Helpfulness = "fully" | "partially" | "could_be_better";
type ThinkingMode = "normal" | "deep";
type ActionTaken = "signal_created" | "signal_deduped" | "signal_failed";
type ConsultantModePreference = "clarify" | "advise" | "challenge" | "brainstorm" | "execute";
type MessageSource = { source: string; label: string; score: number; metadata?: Record<string, unknown> };
type ConsultantDispatch = {
domain: string;
@ -84,18 +96,23 @@ const ERROR_OPTIONS = ["答案错误", "来源错误", "不完整", "答非所
const LAST_SEEN_KEY = "pmflow_qa_chat_last_seen";
const THINKING_MODE_KEY = "pmflow_qa_chat_thinking_mode";
const QUICK_QUESTIONS = ["你是谁", "系统架构是什么", "最近有哪些项目", "当前阻塞项有哪些"];
const CONSULTANT_MODE_LABELS: Record<string, string> = {
clarify: "先澄清",
advise: "给建议",
challenge: "先劝退",
brainstorm: "一起想",
execute: "可执行",
};
const CONSULTANT_MODE_ACTIONS: ReadonlyArray<{
value: ConsultantModePreference;
label: string;
tone: "advise" | "clarify" | "brainstorm" | "challenge";
}> = [
{ value: "advise", label: "给建议", tone: "advise" },
{ value: "clarify", label: "先澄清", tone: "clarify" },
{ value: "brainstorm", label: "一起想", tone: "brainstorm" },
{ value: "challenge", label: "先劝退", tone: "challenge" },
];
const CONSULTANT_RISK_LABELS: Record<string, string> = {
low: "低风险",
medium: "中风险",
high: "高风险",
};
const ACTION_CHIP_BASE =
"h-8 rounded-full border px-3 text-[11px] font-medium shadow-sm transition-all duration-150";
function normalizeRequestError(error: unknown, fallback = "请求失败,请稍后重试。"): string {
const raw = error instanceof Error ? error.message : String(error || "");
@ -138,6 +155,14 @@ function normalizeConsultantDispatch(value: unknown): ConsultantDispatch | null
};
}
function normalizeConsultantModePreference(value: unknown): ConsultantModePreference | null {
const mode = String(value || "").trim().toLowerCase();
if (mode === "clarify" || mode === "advise" || mode === "challenge" || mode === "brainstorm" || mode === "execute") {
return mode;
}
return null;
}
function looksLikeRequirementWrite(text: string): boolean {
const clean = String(text || "").trim();
if (!clean) return false;
@ -167,20 +192,6 @@ function getStageBadge(message: Pick<SessionMessage, "processing_stage" | "reply
return null;
}
function getConsultantModeBadge(
consultantDispatch?: ConsultantDispatch | null,
): { label: string; variant: "secondary" | "destructive" | "outline" } | null {
const mode = String(consultantDispatch?.mode || "").trim().toLowerCase();
if (!mode) return null;
if (mode === "challenge") {
return { label: CONSULTANT_MODE_LABELS[mode] || mode, variant: "destructive" };
}
if (mode === "clarify" || mode === "execute") {
return { label: CONSULTANT_MODE_LABELS[mode] || mode, variant: "secondary" };
}
return { label: CONSULTANT_MODE_LABELS[mode] || mode, variant: "outline" };
}
function getConsultantRiskBadge(
consultantDispatch?: ConsultantDispatch | null,
): { label: string; variant: "secondary" | "destructive" | "outline" } | null {
@ -195,127 +206,79 @@ function getConsultantRiskBadge(
return { label: CONSULTANT_RISK_LABELS[riskLevel] || riskLevel, variant: "secondary" };
}
async function fetchApi<T>(path: string, init?: RequestInit): Promise<T | null> {
const token = getAuthToken();
if (!token) return null;
try {
const res = await fetch(`${apiBaseURL}${path}`, {
...init,
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
...(init?.headers || {}),
},
});
if (!res.ok) return null;
const payload = await res.json().catch(() => ({}));
return (payload?.data ?? payload) as T;
} catch (error) {
console.error(`[FloatingQAChat] request failed: ${path}`, error);
return null;
function buildRiskExplanation(consultantDispatch?: ConsultantDispatch | null): string {
const riskLevel = String(consultantDispatch?.risk_level || "").trim().toLowerCase();
const missingCount = consultantDispatch?.missing_fields?.length || 0;
if (riskLevel === "high") {
return missingCount > 0
? "当前被判为高风险,且还缺少关键输入,系统不会建议直接推进执行。"
: "当前被判为高风险,建议先重新确认范围、影响面和回滚路径。";
}
if (riskLevel === "medium") {
return missingCount > 0
? "当前风险中等,建议先补齐缺失信息,再决定是否继续推进。"
: "当前风险中等,适合先补充证据或确认边界,再继续下一步。";
}
if (consultantDispatch?.allow_execute) {
return "当前被判为低风险,且信息较完整,可以在确认后交接给 AI PMO 执行流。";
}
return "当前被判为低风险,但系统仍建议先按下一步补齐信息,而不是直接执行。";
}
async function askRAG(
question: string,
sessionId?: number | null,
thinkingMode: ThinkingMode = "normal",
newSession = false,
): Promise<{
answer: string;
session_id?: number | null;
thinking_mode?: ThinkingMode;
sources?: MessageSource[];
response_time_ms?: number | null;
action_taken?: ActionTaken | null;
signal_id?: number | null;
reply_required?: boolean;
reply_text?: string | null;
ack_latency_ms?: number | null;
trace_id?: string | null;
processing_stage?: string | null;
consultant_dispatch?: ConsultantDispatch | null;
}> {
const token = getAuthToken();
if (!token) {
throw new Error("登录状态已失效,请重新登录后提问。");
function getActionChipClasses(
tone: "deep" | "advise" | "clarify" | "brainstorm" | "challenge" | "risk-low" | "risk-medium" | "risk-high" | "execute" | "next",
active = false,
): string {
const sharedInactive = "bg-white/90 text-slate-700 hover:bg-white hover:text-slate-900";
switch (tone) {
case "deep":
return cn(
ACTION_CHIP_BASE,
active
? "border-violet-500 bg-violet-50 text-violet-700"
: `${sharedInactive} border-violet-200 hover:border-violet-300 hover:text-violet-700`,
);
case "advise":
return cn(
ACTION_CHIP_BASE,
active
? "border-emerald-500 bg-emerald-50 text-emerald-700"
: `${sharedInactive} border-emerald-200 hover:border-emerald-300 hover:text-emerald-700`,
);
case "clarify":
return cn(
ACTION_CHIP_BASE,
active
? "border-amber-400 bg-amber-50 text-amber-800"
: `${sharedInactive} border-amber-200 hover:border-amber-300 hover:text-amber-800`,
);
case "brainstorm":
return cn(
ACTION_CHIP_BASE,
active
? "border-sky-500 bg-sky-50 text-sky-700"
: `${sharedInactive} border-sky-200 hover:border-sky-300 hover:text-sky-700`,
);
case "challenge":
return cn(
ACTION_CHIP_BASE,
active
? "border-rose-500 bg-rose-50 text-rose-700"
: `${sharedInactive} border-rose-200 hover:border-rose-300 hover:text-rose-700`,
);
case "risk-high":
return cn(ACTION_CHIP_BASE, "border-rose-300 bg-rose-50 text-rose-700 hover:border-rose-400");
case "risk-medium":
return cn(ACTION_CHIP_BASE, "border-amber-300 bg-amber-50 text-amber-800 hover:border-amber-400");
case "risk-low":
return cn(ACTION_CHIP_BASE, "border-emerald-300 bg-emerald-50 text-emerald-700 hover:border-emerald-400");
case "execute":
return cn(ACTION_CHIP_BASE, "border-slate-900 bg-slate-900 text-white hover:bg-slate-800");
case "next":
return cn(ACTION_CHIP_BASE, "border-slate-300 bg-slate-100 text-slate-800 hover:border-slate-400 hover:bg-slate-200");
default:
return cn(ACTION_CHIP_BASE, sharedInactive);
}
const payloadObj: {
question: string;
asked_via: "pmflow";
thinking_mode: ThinkingMode;
new_session: boolean;
action_mode: "auto";
sync_requirement: boolean;
session_id?: number;
} = {
question,
asked_via: "pmflow",
thinking_mode: thinkingMode,
new_session: Boolean(newSession),
action_mode: "auto",
sync_requirement: true,
};
if (typeof sessionId === "number" && Number.isFinite(sessionId) && sessionId > 0) {
payloadObj.session_id = Math.trunc(sessionId);
}
const payload = JSON.stringify(payloadObj);
let res: Response;
try {
res = await fetch(`${apiBaseURL}/rag/ask`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
},
body: payload,
});
} catch (error) {
throw new Error(normalizeRequestError(error, "提问失败,请稍后重试。"));
}
if (!res.ok) {
const body = await res.json().catch(() => ({}));
const detail = String(body?.detail || body?.message || "").trim();
throw new Error(detail || `请求失败(${res.status})`);
}
const body = await res.json().catch(() => ({}));
const data = (body?.data ?? body) as {
answer?: string;
session_id?: number | null;
thinking_mode?: string;
sources?: unknown;
response_time_ms?: number | null;
action_taken?: ActionTaken | null;
signal_id?: number | null;
reply_required?: boolean;
reply_text?: string | null;
ack_latency_ms?: number | null;
trace_id?: string | null;
processing_stage?: string | null;
consultant_dispatch?: unknown;
};
if (!data || !String(data.answer || "").trim()) {
throw new Error("服务返回为空,未生成有效回答。");
}
const resolvedThinkingMode: ThinkingMode = String(data.thinking_mode || thinkingMode).toLowerCase() === "deep" ? "deep" : "normal";
return {
answer: String(data.answer),
session_id: data.session_id ?? null,
thinking_mode: resolvedThinkingMode,
sources: normalizeSources(data.sources),
response_time_ms: data.response_time_ms ?? null,
action_taken: data.action_taken ?? null,
signal_id: data.signal_id ?? null,
reply_required: Boolean(data.reply_required),
reply_text: data.reply_text ?? null,
ack_latency_ms: data.ack_latency_ms ?? null,
trace_id: data.trace_id ?? null,
processing_stage: data.processing_stage ?? null,
consultant_dispatch: normalizeConsultantDispatch(data.consultant_dispatch),
};
}
function groupSessions(items: SessionSummary[]) {
@ -384,6 +347,9 @@ export function FloatingQAChat() {
const [errorTypes, setErrorTypes] = useState<Record<number, string[]>>({});
const [correctAnswer, setCorrectAnswer] = useState<Record<number, string>>({});
const [submittingFeedbackId, setSubmittingFeedbackId] = useState<number | null>(null);
const [actionBusyKey, setActionBusyKey] = useState<string | null>(null);
const [expandedRiskMessageId, setExpandedRiskMessageId] = useState<number | null>(null);
const [executeDialogMessage, setExecuteDialogMessage] = useState<SessionMessage | null>(null);
const [showOlder, setShowOlder] = useState(false);
const [unread, setUnread] = useState(false);
const [renameEditing, setRenameEditing] = useState(false);
@ -395,19 +361,40 @@ export function FloatingQAChat() {
const messageViewportRef = useRef<HTMLDivElement | null>(null);
const messageEndRef = useRef<HTMLDivElement | null>(null);
const forceStickBottomRef = useRef(false);
const questionInputRef = useRef<HTMLTextAreaElement | null>(null);
const normalizeSessionMessage = (message: SessionMessage): SessionMessage => ({
...message,
question: String(message.question || ""),
answer: String(message.answer || ""),
sources: normalizeSources(message.sources),
consultant_dispatch: normalizeConsultantDispatch(message.consultant_dispatch),
});
const normalizeSessionDetail = (detail: SessionDetail): SessionDetail => ({
session: detail.session,
messages: Array.isArray(detail.messages) ? detail.messages.map(normalizeSessionMessage) : [],
});
const appendLocalMessage = (message: Omit<SessionMessage, "id" | "turn_index" | "created_at" | "feedback">) => {
const localId = localMessageIdRef.current;
localMessageIdRef.current -= 1;
setLocalMessages((prev) => [
...prev,
{
id: localId,
turn_index: prev.length + 1,
created_at: new Date().toISOString(),
feedback: null,
...message,
},
]);
};
const loadSessions = async (page = 1, append = false) => {
setHistoryLoading(true);
try {
const data = await fetchApi<{
items: SessionSummary[];
page: number;
total_pages: number;
}>(`/rag/qa-sessions?page=${page}&page_size=20`);
if (!data) {
setHistoryApiAvailable(false);
return;
}
const data = await aiStewardApi.listQASessions({ page, pageSize: 20 });
setHistoryApiAvailable(true);
setHistoryPage(Number(data.page || page));
@ -437,6 +424,9 @@ export function FloatingQAChat() {
if (!activeSessionIdRef.current && !draftNewSessionRef.current && Array.isArray(data.items) && data.items.length > 0) {
setActiveSessionId(data.items[0].id);
}
} catch (error) {
console.error("[FloatingQAChat] loadSessions failed", error);
setHistoryApiAvailable(false);
} finally {
setHistoryLoading(false);
}
@ -449,17 +439,16 @@ export function FloatingQAChat() {
setDetailLoading(true);
}
try {
const data = await fetchApi<SessionDetail>(`/rag/qa-sessions/${sessionId}`);
if (!data) {
setHistoryApiAvailable(false);
return;
}
const data = normalizeSessionDetail(await aiStewardApi.getQASession(sessionId));
setHistoryApiAvailable(true);
setSessionDetail(data);
if (!preserveLocal) {
setLocalMessages([]);
}
setRenameTitle(String(data.session?.title || ""));
} catch (error) {
console.error("[FloatingQAChat] loadSessionDetail failed", error);
setHistoryApiAvailable(false);
} finally {
if (!silent) {
setDetailLoading(false);
@ -487,34 +476,52 @@ export function FloatingQAChat() {
setRenameTitle("");
};
const askQuestion = async () => {
const clean = question.trim();
const runQuestionRequest = async ({
prompt,
requestThinkingModeOverride,
consultantModePreference,
clearInput = false,
}: {
prompt: string;
requestThinkingModeOverride?: ThinkingMode;
consultantModePreference?: ConsultantModePreference | null;
clearInput?: boolean;
}) => {
const clean = prompt.trim();
if (!clean) return;
const currentSessionId = draftNewSession ? null : activeSessionIdRef.current;
const currentThinkingMode: ThinkingMode = thinkingMode;
const currentThinkingMode: ThinkingMode = requestThinkingModeOverride || thinkingMode;
forceStickBottomRef.current = true;
setAskError("");
setRequestThinkingMode(currentThinkingMode);
setAsking(true);
try {
const data = await askRAG(clean, currentSessionId, currentThinkingMode, draftNewSession);
const data = await aiStewardApi.askRag({
question: clean,
asked_via: "pmflow",
thinking_mode: currentThinkingMode,
new_session: Boolean(draftNewSession),
action_mode: "auto",
sync_requirement: true,
...(typeof currentSessionId === "number" && Number.isFinite(currentSessionId) && currentSessionId > 0
? { session_id: Math.trunc(currentSessionId) }
: {}),
...(consultantModePreference ? { consultant_mode_preference: consultantModePreference } : {}),
});
if (!String(data.answer || "").trim() && !String(data.reply_text || "").trim()) {
throw new Error("服务返回为空,未生成有效回答。");
}
if (clearInput) {
setQuestion("");
}
setSessionDetail(null);
draftNewSessionRef.current = false;
setDraftNewSession(false);
const localId = localMessageIdRef.current;
localMessageIdRef.current -= 1;
setLocalMessages((prev) => [
...prev,
{
id: localId,
appendLocalMessage({
question: clean,
answer: String(data.reply_text || data.answer || ""),
sources: data.sources?.length ? data.sources : [{ source: "rag", label: "AI 管家", score: 1 }],
sources: data.sources?.length ? normalizeSources(data.sources) : [{ source: "rag", label: "AI 管家", score: 1 }],
response_time_ms: data.response_time_ms ?? undefined,
turn_index: prev.length + 1,
created_at: new Date().toISOString(),
feedback: null,
thinking_mode: data.thinking_mode || currentThinkingMode,
action_taken: data.action_taken ?? null,
signal_id: data.signal_id ?? null,
@ -523,9 +530,8 @@ export function FloatingQAChat() {
ack_latency_ms: data.ack_latency_ms ?? null,
trace_id: data.trace_id ?? null,
processing_stage: data.processing_stage ?? null,
consultant_dispatch: data.consultant_dispatch ?? null,
},
]);
consultant_dispatch: normalizeConsultantDispatch(data.consultant_dispatch),
});
const returnedSessionId = Number(data.session_id || 0) || null;
if (historyApiAvailable) {
await loadSessions(1, false);
@ -536,7 +542,7 @@ export function FloatingQAChat() {
setActiveSessionId(currentSessionId);
await refreshActiveSession(currentSessionId, { silent: true, preserveLocal: true });
} else if (latestSessionAtRef.current) {
const fallback = await fetchApi<{ items: SessionSummary[] }>("/rag/qa-sessions?page=1&page_size=1");
const fallback = await aiStewardApi.listQASessions({ page: 1, pageSize: 1 });
const target = fallback?.items?.[0]?.id || null;
if (target) {
setActiveSessionId(target);
@ -553,17 +559,22 @@ export function FloatingQAChat() {
}
};
const askQuestion = async () => {
await runQuestionRequest({
prompt: question,
requestThinkingModeOverride: thinkingMode,
clearInput: true,
});
};
const submitFeedback = async (messageId: number, rating: FeedbackRating) => {
setSubmittingFeedbackId(messageId);
try {
await fetchApi(`/rag/qa-records/${messageId}/feedback`, {
method: "POST",
body: JSON.stringify({
await aiStewardApi.submitQAFeedback(messageId, {
rating,
helpfulness: rating === "positive" ? helpfulness[messageId] || "fully" : undefined,
error_types: rating === "negative" ? errorTypes[messageId] || [] : [],
correct_answer: rating === "negative" ? correctAnswer[messageId] || undefined : undefined,
}),
});
setFeedbackMode((prev) => ({ ...prev, [messageId]: null }));
await Promise.all([loadSessions(1, false), refreshActiveSession()]);
@ -576,15 +587,83 @@ export function FloatingQAChat() {
if (!activeSessionId) return;
const clean = renameTitle.trim();
if (!clean) return;
const data = await fetchApi<{ title: string }>(`/rag/qa-sessions/${activeSessionId}/title`, {
method: "PATCH",
body: JSON.stringify({ title: clean }),
});
if (!data) return;
await aiStewardApi.renameQASession(activeSessionId, clean);
setRenameEditing(false);
await Promise.all([loadSessions(1, false), refreshActiveSession(activeSessionId)]);
};
const handleDeepRerun = async (message: SessionMessage) => {
setActionBusyKey(`deep-${message.id}`);
try {
await runQuestionRequest({
prompt: message.question,
requestThinkingModeOverride: "deep",
});
} finally {
setActionBusyKey(null);
}
};
const handleConsultantModeRerun = async (message: SessionMessage, modeValue: ConsultantModePreference) => {
setActionBusyKey(`mode-${message.id}-${modeValue}`);
try {
await runQuestionRequest({
prompt: message.question,
requestThinkingModeOverride: message.thinking_mode || thinkingMode,
consultantModePreference: modeValue,
});
} finally {
setActionBusyKey(null);
}
};
const handleToggleRiskExplanation = (messageId: number) => {
setExpandedRiskMessageId((prev) => (prev === messageId ? null : messageId));
};
const handleUseNextAction = (consultantDispatch?: ConsultantDispatch | null) => {
const nextAction = String(consultantDispatch?.next_action || "").trim();
if (!nextAction) return;
setMode("chat");
setQuestion(nextAction);
setAskError("");
window.setTimeout(() => {
questionInputRef.current?.focus();
questionInputRef.current?.setSelectionRange(nextAction.length, nextAction.length);
}, 0);
};
const handleExecuteViaKernel = async () => {
if (!executeDialogMessage) return;
setActionBusyKey(`execute-${executeDialogMessage.id}`);
try {
const result = await aiPmoApi.processText({
message_text: executeDialogMessage.question,
});
const replyText = String(result.direct_reply_text || result.followup_reply || "已交接到 AI PMO 执行流继续处理。").trim();
appendLocalMessage({
question: executeDialogMessage.question,
answer: replyText || "已交接到 AI PMO 执行流继续处理。",
sources: [{ source: "ai_pmo", label: "AI PMO Kernel", score: 1 }],
thinking_mode: executeDialogMessage.thinking_mode || "normal",
action_taken: null,
signal_id: result.signal_id ?? null,
reply_required: Boolean(result.direct_reply_required),
reply_text: result.direct_reply_text ?? result.followup_reply ?? null,
trace_id: result.ai_trace_id ?? null,
processing_stage: "execute_handoff",
consultant_dispatch: normalizeConsultantDispatch(result.consultant_dispatch),
});
setMode("chat");
setExecuteDialogMessage(null);
} catch (error) {
const message = normalizeRequestError(error, "执行交接失败,请稍后重试。");
setAskError(message);
} finally {
setActionBusyKey(null);
}
};
const grouped = useMemo(() => groupSessions(sessions), [sessions]);
const requestLooksLikeWrite = useMemo(() => looksLikeRequirementWrite(question), [question]);
const displayMessages = useMemo(() => {
@ -831,11 +910,9 @@ export function FloatingQAChat() {
const actionBadge = getActionBadge(msg.action_taken);
const stageBadge = getStageBadge(msg);
const consultantDispatch = msg.consultant_dispatch ?? null;
const consultantModeBadge = getConsultantModeBadge(consultantDispatch);
const consultantModePreference = normalizeConsultantModePreference(consultantDispatch?.mode);
const consultantRiskBadge = getConsultantRiskBadge(consultantDispatch);
const consultantMissingFields = consultantDispatch?.missing_fields?.length
? consultantDispatch.missing_fields.join(" / ")
: "";
const consultantMissingFields = consultantDispatch?.missing_fields?.filter(Boolean) ?? [];
return (
<div key={msg.id} className="space-y-2">
<div className="flex justify-end">
@ -846,7 +923,7 @@ export function FloatingQAChat() {
<div className="flex justify-start">
<div className="max-w-[85%] rounded-xl bg-slate-100 px-3 py-2 text-sm text-slate-900">
<div className="whitespace-pre-wrap">{msg.answer}</div>
<QAMarkdownContent content={msg.answer} />
<div className="mt-2 flex flex-wrap items-center gap-2 text-[11px] text-slate-600">
<span>
来源:{msg.sources?.length ? msg.sources.map((s) => s.label).join(" / ") : "无"}
@ -858,28 +935,107 @@ export function FloatingQAChat() {
{actionBadge ? <Badge variant={actionBadge.variant}>{actionBadge.label}</Badge> : null}
{msg.signal_id ? <Badge variant="outline">signal #{msg.signal_id}</Badge> : null}
{stageBadge ? <Badge variant="outline">{stageBadge}</Badge> : null}
<Badge variant="secondary">{msg.thinking_mode === "deep" ? "深入思考" : "标准回答"}</Badge>
{consultantModeBadge ? (
<Badge variant={consultantModeBadge.variant}>{consultantModeBadge.label}</Badge>
</div>
<div className="mt-3 rounded-2xl border border-slate-200/80 bg-slate-50/90 p-2">
<div className="mb-2 text-[11px] font-medium text-slate-500">顾问动作</div>
<div className="flex flex-wrap items-center gap-2">
<Button
size="sm"
variant="ghost"
className={getActionChipClasses("deep", msg.thinking_mode === "deep")}
disabled={asking || actionBusyKey === `deep-${msg.id}`}
onClick={() => handleDeepRerun(msg)}
>
{actionBusyKey === `deep-${msg.id}` ? "处理中..." : "深入思考"}
</Button>
{CONSULTANT_MODE_ACTIONS.map((item) => {
const busyKey = `mode-${msg.id}-${item.value}`;
return (
<Button
key={`${msg.id}-${item.value}`}
size="sm"
variant="ghost"
className={getActionChipClasses(item.tone, consultantModePreference === item.value)}
disabled={asking || actionBusyKey === busyKey}
onClick={() => handleConsultantModeRerun(msg, item.value)}
>
{actionBusyKey === busyKey ? "处理中..." : item.label}
</Button>
);
})}
{consultantRiskBadge && consultantDispatch ? (
<Button
size="sm"
variant="ghost"
className={getActionChipClasses(
consultantRiskBadge.label === "高风险"
? "risk-high"
: consultantRiskBadge.label === "中风险"
? "risk-medium"
: "risk-low",
)}
onClick={() => handleToggleRiskExplanation(msg.id)}
>
{consultantRiskBadge.label}
</Button>
) : null}
{consultantRiskBadge ? (
<Badge variant={consultantRiskBadge.variant}>{consultantRiskBadge.label}</Badge>
{consultantDispatch?.allow_execute ? (
<Button
size="sm"
variant="ghost"
className={getActionChipClasses("execute")}
disabled={Boolean(actionBusyKey)}
onClick={() => setExecuteDialogMessage(msg)}
>
允许执行
</Button>
) : null}
{consultantDispatch ? (
<Badge variant={consultantDispatch.allow_execute ? "secondary" : "outline"}>
{consultantDispatch.allow_execute ? "允许执行" : "先别执行"}
</Badge>
{consultantDispatch?.next_action ? (
<Button
size="sm"
variant="ghost"
className={getActionChipClasses("next")}
onClick={() => handleUseNextAction(consultantDispatch)}
>
先别执行
</Button>
) : null}
</div>
</div>
{consultantDispatch && expandedRiskMessageId === msg.id ? (
<div className="mt-3 rounded-md border border-amber-200 bg-amber-50/80 px-3 py-2 text-xs leading-6 text-amber-900">
{buildRiskExplanation(consultantDispatch)}
</div>
) : null}
{consultantDispatch ? (
<div className="mt-3 rounded-md border border-slate-200 bg-white/80 px-3 py-2 text-xs text-slate-700">
{consultantMissingFields ? (
<div>缺失信息:{consultantMissingFields}</div>
<div className="mt-3 overflow-hidden rounded-xl border border-slate-200 bg-white/90">
{consultantMissingFields.length ? (
<div className="border-b border-slate-200/80 px-3 py-3">
<div className="text-[11px] font-medium uppercase tracking-[0.08em] text-slate-500">
缺失信息
</div>
<div className="mt-2 flex flex-wrap gap-1.5">
{consultantMissingFields.map((field) => (
<span
key={`${msg.id}-${field}`}
className="rounded-full border border-amber-200 bg-amber-50 px-2.5 py-1 text-[11px] text-amber-800"
>
{field}
</span>
))}
</div>
</div>
) : null}
{consultantDispatch.next_action ? (
<div className={consultantMissingFields ? "mt-2" : undefined}>
<span className="font-medium">下一步:</span>
{consultantDispatch.next_action}
<div className="px-3 py-3">
<div className="text-[11px] font-medium uppercase tracking-[0.08em] text-slate-500">
下一步建议
</div>
<QAMarkdownContent
content={consultantDispatch.next_action}
compact
className="mt-2 text-xs leading-6 text-slate-800"
/>
</div>
) : null}
</div>
@ -1046,6 +1202,7 @@ export function FloatingQAChat() {
/>
</div>
<Textarea
ref={questionInputRef}
rows={2}
placeholder="输入问题..."
value={question}
@ -1091,6 +1248,28 @@ export function FloatingQAChat() {
)}
</div>
</div>
<Dialog open={Boolean(executeDialogMessage)} onOpenChange={(openValue) => (!openValue ? setExecuteDialogMessage(null) : undefined)}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>确认交接执行</DialogTitle>
<DialogDescription>
这一步会把当前问题交给 AI PMO Kernel,由统一语义规划和执行门禁继续处理,而不是继续停留在 RAG 问答链路里。
</DialogDescription>
</DialogHeader>
<div className="rounded-lg border bg-slate-50 px-3 py-3 text-sm leading-6 text-slate-700 [overflow-wrap:anywhere]">
{executeDialogMessage?.question}
</div>
<DialogFooter>
<Button variant="ghost" onClick={() => setExecuteDialogMessage(null)} disabled={actionBusyKey?.startsWith("execute-")}>
取消
</Button>
<Button onClick={handleExecuteViaKernel} disabled={actionBusyKey?.startsWith("execute-")}>
{actionBusyKey?.startsWith("execute-") ? "执行中..." : "继续执行"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
);
}

View File

@ -0,0 +1,73 @@
import ReactMarkdown from "react-markdown";
import remarkBreaks from "remark-breaks";
import remarkGfm from "remark-gfm";
import { cn } from "@/lib/utils";
type QAMarkdownContentProps = {
content: string;
className?: string;
compact?: boolean;
};
export function QAMarkdownContent({ content, className, compact = false }: QAMarkdownContentProps) {
const paragraphClassName = compact ? "mb-2 last:mb-0" : "mb-3 last:mb-0";
const listClassName = compact
? "mb-2 ml-5 list-disc space-y-1 last:mb-0"
: "mb-3 ml-5 list-disc space-y-1 last:mb-0";
const orderedListClassName = compact
? "mb-2 ml-5 list-decimal space-y-1 last:mb-0"
: "mb-3 ml-5 list-decimal space-y-1 last:mb-0";
const blockquoteClassName = compact
? "mb-2 border-l-2 border-slate-300 bg-white/70 px-3 py-2 text-slate-700 last:mb-0"
: "mb-3 border-l-2 border-slate-300 bg-white/70 px-3 py-2 text-slate-700 last:mb-0";
const preClassName = compact
? "mb-2 overflow-x-auto rounded-lg bg-slate-900 p-3 text-slate-100 last:mb-0"
: "mb-3 overflow-x-auto rounded-lg bg-slate-900 p-3 text-slate-100 last:mb-0";
return (
<div className={cn("text-sm leading-7 text-slate-900 [overflow-wrap:anywhere]", className)}>
<ReactMarkdown
remarkPlugins={[remarkGfm, remarkBreaks]}
skipHtml
components={{
p: ({ children }) => <p className={paragraphClassName}>{children}</p>,
ul: ({ children }) => <ul className={listClassName}>{children}</ul>,
ol: ({ children }) => <ol className={orderedListClassName}>{children}</ol>,
li: ({ children }) => <li className="[overflow-wrap:anywhere]">{children}</li>,
blockquote: ({ children }) => <blockquote className={blockquoteClassName}>{children}</blockquote>,
a: ({ children, href }) => (
<a
href={href}
target="_blank"
rel="noreferrer"
className="break-all text-slate-700 underline decoration-slate-300 underline-offset-2 hover:text-slate-950"
>
{children}
</a>
),
code: ({ children, className: codeClassName }) => {
const isBlock = Boolean(codeClassName);
if (isBlock) {
return (
<code className={cn("block whitespace-pre-wrap break-words font-mono text-[12px] leading-6", codeClassName)}>
{children}
</code>
);
}
return (
<code className="break-all rounded bg-slate-200/80 px-1.5 py-0.5 font-mono text-[12px] text-slate-900">
{children}
</code>
);
},
pre: ({ children }) => <pre className={preClassName}>{children}</pre>,
hr: () => <hr className="my-4 border-slate-200" />,
strong: ({ children }) => <strong className="font-semibold text-slate-950">{children}</strong>,
}}
>
{String(content || "").trim()}
</ReactMarkdown>
</div>
);
}

File diff suppressed because one or more lines are too long