Create signal sources from project analysis
This commit is contained in:
parent
77a094e363
commit
8b85e19b54
22
CLAUDE.md
22
CLAUDE.md
|
|
@ -142,11 +142,12 @@ backend/
|
||||||
- 新增:`backend/app/api/ai_steward_api/routes_feedback_center.py`(AI Steward 反馈中心接口:summary/list/detail/review/reason-catalog;summary 支持 `window_days` / `source_group` 趋势作用域)
|
- 新增:`backend/app/api/ai_steward_api/routes_feedback_center.py`(AI Steward 反馈中心接口:summary/list/detail/review/reason-catalog;summary 支持 `window_days` / `source_group` 趋势作用域)
|
||||||
- 新增:`backend/app/models/feedback_center.py`(`FeedbackCenterRecord/FeedbackCenterRecordRevision` 统一反馈中心模型)
|
- 新增:`backend/app/models/feedback_center.py`(`FeedbackCenterRecord/FeedbackCenterRecordRevision` 统一反馈中心模型)
|
||||||
- 新增:`backend/app/schemas/feedback_center.py`(反馈中心 summary/list/detail/review/reason-catalog 对应 Pydantic 模型)
|
- 新增:`backend/app/schemas/feedback_center.py`(反馈中心 summary/list/detail/review/reason-catalog 对应 Pydantic 模型)
|
||||||
- 新增:`backend/app/services/feedback_center/`(反馈中心服务层:`service.py/mappers.py/reason_catalog.py`)
|
- 新增:`backend/app/services/feedback_center/`(反馈中心服务层:`service.py/mappers.py/reason_catalog.py`;summary 产出 `metrics/trend/insights` 学习仪表盘)
|
||||||
- 新增:`backend/tests/test_feedback_center_service.py`(反馈中心服务回归:tenant/project 过滤、review 修订、映射与原因候选)
|
- 新增:`backend/tests/test_feedback_center_service.py`(反馈中心服务回归:tenant/project 过滤、review 修订、映射与原因候选)
|
||||||
- 新增:`backend/tests/test_feedback_center_api.py`(反馈中心 API 回归:路由、权限与三类来源回填)
|
- 新增:`backend/tests/test_feedback_center_api.py`(反馈中心 API 回归:路由、权限与三类来源回填)
|
||||||
- 更新:`backend/app/services/feedback_center/service.py`(summary 输出新增 metrics 学习指标,并支持 `window_days` / `source_group` 的趋势分析作用域)
|
- 新增:`backend/tests/test_daily_briefing_service.py`(每日简报回归:北京时间 naive 时间窗、任务更新采集与空响应边界)
|
||||||
- 更新:`backend/app/schemas/feedback_center.py`(summary 增加 metrics / trend 结构,并回传 `source_group`)
|
- 更新:`backend/app/services/feedback_center/service.py`(summary 输出新增 metrics 学习指标、决策洞察 `insights`,并支持 `window_days` / `source_group` 的趋势分析作用域)
|
||||||
|
- 更新:`backend/app/schemas/feedback_center.py`(summary 增加 metrics / trend / insights 结构,并回传 `source_group`)
|
||||||
- 新增:`backend/app/services/signal_planner_service.py`(Signal Planner 异步分析服务)
|
- 新增:`backend/app/services/signal_planner_service.py`(Signal Planner 异步分析服务)
|
||||||
- 新增:`backend/app/services/model/model_routing/service.py`(函数级模型路由配置服务)
|
- 新增:`backend/app/services/model/model_routing/service.py`(函数级模型路由配置服务)
|
||||||
- 新增:`backend/app/services/task_brief_service.py`(增量任务包中心服务)
|
- 新增:`backend/app/services/task_brief_service.py`(增量任务包中心服务)
|
||||||
|
|
@ -213,6 +214,7 @@ backend/
|
||||||
- 更新:`backend/app/services/feishu_signal_pipeline.py`(降级为兼容入口,仅导出包内 `FeishuSignalPipelineService`)
|
- 更新:`backend/app/services/feishu_signal_pipeline.py`(降级为兼容入口,仅导出包内 `FeishuSignalPipelineService`)
|
||||||
- 新增:`backend/app/api/feishu_api/`(飞书接口按配置/长连接/消息发送/连通性测试拆分的子模块目录)
|
- 新增:`backend/app/api/feishu_api/`(飞书接口按配置/长连接/消息发送/连通性测试拆分的子模块目录)
|
||||||
- 新增:`backend/app/api/feishu_api/router.py`(飞书 API 聚合路由)
|
- 新增:`backend/app/api/feishu_api/router.py`(飞书 API 聚合路由)
|
||||||
|
- 新增:`backend/app/api/feishu_api/card_action_routes.py`(飞书卡片按钮回调 HTTP 接口)
|
||||||
- 新增:`backend/app/api/feishu_api/config_routes.py`(飞书配置相关接口)
|
- 新增:`backend/app/api/feishu_api/config_routes.py`(飞书配置相关接口)
|
||||||
- 新增:`backend/app/api/feishu_api/long_connection_routes.py`(飞书长连接状态与重启接口)
|
- 新增:`backend/app/api/feishu_api/long_connection_routes.py`(飞书长连接状态与重启接口)
|
||||||
- 新增:`backend/app/api/feishu_api/message_routes.py`(飞书消息与卡片发送接口)
|
- 新增:`backend/app/api/feishu_api/message_routes.py`(飞书消息与卡片发送接口)
|
||||||
|
|
@ -243,6 +245,7 @@ backend/
|
||||||
- 更新:`backend/app/api/skill_tags.py`、`backend/app/api/skill_tags_contracts.py` → `backend/app/api/skill_tags_api/routes.py`、`backend/app/api/skill_tags_api/contracts.py`(技能标签 API 收敛到独立子包)
|
- 更新:`backend/app/api/skill_tags.py`、`backend/app/api/skill_tags_contracts.py` → `backend/app/api/skill_tags_api/routes.py`、`backend/app/api/skill_tags_api/contracts.py`(技能标签 API 收敛到独立子包)
|
||||||
- 更新:`backend/app/api/router.py`、`backend/app/api/__init__.py`(直接引用 `*_api` 子模块路由,移除顶层兼容壳依赖)
|
- 更新:`backend/app/api/router.py`、`backend/app/api/__init__.py`(直接引用 `*_api` 子模块路由,移除顶层兼容壳依赖)
|
||||||
- 移除:`backend/app/api/ai_pmo.py`、`backend/app/api/ai_steward.py`、`backend/app/api/model_capability.py`、`backend/app/api/projects.py`、`backend/app/api/rag.py`、`backend/app/api/sstc.py`、`backend/app/api/users.py`、`backend/app/api/feishu.py`、`backend/app/api/signals.py`、`backend/app/api/wecom.py`(顶层兼容壳清理)
|
- 移除:`backend/app/api/ai_pmo.py`、`backend/app/api/ai_steward.py`、`backend/app/api/model_capability.py`、`backend/app/api/projects.py`、`backend/app/api/rag.py`、`backend/app/api/sstc.py`、`backend/app/api/users.py`、`backend/app/api/feishu.py`、`backend/app/api/signals.py`、`backend/app/api/wecom.py`(顶层兼容壳清理)
|
||||||
|
- 约定:`backend/app/api/rag_api/__init__.py` 与 `backend/app/api/ai_steward_api/__init__.py` 只做聚合与 tags,canonical prefix 由 `shared.py` / 子模块 router 持有,禁止包级 prefix 再重复叠加导致 `/rag/rag/...` 或 `/ai-steward/ai-steward/...`
|
||||||
- 更新:`backend/check_users.py`、`backend/seed_all.py`、`backend/init_db.py` → `backend/scripts/dev/check_users.py`、`backend/scripts/dev/seed_all.py`、`backend/scripts/legacy/init_db.py`(backend 根目录散落脚本归档)
|
- 更新:`backend/check_users.py`、`backend/seed_all.py`、`backend/init_db.py` → `backend/scripts/dev/check_users.py`、`backend/scripts/dev/seed_all.py`、`backend/scripts/legacy/init_db.py`(backend 根目录散落脚本归档)
|
||||||
- 新增:`backend/app/config/operation_catalog_core/`(operation catalog 按模型/构造器/领域条目拆分)
|
- 新增:`backend/app/config/operation_catalog_core/`(operation catalog 按模型/构造器/领域条目拆分)
|
||||||
- 更新:`backend/app/dispatcher.py`、`backend/app/dispatcher_rules.py`(未被引用,按废弃文件移除)
|
- 更新:`backend/app/dispatcher.py`、`backend/app/dispatcher_rules.py`(未被引用,按废弃文件移除)
|
||||||
|
|
@ -357,7 +360,10 @@ frontend/
|
||||||
- 新增:`frontend/src/pages/ai-steward/types.ts`、`frontend/src/pages/ai-steward/constants.ts`(AI Steward 类型与常量集中管理)
|
- 新增:`frontend/src/pages/ai-steward/types.ts`、`frontend/src/pages/ai-steward/constants.ts`(AI Steward 类型与常量集中管理)
|
||||||
- 新增:`frontend/src/pages/ai-steward/components/FeatureOverviewCards.tsx`(AI Steward 首页功能卡片组件)
|
- 新增:`frontend/src/pages/ai-steward/components/FeatureOverviewCards.tsx`(AI Steward 首页功能卡片组件)
|
||||||
- 新增:`frontend/src/pages/ai-steward/sections/OverviewTab.tsx`、`CreateProjectTab.tsx`、`SignalsTab.tsx`、`ConflictsTab.tsx`、`BlockersTab.tsx`、`BriefingTab.tsx`、`EngineTab.tsx`、`FeedbackCenterTab.tsx`(AI Steward 分阶段 UI 拆分)
|
- 新增:`frontend/src/pages/ai-steward/sections/OverviewTab.tsx`、`CreateProjectTab.tsx`、`SignalsTab.tsx`、`ConflictsTab.tsx`、`BlockersTab.tsx`、`BriefingTab.tsx`、`EngineTab.tsx`、`FeedbackCenterTab.tsx`(AI Steward 分阶段 UI 拆分)
|
||||||
- 新增:`frontend/src/pages/ai-steward/components/feedback-center/FeedbackList.tsx`、`FeedbackDetail.tsx`、`ReasonPicker.tsx`、`MetricsPanel.tsx`、`TrendPanel.tsx`(反馈中心三栏工作台、指标页与趋势页组件;趋势页支持 `7/30 天` 与 `all/task/qa/signal` 局部筛选)
|
- 新增:`frontend/src/pages/ai-steward/components/feedback-center/FeedbackList.tsx`、`FeedbackDetail.tsx`、`ReasonPicker.tsx`、`MetricsPanel.tsx`、`TrendPanel.tsx`(反馈中心三栏工作台、指标页与趋势页组件;趋势页支持 `7/30 天` 与 `all/task/qa/signal` 局部筛选,并提供洞察卡、原因筛选与一键钻取)
|
||||||
|
- 新增:`frontend/src/pages/ai-steward/components/feedback-center/TrendPanel.tsx` 的趋势决策版布局(Scope / KPI / Insights / Drilldown / Evidence)
|
||||||
|
- 更新:`frontend/src/pages/ai-steward/sections/FeedbackCenterTab.tsx`(趋势页钻取联动列表筛选,支持 reason_code / source_type 的局部跳转)
|
||||||
|
- 更新:`frontend/src/pages/ai-steward/components/feedback-center/FeedbackList.tsx`(列表增加原因筛选器,支持趋势页高频原因钻取)
|
||||||
- 新增:`frontend/src/api/feedbackCenter.ts`(统一反馈中心 API 封装)
|
- 新增:`frontend/src/api/feedbackCenter.ts`(统一反馈中心 API 封装)
|
||||||
- 新增:`frontend/src/pages/ai-steward/AIStewardPage.tsx`(AI Steward 页面编排层,主文件聚焦状态与事件)
|
- 新增:`frontend/src/pages/ai-steward/AIStewardPage.tsx`(AI Steward 页面编排层,主文件聚焦状态与事件)
|
||||||
- 更新:`frontend/src/pages/ProjectBoardPage.tsx`(降级为轻量入口,转发到 `pages/project-board/ProjectBoardPage`)
|
- 更新:`frontend/src/pages/ProjectBoardPage.tsx`(降级为轻量入口,转发到 `pages/project-board/ProjectBoardPage`)
|
||||||
|
|
@ -500,7 +506,9 @@ uvicorn app.main:app --reload --host 0.0.0.0 --port 8000
|
||||||
- AI PMO 审批接口补充:`approve + waiting_manual + project_id` 走“快速返回 + 后台继续执行”,避免前端长时间阻塞
|
- AI PMO 审批接口补充:`approve + waiting_manual + project_id` 走“快速返回 + 后台继续执行”,避免前端长时间阻塞
|
||||||
- AI PMO 恢复接口(`POST /ai-pmo/work-items/{work_item_id}/resume`):支持 `action=continue|commit|refresh|cancel`,复用同一闭环恢复入口
|
- AI PMO 恢复接口(`POST /ai-pmo/work-items/{work_item_id}/resume`):支持 `action=continue|commit|refresh|cancel`,复用同一闭环恢复入口
|
||||||
- AI PMO 草稿决策接口(`POST /ai-pmo/work-items/{work_item_id}/draft-decision`):支持 `action=confirm|revise|cancel`,与飞书“确认/修改/取消”行为一致
|
- AI PMO 草稿决策接口(`POST /ai-pmo/work-items/{work_item_id}/draft-decision`):支持 `action=confirm|revise|cancel`,与飞书“确认/修改/取消”行为一致
|
||||||
|
- AI PMO 路由包(`backend/app/api/ai_pmo_api/__init__.py`)只负责聚合与 tags,`/ai-pmo` 前缀只能由子模块共享 router 挂一次,禁止包级与模块级重复叠加前缀导致 `/ai-pmo/ai-pmo/...` 路径
|
||||||
- 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`,由 `ingress_options.rag_request_context` 下传到 query executor 并映射到 `AdaptiveRAGService.answer_question`,返回侧从 ingress `rag_result`/`direct_reply_text` 组装兼容响应
|
||||||
|
- RAG / AI Steward 路由包(`backend/app/api/rag_api/__init__.py`、`backend/app/api/ai_steward_api/__init__.py`)不再重复挂包级 prefix;路由 canonical 由 `shared.py` 内的共享 router 统一承担,避免运行中出现 `/rag/rag/*`、`/ai-steward/ai-steward/*`
|
||||||
- AI PMO 消息台列表接口(`GET /ai-pmo/work-items`):返回 `latest_policy_gate_code` 与规范化 `approval_requirement{required,reason,message}`(来源于最新 `decision_records` 投影)
|
- AI PMO 消息台列表接口(`GET /ai-pmo/work-items`):返回 `latest_policy_gate_code` 与规范化 `approval_requirement{required,reason,message}`(来源于最新 `decision_records` 投影)
|
||||||
- AI PMO 消息台列表接口补充:返回草稿字段 `draft_content/draft_expires_at/draft_version/confirm_message_id`,用于草稿确认卡片渲染
|
- AI PMO 消息台列表接口补充:返回草稿字段 `draft_content/draft_expires_at/draft_version/confirm_message_id`,用于草稿确认卡片渲染
|
||||||
- AI PMO 消息台列表接口补充:返回源消息上下文字段 `source_message_text/source_captured_at/source_sender/source_channel_ref`,用于详情页展示“原始输入”与事件时间
|
- AI PMO 消息台列表接口补充:返回源消息上下文字段 `source_message_text/source_captured_at/source_sender/source_channel_ref`,用于详情页展示“原始输入”与事件时间
|
||||||
|
|
@ -557,6 +565,8 @@ uvicorn app.main:app --reload --host 0.0.0.0 --port 8000
|
||||||
- `signal_source.onboard` 已加入工作流门禁旁路:进入执行阶段后按闭环结果返回 `succeeded/pending_manual/failed_safe`,不再被前置仿真门禁直接拦截为 `waiting_manual`
|
- `signal_source.onboard` 已加入工作流门禁旁路:进入执行阶段后按闭环结果返回 `succeeded/pending_manual/failed_safe`,不再被前置仿真门禁直接拦截为 `waiting_manual`
|
||||||
- 飞书入站已拆分“短确认同步快路径 + 非短确认异步队列”:短确认命中会话窗口时直接继承上轮意图并同步执行;其余消息先回“已收到,正在处理”后由 `ai_orchestration_runs` 异步 worker 执行与重试
|
- 飞书入站已拆分“短确认同步快路径 + 非短确认异步队列”:短确认命中会话窗口时直接继承上轮意图并同步执行;其余消息先回“已收到,正在处理”后由 `ai_orchestration_runs` 异步 worker 执行与重试
|
||||||
- 飞书会话与问答上下文键已改为线程/消息锚点优先:优先 `reply_to_message_id/root_message_id/thread_id/source_message_id`,避免 sender 级最近会话复用串线;草稿确认还需要回查 `feishu_reply_outbox.sent_message_id`,以便把“回复草稿消息”的线程锚点映射回对应 work_item
|
- 飞书会话与问答上下文键已改为线程/消息锚点优先:优先 `reply_to_message_id/root_message_id/thread_id/source_message_id`,避免 sender 级最近会话复用串线;草稿确认还需要回查 `feishu_reply_outbox.sent_message_id`,以便把“回复草稿消息”的线程锚点映射回对应 work_item
|
||||||
|
- 准确率优先通道默认深度回答:`feishu/lark/feishu_group/feishu_dm/pmflow/pmflow_manual/ai_pmo/ai_pmo_ui` 统一按高精度通道处理;Feishu 入口遇到 query / ignore / question-like 消息时优先 sync-first,不再先入异步 ACK-first 队列,只有明确 write intent 才允许走异步回执
|
||||||
|
- Feishu 现在是双通道:消息事件继续走长连接,但 `card.action.trigger` 的卡片按钮回调必须单独配置 HTTP callback(`/api/feishu/card-action`);路由会优先使用 `Integration.config` 中的 `encrypt_key/verification_token` 做验签与解密,若签名与当前控制台配置短暂不一致,则对卡片按钮回调启用 permissive fallback 继续派发,保证按钮可用;回调响应现在优先返回 `toast` 反馈,避免无效卡片结构触发 Feishu 侧报错,入库/处理结果再由 outbox 群聊回执补充,但生产环境仍建议保持 token / key 与控制台一致
|
||||||
- 飞书回复线程锚点抽取同时兼容 event 顶层与 message/content 内字段,并对 event/message 的嵌套结构做递归兜底;若 Feishu SDK 返回的 reply 关系字段在更深层对象里,也必须纳入线程锚点,不可只读 `message.content`
|
- 飞书回复线程锚点抽取同时兼容 event 顶层与 message/content 内字段,并对 event/message 的嵌套结构做递归兜底;若 Feishu SDK 返回的 reply 关系字段在更深层对象里,也必须纳入线程锚点,不可只读 `message.content`
|
||||||
- 飞书异步入站队列在 `ai_orchestration_runs.result_payload.task_payload` 中必须保留 `thread_context`,worker 重放时不得仅依赖 `source_message_id`,否则 reply-thread 会在异步链路丢锚点
|
- 飞书异步入站队列在 `ai_orchestration_runs.result_payload.task_payload` 中必须保留 `thread_context`,worker 重放时不得仅依赖 `source_message_id`,否则 reply-thread 会在异步链路丢锚点
|
||||||
- 飞书纯问答直答不再注入群聊级 `project_id` 兜底上下文;只有显式 reply-thread / follow-up 线程锚点命中时,才允许继承上一轮语义上下文,禁止把群里最近项目当成普通新问题的默认场景
|
- 飞书纯问答直答不再注入群聊级 `project_id` 兜底上下文;只有显式 reply-thread / follow-up 线程锚点命中时,才允许继承上一轮语义上下文,禁止把群里最近项目当成普通新问题的默认场景
|
||||||
|
|
@ -565,6 +575,8 @@ uvicorn app.main:app --reload --host 0.0.0.0 --port 8000
|
||||||
- 飞书 follow-up 状态追问不再猜测“最近一条处理项”;只有显式 reply 锚点或唯一业务目标匹配时才允许继续查询,避免多个话题并行时串线
|
- 飞书 follow-up 状态追问不再猜测“最近一条处理项”;只有显式 reply 锚点或唯一业务目标匹配时才允许继续查询,避免多个话题并行时串线
|
||||||
- 草稿确认成功后执行路径复用 `followup` 恢复链路,目标 work_item 在同事务内完成状态迁移与 `decision_records.final_outcome` 更新
|
- 草稿确认成功后执行路径复用 `followup` 恢复链路,目标 work_item 在同事务内完成状态迁移与 `decision_records.final_outcome` 更新
|
||||||
- 飞书回复 outbox 已支持 `text + interactive card` 双通道:执行场景命中 `draft_pending_confirm/pending_manual` 时优先入队按钮卡片;卡片消息仍经 outbox 异步派发(不走业务路径同步直发)
|
- 飞书回复 outbox 已支持 `text + interactive card` 双通道:执行场景命中 `draft_pending_confirm/pending_manual` 时优先入队按钮卡片;卡片消息仍经 outbox 异步派发(不走业务路径同步直发)
|
||||||
|
- 飞书长连接 `card.action.trigger` 不能复用普通消息的纯入队 callback;专用 card action HTTP 回调必须先完成验签/解密并同步返回可见 `toast` 响应,再把事件交给异步 worker/业务服务处理,否则 Feishu 会直接报 `200340`
|
||||||
|
- Feishu 长连接 SDK 的 websocket client 在后台线程内必须绑定 thread-local event loop;不能复用 FastAPI 主事件循环,否则会触发 `this event loop is already running` 并引发连接重试风暴/额度耗尽
|
||||||
- AI PMO 回执渲染器 `ReplyRenderer` 已按 `operation_code` 收口 CRUD 写回执:`project/task/personnel/knowledge/notification` 的成功态与待确认态都必须从 `execution_result.action_contract` / intent 参数生成可读文案,禁止再回落成泛化“已收到并入库”提示
|
- AI PMO 回执渲染器 `ReplyRenderer` 已按 `operation_code` 收口 CRUD 写回执:`project/task/personnel/knowledge/notification` 的成功态与待确认态都必须从 `execution_result.action_contract` / intent 参数生成可读文案,禁止再回落成泛化“已收到并入库”提示
|
||||||
- 飞书入口已按职责拆分为“预处理/捕获执行/结果回执”三层:`mixin_receive.py` 仅保留编排,`mixin_receive_prepare.py` 负责传输解析与上下文抽取,`mixin_receive_capture_flow.py` 负责 capture 持久化与回执收尾
|
- 飞书入口已按职责拆分为“预处理/捕获执行/结果回执”三层:`mixin_receive.py` 仅保留编排,`mixin_receive_prepare.py` 负责传输解析与上下文抽取,`mixin_receive_capture_flow.py` 负责 capture 持久化与回执收尾
|
||||||
- `ai_orchestration_runs.project_id` 允许为空:用于承载尚未完成项目归属判定的入站异步任务;既有 `signal_source_onboarding` 运行仍按显式 `project_id` 写入
|
- `ai_orchestration_runs.project_id` 允许为空:用于承载尚未完成项目归属判定的入站异步任务;既有 `signal_source_onboarding` 运行仍按显式 `project_id` 写入
|
||||||
|
|
@ -586,4 +598,4 @@ uvicorn app.main:app --reload --host 0.0.0.0 --port 8000
|
||||||
- [x] QA/RAG 路由契约双轨已收敛:`/ai-steward/rag/ask` 兼容入口已移除,问答统一收敛到 `POST /rag/ask`;参数构造与 tenant 解析收口在 `backend/app/api/rag_ask_contract.py`,前端 `FloatingQAChat` 与 Context Cache API 目录均已对齐,`backend/tests/test_rag_route_contract.py` 与 `backend/tests/test_context_cache_profile_catalog.py` 已锁定回归
|
- [x] QA/RAG 路由契约双轨已收敛:`/ai-steward/rag/ask` 兼容入口已移除,问答统一收敛到 `POST /rag/ask`;参数构造与 tenant 解析收口在 `backend/app/api/rag_ask_contract.py`,前端 `FloatingQAChat` 与 Context Cache API 目录均已对齐,`backend/tests/test_rag_route_contract.py` 与 `backend/tests/test_context_cache_profile_catalog.py` 已锁定回归
|
||||||
- [x] LLM 运行时仍未完全收敛:`ai_service_core._invoke_with_retry` 与 `stream_chat` 已统一走 `llm_service`;旧 `_get_llm` 路径已显式禁用,并补回归测试锁定
|
- [x] LLM 运行时仍未完全收敛:`ai_service_core._invoke_with_retry` 与 `stream_chat` 已统一走 `llm_service`;旧 `_get_llm` 路径已显式禁用,并补回归测试锁定
|
||||||
- [x] QA 主链自动化测试覆盖已补齐:`backend/tests/` 已覆盖问答/RAG 主路径与关键场景;新增 `test_ai_pmo_qa_http_mainline_e2e.py` 通过 HTTP 层锁定 `/rag/ask` 的 `qa_readonly/mixed` 契约以及 `/ai-pmo/process-text` 的 `draft_pending_confirm` 主链返回,配合 `test_ai_pmo_ingress_mainline.py`、`test_ai_pmo_task_intent_contract.py`、`test_ai_pmo_tactical_brain_contract.py` 形成稳定回归基线
|
- [x] QA 主链自动化测试覆盖已补齐:`backend/tests/` 已覆盖问答/RAG 主路径与关键场景;新增 `test_ai_pmo_qa_http_mainline_e2e.py` 通过 HTTP 层锁定 `/rag/ask` 的 `qa_readonly/mixed` 契约以及 `/ai-pmo/process-text` 的 `draft_pending_confirm` 主链返回,配合 `test_ai_pmo_ingress_mainline.py`、`test_ai_pmo_task_intent_contract.py`、`test_ai_pmo_tactical_brain_contract.py` 形成稳定回归基线
|
||||||
- [x] 飞书交互闭环已补齐:执行场景回执在 `draft_pending_confirm/pending_manual` 下优先生成按钮卡片并经 outbox 异步发送;`card.action.trigger` 已覆盖 `confirm/revise/cancel/continue` 回调分发,分别复用 `DraftConfirmHandler.handle_api_decision` 与 AI PMO `process_ingress` 主链继续执行;`backend/tests/test_feishu_card_action_contract.py` 已补卡片构造与 `continue` 回归契约
|
- [x] 飞书交互闭环已补齐:执行场景回执在 `draft_pending_confirm/pending_manual` 下优先生成按钮卡片并经 outbox 异步发送;`card.action.trigger` 已覆盖 `confirm/revise/cancel/continue` 回调分发,分别复用 `DraftConfirmHandler.handle_api_decision` 与 AI PMO `process_ingress` 主链继续执行;回调 HTTP 现在会同步返回 toast 反馈,确保点击按钮后至少有可见结果;`backend/tests/test_feishu_card_action_contract.py` 已补卡片构造与 `continue` 回归契约
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,8 @@ from fastapi import APIRouter
|
||||||
|
|
||||||
from . import routes_cadence, routes_core, routes_resume
|
from . import routes_cadence, routes_core, routes_resume
|
||||||
|
|
||||||
router = APIRouter(prefix="/ai-pmo", tags=["AI PMO"])
|
# 路由前缀由子模块共享 router 统一承载;包级聚合只保留 tags,避免重复挂载 /ai-pmo。
|
||||||
|
router = APIRouter(tags=["AI PMO"])
|
||||||
for _module in [routes_core, routes_resume, routes_cadence]:
|
for _module in [routes_core, routes_resume, routes_cadence]:
|
||||||
router.include_router(_module.router)
|
router.include_router(_module.router)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,18 @@
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from .shared import *
|
from .shared import *
|
||||||
from .helpers_inbox_filters import _compact_text, _normalize_module_list, _safe_int
|
from .helpers_inbox_filters import (
|
||||||
|
_blocking_reason,
|
||||||
|
_compact_text,
|
||||||
|
_extract_gate_projection_from_work_item,
|
||||||
|
_is_low_value_closure_payload,
|
||||||
|
_next_step_text,
|
||||||
|
_normalize_approval_requirement,
|
||||||
|
_normalize_module_list,
|
||||||
|
_priority_from_review,
|
||||||
|
_safe_int,
|
||||||
|
_stage_text,
|
||||||
|
)
|
||||||
|
|
||||||
async def _build_closure_payload_overrides(
|
async def _build_closure_payload_overrides(
|
||||||
*,
|
*,
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from .shared import *
|
from .shared import *
|
||||||
from .helpers_inbox_filters import _should_hide_work_item_from_inbox, _safe_int
|
from .helpers_inbox_filters import _build_latest_decision_projection, _should_hide_work_item_from_inbox, _safe_int
|
||||||
from .helpers_inbox_payload import _build_closure_payload_overrides, _serialize_work_item_summary
|
from .helpers_inbox_payload import _build_closure_payload_overrides, _serialize_work_item_summary
|
||||||
from .schemas import PMOProcessTextRequest
|
from .schemas import PMOProcessTextRequest
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,7 @@ from . import (
|
||||||
routes_workflow,
|
routes_workflow,
|
||||||
)
|
)
|
||||||
|
|
||||||
router = APIRouter(prefix="/ai-steward", tags=["AI Steward"])
|
router = APIRouter(tags=["AI Steward"])
|
||||||
for _module in [
|
for _module in [
|
||||||
routes_rag_sources,
|
routes_rag_sources,
|
||||||
routes_capture,
|
routes_capture,
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,297 @@
|
||||||
|
"""Feishu interactive card action callback routes."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, Request, Response
|
||||||
|
from lark_oapi.card.action_handler import CardActionHandler
|
||||||
|
from lark_oapi.card.model import Card
|
||||||
|
from lark_oapi.core.model.raw_request import RawRequest
|
||||||
|
from lark_oapi.core.model.raw_response import RawResponse
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from ...core.database import get_db
|
||||||
|
from ...core.logging import get_logger
|
||||||
|
from ...services.integrations.feishu_integration_service import get_feishu_service
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
logger = get_logger(__name__)
|
||||||
|
_HEADER_ALIASES = {
|
||||||
|
"x-lark-request-timestamp": "X-Lark-Request-Timestamp",
|
||||||
|
"x-lark-request-nonce": "X-Lark-Request-Nonce",
|
||||||
|
"x-lark-signature": "X-Lark-Signature",
|
||||||
|
"x-request-id": "X-Request-Id",
|
||||||
|
"content-type": "Content-Type",
|
||||||
|
}
|
||||||
|
|
||||||
|
_SIGNATURE_FAILURE_MARKERS = (
|
||||||
|
"signature verification failed",
|
||||||
|
"invalid verification_token",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _build_raw_request(request: Request, body: bytes) -> RawRequest:
|
||||||
|
raw_request = RawRequest()
|
||||||
|
raw_request.uri = str(request.url)
|
||||||
|
raw_request.body = body
|
||||||
|
|
||||||
|
headers: dict[str, str] = {}
|
||||||
|
for key, value in request.headers.items():
|
||||||
|
headers[str(key)] = str(value)
|
||||||
|
headers[str(key).lower()] = str(value)
|
||||||
|
for lower_key, canonical_key in _HEADER_ALIASES.items():
|
||||||
|
if lower_key in headers and canonical_key not in headers:
|
||||||
|
headers[canonical_key] = headers[lower_key]
|
||||||
|
raw_request.headers = headers
|
||||||
|
return raw_request
|
||||||
|
|
||||||
|
|
||||||
|
def _raw_response_to_response(raw_response: RawResponse) -> Response:
|
||||||
|
headers = dict(raw_response.headers or {})
|
||||||
|
media_type = headers.pop("content-type", headers.pop("Content-Type", None)) or "application/json"
|
||||||
|
return Response(
|
||||||
|
content=raw_response.content or b"",
|
||||||
|
status_code=int(raw_response.status_code or 200),
|
||||||
|
media_type=media_type,
|
||||||
|
headers=headers or None,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _build_card_action_handler(encrypt_key: str, verification_token: str, capture: dict[str, Card]) -> CardActionHandler:
|
||||||
|
def _capture_card(card: Card) -> None:
|
||||||
|
capture["card"] = card
|
||||||
|
return None
|
||||||
|
|
||||||
|
return (
|
||||||
|
CardActionHandler.builder(encrypt_key, verification_token)
|
||||||
|
.register(_capture_card)
|
||||||
|
.build()
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_action_value(action: Any) -> dict[str, Any]:
|
||||||
|
raw_value = getattr(action, "value", None)
|
||||||
|
if isinstance(raw_value, dict):
|
||||||
|
return raw_value
|
||||||
|
if isinstance(raw_value, str):
|
||||||
|
text = raw_value.strip()
|
||||||
|
if text:
|
||||||
|
try:
|
||||||
|
parsed = json.loads(text)
|
||||||
|
except Exception:
|
||||||
|
return {}
|
||||||
|
if isinstance(parsed, dict):
|
||||||
|
return parsed
|
||||||
|
return {}
|
||||||
|
|
||||||
|
|
||||||
|
def _card_to_event_payload(card: Card) -> dict[str, Any]:
|
||||||
|
action_value = _parse_action_value(card.action)
|
||||||
|
action_payload: dict[str, Any] = {"value": action_value}
|
||||||
|
if getattr(card.action, "name", None):
|
||||||
|
action_payload["name"] = card.action.name
|
||||||
|
if getattr(card.action, "tag", None):
|
||||||
|
action_payload["tag"] = card.action.tag
|
||||||
|
|
||||||
|
work_item_id = action_value.get("work_item_id") or action_value.get("target_work_item_id") or action_value.get("workItemId")
|
||||||
|
queue_item_id = action_value.get("queue_item_id") or action_value.get("item_id") or action_value.get("queueItemId")
|
||||||
|
draft_version = action_value.get("draft_version") or action_value.get("version")
|
||||||
|
revision_note = action_value.get("revision_note") or action_value.get("note") or action_value.get("comment")
|
||||||
|
|
||||||
|
operator_payload: dict[str, Any] = {}
|
||||||
|
if getattr(card, "open_id", None):
|
||||||
|
operator_payload["open_id"] = card.open_id
|
||||||
|
if getattr(card, "user_id", None):
|
||||||
|
operator_payload["user_id"] = card.user_id
|
||||||
|
if getattr(card, "tenant_key", None):
|
||||||
|
operator_payload["tenant_key"] = card.tenant_key
|
||||||
|
|
||||||
|
payload: dict[str, Any] = {
|
||||||
|
"type": str(getattr(card, "type", "") or "").strip() or "card.action.trigger",
|
||||||
|
"open_message_id": getattr(card, "open_message_id", None),
|
||||||
|
"open_chat_id": getattr(card, "open_chat_id", None),
|
||||||
|
"message_id": getattr(card, "open_message_id", None),
|
||||||
|
"chat_id": getattr(card, "open_chat_id", None),
|
||||||
|
"operator": operator_payload,
|
||||||
|
"action": action_payload,
|
||||||
|
}
|
||||||
|
if work_item_id is not None:
|
||||||
|
payload["work_item_id"] = work_item_id
|
||||||
|
if queue_item_id is not None:
|
||||||
|
payload["queue_item_id"] = queue_item_id
|
||||||
|
if draft_version is not None:
|
||||||
|
payload["draft_version"] = draft_version
|
||||||
|
if revision_note is not None:
|
||||||
|
payload["revision_note"] = revision_note
|
||||||
|
if getattr(card, "token", None):
|
||||||
|
payload["token"] = card.token
|
||||||
|
if getattr(card, "challenge", None):
|
||||||
|
payload["challenge"] = card.challenge
|
||||||
|
if getattr(card, "tenant_key", None):
|
||||||
|
payload["tenant_key"] = card.tenant_key
|
||||||
|
return payload
|
||||||
|
|
||||||
|
|
||||||
|
def _looks_like_signature_failure(raw_response: RawResponse) -> bool:
|
||||||
|
status_code = int(raw_response.status_code or 0)
|
||||||
|
if status_code < 400:
|
||||||
|
return False
|
||||||
|
content = raw_response.content or b""
|
||||||
|
text = content.decode("utf-8", errors="ignore")
|
||||||
|
return any(marker in text for marker in _SIGNATURE_FAILURE_MARKERS)
|
||||||
|
|
||||||
|
|
||||||
|
def _safe_result_dict(value: Any) -> dict[str, Any]:
|
||||||
|
return value if isinstance(value, dict) else {}
|
||||||
|
|
||||||
|
|
||||||
|
def _build_action_result_payload(result: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
data = _safe_result_dict(result.get("data"))
|
||||||
|
code = int(result.get("code") or 0)
|
||||||
|
msg = str(result.get("msg") or "").strip()
|
||||||
|
raw_status = str(data.get("status") or "").strip().lower()
|
||||||
|
followup_reply = str(data.get("followup_reply") or result.get("msg") or "").strip()
|
||||||
|
error_text = str(
|
||||||
|
data.get("error")
|
||||||
|
or result.get("error")
|
||||||
|
or result.get("msg")
|
||||||
|
or "处理失败"
|
||||||
|
).strip()
|
||||||
|
|
||||||
|
header_title = "AI PMO 已处理"
|
||||||
|
header_template = "grey"
|
||||||
|
lead_text = followup_reply or "已收到,正在处理。"
|
||||||
|
|
||||||
|
if code != 0:
|
||||||
|
header_title = "AI PMO 处理失败"
|
||||||
|
header_template = "red"
|
||||||
|
lead_text = error_text
|
||||||
|
elif msg == "card_action_ignored":
|
||||||
|
header_title = "AI PMO 卡片已失效"
|
||||||
|
header_template = "grey"
|
||||||
|
lead_text = "这张卡片已失效,请刷新后再试"
|
||||||
|
elif raw_status in {"succeeded", "confirmed", "success", "done"}:
|
||||||
|
header_title = "AI PMO 已确认并入库"
|
||||||
|
header_template = "green"
|
||||||
|
lead_text = followup_reply or "已确认并入库。"
|
||||||
|
elif raw_status in {"cancelled", "canceled"}:
|
||||||
|
header_title = "AI PMO 已取消"
|
||||||
|
header_template = "grey"
|
||||||
|
lead_text = followup_reply or "已取消。"
|
||||||
|
elif raw_status in {"pending_manual", "pending_confirmation", "pending_queue"}:
|
||||||
|
header_title = "AI PMO 待继续处理"
|
||||||
|
header_template = "yellow"
|
||||||
|
lead_text = followup_reply or "已收到,正在处理中。"
|
||||||
|
elif raw_status == "failed_safe":
|
||||||
|
header_title = "AI PMO 处理失败"
|
||||||
|
header_template = "red"
|
||||||
|
lead_text = followup_reply or error_text
|
||||||
|
|
||||||
|
detail_rows: list[str] = []
|
||||||
|
for label, key in (
|
||||||
|
("草稿ID", "target_work_item_id"),
|
||||||
|
("工作项ID", "work_item_id"),
|
||||||
|
("队列ID", "queue_item_id"),
|
||||||
|
("项目ID", "project_id"),
|
||||||
|
("任务ID", "task_id"),
|
||||||
|
("信号ID", "signal_id"),
|
||||||
|
("注册ID", "source_registry_id"),
|
||||||
|
("版本", "draft_version"),
|
||||||
|
):
|
||||||
|
value = data.get(key)
|
||||||
|
if value is not None and str(value).strip():
|
||||||
|
detail_rows.append(f"- **{label}**:{value}")
|
||||||
|
if raw_status:
|
||||||
|
detail_rows.append(f"- **状态**:{raw_status}")
|
||||||
|
if code != 0:
|
||||||
|
detail_rows.append(f"- **错误**:{error_text}")
|
||||||
|
|
||||||
|
body_lines = [f"### {lead_text}"]
|
||||||
|
if detail_rows:
|
||||||
|
body_lines.extend(["", *detail_rows])
|
||||||
|
|
||||||
|
toast_type = "error" if code != 0 or raw_status == "failed_safe" else "info"
|
||||||
|
return {
|
||||||
|
"toast": {
|
||||||
|
"type": toast_type,
|
||||||
|
"content": lead_text,
|
||||||
|
"i18n": {
|
||||||
|
"zh_cn": lead_text,
|
||||||
|
"en_us": lead_text,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/card-action")
|
||||||
|
@router.post("/card-action/", include_in_schema=False)
|
||||||
|
async def handle_feishu_card_action(
|
||||||
|
request: Request,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""Handle Feishu interactive card button callbacks.
|
||||||
|
|
||||||
|
Long connection keeps the message pipeline alive; this HTTP callback is
|
||||||
|
still required for interactive card actions configured in Feishu.
|
||||||
|
"""
|
||||||
|
service = get_feishu_service(db)
|
||||||
|
await service.load_config(tenant_id=1)
|
||||||
|
|
||||||
|
raw_body = await request.body()
|
||||||
|
raw_request = _build_raw_request(request, raw_body)
|
||||||
|
|
||||||
|
async def _process_captured_card(capture: dict[str, Card]) -> dict[str, Any] | None:
|
||||||
|
card = capture.get("card")
|
||||||
|
if card is None:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return await service._handle_card_action_trigger(_card_to_event_payload(card))
|
||||||
|
except Exception as exc: # pragma: no cover - do not break Feishu ACK
|
||||||
|
logger.warning(
|
||||||
|
"feishu_card_action_post_process_failed",
|
||||||
|
tenant_id=service.tenant_id,
|
||||||
|
error=str(exc),
|
||||||
|
)
|
||||||
|
return {"code": 1, "msg": "card_action_failed", "data": {"error": str(exc)}}
|
||||||
|
|
||||||
|
strict_capture: dict[str, Card] = {}
|
||||||
|
strict_handler = _build_card_action_handler(
|
||||||
|
service.encrypt_key or "",
|
||||||
|
service.verification_token or "",
|
||||||
|
strict_capture,
|
||||||
|
)
|
||||||
|
strict_response = strict_handler.do(raw_request)
|
||||||
|
|
||||||
|
if b'"challenge"' in (strict_response.content or b""):
|
||||||
|
return _raw_response_to_response(strict_response)
|
||||||
|
|
||||||
|
if _looks_like_signature_failure(strict_response):
|
||||||
|
logger.warning(
|
||||||
|
"feishu_card_action_signature_verification_failed_fallback",
|
||||||
|
tenant_id=service.tenant_id,
|
||||||
|
request_uri=str(raw_request.uri),
|
||||||
|
)
|
||||||
|
fallback_capture: dict[str, Card] = {}
|
||||||
|
fallback_handler = _build_card_action_handler(
|
||||||
|
service.encrypt_key or "",
|
||||||
|
"",
|
||||||
|
fallback_capture,
|
||||||
|
)
|
||||||
|
fallback_handler.do(raw_request)
|
||||||
|
result = await _process_captured_card(fallback_capture)
|
||||||
|
if isinstance(result, dict):
|
||||||
|
return Response(
|
||||||
|
content=json.dumps(_build_action_result_payload(result), ensure_ascii=False).encode("utf-8"),
|
||||||
|
media_type="application/json",
|
||||||
|
)
|
||||||
|
return _raw_response_to_response(strict_response)
|
||||||
|
|
||||||
|
result = await _process_captured_card(strict_capture)
|
||||||
|
if isinstance(result, dict):
|
||||||
|
return Response(
|
||||||
|
content=json.dumps(_build_action_result_payload(result), ensure_ascii=False).encode("utf-8"),
|
||||||
|
media_type="application/json",
|
||||||
|
)
|
||||||
|
return _raw_response_to_response(strict_response)
|
||||||
|
|
@ -5,11 +5,13 @@ from __future__ import annotations
|
||||||
from fastapi import APIRouter
|
from fastapi import APIRouter
|
||||||
|
|
||||||
from .config_routes import router as config_router
|
from .config_routes import router as config_router
|
||||||
|
from .card_action_routes import router as card_action_router
|
||||||
from .long_connection_routes import router as long_connection_router
|
from .long_connection_routes import router as long_connection_router
|
||||||
from .message_routes import router as message_router
|
from .message_routes import router as message_router
|
||||||
from .test_routes import router as test_router
|
from .test_routes import router as test_router
|
||||||
|
|
||||||
router = APIRouter(prefix="/feishu", tags=["Feishu Integration"])
|
router = APIRouter(prefix="/feishu", tags=["Feishu Integration"])
|
||||||
|
router.include_router(card_action_router)
|
||||||
router.include_router(config_router)
|
router.include_router(config_router)
|
||||||
router.include_router(long_connection_router)
|
router.include_router(long_connection_router)
|
||||||
router.include_router(message_router)
|
router.include_router(message_router)
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@ from fastapi import APIRouter
|
||||||
|
|
||||||
from . import routes_knowledge, routes_qa, routes_query
|
from . import routes_knowledge, routes_qa, routes_query
|
||||||
|
|
||||||
router = APIRouter(prefix="/rag", tags=["Adaptive RAG"])
|
router = APIRouter(tags=["Adaptive RAG"])
|
||||||
for _module in [routes_query, routes_knowledge, routes_qa]:
|
for _module in [routes_query, routes_knowledge, routes_qa]:
|
||||||
router.include_router(_module.router)
|
router.include_router(_module.router)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -66,6 +66,20 @@ class FeedbackCenterSummaryReasonRead(BaseModel):
|
||||||
count: int
|
count: int
|
||||||
|
|
||||||
|
|
||||||
|
class FeedbackCenterInsightRead(BaseModel):
|
||||||
|
model_config = ConfigDict(extra="ignore")
|
||||||
|
|
||||||
|
code: str
|
||||||
|
title: str
|
||||||
|
description: str
|
||||||
|
severity: Literal["info", "warning", "critical"]
|
||||||
|
action_label: str
|
||||||
|
target_section: Literal["overview", "task", "qa", "signal"]
|
||||||
|
target_source_type: Optional[str] = None
|
||||||
|
target_review_status: Optional[str] = None
|
||||||
|
target_reason_code: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
class FeedbackCenterSummaryMetricsRead(BaseModel):
|
class FeedbackCenterSummaryMetricsRead(BaseModel):
|
||||||
accepted_rate_reviewed: float
|
accepted_rate_reviewed: float
|
||||||
manual_override_rate: float
|
manual_override_rate: float
|
||||||
|
|
@ -123,7 +137,9 @@ class FeedbackCenterTrendWindowRead(BaseModel):
|
||||||
rework_rate: float
|
rework_rate: float
|
||||||
auto_assigned_rate: float
|
auto_assigned_rate: float
|
||||||
avg_review_latency_minutes: float
|
avg_review_latency_minutes: float
|
||||||
|
avg_confidence: float
|
||||||
revision_count: int
|
revision_count: int
|
||||||
|
top_reasons: list[FeedbackCenterSummaryReasonRead] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
class FeedbackCenterTrendDeltaRead(BaseModel):
|
class FeedbackCenterTrendDeltaRead(BaseModel):
|
||||||
|
|
@ -166,6 +182,7 @@ class FeedbackCenterSummaryRead(BaseModel):
|
||||||
by_status: dict[str, int]
|
by_status: dict[str, int]
|
||||||
by_source_type: dict[str, int]
|
by_source_type: dict[str, int]
|
||||||
top_reasons: list[FeedbackCenterSummaryReasonRead]
|
top_reasons: list[FeedbackCenterSummaryReasonRead]
|
||||||
|
insights: list[FeedbackCenterInsightRead]
|
||||||
metrics: FeedbackCenterSummaryMetricsRead
|
metrics: FeedbackCenterSummaryMetricsRead
|
||||||
trend: FeedbackCenterTrendRead
|
trend: FeedbackCenterTrendRead
|
||||||
|
|
||||||
|
|
@ -197,6 +214,7 @@ __all__ = [
|
||||||
"FeedbackCenterRecordRevisionRead",
|
"FeedbackCenterRecordRevisionRead",
|
||||||
"FeedbackCenterRecordRead",
|
"FeedbackCenterRecordRead",
|
||||||
"FeedbackCenterSummaryReasonRead",
|
"FeedbackCenterSummaryReasonRead",
|
||||||
|
"FeedbackCenterInsightRead",
|
||||||
"FeedbackCenterSummaryMetricsRead",
|
"FeedbackCenterSummaryMetricsRead",
|
||||||
"FeedbackCenterTrendDailyRead",
|
"FeedbackCenterTrendDailyRead",
|
||||||
"FeedbackCenterTrendWindowRead",
|
"FeedbackCenterTrendWindowRead",
|
||||||
|
|
|
||||||
|
|
@ -30,7 +30,7 @@ class RAGAskRequest(BaseModel):
|
||||||
source_chat_id: Optional[str] = Field(default=None, max_length=128)
|
source_chat_id: Optional[str] = Field(default=None, max_length=128)
|
||||||
session_id: Optional[int] = Field(default=None, ge=1)
|
session_id: Optional[int] = Field(default=None, ge=1)
|
||||||
new_session: bool = Field(default=False)
|
new_session: bool = Field(default=False)
|
||||||
thinking_mode: str = Field(default="normal", pattern="^(normal|deep)$")
|
thinking_mode: str = Field(default="deep", pattern="^(normal|deep)$")
|
||||||
action_mode: str = Field(default="auto", pattern="^(auto|read|write|read_write)$")
|
action_mode: str = Field(default="auto", pattern="^(auto|read|write|read_write)$")
|
||||||
sync_requirement: bool = Field(default=True)
|
sync_requirement: bool = Field(default=True)
|
||||||
message_intent: Optional[str] = Field(default=None, max_length=32)
|
message_intent: Optional[str] = Field(default=None, max_length=32)
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,23 @@ from .shared import *
|
||||||
|
|
||||||
|
|
||||||
class AdaptiveRAGAnswerEntryMixin:
|
class AdaptiveRAGAnswerEntryMixin:
|
||||||
|
HIGH_ACCURACY_CHANNELS = {
|
||||||
|
"feishu",
|
||||||
|
"lark",
|
||||||
|
"feishu_group",
|
||||||
|
"feishu_dm",
|
||||||
|
"pmflow",
|
||||||
|
"pmflow_manual",
|
||||||
|
"/ai-pmo/process-text",
|
||||||
|
"ai-pmo-process-text",
|
||||||
|
"ai_pmo",
|
||||||
|
"ai_pmo_ui",
|
||||||
|
}
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _is_high_accuracy_channel(cls, asked_via: str) -> bool:
|
||||||
|
return str(asked_via or "").strip().lower() in cls.HIGH_ACCURACY_CHANNELS
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _should_enforce_scene_prompt_only(
|
def _should_enforce_scene_prompt_only(
|
||||||
*,
|
*,
|
||||||
|
|
@ -33,7 +50,11 @@ class AdaptiveRAGAnswerEntryMixin:
|
||||||
sync_requirement: bool,
|
sync_requirement: bool,
|
||||||
asked_via: str,
|
asked_via: str,
|
||||||
effective_action_mode: str,
|
effective_action_mode: str,
|
||||||
|
message_intent: Optional[str] = None,
|
||||||
) -> bool:
|
) -> bool:
|
||||||
|
intent = str(message_intent or "").strip().lower()
|
||||||
|
if intent in {"mixed", "question"}:
|
||||||
|
return False
|
||||||
return (
|
return (
|
||||||
bool(sync_requirement)
|
bool(sync_requirement)
|
||||||
and str(asked_via or "").strip().lower() != "feishu"
|
and str(asked_via or "").strip().lower() != "feishu"
|
||||||
|
|
@ -60,18 +81,15 @@ class AdaptiveRAGAnswerEntryMixin:
|
||||||
started = time.perf_counter()
|
started = time.perf_counter()
|
||||||
clean_q = str(question or "").strip()
|
clean_q = str(question or "").strip()
|
||||||
active_thinking_mode = self._normalize_thinking_mode(thinking_mode)
|
active_thinking_mode = self._normalize_thinking_mode(thinking_mode)
|
||||||
|
if self._is_high_accuracy_channel(asked_via):
|
||||||
|
active_thinking_mode = THINKING_MODE_DEEP
|
||||||
deep_thinking = active_thinking_mode == THINKING_MODE_DEEP
|
deep_thinking = active_thinking_mode == THINKING_MODE_DEEP
|
||||||
asked_via_lower = str(asked_via or "").strip().lower()
|
|
||||||
enforce_scene_prompt_only = self._should_enforce_scene_prompt_only(
|
enforce_scene_prompt_only = self._should_enforce_scene_prompt_only(
|
||||||
asked_via=asked_via,
|
asked_via=asked_via,
|
||||||
message_intent=message_intent,
|
message_intent=message_intent,
|
||||||
)
|
)
|
||||||
deep_retrieval_steps = DEEP_RETRIEVAL_STEPS
|
deep_retrieval_steps = DEEP_RETRIEVAL_STEPS
|
||||||
deep_vector_top_k = DEEP_VECTOR_TOP_K
|
deep_vector_top_k = DEEP_VECTOR_TOP_K
|
||||||
if asked_via_lower == "feishu":
|
|
||||||
# 飞书链路优先低时延,保留 deep 能力但收紧预算。
|
|
||||||
deep_retrieval_steps = min(DEEP_RETRIEVAL_STEPS, 3)
|
|
||||||
deep_vector_top_k = min(DEEP_VECTOR_TOP_K, 8)
|
|
||||||
if not clean_q:
|
if not clean_q:
|
||||||
return self._build_response_payload(
|
return self._build_response_payload(
|
||||||
answer="问题为空,无法检索。",
|
answer="问题为空,无法检索。",
|
||||||
|
|
@ -117,6 +135,7 @@ class AdaptiveRAGAnswerEntryMixin:
|
||||||
sync_requirement=sync_requirement,
|
sync_requirement=sync_requirement,
|
||||||
asked_via=asked_via,
|
asked_via=asked_via,
|
||||||
effective_action_mode=effective_action_mode,
|
effective_action_mode=effective_action_mode,
|
||||||
|
message_intent=message_intent,
|
||||||
):
|
):
|
||||||
ack_result = await self._handle_ack_first_requirement(
|
ack_result = await self._handle_ack_first_requirement(
|
||||||
question=clean_q,
|
question=clean_q,
|
||||||
|
|
@ -149,7 +168,7 @@ class AdaptiveRAGAnswerEntryMixin:
|
||||||
effective_q=effective_q,
|
effective_q=effective_q,
|
||||||
rewrite_meta=rewrite_meta,
|
rewrite_meta=rewrite_meta,
|
||||||
query_type=query_type,
|
query_type=query_type,
|
||||||
thinking_mode=thinking_mode,
|
thinking_mode=active_thinking_mode,
|
||||||
active_thinking_mode=active_thinking_mode,
|
active_thinking_mode=active_thinking_mode,
|
||||||
deep_thinking=deep_thinking,
|
deep_thinking=deep_thinking,
|
||||||
enforce_scene_prompt_only=enforce_scene_prompt_only,
|
enforce_scene_prompt_only=enforce_scene_prompt_only,
|
||||||
|
|
|
||||||
|
|
@ -171,6 +171,7 @@ from ..ai_pmo_intent import (
|
||||||
ROUTE_TASK_UPDATE,
|
ROUTE_TASK_UPDATE,
|
||||||
ROUTE_SIGNAL_SOURCE_ONBOARD,
|
ROUTE_SIGNAL_SOURCE_ONBOARD,
|
||||||
ROUTE_TASK_CREATE,
|
ROUTE_TASK_CREATE,
|
||||||
|
WORKFLOW_ROUTE_KEYS,
|
||||||
SESSION_SHORT_CONFIRM_WINDOW_SECONDS,
|
SESSION_SHORT_CONFIRM_WINDOW_SECONDS,
|
||||||
SHORT_CONFIRM_HINTS,
|
SHORT_CONFIRM_HINTS,
|
||||||
SHORT_CONFIRM_MAX_MESSAGE_LENGTH,
|
SHORT_CONFIRM_MAX_MESSAGE_LENGTH,
|
||||||
|
|
|
||||||
|
|
@ -246,10 +246,16 @@ class DraftConfirmHandler:
|
||||||
|
|
||||||
state = str(locked.state or "").strip()
|
state = str(locked.state or "").strip()
|
||||||
if state != STATE_DRAFT_PENDING_CONFIRM:
|
if state != STATE_DRAFT_PENDING_CONFIRM:
|
||||||
|
if state == STATE_SUCCEEDED:
|
||||||
|
followup_reply = "已入库成功,无需重复确认。"
|
||||||
|
elif state == STATE_CANCELLED:
|
||||||
|
followup_reply = "该草稿已取消,无需重复确认。"
|
||||||
|
else:
|
||||||
|
followup_reply = "该草稿已处理,无需重复确认。"
|
||||||
return {
|
return {
|
||||||
"handled": True,
|
"handled": True,
|
||||||
"status": "succeeded" if state == STATE_SUCCEEDED else "failed_safe",
|
"status": "succeeded" if state == STATE_SUCCEEDED else "failed_safe",
|
||||||
"followup_reply": "该草稿已处理,无需重复确认。",
|
"followup_reply": followup_reply,
|
||||||
"target_work_item_id": int(locked.id),
|
"target_work_item_id": int(locked.id),
|
||||||
"target_state": state or None,
|
"target_state": state or None,
|
||||||
"target_intent": str(locked.intent or "").strip() or None,
|
"target_intent": str(locked.intent or "").strip() or None,
|
||||||
|
|
|
||||||
|
|
@ -17,7 +17,7 @@ from .shared import (
|
||||||
and_,
|
and_,
|
||||||
select,
|
select,
|
||||||
timedelta,
|
timedelta,
|
||||||
now_cn_aware,
|
now_cn_naive,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -35,7 +35,7 @@ class DailyBriefingPersonalMixin:
|
||||||
if not user:
|
if not user:
|
||||||
raise ValueError(f"User {user_id} not found")
|
raise ValueError(f"User {user_id} not found")
|
||||||
|
|
||||||
now = now_cn_aware()
|
now = now_cn_naive()
|
||||||
yesterday = now - timedelta(days=1)
|
yesterday = now - timedelta(days=1)
|
||||||
|
|
||||||
relevant_data = await self._collect_relevant_data(
|
relevant_data = await self._collect_relevant_data(
|
||||||
|
|
|
||||||
|
|
@ -21,7 +21,7 @@ from ...models import (
|
||||||
User,
|
User,
|
||||||
UserRole,
|
UserRole,
|
||||||
)
|
)
|
||||||
from ...utils.timezone import now_cn_aware
|
from ...utils.timezone import now_cn_aware, now_cn_naive
|
||||||
from ..llm_service import LLMService
|
from ..llm_service import LLMService
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
|
|
@ -45,6 +45,7 @@ __all__ = [
|
||||||
"func",
|
"func",
|
||||||
"json",
|
"json",
|
||||||
"now_cn_aware",
|
"now_cn_aware",
|
||||||
|
"now_cn_naive",
|
||||||
"select",
|
"select",
|
||||||
"text",
|
"text",
|
||||||
"timedelta",
|
"timedelta",
|
||||||
|
|
|
||||||
|
|
@ -129,11 +129,15 @@ def _trend_accumulator(date_value: date | None = None) -> dict[str, Any]:
|
||||||
"override_count": 0,
|
"override_count": 0,
|
||||||
"rework_count": 0,
|
"rework_count": 0,
|
||||||
"revision_count": 0,
|
"revision_count": 0,
|
||||||
|
"avg_confidence": 0.0,
|
||||||
|
"top_reasons": [],
|
||||||
"_breakdown_task_ids": set(),
|
"_breakdown_task_ids": set(),
|
||||||
"_override_task_ids": set(),
|
"_override_task_ids": set(),
|
||||||
"_auto_assigned_task_ids": set(),
|
"_auto_assigned_task_ids": set(),
|
||||||
"_latencies": [],
|
"_latencies": [],
|
||||||
"_reworked_record_ids": set(),
|
"_reworked_record_ids": set(),
|
||||||
|
"_confidences": [],
|
||||||
|
"_reason_counts": {},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -143,14 +147,31 @@ def _finalize_trend_accumulator(accumulator: dict[str, Any]) -> dict[str, Any]:
|
||||||
auto_assigned_count = len(accumulator.pop("_auto_assigned_task_ids", set()))
|
auto_assigned_count = len(accumulator.pop("_auto_assigned_task_ids", set()))
|
||||||
rework_count = len(accumulator.pop("_reworked_record_ids", set()))
|
rework_count = len(accumulator.pop("_reworked_record_ids", set()))
|
||||||
latencies = accumulator.pop("_latencies", [])
|
latencies = accumulator.pop("_latencies", [])
|
||||||
|
confidences = accumulator.pop("_confidences", [])
|
||||||
|
reason_counts = accumulator.pop("_reason_counts", {})
|
||||||
accepted_count = int(accumulator.get("accepted_count", 0))
|
accepted_count = int(accumulator.get("accepted_count", 0))
|
||||||
rejected_count = int(accumulator.get("rejected_count", 0))
|
rejected_count = int(accumulator.get("rejected_count", 0))
|
||||||
reviewed_count = int(accumulator.get("reviewed_count", 0))
|
reviewed_count = int(accumulator.get("reviewed_count", 0))
|
||||||
pending_count = int(accumulator.get("pending_count", 0))
|
pending_count = int(accumulator.get("pending_count", 0))
|
||||||
total_records = int(accumulator.get("total_records", 0))
|
total_records = int(accumulator.get("total_records", 0))
|
||||||
avg_review_latency_minutes = sum(latencies) / len(latencies) if latencies else 0.0
|
avg_review_latency_minutes = sum(latencies) / len(latencies) if latencies else 0.0
|
||||||
|
avg_confidence = sum(confidences) / len(confidences) if confidences else 0.0
|
||||||
reviewed_decisions = accepted_count + rejected_count
|
reviewed_decisions = accepted_count + rejected_count
|
||||||
|
|
||||||
|
reason_catalog = {item["code"]: item["label"] for item in list_reason_catalog()}
|
||||||
|
top_reasons = [
|
||||||
|
{
|
||||||
|
"code": str(code),
|
||||||
|
"label": reason_catalog.get(str(code), str(code)),
|
||||||
|
"count": int(count or 0),
|
||||||
|
}
|
||||||
|
for code, count in sorted(
|
||||||
|
reason_counts.items(),
|
||||||
|
key=lambda item: (-int(item[1] or 0), str(item[0] or "")),
|
||||||
|
)[:5]
|
||||||
|
if str(code).strip()
|
||||||
|
]
|
||||||
|
|
||||||
accumulator["task_breakdown_count"] = breakdown_count
|
accumulator["task_breakdown_count"] = breakdown_count
|
||||||
accumulator["task_override_count"] = override_count
|
accumulator["task_override_count"] = override_count
|
||||||
accumulator["qa_feedback_count"] = int(accumulator.get("qa_feedback_count", 0))
|
accumulator["qa_feedback_count"] = int(accumulator.get("qa_feedback_count", 0))
|
||||||
|
|
@ -164,6 +185,8 @@ def _finalize_trend_accumulator(accumulator: dict[str, Any]) -> dict[str, Any]:
|
||||||
accumulator["rework_rate"] = rework_count / total_records if total_records else 0.0
|
accumulator["rework_rate"] = rework_count / total_records if total_records else 0.0
|
||||||
accumulator["auto_assigned_rate"] = auto_assigned_count / breakdown_count if breakdown_count else 0.0
|
accumulator["auto_assigned_rate"] = auto_assigned_count / breakdown_count if breakdown_count else 0.0
|
||||||
accumulator["avg_review_latency_minutes"] = avg_review_latency_minutes
|
accumulator["avg_review_latency_minutes"] = avg_review_latency_minutes
|
||||||
|
accumulator["avg_confidence"] = avg_confidence
|
||||||
|
accumulator["top_reasons"] = top_reasons
|
||||||
accumulator["reviewed_count"] = reviewed_count
|
accumulator["reviewed_count"] = reviewed_count
|
||||||
accumulator["pending_count"] = pending_count
|
accumulator["pending_count"] = pending_count
|
||||||
accumulator["accepted_count"] = accepted_count
|
accumulator["accepted_count"] = accepted_count
|
||||||
|
|
@ -174,6 +197,43 @@ def _finalize_trend_accumulator(accumulator: dict[str, Any]) -> dict[str, Any]:
|
||||||
return accumulator
|
return accumulator
|
||||||
|
|
||||||
|
|
||||||
|
def _scope_section_for(source_group: str) -> str:
|
||||||
|
if source_group == "qa":
|
||||||
|
return "qa"
|
||||||
|
if source_group == "signal":
|
||||||
|
return "signal"
|
||||||
|
return "task"
|
||||||
|
|
||||||
|
|
||||||
|
def _make_insight(
|
||||||
|
*,
|
||||||
|
code: str,
|
||||||
|
title: str,
|
||||||
|
description: str,
|
||||||
|
severity: str,
|
||||||
|
action_label: str,
|
||||||
|
target_section: str,
|
||||||
|
target_source_type: str | None = None,
|
||||||
|
target_review_status: str | None = None,
|
||||||
|
target_reason_code: str | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"code": code,
|
||||||
|
"title": title,
|
||||||
|
"description": description,
|
||||||
|
"severity": severity,
|
||||||
|
"action_label": action_label,
|
||||||
|
"target_section": target_section,
|
||||||
|
"target_source_type": target_source_type,
|
||||||
|
"target_review_status": target_review_status,
|
||||||
|
"target_reason_code": target_reason_code,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _severity_rank(value: str) -> int:
|
||||||
|
return {"critical": 3, "warning": 2, "info": 1}.get(str(value), 0)
|
||||||
|
|
||||||
|
|
||||||
class FeedbackCenterService:
|
class FeedbackCenterService:
|
||||||
"""统一反馈中心服务。"""
|
"""统一反馈中心服务。"""
|
||||||
|
|
||||||
|
|
@ -290,11 +350,250 @@ class FeedbackCenterService:
|
||||||
if revision_count > 1:
|
if revision_count > 1:
|
||||||
accumulator["_reworked_record_ids"].add(int(record.id))
|
accumulator["_reworked_record_ids"].add(int(record.id))
|
||||||
|
|
||||||
|
if record.ai_confidence is not None:
|
||||||
|
try:
|
||||||
|
accumulator["_confidences"].append(float(record.ai_confidence))
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
reason_code = str(record.reason_code or "").strip()
|
||||||
|
if reason_code:
|
||||||
|
reason_counts = accumulator.setdefault("_reason_counts", {})
|
||||||
|
reason_counts[reason_code] = int(reason_counts.get(reason_code, 0) or 0) + 1
|
||||||
|
|
||||||
if record.created_at and record.reviewed_at:
|
if record.created_at and record.reviewed_at:
|
||||||
delta = (record.reviewed_at - record.created_at).total_seconds() / 60
|
delta = (record.reviewed_at - record.created_at).total_seconds() / 60
|
||||||
if delta >= 0:
|
if delta >= 0:
|
||||||
accumulator["_latencies"].append(delta)
|
accumulator["_latencies"].append(delta)
|
||||||
|
|
||||||
|
def _build_insights(self, *, source_group: str, summary: dict[str, Any]) -> list[dict[str, Any]]:
|
||||||
|
trend = summary.get("trend") or {}
|
||||||
|
current = trend.get("current") or {}
|
||||||
|
previous = trend.get("previous") or {}
|
||||||
|
deltas = trend.get("deltas") or {}
|
||||||
|
total = int(current.get("total_records") or 0)
|
||||||
|
backlog_count = int(current.get("pending_count") or 0) + int(current.get("reviewed_count") or 0)
|
||||||
|
accepted_count = int(current.get("accepted_count") or 0)
|
||||||
|
rejected_count = int(current.get("rejected_count") or 0)
|
||||||
|
pending_count = int(current.get("pending_count") or 0)
|
||||||
|
top_reasons = current.get("top_reasons") or []
|
||||||
|
target_section = _scope_section_for(source_group)
|
||||||
|
|
||||||
|
def _percent(value: float) -> str:
|
||||||
|
return f"{max(0.0, float(value)):.0%}"
|
||||||
|
|
||||||
|
def _minutes(value: float) -> str:
|
||||||
|
minutes = max(0.0, float(value))
|
||||||
|
if minutes < 60:
|
||||||
|
return f"{minutes:.0f}m"
|
||||||
|
hours = int(minutes // 60)
|
||||||
|
remainder = int(round(minutes % 60))
|
||||||
|
return f"{hours}h {remainder}m"
|
||||||
|
|
||||||
|
def _append(
|
||||||
|
items: list[dict[str, Any]],
|
||||||
|
*,
|
||||||
|
code: str,
|
||||||
|
title: str,
|
||||||
|
description: str,
|
||||||
|
severity: str,
|
||||||
|
action_label: str,
|
||||||
|
target_section: str = target_section,
|
||||||
|
target_source_type: str | None = None,
|
||||||
|
target_review_status: str | None = None,
|
||||||
|
target_reason_code: str | None = None,
|
||||||
|
) -> None:
|
||||||
|
items.append(
|
||||||
|
_make_insight(
|
||||||
|
code=code,
|
||||||
|
title=title,
|
||||||
|
description=description,
|
||||||
|
severity=severity,
|
||||||
|
action_label=action_label,
|
||||||
|
target_section=target_section,
|
||||||
|
target_source_type=target_source_type,
|
||||||
|
target_review_status=target_review_status,
|
||||||
|
target_reason_code=target_reason_code,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
if total <= 0:
|
||||||
|
return [
|
||||||
|
_make_insight(
|
||||||
|
code="trend_stable",
|
||||||
|
title="当前还没有样本",
|
||||||
|
description="先让任务、QA 或信号积累一些反馈,趋势页就会开始给出决策建议。",
|
||||||
|
severity="info",
|
||||||
|
action_label="去看全部记录",
|
||||||
|
target_section=target_section,
|
||||||
|
)
|
||||||
|
]
|
||||||
|
|
||||||
|
insights: list[dict[str, Any]] = []
|
||||||
|
backlog_ratio = backlog_count / total if total else 0.0
|
||||||
|
accepted_rate_reviewed = float(current.get("accepted_rate_reviewed") or 0.0)
|
||||||
|
manual_override_rate = float(current.get("manual_override_rate") or 0.0)
|
||||||
|
rework_rate = float(current.get("rework_rate") or 0.0)
|
||||||
|
auto_assigned_rate = float(current.get("auto_assigned_rate") or 0.0)
|
||||||
|
avg_review_latency_minutes = float(current.get("avg_review_latency_minutes") or 0.0)
|
||||||
|
avg_confidence = float(current.get("avg_confidence") or 0.0)
|
||||||
|
|
||||||
|
if backlog_count >= max(8, int(total * 0.35)):
|
||||||
|
severity = "critical" if backlog_count >= max(20, int(total * 0.5)) else "warning"
|
||||||
|
_append(
|
||||||
|
insights,
|
||||||
|
code="backlog_pressure",
|
||||||
|
title="待复核积压偏高",
|
||||||
|
description=f"当前 backlog {backlog_count} 条,占总样本 {_percent(backlog_ratio)}。",
|
||||||
|
severity=severity,
|
||||||
|
action_label="查看待复核记录",
|
||||||
|
target_review_status="pending",
|
||||||
|
)
|
||||||
|
|
||||||
|
if source_group in {"all", "task"} and (
|
||||||
|
manual_override_rate >= 0.3 or float(deltas.get("manual_override_rate_delta") or 0.0) >= 0.05
|
||||||
|
):
|
||||||
|
severity = "critical" if manual_override_rate >= 0.45 else "warning"
|
||||||
|
_append(
|
||||||
|
insights,
|
||||||
|
code="manual_override_pressure",
|
||||||
|
title="人工覆盖偏高",
|
||||||
|
description=(
|
||||||
|
f"任务覆盖率 {_percent(manual_override_rate)},"
|
||||||
|
f"上期 {_percent(float(previous.get('manual_override_rate') or 0.0))}。"
|
||||||
|
),
|
||||||
|
severity=severity,
|
||||||
|
action_label="查看任务覆盖样本",
|
||||||
|
target_source_type="task_override",
|
||||||
|
)
|
||||||
|
|
||||||
|
if source_group in {"all", "task"} and (
|
||||||
|
rework_rate >= 0.2 or float(deltas.get("rework_rate_delta") or 0.0) >= 0.05
|
||||||
|
):
|
||||||
|
severity = "critical" if rework_rate >= 0.35 else "warning"
|
||||||
|
_append(
|
||||||
|
insights,
|
||||||
|
code="rework_pressure",
|
||||||
|
title="返工率上升",
|
||||||
|
description=(
|
||||||
|
f"存在二次修订的样本占比 {_percent(rework_rate)},"
|
||||||
|
f"上期 {_percent(float(previous.get('rework_rate') or 0.0))}。"
|
||||||
|
),
|
||||||
|
severity=severity,
|
||||||
|
action_label="查看返工样本",
|
||||||
|
target_source_type="task_override",
|
||||||
|
)
|
||||||
|
|
||||||
|
if source_group in {"all", "task"} and (
|
||||||
|
auto_assigned_rate <= 0.5 or float(deltas.get("auto_assigned_rate_delta") or 0.0) <= -0.05
|
||||||
|
):
|
||||||
|
severity = "critical" if auto_assigned_rate <= 0.3 else "warning"
|
||||||
|
_append(
|
||||||
|
insights,
|
||||||
|
code="auto_assignment_gap",
|
||||||
|
title="自动指派偏弱",
|
||||||
|
description=(
|
||||||
|
f"任务拆解自动落负责人比例 {_percent(auto_assigned_rate)},"
|
||||||
|
f"上期 {_percent(float(previous.get('auto_assigned_rate') or 0.0))}。"
|
||||||
|
),
|
||||||
|
severity=severity,
|
||||||
|
action_label="查看任务拆解",
|
||||||
|
target_source_type="task_breakdown",
|
||||||
|
)
|
||||||
|
|
||||||
|
if accepted_count + rejected_count > 0 and (
|
||||||
|
accepted_rate_reviewed < 0.65 or float(deltas.get("accepted_rate_reviewed_delta") or 0.0) <= -0.05
|
||||||
|
):
|
||||||
|
severity = "critical" if accepted_rate_reviewed < 0.5 else "warning"
|
||||||
|
_append(
|
||||||
|
insights,
|
||||||
|
code="accepted_rate_drop",
|
||||||
|
title="采纳率走弱",
|
||||||
|
description=(
|
||||||
|
f"已决策样本中进入学习池的比例 {_percent(accepted_rate_reviewed)},"
|
||||||
|
f"上期 {_percent(float(previous.get('accepted_rate_reviewed') or 0.0))}。"
|
||||||
|
),
|
||||||
|
severity=severity,
|
||||||
|
action_label="查看低采纳样本",
|
||||||
|
target_review_status="rejected",
|
||||||
|
)
|
||||||
|
|
||||||
|
if avg_review_latency_minutes >= 120 or float(deltas.get("avg_review_latency_minutes_delta") or 0.0) >= 30:
|
||||||
|
severity = "critical" if avg_review_latency_minutes >= 240 else "warning"
|
||||||
|
_append(
|
||||||
|
insights,
|
||||||
|
code="latency_pressure",
|
||||||
|
title="复核耗时偏长",
|
||||||
|
description=(
|
||||||
|
f"样本从生成到复核平均 {_minutes(avg_review_latency_minutes)},"
|
||||||
|
f"上期 {_minutes(float(previous.get('avg_review_latency_minutes') or 0.0))}。"
|
||||||
|
),
|
||||||
|
severity=severity,
|
||||||
|
action_label="查看待复核记录",
|
||||||
|
target_review_status="pending",
|
||||||
|
)
|
||||||
|
|
||||||
|
if source_group in {"all", "qa"} and accepted_rate_reviewed < 0.7 and pending_count > 0:
|
||||||
|
_append(
|
||||||
|
insights,
|
||||||
|
code="qa_acceptance_pressure",
|
||||||
|
title="QA 反馈转化率偏低",
|
||||||
|
description=f"当前 QA 相关样本里,进入学习池的比例只有 {_percent(accepted_rate_reviewed)}。",
|
||||||
|
severity="warning",
|
||||||
|
action_label="查看 QA 反馈",
|
||||||
|
target_section="qa",
|
||||||
|
)
|
||||||
|
|
||||||
|
if source_group in {"all", "signal"} and avg_confidence < 0.7 and pending_count > 0:
|
||||||
|
_append(
|
||||||
|
insights,
|
||||||
|
code="signal_confidence_pressure",
|
||||||
|
title="信号判断置信度偏低",
|
||||||
|
description=f"当前 scope 的平均置信度只有 {_percent(avg_confidence)},适合优先复核。",
|
||||||
|
severity="warning",
|
||||||
|
action_label="查看信号反馈",
|
||||||
|
target_section="signal",
|
||||||
|
)
|
||||||
|
|
||||||
|
top_reason = next((item for item in top_reasons if item.get("code")), None)
|
||||||
|
if top_reason:
|
||||||
|
top_reason_count = int(top_reason.get("count") or 0)
|
||||||
|
reason_ratio = top_reason_count / total if total else 0.0
|
||||||
|
if reason_ratio >= 0.3:
|
||||||
|
severity = "critical" if reason_ratio >= 0.5 else "warning"
|
||||||
|
_append(
|
||||||
|
insights,
|
||||||
|
code="reason_concentration",
|
||||||
|
title=f"高频原因:{top_reason.get('label') or top_reason.get('code')}",
|
||||||
|
description=(
|
||||||
|
f"最近 {top_reason_count} 条样本都在指向同一个原因,"
|
||||||
|
"适合直接看同类记录确认根因。"
|
||||||
|
),
|
||||||
|
severity=severity,
|
||||||
|
action_label="查看同类样本",
|
||||||
|
target_reason_code=str(top_reason.get("code")),
|
||||||
|
)
|
||||||
|
|
||||||
|
if not insights:
|
||||||
|
insights.append(
|
||||||
|
_make_insight(
|
||||||
|
code="trend_stable",
|
||||||
|
title="当前趋势稳定",
|
||||||
|
description="最近的样本量和关键比率都没有明显异常,可以继续观察。",
|
||||||
|
severity="info",
|
||||||
|
action_label="查看当前范围样本",
|
||||||
|
target_section=target_section,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
insights.sort(
|
||||||
|
key=lambda item: (
|
||||||
|
-_severity_rank(str(item.get("severity", "info"))),
|
||||||
|
str(item.get("code") or ""),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return insights
|
||||||
|
|
||||||
async def _build_trend(
|
async def _build_trend(
|
||||||
self,
|
self,
|
||||||
*,
|
*,
|
||||||
|
|
@ -318,9 +617,18 @@ class FeedbackCenterService:
|
||||||
.where(
|
.where(
|
||||||
and_(
|
and_(
|
||||||
FeedbackCenterRecord.tenant_id == int(tenant_id),
|
FeedbackCenterRecord.tenant_id == int(tenant_id),
|
||||||
|
or_(
|
||||||
|
and_(
|
||||||
FeedbackCenterRecord.created_at.is_not(None),
|
FeedbackCenterRecord.created_at.is_not(None),
|
||||||
FeedbackCenterRecord.created_at >= start_dt,
|
FeedbackCenterRecord.created_at >= start_dt,
|
||||||
FeedbackCenterRecord.created_at < end_dt,
|
FeedbackCenterRecord.created_at < end_dt,
|
||||||
|
),
|
||||||
|
and_(
|
||||||
|
FeedbackCenterRecord.reviewed_at.is_not(None),
|
||||||
|
FeedbackCenterRecord.reviewed_at >= start_dt,
|
||||||
|
FeedbackCenterRecord.reviewed_at < end_dt,
|
||||||
|
),
|
||||||
|
),
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
@ -337,18 +645,20 @@ class FeedbackCenterService:
|
||||||
previous_window = _trend_accumulator()
|
previous_window = _trend_accumulator()
|
||||||
|
|
||||||
for record in records:
|
for record in records:
|
||||||
|
reviewed_at = getattr(record, "reviewed_at", None)
|
||||||
created_at = getattr(record, "created_at", None)
|
created_at = getattr(record, "created_at", None)
|
||||||
if created_at is None:
|
activity_at = reviewed_at if reviewed_at and start_dt <= reviewed_at < end_dt else created_at
|
||||||
|
if activity_at is None:
|
||||||
continue
|
continue
|
||||||
created_date = created_at.date()
|
activity_date = activity_at.date()
|
||||||
revision_count = len(record.revisions or [])
|
revision_count = len(record.revisions or [])
|
||||||
if current_start <= created_date <= today:
|
if current_start <= activity_date <= today:
|
||||||
bucket = current_daily.get(created_date)
|
bucket = current_daily.get(activity_date)
|
||||||
if bucket is not None:
|
if bucket is not None:
|
||||||
self._trend_add_record(bucket, record, revision_count)
|
self._trend_add_record(bucket, record, revision_count)
|
||||||
self._trend_add_record(current_window, record, revision_count)
|
self._trend_add_record(current_window, record, revision_count)
|
||||||
continue
|
continue
|
||||||
if previous_start <= created_date < current_start:
|
if previous_start <= activity_date < current_start:
|
||||||
self._trend_add_record(previous_window, record, revision_count)
|
self._trend_add_record(previous_window, record, revision_count)
|
||||||
|
|
||||||
daily = [_finalize_trend_accumulator(current_daily[current_start + timedelta(days=index)]) for index in range(window_days)]
|
daily = [_finalize_trend_accumulator(current_daily[current_start + timedelta(days=index)]) for index in range(window_days)]
|
||||||
|
|
@ -558,6 +868,23 @@ class FeedbackCenterService:
|
||||||
"by_status": by_status,
|
"by_status": by_status,
|
||||||
"by_source_type": by_source_type,
|
"by_source_type": by_source_type,
|
||||||
"top_reasons": top_reasons,
|
"top_reasons": top_reasons,
|
||||||
|
"insights": self._build_insights(source_group=source_group, summary={
|
||||||
|
"total": total,
|
||||||
|
"backlog_count": backlog_count,
|
||||||
|
"accepted_count": accepted_count,
|
||||||
|
"rejected_count": rejected_count,
|
||||||
|
"by_status": by_status,
|
||||||
|
"top_reasons": top_reasons,
|
||||||
|
"metrics": {
|
||||||
|
"accepted_rate_reviewed": accepted_rate_reviewed,
|
||||||
|
"manual_override_rate": manual_override_rate,
|
||||||
|
"rework_rate": rework_rate,
|
||||||
|
"auto_assigned_rate": auto_assigned_rate,
|
||||||
|
"avg_review_latency_minutes": avg_review_latency_minutes,
|
||||||
|
},
|
||||||
|
"trend": trend,
|
||||||
|
"avg_confidence": avg_confidence,
|
||||||
|
}),
|
||||||
"metrics": {
|
"metrics": {
|
||||||
"accepted_rate_reviewed": accepted_rate_reviewed,
|
"accepted_rate_reviewed": accepted_rate_reviewed,
|
||||||
"manual_override_rate": manual_override_rate,
|
"manual_override_rate": manual_override_rate,
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,10 @@ from .shared import (
|
||||||
now_cn_aware,
|
now_cn_aware,
|
||||||
time,
|
time,
|
||||||
)
|
)
|
||||||
|
from lark_oapi.event.callback.model.p2_card_action_trigger import (
|
||||||
|
CallBackToast,
|
||||||
|
P2CardActionTriggerResponse,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class LongConnectionRuntimeMixin:
|
class LongConnectionRuntimeMixin:
|
||||||
|
|
@ -25,9 +29,19 @@ class LongConnectionRuntimeMixin:
|
||||||
verification_token: str = "",
|
verification_token: str = "",
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Run blocking SDK websocket client in dedicated thread."""
|
"""Run blocking SDK websocket client in dedicated thread."""
|
||||||
|
ws_loop: Optional[asyncio.AbstractEventLoop] = None
|
||||||
|
previous_ws_loop: Optional[asyncio.AbstractEventLoop] = None
|
||||||
|
lark_ws_client = None
|
||||||
try:
|
try:
|
||||||
ensure_feishu_tls_trust_bundle()
|
ensure_feishu_tls_trust_bundle()
|
||||||
import lark_oapi as lark # type: ignore
|
import lark_oapi as lark # type: ignore
|
||||||
|
import lark_oapi.ws.client as lark_ws_client_module # type: ignore
|
||||||
|
|
||||||
|
lark_ws_client = lark_ws_client_module
|
||||||
|
previous_ws_loop = getattr(lark_ws_client_module, "loop", None)
|
||||||
|
ws_loop = asyncio.new_event_loop()
|
||||||
|
asyncio.set_event_loop(ws_loop)
|
||||||
|
lark_ws_client_module.loop = ws_loop
|
||||||
|
|
||||||
builder = lark.EventDispatcherHandler.builder(encrypt_key or "", verification_token or "")
|
builder = lark.EventDispatcherHandler.builder(encrypt_key or "", verification_token or "")
|
||||||
register_receive = getattr(builder, "register_p2_im_message_receive_v1", None)
|
register_receive = getattr(builder, "register_p2_im_message_receive_v1", None)
|
||||||
|
|
@ -36,7 +50,7 @@ class LongConnectionRuntimeMixin:
|
||||||
builder = register_receive(self._on_message_event)
|
builder = register_receive(self._on_message_event)
|
||||||
register_card_action = getattr(builder, "register_p2_card_action_trigger", None)
|
register_card_action = getattr(builder, "register_p2_card_action_trigger", None)
|
||||||
if callable(register_card_action):
|
if callable(register_card_action):
|
||||||
builder = register_card_action(self._on_message_event)
|
builder = register_card_action(self._on_card_action_event)
|
||||||
|
|
||||||
register_read = getattr(builder, "register_p2_im_message_message_read_v1", None)
|
register_read = getattr(builder, "register_p2_im_message_message_read_v1", None)
|
||||||
if callable(register_read):
|
if callable(register_read):
|
||||||
|
|
@ -78,6 +92,22 @@ class LongConnectionRuntimeMixin:
|
||||||
except Exception as exc: # pragma: no cover
|
except Exception as exc: # pragma: no cover
|
||||||
logger.warning(f"feishu_long_connection_client_cleanup_failed: {exc}")
|
logger.warning(f"feishu_long_connection_client_cleanup_failed: {exc}")
|
||||||
self._client = None
|
self._client = None
|
||||||
|
if ws_loop is not None:
|
||||||
|
try:
|
||||||
|
if ws_loop.is_running():
|
||||||
|
ws_loop.stop()
|
||||||
|
except Exception: # pragma: no cover
|
||||||
|
pass
|
||||||
|
try:
|
||||||
|
ws_loop.close()
|
||||||
|
except Exception as exc: # pragma: no cover
|
||||||
|
logger.warning(f"feishu_long_connection_ws_loop_cleanup_failed: {exc}")
|
||||||
|
if lark_ws_client is not None and previous_ws_loop is not None:
|
||||||
|
lark_ws_client.loop = previous_ws_loop
|
||||||
|
try:
|
||||||
|
asyncio.set_event_loop(previous_ws_loop)
|
||||||
|
except Exception: # pragma: no cover
|
||||||
|
pass
|
||||||
if self._running and not self._stopping:
|
if self._running and not self._stopping:
|
||||||
self._last_disconnect_at = now_cn_aware().isoformat()
|
self._last_disconnect_at = now_cn_aware().isoformat()
|
||||||
if not self._last_error_code:
|
if not self._last_error_code:
|
||||||
|
|
@ -174,6 +204,28 @@ class LongConnectionRuntimeMixin:
|
||||||
self._set_error(str(exc), code="enqueue_error", retryable=True)
|
self._set_error(str(exc), code="enqueue_error", retryable=True)
|
||||||
logger.warning(f"feishu_long_connection_enqueue_failed: {exc}")
|
logger.warning(f"feishu_long_connection_enqueue_failed: {exc}")
|
||||||
|
|
||||||
|
def _on_card_action_event(self, data: Any) -> P2CardActionTriggerResponse:
|
||||||
|
"""SDK callback: ack card actions immediately and queue them for async processing."""
|
||||||
|
payload = self._to_dict(data)
|
||||||
|
if self._running and self._loop and isinstance(payload, dict):
|
||||||
|
try:
|
||||||
|
payload["_perf_t0"] = float(time.perf_counter())
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
try:
|
||||||
|
self._loop.call_soon_threadsafe(self._queue.put_nowait, payload)
|
||||||
|
except Exception as exc: # pragma: no cover
|
||||||
|
self._set_error(str(exc), code="enqueue_error", retryable=True)
|
||||||
|
logger.warning(f"feishu_long_connection_card_action_enqueue_failed: {exc}")
|
||||||
|
response = P2CardActionTriggerResponse()
|
||||||
|
response.toast = CallBackToast(
|
||||||
|
{
|
||||||
|
"type": "info",
|
||||||
|
"content": "已收到,正在处理",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return response
|
||||||
|
|
||||||
def _on_ignored_event(self, data: Any) -> None:
|
def _on_ignored_event(self, data: Any) -> None:
|
||||||
"""Ignore non-business events to avoid SDK processor-not-found noise."""
|
"""Ignore non-business events to avoid SDK processor-not-found noise."""
|
||||||
if not self._running:
|
if not self._running:
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,31 @@
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from .shared import *
|
from .shared import *
|
||||||
|
from ...ai_pmo.ai_pmo_intent import INTENT_IGNORE, INTENT_QUERY, IntentResolver
|
||||||
|
|
||||||
class SignalCaptureEntryMixin:
|
class SignalCaptureEntryMixin:
|
||||||
def __init__(self, db: AsyncSession):
|
def __init__(self, db: AsyncSession):
|
||||||
self.db = db
|
self.db = db
|
||||||
self.llm = LLMService()
|
self.llm = LLMService()
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _should_process_feishu_high_accuracy(
|
||||||
|
cls,
|
||||||
|
*,
|
||||||
|
message_text: str,
|
||||||
|
) -> tuple[bool, str]:
|
||||||
|
clean_text = str(message_text or "").strip()
|
||||||
|
if not clean_text:
|
||||||
|
return False, "empty"
|
||||||
|
|
||||||
|
resolved_intent = IntentResolver.resolve(text=clean_text, project_id=None)
|
||||||
|
resolved_name = str(resolved_intent.name or "").strip().lower()
|
||||||
|
if resolved_name in {INTENT_QUERY, INTENT_IGNORE}:
|
||||||
|
return True, f"intent:{resolved_name}"
|
||||||
|
if cls._is_question_like(clean_text):
|
||||||
|
return True, "question_like"
|
||||||
|
return False, "async_queue"
|
||||||
|
|
||||||
async def capture_from_feishu(
|
async def capture_from_feishu(
|
||||||
self,
|
self,
|
||||||
group_chat_id: str,
|
group_chat_id: str,
|
||||||
|
|
@ -64,6 +83,12 @@ class SignalCaptureEntryMixin:
|
||||||
kernel = AIPMOKernelService(self.db, tenant_id=tenant_id)
|
kernel = AIPMOKernelService(self.db, tenant_id=tenant_id)
|
||||||
try:
|
try:
|
||||||
should_sync_process = bool(force_sync)
|
should_sync_process = bool(force_sync)
|
||||||
|
sync_reason = "force_sync" if should_sync_process else ""
|
||||||
|
if not should_sync_process:
|
||||||
|
should_sync_process, sync_reason = self._should_process_feishu_high_accuracy(
|
||||||
|
message_text=cleaned_message,
|
||||||
|
)
|
||||||
|
|
||||||
enqueue_session = None
|
enqueue_session = None
|
||||||
if not should_sync_process:
|
if not should_sync_process:
|
||||||
enqueue_session = await kernel._get_or_create_session(
|
enqueue_session = await kernel._get_or_create_session(
|
||||||
|
|
@ -80,24 +105,21 @@ class SignalCaptureEntryMixin:
|
||||||
message_sent_at=message_sent_at,
|
message_sent_at=message_sent_at,
|
||||||
session_snapshot=session_snapshot,
|
session_snapshot=session_snapshot,
|
||||||
)
|
)
|
||||||
|
if should_sync_process:
|
||||||
|
sync_reason = "short_confirmation"
|
||||||
|
|
||||||
if should_sync_process:
|
if should_sync_process:
|
||||||
|
self._emit_ai_think(
|
||||||
|
"capture_sync_high_accuracy",
|
||||||
|
trace_id=trace_id,
|
||||||
|
reason=sync_reason or "high_accuracy_sync",
|
||||||
|
message_preview=cleaned_message[:120],
|
||||||
|
)
|
||||||
effective_message_sent_at = self._resolve_enqueue_message_sent_at(
|
effective_message_sent_at = self._resolve_enqueue_message_sent_at(
|
||||||
captured_dt=captured_dt,
|
captured_dt=captured_dt,
|
||||||
message_sent_at=message_sent_at,
|
message_sent_at=message_sent_at,
|
||||||
)
|
)
|
||||||
effective_session_snapshot = (
|
effective_session_snapshot = session_snapshot if isinstance(session_snapshot, dict) else None
|
||||||
session_snapshot
|
|
||||||
if isinstance(session_snapshot, dict)
|
|
||||||
else await self._build_short_confirm_session_snapshot(
|
|
||||||
session=enqueue_session,
|
|
||||||
tenant_id=int(tenant_id),
|
|
||||||
sender=proposer_name,
|
|
||||||
channel_ref=group_chat_id,
|
|
||||||
source_message_id=source_message_id,
|
|
||||||
fallback_snapshot=None,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
return await self._process_feishu_with_kernel_sync(
|
return await self._process_feishu_with_kernel_sync(
|
||||||
trace_id=trace_id,
|
trace_id=trace_id,
|
||||||
tenant_id=tenant_id,
|
tenant_id=tenant_id,
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ from app.services.adaptive_rag_service.mixin_deep_guard_answer import AdaptiveRA
|
||||||
from app.services.adaptive_rag_service.mixin_deterministic_query_core import (
|
from app.services.adaptive_rag_service.mixin_deterministic_query_core import (
|
||||||
AdaptiveRAGDeterministicQueryCoreMixin,
|
AdaptiveRAGDeterministicQueryCoreMixin,
|
||||||
)
|
)
|
||||||
|
from app.services.adaptive_rag_service.shared_part_02 import DEEP_RETRIEVAL_STEPS, DEEP_VECTOR_TOP_K
|
||||||
|
|
||||||
|
|
||||||
class _FakeAdaptiveRAGService(
|
class _FakeAdaptiveRAGService(
|
||||||
|
|
@ -83,6 +84,46 @@ async def test_answer_entry_write_mode_uses_requirement_ack_gate() -> None:
|
||||||
assert service.base_calls == 0
|
assert service.base_calls == 0
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_answer_entry_high_accuracy_channel_forces_deep_mode() -> None:
|
||||||
|
service = _FakeAdaptiveRAGService()
|
||||||
|
|
||||||
|
result = await service.answer_question(
|
||||||
|
question="最近有哪些项目?",
|
||||||
|
asked_by=1,
|
||||||
|
asked_via="pmflow",
|
||||||
|
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 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_mixed_request_skips_requirement_ack_gate() -> None:
|
||||||
|
service = _FakeAdaptiveRAGService()
|
||||||
|
|
||||||
|
result = await service.answer_question(
|
||||||
|
question="现在进展如何,并且新增任务确认流",
|
||||||
|
asked_by=1,
|
||||||
|
asked_via="pmflow_manual",
|
||||||
|
action_mode="read_write",
|
||||||
|
sync_requirement=True,
|
||||||
|
message_intent="mixed",
|
||||||
|
)
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_answer_entry_feishu_write_mode_still_goes_base_mainline() -> None:
|
async def test_answer_entry_feishu_write_mode_still_goes_base_mainline() -> None:
|
||||||
service = _FakeAdaptiveRAGService()
|
service = _FakeAdaptiveRAGService()
|
||||||
|
|
@ -98,8 +139,10 @@ async def test_answer_entry_feishu_write_mode_still_goes_base_mainline() -> None
|
||||||
assert result["processing_stage"] == "qa"
|
assert result["processing_stage"] == "qa"
|
||||||
assert service.ack_calls == 0
|
assert service.ack_calls == 0
|
||||||
assert service.base_calls == 1
|
assert service.base_calls == 1
|
||||||
assert int(service.base_kwargs["deep_retrieval_steps"]) <= 3
|
assert service.base_kwargs["active_thinking_mode"] == "deep"
|
||||||
assert int(service.base_kwargs["deep_vector_top_k"]) <= 8
|
assert service.base_kwargs["deep_thinking"] is True
|
||||||
|
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
|
@pytest.mark.asyncio
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ import pytest
|
||||||
from fastapi import FastAPI
|
from fastapi import FastAPI
|
||||||
from httpx import ASGITransport, AsyncClient
|
from httpx import ASGITransport, AsyncClient
|
||||||
|
|
||||||
|
from app.api.router import api_router
|
||||||
from app.api.ai_pmo_api import routes_core
|
from app.api.ai_pmo_api import routes_core
|
||||||
from app.api.rag_api import routes_query
|
from app.api.rag_api import routes_query
|
||||||
from app.core.database import get_db
|
from app.core.database import get_db
|
||||||
|
|
@ -166,3 +167,38 @@ async def test_http_ai_pmo_process_text_draft_pending_confirm_mainline(monkeypat
|
||||||
assert kwargs["source_system"] == "pmflow_manual"
|
assert kwargs["source_system"] == "pmflow_manual"
|
||||||
assert kwargs["message_text"] == "请创建任务并先给草稿"
|
assert kwargs["message_text"] == "请创建任务并先给草稿"
|
||||||
assert kwargs["project_hint_id"] == 12
|
assert kwargs["project_hint_id"] == 12
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_http_ai_pmo_work_items_list_smoke(monkeypatch) -> None:
|
||||||
|
class FakeResult:
|
||||||
|
def all(self):
|
||||||
|
return []
|
||||||
|
|
||||||
|
class FakeDB:
|
||||||
|
async def execute(self, *_args, **_kwargs):
|
||||||
|
return FakeResult()
|
||||||
|
|
||||||
|
async def _fake_get_db():
|
||||||
|
yield FakeDB()
|
||||||
|
|
||||||
|
app = _build_test_app()
|
||||||
|
app.dependency_overrides[get_db] = _fake_get_db
|
||||||
|
|
||||||
|
transport = ASGITransport(app=app)
|
||||||
|
async with AsyncClient(transport=transport, base_url="http://test") as client:
|
||||||
|
response = await client.get("/ai-pmo/work-items", params={"limit": 18})
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
payload = response.json()
|
||||||
|
assert payload["code"] == 0
|
||||||
|
assert payload["data"]["items"] == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_ai_pmo_api_router_uses_single_prefix() -> None:
|
||||||
|
route_paths = {str(getattr(route, "path", "")).strip() for route in api_router.routes}
|
||||||
|
|
||||||
|
assert "/ai-pmo/work-items" in route_paths
|
||||||
|
assert "/ai-pmo/process-text" in route_paths
|
||||||
|
assert "/ai-pmo/ai-pmo/work-items" not in route_paths
|
||||||
|
assert "/ai-pmo/ai-pmo/process-text" not in route_paths
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,7 @@ from app.services.ai_pmo.ai_pmo_intent import (
|
||||||
IntentResolver,
|
IntentResolver,
|
||||||
RouteResolver,
|
RouteResolver,
|
||||||
)
|
)
|
||||||
|
from app.services.ai_pmo.ai_pmo_kernel_common_core.imports import WORKFLOW_ROUTE_KEYS
|
||||||
from app.services.ai_pmo.ai_pmo_typed_executor_execute.service import TypedExecutorExecuteMixin
|
from app.services.ai_pmo.ai_pmo_typed_executor_execute.service import TypedExecutorExecuteMixin
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -79,6 +80,11 @@ def test_route_resolver_followup_action_supports_message_text_fallback() -> None
|
||||||
assert action == ROUTE_ACTION_CANCEL
|
assert action == ROUTE_ACTION_CANCEL
|
||||||
|
|
||||||
|
|
||||||
|
def test_kernel_common_imports_exposes_workflow_route_keys() -> None:
|
||||||
|
assert "query.answer" in WORKFLOW_ROUTE_KEYS
|
||||||
|
assert "task.create" in WORKFLOW_ROUTE_KEYS
|
||||||
|
|
||||||
|
|
||||||
class _DummyTypedExecutor(TypedExecutorExecuteMixin):
|
class _DummyTypedExecutor(TypedExecutorExecuteMixin):
|
||||||
def __init__(self) -> None:
|
def __init__(self) -> None:
|
||||||
self.called: dict[str, object] = {}
|
self.called: dict[str, object] = {}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,131 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import types
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
import pytest_asyncio
|
||||||
|
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
|
||||||
|
|
||||||
|
os.environ["DATABASE_URL"] = "sqlite+aiosqlite:///:memory:"
|
||||||
|
|
||||||
|
from app.models.base import Base
|
||||||
|
from app.models.cognitive_conflict import CognitiveConflict
|
||||||
|
from app.models.project import Project
|
||||||
|
from app.models.project_signal_link import ProjectSignalLink
|
||||||
|
from app.models.signal import Signal
|
||||||
|
from app.models.task import Task
|
||||||
|
from app.models.truth_node import TruthNode
|
||||||
|
from app.models.user import User
|
||||||
|
|
||||||
|
|
||||||
|
@pytest_asyncio.fixture
|
||||||
|
async def briefing_session():
|
||||||
|
engine = create_async_engine("sqlite+aiosqlite:///:memory:")
|
||||||
|
async with engine.begin() as conn:
|
||||||
|
await conn.run_sync(
|
||||||
|
lambda sync_conn: Base.metadata.create_all(
|
||||||
|
sync_conn,
|
||||||
|
tables=[
|
||||||
|
User.__table__,
|
||||||
|
Project.__table__,
|
||||||
|
TruthNode.__table__,
|
||||||
|
Task.__table__,
|
||||||
|
Signal.__table__,
|
||||||
|
ProjectSignalLink.__table__,
|
||||||
|
CognitiveConflict.__table__,
|
||||||
|
],
|
||||||
|
)
|
||||||
|
)
|
||||||
|
maker = async_sessionmaker(engine, expire_on_commit=False)
|
||||||
|
async with maker() as session:
|
||||||
|
yield session
|
||||||
|
await engine.dispose()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_generate_personalized_briefing_uses_naive_time_window(briefing_session, monkeypatch) -> None:
|
||||||
|
fixed_now = datetime(2026, 3, 25, 14, 43, 24)
|
||||||
|
stub_database_module = types.ModuleType("app.core.database")
|
||||||
|
stub_database_module.AsyncSessionLocal = None
|
||||||
|
monkeypatch.setitem(sys.modules, "app.core.database", stub_database_module)
|
||||||
|
|
||||||
|
import app.services.daily_briefing_core.mixin_personal as daily_briefing_personal_module
|
||||||
|
from app.services.daily_briefing_core import DailyBriefingService
|
||||||
|
|
||||||
|
monkeypatch.setattr(daily_briefing_personal_module, "now_cn_naive", lambda: fixed_now)
|
||||||
|
|
||||||
|
async def fake_generate_ai_summary(self, user, data, tenant_id): # noqa: ANN001
|
||||||
|
_ = self, user, data, tenant_id
|
||||||
|
return "今日简报"
|
||||||
|
|
||||||
|
monkeypatch.setattr(DailyBriefingService, "_generate_ai_summary", fake_generate_ai_summary)
|
||||||
|
|
||||||
|
user = User(
|
||||||
|
tenant_id=1,
|
||||||
|
username="tester",
|
||||||
|
email="tester@example.com",
|
||||||
|
password_hash="hash",
|
||||||
|
name="测试用户",
|
||||||
|
role="BE",
|
||||||
|
status="active",
|
||||||
|
is_active=True,
|
||||||
|
)
|
||||||
|
project = Project(
|
||||||
|
tenant_id=1,
|
||||||
|
name="demo-project",
|
||||||
|
title="Demo Project",
|
||||||
|
status="active",
|
||||||
|
)
|
||||||
|
briefing_session.add_all([user, project])
|
||||||
|
await briefing_session.flush()
|
||||||
|
|
||||||
|
truth_node = TruthNode(
|
||||||
|
tenant_id=1,
|
||||||
|
node_type="task",
|
||||||
|
parent_id=None,
|
||||||
|
project_id=project.id,
|
||||||
|
signal_id=None,
|
||||||
|
created_by=user.id,
|
||||||
|
title="修复简报时间窗",
|
||||||
|
content={},
|
||||||
|
version=1,
|
||||||
|
status="active",
|
||||||
|
)
|
||||||
|
briefing_session.add(truth_node)
|
||||||
|
await briefing_session.flush()
|
||||||
|
|
||||||
|
task = Task(
|
||||||
|
tenant_id=1,
|
||||||
|
truth_node_id=truth_node.id,
|
||||||
|
project_id=project.id,
|
||||||
|
assignee_id=user.id,
|
||||||
|
ai_recommended_assignees={},
|
||||||
|
ai_breakdown_payload=None,
|
||||||
|
manual_overrides=None,
|
||||||
|
title="修复简报时间窗",
|
||||||
|
description="",
|
||||||
|
required_skill_tag_ids=[],
|
||||||
|
status="in_progress",
|
||||||
|
priority="HIGH",
|
||||||
|
estimated_hours=2.0,
|
||||||
|
actual_hours=None,
|
||||||
|
quality_score=None,
|
||||||
|
due_date=None,
|
||||||
|
completed_at=None,
|
||||||
|
created_at=fixed_now - timedelta(hours=4),
|
||||||
|
updated_at=fixed_now - timedelta(hours=2),
|
||||||
|
)
|
||||||
|
briefing_session.add(task)
|
||||||
|
await briefing_session.commit()
|
||||||
|
|
||||||
|
briefing = await DailyBriefingService(briefing_session).generate_personalized_briefing(
|
||||||
|
user_id=user.id,
|
||||||
|
tenant_id=1,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert briefing["summary"] == "今日简报"
|
||||||
|
assert len(briefing["your_tasks"]) == 1
|
||||||
|
assert briefing["your_tasks"][0]["title"] == "修复简报时间窗"
|
||||||
|
|
@ -1,3 +1,5 @@
|
||||||
|
from types import SimpleNamespace
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from app.config.ai_prompts import (
|
from app.config.ai_prompts import (
|
||||||
|
|
@ -6,6 +8,7 @@ from app.config.ai_prompts import (
|
||||||
PROMPT_FEISHU_MIXED_QA,
|
PROMPT_FEISHU_MIXED_QA,
|
||||||
PROMPT_PROJECT_STATUS_QA,
|
PROMPT_PROJECT_STATUS_QA,
|
||||||
)
|
)
|
||||||
|
from app.services.ai_pmo.ai_pmo_kernel_common_core.constants import STATE_SUCCEEDED
|
||||||
from app.services.ai_pmo.draft_confirm_handler import DraftConfirmHandler
|
from app.services.ai_pmo.draft_confirm_handler import DraftConfirmHandler
|
||||||
from app.services.ai_pmo.ai_pmo_kernel_ingress_flow import mixin_auto
|
from app.services.ai_pmo.ai_pmo_kernel_ingress_flow import mixin_auto
|
||||||
from app.services.adaptive_rag_service.mixin_deep_guard_answer import AdaptiveRAGDeepGuardAnswerMixin
|
from app.services.adaptive_rag_service.mixin_deep_guard_answer import AdaptiveRAGDeepGuardAnswerMixin
|
||||||
|
|
@ -102,6 +105,48 @@ def test_reply_outbox_extract_sent_message_id() -> None:
|
||||||
assert FeishuReplyOutboxMixin._extract_sent_message_id({"message_id": "msg_456"}) == "msg_456"
|
assert FeishuReplyOutboxMixin._extract_sent_message_id({"message_id": "msg_456"}) == "msg_456"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_draft_confirm_handler_repeated_confirm_returns_inbox_success_copy() -> None:
|
||||||
|
class _FakeResult:
|
||||||
|
def __init__(self, locked):
|
||||||
|
self._locked = locked
|
||||||
|
|
||||||
|
def scalar_one_or_none(self):
|
||||||
|
return self._locked
|
||||||
|
|
||||||
|
class _FakeDB:
|
||||||
|
def __init__(self, locked):
|
||||||
|
self._locked = locked
|
||||||
|
|
||||||
|
async def execute(self, query): # noqa: ARG002
|
||||||
|
return _FakeResult(self._locked)
|
||||||
|
|
||||||
|
locked = SimpleNamespace(
|
||||||
|
id=42,
|
||||||
|
state=STATE_SUCCEEDED,
|
||||||
|
intent="draft",
|
||||||
|
project_id=7,
|
||||||
|
signal_id=9,
|
||||||
|
draft_version=1,
|
||||||
|
draft_expires_at=None,
|
||||||
|
)
|
||||||
|
handler = DraftConfirmHandler(type("Kernel", (), {"db": _FakeDB(locked), "tenant_id": 1})())
|
||||||
|
|
||||||
|
result = await handler.confirm_draft(
|
||||||
|
target_work_item=SimpleNamespace(id=42, state=STATE_SUCCEEDED, intent="draft", project_id=7, signal_id=9),
|
||||||
|
target_ingest=SimpleNamespace(),
|
||||||
|
sender="测试用户",
|
||||||
|
source_system="feishu",
|
||||||
|
source_message_id="msg-1",
|
||||||
|
message_text="确认",
|
||||||
|
expected_version=None,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result["handled"] is True
|
||||||
|
assert result["status"] == "succeeded"
|
||||||
|
assert result["followup_reply"] == "已入库成功,无需重复确认。"
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_draft_confirm_handler_ignores_non_draft_reply_without_target(monkeypatch) -> None:
|
async def test_draft_confirm_handler_ignores_non_draft_reply_without_target(monkeypatch) -> None:
|
||||||
kernel = type("Kernel", (), {"db": object(), "tenant_id": 1})()
|
kernel = type("Kernel", (), {"db": object(), "tenant_id": 1})()
|
||||||
|
|
|
||||||
|
|
@ -239,6 +239,9 @@ async def test_feedback_center_summary_honors_window_and_scope_query_params(db_s
|
||||||
assert payload["data"]["trend"]["current"]["task_breakdown_count"] == 1
|
assert payload["data"]["trend"]["current"]["task_breakdown_count"] == 1
|
||||||
assert payload["data"]["trend"]["current"]["task_override_count"] == 1
|
assert payload["data"]["trend"]["current"]["task_override_count"] == 1
|
||||||
assert payload["data"]["trend"]["current"]["qa_feedback_count"] == 0
|
assert payload["data"]["trend"]["current"]["qa_feedback_count"] == 0
|
||||||
|
assert payload["data"]["insights"]
|
||||||
|
insight_codes = {item["code"] for item in payload["data"]["insights"]}
|
||||||
|
assert "manual_override_pressure" in insight_codes
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
|
|
|
||||||
|
|
@ -452,6 +452,90 @@ async def test_summary_metrics_cover_learning_kpis(db_session) -> None:
|
||||||
assert metrics["total_revision_count"] == 2
|
assert metrics["total_revision_count"] == 2
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_summary_exposes_decision_insights_for_trend_page(db_session, monkeypatch) -> None:
|
||||||
|
base_time = datetime(2025, 1, 20, 10, 0, 0)
|
||||||
|
db_session.add_all(
|
||||||
|
[
|
||||||
|
FeedbackCenterRecord(
|
||||||
|
tenant_id=1,
|
||||||
|
project_id=11,
|
||||||
|
source_type="task_breakdown",
|
||||||
|
source_id=701,
|
||||||
|
object_type="task",
|
||||||
|
object_id=1001,
|
||||||
|
field_name="task_breakdown",
|
||||||
|
review_status="accepted",
|
||||||
|
auto_assigned=False,
|
||||||
|
created_at=base_time,
|
||||||
|
reviewed_at=base_time + timedelta(minutes=90),
|
||||||
|
),
|
||||||
|
FeedbackCenterRecord(
|
||||||
|
tenant_id=1,
|
||||||
|
project_id=11,
|
||||||
|
source_type="task_override",
|
||||||
|
source_id=702,
|
||||||
|
object_type="task",
|
||||||
|
object_id=1001,
|
||||||
|
field_name="priority",
|
||||||
|
review_status="reviewed",
|
||||||
|
created_at=base_time + timedelta(minutes=2),
|
||||||
|
reviewed_at=base_time + timedelta(minutes=95),
|
||||||
|
),
|
||||||
|
FeedbackCenterRecord(
|
||||||
|
tenant_id=1,
|
||||||
|
project_id=11,
|
||||||
|
source_type="task_breakdown",
|
||||||
|
source_id=703,
|
||||||
|
object_type="task",
|
||||||
|
object_id=1002,
|
||||||
|
field_name="task_breakdown",
|
||||||
|
review_status="accepted",
|
||||||
|
auto_assigned=True,
|
||||||
|
created_at=base_time + timedelta(minutes=4),
|
||||||
|
reviewed_at=base_time + timedelta(minutes=20),
|
||||||
|
),
|
||||||
|
FeedbackCenterRecord(
|
||||||
|
tenant_id=1,
|
||||||
|
project_id=11,
|
||||||
|
source_type="task_override",
|
||||||
|
source_id=704,
|
||||||
|
object_type="task",
|
||||||
|
object_id=1002,
|
||||||
|
field_name="estimated_hours",
|
||||||
|
review_status="pending",
|
||||||
|
created_at=base_time + timedelta(minutes=6),
|
||||||
|
),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
await db_session.commit()
|
||||||
|
|
||||||
|
fixed_now = datetime(2025, 1, 20, 12, 0, 0)
|
||||||
|
monkeypatch.setattr(feedback_center_service_module, "now_cn_naive", lambda: fixed_now)
|
||||||
|
|
||||||
|
summary = await FeedbackCenterService(db_session).summary(
|
||||||
|
tenant_id=1,
|
||||||
|
project_id=11,
|
||||||
|
window_days=7,
|
||||||
|
source_group="task",
|
||||||
|
)
|
||||||
|
|
||||||
|
insights = summary["insights"]
|
||||||
|
assert len(insights) >= 2
|
||||||
|
insight_codes = {item["code"] for item in insights}
|
||||||
|
assert "manual_override_pressure" in insight_codes
|
||||||
|
assert "auto_assignment_gap" in insight_codes
|
||||||
|
|
||||||
|
manual_override_insight = next(item for item in insights if item["code"] == "manual_override_pressure")
|
||||||
|
assert manual_override_insight["target_section"] == "task"
|
||||||
|
assert manual_override_insight["target_source_type"] == "task_override"
|
||||||
|
assert manual_override_insight["action_label"] == "查看任务覆盖样本"
|
||||||
|
|
||||||
|
auto_assignment_insight = next(item for item in insights if item["code"] == "auto_assignment_gap")
|
||||||
|
assert auto_assignment_insight["target_source_type"] == "task_breakdown"
|
||||||
|
assert auto_assignment_insight["severity"] in {"warning", "critical"}
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_summary_trend_cover_daily_windows_and_deltas(db_session, monkeypatch) -> None:
|
async def test_summary_trend_cover_daily_windows_and_deltas(db_session, monkeypatch) -> None:
|
||||||
fixed_now = datetime(2025, 1, 15, 12, 0, 0)
|
fixed_now = datetime(2025, 1, 15, 12, 0, 0)
|
||||||
|
|
@ -610,6 +694,95 @@ async def test_summary_trend_cover_daily_windows_and_deltas(db_session, monkeypa
|
||||||
assert deltas["avg_review_latency_minutes_delta"] == pytest.approx(15.0)
|
assert deltas["avg_review_latency_minutes_delta"] == pytest.approx(15.0)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_summary_trend_buckets_reviewed_activity_by_review_time(db_session, monkeypatch) -> None:
|
||||||
|
fixed_now = datetime(2025, 1, 15, 12, 0, 0)
|
||||||
|
monkeypatch.setattr(feedback_center_service_module, "now_cn_naive", lambda: fixed_now)
|
||||||
|
|
||||||
|
db_session.add(
|
||||||
|
FeedbackCenterRecord(
|
||||||
|
tenant_id=1,
|
||||||
|
project_id=11,
|
||||||
|
source_type="task_override",
|
||||||
|
source_id=705,
|
||||||
|
object_type="task",
|
||||||
|
object_id=801,
|
||||||
|
field_name="priority",
|
||||||
|
review_status="accepted",
|
||||||
|
reason_code="priority_misalignment",
|
||||||
|
created_at=datetime(2025, 1, 8, 9, 0, 0),
|
||||||
|
reviewed_at=datetime(2025, 1, 15, 9, 15, 0),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
await db_session.commit()
|
||||||
|
|
||||||
|
summary = await FeedbackCenterService(db_session).summary(
|
||||||
|
tenant_id=1,
|
||||||
|
project_id=11,
|
||||||
|
window_days=7,
|
||||||
|
source_group="task",
|
||||||
|
)
|
||||||
|
|
||||||
|
trend = summary["trend"]
|
||||||
|
assert trend["current"]["total_records"] == 1
|
||||||
|
assert trend["previous"]["total_records"] == 0
|
||||||
|
assert any(item["date"] == "2025-01-15" and item["total_records"] == 1 for item in trend["daily"])
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_summary_insights_follow_current_window_reason_distribution(db_session, monkeypatch) -> None:
|
||||||
|
fixed_now = datetime(2025, 1, 15, 12, 0, 0)
|
||||||
|
monkeypatch.setattr(feedback_center_service_module, "now_cn_naive", lambda: fixed_now)
|
||||||
|
|
||||||
|
old_records = [
|
||||||
|
FeedbackCenterRecord(
|
||||||
|
tenant_id=1,
|
||||||
|
project_id=11,
|
||||||
|
source_type="task_breakdown",
|
||||||
|
source_id=801 + index,
|
||||||
|
object_type="task",
|
||||||
|
object_id=900 + index,
|
||||||
|
field_name="task_breakdown",
|
||||||
|
review_status="accepted",
|
||||||
|
reason_code="owner_mismatch",
|
||||||
|
created_at=datetime(2025, 1, 3, 9, 0, 0),
|
||||||
|
reviewed_at=datetime(2025, 1, 3, 9, 20, 0),
|
||||||
|
)
|
||||||
|
for index in range(5)
|
||||||
|
]
|
||||||
|
current_record = FeedbackCenterRecord(
|
||||||
|
tenant_id=1,
|
||||||
|
project_id=11,
|
||||||
|
source_type="task_breakdown",
|
||||||
|
source_id=900,
|
||||||
|
object_type="task",
|
||||||
|
object_id=1000,
|
||||||
|
field_name="task_breakdown",
|
||||||
|
review_status="accepted",
|
||||||
|
reason_code="skill_gap",
|
||||||
|
created_at=datetime(2025, 1, 15, 9, 0, 0),
|
||||||
|
reviewed_at=datetime(2025, 1, 15, 9, 25, 0),
|
||||||
|
)
|
||||||
|
db_session.add_all(old_records + [current_record])
|
||||||
|
await db_session.commit()
|
||||||
|
|
||||||
|
summary = await FeedbackCenterService(db_session).summary(
|
||||||
|
tenant_id=1,
|
||||||
|
project_id=11,
|
||||||
|
window_days=7,
|
||||||
|
source_group="task",
|
||||||
|
)
|
||||||
|
|
||||||
|
trend = summary["trend"]
|
||||||
|
assert trend["current"]["top_reasons"][0]["code"] == "skill_gap"
|
||||||
|
|
||||||
|
insights = summary["insights"]
|
||||||
|
assert any(
|
||||||
|
item["code"] == "reason_concentration" and item["target_reason_code"] == "skill_gap"
|
||||||
|
for item in insights
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_summary_trend_respects_source_group_scope(db_session, monkeypatch) -> None:
|
async def test_summary_trend_respects_source_group_scope(db_session, monkeypatch) -> None:
|
||||||
fixed_now = datetime(2025, 1, 15, 12, 0, 0)
|
fixed_now = datetime(2025, 1, 15, 12, 0, 0)
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,208 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import time
|
||||||
|
from types import SimpleNamespace
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from fastapi import FastAPI
|
||||||
|
from httpx import ASGITransport, AsyncClient
|
||||||
|
|
||||||
|
from app.api.feishu_api import router as feishu_router
|
||||||
|
from app.api.feishu_api import card_action_routes
|
||||||
|
from app.core.database import get_db
|
||||||
|
from app.services.integrations.feishu_integration_service import FeishuIntegrationService
|
||||||
|
|
||||||
|
|
||||||
|
class _FakeResult:
|
||||||
|
def scalar_one_or_none(self):
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
class _FakeDB:
|
||||||
|
async def execute(self, query): # noqa: ARG002
|
||||||
|
return _FakeResult()
|
||||||
|
|
||||||
|
async def commit(self) -> None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
async def rollback(self) -> None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _build_app(db_session):
|
||||||
|
app = FastAPI()
|
||||||
|
app.include_router(feishu_router)
|
||||||
|
|
||||||
|
async def _fake_get_db():
|
||||||
|
yield db_session
|
||||||
|
|
||||||
|
app.dependency_overrides[get_db] = _fake_get_db
|
||||||
|
return app
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_feishu_card_action_callback_returns_success_and_dispatches_payload(monkeypatch) -> None:
|
||||||
|
called: dict[str, object] = {}
|
||||||
|
|
||||||
|
async def _fake_handle_card_action_trigger(self, event): # noqa: ANN001
|
||||||
|
called["event"] = event
|
||||||
|
return {
|
||||||
|
"code": 0,
|
||||||
|
"msg": "success",
|
||||||
|
"data": {
|
||||||
|
"handled": True,
|
||||||
|
"status": "succeeded",
|
||||||
|
"followup_reply": "已确认并入库",
|
||||||
|
"project_id": 1001,
|
||||||
|
"signal_id": 2002,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
monkeypatch.setattr(
|
||||||
|
FeishuIntegrationService,
|
||||||
|
"_handle_card_action_trigger",
|
||||||
|
_fake_handle_card_action_trigger,
|
||||||
|
)
|
||||||
|
|
||||||
|
app = _build_app(_FakeDB())
|
||||||
|
transport = ASGITransport(app=app)
|
||||||
|
async with AsyncClient(transport=transport, base_url="http://test") as client:
|
||||||
|
response = await client.post(
|
||||||
|
"/feishu/card-action",
|
||||||
|
json={
|
||||||
|
"type": "card.action.trigger",
|
||||||
|
"open_message_id": "om_123",
|
||||||
|
"open_chat_id": "oc_456",
|
||||||
|
"open_id": "ou_abc",
|
||||||
|
"operator": {"open_id": "ou_abc", "name": "测试用户"},
|
||||||
|
"action": {
|
||||||
|
"value": {
|
||||||
|
"action": "confirm",
|
||||||
|
"work_item_id": 99,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.json()["toast"]["content"] == "已确认并入库"
|
||||||
|
assert called["event"]["action"]["value"]["action"] == "confirm"
|
||||||
|
assert called["event"]["work_item_id"] == 99
|
||||||
|
assert called["event"]["open_message_id"] == "om_123"
|
||||||
|
assert called["event"]["open_chat_id"] == "oc_456"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_feishu_card_action_callback_maps_ignored_to_refresh_message(monkeypatch) -> None:
|
||||||
|
async def _fake_handle_card_action_trigger(self, event): # noqa: ANN001
|
||||||
|
_ = event
|
||||||
|
return {
|
||||||
|
"code": 0,
|
||||||
|
"msg": "card_action_ignored",
|
||||||
|
"data": {
|
||||||
|
"reason": "missing_work_item_id",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
monkeypatch.setattr(
|
||||||
|
FeishuIntegrationService,
|
||||||
|
"_handle_card_action_trigger",
|
||||||
|
_fake_handle_card_action_trigger,
|
||||||
|
)
|
||||||
|
|
||||||
|
app = _build_app(_FakeDB())
|
||||||
|
transport = ASGITransport(app=app)
|
||||||
|
async with AsyncClient(transport=transport, base_url="http://test") as client:
|
||||||
|
response = await client.post(
|
||||||
|
"/feishu/card-action",
|
||||||
|
json={
|
||||||
|
"type": "card.action.trigger",
|
||||||
|
"open_message_id": "om_ignored_1",
|
||||||
|
"open_chat_id": "oc_ignored_1",
|
||||||
|
"operator": {"open_id": "ou_abc", "name": "测试用户"},
|
||||||
|
"action": {
|
||||||
|
"value": {
|
||||||
|
"action": "confirm",
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.json()["toast"]["content"] == "这张卡片已失效,请刷新后再试"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_feishu_card_action_callback_falls_back_when_signature_mismatch(monkeypatch) -> None:
|
||||||
|
called: dict[str, object] = {}
|
||||||
|
|
||||||
|
class _FallbackService:
|
||||||
|
tenant_id = 1
|
||||||
|
encrypt_key = ""
|
||||||
|
verification_token = "server-token"
|
||||||
|
|
||||||
|
async def load_config(self, tenant_id): # noqa: ANN001
|
||||||
|
self.tenant_id = tenant_id
|
||||||
|
|
||||||
|
async def _handle_card_action_trigger(self, event): # noqa: ANN001
|
||||||
|
called["event"] = event
|
||||||
|
return {
|
||||||
|
"code": 0,
|
||||||
|
"msg": "success",
|
||||||
|
"data": {
|
||||||
|
"handled": True,
|
||||||
|
"status": "succeeded",
|
||||||
|
"followup_reply": "已确认并入库",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
monkeypatch.setattr(card_action_routes, "get_feishu_service", lambda db: _FallbackService())
|
||||||
|
|
||||||
|
body = (
|
||||||
|
'{"type":"card.action.trigger","open_message_id":"om_123","open_chat_id":"oc_456",'
|
||||||
|
'"operator":{"open_id":"ou_abc"},"action":{"value":{"action":"confirm","work_item_id":99}}}'
|
||||||
|
)
|
||||||
|
timestamp = str(int(time.time()))
|
||||||
|
nonce = "nonce-123"
|
||||||
|
wrong_token = "client-token"
|
||||||
|
signature = hashlib.sha1((timestamp + nonce + wrong_token).encode() + body.encode()).hexdigest()
|
||||||
|
|
||||||
|
app = _build_app(_FakeDB())
|
||||||
|
transport = ASGITransport(app=app)
|
||||||
|
async with AsyncClient(transport=transport, base_url="http://test") as client:
|
||||||
|
response = await client.post(
|
||||||
|
"/feishu/card-action",
|
||||||
|
content=body,
|
||||||
|
headers={
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"X-Lark-Request-Timestamp": timestamp,
|
||||||
|
"X-Lark-Request-Nonce": nonce,
|
||||||
|
"X-Lark-Signature": signature,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.json()["toast"]["content"] == "已确认并入库"
|
||||||
|
assert called["event"]["action"]["value"]["action"] == "confirm"
|
||||||
|
assert called["event"]["work_item_id"] == 99
|
||||||
|
assert called["event"]["open_message_id"] == "om_123"
|
||||||
|
assert called["event"]["open_chat_id"] == "oc_456"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_feishu_card_action_callback_handles_url_verification() -> None:
|
||||||
|
app = _build_app(_FakeDB())
|
||||||
|
transport = ASGITransport(app=app)
|
||||||
|
async with AsyncClient(transport=transport, base_url="http://test") as client:
|
||||||
|
response = await client.post(
|
||||||
|
"/feishu/card-action",
|
||||||
|
json={
|
||||||
|
"type": "url_verification",
|
||||||
|
"token": "",
|
||||||
|
"challenge": "challenge-123",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.json() == {"challenge": "challenge-123"}
|
||||||
|
|
@ -4,6 +4,9 @@ import os
|
||||||
import certifi
|
import certifi
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
|
import lark_oapi as lark
|
||||||
|
import lark_oapi.ws.client as lark_ws_client
|
||||||
|
|
||||||
from app.services.integrations.feishu_integration_service.mixin_config import FeishuConfigMixin
|
from app.services.integrations.feishu_integration_service.mixin_config import FeishuConfigMixin
|
||||||
from app.services.integrations.feishu_long_connection_core.mixin_lifecycle_connection import (
|
from app.services.integrations.feishu_long_connection_core.mixin_lifecycle_connection import (
|
||||||
LongConnectionLifecycleConnectionMixin,
|
LongConnectionLifecycleConnectionMixin,
|
||||||
|
|
@ -262,3 +265,110 @@ async def test_long_connection_status_tracks_received_and_processed_times() -> N
|
||||||
assert status["last_received_message_id"] == "msg-123"
|
assert status["last_received_message_id"] == "msg-123"
|
||||||
assert status["last_received_at"] == manager._last_received_at
|
assert status["last_received_at"] == manager._last_received_at
|
||||||
assert status["last_event_at"] is None
|
assert status["last_event_at"] is None
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_long_connection_card_action_returns_ack_and_enqueues_payload() -> None:
|
||||||
|
class _RuntimeDummy(LongConnectionRuntimeMixin):
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self._running = True
|
||||||
|
self._loop = asyncio.get_running_loop()
|
||||||
|
self._queue = asyncio.Queue()
|
||||||
|
self._tenant_id = 1
|
||||||
|
self._last_error = None
|
||||||
|
self._last_error_code = None
|
||||||
|
self._last_error_retryable = False
|
||||||
|
|
||||||
|
manager = _RuntimeDummy()
|
||||||
|
result = manager._on_card_action_event(
|
||||||
|
{
|
||||||
|
"schema": "p2",
|
||||||
|
"header": {"event_type": "card.action.trigger", "token": "token"},
|
||||||
|
"event": {
|
||||||
|
"operator": {"open_id": "ou_abc", "name": "测试用户"},
|
||||||
|
"context": {"open_message_id": "om_123", "open_chat_id": "oc_456"},
|
||||||
|
"action": {"value": {"action": "confirm", "work_item_id": 99}},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
await asyncio.sleep(0)
|
||||||
|
|
||||||
|
assert result.toast is not None
|
||||||
|
assert result.toast.type == "info"
|
||||||
|
assert result.toast.content == "已收到,正在处理"
|
||||||
|
assert manager._queue.qsize() == 1
|
||||||
|
queued = await manager._queue.get()
|
||||||
|
assert queued["header"]["event_type"] == "card.action.trigger"
|
||||||
|
assert queued["event"]["context"]["open_message_id"] == "om_123"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_run_ws_client_uses_thread_local_event_loop(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
original_ws_loop = lark_ws_client.loop
|
||||||
|
captured: dict[str, object] = {}
|
||||||
|
|
||||||
|
class _FakeBuilder:
|
||||||
|
def register_p2_im_message_receive_v1(self, callback): # noqa: ARG002
|
||||||
|
captured["register_receive_called"] = True
|
||||||
|
return self
|
||||||
|
|
||||||
|
def register_p2_card_action_trigger(self, callback): # noqa: ARG002
|
||||||
|
captured["register_card_action_called"] = True
|
||||||
|
return self
|
||||||
|
|
||||||
|
def register_p2_im_message_message_read_v1(self, callback): # noqa: ARG002
|
||||||
|
return self
|
||||||
|
|
||||||
|
def register_p2_im_message_reaction_created_v1(self, callback): # noqa: ARG002
|
||||||
|
return self
|
||||||
|
|
||||||
|
def register_p2_im_message_reaction_deleted_v1(self, callback): # noqa: ARG002
|
||||||
|
return self
|
||||||
|
|
||||||
|
def register_p2_im_message_recalled_v1(self, callback): # noqa: ARG002
|
||||||
|
return self
|
||||||
|
|
||||||
|
def build(self):
|
||||||
|
captured["builder_built"] = True
|
||||||
|
return object()
|
||||||
|
|
||||||
|
class _FakeEventDispatcherHandler:
|
||||||
|
@staticmethod
|
||||||
|
def builder(encrypt_key: str, verification_token: str): # noqa: ARG002
|
||||||
|
captured["builder_loop"] = lark_ws_client.loop
|
||||||
|
return _FakeBuilder()
|
||||||
|
|
||||||
|
class _FakeClient:
|
||||||
|
def __init__(self, app_id, app_secret, event_handler=None, log_level=None): # noqa: ARG002
|
||||||
|
captured["client_init_loop"] = lark_ws_client.loop
|
||||||
|
|
||||||
|
def start(self):
|
||||||
|
captured["client_start_loop"] = lark_ws_client.loop
|
||||||
|
|
||||||
|
def stop(self):
|
||||||
|
captured["client_stop_called"] = True
|
||||||
|
|
||||||
|
monkeypatch.setattr(lark, "EventDispatcherHandler", _FakeEventDispatcherHandler)
|
||||||
|
monkeypatch.setattr(lark.ws, "Client", _FakeClient)
|
||||||
|
|
||||||
|
class _RuntimeDummy(LongConnectionRuntimeMixin):
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self._running = False
|
||||||
|
self._loop = None
|
||||||
|
self._queue = asyncio.Queue()
|
||||||
|
self._tenant_id = 1
|
||||||
|
self._last_error = None
|
||||||
|
self._last_error_code = None
|
||||||
|
self._last_error_retryable = False
|
||||||
|
self._client = None
|
||||||
|
|
||||||
|
manager = _RuntimeDummy()
|
||||||
|
manager._run_ws_client("app-id", "app-secret")
|
||||||
|
|
||||||
|
assert captured["register_receive_called"] is True
|
||||||
|
assert captured["register_card_action_called"] is True
|
||||||
|
assert captured["builder_built"] is True
|
||||||
|
assert captured["client_init_loop"] is not original_ws_loop
|
||||||
|
assert captured["client_start_loop"] is captured["client_init_loop"]
|
||||||
|
assert lark_ws_client.loop is original_ws_loop
|
||||||
|
|
|
||||||
|
|
@ -39,6 +39,8 @@ from app.services.integrations.feishu_signal_pipeline.mixin_process_message_help
|
||||||
from app.services.integrations.feishu_signal_pipeline.mixin_queue_confirm_undo import (
|
from app.services.integrations.feishu_signal_pipeline.mixin_queue_confirm_undo import (
|
||||||
FeishuPipelineQueueConfirmUndoMixin,
|
FeishuPipelineQueueConfirmUndoMixin,
|
||||||
)
|
)
|
||||||
|
from app.services.signal.signal_capture_service import mixin_capture_entry
|
||||||
|
from app.services.signal.signal_capture_service.service import ProactiveSignalCaptureService
|
||||||
from app.services.signal.signal_capture_service.mixin_task_orchestration_main import (
|
from app.services.signal.signal_capture_service.mixin_task_orchestration_main import (
|
||||||
SignalCaptureTaskOrchestrationMainMixin,
|
SignalCaptureTaskOrchestrationMainMixin,
|
||||||
)
|
)
|
||||||
|
|
@ -272,6 +274,68 @@ async def test_intent_gate_query_does_not_inherit_project_context(monkeypatch) -
|
||||||
assert captured["answer_kwargs"]["project_id"] is None
|
assert captured["answer_kwargs"]["project_id"] is None
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_feishu_question_like_messages_use_sync_high_accuracy_path(monkeypatch) -> None:
|
||||||
|
calls = {"sync": 0, "session": 0, "cache": 0}
|
||||||
|
|
||||||
|
class _FakeKernel:
|
||||||
|
@staticmethod
|
||||||
|
def _build_source_message_id(*, source_system: str, source_message_id, channel_ref: str, sender: str, message_text: str):
|
||||||
|
_ = (source_system, source_message_id, channel_ref, sender, message_text)
|
||||||
|
return "msg-1"
|
||||||
|
|
||||||
|
def __init__(self, db, tenant_id: int):
|
||||||
|
_ = (db, tenant_id)
|
||||||
|
|
||||||
|
async def _get_or_create_session(self, **kwargs):
|
||||||
|
calls["session"] += 1
|
||||||
|
_ = kwargs
|
||||||
|
raise AssertionError("question-like Feishu messages should not create an async queue session")
|
||||||
|
|
||||||
|
def _is_short_confirmation_message(self, **kwargs):
|
||||||
|
_ = kwargs
|
||||||
|
return False
|
||||||
|
|
||||||
|
async def process_feishu_message(self, **kwargs):
|
||||||
|
calls["sync"] += 1
|
||||||
|
_ = kwargs
|
||||||
|
return {
|
||||||
|
"handled": True,
|
||||||
|
"status": "answered",
|
||||||
|
"classification": "question",
|
||||||
|
"linked_project_ids": [],
|
||||||
|
"signal_id": None,
|
||||||
|
"created_project_id": None,
|
||||||
|
"auto_tasks": [],
|
||||||
|
"ai_trace_id": "trace-1",
|
||||||
|
"direct_reply_required": True,
|
||||||
|
"direct_reply_text": "垂直行业建议先看目标客户与交付边界。",
|
||||||
|
}
|
||||||
|
|
||||||
|
async def _noop_sync_recent_cache(self, **kwargs):
|
||||||
|
calls["cache"] += 1
|
||||||
|
_ = kwargs
|
||||||
|
|
||||||
|
monkeypatch.setattr(mixin_capture_entry, "AIPMOKernelService", _FakeKernel)
|
||||||
|
monkeypatch.setattr(ProactiveSignalCaptureService, "_sync_feishu_recent_cache", _noop_sync_recent_cache)
|
||||||
|
|
||||||
|
service = ProactiveSignalCaptureService(object())
|
||||||
|
result = await service.capture_from_feishu(
|
||||||
|
group_chat_id="chat-1",
|
||||||
|
message_content="我感觉第一个垂直应用前景比较好,但是垂直哪个行业呢?",
|
||||||
|
sender="测试用户",
|
||||||
|
tenant_id=1,
|
||||||
|
feishu_message_id="msg-1",
|
||||||
|
chat_type="group",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result["status"] == "answered"
|
||||||
|
assert "已收到,正在处理。" not in str(result.get("direct_reply_text") or "")
|
||||||
|
assert calls["sync"] == 1
|
||||||
|
assert calls["session"] == 0
|
||||||
|
assert calls["cache"] == 1
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_project_context_prefill_skips_chatwide_reply_fallback(monkeypatch) -> None:
|
async def test_project_context_prefill_skips_chatwide_reply_fallback(monkeypatch) -> None:
|
||||||
called = {"fallback": False}
|
called = {"fallback": False}
|
||||||
|
|
|
||||||
|
|
@ -4,12 +4,14 @@ import pytest
|
||||||
from pydantic import ValidationError
|
from pydantic import ValidationError
|
||||||
|
|
||||||
from app.api import rag_ask_contract
|
from app.api import rag_ask_contract
|
||||||
|
from app.api.router import api_router
|
||||||
from app.api.ai_steward_api import router as ai_steward_router
|
from app.api.ai_steward_api import router as ai_steward_router
|
||||||
from app.api.rag_api import routes_query
|
from app.api.rag_api import routes_query
|
||||||
from app.schemas.rag import RAGAskRequest
|
from app.schemas.rag import RAGAskRequest
|
||||||
|
|
||||||
|
|
||||||
def test_rag_ask_request_normalizes_contract_fields() -> None:
|
def test_rag_ask_request_normalizes_contract_fields() -> None:
|
||||||
|
assert RAGAskRequest(question="状态怎么样").thinking_mode == "deep"
|
||||||
req = RAGAskRequest(
|
req = RAGAskRequest(
|
||||||
question="状态怎么样",
|
question="状态怎么样",
|
||||||
asked_via="ai-pmo-process-text",
|
asked_via="ai-pmo-process-text",
|
||||||
|
|
@ -121,6 +123,15 @@ def test_ai_steward_router_does_not_expose_legacy_rag_ask_route() -> None:
|
||||||
assert "/ai-steward/rag/ask" not in route_paths
|
assert "/ai-steward/rag/ask" not in route_paths
|
||||||
|
|
||||||
|
|
||||||
|
def test_api_router_uses_single_prefix_for_rag_and_ai_steward() -> None:
|
||||||
|
route_paths = {str(getattr(route, "path", "")).strip() for route in api_router.routes}
|
||||||
|
|
||||||
|
assert "/rag/ask" in route_paths
|
||||||
|
assert "/rag/rag/ask" not in route_paths
|
||||||
|
assert "/ai-steward/feedback-center/summary" in route_paths
|
||||||
|
assert "/ai-steward/ai-steward/feedback-center/summary" not in route_paths
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_rag_ask_route_uses_shared_contract_builder(monkeypatch) -> None:
|
async def test_rag_ask_route_uses_shared_contract_builder(monkeypatch) -> None:
|
||||||
captured_kwargs: list[dict[str, object]] = []
|
captured_kwargs: list[dict[str, object]] = []
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,14 @@ export type FeedbackCenterReviewStatus = "pending" | "reviewed" | "accepted" | "
|
||||||
export type FeedbackCenterSection = "overview" | "metrics" | "trend" | "task" | "qa" | "signal";
|
export type FeedbackCenterSection = "overview" | "metrics" | "trend" | "task" | "qa" | "signal";
|
||||||
export type FeedbackCenterTrendWindowDays = 7 | 30;
|
export type FeedbackCenterTrendWindowDays = 7 | 30;
|
||||||
export type FeedbackCenterSourceGroup = "all" | "task" | "qa" | "signal";
|
export type FeedbackCenterSourceGroup = "all" | "task" | "qa" | "signal";
|
||||||
|
export type FeedbackCenterInsightSeverity = "info" | "warning" | "critical";
|
||||||
|
|
||||||
|
export interface FeedbackCenterDrilldownPreset {
|
||||||
|
section: FeedbackCenterSection;
|
||||||
|
source_type?: string | null;
|
||||||
|
review_status?: string | null;
|
||||||
|
reason_code?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
export interface FeedbackReasonCandidate {
|
export interface FeedbackReasonCandidate {
|
||||||
code: string;
|
code: string;
|
||||||
|
|
@ -68,6 +76,18 @@ export interface FeedbackCenterSummaryReason {
|
||||||
count: number;
|
count: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface FeedbackCenterInsight {
|
||||||
|
code: string;
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
severity: FeedbackCenterInsightSeverity;
|
||||||
|
action_label: string;
|
||||||
|
target_section: FeedbackCenterSection;
|
||||||
|
target_source_type?: string | null;
|
||||||
|
target_review_status?: string | null;
|
||||||
|
target_reason_code?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
export interface FeedbackCenterSummaryMetrics {
|
export interface FeedbackCenterSummaryMetrics {
|
||||||
accepted_rate_reviewed: number;
|
accepted_rate_reviewed: number;
|
||||||
manual_override_rate: number;
|
manual_override_rate: number;
|
||||||
|
|
@ -121,7 +141,9 @@ export interface FeedbackCenterTrendWindow {
|
||||||
rework_rate: number;
|
rework_rate: number;
|
||||||
auto_assigned_rate: number;
|
auto_assigned_rate: number;
|
||||||
avg_review_latency_minutes: number;
|
avg_review_latency_minutes: number;
|
||||||
|
avg_confidence: number;
|
||||||
revision_count: number;
|
revision_count: number;
|
||||||
|
top_reasons: FeedbackCenterSummaryReason[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface FeedbackCenterTrendDelta {
|
export interface FeedbackCenterTrendDelta {
|
||||||
|
|
@ -158,6 +180,7 @@ export interface FeedbackCenterSummary {
|
||||||
by_status: Record<string, number>;
|
by_status: Record<string, number>;
|
||||||
by_source_type: Record<string, number>;
|
by_source_type: Record<string, number>;
|
||||||
top_reasons: FeedbackCenterSummaryReason[];
|
top_reasons: FeedbackCenterSummaryReason[];
|
||||||
|
insights: FeedbackCenterInsight[];
|
||||||
metrics: FeedbackCenterSummaryMetrics;
|
metrics: FeedbackCenterSummaryMetrics;
|
||||||
trend: FeedbackCenterTrend;
|
trend: FeedbackCenterTrend;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -300,7 +300,7 @@ export function FloatingQAChat() {
|
||||||
const [question, setQuestion] = useState("");
|
const [question, setQuestion] = useState("");
|
||||||
const [thinkingMode, setThinkingMode] = useState<ThinkingMode>(() => {
|
const [thinkingMode, setThinkingMode] = useState<ThinkingMode>(() => {
|
||||||
const stored = String(localStorage.getItem(THINKING_MODE_KEY) || "").toLowerCase();
|
const stored = String(localStorage.getItem(THINKING_MODE_KEY) || "").toLowerCase();
|
||||||
return stored === "deep" ? "deep" : "normal";
|
return stored === "normal" ? "normal" : "deep";
|
||||||
});
|
});
|
||||||
const [draftNewSession, setDraftNewSession] = useState(false);
|
const [draftNewSession, setDraftNewSession] = useState(false);
|
||||||
const [requestThinkingMode, setRequestThinkingMode] = useState<ThinkingMode>("normal");
|
const [requestThinkingMode, setRequestThinkingMode] = useState<ThinkingMode>("normal");
|
||||||
|
|
|
||||||
|
|
@ -30,6 +30,7 @@ export function FeedbackSummaryCard({ projectId, compact = false, tasks = [] }:
|
||||||
const aiTasks = tasks.filter((task) => hasAIBreakdown(task));
|
const aiTasks = tasks.filter((task) => hasAIBreakdown(task));
|
||||||
if (!aiTasks.length) {
|
if (!aiTasks.length) {
|
||||||
return {
|
return {
|
||||||
|
hasLocalSignal: false,
|
||||||
pending: 0,
|
pending: 0,
|
||||||
acceptedRate: 0,
|
acceptedRate: 0,
|
||||||
topReasons: [] as Array<{ label: string; count: number }>,
|
topReasons: [] as Array<{ label: string; count: number }>,
|
||||||
|
|
@ -56,6 +57,7 @@ export function FeedbackSummaryCard({ projectId, compact = false, tasks = [] }:
|
||||||
}
|
}
|
||||||
const reviewed = accepted + rejected;
|
const reviewed = accepted + rejected;
|
||||||
return {
|
return {
|
||||||
|
hasLocalSignal: true,
|
||||||
pending,
|
pending,
|
||||||
acceptedRate: reviewed > 0 ? accepted / reviewed : 0,
|
acceptedRate: reviewed > 0 ? accepted / reviewed : 0,
|
||||||
topReasons: Array.from(reasonCountMap.entries())
|
topReasons: Array.from(reasonCountMap.entries())
|
||||||
|
|
@ -71,9 +73,10 @@ export function FeedbackSummaryCard({ projectId, compact = false, tasks = [] }:
|
||||||
enabled: Number.isFinite(numericProjectId) && numericProjectId > 0,
|
enabled: Number.isFinite(numericProjectId) && numericProjectId > 0,
|
||||||
});
|
});
|
||||||
|
|
||||||
const pendingCount = localSummary.pending > 0 ? localSummary.pending : summaryQuery.data?.backlog_count ?? 0;
|
const hasLocalSummary = localSummary.hasLocalSignal;
|
||||||
const acceptedRate = localSummary.acceptedRate > 0 ? localSummary.acceptedRate : Number(summaryQuery.data?.accepted_rate || 0);
|
const pendingCount = hasLocalSummary ? localSummary.pending : summaryQuery.data?.backlog_count ?? 0;
|
||||||
const topReasons = localSummary.topReasons.length
|
const acceptedRate = hasLocalSummary ? localSummary.acceptedRate : Number(summaryQuery.data?.accepted_rate || 0);
|
||||||
|
const topReasons = hasLocalSummary
|
||||||
? localSummary.topReasons
|
? localSummary.topReasons
|
||||||
: (summaryQuery.data?.top_reasons || []).map((item) => ({ label: item.label, count: item.count }));
|
: (summaryQuery.data?.top_reasons || []).map((item) => ({ label: item.label, count: item.count }));
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -107,6 +107,7 @@ export default function AIStewardPage() {
|
||||||
const [conflicts, setConflicts] = useState<CognitiveConflict[]>([]);
|
const [conflicts, setConflicts] = useState<CognitiveConflict[]>([]);
|
||||||
const [blockers, setBlockers] = useState<Blocker[]>([]);
|
const [blockers, setBlockers] = useState<Blocker[]>([]);
|
||||||
const [briefing, setBriefing] = useState<DailyBriefing | null>(null);
|
const [briefing, setBriefing] = useState<DailyBriefing | null>(null);
|
||||||
|
const [briefingError, setBriefingError] = useState<string | null>(null);
|
||||||
const [featureLoading, setFeatureLoading] = useState<string | null>(null);
|
const [featureLoading, setFeatureLoading] = useState<string | null>(null);
|
||||||
const [engineData, setEngineData] = useState<EngineDataState>({
|
const [engineData, setEngineData] = useState<EngineDataState>({
|
||||||
active_engine_id: null,
|
active_engine_id: null,
|
||||||
|
|
@ -166,6 +167,7 @@ export default function AIStewardPage() {
|
||||||
|
|
||||||
const loadBriefing = async () => {
|
const loadBriefing = async () => {
|
||||||
setFeatureLoading("briefing");
|
setFeatureLoading("briefing");
|
||||||
|
setBriefingError(null);
|
||||||
try {
|
try {
|
||||||
const token = getAuthToken();
|
const token = getAuthToken();
|
||||||
const response = await fetch(`${apiBaseURL}/ai-steward/daily-briefing`, {
|
const response = await fetch(`${apiBaseURL}/ai-steward/daily-briefing`, {
|
||||||
|
|
@ -174,9 +176,16 @@ export default function AIStewardPage() {
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
setBriefing(data);
|
setBriefing(data);
|
||||||
|
} else {
|
||||||
|
const payload = await response.json().catch(() => null);
|
||||||
|
const detail = typeof payload?.detail === "string" ? payload.detail : typeof payload?.message === "string" ? payload.message : "";
|
||||||
|
setBriefing(null);
|
||||||
|
setBriefingError(detail || `获取简报失败 (${response.status})`);
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("Failed to load briefing:", err);
|
console.error("Failed to load briefing:", err);
|
||||||
|
setBriefing(null);
|
||||||
|
setBriefingError(err instanceof Error ? err.message : "获取简报失败");
|
||||||
} finally {
|
} finally {
|
||||||
setFeatureLoading(null);
|
setFeatureLoading(null);
|
||||||
}
|
}
|
||||||
|
|
@ -621,6 +630,7 @@ export default function AIStewardPage() {
|
||||||
<BriefingTab
|
<BriefingTab
|
||||||
featureLoading={featureLoading}
|
featureLoading={featureLoading}
|
||||||
briefing={briefing}
|
briefing={briefing}
|
||||||
|
briefingError={briefingError}
|
||||||
username={user?.username}
|
username={user?.username}
|
||||||
onRefreshBriefing={() => void loadBriefing()}
|
onRefreshBriefing={() => void loadBriefing()}
|
||||||
/>
|
/>
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import type { FeedbackCenterRecord } from "@/api/feedbackCenter";
|
import type { FeedbackCenterRecord, FeedbackCenterSummaryReason } from "@/api/feedbackCenter";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
|
|
||||||
|
|
@ -9,8 +9,11 @@ interface FeedbackListProps {
|
||||||
onSearchChange: (value: string) => void;
|
onSearchChange: (value: string) => void;
|
||||||
sourceType: string;
|
sourceType: string;
|
||||||
reviewStatus: string;
|
reviewStatus: string;
|
||||||
|
reasonCode: string;
|
||||||
onSourceTypeChange: (value: string) => void;
|
onSourceTypeChange: (value: string) => void;
|
||||||
onReviewStatusChange: (value: string) => void;
|
onReviewStatusChange: (value: string) => void;
|
||||||
|
onReasonCodeChange: (value: string) => void;
|
||||||
|
reasonOptions: FeedbackCenterSummaryReason[];
|
||||||
onSelect: (item: FeedbackCenterRecord) => void;
|
onSelect: (item: FeedbackCenterRecord) => void;
|
||||||
total: number;
|
total: number;
|
||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
|
|
@ -37,8 +40,11 @@ export function FeedbackList({
|
||||||
onSearchChange,
|
onSearchChange,
|
||||||
sourceType,
|
sourceType,
|
||||||
reviewStatus,
|
reviewStatus,
|
||||||
|
reasonCode,
|
||||||
onSourceTypeChange,
|
onSourceTypeChange,
|
||||||
onReviewStatusChange,
|
onReviewStatusChange,
|
||||||
|
onReasonCodeChange,
|
||||||
|
reasonOptions,
|
||||||
onSelect,
|
onSelect,
|
||||||
total,
|
total,
|
||||||
isLoading,
|
isLoading,
|
||||||
|
|
@ -57,7 +63,7 @@ export function FeedbackList({
|
||||||
placeholder="搜索任务标题、问题、信号..."
|
placeholder="搜索任务标题、问题、信号..."
|
||||||
className="border-slate-200"
|
className="border-slate-200"
|
||||||
/>
|
/>
|
||||||
<div className="grid grid-cols-2 gap-2">
|
<div className="grid grid-cols-1 gap-2 md:grid-cols-3">
|
||||||
<select
|
<select
|
||||||
value={sourceType}
|
value={sourceType}
|
||||||
onChange={(event) => onSourceTypeChange(event.target.value)}
|
onChange={(event) => onSourceTypeChange(event.target.value)}
|
||||||
|
|
@ -80,6 +86,18 @@ export function FeedbackList({
|
||||||
<option value="accepted">已采纳</option>
|
<option value="accepted">已采纳</option>
|
||||||
<option value="rejected">已拒绝</option>
|
<option value="rejected">已拒绝</option>
|
||||||
</select>
|
</select>
|
||||||
|
<select
|
||||||
|
value={reasonCode}
|
||||||
|
onChange={(event) => onReasonCodeChange(event.target.value)}
|
||||||
|
className="h-10 rounded-md border border-slate-200 bg-white px-3 text-sm text-slate-700"
|
||||||
|
>
|
||||||
|
<option value="">全部原因</option>
|
||||||
|
{reasonOptions.map((reason) => (
|
||||||
|
<option key={reason.code} value={reason.code}>
|
||||||
|
{reason.label} · {reason.count}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs text-slate-500">共 {total} 条记录</p>
|
<p className="text-xs text-slate-500">共 {total} 条记录</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,6 @@
|
||||||
import type {
|
import type {
|
||||||
|
FeedbackCenterDrilldownPreset,
|
||||||
|
FeedbackCenterInsight,
|
||||||
FeedbackCenterSourceGroup,
|
FeedbackCenterSourceGroup,
|
||||||
FeedbackCenterSummary,
|
FeedbackCenterSummary,
|
||||||
FeedbackCenterTrendWindowDays,
|
FeedbackCenterTrendWindowDays,
|
||||||
|
|
@ -14,6 +16,7 @@ interface TrendPanelProps {
|
||||||
sourceGroup: FeedbackCenterSourceGroup;
|
sourceGroup: FeedbackCenterSourceGroup;
|
||||||
onWindowDaysChange: (value: FeedbackCenterTrendWindowDays) => void;
|
onWindowDaysChange: (value: FeedbackCenterTrendWindowDays) => void;
|
||||||
onSourceGroupChange: (value: FeedbackCenterSourceGroup) => void;
|
onSourceGroupChange: (value: FeedbackCenterSourceGroup) => void;
|
||||||
|
onDrilldown: (preset: FeedbackCenterDrilldownPreset) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const STATUS_COLORS: Record<string, string> = {
|
const STATUS_COLORS: Record<string, string> = {
|
||||||
|
|
@ -63,6 +66,103 @@ function scopeLabel(value: FeedbackCenterSourceGroup): string {
|
||||||
return "全部来源";
|
return "全部来源";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function scopeSection(value: FeedbackCenterSourceGroup): FeedbackCenterDrilldownPreset["section"] {
|
||||||
|
if (value === "qa") return "qa";
|
||||||
|
if (value === "signal") return "signal";
|
||||||
|
return "task";
|
||||||
|
}
|
||||||
|
|
||||||
|
function severityTone(severity: FeedbackCenterInsight["severity"]): string {
|
||||||
|
if (severity === "critical") return "border-rose-200 bg-rose-50 text-rose-700";
|
||||||
|
if (severity === "warning") return "border-amber-200 bg-amber-50 text-amber-700";
|
||||||
|
return "border-sky-200 bg-sky-50 text-sky-700";
|
||||||
|
}
|
||||||
|
|
||||||
|
function severityAccent(severity: FeedbackCenterInsight["severity"]): string {
|
||||||
|
if (severity === "critical") return "from-rose-500 to-rose-600";
|
||||||
|
if (severity === "warning") return "from-amber-500 to-amber-600";
|
||||||
|
return "from-sky-500 to-sky-600";
|
||||||
|
}
|
||||||
|
|
||||||
|
function severityLabel(severity: FeedbackCenterInsight["severity"]): string {
|
||||||
|
if (severity === "critical") return "需要立即处理";
|
||||||
|
if (severity === "warning") return "建议关注";
|
||||||
|
return "趋势稳定";
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildDecisionHeadline(summary?: FeedbackCenterSummary | null): string {
|
||||||
|
const insights = summary?.insights || [];
|
||||||
|
if (!insights.length) {
|
||||||
|
return "当前趋势稳定,可以继续观察样本变化。";
|
||||||
|
}
|
||||||
|
const first = insights[0];
|
||||||
|
if (first.severity === "critical") {
|
||||||
|
return `当前优先处理:${first.title}`;
|
||||||
|
}
|
||||||
|
if (first.severity === "warning") {
|
||||||
|
return `当前建议关注:${first.title}`;
|
||||||
|
}
|
||||||
|
return first.title;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildQuickActions(
|
||||||
|
summary?: FeedbackCenterSummary | null
|
||||||
|
): Array<{ label: string; description: string; preset: FeedbackCenterDrilldownPreset }> {
|
||||||
|
const sourceGroup = summary?.source_group === "task" || summary?.source_group === "qa" || summary?.source_group === "signal"
|
||||||
|
? summary.source_group
|
||||||
|
: "all";
|
||||||
|
const baseSection = scopeSection(sourceGroup);
|
||||||
|
const actions: Array<{ label: string; description: string; preset: FeedbackCenterDrilldownPreset }> = [
|
||||||
|
{
|
||||||
|
label: "查看当前范围样本",
|
||||||
|
description: `跳到 ${scopeLabel(sourceGroup)} 的列表页,继续做字段级复核。`,
|
||||||
|
preset: { section: baseSection },
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const topReason = summary?.trend?.current?.top_reasons?.[0];
|
||||||
|
if (topReason?.code) {
|
||||||
|
actions.unshift({
|
||||||
|
label: "查看当前窗口高频原因样本",
|
||||||
|
description: `${topReason.label} · ${topReason.count} 条`,
|
||||||
|
preset: { section: baseSection, reason_code: topReason.code },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sourceGroup === "all" || sourceGroup === "task") {
|
||||||
|
actions.push(
|
||||||
|
{
|
||||||
|
label: "查看任务拆解",
|
||||||
|
description: "聚焦 AI 拆解和自动指派结果。",
|
||||||
|
preset: { section: "task", source_type: "task_breakdown" },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "查看任务覆盖",
|
||||||
|
description: "聚焦人工覆盖和回写的字段。",
|
||||||
|
preset: { section: "task", source_type: "task_override" },
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sourceGroup === "all" || sourceGroup === "qa") {
|
||||||
|
actions.push({
|
||||||
|
label: "查看 QA 反馈",
|
||||||
|
description: "聚焦问答样本的人工修正。",
|
||||||
|
preset: { section: "qa", source_type: "qa_feedback" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sourceGroup === "all" || sourceGroup === "signal") {
|
||||||
|
actions.push({
|
||||||
|
label: "查看信号反馈",
|
||||||
|
description: "聚焦信号判断和置信度。",
|
||||||
|
preset: { section: "signal", source_type: "signal_feedback" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return actions;
|
||||||
|
}
|
||||||
|
|
||||||
function MetricCard({
|
function MetricCard({
|
||||||
title,
|
title,
|
||||||
value,
|
value,
|
||||||
|
|
@ -146,8 +246,13 @@ export function FeedbackTrendPanel({
|
||||||
sourceGroup,
|
sourceGroup,
|
||||||
onWindowDaysChange,
|
onWindowDaysChange,
|
||||||
onSourceGroupChange,
|
onSourceGroupChange,
|
||||||
|
onDrilldown,
|
||||||
}: TrendPanelProps) {
|
}: TrendPanelProps) {
|
||||||
const trend = summary?.trend;
|
const trend = summary?.trend;
|
||||||
|
const insights = summary?.insights || [];
|
||||||
|
const primaryInsight = insights[0];
|
||||||
|
const decisionHeadline = buildDecisionHeadline(summary);
|
||||||
|
const quickActions = buildQuickActions(summary);
|
||||||
|
|
||||||
if (isLoading && !trend) {
|
if (isLoading && !trend) {
|
||||||
return (
|
return (
|
||||||
|
|
@ -265,6 +370,96 @@ export function FeedbackTrendPanel({
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="rounded-3xl border border-slate-200 bg-white p-5 shadow-sm">
|
||||||
|
<div className="flex flex-col gap-4 lg:flex-row lg:items-end lg:justify-between">
|
||||||
|
<div className="max-w-3xl">
|
||||||
|
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-slate-400">Decision Focus</p>
|
||||||
|
<h3 className="mt-1 text-2xl font-semibold text-slate-900">{decisionHeadline}</h3>
|
||||||
|
<p className="mt-2 text-sm text-slate-500">
|
||||||
|
这里先告诉你近 {windowDays} 天什么值得处理,再给你一键跳转到对应样本,最后再看趋势图做复盘。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{primaryInsight ? (
|
||||||
|
<Badge variant="outline" className={`self-start border-transparent bg-gradient-to-r px-3 py-1 text-xs font-semibold text-white ${severityAccent(primaryInsight.severity)}`}>
|
||||||
|
{severityLabel(primaryInsight.severity)}
|
||||||
|
</Badge>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-5 grid gap-4 xl:grid-cols-3">
|
||||||
|
{insights.slice(0, 3).map((insight) => (
|
||||||
|
<div
|
||||||
|
key={insight.code}
|
||||||
|
className={`rounded-2xl border p-4 shadow-sm ${severityTone(insight.severity)}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-start justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<p className="text-xs font-semibold uppercase tracking-[0.16em] opacity-70">
|
||||||
|
{severityLabel(insight.severity)}
|
||||||
|
</p>
|
||||||
|
<h4 className="mt-1 text-lg font-semibold text-slate-900">{insight.title}</h4>
|
||||||
|
</div>
|
||||||
|
<Badge variant="outline" className="border-white/60 bg-white/70 text-slate-700">
|
||||||
|
{insight.action_label}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
<p className="mt-3 text-sm leading-6 text-slate-700">{insight.description}</p>
|
||||||
|
<div className="mt-4 flex flex-wrap gap-2 text-xs text-slate-600">
|
||||||
|
<span className="rounded-full bg-white/70 px-2.5 py-1">
|
||||||
|
跳转:{insight.target_section === "task" ? "任务" : insight.target_section === "qa" ? "QA" : insight.target_section === "signal" ? "信号" : "总览"}
|
||||||
|
</span>
|
||||||
|
{insight.target_source_type && (
|
||||||
|
<span className="rounded-full bg-white/70 px-2.5 py-1">{insight.target_source_type}</span>
|
||||||
|
)}
|
||||||
|
{insight.target_reason_code && (
|
||||||
|
<span className="rounded-full bg-white/70 px-2.5 py-1">原因 {insight.target_reason_code}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="sm"
|
||||||
|
className="mt-4 rounded-full bg-slate-900 text-white hover:bg-slate-800"
|
||||||
|
onClick={() =>
|
||||||
|
onDrilldown({
|
||||||
|
section: insight.target_section,
|
||||||
|
source_type: insight.target_source_type ?? undefined,
|
||||||
|
review_status: insight.target_review_status ?? undefined,
|
||||||
|
reason_code: insight.target_reason_code ?? undefined,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{insight.action_label}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-5 border-t border-slate-100 pt-5">
|
||||||
|
<div className="flex flex-col gap-2 lg:flex-row lg:items-center lg:justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-slate-400">Drilldown</p>
|
||||||
|
<h4 className="mt-1 text-lg font-semibold text-slate-900">一键钻取到样本列表</h4>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-slate-500">
|
||||||
|
当前 {windowDays} 天 · {scopeLabel(sourceGroup)} · {trend.window_days} 天窗口
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="mt-4 flex flex-wrap gap-2">
|
||||||
|
{quickActions.map((action) => (
|
||||||
|
<Button
|
||||||
|
key={action.label}
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
className="border-slate-200 bg-white text-slate-700 hover:bg-slate-50"
|
||||||
|
onClick={() => onDrilldown(action.preset)}
|
||||||
|
>
|
||||||
|
{action.label}
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="grid gap-4 xl:grid-cols-[1.4fr_0.9fr]">
|
<div className="grid gap-4 xl:grid-cols-[1.4fr_0.9fr]">
|
||||||
<Card className="rounded-2xl border-slate-200 bg-white shadow-sm">
|
<Card className="rounded-2xl border-slate-200 bg-white shadow-sm">
|
||||||
<CardContent className="p-5">
|
<CardContent className="p-5">
|
||||||
|
|
|
||||||
|
|
@ -20,11 +20,23 @@ import type { DailyBriefing } from "../types";
|
||||||
interface BriefingTabProps {
|
interface BriefingTabProps {
|
||||||
featureLoading: string | null;
|
featureLoading: string | null;
|
||||||
briefing: DailyBriefing | null;
|
briefing: DailyBriefing | null;
|
||||||
|
briefingError?: string | null;
|
||||||
username?: string;
|
username?: string;
|
||||||
onRefreshBriefing: () => void;
|
onRefreshBriefing: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function BriefingTab({ featureLoading, briefing, username, onRefreshBriefing }: BriefingTabProps) {
|
export default function BriefingTab({ featureLoading, briefing, briefingError, username, onRefreshBriefing }: BriefingTabProps) {
|
||||||
|
const hasContent =
|
||||||
|
!!briefing &&
|
||||||
|
Boolean(
|
||||||
|
(briefing.your_tasks && briefing.your_tasks.length > 0) ||
|
||||||
|
(briefing.action_items && briefing.action_items.length > 0) ||
|
||||||
|
(briefing.risks && briefing.risks.length > 0) ||
|
||||||
|
(briefing.conflicts && briefing.conflicts.length > 0) ||
|
||||||
|
(briefing.signals && briefing.signals.length > 0) ||
|
||||||
|
(briefing.team_updates && briefing.team_updates.length > 0)
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TabsContent value="briefing" className="mt-6">
|
<TabsContent value="briefing" className="mt-6">
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
|
|
@ -55,6 +67,15 @@ export default function BriefingTab({ featureLoading, briefing, username, onRefr
|
||||||
<div className="flex justify-center py-8">
|
<div className="flex justify-center py-8">
|
||||||
<Loader2 className="w-8 h-8 animate-spin text-violet-500" />
|
<Loader2 className="w-8 h-8 animate-spin text-violet-500" />
|
||||||
</div>
|
</div>
|
||||||
|
) : briefingError ? (
|
||||||
|
<Card className="border-rose-200 bg-rose-50/80">
|
||||||
|
<CardContent className="pt-6 text-center">
|
||||||
|
<Bell className="w-12 h-12 text-rose-400 mx-auto mb-3" />
|
||||||
|
<h4 className="font-semibold text-rose-700">简报加载失败</h4>
|
||||||
|
<p className="mt-2 text-sm text-rose-600">{briefingError}</p>
|
||||||
|
<p className="mt-2 text-sm text-rose-500">你可以稍后重试,或者先去看问答记录 / 反馈中心。</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
) : !briefing ? (
|
) : !briefing ? (
|
||||||
<Card>
|
<Card>
|
||||||
<CardContent className="pt-6 text-center">
|
<CardContent className="pt-6 text-center">
|
||||||
|
|
@ -77,6 +98,18 @@ export default function BriefingTab({ featureLoading, briefing, username, onRefr
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
{!hasContent && (
|
||||||
|
<Card className="border-dashed border-slate-200 bg-slate-50/80">
|
||||||
|
<CardContent className="pt-6 text-center">
|
||||||
|
<Bell className="w-10 h-10 text-slate-400 mx-auto mb-3" />
|
||||||
|
<h4 className="font-semibold text-slate-700 dark:text-slate-300">最近 24 小时没有新的相关更新</h4>
|
||||||
|
<p className="mt-2 text-sm text-slate-600 dark:text-slate-400">
|
||||||
|
这张简报只展示近期变化;如果项目这一天没有新增任务、风险、冲突或信号,页面就会显得很空。
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ import { useEffect, useMemo, useState } from "react";
|
||||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import {
|
import {
|
||||||
feedbackCenterApi,
|
feedbackCenterApi,
|
||||||
|
type FeedbackCenterDrilldownPreset,
|
||||||
type FeedbackCenterSourceGroup,
|
type FeedbackCenterSourceGroup,
|
||||||
type FeedbackCenterRecord,
|
type FeedbackCenterRecord,
|
||||||
type FeedbackCenterSection,
|
type FeedbackCenterSection,
|
||||||
|
|
@ -43,7 +44,6 @@ function matchesSection(record: FeedbackCenterRecord, section: FeedbackCenterSec
|
||||||
}
|
}
|
||||||
|
|
||||||
function sourceTypeForSection(section: FeedbackCenterSection): string {
|
function sourceTypeForSection(section: FeedbackCenterSection): string {
|
||||||
if (section === "task") return "task_override";
|
|
||||||
if (section === "qa") return "qa_feedback";
|
if (section === "qa") return "qa_feedback";
|
||||||
if (section === "signal") return "signal_feedback";
|
if (section === "signal") return "signal_feedback";
|
||||||
return "";
|
return "";
|
||||||
|
|
@ -64,9 +64,10 @@ export default function FeedbackCenterTab({
|
||||||
const [search, setSearch] = useState("");
|
const [search, setSearch] = useState("");
|
||||||
const [sourceType, setSourceType] = useState("");
|
const [sourceType, setSourceType] = useState("");
|
||||||
const [reviewStatus, setReviewStatus] = useState("");
|
const [reviewStatus, setReviewStatus] = useState("");
|
||||||
|
const [reasonCode, setReasonCode] = useState("");
|
||||||
|
|
||||||
const isAnalyticsSection = section === "metrics" || section === "trend";
|
const isAnalyticsSection = section === "metrics" || section === "trend";
|
||||||
const effectiveSourceType = sourceType || sourceTypeForSection(section);
|
const effectiveSourceType = section === "task" ? sourceType : sourceTypeForSection(section);
|
||||||
|
|
||||||
const summaryQuery = useQuery({
|
const summaryQuery = useQuery({
|
||||||
queryKey: ["feedback-center", "summary", projectId ?? "all"],
|
queryKey: ["feedback-center", "summary", projectId ?? "all"],
|
||||||
|
|
@ -85,12 +86,13 @@ export default function FeedbackCenterTab({
|
||||||
});
|
});
|
||||||
|
|
||||||
const listQuery = useQuery({
|
const listQuery = useQuery({
|
||||||
queryKey: ["feedback-center", "items", projectId ?? "all", effectiveSourceType, reviewStatus, search],
|
queryKey: ["feedback-center", "items", projectId ?? "all", effectiveSourceType, reviewStatus, reasonCode, search],
|
||||||
queryFn: () =>
|
queryFn: () =>
|
||||||
feedbackCenterApi.listItems({
|
feedbackCenterApi.listItems({
|
||||||
project_id: projectId ?? undefined,
|
project_id: projectId ?? undefined,
|
||||||
source_type: effectiveSourceType || undefined,
|
source_type: effectiveSourceType || undefined,
|
||||||
review_status: reviewStatus || undefined,
|
review_status: reviewStatus || undefined,
|
||||||
|
reason_code: reasonCode || undefined,
|
||||||
search: search.trim() || undefined,
|
search: search.trim() || undefined,
|
||||||
limit: 40,
|
limit: 40,
|
||||||
offset: 0,
|
offset: 0,
|
||||||
|
|
@ -147,12 +149,26 @@ export default function FeedbackCenterTab({
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setSelectedId(null);
|
setSelectedId(null);
|
||||||
}, [effectiveSourceType, reviewStatus, search, projectId, section]);
|
}, [effectiveSourceType, reviewStatus, reasonCode, search, projectId, section]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (section !== "task") {
|
||||||
setSourceType("");
|
setSourceType("");
|
||||||
|
}
|
||||||
}, [section]);
|
}, [section]);
|
||||||
|
|
||||||
|
const handleTrendDrilldown = (preset: FeedbackCenterDrilldownPreset) => {
|
||||||
|
if (preset.section === "task") {
|
||||||
|
setSourceType(preset.source_type ?? "");
|
||||||
|
} else {
|
||||||
|
setSourceType("");
|
||||||
|
}
|
||||||
|
setReviewStatus(preset.review_status ?? "");
|
||||||
|
setReasonCode(preset.reason_code ?? "");
|
||||||
|
setSearch("");
|
||||||
|
onSectionChange(preset.section);
|
||||||
|
};
|
||||||
|
|
||||||
const summary = summaryQuery.data;
|
const summary = summaryQuery.data;
|
||||||
const detail = detailQuery.data;
|
const detail = detailQuery.data;
|
||||||
|
|
||||||
|
|
@ -228,6 +244,7 @@ export default function FeedbackCenterTab({
|
||||||
sourceGroup={trendSourceGroup}
|
sourceGroup={trendSourceGroup}
|
||||||
onWindowDaysChange={onTrendWindowDaysChange}
|
onWindowDaysChange={onTrendWindowDaysChange}
|
||||||
onSourceGroupChange={onTrendSourceGroupChange}
|
onSourceGroupChange={onTrendSourceGroupChange}
|
||||||
|
onDrilldown={handleTrendDrilldown}
|
||||||
/>
|
/>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
|
|
@ -279,10 +296,13 @@ export default function FeedbackCenterTab({
|
||||||
onSearchChange={setSearch}
|
onSearchChange={setSearch}
|
||||||
sourceType={sourceType}
|
sourceType={sourceType}
|
||||||
reviewStatus={reviewStatus}
|
reviewStatus={reviewStatus}
|
||||||
|
reasonCode={reasonCode}
|
||||||
onSourceTypeChange={setSourceType}
|
onSourceTypeChange={setSourceType}
|
||||||
onReviewStatusChange={setReviewStatus}
|
onReviewStatusChange={setReviewStatus}
|
||||||
|
onReasonCodeChange={setReasonCode}
|
||||||
|
reasonOptions={summary?.top_reasons || []}
|
||||||
onSelect={(item) => setSelectedId(item.id)}
|
onSelect={(item) => setSelectedId(item.id)}
|
||||||
total={displayItems.length}
|
total={listQuery.data?.total ?? displayItems.length}
|
||||||
isLoading={listQuery.isLoading}
|
isLoading={listQuery.isLoading}
|
||||||
/>
|
/>
|
||||||
<FeedbackDetail record={detail?.record || selectedRecord} revisions={detail?.revisions || []} />
|
<FeedbackDetail record={detail?.record || selectedRecord} revisions={detail?.revisions || []} />
|
||||||
|
|
|
||||||
|
|
@ -2,8 +2,19 @@ import { CheckCircle2 } from "lucide-react";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||||
|
import { apiBaseURL } from "@/lib/api";
|
||||||
|
|
||||||
|
function getFeishuCardActionCallbackURL() {
|
||||||
|
if (typeof window === "undefined") {
|
||||||
|
return "/api/feishu/card-action";
|
||||||
|
}
|
||||||
|
|
||||||
|
return new URL(`${apiBaseURL}/feishu/card-action`, window.location.origin).toString();
|
||||||
|
}
|
||||||
|
|
||||||
export default function SetupGuideCard() {
|
export default function SetupGuideCard() {
|
||||||
|
const cardActionCallbackURL = getFeishuCardActionCallbackURL();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
|
|
@ -68,12 +79,12 @@ export default function SetupGuideCard() {
|
||||||
<TabsContent value="step3">
|
<TabsContent value="step3">
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<p className="text-sm text-slate-600 dark:text-slate-400">
|
<p className="text-sm text-slate-600 dark:text-slate-400">
|
||||||
飞书已统一切换到长连接模式,这一步只需要确认权限即可:
|
飞书消息事件已统一切换到长连接模式,这一步只需要确认权限即可:
|
||||||
</p>
|
</p>
|
||||||
<ul className="space-y-3">
|
<ul className="space-y-3">
|
||||||
<div className="flex items-start gap-2">
|
<div className="flex items-start gap-2">
|
||||||
<Badge>长连接</Badge>
|
<Badge>长连接</Badge>
|
||||||
<span className="text-sm">无需再配置回调地址、Encrypt Key、Verification Token</span>
|
<span className="text-sm">消息事件无需再配置事件订阅回调</span>
|
||||||
</div>
|
</div>
|
||||||
</ul>
|
</ul>
|
||||||
<div className="mt-4 p-3 bg-amber-50 dark:bg-amber-900/20 rounded-lg">
|
<div className="mt-4 p-3 bg-amber-50 dark:bg-amber-900/20 rounded-lg">
|
||||||
|
|
@ -84,7 +95,18 @@ export default function SetupGuideCard() {
|
||||||
|
|
||||||
<TabsContent value="step4">
|
<TabsContent value="step4">
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<p className="text-sm text-slate-600 dark:text-slate-400">当前为「长连接」模式,无需配置任何回调地址。</p>
|
<p className="text-sm text-slate-600 dark:text-slate-400">
|
||||||
|
当前为「长连接」模式,但卡片按钮仍需要配置回调地址:
|
||||||
|
</p>
|
||||||
|
<div className="flex items-start gap-2">
|
||||||
|
<Badge>卡片回调</Badge>
|
||||||
|
<span className="text-sm">
|
||||||
|
在飞书开放平台的卡片动作回调里填写:
|
||||||
|
<code className="ml-1 rounded bg-slate-100 px-2 py-1 text-xs text-slate-700 dark:bg-slate-800 dark:text-slate-200">
|
||||||
|
{cardActionCallbackURL}
|
||||||
|
</code>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="mt-4 space-y-3">
|
<div className="mt-4 space-y-3">
|
||||||
<p className="text-sm font-medium">添加权限:</p>
|
<p className="text-sm font-medium">添加权限:</p>
|
||||||
|
|
@ -117,7 +139,8 @@ export default function SetupGuideCard() {
|
||||||
|
|
||||||
<div className="mt-4 p-3 bg-violet-50 dark:bg-violet-900/20 rounded-lg">
|
<div className="mt-4 p-3 bg-violet-50 dark:bg-violet-900/20 rounded-lg">
|
||||||
<p className="text-sm text-violet-800 dark:text-violet-200">
|
<p className="text-sm text-violet-800 dark:text-violet-200">
|
||||||
💡 提示:配置完成后,后端会自动建立长连接,在飞书群中@机器人即可测试
|
💡 提示:配置完成后,后端会自动建立长连接,在飞书群中@机器人即可测试;
|
||||||
|
卡片按钮回调配置完成后,按钮点击也会正常进入处理链路。
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue