Unify AI PMO ingress flow and add draft confirmation execution ledger

This commit is contained in:
Yuuuu 2026-03-23 17:50:07 +08:00
parent 435fabe563
commit 2e3de00c04
126 changed files with 7388 additions and 617 deletions

View File

@ -37,7 +37,7 @@ backend/
│ ├── __init__.py # app 包入口(根目录轻量化后保留) │ ├── __init__.py # app 包入口(根目录轻量化后保留)
│ ├── api/ # 路由层(含 endpoints) │ ├── api/ # 路由层(含 endpoints)
│ ├── core/ # 配置、日志、核心能力 │ ├── core/ # 配置、日志、核心能力
│ │ └── startup/ # 启动阶段拆分模块(db guard/outbox/ingress async/probe/warmup) │ │ └── startup/ # 启动阶段拆分模块(db guard/outbox/ingress async/draft expiry/probe/warmup)
│ ├── deps.py # 共享依赖导出(get_db/AsyncSessionLocal) │ ├── deps.py # 共享依赖导出(get_db/AsyncSessionLocal)
│ ├── dependencies/ # 鉴权/租户等依赖注入 │ ├── dependencies/ # 鉴权/租户等依赖注入
│ ├── middleware/ # 请求中间件(审计上下文注入等) │ ├── middleware/ # 请求中间件(审计上下文注入等)
@ -55,14 +55,27 @@ backend/
│ ├── 20260311_add_ai_pmo_work_items_view.py # ai_pmo_work_items 兼容视图(映射 work_items) │ ├── 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_expand_user_workload_ratio_precision.py # user_workloads.workload_ratio 精度扩容(10,2)
│ ├── 20260311_add_conversation_sessions_table.py # 飞书多轮会话上下文持久化表 │ ├── 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/ # 后端运维/数据修复脚本 ├── scripts/ # 后端运维/数据修复脚本
│ ├── backfill_orphan_signal_links.py # 回填孤立 signal 的 project_signal_links │ ├── backfill_orphan_signal_links.py # 回填孤立 signal 的 project_signal_links
│ ├── dev/ # 开发辅助脚本(check_users/seed_all) │ ├── dev/ # 开发辅助脚本(check_users/seed_all)
│ └── legacy/ # 历史脚本(仅保留兼容参考,不作为主流程) │ └── 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 依赖 ├── requirements.txt # Python 依赖
├── Dockerfile └── Dockerfile
└── (tests 已移除)
``` ```
- 新增:`backend/app/api/llm_providers.py`(AI 管家 LLM provider preset 配置读取接口) - 新增:`backend/app/api/llm_providers.py`(AI 管家 LLM provider preset 配置读取接口)
- 新增:`backend/app/services/ai_pmo/ai_pmo_kernel/ai_agent_kernel.py`(双 Agent 共享决策内核:复杂度路由/执行门控/修正识别) - 新增:`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/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/signal/signal_planner/signal_strategic_analysis_service.py`(Signal Planner 写回收口)
- 新增:`backend/app/services/integrations/feishu_outbox/service.py`(复用 reply_hash 的 outbox 入队收口) - 新增:`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/signal_planner_service.py`(Signal Planner 异步分析服务)
- 新增:`backend/app/services/model/model_routing/service.py`(函数级模型路由配置服务) - 新增:`backend/app/services/model/model_routing/service.py`(函数级模型路由配置服务)
- 新增:`backend/app/services/task_brief_service.py`(增量任务包中心服务) - 新增:`backend/app/services/task_brief_service.py`(增量任务包中心服务)
@ -107,6 +141,9 @@ backend/
- 新增:`backend/app/services/ai_pmo/ai_pmo_kernel_ingress_flow/`(Kernel 入站主流程编排) - 新增:`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_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/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/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_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 会话继承与会话态维护) - 新增:`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/helpers.ts`(Inbox 页面 tab 过滤、排序、project-scope 判断 helper)
- 新增:`frontend/src/pages/inbox/components/InboxFiltersPanel.tsx`(Inbox 页面顶部筛选/搜索/tab 面板拆分) - 新增:`frontend/src/pages/inbox/components/InboxFiltersPanel.tsx`(Inbox 页面顶部筛选/搜索/tab 面板拆分)
- 新增:`frontend/src/pages/inbox/InboxPage.tsx`(Inbox 页面编排层,主文件聚焦状态与动作流转) - 新增:`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/RecipesPage.tsx`(降级为轻量入口,转发到 `pages/recipes/RecipesPage`)
- 新增:`frontend/src/pages/recipes/helpers.ts`(Recipes 页面默认表单与分类常量) - 新增:`frontend/src/pages/recipes/helpers.ts`(Recipes 页面默认表单与分类常量)
- 新增:`frontend/src/pages/recipes/components/CreateRecipeDialog.tsx`、`AnalyzeRecipeDialog.tsx`(Recipes 页面手动创建/AI 识别弹窗拆分) - 新增:`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 草稿 - AI Steward 零配置创建任一步 LLM 失败、超时或 JSON 非法时必须直接报错,禁止回退默认 analysis/signal/charter/tasks 草稿
- Signal Source Onboarding 的 planner/worker 任一步失败必须显式报错或标记 failed,禁止 heuristic/fallback 兜底计划与“最小闭环”摘要 - Signal Source Onboarding 的 planner/worker 任一步失败必须显式报错或标记 failed,禁止 heuristic/fallback 兜底计划与“最小闭环”摘要
- AI Steward 不再暴露 OpenClaw 指挥规划入口,`/ai-steward/command/dispatch` 视为下线路径 - 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. 新增代码的参考模式 ## 9. 新增代码的参考模式
- 新增后端接口:参考 `backend/app/api/sstc.py` 的结构 - 新增后端接口:参考 `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 审批接口(`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 审批接口补充:`approve + waiting_manual + project_id` 走“快速返回 + 后台继续执行”,避免前端长时间阻塞
- AI PMO 恢复接口(`POST /ai-pmo/work-items/{work_item_id}/resume`):支持 `action=continue|commit|refresh|cancel`,复用同一闭环恢复入口 - AI PMO 恢复接口(`POST /ai-pmo/work-items/{work_item_id}/resume`):支持 `action=continue|commit|refresh|cancel`,复用同一闭环恢复入口
- AI PMO 草稿决策接口(`POST /ai-pmo/work-items/{work_item_id}/draft-decision`):支持 `action=confirm|revise|cancel`,与飞书“确认/修改/取消”行为一致
- 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 消息台列表接口(`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_message_text/source_captured_at/source_sender/source_channel_ref`,用于详情页展示“原始输入”与事件时间
- AI PMO 消息台列表接口补充:返回闭环语义字段 `source_conversation_closure/closure_summary/implementation_modules/source_closure_turns`,用于详情页展示“消息闭环 + 实现模块” - 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 会话过期后仅剩单句摘要 - 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`),避免历史脚本直接查旧表名失败 - 兼容 SQL 查询入口:`ai_pmo_work_items` 为视图别名(底层映射 `work_items`),避免历史脚本直接查旧表名失败
- `signal_source.onboard` 已加入工作流门禁旁路:进入执行阶段后按闭环结果返回 `succeeded/pending_manual/failed_safe`,不再被前置仿真门禁直接拦截为 `waiting_manual` - `signal_source.onboard` 已加入工作流门禁旁路:进入执行阶段后按闭环结果返回 `succeeded/pending_manual/failed_safe`,不再被前置仿真门禁直接拦截为 `waiting_manual`
- 飞书入站已拆分“短确认同步快路径 + 非短确认异步队列”:短确认命中会话窗口时直接继承上轮意图并同步执行;其余消息先回“已收到,正在处理”后由 `ai_orchestration_runs` 异步 worker 执行与重试 - 飞书入站已拆分“短确认同步快路径 + 非短确认异步队列”:短确认命中会话窗口时直接继承上轮意图并同步执行;其余消息先回“已收到,正在处理”后由 `ai_orchestration_runs` 异步 worker 执行与重试
- 会话命中 `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_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 与两段输出格式化 - `/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`),为空时才回退默认技能集合 - `PROMPT_DECOMPOSE_TASKS` 技能标签来源已改为运行时动态注入:优先读取 `skill_tags`(按 `sort_order/name`),为空时才回退默认技能集合
## 12. 技术债务清单 ## 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` 回归契约

View File

@ -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")

View File

@ -7,6 +7,10 @@ def _normalize_noise_text(text: str) -> str:
def _should_hide_work_item_from_inbox(*, row: WorkItem, ingest: IngestEvent) -> bool: def _should_hide_work_item_from_inbox(*, row: WorkItem, ingest: IngestEvent) -> bool:
intent_name = str(row.intent or "").strip().lower() 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: if intent_name not in FRONT_NOISE_INTENTS:
return False return False
payload = ingest.payload if isinstance(ingest.payload, dict) else {} payload = ingest.payload if isinstance(ingest.payload, dict) else {}
@ -107,10 +111,12 @@ def _stage_text(state: str) -> str:
"decided": "已决策", "decided": "已决策",
"planned": "已规划", "planned": "已规划",
"executing": "执行中", "executing": "执行中",
"draft_pending_confirm": "草稿待确认",
"waiting_manual": "待人工确认", "waiting_manual": "待人工确认",
"succeeded": "执行完成", "succeeded": "执行完成",
"failed": "执行失败", "failed": "执行失败",
"ignored": "已忽略", "ignored": "已忽略",
"cancelled": "已取消",
} }
return mapping.get(str(state or "").strip(), str(state or "").strip() or "-") 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), "approval_requirement": _normalize_approval_requirement(row.approval_requirement),
} }
return projection return projection

View File

@ -78,6 +78,9 @@ def _serialize_work_item_summary(
source_captured_at = str(payload.get("captured_at") or "").strip() or None source_captured_at = str(payload.get("captured_at") or "").strip() or None
source_sender = str(payload.get("sender") 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 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_seed = closure_summary or message_text or _next_step_text(row) or row.intent or "AI PMO 工作项"
summary = _compact_text(summary_seed, 180) summary = _compact_text(summary_seed, 180)
compact_message = re.sub(r"\s+", "", message_text).lower() if message_text else "" 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'}") else (message_text or closure_summary or f"AI PMO · {row.intent or 'work_item'}")
) )
title = _compact_text(title_seed, 64) 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) priority, priority_rank = _priority_from_review(row.review_summary)
project_name = None project_name = None
if project is not 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, "target_url": f"/projects/{project_id}/board" if project_id is not None else None,
"review_summary": row.review_summary, "review_summary": row.review_summary,
"execution_result": row.execution_result, "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, "created_at": row.created_at.isoformat() if row.created_at else None,
"updated_at": row.updated_at.isoformat() if row.updated_at else None, "updated_at": row.updated_at.isoformat() if row.updated_at else None,
} }

View File

@ -138,6 +138,10 @@ async def get_work_item(
"execution_result": row.execution_result, "execution_result": row.execution_result,
"review_summary": row.review_summary, "review_summary": row.review_summary,
"reply_template_key": row.reply_template_key, "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": [ "decision_records": [
{ {
"id": int(item.id), "id": int(item.id),
@ -178,4 +182,3 @@ async def get_work_item(
], ],
} }
return success_response(payload) return success_response(payload)

View File

@ -2,7 +2,8 @@ from __future__ import annotations
from .shared import * from .shared import *
from .helpers_resume import _queue_resume_work_item, _reject_work_item 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") @router.post("/work-items/{work_item_id}/resume")
async def resume_work_item( 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) result = _normalize_resume_result(raw=raw_result, target_work_item=target)
return success_response(result) 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)

View File

@ -23,3 +23,9 @@ class PMOWorkItemDecisionRequest(BaseModel):
source_message_id: Optional[str] = Field(default=None, max_length=128) source_message_id: Optional[str] = Field(default=None, max_length=128)
reason: Optional[str] = Field(default=None, max_length=500) 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)

View File

@ -4,29 +4,6 @@ from .shared import *
from .helpers import _ensure_project_signal_link from .helpers import _ensure_project_signal_link
from .schemas import SignalSourceListResponse, SignalSourceOnboardRequest, SignalSourceRepairRequest 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") @router.post("/signal-sources/onboard")
@audited_write(caller="user") @audited_write(caller="user")
async def onboard_signal_source( async def onboard_signal_source(
@ -148,4 +125,3 @@ async def repair_dirty_signal_sources(
"items": rows, "items": rows,
} }
) )

View File

@ -15,8 +15,6 @@ from ...dependencies.auth import get_current_user
from ...dependencies.permissions import require_manager_user from ...dependencies.permissions import require_manager_user
from ...dependencies.tenant import get_tenant_id from ...dependencies.tenant import get_tenant_id
from ...models import ProjectSignalLink, Signal, User 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_orchestration_run import AIOrchestrationRunService
from ...services.ai_steward_service import AIStewardService, get_ai_steward_service from ...services.ai_steward_service import AIStewardService, get_ai_steward_service
from ...services.blocker_detection_core import get_blocker_detection_service from ...services.blocker_detection_core import get_blocker_detection_service

View File

@ -2,6 +2,8 @@ from __future__ import annotations
from .shared import * from .shared import *
from .helpers import _resolve_tenant_id from .helpers import _resolve_tenant_id
from .. import rag_ask_contract
from ...services.ai_pmo.ai_pmo_kernel import AIPMOKernelService
@router.post("/ask") @router.post("/ask")
async def rag_ask( async def rag_ask(
@ -10,20 +12,24 @@ async def rag_ask(
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
tenant_id: int = Depends(get_tenant_id), tenant_id: int = Depends(get_tenant_id),
): ):
service = AdaptiveRAGService(db, tenant_id=_resolve_tenant_id(current_user, tenant_id)) service = AIPMOKernelService(
result = await service.answer_question( db,
question=request.question, tenant_id=rag_ask_contract.resolve_effective_tenant_id(current_user, tenant_id),
asked_by=int(current_user.id), )
asked_via=request.asked_via, ingress_result = await service.process_ingress(
project_id=request.project_id, **rag_ask_contract.build_kernel_ingress_kwargs(
source_chat_id=request.source_chat_id, request=request,
session_id=request.session_id, current_user=current_user,
force_new_session=bool(request.new_session), )
thinking_mode=request.thinking_mode, )
action_mode=request.action_mode, if not bool(ingress_result.get("handled")):
sync_requirement=bool(request.sync_requirement), 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") @router.get("/manifest")
async def rag_manifest( async def rag_manifest(
@ -133,4 +139,3 @@ async def rag_symbol_detail(
except ValueError as exc: except ValueError as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc raise HTTPException(status_code=404, detail=str(exc)) from exc
return success_response(data) return success_response(data)

View File

@ -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

View File

@ -98,6 +98,7 @@ __all__ = [
"PROMPT_AI_WORKER_SIGNAL_SOURCE_EXECUTION", "PROMPT_AI_WORKER_SIGNAL_SOURCE_EXECUTION",
"PROMPT_AI_PMO_INTENT_CLASSIFICATION", "PROMPT_AI_PMO_INTENT_CLASSIFICATION",
"PROMPT_AI_PMO_SIGNAL_BATCH_ANALYSIS", "PROMPT_AI_PMO_SIGNAL_BATCH_ANALYSIS",
"PROMPT_DRAFT_GENERATOR",
"PROMPT_RAG_INTENT_CLASSIFICATION", "PROMPT_RAG_INTENT_CLASSIFICATION",
"PROMPT_RAG_ANSWER", "PROMPT_RAG_ANSWER",
"PROMPT_RAG_DB_PROJECT_QA", "PROMPT_RAG_DB_PROJECT_QA",

View File

@ -1,7 +1,7 @@
from __future__ import annotations 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_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_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_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 from .part_scenes_01 import PROMPT_FEISHU_DIRECT_QA
@ -30,6 +30,7 @@ __all__ = [
"PROMPT_AI_WORKER_SIGNAL_SOURCE_EXECUTION", "PROMPT_AI_WORKER_SIGNAL_SOURCE_EXECUTION",
"PROMPT_AI_PMO_INTENT_CLASSIFICATION", "PROMPT_AI_PMO_INTENT_CLASSIFICATION",
"PROMPT_AI_PMO_SIGNAL_BATCH_ANALYSIS", "PROMPT_AI_PMO_SIGNAL_BATCH_ANALYSIS",
"PROMPT_DRAFT_GENERATOR",
"PROMPT_RAG_INTENT_CLASSIFICATION", "PROMPT_RAG_INTENT_CLASSIFICATION",
"PROMPT_RAG_ANSWER", "PROMPT_RAG_ANSWER",
"PROMPT_RAG_DB_PROJECT_QA", "PROMPT_RAG_DB_PROJECT_QA",

View File

@ -166,9 +166,9 @@ PROMPT_AI_PMO_INTENT_CLASSIFICATION = """
只输出一个 JSON 对象(不要解释、不要 Markdown): 只输出一个 JSON 对象(不要解释、不要 Markdown):
{{ {{
"family": "query|project_create|project_signal|signal_source", "family": "query|project_create|task|project_signal|signal_source",
"action": "answer|create|discover|onboard", "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, "confidence": 0.0,
"project_id": 0, "project_id": 0,
"requested_count": 1, "requested_count": 1,
@ -176,16 +176,26 @@ PROMPT_AI_PMO_INTENT_CLASSIFICATION = """
"reasoning": "一句话简述判定原因" "reasoning": "一句话简述判定原因"
}} }}
快速规则(按优先级): 判定规则(按优先级):
1. 含“分析/梳理/方案/设计思路/帮我分析/先分析” => family=query, action=answer, intent=query.read_only。 1. query.read_only 定义:仅当用户是在“查询 / 了解 / 分析已有内容”时,才判定为 query.read_only。
2. 含“创建/新建/新增 + 项目”,且不含第1条分析诉求 => family=project_create, action=create, intent=project.create。 2. 若消息同时包含“新增 / 创建 / 记录 / 提交”等写操作意图,优先判定为对应写操作意图,不要误判为 query.read_only。
3. 含“新增信号/录入信号/创建信号” => family=project_signal, action=create, intent=signal.create。 3. project.create:当用户明确表达“创建一个新项目”时,返回 family=project_create, action=create, intent=project.create。
4. 含“先分析候选/拆分候选并确认” => family=project_signal, action=discover, intent=signal.create, requires_confirmation=true。 4. task.create:当用户明确表达“创建/新增/安排任务”时,返回 family=task, action=create, intent=task.create。
5. 含“信号源/来源/webhook/rss/github 接入” => family=signal_source, action=onboard, intent=signal_source.onboard。 5. signal.create 定义:当用户想创建 / 新增 / 录入 / 提交一条新的需求、功能、信号、想法或问题时,返回 family=project_signal, action=create, intent=signal.create。核心特征是“要产生一条新记录”,不管用户用什么词表达。
6. 若消息为短确认语气,可结合 session_last_intent;无把握时返回 query.answer。 6. signal.create few-shot:
7. project_id 不确定返回 null;requested_count 默认 1(仅 project_signal 有效,范围 1-5)。 - “给XX项目加个需求” -> signal.create
8. family/action/intent 必须严格使用上面 JSON 示例给定的枚举值,不能自造。 - “新增一个功能需求” -> signal.create
9. 若请求 DDL(建表/改表/删表),必须返回 query.answer + query.read_only,reasoning 含 ddl_api_required。 - “帮我记录这个想法” -> 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 = """ PROMPT_AI_PMO_SIGNAL_BATCH_ANALYSIS = """
@ -218,6 +228,41 @@ PROMPT_AI_PMO_SIGNAL_BATCH_ANALYSIS = """
4. 不要输出 Markdown,不要解释,不要代码块。 4. 不要输出 Markdown,不要解释,不要代码块。
""".strip() """.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 = """ PROMPT_RAG_INTENT_CLASSIFICATION = """
你是 PMFlow 的意图分类器。请判断输入文本属于:question / statement / mixed。 你是 PMFlow 的意图分类器。请判断输入文本属于:question / statement / mixed。
@ -243,5 +288,6 @@ __all__ = [
"PROMPT_AI_WORKER_SIGNAL_SOURCE_EXECUTION", "PROMPT_AI_WORKER_SIGNAL_SOURCE_EXECUTION",
"PROMPT_AI_PMO_INTENT_CLASSIFICATION", "PROMPT_AI_PMO_INTENT_CLASSIFICATION",
"PROMPT_AI_PMO_SIGNAL_BATCH_ANALYSIS", "PROMPT_AI_PMO_SIGNAL_BATCH_ANALYSIS",
"PROMPT_DRAFT_GENERATOR",
"PROMPT_RAG_INTENT_CLASSIFICATION", "PROMPT_RAG_INTENT_CLASSIFICATION",
] ]

View File

@ -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)

View File

@ -16,6 +16,7 @@ from .core.response import success_response
from .core.database import AsyncSessionLocal, engine from .core.database import AsyncSessionLocal, engine
from .core.startup.db_guard import check_database_dialect_guard 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.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.feishu_outbox import feishu_outbox_drain_loop
from .core.startup.model_probe import probe_model_connectivity_on_startup, startup_log from .core.startup.model_probe import probe_model_connectivity_on_startup, startup_log
from .core.startup.world_model_warmup import warmup_world_models 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") 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") 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") 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}") startup_log("后端运行中", url=f"http://127.0.0.1:{backend_port}")
@ -105,6 +107,12 @@ async def lifespan(_: FastAPI):
except asyncio.CancelledError: except asyncio.CancelledError:
pass pass
draft_expiry_task.cancel()
try:
await draft_expiry_task
except asyncio.CancelledError:
pass
if not wm_warmup_task.done(): if not wm_warmup_task.done():
wm_warmup_task.cancel() wm_warmup_task.cancel()
with suppress(asyncio.CancelledError): with suppress(asyncio.CancelledError):

View File

@ -2,9 +2,10 @@
from __future__ import annotations from __future__ import annotations
from datetime import datetime
from typing import Any, Optional 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 sqlalchemy.orm import Mapped, mapped_column
from .base import Base from .base import Base
@ -54,4 +55,7 @@ class WorkItem(Base):
execution_result: Mapped[Optional[Any]] = mapped_column(JSONB, nullable=True) execution_result: Mapped[Optional[Any]] = mapped_column(JSONB, nullable=True)
review_summary: 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) 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)

View File

@ -3,21 +3,66 @@
from __future__ import annotations from __future__ import annotations
from datetime import datetime 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): 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) question: str = Field(..., min_length=1, max_length=4000)
project_id: Optional[int] = Field(default=None) 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) source_chat_id: Optional[str] = Field(default=None, max_length=128)
session_id: Optional[int] = Field(default=None, ge=1) session_id: Optional[int] = Field(default=None, ge=1)
new_session: bool = Field(default=False) new_session: bool = Field(default=False)
thinking_mode: str = Field(default="normal", pattern="^(normal|deep)$") thinking_mode: str = Field(default="normal", pattern="^(normal|deep)$")
action_mode: str = Field(default="auto", pattern="^(auto|read|write|read_write)$") action_mode: str = Field(default="auto", pattern="^(auto|read|write|read_write)$")
sync_requirement: bool = Field(default=True) sync_requirement: bool = Field(default=True)
message_intent: Optional[str] = Field(default=None, max_length=32)
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): class KnowledgeDocCreateRequest(BaseModel):

View File

@ -27,6 +27,19 @@ class AdaptiveRAGAnswerEntryMixin:
"ai_pmo_ui", "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( async def answer_question(
self, self,
*, *,
@ -46,11 +59,6 @@ class AdaptiveRAGAnswerEntryMixin:
) -> dict[str, Any]: ) -> dict[str, Any]:
started = time.perf_counter() started = time.perf_counter()
clean_q = str(question or "").strip() 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) active_thinking_mode = self._normalize_thinking_mode(thinking_mode)
deep_thinking = active_thinking_mode == THINKING_MODE_DEEP deep_thinking = active_thinking_mode == THINKING_MODE_DEEP
asked_via_lower = str(asked_via or "").strip().lower() asked_via_lower = str(asked_via or "").strip().lower()
@ -84,46 +92,31 @@ class AdaptiveRAGAnswerEntryMixin:
thinking_mode=active_thinking_mode, thinking_mode=active_thinking_mode,
) )
session_id, turn_index = await self._touch_session_for_new_record( runtime = await self._prepare_answer_runtime(
asked_by=asked_by, clean_q=clean_q,
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_by=asked_by,
asked_via=asked_via, asked_via=asked_via,
project_id=project_id, project_id=project_id,
source_chat_id=source_chat_id,
session_id=session_id, session_id=session_id,
external_session_id=context_session_id, conversation_key=conversation_key,
limit=10, force_new_session=force_new_session,
action_mode=action_mode,
) )
state_row = await self._ensure_session_state(session_id=session_id) session_id = runtime["session_id"]
effective_q, rewrite_meta = self._rewrite_question_with_state( turn_index = int(runtime["turn_index"] or 1)
question=clean_q, context_session_id = runtime["context_session_id"]
conversation=conversation, conversation = runtime["conversation"]
session_state=state_row, state_row = runtime["state_row"]
) effective_q = str(runtime["effective_q"] or "").strip()
query_type = self._classify_query_type(effective_q) rewrite_meta = runtime["rewrite_meta"] if isinstance(runtime["rewrite_meta"], dict) else {}
requested_action_mode = self._normalize_action_mode(action_mode) query_type = str(runtime["query_type"] or QUERY_TYPE_UNKNOWN)
effective_action_mode = self._resolve_action_mode( effective_action_mode = str(runtime["effective_action_mode"] or ACTION_MODE_AUTO).strip().lower()
requested_mode=requested_action_mode,
question=clean_q, if self._should_trigger_write_ack_gate(
effective_question=effective_q, sync_requirement=sync_requirement,
) asked_via=asked_via,
if ( effective_action_mode=effective_action_mode,
bool(sync_requirement)
and asked_via_lower != "feishu"
and effective_action_mode in {ACTION_MODE_WRITE, ACTION_MODE_READ_WRITE}
): ):
ack_result = await self._handle_ack_first_requirement( ack_result = await self._handle_ack_first_requirement(
question=clean_q, question=clean_q,
@ -142,132 +135,8 @@ class AdaptiveRAGAnswerEntryMixin:
) )
if ack_result is not None: if ack_result is not None:
return ack_result return ack_result
forced_deep_reason = "" return await self._execute_base_answer_mainline(
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, 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_by=asked_by,
asked_via=asked_via, asked_via=asked_via,
project_id=project_id, project_id=project_id,
@ -277,16 +146,15 @@ class AdaptiveRAGAnswerEntryMixin:
context_session_id=context_session_id, context_session_id=context_session_id,
conversation=conversation, conversation=conversation,
state_row=state_row, state_row=state_row,
effective_q=effective_q,
rewrite_meta=rewrite_meta, rewrite_meta=rewrite_meta,
query_type=query_type, query_type=query_type,
thinking_mode=thinking_mode, thinking_mode=thinking_mode,
active_thinking_mode=active_thinking_mode, active_thinking_mode=active_thinking_mode,
deep_thinking=deep_thinking, 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_retrieval_steps=deep_retrieval_steps,
deep_vector_top_k=deep_vector_top_k, deep_vector_top_k=deep_vector_top_k,
system_snapshot=system_snapshot,
user_snapshot=user_snapshot,
started=started, started=started,
message_intent=message_intent, message_intent=message_intent,
mixed_signal_part=mixed_signal_part, mixed_signal_part=mixed_signal_part,

View File

@ -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,
)

View File

@ -3,6 +3,7 @@ from __future__ import annotations
from .mixin_core_intent import AdaptiveRAGCoreIntentMixin from .mixin_core_intent import AdaptiveRAGCoreIntentMixin
from .mixin_answer_entry import AdaptiveRAGAnswerEntryMixin from .mixin_answer_entry import AdaptiveRAGAnswerEntryMixin
from .mixin_answer_mainline import AdaptiveRAGAnswerMainlineMixin
from .mixin_answer_precheck import AdaptiveRAGAnswerPrecheckMixin from .mixin_answer_precheck import AdaptiveRAGAnswerPrecheckMixin
from .mixin_answer_retrieval import AdaptiveRAGAnswerRetrievalMixin from .mixin_answer_retrieval import AdaptiveRAGAnswerRetrievalMixin
from .mixin_answer_payload import AdaptiveRAGAnswerPayloadMixin from .mixin_answer_payload import AdaptiveRAGAnswerPayloadMixin
@ -43,6 +44,7 @@ from .mixin_conversation_utils_b import AdaptiveRAGConversationUtilsBMixin
class AdaptiveRAGService( class AdaptiveRAGService(
AdaptiveRAGCoreIntentMixin, AdaptiveRAGCoreIntentMixin,
AdaptiveRAGAnswerEntryMixin, AdaptiveRAGAnswerEntryMixin,
AdaptiveRAGAnswerMainlineMixin,
AdaptiveRAGAnswerPrecheckMixin, AdaptiveRAGAnswerPrecheckMixin,
AdaptiveRAGAnswerRetrievalMixin, AdaptiveRAGAnswerRetrievalMixin,
AdaptiveRAGAnswerPayloadMixin, AdaptiveRAGAnswerPayloadMixin,

View File

@ -141,6 +141,10 @@ async def _dispatch_claimed_ingress_run(*, run_id: int) -> None:
captured_at_value = row.captured_at captured_at_value = row.captured_at
source_message_id_value = str(row.source_message_id or "").strip() or None source_message_id_value = str(row.source_message_id or "").strip() or None
chat_type_value = str(row.chat_type 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: try:
capture_service = ProactiveSignalCaptureService(db) 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, feishu_message_id=source_message_id_value,
chat_type=chat_type_value, chat_type=chat_type_value,
force_sync=True, force_sync=True,
message_sent_at=message_sent_at_value,
session_snapshot=session_snapshot_value,
) )
finally: finally:
stop_heartbeat.set() stop_heartbeat.set()

View File

@ -7,6 +7,7 @@ import os
from .shared import ( from .shared import (
CanonicalIntent, CanonicalIntent,
INTENT_QUERY, INTENT_QUERY,
INTENT_TASK_CREATE,
INTENT_SIGNAL_CREATE, INTENT_SIGNAL_CREATE,
INTENT_SIGNAL_SOURCE_ONBOARD, INTENT_SIGNAL_SOURCE_ONBOARD,
re, 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 { return {
"auto_execute": True, "auto_execute": True,
"policy_gate_hit": False, "policy_gate_hit": False,

View File

@ -10,6 +10,7 @@ from ..ai_pmo_intent import (
INTENT_FOLLOWUP_CONTINUE, INTENT_FOLLOWUP_CONTINUE,
INTENT_PROJECT_CREATE, INTENT_PROJECT_CREATE,
INTENT_QUERY, INTENT_QUERY,
INTENT_TASK_CREATE,
INTENT_SIGNAL_CREATE, INTENT_SIGNAL_CREATE,
INTENT_SIGNAL_SOURCE_ONBOARD, INTENT_SIGNAL_SOURCE_ONBOARD,
ROUTE_PROJECT_SIGNAL_CANCEL, ROUTE_PROJECT_SIGNAL_CANCEL,
@ -46,6 +47,7 @@ __all__ = [
"INTENT_FOLLOWUP_CONTINUE", "INTENT_FOLLOWUP_CONTINUE",
"INTENT_PROJECT_CREATE", "INTENT_PROJECT_CREATE",
"INTENT_QUERY", "INTENT_QUERY",
"INTENT_TASK_CREATE",
"INTENT_SIGNAL_CREATE", "INTENT_SIGNAL_CREATE",
"INTENT_SIGNAL_SOURCE_ONBOARD", "INTENT_SIGNAL_SOURCE_ONBOARD",
"ROUTE_PROJECT_SIGNAL_CANCEL", "ROUTE_PROJECT_SIGNAL_CANCEL",

View File

@ -19,6 +19,7 @@ class SimulationEngine:
BUDGET_COST_BY_OPERATION = { BUDGET_COST_BY_OPERATION = {
"query.answer": 1.0, "query.answer": 1.0,
"project.create": 120.0, "project.create": 120.0,
"task.create": 14.0,
"signal.enter": 8.0, "signal.enter": 8.0,
"signal_source.onboard": 40.0, "signal_source.onboard": 40.0,
ROUTE_PROJECT_SIGNAL_DISCOVER: 3.0, ROUTE_PROJECT_SIGNAL_DISCOVER: 3.0,
@ -29,6 +30,7 @@ class SimulationEngine:
HOURS_BY_OPERATION = { HOURS_BY_OPERATION = {
"query.answer": 0.2, "query.answer": 0.2,
"project.create": 12.0, "project.create": 12.0,
"task.create": 2.5,
"signal.enter": 1.5, "signal.enter": 1.5,
"signal_source.onboard": 6.0, "signal_source.onboard": 6.0,
ROUTE_PROJECT_SIGNAL_DISCOVER: 0.8, ROUTE_PROJECT_SIGNAL_DISCOVER: 0.8,

View File

@ -5,6 +5,7 @@ from __future__ import annotations
from .shared import ( from .shared import (
CanonicalIntent, CanonicalIntent,
INTENT_PROJECT_CREATE, INTENT_PROJECT_CREATE,
INTENT_TASK_CREATE,
INTENT_SIGNAL_SOURCE_ONBOARD, INTENT_SIGNAL_SOURCE_ONBOARD,
Any, Any,
normalize_workload_ratio, 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) 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: if intent.name == INTENT_PROJECT_CREATE:
risk_score = max(risk_score, 0.62) 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: if intent.name == INTENT_SIGNAL_SOURCE_ONBOARD:
risk_score = max(risk_score, 0.58) risk_score = max(risk_score, 0.58)

View File

@ -8,6 +8,7 @@ from .shared import (
INTENT_FOLLOWUP_CONTINUE, INTENT_FOLLOWUP_CONTINUE,
INTENT_PROJECT_CREATE, INTENT_PROJECT_CREATE,
INTENT_QUERY, INTENT_QUERY,
INTENT_TASK_CREATE,
INTENT_SIGNAL_CREATE, INTENT_SIGNAL_CREATE,
INTENT_SIGNAL_SOURCE_ONBOARD, INTENT_SIGNAL_SOURCE_ONBOARD,
ROUTE_PROJECT_SIGNAL_CANCEL, ROUTE_PROJECT_SIGNAL_CANCEL,
@ -42,6 +43,8 @@ class TacticalBrain:
execution_actions.append({"operation_code": "query.answer", "mode": "read_only"}) execution_actions.append({"operation_code": "query.answer", "mode": "read_only"})
elif intent.name == INTENT_PROJECT_CREATE: elif intent.name == INTENT_PROJECT_CREATE:
execution_actions.append({"operation_code": "project.create", "mode": "write"}) 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: elif intent.name == INTENT_SIGNAL_SOURCE_ONBOARD:
execution_actions.append({"operation_code": "signal.enter", "mode": "write"}) execution_actions.append({"operation_code": "signal.enter", "mode": "write"})
execution_actions.append({"operation_code": "signal_source.onboard", "mode": "write"}) execution_actions.append({"operation_code": "signal_source.onboard", "mode": "write"})

View File

@ -8,6 +8,7 @@ from .intent_resolver import IntentResolver
__all__ = [ __all__ = [
"INTENT_QUERY", "INTENT_QUERY",
"INTENT_PROJECT_CREATE", "INTENT_PROJECT_CREATE",
"INTENT_TASK_CREATE",
"INTENT_SIGNAL_CREATE", "INTENT_SIGNAL_CREATE",
"INTENT_SIGNAL_SOURCE_ONBOARD", "INTENT_SIGNAL_SOURCE_ONBOARD",
"INTENT_FOLLOWUP_CONFIRM", "INTENT_FOLLOWUP_CONFIRM",
@ -16,6 +17,7 @@ __all__ = [
"FOLLOWUP_TARGET_INTENTS", "FOLLOWUP_TARGET_INTENTS",
"ROUTE_FAMILY_QUERY", "ROUTE_FAMILY_QUERY",
"ROUTE_FAMILY_PROJECT_CREATE", "ROUTE_FAMILY_PROJECT_CREATE",
"ROUTE_FAMILY_TASK",
"ROUTE_FAMILY_PROJECT_SIGNAL", "ROUTE_FAMILY_PROJECT_SIGNAL",
"ROUTE_FAMILY_SIGNAL_SOURCE", "ROUTE_FAMILY_SIGNAL_SOURCE",
"ROUTE_FAMILY_FOLLOWUP", "ROUTE_FAMILY_FOLLOWUP",
@ -30,6 +32,7 @@ __all__ = [
"ROUTE_ACTION_CONFIRM", "ROUTE_ACTION_CONFIRM",
"ROUTE_QUERY_ANSWER", "ROUTE_QUERY_ANSWER",
"ROUTE_PROJECT_CREATE", "ROUTE_PROJECT_CREATE",
"ROUTE_TASK_CREATE",
"ROUTE_PROJECT_SIGNAL_CREATE", "ROUTE_PROJECT_SIGNAL_CREATE",
"ROUTE_PROJECT_SIGNAL_DISCOVER", "ROUTE_PROJECT_SIGNAL_DISCOVER",
"ROUTE_PROJECT_SIGNAL_COMMIT", "ROUTE_PROJECT_SIGNAL_COMMIT",
@ -53,6 +56,7 @@ __all__ = [
"FOLLOWUP_CONTINUE_HINTS", "FOLLOWUP_CONTINUE_HINTS",
"FOLLOWUP_WORK_ITEM_ID_RE", "FOLLOWUP_WORK_ITEM_ID_RE",
"SIGNAL_CREATE_HINTS", "SIGNAL_CREATE_HINTS",
"TASK_CREATE_HINTS",
"PROJECT_CREATE_HINTS", "PROJECT_CREATE_HINTS",
"SESSION_SHORT_CONFIRM_WINDOW_SECONDS", "SESSION_SHORT_CONFIRM_WINDOW_SECONDS",
"SHORT_CONFIRM_MAX_MESSAGE_LENGTH", "SHORT_CONFIRM_MAX_MESSAGE_LENGTH",

View File

@ -103,6 +103,16 @@ class IntentResolver:
pretty[:300], 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): if IntentResolver._looks_like_signal_create_request(text=pretty):
return CanonicalIntent( return CanonicalIntent(
INTENT_SIGNAL_CREATE, INTENT_SIGNAL_CREATE,
@ -148,19 +158,71 @@ class IntentResolver:
return True return True
if any(token in normalized for token in SIGNAL_CREATE_HINTS): if any(token in normalized for token in SIGNAL_CREATE_HINTS):
return True return True
signal_object_hints = ("信号", "需求", "任务", "事项", "问题", "改造", "优化") signal_object_hints = ("信号", "需求", "事项", "问题", "改造", "优化")
has_operation = any(token in normalized for token in IntentResolver.OPERATION_VERBS) 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) has_signal_object = any(token in normalized for token in signal_object_hints)
if has_operation and has_signal_object: if has_operation and has_signal_object:
return True return True
return bool( return bool(
re.search( re.search(
r"(?:给|帮|请).{0,20}(?:新增|增加|添加|补充|加).{0,20}(?:信号|需求|任务|事项)", r"(?:给|帮|请).{0,20}(?:新增|增加|添加|补充|加).{0,20}(?:信号|需求|事项)",
normalized, normalized,
re.IGNORECASE, 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 @staticmethod
def _is_smalltalk_message(*, pretty: str, normalized: str) -> bool: def _is_smalltalk_message(*, pretty: str, normalized: str) -> bool:
compact = re.sub(r"[\\s\\??!!,,。~~、]+", "", str(pretty or "")).strip().lower() compact = re.sub(r"[\\s\\??!!,,。~~、]+", "", str(pretty or "")).strip().lower()
@ -181,6 +243,8 @@ class IntentResolver:
return False return False
if IntentResolver._looks_like_signal_create_request(text=normalized): if IntentResolver._looks_like_signal_create_request(text=normalized):
return False return False
if IntentResolver._looks_like_task_create_request(text=normalized):
return False
if any(token in normalized for token in QUESTION_HINTS): if any(token in normalized for token in QUESTION_HINTS):
return True return True
has_question_structure = ( has_question_structure = (

View File

@ -41,6 +41,8 @@ class RouteResolver:
return INTENT_SIGNAL_SOURCE_ONBOARD return INTENT_SIGNAL_SOURCE_ONBOARD
if family == ROUTE_FAMILY_PROJECT_CREATE: if family == ROUTE_FAMILY_PROJECT_CREATE:
return INTENT_PROJECT_CREATE return INTENT_PROJECT_CREATE
if family == ROUTE_FAMILY_TASK:
return INTENT_TASK_CREATE
if family == ROUTE_FAMILY_FOLLOWUP: if family == ROUTE_FAMILY_FOLLOWUP:
return INTENT_FOLLOWUP_CONTINUE return INTENT_FOLLOWUP_CONTINUE
return INTENT_QUERY return INTENT_QUERY
@ -61,8 +63,14 @@ class RouteResolver:
return explicit_action == ROUTE_ACTION_DISCOVER return explicit_action == ROUTE_ACTION_DISCOVER
@staticmethod @staticmethod
def parse_followup_action(*, metadata: dict[str, Any], canonical_intent: str) -> str: def parse_followup_action(
explicit_action = str(metadata.get("followup_action") or metadata.get("action") or "").strip().lower() *,
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 { if explicit_action in {
ROUTE_ACTION_CONTINUE, ROUTE_ACTION_CONTINUE,
ROUTE_ACTION_CONFIRM, ROUTE_ACTION_CONFIRM,
@ -71,6 +79,13 @@ class RouteResolver:
ROUTE_ACTION_REFRESH, ROUTE_ACTION_REFRESH,
}: }:
return explicit_action 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}: if canonical_intent in {INTENT_FOLLOWUP_CONFIRM, INTENT_FOLLOWUP_CONTINUE}:
return ROUTE_ACTION_COMMIT return ROUTE_ACTION_COMMIT
return ROUTE_ACTION_CONTINUE return ROUTE_ACTION_CONTINUE
@ -92,8 +107,15 @@ class RouteResolver:
route_key = ROUTE_PROJECT_SIGNAL_DISCOVER route_key = ROUTE_PROJECT_SIGNAL_DISCOVER
elif route_key not in {ROUTE_PROJECT_SIGNAL_CREATE, ROUTE_PROJECT_SIGNAL_DISCOVER}: elif route_key not in {ROUTE_PROJECT_SIGNAL_CREATE, ROUTE_PROJECT_SIGNAL_DISCOVER}:
route_key = ROUTE_PROJECT_SIGNAL_CREATE 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}: 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: if action == ROUTE_ACTION_CONFIRM:
route_key = ROUTE_FOLLOWUP_CONFIRM route_key = ROUTE_FOLLOWUP_CONFIRM
else: else:

View File

@ -11,6 +11,7 @@ from ...signal.signal_source_orchestration import SignalSourceOrchestrationServi
INTENT_QUERY = "query.read_only" INTENT_QUERY = "query.read_only"
INTENT_PROJECT_CREATE = "project.create" INTENT_PROJECT_CREATE = "project.create"
INTENT_TASK_CREATE = "task.create"
INTENT_SIGNAL_CREATE = "signal.create" INTENT_SIGNAL_CREATE = "signal.create"
INTENT_SIGNAL_SOURCE_ONBOARD = "signal_source.onboard" INTENT_SIGNAL_SOURCE_ONBOARD = "signal_source.onboard"
INTENT_FOLLOWUP_CONFIRM = "followup.confirm" INTENT_FOLLOWUP_CONFIRM = "followup.confirm"
@ -18,12 +19,14 @@ INTENT_FOLLOWUP_CONTINUE = "followup.continue"
INTENT_IGNORE = "ignore" INTENT_IGNORE = "ignore"
FOLLOWUP_TARGET_INTENTS = { FOLLOWUP_TARGET_INTENTS = {
INTENT_PROJECT_CREATE, INTENT_PROJECT_CREATE,
INTENT_TASK_CREATE,
INTENT_SIGNAL_CREATE, INTENT_SIGNAL_CREATE,
INTENT_SIGNAL_SOURCE_ONBOARD, INTENT_SIGNAL_SOURCE_ONBOARD,
} }
ROUTE_FAMILY_QUERY = "query" ROUTE_FAMILY_QUERY = "query"
ROUTE_FAMILY_PROJECT_CREATE = "project_create" ROUTE_FAMILY_PROJECT_CREATE = "project_create"
ROUTE_FAMILY_TASK = "task"
ROUTE_FAMILY_PROJECT_SIGNAL = "project_signal" ROUTE_FAMILY_PROJECT_SIGNAL = "project_signal"
ROUTE_FAMILY_SIGNAL_SOURCE = "signal_source" ROUTE_FAMILY_SIGNAL_SOURCE = "signal_source"
ROUTE_FAMILY_FOLLOWUP = "followup" ROUTE_FAMILY_FOLLOWUP = "followup"
@ -40,6 +43,7 @@ ROUTE_ACTION_CONFIRM = "confirm"
ROUTE_QUERY_ANSWER = f"{ROUTE_FAMILY_QUERY}.{ROUTE_ACTION_ANSWER}" ROUTE_QUERY_ANSWER = f"{ROUTE_FAMILY_QUERY}.{ROUTE_ACTION_ANSWER}"
ROUTE_PROJECT_CREATE = f"{ROUTE_FAMILY_PROJECT_CREATE}.{ROUTE_ACTION_CREATE}" 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_CREATE = f"{ROUTE_FAMILY_PROJECT_SIGNAL}.{ROUTE_ACTION_CREATE}"
ROUTE_PROJECT_SIGNAL_DISCOVER = f"{ROUTE_FAMILY_PROJECT_SIGNAL}.{ROUTE_ACTION_DISCOVER}" ROUTE_PROJECT_SIGNAL_DISCOVER = f"{ROUTE_FAMILY_PROJECT_SIGNAL}.{ROUTE_ACTION_DISCOVER}"
ROUTE_PROJECT_SIGNAL_COMMIT = f"{ROUTE_FAMILY_PROJECT_SIGNAL}.{ROUTE_ACTION_COMMIT}" 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 = { WORKFLOW_ROUTE_KEYS = {
ROUTE_QUERY_ANSWER, ROUTE_QUERY_ANSWER,
ROUTE_PROJECT_CREATE, ROUTE_PROJECT_CREATE,
ROUTE_TASK_CREATE,
ROUTE_PROJECT_SIGNAL_CREATE, ROUTE_PROJECT_SIGNAL_CREATE,
ROUTE_PROJECT_SIGNAL_DISCOVER, ROUTE_PROJECT_SIGNAL_DISCOVER,
ROUTE_PROJECT_SIGNAL_COMMIT, ROUTE_PROJECT_SIGNAL_COMMIT,
@ -65,6 +70,7 @@ WORKFLOW_ROUTE_KEYS = {
ROUTE_CANONICAL_INTENT_MAP: dict[str, str] = { ROUTE_CANONICAL_INTENT_MAP: dict[str, str] = {
ROUTE_QUERY_ANSWER: INTENT_QUERY, ROUTE_QUERY_ANSWER: INTENT_QUERY,
ROUTE_PROJECT_CREATE: INTENT_PROJECT_CREATE, ROUTE_PROJECT_CREATE: INTENT_PROJECT_CREATE,
ROUTE_TASK_CREATE: INTENT_TASK_CREATE,
ROUTE_PROJECT_SIGNAL_CREATE: INTENT_SIGNAL_CREATE, ROUTE_PROJECT_SIGNAL_CREATE: INTENT_SIGNAL_CREATE,
ROUTE_PROJECT_SIGNAL_DISCOVER: INTENT_SIGNAL_CREATE, ROUTE_PROJECT_SIGNAL_DISCOVER: INTENT_SIGNAL_CREATE,
ROUTE_PROJECT_SIGNAL_COMMIT: 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] = { LEGACY_INTENT_ROUTE_MAP: dict[str, str] = {
INTENT_QUERY: ROUTE_QUERY_ANSWER, INTENT_QUERY: ROUTE_QUERY_ANSWER,
INTENT_PROJECT_CREATE: ROUTE_PROJECT_CREATE, INTENT_PROJECT_CREATE: ROUTE_PROJECT_CREATE,
INTENT_TASK_CREATE: ROUTE_TASK_CREATE,
INTENT_SIGNAL_CREATE: ROUTE_PROJECT_SIGNAL_CREATE, INTENT_SIGNAL_CREATE: ROUTE_PROJECT_SIGNAL_CREATE,
INTENT_SIGNAL_SOURCE_ONBOARD: ROUTE_SIGNAL_SOURCE_ONBOARD, INTENT_SIGNAL_SOURCE_ONBOARD: ROUTE_SIGNAL_SOURCE_ONBOARD,
INTENT_FOLLOWUP_CONFIRM: ROUTE_FOLLOWUP_CONFIRM, INTENT_FOLLOWUP_CONFIRM: ROUTE_FOLLOWUP_CONFIRM,
INTENT_FOLLOWUP_CONTINUE: ROUTE_FOLLOWUP_CONTINUE, INTENT_FOLLOWUP_CONTINUE: ROUTE_FOLLOWUP_CONTINUE,
"query.answer": ROUTE_QUERY_ANSWER, "query.answer": ROUTE_QUERY_ANSWER,
"project_create.create": ROUTE_PROJECT_CREATE, "project_create.create": ROUTE_PROJECT_CREATE,
"task.create": ROUTE_TASK_CREATE,
"project_signal.create": ROUTE_PROJECT_SIGNAL_CREATE, "project_signal.create": ROUTE_PROJECT_SIGNAL_CREATE,
"project_signal.discover": ROUTE_PROJECT_SIGNAL_DISCOVER, "project_signal.discover": ROUTE_PROJECT_SIGNAL_DISCOVER,
"project_signal.commit": ROUTE_PROJECT_SIGNAL_COMMIT, "project_signal.commit": ROUTE_PROJECT_SIGNAL_COMMIT,
@ -154,6 +162,18 @@ FOLLOWUP_CONFIRM_HINTS = ("确认归属", "确认项目", "确认到项目", "
FOLLOWUP_CONTINUE_HINTS = ("继续处理", "继续执行", "接着处理", "往下做", "继续推进") FOLLOWUP_CONTINUE_HINTS = ("继续处理", "继续执行", "接着处理", "往下做", "继续推进")
FOLLOWUP_WORK_ITEM_ID_RE = re.compile(r"(?:work[_ -]?item|工作项|队列项)\s*(?:id|ID)?\s*[:#:]?\s*(\d+)", re.IGNORECASE) FOLLOWUP_WORK_ITEM_ID_RE = re.compile(r"(?:work[_ -]?item|工作项|队列项)\s*(?:id|ID)?\s*[:#:]?\s*(\d+)", re.IGNORECASE)
SIGNAL_CREATE_HINTS = ("新增一个信号", "新增一条信号", "增加一个信号", "录入一个信号", "关注", "跟踪") SIGNAL_CREATE_HINTS = ("新增一个信号", "新增一条信号", "增加一个信号", "录入一个信号", "关注", "跟踪")
TASK_CREATE_HINTS = (
"创建任务",
"新建任务",
"新增任务",
"增加任务",
"添加任务",
"加个任务",
"加一个任务",
"安排任务",
"分配任务",
"派个任务",
)
PROJECT_CREATE_HINTS = ( PROJECT_CREATE_HINTS = (
"创建项目", "创建项目",
"新建项目", "新建项目",
@ -193,6 +213,7 @@ SHORT_CONFIRM_HINTS = (
INTENT_LLM_FUNCTION_NAME = "classify_intent_ai_pmo_kernel" INTENT_LLM_FUNCTION_NAME = "classify_intent_ai_pmo_kernel"
INTENT_CANDIDATES = ( INTENT_CANDIDATES = (
INTENT_SIGNAL_SOURCE_ONBOARD, INTENT_SIGNAL_SOURCE_ONBOARD,
INTENT_TASK_CREATE,
INTENT_SIGNAL_CREATE, INTENT_SIGNAL_CREATE,
INTENT_QUERY, INTENT_QUERY,
INTENT_PROJECT_CREATE, INTENT_PROJECT_CREATE,
@ -202,10 +223,10 @@ INTENT_CANDIDATES = (
) )
WRITE_INTENTS = { WRITE_INTENTS = {
INTENT_PROJECT_CREATE, INTENT_PROJECT_CREATE,
INTENT_TASK_CREATE,
INTENT_SIGNAL_CREATE, INTENT_SIGNAL_CREATE,
INTENT_SIGNAL_SOURCE_ONBOARD, INTENT_SIGNAL_SOURCE_ONBOARD,
INTENT_FOLLOWUP_CONFIRM, INTENT_FOLLOWUP_CONFIRM,
INTENT_FOLLOWUP_CONTINUE, INTENT_FOLLOWUP_CONTINUE,
} }
INTENT_CONFLICT_POLICIES = {"pending_manual", "prefer_rule", "prefer_llm"} INTENT_CONFLICT_POLICIES = {"pending_manual", "prefer_rule", "prefer_llm"}

View File

@ -9,6 +9,7 @@ from ..ai_pmo_intent import (
INTENT_FOLLOWUP_CONTINUE, INTENT_FOLLOWUP_CONTINUE,
INTENT_PROJECT_CREATE, INTENT_PROJECT_CREATE,
INTENT_QUERY, INTENT_QUERY,
INTENT_TASK_CREATE,
INTENT_SIGNAL_CREATE, INTENT_SIGNAL_CREATE,
INTENT_SIGNAL_SOURCE_ONBOARD, INTENT_SIGNAL_SOURCE_ONBOARD,
ROUTE_PROJECT_SIGNAL_CANCEL, ROUTE_PROJECT_SIGNAL_CANCEL,
@ -151,6 +152,28 @@ class ReplyRenderer:
"payload": {"intent": intent.name, "project_id": project_id, "project_title": project_title}, "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}》已创建完成。", "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: if intent.name == INTENT_SIGNAL_SOURCE_ONBOARD:
signal_id = _safe_int(execution_result.get("signal_id")) 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 {} workflow = execution_result.get("signal_source_workflow") if isinstance(execution_result.get("signal_source_workflow"), dict) else {}

View File

@ -29,6 +29,7 @@ from .ai_pmo_domain_workflow import SignalSourceDomainWorkflow
from ..ai_pmo_intent import ( from ..ai_pmo_intent import (
INTENT_PROJECT_CREATE, INTENT_PROJECT_CREATE,
INTENT_QUERY, INTENT_QUERY,
INTENT_TASK_CREATE,
INTENT_SIGNAL_CREATE, INTENT_SIGNAL_CREATE,
INTENT_SIGNAL_SOURCE_ONBOARD, INTENT_SIGNAL_SOURCE_ONBOARD,
LEGACY_INTENT_ROUTE_MAP, LEGACY_INTENT_ROUTE_MAP,
@ -36,6 +37,7 @@ from ..ai_pmo_intent import (
ROUTE_ACTION_DISCOVER, ROUTE_ACTION_DISCOVER,
ROUTE_PROJECT_SIGNAL_CREATE, ROUTE_PROJECT_SIGNAL_CREATE,
ROUTE_PROJECT_SIGNAL_DISCOVER, ROUTE_PROJECT_SIGNAL_DISCOVER,
ROUTE_TASK_CREATE,
CanonicalIntent, CanonicalIntent,
IntentResolver, IntentResolver,
RouteResolver, RouteResolver,

View File

@ -14,6 +14,7 @@ from ..ai_pmo_intent import (
ROUTE_FAMILY_PROJECT_SIGNAL, ROUTE_FAMILY_PROJECT_SIGNAL,
ROUTE_FAMILY_QUERY, ROUTE_FAMILY_QUERY,
ROUTE_FAMILY_SIGNAL_SOURCE, ROUTE_FAMILY_SIGNAL_SOURCE,
ROUTE_FAMILY_TASK,
CanonicalIntent, CanonicalIntent,
WorkflowRoute, WorkflowRoute,
_safe_int, _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): class SignalSourceWorkflowHandler(WorkflowHandler):
async def start( async def start(
self, self,
@ -229,6 +255,7 @@ class WorkflowRegistry:
self._handlers: dict[str, WorkflowHandler] = { self._handlers: dict[str, WorkflowHandler] = {
ROUTE_FAMILY_QUERY: QueryWorkflowHandler(kernel), ROUTE_FAMILY_QUERY: QueryWorkflowHandler(kernel),
ROUTE_FAMILY_PROJECT_CREATE: ProjectCreateWorkflowHandler(kernel), ROUTE_FAMILY_PROJECT_CREATE: ProjectCreateWorkflowHandler(kernel),
ROUTE_FAMILY_TASK: TaskCreateWorkflowHandler(kernel),
ROUTE_FAMILY_PROJECT_SIGNAL: ProjectSignalWorkflowHandler(kernel), ROUTE_FAMILY_PROJECT_SIGNAL: ProjectSignalWorkflowHandler(kernel),
ROUTE_FAMILY_SIGNAL_SOURCE: SignalSourceWorkflowHandler(kernel), ROUTE_FAMILY_SIGNAL_SOURCE: SignalSourceWorkflowHandler(kernel),
ROUTE_FAMILY_FOLLOWUP: WorkflowHandler(kernel), ROUTE_FAMILY_FOLLOWUP: WorkflowHandler(kernel),

View File

@ -57,6 +57,7 @@ class KernelFollowupProjectSignalResumeMixin:
ROUTE_FAMILY_PROJECT_SIGNAL, ROUTE_FAMILY_PROJECT_SIGNAL,
ROUTE_FAMILY_SIGNAL_SOURCE, ROUTE_FAMILY_SIGNAL_SOURCE,
ROUTE_FAMILY_PROJECT_CREATE, ROUTE_FAMILY_PROJECT_CREATE,
ROUTE_FAMILY_TASK,
}: }:
reply_text = f"工作项#{int(target_work_item.id)} 当前类型为 {target_intent.name or 'unknown'},暂不支持继续处理。" reply_text = f"工作项#{int(target_work_item.id)} 当前类型为 {target_intent.name or 'unknown'},暂不支持继续处理。"
return { return {

View File

@ -10,6 +10,44 @@ for _name, _value in vars(_common).items():
class KernelFollowupRoutingMixin: 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( async def _project_waiting_manual(
self, self,
*, *,
@ -168,10 +206,15 @@ class KernelFollowupRoutingMixin:
"target_work_item_id": int(target_work_item.id), "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( resolved_target_route = WorkflowRoute(
family=target_family, 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), project_id=_safe_int(target_work_item.project_id),
requested_count=1, requested_count=1,
requires_confirmation=True, requires_confirmation=True,

View File

@ -36,6 +36,8 @@ class KernelFollowupVerificationMixin:
return {"verified": bool(str(execution_result.get("answer") or "").strip()), "issues": []} return {"verified": bool(str(execution_result.get("answer") or "").strip()), "issues": []}
if intent.name == INTENT_PROJECT_CREATE: if intent.name == INTENT_PROJECT_CREATE:
return {"verified": _safe_int(execution_result.get("project_id")) is not None, "issues": []} 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}: if intent.name in {INTENT_SIGNAL_CREATE, INTENT_SIGNAL_SOURCE_ONBOARD}:
signal_ids = _normalize_signal_id_list(execution_result.get("signal_ids")) signal_ids = _normalize_signal_id_list(execution_result.get("signal_ids"))
signal_id = _safe_int(execution_result.get("signal_id")) signal_id = _safe_int(execution_result.get("signal_id"))

View File

@ -57,6 +57,7 @@ class KernelIngressRenderMixin:
"signal_id": primary_signal_id, "signal_id": primary_signal_id,
"signal_ids": signal_ids, "signal_ids": signal_ids,
"created_project_id": _safe_int(execution_result.get("project_id")) if intent.name == INTENT_PROJECT_CREATE else None, "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", []), "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)}", "ai_trace_id": f"ai-pmo-{int(work_item.id)}",
"results": [ "results": [
@ -64,7 +65,11 @@ class KernelIngressRenderMixin:
"work_item_id": int(work_item.id), "work_item_id": int(work_item.id),
"state": work_item.state, "state": work_item.state,
"intent": intent.name, "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, "intent": intent.name,
@ -81,6 +86,12 @@ class KernelIngressRenderMixin:
else ((intent.metadata or {}).get("decision_trace") if isinstance(intent.metadata, dict) else None) 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): if intent.name == INTENT_QUERY and isinstance(execution_result.get("rag_result"), dict):
result["rag_result"] = execution_result["rag_result"] result["rag_result"] = execution_result["rag_result"]
if intent.name == INTENT_PROJECT_CREATE: if intent.name == INTENT_PROJECT_CREATE:
@ -99,6 +110,8 @@ class KernelIngressRenderMixin:
return "query.answer" return "query.answer"
if normalized_route == ROUTE_PROJECT_CREATE: if normalized_route == ROUTE_PROJECT_CREATE:
return "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 in {ROUTE_PROJECT_SIGNAL_CREATE, ROUTE_PROJECT_SIGNAL_DISCOVER, ROUTE_PROJECT_SIGNAL_COMMIT}:
if normalized_route == ROUTE_PROJECT_SIGNAL_DISCOVER: if normalized_route == ROUTE_PROJECT_SIGNAL_DISCOVER:
return ROUTE_PROJECT_SIGNAL_DISCOVER return ROUTE_PROJECT_SIGNAL_DISCOVER
@ -117,6 +130,8 @@ class KernelIngressRenderMixin:
return "query.answer" return "query.answer"
if intent_name == INTENT_PROJECT_CREATE: if intent_name == INTENT_PROJECT_CREATE:
return "project.create" return "project.create"
if intent_name == INTENT_TASK_CREATE:
return "task.create"
if intent_name == INTENT_SIGNAL_CREATE: if intent_name == INTENT_SIGNAL_CREATE:
return "signal.enter" return "signal.enter"
if intent_name == INTENT_SIGNAL_SOURCE_ONBOARD: if intent_name == INTENT_SIGNAL_SOURCE_ONBOARD:
@ -129,7 +144,7 @@ class KernelIngressRenderMixin:
def _risk_level_for_intent(intent_name: str) -> str: def _risk_level_for_intent(intent_name: str) -> str:
if intent_name in {INTENT_PROJECT_CREATE, INTENT_SIGNAL_SOURCE_ONBOARD}: if intent_name in {INTENT_PROJECT_CREATE, INTENT_SIGNAL_SOURCE_ONBOARD}:
return "high" 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 "medium"
return "low" return "low"

View File

@ -10,6 +10,254 @@ for _name, _value in vars(_common).items():
class KernelIngressStorageMixin: 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( async def _claim_ingress_event(
self, self,
*, *,
@ -204,4 +452,3 @@ class KernelIngressStorageMixin:
) )
) )
await self.db.flush() await self.db.flush()

View File

@ -30,6 +30,7 @@ class KernelIntentLLMMixin:
[ [
ROUTE_QUERY_ANSWER, ROUTE_QUERY_ANSWER,
ROUTE_PROJECT_CREATE, ROUTE_PROJECT_CREATE,
ROUTE_TASK_CREATE,
ROUTE_PROJECT_SIGNAL_CREATE, ROUTE_PROJECT_SIGNAL_CREATE,
ROUTE_PROJECT_SIGNAL_DISCOVER, ROUTE_PROJECT_SIGNAL_DISCOVER,
ROUTE_SIGNAL_SOURCE_ONBOARD, ROUTE_SIGNAL_SOURCE_ONBOARD,

View File

@ -16,15 +16,20 @@ class KernelIntentResolveMixin:
message_text: str, message_text: str,
session: Optional[ConversationSession], session: Optional[ConversationSession],
world_model: dict[str, Any], world_model: dict[str, Any],
message_sent_at: Optional[Any] = None,
session_snapshot: Optional[dict[str, Any]] = None,
) -> tuple[CanonicalIntent, str]: ) -> tuple[CanonicalIntent, str]:
if self._is_short_confirmation_message( if self._is_short_confirmation_message(
message_text=message_text, message_text=message_text,
session=session, session=session,
message_sent_at=message_sent_at,
session_snapshot=session_snapshot,
) and session is not None: ) and session is not None:
inherited = self._build_short_confirm_intent( inherited = self._build_short_confirm_intent(
message_text=message_text, message_text=message_text,
session=session, session=session,
world_model=world_model, world_model=world_model,
session_snapshot=session_snapshot,
) )
logger.info( logger.info(
"ai_pmo_short_confirm_inherited", "ai_pmo_short_confirm_inherited",

View File

@ -169,8 +169,7 @@ class KernelIntentRulesMixin:
@staticmethod @staticmethod
def _has_required_slots(*, intent: CanonicalIntent, message_text: str) -> tuple[bool, dict[str, Any]]: def _has_required_slots(*, intent: CanonicalIntent, message_text: str) -> tuple[bool, dict[str, Any]]:
if intent.name != INTENT_PROJECT_CREATE: if intent.name == INTENT_PROJECT_CREATE:
return True, {}
extracted_title = AIStewardService._extract_project_title_from_instruction(message_text) extracted_title = AIStewardService._extract_project_title_from_instruction(message_text)
has_title = bool(str(extracted_title or "").strip()) has_title = bool(str(extracted_title or "").strip())
return has_title, { return has_title, {
@ -178,6 +177,22 @@ class KernelIntentRulesMixin:
"slot_ok": has_title, "slot_ok": has_title,
"extracted_project_title": extracted_title[:50] if extracted_title else "", "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: 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) slot_ok, slot_meta = self._has_required_slots(intent=intent, message_text=message_text)
@ -259,15 +274,22 @@ class KernelIntentRulesMixin:
*, *,
message_text: str, message_text: str,
session: Optional[ConversationSession], session: Optional[ConversationSession],
message_sent_at: Optional[Any] = None,
session_snapshot: Optional[dict[str, Any]] = None,
) -> bool: ) -> bool:
if session is None: snapshot = session_snapshot if isinstance(session_snapshot, dict) else {}
if session is None and not snapshot:
return False return False
if not cls._within_recent_window( last_active_at = cls._coerce_naive_datetime(snapshot.get("last_active_at"))
getattr(session, "last_active_at", None), if last_active_at is None and session is not None:
window_seconds=SESSION_SHORT_CONFIRM_WINDOW_SECONDS, 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 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: if not last_intent:
return False return False
normalized = re.sub(r"\s+", "", str(message_text or "")).lower() normalized = re.sub(r"\s+", "", str(message_text or "")).lower()
@ -277,6 +299,23 @@ class KernelIntentRulesMixin:
return False return False
return any(token in normalized for token in SHORT_CONFIRM_HINTS) 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 @staticmethod
def _build_canonical_intent( def _build_canonical_intent(
*, *,
@ -317,6 +356,19 @@ class KernelIntentRulesMixin:
pretty[:300], pretty[:300],
extras, 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: if intent_name == INTENT_QUERY:
return CanonicalIntent(INTENT_QUERY, safe_confidence, project_id, "", pretty[:300], extras) return CanonicalIntent(INTENT_QUERY, safe_confidence, project_id, "", pretty[:300], extras)
if intent_name == INTENT_FOLLOWUP_CONFIRM: if intent_name == INTENT_FOLLOWUP_CONFIRM:
@ -331,14 +383,25 @@ class KernelIntentRulesMixin:
message_text: str, message_text: str,
session: ConversationSession, session: ConversationSession,
world_model: dict[str, Any], world_model: dict[str, Any],
session_snapshot: Optional[dict[str, Any]] = None,
) -> CanonicalIntent: ) -> 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: if inherited_name not in INTENT_CANDIDATES:
inherited_name = INTENT_QUERY 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: if inherited_project_id is not None and int(inherited_project_id) <= 0:
inherited_project_id = None 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( return self._build_canonical_intent(
intent_name=inherited_name, intent_name=inherited_name,
message_text=inherited_text, message_text=inherited_text,
@ -346,6 +409,6 @@ class KernelIntentRulesMixin:
confidence=0.86, confidence=0.86,
metadata={ metadata={
"context_source": "session_inherited", "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(),
}, },
) )

View File

@ -10,9 +10,11 @@ STATE_DECIDED = "decided"
STATE_PLANNED = "planned" STATE_PLANNED = "planned"
STATE_EXECUTING = "executing" STATE_EXECUTING = "executing"
STATE_WAITING_MANUAL = "waiting_manual" STATE_WAITING_MANUAL = "waiting_manual"
STATE_DRAFT_PENDING_CONFIRM = "draft_pending_confirm"
STATE_SUCCEEDED = "succeeded" STATE_SUCCEEDED = "succeeded"
STATE_FAILED = "failed" STATE_FAILED = "failed"
STATE_IGNORED = "ignored" STATE_IGNORED = "ignored"
STATE_CANCELLED = "cancelled"
RECEIPT_PHASE_ACTION = "action_applied" RECEIPT_PHASE_ACTION = "action_applied"
RECEIPT_PHASE_PENDING = "pending_manual" RECEIPT_PHASE_PENDING = "pending_manual"

View File

@ -83,6 +83,7 @@ from ..ai_pmo_intent import (
INTENT_LLM_FUNCTION_NAME, INTENT_LLM_FUNCTION_NAME,
INTENT_PROJECT_CREATE, INTENT_PROJECT_CREATE,
INTENT_QUERY, INTENT_QUERY,
INTENT_TASK_CREATE,
INTENT_SIGNAL_CREATE, INTENT_SIGNAL_CREATE,
INTENT_SIGNAL_SOURCE_ONBOARD, INTENT_SIGNAL_SOURCE_ONBOARD,
LEGACY_INTENT_ROUTE_MAP, LEGACY_INTENT_ROUTE_MAP,
@ -100,6 +101,7 @@ from ..ai_pmo_intent import (
ROUTE_FAMILY_PROJECT_SIGNAL, ROUTE_FAMILY_PROJECT_SIGNAL,
ROUTE_FAMILY_QUERY, ROUTE_FAMILY_QUERY,
ROUTE_FAMILY_SIGNAL_SOURCE, ROUTE_FAMILY_SIGNAL_SOURCE,
ROUTE_FAMILY_TASK,
ROUTE_FOLLOWUP_CONFIRM, ROUTE_FOLLOWUP_CONFIRM,
ROUTE_FOLLOWUP_CONTINUE, ROUTE_FOLLOWUP_CONTINUE,
ROUTE_PROJECT_CREATE, ROUTE_PROJECT_CREATE,
@ -110,6 +112,7 @@ from ..ai_pmo_intent import (
ROUTE_PROJECT_SIGNAL_REFRESH, ROUTE_PROJECT_SIGNAL_REFRESH,
ROUTE_QUERY_ANSWER, ROUTE_QUERY_ANSWER,
ROUTE_SIGNAL_SOURCE_ONBOARD, ROUTE_SIGNAL_SOURCE_ONBOARD,
ROUTE_TASK_CREATE,
SESSION_SHORT_CONFIRM_WINDOW_SECONDS, SESSION_SHORT_CONFIRM_WINDOW_SECONDS,
SHORT_CONFIRM_HINTS, SHORT_CONFIRM_HINTS,
SHORT_CONFIRM_MAX_MESSAGE_LENGTH, SHORT_CONFIRM_MAX_MESSAGE_LENGTH,

View File

@ -1,6 +1,7 @@
from __future__ import annotations from __future__ import annotations
from .shared import * from .shared import *
from ..ai_pmo_kernel_common_core.helpers import _safe_int as _safe_int
class KernelIngressFlowAutoMixin: class KernelIngressFlowAutoMixin:
async def _execute_auto_ingress_flow( async def _execute_auto_ingress_flow(
@ -29,6 +30,153 @@ class KernelIngressFlowAutoMixin:
work_item.state = STATE_PLANNED work_item.state = STATE_PLANNED
work_item.action_bundle = decision_payload["execution_plan"] work_item.action_bundle = decision_payload["execution_plan"]
work_item.state = STATE_EXECUTING 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}: if intent.name in {INTENT_FOLLOWUP_CONFIRM, INTENT_FOLLOWUP_CONTINUE}:
stage_started_at = time.perf_counter() stage_started_at = time.perf_counter()
execution_result, verification = await self._execute_followup_intent( 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() resolved_route = str(execution_result.get("resolved_route") or "").strip().lower()
if resolved_route: if resolved_route:
work_item.intent = self.route_resolver.normalize_route_key(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 work_item.execution_result = execution_result
if _safe_int(execution_result.get("project_id")) is not None: if _safe_int(execution_result.get("project_id")) is not None:
work_item.project_id = _safe_int(execution_result.get("project_id")) work_item.project_id = _safe_int(execution_result.get("project_id"))
@ -97,6 +254,37 @@ class KernelIngressFlowAutoMixin:
else: else:
work_item.state = STATE_FAILED work_item.state = STATE_FAILED
decision_row.final_outcome = work_item.state 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( render = self.reply_renderer.render(
work_state=work_item.state, work_state=work_item.state,
@ -133,7 +321,7 @@ class KernelIngressFlowAutoMixin:
work_item=work_item, work_item=work_item,
message_text=message_text, message_text=message_text,
route=f"ai_pmo.{work_item.intent or intent.name}", 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, operation_result=execution_result,
processing_status="success" if work_item.state in {STATE_SUCCEEDED, STATE_WAITING_MANUAL} else "failed", processing_status="success" if work_item.state in {STATE_SUCCEEDED, STATE_WAITING_MANUAL} else "failed",
duration_ms=int((time.perf_counter() - started) * 1000), duration_ms=int((time.perf_counter() - started) * 1000),
@ -206,6 +394,14 @@ class KernelIngressFlowAutoMixin:
log_perf_fn("_build_capture_result", stage_started_at) log_perf_fn("_build_capture_result", stage_started_at)
return capture_result 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 @staticmethod
def _build_capacity_warning_text( def _build_capacity_warning_text(
*, *,

View File

@ -96,6 +96,8 @@ class KernelIngressFlowEntryMixin:
source_sender_open_id: Optional[str] = None, source_sender_open_id: Optional[str] = None,
captured_at: Optional[datetime] = None, captured_at: Optional[datetime] = None,
chat_type: Optional[str] = None, chat_type: Optional[str] = None,
message_sent_at: Optional[Any] = None,
session_snapshot: Optional[dict[str, Any]] = None,
) -> dict[str, Any]: ) -> dict[str, Any]:
return await self.process_ingress( return await self.process_ingress(
source_system="feishu", source_system="feishu",
@ -106,6 +108,8 @@ class KernelIngressFlowEntryMixin:
source_sender_open_id=source_sender_open_id, source_sender_open_id=source_sender_open_id,
captured_at=captured_at, captured_at=captured_at,
chat_type=chat_type, chat_type=chat_type,
message_sent_at=message_sent_at,
session_snapshot=session_snapshot,
) )
async def process_ingress( async def process_ingress(
@ -120,6 +124,9 @@ class KernelIngressFlowEntryMixin:
captured_at: Optional[datetime] = None, captured_at: Optional[datetime] = None,
chat_type: Optional[str] = None, chat_type: Optional[str] = None,
project_hint_id: Optional[int] = 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]: ) -> dict[str, Any]:
started = time.perf_counter() started = time.perf_counter()
perf_work_item_id: Optional[int] = None perf_work_item_id: Optional[int] = None
@ -153,10 +160,27 @@ class KernelIngressFlowEntryMixin:
operator = ingress_prepare["operator"] operator = ingress_prepare["operator"]
work_item = ingress_prepare["work_item"] work_item = ingress_prepare["work_item"]
session = ingress_prepare["session"] 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] = {} mixed_meta: dict[str, Any] = {}
if not self._is_short_confirmation_message( if not self._is_short_confirmation_message(
message_text=clean_text, message_text=clean_text,
session=session, session=session,
message_sent_at=message_sent_at,
session_snapshot=session_snapshot,
): ):
clean_text, mixed_meta = await self._prepare_mixed_sidecar_context( clean_text, mixed_meta = await self._prepare_mixed_sidecar_context(
source_system=source_system, source_system=source_system,
@ -182,6 +206,8 @@ class KernelIngressFlowEntryMixin:
message_text=clean_text, message_text=clean_text,
session=session, session=session,
world_model=world_model, world_model=world_model,
message_sent_at=message_sent_at,
session_snapshot=session_snapshot,
) )
intent = self._enrich_signal_source_intent_from_closure( intent = self._enrich_signal_source_intent_from_closure(
intent=intent, intent=intent,
@ -193,6 +219,16 @@ class KernelIngressFlowEntryMixin:
**(intent.metadata or {}), **(intent.metadata or {}),
**mixed_meta, **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( workflow_route = self.route_resolver.from_canonical(
canonical_intent=intent, canonical_intent=intent,
message_text=clean_text, message_text=clean_text,
@ -365,6 +401,153 @@ class KernelIngressFlowEntryMixin:
) )
return {"handled": False, "reason": "kernel_exception", "error": str(exc)} 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( async def _persist_kernel_failure_with_fresh_session(
self, self,
*, *,

View File

@ -184,6 +184,15 @@ class KernelIngressFlowPrepareManualMixin:
"workflow_action": workflow_route.action, "workflow_action": workflow_route.action,
"decision_trace": decision_trace, "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"] manual_issues = ["waiting_manual"]
policy_gate_code = str(policy.get("policy_gate_code") or "").strip() policy_gate_code = str(policy.get("policy_gate_code") or "").strip()
if policy_gate_code: if policy_gate_code:
@ -213,6 +222,32 @@ class KernelIngressFlowPrepareManualMixin:
project_id=_safe_int(work_item.project_id), project_id=_safe_int(work_item.project_id),
review_summary=work_item.review_summary, 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( direct_reply_required = await self._claim_reply_receipt(
work_item_id=int(work_item.id), work_item_id=int(work_item.id),
source_system=source_system, source_system=source_system,
@ -228,7 +263,7 @@ class KernelIngressFlowPrepareManualMixin:
work_item=work_item, work_item=work_item,
message_text=message_text, message_text=message_text,
route="ai_pmo.waiting_manual", route="ai_pmo.waiting_manual",
operation_code=None, operation_code=operation_code_for_log,
operation_result=work_item.execution_result, operation_result=work_item.execution_result,
processing_status="success", processing_status="success",
duration_ms=int((time.perf_counter() - started) * 1000), duration_ms=int((time.perf_counter() - started) * 1000),

View File

@ -3,11 +3,15 @@
from __future__ import annotations from __future__ import annotations
from .shared import * # noqa: F401, F403 from .shared import * # noqa: F401, F403
from .shared import _safe_int
from ...adaptive_rag_service import AdaptiveRAGService 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: class ExecuteQueryProjectMixin:
"""Handle query and project-create intents.""" """Handle query/project/task create intents."""
async def _execute_query_intent( async def _execute_query_intent(
self, self,
@ -19,17 +23,58 @@ class ExecuteQueryProjectMixin:
source_message_id: str, source_message_id: str,
) -> dict[str, Any]: ) -> dict[str, Any]:
rag_service = AdaptiveRAGService(self.db, tenant_id=self.tenant_id) rag_service = AdaptiveRAGService(self.db, tenant_id=self.tenant_id)
try: metadata = intent.metadata if isinstance(intent.metadata, dict) else {}
rag_result = await rag_service.answer_question( rag_request_context = (
question=message_text, metadata.get("rag_request_context")
asked_by=operator_user_id, if isinstance(metadata.get("rag_request_context"), dict)
asked_via=source_system, else None
project_id=intent.project_id,
source_chat_id=source_message_id,
conversation_key=source_message_id,
action_mode="read",
sync_requirement=False,
) )
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(**rag_kwargs)
except Exception as exc: except Exception as exc:
return { return {
"status": "failed_safe", "status": "failed_safe",
@ -71,3 +116,143 @@ class ExecuteQueryProjectMixin:
"tasks": payload.get("tasks") if isinstance(payload.get("tasks"), list) else [], "tasks": payload.get("tasks") if isinstance(payload.get("tasks"), list) else [],
"raw_payload": payload, "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),
}

View File

@ -27,9 +27,10 @@ class TypedExecutorExecuteMixin(
channel_ref: Optional[str] = None, channel_ref: Optional[str] = None,
) -> dict[str, Any]: ) -> dict[str, Any]:
if intent.name == INTENT_QUERY: if intent.name == INTENT_QUERY:
query_text = str(getattr(intent, "description", "") or "").strip() or message_text
return await self._execute_query_intent( return await self._execute_query_intent(
intent=intent, intent=intent,
message_text=message_text, message_text=query_text,
operator_user_id=operator_user_id, operator_user_id=operator_user_id,
source_system=source_system, source_system=source_system,
source_message_id=source_message_id, source_message_id=source_message_id,
@ -41,6 +42,13 @@ class TypedExecutorExecuteMixin(
operator_user_id=operator_user_id, 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: if intent.name == INTENT_SIGNAL_SOURCE_ONBOARD:
return await self._execute_signal_source_onboard_intent( return await self._execute_signal_source_onboard_intent(
intent=intent, intent=intent,

View File

@ -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),
},
)

View File

@ -168,9 +168,12 @@ class AIServiceBusinessAnalysisMixin:
return self._parse_json(text) return self._parse_json(text)
async def stream_chat(self, prompt: str) -> AsyncGenerator[str, None]: async def stream_chat(self, prompt: str) -> AsyncGenerator[str, None]:
"""流式生成""" """兼容流式接口:统一走 llm_service,并以单块返回。"""
llm = self._get_llm() text = await self._invoke_with_retry(
async for chunk in llm.astream([HumanMessage(content=prompt)]): prompt,
if hasattr(chunk, "content") and chunk.content: function_name="ai_service_stream_chat",
yield chunk.content use_cache=False,
)
normalized = str(text or "").strip()
if normalized:
yield normalized

View File

@ -86,18 +86,7 @@ class AIServiceRuntimeCoreMixin:
} }
def _get_llm(self) -> BaseChatModel: def _get_llm(self) -> BaseChatModel:
if not LANGCHAIN_AVAILABLE: raise RuntimeError("AIService 已统一收敛到 llm_service;请使用 _invoke_with_retry。")
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
async def _get_redis(self): async def _get_redis(self):
if not REDIS_AVAILABLE or not self.redis_url: if not REDIS_AVAILABLE or not self.redis_url:
@ -132,4 +121,3 @@ class AIServiceRuntimeCoreMixin:
if text.endswith("```"): if text.endswith("```"):
text = text[:-3] text = text[:-3]
return json.loads(text.strip()) return json.loads(text.strip())

View File

@ -125,7 +125,9 @@ class AIServiceRuntimePromptRetryMixin:
cache_key: Optional[str] = None, cache_key: Optional[str] = None,
project_id: Optional[int] = None, project_id: Optional[int] = None,
) -> str: ) -> str:
"""带缓存、重试、日志的 LLM 调用""" """带缓存、重试、预算治理的 LLM 调用(统一走 llm_service)。"""
from ..llm_service import llm_service
input_hash = cache_key or _hash_input(function_name, prompt) input_hash = cache_key or _hash_input(function_name, prompt)
if use_cache and db is None: if use_cache and db is None:
@ -158,35 +160,27 @@ class AIServiceRuntimePromptRetryMixin:
# 预算系统异常不阻断主流程,避免影响现有可用性 # 预算系统异常不阻断主流程,避免影响现有可用性
pass pass
llm = self._get_llm()
_, system_prompt = await self._resolve_active_system_prompt(
tenant_id=tenant_id,
db=db,
)
last_err = None last_err = None
for attempt in range(self.max_retries): for attempt in range(self.max_retries):
try: try:
t0 = time.perf_counter() text = await llm_service.generate(
messages = [HumanMessage(content=prompt)] prompt,
if system_prompt: db=db,
messages = [SystemMessage(content=system_prompt), *messages] tenant_id=_tenant_to_int(tenant_id),
resp = await llm.ainvoke(messages) function_name=function_name,
latency_ms = int((time.perf_counter() - t0) * 1000) use_prompt_profile=True,
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)
if db:
await self._log_call(
db, tenant_id, function_name, input_hash, "success",
prompt_tokens, completion_tokens, latency_ms,
) )
# 按实际 token 成本累计预算 text = str(text or "")
if not text.strip():
raise RuntimeError("llm_empty_response")
# 预算使用累计(按文本长度估算,不阻断主链路)
if db is not None:
try: try:
from ..budget_enforcement import BudgetEnforcementService 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) actual_cost = _estimate_cost(prompt_tokens, completion_tokens)
await BudgetEnforcementService(db).update_budget_usage_after_ai_call( await BudgetEnforcementService(db).update_budget_usage_after_ai_call(
tenant_id=_tenant_to_int(tenant_id), tenant_id=_tenant_to_int(tenant_id),
@ -200,13 +194,10 @@ class AIServiceRuntimePromptRetryMixin:
return text return text
except Exception as e: except Exception as e:
last_err = e last_err = e
wait = 2 ** attempt if attempt < self.max_retries - 1:
time.sleep(wait) wait_seconds = 2 ** attempt
await asyncio.sleep(wait_seconds)
if db: if last_err is not None:
await self._log_call(
db, tenant_id, function_name, input_hash, "failed",
error_message=str(last_err),
)
raise last_err raise last_err
raise RuntimeError("llm_invoke_failed")

View File

@ -13,6 +13,7 @@ import hashlib
import json import json
import os import os
import time import time
import asyncio
from datetime import datetime from datetime import datetime
from typing import Any, AsyncGenerator, Optional, Union from typing import Any, AsyncGenerator, Optional, Union
from sqlalchemy import and_, select from sqlalchemy import and_, select
@ -71,3 +72,5 @@ def _tenant_to_int(tenant_id: Union[str, int]) -> int:
return 1 return 1
__all__ = [name for name in globals() if not name.startswith("__")]

View File

@ -87,7 +87,7 @@ class ContextCacheProfileBasicsMixin:
"summary": "AI 任务协同与治理中心,包含简报、冲突检测、问答历史。", "summary": "AI 任务协同与治理中心,包含简报、冲突检测、问答历史。",
"page_component": "AIStewardPage", "page_component": "AIStewardPage",
"page_file": "frontend/src/pages/AIStewardPage.tsx", "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", "智能问答"], "aliases": ["ai steward", "ai管家", "问答", "rag", "智能问答"],
}, },
{ {
@ -132,4 +132,3 @@ class ContextCacheProfileBasicsMixin:
"aliases": ["users", "成员管理", "用户", "人员管理"], "aliases": ["users", "成员管理", "用户", "人员管理"],
}, },
] ]

View File

@ -62,7 +62,7 @@ class ContextCacheProfileRouteParserMixin:
), ),
"/ai-steward": ( "/ai-steward": (
"AI 任务协同与治理中心,包含简报、冲突检测、问答历史。", "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 steward", "ai管家", "问答", "rag", "智能问答"],
), ),
"/ai-config": ( "/ai-config": (
@ -251,4 +251,3 @@ class ContextCacheProfileRouteParserMixin:
"scanned_at": now_cn_aware().isoformat(), "scanned_at": now_cn_aware().isoformat(),
}, },
} }

View File

@ -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"<at id={safe_open_id}></at>{(' ' + 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}

View File

@ -135,6 +135,7 @@ class FeishuConfigMixin:
支持的事件类型: 支持的事件类型:
- message.receive_v1: 收到群聊消息 - message.receive_v1: 收到群聊消息
- im.message.receive_v1: 收到群聊消息(schema 2.0) - im.message.receive_v1: 收到群聊消息(schema 2.0)
- card.action.trigger: 卡片按钮回调
""" """
try: try:
event_type = (event_data.get('type') or '').strip() event_type = (event_data.get('type') or '').strip()
@ -142,6 +143,8 @@ class FeishuConfigMixin:
# 飞书 schema 2.0 会返回 im.message.receive_v1 # 飞书 schema 2.0 会返回 im.message.receive_v1
if event_type in {'message.receive_v1', 'im.message.receive_v1'}: if event_type in {'message.receive_v1', 'im.message.receive_v1'}:
return await self._handle_message_receive(event_data) 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 { return {
'code': 0, 'code': 0,

View File

@ -2,216 +2,96 @@ from __future__ import annotations
from .shared import * from .shared import *
class FeishuReceiveMixin: class FeishuReceiveMixin:
async def _handle_message_receive( async def _handle_message_receive(
self, self,
event: Dict[str, Any] event: Dict[str, Any]
) -> Dict[str, Any]: ) -> Dict[str, Any]:
"""处理收到的消息事件""" """处理收到的消息事件(编排层)。"""
state_trace_id = f"feishu-sm-{uuid4().hex[:12]}" state_trace_id = f"feishu-sm-{uuid4().hex[:12]}"
ingress_source = str(event.get("_ingress_source") or "feishu").strip() or "feishu" context = self._build_receive_context(event=event, state_trace_id=state_trace_id)
message = event.get('message', {}) if isinstance(event.get('message', {}), dict) else {} message = context["message"]
chat = event.get('chat', {}) if isinstance(event.get('chat', {}), dict) else {} sender = context["sender"]
sender = event.get('sender', {}) if isinstance(event.get('sender', {}), dict) else {} message_id = str(context["message_id"])
message_id = (message.get("message_id") or "").strip() or "unknown" sender_type = str(context["sender_type"])
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()
if sender_type in {"app", "bot"}: if sender_type in {"app", "bot"}:
return {'code': 0, 'msg': 'Ignore bot/app message'} 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 []
)
await self._record_ingress_trace( await self._record_ingress_trace(
ingress_source=ingress_source, ingress_source=str(context["ingress_source"]),
trace_id=state_trace_id, trace_id=state_trace_id,
message_id=message_id, message_id=message_id,
event=event, 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: if not message_content:
print( await self._reply_for_empty_text_ingress(
f"[Feishu] message.receive_v1 ignored: no text content, " state_trace_id=state_trace_id,
f"message_type={message.get('message_type')}, keys={list(message.keys())}" 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 '' return {"code": 0, "msg": "No text content"}
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'}
# 获取发送者信息
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_name = await self._resolve_sender_display_name(
sender=sender, sender=sender,
sender_id=sender_id, sender_id=(
context.get("sender_id")
if isinstance(context.get("sender_id"), dict)
else {}
),
) )
chat_id = str(context.get("chat_id") or "")
# 获取群聊信息 sender_open_id = context.get("sender_open_id")
chat_id = chat.get('chat_id') or message.get('chat_id') or '' message_attachments = self._normalize_message_attachments_from_context(context=context)
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)
try: try:
result = await capture_service.capture_from_feishu( result = await self._capture_and_persist_from_ingress(
group_chat_id=chat_id, chat_id=chat_id,
message_content=message_content, message_content=message_content,
sender=sender_name, sender_name=sender_name,
tenant_id=self.tenant_id, sender_open_id=sender_open_id,
proposer_open_id=sender_open_id, captured_at=context.get("captured_at"),
captured_at=captured_at, mentioned_bot_names=(
mentioned_bot_names=mentioned_bot_names, context.get("mentioned_bot_names")
feishu_message_id=(message.get("message_id") or "").strip() or None, if isinstance(context.get("mentioned_bot_names"), list)
chat_type=chat_type, else []
) ),
await self._persist_signal_attachments_from_ingress( message_id=(message.get("message_id") or "").strip() or None,
signal_id=result.get("signal_id"), chat_type=str(context.get("chat_type") or "group"),
attachments=message_attachments, message_attachments=message_attachments,
) )
except Exception as exc: except Exception as exc:
self._emit_state_transition( return await self._handle_capture_exception_from_ingress(
trace_id=state_trace_id, state_trace_id=state_trace_id,
state="ERROR_REPLY", error=exc,
reason="capture_exception",
error=str(exc),
)
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,
result=result,
message_content=message_content,
chat_id=chat_id, chat_id=chat_id,
sender_open_id=sender_open_id, sender_open_id=sender_open_id,
sender_name=sender_name, sender_name=sender_name,
mentioned_bot_names=mentioned_bot_names,
trace_id=state_trace_id,
message_id=message_id, message_id=message_id,
perf_t0=perf_t0, perf_t0=context.get("perf_t0"),
) )
return await self._finalize_receive_result(
if reply_route != "none": result=result,
self._emit_state_transition( state_trace_id=state_trace_id,
trace_id=state_trace_id,
state="REPLIED",
route=reply_route,
)
await self._record_message_trace(
trace_id=state_trace_id,
message_id=message_id, message_id=message_id,
chat_type=chat_type,
message_content=message_content, message_content=message_content,
capture_result=result, chat_id=chat_id,
reply_source=reply_route, chat_type=str(context.get("chat_type") or "group"),
sender_open_id=sender_open_id,
sender_name=sender_name,
mentioned_bot_names=(
context.get("mentioned_bot_names")
if isinstance(context.get("mentioned_bot_names"), list)
else []
),
perf_t0=context.get("perf_t0"),
) )
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,
},
}

View File

@ -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,
},
}

View File

@ -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,
)

View File

@ -55,6 +55,20 @@ class FeishuReceiveRoutingMixin:
) )
if not deterministic_reply and str(result.get("status") or "").lower() == "error": if not deterministic_reply and str(result.get("status") or "").lower() == "error":
deterministic_reply = self._build_error_reply_text() 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: if chat_id and deterministic_reply:
await self._safe_reply_text( await self._safe_reply_text(
chat_id=chat_id, chat_id=chat_id,
@ -156,4 +170,3 @@ class FeishuReceiveRoutingMixin:
safe_state = str(state or "unknown") safe_state = str(state or "unknown")
details = ", ".join([f"{k}={v}" for k, v in payload.items()]) if payload else "" 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 ''}") print(f"[Feishu-SM][{safe_trace}] state={safe_state}{', ' + details if details else ''}")

View File

@ -3,6 +3,29 @@ from __future__ import annotations
from .shared import * from .shared import *
class FeishuReplyOutboxMixin: 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( async def _safe_reply_text(
self, self,
*, *,
@ -47,6 +70,42 @@ class FeishuReplyOutboxMixin:
# 统一由 drain_loop 消费 outbox,禁止业务路径直发,避免双发送。 # 统一由 drain_loop 消费 outbox,禁止业务路径直发,避免双发送。
return 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( async def _enqueue_reply_outbox(
self, self,
*, *,
@ -57,6 +116,9 @@ class FeishuReplyOutboxMixin:
) -> int: ) -> int:
async with AsyncSessionLocal() as db: async with AsyncSessionLocal() as db:
normalized_source_message_id = (source_message_id or "")[:128] or None normalized_source_message_id = (source_message_id or "")[:128] or None
if self._is_card_outbox_message(message_text):
normalized_message_text = (message_text or "")[:12000]
else:
normalized_message_text = (message_text or "")[:2000] or "已收到,正在处理。" normalized_message_text = (message_text or "")[:2000] or "已收到,正在处理。"
reply_hash = self._build_reply_hash( reply_hash = self._build_reply_hash(
source_message_id=normalized_source_message_id, source_message_id=normalized_source_message_id,
@ -229,9 +291,19 @@ class FeishuReplyOutboxMixin:
async with AsyncSessionLocal() as db_send: async with AsyncSessionLocal() as db_send:
service = cls(db_send) service = cls(db_send)
await service.load_config(tenant_id=int(claimed.get("tenant_id") or 1)) await service.load_config(tenant_id=int(claimed.get("tenant_id") or 1))
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( await service.send_message_to_group(
chat_id=str(claimed.get("chat_id") or ""), chat_id=str(claimed.get("chat_id") or ""),
message=str(claimed.get("message_text") or ""), message=raw_message,
) )
except Exception as exc: except Exception as exc:
send_error = str(exc) send_error = str(exc)

View File

@ -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 = { card_content = {
'config': {'wide_screen_mode': True}, 'config': {'wide_screen_mode': True},
@ -188,10 +177,30 @@ class FeishuTransportMixin:
elif item.get('type') == 'divider': elif item.get('type') == 'divider':
card_content['elements'].append({'tag': 'hr'}) 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 = { message_data = {
'receive_id': chat_id, 'receive_id': chat_id,
'msg_type': 'interactive', '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: 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')}" f"飞书发送失败: code={payload.get('code')} msg={payload.get('msg') or payload.get('message')}"
) )
return payload return payload

View File

@ -1,6 +1,9 @@
from __future__ import annotations from __future__ import annotations
from .mixin_card_action import FeishuCardActionMixin
from .mixin_config import FeishuConfigMixin 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_message_payload import FeishuMessagePayloadMixin
from .mixin_receive import FeishuReceiveMixin from .mixin_receive import FeishuReceiveMixin
from .mixin_receive_routing import FeishuReceiveRoutingMixin from .mixin_receive_routing import FeishuReceiveRoutingMixin
@ -15,7 +18,10 @@ from .shared import AsyncSession
class FeishuIntegrationService( class FeishuIntegrationService(
FeishuCardActionMixin,
FeishuConfigMixin, FeishuConfigMixin,
FeishuReceivePrepareMixin,
FeishuReceiveCaptureFlowMixin,
FeishuReceiveMixin, FeishuReceiveMixin,
FeishuReceiveRoutingMixin, FeishuReceiveRoutingMixin,
FeishuTraceMixin, FeishuTraceMixin,

View File

@ -18,6 +18,9 @@ class LongConnectionRuntimeMixin:
if not callable(register_receive): if not callable(register_receive):
raise RuntimeError("当前 lark_oapi 版本不支持 register_p2_im_message_receive_v1") raise RuntimeError("当前 lark_oapi 版本不支持 register_p2_im_message_receive_v1")
builder = register_receive(self._on_message_event) builder = register_receive(self._on_message_event)
register_card_action = getattr(builder, "register_p2_card_action_trigger", None)
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) register_read = getattr(builder, "register_p2_im_message_message_read_v1", None)
if callable(register_read): if callable(register_read):
@ -147,7 +150,12 @@ class LongConnectionRuntimeMixin:
return return
event_type = str(event_data.get("type") or "").strip() 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}") logger.info(f"feishu_long_connection_event_skipped: event_type={event_type}")
return return

View File

@ -2,6 +2,11 @@ from __future__ import annotations
from .shared import * 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: class LLMPromptProfileRuntimeMixin:
async def _resolve_active_prompt_profile(self, db: AsyncSession, *, tenant_id: int) -> dict[str, str]: async def _resolve_active_prompt_profile(self, db: AsyncSession, *, tenant_id: int) -> dict[str, str]:
cached = self._prompt_profile_cache.get(int(tenant_id)) cached = self._prompt_profile_cache.get(int(tenant_id))
@ -154,7 +159,7 @@ class LLMPromptProfileRuntimeMixin:
"user_id": None, "user_id": None,
"function_name": function_name, "function_name": function_name,
"prompt": prompt[:8000], "prompt": prompt[:8000],
"response": (response or "")[:8000], "response": self._trim_ai_call_response(response),
"tokens_used": tokens_used, "tokens_used": tokens_used,
"cost": cost, "cost": cost,
"status": status, "status": status,
@ -174,6 +179,19 @@ class LLMPromptProfileRuntimeMixin:
await session.rollback() await session.rollback()
return 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 @staticmethod
def _estimate_cost(provider: str, model: str, tokens: int) -> float: def _estimate_cost(provider: str, model: str, tokens: int) -> float:
_ = model _ = model
@ -242,4 +260,3 @@ class LLMPromptProfileRuntimeMixin:
return "" return ""
cleaned = re.sub(r"<think>.*?</think>", "", cleaned, flags=re.IGNORECASE | re.DOTALL) cleaned = re.sub(r"<think>.*?</think>", "", cleaned, flags=re.IGNORECASE | re.DOTALL)
return cleaned.strip() return cleaned.strip()

View File

@ -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

View File

@ -19,6 +19,8 @@ class SignalCaptureEntryMixin:
feishu_message_id: Optional[str] = None, feishu_message_id: Optional[str] = None,
chat_type: Optional[str] = None, chat_type: Optional[str] = None,
force_sync: bool = False, force_sync: bool = False,
message_sent_at: Optional[Any] = None,
session_snapshot: Optional[Dict[str, Any]] = None,
) -> Dict[str, Any]: ) -> Dict[str, Any]:
""" """
从飞书群聊捕获信号 从飞书群聊捕获信号
@ -48,20 +50,49 @@ class SignalCaptureEntryMixin:
self._emit_ai_think("capture_skip_empty", trace_id=trace_id) self._emit_ai_think("capture_skip_empty", trace_id=trace_id)
return {"status": "ignored", "reason": "empty_or_emoji_only"} 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) kernel = AIPMOKernelService(self.db, tenant_id=tenant_id)
try: try:
should_sync_process = bool(force_sync) should_sync_process = bool(force_sync)
enqueue_session = None
if not should_sync_process: 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), tenant_id=int(tenant_id),
sender=proposer_name, sender=proposer_name,
) )
should_sync_process = kernel._is_short_confirmation_message( should_sync_process = kernel._is_short_confirmation_message(
message_text=cleaned_message, message_text=cleaned_message,
session=session, session=enqueue_session,
message_sent_at=message_sent_at,
session_snapshot=session_snapshot,
) )
if should_sync_process: 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( return await self._process_feishu_with_kernel_sync(
trace_id=trace_id, trace_id=trace_id,
tenant_id=tenant_id, tenant_id=tenant_id,
@ -73,6 +104,8 @@ class SignalCaptureEntryMixin:
captured_dt=captured_dt, captured_dt=captured_dt,
feishu_message_id=feishu_message_id, feishu_message_id=feishu_message_id,
chat_type=chat_type, 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): if self._is_non_requirement_message(cleaned_message) and not self._is_question_like(cleaned_message):
@ -83,14 +116,22 @@ class SignalCaptureEntryMixin:
) )
return {"status": "ignored", "reason": "non_requirement_noise"} return {"status": "ignored", "reason": "non_requirement_noise"}
source_message_id = str(feishu_message_id or "").strip() if enqueue_session is None:
if not source_message_id: enqueue_session = await kernel._get_or_create_session(
source_message_id = AIPMOKernelService._build_source_message_id( tenant_id=int(tenant_id),
source_system="feishu",
source_message_id=None,
channel_ref=group_chat_id,
sender=proposer_name, 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( run_row, created = await AIOrchestrationRunService(self.db).enqueue_ingress_run(
@ -107,6 +148,14 @@ class SignalCaptureEntryMixin:
max_retries=3, max_retries=3,
) )
if created: 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( await AIOrchestrationRunService(self.db).mark_ingress_ack_sent(
tenant_id=int(tenant_id), tenant_id=int(tenant_id),
trace_id=str(run_row.trace_id or ""), trace_id=str(run_row.trace_id or ""),
@ -187,6 +236,8 @@ class SignalCaptureEntryMixin:
captured_dt: datetime, captured_dt: datetime,
feishu_message_id: Optional[str], feishu_message_id: Optional[str],
chat_type: Optional[str], chat_type: Optional[str],
message_sent_at: Optional[Any] = None,
session_snapshot: Optional[Dict[str, Any]] = None,
) -> Dict[str, Any]: ) -> Dict[str, Any]:
pipeline_result = await kernel.process_feishu_message( pipeline_result = await kernel.process_feishu_message(
group_chat_id=group_chat_id, group_chat_id=group_chat_id,
@ -196,6 +247,8 @@ class SignalCaptureEntryMixin:
source_sender_open_id=proposer_open_id, source_sender_open_id=proposer_open_id,
captured_at=captured_dt, captured_at=captured_dt,
chat_type=chat_type, chat_type=chat_type,
message_sent_at=message_sent_at,
session_snapshot=session_snapshot,
) )
if not bool(pipeline_result.get("handled")): if not bool(pipeline_result.get("handled")):
self._emit_ai_think( self._emit_ai_think(
@ -308,3 +361,135 @@ class SignalCaptureEntryMixin:
"decision_level": None, "decision_level": None,
"review_summary": 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,
}

View File

@ -211,7 +211,7 @@ async def process_task_completion(
user_id=operator_user_id or int(task.assignee_id), user_id=operator_user_id or int(task.assignee_id),
function_name="skill_score_update", function_name="skill_score_update",
prompt=prompt, 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), 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), cost=((max(len(prompt) // 4, 1) * 0.00015 + max(len(response_text) // 4, 0) * 0.0006) / 1000),
status=status, status=status,
@ -219,4 +219,3 @@ async def process_task_completion(
) )
await db.commit() await db.commit()
return {"updated_count": updated, "status": status} return {"updated_count": updated, "status": status}

View File

@ -182,7 +182,7 @@ async def build_ai_optimize_preview(
user_id=None, user_id=None,
function_name="workload_optimize_prompt", function_name="workload_optimize_prompt",
prompt=llm_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), 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), cost=((max(len(llm_prompt) // 4, 1) * 0.00015 + max(len(llm_text) // 4, 0) * 0.0006) / 1000),
status=llm_status, status=llm_status,
@ -289,4 +289,3 @@ async def apply_ai_optimize_plan(
await db.commit() await db.commit()
return {"applied_count": applied, "affected_users": len(touched_user_ids)} return {"applied_count": applied, "affected_users": len(touched_user_ids)}

View File

@ -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

View File

@ -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"
)

View File

@ -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"] == "新增任务确认流"

View File

@ -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

View File

@ -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"}]

View File

@ -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

View File

@ -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"]

View File

@ -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

View File

@ -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

View File

@ -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"

View File

@ -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"

View File

@ -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"

View File

@ -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

View File

@ -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小时内未确认自动取消"
}

View File

@ -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

View File

@ -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

Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

View File

@ -0,0 +1,11 @@
sender=草稿优化验收机器人-1774243529
source_message_id=draft-opt-verify2-1774243529
run_status=succeeded
work_item_state=draft_pending_confirm
title=给AI劝退助手加个需
feasibility=待讨论
recommended_assignee_workload=100.0
recommended_assignee_reason=基于当前负载与任务匹配度给出的建议。
semantic_title_ok=False
feasibility_is_合理=False
assignee_under80_or_risk_note=False

View File

@ -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%E8%8D%89%E7%A8%BF%E9%A3%9E%E4%B9%A6%E5%9B%9E%E5%A4%8D%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%E8%8D%89%E7%A8%BF%E9%A3%9E%E4%B9%A6%E5%9B%9E%E5%A4%8D%3C%2Fh1%3E%3Cpre%3E%F0%9F%93%8B%20%E5%B7%B2%E4%B8%BA%E4%BD%A0%E8%B5%B7%E8%8D%89%E9%9C%80%E6%B1%82%EF%BC%8C%E8%AF%B7%E7%A1%AE%E8%AE%A4%EF%BC%9A%0A%0A%F0%9F%93%8C%20%E7%BB%99AI%E5%8A%9D%E9%80%80%E5%8A%A9%E6%89%8B%E5%8A%A0%E4%B8%AA%E9%9C%80%0A%E7%BB%99AI%E5%8A%9D%E9%80%80%E5%8A%A9%E6%89%8B%E5%8A%A0%E4%B8%AA%E9%9C%80%E6%B1%82%20%E5%8A%9D%E9%80%80%E5%AE%8C%E5%90%8E%E7%AC%AC%E4%BA%8C%E5%A4%A9%E9%97%AE%E4%B8%80%E4%B8%8B%E6%83%85%E5%86%B5%0A%0A%E2%9C%85%20%E5%8F%AF%E8%A1%8C%E6%80%A7%EF%BC%9A%E5%BE%85%E8%AE%A8%E8%AE%BA%0A%E4%BF%A1%E6%81%AF%E4%BB%8D%E9%9C%80%E8%A1%A5%E5%85%85%EF%BC%8C%E5%BB%BA%E8%AE%AE%E5%85%88%E7%A1%AE%E8%AE%A4%E8%BE%B9%E7%95%8C%E3%80%82%0A%0A%F0%9F%91%A4%20%E6%8E%A8%E8%8D%90%E8%B4%9F%E8%B4%A3%E4%BA%BA%EF%BC%9A%E7%B3%BB%E7%BB%9F%E7%AE%A1%E7%90%86%E5%91%98%20%C2%B7%20%E5%BD%93%E5%89%8D%E8%B4%9F%E8%BD%BD100%25%0A%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%0A%0A%F0%9F%93%85%20%E8%B7%9F%E8%BF%9B%E8%AE%A1%E5%88%92%EF%BC%9A%0A%C2%B7%20%E6%98%8E%E5%A4%A9%EF%BC%9A%E7%A1%AE%E8%AE%A4%E8%A7%A6%E5%8F%91%E6%97%B6%E6%9C%BA%E4%B8%8E%E9%AA%8C%E6%94%B6%E5%8F%A3%E5%BE%84%0A%C2%B7%20%E4%B8%80%E5%91%A8%E5%90%8E%EF%BC%9A%E5%A4%8D%E6%A0%B8%E6%89%A7%E8%A1%8C%E6%95%88%E6%9E%9C%E5%B9%B6%E5%86%B3%E5%AE%9A%E6%98%AF%E5%90%A6%E6%89%A9%E5%B1%95%0A%0A---%0A%E5%9B%9E%E5%A4%8D%20%E7%A1%AE%E8%AE%A4%20%E2%86%92%20%E5%85%A5%E5%BA%93%0A%E5%9B%9E%E5%A4%8D%20%E4%BF%AE%E6%94%B9%20%5B%E5%85%B7%E4%BD%93%E4%BF%AE%E6%94%B9%E6%84%8F%E8%A7%81%5D%20%E2%86%92%20%E9%87%8D%E6%96%B0%E8%B5%B7%E8%8D%89%0A%E5%9B%9E%E5%A4%8D%20%E5%8F%96%E6%B6%88%20%E2%86%92%20%E6%94%BE%E5%BC%83%0A%E2%8F%B0%2024%E5%B0%8F%E6%97%B6%E5%86%85%E6%9C%AA%E7%A1%AE%E8%AE%A4%E8%87%AA%E5%8A%A8%E5%8F%96%E6%B6%88%3C%2Fpre%3E%3C%2Fbody%3E%3C%2Fhtml%3E

Binary file not shown.

After

Width:  |  Height:  |  Size: 62 KiB

View File

@ -0,0 +1,20 @@
📋 已为你起草需求,请确认:
📌 给AI劝退助手加个需
给AI劝退助手加个需求 劝退完后第二天问一下情况
✅ 可行性:待讨论
信息仍需补充,建议先确认边界。
👤 推荐负责人:系统管理员 · 当前负载100%
基于当前负载与任务匹配度给出的建议。
📅 跟进计划:
· 明天:确认触发时机与验收口径
· 一周后:复核执行效果并决定是否扩展
---
回复 确认 → 入库
回复 修改 [具体修改意见] → 重新起草
回复 取消 → 放弃
⏰ 24小时内未确认自动取消

View File

@ -0,0 +1,81 @@
{
"source_message_id": "draft-opt-verify-1774243376",
"capture_response": {
"status": "captured",
"classification": "command",
"linked_project_ids": [],
"signal_id": null,
"created_project_id": null,
"auto_tasks": [],
"ai_trace_id": "feishu:draft-opt-verify-1774243376",
"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": 285
},
"ok": false,
"run": {
"id": 113,
"status": "succeeded",
"last_error": null,
"retry_count": 0,
"max_retries": 3,
"work_item_id": 406,
"updated_at": "2026-03-23 13:22:56.674242"
},
"work_item": {
"id": 406,
"state": "succeeded",
"draft_version": 0,
"signal_id": null,
"confirm_message_id": null,
"draft_content": null,
"execution_result": {
"status": "pending_manual",
"signal_id": null,
"project_id": 51,
"target_state": "draft_pending_confirm",
"target_intent": "project_signal.create",
"followup_reply": "请回复“确认”、“修改 + 你的意见”或“取消”来处理当前草稿。",
"workflow_route": "project_signal.create",
"target_work_item_id": 405
},
"updated_at": "2026-03-23 13:22:56.643351"
},
"outbox": [
{
"id": 285,
"status": "sent",
"message_text": "已收到,正在处理。",
"created_at": "2026-03-23 13:22:56.388208",
"sent_at": "2026-03-23 13:22:57.387736",
"attempts": 1,
"last_error": null
},
{
"id": 286,
"status": "sent",
"message_text": "请回复“确认”、“修改 + 你的意见”或“取消”来处理当前草稿。",
"created_at": "2026-03-23 13:22:56.674242",
"sent_at": "2026-03-23 13:22:59.656752",
"attempts": 1,
"last_error": null
}
],
"draft_text": "",
"checks": {
"semantic_title_ok": false,
"feasibility_is_合理": false,
"assignee_under80_or_risk_note": true
}
}

View File

@ -0,0 +1,136 @@
{
"sender": "草稿优化验收机器人-1774243529",
"source_message_id": "draft-opt-verify2-1774243529",
"capture_response": {
"status": "captured",
"classification": "command",
"linked_project_ids": [],
"signal_id": null,
"created_project_id": null,
"auto_tasks": [],
"ai_trace_id": "feishu:draft-opt-verify2-1774243529",
"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": 287
},
"ok": true,
"run": {
"id": 114,
"status": "succeeded",
"last_error": null,
"retry_count": 0,
"max_retries": 3,
"work_item_id": 407,
"updated_at": "2026-03-23 13:26:16.013472"
},
"work_item": {
"id": 407,
"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:26:15.966734"
},
"updated_at": "2026-03-23 13:25:30.583098"
},
"outbox": [
{
"id": 287,
"status": "sent",
"message_text": "已收到,正在处理。",
"created_at": "2026-03-23 13:25:29.874847",
"sent_at": "2026-03-23 13:25:31.671652",
"attempts": 1,
"last_error": null
},
{
"id": 288,
"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:26:16.013472",
"sent_at": "2026-03-23 13:26:18.784250",
"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小时内未确认自动取消",
"checks": {
"semantic_title_ok": false,
"feasibility_is_合理": false,
"assignee_under80_or_risk_note": false
}
}

View File

@ -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%E5%9C%BA%E6%99%AF1%20%E6%95%B0%E6%8D%AE%E5%BA%93%E9%AA%8C%E8%AF%81%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%E5%9C%BA%E6%99%AF1%20%E6%95%B0%E6%8D%AE%E5%BA%93%E9%AA%8C%E8%AF%81%3C%2Fh1%3E%3Cpre%3Erun_status%3Dretrying%0Arun_last_error%3Dkernel_exception%0Arun_retry_count%3D3%0Awork_item_count%3D0%0Alatest_outbox_status%3Dsent%0Alatest_outbox_text%3D%E5%B7%B2%E6%94%B6%E5%88%B0%EF%BC%8C%E6%AD%A3%E5%9C%A8%E5%A4%84%E7%90%86%E3%80%82%0A%3C%2Fpre%3E%3C%2Fbody%3E%3C%2Fhtml%3E

View File

@ -0,0 +1,7 @@
<!doctype html><html><head><meta charset="utf-8"><title>场景1 数据库验证</title><style>body{font-family:Menlo,Consolas,monospace;background:#f6f8fa;padding:24px;color:#111}h1{font-size:20px}pre{white-space:pre-wrap;background:#fff;padding:16px;border-radius:8px;border:1px solid #ddd;line-height:1.5}</style></head><body><h1>场景1 数据库验证</h1><pre>run_status=retrying
run_last_error=kernel_exception
run_retry_count=3
work_item_count=0
latest_outbox_status=sent
latest_outbox_text=已收到,正在处理。
</pre></body></html>

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

View File

@ -0,0 +1 @@
work_item_after_draft_state=None\nwork_item_after_confirm_state=None\nwork_item_signal_id=None\nsignal_exists=False\npass=False\npass_rule={"draft_pending_confirm": false, "draft_reply_has_actions": false, "semantic_title": false, "confirm_to_succeeded": false, "signal_created": false}\n

View File

@ -0,0 +1,87 @@
{
"generated_at": "2026-03-23T14:43:11.708259",
"sender": "场景1复验机器人-1774247671",
"step1": {
"source_message_id": "scene1-direct-draft-1774247671",
"capture": {
"status": "captured",
"classification": "command",
"linked_project_ids": [],
"signal_id": null,
"created_project_id": null,
"auto_tasks": [],
"ai_trace_id": "feishu:scene1-direct-draft-1774247671",
"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": 292
},
"run_status": "succeeded",
"reply_text": "已收到,正在处理。",
"work_item_id": 410,
"work_item_state": "draft_pending_confirm",
"work_item_signal_id_before_confirm": null
},
"step2": {
"source_message_id": "scene1-direct-confirm-1774247671",
"capture": {
"handled": true,
"status": "answered",
"classification": "command",
"linked_project_ids": [
51
],
"signal_id": 342,
"signal_ids": [
342
],
"created_project_id": null,
"auto_tasks": [],
"ai_trace_id": "ai-pmo-411",
"results": [
{
"work_item_id": 411,
"state": "succeeded",
"intent": "followup.confirm",
"route_status": "auto_done"
}
],
"intent": "followup.confirm",
"workflow_route": "followup.confirm",
"direct_reply_required": true,
"direct_reply_text": "✅ 已入库\n\n📌 给AI劝退助手加个需\n信号 #342 已创建\n负责人:系统管理员",
"reply_template_key": "action_succeeded",
"work_item_id": 411,
"decision_level": null,
"review_summary": {
"verified": true,
"issues": [],
"next_step": "已处理草稿确认请求"
},
"decision_trace": null,
"reply_outbox_enqueued": true,
"reply_outbox_id": 294
},
"run_status": "missing",
"reply_text": "✅ 已入库\n\n📌 给AI劝退助手加个需\n信号 #342 已创建\n负责人:系统管理员"
},
"db_verify": {
"target_work_item_id": 410,
"target_work_item_state": "succeeded",
"target_signal_id": 342,
"signal_exists": true,
"signal_title": "给AI劝退助手加个需",
"signal_summary": "给AI劝退助手加个需求 劝退完后第二天问一下情况",
"signal_assignee_id": 1
}
}

Some files were not shown because too many files have changed in this diff Show More