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/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_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/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/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/signal/signal_planner/signal_strategic_analysis_service.py`(Signal Planner 写回收口)
- 新增:`backend/app/services/integrations/feishu_outbox/service.py`(复用 reply_hash 的 outbox 入队收口) - 新增:`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_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_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_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_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/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 基础问答主链编排:运行时准备 + 只读主路径执行) - 新增:`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/modelRouting.ts`(模型路由 API 封装)
- 新增:`frontend/src/api/knowledgeGraph.ts`(KG 能力汇总与 low/high 消费验证 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`(移除 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/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/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`(任务/调度/技能页面目录化收敛) - 新增:`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}/resume`):支持 `action=continue|commit|refresh|cancel`,复用同一闭环恢复入口
- AI PMO 草稿决策接口(`POST /ai-pmo/work-items/{work_item_id}/draft-decision`):支持 `action=confirm|revise|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/...` 路径 - 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 / 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/*` - 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 消息台列表接口(`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`,用于草稿确认卡片渲染 - 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 若存在显式 `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 的“追问歧义”与“模型/上游失败”必须分开:前者返回 `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 的 `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 名称 - 元追问(如“哪个模型回复的”“为什么那条报错”“你刚才那句什么意思”)必须优先锚定最近一条 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,避免训练试验误触执行副作用 - Prompt release 目前只接入只读回复链路(AdaptiveRAG / query.read_only);写操作 workflow 仍使用代码 prompt 与显式人工 gate,避免训练试验误触执行副作用
- 训练自动化/影子回放当前只允许覆盖只读问答链路;`project.create/signal.create/signal_source.onboard/task.create` 等写操作 workflow 继续排除在自动训练与自动试验之外 - 训练自动化/影子回放当前只允许覆盖只读问答链路;`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 持久化与回执收尾 - 飞书入口已按职责拆分为“预处理/捕获执行/结果回执”三层:`mixin_receive.py` 仅保留编排,`mixin_receive_prepare.py` 负责传输解析与上下文抽取,`mixin_receive_capture_flow.py` 负责 capture 持久化与回执收尾
- `ai_orchestration_runs.project_id` 允许为空:用于承载尚未完成项目归属判定的入站异步任务;既有 `signal_source_onboarding` 运行仍按显式 `project_id` 写入 - `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 与两段输出格式化 - `/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` 时触发 - 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` - 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`),为空时才回退默认技能集合 - `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] AdaptiveRAG 职责已完成首轮收敛:`answer_question` 入口已分层为 `prepare runtime` 与 `base mainline` 两段,写确认逻辑通过 `_should_trigger_write_ack_gate` 独立门控;基础问答主路径不再与写确认判断混写在同一函数体。分层契约由 `test_adaptive_rag_mainline_contract.py` 锁定(read 绕过 ack、write 命中 ack、feishu write 维持主链)
- [x] 执行层已形成统一执行账本与参数来源约束:ingress 执行路径统一通过 `KernelIngressStorageMixin` 生成 `execution_result.action_contract`(固定 `param_source=decision.execution_plan.actions+workflow_route+execution_result`)并写入 `operation_execution_ledger`(`applied/pending_manual/pending_confirmation/failed`);`mixin_auto.py`、`mixin_prepare_manual.py` 的 `operation_code` 日志来源已对齐 `action_contract`,回归由 `test_ai_pmo_execution_ledger_contract.py` 锁定 - [x] 执行层已形成统一执行账本与参数来源约束:ingress 执行路径统一通过 `KernelIngressStorageMixin` 生成 `execution_result.action_contract`(固定 `param_source=decision.execution_plan.actions+workflow_route+execution_result`)并写入 `operation_execution_ledger`(`applied/pending_manual/pending_confirmation/failed`);`mixin_auto.py`、`mixin_prepare_manual.py` 的 `operation_code` 日志来源已对齐 `action_contract`,回归由 `test_ai_pmo_execution_ledger_contract.py` 锁定
- [x] QA/RAG 路由契约双轨已收敛:`/ai-steward/rag/ask` 兼容入口已移除,问答统一收敛到 `POST /rag/ask`;参数构造与 tenant 解析收口在 `backend/app/api/rag_ask_contract.py`,前端 `FloatingQAChat` 与 Context Cache API 目录均已对齐,`backend/tests/test_rag_route_contract.py` 与 `backend/tests/test_context_cache_profile_catalog.py` 已锁定回归 - [x] QA/RAG 路由契约双轨已收敛:`/ai-steward/rag/ask` 兼容入口已移除,问答统一收敛到 `POST /rag/ask`;参数构造与 tenant 解析收口在 `backend/app/api/rag_ask_contract.py`,前端 `FloatingQAChat` 与 Context Cache API 目录均已对齐,`backend/tests/test_rag_route_contract.py` 与 `backend/tests/test_context_cache_profile_catalog.py` 已锁定回归
- [x] 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] 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] 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` 回归契约 - [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 __future__ import annotations
from fastapi.responses import JSONResponse
from .shared import * from .shared import *
from .helpers import _resolve_tenant_id from .helpers import _resolve_tenant_id
from .. import rag_ask_contract from .. import rag_ask_contract
from ...core.response import ErrorCode, error_response
from ...services.ai_pmo.ai_pmo_kernel import AIPMOKernelService from ...services.ai_pmo.ai_pmo_kernel import AIPMOKernelService
@router.post("/ask") @router.post("/ask")
@ -27,9 +30,13 @@ async def rag_ask(
status_code=400, status_code=400,
detail=str(ingress_result.get("reason") or "ai_pmo_kernel_not_handled"), detail=str(ingress_result.get("reason") or "ai_pmo_kernel_not_handled"),
) )
return success_response( payload = rag_ask_contract.build_rag_response_payload(ingress_result=ingress_result)
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") @router.get("/manifest")
async def rag_manifest( async def rag_manifest(

View File

@ -1,6 +1,7 @@
from __future__ import annotations from __future__ import annotations
from typing import Any from typing import Any
from uuid import uuid4
from ..models import User from ..models import User
from ..schemas.rag import RAGAskRequest 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), "sync_requirement": bool(request.sync_requirement),
"message_intent": request.message_intent, "message_intent": request.message_intent,
"mixed_signal_part": request.mixed_signal_part, "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用户" 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]: def build_kernel_ingress_kwargs(request: RAGAskRequest, current_user: User) -> dict[str, Any]:
rag_request_context = build_answer_question_kwargs(request=request, current_user=current_user) rag_request_context = build_answer_question_kwargs(request=request, current_user=current_user)
return { return {
"source_system": str(request.asked_via or "pmflow").strip() or "pmflow", "source_system": str(request.asked_via or "pmflow").strip() or "pmflow",
"source_message_id": None, "source_message_id": f"rag-ask-{uuid4().hex}",
"message_text": request.question, "message_text": request.question,
"sender": _resolve_sender_name(current_user), "sender": _resolve_sender_name(current_user),
"channel_ref": request.source_chat_id, "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 payload["consultant_dispatch"] = consultant_dispatch
answer = str(payload.get("answer") or result.get("direct_reply_text") or "").strip() 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: if "reply_required" not in payload:
payload["reply_required"] = bool(result.get("direct_reply_required")) 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 payload["trace_id"] = str(result.get("ai_trace_id") or "").strip() or None
if "processing_stage" not in payload: if "processing_stage" not in payload:
payload["processing_stage"] = "qa" 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["work_item_id"] = _safe_int(result.get("work_item_id"))
payload["ingress_status"] = str(result.get("status") or "").strip() or None 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_kind: Mapped[Optional[str]] = mapped_column(VARCHAR(32), nullable=True)
anchor_ref: Mapped[Optional[str]] = mapped_column(VARCHAR(128), nullable=True) anchor_ref: Mapped[Optional[str]] = mapped_column(VARCHAR(128), nullable=True)
routing_snapshot: Mapped[Optional[Any]] = mapped_column(JSONB, 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]] = { _ASKED_VIA_ALIAS: ClassVar[dict[str, str]] = {
"ai-pmo-process-text": "/ai-pmo/process-text", "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) question: str = Field(..., min_length=1, max_length=4000)
project_id: Optional[int] = Field(default=None) project_id: Optional[int] = Field(default=None)
asked_via: str = Field(default="pmflow", min_length=1, max_length=32) 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) sync_requirement: bool = Field(default=True)
message_intent: Optional[str] = Field(default=None, max_length=32) message_intent: Optional[str] = Field(default=None, max_length=32)
mixed_signal_part: Optional[str] = Field(default=None, max_length=2000) 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") @field_validator("asked_via", mode="before")
@classmethod @classmethod
@ -64,6 +72,19 @@ class RAGAskRequest(BaseModel):
text = str(value).strip() text = str(value).strip()
return text or None 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): class KnowledgeDocCreateRequest(BaseModel):
title: str = Field(..., min_length=1, max_length=255) title: str = Field(..., min_length=1, max_length=255)

View File

@ -29,20 +29,7 @@ class AdaptiveRAGAnswerEntryMixin:
message_intent: Optional[str], message_intent: Optional[str],
) -> bool: ) -> bool:
intent = str(message_intent or "").strip().lower() intent = str(message_intent or "").strip().lower()
if intent == "mixed": return 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",
}
@staticmethod @staticmethod
def _should_trigger_write_ack_gate( def _should_trigger_write_ack_gate(
@ -80,12 +67,11 @@ class AdaptiveRAGAnswerEntryMixin:
sync_requirement: bool = True, sync_requirement: bool = True,
message_intent: Optional[str] = None, message_intent: Optional[str] = None,
mixed_signal_part: Optional[str] = None, mixed_signal_part: Optional[str] = None,
consultant_mode_preference: Optional[str] = None,
) -> dict[str, Any]: ) -> dict[str, Any]:
started = time.perf_counter() started = time.perf_counter()
clean_q = str(question or "").strip() clean_q = str(question or "").strip()
active_thinking_mode = self._normalize_thinking_mode(thinking_mode) 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 deep_thinking = active_thinking_mode == THINKING_MODE_DEEP
enforce_scene_prompt_only = self._should_enforce_scene_prompt_only( enforce_scene_prompt_only = self._should_enforce_scene_prompt_only(
asked_via=asked_via, asked_via=asked_via,
@ -186,4 +172,5 @@ class AdaptiveRAGAnswerEntryMixin:
message_intent=message_intent, message_intent=message_intent,
mixed_signal_part=mixed_signal_part, mixed_signal_part=mixed_signal_part,
anchor_resolution=anchor_resolution, anchor_resolution=anchor_resolution,
consultant_mode_preference=consultant_mode_preference,
) )

View File

@ -5,6 +5,80 @@ from .shared import *
class AdaptiveRAGAnswerMainlineMixin: 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( async def _prepare_answer_runtime(
self, self,
*, *,
@ -135,9 +209,23 @@ class AdaptiveRAGAnswerMainlineMixin:
message_intent: Optional[str], message_intent: Optional[str],
mixed_signal_part: Optional[str], mixed_signal_part: Optional[str],
anchor_resolution: Optional[FollowupAnchorResolution] = None, anchor_resolution: Optional[FollowupAnchorResolution] = None,
consultant_mode_preference: Optional[str] = None,
) -> dict[str, Any]: ) -> 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 = "" forced_deep_reason = ""
if not deep_thinking: if not deep_thinking and not planner.allow_fast_path:
forced_deep_reason = self._forced_deep_reason( forced_deep_reason = self._forced_deep_reason(
question=clean_q, question=clean_q,
effective_question=effective_q, effective_question=effective_q,
@ -211,7 +299,7 @@ class AdaptiveRAGAnswerMainlineMixin:
user_id=asked_by, 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( deterministic_result = await self._run_deterministic_query(
question=effective_q, question=effective_q,
project_id=project_id, project_id=project_id,
@ -233,10 +321,11 @@ class AdaptiveRAGAnswerMainlineMixin:
effective_question=effective_q, effective_question=effective_q,
rewrite_meta=rewrite_meta, rewrite_meta=rewrite_meta,
thinking_mode=active_thinking_mode, thinking_mode=active_thinking_mode,
answer_mode=ANSWER_MODE_FAST_PATH,
) )
direct_answer = None 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( direct_answer = self._build_snapshot_direct_answer(
effective_q, effective_q,
system_snapshot=system_snapshot, system_snapshot=system_snapshot,
@ -262,7 +351,7 @@ class AdaptiveRAGAnswerMainlineMixin:
return direct_result return direct_result
is_snapshot_direct_question = self._is_snapshot_direct_question(effective_q) 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) 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: if glm_semantic_result is not None:
return await self._persist_fast_answer( return await self._persist_fast_answer(
@ -280,6 +369,7 @@ class AdaptiveRAGAnswerMainlineMixin:
effective_question=effective_q, effective_question=effective_q,
rewrite_meta=rewrite_meta, rewrite_meta=rewrite_meta,
thinking_mode=active_thinking_mode, thinking_mode=active_thinking_mode,
answer_mode=ANSWER_MODE_FAST_PATH,
) )
return await self._build_retrieval_response( return await self._build_retrieval_response(
@ -299,7 +389,7 @@ class AdaptiveRAGAnswerMainlineMixin:
thinking_mode=thinking_mode, thinking_mode=thinking_mode,
active_thinking_mode=active_thinking_mode, active_thinking_mode=active_thinking_mode,
deep_thinking=deep_thinking, 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_retrieval_steps=deep_retrieval_steps,
deep_vector_top_k=deep_vector_top_k, deep_vector_top_k=deep_vector_top_k,
system_snapshot=system_snapshot, system_snapshot=system_snapshot,
@ -307,4 +397,6 @@ class AdaptiveRAGAnswerMainlineMixin:
started=started, started=started,
message_intent=message_intent, message_intent=message_intent,
mixed_signal_part=mixed_signal_part, 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: 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( def _build_response_payload(
self, self,
*, *,
@ -40,6 +228,13 @@ class AdaptiveRAGAnswerPayloadMixin:
anchor_kind: Optional[str] = None, anchor_kind: Optional[str] = None,
meta_followup_kind: Optional[str] = None, meta_followup_kind: Optional[str] = None,
reason_code: 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]: ) -> dict[str, Any]:
evidence: list[dict[str, Any]] = [] evidence: list[dict[str, Any]] = []
for item in sources[:8]: for item in sources[:8]:
@ -53,6 +248,37 @@ class AdaptiveRAGAnswerPayloadMixin:
"metadata": item.get("metadata") if isinstance(item.get("metadata"), dict) else {}, "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 { return {
"answer": str(answer or "").strip(), "answer": str(answer or "").strip(),
"evidence": evidence, "evidence": evidence,
@ -71,7 +297,7 @@ class AdaptiveRAGAnswerPayloadMixin:
"vector_timeout": bool(vector_timeout), "vector_timeout": bool(vector_timeout),
"graph_timeout": bool(graph_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, "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, "session_id": int(session_id) if session_id is not None else None,
"turn_index": int(turn_index or 1), "turn_index": int(turn_index or 1),
@ -93,6 +319,12 @@ class AdaptiveRAGAnswerPayloadMixin:
"anchor_kind": str(anchor_kind or "").strip() or None, "anchor_kind": str(anchor_kind or "").strip() or None,
"meta_followup_kind": str(meta_followup_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, "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 @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 ( 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 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, anchor_ref=str(meta_followup_payload.get("anchor_ref") or "").strip() or None,
routing_snapshot=routing_snapshot, routing_snapshot=routing_snapshot,
), ),
**runtime_fields,
) )
self.db.add(qa) self.db.add(qa)
await self._update_session_state_after_turn( await self._update_session_state_after_turn(
@ -114,6 +133,14 @@ class AdaptiveRAGAnswerPrecheckMixin:
anchor_kind=anchor_kind, anchor_kind=anchor_kind,
meta_followup_kind=meta_followup_kind, meta_followup_kind=meta_followup_kind,
reason_code=reason_code, 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( async def _maybe_return_clarification_response(
@ -164,6 +191,23 @@ class AdaptiveRAGAnswerPrecheckMixin:
"metadata": {"mode": "clarification", "reason": clarification_code}, "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 ( 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 int(context_session_id) if context_session_id is not None else None
) )
@ -181,6 +225,7 @@ class AdaptiveRAGAnswerPrecheckMixin:
session_id=active_session_id, session_id=active_session_id,
turn_index=turn_index, turn_index=turn_index,
**self._build_qa_record_external_fields(), **self._build_qa_record_external_fields(),
**runtime_fields,
) )
self.db.add(qa) self.db.add(qa)
if clarification_code == "ambiguous_recent_reply" and clarification_candidates: if clarification_code == "ambiguous_recent_reply" and clarification_candidates:
@ -233,6 +278,14 @@ class AdaptiveRAGAnswerPrecheckMixin:
clarification_code=clarification_code, clarification_code=clarification_code,
clarification_candidates=clarification_candidates, clarification_candidates=clarification_candidates,
reason_code=clarification_code, 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( async def _maybe_return_snapshot_direct_response(
@ -274,6 +327,22 @@ class AdaptiveRAGAnswerPrecheckMixin:
"metadata": {"mode": "snapshot_direct", "topic": "current_user"}, "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( qa = QARecord(
tenant_id=self.tenant_id, 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), 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, turn_index=turn_index,
**self._build_qa_record_external_fields(), **self._build_qa_record_external_fields(),
**runtime_fields,
) )
self.db.add(qa) self.db.add(qa)
await self._update_session_state_after_turn( await self._update_session_state_after_turn(
@ -326,4 +396,12 @@ class AdaptiveRAGAnswerPrecheckMixin:
confidence=0.99, confidence=0.99,
missing_fields=[], missing_fields=[],
thinking_mode=active_thinking_mode, 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, started: float,
message_intent: Optional[str] = None, message_intent: Optional[str] = None,
mixed_signal_part: 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]: ) -> dict[str, Any]:
domains = self._route_domains(effective_q) domains = self._route_domains(effective_q)
if str(asked_via or "").strip().lower() == "feishu" and source_chat_id and "feishu" not in domains: 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)] 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 max_score = retrieved[0].score if retrieved else 0.0
answer_text = await self._generate_answer( answer_text = await self._generate_answer(
@ -82,7 +85,9 @@ class AdaptiveRAGAnswerRetrievalMixin:
message_intent=message_intent, message_intent=message_intent,
mixed_signal_part=mixed_signal_part, mixed_signal_part=mixed_signal_part,
query_type=query_type, 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() routing_snapshot = self._peek_last_answer_routing_snapshot()
used_sources = self._dedupe_sources(retrieved) used_sources = self._dedupe_sources(retrieved)
if not used_sources: if not used_sources:
@ -145,6 +150,7 @@ class AdaptiveRAGAnswerRetrievalMixin:
message_intent=message_intent, message_intent=message_intent,
mixed_signal_part=mixed_signal_part, mixed_signal_part=mixed_signal_part,
query_type=query_type, query_type=query_type,
consultant_mode_preference=consultant_mode_preference,
) )
deep_sources = self._dedupe_sources(deep_retrieved) deep_sources = self._dedupe_sources(deep_retrieved)
if not deep_sources: if not deep_sources:
@ -203,6 +209,37 @@ class AdaptiveRAGAnswerRetrievalMixin:
used_sources = deep_sources used_sources = deep_sources
deep_thinking = True deep_thinking = True
active_thinking_mode = THINKING_MODE_DEEP 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( evidence_guard_triggered = self._should_block_answer_with_guard(
question=clean_q, question=clean_q,
@ -230,8 +267,53 @@ class AdaptiveRAGAnswerRetrievalMixin:
"reason": "insufficient_call_chain_evidence", "reason": "insufficient_call_chain_evidence",
} }
) )
answer_mode = ANSWER_MODE_BLOCKED
elapsed = int((time.perf_counter() - started) * 1000) 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( qa = QARecord(
tenant_id=self.tenant_id, tenant_id=self.tenant_id,
question=clean_q, question=clean_q,
@ -250,16 +332,13 @@ class AdaptiveRAGAnswerRetrievalMixin:
anchor_ref=None, anchor_ref=None,
routing_snapshot=routing_snapshot, routing_snapshot=routing_snapshot,
), ),
**runtime_fields,
) )
self.db.add(qa) self.db.add(qa)
confidence = round(max(0.2, min(1.0, max_score if max_score > 0 else 0.55)), 4) confidence = round(max(0.2, min(1.0, max_score if max_score > 0 else 0.55)), 4)
if evidence_guard_triggered: if evidence_guard_triggered:
confidence = min(confidence, 0.35) confidence = min(confidence, 0.35)
missing_fields = [] missing_fields = list(grounding_verdict.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"]))
await self._update_session_state_after_turn( await self._update_session_state_after_turn(
session_state=state_row, session_state=state_row,
question=clean_q, question=clean_q,
@ -290,7 +369,7 @@ class AdaptiveRAGAnswerRetrievalMixin:
db_timeout=db_timeout, db_timeout=db_timeout,
vector_timeout=vector_timeout, vector_timeout=vector_timeout,
graph_timeout=graph_timeout, graph_timeout=graph_timeout,
tool_trace=tool_trace, tool_trace=runtime_fields["tool_trace"],
qa_id=int(qa.id), qa_id=int(qa.id),
session_id=int(session_id) if session_id is not None else None, session_id=int(session_id) if session_id is not None else None,
turn_index=int(turn_index), turn_index=int(turn_index),
@ -299,5 +378,12 @@ class AdaptiveRAGAnswerRetrievalMixin:
confidence=confidence, confidence=confidence,
missing_fields=missing_fields, missing_fields=missing_fields,
thinking_mode=active_thinking_mode, 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.llm = LLMService()
self.context_cache = context_cache_service self.context_cache = context_cache_service
self._active_external_qa_context: dict[str, Any] = {} self._active_external_qa_context: dict[str, Any] = {}
self._last_answer_prompt_variant_id = "unknown"
@staticmethod @staticmethod
def _normalize_thinking_mode(value: Optional[str]) -> str: 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_source = str(payload.get("route_source") or "").strip().lower()
route_rule_name = str(payload.get("route_rule_name") or "").strip() route_rule_name = str(payload.get("route_rule_name") or "").strip()
routing_decision = str(payload.get("routing_decision") or "").strip() routing_decision = str(payload.get("routing_decision") or "").strip()
routing_config_version = str(payload.get("routing_config_version") or "").strip()
normalized = { normalized = {
"planned_tier": planned_tier if planned_tier in {"low", "high"} else None, "planned_tier": planned_tier if planned_tier in {"low", "high"} else None,
"executed_tier": executed_tier if executed_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_source": route_source or None,
"route_rule_name": route_rule_name or None, "route_rule_name": route_rule_name or None,
"routing_decision": routing_decision 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} return {key: value for key, value in normalized.items() if value is not None}
@ -135,6 +138,97 @@ class AdaptiveRAGCoreIntentMixin:
"routing_snapshot": normalized_snapshot, "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 @staticmethod
def _external_session_id_from_key(conversation_key: str) -> Optional[int]: def _external_session_id_from_key(conversation_key: str) -> Optional[int]:
key = str(conversation_key or "").strip() key = str(conversation_key or "").strip()

View File

@ -7,6 +7,50 @@ from .shared import *
class AdaptiveRAGDeepGuardAnswerMixin: 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 @staticmethod
def _compact_member_workload_user_snapshot(user_snapshot: Optional[dict[str, Any]]) -> dict[str, Any]: def _compact_member_workload_user_snapshot(user_snapshot: Optional[dict[str, Any]]) -> dict[str, Any]:
if not isinstance(user_snapshot, dict): if not isinstance(user_snapshot, dict):
@ -171,6 +215,7 @@ class AdaptiveRAGDeepGuardAnswerMixin:
user_snapshot_text: str, user_snapshot_text: str,
relevance_max: float, relevance_max: float,
query_type: Optional[str] = None, query_type: Optional[str] = None,
consultant_mode_preference: Optional[str] = None,
) -> tuple[str, str]: ) -> tuple[str, str]:
normalized_question = re.sub(r"\s+", "", str(question or "").lower()) normalized_question = re.sub(r"\s+", "", str(question or "").lower())
member_keywords = ("谁", "成员", "负责人", "负载") member_keywords = ("谁", "成员", "负责人", "负载")
@ -180,7 +225,7 @@ class AdaptiveRAGDeepGuardAnswerMixin:
filtered_context_blocks = self._filter_member_workload_context_blocks(context_blocks_text) 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 = self._compact_member_workload_user_snapshot(user_snapshot)
compact_user_snapshot_text = json.dumps(compact_user_snapshot or {}, ensure_ascii=False)[:6000] compact_user_snapshot_text = json.dumps(compact_user_snapshot or {}, ensure_ascii=False)[:6000]
return ( scene_key, prompt = (
"member_workload", "member_workload",
await self._render_scene_prompt( await self._render_scene_prompt(
prompt_key="PROMPT_MEMBER_WORKLOAD_QA", prompt_key="PROMPT_MEMBER_WORKLOAD_QA",
@ -191,11 +236,16 @@ class AdaptiveRAGDeepGuardAnswerMixin:
context_blocks=filtered_context_blocks, 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": if str(message_intent or "").strip().lower() == "mixed":
safe_signal_part = str(mixed_signal_part or "").strip() or "需求信息待补充" safe_signal_part = str(mixed_signal_part or "").strip() or "需求信息待补充"
merged_message = f"{question}\n{safe_signal_part}".strip() merged_message = f"{question}\n{safe_signal_part}".strip()
return ( scene_key, prompt = (
"mixed", "mixed",
await self._render_scene_prompt( await self._render_scene_prompt(
prompt_key="PROMPT_FEISHU_MIXED_QA", prompt_key="PROMPT_FEISHU_MIXED_QA",
@ -208,9 +258,14 @@ class AdaptiveRAGDeepGuardAnswerMixin:
context_blocks=context_blocks_text or "无", 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): if self._is_project_status_question(question, query_type):
return ( scene_key, prompt = (
"project_status", "project_status",
await self._render_scene_prompt( await self._render_scene_prompt(
prompt_key="PROMPT_PROJECT_STATUS_QA", prompt_key="PROMPT_PROJECT_STATUS_QA",
@ -222,6 +277,11 @@ class AdaptiveRAGDeepGuardAnswerMixin:
context_blocks=context_blocks_text or "无", 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): if self._contains_any_keyword(normalized_question, trend_keywords):
explicit_project_context_markers = ( explicit_project_context_markers = (
@ -250,7 +310,7 @@ class AdaptiveRAGDeepGuardAnswerMixin:
}, },
ensure_ascii=False, ensure_ascii=False,
)[:16000] )[:16000]
return ( scene_key, prompt = (
"external_trend", "external_trend",
await self._render_scene_prompt( await self._render_scene_prompt(
prompt_key="PROMPT_EXTERNAL_TREND_QA", prompt_key="PROMPT_EXTERNAL_TREND_QA",
@ -260,7 +320,12 @@ class AdaptiveRAGDeepGuardAnswerMixin:
external_results=context_blocks_text or "无", 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", "industry_trend",
await self._render_scene_prompt( await self._render_scene_prompt(
prompt_key="PROMPT_INDUSTRY_TREND_QA", prompt_key="PROMPT_INDUSTRY_TREND_QA",
@ -268,9 +333,14 @@ class AdaptiveRAGDeepGuardAnswerMixin:
question=question, 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): if self._is_decision_choice_question(question):
return ( scene_key, prompt = (
"decision_choice", "decision_choice",
await self._render_scene_prompt( await self._render_scene_prompt(
prompt_key="PROMPT_DECISION_QA", prompt_key="PROMPT_DECISION_QA",
@ -278,6 +348,11 @@ class AdaptiveRAGDeepGuardAnswerMixin:
question=question, 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): if self._is_ai_pmo_process_entry(asked_via):
world_model_text = json.dumps( world_model_text = json.dumps(
@ -287,7 +362,7 @@ class AdaptiveRAGDeepGuardAnswerMixin:
}, },
ensure_ascii=False, ensure_ascii=False,
)[:18000] )[:18000]
return ( scene_key, prompt = (
"ai_pmo", "ai_pmo",
await self._render_scene_prompt( await self._render_scene_prompt(
prompt_key="PROMPT_AI_PMO_QA", prompt_key="PROMPT_AI_PMO_QA",
@ -298,10 +373,15 @@ class AdaptiveRAGDeepGuardAnswerMixin:
context_blocks=context_blocks_text or "无", 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() normalized_asked_via = str(asked_via or "").strip().lower()
if normalized_asked_via in {"feishu", "lark", "feishu_group", "feishu_dm"}: if normalized_asked_via in {"feishu", "lark", "feishu_group", "feishu_dm"}:
return ( scene_key, prompt = (
"feishu_direct", "feishu_direct",
await self._render_scene_prompt( await self._render_scene_prompt(
prompt_key="PROMPT_FEISHU_DIRECT_QA", prompt_key="PROMPT_FEISHU_DIRECT_QA",
@ -314,8 +394,13 @@ class AdaptiveRAGDeepGuardAnswerMixin:
context_blocks=context_blocks_text or "无", 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", "default",
await self._render_scene_prompt( await self._render_scene_prompt(
prompt_key="PROMPT_RAG_ANSWER", prompt_key="PROMPT_RAG_ANSWER",
@ -329,6 +414,11 @@ class AdaptiveRAGDeepGuardAnswerMixin:
relevance_max=f"{max(0.0, float(relevance_max or 0.0)):.3f}", 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( def _build_snapshot_direct_answer(
self, self,
@ -534,6 +624,7 @@ class AdaptiveRAGDeepGuardAnswerMixin:
message_intent: Optional[str] = None, message_intent: Optional[str] = None,
mixed_signal_part: Optional[str] = None, mixed_signal_part: Optional[str] = None,
query_type: Optional[str] = None, query_type: Optional[str] = None,
consultant_mode_preference: Optional[str] = None,
) -> str: ) -> str:
active_thinking_mode = self._normalize_thinking_mode(thinking_mode) active_thinking_mode = self._normalize_thinking_mode(thinking_mode)
deep_thinking = active_thinking_mode == THINKING_MODE_DEEP deep_thinking = active_thinking_mode == THINKING_MODE_DEEP
@ -582,7 +673,11 @@ class AdaptiveRAGDeepGuardAnswerMixin:
user_snapshot_text=user_snapshot_text, user_snapshot_text=user_snapshot_text,
relevance_max=relevance_max, relevance_max=relevance_max,
query_type=query_type, 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: if deep_thinking:
prompt = ( prompt = (
f"{prompt}\n\n" f"{prompt}\n\n"
@ -613,7 +708,7 @@ class AdaptiveRAGDeepGuardAnswerMixin:
text = str(answer or "").strip() text = str(answer or "").strip()
if not text: if not text:
raise ValueError("adaptive_rag_answer_empty_response") 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( return self._format_mixed_two_block_output(
answer_text=text, answer_text=text,
question_part=question, question_part=question,

View File

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

View File

@ -22,6 +22,7 @@ class AdaptiveRAGFastAnswerSemanticMixinP2:
effective_question: Optional[str] = None, effective_question: Optional[str] = None,
rewrite_meta: Optional[dict[str, Any]] = None, rewrite_meta: Optional[dict[str, Any]] = None,
thinking_mode: str = THINKING_MODE_NORMAL, thinking_mode: str = THINKING_MODE_NORMAL,
answer_mode: str = ANSWER_MODE_FAST_PATH,
) -> dict[str, Any]: ) -> dict[str, Any]:
started = time.perf_counter() started = time.perf_counter()
answer_text = str(fast_result.get("answer") or "未命中结果") answer_text = str(fast_result.get("answer") or "未命中结果")
@ -42,6 +43,24 @@ class AdaptiveRAGFastAnswerSemanticMixinP2:
if missing_fields is None: if missing_fields is None:
missing_fields = list(fast_result.get("missing_fields") or []) 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) 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( qa = QARecord(
tenant_id=self.tenant_id, tenant_id=self.tenant_id,
@ -57,6 +76,7 @@ class AdaptiveRAGFastAnswerSemanticMixinP2:
session_id=active_session_id_int, session_id=active_session_id_int,
turn_index=active_turn_index, turn_index=active_turn_index,
**self._build_qa_record_external_fields(), **self._build_qa_record_external_fields(),
**runtime_fields,
) )
self.db.add(qa) self.db.add(qa)
if state_row is None and active_session_id_int is not None: if state_row is None and active_session_id_int is not None:
@ -100,4 +120,12 @@ class AdaptiveRAGFastAnswerSemanticMixinP2:
confidence=confidence, confidence=confidence,
missing_fields=list(missing_fields or []), missing_fields=list(missing_fields or []),
thinking_mode=thinking_mode, 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 qa_id: Optional[int] = None
missing_fields = [] if resolved_project_id is not None else ["project_id"]
try: 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( qa = QARecord(
tenant_id=self.tenant_id, tenant_id=self.tenant_id,
question=question, question=question,
@ -117,6 +136,7 @@ class AdaptiveRAGRequirementAckFlowMixin:
session_id=session_id, session_id=session_id,
turn_index=int(turn_index or 1), turn_index=int(turn_index or 1),
**self._build_qa_record_external_fields(), **self._build_qa_record_external_fields(),
**runtime_fields,
) )
self.db.add(qa) self.db.add(qa)
await self._update_session_state_after_turn( await self._update_session_state_after_turn(
@ -146,7 +166,6 @@ class AdaptiveRAGRequirementAckFlowMixin:
pass pass
confidence = 0.94 if action_taken in {"signal_created", "signal_deduped"} else 0.35 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( return self._build_response_payload(
answer=answer_text, answer=answer_text,
sources=sources, sources=sources,
@ -171,4 +190,8 @@ class AdaptiveRAGRequirementAckFlowMixin:
ack_latency_ms=ack_latency_ms, ack_latency_ms=ack_latency_ms,
trace_id=trace_id, trace_id=trace_id,
processing_stage="ack_only", 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_STATUS = "status"
QUERY_TYPE_EXPLAIN = "explain" QUERY_TYPE_EXPLAIN = "explain"
QUERY_TYPE_UNKNOWN = "unknown" 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_AUTO = "auto"
ACTION_MODE_READ = "read" ACTION_MODE_READ = "read"
ACTION_MODE_WRITE = "write" ACTION_MODE_WRITE = "write"
@ -147,3 +157,26 @@ class RetrievedItem:
score: float score: float
source_label: str source_label: str
metadata: dict[str, Any] 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 hashlib
import json import json
import re
from datetime import timedelta from datetime import timedelta
from typing import Any, Optional from typing import Any, Optional

View File

@ -43,6 +43,7 @@ class ExecuteQueryProjectMixin:
sync_requirement = bool(rag_request_context.get("sync_requirement")) sync_requirement = bool(rag_request_context.get("sync_requirement"))
message_intent = str(rag_request_context.get("message_intent") or "").strip() or None 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 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: else:
thread_context = metadata.get("thread_context") if isinstance(metadata.get("thread_context"), dict) else None 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 source_sender_open_id = str(metadata.get("source_sender_open_id") or "").strip() or None
@ -71,6 +72,7 @@ class ExecuteQueryProjectMixin:
sync_requirement = False sync_requirement = False
message_intent = None message_intent = None
mixed_signal_part = None mixed_signal_part = None
consultant_mode_preference = None
if isinstance(rag_request_context, dict): if isinstance(rag_request_context, dict):
source_sender_open_id = str(rag_request_context.get("source_sender_open_id") or "").strip() or None 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 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, "sync_requirement": sync_requirement,
"message_intent": message_intent, "message_intent": message_intent,
"mixed_signal_part": mixed_signal_part, "mixed_signal_part": mixed_signal_part,
"consultant_mode_preference": consultant_mode_preference,
} }
if session_id is not None: if session_id is not None:
rag_kwargs["session_id"] = int(session_id) rag_kwargs["session_id"] = int(session_id)
@ -98,6 +101,17 @@ class ExecuteQueryProjectMixin:
try: try:
rag_result = await rag_service.answer_question(**rag_kwargs) rag_result = await rag_service.answer_question(**rag_kwargs)
except Exception as exc: 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 { return {
"status": "failed_safe", "status": "failed_safe",
"error_message": self._build_query_error_message(exc), "error_message": self._build_query_error_message(exc),

View File

@ -63,6 +63,48 @@ _MISSING_FIELD_LABELS = {
"project_scope": "项目范围", "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]: def _normalize_missing_fields(value: Any) -> list[str]:
if not isinstance(value, list): if not isinstance(value, list):
@ -75,6 +117,27 @@ def _normalize_missing_fields(value: Any) -> list[str]:
return fields 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: def _resolve_domain(*, intent_name: str, primary_operation_code: Optional[str], classification: str) -> str:
normalized_op = str(primary_operation_code or "").strip().lower() normalized_op = str(primary_operation_code or "").strip().lower()
normalized_intent = str(intent_name 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: def _build_clarify_next_action(*, missing_fields: list[str], direct_reply_text: str) -> str:
if direct_reply_text: if direct_reply_text and not _looks_like_long_answer(direct_reply_text):
return direct_reply_text[:240] return _normalize_action_text(direct_reply_text)[:120]
labels = [_MISSING_FIELD_LABELS.get(item, item) for item in missing_fields] labels = [_MISSING_FIELD_LABELS.get(item, item) for item in missing_fields]
suffix = "、".join([label for label in labels if label]) or "关键信息" suffix = "、".join([label for label in labels if label]) or "关键信息"
return f"请先补充 {suffix},我再继续推进。" return f"请先补充 {suffix},我再继续推进。"[:120]
def _build_next_action( def _build_next_action(
@ -183,13 +246,13 @@ def _build_next_action(
direct_reply_text=direct_reply_text, direct_reply_text=direct_reply_text,
) )
if mode == MODE_CHALLENGE: if mode == MODE_CHALLENGE:
if direct_reply_text: if direct_reply_text and not _looks_like_long_answer(direct_reply_text):
return direct_reply_text[:240] return _normalize_action_text(direct_reply_text)[:120]
if str(risk_level or "").strip().lower() == "high": if str(risk_level or "").strip().lower() == "high":
return "建议先拆分范围,再确认是否继续推进高风险动作。" return "建议先拆分范围,再确认是否继续推进高风险动作。"[:120]
return "建议先确认目标边界与优先级,再决定是否继续推进。" return _domain_next_action(domain=domain, mode=mode)
if mode == MODE_BRAINSTORM: if mode == MODE_BRAINSTORM:
return "请补充目标用户、核心场景和约束,我会继续给出 2-3 个可选方向。" return _domain_next_action(domain=domain, mode=mode)
if mode == MODE_EXECUTE: if mode == MODE_EXECUTE:
if domain == DOMAIN_PROJECT: if domain == DOMAIN_PROJECT:
return "可继续查看项目结果,或补充负责人与排期信息。" return "可继续查看项目结果,或补充负责人与排期信息。"
@ -198,9 +261,9 @@ def _build_next_action(
if domain == DOMAIN_SIGNAL: if domain == DOMAIN_SIGNAL:
return "可继续确认归属项目,或查看后续自动处理结果。" return "可继续确认归属项目,或查看后续自动处理结果。"
return "可继续查看结果,或补充更多执行细节。" return "可继续查看结果,或补充更多执行细节。"
if direct_reply_text: if direct_reply_text and not _looks_like_long_answer(direct_reply_text):
return direct_reply_text[:240] return _normalize_action_text(direct_reply_text)[:120]
return "你可以继续追问细节,或让我给出更具体的建议。" return _domain_next_action(domain=domain, mode=mode)
def build_consultant_dispatch_contract( 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), "question": getattr(qa_record, "question", None),
"answer": getattr(qa_record, "answer", None), "answer": getattr(qa_record, "answer", None),
"sources": _jsonable(getattr(qa_record, "sources", None) or []), "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 = { after_value = {
"rating": rating, "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), "answer": getattr(qa_record, "answer", None),
"sources": _jsonable(getattr(qa_record, "sources", None) or []), "sources": _jsonable(getattr(qa_record, "sources", None) or []),
"domains_used": _jsonable(getattr(qa_record, "domains_used", 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, "ai_confidence": None,
"reason_candidates": reason_candidates, "reason_candidates": reason_candidates,

View File

@ -95,6 +95,62 @@ _REASON_DEFINITIONS: list[dict[str, Any]] = [
"field_names": {"qa_feedback"}, "field_names": {"qa_feedback"},
"confidence": 0.86, "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", "code": "strategic_judgement_mismatch",
"label": "战略判断偏差", "label": "战略判断偏差",
@ -325,6 +381,22 @@ def build_reason_candidates(
0.78, 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: else:
add("manual_review_needed", "需要人工复核", 0.72, "QA 反馈建议保留人工复核以确认样本质量。") add("manual_review_needed", "需要人工复核", 0.72, "QA 反馈建议保留人工复核以确认样本质量。")
elif source_key == "signal_feedback": elif source_key == "signal_feedback":

View File

@ -177,6 +177,7 @@ class LLMPromptProfileRuntimeMixin:
"route_source": meta.get("route_source") or None, "route_source": meta.get("route_source") or None,
"route_rule_name": meta.get("route_rule_name") or None, "route_rule_name": meta.get("route_rule_name") or None,
"routing_decision": meta.get("routing_decision") 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: async with AsyncSessionLocal() as session:
for attempt in range(2): for attempt in range(2):

View File

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

View File

@ -33,10 +33,12 @@ class _FakeAdaptiveRAGService(
"context_session_id": 101, "context_session_id": 101,
"conversation": [], "conversation": [],
"state_row": None, "state_row": None,
"resolved_question": clean_q,
"effective_q": clean_q, "effective_q": clean_q,
"rewrite_meta": {"mode": "direct"}, "rewrite_meta": {"mode": "direct"},
"query_type": "explain", "query_type": "explain",
"effective_action_mode": action_mode, "effective_action_mode": action_mode,
"anchor_resolution": None,
} }
async def _handle_ack_first_requirement(self, **kwargs): 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 @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() service = _FakeAdaptiveRAGService()
result = await service.answer_question( result = await service.answer_question(
question="最近有哪些项目?", question="最近有哪些项目?",
asked_by=1, asked_by=1,
asked_via="pmflow", asked_via="pmflow",
thinking_mode="normal",
sync_requirement=True, sync_requirement=True,
) )
assert result["processing_stage"] == "qa" assert result["processing_stage"] == "qa"
assert service.ack_calls == 0 assert service.ack_calls == 0
assert service.base_calls == 1 assert service.base_calls == 1
assert service.base_kwargs["active_thinking_mode"] == "deep" assert service.base_kwargs["active_thinking_mode"] == "normal"
assert service.base_kwargs["deep_thinking"] is True 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_retrieval_steps"]) == DEEP_RETRIEVAL_STEPS
assert int(service.base_kwargs["deep_vector_top_k"]) == DEEP_VECTOR_TOP_K 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 @pytest.mark.asyncio
async def test_answer_entry_mixed_request_skips_requirement_ack_gate() -> None: async def test_answer_entry_mixed_request_skips_requirement_ack_gate() -> None:
service = _FakeAdaptiveRAGService() service = _FakeAdaptiveRAGService()
@ -120,8 +140,8 @@ async def test_answer_entry_mixed_request_skips_requirement_ack_gate() -> None:
assert result["processing_stage"] == "qa" assert result["processing_stage"] == "qa"
assert service.ack_calls == 0 assert service.ack_calls == 0
assert service.base_calls == 1 assert service.base_calls == 1
assert service.base_kwargs["active_thinking_mode"] == "deep" assert service.base_kwargs["active_thinking_mode"] == "normal"
assert service.base_kwargs["deep_thinking"] is True assert service.base_kwargs["deep_thinking"] is False
@pytest.mark.asyncio @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 result["processing_stage"] == "qa"
assert service.ack_calls == 0 assert service.ack_calls == 0
assert service.base_calls == 1 assert service.base_calls == 1
assert service.base_kwargs["active_thinking_mode"] == "deep" assert service.base_kwargs["active_thinking_mode"] == "normal"
assert service.base_kwargs["deep_thinking"] is True 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_retrieval_steps"]) == DEEP_RETRIEVAL_STEPS
assert int(service.base_kwargs["deep_vector_top_k"]) == DEEP_VECTOR_TOP_K 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 @pytest.mark.asyncio
async def test_deterministic_query_prefers_project_created_at_answer() -> None: async def test_deterministic_query_prefers_project_created_at_answer() -> None:
calls: list[str] = [] calls: list[str] = []
@ -207,7 +264,7 @@ async def test_deep_guard_answer_raises_on_empty_llm_response() -> None:
_ = project_id _ = project_id
return {} return {}
def _select_scene_prompt(self, **kwargs): async def _select_scene_prompt(self, **kwargs):
_ = kwargs _ = kwargs
return "external_trend", "请回答问题" 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 "杨宇当前负载265.56%" in result
assert "严重超载" 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 import routes_core
from app.api.ai_pmo_api.schemas import PMOProcessTextRequest 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 ( from app.services.ai_pmo.ai_pmo_kernel.mixins.intent.ai_pmo_kernel_mixins_intent_rules import (
KernelIntentRulesMixin, KernelIntentRulesMixin,
) )
from app.services.ai_pmo.ai_pmo_kernel.ai_agent_kernel import gate_execution 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.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_entry
from app.services.ai_pmo.ai_pmo_kernel_ingress_flow import mixin_prepare_manual 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 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, "sync_requirement": True,
"message_intent": "mixed", "message_intent": "mixed",
"mixed_signal_part": "新增任务确认流", "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["sync_requirement"] is True
assert answer_kwargs["message_intent"] == "mixed" assert answer_kwargs["message_intent"] == "mixed"
assert answer_kwargs["mixed_signal_part"] == "新增任务确认流" assert answer_kwargs["mixed_signal_part"] == "新增任务确认流"
assert answer_kwargs["consultant_mode_preference"] == "challenge"
@pytest.mark.asyncio @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 @pytest.mark.asyncio
async def test_low_tier_rag_failure_retries_high_tier(monkeypatch) -> None: async def test_low_tier_rag_failure_retries_high_tier(monkeypatch) -> None:
captured: dict[str, object] = {"logs": []} 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", "intent": "query.read_only",
"direct_reply_required": True, "direct_reply_required": True,
"direct_reply_text": "当前主链状态正常。", "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) monkeypatch.setattr(routes_query, "AIPMOKernelService", FakeKernel)
@ -68,6 +84,11 @@ async def test_http_rag_ask_readonly_mainline_contract(monkeypatch) -> None:
payload = response.json() payload = response.json()
assert payload["code"] == 0 assert payload["code"] == 0
assert "当前主链状态正常" in str((payload.get("data") or {}).get("answer") or "") assert "当前主链状态正常" in str((payload.get("data") or {}).get("answer") or "")
assert payload["data"]["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"] ingress_kwargs = captured["ingress_kwargs"]
assert isinstance(ingress_kwargs, dict) assert isinstance(ingress_kwargs, dict)
assert ingress_kwargs["source_system"] == "pmflow_manual" assert ingress_kwargs["source_system"] == "pmflow_manual"

View File

@ -6,7 +6,7 @@ from types import SimpleNamespace
import pytest import pytest
import pytest_asyncio import pytest_asyncio
from sqlalchemy import select from sqlalchemy import JSON, select
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
os.environ["DATABASE_URL"] = "sqlite+aiosqlite:///:memory:" 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_signal_feedback_to_record_payload,
map_task_override_to_record_payloads, 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 import app.services.feedback_center.service as feedback_center_service_module
from app.services.feedback_center.service import FeedbackCenterService 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 @pytest_asyncio.fixture
async def db_session(): async def db_session():
engine = create_async_engine("sqlite+aiosqlite:///:memory:") engine = create_async_engine("sqlite+aiosqlite:///:memory:")
_use_sqlite_json_types()
async with engine.begin() as conn: async with engine.begin() as conn:
await conn.run_sync( await conn.run_sync(
lambda sync_conn: Base.metadata.create_all( 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" 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: def test_source_mappers_generate_unified_payloads() -> None:
qa_payload = map_qa_feedback_to_record_payload( qa_payload = map_qa_feedback_to_record_payload(
qa_feedback=SimpleNamespace( 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 from types import SimpleNamespace
import pytest import pytest
@ -18,11 +19,13 @@ def test_rag_ask_request_normalizes_contract_fields() -> None:
message_intent="MIXED", message_intent="MIXED",
mixed_signal_part="新增任务确认流", mixed_signal_part="新增任务确认流",
action_mode="read", action_mode="read",
consultant_mode_preference="ADVise",
) )
assert req.asked_via == "/ai-pmo/process-text" assert req.asked_via == "/ai-pmo/process-text"
assert req.message_intent == "mixed" assert req.message_intent == "mixed"
assert req.mixed_signal_part == "新增任务确认流" assert req.mixed_signal_part == "新增任务确认流"
assert req.action_mode == "read" assert req.action_mode == "read"
assert req.consultant_mode_preference == "advise"
def test_rag_ask_request_rejects_unknown_asked_via() -> None: 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, sync_requirement=0,
message_intent="mixed", message_intent="mixed",
mixed_signal_part="新增任务确认流", mixed_signal_part="新增任务确认流",
consultant_mode_preference="challenge",
) )
kwargs = rag_ask_contract.build_answer_question_kwargs( kwargs = rag_ask_contract.build_answer_question_kwargs(
request=request, request=request,
@ -63,9 +67,29 @@ def test_build_answer_question_kwargs_maps_fields() -> None:
"sync_requirement": False, "sync_requirement": False,
"message_intent": "mixed", "message_intent": "mixed",
"mixed_signal_part": "新增任务确认流", "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 @pytest.mark.asyncio
async def test_rag_ask_route_forwards_contract_fields(monkeypatch) -> None: async def test_rag_ask_route_forwards_contract_fields(monkeypatch) -> None:
captured: dict[str, object] = {} captured: dict[str, object] = {}
@ -94,6 +118,7 @@ async def test_rag_ask_route_forwards_contract_fields(monkeypatch) -> None:
action_mode="read", action_mode="read",
message_intent="mixed", message_intent="mixed",
mixed_signal_part="新增任务确认流", mixed_signal_part="新增任务确认流",
consultant_mode_preference="clarify",
) )
current_user = SimpleNamespace(id=101, tenant_id=9) current_user = SimpleNamespace(id=101, tenant_id=9)
response = await routes_query.rag_ask( 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["action_mode"] == "read"
assert rag_request_context["message_intent"] == "mixed" assert rag_request_context["message_intent"] == "mixed"
assert rag_request_context["mixed_signal_part"] == "新增任务确认流" assert rag_request_context["mixed_signal_part"] == "新增任务确认流"
assert rag_request_context["consultant_mode_preference"] == "clarify"
def test_ai_steward_router_does_not_expose_legacy_rag_ask_route() -> None: def test_ai_steward_router_does_not_expose_legacy_rag_ask_route() -> None:
@ -167,10 +193,12 @@ async def test_rag_ask_route_uses_shared_contract_builder(monkeypatch) -> None:
assert response_rag["code"] == 0 assert response_rag["code"] == 0
assert captured_tenants == [88] 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_system": "pmflow",
"source_message_id": None, "source_message_id": forwarded["source_message_id"],
"message_text": "q1", "message_text": "q1",
"sender": "PMFlow用户", "sender": "PMFlow用户",
"channel_ref": None, "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: 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", "answer": "final answer",
"session_id": 18, "session_id": 18,
"sources": [{"source": "snapshot"}], "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", "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["trace_id"] == "ai-pmo-321"
assert payload["reply_required"] is True assert payload["reply_required"] is True
assert payload["reply_text"] == "fallback" 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: 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 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", "sources",
"domains_used", "domains_used",
"routing_snapshot", "routing_snapshot",
"citations",
"sources_used",
"evidence_pack",
"tool_trace",
"retrieval_metrics",
"evidence", "evidence",
"source_snapshot", "source_snapshot",
"tags", "tags",

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

@ -150,6 +150,26 @@ export interface AIPMOCadenceSnapshot {
created_at?: string | null; 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 { export interface AIPMOProgramView {
summary: { summary: {
program_count: number; program_count: number;
@ -169,6 +189,12 @@ export interface AIPMOProgramView {
} }
export const aiPmoApi = { 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> { async listWorkItems(params?: { limit?: number; state?: string; projectId?: number }): Promise<AIPMOWorkItemListPayload> {
const { data } = await api.get<ApiEnvelope<AIPMOWorkItemListPayload>>("/ai-pmo/work-items", { const { data } = await api.get<ApiEnvelope<AIPMOWorkItemListPayload>>("/ai-pmo/work-items", {
params: { params: {

View File

@ -279,7 +279,162 @@ export interface LLMEngineConnectionTestResponse {
response_preview: string; 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 = { 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[]> { async listCognitiveConflicts(projectId?: number): Promise<CognitiveConflict[]> {
const query = typeof projectId === "number" ? `?project_id=${projectId}` : ""; const query = typeof projectId === "number" ? `?project_id=${projectId}` : "";
return await apiRequest<CognitiveConflict[]>(`/ai-steward/cognitive-conflicts${query}`); return await apiRequest<CognitiveConflict[]>(`/ai-steward/cognitive-conflicts${query}`);

View File

@ -1,18 +1,30 @@
import { useEffect, useMemo, useRef, useState } from "react"; import { useEffect, useMemo, useRef, useState } from "react";
import { AlertCircle, Loader2, MessageSquare, Sparkles, ThumbsDown, ThumbsUp, X } from "lucide-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 { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea"; import { Textarea } from "@/components/ui/textarea";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Switch } from "@/components/ui/switch"; import { Switch } from "@/components/ui/switch";
import { apiBaseURL, getAuthToken } from "@/lib/api";
import { formatBeijingDate, formatBeijingTime, parseBackendDate } from "@/lib/datetime"; import { formatBeijingDate, formatBeijingTime, parseBackendDate } from "@/lib/datetime";
import { cn } from "@/lib/utils";
type FeedbackRating = "positive" | "negative"; type FeedbackRating = "positive" | "negative";
type Helpfulness = "fully" | "partially" | "could_be_better"; type Helpfulness = "fully" | "partially" | "could_be_better";
type ThinkingMode = "normal" | "deep"; type ThinkingMode = "normal" | "deep";
type ActionTaken = "signal_created" | "signal_deduped" | "signal_failed"; 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 MessageSource = { source: string; label: string; score: number; metadata?: Record<string, unknown> };
type ConsultantDispatch = { type ConsultantDispatch = {
domain: string; domain: string;
@ -84,18 +96,23 @@ const ERROR_OPTIONS = ["答案错误", "来源错误", "不完整", "答非所
const LAST_SEEN_KEY = "pmflow_qa_chat_last_seen"; const LAST_SEEN_KEY = "pmflow_qa_chat_last_seen";
const THINKING_MODE_KEY = "pmflow_qa_chat_thinking_mode"; const THINKING_MODE_KEY = "pmflow_qa_chat_thinking_mode";
const QUICK_QUESTIONS = ["你是谁", "系统架构是什么", "最近有哪些项目", "当前阻塞项有哪些"]; const QUICK_QUESTIONS = ["你是谁", "系统架构是什么", "最近有哪些项目", "当前阻塞项有哪些"];
const CONSULTANT_MODE_LABELS: Record<string, string> = { const CONSULTANT_MODE_ACTIONS: ReadonlyArray<{
clarify: "先澄清", value: ConsultantModePreference;
advise: "给建议", label: string;
challenge: "先劝退", tone: "advise" | "clarify" | "brainstorm" | "challenge";
brainstorm: "一起想", }> = [
execute: "可执行", { 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> = { const CONSULTANT_RISK_LABELS: Record<string, string> = {
low: "低风险", low: "低风险",
medium: "中风险", medium: "中风险",
high: "高风险", 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 { function normalizeRequestError(error: unknown, fallback = "请求失败,请稍后重试。"): string {
const raw = error instanceof Error ? error.message : String(error || ""); 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 { function looksLikeRequirementWrite(text: string): boolean {
const clean = String(text || "").trim(); const clean = String(text || "").trim();
if (!clean) return false; if (!clean) return false;
@ -167,20 +192,6 @@ function getStageBadge(message: Pick<SessionMessage, "processing_stage" | "reply
return null; 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( function getConsultantRiskBadge(
consultantDispatch?: ConsultantDispatch | null, consultantDispatch?: ConsultantDispatch | null,
): { label: string; variant: "secondary" | "destructive" | "outline" } | null { ): { label: string; variant: "secondary" | "destructive" | "outline" } | null {
@ -195,127 +206,79 @@ function getConsultantRiskBadge(
return { label: CONSULTANT_RISK_LABELS[riskLevel] || riskLevel, variant: "secondary" }; return { label: CONSULTANT_RISK_LABELS[riskLevel] || riskLevel, variant: "secondary" };
} }
async function fetchApi<T>(path: string, init?: RequestInit): Promise<T | null> { function buildRiskExplanation(consultantDispatch?: ConsultantDispatch | null): string {
const token = getAuthToken(); const riskLevel = String(consultantDispatch?.risk_level || "").trim().toLowerCase();
if (!token) return null; const missingCount = consultantDispatch?.missing_fields?.length || 0;
try { if (riskLevel === "high") {
const res = await fetch(`${apiBaseURL}${path}`, { return missingCount > 0
...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;
} }
if (riskLevel === "medium") {
return missingCount > 0
? "当前风险中等,建议先补齐缺失信息,再决定是否继续推进。"
: "当前风险中等,适合先补充证据或确认边界,再继续下一步。";
}
if (consultantDispatch?.allow_execute) {
return "当前被判为低风险,且信息较完整,可以在确认后交接给 AI PMO 执行流。";
}
return "当前被判为低风险,但系统仍建议先按下一步补齐信息,而不是直接执行。";
} }
async function askRAG( function getActionChipClasses(
question: string, tone: "deep" | "advise" | "clarify" | "brainstorm" | "challenge" | "risk-low" | "risk-medium" | "risk-high" | "execute" | "next",
sessionId?: number | null, active = false,
thinkingMode: ThinkingMode = "normal", ): string {
newSession = false, const sharedInactive = "bg-white/90 text-slate-700 hover:bg-white hover:text-slate-900";
): Promise<{ switch (tone) {
answer: string; case "deep":
session_id?: number | null; return cn(
thinking_mode?: ThinkingMode; ACTION_CHIP_BASE,
sources?: MessageSource[]; active
response_time_ms?: number | null; ? "border-violet-500 bg-violet-50 text-violet-700"
action_taken?: ActionTaken | null; : `${sharedInactive} border-violet-200 hover:border-violet-300 hover:text-violet-700`,
signal_id?: number | null; );
reply_required?: boolean; case "advise":
reply_text?: string | null; return cn(
ack_latency_ms?: number | null; ACTION_CHIP_BASE,
trace_id?: string | null; active
processing_stage?: string | null; ? "border-emerald-500 bg-emerald-50 text-emerald-700"
consultant_dispatch?: ConsultantDispatch | null; : `${sharedInactive} border-emerald-200 hover:border-emerald-300 hover:text-emerald-700`,
}> { );
const token = getAuthToken(); case "clarify":
if (!token) { return cn(
throw new Error("登录状态已失效,请重新登录后提问。"); 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[]) { function groupSessions(items: SessionSummary[]) {
@ -384,6 +347,9 @@ export function FloatingQAChat() {
const [errorTypes, setErrorTypes] = useState<Record<number, string[]>>({}); const [errorTypes, setErrorTypes] = useState<Record<number, string[]>>({});
const [correctAnswer, setCorrectAnswer] = useState<Record<number, string>>({}); const [correctAnswer, setCorrectAnswer] = useState<Record<number, string>>({});
const [submittingFeedbackId, setSubmittingFeedbackId] = useState<number | null>(null); 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 [showOlder, setShowOlder] = useState(false);
const [unread, setUnread] = useState(false); const [unread, setUnread] = useState(false);
const [renameEditing, setRenameEditing] = useState(false); const [renameEditing, setRenameEditing] = useState(false);
@ -395,19 +361,40 @@ export function FloatingQAChat() {
const messageViewportRef = useRef<HTMLDivElement | null>(null); const messageViewportRef = useRef<HTMLDivElement | null>(null);
const messageEndRef = useRef<HTMLDivElement | null>(null); const messageEndRef = useRef<HTMLDivElement | null>(null);
const forceStickBottomRef = useRef(false); 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) => { const loadSessions = async (page = 1, append = false) => {
setHistoryLoading(true); setHistoryLoading(true);
try { try {
const data = await fetchApi<{ const data = await aiStewardApi.listQASessions({ page, pageSize: 20 });
items: SessionSummary[];
page: number;
total_pages: number;
}>(`/rag/qa-sessions?page=${page}&page_size=20`);
if (!data) {
setHistoryApiAvailable(false);
return;
}
setHistoryApiAvailable(true); setHistoryApiAvailable(true);
setHistoryPage(Number(data.page || page)); 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) { if (!activeSessionIdRef.current && !draftNewSessionRef.current && Array.isArray(data.items) && data.items.length > 0) {
setActiveSessionId(data.items[0].id); setActiveSessionId(data.items[0].id);
} }
} catch (error) {
console.error("[FloatingQAChat] loadSessions failed", error);
setHistoryApiAvailable(false);
} finally { } finally {
setHistoryLoading(false); setHistoryLoading(false);
} }
@ -449,17 +439,16 @@ export function FloatingQAChat() {
setDetailLoading(true); setDetailLoading(true);
} }
try { try {
const data = await fetchApi<SessionDetail>(`/rag/qa-sessions/${sessionId}`); const data = normalizeSessionDetail(await aiStewardApi.getQASession(sessionId));
if (!data) {
setHistoryApiAvailable(false);
return;
}
setHistoryApiAvailable(true); setHistoryApiAvailable(true);
setSessionDetail(data); setSessionDetail(data);
if (!preserveLocal) { if (!preserveLocal) {
setLocalMessages([]); setLocalMessages([]);
} }
setRenameTitle(String(data.session?.title || "")); setRenameTitle(String(data.session?.title || ""));
} catch (error) {
console.error("[FloatingQAChat] loadSessionDetail failed", error);
setHistoryApiAvailable(false);
} finally { } finally {
if (!silent) { if (!silent) {
setDetailLoading(false); setDetailLoading(false);
@ -487,34 +476,52 @@ export function FloatingQAChat() {
setRenameTitle(""); setRenameTitle("");
}; };
const askQuestion = async () => { const runQuestionRequest = async ({
const clean = question.trim(); prompt,
requestThinkingModeOverride,
consultantModePreference,
clearInput = false,
}: {
prompt: string;
requestThinkingModeOverride?: ThinkingMode;
consultantModePreference?: ConsultantModePreference | null;
clearInput?: boolean;
}) => {
const clean = prompt.trim();
if (!clean) return; if (!clean) return;
const currentSessionId = draftNewSession ? null : activeSessionIdRef.current; const currentSessionId = draftNewSession ? null : activeSessionIdRef.current;
const currentThinkingMode: ThinkingMode = thinkingMode; const currentThinkingMode: ThinkingMode = requestThinkingModeOverride || thinkingMode;
forceStickBottomRef.current = true; forceStickBottomRef.current = true;
setAskError(""); setAskError("");
setRequestThinkingMode(currentThinkingMode); setRequestThinkingMode(currentThinkingMode);
setAsking(true); setAsking(true);
try { 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(""); setQuestion("");
}
setSessionDetail(null); setSessionDetail(null);
draftNewSessionRef.current = false; draftNewSessionRef.current = false;
setDraftNewSession(false); setDraftNewSession(false);
const localId = localMessageIdRef.current; appendLocalMessage({
localMessageIdRef.current -= 1;
setLocalMessages((prev) => [
...prev,
{
id: localId,
question: clean, question: clean,
answer: String(data.reply_text || data.answer || ""), 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, 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, thinking_mode: data.thinking_mode || currentThinkingMode,
action_taken: data.action_taken ?? null, action_taken: data.action_taken ?? null,
signal_id: data.signal_id ?? null, signal_id: data.signal_id ?? null,
@ -523,9 +530,8 @@ export function FloatingQAChat() {
ack_latency_ms: data.ack_latency_ms ?? null, ack_latency_ms: data.ack_latency_ms ?? null,
trace_id: data.trace_id ?? null, trace_id: data.trace_id ?? null,
processing_stage: data.processing_stage ?? 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; const returnedSessionId = Number(data.session_id || 0) || null;
if (historyApiAvailable) { if (historyApiAvailable) {
await loadSessions(1, false); await loadSessions(1, false);
@ -536,7 +542,7 @@ export function FloatingQAChat() {
setActiveSessionId(currentSessionId); setActiveSessionId(currentSessionId);
await refreshActiveSession(currentSessionId, { silent: true, preserveLocal: true }); await refreshActiveSession(currentSessionId, { silent: true, preserveLocal: true });
} else if (latestSessionAtRef.current) { } 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; const target = fallback?.items?.[0]?.id || null;
if (target) { if (target) {
setActiveSessionId(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) => { const submitFeedback = async (messageId: number, rating: FeedbackRating) => {
setSubmittingFeedbackId(messageId); setSubmittingFeedbackId(messageId);
try { try {
await fetchApi(`/rag/qa-records/${messageId}/feedback`, { await aiStewardApi.submitQAFeedback(messageId, {
method: "POST",
body: JSON.stringify({
rating, rating,
helpfulness: rating === "positive" ? helpfulness[messageId] || "fully" : undefined, helpfulness: rating === "positive" ? helpfulness[messageId] || "fully" : undefined,
error_types: rating === "negative" ? errorTypes[messageId] || [] : [], error_types: rating === "negative" ? errorTypes[messageId] || [] : [],
correct_answer: rating === "negative" ? correctAnswer[messageId] || undefined : undefined, correct_answer: rating === "negative" ? correctAnswer[messageId] || undefined : undefined,
}),
}); });
setFeedbackMode((prev) => ({ ...prev, [messageId]: null })); setFeedbackMode((prev) => ({ ...prev, [messageId]: null }));
await Promise.all([loadSessions(1, false), refreshActiveSession()]); await Promise.all([loadSessions(1, false), refreshActiveSession()]);
@ -576,15 +587,83 @@ export function FloatingQAChat() {
if (!activeSessionId) return; if (!activeSessionId) return;
const clean = renameTitle.trim(); const clean = renameTitle.trim();
if (!clean) return; if (!clean) return;
const data = await fetchApi<{ title: string }>(`/rag/qa-sessions/${activeSessionId}/title`, { await aiStewardApi.renameQASession(activeSessionId, clean);
method: "PATCH",
body: JSON.stringify({ title: clean }),
});
if (!data) return;
setRenameEditing(false); setRenameEditing(false);
await Promise.all([loadSessions(1, false), refreshActiveSession(activeSessionId)]); 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 grouped = useMemo(() => groupSessions(sessions), [sessions]);
const requestLooksLikeWrite = useMemo(() => looksLikeRequirementWrite(question), [question]); const requestLooksLikeWrite = useMemo(() => looksLikeRequirementWrite(question), [question]);
const displayMessages = useMemo(() => { const displayMessages = useMemo(() => {
@ -831,11 +910,9 @@ export function FloatingQAChat() {
const actionBadge = getActionBadge(msg.action_taken); const actionBadge = getActionBadge(msg.action_taken);
const stageBadge = getStageBadge(msg); const stageBadge = getStageBadge(msg);
const consultantDispatch = msg.consultant_dispatch ?? null; const consultantDispatch = msg.consultant_dispatch ?? null;
const consultantModeBadge = getConsultantModeBadge(consultantDispatch); const consultantModePreference = normalizeConsultantModePreference(consultantDispatch?.mode);
const consultantRiskBadge = getConsultantRiskBadge(consultantDispatch); const consultantRiskBadge = getConsultantRiskBadge(consultantDispatch);
const consultantMissingFields = consultantDispatch?.missing_fields?.length const consultantMissingFields = consultantDispatch?.missing_fields?.filter(Boolean) ?? [];
? consultantDispatch.missing_fields.join(" / ")
: "";
return ( return (
<div key={msg.id} className="space-y-2"> <div key={msg.id} className="space-y-2">
<div className="flex justify-end"> <div className="flex justify-end">
@ -846,7 +923,7 @@ export function FloatingQAChat() {
<div className="flex justify-start"> <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="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"> <div className="mt-2 flex flex-wrap items-center gap-2 text-[11px] text-slate-600">
<span> <span>
来源:{msg.sources?.length ? msg.sources.map((s) => s.label).join(" / ") : "无"} 来源:{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} {actionBadge ? <Badge variant={actionBadge.variant}>{actionBadge.label}</Badge> : null}
{msg.signal_id ? <Badge variant="outline">signal #{msg.signal_id}</Badge> : null} {msg.signal_id ? <Badge variant="outline">signal #{msg.signal_id}</Badge> : null}
{stageBadge ? <Badge variant="outline">{stageBadge}</Badge> : null} {stageBadge ? <Badge variant="outline">{stageBadge}</Badge> : null}
<Badge variant="secondary">{msg.thinking_mode === "deep" ? "深入思考" : "标准回答"}</Badge> </div>
{consultantModeBadge ? ( <div className="mt-3 rounded-2xl border border-slate-200/80 bg-slate-50/90 p-2">
<Badge variant={consultantModeBadge.variant}>{consultantModeBadge.label}</Badge> <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} ) : null}
{consultantRiskBadge ? ( {consultantDispatch?.allow_execute ? (
<Badge variant={consultantRiskBadge.variant}>{consultantRiskBadge.label}</Badge> <Button
size="sm"
variant="ghost"
className={getActionChipClasses("execute")}
disabled={Boolean(actionBusyKey)}
onClick={() => setExecuteDialogMessage(msg)}
>
允许执行
</Button>
) : null} ) : null}
{consultantDispatch ? ( {consultantDispatch?.next_action ? (
<Badge variant={consultantDispatch.allow_execute ? "secondary" : "outline"}> <Button
{consultantDispatch.allow_execute ? "允许执行" : "先别执行"} size="sm"
</Badge> variant="ghost"
className={getActionChipClasses("next")}
onClick={() => handleUseNextAction(consultantDispatch)}
>
先别执行
</Button>
) : null} ) : null}
</div> </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 ? ( {consultantDispatch ? (
<div className="mt-3 rounded-md border border-slate-200 bg-white/80 px-3 py-2 text-xs text-slate-700"> <div className="mt-3 overflow-hidden rounded-xl border border-slate-200 bg-white/90">
{consultantMissingFields ? ( {consultantMissingFields.length ? (
<div>缺失信息:{consultantMissingFields}</div> <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} ) : null}
{consultantDispatch.next_action ? ( {consultantDispatch.next_action ? (
<div className={consultantMissingFields ? "mt-2" : undefined}> <div className="px-3 py-3">
<span className="font-medium">下一步:</span> <div className="text-[11px] font-medium uppercase tracking-[0.08em] text-slate-500">
{consultantDispatch.next_action} 下一步建议
</div>
<QAMarkdownContent
content={consultantDispatch.next_action}
compact
className="mt-2 text-xs leading-6 text-slate-800"
/>
</div> </div>
) : null} ) : null}
</div> </div>
@ -1046,6 +1202,7 @@ export function FloatingQAChat() {
/> />
</div> </div>
<Textarea <Textarea
ref={questionInputRef}
rows={2} rows={2}
placeholder="输入问题..." placeholder="输入问题..."
value={question} value={question}
@ -1091,6 +1248,28 @@ export function FloatingQAChat() {
)} )}
</div> </div>
</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