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/20260327_add_external_qa_followup_state.py`(`qa_sessions` 增加 `external_conversation_key`,`qa_session_states` 增加外部追问待澄清状态字段)
|
||||
- 新增:`backend/alembic/versions/20260327_add_qa_record_followup_anchor_fields.py`(`qa_records` 增加外部会话 scope / reply anchor / 路由快照字段,支撑跨 session 追问锚定与元追问追溯)
|
||||
- 新增:`backend/alembic/versions/20260331_add_qa_record_runtime_fields.py`(`qa_records` 增加 `answer_mode/grounding_status/policy_version/citations/sources_used/evidence_pack/tool_trace/retrieval_metrics`,用于 Grounding Runtime 持久化与回放)
|
||||
- 新增:`backend/alembic/versions/20260312_add_signal_planner_fields.py`(signals 增加 planner 状态字段与索引)
|
||||
- 新增:`backend/app/services/signal/signal_planner/signal_strategic_analysis_service.py`(Signal Planner 写回收口)
|
||||
- 新增:`backend/app/services/integrations/feishu_outbox/service.py`(复用 reply_hash 的 outbox 入队收口)
|
||||
|
|
@ -127,6 +128,7 @@ backend/
|
|||
- 新增:`backend/tests/test_ai_pmo_task_intent_contract.py`(AI PMO `task.create` 一等 typed intent 契约回归:意图识别、路由归一与执行器分派)
|
||||
- 新增:`backend/tests/test_ai_pmo_ingress_mainline.py`(AI PMO 主链入口回归:`/ai-pmo/process-text` 手工问答入口、mixed sidecar 透传、`draft_pending_confirm` 优先分支)
|
||||
- 新增:`backend/tests/test_ai_pmo_qa_http_mainline_e2e.py`(AI PMO/QA HTTP 主链集成回归:`/rag/ask` 只读问答、mixed 两段输出、`/ai-pmo/process-text` 草稿待确认)
|
||||
- 新增:`backend/tests/test_qa_record_runtime_fields_migration.py`(QA Runtime migration 回归:`qa_records` runtime/evidence 字段新增与回滚)
|
||||
- 新增:`backend/tests/test_context_cache_profile_catalog.py`(Context Cache 功能目录契约回归:AI Steward 统一指向 `/rag/ask`)
|
||||
- 新增:`backend/tests/test_ai_pmo_tactical_brain_contract.py`(TacticalBrain 执行动作契约回归:`query.answer`/`work_item.resume`/`project_signal.*` 映射)
|
||||
- 新增:`backend/app/services/adaptive_rag_service/mixin_answer_mainline.py`(AdaptiveRAG 基础问答主链编排:运行时准备 + 只读主路径执行)
|
||||
|
|
@ -363,6 +365,11 @@ frontend/
|
|||
- 新增:`frontend/src/api/modelRouting.ts`(模型路由 API 封装)
|
||||
- 新增:`frontend/src/api/knowledgeGraph.ts`(KG 能力汇总与 low/high 消费验证 API)
|
||||
- 更新:`frontend/src/api/aiSteward.ts`(移除 OpenClaw dispatch 封装,新增 LLM 引擎 CRUD / settings 封装)
|
||||
- 更新:`frontend/src/api/aiSteward.ts`(新增 AI 管家 QA 封装:`/rag/ask`、QA sessions、feedback、会话改名;浮窗不再直接 `fetch`)
|
||||
- 更新:`frontend/src/api/aiPmo.ts`(新增 `processText` 封装,供 AI 管家浮窗把“允许执行”显式交接到 AI PMO Kernel)
|
||||
- 新增:`frontend/src/components/ai-steward/QAMarkdownContent.tsx`(AI 管家问答 markdown 渲染组件:统一列表/引用/代码块/长路径换行)
|
||||
- 更新:`frontend/src/components/ai-steward/FloatingQAChat.tsx`(回答区改为 markdown 渲染;浮窗顾问动作升级为整组可交互 chips:`深入思考`、`给建议`、`先澄清`、`一起想`、`先劝退`、风险解释、`允许执行` 确认交接、`先别执行` 回填 `next_action`)
|
||||
- 更新:`frontend/package.json`(新增 `react-markdown`、`remark-gfm`、`remark-breaks` 依赖,供 AI 管家问答渲染)
|
||||
- 更新:`frontend/src/pages/CharterDetailPage.tsx`、`ContractSyncBoardPage.tsx`、`DashboardPage.tsx`、`KnowledgeGraphContextBoardPage.tsx`、`LoginPage.tsx`、`ModelRoutingConfigPage.tsx`、`SchedulerCenterPage.tsx`、`SkillTasksPage.tsx`、`SkillsPage.tsx`、`TaskBriefCenterPage.tsx`、`TaskBriefRunsPage.tsx`、`TaskBriefTemplatePage.tsx`、`UserProfilePage.tsx`、`UserSkillsPage.tsx`、`WorkloadDetailPage.tsx`、`WorkloadsPage.tsx`(统一降级为轻量入口,转发到 `pages/*/` 子目录)
|
||||
- 新增:`frontend/src/pages/charter-detail/CharterDetailPage.tsx`、`contract-sync-board/ContractSyncBoardPage.tsx`、`dashboard/DashboardPage.tsx`、`knowledge-graph-context-board/KnowledgeGraphContextBoardPage.tsx`、`login/LoginPage.tsx`、`model-routing-config/ModelRoutingConfigPage.tsx`(剩余核心页面目录化收敛)
|
||||
- 新增:`frontend/src/pages/scheduler-center/SchedulerCenterPage.tsx`、`skill-tasks/SkillTasksPage.tsx`、`skills/SkillsPage.tsx`、`task-brief-center/TaskBriefCenterPage.tsx`、`task-brief-runs/TaskBriefRunsPage.tsx`、`task-brief-template/TaskBriefTemplatePage.tsx`(任务/调度/技能页面目录化收敛)
|
||||
|
|
@ -570,8 +577,11 @@ uvicorn app.main:app --reload --host 0.0.0.0 --port 8000
|
|||
- AI PMO 恢复接口(`POST /ai-pmo/work-items/{work_item_id}/resume`):支持 `action=continue|commit|refresh|cancel`,复用同一闭环恢复入口
|
||||
- AI PMO 草稿决策接口(`POST /ai-pmo/work-items/{work_item_id}/draft-decision`):支持 `action=confirm|revise|cancel`,与飞书“确认/修改/取消”行为一致
|
||||
- AI PMO 路由包(`backend/app/api/ai_pmo_api/__init__.py`)只负责聚合与 tags,`/ai-pmo` 前缀只能由子模块共享 router 挂一次,禁止包级与模块级重复叠加前缀导致 `/ai-pmo/ai-pmo/...` 路径
|
||||
- RAG 问答主入口契约(`POST /rag/ask`):HTTP 入口已收敛到 `AIPMOKernelService.process_ingress` 主链;请求侧统一透传 `asked_via/action_mode/message_intent/mixed_signal_part`,由 `ingress_options.rag_request_context` 下传到 query executor 并映射到 `AdaptiveRAGService.answer_question`,返回侧从 ingress `rag_result`/`direct_reply_text` 组装兼容响应
|
||||
- RAG 问答主入口契约(`POST /rag/ask`):HTTP 入口已收敛到 `AIPMOKernelService.process_ingress` 主链;请求侧统一透传 `asked_via/action_mode/message_intent/mixed_signal_part/consultant_mode_preference`,由 `ingress_options.rag_request_context` 下传到 query executor 并映射到 `AdaptiveRAGService.answer_question`,返回侧从 ingress `rag_result`/`direct_reply_text` 组装兼容响应
|
||||
- RAG 空回答保护:`POST /rag/ask` 若 `answer` 与 `reply_text` 同时为空,必须返回失败包络,禁止再用“当前暂无可用回答。”之类兜底文案冒充成功结果
|
||||
- RAG Grounding Runtime 合同补充:`POST /rag/ask` 的成功返回统一透传 `answer_mode/grounding_status/policy_version/citations/sources_used/evidence_pack/tool_trace/retrieval_metrics`;`AdaptiveRAG` 内部允许通过兼容层把异构 `sources` 映射成稳定 citation 结构,但业务语义判断仍只允许放在 `workflow_semantic_planner`
|
||||
- RAG / AI 管家顾问合同:`POST /rag/ask` 返回体会透传 `consultant_dispatch={domain,mode,risk_level,missing_fields,allow_execute,next_action}`;该合同由 `backend/app/services/ai_pmo/consultant_dispatch.py` 统一生成,飞书 mention 回复与 AI 管家问答共享同一份模式判断
|
||||
- RAG 顾问模式偏好约束:`consultant_mode_preference` 只允许取 `clarify/advise/challenge/brainstorm/execute`,且仅影响回答风格与 prompt wrapper;它不能替代 `workflow_semantic_planner` 去决定 `query vs execute`
|
||||
- RAG / AI Steward 路由包(`backend/app/api/rag_api/__init__.py`、`backend/app/api/ai_steward_api/__init__.py`)不再重复挂包级 prefix;路由 canonical 由 `shared.py` 内的共享 router 统一承担,避免运行中出现 `/rag/rag/*`、`/ai-steward/ai-steward/*`
|
||||
- AI PMO 消息台列表接口(`GET /ai-pmo/work-items`):返回 `latest_policy_gate_code` 与规范化 `approval_requirement{required,reason,message}`(来源于最新 `decision_records` 投影)
|
||||
- AI PMO 消息台列表接口补充:返回草稿字段 `draft_content/draft_expires_at/draft_version/confirm_message_id`,用于草稿确认卡片渲染
|
||||
|
|
@ -650,6 +660,7 @@ uvicorn app.main:app --reload --host 0.0.0.0 --port 8000
|
|||
- 外部 QA 若存在显式 `reply_to_message_id`,必须优先按消息级锚定处理:先反查 `feishu_reply_outbox.sent_message_id` 还原被引用的 PMFlow 回复;若消息 ID 无法映射,也要回退到当前入站消息携带的 `closure_turns/closure_dialogue` 恢复上一轮机器人回复,再回退到 session/recent candidates;禁止把“直接引用机器人回复”的消息仍判成无锚点
|
||||
- 外部 QA 的“追问歧义”与“模型/上游失败”必须分开:前者返回 `clarification_code`(如 `ambiguous_recent_reply` / `no_anchor_found`)及候选列表,后者继续保留真实 provider/空响应错误,禁止共用模糊兜底文案
|
||||
- 外部 QA 的 `qa_records` 必须持久化 `conversation_scope_key/source_message_id/source_chat_id/source_sender_open_id/reply_to_message_id/root_message_id/anchor_kind/anchor_ref/routing_snapshot`,用于回答“为什么没接上上一条”“为什么走了候选澄清”“上一条回复走的是 low 还是 high”
|
||||
- `qa_records` 现阶段还必须持久化 Grounding Runtime 字段:`answer_mode/grounding_status/policy_version/citations/sources_used/evidence_pack/tool_trace/retrieval_metrics`;旧 `sources/domains_used/routing_snapshot` 继续保留兼容,禁止只改接口不落库
|
||||
- 元追问(如“哪个模型回复的”“为什么那条报错”“你刚才那句什么意思”)必须优先锚定最近一条 PMFlow 回复;对外只允许返回 `planned_tier/executed_tier/route_source/route_rule_name/routing_decision` 这类 low/high 路由语义,禁止暴露真实 provider/model 名称
|
||||
- Prompt release 目前只接入只读回复链路(AdaptiveRAG / query.read_only);写操作 workflow 仍使用代码 prompt 与显式人工 gate,避免训练试验误触执行副作用
|
||||
- 训练自动化/影子回放当前只允许覆盖只读问答链路;`project.create/signal.create/signal_source.onboard/task.create` 等写操作 workflow 继续排除在自动训练与自动试验之外
|
||||
|
|
@ -664,8 +675,10 @@ uvicorn app.main:app --reload --host 0.0.0.0 --port 8000
|
|||
- 飞书入口已按职责拆分为“预处理/捕获执行/结果回执”三层:`mixin_receive.py` 仅保留编排,`mixin_receive_prepare.py` 负责传输解析与上下文抽取,`mixin_receive_capture_flow.py` 负责 capture 持久化与回执收尾
|
||||
- `ai_orchestration_runs.project_id` 允许为空:用于承载尚未完成项目归属判定的入站异步任务;既有 `signal_source_onboarding` 运行仍按显式 `project_id` 写入
|
||||
- `/ai-pmo/process-text` 问答链路在 AdaptiveRAG 内通过 `asked_via=pmflow_manual` 识别为 AI PMO 入口;mixed 场景通过 `message_intent=mixed` 透传触发专用 Prompt 与两段输出格式化
|
||||
- `/rag/ask` 已改为经 AI PMO kernel ingress 主链执行(与飞书长连接、`/ai-pmo/process-text` 同链路);query 执行阶段通过 `rag_request_context` 保留 `session_id/new_session/thinking_mode/action_mode/sync_requirement/message_intent/mixed_signal_part`
|
||||
- `/rag/ask` 已改为经 AI PMO kernel ingress 主链执行(与飞书长连接、`/ai-pmo/process-text` 同链路);query 执行阶段通过 `rag_request_context` 保留 `session_id/new_session/thinking_mode/action_mode/sync_requirement/message_intent/mixed_signal_part/consultant_mode_preference`
|
||||
- AdaptiveRAG `answer_question` 入口已拆为“双阶段”:`_prepare_answer_runtime`(会话/上下文/动作模式解析)+ `_execute_base_answer_mainline`(基础问答主路径);写确认 `ack` gate 仅在 `sync_requirement=true && action_mode in write/read_write && 非 feishu` 时触发
|
||||
- 高准确通道不再按 `asked_via` 一刀切强制 deep:`count/list/status/existence` 由 `AdaptiveRAG` retrieval planner 优先回落到 `fast_path`,`snapshot-only + weak evidence` 则要求进入 `clarify` 或 `blocked`;该 planner 只负责 retrieval/runtime 决策,禁止接管 `query vs execute / project.create / signal_source.onboard / followup.*`
|
||||
- AI 管家浮窗顾问按钮边界:`深入思考` 与整组 `给建议/先澄清/一起想/先劝退` chips 都属于 `/rag/ask` 内部重答;`允许执行` 必须先确认,再显式交接到 `/ai-pmo/process-text`,禁止把执行动作继续伪装成 RAG 重答;`先别执行` 只允许回填 `next_action` 到输入框,不得偷偷触发写操作
|
||||
- AI PMO ingress 执行路径(`waiting_manual/draft_pending_confirm/auto execute`)已统一写入 `operation_execution_ledger`,`operation_code` 与 `execution_result.action_contract` 一致;账本状态统一映射为 `applied/pending_manual/pending_confirmation/failed`
|
||||
- `PROMPT_DECOMPOSE_TASKS` 技能标签来源已改为运行时动态注入:优先读取 `skill_tags`(按 `sort_order/name`),为空时才回退默认技能集合
|
||||
|
||||
|
|
@ -679,6 +692,13 @@ uvicorn app.main:app --reload --host 0.0.0.0 --port 8000
|
|||
- [x] AdaptiveRAG 职责已完成首轮收敛:`answer_question` 入口已分层为 `prepare runtime` 与 `base mainline` 两段,写确认逻辑通过 `_should_trigger_write_ack_gate` 独立门控;基础问答主路径不再与写确认判断混写在同一函数体。分层契约由 `test_adaptive_rag_mainline_contract.py` 锁定(read 绕过 ack、write 命中 ack、feishu write 维持主链)
|
||||
- [x] 执行层已形成统一执行账本与参数来源约束:ingress 执行路径统一通过 `KernelIngressStorageMixin` 生成 `execution_result.action_contract`(固定 `param_source=decision.execution_plan.actions+workflow_route+execution_result`)并写入 `operation_execution_ledger`(`applied/pending_manual/pending_confirmation/failed`);`mixin_auto.py`、`mixin_prepare_manual.py` 的 `operation_code` 日志来源已对齐 `action_contract`,回归由 `test_ai_pmo_execution_ledger_contract.py` 锁定
|
||||
- [x] QA/RAG 路由契约双轨已收敛:`/ai-steward/rag/ask` 兼容入口已移除,问答统一收敛到 `POST /rag/ask`;参数构造与 tenant 解析收口在 `backend/app/api/rag_ask_contract.py`,前端 `FloatingQAChat` 与 Context Cache API 目录均已对齐,`backend/tests/test_rag_route_contract.py` 与 `backend/tests/test_context_cache_profile_catalog.py` 已锁定回归
|
||||
- [x] AI 管家浮窗交互已与 Grounding Runtime 对齐:回答区已切到 markdown 渲染,顾问标签升级为显式交互按钮;`consultant_mode_preference` 只用于 `/rag/ask` 内部重答,`允许执行` 明确交接到 `/ai-pmo/process-text`,避免 query/execute 边界回退到旧的一锅煮
|
||||
- [ ] Retrieval source registry / metadata 统一仍在技术债:当前 `doc/url`、`feishu`、`pmflow`、`graph/db` 继续保留异构 metadata,`citations[]/sources_used[]` 先由 response-layer 兼容适配器统一;后续仍需补齐原生 `source_id/chunk_id/permission_scope/updated_at`、统一 source priority 配置并为 reranker 预留接口
|
||||
- [ ] 语义治理 baseline 尚未清零:`scripts/semantic_lint_baseline.txt` 仍豁免 `backend/app/services/integrations/feishu_signal_pipeline/shared_core.py`、`backend/app/services/signal/signal_capture_service/shared.py`、`backend/app/api/ai_pmo_api/shared.py` 中的 `*_HINTS/*_RE` 规则常量;`backend/tests/test_semantic_lint.py` 目前只阻止新增散落语义,尚未推动存量归零。后续需把这些兼容残留迁回 `workflow_semantic_planner` 或 prompt catalog,并逐项删除 baseline
|
||||
- [ ] 历史 Feishu 队列 / `feishu_items` 兼容链仍未退场:`FeishuSignalPipelineService` 已不再承担线上主入口,但离线回放、单测基座、`ai_logs/project_knowledge_graph` 历史过滤,以及 Alembic / 启动自检仍依赖 `feishu_items`。后续需先迁走历史读链路与回放基座,再为该链路补明确的 deprecated / 下线计划
|
||||
- [ ] AdaptiveRAG 向量检索仍保留 legacy fallback:`AdaptiveRAGRetrievalVectorMixin._retrieve_vector_context()` 在非 PostgreSQL 或 PG 查询失败时会回退 `_retrieve_vector_context_legacy()`,对 `knowledge_base_chunks` 做 Python 侧扫描与打分;这使 pgvector 主路径与 legacy 主路径长期双轨。后续需决定是正式声明“只支持 PostgreSQL/pgvector”,还是补齐统一 retrieval contract 与测试矩阵
|
||||
- [ ] 历史 Alembic 链幂等性缺陷仍未修复:仓库里已确认 `add_users_table_rbac.py` 在 PostgreSQL 可能重复创建 `userrole` ENUM;当前运行时仍通过直接 `uvicorn` 启动 + `AUTO_CREATE_TABLES=true` 规避全量 upgrade。后续需修补旧 migration,保证 PostgreSQL 上的 Alembic upgrade 可重复执行
|
||||
- [ ] 食谱图片上传仍是示例桩实现:`backend/app/services/recipe/service.py` 仍以模拟 URL 代替对象存储,`POST /recipes/upload-image` 也还停留在示例接口形态;若该模块继续保留,需要补齐真实对象存储接入、租户隔离与统一 `{code,message,data}` 响应包络,否则应明确标注为非核心示例模块
|
||||
- [x] LLM 运行时仍未完全收敛:`ai_service_core._invoke_with_retry` 与 `stream_chat` 已统一走 `llm_service`;旧 `_get_llm` 路径已显式禁用,并补回归测试锁定
|
||||
- [x] QA 主链自动化测试覆盖已补齐:`backend/tests/` 已覆盖问答/RAG 主路径与关键场景;新增 `test_ai_pmo_qa_http_mainline_e2e.py` 通过 HTTP 层锁定 `/rag/ask` 的 `qa_readonly/mixed` 契约以及 `/ai-pmo/process-text` 的 `draft_pending_confirm` 主链返回,配合 `test_ai_pmo_ingress_mainline.py`、`test_ai_pmo_task_intent_contract.py`、`test_ai_pmo_tactical_brain_contract.py` 形成稳定回归基线
|
||||
- [x] 飞书交互闭环已补齐:执行场景回执在 `draft_pending_confirm/pending_manual` 下优先生成按钮卡片并经 outbox 异步发送;`card.action.trigger` 已覆盖 `confirm/revise/cancel/continue` 回调分发,分别复用 `DraftConfirmHandler.handle_api_decision` 与 AI PMO `process_ingress` 主链继续执行;回调 HTTP 现在会同步返回 toast 反馈,确保点击按钮后至少有可见结果;`backend/tests/test_feishu_card_action_contract.py` 已补卡片构造与 `continue` 回归契约
|
||||
|
|
|
|||
|
|
@ -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 fastapi.responses import JSONResponse
|
||||
|
||||
from .shared import *
|
||||
from .helpers import _resolve_tenant_id
|
||||
from .. import rag_ask_contract
|
||||
from ...core.response import ErrorCode, error_response
|
||||
from ...services.ai_pmo.ai_pmo_kernel import AIPMOKernelService
|
||||
|
||||
@router.post("/ask")
|
||||
|
|
@ -27,9 +30,13 @@ async def rag_ask(
|
|||
status_code=400,
|
||||
detail=str(ingress_result.get("reason") or "ai_pmo_kernel_not_handled"),
|
||||
)
|
||||
return success_response(
|
||||
rag_ask_contract.build_rag_response_payload(ingress_result=ingress_result),
|
||||
)
|
||||
payload = rag_ask_contract.build_rag_response_payload(ingress_result=ingress_result)
|
||||
if not str(payload.get("answer") or "").strip() and not str(payload.get("reply_text") or "").strip():
|
||||
return JSONResponse(
|
||||
status_code=500,
|
||||
content=error_response(ErrorCode.INTERNAL_ERROR, "RAG 返回空回答,未生成有效响应。"),
|
||||
)
|
||||
return success_response(payload)
|
||||
|
||||
@router.get("/manifest")
|
||||
async def rag_manifest(
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
from uuid import uuid4
|
||||
|
||||
from ..models import User
|
||||
from ..schemas.rag import RAGAskRequest
|
||||
|
|
@ -28,6 +29,7 @@ def build_answer_question_kwargs(request: RAGAskRequest, current_user: User) ->
|
|||
"sync_requirement": bool(request.sync_requirement),
|
||||
"message_intent": request.message_intent,
|
||||
"mixed_signal_part": request.mixed_signal_part,
|
||||
"consultant_mode_preference": request.consultant_mode_preference,
|
||||
}
|
||||
|
||||
|
||||
|
|
@ -49,11 +51,28 @@ def _resolve_sender_name(current_user: User) -> str:
|
|||
return sender or "PMFlow用户"
|
||||
|
||||
|
||||
def _normalize_dict_list(value: Any) -> list[dict[str, Any]]:
|
||||
if not isinstance(value, list):
|
||||
return []
|
||||
return [item for item in value if isinstance(item, dict)]
|
||||
|
||||
|
||||
def _default_evidence_pack(*, answer: str, tool_trace: list[dict[str, Any]]) -> dict[str, Any]:
|
||||
return {
|
||||
"summary": {
|
||||
"conclusion": str(answer or "").strip(),
|
||||
"evidence": [],
|
||||
"uncertainties": [],
|
||||
},
|
||||
"tool_trace": tool_trace,
|
||||
}
|
||||
|
||||
|
||||
def build_kernel_ingress_kwargs(request: RAGAskRequest, current_user: User) -> dict[str, Any]:
|
||||
rag_request_context = build_answer_question_kwargs(request=request, current_user=current_user)
|
||||
return {
|
||||
"source_system": str(request.asked_via or "pmflow").strip() or "pmflow",
|
||||
"source_message_id": None,
|
||||
"source_message_id": f"rag-ask-{uuid4().hex}",
|
||||
"message_text": request.question,
|
||||
"sender": _resolve_sender_name(current_user),
|
||||
"channel_ref": request.source_chat_id,
|
||||
|
|
@ -80,7 +99,7 @@ def build_rag_response_payload(*, ingress_result: dict[str, Any]) -> dict[str, A
|
|||
payload["consultant_dispatch"] = consultant_dispatch
|
||||
|
||||
answer = str(payload.get("answer") or result.get("direct_reply_text") or "").strip()
|
||||
payload["answer"] = answer or "当前暂无可用回答。"
|
||||
payload["answer"] = answer
|
||||
|
||||
if "reply_required" not in payload:
|
||||
payload["reply_required"] = bool(result.get("direct_reply_required"))
|
||||
|
|
@ -90,6 +109,22 @@ def build_rag_response_payload(*, ingress_result: dict[str, Any]) -> dict[str, A
|
|||
payload["trace_id"] = str(result.get("ai_trace_id") or "").strip() or None
|
||||
if "processing_stage" not in payload:
|
||||
payload["processing_stage"] = "qa"
|
||||
if "tool_trace" not in payload:
|
||||
payload["tool_trace"] = []
|
||||
payload["tool_trace"] = _normalize_dict_list(payload.get("tool_trace"))
|
||||
payload["citations"] = _normalize_dict_list(payload.get("citations"))
|
||||
payload["sources_used"] = _normalize_dict_list(payload.get("sources_used"))
|
||||
payload["answer_mode"] = str(payload.get("answer_mode") or "retrieval_normal").strip() or "retrieval_normal"
|
||||
payload["grounding_status"] = str(payload.get("grounding_status") or "partially_grounded").strip() or "partially_grounded"
|
||||
payload["policy_version"] = (
|
||||
str(payload.get("policy_version") or "retrieval-v2.1|routing:unknown|prompt:unknown").strip()
|
||||
or "retrieval-v2.1|routing:unknown|prompt:unknown"
|
||||
)
|
||||
evidence_pack = payload.get("evidence_pack") if isinstance(payload.get("evidence_pack"), dict) else None
|
||||
payload["evidence_pack"] = evidence_pack or _default_evidence_pack(
|
||||
answer=answer,
|
||||
tool_trace=payload["tool_trace"],
|
||||
)
|
||||
|
||||
payload["work_item_id"] = _safe_int(result.get("work_item_id"))
|
||||
payload["ingress_status"] = str(result.get("status") or "").strip() or None
|
||||
|
|
|
|||
|
|
@ -40,3 +40,11 @@ class QARecord(Base):
|
|||
anchor_kind: Mapped[Optional[str]] = mapped_column(VARCHAR(32), nullable=True)
|
||||
anchor_ref: Mapped[Optional[str]] = mapped_column(VARCHAR(128), nullable=True)
|
||||
routing_snapshot: Mapped[Optional[Any]] = mapped_column(JSONB, nullable=True)
|
||||
answer_mode: Mapped[Optional[str]] = mapped_column(VARCHAR(32), nullable=True)
|
||||
grounding_status: Mapped[Optional[str]] = mapped_column(VARCHAR(32), nullable=True)
|
||||
policy_version: Mapped[Optional[str]] = mapped_column(VARCHAR(160), nullable=True)
|
||||
citations: Mapped[Optional[Any]] = mapped_column(JSONB, nullable=True)
|
||||
sources_used: Mapped[Optional[Any]] = mapped_column(JSONB, nullable=True)
|
||||
evidence_pack: Mapped[Optional[Any]] = mapped_column(JSONB, nullable=True)
|
||||
tool_trace: Mapped[Optional[Any]] = mapped_column(JSONB, nullable=True)
|
||||
retrieval_metrics: Mapped[Optional[Any]] = mapped_column(JSONB, nullable=True)
|
||||
|
|
|
|||
|
|
@ -24,6 +24,13 @@ class RAGAskRequest(BaseModel):
|
|||
_ASKED_VIA_ALIAS: ClassVar[dict[str, str]] = {
|
||||
"ai-pmo-process-text": "/ai-pmo/process-text",
|
||||
}
|
||||
_ALLOWED_CONSULTANT_MODES: ClassVar[set[str]] = {
|
||||
"clarify",
|
||||
"advise",
|
||||
"challenge",
|
||||
"brainstorm",
|
||||
"execute",
|
||||
}
|
||||
question: str = Field(..., min_length=1, max_length=4000)
|
||||
project_id: Optional[int] = Field(default=None)
|
||||
asked_via: str = Field(default="pmflow", min_length=1, max_length=32)
|
||||
|
|
@ -35,6 +42,7 @@ class RAGAskRequest(BaseModel):
|
|||
sync_requirement: bool = Field(default=True)
|
||||
message_intent: Optional[str] = Field(default=None, max_length=32)
|
||||
mixed_signal_part: Optional[str] = Field(default=None, max_length=2000)
|
||||
consultant_mode_preference: Optional[str] = Field(default=None, max_length=16)
|
||||
|
||||
@field_validator("asked_via", mode="before")
|
||||
@classmethod
|
||||
|
|
@ -64,6 +72,19 @@ class RAGAskRequest(BaseModel):
|
|||
text = str(value).strip()
|
||||
return text or None
|
||||
|
||||
@field_validator("consultant_mode_preference", mode="before")
|
||||
@classmethod
|
||||
def _normalize_consultant_mode_preference(cls, value: object) -> Optional[str]:
|
||||
if value is None:
|
||||
return None
|
||||
text = str(value).strip().lower()
|
||||
if not text:
|
||||
return None
|
||||
if text not in cls._ALLOWED_CONSULTANT_MODES:
|
||||
allowed = ", ".join(sorted(cls._ALLOWED_CONSULTANT_MODES))
|
||||
raise ValueError(f"consultant_mode_preference must be one of: {allowed}")
|
||||
return text
|
||||
|
||||
|
||||
class KnowledgeDocCreateRequest(BaseModel):
|
||||
title: str = Field(..., min_length=1, max_length=255)
|
||||
|
|
|
|||
|
|
@ -29,20 +29,7 @@ class AdaptiveRAGAnswerEntryMixin:
|
|||
message_intent: Optional[str],
|
||||
) -> bool:
|
||||
intent = str(message_intent or "").strip().lower()
|
||||
if intent == "mixed":
|
||||
return True
|
||||
channel = str(asked_via or "").strip().lower()
|
||||
return channel in {
|
||||
"feishu",
|
||||
"lark",
|
||||
"feishu_group",
|
||||
"feishu_dm",
|
||||
"pmflow_manual",
|
||||
"/ai-pmo/process-text",
|
||||
"ai-pmo-process-text",
|
||||
"ai_pmo",
|
||||
"ai_pmo_ui",
|
||||
}
|
||||
return intent == "mixed"
|
||||
|
||||
@staticmethod
|
||||
def _should_trigger_write_ack_gate(
|
||||
|
|
@ -80,12 +67,11 @@ class AdaptiveRAGAnswerEntryMixin:
|
|||
sync_requirement: bool = True,
|
||||
message_intent: Optional[str] = None,
|
||||
mixed_signal_part: Optional[str] = None,
|
||||
consultant_mode_preference: Optional[str] = None,
|
||||
) -> dict[str, Any]:
|
||||
started = time.perf_counter()
|
||||
clean_q = str(question or "").strip()
|
||||
active_thinking_mode = self._normalize_thinking_mode(thinking_mode)
|
||||
if self._is_high_accuracy_channel(asked_via):
|
||||
active_thinking_mode = THINKING_MODE_DEEP
|
||||
deep_thinking = active_thinking_mode == THINKING_MODE_DEEP
|
||||
enforce_scene_prompt_only = self._should_enforce_scene_prompt_only(
|
||||
asked_via=asked_via,
|
||||
|
|
@ -186,4 +172,5 @@ class AdaptiveRAGAnswerEntryMixin:
|
|||
message_intent=message_intent,
|
||||
mixed_signal_part=mixed_signal_part,
|
||||
anchor_resolution=anchor_resolution,
|
||||
consultant_mode_preference=consultant_mode_preference,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -5,6 +5,80 @@ from .shared import *
|
|||
|
||||
|
||||
class AdaptiveRAGAnswerMainlineMixin:
|
||||
def _plan_query_runtime(
|
||||
self,
|
||||
*,
|
||||
question: str,
|
||||
effective_question: str,
|
||||
query_type: str,
|
||||
requested_thinking_mode: str,
|
||||
enforce_scene_prompt_only: bool,
|
||||
consultant_mode_preference: Optional[str] = None,
|
||||
) -> QueryPlannerDecision:
|
||||
normalize_thinking_mode = getattr(
|
||||
self,
|
||||
"_normalize_thinking_mode",
|
||||
lambda value: THINKING_MODE_DEEP if str(value or "").strip().lower() == THINKING_MODE_DEEP else THINKING_MODE_NORMAL,
|
||||
)
|
||||
normalized_thinking_mode = normalize_thinking_mode(requested_thinking_mode)
|
||||
normalized_query_type = str(query_type or QUERY_TYPE_UNKNOWN).strip().lower()
|
||||
normalized_consultant_mode = str(consultant_mode_preference or "").strip().lower()
|
||||
fast_path_candidate = normalized_query_type in {QUERY_TYPE_COUNT, QUERY_TYPE_LIST, QUERY_TYPE_STATUS}
|
||||
normalized_question = str(question or "").strip()
|
||||
normalized_effective = str(effective_question or "").strip()
|
||||
combined = f"{normalized_question} {normalized_effective}".strip()
|
||||
is_existence_query = any(token in combined for token in ("有没有", "是否存在", "有无", "存在"))
|
||||
if is_existence_query:
|
||||
fast_path_candidate = True
|
||||
|
||||
is_engine_query = bool(getattr(self, "_is_engine_process_query", lambda _text: False)(combined))
|
||||
is_explicit_deep = bool(getattr(self, "_is_explicit_deep_request", lambda _text: False)(combined))
|
||||
if enforce_scene_prompt_only:
|
||||
retrieval_mode = ANSWER_MODE_RETRIEVAL_DEEP if normalized_thinking_mode == THINKING_MODE_DEEP else ANSWER_MODE_RETRIEVAL_NORMAL
|
||||
return QueryPlannerDecision(
|
||||
answer_mode=retrieval_mode,
|
||||
thinking_mode=THINKING_MODE_DEEP if retrieval_mode == ANSWER_MODE_RETRIEVAL_DEEP else THINKING_MODE_NORMAL,
|
||||
allow_fast_path=False,
|
||||
retrieval_answer_mode=retrieval_mode,
|
||||
budget=RetrievalBudget(
|
||||
max_steps=DEEP_RETRIEVAL_STEPS if retrieval_mode == ANSWER_MODE_RETRIEVAL_DEEP else AGENT_MAX_RETRIEVAL_STEPS,
|
||||
vector_top_k=DEEP_VECTOR_TOP_K if retrieval_mode == ANSWER_MODE_RETRIEVAL_DEEP else DEFAULT_TOP_K,
|
||||
),
|
||||
reason="scene_prompt_only",
|
||||
)
|
||||
|
||||
if fast_path_candidate and not is_engine_query and not is_explicit_deep:
|
||||
if normalized_consultant_mode:
|
||||
return QueryPlannerDecision(
|
||||
answer_mode=ANSWER_MODE_RETRIEVAL_NORMAL,
|
||||
thinking_mode=THINKING_MODE_NORMAL,
|
||||
allow_fast_path=False,
|
||||
retrieval_answer_mode=ANSWER_MODE_RETRIEVAL_NORMAL,
|
||||
budget=RetrievalBudget(max_steps=AGENT_MAX_RETRIEVAL_STEPS, vector_top_k=DEFAULT_TOP_K),
|
||||
reason=f"consultant_mode:{normalized_consultant_mode}",
|
||||
)
|
||||
return QueryPlannerDecision(
|
||||
answer_mode=ANSWER_MODE_FAST_PATH,
|
||||
thinking_mode=THINKING_MODE_NORMAL,
|
||||
allow_fast_path=True,
|
||||
retrieval_answer_mode=ANSWER_MODE_RETRIEVAL_NORMAL,
|
||||
budget=RetrievalBudget(max_steps=AGENT_MAX_RETRIEVAL_STEPS, vector_top_k=DEFAULT_TOP_K),
|
||||
reason="deterministic_query_type",
|
||||
)
|
||||
|
||||
retrieval_mode = ANSWER_MODE_RETRIEVAL_DEEP if (normalized_thinking_mode == THINKING_MODE_DEEP or is_engine_query or is_explicit_deep) else ANSWER_MODE_RETRIEVAL_NORMAL
|
||||
return QueryPlannerDecision(
|
||||
answer_mode=retrieval_mode,
|
||||
thinking_mode=THINKING_MODE_DEEP if retrieval_mode == ANSWER_MODE_RETRIEVAL_DEEP else THINKING_MODE_NORMAL,
|
||||
allow_fast_path=False,
|
||||
retrieval_answer_mode=retrieval_mode,
|
||||
budget=RetrievalBudget(
|
||||
max_steps=DEEP_RETRIEVAL_STEPS if retrieval_mode == ANSWER_MODE_RETRIEVAL_DEEP else AGENT_MAX_RETRIEVAL_STEPS,
|
||||
vector_top_k=DEEP_VECTOR_TOP_K if retrieval_mode == ANSWER_MODE_RETRIEVAL_DEEP else DEFAULT_TOP_K,
|
||||
),
|
||||
reason="requested_thinking_mode" if retrieval_mode == ANSWER_MODE_RETRIEVAL_DEEP else "default_retrieval",
|
||||
)
|
||||
|
||||
async def _prepare_answer_runtime(
|
||||
self,
|
||||
*,
|
||||
|
|
@ -135,9 +209,23 @@ class AdaptiveRAGAnswerMainlineMixin:
|
|||
message_intent: Optional[str],
|
||||
mixed_signal_part: Optional[str],
|
||||
anchor_resolution: Optional[FollowupAnchorResolution] = None,
|
||||
consultant_mode_preference: Optional[str] = None,
|
||||
) -> dict[str, Any]:
|
||||
planner = self._plan_query_runtime(
|
||||
question=clean_q,
|
||||
effective_question=effective_q,
|
||||
query_type=query_type,
|
||||
requested_thinking_mode=active_thinking_mode,
|
||||
enforce_scene_prompt_only=enforce_scene_prompt_only,
|
||||
consultant_mode_preference=consultant_mode_preference,
|
||||
)
|
||||
active_thinking_mode = planner.thinking_mode
|
||||
deep_thinking = planner.retrieval_answer_mode == ANSWER_MODE_RETRIEVAL_DEEP
|
||||
deep_retrieval_steps = int(planner.budget.max_steps or deep_retrieval_steps)
|
||||
deep_vector_top_k = int(planner.budget.vector_top_k or deep_vector_top_k)
|
||||
|
||||
forced_deep_reason = ""
|
||||
if not deep_thinking:
|
||||
if not deep_thinking and not planner.allow_fast_path:
|
||||
forced_deep_reason = self._forced_deep_reason(
|
||||
question=clean_q,
|
||||
effective_question=effective_q,
|
||||
|
|
@ -211,7 +299,7 @@ class AdaptiveRAGAnswerMainlineMixin:
|
|||
user_id=asked_by,
|
||||
)
|
||||
|
||||
if not deep_thinking and not enforce_scene_prompt_only:
|
||||
if planner.allow_fast_path:
|
||||
deterministic_result = await self._run_deterministic_query(
|
||||
question=effective_q,
|
||||
project_id=project_id,
|
||||
|
|
@ -233,10 +321,11 @@ class AdaptiveRAGAnswerMainlineMixin:
|
|||
effective_question=effective_q,
|
||||
rewrite_meta=rewrite_meta,
|
||||
thinking_mode=active_thinking_mode,
|
||||
answer_mode=ANSWER_MODE_FAST_PATH,
|
||||
)
|
||||
|
||||
direct_answer = None
|
||||
if not deep_thinking and not enforce_scene_prompt_only:
|
||||
if planner.allow_fast_path:
|
||||
direct_answer = self._build_snapshot_direct_answer(
|
||||
effective_q,
|
||||
system_snapshot=system_snapshot,
|
||||
|
|
@ -262,7 +351,7 @@ class AdaptiveRAGAnswerMainlineMixin:
|
|||
return direct_result
|
||||
|
||||
is_snapshot_direct_question = self._is_snapshot_direct_question(effective_q)
|
||||
if not deep_thinking and not enforce_scene_prompt_only and not is_snapshot_direct_question:
|
||||
if planner.allow_fast_path and not is_snapshot_direct_question:
|
||||
glm_semantic_result = await self._try_glm_project_semantic_answer(clean_q=effective_q, project_id=project_id)
|
||||
if glm_semantic_result is not None:
|
||||
return await self._persist_fast_answer(
|
||||
|
|
@ -280,6 +369,7 @@ class AdaptiveRAGAnswerMainlineMixin:
|
|||
effective_question=effective_q,
|
||||
rewrite_meta=rewrite_meta,
|
||||
thinking_mode=active_thinking_mode,
|
||||
answer_mode=ANSWER_MODE_FAST_PATH,
|
||||
)
|
||||
|
||||
return await self._build_retrieval_response(
|
||||
|
|
@ -299,7 +389,7 @@ class AdaptiveRAGAnswerMainlineMixin:
|
|||
thinking_mode=thinking_mode,
|
||||
active_thinking_mode=active_thinking_mode,
|
||||
deep_thinking=deep_thinking,
|
||||
forced_deep_reason=forced_deep_reason,
|
||||
forced_deep_reason=forced_deep_reason or str(planner.reason or ""),
|
||||
deep_retrieval_steps=deep_retrieval_steps,
|
||||
deep_vector_top_k=deep_vector_top_k,
|
||||
system_snapshot=system_snapshot,
|
||||
|
|
@ -307,4 +397,6 @@ class AdaptiveRAGAnswerMainlineMixin:
|
|||
started=started,
|
||||
message_intent=message_intent,
|
||||
mixed_signal_part=mixed_signal_part,
|
||||
answer_mode=planner.retrieval_answer_mode,
|
||||
consultant_mode_preference=consultant_mode_preference,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -5,6 +5,194 @@ from .shared import *
|
|||
|
||||
|
||||
class AdaptiveRAGAnswerPayloadMixin:
|
||||
@staticmethod
|
||||
def _safe_source_metadata(raw: Any) -> dict[str, Any]:
|
||||
return raw if isinstance(raw, dict) else {}
|
||||
|
||||
@staticmethod
|
||||
def _source_ref_value(value: Any) -> Any:
|
||||
if value is None:
|
||||
return None
|
||||
if isinstance(value, (str, int, float, bool)):
|
||||
text = str(value).strip()
|
||||
return text or None
|
||||
if isinstance(value, list):
|
||||
rows = [str(item).strip() for item in value if str(item).strip()]
|
||||
return rows or None
|
||||
if isinstance(value, dict):
|
||||
try:
|
||||
return json.loads(json.dumps(value, ensure_ascii=False))
|
||||
except Exception:
|
||||
return dict(value)
|
||||
text = str(value).strip()
|
||||
return text or None
|
||||
|
||||
@staticmethod
|
||||
def _normalize_answer_mode(value: Optional[str]) -> str:
|
||||
raw = str(value or "").strip().lower()
|
||||
if raw in {
|
||||
ANSWER_MODE_FAST_PATH,
|
||||
ANSWER_MODE_RETRIEVAL_NORMAL,
|
||||
ANSWER_MODE_RETRIEVAL_DEEP,
|
||||
ANSWER_MODE_CLARIFY,
|
||||
ANSWER_MODE_BLOCKED,
|
||||
}:
|
||||
return raw
|
||||
return ANSWER_MODE_RETRIEVAL_NORMAL
|
||||
|
||||
def _normalize_grounding_status(
|
||||
self,
|
||||
value: Optional[str],
|
||||
*,
|
||||
sources: Optional[list[dict[str, Any]]] = None,
|
||||
missing_fields: Optional[list[str]] = None,
|
||||
answer_mode: Optional[str] = None,
|
||||
reason_code: Optional[str] = None,
|
||||
) -> str:
|
||||
raw = str(value or "").strip().lower()
|
||||
if raw in {
|
||||
GROUNDING_STATUS_GROUNDED,
|
||||
GROUNDING_STATUS_PARTIALLY_GROUNDED,
|
||||
GROUNDING_STATUS_INSUFFICIENT_EVIDENCE,
|
||||
GROUNDING_STATUS_BLOCKED,
|
||||
}:
|
||||
return raw
|
||||
|
||||
normalized_mode = self._normalize_answer_mode(answer_mode)
|
||||
if normalized_mode == ANSWER_MODE_BLOCKED:
|
||||
return GROUNDING_STATUS_BLOCKED
|
||||
if normalized_mode == ANSWER_MODE_CLARIFY:
|
||||
return GROUNDING_STATUS_INSUFFICIENT_EVIDENCE
|
||||
|
||||
source_rows = sources if isinstance(sources, list) else []
|
||||
snapshot_only = False
|
||||
if hasattr(self, "_is_snapshot_only_sources"):
|
||||
try:
|
||||
snapshot_only = bool(self._is_snapshot_only_sources(source_rows))
|
||||
except Exception:
|
||||
snapshot_only = False
|
||||
if not snapshot_only and source_rows:
|
||||
snapshot_only = all(str(item.get("source") or "").strip().lower() == "system" for item in source_rows if isinstance(item, dict))
|
||||
|
||||
missing = [str(item).strip() for item in (missing_fields or []) if str(item).strip()]
|
||||
normalized_reason = str(reason_code or "").strip().lower()
|
||||
if normalized_reason == "insufficient_evidence":
|
||||
return GROUNDING_STATUS_INSUFFICIENT_EVIDENCE
|
||||
if snapshot_only or missing:
|
||||
return GROUNDING_STATUS_PARTIALLY_GROUNDED
|
||||
if source_rows:
|
||||
return GROUNDING_STATUS_GROUNDED
|
||||
return GROUNDING_STATUS_PARTIALLY_GROUNDED
|
||||
|
||||
def _build_citations_from_sources(self, sources: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||
normalized: list[dict[str, Any]] = []
|
||||
seen: set[tuple[str, str, str, str]] = set()
|
||||
for item in (sources or [])[:8]:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
metadata = self._safe_source_metadata(item.get("metadata"))
|
||||
source_type = self._source_ref_value(
|
||||
metadata.get("source_type")
|
||||
or item.get("source")
|
||||
or metadata.get("table")
|
||||
or metadata.get("mode")
|
||||
)
|
||||
source_label = self._source_ref_value(item.get("label") or metadata.get("source_label"))
|
||||
source_id = self._source_ref_value(
|
||||
metadata.get("source_id")
|
||||
or metadata.get("doc_id")
|
||||
or metadata.get("knowledge_doc_id")
|
||||
or metadata.get("message_id")
|
||||
or metadata.get("url")
|
||||
or metadata.get("table")
|
||||
or metadata.get("matched_project_ids")
|
||||
)
|
||||
chunk_id = self._source_ref_value(metadata.get("chunk_id") or metadata.get("chunk_index"))
|
||||
target_url = self._source_ref_value(
|
||||
metadata.get("target_url")
|
||||
or metadata.get("doc_url")
|
||||
or metadata.get("url")
|
||||
or metadata.get("permalink")
|
||||
)
|
||||
project_id = self._source_ref_value(metadata.get("project_id"))
|
||||
updated_at = self._source_ref_value(
|
||||
metadata.get("updated_at")
|
||||
or metadata.get("doc_updated_at")
|
||||
or metadata.get("source_updated_at")
|
||||
)
|
||||
citation = {
|
||||
"source_type": source_type,
|
||||
"source_label": source_label,
|
||||
"source_id": source_id,
|
||||
"chunk_id": chunk_id,
|
||||
"target_url": target_url,
|
||||
"project_id": project_id,
|
||||
"updated_at": updated_at,
|
||||
"confidence": round(self._to_float(item.get("score"), 0.0), 4),
|
||||
}
|
||||
key = (
|
||||
str(source_type or ""),
|
||||
str(source_id or ""),
|
||||
str(chunk_id or ""),
|
||||
str(target_url or ""),
|
||||
)
|
||||
if key in seen:
|
||||
continue
|
||||
seen.add(key)
|
||||
normalized.append(citation)
|
||||
return normalized
|
||||
|
||||
@staticmethod
|
||||
def _build_sources_used_from_citations(citations: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||
rows: list[dict[str, Any]] = []
|
||||
for item in citations:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
rows.append(
|
||||
{
|
||||
"source_type": item.get("source_type"),
|
||||
"source_label": item.get("source_label"),
|
||||
"source_id": item.get("source_id"),
|
||||
"chunk_id": item.get("chunk_id"),
|
||||
"target_url": item.get("target_url"),
|
||||
"project_id": item.get("project_id"),
|
||||
"updated_at": item.get("updated_at"),
|
||||
}
|
||||
)
|
||||
return rows
|
||||
|
||||
def _build_evidence_pack(
|
||||
self,
|
||||
*,
|
||||
answer: str,
|
||||
citations: list[dict[str, Any]],
|
||||
tool_trace: list[dict[str, Any]],
|
||||
missing_fields: Optional[list[str]] = None,
|
||||
grounding_status: Optional[str] = None,
|
||||
reason_code: Optional[str] = None,
|
||||
) -> dict[str, Any]:
|
||||
evidence_labels: list[str] = []
|
||||
for item in citations[:5]:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
label = str(item.get("source_label") or item.get("source_id") or item.get("source_type") or "").strip()
|
||||
if label:
|
||||
evidence_labels.append(label)
|
||||
uncertainties = [str(item).strip() for item in (missing_fields or []) if str(item).strip()]
|
||||
normalized_grounding = str(grounding_status or "").strip().lower()
|
||||
normalized_reason = str(reason_code or "").strip()
|
||||
if normalized_grounding in {GROUNDING_STATUS_INSUFFICIENT_EVIDENCE, GROUNDING_STATUS_BLOCKED} and normalized_reason:
|
||||
if normalized_reason not in uncertainties:
|
||||
uncertainties.append(normalized_reason)
|
||||
return {
|
||||
"summary": {
|
||||
"conclusion": str(answer or "").strip(),
|
||||
"evidence": evidence_labels,
|
||||
"uncertainties": uncertainties,
|
||||
},
|
||||
"tool_trace": tool_trace if isinstance(tool_trace, list) else [],
|
||||
}
|
||||
|
||||
def _build_response_payload(
|
||||
self,
|
||||
*,
|
||||
|
|
@ -40,6 +228,13 @@ class AdaptiveRAGAnswerPayloadMixin:
|
|||
anchor_kind: Optional[str] = None,
|
||||
meta_followup_kind: Optional[str] = None,
|
||||
reason_code: Optional[str] = None,
|
||||
answer_mode: Optional[str] = None,
|
||||
grounding_status: Optional[str] = None,
|
||||
policy_version: Optional[str] = None,
|
||||
citations: Optional[list[dict[str, Any]]] = None,
|
||||
sources_used: Optional[list[dict[str, Any]]] = None,
|
||||
evidence_pack: Optional[dict[str, Any]] = None,
|
||||
prompt_variant_id: Optional[str] = None,
|
||||
) -> dict[str, Any]:
|
||||
evidence: list[dict[str, Any]] = []
|
||||
for item in sources[:8]:
|
||||
|
|
@ -53,6 +248,37 @@ class AdaptiveRAGAnswerPayloadMixin:
|
|||
"metadata": item.get("metadata") if isinstance(item.get("metadata"), dict) else {},
|
||||
}
|
||||
)
|
||||
normalized_tool_trace = tool_trace if isinstance(tool_trace, list) else []
|
||||
normalized_citations = citations if isinstance(citations, list) else self._build_citations_from_sources(sources)
|
||||
normalized_sources_used = (
|
||||
sources_used
|
||||
if isinstance(sources_used, list)
|
||||
else self._build_sources_used_from_citations(normalized_citations)
|
||||
)
|
||||
normalized_answer_mode = self._normalize_answer_mode(answer_mode)
|
||||
normalized_grounding_status = self._normalize_grounding_status(
|
||||
grounding_status,
|
||||
sources=sources,
|
||||
missing_fields=missing_fields,
|
||||
answer_mode=normalized_answer_mode,
|
||||
reason_code=reason_code,
|
||||
)
|
||||
normalized_policy_version = str(
|
||||
policy_version
|
||||
or self._build_policy_version(prompt_variant_id=prompt_variant_id)
|
||||
).strip() or self._build_policy_version(prompt_variant_id=prompt_variant_id)
|
||||
normalized_evidence_pack = (
|
||||
evidence_pack
|
||||
if isinstance(evidence_pack, dict)
|
||||
else self._build_evidence_pack(
|
||||
answer=answer,
|
||||
citations=normalized_citations,
|
||||
tool_trace=normalized_tool_trace,
|
||||
missing_fields=missing_fields,
|
||||
grounding_status=normalized_grounding_status,
|
||||
reason_code=reason_code,
|
||||
)
|
||||
)
|
||||
return {
|
||||
"answer": str(answer or "").strip(),
|
||||
"evidence": evidence,
|
||||
|
|
@ -71,7 +297,7 @@ class AdaptiveRAGAnswerPayloadMixin:
|
|||
"vector_timeout": bool(vector_timeout),
|
||||
"graph_timeout": bool(graph_timeout),
|
||||
},
|
||||
"tool_trace": tool_trace if isinstance(tool_trace, list) else [],
|
||||
"tool_trace": normalized_tool_trace,
|
||||
"qa_id": int(qa_id) if qa_id is not None else None,
|
||||
"session_id": int(session_id) if session_id is not None else None,
|
||||
"turn_index": int(turn_index or 1),
|
||||
|
|
@ -93,6 +319,12 @@ class AdaptiveRAGAnswerPayloadMixin:
|
|||
"anchor_kind": str(anchor_kind or "").strip() or None,
|
||||
"meta_followup_kind": str(meta_followup_kind or "").strip() or None,
|
||||
"reason_code": str(reason_code or "").strip() or None,
|
||||
"answer_mode": normalized_answer_mode,
|
||||
"grounding_status": normalized_grounding_status,
|
||||
"policy_version": normalized_policy_version,
|
||||
"citations": normalized_citations,
|
||||
"sources_used": normalized_sources_used,
|
||||
"evidence_pack": normalized_evidence_pack,
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
|
|
|
|||
|
|
@ -52,6 +52,24 @@ class AdaptiveRAGAnswerPrecheckMixin:
|
|||
},
|
||||
}
|
||||
]
|
||||
runtime_fields = self._build_qa_record_runtime_fields(
|
||||
answer=answer,
|
||||
sources=direct_sources,
|
||||
missing_fields=[],
|
||||
answer_mode=ANSWER_MODE_FAST_PATH,
|
||||
grounding_status=GROUNDING_STATUS_GROUNDED,
|
||||
prompt_variant_id="meta_followup",
|
||||
reason_code=reason_code,
|
||||
routing_snapshot=routing_snapshot,
|
||||
retrieval_metrics={
|
||||
"db_query_ms": 0,
|
||||
"vector_search_ms": 0,
|
||||
"graph_query_ms": 0,
|
||||
"db_timeout": False,
|
||||
"vector_timeout": False,
|
||||
"graph_timeout": False,
|
||||
},
|
||||
)
|
||||
active_session_id = int(session_id) if session_id is not None else (
|
||||
int(context_session_id) if context_session_id is not None else None
|
||||
)
|
||||
|
|
@ -73,6 +91,7 @@ class AdaptiveRAGAnswerPrecheckMixin:
|
|||
anchor_ref=str(meta_followup_payload.get("anchor_ref") or "").strip() or None,
|
||||
routing_snapshot=routing_snapshot,
|
||||
),
|
||||
**runtime_fields,
|
||||
)
|
||||
self.db.add(qa)
|
||||
await self._update_session_state_after_turn(
|
||||
|
|
@ -114,6 +133,14 @@ class AdaptiveRAGAnswerPrecheckMixin:
|
|||
anchor_kind=anchor_kind,
|
||||
meta_followup_kind=meta_followup_kind,
|
||||
reason_code=reason_code,
|
||||
answer_mode=ANSWER_MODE_FAST_PATH,
|
||||
grounding_status=GROUNDING_STATUS_GROUNDED,
|
||||
policy_version=runtime_fields["policy_version"],
|
||||
citations=runtime_fields["citations"],
|
||||
sources_used=runtime_fields["sources_used"],
|
||||
evidence_pack=runtime_fields["evidence_pack"],
|
||||
tool_trace=runtime_fields["tool_trace"],
|
||||
prompt_variant_id="meta_followup",
|
||||
)
|
||||
|
||||
async def _maybe_return_clarification_response(
|
||||
|
|
@ -164,6 +191,23 @@ class AdaptiveRAGAnswerPrecheckMixin:
|
|||
"metadata": {"mode": "clarification", "reason": clarification_code},
|
||||
}
|
||||
]
|
||||
runtime_fields = self._build_qa_record_runtime_fields(
|
||||
answer=clarification_answer,
|
||||
sources=clarify_sources,
|
||||
missing_fields=missing_fields,
|
||||
answer_mode=ANSWER_MODE_CLARIFY,
|
||||
grounding_status=GROUNDING_STATUS_INSUFFICIENT_EVIDENCE,
|
||||
prompt_variant_id="clarification",
|
||||
reason_code=clarification_code,
|
||||
retrieval_metrics={
|
||||
"db_query_ms": 0,
|
||||
"vector_search_ms": 0,
|
||||
"graph_query_ms": 0,
|
||||
"db_timeout": False,
|
||||
"vector_timeout": False,
|
||||
"graph_timeout": False,
|
||||
},
|
||||
)
|
||||
active_session_id = int(session_id) if session_id is not None else (
|
||||
int(context_session_id) if context_session_id is not None else None
|
||||
)
|
||||
|
|
@ -181,6 +225,7 @@ class AdaptiveRAGAnswerPrecheckMixin:
|
|||
session_id=active_session_id,
|
||||
turn_index=turn_index,
|
||||
**self._build_qa_record_external_fields(),
|
||||
**runtime_fields,
|
||||
)
|
||||
self.db.add(qa)
|
||||
if clarification_code == "ambiguous_recent_reply" and clarification_candidates:
|
||||
|
|
@ -233,6 +278,14 @@ class AdaptiveRAGAnswerPrecheckMixin:
|
|||
clarification_code=clarification_code,
|
||||
clarification_candidates=clarification_candidates,
|
||||
reason_code=clarification_code,
|
||||
answer_mode=ANSWER_MODE_CLARIFY,
|
||||
grounding_status=GROUNDING_STATUS_INSUFFICIENT_EVIDENCE,
|
||||
policy_version=runtime_fields["policy_version"],
|
||||
citations=runtime_fields["citations"],
|
||||
sources_used=runtime_fields["sources_used"],
|
||||
evidence_pack=runtime_fields["evidence_pack"],
|
||||
tool_trace=runtime_fields["tool_trace"],
|
||||
prompt_variant_id="clarification",
|
||||
)
|
||||
|
||||
async def _maybe_return_snapshot_direct_response(
|
||||
|
|
@ -274,6 +327,22 @@ class AdaptiveRAGAnswerPrecheckMixin:
|
|||
"metadata": {"mode": "snapshot_direct", "topic": "current_user"},
|
||||
}
|
||||
)
|
||||
runtime_fields = self._build_qa_record_runtime_fields(
|
||||
answer=direct_answer,
|
||||
sources=direct_sources,
|
||||
missing_fields=[],
|
||||
answer_mode=ANSWER_MODE_FAST_PATH,
|
||||
grounding_status=GROUNDING_STATUS_PARTIALLY_GROUNDED,
|
||||
prompt_variant_id="snapshot_direct",
|
||||
retrieval_metrics={
|
||||
"db_query_ms": 0,
|
||||
"vector_search_ms": 0,
|
||||
"graph_query_ms": 0,
|
||||
"db_timeout": False,
|
||||
"vector_timeout": False,
|
||||
"graph_timeout": False,
|
||||
},
|
||||
)
|
||||
|
||||
qa = QARecord(
|
||||
tenant_id=self.tenant_id,
|
||||
|
|
@ -289,6 +358,7 @@ class AdaptiveRAGAnswerPrecheckMixin:
|
|||
session_id=int(session_id) if session_id is not None else (int(context_session_id) if context_session_id is not None else None),
|
||||
turn_index=turn_index,
|
||||
**self._build_qa_record_external_fields(),
|
||||
**runtime_fields,
|
||||
)
|
||||
self.db.add(qa)
|
||||
await self._update_session_state_after_turn(
|
||||
|
|
@ -326,4 +396,12 @@ class AdaptiveRAGAnswerPrecheckMixin:
|
|||
confidence=0.99,
|
||||
missing_fields=[],
|
||||
thinking_mode=active_thinking_mode,
|
||||
answer_mode=ANSWER_MODE_FAST_PATH,
|
||||
grounding_status=GROUNDING_STATUS_PARTIALLY_GROUNDED,
|
||||
policy_version=runtime_fields["policy_version"],
|
||||
citations=runtime_fields["citations"],
|
||||
sources_used=runtime_fields["sources_used"],
|
||||
evidence_pack=runtime_fields["evidence_pack"],
|
||||
tool_trace=runtime_fields["tool_trace"],
|
||||
prompt_variant_id="snapshot_direct",
|
||||
)
|
||||
|
|
|
|||
|
|
@ -32,6 +32,8 @@ class AdaptiveRAGAnswerRetrievalMixin:
|
|||
started: float,
|
||||
message_intent: Optional[str] = None,
|
||||
mixed_signal_part: Optional[str] = None,
|
||||
answer_mode: str = ANSWER_MODE_RETRIEVAL_NORMAL,
|
||||
consultant_mode_preference: Optional[str] = None,
|
||||
) -> dict[str, Any]:
|
||||
domains = self._route_domains(effective_q)
|
||||
if str(asked_via or "").strip().lower() == "feishu" and source_chat_id and "feishu" not in domains:
|
||||
|
|
@ -65,6 +67,7 @@ class AdaptiveRAGAnswerRetrievalMixin:
|
|||
},
|
||||
)
|
||||
final_domains = [str(x) for x in (search_meta.get("domains_used") or domains)]
|
||||
prompt_variant_id = self._peek_answer_prompt_variant()
|
||||
|
||||
max_score = retrieved[0].score if retrieved else 0.0
|
||||
answer_text = await self._generate_answer(
|
||||
|
|
@ -82,7 +85,9 @@ class AdaptiveRAGAnswerRetrievalMixin:
|
|||
message_intent=message_intent,
|
||||
mixed_signal_part=mixed_signal_part,
|
||||
query_type=query_type,
|
||||
consultant_mode_preference=consultant_mode_preference,
|
||||
)
|
||||
prompt_variant_id = self._peek_answer_prompt_variant()
|
||||
routing_snapshot = self._peek_last_answer_routing_snapshot()
|
||||
used_sources = self._dedupe_sources(retrieved)
|
||||
if not used_sources:
|
||||
|
|
@ -145,6 +150,7 @@ class AdaptiveRAGAnswerRetrievalMixin:
|
|||
message_intent=message_intent,
|
||||
mixed_signal_part=mixed_signal_part,
|
||||
query_type=query_type,
|
||||
consultant_mode_preference=consultant_mode_preference,
|
||||
)
|
||||
deep_sources = self._dedupe_sources(deep_retrieved)
|
||||
if not deep_sources:
|
||||
|
|
@ -203,6 +209,37 @@ class AdaptiveRAGAnswerRetrievalMixin:
|
|||
used_sources = deep_sources
|
||||
deep_thinking = True
|
||||
active_thinking_mode = THINKING_MODE_DEEP
|
||||
answer_mode = ANSWER_MODE_RETRIEVAL_DEEP
|
||||
prompt_variant_id = self._peek_answer_prompt_variant()
|
||||
routing_snapshot = self._peek_last_answer_routing_snapshot()
|
||||
|
||||
evidence_guard_triggered = False
|
||||
clarification_due_to_evidence = False
|
||||
if not evidence_guard_triggered and self._is_snapshot_only_sources(used_sources) and float(max_score or 0.0) < 0.45:
|
||||
clarification_due_to_evidence = True
|
||||
answer_text = (
|
||||
f"当前关于“{clean_q[:80]}”的证据不足,现阶段只命中系统快照。"
|
||||
"请补充更明确的对象、范围或文档入口,我再给你可核验的结论。"
|
||||
)
|
||||
used_sources = [
|
||||
{
|
||||
"source": "system",
|
||||
"label": "Evidence Guard",
|
||||
"score": 0.91,
|
||||
"metadata": {"mode": "clarification", "reason": "insufficient_evidence"},
|
||||
}
|
||||
]
|
||||
max_score = min(float(max_score or 0.0), 0.4)
|
||||
if "system_context" not in final_domains:
|
||||
final_domains.append("system_context")
|
||||
tool_trace.append(
|
||||
{
|
||||
"step": 97,
|
||||
"tool": "clarification_guard",
|
||||
"reason": "snapshot_only_low_relevance",
|
||||
}
|
||||
)
|
||||
answer_mode = ANSWER_MODE_CLARIFY
|
||||
|
||||
evidence_guard_triggered = self._should_block_answer_with_guard(
|
||||
question=clean_q,
|
||||
|
|
@ -230,8 +267,53 @@ class AdaptiveRAGAnswerRetrievalMixin:
|
|||
"reason": "insufficient_call_chain_evidence",
|
||||
}
|
||||
)
|
||||
answer_mode = ANSWER_MODE_BLOCKED
|
||||
|
||||
elapsed = int((time.perf_counter() - started) * 1000)
|
||||
grounding_verdict = GroundingVerdict(
|
||||
status=GROUNDING_STATUS_GROUNDED,
|
||||
missing_fields=[],
|
||||
reason_code=None,
|
||||
)
|
||||
if evidence_guard_triggered:
|
||||
grounding_verdict = GroundingVerdict(
|
||||
status=GROUNDING_STATUS_BLOCKED,
|
||||
missing_fields=["call_chain_evidence", "target_scope"],
|
||||
reason_code="insufficient_evidence",
|
||||
)
|
||||
elif clarification_due_to_evidence:
|
||||
grounding_verdict = GroundingVerdict(
|
||||
status=GROUNDING_STATUS_INSUFFICIENT_EVIDENCE,
|
||||
missing_fields=["high_relevance_evidence"],
|
||||
reason_code="insufficient_evidence",
|
||||
)
|
||||
elif self._is_snapshot_only_sources(used_sources) or float(max_score or 0.0) < 0.45:
|
||||
grounding_verdict = GroundingVerdict(
|
||||
status=GROUNDING_STATUS_PARTIALLY_GROUNDED,
|
||||
missing_fields=["high_relevance_evidence"] if float(max_score or 0.0) < 0.45 else [],
|
||||
reason_code=None,
|
||||
)
|
||||
|
||||
retrieval_metrics = {
|
||||
"db_query_ms": int(db_query_ms or 0),
|
||||
"vector_search_ms": int(vector_search_ms or 0),
|
||||
"graph_query_ms": int(graph_query_ms or 0),
|
||||
"db_timeout": bool(db_timeout),
|
||||
"vector_timeout": bool(vector_timeout),
|
||||
"graph_timeout": bool(graph_timeout),
|
||||
}
|
||||
runtime_fields = self._build_qa_record_runtime_fields(
|
||||
answer=answer_text,
|
||||
sources=used_sources,
|
||||
missing_fields=grounding_verdict.missing_fields,
|
||||
answer_mode=answer_mode,
|
||||
grounding_status=grounding_verdict.status,
|
||||
prompt_variant_id=prompt_variant_id,
|
||||
reason_code=grounding_verdict.reason_code,
|
||||
routing_snapshot=routing_snapshot,
|
||||
tool_trace=tool_trace,
|
||||
retrieval_metrics=retrieval_metrics,
|
||||
)
|
||||
qa = QARecord(
|
||||
tenant_id=self.tenant_id,
|
||||
question=clean_q,
|
||||
|
|
@ -250,16 +332,13 @@ class AdaptiveRAGAnswerRetrievalMixin:
|
|||
anchor_ref=None,
|
||||
routing_snapshot=routing_snapshot,
|
||||
),
|
||||
**runtime_fields,
|
||||
)
|
||||
self.db.add(qa)
|
||||
confidence = round(max(0.2, min(1.0, max_score if max_score > 0 else 0.55)), 4)
|
||||
if evidence_guard_triggered:
|
||||
confidence = min(confidence, 0.35)
|
||||
missing_fields = []
|
||||
if max_score < 0.45:
|
||||
missing_fields = ["high_relevance_evidence"]
|
||||
if evidence_guard_triggered:
|
||||
missing_fields = sorted(set([*missing_fields, "call_chain_evidence", "target_scope"]))
|
||||
missing_fields = list(grounding_verdict.missing_fields)
|
||||
await self._update_session_state_after_turn(
|
||||
session_state=state_row,
|
||||
question=clean_q,
|
||||
|
|
@ -290,7 +369,7 @@ class AdaptiveRAGAnswerRetrievalMixin:
|
|||
db_timeout=db_timeout,
|
||||
vector_timeout=vector_timeout,
|
||||
graph_timeout=graph_timeout,
|
||||
tool_trace=tool_trace,
|
||||
tool_trace=runtime_fields["tool_trace"],
|
||||
qa_id=int(qa.id),
|
||||
session_id=int(session_id) if session_id is not None else None,
|
||||
turn_index=int(turn_index),
|
||||
|
|
@ -299,5 +378,12 @@ class AdaptiveRAGAnswerRetrievalMixin:
|
|||
confidence=confidence,
|
||||
missing_fields=missing_fields,
|
||||
thinking_mode=active_thinking_mode,
|
||||
reason_code="answer_generated",
|
||||
reason_code=grounding_verdict.reason_code or ("answer_generated" if answer_mode not in {ANSWER_MODE_CLARIFY, ANSWER_MODE_BLOCKED} else "insufficient_evidence"),
|
||||
answer_mode=answer_mode,
|
||||
grounding_status=grounding_verdict.status,
|
||||
policy_version=runtime_fields["policy_version"],
|
||||
citations=runtime_fields["citations"],
|
||||
sources_used=runtime_fields["sources_used"],
|
||||
evidence_pack=runtime_fields["evidence_pack"],
|
||||
prompt_variant_id=prompt_variant_id,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ class AdaptiveRAGCoreIntentMixin:
|
|||
self.llm = LLMService()
|
||||
self.context_cache = context_cache_service
|
||||
self._active_external_qa_context: dict[str, Any] = {}
|
||||
self._last_answer_prompt_variant_id = "unknown"
|
||||
|
||||
@staticmethod
|
||||
def _normalize_thinking_mode(value: Optional[str]) -> str:
|
||||
|
|
@ -94,12 +95,14 @@ class AdaptiveRAGCoreIntentMixin:
|
|||
route_source = str(payload.get("route_source") or "").strip().lower()
|
||||
route_rule_name = str(payload.get("route_rule_name") or "").strip()
|
||||
routing_decision = str(payload.get("routing_decision") or "").strip()
|
||||
routing_config_version = str(payload.get("routing_config_version") or "").strip()
|
||||
normalized = {
|
||||
"planned_tier": planned_tier if planned_tier in {"low", "high"} else None,
|
||||
"executed_tier": executed_tier if executed_tier in {"low", "high"} else None,
|
||||
"route_source": route_source or None,
|
||||
"route_rule_name": route_rule_name or None,
|
||||
"routing_decision": routing_decision or None,
|
||||
"routing_config_version": routing_config_version or None,
|
||||
}
|
||||
return {key: value for key, value in normalized.items() if value is not None}
|
||||
|
||||
|
|
@ -135,6 +138,97 @@ class AdaptiveRAGCoreIntentMixin:
|
|||
"routing_snapshot": normalized_snapshot,
|
||||
}
|
||||
|
||||
def _remember_answer_prompt_variant(self, prompt_variant_id: Optional[str]) -> None:
|
||||
normalized = str(prompt_variant_id or "").strip() or "unknown"
|
||||
self._last_answer_prompt_variant_id = normalized[:64]
|
||||
|
||||
def _peek_answer_prompt_variant(self) -> str:
|
||||
return str(getattr(self, "_last_answer_prompt_variant_id", None) or "").strip() or "unknown"
|
||||
|
||||
def _build_policy_version(
|
||||
self,
|
||||
*,
|
||||
routing_snapshot: Optional[dict[str, Any]] = None,
|
||||
prompt_variant_id: Optional[str] = None,
|
||||
retrieval_policy_version: Optional[str] = None,
|
||||
) -> str:
|
||||
snapshot = self._normalize_routing_snapshot(routing_snapshot or self._peek_last_answer_routing_snapshot())
|
||||
routing_version = str(snapshot.get("routing_config_version") or "").strip() or "unknown"
|
||||
prompt_variant = str(prompt_variant_id or self._peek_answer_prompt_variant() or "").strip() or "unknown"
|
||||
retrieval_version = str(retrieval_policy_version or RETRIEVAL_POLICY_VERSION).strip() or RETRIEVAL_POLICY_VERSION
|
||||
return f"{retrieval_version}|routing:{routing_version}|prompt:{prompt_variant}"
|
||||
|
||||
def _build_qa_record_runtime_fields(
|
||||
self,
|
||||
*,
|
||||
answer: str,
|
||||
sources: list[dict[str, Any]],
|
||||
missing_fields: Optional[list[str]] = None,
|
||||
answer_mode: Optional[str] = None,
|
||||
grounding_status: Optional[str] = None,
|
||||
policy_version: Optional[str] = None,
|
||||
prompt_variant_id: Optional[str] = None,
|
||||
citations: Optional[list[dict[str, Any]]] = None,
|
||||
sources_used: Optional[list[dict[str, Any]]] = None,
|
||||
evidence_pack: Optional[dict[str, Any]] = None,
|
||||
tool_trace: Optional[list[dict[str, Any]]] = None,
|
||||
retrieval_metrics: Optional[dict[str, Any]] = None,
|
||||
reason_code: Optional[str] = None,
|
||||
routing_snapshot: Optional[dict[str, Any]] = None,
|
||||
) -> dict[str, Any]:
|
||||
normalized_citations = (
|
||||
citations
|
||||
if isinstance(citations, list)
|
||||
else self._build_citations_from_sources(sources)
|
||||
)
|
||||
normalized_sources_used = (
|
||||
sources_used
|
||||
if isinstance(sources_used, list)
|
||||
else self._build_sources_used_from_citations(normalized_citations)
|
||||
)
|
||||
normalized_tool_trace = tool_trace if isinstance(tool_trace, list) else []
|
||||
normalized_metrics = retrieval_metrics if isinstance(retrieval_metrics, dict) else {}
|
||||
normalized_answer_mode = self._normalize_answer_mode(answer_mode)
|
||||
normalized_grounding_status = self._normalize_grounding_status(
|
||||
grounding_status,
|
||||
sources=sources,
|
||||
missing_fields=missing_fields,
|
||||
answer_mode=normalized_answer_mode,
|
||||
reason_code=reason_code,
|
||||
)
|
||||
normalized_policy_version = str(
|
||||
policy_version
|
||||
or self._build_policy_version(
|
||||
routing_snapshot=routing_snapshot,
|
||||
prompt_variant_id=prompt_variant_id,
|
||||
)
|
||||
).strip() or self._build_policy_version(
|
||||
routing_snapshot=routing_snapshot,
|
||||
prompt_variant_id=prompt_variant_id,
|
||||
)
|
||||
normalized_evidence_pack = (
|
||||
evidence_pack
|
||||
if isinstance(evidence_pack, dict)
|
||||
else self._build_evidence_pack(
|
||||
answer=answer,
|
||||
citations=normalized_citations,
|
||||
tool_trace=normalized_tool_trace,
|
||||
missing_fields=missing_fields,
|
||||
grounding_status=normalized_grounding_status,
|
||||
reason_code=reason_code,
|
||||
)
|
||||
)
|
||||
return {
|
||||
"answer_mode": normalized_answer_mode,
|
||||
"grounding_status": normalized_grounding_status,
|
||||
"policy_version": normalized_policy_version,
|
||||
"citations": normalized_citations,
|
||||
"sources_used": normalized_sources_used,
|
||||
"evidence_pack": normalized_evidence_pack,
|
||||
"tool_trace": normalized_tool_trace,
|
||||
"retrieval_metrics": normalized_metrics,
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _external_session_id_from_key(conversation_key: str) -> Optional[int]:
|
||||
key = str(conversation_key or "").strip()
|
||||
|
|
|
|||
|
|
@ -7,6 +7,50 @@ from .shared import *
|
|||
|
||||
|
||||
class AdaptiveRAGDeepGuardAnswerMixin:
|
||||
@staticmethod
|
||||
def _normalize_consultant_mode_preference(value: Optional[str]) -> Optional[str]:
|
||||
text = str(value or "").strip().lower()
|
||||
if text in {"clarify", "advise", "challenge", "brainstorm", "execute"}:
|
||||
return text
|
||||
return None
|
||||
|
||||
@classmethod
|
||||
def _apply_consultant_mode_preference(
|
||||
cls,
|
||||
*,
|
||||
scene_key: str,
|
||||
prompt: str,
|
||||
consultant_mode_preference: Optional[str],
|
||||
) -> tuple[str, str]:
|
||||
mode = cls._normalize_consultant_mode_preference(consultant_mode_preference)
|
||||
if not mode:
|
||||
return scene_key, prompt
|
||||
|
||||
label_map = {
|
||||
"clarify": "先澄清",
|
||||
"advise": "给建议",
|
||||
"challenge": "先劝退",
|
||||
"brainstorm": "一起想",
|
||||
"execute": "可执行",
|
||||
}
|
||||
instruction_map = {
|
||||
"clarify": "请优先指出证据不足或边界不清的地方,并给出最多 3 个必须先补充的澄清问题。",
|
||||
"advise": "请优先给出稳妥建议,明确推荐方案、原因、取舍和下一步。",
|
||||
"challenge": "请优先挑战隐含假设、范围膨胀和高风险点,不要把推断包装成已验证事实。",
|
||||
"brainstorm": "请优先给出 2-3 个可选方向,分别说明适用场景、代价和收敛建议。",
|
||||
"execute": "请只说明可执行前提、风险和下一步,不得把未发生的写操作表述为已完成。",
|
||||
}
|
||||
label = label_map.get(mode, mode)
|
||||
supplement = instruction_map.get(mode, "")
|
||||
wrapped_prompt = str(prompt or "").rstrip()
|
||||
if supplement:
|
||||
wrapped_prompt = (
|
||||
f"{wrapped_prompt}\n\n"
|
||||
f"补充要求(顾问模式:{label}):\n"
|
||||
f"{supplement}"
|
||||
)
|
||||
return f"{scene_key}_consultant_{mode}", wrapped_prompt
|
||||
|
||||
@staticmethod
|
||||
def _compact_member_workload_user_snapshot(user_snapshot: Optional[dict[str, Any]]) -> dict[str, Any]:
|
||||
if not isinstance(user_snapshot, dict):
|
||||
|
|
@ -171,6 +215,7 @@ class AdaptiveRAGDeepGuardAnswerMixin:
|
|||
user_snapshot_text: str,
|
||||
relevance_max: float,
|
||||
query_type: Optional[str] = None,
|
||||
consultant_mode_preference: Optional[str] = None,
|
||||
) -> tuple[str, str]:
|
||||
normalized_question = re.sub(r"\s+", "", str(question or "").lower())
|
||||
member_keywords = ("谁", "成员", "负责人", "负载")
|
||||
|
|
@ -180,7 +225,7 @@ class AdaptiveRAGDeepGuardAnswerMixin:
|
|||
filtered_context_blocks = self._filter_member_workload_context_blocks(context_blocks_text)
|
||||
compact_user_snapshot = self._compact_member_workload_user_snapshot(user_snapshot)
|
||||
compact_user_snapshot_text = json.dumps(compact_user_snapshot or {}, ensure_ascii=False)[:6000]
|
||||
return (
|
||||
scene_key, prompt = (
|
||||
"member_workload",
|
||||
await self._render_scene_prompt(
|
||||
prompt_key="PROMPT_MEMBER_WORKLOAD_QA",
|
||||
|
|
@ -191,11 +236,16 @@ class AdaptiveRAGDeepGuardAnswerMixin:
|
|||
context_blocks=filtered_context_blocks,
|
||||
),
|
||||
)
|
||||
return self._apply_consultant_mode_preference(
|
||||
scene_key=scene_key,
|
||||
prompt=prompt,
|
||||
consultant_mode_preference=consultant_mode_preference,
|
||||
)
|
||||
|
||||
if str(message_intent or "").strip().lower() == "mixed":
|
||||
safe_signal_part = str(mixed_signal_part or "").strip() or "需求信息待补充"
|
||||
merged_message = f"{question}\n{safe_signal_part}".strip()
|
||||
return (
|
||||
scene_key, prompt = (
|
||||
"mixed",
|
||||
await self._render_scene_prompt(
|
||||
prompt_key="PROMPT_FEISHU_MIXED_QA",
|
||||
|
|
@ -208,9 +258,14 @@ class AdaptiveRAGDeepGuardAnswerMixin:
|
|||
context_blocks=context_blocks_text or "无",
|
||||
),
|
||||
)
|
||||
return self._apply_consultant_mode_preference(
|
||||
scene_key=scene_key,
|
||||
prompt=prompt,
|
||||
consultant_mode_preference=consultant_mode_preference,
|
||||
)
|
||||
|
||||
if self._is_project_status_question(question, query_type):
|
||||
return (
|
||||
scene_key, prompt = (
|
||||
"project_status",
|
||||
await self._render_scene_prompt(
|
||||
prompt_key="PROMPT_PROJECT_STATUS_QA",
|
||||
|
|
@ -222,6 +277,11 @@ class AdaptiveRAGDeepGuardAnswerMixin:
|
|||
context_blocks=context_blocks_text or "无",
|
||||
),
|
||||
)
|
||||
return self._apply_consultant_mode_preference(
|
||||
scene_key=scene_key,
|
||||
prompt=prompt,
|
||||
consultant_mode_preference=consultant_mode_preference,
|
||||
)
|
||||
|
||||
if self._contains_any_keyword(normalized_question, trend_keywords):
|
||||
explicit_project_context_markers = (
|
||||
|
|
@ -250,7 +310,7 @@ class AdaptiveRAGDeepGuardAnswerMixin:
|
|||
},
|
||||
ensure_ascii=False,
|
||||
)[:16000]
|
||||
return (
|
||||
scene_key, prompt = (
|
||||
"external_trend",
|
||||
await self._render_scene_prompt(
|
||||
prompt_key="PROMPT_EXTERNAL_TREND_QA",
|
||||
|
|
@ -260,7 +320,12 @@ class AdaptiveRAGDeepGuardAnswerMixin:
|
|||
external_results=context_blocks_text or "无",
|
||||
),
|
||||
)
|
||||
return (
|
||||
return self._apply_consultant_mode_preference(
|
||||
scene_key=scene_key,
|
||||
prompt=prompt,
|
||||
consultant_mode_preference=consultant_mode_preference,
|
||||
)
|
||||
scene_key, prompt = (
|
||||
"industry_trend",
|
||||
await self._render_scene_prompt(
|
||||
prompt_key="PROMPT_INDUSTRY_TREND_QA",
|
||||
|
|
@ -268,9 +333,14 @@ class AdaptiveRAGDeepGuardAnswerMixin:
|
|||
question=question,
|
||||
),
|
||||
)
|
||||
return self._apply_consultant_mode_preference(
|
||||
scene_key=scene_key,
|
||||
prompt=prompt,
|
||||
consultant_mode_preference=consultant_mode_preference,
|
||||
)
|
||||
|
||||
if self._is_decision_choice_question(question):
|
||||
return (
|
||||
scene_key, prompt = (
|
||||
"decision_choice",
|
||||
await self._render_scene_prompt(
|
||||
prompt_key="PROMPT_DECISION_QA",
|
||||
|
|
@ -278,6 +348,11 @@ class AdaptiveRAGDeepGuardAnswerMixin:
|
|||
question=question,
|
||||
),
|
||||
)
|
||||
return self._apply_consultant_mode_preference(
|
||||
scene_key=scene_key,
|
||||
prompt=prompt,
|
||||
consultant_mode_preference=consultant_mode_preference,
|
||||
)
|
||||
|
||||
if self._is_ai_pmo_process_entry(asked_via):
|
||||
world_model_text = json.dumps(
|
||||
|
|
@ -287,7 +362,7 @@ class AdaptiveRAGDeepGuardAnswerMixin:
|
|||
},
|
||||
ensure_ascii=False,
|
||||
)[:18000]
|
||||
return (
|
||||
scene_key, prompt = (
|
||||
"ai_pmo",
|
||||
await self._render_scene_prompt(
|
||||
prompt_key="PROMPT_AI_PMO_QA",
|
||||
|
|
@ -298,10 +373,15 @@ class AdaptiveRAGDeepGuardAnswerMixin:
|
|||
context_blocks=context_blocks_text or "无",
|
||||
),
|
||||
)
|
||||
return self._apply_consultant_mode_preference(
|
||||
scene_key=scene_key,
|
||||
prompt=prompt,
|
||||
consultant_mode_preference=consultant_mode_preference,
|
||||
)
|
||||
|
||||
normalized_asked_via = str(asked_via or "").strip().lower()
|
||||
if normalized_asked_via in {"feishu", "lark", "feishu_group", "feishu_dm"}:
|
||||
return (
|
||||
scene_key, prompt = (
|
||||
"feishu_direct",
|
||||
await self._render_scene_prompt(
|
||||
prompt_key="PROMPT_FEISHU_DIRECT_QA",
|
||||
|
|
@ -314,8 +394,13 @@ class AdaptiveRAGDeepGuardAnswerMixin:
|
|||
context_blocks=context_blocks_text or "无",
|
||||
),
|
||||
)
|
||||
return self._apply_consultant_mode_preference(
|
||||
scene_key=scene_key,
|
||||
prompt=prompt,
|
||||
consultant_mode_preference=consultant_mode_preference,
|
||||
)
|
||||
|
||||
return (
|
||||
scene_key, prompt = (
|
||||
"default",
|
||||
await self._render_scene_prompt(
|
||||
prompt_key="PROMPT_RAG_ANSWER",
|
||||
|
|
@ -329,6 +414,11 @@ class AdaptiveRAGDeepGuardAnswerMixin:
|
|||
relevance_max=f"{max(0.0, float(relevance_max or 0.0)):.3f}",
|
||||
),
|
||||
)
|
||||
return self._apply_consultant_mode_preference(
|
||||
scene_key=scene_key,
|
||||
prompt=prompt,
|
||||
consultant_mode_preference=consultant_mode_preference,
|
||||
)
|
||||
|
||||
def _build_snapshot_direct_answer(
|
||||
self,
|
||||
|
|
@ -534,6 +624,7 @@ class AdaptiveRAGDeepGuardAnswerMixin:
|
|||
message_intent: Optional[str] = None,
|
||||
mixed_signal_part: Optional[str] = None,
|
||||
query_type: Optional[str] = None,
|
||||
consultant_mode_preference: Optional[str] = None,
|
||||
) -> str:
|
||||
active_thinking_mode = self._normalize_thinking_mode(thinking_mode)
|
||||
deep_thinking = active_thinking_mode == THINKING_MODE_DEEP
|
||||
|
|
@ -582,7 +673,11 @@ class AdaptiveRAGDeepGuardAnswerMixin:
|
|||
user_snapshot_text=user_snapshot_text,
|
||||
relevance_max=relevance_max,
|
||||
query_type=query_type,
|
||||
consultant_mode_preference=consultant_mode_preference,
|
||||
)
|
||||
remember_prompt_variant = getattr(self, "_remember_answer_prompt_variant", None)
|
||||
if callable(remember_prompt_variant):
|
||||
remember_prompt_variant(scene_key)
|
||||
if deep_thinking:
|
||||
prompt = (
|
||||
f"{prompt}\n\n"
|
||||
|
|
@ -613,7 +708,7 @@ class AdaptiveRAGDeepGuardAnswerMixin:
|
|||
text = str(answer or "").strip()
|
||||
if not text:
|
||||
raise ValueError("adaptive_rag_answer_empty_response")
|
||||
if scene_key == "mixed":
|
||||
if scene_key == "mixed" or str(scene_key).startswith("mixed_consultant_"):
|
||||
return self._format_mixed_two_block_output(
|
||||
answer_text=text,
|
||||
question_part=question,
|
||||
|
|
|
|||
|
|
@ -166,6 +166,7 @@ class AdaptiveRAGFastAnswerSemanticMixinP1:
|
|||
],
|
||||
"db_query_ms": db_query_ms,
|
||||
"response_time_ms": response_ms,
|
||||
"prompt_variant_id": "glm_semantic",
|
||||
}
|
||||
except Exception:
|
||||
return None
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@ class AdaptiveRAGFastAnswerSemanticMixinP2:
|
|||
effective_question: Optional[str] = None,
|
||||
rewrite_meta: Optional[dict[str, Any]] = None,
|
||||
thinking_mode: str = THINKING_MODE_NORMAL,
|
||||
answer_mode: str = ANSWER_MODE_FAST_PATH,
|
||||
) -> dict[str, Any]:
|
||||
started = time.perf_counter()
|
||||
answer_text = str(fast_result.get("answer") or "未命中结果")
|
||||
|
|
@ -42,6 +43,24 @@ class AdaptiveRAGFastAnswerSemanticMixinP2:
|
|||
if missing_fields is None:
|
||||
missing_fields = list(fast_result.get("missing_fields") or [])
|
||||
final_query_type = str(fast_result.get("query_type") or query_type or QUERY_TYPE_EXPLAIN)
|
||||
prompt_variant_id = str(fast_result.get("prompt_variant_id") or "deterministic").strip() or "deterministic"
|
||||
runtime_fields = self._build_qa_record_runtime_fields(
|
||||
answer=answer_text,
|
||||
sources=sources,
|
||||
missing_fields=missing_fields,
|
||||
answer_mode=answer_mode,
|
||||
grounding_status=GROUNDING_STATUS_GROUNDED,
|
||||
prompt_variant_id=prompt_variant_id,
|
||||
reason_code=str(fast_result.get("reason_code") or "").strip() or None,
|
||||
retrieval_metrics={
|
||||
"db_query_ms": int(fast_result.get("db_query_ms") or 0),
|
||||
"vector_search_ms": 0,
|
||||
"graph_query_ms": 0,
|
||||
"db_timeout": False,
|
||||
"vector_timeout": False,
|
||||
"graph_timeout": False,
|
||||
},
|
||||
)
|
||||
|
||||
qa = QARecord(
|
||||
tenant_id=self.tenant_id,
|
||||
|
|
@ -57,6 +76,7 @@ class AdaptiveRAGFastAnswerSemanticMixinP2:
|
|||
session_id=active_session_id_int,
|
||||
turn_index=active_turn_index,
|
||||
**self._build_qa_record_external_fields(),
|
||||
**runtime_fields,
|
||||
)
|
||||
self.db.add(qa)
|
||||
if state_row is None and active_session_id_int is not None:
|
||||
|
|
@ -100,4 +120,12 @@ class AdaptiveRAGFastAnswerSemanticMixinP2:
|
|||
confidence=confidence,
|
||||
missing_fields=list(missing_fields or []),
|
||||
thinking_mode=thinking_mode,
|
||||
answer_mode=answer_mode,
|
||||
grounding_status=GROUNDING_STATUS_GROUNDED,
|
||||
policy_version=runtime_fields["policy_version"],
|
||||
citations=runtime_fields["citations"],
|
||||
sources_used=runtime_fields["sources_used"],
|
||||
evidence_pack=runtime_fields["evidence_pack"],
|
||||
tool_trace=runtime_fields["tool_trace"],
|
||||
prompt_variant_id=prompt_variant_id,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -102,7 +102,26 @@ class AdaptiveRAGRequirementAckFlowMixin:
|
|||
]
|
||||
|
||||
qa_id: Optional[int] = None
|
||||
missing_fields = [] if resolved_project_id is not None else ["project_id"]
|
||||
try:
|
||||
runtime_fields = self._build_qa_record_runtime_fields(
|
||||
answer=answer_text,
|
||||
sources=sources,
|
||||
missing_fields=missing_fields,
|
||||
answer_mode=ANSWER_MODE_FAST_PATH,
|
||||
grounding_status=GROUNDING_STATUS_GROUNDED if action_taken in {"signal_created", "signal_deduped"} else GROUNDING_STATUS_PARTIALLY_GROUNDED,
|
||||
prompt_variant_id="requirement_ack",
|
||||
reason_code=str(action_taken or "").strip() or None,
|
||||
retrieval_metrics={
|
||||
"db_query_ms": 0,
|
||||
"vector_search_ms": 0,
|
||||
"graph_query_ms": 0,
|
||||
"db_timeout": False,
|
||||
"vector_timeout": False,
|
||||
"graph_timeout": False,
|
||||
},
|
||||
tool_trace=[],
|
||||
)
|
||||
qa = QARecord(
|
||||
tenant_id=self.tenant_id,
|
||||
question=question,
|
||||
|
|
@ -117,6 +136,7 @@ class AdaptiveRAGRequirementAckFlowMixin:
|
|||
session_id=session_id,
|
||||
turn_index=int(turn_index or 1),
|
||||
**self._build_qa_record_external_fields(),
|
||||
**runtime_fields,
|
||||
)
|
||||
self.db.add(qa)
|
||||
await self._update_session_state_after_turn(
|
||||
|
|
@ -146,7 +166,6 @@ class AdaptiveRAGRequirementAckFlowMixin:
|
|||
pass
|
||||
|
||||
confidence = 0.94 if action_taken in {"signal_created", "signal_deduped"} else 0.35
|
||||
missing_fields = [] if resolved_project_id is not None else ["project_id"]
|
||||
return self._build_response_payload(
|
||||
answer=answer_text,
|
||||
sources=sources,
|
||||
|
|
@ -171,4 +190,8 @@ class AdaptiveRAGRequirementAckFlowMixin:
|
|||
ack_latency_ms=ack_latency_ms,
|
||||
trace_id=trace_id,
|
||||
processing_stage="ack_only",
|
||||
answer_mode=ANSWER_MODE_FAST_PATH,
|
||||
grounding_status=GROUNDING_STATUS_GROUNDED if action_taken in {"signal_created", "signal_deduped"} else GROUNDING_STATUS_PARTIALLY_GROUNDED,
|
||||
policy_version=self._build_policy_version(prompt_variant_id="requirement_ack"),
|
||||
prompt_variant_id="requirement_ack",
|
||||
)
|
||||
|
|
|
|||
|
|
@ -54,6 +54,16 @@ QUERY_TYPE_LIST = "list"
|
|||
QUERY_TYPE_STATUS = "status"
|
||||
QUERY_TYPE_EXPLAIN = "explain"
|
||||
QUERY_TYPE_UNKNOWN = "unknown"
|
||||
ANSWER_MODE_FAST_PATH = "fast_path"
|
||||
ANSWER_MODE_RETRIEVAL_NORMAL = "retrieval_normal"
|
||||
ANSWER_MODE_RETRIEVAL_DEEP = "retrieval_deep"
|
||||
ANSWER_MODE_CLARIFY = "clarify"
|
||||
ANSWER_MODE_BLOCKED = "blocked"
|
||||
GROUNDING_STATUS_GROUNDED = "grounded"
|
||||
GROUNDING_STATUS_PARTIALLY_GROUNDED = "partially_grounded"
|
||||
GROUNDING_STATUS_INSUFFICIENT_EVIDENCE = "insufficient_evidence"
|
||||
GROUNDING_STATUS_BLOCKED = "blocked"
|
||||
RETRIEVAL_POLICY_VERSION = "retrieval-v2.1"
|
||||
ACTION_MODE_AUTO = "auto"
|
||||
ACTION_MODE_READ = "read"
|
||||
ACTION_MODE_WRITE = "write"
|
||||
|
|
@ -147,3 +157,26 @@ class RetrievedItem:
|
|||
score: float
|
||||
source_label: str
|
||||
metadata: dict[str, Any]
|
||||
|
||||
|
||||
@dataclass
|
||||
class RetrievalBudget:
|
||||
max_steps: int
|
||||
vector_top_k: int
|
||||
|
||||
|
||||
@dataclass
|
||||
class QueryPlannerDecision:
|
||||
answer_mode: str
|
||||
thinking_mode: str
|
||||
allow_fast_path: bool
|
||||
retrieval_answer_mode: str
|
||||
budget: RetrievalBudget
|
||||
reason: Optional[str] = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class GroundingVerdict:
|
||||
status: str
|
||||
missing_fields: list[str]
|
||||
reason_code: Optional[str] = None
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ from __future__ import annotations
|
|||
|
||||
import hashlib
|
||||
import json
|
||||
import re
|
||||
from datetime import timedelta
|
||||
from typing import Any, Optional
|
||||
|
||||
|
|
|
|||
|
|
@ -43,6 +43,7 @@ class ExecuteQueryProjectMixin:
|
|||
sync_requirement = bool(rag_request_context.get("sync_requirement"))
|
||||
message_intent = str(rag_request_context.get("message_intent") or "").strip() or None
|
||||
mixed_signal_part = str(rag_request_context.get("mixed_signal_part") or "").strip() or None
|
||||
consultant_mode_preference = str(rag_request_context.get("consultant_mode_preference") or "").strip().lower() or None
|
||||
else:
|
||||
thread_context = metadata.get("thread_context") if isinstance(metadata.get("thread_context"), dict) else None
|
||||
source_sender_open_id = str(metadata.get("source_sender_open_id") or "").strip() or None
|
||||
|
|
@ -71,6 +72,7 @@ class ExecuteQueryProjectMixin:
|
|||
sync_requirement = False
|
||||
message_intent = None
|
||||
mixed_signal_part = None
|
||||
consultant_mode_preference = None
|
||||
if isinstance(rag_request_context, dict):
|
||||
source_sender_open_id = str(rag_request_context.get("source_sender_open_id") or "").strip() or None
|
||||
thread_context = rag_request_context.get("thread_context") if isinstance(rag_request_context.get("thread_context"), dict) else None
|
||||
|
|
@ -88,6 +90,7 @@ class ExecuteQueryProjectMixin:
|
|||
"sync_requirement": sync_requirement,
|
||||
"message_intent": message_intent,
|
||||
"mixed_signal_part": mixed_signal_part,
|
||||
"consultant_mode_preference": consultant_mode_preference,
|
||||
}
|
||||
if session_id is not None:
|
||||
rag_kwargs["session_id"] = int(session_id)
|
||||
|
|
@ -98,6 +101,17 @@ class ExecuteQueryProjectMixin:
|
|||
try:
|
||||
rag_result = await rag_service.answer_question(**rag_kwargs)
|
||||
except Exception as exc:
|
||||
rollback = getattr(self.db, "rollback", None)
|
||||
if callable(rollback):
|
||||
try:
|
||||
await rollback()
|
||||
except Exception:
|
||||
logger.warning(
|
||||
"ai_pmo_query_intent_rollback_failed",
|
||||
tenant_id=self.tenant_id,
|
||||
source_system=source_system,
|
||||
source_message_id=source_message_id,
|
||||
)
|
||||
return {
|
||||
"status": "failed_safe",
|
||||
"error_message": self._build_query_error_message(exc),
|
||||
|
|
|
|||
|
|
@ -63,6 +63,48 @@ _MISSING_FIELD_LABELS = {
|
|||
"project_scope": "项目范围",
|
||||
}
|
||||
|
||||
_LONG_REPLY_SECTION_HINTS = (
|
||||
"结论:",
|
||||
"依据:",
|
||||
"不确定项:",
|
||||
"事实:",
|
||||
"建议:",
|
||||
"待确认项:",
|
||||
)
|
||||
|
||||
_DOMAIN_NEXT_ACTION_FALLBACKS: dict[str, dict[str, str]] = {
|
||||
DOMAIN_RAG: {
|
||||
MODE_ADVISE: "继续追问具体模块、入口函数或文件路径,我会把调用链收窄到那一段。",
|
||||
MODE_CLARIFY: "请先限定范围:前端入口、/rag/ask、Kernel 或 AdaptiveRAG 其中一段。",
|
||||
MODE_CHALLENGE: "先确认你要的是 RAG 链路、业务执行链路,还是完整系统拓扑,再继续追查。",
|
||||
MODE_BRAINSTORM: "告诉我你最想看的那一段,我给你 2-3 条追查路径。",
|
||||
},
|
||||
DOMAIN_PROJECT: {
|
||||
MODE_ADVISE: "继续补充项目目标、时间边界或负责人,我会把建议收敛成更具体方案。",
|
||||
MODE_CLARIFY: "请先补充归属项目、目标范围或关键约束,我再继续推进。",
|
||||
MODE_CHALLENGE: "先确认项目范围和优先级,避免一次推进过多内容。",
|
||||
MODE_BRAINSTORM: "告诉我目标、约束和时间窗口,我给你 2-3 个项目推进方向。",
|
||||
},
|
||||
DOMAIN_TASK: {
|
||||
MODE_ADVISE: "继续补充任务目标、负责人或截止时间,我会给出更具体建议。",
|
||||
MODE_CLARIFY: "请先补充任务标题、归属项目或截止时间,我再继续推进。",
|
||||
MODE_CHALLENGE: "先确认任务边界和优先级,避免一次塞进过多要求。",
|
||||
MODE_BRAINSTORM: "告诉我任务目标和约束,我给你拆成 2-3 条可执行路径。",
|
||||
},
|
||||
DOMAIN_SIGNAL: {
|
||||
MODE_ADVISE: "继续补充信号背景、归属项目或影响范围,我会给出更具体建议。",
|
||||
MODE_CLARIFY: "请先补充归属项目、标题或信号背景,我再继续推进。",
|
||||
MODE_CHALLENGE: "先确认这个信号是否值得入库,再决定是否继续推进执行。",
|
||||
MODE_BRAINSTORM: "告诉我信号来源、影响对象和目标,我给你列 2-3 种处理路径。",
|
||||
},
|
||||
DOMAIN_GENERIC: {
|
||||
MODE_ADVISE: "继续补充你最关心的对象、范围或约束,我会给出更具体建议。",
|
||||
MODE_CLARIFY: "请先补充关键对象、范围或约束,我再继续推进。",
|
||||
MODE_CHALLENGE: "先确认目标边界和优先级,再决定是否继续推进。",
|
||||
MODE_BRAINSTORM: "告诉我目标和约束,我给你列 2-3 个可选方向。",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _normalize_missing_fields(value: Any) -> list[str]:
|
||||
if not isinstance(value, list):
|
||||
|
|
@ -75,6 +117,27 @@ def _normalize_missing_fields(value: Any) -> list[str]:
|
|||
return fields
|
||||
|
||||
|
||||
def _normalize_action_text(text: str) -> str:
|
||||
return re.sub(r"\s+", " ", str(text or "")).strip()
|
||||
|
||||
|
||||
def _looks_like_long_answer(text: str) -> bool:
|
||||
raw = str(text or "")
|
||||
normalized = _normalize_action_text(raw)
|
||||
if not normalized:
|
||||
return False
|
||||
if "\n" in raw or len(normalized) > 120:
|
||||
return True
|
||||
return any(token in normalized for token in _LONG_REPLY_SECTION_HINTS)
|
||||
|
||||
|
||||
def _domain_next_action(domain: str, mode: str) -> str:
|
||||
normalized_domain = str(domain or "").strip().lower() or DOMAIN_GENERIC
|
||||
normalized_mode = str(mode or "").strip().lower() or MODE_ADVISE
|
||||
domain_map = _DOMAIN_NEXT_ACTION_FALLBACKS.get(normalized_domain) or _DOMAIN_NEXT_ACTION_FALLBACKS[DOMAIN_GENERIC]
|
||||
return str(domain_map.get(normalized_mode) or _DOMAIN_NEXT_ACTION_FALLBACKS[DOMAIN_GENERIC][MODE_ADVISE]).strip()
|
||||
|
||||
|
||||
def _resolve_domain(*, intent_name: str, primary_operation_code: Optional[str], classification: str) -> str:
|
||||
normalized_op = str(primary_operation_code or "").strip().lower()
|
||||
normalized_intent = str(intent_name or "").strip().lower()
|
||||
|
|
@ -162,11 +225,11 @@ def _derive_mode(
|
|||
|
||||
|
||||
def _build_clarify_next_action(*, missing_fields: list[str], direct_reply_text: str) -> str:
|
||||
if direct_reply_text:
|
||||
return direct_reply_text[:240]
|
||||
if direct_reply_text and not _looks_like_long_answer(direct_reply_text):
|
||||
return _normalize_action_text(direct_reply_text)[:120]
|
||||
labels = [_MISSING_FIELD_LABELS.get(item, item) for item in missing_fields]
|
||||
suffix = "、".join([label for label in labels if label]) or "关键信息"
|
||||
return f"请先补充 {suffix},我再继续推进。"
|
||||
return f"请先补充 {suffix},我再继续推进。"[:120]
|
||||
|
||||
|
||||
def _build_next_action(
|
||||
|
|
@ -183,13 +246,13 @@ def _build_next_action(
|
|||
direct_reply_text=direct_reply_text,
|
||||
)
|
||||
if mode == MODE_CHALLENGE:
|
||||
if direct_reply_text:
|
||||
return direct_reply_text[:240]
|
||||
if direct_reply_text and not _looks_like_long_answer(direct_reply_text):
|
||||
return _normalize_action_text(direct_reply_text)[:120]
|
||||
if str(risk_level or "").strip().lower() == "high":
|
||||
return "建议先拆分范围,再确认是否继续推进高风险动作。"
|
||||
return "建议先确认目标边界与优先级,再决定是否继续推进。"
|
||||
return "建议先拆分范围,再确认是否继续推进高风险动作。"[:120]
|
||||
return _domain_next_action(domain=domain, mode=mode)
|
||||
if mode == MODE_BRAINSTORM:
|
||||
return "请补充目标用户、核心场景和约束,我会继续给出 2-3 个可选方向。"
|
||||
return _domain_next_action(domain=domain, mode=mode)
|
||||
if mode == MODE_EXECUTE:
|
||||
if domain == DOMAIN_PROJECT:
|
||||
return "可继续查看项目结果,或补充负责人与排期信息。"
|
||||
|
|
@ -198,9 +261,9 @@ def _build_next_action(
|
|||
if domain == DOMAIN_SIGNAL:
|
||||
return "可继续确认归属项目,或查看后续自动处理结果。"
|
||||
return "可继续查看结果,或补充更多执行细节。"
|
||||
if direct_reply_text:
|
||||
return direct_reply_text[:240]
|
||||
return "你可以继续追问细节,或让我给出更具体的建议。"
|
||||
if direct_reply_text and not _looks_like_long_answer(direct_reply_text):
|
||||
return _normalize_action_text(direct_reply_text)[:120]
|
||||
return _domain_next_action(domain=domain, mode=mode)
|
||||
|
||||
|
||||
def build_consultant_dispatch_contract(
|
||||
|
|
|
|||
|
|
@ -198,6 +198,8 @@ def map_qa_feedback_to_record_payload(*, qa_feedback: Any, qa_record: Any) -> di
|
|||
"question": getattr(qa_record, "question", None),
|
||||
"answer": getattr(qa_record, "answer", None),
|
||||
"sources": _jsonable(getattr(qa_record, "sources", None) or []),
|
||||
"citations": _jsonable(getattr(qa_record, "citations", None) or []),
|
||||
"sources_used": _jsonable(getattr(qa_record, "sources_used", None) or []),
|
||||
}
|
||||
after_value = {
|
||||
"rating": rating,
|
||||
|
|
@ -228,6 +230,14 @@ def map_qa_feedback_to_record_payload(*, qa_feedback: Any, qa_record: Any) -> di
|
|||
"answer": getattr(qa_record, "answer", None),
|
||||
"sources": _jsonable(getattr(qa_record, "sources", None) or []),
|
||||
"domains_used": _jsonable(getattr(qa_record, "domains_used", None) or []),
|
||||
"answer_mode": getattr(qa_record, "answer_mode", None),
|
||||
"grounding_status": getattr(qa_record, "grounding_status", None),
|
||||
"policy_version": getattr(qa_record, "policy_version", None),
|
||||
"citations": _jsonable(getattr(qa_record, "citations", None) or []),
|
||||
"sources_used": _jsonable(getattr(qa_record, "sources_used", None) or []),
|
||||
"evidence_pack": _jsonable(getattr(qa_record, "evidence_pack", None) or {}),
|
||||
"tool_trace": _jsonable(getattr(qa_record, "tool_trace", None) or []),
|
||||
"retrieval_metrics": _jsonable(getattr(qa_record, "retrieval_metrics", None) or {}),
|
||||
},
|
||||
"ai_confidence": None,
|
||||
"reason_candidates": reason_candidates,
|
||||
|
|
|
|||
|
|
@ -95,6 +95,62 @@ _REASON_DEFINITIONS: list[dict[str, Any]] = [
|
|||
"field_names": {"qa_feedback"},
|
||||
"confidence": 0.86,
|
||||
},
|
||||
{
|
||||
"code": "wrong_fast_path",
|
||||
"label": "误走快速路径",
|
||||
"description": "问题本应进入更重的检索路径,却被过早走了 fast path。",
|
||||
"source_types": {"qa_feedback"},
|
||||
"field_names": {"qa_feedback"},
|
||||
"confidence": 0.9,
|
||||
},
|
||||
{
|
||||
"code": "should_have_gone_deep",
|
||||
"label": "应该进入深检索",
|
||||
"description": "当前问题需要更深层的证据检索与合成,但 runtime 未升级。",
|
||||
"source_types": {"qa_feedback"},
|
||||
"field_names": {"qa_feedback"},
|
||||
"confidence": 0.91,
|
||||
},
|
||||
{
|
||||
"code": "wrong_source_priority",
|
||||
"label": "来源优先级错误",
|
||||
"description": "检索时对事实源的优先级排序不合理,导致引用偏差。",
|
||||
"source_types": {"qa_feedback"},
|
||||
"field_names": {"qa_feedback"},
|
||||
"confidence": 0.88,
|
||||
},
|
||||
{
|
||||
"code": "insufficient_evidence",
|
||||
"label": "证据不足",
|
||||
"description": "回答缺少足够内部证据支撑,应澄清或拒答。",
|
||||
"source_types": {"qa_feedback"},
|
||||
"field_names": {"qa_feedback"},
|
||||
"confidence": 0.94,
|
||||
},
|
||||
{
|
||||
"code": "good_retrieval_bad_synthesis",
|
||||
"label": "检索好但合成差",
|
||||
"description": "证据命中基本正确,但最终答案组织、归纳或结论表达有偏差。",
|
||||
"source_types": {"qa_feedback"},
|
||||
"field_names": {"qa_feedback"},
|
||||
"confidence": 0.83,
|
||||
},
|
||||
{
|
||||
"code": "over_deep_latency",
|
||||
"label": "过度深检索延迟",
|
||||
"description": "问题本可快速回答,但进入过深链路导致延迟偏高。",
|
||||
"source_types": {"qa_feedback"},
|
||||
"field_names": {"qa_feedback"},
|
||||
"confidence": 0.84,
|
||||
},
|
||||
{
|
||||
"code": "permission_scope_miss",
|
||||
"label": "权限范围漏检",
|
||||
"description": "检索没有正确命中用户应有权限范围内的事实源。",
|
||||
"source_types": {"qa_feedback"},
|
||||
"field_names": {"qa_feedback"},
|
||||
"confidence": 0.87,
|
||||
},
|
||||
{
|
||||
"code": "strategic_judgement_mismatch",
|
||||
"label": "战略判断偏差",
|
||||
|
|
@ -325,6 +381,22 @@ def build_reason_candidates(
|
|||
0.78,
|
||||
"回答缺少关键信息或上下文不够完整。",
|
||||
)
|
||||
grounding_reason_map = {
|
||||
"wrong_fast_path": ("wrong_fast_path", "误走快速路径", 0.91, "问题本应进入更重的检索路径,却被过早走了 fast path。"),
|
||||
"fast_path": ("wrong_fast_path", "误走快速路径", 0.89, "问题本应进入更重的检索路径,却被过早走了 fast path。"),
|
||||
"should_have_gone_deep": ("should_have_gone_deep", "应该进入深检索", 0.92, "当前问题需要更深层的证据检索与合成,但 runtime 未升级。"),
|
||||
"deep": ("should_have_gone_deep", "应该进入深检索", 0.88, "当前问题需要更深层的证据检索与合成,但 runtime 未升级。"),
|
||||
"wrong_source_priority": ("wrong_source_priority", "来源优先级错误", 0.88, "检索时对事实源的优先级排序不合理。"),
|
||||
"insufficient_evidence": ("insufficient_evidence", "证据不足", 0.94, "回答缺少足够内部证据支撑,应澄清或拒答。"),
|
||||
"good_retrieval_bad_synthesis": ("good_retrieval_bad_synthesis", "检索好但合成差", 0.83, "证据命中基本正确,但最终答案组织或结论表达有偏差。"),
|
||||
"over_deep_latency": ("over_deep_latency", "过度深检索延迟", 0.84, "问题本可快速回答,但进入过深链路导致延迟偏高。"),
|
||||
"permission_scope_miss": ("permission_scope_miss", "权限范围漏检", 0.87, "检索没有正确命中用户应有权限范围内的事实源。"),
|
||||
}
|
||||
for token in error_types:
|
||||
mapping = grounding_reason_map.get(token)
|
||||
if mapping is None:
|
||||
continue
|
||||
add(mapping[0], mapping[1], mapping[2], mapping[3])
|
||||
else:
|
||||
add("manual_review_needed", "需要人工复核", 0.72, "QA 反馈建议保留人工复核以确认样本质量。")
|
||||
elif source_key == "signal_feedback":
|
||||
|
|
|
|||
|
|
@ -177,6 +177,7 @@ class LLMPromptProfileRuntimeMixin:
|
|||
"route_source": meta.get("route_source") or None,
|
||||
"route_rule_name": meta.get("route_rule_name") or None,
|
||||
"routing_decision": meta.get("routing_decision") or None,
|
||||
"routing_config_version": meta.get("routing_config_version") or None,
|
||||
}
|
||||
async with AsyncSessionLocal() as session:
|
||||
for attempt in range(2):
|
||||
|
|
|
|||
|
|
@ -29,6 +29,11 @@ def _use_sqlite_json_types() -> None:
|
|||
if column.name in {
|
||||
"sources",
|
||||
"domains_used",
|
||||
"citations",
|
||||
"sources_used",
|
||||
"evidence_pack",
|
||||
"tool_trace",
|
||||
"retrieval_metrics",
|
||||
"last_entity_ids",
|
||||
"rewrite_history",
|
||||
"pending_clarification",
|
||||
|
|
|
|||
|
|
@ -33,10 +33,12 @@ class _FakeAdaptiveRAGService(
|
|||
"context_session_id": 101,
|
||||
"conversation": [],
|
||||
"state_row": None,
|
||||
"resolved_question": clean_q,
|
||||
"effective_q": clean_q,
|
||||
"rewrite_meta": {"mode": "direct"},
|
||||
"query_type": "explain",
|
||||
"effective_action_mode": action_mode,
|
||||
"anchor_resolution": None,
|
||||
}
|
||||
|
||||
async def _handle_ack_first_requirement(self, **kwargs):
|
||||
|
|
@ -85,25 +87,43 @@ async def test_answer_entry_write_mode_uses_requirement_ack_gate() -> None:
|
|||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_answer_entry_high_accuracy_channel_forces_deep_mode() -> None:
|
||||
async def test_answer_entry_high_accuracy_channel_respects_requested_normal_mode() -> None:
|
||||
service = _FakeAdaptiveRAGService()
|
||||
|
||||
result = await service.answer_question(
|
||||
question="最近有哪些项目?",
|
||||
asked_by=1,
|
||||
asked_via="pmflow",
|
||||
thinking_mode="normal",
|
||||
sync_requirement=True,
|
||||
)
|
||||
|
||||
assert result["processing_stage"] == "qa"
|
||||
assert service.ack_calls == 0
|
||||
assert service.base_calls == 1
|
||||
assert service.base_kwargs["active_thinking_mode"] == "deep"
|
||||
assert service.base_kwargs["deep_thinking"] is True
|
||||
assert service.base_kwargs["active_thinking_mode"] == "normal"
|
||||
assert service.base_kwargs["deep_thinking"] is False
|
||||
assert int(service.base_kwargs["deep_retrieval_steps"]) == DEEP_RETRIEVAL_STEPS
|
||||
assert int(service.base_kwargs["deep_vector_top_k"]) == DEEP_VECTOR_TOP_K
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_answer_entry_forwards_consultant_mode_preference_to_mainline() -> None:
|
||||
service = _FakeAdaptiveRAGService()
|
||||
|
||||
result = await service.answer_question(
|
||||
question="这个方案怎么做更稳妥?",
|
||||
asked_by=1,
|
||||
asked_via="pmflow",
|
||||
thinking_mode="normal",
|
||||
consultant_mode_preference="advise",
|
||||
)
|
||||
|
||||
assert result["processing_stage"] == "qa"
|
||||
assert service.base_calls == 1
|
||||
assert service.base_kwargs["consultant_mode_preference"] == "advise"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_answer_entry_mixed_request_skips_requirement_ack_gate() -> None:
|
||||
service = _FakeAdaptiveRAGService()
|
||||
|
|
@ -120,8 +140,8 @@ async def test_answer_entry_mixed_request_skips_requirement_ack_gate() -> None:
|
|||
assert result["processing_stage"] == "qa"
|
||||
assert service.ack_calls == 0
|
||||
assert service.base_calls == 1
|
||||
assert service.base_kwargs["active_thinking_mode"] == "deep"
|
||||
assert service.base_kwargs["deep_thinking"] is True
|
||||
assert service.base_kwargs["active_thinking_mode"] == "normal"
|
||||
assert service.base_kwargs["deep_thinking"] is False
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
|
@ -139,12 +159,49 @@ async def test_answer_entry_feishu_write_mode_still_goes_base_mainline() -> None
|
|||
assert result["processing_stage"] == "qa"
|
||||
assert service.ack_calls == 0
|
||||
assert service.base_calls == 1
|
||||
assert service.base_kwargs["active_thinking_mode"] == "deep"
|
||||
assert service.base_kwargs["deep_thinking"] is True
|
||||
assert service.base_kwargs["active_thinking_mode"] == "normal"
|
||||
assert service.base_kwargs["deep_thinking"] is False
|
||||
assert int(service.base_kwargs["deep_retrieval_steps"]) == DEEP_RETRIEVAL_STEPS
|
||||
assert int(service.base_kwargs["deep_vector_top_k"]) == DEEP_VECTOR_TOP_K
|
||||
|
||||
|
||||
def test_query_runtime_planner_prefers_fast_path_for_list_question_even_if_requested_deep() -> None:
|
||||
class _FakePlannerService(AdaptiveRAGAnswerMainlineMixin):
|
||||
pass
|
||||
|
||||
service = _FakePlannerService()
|
||||
decision = service._plan_query_runtime(
|
||||
question="现在有哪些项目?",
|
||||
effective_question="现在有哪些项目?",
|
||||
query_type="list",
|
||||
requested_thinking_mode="deep",
|
||||
enforce_scene_prompt_only=False,
|
||||
)
|
||||
|
||||
assert decision.answer_mode == "fast_path"
|
||||
assert decision.thinking_mode == "normal"
|
||||
assert decision.allow_fast_path is True
|
||||
|
||||
|
||||
def test_query_runtime_planner_bypasses_fast_path_when_consultant_mode_preference_present() -> None:
|
||||
class _FakePlannerService(AdaptiveRAGAnswerMainlineMixin):
|
||||
pass
|
||||
|
||||
service = _FakePlannerService()
|
||||
decision = service._plan_query_runtime(
|
||||
question="现在有哪些项目?",
|
||||
effective_question="现在有哪些项目?",
|
||||
query_type="list",
|
||||
requested_thinking_mode="normal",
|
||||
enforce_scene_prompt_only=False,
|
||||
consultant_mode_preference="advise",
|
||||
)
|
||||
|
||||
assert decision.answer_mode == "retrieval_normal"
|
||||
assert decision.thinking_mode == "normal"
|
||||
assert decision.allow_fast_path is False
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_deterministic_query_prefers_project_created_at_answer() -> None:
|
||||
calls: list[str] = []
|
||||
|
|
@ -207,7 +264,7 @@ async def test_deep_guard_answer_raises_on_empty_llm_response() -> None:
|
|||
_ = project_id
|
||||
return {}
|
||||
|
||||
def _select_scene_prompt(self, **kwargs):
|
||||
async def _select_scene_prompt(self, **kwargs):
|
||||
_ = kwargs
|
||||
return "external_trend", "请回答问题"
|
||||
|
||||
|
|
@ -289,3 +346,38 @@ async def test_deep_guard_answer_prefers_member_workload_direct_answer() -> None
|
|||
|
||||
assert "杨宇当前负载265.56%" in result
|
||||
assert "严重超载" in result
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_select_scene_prompt_applies_consultant_mode_preference_wrapper() -> None:
|
||||
class _FakeService(AdaptiveRAGDeepGuardAnswerMixin):
|
||||
def __init__(self) -> None:
|
||||
self.db = object()
|
||||
self.tenant_id = 1
|
||||
|
||||
async def _render_scene_prompt(self, *, prompt_key: str, fallback_template: str, **kwargs):
|
||||
_ = (fallback_template, kwargs)
|
||||
return f"PROMPT::{prompt_key}"
|
||||
|
||||
service = _FakeService()
|
||||
|
||||
scene_key, prompt = await service._select_scene_prompt(
|
||||
question="这个方案怎么做更稳妥?",
|
||||
project_id=7,
|
||||
asked_via="pmflow",
|
||||
message_intent=None,
|
||||
mixed_signal_part=None,
|
||||
conversation_text="无",
|
||||
context_blocks_text="无",
|
||||
system_snapshot={},
|
||||
user_snapshot={},
|
||||
system_snapshot_text="{}",
|
||||
user_snapshot_text="{}",
|
||||
relevance_max=0.61,
|
||||
query_type="explain",
|
||||
consultant_mode_preference="advise",
|
||||
)
|
||||
|
||||
assert scene_key == "default_consultant_advise"
|
||||
assert "PROMPT::PROMPT_RAG_ANSWER" in prompt
|
||||
assert "稳妥建议" in prompt
|
||||
|
|
|
|||
|
|
@ -6,11 +6,13 @@ from fastapi import HTTPException
|
|||
|
||||
from app.api.ai_pmo_api import routes_core
|
||||
from app.api.ai_pmo_api.schemas import PMOProcessTextRequest
|
||||
from app.services.ai_pmo.consultant_dispatch import build_consultant_dispatch_contract
|
||||
from app.services.ai_pmo.ai_pmo_kernel.mixins.intent.ai_pmo_kernel_mixins_intent_rules import (
|
||||
KernelIntentRulesMixin,
|
||||
)
|
||||
from app.services.ai_pmo.ai_pmo_kernel.ai_agent_kernel import gate_execution
|
||||
from app.services.ai_pmo.ai_pmo_kernel.ai_pmo_reply_renderer import ReplyRenderer
|
||||
from app.services.ai_pmo.ai_pmo_kernel import ai_pmo_typed_executor_mixins_parse
|
||||
from app.services.ai_pmo.ai_pmo_kernel_ingress_flow import mixin_entry
|
||||
from app.services.ai_pmo.ai_pmo_kernel_ingress_flow import mixin_prepare_manual
|
||||
from app.services.ai_pmo.ai_pmo_typed_executor_execute import mixin_execute_query_project
|
||||
|
|
@ -499,6 +501,7 @@ async def test_execute_query_intent_honors_rag_request_context(monkeypatch) -> N
|
|||
"sync_requirement": True,
|
||||
"message_intent": "mixed",
|
||||
"mixed_signal_part": "新增任务确认流",
|
||||
"consultant_mode_preference": "challenge",
|
||||
}
|
||||
},
|
||||
)
|
||||
|
|
@ -525,6 +528,7 @@ async def test_execute_query_intent_honors_rag_request_context(monkeypatch) -> N
|
|||
assert answer_kwargs["sync_requirement"] is True
|
||||
assert answer_kwargs["message_intent"] == "mixed"
|
||||
assert answer_kwargs["mixed_signal_part"] == "新增任务确认流"
|
||||
assert answer_kwargs["consultant_mode_preference"] == "challenge"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
|
@ -577,6 +581,91 @@ async def test_execute_query_intent_feishu_fallback_uses_thread_key(monkeypatch)
|
|||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_execute_query_intent_rolls_back_failed_rag_transaction(monkeypatch) -> None:
|
||||
class FakeAdaptiveRAGService:
|
||||
def __init__(self, db, tenant_id: int):
|
||||
_ = (db, tenant_id)
|
||||
|
||||
async def answer_question(self, **kwargs):
|
||||
_ = kwargs
|
||||
raise RuntimeError("rag failed before response")
|
||||
|
||||
class FakeDB:
|
||||
def __init__(self) -> None:
|
||||
self.rollback_calls = 0
|
||||
|
||||
async def rollback(self) -> None:
|
||||
self.rollback_calls += 1
|
||||
|
||||
class FakeExecutor(mixin_execute_query_project.ExecuteQueryProjectMixin):
|
||||
def __init__(self) -> None:
|
||||
self.db = FakeDB()
|
||||
self.tenant_id = 3
|
||||
|
||||
@staticmethod
|
||||
def _build_query_error_message(exc: Exception) -> str:
|
||||
return f"query failed: {exc}"
|
||||
|
||||
monkeypatch.setattr(mixin_execute_query_project, "AdaptiveRAGService", FakeAdaptiveRAGService)
|
||||
executor = FakeExecutor()
|
||||
intent = SimpleNamespace(project_id=12, metadata={})
|
||||
|
||||
result = await executor._execute_query_intent(
|
||||
intent=intent,
|
||||
message_text="现在完整调用链是什么?",
|
||||
operator_user_id=999,
|
||||
source_system="pmflow_manual",
|
||||
source_message_id="msg-rollback-1",
|
||||
)
|
||||
|
||||
assert result["status"] == "failed_safe"
|
||||
assert "rag failed" in str(result["error_message"] or "")
|
||||
assert executor.db.rollback_calls == 1
|
||||
|
||||
|
||||
def test_typed_executor_common_exports_regex_helpers_to_query_mixins() -> None:
|
||||
assert mixin_execute_query_project.ExecuteQueryProjectMixin._extract_task_priority("p0 紧急任务") == "high"
|
||||
assert (
|
||||
ai_pmo_typed_executor_mixins_parse.TypedExecutorParseMixin._extract_requested_signal_count(
|
||||
message_text="请帮我分析 3 个信号",
|
||||
metadata={},
|
||||
)
|
||||
== 3
|
||||
)
|
||||
assert (
|
||||
ai_pmo_typed_executor_mixins_parse.TypedExecutorParseMixin._requires_strict_signal_analysis(
|
||||
message_text="先分析后入库 2 个信号",
|
||||
requested_count=2,
|
||||
)
|
||||
is True
|
||||
)
|
||||
|
||||
|
||||
def test_consultant_dispatch_uses_short_next_action_for_query_answers() -> None:
|
||||
dispatch = build_consultant_dispatch_contract(
|
||||
intent_name="query.answer",
|
||||
message_text="PMFlow 现在完整调用链是什么?",
|
||||
source_system="pmflow_manual",
|
||||
status="answered",
|
||||
classification="question",
|
||||
primary_operation_code="query.answer",
|
||||
risk_level="low",
|
||||
missing_fields=[],
|
||||
direct_reply_text=(
|
||||
"结论:PMFlow 当前完整调用链无法完整还原。\n\n"
|
||||
"依据:前端 `/ai-steward` 页面通过 `/rag/ask` 发起请求,"
|
||||
"并继续进入 `AdaptiveRAGService.upsert_pmflow_record_async`。"
|
||||
),
|
||||
)
|
||||
|
||||
assert dispatch["allow_execute"] is False
|
||||
assert dispatch["mode"] == "advise"
|
||||
assert dispatch["next_action"] == "继续追问具体模块、入口函数或文件路径,我会把调用链收窄到那一段。"
|
||||
assert "结论:" not in dispatch["next_action"]
|
||||
assert "/rag/ask" not in dispatch["next_action"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_low_tier_rag_failure_retries_high_tier(monkeypatch) -> None:
|
||||
captured: dict[str, object] = {"logs": []}
|
||||
|
|
|
|||
|
|
@ -48,7 +48,23 @@ async def test_http_rag_ask_readonly_mainline_contract(monkeypatch) -> None:
|
|||
"intent": "query.read_only",
|
||||
"direct_reply_required": True,
|
||||
"direct_reply_text": "当前主链状态正常。",
|
||||
"rag_result": {"answer": "当前主链状态正常。", "processing_stage": "qa"},
|
||||
"rag_result": {
|
||||
"answer": "当前主链状态正常。",
|
||||
"processing_stage": "qa",
|
||||
"answer_mode": "fast_path",
|
||||
"grounding_status": "grounded",
|
||||
"policy_version": "retrieval-v2.1|routing:v3|prompt:deterministic",
|
||||
"citations": [],
|
||||
"sources_used": [],
|
||||
"evidence_pack": {
|
||||
"summary": {
|
||||
"conclusion": "当前主链状态正常。",
|
||||
"evidence": [],
|
||||
"uncertainties": [],
|
||||
},
|
||||
"tool_trace": [],
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
monkeypatch.setattr(routes_query, "AIPMOKernelService", FakeKernel)
|
||||
|
|
@ -68,6 +84,11 @@ async def test_http_rag_ask_readonly_mainline_contract(monkeypatch) -> None:
|
|||
payload = response.json()
|
||||
assert payload["code"] == 0
|
||||
assert "当前主链状态正常" in str((payload.get("data") or {}).get("answer") or "")
|
||||
assert payload["data"]["answer_mode"] == "fast_path"
|
||||
assert payload["data"]["grounding_status"] == "grounded"
|
||||
assert payload["data"]["policy_version"] == "retrieval-v2.1|routing:v3|prompt:deterministic"
|
||||
assert payload["data"]["citations"] == []
|
||||
assert payload["data"]["sources_used"] == []
|
||||
ingress_kwargs = captured["ingress_kwargs"]
|
||||
assert isinstance(ingress_kwargs, dict)
|
||||
assert ingress_kwargs["source_system"] == "pmflow_manual"
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ from types import SimpleNamespace
|
|||
|
||||
import pytest
|
||||
import pytest_asyncio
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy import JSON, select
|
||||
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
|
||||
|
||||
os.environ["DATABASE_URL"] = "sqlite+aiosqlite:///:memory:"
|
||||
|
|
@ -18,14 +18,29 @@ from app.services.feedback_center.mappers import (
|
|||
map_signal_feedback_to_record_payload,
|
||||
map_task_override_to_record_payloads,
|
||||
)
|
||||
from app.services.feedback_center.reason_catalog import build_reason_candidates
|
||||
from app.services.feedback_center.reason_catalog import build_reason_candidates, list_reason_catalog
|
||||
import app.services.feedback_center.service as feedback_center_service_module
|
||||
from app.services.feedback_center.service import FeedbackCenterService
|
||||
|
||||
|
||||
def _use_sqlite_json_types() -> None:
|
||||
for table in (FeedbackCenterRecord.__table__, FeedbackCenterRecordRevision.__table__):
|
||||
for column in table.columns:
|
||||
if column.name in {
|
||||
"before_value",
|
||||
"after_value",
|
||||
"ai_snapshot",
|
||||
"reason_candidates",
|
||||
"before_snapshot",
|
||||
"after_snapshot",
|
||||
}:
|
||||
column.type = JSON()
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def db_session():
|
||||
engine = create_async_engine("sqlite+aiosqlite:///:memory:")
|
||||
_use_sqlite_json_types()
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(
|
||||
lambda sync_conn: Base.metadata.create_all(
|
||||
|
|
@ -268,6 +283,19 @@ def test_reason_catalog_is_deterministic_for_field_diff() -> None:
|
|||
assert first[0]["code"] == "effort_estimation_bias"
|
||||
|
||||
|
||||
def test_reason_catalog_exposes_grounding_retrieval_codes_for_qa_feedback() -> None:
|
||||
catalog = list_reason_catalog(source_type="qa_feedback")
|
||||
codes = {item["code"] for item in catalog}
|
||||
|
||||
assert "wrong_fast_path" in codes
|
||||
assert "should_have_gone_deep" in codes
|
||||
assert "wrong_source_priority" in codes
|
||||
assert "insufficient_evidence" in codes
|
||||
assert "good_retrieval_bad_synthesis" in codes
|
||||
assert "over_deep_latency" in codes
|
||||
assert "permission_scope_miss" in codes
|
||||
|
||||
|
||||
def test_source_mappers_generate_unified_payloads() -> None:
|
||||
qa_payload = map_qa_feedback_to_record_payload(
|
||||
qa_feedback=SimpleNamespace(
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
import pytest
|
||||
|
|
@ -18,11 +19,13 @@ def test_rag_ask_request_normalizes_contract_fields() -> None:
|
|||
message_intent="MIXED",
|
||||
mixed_signal_part="新增任务确认流",
|
||||
action_mode="read",
|
||||
consultant_mode_preference="ADVise",
|
||||
)
|
||||
assert req.asked_via == "/ai-pmo/process-text"
|
||||
assert req.message_intent == "mixed"
|
||||
assert req.mixed_signal_part == "新增任务确认流"
|
||||
assert req.action_mode == "read"
|
||||
assert req.consultant_mode_preference == "advise"
|
||||
|
||||
|
||||
def test_rag_ask_request_rejects_unknown_asked_via() -> None:
|
||||
|
|
@ -44,6 +47,7 @@ def test_build_answer_question_kwargs_maps_fields() -> None:
|
|||
sync_requirement=0,
|
||||
message_intent="mixed",
|
||||
mixed_signal_part="新增任务确认流",
|
||||
consultant_mode_preference="challenge",
|
||||
)
|
||||
kwargs = rag_ask_contract.build_answer_question_kwargs(
|
||||
request=request,
|
||||
|
|
@ -63,9 +67,29 @@ def test_build_answer_question_kwargs_maps_fields() -> None:
|
|||
"sync_requirement": False,
|
||||
"message_intent": "mixed",
|
||||
"mixed_signal_part": "新增任务确认流",
|
||||
"consultant_mode_preference": "challenge",
|
||||
}
|
||||
|
||||
|
||||
def test_build_kernel_ingress_kwargs_assigns_unique_source_message_id_for_rag() -> None:
|
||||
request = RAGAskRequest(question="现在进展怎么样")
|
||||
kwargs_a = rag_ask_contract.build_kernel_ingress_kwargs(
|
||||
request=request,
|
||||
current_user=SimpleNamespace(id=1, tenant_id=1, username="admin"),
|
||||
)
|
||||
kwargs_b = rag_ask_contract.build_kernel_ingress_kwargs(
|
||||
request=request,
|
||||
current_user=SimpleNamespace(id=1, tenant_id=1, username="admin"),
|
||||
)
|
||||
|
||||
source_message_id_a = str(kwargs_a["source_message_id"] or "")
|
||||
source_message_id_b = str(kwargs_b["source_message_id"] or "")
|
||||
|
||||
assert source_message_id_a.startswith("rag-ask-")
|
||||
assert source_message_id_b.startswith("rag-ask-")
|
||||
assert source_message_id_a != source_message_id_b
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_rag_ask_route_forwards_contract_fields(monkeypatch) -> None:
|
||||
captured: dict[str, object] = {}
|
||||
|
|
@ -94,6 +118,7 @@ async def test_rag_ask_route_forwards_contract_fields(monkeypatch) -> None:
|
|||
action_mode="read",
|
||||
message_intent="mixed",
|
||||
mixed_signal_part="新增任务确认流",
|
||||
consultant_mode_preference="clarify",
|
||||
)
|
||||
current_user = SimpleNamespace(id=101, tenant_id=9)
|
||||
response = await routes_query.rag_ask(
|
||||
|
|
@ -116,6 +141,7 @@ async def test_rag_ask_route_forwards_contract_fields(monkeypatch) -> None:
|
|||
assert rag_request_context["action_mode"] == "read"
|
||||
assert rag_request_context["message_intent"] == "mixed"
|
||||
assert rag_request_context["mixed_signal_part"] == "新增任务确认流"
|
||||
assert rag_request_context["consultant_mode_preference"] == "clarify"
|
||||
|
||||
|
||||
def test_ai_steward_router_does_not_expose_legacy_rag_ask_route() -> None:
|
||||
|
|
@ -167,26 +193,27 @@ async def test_rag_ask_route_uses_shared_contract_builder(monkeypatch) -> None:
|
|||
|
||||
assert response_rag["code"] == 0
|
||||
assert captured_tenants == [88]
|
||||
assert captured_kwargs == [
|
||||
{
|
||||
"source_system": "pmflow",
|
||||
"source_message_id": None,
|
||||
"message_text": "q1",
|
||||
"sender": "PMFlow用户",
|
||||
"channel_ref": None,
|
||||
"source_sender_open_id": None,
|
||||
"captured_at": None,
|
||||
"chat_type": "manual",
|
||||
"project_hint_id": None,
|
||||
"ingress_options": {
|
||||
"rag_request_context": {
|
||||
"question": "q1",
|
||||
"asked_by": 1,
|
||||
"asked_via": "shared-builder",
|
||||
}
|
||||
},
|
||||
}
|
||||
]
|
||||
assert len(captured_kwargs) == 1
|
||||
forwarded = captured_kwargs[0]
|
||||
assert str(forwarded["source_message_id"]).startswith("rag-ask-")
|
||||
assert forwarded == {
|
||||
"source_system": "pmflow",
|
||||
"source_message_id": forwarded["source_message_id"],
|
||||
"message_text": "q1",
|
||||
"sender": "PMFlow用户",
|
||||
"channel_ref": None,
|
||||
"source_sender_open_id": None,
|
||||
"captured_at": None,
|
||||
"chat_type": "manual",
|
||||
"project_hint_id": None,
|
||||
"ingress_options": {
|
||||
"rag_request_context": {
|
||||
"question": "q1",
|
||||
"asked_by": 1,
|
||||
"asked_via": "shared-builder",
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def test_build_rag_response_payload_preserves_rag_fields() -> None:
|
||||
|
|
@ -202,6 +229,19 @@ def test_build_rag_response_payload_preserves_rag_fields() -> None:
|
|||
"answer": "final answer",
|
||||
"session_id": 18,
|
||||
"sources": [{"source": "snapshot"}],
|
||||
"answer_mode": "retrieval_deep",
|
||||
"grounding_status": "grounded",
|
||||
"policy_version": "gr-v2.1|routing-v3|prompt:feishu_direct",
|
||||
"citations": [{"source_type": "doc", "source_id": "doc-1"}],
|
||||
"sources_used": [{"source_type": "doc", "source_id": "doc-1"}],
|
||||
"evidence_pack": {
|
||||
"summary": {
|
||||
"conclusion": "final answer",
|
||||
"evidence": ["doc-1"],
|
||||
"uncertainties": [],
|
||||
},
|
||||
"tool_trace": [{"tool": "vector_search"}],
|
||||
},
|
||||
"processing_stage": "qa",
|
||||
},
|
||||
}
|
||||
|
|
@ -213,6 +253,40 @@ def test_build_rag_response_payload_preserves_rag_fields() -> None:
|
|||
assert payload["trace_id"] == "ai-pmo-321"
|
||||
assert payload["reply_required"] is True
|
||||
assert payload["reply_text"] == "fallback"
|
||||
assert payload["answer_mode"] == "retrieval_deep"
|
||||
assert payload["grounding_status"] == "grounded"
|
||||
assert payload["policy_version"] == "gr-v2.1|routing-v3|prompt:feishu_direct"
|
||||
assert payload["citations"] == [{"source_type": "doc", "source_id": "doc-1"}]
|
||||
assert payload["sources_used"] == [{"source_type": "doc", "source_id": "doc-1"}]
|
||||
assert payload["evidence_pack"]["summary"]["conclusion"] == "final answer"
|
||||
assert payload["evidence_pack"]["tool_trace"] == [{"tool": "vector_search"}]
|
||||
|
||||
|
||||
def test_build_rag_response_payload_adds_grounding_contract_defaults() -> None:
|
||||
payload = rag_ask_contract.build_rag_response_payload(
|
||||
ingress_result={
|
||||
"status": "answered",
|
||||
"intent": "query.read_only",
|
||||
"direct_reply_text": "fallback",
|
||||
"rag_result": {
|
||||
"answer": "final answer",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
assert payload["answer_mode"] == "retrieval_normal"
|
||||
assert payload["grounding_status"] == "partially_grounded"
|
||||
assert payload["policy_version"] == "retrieval-v2.1|routing:unknown|prompt:unknown"
|
||||
assert payload["citations"] == []
|
||||
assert payload["sources_used"] == []
|
||||
assert payload["evidence_pack"] == {
|
||||
"summary": {
|
||||
"conclusion": "final answer",
|
||||
"evidence": [],
|
||||
"uncertainties": [],
|
||||
},
|
||||
"tool_trace": [],
|
||||
}
|
||||
|
||||
|
||||
def test_build_rag_response_payload_falls_back_to_top_level_consultant_dispatch() -> None:
|
||||
|
|
@ -239,3 +313,55 @@ def test_build_rag_response_payload_falls_back_to_top_level_consultant_dispatch(
|
|||
)
|
||||
|
||||
assert payload["consultant_dispatch"] == consultant_dispatch
|
||||
|
||||
|
||||
def test_build_rag_response_payload_does_not_inject_fake_answer_text() -> None:
|
||||
payload = rag_ask_contract.build_rag_response_payload(
|
||||
ingress_result={
|
||||
"status": "answered",
|
||||
"intent": "query.read_only",
|
||||
"direct_reply_text": "",
|
||||
"rag_result": {
|
||||
"processing_stage": "qa",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
assert payload["answer"] == ""
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_rag_ask_route_rejects_empty_rag_answer(monkeypatch) -> None:
|
||||
class FakeKernel:
|
||||
def __init__(self, db, tenant_id: int):
|
||||
_ = db
|
||||
_ = tenant_id
|
||||
|
||||
async def process_ingress(self, **kwargs):
|
||||
_ = kwargs
|
||||
return {
|
||||
"handled": True,
|
||||
"status": "answered",
|
||||
"intent": "query.read_only",
|
||||
"direct_reply_text": "",
|
||||
"rag_result": {
|
||||
"processing_stage": "qa",
|
||||
},
|
||||
}
|
||||
|
||||
monkeypatch.setattr(routes_query, "AIPMOKernelService", FakeKernel)
|
||||
|
||||
response = await routes_query.rag_ask(
|
||||
request=RAGAskRequest(question="空回答测试"),
|
||||
current_user=SimpleNamespace(id=1, tenant_id=1),
|
||||
db=object(),
|
||||
tenant_id=1,
|
||||
)
|
||||
|
||||
assert getattr(response, "status_code", None) == 500
|
||||
body = json.loads(response.body.decode("utf-8"))
|
||||
assert body == {
|
||||
"code": 500,
|
||||
"message": "RAG 返回空回答,未生成有效响应。",
|
||||
"data": None,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -65,6 +65,11 @@ def _use_sqlite_json_types() -> None:
|
|||
"sources",
|
||||
"domains_used",
|
||||
"routing_snapshot",
|
||||
"citations",
|
||||
"sources_used",
|
||||
"evidence_pack",
|
||||
"tool_trace",
|
||||
"retrieval_metrics",
|
||||
"evidence",
|
||||
"source_snapshot",
|
||||
"tags",
|
||||
|
|
|
|||
|
|
@ -44,6 +44,11 @@ def _use_sqlite_json_types() -> None:
|
|||
"sources",
|
||||
"domains_used",
|
||||
"routing_snapshot",
|
||||
"citations",
|
||||
"sources_used",
|
||||
"evidence_pack",
|
||||
"tool_trace",
|
||||
"retrieval_metrics",
|
||||
"evidence",
|
||||
"source_snapshot",
|
||||
"tags",
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -22,8 +22,11 @@
|
|||
"lucide-react": "^0.460.0",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-markdown": "^10.1.0",
|
||||
"react-router-dom": "^6.28.0",
|
||||
"recharts": "^2.13.0",
|
||||
"remark-breaks": "^4.0.0",
|
||||
"remark-gfm": "^4.0.1",
|
||||
"tailwind-merge": "^2.5.0",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"zustand": "^4.5.0"
|
||||
|
|
|
|||
|
|
@ -150,6 +150,26 @@ export interface AIPMOCadenceSnapshot {
|
|||
created_at?: string | null;
|
||||
}
|
||||
|
||||
export interface AIPMOProcessTextRequest {
|
||||
message_text: string;
|
||||
sender?: string | null;
|
||||
source_message_id?: string | null;
|
||||
project_id?: number | null;
|
||||
}
|
||||
|
||||
export interface AIPMOProcessTextResult {
|
||||
handled?: boolean;
|
||||
status?: string | null;
|
||||
intent?: string | null;
|
||||
direct_reply_required?: boolean;
|
||||
direct_reply_text?: string | null;
|
||||
followup_reply?: string | null;
|
||||
consultant_dispatch?: Record<string, unknown> | null;
|
||||
work_item_id?: number | null;
|
||||
signal_id?: number | null;
|
||||
ai_trace_id?: string | null;
|
||||
}
|
||||
|
||||
export interface AIPMOProgramView {
|
||||
summary: {
|
||||
program_count: number;
|
||||
|
|
@ -169,6 +189,12 @@ export interface AIPMOProgramView {
|
|||
}
|
||||
|
||||
export const aiPmoApi = {
|
||||
async processText(body: AIPMOProcessTextRequest): Promise<AIPMOProcessTextResult> {
|
||||
const { data } = await api.post<ApiEnvelope<AIPMOProcessTextResult>>("/ai-pmo/process-text", body, {
|
||||
timeout: 15000,
|
||||
});
|
||||
return unwrap(data);
|
||||
},
|
||||
async listWorkItems(params?: { limit?: number; state?: string; projectId?: number }): Promise<AIPMOWorkItemListPayload> {
|
||||
const { data } = await api.get<ApiEnvelope<AIPMOWorkItemListPayload>>("/ai-pmo/work-items", {
|
||||
params: {
|
||||
|
|
|
|||
|
|
@ -279,7 +279,162 @@ export interface LLMEngineConnectionTestResponse {
|
|||
response_preview: string;
|
||||
}
|
||||
|
||||
export type QAFeedbackRating = "positive" | "negative";
|
||||
export type QAHelpfulness = "fully" | "partially" | "could_be_better";
|
||||
export type QAThinkingMode = "normal" | "deep";
|
||||
export type QAActionTaken = "signal_created" | "signal_deduped" | "signal_failed";
|
||||
|
||||
export interface QAMessageSource {
|
||||
source: string;
|
||||
label: string;
|
||||
score: number;
|
||||
metadata?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface QAConsultantDispatch {
|
||||
domain: string;
|
||||
mode: string;
|
||||
risk_level: string;
|
||||
missing_fields: string[];
|
||||
allow_execute: boolean;
|
||||
next_action: string;
|
||||
}
|
||||
|
||||
export interface QASessionSummary {
|
||||
id: number;
|
||||
title: string;
|
||||
message_count: number;
|
||||
last_message_at?: string | null;
|
||||
rating_summary?: {
|
||||
positive: number;
|
||||
negative: number;
|
||||
unrated: number;
|
||||
corrected: number;
|
||||
};
|
||||
last_asked_via?: "pmflow" | "feishu";
|
||||
preview_question?: string;
|
||||
}
|
||||
|
||||
export interface QASessionMessage {
|
||||
id: number;
|
||||
question: string;
|
||||
answer: string;
|
||||
sources: QAMessageSource[];
|
||||
response_time_ms?: number;
|
||||
turn_index: number;
|
||||
created_at?: string | null;
|
||||
feedback?: {
|
||||
id: number;
|
||||
rating: QAFeedbackRating;
|
||||
helpfulness?: QAHelpfulness;
|
||||
error_types?: string[];
|
||||
correct_answer?: string;
|
||||
} | null;
|
||||
thinking_mode?: QAThinkingMode;
|
||||
action_taken?: QAActionTaken | null;
|
||||
signal_id?: number | null;
|
||||
reply_required?: boolean;
|
||||
reply_text?: string | null;
|
||||
ack_latency_ms?: number | null;
|
||||
trace_id?: string | null;
|
||||
processing_stage?: string | null;
|
||||
consultant_dispatch?: QAConsultantDispatch | null;
|
||||
}
|
||||
|
||||
export interface QASessionDetail {
|
||||
session: {
|
||||
id: number;
|
||||
title: string;
|
||||
message_count: number;
|
||||
started_at?: string | null;
|
||||
last_message_at?: string | null;
|
||||
};
|
||||
messages: QASessionMessage[];
|
||||
}
|
||||
|
||||
export interface QAListSessionsResponse {
|
||||
items: QASessionSummary[];
|
||||
page: number;
|
||||
total_pages: number;
|
||||
}
|
||||
|
||||
export interface QAStats {
|
||||
total_sessions: number;
|
||||
total_qa: number;
|
||||
rated_count: number;
|
||||
rated_ratio: number;
|
||||
average_helpfulness: number;
|
||||
exportable_pairs: number;
|
||||
}
|
||||
|
||||
export interface QAAskRequest {
|
||||
question: string;
|
||||
asked_via: "pmflow";
|
||||
thinking_mode: QAThinkingMode;
|
||||
new_session: boolean;
|
||||
action_mode: "auto";
|
||||
sync_requirement: boolean;
|
||||
session_id?: number;
|
||||
consultant_mode_preference?: "clarify" | "advise" | "challenge" | "brainstorm" | "execute";
|
||||
}
|
||||
|
||||
export interface QAAskResponse {
|
||||
answer: string;
|
||||
session_id?: number | null;
|
||||
thinking_mode?: QAThinkingMode;
|
||||
sources?: QAMessageSource[];
|
||||
response_time_ms?: number | null;
|
||||
action_taken?: QAActionTaken | null;
|
||||
signal_id?: number | null;
|
||||
reply_required?: boolean;
|
||||
reply_text?: string | null;
|
||||
ack_latency_ms?: number | null;
|
||||
trace_id?: string | null;
|
||||
processing_stage?: string | null;
|
||||
consultant_dispatch?: QAConsultantDispatch | null;
|
||||
}
|
||||
|
||||
export const aiStewardApi = {
|
||||
async askRag(body: QAAskRequest): Promise<QAAskResponse> {
|
||||
const raw = await apiRequest<ApiEnvelope<QAAskResponse>>("/rag/ask", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
return unwrap(raw);
|
||||
},
|
||||
async listQASessions(params?: { page?: number; pageSize?: number }): Promise<QAListSessionsResponse> {
|
||||
const query = new URLSearchParams();
|
||||
query.set("page", String(params?.page ?? 1));
|
||||
query.set("page_size", String(params?.pageSize ?? 20));
|
||||
const raw = await apiRequest<ApiEnvelope<QAListSessionsResponse>>(`/rag/qa-sessions?${query.toString()}`);
|
||||
return unwrap(raw);
|
||||
},
|
||||
async getQASession(sessionId: number): Promise<QASessionDetail> {
|
||||
const raw = await apiRequest<ApiEnvelope<QASessionDetail>>(`/rag/qa-sessions/${sessionId}`);
|
||||
return unwrap(raw);
|
||||
},
|
||||
async renameQASession(sessionId: number, title: string): Promise<{ title: string }> {
|
||||
const raw = await apiRequest<ApiEnvelope<{ title: string }>>(`/rag/qa-sessions/${sessionId}/title`, {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify({ title }),
|
||||
});
|
||||
return unwrap(raw);
|
||||
},
|
||||
async submitQAFeedback(
|
||||
qaRecordId: number,
|
||||
body: {
|
||||
rating: QAFeedbackRating;
|
||||
helpfulness?: QAHelpfulness;
|
||||
error_types?: string[];
|
||||
correct_answer?: string;
|
||||
},
|
||||
): Promise<Record<string, unknown>> {
|
||||
const raw = await apiRequest<ApiEnvelope<Record<string, unknown>>>(`/rag/qa-records/${qaRecordId}/feedback`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
return unwrap(raw);
|
||||
},
|
||||
async listCognitiveConflicts(projectId?: number): Promise<CognitiveConflict[]> {
|
||||
const query = typeof projectId === "number" ? `?project_id=${projectId}` : "";
|
||||
return await apiRequest<CognitiveConflict[]>(`/ai-steward/cognitive-conflicts${query}`);
|
||||
|
|
|
|||
|
|
@ -1,18 +1,30 @@
|
|||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { AlertCircle, Loader2, MessageSquare, Sparkles, ThumbsDown, ThumbsUp, X } from "lucide-react";
|
||||
|
||||
import { aiPmoApi } from "@/api/aiPmo";
|
||||
import { aiStewardApi } from "@/api/aiSteward";
|
||||
import { QAMarkdownContent } from "@/components/ai-steward/QAMarkdownContent";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { apiBaseURL, getAuthToken } from "@/lib/api";
|
||||
import { formatBeijingDate, formatBeijingTime, parseBackendDate } from "@/lib/datetime";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type FeedbackRating = "positive" | "negative";
|
||||
type Helpfulness = "fully" | "partially" | "could_be_better";
|
||||
type ThinkingMode = "normal" | "deep";
|
||||
type ActionTaken = "signal_created" | "signal_deduped" | "signal_failed";
|
||||
type ConsultantModePreference = "clarify" | "advise" | "challenge" | "brainstorm" | "execute";
|
||||
type MessageSource = { source: string; label: string; score: number; metadata?: Record<string, unknown> };
|
||||
type ConsultantDispatch = {
|
||||
domain: string;
|
||||
|
|
@ -84,18 +96,23 @@ const ERROR_OPTIONS = ["答案错误", "来源错误", "不完整", "答非所
|
|||
const LAST_SEEN_KEY = "pmflow_qa_chat_last_seen";
|
||||
const THINKING_MODE_KEY = "pmflow_qa_chat_thinking_mode";
|
||||
const QUICK_QUESTIONS = ["你是谁", "系统架构是什么", "最近有哪些项目", "当前阻塞项有哪些"];
|
||||
const CONSULTANT_MODE_LABELS: Record<string, string> = {
|
||||
clarify: "先澄清",
|
||||
advise: "给建议",
|
||||
challenge: "先劝退",
|
||||
brainstorm: "一起想",
|
||||
execute: "可执行",
|
||||
};
|
||||
const CONSULTANT_MODE_ACTIONS: ReadonlyArray<{
|
||||
value: ConsultantModePreference;
|
||||
label: string;
|
||||
tone: "advise" | "clarify" | "brainstorm" | "challenge";
|
||||
}> = [
|
||||
{ value: "advise", label: "给建议", tone: "advise" },
|
||||
{ value: "clarify", label: "先澄清", tone: "clarify" },
|
||||
{ value: "brainstorm", label: "一起想", tone: "brainstorm" },
|
||||
{ value: "challenge", label: "先劝退", tone: "challenge" },
|
||||
];
|
||||
const CONSULTANT_RISK_LABELS: Record<string, string> = {
|
||||
low: "低风险",
|
||||
medium: "中风险",
|
||||
high: "高风险",
|
||||
};
|
||||
const ACTION_CHIP_BASE =
|
||||
"h-8 rounded-full border px-3 text-[11px] font-medium shadow-sm transition-all duration-150";
|
||||
|
||||
function normalizeRequestError(error: unknown, fallback = "请求失败,请稍后重试。"): string {
|
||||
const raw = error instanceof Error ? error.message : String(error || "");
|
||||
|
|
@ -138,6 +155,14 @@ function normalizeConsultantDispatch(value: unknown): ConsultantDispatch | null
|
|||
};
|
||||
}
|
||||
|
||||
function normalizeConsultantModePreference(value: unknown): ConsultantModePreference | null {
|
||||
const mode = String(value || "").trim().toLowerCase();
|
||||
if (mode === "clarify" || mode === "advise" || mode === "challenge" || mode === "brainstorm" || mode === "execute") {
|
||||
return mode;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function looksLikeRequirementWrite(text: string): boolean {
|
||||
const clean = String(text || "").trim();
|
||||
if (!clean) return false;
|
||||
|
|
@ -167,20 +192,6 @@ function getStageBadge(message: Pick<SessionMessage, "processing_stage" | "reply
|
|||
return null;
|
||||
}
|
||||
|
||||
function getConsultantModeBadge(
|
||||
consultantDispatch?: ConsultantDispatch | null,
|
||||
): { label: string; variant: "secondary" | "destructive" | "outline" } | null {
|
||||
const mode = String(consultantDispatch?.mode || "").trim().toLowerCase();
|
||||
if (!mode) return null;
|
||||
if (mode === "challenge") {
|
||||
return { label: CONSULTANT_MODE_LABELS[mode] || mode, variant: "destructive" };
|
||||
}
|
||||
if (mode === "clarify" || mode === "execute") {
|
||||
return { label: CONSULTANT_MODE_LABELS[mode] || mode, variant: "secondary" };
|
||||
}
|
||||
return { label: CONSULTANT_MODE_LABELS[mode] || mode, variant: "outline" };
|
||||
}
|
||||
|
||||
function getConsultantRiskBadge(
|
||||
consultantDispatch?: ConsultantDispatch | null,
|
||||
): { label: string; variant: "secondary" | "destructive" | "outline" } | null {
|
||||
|
|
@ -195,127 +206,79 @@ function getConsultantRiskBadge(
|
|||
return { label: CONSULTANT_RISK_LABELS[riskLevel] || riskLevel, variant: "secondary" };
|
||||
}
|
||||
|
||||
async function fetchApi<T>(path: string, init?: RequestInit): Promise<T | null> {
|
||||
const token = getAuthToken();
|
||||
if (!token) return null;
|
||||
try {
|
||||
const res = await fetch(`${apiBaseURL}${path}`, {
|
||||
...init,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${token}`,
|
||||
...(init?.headers || {}),
|
||||
},
|
||||
});
|
||||
if (!res.ok) return null;
|
||||
const payload = await res.json().catch(() => ({}));
|
||||
return (payload?.data ?? payload) as T;
|
||||
} catch (error) {
|
||||
console.error(`[FloatingQAChat] request failed: ${path}`, error);
|
||||
return null;
|
||||
function buildRiskExplanation(consultantDispatch?: ConsultantDispatch | null): string {
|
||||
const riskLevel = String(consultantDispatch?.risk_level || "").trim().toLowerCase();
|
||||
const missingCount = consultantDispatch?.missing_fields?.length || 0;
|
||||
if (riskLevel === "high") {
|
||||
return missingCount > 0
|
||||
? "当前被判为高风险,且还缺少关键输入,系统不会建议直接推进执行。"
|
||||
: "当前被判为高风险,建议先重新确认范围、影响面和回滚路径。";
|
||||
}
|
||||
if (riskLevel === "medium") {
|
||||
return missingCount > 0
|
||||
? "当前风险中等,建议先补齐缺失信息,再决定是否继续推进。"
|
||||
: "当前风险中等,适合先补充证据或确认边界,再继续下一步。";
|
||||
}
|
||||
if (consultantDispatch?.allow_execute) {
|
||||
return "当前被判为低风险,且信息较完整,可以在确认后交接给 AI PMO 执行流。";
|
||||
}
|
||||
return "当前被判为低风险,但系统仍建议先按下一步补齐信息,而不是直接执行。";
|
||||
}
|
||||
|
||||
async function askRAG(
|
||||
question: string,
|
||||
sessionId?: number | null,
|
||||
thinkingMode: ThinkingMode = "normal",
|
||||
newSession = false,
|
||||
): Promise<{
|
||||
answer: string;
|
||||
session_id?: number | null;
|
||||
thinking_mode?: ThinkingMode;
|
||||
sources?: MessageSource[];
|
||||
response_time_ms?: number | null;
|
||||
action_taken?: ActionTaken | null;
|
||||
signal_id?: number | null;
|
||||
reply_required?: boolean;
|
||||
reply_text?: string | null;
|
||||
ack_latency_ms?: number | null;
|
||||
trace_id?: string | null;
|
||||
processing_stage?: string | null;
|
||||
consultant_dispatch?: ConsultantDispatch | null;
|
||||
}> {
|
||||
const token = getAuthToken();
|
||||
if (!token) {
|
||||
throw new Error("登录状态已失效,请重新登录后提问。");
|
||||
function getActionChipClasses(
|
||||
tone: "deep" | "advise" | "clarify" | "brainstorm" | "challenge" | "risk-low" | "risk-medium" | "risk-high" | "execute" | "next",
|
||||
active = false,
|
||||
): string {
|
||||
const sharedInactive = "bg-white/90 text-slate-700 hover:bg-white hover:text-slate-900";
|
||||
switch (tone) {
|
||||
case "deep":
|
||||
return cn(
|
||||
ACTION_CHIP_BASE,
|
||||
active
|
||||
? "border-violet-500 bg-violet-50 text-violet-700"
|
||||
: `${sharedInactive} border-violet-200 hover:border-violet-300 hover:text-violet-700`,
|
||||
);
|
||||
case "advise":
|
||||
return cn(
|
||||
ACTION_CHIP_BASE,
|
||||
active
|
||||
? "border-emerald-500 bg-emerald-50 text-emerald-700"
|
||||
: `${sharedInactive} border-emerald-200 hover:border-emerald-300 hover:text-emerald-700`,
|
||||
);
|
||||
case "clarify":
|
||||
return cn(
|
||||
ACTION_CHIP_BASE,
|
||||
active
|
||||
? "border-amber-400 bg-amber-50 text-amber-800"
|
||||
: `${sharedInactive} border-amber-200 hover:border-amber-300 hover:text-amber-800`,
|
||||
);
|
||||
case "brainstorm":
|
||||
return cn(
|
||||
ACTION_CHIP_BASE,
|
||||
active
|
||||
? "border-sky-500 bg-sky-50 text-sky-700"
|
||||
: `${sharedInactive} border-sky-200 hover:border-sky-300 hover:text-sky-700`,
|
||||
);
|
||||
case "challenge":
|
||||
return cn(
|
||||
ACTION_CHIP_BASE,
|
||||
active
|
||||
? "border-rose-500 bg-rose-50 text-rose-700"
|
||||
: `${sharedInactive} border-rose-200 hover:border-rose-300 hover:text-rose-700`,
|
||||
);
|
||||
case "risk-high":
|
||||
return cn(ACTION_CHIP_BASE, "border-rose-300 bg-rose-50 text-rose-700 hover:border-rose-400");
|
||||
case "risk-medium":
|
||||
return cn(ACTION_CHIP_BASE, "border-amber-300 bg-amber-50 text-amber-800 hover:border-amber-400");
|
||||
case "risk-low":
|
||||
return cn(ACTION_CHIP_BASE, "border-emerald-300 bg-emerald-50 text-emerald-700 hover:border-emerald-400");
|
||||
case "execute":
|
||||
return cn(ACTION_CHIP_BASE, "border-slate-900 bg-slate-900 text-white hover:bg-slate-800");
|
||||
case "next":
|
||||
return cn(ACTION_CHIP_BASE, "border-slate-300 bg-slate-100 text-slate-800 hover:border-slate-400 hover:bg-slate-200");
|
||||
default:
|
||||
return cn(ACTION_CHIP_BASE, sharedInactive);
|
||||
}
|
||||
|
||||
const payloadObj: {
|
||||
question: string;
|
||||
asked_via: "pmflow";
|
||||
thinking_mode: ThinkingMode;
|
||||
new_session: boolean;
|
||||
action_mode: "auto";
|
||||
sync_requirement: boolean;
|
||||
session_id?: number;
|
||||
} = {
|
||||
question,
|
||||
asked_via: "pmflow",
|
||||
thinking_mode: thinkingMode,
|
||||
new_session: Boolean(newSession),
|
||||
action_mode: "auto",
|
||||
sync_requirement: true,
|
||||
};
|
||||
if (typeof sessionId === "number" && Number.isFinite(sessionId) && sessionId > 0) {
|
||||
payloadObj.session_id = Math.trunc(sessionId);
|
||||
}
|
||||
const payload = JSON.stringify(payloadObj);
|
||||
let res: Response;
|
||||
try {
|
||||
res = await fetch(`${apiBaseURL}/rag/ask`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
body: payload,
|
||||
});
|
||||
} catch (error) {
|
||||
throw new Error(normalizeRequestError(error, "提问失败,请稍后重试。"));
|
||||
}
|
||||
|
||||
if (!res.ok) {
|
||||
const body = await res.json().catch(() => ({}));
|
||||
const detail = String(body?.detail || body?.message || "").trim();
|
||||
throw new Error(detail || `请求失败(${res.status})`);
|
||||
}
|
||||
|
||||
const body = await res.json().catch(() => ({}));
|
||||
const data = (body?.data ?? body) as {
|
||||
answer?: string;
|
||||
session_id?: number | null;
|
||||
thinking_mode?: string;
|
||||
sources?: unknown;
|
||||
response_time_ms?: number | null;
|
||||
action_taken?: ActionTaken | null;
|
||||
signal_id?: number | null;
|
||||
reply_required?: boolean;
|
||||
reply_text?: string | null;
|
||||
ack_latency_ms?: number | null;
|
||||
trace_id?: string | null;
|
||||
processing_stage?: string | null;
|
||||
consultant_dispatch?: unknown;
|
||||
};
|
||||
if (!data || !String(data.answer || "").trim()) {
|
||||
throw new Error("服务返回为空,未生成有效回答。");
|
||||
}
|
||||
const resolvedThinkingMode: ThinkingMode = String(data.thinking_mode || thinkingMode).toLowerCase() === "deep" ? "deep" : "normal";
|
||||
return {
|
||||
answer: String(data.answer),
|
||||
session_id: data.session_id ?? null,
|
||||
thinking_mode: resolvedThinkingMode,
|
||||
sources: normalizeSources(data.sources),
|
||||
response_time_ms: data.response_time_ms ?? null,
|
||||
action_taken: data.action_taken ?? null,
|
||||
signal_id: data.signal_id ?? null,
|
||||
reply_required: Boolean(data.reply_required),
|
||||
reply_text: data.reply_text ?? null,
|
||||
ack_latency_ms: data.ack_latency_ms ?? null,
|
||||
trace_id: data.trace_id ?? null,
|
||||
processing_stage: data.processing_stage ?? null,
|
||||
consultant_dispatch: normalizeConsultantDispatch(data.consultant_dispatch),
|
||||
};
|
||||
}
|
||||
|
||||
function groupSessions(items: SessionSummary[]) {
|
||||
|
|
@ -384,6 +347,9 @@ export function FloatingQAChat() {
|
|||
const [errorTypes, setErrorTypes] = useState<Record<number, string[]>>({});
|
||||
const [correctAnswer, setCorrectAnswer] = useState<Record<number, string>>({});
|
||||
const [submittingFeedbackId, setSubmittingFeedbackId] = useState<number | null>(null);
|
||||
const [actionBusyKey, setActionBusyKey] = useState<string | null>(null);
|
||||
const [expandedRiskMessageId, setExpandedRiskMessageId] = useState<number | null>(null);
|
||||
const [executeDialogMessage, setExecuteDialogMessage] = useState<SessionMessage | null>(null);
|
||||
const [showOlder, setShowOlder] = useState(false);
|
||||
const [unread, setUnread] = useState(false);
|
||||
const [renameEditing, setRenameEditing] = useState(false);
|
||||
|
|
@ -395,19 +361,40 @@ export function FloatingQAChat() {
|
|||
const messageViewportRef = useRef<HTMLDivElement | null>(null);
|
||||
const messageEndRef = useRef<HTMLDivElement | null>(null);
|
||||
const forceStickBottomRef = useRef(false);
|
||||
const questionInputRef = useRef<HTMLTextAreaElement | null>(null);
|
||||
|
||||
const normalizeSessionMessage = (message: SessionMessage): SessionMessage => ({
|
||||
...message,
|
||||
question: String(message.question || ""),
|
||||
answer: String(message.answer || ""),
|
||||
sources: normalizeSources(message.sources),
|
||||
consultant_dispatch: normalizeConsultantDispatch(message.consultant_dispatch),
|
||||
});
|
||||
|
||||
const normalizeSessionDetail = (detail: SessionDetail): SessionDetail => ({
|
||||
session: detail.session,
|
||||
messages: Array.isArray(detail.messages) ? detail.messages.map(normalizeSessionMessage) : [],
|
||||
});
|
||||
|
||||
const appendLocalMessage = (message: Omit<SessionMessage, "id" | "turn_index" | "created_at" | "feedback">) => {
|
||||
const localId = localMessageIdRef.current;
|
||||
localMessageIdRef.current -= 1;
|
||||
setLocalMessages((prev) => [
|
||||
...prev,
|
||||
{
|
||||
id: localId,
|
||||
turn_index: prev.length + 1,
|
||||
created_at: new Date().toISOString(),
|
||||
feedback: null,
|
||||
...message,
|
||||
},
|
||||
]);
|
||||
};
|
||||
|
||||
const loadSessions = async (page = 1, append = false) => {
|
||||
setHistoryLoading(true);
|
||||
try {
|
||||
const data = await fetchApi<{
|
||||
items: SessionSummary[];
|
||||
page: number;
|
||||
total_pages: number;
|
||||
}>(`/rag/qa-sessions?page=${page}&page_size=20`);
|
||||
if (!data) {
|
||||
setHistoryApiAvailable(false);
|
||||
return;
|
||||
}
|
||||
const data = await aiStewardApi.listQASessions({ page, pageSize: 20 });
|
||||
setHistoryApiAvailable(true);
|
||||
|
||||
setHistoryPage(Number(data.page || page));
|
||||
|
|
@ -437,6 +424,9 @@ export function FloatingQAChat() {
|
|||
if (!activeSessionIdRef.current && !draftNewSessionRef.current && Array.isArray(data.items) && data.items.length > 0) {
|
||||
setActiveSessionId(data.items[0].id);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("[FloatingQAChat] loadSessions failed", error);
|
||||
setHistoryApiAvailable(false);
|
||||
} finally {
|
||||
setHistoryLoading(false);
|
||||
}
|
||||
|
|
@ -449,17 +439,16 @@ export function FloatingQAChat() {
|
|||
setDetailLoading(true);
|
||||
}
|
||||
try {
|
||||
const data = await fetchApi<SessionDetail>(`/rag/qa-sessions/${sessionId}`);
|
||||
if (!data) {
|
||||
setHistoryApiAvailable(false);
|
||||
return;
|
||||
}
|
||||
const data = normalizeSessionDetail(await aiStewardApi.getQASession(sessionId));
|
||||
setHistoryApiAvailable(true);
|
||||
setSessionDetail(data);
|
||||
if (!preserveLocal) {
|
||||
setLocalMessages([]);
|
||||
}
|
||||
setRenameTitle(String(data.session?.title || ""));
|
||||
} catch (error) {
|
||||
console.error("[FloatingQAChat] loadSessionDetail failed", error);
|
||||
setHistoryApiAvailable(false);
|
||||
} finally {
|
||||
if (!silent) {
|
||||
setDetailLoading(false);
|
||||
|
|
@ -487,45 +476,62 @@ export function FloatingQAChat() {
|
|||
setRenameTitle("");
|
||||
};
|
||||
|
||||
const askQuestion = async () => {
|
||||
const clean = question.trim();
|
||||
const runQuestionRequest = async ({
|
||||
prompt,
|
||||
requestThinkingModeOverride,
|
||||
consultantModePreference,
|
||||
clearInput = false,
|
||||
}: {
|
||||
prompt: string;
|
||||
requestThinkingModeOverride?: ThinkingMode;
|
||||
consultantModePreference?: ConsultantModePreference | null;
|
||||
clearInput?: boolean;
|
||||
}) => {
|
||||
const clean = prompt.trim();
|
||||
if (!clean) return;
|
||||
const currentSessionId = draftNewSession ? null : activeSessionIdRef.current;
|
||||
const currentThinkingMode: ThinkingMode = thinkingMode;
|
||||
const currentThinkingMode: ThinkingMode = requestThinkingModeOverride || thinkingMode;
|
||||
forceStickBottomRef.current = true;
|
||||
setAskError("");
|
||||
setRequestThinkingMode(currentThinkingMode);
|
||||
setAsking(true);
|
||||
try {
|
||||
const data = await askRAG(clean, currentSessionId, currentThinkingMode, draftNewSession);
|
||||
setQuestion("");
|
||||
const data = await aiStewardApi.askRag({
|
||||
question: clean,
|
||||
asked_via: "pmflow",
|
||||
thinking_mode: currentThinkingMode,
|
||||
new_session: Boolean(draftNewSession),
|
||||
action_mode: "auto",
|
||||
sync_requirement: true,
|
||||
...(typeof currentSessionId === "number" && Number.isFinite(currentSessionId) && currentSessionId > 0
|
||||
? { session_id: Math.trunc(currentSessionId) }
|
||||
: {}),
|
||||
...(consultantModePreference ? { consultant_mode_preference: consultantModePreference } : {}),
|
||||
});
|
||||
if (!String(data.answer || "").trim() && !String(data.reply_text || "").trim()) {
|
||||
throw new Error("服务返回为空,未生成有效回答。");
|
||||
}
|
||||
if (clearInput) {
|
||||
setQuestion("");
|
||||
}
|
||||
setSessionDetail(null);
|
||||
draftNewSessionRef.current = false;
|
||||
setDraftNewSession(false);
|
||||
const localId = localMessageIdRef.current;
|
||||
localMessageIdRef.current -= 1;
|
||||
setLocalMessages((prev) => [
|
||||
...prev,
|
||||
{
|
||||
id: localId,
|
||||
question: clean,
|
||||
answer: String(data.reply_text || data.answer || ""),
|
||||
sources: data.sources?.length ? data.sources : [{ source: "rag", label: "AI 管家", score: 1 }],
|
||||
response_time_ms: data.response_time_ms ?? undefined,
|
||||
turn_index: prev.length + 1,
|
||||
created_at: new Date().toISOString(),
|
||||
feedback: null,
|
||||
thinking_mode: data.thinking_mode || currentThinkingMode,
|
||||
action_taken: data.action_taken ?? null,
|
||||
signal_id: data.signal_id ?? null,
|
||||
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: data.consultant_dispatch ?? null,
|
||||
},
|
||||
]);
|
||||
appendLocalMessage({
|
||||
question: clean,
|
||||
answer: String(data.reply_text || data.answer || ""),
|
||||
sources: data.sources?.length ? normalizeSources(data.sources) : [{ source: "rag", label: "AI 管家", score: 1 }],
|
||||
response_time_ms: data.response_time_ms ?? undefined,
|
||||
thinking_mode: data.thinking_mode || currentThinkingMode,
|
||||
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),
|
||||
});
|
||||
const returnedSessionId = Number(data.session_id || 0) || null;
|
||||
if (historyApiAvailable) {
|
||||
await loadSessions(1, false);
|
||||
|
|
@ -536,7 +542,7 @@ export function FloatingQAChat() {
|
|||
setActiveSessionId(currentSessionId);
|
||||
await refreshActiveSession(currentSessionId, { silent: true, preserveLocal: true });
|
||||
} else if (latestSessionAtRef.current) {
|
||||
const fallback = await fetchApi<{ items: SessionSummary[] }>("/rag/qa-sessions?page=1&page_size=1");
|
||||
const fallback = await aiStewardApi.listQASessions({ page: 1, pageSize: 1 });
|
||||
const target = fallback?.items?.[0]?.id || null;
|
||||
if (target) {
|
||||
setActiveSessionId(target);
|
||||
|
|
@ -553,17 +559,22 @@ export function FloatingQAChat() {
|
|||
}
|
||||
};
|
||||
|
||||
const askQuestion = async () => {
|
||||
await runQuestionRequest({
|
||||
prompt: question,
|
||||
requestThinkingModeOverride: thinkingMode,
|
||||
clearInput: true,
|
||||
});
|
||||
};
|
||||
|
||||
const submitFeedback = async (messageId: number, rating: FeedbackRating) => {
|
||||
setSubmittingFeedbackId(messageId);
|
||||
try {
|
||||
await fetchApi(`/rag/qa-records/${messageId}/feedback`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
rating,
|
||||
helpfulness: rating === "positive" ? helpfulness[messageId] || "fully" : undefined,
|
||||
error_types: rating === "negative" ? errorTypes[messageId] || [] : [],
|
||||
correct_answer: rating === "negative" ? correctAnswer[messageId] || undefined : undefined,
|
||||
}),
|
||||
await aiStewardApi.submitQAFeedback(messageId, {
|
||||
rating,
|
||||
helpfulness: rating === "positive" ? helpfulness[messageId] || "fully" : undefined,
|
||||
error_types: rating === "negative" ? errorTypes[messageId] || [] : [],
|
||||
correct_answer: rating === "negative" ? correctAnswer[messageId] || undefined : undefined,
|
||||
});
|
||||
setFeedbackMode((prev) => ({ ...prev, [messageId]: null }));
|
||||
await Promise.all([loadSessions(1, false), refreshActiveSession()]);
|
||||
|
|
@ -576,15 +587,83 @@ export function FloatingQAChat() {
|
|||
if (!activeSessionId) return;
|
||||
const clean = renameTitle.trim();
|
||||
if (!clean) return;
|
||||
const data = await fetchApi<{ title: string }>(`/rag/qa-sessions/${activeSessionId}/title`, {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify({ title: clean }),
|
||||
});
|
||||
if (!data) return;
|
||||
await aiStewardApi.renameQASession(activeSessionId, clean);
|
||||
setRenameEditing(false);
|
||||
await Promise.all([loadSessions(1, false), refreshActiveSession(activeSessionId)]);
|
||||
};
|
||||
|
||||
const handleDeepRerun = async (message: SessionMessage) => {
|
||||
setActionBusyKey(`deep-${message.id}`);
|
||||
try {
|
||||
await runQuestionRequest({
|
||||
prompt: message.question,
|
||||
requestThinkingModeOverride: "deep",
|
||||
});
|
||||
} finally {
|
||||
setActionBusyKey(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleConsultantModeRerun = async (message: SessionMessage, modeValue: ConsultantModePreference) => {
|
||||
setActionBusyKey(`mode-${message.id}-${modeValue}`);
|
||||
try {
|
||||
await runQuestionRequest({
|
||||
prompt: message.question,
|
||||
requestThinkingModeOverride: message.thinking_mode || thinkingMode,
|
||||
consultantModePreference: modeValue,
|
||||
});
|
||||
} finally {
|
||||
setActionBusyKey(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleToggleRiskExplanation = (messageId: number) => {
|
||||
setExpandedRiskMessageId((prev) => (prev === messageId ? null : messageId));
|
||||
};
|
||||
|
||||
const handleUseNextAction = (consultantDispatch?: ConsultantDispatch | null) => {
|
||||
const nextAction = String(consultantDispatch?.next_action || "").trim();
|
||||
if (!nextAction) return;
|
||||
setMode("chat");
|
||||
setQuestion(nextAction);
|
||||
setAskError("");
|
||||
window.setTimeout(() => {
|
||||
questionInputRef.current?.focus();
|
||||
questionInputRef.current?.setSelectionRange(nextAction.length, nextAction.length);
|
||||
}, 0);
|
||||
};
|
||||
|
||||
const handleExecuteViaKernel = async () => {
|
||||
if (!executeDialogMessage) return;
|
||||
setActionBusyKey(`execute-${executeDialogMessage.id}`);
|
||||
try {
|
||||
const result = await aiPmoApi.processText({
|
||||
message_text: executeDialogMessage.question,
|
||||
});
|
||||
const replyText = String(result.direct_reply_text || result.followup_reply || "已交接到 AI PMO 执行流继续处理。").trim();
|
||||
appendLocalMessage({
|
||||
question: executeDialogMessage.question,
|
||||
answer: replyText || "已交接到 AI PMO 执行流继续处理。",
|
||||
sources: [{ source: "ai_pmo", label: "AI PMO Kernel", score: 1 }],
|
||||
thinking_mode: executeDialogMessage.thinking_mode || "normal",
|
||||
action_taken: null,
|
||||
signal_id: result.signal_id ?? null,
|
||||
reply_required: Boolean(result.direct_reply_required),
|
||||
reply_text: result.direct_reply_text ?? result.followup_reply ?? null,
|
||||
trace_id: result.ai_trace_id ?? null,
|
||||
processing_stage: "execute_handoff",
|
||||
consultant_dispatch: normalizeConsultantDispatch(result.consultant_dispatch),
|
||||
});
|
||||
setMode("chat");
|
||||
setExecuteDialogMessage(null);
|
||||
} catch (error) {
|
||||
const message = normalizeRequestError(error, "执行交接失败,请稍后重试。");
|
||||
setAskError(message);
|
||||
} finally {
|
||||
setActionBusyKey(null);
|
||||
}
|
||||
};
|
||||
|
||||
const grouped = useMemo(() => groupSessions(sessions), [sessions]);
|
||||
const requestLooksLikeWrite = useMemo(() => looksLikeRequirementWrite(question), [question]);
|
||||
const displayMessages = useMemo(() => {
|
||||
|
|
@ -831,11 +910,9 @@ export function FloatingQAChat() {
|
|||
const actionBadge = getActionBadge(msg.action_taken);
|
||||
const stageBadge = getStageBadge(msg);
|
||||
const consultantDispatch = msg.consultant_dispatch ?? null;
|
||||
const consultantModeBadge = getConsultantModeBadge(consultantDispatch);
|
||||
const consultantModePreference = normalizeConsultantModePreference(consultantDispatch?.mode);
|
||||
const consultantRiskBadge = getConsultantRiskBadge(consultantDispatch);
|
||||
const consultantMissingFields = consultantDispatch?.missing_fields?.length
|
||||
? consultantDispatch.missing_fields.join(" / ")
|
||||
: "";
|
||||
const consultantMissingFields = consultantDispatch?.missing_fields?.filter(Boolean) ?? [];
|
||||
return (
|
||||
<div key={msg.id} className="space-y-2">
|
||||
<div className="flex justify-end">
|
||||
|
|
@ -846,7 +923,7 @@ export function FloatingQAChat() {
|
|||
|
||||
<div className="flex justify-start">
|
||||
<div className="max-w-[85%] rounded-xl bg-slate-100 px-3 py-2 text-sm text-slate-900">
|
||||
<div className="whitespace-pre-wrap">{msg.answer}</div>
|
||||
<QAMarkdownContent content={msg.answer} />
|
||||
<div className="mt-2 flex flex-wrap items-center gap-2 text-[11px] text-slate-600">
|
||||
<span>
|
||||
来源:{msg.sources?.length ? msg.sources.map((s) => s.label).join(" / ") : "无"}
|
||||
|
|
@ -858,28 +935,107 @@ export function FloatingQAChat() {
|
|||
{actionBadge ? <Badge variant={actionBadge.variant}>{actionBadge.label}</Badge> : null}
|
||||
{msg.signal_id ? <Badge variant="outline">signal #{msg.signal_id}</Badge> : null}
|
||||
{stageBadge ? <Badge variant="outline">{stageBadge}</Badge> : null}
|
||||
<Badge variant="secondary">{msg.thinking_mode === "deep" ? "深入思考" : "标准回答"}</Badge>
|
||||
{consultantModeBadge ? (
|
||||
<Badge variant={consultantModeBadge.variant}>{consultantModeBadge.label}</Badge>
|
||||
) : null}
|
||||
{consultantRiskBadge ? (
|
||||
<Badge variant={consultantRiskBadge.variant}>{consultantRiskBadge.label}</Badge>
|
||||
) : null}
|
||||
{consultantDispatch ? (
|
||||
<Badge variant={consultantDispatch.allow_execute ? "secondary" : "outline"}>
|
||||
{consultantDispatch.allow_execute ? "允许执行" : "先别执行"}
|
||||
</Badge>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="mt-3 rounded-2xl border border-slate-200/80 bg-slate-50/90 p-2">
|
||||
<div className="mb-2 text-[11px] font-medium text-slate-500">顾问动作</div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className={getActionChipClasses("deep", msg.thinking_mode === "deep")}
|
||||
disabled={asking || actionBusyKey === `deep-${msg.id}`}
|
||||
onClick={() => handleDeepRerun(msg)}
|
||||
>
|
||||
{actionBusyKey === `deep-${msg.id}` ? "处理中..." : "深入思考"}
|
||||
</Button>
|
||||
{CONSULTANT_MODE_ACTIONS.map((item) => {
|
||||
const busyKey = `mode-${msg.id}-${item.value}`;
|
||||
return (
|
||||
<Button
|
||||
key={`${msg.id}-${item.value}`}
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className={getActionChipClasses(item.tone, consultantModePreference === item.value)}
|
||||
disabled={asking || actionBusyKey === busyKey}
|
||||
onClick={() => handleConsultantModeRerun(msg, item.value)}
|
||||
>
|
||||
{actionBusyKey === busyKey ? "处理中..." : item.label}
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
{consultantRiskBadge && consultantDispatch ? (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className={getActionChipClasses(
|
||||
consultantRiskBadge.label === "高风险"
|
||||
? "risk-high"
|
||||
: consultantRiskBadge.label === "中风险"
|
||||
? "risk-medium"
|
||||
: "risk-low",
|
||||
)}
|
||||
onClick={() => handleToggleRiskExplanation(msg.id)}
|
||||
>
|
||||
{consultantRiskBadge.label}
|
||||
</Button>
|
||||
) : null}
|
||||
{consultantDispatch?.allow_execute ? (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className={getActionChipClasses("execute")}
|
||||
disabled={Boolean(actionBusyKey)}
|
||||
onClick={() => setExecuteDialogMessage(msg)}
|
||||
>
|
||||
允许执行
|
||||
</Button>
|
||||
) : null}
|
||||
{consultantDispatch?.next_action ? (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className={getActionChipClasses("next")}
|
||||
onClick={() => handleUseNextAction(consultantDispatch)}
|
||||
>
|
||||
先别执行
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
{consultantDispatch && expandedRiskMessageId === msg.id ? (
|
||||
<div className="mt-3 rounded-md border border-amber-200 bg-amber-50/80 px-3 py-2 text-xs leading-6 text-amber-900">
|
||||
{buildRiskExplanation(consultantDispatch)}
|
||||
</div>
|
||||
) : null}
|
||||
{consultantDispatch ? (
|
||||
<div className="mt-3 rounded-md border border-slate-200 bg-white/80 px-3 py-2 text-xs text-slate-700">
|
||||
{consultantMissingFields ? (
|
||||
<div>缺失信息:{consultantMissingFields}</div>
|
||||
<div className="mt-3 overflow-hidden rounded-xl border border-slate-200 bg-white/90">
|
||||
{consultantMissingFields.length ? (
|
||||
<div className="border-b border-slate-200/80 px-3 py-3">
|
||||
<div className="text-[11px] font-medium uppercase tracking-[0.08em] text-slate-500">
|
||||
缺失信息
|
||||
</div>
|
||||
<div className="mt-2 flex flex-wrap gap-1.5">
|
||||
{consultantMissingFields.map((field) => (
|
||||
<span
|
||||
key={`${msg.id}-${field}`}
|
||||
className="rounded-full border border-amber-200 bg-amber-50 px-2.5 py-1 text-[11px] text-amber-800"
|
||||
>
|
||||
{field}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
{consultantDispatch.next_action ? (
|
||||
<div className={consultantMissingFields ? "mt-2" : undefined}>
|
||||
<span className="font-medium">下一步:</span>
|
||||
{consultantDispatch.next_action}
|
||||
<div className="px-3 py-3">
|
||||
<div className="text-[11px] font-medium uppercase tracking-[0.08em] text-slate-500">
|
||||
下一步建议
|
||||
</div>
|
||||
<QAMarkdownContent
|
||||
content={consultantDispatch.next_action}
|
||||
compact
|
||||
className="mt-2 text-xs leading-6 text-slate-800"
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
|
@ -1046,6 +1202,7 @@ export function FloatingQAChat() {
|
|||
/>
|
||||
</div>
|
||||
<Textarea
|
||||
ref={questionInputRef}
|
||||
rows={2}
|
||||
placeholder="输入问题..."
|
||||
value={question}
|
||||
|
|
@ -1091,6 +1248,28 @@ export function FloatingQAChat() {
|
|||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Dialog open={Boolean(executeDialogMessage)} onOpenChange={(openValue) => (!openValue ? setExecuteDialogMessage(null) : undefined)}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>确认交接执行</DialogTitle>
|
||||
<DialogDescription>
|
||||
这一步会把当前问题交给 AI PMO Kernel,由统一语义规划和执行门禁继续处理,而不是继续停留在 RAG 问答链路里。
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="rounded-lg border bg-slate-50 px-3 py-3 text-sm leading-6 text-slate-700 [overflow-wrap:anywhere]">
|
||||
{executeDialogMessage?.question}
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="ghost" onClick={() => setExecuteDialogMessage(null)} disabled={actionBusyKey?.startsWith("execute-")}>
|
||||
取消
|
||||
</Button>
|
||||
<Button onClick={handleExecuteViaKernel} disabled={actionBusyKey?.startsWith("execute-")}>
|
||||
{actionBusyKey?.startsWith("execute-") ? "执行中..." : "继续执行"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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