Refine AI PMO grounding runtime and consultant flow
This commit is contained in:
parent
6bb8354b8d
commit
9ab2e14838
24
CLAUDE.md
24
CLAUDE.md
|
|
@ -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` 回归契约
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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),
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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":
|
||||||
|
|
|
||||||
|
|
@ -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):
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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": []}
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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,
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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: {
|
||||||
|
|
|
||||||
|
|
@ -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}`);
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
Loading…
Reference in New Issue