Unify AI PMO ingress flow and add draft confirmation execution ledger
This commit is contained in:
parent
435fabe563
commit
2e3de00c04
72
CLAUDE.md
72
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` 回归契约
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
]
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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"})
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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 = (
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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"}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 {}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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"))
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -169,8 +169,7 @@ 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, {}
|
||||
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, {
|
||||
|
|
@ -178,6 +177,22 @@ class KernelIntentRulesMixin:
|
|||
"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(),
|
||||
},
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
*,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
*,
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
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,
|
||||
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(**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),
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
},
|
||||
)
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
if db:
|
||||
await self._log_call(
|
||||
db, tenant_id, function_name, input_hash, "success",
|
||||
prompt_tokens, completion_tokens, latency_ms,
|
||||
text = await llm_service.generate(
|
||||
prompt,
|
||||
db=db,
|
||||
tenant_id=_tenant_to_int(tenant_id),
|
||||
function_name=function_name,
|
||||
use_prompt_profile=True,
|
||||
)
|
||||
# 按实际 token 成本累计预算
|
||||
text = str(text or "")
|
||||
if not text.strip():
|
||||
raise RuntimeError("llm_empty_response")
|
||||
|
||||
# 预算使用累计(按文本长度估算,不阻断主链路)
|
||||
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 attempt < self.max_retries - 1:
|
||||
wait_seconds = 2 ** attempt
|
||||
await asyncio.sleep(wait_seconds)
|
||||
|
||||
if db:
|
||||
await self._log_call(
|
||||
db, tenant_id, function_name, input_hash, "failed",
|
||||
error_message=str(last_err),
|
||||
)
|
||||
if last_err is not None:
|
||||
raise last_err
|
||||
|
||||
raise RuntimeError("llm_invoke_failed")
|
||||
|
|
|
|||
|
|
@ -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("__")]
|
||||
|
||||
|
|
|
|||
|
|
@ -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", "成员管理", "用户", "人员管理"],
|
||||
},
|
||||
]
|
||||
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
},
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,346 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from .shared import *
|
||||
|
||||
|
||||
class FeishuCardActionMixin:
|
||||
_ACTION_CONFIRM = "confirm"
|
||||
_ACTION_REVISE = "revise"
|
||||
_ACTION_CANCEL = "cancel"
|
||||
_ACTION_CONTINUE = "continue"
|
||||
_TEMPLATE_DRAFT_PENDING_CONFIRM = "draft_pending_confirm"
|
||||
_TEMPLATE_PENDING_MANUAL = "pending_manual"
|
||||
_ACTION_ALIASES: dict[str, str] = {
|
||||
"confirm": _ACTION_CONFIRM,
|
||||
"draft_confirm": _ACTION_CONFIRM,
|
||||
"confirm_draft": _ACTION_CONFIRM,
|
||||
"确认": _ACTION_CONFIRM,
|
||||
"ok": _ACTION_CONFIRM,
|
||||
"revise": _ACTION_REVISE,
|
||||
"draft_revise": _ACTION_REVISE,
|
||||
"revise_draft": _ACTION_REVISE,
|
||||
"修改": _ACTION_REVISE,
|
||||
"edit": _ACTION_REVISE,
|
||||
"cancel": _ACTION_CANCEL,
|
||||
"draft_cancel": _ACTION_CANCEL,
|
||||
"cancel_draft": _ACTION_CANCEL,
|
||||
"取消": _ACTION_CANCEL,
|
||||
"abort": _ACTION_CANCEL,
|
||||
"continue": _ACTION_CONTINUE,
|
||||
"resume": _ACTION_CONTINUE,
|
||||
"继续": _ACTION_CONTINUE,
|
||||
"继续执行": _ACTION_CONTINUE,
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _safe_int(value: Any) -> Optional[int]:
|
||||
try:
|
||||
if value is None or value == "":
|
||||
return None
|
||||
return int(value)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
@classmethod
|
||||
def _normalize_draft_action(cls, raw_action: Any) -> Optional[str]:
|
||||
normalized = str(raw_action or "").strip().lower()
|
||||
if not normalized:
|
||||
return None
|
||||
return cls._ACTION_ALIASES.get(normalized)
|
||||
|
||||
@staticmethod
|
||||
def _coerce_capture_work_item_id(capture_result: Dict[str, Any]) -> Optional[int]:
|
||||
work_item_id = FeishuCardActionMixin._safe_int(capture_result.get("work_item_id"))
|
||||
if work_item_id is not None and work_item_id > 0:
|
||||
return int(work_item_id)
|
||||
rows = capture_result.get("results") if isinstance(capture_result.get("results"), list) else []
|
||||
if rows and isinstance(rows[0], dict):
|
||||
row_work_item_id = FeishuCardActionMixin._safe_int(rows[0].get("work_item_id"))
|
||||
if row_work_item_id is not None and row_work_item_id > 0:
|
||||
return int(row_work_item_id)
|
||||
return None
|
||||
|
||||
@classmethod
|
||||
def _build_action_card_from_capture_result(
|
||||
cls,
|
||||
*,
|
||||
capture_result: Dict[str, Any],
|
||||
mention_open_id: Optional[str],
|
||||
mention_name: Optional[str],
|
||||
) -> Optional[dict[str, Any]]:
|
||||
if not isinstance(capture_result, dict):
|
||||
return None
|
||||
template_key = str(capture_result.get("reply_template_key") or "").strip().lower()
|
||||
work_item_id = cls._coerce_capture_work_item_id(capture_result)
|
||||
if work_item_id is None:
|
||||
return None
|
||||
reply_text = str(capture_result.get("direct_reply_text") or "").strip()
|
||||
safe_reply_text = reply_text[:3000] if reply_text else "已生成动作卡片,请点击按钮继续。"
|
||||
|
||||
mention_line = ""
|
||||
safe_open_id = str(mention_open_id or "").strip()[:128]
|
||||
safe_name = re.sub(r"\s+", " ", str(mention_name or "").strip())[:40]
|
||||
if safe_open_id:
|
||||
mention_line = f"<at id={safe_open_id}></at>{(' ' + safe_name) if safe_name else ''}"
|
||||
|
||||
base_elements: list[dict[str, Any]] = []
|
||||
if mention_line:
|
||||
base_elements.append(
|
||||
{
|
||||
"tag": "div",
|
||||
"text": {"tag": "lark_md", "content": mention_line},
|
||||
}
|
||||
)
|
||||
base_elements.append(
|
||||
{
|
||||
"tag": "div",
|
||||
"text": {"tag": "lark_md", "content": safe_reply_text},
|
||||
}
|
||||
)
|
||||
|
||||
if template_key == cls._TEMPLATE_DRAFT_PENDING_CONFIRM:
|
||||
draft_version = cls._safe_int(capture_result.get("draft_version"))
|
||||
actions = [
|
||||
{
|
||||
"tag": "button",
|
||||
"type": "primary",
|
||||
"text": {"tag": "plain_text", "content": "确认"},
|
||||
"value": {
|
||||
"action": cls._ACTION_CONFIRM,
|
||||
"work_item_id": int(work_item_id),
|
||||
"draft_version": draft_version,
|
||||
},
|
||||
},
|
||||
{
|
||||
"tag": "button",
|
||||
"type": "default",
|
||||
"text": {"tag": "plain_text", "content": "修改"},
|
||||
"value": {
|
||||
"action": cls._ACTION_REVISE,
|
||||
"work_item_id": int(work_item_id),
|
||||
"draft_version": draft_version,
|
||||
},
|
||||
},
|
||||
{
|
||||
"tag": "button",
|
||||
"type": "danger",
|
||||
"text": {"tag": "plain_text", "content": "取消"},
|
||||
"value": {
|
||||
"action": cls._ACTION_CANCEL,
|
||||
"work_item_id": int(work_item_id),
|
||||
"draft_version": draft_version,
|
||||
},
|
||||
},
|
||||
]
|
||||
return {
|
||||
"config": {"wide_screen_mode": True, "enable_forward": True},
|
||||
"header": {
|
||||
"title": {"tag": "plain_text", "content": "AI PMO 草稿待确认"},
|
||||
},
|
||||
"elements": [*base_elements, {"tag": "action", "actions": actions}],
|
||||
}
|
||||
|
||||
if template_key == cls._TEMPLATE_PENDING_MANUAL:
|
||||
actions = [
|
||||
{
|
||||
"tag": "button",
|
||||
"type": "primary",
|
||||
"text": {"tag": "plain_text", "content": "继续执行"},
|
||||
"value": {
|
||||
"action": cls._ACTION_CONTINUE,
|
||||
"work_item_id": int(work_item_id),
|
||||
},
|
||||
}
|
||||
]
|
||||
return {
|
||||
"config": {"wide_screen_mode": True, "enable_forward": True},
|
||||
"header": {
|
||||
"title": {"tag": "plain_text", "content": "AI PMO 待继续执行"},
|
||||
},
|
||||
"elements": [*base_elements, {"tag": "action", "actions": actions}],
|
||||
}
|
||||
|
||||
return None
|
||||
|
||||
@classmethod
|
||||
def _extract_card_action_payload(cls, event: Dict[str, Any]) -> dict[str, Any]:
|
||||
payload = event if isinstance(event, dict) else {}
|
||||
operator = payload.get("operator") if isinstance(payload.get("operator"), dict) else {}
|
||||
action = payload.get("action") if isinstance(payload.get("action"), dict) else {}
|
||||
raw_value = action.get("value")
|
||||
value: dict[str, Any] = {}
|
||||
if isinstance(raw_value, dict):
|
||||
value = raw_value
|
||||
elif isinstance(raw_value, str):
|
||||
raw_text = raw_value.strip()
|
||||
if raw_text:
|
||||
try:
|
||||
parsed = json.loads(raw_text)
|
||||
if isinstance(parsed, dict):
|
||||
value = parsed
|
||||
except Exception:
|
||||
value = {}
|
||||
|
||||
normalized_action = cls._normalize_draft_action(
|
||||
value.get("action")
|
||||
or value.get("draft_action")
|
||||
or value.get("followup_action")
|
||||
or value.get("resume_action")
|
||||
or value.get("op")
|
||||
or action.get("name")
|
||||
or action.get("tag")
|
||||
)
|
||||
work_item_id = cls._safe_int(
|
||||
value.get("work_item_id")
|
||||
or value.get("target_work_item_id")
|
||||
or value.get("workItemId")
|
||||
)
|
||||
draft_version = cls._safe_int(value.get("draft_version") or value.get("version"))
|
||||
revision_note = (
|
||||
str(value.get("revision_note") or value.get("note") or value.get("comment") or "")
|
||||
.strip()[:500]
|
||||
or None
|
||||
)
|
||||
source_message_id = (
|
||||
str(
|
||||
payload.get("open_message_id")
|
||||
or payload.get("message_id")
|
||||
or payload.get("token")
|
||||
or ""
|
||||
).strip()[:128]
|
||||
or None
|
||||
)
|
||||
chat_id = (
|
||||
str(payload.get("open_chat_id") or payload.get("chat_id") or "").strip()[:128]
|
||||
or None
|
||||
)
|
||||
sender_open_id = (
|
||||
str(operator.get("open_id") or operator.get("user_id") or "").strip()[:128]
|
||||
or None
|
||||
)
|
||||
sender_name = (
|
||||
str(operator.get("name") or operator.get("display_name") or "").strip()[:80]
|
||||
or None
|
||||
)
|
||||
return {
|
||||
"action": normalized_action,
|
||||
"work_item_id": work_item_id,
|
||||
"draft_version": draft_version,
|
||||
"revision_note": revision_note,
|
||||
"source_message_id": source_message_id,
|
||||
"chat_id": chat_id,
|
||||
"sender_open_id": sender_open_id,
|
||||
"sender_name": sender_name,
|
||||
}
|
||||
|
||||
async def _handle_card_action_trigger(
|
||||
self,
|
||||
event: Dict[str, Any],
|
||||
) -> Dict[str, Any]:
|
||||
parsed = self._extract_card_action_payload(event)
|
||||
action = str(parsed.get("action") or "").strip().lower()
|
||||
work_item_id = self._safe_int(parsed.get("work_item_id"))
|
||||
if action not in {self._ACTION_CONFIRM, self._ACTION_REVISE, self._ACTION_CANCEL, self._ACTION_CONTINUE}:
|
||||
return {"code": 0, "msg": "card_action_ignored", "data": {"reason": "unsupported_action"}}
|
||||
if work_item_id is None or work_item_id <= 0:
|
||||
return {"code": 0, "msg": "card_action_ignored", "data": {"reason": "missing_work_item_id"}}
|
||||
|
||||
from app.services.ai_pmo.ai_pmo_kernel.ai_pmo_kernel_runtime import AIPMOKernelService
|
||||
from app.services.ai_pmo.draft_confirm_handler import DraftConfirmHandler
|
||||
|
||||
operator_sender = str(parsed.get("sender_name") or "飞书用户").strip()[:80] or "飞书用户"
|
||||
source_message_id = str(parsed.get("source_message_id") or "").strip()[:128] or None
|
||||
chat_id = str(parsed.get("chat_id") or "").strip()[:128] or None
|
||||
sender_open_id = str(parsed.get("sender_open_id") or "").strip()[:128] or None
|
||||
try:
|
||||
if action == self._ACTION_CONTINUE:
|
||||
kernel = AIPMOKernelService(self.db, tenant_id=self.tenant_id)
|
||||
result = await kernel.process_ingress(
|
||||
source_system="ai_pmo_ui",
|
||||
source_message_id=(
|
||||
source_message_id
|
||||
or f"feishu-card-continue-{int(work_item_id)}-{int(time.time() * 1000)}"
|
||||
),
|
||||
message_text=f"继续处理 work_item #{int(work_item_id)}",
|
||||
sender=f"{operator_sender}(card)",
|
||||
channel_ref="ai_pmo_ui",
|
||||
source_sender_open_id=sender_open_id,
|
||||
captured_at=None,
|
||||
chat_type="manual",
|
||||
project_hint_id=None,
|
||||
)
|
||||
if hasattr(self.db, "commit") and callable(getattr(self.db, "commit")):
|
||||
await self.db.commit()
|
||||
followup_reply = str(result.get("direct_reply_text") or "").strip()
|
||||
if not followup_reply:
|
||||
if bool(result.get("handled")):
|
||||
followup_reply = "已收到继续执行指令,正在处理。"
|
||||
else:
|
||||
followup_reply = str(result.get("error") or result.get("reason") or "继续执行失败").strip()
|
||||
normalized = {
|
||||
"handled": bool(result.get("handled")),
|
||||
"status": str(result.get("status") or "").strip() or ("captured" if bool(result.get("handled")) else "failed_safe"),
|
||||
"followup_reply": followup_reply,
|
||||
"target_work_item_id": int(work_item_id),
|
||||
}
|
||||
else:
|
||||
draft_version = self._safe_int(parsed.get("draft_version"))
|
||||
revision_note = str(parsed.get("revision_note") or "").strip() or None
|
||||
handler = DraftConfirmHandler(AIPMOKernelService(self.db, tenant_id=self.tenant_id))
|
||||
normalized = await handler.handle_api_decision(
|
||||
work_item_id=int(work_item_id),
|
||||
action=action,
|
||||
operator_sender=operator_sender,
|
||||
source_message_id=source_message_id,
|
||||
revision_note=revision_note,
|
||||
draft_version=draft_version,
|
||||
)
|
||||
if hasattr(self.db, "commit") and callable(getattr(self.db, "commit")):
|
||||
await self.db.commit()
|
||||
except Exception as exc:
|
||||
if hasattr(self.db, "rollback") and callable(getattr(self.db, "rollback")):
|
||||
await self.db.rollback()
|
||||
return {"code": 1, "msg": "card_action_failed", "data": {"error": str(exc)}}
|
||||
|
||||
followup_reply = str(normalized.get("followup_reply") or "").strip()
|
||||
if followup_reply and chat_id:
|
||||
status = str(normalized.get("status") or "").strip().lower()
|
||||
target_work_item_id = self._safe_int(
|
||||
normalized.get("target_work_item_id")
|
||||
or normalized.get("work_item_id")
|
||||
or work_item_id
|
||||
)
|
||||
template_key = ""
|
||||
if status == self._TEMPLATE_DRAFT_PENDING_CONFIRM:
|
||||
template_key = self._TEMPLATE_DRAFT_PENDING_CONFIRM
|
||||
elif status in {"pending_manual", "pending_confirmation"}:
|
||||
template_key = self._TEMPLATE_PENDING_MANUAL
|
||||
action_card = self._build_action_card_from_capture_result(
|
||||
capture_result={
|
||||
"reply_template_key": template_key,
|
||||
"work_item_id": target_work_item_id,
|
||||
"draft_version": self._safe_int(normalized.get("draft_version")),
|
||||
"direct_reply_text": followup_reply,
|
||||
},
|
||||
mention_open_id=sender_open_id,
|
||||
mention_name=operator_sender,
|
||||
)
|
||||
if isinstance(action_card, dict):
|
||||
await self._safe_reply_card(
|
||||
chat_id=chat_id,
|
||||
card_content=action_card,
|
||||
trace_id=f"feishu-card-action-{int(work_item_id)}",
|
||||
source_message_id=source_message_id,
|
||||
perf_t0=None,
|
||||
)
|
||||
else:
|
||||
await self._safe_reply_text(
|
||||
chat_id=chat_id,
|
||||
mention_open_id=sender_open_id,
|
||||
mention_name=operator_sender,
|
||||
text=followup_reply,
|
||||
trace_id=f"feishu-card-action-{int(work_item_id)}",
|
||||
source_message_id=source_message_id,
|
||||
perf_t0=None,
|
||||
)
|
||||
return {"code": 0, "msg": "success", "data": normalized}
|
||||
|
|
@ -135,6 +135,7 @@ class FeishuConfigMixin:
|
|||
支持的事件类型:
|
||||
- message.receive_v1: 收到群聊消息
|
||||
- im.message.receive_v1: 收到群聊消息(schema 2.0)
|
||||
- card.action.trigger: 卡片按钮回调
|
||||
"""
|
||||
try:
|
||||
event_type = (event_data.get('type') or '').strip()
|
||||
|
|
@ -142,6 +143,8 @@ class FeishuConfigMixin:
|
|||
# 飞书 schema 2.0 会返回 im.message.receive_v1
|
||||
if event_type in {'message.receive_v1', 'im.message.receive_v1'}:
|
||||
return await self._handle_message_receive(event_data)
|
||||
if event_type in {'card.action.trigger', 'im.card.action.trigger'}:
|
||||
return await self._handle_card_action_trigger(event_data)
|
||||
|
||||
return {
|
||||
'code': 0,
|
||||
|
|
|
|||
|
|
@ -2,216 +2,96 @@ from __future__ import annotations
|
|||
|
||||
from .shared import *
|
||||
|
||||
|
||||
class FeishuReceiveMixin:
|
||||
async def _handle_message_receive(
|
||||
self,
|
||||
event: Dict[str, Any]
|
||||
) -> Dict[str, Any]:
|
||||
"""处理收到的消息事件"""
|
||||
"""处理收到的消息事件(编排层)。"""
|
||||
state_trace_id = f"feishu-sm-{uuid4().hex[:12]}"
|
||||
ingress_source = str(event.get("_ingress_source") or "feishu").strip() or "feishu"
|
||||
message = event.get('message', {}) if isinstance(event.get('message', {}), dict) else {}
|
||||
chat = event.get('chat', {}) if isinstance(event.get('chat', {}), dict) else {}
|
||||
sender = event.get('sender', {}) if isinstance(event.get('sender', {}), dict) else {}
|
||||
message_id = (message.get("message_id") or "").strip() or "unknown"
|
||||
perf_t0_raw = event.get("_perf_t0")
|
||||
perf_t0: Optional[float] = None
|
||||
if isinstance(perf_t0_raw, (int, float)):
|
||||
perf_t0 = float(perf_t0_raw)
|
||||
else:
|
||||
try:
|
||||
perf_t0 = float(str(perf_t0_raw).strip())
|
||||
except Exception:
|
||||
perf_t0 = None
|
||||
self._emit_state_transition(
|
||||
trace_id=state_trace_id,
|
||||
state="RECEIVED",
|
||||
message_id=message_id,
|
||||
message_type=str(message.get("message_type") or "unknown"),
|
||||
)
|
||||
sender_type = str(sender.get("sender_type") or "").strip().lower()
|
||||
context = self._build_receive_context(event=event, state_trace_id=state_trace_id)
|
||||
message = context["message"]
|
||||
sender = context["sender"]
|
||||
message_id = str(context["message_id"])
|
||||
sender_type = str(context["sender_type"])
|
||||
if sender_type in {"app", "bot"}:
|
||||
return {'code': 0, 'msg': 'Ignore bot/app message'}
|
||||
|
||||
mentioned_bot_names = self._extract_mentioned_bot_names(message)
|
||||
|
||||
# 解析消息内容与附件元数据
|
||||
message_payload = self._extract_message_payload(event)
|
||||
raw_message_content = str(message_payload.get("text") or "").strip() or None
|
||||
message_content = (
|
||||
self._strip_bot_mention_tokens(raw_message_content)
|
||||
if raw_message_content
|
||||
else ""
|
||||
).strip() or None
|
||||
message_attachments = (
|
||||
message_payload.get("attachments")
|
||||
if isinstance(message_payload.get("attachments"), list)
|
||||
else []
|
||||
)
|
||||
return {"code": 0, "msg": "Ignore bot/app message"}
|
||||
await self._record_ingress_trace(
|
||||
ingress_source=ingress_source,
|
||||
ingress_source=str(context["ingress_source"]),
|
||||
trace_id=state_trace_id,
|
||||
message_id=message_id,
|
||||
event=event,
|
||||
message_content=raw_message_content or "",
|
||||
message_content=str(context.get("raw_message_content") or ""),
|
||||
)
|
||||
message_content = str(context.get("message_content") or "").strip()
|
||||
if not message_content:
|
||||
print(
|
||||
f"[Feishu] message.receive_v1 ignored: no text content, "
|
||||
f"message_type={message.get('message_type')}, keys={list(message.keys())}"
|
||||
await self._reply_for_empty_text_ingress(
|
||||
state_trace_id=state_trace_id,
|
||||
message=message,
|
||||
sender=sender,
|
||||
message_id=message_id,
|
||||
chat_id=str(context.get("chat_id") or ""),
|
||||
mentioned_bot_names=(
|
||||
context.get("mentioned_bot_names")
|
||||
if isinstance(context.get("mentioned_bot_names"), list)
|
||||
else []
|
||||
),
|
||||
sender_open_id=context.get("sender_open_id"),
|
||||
perf_t0=context.get("perf_t0"),
|
||||
)
|
||||
chat_id = chat.get('chat_id') or message.get('chat_id') or ''
|
||||
if mentioned_bot_names and chat_id:
|
||||
await self._safe_reply_text(
|
||||
chat_id=chat_id,
|
||||
mention_open_id=(sender.get('sender_id', {}) or {}).get('open_id'),
|
||||
mention_name=(sender.get('sender_name') or sender.get('name') or '你'),
|
||||
text="我已收到你的消息,但目前无法直接解析纯图片内容。请补充 1-2 句文字需求,我会立即给出分派建议。",
|
||||
trace_id=state_trace_id,
|
||||
source_message_id=message_id,
|
||||
perf_t0=perf_t0,
|
||||
)
|
||||
self._emit_state_transition(
|
||||
trace_id=state_trace_id,
|
||||
state="REPLIED",
|
||||
route="empty_text_prompt",
|
||||
)
|
||||
return {'code': 0, 'msg': 'No text content'}
|
||||
return {"code": 0, "msg": "No text content"}
|
||||
|
||||
# 获取发送者信息
|
||||
sender_id = sender.get('sender_id', {}) if isinstance(sender.get('sender_id', {}), dict) else {}
|
||||
sender_open_id = (sender_id.get('open_id') or "").strip() or None
|
||||
sender_name = await self._resolve_sender_display_name(
|
||||
sender=sender,
|
||||
sender_id=sender_id,
|
||||
sender_id=(
|
||||
context.get("sender_id")
|
||||
if isinstance(context.get("sender_id"), dict)
|
||||
else {}
|
||||
),
|
||||
)
|
||||
|
||||
# 获取群聊信息
|
||||
chat_id = chat.get('chat_id') or message.get('chat_id') or ''
|
||||
chat_type = str(chat.get("chat_type") or message.get("chat_type") or "").strip().lower() or "group"
|
||||
captured_at = self._extract_message_datetime(message)
|
||||
occurred_at_iso = (
|
||||
captured_at.isoformat() if isinstance(captured_at, datetime) else None
|
||||
)
|
||||
message_attachments = self._normalize_ingress_attachment_records(
|
||||
message_attachments,
|
||||
occurred_at=occurred_at_iso,
|
||||
source_message_id=(message.get("message_id") or "").strip() or None,
|
||||
source_chat_id=chat_id or None,
|
||||
)
|
||||
|
||||
# 使用信号捕获服务处理
|
||||
capture_service = ProactiveSignalCaptureService(self.db)
|
||||
|
||||
chat_id = str(context.get("chat_id") or "")
|
||||
sender_open_id = context.get("sender_open_id")
|
||||
message_attachments = self._normalize_message_attachments_from_context(context=context)
|
||||
try:
|
||||
result = await capture_service.capture_from_feishu(
|
||||
group_chat_id=chat_id,
|
||||
result = await self._capture_and_persist_from_ingress(
|
||||
chat_id=chat_id,
|
||||
message_content=message_content,
|
||||
sender=sender_name,
|
||||
tenant_id=self.tenant_id,
|
||||
proposer_open_id=sender_open_id,
|
||||
captured_at=captured_at,
|
||||
mentioned_bot_names=mentioned_bot_names,
|
||||
feishu_message_id=(message.get("message_id") or "").strip() or None,
|
||||
chat_type=chat_type,
|
||||
)
|
||||
await self._persist_signal_attachments_from_ingress(
|
||||
signal_id=result.get("signal_id"),
|
||||
attachments=message_attachments,
|
||||
sender_name=sender_name,
|
||||
sender_open_id=sender_open_id,
|
||||
captured_at=context.get("captured_at"),
|
||||
mentioned_bot_names=(
|
||||
context.get("mentioned_bot_names")
|
||||
if isinstance(context.get("mentioned_bot_names"), list)
|
||||
else []
|
||||
),
|
||||
message_id=(message.get("message_id") or "").strip() or None,
|
||||
chat_type=str(context.get("chat_type") or "group"),
|
||||
message_attachments=message_attachments,
|
||||
)
|
||||
except Exception as exc:
|
||||
self._emit_state_transition(
|
||||
trace_id=state_trace_id,
|
||||
state="ERROR_REPLY",
|
||||
reason="capture_exception",
|
||||
error=str(exc),
|
||||
)
|
||||
if chat_id:
|
||||
await self._safe_reply_text(
|
||||
chat_id=chat_id,
|
||||
mention_open_id=sender_open_id,
|
||||
mention_name=sender_name,
|
||||
text=self._build_error_reply_text(),
|
||||
trace_id=state_trace_id,
|
||||
source_message_id=message_id,
|
||||
perf_t0=perf_t0,
|
||||
)
|
||||
self._emit_state_transition(
|
||||
trace_id=state_trace_id,
|
||||
state="REPLIED",
|
||||
route="error_template",
|
||||
)
|
||||
return {
|
||||
"code": 0,
|
||||
"msg": "error_handled",
|
||||
"data": {
|
||||
"status": "error",
|
||||
"classification": "error",
|
||||
"ai_trace_id": state_trace_id,
|
||||
},
|
||||
}
|
||||
|
||||
print(
|
||||
"[Feishu] Message processed: "
|
||||
f"status={result.get('status')}, "
|
||||
f"classification={result.get('classification')}, "
|
||||
f"linked_projects={result.get('linked_project_ids', [])}, "
|
||||
f"auto_tasks={len(result.get('auto_tasks', []) if isinstance(result.get('auto_tasks', []), list) else [])}, "
|
||||
f"ai_trace_id={result.get('ai_trace_id')}"
|
||||
)
|
||||
scene = self._classify_message_scene(capture_result=result)
|
||||
self._emit_state_transition(
|
||||
trace_id=state_trace_id,
|
||||
state="CLASSIFIED",
|
||||
scene=scene,
|
||||
status=str(result.get("status") or "unknown"),
|
||||
classification=str(result.get("classification") or "unknown"),
|
||||
)
|
||||
if str(result.get("status") or "").lower() != "error":
|
||||
self._emit_state_transition(
|
||||
trace_id=state_trace_id,
|
||||
state="COMMITTED",
|
||||
ai_trace_id=str(result.get("ai_trace_id") or ""),
|
||||
)
|
||||
|
||||
reply_route = await self._route_scene_reply(
|
||||
scene=scene,
|
||||
result=result,
|
||||
message_content=message_content,
|
||||
return await self._handle_capture_exception_from_ingress(
|
||||
state_trace_id=state_trace_id,
|
||||
error=exc,
|
||||
chat_id=chat_id,
|
||||
sender_open_id=sender_open_id,
|
||||
sender_name=sender_name,
|
||||
mentioned_bot_names=mentioned_bot_names,
|
||||
trace_id=state_trace_id,
|
||||
message_id=message_id,
|
||||
perf_t0=perf_t0,
|
||||
perf_t0=context.get("perf_t0"),
|
||||
)
|
||||
|
||||
if reply_route != "none":
|
||||
self._emit_state_transition(
|
||||
trace_id=state_trace_id,
|
||||
state="REPLIED",
|
||||
route=reply_route,
|
||||
)
|
||||
await self._record_message_trace(
|
||||
trace_id=state_trace_id,
|
||||
return await self._finalize_receive_result(
|
||||
result=result,
|
||||
state_trace_id=state_trace_id,
|
||||
message_id=message_id,
|
||||
chat_type=chat_type,
|
||||
message_content=message_content,
|
||||
capture_result=result,
|
||||
reply_source=reply_route,
|
||||
chat_id=chat_id,
|
||||
chat_type=str(context.get("chat_type") or "group"),
|
||||
sender_open_id=sender_open_id,
|
||||
sender_name=sender_name,
|
||||
mentioned_bot_names=(
|
||||
context.get("mentioned_bot_names")
|
||||
if isinstance(context.get("mentioned_bot_names"), list)
|
||||
else []
|
||||
),
|
||||
perf_t0=context.get("perf_t0"),
|
||||
)
|
||||
|
||||
return {
|
||||
'code': 0,
|
||||
'msg': 'success',
|
||||
'data': {
|
||||
'status': result.get('status'),
|
||||
'classification': result.get('classification'),
|
||||
'linked_project_ids': result.get('linked_project_ids', []),
|
||||
'auto_tasks_count': len(result.get('auto_tasks', []) if isinstance(result.get('auto_tasks', []), list) else []),
|
||||
'ai_trace_id': result.get('ai_trace_id'),
|
||||
'state_trace_id': state_trace_id,
|
||||
'scene': scene,
|
||||
},
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,160 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from .shared import *
|
||||
|
||||
|
||||
class FeishuReceiveCaptureFlowMixin:
|
||||
async def _capture_and_persist_from_ingress(
|
||||
self,
|
||||
*,
|
||||
chat_id: str,
|
||||
message_content: str,
|
||||
sender_name: str,
|
||||
sender_open_id: Optional[str],
|
||||
captured_at: Optional[datetime],
|
||||
mentioned_bot_names: List[str],
|
||||
message_id: str,
|
||||
chat_type: str,
|
||||
message_attachments: List[Dict[str, Any]],
|
||||
) -> Dict[str, Any]:
|
||||
capture_service = ProactiveSignalCaptureService(self.db)
|
||||
result = await capture_service.capture_from_feishu(
|
||||
group_chat_id=chat_id,
|
||||
message_content=message_content,
|
||||
sender=sender_name,
|
||||
tenant_id=self.tenant_id,
|
||||
proposer_open_id=sender_open_id,
|
||||
captured_at=captured_at,
|
||||
mentioned_bot_names=mentioned_bot_names,
|
||||
feishu_message_id=message_id,
|
||||
chat_type=chat_type,
|
||||
)
|
||||
await self._persist_signal_attachments_from_ingress(
|
||||
signal_id=result.get("signal_id"),
|
||||
attachments=message_attachments,
|
||||
)
|
||||
return result
|
||||
|
||||
async def _handle_capture_exception_from_ingress(
|
||||
self,
|
||||
*,
|
||||
state_trace_id: str,
|
||||
error: Exception,
|
||||
chat_id: str,
|
||||
sender_open_id: Optional[str],
|
||||
sender_name: str,
|
||||
message_id: str,
|
||||
perf_t0: Optional[float],
|
||||
) -> Dict[str, Any]:
|
||||
self._emit_state_transition(
|
||||
trace_id=state_trace_id,
|
||||
state="ERROR_REPLY",
|
||||
reason="capture_exception",
|
||||
error=str(error),
|
||||
)
|
||||
if chat_id:
|
||||
await self._safe_reply_text(
|
||||
chat_id=chat_id,
|
||||
mention_open_id=sender_open_id,
|
||||
mention_name=sender_name,
|
||||
text=self._build_error_reply_text(),
|
||||
trace_id=state_trace_id,
|
||||
source_message_id=message_id,
|
||||
perf_t0=perf_t0,
|
||||
)
|
||||
self._emit_state_transition(
|
||||
trace_id=state_trace_id,
|
||||
state="REPLIED",
|
||||
route="error_template",
|
||||
)
|
||||
return {
|
||||
"code": 0,
|
||||
"msg": "error_handled",
|
||||
"data": {
|
||||
"status": "error",
|
||||
"classification": "error",
|
||||
"ai_trace_id": state_trace_id,
|
||||
},
|
||||
}
|
||||
|
||||
async def _finalize_receive_result(
|
||||
self,
|
||||
*,
|
||||
result: Dict[str, Any],
|
||||
state_trace_id: str,
|
||||
message_id: str,
|
||||
message_content: str,
|
||||
chat_id: str,
|
||||
chat_type: str,
|
||||
sender_open_id: Optional[str],
|
||||
sender_name: str,
|
||||
mentioned_bot_names: List[str],
|
||||
perf_t0: Optional[float],
|
||||
) -> Dict[str, Any]:
|
||||
print(
|
||||
"[Feishu] Message processed: "
|
||||
f"status={result.get('status')}, "
|
||||
f"classification={result.get('classification')}, "
|
||||
f"linked_projects={result.get('linked_project_ids', [])}, "
|
||||
f"auto_tasks={len(result.get('auto_tasks', []) if isinstance(result.get('auto_tasks', []), list) else [])}, "
|
||||
f"ai_trace_id={result.get('ai_trace_id')}"
|
||||
)
|
||||
scene = self._classify_message_scene(capture_result=result)
|
||||
self._emit_state_transition(
|
||||
trace_id=state_trace_id,
|
||||
state="CLASSIFIED",
|
||||
scene=scene,
|
||||
status=str(result.get("status") or "unknown"),
|
||||
classification=str(result.get("classification") or "unknown"),
|
||||
)
|
||||
if str(result.get("status") or "").lower() != "error":
|
||||
self._emit_state_transition(
|
||||
trace_id=state_trace_id,
|
||||
state="COMMITTED",
|
||||
ai_trace_id=str(result.get("ai_trace_id") or ""),
|
||||
)
|
||||
|
||||
reply_route = await self._route_scene_reply(
|
||||
scene=scene,
|
||||
result=result,
|
||||
message_content=message_content,
|
||||
chat_id=chat_id,
|
||||
sender_open_id=sender_open_id,
|
||||
sender_name=sender_name,
|
||||
mentioned_bot_names=mentioned_bot_names,
|
||||
trace_id=state_trace_id,
|
||||
message_id=message_id,
|
||||
perf_t0=perf_t0,
|
||||
)
|
||||
if reply_route != "none":
|
||||
self._emit_state_transition(
|
||||
trace_id=state_trace_id,
|
||||
state="REPLIED",
|
||||
route=reply_route,
|
||||
)
|
||||
|
||||
await self._record_message_trace(
|
||||
trace_id=state_trace_id,
|
||||
message_id=message_id,
|
||||
chat_type=chat_type,
|
||||
message_content=message_content,
|
||||
capture_result=result,
|
||||
reply_source=reply_route,
|
||||
)
|
||||
return {
|
||||
"code": 0,
|
||||
"msg": "success",
|
||||
"data": {
|
||||
"status": result.get("status"),
|
||||
"classification": result.get("classification"),
|
||||
"linked_project_ids": result.get("linked_project_ids", []),
|
||||
"auto_tasks_count": len(
|
||||
result.get("auto_tasks", [])
|
||||
if isinstance(result.get("auto_tasks", []), list)
|
||||
else []
|
||||
),
|
||||
"ai_trace_id": result.get("ai_trace_id"),
|
||||
"state_trace_id": state_trace_id,
|
||||
"scene": scene,
|
||||
},
|
||||
}
|
||||
|
|
@ -0,0 +1,123 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from .shared import *
|
||||
|
||||
|
||||
class FeishuReceivePrepareMixin:
|
||||
@staticmethod
|
||||
def _extract_perf_t0(event: Dict[str, Any]) -> Optional[float]:
|
||||
perf_t0_raw = event.get("_perf_t0")
|
||||
if isinstance(perf_t0_raw, (int, float)):
|
||||
return float(perf_t0_raw)
|
||||
try:
|
||||
return float(str(perf_t0_raw).strip())
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
def _build_receive_context(
|
||||
self,
|
||||
*,
|
||||
event: Dict[str, Any],
|
||||
state_trace_id: str,
|
||||
) -> Dict[str, Any]:
|
||||
ingress_source = str(event.get("_ingress_source") or "feishu").strip() or "feishu"
|
||||
message = event.get("message", {}) if isinstance(event.get("message", {}), dict) else {}
|
||||
chat = event.get("chat", {}) if isinstance(event.get("chat", {}), dict) else {}
|
||||
sender = event.get("sender", {}) if isinstance(event.get("sender", {}), dict) else {}
|
||||
message_id = (message.get("message_id") or "").strip() or "unknown"
|
||||
perf_t0 = self._extract_perf_t0(event)
|
||||
sender_type = str(sender.get("sender_type") or "").strip().lower()
|
||||
mentioned_bot_names = self._extract_mentioned_bot_names(message)
|
||||
message_payload = self._extract_message_payload(event)
|
||||
raw_message_content = str(message_payload.get("text") or "").strip() or None
|
||||
message_content = (
|
||||
self._strip_bot_mention_tokens(raw_message_content)
|
||||
if raw_message_content
|
||||
else ""
|
||||
).strip() or None
|
||||
sender_id = sender.get("sender_id", {}) if isinstance(sender.get("sender_id", {}), dict) else {}
|
||||
sender_open_id = (sender_id.get("open_id") or "").strip() or None
|
||||
chat_id = chat.get("chat_id") or message.get("chat_id") or ""
|
||||
chat_type = str(chat.get("chat_type") or message.get("chat_type") or "").strip().lower() or "group"
|
||||
captured_at = self._extract_message_datetime(message)
|
||||
occurred_at_iso = captured_at.isoformat() if isinstance(captured_at, datetime) else None
|
||||
|
||||
self._emit_state_transition(
|
||||
trace_id=state_trace_id,
|
||||
state="RECEIVED",
|
||||
message_id=message_id,
|
||||
message_type=str(message.get("message_type") or "unknown"),
|
||||
)
|
||||
|
||||
return {
|
||||
"ingress_source": ingress_source,
|
||||
"message": message,
|
||||
"chat": chat,
|
||||
"sender": sender,
|
||||
"message_id": message_id,
|
||||
"perf_t0": perf_t0,
|
||||
"sender_type": sender_type,
|
||||
"mentioned_bot_names": mentioned_bot_names,
|
||||
"raw_message_content": raw_message_content,
|
||||
"message_content": message_content,
|
||||
"message_attachments": (
|
||||
message_payload.get("attachments")
|
||||
if isinstance(message_payload.get("attachments"), list)
|
||||
else []
|
||||
),
|
||||
"sender_id": sender_id,
|
||||
"sender_open_id": sender_open_id,
|
||||
"chat_id": chat_id,
|
||||
"chat_type": chat_type,
|
||||
"captured_at": captured_at,
|
||||
"occurred_at_iso": occurred_at_iso,
|
||||
}
|
||||
|
||||
async def _reply_for_empty_text_ingress(
|
||||
self,
|
||||
*,
|
||||
state_trace_id: str,
|
||||
message: Dict[str, Any],
|
||||
sender: Dict[str, Any],
|
||||
message_id: str,
|
||||
chat_id: str,
|
||||
mentioned_bot_names: List[str],
|
||||
sender_open_id: Optional[str],
|
||||
perf_t0: Optional[float],
|
||||
) -> bool:
|
||||
if chat_id and mentioned_bot_names:
|
||||
await self._safe_reply_text(
|
||||
chat_id=chat_id,
|
||||
mention_open_id=sender_open_id,
|
||||
mention_name=(sender.get("sender_name") or sender.get("name") or "你"),
|
||||
text="我已收到你的消息,但目前无法直接解析纯图片内容。请补充 1-2 句文字需求,我会立即给出分派建议。",
|
||||
trace_id=state_trace_id,
|
||||
source_message_id=message_id,
|
||||
perf_t0=perf_t0,
|
||||
)
|
||||
self._emit_state_transition(
|
||||
trace_id=state_trace_id,
|
||||
state="REPLIED",
|
||||
route="empty_text_prompt",
|
||||
)
|
||||
print(
|
||||
f"[Feishu] message.receive_v1 ignored: no text content, "
|
||||
f"message_type={message.get('message_type')}, keys={list(message.keys())}"
|
||||
)
|
||||
return True
|
||||
|
||||
def _normalize_message_attachments_from_context(
|
||||
self,
|
||||
*,
|
||||
context: Dict[str, Any],
|
||||
) -> List[Dict[str, Any]]:
|
||||
message = context.get("message") if isinstance(context.get("message"), dict) else {}
|
||||
chat_id = str(context.get("chat_id") or "")
|
||||
return self._normalize_ingress_attachment_records(
|
||||
context.get("message_attachments")
|
||||
if isinstance(context.get("message_attachments"), list)
|
||||
else [],
|
||||
occurred_at=context.get("occurred_at_iso"),
|
||||
source_message_id=(message.get("message_id") or "").strip() or None,
|
||||
source_chat_id=chat_id or None,
|
||||
)
|
||||
|
|
@ -55,6 +55,20 @@ class FeishuReceiveRoutingMixin:
|
|||
)
|
||||
if not deterministic_reply and str(result.get("status") or "").lower() == "error":
|
||||
deterministic_reply = self._build_error_reply_text()
|
||||
action_card = self._build_action_card_from_capture_result(
|
||||
capture_result=result,
|
||||
mention_open_id=sender_open_id,
|
||||
mention_name=sender_name,
|
||||
)
|
||||
if chat_id and isinstance(action_card, dict):
|
||||
await self._safe_reply_card(
|
||||
chat_id=chat_id,
|
||||
card_content=action_card,
|
||||
trace_id=trace_id,
|
||||
source_message_id=message_id,
|
||||
perf_t0=perf_t0,
|
||||
)
|
||||
return "execute_action_card"
|
||||
if chat_id and deterministic_reply:
|
||||
await self._safe_reply_text(
|
||||
chat_id=chat_id,
|
||||
|
|
@ -156,4 +170,3 @@ class FeishuReceiveRoutingMixin:
|
|||
safe_state = str(state or "unknown")
|
||||
details = ", ".join([f"{k}={v}" for k, v in payload.items()]) if payload else ""
|
||||
print(f"[Feishu-SM][{safe_trace}] state={safe_state}{', ' + details if details else ''}")
|
||||
|
||||
|
|
|
|||
|
|
@ -3,6 +3,29 @@ from __future__ import annotations
|
|||
from .shared import *
|
||||
|
||||
class FeishuReplyOutboxMixin:
|
||||
_CARD_MESSAGE_PREFIX = "__pmflow_feishu_card__:"
|
||||
|
||||
@classmethod
|
||||
def _is_card_outbox_message(cls, value: Optional[str]) -> bool:
|
||||
return str(value or "").startswith(cls._CARD_MESSAGE_PREFIX)
|
||||
|
||||
@classmethod
|
||||
def _encode_outbox_card_message(cls, *, card_content: dict[str, Any]) -> str:
|
||||
return f"{cls._CARD_MESSAGE_PREFIX}{json.dumps(card_content, ensure_ascii=False, separators=(',', ':'))}"
|
||||
|
||||
@classmethod
|
||||
def _decode_outbox_card_message(cls, value: Optional[str]) -> Optional[dict[str, Any]]:
|
||||
raw = str(value or "")
|
||||
if not raw.startswith(cls._CARD_MESSAGE_PREFIX):
|
||||
return None
|
||||
payload_text = raw[len(cls._CARD_MESSAGE_PREFIX):].strip()
|
||||
if not payload_text:
|
||||
return None
|
||||
parsed = json.loads(payload_text)
|
||||
if isinstance(parsed, dict):
|
||||
return parsed
|
||||
return None
|
||||
|
||||
async def _safe_reply_text(
|
||||
self,
|
||||
*,
|
||||
|
|
@ -47,6 +70,42 @@ class FeishuReplyOutboxMixin:
|
|||
# 统一由 drain_loop 消费 outbox,禁止业务路径直发,避免双发送。
|
||||
return
|
||||
|
||||
async def _safe_reply_card(
|
||||
self,
|
||||
*,
|
||||
chat_id: str,
|
||||
card_content: dict[str, Any],
|
||||
trace_id: Optional[str] = None,
|
||||
source_message_id: Optional[str] = None,
|
||||
perf_t0: Optional[float] = None,
|
||||
) -> None:
|
||||
try:
|
||||
payload = self._encode_outbox_card_message(card_content=card_content)
|
||||
outbox_id = await self._enqueue_reply_outbox(
|
||||
chat_id=chat_id,
|
||||
message_text=payload,
|
||||
trace_id=trace_id,
|
||||
source_message_id=source_message_id,
|
||||
)
|
||||
perf_t1 = time.perf_counter()
|
||||
if outbox_id > 0 and perf_t0 is not None:
|
||||
_OUTBOX_PERF_T0_BY_ID[int(outbox_id)] = float(perf_t0)
|
||||
if perf_t0 is not None:
|
||||
print(
|
||||
f"[PERF] outbox_written ts={now_cn_naive().isoformat()} T1={perf_t1:.6f} "
|
||||
f"delta={perf_t1 - perf_t0:.2f}s "
|
||||
f"outbox_id={outbox_id}"
|
||||
)
|
||||
else:
|
||||
print(
|
||||
f"[PERF] outbox_written ts={now_cn_naive().isoformat()} T1={perf_t1:.6f} "
|
||||
f"delta=N/A "
|
||||
f"outbox_id={outbox_id}"
|
||||
)
|
||||
except Exception as exc:
|
||||
print(f"[Feishu] enqueue card outbox failed: chat_id={chat_id}, error={exc}")
|
||||
return
|
||||
|
||||
async def _enqueue_reply_outbox(
|
||||
self,
|
||||
*,
|
||||
|
|
@ -57,6 +116,9 @@ class FeishuReplyOutboxMixin:
|
|||
) -> int:
|
||||
async with AsyncSessionLocal() as db:
|
||||
normalized_source_message_id = (source_message_id or "")[:128] or None
|
||||
if self._is_card_outbox_message(message_text):
|
||||
normalized_message_text = (message_text or "")[:12000]
|
||||
else:
|
||||
normalized_message_text = (message_text or "")[:2000] or "已收到,正在处理。"
|
||||
reply_hash = self._build_reply_hash(
|
||||
source_message_id=normalized_source_message_id,
|
||||
|
|
@ -229,9 +291,19 @@ class FeishuReplyOutboxMixin:
|
|||
async with AsyncSessionLocal() as db_send:
|
||||
service = cls(db_send)
|
||||
await service.load_config(tenant_id=int(claimed.get("tenant_id") or 1))
|
||||
raw_message = str(claimed.get("message_text") or "")
|
||||
if cls._is_card_outbox_message(raw_message):
|
||||
card_content = cls._decode_outbox_card_message(raw_message)
|
||||
if not isinstance(card_content, dict):
|
||||
raise ValueError("invalid_outbox_card_payload")
|
||||
await service.send_interactive_card(
|
||||
chat_id=str(claimed.get("chat_id") or ""),
|
||||
card_content=card_content,
|
||||
)
|
||||
else:
|
||||
await service.send_message_to_group(
|
||||
chat_id=str(claimed.get("chat_id") or ""),
|
||||
message=str(claimed.get("message_text") or ""),
|
||||
message=raw_message,
|
||||
)
|
||||
except Exception as exc:
|
||||
send_error = str(exc)
|
||||
|
|
|
|||
|
|
@ -153,17 +153,6 @@ class FeishuTransportMixin:
|
|||
|
||||
用于发送格式化的简报、通知等
|
||||
"""
|
||||
if not self.is_configured():
|
||||
raise ValueError("飞书未配置")
|
||||
|
||||
access_token = await self._get_tenant_access_token()
|
||||
|
||||
url = "https://open.feishu.cn/open-apis/im/v1/messages"
|
||||
headers = {
|
||||
'Authorization': f'Bearer {access_token}',
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
|
||||
# 构造卡片内容
|
||||
card_content = {
|
||||
'config': {'wide_screen_mode': True},
|
||||
|
|
@ -188,10 +177,30 @@ class FeishuTransportMixin:
|
|||
elif item.get('type') == 'divider':
|
||||
card_content['elements'].append({'tag': 'hr'})
|
||||
|
||||
return await self.send_interactive_card(
|
||||
chat_id=chat_id,
|
||||
card_content=card_content,
|
||||
)
|
||||
|
||||
async def send_interactive_card(
|
||||
self,
|
||||
chat_id: str,
|
||||
card_content: Dict[str, Any],
|
||||
) -> Dict[str, Any]:
|
||||
"""发送交互卡片消息(支持按钮回调 value 透传)。"""
|
||||
if not self.is_configured():
|
||||
raise ValueError("飞书未配置")
|
||||
|
||||
access_token = await self._get_tenant_access_token()
|
||||
url = "https://open.feishu.cn/open-apis/im/v1/messages"
|
||||
headers = {
|
||||
'Authorization': f'Bearer {access_token}',
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
message_data = {
|
||||
'receive_id': chat_id,
|
||||
'msg_type': 'interactive',
|
||||
'content': json.dumps(card_content)
|
||||
'content': json.dumps(card_content, ensure_ascii=False),
|
||||
}
|
||||
|
||||
async with httpx.AsyncClient(timeout=20.0, trust_env=False) as client:
|
||||
|
|
@ -215,4 +224,3 @@ class FeishuTransportMixin:
|
|||
f"飞书发送失败: code={payload.get('code')} msg={payload.get('msg') or payload.get('message')}"
|
||||
)
|
||||
return payload
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,9 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from .mixin_card_action import FeishuCardActionMixin
|
||||
from .mixin_config import FeishuConfigMixin
|
||||
from .mixin_receive_capture_flow import FeishuReceiveCaptureFlowMixin
|
||||
from .mixin_receive_prepare import FeishuReceivePrepareMixin
|
||||
from .mixin_message_payload import FeishuMessagePayloadMixin
|
||||
from .mixin_receive import FeishuReceiveMixin
|
||||
from .mixin_receive_routing import FeishuReceiveRoutingMixin
|
||||
|
|
@ -15,7 +18,10 @@ from .shared import AsyncSession
|
|||
|
||||
|
||||
class FeishuIntegrationService(
|
||||
FeishuCardActionMixin,
|
||||
FeishuConfigMixin,
|
||||
FeishuReceivePrepareMixin,
|
||||
FeishuReceiveCaptureFlowMixin,
|
||||
FeishuReceiveMixin,
|
||||
FeishuReceiveRoutingMixin,
|
||||
FeishuTraceMixin,
|
||||
|
|
|
|||
|
|
@ -18,6 +18,9 @@ class LongConnectionRuntimeMixin:
|
|||
if not callable(register_receive):
|
||||
raise RuntimeError("当前 lark_oapi 版本不支持 register_p2_im_message_receive_v1")
|
||||
builder = register_receive(self._on_message_event)
|
||||
register_card_action = getattr(builder, "register_p2_card_action_trigger", None)
|
||||
if callable(register_card_action):
|
||||
builder = register_card_action(self._on_message_event)
|
||||
|
||||
register_read = getattr(builder, "register_p2_im_message_message_read_v1", None)
|
||||
if callable(register_read):
|
||||
|
|
@ -147,7 +150,12 @@ class LongConnectionRuntimeMixin:
|
|||
return
|
||||
|
||||
event_type = str(event_data.get("type") or "").strip()
|
||||
if event_type and event_type not in {"im.message.receive_v1", "message.receive_v1"}:
|
||||
if event_type and event_type not in {
|
||||
"im.message.receive_v1",
|
||||
"message.receive_v1",
|
||||
"card.action.trigger",
|
||||
"im.card.action.trigger",
|
||||
}:
|
||||
logger.info(f"feishu_long_connection_event_skipped: event_type={event_type}")
|
||||
return
|
||||
|
||||
|
|
|
|||
|
|
@ -2,6 +2,11 @@ from __future__ import annotations
|
|||
|
||||
from .shared import *
|
||||
|
||||
AI_CALL_LOG_RESPONSE_MAX_CHARS = 10_000
|
||||
AI_CALL_LOG_RESPONSE_TAIL_CHARS = 2_000
|
||||
AI_CALL_LOG_TRUNCATION_MARKER = "\n...[TRUNCATED]...\n"
|
||||
|
||||
|
||||
class LLMPromptProfileRuntimeMixin:
|
||||
async def _resolve_active_prompt_profile(self, db: AsyncSession, *, tenant_id: int) -> dict[str, str]:
|
||||
cached = self._prompt_profile_cache.get(int(tenant_id))
|
||||
|
|
@ -154,7 +159,7 @@ class LLMPromptProfileRuntimeMixin:
|
|||
"user_id": None,
|
||||
"function_name": function_name,
|
||||
"prompt": prompt[:8000],
|
||||
"response": (response or "")[:8000],
|
||||
"response": self._trim_ai_call_response(response),
|
||||
"tokens_used": tokens_used,
|
||||
"cost": cost,
|
||||
"status": status,
|
||||
|
|
@ -174,6 +179,19 @@ class LLMPromptProfileRuntimeMixin:
|
|||
await session.rollback()
|
||||
return
|
||||
|
||||
@staticmethod
|
||||
def _trim_ai_call_response(response: str) -> str:
|
||||
text = str(response or "")
|
||||
if len(text) <= AI_CALL_LOG_RESPONSE_MAX_CHARS:
|
||||
return text
|
||||
|
||||
tail_chars = min(AI_CALL_LOG_RESPONSE_TAIL_CHARS, AI_CALL_LOG_RESPONSE_MAX_CHARS // 3)
|
||||
head_budget = AI_CALL_LOG_RESPONSE_MAX_CHARS - tail_chars - len(AI_CALL_LOG_TRUNCATION_MARKER)
|
||||
if head_budget <= 0:
|
||||
return text[:AI_CALL_LOG_RESPONSE_MAX_CHARS]
|
||||
|
||||
return f"{text[:head_budget]}{AI_CALL_LOG_TRUNCATION_MARKER}{text[-tail_chars:]}"
|
||||
|
||||
@staticmethod
|
||||
def _estimate_cost(provider: str, model: str, tokens: int) -> float:
|
||||
_ = model
|
||||
|
|
@ -242,4 +260,3 @@ class LLMPromptProfileRuntimeMixin:
|
|||
return ""
|
||||
cleaned = re.sub(r"<think>.*?</think>", "", cleaned, flags=re.IGNORECASE | re.DOTALL)
|
||||
return cleaned.strip()
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,352 @@
|
|||
"""Draft generation service for write-operation confirmation."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
from datetime import timedelta
|
||||
from typing import Any, Optional
|
||||
|
||||
from sqlalchemy import and_, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from ...config.ai_prompts import PROMPT_DRAFT_GENERATOR
|
||||
from ...core.logging import get_logger
|
||||
from ...models import User, UserWorkload, WorkItem
|
||||
from ...utils.timezone import now_cn_naive
|
||||
from ..llm_service import llm_service
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
_DRAFT_TIMEOUT_HOURS = 24
|
||||
_FEASIBILITY_ALLOWED = {"合理", "待讨论", "不合理"}
|
||||
|
||||
|
||||
class DraftGeneratorService:
|
||||
"""Generate and persist draft payload for write intents."""
|
||||
|
||||
def __init__(self, db: AsyncSession, *, tenant_id: int) -> None:
|
||||
self.db = db
|
||||
self.tenant_id = int(tenant_id)
|
||||
|
||||
async def generate_and_persist(
|
||||
self,
|
||||
*,
|
||||
work_item: WorkItem,
|
||||
intent_name: str,
|
||||
message_text: str,
|
||||
world_model: dict[str, Any],
|
||||
revision_note: Optional[str] = None,
|
||||
) -> dict[str, Any]:
|
||||
current_version = int(work_item.draft_version or 0)
|
||||
next_version = current_version + 1
|
||||
project = world_model.get("project") if isinstance(world_model.get("project"), dict) else {}
|
||||
project_title = str(project.get("title") or project.get("name") or "未指定项目").strip() or "未指定项目"
|
||||
project_description = str(project.get("description") or "").strip() or "暂无项目描述"
|
||||
|
||||
members = await self._load_members(world_model=world_model)
|
||||
recent_signals = self._normalize_recent_signals(world_model.get("recent_signals"))
|
||||
prompt = PROMPT_DRAFT_GENERATOR.format(
|
||||
project_title=project_title,
|
||||
project_description=project_description,
|
||||
members_json=json.dumps(members, ensure_ascii=False),
|
||||
recent_signals=json.dumps(recent_signals, ensure_ascii=False),
|
||||
user_message=str(message_text or "")[:2000],
|
||||
intent_name=str(intent_name or "")[:64],
|
||||
revision_note=str(revision_note or "")[:500],
|
||||
).strip()
|
||||
|
||||
payload = await self._generate_draft_payload(prompt=prompt)
|
||||
draft = self._normalize_draft_payload(
|
||||
payload=payload,
|
||||
members=members,
|
||||
original_text=message_text,
|
||||
version=next_version,
|
||||
)
|
||||
|
||||
expires_at = now_cn_naive() + timedelta(hours=_DRAFT_TIMEOUT_HOURS)
|
||||
work_item.draft_content = draft
|
||||
work_item.draft_version = next_version
|
||||
work_item.draft_expires_at = expires_at
|
||||
work_item.confirm_message_id = None
|
||||
|
||||
existing_execution = work_item.execution_result if isinstance(work_item.execution_result, dict) else {}
|
||||
work_item.execution_result = {
|
||||
**existing_execution,
|
||||
"status": "draft_pending_confirm",
|
||||
"draft": draft,
|
||||
"draft_version": next_version,
|
||||
"draft_expires_at": expires_at.isoformat(),
|
||||
"revision_note": str(revision_note or "")[:500] or None,
|
||||
}
|
||||
|
||||
return {
|
||||
"draft": draft,
|
||||
"draft_text": self.render_draft_message(draft),
|
||||
"draft_expires_at": expires_at,
|
||||
"draft_version": next_version,
|
||||
}
|
||||
|
||||
async def _generate_draft_payload(self, *, prompt: str) -> dict[str, Any]:
|
||||
try:
|
||||
extraction = await asyncio.wait_for(
|
||||
llm_service.generate_structured_object(
|
||||
prompt,
|
||||
db=self.db,
|
||||
tenant_id=self.tenant_id,
|
||||
function_name="draft_generator_v1",
|
||||
use_prompt_profile=False,
|
||||
temperature=0.0,
|
||||
max_tokens=1400,
|
||||
response_mode="fast",
|
||||
),
|
||||
timeout=35.0,
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.warning(
|
||||
"draft_generator_llm_failed",
|
||||
tenant_id=self.tenant_id,
|
||||
error=str(exc),
|
||||
)
|
||||
return {}
|
||||
if extraction is None or not isinstance(extraction.payload, dict):
|
||||
return {}
|
||||
return extraction.payload
|
||||
|
||||
async def _load_members(self, *, world_model: dict[str, Any]) -> list[dict[str, Any]]:
|
||||
capacity = world_model.get("capacity") if isinstance(world_model.get("capacity"), list) else []
|
||||
workload_map: dict[int, float] = {}
|
||||
for row in capacity:
|
||||
if not isinstance(row, dict):
|
||||
continue
|
||||
user_id = self._safe_int(row.get("user_id"))
|
||||
if user_id is None:
|
||||
continue
|
||||
ratio = self._safe_float(row.get("workload_ratio"))
|
||||
workload_map[user_id] = max(0.0, min(100.0, ratio * 100.0))
|
||||
|
||||
user_ids = sorted(workload_map.keys())
|
||||
if user_ids:
|
||||
rows = (
|
||||
await self.db.execute(
|
||||
select(User.id, User.name, User.username)
|
||||
.where(
|
||||
and_(
|
||||
User.tenant_id == int(self.tenant_id),
|
||||
User.id.in_(user_ids),
|
||||
User.is_active.is_(True),
|
||||
)
|
||||
)
|
||||
)
|
||||
).all()
|
||||
members = [
|
||||
{
|
||||
"id": int(row.id),
|
||||
"name": str(row.name or row.username or f"成员#{int(row.id)}").strip()[:40],
|
||||
"workload_percent": round(float(workload_map.get(int(row.id), 0.0)), 1),
|
||||
}
|
||||
for row in rows
|
||||
]
|
||||
members.sort(key=lambda item: (float(item.get("workload_percent") or 0.0), int(item.get("id") or 0)))
|
||||
return members[:20]
|
||||
|
||||
rows = (
|
||||
await self.db.execute(
|
||||
select(User.id, User.name, User.username, UserWorkload.workload_ratio)
|
||||
.outerjoin(
|
||||
UserWorkload,
|
||||
and_(
|
||||
UserWorkload.tenant_id == User.tenant_id,
|
||||
UserWorkload.user_id == User.id,
|
||||
),
|
||||
)
|
||||
.where(
|
||||
and_(
|
||||
User.tenant_id == int(self.tenant_id),
|
||||
User.is_active.is_(True),
|
||||
)
|
||||
)
|
||||
.order_by(User.id.asc())
|
||||
.limit(20)
|
||||
)
|
||||
).all()
|
||||
members = []
|
||||
for row in rows:
|
||||
members.append(
|
||||
{
|
||||
"id": int(row.id),
|
||||
"name": str(row.name or row.username or f"成员#{int(row.id)}").strip()[:40],
|
||||
"workload_percent": round(max(0.0, min(100.0, self._safe_float(row.workload_ratio) * 100.0)), 1),
|
||||
}
|
||||
)
|
||||
members.sort(key=lambda item: (float(item.get("workload_percent") or 0.0), int(item.get("id") or 0)))
|
||||
return members
|
||||
|
||||
@staticmethod
|
||||
def _normalize_recent_signals(value: Any) -> list[dict[str, Any]]:
|
||||
if not isinstance(value, list):
|
||||
return []
|
||||
normalized: list[dict[str, Any]] = []
|
||||
for row in value[:8]:
|
||||
if not isinstance(row, dict):
|
||||
continue
|
||||
normalized.append(
|
||||
{
|
||||
"id": DraftGeneratorService._safe_int(row.get("id")),
|
||||
"title": str(row.get("title") or "").strip()[:80],
|
||||
"type": str(row.get("signal_type") or "").strip()[:30],
|
||||
"status": str(row.get("status") or "").strip()[:20],
|
||||
}
|
||||
)
|
||||
return normalized
|
||||
|
||||
def _normalize_draft_payload(
|
||||
self,
|
||||
*,
|
||||
payload: dict[str, Any],
|
||||
members: list[dict[str, Any]],
|
||||
original_text: str,
|
||||
version: int,
|
||||
) -> dict[str, Any]:
|
||||
title = self._normalize_title(str(payload.get("title") or ""))
|
||||
summary = self._normalize_summary(str(payload.get("summary") or ""), fallback_text=original_text)
|
||||
|
||||
feasibility = str(payload.get("feasibility") or "").strip()
|
||||
if feasibility not in _FEASIBILITY_ALLOWED:
|
||||
feasibility = "待讨论"
|
||||
feasibility_reason = str(payload.get("feasibility_reason") or "").strip()[:30]
|
||||
if not feasibility_reason:
|
||||
feasibility_reason = "信息仍需补充,建议先确认边界。"
|
||||
|
||||
assignee_map = {
|
||||
int(item.get("id") or 0): item
|
||||
for item in members
|
||||
if self._safe_int(item.get("id")) is not None
|
||||
}
|
||||
assignee_id = self._safe_int(payload.get("recommended_assignee_id"))
|
||||
if assignee_id is None or assignee_id not in assignee_map:
|
||||
assignee_id = int(members[0]["id"]) if members else None
|
||||
assignee = assignee_map.get(assignee_id) if assignee_id is not None else None
|
||||
assignee_reason = str(payload.get("recommended_assignee_reason") or "").strip()[:80]
|
||||
if not assignee_reason:
|
||||
assignee_reason = "基于当前负载与任务匹配度给出的建议。"
|
||||
|
||||
followup_plan = self._normalize_followup_plan(payload.get("followup_plan"))
|
||||
if not followup_plan:
|
||||
followup_plan = [
|
||||
{"when": "明天", "action": "确认触发时机与验收口径"},
|
||||
{"when": "一周后", "action": "复核执行效果并决定是否扩展"},
|
||||
]
|
||||
|
||||
workload = 0.0
|
||||
if assignee is not None:
|
||||
workload = float(assignee.get("workload_percent") or 0.0)
|
||||
|
||||
return {
|
||||
"title": title,
|
||||
"summary": summary,
|
||||
"feasibility": feasibility,
|
||||
"feasibility_reason": feasibility_reason,
|
||||
"recommended_assignee_id": assignee_id,
|
||||
"recommended_assignee_name": str((assignee or {}).get("name") or "待指定").strip()[:40] or "待指定",
|
||||
"recommended_assignee_workload": round(max(0.0, min(100.0, workload)), 1),
|
||||
"recommended_assignee_reason": assignee_reason,
|
||||
"followup_plan": followup_plan,
|
||||
"original_text": str(original_text or "")[:2000],
|
||||
"version": int(version),
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def render_draft_message(draft: dict[str, Any]) -> str:
|
||||
title = str(draft.get("title") or "未命名需求").strip() or "未命名需求"
|
||||
summary = str(draft.get("summary") or "暂无摘要").strip() or "暂无摘要"
|
||||
feasibility = str(draft.get("feasibility") or "待讨论").strip() or "待讨论"
|
||||
feasibility_reason = str(draft.get("feasibility_reason") or "").strip()
|
||||
assignee_name = str(draft.get("recommended_assignee_name") or "待指定").strip() or "待指定"
|
||||
assignee_workload = DraftGeneratorService._safe_float(draft.get("recommended_assignee_workload"))
|
||||
assignee_reason = str(draft.get("recommended_assignee_reason") or "").strip()
|
||||
plan = draft.get("followup_plan") if isinstance(draft.get("followup_plan"), list) else []
|
||||
|
||||
plan_lines: list[str] = []
|
||||
for row in plan[:2]:
|
||||
if not isinstance(row, dict):
|
||||
continue
|
||||
when = str(row.get("when") or "待定").strip()[:20] or "待定"
|
||||
action = str(row.get("action") or "补充执行动作").strip()[:80] or "补充执行动作"
|
||||
plan_lines.append(f"· {when}:{action}")
|
||||
if not plan_lines:
|
||||
plan_lines = ["· 待定:补充跟进计划"]
|
||||
|
||||
workload_text = f"{assignee_workload:.1f}" if assignee_workload % 1 else f"{int(assignee_workload)}"
|
||||
feasibility_reason_line = feasibility_reason if feasibility_reason else "需补充约束后再确认。"
|
||||
assignee_reason_line = assignee_reason if assignee_reason else "建议结合成员技能与当前负载综合判断。"
|
||||
return "\n".join(
|
||||
[
|
||||
"📋 已为你起草需求,请确认:",
|
||||
"",
|
||||
f"📌 {title}",
|
||||
summary,
|
||||
"",
|
||||
f"✅ 可行性:{feasibility}",
|
||||
feasibility_reason_line,
|
||||
"",
|
||||
f"👤 推荐负责人:{assignee_name} · 当前负载{workload_text}%",
|
||||
assignee_reason_line,
|
||||
"",
|
||||
"📅 跟进计划:",
|
||||
*plan_lines,
|
||||
"",
|
||||
"---",
|
||||
"回复 确认 → 入库",
|
||||
"回复 修改 [具体修改意见] → 重新起草",
|
||||
"回复 取消 → 放弃",
|
||||
"⏰ 24小时内未确认自动取消",
|
||||
]
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _normalize_title(raw_title: str) -> str:
|
||||
title = str(raw_title or "").strip()
|
||||
if not title:
|
||||
return "需求草稿"
|
||||
return title[:20]
|
||||
|
||||
@staticmethod
|
||||
def _normalize_summary(raw_summary: str, *, fallback_text: str) -> str:
|
||||
summary = " ".join(str(raw_summary or "").split()).strip()
|
||||
if not summary:
|
||||
summary = " ".join(str(fallback_text or "").split()).strip()
|
||||
if not summary:
|
||||
summary = "请确认该需求是否需要立即入库。"
|
||||
return summary[:80]
|
||||
|
||||
@staticmethod
|
||||
def _normalize_followup_plan(value: Any) -> list[dict[str, str]]:
|
||||
if not isinstance(value, list):
|
||||
return []
|
||||
normalized: list[dict[str, str]] = []
|
||||
for row in value[:3]:
|
||||
if not isinstance(row, dict):
|
||||
continue
|
||||
when = str(row.get("when") or "").strip()[:20]
|
||||
action = str(row.get("action") or "").strip()[:80]
|
||||
if not when or not action:
|
||||
continue
|
||||
normalized.append({"when": when, "action": action})
|
||||
return normalized
|
||||
|
||||
@staticmethod
|
||||
def _safe_int(value: Any) -> Optional[int]:
|
||||
try:
|
||||
if value is None or value == "":
|
||||
return None
|
||||
return int(value)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _safe_float(value: Any) -> float:
|
||||
try:
|
||||
return float(value or 0.0)
|
||||
except Exception:
|
||||
return 0.0
|
||||
|
|
@ -19,6 +19,8 @@ class SignalCaptureEntryMixin:
|
|||
feishu_message_id: Optional[str] = None,
|
||||
chat_type: Optional[str] = None,
|
||||
force_sync: bool = False,
|
||||
message_sent_at: Optional[Any] = None,
|
||||
session_snapshot: Optional[Dict[str, Any]] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
从飞书群聊捕获信号
|
||||
|
|
@ -48,20 +50,49 @@ class SignalCaptureEntryMixin:
|
|||
self._emit_ai_think("capture_skip_empty", trace_id=trace_id)
|
||||
return {"status": "ignored", "reason": "empty_or_emoji_only"}
|
||||
|
||||
source_message_id = str(feishu_message_id or "").strip()
|
||||
if not source_message_id:
|
||||
source_message_id = AIPMOKernelService._build_source_message_id(
|
||||
source_system="feishu",
|
||||
source_message_id=None,
|
||||
channel_ref=group_chat_id,
|
||||
sender=proposer_name,
|
||||
message_text=cleaned_message,
|
||||
)
|
||||
|
||||
kernel = AIPMOKernelService(self.db, tenant_id=tenant_id)
|
||||
try:
|
||||
should_sync_process = bool(force_sync)
|
||||
enqueue_session = None
|
||||
if not should_sync_process:
|
||||
session = await kernel._get_or_create_session(
|
||||
enqueue_session = await kernel._get_or_create_session(
|
||||
tenant_id=int(tenant_id),
|
||||
sender=proposer_name,
|
||||
)
|
||||
should_sync_process = kernel._is_short_confirmation_message(
|
||||
message_text=cleaned_message,
|
||||
session=session,
|
||||
session=enqueue_session,
|
||||
message_sent_at=message_sent_at,
|
||||
session_snapshot=session_snapshot,
|
||||
)
|
||||
|
||||
if should_sync_process:
|
||||
effective_message_sent_at = self._resolve_enqueue_message_sent_at(
|
||||
captured_dt=captured_dt,
|
||||
message_sent_at=message_sent_at,
|
||||
)
|
||||
effective_session_snapshot = (
|
||||
session_snapshot
|
||||
if isinstance(session_snapshot, dict)
|
||||
else await self._build_short_confirm_session_snapshot(
|
||||
session=enqueue_session,
|
||||
tenant_id=int(tenant_id),
|
||||
sender=proposer_name,
|
||||
channel_ref=group_chat_id,
|
||||
source_message_id=source_message_id,
|
||||
fallback_snapshot=None,
|
||||
)
|
||||
)
|
||||
return await self._process_feishu_with_kernel_sync(
|
||||
trace_id=trace_id,
|
||||
tenant_id=tenant_id,
|
||||
|
|
@ -73,6 +104,8 @@ class SignalCaptureEntryMixin:
|
|||
captured_dt=captured_dt,
|
||||
feishu_message_id=feishu_message_id,
|
||||
chat_type=chat_type,
|
||||
message_sent_at=effective_message_sent_at,
|
||||
session_snapshot=effective_session_snapshot,
|
||||
)
|
||||
|
||||
if self._is_non_requirement_message(cleaned_message) and not self._is_question_like(cleaned_message):
|
||||
|
|
@ -83,14 +116,22 @@ class SignalCaptureEntryMixin:
|
|||
)
|
||||
return {"status": "ignored", "reason": "non_requirement_noise"}
|
||||
|
||||
source_message_id = str(feishu_message_id or "").strip()
|
||||
if not source_message_id:
|
||||
source_message_id = AIPMOKernelService._build_source_message_id(
|
||||
source_system="feishu",
|
||||
source_message_id=None,
|
||||
channel_ref=group_chat_id,
|
||||
if enqueue_session is None:
|
||||
enqueue_session = await kernel._get_or_create_session(
|
||||
tenant_id=int(tenant_id),
|
||||
sender=proposer_name,
|
||||
message_text=cleaned_message,
|
||||
)
|
||||
enqueue_message_sent_at = self._resolve_enqueue_message_sent_at(
|
||||
captured_dt=captured_dt,
|
||||
message_sent_at=message_sent_at,
|
||||
)
|
||||
enqueue_session_snapshot = await self._build_short_confirm_session_snapshot(
|
||||
session=enqueue_session,
|
||||
tenant_id=int(tenant_id),
|
||||
sender=proposer_name,
|
||||
channel_ref=group_chat_id,
|
||||
source_message_id=source_message_id,
|
||||
fallback_snapshot=session_snapshot,
|
||||
)
|
||||
|
||||
run_row, created = await AIOrchestrationRunService(self.db).enqueue_ingress_run(
|
||||
|
|
@ -107,6 +148,14 @@ class SignalCaptureEntryMixin:
|
|||
max_retries=3,
|
||||
)
|
||||
if created:
|
||||
base_payload = run_row.result_payload if isinstance(run_row.result_payload, dict) else {}
|
||||
run_row.result_payload = {
|
||||
**base_payload,
|
||||
"task_payload": {
|
||||
"message_sent_at": enqueue_message_sent_at.isoformat(),
|
||||
"session_snapshot": enqueue_session_snapshot,
|
||||
},
|
||||
}
|
||||
await AIOrchestrationRunService(self.db).mark_ingress_ack_sent(
|
||||
tenant_id=int(tenant_id),
|
||||
trace_id=str(run_row.trace_id or ""),
|
||||
|
|
@ -187,6 +236,8 @@ class SignalCaptureEntryMixin:
|
|||
captured_dt: datetime,
|
||||
feishu_message_id: Optional[str],
|
||||
chat_type: Optional[str],
|
||||
message_sent_at: Optional[Any] = None,
|
||||
session_snapshot: Optional[Dict[str, Any]] = None,
|
||||
) -> Dict[str, Any]:
|
||||
pipeline_result = await kernel.process_feishu_message(
|
||||
group_chat_id=group_chat_id,
|
||||
|
|
@ -196,6 +247,8 @@ class SignalCaptureEntryMixin:
|
|||
source_sender_open_id=proposer_open_id,
|
||||
captured_at=captured_dt,
|
||||
chat_type=chat_type,
|
||||
message_sent_at=message_sent_at,
|
||||
session_snapshot=session_snapshot,
|
||||
)
|
||||
if not bool(pipeline_result.get("handled")):
|
||||
self._emit_ai_think(
|
||||
|
|
@ -308,3 +361,135 @@ class SignalCaptureEntryMixin:
|
|||
"decision_level": None,
|
||||
"review_summary": None,
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _resolve_enqueue_message_sent_at(
|
||||
*,
|
||||
captured_dt: datetime,
|
||||
message_sent_at: Optional[Any],
|
||||
) -> datetime:
|
||||
if isinstance(message_sent_at, datetime):
|
||||
sent_at = message_sent_at
|
||||
else:
|
||||
sent_at = captured_dt
|
||||
if sent_at.tzinfo is None:
|
||||
return sent_at.replace(tzinfo=CN_TZ)
|
||||
return sent_at
|
||||
|
||||
@staticmethod
|
||||
def _coerce_snapshot_datetime(value: Any) -> Optional[datetime]:
|
||||
if isinstance(value, datetime):
|
||||
return value
|
||||
text_value = str(value or "").strip()
|
||||
if not text_value:
|
||||
return None
|
||||
try:
|
||||
parsed = datetime.fromisoformat(text_value.replace("Z", "+00:00"))
|
||||
except Exception:
|
||||
return None
|
||||
if parsed.tzinfo is None:
|
||||
return parsed
|
||||
return parsed.astimezone(CN_TZ).replace(tzinfo=None)
|
||||
|
||||
@staticmethod
|
||||
def _coerce_optional_int(value: Any) -> Optional[int]:
|
||||
try:
|
||||
if value is None:
|
||||
return None
|
||||
result = int(value)
|
||||
return result if result > 0 else None
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
async def _load_latest_ingress_context(
|
||||
self,
|
||||
*,
|
||||
tenant_id: int,
|
||||
sender: str,
|
||||
channel_ref: str,
|
||||
source_message_id: Optional[str],
|
||||
) -> Dict[str, Any]:
|
||||
row = (
|
||||
await self.db.execute(
|
||||
text(
|
||||
"""
|
||||
SELECT
|
||||
r.id,
|
||||
COALESCE(r.captured_at, r.created_at) AS last_message_at,
|
||||
r.message_text,
|
||||
w.intent AS resolved_intent,
|
||||
COALESCE(w.project_id, r.project_hint_id) AS project_id
|
||||
FROM ai_orchestration_runs r
|
||||
LEFT JOIN work_items w
|
||||
ON w.id = r.work_item_id
|
||||
WHERE r.tenant_id = :tenant_id
|
||||
AND r.run_type = 'ingress_feishu_message'
|
||||
AND r.source_system = 'feishu'
|
||||
AND COALESCE(r.sender, '') = :sender
|
||||
AND COALESCE(r.channel_ref, '') = :channel_ref
|
||||
AND (:source_message_id = '' OR COALESCE(r.source_message_id, '') <> :source_message_id)
|
||||
ORDER BY r.id DESC
|
||||
LIMIT 1
|
||||
"""
|
||||
),
|
||||
{
|
||||
"tenant_id": int(tenant_id),
|
||||
"sender": str(sender or "").strip(),
|
||||
"channel_ref": str(channel_ref or "").strip(),
|
||||
"source_message_id": str(source_message_id or "").strip(),
|
||||
},
|
||||
)
|
||||
).mappings().first()
|
||||
return dict(row or {})
|
||||
|
||||
async def _build_short_confirm_session_snapshot(
|
||||
self,
|
||||
*,
|
||||
session: Optional[Any],
|
||||
tenant_id: int,
|
||||
sender: str,
|
||||
channel_ref: str,
|
||||
source_message_id: Optional[str],
|
||||
fallback_snapshot: Optional[Dict[str, Any]] = None,
|
||||
) -> Dict[str, Any]:
|
||||
fallback = fallback_snapshot if isinstance(fallback_snapshot, dict) else {}
|
||||
recent_context = await self._load_latest_ingress_context(
|
||||
tenant_id=int(tenant_id),
|
||||
sender=sender,
|
||||
channel_ref=channel_ref,
|
||||
source_message_id=source_message_id,
|
||||
)
|
||||
session_last_active = self._coerce_snapshot_datetime(getattr(session, "last_active_at", None)) if session is not None else None
|
||||
recent_last_active = self._coerce_snapshot_datetime(recent_context.get("last_message_at"))
|
||||
fallback_last_active = self._coerce_snapshot_datetime(fallback.get("last_active_at"))
|
||||
|
||||
if isinstance(session_last_active, datetime) and isinstance(recent_last_active, datetime):
|
||||
effective_last_active = recent_last_active if recent_last_active > session_last_active else session_last_active
|
||||
else:
|
||||
effective_last_active = recent_last_active or session_last_active or fallback_last_active
|
||||
|
||||
session_last_intent = str(getattr(session, "last_intent", "") or "").strip() if session is not None else ""
|
||||
recent_last_intent = str(recent_context.get("resolved_intent") or "").strip()
|
||||
fallback_last_intent = str(fallback.get("last_intent") or "").strip()
|
||||
effective_last_intent = session_last_intent or recent_last_intent or fallback_last_intent
|
||||
|
||||
recent_message_text = str(recent_context.get("message_text") or "").strip()
|
||||
session_message_text = str(getattr(session, "last_message_text", "") or "").strip() if session is not None else ""
|
||||
fallback_message_text = str(fallback.get("last_message_text") or "").strip()
|
||||
effective_message_text = recent_message_text or session_message_text or fallback_message_text
|
||||
|
||||
if not effective_last_intent and effective_message_text:
|
||||
effective_last_intent = "query.read_only"
|
||||
|
||||
effective_project_id = (
|
||||
self._coerce_optional_int(getattr(session, "current_project_id", None) if session is not None else None)
|
||||
or self._coerce_optional_int(recent_context.get("project_id"))
|
||||
or self._coerce_optional_int(fallback.get("current_project_id"))
|
||||
)
|
||||
|
||||
return {
|
||||
"last_intent": effective_last_intent or None,
|
||||
"last_active_at": effective_last_active.isoformat() if isinstance(effective_last_active, datetime) else None,
|
||||
"current_project_id": effective_project_id,
|
||||
"last_message_text": effective_message_text or None,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -211,7 +211,7 @@ async def process_task_completion(
|
|||
user_id=operator_user_id or int(task.assignee_id),
|
||||
function_name="skill_score_update",
|
||||
prompt=prompt,
|
||||
response=(response_text or json.dumps({"deltas": deltas}, ensure_ascii=False))[:8000],
|
||||
response=(response_text or json.dumps({"deltas": deltas}, ensure_ascii=False))[:10000],
|
||||
tokens_used=max(len(prompt) // 4, 1) + max(len(response_text) // 4, 0),
|
||||
cost=((max(len(prompt) // 4, 1) * 0.00015 + max(len(response_text) // 4, 0) * 0.0006) / 1000),
|
||||
status=status,
|
||||
|
|
@ -219,4 +219,3 @@ async def process_task_completion(
|
|||
)
|
||||
await db.commit()
|
||||
return {"updated_count": updated, "status": status}
|
||||
|
||||
|
|
|
|||
|
|
@ -182,7 +182,7 @@ async def build_ai_optimize_preview(
|
|||
user_id=None,
|
||||
function_name="workload_optimize_prompt",
|
||||
prompt=llm_prompt,
|
||||
response=(llm_text or "")[:8000],
|
||||
response=(llm_text or "")[:10000],
|
||||
tokens_used=max(len(llm_prompt) // 4, 1) + max(len(llm_text) // 4, 0),
|
||||
cost=((max(len(llm_prompt) // 4, 1) * 0.00015 + max(len(llm_text) // 4, 0) * 0.0006) / 1000),
|
||||
status=llm_status,
|
||||
|
|
@ -289,4 +289,3 @@ async def apply_ai_optimize_plan(
|
|||
await db.commit()
|
||||
|
||||
return {"applied_count": applied, "affected_users": len(touched_user_ids)}
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,97 @@
|
|||
import pytest
|
||||
|
||||
from app.services.adaptive_rag_service.mixin_answer_entry import AdaptiveRAGAnswerEntryMixin
|
||||
from app.services.adaptive_rag_service.mixin_answer_mainline import AdaptiveRAGAnswerMainlineMixin
|
||||
|
||||
|
||||
class _FakeAdaptiveRAGService(
|
||||
AdaptiveRAGAnswerEntryMixin,
|
||||
AdaptiveRAGAnswerMainlineMixin,
|
||||
):
|
||||
def __init__(self) -> None:
|
||||
self.db = object()
|
||||
self.tenant_id = 9
|
||||
self.ack_calls = 0
|
||||
self.base_calls = 0
|
||||
self.base_kwargs: dict[str, object] = {}
|
||||
|
||||
def _normalize_thinking_mode(self, thinking_mode: str) -> str:
|
||||
return "deep" if str(thinking_mode or "").strip().lower() == "deep" else "normal"
|
||||
|
||||
async def _prepare_answer_runtime(self, **kwargs):
|
||||
clean_q = str(kwargs.get("clean_q") or "").strip()
|
||||
action_mode = str(kwargs.get("action_mode") or "").strip().lower()
|
||||
return {
|
||||
"session_id": 101,
|
||||
"turn_index": 2,
|
||||
"context_session_id": 101,
|
||||
"conversation": [],
|
||||
"state_row": None,
|
||||
"effective_q": clean_q,
|
||||
"rewrite_meta": {"mode": "direct"},
|
||||
"query_type": "explain",
|
||||
"effective_action_mode": action_mode,
|
||||
}
|
||||
|
||||
async def _handle_ack_first_requirement(self, **kwargs):
|
||||
self.ack_calls += 1
|
||||
_ = kwargs
|
||||
return {"answer": "ack-required", "processing_stage": "ack_only"}
|
||||
|
||||
async def _execute_base_answer_mainline(self, **kwargs):
|
||||
self.base_calls += 1
|
||||
self.base_kwargs = dict(kwargs)
|
||||
return {"answer": "base-answer", "processing_stage": "qa"}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_answer_entry_read_mainline_skips_requirement_ack() -> None:
|
||||
service = _FakeAdaptiveRAGService()
|
||||
|
||||
result = await service.answer_question(
|
||||
question="现在有哪些阻塞?",
|
||||
asked_by=1,
|
||||
asked_via="pmflow_manual",
|
||||
action_mode="read",
|
||||
sync_requirement=True,
|
||||
)
|
||||
|
||||
assert result["processing_stage"] == "qa"
|
||||
assert service.ack_calls == 0
|
||||
assert service.base_calls == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_answer_entry_write_mode_uses_requirement_ack_gate() -> None:
|
||||
service = _FakeAdaptiveRAGService()
|
||||
|
||||
result = await service.answer_question(
|
||||
question="请同步写入一条风险",
|
||||
asked_by=1,
|
||||
asked_via="pmflow_manual",
|
||||
action_mode="write",
|
||||
sync_requirement=True,
|
||||
)
|
||||
|
||||
assert result["processing_stage"] == "ack_only"
|
||||
assert service.ack_calls == 1
|
||||
assert service.base_calls == 0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_answer_entry_feishu_write_mode_still_goes_base_mainline() -> None:
|
||||
service = _FakeAdaptiveRAGService()
|
||||
|
||||
result = await service.answer_question(
|
||||
question="请在飞书里补一条风险",
|
||||
asked_by=1,
|
||||
asked_via="feishu",
|
||||
action_mode="write",
|
||||
sync_requirement=True,
|
||||
)
|
||||
|
||||
assert result["processing_stage"] == "qa"
|
||||
assert service.ack_calls == 0
|
||||
assert service.base_calls == 1
|
||||
assert int(service.base_kwargs["deep_retrieval_steps"]) <= 3
|
||||
assert int(service.base_kwargs["deep_vector_top_k"]) <= 8
|
||||
|
|
@ -0,0 +1,147 @@
|
|||
from types import SimpleNamespace
|
||||
|
||||
from app.services.ai_pmo.ai_pmo_intent import (
|
||||
INTENT_QUERY,
|
||||
INTENT_SIGNAL_CREATE,
|
||||
CanonicalIntent,
|
||||
)
|
||||
from app.services.ai_pmo.ai_pmo_kernel.mixins.ingress.ai_pmo_kernel_mixins_ingress_storage import (
|
||||
KernelIngressStorageMixin,
|
||||
)
|
||||
|
||||
|
||||
class _FakeStorage(KernelIngressStorageMixin):
|
||||
def __init__(self) -> None:
|
||||
self.tenant_id = 9
|
||||
|
||||
@staticmethod
|
||||
def _primary_operation_code(intent_name: str) -> str | None:
|
||||
normalized = str(intent_name or "").strip().lower()
|
||||
if normalized == "project_signal.discover":
|
||||
return "project_signal.discover"
|
||||
if normalized == INTENT_QUERY:
|
||||
return "query.answer"
|
||||
return None
|
||||
|
||||
|
||||
def _build_intent(name: str, *, project_id: int = 11) -> CanonicalIntent:
|
||||
return CanonicalIntent(
|
||||
name=name,
|
||||
confidence=0.9,
|
||||
project_id=project_id,
|
||||
signal_title="",
|
||||
signal_summary="",
|
||||
metadata={},
|
||||
)
|
||||
|
||||
|
||||
def test_build_execution_action_contract_uses_plan_route_and_result() -> None:
|
||||
storage = _FakeStorage()
|
||||
contract = storage._build_execution_action_contract(
|
||||
intent=_build_intent(INTENT_SIGNAL_CREATE),
|
||||
workflow_route=SimpleNamespace(
|
||||
key="project_signal.discover",
|
||||
action="discover",
|
||||
project_id=22,
|
||||
requested_count=3,
|
||||
),
|
||||
decision_payload={
|
||||
"execution_plan": {
|
||||
"project_id": 33,
|
||||
"actions": [
|
||||
{"operation_code": "project_signal.discover", "mode": "write"},
|
||||
{"operation_code": "signal.enter", "mode": "write"},
|
||||
],
|
||||
}
|
||||
},
|
||||
execution_result={"signal_id": 88, "requested_count": 5},
|
||||
source_system="feishu",
|
||||
source_message_id="msg-1",
|
||||
channel_ref="oc_xxx",
|
||||
)
|
||||
|
||||
assert contract["operation_code"] == "project_signal.discover"
|
||||
assert contract["mode"] == "write"
|
||||
assert contract["workflow_route"] == "project_signal.discover"
|
||||
assert contract["workflow_action"] == "discover"
|
||||
assert contract["params"]["project_id"] == 22
|
||||
assert contract["params"]["requested_count"] == 5
|
||||
assert contract["params"]["signal_id"] == 88
|
||||
assert contract["params"]["source_system"] == "feishu"
|
||||
assert contract["params"]["source_message_id"] == "msg-1"
|
||||
assert contract["params"]["channel_ref"] == "oc_xxx"
|
||||
assert contract["param_source"] == "decision.execution_plan.actions+workflow_route+execution_result"
|
||||
|
||||
|
||||
def test_apply_execution_action_contract_sets_uniform_fields() -> None:
|
||||
storage = _FakeStorage()
|
||||
applied = storage._apply_execution_action_contract(
|
||||
intent=_build_intent(INTENT_QUERY, project_id=77),
|
||||
workflow_route=SimpleNamespace(
|
||||
key=INTENT_QUERY,
|
||||
action="answer",
|
||||
project_id=77,
|
||||
requested_count=None,
|
||||
),
|
||||
decision_payload={"execution_plan": {"actions": []}},
|
||||
execution_result={"status": "succeeded"},
|
||||
source_system="pmflow_manual",
|
||||
source_message_id="manual-1",
|
||||
channel_ref=None,
|
||||
)
|
||||
|
||||
assert applied["action_param_source"] == "decision.execution_plan.actions+workflow_route+execution_result"
|
||||
assert applied["operation_code"] == "query.answer"
|
||||
action_contract = applied["action_contract"]
|
||||
assert action_contract["operation_code"] == "query.answer"
|
||||
assert action_contract["mode"] == "read_only"
|
||||
assert action_contract["params"]["project_id"] == 77
|
||||
|
||||
|
||||
def test_execution_ledger_idempotency_key_is_stable() -> None:
|
||||
key_a = KernelIngressStorageMixin._build_execution_ledger_idempotency_key(
|
||||
tenant_id=9,
|
||||
work_item_id=101,
|
||||
operation_code="task.create",
|
||||
source_message_id="feishu-msg-abc",
|
||||
)
|
||||
key_b = KernelIngressStorageMixin._build_execution_ledger_idempotency_key(
|
||||
tenant_id=9,
|
||||
work_item_id=101,
|
||||
operation_code="task.create",
|
||||
source_message_id="feishu-msg-abc",
|
||||
)
|
||||
key_c = KernelIngressStorageMixin._build_execution_ledger_idempotency_key(
|
||||
tenant_id=9,
|
||||
work_item_id=102,
|
||||
operation_code="task.create",
|
||||
source_message_id="feishu-msg-abc",
|
||||
)
|
||||
|
||||
assert key_a == key_b
|
||||
assert key_a != key_c
|
||||
assert len(key_a) == 40
|
||||
|
||||
|
||||
def test_normalize_execution_ledger_status_maps_work_item_state() -> None:
|
||||
assert (
|
||||
KernelIngressStorageMixin._normalize_execution_ledger_status(
|
||||
work_item_state="succeeded",
|
||||
execution_status="failed_safe",
|
||||
)
|
||||
== "applied"
|
||||
)
|
||||
assert (
|
||||
KernelIngressStorageMixin._normalize_execution_ledger_status(
|
||||
work_item_state="waiting_manual",
|
||||
execution_status="succeeded",
|
||||
)
|
||||
== "pending_manual"
|
||||
)
|
||||
assert (
|
||||
KernelIngressStorageMixin._normalize_execution_ledger_status(
|
||||
work_item_state="failed",
|
||||
execution_status="succeeded",
|
||||
)
|
||||
== "failed"
|
||||
)
|
||||
|
|
@ -0,0 +1,251 @@
|
|||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
from fastapi import HTTPException
|
||||
|
||||
from app.api.ai_pmo_api import routes_core
|
||||
from app.api.ai_pmo_api.schemas import PMOProcessTextRequest
|
||||
from app.services.ai_pmo.ai_pmo_kernel_ingress_flow import mixin_entry
|
||||
from app.services.ai_pmo.ai_pmo_typed_executor_execute import mixin_execute_query_project
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_process_text_route_uses_pmflow_manual_ingress(monkeypatch) -> None:
|
||||
captured: dict[str, object] = {}
|
||||
|
||||
class FakeKernel:
|
||||
def __init__(self, db, tenant_id: int):
|
||||
captured["init_db"] = db
|
||||
captured["init_tenant_id"] = tenant_id
|
||||
|
||||
async def process_ingress(self, **kwargs):
|
||||
captured["ingress_kwargs"] = kwargs
|
||||
return {
|
||||
"handled": True,
|
||||
"status": "answered",
|
||||
"classification": "question",
|
||||
"direct_reply_required": True,
|
||||
"direct_reply_text": "当前主链正常。",
|
||||
}
|
||||
|
||||
monkeypatch.setattr(routes_core, "AIPMOKernelService", FakeKernel)
|
||||
|
||||
request = PMOProcessTextRequest(
|
||||
message_text="现在进展怎么样?",
|
||||
sender="产品经理A",
|
||||
source_message_id="manual-1",
|
||||
project_id=11,
|
||||
)
|
||||
current_user = SimpleNamespace(name="ignored", username="ignored")
|
||||
|
||||
response = await routes_core.process_text(
|
||||
request=request,
|
||||
current_user=current_user,
|
||||
db=object(),
|
||||
tenant_id=5,
|
||||
)
|
||||
|
||||
assert response["code"] == 0
|
||||
data = response["data"]
|
||||
assert data["status"] == "answered"
|
||||
kwargs = captured["ingress_kwargs"]
|
||||
assert isinstance(kwargs, dict)
|
||||
assert kwargs["source_system"] == "pmflow_manual"
|
||||
assert kwargs["source_message_id"] == "manual-1"
|
||||
assert kwargs["message_text"] == "现在进展怎么样?"
|
||||
assert kwargs["sender"] == "产品经理A"
|
||||
assert kwargs["channel_ref"] == "pmflow_manual"
|
||||
assert kwargs["chat_type"] == "manual"
|
||||
assert kwargs["project_hint_id"] == 11
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_process_text_route_rejects_unhandled_result(monkeypatch) -> None:
|
||||
class FakeKernel:
|
||||
def __init__(self, db, tenant_id: int):
|
||||
_ = (db, tenant_id)
|
||||
|
||||
async def process_ingress(self, **kwargs):
|
||||
_ = kwargs
|
||||
return {"handled": False, "reason": "kernel_not_handled"}
|
||||
|
||||
monkeypatch.setattr(routes_core, "AIPMOKernelService", FakeKernel)
|
||||
request = PMOProcessTextRequest(message_text="hello")
|
||||
current_user = SimpleNamespace(name="u", username="u")
|
||||
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await routes_core.process_text(
|
||||
request=request,
|
||||
current_user=current_user,
|
||||
db=object(),
|
||||
tenant_id=1,
|
||||
)
|
||||
assert exc_info.value.status_code == 400
|
||||
assert str(exc_info.value.detail) == "kernel_not_handled"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_prepare_mixed_sidecar_context_passes_mixed_contract(monkeypatch) -> None:
|
||||
captured: dict[str, object] = {}
|
||||
|
||||
class FakeAdaptiveRAGService:
|
||||
def __init__(self, db, tenant_id: int):
|
||||
captured["tenant_id"] = tenant_id
|
||||
_ = db
|
||||
|
||||
async def classify_intent(self, text: str):
|
||||
captured["classified_text"] = text
|
||||
return {
|
||||
"intent": "mixed",
|
||||
"question_part": "现在进展如何",
|
||||
"statement_part": "新增任务确认流",
|
||||
}
|
||||
|
||||
async def answer_question(self, **kwargs):
|
||||
captured["answer_kwargs"] = kwargs
|
||||
return {"answer": "[回答] 进展稳定\n[已收到需求] 新增任务确认流"}
|
||||
|
||||
class FakeKernel(mixin_entry.KernelIngressFlowEntryMixin):
|
||||
def __init__(self) -> None:
|
||||
self.db = object()
|
||||
self.tenant_id = 9
|
||||
|
||||
monkeypatch.setattr(mixin_entry, "AdaptiveRAGService", FakeAdaptiveRAGService)
|
||||
kernel = FakeKernel()
|
||||
clean_text, mixed_meta = await kernel._prepare_mixed_sidecar_context(
|
||||
source_system="pmflow_manual",
|
||||
source_message_id="manual-msg-2",
|
||||
channel_ref="pmflow-chat",
|
||||
message_text="顺便问下现在进展如何,并且新增任务确认流",
|
||||
project_hint_id=22,
|
||||
)
|
||||
|
||||
assert clean_text == "新增任务确认流"
|
||||
assert mixed_meta["mixed_intent"] is True
|
||||
assert mixed_meta["mixed_question_part"] == "现在进展如何"
|
||||
assert mixed_meta["mixed_signal_part"] == "新增任务确认流"
|
||||
assert "[回答] 进展稳定" in str(mixed_meta.get("mixed_sidecar_reply") or "")
|
||||
answer_kwargs = captured["answer_kwargs"]
|
||||
assert isinstance(answer_kwargs, dict)
|
||||
assert answer_kwargs["asked_via"] == "pmflow_manual"
|
||||
assert answer_kwargs["message_intent"] == "mixed"
|
||||
assert answer_kwargs["mixed_signal_part"] == "新增任务确认流"
|
||||
assert answer_kwargs["conversation_key"] == "manual-msg-2"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_process_ingress_prioritizes_draft_pending_confirm(monkeypatch) -> None:
|
||||
class FakeKernel(mixin_entry.KernelIngressFlowEntryMixin):
|
||||
def __init__(self) -> None:
|
||||
self.db = object()
|
||||
self.tenant_id = 1
|
||||
self.draft_called = False
|
||||
|
||||
async def _prepare_ingress_envelope(self, **kwargs):
|
||||
_ = kwargs
|
||||
return {
|
||||
"early_result": None,
|
||||
"clean_text": "确认",
|
||||
"synthetic_message_id": "s-msg-1",
|
||||
"closure_payload": {},
|
||||
"ingest_event": SimpleNamespace(id=100),
|
||||
"operator": SimpleNamespace(id=8),
|
||||
"work_item": SimpleNamespace(id=55),
|
||||
"session": SimpleNamespace(last_intent=mixin_entry.STATE_DRAFT_PENDING_CONFIRM),
|
||||
}
|
||||
|
||||
async def _maybe_handle_draft_confirmation_ingress(self, **kwargs):
|
||||
self.draft_called = True
|
||||
_ = kwargs
|
||||
return {
|
||||
"handled": True,
|
||||
"status": "answered",
|
||||
"classification": "command",
|
||||
"direct_reply_required": True,
|
||||
"direct_reply_text": "已确认草稿并继续执行。",
|
||||
}
|
||||
|
||||
def _is_short_confirmation_message(self, **kwargs):
|
||||
_ = kwargs
|
||||
return False
|
||||
|
||||
async def _prepare_mixed_sidecar_context(self, **kwargs):
|
||||
_ = kwargs
|
||||
raise AssertionError("draft_pending_confirm flow should bypass mixed sidecar precheck")
|
||||
|
||||
kernel = FakeKernel()
|
||||
result = await kernel.process_ingress(
|
||||
source_system="feishu",
|
||||
source_message_id="feishu-1",
|
||||
message_text="确认",
|
||||
sender="tester",
|
||||
channel_ref="chat-1",
|
||||
source_sender_open_id="ou_xxx",
|
||||
)
|
||||
|
||||
assert kernel.draft_called is True
|
||||
assert result["status"] == "answered"
|
||||
assert "草稿" in result["direct_reply_text"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_execute_query_intent_honors_rag_request_context(monkeypatch) -> None:
|
||||
captured: dict[str, object] = {}
|
||||
|
||||
class FakeAdaptiveRAGService:
|
||||
def __init__(self, db, tenant_id: int):
|
||||
_ = db
|
||||
captured["tenant_id"] = tenant_id
|
||||
|
||||
async def answer_question(self, **kwargs):
|
||||
captured["answer_kwargs"] = kwargs
|
||||
return {"answer": "来自统一 ingress 的回答", "session_id": 7}
|
||||
|
||||
class FakeExecutor(mixin_execute_query_project.ExecuteQueryProjectMixin):
|
||||
def __init__(self) -> None:
|
||||
self.db = object()
|
||||
self.tenant_id = 3
|
||||
|
||||
monkeypatch.setattr(mixin_execute_query_project, "AdaptiveRAGService", FakeAdaptiveRAGService)
|
||||
executor = FakeExecutor()
|
||||
intent = SimpleNamespace(
|
||||
project_id=12,
|
||||
metadata={
|
||||
"rag_request_context": {
|
||||
"asked_by": 101,
|
||||
"asked_via": "pmflow_manual",
|
||||
"project_id": 19,
|
||||
"source_chat_id": "chat-abc",
|
||||
"session_id": 55,
|
||||
"force_new_session": True,
|
||||
"thinking_mode": "deep",
|
||||
"action_mode": "read_write",
|
||||
"sync_requirement": True,
|
||||
"message_intent": "mixed",
|
||||
"mixed_signal_part": "新增任务确认流",
|
||||
}
|
||||
},
|
||||
)
|
||||
result = await executor._execute_query_intent(
|
||||
intent=intent,
|
||||
message_text="请同步更新状态",
|
||||
operator_user_id=999,
|
||||
source_system="feishu",
|
||||
source_message_id="msg-1",
|
||||
)
|
||||
|
||||
assert result["status"] == "succeeded"
|
||||
assert "统一 ingress" in result["answer"]
|
||||
answer_kwargs = captured["answer_kwargs"]
|
||||
assert isinstance(answer_kwargs, dict)
|
||||
assert answer_kwargs["asked_by"] == 101
|
||||
assert answer_kwargs["asked_via"] == "pmflow_manual"
|
||||
assert answer_kwargs["project_id"] == 19
|
||||
assert answer_kwargs["source_chat_id"] == "chat-abc"
|
||||
assert answer_kwargs["session_id"] == 55
|
||||
assert answer_kwargs["force_new_session"] is True
|
||||
assert answer_kwargs["thinking_mode"] == "deep"
|
||||
assert answer_kwargs["action_mode"] == "read_write"
|
||||
assert answer_kwargs["sync_requirement"] is True
|
||||
assert answer_kwargs["message_intent"] == "mixed"
|
||||
assert answer_kwargs["mixed_signal_part"] == "新增任务确认流"
|
||||
|
|
@ -0,0 +1,168 @@
|
|||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
from fastapi import FastAPI
|
||||
from httpx import ASGITransport, AsyncClient
|
||||
|
||||
from app.api.ai_pmo_api import routes_core
|
||||
from app.api.rag_api import routes_query
|
||||
from app.core.database import get_db
|
||||
from app.dependencies.auth import get_current_user
|
||||
from app.dependencies.tenant import get_tenant_id
|
||||
|
||||
|
||||
def _build_test_app() -> FastAPI:
|
||||
app = FastAPI()
|
||||
app.include_router(routes_query.router)
|
||||
app.include_router(routes_core.router)
|
||||
|
||||
async def _fake_get_db():
|
||||
yield object()
|
||||
|
||||
app.dependency_overrides[get_db] = _fake_get_db
|
||||
app.dependency_overrides[get_tenant_id] = lambda: 9
|
||||
app.dependency_overrides[get_current_user] = lambda: SimpleNamespace(
|
||||
id=101,
|
||||
tenant_id=9,
|
||||
name="测试用户",
|
||||
username="tester",
|
||||
)
|
||||
return app
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_http_rag_ask_readonly_mainline_contract(monkeypatch) -> None:
|
||||
captured: dict[str, object] = {}
|
||||
|
||||
class FakeKernel:
|
||||
def __init__(self, db, tenant_id: int):
|
||||
_ = db
|
||||
captured["tenant_id"] = tenant_id
|
||||
|
||||
async def process_ingress(self, **kwargs):
|
||||
captured["ingress_kwargs"] = kwargs
|
||||
return {
|
||||
"handled": True,
|
||||
"status": "answered",
|
||||
"intent": "query.read_only",
|
||||
"direct_reply_required": True,
|
||||
"direct_reply_text": "当前主链状态正常。",
|
||||
"rag_result": {"answer": "当前主链状态正常。", "processing_stage": "qa"},
|
||||
}
|
||||
|
||||
monkeypatch.setattr(routes_query, "AIPMOKernelService", FakeKernel)
|
||||
app = _build_test_app()
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
response = await client.post(
|
||||
"/rag/ask",
|
||||
json={
|
||||
"question": "当前主链状态?",
|
||||
"asked_via": "pmflow_manual",
|
||||
"action_mode": "read",
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
payload = response.json()
|
||||
assert payload["code"] == 0
|
||||
assert "当前主链状态正常" in str((payload.get("data") or {}).get("answer") or "")
|
||||
ingress_kwargs = captured["ingress_kwargs"]
|
||||
assert isinstance(ingress_kwargs, dict)
|
||||
assert ingress_kwargs["source_system"] == "pmflow_manual"
|
||||
rag_request_context = ingress_kwargs["ingress_options"]["rag_request_context"]
|
||||
assert rag_request_context["asked_via"] == "pmflow_manual"
|
||||
assert rag_request_context["action_mode"] == "read"
|
||||
assert rag_request_context["message_intent"] is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_http_rag_ask_mixed_mainline_contract(monkeypatch) -> None:
|
||||
captured: dict[str, object] = {}
|
||||
|
||||
class FakeKernel:
|
||||
def __init__(self, db, tenant_id: int):
|
||||
_ = db
|
||||
captured["tenant_id"] = tenant_id
|
||||
|
||||
async def process_ingress(self, **kwargs):
|
||||
captured["ingress_kwargs"] = kwargs
|
||||
return {
|
||||
"handled": True,
|
||||
"status": "answered",
|
||||
"intent": "query.read_only",
|
||||
"direct_reply_required": True,
|
||||
"direct_reply_text": "[回答] 进展稳定\n[已收到需求] 新增任务确认流",
|
||||
"rag_result": {"answer": "[回答] 进展稳定\n[已收到需求] 新增任务确认流"},
|
||||
}
|
||||
|
||||
monkeypatch.setattr(routes_query, "AIPMOKernelService", FakeKernel)
|
||||
app = _build_test_app()
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
response = await client.post(
|
||||
"/rag/ask",
|
||||
json={
|
||||
"question": "顺便问进展并新增任务确认流",
|
||||
"asked_via": "pmflow_manual",
|
||||
"action_mode": "read",
|
||||
"message_intent": "mixed",
|
||||
"mixed_signal_part": "新增任务确认流",
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
payload = response.json()
|
||||
assert payload["code"] == 0
|
||||
assert "[回答]" in str((payload.get("data") or {}).get("answer") or "")
|
||||
ingress_kwargs = captured["ingress_kwargs"]
|
||||
assert isinstance(ingress_kwargs, dict)
|
||||
rag_request_context = ingress_kwargs["ingress_options"]["rag_request_context"]
|
||||
assert rag_request_context["message_intent"] == "mixed"
|
||||
assert rag_request_context["mixed_signal_part"] == "新增任务确认流"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_http_ai_pmo_process_text_draft_pending_confirm_mainline(monkeypatch) -> None:
|
||||
captured: dict[str, object] = {}
|
||||
|
||||
class FakeKernel:
|
||||
def __init__(self, db, tenant_id: int):
|
||||
_ = db
|
||||
captured["tenant_id"] = tenant_id
|
||||
|
||||
async def process_ingress(self, **kwargs):
|
||||
captured["ingress_kwargs"] = kwargs
|
||||
return {
|
||||
"handled": True,
|
||||
"status": "draft_pending_confirm",
|
||||
"classification": "command",
|
||||
"direct_reply_required": True,
|
||||
"direct_reply_text": "草稿已生成,请确认。",
|
||||
"draft_version": 2,
|
||||
}
|
||||
|
||||
monkeypatch.setattr(routes_core, "AIPMOKernelService", FakeKernel)
|
||||
app = _build_test_app()
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
response = await client.post(
|
||||
"/ai-pmo/process-text",
|
||||
json={
|
||||
"message_text": "请创建任务并先给草稿",
|
||||
"sender": "产品经理A",
|
||||
"source_message_id": "manual-msg-100",
|
||||
"project_id": 12,
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
payload = response.json()
|
||||
assert payload["code"] == 0
|
||||
data = payload["data"]
|
||||
assert data["status"] == "draft_pending_confirm"
|
||||
kwargs = captured["ingress_kwargs"]
|
||||
assert isinstance(kwargs, dict)
|
||||
assert kwargs["source_system"] == "pmflow_manual"
|
||||
assert kwargs["message_text"] == "请创建任务并先给草稿"
|
||||
assert kwargs["project_hint_id"] == 12
|
||||
|
|
@ -0,0 +1,73 @@
|
|||
import pytest
|
||||
|
||||
from app.services.ai_pmo.ai_pmo_decision.tactical import TacticalBrain
|
||||
from app.services.ai_pmo.ai_pmo_intent import (
|
||||
INTENT_FOLLOWUP_CONTINUE,
|
||||
INTENT_QUERY,
|
||||
INTENT_SIGNAL_CREATE,
|
||||
ROUTE_PROJECT_SIGNAL_CANCEL,
|
||||
ROUTE_PROJECT_SIGNAL_COMMIT,
|
||||
ROUTE_PROJECT_SIGNAL_DISCOVER,
|
||||
ROUTE_PROJECT_SIGNAL_REFRESH,
|
||||
CanonicalIntent,
|
||||
)
|
||||
|
||||
|
||||
def _build_intent(name: str, *, metadata: dict | None = None) -> CanonicalIntent:
|
||||
return CanonicalIntent(
|
||||
name=name,
|
||||
confidence=0.9,
|
||||
project_id=11,
|
||||
signal_title="",
|
||||
signal_summary="",
|
||||
metadata=metadata or {},
|
||||
)
|
||||
|
||||
|
||||
def test_tactical_brain_query_intent_maps_to_read_only_answer_action() -> None:
|
||||
_strategic_plan, execution_plan = TacticalBrain.plan(
|
||||
intent=_build_intent(INTENT_QUERY),
|
||||
world_model={},
|
||||
decision={},
|
||||
)
|
||||
assert execution_plan["intent"] == INTENT_QUERY
|
||||
assert execution_plan["actions"] == [{"operation_code": "query.answer", "mode": "read_only"}]
|
||||
|
||||
|
||||
def test_tactical_brain_followup_intent_maps_to_resume_action() -> None:
|
||||
_strategic_plan, execution_plan = TacticalBrain.plan(
|
||||
intent=_build_intent(INTENT_FOLLOWUP_CONTINUE),
|
||||
world_model={},
|
||||
decision={},
|
||||
)
|
||||
assert execution_plan["actions"] == [{"operation_code": "work_item.resume", "mode": "write"}]
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("workflow_route", "expected_operation_code"),
|
||||
[
|
||||
(ROUTE_PROJECT_SIGNAL_DISCOVER, ROUTE_PROJECT_SIGNAL_DISCOVER),
|
||||
(ROUTE_PROJECT_SIGNAL_COMMIT, ROUTE_PROJECT_SIGNAL_COMMIT),
|
||||
(ROUTE_PROJECT_SIGNAL_REFRESH, ROUTE_PROJECT_SIGNAL_REFRESH),
|
||||
(ROUTE_PROJECT_SIGNAL_CANCEL, ROUTE_PROJECT_SIGNAL_CANCEL),
|
||||
],
|
||||
)
|
||||
def test_tactical_brain_signal_create_route_specific_action_mapping(
|
||||
workflow_route: str,
|
||||
expected_operation_code: str,
|
||||
) -> None:
|
||||
_strategic_plan, execution_plan = TacticalBrain.plan(
|
||||
intent=_build_intent(INTENT_SIGNAL_CREATE, metadata={"workflow_route": workflow_route}),
|
||||
world_model={},
|
||||
decision={},
|
||||
)
|
||||
assert execution_plan["actions"] == [{"operation_code": expected_operation_code, "mode": "write"}]
|
||||
|
||||
|
||||
def test_tactical_brain_signal_create_defaults_to_signal_enter() -> None:
|
||||
_strategic_plan, execution_plan = TacticalBrain.plan(
|
||||
intent=_build_intent(INTENT_SIGNAL_CREATE, metadata={"workflow_route": "project_signal.unknown"}),
|
||||
world_model={},
|
||||
decision={},
|
||||
)
|
||||
assert execution_plan["actions"] == [{"operation_code": "signal.enter", "mode": "write"}]
|
||||
|
|
@ -0,0 +1,119 @@
|
|||
import pytest
|
||||
|
||||
from app.services.ai_pmo.ai_pmo_intent import (
|
||||
INTENT_FOLLOWUP_CONTINUE,
|
||||
INTENT_QUERY,
|
||||
INTENT_TASK_CREATE,
|
||||
ROUTE_ACTION_ANSWER,
|
||||
ROUTE_ACTION_CANCEL,
|
||||
ROUTE_ACTION_CREATE,
|
||||
ROUTE_FAMILY_QUERY,
|
||||
ROUTE_FAMILY_TASK,
|
||||
ROUTE_QUERY_ANSWER,
|
||||
ROUTE_TASK_CREATE,
|
||||
CanonicalIntent,
|
||||
IntentResolver,
|
||||
RouteResolver,
|
||||
)
|
||||
from app.services.ai_pmo.ai_pmo_typed_executor_execute.service import TypedExecutorExecuteMixin
|
||||
|
||||
|
||||
def test_intent_resolver_recognizes_task_create() -> None:
|
||||
intent = IntentResolver.resolve(
|
||||
text="请在项目#12 新建任务:修复登录超时",
|
||||
project_id=12,
|
||||
)
|
||||
assert intent.name == INTENT_TASK_CREATE
|
||||
assert intent.project_id == 12
|
||||
assert str((intent.metadata or {}).get("task_title") or "").startswith("修复登录超时")
|
||||
|
||||
|
||||
def test_route_resolver_builds_task_create_route() -> None:
|
||||
route = RouteResolver.from_canonical(
|
||||
canonical_intent=CanonicalIntent(
|
||||
name=INTENT_TASK_CREATE,
|
||||
confidence=0.9,
|
||||
project_id=3,
|
||||
signal_title="修复登录超时",
|
||||
signal_summary="修复线上登录超时问题",
|
||||
metadata={},
|
||||
),
|
||||
message_text="新增任务:修复登录超时",
|
||||
)
|
||||
assert route.family == ROUTE_FAMILY_TASK
|
||||
assert route.action == ROUTE_ACTION_CREATE
|
||||
assert route.key == ROUTE_TASK_CREATE
|
||||
|
||||
|
||||
def test_intent_resolver_negated_write_statement_falls_back_to_query_read_only() -> None:
|
||||
intent = IntentResolver.resolve(
|
||||
text="先不要创建任务,告诉我当前进展和风险",
|
||||
project_id=7,
|
||||
)
|
||||
assert intent.name == INTENT_QUERY
|
||||
assert str((intent.metadata or {}).get("reason") or "") == "negated_write_statement"
|
||||
|
||||
|
||||
def test_route_resolver_builds_query_answer_route() -> None:
|
||||
route = RouteResolver.from_canonical(
|
||||
canonical_intent=CanonicalIntent(
|
||||
name=INTENT_QUERY,
|
||||
confidence=0.92,
|
||||
project_id=7,
|
||||
signal_title="",
|
||||
signal_summary="当前进展和风险",
|
||||
metadata={},
|
||||
),
|
||||
message_text="当前进展和风险是什么?",
|
||||
)
|
||||
assert route.family == ROUTE_FAMILY_QUERY
|
||||
assert route.action == ROUTE_ACTION_ANSWER
|
||||
assert route.key == ROUTE_QUERY_ANSWER
|
||||
|
||||
|
||||
def test_route_resolver_followup_action_supports_message_text_fallback() -> None:
|
||||
action = RouteResolver.parse_followup_action(
|
||||
canonical_intent=INTENT_FOLLOWUP_CONTINUE,
|
||||
message_text="先取消这个工作项",
|
||||
)
|
||||
assert action == ROUTE_ACTION_CANCEL
|
||||
|
||||
|
||||
class _DummyTypedExecutor(TypedExecutorExecuteMixin):
|
||||
def __init__(self) -> None:
|
||||
self.called: dict[str, object] = {}
|
||||
|
||||
async def _execute_task_create_intent(self, *, intent, message_text: str, operator_user_id):
|
||||
self.called = {
|
||||
"intent": intent.name,
|
||||
"message_text": message_text,
|
||||
"operator_user_id": operator_user_id,
|
||||
}
|
||||
return {"status": "succeeded", "task_id": 77}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_typed_executor_dispatches_task_create() -> None:
|
||||
executor = _DummyTypedExecutor()
|
||||
intent = CanonicalIntent(
|
||||
name=INTENT_TASK_CREATE,
|
||||
confidence=0.88,
|
||||
project_id=6,
|
||||
signal_title="补充埋点",
|
||||
signal_summary="补充行为埋点以支持漏斗分析",
|
||||
metadata={"task_title": "补充埋点"},
|
||||
)
|
||||
|
||||
result = await executor.execute(
|
||||
intent=intent,
|
||||
message_text="请创建任务:补充埋点",
|
||||
sender="alice",
|
||||
operator_user_id=101,
|
||||
source_system="feishu",
|
||||
source_message_id="msg-1",
|
||||
)
|
||||
|
||||
assert result["status"] == "succeeded"
|
||||
assert result["task_id"] == 77
|
||||
assert executor.called["intent"] == INTENT_TASK_CREATE
|
||||
assert executor.called["operator_user_id"] == 101
|
||||
|
|
@ -0,0 +1,69 @@
|
|||
import pytest
|
||||
|
||||
from app.services.ai_service_core import AIService
|
||||
from app.services.llm_service import llm_service
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_invoke_with_retry_uses_llm_service(monkeypatch) -> None:
|
||||
service = AIService()
|
||||
captured: dict[str, object] = {}
|
||||
|
||||
async def fake_generate(prompt: str, **kwargs):
|
||||
captured["prompt"] = prompt
|
||||
captured["kwargs"] = kwargs
|
||||
return "ok-from-llm-service"
|
||||
|
||||
monkeypatch.setattr(llm_service, "generate", fake_generate)
|
||||
result = await service._invoke_with_retry(
|
||||
"hello",
|
||||
tenant_id="2",
|
||||
function_name="unit_test_invoke",
|
||||
use_cache=False,
|
||||
)
|
||||
|
||||
assert result == "ok-from-llm-service"
|
||||
assert captured["prompt"] == "hello"
|
||||
kwargs = captured["kwargs"]
|
||||
assert isinstance(kwargs, dict)
|
||||
assert kwargs.get("tenant_id") == 2
|
||||
assert kwargs.get("function_name") == "unit_test_invoke"
|
||||
assert kwargs.get("use_prompt_profile") is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_invoke_with_retry_returns_cached_value_without_llm(monkeypatch) -> None:
|
||||
service = AIService()
|
||||
|
||||
async def fake_cache_get(key: str):
|
||||
_ = key
|
||||
return "cached-hit"
|
||||
|
||||
async def fake_generate(prompt: str, **kwargs):
|
||||
raise AssertionError("llm_service.generate should not be called when cache hit")
|
||||
|
||||
monkeypatch.setattr(service, "_cache_get", fake_cache_get)
|
||||
monkeypatch.setattr(llm_service, "generate", fake_generate)
|
||||
result = await service._invoke_with_retry(
|
||||
"cacheable",
|
||||
function_name="unit_test_cache",
|
||||
use_cache=True,
|
||||
)
|
||||
assert result == "cached-hit"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_stream_chat_yields_single_chunk_from_invoke(monkeypatch) -> None:
|
||||
service = AIService()
|
||||
|
||||
async def fake_invoke(prompt: str, **kwargs):
|
||||
_ = (prompt, kwargs)
|
||||
return "stream-result"
|
||||
|
||||
monkeypatch.setattr(service, "_invoke_with_retry", fake_invoke)
|
||||
|
||||
chunks = []
|
||||
async for chunk in service.stream_chat("hi"):
|
||||
chunks.append(chunk)
|
||||
|
||||
assert chunks == ["stream-result"]
|
||||
|
|
@ -0,0 +1,23 @@
|
|||
from app.services.context_cache_service.mixin_profile_basics import ContextCacheProfileBasicsMixin
|
||||
from app.services.context_cache_service.mixin_profile_route_parser import ContextCacheProfileRouteParserMixin
|
||||
|
||||
|
||||
def _find_route(items: list[dict], route: str) -> dict:
|
||||
for item in items:
|
||||
if str(item.get("route") or "") == route:
|
||||
return item
|
||||
raise AssertionError(f"route not found in catalog: {route}")
|
||||
|
||||
|
||||
def test_default_feature_catalog_uses_canonical_rag_ask() -> None:
|
||||
items = ContextCacheProfileBasicsMixin._default_feature_catalog_items()
|
||||
ai_steward_item = _find_route(items, "/ai-steward")
|
||||
apis = list(ai_steward_item.get("apis") or [])
|
||||
assert "/rag/ask" in apis
|
||||
assert "/ai-steward/rag/ask" not in apis
|
||||
|
||||
|
||||
def test_route_feature_summary_uses_canonical_rag_ask() -> None:
|
||||
_summary, apis, _aliases = ContextCacheProfileRouteParserMixin._route_feature_summary("/ai-steward")
|
||||
assert "/rag/ask" in apis
|
||||
assert "/ai-steward/rag/ask" not in apis
|
||||
|
|
@ -0,0 +1,90 @@
|
|||
from app.services.ai_pmo.draft_confirm_handler import DraftConfirmHandler
|
||||
from app.services.ai_pmo.ai_pmo_kernel_ingress_flow import mixin_auto
|
||||
from app.services.adaptive_rag_service.mixin_deep_guard_answer import AdaptiveRAGDeepGuardAnswerMixin
|
||||
|
||||
|
||||
def test_parse_action_confirm() -> None:
|
||||
action, note = DraftConfirmHandler.parse_action_from_text(message_text="确认")
|
||||
assert action == "confirm"
|
||||
assert note == ""
|
||||
|
||||
|
||||
def test_parse_action_revise_with_note() -> None:
|
||||
action, note = DraftConfirmHandler.parse_action_from_text(message_text="修改 改成每周跟进")
|
||||
assert action == "revise"
|
||||
assert note == "改成每周跟进"
|
||||
|
||||
|
||||
def test_parse_action_cancel() -> None:
|
||||
action, note = DraftConfirmHandler.parse_action_from_text(message_text="取消")
|
||||
assert action == "cancel"
|
||||
assert note == ""
|
||||
|
||||
|
||||
def test_parse_action_yes_as_confirm() -> None:
|
||||
action, note = DraftConfirmHandler.parse_action_from_text(message_text="yes")
|
||||
assert action == "confirm"
|
||||
assert note == ""
|
||||
|
||||
|
||||
def test_kernel_ingress_mixin_auto_safe_int_regression() -> None:
|
||||
assert callable(mixin_auto._safe_int)
|
||||
assert mixin_auto._safe_int("123") == 123
|
||||
assert mixin_auto._safe_int("") is None
|
||||
assert mixin_auto._safe_int(None) is None
|
||||
|
||||
|
||||
def test_adaptive_rag_process_entry_and_mixed_format() -> None:
|
||||
mixin = AdaptiveRAGDeepGuardAnswerMixin()
|
||||
assert mixin._is_ai_pmo_process_entry("pmflow_manual") is True
|
||||
assert mixin._is_ai_pmo_process_entry("feishu") is False
|
||||
|
||||
mixed = mixin._format_mixed_two_block_output(
|
||||
answer_text="[回答] 接口契约已对齐\n[已收到需求] 新增任务创建确认流",
|
||||
question_part="现在进展怎么样",
|
||||
signal_part="补齐任务确认",
|
||||
)
|
||||
assert mixed.startswith("[回答] ")
|
||||
assert "\n[已收到需求] " in mixed
|
||||
assert mixed.endswith(",已入库处理")
|
||||
|
||||
|
||||
def test_adaptive_rag_select_scene_prompt_prefers_mixed() -> None:
|
||||
mixin = AdaptiveRAGDeepGuardAnswerMixin()
|
||||
scene_key, prompt = mixin._select_scene_prompt(
|
||||
question="现在进展怎么样?",
|
||||
project_id=7,
|
||||
asked_via="pmflow_manual",
|
||||
message_intent="mixed",
|
||||
mixed_signal_part="新增任务确认流",
|
||||
conversation_text="无",
|
||||
context_blocks_text="无",
|
||||
system_snapshot={},
|
||||
user_snapshot={},
|
||||
system_snapshot_text="{}",
|
||||
user_snapshot_text="{}",
|
||||
relevance_max=0.0,
|
||||
)
|
||||
assert scene_key == "mixed"
|
||||
assert "现在进展怎么样" in prompt
|
||||
assert "新增任务确认流" in prompt
|
||||
|
||||
|
||||
def test_adaptive_rag_select_scene_prompt_ai_pmo_manual_entry() -> None:
|
||||
mixin = AdaptiveRAGDeepGuardAnswerMixin()
|
||||
scene_key, prompt = mixin._select_scene_prompt(
|
||||
question="帮我总结这个项目",
|
||||
project_id=9,
|
||||
asked_via="pmflow_manual",
|
||||
message_intent=None,
|
||||
mixed_signal_part=None,
|
||||
conversation_text="无",
|
||||
context_blocks_text="无",
|
||||
system_snapshot={},
|
||||
user_snapshot={},
|
||||
system_snapshot_text="{}",
|
||||
user_snapshot_text="{}",
|
||||
relevance_max=0.0,
|
||||
)
|
||||
assert scene_key == "ai_pmo"
|
||||
assert "帮我总结这个项目" in prompt
|
||||
|
|
@ -0,0 +1,156 @@
|
|||
import pytest
|
||||
|
||||
from app.services.integrations.feishu_integration_service.mixin_card_action import FeishuCardActionMixin
|
||||
from app.services.integrations.feishu_integration_service.service import FeishuIntegrationService
|
||||
|
||||
|
||||
def test_extract_card_action_payload_parses_dict_value() -> None:
|
||||
payload = FeishuCardActionMixin._extract_card_action_payload(
|
||||
{
|
||||
"open_message_id": "om_123",
|
||||
"open_chat_id": "oc_123",
|
||||
"operator": {"open_id": "ou_abc", "name": "测试用户"},
|
||||
"action": {
|
||||
"value": {
|
||||
"action": "confirm_draft",
|
||||
"work_item_id": 88,
|
||||
"draft_version": 3,
|
||||
"revision_note": "保持现有范围",
|
||||
}
|
||||
},
|
||||
}
|
||||
)
|
||||
assert payload["action"] == "confirm"
|
||||
assert payload["work_item_id"] == 88
|
||||
assert payload["draft_version"] == 3
|
||||
assert payload["source_message_id"] == "om_123"
|
||||
assert payload["chat_id"] == "oc_123"
|
||||
assert payload["sender_open_id"] == "ou_abc"
|
||||
|
||||
|
||||
def test_extract_card_action_payload_parses_json_string_value_and_aliases() -> None:
|
||||
payload = FeishuCardActionMixin._extract_card_action_payload(
|
||||
{
|
||||
"message_id": "msg_1",
|
||||
"chat_id": "chat_9",
|
||||
"operator": {"user_id": "ou_user", "display_name": "张三"},
|
||||
"action": {"value": '{"draft_action":"修改","workItemId":"100","version":"4","note":"改成下周上线"}'},
|
||||
}
|
||||
)
|
||||
assert payload["action"] == "revise"
|
||||
assert payload["work_item_id"] == 100
|
||||
assert payload["draft_version"] == 4
|
||||
assert payload["revision_note"] == "改成下周上线"
|
||||
assert payload["source_message_id"] == "msg_1"
|
||||
assert payload["chat_id"] == "chat_9"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_handle_message_event_dispatches_card_action(monkeypatch) -> None:
|
||||
service = FeishuIntegrationService(db=object())
|
||||
called: dict[str, object] = {}
|
||||
|
||||
async def _fake_card_handler(event):
|
||||
called["event_type"] = event.get("type")
|
||||
return {"code": 0, "msg": "success", "data": {"handled": True}}
|
||||
|
||||
monkeypatch.setattr(service, "_handle_card_action_trigger", _fake_card_handler)
|
||||
|
||||
result = await service.handle_message_event({"type": "card.action.trigger", "event": {}})
|
||||
assert result["code"] == 0
|
||||
assert called["event_type"] == "card.action.trigger"
|
||||
|
||||
|
||||
def test_build_action_card_for_draft_pending_confirm() -> None:
|
||||
card = FeishuCardActionMixin._build_action_card_from_capture_result(
|
||||
capture_result={
|
||||
"reply_template_key": "draft_pending_confirm",
|
||||
"work_item_id": 321,
|
||||
"draft_version": 5,
|
||||
"direct_reply_text": "请确认草稿内容。",
|
||||
},
|
||||
mention_open_id="ou_xxx",
|
||||
mention_name="测试同学",
|
||||
)
|
||||
assert isinstance(card, dict)
|
||||
assert card["header"]["title"]["content"] == "AI PMO 草稿待确认"
|
||||
action_row = next((item for item in card["elements"] if item.get("tag") == "action"), {})
|
||||
actions = action_row.get("actions") if isinstance(action_row.get("actions"), list) else []
|
||||
action_names = {((btn.get("value") or {}).get("action")) for btn in actions if isinstance(btn, dict)}
|
||||
assert {"confirm", "revise", "cancel"} <= action_names
|
||||
|
||||
|
||||
def test_build_action_card_for_pending_manual_continue() -> None:
|
||||
card = FeishuCardActionMixin._build_action_card_from_capture_result(
|
||||
capture_result={
|
||||
"reply_template_key": "pending_manual",
|
||||
"results": [{"work_item_id": 99}],
|
||||
"direct_reply_text": "需要继续执行。",
|
||||
},
|
||||
mention_open_id=None,
|
||||
mention_name=None,
|
||||
)
|
||||
assert isinstance(card, dict)
|
||||
assert card["header"]["title"]["content"] == "AI PMO 待继续执行"
|
||||
action_row = next((item for item in card["elements"] if item.get("tag") == "action"), {})
|
||||
actions = action_row.get("actions") if isinstance(action_row.get("actions"), list) else []
|
||||
assert len(actions) == 1
|
||||
assert (actions[0].get("value") or {}).get("action") == "continue"
|
||||
assert (actions[0].get("value") or {}).get("work_item_id") == 99
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_handle_card_action_trigger_continue_dispatches_resume(monkeypatch) -> None:
|
||||
called: dict[str, object] = {}
|
||||
|
||||
class DummyDB:
|
||||
async def commit(self) -> None:
|
||||
called["committed"] = True
|
||||
|
||||
async def rollback(self) -> None:
|
||||
called["rolled_back"] = True
|
||||
|
||||
service = FeishuIntegrationService(db=DummyDB())
|
||||
service.tenant_id = 7
|
||||
|
||||
async def _fake_reply_text(**kwargs):
|
||||
called["reply_text"] = kwargs.get("text")
|
||||
called["reply_chat"] = kwargs.get("chat_id")
|
||||
|
||||
monkeypatch.setattr(service, "_safe_reply_text", _fake_reply_text)
|
||||
|
||||
class _FakeKernel:
|
||||
def __init__(self, db, tenant_id):
|
||||
called["kernel_tenant_id"] = tenant_id
|
||||
|
||||
async def process_ingress(self, **kwargs):
|
||||
called["ingress_kwargs"] = kwargs
|
||||
return {
|
||||
"handled": True,
|
||||
"status": "captured",
|
||||
"direct_reply_text": "已继续执行。",
|
||||
}
|
||||
|
||||
monkeypatch.setattr(
|
||||
"app.services.ai_pmo.ai_pmo_kernel.ai_pmo_kernel_runtime.AIPMOKernelService",
|
||||
_FakeKernel,
|
||||
)
|
||||
|
||||
result = await service._handle_card_action_trigger(
|
||||
{
|
||||
"open_message_id": "om_continue_1",
|
||||
"open_chat_id": "oc_continue_1",
|
||||
"operator": {"open_id": "ou_continue", "name": "继续用户"},
|
||||
"action": {"value": {"action": "continue", "work_item_id": 55}},
|
||||
}
|
||||
)
|
||||
|
||||
assert result["code"] == 0
|
||||
data = result.get("data") if isinstance(result.get("data"), dict) else {}
|
||||
assert data.get("handled") is True
|
||||
assert data.get("target_work_item_id") == 55
|
||||
ingress_kwargs = called["ingress_kwargs"]
|
||||
assert ingress_kwargs["message_text"] == "继续处理 work_item #55"
|
||||
assert ingress_kwargs["source_system"] == "ai_pmo_ui"
|
||||
assert called["reply_text"] == "已继续执行。"
|
||||
assert called["reply_chat"] == "oc_continue_1"
|
||||
|
|
@ -0,0 +1,118 @@
|
|||
import pytest
|
||||
|
||||
from app.services.integrations.feishu_integration_service.service import FeishuIntegrationService
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_handle_message_receive_ignores_bot_sender() -> None:
|
||||
service = FeishuIntegrationService(db=object())
|
||||
result = await service._handle_message_receive(
|
||||
{
|
||||
"message": {"message_id": "m-bot"},
|
||||
"sender": {"sender_type": "bot"},
|
||||
"chat": {"chat_id": "c1"},
|
||||
}
|
||||
)
|
||||
assert result["code"] == 0
|
||||
assert result["msg"] == "Ignore bot/app message"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_handle_message_receive_routes_empty_text_branch(monkeypatch) -> None:
|
||||
service = FeishuIntegrationService(db=object())
|
||||
called: dict[str, object] = {}
|
||||
|
||||
def _fake_build_receive_context(*, event, state_trace_id): # noqa: ARG001
|
||||
return {
|
||||
"ingress_source": "feishu",
|
||||
"message": {"message_id": "m-empty", "message_type": "post"},
|
||||
"sender": {"sender_name": "测试用户"},
|
||||
"message_id": "m-empty",
|
||||
"sender_type": "user",
|
||||
"raw_message_content": "",
|
||||
"message_content": "",
|
||||
"chat_id": "chat-empty",
|
||||
"mentioned_bot_names": ["PMFlow"],
|
||||
"sender_open_id": "ou-empty",
|
||||
"perf_t0": None,
|
||||
}
|
||||
|
||||
async def _fake_record_ingress_trace(**kwargs):
|
||||
called["trace"] = kwargs
|
||||
|
||||
async def _fake_reply_for_empty_text_ingress(**kwargs):
|
||||
called["empty_reply"] = kwargs
|
||||
return True
|
||||
|
||||
monkeypatch.setattr(service, "_build_receive_context", _fake_build_receive_context)
|
||||
monkeypatch.setattr(service, "_record_ingress_trace", _fake_record_ingress_trace)
|
||||
monkeypatch.setattr(service, "_reply_for_empty_text_ingress", _fake_reply_for_empty_text_ingress)
|
||||
|
||||
result = await service._handle_message_receive({"type": "im.message.receive_v1"})
|
||||
assert result["code"] == 0
|
||||
assert result["msg"] == "No text content"
|
||||
assert called["trace"]["message_id"] == "m-empty"
|
||||
assert called["empty_reply"]["chat_id"] == "chat-empty"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_handle_message_receive_orchestrates_capture_and_finalize(monkeypatch) -> None:
|
||||
service = FeishuIntegrationService(db=object())
|
||||
called: dict[str, object] = {}
|
||||
|
||||
def _fake_build_receive_context(*, event, state_trace_id): # noqa: ARG001
|
||||
return {
|
||||
"ingress_source": "long_connection",
|
||||
"message": {"message_id": "m-1", "message_type": "text"},
|
||||
"sender": {"sender_name": "原始用户名"},
|
||||
"sender_id": {"open_id": "ou-1"},
|
||||
"message_id": "m-1",
|
||||
"sender_type": "user",
|
||||
"raw_message_content": "hello",
|
||||
"message_content": "hello",
|
||||
"chat_id": "chat-1",
|
||||
"chat_type": "group",
|
||||
"captured_at": None,
|
||||
"mentioned_bot_names": [],
|
||||
"sender_open_id": "ou-1",
|
||||
"perf_t0": 1.2,
|
||||
"message_attachments": [],
|
||||
"occurred_at_iso": None,
|
||||
}
|
||||
|
||||
async def _fake_record_ingress_trace(**kwargs):
|
||||
called["trace"] = kwargs
|
||||
|
||||
async def _fake_resolve_sender_display_name(*, sender, sender_id): # noqa: ARG001
|
||||
return "解析后用户名"
|
||||
|
||||
def _fake_normalize_message_attachments_from_context(*, context): # noqa: ARG001
|
||||
return []
|
||||
|
||||
async def _fake_capture_and_persist_from_ingress(**kwargs):
|
||||
called["capture"] = kwargs
|
||||
return {
|
||||
"status": "captured",
|
||||
"classification": "signal",
|
||||
"linked_project_ids": [11],
|
||||
"auto_tasks": [],
|
||||
"ai_trace_id": "trace-1",
|
||||
}
|
||||
|
||||
async def _fake_finalize_receive_result(**kwargs):
|
||||
called["finalize"] = kwargs
|
||||
return {"code": 0, "msg": "success", "data": {"scene": "execute"}}
|
||||
|
||||
monkeypatch.setattr(service, "_build_receive_context", _fake_build_receive_context)
|
||||
monkeypatch.setattr(service, "_record_ingress_trace", _fake_record_ingress_trace)
|
||||
monkeypatch.setattr(service, "_resolve_sender_display_name", _fake_resolve_sender_display_name)
|
||||
monkeypatch.setattr(service, "_normalize_message_attachments_from_context", _fake_normalize_message_attachments_from_context)
|
||||
monkeypatch.setattr(service, "_capture_and_persist_from_ingress", _fake_capture_and_persist_from_ingress)
|
||||
monkeypatch.setattr(service, "_finalize_receive_result", _fake_finalize_receive_result)
|
||||
|
||||
result = await service._handle_message_receive({"type": "im.message.receive_v1"})
|
||||
assert result["code"] == 0
|
||||
assert result["data"]["scene"] == "execute"
|
||||
assert called["capture"]["chat_id"] == "chat-1"
|
||||
assert called["capture"]["sender_name"] == "解析后用户名"
|
||||
assert called["finalize"]["chat_type"] == "group"
|
||||
|
|
@ -0,0 +1,204 @@
|
|||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
from pydantic import ValidationError
|
||||
|
||||
from app.api import rag_ask_contract
|
||||
from app.api.ai_steward_api import router as ai_steward_router
|
||||
from app.api.rag_api import routes_query
|
||||
from app.schemas.rag import RAGAskRequest
|
||||
|
||||
|
||||
def test_rag_ask_request_normalizes_contract_fields() -> None:
|
||||
req = RAGAskRequest(
|
||||
question="状态怎么样",
|
||||
asked_via="ai-pmo-process-text",
|
||||
message_intent="MIXED",
|
||||
mixed_signal_part="新增任务确认流",
|
||||
action_mode="read",
|
||||
)
|
||||
assert req.asked_via == "/ai-pmo/process-text"
|
||||
assert req.message_intent == "mixed"
|
||||
assert req.mixed_signal_part == "新增任务确认流"
|
||||
assert req.action_mode == "read"
|
||||
|
||||
|
||||
def test_rag_ask_request_rejects_unknown_asked_via() -> None:
|
||||
with pytest.raises(ValidationError):
|
||||
RAGAskRequest(
|
||||
question="hello",
|
||||
asked_via="email",
|
||||
)
|
||||
|
||||
|
||||
def test_build_answer_question_kwargs_maps_fields() -> None:
|
||||
request = RAGAskRequest(
|
||||
question="请确认任务",
|
||||
asked_via="pmflow_manual",
|
||||
session_id=33,
|
||||
new_session=1,
|
||||
thinking_mode="deep",
|
||||
action_mode="read_write",
|
||||
sync_requirement=0,
|
||||
message_intent="mixed",
|
||||
mixed_signal_part="新增任务确认流",
|
||||
)
|
||||
kwargs = rag_ask_contract.build_answer_question_kwargs(
|
||||
request=request,
|
||||
current_user=SimpleNamespace(id="77"),
|
||||
)
|
||||
|
||||
assert kwargs == {
|
||||
"question": "请确认任务",
|
||||
"asked_by": 77,
|
||||
"asked_via": "pmflow_manual",
|
||||
"project_id": None,
|
||||
"source_chat_id": None,
|
||||
"session_id": 33,
|
||||
"force_new_session": True,
|
||||
"thinking_mode": "deep",
|
||||
"action_mode": "read_write",
|
||||
"sync_requirement": False,
|
||||
"message_intent": "mixed",
|
||||
"mixed_signal_part": "新增任务确认流",
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_rag_ask_route_forwards_contract_fields(monkeypatch) -> None:
|
||||
captured: dict[str, object] = {}
|
||||
|
||||
class FakeKernel:
|
||||
def __init__(self, db, tenant_id: int):
|
||||
_ = db
|
||||
captured["tenant_id"] = tenant_id
|
||||
|
||||
async def process_ingress(self, **kwargs):
|
||||
captured["ingress_kwargs"] = kwargs
|
||||
return {
|
||||
"handled": True,
|
||||
"status": "answered",
|
||||
"intent": "query.read_only",
|
||||
"direct_reply_required": True,
|
||||
"direct_reply_text": "ok",
|
||||
"rag_result": {"answer": "ok", "session_id": 12, "processing_stage": "qa"},
|
||||
}
|
||||
|
||||
monkeypatch.setattr(routes_query, "AIPMOKernelService", FakeKernel)
|
||||
|
||||
request = RAGAskRequest(
|
||||
question="现在进展怎么样",
|
||||
asked_via="pmflow_manual",
|
||||
action_mode="read",
|
||||
message_intent="mixed",
|
||||
mixed_signal_part="新增任务确认流",
|
||||
)
|
||||
current_user = SimpleNamespace(id=101, tenant_id=9)
|
||||
response = await routes_query.rag_ask(
|
||||
request=request,
|
||||
current_user=current_user,
|
||||
db=object(),
|
||||
tenant_id=9,
|
||||
)
|
||||
|
||||
assert response["code"] == 0
|
||||
data = response["data"]
|
||||
assert data["answer"] == "ok"
|
||||
assert data["session_id"] == 12
|
||||
ingress_kwargs = captured["ingress_kwargs"]
|
||||
assert isinstance(ingress_kwargs, dict)
|
||||
assert ingress_kwargs["source_system"] == "pmflow_manual"
|
||||
assert ingress_kwargs["message_text"] == "现在进展怎么样"
|
||||
rag_request_context = ingress_kwargs["ingress_options"]["rag_request_context"]
|
||||
assert rag_request_context["asked_via"] == "pmflow_manual"
|
||||
assert rag_request_context["action_mode"] == "read"
|
||||
assert rag_request_context["message_intent"] == "mixed"
|
||||
assert rag_request_context["mixed_signal_part"] == "新增任务确认流"
|
||||
|
||||
|
||||
def test_ai_steward_router_does_not_expose_legacy_rag_ask_route() -> None:
|
||||
route_paths = {str(getattr(route, "path", "")).strip() for route in ai_steward_router.routes}
|
||||
assert "/ai-steward/rag/ask" not in route_paths
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_rag_ask_route_uses_shared_contract_builder(monkeypatch) -> None:
|
||||
captured_kwargs: list[dict[str, object]] = []
|
||||
captured_tenants: list[int] = []
|
||||
|
||||
def fake_build_answer_kwargs(request: RAGAskRequest, current_user) -> dict[str, object]:
|
||||
return {
|
||||
"question": request.question,
|
||||
"asked_by": int(current_user.id),
|
||||
"asked_via": "shared-builder",
|
||||
}
|
||||
|
||||
monkeypatch.setattr(rag_ask_contract, "build_answer_question_kwargs", fake_build_answer_kwargs)
|
||||
monkeypatch.setattr(rag_ask_contract, "resolve_effective_tenant_id", lambda *_args, **_kwargs: 88)
|
||||
|
||||
class FakeKernel:
|
||||
def __init__(self, db, tenant_id: int):
|
||||
_ = db
|
||||
captured_tenants.append(int(tenant_id))
|
||||
|
||||
async def process_ingress(self, **kwargs):
|
||||
captured_kwargs.append(dict(kwargs))
|
||||
return {"handled": True, "status": "answered", "direct_reply_text": "ok", "rag_result": {"answer": "ok"}}
|
||||
|
||||
monkeypatch.setattr(routes_query, "AIPMOKernelService", FakeKernel)
|
||||
|
||||
response_rag = await routes_query.rag_ask(
|
||||
request=RAGAskRequest(question="q1"),
|
||||
current_user=SimpleNamespace(id=1, tenant_id=1),
|
||||
db=object(),
|
||||
tenant_id=1,
|
||||
)
|
||||
|
||||
assert response_rag["code"] == 0
|
||||
assert captured_tenants == [88]
|
||||
assert captured_kwargs == [
|
||||
{
|
||||
"source_system": "pmflow",
|
||||
"source_message_id": None,
|
||||
"message_text": "q1",
|
||||
"sender": "PMFlow用户",
|
||||
"channel_ref": None,
|
||||
"source_sender_open_id": None,
|
||||
"captured_at": None,
|
||||
"chat_type": "manual",
|
||||
"project_hint_id": None,
|
||||
"ingress_options": {
|
||||
"rag_request_context": {
|
||||
"question": "q1",
|
||||
"asked_by": 1,
|
||||
"asked_via": "shared-builder",
|
||||
}
|
||||
},
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
def test_build_rag_response_payload_preserves_rag_fields() -> None:
|
||||
payload = rag_ask_contract.build_rag_response_payload(
|
||||
ingress_result={
|
||||
"status": "answered",
|
||||
"intent": "query.read_only",
|
||||
"work_item_id": 321,
|
||||
"ai_trace_id": "ai-pmo-321",
|
||||
"direct_reply_required": True,
|
||||
"direct_reply_text": "fallback",
|
||||
"rag_result": {
|
||||
"answer": "final answer",
|
||||
"session_id": 18,
|
||||
"sources": [{"source": "snapshot"}],
|
||||
"processing_stage": "qa",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
assert payload["answer"] == "final answer"
|
||||
assert payload["session_id"] == 18
|
||||
assert payload["work_item_id"] == 321
|
||||
assert payload["trace_id"] == "ai-pmo-321"
|
||||
assert payload["reply_required"] is True
|
||||
assert payload["reply_text"] == "fallback"
|
||||
|
|
@ -0,0 +1,28 @@
|
|||
rows=3
|
||||
====================================================================================================
|
||||
#1
|
||||
id=1902
|
||||
function_name=project_draft_charter_glm47_fallback
|
||||
status=success
|
||||
created_at=2026-03-19 18:13:49.041521
|
||||
response_start
|
||||
|
||||
response_end
|
||||
====================================================================================================
|
||||
#2
|
||||
id=1900
|
||||
function_name=project_draft_charter_glm47_fallback
|
||||
status=success
|
||||
created_at=2026-03-19 18:12:49.020651
|
||||
response_start
|
||||
|
||||
response_end
|
||||
====================================================================================================
|
||||
#3
|
||||
id=1899
|
||||
function_name=project_draft_signal_glm47_fallback
|
||||
status=success
|
||||
created_at=2026-03-19 18:12:19.010876
|
||||
response_start
|
||||
{"content":"竞品多侧重基础连通性测试,缺乏自动化验收流程。差异化机会在于集成CI/CD与全场景覆盖。需警惕硬件兼容性及协议复杂度带来的技术风险,建议优先构建自动化测试框架以提升效率。","impact_score":0.9,"priority":"high","strategic_analysis":{"is_strategic":true,"threat_level":"MEDIUM"}}
|
||||
response_end
|
||||
|
|
@ -0,0 +1,130 @@
|
|||
{
|
||||
"source_message_id": "draft-fix-verify-1774243078",
|
||||
"capture_response": {
|
||||
"status": "captured",
|
||||
"classification": "command",
|
||||
"linked_project_ids": [],
|
||||
"signal_id": null,
|
||||
"created_project_id": null,
|
||||
"auto_tasks": [],
|
||||
"ai_trace_id": "feishu:draft-fix-verify-1774243078",
|
||||
"results": [
|
||||
{
|
||||
"route_status": "queued_for_decision",
|
||||
"summary": "message_queued_async"
|
||||
}
|
||||
],
|
||||
"direct_reply_required": true,
|
||||
"direct_reply_text": "已收到,正在处理。",
|
||||
"reply_template_key": "async_processing_ack",
|
||||
"work_item_id": null,
|
||||
"decision_level": null,
|
||||
"review_summary": null,
|
||||
"reply_outbox_enqueued": true,
|
||||
"reply_outbox_id": 283
|
||||
},
|
||||
"ok": true,
|
||||
"run": {
|
||||
"id": 112,
|
||||
"status": "succeeded",
|
||||
"last_error": null,
|
||||
"retry_count": 0,
|
||||
"max_retries": 3,
|
||||
"work_item_id": 405,
|
||||
"updated_at": "2026-03-23 13:18:54.271908"
|
||||
},
|
||||
"work_item": {
|
||||
"id": 405,
|
||||
"state": "draft_pending_confirm",
|
||||
"draft_version": 1,
|
||||
"signal_id": null,
|
||||
"confirm_message_id": null,
|
||||
"draft_content": {
|
||||
"title": "给AI劝退助手加个需",
|
||||
"summary": "给AI劝退助手加个需求 劝退完后第二天问一下情况",
|
||||
"version": 1,
|
||||
"feasibility": "待讨论",
|
||||
"followup_plan": [
|
||||
{
|
||||
"when": "明天",
|
||||
"action": "确认触发时机与验收口径"
|
||||
},
|
||||
{
|
||||
"when": "一周后",
|
||||
"action": "复核执行效果并决定是否扩展"
|
||||
}
|
||||
],
|
||||
"original_text": "给AI劝退助手加个需求 劝退完后第二天问一下情况",
|
||||
"feasibility_reason": "信息仍需补充,建议先确认边界。",
|
||||
"recommended_assignee_id": 1,
|
||||
"recommended_assignee_name": "系统管理员",
|
||||
"recommended_assignee_reason": "基于当前负载与任务匹配度给出的建议。",
|
||||
"recommended_assignee_workload": 100.0
|
||||
},
|
||||
"execution_result": {
|
||||
"draft": {
|
||||
"title": "给AI劝退助手加个需",
|
||||
"summary": "给AI劝退助手加个需求 劝退完后第二天问一下情况",
|
||||
"version": 1,
|
||||
"feasibility": "待讨论",
|
||||
"followup_plan": [
|
||||
{
|
||||
"when": "明天",
|
||||
"action": "确认触发时机与验收口径"
|
||||
},
|
||||
{
|
||||
"when": "一周后",
|
||||
"action": "复核执行效果并决定是否扩展"
|
||||
}
|
||||
],
|
||||
"original_text": "给AI劝退助手加个需求 劝退完后第二天问一下情况",
|
||||
"feasibility_reason": "信息仍需补充,建议先确认边界。",
|
||||
"recommended_assignee_id": 1,
|
||||
"recommended_assignee_name": "系统管理员",
|
||||
"recommended_assignee_reason": "基于当前负载与任务匹配度给出的建议。",
|
||||
"recommended_assignee_workload": 100.0
|
||||
},
|
||||
"status": "draft_pending_confirm",
|
||||
"draft_version": 1,
|
||||
"revision_note": null,
|
||||
"decision_trace": {
|
||||
"intent": "signal.create",
|
||||
"gate_code": "auto_execute_allowed",
|
||||
"confidence": 0.95,
|
||||
"route_tier": "low",
|
||||
"gate_action": "auto_execute",
|
||||
"route_function": "classify_intent_ai_pmo_kernel",
|
||||
"correction_action": "none",
|
||||
"complexity_reasons": [
|
||||
"llm_only_stage1"
|
||||
],
|
||||
"correction_keywords": []
|
||||
},
|
||||
"workflow_route": "project_signal.create",
|
||||
"workflow_action": "create",
|
||||
"draft_expires_at": "2026-03-24T13:18:54.197287"
|
||||
},
|
||||
"updated_at": "2026-03-23 13:17:58.698395"
|
||||
},
|
||||
"outbox": [
|
||||
{
|
||||
"id": 283,
|
||||
"status": "sent",
|
||||
"message_text": "已收到,正在处理。",
|
||||
"created_at": "2026-03-23 13:17:58.143284",
|
||||
"sent_at": "2026-03-23 13:17:59.389384",
|
||||
"attempts": 1,
|
||||
"last_error": null
|
||||
},
|
||||
{
|
||||
"id": 284,
|
||||
"status": "sent",
|
||||
"message_text": "📋 已为你起草需求,请确认:\n\n📌 给AI劝退助手加个需\n给AI劝退助手加个需求 劝退完后第二天问一下情况\n\n✅ 可行性:待讨论\n信息仍需补充,建议先确认边界。\n\n👤 推荐负责人:系统管理员 · 当前负载100%\n基于当前负载与任务匹配度给出的建议。\n\n📅 跟进计划:\n· 明天:确认触发时机与验收口径\n· 一周后:复核执行效果并决定是否扩展\n\n---\n回复 确认 → 入库\n回复 修改 [具体修改意见] → 重新起草\n回复 取消 → 放弃\n⏰ 24小时内未确认自动取消",
|
||||
"created_at": "2026-03-23 13:18:54.271908",
|
||||
"sent_at": "2026-03-23 13:18:56.583867",
|
||||
"attempts": 1,
|
||||
"last_error": null
|
||||
}
|
||||
],
|
||||
"draft_text": "📋 已为你起草需求,请确认:\n\n📌 给AI劝退助手加个需\n给AI劝退助手加个需求 劝退完后第二天问一下情况\n\n✅ 可行性:待讨论\n信息仍需补充,建议先确认边界。\n\n👤 推荐负责人:系统管理员 · 当前负载100%\n基于当前负载与任务匹配度给出的建议。\n\n📅 跟进计划:\n· 明天:确认触发时机与验收口径\n· 一周后:复核执行效果并决定是否扩展\n\n---\n回复 确认 → 入库\n回复 修改 [具体修改意见] → 重新起草\n回复 取消 → 放弃\n⏰ 24小时内未确认自动取消"
|
||||
}
|
||||
|
|
@ -0,0 +1,10 @@
|
|||
source_message_id=draft-opt-verify-1774243376
|
||||
run_status=succeeded
|
||||
work_item_state=succeeded
|
||||
title=
|
||||
feasibility=
|
||||
recommended_assignee_workload=0.0
|
||||
recommended_assignee_reason=
|
||||
semantic_title_ok=False
|
||||
feasibility_is_合理=False
|
||||
assignee_under80_or_risk_note=True
|
||||
|
|
@ -0,0 +1 @@
|
|||
data:text/html;charset=utf-8,%3C%21doctype%20html%3E%3Chtml%3E%3Chead%3E%3Cmeta%20charset%3D%27utf-8%27%3E%3Ctitle%3E%E4%BC%98%E5%8C%96%E5%90%8E%E6%95%B0%E6%8D%AE%E5%BA%93%E6%A0%A1%E9%AA%8C%3C%2Ftitle%3E%3Cstyle%3Ebody%7Bfont-family%3AMenlo%2CConsolas%2Cmonospace%3Bbackground%3A%23f6f8fa%3Bpadding%3A24px%3Bcolor%3A%23111%7Dh1%7Bfont-size%3A20px%7Dpre%7Bwhite-space%3Apre-wrap%3Bbackground%3A%23fff%3Bpadding%3A16px%3Bborder-radius%3A8px%3Bborder%3A1px%20solid%20%23ddd%3Bline-height%3A1.5%7D%3C%2Fstyle%3E%3C%2Fhead%3E%3Cbody%3E%3Ch1%3E%E4%BC%98%E5%8C%96%E5%90%8E%E6%95%B0%E6%8D%AE%E5%BA%93%E6%A0%A1%E9%AA%8C%3C%2Fh1%3E%3Cpre%3Esender%3D%E8%8D%89%E7%A8%BF%E4%BC%98%E5%8C%96%E9%AA%8C%E6%94%B6%E6%9C%BA%E5%99%A8%E4%BA%BA-1774243529%0Asource_message_id%3Ddraft-opt-verify2-1774243529%0Arun_status%3Dsucceeded%0Awork_item_state%3Ddraft_pending_confirm%0Atitle%3D%E7%BB%99AI%E5%8A%9D%E9%80%80%E5%8A%A9%E6%89%8B%E5%8A%A0%E4%B8%AA%E9%9C%80%0Afeasibility%3D%E5%BE%85%E8%AE%A8%E8%AE%BA%0Arecommended_assignee_workload%3D100.0%0Arecommended_assignee_reason%3D%E5%9F%BA%E4%BA%8E%E5%BD%93%E5%89%8D%E8%B4%9F%E8%BD%BD%E4%B8%8E%E4%BB%BB%E5%8A%A1%E5%8C%B9%E9%85%8D%E5%BA%A6%E7%BB%99%E5%87%BA%E7%9A%84%E5%BB%BA%E8%AE%AE%E3%80%82%0Asemantic_title_ok%3DFalse%0Afeasibility_is_%E5%90%88%E7%90%86%3DFalse%0Aassignee_under80_or_risk_note%3DFalse%0A%3C%2Fpre%3E%3C%2Fbody%3E%3C%2Fhtml%3E
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 49 KiB |
|
|
@ -0,0 +1,11 @@
|
|||
sender=草稿优化验收机器人-1774243529
|
||||
source_message_id=draft-opt-verify2-1774243529
|
||||
run_status=succeeded
|
||||
work_item_state=draft_pending_confirm
|
||||
title=给AI劝退助手加个需
|
||||
feasibility=待讨论
|
||||
recommended_assignee_workload=100.0
|
||||
recommended_assignee_reason=基于当前负载与任务匹配度给出的建议。
|
||||
semantic_title_ok=False
|
||||
feasibility_is_合理=False
|
||||
assignee_under80_or_risk_note=False
|
||||
|
|
@ -0,0 +1 @@
|
|||
data:text/html;charset=utf-8,%3C%21doctype%20html%3E%3Chtml%3E%3Chead%3E%3Cmeta%20charset%3D%27utf-8%27%3E%3Ctitle%3E%E4%BC%98%E5%8C%96%E5%90%8E%E8%8D%89%E7%A8%BF%E9%A3%9E%E4%B9%A6%E5%9B%9E%E5%A4%8D%3C%2Ftitle%3E%3Cstyle%3Ebody%7Bfont-family%3AMenlo%2CConsolas%2Cmonospace%3Bbackground%3A%23f6f8fa%3Bpadding%3A24px%3Bcolor%3A%23111%7Dh1%7Bfont-size%3A20px%7Dpre%7Bwhite-space%3Apre-wrap%3Bbackground%3A%23fff%3Bpadding%3A16px%3Bborder-radius%3A8px%3Bborder%3A1px%20solid%20%23ddd%3Bline-height%3A1.5%7D%3C%2Fstyle%3E%3C%2Fhead%3E%3Cbody%3E%3Ch1%3E%E4%BC%98%E5%8C%96%E5%90%8E%E8%8D%89%E7%A8%BF%E9%A3%9E%E4%B9%A6%E5%9B%9E%E5%A4%8D%3C%2Fh1%3E%3Cpre%3E%F0%9F%93%8B%20%E5%B7%B2%E4%B8%BA%E4%BD%A0%E8%B5%B7%E8%8D%89%E9%9C%80%E6%B1%82%EF%BC%8C%E8%AF%B7%E7%A1%AE%E8%AE%A4%EF%BC%9A%0A%0A%F0%9F%93%8C%20%E7%BB%99AI%E5%8A%9D%E9%80%80%E5%8A%A9%E6%89%8B%E5%8A%A0%E4%B8%AA%E9%9C%80%0A%E7%BB%99AI%E5%8A%9D%E9%80%80%E5%8A%A9%E6%89%8B%E5%8A%A0%E4%B8%AA%E9%9C%80%E6%B1%82%20%E5%8A%9D%E9%80%80%E5%AE%8C%E5%90%8E%E7%AC%AC%E4%BA%8C%E5%A4%A9%E9%97%AE%E4%B8%80%E4%B8%8B%E6%83%85%E5%86%B5%0A%0A%E2%9C%85%20%E5%8F%AF%E8%A1%8C%E6%80%A7%EF%BC%9A%E5%BE%85%E8%AE%A8%E8%AE%BA%0A%E4%BF%A1%E6%81%AF%E4%BB%8D%E9%9C%80%E8%A1%A5%E5%85%85%EF%BC%8C%E5%BB%BA%E8%AE%AE%E5%85%88%E7%A1%AE%E8%AE%A4%E8%BE%B9%E7%95%8C%E3%80%82%0A%0A%F0%9F%91%A4%20%E6%8E%A8%E8%8D%90%E8%B4%9F%E8%B4%A3%E4%BA%BA%EF%BC%9A%E7%B3%BB%E7%BB%9F%E7%AE%A1%E7%90%86%E5%91%98%20%C2%B7%20%E5%BD%93%E5%89%8D%E8%B4%9F%E8%BD%BD100%25%0A%E5%9F%BA%E4%BA%8E%E5%BD%93%E5%89%8D%E8%B4%9F%E8%BD%BD%E4%B8%8E%E4%BB%BB%E5%8A%A1%E5%8C%B9%E9%85%8D%E5%BA%A6%E7%BB%99%E5%87%BA%E7%9A%84%E5%BB%BA%E8%AE%AE%E3%80%82%0A%0A%F0%9F%93%85%20%E8%B7%9F%E8%BF%9B%E8%AE%A1%E5%88%92%EF%BC%9A%0A%C2%B7%20%E6%98%8E%E5%A4%A9%EF%BC%9A%E7%A1%AE%E8%AE%A4%E8%A7%A6%E5%8F%91%E6%97%B6%E6%9C%BA%E4%B8%8E%E9%AA%8C%E6%94%B6%E5%8F%A3%E5%BE%84%0A%C2%B7%20%E4%B8%80%E5%91%A8%E5%90%8E%EF%BC%9A%E5%A4%8D%E6%A0%B8%E6%89%A7%E8%A1%8C%E6%95%88%E6%9E%9C%E5%B9%B6%E5%86%B3%E5%AE%9A%E6%98%AF%E5%90%A6%E6%89%A9%E5%B1%95%0A%0A---%0A%E5%9B%9E%E5%A4%8D%20%E7%A1%AE%E8%AE%A4%20%E2%86%92%20%E5%85%A5%E5%BA%93%0A%E5%9B%9E%E5%A4%8D%20%E4%BF%AE%E6%94%B9%20%5B%E5%85%B7%E4%BD%93%E4%BF%AE%E6%94%B9%E6%84%8F%E8%A7%81%5D%20%E2%86%92%20%E9%87%8D%E6%96%B0%E8%B5%B7%E8%8D%89%0A%E5%9B%9E%E5%A4%8D%20%E5%8F%96%E6%B6%88%20%E2%86%92%20%E6%94%BE%E5%BC%83%0A%E2%8F%B0%2024%E5%B0%8F%E6%97%B6%E5%86%85%E6%9C%AA%E7%A1%AE%E8%AE%A4%E8%87%AA%E5%8A%A8%E5%8F%96%E6%B6%88%3C%2Fpre%3E%3C%2Fbody%3E%3C%2Fhtml%3E
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 62 KiB |
|
|
@ -0,0 +1,20 @@
|
|||
📋 已为你起草需求,请确认:
|
||||
|
||||
📌 给AI劝退助手加个需
|
||||
给AI劝退助手加个需求 劝退完后第二天问一下情况
|
||||
|
||||
✅ 可行性:待讨论
|
||||
信息仍需补充,建议先确认边界。
|
||||
|
||||
👤 推荐负责人:系统管理员 · 当前负载100%
|
||||
基于当前负载与任务匹配度给出的建议。
|
||||
|
||||
📅 跟进计划:
|
||||
· 明天:确认触发时机与验收口径
|
||||
· 一周后:复核执行效果并决定是否扩展
|
||||
|
||||
---
|
||||
回复 确认 → 入库
|
||||
回复 修改 [具体修改意见] → 重新起草
|
||||
回复 取消 → 放弃
|
||||
⏰ 24小时内未确认自动取消
|
||||
|
|
@ -0,0 +1,81 @@
|
|||
{
|
||||
"source_message_id": "draft-opt-verify-1774243376",
|
||||
"capture_response": {
|
||||
"status": "captured",
|
||||
"classification": "command",
|
||||
"linked_project_ids": [],
|
||||
"signal_id": null,
|
||||
"created_project_id": null,
|
||||
"auto_tasks": [],
|
||||
"ai_trace_id": "feishu:draft-opt-verify-1774243376",
|
||||
"results": [
|
||||
{
|
||||
"route_status": "queued_for_decision",
|
||||
"summary": "message_queued_async"
|
||||
}
|
||||
],
|
||||
"direct_reply_required": true,
|
||||
"direct_reply_text": "已收到,正在处理。",
|
||||
"reply_template_key": "async_processing_ack",
|
||||
"work_item_id": null,
|
||||
"decision_level": null,
|
||||
"review_summary": null,
|
||||
"reply_outbox_enqueued": true,
|
||||
"reply_outbox_id": 285
|
||||
},
|
||||
"ok": false,
|
||||
"run": {
|
||||
"id": 113,
|
||||
"status": "succeeded",
|
||||
"last_error": null,
|
||||
"retry_count": 0,
|
||||
"max_retries": 3,
|
||||
"work_item_id": 406,
|
||||
"updated_at": "2026-03-23 13:22:56.674242"
|
||||
},
|
||||
"work_item": {
|
||||
"id": 406,
|
||||
"state": "succeeded",
|
||||
"draft_version": 0,
|
||||
"signal_id": null,
|
||||
"confirm_message_id": null,
|
||||
"draft_content": null,
|
||||
"execution_result": {
|
||||
"status": "pending_manual",
|
||||
"signal_id": null,
|
||||
"project_id": 51,
|
||||
"target_state": "draft_pending_confirm",
|
||||
"target_intent": "project_signal.create",
|
||||
"followup_reply": "请回复“确认”、“修改 + 你的意见”或“取消”来处理当前草稿。",
|
||||
"workflow_route": "project_signal.create",
|
||||
"target_work_item_id": 405
|
||||
},
|
||||
"updated_at": "2026-03-23 13:22:56.643351"
|
||||
},
|
||||
"outbox": [
|
||||
{
|
||||
"id": 285,
|
||||
"status": "sent",
|
||||
"message_text": "已收到,正在处理。",
|
||||
"created_at": "2026-03-23 13:22:56.388208",
|
||||
"sent_at": "2026-03-23 13:22:57.387736",
|
||||
"attempts": 1,
|
||||
"last_error": null
|
||||
},
|
||||
{
|
||||
"id": 286,
|
||||
"status": "sent",
|
||||
"message_text": "请回复“确认”、“修改 + 你的意见”或“取消”来处理当前草稿。",
|
||||
"created_at": "2026-03-23 13:22:56.674242",
|
||||
"sent_at": "2026-03-23 13:22:59.656752",
|
||||
"attempts": 1,
|
||||
"last_error": null
|
||||
}
|
||||
],
|
||||
"draft_text": "",
|
||||
"checks": {
|
||||
"semantic_title_ok": false,
|
||||
"feasibility_is_合理": false,
|
||||
"assignee_under80_or_risk_note": true
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,136 @@
|
|||
{
|
||||
"sender": "草稿优化验收机器人-1774243529",
|
||||
"source_message_id": "draft-opt-verify2-1774243529",
|
||||
"capture_response": {
|
||||
"status": "captured",
|
||||
"classification": "command",
|
||||
"linked_project_ids": [],
|
||||
"signal_id": null,
|
||||
"created_project_id": null,
|
||||
"auto_tasks": [],
|
||||
"ai_trace_id": "feishu:draft-opt-verify2-1774243529",
|
||||
"results": [
|
||||
{
|
||||
"route_status": "queued_for_decision",
|
||||
"summary": "message_queued_async"
|
||||
}
|
||||
],
|
||||
"direct_reply_required": true,
|
||||
"direct_reply_text": "已收到,正在处理。",
|
||||
"reply_template_key": "async_processing_ack",
|
||||
"work_item_id": null,
|
||||
"decision_level": null,
|
||||
"review_summary": null,
|
||||
"reply_outbox_enqueued": true,
|
||||
"reply_outbox_id": 287
|
||||
},
|
||||
"ok": true,
|
||||
"run": {
|
||||
"id": 114,
|
||||
"status": "succeeded",
|
||||
"last_error": null,
|
||||
"retry_count": 0,
|
||||
"max_retries": 3,
|
||||
"work_item_id": 407,
|
||||
"updated_at": "2026-03-23 13:26:16.013472"
|
||||
},
|
||||
"work_item": {
|
||||
"id": 407,
|
||||
"state": "draft_pending_confirm",
|
||||
"draft_version": 1,
|
||||
"signal_id": null,
|
||||
"confirm_message_id": null,
|
||||
"draft_content": {
|
||||
"title": "给AI劝退助手加个需",
|
||||
"summary": "给AI劝退助手加个需求 劝退完后第二天问一下情况",
|
||||
"version": 1,
|
||||
"feasibility": "待讨论",
|
||||
"followup_plan": [
|
||||
{
|
||||
"when": "明天",
|
||||
"action": "确认触发时机与验收口径"
|
||||
},
|
||||
{
|
||||
"when": "一周后",
|
||||
"action": "复核执行效果并决定是否扩展"
|
||||
}
|
||||
],
|
||||
"original_text": "给AI劝退助手加个需求 劝退完后第二天问一下情况",
|
||||
"feasibility_reason": "信息仍需补充,建议先确认边界。",
|
||||
"recommended_assignee_id": 1,
|
||||
"recommended_assignee_name": "系统管理员",
|
||||
"recommended_assignee_reason": "基于当前负载与任务匹配度给出的建议。",
|
||||
"recommended_assignee_workload": 100.0
|
||||
},
|
||||
"execution_result": {
|
||||
"draft": {
|
||||
"title": "给AI劝退助手加个需",
|
||||
"summary": "给AI劝退助手加个需求 劝退完后第二天问一下情况",
|
||||
"version": 1,
|
||||
"feasibility": "待讨论",
|
||||
"followup_plan": [
|
||||
{
|
||||
"when": "明天",
|
||||
"action": "确认触发时机与验收口径"
|
||||
},
|
||||
{
|
||||
"when": "一周后",
|
||||
"action": "复核执行效果并决定是否扩展"
|
||||
}
|
||||
],
|
||||
"original_text": "给AI劝退助手加个需求 劝退完后第二天问一下情况",
|
||||
"feasibility_reason": "信息仍需补充,建议先确认边界。",
|
||||
"recommended_assignee_id": 1,
|
||||
"recommended_assignee_name": "系统管理员",
|
||||
"recommended_assignee_reason": "基于当前负载与任务匹配度给出的建议。",
|
||||
"recommended_assignee_workload": 100.0
|
||||
},
|
||||
"status": "draft_pending_confirm",
|
||||
"draft_version": 1,
|
||||
"revision_note": null,
|
||||
"decision_trace": {
|
||||
"intent": "signal.create",
|
||||
"gate_code": "auto_execute_allowed",
|
||||
"confidence": 0.95,
|
||||
"route_tier": "low",
|
||||
"gate_action": "auto_execute",
|
||||
"route_function": "classify_intent_ai_pmo_kernel",
|
||||
"correction_action": "none",
|
||||
"complexity_reasons": [
|
||||
"llm_only_stage1"
|
||||
],
|
||||
"correction_keywords": []
|
||||
},
|
||||
"workflow_route": "project_signal.create",
|
||||
"workflow_action": "create",
|
||||
"draft_expires_at": "2026-03-24T13:26:15.966734"
|
||||
},
|
||||
"updated_at": "2026-03-23 13:25:30.583098"
|
||||
},
|
||||
"outbox": [
|
||||
{
|
||||
"id": 287,
|
||||
"status": "sent",
|
||||
"message_text": "已收到,正在处理。",
|
||||
"created_at": "2026-03-23 13:25:29.874847",
|
||||
"sent_at": "2026-03-23 13:25:31.671652",
|
||||
"attempts": 1,
|
||||
"last_error": null
|
||||
},
|
||||
{
|
||||
"id": 288,
|
||||
"status": "sent",
|
||||
"message_text": "📋 已为你起草需求,请确认:\n\n📌 给AI劝退助手加个需\n给AI劝退助手加个需求 劝退完后第二天问一下情况\n\n✅ 可行性:待讨论\n信息仍需补充,建议先确认边界。\n\n👤 推荐负责人:系统管理员 · 当前负载100%\n基于当前负载与任务匹配度给出的建议。\n\n📅 跟进计划:\n· 明天:确认触发时机与验收口径\n· 一周后:复核执行效果并决定是否扩展\n\n---\n回复 确认 → 入库\n回复 修改 [具体修改意见] → 重新起草\n回复 取消 → 放弃\n⏰ 24小时内未确认自动取消",
|
||||
"created_at": "2026-03-23 13:26:16.013472",
|
||||
"sent_at": "2026-03-23 13:26:18.784250",
|
||||
"attempts": 1,
|
||||
"last_error": null
|
||||
}
|
||||
],
|
||||
"draft_text": "📋 已为你起草需求,请确认:\n\n📌 给AI劝退助手加个需\n给AI劝退助手加个需求 劝退完后第二天问一下情况\n\n✅ 可行性:待讨论\n信息仍需补充,建议先确认边界。\n\n👤 推荐负责人:系统管理员 · 当前负载100%\n基于当前负载与任务匹配度给出的建议。\n\n📅 跟进计划:\n· 明天:确认触发时机与验收口径\n· 一周后:复核执行效果并决定是否扩展\n\n---\n回复 确认 → 入库\n回复 修改 [具体修改意见] → 重新起草\n回复 取消 → 放弃\n⏰ 24小时内未确认自动取消",
|
||||
"checks": {
|
||||
"semantic_title_ok": false,
|
||||
"feasibility_is_合理": false,
|
||||
"assignee_under80_or_risk_note": false
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1 @@
|
|||
data:text/html;charset=utf-8,%3C%21doctype%20html%3E%3Chtml%3E%3Chead%3E%3Cmeta%20charset%3D%27utf-8%27%3E%3Ctitle%3E%E5%9C%BA%E6%99%AF1%20%E6%95%B0%E6%8D%AE%E5%BA%93%E9%AA%8C%E8%AF%81%3C%2Ftitle%3E%3Cstyle%3Ebody%7Bfont-family%3AMenlo%2CConsolas%2Cmonospace%3Bbackground%3A%23f6f8fa%3Bpadding%3A24px%3Bcolor%3A%23111%7Dh1%7Bfont-size%3A20px%7Dpre%7Bwhite-space%3Apre-wrap%3Bbackground%3A%23fff%3Bpadding%3A16px%3Bborder-radius%3A8px%3Bborder%3A1px%20solid%20%23ddd%3Bline-height%3A1.5%7D%3C%2Fstyle%3E%3C%2Fhead%3E%3Cbody%3E%3Ch1%3E%E5%9C%BA%E6%99%AF1%20%E6%95%B0%E6%8D%AE%E5%BA%93%E9%AA%8C%E8%AF%81%3C%2Fh1%3E%3Cpre%3Erun_status%3Dretrying%0Arun_last_error%3Dkernel_exception%0Arun_retry_count%3D3%0Awork_item_count%3D0%0Alatest_outbox_status%3Dsent%0Alatest_outbox_text%3D%E5%B7%B2%E6%94%B6%E5%88%B0%EF%BC%8C%E6%AD%A3%E5%9C%A8%E5%A4%84%E7%90%86%E3%80%82%0A%3C%2Fpre%3E%3C%2Fbody%3E%3C%2Fhtml%3E
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
<!doctype html><html><head><meta charset="utf-8"><title>场景1 数据库验证</title><style>body{font-family:Menlo,Consolas,monospace;background:#f6f8fa;padding:24px;color:#111}h1{font-size:20px}pre{white-space:pre-wrap;background:#fff;padding:16px;border-radius:8px;border:1px solid #ddd;line-height:1.5}</style></head><body><h1>场景1 数据库验证</h1><pre>run_status=retrying
|
||||
run_last_error=kernel_exception
|
||||
run_retry_count=3
|
||||
work_item_count=0
|
||||
latest_outbox_status=sent
|
||||
latest_outbox_text=已收到,正在处理。
|
||||
</pre></body></html>
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 26 KiB |
|
|
@ -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
|
||||
|
|
@ -0,0 +1,87 @@
|
|||
{
|
||||
"generated_at": "2026-03-23T14:43:11.708259",
|
||||
"sender": "场景1复验机器人-1774247671",
|
||||
"step1": {
|
||||
"source_message_id": "scene1-direct-draft-1774247671",
|
||||
"capture": {
|
||||
"status": "captured",
|
||||
"classification": "command",
|
||||
"linked_project_ids": [],
|
||||
"signal_id": null,
|
||||
"created_project_id": null,
|
||||
"auto_tasks": [],
|
||||
"ai_trace_id": "feishu:scene1-direct-draft-1774247671",
|
||||
"results": [
|
||||
{
|
||||
"route_status": "queued_for_decision",
|
||||
"summary": "message_queued_async"
|
||||
}
|
||||
],
|
||||
"direct_reply_required": true,
|
||||
"direct_reply_text": "已收到,正在处理。",
|
||||
"reply_template_key": "async_processing_ack",
|
||||
"work_item_id": null,
|
||||
"decision_level": null,
|
||||
"review_summary": null,
|
||||
"reply_outbox_enqueued": true,
|
||||
"reply_outbox_id": 292
|
||||
},
|
||||
"run_status": "succeeded",
|
||||
"reply_text": "已收到,正在处理。",
|
||||
"work_item_id": 410,
|
||||
"work_item_state": "draft_pending_confirm",
|
||||
"work_item_signal_id_before_confirm": null
|
||||
},
|
||||
"step2": {
|
||||
"source_message_id": "scene1-direct-confirm-1774247671",
|
||||
"capture": {
|
||||
"handled": true,
|
||||
"status": "answered",
|
||||
"classification": "command",
|
||||
"linked_project_ids": [
|
||||
51
|
||||
],
|
||||
"signal_id": 342,
|
||||
"signal_ids": [
|
||||
342
|
||||
],
|
||||
"created_project_id": null,
|
||||
"auto_tasks": [],
|
||||
"ai_trace_id": "ai-pmo-411",
|
||||
"results": [
|
||||
{
|
||||
"work_item_id": 411,
|
||||
"state": "succeeded",
|
||||
"intent": "followup.confirm",
|
||||
"route_status": "auto_done"
|
||||
}
|
||||
],
|
||||
"intent": "followup.confirm",
|
||||
"workflow_route": "followup.confirm",
|
||||
"direct_reply_required": true,
|
||||
"direct_reply_text": "✅ 已入库\n\n📌 给AI劝退助手加个需\n信号 #342 已创建\n负责人:系统管理员",
|
||||
"reply_template_key": "action_succeeded",
|
||||
"work_item_id": 411,
|
||||
"decision_level": null,
|
||||
"review_summary": {
|
||||
"verified": true,
|
||||
"issues": [],
|
||||
"next_step": "已处理草稿确认请求"
|
||||
},
|
||||
"decision_trace": null,
|
||||
"reply_outbox_enqueued": true,
|
||||
"reply_outbox_id": 294
|
||||
},
|
||||
"run_status": "missing",
|
||||
"reply_text": "✅ 已入库\n\n📌 给AI劝退助手加个需\n信号 #342 已创建\n负责人:系统管理员"
|
||||
},
|
||||
"db_verify": {
|
||||
"target_work_item_id": 410,
|
||||
"target_work_item_state": "succeeded",
|
||||
"target_signal_id": 342,
|
||||
"signal_exists": true,
|
||||
"signal_title": "给AI劝退助手加个需",
|
||||
"signal_summary": "给AI劝退助手加个需求 劝退完后第二天问一下情况",
|
||||
"signal_assignee_id": 1
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue