From 9ab2e14838de26775939e331703f12414be5a118 Mon Sep 17 00:00:00 2001 From: Yuuuu <1569752928@qq.com> Date: Wed, 1 Apr 2026 08:50:39 +0800 Subject: [PATCH] Refine AI PMO grounding runtime and consultant flow --- CLAUDE.md | 24 +- .../20260331_add_qa_record_runtime_fields.py | 83 + backend/app/api/rag_api/routes_query.py | 13 +- backend/app/api/rag_ask_contract.py | 39 +- backend/app/models/qa_record.py | 8 + backend/app/schemas/rag.py | 21 + .../mixin_answer_entry.py | 19 +- .../mixin_answer_mainline.py | 102 +- .../mixin_answer_payload.py | 234 ++- .../mixin_answer_precheck.py | 78 + .../mixin_answer_retrieval.py | 100 +- .../adaptive_rag_service/mixin_core_intent.py | 94 ++ .../mixin_deep_guard_answer.py | 115 +- .../mixin_fast_answer_semantic_p1.py | 1 + .../mixin_fast_answer_semantic_p2.py | 28 + .../mixin_requirement_ack_flow.py | 25 +- .../adaptive_rag_service/shared_part_02.py | 33 + .../ai_pmo_typed_executor_common.py | 1 + .../mixin_execute_query_project.py | 14 + .../services/ai_pmo/consultant_dispatch.py | 85 +- .../app/services/feedback_center/mappers.py | 10 + .../feedback_center/reason_catalog.py | 72 + .../mixin_prompt_profile_runtime.py | 1 + ...adaptive_rag_external_followup_contract.py | 5 + .../test_adaptive_rag_mainline_contract.py | 108 +- backend/tests/test_ai_pmo_ingress_mainline.py | 89 + .../tests/test_ai_pmo_qa_http_mainline_e2e.py | 23 +- backend/tests/test_feedback_center_service.py | 32 +- ...test_qa_record_runtime_fields_migration.py | 93 + backend/tests/test_rag_route_contract.py | 166 +- backend/tests/test_reply_iteration_api.py | 5 + backend/tests/test_reply_iteration_service.py | 5 + frontend/package-lock.json | 1504 ++++++++++++++++- frontend/package.json | 3 + frontend/src/api/aiPmo.ts | 26 + frontend/src/api/aiSteward.ts | 155 ++ .../components/ai-steward/FloatingQAChat.tsx | 617 ++++--- .../ai-steward/QAMarkdownContent.tsx | 73 + frontend/tsconfig.tsbuildinfo | 2 +- 39 files changed, 3792 insertions(+), 314 deletions(-) create mode 100644 backend/alembic/versions/20260331_add_qa_record_runtime_fields.py create mode 100644 backend/tests/test_qa_record_runtime_fields_migration.py create mode 100644 frontend/src/components/ai-steward/QAMarkdownContent.tsx diff --git a/CLAUDE.md b/CLAUDE.md index 3f8813eb..6fdc2760 100644 --- a/CLAUDE.md +++ b/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` 回归契约 diff --git a/backend/alembic/versions/20260331_add_qa_record_runtime_fields.py b/backend/alembic/versions/20260331_add_qa_record_runtime_fields.py new file mode 100644 index 00000000..c60703b4 --- /dev/null +++ b/backend/alembic/versions/20260331_add_qa_record_runtime_fields.py @@ -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) diff --git a/backend/app/api/rag_api/routes_query.py b/backend/app/api/rag_api/routes_query.py index ce88fade..4e00c74d 100644 --- a/backend/app/api/rag_api/routes_query.py +++ b/backend/app/api/rag_api/routes_query.py @@ -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( diff --git a/backend/app/api/rag_ask_contract.py b/backend/app/api/rag_ask_contract.py index 43cab963..e4a2898d 100644 --- a/backend/app/api/rag_ask_contract.py +++ b/backend/app/api/rag_ask_contract.py @@ -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 diff --git a/backend/app/models/qa_record.py b/backend/app/models/qa_record.py index 7d067770..3f3452f5 100644 --- a/backend/app/models/qa_record.py +++ b/backend/app/models/qa_record.py @@ -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) diff --git a/backend/app/schemas/rag.py b/backend/app/schemas/rag.py index cbbb6dd6..30e62762 100644 --- a/backend/app/schemas/rag.py +++ b/backend/app/schemas/rag.py @@ -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) diff --git a/backend/app/services/adaptive_rag_service/mixin_answer_entry.py b/backend/app/services/adaptive_rag_service/mixin_answer_entry.py index 29a08e4a..eeff7449 100644 --- a/backend/app/services/adaptive_rag_service/mixin_answer_entry.py +++ b/backend/app/services/adaptive_rag_service/mixin_answer_entry.py @@ -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, ) diff --git a/backend/app/services/adaptive_rag_service/mixin_answer_mainline.py b/backend/app/services/adaptive_rag_service/mixin_answer_mainline.py index 6004e751..ca604655 100644 --- a/backend/app/services/adaptive_rag_service/mixin_answer_mainline.py +++ b/backend/app/services/adaptive_rag_service/mixin_answer_mainline.py @@ -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, ) diff --git a/backend/app/services/adaptive_rag_service/mixin_answer_payload.py b/backend/app/services/adaptive_rag_service/mixin_answer_payload.py index 42abbfdc..e06a2550 100644 --- a/backend/app/services/adaptive_rag_service/mixin_answer_payload.py +++ b/backend/app/services/adaptive_rag_service/mixin_answer_payload.py @@ -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 diff --git a/backend/app/services/adaptive_rag_service/mixin_answer_precheck.py b/backend/app/services/adaptive_rag_service/mixin_answer_precheck.py index d39d049c..af341cf5 100644 --- a/backend/app/services/adaptive_rag_service/mixin_answer_precheck.py +++ b/backend/app/services/adaptive_rag_service/mixin_answer_precheck.py @@ -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", ) diff --git a/backend/app/services/adaptive_rag_service/mixin_answer_retrieval.py b/backend/app/services/adaptive_rag_service/mixin_answer_retrieval.py index 45ccaf3f..ddeb7dda 100644 --- a/backend/app/services/adaptive_rag_service/mixin_answer_retrieval.py +++ b/backend/app/services/adaptive_rag_service/mixin_answer_retrieval.py @@ -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, ) diff --git a/backend/app/services/adaptive_rag_service/mixin_core_intent.py b/backend/app/services/adaptive_rag_service/mixin_core_intent.py index 36fa074d..8ebb4b33 100644 --- a/backend/app/services/adaptive_rag_service/mixin_core_intent.py +++ b/backend/app/services/adaptive_rag_service/mixin_core_intent.py @@ -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() diff --git a/backend/app/services/adaptive_rag_service/mixin_deep_guard_answer.py b/backend/app/services/adaptive_rag_service/mixin_deep_guard_answer.py index c3970309..741681ae 100644 --- a/backend/app/services/adaptive_rag_service/mixin_deep_guard_answer.py +++ b/backend/app/services/adaptive_rag_service/mixin_deep_guard_answer.py @@ -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, diff --git a/backend/app/services/adaptive_rag_service/mixin_fast_answer_semantic_p1.py b/backend/app/services/adaptive_rag_service/mixin_fast_answer_semantic_p1.py index 366002a3..772a2454 100644 --- a/backend/app/services/adaptive_rag_service/mixin_fast_answer_semantic_p1.py +++ b/backend/app/services/adaptive_rag_service/mixin_fast_answer_semantic_p1.py @@ -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 diff --git a/backend/app/services/adaptive_rag_service/mixin_fast_answer_semantic_p2.py b/backend/app/services/adaptive_rag_service/mixin_fast_answer_semantic_p2.py index 8750815f..c8830935 100644 --- a/backend/app/services/adaptive_rag_service/mixin_fast_answer_semantic_p2.py +++ b/backend/app/services/adaptive_rag_service/mixin_fast_answer_semantic_p2.py @@ -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, ) diff --git a/backend/app/services/adaptive_rag_service/mixin_requirement_ack_flow.py b/backend/app/services/adaptive_rag_service/mixin_requirement_ack_flow.py index dc046fdc..c99a7d80 100644 --- a/backend/app/services/adaptive_rag_service/mixin_requirement_ack_flow.py +++ b/backend/app/services/adaptive_rag_service/mixin_requirement_ack_flow.py @@ -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", ) diff --git a/backend/app/services/adaptive_rag_service/shared_part_02.py b/backend/app/services/adaptive_rag_service/shared_part_02.py index f9179382..f4ac6a25 100644 --- a/backend/app/services/adaptive_rag_service/shared_part_02.py +++ b/backend/app/services/adaptive_rag_service/shared_part_02.py @@ -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 diff --git a/backend/app/services/ai_pmo/ai_pmo_kernel/ai_pmo_typed_executor_common.py b/backend/app/services/ai_pmo/ai_pmo_kernel/ai_pmo_typed_executor_common.py index a0ec0dcf..e8fa28e8 100644 --- a/backend/app/services/ai_pmo/ai_pmo_kernel/ai_pmo_typed_executor_common.py +++ b/backend/app/services/ai_pmo/ai_pmo_kernel/ai_pmo_typed_executor_common.py @@ -4,6 +4,7 @@ from __future__ import annotations import hashlib import json +import re from datetime import timedelta from typing import Any, Optional diff --git a/backend/app/services/ai_pmo/ai_pmo_typed_executor_execute/mixin_execute_query_project.py b/backend/app/services/ai_pmo/ai_pmo_typed_executor_execute/mixin_execute_query_project.py index 93c402bd..06263ab9 100644 --- a/backend/app/services/ai_pmo/ai_pmo_typed_executor_execute/mixin_execute_query_project.py +++ b/backend/app/services/ai_pmo/ai_pmo_typed_executor_execute/mixin_execute_query_project.py @@ -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), diff --git a/backend/app/services/ai_pmo/consultant_dispatch.py b/backend/app/services/ai_pmo/consultant_dispatch.py index 64facc1e..77e7f709 100644 --- a/backend/app/services/ai_pmo/consultant_dispatch.py +++ b/backend/app/services/ai_pmo/consultant_dispatch.py @@ -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( diff --git a/backend/app/services/feedback_center/mappers.py b/backend/app/services/feedback_center/mappers.py index 318321ed..d61c28e9 100644 --- a/backend/app/services/feedback_center/mappers.py +++ b/backend/app/services/feedback_center/mappers.py @@ -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, diff --git a/backend/app/services/feedback_center/reason_catalog.py b/backend/app/services/feedback_center/reason_catalog.py index 39e19d1f..27e86f93 100644 --- a/backend/app/services/feedback_center/reason_catalog.py +++ b/backend/app/services/feedback_center/reason_catalog.py @@ -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": diff --git a/backend/app/services/llm_service/mixin_prompt_profile_runtime.py b/backend/app/services/llm_service/mixin_prompt_profile_runtime.py index c9c45fe2..745c92dd 100644 --- a/backend/app/services/llm_service/mixin_prompt_profile_runtime.py +++ b/backend/app/services/llm_service/mixin_prompt_profile_runtime.py @@ -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): diff --git a/backend/tests/test_adaptive_rag_external_followup_contract.py b/backend/tests/test_adaptive_rag_external_followup_contract.py index 94aafb64..e05e3dcd 100644 --- a/backend/tests/test_adaptive_rag_external_followup_contract.py +++ b/backend/tests/test_adaptive_rag_external_followup_contract.py @@ -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", diff --git a/backend/tests/test_adaptive_rag_mainline_contract.py b/backend/tests/test_adaptive_rag_mainline_contract.py index 6ea61237..1ff08d08 100644 --- a/backend/tests/test_adaptive_rag_mainline_contract.py +++ b/backend/tests/test_adaptive_rag_mainline_contract.py @@ -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 diff --git a/backend/tests/test_ai_pmo_ingress_mainline.py b/backend/tests/test_ai_pmo_ingress_mainline.py index 1c8b34bc..874c9c09 100644 --- a/backend/tests/test_ai_pmo_ingress_mainline.py +++ b/backend/tests/test_ai_pmo_ingress_mainline.py @@ -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": []} diff --git a/backend/tests/test_ai_pmo_qa_http_mainline_e2e.py b/backend/tests/test_ai_pmo_qa_http_mainline_e2e.py index 2352ffc8..c5789f07 100644 --- a/backend/tests/test_ai_pmo_qa_http_mainline_e2e.py +++ b/backend/tests/test_ai_pmo_qa_http_mainline_e2e.py @@ -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" diff --git a/backend/tests/test_feedback_center_service.py b/backend/tests/test_feedback_center_service.py index 996f0d52..bb2d9f69 100644 --- a/backend/tests/test_feedback_center_service.py +++ b/backend/tests/test_feedback_center_service.py @@ -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( diff --git a/backend/tests/test_qa_record_runtime_fields_migration.py b/backend/tests/test_qa_record_runtime_fields_migration.py new file mode 100644 index 00000000..cf7cd4b2 --- /dev/null +++ b/backend/tests/test_qa_record_runtime_fields_migration.py @@ -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 diff --git a/backend/tests/test_rag_route_contract.py b/backend/tests/test_rag_route_contract.py index d048c886..478d257a 100644 --- a/backend/tests/test_rag_route_contract.py +++ b/backend/tests/test_rag_route_contract.py @@ -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, + } diff --git a/backend/tests/test_reply_iteration_api.py b/backend/tests/test_reply_iteration_api.py index 03792b21..95d87d5a 100644 --- a/backend/tests/test_reply_iteration_api.py +++ b/backend/tests/test_reply_iteration_api.py @@ -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", diff --git a/backend/tests/test_reply_iteration_service.py b/backend/tests/test_reply_iteration_service.py index bceb58b6..e67e3d48 100644 --- a/backend/tests/test_reply_iteration_service.py +++ b/backend/tests/test_reply_iteration_service.py @@ -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", diff --git a/frontend/package-lock.json b/frontend/package-lock.json index cf82656b..c0878099 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -21,8 +21,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" @@ -2107,11 +2110,52 @@ "integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==", "license": "MIT" }, + "node_modules/@types/debug": { + "version": "4.1.13", + "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.13.tgz", + "integrity": "sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw==", + "license": "MIT", + "dependencies": { + "@types/ms": "*" + } + }, "node_modules/@types/estree": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", - "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree-jsx": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/@types/estree-jsx/-/estree-jsx-1.0.5.tgz", + "integrity": "sha512-52CcUVNFyfb1A2ALocQw/Dd1BQFNmSdkuC3BkZ6iqhdMfQz7JWOFRuJFloOzjk+6WijU56m9oKXFAXc7o3Towg==", + "license": "MIT", + "dependencies": { + "@types/estree": "*" + } + }, + "node_modules/@types/hast": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/hast/-/hast-3.0.4.tgz", + "integrity": "sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==", + "license": "MIT", + "dependencies": { + "@types/unist": "*" + } + }, + "node_modules/@types/mdast": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/@types/mdast/-/mdast-4.0.4.tgz", + "integrity": "sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==", + "license": "MIT", + "dependencies": { + "@types/unist": "*" + } + }, + "node_modules/@types/ms": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz", + "integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==", "license": "MIT" }, "node_modules/@types/node": { @@ -2128,14 +2172,12 @@ "version": "15.7.15", "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==", - "devOptional": true, "license": "MIT" }, "node_modules/@types/react": { "version": "18.3.28", "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.28.tgz", "integrity": "sha512-z9VXpC7MWrhfWipitjNdgCauoMLRdIILQsAEV+ZesIzBq/oUlxk0m3ApZuMFCXdnS4U7KrI+l3WRUEGQ8K1QKw==", - "devOptional": true, "license": "MIT", "dependencies": { "@types/prop-types": "*", @@ -2152,6 +2194,18 @@ "@types/react": "^18.0.0" } }, + "node_modules/@types/unist": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz", + "integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==", + "license": "MIT" + }, + "node_modules/@ungap/structured-clone": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.0.tgz", + "integrity": "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==", + "license": "ISC" + }, "node_modules/@vitejs/plugin-react": { "version": "4.7.0", "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz", @@ -2309,6 +2363,16 @@ "proxy-from-env": "^1.1.0" } }, + "node_modules/bail": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/bail/-/bail-2.0.2.tgz", + "integrity": "sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/baseline-browser-mapping": { "version": "2.10.0", "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.0.tgz", @@ -2423,6 +2487,16 @@ ], "license": "CC-BY-4.0" }, + "node_modules/ccount": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/ccount/-/ccount-2.0.1.tgz", + "integrity": "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/chalk": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", @@ -2453,6 +2527,46 @@ "node": ">=8" } }, + "node_modules/character-entities": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/character-entities/-/character-entities-2.0.2.tgz", + "integrity": "sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-entities-html4": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/character-entities-html4/-/character-entities-html4-2.1.0.tgz", + "integrity": "sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-entities-legacy": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/character-entities-legacy/-/character-entities-legacy-3.0.0.tgz", + "integrity": "sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-reference-invalid": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/character-reference-invalid/-/character-reference-invalid-2.0.1.tgz", + "integrity": "sha512-iBZ4F4wRbyORVsu0jPV7gXkOsGYjGHPmAyv+HiHG8gi5PtC9KI2j1+v8/tlibRvjoWX027ypmG/n0HtO5t7unw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/chokidar": { "version": "3.6.0", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", @@ -2557,6 +2671,16 @@ "node": ">= 0.8" } }, + "node_modules/comma-separated-tokens": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/comma-separated-tokens/-/comma-separated-tokens-2.0.3.tgz", + "integrity": "sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/commander": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz", @@ -2741,7 +2865,6 @@ "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", - "dev": true, "license": "MIT", "dependencies": { "ms": "^2.1.3" @@ -2761,6 +2884,19 @@ "integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==", "license": "MIT" }, + "node_modules/decode-named-character-reference": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/decode-named-character-reference/-/decode-named-character-reference-1.3.0.tgz", + "integrity": "sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q==", + "license": "MIT", + "dependencies": { + "character-entities": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/delayed-stream": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", @@ -2770,6 +2906,15 @@ "node": ">=0.4.0" } }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/detect-libc": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", @@ -2788,6 +2933,19 @@ "integrity": "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==", "license": "MIT" }, + "node_modules/devlop": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/devlop/-/devlop-1.1.0.tgz", + "integrity": "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==", + "license": "MIT", + "dependencies": { + "dequal": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/didyoumean": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz", @@ -2932,12 +3090,40 @@ "node": ">=6" } }, + "node_modules/escape-string-regexp": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-5.0.0.tgz", + "integrity": "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/estree-util-is-identifier-name": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/estree-util-is-identifier-name/-/estree-util-is-identifier-name-3.0.0.tgz", + "integrity": "sha512-hFtqIDZTIUZ9BXLb8y4pYGyk6+wekIivNVTcmvk8NoOh+VeRn5y6cEHzbURrWbfp1fIqdVipilzj+lfaadNZmg==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/eventemitter3": { "version": "4.0.7", "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz", "integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==", "license": "MIT" }, + "node_modules/extend": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", + "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", + "license": "MIT" + }, "node_modules/fast-equals": { "version": "5.4.0", "resolved": "https://registry.npmjs.org/fast-equals/-/fast-equals-5.4.0.tgz", @@ -3225,6 +3411,62 @@ "node": ">= 0.4" } }, + "node_modules/hast-util-to-jsx-runtime": { + "version": "2.3.6", + "resolved": "https://registry.npmjs.org/hast-util-to-jsx-runtime/-/hast-util-to-jsx-runtime-2.3.6.tgz", + "integrity": "sha512-zl6s8LwNyo1P9uw+XJGvZtdFF1GdAkOg8ujOw+4Pyb76874fLps4ueHXDhXWdk6YHQ6OgUtinliG7RsYvCbbBg==", + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/unist": "^3.0.0", + "comma-separated-tokens": "^2.0.0", + "devlop": "^1.0.0", + "estree-util-is-identifier-name": "^3.0.0", + "hast-util-whitespace": "^3.0.0", + "mdast-util-mdx-expression": "^2.0.0", + "mdast-util-mdx-jsx": "^3.0.0", + "mdast-util-mdxjs-esm": "^2.0.0", + "property-information": "^7.0.0", + "space-separated-tokens": "^2.0.0", + "style-to-js": "^1.0.0", + "unist-util-position": "^5.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-whitespace": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/hast-util-whitespace/-/hast-util-whitespace-3.0.0.tgz", + "integrity": "sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/html-url-attributes": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/html-url-attributes/-/html-url-attributes-3.0.1.tgz", + "integrity": "sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/inline-style-parser": { + "version": "0.2.7", + "resolved": "https://registry.npmjs.org/inline-style-parser/-/inline-style-parser-0.2.7.tgz", + "integrity": "sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA==", + "license": "MIT" + }, "node_modules/internmap": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz", @@ -3234,6 +3476,30 @@ "node": ">=12" } }, + "node_modules/is-alphabetical": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-alphabetical/-/is-alphabetical-2.0.1.tgz", + "integrity": "sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/is-alphanumerical": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-alphanumerical/-/is-alphanumerical-2.0.1.tgz", + "integrity": "sha512-hmbYhX/9MUMF5uh7tOXyK/n0ZvWpad5caBA17GsC6vyuCqaWliRG5K1qS9inmUhEMaOBIW7/whAnSwveW/LtZw==", + "license": "MIT", + "dependencies": { + "is-alphabetical": "^2.0.0", + "is-decimal": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/is-binary-path": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", @@ -3261,6 +3527,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/is-decimal": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-decimal/-/is-decimal-2.0.1.tgz", + "integrity": "sha512-AAB9hiomQs5DXWcRB1rqsxGUstbRroFOPPVAomNk/3XHR5JyEZChOyTWe2oayKnsSsr/kcGqF+z6yuH6HHpN0A==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/is-extglob": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", @@ -3292,6 +3568,16 @@ "node": ">=0.10.0" } }, + "node_modules/is-hexadecimal": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-hexadecimal/-/is-hexadecimal-2.0.1.tgz", + "integrity": "sha512-DgZQp241c8oO6cA1SbTEWiXeoxV42vlcJxgH+B3hi1AiqqKruZR3ZGF8In3fj4+/y/7rHvlOZLZtgJ/4ttYGZg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/is-number": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", @@ -3301,6 +3587,18 @@ "node": ">=0.12.0" } }, + "node_modules/is-plain-obj": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-4.1.0.tgz", + "integrity": "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/jiti": { "version": "1.21.7", "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz", @@ -3640,6 +3938,16 @@ "integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==", "license": "MIT" }, + "node_modules/longest-streak": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/longest-streak/-/longest-streak-3.1.0.tgz", + "integrity": "sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/loose-envify": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", @@ -3671,6 +3979,16 @@ "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0-rc" } }, + "node_modules/markdown-table": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/markdown-table/-/markdown-table-3.0.4.tgz", + "integrity": "sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/math-intrinsics": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", @@ -3680,6 +3998,290 @@ "node": ">= 0.4" } }, + "node_modules/mdast-util-find-and-replace": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mdast-util-find-and-replace/-/mdast-util-find-and-replace-3.0.2.tgz", + "integrity": "sha512-Tmd1Vg/m3Xz43afeNxDIhWRtFZgM2VLyaf4vSTYwudTyeuTneoL3qtWMA5jeLyz/O1vDJmmV4QuScFCA2tBPwg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "escape-string-regexp": "^5.0.0", + "unist-util-is": "^6.0.0", + "unist-util-visit-parents": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-from-markdown": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/mdast-util-from-markdown/-/mdast-util-from-markdown-2.0.3.tgz", + "integrity": "sha512-W4mAWTvSlKvf8L6J+VN9yLSqQ9AOAAvHuoDAmPkz4dHf553m5gVj2ejadHJhoJmcmxEnOv6Pa8XJhpxE93kb8Q==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "@types/unist": "^3.0.0", + "decode-named-character-reference": "^1.0.0", + "devlop": "^1.0.0", + "mdast-util-to-string": "^4.0.0", + "micromark": "^4.0.0", + "micromark-util-decode-numeric-character-reference": "^2.0.0", + "micromark-util-decode-string": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0", + "unist-util-stringify-position": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm/-/mdast-util-gfm-3.1.0.tgz", + "integrity": "sha512-0ulfdQOM3ysHhCJ1p06l0b0VKlhU0wuQs3thxZQagjcjPrlFRqY215uZGHHJan9GEAXd9MbfPjFJz+qMkVR6zQ==", + "license": "MIT", + "dependencies": { + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-gfm-autolink-literal": "^2.0.0", + "mdast-util-gfm-footnote": "^2.0.0", + "mdast-util-gfm-strikethrough": "^2.0.0", + "mdast-util-gfm-table": "^2.0.0", + "mdast-util-gfm-task-list-item": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-autolink-literal": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-autolink-literal/-/mdast-util-gfm-autolink-literal-2.0.1.tgz", + "integrity": "sha512-5HVP2MKaP6L+G6YaxPNjuL0BPrq9orG3TsrZ9YXbA3vDw/ACI4MEsnoDpn6ZNm7GnZgtAcONJyPhOP8tNJQavQ==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "ccount": "^2.0.0", + "devlop": "^1.0.0", + "mdast-util-find-and-replace": "^3.0.0", + "micromark-util-character": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-footnote": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-footnote/-/mdast-util-gfm-footnote-2.1.0.tgz", + "integrity": "sha512-sqpDWlsHn7Ac9GNZQMeUzPQSMzR6Wv0WKRNvQRg0KqHh02fpTz69Qc1QSseNX29bhz1ROIyNyxExfawVKTm1GQ==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "devlop": "^1.1.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-strikethrough": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-strikethrough/-/mdast-util-gfm-strikethrough-2.0.0.tgz", + "integrity": "sha512-mKKb915TF+OC5ptj5bJ7WFRPdYtuHv0yTRxK2tJvi+BDqbkiG7h7u/9SI89nRAYcmap2xHQL9D+QG/6wSrTtXg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-table": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-table/-/mdast-util-gfm-table-2.0.0.tgz", + "integrity": "sha512-78UEvebzz/rJIxLvE7ZtDd/vIQ0RHv+3Mh5DR96p7cS7HsBhYIICDBCu8csTNWNO6tBWfqXPWekRuj2FNOGOZg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "markdown-table": "^3.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-task-list-item": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-task-list-item/-/mdast-util-gfm-task-list-item-2.0.0.tgz", + "integrity": "sha512-IrtvNvjxC1o06taBAVJznEnkiHxLFTzgonUdy8hzFVeDun0uTjxxrRGVaNFqkU1wJR3RBPEfsxmU6jDWPofrTQ==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-mdx-expression": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mdast-util-mdx-expression/-/mdast-util-mdx-expression-2.0.1.tgz", + "integrity": "sha512-J6f+9hUp+ldTZqKRSg7Vw5V6MqjATc+3E4gf3CFNcuZNWD8XdyI6zQ8GqH7f8169MM6P7hMBRDVGnn7oHB9kXQ==", + "license": "MIT", + "dependencies": { + "@types/estree-jsx": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-mdx-jsx": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/mdast-util-mdx-jsx/-/mdast-util-mdx-jsx-3.2.0.tgz", + "integrity": "sha512-lj/z8v0r6ZtsN/cGNNtemmmfoLAFZnjMbNyLzBafjzikOM+glrjNHPlf6lQDOTccj9n5b0PPihEBbhneMyGs1Q==", + "license": "MIT", + "dependencies": { + "@types/estree-jsx": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "@types/unist": "^3.0.0", + "ccount": "^2.0.0", + "devlop": "^1.1.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0", + "parse-entities": "^4.0.0", + "stringify-entities": "^4.0.0", + "unist-util-stringify-position": "^4.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-mdxjs-esm": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mdast-util-mdxjs-esm/-/mdast-util-mdxjs-esm-2.0.1.tgz", + "integrity": "sha512-EcmOpxsZ96CvlP03NghtH1EsLtr0n9Tm4lPUJUBccV9RwUOneqSycg19n5HGzCf+10LozMRSObtVr3ee1WoHtg==", + "license": "MIT", + "dependencies": { + "@types/estree-jsx": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-newline-to-break": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-newline-to-break/-/mdast-util-newline-to-break-2.0.0.tgz", + "integrity": "sha512-MbgeFca0hLYIEx/2zGsszCSEJJ1JSCdiY5xQxRcLDDGa8EPvlLPupJ4DSajbMPAnC0je8jfb9TiUATnxxrHUog==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-find-and-replace": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-phrasing": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/mdast-util-phrasing/-/mdast-util-phrasing-4.1.0.tgz", + "integrity": "sha512-TqICwyvJJpBwvGAMZjj4J2n0X8QWp21b9l0o7eXyVJ25YNWYbJDVIyD1bZXE6WtV6RmKJVYmQAKWa0zWOABz2w==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "unist-util-is": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-to-hast": { + "version": "13.2.1", + "resolved": "https://registry.npmjs.org/mdast-util-to-hast/-/mdast-util-to-hast-13.2.1.tgz", + "integrity": "sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "@ungap/structured-clone": "^1.0.0", + "devlop": "^1.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "trim-lines": "^3.0.0", + "unist-util-position": "^5.0.0", + "unist-util-visit": "^5.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-to-markdown": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/mdast-util-to-markdown/-/mdast-util-to-markdown-2.1.2.tgz", + "integrity": "sha512-xj68wMTvGXVOKonmog6LwyJKrYXZPvlwabaryTjLh9LuvovB/KAH+kvi8Gjj+7rJjsFi23nkUxRQv1KqSroMqA==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "@types/unist": "^3.0.0", + "longest-streak": "^3.0.0", + "mdast-util-phrasing": "^4.0.0", + "mdast-util-to-string": "^4.0.0", + "micromark-util-classify-character": "^2.0.0", + "micromark-util-decode-string": "^2.0.0", + "unist-util-visit": "^5.0.0", + "zwitch": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-to-string": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-to-string/-/mdast-util-to-string-4.0.0.tgz", + "integrity": "sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/merge2": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", @@ -3689,6 +4291,569 @@ "node": ">= 8" } }, + "node_modules/micromark": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/micromark/-/micromark-4.0.2.tgz", + "integrity": "sha512-zpe98Q6kvavpCr1NPVSCMebCKfD7CA2NqZ+rykeNhONIJBpc1tFKt9hucLGwha3jNTNI8lHpctWJWoimVF4PfA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "@types/debug": "^4.0.0", + "debug": "^4.0.0", + "decode-named-character-reference": "^1.0.0", + "devlop": "^1.0.0", + "micromark-core-commonmark": "^2.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-combine-extensions": "^2.0.0", + "micromark-util-decode-numeric-character-reference": "^2.0.0", + "micromark-util-encode": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-resolve-all": "^2.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "micromark-util-subtokenize": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-core-commonmark": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/micromark-core-commonmark/-/micromark-core-commonmark-2.0.3.tgz", + "integrity": "sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "decode-named-character-reference": "^1.0.0", + "devlop": "^1.0.0", + "micromark-factory-destination": "^2.0.0", + "micromark-factory-label": "^2.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-factory-title": "^2.0.0", + "micromark-factory-whitespace": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-classify-character": "^2.0.0", + "micromark-util-html-tag-name": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-resolve-all": "^2.0.0", + "micromark-util-subtokenize": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-extension-gfm": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm/-/micromark-extension-gfm-3.0.0.tgz", + "integrity": "sha512-vsKArQsicm7t0z2GugkCKtZehqUm31oeGBV/KVSorWSy8ZlNAv7ytjFhvaryUiCUJYqs+NoE6AFhpQvBTM6Q4w==", + "license": "MIT", + "dependencies": { + "micromark-extension-gfm-autolink-literal": "^2.0.0", + "micromark-extension-gfm-footnote": "^2.0.0", + "micromark-extension-gfm-strikethrough": "^2.0.0", + "micromark-extension-gfm-table": "^2.0.0", + "micromark-extension-gfm-tagfilter": "^2.0.0", + "micromark-extension-gfm-task-list-item": "^2.0.0", + "micromark-util-combine-extensions": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-autolink-literal": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-autolink-literal/-/micromark-extension-gfm-autolink-literal-2.1.0.tgz", + "integrity": "sha512-oOg7knzhicgQ3t4QCjCWgTmfNhvQbDDnJeVu9v81r7NltNCVmhPy1fJRX27pISafdjL+SVc4d3l48Gb6pbRypw==", + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-footnote": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-footnote/-/micromark-extension-gfm-footnote-2.1.0.tgz", + "integrity": "sha512-/yPhxI1ntnDNsiHtzLKYnE3vf9JZ6cAisqVDauhp4CEHxlb4uoOTxOCJ+9s51bIB8U1N1FJ1RXOKTIlD5B/gqw==", + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-core-commonmark": "^2.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-strikethrough": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-strikethrough/-/micromark-extension-gfm-strikethrough-2.1.0.tgz", + "integrity": "sha512-ADVjpOOkjz1hhkZLlBiYA9cR2Anf8F4HqZUO6e5eDcPQd0Txw5fxLzzxnEkSkfnD0wziSGiv7sYhk/ktvbf1uw==", + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-classify-character": "^2.0.0", + "micromark-util-resolve-all": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-table": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-table/-/micromark-extension-gfm-table-2.1.1.tgz", + "integrity": "sha512-t2OU/dXXioARrC6yWfJ4hqB7rct14e8f7m0cbI5hUmDyyIlwv5vEtooptH8INkbLzOatzKuVbQmAYcbWoyz6Dg==", + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-tagfilter": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-tagfilter/-/micromark-extension-gfm-tagfilter-2.0.0.tgz", + "integrity": "sha512-xHlTOmuCSotIA8TW1mDIM6X2O1SiX5P9IuDtqGonFhEK0qgRI4yeC6vMxEV2dgyr2TiD+2PQ10o+cOhdVAcwfg==", + "license": "MIT", + "dependencies": { + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-task-list-item": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-task-list-item/-/micromark-extension-gfm-task-list-item-2.1.0.tgz", + "integrity": "sha512-qIBZhqxqI6fjLDYFTBIa4eivDMnP+OZqsNwmQ3xNLE4Cxwc+zfQEfbs6tzAo2Hjq+bh6q5F+Z8/cksrLFYWQQw==", + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-factory-destination": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-destination/-/micromark-factory-destination-2.0.1.tgz", + "integrity": "sha512-Xe6rDdJlkmbFRExpTOmRj9N3MaWmbAgdpSrBQvCFqhezUn4AHqJHbaEnfbVYYiexVSs//tqOdY/DxhjdCiJnIA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-label": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-label/-/micromark-factory-label-2.0.1.tgz", + "integrity": "sha512-VFMekyQExqIW7xIChcXn4ok29YE3rnuyveW3wZQWWqF4Nv9Wk5rgJ99KzPvHjkmPXF93FXIbBp6YdW3t71/7Vg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-space": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-space/-/micromark-factory-space-2.0.1.tgz", + "integrity": "sha512-zRkxjtBxxLd2Sc0d+fbnEunsTj46SWXgXciZmHq0kDYGnck/ZSGj9/wULTV95uoeYiK5hRXP2mJ98Uo4cq/LQg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-title": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-title/-/micromark-factory-title-2.0.1.tgz", + "integrity": "sha512-5bZ+3CjhAd9eChYTHsjy6TGxpOFSKgKKJPJxr293jTbfry2KDoWkhBb6TcPVB4NmzaPhMs1Frm9AZH7OD4Cjzw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-whitespace": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-whitespace/-/micromark-factory-whitespace-2.0.1.tgz", + "integrity": "sha512-Ob0nuZ3PKt/n0hORHyvoD9uZhr+Za8sFoP+OnMcnWK5lngSzALgQYKMr9RJVOWLqQYuyn6ulqGWSXdwf6F80lQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-character": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/micromark-util-character/-/micromark-util-character-2.1.1.tgz", + "integrity": "sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-chunked": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-chunked/-/micromark-util-chunked-2.0.1.tgz", + "integrity": "sha512-QUNFEOPELfmvv+4xiNg2sRYeS/P84pTW0TCgP5zc9FpXetHY0ab7SxKyAQCNCc1eK0459uoLI1y5oO5Vc1dbhA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-classify-character": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-classify-character/-/micromark-util-classify-character-2.0.1.tgz", + "integrity": "sha512-K0kHzM6afW/MbeWYWLjoHQv1sgg2Q9EccHEDzSkxiP/EaagNzCm7T/WMKZ3rjMbvIpvBiZgwR3dKMygtA4mG1Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-combine-extensions": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-combine-extensions/-/micromark-util-combine-extensions-2.0.1.tgz", + "integrity": "sha512-OnAnH8Ujmy59JcyZw8JSbK9cGpdVY44NKgSM7E9Eh7DiLS2E9RNQf0dONaGDzEG9yjEl5hcqeIsj4hfRkLH/Bg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-chunked": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-decode-numeric-character-reference": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/micromark-util-decode-numeric-character-reference/-/micromark-util-decode-numeric-character-reference-2.0.2.tgz", + "integrity": "sha512-ccUbYk6CwVdkmCQMyr64dXz42EfHGkPQlBj5p7YVGzq8I7CtjXZJrubAYezf7Rp+bjPseiROqe7G6foFd+lEuw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-decode-string": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-decode-string/-/micromark-util-decode-string-2.0.1.tgz", + "integrity": "sha512-nDV/77Fj6eH1ynwscYTOsbK7rR//Uj0bZXBwJZRfaLEJ1iGBR6kIfNmlNqaqJf649EP0F3NWNdeJi03elllNUQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "decode-named-character-reference": "^1.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-decode-numeric-character-reference": "^2.0.0", + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-encode": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-encode/-/micromark-util-encode-2.0.1.tgz", + "integrity": "sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-html-tag-name": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-html-tag-name/-/micromark-util-html-tag-name-2.0.1.tgz", + "integrity": "sha512-2cNEiYDhCWKI+Gs9T0Tiysk136SnR13hhO8yW6BGNyhOC4qYFnwF1nKfD3HFAIXA5c45RrIG1ub11GiXeYd1xA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-normalize-identifier": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-normalize-identifier/-/micromark-util-normalize-identifier-2.0.1.tgz", + "integrity": "sha512-sxPqmo70LyARJs0w2UclACPUUEqltCkJ6PhKdMIDuJ3gSf/Q+/GIe3WKl0Ijb/GyH9lOpUkRAO2wp0GVkLvS9Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-resolve-all": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-resolve-all/-/micromark-util-resolve-all-2.0.1.tgz", + "integrity": "sha512-VdQyxFWFT2/FGJgwQnJYbe1jjQoNTS4RjglmSjTUlpUMa95Htx9NHeYW4rGDJzbjvCsl9eLjMQwGeElsqmzcHg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-sanitize-uri": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-sanitize-uri/-/micromark-util-sanitize-uri-2.0.1.tgz", + "integrity": "sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-encode": "^2.0.0", + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-subtokenize": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-util-subtokenize/-/micromark-util-subtokenize-2.1.0.tgz", + "integrity": "sha512-XQLu552iSctvnEcgXw6+Sx75GflAPNED1qx7eBJ+wydBb2KCbRZe+NwvIEEMM83uml1+2WSXpBAcp9IUCgCYWA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-symbol": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-symbol/-/micromark-util-symbol-2.0.1.tgz", + "integrity": "sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-types": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/micromark-util-types/-/micromark-util-types-2.0.2.tgz", + "integrity": "sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, "node_modules/micromatch": { "version": "4.0.8", "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", @@ -3739,7 +4904,6 @@ "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "dev": true, "license": "MIT" }, "node_modules/mz": { @@ -3805,6 +4969,31 @@ "node": ">= 6" } }, + "node_modules/parse-entities": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/parse-entities/-/parse-entities-4.0.2.tgz", + "integrity": "sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw==", + "license": "MIT", + "dependencies": { + "@types/unist": "^2.0.0", + "character-entities-legacy": "^3.0.0", + "character-reference-invalid": "^2.0.0", + "decode-named-character-reference": "^1.0.0", + "is-alphanumerical": "^2.0.0", + "is-decimal": "^2.0.0", + "is-hexadecimal": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/parse-entities/node_modules/@types/unist": { + "version": "2.0.11", + "resolved": "https://registry.npmjs.org/@types/unist/-/unist-2.0.11.tgz", + "integrity": "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==", + "license": "MIT" + }, "node_modules/path-parse": { "version": "1.0.7", "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", @@ -4020,6 +5209,16 @@ "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", "license": "MIT" }, + "node_modules/property-information": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/property-information/-/property-information-7.1.0.tgz", + "integrity": "sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/proxy-from-env": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", @@ -4077,6 +5276,33 @@ "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", "license": "MIT" }, + "node_modules/react-markdown": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/react-markdown/-/react-markdown-10.1.0.tgz", + "integrity": "sha512-qKxVopLT/TyA6BX3Ue5NwabOsAzm0Q7kAPwq6L+wWDwisYs7R8vZ0nRXqq6rkueboxpkjvLGU9fWifiX/ZZFxQ==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "hast-util-to-jsx-runtime": "^2.0.0", + "html-url-attributes": "^3.0.0", + "mdast-util-to-hast": "^13.0.0", + "remark-parse": "^11.0.0", + "remark-rehype": "^11.0.0", + "unified": "^11.0.0", + "unist-util-visit": "^5.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + }, + "peerDependencies": { + "@types/react": ">=18", + "react": ">=18" + } + }, "node_modules/react-refresh": { "version": "0.17.0", "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", @@ -4284,6 +5510,87 @@ "decimal.js-light": "^2.4.1" } }, + "node_modules/remark-breaks": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/remark-breaks/-/remark-breaks-4.0.0.tgz", + "integrity": "sha512-IjEjJOkH4FuJvHZVIW0QCDWxcG96kCq7An/KVH2NfJe6rKZU2AsHeB3OEjPNRxi4QC34Xdx7I2KGYn6IpT7gxQ==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-newline-to-break": "^2.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/remark-gfm": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/remark-gfm/-/remark-gfm-4.0.1.tgz", + "integrity": "sha512-1quofZ2RQ9EWdeN34S79+KExV1764+wCUGop5CPL1WGdD0ocPpu91lzPGbwWMECpEpd42kJGQwzRfyov9j4yNg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-gfm": "^3.0.0", + "micromark-extension-gfm": "^3.0.0", + "remark-parse": "^11.0.0", + "remark-stringify": "^11.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/remark-parse": { + "version": "11.0.0", + "resolved": "https://registry.npmjs.org/remark-parse/-/remark-parse-11.0.0.tgz", + "integrity": "sha512-FCxlKLNGknS5ba/1lmpYijMUzX2esxW5xQqjWxw2eHFfS2MSdaHVINFmhjo+qN1WhZhNimq0dZATN9pH0IDrpA==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-from-markdown": "^2.0.0", + "micromark-util-types": "^2.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/remark-rehype": { + "version": "11.1.2", + "resolved": "https://registry.npmjs.org/remark-rehype/-/remark-rehype-11.1.2.tgz", + "integrity": "sha512-Dh7l57ianaEoIpzbp0PC9UKAdCSVklD8E5Rpw7ETfbTl3FqcOOgq5q2LVDhgGCkaBv7p24JXikPdvhhmHvKMsw==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "mdast-util-to-hast": "^13.0.0", + "unified": "^11.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/remark-stringify": { + "version": "11.0.0", + "resolved": "https://registry.npmjs.org/remark-stringify/-/remark-stringify-11.0.0.tgz", + "integrity": "sha512-1OSmLd3awB/t8qdoEOMazZkNsfVTeY4fTsgzcQFdXNq8ToTN4ZGwrMnlda4K6smTFKD+GRV6O48i6Z4iKgPPpw==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-to-markdown": "^2.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/require-directory": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", @@ -4443,6 +5750,16 @@ "node": ">=0.10.0" } }, + "node_modules/space-separated-tokens": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/space-separated-tokens/-/space-separated-tokens-2.0.2.tgz", + "integrity": "sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/string-width": { "version": "4.2.3", "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", @@ -4458,6 +5775,20 @@ "node": ">=8" } }, + "node_modules/stringify-entities": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/stringify-entities/-/stringify-entities-4.0.4.tgz", + "integrity": "sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==", + "license": "MIT", + "dependencies": { + "character-entities-html4": "^2.0.0", + "character-entities-legacy": "^3.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/strip-ansi": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", @@ -4471,6 +5802,24 @@ "node": ">=8" } }, + "node_modules/style-to-js": { + "version": "1.1.21", + "resolved": "https://registry.npmjs.org/style-to-js/-/style-to-js-1.1.21.tgz", + "integrity": "sha512-RjQetxJrrUJLQPHbLku6U/ocGtzyjbJMP9lCNK7Ag0CNh690nSH8woqWH9u16nMjYBAok+i7JO1NP2pOy8IsPQ==", + "license": "MIT", + "dependencies": { + "style-to-object": "1.0.14" + } + }, + "node_modules/style-to-object": { + "version": "1.0.14", + "resolved": "https://registry.npmjs.org/style-to-object/-/style-to-object-1.0.14.tgz", + "integrity": "sha512-LIN7rULI0jBscWQYaSswptyderlarFkjQ+t79nzty8tcIAceVomEVlLzH5VP4Cmsv6MtKhs7qaAiwlcp+Mgaxw==", + "license": "MIT", + "dependencies": { + "inline-style-parser": "0.2.7" + } + }, "node_modules/sucrase": { "version": "3.35.1", "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.1.tgz", @@ -4642,6 +5991,26 @@ "tree-kill": "cli.js" } }, + "node_modules/trim-lines": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/trim-lines/-/trim-lines-3.0.1.tgz", + "integrity": "sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/trough": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/trough/-/trough-2.2.0.tgz", + "integrity": "sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/ts-interface-checker": { "version": "0.1.13", "resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz", @@ -4675,6 +6044,93 @@ "dev": true, "license": "MIT" }, + "node_modules/unified": { + "version": "11.0.5", + "resolved": "https://registry.npmjs.org/unified/-/unified-11.0.5.tgz", + "integrity": "sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "bail": "^2.0.0", + "devlop": "^1.0.0", + "extend": "^3.0.0", + "is-plain-obj": "^4.0.0", + "trough": "^2.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-is": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/unist-util-is/-/unist-util-is-6.0.1.tgz", + "integrity": "sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-position": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/unist-util-position/-/unist-util-position-5.0.0.tgz", + "integrity": "sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-stringify-position": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/unist-util-stringify-position/-/unist-util-stringify-position-4.0.0.tgz", + "integrity": "sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-visit": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/unist-util-visit/-/unist-util-visit-5.1.0.tgz", + "integrity": "sha512-m+vIdyeCOpdr/QeQCu2EzxX/ohgS8KbnPDgFni4dQsfSCtpz8UqDyY5GjRru8PDKuYn7Fq19j1CQ+nJSsGKOzg==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-is": "^6.0.0", + "unist-util-visit-parents": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-visit-parents": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/unist-util-visit-parents/-/unist-util-visit-parents-6.0.2.tgz", + "integrity": "sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-is": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/update-browserslist-db": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", @@ -4764,6 +6220,34 @@ "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", "license": "MIT" }, + "node_modules/vfile": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/vfile/-/vfile-6.0.3.tgz", + "integrity": "sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/vfile-message": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/vfile-message/-/vfile-message-4.0.3.tgz", + "integrity": "sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-stringify-position": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/victory-vendor": { "version": "36.9.2", "resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-36.9.2.tgz", @@ -4937,6 +6421,16 @@ "optional": true } } + }, + "node_modules/zwitch": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/zwitch/-/zwitch-2.0.4.tgz", + "integrity": "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } } } } diff --git a/frontend/package.json b/frontend/package.json index 695313c3..ef617848 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -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" diff --git a/frontend/src/api/aiPmo.ts b/frontend/src/api/aiPmo.ts index 37bdc66d..1e1b3be5 100644 --- a/frontend/src/api/aiPmo.ts +++ b/frontend/src/api/aiPmo.ts @@ -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 | 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 { + const { data } = await api.post>("/ai-pmo/process-text", body, { + timeout: 15000, + }); + return unwrap(data); + }, async listWorkItems(params?: { limit?: number; state?: string; projectId?: number }): Promise { const { data } = await api.get>("/ai-pmo/work-items", { params: { diff --git a/frontend/src/api/aiSteward.ts b/frontend/src/api/aiSteward.ts index c5b0fe50..312d81d6 100644 --- a/frontend/src/api/aiSteward.ts +++ b/frontend/src/api/aiSteward.ts @@ -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; +} + +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 { + const raw = await apiRequest>("/rag/ask", { + method: "POST", + body: JSON.stringify(body), + }); + return unwrap(raw); + }, + async listQASessions(params?: { page?: number; pageSize?: number }): Promise { + const query = new URLSearchParams(); + query.set("page", String(params?.page ?? 1)); + query.set("page_size", String(params?.pageSize ?? 20)); + const raw = await apiRequest>(`/rag/qa-sessions?${query.toString()}`); + return unwrap(raw); + }, + async getQASession(sessionId: number): Promise { + const raw = await apiRequest>(`/rag/qa-sessions/${sessionId}`); + return unwrap(raw); + }, + async renameQASession(sessionId: number, title: string): Promise<{ title: string }> { + const raw = await apiRequest>(`/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> { + const raw = await apiRequest>>(`/rag/qa-records/${qaRecordId}/feedback`, { + method: "POST", + body: JSON.stringify(body), + }); + return unwrap(raw); + }, async listCognitiveConflicts(projectId?: number): Promise { const query = typeof projectId === "number" ? `?project_id=${projectId}` : ""; return await apiRequest(`/ai-steward/cognitive-conflicts${query}`); diff --git a/frontend/src/components/ai-steward/FloatingQAChat.tsx b/frontend/src/components/ai-steward/FloatingQAChat.tsx index 7d06171f..3616c8c0 100644 --- a/frontend/src/components/ai-steward/FloatingQAChat.tsx +++ b/frontend/src/components/ai-steward/FloatingQAChat.tsx @@ -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 }; 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 = { - 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 = { 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(path: string, init?: RequestInit): Promise { - 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>({}); const [correctAnswer, setCorrectAnswer] = useState>({}); const [submittingFeedbackId, setSubmittingFeedbackId] = useState(null); + const [actionBusyKey, setActionBusyKey] = useState(null); + const [expandedRiskMessageId, setExpandedRiskMessageId] = useState(null); + const [executeDialogMessage, setExecuteDialogMessage] = useState(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(null); const messageEndRef = useRef(null); const forceStickBottomRef = useRef(false); + const questionInputRef = useRef(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) => { + 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(`/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 (
@@ -846,7 +923,7 @@ export function FloatingQAChat() {
-
{msg.answer}
+
来源:{msg.sources?.length ? msg.sources.map((s) => s.label).join(" / ") : "无"} @@ -858,28 +935,107 @@ export function FloatingQAChat() { {actionBadge ? {actionBadge.label} : null} {msg.signal_id ? signal #{msg.signal_id} : null} {stageBadge ? {stageBadge} : null} - {msg.thinking_mode === "deep" ? "深入思考" : "标准回答"} - {consultantModeBadge ? ( - {consultantModeBadge.label} - ) : null} - {consultantRiskBadge ? ( - {consultantRiskBadge.label} - ) : null} - {consultantDispatch ? ( - - {consultantDispatch.allow_execute ? "允许执行" : "先别执行"} - - ) : null}
+
+
顾问动作
+
+ + {CONSULTANT_MODE_ACTIONS.map((item) => { + const busyKey = `mode-${msg.id}-${item.value}`; + return ( + + ); + })} + {consultantRiskBadge && consultantDispatch ? ( + + ) : null} + {consultantDispatch?.allow_execute ? ( + + ) : null} + {consultantDispatch?.next_action ? ( + + ) : null} +
+
+ {consultantDispatch && expandedRiskMessageId === msg.id ? ( +
+ {buildRiskExplanation(consultantDispatch)} +
+ ) : null} {consultantDispatch ? ( -
- {consultantMissingFields ? ( -
缺失信息:{consultantMissingFields}
+
+ {consultantMissingFields.length ? ( +
+
+ 缺失信息 +
+
+ {consultantMissingFields.map((field) => ( + + {field} + + ))} +
+
) : null} {consultantDispatch.next_action ? ( -
- 下一步: - {consultantDispatch.next_action} +
+
+ 下一步建议 +
+
) : null}
@@ -1046,6 +1202,7 @@ export function FloatingQAChat() { />