From 2e3de00c04ee585d4cd10d03ed10be2cdd20bd53 Mon Sep 17 00:00:00 2001 From: Yuuuu <1569752928@qq.com> Date: Mon, 23 Mar 2026 17:50:07 +0800 Subject: [PATCH] Unify AI PMO ingress flow and add draft confirmation execution ledger --- CLAUDE.md | 72 +- ...add_work_item_draft_confirmation_fields.py | 92 ++ .../api/ai_pmo_api/helpers_inbox_filters.py | 7 +- .../api/ai_pmo_api/helpers_inbox_payload.py | 13 +- backend/app/api/ai_pmo_api/routes_core.py | 5 +- backend/app/api/ai_pmo_api/routes_resume.py | 38 +- backend/app/api/ai_pmo_api/schemas.py | 6 + .../api/ai_steward_api/routes_rag_sources.py | 24 - backend/app/api/ai_steward_api/shared.py | 2 - backend/app/api/rag_api/routes_query.py | 33 +- backend/app/api/rag_ask_contract.py | 90 ++ backend/app/config/ai_prompts.py | 1 + .../app/config/ai_prompts_catalog/__init__.py | 3 +- .../app/config/ai_prompts_catalog/part_02.py | 70 +- .../app/core/startup/draft_expiry_worker.py | 114 +++ backend/app/main.py | 8 + backend/app/models/work_item.py | 8 +- backend/app/schemas/rag.py | 51 +- .../mixin_answer_entry.py | 204 +---- .../mixin_answer_mainline.py | 248 ++++++ .../services/adaptive_rag_service/service.py | 2 + .../ai_orchestration_run/ingress_worker.py | 6 + .../services/ai_pmo/ai_pmo_decision/policy.py | 3 +- .../services/ai_pmo/ai_pmo_decision/shared.py | 2 + .../ai_pmo/ai_pmo_decision/simulation.py | 2 + .../ai_pmo/ai_pmo_decision/strategic.py | 3 + .../ai_pmo/ai_pmo_decision/tactical.py | 3 + .../services/ai_pmo/ai_pmo_intent/__init__.py | 4 + .../ai_pmo/ai_pmo_intent/intent_resolver.py | 68 +- .../ai_pmo/ai_pmo_intent/route_resolver.py | 28 +- .../services/ai_pmo/ai_pmo_intent/shared.py | 23 +- .../ai_pmo_kernel/ai_pmo_reply_renderer.py | 23 + .../ai_pmo_typed_executor_common.py | 2 + .../ai_pmo_kernel/ai_pmo_workflow_runtime.py | 27 + ...l_mixins_followup_project_signal_resume.py | 1 + .../ai_pmo_kernel_mixins_followup_routing.py | 47 +- ...pmo_kernel_mixins_followup_verification.py | 2 + .../ai_pmo_kernel_mixins_ingress_render.py | 19 +- .../ai_pmo_kernel_mixins_ingress_storage.py | 249 +++++- .../intent/ai_pmo_kernel_mixins_intent_llm.py | 1 + .../ai_pmo_kernel_mixins_intent_resolve.py | 5 + .../ai_pmo_kernel_mixins_intent_rules.py | 101 ++- .../ai_pmo_kernel_common_core/constants.py | 2 + .../ai_pmo_kernel_common_core/imports.py | 3 + .../ai_pmo_kernel_ingress_flow/mixin_auto.py | 198 ++++- .../ai_pmo_kernel_ingress_flow/mixin_entry.py | 183 ++++ .../mixin_prepare_manual.py | 37 +- .../mixin_execute_query_project.py | 207 ++++- .../ai_pmo_typed_executor_execute/service.py | 10 +- .../services/ai_pmo/draft_confirm_handler.py | 816 ++++++++++++++++++ .../mixin_business_analysis.py | 15 +- .../ai_service_core/mixin_runtime_core.py | 14 +- .../mixin_runtime_prompt_retry.py | 55 +- .../app/services/ai_service_core/shared.py | 3 + .../mixin_profile_basics.py | 3 +- .../mixin_profile_route_parser.py | 3 +- .../mixin_card_action.py | 346 ++++++++ .../mixin_config.py | 3 + .../mixin_receive.py | 248 ++---- .../mixin_receive_capture_flow.py | 160 ++++ .../mixin_receive_prepare.py | 123 +++ .../mixin_receive_routing.py | 15 +- .../mixin_reply_outbox.py | 82 +- .../mixin_transport.py | 34 +- .../feishu_integration_service/service.py | 6 + .../mixin_runtime.py | 10 +- .../mixin_prompt_profile_runtime.py | 21 +- .../signal/draft_generator_service.py | 352 ++++++++ .../mixin_capture_entry.py | 203 ++++- .../app/services/skill/skill_score_service.py | 3 +- backend/app/services/workload/ai_optimize.py | 3 +- .../test_adaptive_rag_mainline_contract.py | 97 +++ .../test_ai_pmo_execution_ledger_contract.py | 147 ++++ backend/tests/test_ai_pmo_ingress_mainline.py | 251 ++++++ .../tests/test_ai_pmo_qa_http_mainline_e2e.py | 168 ++++ .../test_ai_pmo_tactical_brain_contract.py | 73 ++ .../tests/test_ai_pmo_task_intent_contract.py | 119 +++ backend/tests/test_ai_service_core_runtime.py | 69 ++ .../test_context_cache_profile_catalog.py | 23 + backend/tests/test_draft_confirm_handler.py | 90 ++ .../tests/test_feishu_card_action_contract.py | 156 ++++ ...t_feishu_receive_orchestration_contract.py | 118 +++ backend/tests/test_rag_route_contract.py | 204 +++++ .../draft_ai_call_logs_latest3.txt | 28 + .../draft_fix_verify_result.json | 130 +++ .../draft_optimize_db_check.txt | 10 + .../draft_optimize_db_check_v2.dataurl | 1 + .../draft_optimize_db_check_v2.png | Bin 0 -> 49729 bytes .../draft_optimize_db_check_v2.txt | 11 + .../draft_optimize_feishu_reply.txt | 0 .../draft_optimize_feishu_reply_v2.dataurl | 1 + .../draft_optimize_feishu_reply_v2.png | Bin 0 -> 63169 bytes .../draft_optimize_feishu_reply_v2.txt | 20 + .../draft_optimize_verify_result.json | 81 ++ .../draft_optimize_verify_result_v2.json | 136 +++ .../tmp_acceptance/scene1_db_check.dataurl | 1 + backend/tmp_acceptance/scene1_db_check.html | 7 + backend/tmp_acceptance/scene1_db_check.png | Bin 0 -> 26924 bytes backend/tmp_acceptance/scene1_db_check.txt | 1 + .../scene1_direct_commit_result.json | 87 ++ .../scene1_direct_commit_summary.txt | 21 + .../scene1_failure_snapshot.json | 55 ++ .../scene1_feishu_reply.dataurl | 1 + .../tmp_acceptance/scene1_feishu_reply.html | 4 + .../tmp_acceptance/scene1_feishu_reply.png | Bin 0 -> 19636 bytes .../tmp_acceptance/scene1_feishu_reply.txt | 3 + .../scene1_full_accept_result.json | 201 +++++ .../tmp_acceptance/scene1_full_db_check.txt | 14 + .../scene1_full_step1_feishu.dataurl | 1 + .../scene1_full_step1_feishu.png | Bin 0 -> 49729 bytes .../scene1_full_step1_feishu.txt | 20 + .../scene1_full_step1_feishu_v2.png | Bin 0 -> 63574 bytes .../scene1_full_step2_feishu.dataurl | 1 + .../scene1_full_step2_feishu.txt | 1 + .../scene1_full_step2_feishu_v2.png | Bin 0 -> 17865 bytes .../scene1_manual_process_text_result.json | 146 ++++ .../scene1_manual_process_text_summary.txt | 146 ++++ backend/tmp_acceptance/scene1_result.json | 46 + frontend/src/api/aiPmo.ts | 24 + .../components/ai-steward/FloatingQAChat.tsx | 109 ++- frontend/src/pages/inbox/InboxPage.tsx | 88 +- frontend/src/types/inbox.ts | 2 + scripts/ai_steward_pressure_coach.py | 2 +- tmp_short_confirm_redesign_snapshot.html | 100 +++ tmp_short_confirm_redesign_snapshot.json | 87 ++ tmp_short_confirm_redesign_summary.html | 36 + 126 files changed, 7388 insertions(+), 617 deletions(-) create mode 100644 backend/alembic/versions/20260323_add_work_item_draft_confirmation_fields.py create mode 100644 backend/app/api/rag_ask_contract.py create mode 100644 backend/app/core/startup/draft_expiry_worker.py create mode 100644 backend/app/services/adaptive_rag_service/mixin_answer_mainline.py create mode 100644 backend/app/services/ai_pmo/draft_confirm_handler.py create mode 100644 backend/app/services/integrations/feishu_integration_service/mixin_card_action.py create mode 100644 backend/app/services/integrations/feishu_integration_service/mixin_receive_capture_flow.py create mode 100644 backend/app/services/integrations/feishu_integration_service/mixin_receive_prepare.py create mode 100644 backend/app/services/signal/draft_generator_service.py create mode 100644 backend/tests/test_adaptive_rag_mainline_contract.py create mode 100644 backend/tests/test_ai_pmo_execution_ledger_contract.py create mode 100644 backend/tests/test_ai_pmo_ingress_mainline.py create mode 100644 backend/tests/test_ai_pmo_qa_http_mainline_e2e.py create mode 100644 backend/tests/test_ai_pmo_tactical_brain_contract.py create mode 100644 backend/tests/test_ai_pmo_task_intent_contract.py create mode 100644 backend/tests/test_ai_service_core_runtime.py create mode 100644 backend/tests/test_context_cache_profile_catalog.py create mode 100644 backend/tests/test_draft_confirm_handler.py create mode 100644 backend/tests/test_feishu_card_action_contract.py create mode 100644 backend/tests/test_feishu_receive_orchestration_contract.py create mode 100644 backend/tests/test_rag_route_contract.py create mode 100644 backend/tmp_acceptance/draft_ai_call_logs_latest3.txt create mode 100644 backend/tmp_acceptance/draft_fix_verify_result.json create mode 100644 backend/tmp_acceptance/draft_optimize_db_check.txt create mode 100644 backend/tmp_acceptance/draft_optimize_db_check_v2.dataurl create mode 100644 backend/tmp_acceptance/draft_optimize_db_check_v2.png create mode 100644 backend/tmp_acceptance/draft_optimize_db_check_v2.txt create mode 100644 backend/tmp_acceptance/draft_optimize_feishu_reply.txt create mode 100644 backend/tmp_acceptance/draft_optimize_feishu_reply_v2.dataurl create mode 100644 backend/tmp_acceptance/draft_optimize_feishu_reply_v2.png create mode 100644 backend/tmp_acceptance/draft_optimize_feishu_reply_v2.txt create mode 100644 backend/tmp_acceptance/draft_optimize_verify_result.json create mode 100644 backend/tmp_acceptance/draft_optimize_verify_result_v2.json create mode 100644 backend/tmp_acceptance/scene1_db_check.dataurl create mode 100644 backend/tmp_acceptance/scene1_db_check.html create mode 100644 backend/tmp_acceptance/scene1_db_check.png create mode 100644 backend/tmp_acceptance/scene1_db_check.txt create mode 100644 backend/tmp_acceptance/scene1_direct_commit_result.json create mode 100644 backend/tmp_acceptance/scene1_direct_commit_summary.txt create mode 100644 backend/tmp_acceptance/scene1_failure_snapshot.json create mode 100644 backend/tmp_acceptance/scene1_feishu_reply.dataurl create mode 100644 backend/tmp_acceptance/scene1_feishu_reply.html create mode 100644 backend/tmp_acceptance/scene1_feishu_reply.png create mode 100644 backend/tmp_acceptance/scene1_feishu_reply.txt create mode 100644 backend/tmp_acceptance/scene1_full_accept_result.json create mode 100644 backend/tmp_acceptance/scene1_full_db_check.txt create mode 100644 backend/tmp_acceptance/scene1_full_step1_feishu.dataurl create mode 100644 backend/tmp_acceptance/scene1_full_step1_feishu.png create mode 100644 backend/tmp_acceptance/scene1_full_step1_feishu.txt create mode 100644 backend/tmp_acceptance/scene1_full_step1_feishu_v2.png create mode 100644 backend/tmp_acceptance/scene1_full_step2_feishu.dataurl create mode 100644 backend/tmp_acceptance/scene1_full_step2_feishu.txt create mode 100644 backend/tmp_acceptance/scene1_full_step2_feishu_v2.png create mode 100644 backend/tmp_acceptance/scene1_manual_process_text_result.json create mode 100644 backend/tmp_acceptance/scene1_manual_process_text_summary.txt create mode 100644 backend/tmp_acceptance/scene1_result.json create mode 100644 tmp_short_confirm_redesign_snapshot.html create mode 100644 tmp_short_confirm_redesign_snapshot.json create mode 100644 tmp_short_confirm_redesign_summary.html diff --git a/CLAUDE.md b/CLAUDE.md index e225ed50..2ca5b438 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -37,7 +37,7 @@ backend/ │ ├── __init__.py # app 包入口(根目录轻量化后保留) │ ├── api/ # 路由层(含 endpoints) │ ├── core/ # 配置、日志、核心能力 -│ │ └── startup/ # 启动阶段拆分模块(db guard/outbox/ingress async/probe/warmup) +│ │ └── startup/ # 启动阶段拆分模块(db guard/outbox/ingress async/draft expiry/probe/warmup) │ ├── deps.py # 共享依赖导出(get_db/AsyncSessionLocal) │ ├── dependencies/ # 鉴权/租户等依赖注入 │ ├── middleware/ # 请求中间件(审计上下文注入等) @@ -55,14 +55,27 @@ backend/ │ ├── 20260311_add_ai_pmo_work_items_view.py # ai_pmo_work_items 兼容视图(映射 work_items) │ ├── 20260311_expand_user_workload_ratio_precision.py # user_workloads.workload_ratio 精度扩容(10,2) │ ├── 20260311_add_conversation_sessions_table.py # 飞书多轮会话上下文持久化表 -│ └── 20260316_add_ai_orchestration_ingress_async_fields.py # ai_orchestration_runs 入站异步重试字段扩展 +│ ├── 20260316_add_ai_orchestration_ingress_async_fields.py # ai_orchestration_runs 入站异步重试字段扩展 +│ └── 20260323_add_work_item_draft_confirmation_fields.py # work_items 草稿确认字段与索引 ├── scripts/ # 后端运维/数据修复脚本 │ ├── backfill_orphan_signal_links.py # 回填孤立 signal 的 project_signal_links │ ├── dev/ # 开发辅助脚本(check_users/seed_all) │ └── legacy/ # 历史脚本(仅保留兼容参考,不作为主流程) +├── tests/ # 后端回归测试 +│ ├── test_draft_confirm_handler.py +│ ├── test_ai_service_core_runtime.py +│ ├── test_rag_route_contract.py +│ ├── test_ai_pmo_task_intent_contract.py +│ ├── test_ai_pmo_ingress_mainline.py +│ ├── test_ai_pmo_qa_http_mainline_e2e.py +│ ├── test_context_cache_profile_catalog.py +│ ├── test_ai_pmo_tactical_brain_contract.py +│ ├── test_adaptive_rag_mainline_contract.py +│ ├── test_ai_pmo_execution_ledger_contract.py +│ ├── test_feishu_card_action_contract.py +│ └── test_feishu_receive_orchestration_contract.py ├── requirements.txt # Python 依赖 -├── Dockerfile -└── (tests 已移除) +└── Dockerfile ``` - 新增:`backend/app/api/llm_providers.py`(AI 管家 LLM provider preset 配置读取接口) - 新增:`backend/app/services/ai_pmo/ai_pmo_kernel/ai_agent_kernel.py`(双 Agent 共享决策内核:复杂度路由/执行门控/修正识别) @@ -77,6 +90,27 @@ backend/ - 新增:`backend/alembic/versions/20260312_add_signal_planner_fields.py`(signals 增加 planner 状态字段与索引) - 新增:`backend/app/services/signal/signal_planner/signal_strategic_analysis_service.py`(Signal Planner 写回收口) - 新增:`backend/app/services/integrations/feishu_outbox/service.py`(复用 reply_hash 的 outbox 入队收口) +- 新增:`backend/app/services/signal/draft_generator_service.py`(写操作草稿生成服务:草稿结构化产出 + 24h 过期) +- 新增:`backend/app/services/ai_pmo/draft_confirm_handler.py`(草稿确认处理器:确认/修改/取消 + 幂等保护) +- 新增:`backend/app/core/startup/draft_expiry_worker.py`(草稿超时取消后台扫描任务) +- 新增:`backend/tests/test_ai_service_core_runtime.py`(AIService 运行时回归:统一收敛 llm_service、缓存命中与流式兼容输出) +- 新增:`backend/tests/test_rag_route_contract.py`(RAG 双入口契约回归:`asked_via/action_mode/message_intent/mixed_signal_part` 统一透传) +- 新增:`backend/tests/test_ai_pmo_task_intent_contract.py`(AI PMO `task.create` 一等 typed intent 契约回归:意图识别、路由归一与执行器分派) +- 新增:`backend/tests/test_ai_pmo_ingress_mainline.py`(AI PMO 主链入口回归:`/ai-pmo/process-text` 手工问答入口、mixed sidecar 透传、`draft_pending_confirm` 优先分支) +- 新增:`backend/tests/test_ai_pmo_qa_http_mainline_e2e.py`(AI PMO/QA HTTP 主链集成回归:`/rag/ask` 只读问答、mixed 两段输出、`/ai-pmo/process-text` 草稿待确认) +- 新增:`backend/tests/test_context_cache_profile_catalog.py`(Context Cache 功能目录契约回归:AI Steward 统一指向 `/rag/ask`) +- 新增:`backend/tests/test_ai_pmo_tactical_brain_contract.py`(TacticalBrain 执行动作契约回归:`query.answer`/`work_item.resume`/`project_signal.*` 映射) +- 新增:`backend/app/services/adaptive_rag_service/mixin_answer_mainline.py`(AdaptiveRAG 基础问答主链编排:运行时准备 + 只读主路径执行) +- 新增:`backend/tests/test_adaptive_rag_mainline_contract.py`(AdaptiveRAG 入口分层契约回归:read 路径绕过写确认 gate、write 路径命中确认 gate、feishu write 仍走主链) +- 新增:`backend/tests/test_ai_pmo_execution_ledger_contract.py`(执行账本/动作参数来源契约回归:`action_contract` 归一、账本幂等 key、状态映射) +- 更新:`backend/app/services/ai_pmo/ai_pmo_kernel/mixins/ingress/ai_pmo_kernel_mixins_ingress_storage.py`(新增统一 `action_contract` 组装、`operation_execution_ledger` upsert、账本状态归一) +- 更新:`backend/app/services/ai_pmo/ai_pmo_kernel_ingress_flow/mixin_auto.py`(自动执行与草稿待确认路径接入统一执行账本,日志 `operation_code` 对齐 `action_contract`) +- 更新:`backend/app/services/ai_pmo/ai_pmo_kernel_ingress_flow/mixin_prepare_manual.py`(`waiting_manual` 路径接入统一执行账本与动作参数来源约束) +- 新增:`backend/app/services/integrations/feishu_integration_service/mixin_card_action.py`(飞书卡片动作解析与草稿确认回调处理) +- 新增:`backend/app/services/integrations/feishu_integration_service/mixin_receive_prepare.py`(飞书消息入口预处理:上下文抽取、空文本分支、附件标准化) +- 新增:`backend/app/services/integrations/feishu_integration_service/mixin_receive_capture_flow.py`(飞书消息入口执行流:capture 持久化、异常回执、结果收尾) +- 新增:`backend/tests/test_feishu_card_action_contract.py`(飞书卡片回调契约回归:动作解析与事件分发) +- 新增:`backend/tests/test_feishu_receive_orchestration_contract.py`(飞书入口编排契约回归:bot 过滤、空文本分支、capture/finalize 编排) - 新增:`backend/app/services/signal_planner_service.py`(Signal Planner 异步分析服务) - 新增:`backend/app/services/model/model_routing/service.py`(函数级模型路由配置服务) - 新增:`backend/app/services/task_brief_service.py`(增量任务包中心服务) @@ -107,6 +141,9 @@ backend/ - 新增:`backend/app/services/ai_pmo/ai_pmo_kernel_ingress_flow/`(Kernel 入站主流程编排) - 新增:`backend/app/services/ai_pmo/ai_pmo_kernel/mixins/ingress/ai_pmo_kernel_mixins_ingress_storage.py`(Kernel 入站事件/工作项写入) - 新增:`backend/app/services/ai_pmo/ai_pmo_kernel/mixins/ingress/ai_pmo_kernel_mixins_ingress_render.py`(Kernel 入站结果组装与输出辅助) +- 更新:`backend/app/services/ai_pmo/ai_pmo_kernel_ingress_flow/mixin_auto.py`(写操作改为“先起草后确认”,状态进入 `draft_pending_confirm`) +- 更新:`backend/app/services/ai_pmo/ai_pmo_kernel_ingress_flow/mixin_entry.py`(会话命中 `draft_pending_confirm` 时优先路由草稿确认处理) +- 更新:`backend/app/models/work_item.py`(新增 `draft_content/draft_expires_at/draft_version/confirm_message_id`) - 新增:`backend/app/services/ai_pmo/ai_pmo_kernel/compositions/world_model.py`(Kernel world-model 组合入口) - 新增:`backend/app/services/ai_pmo/ai_pmo_kernel_world_model_core/`(Kernel world-model 核心构建与缓存) - 新增:`backend/app/services/ai_pmo/ai_pmo_kernel/mixins/world_model/ai_pmo_kernel_mixins_world_model_session.py`(Kernel 会话继承与会话态维护) @@ -263,6 +300,9 @@ frontend/ - 新增:`frontend/src/pages/inbox/helpers.ts`(Inbox 页面 tab 过滤、排序、project-scope 判断 helper) - 新增:`frontend/src/pages/inbox/components/InboxFiltersPanel.tsx`(Inbox 页面顶部筛选/搜索/tab 面板拆分) - 新增:`frontend/src/pages/inbox/InboxPage.tsx`(Inbox 页面编排层,主文件聚焦状态与动作流转) +- 更新:`frontend/src/api/aiPmo.ts`(新增草稿决策 API:`decideDraftWorkItem`,并扩展工作项 draft 字段) +- 更新:`frontend/src/lib/inbox.ts`(支持 `draft_pending_confirm` 卡片映射与三动作按钮) +- 更新:`frontend/src/types/inbox.ts`(新增草稿动作类型 `revise_draft/cancel_draft`) - 更新:`frontend/src/pages/RecipesPage.tsx`(降级为轻量入口,转发到 `pages/recipes/RecipesPage`) - 新增:`frontend/src/pages/recipes/helpers.ts`(Recipes 页面默认表单与分类常量) - 新增:`frontend/src/pages/recipes/components/CreateRecipeDialog.tsx`、`AnalyzeRecipeDialog.tsx`(Recipes 页面手动创建/AI 识别弹窗拆分) @@ -392,6 +432,9 @@ uvicorn app.main:app --reload --host 0.0.0.0 --port 8000 - AI Steward 零配置创建任一步 LLM 失败、超时或 JSON 非法时必须直接报错,禁止回退默认 analysis/signal/charter/tasks 草稿 - Signal Source Onboarding 的 planner/worker 任一步失败必须显式报错或标记 failed,禁止 heuristic/fallback 兜底计划与“最小闭环”摘要 - AI Steward 不再暴露 OpenClaw 指挥规划入口,`/ai-steward/command/dispatch` 视为下线路径 +- AI PMO 写操作(`project.create/signal.create/signal_source.onboard`)默认先生成草稿并进入 `draft_pending_confirm`,未确认前禁止自动执行入库 +- 草稿确认必须校验 `draft_version`,并通过 `confirm_message_id` 防重放;草稿超时取消统一由 `draft_expiry_worker` + `feishu_reply_outbox` 处理 +- AI PMO 执行层必须统一产出 `execution_result.action_contract` 与 `action_param_source`,并写入 `operation_execution_ledger`;禁止在执行链路旁路写副作用且不入账 ## 9. 新增代码的参考模式 - 新增后端接口:参考 `backend/app/api/sstc.py` 的结构 @@ -410,7 +453,10 @@ uvicorn app.main:app --reload --host 0.0.0.0 --port 8000 - AI PMO 审批接口(`POST /ai-pmo/work-items/{work_item_id}/decision`):显式 `approve/reject`,`reject` 必须落 `ignored`(失效)并同步 `decision_records.final_outcome` - 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`,与飞书“确认/修改/取消”行为一致 +- 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` 组装兼容响应 - 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`,用于详情页展示“原始输入”与事件时间 - AI PMO 消息台列表接口补充:返回闭环语义字段 `source_conversation_closure/closure_summary/implementation_modules/source_closure_turns`,用于详情页展示“消息闭环 + 实现模块” - AI PMO 消息台历史回填补充:当 `ingest_events.payload` 闭环字段缺失或低质量时,优先回放 `ai_processing_logs`(同 `feishu_message_id`)并补充 `qa_records` 最近问答,避免 Redis 会话过期后仅剩单句摘要 @@ -457,9 +503,25 @@ uvicorn app.main:app --reload --host 0.0.0.0 --port 8000 - 兼容 SQL 查询入口:`ai_pmo_work_items` 为视图别名(底层映射 `work_items`),避免历史脚本直接查旧表名失败 - `signal_source.onboard` 已加入工作流门禁旁路:进入执行阶段后按闭环结果返回 `succeeded/pending_manual/failed_safe`,不再被前置仿真门禁直接拦截为 `waiting_manual` - 飞书入站已拆分“短确认同步快路径 + 非短确认异步队列”:短确认命中会话窗口时直接继承上轮意图并同步执行;其余消息先回“已收到,正在处理”后由 `ai_orchestration_runs` 异步 worker 执行与重试 +- 会话命中 `session.last_intent=draft_pending_confirm` 时,入站优先进入草稿确认处理器,不再走常规意图识别 +- 草稿确认成功后执行路径复用 `followup` 恢复链路,目标 work_item 在同事务内完成状态迁移与 `decision_records.final_outcome` 更新 +- 飞书回复 outbox 已支持 `text + interactive card` 双通道:执行场景命中 `draft_pending_confirm/pending_manual` 时优先入队按钮卡片;卡片消息仍经 outbox 异步派发(不走业务路径同步直发) +- 飞书入口已按职责拆分为“预处理/捕获执行/结果回执”三层:`mixin_receive.py` 仅保留编排,`mixin_receive_prepare.py` 负责传输解析与上下文抽取,`mixin_receive_capture_flow.py` 负责 capture 持久化与回执收尾 - `ai_orchestration_runs.project_id` 允许为空:用于承载尚未完成项目归属判定的入站异步任务;既有 `signal_source_onboarding` 运行仍按显式 `project_id` 写入 - `/ai-pmo/process-text` 问答链路在 AdaptiveRAG 内通过 `asked_via=pmflow_manual` 识别为 AI PMO 入口;mixed 场景通过 `message_intent=mixed` 透传触发专用 Prompt 与两段输出格式化 +- `/rag/ask` 已改为经 AI PMO kernel ingress 主链执行(与飞书长连接、`/ai-pmo/process-text` 同链路);query 执行阶段通过 `rag_request_context` 保留 `session_id/new_session/thinking_mode/action_mode/sync_requirement/message_intent/mixed_signal_part` +- AdaptiveRAG `answer_question` 入口已拆为“双阶段”:`_prepare_answer_runtime`(会话/上下文/动作模式解析)+ `_execute_base_answer_mainline`(基础问答主路径);写确认 `ack` gate 仅在 `sync_requirement=true && action_mode in write/read_write && 非 feishu` 时触发 +- AI PMO ingress 执行路径(`waiting_manual/draft_pending_confirm/auto execute`)已统一写入 `operation_execution_ledger`,`operation_code` 与 `execution_result.action_contract` 一致;账本状态统一映射为 `applied/pending_manual/pending_confirmation/failed` - `PROMPT_DECOMPOSE_TASKS` 技能标签来源已改为运行时动态注入:优先读取 `skill_tags`(按 `sort_order/name`),为空时才回退默认技能集合 ## 12. 技术债务清单 -- [ ] `mixin_auto.py` `_safe_int` NameError:之前被 `simulation_capacity_limit` 门禁遮住未暴露,需单独任务修复 +- [x] `mixin_auto.py` `_safe_int` NameError:已通过显式导入 `_safe_int` + 回归测试锁定,避免后续共享导出调整导致运行时未定义 +- [x] AI 入口主链已统一:飞书长连接(`capture_from_feishu`)、`/ai-pmo/process-text` 与 `/rag/ask` 已统一收敛到 `AIPMOKernelService.process_ingress` 主链;`/rag/ask` 通过 `rag_ask_contract.build_kernel_ingress_kwargs` 透传 RAG 上下文(`session_id/new_session/thinking_mode/action_mode/...`),query 执行后由 `build_rag_response_payload` 保持 HTTP 响应兼容,回归由 `test_rag_route_contract.py`、`test_ai_pmo_qa_http_mainline_e2e.py`、`test_ai_pmo_ingress_mainline.py` 锁定 +- [x] 飞书入口层耦合已收敛:飞书真实入口已拆为三段职责链(预处理 `mixin_receive_prepare.py` / capture 执行 `mixin_receive_capture_flow.py` / 编排 `mixin_receive.py`);`mixin_receive.py` 不再混合传输解析、附件标准化、capture 异常兜底与收尾回执逻辑,分层边界通过 `test_feishu_receive_orchestration_contract.py` 锁定 +- [x] `task.create` 已升级为一等 typed intent:`IntentResolver/RouteResolver` 新增 `task.create` 常量与 `task.create` 路由族;TypedExecutor 已接入任务落库执行(含 `project_id`/任务标题槽位守卫、对齐 Charter 校验、`persist_task` 写入);`WorkflowRegistry`、`ReplyRenderer`、`followup` 校验与草稿确认闭环已纳入 `task.create`;并补 `test_ai_pmo_task_intent_contract.py` 回归锁定 +- [x] AdaptiveRAG 职责已完成首轮收敛:`answer_question` 入口已分层为 `prepare runtime` 与 `base mainline` 两段,写确认逻辑通过 `_should_trigger_write_ack_gate` 独立门控;基础问答主路径不再与写确认判断混写在同一函数体。分层契约由 `test_adaptive_rag_mainline_contract.py` 锁定(read 绕过 ack、write 命中 ack、feishu write 维持主链) +- [x] 执行层已形成统一执行账本与参数来源约束:ingress 执行路径统一通过 `KernelIngressStorageMixin` 生成 `execution_result.action_contract`(固定 `param_source=decision.execution_plan.actions+workflow_route+execution_result`)并写入 `operation_execution_ledger`(`applied/pending_manual/pending_confirmation/failed`);`mixin_auto.py`、`mixin_prepare_manual.py` 的 `operation_code` 日志来源已对齐 `action_contract`,回归由 `test_ai_pmo_execution_ledger_contract.py` 锁定 +- [x] QA/RAG 路由契约双轨已收敛:`/ai-steward/rag/ask` 兼容入口已移除,问答统一收敛到 `POST /rag/ask`;参数构造与 tenant 解析收口在 `backend/app/api/rag_ask_contract.py`,前端 `FloatingQAChat` 与 Context Cache API 目录均已对齐,`backend/tests/test_rag_route_contract.py` 与 `backend/tests/test_context_cache_profile_catalog.py` 已锁定回归 +- [x] 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` 回归契约 diff --git a/backend/alembic/versions/20260323_add_work_item_draft_confirmation_fields.py b/backend/alembic/versions/20260323_add_work_item_draft_confirmation_fields.py new file mode 100644 index 00000000..36ecb9b6 --- /dev/null +++ b/backend/alembic/versions/20260323_add_work_item_draft_confirmation_fields.py @@ -0,0 +1,92 @@ +"""add work item draft confirmation fields + +Revision ID: add_work_item_draft_confirmation_fields +Revises: add_ai_orchestration_ingress_async_fields +Create Date: 2026-03-23 +""" + +from __future__ import annotations + +from alembic import op +import sqlalchemy as sa + + +revision = "add_work_item_draft_confirmation_fields" +down_revision = "add_ai_orchestration_ingress_async_fields" +branch_labels = None +depends_on = None + + +def _table_exists(bind, table_name: str) -> bool: + return sa.inspect(bind).has_table(table_name) + + +def _column_exists(bind, table_name: str, column_name: str) -> bool: + inspector = sa.inspect(bind) + if not inspector.has_table(table_name): + return False + return any(str(item.get("name") or "") == column_name for item in inspector.get_columns(table_name)) + + +def _index_exists(bind, table_name: str, index_name: str) -> bool: + inspector = sa.inspect(bind) + if not inspector.has_table(table_name): + return False + return any(str(item.get("name") or "") == index_name for item in inspector.get_indexes(table_name)) + + +def upgrade() -> None: + bind = op.get_bind() + table_name = "work_items" + if not _table_exists(bind, table_name): + return + + with op.batch_alter_table(table_name) as batch_op: + if not _column_exists(bind, table_name, "draft_content"): + batch_op.add_column(sa.Column("draft_content", sa.JSON(), nullable=True)) + if not _column_exists(bind, table_name, "draft_expires_at"): + batch_op.add_column(sa.Column("draft_expires_at", sa.TIMESTAMP(), nullable=True)) + if not _column_exists(bind, table_name, "draft_version"): + batch_op.add_column(sa.Column("draft_version", sa.Integer(), nullable=False, server_default="0")) + if not _column_exists(bind, table_name, "confirm_message_id"): + batch_op.add_column(sa.Column("confirm_message_id", sa.String(length=128), nullable=True)) + + if not _index_exists(bind, table_name, "ix_work_items_draft_expires"): + op.create_index( + "ix_work_items_draft_expires", + table_name, + ["draft_expires_at"], + postgresql_where=sa.text("state = 'draft_pending_confirm'"), + ) + + if not _index_exists(bind, table_name, "uq_work_items_tenant_confirm_message"): + op.create_index( + "uq_work_items_tenant_confirm_message", + table_name, + ["tenant_id", "confirm_message_id"], + unique=True, + postgresql_where=sa.text("confirm_message_id IS NOT NULL"), + ) + + +def downgrade() -> None: + bind = op.get_bind() + table_name = "work_items" + if not _table_exists(bind, table_name): + return + + if _index_exists(bind, table_name, "uq_work_items_tenant_confirm_message"): + op.drop_index("uq_work_items_tenant_confirm_message", table_name=table_name) + + if _index_exists(bind, table_name, "ix_work_items_draft_expires"): + op.drop_index("ix_work_items_draft_expires", table_name=table_name) + + with op.batch_alter_table(table_name) as batch_op: + if _column_exists(bind, table_name, "confirm_message_id"): + batch_op.drop_column("confirm_message_id") + if _column_exists(bind, table_name, "draft_version"): + batch_op.drop_column("draft_version") + if _column_exists(bind, table_name, "draft_expires_at"): + batch_op.drop_column("draft_expires_at") + if _column_exists(bind, table_name, "draft_content"): + batch_op.drop_column("draft_content") diff --git a/backend/app/api/ai_pmo_api/helpers_inbox_filters.py b/backend/app/api/ai_pmo_api/helpers_inbox_filters.py index 651e3c75..97135f74 100644 --- a/backend/app/api/ai_pmo_api/helpers_inbox_filters.py +++ b/backend/app/api/ai_pmo_api/helpers_inbox_filters.py @@ -7,6 +7,10 @@ def _normalize_noise_text(text: str) -> str: def _should_hide_work_item_from_inbox(*, row: WorkItem, ingest: IngestEvent) -> bool: intent_name = str(row.intent or "").strip().lower() + if intent_name == "followup.confirm": + execution_result = row.execution_result if isinstance(row.execution_result, dict) else {} + if _safe_int(execution_result.get("target_work_item_id")) is not None: + return True if intent_name not in FRONT_NOISE_INTENTS: return False payload = ingest.payload if isinstance(ingest.payload, dict) else {} @@ -107,10 +111,12 @@ def _stage_text(state: str) -> str: "decided": "已决策", "planned": "已规划", "executing": "执行中", + "draft_pending_confirm": "草稿待确认", "waiting_manual": "待人工确认", "succeeded": "执行完成", "failed": "执行失败", "ignored": "已忽略", + "cancelled": "已取消", } return mapping.get(str(state or "").strip(), str(state or "").strip() or "-") @@ -180,4 +186,3 @@ async def _build_latest_decision_projection( "approval_requirement": _normalize_approval_requirement(row.approval_requirement), } return projection - 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 8d2589e2..43071d78 100644 --- a/backend/app/api/ai_pmo_api/helpers_inbox_payload.py +++ b/backend/app/api/ai_pmo_api/helpers_inbox_payload.py @@ -78,6 +78,9 @@ def _serialize_work_item_summary( source_captured_at = str(payload.get("captured_at") or "").strip() or None source_sender = str(payload.get("sender") or "").strip() or None source_channel_ref = str(payload.get("channel_ref") or "").strip() or None + draft_content = row.draft_content if isinstance(row.draft_content, dict) else {} + draft_title = _compact_text(str(draft_content.get("title") or "").strip(), 64) + draft_summary = _compact_text(str(draft_content.get("summary") or "").strip(), 180) summary_seed = closure_summary or message_text or _next_step_text(row) or row.intent or "AI PMO 工作项" summary = _compact_text(summary_seed, 180) compact_message = re.sub(r"\s+", "", message_text).lower() if message_text else "" @@ -88,6 +91,11 @@ def _serialize_work_item_summary( else (message_text or closure_summary or f"AI PMO · {row.intent or 'work_item'}") ) title = _compact_text(title_seed, 64) + if str(row.state or "").strip() == "draft_pending_confirm": + if draft_title: + title = draft_title + if draft_summary: + summary = draft_summary priority, priority_rank = _priority_from_review(row.review_summary) project_name = None if project is not None: @@ -134,7 +142,10 @@ def _serialize_work_item_summary( "target_url": f"/projects/{project_id}/board" if project_id is not None else None, "review_summary": row.review_summary, "execution_result": row.execution_result, + "draft_content": draft_content or None, + "draft_expires_at": row.draft_expires_at.isoformat() if row.draft_expires_at else None, + "draft_version": int(row.draft_version or 0), + "confirm_message_id": str(row.confirm_message_id or "").strip() or None, "created_at": row.created_at.isoformat() if row.created_at else None, "updated_at": row.updated_at.isoformat() if row.updated_at else None, } - diff --git a/backend/app/api/ai_pmo_api/routes_core.py b/backend/app/api/ai_pmo_api/routes_core.py index ba364d3d..24a80e5a 100644 --- a/backend/app/api/ai_pmo_api/routes_core.py +++ b/backend/app/api/ai_pmo_api/routes_core.py @@ -138,6 +138,10 @@ async def get_work_item( "execution_result": row.execution_result, "review_summary": row.review_summary, "reply_template_key": row.reply_template_key, + "draft_content": row.draft_content if isinstance(row.draft_content, dict) else None, + "draft_expires_at": row.draft_expires_at.isoformat() if row.draft_expires_at else None, + "draft_version": int(row.draft_version or 0), + "confirm_message_id": str(row.confirm_message_id or "").strip() or None, "decision_records": [ { "id": int(item.id), @@ -178,4 +182,3 @@ async def get_work_item( ], } return success_response(payload) - diff --git a/backend/app/api/ai_pmo_api/routes_resume.py b/backend/app/api/ai_pmo_api/routes_resume.py index 1e45db6e..c17178dc 100644 --- a/backend/app/api/ai_pmo_api/routes_resume.py +++ b/backend/app/api/ai_pmo_api/routes_resume.py @@ -2,7 +2,8 @@ from __future__ import annotations from .shared import * from .helpers_resume import _queue_resume_work_item, _reject_work_item -from .schemas import PMOResumeWorkItemRequest, PMOWorkItemDecisionRequest +from .schemas import PMODraftDecisionRequest, PMOResumeWorkItemRequest, PMOWorkItemDecisionRequest +from ...services.ai_pmo.draft_confirm_handler import DraftConfirmHandler @router.post("/work-items/{work_item_id}/resume") async def resume_work_item( @@ -149,3 +150,38 @@ async def decide_work_item( result = _normalize_resume_result(raw=raw_result, target_work_item=target) return success_response(result) + +@router.post("/work-items/{work_item_id}/draft-decision") +async def decide_draft_work_item( + work_item_id: int, + request: PMODraftDecisionRequest, + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), + tenant_id: int = Depends(get_tenant_id), +): + target = ( + await db.execute( + select(WorkItem).where( + and_( + WorkItem.tenant_id == tenant_id, + WorkItem.id == int(work_item_id), + ) + ).limit(1) + ) + ).scalar_one_or_none() + if target is None: + raise HTTPException(status_code=404, detail="work_item_not_found") + + handler = DraftConfirmHandler(AIPMOKernelService(db, tenant_id=tenant_id)) + result = await handler.handle_api_decision( + work_item_id=int(work_item_id), + action=str(request.action or "").strip().lower(), + operator_sender=str(current_user.name or current_user.username or "PMFlow用户"), + source_message_id=request.source_message_id, + revision_note=request.revision_note, + draft_version=request.draft_version, + ) + await db.commit() + if not bool(result.get("handled")): + raise HTTPException(status_code=400, detail=str(result.get("followup_reply") or "draft_decision_failed")) + return success_response(result) diff --git a/backend/app/api/ai_pmo_api/schemas.py b/backend/app/api/ai_pmo_api/schemas.py index f271a8d0..708d3068 100644 --- a/backend/app/api/ai_pmo_api/schemas.py +++ b/backend/app/api/ai_pmo_api/schemas.py @@ -23,3 +23,9 @@ class PMOWorkItemDecisionRequest(BaseModel): source_message_id: Optional[str] = Field(default=None, max_length=128) reason: Optional[str] = Field(default=None, max_length=500) + +class PMODraftDecisionRequest(BaseModel): + action: Literal["confirm", "revise", "cancel"] = Field(..., description="草稿动作:confirm/revise/cancel") + revision_note: Optional[str] = Field(default=None, max_length=500) + source_message_id: Optional[str] = Field(default=None, max_length=128) + draft_version: Optional[int] = Field(default=None, ge=1) diff --git a/backend/app/api/ai_steward_api/routes_rag_sources.py b/backend/app/api/ai_steward_api/routes_rag_sources.py index f22a194e..f897b3a2 100644 --- a/backend/app/api/ai_steward_api/routes_rag_sources.py +++ b/backend/app/api/ai_steward_api/routes_rag_sources.py @@ -4,29 +4,6 @@ from .shared import * from .helpers import _ensure_project_signal_link from .schemas import SignalSourceListResponse, SignalSourceOnboardRequest, SignalSourceRepairRequest -@router.post("/rag/ask") -async def ask_ai_steward_rag( - request: RAGAskRequest, - current_user: User = Depends(get_current_user), - db: AsyncSession = Depends(get_db), - tenant_id: int = Depends(get_tenant_id), -): - effective_tenant_id = int(getattr(current_user, "tenant_id", tenant_id) or tenant_id) - service = AdaptiveRAGService(db, tenant_id=effective_tenant_id) - result = await service.answer_question( - question=request.question, - asked_by=int(current_user.id), - asked_via="pmflow", - project_id=request.project_id, - source_chat_id=request.source_chat_id, - session_id=request.session_id, - force_new_session=bool(request.new_session), - thinking_mode=request.thinking_mode, - action_mode=request.action_mode, - sync_requirement=bool(request.sync_requirement), - ) - return success_response(result) - @router.post("/signal-sources/onboard") @audited_write(caller="user") async def onboard_signal_source( @@ -148,4 +125,3 @@ async def repair_dirty_signal_sources( "items": rows, } ) - diff --git a/backend/app/api/ai_steward_api/shared.py b/backend/app/api/ai_steward_api/shared.py index 65df3d53..aeb4d426 100644 --- a/backend/app/api/ai_steward_api/shared.py +++ b/backend/app/api/ai_steward_api/shared.py @@ -15,8 +15,6 @@ from ...dependencies.auth import get_current_user from ...dependencies.permissions import require_manager_user from ...dependencies.tenant import get_tenant_id from ...models import ProjectSignalLink, Signal, User -from ...schemas.rag import RAGAskRequest -from ...services.adaptive_rag_service import AdaptiveRAGService from ...services.ai_orchestration_run import AIOrchestrationRunService from ...services.ai_steward_service import AIStewardService, get_ai_steward_service from ...services.blocker_detection_core import get_blocker_detection_service diff --git a/backend/app/api/rag_api/routes_query.py b/backend/app/api/rag_api/routes_query.py index ea3175bc..ce88fade 100644 --- a/backend/app/api/rag_api/routes_query.py +++ b/backend/app/api/rag_api/routes_query.py @@ -2,6 +2,8 @@ from __future__ import annotations from .shared import * from .helpers import _resolve_tenant_id +from .. import rag_ask_contract +from ...services.ai_pmo.ai_pmo_kernel import AIPMOKernelService @router.post("/ask") async def rag_ask( @@ -10,20 +12,24 @@ async def rag_ask( db: AsyncSession = Depends(get_db), tenant_id: int = Depends(get_tenant_id), ): - service = AdaptiveRAGService(db, tenant_id=_resolve_tenant_id(current_user, tenant_id)) - result = await service.answer_question( - question=request.question, - asked_by=int(current_user.id), - asked_via=request.asked_via, - project_id=request.project_id, - source_chat_id=request.source_chat_id, - session_id=request.session_id, - force_new_session=bool(request.new_session), - thinking_mode=request.thinking_mode, - action_mode=request.action_mode, - sync_requirement=bool(request.sync_requirement), + service = AIPMOKernelService( + db, + tenant_id=rag_ask_contract.resolve_effective_tenant_id(current_user, tenant_id), + ) + ingress_result = await service.process_ingress( + **rag_ask_contract.build_kernel_ingress_kwargs( + request=request, + current_user=current_user, + ) + ) + if not bool(ingress_result.get("handled")): + raise HTTPException( + status_code=400, + detail=str(ingress_result.get("reason") or "ai_pmo_kernel_not_handled"), + ) + return success_response( + rag_ask_contract.build_rag_response_payload(ingress_result=ingress_result), ) - return success_response(result) @router.get("/manifest") async def rag_manifest( @@ -133,4 +139,3 @@ async def rag_symbol_detail( except ValueError as exc: raise HTTPException(status_code=404, detail=str(exc)) from exc return success_response(data) - diff --git a/backend/app/api/rag_ask_contract.py b/backend/app/api/rag_ask_contract.py new file mode 100644 index 00000000..41f9f2ae --- /dev/null +++ b/backend/app/api/rag_ask_contract.py @@ -0,0 +1,90 @@ +from __future__ import annotations + +from typing import Any + +from ..models import User +from ..schemas.rag import RAGAskRequest + + +def resolve_effective_tenant_id(current_user: User, fallback_tenant_id: int) -> int: + try: + return int(getattr(current_user, "tenant_id") or fallback_tenant_id or 1) + except Exception: + return int(fallback_tenant_id or 1) + + +def build_answer_question_kwargs(request: RAGAskRequest, current_user: User) -> dict[str, Any]: + asked_by = int(getattr(current_user, "id")) + return { + "question": request.question, + "asked_by": asked_by, + "asked_via": request.asked_via, + "project_id": request.project_id, + "source_chat_id": request.source_chat_id, + "session_id": request.session_id, + "force_new_session": bool(request.new_session), + "thinking_mode": request.thinking_mode, + "action_mode": request.action_mode, + "sync_requirement": bool(request.sync_requirement), + "message_intent": request.message_intent, + "mixed_signal_part": request.mixed_signal_part, + } + + +def _safe_int(value: Any) -> int | None: + try: + if value is None: + return None + return int(value) + except Exception: + return None + + +def _resolve_sender_name(current_user: User) -> str: + sender = str( + getattr(current_user, "name", None) + or getattr(current_user, "username", None) + or "PMFlow用户" + ).strip() + return sender or "PMFlow用户" + + +def build_kernel_ingress_kwargs(request: RAGAskRequest, current_user: User) -> dict[str, Any]: + rag_request_context = build_answer_question_kwargs(request=request, current_user=current_user) + return { + "source_system": str(request.asked_via or "pmflow").strip() or "pmflow", + "source_message_id": None, + "message_text": request.question, + "sender": _resolve_sender_name(current_user), + "channel_ref": request.source_chat_id, + "source_sender_open_id": None, + "captured_at": None, + "chat_type": "manual", + "project_hint_id": request.project_id, + "ingress_options": { + "rag_request_context": rag_request_context, + }, + } + + +def build_rag_response_payload(*, ingress_result: dict[str, Any]) -> dict[str, Any]: + result = ingress_result if isinstance(ingress_result, dict) else {} + rag_result = result.get("rag_result") if isinstance(result.get("rag_result"), dict) else {} + payload = dict(rag_result) + + answer = str(payload.get("answer") or result.get("direct_reply_text") or "").strip() + payload["answer"] = answer or "当前暂无可用回答。" + + if "reply_required" not in payload: + payload["reply_required"] = bool(result.get("direct_reply_required")) + if "reply_text" not in payload: + payload["reply_text"] = str(result.get("direct_reply_text") or "").strip() or None + if "trace_id" not in payload: + payload["trace_id"] = str(result.get("ai_trace_id") or "").strip() or None + if "processing_stage" not in payload: + payload["processing_stage"] = "qa" + + payload["work_item_id"] = _safe_int(result.get("work_item_id")) + payload["ingress_status"] = str(result.get("status") or "").strip() or None + payload["intent"] = str(result.get("intent") or "").strip() or None + return payload diff --git a/backend/app/config/ai_prompts.py b/backend/app/config/ai_prompts.py index 4c9f0005..686b037a 100644 --- a/backend/app/config/ai_prompts.py +++ b/backend/app/config/ai_prompts.py @@ -98,6 +98,7 @@ __all__ = [ "PROMPT_AI_WORKER_SIGNAL_SOURCE_EXECUTION", "PROMPT_AI_PMO_INTENT_CLASSIFICATION", "PROMPT_AI_PMO_SIGNAL_BATCH_ANALYSIS", + "PROMPT_DRAFT_GENERATOR", "PROMPT_RAG_INTENT_CLASSIFICATION", "PROMPT_RAG_ANSWER", "PROMPT_RAG_DB_PROJECT_QA", diff --git a/backend/app/config/ai_prompts_catalog/__init__.py b/backend/app/config/ai_prompts_catalog/__init__.py index 3419ac31..6daddc2b 100644 --- a/backend/app/config/ai_prompts_catalog/__init__.py +++ b/backend/app/config/ai_prompts_catalog/__init__.py @@ -1,7 +1,7 @@ from __future__ import annotations from .part_01 import PROMPT_IMPACT_ANALYSIS, PROMPT_TASK_BREAKDOWN, PROMPT_STRATEGIC_ANALYSIS_HEADER, PROMPT_RAG_CONTEXT_HEADER, PROMPT_RAG_CONTEXT_ITEM, PROMPT_CODE_AUDIT, PROMPT_CONTEXT_INSIGHTS, PROMPT_FEISHU_REQUIREMENT_ANALYSIS, PROMPT_FEISHU_HUMANIZED_REPLY, PROMPT_FEISHU_PROJECT_SIGNAL_CLASSIFICATION, PROMPT_FEISHU_LEVEL_CLASSIFICATION -from .part_02 import PROMPT_FEISHU_SIGNAL_EXTRACTION, PROMPT_FEISHU_PROJECT_PREFILL, PROMPT_FEISHU_TASK_ORCHESTRATION, PROMPT_SIGNAL_SOURCE_ONBOARDING, PROMPT_AI_WORKER_SIGNAL_SOURCE_EXECUTION, PROMPT_AI_PMO_INTENT_CLASSIFICATION, PROMPT_AI_PMO_SIGNAL_BATCH_ANALYSIS, PROMPT_RAG_INTENT_CLASSIFICATION +from .part_02 import PROMPT_FEISHU_SIGNAL_EXTRACTION, PROMPT_FEISHU_PROJECT_PREFILL, PROMPT_FEISHU_TASK_ORCHESTRATION, PROMPT_SIGNAL_SOURCE_ONBOARDING, PROMPT_AI_WORKER_SIGNAL_SOURCE_EXECUTION, PROMPT_AI_PMO_INTENT_CLASSIFICATION, PROMPT_AI_PMO_SIGNAL_BATCH_ANALYSIS, PROMPT_DRAFT_GENERATOR, PROMPT_RAG_INTENT_CLASSIFICATION from .part_03 import PROMPT_RAG_ANSWER, PROMPT_RAG_DB_PROJECT_QA, PROMPT_GENERATE_CHARTER, PROMPT_DECOMPOSE_TASKS, PROMPT_RECOMMEND_ASSIGNEES, PROMPT_ALIGNMENT_SCORE, PROMPT_COGNITIVE_CONFLICTS, PROMPT_EVALUATE_MODEL_REQUIREMENT, PROMPT_ANALYZE_CAPABILITY_GAP, PROMPT_ANALYZE_COST_ROI from .part_04 import PROMPT_ANALYZE_COST_OPTIMIZATION, PROMPT_ASSESS_MODEL_UPDATE_IMPACT, PROMPT_EXPERIMENT_SYNC_DECISION, PROMPT_SKILL_SCORE_UPDATE, PROMPT_WORKLOAD_OPTIMIZATION, PROMPT_ANALYZE_FOOD_CALORIES, PROMPT_SIGNAL_PLANNER from .part_scenes_01 import PROMPT_FEISHU_DIRECT_QA @@ -30,6 +30,7 @@ __all__ = [ "PROMPT_AI_WORKER_SIGNAL_SOURCE_EXECUTION", "PROMPT_AI_PMO_INTENT_CLASSIFICATION", "PROMPT_AI_PMO_SIGNAL_BATCH_ANALYSIS", + "PROMPT_DRAFT_GENERATOR", "PROMPT_RAG_INTENT_CLASSIFICATION", "PROMPT_RAG_ANSWER", "PROMPT_RAG_DB_PROJECT_QA", diff --git a/backend/app/config/ai_prompts_catalog/part_02.py b/backend/app/config/ai_prompts_catalog/part_02.py index 0f747348..8efecd84 100644 --- a/backend/app/config/ai_prompts_catalog/part_02.py +++ b/backend/app/config/ai_prompts_catalog/part_02.py @@ -166,9 +166,9 @@ PROMPT_AI_PMO_INTENT_CLASSIFICATION = """ 只输出一个 JSON 对象(不要解释、不要 Markdown): {{ - "family": "query|project_create|project_signal|signal_source", + "family": "query|project_create|task|project_signal|signal_source", "action": "answer|create|discover|onboard", - "intent": "query.read_only|project.create|signal.create|signal_source.onboard|followup.continue", + "intent": "query.read_only|project.create|task.create|signal.create|signal_source.onboard|followup.continue", "confidence": 0.0, "project_id": 0, "requested_count": 1, @@ -176,16 +176,26 @@ PROMPT_AI_PMO_INTENT_CLASSIFICATION = """ "reasoning": "一句话简述判定原因" }} -快速规则(按优先级): -1. 含“分析/梳理/方案/设计思路/帮我分析/先分析” => family=query, action=answer, intent=query.read_only。 -2. 含“创建/新建/新增 + 项目”,且不含第1条分析诉求 => family=project_create, action=create, intent=project.create。 -3. 含“新增信号/录入信号/创建信号” => family=project_signal, action=create, intent=signal.create。 -4. 含“先分析候选/拆分候选并确认” => family=project_signal, action=discover, intent=signal.create, requires_confirmation=true。 -5. 含“信号源/来源/webhook/rss/github 接入” => family=signal_source, action=onboard, intent=signal_source.onboard。 -6. 若消息为短确认语气,可结合 session_last_intent;无把握时返回 query.answer。 -7. project_id 不确定返回 null;requested_count 默认 1(仅 project_signal 有效,范围 1-5)。 -8. family/action/intent 必须严格使用上面 JSON 示例给定的枚举值,不能自造。 -9. 若请求 DDL(建表/改表/删表),必须返回 query.answer + query.read_only,reasoning 含 ddl_api_required。 +判定规则(按优先级): +1. query.read_only 定义:仅当用户是在“查询 / 了解 / 分析已有内容”时,才判定为 query.read_only。 +2. 若消息同时包含“新增 / 创建 / 记录 / 提交”等写操作意图,优先判定为对应写操作意图,不要误判为 query.read_only。 +3. project.create:当用户明确表达“创建一个新项目”时,返回 family=project_create, action=create, intent=project.create。 +4. task.create:当用户明确表达“创建/新增/安排任务”时,返回 family=task, action=create, intent=task.create。 +5. signal.create 定义:当用户想创建 / 新增 / 录入 / 提交一条新的需求、功能、信号、想法或问题时,返回 family=project_signal, action=create, intent=signal.create。核心特征是“要产生一条新记录”,不管用户用什么词表达。 +6. signal.create few-shot: + - “给XX项目加个需求” -> signal.create + - “新增一个功能需求” -> signal.create + - “帮我记录这个想法” -> signal.create + - “提个需求:支持导出” -> signal.create +7. project_signal discover:当用户表达“先分析候选 / 拆分候选并确认”时,返回 family=project_signal, action=discover, intent=signal.create, requires_confirmation=true。 +8. signal_source.onboard:当用户表达“信号源 / 来源 / webhook / rss / github 接入”时,返回 family=signal_source, action=onboard, intent=signal_source.onboard。 +9. 若 session_last_intent=draft_pending_confirm 且消息含“确认/好的/就这样/行/ok”,优先返回 followup.continue(用于草稿确认执行)。 +10. 若 session_last_intent=draft_pending_confirm 且消息含“修改/改成/换成”,返回 followup.continue(用于草稿重起草)。 +11. 若 session_last_intent=draft_pending_confirm 且消息含“取消/不要/算了”,返回 followup.continue(用于草稿取消)。 +12. 若消息为短确认语气,可结合 session_last_intent;无把握时返回 query.answer。 +13. project_id 不确定返回 null;requested_count 默认 1(仅 project_signal 有效,范围 1-5)。 +14. family/action/intent 必须严格使用上面 JSON 示例给定的枚举值,不能自造。 +15. 若请求 DDL(建表/改表/删表),必须返回 query.answer + query.read_only,reasoning 含 ddl_api_required。 """ PROMPT_AI_PMO_SIGNAL_BATCH_ANALYSIS = """ @@ -218,6 +228,41 @@ PROMPT_AI_PMO_SIGNAL_BATCH_ANALYSIS = """ 4. 不要输出 Markdown,不要解释,不要代码块。 """.strip() +PROMPT_DRAFT_GENERATOR = """ +首字符必须是 {{,末字符必须是 }}。 +你是 PMFlow 的资深产品经理。 +收到一条新的业务需求,请起草分析报告。 + +输入: +- 项目信息:{project_title} - {project_description} +- 团队成员(按负载排序):{members_json} +- 最近同类需求:{recent_signals} +- 用户需求:{user_message} +- 意图:{intent_name} +- 修改意见(若为空表示首次起草):{revision_note} + +请输出 JSON,包含: +- title: 语义化标题(10字以内,必须概括目标,不得复述原话) +- summary: 需求摘要(50字以内) +- feasibility: 合理|待讨论|不合理 +- feasibility_reason: 判断理由(30字以内) +- recommended_assignee_id: 推荐成员ID +- recommended_assignee_reason: 推荐理由 +- followup_plan: 数组,每项含 when 和 action + +约束: +1. 只输出 JSON,不要解释,不要思考过程,不要 Markdown。 +2. title 必须是语义化摘要,不能包含原始消息中的原话或长片段复述。示例:可写“劝退后次日跟进机制”,不要写“给AI劝退助手加个需求...”。 +3. feasibility 判定规则: + - 明确的功能需求且技术可实现 => 合理 + - 需求模糊、边界冲突或依赖不清 => 待讨论 + - 明显不可行或超出系统范围 => 不合理 +4. 推荐负责人优先选择负载低于 80% 的成员。 +5. 若所有成员负载都 >= 80%,推荐负载最低者,并在 recommended_assignee_reason 明确写出负载风险。 +6. followup_plan 最少 1 项,最多 3 项。 +7. 若无法确定推荐人,recommended_assignee_id 返回 null。 +""".strip() + PROMPT_RAG_INTENT_CLASSIFICATION = """ 你是 PMFlow 的意图分类器。请判断输入文本属于:question / statement / mixed。 @@ -243,5 +288,6 @@ __all__ = [ "PROMPT_AI_WORKER_SIGNAL_SOURCE_EXECUTION", "PROMPT_AI_PMO_INTENT_CLASSIFICATION", "PROMPT_AI_PMO_SIGNAL_BATCH_ANALYSIS", + "PROMPT_DRAFT_GENERATOR", "PROMPT_RAG_INTENT_CLASSIFICATION", ] diff --git a/backend/app/core/startup/draft_expiry_worker.py b/backend/app/core/startup/draft_expiry_worker.py new file mode 100644 index 00000000..56023e84 --- /dev/null +++ b/backend/app/core/startup/draft_expiry_worker.py @@ -0,0 +1,114 @@ +"""Background worker for expiring stale draft confirmations.""" + +from __future__ import annotations + +import asyncio +from typing import Any + +from sqlalchemy import and_, select + +from ..database import AsyncSessionLocal +from ..logging import get_logger +from ...models import DecisionRecord, IngestEvent, WorkItem +from ...services.ai_pmo.ai_pmo_kernel_common_core.constants import STATE_CANCELLED, STATE_DRAFT_PENDING_CONFIRM +from ...services.integrations.feishu_outbox import FeishuOutboxService +from ...utils.timezone import now_cn_naive + + +logger = get_logger(__name__) +_DRAFT_EXPIRY_SCAN_INTERVAL_SECONDS = 60 * 60 +_DRAFT_EXPIRY_BATCH_SIZE = 200 + + +async def _cancel_expired_drafts_once(limit: int = _DRAFT_EXPIRY_BATCH_SIZE) -> int: + async with AsyncSessionLocal() as db: + now_at = now_cn_naive() + rows = ( + await db.execute( + select(WorkItem, IngestEvent) + .join(IngestEvent, IngestEvent.id == WorkItem.ingest_event_id) + .where( + and_( + WorkItem.state == STATE_DRAFT_PENDING_CONFIRM, + WorkItem.draft_expires_at.is_not(None), + WorkItem.draft_expires_at <= now_at, + ) + ) + .order_by(WorkItem.id.asc()) + .limit(max(1, int(limit))) + ) + ).all() + + cancelled_count = 0 + for work_item, ingest in rows: + payload = ingest.payload if isinstance(ingest.payload, dict) else {} + draft_payload = work_item.draft_content if isinstance(work_item.draft_content, dict) else {} + original_text = str(draft_payload.get("original_text") or payload.get("message_text") or "").strip() + + work_item.state = STATE_CANCELLED + work_item.draft_expires_at = None + work_item.reply_template_key = "draft_timeout_cancelled" + work_item.execution_result = { + **(work_item.execution_result if isinstance(work_item.execution_result, dict) else {}), + "status": "cancelled", + "cancel_reason": "draft_expired", + "cancelled_at": now_at.isoformat(), + } + + decision = ( + await db.execute( + select(DecisionRecord) + .where( + and_( + DecisionRecord.tenant_id == int(work_item.tenant_id), + DecisionRecord.work_item_id == int(work_item.id), + ) + ) + .order_by(DecisionRecord.id.desc()) + .limit(1) + ) + ).scalar_one_or_none() + if decision is not None: + decision.execution_result = work_item.execution_result + decision.final_outcome = STATE_CANCELLED + + source_system = str(work_item.source_system or "").strip().lower() + source_channel = str(payload.get("channel_ref") or "").strip() + if source_system == "feishu" and source_channel: + message_preview = original_text[:20] + if message_preview: + timeout_text = ( + "⏰ 草稿已超时取消\n\n" + f"需求\"{message_preview}...\"\n" + "已自动取消,如需提交请重新发送。" + ) + else: + timeout_text = "⏰ 草稿已超时取消,已自动取消,如需提交请重新发送。" + await FeishuOutboxService.enqueue( + db, + tenant_id=int(work_item.tenant_id), + chat_id=source_channel, + text=timeout_text, + source_message_id=str(work_item.source_message_id or "").strip() or None, + dedupe_seed=f"draft-expired:{int(work_item.id)}", + ) + cancelled_count += 1 + + if cancelled_count > 0: + await db.commit() + logger.info("draft_expiry_worker_cancelled", count=cancelled_count) + else: + await db.rollback() + return cancelled_count + + +async def draft_expiry_drain_loop() -> None: + while True: + try: + await _cancel_expired_drafts_once() + await asyncio.sleep(_DRAFT_EXPIRY_SCAN_INTERVAL_SECONDS) + except asyncio.CancelledError: + break + except Exception as exc: + logger.warning("draft_expiry_worker_failed", error=str(exc)[:500]) + await asyncio.sleep(30) diff --git a/backend/app/main.py b/backend/app/main.py index 5216931e..a8745cf3 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -16,6 +16,7 @@ from .core.response import success_response from .core.database import AsyncSessionLocal, engine from .core.startup.db_guard import check_database_dialect_guard from .core.startup.ai_pmo_ingress_async import ai_pmo_ingress_drain_loop +from .core.startup.draft_expiry_worker import draft_expiry_drain_loop from .core.startup.feishu_outbox import feishu_outbox_drain_loop from .core.startup.model_probe import probe_model_connectivity_on_startup, startup_log from .core.startup.world_model_warmup import warmup_world_models @@ -87,6 +88,7 @@ async def lifespan(_: FastAPI): outbox_task = asyncio.create_task(feishu_outbox_drain_loop(), name="feishu-outbox-drain-loop") ingress_task = asyncio.create_task(ai_pmo_ingress_drain_loop(), name="ai-pmo-ingress-drain-loop") + draft_expiry_task = asyncio.create_task(draft_expiry_drain_loop(), name="draft-expiry-drain-loop") wm_warmup_task = asyncio.create_task(warmup_world_models(), name="wm-world-model-warmup") startup_log("后端运行中", url=f"http://127.0.0.1:{backend_port}") @@ -105,6 +107,12 @@ async def lifespan(_: FastAPI): except asyncio.CancelledError: pass + draft_expiry_task.cancel() + try: + await draft_expiry_task + except asyncio.CancelledError: + pass + if not wm_warmup_task.done(): wm_warmup_task.cancel() with suppress(asyncio.CancelledError): diff --git a/backend/app/models/work_item.py b/backend/app/models/work_item.py index 8fe87525..4d688563 100644 --- a/backend/app/models/work_item.py +++ b/backend/app/models/work_item.py @@ -2,9 +2,10 @@ from __future__ import annotations +from datetime import datetime from typing import Any, Optional -from sqlalchemy import BigInteger, ForeignKey, Integer, UniqueConstraint, VARCHAR +from sqlalchemy import BigInteger, ForeignKey, Integer, TIMESTAMP, UniqueConstraint, VARCHAR from sqlalchemy.orm import Mapped, mapped_column from .base import Base @@ -54,4 +55,7 @@ class WorkItem(Base): execution_result: Mapped[Optional[Any]] = mapped_column(JSONB, nullable=True) review_summary: Mapped[Optional[Any]] = mapped_column(JSONB, nullable=True) reply_template_key: Mapped[Optional[str]] = mapped_column(VARCHAR(64), nullable=True) - + draft_content: Mapped[Optional[Any]] = mapped_column(JSONB, nullable=True) + draft_expires_at: Mapped[Optional[datetime]] = mapped_column(TIMESTAMP, nullable=True, index=True) + draft_version: Mapped[int] = mapped_column(Integer, nullable=False, default=0) + confirm_message_id: Mapped[Optional[str]] = mapped_column(VARCHAR(128), nullable=True, index=True) diff --git a/backend/app/schemas/rag.py b/backend/app/schemas/rag.py index 3eef117e..cf665740 100644 --- a/backend/app/schemas/rag.py +++ b/backend/app/schemas/rag.py @@ -3,21 +3,66 @@ from __future__ import annotations from datetime import datetime -from typing import Optional +from typing import ClassVar, Optional -from pydantic import BaseModel, Field +from pydantic import BaseModel, Field, field_validator class RAGAskRequest(BaseModel): + _ALLOWED_ASKED_VIA: ClassVar[set[str]] = { + "pmflow", + "feishu", + "lark", + "feishu_group", + "feishu_dm", + "pmflow_manual", + "/ai-pmo/process-text", + "ai-pmo-process-text", + "ai_pmo", + "ai_pmo_ui", + } + _ASKED_VIA_ALIAS: ClassVar[dict[str, str]] = { + "ai-pmo-process-text": "/ai-pmo/process-text", + } question: str = Field(..., min_length=1, max_length=4000) project_id: Optional[int] = Field(default=None) - asked_via: str = Field(default="pmflow", pattern="^(pmflow|feishu)$") + asked_via: str = Field(default="pmflow", min_length=1, max_length=32) 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)$") 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) + mixed_signal_part: Optional[str] = Field(default=None, max_length=2000) + + @field_validator("asked_via", mode="before") + @classmethod + def _normalize_asked_via(cls, value: object) -> str: + text = str(value or "pmflow").strip().lower() + text = cls._ASKED_VIA_ALIAS.get(text, text) + if text not in cls._ALLOWED_ASKED_VIA: + allowed = ", ".join(sorted(cls._ALLOWED_ASKED_VIA)) + raise ValueError(f"asked_via must be one of: {allowed}") + return text + + @field_validator("message_intent", mode="before") + @classmethod + def _normalize_message_intent(cls, value: object) -> Optional[str]: + if value is None: + return None + text = str(value).strip().lower() + if not text: + return None + return text + + @field_validator("mixed_signal_part", mode="before") + @classmethod + def _normalize_mixed_signal_part(cls, value: object) -> Optional[str]: + if value is None: + return None + text = str(value).strip() + return text or None class KnowledgeDocCreateRequest(BaseModel): 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 9b4162f5..86795c58 100644 --- a/backend/app/services/adaptive_rag_service/mixin_answer_entry.py +++ b/backend/app/services/adaptive_rag_service/mixin_answer_entry.py @@ -27,6 +27,19 @@ class AdaptiveRAGAnswerEntryMixin: "ai_pmo_ui", } + @staticmethod + def _should_trigger_write_ack_gate( + *, + sync_requirement: bool, + asked_via: str, + effective_action_mode: str, + ) -> bool: + return ( + bool(sync_requirement) + and str(asked_via or "").strip().lower() != "feishu" + and str(effective_action_mode or "").strip().lower() in {ACTION_MODE_WRITE, ACTION_MODE_READ_WRITE} + ) + async def answer_question( self, *, @@ -46,11 +59,6 @@ class AdaptiveRAGAnswerEntryMixin: ) -> dict[str, Any]: started = time.perf_counter() clean_q = str(question or "").strip() - external_conversation_key = self._build_external_conversation_key( - asked_via=asked_via, - source_chat_id=source_chat_id, - conversation_key=conversation_key, - ) active_thinking_mode = self._normalize_thinking_mode(thinking_mode) deep_thinking = active_thinking_mode == THINKING_MODE_DEEP asked_via_lower = str(asked_via or "").strip().lower() @@ -84,46 +92,31 @@ class AdaptiveRAGAnswerEntryMixin: thinking_mode=active_thinking_mode, ) - session_id, turn_index = await self._touch_session_for_new_record( - asked_by=asked_by, - asked_via=asked_via, - project_id=project_id, - question=clean_q, - preferred_session_id=session_id, - force_new_session=bool(force_new_session), - ) - context_session_id: Optional[int] - if session_id is not None: - context_session_id = int(session_id) - elif asked_by is None: - context_session_id = self._external_session_id_from_key(external_conversation_key) - else: - context_session_id = None - conversation = await self._load_recent_conversation( + runtime = await self._prepare_answer_runtime( + clean_q=clean_q, asked_by=asked_by, asked_via=asked_via, project_id=project_id, + source_chat_id=source_chat_id, session_id=session_id, - external_session_id=context_session_id, - limit=10, + conversation_key=conversation_key, + force_new_session=force_new_session, + action_mode=action_mode, ) - state_row = await self._ensure_session_state(session_id=session_id) - effective_q, rewrite_meta = self._rewrite_question_with_state( - question=clean_q, - conversation=conversation, - session_state=state_row, - ) - query_type = self._classify_query_type(effective_q) - requested_action_mode = self._normalize_action_mode(action_mode) - effective_action_mode = self._resolve_action_mode( - requested_mode=requested_action_mode, - question=clean_q, - effective_question=effective_q, - ) - if ( - bool(sync_requirement) - and asked_via_lower != "feishu" - and effective_action_mode in {ACTION_MODE_WRITE, ACTION_MODE_READ_WRITE} + session_id = runtime["session_id"] + turn_index = int(runtime["turn_index"] or 1) + context_session_id = runtime["context_session_id"] + conversation = runtime["conversation"] + state_row = runtime["state_row"] + effective_q = str(runtime["effective_q"] or "").strip() + rewrite_meta = runtime["rewrite_meta"] if isinstance(runtime["rewrite_meta"], dict) else {} + query_type = str(runtime["query_type"] or QUERY_TYPE_UNKNOWN) + effective_action_mode = str(runtime["effective_action_mode"] or ACTION_MODE_AUTO).strip().lower() + + if self._should_trigger_write_ack_gate( + sync_requirement=sync_requirement, + asked_via=asked_via, + effective_action_mode=effective_action_mode, ): ack_result = await self._handle_ack_first_requirement( question=clean_q, @@ -142,132 +135,8 @@ class AdaptiveRAGAnswerEntryMixin: ) if ack_result is not None: return ack_result - forced_deep_reason = "" - if not deep_thinking: - forced_deep_reason = self._forced_deep_reason( - question=clean_q, - effective_question=effective_q, - conversation=conversation, - ) - if forced_deep_reason and self._should_force_deep_for_channel( - asked_via=asked_via, - question=clean_q, - effective_question=effective_q, - reason=forced_deep_reason, - ): - deep_thinking = True - active_thinking_mode = THINKING_MODE_DEEP - else: - forced_deep_reason = "" - - clarification_answer = self._build_clarification_question( - original_question=clean_q, - effective_question=effective_q, - session_state=state_row, - ) - clarification_result = await self._maybe_return_clarification_response( - clarification_answer=clarification_answer, - started=started, + return await self._execute_base_answer_mainline( clean_q=clean_q, - effective_q=effective_q, - query_type=query_type, - asked_by=asked_by, - asked_via=asked_via, - project_id=project_id, - session_id=session_id, - turn_index=turn_index, - state_row=state_row, - rewrite_meta=rewrite_meta, - context_session_id=context_session_id, - active_thinking_mode=active_thinking_mode, - ) - if clarification_result is not None: - return clarification_result - - system_snapshot = await self.context_cache.get_system_snapshot( - db=self.db, - tenant_id=self.tenant_id, - ) - user_snapshot = await self.context_cache.get_user_snapshot( - db=self.db, - tenant_id=self.tenant_id, - user_id=asked_by, - ) - - if not deep_thinking and not enforce_scene_prompt_only: - deterministic_result = await self._run_deterministic_query( - question=effective_q, - project_id=project_id, - query_type=query_type, - ) - if deterministic_result is not None: - return await self._persist_fast_answer( - question=clean_q, - asked_by=asked_by, - asked_via=asked_via, - project_id=project_id, - fast_result=deterministic_result, - session_id=session_id, - external_session_id=context_session_id, - turn_index=turn_index, - query_type=str(deterministic_result.get("query_type") or query_type), - missing_fields=list(deterministic_result.get("missing_fields") or []), - state_row=state_row, - effective_question=effective_q, - rewrite_meta=rewrite_meta, - thinking_mode=active_thinking_mode, - ) - - direct_answer = None - if not deep_thinking and not enforce_scene_prompt_only: - direct_answer = self._build_snapshot_direct_answer( - effective_q, - system_snapshot=system_snapshot, - user_snapshot=user_snapshot, - ) - direct_result = await self._maybe_return_snapshot_direct_response( - direct_answer=direct_answer, - started=started, - clean_q=clean_q, - effective_q=effective_q, - asked_by=asked_by, - asked_via=asked_via, - project_id=project_id, - session_id=session_id, - turn_index=turn_index, - state_row=state_row, - rewrite_meta=rewrite_meta, - context_session_id=context_session_id, - active_thinking_mode=active_thinking_mode, - user_snapshot=user_snapshot, - ) - if direct_result is not None: - return direct_result - - is_snapshot_direct_question = self._is_snapshot_direct_question(effective_q) - if not deep_thinking and not enforce_scene_prompt_only and not is_snapshot_direct_question: - glm_semantic_result = await self._try_glm_project_semantic_answer(clean_q=effective_q, project_id=project_id) - if glm_semantic_result is not None: - return await self._persist_fast_answer( - question=clean_q, - asked_by=asked_by, - asked_via=asked_via, - project_id=project_id, - fast_result=glm_semantic_result, - session_id=session_id, - external_session_id=context_session_id, - turn_index=turn_index, - query_type=query_type if query_type != QUERY_TYPE_UNKNOWN else QUERY_TYPE_EXPLAIN, - missing_fields=[], - state_row=state_row, - effective_question=effective_q, - rewrite_meta=rewrite_meta, - thinking_mode=active_thinking_mode, - ) - - return await self._build_retrieval_response( - clean_q=clean_q, - effective_q=effective_q, asked_by=asked_by, asked_via=asked_via, project_id=project_id, @@ -277,16 +146,15 @@ class AdaptiveRAGAnswerEntryMixin: context_session_id=context_session_id, conversation=conversation, state_row=state_row, + effective_q=effective_q, rewrite_meta=rewrite_meta, query_type=query_type, thinking_mode=thinking_mode, active_thinking_mode=active_thinking_mode, deep_thinking=deep_thinking, - forced_deep_reason=forced_deep_reason, + enforce_scene_prompt_only=enforce_scene_prompt_only, deep_retrieval_steps=deep_retrieval_steps, deep_vector_top_k=deep_vector_top_k, - system_snapshot=system_snapshot, - user_snapshot=user_snapshot, started=started, message_intent=message_intent, mixed_signal_part=mixed_signal_part, diff --git a/backend/app/services/adaptive_rag_service/mixin_answer_mainline.py b/backend/app/services/adaptive_rag_service/mixin_answer_mainline.py new file mode 100644 index 00000000..5132d588 --- /dev/null +++ b/backend/app/services/adaptive_rag_service/mixin_answer_mainline.py @@ -0,0 +1,248 @@ +"""Adaptive RAG answer mainline orchestration helpers.""" +from __future__ import annotations + +from .shared import * + + +class AdaptiveRAGAnswerMainlineMixin: + async def _prepare_answer_runtime( + self, + *, + clean_q: str, + asked_by: Optional[int], + asked_via: str, + project_id: Optional[int], + source_chat_id: Optional[str], + session_id: Optional[int], + conversation_key: Optional[str], + force_new_session: bool, + action_mode: str, + ) -> dict[str, Any]: + external_conversation_key = self._build_external_conversation_key( + asked_via=asked_via, + source_chat_id=source_chat_id, + conversation_key=conversation_key, + ) + session_id, turn_index = await self._touch_session_for_new_record( + asked_by=asked_by, + asked_via=asked_via, + project_id=project_id, + question=clean_q, + preferred_session_id=session_id, + force_new_session=bool(force_new_session), + ) + context_session_id: Optional[int] + if session_id is not None: + context_session_id = int(session_id) + elif asked_by is None: + context_session_id = self._external_session_id_from_key(external_conversation_key) + else: + context_session_id = None + conversation = await self._load_recent_conversation( + asked_by=asked_by, + asked_via=asked_via, + project_id=project_id, + session_id=session_id, + external_session_id=context_session_id, + limit=10, + ) + state_row = await self._ensure_session_state(session_id=session_id) + effective_q, rewrite_meta = self._rewrite_question_with_state( + question=clean_q, + conversation=conversation, + session_state=state_row, + ) + query_type = self._classify_query_type(effective_q) + requested_action_mode = self._normalize_action_mode(action_mode) + effective_action_mode = self._resolve_action_mode( + requested_mode=requested_action_mode, + question=clean_q, + effective_question=effective_q, + ) + return { + "session_id": session_id, + "turn_index": turn_index, + "context_session_id": context_session_id, + "conversation": conversation, + "state_row": state_row, + "effective_q": effective_q, + "rewrite_meta": rewrite_meta, + "query_type": query_type, + "effective_action_mode": effective_action_mode, + } + + async def _execute_base_answer_mainline( + self, + *, + clean_q: str, + asked_by: Optional[int], + asked_via: str, + project_id: Optional[int], + source_chat_id: Optional[str], + session_id: Optional[int], + turn_index: int, + context_session_id: Optional[int], + conversation: list[dict[str, str]], + state_row: Optional[QASessionState], + effective_q: str, + rewrite_meta: dict[str, Any], + query_type: str, + thinking_mode: str, + active_thinking_mode: str, + deep_thinking: bool, + enforce_scene_prompt_only: bool, + deep_retrieval_steps: int, + deep_vector_top_k: int, + started: float, + message_intent: Optional[str], + mixed_signal_part: Optional[str], + ) -> dict[str, Any]: + forced_deep_reason = "" + if not deep_thinking: + forced_deep_reason = self._forced_deep_reason( + question=clean_q, + effective_question=effective_q, + conversation=conversation, + ) + if forced_deep_reason and self._should_force_deep_for_channel( + asked_via=asked_via, + question=clean_q, + effective_question=effective_q, + reason=forced_deep_reason, + ): + deep_thinking = True + active_thinking_mode = THINKING_MODE_DEEP + else: + forced_deep_reason = "" + + clarification_answer = self._build_clarification_question( + original_question=clean_q, + effective_question=effective_q, + session_state=state_row, + ) + clarification_result = await self._maybe_return_clarification_response( + clarification_answer=clarification_answer, + started=started, + clean_q=clean_q, + effective_q=effective_q, + query_type=query_type, + asked_by=asked_by, + asked_via=asked_via, + project_id=project_id, + session_id=session_id, + turn_index=turn_index, + state_row=state_row, + rewrite_meta=rewrite_meta, + context_session_id=context_session_id, + active_thinking_mode=active_thinking_mode, + ) + if clarification_result is not None: + return clarification_result + + system_snapshot = await self.context_cache.get_system_snapshot( + db=self.db, + tenant_id=self.tenant_id, + ) + user_snapshot = await self.context_cache.get_user_snapshot( + db=self.db, + tenant_id=self.tenant_id, + user_id=asked_by, + ) + + if not deep_thinking and not enforce_scene_prompt_only: + deterministic_result = await self._run_deterministic_query( + question=effective_q, + project_id=project_id, + query_type=query_type, + ) + if deterministic_result is not None: + return await self._persist_fast_answer( + question=clean_q, + asked_by=asked_by, + asked_via=asked_via, + project_id=project_id, + fast_result=deterministic_result, + session_id=session_id, + external_session_id=context_session_id, + turn_index=turn_index, + query_type=str(deterministic_result.get("query_type") or query_type), + missing_fields=list(deterministic_result.get("missing_fields") or []), + state_row=state_row, + effective_question=effective_q, + rewrite_meta=rewrite_meta, + thinking_mode=active_thinking_mode, + ) + + direct_answer = None + if not deep_thinking and not enforce_scene_prompt_only: + direct_answer = self._build_snapshot_direct_answer( + effective_q, + system_snapshot=system_snapshot, + user_snapshot=user_snapshot, + ) + direct_result = await self._maybe_return_snapshot_direct_response( + direct_answer=direct_answer, + started=started, + clean_q=clean_q, + effective_q=effective_q, + asked_by=asked_by, + asked_via=asked_via, + project_id=project_id, + session_id=session_id, + turn_index=turn_index, + state_row=state_row, + rewrite_meta=rewrite_meta, + context_session_id=context_session_id, + active_thinking_mode=active_thinking_mode, + user_snapshot=user_snapshot, + ) + if direct_result is not None: + return direct_result + + is_snapshot_direct_question = self._is_snapshot_direct_question(effective_q) + if not deep_thinking and not enforce_scene_prompt_only and not is_snapshot_direct_question: + glm_semantic_result = await self._try_glm_project_semantic_answer(clean_q=effective_q, project_id=project_id) + if glm_semantic_result is not None: + return await self._persist_fast_answer( + question=clean_q, + asked_by=asked_by, + asked_via=asked_via, + project_id=project_id, + fast_result=glm_semantic_result, + session_id=session_id, + external_session_id=context_session_id, + turn_index=turn_index, + query_type=query_type if query_type != QUERY_TYPE_UNKNOWN else QUERY_TYPE_EXPLAIN, + missing_fields=[], + state_row=state_row, + effective_question=effective_q, + rewrite_meta=rewrite_meta, + thinking_mode=active_thinking_mode, + ) + + return await self._build_retrieval_response( + clean_q=clean_q, + effective_q=effective_q, + asked_by=asked_by, + asked_via=asked_via, + project_id=project_id, + source_chat_id=source_chat_id, + session_id=session_id, + turn_index=turn_index, + context_session_id=context_session_id, + conversation=conversation, + state_row=state_row, + rewrite_meta=rewrite_meta, + query_type=query_type, + thinking_mode=thinking_mode, + active_thinking_mode=active_thinking_mode, + deep_thinking=deep_thinking, + forced_deep_reason=forced_deep_reason, + deep_retrieval_steps=deep_retrieval_steps, + deep_vector_top_k=deep_vector_top_k, + system_snapshot=system_snapshot, + user_snapshot=user_snapshot, + started=started, + message_intent=message_intent, + mixed_signal_part=mixed_signal_part, + ) diff --git a/backend/app/services/adaptive_rag_service/service.py b/backend/app/services/adaptive_rag_service/service.py index 3092a2b5..05bec306 100644 --- a/backend/app/services/adaptive_rag_service/service.py +++ b/backend/app/services/adaptive_rag_service/service.py @@ -3,6 +3,7 @@ from __future__ import annotations from .mixin_core_intent import AdaptiveRAGCoreIntentMixin from .mixin_answer_entry import AdaptiveRAGAnswerEntryMixin +from .mixin_answer_mainline import AdaptiveRAGAnswerMainlineMixin from .mixin_answer_precheck import AdaptiveRAGAnswerPrecheckMixin from .mixin_answer_retrieval import AdaptiveRAGAnswerRetrievalMixin from .mixin_answer_payload import AdaptiveRAGAnswerPayloadMixin @@ -43,6 +44,7 @@ from .mixin_conversation_utils_b import AdaptiveRAGConversationUtilsBMixin class AdaptiveRAGService( AdaptiveRAGCoreIntentMixin, AdaptiveRAGAnswerEntryMixin, + AdaptiveRAGAnswerMainlineMixin, AdaptiveRAGAnswerPrecheckMixin, AdaptiveRAGAnswerRetrievalMixin, AdaptiveRAGAnswerPayloadMixin, diff --git a/backend/app/services/ai_orchestration_run/ingress_worker.py b/backend/app/services/ai_orchestration_run/ingress_worker.py index e098e91e..262a866f 100644 --- a/backend/app/services/ai_orchestration_run/ingress_worker.py +++ b/backend/app/services/ai_orchestration_run/ingress_worker.py @@ -141,6 +141,10 @@ async def _dispatch_claimed_ingress_run(*, run_id: int) -> None: captured_at_value = row.captured_at source_message_id_value = str(row.source_message_id or "").strip() or None chat_type_value = str(row.chat_type or "").strip() or None + result_payload_value = row.result_payload if isinstance(row.result_payload, dict) else {} + task_payload_value = result_payload_value.get("task_payload") if isinstance(result_payload_value.get("task_payload"), dict) else {} + message_sent_at_value = task_payload_value.get("message_sent_at") + session_snapshot_value = task_payload_value.get("session_snapshot") if isinstance(task_payload_value.get("session_snapshot"), dict) else None try: capture_service = ProactiveSignalCaptureService(db) @@ -177,6 +181,8 @@ async def _dispatch_claimed_ingress_run(*, run_id: int) -> None: feishu_message_id=source_message_id_value, chat_type=chat_type_value, force_sync=True, + message_sent_at=message_sent_at_value, + session_snapshot=session_snapshot_value, ) finally: stop_heartbeat.set() diff --git a/backend/app/services/ai_pmo/ai_pmo_decision/policy.py b/backend/app/services/ai_pmo/ai_pmo_decision/policy.py index c60effb0..a3b8c6e1 100644 --- a/backend/app/services/ai_pmo/ai_pmo_decision/policy.py +++ b/backend/app/services/ai_pmo/ai_pmo_decision/policy.py @@ -7,6 +7,7 @@ import os from .shared import ( CanonicalIntent, INTENT_QUERY, + INTENT_TASK_CREATE, INTENT_SIGNAL_CREATE, INTENT_SIGNAL_SOURCE_ONBOARD, re, @@ -59,7 +60,7 @@ class ExecutionPolicyBrain: }, } - if intent.name in {INTENT_QUERY, INTENT_SIGNAL_CREATE}: + if intent.name in {INTENT_QUERY, INTENT_TASK_CREATE, INTENT_SIGNAL_CREATE}: return { "auto_execute": True, "policy_gate_hit": False, diff --git a/backend/app/services/ai_pmo/ai_pmo_decision/shared.py b/backend/app/services/ai_pmo/ai_pmo_decision/shared.py index 66212919..caf99027 100644 --- a/backend/app/services/ai_pmo/ai_pmo_decision/shared.py +++ b/backend/app/services/ai_pmo/ai_pmo_decision/shared.py @@ -10,6 +10,7 @@ from ..ai_pmo_intent import ( INTENT_FOLLOWUP_CONTINUE, INTENT_PROJECT_CREATE, INTENT_QUERY, + INTENT_TASK_CREATE, INTENT_SIGNAL_CREATE, INTENT_SIGNAL_SOURCE_ONBOARD, ROUTE_PROJECT_SIGNAL_CANCEL, @@ -46,6 +47,7 @@ __all__ = [ "INTENT_FOLLOWUP_CONTINUE", "INTENT_PROJECT_CREATE", "INTENT_QUERY", + "INTENT_TASK_CREATE", "INTENT_SIGNAL_CREATE", "INTENT_SIGNAL_SOURCE_ONBOARD", "ROUTE_PROJECT_SIGNAL_CANCEL", diff --git a/backend/app/services/ai_pmo/ai_pmo_decision/simulation.py b/backend/app/services/ai_pmo/ai_pmo_decision/simulation.py index 6d9c3299..ffc9f7cb 100644 --- a/backend/app/services/ai_pmo/ai_pmo_decision/simulation.py +++ b/backend/app/services/ai_pmo/ai_pmo_decision/simulation.py @@ -19,6 +19,7 @@ class SimulationEngine: BUDGET_COST_BY_OPERATION = { "query.answer": 1.0, "project.create": 120.0, + "task.create": 14.0, "signal.enter": 8.0, "signal_source.onboard": 40.0, ROUTE_PROJECT_SIGNAL_DISCOVER: 3.0, @@ -29,6 +30,7 @@ class SimulationEngine: HOURS_BY_OPERATION = { "query.answer": 0.2, "project.create": 12.0, + "task.create": 2.5, "signal.enter": 1.5, "signal_source.onboard": 6.0, ROUTE_PROJECT_SIGNAL_DISCOVER: 0.8, diff --git a/backend/app/services/ai_pmo/ai_pmo_decision/strategic.py b/backend/app/services/ai_pmo/ai_pmo_decision/strategic.py index 45e1bfed..5cb031e7 100644 --- a/backend/app/services/ai_pmo/ai_pmo_decision/strategic.py +++ b/backend/app/services/ai_pmo/ai_pmo_decision/strategic.py @@ -5,6 +5,7 @@ from __future__ import annotations from .shared import ( CanonicalIntent, INTENT_PROJECT_CREATE, + INTENT_TASK_CREATE, INTENT_SIGNAL_SOURCE_ONBOARD, Any, normalize_workload_ratio, @@ -28,6 +29,8 @@ class StrategicBrain: risk_score = min(1.0, 0.18 + risk_count * 0.08 + budget_ratio * 0.25 + len(overloaded_people) * 0.1) if intent.name == INTENT_PROJECT_CREATE: risk_score = max(risk_score, 0.62) + if intent.name == INTENT_TASK_CREATE: + risk_score = max(risk_score, 0.55) if intent.name == INTENT_SIGNAL_SOURCE_ONBOARD: risk_score = max(risk_score, 0.58) diff --git a/backend/app/services/ai_pmo/ai_pmo_decision/tactical.py b/backend/app/services/ai_pmo/ai_pmo_decision/tactical.py index 2c9bc5b7..f1d402ca 100644 --- a/backend/app/services/ai_pmo/ai_pmo_decision/tactical.py +++ b/backend/app/services/ai_pmo/ai_pmo_decision/tactical.py @@ -8,6 +8,7 @@ from .shared import ( INTENT_FOLLOWUP_CONTINUE, INTENT_PROJECT_CREATE, INTENT_QUERY, + INTENT_TASK_CREATE, INTENT_SIGNAL_CREATE, INTENT_SIGNAL_SOURCE_ONBOARD, ROUTE_PROJECT_SIGNAL_CANCEL, @@ -42,6 +43,8 @@ class TacticalBrain: execution_actions.append({"operation_code": "query.answer", "mode": "read_only"}) elif intent.name == INTENT_PROJECT_CREATE: execution_actions.append({"operation_code": "project.create", "mode": "write"}) + elif intent.name == INTENT_TASK_CREATE: + execution_actions.append({"operation_code": "task.create", "mode": "write"}) elif intent.name == INTENT_SIGNAL_SOURCE_ONBOARD: execution_actions.append({"operation_code": "signal.enter", "mode": "write"}) execution_actions.append({"operation_code": "signal_source.onboard", "mode": "write"}) diff --git a/backend/app/services/ai_pmo/ai_pmo_intent/__init__.py b/backend/app/services/ai_pmo/ai_pmo_intent/__init__.py index ef1c7c0e..a4876d4f 100644 --- a/backend/app/services/ai_pmo/ai_pmo_intent/__init__.py +++ b/backend/app/services/ai_pmo/ai_pmo_intent/__init__.py @@ -8,6 +8,7 @@ from .intent_resolver import IntentResolver __all__ = [ "INTENT_QUERY", "INTENT_PROJECT_CREATE", + "INTENT_TASK_CREATE", "INTENT_SIGNAL_CREATE", "INTENT_SIGNAL_SOURCE_ONBOARD", "INTENT_FOLLOWUP_CONFIRM", @@ -16,6 +17,7 @@ __all__ = [ "FOLLOWUP_TARGET_INTENTS", "ROUTE_FAMILY_QUERY", "ROUTE_FAMILY_PROJECT_CREATE", + "ROUTE_FAMILY_TASK", "ROUTE_FAMILY_PROJECT_SIGNAL", "ROUTE_FAMILY_SIGNAL_SOURCE", "ROUTE_FAMILY_FOLLOWUP", @@ -30,6 +32,7 @@ __all__ = [ "ROUTE_ACTION_CONFIRM", "ROUTE_QUERY_ANSWER", "ROUTE_PROJECT_CREATE", + "ROUTE_TASK_CREATE", "ROUTE_PROJECT_SIGNAL_CREATE", "ROUTE_PROJECT_SIGNAL_DISCOVER", "ROUTE_PROJECT_SIGNAL_COMMIT", @@ -53,6 +56,7 @@ __all__ = [ "FOLLOWUP_CONTINUE_HINTS", "FOLLOWUP_WORK_ITEM_ID_RE", "SIGNAL_CREATE_HINTS", + "TASK_CREATE_HINTS", "PROJECT_CREATE_HINTS", "SESSION_SHORT_CONFIRM_WINDOW_SECONDS", "SHORT_CONFIRM_MAX_MESSAGE_LENGTH", diff --git a/backend/app/services/ai_pmo/ai_pmo_intent/intent_resolver.py b/backend/app/services/ai_pmo/ai_pmo_intent/intent_resolver.py index 26a6a408..6c88445d 100644 --- a/backend/app/services/ai_pmo/ai_pmo_intent/intent_resolver.py +++ b/backend/app/services/ai_pmo/ai_pmo_intent/intent_resolver.py @@ -103,6 +103,16 @@ class IntentResolver: pretty[:300], {}, ) + if IntentResolver._looks_like_task_create_request(text=pretty): + extracted_title = IntentResolver._extract_task_title(pretty) + return CanonicalIntent( + INTENT_TASK_CREATE, + 0.82, + project_id, + extracted_title or IntentResolver._build_signal_title(pretty, default="新任务"), + pretty[:300], + {"task_title": extracted_title} if extracted_title else {}, + ) if IntentResolver._looks_like_signal_create_request(text=pretty): return CanonicalIntent( INTENT_SIGNAL_CREATE, @@ -148,19 +158,71 @@ class IntentResolver: return True if any(token in normalized for token in SIGNAL_CREATE_HINTS): return True - signal_object_hints = ("信号", "需求", "任务", "事项", "问题", "改造", "优化") + signal_object_hints = ("信号", "需求", "事项", "问题", "改造", "优化") has_operation = any(token in normalized for token in IntentResolver.OPERATION_VERBS) has_signal_object = any(token in normalized for token in signal_object_hints) if has_operation and has_signal_object: return True return bool( re.search( - r"(?:给|帮|请).{0,20}(?:新增|增加|添加|补充|加).{0,20}(?:信号|需求|任务|事项)", + r"(?:给|帮|请).{0,20}(?:新增|增加|添加|补充|加).{0,20}(?:信号|需求|事项)", normalized, re.IGNORECASE, ) ) + @staticmethod + def _looks_like_task_create_request(*, text: str) -> bool: + normalized = re.sub(r"\s+", "", str(text or "")).lower() + if not normalized: + return False + if IntentResolver._looks_like_negated_write_statement(text=normalized): + return False + if any(token in normalized for token in TASK_CREATE_HINTS): + return True + task_object_hints = ("任务", "工单", "待办", "todo") + has_operation = any(token in normalized for token in IntentResolver.OPERATION_VERBS) + has_task_object = any(token in normalized for token in task_object_hints) + if has_operation and has_task_object: + return True + return bool( + re.search( + r"(?:给|帮|请).{0,24}(?:新增|增加|添加|创建|新建|加).{0,16}(?:任务|工单|待办)", + normalized, + re.IGNORECASE, + ) + ) + + @staticmethod + def _extract_task_title(text: str) -> str: + cleaned = re.sub(r"\s+", " ", str(text or "")).strip() + if not cleaned: + return "" + quoted_match = re.search(r"[\"'“](.+?)[\"'”]", cleaned) + if quoted_match: + return quoted_match.group(1).strip()[:120] + for pattern in ( + r"(?:新增|新建|创建|增加|添加|安排|分配|加一个|加个).{0,8}任务(?:叫|名为|标题是|是|:|:)?\s*(.+)", + r"任务(?:叫|名为|标题是|是|:|:)\s*(.+)", + ): + match = re.search(pattern, cleaned, re.IGNORECASE) + if not match: + continue + candidate = re.sub(r"[。;;!!??].*$", "", match.group(1)).strip(" ,,::") + candidate = re.sub(r"^(?:到|进|放到)\s*项目.*$", "", candidate, flags=re.IGNORECASE).strip(" ,,::") + if candidate: + return candidate[:120] + fallback = re.sub( + r"^(?:请|帮我|帮忙)?(?:给|在)?(?:项目\s*#?\d+\s*)?(?:里|中)?(?:新增|新建|创建|增加|添加|加(?:一个|个)?|安排|分配)\s*(?:一条|一个|个)?任务", + "", + cleaned, + flags=re.IGNORECASE, + ).strip(" ,,::") + fallback = re.sub(r"[。;;!!??].*$", "", fallback).strip(" ,,::") + if not fallback: + return "" + return fallback[:120] + @staticmethod def _is_smalltalk_message(*, pretty: str, normalized: str) -> bool: compact = re.sub(r"[\\s\\??!!,,。~~、]+", "", str(pretty or "")).strip().lower() @@ -181,6 +243,8 @@ class IntentResolver: return False if IntentResolver._looks_like_signal_create_request(text=normalized): return False + if IntentResolver._looks_like_task_create_request(text=normalized): + return False if any(token in normalized for token in QUESTION_HINTS): return True has_question_structure = ( diff --git a/backend/app/services/ai_pmo/ai_pmo_intent/route_resolver.py b/backend/app/services/ai_pmo/ai_pmo_intent/route_resolver.py index 43e146f1..1e77d231 100644 --- a/backend/app/services/ai_pmo/ai_pmo_intent/route_resolver.py +++ b/backend/app/services/ai_pmo/ai_pmo_intent/route_resolver.py @@ -41,6 +41,8 @@ class RouteResolver: return INTENT_SIGNAL_SOURCE_ONBOARD if family == ROUTE_FAMILY_PROJECT_CREATE: return INTENT_PROJECT_CREATE + if family == ROUTE_FAMILY_TASK: + return INTENT_TASK_CREATE if family == ROUTE_FAMILY_FOLLOWUP: return INTENT_FOLLOWUP_CONTINUE return INTENT_QUERY @@ -61,8 +63,14 @@ class RouteResolver: return explicit_action == ROUTE_ACTION_DISCOVER @staticmethod - def parse_followup_action(*, metadata: dict[str, Any], canonical_intent: str) -> str: - explicit_action = str(metadata.get("followup_action") or metadata.get("action") or "").strip().lower() + def parse_followup_action( + *, + metadata: Optional[dict[str, Any]] = None, + canonical_intent: str, + message_text: str = "", + ) -> str: + payload = metadata if isinstance(metadata, dict) else {} + explicit_action = str(payload.get("followup_action") or payload.get("action") or "").strip().lower() if explicit_action in { ROUTE_ACTION_CONTINUE, ROUTE_ACTION_CONFIRM, @@ -71,6 +79,13 @@ class RouteResolver: ROUTE_ACTION_REFRESH, }: return explicit_action + normalized_text = re.sub(r"\s+", "", str(message_text or "")).lower() + if any(token in normalized_text for token in FOLLOWUP_REFRESH_HINTS): + return ROUTE_ACTION_REFRESH + if any(token in normalized_text for token in FOLLOWUP_CANCEL_HINTS): + return ROUTE_ACTION_CANCEL + if any(token in normalized_text for token in FOLLOWUP_COMMIT_HINTS): + return ROUTE_ACTION_COMMIT if canonical_intent in {INTENT_FOLLOWUP_CONFIRM, INTENT_FOLLOWUP_CONTINUE}: return ROUTE_ACTION_COMMIT return ROUTE_ACTION_CONTINUE @@ -92,8 +107,15 @@ class RouteResolver: route_key = ROUTE_PROJECT_SIGNAL_DISCOVER elif route_key not in {ROUTE_PROJECT_SIGNAL_CREATE, ROUTE_PROJECT_SIGNAL_DISCOVER}: route_key = ROUTE_PROJECT_SIGNAL_CREATE + elif canonical_intent.name == INTENT_TASK_CREATE: + if route_key != ROUTE_TASK_CREATE: + route_key = ROUTE_TASK_CREATE elif canonical_intent.name in {INTENT_FOLLOWUP_CONFIRM, INTENT_FOLLOWUP_CONTINUE}: - action = cls.parse_followup_action(metadata=metadata, canonical_intent=canonical_intent.name) + action = cls.parse_followup_action( + metadata=metadata, + canonical_intent=canonical_intent.name, + message_text=message_text, + ) if action == ROUTE_ACTION_CONFIRM: route_key = ROUTE_FOLLOWUP_CONFIRM else: diff --git a/backend/app/services/ai_pmo/ai_pmo_intent/shared.py b/backend/app/services/ai_pmo/ai_pmo_intent/shared.py index 6df8a788..e48a7350 100644 --- a/backend/app/services/ai_pmo/ai_pmo_intent/shared.py +++ b/backend/app/services/ai_pmo/ai_pmo_intent/shared.py @@ -11,6 +11,7 @@ from ...signal.signal_source_orchestration import SignalSourceOrchestrationServi INTENT_QUERY = "query.read_only" INTENT_PROJECT_CREATE = "project.create" +INTENT_TASK_CREATE = "task.create" INTENT_SIGNAL_CREATE = "signal.create" INTENT_SIGNAL_SOURCE_ONBOARD = "signal_source.onboard" INTENT_FOLLOWUP_CONFIRM = "followup.confirm" @@ -18,12 +19,14 @@ INTENT_FOLLOWUP_CONTINUE = "followup.continue" INTENT_IGNORE = "ignore" FOLLOWUP_TARGET_INTENTS = { INTENT_PROJECT_CREATE, + INTENT_TASK_CREATE, INTENT_SIGNAL_CREATE, INTENT_SIGNAL_SOURCE_ONBOARD, } ROUTE_FAMILY_QUERY = "query" ROUTE_FAMILY_PROJECT_CREATE = "project_create" +ROUTE_FAMILY_TASK = "task" ROUTE_FAMILY_PROJECT_SIGNAL = "project_signal" ROUTE_FAMILY_SIGNAL_SOURCE = "signal_source" ROUTE_FAMILY_FOLLOWUP = "followup" @@ -40,6 +43,7 @@ ROUTE_ACTION_CONFIRM = "confirm" ROUTE_QUERY_ANSWER = f"{ROUTE_FAMILY_QUERY}.{ROUTE_ACTION_ANSWER}" ROUTE_PROJECT_CREATE = f"{ROUTE_FAMILY_PROJECT_CREATE}.{ROUTE_ACTION_CREATE}" +ROUTE_TASK_CREATE = f"{ROUTE_FAMILY_TASK}.{ROUTE_ACTION_CREATE}" ROUTE_PROJECT_SIGNAL_CREATE = f"{ROUTE_FAMILY_PROJECT_SIGNAL}.{ROUTE_ACTION_CREATE}" ROUTE_PROJECT_SIGNAL_DISCOVER = f"{ROUTE_FAMILY_PROJECT_SIGNAL}.{ROUTE_ACTION_DISCOVER}" ROUTE_PROJECT_SIGNAL_COMMIT = f"{ROUTE_FAMILY_PROJECT_SIGNAL}.{ROUTE_ACTION_COMMIT}" @@ -52,6 +56,7 @@ ROUTE_FOLLOWUP_CONTINUE = f"{ROUTE_FAMILY_FOLLOWUP}.{ROUTE_ACTION_CONTINUE}" WORKFLOW_ROUTE_KEYS = { ROUTE_QUERY_ANSWER, ROUTE_PROJECT_CREATE, + ROUTE_TASK_CREATE, ROUTE_PROJECT_SIGNAL_CREATE, ROUTE_PROJECT_SIGNAL_DISCOVER, ROUTE_PROJECT_SIGNAL_COMMIT, @@ -65,6 +70,7 @@ WORKFLOW_ROUTE_KEYS = { ROUTE_CANONICAL_INTENT_MAP: dict[str, str] = { ROUTE_QUERY_ANSWER: INTENT_QUERY, ROUTE_PROJECT_CREATE: INTENT_PROJECT_CREATE, + ROUTE_TASK_CREATE: INTENT_TASK_CREATE, ROUTE_PROJECT_SIGNAL_CREATE: INTENT_SIGNAL_CREATE, ROUTE_PROJECT_SIGNAL_DISCOVER: INTENT_SIGNAL_CREATE, ROUTE_PROJECT_SIGNAL_COMMIT: INTENT_SIGNAL_CREATE, @@ -78,12 +84,14 @@ ROUTE_CANONICAL_INTENT_MAP: dict[str, str] = { LEGACY_INTENT_ROUTE_MAP: dict[str, str] = { INTENT_QUERY: ROUTE_QUERY_ANSWER, INTENT_PROJECT_CREATE: ROUTE_PROJECT_CREATE, + INTENT_TASK_CREATE: ROUTE_TASK_CREATE, INTENT_SIGNAL_CREATE: ROUTE_PROJECT_SIGNAL_CREATE, INTENT_SIGNAL_SOURCE_ONBOARD: ROUTE_SIGNAL_SOURCE_ONBOARD, INTENT_FOLLOWUP_CONFIRM: ROUTE_FOLLOWUP_CONFIRM, INTENT_FOLLOWUP_CONTINUE: ROUTE_FOLLOWUP_CONTINUE, "query.answer": ROUTE_QUERY_ANSWER, "project_create.create": ROUTE_PROJECT_CREATE, + "task.create": ROUTE_TASK_CREATE, "project_signal.create": ROUTE_PROJECT_SIGNAL_CREATE, "project_signal.discover": ROUTE_PROJECT_SIGNAL_DISCOVER, "project_signal.commit": ROUTE_PROJECT_SIGNAL_COMMIT, @@ -154,6 +162,18 @@ FOLLOWUP_CONFIRM_HINTS = ("确认归属", "确认项目", "确认到项目", " FOLLOWUP_CONTINUE_HINTS = ("继续处理", "继续执行", "接着处理", "往下做", "继续推进") FOLLOWUP_WORK_ITEM_ID_RE = re.compile(r"(?:work[_ -]?item|工作项|队列项)\s*(?:id|ID)?\s*[:#:]?\s*(\d+)", re.IGNORECASE) SIGNAL_CREATE_HINTS = ("新增一个信号", "新增一条信号", "增加一个信号", "录入一个信号", "关注", "跟踪") +TASK_CREATE_HINTS = ( + "创建任务", + "新建任务", + "新增任务", + "增加任务", + "添加任务", + "加个任务", + "加一个任务", + "安排任务", + "分配任务", + "派个任务", +) PROJECT_CREATE_HINTS = ( "创建项目", "新建项目", @@ -193,6 +213,7 @@ SHORT_CONFIRM_HINTS = ( INTENT_LLM_FUNCTION_NAME = "classify_intent_ai_pmo_kernel" INTENT_CANDIDATES = ( INTENT_SIGNAL_SOURCE_ONBOARD, + INTENT_TASK_CREATE, INTENT_SIGNAL_CREATE, INTENT_QUERY, INTENT_PROJECT_CREATE, @@ -202,10 +223,10 @@ INTENT_CANDIDATES = ( ) WRITE_INTENTS = { INTENT_PROJECT_CREATE, + INTENT_TASK_CREATE, INTENT_SIGNAL_CREATE, INTENT_SIGNAL_SOURCE_ONBOARD, INTENT_FOLLOWUP_CONFIRM, INTENT_FOLLOWUP_CONTINUE, } INTENT_CONFLICT_POLICIES = {"pending_manual", "prefer_rule", "prefer_llm"} - diff --git a/backend/app/services/ai_pmo/ai_pmo_kernel/ai_pmo_reply_renderer.py b/backend/app/services/ai_pmo/ai_pmo_kernel/ai_pmo_reply_renderer.py index 43410595..cb5afa1b 100644 --- a/backend/app/services/ai_pmo/ai_pmo_kernel/ai_pmo_reply_renderer.py +++ b/backend/app/services/ai_pmo/ai_pmo_kernel/ai_pmo_reply_renderer.py @@ -9,6 +9,7 @@ from ..ai_pmo_intent import ( INTENT_FOLLOWUP_CONTINUE, INTENT_PROJECT_CREATE, INTENT_QUERY, + INTENT_TASK_CREATE, INTENT_SIGNAL_CREATE, INTENT_SIGNAL_SOURCE_ONBOARD, ROUTE_PROJECT_SIGNAL_CANCEL, @@ -151,6 +152,28 @@ class ReplyRenderer: "payload": {"intent": intent.name, "project_id": project_id, "project_title": project_title}, "text": f"已立项:项目#{project_id}《{project_title}》已创建完成。" if project_id else f"已立项:《{project_title}》已创建完成。", } + if intent.name == INTENT_TASK_CREATE: + task = execution_result.get("task") if isinstance(execution_result.get("task"), dict) else {} + task_id = _safe_int(execution_result.get("task_id")) or _safe_int(task.get("id")) + project_id = _safe_int(execution_result.get("project_id")) + task_title = str(task.get("title") or "").strip() or "新任务" + if task_id is not None and project_id is not None: + text = f"已创建任务:项目#{project_id} 任务#{task_id}《{task_title}》。" + elif task_id is not None: + text = f"已创建任务:任务#{task_id}《{task_title}》。" + else: + text = f"已创建任务:《{task_title}》。" + return { + "receipt_phase": self.receipt_phase_action, + "template_key": "action_succeeded", + "payload": { + "intent": intent.name, + "project_id": project_id, + "task_id": task_id, + "task_title": task_title, + }, + "text": text, + } if intent.name == INTENT_SIGNAL_SOURCE_ONBOARD: signal_id = _safe_int(execution_result.get("signal_id")) workflow = execution_result.get("signal_source_workflow") if isinstance(execution_result.get("signal_source_workflow"), dict) else {} diff --git a/backend/app/services/ai_pmo/ai_pmo_kernel/ai_pmo_typed_executor_common.py b/backend/app/services/ai_pmo/ai_pmo_kernel/ai_pmo_typed_executor_common.py index 4ecd2740..71c93d16 100644 --- a/backend/app/services/ai_pmo/ai_pmo_kernel/ai_pmo_typed_executor_common.py +++ b/backend/app/services/ai_pmo/ai_pmo_kernel/ai_pmo_typed_executor_common.py @@ -29,6 +29,7 @@ from .ai_pmo_domain_workflow import SignalSourceDomainWorkflow from ..ai_pmo_intent import ( INTENT_PROJECT_CREATE, INTENT_QUERY, + INTENT_TASK_CREATE, INTENT_SIGNAL_CREATE, INTENT_SIGNAL_SOURCE_ONBOARD, LEGACY_INTENT_ROUTE_MAP, @@ -36,6 +37,7 @@ from ..ai_pmo_intent import ( ROUTE_ACTION_DISCOVER, ROUTE_PROJECT_SIGNAL_CREATE, ROUTE_PROJECT_SIGNAL_DISCOVER, + ROUTE_TASK_CREATE, CanonicalIntent, IntentResolver, RouteResolver, diff --git a/backend/app/services/ai_pmo/ai_pmo_kernel/ai_pmo_workflow_runtime.py b/backend/app/services/ai_pmo/ai_pmo_kernel/ai_pmo_workflow_runtime.py index e423845b..ce35ad55 100644 --- a/backend/app/services/ai_pmo/ai_pmo_kernel/ai_pmo_workflow_runtime.py +++ b/backend/app/services/ai_pmo/ai_pmo_kernel/ai_pmo_workflow_runtime.py @@ -14,6 +14,7 @@ from ..ai_pmo_intent import ( ROUTE_FAMILY_PROJECT_SIGNAL, ROUTE_FAMILY_QUERY, ROUTE_FAMILY_SIGNAL_SOURCE, + ROUTE_FAMILY_TASK, CanonicalIntent, WorkflowRoute, _safe_int, @@ -162,6 +163,31 @@ class ProjectCreateWorkflowHandler(WorkflowHandler): ) +class TaskCreateWorkflowHandler(WorkflowHandler): + async def start( + self, + *, + route: WorkflowRoute, + intent: CanonicalIntent, + message_text: str, + sender: str, + operator_user_id: Optional[int], + source_system: str, + source_message_id: str, + channel_ref: Optional[str], + ) -> dict[str, object]: + _ = route + return await self.kernel.executor.execute( + intent=intent, + message_text=message_text, + sender=sender, + operator_user_id=operator_user_id, + source_system=source_system, + source_message_id=source_message_id, + channel_ref=channel_ref, + ) + + class SignalSourceWorkflowHandler(WorkflowHandler): async def start( self, @@ -229,6 +255,7 @@ class WorkflowRegistry: self._handlers: dict[str, WorkflowHandler] = { ROUTE_FAMILY_QUERY: QueryWorkflowHandler(kernel), ROUTE_FAMILY_PROJECT_CREATE: ProjectCreateWorkflowHandler(kernel), + ROUTE_FAMILY_TASK: TaskCreateWorkflowHandler(kernel), ROUTE_FAMILY_PROJECT_SIGNAL: ProjectSignalWorkflowHandler(kernel), ROUTE_FAMILY_SIGNAL_SOURCE: SignalSourceWorkflowHandler(kernel), ROUTE_FAMILY_FOLLOWUP: WorkflowHandler(kernel), diff --git a/backend/app/services/ai_pmo/ai_pmo_kernel/mixins/followup/ai_pmo_kernel_mixins_followup_project_signal_resume.py b/backend/app/services/ai_pmo/ai_pmo_kernel/mixins/followup/ai_pmo_kernel_mixins_followup_project_signal_resume.py index e50196ed..d94a5175 100644 --- a/backend/app/services/ai_pmo/ai_pmo_kernel/mixins/followup/ai_pmo_kernel_mixins_followup_project_signal_resume.py +++ b/backend/app/services/ai_pmo/ai_pmo_kernel/mixins/followup/ai_pmo_kernel_mixins_followup_project_signal_resume.py @@ -57,6 +57,7 @@ class KernelFollowupProjectSignalResumeMixin: ROUTE_FAMILY_PROJECT_SIGNAL, ROUTE_FAMILY_SIGNAL_SOURCE, ROUTE_FAMILY_PROJECT_CREATE, + ROUTE_FAMILY_TASK, }: reply_text = f"工作项#{int(target_work_item.id)} 当前类型为 {target_intent.name or 'unknown'},暂不支持继续处理。" return { diff --git a/backend/app/services/ai_pmo/ai_pmo_kernel/mixins/followup/ai_pmo_kernel_mixins_followup_routing.py b/backend/app/services/ai_pmo/ai_pmo_kernel/mixins/followup/ai_pmo_kernel_mixins_followup_routing.py index a9ee459f..a035e7ab 100644 --- a/backend/app/services/ai_pmo/ai_pmo_kernel/mixins/followup/ai_pmo_kernel_mixins_followup_routing.py +++ b/backend/app/services/ai_pmo/ai_pmo_kernel/mixins/followup/ai_pmo_kernel_mixins_followup_routing.py @@ -10,6 +10,44 @@ for _name, _value in vars(_common).items(): class KernelFollowupRoutingMixin: + async def _project_draft_pending_confirm( + self, + *, + work_item: WorkItem, + message_text: str, + draft_text: str, + intent: CanonicalIntent, + ) -> None: + await NotificationService(self.db).create_notifications( + tenant_id=self.tenant_id, + user_ids=await NotificationService(self.db)._resolve_recipients(tenant_id=self.tenant_id), + title="AI PMO 草稿待确认", + content=str(draft_text or message_text)[:4000], + category="decision", + source="ai_pmo", + entity_type="work_item", + entity_id=int(work_item.id), + target_url=f"/inbox?workItemId={int(work_item.id)}", + kind="action_required", + priority="high", + status="pending_manual", + recommended_action="view_detail", + dedupe_key=f"ai-pmo-draft:{int(work_item.id)}", + payload={ + "work_item_id": int(work_item.id), + "intent": intent.name, + "project_id": intent.project_id, + "message_text": message_text[:500], + "draft_version": int(work_item.draft_version or 0), + "draft_expires_at": ( + work_item.draft_expires_at.isoformat() + if isinstance(work_item.draft_expires_at, datetime) + else None + ), + }, + commit=False, + ) + async def _project_waiting_manual( self, *, @@ -168,10 +206,15 @@ class KernelFollowupRoutingMixin: "target_work_item_id": int(target_work_item.id), }, ) - elif target_family in {ROUTE_FAMILY_SIGNAL_SOURCE, ROUTE_FAMILY_PROJECT_CREATE}: + elif target_family in {ROUTE_FAMILY_SIGNAL_SOURCE, ROUTE_FAMILY_PROJECT_CREATE, ROUTE_FAMILY_TASK}: resolved_target_route = WorkflowRoute( family=target_family, - action=target_action or (ROUTE_ACTION_ONBOARD if target_family == ROUTE_FAMILY_SIGNAL_SOURCE else ROUTE_ACTION_CREATE), + action=target_action + or ( + ROUTE_ACTION_ONBOARD + if target_family == ROUTE_FAMILY_SIGNAL_SOURCE + else ROUTE_ACTION_CREATE + ), project_id=_safe_int(target_work_item.project_id), requested_count=1, requires_confirmation=True, diff --git a/backend/app/services/ai_pmo/ai_pmo_kernel/mixins/followup/ai_pmo_kernel_mixins_followup_verification.py b/backend/app/services/ai_pmo/ai_pmo_kernel/mixins/followup/ai_pmo_kernel_mixins_followup_verification.py index a78fa435..869f1faa 100644 --- a/backend/app/services/ai_pmo/ai_pmo_kernel/mixins/followup/ai_pmo_kernel_mixins_followup_verification.py +++ b/backend/app/services/ai_pmo/ai_pmo_kernel/mixins/followup/ai_pmo_kernel_mixins_followup_verification.py @@ -36,6 +36,8 @@ class KernelFollowupVerificationMixin: return {"verified": bool(str(execution_result.get("answer") or "").strip()), "issues": []} if intent.name == INTENT_PROJECT_CREATE: return {"verified": _safe_int(execution_result.get("project_id")) is not None, "issues": []} + if intent.name == INTENT_TASK_CREATE: + return {"verified": _safe_int(execution_result.get("task_id")) is not None, "issues": []} if intent.name in {INTENT_SIGNAL_CREATE, INTENT_SIGNAL_SOURCE_ONBOARD}: signal_ids = _normalize_signal_id_list(execution_result.get("signal_ids")) signal_id = _safe_int(execution_result.get("signal_id")) diff --git a/backend/app/services/ai_pmo/ai_pmo_kernel/mixins/ingress/ai_pmo_kernel_mixins_ingress_render.py b/backend/app/services/ai_pmo/ai_pmo_kernel/mixins/ingress/ai_pmo_kernel_mixins_ingress_render.py index 5dbc9782..aba7f514 100644 --- a/backend/app/services/ai_pmo/ai_pmo_kernel/mixins/ingress/ai_pmo_kernel_mixins_ingress_render.py +++ b/backend/app/services/ai_pmo/ai_pmo_kernel/mixins/ingress/ai_pmo_kernel_mixins_ingress_render.py @@ -57,6 +57,7 @@ class KernelIngressRenderMixin: "signal_id": primary_signal_id, "signal_ids": signal_ids, "created_project_id": _safe_int(execution_result.get("project_id")) if intent.name == INTENT_PROJECT_CREATE else None, + "created_task_id": _safe_int(execution_result.get("task_id")) if intent.name == INTENT_TASK_CREATE else None, "auto_tasks": execution_result.get("auto_tasks") if isinstance(execution_result.get("auto_tasks"), list) else execution_result.get("tasks", []), "ai_trace_id": f"ai-pmo-{int(work_item.id)}", "results": [ @@ -64,7 +65,11 @@ class KernelIngressRenderMixin: "work_item_id": int(work_item.id), "state": work_item.state, "intent": intent.name, - "route_status": "queued_for_decision" if work_item.state == STATE_WAITING_MANUAL else "auto_done", + "route_status": ( + "queued_for_decision" + if work_item.state in {STATE_WAITING_MANUAL, STATE_DRAFT_PENDING_CONFIRM} + else "auto_done" + ), } ], "intent": intent.name, @@ -81,6 +86,12 @@ class KernelIngressRenderMixin: else ((intent.metadata or {}).get("decision_trace") if isinstance(intent.metadata, dict) else None) ), } + if str(render.get("template_key") or "").strip().lower() == "draft_pending_confirm": + payload = render.get("payload") if isinstance(render.get("payload"), dict) else {} + result["draft_version"] = _safe_int(payload.get("draft_version")) or _safe_int(getattr(work_item, "draft_version", None)) + draft_expires_at = payload.get("draft_expires_at") + if isinstance(draft_expires_at, str) and draft_expires_at.strip(): + result["draft_expires_at"] = draft_expires_at.strip() if intent.name == INTENT_QUERY and isinstance(execution_result.get("rag_result"), dict): result["rag_result"] = execution_result["rag_result"] if intent.name == INTENT_PROJECT_CREATE: @@ -99,6 +110,8 @@ class KernelIngressRenderMixin: return "query.answer" if normalized_route == ROUTE_PROJECT_CREATE: return "project.create" + if normalized_route == ROUTE_TASK_CREATE: + return "task.create" if normalized_route in {ROUTE_PROJECT_SIGNAL_CREATE, ROUTE_PROJECT_SIGNAL_DISCOVER, ROUTE_PROJECT_SIGNAL_COMMIT}: if normalized_route == ROUTE_PROJECT_SIGNAL_DISCOVER: return ROUTE_PROJECT_SIGNAL_DISCOVER @@ -117,6 +130,8 @@ class KernelIngressRenderMixin: return "query.answer" if intent_name == INTENT_PROJECT_CREATE: return "project.create" + if intent_name == INTENT_TASK_CREATE: + return "task.create" if intent_name == INTENT_SIGNAL_CREATE: return "signal.enter" if intent_name == INTENT_SIGNAL_SOURCE_ONBOARD: @@ -129,7 +144,7 @@ class KernelIngressRenderMixin: def _risk_level_for_intent(intent_name: str) -> str: if intent_name in {INTENT_PROJECT_CREATE, INTENT_SIGNAL_SOURCE_ONBOARD}: return "high" - if intent_name in {INTENT_SIGNAL_CREATE, INTENT_FOLLOWUP_CONFIRM, INTENT_FOLLOWUP_CONTINUE}: + if intent_name in {INTENT_TASK_CREATE, INTENT_SIGNAL_CREATE, INTENT_FOLLOWUP_CONFIRM, INTENT_FOLLOWUP_CONTINUE}: return "medium" return "low" diff --git a/backend/app/services/ai_pmo/ai_pmo_kernel/mixins/ingress/ai_pmo_kernel_mixins_ingress_storage.py b/backend/app/services/ai_pmo/ai_pmo_kernel/mixins/ingress/ai_pmo_kernel_mixins_ingress_storage.py index 2e2cc63d..c8580c4d 100644 --- a/backend/app/services/ai_pmo/ai_pmo_kernel/mixins/ingress/ai_pmo_kernel_mixins_ingress_storage.py +++ b/backend/app/services/ai_pmo/ai_pmo_kernel/mixins/ingress/ai_pmo_kernel_mixins_ingress_storage.py @@ -10,6 +10,254 @@ for _name, _value in vars(_common).items(): class KernelIngressStorageMixin: + @staticmethod + def _normalize_execution_ledger_status( + *, + work_item_state: Optional[str], + execution_status: Optional[str], + ) -> str: + state = str(work_item_state or "").strip().lower() + status = str(execution_status or "").strip().lower() + if state == STATE_SUCCEEDED: + return "applied" + if state == STATE_DRAFT_PENDING_CONFIRM: + return "pending_confirmation" + if state == STATE_WAITING_MANUAL: + return "pending_manual" + if state in {STATE_FAILED, STATE_IGNORED}: + return "failed" + if status in {"succeeded", "applied", "answered"}: + return "applied" + if status in {"pending_manual", "pending_confirmation"}: + return status + if status in {"failed", "failed_safe", "error"}: + return "failed" + return "processing" + + @staticmethod + def _build_execution_ledger_idempotency_key( + *, + tenant_id: int, + work_item_id: int, + operation_code: str, + source_message_id: str, + ) -> str: + raw = ( + f"{int(tenant_id)}:" + f"{int(work_item_id)}:" + f"{str(operation_code or '').strip().lower()}:" + f"{str(source_message_id or '').strip().lower()}" + ) + return hashlib.sha1(raw.encode("utf-8"), usedforsecurity=False).hexdigest()[:64] + + @staticmethod + def _resolve_execution_ledger_entity( + *, + operation_code: str, + execution_result: dict[str, Any], + ) -> tuple[Optional[str], Optional[str]]: + op = str(operation_code or "").strip().lower() + if op == "project.create": + entity_id = _safe_int(execution_result.get("project_id")) + return ("project", str(entity_id)) if entity_id is not None else ("project", None) + if op == "task.create": + entity_id = _safe_int(execution_result.get("task_id")) + return ("task", str(entity_id)) if entity_id is not None else ("task", None) + if op in {"signal.enter", ROUTE_PROJECT_SIGNAL_COMMIT, ROUTE_PROJECT_SIGNAL_CREATE}: + entity_id = _safe_int(execution_result.get("signal_id")) + return ("signal", str(entity_id)) if entity_id is not None else ("signal", None) + if op in {"signal_source.onboard", ROUTE_SIGNAL_SOURCE_ONBOARD}: + entity_id = _safe_int(execution_result.get("signal_id")) + return ("signal_source", str(entity_id)) if entity_id is not None else ("signal_source", None) + if op == "work_item.resume": + entity_id = _safe_int(execution_result.get("target_work_item_id")) + return ("work_item", str(entity_id)) if entity_id is not None else ("work_item", None) + return (None, None) + + def _build_execution_action_contract( + self, + *, + intent: CanonicalIntent, + workflow_route: WorkflowRoute, + decision_payload: dict[str, Any], + execution_result: dict[str, Any], + source_system: str, + source_message_id: str, + channel_ref: Optional[str], + ) -> dict[str, Any]: + execution_plan = decision_payload.get("execution_plan") if isinstance(decision_payload.get("execution_plan"), dict) else {} + actions = execution_plan.get("actions") if isinstance(execution_plan.get("actions"), list) else [] + route_key = str(getattr(workflow_route, "key", "") or "").strip().lower() + operation_code = str( + self._primary_operation_code(route_key or str(intent.name or "")) + or "" + ).strip().lower() + selected_action: dict[str, Any] = {} + for action in actions: + if not isinstance(action, dict): + continue + current_code = str(action.get("operation_code") or "").strip().lower() + if current_code and current_code == operation_code: + selected_action = action + break + if not selected_action: + selected_action = actions[0] if actions and isinstance(actions[0], dict) else {} + if not operation_code: + operation_code = str(selected_action.get("operation_code") or "").strip().lower() + mode = str(selected_action.get("mode") or "").strip().lower() + if not mode: + mode = "read_only" if str(intent.name or "") == INTENT_QUERY else "write" + + signal_ids = _normalize_signal_id_list(execution_result.get("signal_ids")) + if not signal_ids and _safe_int(execution_result.get("signal_id")) is not None: + signal_ids = [_safe_int(execution_result.get("signal_id"))] + params: dict[str, Any] = { + "project_id": ( + _safe_int(execution_result.get("project_id")) + or _safe_int(getattr(workflow_route, "project_id", None)) + or _safe_int(execution_plan.get("project_id")) + or _safe_int(intent.project_id) + ), + "requested_count": ( + _safe_int(execution_result.get("requested_count")) + or _safe_int(getattr(workflow_route, "requested_count", None)) + ), + "signal_id": _safe_int(execution_result.get("signal_id")), + "signal_ids": signal_ids, + "task_id": _safe_int(execution_result.get("task_id")), + "target_work_item_id": _safe_int(execution_result.get("target_work_item_id")), + "source_system": str(source_system or "").strip() or None, + "source_message_id": str(source_message_id or "").strip() or None, + "channel_ref": str(channel_ref or "").strip() or None, + } + normalized_params = { + key: value + for key, value in params.items() + if value not in (None, "", []) + } + return { + "operation_code": operation_code or None, + "mode": mode, + "workflow_route": route_key or None, + "workflow_action": str(getattr(workflow_route, "action", "") or "").strip().lower() or None, + "param_source": "decision.execution_plan.actions+workflow_route+execution_result", + "params": normalized_params, + } + + def _apply_execution_action_contract( + self, + *, + intent: CanonicalIntent, + workflow_route: WorkflowRoute, + decision_payload: dict[str, Any], + execution_result: dict[str, Any], + source_system: str, + source_message_id: str, + channel_ref: Optional[str], + ) -> dict[str, Any]: + normalized_result = dict(execution_result or {}) + action_contract = self._build_execution_action_contract( + intent=intent, + workflow_route=workflow_route, + decision_payload=decision_payload, + execution_result=normalized_result, + source_system=source_system, + source_message_id=source_message_id, + channel_ref=channel_ref, + ) + normalized_result["action_contract"] = action_contract + normalized_result["action_param_source"] = action_contract["param_source"] + operation_code = str(action_contract.get("operation_code") or "").strip().lower() + if operation_code and not str(normalized_result.get("operation_code") or "").strip(): + normalized_result["operation_code"] = operation_code + return normalized_result + + async def _upsert_operation_execution_ledger( + self, + *, + work_item_id: int, + operation_code: Optional[str], + source_message_id: str, + project_id: Optional[int], + status: str, + payload: dict[str, Any], + execution_result: dict[str, Any], + error_message: Optional[str] = None, + ) -> Optional[OperationExecutionLedger]: + normalized_code = str(operation_code or "").strip().lower() + if not normalized_code: + return None + idempotency_key = self._build_execution_ledger_idempotency_key( + tenant_id=int(self.tenant_id), + work_item_id=int(work_item_id), + operation_code=normalized_code, + source_message_id=source_message_id, + ) + ledger = ( + await self.db.execute( + select(OperationExecutionLedger).where( + and_( + OperationExecutionLedger.tenant_id == int(self.tenant_id), + OperationExecutionLedger.operation_code == normalized_code, + OperationExecutionLedger.idempotency_key == idempotency_key, + ) + ).limit(1) + ) + ).scalar_one_or_none() + entity_type, entity_id = self._resolve_execution_ledger_entity( + operation_code=normalized_code, + execution_result=execution_result if isinstance(execution_result, dict) else {}, + ) + normalized_payload = payload if isinstance(payload, dict) else {"payload": payload} + normalized_result = ( + execution_result + if isinstance(execution_result, dict) + else {"result": execution_result} + ) + normalized_status = self._normalize_execution_ledger_status( + work_item_state=None, + execution_status=status, + ) + if ledger is None: + ledger = OperationExecutionLedger( + tenant_id=int(self.tenant_id), + operation_code=normalized_code, + project_id=_safe_int(project_id), + idempotency_key=idempotency_key, + trace_id=f"work_item:{int(work_item_id)}", + status=normalized_status, + payload=normalized_payload, + result_payload=normalized_result, + entity_type=entity_type, + entity_id=entity_id, + error_message=(str(error_message or "").strip() or None), + ) + self.db.add(ledger) + else: + ledger.project_id = _safe_int(project_id) + ledger.status = normalized_status + ledger.payload = normalized_payload + ledger.result_payload = normalized_result + ledger.entity_type = entity_type + ledger.entity_id = entity_id + ledger.error_message = (str(error_message or "").strip() or None) + await self.db.flush() + return ledger + + @staticmethod + def _extract_execution_operation_code(execution_result: dict[str, Any]) -> Optional[str]: + action_contract = ( + execution_result.get("action_contract") + if isinstance(execution_result.get("action_contract"), dict) + else {} + ) + operation_code = str( + action_contract.get("operation_code") + or execution_result.get("operation_code") + or "" + ).strip().lower() + return operation_code or None + async def _claim_ingress_event( self, *, @@ -204,4 +452,3 @@ class KernelIngressStorageMixin: ) ) await self.db.flush() - diff --git a/backend/app/services/ai_pmo/ai_pmo_kernel/mixins/intent/ai_pmo_kernel_mixins_intent_llm.py b/backend/app/services/ai_pmo/ai_pmo_kernel/mixins/intent/ai_pmo_kernel_mixins_intent_llm.py index 583fe361..dbdd1e29 100644 --- a/backend/app/services/ai_pmo/ai_pmo_kernel/mixins/intent/ai_pmo_kernel_mixins_intent_llm.py +++ b/backend/app/services/ai_pmo/ai_pmo_kernel/mixins/intent/ai_pmo_kernel_mixins_intent_llm.py @@ -30,6 +30,7 @@ class KernelIntentLLMMixin: [ ROUTE_QUERY_ANSWER, ROUTE_PROJECT_CREATE, + ROUTE_TASK_CREATE, ROUTE_PROJECT_SIGNAL_CREATE, ROUTE_PROJECT_SIGNAL_DISCOVER, ROUTE_SIGNAL_SOURCE_ONBOARD, diff --git a/backend/app/services/ai_pmo/ai_pmo_kernel/mixins/intent/ai_pmo_kernel_mixins_intent_resolve.py b/backend/app/services/ai_pmo/ai_pmo_kernel/mixins/intent/ai_pmo_kernel_mixins_intent_resolve.py index 0fd48b78..404c2903 100644 --- a/backend/app/services/ai_pmo/ai_pmo_kernel/mixins/intent/ai_pmo_kernel_mixins_intent_resolve.py +++ b/backend/app/services/ai_pmo/ai_pmo_kernel/mixins/intent/ai_pmo_kernel_mixins_intent_resolve.py @@ -16,15 +16,20 @@ class KernelIntentResolveMixin: message_text: str, session: Optional[ConversationSession], world_model: dict[str, Any], + message_sent_at: Optional[Any] = None, + session_snapshot: Optional[dict[str, Any]] = None, ) -> tuple[CanonicalIntent, str]: if self._is_short_confirmation_message( message_text=message_text, session=session, + message_sent_at=message_sent_at, + session_snapshot=session_snapshot, ) and session is not None: inherited = self._build_short_confirm_intent( message_text=message_text, session=session, world_model=world_model, + session_snapshot=session_snapshot, ) logger.info( "ai_pmo_short_confirm_inherited", diff --git a/backend/app/services/ai_pmo/ai_pmo_kernel/mixins/intent/ai_pmo_kernel_mixins_intent_rules.py b/backend/app/services/ai_pmo/ai_pmo_kernel/mixins/intent/ai_pmo_kernel_mixins_intent_rules.py index 36d430db..e202035a 100644 --- a/backend/app/services/ai_pmo/ai_pmo_kernel/mixins/intent/ai_pmo_kernel_mixins_intent_rules.py +++ b/backend/app/services/ai_pmo/ai_pmo_kernel/mixins/intent/ai_pmo_kernel_mixins_intent_rules.py @@ -169,15 +169,30 @@ class KernelIntentRulesMixin: @staticmethod def _has_required_slots(*, intent: CanonicalIntent, message_text: str) -> tuple[bool, dict[str, Any]]: - if intent.name != INTENT_PROJECT_CREATE: - return True, {} - extracted_title = AIStewardService._extract_project_title_from_instruction(message_text) - has_title = bool(str(extracted_title or "").strip()) - return has_title, { - "slot_check": "project_title", - "slot_ok": has_title, - "extracted_project_title": extracted_title[:50] if extracted_title else "", - } + if intent.name == INTENT_PROJECT_CREATE: + extracted_title = AIStewardService._extract_project_title_from_instruction(message_text) + has_title = bool(str(extracted_title or "").strip()) + return has_title, { + "slot_check": "project_title", + "slot_ok": has_title, + "extracted_project_title": extracted_title[:50] if extracted_title else "", + } + if intent.name == INTENT_TASK_CREATE: + extracted_task_title = IntentResolver._extract_task_title(message_text) + has_task_title = bool(str(extracted_task_title or "").strip()) + has_project_scope = _safe_int(intent.project_id) is not None + missing_fields: list[str] = [] + if not has_project_scope: + missing_fields.append("project_id") + if not has_task_title: + missing_fields.append("task_title") + return not missing_fields, { + "slot_check": "task_create_required_fields", + "slot_ok": not missing_fields, + "missing_fields": missing_fields, + "extracted_task_title": extracted_task_title[:80] if extracted_task_title else "", + } + return True, {} def _apply_slot_guard(self, *, intent: CanonicalIntent, message_text: str) -> CanonicalIntent: slot_ok, slot_meta = self._has_required_slots(intent=intent, message_text=message_text) @@ -259,15 +274,22 @@ class KernelIntentRulesMixin: *, message_text: str, session: Optional[ConversationSession], + message_sent_at: Optional[Any] = None, + session_snapshot: Optional[dict[str, Any]] = None, ) -> bool: - if session is None: + snapshot = session_snapshot if isinstance(session_snapshot, dict) else {} + if session is None and not snapshot: return False - if not cls._within_recent_window( - getattr(session, "last_active_at", None), - window_seconds=SESSION_SHORT_CONFIRM_WINDOW_SECONDS, - ): + last_active_at = cls._coerce_naive_datetime(snapshot.get("last_active_at")) + if last_active_at is None and session is not None: + last_active_at = cls._coerce_naive_datetime(getattr(session, "last_active_at", None)) + sent_at = cls._coerce_naive_datetime(message_sent_at) or _utc_now_naive() + if last_active_at is None: return False - last_intent = str(getattr(session, "last_intent", "") or "").strip() + delta = sent_at - last_active_at + if not (0 <= delta.total_seconds() <= float(SESSION_SHORT_CONFIRM_WINDOW_SECONDS)): + return False + last_intent = str(snapshot.get("last_intent") or (getattr(session, "last_intent", "") if session is not None else "") or "").strip() if not last_intent: return False normalized = re.sub(r"\s+", "", str(message_text or "")).lower() @@ -277,6 +299,23 @@ class KernelIntentRulesMixin: return False return any(token in normalized for token in SHORT_CONFIRM_HINTS) + @staticmethod + def _coerce_naive_datetime(value: Any) -> Optional[datetime]: + if isinstance(value, datetime): + if value.tzinfo is None: + return value + return value.astimezone().replace(tzinfo=None) + text = str(value or "").strip() + if not text: + return None + try: + parsed = datetime.fromisoformat(text.replace("Z", "+00:00")) + except Exception: + return None + if parsed.tzinfo is None: + return parsed + return parsed.astimezone().replace(tzinfo=None) + @staticmethod def _build_canonical_intent( *, @@ -317,6 +356,19 @@ class KernelIntentRulesMixin: pretty[:300], extras, ) + if intent_name == INTENT_TASK_CREATE: + task_title = str(extras.get("task_title") or IntentResolver._extract_task_title(pretty) or "").strip() + if not task_title: + task_title = IntentResolver._build_signal_title(pretty, default="新任务") + extras.setdefault("task_title", task_title[:120]) + return CanonicalIntent( + INTENT_TASK_CREATE, + safe_confidence, + project_id, + task_title[:120], + pretty[:300], + extras, + ) if intent_name == INTENT_QUERY: return CanonicalIntent(INTENT_QUERY, safe_confidence, project_id, "", pretty[:300], extras) if intent_name == INTENT_FOLLOWUP_CONFIRM: @@ -331,14 +383,25 @@ class KernelIntentRulesMixin: message_text: str, session: ConversationSession, world_model: dict[str, Any], + session_snapshot: Optional[dict[str, Any]] = None, ) -> CanonicalIntent: - inherited_name = str(getattr(session, "last_intent", "") or "").strip() + snapshot = session_snapshot if isinstance(session_snapshot, dict) else {} + inherited_name = str(snapshot.get("last_intent") or getattr(session, "last_intent", "") or "").strip() if inherited_name not in INTENT_CANDIDATES: inherited_name = INTENT_QUERY - inherited_project_id = _safe_int(getattr(session, "current_project_id", None)) or _safe_int(world_model.get("resolved_project_id")) + snapshot_project_id = _safe_int(snapshot.get("current_project_id")) + inherited_project_id = ( + snapshot_project_id + or _safe_int(getattr(session, "current_project_id", None)) + or _safe_int(world_model.get("resolved_project_id")) + ) if inherited_project_id is not None and int(inherited_project_id) <= 0: inherited_project_id = None - inherited_text = str(getattr(session, "last_message_text", "") or "").strip() or message_text + inherited_text = ( + str(snapshot.get("last_message_text") or "").strip() + or str(getattr(session, "last_message_text", "") or "").strip() + or message_text + ) return self._build_canonical_intent( intent_name=inherited_name, message_text=inherited_text, @@ -346,6 +409,6 @@ class KernelIntentRulesMixin: confidence=0.86, metadata={ "context_source": "session_inherited", - "inherited_from_intent": str(getattr(session, "last_intent", "") or "").strip(), + "inherited_from_intent": str(snapshot.get("last_intent") or getattr(session, "last_intent", "") or "").strip(), }, ) diff --git a/backend/app/services/ai_pmo/ai_pmo_kernel_common_core/constants.py b/backend/app/services/ai_pmo/ai_pmo_kernel_common_core/constants.py index b891b50e..73158e7c 100644 --- a/backend/app/services/ai_pmo/ai_pmo_kernel_common_core/constants.py +++ b/backend/app/services/ai_pmo/ai_pmo_kernel_common_core/constants.py @@ -10,9 +10,11 @@ STATE_DECIDED = "decided" STATE_PLANNED = "planned" STATE_EXECUTING = "executing" STATE_WAITING_MANUAL = "waiting_manual" +STATE_DRAFT_PENDING_CONFIRM = "draft_pending_confirm" STATE_SUCCEEDED = "succeeded" STATE_FAILED = "failed" STATE_IGNORED = "ignored" +STATE_CANCELLED = "cancelled" RECEIPT_PHASE_ACTION = "action_applied" RECEIPT_PHASE_PENDING = "pending_manual" 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 fa2bc2cf..4550cb8a 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 @@ -83,6 +83,7 @@ from ..ai_pmo_intent import ( INTENT_LLM_FUNCTION_NAME, INTENT_PROJECT_CREATE, INTENT_QUERY, + INTENT_TASK_CREATE, INTENT_SIGNAL_CREATE, INTENT_SIGNAL_SOURCE_ONBOARD, LEGACY_INTENT_ROUTE_MAP, @@ -100,6 +101,7 @@ from ..ai_pmo_intent import ( ROUTE_FAMILY_PROJECT_SIGNAL, ROUTE_FAMILY_QUERY, ROUTE_FAMILY_SIGNAL_SOURCE, + ROUTE_FAMILY_TASK, ROUTE_FOLLOWUP_CONFIRM, ROUTE_FOLLOWUP_CONTINUE, ROUTE_PROJECT_CREATE, @@ -110,6 +112,7 @@ from ..ai_pmo_intent import ( ROUTE_PROJECT_SIGNAL_REFRESH, ROUTE_QUERY_ANSWER, ROUTE_SIGNAL_SOURCE_ONBOARD, + ROUTE_TASK_CREATE, SESSION_SHORT_CONFIRM_WINDOW_SECONDS, SHORT_CONFIRM_HINTS, SHORT_CONFIRM_MAX_MESSAGE_LENGTH, diff --git a/backend/app/services/ai_pmo/ai_pmo_kernel_ingress_flow/mixin_auto.py b/backend/app/services/ai_pmo/ai_pmo_kernel_ingress_flow/mixin_auto.py index 4a1c5380..32261c4e 100644 --- a/backend/app/services/ai_pmo/ai_pmo_kernel_ingress_flow/mixin_auto.py +++ b/backend/app/services/ai_pmo/ai_pmo_kernel_ingress_flow/mixin_auto.py @@ -1,6 +1,7 @@ from __future__ import annotations from .shared import * +from ..ai_pmo_kernel_common_core.helpers import _safe_int as _safe_int class KernelIngressFlowAutoMixin: async def _execute_auto_ingress_flow( @@ -29,6 +30,153 @@ class KernelIngressFlowAutoMixin: work_item.state = STATE_PLANNED work_item.action_bundle = decision_payload["execution_plan"] work_item.state = STATE_EXECUTING + if self._should_enter_draft_confirmation(intent=intent, workflow_route=workflow_route): + from ...signal.draft_generator_service import DraftGeneratorService + + stage_started_at = time.perf_counter() + generator = DraftGeneratorService(self.db, tenant_id=self.tenant_id) + generated = await generator.generate_and_persist( + work_item=work_item, + intent_name=intent.name, + message_text=message_text, + world_model=world_model, + ) + log_perf_fn("DraftGenerator.generate", stage_started_at) + + execution_result = work_item.execution_result if isinstance(work_item.execution_result, dict) else {} + execution_result.setdefault("decision_trace", decision_trace) + execution_result.setdefault("workflow_route", workflow_route.key) + execution_result.setdefault("workflow_action", workflow_route.action) + execution_result = self._apply_execution_action_contract( + intent=intent, + workflow_route=workflow_route, + decision_payload=decision_payload, + execution_result=execution_result, + source_system=source_system, + source_message_id=source_message_id, + channel_ref=channel_ref, + ) + work_item.execution_result = execution_result + work_item.state = STATE_DRAFT_PENDING_CONFIRM + work_item.reply_template_key = "draft_pending_confirm" + + verification = {"verified": False, "issues": [STATE_DRAFT_PENDING_CONFIRM]} + work_item.review_summary = await self.review_service.review_work_item( + work_item=work_item, + intent_name=intent.name, + execution_result=execution_result, + world_model=world_model, + verification=verification, + simulation=decision_payload["simulation"], + ) + await self._maybe_emit_risk_cadence_snapshot( + project_id=_safe_int(work_item.project_id), + review_summary=work_item.review_summary, + ) + + decision_row.execution_result = execution_result + decision_row.final_outcome = STATE_DRAFT_PENDING_CONFIRM + operation_code_for_log = ( + self._extract_execution_operation_code(execution_result) + or self._primary_operation_code(str(work_item.intent or intent.name)) + ) + await self._upsert_operation_execution_ledger( + work_item_id=int(work_item.id), + operation_code=operation_code_for_log, + source_message_id=source_message_id, + project_id=_safe_int(work_item.project_id), + status=self._normalize_execution_ledger_status( + work_item_state=STATE_DRAFT_PENDING_CONFIRM, + execution_status=str(execution_result.get("status") or ""), + ), + payload={ + "work_item_id": int(work_item.id), + "workflow_route": workflow_route.key, + "workflow_action": workflow_route.action, + "source_system": source_system, + "source_message_id": source_message_id, + "action_contract": execution_result.get("action_contract"), + }, + execution_result={ + **execution_result, + "work_item_state": STATE_DRAFT_PENDING_CONFIRM, + }, + ) + + draft_text = str(generated.get("draft_text") or "").strip() + render = { + "receipt_phase": RECEIPT_PHASE_PENDING, + "template_key": "draft_pending_confirm", + "payload": { + "intent": intent.name, + "draft_version": int(generated.get("draft_version") or 0), + "draft_expires_at": ( + generated["draft_expires_at"].isoformat() + if isinstance(generated.get("draft_expires_at"), datetime) + else None + ), + }, + "text": draft_text or "已生成草稿,请确认后入库。", + } + await self._project_draft_pending_confirm( + work_item=work_item, + message_text=message_text, + draft_text=str(render["text"]), + intent=intent, + ) + + direct_reply_required = await self._claim_reply_receipt( + work_item_id=int(work_item.id), + source_system=source_system, + source_message_id=source_message_id, + receipt_phase=str(render["receipt_phase"]), + template_key=str(render["template_key"]), + payload=render["payload"], + ) + ingest_event.status = "done" + ingest_event.processed_at = _utc_now_naive() + await self._write_processing_log( + source_message_id=source_message_id, + work_item=work_item, + message_text=message_text, + route=f"ai_pmo.{STATE_DRAFT_PENDING_CONFIRM}", + operation_code=operation_code_for_log, + operation_result=execution_result, + processing_status="success", + duration_ms=int((time.perf_counter() - started) * 1000), + ) + final_project_id = _safe_int(work_item.project_id) or resolved_project_for_session + await self._update_session( + tenant_id=self.tenant_id, + sender=sender, + current_project_id=final_project_id, + last_intent=STATE_DRAFT_PENDING_CONFIRM, + last_message_text=message_text[:200], + ) + try: + await self.db.commit() + except Exception as exc: + await self._persist_kernel_failure_with_fresh_session( + work_item_id=_safe_int(getattr(work_item, "id", None)), + ingest_event_id=_safe_int(getattr(ingest_event, "id", None)), + error_message=str(exc), + source_system=source_system, + source_message_id=source_message_id, + ) + raise + stage_started_at = time.perf_counter() + capture_result = self._build_capture_result( + work_item=work_item, + intent=intent, + execution_result=execution_result, + render=render, + direct_reply_required=direct_reply_required, + status="captured", + classification="command", + ) + log_perf_fn("_build_capture_result", stage_started_at) + return capture_result + if intent.name in {INTENT_FOLLOWUP_CONFIRM, INTENT_FOLLOWUP_CONTINUE}: stage_started_at = time.perf_counter() execution_result, verification = await self._execute_followup_intent( @@ -67,6 +215,15 @@ class KernelIngressFlowAutoMixin: resolved_route = str(execution_result.get("resolved_route") or "").strip().lower() if resolved_route: work_item.intent = self.route_resolver.normalize_route_key(resolved_route) + execution_result = self._apply_execution_action_contract( + intent=intent, + workflow_route=workflow_route, + decision_payload=decision_payload, + execution_result=execution_result, + source_system=source_system, + source_message_id=source_message_id, + channel_ref=channel_ref, + ) work_item.execution_result = execution_result if _safe_int(execution_result.get("project_id")) is not None: work_item.project_id = _safe_int(execution_result.get("project_id")) @@ -97,6 +254,37 @@ class KernelIngressFlowAutoMixin: else: work_item.state = STATE_FAILED decision_row.final_outcome = work_item.state + operation_code_for_log = ( + self._extract_execution_operation_code(execution_result) + or self._primary_operation_code(str(work_item.intent or intent.name)) + ) + await self._upsert_operation_execution_ledger( + work_item_id=int(work_item.id), + operation_code=operation_code_for_log, + source_message_id=source_message_id, + project_id=_safe_int(work_item.project_id), + status=self._normalize_execution_ledger_status( + work_item_state=str(work_item.state or ""), + execution_status=str(execution_result.get("status") or ""), + ), + payload={ + "work_item_id": int(work_item.id), + "workflow_route": workflow_route.key, + "workflow_action": workflow_route.action, + "source_system": source_system, + "source_message_id": source_message_id, + "action_contract": execution_result.get("action_contract"), + }, + execution_result={ + **execution_result, + "work_item_state": str(work_item.state or ""), + }, + error_message=( + str(execution_result.get("error_message") or "").strip() + if str(work_item.state or "") == STATE_FAILED + else None + ), + ) render = self.reply_renderer.render( work_state=work_item.state, @@ -133,7 +321,7 @@ class KernelIngressFlowAutoMixin: work_item=work_item, message_text=message_text, route=f"ai_pmo.{work_item.intent or intent.name}", - operation_code=self._primary_operation_code(str(work_item.intent or intent.name)), + operation_code=operation_code_for_log, operation_result=execution_result, processing_status="success" if work_item.state in {STATE_SUCCEEDED, STATE_WAITING_MANUAL} else "failed", duration_ms=int((time.perf_counter() - started) * 1000), @@ -206,6 +394,14 @@ class KernelIngressFlowAutoMixin: log_perf_fn("_build_capture_result", stage_started_at) return capture_result + @staticmethod + def _should_enter_draft_confirmation(*, intent: CanonicalIntent, workflow_route: Any) -> bool: + if intent.name not in {INTENT_PROJECT_CREATE, INTENT_TASK_CREATE, INTENT_SIGNAL_CREATE, INTENT_SIGNAL_SOURCE_ONBOARD}: + return False + if str(getattr(workflow_route, "family", "") or "") == ROUTE_FAMILY_PROJECT_SIGNAL: + return str(getattr(workflow_route, "action", "") or "") == ROUTE_ACTION_CREATE + return True + @staticmethod def _build_capacity_warning_text( *, diff --git a/backend/app/services/ai_pmo/ai_pmo_kernel_ingress_flow/mixin_entry.py b/backend/app/services/ai_pmo/ai_pmo_kernel_ingress_flow/mixin_entry.py index 6977bef0..5151df2e 100644 --- a/backend/app/services/ai_pmo/ai_pmo_kernel_ingress_flow/mixin_entry.py +++ b/backend/app/services/ai_pmo/ai_pmo_kernel_ingress_flow/mixin_entry.py @@ -96,6 +96,8 @@ class KernelIngressFlowEntryMixin: source_sender_open_id: Optional[str] = None, captured_at: Optional[datetime] = None, chat_type: Optional[str] = None, + message_sent_at: Optional[Any] = None, + session_snapshot: Optional[dict[str, Any]] = None, ) -> dict[str, Any]: return await self.process_ingress( source_system="feishu", @@ -106,6 +108,8 @@ class KernelIngressFlowEntryMixin: source_sender_open_id=source_sender_open_id, captured_at=captured_at, chat_type=chat_type, + message_sent_at=message_sent_at, + session_snapshot=session_snapshot, ) async def process_ingress( @@ -120,6 +124,9 @@ class KernelIngressFlowEntryMixin: captured_at: Optional[datetime] = None, chat_type: Optional[str] = None, project_hint_id: Optional[int] = None, + message_sent_at: Optional[Any] = None, + session_snapshot: Optional[dict[str, Any]] = None, + ingress_options: Optional[dict[str, Any]] = None, ) -> dict[str, Any]: started = time.perf_counter() perf_work_item_id: Optional[int] = None @@ -153,10 +160,27 @@ class KernelIngressFlowEntryMixin: operator = ingress_prepare["operator"] work_item = ingress_prepare["work_item"] session = ingress_prepare["session"] + if str(getattr(session, "last_intent", "") or "").strip() == STATE_DRAFT_PENDING_CONFIRM: + draft_capture = await self._maybe_handle_draft_confirmation_ingress( + work_item=work_item, + ingest_event=ingest_event, + session=session, + synthetic_message_id=synthetic_message_id, + source_system=source_system, + source_sender_open_id=source_sender_open_id, + sender=sender, + channel_ref=channel_ref, + message_text=clean_text, + started=started, + ) + if draft_capture is not None: + return draft_capture mixed_meta: dict[str, Any] = {} if not self._is_short_confirmation_message( message_text=clean_text, session=session, + message_sent_at=message_sent_at, + session_snapshot=session_snapshot, ): clean_text, mixed_meta = await self._prepare_mixed_sidecar_context( source_system=source_system, @@ -182,6 +206,8 @@ class KernelIngressFlowEntryMixin: message_text=clean_text, session=session, world_model=world_model, + message_sent_at=message_sent_at, + session_snapshot=session_snapshot, ) intent = self._enrich_signal_source_intent_from_closure( intent=intent, @@ -193,6 +219,16 @@ class KernelIngressFlowEntryMixin: **(intent.metadata or {}), **mixed_meta, } + rag_request_context = ( + ingress_options.get("rag_request_context") + if isinstance(ingress_options, dict) + else None + ) + if isinstance(rag_request_context, dict): + intent.metadata = { + **(intent.metadata or {}), + "rag_request_context": dict(rag_request_context), + } workflow_route = self.route_resolver.from_canonical( canonical_intent=intent, message_text=clean_text, @@ -365,6 +401,153 @@ class KernelIngressFlowEntryMixin: ) return {"handled": False, "reason": "kernel_exception", "error": str(exc)} + async def _maybe_handle_draft_confirmation_ingress( + self, + *, + work_item: WorkItem, + ingest_event: IngestEvent, + session: ConversationSession, + synthetic_message_id: str, + source_system: str, + source_sender_open_id: Optional[str], + sender: str, + channel_ref: Optional[str], + message_text: str, + started: float, + ) -> Optional[dict[str, Any]]: + from ..draft_confirm_handler import DraftConfirmHandler + + handler = DraftConfirmHandler(self) + decision = await handler.handle_text_reply( + message_text=message_text, + sender=sender, + source_system=source_system, + source_message_id=synthetic_message_id, + channel_ref=channel_ref, + source_sender_open_id=source_sender_open_id, + expected_version=None, + ) + if not bool(decision.get("handled")): + return None + + result_status = str(decision.get("status") or "").strip().lower() + if result_status in {"succeeded", "draft_pending_confirm", "pending_manual", "cancelled", "ignored"}: + work_item.state = STATE_SUCCEEDED + else: + work_item.state = STATE_FAILED + work_item.intent = ROUTE_FOLLOWUP_CONFIRM + work_item.project_id = _safe_int(decision.get("project_id")) + work_item.signal_id = _safe_int(decision.get("signal_id")) + execution_result = { + "status": result_status or "failed_safe", + "followup_reply": str(decision.get("followup_reply") or "").strip(), + "target_work_item_id": _safe_int(decision.get("target_work_item_id")), + "target_state": str(decision.get("target_state") or "").strip() or None, + "target_intent": str(decision.get("target_intent") or "").strip() or None, + "project_id": _safe_int(decision.get("project_id")), + "signal_id": _safe_int(decision.get("signal_id")), + "workflow_route": str(decision.get("workflow_route") or ROUTE_FOLLOWUP_CONFIRM).strip(), + } + work_item.execution_result = execution_result + work_item.review_summary = { + "verified": work_item.state == STATE_SUCCEEDED, + "issues": [] if work_item.state == STATE_SUCCEEDED else ["draft_confirm_failed"], + "next_step": "已处理草稿确认请求" if work_item.state == STATE_SUCCEEDED else "请检查草稿确认状态", + } + followup_text = str(decision.get("followup_reply") or "").strip() or "草稿状态已更新。" + if result_status == "draft_pending_confirm": + render = { + "receipt_phase": RECEIPT_PHASE_PENDING, + "template_key": "draft_pending_confirm", + "payload": {"status": result_status}, + "text": followup_text, + } + elif result_status == "cancelled": + render = { + "receipt_phase": RECEIPT_PHASE_ACTION, + "template_key": "action_rejected", + "payload": {"status": result_status}, + "text": followup_text, + } + elif work_item.state == STATE_SUCCEEDED: + render = { + "receipt_phase": RECEIPT_PHASE_ACTION, + "template_key": "action_succeeded", + "payload": {"status": result_status}, + "text": followup_text, + } + else: + render = { + "receipt_phase": RECEIPT_PHASE_ERROR, + "template_key": "safe_error", + "payload": {"status": result_status}, + "text": followup_text, + } + work_item.reply_template_key = str(render["template_key"]) + direct_reply_required = await self._claim_reply_receipt( + work_item_id=int(work_item.id), + source_system=source_system, + source_message_id=synthetic_message_id, + receipt_phase=str(render["receipt_phase"]), + template_key=str(render["template_key"]), + payload=render["payload"], + ) + ingest_event.status = "done" + ingest_event.processed_at = _utc_now_naive() + await self._write_processing_log( + source_message_id=synthetic_message_id, + work_item=work_item, + message_text=message_text, + route="ai_pmo.draft_confirm", + operation_code="draft.confirm", + operation_result=execution_result, + processing_status="success" if work_item.state == STATE_SUCCEEDED else "failed", + duration_ms=int((time.perf_counter() - started) * 1000), + ) + + next_session_intent = ( + STATE_DRAFT_PENDING_CONFIRM + if result_status in {"draft_pending_confirm", "pending_manual"} + else str(decision.get("target_intent") or INTENT_FOLLOWUP_CONFIRM).strip() + ) + await self._update_session( + tenant_id=self.tenant_id, + sender=sender, + current_project_id=_safe_int(decision.get("project_id")), + last_intent=next_session_intent, + last_message_text=message_text[:200], + ) + + try: + await self.db.commit() + except Exception as exc: + await self._persist_kernel_failure_with_fresh_session( + work_item_id=_safe_int(getattr(work_item, "id", None)), + ingest_event_id=_safe_int(getattr(ingest_event, "id", None)), + error_message=str(exc), + source_system=source_system, + source_message_id=synthetic_message_id, + ) + raise + + capture_intent = CanonicalIntent( + name=INTENT_FOLLOWUP_CONFIRM, + confidence=0.95, + project_id=_safe_int(decision.get("project_id")), + signal_title="", + signal_summary="", + metadata={"workflow_route": ROUTE_FOLLOWUP_CONFIRM}, + ) + return self._build_capture_result( + work_item=work_item, + intent=capture_intent, + execution_result=execution_result, + render=render, + direct_reply_required=direct_reply_required, + status="answered" if work_item.state == STATE_SUCCEEDED else "captured", + classification="command", + ) + async def _persist_kernel_failure_with_fresh_session( self, *, diff --git a/backend/app/services/ai_pmo/ai_pmo_kernel_ingress_flow/mixin_prepare_manual.py b/backend/app/services/ai_pmo/ai_pmo_kernel_ingress_flow/mixin_prepare_manual.py index 9346b05f..c13065ed 100644 --- a/backend/app/services/ai_pmo/ai_pmo_kernel_ingress_flow/mixin_prepare_manual.py +++ b/backend/app/services/ai_pmo/ai_pmo_kernel_ingress_flow/mixin_prepare_manual.py @@ -184,6 +184,15 @@ class KernelIngressFlowPrepareManualMixin: "workflow_action": workflow_route.action, "decision_trace": decision_trace, } + work_item.execution_result = self._apply_execution_action_contract( + intent=intent, + workflow_route=workflow_route, + decision_payload=decision_payload, + execution_result=work_item.execution_result, + source_system=source_system, + source_message_id=source_message_id, + channel_ref=None, + ) manual_issues = ["waiting_manual"] policy_gate_code = str(policy.get("policy_gate_code") or "").strip() if policy_gate_code: @@ -213,6 +222,32 @@ class KernelIngressFlowPrepareManualMixin: project_id=_safe_int(work_item.project_id), review_summary=work_item.review_summary, ) + operation_code_for_log = ( + self._extract_execution_operation_code(work_item.execution_result) + or self._primary_operation_code(str(work_item.intent or intent.name)) + ) + await self._upsert_operation_execution_ledger( + work_item_id=int(work_item.id), + operation_code=operation_code_for_log, + source_message_id=source_message_id, + project_id=_safe_int(work_item.project_id), + status=self._normalize_execution_ledger_status( + work_item_state=STATE_WAITING_MANUAL, + execution_status=str((work_item.execution_result or {}).get("status") or ""), + ), + payload={ + "work_item_id": int(work_item.id), + "workflow_route": workflow_route.key, + "workflow_action": workflow_route.action, + "source_system": source_system, + "source_message_id": source_message_id, + "action_contract": (work_item.execution_result or {}).get("action_contract"), + }, + execution_result={ + **(work_item.execution_result or {}), + "work_item_state": STATE_WAITING_MANUAL, + }, + ) direct_reply_required = await self._claim_reply_receipt( work_item_id=int(work_item.id), source_system=source_system, @@ -228,7 +263,7 @@ class KernelIngressFlowPrepareManualMixin: work_item=work_item, message_text=message_text, route="ai_pmo.waiting_manual", - operation_code=None, + operation_code=operation_code_for_log, operation_result=work_item.execution_result, processing_status="success", duration_ms=int((time.perf_counter() - started) * 1000), diff --git a/backend/app/services/ai_pmo/ai_pmo_typed_executor_execute/mixin_execute_query_project.py b/backend/app/services/ai_pmo/ai_pmo_typed_executor_execute/mixin_execute_query_project.py index c9db5e67..2cd19dbb 100644 --- a/backend/app/services/ai_pmo/ai_pmo_typed_executor_execute/mixin_execute_query_project.py +++ b/backend/app/services/ai_pmo/ai_pmo_typed_executor_execute/mixin_execute_query_project.py @@ -3,11 +3,15 @@ from __future__ import annotations from .shared import * # noqa: F401, F403 +from .shared import _safe_int from ...adaptive_rag_service import AdaptiveRAGService +from ...context_cache_service import context_cache_service +from ...sstc_service_core import ALIGNED_STATUSES +from ...task_persistence_core import build_task_create_draft, persist_task class ExecuteQueryProjectMixin: - """Handle query and project-create intents.""" + """Handle query/project/task create intents.""" async def _execute_query_intent( self, @@ -19,17 +23,58 @@ class ExecuteQueryProjectMixin: source_message_id: str, ) -> dict[str, Any]: rag_service = AdaptiveRAGService(self.db, tenant_id=self.tenant_id) + metadata = intent.metadata if isinstance(intent.metadata, dict) else {} + rag_request_context = ( + metadata.get("rag_request_context") + if isinstance(metadata.get("rag_request_context"), dict) + else None + ) + if isinstance(rag_request_context, dict): + asked_by = _safe_int(rag_request_context.get("asked_by")) + asked_via = str(rag_request_context.get("asked_via") or source_system).strip() or source_system + requested_project_id = _safe_int(rag_request_context.get("project_id")) + source_chat_id = str(rag_request_context.get("source_chat_id") or "").strip() or None + conversation_key = str(rag_request_context.get("conversation_key") or "").strip() or None + session_id = _safe_int(rag_request_context.get("session_id")) + force_new_session = bool(rag_request_context.get("force_new_session")) + thinking_mode = str(rag_request_context.get("thinking_mode") or "").strip() or None + action_mode = str(rag_request_context.get("action_mode") or "read").strip() or "read" + sync_requirement = bool(rag_request_context.get("sync_requirement")) + message_intent = str(rag_request_context.get("message_intent") or "").strip() or None + mixed_signal_part = str(rag_request_context.get("mixed_signal_part") or "").strip() or None + else: + asked_by = _safe_int(operator_user_id) + asked_via = source_system + requested_project_id = None + source_chat_id = source_message_id + conversation_key = source_message_id + session_id = None + force_new_session = False + thinking_mode = None + action_mode = "read" + sync_requirement = False + message_intent = None + mixed_signal_part = None + rag_kwargs: dict[str, Any] = { + "question": message_text, + "asked_by": asked_by, + "asked_via": asked_via, + "project_id": requested_project_id if requested_project_id is not None else intent.project_id, + "source_chat_id": source_chat_id, + "conversation_key": conversation_key, + "action_mode": action_mode, + "sync_requirement": sync_requirement, + "message_intent": message_intent, + "mixed_signal_part": mixed_signal_part, + } + if session_id is not None: + rag_kwargs["session_id"] = int(session_id) + if force_new_session: + rag_kwargs["force_new_session"] = True + if thinking_mode: + rag_kwargs["thinking_mode"] = thinking_mode try: - rag_result = await rag_service.answer_question( - question=message_text, - asked_by=operator_user_id, - asked_via=source_system, - project_id=intent.project_id, - source_chat_id=source_message_id, - conversation_key=source_message_id, - action_mode="read", - sync_requirement=False, - ) + rag_result = await rag_service.answer_question(**rag_kwargs) except Exception as exc: return { "status": "failed_safe", @@ -71,3 +116,143 @@ class ExecuteQueryProjectMixin: "tasks": payload.get("tasks") if isinstance(payload.get("tasks"), list) else [], "raw_payload": payload, } + + @staticmethod + def _extract_task_priority(message_text: str) -> str: + normalized = re.sub(r"\s+", "", str(message_text or "")).lower() + if re.search(r"\bp0\b", normalized, re.IGNORECASE) or any( + token in normalized for token in ("最高优先", "紧急", "critical", "urgent") + ): + return "high" + if re.search(r"\bp1\b", normalized, re.IGNORECASE) or any( + token in normalized for token in ("高优先", "高优先级", "highpriority") + ): + return "high" + if re.search(r"\bp3\b", normalized, re.IGNORECASE) or any( + token in normalized for token in ("低优先", "低优先级", "lowpriority") + ): + return "low" + return "medium" + + @staticmethod + def _extract_task_description(*, message_text: str, title: str) -> Optional[str]: + cleaned = re.sub(r"\s+", " ", str(message_text or "")).strip() + if not cleaned: + return None + description = cleaned.replace(str(title or "").strip(), "", 1).strip(" ,,::") + description = re.sub( + r"^(?:请|帮我|帮忙)?(?:给|在)?(?:项目\s*#?\d+\s*)?(?:里|中)?(?:新增|新建|创建|增加|添加|加(?:一个|个)?|安排|分配)\s*(?:一条|一个|个)?任务", + "", + description, + flags=re.IGNORECASE, + ).strip(" ,,::") + return description[:5000] or None + + async def _execute_task_create_intent( + self, + *, + intent, + message_text: str, + operator_user_id, + ) -> dict[str, Any]: + metadata = intent.metadata if isinstance(intent.metadata, dict) else {} + project_id = _safe_int(intent.project_id) or _safe_int(metadata.get("project_id")) + if project_id is None: + return { + "status": "pending_manual", + "error_message": "未执行:当前建任务请求缺少项目归属,请补充项目 ID 后再确认。", + "project_id": None, + } + + project = ( + await self.db.execute( + select(Project).where( + and_( + Project.tenant_id == int(self.tenant_id), + Project.id == int(project_id), + ) + ) + ) + ).scalar_one_or_none() + if project is None: + return { + "status": "failed_safe", + "error_message": f"未执行:项目#{int(project_id)} 不存在或不可访问。", + "project_id": int(project_id), + } + + provided_title = str(metadata.get("task_title") or "").strip() + extracted_title = ( + provided_title + or IntentResolver._extract_task_title(message_text) + or IntentResolver._extract_task_title(str(getattr(intent, "signal_title", "") or "")) + ) + task_title = str(extracted_title or "").strip() + if not task_title: + return { + "status": "pending_manual", + "error_message": "未执行:当前建任务请求缺少任务标题,请补充后再确认。", + "project_id": int(project_id), + } + + charter = ( + await self.db.execute( + select(TruthNode) + .where( + and_( + TruthNode.tenant_id == int(self.tenant_id), + TruthNode.project_id == int(project_id), + TruthNode.node_type == "charter", + TruthNode.status.in_(tuple(ALIGNED_STATUSES)), + ) + ) + .order_by(TruthNode.updated_at.desc(), TruthNode.id.desc()) + ) + ).scalars().first() + if charter is None: + return { + "status": "pending_manual", + "error_message": "未执行:项目尚无已对齐的 Charter,当前不能直接创建任务。", + "project_id": int(project_id), + } + + description = self._extract_task_description(message_text=message_text, title=task_title) + try: + draft = build_task_create_draft( + title=task_title, + description=description, + priority=self._extract_task_priority(message_text), + ) + except Exception as exc: + return { + "status": "failed_safe", + "error_message": f"任务字段校验失败:{type(exc).__name__}", + "project_id": int(project_id), + } + + task = await persist_task( + self.db, + tenant_id=int(self.tenant_id), + truth_node_id=int(charter.id), + project_id=int(project_id), + draft=draft, + actor_user_id=_safe_int(operator_user_id), + action=INTENT_TASK_CREATE, + remark="ai_pmo_typed_intent", + commit=False, + refresh_workload=False, + ) + await context_cache_service.invalidate_system_context(tenant_id=int(self.tenant_id)) + return { + "status": "succeeded", + "project_id": int(project_id), + "task_id": int(task.id), + "task": { + "id": int(task.id), + "title": str(task.title or "").strip(), + "description": str(task.description or "").strip(), + "priority": str(task.priority or "").strip(), + "status": str(task.status or "").strip(), + }, + "charter_id": int(charter.id), + } diff --git a/backend/app/services/ai_pmo/ai_pmo_typed_executor_execute/service.py b/backend/app/services/ai_pmo/ai_pmo_typed_executor_execute/service.py index 1d4f6354..5d3258fb 100644 --- a/backend/app/services/ai_pmo/ai_pmo_typed_executor_execute/service.py +++ b/backend/app/services/ai_pmo/ai_pmo_typed_executor_execute/service.py @@ -27,9 +27,10 @@ class TypedExecutorExecuteMixin( channel_ref: Optional[str] = None, ) -> dict[str, Any]: if intent.name == INTENT_QUERY: + query_text = str(getattr(intent, "description", "") or "").strip() or message_text return await self._execute_query_intent( intent=intent, - message_text=message_text, + message_text=query_text, operator_user_id=operator_user_id, source_system=source_system, source_message_id=source_message_id, @@ -41,6 +42,13 @@ class TypedExecutorExecuteMixin( operator_user_id=operator_user_id, ) + if intent.name == INTENT_TASK_CREATE: + return await self._execute_task_create_intent( + intent=intent, + message_text=message_text, + operator_user_id=operator_user_id, + ) + if intent.name == INTENT_SIGNAL_SOURCE_ONBOARD: return await self._execute_signal_source_onboard_intent( intent=intent, diff --git a/backend/app/services/ai_pmo/draft_confirm_handler.py b/backend/app/services/ai_pmo/draft_confirm_handler.py new file mode 100644 index 00000000..b2b377fd --- /dev/null +++ b/backend/app/services/ai_pmo/draft_confirm_handler.py @@ -0,0 +1,816 @@ +"""Draft confirmation/revision/cancel handler for AI PMO write intents.""" + +from __future__ import annotations + +from datetime import datetime +from typing import Any, Optional + +from . import ai_pmo_kernel_common_core as _common + +for _name, _value in vars(_common).items(): + if not _name.startswith("__"): + globals()[_name] = _value + +from ..signal.draft_generator_service import DraftGeneratorService + + +class DraftConfirmHandler: + """Handle draft decision from Feishu text or inbox API.""" + + _ACTION_CONFIRM = "confirm" + _ACTION_REVISE = "revise" + _ACTION_CANCEL = "cancel" + _ACTION_UNKNOWN = "unknown" + + _CONFIRM_HINTS = ("确认", "好的", "就这样", "行", "ok", "okay", "同意", "没问题") + _REVISE_HINTS = ("修改", "改成", "改为", "改", "换成") + _CANCEL_HINTS = ("取消", "不要", "算了", "放弃") + + _WORK_ITEM_ID_RE = re.compile(r"(?:work[_ -]?item|工作项|草稿)\s*(?:id|ID)?\s*[:#:]?\s*(\d+)", re.IGNORECASE) + + def __init__(self, kernel: Any) -> None: + self.kernel = kernel + self.db = kernel.db + self.tenant_id = int(kernel.tenant_id) + self.draft_generator = DraftGeneratorService(self.db, tenant_id=self.tenant_id) + + @classmethod + def parse_action_from_text(cls, *, message_text: str) -> tuple[str, str]: + text = str(message_text or "").strip() + lowered = text.lower() + if any(hint in text for hint in cls._REVISE_HINTS): + return cls._ACTION_REVISE, cls._extract_revision_note(text) + if any(hint in text for hint in cls._CANCEL_HINTS): + return cls._ACTION_CANCEL, "" + if any(hint in text for hint in cls._CONFIRM_HINTS) or lowered in {"y", "yes"}: + return cls._ACTION_CONFIRM, "" + return cls._ACTION_UNKNOWN, "" + + async def handle_text_reply( + self, + *, + message_text: str, + sender: str, + source_system: str, + source_message_id: str, + channel_ref: Optional[str], + source_sender_open_id: Optional[str], + expected_version: Optional[int] = None, + ) -> dict[str, Any]: + action, revision_note = self.parse_action_from_text(message_text=message_text) + explicit_work_item_id = self._extract_work_item_id(message_text) + + target = await self._resolve_pending_draft( + sender=sender, + source_system=source_system, + channel_ref=channel_ref, + source_sender_open_id=source_sender_open_id, + explicit_work_item_id=explicit_work_item_id, + ) + if target is None: + return { + "handled": True, + "status": "ignored", + "followup_reply": "当前没有待确认草稿,请先提交写操作需求。", + "target_work_item_id": None, + "target_state": None, + "target_intent": None, + "project_id": None, + "signal_id": None, + "workflow_route": None, + } + + target_work_item, target_ingest = target + if action == self._ACTION_CONFIRM: + return await self.confirm_draft( + target_work_item=target_work_item, + target_ingest=target_ingest, + sender=sender, + source_system=source_system, + source_message_id=source_message_id, + message_text=message_text, + expected_version=expected_version, + ) + if action == self._ACTION_REVISE: + return await self.revise_draft( + target_work_item=target_work_item, + target_ingest=target_ingest, + revision_note=revision_note, + expected_version=expected_version, + ) + if action == self._ACTION_CANCEL: + return await self.cancel_draft( + target_work_item=target_work_item, + reason="用户取消草稿", + ) + return { + "handled": True, + "status": "pending_manual", + "followup_reply": "请回复“确认”、“修改 + 你的意见”或“取消”来处理当前草稿。", + "target_work_item_id": int(target_work_item.id), + "target_state": str(target_work_item.state or "").strip() or None, + "target_intent": str(target_work_item.intent or "").strip() or None, + "project_id": _safe_int(target_work_item.project_id), + "signal_id": _safe_int(target_work_item.signal_id), + "workflow_route": RouteResolver.normalize_route_key(str(target_work_item.intent or "")), + } + + async def handle_api_decision( + self, + *, + work_item_id: int, + action: str, + operator_sender: str, + source_message_id: Optional[str], + revision_note: Optional[str], + draft_version: Optional[int], + ) -> dict[str, Any]: + row = ( + await self.db.execute( + select(WorkItem, IngestEvent) + .join(IngestEvent, IngestEvent.id == WorkItem.ingest_event_id) + .where( + and_( + WorkItem.tenant_id == int(self.tenant_id), + WorkItem.id == int(work_item_id), + ) + ) + .limit(1) + ) + ).first() + if row is None: + return { + "handled": False, + "status": "failed_safe", + "followup_reply": "草稿不存在或已被删除。", + "target_work_item_id": int(work_item_id), + "target_state": None, + "target_intent": None, + "project_id": None, + "signal_id": None, + "workflow_route": None, + } + + target_work_item, target_ingest = row + normalized_action = str(action or "").strip().lower() + normalized_source_message_id = ( + str(source_message_id or "").strip() + or f"draft-api-{normalized_action}-{int(work_item_id)}-{int(datetime.now().timestamp() * 1000)}" + )[:128] + + if normalized_action == self._ACTION_CONFIRM: + return await self.confirm_draft( + target_work_item=target_work_item, + target_ingest=target_ingest, + sender=operator_sender, + source_system="ai_pmo_ui", + source_message_id=normalized_source_message_id, + message_text="确认", + expected_version=draft_version, + ) + if normalized_action == self._ACTION_REVISE: + return await self.revise_draft( + target_work_item=target_work_item, + target_ingest=target_ingest, + revision_note=str(revision_note or "").strip(), + expected_version=draft_version, + ) + if normalized_action == self._ACTION_CANCEL: + return await self.cancel_draft( + target_work_item=target_work_item, + reason="用户在消息台取消草稿", + ) + return { + "handled": False, + "status": "failed_safe", + "followup_reply": "不支持的草稿操作。", + "target_work_item_id": int(work_item_id), + "target_state": str(target_work_item.state or "").strip() or None, + "target_intent": str(target_work_item.intent or "").strip() or None, + "project_id": _safe_int(target_work_item.project_id), + "signal_id": _safe_int(target_work_item.signal_id), + "workflow_route": RouteResolver.normalize_route_key(str(target_work_item.intent or "")), + } + + async def confirm_draft( + self, + *, + target_work_item: WorkItem, + target_ingest: IngestEvent, + sender: str, + source_system: str, + source_message_id: str, + message_text: str, + expected_version: Optional[int], + ) -> dict[str, Any]: + locked = ( + await self.db.execute( + select(WorkItem) + .where( + and_( + WorkItem.tenant_id == int(self.tenant_id), + WorkItem.id == int(target_work_item.id), + ) + ) + .with_for_update() + .limit(1) + ) + ).scalar_one_or_none() + if locked is None: + return { + "handled": False, + "status": "failed_safe", + "followup_reply": "草稿不存在或已被删除。", + "target_work_item_id": int(target_work_item.id), + "target_state": None, + "target_intent": None, + "project_id": None, + "signal_id": None, + "workflow_route": None, + } + + state = str(locked.state or "").strip() + if state != STATE_DRAFT_PENDING_CONFIRM: + return { + "handled": True, + "status": "succeeded" if state == STATE_SUCCEEDED else "failed_safe", + "followup_reply": "该草稿已处理,无需重复确认。", + "target_work_item_id": int(locked.id), + "target_state": state or None, + "target_intent": str(locked.intent or "").strip() or None, + "project_id": _safe_int(locked.project_id), + "signal_id": _safe_int(locked.signal_id), + "workflow_route": RouteResolver.normalize_route_key(str(locked.intent or "")), + } + + current_version = int(locked.draft_version or 0) + if expected_version is not None and int(expected_version) != current_version: + return { + "handled": True, + "status": "pending_manual", + "followup_reply": f"草稿版本已更新,请基于最新版本 v{current_version} 再确认。", + "target_work_item_id": int(locked.id), + "target_state": state, + "target_intent": str(locked.intent or "").strip() or None, + "project_id": _safe_int(locked.project_id), + "signal_id": _safe_int(locked.signal_id), + "workflow_route": RouteResolver.normalize_route_key(str(locked.intent or "")), + } + + now_at = _utc_now_naive() + if isinstance(locked.draft_expires_at, datetime) and locked.draft_expires_at <= now_at: + locked.state = STATE_CANCELLED + locked.execution_result = { + **(locked.execution_result if isinstance(locked.execution_result, dict) else {}), + "status": "cancelled", + "cancel_reason": "draft_expired", + "cancelled_at": now_at.isoformat(), + } + await self._sync_decision_outcome( + work_item_id=int(locked.id), + execution_result=locked.execution_result, + final_outcome=STATE_CANCELLED, + ) + return { + "handled": True, + "status": "cancelled", + "followup_reply": "⏰ 草稿已超时取消,请重新发送需求。", + "target_work_item_id": int(locked.id), + "target_state": STATE_CANCELLED, + "target_intent": str(locked.intent or "").strip() or None, + "project_id": _safe_int(locked.project_id), + "signal_id": _safe_int(locked.signal_id), + "workflow_route": RouteResolver.normalize_route_key(str(locked.intent or "")), + } + + existing_confirm_id = str(locked.confirm_message_id or "").strip() + if existing_confirm_id and existing_confirm_id != str(source_message_id or "").strip(): + return { + "handled": True, + "status": "failed_safe", + "followup_reply": "该草稿已被其他确认消息处理,请刷新状态后查看结果。", + "target_work_item_id": int(locked.id), + "target_state": str(locked.state or "").strip() or None, + "target_intent": str(locked.intent or "").strip() or None, + "project_id": _safe_int(locked.project_id), + "signal_id": _safe_int(locked.signal_id), + "workflow_route": RouteResolver.normalize_route_key(str(locked.intent or "")), + } + + locked.confirm_message_id = str(source_message_id or "").strip()[:128] or None + locked.state = STATE_EXECUTING + locked.reply_template_key = "action_processing" + await self.db.flush() + + route_key = RouteResolver.normalize_route_key(str(locked.intent or "")) + _, route_action = RouteResolver._split_route_key(route_key) + draft_payload = locked.draft_content if isinstance(locked.draft_content, dict) else {} + existing_execution = locked.execution_result if isinstance(locked.execution_result, dict) else {} + + try: + signal = await self._persist_signal_from_draft( + work_item=locked, + draft=draft_payload, + source_system=source_system, + source_message_id=source_message_id, + sender=sender, + message_text=message_text, + ) + except Exception as exc: + locked.state = STATE_FAILED + locked.draft_expires_at = None + locked.reply_template_key = "safe_error" + error_message = f"草稿确认入库失败:{str(exc)[:240]}" + locked.execution_result = { + **existing_execution, + "status": "failed_safe", + "workflow_route": route_key, + "workflow_action": route_action, + "error_message": error_message, + "committed_from_draft": False, + } + await self._sync_decision_outcome( + work_item_id=int(locked.id), + execution_result=locked.execution_result, + final_outcome=STATE_FAILED, + ) + await self.db.flush() + return { + "handled": True, + "status": "failed_safe", + "followup_reply": error_message, + "target_work_item_id": int(locked.id), + "target_state": STATE_FAILED, + "target_intent": str(locked.intent or "").strip() or None, + "project_id": _safe_int(locked.project_id), + "signal_id": _safe_int(locked.signal_id), + "workflow_route": route_key, + } + + signal_id = _safe_int(getattr(signal, "id", None)) + if signal_id is not None: + locked.signal_id = int(signal_id) + locked.state = STATE_SUCCEEDED + locked.draft_expires_at = None + locked.reply_template_key = "action_succeeded" + + reply_text = self._build_confirm_success_text( + draft=draft_payload, + signal_id=_safe_int(locked.signal_id), + ) + locked.execution_result = { + **existing_execution, + "status": "succeeded", + "workflow_route": route_key, + "workflow_action": route_action, + "signal_id": _safe_int(locked.signal_id), + "project_id": _safe_int(locked.project_id), + "draft_version": current_version, + "followup_reply": reply_text, + "committed_from_draft": True, + } + await self._sync_decision_outcome( + work_item_id=int(locked.id), + execution_result=locked.execution_result, + final_outcome=STATE_SUCCEEDED, + ) + await self.db.flush() + + return { + "handled": True, + "status": "succeeded", + "followup_reply": reply_text, + "target_work_item_id": int(locked.id), + "target_state": STATE_SUCCEEDED, + "target_intent": str(locked.intent or "").strip() or None, + "project_id": _safe_int(locked.project_id), + "signal_id": _safe_int(locked.signal_id), + "workflow_route": route_key, + } + + async def revise_draft( + self, + *, + target_work_item: WorkItem, + target_ingest: IngestEvent, + revision_note: str, + expected_version: Optional[int], + ) -> dict[str, Any]: + locked = ( + await self.db.execute( + select(WorkItem) + .where( + and_( + WorkItem.tenant_id == int(self.tenant_id), + WorkItem.id == int(target_work_item.id), + ) + ) + .with_for_update() + .limit(1) + ) + ).scalar_one_or_none() + if locked is None: + return { + "handled": False, + "status": "failed_safe", + "followup_reply": "草稿不存在或已被删除。", + "target_work_item_id": int(target_work_item.id), + "target_state": None, + "target_intent": None, + "project_id": None, + "signal_id": None, + "workflow_route": None, + } + + state = str(locked.state or "").strip() + if state != STATE_DRAFT_PENDING_CONFIRM: + return { + "handled": True, + "status": "failed_safe", + "followup_reply": "当前草稿已不在待确认状态,无法再修改。", + "target_work_item_id": int(locked.id), + "target_state": state or None, + "target_intent": str(locked.intent or "").strip() or None, + "project_id": _safe_int(locked.project_id), + "signal_id": _safe_int(locked.signal_id), + "workflow_route": RouteResolver.normalize_route_key(str(locked.intent or "")), + } + + current_version = int(locked.draft_version or 0) + if expected_version is not None and int(expected_version) != current_version: + return { + "handled": True, + "status": "pending_manual", + "followup_reply": f"草稿版本已更新,请刷新后基于 v{current_version} 再修改。", + "target_work_item_id": int(locked.id), + "target_state": state, + "target_intent": str(locked.intent or "").strip() or None, + "project_id": _safe_int(locked.project_id), + "signal_id": _safe_int(locked.signal_id), + "workflow_route": RouteResolver.normalize_route_key(str(locked.intent or "")), + } + + ingest_payload = target_ingest.payload if isinstance(target_ingest.payload, dict) else {} + original_message = str(ingest_payload.get("message_text") or "").strip() + if not original_message: + original_message = str((locked.draft_content or {}).get("original_text") or "").strip() + + intent_name = RouteResolver.canonical_intent_for_route(RouteResolver.normalize_route_key(str(locked.intent or ""))) + generated = await self.draft_generator.generate_and_persist( + work_item=locked, + intent_name=intent_name, + message_text=original_message, + world_model=locked.context_snapshot if isinstance(locked.context_snapshot, dict) else {}, + revision_note=revision_note, + ) + locked.state = STATE_DRAFT_PENDING_CONFIRM + locked.reply_template_key = "draft_pending_confirm" + await self.db.flush() + + draft_text = str(generated.get("draft_text") or "").strip() + if not draft_text: + draft_text = DraftGeneratorService.render_draft_message(locked.draft_content if isinstance(locked.draft_content, dict) else {}) + followup_reply = "🔄 正在重新起草,请稍候...\n\n" + draft_text + + return { + "handled": True, + "status": "draft_pending_confirm", + "followup_reply": followup_reply, + "target_work_item_id": int(locked.id), + "target_state": STATE_DRAFT_PENDING_CONFIRM, + "target_intent": str(locked.intent or "").strip() or None, + "project_id": _safe_int(locked.project_id), + "signal_id": _safe_int(locked.signal_id), + "workflow_route": RouteResolver.normalize_route_key(str(locked.intent or "")), + "draft_version": int(locked.draft_version or 0), + "draft_content": locked.draft_content if isinstance(locked.draft_content, dict) else None, + } + + async def cancel_draft( + self, + *, + target_work_item: WorkItem, + reason: str, + ) -> dict[str, Any]: + locked = ( + await self.db.execute( + select(WorkItem) + .where( + and_( + WorkItem.tenant_id == int(self.tenant_id), + WorkItem.id == int(target_work_item.id), + ) + ) + .with_for_update() + .limit(1) + ) + ).scalar_one_or_none() + if locked is None: + return { + "handled": False, + "status": "failed_safe", + "followup_reply": "草稿不存在或已被删除。", + "target_work_item_id": int(target_work_item.id), + "target_state": None, + "target_intent": None, + "project_id": None, + "signal_id": None, + "workflow_route": None, + } + + if str(locked.state or "").strip() == STATE_CANCELLED: + return { + "handled": True, + "status": "cancelled", + "followup_reply": "已取消,如需重新提交请再次发送。", + "target_work_item_id": int(locked.id), + "target_state": STATE_CANCELLED, + "target_intent": str(locked.intent or "").strip() or None, + "project_id": _safe_int(locked.project_id), + "signal_id": _safe_int(locked.signal_id), + "workflow_route": RouteResolver.normalize_route_key(str(locked.intent or "")), + } + + if str(locked.state or "").strip() != STATE_DRAFT_PENDING_CONFIRM: + return { + "handled": True, + "status": "failed_safe", + "followup_reply": "当前草稿已不在待确认状态,无法取消。", + "target_work_item_id": int(locked.id), + "target_state": str(locked.state or "").strip() or None, + "target_intent": str(locked.intent or "").strip() or None, + "project_id": _safe_int(locked.project_id), + "signal_id": _safe_int(locked.signal_id), + "workflow_route": RouteResolver.normalize_route_key(str(locked.intent or "")), + } + + locked.state = STATE_CANCELLED + locked.draft_expires_at = None + locked.reply_template_key = "draft_cancelled" + locked.execution_result = { + **(locked.execution_result if isinstance(locked.execution_result, dict) else {}), + "status": "cancelled", + "cancel_reason": str(reason or "用户取消草稿")[:120], + "cancelled_at": _utc_now_naive().isoformat(), + } + await self._sync_decision_outcome( + work_item_id=int(locked.id), + execution_result=locked.execution_result, + final_outcome=STATE_CANCELLED, + ) + await self.db.flush() + + return { + "handled": True, + "status": "cancelled", + "followup_reply": "已取消,如需重新提交请再次发送。", + "target_work_item_id": int(locked.id), + "target_state": STATE_CANCELLED, + "target_intent": str(locked.intent or "").strip() or None, + "project_id": _safe_int(locked.project_id), + "signal_id": _safe_int(locked.signal_id), + "workflow_route": RouteResolver.normalize_route_key(str(locked.intent or "")), + } + + async def _resolve_pending_draft( + self, + *, + sender: str, + source_system: str, + channel_ref: Optional[str], + source_sender_open_id: Optional[str], + explicit_work_item_id: Optional[int], + ) -> Optional[tuple[WorkItem, IngestEvent]]: + stmt = ( + select(WorkItem, IngestEvent) + .join(IngestEvent, IngestEvent.id == WorkItem.ingest_event_id) + .where( + and_( + WorkItem.tenant_id == int(self.tenant_id), + WorkItem.state == STATE_DRAFT_PENDING_CONFIRM, + ) + ) + .order_by(WorkItem.id.desc()) + .limit(30) + ) + rows = (await self.db.execute(stmt)).all() + if explicit_work_item_id is not None: + for work_item, ingest in rows: + if int(work_item.id) == int(explicit_work_item_id): + return work_item, ingest + return None + + normalized_sender = str(sender or "").strip() + normalized_channel = str(channel_ref or "").strip() + normalized_open_id = str(source_sender_open_id or "").strip() + + candidates: list[tuple[WorkItem, IngestEvent]] = [] + for work_item, ingest in rows: + payload = ingest.payload if isinstance(ingest.payload, dict) else {} + same_sender = normalized_sender and str(payload.get("sender") or "").strip() == normalized_sender + same_channel = normalized_channel and str(payload.get("channel_ref") or "").strip() == normalized_channel + same_open_id = normalized_open_id and str(payload.get("source_sender_open_id") or "").strip() == normalized_open_id + if str(source_system or "").strip().lower() == "feishu": + if not (same_open_id or same_channel or same_sender): + continue + else: + if not (same_sender or same_channel): + continue + candidates.append((work_item, ingest)) + + if not candidates: + return None + return candidates[0] + + async def _sync_decision_outcome( + self, + *, + work_item_id: int, + execution_result: dict[str, Any], + final_outcome: str, + ) -> None: + decision = ( + await self.db.execute( + select(DecisionRecord) + .where( + and_( + DecisionRecord.tenant_id == int(self.tenant_id), + DecisionRecord.work_item_id == int(work_item_id), + ) + ) + .order_by(DecisionRecord.id.desc()) + .limit(1) + ) + ).scalar_one_or_none() + if decision is None: + return + decision.execution_result = execution_result + decision.final_outcome = str(final_outcome or "")[:32] or None + await self.db.flush() + + @classmethod + def _extract_work_item_id(cls, text: str) -> Optional[int]: + matched = cls._WORK_ITEM_ID_RE.search(str(text or "")) + if matched is None: + return None + return _safe_int(matched.group(1)) + + @classmethod + def _extract_revision_note(cls, text: str) -> str: + normalized = str(text or "").strip() + for hint in cls._REVISE_HINTS: + if normalized.startswith(hint): + note = normalized[len(hint):].strip(" ::") + return note[:500] + return "" + + @staticmethod + def _safe_float(value: Any) -> Optional[float]: + try: + if value is None or value == "": + return None + return float(value) + except (TypeError, ValueError): + return None + + @staticmethod + def _build_confirm_success_text(*, draft: dict[str, Any], signal_id: Optional[int]) -> str: + title = str(draft.get("title") or "需求").strip() or "需求" + assignee_name = str(draft.get("recommended_assignee_name") or "待指定").strip() or "待指定" + lines = ["✅ 已入库", "", f"📌 {title}"] + if signal_id is not None: + lines.append(f"信号 #{int(signal_id)} 已创建") + else: + lines.append("已完成入库并开始执行") + lines.append(f"负责人:{assignee_name}") + return "\n".join(lines) + + async def _persist_signal_from_draft( + self, + *, + work_item: WorkItem, + draft: dict[str, Any], + source_system: str, + source_message_id: str, + sender: str, + message_text: str, + ) -> Signal: + title = str(draft.get("title") or "").strip()[:200] + if not title: + title = str(draft.get("summary") or "").strip()[:50] + if not title: + title = "新需求" + summary = str(draft.get("summary") or "").strip()[:300] or title + content = str(draft.get("original_text") or "").strip()[:2000] or summary + + assignee_id = _safe_int(draft.get("recommended_assignee_id")) + assignee_name = str(draft.get("recommended_assignee_name") or "").strip()[:40] + workload = self._safe_float(draft.get("recommended_assignee_workload")) + feasibility = str(draft.get("feasibility") or "").strip()[:20] + feasibility_reason = str(draft.get("feasibility_reason") or "").strip()[:120] + followup_plan = draft.get("followup_plan") if isinstance(draft.get("followup_plan"), list) else [] + + project_id = _safe_int(work_item.project_id) + strategy_payload = { + "source_context": { + "source_message_id": str(source_message_id or "").strip()[:128], + "source_system": str(source_system or "").strip()[:32], + "from_work_item_id": int(work_item.id), + "followup_sender": str(sender or "").strip()[:80], + }, + "ai_analysis": { + "summary": summary, + "intent": RouteResolver.canonical_intent_for_route( + RouteResolver.normalize_route_key(str(work_item.intent or "")) + ), + "draft_confirmed_direct_commit": True, + "feasibility": feasibility, + "feasibility_reason": feasibility_reason, + "followup_plan": followup_plan[:3], + }, + "assignee": { + "user_id": assignee_id, + "name": assignee_name, + "workload_percent": workload, + }, + } + reasoning_items = [ + "来源:草稿确认后直接入库(不重新分析)。", + f"可行性:{feasibility or '待讨论'}", + f"原因:{feasibility_reason or '无'}", + ] + + signal = Signal( + tenant_id=int(self.tenant_id), + source="PMFlow", + title=title, + summary=summary, + content=content, + signal_type="新需求", + reasoning=json.dumps(reasoning_items, ensure_ascii=False), + strategic_analysis=json.dumps(strategy_payload, ensure_ascii=False), + confidence=0.78, + recommended_action="enter_only", + matched_project_id=project_id, + impact_score=0.7, + priority="medium", + processed=False, + tasks_generated=json.dumps([], ensure_ascii=False), + status="待处理", + planner_assignee_user_id=assignee_id, + ) + self.db.add(signal) + await self.db.flush() + + if project_id is not None: + link_exists = ( + await self.db.execute( + select(ProjectSignalLink.id) + .where( + and_( + ProjectSignalLink.tenant_id == int(self.tenant_id), + ProjectSignalLink.project_id == int(project_id), + ProjectSignalLink.signal_id == int(signal.id), + ) + ) + .limit(1) + ) + ).scalar_one_or_none() + if link_exists is None: + self.db.add( + ProjectSignalLink( + tenant_id=int(self.tenant_id), + project_id=int(project_id), + signal_id=int(signal.id), + link_type="related", + relevance_score=1.0, + ) + ) + await self.db.flush() + + if not str(message_text or "").strip(): + message_text = summary + return signal + + @staticmethod + def _build_execution_route(*, work_item: WorkItem) -> WorkflowRoute: + normalized_key = RouteResolver.normalize_route_key(str(work_item.intent or "")) + family, action = RouteResolver._split_route_key(normalized_key) + execution_result = work_item.execution_result if isinstance(work_item.execution_result, dict) else {} + requested_count = _safe_int(execution_result.get("requested_count")) or 1 + if family != ROUTE_FAMILY_PROJECT_SIGNAL: + requested_count = 1 + return WorkflowRoute( + family=family, + action=action, + project_id=_safe_int(work_item.project_id), + requested_count=requested_count, + requires_confirmation=True, + metadata={ + "workflow_route": normalized_key, + "workflow_family": family, + "workflow_action": action, + "resumed_from": "draft_pending_confirm", + "target_work_item_id": int(work_item.id), + }, + ) diff --git a/backend/app/services/ai_service_core/mixin_business_analysis.py b/backend/app/services/ai_service_core/mixin_business_analysis.py index d2e7af1b..bd0525c1 100644 --- a/backend/app/services/ai_service_core/mixin_business_analysis.py +++ b/backend/app/services/ai_service_core/mixin_business_analysis.py @@ -168,9 +168,12 @@ class AIServiceBusinessAnalysisMixin: return self._parse_json(text) async def stream_chat(self, prompt: str) -> AsyncGenerator[str, None]: - """流式生成""" - llm = self._get_llm() - async for chunk in llm.astream([HumanMessage(content=prompt)]): - if hasattr(chunk, "content") and chunk.content: - yield chunk.content - + """兼容流式接口:统一走 llm_service,并以单块返回。""" + text = await self._invoke_with_retry( + prompt, + function_name="ai_service_stream_chat", + use_cache=False, + ) + normalized = str(text or "").strip() + if normalized: + yield normalized diff --git a/backend/app/services/ai_service_core/mixin_runtime_core.py b/backend/app/services/ai_service_core/mixin_runtime_core.py index 13dacbc2..6f57f784 100644 --- a/backend/app/services/ai_service_core/mixin_runtime_core.py +++ b/backend/app/services/ai_service_core/mixin_runtime_core.py @@ -86,18 +86,7 @@ class AIServiceRuntimeCoreMixin: } def _get_llm(self) -> BaseChatModel: - if not LANGCHAIN_AVAILABLE: - raise RuntimeError("请安装 langchain-openai: pip install langchain-openai langchain-core") - if not self.api_key: - raise RuntimeError("请设置统一路由所需 API Key 环境变量") - if self._llm is None: - self._llm = ChatOpenAI( - model=self.model, - api_key=self.api_key, - base_url=self.base_url, - temperature=0.3, - ) - return self._llm + raise RuntimeError("AIService 已统一收敛到 llm_service;请使用 _invoke_with_retry。") async def _get_redis(self): if not REDIS_AVAILABLE or not self.redis_url: @@ -132,4 +121,3 @@ class AIServiceRuntimeCoreMixin: if text.endswith("```"): text = text[:-3] return json.loads(text.strip()) - diff --git a/backend/app/services/ai_service_core/mixin_runtime_prompt_retry.py b/backend/app/services/ai_service_core/mixin_runtime_prompt_retry.py index 34a947a6..8e66324c 100644 --- a/backend/app/services/ai_service_core/mixin_runtime_prompt_retry.py +++ b/backend/app/services/ai_service_core/mixin_runtime_prompt_retry.py @@ -125,7 +125,9 @@ class AIServiceRuntimePromptRetryMixin: cache_key: Optional[str] = None, project_id: Optional[int] = None, ) -> str: - """带缓存、重试、日志的 LLM 调用""" + """带缓存、重试、预算治理的 LLM 调用(统一走 llm_service)。""" + from ..llm_service import llm_service + input_hash = cache_key or _hash_input(function_name, prompt) if use_cache and db is None: @@ -158,35 +160,27 @@ class AIServiceRuntimePromptRetryMixin: # 预算系统异常不阻断主流程,避免影响现有可用性 pass - llm = self._get_llm() - _, system_prompt = await self._resolve_active_system_prompt( - tenant_id=tenant_id, - db=db, - ) last_err = None for attempt in range(self.max_retries): try: - t0 = time.perf_counter() - messages = [HumanMessage(content=prompt)] - if system_prompt: - messages = [SystemMessage(content=system_prompt), *messages] - resp = await llm.ainvoke(messages) - latency_ms = int((time.perf_counter() - t0) * 1000) - text = resp.content if hasattr(resp, "content") else str(resp) - pt = getattr(resp, "response_metadata", None) or {} - usage = (pt or {}).get("usage", {}) or (getattr(resp, "response_metadata", None) or {}).get("usage", {}) - prompt_tokens = usage.get("input_tokens", 0) or usage.get("prompt_tokens", 0) - completion_tokens = usage.get("output_tokens", 0) or usage.get("completion_tokens", 0) + text = await llm_service.generate( + prompt, + db=db, + tenant_id=_tenant_to_int(tenant_id), + function_name=function_name, + use_prompt_profile=True, + ) + text = str(text or "") + if not text.strip(): + raise RuntimeError("llm_empty_response") - if db: - await self._log_call( - db, tenant_id, function_name, input_hash, "success", - prompt_tokens, completion_tokens, latency_ms, - ) - # 按实际 token 成本累计预算 + # 预算使用累计(按文本长度估算,不阻断主链路) + if db is not None: try: from ..budget_enforcement import BudgetEnforcementService + prompt_tokens = max(len(prompt) // 4, 1) + completion_tokens = max(len(text) // 4, 1) actual_cost = _estimate_cost(prompt_tokens, completion_tokens) await BudgetEnforcementService(db).update_budget_usage_after_ai_call( tenant_id=_tenant_to_int(tenant_id), @@ -200,13 +194,10 @@ class AIServiceRuntimePromptRetryMixin: return text except Exception as e: last_err = e - wait = 2 ** attempt - time.sleep(wait) - - if db: - await self._log_call( - db, tenant_id, function_name, input_hash, "failed", - error_message=str(last_err), - ) - raise last_err + if attempt < self.max_retries - 1: + wait_seconds = 2 ** attempt + await asyncio.sleep(wait_seconds) + if last_err is not None: + raise last_err + raise RuntimeError("llm_invoke_failed") diff --git a/backend/app/services/ai_service_core/shared.py b/backend/app/services/ai_service_core/shared.py index 8d3bd95f..f6aace8b 100644 --- a/backend/app/services/ai_service_core/shared.py +++ b/backend/app/services/ai_service_core/shared.py @@ -13,6 +13,7 @@ import hashlib import json import os import time +import asyncio from datetime import datetime from typing import Any, AsyncGenerator, Optional, Union from sqlalchemy import and_, select @@ -71,3 +72,5 @@ def _tenant_to_int(tenant_id: Union[str, int]) -> int: return 1 +__all__ = [name for name in globals() if not name.startswith("__")] + diff --git a/backend/app/services/context_cache_service/mixin_profile_basics.py b/backend/app/services/context_cache_service/mixin_profile_basics.py index 6a693811..0b514358 100644 --- a/backend/app/services/context_cache_service/mixin_profile_basics.py +++ b/backend/app/services/context_cache_service/mixin_profile_basics.py @@ -87,7 +87,7 @@ class ContextCacheProfileBasicsMixin: "summary": "AI 任务协同与治理中心,包含简报、冲突检测、问答历史。", "page_component": "AIStewardPage", "page_file": "frontend/src/pages/AIStewardPage.tsx", - "apis": ["/ai-steward/", "/ai-steward/rag/ask", "/rag/qa-sessions", "/rag/qa-records"], + "apis": ["/ai-steward/", "/rag/ask", "/rag/qa-sessions", "/rag/qa-records"], "aliases": ["ai steward", "ai管家", "问答", "rag", "智能问答"], }, { @@ -132,4 +132,3 @@ class ContextCacheProfileBasicsMixin: "aliases": ["users", "成员管理", "用户", "人员管理"], }, ] - diff --git a/backend/app/services/context_cache_service/mixin_profile_route_parser.py b/backend/app/services/context_cache_service/mixin_profile_route_parser.py index 3006b5c6..04193a50 100644 --- a/backend/app/services/context_cache_service/mixin_profile_route_parser.py +++ b/backend/app/services/context_cache_service/mixin_profile_route_parser.py @@ -62,7 +62,7 @@ class ContextCacheProfileRouteParserMixin: ), "/ai-steward": ( "AI 任务协同与治理中心,包含简报、冲突检测、问答历史。", - ["/ai-steward/", "/ai-steward/rag/ask", "/rag/qa-sessions", "/rag/qa-records"], + ["/ai-steward/", "/rag/ask", "/rag/qa-sessions", "/rag/qa-records"], ["ai steward", "ai管家", "问答", "rag", "智能问答"], ), "/ai-config": ( @@ -251,4 +251,3 @@ class ContextCacheProfileRouteParserMixin: "scanned_at": now_cn_aware().isoformat(), }, } - diff --git a/backend/app/services/integrations/feishu_integration_service/mixin_card_action.py b/backend/app/services/integrations/feishu_integration_service/mixin_card_action.py new file mode 100644 index 00000000..f9b03091 --- /dev/null +++ b/backend/app/services/integrations/feishu_integration_service/mixin_card_action.py @@ -0,0 +1,346 @@ +from __future__ import annotations + +from .shared import * + + +class FeishuCardActionMixin: + _ACTION_CONFIRM = "confirm" + _ACTION_REVISE = "revise" + _ACTION_CANCEL = "cancel" + _ACTION_CONTINUE = "continue" + _TEMPLATE_DRAFT_PENDING_CONFIRM = "draft_pending_confirm" + _TEMPLATE_PENDING_MANUAL = "pending_manual" + _ACTION_ALIASES: dict[str, str] = { + "confirm": _ACTION_CONFIRM, + "draft_confirm": _ACTION_CONFIRM, + "confirm_draft": _ACTION_CONFIRM, + "确认": _ACTION_CONFIRM, + "ok": _ACTION_CONFIRM, + "revise": _ACTION_REVISE, + "draft_revise": _ACTION_REVISE, + "revise_draft": _ACTION_REVISE, + "修改": _ACTION_REVISE, + "edit": _ACTION_REVISE, + "cancel": _ACTION_CANCEL, + "draft_cancel": _ACTION_CANCEL, + "cancel_draft": _ACTION_CANCEL, + "取消": _ACTION_CANCEL, + "abort": _ACTION_CANCEL, + "continue": _ACTION_CONTINUE, + "resume": _ACTION_CONTINUE, + "继续": _ACTION_CONTINUE, + "继续执行": _ACTION_CONTINUE, + } + + @staticmethod + def _safe_int(value: Any) -> Optional[int]: + try: + if value is None or value == "": + return None + return int(value) + except (TypeError, ValueError): + return None + + @classmethod + def _normalize_draft_action(cls, raw_action: Any) -> Optional[str]: + normalized = str(raw_action or "").strip().lower() + if not normalized: + return None + return cls._ACTION_ALIASES.get(normalized) + + @staticmethod + def _coerce_capture_work_item_id(capture_result: Dict[str, Any]) -> Optional[int]: + work_item_id = FeishuCardActionMixin._safe_int(capture_result.get("work_item_id")) + if work_item_id is not None and work_item_id > 0: + return int(work_item_id) + rows = capture_result.get("results") if isinstance(capture_result.get("results"), list) else [] + if rows and isinstance(rows[0], dict): + row_work_item_id = FeishuCardActionMixin._safe_int(rows[0].get("work_item_id")) + if row_work_item_id is not None and row_work_item_id > 0: + return int(row_work_item_id) + return None + + @classmethod + def _build_action_card_from_capture_result( + cls, + *, + capture_result: Dict[str, Any], + mention_open_id: Optional[str], + mention_name: Optional[str], + ) -> Optional[dict[str, Any]]: + if not isinstance(capture_result, dict): + return None + template_key = str(capture_result.get("reply_template_key") or "").strip().lower() + work_item_id = cls._coerce_capture_work_item_id(capture_result) + if work_item_id is None: + return None + reply_text = str(capture_result.get("direct_reply_text") or "").strip() + safe_reply_text = reply_text[:3000] if reply_text else "已生成动作卡片,请点击按钮继续。" + + mention_line = "" + safe_open_id = str(mention_open_id or "").strip()[:128] + safe_name = re.sub(r"\s+", " ", str(mention_name or "").strip())[:40] + if safe_open_id: + mention_line = f"{(' ' + safe_name) if safe_name else ''}" + + base_elements: list[dict[str, Any]] = [] + if mention_line: + base_elements.append( + { + "tag": "div", + "text": {"tag": "lark_md", "content": mention_line}, + } + ) + base_elements.append( + { + "tag": "div", + "text": {"tag": "lark_md", "content": safe_reply_text}, + } + ) + + if template_key == cls._TEMPLATE_DRAFT_PENDING_CONFIRM: + draft_version = cls._safe_int(capture_result.get("draft_version")) + actions = [ + { + "tag": "button", + "type": "primary", + "text": {"tag": "plain_text", "content": "确认"}, + "value": { + "action": cls._ACTION_CONFIRM, + "work_item_id": int(work_item_id), + "draft_version": draft_version, + }, + }, + { + "tag": "button", + "type": "default", + "text": {"tag": "plain_text", "content": "修改"}, + "value": { + "action": cls._ACTION_REVISE, + "work_item_id": int(work_item_id), + "draft_version": draft_version, + }, + }, + { + "tag": "button", + "type": "danger", + "text": {"tag": "plain_text", "content": "取消"}, + "value": { + "action": cls._ACTION_CANCEL, + "work_item_id": int(work_item_id), + "draft_version": draft_version, + }, + }, + ] + return { + "config": {"wide_screen_mode": True, "enable_forward": True}, + "header": { + "title": {"tag": "plain_text", "content": "AI PMO 草稿待确认"}, + }, + "elements": [*base_elements, {"tag": "action", "actions": actions}], + } + + if template_key == cls._TEMPLATE_PENDING_MANUAL: + actions = [ + { + "tag": "button", + "type": "primary", + "text": {"tag": "plain_text", "content": "继续执行"}, + "value": { + "action": cls._ACTION_CONTINUE, + "work_item_id": int(work_item_id), + }, + } + ] + return { + "config": {"wide_screen_mode": True, "enable_forward": True}, + "header": { + "title": {"tag": "plain_text", "content": "AI PMO 待继续执行"}, + }, + "elements": [*base_elements, {"tag": "action", "actions": actions}], + } + + return None + + @classmethod + def _extract_card_action_payload(cls, event: Dict[str, Any]) -> dict[str, Any]: + payload = event if isinstance(event, dict) else {} + operator = payload.get("operator") if isinstance(payload.get("operator"), dict) else {} + action = payload.get("action") if isinstance(payload.get("action"), dict) else {} + raw_value = action.get("value") + value: dict[str, Any] = {} + if isinstance(raw_value, dict): + value = raw_value + elif isinstance(raw_value, str): + raw_text = raw_value.strip() + if raw_text: + try: + parsed = json.loads(raw_text) + if isinstance(parsed, dict): + value = parsed + except Exception: + value = {} + + normalized_action = cls._normalize_draft_action( + value.get("action") + or value.get("draft_action") + or value.get("followup_action") + or value.get("resume_action") + or value.get("op") + or action.get("name") + or action.get("tag") + ) + work_item_id = cls._safe_int( + value.get("work_item_id") + or value.get("target_work_item_id") + or value.get("workItemId") + ) + draft_version = cls._safe_int(value.get("draft_version") or value.get("version")) + revision_note = ( + str(value.get("revision_note") or value.get("note") or value.get("comment") or "") + .strip()[:500] + or None + ) + source_message_id = ( + str( + payload.get("open_message_id") + or payload.get("message_id") + or payload.get("token") + or "" + ).strip()[:128] + or None + ) + chat_id = ( + str(payload.get("open_chat_id") or payload.get("chat_id") or "").strip()[:128] + or None + ) + sender_open_id = ( + str(operator.get("open_id") or operator.get("user_id") or "").strip()[:128] + or None + ) + sender_name = ( + str(operator.get("name") or operator.get("display_name") or "").strip()[:80] + or None + ) + return { + "action": normalized_action, + "work_item_id": work_item_id, + "draft_version": draft_version, + "revision_note": revision_note, + "source_message_id": source_message_id, + "chat_id": chat_id, + "sender_open_id": sender_open_id, + "sender_name": sender_name, + } + + async def _handle_card_action_trigger( + self, + event: Dict[str, Any], + ) -> Dict[str, Any]: + parsed = self._extract_card_action_payload(event) + action = str(parsed.get("action") or "").strip().lower() + work_item_id = self._safe_int(parsed.get("work_item_id")) + if action not in {self._ACTION_CONFIRM, self._ACTION_REVISE, self._ACTION_CANCEL, self._ACTION_CONTINUE}: + return {"code": 0, "msg": "card_action_ignored", "data": {"reason": "unsupported_action"}} + if work_item_id is None or work_item_id <= 0: + return {"code": 0, "msg": "card_action_ignored", "data": {"reason": "missing_work_item_id"}} + + from app.services.ai_pmo.ai_pmo_kernel.ai_pmo_kernel_runtime import AIPMOKernelService + from app.services.ai_pmo.draft_confirm_handler import DraftConfirmHandler + + operator_sender = str(parsed.get("sender_name") or "飞书用户").strip()[:80] or "飞书用户" + source_message_id = str(parsed.get("source_message_id") or "").strip()[:128] or None + chat_id = str(parsed.get("chat_id") or "").strip()[:128] or None + sender_open_id = str(parsed.get("sender_open_id") or "").strip()[:128] or None + try: + if action == self._ACTION_CONTINUE: + kernel = AIPMOKernelService(self.db, tenant_id=self.tenant_id) + result = await kernel.process_ingress( + source_system="ai_pmo_ui", + source_message_id=( + source_message_id + or f"feishu-card-continue-{int(work_item_id)}-{int(time.time() * 1000)}" + ), + message_text=f"继续处理 work_item #{int(work_item_id)}", + sender=f"{operator_sender}(card)", + channel_ref="ai_pmo_ui", + source_sender_open_id=sender_open_id, + captured_at=None, + chat_type="manual", + project_hint_id=None, + ) + if hasattr(self.db, "commit") and callable(getattr(self.db, "commit")): + await self.db.commit() + followup_reply = str(result.get("direct_reply_text") or "").strip() + if not followup_reply: + if bool(result.get("handled")): + followup_reply = "已收到继续执行指令,正在处理。" + else: + followup_reply = str(result.get("error") or result.get("reason") or "继续执行失败").strip() + normalized = { + "handled": bool(result.get("handled")), + "status": str(result.get("status") or "").strip() or ("captured" if bool(result.get("handled")) else "failed_safe"), + "followup_reply": followup_reply, + "target_work_item_id": int(work_item_id), + } + else: + draft_version = self._safe_int(parsed.get("draft_version")) + revision_note = str(parsed.get("revision_note") or "").strip() or None + handler = DraftConfirmHandler(AIPMOKernelService(self.db, tenant_id=self.tenant_id)) + normalized = await handler.handle_api_decision( + work_item_id=int(work_item_id), + action=action, + operator_sender=operator_sender, + source_message_id=source_message_id, + revision_note=revision_note, + draft_version=draft_version, + ) + if hasattr(self.db, "commit") and callable(getattr(self.db, "commit")): + await self.db.commit() + except Exception as exc: + if hasattr(self.db, "rollback") and callable(getattr(self.db, "rollback")): + await self.db.rollback() + return {"code": 1, "msg": "card_action_failed", "data": {"error": str(exc)}} + + followup_reply = str(normalized.get("followup_reply") or "").strip() + if followup_reply and chat_id: + status = str(normalized.get("status") or "").strip().lower() + target_work_item_id = self._safe_int( + normalized.get("target_work_item_id") + or normalized.get("work_item_id") + or work_item_id + ) + template_key = "" + if status == self._TEMPLATE_DRAFT_PENDING_CONFIRM: + template_key = self._TEMPLATE_DRAFT_PENDING_CONFIRM + elif status in {"pending_manual", "pending_confirmation"}: + template_key = self._TEMPLATE_PENDING_MANUAL + action_card = self._build_action_card_from_capture_result( + capture_result={ + "reply_template_key": template_key, + "work_item_id": target_work_item_id, + "draft_version": self._safe_int(normalized.get("draft_version")), + "direct_reply_text": followup_reply, + }, + mention_open_id=sender_open_id, + mention_name=operator_sender, + ) + if isinstance(action_card, dict): + await self._safe_reply_card( + chat_id=chat_id, + card_content=action_card, + trace_id=f"feishu-card-action-{int(work_item_id)}", + source_message_id=source_message_id, + perf_t0=None, + ) + else: + await self._safe_reply_text( + chat_id=chat_id, + mention_open_id=sender_open_id, + mention_name=operator_sender, + text=followup_reply, + trace_id=f"feishu-card-action-{int(work_item_id)}", + source_message_id=source_message_id, + perf_t0=None, + ) + return {"code": 0, "msg": "success", "data": normalized} diff --git a/backend/app/services/integrations/feishu_integration_service/mixin_config.py b/backend/app/services/integrations/feishu_integration_service/mixin_config.py index 0f080c31..e0549242 100644 --- a/backend/app/services/integrations/feishu_integration_service/mixin_config.py +++ b/backend/app/services/integrations/feishu_integration_service/mixin_config.py @@ -135,6 +135,7 @@ class FeishuConfigMixin: 支持的事件类型: - message.receive_v1: 收到群聊消息 - im.message.receive_v1: 收到群聊消息(schema 2.0) + - card.action.trigger: 卡片按钮回调 """ try: event_type = (event_data.get('type') or '').strip() @@ -142,6 +143,8 @@ class FeishuConfigMixin: # 飞书 schema 2.0 会返回 im.message.receive_v1 if event_type in {'message.receive_v1', 'im.message.receive_v1'}: return await self._handle_message_receive(event_data) + if event_type in {'card.action.trigger', 'im.card.action.trigger'}: + return await self._handle_card_action_trigger(event_data) return { 'code': 0, diff --git a/backend/app/services/integrations/feishu_integration_service/mixin_receive.py b/backend/app/services/integrations/feishu_integration_service/mixin_receive.py index 27d1f788..47455672 100644 --- a/backend/app/services/integrations/feishu_integration_service/mixin_receive.py +++ b/backend/app/services/integrations/feishu_integration_service/mixin_receive.py @@ -2,216 +2,96 @@ from __future__ import annotations from .shared import * + class FeishuReceiveMixin: async def _handle_message_receive( self, event: Dict[str, Any] ) -> Dict[str, Any]: - """处理收到的消息事件""" + """处理收到的消息事件(编排层)。""" state_trace_id = f"feishu-sm-{uuid4().hex[:12]}" - ingress_source = str(event.get("_ingress_source") or "feishu").strip() or "feishu" - message = event.get('message', {}) if isinstance(event.get('message', {}), dict) else {} - chat = event.get('chat', {}) if isinstance(event.get('chat', {}), dict) else {} - sender = event.get('sender', {}) if isinstance(event.get('sender', {}), dict) else {} - message_id = (message.get("message_id") or "").strip() or "unknown" - perf_t0_raw = event.get("_perf_t0") - perf_t0: Optional[float] = None - if isinstance(perf_t0_raw, (int, float)): - perf_t0 = float(perf_t0_raw) - else: - try: - perf_t0 = float(str(perf_t0_raw).strip()) - except Exception: - perf_t0 = None - self._emit_state_transition( - trace_id=state_trace_id, - state="RECEIVED", - message_id=message_id, - message_type=str(message.get("message_type") or "unknown"), - ) - sender_type = str(sender.get("sender_type") or "").strip().lower() + context = self._build_receive_context(event=event, state_trace_id=state_trace_id) + message = context["message"] + sender = context["sender"] + message_id = str(context["message_id"]) + sender_type = str(context["sender_type"]) if sender_type in {"app", "bot"}: - return {'code': 0, 'msg': 'Ignore bot/app message'} - - mentioned_bot_names = self._extract_mentioned_bot_names(message) - - # 解析消息内容与附件元数据 - message_payload = self._extract_message_payload(event) - raw_message_content = str(message_payload.get("text") or "").strip() or None - message_content = ( - self._strip_bot_mention_tokens(raw_message_content) - if raw_message_content - else "" - ).strip() or None - message_attachments = ( - message_payload.get("attachments") - if isinstance(message_payload.get("attachments"), list) - else [] - ) + return {"code": 0, "msg": "Ignore bot/app message"} await self._record_ingress_trace( - ingress_source=ingress_source, + ingress_source=str(context["ingress_source"]), trace_id=state_trace_id, message_id=message_id, event=event, - message_content=raw_message_content or "", + message_content=str(context.get("raw_message_content") or ""), ) + message_content = str(context.get("message_content") or "").strip() if not message_content: - print( - f"[Feishu] message.receive_v1 ignored: no text content, " - f"message_type={message.get('message_type')}, keys={list(message.keys())}" + await self._reply_for_empty_text_ingress( + state_trace_id=state_trace_id, + message=message, + sender=sender, + message_id=message_id, + chat_id=str(context.get("chat_id") or ""), + mentioned_bot_names=( + context.get("mentioned_bot_names") + if isinstance(context.get("mentioned_bot_names"), list) + else [] + ), + sender_open_id=context.get("sender_open_id"), + perf_t0=context.get("perf_t0"), ) - chat_id = chat.get('chat_id') or message.get('chat_id') or '' - if mentioned_bot_names and chat_id: - await self._safe_reply_text( - chat_id=chat_id, - mention_open_id=(sender.get('sender_id', {}) or {}).get('open_id'), - mention_name=(sender.get('sender_name') or sender.get('name') or '你'), - text="我已收到你的消息,但目前无法直接解析纯图片内容。请补充 1-2 句文字需求,我会立即给出分派建议。", - trace_id=state_trace_id, - source_message_id=message_id, - perf_t0=perf_t0, - ) - self._emit_state_transition( - trace_id=state_trace_id, - state="REPLIED", - route="empty_text_prompt", - ) - return {'code': 0, 'msg': 'No text content'} + return {"code": 0, "msg": "No text content"} - # 获取发送者信息 - sender_id = sender.get('sender_id', {}) if isinstance(sender.get('sender_id', {}), dict) else {} - sender_open_id = (sender_id.get('open_id') or "").strip() or None sender_name = await self._resolve_sender_display_name( sender=sender, - sender_id=sender_id, + sender_id=( + context.get("sender_id") + if isinstance(context.get("sender_id"), dict) + else {} + ), ) - - # 获取群聊信息 - chat_id = chat.get('chat_id') or message.get('chat_id') or '' - chat_type = str(chat.get("chat_type") or message.get("chat_type") or "").strip().lower() or "group" - captured_at = self._extract_message_datetime(message) - occurred_at_iso = ( - captured_at.isoformat() if isinstance(captured_at, datetime) else None - ) - message_attachments = self._normalize_ingress_attachment_records( - message_attachments, - occurred_at=occurred_at_iso, - source_message_id=(message.get("message_id") or "").strip() or None, - source_chat_id=chat_id or None, - ) - - # 使用信号捕获服务处理 - capture_service = ProactiveSignalCaptureService(self.db) - + chat_id = str(context.get("chat_id") or "") + sender_open_id = context.get("sender_open_id") + message_attachments = self._normalize_message_attachments_from_context(context=context) try: - result = await capture_service.capture_from_feishu( - group_chat_id=chat_id, + result = await self._capture_and_persist_from_ingress( + chat_id=chat_id, message_content=message_content, - sender=sender_name, - tenant_id=self.tenant_id, - proposer_open_id=sender_open_id, - captured_at=captured_at, - mentioned_bot_names=mentioned_bot_names, - feishu_message_id=(message.get("message_id") or "").strip() or None, - chat_type=chat_type, - ) - await self._persist_signal_attachments_from_ingress( - signal_id=result.get("signal_id"), - attachments=message_attachments, + sender_name=sender_name, + sender_open_id=sender_open_id, + captured_at=context.get("captured_at"), + mentioned_bot_names=( + context.get("mentioned_bot_names") + if isinstance(context.get("mentioned_bot_names"), list) + else [] + ), + message_id=(message.get("message_id") or "").strip() or None, + chat_type=str(context.get("chat_type") or "group"), + message_attachments=message_attachments, ) except Exception as exc: - self._emit_state_transition( - trace_id=state_trace_id, - state="ERROR_REPLY", - reason="capture_exception", - error=str(exc), + return await self._handle_capture_exception_from_ingress( + state_trace_id=state_trace_id, + error=exc, + chat_id=chat_id, + sender_open_id=sender_open_id, + sender_name=sender_name, + message_id=message_id, + perf_t0=context.get("perf_t0"), ) - if chat_id: - await self._safe_reply_text( - chat_id=chat_id, - mention_open_id=sender_open_id, - mention_name=sender_name, - text=self._build_error_reply_text(), - trace_id=state_trace_id, - source_message_id=message_id, - perf_t0=perf_t0, - ) - self._emit_state_transition( - trace_id=state_trace_id, - state="REPLIED", - route="error_template", - ) - return { - "code": 0, - "msg": "error_handled", - "data": { - "status": "error", - "classification": "error", - "ai_trace_id": state_trace_id, - }, - } - - print( - "[Feishu] Message processed: " - f"status={result.get('status')}, " - f"classification={result.get('classification')}, " - f"linked_projects={result.get('linked_project_ids', [])}, " - f"auto_tasks={len(result.get('auto_tasks', []) if isinstance(result.get('auto_tasks', []), list) else [])}, " - f"ai_trace_id={result.get('ai_trace_id')}" - ) - scene = self._classify_message_scene(capture_result=result) - self._emit_state_transition( - trace_id=state_trace_id, - state="CLASSIFIED", - scene=scene, - status=str(result.get("status") or "unknown"), - classification=str(result.get("classification") or "unknown"), - ) - if str(result.get("status") or "").lower() != "error": - self._emit_state_transition( - trace_id=state_trace_id, - state="COMMITTED", - ai_trace_id=str(result.get("ai_trace_id") or ""), - ) - - reply_route = await self._route_scene_reply( - scene=scene, + return await self._finalize_receive_result( result=result, + state_trace_id=state_trace_id, + message_id=message_id, message_content=message_content, chat_id=chat_id, + chat_type=str(context.get("chat_type") or "group"), sender_open_id=sender_open_id, sender_name=sender_name, - mentioned_bot_names=mentioned_bot_names, - trace_id=state_trace_id, - message_id=message_id, - perf_t0=perf_t0, + mentioned_bot_names=( + context.get("mentioned_bot_names") + if isinstance(context.get("mentioned_bot_names"), list) + else [] + ), + perf_t0=context.get("perf_t0"), ) - - if reply_route != "none": - self._emit_state_transition( - trace_id=state_trace_id, - state="REPLIED", - route=reply_route, - ) - await self._record_message_trace( - trace_id=state_trace_id, - message_id=message_id, - chat_type=chat_type, - message_content=message_content, - capture_result=result, - reply_source=reply_route, - ) - - return { - 'code': 0, - 'msg': 'success', - 'data': { - 'status': result.get('status'), - 'classification': result.get('classification'), - 'linked_project_ids': result.get('linked_project_ids', []), - 'auto_tasks_count': len(result.get('auto_tasks', []) if isinstance(result.get('auto_tasks', []), list) else []), - 'ai_trace_id': result.get('ai_trace_id'), - 'state_trace_id': state_trace_id, - 'scene': scene, - }, - } diff --git a/backend/app/services/integrations/feishu_integration_service/mixin_receive_capture_flow.py b/backend/app/services/integrations/feishu_integration_service/mixin_receive_capture_flow.py new file mode 100644 index 00000000..931e4762 --- /dev/null +++ b/backend/app/services/integrations/feishu_integration_service/mixin_receive_capture_flow.py @@ -0,0 +1,160 @@ +from __future__ import annotations + +from .shared import * + + +class FeishuReceiveCaptureFlowMixin: + async def _capture_and_persist_from_ingress( + self, + *, + chat_id: str, + message_content: str, + sender_name: str, + sender_open_id: Optional[str], + captured_at: Optional[datetime], + mentioned_bot_names: List[str], + message_id: str, + chat_type: str, + message_attachments: List[Dict[str, Any]], + ) -> Dict[str, Any]: + capture_service = ProactiveSignalCaptureService(self.db) + result = await capture_service.capture_from_feishu( + group_chat_id=chat_id, + message_content=message_content, + sender=sender_name, + tenant_id=self.tenant_id, + proposer_open_id=sender_open_id, + captured_at=captured_at, + mentioned_bot_names=mentioned_bot_names, + feishu_message_id=message_id, + chat_type=chat_type, + ) + await self._persist_signal_attachments_from_ingress( + signal_id=result.get("signal_id"), + attachments=message_attachments, + ) + return result + + async def _handle_capture_exception_from_ingress( + self, + *, + state_trace_id: str, + error: Exception, + chat_id: str, + sender_open_id: Optional[str], + sender_name: str, + message_id: str, + perf_t0: Optional[float], + ) -> Dict[str, Any]: + self._emit_state_transition( + trace_id=state_trace_id, + state="ERROR_REPLY", + reason="capture_exception", + error=str(error), + ) + if chat_id: + await self._safe_reply_text( + chat_id=chat_id, + mention_open_id=sender_open_id, + mention_name=sender_name, + text=self._build_error_reply_text(), + trace_id=state_trace_id, + source_message_id=message_id, + perf_t0=perf_t0, + ) + self._emit_state_transition( + trace_id=state_trace_id, + state="REPLIED", + route="error_template", + ) + return { + "code": 0, + "msg": "error_handled", + "data": { + "status": "error", + "classification": "error", + "ai_trace_id": state_trace_id, + }, + } + + async def _finalize_receive_result( + self, + *, + result: Dict[str, Any], + state_trace_id: str, + message_id: str, + message_content: str, + chat_id: str, + chat_type: str, + sender_open_id: Optional[str], + sender_name: str, + mentioned_bot_names: List[str], + perf_t0: Optional[float], + ) -> Dict[str, Any]: + print( + "[Feishu] Message processed: " + f"status={result.get('status')}, " + f"classification={result.get('classification')}, " + f"linked_projects={result.get('linked_project_ids', [])}, " + f"auto_tasks={len(result.get('auto_tasks', []) if isinstance(result.get('auto_tasks', []), list) else [])}, " + f"ai_trace_id={result.get('ai_trace_id')}" + ) + scene = self._classify_message_scene(capture_result=result) + self._emit_state_transition( + trace_id=state_trace_id, + state="CLASSIFIED", + scene=scene, + status=str(result.get("status") or "unknown"), + classification=str(result.get("classification") or "unknown"), + ) + if str(result.get("status") or "").lower() != "error": + self._emit_state_transition( + trace_id=state_trace_id, + state="COMMITTED", + ai_trace_id=str(result.get("ai_trace_id") or ""), + ) + + reply_route = await self._route_scene_reply( + scene=scene, + result=result, + message_content=message_content, + chat_id=chat_id, + sender_open_id=sender_open_id, + sender_name=sender_name, + mentioned_bot_names=mentioned_bot_names, + trace_id=state_trace_id, + message_id=message_id, + perf_t0=perf_t0, + ) + if reply_route != "none": + self._emit_state_transition( + trace_id=state_trace_id, + state="REPLIED", + route=reply_route, + ) + + await self._record_message_trace( + trace_id=state_trace_id, + message_id=message_id, + chat_type=chat_type, + message_content=message_content, + capture_result=result, + reply_source=reply_route, + ) + return { + "code": 0, + "msg": "success", + "data": { + "status": result.get("status"), + "classification": result.get("classification"), + "linked_project_ids": result.get("linked_project_ids", []), + "auto_tasks_count": len( + result.get("auto_tasks", []) + if isinstance(result.get("auto_tasks", []), list) + else [] + ), + "ai_trace_id": result.get("ai_trace_id"), + "state_trace_id": state_trace_id, + "scene": scene, + }, + } diff --git a/backend/app/services/integrations/feishu_integration_service/mixin_receive_prepare.py b/backend/app/services/integrations/feishu_integration_service/mixin_receive_prepare.py new file mode 100644 index 00000000..ae3241d4 --- /dev/null +++ b/backend/app/services/integrations/feishu_integration_service/mixin_receive_prepare.py @@ -0,0 +1,123 @@ +from __future__ import annotations + +from .shared import * + + +class FeishuReceivePrepareMixin: + @staticmethod + def _extract_perf_t0(event: Dict[str, Any]) -> Optional[float]: + perf_t0_raw = event.get("_perf_t0") + if isinstance(perf_t0_raw, (int, float)): + return float(perf_t0_raw) + try: + return float(str(perf_t0_raw).strip()) + except Exception: + return None + + def _build_receive_context( + self, + *, + event: Dict[str, Any], + state_trace_id: str, + ) -> Dict[str, Any]: + ingress_source = str(event.get("_ingress_source") or "feishu").strip() or "feishu" + message = event.get("message", {}) if isinstance(event.get("message", {}), dict) else {} + chat = event.get("chat", {}) if isinstance(event.get("chat", {}), dict) else {} + sender = event.get("sender", {}) if isinstance(event.get("sender", {}), dict) else {} + message_id = (message.get("message_id") or "").strip() or "unknown" + perf_t0 = self._extract_perf_t0(event) + sender_type = str(sender.get("sender_type") or "").strip().lower() + mentioned_bot_names = self._extract_mentioned_bot_names(message) + message_payload = self._extract_message_payload(event) + raw_message_content = str(message_payload.get("text") or "").strip() or None + message_content = ( + self._strip_bot_mention_tokens(raw_message_content) + if raw_message_content + else "" + ).strip() or None + sender_id = sender.get("sender_id", {}) if isinstance(sender.get("sender_id", {}), dict) else {} + sender_open_id = (sender_id.get("open_id") or "").strip() or None + chat_id = chat.get("chat_id") or message.get("chat_id") or "" + chat_type = str(chat.get("chat_type") or message.get("chat_type") or "").strip().lower() or "group" + captured_at = self._extract_message_datetime(message) + occurred_at_iso = captured_at.isoformat() if isinstance(captured_at, datetime) else None + + self._emit_state_transition( + trace_id=state_trace_id, + state="RECEIVED", + message_id=message_id, + message_type=str(message.get("message_type") or "unknown"), + ) + + return { + "ingress_source": ingress_source, + "message": message, + "chat": chat, + "sender": sender, + "message_id": message_id, + "perf_t0": perf_t0, + "sender_type": sender_type, + "mentioned_bot_names": mentioned_bot_names, + "raw_message_content": raw_message_content, + "message_content": message_content, + "message_attachments": ( + message_payload.get("attachments") + if isinstance(message_payload.get("attachments"), list) + else [] + ), + "sender_id": sender_id, + "sender_open_id": sender_open_id, + "chat_id": chat_id, + "chat_type": chat_type, + "captured_at": captured_at, + "occurred_at_iso": occurred_at_iso, + } + + async def _reply_for_empty_text_ingress( + self, + *, + state_trace_id: str, + message: Dict[str, Any], + sender: Dict[str, Any], + message_id: str, + chat_id: str, + mentioned_bot_names: List[str], + sender_open_id: Optional[str], + perf_t0: Optional[float], + ) -> bool: + if chat_id and mentioned_bot_names: + await self._safe_reply_text( + chat_id=chat_id, + mention_open_id=sender_open_id, + mention_name=(sender.get("sender_name") or sender.get("name") or "你"), + text="我已收到你的消息,但目前无法直接解析纯图片内容。请补充 1-2 句文字需求,我会立即给出分派建议。", + trace_id=state_trace_id, + source_message_id=message_id, + perf_t0=perf_t0, + ) + self._emit_state_transition( + trace_id=state_trace_id, + state="REPLIED", + route="empty_text_prompt", + ) + print( + f"[Feishu] message.receive_v1 ignored: no text content, " + f"message_type={message.get('message_type')}, keys={list(message.keys())}" + ) + return True + + def _normalize_message_attachments_from_context( + self, + *, + context: Dict[str, Any], + ) -> List[Dict[str, Any]]: + message = context.get("message") if isinstance(context.get("message"), dict) else {} + chat_id = str(context.get("chat_id") or "") + return self._normalize_ingress_attachment_records( + context.get("message_attachments") + if isinstance(context.get("message_attachments"), list) + else [], + occurred_at=context.get("occurred_at_iso"), + source_message_id=(message.get("message_id") or "").strip() or None, + source_chat_id=chat_id or None, + ) diff --git a/backend/app/services/integrations/feishu_integration_service/mixin_receive_routing.py b/backend/app/services/integrations/feishu_integration_service/mixin_receive_routing.py index 76e41e3f..4009108d 100644 --- a/backend/app/services/integrations/feishu_integration_service/mixin_receive_routing.py +++ b/backend/app/services/integrations/feishu_integration_service/mixin_receive_routing.py @@ -55,6 +55,20 @@ class FeishuReceiveRoutingMixin: ) if not deterministic_reply and str(result.get("status") or "").lower() == "error": deterministic_reply = self._build_error_reply_text() + action_card = self._build_action_card_from_capture_result( + capture_result=result, + mention_open_id=sender_open_id, + mention_name=sender_name, + ) + if chat_id and isinstance(action_card, dict): + await self._safe_reply_card( + chat_id=chat_id, + card_content=action_card, + trace_id=trace_id, + source_message_id=message_id, + perf_t0=perf_t0, + ) + return "execute_action_card" if chat_id and deterministic_reply: await self._safe_reply_text( chat_id=chat_id, @@ -156,4 +170,3 @@ class FeishuReceiveRoutingMixin: safe_state = str(state or "unknown") details = ", ".join([f"{k}={v}" for k, v in payload.items()]) if payload else "" print(f"[Feishu-SM][{safe_trace}] state={safe_state}{', ' + details if details else ''}") - diff --git a/backend/app/services/integrations/feishu_integration_service/mixin_reply_outbox.py b/backend/app/services/integrations/feishu_integration_service/mixin_reply_outbox.py index 739098b2..4208ab85 100644 --- a/backend/app/services/integrations/feishu_integration_service/mixin_reply_outbox.py +++ b/backend/app/services/integrations/feishu_integration_service/mixin_reply_outbox.py @@ -3,6 +3,29 @@ from __future__ import annotations from .shared import * class FeishuReplyOutboxMixin: + _CARD_MESSAGE_PREFIX = "__pmflow_feishu_card__:" + + @classmethod + def _is_card_outbox_message(cls, value: Optional[str]) -> bool: + return str(value or "").startswith(cls._CARD_MESSAGE_PREFIX) + + @classmethod + def _encode_outbox_card_message(cls, *, card_content: dict[str, Any]) -> str: + return f"{cls._CARD_MESSAGE_PREFIX}{json.dumps(card_content, ensure_ascii=False, separators=(',', ':'))}" + + @classmethod + def _decode_outbox_card_message(cls, value: Optional[str]) -> Optional[dict[str, Any]]: + raw = str(value or "") + if not raw.startswith(cls._CARD_MESSAGE_PREFIX): + return None + payload_text = raw[len(cls._CARD_MESSAGE_PREFIX):].strip() + if not payload_text: + return None + parsed = json.loads(payload_text) + if isinstance(parsed, dict): + return parsed + return None + async def _safe_reply_text( self, *, @@ -47,6 +70,42 @@ class FeishuReplyOutboxMixin: # 统一由 drain_loop 消费 outbox,禁止业务路径直发,避免双发送。 return + async def _safe_reply_card( + self, + *, + chat_id: str, + card_content: dict[str, Any], + trace_id: Optional[str] = None, + source_message_id: Optional[str] = None, + perf_t0: Optional[float] = None, + ) -> None: + try: + payload = self._encode_outbox_card_message(card_content=card_content) + outbox_id = await self._enqueue_reply_outbox( + chat_id=chat_id, + message_text=payload, + trace_id=trace_id, + source_message_id=source_message_id, + ) + perf_t1 = time.perf_counter() + if outbox_id > 0 and perf_t0 is not None: + _OUTBOX_PERF_T0_BY_ID[int(outbox_id)] = float(perf_t0) + if perf_t0 is not None: + print( + f"[PERF] outbox_written ts={now_cn_naive().isoformat()} T1={perf_t1:.6f} " + f"delta={perf_t1 - perf_t0:.2f}s " + f"outbox_id={outbox_id}" + ) + else: + print( + f"[PERF] outbox_written ts={now_cn_naive().isoformat()} T1={perf_t1:.6f} " + f"delta=N/A " + f"outbox_id={outbox_id}" + ) + except Exception as exc: + print(f"[Feishu] enqueue card outbox failed: chat_id={chat_id}, error={exc}") + return + async def _enqueue_reply_outbox( self, *, @@ -57,7 +116,10 @@ class FeishuReplyOutboxMixin: ) -> int: async with AsyncSessionLocal() as db: normalized_source_message_id = (source_message_id or "")[:128] or None - normalized_message_text = (message_text or "")[:2000] or "已收到,正在处理。" + if self._is_card_outbox_message(message_text): + normalized_message_text = (message_text or "")[:12000] + else: + normalized_message_text = (message_text or "")[:2000] or "已收到,正在处理。" reply_hash = self._build_reply_hash( source_message_id=normalized_source_message_id, message_text=normalized_message_text, @@ -229,10 +291,20 @@ class FeishuReplyOutboxMixin: async with AsyncSessionLocal() as db_send: service = cls(db_send) await service.load_config(tenant_id=int(claimed.get("tenant_id") or 1)) - await service.send_message_to_group( - chat_id=str(claimed.get("chat_id") or ""), - message=str(claimed.get("message_text") or ""), - ) + raw_message = str(claimed.get("message_text") or "") + if cls._is_card_outbox_message(raw_message): + card_content = cls._decode_outbox_card_message(raw_message) + if not isinstance(card_content, dict): + raise ValueError("invalid_outbox_card_payload") + await service.send_interactive_card( + chat_id=str(claimed.get("chat_id") or ""), + card_content=card_content, + ) + else: + await service.send_message_to_group( + chat_id=str(claimed.get("chat_id") or ""), + message=raw_message, + ) except Exception as exc: send_error = str(exc) diff --git a/backend/app/services/integrations/feishu_integration_service/mixin_transport.py b/backend/app/services/integrations/feishu_integration_service/mixin_transport.py index f10c2975..e8846f62 100644 --- a/backend/app/services/integrations/feishu_integration_service/mixin_transport.py +++ b/backend/app/services/integrations/feishu_integration_service/mixin_transport.py @@ -153,17 +153,6 @@ class FeishuTransportMixin: 用于发送格式化的简报、通知等 """ - if not self.is_configured(): - raise ValueError("飞书未配置") - - access_token = await self._get_tenant_access_token() - - url = "https://open.feishu.cn/open-apis/im/v1/messages" - headers = { - 'Authorization': f'Bearer {access_token}', - 'Content-Type': 'application/json' - } - # 构造卡片内容 card_content = { 'config': {'wide_screen_mode': True}, @@ -188,10 +177,30 @@ class FeishuTransportMixin: elif item.get('type') == 'divider': card_content['elements'].append({'tag': 'hr'}) + return await self.send_interactive_card( + chat_id=chat_id, + card_content=card_content, + ) + + async def send_interactive_card( + self, + chat_id: str, + card_content: Dict[str, Any], + ) -> Dict[str, Any]: + """发送交互卡片消息(支持按钮回调 value 透传)。""" + if not self.is_configured(): + raise ValueError("飞书未配置") + + access_token = await self._get_tenant_access_token() + url = "https://open.feishu.cn/open-apis/im/v1/messages" + headers = { + 'Authorization': f'Bearer {access_token}', + 'Content-Type': 'application/json' + } message_data = { 'receive_id': chat_id, 'msg_type': 'interactive', - 'content': json.dumps(card_content) + 'content': json.dumps(card_content, ensure_ascii=False), } async with httpx.AsyncClient(timeout=20.0, trust_env=False) as client: @@ -215,4 +224,3 @@ class FeishuTransportMixin: f"飞书发送失败: code={payload.get('code')} msg={payload.get('msg') or payload.get('message')}" ) return payload - diff --git a/backend/app/services/integrations/feishu_integration_service/service.py b/backend/app/services/integrations/feishu_integration_service/service.py index 33a03497..dd54a7d7 100644 --- a/backend/app/services/integrations/feishu_integration_service/service.py +++ b/backend/app/services/integrations/feishu_integration_service/service.py @@ -1,6 +1,9 @@ from __future__ import annotations +from .mixin_card_action import FeishuCardActionMixin from .mixin_config import FeishuConfigMixin +from .mixin_receive_capture_flow import FeishuReceiveCaptureFlowMixin +from .mixin_receive_prepare import FeishuReceivePrepareMixin from .mixin_message_payload import FeishuMessagePayloadMixin from .mixin_receive import FeishuReceiveMixin from .mixin_receive_routing import FeishuReceiveRoutingMixin @@ -15,7 +18,10 @@ from .shared import AsyncSession class FeishuIntegrationService( + FeishuCardActionMixin, FeishuConfigMixin, + FeishuReceivePrepareMixin, + FeishuReceiveCaptureFlowMixin, FeishuReceiveMixin, FeishuReceiveRoutingMixin, FeishuTraceMixin, 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 44dcf67e..792b85ef 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 @@ -18,6 +18,9 @@ class LongConnectionRuntimeMixin: if not callable(register_receive): raise RuntimeError("当前 lark_oapi 版本不支持 register_p2_im_message_receive_v1") 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) register_read = getattr(builder, "register_p2_im_message_message_read_v1", None) if callable(register_read): @@ -147,7 +150,12 @@ class LongConnectionRuntimeMixin: return event_type = str(event_data.get("type") or "").strip() - if event_type and event_type not in {"im.message.receive_v1", "message.receive_v1"}: + if event_type and event_type not in { + "im.message.receive_v1", + "message.receive_v1", + "card.action.trigger", + "im.card.action.trigger", + }: logger.info(f"feishu_long_connection_event_skipped: event_type={event_type}") return diff --git a/backend/app/services/llm_service/mixin_prompt_profile_runtime.py b/backend/app/services/llm_service/mixin_prompt_profile_runtime.py index 7284ab41..34c74a0b 100644 --- a/backend/app/services/llm_service/mixin_prompt_profile_runtime.py +++ b/backend/app/services/llm_service/mixin_prompt_profile_runtime.py @@ -2,6 +2,11 @@ from __future__ import annotations from .shared import * +AI_CALL_LOG_RESPONSE_MAX_CHARS = 10_000 +AI_CALL_LOG_RESPONSE_TAIL_CHARS = 2_000 +AI_CALL_LOG_TRUNCATION_MARKER = "\n...[TRUNCATED]...\n" + + class LLMPromptProfileRuntimeMixin: async def _resolve_active_prompt_profile(self, db: AsyncSession, *, tenant_id: int) -> dict[str, str]: cached = self._prompt_profile_cache.get(int(tenant_id)) @@ -154,7 +159,7 @@ class LLMPromptProfileRuntimeMixin: "user_id": None, "function_name": function_name, "prompt": prompt[:8000], - "response": (response or "")[:8000], + "response": self._trim_ai_call_response(response), "tokens_used": tokens_used, "cost": cost, "status": status, @@ -174,6 +179,19 @@ class LLMPromptProfileRuntimeMixin: await session.rollback() return + @staticmethod + def _trim_ai_call_response(response: str) -> str: + text = str(response or "") + if len(text) <= AI_CALL_LOG_RESPONSE_MAX_CHARS: + return text + + tail_chars = min(AI_CALL_LOG_RESPONSE_TAIL_CHARS, AI_CALL_LOG_RESPONSE_MAX_CHARS // 3) + head_budget = AI_CALL_LOG_RESPONSE_MAX_CHARS - tail_chars - len(AI_CALL_LOG_TRUNCATION_MARKER) + if head_budget <= 0: + return text[:AI_CALL_LOG_RESPONSE_MAX_CHARS] + + return f"{text[:head_budget]}{AI_CALL_LOG_TRUNCATION_MARKER}{text[-tail_chars:]}" + @staticmethod def _estimate_cost(provider: str, model: str, tokens: int) -> float: _ = model @@ -242,4 +260,3 @@ class LLMPromptProfileRuntimeMixin: return "" cleaned = re.sub(r".*?", "", cleaned, flags=re.IGNORECASE | re.DOTALL) return cleaned.strip() - diff --git a/backend/app/services/signal/draft_generator_service.py b/backend/app/services/signal/draft_generator_service.py new file mode 100644 index 00000000..15f574ce --- /dev/null +++ b/backend/app/services/signal/draft_generator_service.py @@ -0,0 +1,352 @@ +"""Draft generation service for write-operation confirmation.""" + +from __future__ import annotations + +import asyncio +import json +from datetime import timedelta +from typing import Any, Optional + +from sqlalchemy import and_, select +from sqlalchemy.ext.asyncio import AsyncSession + +from ...config.ai_prompts import PROMPT_DRAFT_GENERATOR +from ...core.logging import get_logger +from ...models import User, UserWorkload, WorkItem +from ...utils.timezone import now_cn_naive +from ..llm_service import llm_service + +logger = get_logger(__name__) + +_DRAFT_TIMEOUT_HOURS = 24 +_FEASIBILITY_ALLOWED = {"合理", "待讨论", "不合理"} + + +class DraftGeneratorService: + """Generate and persist draft payload for write intents.""" + + def __init__(self, db: AsyncSession, *, tenant_id: int) -> None: + self.db = db + self.tenant_id = int(tenant_id) + + async def generate_and_persist( + self, + *, + work_item: WorkItem, + intent_name: str, + message_text: str, + world_model: dict[str, Any], + revision_note: Optional[str] = None, + ) -> dict[str, Any]: + current_version = int(work_item.draft_version or 0) + next_version = current_version + 1 + project = world_model.get("project") if isinstance(world_model.get("project"), dict) else {} + project_title = str(project.get("title") or project.get("name") or "未指定项目").strip() or "未指定项目" + project_description = str(project.get("description") or "").strip() or "暂无项目描述" + + members = await self._load_members(world_model=world_model) + recent_signals = self._normalize_recent_signals(world_model.get("recent_signals")) + prompt = PROMPT_DRAFT_GENERATOR.format( + project_title=project_title, + project_description=project_description, + members_json=json.dumps(members, ensure_ascii=False), + recent_signals=json.dumps(recent_signals, ensure_ascii=False), + user_message=str(message_text or "")[:2000], + intent_name=str(intent_name or "")[:64], + revision_note=str(revision_note or "")[:500], + ).strip() + + payload = await self._generate_draft_payload(prompt=prompt) + draft = self._normalize_draft_payload( + payload=payload, + members=members, + original_text=message_text, + version=next_version, + ) + + expires_at = now_cn_naive() + timedelta(hours=_DRAFT_TIMEOUT_HOURS) + work_item.draft_content = draft + work_item.draft_version = next_version + work_item.draft_expires_at = expires_at + work_item.confirm_message_id = None + + existing_execution = work_item.execution_result if isinstance(work_item.execution_result, dict) else {} + work_item.execution_result = { + **existing_execution, + "status": "draft_pending_confirm", + "draft": draft, + "draft_version": next_version, + "draft_expires_at": expires_at.isoformat(), + "revision_note": str(revision_note or "")[:500] or None, + } + + return { + "draft": draft, + "draft_text": self.render_draft_message(draft), + "draft_expires_at": expires_at, + "draft_version": next_version, + } + + async def _generate_draft_payload(self, *, prompt: str) -> dict[str, Any]: + try: + extraction = await asyncio.wait_for( + llm_service.generate_structured_object( + prompt, + db=self.db, + tenant_id=self.tenant_id, + function_name="draft_generator_v1", + use_prompt_profile=False, + temperature=0.0, + max_tokens=1400, + response_mode="fast", + ), + timeout=35.0, + ) + except Exception as exc: + logger.warning( + "draft_generator_llm_failed", + tenant_id=self.tenant_id, + error=str(exc), + ) + return {} + if extraction is None or not isinstance(extraction.payload, dict): + return {} + return extraction.payload + + async def _load_members(self, *, world_model: dict[str, Any]) -> list[dict[str, Any]]: + capacity = world_model.get("capacity") if isinstance(world_model.get("capacity"), list) else [] + workload_map: dict[int, float] = {} + for row in capacity: + if not isinstance(row, dict): + continue + user_id = self._safe_int(row.get("user_id")) + if user_id is None: + continue + ratio = self._safe_float(row.get("workload_ratio")) + workload_map[user_id] = max(0.0, min(100.0, ratio * 100.0)) + + user_ids = sorted(workload_map.keys()) + if user_ids: + rows = ( + await self.db.execute( + select(User.id, User.name, User.username) + .where( + and_( + User.tenant_id == int(self.tenant_id), + User.id.in_(user_ids), + User.is_active.is_(True), + ) + ) + ) + ).all() + members = [ + { + "id": int(row.id), + "name": str(row.name or row.username or f"成员#{int(row.id)}").strip()[:40], + "workload_percent": round(float(workload_map.get(int(row.id), 0.0)), 1), + } + for row in rows + ] + members.sort(key=lambda item: (float(item.get("workload_percent") or 0.0), int(item.get("id") or 0))) + return members[:20] + + rows = ( + await self.db.execute( + select(User.id, User.name, User.username, UserWorkload.workload_ratio) + .outerjoin( + UserWorkload, + and_( + UserWorkload.tenant_id == User.tenant_id, + UserWorkload.user_id == User.id, + ), + ) + .where( + and_( + User.tenant_id == int(self.tenant_id), + User.is_active.is_(True), + ) + ) + .order_by(User.id.asc()) + .limit(20) + ) + ).all() + members = [] + for row in rows: + members.append( + { + "id": int(row.id), + "name": str(row.name or row.username or f"成员#{int(row.id)}").strip()[:40], + "workload_percent": round(max(0.0, min(100.0, self._safe_float(row.workload_ratio) * 100.0)), 1), + } + ) + members.sort(key=lambda item: (float(item.get("workload_percent") or 0.0), int(item.get("id") or 0))) + return members + + @staticmethod + def _normalize_recent_signals(value: Any) -> list[dict[str, Any]]: + if not isinstance(value, list): + return [] + normalized: list[dict[str, Any]] = [] + for row in value[:8]: + if not isinstance(row, dict): + continue + normalized.append( + { + "id": DraftGeneratorService._safe_int(row.get("id")), + "title": str(row.get("title") or "").strip()[:80], + "type": str(row.get("signal_type") or "").strip()[:30], + "status": str(row.get("status") or "").strip()[:20], + } + ) + return normalized + + def _normalize_draft_payload( + self, + *, + payload: dict[str, Any], + members: list[dict[str, Any]], + original_text: str, + version: int, + ) -> dict[str, Any]: + title = self._normalize_title(str(payload.get("title") or "")) + summary = self._normalize_summary(str(payload.get("summary") or ""), fallback_text=original_text) + + feasibility = str(payload.get("feasibility") or "").strip() + if feasibility not in _FEASIBILITY_ALLOWED: + feasibility = "待讨论" + feasibility_reason = str(payload.get("feasibility_reason") or "").strip()[:30] + if not feasibility_reason: + feasibility_reason = "信息仍需补充,建议先确认边界。" + + assignee_map = { + int(item.get("id") or 0): item + for item in members + if self._safe_int(item.get("id")) is not None + } + assignee_id = self._safe_int(payload.get("recommended_assignee_id")) + if assignee_id is None or assignee_id not in assignee_map: + assignee_id = int(members[0]["id"]) if members else None + assignee = assignee_map.get(assignee_id) if assignee_id is not None else None + assignee_reason = str(payload.get("recommended_assignee_reason") or "").strip()[:80] + if not assignee_reason: + assignee_reason = "基于当前负载与任务匹配度给出的建议。" + + followup_plan = self._normalize_followup_plan(payload.get("followup_plan")) + if not followup_plan: + followup_plan = [ + {"when": "明天", "action": "确认触发时机与验收口径"}, + {"when": "一周后", "action": "复核执行效果并决定是否扩展"}, + ] + + workload = 0.0 + if assignee is not None: + workload = float(assignee.get("workload_percent") or 0.0) + + return { + "title": title, + "summary": summary, + "feasibility": feasibility, + "feasibility_reason": feasibility_reason, + "recommended_assignee_id": assignee_id, + "recommended_assignee_name": str((assignee or {}).get("name") or "待指定").strip()[:40] or "待指定", + "recommended_assignee_workload": round(max(0.0, min(100.0, workload)), 1), + "recommended_assignee_reason": assignee_reason, + "followup_plan": followup_plan, + "original_text": str(original_text or "")[:2000], + "version": int(version), + } + + @staticmethod + def render_draft_message(draft: dict[str, Any]) -> str: + title = str(draft.get("title") or "未命名需求").strip() or "未命名需求" + summary = str(draft.get("summary") or "暂无摘要").strip() or "暂无摘要" + feasibility = str(draft.get("feasibility") or "待讨论").strip() or "待讨论" + feasibility_reason = str(draft.get("feasibility_reason") or "").strip() + assignee_name = str(draft.get("recommended_assignee_name") or "待指定").strip() or "待指定" + assignee_workload = DraftGeneratorService._safe_float(draft.get("recommended_assignee_workload")) + assignee_reason = str(draft.get("recommended_assignee_reason") or "").strip() + plan = draft.get("followup_plan") if isinstance(draft.get("followup_plan"), list) else [] + + plan_lines: list[str] = [] + for row in plan[:2]: + if not isinstance(row, dict): + continue + when = str(row.get("when") or "待定").strip()[:20] or "待定" + action = str(row.get("action") or "补充执行动作").strip()[:80] or "补充执行动作" + plan_lines.append(f"· {when}:{action}") + if not plan_lines: + plan_lines = ["· 待定:补充跟进计划"] + + workload_text = f"{assignee_workload:.1f}" if assignee_workload % 1 else f"{int(assignee_workload)}" + feasibility_reason_line = feasibility_reason if feasibility_reason else "需补充约束后再确认。" + assignee_reason_line = assignee_reason if assignee_reason else "建议结合成员技能与当前负载综合判断。" + return "\n".join( + [ + "📋 已为你起草需求,请确认:", + "", + f"📌 {title}", + summary, + "", + f"✅ 可行性:{feasibility}", + feasibility_reason_line, + "", + f"👤 推荐负责人:{assignee_name} · 当前负载{workload_text}%", + assignee_reason_line, + "", + "📅 跟进计划:", + *plan_lines, + "", + "---", + "回复 确认 → 入库", + "回复 修改 [具体修改意见] → 重新起草", + "回复 取消 → 放弃", + "⏰ 24小时内未确认自动取消", + ] + ) + + @staticmethod + def _normalize_title(raw_title: str) -> str: + title = str(raw_title or "").strip() + if not title: + return "需求草稿" + return title[:20] + + @staticmethod + def _normalize_summary(raw_summary: str, *, fallback_text: str) -> str: + summary = " ".join(str(raw_summary or "").split()).strip() + if not summary: + summary = " ".join(str(fallback_text or "").split()).strip() + if not summary: + summary = "请确认该需求是否需要立即入库。" + return summary[:80] + + @staticmethod + def _normalize_followup_plan(value: Any) -> list[dict[str, str]]: + if not isinstance(value, list): + return [] + normalized: list[dict[str, str]] = [] + for row in value[:3]: + if not isinstance(row, dict): + continue + when = str(row.get("when") or "").strip()[:20] + action = str(row.get("action") or "").strip()[:80] + if not when or not action: + continue + normalized.append({"when": when, "action": action}) + return normalized + + @staticmethod + def _safe_int(value: Any) -> Optional[int]: + try: + if value is None or value == "": + return None + return int(value) + except Exception: + return None + + @staticmethod + def _safe_float(value: Any) -> float: + try: + return float(value or 0.0) + except Exception: + return 0.0 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 91bba63c..7f8ce81f 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 @@ -19,6 +19,8 @@ class SignalCaptureEntryMixin: feishu_message_id: Optional[str] = None, chat_type: Optional[str] = None, force_sync: bool = False, + message_sent_at: Optional[Any] = None, + session_snapshot: Optional[Dict[str, Any]] = None, ) -> Dict[str, Any]: """ 从飞书群聊捕获信号 @@ -48,20 +50,49 @@ class SignalCaptureEntryMixin: self._emit_ai_think("capture_skip_empty", trace_id=trace_id) return {"status": "ignored", "reason": "empty_or_emoji_only"} + source_message_id = str(feishu_message_id or "").strip() + if not source_message_id: + source_message_id = AIPMOKernelService._build_source_message_id( + source_system="feishu", + source_message_id=None, + channel_ref=group_chat_id, + sender=proposer_name, + message_text=cleaned_message, + ) + kernel = AIPMOKernelService(self.db, tenant_id=tenant_id) try: should_sync_process = bool(force_sync) + enqueue_session = None if not should_sync_process: - session = await kernel._get_or_create_session( + enqueue_session = await kernel._get_or_create_session( tenant_id=int(tenant_id), sender=proposer_name, ) should_sync_process = kernel._is_short_confirmation_message( message_text=cleaned_message, - session=session, + session=enqueue_session, + message_sent_at=message_sent_at, + session_snapshot=session_snapshot, ) if should_sync_process: + 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, + ) + ) return await self._process_feishu_with_kernel_sync( trace_id=trace_id, tenant_id=tenant_id, @@ -73,6 +104,8 @@ class SignalCaptureEntryMixin: captured_dt=captured_dt, feishu_message_id=feishu_message_id, chat_type=chat_type, + message_sent_at=effective_message_sent_at, + session_snapshot=effective_session_snapshot, ) if self._is_non_requirement_message(cleaned_message) and not self._is_question_like(cleaned_message): @@ -83,15 +116,23 @@ class SignalCaptureEntryMixin: ) return {"status": "ignored", "reason": "non_requirement_noise"} - source_message_id = str(feishu_message_id or "").strip() - if not source_message_id: - source_message_id = AIPMOKernelService._build_source_message_id( - source_system="feishu", - source_message_id=None, - channel_ref=group_chat_id, + if enqueue_session is None: + enqueue_session = await kernel._get_or_create_session( + tenant_id=int(tenant_id), sender=proposer_name, - message_text=cleaned_message, ) + enqueue_message_sent_at = self._resolve_enqueue_message_sent_at( + captured_dt=captured_dt, + message_sent_at=message_sent_at, + ) + enqueue_session_snapshot = 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=session_snapshot, + ) run_row, created = await AIOrchestrationRunService(self.db).enqueue_ingress_run( tenant_id=int(tenant_id), @@ -107,6 +148,14 @@ class SignalCaptureEntryMixin: max_retries=3, ) if created: + base_payload = run_row.result_payload if isinstance(run_row.result_payload, dict) else {} + run_row.result_payload = { + **base_payload, + "task_payload": { + "message_sent_at": enqueue_message_sent_at.isoformat(), + "session_snapshot": enqueue_session_snapshot, + }, + } await AIOrchestrationRunService(self.db).mark_ingress_ack_sent( tenant_id=int(tenant_id), trace_id=str(run_row.trace_id or ""), @@ -187,6 +236,8 @@ class SignalCaptureEntryMixin: captured_dt: datetime, feishu_message_id: Optional[str], chat_type: Optional[str], + message_sent_at: Optional[Any] = None, + session_snapshot: Optional[Dict[str, Any]] = None, ) -> Dict[str, Any]: pipeline_result = await kernel.process_feishu_message( group_chat_id=group_chat_id, @@ -196,6 +247,8 @@ class SignalCaptureEntryMixin: source_sender_open_id=proposer_open_id, captured_at=captured_dt, chat_type=chat_type, + message_sent_at=message_sent_at, + session_snapshot=session_snapshot, ) if not bool(pipeline_result.get("handled")): self._emit_ai_think( @@ -308,3 +361,135 @@ class SignalCaptureEntryMixin: "decision_level": None, "review_summary": None, } + + @staticmethod + def _resolve_enqueue_message_sent_at( + *, + captured_dt: datetime, + message_sent_at: Optional[Any], + ) -> datetime: + if isinstance(message_sent_at, datetime): + sent_at = message_sent_at + else: + sent_at = captured_dt + if sent_at.tzinfo is None: + return sent_at.replace(tzinfo=CN_TZ) + return sent_at + + @staticmethod + def _coerce_snapshot_datetime(value: Any) -> Optional[datetime]: + if isinstance(value, datetime): + return value + text_value = str(value or "").strip() + if not text_value: + return None + try: + parsed = datetime.fromisoformat(text_value.replace("Z", "+00:00")) + except Exception: + return None + if parsed.tzinfo is None: + return parsed + return parsed.astimezone(CN_TZ).replace(tzinfo=None) + + @staticmethod + def _coerce_optional_int(value: Any) -> Optional[int]: + try: + if value is None: + return None + result = int(value) + return result if result > 0 else None + except Exception: + return None + + async def _load_latest_ingress_context( + self, + *, + tenant_id: int, + sender: str, + channel_ref: str, + source_message_id: Optional[str], + ) -> Dict[str, Any]: + row = ( + await self.db.execute( + text( + """ + SELECT + r.id, + COALESCE(r.captured_at, r.created_at) AS last_message_at, + r.message_text, + w.intent AS resolved_intent, + COALESCE(w.project_id, r.project_hint_id) AS project_id + FROM ai_orchestration_runs r + LEFT JOIN work_items w + ON w.id = r.work_item_id + WHERE r.tenant_id = :tenant_id + AND r.run_type = 'ingress_feishu_message' + AND r.source_system = 'feishu' + AND COALESCE(r.sender, '') = :sender + AND COALESCE(r.channel_ref, '') = :channel_ref + AND (:source_message_id = '' OR COALESCE(r.source_message_id, '') <> :source_message_id) + ORDER BY r.id DESC + LIMIT 1 + """ + ), + { + "tenant_id": int(tenant_id), + "sender": str(sender or "").strip(), + "channel_ref": str(channel_ref or "").strip(), + "source_message_id": str(source_message_id or "").strip(), + }, + ) + ).mappings().first() + return dict(row or {}) + + async def _build_short_confirm_session_snapshot( + self, + *, + session: Optional[Any], + tenant_id: int, + sender: str, + channel_ref: str, + source_message_id: Optional[str], + fallback_snapshot: Optional[Dict[str, Any]] = None, + ) -> Dict[str, Any]: + fallback = fallback_snapshot if isinstance(fallback_snapshot, dict) else {} + recent_context = await self._load_latest_ingress_context( + tenant_id=int(tenant_id), + sender=sender, + channel_ref=channel_ref, + source_message_id=source_message_id, + ) + session_last_active = self._coerce_snapshot_datetime(getattr(session, "last_active_at", None)) if session is not None else None + recent_last_active = self._coerce_snapshot_datetime(recent_context.get("last_message_at")) + fallback_last_active = self._coerce_snapshot_datetime(fallback.get("last_active_at")) + + if isinstance(session_last_active, datetime) and isinstance(recent_last_active, datetime): + effective_last_active = recent_last_active if recent_last_active > session_last_active else session_last_active + else: + effective_last_active = recent_last_active or session_last_active or fallback_last_active + + session_last_intent = str(getattr(session, "last_intent", "") or "").strip() if session is not None else "" + recent_last_intent = str(recent_context.get("resolved_intent") or "").strip() + fallback_last_intent = str(fallback.get("last_intent") or "").strip() + effective_last_intent = session_last_intent or recent_last_intent or fallback_last_intent + + recent_message_text = str(recent_context.get("message_text") or "").strip() + session_message_text = str(getattr(session, "last_message_text", "") or "").strip() if session is not None else "" + fallback_message_text = str(fallback.get("last_message_text") or "").strip() + effective_message_text = recent_message_text or session_message_text or fallback_message_text + + if not effective_last_intent and effective_message_text: + effective_last_intent = "query.read_only" + + effective_project_id = ( + self._coerce_optional_int(getattr(session, "current_project_id", None) if session is not None else None) + or self._coerce_optional_int(recent_context.get("project_id")) + or self._coerce_optional_int(fallback.get("current_project_id")) + ) + + return { + "last_intent": effective_last_intent or None, + "last_active_at": effective_last_active.isoformat() if isinstance(effective_last_active, datetime) else None, + "current_project_id": effective_project_id, + "last_message_text": effective_message_text or None, + } diff --git a/backend/app/services/skill/skill_score_service.py b/backend/app/services/skill/skill_score_service.py index 8db130df..8a9f933e 100644 --- a/backend/app/services/skill/skill_score_service.py +++ b/backend/app/services/skill/skill_score_service.py @@ -211,7 +211,7 @@ async def process_task_completion( user_id=operator_user_id or int(task.assignee_id), function_name="skill_score_update", prompt=prompt, - response=(response_text or json.dumps({"deltas": deltas}, ensure_ascii=False))[:8000], + response=(response_text or json.dumps({"deltas": deltas}, ensure_ascii=False))[:10000], tokens_used=max(len(prompt) // 4, 1) + max(len(response_text) // 4, 0), cost=((max(len(prompt) // 4, 1) * 0.00015 + max(len(response_text) // 4, 0) * 0.0006) / 1000), status=status, @@ -219,4 +219,3 @@ async def process_task_completion( ) await db.commit() return {"updated_count": updated, "status": status} - diff --git a/backend/app/services/workload/ai_optimize.py b/backend/app/services/workload/ai_optimize.py index df7ac7c7..82042711 100644 --- a/backend/app/services/workload/ai_optimize.py +++ b/backend/app/services/workload/ai_optimize.py @@ -182,7 +182,7 @@ async def build_ai_optimize_preview( user_id=None, function_name="workload_optimize_prompt", prompt=llm_prompt, - response=(llm_text or "")[:8000], + response=(llm_text or "")[:10000], tokens_used=max(len(llm_prompt) // 4, 1) + max(len(llm_text) // 4, 0), cost=((max(len(llm_prompt) // 4, 1) * 0.00015 + max(len(llm_text) // 4, 0) * 0.0006) / 1000), status=llm_status, @@ -289,4 +289,3 @@ async def apply_ai_optimize_plan( await db.commit() return {"applied_count": applied, "affected_users": len(touched_user_ids)} - diff --git a/backend/tests/test_adaptive_rag_mainline_contract.py b/backend/tests/test_adaptive_rag_mainline_contract.py new file mode 100644 index 00000000..6540dc0a --- /dev/null +++ b/backend/tests/test_adaptive_rag_mainline_contract.py @@ -0,0 +1,97 @@ +import pytest + +from app.services.adaptive_rag_service.mixin_answer_entry import AdaptiveRAGAnswerEntryMixin +from app.services.adaptive_rag_service.mixin_answer_mainline import AdaptiveRAGAnswerMainlineMixin + + +class _FakeAdaptiveRAGService( + AdaptiveRAGAnswerEntryMixin, + AdaptiveRAGAnswerMainlineMixin, +): + def __init__(self) -> None: + self.db = object() + self.tenant_id = 9 + self.ack_calls = 0 + self.base_calls = 0 + self.base_kwargs: dict[str, object] = {} + + def _normalize_thinking_mode(self, thinking_mode: str) -> str: + return "deep" if str(thinking_mode or "").strip().lower() == "deep" else "normal" + + async def _prepare_answer_runtime(self, **kwargs): + clean_q = str(kwargs.get("clean_q") or "").strip() + action_mode = str(kwargs.get("action_mode") or "").strip().lower() + return { + "session_id": 101, + "turn_index": 2, + "context_session_id": 101, + "conversation": [], + "state_row": None, + "effective_q": clean_q, + "rewrite_meta": {"mode": "direct"}, + "query_type": "explain", + "effective_action_mode": action_mode, + } + + async def _handle_ack_first_requirement(self, **kwargs): + self.ack_calls += 1 + _ = kwargs + return {"answer": "ack-required", "processing_stage": "ack_only"} + + async def _execute_base_answer_mainline(self, **kwargs): + self.base_calls += 1 + self.base_kwargs = dict(kwargs) + return {"answer": "base-answer", "processing_stage": "qa"} + + +@pytest.mark.asyncio +async def test_answer_entry_read_mainline_skips_requirement_ack() -> None: + service = _FakeAdaptiveRAGService() + + result = await service.answer_question( + question="现在有哪些阻塞?", + asked_by=1, + asked_via="pmflow_manual", + action_mode="read", + sync_requirement=True, + ) + + assert result["processing_stage"] == "qa" + assert service.ack_calls == 0 + assert service.base_calls == 1 + + +@pytest.mark.asyncio +async def test_answer_entry_write_mode_uses_requirement_ack_gate() -> None: + service = _FakeAdaptiveRAGService() + + result = await service.answer_question( + question="请同步写入一条风险", + asked_by=1, + asked_via="pmflow_manual", + action_mode="write", + sync_requirement=True, + ) + + assert result["processing_stage"] == "ack_only" + assert service.ack_calls == 1 + assert service.base_calls == 0 + + +@pytest.mark.asyncio +async def test_answer_entry_feishu_write_mode_still_goes_base_mainline() -> None: + service = _FakeAdaptiveRAGService() + + result = await service.answer_question( + question="请在飞书里补一条风险", + asked_by=1, + asked_via="feishu", + action_mode="write", + sync_requirement=True, + ) + + 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 diff --git a/backend/tests/test_ai_pmo_execution_ledger_contract.py b/backend/tests/test_ai_pmo_execution_ledger_contract.py new file mode 100644 index 00000000..466bb159 --- /dev/null +++ b/backend/tests/test_ai_pmo_execution_ledger_contract.py @@ -0,0 +1,147 @@ +from types import SimpleNamespace + +from app.services.ai_pmo.ai_pmo_intent import ( + INTENT_QUERY, + INTENT_SIGNAL_CREATE, + CanonicalIntent, +) +from app.services.ai_pmo.ai_pmo_kernel.mixins.ingress.ai_pmo_kernel_mixins_ingress_storage import ( + KernelIngressStorageMixin, +) + + +class _FakeStorage(KernelIngressStorageMixin): + def __init__(self) -> None: + self.tenant_id = 9 + + @staticmethod + def _primary_operation_code(intent_name: str) -> str | None: + normalized = str(intent_name or "").strip().lower() + if normalized == "project_signal.discover": + return "project_signal.discover" + if normalized == INTENT_QUERY: + return "query.answer" + return None + + +def _build_intent(name: str, *, project_id: int = 11) -> CanonicalIntent: + return CanonicalIntent( + name=name, + confidence=0.9, + project_id=project_id, + signal_title="", + signal_summary="", + metadata={}, + ) + + +def test_build_execution_action_contract_uses_plan_route_and_result() -> None: + storage = _FakeStorage() + contract = storage._build_execution_action_contract( + intent=_build_intent(INTENT_SIGNAL_CREATE), + workflow_route=SimpleNamespace( + key="project_signal.discover", + action="discover", + project_id=22, + requested_count=3, + ), + decision_payload={ + "execution_plan": { + "project_id": 33, + "actions": [ + {"operation_code": "project_signal.discover", "mode": "write"}, + {"operation_code": "signal.enter", "mode": "write"}, + ], + } + }, + execution_result={"signal_id": 88, "requested_count": 5}, + source_system="feishu", + source_message_id="msg-1", + channel_ref="oc_xxx", + ) + + assert contract["operation_code"] == "project_signal.discover" + assert contract["mode"] == "write" + assert contract["workflow_route"] == "project_signal.discover" + assert contract["workflow_action"] == "discover" + assert contract["params"]["project_id"] == 22 + assert contract["params"]["requested_count"] == 5 + assert contract["params"]["signal_id"] == 88 + assert contract["params"]["source_system"] == "feishu" + assert contract["params"]["source_message_id"] == "msg-1" + assert contract["params"]["channel_ref"] == "oc_xxx" + assert contract["param_source"] == "decision.execution_plan.actions+workflow_route+execution_result" + + +def test_apply_execution_action_contract_sets_uniform_fields() -> None: + storage = _FakeStorage() + applied = storage._apply_execution_action_contract( + intent=_build_intent(INTENT_QUERY, project_id=77), + workflow_route=SimpleNamespace( + key=INTENT_QUERY, + action="answer", + project_id=77, + requested_count=None, + ), + decision_payload={"execution_plan": {"actions": []}}, + execution_result={"status": "succeeded"}, + source_system="pmflow_manual", + source_message_id="manual-1", + channel_ref=None, + ) + + assert applied["action_param_source"] == "decision.execution_plan.actions+workflow_route+execution_result" + assert applied["operation_code"] == "query.answer" + action_contract = applied["action_contract"] + assert action_contract["operation_code"] == "query.answer" + assert action_contract["mode"] == "read_only" + assert action_contract["params"]["project_id"] == 77 + + +def test_execution_ledger_idempotency_key_is_stable() -> None: + key_a = KernelIngressStorageMixin._build_execution_ledger_idempotency_key( + tenant_id=9, + work_item_id=101, + operation_code="task.create", + source_message_id="feishu-msg-abc", + ) + key_b = KernelIngressStorageMixin._build_execution_ledger_idempotency_key( + tenant_id=9, + work_item_id=101, + operation_code="task.create", + source_message_id="feishu-msg-abc", + ) + key_c = KernelIngressStorageMixin._build_execution_ledger_idempotency_key( + tenant_id=9, + work_item_id=102, + operation_code="task.create", + source_message_id="feishu-msg-abc", + ) + + assert key_a == key_b + assert key_a != key_c + assert len(key_a) == 40 + + +def test_normalize_execution_ledger_status_maps_work_item_state() -> None: + assert ( + KernelIngressStorageMixin._normalize_execution_ledger_status( + work_item_state="succeeded", + execution_status="failed_safe", + ) + == "applied" + ) + assert ( + KernelIngressStorageMixin._normalize_execution_ledger_status( + work_item_state="waiting_manual", + execution_status="succeeded", + ) + == "pending_manual" + ) + assert ( + KernelIngressStorageMixin._normalize_execution_ledger_status( + work_item_state="failed", + execution_status="succeeded", + ) + == "failed" + ) diff --git a/backend/tests/test_ai_pmo_ingress_mainline.py b/backend/tests/test_ai_pmo_ingress_mainline.py new file mode 100644 index 00000000..6676951d --- /dev/null +++ b/backend/tests/test_ai_pmo_ingress_mainline.py @@ -0,0 +1,251 @@ +from types import SimpleNamespace + +import pytest +from fastapi import HTTPException + +from app.api.ai_pmo_api import routes_core +from app.api.ai_pmo_api.schemas import PMOProcessTextRequest +from app.services.ai_pmo.ai_pmo_kernel_ingress_flow import mixin_entry +from app.services.ai_pmo.ai_pmo_typed_executor_execute import mixin_execute_query_project + + +@pytest.mark.asyncio +async def test_process_text_route_uses_pmflow_manual_ingress(monkeypatch) -> None: + captured: dict[str, object] = {} + + class FakeKernel: + def __init__(self, db, tenant_id: int): + captured["init_db"] = db + captured["init_tenant_id"] = tenant_id + + async def process_ingress(self, **kwargs): + captured["ingress_kwargs"] = kwargs + return { + "handled": True, + "status": "answered", + "classification": "question", + "direct_reply_required": True, + "direct_reply_text": "当前主链正常。", + } + + monkeypatch.setattr(routes_core, "AIPMOKernelService", FakeKernel) + + request = PMOProcessTextRequest( + message_text="现在进展怎么样?", + sender="产品经理A", + source_message_id="manual-1", + project_id=11, + ) + current_user = SimpleNamespace(name="ignored", username="ignored") + + response = await routes_core.process_text( + request=request, + current_user=current_user, + db=object(), + tenant_id=5, + ) + + assert response["code"] == 0 + data = response["data"] + assert data["status"] == "answered" + kwargs = captured["ingress_kwargs"] + assert isinstance(kwargs, dict) + assert kwargs["source_system"] == "pmflow_manual" + assert kwargs["source_message_id"] == "manual-1" + assert kwargs["message_text"] == "现在进展怎么样?" + assert kwargs["sender"] == "产品经理A" + assert kwargs["channel_ref"] == "pmflow_manual" + assert kwargs["chat_type"] == "manual" + assert kwargs["project_hint_id"] == 11 + + +@pytest.mark.asyncio +async def test_process_text_route_rejects_unhandled_result(monkeypatch) -> None: + class FakeKernel: + def __init__(self, db, tenant_id: int): + _ = (db, tenant_id) + + async def process_ingress(self, **kwargs): + _ = kwargs + return {"handled": False, "reason": "kernel_not_handled"} + + monkeypatch.setattr(routes_core, "AIPMOKernelService", FakeKernel) + request = PMOProcessTextRequest(message_text="hello") + current_user = SimpleNamespace(name="u", username="u") + + with pytest.raises(HTTPException) as exc_info: + await routes_core.process_text( + request=request, + current_user=current_user, + db=object(), + tenant_id=1, + ) + assert exc_info.value.status_code == 400 + assert str(exc_info.value.detail) == "kernel_not_handled" + + +@pytest.mark.asyncio +async def test_prepare_mixed_sidecar_context_passes_mixed_contract(monkeypatch) -> None: + captured: dict[str, object] = {} + + class FakeAdaptiveRAGService: + def __init__(self, db, tenant_id: int): + captured["tenant_id"] = tenant_id + _ = db + + async def classify_intent(self, text: str): + captured["classified_text"] = text + return { + "intent": "mixed", + "question_part": "现在进展如何", + "statement_part": "新增任务确认流", + } + + async def answer_question(self, **kwargs): + captured["answer_kwargs"] = kwargs + return {"answer": "[回答] 进展稳定\n[已收到需求] 新增任务确认流"} + + class FakeKernel(mixin_entry.KernelIngressFlowEntryMixin): + def __init__(self) -> None: + self.db = object() + self.tenant_id = 9 + + monkeypatch.setattr(mixin_entry, "AdaptiveRAGService", FakeAdaptiveRAGService) + kernel = FakeKernel() + clean_text, mixed_meta = await kernel._prepare_mixed_sidecar_context( + source_system="pmflow_manual", + source_message_id="manual-msg-2", + channel_ref="pmflow-chat", + message_text="顺便问下现在进展如何,并且新增任务确认流", + project_hint_id=22, + ) + + assert clean_text == "新增任务确认流" + assert mixed_meta["mixed_intent"] is True + assert mixed_meta["mixed_question_part"] == "现在进展如何" + assert mixed_meta["mixed_signal_part"] == "新增任务确认流" + assert "[回答] 进展稳定" in str(mixed_meta.get("mixed_sidecar_reply") or "") + answer_kwargs = captured["answer_kwargs"] + assert isinstance(answer_kwargs, dict) + assert answer_kwargs["asked_via"] == "pmflow_manual" + assert answer_kwargs["message_intent"] == "mixed" + assert answer_kwargs["mixed_signal_part"] == "新增任务确认流" + assert answer_kwargs["conversation_key"] == "manual-msg-2" + + +@pytest.mark.asyncio +async def test_process_ingress_prioritizes_draft_pending_confirm(monkeypatch) -> None: + class FakeKernel(mixin_entry.KernelIngressFlowEntryMixin): + def __init__(self) -> None: + self.db = object() + self.tenant_id = 1 + self.draft_called = False + + async def _prepare_ingress_envelope(self, **kwargs): + _ = kwargs + return { + "early_result": None, + "clean_text": "确认", + "synthetic_message_id": "s-msg-1", + "closure_payload": {}, + "ingest_event": SimpleNamespace(id=100), + "operator": SimpleNamespace(id=8), + "work_item": SimpleNamespace(id=55), + "session": SimpleNamespace(last_intent=mixin_entry.STATE_DRAFT_PENDING_CONFIRM), + } + + async def _maybe_handle_draft_confirmation_ingress(self, **kwargs): + self.draft_called = True + _ = kwargs + return { + "handled": True, + "status": "answered", + "classification": "command", + "direct_reply_required": True, + "direct_reply_text": "已确认草稿并继续执行。", + } + + def _is_short_confirmation_message(self, **kwargs): + _ = kwargs + return False + + async def _prepare_mixed_sidecar_context(self, **kwargs): + _ = kwargs + raise AssertionError("draft_pending_confirm flow should bypass mixed sidecar precheck") + + kernel = FakeKernel() + result = await kernel.process_ingress( + source_system="feishu", + source_message_id="feishu-1", + message_text="确认", + sender="tester", + channel_ref="chat-1", + source_sender_open_id="ou_xxx", + ) + + assert kernel.draft_called is True + assert result["status"] == "answered" + assert "草稿" in result["direct_reply_text"] + + +@pytest.mark.asyncio +async def test_execute_query_intent_honors_rag_request_context(monkeypatch) -> None: + captured: dict[str, object] = {} + + class FakeAdaptiveRAGService: + def __init__(self, db, tenant_id: int): + _ = db + captured["tenant_id"] = tenant_id + + async def answer_question(self, **kwargs): + captured["answer_kwargs"] = kwargs + return {"answer": "来自统一 ingress 的回答", "session_id": 7} + + class FakeExecutor(mixin_execute_query_project.ExecuteQueryProjectMixin): + def __init__(self) -> None: + self.db = object() + self.tenant_id = 3 + + monkeypatch.setattr(mixin_execute_query_project, "AdaptiveRAGService", FakeAdaptiveRAGService) + executor = FakeExecutor() + intent = SimpleNamespace( + project_id=12, + metadata={ + "rag_request_context": { + "asked_by": 101, + "asked_via": "pmflow_manual", + "project_id": 19, + "source_chat_id": "chat-abc", + "session_id": 55, + "force_new_session": True, + "thinking_mode": "deep", + "action_mode": "read_write", + "sync_requirement": True, + "message_intent": "mixed", + "mixed_signal_part": "新增任务确认流", + } + }, + ) + result = await executor._execute_query_intent( + intent=intent, + message_text="请同步更新状态", + operator_user_id=999, + source_system="feishu", + source_message_id="msg-1", + ) + + assert result["status"] == "succeeded" + assert "统一 ingress" in result["answer"] + answer_kwargs = captured["answer_kwargs"] + assert isinstance(answer_kwargs, dict) + assert answer_kwargs["asked_by"] == 101 + assert answer_kwargs["asked_via"] == "pmflow_manual" + assert answer_kwargs["project_id"] == 19 + assert answer_kwargs["source_chat_id"] == "chat-abc" + assert answer_kwargs["session_id"] == 55 + assert answer_kwargs["force_new_session"] is True + assert answer_kwargs["thinking_mode"] == "deep" + assert answer_kwargs["action_mode"] == "read_write" + assert answer_kwargs["sync_requirement"] is True + assert answer_kwargs["message_intent"] == "mixed" + assert answer_kwargs["mixed_signal_part"] == "新增任务确认流" diff --git a/backend/tests/test_ai_pmo_qa_http_mainline_e2e.py b/backend/tests/test_ai_pmo_qa_http_mainline_e2e.py new file mode 100644 index 00000000..1b3042c7 --- /dev/null +++ b/backend/tests/test_ai_pmo_qa_http_mainline_e2e.py @@ -0,0 +1,168 @@ +from types import SimpleNamespace + +import pytest +from fastapi import FastAPI +from httpx import ASGITransport, AsyncClient + +from app.api.ai_pmo_api import routes_core +from app.api.rag_api import routes_query +from app.core.database import get_db +from app.dependencies.auth import get_current_user +from app.dependencies.tenant import get_tenant_id + + +def _build_test_app() -> FastAPI: + app = FastAPI() + app.include_router(routes_query.router) + app.include_router(routes_core.router) + + async def _fake_get_db(): + yield object() + + app.dependency_overrides[get_db] = _fake_get_db + app.dependency_overrides[get_tenant_id] = lambda: 9 + app.dependency_overrides[get_current_user] = lambda: SimpleNamespace( + id=101, + tenant_id=9, + name="测试用户", + username="tester", + ) + return app + + +@pytest.mark.asyncio +async def test_http_rag_ask_readonly_mainline_contract(monkeypatch) -> None: + captured: dict[str, object] = {} + + class FakeKernel: + def __init__(self, db, tenant_id: int): + _ = db + captured["tenant_id"] = tenant_id + + async def process_ingress(self, **kwargs): + captured["ingress_kwargs"] = kwargs + return { + "handled": True, + "status": "answered", + "intent": "query.read_only", + "direct_reply_required": True, + "direct_reply_text": "当前主链状态正常。", + "rag_result": {"answer": "当前主链状态正常。", "processing_stage": "qa"}, + } + + monkeypatch.setattr(routes_query, "AIPMOKernelService", FakeKernel) + app = _build_test_app() + transport = ASGITransport(app=app) + async with AsyncClient(transport=transport, base_url="http://test") as client: + response = await client.post( + "/rag/ask", + json={ + "question": "当前主链状态?", + "asked_via": "pmflow_manual", + "action_mode": "read", + }, + ) + + assert response.status_code == 200 + payload = response.json() + assert payload["code"] == 0 + assert "当前主链状态正常" in str((payload.get("data") or {}).get("answer") or "") + ingress_kwargs = captured["ingress_kwargs"] + assert isinstance(ingress_kwargs, dict) + assert ingress_kwargs["source_system"] == "pmflow_manual" + rag_request_context = ingress_kwargs["ingress_options"]["rag_request_context"] + assert rag_request_context["asked_via"] == "pmflow_manual" + assert rag_request_context["action_mode"] == "read" + assert rag_request_context["message_intent"] is None + + +@pytest.mark.asyncio +async def test_http_rag_ask_mixed_mainline_contract(monkeypatch) -> None: + captured: dict[str, object] = {} + + class FakeKernel: + def __init__(self, db, tenant_id: int): + _ = db + captured["tenant_id"] = tenant_id + + async def process_ingress(self, **kwargs): + captured["ingress_kwargs"] = kwargs + return { + "handled": True, + "status": "answered", + "intent": "query.read_only", + "direct_reply_required": True, + "direct_reply_text": "[回答] 进展稳定\n[已收到需求] 新增任务确认流", + "rag_result": {"answer": "[回答] 进展稳定\n[已收到需求] 新增任务确认流"}, + } + + monkeypatch.setattr(routes_query, "AIPMOKernelService", FakeKernel) + app = _build_test_app() + transport = ASGITransport(app=app) + async with AsyncClient(transport=transport, base_url="http://test") as client: + response = await client.post( + "/rag/ask", + json={ + "question": "顺便问进展并新增任务确认流", + "asked_via": "pmflow_manual", + "action_mode": "read", + "message_intent": "mixed", + "mixed_signal_part": "新增任务确认流", + }, + ) + + assert response.status_code == 200 + payload = response.json() + assert payload["code"] == 0 + assert "[回答]" in str((payload.get("data") or {}).get("answer") or "") + ingress_kwargs = captured["ingress_kwargs"] + assert isinstance(ingress_kwargs, dict) + rag_request_context = ingress_kwargs["ingress_options"]["rag_request_context"] + assert rag_request_context["message_intent"] == "mixed" + assert rag_request_context["mixed_signal_part"] == "新增任务确认流" + + +@pytest.mark.asyncio +async def test_http_ai_pmo_process_text_draft_pending_confirm_mainline(monkeypatch) -> None: + captured: dict[str, object] = {} + + class FakeKernel: + def __init__(self, db, tenant_id: int): + _ = db + captured["tenant_id"] = tenant_id + + async def process_ingress(self, **kwargs): + captured["ingress_kwargs"] = kwargs + return { + "handled": True, + "status": "draft_pending_confirm", + "classification": "command", + "direct_reply_required": True, + "direct_reply_text": "草稿已生成,请确认。", + "draft_version": 2, + } + + monkeypatch.setattr(routes_core, "AIPMOKernelService", FakeKernel) + app = _build_test_app() + transport = ASGITransport(app=app) + async with AsyncClient(transport=transport, base_url="http://test") as client: + response = await client.post( + "/ai-pmo/process-text", + json={ + "message_text": "请创建任务并先给草稿", + "sender": "产品经理A", + "source_message_id": "manual-msg-100", + "project_id": 12, + }, + ) + + assert response.status_code == 200 + payload = response.json() + assert payload["code"] == 0 + data = payload["data"] + assert data["status"] == "draft_pending_confirm" + kwargs = captured["ingress_kwargs"] + assert isinstance(kwargs, dict) + assert kwargs["source_system"] == "pmflow_manual" + assert kwargs["message_text"] == "请创建任务并先给草稿" + assert kwargs["project_hint_id"] == 12 diff --git a/backend/tests/test_ai_pmo_tactical_brain_contract.py b/backend/tests/test_ai_pmo_tactical_brain_contract.py new file mode 100644 index 00000000..f70f076a --- /dev/null +++ b/backend/tests/test_ai_pmo_tactical_brain_contract.py @@ -0,0 +1,73 @@ +import pytest + +from app.services.ai_pmo.ai_pmo_decision.tactical import TacticalBrain +from app.services.ai_pmo.ai_pmo_intent import ( + INTENT_FOLLOWUP_CONTINUE, + INTENT_QUERY, + INTENT_SIGNAL_CREATE, + ROUTE_PROJECT_SIGNAL_CANCEL, + ROUTE_PROJECT_SIGNAL_COMMIT, + ROUTE_PROJECT_SIGNAL_DISCOVER, + ROUTE_PROJECT_SIGNAL_REFRESH, + CanonicalIntent, +) + + +def _build_intent(name: str, *, metadata: dict | None = None) -> CanonicalIntent: + return CanonicalIntent( + name=name, + confidence=0.9, + project_id=11, + signal_title="", + signal_summary="", + metadata=metadata or {}, + ) + + +def test_tactical_brain_query_intent_maps_to_read_only_answer_action() -> None: + _strategic_plan, execution_plan = TacticalBrain.plan( + intent=_build_intent(INTENT_QUERY), + world_model={}, + decision={}, + ) + assert execution_plan["intent"] == INTENT_QUERY + assert execution_plan["actions"] == [{"operation_code": "query.answer", "mode": "read_only"}] + + +def test_tactical_brain_followup_intent_maps_to_resume_action() -> None: + _strategic_plan, execution_plan = TacticalBrain.plan( + intent=_build_intent(INTENT_FOLLOWUP_CONTINUE), + world_model={}, + decision={}, + ) + assert execution_plan["actions"] == [{"operation_code": "work_item.resume", "mode": "write"}] + + +@pytest.mark.parametrize( + ("workflow_route", "expected_operation_code"), + [ + (ROUTE_PROJECT_SIGNAL_DISCOVER, ROUTE_PROJECT_SIGNAL_DISCOVER), + (ROUTE_PROJECT_SIGNAL_COMMIT, ROUTE_PROJECT_SIGNAL_COMMIT), + (ROUTE_PROJECT_SIGNAL_REFRESH, ROUTE_PROJECT_SIGNAL_REFRESH), + (ROUTE_PROJECT_SIGNAL_CANCEL, ROUTE_PROJECT_SIGNAL_CANCEL), + ], +) +def test_tactical_brain_signal_create_route_specific_action_mapping( + workflow_route: str, + expected_operation_code: str, +) -> None: + _strategic_plan, execution_plan = TacticalBrain.plan( + intent=_build_intent(INTENT_SIGNAL_CREATE, metadata={"workflow_route": workflow_route}), + world_model={}, + decision={}, + ) + assert execution_plan["actions"] == [{"operation_code": expected_operation_code, "mode": "write"}] + + +def test_tactical_brain_signal_create_defaults_to_signal_enter() -> None: + _strategic_plan, execution_plan = TacticalBrain.plan( + intent=_build_intent(INTENT_SIGNAL_CREATE, metadata={"workflow_route": "project_signal.unknown"}), + world_model={}, + decision={}, + ) + assert execution_plan["actions"] == [{"operation_code": "signal.enter", "mode": "write"}] diff --git a/backend/tests/test_ai_pmo_task_intent_contract.py b/backend/tests/test_ai_pmo_task_intent_contract.py new file mode 100644 index 00000000..bb87dc35 --- /dev/null +++ b/backend/tests/test_ai_pmo_task_intent_contract.py @@ -0,0 +1,119 @@ +import pytest + +from app.services.ai_pmo.ai_pmo_intent import ( + INTENT_FOLLOWUP_CONTINUE, + INTENT_QUERY, + INTENT_TASK_CREATE, + ROUTE_ACTION_ANSWER, + ROUTE_ACTION_CANCEL, + ROUTE_ACTION_CREATE, + ROUTE_FAMILY_QUERY, + ROUTE_FAMILY_TASK, + ROUTE_QUERY_ANSWER, + ROUTE_TASK_CREATE, + CanonicalIntent, + IntentResolver, + RouteResolver, +) +from app.services.ai_pmo.ai_pmo_typed_executor_execute.service import TypedExecutorExecuteMixin + + +def test_intent_resolver_recognizes_task_create() -> None: + intent = IntentResolver.resolve( + text="请在项目#12 新建任务:修复登录超时", + project_id=12, + ) + assert intent.name == INTENT_TASK_CREATE + assert intent.project_id == 12 + assert str((intent.metadata or {}).get("task_title") or "").startswith("修复登录超时") + + +def test_route_resolver_builds_task_create_route() -> None: + route = RouteResolver.from_canonical( + canonical_intent=CanonicalIntent( + name=INTENT_TASK_CREATE, + confidence=0.9, + project_id=3, + signal_title="修复登录超时", + signal_summary="修复线上登录超时问题", + metadata={}, + ), + message_text="新增任务:修复登录超时", + ) + assert route.family == ROUTE_FAMILY_TASK + assert route.action == ROUTE_ACTION_CREATE + assert route.key == ROUTE_TASK_CREATE + + +def test_intent_resolver_negated_write_statement_falls_back_to_query_read_only() -> None: + intent = IntentResolver.resolve( + text="先不要创建任务,告诉我当前进展和风险", + project_id=7, + ) + assert intent.name == INTENT_QUERY + assert str((intent.metadata or {}).get("reason") or "") == "negated_write_statement" + + +def test_route_resolver_builds_query_answer_route() -> None: + route = RouteResolver.from_canonical( + canonical_intent=CanonicalIntent( + name=INTENT_QUERY, + confidence=0.92, + project_id=7, + signal_title="", + signal_summary="当前进展和风险", + metadata={}, + ), + message_text="当前进展和风险是什么?", + ) + assert route.family == ROUTE_FAMILY_QUERY + assert route.action == ROUTE_ACTION_ANSWER + assert route.key == ROUTE_QUERY_ANSWER + + +def test_route_resolver_followup_action_supports_message_text_fallback() -> None: + action = RouteResolver.parse_followup_action( + canonical_intent=INTENT_FOLLOWUP_CONTINUE, + message_text="先取消这个工作项", + ) + assert action == ROUTE_ACTION_CANCEL + + +class _DummyTypedExecutor(TypedExecutorExecuteMixin): + def __init__(self) -> None: + self.called: dict[str, object] = {} + + async def _execute_task_create_intent(self, *, intent, message_text: str, operator_user_id): + self.called = { + "intent": intent.name, + "message_text": message_text, + "operator_user_id": operator_user_id, + } + return {"status": "succeeded", "task_id": 77} + + +@pytest.mark.asyncio +async def test_typed_executor_dispatches_task_create() -> None: + executor = _DummyTypedExecutor() + intent = CanonicalIntent( + name=INTENT_TASK_CREATE, + confidence=0.88, + project_id=6, + signal_title="补充埋点", + signal_summary="补充行为埋点以支持漏斗分析", + metadata={"task_title": "补充埋点"}, + ) + + result = await executor.execute( + intent=intent, + message_text="请创建任务:补充埋点", + sender="alice", + operator_user_id=101, + source_system="feishu", + source_message_id="msg-1", + ) + + assert result["status"] == "succeeded" + assert result["task_id"] == 77 + assert executor.called["intent"] == INTENT_TASK_CREATE + assert executor.called["operator_user_id"] == 101 diff --git a/backend/tests/test_ai_service_core_runtime.py b/backend/tests/test_ai_service_core_runtime.py new file mode 100644 index 00000000..ab969cd2 --- /dev/null +++ b/backend/tests/test_ai_service_core_runtime.py @@ -0,0 +1,69 @@ +import pytest + +from app.services.ai_service_core import AIService +from app.services.llm_service import llm_service + + +@pytest.mark.asyncio +async def test_invoke_with_retry_uses_llm_service(monkeypatch) -> None: + service = AIService() + captured: dict[str, object] = {} + + async def fake_generate(prompt: str, **kwargs): + captured["prompt"] = prompt + captured["kwargs"] = kwargs + return "ok-from-llm-service" + + monkeypatch.setattr(llm_service, "generate", fake_generate) + result = await service._invoke_with_retry( + "hello", + tenant_id="2", + function_name="unit_test_invoke", + use_cache=False, + ) + + assert result == "ok-from-llm-service" + assert captured["prompt"] == "hello" + kwargs = captured["kwargs"] + assert isinstance(kwargs, dict) + assert kwargs.get("tenant_id") == 2 + assert kwargs.get("function_name") == "unit_test_invoke" + assert kwargs.get("use_prompt_profile") is True + + +@pytest.mark.asyncio +async def test_invoke_with_retry_returns_cached_value_without_llm(monkeypatch) -> None: + service = AIService() + + async def fake_cache_get(key: str): + _ = key + return "cached-hit" + + async def fake_generate(prompt: str, **kwargs): + raise AssertionError("llm_service.generate should not be called when cache hit") + + monkeypatch.setattr(service, "_cache_get", fake_cache_get) + monkeypatch.setattr(llm_service, "generate", fake_generate) + result = await service._invoke_with_retry( + "cacheable", + function_name="unit_test_cache", + use_cache=True, + ) + assert result == "cached-hit" + + +@pytest.mark.asyncio +async def test_stream_chat_yields_single_chunk_from_invoke(monkeypatch) -> None: + service = AIService() + + async def fake_invoke(prompt: str, **kwargs): + _ = (prompt, kwargs) + return "stream-result" + + monkeypatch.setattr(service, "_invoke_with_retry", fake_invoke) + + chunks = [] + async for chunk in service.stream_chat("hi"): + chunks.append(chunk) + + assert chunks == ["stream-result"] diff --git a/backend/tests/test_context_cache_profile_catalog.py b/backend/tests/test_context_cache_profile_catalog.py new file mode 100644 index 00000000..77d3af91 --- /dev/null +++ b/backend/tests/test_context_cache_profile_catalog.py @@ -0,0 +1,23 @@ +from app.services.context_cache_service.mixin_profile_basics import ContextCacheProfileBasicsMixin +from app.services.context_cache_service.mixin_profile_route_parser import ContextCacheProfileRouteParserMixin + + +def _find_route(items: list[dict], route: str) -> dict: + for item in items: + if str(item.get("route") or "") == route: + return item + raise AssertionError(f"route not found in catalog: {route}") + + +def test_default_feature_catalog_uses_canonical_rag_ask() -> None: + items = ContextCacheProfileBasicsMixin._default_feature_catalog_items() + ai_steward_item = _find_route(items, "/ai-steward") + apis = list(ai_steward_item.get("apis") or []) + assert "/rag/ask" in apis + assert "/ai-steward/rag/ask" not in apis + + +def test_route_feature_summary_uses_canonical_rag_ask() -> None: + _summary, apis, _aliases = ContextCacheProfileRouteParserMixin._route_feature_summary("/ai-steward") + assert "/rag/ask" in apis + assert "/ai-steward/rag/ask" not in apis diff --git a/backend/tests/test_draft_confirm_handler.py b/backend/tests/test_draft_confirm_handler.py new file mode 100644 index 00000000..24609902 --- /dev/null +++ b/backend/tests/test_draft_confirm_handler.py @@ -0,0 +1,90 @@ +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 + + +def test_parse_action_confirm() -> None: + action, note = DraftConfirmHandler.parse_action_from_text(message_text="确认") + assert action == "confirm" + assert note == "" + + +def test_parse_action_revise_with_note() -> None: + action, note = DraftConfirmHandler.parse_action_from_text(message_text="修改 改成每周跟进") + assert action == "revise" + assert note == "改成每周跟进" + + +def test_parse_action_cancel() -> None: + action, note = DraftConfirmHandler.parse_action_from_text(message_text="取消") + assert action == "cancel" + assert note == "" + + +def test_parse_action_yes_as_confirm() -> None: + action, note = DraftConfirmHandler.parse_action_from_text(message_text="yes") + assert action == "confirm" + assert note == "" + + +def test_kernel_ingress_mixin_auto_safe_int_regression() -> None: + assert callable(mixin_auto._safe_int) + assert mixin_auto._safe_int("123") == 123 + assert mixin_auto._safe_int("") is None + assert mixin_auto._safe_int(None) is None + + +def test_adaptive_rag_process_entry_and_mixed_format() -> None: + mixin = AdaptiveRAGDeepGuardAnswerMixin() + assert mixin._is_ai_pmo_process_entry("pmflow_manual") is True + assert mixin._is_ai_pmo_process_entry("feishu") is False + + mixed = mixin._format_mixed_two_block_output( + answer_text="[回答] 接口契约已对齐\n[已收到需求] 新增任务创建确认流", + question_part="现在进展怎么样", + signal_part="补齐任务确认", + ) + assert mixed.startswith("[回答] ") + assert "\n[已收到需求] " in mixed + assert mixed.endswith(",已入库处理") + + +def test_adaptive_rag_select_scene_prompt_prefers_mixed() -> None: + mixin = AdaptiveRAGDeepGuardAnswerMixin() + scene_key, prompt = mixin._select_scene_prompt( + question="现在进展怎么样?", + project_id=7, + asked_via="pmflow_manual", + message_intent="mixed", + mixed_signal_part="新增任务确认流", + conversation_text="无", + context_blocks_text="无", + system_snapshot={}, + user_snapshot={}, + system_snapshot_text="{}", + user_snapshot_text="{}", + relevance_max=0.0, + ) + assert scene_key == "mixed" + assert "现在进展怎么样" in prompt + assert "新增任务确认流" in prompt + + +def test_adaptive_rag_select_scene_prompt_ai_pmo_manual_entry() -> None: + mixin = AdaptiveRAGDeepGuardAnswerMixin() + scene_key, prompt = mixin._select_scene_prompt( + question="帮我总结这个项目", + project_id=9, + asked_via="pmflow_manual", + message_intent=None, + mixed_signal_part=None, + conversation_text="无", + context_blocks_text="无", + system_snapshot={}, + user_snapshot={}, + system_snapshot_text="{}", + user_snapshot_text="{}", + relevance_max=0.0, + ) + assert scene_key == "ai_pmo" + assert "帮我总结这个项目" in prompt diff --git a/backend/tests/test_feishu_card_action_contract.py b/backend/tests/test_feishu_card_action_contract.py new file mode 100644 index 00000000..24a16166 --- /dev/null +++ b/backend/tests/test_feishu_card_action_contract.py @@ -0,0 +1,156 @@ +import pytest + +from app.services.integrations.feishu_integration_service.mixin_card_action import FeishuCardActionMixin +from app.services.integrations.feishu_integration_service.service import FeishuIntegrationService + + +def test_extract_card_action_payload_parses_dict_value() -> None: + payload = FeishuCardActionMixin._extract_card_action_payload( + { + "open_message_id": "om_123", + "open_chat_id": "oc_123", + "operator": {"open_id": "ou_abc", "name": "测试用户"}, + "action": { + "value": { + "action": "confirm_draft", + "work_item_id": 88, + "draft_version": 3, + "revision_note": "保持现有范围", + } + }, + } + ) + assert payload["action"] == "confirm" + assert payload["work_item_id"] == 88 + assert payload["draft_version"] == 3 + assert payload["source_message_id"] == "om_123" + assert payload["chat_id"] == "oc_123" + assert payload["sender_open_id"] == "ou_abc" + + +def test_extract_card_action_payload_parses_json_string_value_and_aliases() -> None: + payload = FeishuCardActionMixin._extract_card_action_payload( + { + "message_id": "msg_1", + "chat_id": "chat_9", + "operator": {"user_id": "ou_user", "display_name": "张三"}, + "action": {"value": '{"draft_action":"修改","workItemId":"100","version":"4","note":"改成下周上线"}'}, + } + ) + assert payload["action"] == "revise" + assert payload["work_item_id"] == 100 + assert payload["draft_version"] == 4 + assert payload["revision_note"] == "改成下周上线" + assert payload["source_message_id"] == "msg_1" + assert payload["chat_id"] == "chat_9" + + +@pytest.mark.asyncio +async def test_handle_message_event_dispatches_card_action(monkeypatch) -> None: + service = FeishuIntegrationService(db=object()) + called: dict[str, object] = {} + + async def _fake_card_handler(event): + called["event_type"] = event.get("type") + return {"code": 0, "msg": "success", "data": {"handled": True}} + + monkeypatch.setattr(service, "_handle_card_action_trigger", _fake_card_handler) + + result = await service.handle_message_event({"type": "card.action.trigger", "event": {}}) + assert result["code"] == 0 + assert called["event_type"] == "card.action.trigger" + + +def test_build_action_card_for_draft_pending_confirm() -> None: + card = FeishuCardActionMixin._build_action_card_from_capture_result( + capture_result={ + "reply_template_key": "draft_pending_confirm", + "work_item_id": 321, + "draft_version": 5, + "direct_reply_text": "请确认草稿内容。", + }, + mention_open_id="ou_xxx", + mention_name="测试同学", + ) + assert isinstance(card, dict) + assert card["header"]["title"]["content"] == "AI PMO 草稿待确认" + action_row = next((item for item in card["elements"] if item.get("tag") == "action"), {}) + actions = action_row.get("actions") if isinstance(action_row.get("actions"), list) else [] + action_names = {((btn.get("value") or {}).get("action")) for btn in actions if isinstance(btn, dict)} + assert {"confirm", "revise", "cancel"} <= action_names + + +def test_build_action_card_for_pending_manual_continue() -> None: + card = FeishuCardActionMixin._build_action_card_from_capture_result( + capture_result={ + "reply_template_key": "pending_manual", + "results": [{"work_item_id": 99}], + "direct_reply_text": "需要继续执行。", + }, + mention_open_id=None, + mention_name=None, + ) + assert isinstance(card, dict) + assert card["header"]["title"]["content"] == "AI PMO 待继续执行" + action_row = next((item for item in card["elements"] if item.get("tag") == "action"), {}) + actions = action_row.get("actions") if isinstance(action_row.get("actions"), list) else [] + assert len(actions) == 1 + assert (actions[0].get("value") or {}).get("action") == "continue" + assert (actions[0].get("value") or {}).get("work_item_id") == 99 + + +@pytest.mark.asyncio +async def test_handle_card_action_trigger_continue_dispatches_resume(monkeypatch) -> None: + called: dict[str, object] = {} + + class DummyDB: + async def commit(self) -> None: + called["committed"] = True + + async def rollback(self) -> None: + called["rolled_back"] = True + + service = FeishuIntegrationService(db=DummyDB()) + service.tenant_id = 7 + + async def _fake_reply_text(**kwargs): + called["reply_text"] = kwargs.get("text") + called["reply_chat"] = kwargs.get("chat_id") + + monkeypatch.setattr(service, "_safe_reply_text", _fake_reply_text) + + class _FakeKernel: + def __init__(self, db, tenant_id): + called["kernel_tenant_id"] = tenant_id + + async def process_ingress(self, **kwargs): + called["ingress_kwargs"] = kwargs + return { + "handled": True, + "status": "captured", + "direct_reply_text": "已继续执行。", + } + + monkeypatch.setattr( + "app.services.ai_pmo.ai_pmo_kernel.ai_pmo_kernel_runtime.AIPMOKernelService", + _FakeKernel, + ) + + result = await service._handle_card_action_trigger( + { + "open_message_id": "om_continue_1", + "open_chat_id": "oc_continue_1", + "operator": {"open_id": "ou_continue", "name": "继续用户"}, + "action": {"value": {"action": "continue", "work_item_id": 55}}, + } + ) + + assert result["code"] == 0 + data = result.get("data") if isinstance(result.get("data"), dict) else {} + assert data.get("handled") is True + assert data.get("target_work_item_id") == 55 + ingress_kwargs = called["ingress_kwargs"] + assert ingress_kwargs["message_text"] == "继续处理 work_item #55" + assert ingress_kwargs["source_system"] == "ai_pmo_ui" + assert called["reply_text"] == "已继续执行。" + assert called["reply_chat"] == "oc_continue_1" diff --git a/backend/tests/test_feishu_receive_orchestration_contract.py b/backend/tests/test_feishu_receive_orchestration_contract.py new file mode 100644 index 00000000..6c474555 --- /dev/null +++ b/backend/tests/test_feishu_receive_orchestration_contract.py @@ -0,0 +1,118 @@ +import pytest + +from app.services.integrations.feishu_integration_service.service import FeishuIntegrationService + + +@pytest.mark.asyncio +async def test_handle_message_receive_ignores_bot_sender() -> None: + service = FeishuIntegrationService(db=object()) + result = await service._handle_message_receive( + { + "message": {"message_id": "m-bot"}, + "sender": {"sender_type": "bot"}, + "chat": {"chat_id": "c1"}, + } + ) + assert result["code"] == 0 + assert result["msg"] == "Ignore bot/app message" + + +@pytest.mark.asyncio +async def test_handle_message_receive_routes_empty_text_branch(monkeypatch) -> None: + service = FeishuIntegrationService(db=object()) + called: dict[str, object] = {} + + def _fake_build_receive_context(*, event, state_trace_id): # noqa: ARG001 + return { + "ingress_source": "feishu", + "message": {"message_id": "m-empty", "message_type": "post"}, + "sender": {"sender_name": "测试用户"}, + "message_id": "m-empty", + "sender_type": "user", + "raw_message_content": "", + "message_content": "", + "chat_id": "chat-empty", + "mentioned_bot_names": ["PMFlow"], + "sender_open_id": "ou-empty", + "perf_t0": None, + } + + async def _fake_record_ingress_trace(**kwargs): + called["trace"] = kwargs + + async def _fake_reply_for_empty_text_ingress(**kwargs): + called["empty_reply"] = kwargs + return True + + monkeypatch.setattr(service, "_build_receive_context", _fake_build_receive_context) + monkeypatch.setattr(service, "_record_ingress_trace", _fake_record_ingress_trace) + monkeypatch.setattr(service, "_reply_for_empty_text_ingress", _fake_reply_for_empty_text_ingress) + + result = await service._handle_message_receive({"type": "im.message.receive_v1"}) + assert result["code"] == 0 + assert result["msg"] == "No text content" + assert called["trace"]["message_id"] == "m-empty" + assert called["empty_reply"]["chat_id"] == "chat-empty" + + +@pytest.mark.asyncio +async def test_handle_message_receive_orchestrates_capture_and_finalize(monkeypatch) -> None: + service = FeishuIntegrationService(db=object()) + called: dict[str, object] = {} + + def _fake_build_receive_context(*, event, state_trace_id): # noqa: ARG001 + return { + "ingress_source": "long_connection", + "message": {"message_id": "m-1", "message_type": "text"}, + "sender": {"sender_name": "原始用户名"}, + "sender_id": {"open_id": "ou-1"}, + "message_id": "m-1", + "sender_type": "user", + "raw_message_content": "hello", + "message_content": "hello", + "chat_id": "chat-1", + "chat_type": "group", + "captured_at": None, + "mentioned_bot_names": [], + "sender_open_id": "ou-1", + "perf_t0": 1.2, + "message_attachments": [], + "occurred_at_iso": None, + } + + async def _fake_record_ingress_trace(**kwargs): + called["trace"] = kwargs + + async def _fake_resolve_sender_display_name(*, sender, sender_id): # noqa: ARG001 + return "解析后用户名" + + def _fake_normalize_message_attachments_from_context(*, context): # noqa: ARG001 + return [] + + async def _fake_capture_and_persist_from_ingress(**kwargs): + called["capture"] = kwargs + return { + "status": "captured", + "classification": "signal", + "linked_project_ids": [11], + "auto_tasks": [], + "ai_trace_id": "trace-1", + } + + async def _fake_finalize_receive_result(**kwargs): + called["finalize"] = kwargs + return {"code": 0, "msg": "success", "data": {"scene": "execute"}} + + monkeypatch.setattr(service, "_build_receive_context", _fake_build_receive_context) + monkeypatch.setattr(service, "_record_ingress_trace", _fake_record_ingress_trace) + monkeypatch.setattr(service, "_resolve_sender_display_name", _fake_resolve_sender_display_name) + monkeypatch.setattr(service, "_normalize_message_attachments_from_context", _fake_normalize_message_attachments_from_context) + monkeypatch.setattr(service, "_capture_and_persist_from_ingress", _fake_capture_and_persist_from_ingress) + monkeypatch.setattr(service, "_finalize_receive_result", _fake_finalize_receive_result) + + result = await service._handle_message_receive({"type": "im.message.receive_v1"}) + assert result["code"] == 0 + assert result["data"]["scene"] == "execute" + assert called["capture"]["chat_id"] == "chat-1" + assert called["capture"]["sender_name"] == "解析后用户名" + assert called["finalize"]["chat_type"] == "group" diff --git a/backend/tests/test_rag_route_contract.py b/backend/tests/test_rag_route_contract.py new file mode 100644 index 00000000..2a12af3f --- /dev/null +++ b/backend/tests/test_rag_route_contract.py @@ -0,0 +1,204 @@ +from types import SimpleNamespace + +import pytest +from pydantic import ValidationError + +from app.api import rag_ask_contract +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: + req = RAGAskRequest( + question="状态怎么样", + asked_via="ai-pmo-process-text", + message_intent="MIXED", + mixed_signal_part="新增任务确认流", + action_mode="read", + ) + assert req.asked_via == "/ai-pmo/process-text" + assert req.message_intent == "mixed" + assert req.mixed_signal_part == "新增任务确认流" + assert req.action_mode == "read" + + +def test_rag_ask_request_rejects_unknown_asked_via() -> None: + with pytest.raises(ValidationError): + RAGAskRequest( + question="hello", + asked_via="email", + ) + + +def test_build_answer_question_kwargs_maps_fields() -> None: + request = RAGAskRequest( + question="请确认任务", + asked_via="pmflow_manual", + session_id=33, + new_session=1, + thinking_mode="deep", + action_mode="read_write", + sync_requirement=0, + message_intent="mixed", + mixed_signal_part="新增任务确认流", + ) + kwargs = rag_ask_contract.build_answer_question_kwargs( + request=request, + current_user=SimpleNamespace(id="77"), + ) + + assert kwargs == { + "question": "请确认任务", + "asked_by": 77, + "asked_via": "pmflow_manual", + "project_id": None, + "source_chat_id": None, + "session_id": 33, + "force_new_session": True, + "thinking_mode": "deep", + "action_mode": "read_write", + "sync_requirement": False, + "message_intent": "mixed", + "mixed_signal_part": "新增任务确认流", + } + + +@pytest.mark.asyncio +async def test_rag_ask_route_forwards_contract_fields(monkeypatch) -> None: + captured: dict[str, object] = {} + + class FakeKernel: + def __init__(self, db, tenant_id: int): + _ = db + captured["tenant_id"] = tenant_id + + async def process_ingress(self, **kwargs): + captured["ingress_kwargs"] = kwargs + return { + "handled": True, + "status": "answered", + "intent": "query.read_only", + "direct_reply_required": True, + "direct_reply_text": "ok", + "rag_result": {"answer": "ok", "session_id": 12, "processing_stage": "qa"}, + } + + monkeypatch.setattr(routes_query, "AIPMOKernelService", FakeKernel) + + request = RAGAskRequest( + question="现在进展怎么样", + asked_via="pmflow_manual", + action_mode="read", + message_intent="mixed", + mixed_signal_part="新增任务确认流", + ) + current_user = SimpleNamespace(id=101, tenant_id=9) + response = await routes_query.rag_ask( + request=request, + current_user=current_user, + db=object(), + tenant_id=9, + ) + + assert response["code"] == 0 + data = response["data"] + assert data["answer"] == "ok" + assert data["session_id"] == 12 + ingress_kwargs = captured["ingress_kwargs"] + assert isinstance(ingress_kwargs, dict) + assert ingress_kwargs["source_system"] == "pmflow_manual" + assert ingress_kwargs["message_text"] == "现在进展怎么样" + rag_request_context = ingress_kwargs["ingress_options"]["rag_request_context"] + assert rag_request_context["asked_via"] == "pmflow_manual" + assert rag_request_context["action_mode"] == "read" + assert rag_request_context["message_intent"] == "mixed" + assert rag_request_context["mixed_signal_part"] == "新增任务确认流" + + +def test_ai_steward_router_does_not_expose_legacy_rag_ask_route() -> None: + route_paths = {str(getattr(route, "path", "")).strip() for route in ai_steward_router.routes} + assert "/ai-steward/rag/ask" 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]] = [] + captured_tenants: list[int] = [] + + def fake_build_answer_kwargs(request: RAGAskRequest, current_user) -> dict[str, object]: + return { + "question": request.question, + "asked_by": int(current_user.id), + "asked_via": "shared-builder", + } + + monkeypatch.setattr(rag_ask_contract, "build_answer_question_kwargs", fake_build_answer_kwargs) + monkeypatch.setattr(rag_ask_contract, "resolve_effective_tenant_id", lambda *_args, **_kwargs: 88) + + class FakeKernel: + def __init__(self, db, tenant_id: int): + _ = db + captured_tenants.append(int(tenant_id)) + + async def process_ingress(self, **kwargs): + captured_kwargs.append(dict(kwargs)) + return {"handled": True, "status": "answered", "direct_reply_text": "ok", "rag_result": {"answer": "ok"}} + + monkeypatch.setattr(routes_query, "AIPMOKernelService", FakeKernel) + + response_rag = await routes_query.rag_ask( + request=RAGAskRequest(question="q1"), + current_user=SimpleNamespace(id=1, tenant_id=1), + db=object(), + tenant_id=1, + ) + + assert response_rag["code"] == 0 + assert captured_tenants == [88] + assert captured_kwargs == [ + { + "source_system": "pmflow", + "source_message_id": None, + "message_text": "q1", + "sender": "PMFlow用户", + "channel_ref": None, + "source_sender_open_id": None, + "captured_at": None, + "chat_type": "manual", + "project_hint_id": None, + "ingress_options": { + "rag_request_context": { + "question": "q1", + "asked_by": 1, + "asked_via": "shared-builder", + } + }, + } + ] + + +def test_build_rag_response_payload_preserves_rag_fields() -> None: + payload = rag_ask_contract.build_rag_response_payload( + ingress_result={ + "status": "answered", + "intent": "query.read_only", + "work_item_id": 321, + "ai_trace_id": "ai-pmo-321", + "direct_reply_required": True, + "direct_reply_text": "fallback", + "rag_result": { + "answer": "final answer", + "session_id": 18, + "sources": [{"source": "snapshot"}], + "processing_stage": "qa", + }, + } + ) + + assert payload["answer"] == "final answer" + assert payload["session_id"] == 18 + assert payload["work_item_id"] == 321 + assert payload["trace_id"] == "ai-pmo-321" + assert payload["reply_required"] is True + assert payload["reply_text"] == "fallback" diff --git a/backend/tmp_acceptance/draft_ai_call_logs_latest3.txt b/backend/tmp_acceptance/draft_ai_call_logs_latest3.txt new file mode 100644 index 00000000..bbf47cdf --- /dev/null +++ b/backend/tmp_acceptance/draft_ai_call_logs_latest3.txt @@ -0,0 +1,28 @@ +rows=3 +==================================================================================================== +#1 +id=1902 +function_name=project_draft_charter_glm47_fallback +status=success +created_at=2026-03-19 18:13:49.041521 +response_start + +response_end +==================================================================================================== +#2 +id=1900 +function_name=project_draft_charter_glm47_fallback +status=success +created_at=2026-03-19 18:12:49.020651 +response_start + +response_end +==================================================================================================== +#3 +id=1899 +function_name=project_draft_signal_glm47_fallback +status=success +created_at=2026-03-19 18:12:19.010876 +response_start +{"content":"竞品多侧重基础连通性测试,缺乏自动化验收流程。差异化机会在于集成CI/CD与全场景覆盖。需警惕硬件兼容性及协议复杂度带来的技术风险,建议优先构建自动化测试框架以提升效率。","impact_score":0.9,"priority":"high","strategic_analysis":{"is_strategic":true,"threat_level":"MEDIUM"}} +response_end \ No newline at end of file diff --git a/backend/tmp_acceptance/draft_fix_verify_result.json b/backend/tmp_acceptance/draft_fix_verify_result.json new file mode 100644 index 00000000..6b5e7864 --- /dev/null +++ b/backend/tmp_acceptance/draft_fix_verify_result.json @@ -0,0 +1,130 @@ +{ + "source_message_id": "draft-fix-verify-1774243078", + "capture_response": { + "status": "captured", + "classification": "command", + "linked_project_ids": [], + "signal_id": null, + "created_project_id": null, + "auto_tasks": [], + "ai_trace_id": "feishu:draft-fix-verify-1774243078", + "results": [ + { + "route_status": "queued_for_decision", + "summary": "message_queued_async" + } + ], + "direct_reply_required": true, + "direct_reply_text": "已收到,正在处理。", + "reply_template_key": "async_processing_ack", + "work_item_id": null, + "decision_level": null, + "review_summary": null, + "reply_outbox_enqueued": true, + "reply_outbox_id": 283 + }, + "ok": true, + "run": { + "id": 112, + "status": "succeeded", + "last_error": null, + "retry_count": 0, + "max_retries": 3, + "work_item_id": 405, + "updated_at": "2026-03-23 13:18:54.271908" + }, + "work_item": { + "id": 405, + "state": "draft_pending_confirm", + "draft_version": 1, + "signal_id": null, + "confirm_message_id": null, + "draft_content": { + "title": "给AI劝退助手加个需", + "summary": "给AI劝退助手加个需求 劝退完后第二天问一下情况", + "version": 1, + "feasibility": "待讨论", + "followup_plan": [ + { + "when": "明天", + "action": "确认触发时机与验收口径" + }, + { + "when": "一周后", + "action": "复核执行效果并决定是否扩展" + } + ], + "original_text": "给AI劝退助手加个需求 劝退完后第二天问一下情况", + "feasibility_reason": "信息仍需补充,建议先确认边界。", + "recommended_assignee_id": 1, + "recommended_assignee_name": "系统管理员", + "recommended_assignee_reason": "基于当前负载与任务匹配度给出的建议。", + "recommended_assignee_workload": 100.0 + }, + "execution_result": { + "draft": { + "title": "给AI劝退助手加个需", + "summary": "给AI劝退助手加个需求 劝退完后第二天问一下情况", + "version": 1, + "feasibility": "待讨论", + "followup_plan": [ + { + "when": "明天", + "action": "确认触发时机与验收口径" + }, + { + "when": "一周后", + "action": "复核执行效果并决定是否扩展" + } + ], + "original_text": "给AI劝退助手加个需求 劝退完后第二天问一下情况", + "feasibility_reason": "信息仍需补充,建议先确认边界。", + "recommended_assignee_id": 1, + "recommended_assignee_name": "系统管理员", + "recommended_assignee_reason": "基于当前负载与任务匹配度给出的建议。", + "recommended_assignee_workload": 100.0 + }, + "status": "draft_pending_confirm", + "draft_version": 1, + "revision_note": null, + "decision_trace": { + "intent": "signal.create", + "gate_code": "auto_execute_allowed", + "confidence": 0.95, + "route_tier": "low", + "gate_action": "auto_execute", + "route_function": "classify_intent_ai_pmo_kernel", + "correction_action": "none", + "complexity_reasons": [ + "llm_only_stage1" + ], + "correction_keywords": [] + }, + "workflow_route": "project_signal.create", + "workflow_action": "create", + "draft_expires_at": "2026-03-24T13:18:54.197287" + }, + "updated_at": "2026-03-23 13:17:58.698395" + }, + "outbox": [ + { + "id": 283, + "status": "sent", + "message_text": "已收到,正在处理。", + "created_at": "2026-03-23 13:17:58.143284", + "sent_at": "2026-03-23 13:17:59.389384", + "attempts": 1, + "last_error": null + }, + { + "id": 284, + "status": "sent", + "message_text": "📋 已为你起草需求,请确认:\n\n📌 给AI劝退助手加个需\n给AI劝退助手加个需求 劝退完后第二天问一下情况\n\n✅ 可行性:待讨论\n信息仍需补充,建议先确认边界。\n\n👤 推荐负责人:系统管理员 · 当前负载100%\n基于当前负载与任务匹配度给出的建议。\n\n📅 跟进计划:\n· 明天:确认触发时机与验收口径\n· 一周后:复核执行效果并决定是否扩展\n\n---\n回复 确认 → 入库\n回复 修改 [具体修改意见] → 重新起草\n回复 取消 → 放弃\n⏰ 24小时内未确认自动取消", + "created_at": "2026-03-23 13:18:54.271908", + "sent_at": "2026-03-23 13:18:56.583867", + "attempts": 1, + "last_error": null + } + ], + "draft_text": "📋 已为你起草需求,请确认:\n\n📌 给AI劝退助手加个需\n给AI劝退助手加个需求 劝退完后第二天问一下情况\n\n✅ 可行性:待讨论\n信息仍需补充,建议先确认边界。\n\n👤 推荐负责人:系统管理员 · 当前负载100%\n基于当前负载与任务匹配度给出的建议。\n\n📅 跟进计划:\n· 明天:确认触发时机与验收口径\n· 一周后:复核执行效果并决定是否扩展\n\n---\n回复 确认 → 入库\n回复 修改 [具体修改意见] → 重新起草\n回复 取消 → 放弃\n⏰ 24小时内未确认自动取消" +} \ No newline at end of file diff --git a/backend/tmp_acceptance/draft_optimize_db_check.txt b/backend/tmp_acceptance/draft_optimize_db_check.txt new file mode 100644 index 00000000..0b83b317 --- /dev/null +++ b/backend/tmp_acceptance/draft_optimize_db_check.txt @@ -0,0 +1,10 @@ +source_message_id=draft-opt-verify-1774243376 +run_status=succeeded +work_item_state=succeeded +title= +feasibility= +recommended_assignee_workload=0.0 +recommended_assignee_reason= +semantic_title_ok=False +feasibility_is_合理=False +assignee_under80_or_risk_note=True diff --git a/backend/tmp_acceptance/draft_optimize_db_check_v2.dataurl b/backend/tmp_acceptance/draft_optimize_db_check_v2.dataurl new file mode 100644 index 00000000..b6ffbe06 --- /dev/null +++ b/backend/tmp_acceptance/draft_optimize_db_check_v2.dataurl @@ -0,0 +1 @@ +data:text/html;charset=utf-8,%3C%21doctype%20html%3E%3Chtml%3E%3Chead%3E%3Cmeta%20charset%3D%27utf-8%27%3E%3Ctitle%3E%E4%BC%98%E5%8C%96%E5%90%8E%E6%95%B0%E6%8D%AE%E5%BA%93%E6%A0%A1%E9%AA%8C%3C%2Ftitle%3E%3Cstyle%3Ebody%7Bfont-family%3AMenlo%2CConsolas%2Cmonospace%3Bbackground%3A%23f6f8fa%3Bpadding%3A24px%3Bcolor%3A%23111%7Dh1%7Bfont-size%3A20px%7Dpre%7Bwhite-space%3Apre-wrap%3Bbackground%3A%23fff%3Bpadding%3A16px%3Bborder-radius%3A8px%3Bborder%3A1px%20solid%20%23ddd%3Bline-height%3A1.5%7D%3C%2Fstyle%3E%3C%2Fhead%3E%3Cbody%3E%3Ch1%3E%E4%BC%98%E5%8C%96%E5%90%8E%E6%95%B0%E6%8D%AE%E5%BA%93%E6%A0%A1%E9%AA%8C%3C%2Fh1%3E%3Cpre%3Esender%3D%E8%8D%89%E7%A8%BF%E4%BC%98%E5%8C%96%E9%AA%8C%E6%94%B6%E6%9C%BA%E5%99%A8%E4%BA%BA-1774243529%0Asource_message_id%3Ddraft-opt-verify2-1774243529%0Arun_status%3Dsucceeded%0Awork_item_state%3Ddraft_pending_confirm%0Atitle%3D%E7%BB%99AI%E5%8A%9D%E9%80%80%E5%8A%A9%E6%89%8B%E5%8A%A0%E4%B8%AA%E9%9C%80%0Afeasibility%3D%E5%BE%85%E8%AE%A8%E8%AE%BA%0Arecommended_assignee_workload%3D100.0%0Arecommended_assignee_reason%3D%E5%9F%BA%E4%BA%8E%E5%BD%93%E5%89%8D%E8%B4%9F%E8%BD%BD%E4%B8%8E%E4%BB%BB%E5%8A%A1%E5%8C%B9%E9%85%8D%E5%BA%A6%E7%BB%99%E5%87%BA%E7%9A%84%E5%BB%BA%E8%AE%AE%E3%80%82%0Asemantic_title_ok%3DFalse%0Afeasibility_is_%E5%90%88%E7%90%86%3DFalse%0Aassignee_under80_or_risk_note%3DFalse%0A%3C%2Fpre%3E%3C%2Fbody%3E%3C%2Fhtml%3E \ No newline at end of file diff --git a/backend/tmp_acceptance/draft_optimize_db_check_v2.png b/backend/tmp_acceptance/draft_optimize_db_check_v2.png new file mode 100644 index 0000000000000000000000000000000000000000..0d57c553a8454dd80e0b23955a1f72f1ec6681ab GIT binary patch literal 49729 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%0A{{u)g!jMN+;g75~ z0ywpoH1Gz1B-#Wlsod??HtcrugL@xMq@YaKpU|?!vVYC-H-Ue5oHjBx#jOO=>iVOn6TCmO8$!WIYvg6a!PwinzWa?5d#TQ_C?+C~ zG%&gUv-Q&`Lw{DCj-mJ?C6#gM>*cm{3-$EIrhln`bI-o4tlR%0t-)mH>WK;IL@O{} z3Rf4e2TcBYCkzkV`|sZsnsHp50pEb`3{|0J*LFCDQAp@+C6j5k<-Bn;GHOP-g8U5` z*Wj?QRctd4U)fg&?l}F;ZnQ9L!t3NoqL6NN17kL}{x|~ju_K#%Ac}>b7W@f*T@6G| z$eA)gBW~*N%Qpu6ya6LeiYcO0zU53}?!zd9c6rKo0#h`4^$FS@nV$9=XpwW?5aDG6 zSMq?4)$KX5Xp%v4&4E3Nz4zx_TIN!o-UDBp_s@B1a%y?QVw0-MM3; z_6vQtmd*<3Dxh?{s^|n;FPjCG2*Y9m1k3Xw^u`V3sD8mWGg|`6gAyEfu-WF#n_<~5 z>39$KSK_Akmf}W4CyEpa$%eYRWJzaxA#Ddnh^OvgPdE*uQCN?(3_Oj2!*^_808Z7lhHRVU?JHAvoIRENyrl)S zPsp(&Xue?MbnnQDa`Q(v8hX*kH~BS+htd2&RjhXU+g0BlJQnQoK@GIu05AvzL_I@c zuBdeHgOkh+P$6CJaAM)6(jkCvR3U<`^k^4bf6^>3J0}nH<^E{WUI0nep<>q$ef}IH zhr__&MB6|9?E|;Ie*Fsn;CNC*L%|L6-E(xD(6(TUzUqcLQ*jM>St=?WsW9*hh18CrK(ZL2b9Wi*GiD>aV zcfP@$2iw7hoHsz9v@1j9BqSuzsG^(E(+Pc}kS}!{8ETFI0VrCEpX>L#Ffh^~<002d z<8G=!(CoNo<;fE#@Xt*$H=u3Wanf4pcs=d@4lP=}v3HPCQaBnF6dUZ3tTIUnV_0+f z$`wyE7zoz|%4Lx+@=eMtO#^iY?oq$*eLHY@u$ZHkLzNs{L;`I~F^ONV9^X3zP|1Ly z$P+OyiO+~05t%;x^{BN`PxHs&9DjuW^Y746gn?CeMTn#L_U)A*c~zO}K~ZQwI_IW2 z#9WIW_UNm8@x@n)G}wy}(s!e6;YgP3A*fqVF7?ΠIi6vkLsf4Rocxr=+)mWtb=n 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场景1 数据库验证

场景1 数据库验证

run_status=retrying
+run_last_error=kernel_exception
+run_retry_count=3
+work_item_count=0
+latest_outbox_status=sent
+latest_outbox_text=已收到,正在处理。
+
\ No newline at end of file diff --git a/backend/tmp_acceptance/scene1_db_check.png b/backend/tmp_acceptance/scene1_db_check.png new file mode 100644 index 0000000000000000000000000000000000000000..c28f03258a5f01a068ffa2b9f8ef1246da4f267b GIT binary patch literal 26924 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场景1 飞书回复抓取

场景1 飞书回复抓取

[1] status=sent attempts=1 created_at=2026-03-23 13:06:17.169520
+已收到,正在处理。
+
+
\ No newline at end of file diff --git a/backend/tmp_acceptance/scene1_feishu_reply.png b/backend/tmp_acceptance/scene1_feishu_reply.png new file mode 100644 index 0000000000000000000000000000000000000000..50d2f6f9a924f9d59e1166a370cf57eaec9cf299 GIT binary patch literal 19636 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
TMV&-nw6{KC+=A{5JK-6*mpGMs8 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%0DS%e%|yOQjxL zf4Ij1TtNOMPs}Nw3h8opGF!_82O}SDHai|}P?5iX4efIZHa3v1UBK7vQYs<$ zABs>~JG>xqagotsS{O8uZHve`gB0e^P zQld4z!UjDy9&&;Yza(z=b!^@IkCOPe&tI1599we&SQ+VqkRs_l&z9XRsCUPq$()%6 zJpes!-9GV6F)-0@A&5iJ<6Ih#1d$fo3PZmuH<^IYGziHbK&72KU!ns6fXVO?lyKkL zkE+X8QH&wSNpISZ0pxQ$ceN&SDmj3SX?Q!n+#J|dFm?|wnaiFPR%fD{xpI2UOrS*7ToxTK77_;!hrws(}%e{xC+JsjNF zta}{)45ogQIbH@3gvZX`MuC4wjV8ahhZ_e{w@bYK?~BM}D{kQ{e^ds%{|5zR-2a>0 zM6gDwIlk)jQ!KbnsaFqw|Hd`DhULWZSKY7pT#Hxnt>5kZc2~K`=;&;Bx3b#W2KA#K zsC)kTyD874#(?k{>Z{@YHIaS&pum>XzizGDyWKqLbC&S$A6Kjd`bRzYlz66&ybv$j z^XcbeP=GDWd04)i{6}Ob=Y?OK z)%t9bo|)tBnRB(b^UH|X0Xad_IL4~rLMy9IP1U&z*}|V=fs4M{PtUSA?@`HE)J*lum^DnVxja#(>aj95E5EY0+Ctu6!x0_?R7-FF?@ zNAz|uZ#Om}>%ol7ETr(c-0g~rfBZfYXeIc3sQW|5yX1W*KzHxt<3}-YHWaBOc_zp3 zjFPf4V85i-^(NUvdC%?VlVA39)Vd;8TD7lbD|(EfBCPv;y7J@je4f*$__bmrYp!x8dCL3Xcsg982keuG;ab4BY4)+ z#w<80x*i3}vuDq`x@OVHAS+_eLM4`9Mp04TC@O>7k_$!HT6u9UTOkRFqX!S3Kmh{Q zJ$$<%U;@FG+qWOjUMfAw;`hQ`*dRz|%BVPzx4 zkEADQ_DQmz84_02EOU=CT^# zoyGtfit|Rfj%l(C8y+g$C&Z|{mf5?bh&D&?IEaoXKpyQf3BtL+dsl)|3SVY>CM&Qn#dS}mC_8Uplw&cO9j%zJ;5h^d3Td$R+=B-{(e2Y70Cmpu!<-Iv1r&9MU*D>2Xdsp#u0ifs%;OW? zVs3sN@f^hY^4<4fBxroQ3g3gQVt59B$vBgI5v{)Y40u6Tk-q?kRiH)yLBjM%Cr*Xk zI-Xxve{THjjuZ^g3ZkP6DzzAlpOW-zP!n2bpZt7g^?W%&r;LU;H6GQ=K}XVa>o-_X|bXn`wiI9bwCuQ^3UmMwe{)XaOxRT6Fvg;wbrT!}4&76_9<^xcr&O8?i{!0^fi0gX}2 zGLAx#gB1MUJpzxK8F}H1MVwMn`8Z>O^0w0Gk&tM#i@#7N04FlU;6f7h09Xt~?{K@Z zpo~D0TGEPv*Z^p?-{sZa(E&h{jV!Qc%_Vq9#+*Ah==hqvREcxD4uz*b_wqo_o09qY z`Hx32rvGb5VAa=oG%)IbG(JDTUei?&9wZx>?AXCQ-3TqhN)F+Oekq7Y9(S2t0v{Le z^%F|L0gMU6&~hBzx6f~O)F$!4TS(KHW1mY@>%V^XjD6ZlL;wss_*;I4cODRanM7|~ zf+v0Y8LfYkU`L<47z8}-si&MUcFA7cJ%CpG_1m{^dwWAK1yBzjA)^OAd+uDk`U%3N z3(Pb?fJ*mCNrob;L`5G1FvE2(uCbLZ1jdi_--FGC&M`zGiB;4w&;bp+Be<{Mh`5Mt z?Pf19@%*1T(2g9%LiYhqLf3Dx0}IG7BAG(rhM$#`+5TfjgawX6sPKRt5>pA78Df_O z^qteXgbg^%ZwOnL18P9lAe2MP1?6NI?2OQC$q-lY;-+KQ0s<|we-8kpLo~pdiC322 zc?mQCbg~eWZO6qkX8W^*6~KV9OJzGWgfKy>3Rs=>H+hZi1^I#>fHaFb3ZE=|w8bAk zENs)tZCiCWLEeH#f@d5KT@s`}d)b|UQ;Sa4$nnhF zK`;?OS^2x}dGxB8toDYz3+W+MRTC2vVV*+k)~%BfHT31>=jX=}lrranEF60wOhjU5 zw)inhxw~J|khp6pucFf0)Wnh+clGMkd-v`w{1_TK;Ojt5MbmffjnUSvL>Zo%?5RT# z7h83~SOQ#u%ZAw{qTK(&lO0B+{~IetR#&sKtfQW{*Vd+56{6b9zkW`&zNww3!wiQj z*u&V}y|m2f4~v<+@N=H9si@pqX5V`G?>Ds3P6_L~_1AV~*0pgo`fvVF{Qtk0!^&ad z6C5DIOH8IWXL0;JqBn{VKS;+2d$W8RDy zea!|1a2Xn*+S=OjH60`1hH84l7jsRzjWD`*;JW_Ex7Xv9!$%Q6@b>Nc2xHnnUN*Q? zly`YPZXP8v(5^Cz{9)LdW%SMe`W2rDOyq0NjZ2Rnoo-pc$wV4R8``)TGzqSybxTed zU^zWLrS`^uc?XimpO4<*`mILwGjQT!-0wUWu;nmeB>vNY(xDf4J%gG74IpM}Ov5-; zQl$2tR@gkIgj)0+!rUB@z+MlmZDKJWg_}pP-yJOr0-8$o3|uZ7{NZeInff|PatSje z4G|Y0K&!3-^U8(yN(oL`+(iquuF{W-{+lZ0u(uT-dr%ZN5|e#QZBO9DA=<}o5!dui zE9%1p66r+Y(P=1C508PqRl&r|u$w0%q6ICB;XLMnm|6mH+f|ag?Es%eaREOXwyB@! z5;4YGb1po5_Ca~nj)S-TcbD&bb^6c5MwvgEzHClm| z%1i~^jt7EyQ_zELDL(V~8uXkLyFbA*uckkIDDny#eG;0r4=@PCc{$!eP2SJQAAw~HyMkdW@wV_&pb@GlY+VaToYhoI>LHSzWy*qMkV%c?X=!KOf*<^65xH+U)A+ZCV-?(y3SeK0|@u zI?rfsJlSb!1tC~@bp_=60~8hp_tW3gO#qvKbl3m+bE$7F)s(h?7of|C=@27(R~C|;cRWDgMlDz zQ{cpFe}obM18H}SZ$^rmKa5UGxq%WPpL@@qJuuGUt>|~hH>N#6wS_fk(*VpnK8>FS zQo`Mvn0e>SGEv#0UMigIDYB6&FvTpg{tKixn^Yvcgzo2j#!zK9ymDOr&QRbP_<5Ak z-HYSwvvkFu=QIIcv}F~p7456NhQ{-CC*iDe5q zCrqTgl$ttm94z;S34Gjw(bmkTw(^h|buQ%=#3@DWlt2u?Y!WyMD37^iRe6NBu~rY} zpS<~4!|m9~WywdqnAXvZlgK-35pX|B%4&utIHZT?Z+PvJq<2zouHBKHkcf`UY&lAN6#ZD35i zDP0t<#IMm`g?L_1mm$r8v~8K&2NK_(7?cAgw$7e3a3S|7+6ZbJM&~{r!xg7}_#nP1 zzCob`zKO$`>kn;5qT$(WG1FNB85j)#WD9W(6AGbzr!LMVEK~z*H!KJMd`D={*I@Mm zGb6dBs$Jm-tkt)h?MK@^GN3JLiXXm%T>apxp z*zioSRBUXlClW2O(oO3d8aA)X*Z;BWqpm+bZZ~YI3$_Z%-&&iLdN5d+4caQ!A|K{%FWy5X}|0CG9s^Nu;@&ZuDX7Yl$|s{@D8 zpA^FXP~eEleL`Lkd;7nKMp=#yTncc*x(n&$))sJZ-0WWwM6lO*%PIC?dsn2T187M4 zsNL`dxb@GO87n8r#8;14K*2vg-euDl)wRNgNu9kwf$&PXFV!$lOoU@F)!a8!?z_&e zh=-n+Q5!Nx)%EM2rAR(S*~=>f1!OQh;4nRP@W#GX_%QNrJ|pdhkD)b+QM|vNo7k>e z8jbOlN_HPOV^Zod@E-Ae9Ev&d3v`G?0S@F2wIn*g`)E6^1-DYJs$Yv};N33&`I&}S zr%Bo4N6?j;*rCSN!>k=gIAyH=EgJXyvJVH5b)JiUYYJ;t-(vQ=G)jztt zJQwIkfwAtwsAggkeszRJy~i;BfAB~iWS{1;O+sCtJVInmR#7j9GCqT*q}+y%fv^=J z9Wn5Zs2Pyw=G=V1OO(kn`jnn23oEN(C$abu8<8;AEz~WNUgxUNoO~X=I^8By6lrPc zPJ|1=E-mC@#flXs`krjgXch=@;Y1YQt~YRmWDYow+MYaPFbXXjF6rr;pj!2WnuHVoB<) zblrYlT8bv(MBS!?6&D}t{6`6*{lfs$1>W)aOj7yn^SRvq{5B&Be+1C8LQBcI&muZz z@O)*07O}768F*6)uJdzqa&5W)$~Dc1?Fg@!FI~L27cCv`1AJuq?)hX;#bB;GaylvA zfCdsnZ7toyr$8cLC(^zT5{fQ~9|#`P*nhS!46g-MY_{rtBeX=f-UF&;Z9;X4O>I*s zs8CE`-;jiHs7yarV$>y3WCM>M2SM)N9c_XvVj!`_KcT-NnQovRAx)oa3`&4C;G;9A zVmMm~;S6v=y9+0I;kA=qv7jViqD&|ke_PC)&M@7^Xj>^7n$-{^D=X^?#?$ZDkJ{$s zTIzrQI?rP?S@koCK2F1n9A_cGM8|8$<8^z56(3zHd7A+*-Iy&C_nwQLr!$faBH9@& zS&9qZ_x0`lne{uHlW&^k)6nRrB~p2Bug`Vm9V1Vl6E*CYm5(lt=gy<9?;i2GbKGv* z!}f!tnV`uN8DiK)=~cPX=SfUz0vYe#L(Ss^ArH<1a!GFffeXc(-@{?PvE+TYdGj7@ zQw+M9$W{5!>)G>HDw&(Y5hud!<+MpsB>~?S^wxu_K68a;< zH7rXn^dCXn+is@ac}J_~N9I3WeP#Y3?AKv?W+r}*cw$Dc?7Zu!M^j2|wLGyB0nykp zl^nB6!;MCv{mNs2YtG4I_u|ibUB5dH?Xd1NjZa*v+p}vef5BZkw6V9tUz!8pELAOd zkhYIl3%x9V!=cy%2@V?lg3aF$`M^`2Tm07c;)M5(^xc>@UKe!_`)dAn;|^Cn zC6Dlm2N%nw(2|=VVQyV|5cZqrh6QUwz@A?@HSqA+UEEmDrN9Nh#2?4(IZZh=lh9D6 zXP{&_9(ifwQE!`Kmx=u%ITpEeBlj}^3MmQ)De3F!5xzD*j($C5{kXmHW#N`j(@L+2 zCTH8FU2k~=1rNTuD($)QUMh$bRw43M=<5M&-JRzC)zXD>Pp=vQI5tC2L_`~CtiFB? z0B@Pwoj~807Cx7w*}vxix59TQKprMTrm2TR&7hC$a^k1NRW1$=a4lW}VFFL}z(~mN zrJqPPzXsyhJ%0ua8lzsgq`j7S82-EZemDx_3+oT>1u5a)(8}u5h@GfTWU^ghB8dF# zO_r22biF*R1ZLq6hnI+{s1!4loqHa4QTt9$Rv~j%N;E`7L_ngUt$YJ*&X2DQ#B*GJ zwN%yFj96yRhqfbq?goy1$oFms^p`L1+8Uu?07npAjQ|N7p!2n@vA72?vw*y<$+2Zm z?JU^AbQ&WV@YQq!_;A=8RKIl+PDVE#e6TGBe>%VQ2cS%h!soko?(k^6>@?X^>aY&? z=pyK^^_XD4Fo(dRV0P+Bde}^}Y#hJ#$M7%rUfn(L^8vpnKZ~6uDiA;LB)?Q9#U>~3 zz%#%LDHZ$an0B^mwsXt6>bbn(I4LWEgS&mO*Q)`v(1}zw9xx(-z-?4Ed*Si=*8VrE zPDd#F~ zbvg`IMiApwGSaUcI_?U)BBhPSvS8cH83KYkl9G zV~#QAoDg(uT{P}S@hWwvIwT#4TWb)HcMId0%H7!BXB#rK3u+fTGiMDDz;5eWVc1qY zJg6O=8;A!zr#D+rte=3>0{XJG96xu@F_`WQ`%^T(7-Bnk%Du>MJ}4)V@sie`;^xMz zbr-Y-_+9>i!W(;|PXV}k+uW5w_c3&7K`WF}D2v0ELq`EmJ*h+6Uc=}F8lK;E5BrH~ z9o;O@7T|N1elRY7DRsIlC+D$0@`OsX#AX3euzg@oki!(4y6;VSO6l?qN}ZMHH1=}S ze5U-jqdUhXAk1p%N8Ug(+UYFOC7C+c&L(}31E+i(y0%BNF&gaDh^38azq$d{srq*E zX_L)cwrsfsTE^jrrx03XQ@m#fn|?jxEmibY zbhOy-AKv0-=a%<1t5=^@DQJ8V?WUpU)KaQi(KS^kZn+X8sOP9&btHIUX3+BY?)`f^ z^<^AG(^^1&k&kRggSPF&{+s>#2PbbLB-iZu3qYc+y;SRd(<@N;#I*zt!+FyleY3=! z7kWv2iCEgA63#)IiJ;Mq)uXjY*HJ@Rq$W+rXz; zko^If%Jlj%dQRH)(9i>RjSM14h5P}9uKy&XXY(2Xi)^pJm}9NzR*0hoXB|MN=G~@~ zdx-uU*gf_b6QR*K&GgGLKc?PrxAkUkL>#R`JG|82r8-csSQosrn zA;$F|c!KVKdk^7T-|tHe-ns)O3Z@m4Xz*R?zW&=l6j))p2vBqxbzykSvS>C0JR(wW zC|-I5hSuRb!^VR#7vQKb_qpPa;h38fEwD3D7jdf)(TsUCWFW{PCe}{0n2f}kb~?}Z z(Up+jZRJh$DD37n9LCjxQKH*rc?pM(2Mj2|7GV4CLCe?r4C>KWW7UfF++teT%S{Xb z>~SQEgl|GLQtjni3&AO)HO37>1G@}{_9f;8U`P{L01(eD|(pHUmEF#{8Ylu zq|rRl3*D8SmSt(-zu#PsWJ_WzFRW7{DLG`lyn?mv0TO4WDpan>w4CpgzLo%~7K%Y5 z59foD9_18u?wTZ}n8z1@X+9kKRgSYBz4k6{T!FER?!W;@a1h>kg=MN%$2;@yjO&KI;*=Sf)};+8Last!E7TJ)xXG^U1I1GM ztCKC2k%1w-3|%Q|2KC7`ivt4#bz&5`t|Mr;KzPM-D+U2PE`I%8DNw9a!Fk2 zpmo`^WawJ5`xq`NK5;}YsF_AmT3TGh67KY4Nx}Wjhj@82K)SQ5=2>>*VUmG7^2=IL zK@Hqb5!Kvb%arGhT2-~6kxri{u%S)A$Sd5pX>El0N}8L~^G|L1l#)@Z1;%sgyEhXm zrT5NcNOXpqH{6>vpjVzB-1KJ8bI@^-Im+TU!ht2 z9Ji{<&c`c>gVdqbU%}{dgSY%9UkK6WQ}pwCLKU0b&=vrT#FsK(+P!<@*Yt?V zZH~kUrjVqZUX)aYWpOW~E?iyCPp?fU3lZQcqX1Q?`zEC228U8gU2BI>8W)_Xf<`z7 zz?f7jWjsK-6d?ul#nN=sB~q4#*q4&K1&>Q+I?VfjyCdlafYrNuC0Uid3f3i8|l|EyW*Y@6flw|$5tXL{C?p6_)pKJ6d^!)nR`{~_R6axkV@xL&By)@IAEaGU6ve(!&KId{e zqe42S>;{Vgb?%4>Zfzx|1JeR%socP2_Vw}(j2Dah+CRB(H?zRCogczz#24$Ul@gVI zWJa7#Q48e;`gxYmbo{vDK};!fa$4^eF=EaZ2Jm-b`CPT1Uhe6Tq+T1Co**HZGTJe5 zuLh8F2q3a+T93sEd?Si!Ny$O~P7yOER8W(Gf08cZ92fODDi7kveC@U_GyPyKxO1rs z%;YXk&>`s^4`(DV=4AZUFrVMb4K@LE2;wA#UlN&ocrpoQ=~nRw zJAOd#+3R76$paHZ&+2nY&A5CB6>&cz`=hVw(=?^CA8fy5YUeWJzQq0WR)fA$CL4wZ z&c8M69jFOJ`-)EQCZt%fPT8j!odH_F+x%tM_P+lh+bS5~w)!akO`6r;7*R0LVhIscHcQ^X7u%=$P_5 z-kgaRmQ-m~aC9|FFXjgmBC0JnyZwC;_&ryz{~hgz7aOT#`)U4>=JRS@$u1n)UHp{A&*LfRs_)W9B-&ZClAtPrRA zl=IttyvZ!1c~2-L{Z~McKDve!Z?%^?75BNX$Irh~WF6=GoRaPUCij*LF)0u9SV|~HA}`>KLy1*P z0&P)?ARQf0x-gVN;_CPg<`k36F0h|<$-|el%-0DZw7O(*u&RgPs_r-6n)eVl`xd^J zn3&gcItbus!lu*Oqc+_jJ(cN;jFjR#P>G??8Hk)5f?H1DLbqf&qDFy$x`&RASZF_* z`JDOO!8kvnWVPs2|J6CKGcu1T*F2aW{T|&q zTpIdT; zhc;}-7%wA`cz&l2LlGQ7=tm(W`+R=X=v~%q3@j zNqp}6T8kdA1_6@Q zX*R85aG_-lWv|C_`j;>Z&S*j&iv}^P_XGA7vqP&WNSk=TsxfGu`f&O*CImz z6NXS7H0~CQ@#IyEyzjvE^c%5EVV4{KgQa6NqyuAgI&1?2Ib#KhUtc>k9isK-E`syF zx5^^7o#e|>k zyTje4uX(fc-@jP%M#)-^6+P!zBb`!F4d&|`_z^fr7^a|KVZmF}##(5N#jCv91S?a8 zE#@u8I`;DA;8uVjlMxXokvdFE|M?Go*-J`v6)+?WYpliSJhN36PQ>g#-pXVu?dn^Q ziv6Gd#kbbwLf9CP(k;4PwJf+A7#beyGO5MZD;(ZKjEv)a)`+oy999v8T@K((%iA-* z$M2GuuvKsQd9U%NggTV-Tq~b%B9XZ%MFDfYUtw5W!W|G|=`Z!;36_x1q&;Cua@>}p zmePu`fG_t(x7NdYN9ltvkO&uGNnw>JK?_ML1RO=(EH8ppH1-%fFjIxF%d46F6O6~qOH3OY`muNAW6jz4nv$S9Xre$t zmfJ<-gmgsj;6eO9tSD7=bs4Cz8Qv=+(IVzBsW-sR09Ls|IWV8tv{IT5ifjP9p8ijfRhorOe1&@ev;Zc~sKU6-MP z=7C~Pj(5)>sIOqiH4_QU!`H(QIq=t1IC<@QEb0Wx4{(`NpP&jc3g;H%ZPY>u;f}=K zD;ch@RaNH8)Fpg_88Z0_A~Ye)XrwQGpwk2Oe)9Nn%rf=F!5ILA+W!>~0Tf4jEi)Ll zeujt&!Y_x1)Lmd1;gt9635d)Tz9h2AY1lxO!Cz6*Dz4mKePp51t%FP_PUM2O1A!vI zi$;fxbp5iFpAGol_^}vAxP9O8H%qSY>TKS$X|Agea96X4P@S|YQ46JB!dO-bT0;0p zhD4sh#D{e__^Zt)5|v^`sz~WYi=aft@_K#HI37p!0yhJcLUSk+RL`hA1`5Qh`Cwi- z3s~Ex$yH9|O@!Z9M%omutmTNWmeM}hLWFsMRh^xu(aY9Y28wKm8oK)DhNgi4PEDa+|VbPXlXa4AsjjT*e#8V$) zaX*a6K5IV<(_-K-OALSr^5q>6!~}|&E*gmrwq3o53~pubylFRX*uarmSO+Om7DBu) z)&Jc?s`E_ybz;fenJgU|ZF=;Yg4s%t58bieefTvi3lCsC`D;;5jk;foB_LX!GgsXgJ5M4Zd zujttEm;Q6?n|$w*HgDaUlhJ&uZ0x-{wgx})2JQhQz)BL?nMFK7))J#VPXP6c>P)|6 z_Mo2*I4CX|!3ws8$c@g57JzEpFyFLR;xe;znSjWS%l;uIMUKS)ae|TXMbDT&P{sVh z0{oamK)gDTRba&#xikK?2#AeA{ty&BK~{mwS1t!EqB9ns})>3dP(M-QUhrH9~mL z^t*QPV@~6&4`u@ryn*%Oz}Ln#2dVuos5=2Yds3kvRF6cH@$gveTxih%?}AJ2=is-0y5JpkhF z3JMdTbTtyNo<_8U8(}gwJbLUHR$Td`SDkps0UEkWAxWTO`+rrvu!NE_UbaldOWCCt z?Ry#2q2Cw_5DUl}{Ixw0}|Jv?|HubpJ7P{Or%?{=yN zsjz$HlTTb{>b5n|qv`E|xMh4UN*}900Y0~F+C&t-$WO#`s1e7LWSQ1bll%|`#z7&a zKEYo>E7v3o-PO;Z|3%s2gRGI{%cd6e;`342T|~Ku^X~Ms&v=?hY5ifl zWOk?v z+tbstaPYu^5Oj*d2t3ONn`-hNhB|FbJk#yXTkag|E{iB9^G|}95`(ksL>(!MeF+I5 z9~X4>_PZSX5BR{`^pD;*Q4GD0sc88W-^$Z`_&nU|qXL5EAHZK2@R(v=DYAbP%@{2Y3d-65WE+ z`_w_N96A5^vP=k!>X_6W91hD4UNA2Fv8inE0|*?g)zDfyh*=}M{`xD@rXOmRri%p3 zG6bJ4kJH100TIUW3Jm}oSj=R0H|Tc_j9GSKfE%+q z2%BLKVI-f@C*8Zq$cReIpvQxS9fe^ypf({@{gZ?yw;)Sw8Izns-8V#8@3OWqw9dcCxO$Ss8GEG0E zbik0O9v;mvfp-RP1&>GLk0GJeSEF0S&Mz^MjhUO7ZxXLRkDnx0SdP-)$QNW0u_~z7)oY7UF5<2;rGoz5E5JwXio?sZ@h90_x;))t# z+F-hDQzx3>hHZq2Ca|HPFCYzH#FLTFKmufPu0^tl-o433A2Fh@`Qch-4+J?~+5_=j zO78p@Q#5npcl_P=%H)GJc|E;$4h12JAd2vz^^117QuYpoHu9L1XchCj2NL{#G0ogq z@%V{f54!s!V(E#Urbs>(x*?p7W3P}cFpXgOSA>iDNe25o<0*y0wnf;6mwM!JtkhkH4a==q$UDv>jW-D2 z0vyFv*T!^SVCSTpCI)z21h*@*L0Bi%B|PximUoo#G7M(0c@zqXSfl5S<0<%U8M-7& z7k^)zy?8P@I$C5yqU-Cq-eU9`@f0PZ!{+9BLnnp4*&E_WbWA2hIBWG)Zli#}i%R3C`Hd|3Ow54iA>sGW7ieX+dR=b~kbusH zR6b&6#SIRv+(f7_lA5yPVz*p7b4`DgzFofJeik(=--in8C0F1{s4aEX7o7x227%_6 z$acej4cb%Zet4675g8lrtd8jQ81v?1QNoB~+; zNe!&XImck~{mp|d%>8>kVD}7D;C#d;b8lfV0jD$!#-rLeD3eJ7J&4LMBGl>F@nb}P zOCVVA*WkTLH7C=0>aegWnWDx@nkq=Z%m$FPPBvW+iJ{4W{G96&^-!yRj3r{*_8iMs zuz1L!L#Wvn4#C(pxt85xB`Iob@obEQ4iPwP%Hn8_jNb8rfH5Ix`e-j5mCQ9q5t3=? zk!>1`Lek+X2^UX6CxjXcKlW~3T^&8!T?BR_a}OMBn1-q(s6QsDOqu&##ak1EI7iIQ zm5;@JgSjFsI&{#AgBO=amK)hngH0IjOOy>3MbwrgQXjTB3rcK^0&f^XESjQMn^hZ* zG|lMx6*R24`b?Q!9x<}2Z_1oO;ZI}EScL+~+V4ZeC zJ?ZU9&+lLcF|_D-lNs97@W$7R_)$gC0Tn=RueA9DrFLYm7 z{dlY!j$K%=DC_);{%HkF3X)olH$XC%7#J+{;v&YsS_8Eu@9Q`gHUo8Esi@qRaeI9h z|D_vPf~4HM8}aG{=F2iV&%OOY=YZ53ANf5iFT_;1zQ0{)ohXOdW&BnTM01m|VC!gJ z>GvnBz}iZl5Eyk%dGX4PDidUSji3IqpCKV{klB|G9EO`+ask_7?8nSl;8*1m$G5+EGPdBY|6sq#|sdi{5OBK^p{>x zUSDOm^FeL(=nGO4VPJ+*2F>OK&Iozy>}ho{I1pBKDShw;CA183b(N;~bms%(%(C7L zN_cZ%3kg*b6;X{#gFJw2kE#FF+OUhEIBwG&{tme`9=U8L5Tn2`=)n|Zk~=>PK&?_3 zBHIalVJh8GkdI38EbbEyvjT-mkl ztFc%%qtu5$=++iH`!1YpfFrEES0rp4W$e=r}fv!y|xNLJ>p})cSbwe7?iL^dUgbF%)NfAsO_sXAl1>=jwA!V8U7e7Q83dr>u^?0*m>|^ zF^XXur8mlS0#nd&!Pj3}xYv0e$i7^J!MCz33U9mry2rKvcwFPRVco8qccK}f zi(;sL5s!Pr$xu_~*n)44RUW{lUOP+yuWY|gOKPW^MDOjNtdrR&aoW7p%89-Ij)VV0 zRU_Mpnjcq3Z$`TskIi60+VdS`B>J00&h;HX?u4yyfXl;pxDCOhI1|46Ccu|$T(=IG zL-jg!%XU(y_tU3}GAKSO@qE*s#82t3r)$|8_7DshA>N`$MU55JC9f(6k@+-Y5KKII z2omw?5O@obvp=)7G7?K~;C&y=iMaRM&ETPMw|bINRlQtTATDdS0xJj0XOcS$Vve_$ zSH6xn7sedzho%m0)9&3k)x1!41GRbxk&&?d z#fm5(EoeCAUkkH-`|;O+LqQ#tx+$6<3x;wy1Uu~4(^Zl0`N70{Ap*?671$}jAv*Pz zZ>AUWZ1^oYwq`*HD)YddibVh5(^f_(%s|xGmZencGzi=o({YmO-$gWvw19pA9%U%| z%(O-BK>V}-sxII@``+;y%FmQGq$jLYq5B>9z1Wovmnw&f!(eX9XXI?X&g@VlWQv{cZGHuD!q+ zG#}50rRioP9HX<{F5+;Pd_GFj2?6d52H9=HB&b|L>}3udK5}>=C3^~~HAFY2&dd~t z77d0G4v%l5f6?*P<@6M#>T?o?BD?Bhem{CvAvb$4jH^xUc{jnLv&%gZ&z~!TnFq3CV(1?qM`Nt>Yy_43*REaB z&-EF$IwIacoNoDxQwBBq%$%puql0OZOU#2bXgUv-GBl|YJjKB=1eBvt5RGVjG4A7y zqS{4tO^seJHgm+tFNCMbxk?l>&A!!{$d9-#LW93DO(sBeSNj?GeJO@im(w2I`=d0p z!z%6FkILbFLzU)1)*efjpMz(c8W!(hzuWH;ZYGUdx2EiZ;7%K6uhLEQmuSWOKgwxa zE&Ed+nEuF9>5nVj?R^D(-1())rZP6IoH54urXG>`EtkDjP?oMBrZr{5;?EU)Dc-az z7N<3fbUq5Jzj5fl_{CD2Gy9AZEOaJq>0KzB)5Kw^bg3&NA=UKi<7pCzO1vI(<*=Jt zTD}^O5nOfPu#`V$j$ad1?(ju?=-`EYu>tQD#dX3Y4CRPNm}2$nA95+r$gxERH}=lR zm!9c#*|(?^s;BYl(T;H!2kZZkSUUJ&_LJX@z4To@c%)!e(Rvdt3a9a(;5fEz-5QF; z#9;HVzeW(&Rg{n0_8tFT(CvKDkHzz&$3DJu7I82Jw8p3aW#CVj&VIWKBW-gP7)GM- z=%nZ!ebG?^wPfT4xam3xefBp68@GiPnd=kKDrSX6|6#lt&01*5pGQkU zniOP}pXf_ydFV(q(7KmP=n(A-66)a>KKIGjYi!SC9Q?4{(=$Hus7g)i(;&eop zNMLFcN>vk_wiU}gW-L(QvY07`-w>P&F?u#4q8m$U%L;zXSXBB|0~tL3CKDo&m;J`9Jy~ zXxBTN)@JI%?EVfanJ>sQxH^Bj}14_{AB9fzh5+Ha=#JtV#K7Fg&MKN&-(gPV1MD3#}- zcwp$>bI}{%kv?;R{ZV-y{i7&bb zpUjWowzY_jJkx~vVTx#*(S3ai2*+%%u`w8XSk8UWpafhsj)aXwHB&)z=v3qzebJZb zl*HKuQ%%eQkl9vjKN;O>4XOqI5ypj7bff#R%-M{DxG=;%lL^{m0MeBOPQ3}wL&9W? z-s4R8k8o@L;rf-N#|D|WxT;H)&o{YkME9#cdc=U&51hPl8Q*tg9z|^RPrU1vo%8@?WQC0vL4x^fR0koC?&eelgd}CxMon|zd z-H<5Ye)@&?o(9l8$U7!JM$5n;2JVW`JV2}Cu6@J&+9e#40$*J!pb;v7YFms8AR43# zZjMqp-!Z6)s$N&M$)y9XZzkNZ@P*~zK?5xa7=m&%4Ijm_SHmJ6@do=UJE=O5(lzT( zK@uU`Q&-RFoDkEnZM0&vu}}GZ#Lv@*4_`&)rsqUf`%f__!2cgHNVakP3)~|{So{H{ z5xPS`0^{P%qaM=2(3VVBb*4Mk5-g0uTwVRd$d-N$cV8L$&f zK#kVq15*O;!F{N;HdwP1nkc|vTf@7B05=Hos-OAlwC5+@)8N}ua1t-CY z5JXukE7C2J-m(uL2Gu(HB32>oFv6s{F(Pb>yc}8j9ehZf==(=PI`J#$o+_&!IuKy1Vq_oOyc3WiI3EJz6w{Tv5N^vpm@AgB?*c0 zl>J2H$7PU5T3ranpqpa052MHBdW4fPPl=Lt!v-Wqa!-WH>NBP${tQ*XFbilY2oeql zo`T)Ql!iGl7^q=U_9TI)N85=>k+bDx+J%&4e$?dGd^Ymv?SB`JiX^9z*x>;D3;skg zF9@N8?Jb6>52Ad=ubbbAiiu$*G2~HVxu{#QB{dPlH}`U=vVl&5DY6_`1Ppi!dXxhE zLB?sNKht|v6m-z%o5QcL-R#Qu(0Z||z1ZLQ&mJ;d+&t=GvJVLAkS9*|PqEi;>sA3Z z$ut1>D75MjPB*S>uwHw!k?&|G@K6V}dAbHW?l zq~sy+^u#=hGlrrhtbU?NTZG~yms5f3?A)etzge$IiOc;|1r zD-Z`)JHS4j2wR0Si|NZ}NHSyM3forxc|z~M=mGPWYx=ZA;hXgid-}Cl{3H+0a7v9z zf6dZx_<3NPJrPbUz!;HnoV&O zC&Vq~UIX_c3uNWRldws2;A`4%vTH4;l7kJD56<|o_W+6K@cMV z7JHsBmV!_n5(`O^B>KYi2at#s5FLQtR!vdQxtJ?#d$qG;EmPF3%5Eez#9C2jzsek>GOHRD(QR3Oi{LB8q#hSEZVo1Yv@ZfNEblU{3;>WACQ!?+W(6jVPkdoUNWZ;dS{iFz`SVOe-*r}!3@3v1axYYXnSm!+=-*Ye#%I*v%Mm z>i{}h^a8wq$b$=1)%>L~&A?nY`KImow=OxnrWp|R#vQ32cI@|kN1oHJ!DyU2IDy!= z0N9)^4?9p}Iear(eu5w{5tODtCK=^x0 z>_ATB&|#v+0%p%e(TL3*#K%@8h{2{fpT+=Rb-P#XwvzR=D+6T3APSdBSJCETt z{~R8^+xL0-uJmVQCfM~-{qD`$M*rqVCL!nySbZ3@FGrsFBW$XZx7co29fCIV+9`gR zIX=@GJK~me@Xu5M=66J7kEi;;jWl2hdPh_p1ILPCk3%|F(oX4(F^ahtQ=$!O7PzJu zQ3B;T$7N#O?t^0}+i{ih#1BH|E{*A$E#xJVY4S-1PGyQc1euy?@b_OspLr3h0CV?a z2tS&H$cUjRU-iq1;p!pm0$4veSYB0i(Zwzoq4TxNvU8I+!VBTTNs=4PH1XOYjkC?w z)*=*`aw!bs=@bN3;wY04j%)e4^<82PT=F#`-}W~qNUI&6X^iVSeOqK+qWw$ImmdkW z(0yKAjyh)r13iboxWUp;(D%sQ%OMJ1AtB4Vj2_h9y&i?L4KxXqv9D+VLi`-vlOIy# zuK6Pd)&}vtS76=3xKLO-qi5gq2m$ude&kG06I#?{yova`{0-gGv0mWjLkDq z8kL4sIKQFg=^IZ754d48jV|K?VmSBk1M^GL z(pbOqRE)5DbKe6`FgC1qv+@ORM6kM!;(iQ~jQi7F_90?mbPzEZP#)f*W@oNvELi!O zIq=7*a1koji6)wj2_T^TXTF zPM2zYrT39gM`7S0tQDxno2yW!4Ieh2vK^>i6W{|{(L5d%%aUOz>`Wk4Ry~?jDS#K> zqw*mWL52IeRnngI5VqFBZZmL2tgkexeCeMXC4zWL4+p@(bN&@3N z2z4&JL<>h2UnJ3yiJEk^iX8KfgA0{57$0?XMKzxy9C*$#ZHXfWMd4Y>5O!>yG$5v; z8+Ww$E=KGU_FL7S(w~GYzTJdC1n2<6@~!zdT|_9u#0)~tBy&_0>a|D3_BwYzx5C~_ z%47N}^0CvrCDXnk(tr(QA!I!`yF!Z=SWcc)52#>B8r;p(AGhNoO540eCbk}za zeL~meh1+UbI`kDQuJXhCr}BLxRN)EX)D;+ood$|cS7;Vwg;H{9 zOIxxB#LwF3UeN43IZN*9$Tb`~g%faOj>e#+ejWW^P4_YA@oUv;9r#Hn`?Y>Ww);tw zTi7I_N5_+nPNP;Fuh;Xb6Pq}XeB-o;|8nt1Ex_SI+`CO9W84+{Y`@xr;^biXLA{#286JayvHG7F{K6|AW+_owwJ2 z!K9jbw_ccx#3qjAgA__C3i{bBAR4HChPw@N zv5+jFDdh>V;DCp;t`U5OD8>=_<(QP`0iyT(AuN-6KO4HsX+GPP?J`2<8M93C@Mlx% zB~IM$FFDt}`(7QCcIg^lOS1m?RJn=}-aYSu1mtduXEG zlN`}+t5HgG@;}rM@Z`-4KWgXnwv;#vPXCJV#&AZGRC;cEwxfe)!`mZPXYYJ2U9{F| zIgpgym#iL@OCEsgC?F!DbB)-?OCx~p0Mg;ZXJ}C`fsf1yPC5sx`6nQ?LT?Xucc?KT zEEq;NK0F+1uLNET!!lHuWen_QujD!m?dYh5?>Q(asC*~WsZ*z{JPNTcyJ(YSXjC1? zBl~TqoECC#A3=MWTN|$L6;M>5`Z+j~s3H?l$3YWag3%bQu3rnJevp-ABsYE*(S|!d z1w~)KkQ=n~nD@~NMyp@>dw1k=K;25a51gU9oR+BpDH>D1YC=D^&CUc1yqFo;K2sJ+ zJ9h(tKUJOE+-;WC4{jhL>28E4`jRT`jQJFtD%Rcx~e_-`$ZxCcBPYR1>Sj%$s9z z-y=t^iW+;1hRrR+EB~=&+qT@5Z?uMCXWWMW_(Kx>#mkh1hUKhTvr$7v;;ibwAKHKOq5b>>sNN*O3h=PbTDN#_6E;yWfk2*}-~ahP&spcyIcuGDUd+%H~#fNK7(n8CpBSuECyany?$cNYdd3AajbtYr7E}v*Zr=hS8KaP=;DLNXp2JQq^7M!#ITbeYNd|&(X!C^Gjd>54uC5^f!d-i|wNQse5{@Zc}CK0ufDjQ*d#N|cUn9Evb z`Zm$py`9VJ_je}z&^K6W^~8{+nDYu%)r;#hZPb8?;9+|WxCgjB5A}EQ#lJB6VmR{y z)U`mi^+ZK|9(mKjyOC0jSAR#YGTvWrQ>bcjoawT!af{knkX=LV`Vw2CV)#T)j|^6# z*2$>+?tSU17B>B?`2I=Z)>B(6*N^QH!y}j42ZbRb4(LT)~YMz4~Wy^@F*7BBsy0MT64QIGslj%M=jGAY~UVL#(S>8gn zR;WynlPp4;GS~6uJWPox^nFXTnEl8MO$|@Dzu#=YVKX3p+4VsI&th(L4Rn@T7B)E( z&!Z!U(sSov6BEAZLXQC_RWMFjRr~G>j|r%4=TOup9k1%%+eF@Ske0$wI*u+?G*659 zUH$;)WED}Fh&Tg|Zj6Gg`DANUv$y^`t-v|4Ds;_+xM1a&*IHDDV?bxa-706425~V5 zqBTQ3GEqCBuC!dU)_Nm`xACLb$depJ9oL;i=x|K1uFK4Lt&T+4VDsM2R0Ltls~l+z zOIzKZ7FhVyFmY3!--?8`ve4Q4IzZ?s^DfB^aSo%T`!#P4cS!oEk3DW)sY_AFbf{#} zaN=n_=2{oWGqz}9yA>LY%!f%C7ZoPi(ZY~vI66vdTkH#`buDWB(aS?T@kI0Ynh`xC z6}Em#d_Nn=7FqbdtBoc3;nlGwjh$(-`Qda^7DaDsQnh+ya_u9Fj&}zGbP2sj=qyKq%DA&K;gY{#&GHSQisYwZmIT6@mdYqtIOkgF-8W7}i6-8)@LVjPNEVW~q zAzDY&yoMtw>6rwb?e{l$^~&4M#0YJt-$D zKI4M;Sk)wN@a%f%wO?{ zhht`91P$h7(VENa$-65C3hiXVq2sZVwhufg+|@Od?bW+iNpG{RmPc5X^akg;=gX1N zW3o>c2S2A6sPC4(OEK8jL>Jo%**N$7Ft@9meL-LA8k>r#P@?pB&+D@< zYwm@LXa|LqJ)Ld|L*6FV6L&_)$M|kmVdh7S3}O#Qhvk3)y2s&ZXc+`Ja`{SsJN=)cZ~uC!JnFQ6<9UI&SSMzK4>I zQ0gDJ#GRK|Wp$L>*GxyOO=S_PP?csZ-eUy4UGG-2@0KQ#HQ$dayd{3|Bi56NJnExg z*taV=NuaUh(ebA=0!nhjZ?RHxtC~ZSHM~bEoiNE&=pAjPs)q0JU~ugmpVmzVfYKvw zGk&nhXQBcImO-X-IIZB=e*bP9j#10+weaC3FLVQmQ&@{taqG^K3Mp?j#+P>$Tjb}h z)mmx~Erqbs^&}P>|Jhk;mt0{i2jZ0`9#7S}uPxGhe$2c)1bQdFhw-HN_PcwnOD6l; z8KNpK7-iws-Hic@;Rj18V^wjtZN!Kh|Lm@n2u;*EH9M#1*7@hC^E*~oM?C_A_3#(O zR#0`9JOZ|1%E_8`xRQ@Tw3GdwC;m)$cm zBv2~Guo)IL&tWdJ>O8DqTcGe33VQ0Z814vJ%s$i z?09c3BXII@k!$pHBsppD4XD_k8YZi)5v1itO6%@cbJ#dPS4hqcah9(jE%c|{+cy?Q z90P}H2)Z>huPQXgq2wIjmXD@s#fL-Zp8?gQ)HGjUztHkUkoUpo0?3|v#^hIz0lKvP zy@K5T4b)-8^Rt&O_fJ&K2 z1P3`n=iccD-j;oNm``5GV1Lw=v4JvReA+MA%!ITWQI=#avfuv#M0M~^eo@+xjl;#R z>p^J^k<0$L?MUXwhA(Z&I!gBA#K)=kwCx|K8u>OyZP{|BLm}JEYdaHF)6_$v-cX9Q zt-JLWdA{%PDw^mh)UZr9j}f0nGHGS< zEh`)K@^v3c(d}12uXoBy;;N)lkBe)6g>&|aQQK3u6e=P|a;GX$J7uKxqKt|M)|$UB zzvR<>W>kV_;PoQ(PG6HnR4eOqbcu}fS3CzVaNI}%J`=vy=xZ-a@|JVcdoNuMnH;6& zM<^k(r;D-$5G1ZhVy9QN?gZpXI1B;7g@)*<9^+Px7>>R6JN>8~F#2ifA<<7!ea118 zAve_{68&_Zt}ipn)V+UviJb9^;lo>8&<5RW9}d_3 z+F6|A#*n&xyLoGcdhbTBHue3JOrJL;X-nFnahniq(20-d3?}C$8NKkWG53NOSUBSS zy28RNR@nIhFXPm{*@Nr^h03O#m)k^wMtX-&Ze>Fm{qnJ){d}Kx@a5R>HMnYEZ+qM#a1L6B~BfIgY~)@V&rZy(;>fy&crR-gE@%`#<8mV^Fd>E8R>Hi80q%y9{G9 zLy0Tfo3@aOx}C*T^TiR!#!OyH%jR&-)M~?CKVB(i=Lf=;P*Fc!yt~J@0bT#gtE#cs z8llFh`jb}~&F=3r5q}41t>2Au@Eudn3C(nZ8R0W7`ps)otwX8ep^CK|{Va--lQsub zx*#Bof)QUO@E#= zHE$dKh_X#I|XGM$(ESxxLu z=Yp5^mhQ`?y6y-4{DnoCmznUMP})sVte*@(R5|;jlEhVI3=B&(9ef&E&8UsUAyvYL z)>7gCht?;{)!zrV3Ffl9lFuFj=~?I9Yz-UjJueKoKXWYc7`HI^-@VspP^m{v!yCIb z%`GCPVM`UHZ1&Q-cuCF*BAL2_>HtHtE(sb0SCJ78anLBS|7HQlnPA-Ml8jtlb1*p) zlo~e%QVgd3IC~7nOU(Vq`HwN!=rywvn~G<7gOp6jqlAm~Vn5KFMXz#8giS@mT&*pn z=a!z!(2$L|Mu>MC)(tM%Bf0;@hE?IhI(93j{dT@Osv%$vwbh{FUPA)cuN@W&4aQ&8z`?O8hb1F# z`umQDU3Q)8W?%Q0$N;7FA1005%r!8&E|}Oz*$-V()rf$x7B8Y8SYV%kDLmb7y;tL zA|K76v@nblnM!qkt!_^lpj#78gRz${-TJErvCCZ;=zft#5Z)_P5?BgfZjVTYgaSdFxKlzTv)bjprvjL4s z7Th0Z(ge8D1hlXQ$JP|%XxZw`;fcqL)iyo@M~`UuP{HEYS{gf~ z5>9HoyByX9^m9#PHVdD5p1L@M2<4t&G-l*Fx=e`AgzZTLQ(ANw|7&`#(isp8FM|-N8bmxFR*%rXMpkDiTwHP z(T$i^CMkj6kd$px@!(03RYA)@{aRtlR`~i~hw10ZxS6(?kuqylh@wK&_N{%M%eGEq za{CENHNds3)pLHS;bXm9QV~=09PTbtTvdP232eu{mgnHMK_sU?L3x(Q^L7Hr=g>)3 zeV6R#sfTSmWb~u9S3F{x>j-6Z2g9W85YN(ZMFjIv4CYoT1ifFJ<0BMW3jjrB3tfK1 zIWSl^T7qvHJ^=BHyKpHJXBFry!4{+FGQS_*%xs*dvd1qlhf*s%l&w5@rZ6qV19%{C8|998Fw_sG;>0^b}w0HxC7-tk~&RjjHZ<=F=bsOf=BQTa!`sq0d~<_GdTt$FTdvv8W2GmFYODrPtP72k zC!e-)ypE=d7Tk$s(n>70UJ@-#fAI)x+!^%wWZ5R;i_b0o)oYJzEk zXv%oq)Z(|7yyL#jE7XAG{&_`eUSJ5U5vaSK3@@PpSWudJj(0%Rh^S=LCtK%7=31Ly z|M-RaKFa1w_D)f-8d@Uq>~Kg*j9dQLtfb_=w84gq!DR8X&hx^8CfCy;l5&fHlq`l& zopSt;MzHa5sMcy@eFUrW{G*Ynkcd*;%PQcLfLyHR^u|cS8i@K4J+#RSsotlRhA(jf z@_{<3WbZ!DgU=M%cvZdw!}X0_bEfxC{-h7z)%w&{8@F3a@?nbrM3g$zuWon@sEg%d<GT+A3Xe|ygW?G|&O05r` zuGEhB0#?}i(lqG(A5!3UgzJt2Goi?Fn+IksYJGoqtz{sz_Fm`TMoWZ@i*|rZvDf(6pY;J6 z&DL@?EvFzUPj0;sRDFe=; z4Y(>W%tlu|8lZ*Vf7xAKn8o!K3ROBqd@jo9;vRBehx^sFea26iDaH2QRYkza?d(rE z+>aP$hOch&@QYP1+O8}-UL2IOD!x5@2Ym3Hb>ZHP-(os=?bWz4B8-H2#UtwWpMSCb z|K2|F`_oh-p-%3*bBI59Q^l)2ZB`)Wx=}kp#1a6J4|Ip}i%ID`9x5Ln;M%rtYnlJ& z0)X0H9gebnaP}pbBlvpNoQ`N=XOVhwt$^7fTL=m?Xlv$5mWr$XK9lT6_3Seeu5hUL zjTT+~I$%EGUDJ~rI^76(%|(FWDClfWe;0rOMw~*Y0Fn)z|M7^Nq~ZJH!?nd3fNzwA z5)4$H{rmIN?k z4w&osBwVbERTD5U&(}w}^<-0y>2CbEK``J%BF%l#dt2K?IDBt&L>c17X3`Fe2mR>P zS$j|HB<^q31CLZjUrgeWc7Aa-eGp-*Eb%Nlnu<>l1UeZ7)usKGfK_UoebX=jU|KQDxzD0f&`FOx)_?v3 z8nDm4YqzV)*Lu$(Pe2a1H$Way{ssacws*G{!M6dXulJb^1^MXBYpo98 zAAnTH4_7$gT24QC#M#?c&Pdsf>ogF$fKCIq7FB9srquYjn{!y~*;T-;Taz?i4df7}#%rW7$*_o{V9eU4s3~|QPjMMR@Ph& zbEw};IoIIxG%5mDMfwh=h8U+^K~R96th1WMnaV0+u+)dU8v%fMG?=3FqsS~1wKa}l zcd_U$6rTB4qP`t9W5lxD5WDD-!N4 zl&Z$L?57`Svh*eLl%*k(S%|D-;AsVzow#Fwtn_Lxm0PiRh1oj5{4>QrtN7Cg%e9l3 zA)^2~yzObmD>W->e+Tw7n{b`Q`^O{DT`cdp@TTz1qB;A!K;hxi^&+;!G<+R#$>M%s zC2{d$4ME@k$-tWq$*7$TIW$!|SrZ@ubCuHV`9A65DqeAIjG;KAA1qIo4oS^t509hl zKYzg}o;-2}ZQVW5yj*SOFYAvm5_a0iaPaMfCmIW+$h(cOanMV&pVAmiqA)qC#;wQ3 zDR_vQ4~Nwb+2r7T6=`=}JF_~enk^L4wf6U7s54zg6rw)8Lpn7LXz7mfuL{?W_ah&( z&P~?&OoWANDS=`F)kWG!t736PctEs4iRNU?C?arA91M)txpUhM<++b%5o>H$+Ng{5tf|#&;7k! zoX&X(0zj?Ie4h+C!D)M%YYQedBfkE&&3K!_xJgSjzot|<#)e;IJNzpmmRx|vsFn1u-82As@US?nM%CjyZ&O6JhaDrARwTOq$x#bBvL(jT( zWbI`*g^Y14VJpNDxW7EKC zXcHM)%)t1$zKv(8u4*-z-X07q=C%c?Y7B(ncW=4#gw-l{l9-$N$EYOXO2+t4x@;x< zB|kUBysNEGO+iZ>WN@1p9*_u^)#f!DJIy?1pNXWnv}59j zz?OPJTSuA&e8=i|l1e4covuddyIU&> zBNyu8p|5EsWH_GNbkJ;yGSbdF13R&pW}&~(Ut}hcC-qG*h98^jS2yN{R5>YXI$K6Z zPXi)-^t05yA-j=;Bk|-UpaT|)GTn;AE$Ch(o_jF9WAxnL0f=j53k{eiK4RhUDr!yv zo#%l-j!3*fvxG+$?{P1f)-Fnsy){3IB!d}y%Jp_4MI{Rx^c8c$3{x5cLTc(U#oEDp zmXBQSDsEM#{BEw#nic7G5lf=Dwj?&{=?Ath6z7l#*uV3Yg`}Sz4-jmxp^^$0Et+s%{s@u#T`6>js~<}pvN;(p>*;S`Gj{`{WlZ^GDqxOlSn%k>qbef zfW(!_=(nW==cJ!Df!)zfwJ_Mbf)it#Bs*&Atkhc!BmRyGObz5*b_Z$CU17=1 z^6d-E6*^v7a(k>LjM2ccJ5x84g{ryba*<*c1+*Y3IzgxEQVgyv`SP2!7yQagdfSs$ ztq1SN<{0`<1P$CNJm(C&bn1~+&=H_gwjdzBc36A^pbdFQ7Ibd-KuyF~_p2zK$-tCQbCzoeU2swOmFZ1>Q?j1KQVl!G((cll;(-G{L#g#y<}N{n%qVbJ`0+H*b;DqH zZoi@ZwdGNgD&{Q#F-pX!@NF-TsX}B9eFAJ;5w+2eCB}EE785V%s9eJ`AFxun z`%6>~;@V;t^j_5iZ7aud$kh(+x+VKP_S7aQrM@6VzTHSMp<4Phee=SONE1!s0Q=-n-^3CrzWtuYh>4 z6#Mzwq7F*|y!T1b4RsPxL`OFLanbk*EB`!mI)oCTjd`A%9wHE1Yx(JQ>-2D(=z05v zZ^)K#44pz9ywHod$J+QR3Ci?8hb6`0WXnu+$C}NWLf4$no-(|PPcYVf)kQe(3_P#B zjFjzcdaPBg=TQ8;*Xea^rb?b0z}AfORF9hSf(F9P2mIGuY7#Rw;W0wH=_gYzsmDRR z&|D#Nhm_JT$_A~pV;BGBQ5&~SyRg`HIX5p+bWUM^#V*84=bqfXN-1a-%q0IF|9-p* zf_4L!c?rdYyoq&GsBCa?_LP7BBJ{8SXkub6(M3D%k@Ab1pr~;>{QPMgpgv$1!3NG? zUqxZ%D8E5_75x`A(#_Vio3pR7{sEovJ>CBTM?UQfC_+To?h1{zD`2792YPUMP{)nP zyJC_#IQI@#FN@zRKJ6~XyQTk@0+tTY4+&Hh+vPoe$z@!AGFqfCB5_-#%qePfh?*Pf ztnbr)M%-f#uySEW)+0#f->*0XjAKnSXI~CIH|rIz`01P{y4%nrbaEca$L?HT=?SD;b^kwVsqyi9I$eSHyjg)-58V-O zHisR+6_-}UF7U@<{7=Y71EAl_syL2Ns#9GBS@d#Of`op>myV6>Rs<9 z@->l((AWG=qz-q}65L!x65s$(%`?4q^OgZIIt6ObAA2kH#P$sl^hLDw{dAE~vL%)x zGDGqY0XYmi$Hh2BLfk6qapqG2t^()Ln0yV%&p*3ahN$c!1lkKyi0GUIkDAf3a}u7c z)gvnFDW_D6>A9Fl=l$qw4t}U$*agh*!j7+dI)UES0>@+rME0VAr@L==hNC@deXIIj zLYz_^h_};~2R@xc>%t-BH)WB}bQ2Ep>0X)xDbytL4=H}M%X;#rSn$IfpSsV_+SvD{ zqdQLZS&<~9SlD5~)Ji*~ozbW?31QEJY#$iLZ-*vSDW$5?8*!BSQ`AS0u|v0r*~mln6(kt(e*`K%548oQl{>2a;D zSVvvt`~|QJ?t%lXhegeYI7+B_-e<`@JSNyGE+b*BpUW$%uDGj)+#<0us%EaeJY*Cr z*LuMnOk+!do%ECV;{dRKY2!t#pO<4$u<<#V452{i#tY~W{f3`QVVA&dLY^Mu;3FyH z*~4O38R3apu7(;h5sT{pIxcfISE_hB>znK8s%ORhj*0_~fz-fWEm_j_RPzFg;DmFu zK=E9AiX?pTbBXZ{-Nn*EDWc)0SRYMJE7i`U;z4cvkTur*ff0Wr9d#d=;f7LOmaQ5t zJQm#mtSBbuiYN94pWexov(q}MmWPguye3|neu=zo*lBkbsrR-%2OuP@wD&}v5A1t3 zkV~Lu>#rJ|z7;JUSMZzlyqutVUF;|12^Z`9(CIKR0%wgn6!QGaCrD<^`}O~k5A|yr zx#lj+L?GDtpn=@Y2V4PKL%DaJ)pAm@1g8di2o_)nAuE^e*OXlX?rY%pBeHCe0;=2* zteJPq@fj{ON!i;b8o)&Jy{UK{^px}fx0t>ct^Lz=8u$)pv6cZ;#5P!}b(%E8EyAublmxD>7@<1quUH3ZGyatCOOB5_tyxkY&!Jf)buNGb`Ji zl(N?xv~WW5qsf`r6q?4^<21VQIv_(>6N<_=s(}Om3Mw~@A+b1kmt4se-$_kGHeemT zO6WULv}e%=L*iYnyA0Oz6FPWQRD}(M!lZ;l@0HJ7H%uFqmv_{HIF|#mE9EO#2)*)R zbUL2DsoG8^BQZ`dBk^&~Vi}_Sxwxp0VG`)KQU=M2F82Nncc0J(UGRji;WiL{-Dl~_ zQC{&$N?4q&W3%x&(mjS35|_YgSb};ff=R=xXCqWpfw$ifr9%*Wu-p%^&yJ3<9#fzz*vpy@Z*d0_l>7PKQee@84wz zslti&sYSr}q=V8O67AOkFfa@a8(273oSkK@J`E+82R*$Cdvg3C&3+p&!hC|I3I~%o z2&Pq*xobuOQcM4Kvc1Y>lh$vc#He}zQ^cY+`h=Z=EW48^l6P>rLWR)d=3W)GHdj(b zP%b~?;z~o;rdhxG+j7++V1lCR-b~Qq+B-7S=PE@3umcb(YVMtTJb@$Kg^3x$rcm-< zbIp)H0kj)7% zJxb_D7(jU@Bi8|$>x6e()a-zQfcqd)2=xr8JTq(>c^SgmfnJoly0Jzy$onbgm$fE) zk1eA7yVb)L9;i1)kQLpU!8(b8gdIDOH}855X2^u;K9<>lX-DHQrG&-UF85 zon5d>jC)a4M^EUXP78sY^pJ0jfKmY2a@xV{aYJNt4;sy?g70|&7gNY<*pXAh$eIPY z_y_i}CsN0;39YM5OX^R5JOnrg0rDNt4$|{WgAo;VOg7hBOkBYQ&tykm=#MG zu`nPu71-#me>(_vVp}6sZ|;v{A#uh6NI3=6YTxC8pBNwMOpHz_aB@j^6yp1V=mVQB z9879rXHOy!nlI#54eD07aLdN2Jf?QUS%*6{tEzrz;Q3DN0%gd*Hr9{^4K*~|0ak+) zhk2Ene_BrB_FyUWJ!3CdkRxys(FC|E?P(EsEP%;r_tVbBAdC)!hX(%Z8G-e{_EYYd z$rDyj&n32e{F`|}Q=w-F==<14J!&%L&S@ zPXXU&sipqyqQ~XtsZFE>N1$^prAS7JyQWh&{IzTXV3lcIq@olRO`+J`L@6sr14&4^ z9ZK3bG1fbD!lxelBQGDPVdDpzG|?@LgeLQ-<@EwFl5YDE2nH8qlS_EYZc4O5eWHB4 zP9n=7`$0FSPmZqO>LoWE=&egbK<=mp5)~2E(f3!Q0#8n@d`dI-oNi&Ewx>nCes(ET zfplD&K6P7aRd;V=z|sQP6p16!LKg_m(H#lGj?b>Ohy#XW?RYA<@Vd4fu@7Vz>I$tl zb#{xAjo@)3=lgG$=a+iWMCNvN6!wLs<$cbj)7NZ4oCRC+y!~ER4Ph?bDsIE_=C!5) zMbHjC)z%5JO&84+Em?hYWpj(y-0hZzuRG2Qwkk?1+{yaSia80kVm;6X`%Iy_*s(`= zo>P}U(Odac-MTvnB>u4T`P(2B63S#e^1g3sqaA2Sd88&&c|i?qThG!8bn`EW7s+uo zR~Wxzv%@yfFI3{37>2f{iWCga*2m*OO0;p5fO1Tvv3khL8StC{8*L*q-DtW6{zjy{ zKFC`@(^B761966@ZXM_6F|R=2ZG0xxIjiYF17O2rO-`u#97nO= z>G;5#Phhzpcz*7`@=eqKy^lU+gY?h71_WHHs@$Wn3u6Q_7tM+jjMX zy9Sw|HtGu8GnZzcwoiIcEA*hD_dCt6?!d+Q|Adw6I@MQoc(_-+mhgWrz5MriC@`Y` zmxA_xpG4z^@Bco0_wS>K|2~)r7Q(;JqW=42?!Wc$-{$*oL;N2bqUWU4BKQFTbMEg2 z{5vxL | null; execution_result?: Record | null; + draft_content?: Record | null; + draft_expires_at?: string | null; + draft_version?: number | null; + confirm_message_id?: string | null; latest_policy_gate_code?: string | null; approval_requirement?: AIPMOApprovalRequirement | null; created_at?: string | null; @@ -100,6 +104,10 @@ export interface AIPMOWorkItemDetail { context_snapshot?: Record | null; action_bundle?: Record | null; execution_result?: Record | null; + draft_content?: Record | null; + draft_expires_at?: string | null; + draft_version?: number | null; + confirm_message_id?: string | null; review_summary?: Record | null; reply_template_key?: string | null; decision_records: AIPMODecisionRecordDetail[]; @@ -126,6 +134,13 @@ export interface AIPMOWorkItemDecisionRequest { reason?: string; } +export interface AIPMODraftDecisionRequest { + action: "confirm" | "revise" | "cancel"; + revision_note?: string | null; + source_message_id?: string; + draft_version?: number | null; +} + export interface AIPMOCadenceSnapshot { id: number; scope_type: string; @@ -184,6 +199,15 @@ export const aiPmoApi = { return unwrap(data); }, + async decideDraftWorkItem(workItemId: number, body: AIPMODraftDecisionRequest): Promise { + const { data } = await api.post>( + `/ai-pmo/work-items/${workItemId}/draft-decision`, + body, + { timeout: 15000 } + ); + return unwrap(data); + }, + async getLatestCadence(params: { cadenceType: string; projectId?: number }): Promise { const { data } = await api.get>("/ai-pmo/cadence/latest", { params: { diff --git a/frontend/src/components/ai-steward/FloatingQAChat.tsx b/frontend/src/components/ai-steward/FloatingQAChat.tsx index d8e9a1e7..094aa7d7 100644 --- a/frontend/src/components/ai-steward/FloatingQAChat.tsx +++ b/frontend/src/components/ai-steward/FloatingQAChat.tsx @@ -190,66 +190,59 @@ async function askRAG( payloadObj.session_id = Math.trunc(sessionId); } const payload = JSON.stringify(payloadObj); - const paths = ["/rag/ask", "/ai-steward/rag/ask"]; - let lastError = "提问失败,请稍后重试。"; - - for (const path of paths) { - let res: Response; - try { - res = await fetch(`${apiBaseURL}${path}`, { - method: "POST", - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${token}`, - }, - body: payload, - }); - } catch (error) { - throw new Error(normalizeRequestError(error, "提问失败,请稍后重试。")); - } - if (res.ok) { - const body = await res.json().catch(() => ({})); - const data = (body?.data ?? body) as { - answer?: string; - session_id?: number | null; - thinking_mode?: string; - sources?: unknown; - response_time_ms?: number | null; - action_taken?: ActionTaken | null; - signal_id?: number | null; - reply_required?: boolean; - reply_text?: string | null; - ack_latency_ms?: number | null; - trace_id?: string | null; - processing_stage?: string | null; - }; - if (!data || !String(data.answer || "").trim()) { - throw new Error("服务返回为空,未生成有效回答。"); - } - const resolvedThinkingMode: ThinkingMode = String(data.thinking_mode || thinkingMode).toLowerCase() === "deep" ? "deep" : "normal"; - return { - answer: String(data.answer), - session_id: data.session_id ?? null, - thinking_mode: resolvedThinkingMode, - sources: normalizeSources(data.sources), - response_time_ms: data.response_time_ms ?? null, - action_taken: data.action_taken ?? null, - signal_id: data.signal_id ?? null, - reply_required: Boolean(data.reply_required), - reply_text: data.reply_text ?? null, - ack_latency_ms: data.ack_latency_ms ?? null, - trace_id: data.trace_id ?? null, - processing_stage: data.processing_stage ?? null, - }; - } - - const body = await res.json().catch(() => ({})); - const detail = String(body?.detail || body?.message || "").trim(); - lastError = detail || `请求失败(${res.status})`; - if (res.status !== 404) break; + let res: Response; + try { + res = await fetch(`${apiBaseURL}/rag/ask`, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${token}`, + }, + body: payload, + }); + } catch (error) { + throw new Error(normalizeRequestError(error, "提问失败,请稍后重试。")); } - throw new Error(lastError); + if (!res.ok) { + const body = await res.json().catch(() => ({})); + const detail = String(body?.detail || body?.message || "").trim(); + throw new Error(detail || `请求失败(${res.status})`); + } + + const body = await res.json().catch(() => ({})); + const data = (body?.data ?? body) as { + answer?: string; + session_id?: number | null; + thinking_mode?: string; + sources?: unknown; + response_time_ms?: number | null; + action_taken?: ActionTaken | null; + signal_id?: number | null; + reply_required?: boolean; + reply_text?: string | null; + ack_latency_ms?: number | null; + trace_id?: string | null; + processing_stage?: string | null; + }; + if (!data || !String(data.answer || "").trim()) { + throw new Error("服务返回为空,未生成有效回答。"); + } + const resolvedThinkingMode: ThinkingMode = String(data.thinking_mode || thinkingMode).toLowerCase() === "deep" ? "deep" : "normal"; + return { + answer: String(data.answer), + session_id: data.session_id ?? null, + thinking_mode: resolvedThinkingMode, + sources: normalizeSources(data.sources), + response_time_ms: data.response_time_ms ?? null, + action_taken: data.action_taken ?? null, + signal_id: data.signal_id ?? null, + reply_required: Boolean(data.reply_required), + reply_text: data.reply_text ?? null, + ack_latency_ms: data.ack_latency_ms ?? null, + trace_id: data.trace_id ?? null, + processing_stage: data.processing_stage ?? null, + }; } function groupSessions(items: SessionSummary[]) { diff --git a/frontend/src/pages/inbox/InboxPage.tsx b/frontend/src/pages/inbox/InboxPage.tsx index 9bf7d2bc..1b2a710c 100644 --- a/frontend/src/pages/inbox/InboxPage.tsx +++ b/frontend/src/pages/inbox/InboxPage.tsx @@ -22,6 +22,31 @@ import { type InboxTabKey, } from "./helpers"; +function remapDraftInboxItem(item: InboxItemViewModel): InboxItemViewModel { + if (item.source_kind !== "ai_pmo_work_item" || !item.source_work_item) return item; + const state = String(item.source_work_item.state || "").toLowerCase(); + if (state !== "draft_pending_confirm") return item; + + const draftPayload = item.source_work_item.draft_content && typeof item.source_work_item.draft_content === "object" + ? item.source_work_item.draft_content as Record + : null; + const draftTitle = String(draftPayload?.title || "").trim(); + const draftSummary = String(draftPayload?.summary || "").trim(); + const draftExpiresAt = String(item.source_work_item.draft_expires_at || "").trim(); + return { + ...item, + display_kind: "action_required", + title: draftTitle || item.title, + summary: draftSummary || item.summary, + stage_text: "草稿待确认", + next_step_text: `回复确认入库、修改或取消${draftExpiresAt ? `(截止 ${draftExpiresAt})` : ""}`, + primary_action: { kind: "confirm", label: "确认入库" } satisfies InboxActionDescriptor, + secondary_action: { kind: "revise_draft", label: "修改" } satisfies InboxActionDescriptor, + weak_action: { kind: "cancel_draft", label: "取消" } satisfies InboxActionDescriptor, + priority_rank: Math.max(3, Number(item.priority_rank || 0)), + }; +} + export default function InboxPage() { const navigate = useNavigate(); const queryClient = useQueryClient(); @@ -61,14 +86,18 @@ export default function InboxPage() { }); }, [notificationsQuery.data, workItemsQuery.data]); + const viewItems = useMemo(() => { + return inboxData.items.map((item) => remapDraftInboxItem(item)); + }, [inboxData.items]); + const projectOptions = useMemo(() => { const map = new Map(); - for (const item of inboxData.items) { + for (const item of viewItems) { if (!item.project_id) continue; map.set(String(item.project_id), item.project_name || `项目 #${item.project_id}`); } return Array.from(map.entries()).map(([value, label]) => ({ value, label })); - }, [inboxData.items]); + }, [viewItems]); const activeProjectOptions = useMemo(() => { const projects = activeProjectsQuery.data || []; @@ -79,11 +108,11 @@ export default function InboxPage() { }, [activeProjectsQuery.data]); const projectScopedItems = useMemo(() => { - return inboxData.items.filter((item) => { + return viewItems.filter((item) => { if (projectFilter === "all") return true; return String(item.project_id || "") === projectFilter; }); - }, [inboxData.items, projectFilter]); + }, [viewItems, projectFilter]); const tabCounts = useMemo(() => { return { @@ -212,6 +241,25 @@ export default function InboxPage() { } if (action.kind === "confirm" && item.source_kind === "ai_pmo_work_item" && item.source_work_item) { + const workItemState = String(item.source_work_item.state || "").toLowerCase(); + if (workItemState === "draft_pending_confirm") { + const result = await aiPmoApi.decideDraftWorkItem(item.source_work_item.id, { + action: "confirm", + draft_version: item.source_work_item.draft_version ?? undefined, + }); + await refreshAll(); + const followupReply = String(result.followup_reply || "").trim(); + if (result.status === "succeeded") { + toast({ title: followupReply || "草稿已确认并入库", variant: "success" }); + } else if (result.status === "pending_manual") { + toast({ title: followupReply || "草稿确认后仍需人工处理", variant: "default" }); + } else { + toast({ title: followupReply || "草稿确认失败,请查看详情", variant: "error" }); + } + setExpandedItemId(item.id); + return; + } + const missingProjectScope = isMissingProjectScopeItem(item); const selectedProjectId = missingProjectScope ? getSelectedProjectId(item) : (item.project_id ?? null); if (missingProjectScope && !selectedProjectId) { @@ -243,6 +291,38 @@ export default function InboxPage() { return; } + if (action.kind === "revise_draft" && item.source_kind === "ai_pmo_work_item" && item.source_work_item) { + const revision = window.prompt("请输入修改意见", ""); + if (revision === null) return; + const trimmed = revision.trim(); + if (!trimmed) { + toast({ title: "请先输入修改意见", variant: "default" }); + return; + } + const result = await aiPmoApi.decideDraftWorkItem(item.source_work_item.id, { + action: "revise", + revision_note: trimmed, + draft_version: item.source_work_item.draft_version ?? undefined, + }); + await refreshAll(); + const followupReply = String(result.followup_reply || "").trim(); + toast({ title: followupReply || "草稿已重新起草", variant: "success" }); + setExpandedItemId(item.id); + return; + } + + if (action.kind === "cancel_draft" && item.source_kind === "ai_pmo_work_item" && item.source_work_item) { + const result = await aiPmoApi.decideDraftWorkItem(item.source_work_item.id, { + action: "cancel", + draft_version: item.source_work_item.draft_version ?? undefined, + }); + await refreshAll(); + const followupReply = String(result.followup_reply || "").trim(); + toast({ title: followupReply || "草稿已取消", variant: "default" }); + setExpandedItemId(item.id); + return; + } + if (action.kind === "reject" && item.source_kind === "ai_pmo_work_item" && item.source_work_item) { const result = await aiPmoApi.decideWorkItem(item.source_work_item.id, { action: "reject", diff --git a/frontend/src/types/inbox.ts b/frontend/src/types/inbox.ts index d7d47563..874b0906 100644 --- a/frontend/src/types/inbox.ts +++ b/frontend/src/types/inbox.ts @@ -6,6 +6,8 @@ export type InboxDisplayKind = "action_required" | "auto_running" | "auto_done" export type InboxSectionKey = "all" | InboxDisplayKind; export type InboxActionKind = | "confirm" + | "revise_draft" + | "cancel_draft" | "reject" | "open_detail" | "open_target" diff --git a/scripts/ai_steward_pressure_coach.py b/scripts/ai_steward_pressure_coach.py index 65a314c9..405625de 100755 --- a/scripts/ai_steward_pressure_coach.py +++ b/scripts/ai_steward_pressure_coach.py @@ -2,7 +2,7 @@ """AI 管家并发压测 + 纠错调教脚本。 目标: -1. 对 /rag/ask 或 /ai-steward/rag/ask 做并发多轮压测。 +1. 对 /rag/ask 做并发多轮压测。 2. 覆盖“纠错追问 + AI 引擎调用链”高风险问法,检测模板化误答泄漏。 3. 可选自动提交反馈(positive / negative + correct_answer)用于线上调教数据积累。 4. 输出 JSON 报告并按阈值给出 pass/fail 退出码。 diff --git a/tmp_short_confirm_redesign_snapshot.html b/tmp_short_confirm_redesign_snapshot.html new file mode 100644 index 00000000..a66f82d2 --- /dev/null +++ b/tmp_short_confirm_redesign_snapshot.html @@ -0,0 +1,100 @@ + +short-confirm-redesign-snapshot + + +
+

短确认重设计验收结果快照

+
{
+  "messages": {
+    "msg1_id": "accept-shortconfirm-redesign-msg1-1773935717",
+    "msg2_id": "accept-shortconfirm-redesign-msg2-1773935717"
+  },
+  "checks": {
+    "msg1_intent": "query.answer",
+    "msg2_intent": "query.answer",
+    "msg2_context_source": "session_inherited",
+    "msg2_has_classify_call": false,
+    "msg2_has_any_llm_call": true
+  },
+  "runs": [
+    {
+      "id": 106,
+      "source_message_id": "accept-shortconfirm-redesign-msg1-1773935717",
+      "status": "succeeded",
+      "retry_count": 0,
+      "work_item_id": 397,
+      "summary": "模型调用失败:function=adaptive_rag_answer_normal, provider=glm, model=GLM-4.7, reason=LLM 返回空内容: message_keys=['role', 'conten...",
+      "created_at": "2026-03-19 23:55:17.132742",
+      "updated_at": "2026-03-19 23:56:50.205344",
+      "completed_at": "2026-03-19 23:56:50.205773"
+    },
+    {
+      "id": 107,
+      "source_message_id": "accept-shortconfirm-redesign-msg2-1773935717",
+      "status": "succeeded",
+      "retry_count": 0,
+      "work_item_id": 398,
+      "summary": "好的,已收到您的确认。如果后续有需要记录或处理的事项,请随时告诉我。",
+      "created_at": "2026-03-19 23:55:22.233825",
+      "updated_at": "2026-03-19 23:56:52.832398",
+      "completed_at": "2026-03-19 23:56:52.832584"
+    }
+  ],
+  "work_items": [
+    {
+      "id": 397,
+      "source_message_id": "accept-shortconfirm-redesign-msg1-1773935717",
+      "state": "failed",
+      "intent": "query.answer",
+      "project_id": 51,
+      "signal_id": null,
+      "updated_at": "2026-03-19 23:55:17.185230",
+      "context_source": "llm_only",
+      "execution_signal_id": null
+    },
+    {
+      "id": 398,
+      "source_message_id": "accept-shortconfirm-redesign-msg2-1773935717",
+      "state": "succeeded",
+      "intent": "query.answer",
+      "project_id": 51,
+      "signal_id": null,
+      "updated_at": "2026-03-19 23:56:52.816953",
+      "context_source": "session_inherited",
+      "execution_signal_id": null
+    }
+  ],
+  "ai_calls": [
+    {
+      "id": 1932,
+      "function_name": "classify_intent_ai_pmo_kernel",
+      "created_at": "2026-03-19 23:55:39.954021",
+      "hit_m1": 1,
+      "hit_m2": 0,
+      "hit_msg2_classify": 0
+    },
+    {
+      "id": 1933,
+      "function_name": "adaptive_rag_answer_normal",
+      "created_at": "2026-03-19 23:56:50.143564",
+      "hit_m1": 1,
+      "hit_m2": 0,
+      "hit_msg2_classify": 0
+    },
+    {
+      "id": 1934,
+      "function_name": "adaptive_rag_answer_normal",
+      "created_at": "2026-03-19 23:56:52.810858",
+      "hit_m1": 0,
+      "hit_m2": 1,
+      "hit_msg2_classify": 0
+    }
+  ]
+}
+
+ \ No newline at end of file diff --git a/tmp_short_confirm_redesign_snapshot.json b/tmp_short_confirm_redesign_snapshot.json new file mode 100644 index 00000000..8046bcc2 --- /dev/null +++ b/tmp_short_confirm_redesign_snapshot.json @@ -0,0 +1,87 @@ +{ + "messages": { + "msg1_id": "accept-shortconfirm-redesign-msg1-1773935717", + "msg2_id": "accept-shortconfirm-redesign-msg2-1773935717" + }, + "checks": { + "msg1_intent": "query.answer", + "msg2_intent": "query.answer", + "msg2_context_source": "session_inherited", + "msg2_has_classify_call": false, + "msg2_has_any_llm_call": true + }, + "runs": [ + { + "id": 106, + "source_message_id": "accept-shortconfirm-redesign-msg1-1773935717", + "status": "succeeded", + "retry_count": 0, + "work_item_id": 397, + "summary": "模型调用失败:function=adaptive_rag_answer_normal, provider=glm, model=GLM-4.7, reason=LLM 返回空内容: message_keys=['role', 'conten...", + "created_at": "2026-03-19 23:55:17.132742", + "updated_at": "2026-03-19 23:56:50.205344", + "completed_at": "2026-03-19 23:56:50.205773" + }, + { + "id": 107, + "source_message_id": "accept-shortconfirm-redesign-msg2-1773935717", + "status": "succeeded", + "retry_count": 0, + "work_item_id": 398, + "summary": "好的,已收到您的确认。如果后续有需要记录或处理的事项,请随时告诉我。", + "created_at": "2026-03-19 23:55:22.233825", + "updated_at": "2026-03-19 23:56:52.832398", + "completed_at": "2026-03-19 23:56:52.832584" + } + ], + "work_items": [ + { + "id": 397, + "source_message_id": "accept-shortconfirm-redesign-msg1-1773935717", + "state": "failed", + "intent": "query.answer", + "project_id": 51, + "signal_id": null, + "updated_at": "2026-03-19 23:55:17.185230", + "context_source": "llm_only", + "execution_signal_id": null + }, + { + "id": 398, + "source_message_id": "accept-shortconfirm-redesign-msg2-1773935717", + "state": "succeeded", + "intent": "query.answer", + "project_id": 51, + "signal_id": null, + "updated_at": "2026-03-19 23:56:52.816953", + "context_source": "session_inherited", + "execution_signal_id": null + } + ], + "ai_calls": [ + { + "id": 1932, + "function_name": "classify_intent_ai_pmo_kernel", + "created_at": "2026-03-19 23:55:39.954021", + "hit_m1": 1, + "hit_m2": 0, + "hit_msg2_classify": 0 + }, + { + "id": 1933, + "function_name": "adaptive_rag_answer_normal", + "created_at": "2026-03-19 23:56:50.143564", + "hit_m1": 1, + "hit_m2": 0, + "hit_msg2_classify": 0 + }, + { + "id": 1934, + "function_name": "adaptive_rag_answer_normal", + "created_at": "2026-03-19 23:56:52.810858", + "hit_m1": 0, + "hit_m2": 1, + "hit_msg2_classify": 0 + } + ] +} \ No newline at end of file diff --git a/tmp_short_confirm_redesign_summary.html b/tmp_short_confirm_redesign_summary.html new file mode 100644 index 00000000..48d95b69 --- /dev/null +++ b/tmp_short_confirm_redesign_summary.html @@ -0,0 +1,36 @@ + + + + + 短确认验收结果 + + + +
+

短确认重设计验收(消息1写操作场景)

+

消息1 ID: accept-shortconfirm-redesign-msg1-1773935717

+

消息2 ID: accept-shortconfirm-redesign-msg2-1773935717

+

消息1 intent: query.answer(预期 signal.create)

+

消息2 context_source: session_inherited

+

消息2 intent: query.answer(继承了消息1的查询意图)

+

消息2 classify_intent_ai_pmo_kernel 调用: 无

+

消息2 LLM 调用: 有(adaptive_rag_answer_normal)

+
ai_call_logs:
+1932 classify_intent_ai_pmo_kernel hit_msg1=1 hit_msg2=0
+1933 adaptive_rag_answer_normal    hit_msg1=1 hit_msg2=0
+1934 adaptive_rag_answer_normal    hit_msg1=0 hit_msg2=1
+
+work_items:
+397 source=msg1 state=failed    intent=query.answer context_source=llm_only
+398 source=msg2 state=succeeded intent=query.answer context_source=session_inherited
+
+ +