From 8b85e19b54bd4b539adbe1a9bebf7fd945cecb19 Mon Sep 17 00:00:00 2001 From: Yuuuu <1569752928@qq.com> Date: Thu, 26 Mar 2026 16:40:27 +0800 Subject: [PATCH] Create signal sources from project analysis --- CLAUDE.md | 22 +- backend/app/api/ai_pmo_api/__init__.py | 3 +- .../api/ai_pmo_api/helpers_inbox_payload.py | 13 +- backend/app/api/ai_pmo_api/routes_core.py | 2 +- backend/app/api/ai_steward_api/__init__.py | 2 +- .../app/api/feishu_api/card_action_routes.py | 297 +++++++++++++++ backend/app/api/feishu_api/router.py | 2 + backend/app/api/rag_api/__init__.py | 2 +- backend/app/schemas/feedback_center.py | 18 + backend/app/schemas/rag.py | 2 +- .../mixin_answer_entry.py | 31 +- .../ai_pmo_kernel_common_core/imports.py | 1 + .../services/ai_pmo/draft_confirm_handler.py | 8 +- .../daily_briefing_core/mixin_personal.py | 4 +- .../services/daily_briefing_core/shared.py | 3 +- .../app/services/feedback_center/service.py | 343 +++++++++++++++++- .../mixin_runtime.py | 54 ++- .../mixin_capture_entry.py | 46 ++- .../test_adaptive_rag_mainline_contract.py | 47 ++- .../tests/test_ai_pmo_qa_http_mainline_e2e.py | 36 ++ .../tests/test_ai_pmo_task_intent_contract.py | 6 + backend/tests/test_daily_briefing_service.py | 131 +++++++ backend/tests/test_draft_confirm_handler.py | 45 +++ backend/tests/test_feedback_center_api.py | 3 + backend/tests/test_feedback_center_service.py | 173 +++++++++ .../tests/test_feishu_card_action_http_api.py | 208 +++++++++++ ...est_feishu_long_connection_tls_contract.py | 110 ++++++ .../test_feishu_pipeline_queue_contract.py | 64 ++++ backend/tests/test_rag_route_contract.py | 11 + frontend/src/api/feedbackCenter.ts | 23 ++ .../components/ai-steward/FloatingQAChat.tsx | 2 +- .../components/sstc/FeedbackSummaryCard.tsx | 9 +- .../src/pages/ai-steward/AIStewardPage.tsx | 10 + .../feedback-center/FeedbackList.tsx | 22 +- .../components/feedback-center/TrendPanel.tsx | 199 +++++++++- .../pages/ai-steward/sections/BriefingTab.tsx | 35 +- .../ai-steward/sections/FeedbackCenterTab.tsx | 38 +- .../components/SetupGuideCard.tsx | 31 +- 38 files changed, 1990 insertions(+), 66 deletions(-) create mode 100644 backend/app/api/feishu_api/card_action_routes.py create mode 100644 backend/tests/test_daily_briefing_service.py create mode 100644 backend/tests/test_feishu_card_action_http_api.py diff --git a/CLAUDE.md b/CLAUDE.md index a4905d35..561fead9 100644 --- a/CLAUDE.md +++ b/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/models/feedback_center.py`(`FeedbackCenterRecord/FeedbackCenterRecordRevision` 统一反馈中心模型) - 新增:`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_api.py`(反馈中心 API 回归:路由、权限与三类来源回填) -- 更新:`backend/app/services/feedback_center/service.py`(summary 输出新增 metrics 学习指标,并支持 `window_days` / `source_group` 的趋势分析作用域) -- 更新:`backend/app/schemas/feedback_center.py`(summary 增加 metrics / trend 结构,并回传 `source_group`) +- 新增:`backend/tests/test_daily_briefing_service.py`(每日简报回归:北京时间 naive 时间窗、任务更新采集与空响应边界) +- 更新:`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/model/model_routing/service.py`(函数级模型路由配置服务) - 新增:`backend/app/services/task_brief_service.py`(增量任务包中心服务) @@ -213,6 +214,7 @@ backend/ - 更新:`backend/app/services/feishu_signal_pipeline.py`(降级为兼容入口,仅导出包内 `FeishuSignalPipelineService`) - 新增:`backend/app/api/feishu_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/long_connection_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/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/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/app/config/operation_catalog_core/`(operation catalog 按模型/构造器/领域条目拆分) - 更新:`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/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/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/pages/ai-steward/AIStewardPage.tsx`(AI Steward 页面编排层,主文件聚焦状态与事件) - 更新:`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 恢复接口(`POST /ai-pmo/work-items/{work_item_id}/resume`):支持 `action=continue|commit|refresh|cancel`,复用同一闭环恢复入口 - AI PMO 草稿决策接口(`POST /ai-pmo/work-items/{work_item_id}/draft-decision`):支持 `action=confirm|revise|cancel`,与飞书“确认/修改/取消”行为一致 +- AI PMO 路由包(`backend/app/api/ai_pmo_api/__init__.py`)只负责聚合与 tags,`/ai-pmo` 前缀只能由子模块共享 router 挂一次,禁止包级与模块级重复叠加前缀导致 `/ai-pmo/ai-pmo/...` 路径 - RAG 问答主入口契约(`POST /rag/ask`):HTTP 入口已收敛到 `AIPMOKernelService.process_ingress` 主链;请求侧统一透传 `asked_via/action_mode/message_intent/mixed_signal_part`,由 `ingress_options.rag_request_context` 下传到 query executor 并映射到 `AdaptiveRAGService.answer_question`,返回侧从 ingress `rag_result`/`direct_reply_text` 组装兼容响应 +- RAG / AI Steward 路由包(`backend/app/api/rag_api/__init__.py`、`backend/app/api/ai_steward_api/__init__.py`)不再重复挂包级 prefix;路由 canonical 由 `shared.py` 内的共享 router 统一承担,避免运行中出现 `/rag/rag/*`、`/ai-steward/ai-steward/*` - AI PMO 消息台列表接口(`GET /ai-pmo/work-items`):返回 `latest_policy_gate_code` 与规范化 `approval_requirement{required,reason,message}`(来源于最新 `decision_records` 投影) - AI PMO 消息台列表接口补充:返回草稿字段 `draft_content/draft_expires_at/draft_version/confirm_message_id`,用于草稿确认卡片渲染 - 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` - 飞书入站已拆分“短确认同步快路径 + 非短确认异步队列”:短确认命中会话窗口时直接继承上轮意图并同步执行;其余消息先回“已收到,正在处理”后由 `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 +- 准确率优先通道默认深度回答:`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` - 飞书异步入站队列在 `ai_orchestration_runs.result_payload.task_payload` 中必须保留 `thread_context`,worker 重放时不得仅依赖 `source_message_id`,否则 reply-thread 会在异步链路丢锚点 - 飞书纯问答直答不再注入群聊级 `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 锚点或唯一业务目标匹配时才允许继续查询,避免多个话题并行时串线 - 草稿确认成功后执行路径复用 `followup` 恢复链路,目标 work_item 在同事务内完成状态迁移与 `decision_records.final_outcome` 更新 - 飞书回复 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 参数生成可读文案,禁止再回落成泛化“已收到并入库”提示 - 飞书入口已按职责拆分为“预处理/捕获执行/结果回执”三层:`mixin_receive.py` 仅保留编排,`mixin_receive_prepare.py` 负责传输解析与上下文抽取,`mixin_receive_capture_flow.py` 负责 capture 持久化与回执收尾 - `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] LLM 运行时仍未完全收敛:`ai_service_core._invoke_with_retry` 与 `stream_chat` 已统一走 `llm_service`;旧 `_get_llm` 路径已显式禁用,并补回归测试锁定 - [x] QA 主链自动化测试覆盖已补齐:`backend/tests/` 已覆盖问答/RAG 主路径与关键场景;新增 `test_ai_pmo_qa_http_mainline_e2e.py` 通过 HTTP 层锁定 `/rag/ask` 的 `qa_readonly/mixed` 契约以及 `/ai-pmo/process-text` 的 `draft_pending_confirm` 主链返回,配合 `test_ai_pmo_ingress_mainline.py`、`test_ai_pmo_task_intent_contract.py`、`test_ai_pmo_tactical_brain_contract.py` 形成稳定回归基线 -- [x] 飞书交互闭环已补齐:执行场景回执在 `draft_pending_confirm/pending_manual` 下优先生成按钮卡片并经 outbox 异步发送;`card.action.trigger` 已覆盖 `confirm/revise/cancel/continue` 回调分发,分别复用 `DraftConfirmHandler.handle_api_decision` 与 AI PMO `process_ingress` 主链继续执行;`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` 回归契约 diff --git a/backend/app/api/ai_pmo_api/__init__.py b/backend/app/api/ai_pmo_api/__init__.py index 67ff2ba3..bebae732 100644 --- a/backend/app/api/ai_pmo_api/__init__.py +++ b/backend/app/api/ai_pmo_api/__init__.py @@ -4,7 +4,8 @@ from fastapi import APIRouter 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]: router.include_router(_module.router) diff --git a/backend/app/api/ai_pmo_api/helpers_inbox_payload.py b/backend/app/api/ai_pmo_api/helpers_inbox_payload.py index 43071d78..20ca6a61 100644 --- a/backend/app/api/ai_pmo_api/helpers_inbox_payload.py +++ b/backend/app/api/ai_pmo_api/helpers_inbox_payload.py @@ -1,7 +1,18 @@ from __future__ import annotations 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( *, diff --git a/backend/app/api/ai_pmo_api/routes_core.py b/backend/app/api/ai_pmo_api/routes_core.py index 24a80e5a..5be9f83b 100644 --- a/backend/app/api/ai_pmo_api/routes_core.py +++ b/backend/app/api/ai_pmo_api/routes_core.py @@ -1,7 +1,7 @@ from __future__ import annotations 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 .schemas import PMOProcessTextRequest diff --git a/backend/app/api/ai_steward_api/__init__.py b/backend/app/api/ai_steward_api/__init__.py index 7f07d692..cab4ee34 100644 --- a/backend/app/api/ai_steward_api/__init__.py +++ b/backend/app/api/ai_steward_api/__init__.py @@ -12,7 +12,7 @@ from . import ( routes_workflow, ) -router = APIRouter(prefix="/ai-steward", tags=["AI Steward"]) +router = APIRouter(tags=["AI Steward"]) for _module in [ routes_rag_sources, routes_capture, diff --git a/backend/app/api/feishu_api/card_action_routes.py b/backend/app/api/feishu_api/card_action_routes.py new file mode 100644 index 00000000..b09421e8 --- /dev/null +++ b/backend/app/api/feishu_api/card_action_routes.py @@ -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) diff --git a/backend/app/api/feishu_api/router.py b/backend/app/api/feishu_api/router.py index b89f7c77..742f69bb 100644 --- a/backend/app/api/feishu_api/router.py +++ b/backend/app/api/feishu_api/router.py @@ -5,11 +5,13 @@ from __future__ import annotations from fastapi import APIRouter 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 .message_routes import router as message_router from .test_routes import router as test_router router = APIRouter(prefix="/feishu", tags=["Feishu Integration"]) +router.include_router(card_action_router) router.include_router(config_router) router.include_router(long_connection_router) router.include_router(message_router) diff --git a/backend/app/api/rag_api/__init__.py b/backend/app/api/rag_api/__init__.py index 48bf177c..29c7d158 100644 --- a/backend/app/api/rag_api/__init__.py +++ b/backend/app/api/rag_api/__init__.py @@ -4,7 +4,7 @@ from fastapi import APIRouter 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]: router.include_router(_module.router) diff --git a/backend/app/schemas/feedback_center.py b/backend/app/schemas/feedback_center.py index dbe6b644..256268b3 100644 --- a/backend/app/schemas/feedback_center.py +++ b/backend/app/schemas/feedback_center.py @@ -66,6 +66,20 @@ class FeedbackCenterSummaryReasonRead(BaseModel): 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): accepted_rate_reviewed: float manual_override_rate: float @@ -123,7 +137,9 @@ class FeedbackCenterTrendWindowRead(BaseModel): rework_rate: float auto_assigned_rate: float avg_review_latency_minutes: float + avg_confidence: float revision_count: int + top_reasons: list[FeedbackCenterSummaryReasonRead] = Field(default_factory=list) class FeedbackCenterTrendDeltaRead(BaseModel): @@ -166,6 +182,7 @@ class FeedbackCenterSummaryRead(BaseModel): by_status: dict[str, int] by_source_type: dict[str, int] top_reasons: list[FeedbackCenterSummaryReasonRead] + insights: list[FeedbackCenterInsightRead] metrics: FeedbackCenterSummaryMetricsRead trend: FeedbackCenterTrendRead @@ -197,6 +214,7 @@ __all__ = [ "FeedbackCenterRecordRevisionRead", "FeedbackCenterRecordRead", "FeedbackCenterSummaryReasonRead", + "FeedbackCenterInsightRead", "FeedbackCenterSummaryMetricsRead", "FeedbackCenterTrendDailyRead", "FeedbackCenterTrendWindowRead", diff --git a/backend/app/schemas/rag.py b/backend/app/schemas/rag.py index cf665740..cbbb6dd6 100644 --- a/backend/app/schemas/rag.py +++ b/backend/app/schemas/rag.py @@ -30,7 +30,7 @@ class RAGAskRequest(BaseModel): source_chat_id: Optional[str] = Field(default=None, max_length=128) session_id: Optional[int] = Field(default=None, ge=1) 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)$") sync_requirement: bool = Field(default=True) message_intent: Optional[str] = Field(default=None, max_length=32) diff --git a/backend/app/services/adaptive_rag_service/mixin_answer_entry.py b/backend/app/services/adaptive_rag_service/mixin_answer_entry.py index 86795c58..ffb67262 100644 --- a/backend/app/services/adaptive_rag_service/mixin_answer_entry.py +++ b/backend/app/services/adaptive_rag_service/mixin_answer_entry.py @@ -5,6 +5,23 @@ from .shared import * 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 def _should_enforce_scene_prompt_only( *, @@ -33,7 +50,11 @@ class AdaptiveRAGAnswerEntryMixin: sync_requirement: bool, asked_via: str, effective_action_mode: str, + message_intent: Optional[str] = None, ) -> bool: + intent = str(message_intent or "").strip().lower() + if intent in {"mixed", "question"}: + return False return ( bool(sync_requirement) and str(asked_via or "").strip().lower() != "feishu" @@ -60,18 +81,15 @@ class AdaptiveRAGAnswerEntryMixin: started = time.perf_counter() clean_q = str(question or "").strip() active_thinking_mode = self._normalize_thinking_mode(thinking_mode) + if self._is_high_accuracy_channel(asked_via): + active_thinking_mode = THINKING_MODE_DEEP deep_thinking = active_thinking_mode == THINKING_MODE_DEEP - asked_via_lower = str(asked_via or "").strip().lower() enforce_scene_prompt_only = self._should_enforce_scene_prompt_only( asked_via=asked_via, message_intent=message_intent, ) deep_retrieval_steps = DEEP_RETRIEVAL_STEPS 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: return self._build_response_payload( answer="问题为空,无法检索。", @@ -117,6 +135,7 @@ class AdaptiveRAGAnswerEntryMixin: sync_requirement=sync_requirement, asked_via=asked_via, effective_action_mode=effective_action_mode, + message_intent=message_intent, ): ack_result = await self._handle_ack_first_requirement( question=clean_q, @@ -149,7 +168,7 @@ class AdaptiveRAGAnswerEntryMixin: effective_q=effective_q, rewrite_meta=rewrite_meta, query_type=query_type, - thinking_mode=thinking_mode, + thinking_mode=active_thinking_mode, active_thinking_mode=active_thinking_mode, deep_thinking=deep_thinking, enforce_scene_prompt_only=enforce_scene_prompt_only, diff --git a/backend/app/services/ai_pmo/ai_pmo_kernel_common_core/imports.py b/backend/app/services/ai_pmo/ai_pmo_kernel_common_core/imports.py index ec3d7345..ee252eb9 100644 --- a/backend/app/services/ai_pmo/ai_pmo_kernel_common_core/imports.py +++ b/backend/app/services/ai_pmo/ai_pmo_kernel_common_core/imports.py @@ -171,6 +171,7 @@ from ..ai_pmo_intent import ( ROUTE_TASK_UPDATE, ROUTE_SIGNAL_SOURCE_ONBOARD, ROUTE_TASK_CREATE, + WORKFLOW_ROUTE_KEYS, SESSION_SHORT_CONFIRM_WINDOW_SECONDS, SHORT_CONFIRM_HINTS, SHORT_CONFIRM_MAX_MESSAGE_LENGTH, diff --git a/backend/app/services/ai_pmo/draft_confirm_handler.py b/backend/app/services/ai_pmo/draft_confirm_handler.py index 6850ae30..620f9f71 100644 --- a/backend/app/services/ai_pmo/draft_confirm_handler.py +++ b/backend/app/services/ai_pmo/draft_confirm_handler.py @@ -246,10 +246,16 @@ class DraftConfirmHandler: state = str(locked.state or "").strip() if state != STATE_DRAFT_PENDING_CONFIRM: + if state == STATE_SUCCEEDED: + followup_reply = "已入库成功,无需重复确认。" + elif state == STATE_CANCELLED: + followup_reply = "该草稿已取消,无需重复确认。" + else: + followup_reply = "该草稿已处理,无需重复确认。" return { "handled": True, "status": "succeeded" if state == STATE_SUCCEEDED else "failed_safe", - "followup_reply": "该草稿已处理,无需重复确认。", + "followup_reply": followup_reply, "target_work_item_id": int(locked.id), "target_state": state or None, "target_intent": str(locked.intent or "").strip() or None, diff --git a/backend/app/services/daily_briefing_core/mixin_personal.py b/backend/app/services/daily_briefing_core/mixin_personal.py index 268186f9..e6a5ed66 100644 --- a/backend/app/services/daily_briefing_core/mixin_personal.py +++ b/backend/app/services/daily_briefing_core/mixin_personal.py @@ -17,7 +17,7 @@ from .shared import ( and_, select, timedelta, - now_cn_aware, + now_cn_naive, ) @@ -35,7 +35,7 @@ class DailyBriefingPersonalMixin: if not user: raise ValueError(f"User {user_id} not found") - now = now_cn_aware() + now = now_cn_naive() yesterday = now - timedelta(days=1) relevant_data = await self._collect_relevant_data( diff --git a/backend/app/services/daily_briefing_core/shared.py b/backend/app/services/daily_briefing_core/shared.py index 159e0e1a..c3f9c402 100644 --- a/backend/app/services/daily_briefing_core/shared.py +++ b/backend/app/services/daily_briefing_core/shared.py @@ -21,7 +21,7 @@ from ...models import ( User, UserRole, ) -from ...utils.timezone import now_cn_aware +from ...utils.timezone import now_cn_aware, now_cn_naive from ..llm_service import LLMService __all__ = [ @@ -45,6 +45,7 @@ __all__ = [ "func", "json", "now_cn_aware", + "now_cn_naive", "select", "text", "timedelta", diff --git a/backend/app/services/feedback_center/service.py b/backend/app/services/feedback_center/service.py index 59c2cd3d..f1ad0982 100644 --- a/backend/app/services/feedback_center/service.py +++ b/backend/app/services/feedback_center/service.py @@ -129,11 +129,15 @@ def _trend_accumulator(date_value: date | None = None) -> dict[str, Any]: "override_count": 0, "rework_count": 0, "revision_count": 0, + "avg_confidence": 0.0, + "top_reasons": [], "_breakdown_task_ids": set(), "_override_task_ids": set(), "_auto_assigned_task_ids": set(), "_latencies": [], "_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())) rework_count = len(accumulator.pop("_reworked_record_ids", set())) latencies = accumulator.pop("_latencies", []) + confidences = accumulator.pop("_confidences", []) + reason_counts = accumulator.pop("_reason_counts", {}) accepted_count = int(accumulator.get("accepted_count", 0)) rejected_count = int(accumulator.get("rejected_count", 0)) reviewed_count = int(accumulator.get("reviewed_count", 0)) pending_count = int(accumulator.get("pending_count", 0)) total_records = int(accumulator.get("total_records", 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 + 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_override_count"] = override_count 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["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_confidence"] = avg_confidence + accumulator["top_reasons"] = top_reasons accumulator["reviewed_count"] = reviewed_count accumulator["pending_count"] = pending_count accumulator["accepted_count"] = accepted_count @@ -174,6 +197,43 @@ def _finalize_trend_accumulator(accumulator: dict[str, Any]) -> dict[str, Any]: 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: """统一反馈中心服务。""" @@ -290,11 +350,250 @@ class FeedbackCenterService: if revision_count > 1: 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: delta = (record.reviewed_at - record.created_at).total_seconds() / 60 if delta >= 0: 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( self, *, @@ -318,9 +617,18 @@ class FeedbackCenterService: .where( and_( FeedbackCenterRecord.tenant_id == int(tenant_id), - FeedbackCenterRecord.created_at.is_not(None), - FeedbackCenterRecord.created_at >= start_dt, - FeedbackCenterRecord.created_at < end_dt, + or_( + and_( + FeedbackCenterRecord.created_at.is_not(None), + FeedbackCenterRecord.created_at >= start_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() for record in records: + reviewed_at = getattr(record, "reviewed_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 - created_date = created_at.date() + activity_date = activity_at.date() revision_count = len(record.revisions or []) - if current_start <= created_date <= today: - bucket = current_daily.get(created_date) + if current_start <= activity_date <= today: + bucket = current_daily.get(activity_date) if bucket is not None: self._trend_add_record(bucket, record, revision_count) self._trend_add_record(current_window, record, revision_count) continue - if previous_start <= created_date < current_start: + if previous_start <= activity_date < current_start: 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)] @@ -558,6 +868,23 @@ class FeedbackCenterService: "by_status": by_status, "by_source_type": by_source_type, "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": { "accepted_rate_reviewed": accepted_rate_reviewed, "manual_override_rate": manual_override_rate, diff --git a/backend/app/services/integrations/feishu_long_connection_core/mixin_runtime.py b/backend/app/services/integrations/feishu_long_connection_core/mixin_runtime.py index 6f989497..5b7e4e2b 100644 --- a/backend/app/services/integrations/feishu_long_connection_core/mixin_runtime.py +++ b/backend/app/services/integrations/feishu_long_connection_core/mixin_runtime.py @@ -12,6 +12,10 @@ from .shared import ( now_cn_aware, time, ) +from lark_oapi.event.callback.model.p2_card_action_trigger import ( + CallBackToast, + P2CardActionTriggerResponse, +) class LongConnectionRuntimeMixin: @@ -25,9 +29,19 @@ class LongConnectionRuntimeMixin: verification_token: str = "", ) -> None: """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: ensure_feishu_tls_trust_bundle() 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 "") register_receive = getattr(builder, "register_p2_im_message_receive_v1", None) @@ -36,7 +50,7 @@ class LongConnectionRuntimeMixin: builder = register_receive(self._on_message_event) register_card_action = getattr(builder, "register_p2_card_action_trigger", None) 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) if callable(register_read): @@ -78,6 +92,22 @@ class LongConnectionRuntimeMixin: except Exception as exc: # pragma: no cover logger.warning(f"feishu_long_connection_client_cleanup_failed: {exc}") 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: self._last_disconnect_at = now_cn_aware().isoformat() if not self._last_error_code: @@ -174,6 +204,28 @@ class LongConnectionRuntimeMixin: self._set_error(str(exc), code="enqueue_error", retryable=True) 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: """Ignore non-business events to avoid SDK processor-not-found noise.""" if not self._running: diff --git a/backend/app/services/signal/signal_capture_service/mixin_capture_entry.py b/backend/app/services/signal/signal_capture_service/mixin_capture_entry.py index 2f3cf05c..93b3cf82 100644 --- a/backend/app/services/signal/signal_capture_service/mixin_capture_entry.py +++ b/backend/app/services/signal/signal_capture_service/mixin_capture_entry.py @@ -1,12 +1,31 @@ from __future__ import annotations from .shared import * +from ...ai_pmo.ai_pmo_intent import INTENT_IGNORE, INTENT_QUERY, IntentResolver class SignalCaptureEntryMixin: def __init__(self, db: AsyncSession): self.db = db 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( self, group_chat_id: str, @@ -64,6 +83,12 @@ class SignalCaptureEntryMixin: kernel = AIPMOKernelService(self.db, tenant_id=tenant_id) try: 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 if not should_sync_process: enqueue_session = await kernel._get_or_create_session( @@ -80,24 +105,21 @@ class SignalCaptureEntryMixin: message_sent_at=message_sent_at, session_snapshot=session_snapshot, ) + if should_sync_process: + sync_reason = "short_confirmation" 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( captured_dt=captured_dt, message_sent_at=message_sent_at, ) - effective_session_snapshot = ( - 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, - ) - ) + effective_session_snapshot = session_snapshot if isinstance(session_snapshot, dict) else None return await self._process_feishu_with_kernel_sync( trace_id=trace_id, tenant_id=tenant_id, diff --git a/backend/tests/test_adaptive_rag_mainline_contract.py b/backend/tests/test_adaptive_rag_mainline_contract.py index 1e3aa9e1..6ea61237 100644 --- a/backend/tests/test_adaptive_rag_mainline_contract.py +++ b/backend/tests/test_adaptive_rag_mainline_contract.py @@ -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 ( AdaptiveRAGDeterministicQueryCoreMixin, ) +from app.services.adaptive_rag_service.shared_part_02 import DEEP_RETRIEVAL_STEPS, DEEP_VECTOR_TOP_K class _FakeAdaptiveRAGService( @@ -83,6 +84,46 @@ async def test_answer_entry_write_mode_uses_requirement_ack_gate() -> None: 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 async def test_answer_entry_feishu_write_mode_still_goes_base_mainline() -> None: 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 service.ack_calls == 0 assert service.base_calls == 1 - assert int(service.base_kwargs["deep_retrieval_steps"]) <= 3 - assert int(service.base_kwargs["deep_vector_top_k"]) <= 8 + 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 diff --git a/backend/tests/test_ai_pmo_qa_http_mainline_e2e.py b/backend/tests/test_ai_pmo_qa_http_mainline_e2e.py index 1b3042c7..2352ffc8 100644 --- a/backend/tests/test_ai_pmo_qa_http_mainline_e2e.py +++ b/backend/tests/test_ai_pmo_qa_http_mainline_e2e.py @@ -4,6 +4,7 @@ import pytest from fastapi import FastAPI from httpx import ASGITransport, AsyncClient +from app.api.router import api_router from app.api.ai_pmo_api import routes_core from app.api.rag_api import routes_query 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["message_text"] == "请创建任务并先给草稿" 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 diff --git a/backend/tests/test_ai_pmo_task_intent_contract.py b/backend/tests/test_ai_pmo_task_intent_contract.py index bb87dc35..b72945a0 100644 --- a/backend/tests/test_ai_pmo_task_intent_contract.py +++ b/backend/tests/test_ai_pmo_task_intent_contract.py @@ -15,6 +15,7 @@ from app.services.ai_pmo.ai_pmo_intent import ( IntentResolver, 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 @@ -79,6 +80,11 @@ def test_route_resolver_followup_action_supports_message_text_fallback() -> None 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): def __init__(self) -> None: self.called: dict[str, object] = {} diff --git a/backend/tests/test_daily_briefing_service.py b/backend/tests/test_daily_briefing_service.py new file mode 100644 index 00000000..19c88d61 --- /dev/null +++ b/backend/tests/test_daily_briefing_service.py @@ -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"] == "修复简报时间窗" diff --git a/backend/tests/test_draft_confirm_handler.py b/backend/tests/test_draft_confirm_handler.py index 26eaab1e..cd53902f 100644 --- a/backend/tests/test_draft_confirm_handler.py +++ b/backend/tests/test_draft_confirm_handler.py @@ -1,3 +1,5 @@ +from types import SimpleNamespace + import pytest from app.config.ai_prompts import ( @@ -6,6 +8,7 @@ from app.config.ai_prompts import ( PROMPT_FEISHU_MIXED_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.ai_pmo_kernel_ingress_flow import mixin_auto 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" +@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 async def test_draft_confirm_handler_ignores_non_draft_reply_without_target(monkeypatch) -> None: kernel = type("Kernel", (), {"db": object(), "tenant_id": 1})() diff --git a/backend/tests/test_feedback_center_api.py b/backend/tests/test_feedback_center_api.py index 2efedb0a..cc4adf5e 100644 --- a/backend/tests/test_feedback_center_api.py +++ b/backend/tests/test_feedback_center_api.py @@ -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_override_count"] == 1 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 diff --git a/backend/tests/test_feedback_center_service.py b/backend/tests/test_feedback_center_service.py index ee0bacf3..996f0d52 100644 --- a/backend/tests/test_feedback_center_service.py +++ b/backend/tests/test_feedback_center_service.py @@ -452,6 +452,90 @@ async def test_summary_metrics_cover_learning_kpis(db_session) -> None: 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 async def test_summary_trend_cover_daily_windows_and_deltas(db_session, monkeypatch) -> None: 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) +@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 async def test_summary_trend_respects_source_group_scope(db_session, monkeypatch) -> None: fixed_now = datetime(2025, 1, 15, 12, 0, 0) diff --git a/backend/tests/test_feishu_card_action_http_api.py b/backend/tests/test_feishu_card_action_http_api.py new file mode 100644 index 00000000..3a6f54d4 --- /dev/null +++ b/backend/tests/test_feishu_card_action_http_api.py @@ -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"} diff --git a/backend/tests/test_feishu_long_connection_tls_contract.py b/backend/tests/test_feishu_long_connection_tls_contract.py index bb1939cb..9d5aa13f 100644 --- a/backend/tests/test_feishu_long_connection_tls_contract.py +++ b/backend/tests/test_feishu_long_connection_tls_contract.py @@ -4,6 +4,9 @@ import os import certifi 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_long_connection_core.mixin_lifecycle_connection import ( 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_at"] == manager._last_received_at 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 diff --git a/backend/tests/test_feishu_pipeline_queue_contract.py b/backend/tests/test_feishu_pipeline_queue_contract.py index 829d2fc6..af35af1f 100644 --- a/backend/tests/test_feishu_pipeline_queue_contract.py +++ b/backend/tests/test_feishu_pipeline_queue_contract.py @@ -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 ( 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 ( 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 +@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 async def test_project_context_prefill_skips_chatwide_reply_fallback(monkeypatch) -> None: called = {"fallback": False} diff --git a/backend/tests/test_rag_route_contract.py b/backend/tests/test_rag_route_contract.py index 2a12af3f..898e347d 100644 --- a/backend/tests/test_rag_route_contract.py +++ b/backend/tests/test_rag_route_contract.py @@ -4,12 +4,14 @@ import pytest from pydantic import ValidationError 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.rag_api import routes_query from app.schemas.rag import RAGAskRequest def test_rag_ask_request_normalizes_contract_fields() -> None: + assert RAGAskRequest(question="状态怎么样").thinking_mode == "deep" req = RAGAskRequest( question="状态怎么样", 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 +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 async def test_rag_ask_route_uses_shared_contract_builder(monkeypatch) -> None: captured_kwargs: list[dict[str, object]] = [] diff --git a/frontend/src/api/feedbackCenter.ts b/frontend/src/api/feedbackCenter.ts index 5c873fac..2ef9a120 100644 --- a/frontend/src/api/feedbackCenter.ts +++ b/frontend/src/api/feedbackCenter.ts @@ -14,6 +14,14 @@ export type FeedbackCenterReviewStatus = "pending" | "reviewed" | "accepted" | " export type FeedbackCenterSection = "overview" | "metrics" | "trend" | "task" | "qa" | "signal"; export type FeedbackCenterTrendWindowDays = 7 | 30; 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 { code: string; @@ -68,6 +76,18 @@ export interface FeedbackCenterSummaryReason { 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 { accepted_rate_reviewed: number; manual_override_rate: number; @@ -121,7 +141,9 @@ export interface FeedbackCenterTrendWindow { rework_rate: number; auto_assigned_rate: number; avg_review_latency_minutes: number; + avg_confidence: number; revision_count: number; + top_reasons: FeedbackCenterSummaryReason[]; } export interface FeedbackCenterTrendDelta { @@ -158,6 +180,7 @@ export interface FeedbackCenterSummary { by_status: Record; by_source_type: Record; top_reasons: FeedbackCenterSummaryReason[]; + insights: FeedbackCenterInsight[]; metrics: FeedbackCenterSummaryMetrics; trend: FeedbackCenterTrend; } diff --git a/frontend/src/components/ai-steward/FloatingQAChat.tsx b/frontend/src/components/ai-steward/FloatingQAChat.tsx index 094aa7d7..6640c9c4 100644 --- a/frontend/src/components/ai-steward/FloatingQAChat.tsx +++ b/frontend/src/components/ai-steward/FloatingQAChat.tsx @@ -300,7 +300,7 @@ export function FloatingQAChat() { const [question, setQuestion] = useState(""); const [thinkingMode, setThinkingMode] = useState(() => { 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 [requestThinkingMode, setRequestThinkingMode] = useState("normal"); diff --git a/frontend/src/components/sstc/FeedbackSummaryCard.tsx b/frontend/src/components/sstc/FeedbackSummaryCard.tsx index 72da7921..271f906d 100644 --- a/frontend/src/components/sstc/FeedbackSummaryCard.tsx +++ b/frontend/src/components/sstc/FeedbackSummaryCard.tsx @@ -30,6 +30,7 @@ export function FeedbackSummaryCard({ projectId, compact = false, tasks = [] }: const aiTasks = tasks.filter((task) => hasAIBreakdown(task)); if (!aiTasks.length) { return { + hasLocalSignal: false, pending: 0, acceptedRate: 0, topReasons: [] as Array<{ label: string; count: number }>, @@ -56,6 +57,7 @@ export function FeedbackSummaryCard({ projectId, compact = false, tasks = [] }: } const reviewed = accepted + rejected; return { + hasLocalSignal: true, pending, acceptedRate: reviewed > 0 ? accepted / reviewed : 0, topReasons: Array.from(reasonCountMap.entries()) @@ -71,9 +73,10 @@ export function FeedbackSummaryCard({ projectId, compact = false, tasks = [] }: enabled: Number.isFinite(numericProjectId) && numericProjectId > 0, }); - const pendingCount = localSummary.pending > 0 ? localSummary.pending : summaryQuery.data?.backlog_count ?? 0; - const acceptedRate = localSummary.acceptedRate > 0 ? localSummary.acceptedRate : Number(summaryQuery.data?.accepted_rate || 0); - const topReasons = localSummary.topReasons.length + const hasLocalSummary = localSummary.hasLocalSignal; + const pendingCount = hasLocalSummary ? localSummary.pending : summaryQuery.data?.backlog_count ?? 0; + const acceptedRate = hasLocalSummary ? localSummary.acceptedRate : Number(summaryQuery.data?.accepted_rate || 0); + const topReasons = hasLocalSummary ? localSummary.topReasons : (summaryQuery.data?.top_reasons || []).map((item) => ({ label: item.label, count: item.count })); diff --git a/frontend/src/pages/ai-steward/AIStewardPage.tsx b/frontend/src/pages/ai-steward/AIStewardPage.tsx index b9238620..8960c5ac 100644 --- a/frontend/src/pages/ai-steward/AIStewardPage.tsx +++ b/frontend/src/pages/ai-steward/AIStewardPage.tsx @@ -107,6 +107,7 @@ export default function AIStewardPage() { const [conflicts, setConflicts] = useState([]); const [blockers, setBlockers] = useState([]); const [briefing, setBriefing] = useState(null); + const [briefingError, setBriefingError] = useState(null); const [featureLoading, setFeatureLoading] = useState(null); const [engineData, setEngineData] = useState({ active_engine_id: null, @@ -166,6 +167,7 @@ export default function AIStewardPage() { const loadBriefing = async () => { setFeatureLoading("briefing"); + setBriefingError(null); try { const token = getAuthToken(); const response = await fetch(`${apiBaseURL}/ai-steward/daily-briefing`, { @@ -174,9 +176,16 @@ export default function AIStewardPage() { if (response.ok) { const data = await response.json(); 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) { console.error("Failed to load briefing:", err); + setBriefing(null); + setBriefingError(err instanceof Error ? err.message : "获取简报失败"); } finally { setFeatureLoading(null); } @@ -621,6 +630,7 @@ export default function AIStewardPage() { void loadBriefing()} /> diff --git a/frontend/src/pages/ai-steward/components/feedback-center/FeedbackList.tsx b/frontend/src/pages/ai-steward/components/feedback-center/FeedbackList.tsx index d153668e..c8d2aade 100644 --- a/frontend/src/pages/ai-steward/components/feedback-center/FeedbackList.tsx +++ b/frontend/src/pages/ai-steward/components/feedback-center/FeedbackList.tsx @@ -1,4 +1,4 @@ -import type { FeedbackCenterRecord } from "@/api/feedbackCenter"; +import type { FeedbackCenterRecord, FeedbackCenterSummaryReason } from "@/api/feedbackCenter"; import { Badge } from "@/components/ui/badge"; import { Input } from "@/components/ui/input"; @@ -9,8 +9,11 @@ interface FeedbackListProps { onSearchChange: (value: string) => void; sourceType: string; reviewStatus: string; + reasonCode: string; onSourceTypeChange: (value: string) => void; onReviewStatusChange: (value: string) => void; + onReasonCodeChange: (value: string) => void; + reasonOptions: FeedbackCenterSummaryReason[]; onSelect: (item: FeedbackCenterRecord) => void; total: number; isLoading: boolean; @@ -37,8 +40,11 @@ export function FeedbackList({ onSearchChange, sourceType, reviewStatus, + reasonCode, onSourceTypeChange, onReviewStatusChange, + onReasonCodeChange, + reasonOptions, onSelect, total, isLoading, @@ -57,7 +63,7 @@ export function FeedbackList({ placeholder="搜索任务标题、问题、信号..." className="border-slate-200" /> -
+
+

共 {total} 条记录

diff --git a/frontend/src/pages/ai-steward/components/feedback-center/TrendPanel.tsx b/frontend/src/pages/ai-steward/components/feedback-center/TrendPanel.tsx index 9d9d8efe..e0c62e51 100644 --- a/frontend/src/pages/ai-steward/components/feedback-center/TrendPanel.tsx +++ b/frontend/src/pages/ai-steward/components/feedback-center/TrendPanel.tsx @@ -1,4 +1,6 @@ import type { + FeedbackCenterDrilldownPreset, + FeedbackCenterInsight, FeedbackCenterSourceGroup, FeedbackCenterSummary, FeedbackCenterTrendWindowDays, @@ -14,6 +16,7 @@ interface TrendPanelProps { sourceGroup: FeedbackCenterSourceGroup; onWindowDaysChange: (value: FeedbackCenterTrendWindowDays) => void; onSourceGroupChange: (value: FeedbackCenterSourceGroup) => void; + onDrilldown: (preset: FeedbackCenterDrilldownPreset) => void; } const STATUS_COLORS: Record = { @@ -63,6 +66,103 @@ function scopeLabel(value: FeedbackCenterSourceGroup): string { 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({ title, value, @@ -128,8 +228,8 @@ function ToggleGroup({ size="sm" variant={active ? "default" : "outline"} className={active ? "bg-slate-900 text-white hover:bg-slate-800" : "border-slate-200 bg-white text-slate-700 hover:bg-slate-50"} - onClick={() => onChange(option)} - > + onClick={() => onChange(option)} + > {renderLabel(option)} ); @@ -146,8 +246,13 @@ export function FeedbackTrendPanel({ sourceGroup, onWindowDaysChange, onSourceGroupChange, + onDrilldown, }: TrendPanelProps) { const trend = summary?.trend; + const insights = summary?.insights || []; + const primaryInsight = insights[0]; + const decisionHeadline = buildDecisionHeadline(summary); + const quickActions = buildQuickActions(summary); if (isLoading && !trend) { return ( @@ -265,6 +370,96 @@ export function FeedbackTrendPanel({ /> +
+
+
+

Decision Focus

+

{decisionHeadline}

+

+ 这里先告诉你近 {windowDays} 天什么值得处理,再给你一键跳转到对应样本,最后再看趋势图做复盘。 +

+
+ {primaryInsight ? ( + + {severityLabel(primaryInsight.severity)} + + ) : null} +
+ +
+ {insights.slice(0, 3).map((insight) => ( +
+
+
+

+ {severityLabel(insight.severity)} +

+

{insight.title}

+
+ + {insight.action_label} + +
+

{insight.description}

+
+ + 跳转:{insight.target_section === "task" ? "任务" : insight.target_section === "qa" ? "QA" : insight.target_section === "signal" ? "信号" : "总览"} + + {insight.target_source_type && ( + {insight.target_source_type} + )} + {insight.target_reason_code && ( + 原因 {insight.target_reason_code} + )} +
+ +
+ ))} +
+ +
+
+
+

Drilldown

+

一键钻取到样本列表

+
+

+ 当前 {windowDays} 天 · {scopeLabel(sourceGroup)} · {trend.window_days} 天窗口 +

+
+
+ {quickActions.map((action) => ( + + ))} +
+
+
+
diff --git a/frontend/src/pages/ai-steward/sections/BriefingTab.tsx b/frontend/src/pages/ai-steward/sections/BriefingTab.tsx index 83396d63..f12e218b 100644 --- a/frontend/src/pages/ai-steward/sections/BriefingTab.tsx +++ b/frontend/src/pages/ai-steward/sections/BriefingTab.tsx @@ -20,11 +20,23 @@ import type { DailyBriefing } from "../types"; interface BriefingTabProps { featureLoading: string | null; briefing: DailyBriefing | null; + briefingError?: string | null; username?: string; 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 (
@@ -55,6 +67,15 @@ export default function BriefingTab({ featureLoading, briefing, username, onRefr
+ ) : briefingError ? ( + + + +

简报加载失败

+

{briefingError}

+

你可以稍后重试,或者先去看问答记录 / 反馈中心。

+
+
) : !briefing ? ( @@ -77,6 +98,18 @@ export default function BriefingTab({ featureLoading, briefing, username, onRefr + {!hasContent && ( + + + +

最近 24 小时没有新的相关更新

+

+ 这张简报只展示近期变化;如果项目这一天没有新增任务、风险、冲突或信号,页面就会显得很空。 +

+
+
+ )} +
diff --git a/frontend/src/pages/ai-steward/sections/FeedbackCenterTab.tsx b/frontend/src/pages/ai-steward/sections/FeedbackCenterTab.tsx index 969cf770..ae893b62 100644 --- a/frontend/src/pages/ai-steward/sections/FeedbackCenterTab.tsx +++ b/frontend/src/pages/ai-steward/sections/FeedbackCenterTab.tsx @@ -2,6 +2,7 @@ import { useEffect, useMemo, useState } from "react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { feedbackCenterApi, + type FeedbackCenterDrilldownPreset, type FeedbackCenterSourceGroup, type FeedbackCenterRecord, type FeedbackCenterSection, @@ -43,7 +44,6 @@ function matchesSection(record: FeedbackCenterRecord, section: FeedbackCenterSec } function sourceTypeForSection(section: FeedbackCenterSection): string { - if (section === "task") return "task_override"; if (section === "qa") return "qa_feedback"; if (section === "signal") return "signal_feedback"; return ""; @@ -64,9 +64,10 @@ export default function FeedbackCenterTab({ const [search, setSearch] = useState(""); const [sourceType, setSourceType] = useState(""); const [reviewStatus, setReviewStatus] = useState(""); + const [reasonCode, setReasonCode] = useState(""); const isAnalyticsSection = section === "metrics" || section === "trend"; - const effectiveSourceType = sourceType || sourceTypeForSection(section); + const effectiveSourceType = section === "task" ? sourceType : sourceTypeForSection(section); const summaryQuery = useQuery({ queryKey: ["feedback-center", "summary", projectId ?? "all"], @@ -85,12 +86,13 @@ export default function FeedbackCenterTab({ }); const listQuery = useQuery({ - queryKey: ["feedback-center", "items", projectId ?? "all", effectiveSourceType, reviewStatus, search], + queryKey: ["feedback-center", "items", projectId ?? "all", effectiveSourceType, reviewStatus, reasonCode, search], queryFn: () => feedbackCenterApi.listItems({ project_id: projectId ?? undefined, source_type: effectiveSourceType || undefined, review_status: reviewStatus || undefined, + reason_code: reasonCode || undefined, search: search.trim() || undefined, limit: 40, offset: 0, @@ -147,12 +149,26 @@ export default function FeedbackCenterTab({ useEffect(() => { setSelectedId(null); - }, [effectiveSourceType, reviewStatus, search, projectId, section]); + }, [effectiveSourceType, reviewStatus, reasonCode, search, projectId, section]); useEffect(() => { - setSourceType(""); + if (section !== "task") { + setSourceType(""); + } }, [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 detail = detailQuery.data; @@ -228,6 +244,7 @@ export default function FeedbackCenterTab({ sourceGroup={trendSourceGroup} onWindowDaysChange={onTrendWindowDaysChange} onSourceGroupChange={onTrendSourceGroupChange} + onDrilldown={handleTrendDrilldown} /> @@ -279,12 +296,15 @@ export default function FeedbackCenterTab({ onSearchChange={setSearch} sourceType={sourceType} reviewStatus={reviewStatus} + reasonCode={reasonCode} onSourceTypeChange={setSourceType} onReviewStatusChange={setReviewStatus} - onSelect={(item) => setSelectedId(item.id)} - total={displayItems.length} - isLoading={listQuery.isLoading} - /> + onReasonCodeChange={setReasonCode} + reasonOptions={summary?.top_reasons || []} + onSelect={(item) => setSelectedId(item.id)} + total={listQuery.data?.total ?? displayItems.length} + isLoading={listQuery.isLoading} + /> @@ -68,12 +79,12 @@ export default function SetupGuideCard() {

- 飞书已统一切换到长连接模式,这一步只需要确认权限即可: + 飞书消息事件已统一切换到长连接模式,这一步只需要确认权限即可:

    长连接 - 无需再配置回调地址、Encrypt Key、Verification Token + 消息事件无需再配置事件订阅回调
@@ -84,7 +95,18 @@ export default function SetupGuideCard() {
-

当前为「长连接」模式,无需配置任何回调地址。

+

+ 当前为「长连接」模式,但卡片按钮仍需要配置回调地址: +

+
+ 卡片回调 + + 在飞书开放平台的卡片动作回调里填写: + + {cardActionCallbackURL} + + +

添加权限:

@@ -117,7 +139,8 @@ export default function SetupGuideCard() {

- 💡 提示:配置完成后,后端会自动建立长连接,在飞书群中@机器人即可测试 + 💡 提示:配置完成后,后端会自动建立长连接,在飞书群中@机器人即可测试; + 卡片按钮回调配置完成后,按钮点击也会正常进入处理链路。