diff --git a/CLAUDE.md b/CLAUDE.md
index e225ed50..2ca5b438 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -37,7 +37,7 @@ backend/
│ ├── __init__.py # app 包入口(根目录轻量化后保留)
│ ├── api/ # 路由层(含 endpoints)
│ ├── core/ # 配置、日志、核心能力
-│ │ └── startup/ # 启动阶段拆分模块(db guard/outbox/ingress async/probe/warmup)
+│ │ └── startup/ # 启动阶段拆分模块(db guard/outbox/ingress async/draft expiry/probe/warmup)
│ ├── deps.py # 共享依赖导出(get_db/AsyncSessionLocal)
│ ├── dependencies/ # 鉴权/租户等依赖注入
│ ├── middleware/ # 请求中间件(审计上下文注入等)
@@ -55,14 +55,27 @@ backend/
│ ├── 20260311_add_ai_pmo_work_items_view.py # ai_pmo_work_items 兼容视图(映射 work_items)
│ ├── 20260311_expand_user_workload_ratio_precision.py # user_workloads.workload_ratio 精度扩容(10,2)
│ ├── 20260311_add_conversation_sessions_table.py # 飞书多轮会话上下文持久化表
-│ └── 20260316_add_ai_orchestration_ingress_async_fields.py # ai_orchestration_runs 入站异步重试字段扩展
+│ ├── 20260316_add_ai_orchestration_ingress_async_fields.py # ai_orchestration_runs 入站异步重试字段扩展
+│ └── 20260323_add_work_item_draft_confirmation_fields.py # work_items 草稿确认字段与索引
├── scripts/ # 后端运维/数据修复脚本
│ ├── backfill_orphan_signal_links.py # 回填孤立 signal 的 project_signal_links
│ ├── dev/ # 开发辅助脚本(check_users/seed_all)
│ └── legacy/ # 历史脚本(仅保留兼容参考,不作为主流程)
+├── tests/ # 后端回归测试
+│ ├── test_draft_confirm_handler.py
+│ ├── test_ai_service_core_runtime.py
+│ ├── test_rag_route_contract.py
+│ ├── test_ai_pmo_task_intent_contract.py
+│ ├── test_ai_pmo_ingress_mainline.py
+│ ├── test_ai_pmo_qa_http_mainline_e2e.py
+│ ├── test_context_cache_profile_catalog.py
+│ ├── test_ai_pmo_tactical_brain_contract.py
+│ ├── test_adaptive_rag_mainline_contract.py
+│ ├── test_ai_pmo_execution_ledger_contract.py
+│ ├── test_feishu_card_action_contract.py
+│ └── test_feishu_receive_orchestration_contract.py
├── requirements.txt # Python 依赖
-├── Dockerfile
-└── (tests 已移除)
+└── Dockerfile
```
- 新增:`backend/app/api/llm_providers.py`(AI 管家 LLM provider preset 配置读取接口)
- 新增:`backend/app/services/ai_pmo/ai_pmo_kernel/ai_agent_kernel.py`(双 Agent 共享决策内核:复杂度路由/执行门控/修正识别)
@@ -77,6 +90,27 @@ backend/
- 新增:`backend/alembic/versions/20260312_add_signal_planner_fields.py`(signals 增加 planner 状态字段与索引)
- 新增:`backend/app/services/signal/signal_planner/signal_strategic_analysis_service.py`(Signal Planner 写回收口)
- 新增:`backend/app/services/integrations/feishu_outbox/service.py`(复用 reply_hash 的 outbox 入队收口)
+- 新增:`backend/app/services/signal/draft_generator_service.py`(写操作草稿生成服务:草稿结构化产出 + 24h 过期)
+- 新增:`backend/app/services/ai_pmo/draft_confirm_handler.py`(草稿确认处理器:确认/修改/取消 + 幂等保护)
+- 新增:`backend/app/core/startup/draft_expiry_worker.py`(草稿超时取消后台扫描任务)
+- 新增:`backend/tests/test_ai_service_core_runtime.py`(AIService 运行时回归:统一收敛 llm_service、缓存命中与流式兼容输出)
+- 新增:`backend/tests/test_rag_route_contract.py`(RAG 双入口契约回归:`asked_via/action_mode/message_intent/mixed_signal_part` 统一透传)
+- 新增:`backend/tests/test_ai_pmo_task_intent_contract.py`(AI PMO `task.create` 一等 typed intent 契约回归:意图识别、路由归一与执行器分派)
+- 新增:`backend/tests/test_ai_pmo_ingress_mainline.py`(AI PMO 主链入口回归:`/ai-pmo/process-text` 手工问答入口、mixed sidecar 透传、`draft_pending_confirm` 优先分支)
+- 新增:`backend/tests/test_ai_pmo_qa_http_mainline_e2e.py`(AI PMO/QA HTTP 主链集成回归:`/rag/ask` 只读问答、mixed 两段输出、`/ai-pmo/process-text` 草稿待确认)
+- 新增:`backend/tests/test_context_cache_profile_catalog.py`(Context Cache 功能目录契约回归:AI Steward 统一指向 `/rag/ask`)
+- 新增:`backend/tests/test_ai_pmo_tactical_brain_contract.py`(TacticalBrain 执行动作契约回归:`query.answer`/`work_item.resume`/`project_signal.*` 映射)
+- 新增:`backend/app/services/adaptive_rag_service/mixin_answer_mainline.py`(AdaptiveRAG 基础问答主链编排:运行时准备 + 只读主路径执行)
+- 新增:`backend/tests/test_adaptive_rag_mainline_contract.py`(AdaptiveRAG 入口分层契约回归:read 路径绕过写确认 gate、write 路径命中确认 gate、feishu write 仍走主链)
+- 新增:`backend/tests/test_ai_pmo_execution_ledger_contract.py`(执行账本/动作参数来源契约回归:`action_contract` 归一、账本幂等 key、状态映射)
+- 更新:`backend/app/services/ai_pmo/ai_pmo_kernel/mixins/ingress/ai_pmo_kernel_mixins_ingress_storage.py`(新增统一 `action_contract` 组装、`operation_execution_ledger` upsert、账本状态归一)
+- 更新:`backend/app/services/ai_pmo/ai_pmo_kernel_ingress_flow/mixin_auto.py`(自动执行与草稿待确认路径接入统一执行账本,日志 `operation_code` 对齐 `action_contract`)
+- 更新:`backend/app/services/ai_pmo/ai_pmo_kernel_ingress_flow/mixin_prepare_manual.py`(`waiting_manual` 路径接入统一执行账本与动作参数来源约束)
+- 新增:`backend/app/services/integrations/feishu_integration_service/mixin_card_action.py`(飞书卡片动作解析与草稿确认回调处理)
+- 新增:`backend/app/services/integrations/feishu_integration_service/mixin_receive_prepare.py`(飞书消息入口预处理:上下文抽取、空文本分支、附件标准化)
+- 新增:`backend/app/services/integrations/feishu_integration_service/mixin_receive_capture_flow.py`(飞书消息入口执行流:capture 持久化、异常回执、结果收尾)
+- 新增:`backend/tests/test_feishu_card_action_contract.py`(飞书卡片回调契约回归:动作解析与事件分发)
+- 新增:`backend/tests/test_feishu_receive_orchestration_contract.py`(飞书入口编排契约回归:bot 过滤、空文本分支、capture/finalize 编排)
- 新增:`backend/app/services/signal_planner_service.py`(Signal Planner 异步分析服务)
- 新增:`backend/app/services/model/model_routing/service.py`(函数级模型路由配置服务)
- 新增:`backend/app/services/task_brief_service.py`(增量任务包中心服务)
@@ -107,6 +141,9 @@ backend/
- 新增:`backend/app/services/ai_pmo/ai_pmo_kernel_ingress_flow/`(Kernel 入站主流程编排)
- 新增:`backend/app/services/ai_pmo/ai_pmo_kernel/mixins/ingress/ai_pmo_kernel_mixins_ingress_storage.py`(Kernel 入站事件/工作项写入)
- 新增:`backend/app/services/ai_pmo/ai_pmo_kernel/mixins/ingress/ai_pmo_kernel_mixins_ingress_render.py`(Kernel 入站结果组装与输出辅助)
+- 更新:`backend/app/services/ai_pmo/ai_pmo_kernel_ingress_flow/mixin_auto.py`(写操作改为“先起草后确认”,状态进入 `draft_pending_confirm`)
+- 更新:`backend/app/services/ai_pmo/ai_pmo_kernel_ingress_flow/mixin_entry.py`(会话命中 `draft_pending_confirm` 时优先路由草稿确认处理)
+- 更新:`backend/app/models/work_item.py`(新增 `draft_content/draft_expires_at/draft_version/confirm_message_id`)
- 新增:`backend/app/services/ai_pmo/ai_pmo_kernel/compositions/world_model.py`(Kernel world-model 组合入口)
- 新增:`backend/app/services/ai_pmo/ai_pmo_kernel_world_model_core/`(Kernel world-model 核心构建与缓存)
- 新增:`backend/app/services/ai_pmo/ai_pmo_kernel/mixins/world_model/ai_pmo_kernel_mixins_world_model_session.py`(Kernel 会话继承与会话态维护)
@@ -263,6 +300,9 @@ frontend/
- 新增:`frontend/src/pages/inbox/helpers.ts`(Inbox 页面 tab 过滤、排序、project-scope 判断 helper)
- 新增:`frontend/src/pages/inbox/components/InboxFiltersPanel.tsx`(Inbox 页面顶部筛选/搜索/tab 面板拆分)
- 新增:`frontend/src/pages/inbox/InboxPage.tsx`(Inbox 页面编排层,主文件聚焦状态与动作流转)
+- 更新:`frontend/src/api/aiPmo.ts`(新增草稿决策 API:`decideDraftWorkItem`,并扩展工作项 draft 字段)
+- 更新:`frontend/src/lib/inbox.ts`(支持 `draft_pending_confirm` 卡片映射与三动作按钮)
+- 更新:`frontend/src/types/inbox.ts`(新增草稿动作类型 `revise_draft/cancel_draft`)
- 更新:`frontend/src/pages/RecipesPage.tsx`(降级为轻量入口,转发到 `pages/recipes/RecipesPage`)
- 新增:`frontend/src/pages/recipes/helpers.ts`(Recipes 页面默认表单与分类常量)
- 新增:`frontend/src/pages/recipes/components/CreateRecipeDialog.tsx`、`AnalyzeRecipeDialog.tsx`(Recipes 页面手动创建/AI 识别弹窗拆分)
@@ -392,6 +432,9 @@ uvicorn app.main:app --reload --host 0.0.0.0 --port 8000
- AI Steward 零配置创建任一步 LLM 失败、超时或 JSON 非法时必须直接报错,禁止回退默认 analysis/signal/charter/tasks 草稿
- Signal Source Onboarding 的 planner/worker 任一步失败必须显式报错或标记 failed,禁止 heuristic/fallback 兜底计划与“最小闭环”摘要
- AI Steward 不再暴露 OpenClaw 指挥规划入口,`/ai-steward/command/dispatch` 视为下线路径
+- AI PMO 写操作(`project.create/signal.create/signal_source.onboard`)默认先生成草稿并进入 `draft_pending_confirm`,未确认前禁止自动执行入库
+- 草稿确认必须校验 `draft_version`,并通过 `confirm_message_id` 防重放;草稿超时取消统一由 `draft_expiry_worker` + `feishu_reply_outbox` 处理
+- AI PMO 执行层必须统一产出 `execution_result.action_contract` 与 `action_param_source`,并写入 `operation_execution_ledger`;禁止在执行链路旁路写副作用且不入账
## 9. 新增代码的参考模式
- 新增后端接口:参考 `backend/app/api/sstc.py` 的结构
@@ -410,7 +453,10 @@ uvicorn app.main:app --reload --host 0.0.0.0 --port 8000
- AI PMO 审批接口(`POST /ai-pmo/work-items/{work_item_id}/decision`):显式 `approve/reject`,`reject` 必须落 `ignored`(失效)并同步 `decision_records.final_outcome`
- AI PMO 审批接口补充:`approve + waiting_manual + project_id` 走“快速返回 + 后台继续执行”,避免前端长时间阻塞
- AI PMO 恢复接口(`POST /ai-pmo/work-items/{work_item_id}/resume`):支持 `action=continue|commit|refresh|cancel`,复用同一闭环恢复入口
+- AI PMO 草稿决策接口(`POST /ai-pmo/work-items/{work_item_id}/draft-decision`):支持 `action=confirm|revise|cancel`,与飞书“确认/修改/取消”行为一致
+- RAG 问答主入口契约(`POST /rag/ask`):HTTP 入口已收敛到 `AIPMOKernelService.process_ingress` 主链;请求侧统一透传 `asked_via/action_mode/message_intent/mixed_signal_part`,由 `ingress_options.rag_request_context` 下传到 query executor 并映射到 `AdaptiveRAGService.answer_question`,返回侧从 ingress `rag_result`/`direct_reply_text` 组装兼容响应
- AI PMO 消息台列表接口(`GET /ai-pmo/work-items`):返回 `latest_policy_gate_code` 与规范化 `approval_requirement{required,reason,message}`(来源于最新 `decision_records` 投影)
+- AI PMO 消息台列表接口补充:返回草稿字段 `draft_content/draft_expires_at/draft_version/confirm_message_id`,用于草稿确认卡片渲染
- AI PMO 消息台列表接口补充:返回源消息上下文字段 `source_message_text/source_captured_at/source_sender/source_channel_ref`,用于详情页展示“原始输入”与事件时间
- AI PMO 消息台列表接口补充:返回闭环语义字段 `source_conversation_closure/closure_summary/implementation_modules/source_closure_turns`,用于详情页展示“消息闭环 + 实现模块”
- AI PMO 消息台历史回填补充:当 `ingest_events.payload` 闭环字段缺失或低质量时,优先回放 `ai_processing_logs`(同 `feishu_message_id`)并补充 `qa_records` 最近问答,避免 Redis 会话过期后仅剩单句摘要
@@ -457,9 +503,25 @@ uvicorn app.main:app --reload --host 0.0.0.0 --port 8000
- 兼容 SQL 查询入口:`ai_pmo_work_items` 为视图别名(底层映射 `work_items`),避免历史脚本直接查旧表名失败
- `signal_source.onboard` 已加入工作流门禁旁路:进入执行阶段后按闭环结果返回 `succeeded/pending_manual/failed_safe`,不再被前置仿真门禁直接拦截为 `waiting_manual`
- 飞书入站已拆分“短确认同步快路径 + 非短确认异步队列”:短确认命中会话窗口时直接继承上轮意图并同步执行;其余消息先回“已收到,正在处理”后由 `ai_orchestration_runs` 异步 worker 执行与重试
+- 会话命中 `session.last_intent=draft_pending_confirm` 时,入站优先进入草稿确认处理器,不再走常规意图识别
+- 草稿确认成功后执行路径复用 `followup` 恢复链路,目标 work_item 在同事务内完成状态迁移与 `decision_records.final_outcome` 更新
+- 飞书回复 outbox 已支持 `text + interactive card` 双通道:执行场景命中 `draft_pending_confirm/pending_manual` 时优先入队按钮卡片;卡片消息仍经 outbox 异步派发(不走业务路径同步直发)
+- 飞书入口已按职责拆分为“预处理/捕获执行/结果回执”三层:`mixin_receive.py` 仅保留编排,`mixin_receive_prepare.py` 负责传输解析与上下文抽取,`mixin_receive_capture_flow.py` 负责 capture 持久化与回执收尾
- `ai_orchestration_runs.project_id` 允许为空:用于承载尚未完成项目归属判定的入站异步任务;既有 `signal_source_onboarding` 运行仍按显式 `project_id` 写入
- `/ai-pmo/process-text` 问答链路在 AdaptiveRAG 内通过 `asked_via=pmflow_manual` 识别为 AI PMO 入口;mixed 场景通过 `message_intent=mixed` 透传触发专用 Prompt 与两段输出格式化
+- `/rag/ask` 已改为经 AI PMO kernel ingress 主链执行(与飞书长连接、`/ai-pmo/process-text` 同链路);query 执行阶段通过 `rag_request_context` 保留 `session_id/new_session/thinking_mode/action_mode/sync_requirement/message_intent/mixed_signal_part`
+- AdaptiveRAG `answer_question` 入口已拆为“双阶段”:`_prepare_answer_runtime`(会话/上下文/动作模式解析)+ `_execute_base_answer_mainline`(基础问答主路径);写确认 `ack` gate 仅在 `sync_requirement=true && action_mode in write/read_write && 非 feishu` 时触发
+- AI PMO ingress 执行路径(`waiting_manual/draft_pending_confirm/auto execute`)已统一写入 `operation_execution_ledger`,`operation_code` 与 `execution_result.action_contract` 一致;账本状态统一映射为 `applied/pending_manual/pending_confirmation/failed`
- `PROMPT_DECOMPOSE_TASKS` 技能标签来源已改为运行时动态注入:优先读取 `skill_tags`(按 `sort_order/name`),为空时才回退默认技能集合
## 12. 技术债务清单
-- [ ] `mixin_auto.py` `_safe_int` NameError:之前被 `simulation_capacity_limit` 门禁遮住未暴露,需单独任务修复
+- [x] `mixin_auto.py` `_safe_int` NameError:已通过显式导入 `_safe_int` + 回归测试锁定,避免后续共享导出调整导致运行时未定义
+- [x] AI 入口主链已统一:飞书长连接(`capture_from_feishu`)、`/ai-pmo/process-text` 与 `/rag/ask` 已统一收敛到 `AIPMOKernelService.process_ingress` 主链;`/rag/ask` 通过 `rag_ask_contract.build_kernel_ingress_kwargs` 透传 RAG 上下文(`session_id/new_session/thinking_mode/action_mode/...`),query 执行后由 `build_rag_response_payload` 保持 HTTP 响应兼容,回归由 `test_rag_route_contract.py`、`test_ai_pmo_qa_http_mainline_e2e.py`、`test_ai_pmo_ingress_mainline.py` 锁定
+- [x] 飞书入口层耦合已收敛:飞书真实入口已拆为三段职责链(预处理 `mixin_receive_prepare.py` / capture 执行 `mixin_receive_capture_flow.py` / 编排 `mixin_receive.py`);`mixin_receive.py` 不再混合传输解析、附件标准化、capture 异常兜底与收尾回执逻辑,分层边界通过 `test_feishu_receive_orchestration_contract.py` 锁定
+- [x] `task.create` 已升级为一等 typed intent:`IntentResolver/RouteResolver` 新增 `task.create` 常量与 `task.create` 路由族;TypedExecutor 已接入任务落库执行(含 `project_id`/任务标题槽位守卫、对齐 Charter 校验、`persist_task` 写入);`WorkflowRegistry`、`ReplyRenderer`、`followup` 校验与草稿确认闭环已纳入 `task.create`;并补 `test_ai_pmo_task_intent_contract.py` 回归锁定
+- [x] AdaptiveRAG 职责已完成首轮收敛:`answer_question` 入口已分层为 `prepare runtime` 与 `base mainline` 两段,写确认逻辑通过 `_should_trigger_write_ack_gate` 独立门控;基础问答主路径不再与写确认判断混写在同一函数体。分层契约由 `test_adaptive_rag_mainline_contract.py` 锁定(read 绕过 ack、write 命中 ack、feishu write 维持主链)
+- [x] 执行层已形成统一执行账本与参数来源约束:ingress 执行路径统一通过 `KernelIngressStorageMixin` 生成 `execution_result.action_contract`(固定 `param_source=decision.execution_plan.actions+workflow_route+execution_result`)并写入 `operation_execution_ledger`(`applied/pending_manual/pending_confirmation/failed`);`mixin_auto.py`、`mixin_prepare_manual.py` 的 `operation_code` 日志来源已对齐 `action_contract`,回归由 `test_ai_pmo_execution_ledger_contract.py` 锁定
+- [x] QA/RAG 路由契约双轨已收敛:`/ai-steward/rag/ask` 兼容入口已移除,问答统一收敛到 `POST /rag/ask`;参数构造与 tenant 解析收口在 `backend/app/api/rag_ask_contract.py`,前端 `FloatingQAChat` 与 Context Cache API 目录均已对齐,`backend/tests/test_rag_route_contract.py` 与 `backend/tests/test_context_cache_profile_catalog.py` 已锁定回归
+- [x] LLM 运行时仍未完全收敛:`ai_service_core._invoke_with_retry` 与 `stream_chat` 已统一走 `llm_service`;旧 `_get_llm` 路径已显式禁用,并补回归测试锁定
+- [x] QA 主链自动化测试覆盖已补齐:`backend/tests/` 已覆盖问答/RAG 主路径与关键场景;新增 `test_ai_pmo_qa_http_mainline_e2e.py` 通过 HTTP 层锁定 `/rag/ask` 的 `qa_readonly/mixed` 契约以及 `/ai-pmo/process-text` 的 `draft_pending_confirm` 主链返回,配合 `test_ai_pmo_ingress_mainline.py`、`test_ai_pmo_task_intent_contract.py`、`test_ai_pmo_tactical_brain_contract.py` 形成稳定回归基线
+- [x] 飞书交互闭环已补齐:执行场景回执在 `draft_pending_confirm/pending_manual` 下优先生成按钮卡片并经 outbox 异步发送;`card.action.trigger` 已覆盖 `confirm/revise/cancel/continue` 回调分发,分别复用 `DraftConfirmHandler.handle_api_decision` 与 AI PMO `process_ingress` 主链继续执行;`backend/tests/test_feishu_card_action_contract.py` 已补卡片构造与 `continue` 回归契约
diff --git a/backend/alembic/versions/20260323_add_work_item_draft_confirmation_fields.py b/backend/alembic/versions/20260323_add_work_item_draft_confirmation_fields.py
new file mode 100644
index 00000000..36ecb9b6
--- /dev/null
+++ b/backend/alembic/versions/20260323_add_work_item_draft_confirmation_fields.py
@@ -0,0 +1,92 @@
+"""add work item draft confirmation fields
+
+Revision ID: add_work_item_draft_confirmation_fields
+Revises: add_ai_orchestration_ingress_async_fields
+Create Date: 2026-03-23
+"""
+
+from __future__ import annotations
+
+from alembic import op
+import sqlalchemy as sa
+
+
+revision = "add_work_item_draft_confirmation_fields"
+down_revision = "add_ai_orchestration_ingress_async_fields"
+branch_labels = None
+depends_on = None
+
+
+def _table_exists(bind, table_name: str) -> bool:
+ return sa.inspect(bind).has_table(table_name)
+
+
+def _column_exists(bind, table_name: str, column_name: str) -> bool:
+ inspector = sa.inspect(bind)
+ if not inspector.has_table(table_name):
+ return False
+ return any(str(item.get("name") or "") == column_name for item in inspector.get_columns(table_name))
+
+
+def _index_exists(bind, table_name: str, index_name: str) -> bool:
+ inspector = sa.inspect(bind)
+ if not inspector.has_table(table_name):
+ return False
+ return any(str(item.get("name") or "") == index_name for item in inspector.get_indexes(table_name))
+
+
+def upgrade() -> None:
+ bind = op.get_bind()
+ table_name = "work_items"
+ if not _table_exists(bind, table_name):
+ return
+
+ with op.batch_alter_table(table_name) as batch_op:
+ if not _column_exists(bind, table_name, "draft_content"):
+ batch_op.add_column(sa.Column("draft_content", sa.JSON(), nullable=True))
+ if not _column_exists(bind, table_name, "draft_expires_at"):
+ batch_op.add_column(sa.Column("draft_expires_at", sa.TIMESTAMP(), nullable=True))
+ if not _column_exists(bind, table_name, "draft_version"):
+ batch_op.add_column(sa.Column("draft_version", sa.Integer(), nullable=False, server_default="0"))
+ if not _column_exists(bind, table_name, "confirm_message_id"):
+ batch_op.add_column(sa.Column("confirm_message_id", sa.String(length=128), nullable=True))
+
+ if not _index_exists(bind, table_name, "ix_work_items_draft_expires"):
+ op.create_index(
+ "ix_work_items_draft_expires",
+ table_name,
+ ["draft_expires_at"],
+ postgresql_where=sa.text("state = 'draft_pending_confirm'"),
+ )
+
+ if not _index_exists(bind, table_name, "uq_work_items_tenant_confirm_message"):
+ op.create_index(
+ "uq_work_items_tenant_confirm_message",
+ table_name,
+ ["tenant_id", "confirm_message_id"],
+ unique=True,
+ postgresql_where=sa.text("confirm_message_id IS NOT NULL"),
+ )
+
+
+def downgrade() -> None:
+ bind = op.get_bind()
+ table_name = "work_items"
+ if not _table_exists(bind, table_name):
+ return
+
+ if _index_exists(bind, table_name, "uq_work_items_tenant_confirm_message"):
+ op.drop_index("uq_work_items_tenant_confirm_message", table_name=table_name)
+
+ if _index_exists(bind, table_name, "ix_work_items_draft_expires"):
+ op.drop_index("ix_work_items_draft_expires", table_name=table_name)
+
+ with op.batch_alter_table(table_name) as batch_op:
+ if _column_exists(bind, table_name, "confirm_message_id"):
+ batch_op.drop_column("confirm_message_id")
+ if _column_exists(bind, table_name, "draft_version"):
+ batch_op.drop_column("draft_version")
+ if _column_exists(bind, table_name, "draft_expires_at"):
+ batch_op.drop_column("draft_expires_at")
+ if _column_exists(bind, table_name, "draft_content"):
+ batch_op.drop_column("draft_content")
diff --git a/backend/app/api/ai_pmo_api/helpers_inbox_filters.py b/backend/app/api/ai_pmo_api/helpers_inbox_filters.py
index 651e3c75..97135f74 100644
--- a/backend/app/api/ai_pmo_api/helpers_inbox_filters.py
+++ b/backend/app/api/ai_pmo_api/helpers_inbox_filters.py
@@ -7,6 +7,10 @@ def _normalize_noise_text(text: str) -> str:
def _should_hide_work_item_from_inbox(*, row: WorkItem, ingest: IngestEvent) -> bool:
intent_name = str(row.intent or "").strip().lower()
+ if intent_name == "followup.confirm":
+ execution_result = row.execution_result if isinstance(row.execution_result, dict) else {}
+ if _safe_int(execution_result.get("target_work_item_id")) is not None:
+ return True
if intent_name not in FRONT_NOISE_INTENTS:
return False
payload = ingest.payload if isinstance(ingest.payload, dict) else {}
@@ -107,10 +111,12 @@ def _stage_text(state: str) -> str:
"decided": "已决策",
"planned": "已规划",
"executing": "执行中",
+ "draft_pending_confirm": "草稿待确认",
"waiting_manual": "待人工确认",
"succeeded": "执行完成",
"failed": "执行失败",
"ignored": "已忽略",
+ "cancelled": "已取消",
}
return mapping.get(str(state or "").strip(), str(state or "").strip() or "-")
@@ -180,4 +186,3 @@ async def _build_latest_decision_projection(
"approval_requirement": _normalize_approval_requirement(row.approval_requirement),
}
return projection
-
diff --git a/backend/app/api/ai_pmo_api/helpers_inbox_payload.py b/backend/app/api/ai_pmo_api/helpers_inbox_payload.py
index 8d2589e2..43071d78 100644
--- a/backend/app/api/ai_pmo_api/helpers_inbox_payload.py
+++ b/backend/app/api/ai_pmo_api/helpers_inbox_payload.py
@@ -78,6 +78,9 @@ def _serialize_work_item_summary(
source_captured_at = str(payload.get("captured_at") or "").strip() or None
source_sender = str(payload.get("sender") or "").strip() or None
source_channel_ref = str(payload.get("channel_ref") or "").strip() or None
+ draft_content = row.draft_content if isinstance(row.draft_content, dict) else {}
+ draft_title = _compact_text(str(draft_content.get("title") or "").strip(), 64)
+ draft_summary = _compact_text(str(draft_content.get("summary") or "").strip(), 180)
summary_seed = closure_summary or message_text or _next_step_text(row) or row.intent or "AI PMO 工作项"
summary = _compact_text(summary_seed, 180)
compact_message = re.sub(r"\s+", "", message_text).lower() if message_text else ""
@@ -88,6 +91,11 @@ def _serialize_work_item_summary(
else (message_text or closure_summary or f"AI PMO · {row.intent or 'work_item'}")
)
title = _compact_text(title_seed, 64)
+ if str(row.state or "").strip() == "draft_pending_confirm":
+ if draft_title:
+ title = draft_title
+ if draft_summary:
+ summary = draft_summary
priority, priority_rank = _priority_from_review(row.review_summary)
project_name = None
if project is not None:
@@ -134,7 +142,10 @@ def _serialize_work_item_summary(
"target_url": f"/projects/{project_id}/board" if project_id is not None else None,
"review_summary": row.review_summary,
"execution_result": row.execution_result,
+ "draft_content": draft_content or None,
+ "draft_expires_at": row.draft_expires_at.isoformat() if row.draft_expires_at else None,
+ "draft_version": int(row.draft_version or 0),
+ "confirm_message_id": str(row.confirm_message_id or "").strip() or None,
"created_at": row.created_at.isoformat() if row.created_at else None,
"updated_at": row.updated_at.isoformat() if row.updated_at else None,
}
-
diff --git a/backend/app/api/ai_pmo_api/routes_core.py b/backend/app/api/ai_pmo_api/routes_core.py
index ba364d3d..24a80e5a 100644
--- a/backend/app/api/ai_pmo_api/routes_core.py
+++ b/backend/app/api/ai_pmo_api/routes_core.py
@@ -138,6 +138,10 @@ async def get_work_item(
"execution_result": row.execution_result,
"review_summary": row.review_summary,
"reply_template_key": row.reply_template_key,
+ "draft_content": row.draft_content if isinstance(row.draft_content, dict) else None,
+ "draft_expires_at": row.draft_expires_at.isoformat() if row.draft_expires_at else None,
+ "draft_version": int(row.draft_version or 0),
+ "confirm_message_id": str(row.confirm_message_id or "").strip() or None,
"decision_records": [
{
"id": int(item.id),
@@ -178,4 +182,3 @@ async def get_work_item(
],
}
return success_response(payload)
-
diff --git a/backend/app/api/ai_pmo_api/routes_resume.py b/backend/app/api/ai_pmo_api/routes_resume.py
index 1e45db6e..c17178dc 100644
--- a/backend/app/api/ai_pmo_api/routes_resume.py
+++ b/backend/app/api/ai_pmo_api/routes_resume.py
@@ -2,7 +2,8 @@ from __future__ import annotations
from .shared import *
from .helpers_resume import _queue_resume_work_item, _reject_work_item
-from .schemas import PMOResumeWorkItemRequest, PMOWorkItemDecisionRequest
+from .schemas import PMODraftDecisionRequest, PMOResumeWorkItemRequest, PMOWorkItemDecisionRequest
+from ...services.ai_pmo.draft_confirm_handler import DraftConfirmHandler
@router.post("/work-items/{work_item_id}/resume")
async def resume_work_item(
@@ -149,3 +150,38 @@ async def decide_work_item(
result = _normalize_resume_result(raw=raw_result, target_work_item=target)
return success_response(result)
+
+@router.post("/work-items/{work_item_id}/draft-decision")
+async def decide_draft_work_item(
+ work_item_id: int,
+ request: PMODraftDecisionRequest,
+ current_user: User = Depends(get_current_user),
+ db: AsyncSession = Depends(get_db),
+ tenant_id: int = Depends(get_tenant_id),
+):
+ target = (
+ await db.execute(
+ select(WorkItem).where(
+ and_(
+ WorkItem.tenant_id == tenant_id,
+ WorkItem.id == int(work_item_id),
+ )
+ ).limit(1)
+ )
+ ).scalar_one_or_none()
+ if target is None:
+ raise HTTPException(status_code=404, detail="work_item_not_found")
+
+ handler = DraftConfirmHandler(AIPMOKernelService(db, tenant_id=tenant_id))
+ result = await handler.handle_api_decision(
+ work_item_id=int(work_item_id),
+ action=str(request.action or "").strip().lower(),
+ operator_sender=str(current_user.name or current_user.username or "PMFlow用户"),
+ source_message_id=request.source_message_id,
+ revision_note=request.revision_note,
+ draft_version=request.draft_version,
+ )
+ await db.commit()
+ if not bool(result.get("handled")):
+ raise HTTPException(status_code=400, detail=str(result.get("followup_reply") or "draft_decision_failed"))
+ return success_response(result)
diff --git a/backend/app/api/ai_pmo_api/schemas.py b/backend/app/api/ai_pmo_api/schemas.py
index f271a8d0..708d3068 100644
--- a/backend/app/api/ai_pmo_api/schemas.py
+++ b/backend/app/api/ai_pmo_api/schemas.py
@@ -23,3 +23,9 @@ class PMOWorkItemDecisionRequest(BaseModel):
source_message_id: Optional[str] = Field(default=None, max_length=128)
reason: Optional[str] = Field(default=None, max_length=500)
+
+class PMODraftDecisionRequest(BaseModel):
+ action: Literal["confirm", "revise", "cancel"] = Field(..., description="草稿动作:confirm/revise/cancel")
+ revision_note: Optional[str] = Field(default=None, max_length=500)
+ source_message_id: Optional[str] = Field(default=None, max_length=128)
+ draft_version: Optional[int] = Field(default=None, ge=1)
diff --git a/backend/app/api/ai_steward_api/routes_rag_sources.py b/backend/app/api/ai_steward_api/routes_rag_sources.py
index f22a194e..f897b3a2 100644
--- a/backend/app/api/ai_steward_api/routes_rag_sources.py
+++ b/backend/app/api/ai_steward_api/routes_rag_sources.py
@@ -4,29 +4,6 @@ from .shared import *
from .helpers import _ensure_project_signal_link
from .schemas import SignalSourceListResponse, SignalSourceOnboardRequest, SignalSourceRepairRequest
-@router.post("/rag/ask")
-async def ask_ai_steward_rag(
- request: RAGAskRequest,
- current_user: User = Depends(get_current_user),
- db: AsyncSession = Depends(get_db),
- tenant_id: int = Depends(get_tenant_id),
-):
- effective_tenant_id = int(getattr(current_user, "tenant_id", tenant_id) or tenant_id)
- service = AdaptiveRAGService(db, tenant_id=effective_tenant_id)
- result = await service.answer_question(
- question=request.question,
- asked_by=int(current_user.id),
- asked_via="pmflow",
- project_id=request.project_id,
- source_chat_id=request.source_chat_id,
- session_id=request.session_id,
- force_new_session=bool(request.new_session),
- thinking_mode=request.thinking_mode,
- action_mode=request.action_mode,
- sync_requirement=bool(request.sync_requirement),
- )
- return success_response(result)
-
@router.post("/signal-sources/onboard")
@audited_write(caller="user")
async def onboard_signal_source(
@@ -148,4 +125,3 @@ async def repair_dirty_signal_sources(
"items": rows,
}
)
-
diff --git a/backend/app/api/ai_steward_api/shared.py b/backend/app/api/ai_steward_api/shared.py
index 65df3d53..aeb4d426 100644
--- a/backend/app/api/ai_steward_api/shared.py
+++ b/backend/app/api/ai_steward_api/shared.py
@@ -15,8 +15,6 @@ from ...dependencies.auth import get_current_user
from ...dependencies.permissions import require_manager_user
from ...dependencies.tenant import get_tenant_id
from ...models import ProjectSignalLink, Signal, User
-from ...schemas.rag import RAGAskRequest
-from ...services.adaptive_rag_service import AdaptiveRAGService
from ...services.ai_orchestration_run import AIOrchestrationRunService
from ...services.ai_steward_service import AIStewardService, get_ai_steward_service
from ...services.blocker_detection_core import get_blocker_detection_service
diff --git a/backend/app/api/rag_api/routes_query.py b/backend/app/api/rag_api/routes_query.py
index ea3175bc..ce88fade 100644
--- a/backend/app/api/rag_api/routes_query.py
+++ b/backend/app/api/rag_api/routes_query.py
@@ -2,6 +2,8 @@ from __future__ import annotations
from .shared import *
from .helpers import _resolve_tenant_id
+from .. import rag_ask_contract
+from ...services.ai_pmo.ai_pmo_kernel import AIPMOKernelService
@router.post("/ask")
async def rag_ask(
@@ -10,20 +12,24 @@ async def rag_ask(
db: AsyncSession = Depends(get_db),
tenant_id: int = Depends(get_tenant_id),
):
- service = AdaptiveRAGService(db, tenant_id=_resolve_tenant_id(current_user, tenant_id))
- result = await service.answer_question(
- question=request.question,
- asked_by=int(current_user.id),
- asked_via=request.asked_via,
- project_id=request.project_id,
- source_chat_id=request.source_chat_id,
- session_id=request.session_id,
- force_new_session=bool(request.new_session),
- thinking_mode=request.thinking_mode,
- action_mode=request.action_mode,
- sync_requirement=bool(request.sync_requirement),
+ service = AIPMOKernelService(
+ db,
+ tenant_id=rag_ask_contract.resolve_effective_tenant_id(current_user, tenant_id),
+ )
+ ingress_result = await service.process_ingress(
+ **rag_ask_contract.build_kernel_ingress_kwargs(
+ request=request,
+ current_user=current_user,
+ )
+ )
+ if not bool(ingress_result.get("handled")):
+ raise HTTPException(
+ status_code=400,
+ detail=str(ingress_result.get("reason") or "ai_pmo_kernel_not_handled"),
+ )
+ return success_response(
+ rag_ask_contract.build_rag_response_payload(ingress_result=ingress_result),
)
- return success_response(result)
@router.get("/manifest")
async def rag_manifest(
@@ -133,4 +139,3 @@ async def rag_symbol_detail(
except ValueError as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc
return success_response(data)
-
diff --git a/backend/app/api/rag_ask_contract.py b/backend/app/api/rag_ask_contract.py
new file mode 100644
index 00000000..41f9f2ae
--- /dev/null
+++ b/backend/app/api/rag_ask_contract.py
@@ -0,0 +1,90 @@
+from __future__ import annotations
+
+from typing import Any
+
+from ..models import User
+from ..schemas.rag import RAGAskRequest
+
+
+def resolve_effective_tenant_id(current_user: User, fallback_tenant_id: int) -> int:
+ try:
+ return int(getattr(current_user, "tenant_id") or fallback_tenant_id or 1)
+ except Exception:
+ return int(fallback_tenant_id or 1)
+
+
+def build_answer_question_kwargs(request: RAGAskRequest, current_user: User) -> dict[str, Any]:
+ asked_by = int(getattr(current_user, "id"))
+ return {
+ "question": request.question,
+ "asked_by": asked_by,
+ "asked_via": request.asked_via,
+ "project_id": request.project_id,
+ "source_chat_id": request.source_chat_id,
+ "session_id": request.session_id,
+ "force_new_session": bool(request.new_session),
+ "thinking_mode": request.thinking_mode,
+ "action_mode": request.action_mode,
+ "sync_requirement": bool(request.sync_requirement),
+ "message_intent": request.message_intent,
+ "mixed_signal_part": request.mixed_signal_part,
+ }
+
+
+def _safe_int(value: Any) -> int | None:
+ try:
+ if value is None:
+ return None
+ return int(value)
+ except Exception:
+ return None
+
+
+def _resolve_sender_name(current_user: User) -> str:
+ sender = str(
+ getattr(current_user, "name", None)
+ or getattr(current_user, "username", None)
+ or "PMFlow用户"
+ ).strip()
+ return sender or "PMFlow用户"
+
+
+def build_kernel_ingress_kwargs(request: RAGAskRequest, current_user: User) -> dict[str, Any]:
+ rag_request_context = build_answer_question_kwargs(request=request, current_user=current_user)
+ return {
+ "source_system": str(request.asked_via or "pmflow").strip() or "pmflow",
+ "source_message_id": None,
+ "message_text": request.question,
+ "sender": _resolve_sender_name(current_user),
+ "channel_ref": request.source_chat_id,
+ "source_sender_open_id": None,
+ "captured_at": None,
+ "chat_type": "manual",
+ "project_hint_id": request.project_id,
+ "ingress_options": {
+ "rag_request_context": rag_request_context,
+ },
+ }
+
+
+def build_rag_response_payload(*, ingress_result: dict[str, Any]) -> dict[str, Any]:
+ result = ingress_result if isinstance(ingress_result, dict) else {}
+ rag_result = result.get("rag_result") if isinstance(result.get("rag_result"), dict) else {}
+ payload = dict(rag_result)
+
+ answer = str(payload.get("answer") or result.get("direct_reply_text") or "").strip()
+ payload["answer"] = answer or "当前暂无可用回答。"
+
+ if "reply_required" not in payload:
+ payload["reply_required"] = bool(result.get("direct_reply_required"))
+ if "reply_text" not in payload:
+ payload["reply_text"] = str(result.get("direct_reply_text") or "").strip() or None
+ if "trace_id" not in payload:
+ payload["trace_id"] = str(result.get("ai_trace_id") or "").strip() or None
+ if "processing_stage" not in payload:
+ payload["processing_stage"] = "qa"
+
+ payload["work_item_id"] = _safe_int(result.get("work_item_id"))
+ payload["ingress_status"] = str(result.get("status") or "").strip() or None
+ payload["intent"] = str(result.get("intent") or "").strip() or None
+ return payload
diff --git a/backend/app/config/ai_prompts.py b/backend/app/config/ai_prompts.py
index 4c9f0005..686b037a 100644
--- a/backend/app/config/ai_prompts.py
+++ b/backend/app/config/ai_prompts.py
@@ -98,6 +98,7 @@ __all__ = [
"PROMPT_AI_WORKER_SIGNAL_SOURCE_EXECUTION",
"PROMPT_AI_PMO_INTENT_CLASSIFICATION",
"PROMPT_AI_PMO_SIGNAL_BATCH_ANALYSIS",
+ "PROMPT_DRAFT_GENERATOR",
"PROMPT_RAG_INTENT_CLASSIFICATION",
"PROMPT_RAG_ANSWER",
"PROMPT_RAG_DB_PROJECT_QA",
diff --git a/backend/app/config/ai_prompts_catalog/__init__.py b/backend/app/config/ai_prompts_catalog/__init__.py
index 3419ac31..6daddc2b 100644
--- a/backend/app/config/ai_prompts_catalog/__init__.py
+++ b/backend/app/config/ai_prompts_catalog/__init__.py
@@ -1,7 +1,7 @@
from __future__ import annotations
from .part_01 import PROMPT_IMPACT_ANALYSIS, PROMPT_TASK_BREAKDOWN, PROMPT_STRATEGIC_ANALYSIS_HEADER, PROMPT_RAG_CONTEXT_HEADER, PROMPT_RAG_CONTEXT_ITEM, PROMPT_CODE_AUDIT, PROMPT_CONTEXT_INSIGHTS, PROMPT_FEISHU_REQUIREMENT_ANALYSIS, PROMPT_FEISHU_HUMANIZED_REPLY, PROMPT_FEISHU_PROJECT_SIGNAL_CLASSIFICATION, PROMPT_FEISHU_LEVEL_CLASSIFICATION
-from .part_02 import PROMPT_FEISHU_SIGNAL_EXTRACTION, PROMPT_FEISHU_PROJECT_PREFILL, PROMPT_FEISHU_TASK_ORCHESTRATION, PROMPT_SIGNAL_SOURCE_ONBOARDING, PROMPT_AI_WORKER_SIGNAL_SOURCE_EXECUTION, PROMPT_AI_PMO_INTENT_CLASSIFICATION, PROMPT_AI_PMO_SIGNAL_BATCH_ANALYSIS, PROMPT_RAG_INTENT_CLASSIFICATION
+from .part_02 import PROMPT_FEISHU_SIGNAL_EXTRACTION, PROMPT_FEISHU_PROJECT_PREFILL, PROMPT_FEISHU_TASK_ORCHESTRATION, PROMPT_SIGNAL_SOURCE_ONBOARDING, PROMPT_AI_WORKER_SIGNAL_SOURCE_EXECUTION, PROMPT_AI_PMO_INTENT_CLASSIFICATION, PROMPT_AI_PMO_SIGNAL_BATCH_ANALYSIS, PROMPT_DRAFT_GENERATOR, PROMPT_RAG_INTENT_CLASSIFICATION
from .part_03 import PROMPT_RAG_ANSWER, PROMPT_RAG_DB_PROJECT_QA, PROMPT_GENERATE_CHARTER, PROMPT_DECOMPOSE_TASKS, PROMPT_RECOMMEND_ASSIGNEES, PROMPT_ALIGNMENT_SCORE, PROMPT_COGNITIVE_CONFLICTS, PROMPT_EVALUATE_MODEL_REQUIREMENT, PROMPT_ANALYZE_CAPABILITY_GAP, PROMPT_ANALYZE_COST_ROI
from .part_04 import PROMPT_ANALYZE_COST_OPTIMIZATION, PROMPT_ASSESS_MODEL_UPDATE_IMPACT, PROMPT_EXPERIMENT_SYNC_DECISION, PROMPT_SKILL_SCORE_UPDATE, PROMPT_WORKLOAD_OPTIMIZATION, PROMPT_ANALYZE_FOOD_CALORIES, PROMPT_SIGNAL_PLANNER
from .part_scenes_01 import PROMPT_FEISHU_DIRECT_QA
@@ -30,6 +30,7 @@ __all__ = [
"PROMPT_AI_WORKER_SIGNAL_SOURCE_EXECUTION",
"PROMPT_AI_PMO_INTENT_CLASSIFICATION",
"PROMPT_AI_PMO_SIGNAL_BATCH_ANALYSIS",
+ "PROMPT_DRAFT_GENERATOR",
"PROMPT_RAG_INTENT_CLASSIFICATION",
"PROMPT_RAG_ANSWER",
"PROMPT_RAG_DB_PROJECT_QA",
diff --git a/backend/app/config/ai_prompts_catalog/part_02.py b/backend/app/config/ai_prompts_catalog/part_02.py
index 0f747348..8efecd84 100644
--- a/backend/app/config/ai_prompts_catalog/part_02.py
+++ b/backend/app/config/ai_prompts_catalog/part_02.py
@@ -166,9 +166,9 @@ PROMPT_AI_PMO_INTENT_CLASSIFICATION = """
只输出一个 JSON 对象(不要解释、不要 Markdown):
{{
- "family": "query|project_create|project_signal|signal_source",
+ "family": "query|project_create|task|project_signal|signal_source",
"action": "answer|create|discover|onboard",
- "intent": "query.read_only|project.create|signal.create|signal_source.onboard|followup.continue",
+ "intent": "query.read_only|project.create|task.create|signal.create|signal_source.onboard|followup.continue",
"confidence": 0.0,
"project_id": 0,
"requested_count": 1,
@@ -176,16 +176,26 @@ PROMPT_AI_PMO_INTENT_CLASSIFICATION = """
"reasoning": "一句话简述判定原因"
}}
-快速规则(按优先级):
-1. 含“分析/梳理/方案/设计思路/帮我分析/先分析” => family=query, action=answer, intent=query.read_only。
-2. 含“创建/新建/新增 + 项目”,且不含第1条分析诉求 => family=project_create, action=create, intent=project.create。
-3. 含“新增信号/录入信号/创建信号” => family=project_signal, action=create, intent=signal.create。
-4. 含“先分析候选/拆分候选并确认” => family=project_signal, action=discover, intent=signal.create, requires_confirmation=true。
-5. 含“信号源/来源/webhook/rss/github 接入” => family=signal_source, action=onboard, intent=signal_source.onboard。
-6. 若消息为短确认语气,可结合 session_last_intent;无把握时返回 query.answer。
-7. project_id 不确定返回 null;requested_count 默认 1(仅 project_signal 有效,范围 1-5)。
-8. family/action/intent 必须严格使用上面 JSON 示例给定的枚举值,不能自造。
-9. 若请求 DDL(建表/改表/删表),必须返回 query.answer + query.read_only,reasoning 含 ddl_api_required。
+判定规则(按优先级):
+1. query.read_only 定义:仅当用户是在“查询 / 了解 / 分析已有内容”时,才判定为 query.read_only。
+2. 若消息同时包含“新增 / 创建 / 记录 / 提交”等写操作意图,优先判定为对应写操作意图,不要误判为 query.read_only。
+3. project.create:当用户明确表达“创建一个新项目”时,返回 family=project_create, action=create, intent=project.create。
+4. task.create:当用户明确表达“创建/新增/安排任务”时,返回 family=task, action=create, intent=task.create。
+5. signal.create 定义:当用户想创建 / 新增 / 录入 / 提交一条新的需求、功能、信号、想法或问题时,返回 family=project_signal, action=create, intent=signal.create。核心特征是“要产生一条新记录”,不管用户用什么词表达。
+6. signal.create few-shot:
+ - “给XX项目加个需求” -> signal.create
+ - “新增一个功能需求” -> signal.create
+ - “帮我记录这个想法” -> signal.create
+ - “提个需求:支持导出” -> signal.create
+7. project_signal discover:当用户表达“先分析候选 / 拆分候选并确认”时,返回 family=project_signal, action=discover, intent=signal.create, requires_confirmation=true。
+8. signal_source.onboard:当用户表达“信号源 / 来源 / webhook / rss / github 接入”时,返回 family=signal_source, action=onboard, intent=signal_source.onboard。
+9. 若 session_last_intent=draft_pending_confirm 且消息含“确认/好的/就这样/行/ok”,优先返回 followup.continue(用于草稿确认执行)。
+10. 若 session_last_intent=draft_pending_confirm 且消息含“修改/改成/换成”,返回 followup.continue(用于草稿重起草)。
+11. 若 session_last_intent=draft_pending_confirm 且消息含“取消/不要/算了”,返回 followup.continue(用于草稿取消)。
+12. 若消息为短确认语气,可结合 session_last_intent;无把握时返回 query.answer。
+13. project_id 不确定返回 null;requested_count 默认 1(仅 project_signal 有效,范围 1-5)。
+14. family/action/intent 必须严格使用上面 JSON 示例给定的枚举值,不能自造。
+15. 若请求 DDL(建表/改表/删表),必须返回 query.answer + query.read_only,reasoning 含 ddl_api_required。
"""
PROMPT_AI_PMO_SIGNAL_BATCH_ANALYSIS = """
@@ -218,6 +228,41 @@ PROMPT_AI_PMO_SIGNAL_BATCH_ANALYSIS = """
4. 不要输出 Markdown,不要解释,不要代码块。
""".strip()
+PROMPT_DRAFT_GENERATOR = """
+首字符必须是 {{,末字符必须是 }}。
+你是 PMFlow 的资深产品经理。
+收到一条新的业务需求,请起草分析报告。
+
+输入:
+- 项目信息:{project_title} - {project_description}
+- 团队成员(按负载排序):{members_json}
+- 最近同类需求:{recent_signals}
+- 用户需求:{user_message}
+- 意图:{intent_name}
+- 修改意见(若为空表示首次起草):{revision_note}
+
+请输出 JSON,包含:
+- title: 语义化标题(10字以内,必须概括目标,不得复述原话)
+- summary: 需求摘要(50字以内)
+- feasibility: 合理|待讨论|不合理
+- feasibility_reason: 判断理由(30字以内)
+- recommended_assignee_id: 推荐成员ID
+- recommended_assignee_reason: 推荐理由
+- followup_plan: 数组,每项含 when 和 action
+
+约束:
+1. 只输出 JSON,不要解释,不要思考过程,不要 Markdown。
+2. title 必须是语义化摘要,不能包含原始消息中的原话或长片段复述。示例:可写“劝退后次日跟进机制”,不要写“给AI劝退助手加个需求...”。
+3. feasibility 判定规则:
+ - 明确的功能需求且技术可实现 => 合理
+ - 需求模糊、边界冲突或依赖不清 => 待讨论
+ - 明显不可行或超出系统范围 => 不合理
+4. 推荐负责人优先选择负载低于 80% 的成员。
+5. 若所有成员负载都 >= 80%,推荐负载最低者,并在 recommended_assignee_reason 明确写出负载风险。
+6. followup_plan 最少 1 项,最多 3 项。
+7. 若无法确定推荐人,recommended_assignee_id 返回 null。
+""".strip()
+
PROMPT_RAG_INTENT_CLASSIFICATION = """
你是 PMFlow 的意图分类器。请判断输入文本属于:question / statement / mixed。
@@ -243,5 +288,6 @@ __all__ = [
"PROMPT_AI_WORKER_SIGNAL_SOURCE_EXECUTION",
"PROMPT_AI_PMO_INTENT_CLASSIFICATION",
"PROMPT_AI_PMO_SIGNAL_BATCH_ANALYSIS",
+ "PROMPT_DRAFT_GENERATOR",
"PROMPT_RAG_INTENT_CLASSIFICATION",
]
diff --git a/backend/app/core/startup/draft_expiry_worker.py b/backend/app/core/startup/draft_expiry_worker.py
new file mode 100644
index 00000000..56023e84
--- /dev/null
+++ b/backend/app/core/startup/draft_expiry_worker.py
@@ -0,0 +1,114 @@
+"""Background worker for expiring stale draft confirmations."""
+
+from __future__ import annotations
+
+import asyncio
+from typing import Any
+
+from sqlalchemy import and_, select
+
+from ..database import AsyncSessionLocal
+from ..logging import get_logger
+from ...models import DecisionRecord, IngestEvent, WorkItem
+from ...services.ai_pmo.ai_pmo_kernel_common_core.constants import STATE_CANCELLED, STATE_DRAFT_PENDING_CONFIRM
+from ...services.integrations.feishu_outbox import FeishuOutboxService
+from ...utils.timezone import now_cn_naive
+
+
+logger = get_logger(__name__)
+_DRAFT_EXPIRY_SCAN_INTERVAL_SECONDS = 60 * 60
+_DRAFT_EXPIRY_BATCH_SIZE = 200
+
+
+async def _cancel_expired_drafts_once(limit: int = _DRAFT_EXPIRY_BATCH_SIZE) -> int:
+ async with AsyncSessionLocal() as db:
+ now_at = now_cn_naive()
+ rows = (
+ await db.execute(
+ select(WorkItem, IngestEvent)
+ .join(IngestEvent, IngestEvent.id == WorkItem.ingest_event_id)
+ .where(
+ and_(
+ WorkItem.state == STATE_DRAFT_PENDING_CONFIRM,
+ WorkItem.draft_expires_at.is_not(None),
+ WorkItem.draft_expires_at <= now_at,
+ )
+ )
+ .order_by(WorkItem.id.asc())
+ .limit(max(1, int(limit)))
+ )
+ ).all()
+
+ cancelled_count = 0
+ for work_item, ingest in rows:
+ payload = ingest.payload if isinstance(ingest.payload, dict) else {}
+ draft_payload = work_item.draft_content if isinstance(work_item.draft_content, dict) else {}
+ original_text = str(draft_payload.get("original_text") or payload.get("message_text") or "").strip()
+
+ work_item.state = STATE_CANCELLED
+ work_item.draft_expires_at = None
+ work_item.reply_template_key = "draft_timeout_cancelled"
+ work_item.execution_result = {
+ **(work_item.execution_result if isinstance(work_item.execution_result, dict) else {}),
+ "status": "cancelled",
+ "cancel_reason": "draft_expired",
+ "cancelled_at": now_at.isoformat(),
+ }
+
+ decision = (
+ await db.execute(
+ select(DecisionRecord)
+ .where(
+ and_(
+ DecisionRecord.tenant_id == int(work_item.tenant_id),
+ DecisionRecord.work_item_id == int(work_item.id),
+ )
+ )
+ .order_by(DecisionRecord.id.desc())
+ .limit(1)
+ )
+ ).scalar_one_or_none()
+ if decision is not None:
+ decision.execution_result = work_item.execution_result
+ decision.final_outcome = STATE_CANCELLED
+
+ source_system = str(work_item.source_system or "").strip().lower()
+ source_channel = str(payload.get("channel_ref") or "").strip()
+ if source_system == "feishu" and source_channel:
+ message_preview = original_text[:20]
+ if message_preview:
+ timeout_text = (
+ "⏰ 草稿已超时取消\n\n"
+ f"需求\"{message_preview}...\"\n"
+ "已自动取消,如需提交请重新发送。"
+ )
+ else:
+ timeout_text = "⏰ 草稿已超时取消,已自动取消,如需提交请重新发送。"
+ await FeishuOutboxService.enqueue(
+ db,
+ tenant_id=int(work_item.tenant_id),
+ chat_id=source_channel,
+ text=timeout_text,
+ source_message_id=str(work_item.source_message_id or "").strip() or None,
+ dedupe_seed=f"draft-expired:{int(work_item.id)}",
+ )
+ cancelled_count += 1
+
+ if cancelled_count > 0:
+ await db.commit()
+ logger.info("draft_expiry_worker_cancelled", count=cancelled_count)
+ else:
+ await db.rollback()
+ return cancelled_count
+
+
+async def draft_expiry_drain_loop() -> None:
+ while True:
+ try:
+ await _cancel_expired_drafts_once()
+ await asyncio.sleep(_DRAFT_EXPIRY_SCAN_INTERVAL_SECONDS)
+ except asyncio.CancelledError:
+ break
+ except Exception as exc:
+ logger.warning("draft_expiry_worker_failed", error=str(exc)[:500])
+ await asyncio.sleep(30)
diff --git a/backend/app/main.py b/backend/app/main.py
index 5216931e..a8745cf3 100644
--- a/backend/app/main.py
+++ b/backend/app/main.py
@@ -16,6 +16,7 @@ from .core.response import success_response
from .core.database import AsyncSessionLocal, engine
from .core.startup.db_guard import check_database_dialect_guard
from .core.startup.ai_pmo_ingress_async import ai_pmo_ingress_drain_loop
+from .core.startup.draft_expiry_worker import draft_expiry_drain_loop
from .core.startup.feishu_outbox import feishu_outbox_drain_loop
from .core.startup.model_probe import probe_model_connectivity_on_startup, startup_log
from .core.startup.world_model_warmup import warmup_world_models
@@ -87,6 +88,7 @@ async def lifespan(_: FastAPI):
outbox_task = asyncio.create_task(feishu_outbox_drain_loop(), name="feishu-outbox-drain-loop")
ingress_task = asyncio.create_task(ai_pmo_ingress_drain_loop(), name="ai-pmo-ingress-drain-loop")
+ draft_expiry_task = asyncio.create_task(draft_expiry_drain_loop(), name="draft-expiry-drain-loop")
wm_warmup_task = asyncio.create_task(warmup_world_models(), name="wm-world-model-warmup")
startup_log("后端运行中", url=f"http://127.0.0.1:{backend_port}")
@@ -105,6 +107,12 @@ async def lifespan(_: FastAPI):
except asyncio.CancelledError:
pass
+ draft_expiry_task.cancel()
+ try:
+ await draft_expiry_task
+ except asyncio.CancelledError:
+ pass
+
if not wm_warmup_task.done():
wm_warmup_task.cancel()
with suppress(asyncio.CancelledError):
diff --git a/backend/app/models/work_item.py b/backend/app/models/work_item.py
index 8fe87525..4d688563 100644
--- a/backend/app/models/work_item.py
+++ b/backend/app/models/work_item.py
@@ -2,9 +2,10 @@
from __future__ import annotations
+from datetime import datetime
from typing import Any, Optional
-from sqlalchemy import BigInteger, ForeignKey, Integer, UniqueConstraint, VARCHAR
+from sqlalchemy import BigInteger, ForeignKey, Integer, TIMESTAMP, UniqueConstraint, VARCHAR
from sqlalchemy.orm import Mapped, mapped_column
from .base import Base
@@ -54,4 +55,7 @@ class WorkItem(Base):
execution_result: Mapped[Optional[Any]] = mapped_column(JSONB, nullable=True)
review_summary: Mapped[Optional[Any]] = mapped_column(JSONB, nullable=True)
reply_template_key: Mapped[Optional[str]] = mapped_column(VARCHAR(64), nullable=True)
-
+ draft_content: Mapped[Optional[Any]] = mapped_column(JSONB, nullable=True)
+ draft_expires_at: Mapped[Optional[datetime]] = mapped_column(TIMESTAMP, nullable=True, index=True)
+ draft_version: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
+ confirm_message_id: Mapped[Optional[str]] = mapped_column(VARCHAR(128), nullable=True, index=True)
diff --git a/backend/app/schemas/rag.py b/backend/app/schemas/rag.py
index 3eef117e..cf665740 100644
--- a/backend/app/schemas/rag.py
+++ b/backend/app/schemas/rag.py
@@ -3,21 +3,66 @@
from __future__ import annotations
from datetime import datetime
-from typing import Optional
+from typing import ClassVar, Optional
-from pydantic import BaseModel, Field
+from pydantic import BaseModel, Field, field_validator
class RAGAskRequest(BaseModel):
+ _ALLOWED_ASKED_VIA: ClassVar[set[str]] = {
+ "pmflow",
+ "feishu",
+ "lark",
+ "feishu_group",
+ "feishu_dm",
+ "pmflow_manual",
+ "/ai-pmo/process-text",
+ "ai-pmo-process-text",
+ "ai_pmo",
+ "ai_pmo_ui",
+ }
+ _ASKED_VIA_ALIAS: ClassVar[dict[str, str]] = {
+ "ai-pmo-process-text": "/ai-pmo/process-text",
+ }
question: str = Field(..., min_length=1, max_length=4000)
project_id: Optional[int] = Field(default=None)
- asked_via: str = Field(default="pmflow", pattern="^(pmflow|feishu)$")
+ asked_via: str = Field(default="pmflow", min_length=1, max_length=32)
source_chat_id: Optional[str] = Field(default=None, max_length=128)
session_id: Optional[int] = Field(default=None, ge=1)
new_session: bool = Field(default=False)
thinking_mode: str = Field(default="normal", pattern="^(normal|deep)$")
action_mode: str = Field(default="auto", pattern="^(auto|read|write|read_write)$")
sync_requirement: bool = Field(default=True)
+ message_intent: Optional[str] = Field(default=None, max_length=32)
+ mixed_signal_part: Optional[str] = Field(default=None, max_length=2000)
+
+ @field_validator("asked_via", mode="before")
+ @classmethod
+ def _normalize_asked_via(cls, value: object) -> str:
+ text = str(value or "pmflow").strip().lower()
+ text = cls._ASKED_VIA_ALIAS.get(text, text)
+ if text not in cls._ALLOWED_ASKED_VIA:
+ allowed = ", ".join(sorted(cls._ALLOWED_ASKED_VIA))
+ raise ValueError(f"asked_via must be one of: {allowed}")
+ return text
+
+ @field_validator("message_intent", mode="before")
+ @classmethod
+ def _normalize_message_intent(cls, value: object) -> Optional[str]:
+ if value is None:
+ return None
+ text = str(value).strip().lower()
+ if not text:
+ return None
+ return text
+
+ @field_validator("mixed_signal_part", mode="before")
+ @classmethod
+ def _normalize_mixed_signal_part(cls, value: object) -> Optional[str]:
+ if value is None:
+ return None
+ text = str(value).strip()
+ return text or None
class KnowledgeDocCreateRequest(BaseModel):
diff --git a/backend/app/services/adaptive_rag_service/mixin_answer_entry.py b/backend/app/services/adaptive_rag_service/mixin_answer_entry.py
index 9b4162f5..86795c58 100644
--- a/backend/app/services/adaptive_rag_service/mixin_answer_entry.py
+++ b/backend/app/services/adaptive_rag_service/mixin_answer_entry.py
@@ -27,6 +27,19 @@ class AdaptiveRAGAnswerEntryMixin:
"ai_pmo_ui",
}
+ @staticmethod
+ def _should_trigger_write_ack_gate(
+ *,
+ sync_requirement: bool,
+ asked_via: str,
+ effective_action_mode: str,
+ ) -> bool:
+ return (
+ bool(sync_requirement)
+ and str(asked_via or "").strip().lower() != "feishu"
+ and str(effective_action_mode or "").strip().lower() in {ACTION_MODE_WRITE, ACTION_MODE_READ_WRITE}
+ )
+
async def answer_question(
self,
*,
@@ -46,11 +59,6 @@ class AdaptiveRAGAnswerEntryMixin:
) -> dict[str, Any]:
started = time.perf_counter()
clean_q = str(question or "").strip()
- external_conversation_key = self._build_external_conversation_key(
- asked_via=asked_via,
- source_chat_id=source_chat_id,
- conversation_key=conversation_key,
- )
active_thinking_mode = self._normalize_thinking_mode(thinking_mode)
deep_thinking = active_thinking_mode == THINKING_MODE_DEEP
asked_via_lower = str(asked_via or "").strip().lower()
@@ -84,46 +92,31 @@ class AdaptiveRAGAnswerEntryMixin:
thinking_mode=active_thinking_mode,
)
- session_id, turn_index = await self._touch_session_for_new_record(
- asked_by=asked_by,
- asked_via=asked_via,
- project_id=project_id,
- question=clean_q,
- preferred_session_id=session_id,
- force_new_session=bool(force_new_session),
- )
- context_session_id: Optional[int]
- if session_id is not None:
- context_session_id = int(session_id)
- elif asked_by is None:
- context_session_id = self._external_session_id_from_key(external_conversation_key)
- else:
- context_session_id = None
- conversation = await self._load_recent_conversation(
+ runtime = await self._prepare_answer_runtime(
+ clean_q=clean_q,
asked_by=asked_by,
asked_via=asked_via,
project_id=project_id,
+ source_chat_id=source_chat_id,
session_id=session_id,
- external_session_id=context_session_id,
- limit=10,
+ conversation_key=conversation_key,
+ force_new_session=force_new_session,
+ action_mode=action_mode,
)
- state_row = await self._ensure_session_state(session_id=session_id)
- effective_q, rewrite_meta = self._rewrite_question_with_state(
- question=clean_q,
- conversation=conversation,
- session_state=state_row,
- )
- query_type = self._classify_query_type(effective_q)
- requested_action_mode = self._normalize_action_mode(action_mode)
- effective_action_mode = self._resolve_action_mode(
- requested_mode=requested_action_mode,
- question=clean_q,
- effective_question=effective_q,
- )
- if (
- bool(sync_requirement)
- and asked_via_lower != "feishu"
- and effective_action_mode in {ACTION_MODE_WRITE, ACTION_MODE_READ_WRITE}
+ session_id = runtime["session_id"]
+ turn_index = int(runtime["turn_index"] or 1)
+ context_session_id = runtime["context_session_id"]
+ conversation = runtime["conversation"]
+ state_row = runtime["state_row"]
+ effective_q = str(runtime["effective_q"] or "").strip()
+ rewrite_meta = runtime["rewrite_meta"] if isinstance(runtime["rewrite_meta"], dict) else {}
+ query_type = str(runtime["query_type"] or QUERY_TYPE_UNKNOWN)
+ effective_action_mode = str(runtime["effective_action_mode"] or ACTION_MODE_AUTO).strip().lower()
+
+ if self._should_trigger_write_ack_gate(
+ sync_requirement=sync_requirement,
+ asked_via=asked_via,
+ effective_action_mode=effective_action_mode,
):
ack_result = await self._handle_ack_first_requirement(
question=clean_q,
@@ -142,132 +135,8 @@ class AdaptiveRAGAnswerEntryMixin:
)
if ack_result is not None:
return ack_result
- forced_deep_reason = ""
- if not deep_thinking:
- forced_deep_reason = self._forced_deep_reason(
- question=clean_q,
- effective_question=effective_q,
- conversation=conversation,
- )
- if forced_deep_reason and self._should_force_deep_for_channel(
- asked_via=asked_via,
- question=clean_q,
- effective_question=effective_q,
- reason=forced_deep_reason,
- ):
- deep_thinking = True
- active_thinking_mode = THINKING_MODE_DEEP
- else:
- forced_deep_reason = ""
-
- clarification_answer = self._build_clarification_question(
- original_question=clean_q,
- effective_question=effective_q,
- session_state=state_row,
- )
- clarification_result = await self._maybe_return_clarification_response(
- clarification_answer=clarification_answer,
- started=started,
+ return await self._execute_base_answer_mainline(
clean_q=clean_q,
- effective_q=effective_q,
- query_type=query_type,
- asked_by=asked_by,
- asked_via=asked_via,
- project_id=project_id,
- session_id=session_id,
- turn_index=turn_index,
- state_row=state_row,
- rewrite_meta=rewrite_meta,
- context_session_id=context_session_id,
- active_thinking_mode=active_thinking_mode,
- )
- if clarification_result is not None:
- return clarification_result
-
- system_snapshot = await self.context_cache.get_system_snapshot(
- db=self.db,
- tenant_id=self.tenant_id,
- )
- user_snapshot = await self.context_cache.get_user_snapshot(
- db=self.db,
- tenant_id=self.tenant_id,
- user_id=asked_by,
- )
-
- if not deep_thinking and not enforce_scene_prompt_only:
- deterministic_result = await self._run_deterministic_query(
- question=effective_q,
- project_id=project_id,
- query_type=query_type,
- )
- if deterministic_result is not None:
- return await self._persist_fast_answer(
- question=clean_q,
- asked_by=asked_by,
- asked_via=asked_via,
- project_id=project_id,
- fast_result=deterministic_result,
- session_id=session_id,
- external_session_id=context_session_id,
- turn_index=turn_index,
- query_type=str(deterministic_result.get("query_type") or query_type),
- missing_fields=list(deterministic_result.get("missing_fields") or []),
- state_row=state_row,
- effective_question=effective_q,
- rewrite_meta=rewrite_meta,
- thinking_mode=active_thinking_mode,
- )
-
- direct_answer = None
- if not deep_thinking and not enforce_scene_prompt_only:
- direct_answer = self._build_snapshot_direct_answer(
- effective_q,
- system_snapshot=system_snapshot,
- user_snapshot=user_snapshot,
- )
- direct_result = await self._maybe_return_snapshot_direct_response(
- direct_answer=direct_answer,
- started=started,
- clean_q=clean_q,
- effective_q=effective_q,
- asked_by=asked_by,
- asked_via=asked_via,
- project_id=project_id,
- session_id=session_id,
- turn_index=turn_index,
- state_row=state_row,
- rewrite_meta=rewrite_meta,
- context_session_id=context_session_id,
- active_thinking_mode=active_thinking_mode,
- user_snapshot=user_snapshot,
- )
- if direct_result is not None:
- return direct_result
-
- is_snapshot_direct_question = self._is_snapshot_direct_question(effective_q)
- if not deep_thinking and not enforce_scene_prompt_only and not is_snapshot_direct_question:
- glm_semantic_result = await self._try_glm_project_semantic_answer(clean_q=effective_q, project_id=project_id)
- if glm_semantic_result is not None:
- return await self._persist_fast_answer(
- question=clean_q,
- asked_by=asked_by,
- asked_via=asked_via,
- project_id=project_id,
- fast_result=glm_semantic_result,
- session_id=session_id,
- external_session_id=context_session_id,
- turn_index=turn_index,
- query_type=query_type if query_type != QUERY_TYPE_UNKNOWN else QUERY_TYPE_EXPLAIN,
- missing_fields=[],
- state_row=state_row,
- effective_question=effective_q,
- rewrite_meta=rewrite_meta,
- thinking_mode=active_thinking_mode,
- )
-
- return await self._build_retrieval_response(
- clean_q=clean_q,
- effective_q=effective_q,
asked_by=asked_by,
asked_via=asked_via,
project_id=project_id,
@@ -277,16 +146,15 @@ class AdaptiveRAGAnswerEntryMixin:
context_session_id=context_session_id,
conversation=conversation,
state_row=state_row,
+ effective_q=effective_q,
rewrite_meta=rewrite_meta,
query_type=query_type,
thinking_mode=thinking_mode,
active_thinking_mode=active_thinking_mode,
deep_thinking=deep_thinking,
- forced_deep_reason=forced_deep_reason,
+ enforce_scene_prompt_only=enforce_scene_prompt_only,
deep_retrieval_steps=deep_retrieval_steps,
deep_vector_top_k=deep_vector_top_k,
- system_snapshot=system_snapshot,
- user_snapshot=user_snapshot,
started=started,
message_intent=message_intent,
mixed_signal_part=mixed_signal_part,
diff --git a/backend/app/services/adaptive_rag_service/mixin_answer_mainline.py b/backend/app/services/adaptive_rag_service/mixin_answer_mainline.py
new file mode 100644
index 00000000..5132d588
--- /dev/null
+++ b/backend/app/services/adaptive_rag_service/mixin_answer_mainline.py
@@ -0,0 +1,248 @@
+"""Adaptive RAG answer mainline orchestration helpers."""
+from __future__ import annotations
+
+from .shared import *
+
+
+class AdaptiveRAGAnswerMainlineMixin:
+ async def _prepare_answer_runtime(
+ self,
+ *,
+ clean_q: str,
+ asked_by: Optional[int],
+ asked_via: str,
+ project_id: Optional[int],
+ source_chat_id: Optional[str],
+ session_id: Optional[int],
+ conversation_key: Optional[str],
+ force_new_session: bool,
+ action_mode: str,
+ ) -> dict[str, Any]:
+ external_conversation_key = self._build_external_conversation_key(
+ asked_via=asked_via,
+ source_chat_id=source_chat_id,
+ conversation_key=conversation_key,
+ )
+ session_id, turn_index = await self._touch_session_for_new_record(
+ asked_by=asked_by,
+ asked_via=asked_via,
+ project_id=project_id,
+ question=clean_q,
+ preferred_session_id=session_id,
+ force_new_session=bool(force_new_session),
+ )
+ context_session_id: Optional[int]
+ if session_id is not None:
+ context_session_id = int(session_id)
+ elif asked_by is None:
+ context_session_id = self._external_session_id_from_key(external_conversation_key)
+ else:
+ context_session_id = None
+ conversation = await self._load_recent_conversation(
+ asked_by=asked_by,
+ asked_via=asked_via,
+ project_id=project_id,
+ session_id=session_id,
+ external_session_id=context_session_id,
+ limit=10,
+ )
+ state_row = await self._ensure_session_state(session_id=session_id)
+ effective_q, rewrite_meta = self._rewrite_question_with_state(
+ question=clean_q,
+ conversation=conversation,
+ session_state=state_row,
+ )
+ query_type = self._classify_query_type(effective_q)
+ requested_action_mode = self._normalize_action_mode(action_mode)
+ effective_action_mode = self._resolve_action_mode(
+ requested_mode=requested_action_mode,
+ question=clean_q,
+ effective_question=effective_q,
+ )
+ return {
+ "session_id": session_id,
+ "turn_index": turn_index,
+ "context_session_id": context_session_id,
+ "conversation": conversation,
+ "state_row": state_row,
+ "effective_q": effective_q,
+ "rewrite_meta": rewrite_meta,
+ "query_type": query_type,
+ "effective_action_mode": effective_action_mode,
+ }
+
+ async def _execute_base_answer_mainline(
+ self,
+ *,
+ clean_q: str,
+ asked_by: Optional[int],
+ asked_via: str,
+ project_id: Optional[int],
+ source_chat_id: Optional[str],
+ session_id: Optional[int],
+ turn_index: int,
+ context_session_id: Optional[int],
+ conversation: list[dict[str, str]],
+ state_row: Optional[QASessionState],
+ effective_q: str,
+ rewrite_meta: dict[str, Any],
+ query_type: str,
+ thinking_mode: str,
+ active_thinking_mode: str,
+ deep_thinking: bool,
+ enforce_scene_prompt_only: bool,
+ deep_retrieval_steps: int,
+ deep_vector_top_k: int,
+ started: float,
+ message_intent: Optional[str],
+ mixed_signal_part: Optional[str],
+ ) -> dict[str, Any]:
+ forced_deep_reason = ""
+ if not deep_thinking:
+ forced_deep_reason = self._forced_deep_reason(
+ question=clean_q,
+ effective_question=effective_q,
+ conversation=conversation,
+ )
+ if forced_deep_reason and self._should_force_deep_for_channel(
+ asked_via=asked_via,
+ question=clean_q,
+ effective_question=effective_q,
+ reason=forced_deep_reason,
+ ):
+ deep_thinking = True
+ active_thinking_mode = THINKING_MODE_DEEP
+ else:
+ forced_deep_reason = ""
+
+ clarification_answer = self._build_clarification_question(
+ original_question=clean_q,
+ effective_question=effective_q,
+ session_state=state_row,
+ )
+ clarification_result = await self._maybe_return_clarification_response(
+ clarification_answer=clarification_answer,
+ started=started,
+ clean_q=clean_q,
+ effective_q=effective_q,
+ query_type=query_type,
+ asked_by=asked_by,
+ asked_via=asked_via,
+ project_id=project_id,
+ session_id=session_id,
+ turn_index=turn_index,
+ state_row=state_row,
+ rewrite_meta=rewrite_meta,
+ context_session_id=context_session_id,
+ active_thinking_mode=active_thinking_mode,
+ )
+ if clarification_result is not None:
+ return clarification_result
+
+ system_snapshot = await self.context_cache.get_system_snapshot(
+ db=self.db,
+ tenant_id=self.tenant_id,
+ )
+ user_snapshot = await self.context_cache.get_user_snapshot(
+ db=self.db,
+ tenant_id=self.tenant_id,
+ user_id=asked_by,
+ )
+
+ if not deep_thinking and not enforce_scene_prompt_only:
+ deterministic_result = await self._run_deterministic_query(
+ question=effective_q,
+ project_id=project_id,
+ query_type=query_type,
+ )
+ if deterministic_result is not None:
+ return await self._persist_fast_answer(
+ question=clean_q,
+ asked_by=asked_by,
+ asked_via=asked_via,
+ project_id=project_id,
+ fast_result=deterministic_result,
+ session_id=session_id,
+ external_session_id=context_session_id,
+ turn_index=turn_index,
+ query_type=str(deterministic_result.get("query_type") or query_type),
+ missing_fields=list(deterministic_result.get("missing_fields") or []),
+ state_row=state_row,
+ effective_question=effective_q,
+ rewrite_meta=rewrite_meta,
+ thinking_mode=active_thinking_mode,
+ )
+
+ direct_answer = None
+ if not deep_thinking and not enforce_scene_prompt_only:
+ direct_answer = self._build_snapshot_direct_answer(
+ effective_q,
+ system_snapshot=system_snapshot,
+ user_snapshot=user_snapshot,
+ )
+ direct_result = await self._maybe_return_snapshot_direct_response(
+ direct_answer=direct_answer,
+ started=started,
+ clean_q=clean_q,
+ effective_q=effective_q,
+ asked_by=asked_by,
+ asked_via=asked_via,
+ project_id=project_id,
+ session_id=session_id,
+ turn_index=turn_index,
+ state_row=state_row,
+ rewrite_meta=rewrite_meta,
+ context_session_id=context_session_id,
+ active_thinking_mode=active_thinking_mode,
+ user_snapshot=user_snapshot,
+ )
+ if direct_result is not None:
+ return direct_result
+
+ is_snapshot_direct_question = self._is_snapshot_direct_question(effective_q)
+ if not deep_thinking and not enforce_scene_prompt_only and not is_snapshot_direct_question:
+ glm_semantic_result = await self._try_glm_project_semantic_answer(clean_q=effective_q, project_id=project_id)
+ if glm_semantic_result is not None:
+ return await self._persist_fast_answer(
+ question=clean_q,
+ asked_by=asked_by,
+ asked_via=asked_via,
+ project_id=project_id,
+ fast_result=glm_semantic_result,
+ session_id=session_id,
+ external_session_id=context_session_id,
+ turn_index=turn_index,
+ query_type=query_type if query_type != QUERY_TYPE_UNKNOWN else QUERY_TYPE_EXPLAIN,
+ missing_fields=[],
+ state_row=state_row,
+ effective_question=effective_q,
+ rewrite_meta=rewrite_meta,
+ thinking_mode=active_thinking_mode,
+ )
+
+ return await self._build_retrieval_response(
+ clean_q=clean_q,
+ effective_q=effective_q,
+ asked_by=asked_by,
+ asked_via=asked_via,
+ project_id=project_id,
+ source_chat_id=source_chat_id,
+ session_id=session_id,
+ turn_index=turn_index,
+ context_session_id=context_session_id,
+ conversation=conversation,
+ state_row=state_row,
+ rewrite_meta=rewrite_meta,
+ query_type=query_type,
+ thinking_mode=thinking_mode,
+ active_thinking_mode=active_thinking_mode,
+ deep_thinking=deep_thinking,
+ forced_deep_reason=forced_deep_reason,
+ deep_retrieval_steps=deep_retrieval_steps,
+ deep_vector_top_k=deep_vector_top_k,
+ system_snapshot=system_snapshot,
+ user_snapshot=user_snapshot,
+ started=started,
+ message_intent=message_intent,
+ mixed_signal_part=mixed_signal_part,
+ )
diff --git a/backend/app/services/adaptive_rag_service/service.py b/backend/app/services/adaptive_rag_service/service.py
index 3092a2b5..05bec306 100644
--- a/backend/app/services/adaptive_rag_service/service.py
+++ b/backend/app/services/adaptive_rag_service/service.py
@@ -3,6 +3,7 @@ from __future__ import annotations
from .mixin_core_intent import AdaptiveRAGCoreIntentMixin
from .mixin_answer_entry import AdaptiveRAGAnswerEntryMixin
+from .mixin_answer_mainline import AdaptiveRAGAnswerMainlineMixin
from .mixin_answer_precheck import AdaptiveRAGAnswerPrecheckMixin
from .mixin_answer_retrieval import AdaptiveRAGAnswerRetrievalMixin
from .mixin_answer_payload import AdaptiveRAGAnswerPayloadMixin
@@ -43,6 +44,7 @@ from .mixin_conversation_utils_b import AdaptiveRAGConversationUtilsBMixin
class AdaptiveRAGService(
AdaptiveRAGCoreIntentMixin,
AdaptiveRAGAnswerEntryMixin,
+ AdaptiveRAGAnswerMainlineMixin,
AdaptiveRAGAnswerPrecheckMixin,
AdaptiveRAGAnswerRetrievalMixin,
AdaptiveRAGAnswerPayloadMixin,
diff --git a/backend/app/services/ai_orchestration_run/ingress_worker.py b/backend/app/services/ai_orchestration_run/ingress_worker.py
index e098e91e..262a866f 100644
--- a/backend/app/services/ai_orchestration_run/ingress_worker.py
+++ b/backend/app/services/ai_orchestration_run/ingress_worker.py
@@ -141,6 +141,10 @@ async def _dispatch_claimed_ingress_run(*, run_id: int) -> None:
captured_at_value = row.captured_at
source_message_id_value = str(row.source_message_id or "").strip() or None
chat_type_value = str(row.chat_type or "").strip() or None
+ result_payload_value = row.result_payload if isinstance(row.result_payload, dict) else {}
+ task_payload_value = result_payload_value.get("task_payload") if isinstance(result_payload_value.get("task_payload"), dict) else {}
+ message_sent_at_value = task_payload_value.get("message_sent_at")
+ session_snapshot_value = task_payload_value.get("session_snapshot") if isinstance(task_payload_value.get("session_snapshot"), dict) else None
try:
capture_service = ProactiveSignalCaptureService(db)
@@ -177,6 +181,8 @@ async def _dispatch_claimed_ingress_run(*, run_id: int) -> None:
feishu_message_id=source_message_id_value,
chat_type=chat_type_value,
force_sync=True,
+ message_sent_at=message_sent_at_value,
+ session_snapshot=session_snapshot_value,
)
finally:
stop_heartbeat.set()
diff --git a/backend/app/services/ai_pmo/ai_pmo_decision/policy.py b/backend/app/services/ai_pmo/ai_pmo_decision/policy.py
index c60effb0..a3b8c6e1 100644
--- a/backend/app/services/ai_pmo/ai_pmo_decision/policy.py
+++ b/backend/app/services/ai_pmo/ai_pmo_decision/policy.py
@@ -7,6 +7,7 @@ import os
from .shared import (
CanonicalIntent,
INTENT_QUERY,
+ INTENT_TASK_CREATE,
INTENT_SIGNAL_CREATE,
INTENT_SIGNAL_SOURCE_ONBOARD,
re,
@@ -59,7 +60,7 @@ class ExecutionPolicyBrain:
},
}
- if intent.name in {INTENT_QUERY, INTENT_SIGNAL_CREATE}:
+ if intent.name in {INTENT_QUERY, INTENT_TASK_CREATE, INTENT_SIGNAL_CREATE}:
return {
"auto_execute": True,
"policy_gate_hit": False,
diff --git a/backend/app/services/ai_pmo/ai_pmo_decision/shared.py b/backend/app/services/ai_pmo/ai_pmo_decision/shared.py
index 66212919..caf99027 100644
--- a/backend/app/services/ai_pmo/ai_pmo_decision/shared.py
+++ b/backend/app/services/ai_pmo/ai_pmo_decision/shared.py
@@ -10,6 +10,7 @@ from ..ai_pmo_intent import (
INTENT_FOLLOWUP_CONTINUE,
INTENT_PROJECT_CREATE,
INTENT_QUERY,
+ INTENT_TASK_CREATE,
INTENT_SIGNAL_CREATE,
INTENT_SIGNAL_SOURCE_ONBOARD,
ROUTE_PROJECT_SIGNAL_CANCEL,
@@ -46,6 +47,7 @@ __all__ = [
"INTENT_FOLLOWUP_CONTINUE",
"INTENT_PROJECT_CREATE",
"INTENT_QUERY",
+ "INTENT_TASK_CREATE",
"INTENT_SIGNAL_CREATE",
"INTENT_SIGNAL_SOURCE_ONBOARD",
"ROUTE_PROJECT_SIGNAL_CANCEL",
diff --git a/backend/app/services/ai_pmo/ai_pmo_decision/simulation.py b/backend/app/services/ai_pmo/ai_pmo_decision/simulation.py
index 6d9c3299..ffc9f7cb 100644
--- a/backend/app/services/ai_pmo/ai_pmo_decision/simulation.py
+++ b/backend/app/services/ai_pmo/ai_pmo_decision/simulation.py
@@ -19,6 +19,7 @@ class SimulationEngine:
BUDGET_COST_BY_OPERATION = {
"query.answer": 1.0,
"project.create": 120.0,
+ "task.create": 14.0,
"signal.enter": 8.0,
"signal_source.onboard": 40.0,
ROUTE_PROJECT_SIGNAL_DISCOVER: 3.0,
@@ -29,6 +30,7 @@ class SimulationEngine:
HOURS_BY_OPERATION = {
"query.answer": 0.2,
"project.create": 12.0,
+ "task.create": 2.5,
"signal.enter": 1.5,
"signal_source.onboard": 6.0,
ROUTE_PROJECT_SIGNAL_DISCOVER: 0.8,
diff --git a/backend/app/services/ai_pmo/ai_pmo_decision/strategic.py b/backend/app/services/ai_pmo/ai_pmo_decision/strategic.py
index 45e1bfed..5cb031e7 100644
--- a/backend/app/services/ai_pmo/ai_pmo_decision/strategic.py
+++ b/backend/app/services/ai_pmo/ai_pmo_decision/strategic.py
@@ -5,6 +5,7 @@ from __future__ import annotations
from .shared import (
CanonicalIntent,
INTENT_PROJECT_CREATE,
+ INTENT_TASK_CREATE,
INTENT_SIGNAL_SOURCE_ONBOARD,
Any,
normalize_workload_ratio,
@@ -28,6 +29,8 @@ class StrategicBrain:
risk_score = min(1.0, 0.18 + risk_count * 0.08 + budget_ratio * 0.25 + len(overloaded_people) * 0.1)
if intent.name == INTENT_PROJECT_CREATE:
risk_score = max(risk_score, 0.62)
+ if intent.name == INTENT_TASK_CREATE:
+ risk_score = max(risk_score, 0.55)
if intent.name == INTENT_SIGNAL_SOURCE_ONBOARD:
risk_score = max(risk_score, 0.58)
diff --git a/backend/app/services/ai_pmo/ai_pmo_decision/tactical.py b/backend/app/services/ai_pmo/ai_pmo_decision/tactical.py
index 2c9bc5b7..f1d402ca 100644
--- a/backend/app/services/ai_pmo/ai_pmo_decision/tactical.py
+++ b/backend/app/services/ai_pmo/ai_pmo_decision/tactical.py
@@ -8,6 +8,7 @@ from .shared import (
INTENT_FOLLOWUP_CONTINUE,
INTENT_PROJECT_CREATE,
INTENT_QUERY,
+ INTENT_TASK_CREATE,
INTENT_SIGNAL_CREATE,
INTENT_SIGNAL_SOURCE_ONBOARD,
ROUTE_PROJECT_SIGNAL_CANCEL,
@@ -42,6 +43,8 @@ class TacticalBrain:
execution_actions.append({"operation_code": "query.answer", "mode": "read_only"})
elif intent.name == INTENT_PROJECT_CREATE:
execution_actions.append({"operation_code": "project.create", "mode": "write"})
+ elif intent.name == INTENT_TASK_CREATE:
+ execution_actions.append({"operation_code": "task.create", "mode": "write"})
elif intent.name == INTENT_SIGNAL_SOURCE_ONBOARD:
execution_actions.append({"operation_code": "signal.enter", "mode": "write"})
execution_actions.append({"operation_code": "signal_source.onboard", "mode": "write"})
diff --git a/backend/app/services/ai_pmo/ai_pmo_intent/__init__.py b/backend/app/services/ai_pmo/ai_pmo_intent/__init__.py
index ef1c7c0e..a4876d4f 100644
--- a/backend/app/services/ai_pmo/ai_pmo_intent/__init__.py
+++ b/backend/app/services/ai_pmo/ai_pmo_intent/__init__.py
@@ -8,6 +8,7 @@ from .intent_resolver import IntentResolver
__all__ = [
"INTENT_QUERY",
"INTENT_PROJECT_CREATE",
+ "INTENT_TASK_CREATE",
"INTENT_SIGNAL_CREATE",
"INTENT_SIGNAL_SOURCE_ONBOARD",
"INTENT_FOLLOWUP_CONFIRM",
@@ -16,6 +17,7 @@ __all__ = [
"FOLLOWUP_TARGET_INTENTS",
"ROUTE_FAMILY_QUERY",
"ROUTE_FAMILY_PROJECT_CREATE",
+ "ROUTE_FAMILY_TASK",
"ROUTE_FAMILY_PROJECT_SIGNAL",
"ROUTE_FAMILY_SIGNAL_SOURCE",
"ROUTE_FAMILY_FOLLOWUP",
@@ -30,6 +32,7 @@ __all__ = [
"ROUTE_ACTION_CONFIRM",
"ROUTE_QUERY_ANSWER",
"ROUTE_PROJECT_CREATE",
+ "ROUTE_TASK_CREATE",
"ROUTE_PROJECT_SIGNAL_CREATE",
"ROUTE_PROJECT_SIGNAL_DISCOVER",
"ROUTE_PROJECT_SIGNAL_COMMIT",
@@ -53,6 +56,7 @@ __all__ = [
"FOLLOWUP_CONTINUE_HINTS",
"FOLLOWUP_WORK_ITEM_ID_RE",
"SIGNAL_CREATE_HINTS",
+ "TASK_CREATE_HINTS",
"PROJECT_CREATE_HINTS",
"SESSION_SHORT_CONFIRM_WINDOW_SECONDS",
"SHORT_CONFIRM_MAX_MESSAGE_LENGTH",
diff --git a/backend/app/services/ai_pmo/ai_pmo_intent/intent_resolver.py b/backend/app/services/ai_pmo/ai_pmo_intent/intent_resolver.py
index 26a6a408..6c88445d 100644
--- a/backend/app/services/ai_pmo/ai_pmo_intent/intent_resolver.py
+++ b/backend/app/services/ai_pmo/ai_pmo_intent/intent_resolver.py
@@ -103,6 +103,16 @@ class IntentResolver:
pretty[:300],
{},
)
+ if IntentResolver._looks_like_task_create_request(text=pretty):
+ extracted_title = IntentResolver._extract_task_title(pretty)
+ return CanonicalIntent(
+ INTENT_TASK_CREATE,
+ 0.82,
+ project_id,
+ extracted_title or IntentResolver._build_signal_title(pretty, default="新任务"),
+ pretty[:300],
+ {"task_title": extracted_title} if extracted_title else {},
+ )
if IntentResolver._looks_like_signal_create_request(text=pretty):
return CanonicalIntent(
INTENT_SIGNAL_CREATE,
@@ -148,19 +158,71 @@ class IntentResolver:
return True
if any(token in normalized for token in SIGNAL_CREATE_HINTS):
return True
- signal_object_hints = ("信号", "需求", "任务", "事项", "问题", "改造", "优化")
+ signal_object_hints = ("信号", "需求", "事项", "问题", "改造", "优化")
has_operation = any(token in normalized for token in IntentResolver.OPERATION_VERBS)
has_signal_object = any(token in normalized for token in signal_object_hints)
if has_operation and has_signal_object:
return True
return bool(
re.search(
- r"(?:给|帮|请).{0,20}(?:新增|增加|添加|补充|加).{0,20}(?:信号|需求|任务|事项)",
+ r"(?:给|帮|请).{0,20}(?:新增|增加|添加|补充|加).{0,20}(?:信号|需求|事项)",
normalized,
re.IGNORECASE,
)
)
+ @staticmethod
+ def _looks_like_task_create_request(*, text: str) -> bool:
+ normalized = re.sub(r"\s+", "", str(text or "")).lower()
+ if not normalized:
+ return False
+ if IntentResolver._looks_like_negated_write_statement(text=normalized):
+ return False
+ if any(token in normalized for token in TASK_CREATE_HINTS):
+ return True
+ task_object_hints = ("任务", "工单", "待办", "todo")
+ has_operation = any(token in normalized for token in IntentResolver.OPERATION_VERBS)
+ has_task_object = any(token in normalized for token in task_object_hints)
+ if has_operation and has_task_object:
+ return True
+ return bool(
+ re.search(
+ r"(?:给|帮|请).{0,24}(?:新增|增加|添加|创建|新建|加).{0,16}(?:任务|工单|待办)",
+ normalized,
+ re.IGNORECASE,
+ )
+ )
+
+ @staticmethod
+ def _extract_task_title(text: str) -> str:
+ cleaned = re.sub(r"\s+", " ", str(text or "")).strip()
+ if not cleaned:
+ return ""
+ quoted_match = re.search(r"[\"'“](.+?)[\"'”]", cleaned)
+ if quoted_match:
+ return quoted_match.group(1).strip()[:120]
+ for pattern in (
+ r"(?:新增|新建|创建|增加|添加|安排|分配|加一个|加个).{0,8}任务(?:叫|名为|标题是|是|:|:)?\s*(.+)",
+ r"任务(?:叫|名为|标题是|是|:|:)\s*(.+)",
+ ):
+ match = re.search(pattern, cleaned, re.IGNORECASE)
+ if not match:
+ continue
+ candidate = re.sub(r"[。;;!!??].*$", "", match.group(1)).strip(" ,,::")
+ candidate = re.sub(r"^(?:到|进|放到)\s*项目.*$", "", candidate, flags=re.IGNORECASE).strip(" ,,::")
+ if candidate:
+ return candidate[:120]
+ fallback = re.sub(
+ r"^(?:请|帮我|帮忙)?(?:给|在)?(?:项目\s*#?\d+\s*)?(?:里|中)?(?:新增|新建|创建|增加|添加|加(?:一个|个)?|安排|分配)\s*(?:一条|一个|个)?任务",
+ "",
+ cleaned,
+ flags=re.IGNORECASE,
+ ).strip(" ,,::")
+ fallback = re.sub(r"[。;;!!??].*$", "", fallback).strip(" ,,::")
+ if not fallback:
+ return ""
+ return fallback[:120]
+
@staticmethod
def _is_smalltalk_message(*, pretty: str, normalized: str) -> bool:
compact = re.sub(r"[\\s\\??!!,,。~~、]+", "", str(pretty or "")).strip().lower()
@@ -181,6 +243,8 @@ class IntentResolver:
return False
if IntentResolver._looks_like_signal_create_request(text=normalized):
return False
+ if IntentResolver._looks_like_task_create_request(text=normalized):
+ return False
if any(token in normalized for token in QUESTION_HINTS):
return True
has_question_structure = (
diff --git a/backend/app/services/ai_pmo/ai_pmo_intent/route_resolver.py b/backend/app/services/ai_pmo/ai_pmo_intent/route_resolver.py
index 43e146f1..1e77d231 100644
--- a/backend/app/services/ai_pmo/ai_pmo_intent/route_resolver.py
+++ b/backend/app/services/ai_pmo/ai_pmo_intent/route_resolver.py
@@ -41,6 +41,8 @@ class RouteResolver:
return INTENT_SIGNAL_SOURCE_ONBOARD
if family == ROUTE_FAMILY_PROJECT_CREATE:
return INTENT_PROJECT_CREATE
+ if family == ROUTE_FAMILY_TASK:
+ return INTENT_TASK_CREATE
if family == ROUTE_FAMILY_FOLLOWUP:
return INTENT_FOLLOWUP_CONTINUE
return INTENT_QUERY
@@ -61,8 +63,14 @@ class RouteResolver:
return explicit_action == ROUTE_ACTION_DISCOVER
@staticmethod
- def parse_followup_action(*, metadata: dict[str, Any], canonical_intent: str) -> str:
- explicit_action = str(metadata.get("followup_action") or metadata.get("action") or "").strip().lower()
+ def parse_followup_action(
+ *,
+ metadata: Optional[dict[str, Any]] = None,
+ canonical_intent: str,
+ message_text: str = "",
+ ) -> str:
+ payload = metadata if isinstance(metadata, dict) else {}
+ explicit_action = str(payload.get("followup_action") or payload.get("action") or "").strip().lower()
if explicit_action in {
ROUTE_ACTION_CONTINUE,
ROUTE_ACTION_CONFIRM,
@@ -71,6 +79,13 @@ class RouteResolver:
ROUTE_ACTION_REFRESH,
}:
return explicit_action
+ normalized_text = re.sub(r"\s+", "", str(message_text or "")).lower()
+ if any(token in normalized_text for token in FOLLOWUP_REFRESH_HINTS):
+ return ROUTE_ACTION_REFRESH
+ if any(token in normalized_text for token in FOLLOWUP_CANCEL_HINTS):
+ return ROUTE_ACTION_CANCEL
+ if any(token in normalized_text for token in FOLLOWUP_COMMIT_HINTS):
+ return ROUTE_ACTION_COMMIT
if canonical_intent in {INTENT_FOLLOWUP_CONFIRM, INTENT_FOLLOWUP_CONTINUE}:
return ROUTE_ACTION_COMMIT
return ROUTE_ACTION_CONTINUE
@@ -92,8 +107,15 @@ class RouteResolver:
route_key = ROUTE_PROJECT_SIGNAL_DISCOVER
elif route_key not in {ROUTE_PROJECT_SIGNAL_CREATE, ROUTE_PROJECT_SIGNAL_DISCOVER}:
route_key = ROUTE_PROJECT_SIGNAL_CREATE
+ elif canonical_intent.name == INTENT_TASK_CREATE:
+ if route_key != ROUTE_TASK_CREATE:
+ route_key = ROUTE_TASK_CREATE
elif canonical_intent.name in {INTENT_FOLLOWUP_CONFIRM, INTENT_FOLLOWUP_CONTINUE}:
- action = cls.parse_followup_action(metadata=metadata, canonical_intent=canonical_intent.name)
+ action = cls.parse_followup_action(
+ metadata=metadata,
+ canonical_intent=canonical_intent.name,
+ message_text=message_text,
+ )
if action == ROUTE_ACTION_CONFIRM:
route_key = ROUTE_FOLLOWUP_CONFIRM
else:
diff --git a/backend/app/services/ai_pmo/ai_pmo_intent/shared.py b/backend/app/services/ai_pmo/ai_pmo_intent/shared.py
index 6df8a788..e48a7350 100644
--- a/backend/app/services/ai_pmo/ai_pmo_intent/shared.py
+++ b/backend/app/services/ai_pmo/ai_pmo_intent/shared.py
@@ -11,6 +11,7 @@ from ...signal.signal_source_orchestration import SignalSourceOrchestrationServi
INTENT_QUERY = "query.read_only"
INTENT_PROJECT_CREATE = "project.create"
+INTENT_TASK_CREATE = "task.create"
INTENT_SIGNAL_CREATE = "signal.create"
INTENT_SIGNAL_SOURCE_ONBOARD = "signal_source.onboard"
INTENT_FOLLOWUP_CONFIRM = "followup.confirm"
@@ -18,12 +19,14 @@ INTENT_FOLLOWUP_CONTINUE = "followup.continue"
INTENT_IGNORE = "ignore"
FOLLOWUP_TARGET_INTENTS = {
INTENT_PROJECT_CREATE,
+ INTENT_TASK_CREATE,
INTENT_SIGNAL_CREATE,
INTENT_SIGNAL_SOURCE_ONBOARD,
}
ROUTE_FAMILY_QUERY = "query"
ROUTE_FAMILY_PROJECT_CREATE = "project_create"
+ROUTE_FAMILY_TASK = "task"
ROUTE_FAMILY_PROJECT_SIGNAL = "project_signal"
ROUTE_FAMILY_SIGNAL_SOURCE = "signal_source"
ROUTE_FAMILY_FOLLOWUP = "followup"
@@ -40,6 +43,7 @@ ROUTE_ACTION_CONFIRM = "confirm"
ROUTE_QUERY_ANSWER = f"{ROUTE_FAMILY_QUERY}.{ROUTE_ACTION_ANSWER}"
ROUTE_PROJECT_CREATE = f"{ROUTE_FAMILY_PROJECT_CREATE}.{ROUTE_ACTION_CREATE}"
+ROUTE_TASK_CREATE = f"{ROUTE_FAMILY_TASK}.{ROUTE_ACTION_CREATE}"
ROUTE_PROJECT_SIGNAL_CREATE = f"{ROUTE_FAMILY_PROJECT_SIGNAL}.{ROUTE_ACTION_CREATE}"
ROUTE_PROJECT_SIGNAL_DISCOVER = f"{ROUTE_FAMILY_PROJECT_SIGNAL}.{ROUTE_ACTION_DISCOVER}"
ROUTE_PROJECT_SIGNAL_COMMIT = f"{ROUTE_FAMILY_PROJECT_SIGNAL}.{ROUTE_ACTION_COMMIT}"
@@ -52,6 +56,7 @@ ROUTE_FOLLOWUP_CONTINUE = f"{ROUTE_FAMILY_FOLLOWUP}.{ROUTE_ACTION_CONTINUE}"
WORKFLOW_ROUTE_KEYS = {
ROUTE_QUERY_ANSWER,
ROUTE_PROJECT_CREATE,
+ ROUTE_TASK_CREATE,
ROUTE_PROJECT_SIGNAL_CREATE,
ROUTE_PROJECT_SIGNAL_DISCOVER,
ROUTE_PROJECT_SIGNAL_COMMIT,
@@ -65,6 +70,7 @@ WORKFLOW_ROUTE_KEYS = {
ROUTE_CANONICAL_INTENT_MAP: dict[str, str] = {
ROUTE_QUERY_ANSWER: INTENT_QUERY,
ROUTE_PROJECT_CREATE: INTENT_PROJECT_CREATE,
+ ROUTE_TASK_CREATE: INTENT_TASK_CREATE,
ROUTE_PROJECT_SIGNAL_CREATE: INTENT_SIGNAL_CREATE,
ROUTE_PROJECT_SIGNAL_DISCOVER: INTENT_SIGNAL_CREATE,
ROUTE_PROJECT_SIGNAL_COMMIT: INTENT_SIGNAL_CREATE,
@@ -78,12 +84,14 @@ ROUTE_CANONICAL_INTENT_MAP: dict[str, str] = {
LEGACY_INTENT_ROUTE_MAP: dict[str, str] = {
INTENT_QUERY: ROUTE_QUERY_ANSWER,
INTENT_PROJECT_CREATE: ROUTE_PROJECT_CREATE,
+ INTENT_TASK_CREATE: ROUTE_TASK_CREATE,
INTENT_SIGNAL_CREATE: ROUTE_PROJECT_SIGNAL_CREATE,
INTENT_SIGNAL_SOURCE_ONBOARD: ROUTE_SIGNAL_SOURCE_ONBOARD,
INTENT_FOLLOWUP_CONFIRM: ROUTE_FOLLOWUP_CONFIRM,
INTENT_FOLLOWUP_CONTINUE: ROUTE_FOLLOWUP_CONTINUE,
"query.answer": ROUTE_QUERY_ANSWER,
"project_create.create": ROUTE_PROJECT_CREATE,
+ "task.create": ROUTE_TASK_CREATE,
"project_signal.create": ROUTE_PROJECT_SIGNAL_CREATE,
"project_signal.discover": ROUTE_PROJECT_SIGNAL_DISCOVER,
"project_signal.commit": ROUTE_PROJECT_SIGNAL_COMMIT,
@@ -154,6 +162,18 @@ FOLLOWUP_CONFIRM_HINTS = ("确认归属", "确认项目", "确认到项目", "
FOLLOWUP_CONTINUE_HINTS = ("继续处理", "继续执行", "接着处理", "往下做", "继续推进")
FOLLOWUP_WORK_ITEM_ID_RE = re.compile(r"(?:work[_ -]?item|工作项|队列项)\s*(?:id|ID)?\s*[:#:]?\s*(\d+)", re.IGNORECASE)
SIGNAL_CREATE_HINTS = ("新增一个信号", "新增一条信号", "增加一个信号", "录入一个信号", "关注", "跟踪")
+TASK_CREATE_HINTS = (
+ "创建任务",
+ "新建任务",
+ "新增任务",
+ "增加任务",
+ "添加任务",
+ "加个任务",
+ "加一个任务",
+ "安排任务",
+ "分配任务",
+ "派个任务",
+)
PROJECT_CREATE_HINTS = (
"创建项目",
"新建项目",
@@ -193,6 +213,7 @@ SHORT_CONFIRM_HINTS = (
INTENT_LLM_FUNCTION_NAME = "classify_intent_ai_pmo_kernel"
INTENT_CANDIDATES = (
INTENT_SIGNAL_SOURCE_ONBOARD,
+ INTENT_TASK_CREATE,
INTENT_SIGNAL_CREATE,
INTENT_QUERY,
INTENT_PROJECT_CREATE,
@@ -202,10 +223,10 @@ INTENT_CANDIDATES = (
)
WRITE_INTENTS = {
INTENT_PROJECT_CREATE,
+ INTENT_TASK_CREATE,
INTENT_SIGNAL_CREATE,
INTENT_SIGNAL_SOURCE_ONBOARD,
INTENT_FOLLOWUP_CONFIRM,
INTENT_FOLLOWUP_CONTINUE,
}
INTENT_CONFLICT_POLICIES = {"pending_manual", "prefer_rule", "prefer_llm"}
-
diff --git a/backend/app/services/ai_pmo/ai_pmo_kernel/ai_pmo_reply_renderer.py b/backend/app/services/ai_pmo/ai_pmo_kernel/ai_pmo_reply_renderer.py
index 43410595..cb5afa1b 100644
--- a/backend/app/services/ai_pmo/ai_pmo_kernel/ai_pmo_reply_renderer.py
+++ b/backend/app/services/ai_pmo/ai_pmo_kernel/ai_pmo_reply_renderer.py
@@ -9,6 +9,7 @@ from ..ai_pmo_intent import (
INTENT_FOLLOWUP_CONTINUE,
INTENT_PROJECT_CREATE,
INTENT_QUERY,
+ INTENT_TASK_CREATE,
INTENT_SIGNAL_CREATE,
INTENT_SIGNAL_SOURCE_ONBOARD,
ROUTE_PROJECT_SIGNAL_CANCEL,
@@ -151,6 +152,28 @@ class ReplyRenderer:
"payload": {"intent": intent.name, "project_id": project_id, "project_title": project_title},
"text": f"已立项:项目#{project_id}《{project_title}》已创建完成。" if project_id else f"已立项:《{project_title}》已创建完成。",
}
+ if intent.name == INTENT_TASK_CREATE:
+ task = execution_result.get("task") if isinstance(execution_result.get("task"), dict) else {}
+ task_id = _safe_int(execution_result.get("task_id")) or _safe_int(task.get("id"))
+ project_id = _safe_int(execution_result.get("project_id"))
+ task_title = str(task.get("title") or "").strip() or "新任务"
+ if task_id is not None and project_id is not None:
+ text = f"已创建任务:项目#{project_id} 任务#{task_id}《{task_title}》。"
+ elif task_id is not None:
+ text = f"已创建任务:任务#{task_id}《{task_title}》。"
+ else:
+ text = f"已创建任务:《{task_title}》。"
+ return {
+ "receipt_phase": self.receipt_phase_action,
+ "template_key": "action_succeeded",
+ "payload": {
+ "intent": intent.name,
+ "project_id": project_id,
+ "task_id": task_id,
+ "task_title": task_title,
+ },
+ "text": text,
+ }
if intent.name == INTENT_SIGNAL_SOURCE_ONBOARD:
signal_id = _safe_int(execution_result.get("signal_id"))
workflow = execution_result.get("signal_source_workflow") if isinstance(execution_result.get("signal_source_workflow"), dict) else {}
diff --git a/backend/app/services/ai_pmo/ai_pmo_kernel/ai_pmo_typed_executor_common.py b/backend/app/services/ai_pmo/ai_pmo_kernel/ai_pmo_typed_executor_common.py
index 4ecd2740..71c93d16 100644
--- a/backend/app/services/ai_pmo/ai_pmo_kernel/ai_pmo_typed_executor_common.py
+++ b/backend/app/services/ai_pmo/ai_pmo_kernel/ai_pmo_typed_executor_common.py
@@ -29,6 +29,7 @@ from .ai_pmo_domain_workflow import SignalSourceDomainWorkflow
from ..ai_pmo_intent import (
INTENT_PROJECT_CREATE,
INTENT_QUERY,
+ INTENT_TASK_CREATE,
INTENT_SIGNAL_CREATE,
INTENT_SIGNAL_SOURCE_ONBOARD,
LEGACY_INTENT_ROUTE_MAP,
@@ -36,6 +37,7 @@ from ..ai_pmo_intent import (
ROUTE_ACTION_DISCOVER,
ROUTE_PROJECT_SIGNAL_CREATE,
ROUTE_PROJECT_SIGNAL_DISCOVER,
+ ROUTE_TASK_CREATE,
CanonicalIntent,
IntentResolver,
RouteResolver,
diff --git a/backend/app/services/ai_pmo/ai_pmo_kernel/ai_pmo_workflow_runtime.py b/backend/app/services/ai_pmo/ai_pmo_kernel/ai_pmo_workflow_runtime.py
index e423845b..ce35ad55 100644
--- a/backend/app/services/ai_pmo/ai_pmo_kernel/ai_pmo_workflow_runtime.py
+++ b/backend/app/services/ai_pmo/ai_pmo_kernel/ai_pmo_workflow_runtime.py
@@ -14,6 +14,7 @@ from ..ai_pmo_intent import (
ROUTE_FAMILY_PROJECT_SIGNAL,
ROUTE_FAMILY_QUERY,
ROUTE_FAMILY_SIGNAL_SOURCE,
+ ROUTE_FAMILY_TASK,
CanonicalIntent,
WorkflowRoute,
_safe_int,
@@ -162,6 +163,31 @@ class ProjectCreateWorkflowHandler(WorkflowHandler):
)
+class TaskCreateWorkflowHandler(WorkflowHandler):
+ async def start(
+ self,
+ *,
+ route: WorkflowRoute,
+ intent: CanonicalIntent,
+ message_text: str,
+ sender: str,
+ operator_user_id: Optional[int],
+ source_system: str,
+ source_message_id: str,
+ channel_ref: Optional[str],
+ ) -> dict[str, object]:
+ _ = route
+ return await self.kernel.executor.execute(
+ intent=intent,
+ message_text=message_text,
+ sender=sender,
+ operator_user_id=operator_user_id,
+ source_system=source_system,
+ source_message_id=source_message_id,
+ channel_ref=channel_ref,
+ )
+
+
class SignalSourceWorkflowHandler(WorkflowHandler):
async def start(
self,
@@ -229,6 +255,7 @@ class WorkflowRegistry:
self._handlers: dict[str, WorkflowHandler] = {
ROUTE_FAMILY_QUERY: QueryWorkflowHandler(kernel),
ROUTE_FAMILY_PROJECT_CREATE: ProjectCreateWorkflowHandler(kernel),
+ ROUTE_FAMILY_TASK: TaskCreateWorkflowHandler(kernel),
ROUTE_FAMILY_PROJECT_SIGNAL: ProjectSignalWorkflowHandler(kernel),
ROUTE_FAMILY_SIGNAL_SOURCE: SignalSourceWorkflowHandler(kernel),
ROUTE_FAMILY_FOLLOWUP: WorkflowHandler(kernel),
diff --git a/backend/app/services/ai_pmo/ai_pmo_kernel/mixins/followup/ai_pmo_kernel_mixins_followup_project_signal_resume.py b/backend/app/services/ai_pmo/ai_pmo_kernel/mixins/followup/ai_pmo_kernel_mixins_followup_project_signal_resume.py
index e50196ed..d94a5175 100644
--- a/backend/app/services/ai_pmo/ai_pmo_kernel/mixins/followup/ai_pmo_kernel_mixins_followup_project_signal_resume.py
+++ b/backend/app/services/ai_pmo/ai_pmo_kernel/mixins/followup/ai_pmo_kernel_mixins_followup_project_signal_resume.py
@@ -57,6 +57,7 @@ class KernelFollowupProjectSignalResumeMixin:
ROUTE_FAMILY_PROJECT_SIGNAL,
ROUTE_FAMILY_SIGNAL_SOURCE,
ROUTE_FAMILY_PROJECT_CREATE,
+ ROUTE_FAMILY_TASK,
}:
reply_text = f"工作项#{int(target_work_item.id)} 当前类型为 {target_intent.name or 'unknown'},暂不支持继续处理。"
return {
diff --git a/backend/app/services/ai_pmo/ai_pmo_kernel/mixins/followup/ai_pmo_kernel_mixins_followup_routing.py b/backend/app/services/ai_pmo/ai_pmo_kernel/mixins/followup/ai_pmo_kernel_mixins_followup_routing.py
index a9ee459f..a035e7ab 100644
--- a/backend/app/services/ai_pmo/ai_pmo_kernel/mixins/followup/ai_pmo_kernel_mixins_followup_routing.py
+++ b/backend/app/services/ai_pmo/ai_pmo_kernel/mixins/followup/ai_pmo_kernel_mixins_followup_routing.py
@@ -10,6 +10,44 @@ for _name, _value in vars(_common).items():
class KernelFollowupRoutingMixin:
+ async def _project_draft_pending_confirm(
+ self,
+ *,
+ work_item: WorkItem,
+ message_text: str,
+ draft_text: str,
+ intent: CanonicalIntent,
+ ) -> None:
+ await NotificationService(self.db).create_notifications(
+ tenant_id=self.tenant_id,
+ user_ids=await NotificationService(self.db)._resolve_recipients(tenant_id=self.tenant_id),
+ title="AI PMO 草稿待确认",
+ content=str(draft_text or message_text)[:4000],
+ category="decision",
+ source="ai_pmo",
+ entity_type="work_item",
+ entity_id=int(work_item.id),
+ target_url=f"/inbox?workItemId={int(work_item.id)}",
+ kind="action_required",
+ priority="high",
+ status="pending_manual",
+ recommended_action="view_detail",
+ dedupe_key=f"ai-pmo-draft:{int(work_item.id)}",
+ payload={
+ "work_item_id": int(work_item.id),
+ "intent": intent.name,
+ "project_id": intent.project_id,
+ "message_text": message_text[:500],
+ "draft_version": int(work_item.draft_version or 0),
+ "draft_expires_at": (
+ work_item.draft_expires_at.isoformat()
+ if isinstance(work_item.draft_expires_at, datetime)
+ else None
+ ),
+ },
+ commit=False,
+ )
+
async def _project_waiting_manual(
self,
*,
@@ -168,10 +206,15 @@ class KernelFollowupRoutingMixin:
"target_work_item_id": int(target_work_item.id),
},
)
- elif target_family in {ROUTE_FAMILY_SIGNAL_SOURCE, ROUTE_FAMILY_PROJECT_CREATE}:
+ elif target_family in {ROUTE_FAMILY_SIGNAL_SOURCE, ROUTE_FAMILY_PROJECT_CREATE, ROUTE_FAMILY_TASK}:
resolved_target_route = WorkflowRoute(
family=target_family,
- action=target_action or (ROUTE_ACTION_ONBOARD if target_family == ROUTE_FAMILY_SIGNAL_SOURCE else ROUTE_ACTION_CREATE),
+ action=target_action
+ or (
+ ROUTE_ACTION_ONBOARD
+ if target_family == ROUTE_FAMILY_SIGNAL_SOURCE
+ else ROUTE_ACTION_CREATE
+ ),
project_id=_safe_int(target_work_item.project_id),
requested_count=1,
requires_confirmation=True,
diff --git a/backend/app/services/ai_pmo/ai_pmo_kernel/mixins/followup/ai_pmo_kernel_mixins_followup_verification.py b/backend/app/services/ai_pmo/ai_pmo_kernel/mixins/followup/ai_pmo_kernel_mixins_followup_verification.py
index a78fa435..869f1faa 100644
--- a/backend/app/services/ai_pmo/ai_pmo_kernel/mixins/followup/ai_pmo_kernel_mixins_followup_verification.py
+++ b/backend/app/services/ai_pmo/ai_pmo_kernel/mixins/followup/ai_pmo_kernel_mixins_followup_verification.py
@@ -36,6 +36,8 @@ class KernelFollowupVerificationMixin:
return {"verified": bool(str(execution_result.get("answer") or "").strip()), "issues": []}
if intent.name == INTENT_PROJECT_CREATE:
return {"verified": _safe_int(execution_result.get("project_id")) is not None, "issues": []}
+ if intent.name == INTENT_TASK_CREATE:
+ return {"verified": _safe_int(execution_result.get("task_id")) is not None, "issues": []}
if intent.name in {INTENT_SIGNAL_CREATE, INTENT_SIGNAL_SOURCE_ONBOARD}:
signal_ids = _normalize_signal_id_list(execution_result.get("signal_ids"))
signal_id = _safe_int(execution_result.get("signal_id"))
diff --git a/backend/app/services/ai_pmo/ai_pmo_kernel/mixins/ingress/ai_pmo_kernel_mixins_ingress_render.py b/backend/app/services/ai_pmo/ai_pmo_kernel/mixins/ingress/ai_pmo_kernel_mixins_ingress_render.py
index 5dbc9782..aba7f514 100644
--- a/backend/app/services/ai_pmo/ai_pmo_kernel/mixins/ingress/ai_pmo_kernel_mixins_ingress_render.py
+++ b/backend/app/services/ai_pmo/ai_pmo_kernel/mixins/ingress/ai_pmo_kernel_mixins_ingress_render.py
@@ -57,6 +57,7 @@ class KernelIngressRenderMixin:
"signal_id": primary_signal_id,
"signal_ids": signal_ids,
"created_project_id": _safe_int(execution_result.get("project_id")) if intent.name == INTENT_PROJECT_CREATE else None,
+ "created_task_id": _safe_int(execution_result.get("task_id")) if intent.name == INTENT_TASK_CREATE else None,
"auto_tasks": execution_result.get("auto_tasks") if isinstance(execution_result.get("auto_tasks"), list) else execution_result.get("tasks", []),
"ai_trace_id": f"ai-pmo-{int(work_item.id)}",
"results": [
@@ -64,7 +65,11 @@ class KernelIngressRenderMixin:
"work_item_id": int(work_item.id),
"state": work_item.state,
"intent": intent.name,
- "route_status": "queued_for_decision" if work_item.state == STATE_WAITING_MANUAL else "auto_done",
+ "route_status": (
+ "queued_for_decision"
+ if work_item.state in {STATE_WAITING_MANUAL, STATE_DRAFT_PENDING_CONFIRM}
+ else "auto_done"
+ ),
}
],
"intent": intent.name,
@@ -81,6 +86,12 @@ class KernelIngressRenderMixin:
else ((intent.metadata or {}).get("decision_trace") if isinstance(intent.metadata, dict) else None)
),
}
+ if str(render.get("template_key") or "").strip().lower() == "draft_pending_confirm":
+ payload = render.get("payload") if isinstance(render.get("payload"), dict) else {}
+ result["draft_version"] = _safe_int(payload.get("draft_version")) or _safe_int(getattr(work_item, "draft_version", None))
+ draft_expires_at = payload.get("draft_expires_at")
+ if isinstance(draft_expires_at, str) and draft_expires_at.strip():
+ result["draft_expires_at"] = draft_expires_at.strip()
if intent.name == INTENT_QUERY and isinstance(execution_result.get("rag_result"), dict):
result["rag_result"] = execution_result["rag_result"]
if intent.name == INTENT_PROJECT_CREATE:
@@ -99,6 +110,8 @@ class KernelIngressRenderMixin:
return "query.answer"
if normalized_route == ROUTE_PROJECT_CREATE:
return "project.create"
+ if normalized_route == ROUTE_TASK_CREATE:
+ return "task.create"
if normalized_route in {ROUTE_PROJECT_SIGNAL_CREATE, ROUTE_PROJECT_SIGNAL_DISCOVER, ROUTE_PROJECT_SIGNAL_COMMIT}:
if normalized_route == ROUTE_PROJECT_SIGNAL_DISCOVER:
return ROUTE_PROJECT_SIGNAL_DISCOVER
@@ -117,6 +130,8 @@ class KernelIngressRenderMixin:
return "query.answer"
if intent_name == INTENT_PROJECT_CREATE:
return "project.create"
+ if intent_name == INTENT_TASK_CREATE:
+ return "task.create"
if intent_name == INTENT_SIGNAL_CREATE:
return "signal.enter"
if intent_name == INTENT_SIGNAL_SOURCE_ONBOARD:
@@ -129,7 +144,7 @@ class KernelIngressRenderMixin:
def _risk_level_for_intent(intent_name: str) -> str:
if intent_name in {INTENT_PROJECT_CREATE, INTENT_SIGNAL_SOURCE_ONBOARD}:
return "high"
- if intent_name in {INTENT_SIGNAL_CREATE, INTENT_FOLLOWUP_CONFIRM, INTENT_FOLLOWUP_CONTINUE}:
+ if intent_name in {INTENT_TASK_CREATE, INTENT_SIGNAL_CREATE, INTENT_FOLLOWUP_CONFIRM, INTENT_FOLLOWUP_CONTINUE}:
return "medium"
return "low"
diff --git a/backend/app/services/ai_pmo/ai_pmo_kernel/mixins/ingress/ai_pmo_kernel_mixins_ingress_storage.py b/backend/app/services/ai_pmo/ai_pmo_kernel/mixins/ingress/ai_pmo_kernel_mixins_ingress_storage.py
index 2e2cc63d..c8580c4d 100644
--- a/backend/app/services/ai_pmo/ai_pmo_kernel/mixins/ingress/ai_pmo_kernel_mixins_ingress_storage.py
+++ b/backend/app/services/ai_pmo/ai_pmo_kernel/mixins/ingress/ai_pmo_kernel_mixins_ingress_storage.py
@@ -10,6 +10,254 @@ for _name, _value in vars(_common).items():
class KernelIngressStorageMixin:
+ @staticmethod
+ def _normalize_execution_ledger_status(
+ *,
+ work_item_state: Optional[str],
+ execution_status: Optional[str],
+ ) -> str:
+ state = str(work_item_state or "").strip().lower()
+ status = str(execution_status or "").strip().lower()
+ if state == STATE_SUCCEEDED:
+ return "applied"
+ if state == STATE_DRAFT_PENDING_CONFIRM:
+ return "pending_confirmation"
+ if state == STATE_WAITING_MANUAL:
+ return "pending_manual"
+ if state in {STATE_FAILED, STATE_IGNORED}:
+ return "failed"
+ if status in {"succeeded", "applied", "answered"}:
+ return "applied"
+ if status in {"pending_manual", "pending_confirmation"}:
+ return status
+ if status in {"failed", "failed_safe", "error"}:
+ return "failed"
+ return "processing"
+
+ @staticmethod
+ def _build_execution_ledger_idempotency_key(
+ *,
+ tenant_id: int,
+ work_item_id: int,
+ operation_code: str,
+ source_message_id: str,
+ ) -> str:
+ raw = (
+ f"{int(tenant_id)}:"
+ f"{int(work_item_id)}:"
+ f"{str(operation_code or '').strip().lower()}:"
+ f"{str(source_message_id or '').strip().lower()}"
+ )
+ return hashlib.sha1(raw.encode("utf-8"), usedforsecurity=False).hexdigest()[:64]
+
+ @staticmethod
+ def _resolve_execution_ledger_entity(
+ *,
+ operation_code: str,
+ execution_result: dict[str, Any],
+ ) -> tuple[Optional[str], Optional[str]]:
+ op = str(operation_code or "").strip().lower()
+ if op == "project.create":
+ entity_id = _safe_int(execution_result.get("project_id"))
+ return ("project", str(entity_id)) if entity_id is not None else ("project", None)
+ if op == "task.create":
+ entity_id = _safe_int(execution_result.get("task_id"))
+ return ("task", str(entity_id)) if entity_id is not None else ("task", None)
+ if op in {"signal.enter", ROUTE_PROJECT_SIGNAL_COMMIT, ROUTE_PROJECT_SIGNAL_CREATE}:
+ entity_id = _safe_int(execution_result.get("signal_id"))
+ return ("signal", str(entity_id)) if entity_id is not None else ("signal", None)
+ if op in {"signal_source.onboard", ROUTE_SIGNAL_SOURCE_ONBOARD}:
+ entity_id = _safe_int(execution_result.get("signal_id"))
+ return ("signal_source", str(entity_id)) if entity_id is not None else ("signal_source", None)
+ if op == "work_item.resume":
+ entity_id = _safe_int(execution_result.get("target_work_item_id"))
+ return ("work_item", str(entity_id)) if entity_id is not None else ("work_item", None)
+ return (None, None)
+
+ def _build_execution_action_contract(
+ self,
+ *,
+ intent: CanonicalIntent,
+ workflow_route: WorkflowRoute,
+ decision_payload: dict[str, Any],
+ execution_result: dict[str, Any],
+ source_system: str,
+ source_message_id: str,
+ channel_ref: Optional[str],
+ ) -> dict[str, Any]:
+ execution_plan = decision_payload.get("execution_plan") if isinstance(decision_payload.get("execution_plan"), dict) else {}
+ actions = execution_plan.get("actions") if isinstance(execution_plan.get("actions"), list) else []
+ route_key = str(getattr(workflow_route, "key", "") or "").strip().lower()
+ operation_code = str(
+ self._primary_operation_code(route_key or str(intent.name or ""))
+ or ""
+ ).strip().lower()
+ selected_action: dict[str, Any] = {}
+ for action in actions:
+ if not isinstance(action, dict):
+ continue
+ current_code = str(action.get("operation_code") or "").strip().lower()
+ if current_code and current_code == operation_code:
+ selected_action = action
+ break
+ if not selected_action:
+ selected_action = actions[0] if actions and isinstance(actions[0], dict) else {}
+ if not operation_code:
+ operation_code = str(selected_action.get("operation_code") or "").strip().lower()
+ mode = str(selected_action.get("mode") or "").strip().lower()
+ if not mode:
+ mode = "read_only" if str(intent.name or "") == INTENT_QUERY else "write"
+
+ signal_ids = _normalize_signal_id_list(execution_result.get("signal_ids"))
+ if not signal_ids and _safe_int(execution_result.get("signal_id")) is not None:
+ signal_ids = [_safe_int(execution_result.get("signal_id"))]
+ params: dict[str, Any] = {
+ "project_id": (
+ _safe_int(execution_result.get("project_id"))
+ or _safe_int(getattr(workflow_route, "project_id", None))
+ or _safe_int(execution_plan.get("project_id"))
+ or _safe_int(intent.project_id)
+ ),
+ "requested_count": (
+ _safe_int(execution_result.get("requested_count"))
+ or _safe_int(getattr(workflow_route, "requested_count", None))
+ ),
+ "signal_id": _safe_int(execution_result.get("signal_id")),
+ "signal_ids": signal_ids,
+ "task_id": _safe_int(execution_result.get("task_id")),
+ "target_work_item_id": _safe_int(execution_result.get("target_work_item_id")),
+ "source_system": str(source_system or "").strip() or None,
+ "source_message_id": str(source_message_id or "").strip() or None,
+ "channel_ref": str(channel_ref or "").strip() or None,
+ }
+ normalized_params = {
+ key: value
+ for key, value in params.items()
+ if value not in (None, "", [])
+ }
+ return {
+ "operation_code": operation_code or None,
+ "mode": mode,
+ "workflow_route": route_key or None,
+ "workflow_action": str(getattr(workflow_route, "action", "") or "").strip().lower() or None,
+ "param_source": "decision.execution_plan.actions+workflow_route+execution_result",
+ "params": normalized_params,
+ }
+
+ def _apply_execution_action_contract(
+ self,
+ *,
+ intent: CanonicalIntent,
+ workflow_route: WorkflowRoute,
+ decision_payload: dict[str, Any],
+ execution_result: dict[str, Any],
+ source_system: str,
+ source_message_id: str,
+ channel_ref: Optional[str],
+ ) -> dict[str, Any]:
+ normalized_result = dict(execution_result or {})
+ action_contract = self._build_execution_action_contract(
+ intent=intent,
+ workflow_route=workflow_route,
+ decision_payload=decision_payload,
+ execution_result=normalized_result,
+ source_system=source_system,
+ source_message_id=source_message_id,
+ channel_ref=channel_ref,
+ )
+ normalized_result["action_contract"] = action_contract
+ normalized_result["action_param_source"] = action_contract["param_source"]
+ operation_code = str(action_contract.get("operation_code") or "").strip().lower()
+ if operation_code and not str(normalized_result.get("operation_code") or "").strip():
+ normalized_result["operation_code"] = operation_code
+ return normalized_result
+
+ async def _upsert_operation_execution_ledger(
+ self,
+ *,
+ work_item_id: int,
+ operation_code: Optional[str],
+ source_message_id: str,
+ project_id: Optional[int],
+ status: str,
+ payload: dict[str, Any],
+ execution_result: dict[str, Any],
+ error_message: Optional[str] = None,
+ ) -> Optional[OperationExecutionLedger]:
+ normalized_code = str(operation_code or "").strip().lower()
+ if not normalized_code:
+ return None
+ idempotency_key = self._build_execution_ledger_idempotency_key(
+ tenant_id=int(self.tenant_id),
+ work_item_id=int(work_item_id),
+ operation_code=normalized_code,
+ source_message_id=source_message_id,
+ )
+ ledger = (
+ await self.db.execute(
+ select(OperationExecutionLedger).where(
+ and_(
+ OperationExecutionLedger.tenant_id == int(self.tenant_id),
+ OperationExecutionLedger.operation_code == normalized_code,
+ OperationExecutionLedger.idempotency_key == idempotency_key,
+ )
+ ).limit(1)
+ )
+ ).scalar_one_or_none()
+ entity_type, entity_id = self._resolve_execution_ledger_entity(
+ operation_code=normalized_code,
+ execution_result=execution_result if isinstance(execution_result, dict) else {},
+ )
+ normalized_payload = payload if isinstance(payload, dict) else {"payload": payload}
+ normalized_result = (
+ execution_result
+ if isinstance(execution_result, dict)
+ else {"result": execution_result}
+ )
+ normalized_status = self._normalize_execution_ledger_status(
+ work_item_state=None,
+ execution_status=status,
+ )
+ if ledger is None:
+ ledger = OperationExecutionLedger(
+ tenant_id=int(self.tenant_id),
+ operation_code=normalized_code,
+ project_id=_safe_int(project_id),
+ idempotency_key=idempotency_key,
+ trace_id=f"work_item:{int(work_item_id)}",
+ status=normalized_status,
+ payload=normalized_payload,
+ result_payload=normalized_result,
+ entity_type=entity_type,
+ entity_id=entity_id,
+ error_message=(str(error_message or "").strip() or None),
+ )
+ self.db.add(ledger)
+ else:
+ ledger.project_id = _safe_int(project_id)
+ ledger.status = normalized_status
+ ledger.payload = normalized_payload
+ ledger.result_payload = normalized_result
+ ledger.entity_type = entity_type
+ ledger.entity_id = entity_id
+ ledger.error_message = (str(error_message or "").strip() or None)
+ await self.db.flush()
+ return ledger
+
+ @staticmethod
+ def _extract_execution_operation_code(execution_result: dict[str, Any]) -> Optional[str]:
+ action_contract = (
+ execution_result.get("action_contract")
+ if isinstance(execution_result.get("action_contract"), dict)
+ else {}
+ )
+ operation_code = str(
+ action_contract.get("operation_code")
+ or execution_result.get("operation_code")
+ or ""
+ ).strip().lower()
+ return operation_code or None
+
async def _claim_ingress_event(
self,
*,
@@ -204,4 +452,3 @@ class KernelIngressStorageMixin:
)
)
await self.db.flush()
-
diff --git a/backend/app/services/ai_pmo/ai_pmo_kernel/mixins/intent/ai_pmo_kernel_mixins_intent_llm.py b/backend/app/services/ai_pmo/ai_pmo_kernel/mixins/intent/ai_pmo_kernel_mixins_intent_llm.py
index 583fe361..dbdd1e29 100644
--- a/backend/app/services/ai_pmo/ai_pmo_kernel/mixins/intent/ai_pmo_kernel_mixins_intent_llm.py
+++ b/backend/app/services/ai_pmo/ai_pmo_kernel/mixins/intent/ai_pmo_kernel_mixins_intent_llm.py
@@ -30,6 +30,7 @@ class KernelIntentLLMMixin:
[
ROUTE_QUERY_ANSWER,
ROUTE_PROJECT_CREATE,
+ ROUTE_TASK_CREATE,
ROUTE_PROJECT_SIGNAL_CREATE,
ROUTE_PROJECT_SIGNAL_DISCOVER,
ROUTE_SIGNAL_SOURCE_ONBOARD,
diff --git a/backend/app/services/ai_pmo/ai_pmo_kernel/mixins/intent/ai_pmo_kernel_mixins_intent_resolve.py b/backend/app/services/ai_pmo/ai_pmo_kernel/mixins/intent/ai_pmo_kernel_mixins_intent_resolve.py
index 0fd48b78..404c2903 100644
--- a/backend/app/services/ai_pmo/ai_pmo_kernel/mixins/intent/ai_pmo_kernel_mixins_intent_resolve.py
+++ b/backend/app/services/ai_pmo/ai_pmo_kernel/mixins/intent/ai_pmo_kernel_mixins_intent_resolve.py
@@ -16,15 +16,20 @@ class KernelIntentResolveMixin:
message_text: str,
session: Optional[ConversationSession],
world_model: dict[str, Any],
+ message_sent_at: Optional[Any] = None,
+ session_snapshot: Optional[dict[str, Any]] = None,
) -> tuple[CanonicalIntent, str]:
if self._is_short_confirmation_message(
message_text=message_text,
session=session,
+ message_sent_at=message_sent_at,
+ session_snapshot=session_snapshot,
) and session is not None:
inherited = self._build_short_confirm_intent(
message_text=message_text,
session=session,
world_model=world_model,
+ session_snapshot=session_snapshot,
)
logger.info(
"ai_pmo_short_confirm_inherited",
diff --git a/backend/app/services/ai_pmo/ai_pmo_kernel/mixins/intent/ai_pmo_kernel_mixins_intent_rules.py b/backend/app/services/ai_pmo/ai_pmo_kernel/mixins/intent/ai_pmo_kernel_mixins_intent_rules.py
index 36d430db..e202035a 100644
--- a/backend/app/services/ai_pmo/ai_pmo_kernel/mixins/intent/ai_pmo_kernel_mixins_intent_rules.py
+++ b/backend/app/services/ai_pmo/ai_pmo_kernel/mixins/intent/ai_pmo_kernel_mixins_intent_rules.py
@@ -169,15 +169,30 @@ class KernelIntentRulesMixin:
@staticmethod
def _has_required_slots(*, intent: CanonicalIntent, message_text: str) -> tuple[bool, dict[str, Any]]:
- if intent.name != INTENT_PROJECT_CREATE:
- return True, {}
- extracted_title = AIStewardService._extract_project_title_from_instruction(message_text)
- has_title = bool(str(extracted_title or "").strip())
- return has_title, {
- "slot_check": "project_title",
- "slot_ok": has_title,
- "extracted_project_title": extracted_title[:50] if extracted_title else "",
- }
+ if intent.name == INTENT_PROJECT_CREATE:
+ extracted_title = AIStewardService._extract_project_title_from_instruction(message_text)
+ has_title = bool(str(extracted_title or "").strip())
+ return has_title, {
+ "slot_check": "project_title",
+ "slot_ok": has_title,
+ "extracted_project_title": extracted_title[:50] if extracted_title else "",
+ }
+ if intent.name == INTENT_TASK_CREATE:
+ extracted_task_title = IntentResolver._extract_task_title(message_text)
+ has_task_title = bool(str(extracted_task_title or "").strip())
+ has_project_scope = _safe_int(intent.project_id) is not None
+ missing_fields: list[str] = []
+ if not has_project_scope:
+ missing_fields.append("project_id")
+ if not has_task_title:
+ missing_fields.append("task_title")
+ return not missing_fields, {
+ "slot_check": "task_create_required_fields",
+ "slot_ok": not missing_fields,
+ "missing_fields": missing_fields,
+ "extracted_task_title": extracted_task_title[:80] if extracted_task_title else "",
+ }
+ return True, {}
def _apply_slot_guard(self, *, intent: CanonicalIntent, message_text: str) -> CanonicalIntent:
slot_ok, slot_meta = self._has_required_slots(intent=intent, message_text=message_text)
@@ -259,15 +274,22 @@ class KernelIntentRulesMixin:
*,
message_text: str,
session: Optional[ConversationSession],
+ message_sent_at: Optional[Any] = None,
+ session_snapshot: Optional[dict[str, Any]] = None,
) -> bool:
- if session is None:
+ snapshot = session_snapshot if isinstance(session_snapshot, dict) else {}
+ if session is None and not snapshot:
return False
- if not cls._within_recent_window(
- getattr(session, "last_active_at", None),
- window_seconds=SESSION_SHORT_CONFIRM_WINDOW_SECONDS,
- ):
+ last_active_at = cls._coerce_naive_datetime(snapshot.get("last_active_at"))
+ if last_active_at is None and session is not None:
+ last_active_at = cls._coerce_naive_datetime(getattr(session, "last_active_at", None))
+ sent_at = cls._coerce_naive_datetime(message_sent_at) or _utc_now_naive()
+ if last_active_at is None:
return False
- last_intent = str(getattr(session, "last_intent", "") or "").strip()
+ delta = sent_at - last_active_at
+ if not (0 <= delta.total_seconds() <= float(SESSION_SHORT_CONFIRM_WINDOW_SECONDS)):
+ return False
+ last_intent = str(snapshot.get("last_intent") or (getattr(session, "last_intent", "") if session is not None else "") or "").strip()
if not last_intent:
return False
normalized = re.sub(r"\s+", "", str(message_text or "")).lower()
@@ -277,6 +299,23 @@ class KernelIntentRulesMixin:
return False
return any(token in normalized for token in SHORT_CONFIRM_HINTS)
+ @staticmethod
+ def _coerce_naive_datetime(value: Any) -> Optional[datetime]:
+ if isinstance(value, datetime):
+ if value.tzinfo is None:
+ return value
+ return value.astimezone().replace(tzinfo=None)
+ text = str(value or "").strip()
+ if not text:
+ return None
+ try:
+ parsed = datetime.fromisoformat(text.replace("Z", "+00:00"))
+ except Exception:
+ return None
+ if parsed.tzinfo is None:
+ return parsed
+ return parsed.astimezone().replace(tzinfo=None)
+
@staticmethod
def _build_canonical_intent(
*,
@@ -317,6 +356,19 @@ class KernelIntentRulesMixin:
pretty[:300],
extras,
)
+ if intent_name == INTENT_TASK_CREATE:
+ task_title = str(extras.get("task_title") or IntentResolver._extract_task_title(pretty) or "").strip()
+ if not task_title:
+ task_title = IntentResolver._build_signal_title(pretty, default="新任务")
+ extras.setdefault("task_title", task_title[:120])
+ return CanonicalIntent(
+ INTENT_TASK_CREATE,
+ safe_confidence,
+ project_id,
+ task_title[:120],
+ pretty[:300],
+ extras,
+ )
if intent_name == INTENT_QUERY:
return CanonicalIntent(INTENT_QUERY, safe_confidence, project_id, "", pretty[:300], extras)
if intent_name == INTENT_FOLLOWUP_CONFIRM:
@@ -331,14 +383,25 @@ class KernelIntentRulesMixin:
message_text: str,
session: ConversationSession,
world_model: dict[str, Any],
+ session_snapshot: Optional[dict[str, Any]] = None,
) -> CanonicalIntent:
- inherited_name = str(getattr(session, "last_intent", "") or "").strip()
+ snapshot = session_snapshot if isinstance(session_snapshot, dict) else {}
+ inherited_name = str(snapshot.get("last_intent") or getattr(session, "last_intent", "") or "").strip()
if inherited_name not in INTENT_CANDIDATES:
inherited_name = INTENT_QUERY
- inherited_project_id = _safe_int(getattr(session, "current_project_id", None)) or _safe_int(world_model.get("resolved_project_id"))
+ snapshot_project_id = _safe_int(snapshot.get("current_project_id"))
+ inherited_project_id = (
+ snapshot_project_id
+ or _safe_int(getattr(session, "current_project_id", None))
+ or _safe_int(world_model.get("resolved_project_id"))
+ )
if inherited_project_id is not None and int(inherited_project_id) <= 0:
inherited_project_id = None
- inherited_text = str(getattr(session, "last_message_text", "") or "").strip() or message_text
+ inherited_text = (
+ str(snapshot.get("last_message_text") or "").strip()
+ or str(getattr(session, "last_message_text", "") or "").strip()
+ or message_text
+ )
return self._build_canonical_intent(
intent_name=inherited_name,
message_text=inherited_text,
@@ -346,6 +409,6 @@ class KernelIntentRulesMixin:
confidence=0.86,
metadata={
"context_source": "session_inherited",
- "inherited_from_intent": str(getattr(session, "last_intent", "") or "").strip(),
+ "inherited_from_intent": str(snapshot.get("last_intent") or getattr(session, "last_intent", "") or "").strip(),
},
)
diff --git a/backend/app/services/ai_pmo/ai_pmo_kernel_common_core/constants.py b/backend/app/services/ai_pmo/ai_pmo_kernel_common_core/constants.py
index b891b50e..73158e7c 100644
--- a/backend/app/services/ai_pmo/ai_pmo_kernel_common_core/constants.py
+++ b/backend/app/services/ai_pmo/ai_pmo_kernel_common_core/constants.py
@@ -10,9 +10,11 @@ STATE_DECIDED = "decided"
STATE_PLANNED = "planned"
STATE_EXECUTING = "executing"
STATE_WAITING_MANUAL = "waiting_manual"
+STATE_DRAFT_PENDING_CONFIRM = "draft_pending_confirm"
STATE_SUCCEEDED = "succeeded"
STATE_FAILED = "failed"
STATE_IGNORED = "ignored"
+STATE_CANCELLED = "cancelled"
RECEIPT_PHASE_ACTION = "action_applied"
RECEIPT_PHASE_PENDING = "pending_manual"
diff --git a/backend/app/services/ai_pmo/ai_pmo_kernel_common_core/imports.py b/backend/app/services/ai_pmo/ai_pmo_kernel_common_core/imports.py
index fa2bc2cf..4550cb8a 100644
--- a/backend/app/services/ai_pmo/ai_pmo_kernel_common_core/imports.py
+++ b/backend/app/services/ai_pmo/ai_pmo_kernel_common_core/imports.py
@@ -83,6 +83,7 @@ from ..ai_pmo_intent import (
INTENT_LLM_FUNCTION_NAME,
INTENT_PROJECT_CREATE,
INTENT_QUERY,
+ INTENT_TASK_CREATE,
INTENT_SIGNAL_CREATE,
INTENT_SIGNAL_SOURCE_ONBOARD,
LEGACY_INTENT_ROUTE_MAP,
@@ -100,6 +101,7 @@ from ..ai_pmo_intent import (
ROUTE_FAMILY_PROJECT_SIGNAL,
ROUTE_FAMILY_QUERY,
ROUTE_FAMILY_SIGNAL_SOURCE,
+ ROUTE_FAMILY_TASK,
ROUTE_FOLLOWUP_CONFIRM,
ROUTE_FOLLOWUP_CONTINUE,
ROUTE_PROJECT_CREATE,
@@ -110,6 +112,7 @@ from ..ai_pmo_intent import (
ROUTE_PROJECT_SIGNAL_REFRESH,
ROUTE_QUERY_ANSWER,
ROUTE_SIGNAL_SOURCE_ONBOARD,
+ ROUTE_TASK_CREATE,
SESSION_SHORT_CONFIRM_WINDOW_SECONDS,
SHORT_CONFIRM_HINTS,
SHORT_CONFIRM_MAX_MESSAGE_LENGTH,
diff --git a/backend/app/services/ai_pmo/ai_pmo_kernel_ingress_flow/mixin_auto.py b/backend/app/services/ai_pmo/ai_pmo_kernel_ingress_flow/mixin_auto.py
index 4a1c5380..32261c4e 100644
--- a/backend/app/services/ai_pmo/ai_pmo_kernel_ingress_flow/mixin_auto.py
+++ b/backend/app/services/ai_pmo/ai_pmo_kernel_ingress_flow/mixin_auto.py
@@ -1,6 +1,7 @@
from __future__ import annotations
from .shared import *
+from ..ai_pmo_kernel_common_core.helpers import _safe_int as _safe_int
class KernelIngressFlowAutoMixin:
async def _execute_auto_ingress_flow(
@@ -29,6 +30,153 @@ class KernelIngressFlowAutoMixin:
work_item.state = STATE_PLANNED
work_item.action_bundle = decision_payload["execution_plan"]
work_item.state = STATE_EXECUTING
+ if self._should_enter_draft_confirmation(intent=intent, workflow_route=workflow_route):
+ from ...signal.draft_generator_service import DraftGeneratorService
+
+ stage_started_at = time.perf_counter()
+ generator = DraftGeneratorService(self.db, tenant_id=self.tenant_id)
+ generated = await generator.generate_and_persist(
+ work_item=work_item,
+ intent_name=intent.name,
+ message_text=message_text,
+ world_model=world_model,
+ )
+ log_perf_fn("DraftGenerator.generate", stage_started_at)
+
+ execution_result = work_item.execution_result if isinstance(work_item.execution_result, dict) else {}
+ execution_result.setdefault("decision_trace", decision_trace)
+ execution_result.setdefault("workflow_route", workflow_route.key)
+ execution_result.setdefault("workflow_action", workflow_route.action)
+ execution_result = self._apply_execution_action_contract(
+ intent=intent,
+ workflow_route=workflow_route,
+ decision_payload=decision_payload,
+ execution_result=execution_result,
+ source_system=source_system,
+ source_message_id=source_message_id,
+ channel_ref=channel_ref,
+ )
+ work_item.execution_result = execution_result
+ work_item.state = STATE_DRAFT_PENDING_CONFIRM
+ work_item.reply_template_key = "draft_pending_confirm"
+
+ verification = {"verified": False, "issues": [STATE_DRAFT_PENDING_CONFIRM]}
+ work_item.review_summary = await self.review_service.review_work_item(
+ work_item=work_item,
+ intent_name=intent.name,
+ execution_result=execution_result,
+ world_model=world_model,
+ verification=verification,
+ simulation=decision_payload["simulation"],
+ )
+ await self._maybe_emit_risk_cadence_snapshot(
+ project_id=_safe_int(work_item.project_id),
+ review_summary=work_item.review_summary,
+ )
+
+ decision_row.execution_result = execution_result
+ decision_row.final_outcome = STATE_DRAFT_PENDING_CONFIRM
+ operation_code_for_log = (
+ self._extract_execution_operation_code(execution_result)
+ or self._primary_operation_code(str(work_item.intent or intent.name))
+ )
+ await self._upsert_operation_execution_ledger(
+ work_item_id=int(work_item.id),
+ operation_code=operation_code_for_log,
+ source_message_id=source_message_id,
+ project_id=_safe_int(work_item.project_id),
+ status=self._normalize_execution_ledger_status(
+ work_item_state=STATE_DRAFT_PENDING_CONFIRM,
+ execution_status=str(execution_result.get("status") or ""),
+ ),
+ payload={
+ "work_item_id": int(work_item.id),
+ "workflow_route": workflow_route.key,
+ "workflow_action": workflow_route.action,
+ "source_system": source_system,
+ "source_message_id": source_message_id,
+ "action_contract": execution_result.get("action_contract"),
+ },
+ execution_result={
+ **execution_result,
+ "work_item_state": STATE_DRAFT_PENDING_CONFIRM,
+ },
+ )
+
+ draft_text = str(generated.get("draft_text") or "").strip()
+ render = {
+ "receipt_phase": RECEIPT_PHASE_PENDING,
+ "template_key": "draft_pending_confirm",
+ "payload": {
+ "intent": intent.name,
+ "draft_version": int(generated.get("draft_version") or 0),
+ "draft_expires_at": (
+ generated["draft_expires_at"].isoformat()
+ if isinstance(generated.get("draft_expires_at"), datetime)
+ else None
+ ),
+ },
+ "text": draft_text or "已生成草稿,请确认后入库。",
+ }
+ await self._project_draft_pending_confirm(
+ work_item=work_item,
+ message_text=message_text,
+ draft_text=str(render["text"]),
+ intent=intent,
+ )
+
+ direct_reply_required = await self._claim_reply_receipt(
+ work_item_id=int(work_item.id),
+ source_system=source_system,
+ source_message_id=source_message_id,
+ receipt_phase=str(render["receipt_phase"]),
+ template_key=str(render["template_key"]),
+ payload=render["payload"],
+ )
+ ingest_event.status = "done"
+ ingest_event.processed_at = _utc_now_naive()
+ await self._write_processing_log(
+ source_message_id=source_message_id,
+ work_item=work_item,
+ message_text=message_text,
+ route=f"ai_pmo.{STATE_DRAFT_PENDING_CONFIRM}",
+ operation_code=operation_code_for_log,
+ operation_result=execution_result,
+ processing_status="success",
+ duration_ms=int((time.perf_counter() - started) * 1000),
+ )
+ final_project_id = _safe_int(work_item.project_id) or resolved_project_for_session
+ await self._update_session(
+ tenant_id=self.tenant_id,
+ sender=sender,
+ current_project_id=final_project_id,
+ last_intent=STATE_DRAFT_PENDING_CONFIRM,
+ last_message_text=message_text[:200],
+ )
+ try:
+ await self.db.commit()
+ except Exception as exc:
+ await self._persist_kernel_failure_with_fresh_session(
+ work_item_id=_safe_int(getattr(work_item, "id", None)),
+ ingest_event_id=_safe_int(getattr(ingest_event, "id", None)),
+ error_message=str(exc),
+ source_system=source_system,
+ source_message_id=source_message_id,
+ )
+ raise
+ stage_started_at = time.perf_counter()
+ capture_result = self._build_capture_result(
+ work_item=work_item,
+ intent=intent,
+ execution_result=execution_result,
+ render=render,
+ direct_reply_required=direct_reply_required,
+ status="captured",
+ classification="command",
+ )
+ log_perf_fn("_build_capture_result", stage_started_at)
+ return capture_result
+
if intent.name in {INTENT_FOLLOWUP_CONFIRM, INTENT_FOLLOWUP_CONTINUE}:
stage_started_at = time.perf_counter()
execution_result, verification = await self._execute_followup_intent(
@@ -67,6 +215,15 @@ class KernelIngressFlowAutoMixin:
resolved_route = str(execution_result.get("resolved_route") or "").strip().lower()
if resolved_route:
work_item.intent = self.route_resolver.normalize_route_key(resolved_route)
+ execution_result = self._apply_execution_action_contract(
+ intent=intent,
+ workflow_route=workflow_route,
+ decision_payload=decision_payload,
+ execution_result=execution_result,
+ source_system=source_system,
+ source_message_id=source_message_id,
+ channel_ref=channel_ref,
+ )
work_item.execution_result = execution_result
if _safe_int(execution_result.get("project_id")) is not None:
work_item.project_id = _safe_int(execution_result.get("project_id"))
@@ -97,6 +254,37 @@ class KernelIngressFlowAutoMixin:
else:
work_item.state = STATE_FAILED
decision_row.final_outcome = work_item.state
+ operation_code_for_log = (
+ self._extract_execution_operation_code(execution_result)
+ or self._primary_operation_code(str(work_item.intent or intent.name))
+ )
+ await self._upsert_operation_execution_ledger(
+ work_item_id=int(work_item.id),
+ operation_code=operation_code_for_log,
+ source_message_id=source_message_id,
+ project_id=_safe_int(work_item.project_id),
+ status=self._normalize_execution_ledger_status(
+ work_item_state=str(work_item.state or ""),
+ execution_status=str(execution_result.get("status") or ""),
+ ),
+ payload={
+ "work_item_id": int(work_item.id),
+ "workflow_route": workflow_route.key,
+ "workflow_action": workflow_route.action,
+ "source_system": source_system,
+ "source_message_id": source_message_id,
+ "action_contract": execution_result.get("action_contract"),
+ },
+ execution_result={
+ **execution_result,
+ "work_item_state": str(work_item.state or ""),
+ },
+ error_message=(
+ str(execution_result.get("error_message") or "").strip()
+ if str(work_item.state or "") == STATE_FAILED
+ else None
+ ),
+ )
render = self.reply_renderer.render(
work_state=work_item.state,
@@ -133,7 +321,7 @@ class KernelIngressFlowAutoMixin:
work_item=work_item,
message_text=message_text,
route=f"ai_pmo.{work_item.intent or intent.name}",
- operation_code=self._primary_operation_code(str(work_item.intent or intent.name)),
+ operation_code=operation_code_for_log,
operation_result=execution_result,
processing_status="success" if work_item.state in {STATE_SUCCEEDED, STATE_WAITING_MANUAL} else "failed",
duration_ms=int((time.perf_counter() - started) * 1000),
@@ -206,6 +394,14 @@ class KernelIngressFlowAutoMixin:
log_perf_fn("_build_capture_result", stage_started_at)
return capture_result
+ @staticmethod
+ def _should_enter_draft_confirmation(*, intent: CanonicalIntent, workflow_route: Any) -> bool:
+ if intent.name not in {INTENT_PROJECT_CREATE, INTENT_TASK_CREATE, INTENT_SIGNAL_CREATE, INTENT_SIGNAL_SOURCE_ONBOARD}:
+ return False
+ if str(getattr(workflow_route, "family", "") or "") == ROUTE_FAMILY_PROJECT_SIGNAL:
+ return str(getattr(workflow_route, "action", "") or "") == ROUTE_ACTION_CREATE
+ return True
+
@staticmethod
def _build_capacity_warning_text(
*,
diff --git a/backend/app/services/ai_pmo/ai_pmo_kernel_ingress_flow/mixin_entry.py b/backend/app/services/ai_pmo/ai_pmo_kernel_ingress_flow/mixin_entry.py
index 6977bef0..5151df2e 100644
--- a/backend/app/services/ai_pmo/ai_pmo_kernel_ingress_flow/mixin_entry.py
+++ b/backend/app/services/ai_pmo/ai_pmo_kernel_ingress_flow/mixin_entry.py
@@ -96,6 +96,8 @@ class KernelIngressFlowEntryMixin:
source_sender_open_id: Optional[str] = None,
captured_at: Optional[datetime] = None,
chat_type: Optional[str] = None,
+ message_sent_at: Optional[Any] = None,
+ session_snapshot: Optional[dict[str, Any]] = None,
) -> dict[str, Any]:
return await self.process_ingress(
source_system="feishu",
@@ -106,6 +108,8 @@ class KernelIngressFlowEntryMixin:
source_sender_open_id=source_sender_open_id,
captured_at=captured_at,
chat_type=chat_type,
+ message_sent_at=message_sent_at,
+ session_snapshot=session_snapshot,
)
async def process_ingress(
@@ -120,6 +124,9 @@ class KernelIngressFlowEntryMixin:
captured_at: Optional[datetime] = None,
chat_type: Optional[str] = None,
project_hint_id: Optional[int] = None,
+ message_sent_at: Optional[Any] = None,
+ session_snapshot: Optional[dict[str, Any]] = None,
+ ingress_options: Optional[dict[str, Any]] = None,
) -> dict[str, Any]:
started = time.perf_counter()
perf_work_item_id: Optional[int] = None
@@ -153,10 +160,27 @@ class KernelIngressFlowEntryMixin:
operator = ingress_prepare["operator"]
work_item = ingress_prepare["work_item"]
session = ingress_prepare["session"]
+ if str(getattr(session, "last_intent", "") or "").strip() == STATE_DRAFT_PENDING_CONFIRM:
+ draft_capture = await self._maybe_handle_draft_confirmation_ingress(
+ work_item=work_item,
+ ingest_event=ingest_event,
+ session=session,
+ synthetic_message_id=synthetic_message_id,
+ source_system=source_system,
+ source_sender_open_id=source_sender_open_id,
+ sender=sender,
+ channel_ref=channel_ref,
+ message_text=clean_text,
+ started=started,
+ )
+ if draft_capture is not None:
+ return draft_capture
mixed_meta: dict[str, Any] = {}
if not self._is_short_confirmation_message(
message_text=clean_text,
session=session,
+ message_sent_at=message_sent_at,
+ session_snapshot=session_snapshot,
):
clean_text, mixed_meta = await self._prepare_mixed_sidecar_context(
source_system=source_system,
@@ -182,6 +206,8 @@ class KernelIngressFlowEntryMixin:
message_text=clean_text,
session=session,
world_model=world_model,
+ message_sent_at=message_sent_at,
+ session_snapshot=session_snapshot,
)
intent = self._enrich_signal_source_intent_from_closure(
intent=intent,
@@ -193,6 +219,16 @@ class KernelIngressFlowEntryMixin:
**(intent.metadata or {}),
**mixed_meta,
}
+ rag_request_context = (
+ ingress_options.get("rag_request_context")
+ if isinstance(ingress_options, dict)
+ else None
+ )
+ if isinstance(rag_request_context, dict):
+ intent.metadata = {
+ **(intent.metadata or {}),
+ "rag_request_context": dict(rag_request_context),
+ }
workflow_route = self.route_resolver.from_canonical(
canonical_intent=intent,
message_text=clean_text,
@@ -365,6 +401,153 @@ class KernelIngressFlowEntryMixin:
)
return {"handled": False, "reason": "kernel_exception", "error": str(exc)}
+ async def _maybe_handle_draft_confirmation_ingress(
+ self,
+ *,
+ work_item: WorkItem,
+ ingest_event: IngestEvent,
+ session: ConversationSession,
+ synthetic_message_id: str,
+ source_system: str,
+ source_sender_open_id: Optional[str],
+ sender: str,
+ channel_ref: Optional[str],
+ message_text: str,
+ started: float,
+ ) -> Optional[dict[str, Any]]:
+ from ..draft_confirm_handler import DraftConfirmHandler
+
+ handler = DraftConfirmHandler(self)
+ decision = await handler.handle_text_reply(
+ message_text=message_text,
+ sender=sender,
+ source_system=source_system,
+ source_message_id=synthetic_message_id,
+ channel_ref=channel_ref,
+ source_sender_open_id=source_sender_open_id,
+ expected_version=None,
+ )
+ if not bool(decision.get("handled")):
+ return None
+
+ result_status = str(decision.get("status") or "").strip().lower()
+ if result_status in {"succeeded", "draft_pending_confirm", "pending_manual", "cancelled", "ignored"}:
+ work_item.state = STATE_SUCCEEDED
+ else:
+ work_item.state = STATE_FAILED
+ work_item.intent = ROUTE_FOLLOWUP_CONFIRM
+ work_item.project_id = _safe_int(decision.get("project_id"))
+ work_item.signal_id = _safe_int(decision.get("signal_id"))
+ execution_result = {
+ "status": result_status or "failed_safe",
+ "followup_reply": str(decision.get("followup_reply") or "").strip(),
+ "target_work_item_id": _safe_int(decision.get("target_work_item_id")),
+ "target_state": str(decision.get("target_state") or "").strip() or None,
+ "target_intent": str(decision.get("target_intent") or "").strip() or None,
+ "project_id": _safe_int(decision.get("project_id")),
+ "signal_id": _safe_int(decision.get("signal_id")),
+ "workflow_route": str(decision.get("workflow_route") or ROUTE_FOLLOWUP_CONFIRM).strip(),
+ }
+ work_item.execution_result = execution_result
+ work_item.review_summary = {
+ "verified": work_item.state == STATE_SUCCEEDED,
+ "issues": [] if work_item.state == STATE_SUCCEEDED else ["draft_confirm_failed"],
+ "next_step": "已处理草稿确认请求" if work_item.state == STATE_SUCCEEDED else "请检查草稿确认状态",
+ }
+ followup_text = str(decision.get("followup_reply") or "").strip() or "草稿状态已更新。"
+ if result_status == "draft_pending_confirm":
+ render = {
+ "receipt_phase": RECEIPT_PHASE_PENDING,
+ "template_key": "draft_pending_confirm",
+ "payload": {"status": result_status},
+ "text": followup_text,
+ }
+ elif result_status == "cancelled":
+ render = {
+ "receipt_phase": RECEIPT_PHASE_ACTION,
+ "template_key": "action_rejected",
+ "payload": {"status": result_status},
+ "text": followup_text,
+ }
+ elif work_item.state == STATE_SUCCEEDED:
+ render = {
+ "receipt_phase": RECEIPT_PHASE_ACTION,
+ "template_key": "action_succeeded",
+ "payload": {"status": result_status},
+ "text": followup_text,
+ }
+ else:
+ render = {
+ "receipt_phase": RECEIPT_PHASE_ERROR,
+ "template_key": "safe_error",
+ "payload": {"status": result_status},
+ "text": followup_text,
+ }
+ work_item.reply_template_key = str(render["template_key"])
+ direct_reply_required = await self._claim_reply_receipt(
+ work_item_id=int(work_item.id),
+ source_system=source_system,
+ source_message_id=synthetic_message_id,
+ receipt_phase=str(render["receipt_phase"]),
+ template_key=str(render["template_key"]),
+ payload=render["payload"],
+ )
+ ingest_event.status = "done"
+ ingest_event.processed_at = _utc_now_naive()
+ await self._write_processing_log(
+ source_message_id=synthetic_message_id,
+ work_item=work_item,
+ message_text=message_text,
+ route="ai_pmo.draft_confirm",
+ operation_code="draft.confirm",
+ operation_result=execution_result,
+ processing_status="success" if work_item.state == STATE_SUCCEEDED else "failed",
+ duration_ms=int((time.perf_counter() - started) * 1000),
+ )
+
+ next_session_intent = (
+ STATE_DRAFT_PENDING_CONFIRM
+ if result_status in {"draft_pending_confirm", "pending_manual"}
+ else str(decision.get("target_intent") or INTENT_FOLLOWUP_CONFIRM).strip()
+ )
+ await self._update_session(
+ tenant_id=self.tenant_id,
+ sender=sender,
+ current_project_id=_safe_int(decision.get("project_id")),
+ last_intent=next_session_intent,
+ last_message_text=message_text[:200],
+ )
+
+ try:
+ await self.db.commit()
+ except Exception as exc:
+ await self._persist_kernel_failure_with_fresh_session(
+ work_item_id=_safe_int(getattr(work_item, "id", None)),
+ ingest_event_id=_safe_int(getattr(ingest_event, "id", None)),
+ error_message=str(exc),
+ source_system=source_system,
+ source_message_id=synthetic_message_id,
+ )
+ raise
+
+ capture_intent = CanonicalIntent(
+ name=INTENT_FOLLOWUP_CONFIRM,
+ confidence=0.95,
+ project_id=_safe_int(decision.get("project_id")),
+ signal_title="",
+ signal_summary="",
+ metadata={"workflow_route": ROUTE_FOLLOWUP_CONFIRM},
+ )
+ return self._build_capture_result(
+ work_item=work_item,
+ intent=capture_intent,
+ execution_result=execution_result,
+ render=render,
+ direct_reply_required=direct_reply_required,
+ status="answered" if work_item.state == STATE_SUCCEEDED else "captured",
+ classification="command",
+ )
+
async def _persist_kernel_failure_with_fresh_session(
self,
*,
diff --git a/backend/app/services/ai_pmo/ai_pmo_kernel_ingress_flow/mixin_prepare_manual.py b/backend/app/services/ai_pmo/ai_pmo_kernel_ingress_flow/mixin_prepare_manual.py
index 9346b05f..c13065ed 100644
--- a/backend/app/services/ai_pmo/ai_pmo_kernel_ingress_flow/mixin_prepare_manual.py
+++ b/backend/app/services/ai_pmo/ai_pmo_kernel_ingress_flow/mixin_prepare_manual.py
@@ -184,6 +184,15 @@ class KernelIngressFlowPrepareManualMixin:
"workflow_action": workflow_route.action,
"decision_trace": decision_trace,
}
+ work_item.execution_result = self._apply_execution_action_contract(
+ intent=intent,
+ workflow_route=workflow_route,
+ decision_payload=decision_payload,
+ execution_result=work_item.execution_result,
+ source_system=source_system,
+ source_message_id=source_message_id,
+ channel_ref=None,
+ )
manual_issues = ["waiting_manual"]
policy_gate_code = str(policy.get("policy_gate_code") or "").strip()
if policy_gate_code:
@@ -213,6 +222,32 @@ class KernelIngressFlowPrepareManualMixin:
project_id=_safe_int(work_item.project_id),
review_summary=work_item.review_summary,
)
+ operation_code_for_log = (
+ self._extract_execution_operation_code(work_item.execution_result)
+ or self._primary_operation_code(str(work_item.intent or intent.name))
+ )
+ await self._upsert_operation_execution_ledger(
+ work_item_id=int(work_item.id),
+ operation_code=operation_code_for_log,
+ source_message_id=source_message_id,
+ project_id=_safe_int(work_item.project_id),
+ status=self._normalize_execution_ledger_status(
+ work_item_state=STATE_WAITING_MANUAL,
+ execution_status=str((work_item.execution_result or {}).get("status") or ""),
+ ),
+ payload={
+ "work_item_id": int(work_item.id),
+ "workflow_route": workflow_route.key,
+ "workflow_action": workflow_route.action,
+ "source_system": source_system,
+ "source_message_id": source_message_id,
+ "action_contract": (work_item.execution_result or {}).get("action_contract"),
+ },
+ execution_result={
+ **(work_item.execution_result or {}),
+ "work_item_state": STATE_WAITING_MANUAL,
+ },
+ )
direct_reply_required = await self._claim_reply_receipt(
work_item_id=int(work_item.id),
source_system=source_system,
@@ -228,7 +263,7 @@ class KernelIngressFlowPrepareManualMixin:
work_item=work_item,
message_text=message_text,
route="ai_pmo.waiting_manual",
- operation_code=None,
+ operation_code=operation_code_for_log,
operation_result=work_item.execution_result,
processing_status="success",
duration_ms=int((time.perf_counter() - started) * 1000),
diff --git a/backend/app/services/ai_pmo/ai_pmo_typed_executor_execute/mixin_execute_query_project.py b/backend/app/services/ai_pmo/ai_pmo_typed_executor_execute/mixin_execute_query_project.py
index c9db5e67..2cd19dbb 100644
--- a/backend/app/services/ai_pmo/ai_pmo_typed_executor_execute/mixin_execute_query_project.py
+++ b/backend/app/services/ai_pmo/ai_pmo_typed_executor_execute/mixin_execute_query_project.py
@@ -3,11 +3,15 @@
from __future__ import annotations
from .shared import * # noqa: F401, F403
+from .shared import _safe_int
from ...adaptive_rag_service import AdaptiveRAGService
+from ...context_cache_service import context_cache_service
+from ...sstc_service_core import ALIGNED_STATUSES
+from ...task_persistence_core import build_task_create_draft, persist_task
class ExecuteQueryProjectMixin:
- """Handle query and project-create intents."""
+ """Handle query/project/task create intents."""
async def _execute_query_intent(
self,
@@ -19,17 +23,58 @@ class ExecuteQueryProjectMixin:
source_message_id: str,
) -> dict[str, Any]:
rag_service = AdaptiveRAGService(self.db, tenant_id=self.tenant_id)
+ metadata = intent.metadata if isinstance(intent.metadata, dict) else {}
+ rag_request_context = (
+ metadata.get("rag_request_context")
+ if isinstance(metadata.get("rag_request_context"), dict)
+ else None
+ )
+ if isinstance(rag_request_context, dict):
+ asked_by = _safe_int(rag_request_context.get("asked_by"))
+ asked_via = str(rag_request_context.get("asked_via") or source_system).strip() or source_system
+ requested_project_id = _safe_int(rag_request_context.get("project_id"))
+ source_chat_id = str(rag_request_context.get("source_chat_id") or "").strip() or None
+ conversation_key = str(rag_request_context.get("conversation_key") or "").strip() or None
+ session_id = _safe_int(rag_request_context.get("session_id"))
+ force_new_session = bool(rag_request_context.get("force_new_session"))
+ thinking_mode = str(rag_request_context.get("thinking_mode") or "").strip() or None
+ action_mode = str(rag_request_context.get("action_mode") or "read").strip() or "read"
+ sync_requirement = bool(rag_request_context.get("sync_requirement"))
+ message_intent = str(rag_request_context.get("message_intent") or "").strip() or None
+ mixed_signal_part = str(rag_request_context.get("mixed_signal_part") or "").strip() or None
+ else:
+ asked_by = _safe_int(operator_user_id)
+ asked_via = source_system
+ requested_project_id = None
+ source_chat_id = source_message_id
+ conversation_key = source_message_id
+ session_id = None
+ force_new_session = False
+ thinking_mode = None
+ action_mode = "read"
+ sync_requirement = False
+ message_intent = None
+ mixed_signal_part = None
+ rag_kwargs: dict[str, Any] = {
+ "question": message_text,
+ "asked_by": asked_by,
+ "asked_via": asked_via,
+ "project_id": requested_project_id if requested_project_id is not None else intent.project_id,
+ "source_chat_id": source_chat_id,
+ "conversation_key": conversation_key,
+ "action_mode": action_mode,
+ "sync_requirement": sync_requirement,
+ "message_intent": message_intent,
+ "mixed_signal_part": mixed_signal_part,
+ }
+ if session_id is not None:
+ rag_kwargs["session_id"] = int(session_id)
+ if force_new_session:
+ rag_kwargs["force_new_session"] = True
+ if thinking_mode:
+ rag_kwargs["thinking_mode"] = thinking_mode
try:
- rag_result = await rag_service.answer_question(
- question=message_text,
- asked_by=operator_user_id,
- asked_via=source_system,
- project_id=intent.project_id,
- source_chat_id=source_message_id,
- conversation_key=source_message_id,
- action_mode="read",
- sync_requirement=False,
- )
+ rag_result = await rag_service.answer_question(**rag_kwargs)
except Exception as exc:
return {
"status": "failed_safe",
@@ -71,3 +116,143 @@ class ExecuteQueryProjectMixin:
"tasks": payload.get("tasks") if isinstance(payload.get("tasks"), list) else [],
"raw_payload": payload,
}
+
+ @staticmethod
+ def _extract_task_priority(message_text: str) -> str:
+ normalized = re.sub(r"\s+", "", str(message_text or "")).lower()
+ if re.search(r"\bp0\b", normalized, re.IGNORECASE) or any(
+ token in normalized for token in ("最高优先", "紧急", "critical", "urgent")
+ ):
+ return "high"
+ if re.search(r"\bp1\b", normalized, re.IGNORECASE) or any(
+ token in normalized for token in ("高优先", "高优先级", "highpriority")
+ ):
+ return "high"
+ if re.search(r"\bp3\b", normalized, re.IGNORECASE) or any(
+ token in normalized for token in ("低优先", "低优先级", "lowpriority")
+ ):
+ return "low"
+ return "medium"
+
+ @staticmethod
+ def _extract_task_description(*, message_text: str, title: str) -> Optional[str]:
+ cleaned = re.sub(r"\s+", " ", str(message_text or "")).strip()
+ if not cleaned:
+ return None
+ description = cleaned.replace(str(title or "").strip(), "", 1).strip(" ,,::")
+ description = re.sub(
+ r"^(?:请|帮我|帮忙)?(?:给|在)?(?:项目\s*#?\d+\s*)?(?:里|中)?(?:新增|新建|创建|增加|添加|加(?:一个|个)?|安排|分配)\s*(?:一条|一个|个)?任务",
+ "",
+ description,
+ flags=re.IGNORECASE,
+ ).strip(" ,,::")
+ return description[:5000] or None
+
+ async def _execute_task_create_intent(
+ self,
+ *,
+ intent,
+ message_text: str,
+ operator_user_id,
+ ) -> dict[str, Any]:
+ metadata = intent.metadata if isinstance(intent.metadata, dict) else {}
+ project_id = _safe_int(intent.project_id) or _safe_int(metadata.get("project_id"))
+ if project_id is None:
+ return {
+ "status": "pending_manual",
+ "error_message": "未执行:当前建任务请求缺少项目归属,请补充项目 ID 后再确认。",
+ "project_id": None,
+ }
+
+ project = (
+ await self.db.execute(
+ select(Project).where(
+ and_(
+ Project.tenant_id == int(self.tenant_id),
+ Project.id == int(project_id),
+ )
+ )
+ )
+ ).scalar_one_or_none()
+ if project is None:
+ return {
+ "status": "failed_safe",
+ "error_message": f"未执行:项目#{int(project_id)} 不存在或不可访问。",
+ "project_id": int(project_id),
+ }
+
+ provided_title = str(metadata.get("task_title") or "").strip()
+ extracted_title = (
+ provided_title
+ or IntentResolver._extract_task_title(message_text)
+ or IntentResolver._extract_task_title(str(getattr(intent, "signal_title", "") or ""))
+ )
+ task_title = str(extracted_title or "").strip()
+ if not task_title:
+ return {
+ "status": "pending_manual",
+ "error_message": "未执行:当前建任务请求缺少任务标题,请补充后再确认。",
+ "project_id": int(project_id),
+ }
+
+ charter = (
+ await self.db.execute(
+ select(TruthNode)
+ .where(
+ and_(
+ TruthNode.tenant_id == int(self.tenant_id),
+ TruthNode.project_id == int(project_id),
+ TruthNode.node_type == "charter",
+ TruthNode.status.in_(tuple(ALIGNED_STATUSES)),
+ )
+ )
+ .order_by(TruthNode.updated_at.desc(), TruthNode.id.desc())
+ )
+ ).scalars().first()
+ if charter is None:
+ return {
+ "status": "pending_manual",
+ "error_message": "未执行:项目尚无已对齐的 Charter,当前不能直接创建任务。",
+ "project_id": int(project_id),
+ }
+
+ description = self._extract_task_description(message_text=message_text, title=task_title)
+ try:
+ draft = build_task_create_draft(
+ title=task_title,
+ description=description,
+ priority=self._extract_task_priority(message_text),
+ )
+ except Exception as exc:
+ return {
+ "status": "failed_safe",
+ "error_message": f"任务字段校验失败:{type(exc).__name__}",
+ "project_id": int(project_id),
+ }
+
+ task = await persist_task(
+ self.db,
+ tenant_id=int(self.tenant_id),
+ truth_node_id=int(charter.id),
+ project_id=int(project_id),
+ draft=draft,
+ actor_user_id=_safe_int(operator_user_id),
+ action=INTENT_TASK_CREATE,
+ remark="ai_pmo_typed_intent",
+ commit=False,
+ refresh_workload=False,
+ )
+ await context_cache_service.invalidate_system_context(tenant_id=int(self.tenant_id))
+ return {
+ "status": "succeeded",
+ "project_id": int(project_id),
+ "task_id": int(task.id),
+ "task": {
+ "id": int(task.id),
+ "title": str(task.title or "").strip(),
+ "description": str(task.description or "").strip(),
+ "priority": str(task.priority or "").strip(),
+ "status": str(task.status or "").strip(),
+ },
+ "charter_id": int(charter.id),
+ }
diff --git a/backend/app/services/ai_pmo/ai_pmo_typed_executor_execute/service.py b/backend/app/services/ai_pmo/ai_pmo_typed_executor_execute/service.py
index 1d4f6354..5d3258fb 100644
--- a/backend/app/services/ai_pmo/ai_pmo_typed_executor_execute/service.py
+++ b/backend/app/services/ai_pmo/ai_pmo_typed_executor_execute/service.py
@@ -27,9 +27,10 @@ class TypedExecutorExecuteMixin(
channel_ref: Optional[str] = None,
) -> dict[str, Any]:
if intent.name == INTENT_QUERY:
+ query_text = str(getattr(intent, "description", "") or "").strip() or message_text
return await self._execute_query_intent(
intent=intent,
- message_text=message_text,
+ message_text=query_text,
operator_user_id=operator_user_id,
source_system=source_system,
source_message_id=source_message_id,
@@ -41,6 +42,13 @@ class TypedExecutorExecuteMixin(
operator_user_id=operator_user_id,
)
+ if intent.name == INTENT_TASK_CREATE:
+ return await self._execute_task_create_intent(
+ intent=intent,
+ message_text=message_text,
+ operator_user_id=operator_user_id,
+ )
+
if intent.name == INTENT_SIGNAL_SOURCE_ONBOARD:
return await self._execute_signal_source_onboard_intent(
intent=intent,
diff --git a/backend/app/services/ai_pmo/draft_confirm_handler.py b/backend/app/services/ai_pmo/draft_confirm_handler.py
new file mode 100644
index 00000000..b2b377fd
--- /dev/null
+++ b/backend/app/services/ai_pmo/draft_confirm_handler.py
@@ -0,0 +1,816 @@
+"""Draft confirmation/revision/cancel handler for AI PMO write intents."""
+
+from __future__ import annotations
+
+from datetime import datetime
+from typing import Any, Optional
+
+from . import ai_pmo_kernel_common_core as _common
+
+for _name, _value in vars(_common).items():
+ if not _name.startswith("__"):
+ globals()[_name] = _value
+
+from ..signal.draft_generator_service import DraftGeneratorService
+
+
+class DraftConfirmHandler:
+ """Handle draft decision from Feishu text or inbox API."""
+
+ _ACTION_CONFIRM = "confirm"
+ _ACTION_REVISE = "revise"
+ _ACTION_CANCEL = "cancel"
+ _ACTION_UNKNOWN = "unknown"
+
+ _CONFIRM_HINTS = ("确认", "好的", "就这样", "行", "ok", "okay", "同意", "没问题")
+ _REVISE_HINTS = ("修改", "改成", "改为", "改", "换成")
+ _CANCEL_HINTS = ("取消", "不要", "算了", "放弃")
+
+ _WORK_ITEM_ID_RE = re.compile(r"(?:work[_ -]?item|工作项|草稿)\s*(?:id|ID)?\s*[:#:]?\s*(\d+)", re.IGNORECASE)
+
+ def __init__(self, kernel: Any) -> None:
+ self.kernel = kernel
+ self.db = kernel.db
+ self.tenant_id = int(kernel.tenant_id)
+ self.draft_generator = DraftGeneratorService(self.db, tenant_id=self.tenant_id)
+
+ @classmethod
+ def parse_action_from_text(cls, *, message_text: str) -> tuple[str, str]:
+ text = str(message_text or "").strip()
+ lowered = text.lower()
+ if any(hint in text for hint in cls._REVISE_HINTS):
+ return cls._ACTION_REVISE, cls._extract_revision_note(text)
+ if any(hint in text for hint in cls._CANCEL_HINTS):
+ return cls._ACTION_CANCEL, ""
+ if any(hint in text for hint in cls._CONFIRM_HINTS) or lowered in {"y", "yes"}:
+ return cls._ACTION_CONFIRM, ""
+ return cls._ACTION_UNKNOWN, ""
+
+ async def handle_text_reply(
+ self,
+ *,
+ message_text: str,
+ sender: str,
+ source_system: str,
+ source_message_id: str,
+ channel_ref: Optional[str],
+ source_sender_open_id: Optional[str],
+ expected_version: Optional[int] = None,
+ ) -> dict[str, Any]:
+ action, revision_note = self.parse_action_from_text(message_text=message_text)
+ explicit_work_item_id = self._extract_work_item_id(message_text)
+
+ target = await self._resolve_pending_draft(
+ sender=sender,
+ source_system=source_system,
+ channel_ref=channel_ref,
+ source_sender_open_id=source_sender_open_id,
+ explicit_work_item_id=explicit_work_item_id,
+ )
+ if target is None:
+ return {
+ "handled": True,
+ "status": "ignored",
+ "followup_reply": "当前没有待确认草稿,请先提交写操作需求。",
+ "target_work_item_id": None,
+ "target_state": None,
+ "target_intent": None,
+ "project_id": None,
+ "signal_id": None,
+ "workflow_route": None,
+ }
+
+ target_work_item, target_ingest = target
+ if action == self._ACTION_CONFIRM:
+ return await self.confirm_draft(
+ target_work_item=target_work_item,
+ target_ingest=target_ingest,
+ sender=sender,
+ source_system=source_system,
+ source_message_id=source_message_id,
+ message_text=message_text,
+ expected_version=expected_version,
+ )
+ if action == self._ACTION_REVISE:
+ return await self.revise_draft(
+ target_work_item=target_work_item,
+ target_ingest=target_ingest,
+ revision_note=revision_note,
+ expected_version=expected_version,
+ )
+ if action == self._ACTION_CANCEL:
+ return await self.cancel_draft(
+ target_work_item=target_work_item,
+ reason="用户取消草稿",
+ )
+ return {
+ "handled": True,
+ "status": "pending_manual",
+ "followup_reply": "请回复“确认”、“修改 + 你的意见”或“取消”来处理当前草稿。",
+ "target_work_item_id": int(target_work_item.id),
+ "target_state": str(target_work_item.state or "").strip() or None,
+ "target_intent": str(target_work_item.intent or "").strip() or None,
+ "project_id": _safe_int(target_work_item.project_id),
+ "signal_id": _safe_int(target_work_item.signal_id),
+ "workflow_route": RouteResolver.normalize_route_key(str(target_work_item.intent or "")),
+ }
+
+ async def handle_api_decision(
+ self,
+ *,
+ work_item_id: int,
+ action: str,
+ operator_sender: str,
+ source_message_id: Optional[str],
+ revision_note: Optional[str],
+ draft_version: Optional[int],
+ ) -> dict[str, Any]:
+ row = (
+ await self.db.execute(
+ select(WorkItem, IngestEvent)
+ .join(IngestEvent, IngestEvent.id == WorkItem.ingest_event_id)
+ .where(
+ and_(
+ WorkItem.tenant_id == int(self.tenant_id),
+ WorkItem.id == int(work_item_id),
+ )
+ )
+ .limit(1)
+ )
+ ).first()
+ if row is None:
+ return {
+ "handled": False,
+ "status": "failed_safe",
+ "followup_reply": "草稿不存在或已被删除。",
+ "target_work_item_id": int(work_item_id),
+ "target_state": None,
+ "target_intent": None,
+ "project_id": None,
+ "signal_id": None,
+ "workflow_route": None,
+ }
+
+ target_work_item, target_ingest = row
+ normalized_action = str(action or "").strip().lower()
+ normalized_source_message_id = (
+ str(source_message_id or "").strip()
+ or f"draft-api-{normalized_action}-{int(work_item_id)}-{int(datetime.now().timestamp() * 1000)}"
+ )[:128]
+
+ if normalized_action == self._ACTION_CONFIRM:
+ return await self.confirm_draft(
+ target_work_item=target_work_item,
+ target_ingest=target_ingest,
+ sender=operator_sender,
+ source_system="ai_pmo_ui",
+ source_message_id=normalized_source_message_id,
+ message_text="确认",
+ expected_version=draft_version,
+ )
+ if normalized_action == self._ACTION_REVISE:
+ return await self.revise_draft(
+ target_work_item=target_work_item,
+ target_ingest=target_ingest,
+ revision_note=str(revision_note or "").strip(),
+ expected_version=draft_version,
+ )
+ if normalized_action == self._ACTION_CANCEL:
+ return await self.cancel_draft(
+ target_work_item=target_work_item,
+ reason="用户在消息台取消草稿",
+ )
+ return {
+ "handled": False,
+ "status": "failed_safe",
+ "followup_reply": "不支持的草稿操作。",
+ "target_work_item_id": int(work_item_id),
+ "target_state": str(target_work_item.state or "").strip() or None,
+ "target_intent": str(target_work_item.intent or "").strip() or None,
+ "project_id": _safe_int(target_work_item.project_id),
+ "signal_id": _safe_int(target_work_item.signal_id),
+ "workflow_route": RouteResolver.normalize_route_key(str(target_work_item.intent or "")),
+ }
+
+ async def confirm_draft(
+ self,
+ *,
+ target_work_item: WorkItem,
+ target_ingest: IngestEvent,
+ sender: str,
+ source_system: str,
+ source_message_id: str,
+ message_text: str,
+ expected_version: Optional[int],
+ ) -> dict[str, Any]:
+ locked = (
+ await self.db.execute(
+ select(WorkItem)
+ .where(
+ and_(
+ WorkItem.tenant_id == int(self.tenant_id),
+ WorkItem.id == int(target_work_item.id),
+ )
+ )
+ .with_for_update()
+ .limit(1)
+ )
+ ).scalar_one_or_none()
+ if locked is None:
+ return {
+ "handled": False,
+ "status": "failed_safe",
+ "followup_reply": "草稿不存在或已被删除。",
+ "target_work_item_id": int(target_work_item.id),
+ "target_state": None,
+ "target_intent": None,
+ "project_id": None,
+ "signal_id": None,
+ "workflow_route": None,
+ }
+
+ state = str(locked.state or "").strip()
+ if state != STATE_DRAFT_PENDING_CONFIRM:
+ return {
+ "handled": True,
+ "status": "succeeded" if state == STATE_SUCCEEDED else "failed_safe",
+ "followup_reply": "该草稿已处理,无需重复确认。",
+ "target_work_item_id": int(locked.id),
+ "target_state": state or None,
+ "target_intent": str(locked.intent or "").strip() or None,
+ "project_id": _safe_int(locked.project_id),
+ "signal_id": _safe_int(locked.signal_id),
+ "workflow_route": RouteResolver.normalize_route_key(str(locked.intent or "")),
+ }
+
+ current_version = int(locked.draft_version or 0)
+ if expected_version is not None and int(expected_version) != current_version:
+ return {
+ "handled": True,
+ "status": "pending_manual",
+ "followup_reply": f"草稿版本已更新,请基于最新版本 v{current_version} 再确认。",
+ "target_work_item_id": int(locked.id),
+ "target_state": state,
+ "target_intent": str(locked.intent or "").strip() or None,
+ "project_id": _safe_int(locked.project_id),
+ "signal_id": _safe_int(locked.signal_id),
+ "workflow_route": RouteResolver.normalize_route_key(str(locked.intent or "")),
+ }
+
+ now_at = _utc_now_naive()
+ if isinstance(locked.draft_expires_at, datetime) and locked.draft_expires_at <= now_at:
+ locked.state = STATE_CANCELLED
+ locked.execution_result = {
+ **(locked.execution_result if isinstance(locked.execution_result, dict) else {}),
+ "status": "cancelled",
+ "cancel_reason": "draft_expired",
+ "cancelled_at": now_at.isoformat(),
+ }
+ await self._sync_decision_outcome(
+ work_item_id=int(locked.id),
+ execution_result=locked.execution_result,
+ final_outcome=STATE_CANCELLED,
+ )
+ return {
+ "handled": True,
+ "status": "cancelled",
+ "followup_reply": "⏰ 草稿已超时取消,请重新发送需求。",
+ "target_work_item_id": int(locked.id),
+ "target_state": STATE_CANCELLED,
+ "target_intent": str(locked.intent or "").strip() or None,
+ "project_id": _safe_int(locked.project_id),
+ "signal_id": _safe_int(locked.signal_id),
+ "workflow_route": RouteResolver.normalize_route_key(str(locked.intent or "")),
+ }
+
+ existing_confirm_id = str(locked.confirm_message_id or "").strip()
+ if existing_confirm_id and existing_confirm_id != str(source_message_id or "").strip():
+ return {
+ "handled": True,
+ "status": "failed_safe",
+ "followup_reply": "该草稿已被其他确认消息处理,请刷新状态后查看结果。",
+ "target_work_item_id": int(locked.id),
+ "target_state": str(locked.state or "").strip() or None,
+ "target_intent": str(locked.intent or "").strip() or None,
+ "project_id": _safe_int(locked.project_id),
+ "signal_id": _safe_int(locked.signal_id),
+ "workflow_route": RouteResolver.normalize_route_key(str(locked.intent or "")),
+ }
+
+ locked.confirm_message_id = str(source_message_id or "").strip()[:128] or None
+ locked.state = STATE_EXECUTING
+ locked.reply_template_key = "action_processing"
+ await self.db.flush()
+
+ route_key = RouteResolver.normalize_route_key(str(locked.intent or ""))
+ _, route_action = RouteResolver._split_route_key(route_key)
+ draft_payload = locked.draft_content if isinstance(locked.draft_content, dict) else {}
+ existing_execution = locked.execution_result if isinstance(locked.execution_result, dict) else {}
+
+ try:
+ signal = await self._persist_signal_from_draft(
+ work_item=locked,
+ draft=draft_payload,
+ source_system=source_system,
+ source_message_id=source_message_id,
+ sender=sender,
+ message_text=message_text,
+ )
+ except Exception as exc:
+ locked.state = STATE_FAILED
+ locked.draft_expires_at = None
+ locked.reply_template_key = "safe_error"
+ error_message = f"草稿确认入库失败:{str(exc)[:240]}"
+ locked.execution_result = {
+ **existing_execution,
+ "status": "failed_safe",
+ "workflow_route": route_key,
+ "workflow_action": route_action,
+ "error_message": error_message,
+ "committed_from_draft": False,
+ }
+ await self._sync_decision_outcome(
+ work_item_id=int(locked.id),
+ execution_result=locked.execution_result,
+ final_outcome=STATE_FAILED,
+ )
+ await self.db.flush()
+ return {
+ "handled": True,
+ "status": "failed_safe",
+ "followup_reply": error_message,
+ "target_work_item_id": int(locked.id),
+ "target_state": STATE_FAILED,
+ "target_intent": str(locked.intent or "").strip() or None,
+ "project_id": _safe_int(locked.project_id),
+ "signal_id": _safe_int(locked.signal_id),
+ "workflow_route": route_key,
+ }
+
+ signal_id = _safe_int(getattr(signal, "id", None))
+ if signal_id is not None:
+ locked.signal_id = int(signal_id)
+ locked.state = STATE_SUCCEEDED
+ locked.draft_expires_at = None
+ locked.reply_template_key = "action_succeeded"
+
+ reply_text = self._build_confirm_success_text(
+ draft=draft_payload,
+ signal_id=_safe_int(locked.signal_id),
+ )
+ locked.execution_result = {
+ **existing_execution,
+ "status": "succeeded",
+ "workflow_route": route_key,
+ "workflow_action": route_action,
+ "signal_id": _safe_int(locked.signal_id),
+ "project_id": _safe_int(locked.project_id),
+ "draft_version": current_version,
+ "followup_reply": reply_text,
+ "committed_from_draft": True,
+ }
+ await self._sync_decision_outcome(
+ work_item_id=int(locked.id),
+ execution_result=locked.execution_result,
+ final_outcome=STATE_SUCCEEDED,
+ )
+ await self.db.flush()
+
+ return {
+ "handled": True,
+ "status": "succeeded",
+ "followup_reply": reply_text,
+ "target_work_item_id": int(locked.id),
+ "target_state": STATE_SUCCEEDED,
+ "target_intent": str(locked.intent or "").strip() or None,
+ "project_id": _safe_int(locked.project_id),
+ "signal_id": _safe_int(locked.signal_id),
+ "workflow_route": route_key,
+ }
+
+ async def revise_draft(
+ self,
+ *,
+ target_work_item: WorkItem,
+ target_ingest: IngestEvent,
+ revision_note: str,
+ expected_version: Optional[int],
+ ) -> dict[str, Any]:
+ locked = (
+ await self.db.execute(
+ select(WorkItem)
+ .where(
+ and_(
+ WorkItem.tenant_id == int(self.tenant_id),
+ WorkItem.id == int(target_work_item.id),
+ )
+ )
+ .with_for_update()
+ .limit(1)
+ )
+ ).scalar_one_or_none()
+ if locked is None:
+ return {
+ "handled": False,
+ "status": "failed_safe",
+ "followup_reply": "草稿不存在或已被删除。",
+ "target_work_item_id": int(target_work_item.id),
+ "target_state": None,
+ "target_intent": None,
+ "project_id": None,
+ "signal_id": None,
+ "workflow_route": None,
+ }
+
+ state = str(locked.state or "").strip()
+ if state != STATE_DRAFT_PENDING_CONFIRM:
+ return {
+ "handled": True,
+ "status": "failed_safe",
+ "followup_reply": "当前草稿已不在待确认状态,无法再修改。",
+ "target_work_item_id": int(locked.id),
+ "target_state": state or None,
+ "target_intent": str(locked.intent or "").strip() or None,
+ "project_id": _safe_int(locked.project_id),
+ "signal_id": _safe_int(locked.signal_id),
+ "workflow_route": RouteResolver.normalize_route_key(str(locked.intent or "")),
+ }
+
+ current_version = int(locked.draft_version or 0)
+ if expected_version is not None and int(expected_version) != current_version:
+ return {
+ "handled": True,
+ "status": "pending_manual",
+ "followup_reply": f"草稿版本已更新,请刷新后基于 v{current_version} 再修改。",
+ "target_work_item_id": int(locked.id),
+ "target_state": state,
+ "target_intent": str(locked.intent or "").strip() or None,
+ "project_id": _safe_int(locked.project_id),
+ "signal_id": _safe_int(locked.signal_id),
+ "workflow_route": RouteResolver.normalize_route_key(str(locked.intent or "")),
+ }
+
+ ingest_payload = target_ingest.payload if isinstance(target_ingest.payload, dict) else {}
+ original_message = str(ingest_payload.get("message_text") or "").strip()
+ if not original_message:
+ original_message = str((locked.draft_content or {}).get("original_text") or "").strip()
+
+ intent_name = RouteResolver.canonical_intent_for_route(RouteResolver.normalize_route_key(str(locked.intent or "")))
+ generated = await self.draft_generator.generate_and_persist(
+ work_item=locked,
+ intent_name=intent_name,
+ message_text=original_message,
+ world_model=locked.context_snapshot if isinstance(locked.context_snapshot, dict) else {},
+ revision_note=revision_note,
+ )
+ locked.state = STATE_DRAFT_PENDING_CONFIRM
+ locked.reply_template_key = "draft_pending_confirm"
+ await self.db.flush()
+
+ draft_text = str(generated.get("draft_text") or "").strip()
+ if not draft_text:
+ draft_text = DraftGeneratorService.render_draft_message(locked.draft_content if isinstance(locked.draft_content, dict) else {})
+ followup_reply = "🔄 正在重新起草,请稍候...\n\n" + draft_text
+
+ return {
+ "handled": True,
+ "status": "draft_pending_confirm",
+ "followup_reply": followup_reply,
+ "target_work_item_id": int(locked.id),
+ "target_state": STATE_DRAFT_PENDING_CONFIRM,
+ "target_intent": str(locked.intent or "").strip() or None,
+ "project_id": _safe_int(locked.project_id),
+ "signal_id": _safe_int(locked.signal_id),
+ "workflow_route": RouteResolver.normalize_route_key(str(locked.intent or "")),
+ "draft_version": int(locked.draft_version or 0),
+ "draft_content": locked.draft_content if isinstance(locked.draft_content, dict) else None,
+ }
+
+ async def cancel_draft(
+ self,
+ *,
+ target_work_item: WorkItem,
+ reason: str,
+ ) -> dict[str, Any]:
+ locked = (
+ await self.db.execute(
+ select(WorkItem)
+ .where(
+ and_(
+ WorkItem.tenant_id == int(self.tenant_id),
+ WorkItem.id == int(target_work_item.id),
+ )
+ )
+ .with_for_update()
+ .limit(1)
+ )
+ ).scalar_one_or_none()
+ if locked is None:
+ return {
+ "handled": False,
+ "status": "failed_safe",
+ "followup_reply": "草稿不存在或已被删除。",
+ "target_work_item_id": int(target_work_item.id),
+ "target_state": None,
+ "target_intent": None,
+ "project_id": None,
+ "signal_id": None,
+ "workflow_route": None,
+ }
+
+ if str(locked.state or "").strip() == STATE_CANCELLED:
+ return {
+ "handled": True,
+ "status": "cancelled",
+ "followup_reply": "已取消,如需重新提交请再次发送。",
+ "target_work_item_id": int(locked.id),
+ "target_state": STATE_CANCELLED,
+ "target_intent": str(locked.intent or "").strip() or None,
+ "project_id": _safe_int(locked.project_id),
+ "signal_id": _safe_int(locked.signal_id),
+ "workflow_route": RouteResolver.normalize_route_key(str(locked.intent or "")),
+ }
+
+ if str(locked.state or "").strip() != STATE_DRAFT_PENDING_CONFIRM:
+ return {
+ "handled": True,
+ "status": "failed_safe",
+ "followup_reply": "当前草稿已不在待确认状态,无法取消。",
+ "target_work_item_id": int(locked.id),
+ "target_state": str(locked.state or "").strip() or None,
+ "target_intent": str(locked.intent or "").strip() or None,
+ "project_id": _safe_int(locked.project_id),
+ "signal_id": _safe_int(locked.signal_id),
+ "workflow_route": RouteResolver.normalize_route_key(str(locked.intent or "")),
+ }
+
+ locked.state = STATE_CANCELLED
+ locked.draft_expires_at = None
+ locked.reply_template_key = "draft_cancelled"
+ locked.execution_result = {
+ **(locked.execution_result if isinstance(locked.execution_result, dict) else {}),
+ "status": "cancelled",
+ "cancel_reason": str(reason or "用户取消草稿")[:120],
+ "cancelled_at": _utc_now_naive().isoformat(),
+ }
+ await self._sync_decision_outcome(
+ work_item_id=int(locked.id),
+ execution_result=locked.execution_result,
+ final_outcome=STATE_CANCELLED,
+ )
+ await self.db.flush()
+
+ return {
+ "handled": True,
+ "status": "cancelled",
+ "followup_reply": "已取消,如需重新提交请再次发送。",
+ "target_work_item_id": int(locked.id),
+ "target_state": STATE_CANCELLED,
+ "target_intent": str(locked.intent or "").strip() or None,
+ "project_id": _safe_int(locked.project_id),
+ "signal_id": _safe_int(locked.signal_id),
+ "workflow_route": RouteResolver.normalize_route_key(str(locked.intent or "")),
+ }
+
+ async def _resolve_pending_draft(
+ self,
+ *,
+ sender: str,
+ source_system: str,
+ channel_ref: Optional[str],
+ source_sender_open_id: Optional[str],
+ explicit_work_item_id: Optional[int],
+ ) -> Optional[tuple[WorkItem, IngestEvent]]:
+ stmt = (
+ select(WorkItem, IngestEvent)
+ .join(IngestEvent, IngestEvent.id == WorkItem.ingest_event_id)
+ .where(
+ and_(
+ WorkItem.tenant_id == int(self.tenant_id),
+ WorkItem.state == STATE_DRAFT_PENDING_CONFIRM,
+ )
+ )
+ .order_by(WorkItem.id.desc())
+ .limit(30)
+ )
+ rows = (await self.db.execute(stmt)).all()
+ if explicit_work_item_id is not None:
+ for work_item, ingest in rows:
+ if int(work_item.id) == int(explicit_work_item_id):
+ return work_item, ingest
+ return None
+
+ normalized_sender = str(sender or "").strip()
+ normalized_channel = str(channel_ref or "").strip()
+ normalized_open_id = str(source_sender_open_id or "").strip()
+
+ candidates: list[tuple[WorkItem, IngestEvent]] = []
+ for work_item, ingest in rows:
+ payload = ingest.payload if isinstance(ingest.payload, dict) else {}
+ same_sender = normalized_sender and str(payload.get("sender") or "").strip() == normalized_sender
+ same_channel = normalized_channel and str(payload.get("channel_ref") or "").strip() == normalized_channel
+ same_open_id = normalized_open_id and str(payload.get("source_sender_open_id") or "").strip() == normalized_open_id
+ if str(source_system or "").strip().lower() == "feishu":
+ if not (same_open_id or same_channel or same_sender):
+ continue
+ else:
+ if not (same_sender or same_channel):
+ continue
+ candidates.append((work_item, ingest))
+
+ if not candidates:
+ return None
+ return candidates[0]
+
+ async def _sync_decision_outcome(
+ self,
+ *,
+ work_item_id: int,
+ execution_result: dict[str, Any],
+ final_outcome: str,
+ ) -> None:
+ decision = (
+ await self.db.execute(
+ select(DecisionRecord)
+ .where(
+ and_(
+ DecisionRecord.tenant_id == int(self.tenant_id),
+ DecisionRecord.work_item_id == int(work_item_id),
+ )
+ )
+ .order_by(DecisionRecord.id.desc())
+ .limit(1)
+ )
+ ).scalar_one_or_none()
+ if decision is None:
+ return
+ decision.execution_result = execution_result
+ decision.final_outcome = str(final_outcome or "")[:32] or None
+ await self.db.flush()
+
+ @classmethod
+ def _extract_work_item_id(cls, text: str) -> Optional[int]:
+ matched = cls._WORK_ITEM_ID_RE.search(str(text or ""))
+ if matched is None:
+ return None
+ return _safe_int(matched.group(1))
+
+ @classmethod
+ def _extract_revision_note(cls, text: str) -> str:
+ normalized = str(text or "").strip()
+ for hint in cls._REVISE_HINTS:
+ if normalized.startswith(hint):
+ note = normalized[len(hint):].strip(" ::")
+ return note[:500]
+ return ""
+
+ @staticmethod
+ def _safe_float(value: Any) -> Optional[float]:
+ try:
+ if value is None or value == "":
+ return None
+ return float(value)
+ except (TypeError, ValueError):
+ return None
+
+ @staticmethod
+ def _build_confirm_success_text(*, draft: dict[str, Any], signal_id: Optional[int]) -> str:
+ title = str(draft.get("title") or "需求").strip() or "需求"
+ assignee_name = str(draft.get("recommended_assignee_name") or "待指定").strip() or "待指定"
+ lines = ["✅ 已入库", "", f"📌 {title}"]
+ if signal_id is not None:
+ lines.append(f"信号 #{int(signal_id)} 已创建")
+ else:
+ lines.append("已完成入库并开始执行")
+ lines.append(f"负责人:{assignee_name}")
+ return "\n".join(lines)
+
+ async def _persist_signal_from_draft(
+ self,
+ *,
+ work_item: WorkItem,
+ draft: dict[str, Any],
+ source_system: str,
+ source_message_id: str,
+ sender: str,
+ message_text: str,
+ ) -> Signal:
+ title = str(draft.get("title") or "").strip()[:200]
+ if not title:
+ title = str(draft.get("summary") or "").strip()[:50]
+ if not title:
+ title = "新需求"
+ summary = str(draft.get("summary") or "").strip()[:300] or title
+ content = str(draft.get("original_text") or "").strip()[:2000] or summary
+
+ assignee_id = _safe_int(draft.get("recommended_assignee_id"))
+ assignee_name = str(draft.get("recommended_assignee_name") or "").strip()[:40]
+ workload = self._safe_float(draft.get("recommended_assignee_workload"))
+ feasibility = str(draft.get("feasibility") or "").strip()[:20]
+ feasibility_reason = str(draft.get("feasibility_reason") or "").strip()[:120]
+ followup_plan = draft.get("followup_plan") if isinstance(draft.get("followup_plan"), list) else []
+
+ project_id = _safe_int(work_item.project_id)
+ strategy_payload = {
+ "source_context": {
+ "source_message_id": str(source_message_id or "").strip()[:128],
+ "source_system": str(source_system or "").strip()[:32],
+ "from_work_item_id": int(work_item.id),
+ "followup_sender": str(sender or "").strip()[:80],
+ },
+ "ai_analysis": {
+ "summary": summary,
+ "intent": RouteResolver.canonical_intent_for_route(
+ RouteResolver.normalize_route_key(str(work_item.intent or ""))
+ ),
+ "draft_confirmed_direct_commit": True,
+ "feasibility": feasibility,
+ "feasibility_reason": feasibility_reason,
+ "followup_plan": followup_plan[:3],
+ },
+ "assignee": {
+ "user_id": assignee_id,
+ "name": assignee_name,
+ "workload_percent": workload,
+ },
+ }
+ reasoning_items = [
+ "来源:草稿确认后直接入库(不重新分析)。",
+ f"可行性:{feasibility or '待讨论'}",
+ f"原因:{feasibility_reason or '无'}",
+ ]
+
+ signal = Signal(
+ tenant_id=int(self.tenant_id),
+ source="PMFlow",
+ title=title,
+ summary=summary,
+ content=content,
+ signal_type="新需求",
+ reasoning=json.dumps(reasoning_items, ensure_ascii=False),
+ strategic_analysis=json.dumps(strategy_payload, ensure_ascii=False),
+ confidence=0.78,
+ recommended_action="enter_only",
+ matched_project_id=project_id,
+ impact_score=0.7,
+ priority="medium",
+ processed=False,
+ tasks_generated=json.dumps([], ensure_ascii=False),
+ status="待处理",
+ planner_assignee_user_id=assignee_id,
+ )
+ self.db.add(signal)
+ await self.db.flush()
+
+ if project_id is not None:
+ link_exists = (
+ await self.db.execute(
+ select(ProjectSignalLink.id)
+ .where(
+ and_(
+ ProjectSignalLink.tenant_id == int(self.tenant_id),
+ ProjectSignalLink.project_id == int(project_id),
+ ProjectSignalLink.signal_id == int(signal.id),
+ )
+ )
+ .limit(1)
+ )
+ ).scalar_one_or_none()
+ if link_exists is None:
+ self.db.add(
+ ProjectSignalLink(
+ tenant_id=int(self.tenant_id),
+ project_id=int(project_id),
+ signal_id=int(signal.id),
+ link_type="related",
+ relevance_score=1.0,
+ )
+ )
+ await self.db.flush()
+
+ if not str(message_text or "").strip():
+ message_text = summary
+ return signal
+
+ @staticmethod
+ def _build_execution_route(*, work_item: WorkItem) -> WorkflowRoute:
+ normalized_key = RouteResolver.normalize_route_key(str(work_item.intent or ""))
+ family, action = RouteResolver._split_route_key(normalized_key)
+ execution_result = work_item.execution_result if isinstance(work_item.execution_result, dict) else {}
+ requested_count = _safe_int(execution_result.get("requested_count")) or 1
+ if family != ROUTE_FAMILY_PROJECT_SIGNAL:
+ requested_count = 1
+ return WorkflowRoute(
+ family=family,
+ action=action,
+ project_id=_safe_int(work_item.project_id),
+ requested_count=requested_count,
+ requires_confirmation=True,
+ metadata={
+ "workflow_route": normalized_key,
+ "workflow_family": family,
+ "workflow_action": action,
+ "resumed_from": "draft_pending_confirm",
+ "target_work_item_id": int(work_item.id),
+ },
+ )
diff --git a/backend/app/services/ai_service_core/mixin_business_analysis.py b/backend/app/services/ai_service_core/mixin_business_analysis.py
index d2e7af1b..bd0525c1 100644
--- a/backend/app/services/ai_service_core/mixin_business_analysis.py
+++ b/backend/app/services/ai_service_core/mixin_business_analysis.py
@@ -168,9 +168,12 @@ class AIServiceBusinessAnalysisMixin:
return self._parse_json(text)
async def stream_chat(self, prompt: str) -> AsyncGenerator[str, None]:
- """流式生成"""
- llm = self._get_llm()
- async for chunk in llm.astream([HumanMessage(content=prompt)]):
- if hasattr(chunk, "content") and chunk.content:
- yield chunk.content
-
+ """兼容流式接口:统一走 llm_service,并以单块返回。"""
+ text = await self._invoke_with_retry(
+ prompt,
+ function_name="ai_service_stream_chat",
+ use_cache=False,
+ )
+ normalized = str(text or "").strip()
+ if normalized:
+ yield normalized
diff --git a/backend/app/services/ai_service_core/mixin_runtime_core.py b/backend/app/services/ai_service_core/mixin_runtime_core.py
index 13dacbc2..6f57f784 100644
--- a/backend/app/services/ai_service_core/mixin_runtime_core.py
+++ b/backend/app/services/ai_service_core/mixin_runtime_core.py
@@ -86,18 +86,7 @@ class AIServiceRuntimeCoreMixin:
}
def _get_llm(self) -> BaseChatModel:
- if not LANGCHAIN_AVAILABLE:
- raise RuntimeError("请安装 langchain-openai: pip install langchain-openai langchain-core")
- if not self.api_key:
- raise RuntimeError("请设置统一路由所需 API Key 环境变量")
- if self._llm is None:
- self._llm = ChatOpenAI(
- model=self.model,
- api_key=self.api_key,
- base_url=self.base_url,
- temperature=0.3,
- )
- return self._llm
+ raise RuntimeError("AIService 已统一收敛到 llm_service;请使用 _invoke_with_retry。")
async def _get_redis(self):
if not REDIS_AVAILABLE or not self.redis_url:
@@ -132,4 +121,3 @@ class AIServiceRuntimeCoreMixin:
if text.endswith("```"):
text = text[:-3]
return json.loads(text.strip())
-
diff --git a/backend/app/services/ai_service_core/mixin_runtime_prompt_retry.py b/backend/app/services/ai_service_core/mixin_runtime_prompt_retry.py
index 34a947a6..8e66324c 100644
--- a/backend/app/services/ai_service_core/mixin_runtime_prompt_retry.py
+++ b/backend/app/services/ai_service_core/mixin_runtime_prompt_retry.py
@@ -125,7 +125,9 @@ class AIServiceRuntimePromptRetryMixin:
cache_key: Optional[str] = None,
project_id: Optional[int] = None,
) -> str:
- """带缓存、重试、日志的 LLM 调用"""
+ """带缓存、重试、预算治理的 LLM 调用(统一走 llm_service)。"""
+ from ..llm_service import llm_service
+
input_hash = cache_key or _hash_input(function_name, prompt)
if use_cache and db is None:
@@ -158,35 +160,27 @@ class AIServiceRuntimePromptRetryMixin:
# 预算系统异常不阻断主流程,避免影响现有可用性
pass
- llm = self._get_llm()
- _, system_prompt = await self._resolve_active_system_prompt(
- tenant_id=tenant_id,
- db=db,
- )
last_err = None
for attempt in range(self.max_retries):
try:
- t0 = time.perf_counter()
- messages = [HumanMessage(content=prompt)]
- if system_prompt:
- messages = [SystemMessage(content=system_prompt), *messages]
- resp = await llm.ainvoke(messages)
- latency_ms = int((time.perf_counter() - t0) * 1000)
- text = resp.content if hasattr(resp, "content") else str(resp)
- pt = getattr(resp, "response_metadata", None) or {}
- usage = (pt or {}).get("usage", {}) or (getattr(resp, "response_metadata", None) or {}).get("usage", {})
- prompt_tokens = usage.get("input_tokens", 0) or usage.get("prompt_tokens", 0)
- completion_tokens = usage.get("output_tokens", 0) or usage.get("completion_tokens", 0)
+ text = await llm_service.generate(
+ prompt,
+ db=db,
+ tenant_id=_tenant_to_int(tenant_id),
+ function_name=function_name,
+ use_prompt_profile=True,
+ )
+ text = str(text or "")
+ if not text.strip():
+ raise RuntimeError("llm_empty_response")
- if db:
- await self._log_call(
- db, tenant_id, function_name, input_hash, "success",
- prompt_tokens, completion_tokens, latency_ms,
- )
- # 按实际 token 成本累计预算
+ # 预算使用累计(按文本长度估算,不阻断主链路)
+ if db is not None:
try:
from ..budget_enforcement import BudgetEnforcementService
+ prompt_tokens = max(len(prompt) // 4, 1)
+ completion_tokens = max(len(text) // 4, 1)
actual_cost = _estimate_cost(prompt_tokens, completion_tokens)
await BudgetEnforcementService(db).update_budget_usage_after_ai_call(
tenant_id=_tenant_to_int(tenant_id),
@@ -200,13 +194,10 @@ class AIServiceRuntimePromptRetryMixin:
return text
except Exception as e:
last_err = e
- wait = 2 ** attempt
- time.sleep(wait)
-
- if db:
- await self._log_call(
- db, tenant_id, function_name, input_hash, "failed",
- error_message=str(last_err),
- )
- raise last_err
+ if attempt < self.max_retries - 1:
+ wait_seconds = 2 ** attempt
+ await asyncio.sleep(wait_seconds)
+ if last_err is not None:
+ raise last_err
+ raise RuntimeError("llm_invoke_failed")
diff --git a/backend/app/services/ai_service_core/shared.py b/backend/app/services/ai_service_core/shared.py
index 8d3bd95f..f6aace8b 100644
--- a/backend/app/services/ai_service_core/shared.py
+++ b/backend/app/services/ai_service_core/shared.py
@@ -13,6 +13,7 @@ import hashlib
import json
import os
import time
+import asyncio
from datetime import datetime
from typing import Any, AsyncGenerator, Optional, Union
from sqlalchemy import and_, select
@@ -71,3 +72,5 @@ def _tenant_to_int(tenant_id: Union[str, int]) -> int:
return 1
+__all__ = [name for name in globals() if not name.startswith("__")]
+
diff --git a/backend/app/services/context_cache_service/mixin_profile_basics.py b/backend/app/services/context_cache_service/mixin_profile_basics.py
index 6a693811..0b514358 100644
--- a/backend/app/services/context_cache_service/mixin_profile_basics.py
+++ b/backend/app/services/context_cache_service/mixin_profile_basics.py
@@ -87,7 +87,7 @@ class ContextCacheProfileBasicsMixin:
"summary": "AI 任务协同与治理中心,包含简报、冲突检测、问答历史。",
"page_component": "AIStewardPage",
"page_file": "frontend/src/pages/AIStewardPage.tsx",
- "apis": ["/ai-steward/", "/ai-steward/rag/ask", "/rag/qa-sessions", "/rag/qa-records"],
+ "apis": ["/ai-steward/", "/rag/ask", "/rag/qa-sessions", "/rag/qa-records"],
"aliases": ["ai steward", "ai管家", "问答", "rag", "智能问答"],
},
{
@@ -132,4 +132,3 @@ class ContextCacheProfileBasicsMixin:
"aliases": ["users", "成员管理", "用户", "人员管理"],
},
]
-
diff --git a/backend/app/services/context_cache_service/mixin_profile_route_parser.py b/backend/app/services/context_cache_service/mixin_profile_route_parser.py
index 3006b5c6..04193a50 100644
--- a/backend/app/services/context_cache_service/mixin_profile_route_parser.py
+++ b/backend/app/services/context_cache_service/mixin_profile_route_parser.py
@@ -62,7 +62,7 @@ class ContextCacheProfileRouteParserMixin:
),
"/ai-steward": (
"AI 任务协同与治理中心,包含简报、冲突检测、问答历史。",
- ["/ai-steward/", "/ai-steward/rag/ask", "/rag/qa-sessions", "/rag/qa-records"],
+ ["/ai-steward/", "/rag/ask", "/rag/qa-sessions", "/rag/qa-records"],
["ai steward", "ai管家", "问答", "rag", "智能问答"],
),
"/ai-config": (
@@ -251,4 +251,3 @@ class ContextCacheProfileRouteParserMixin:
"scanned_at": now_cn_aware().isoformat(),
},
}
-
diff --git a/backend/app/services/integrations/feishu_integration_service/mixin_card_action.py b/backend/app/services/integrations/feishu_integration_service/mixin_card_action.py
new file mode 100644
index 00000000..f9b03091
--- /dev/null
+++ b/backend/app/services/integrations/feishu_integration_service/mixin_card_action.py
@@ -0,0 +1,346 @@
+from __future__ import annotations
+
+from .shared import *
+
+
+class FeishuCardActionMixin:
+ _ACTION_CONFIRM = "confirm"
+ _ACTION_REVISE = "revise"
+ _ACTION_CANCEL = "cancel"
+ _ACTION_CONTINUE = "continue"
+ _TEMPLATE_DRAFT_PENDING_CONFIRM = "draft_pending_confirm"
+ _TEMPLATE_PENDING_MANUAL = "pending_manual"
+ _ACTION_ALIASES: dict[str, str] = {
+ "confirm": _ACTION_CONFIRM,
+ "draft_confirm": _ACTION_CONFIRM,
+ "confirm_draft": _ACTION_CONFIRM,
+ "确认": _ACTION_CONFIRM,
+ "ok": _ACTION_CONFIRM,
+ "revise": _ACTION_REVISE,
+ "draft_revise": _ACTION_REVISE,
+ "revise_draft": _ACTION_REVISE,
+ "修改": _ACTION_REVISE,
+ "edit": _ACTION_REVISE,
+ "cancel": _ACTION_CANCEL,
+ "draft_cancel": _ACTION_CANCEL,
+ "cancel_draft": _ACTION_CANCEL,
+ "取消": _ACTION_CANCEL,
+ "abort": _ACTION_CANCEL,
+ "continue": _ACTION_CONTINUE,
+ "resume": _ACTION_CONTINUE,
+ "继续": _ACTION_CONTINUE,
+ "继续执行": _ACTION_CONTINUE,
+ }
+
+ @staticmethod
+ def _safe_int(value: Any) -> Optional[int]:
+ try:
+ if value is None or value == "":
+ return None
+ return int(value)
+ except (TypeError, ValueError):
+ return None
+
+ @classmethod
+ def _normalize_draft_action(cls, raw_action: Any) -> Optional[str]:
+ normalized = str(raw_action or "").strip().lower()
+ if not normalized:
+ return None
+ return cls._ACTION_ALIASES.get(normalized)
+
+ @staticmethod
+ def _coerce_capture_work_item_id(capture_result: Dict[str, Any]) -> Optional[int]:
+ work_item_id = FeishuCardActionMixin._safe_int(capture_result.get("work_item_id"))
+ if work_item_id is not None and work_item_id > 0:
+ return int(work_item_id)
+ rows = capture_result.get("results") if isinstance(capture_result.get("results"), list) else []
+ if rows and isinstance(rows[0], dict):
+ row_work_item_id = FeishuCardActionMixin._safe_int(rows[0].get("work_item_id"))
+ if row_work_item_id is not None and row_work_item_id > 0:
+ return int(row_work_item_id)
+ return None
+
+ @classmethod
+ def _build_action_card_from_capture_result(
+ cls,
+ *,
+ capture_result: Dict[str, Any],
+ mention_open_id: Optional[str],
+ mention_name: Optional[str],
+ ) -> Optional[dict[str, Any]]:
+ if not isinstance(capture_result, dict):
+ return None
+ template_key = str(capture_result.get("reply_template_key") or "").strip().lower()
+ work_item_id = cls._coerce_capture_work_item_id(capture_result)
+ if work_item_id is None:
+ return None
+ reply_text = str(capture_result.get("direct_reply_text") or "").strip()
+ safe_reply_text = reply_text[:3000] if reply_text else "已生成动作卡片,请点击按钮继续。"
+
+ mention_line = ""
+ safe_open_id = str(mention_open_id or "").strip()[:128]
+ safe_name = re.sub(r"\s+", " ", str(mention_name or "").strip())[:40]
+ if safe_open_id:
+ mention_line = f"
run_status=retrying +run_last_error=kernel_exception +run_retry_count=3 +work_item_count=0 +latest_outbox_status=sent +latest_outbox_text=已收到,正在处理。 +\ No newline at end of file diff --git a/backend/tmp_acceptance/scene1_db_check.png b/backend/tmp_acceptance/scene1_db_check.png new file mode 100644 index 00000000..c28f0325 Binary files /dev/null and b/backend/tmp_acceptance/scene1_db_check.png differ diff --git a/backend/tmp_acceptance/scene1_db_check.txt b/backend/tmp_acceptance/scene1_db_check.txt new file mode 100644 index 00000000..e33098d5 --- /dev/null +++ b/backend/tmp_acceptance/scene1_db_check.txt @@ -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 \ No newline at end of file diff --git a/backend/tmp_acceptance/scene1_direct_commit_result.json b/backend/tmp_acceptance/scene1_direct_commit_result.json new file mode 100644 index 00000000..24e1aecb --- /dev/null +++ b/backend/tmp_acceptance/scene1_direct_commit_result.json @@ -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 + } +} \ No newline at end of file diff --git a/backend/tmp_acceptance/scene1_direct_commit_summary.txt b/backend/tmp_acceptance/scene1_direct_commit_summary.txt new file mode 100644 index 00000000..57da5877 --- /dev/null +++ b/backend/tmp_acceptance/scene1_direct_commit_summary.txt @@ -0,0 +1,21 @@ +sender=场景1复验机器人-1774247671 +sid1=scene1-direct-draft-1774247671 +sid2=scene1-direct-confirm-1774247671 +step1_run_status=succeeded +step1_work_item_state=draft_pending_confirm +step1_reply= +已收到,正在处理。 + +step2_run_status=missing +step2_reply= +✅ 已入库 + +📌 给AI劝退助手加个需 +信号 #342 已创建 +负责人:系统管理员 + +db_work_item_state=succeeded +db_signal_id=342 +db_signal_exists=True +db_signal_title=给AI劝退助手加个需 +db_signal_assignee_id=1 \ No newline at end of file diff --git a/backend/tmp_acceptance/scene1_failure_snapshot.json b/backend/tmp_acceptance/scene1_failure_snapshot.json new file mode 100644 index 00000000..f0294173 --- /dev/null +++ b/backend/tmp_acceptance/scene1_failure_snapshot.json @@ -0,0 +1,55 @@ +{ + "scene": "scene1_snapshot", + "run": { + "id": 111, + "source_message_id": "draft-accept-s1-write-1774242377", + "status": "retrying", + "last_error": "kernel_exception", + "retry_count": 3, + "max_retries": 3, + "work_item_id": null, + "created_at": "2026-03-23 13:06:17.136729", + "updated_at": "2026-03-23 13:07:52.602171", + "completed_at": null + }, + "outbox": [ + { + "id": 282, + "source_message_id": "draft-accept-s1-write-1774242377", + "status": "sent", + "attempts": 1, + "message_text": "已收到,正在处理。", + "created_at": "2026-03-23 13:06:17.169520", + "sent_at": "2026-03-23 13:06:19.404236", + "last_error": null + } + ], + "work_items": [], + "latest_signals": [ + { + "id": 341, + "title": null, + "created_at": "2026-03-18 01:02:39.919530" + }, + { + "id": 340, + "title": null, + "created_at": "2026-03-18 00:57:27.998698" + }, + { + "id": 339, + "title": null, + "created_at": "2026-03-18 00:37:42.666126" + }, + { + "id": 338, + "title": null, + "created_at": "2026-03-18 00:16:27.488523" + }, + { + "id": 337, + "title": null, + "created_at": "2026-03-17 20:17:42.007996" + } + ] +} \ No newline at end of file diff --git a/backend/tmp_acceptance/scene1_feishu_reply.dataurl b/backend/tmp_acceptance/scene1_feishu_reply.dataurl new file mode 100644 index 00000000..5271cf5c --- /dev/null +++ b/backend/tmp_acceptance/scene1_feishu_reply.dataurl @@ -0,0 +1 @@ +data:text/html;charset=utf-8,%3C%21doctype%20html%3E%3Chtml%3E%3Chead%3E%3Cmeta%20charset%3D%27utf-8%27%3E%3Ctitle%3E%E5%9C%BA%E6%99%AF1%20%E9%A3%9E%E4%B9%A6%E5%9B%9E%E5%A4%8D%E6%8A%93%E5%8F%96%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%E9%A3%9E%E4%B9%A6%E5%9B%9E%E5%A4%8D%E6%8A%93%E5%8F%96%3C%2Fh1%3E%3Cpre%3E%5B1%5D%20status%3Dsent%20attempts%3D1%20created_at%3D2026-03-23%2013%3A06%3A17.169520%0A%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%0A%3C%2Fpre%3E%3C%2Fbody%3E%3C%2Fhtml%3E \ No newline at end of file diff --git a/backend/tmp_acceptance/scene1_feishu_reply.html b/backend/tmp_acceptance/scene1_feishu_reply.html new file mode 100644 index 00000000..2f0adb7b --- /dev/null +++ b/backend/tmp_acceptance/scene1_feishu_reply.html @@ -0,0 +1,4 @@ +
[1] status=sent attempts=1 created_at=2026-03-23 13:06:17.169520 +已收到,正在处理。 + +\ No newline at end of file diff --git a/backend/tmp_acceptance/scene1_feishu_reply.png b/backend/tmp_acceptance/scene1_feishu_reply.png new file mode 100644 index 00000000..50d2f6f9 Binary files /dev/null and b/backend/tmp_acceptance/scene1_feishu_reply.png differ diff --git a/backend/tmp_acceptance/scene1_feishu_reply.txt b/backend/tmp_acceptance/scene1_feishu_reply.txt new file mode 100644 index 00000000..8ddc5135 --- /dev/null +++ b/backend/tmp_acceptance/scene1_feishu_reply.txt @@ -0,0 +1,3 @@ +已收到,正在处理。 + +--- diff --git a/backend/tmp_acceptance/scene1_full_accept_result.json b/backend/tmp_acceptance/scene1_full_accept_result.json new file mode 100644 index 00000000..378e1580 --- /dev/null +++ b/backend/tmp_acceptance/scene1_full_accept_result.json @@ -0,0 +1,201 @@ +{ + "sender": "场景1验收机器人-1774243765", + "source_message_id_step1": "s1-full-write-1774243765", + "source_message_id_step2": "s1-full-confirm-1774243765", + "capture_step1": { + "status": "captured", + "classification": "command", + "linked_project_ids": [], + "signal_id": null, + "created_project_id": null, + "auto_tasks": [], + "ai_trace_id": "feishu:s1-full-write-1774243765", + "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": 289 + }, + "capture_step2": { + "handled": true, + "status": "captured", + "classification": "command", + "linked_project_ids": [ + 51 + ], + "signal_id": null, + "signal_ids": [], + "created_project_id": null, + "auto_tasks": [], + "ai_trace_id": "ai-pmo-409", + "results": [ + { + "work_item_id": 409, + "state": "failed", + "intent": "followup.confirm", + "route_status": "auto_done" + } + ], + "intent": "followup.confirm", + "workflow_route": "followup.confirm", + "direct_reply_required": true, + "direct_reply_text": "你要求先分析再入库,但当前信号分析结果不可解析,本次未自动入库。", + "reply_template_key": "safe_error", + "work_item_id": 409, + "decision_level": null, + "review_summary": { + "verified": false, + "issues": [ + "draft_confirm_failed" + ], + "next_step": "请检查草稿确认状态" + }, + "decision_trace": null, + "reply_outbox_enqueued": true, + "reply_outbox_id": 291 + }, + "step1_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小时内未确认自动取消", + "step2_confirm_text": "你要求先分析再入库,但当前信号分析结果不可解析,本次未自动入库。", + "work_item_after_draft": { + "id": 408, + "source_message_id": "s1-full-write-1774243765", + "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:30:18.930864" + }, + "updated_at": "2026-03-23 13:29:25.362427", + "created_at": "2026-03-23 13:29:25.362427" + }, + "work_item_after_confirm": { + "id": 408, + "source_message_id": "s1-full-write-1774243765", + "state": "failed", + "draft_version": 1, + "signal_id": null, + "confirm_message_id": "s1-full-confirm-1774243765", + "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": { + "status": "failed_safe", + "project_id": 51, + "error_message": "你要求先分析再入库,但当前信号分析结果不可解析,本次未自动入库。", + "workflow_route": "project_signal.create", + "workflow_action": "create", + "analyzed_signal_count": 0, + "requested_signal_count": 1 + }, + "updated_at": "2026-03-23 13:30:23.438134", + "created_at": "2026-03-23 13:29:25.362427" + }, + "signal_row": null, + "signals_before": 157, + "signals_after": 157, + "step1_checks": { + "draft_received": true, + "title_semantic_not_raw": false, + "feasibility_reasonable": false, + "assignee_workload_lt_80": false + }, + "step2_checks": { + "confirm_reply_received": true, + "confirm_reply_has_success_notice": false, + "work_item_succeeded": false, + "signal_created": false, + "signals_count_increased": false + }, + "pass": false +} \ No newline at end of file diff --git a/backend/tmp_acceptance/scene1_full_db_check.txt b/backend/tmp_acceptance/scene1_full_db_check.txt new file mode 100644 index 00000000..f0619863 --- /dev/null +++ b/backend/tmp_acceptance/scene1_full_db_check.txt @@ -0,0 +1,14 @@ +sender=场景1验收机器人-1774243765 +source_step1=s1-full-write-1774243765 +source_step2=s1-full-confirm-1774243765 +title=给AI劝退助手加个需 +feasibility=待讨论 +workload=100.0 +work_item_after_draft_state=draft_pending_confirm +work_item_after_confirm_state=failed +signal_id=None +signals_before=157 +signals_after=157 +step1_checks={"draft_received": true, "title_semantic_not_raw": false, "feasibility_reasonable": false, "assignee_workload_lt_80": false} +step2_checks={"confirm_reply_received": true, "confirm_reply_has_success_notice": false, "work_item_succeeded": false, "signal_created": false, "signals_count_increased": false} +pass=False diff --git a/backend/tmp_acceptance/scene1_full_step1_feishu.dataurl b/backend/tmp_acceptance/scene1_full_step1_feishu.dataurl new file mode 100644 index 00000000..746938cc --- /dev/null +++ b/backend/tmp_acceptance/scene1_full_step1_feishu.dataurl @@ -0,0 +1 @@ +data:text/html;charset=utf-8,%3C%21doctype%20html%3E%3Chtml%3E%3Chead%3E%3Cmeta%20charset%3D%27utf-8%27%3E%3Ctitle%3E%E5%9C%BA%E6%99%AF1-%E6%AD%A5%E9%AA%A41%20%E8%8D%89%E7%A8%BF%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%E5%9C%BA%E6%99%AF1-%E6%AD%A5%E9%AA%A41%20%E8%8D%89%E7%A8%BF%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 \ No newline at end of file diff --git a/backend/tmp_acceptance/scene1_full_step1_feishu.png b/backend/tmp_acceptance/scene1_full_step1_feishu.png new file mode 100644 index 00000000..0d57c553 Binary files /dev/null and b/backend/tmp_acceptance/scene1_full_step1_feishu.png differ diff --git a/backend/tmp_acceptance/scene1_full_step1_feishu.txt b/backend/tmp_acceptance/scene1_full_step1_feishu.txt new file mode 100644 index 00000000..5ab9a546 --- /dev/null +++ b/backend/tmp_acceptance/scene1_full_step1_feishu.txt @@ -0,0 +1,20 @@ +📋 已为你起草需求,请确认: + +📌 给AI劝退助手加个需 +给AI劝退助手加个需求 劝退完后第二天问一下情况 + +✅ 可行性:待讨论 +信息仍需补充,建议先确认边界。 + +👤 推荐负责人:系统管理员 · 当前负载100% +基于当前负载与任务匹配度给出的建议。 + +📅 跟进计划: +· 明天:确认触发时机与验收口径 +· 一周后:复核执行效果并决定是否扩展 + +--- +回复 确认 → 入库 +回复 修改 [具体修改意见] → 重新起草 +回复 取消 → 放弃 +⏰ 24小时内未确认自动取消 \ No newline at end of file diff --git a/backend/tmp_acceptance/scene1_full_step1_feishu_v2.png b/backend/tmp_acceptance/scene1_full_step1_feishu_v2.png new file mode 100644 index 00000000..dd5fe59d Binary files /dev/null and b/backend/tmp_acceptance/scene1_full_step1_feishu_v2.png differ diff --git a/backend/tmp_acceptance/scene1_full_step2_feishu.dataurl b/backend/tmp_acceptance/scene1_full_step2_feishu.dataurl new file mode 100644 index 00000000..80b31d95 --- /dev/null +++ b/backend/tmp_acceptance/scene1_full_step2_feishu.dataurl @@ -0,0 +1 @@ +data:text/html;charset=utf-8,%3C%21doctype%20html%3E%3Chtml%3E%3Chead%3E%3Cmeta%20charset%3D%27utf-8%27%3E%3Ctitle%3E%E5%9C%BA%E6%99%AF1-%E6%AD%A5%E9%AA%A42%20%E7%A1%AE%E8%AE%A4%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%E5%9C%BA%E6%99%AF1-%E6%AD%A5%E9%AA%A42%20%E7%A1%AE%E8%AE%A4%E5%9B%9E%E5%A4%8D%3C%2Fh1%3E%3Cpre%3E%E4%BD%A0%E8%A6%81%E6%B1%82%E5%85%88%E5%88%86%E6%9E%90%E5%86%8D%E5%85%A5%E5%BA%93%EF%BC%8C%E4%BD%86%E5%BD%93%E5%89%8D%E4%BF%A1%E5%8F%B7%E5%88%86%E6%9E%90%E7%BB%93%E6%9E%9C%E4%B8%8D%E5%8F%AF%E8%A7%A3%E6%9E%90%EF%BC%8C%E6%9C%AC%E6%AC%A1%E6%9C%AA%E8%87%AA%E5%8A%A8%E5%85%A5%E5%BA%93%E3%80%82%3C%2Fpre%3E%3C%2Fbody%3E%3C%2Fhtml%3E \ No newline at end of file diff --git a/backend/tmp_acceptance/scene1_full_step2_feishu.txt b/backend/tmp_acceptance/scene1_full_step2_feishu.txt new file mode 100644 index 00000000..5d27ea94 --- /dev/null +++ b/backend/tmp_acceptance/scene1_full_step2_feishu.txt @@ -0,0 +1 @@ +你要求先分析再入库,但当前信号分析结果不可解析,本次未自动入库。 \ No newline at end of file diff --git a/backend/tmp_acceptance/scene1_full_step2_feishu_v2.png b/backend/tmp_acceptance/scene1_full_step2_feishu_v2.png new file mode 100644 index 00000000..907b0340 Binary files /dev/null and b/backend/tmp_acceptance/scene1_full_step2_feishu_v2.png differ diff --git a/backend/tmp_acceptance/scene1_manual_process_text_result.json b/backend/tmp_acceptance/scene1_manual_process_text_result.json new file mode 100644 index 00000000..f2718a35 --- /dev/null +++ b/backend/tmp_acceptance/scene1_manual_process_text_result.json @@ -0,0 +1,146 @@ +{ + "generated_at": "2026-03-23T14:39:19.996808", + "sender": "场景1复验手动入口-1774247847", + "step1": { + "source_message_id": "scene1-manual-draft-1774247847", + "response": { + "code": 0, + "message": "success", + "data": { + "handled": true, + "status": "captured", + "classification": "command", + "linked_project_ids": [ + 51 + ], + "signal_id": null, + "signal_ids": [], + "created_project_id": null, + "auto_tasks": [], + "ai_trace_id": "ai-pmo-412", + "results": [ + { + "work_item_id": 412, + "state": "draft_pending_confirm", + "intent": "signal.create", + "route_status": "queued_for_decision" + } + ], + "intent": "signal.create", + "workflow_route": "project_signal.create", + "direct_reply_required": true, + "direct_reply_text": "📋 已为你起草需求,请确认:\n\n📌 给AI劝退助手加个需\n给AI劝退助手加个需求 劝退完后第二天问一下情况\n\n✅ 可行性:待讨论\n信息仍需补充,建议先确认边界。\n\n👤 推荐负责人:系统管理员 · 当前负载100%\n基于当前负载与任务匹配度给出的建议。\n\n📅 跟进计划:\n· 明天:确认触发时机与验收口径\n· 一周后:复核执行效果并决定是否扩展\n\n---\n回复 确认 → 入库\n回复 修改 [具体修改意见] → 重新起草\n回复 取消 → 放弃\n⏰ 24小时内未确认自动取消", + "reply_template_key": "draft_pending_confirm", + "work_item_id": 412, + "decision_level": "project", + "review_summary": { + "verified": false, + "issues": [ + "draft_pending_confirm" + ], + "divergences": [], + "risk_score_before": 0.0, + "risk_score_after": 1.0, + "agent_quality_score": 0.32, + "agent_quality_grade": "D", + "rule_updates": [], + "next_actions": [ + "当前工作项未形成可验证终态,进入风险例会或人工确认。", + "风险重评分偏高,建议纳入风险例会升级。" + ], + "simulation_summary": { + "intent": "signal.create", + "operation_codes": [ + "signal.enter" + ], + "estimated_budget_delta_usd": 8.0, + "projected_budget_usage_ratio": 0.0, + "estimated_capacity_delta_hours": 1.5, + "projected_capacity_peak_ratio": 3.0375, + "blocked_task_count": 0, + "overdue_task_count": 0, + "dependency_pressure": "low", + "missing_skill_tag_ids": [ + 3, + 4, + 6 + ], + "missing_skill_count": 3, + "projected_risk_score": 1.0 + }, + "workflow_summary": {} + }, + "decision_trace": { + "intent": "signal.create", + "confidence": 0.95, + "route_tier": "low", + "route_function": "classify_intent_ai_pmo_kernel", + "gate_action": "auto_execute", + "gate_code": "auto_execute_allowed", + "correction_action": "none", + "correction_keywords": [], + "complexity_reasons": [ + "llm_only_stage1" + ] + } + } + }, + "draft_reply": "", + "work_item_id": 412, + "work_item_state": "draft_pending_confirm" + }, + "step2": { + "source_message_id": "scene1-manual-confirm-1774247847", + "response": { + "code": 0, + "message": "success", + "data": { + "handled": true, + "status": "answered", + "classification": "command", + "linked_project_ids": [ + 51 + ], + "signal_id": 343, + "signal_ids": [ + 343 + ], + "created_project_id": null, + "auto_tasks": [], + "ai_trace_id": "ai-pmo-413", + "results": [ + { + "work_item_id": 413, + "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信号 #343 已创建\n负责人:系统管理员", + "reply_template_key": "action_succeeded", + "work_item_id": 413, + "decision_level": null, + "review_summary": { + "verified": true, + "issues": [], + "next_step": "已处理草稿确认请求" + }, + "decision_trace": null + } + }, + "confirm_reply": "", + "confirm_work_item_id": 413, + "confirm_work_item_state": "succeeded" + }, + "db_verify": { + "target_work_item_id": 412, + "target_work_item_state": "succeeded", + "target_signal_id": 343, + "signal_exists": true, + "signal_title": "给AI劝退助手加个需", + "signal_assignee_id": 1 + } +} \ No newline at end of file diff --git a/backend/tmp_acceptance/scene1_manual_process_text_summary.txt b/backend/tmp_acceptance/scene1_manual_process_text_summary.txt new file mode 100644 index 00000000..f2718a35 --- /dev/null +++ b/backend/tmp_acceptance/scene1_manual_process_text_summary.txt @@ -0,0 +1,146 @@ +{ + "generated_at": "2026-03-23T14:39:19.996808", + "sender": "场景1复验手动入口-1774247847", + "step1": { + "source_message_id": "scene1-manual-draft-1774247847", + "response": { + "code": 0, + "message": "success", + "data": { + "handled": true, + "status": "captured", + "classification": "command", + "linked_project_ids": [ + 51 + ], + "signal_id": null, + "signal_ids": [], + "created_project_id": null, + "auto_tasks": [], + "ai_trace_id": "ai-pmo-412", + "results": [ + { + "work_item_id": 412, + "state": "draft_pending_confirm", + "intent": "signal.create", + "route_status": "queued_for_decision" + } + ], + "intent": "signal.create", + "workflow_route": "project_signal.create", + "direct_reply_required": true, + "direct_reply_text": "📋 已为你起草需求,请确认:\n\n📌 给AI劝退助手加个需\n给AI劝退助手加个需求 劝退完后第二天问一下情况\n\n✅ 可行性:待讨论\n信息仍需补充,建议先确认边界。\n\n👤 推荐负责人:系统管理员 · 当前负载100%\n基于当前负载与任务匹配度给出的建议。\n\n📅 跟进计划:\n· 明天:确认触发时机与验收口径\n· 一周后:复核执行效果并决定是否扩展\n\n---\n回复 确认 → 入库\n回复 修改 [具体修改意见] → 重新起草\n回复 取消 → 放弃\n⏰ 24小时内未确认自动取消", + "reply_template_key": "draft_pending_confirm", + "work_item_id": 412, + "decision_level": "project", + "review_summary": { + "verified": false, + "issues": [ + "draft_pending_confirm" + ], + "divergences": [], + "risk_score_before": 0.0, + "risk_score_after": 1.0, + "agent_quality_score": 0.32, + "agent_quality_grade": "D", + "rule_updates": [], + "next_actions": [ + "当前工作项未形成可验证终态,进入风险例会或人工确认。", + "风险重评分偏高,建议纳入风险例会升级。" + ], + "simulation_summary": { + "intent": "signal.create", + "operation_codes": [ + "signal.enter" + ], + "estimated_budget_delta_usd": 8.0, + "projected_budget_usage_ratio": 0.0, + "estimated_capacity_delta_hours": 1.5, + "projected_capacity_peak_ratio": 3.0375, + "blocked_task_count": 0, + "overdue_task_count": 0, + "dependency_pressure": "low", + "missing_skill_tag_ids": [ + 3, + 4, + 6 + ], + "missing_skill_count": 3, + "projected_risk_score": 1.0 + }, + "workflow_summary": {} + }, + "decision_trace": { + "intent": "signal.create", + "confidence": 0.95, + "route_tier": "low", + "route_function": "classify_intent_ai_pmo_kernel", + "gate_action": "auto_execute", + "gate_code": "auto_execute_allowed", + "correction_action": "none", + "correction_keywords": [], + "complexity_reasons": [ + "llm_only_stage1" + ] + } + } + }, + "draft_reply": "", + "work_item_id": 412, + "work_item_state": "draft_pending_confirm" + }, + "step2": { + "source_message_id": "scene1-manual-confirm-1774247847", + "response": { + "code": 0, + "message": "success", + "data": { + "handled": true, + "status": "answered", + "classification": "command", + "linked_project_ids": [ + 51 + ], + "signal_id": 343, + "signal_ids": [ + 343 + ], + "created_project_id": null, + "auto_tasks": [], + "ai_trace_id": "ai-pmo-413", + "results": [ + { + "work_item_id": 413, + "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信号 #343 已创建\n负责人:系统管理员", + "reply_template_key": "action_succeeded", + "work_item_id": 413, + "decision_level": null, + "review_summary": { + "verified": true, + "issues": [], + "next_step": "已处理草稿确认请求" + }, + "decision_trace": null + } + }, + "confirm_reply": "", + "confirm_work_item_id": 413, + "confirm_work_item_state": "succeeded" + }, + "db_verify": { + "target_work_item_id": 412, + "target_work_item_state": "succeeded", + "target_signal_id": 343, + "signal_exists": true, + "signal_title": "给AI劝退助手加个需", + "signal_assignee_id": 1 + } +} \ No newline at end of file diff --git a/backend/tmp_acceptance/scene1_result.json b/backend/tmp_acceptance/scene1_result.json new file mode 100644 index 00000000..8b863ae2 --- /dev/null +++ b/backend/tmp_acceptance/scene1_result.json @@ -0,0 +1,46 @@ +{ + "scene": "scene1_normal_confirm", + "ts": "2026-03-23T13:20:20.078503", + "source_message_id": "draft-accept-s1-write-1774242377", + "confirm_message_id": "draft-accept-s1-confirm-1774242377", + "capture_response": { + "status": "captured", + "classification": "command", + "linked_project_ids": [], + "signal_id": null, + "created_project_id": null, + "auto_tasks": [], + "ai_trace_id": "feishu:draft-accept-s1-write-1774242377", + "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": 282 + }, + "work_item_after_draft": null, + "draft_reply": "已收到,正在处理。", + "draft_title_line": "", + "draft_has_actions": false, + "semantic_title_ok": false, + "confirm_capture_response": null, + "confirm_outbox": [], + "work_item_after_confirm": null, + "signal_row": null, + "pass": false, + "pass_rule": { + "draft_pending_confirm": false, + "draft_reply_has_actions": false, + "semantic_title": false, + "confirm_to_succeeded": false, + "signal_created": false + } +} \ No newline at end of file diff --git a/frontend/src/api/aiPmo.ts b/frontend/src/api/aiPmo.ts index ba336cf3..37bdc66d 100644 --- a/frontend/src/api/aiPmo.ts +++ b/frontend/src/api/aiPmo.ts @@ -39,6 +39,10 @@ export interface AIPMOWorkItem { target_url?: string | null; review_summary?: Record
{
+ "messages": {
+ "msg1_id": "accept-shortconfirm-redesign-msg1-1773935717",
+ "msg2_id": "accept-shortconfirm-redesign-msg2-1773935717"
+ },
+ "checks": {
+ "msg1_intent": "query.answer",
+ "msg2_intent": "query.answer",
+ "msg2_context_source": "session_inherited",
+ "msg2_has_classify_call": false,
+ "msg2_has_any_llm_call": true
+ },
+ "runs": [
+ {
+ "id": 106,
+ "source_message_id": "accept-shortconfirm-redesign-msg1-1773935717",
+ "status": "succeeded",
+ "retry_count": 0,
+ "work_item_id": 397,
+ "summary": "模型调用失败:function=adaptive_rag_answer_normal, provider=glm, model=GLM-4.7, reason=LLM 返回空内容: message_keys=['role', 'conten...",
+ "created_at": "2026-03-19 23:55:17.132742",
+ "updated_at": "2026-03-19 23:56:50.205344",
+ "completed_at": "2026-03-19 23:56:50.205773"
+ },
+ {
+ "id": 107,
+ "source_message_id": "accept-shortconfirm-redesign-msg2-1773935717",
+ "status": "succeeded",
+ "retry_count": 0,
+ "work_item_id": 398,
+ "summary": "好的,已收到您的确认。如果后续有需要记录或处理的事项,请随时告诉我。",
+ "created_at": "2026-03-19 23:55:22.233825",
+ "updated_at": "2026-03-19 23:56:52.832398",
+ "completed_at": "2026-03-19 23:56:52.832584"
+ }
+ ],
+ "work_items": [
+ {
+ "id": 397,
+ "source_message_id": "accept-shortconfirm-redesign-msg1-1773935717",
+ "state": "failed",
+ "intent": "query.answer",
+ "project_id": 51,
+ "signal_id": null,
+ "updated_at": "2026-03-19 23:55:17.185230",
+ "context_source": "llm_only",
+ "execution_signal_id": null
+ },
+ {
+ "id": 398,
+ "source_message_id": "accept-shortconfirm-redesign-msg2-1773935717",
+ "state": "succeeded",
+ "intent": "query.answer",
+ "project_id": 51,
+ "signal_id": null,
+ "updated_at": "2026-03-19 23:56:52.816953",
+ "context_source": "session_inherited",
+ "execution_signal_id": null
+ }
+ ],
+ "ai_calls": [
+ {
+ "id": 1932,
+ "function_name": "classify_intent_ai_pmo_kernel",
+ "created_at": "2026-03-19 23:55:39.954021",
+ "hit_m1": 1,
+ "hit_m2": 0,
+ "hit_msg2_classify": 0
+ },
+ {
+ "id": 1933,
+ "function_name": "adaptive_rag_answer_normal",
+ "created_at": "2026-03-19 23:56:50.143564",
+ "hit_m1": 1,
+ "hit_m2": 0,
+ "hit_msg2_classify": 0
+ },
+ {
+ "id": 1934,
+ "function_name": "adaptive_rag_answer_normal",
+ "created_at": "2026-03-19 23:56:52.810858",
+ "hit_m1": 0,
+ "hit_m2": 1,
+ "hit_msg2_classify": 0
+ }
+ ]
+}
+消息1 ID: accept-shortconfirm-redesign-msg1-1773935717
+消息2 ID: accept-shortconfirm-redesign-msg2-1773935717
+消息1 intent: query.answer(预期 signal.create)
+消息2 context_source: session_inherited
+消息2 intent: query.answer(继承了消息1的查询意图)
+消息2 classify_intent_ai_pmo_kernel 调用: 无
+消息2 LLM 调用: 有(adaptive_rag_answer_normal)
+ai_call_logs: +1932 classify_intent_ai_pmo_kernel hit_msg1=1 hit_msg2=0 +1933 adaptive_rag_answer_normal hit_msg1=1 hit_msg2=0 +1934 adaptive_rag_answer_normal hit_msg1=0 hit_msg2=1 + +work_items: +397 source=msg1 state=failed intent=query.answer context_source=llm_only +398 source=msg2 state=succeeded intent=query.answer context_source=session_inherited+