Compare commits

..

2 Commits

Author SHA1 Message Date
Yuuuu d59b1489a1 Review AI agent updates 2026-03-14 00:00:00 +08:00
Yuuuu 8dde37448e Update PMFlow docs and services 2026-03-13 01:19:35 +08:00
126 changed files with 10540 additions and 16497 deletions

View File

@ -23,7 +23,6 @@ AIProjectManger/
├── backend/ # FastAPI 后端 ├── backend/ # FastAPI 后端
├── frontend/ # React 前端 ├── frontend/ # React 前端
├── scripts/ # 根目录运维脚本 ├── scripts/ # 根目录运维脚本
├── tests/ # 根级测试(集成/脚本)
└── docs/ # 额外文档 └── docs/ # 额外文档
``` ```
- 新增:`docs/INBOX_INLINE_OPS_3_PHASE_CHECKLIST.md`(消息台内联操作三期执行清单) - 新增:`docs/INBOX_INLINE_OPS_3_PHASE_CHECKLIST.md`(消息台内联操作三期执行清单)
@ -57,7 +56,7 @@ backend/
│ └── backfill_orphan_signal_links.py # 回填孤立 signal 的 project_signal_links │ └── backfill_orphan_signal_links.py # 回填孤立 signal 的 project_signal_links
├── requirements.txt # Python 依赖 ├── requirements.txt # Python 依赖
├── Dockerfile ├── Dockerfile
└── tests/ └── (tests 已移除)
``` ```
- 新增:`backend/app/api/llm_providers.py`(AI 管家 LLM provider preset 配置读取接口) - 新增:`backend/app/api/llm_providers.py`(AI 管家 LLM provider preset 配置读取接口)
- 新增:`backend/app/services/ai_agent_kernel.py`(双 Agent 共享决策内核:复杂度路由/执行门控/修正识别) - 新增:`backend/app/services/ai_agent_kernel.py`(双 Agent 共享决策内核:复杂度路由/执行门控/修正识别)
@ -68,19 +67,58 @@ backend/
- 新增:`backend/app/services/audit_query_service.py`(operation_logs 过滤与序列化) - 新增:`backend/app/services/audit_query_service.py`(operation_logs 过滤与序列化)
- 新增:`backend/app/services/signal_source_repair_service.py`(脏信号源回填服务) - 新增:`backend/app/services/signal_source_repair_service.py`(脏信号源回填服务)
- 新增:`backend/alembic/versions/20260311_shift_all_timestamps_to_beijing.py`(全库历史时间 +8h,数据库默认时区切到 Asia/Shanghai) - 新增:`backend/alembic/versions/20260311_shift_all_timestamps_to_beijing.py`(全库历史时间 +8h,数据库默认时区切到 Asia/Shanghai)
- 新增:`backend/tests/test_ai_pmo_work_item_decision_api.py`(AI PMO 审批动作接口回归测试)
- 新增:`backend/alembic/versions/20260312_add_signal_planner_fields.py`(signals 增加 planner 状态字段与索引) - 新增:`backend/alembic/versions/20260312_add_signal_planner_fields.py`(signals 增加 planner 状态字段与索引)
- 新增:`backend/app/services/signal_strategic_analysis_service.py`(Signal Planner 写回收口) - 新增:`backend/app/services/signal_strategic_analysis_service.py`(Signal Planner 写回收口)
- 新增:`backend/app/services/feishu_outbox_service.py`(复用 reply_hash 的 outbox 入队收口) - 新增:`backend/app/services/feishu_outbox_service.py`(复用 reply_hash 的 outbox 入队收口)
- 新增:`backend/app/services/signal_planner_service.py`(Signal Planner 异步分析服务) - 新增:`backend/app/services/signal_planner_service.py`(Signal Planner 异步分析服务)
- 新增:`backend/app/services/model_routing_service.py`(函数级模型路由配置服务) - 新增:`backend/app/services/model_routing_service.py`(函数级模型路由配置服务)
- 新增:`backend/app/services/task_brief_service.py`(增量任务包中心服务) - 新增:`backend/app/services/task_brief_service.py`(增量任务包中心服务)
- 新增:`backend/app/services/model_context_bundle_service.py`(双模型上下文组包/能力汇总/消费验证服务)
- 新增:`backend/app/api/model_routing.py`(模型路由配置接口) - 新增:`backend/app/api/model_routing.py`(模型路由配置接口)
- 新增:`backend/app/api/task_briefs.py`(任务包中心接口) - 新增:`backend/app/api/task_briefs.py`(任务包中心接口)
- 新增:`backend/tests/test_model_routing_api.py`(模型路由接口回归测试) - 更新:`backend/app/api/knowledge_graph.py`(新增 `capability-summary` 与 `verify-context-consumption` 接口)
- 新增:`backend/tests/test_task_briefs_api.py`(任务包中心接口回归测试) - 更新:`backend/app/services/llm.py`(按 function_name 注入 DDL 接口化硬约束,默认直连 GLM 严格失败;本地 Ollama 仅允许知识库问答链路按开关启用)
- 更新:`backend/app/api/llm_engines.py`(新增 `PUT /ai-steward/llm-engines/settings`,维护本地知识库问答开关)
- 更新:`backend/app/api/ai_steward.py`(移除 `/ai-steward/command/dispatch` OpenClaw 指挥规划入口)
- 更新:`backend/app/services/ai_steward.py`(零配置项目创建任一步 LLM 失败直接报错,不再回退默认草稿)
- 更新:`backend/app/services/project_knowledge_graph.py`(新增 `ai_model -> governance_policy -> interface` 治理图谱同步能力,增量任务新增 `model_guardrails` 阶段)
- 移除:`backend/.env.example`(配置模板统一到根目录 `.env.example`) - 移除:`backend/.env.example`(配置模板统一到根目录 `.env.example`)
- 新增:`backend/.openclaw/pmflow-routing-rules.json`(容器内兼容同步副本,与根目录规则保持一致) - 新增:`backend/app/services/project_command_utils.py`(项目创建标题/描述提取与归一化辅助)
- 新增:`backend/app/services/ai_pmo_intent_routing.py`(AI PMO 意图/路由常量与解析器收口,统一 `family.action` 路由映射)
- 新增:`backend/app/services/ai_pmo_workflow_runtime.py`(AI PMO `ContextAssembler` + `WorkflowRegistry/WorkflowHandler` 运行时骨架)
- 新增:`backend/app/services/ai_pmo_decision_runtime.py`(AI PMO 决策脑层:`Strategic/Tactical/Simulation/ExecutionPolicy/DecisionEngine`)
- 新增:`backend/app/services/ai_pmo_reply_renderer.py`(AI PMO 回执渲染出口 `ReplyRenderer`)
- 新增:`backend/app/services/ai_pmo_typed_executor.py`(AI PMO typed 执行层组合入口)
- 新增:`backend/app/services/ai_pmo_typed_executor_common.py`(TypedExecutor 共享常量/依赖)
- 新增:`backend/app/services/ai_pmo_typed_executor_mixins_parse.py`(TypedExecutor 解析与批量分析逻辑)
- 新增:`backend/app/services/ai_pmo_typed_executor_mixins_discovery.py`(TypedExecutor discover 候选上下文逻辑)
- 新增:`backend/app/services/ai_pmo_typed_executor_mixins_execute.py`(TypedExecutor execute/create/source-onboard 落库逻辑)
- 新增:`backend/app/services/ai_pmo_kernel_runtime.py`(AI PMO kernel 组合入口)
- 新增:`backend/app/services/ai_pmo_kernel_common.py`(Kernel 共享常量/依赖)
- 新增:`backend/app/services/ai_pmo_kernel_mixins_ingress.py`(Kernel ingress 组合入口)
- 新增:`backend/app/services/ai_pmo_kernel_mixins_ingress_flow.py`(Kernel 入站主流程编排)
- 新增:`backend/app/services/ai_pmo_kernel_mixins_ingress_storage.py`(Kernel 入站事件/工作项写入)
- 新增:`backend/app/services/ai_pmo_kernel_mixins_ingress_render.py`(Kernel 入站结果组装与输出辅助)
- 新增:`backend/app/services/ai_pmo_kernel_mixins_world_model.py`(Kernel world-model 组合入口)
- 新增:`backend/app/services/ai_pmo_kernel_mixins_world_model_core.py`(Kernel world-model 核心构建与缓存)
- 新增:`backend/app/services/ai_pmo_kernel_mixins_world_model_session.py`(Kernel 会话继承与会话态维护)
- 新增:`backend/app/services/ai_pmo_kernel_mixins_world_model_aggregates.py`(Kernel 组合摘要构建器)
- 新增:`backend/app/services/ai_pmo_kernel_mixins_intent.py`(Kernel intent 组合入口)
- 新增:`backend/app/services/ai_pmo_kernel_mixins_intent_rules.py`(Kernel 规则意图与槽位守卫)
- 新增:`backend/app/services/ai_pmo_kernel_mixins_intent_llm.py`(Kernel LLM 意图识别与仲裁)
- 新增:`backend/app/services/ai_pmo_kernel_mixins_intent_resolve.py`(Kernel 最终意图解析入口)
- 新增:`backend/app/services/ai_pmo_kernel_mixins_followup.py`(Kernel followup 组合入口)
- 新增:`backend/app/services/ai_pmo_kernel_mixins_followup_records.py`(followup 决策/计划/回执写入)
- 新增:`backend/app/services/ai_pmo_kernel_mixins_followup_routing.py`(followup 候选定位与恢复分发)
- 新增:`backend/app/services/ai_pmo_kernel_mixins_followup_project_signal.py`(project_signal followup 组合入口)
- 新增:`backend/app/services/ai_pmo_kernel_mixins_followup_project_signal_actions.py`(project_signal commit/refresh/cancel 动作执行)
- 新增:`backend/app/services/ai_pmo_kernel_mixins_followup_project_signal_resume.py`(project_signal 目标工作项恢复闭环)
- 新增:`backend/app/services/ai_pmo_kernel_mixins_followup_verification.py`(followup 执行校验与工具函数)
- 新增:`backend/app/services/ai_pmo_kernel_mixins_closure.py`(Kernel closure 组合入口)
- 新增:`backend/app/services/ai_pmo_kernel_mixins_closure_helpers.py`(Kernel 闭环通用归一化工具)
- 新增:`backend/app/services/ai_pmo_kernel_mixins_closure_data.py`(Kernel 闭环历史上下文回放)
- 新增:`backend/app/services/ai_pmo_kernel_mixins_closure_payload.py`(Kernel 闭环语义与来源上下文组装)
- 更新:`backend/app/services/ai_pmo_kernel_service.py`(兼容入口层,仅导出 `AIPMOKernelService`)
## 5. 前端结构(`frontend/`) ## 5. 前端结构(`frontend/`)
```text ```text
@ -109,11 +147,16 @@ frontend/
- 新增:`frontend/src/components/ui/SkillBadge.tsx`(技能徽章组件,默认显示 3 个并支持 +N) - 新增:`frontend/src/components/ui/SkillBadge.tsx`(技能徽章组件,默认显示 3 个并支持 +N)
- 新增:`frontend/src/api/taskBriefs.ts`(任务包中心 API 封装) - 新增:`frontend/src/api/taskBriefs.ts`(任务包中心 API 封装)
- 新增:`frontend/src/api/modelRouting.ts`(模型路由 API 封装) - 新增:`frontend/src/api/modelRouting.ts`(模型路由 API 封装)
- 新增:`frontend/src/api/knowledgeGraph.ts`(KG 能力汇总与双模型消费验证 API)
- 更新:`frontend/src/api/aiSteward.ts`(移除 OpenClaw dispatch 封装,新增 LLM 引擎 CRUD / settings 封装)
- 新增:`frontend/src/pages/TaskBriefCenterPage.tsx`(增量任务包中心页面) - 新增:`frontend/src/pages/TaskBriefCenterPage.tsx`(增量任务包中心页面)
- 新增:`frontend/src/pages/TaskBriefTemplatePage.tsx`(任务包模板管理页面) - 新增:`frontend/src/pages/TaskBriefTemplatePage.tsx`(任务包模板管理页面)
- 新增:`frontend/src/pages/TaskBriefRunsPage.tsx`(任务包生成记录页面) - 新增:`frontend/src/pages/TaskBriefRunsPage.tsx`(任务包生成记录页面)
- 新增:`frontend/src/pages/ContractSyncBoardPage.tsx`(契约同步看板页面) - 新增:`frontend/src/pages/ContractSyncBoardPage.tsx`(契约同步看板页面)
- 新增:`frontend/src/pages/ModelRoutingConfigPage.tsx`(模型路由配置页面) - 新增:`frontend/src/pages/ModelRoutingConfigPage.tsx`(模型路由配置页面)
- 新增:`frontend/src/pages/KnowledgeGraphContextBoardPage.tsx`(知识图谱与双模型上下文看板)
- 更新:`frontend/src/pages/AIStewardPage.tsx`(移除 OpenClaw 指挥调度入口,新增本地 LLM 开关与严格失败边界说明)
- 移除:`frontend/src/components/ai-steward/OpenClawCommanderPanel.tsx`(OpenClaw 指挥调度面板)
### 5.1 `frontend/src/components/sstc/` 目录说明(模块化拆分后) ### 5.1 `frontend/src/components/sstc/` 目录说明(模块化拆分后)
- `SSTCWorkspace.tsx`:SSTC 工作区主容器(组装各阶段组件) - `SSTCWorkspace.tsx`:SSTC 工作区主容器(组装各阶段组件)
@ -190,6 +233,7 @@ uvicorn app.main:app --reload --host 0.0.0.0 --port 8000
## 8. 服务边界约束(禁止越界) ## 8. 服务边界约束(禁止越界)
- 数据库写操作必须通过 Alembic migration,禁止直接 `ALTER TABLE` - 数据库写操作必须通过 Alembic migration,禁止直接 `ALTER TABLE`
- 运行期禁止 DDL:`database.py` 钩子统一拦截 `CREATE/ALTER/DROP/...`,仅迁移路径允许执行 - 运行期禁止 DDL:`database.py` 钩子统一拦截 `CREATE/ALTER/DROP/...`,仅迁移路径允许执行
- 运行环境数据库仅允许 PostgreSQL(`postgresql+asyncpg`);禁止回落 SQLite
- 新增接口必须遵循响应包络:`{code, message, data}`,不得裸返回 - 新增接口必须遵循响应包络:`{code, message, data}`,不得裸返回
- 禁止在 API 层写业务逻辑,业务逻辑归属 `services/` - 禁止在 API 层写业务逻辑,业务逻辑归属 `services/`
- 禁止在前端组件内直接调用 `fetch`,必须通过 `frontend/src/api/` 封装层 - 禁止在前端组件内直接调用 `fetch`,必须通过 `frontend/src/api/` 封装层
@ -197,19 +241,31 @@ uvicorn app.main:app --reload --host 0.0.0.0 --port 8000
- 飞书回执发送仅允许 `main.py` 的 drain_loop 作为消费者,禁止在业务路径即时派发 outbox 发送任务 - 飞书回执发送仅允许 `main.py` 的 drain_loop 作为消费者,禁止在业务路径即时派发 outbox 发送任务
- 所有写接口必须落审计日志(`operation_logs`):至少包含 `trace_id/interface_path/caller` - 所有写接口必须落审计日志(`operation_logs`):至少包含 `trace_id/interface_path/caller`
- `created_at_beijing` 为计算属性返回,禁止新增数据库物理列 - `created_at_beijing` 为计算属性返回,禁止新增数据库物理列
- low/high 双模型(`classify_intent_ai_pmo_kernel`、`classify_intent_complex`)与 `openclaw_command_dispatch` 必须遵循 DDL 接口化约束:禁止直连 SQL DDL,命中时强制输出 `ddl_api_required`
- 双模型上下文注入仅允许通过 `ModelContextBundleService` 统一组包:固定三段 `knowledge_graph/task_template/contract_sync`,禁止在业务服务散落拼接 prompt 上下文
- AI PMO `signal.create` 在用户显式要求“先分析再入库 / 自行分析”时,若批量信号结构化分析失败,禁止回退规则标题直接入库,必须返回失败安全态
- 所有 LLM 调用禁止模型级兜底:禁止高阶升级、Safety 引擎、应急直连、stub 假成功;模型异常、超时或结构化输出不合法时必须显式报错
- 本地 `ollama` 仅允许知识库问答链路使用,且必须通过 `llm_engine.settings.enable_local_kb_qa` 显式开启;未开启时运行期与连通测试都不可用
- AI Steward 零配置创建任一步 LLM 失败、超时或 JSON 非法时必须直接报错,禁止回退默认 analysis/signal/charter/tasks 草稿
- AI Steward 不再暴露 OpenClaw 指挥规划入口,`/ai-steward/command/dispatch` 视为下线路径
## 9. 新增代码的参考模式 ## 9. 新增代码的参考模式
- 新增后端接口:参考 `backend/app/api/sstc.py` 的结构 - 新增后端接口:参考 `backend/app/api/sstc.py` 的结构
- 模型路由配置接口:参考 `backend/app/api/model_routing.py`(`GET/PUT /model-routing/functions/{function_name}`,仅管理员可写并写入 `operation_logs`) - 模型路由配置接口:参考 `backend/app/api/model_routing.py`(`GET/PUT /model-routing/functions/{function_name}`,仅管理员可写并写入 `operation_logs`)
- 任务包中心接口:参考 `backend/app/api/task_briefs.py`(`/task-briefs/center/templates/runs/generate/contract-sync`) - 任务包中心接口:参考 `backend/app/api/task_briefs.py`(`/task-briefs/center/templates/runs/generate/contract-sync`)
- KG 上下文闭环接口:参考 `backend/app/api/knowledge_graph.py`(`GET /ai-steward/knowledge-graph/capability-summary`、`POST /ai-steward/knowledge-graph/verify-context-consumption`)
- 审计查询接口:参考 `backend/app/api/audit.py`(`GET /audit/operations`) - 审计查询接口:参考 `backend/app/api/audit.py`(`GET /audit/operations`)
- 脏数据修复接口:参考 `backend/app/api/ai_steward.py`(`POST /ai-steward/signal-sources/repair-dirty`) - 脏数据修复接口:参考 `backend/app/api/ai_steward.py`(`POST /ai-steward/signal-sources/repair-dirty`)
- LLM 引擎设置接口:参考 `backend/app/api/llm_engines.py`(`PUT /ai-steward/llm-engines/settings`,仅管理员可修改本地知识库问答开关)
- 飞书捕获模拟接口:`POST /ai-steward/signal/capture-feishu` 在 `direct_reply_required=true` 时会同步写入 `feishu_reply_outbox`,并返回 `reply_outbox_enqueued/reply_outbox_id` 便于链路自检
- 配置读取类接口:参考 `backend/app/api/llm_providers.py`(`GET /ai-steward/llm/providers`) - 配置读取类接口:参考 `backend/app/api/llm_providers.py`(`GET /ai-steward/llm/providers`)
- AI Steward 指挥规划接口已移除:`POST /ai-steward/command/dispatch`
- 项目删除接口(`DELETE /projects/{project_id}`):参考 `backend/app/api/projects.py`,先清理强依赖外键、再置空弱依赖外键、最后删除主记录(单事务提交) - 项目删除接口(`DELETE /projects/{project_id}`):参考 `backend/app/api/projects.py`,先清理强依赖外键、再置空弱依赖外键、最后删除主记录(单事务提交)
- 项目成员分配接口(`PUT /projects/{project_id}/members`):参考 `backend/app/api/projects.py` + `backend/app/services/project_member_service.py`,要求显式传入 `member_user_ids` 并写入 `user_projects` - 项目成员分配接口(`PUT /projects/{project_id}/members`):参考 `backend/app/api/projects.py` + `backend/app/services/project_member_service.py`,要求显式传入 `member_user_ids` 并写入 `user_projects`
- SSTC 总览接口(`GET /projects/{project_id}/sstc/overview`):`workload_health` 返回成员明细 `members[]`(姓名/角色/负载比例/状态)用于头部卡片展示 - SSTC 总览接口(`GET /projects/{project_id}/sstc/overview`):`workload_health` 返回成员明细 `members[]`(姓名/角色/负载比例/状态)用于头部卡片展示
- AI PMO 审批接口(`POST /ai-pmo/work-items/{work_item_id}/decision`):显式 `approve/reject`,`reject` 必须落 `ignored`(失效)并同步 `decision_records.final_outcome` - AI PMO 审批接口(`POST /ai-pmo/work-items/{work_item_id}/decision`):显式 `approve/reject`,`reject` 必须落 `ignored`(失效)并同步 `decision_records.final_outcome`
- AI PMO 审批接口补充:`approve + waiting_manual + project_id` 走“快速返回 + 后台继续执行”,避免前端长时间阻塞 - AI PMO 审批接口补充:`approve + waiting_manual + project_id` 走“快速返回 + 后台继续执行”,避免前端长时间阻塞
- AI PMO 恢复接口(`POST /ai-pmo/work-items/{work_item_id}/resume`):支持 `action=continue|commit|refresh|cancel`,复用同一闭环恢复入口
- AI PMO 消息台列表接口(`GET /ai-pmo/work-items`):返回 `latest_policy_gate_code` 与规范化 `approval_requirement{required,reason,message}`(来源于最新 `decision_records` 投影) - AI PMO 消息台列表接口(`GET /ai-pmo/work-items`):返回 `latest_policy_gate_code` 与规范化 `approval_requirement{required,reason,message}`(来源于最新 `decision_records` 投影)
- AI PMO 消息台列表接口补充:返回源消息上下文字段 `source_message_text/source_captured_at/source_sender/source_channel_ref`,用于详情页展示“原始输入”与事件时间 - AI PMO 消息台列表接口补充:返回源消息上下文字段 `source_message_text/source_captured_at/source_sender/source_channel_ref`,用于详情页展示“原始输入”与事件时间
- AI PMO 消息台列表接口补充:返回闭环语义字段 `source_conversation_closure/closure_summary/implementation_modules/source_closure_turns`,用于详情页展示“消息闭环 + 实现模块” - AI PMO 消息台列表接口补充:返回闭环语义字段 `source_conversation_closure/closure_summary/implementation_modules/source_closure_turns`,用于详情页展示“消息闭环 + 实现模块”
@ -217,7 +273,7 @@ uvicorn app.main:app --reload --host 0.0.0.0 --port 8000
- AI PMO 消息台列表接口补充:过滤短口水话 `query.read_only` 项(如“怎么样了/在吗”),保留后端记录但不下发前端收件流 - AI PMO 消息台列表接口补充:过滤短口水话 `query.read_only` 项(如“怎么样了/在吗”),保留后端记录但不下发前端收件流
- AI PMO `signal_source.onboard` 入库规则:优先使用闭环摘要写入 `signals.summary`,闭环对话写入 `signals.content`,并在 `signals.strategic_analysis.source_context.implementation_modules` 持久化实现模块 - AI PMO `signal_source.onboard` 入库规则:优先使用闭环摘要写入 `signals.summary`,闭环对话写入 `signals.content`,并在 `signals.strategic_analysis.source_context.implementation_modules` 持久化实现模块
- SSTC 信号列表接口(`GET /projects/{project_id}/signals`)标题生成规则:优先 `signals.title`,其次 `signals.summary`,最后回退 `signals.content` 去前缀文本 - SSTC 信号列表接口(`GET /projects/{project_id}/signals`)标题生成规则:优先 `signals.title`,其次 `signals.summary`,最后回退 `signals.content` 去前缀文本
- 信号源编排事务补充:`SignalSourceOrchestrationService` 内部调用 OpenClaw 指挥链使用隔离 Session,禁止把子链路 rollback 传播到外层 `signal/work_item` 事务 - 信号源编排补充:`SignalSourceOrchestrationService` 已停止运行时调用旧 command-first 执行链,统一改为“GLM 分析后直接调用服务写入”,编排结果字段对外收敛为 `execution`
- 新增前端 API 调用:参考 `frontend/src/api/sstc.ts` - 新增前端 API 调用:参考 `frontend/src/api/sstc.ts`
- 新增数据库表:参考 `alembic/versions/` 最新 migration - 新增数据库表:参考 `alembic/versions/` 最新 migration
- 新增飞书事件处理:参考 `backend/app/services/feishu_integration.py` - 新增飞书事件处理:参考 `backend/app/services/feishu_integration.py`
@ -230,21 +286,27 @@ uvicorn app.main:app --reload --host 0.0.0.0 --port 8000
## 11. 已知 BOUNDARY(不要幻构行为) ## 11. 已知 BOUNDARY(不要幻构行为)
- 飞书回调字段可能缺失文件名/大小,仅保证 `attachment_id/key/type` 可用 - 飞书回调字段可能缺失文件名/大小,仅保证 `attachment_id/key/type` 可用
- `agents.defaults.timeoutSeconds` 在 OpenClaw 网关行为未验证,当前以 `LLM_REQUEST_TIMEOUT` 环境变量为准 - 当前 LLM 主链默认直连引擎,不再依赖 OpenClaw 网关;请求超时统一以 `LLM_REQUEST_TIMEOUT` 环境变量为准
- LLM 非结构化响应在 `content` 为空且存在 `reasoning_content` 时,可按需回退读取 `reasoning_content`(开关:`LLM_REASONING_CONTENT_FALLBACK`,默认关闭)
- World Model 静态层已接入 Redis 缓存(key: `wm:static:{tenant_id}:{project_id}`,TTL 600s) - World Model 静态层已接入 Redis 缓存(key: `wm:static:{tenant_id}:{project_id}`,TTL 600s)
- 写操作后主动 invalidate,动态层不缓存 - 写操作后主动 invalidate,动态层不缓存
- AI PMO 意图识别已启用 LLM 主路径(`AI_PMO_INTENT_LLM_ENABLED`,默认开启);当模型超时/不可用/结构化解析失败时自动降级到规则链路,不中断主流程 - AI PMO 意图识别默认 `rule-first`:`AI_PMO_INTENT_LLM_ENABLED=true` 时,LLM 仅在规则低置信或 `default_query_fallback` 场景作为末位兜底;模型超时/不可用/结构化解析失败时回退规则链路,不中断主流程
- 环境变量以根目录 `.env` / `.env.example` 为唯一配置源;`backend/.env` 已移除且不再读取 - 环境变量以根目录 `.env` / `.env.example` 为唯一配置源;`backend/.env` 已移除且不再读取
- OpenClaw 路由规则统一由根目录 `.openclaw/pmflow-routing-rules.json` 管理,容器通过 `OPENCLAW_ROUTING_RULES_PATH` 相对路径 `../.openclaw/pmflow-routing-rules.json` 读取 - Signal Planner 函数路由支持 `integrations(type='model_routing')` 覆盖配置;配置缺失时默认直连 GLM
- Signal Planner 函数路由支持 `integrations(type='model_routing')` 覆盖配置;配置缺失时回退 OpenClaw 默认规则并记录告警 - Signal Planner 结构化调用采用 `25s` 硬超时:超时或结构化不合法时标记失败并通知人工重试,不再写入降级 `ai_planner(fallback_mode=true)`
- Signal Planner 结构化调用采用 `25s` 硬超时:超时或结构化不合法时自动写入降级 `ai_planner(fallback_mode=true)` 并收敛为 `planner_status=completed` - AI Steward 用户侧已移除 OpenClaw 指挥规划入口;后续项目分析主链以 GLM 严格失败直出为准
- 飞书业务命令命中旧 command-first 路由时,仅返回“已下线”阻断提示,不再触发实际执行
- 模型命名统一使用裸模型名(如 `GLM-4.7`、`MiniMax-M2.5`),不使用供应商前缀模型名 - 模型命名统一使用裸模型名(如 `GLM-4.7`、`MiniMax-M2.5`),不使用供应商前缀模型名
- 双 Agent 路由治理:默认启用 `OPENCLAW_ROUTING_ENABLED=true`、关闭 `LLM_MIXED_ROUTING_ENABLED`,复杂意图使用 `classify_intent_complex` 走 high-tier - 双 Agent 路由治理:默认直连 `GLM`,复杂意图仍使用 `classify_intent_complex`,不再经过 OpenClaw 控制平面
- 双模型 DDL 治理图谱的模型来源优先读取显式模型配置;缺失时回退 `LLM_ROUTING_LOW_MODEL/LLM_ROUTING_HIGH_MODEL`
- 写操作链路统一产出 `decision_trace`(intent/tier/confidence/gate_code/correction_action)用于离线回放与分桶评分 - 写操作链路统一产出 `decision_trace`(intent/tier/confidence/gate_code/correction_action)用于离线回放与分桶评分
- AI PMO 意图仲裁默认开启(`AI_PMO_INTENT_ARBITRATION_ENABLED=true`):规则与 LLM 写操作冲突时按 `AI_PMO_INTENT_CONFLICT_POLICY=pending_manual` 强制进入人工确认,不直接自动执行 - AI PMO 意图仲裁默认开启(`AI_PMO_INTENT_ARBITRATION_ENABLED=true`):规则与 LLM 写操作冲突时按 `AI_PMO_INTENT_CONFLICT_POLICY=pending_manual` 强制进入人工确认,不直接自动执行
- 飞书 `qa_llm` 回复已增加事实熔断:命中“数据库迁移/DDL/Alembic/让我直接处理/模型失败了所以没分析”等漂移语句时,自动回退固定模板,禁止对外宣称未证实执行动作
- `project.create` 启用槽位完整性闸门:无法从指令提取有效项目名时标记 `slot_incomplete` 并进入人工确认 - `project.create` 启用槽位完整性闸门:无法从指令提取有效项目名时标记 `slot_incomplete` 并进入人工确认
- 数据库与核心写入链路统一使用北京时间(`Asia/Shanghai`):PostgreSQL 会话时区、数据库默认时区与核心 `_utc_now*` 写入入口均已切换到北京时间 - 数据库与核心写入链路统一使用北京时间(`Asia/Shanghai`):PostgreSQL 会话时区、数据库默认时区与核心 `_utc_now*` 写入入口均已切换到北京时间
- 后端与脚本层已清零 `datetime.utcnow()/timezone.utc`:统一使用 `backend/app/utils/timezone.py`(`now_cn_aware/now_cn_naive/CN_TZ`)管理时间语义 - 后端与脚本层已清零 `datetime.utcnow()/timezone.utc`:统一使用 `backend/app/utils/timezone.py`(`now_cn_aware/now_cn_naive/CN_TZ`)管理时间语义
- `operation_logs.created_at_beijing` 为应用层计算字段(非 DB 列),审计查询接口返回时统一补齐 - `operation_logs.created_at_beijing` 为应用层计算字段(非 DB 列),审计查询接口返回时统一补齐
- 历史脏信号源修复优先复用同项目已存在 `signal_id`,仅无法复用时才触发 `capture-feishu` 补录 - 历史脏信号源修复优先复用同项目已存在 `signal_id`,仅无法复用时才触发 `capture-feishu` 补录
- 兼容 SQL 查询入口:`ai_pmo_work_items` 为视图别名(底层映射 `work_items`),避免历史脚本直接查旧表名失败 - 兼容 SQL 查询入口:`ai_pmo_work_items` 为视图别名(底层映射 `work_items`),避免历史脚本直接查旧表名失败
- `project.create` 在 AI 管家建项链路连续失败时,允许 TypedExecutor 启用本地兜底建项(最小项目 + 初始信号),并在返回 payload 标记 `fallback.enabled=true`
- `signal_source.onboard` 已加入工作流门禁旁路:进入执行阶段后按闭环结果返回 `succeeded/pending_manual/failed_safe`,不再被前置仿真门禁直接拦截为 `waiting_manual`

View File

@ -13,6 +13,7 @@ COPY backend/requirements.txt /tmp/requirements.txt
RUN pip install --no-cache-dir -r /tmp/requirements.txt RUN pip install --no-cache-dir -r /tmp/requirements.txt
COPY backend /app/backend COPY backend /app/backend
COPY docs /app/docs
COPY .openclaw /app/.openclaw COPY .openclaw /app/.openclaw
EXPOSE 8000 EXPOSE 8000

View File

@ -24,8 +24,7 @@ if config.config_file_name is not None:
# Set the database URL from settings # Set the database URL from settings
# Replace async driver with sync driver for migrations # Replace async driver with sync driver for migrations
db_url = settings.database_url.replace("sqlite+aiosqlite", "sqlite") db_url = settings.database_url.replace("postgresql+asyncpg", "postgresql+psycopg")
db_url = db_url.replace("postgresql+asyncpg", "postgresql+psycopg")
config.set_main_option("sqlalchemy.url", db_url) config.set_main_option("sqlalchemy.url", db_url)
# add your model's MetaData object here # add your model's MetaData object here

View File

@ -23,7 +23,7 @@ from ..services.ai_pmo_kernel_service import AIPMOKernelService
router = APIRouter(prefix="/ai-pmo", tags=["AI PMO"]) router = APIRouter(prefix="/ai-pmo", tags=["AI PMO"])
logger = get_logger(__name__) logger = get_logger(__name__)
FRONT_NOISE_INTENT = "query.read_only" FRONT_NOISE_INTENTS = {"query.read_only", "query.answer"}
FRONT_NOISE_EXACT = { FRONT_NOISE_EXACT = {
"收到", "收到",
"收到啦", "收到啦",
@ -63,6 +63,7 @@ class PMOCadenceRunRequest(BaseModel):
class PMOResumeWorkItemRequest(BaseModel): class PMOResumeWorkItemRequest(BaseModel):
action: Literal["continue", "commit", "refresh", "cancel"] = Field(default="continue")
project_id: Optional[int] = Field(default=None) project_id: Optional[int] = Field(default=None)
source_message_id: Optional[str] = Field(default=None, max_length=128) source_message_id: Optional[str] = Field(default=None, max_length=128)
@ -271,11 +272,18 @@ async def resume_work_item(
).scalar_one_or_none() ).scalar_one_or_none()
if target is None: if target is None:
raise HTTPException(status_code=404, detail="work_item_not_found") raise HTTPException(status_code=404, detail="work_item_not_found")
action_text_map = {
"continue": "继续处理",
"commit": "确认并提交候选",
"refresh": "换一批候选",
"cancel": "取消本次候选",
}
action_message = action_text_map.get(str(request.action or "").strip().lower(), "继续处理")
service = AIPMOKernelService(db, tenant_id=tenant_id) service = AIPMOKernelService(db, tenant_id=tenant_id)
raw_result = await service.process_ingress( raw_result = await service.process_ingress(
source_system="ai_pmo_ui", source_system="ai_pmo_ui",
source_message_id=request.source_message_id or f"resume-{int(work_item_id)}-{int(datetime.now().timestamp() * 1000)}", source_message_id=request.source_message_id or f"resume-{int(work_item_id)}-{int(datetime.now().timestamp() * 1000)}",
message_text=f"继续处理 work_item #{int(work_item_id)}", message_text=f"{action_message} work_item #{int(work_item_id)}",
sender=str(current_user.name or current_user.username or "PMFlow用户"), sender=str(current_user.name or current_user.username or "PMFlow用户"),
channel_ref="ai_pmo_ui", channel_ref="ai_pmo_ui",
source_sender_open_id=None, source_sender_open_id=None,
@ -458,7 +466,7 @@ def _normalize_noise_text(text: str) -> str:
def _should_hide_work_item_from_inbox(*, row: WorkItem, ingest: IngestEvent) -> bool: def _should_hide_work_item_from_inbox(*, row: WorkItem, ingest: IngestEvent) -> bool:
intent_name = str(row.intent or "").strip().lower() intent_name = str(row.intent or "").strip().lower()
if intent_name != FRONT_NOISE_INTENT: if intent_name not in FRONT_NOISE_INTENTS:
return False return False
payload = ingest.payload if isinstance(ingest.payload, dict) else {} payload = ingest.payload if isinstance(ingest.payload, dict) else {}
message_text = str(payload.get("message_text") or "").strip() message_text = str(payload.get("message_text") or "").strip()

View File

@ -30,7 +30,7 @@ from ..services.daily_briefing import get_daily_briefing_service
from ..services.event_workflow import get_event_workflow_engine, WorkflowEventType from ..services.event_workflow import get_event_workflow_engine, WorkflowEventType
from ..services.adaptive_rag_service import AdaptiveRAGService from ..services.adaptive_rag_service import AdaptiveRAGService
from ..services.ai_orchestration_run_service import AIOrchestrationRunService from ..services.ai_orchestration_run_service import AIOrchestrationRunService
from ..services.openclaw_commander_service import OpenClawCommanderService from ..services.feishu_outbox_service import FeishuOutboxService
from ..services.signal_source_orchestration_service import SignalSourceOrchestrationService from ..services.signal_source_orchestration_service import SignalSourceOrchestrationService
from ..services.signal_source_repair_service import ( from ..services.signal_source_repair_service import (
SignalSourceRepairMissingSignalError, SignalSourceRepairMissingSignalError,
@ -152,15 +152,6 @@ class ConflictDetectRequest(BaseModel):
detect_type: str = "all" # all, charter, definition detect_type: str = "all" # all, charter, definition
class OpenClawCommandDispatchRequest(BaseModel):
"""OpenClaw 指挥层任务分配请求"""
project_id: int = Field(..., ge=1)
instruction: str = Field(..., min_length=4, max_length=2000)
dry_run: bool = False
max_operations: int = Field(default=8, ge=1, le=20)
response_mode: Literal["fast", "complete"] = "fast"
class SignalSourceOnboardRequest(BaseModel): class SignalSourceOnboardRequest(BaseModel):
project_id: int = Field(..., ge=1) project_id: int = Field(..., ge=1)
instruction: str = Field(..., min_length=6, max_length=2000) instruction: str = Field(..., min_length=6, max_length=2000)
@ -230,34 +221,6 @@ async def ask_ai_steward_rag(
return success_response(result) return success_response(result)
@router.post("/command/dispatch")
@audited_write(caller="user")
async def dispatch_openclaw_command(
request: OpenClawCommandDispatchRequest,
current_user: User = Depends(require_manager_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 = OpenClawCommanderService(db)
try:
result = await service.dispatch_command(
tenant_id=effective_tenant_id,
operator_user=current_user,
project_id=request.project_id,
instruction=request.instruction,
dry_run=request.dry_run,
max_operations=request.max_operations,
response_mode=request.response_mode,
)
except ValueError as exc:
detail = str(exc)
if detail == "project not found":
raise HTTPException(status_code=404, detail=detail) from exc
raise HTTPException(status_code=400, detail=detail) from exc
return success_response(result)
@router.post("/signal-sources/onboard") @router.post("/signal-sources/onboard")
@audited_write(caller="user") @audited_write(caller="user")
async def onboard_signal_source( async def onboard_signal_source(
@ -448,7 +411,31 @@ async def capture_feishu_signal(
tenant_id=tenant_id, tenant_id=tenant_id,
feishu_message_id=request.feishu_message_id, feishu_message_id=request.feishu_message_id,
) )
return result response_payload = dict(result) if isinstance(result, dict) else {"result": result}
should_enqueue_reply = bool(response_payload.get("direct_reply_required"))
reply_text = str(response_payload.get("direct_reply_text") or "").strip() or "已收到,正在处理。"
source_message_id = (str(request.feishu_message_id or "").strip()[:128] or None)
normalized_chat_id = str(request.group_chat_id or "").strip()
if should_enqueue_reply and normalized_chat_id:
try:
outbox_id = await FeishuOutboxService.enqueue(
db,
tenant_id=int(tenant_id),
chat_id=normalized_chat_id,
text=reply_text,
source_message_id=source_message_id,
dedupe_seed=f"capture-feishu:{normalized_chat_id}:{str(request.sender or '').strip()[:64]}",
trace_id=str(response_payload.get("ai_trace_id") or "").strip()[:64] or None,
)
await db.commit()
response_payload["reply_outbox_enqueued"] = bool(outbox_id)
response_payload["reply_outbox_id"] = int(outbox_id) if outbox_id else None
except Exception as outbox_exc:
await db.rollback()
response_payload["reply_outbox_enqueued"] = False
response_payload["reply_outbox_error"] = str(outbox_exc)[:200]
return response_payload
except Exception as e: except Exception as e:
raise HTTPException(status_code=500, detail=f"飞书信号捕获失败: {str(e)}") raise HTTPException(status_code=500, detail=f"飞书信号捕获失败: {str(e)}")

View File

@ -15,6 +15,7 @@ from ..dependencies.auth import get_current_user
from ..dependencies.tenant import get_tenant_id from ..dependencies.tenant import get_tenant_id
from ..models import User from ..models import User
from ..services.auth import has_any_role from ..services.auth import has_any_role
from ..services.model_context_bundle_service import ModelContextBundleService
from ..services.project_knowledge_graph import ProjectKnowledgeGraphService from ..services.project_knowledge_graph import ProjectKnowledgeGraphService
router = APIRouter(prefix="/ai-steward/knowledge-graph", tags=["AI Steward"]) router = APIRouter(prefix="/ai-steward/knowledge-graph", tags=["AI Steward"])
@ -44,6 +45,10 @@ class IncrementalCycleRequest(BaseModel):
code_scan_base_dir: Optional[str] = Field(default=None, max_length=400) code_scan_base_dir: Optional[str] = Field(default=None, max_length=400)
class VerifyContextConsumptionRequest(BaseModel):
run_canary: bool = Field(default=True)
def _require_manager(user: User) -> None: def _require_manager(user: User) -> None:
if not has_any_role(str(user.role or ""), ["BOSS", "ADMIN"]): if not has_any_role(str(user.role or ""), ["BOSS", "ADMIN"]):
raise HTTPException(status_code=403, detail="仅管理员可操作知识图谱同步") raise HTTPException(status_code=403, detail="仅管理员可操作知识图谱同步")
@ -93,6 +98,37 @@ async def get_sync_slo(
return success_response(data) return success_response(data)
@router.get("/capability-summary")
async def get_capability_summary(
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)
data = await ModelContextBundleService.build_capability_summary(
db,
tenant_id=effective_tenant_id,
)
return success_response(data)
@router.post("/verify-context-consumption")
async def verify_context_consumption(
body: Optional[VerifyContextConsumptionRequest] = None,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
tenant_id: int = Depends(get_tenant_id),
):
_require_manager(current_user)
effective_tenant_id = int(getattr(current_user, "tenant_id", tenant_id) or tenant_id)
data = await ModelContextBundleService.verify_context_consumption(
db,
tenant_id=effective_tenant_id,
run_canary=bool(body.run_canary) if body is not None else True,
)
return success_response(data)
@router.post("/sync/structured") @router.post("/sync/structured")
async def sync_structured( async def sync_structured(
body: StructuredSyncRequest, body: StructuredSyncRequest,

View File

@ -40,6 +40,10 @@ class LLMEngineTestRequest(BaseModel):
prompt: Optional[str] = Field(default=None, max_length=1000) prompt: Optional[str] = Field(default=None, max_length=1000)
class LLMEngineSettingsUpdateRequest(BaseModel):
enable_local_kb_qa: bool = False
def _require_manager(user: User) -> None: def _require_manager(user: User) -> None:
if not has_any_role(str(user.role or ""), ["BOSS", "ADMIN"]): if not has_any_role(str(user.role or ""), ["BOSS", "ADMIN"]):
raise HTTPException(status_code=403, detail="仅管理员可操作 LLM 引擎配置") raise HTTPException(status_code=403, detail="仅管理员可操作 LLM 引擎配置")
@ -81,6 +85,26 @@ async def create_llm_engine(
raise HTTPException(status_code=400, detail=str(exc)) from exc raise HTTPException(status_code=400, detail=str(exc)) from exc
@router.put("/settings")
async def update_llm_engine_settings(
body: LLMEngineSettingsUpdateRequest,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
tenant_id: int = Depends(get_tenant_id),
):
_require_manager(current_user)
try:
settings = await llm_service.update_engine_settings(
db,
tenant_id=tenant_id,
enable_local_kb_qa=body.enable_local_kb_qa,
)
all_items = await llm_service.list_engines(db, tenant_id=tenant_id)
return success_response({"settings": settings, **all_items})
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
@router.put("/{engine_id}") @router.put("/{engine_id}")
async def update_llm_engine( async def update_llm_engine(
engine_id: str, engine_id: str,

View File

@ -225,6 +225,9 @@ PROMPT_FEISHU_HUMANIZED_REPLY = """
4. 若信息不足,请明确缺失信息并给出 1-3 条可执行下一步。 4. 若信息不足,请明确缺失信息并给出 1-3 条可执行下一步。
5. 如果用户询问日期/时间/星期,必须基于“当前系统时间(北京时间)”直接回答,不得编造。 5. 如果用户询问日期/时间/星期,必须基于“当前系统时间(北京时间)”直接回答,不得编造。
6. 不要输出 Markdown,不要输出 JSON。 6. 不要输出 Markdown,不要输出 JSON。
7. 禁止宣称“已执行/将执行”任何未在上下文明确完成的动作。
8. 禁止输出数据库内部实施细节(如数据库迁移、DDL、SQL、ALTER TABLE、DROP TABLE、Alembic)。
9. 当 status/classification 无法证明写入动作已成功时,禁止编造处理步骤。
只输出最终回复正文。 只输出最终回复正文。
""".strip() """.strip()
@ -380,51 +383,6 @@ PROMPT_FEISHU_TASK_ORCHESTRATION = """
""" """
# ============================================================================
# OpenClaw 指挥层
# ============================================================================
PROMPT_OPENCLAW_COMMAND_DISPATCH = """
首字符必须是 {{,末字符必须是 }}。
你是 PMFlow 的 OpenClaw 指挥层。只做一件事:根据输入生成可执行 operations。
输入:
- command_json: {command_json}
- project_json: {project_json}
- tasks_json: {tasks_json}
- members_json: {members_json}
- operation_catalog_json: {operation_catalog_json}
只输出 JSON:
{{
"summary": "一句话总结本次指挥决策",
"confidence": 0.0-1.0,
"operations": [
{{
"operation_code": "project.create|project.update|project.archive|project.reopen|task.create|task.assign|task.prioritize|task.status_transition|notification.create|personnel.workload_update",
"payload": {{}},
"reason": "为什么执行这一步"
}}
]
}}
规则:
1. operation_code 只能来自 operation_catalog_json。
2. 总操作数 <= {max_operations}。
3. 不编造 task_id / user_id / project_id。
4. 若 project_json.scope=global,当前项目为空时可从 project_json.candidate_projects 里选择 project_id;需要新建项目时使用 project.create。
5. project.create 缺少 title 时不要生成该操作。
6. project.update / project.archive / project.reopen 只有在 project_id 明确时才能生成。
7. 信息不足时返回空 operations,并在 summary 说明阻塞原因。
8. reason 要短、直接、可执行。
9. confidence 必须是 number,不能是字符串。
10. 禁止输出 thinking、analysis、解释句、Markdown、代码块。
11. 字段未知时返回空字符串、空数组或 null。
12. 信息不足时也必须返回合法 JSON,operations=[]。
13. 不要 Markdown,不要解释,只返回 JSON。
"""
PROMPT_SIGNAL_SOURCE_ONBOARDING = """ PROMPT_SIGNAL_SOURCE_ONBOARDING = """
首字符必须是 {{,末字符必须是 }}。 首字符必须是 {{,末字符必须是 }}。
你是 PMFlow 的信号源接入规划器。判断当前请求是不是“新增/接入一个信号源”,并返回结构化计划。 你是 PMFlow 的信号源接入规划器。判断当前请求是不是“新增/接入一个信号源”,并返回结构化计划。
@ -446,7 +404,7 @@ PROMPT_SIGNAL_SOURCE_ONBOARDING = """
"config": {{}} "config": {{}}
}}, }},
"worker_roles": ["pm_dispatcher", "tech_architect", "delivery_coach"], "worker_roles": ["pm_dispatcher", "tech_architect", "delivery_coach"],
"openclaw_instruction": "交给 OpenClaw 的自然语言指令" "execution_instruction": "交给显式服务执行层的自然语言指令"
}} }}
若不是信号源接入请求,输出: 若不是信号源接入请求,输出:
@ -455,14 +413,14 @@ PROMPT_SIGNAL_SOURCE_ONBOARDING = """
"summary": "不是信号源接入请求的原因", "summary": "不是信号源接入请求的原因",
"source": null, "source": null,
"worker_roles": [], "worker_roles": [],
"openclaw_instruction": "" "execution_instruction": ""
}} }}
规则: 规则:
1. 只有明确新增/接入/配置信号源时才返回 should_onboard=true。 1. 只有明确新增/接入/配置信号源时才返回 should_onboard=true。
2. 不编造 url / chat_id / repo / webhook。 2. 不编造 url / chat_id / repo / webhook。
3. worker_roles 只允许 pm_dispatcher / tech_architect / delivery_coach。 3. worker_roles 只允许 pm_dispatcher / tech_architect / delivery_coach。
4. openclaw_instruction 保持简短,只说接入准备、任务拆解、负责人通知。 4. execution_instruction 保持简短,只说接入准备、任务拆解、负责人通知。
5. 无法确定 source_identifier 时,返回短且稳定的键,不要输出解释。 5. 无法确定 source_identifier 时,返回短且稳定的键,不要输出解释。
6. 禁止输出 thinking、analysis、解释句、Markdown、代码块。 6. 禁止输出 thinking、analysis、解释句、Markdown、代码块。
7. 字段未知时返回空字符串、空数组或 null。 7. 字段未知时返回空字符串、空数组或 null。
@ -510,26 +468,70 @@ PROMPT_AI_PMO_INTENT_CLASSIFICATION = """
- session_project_id: {session_project_id} - session_project_id: {session_project_id}
- world_model_project_id: {world_model_project_id} - world_model_project_id: {world_model_project_id}
- candidate_intents_json: {candidate_intents_json} - candidate_intents_json: {candidate_intents_json}
- candidate_routes_json: {candidate_routes_json}
输出 JSON: 输出 JSON:
{{ {{
"intent": "signal_source.onboard|signal.create|query.read_only|project.create|followup.confirm|followup.continue|ignore", "family": "query|project_create|project_signal|signal_source",
"action": "answer|create|discover|onboard",
"intent": "query.read_only|project.create|signal.create|signal_source.onboard|followup.continue",
"confidence": 0.0, "confidence": 0.0,
"project_id": 0, "project_id": 0,
"requested_count": 1,
"requires_confirmation": false,
"reasoning": "一句话简述判定原因" "reasoning": "一句话简述判定原因"
}} }}
规则: 规则:
1. intent 必须从 candidate_intents_json 内选择,不能自造。 1. family/action 必须和 candidate_routes_json 可映射,不能自造。
2. 兼容字段 intent 必须从 candidate_intents_json 内选择,不能自造。
3. 若 family=project_signal 且 action=discover,requires_confirmation 必须为 true。
4. requested_count 仅在 project_signal 路由有效,范围 1-5;无法判断时返回 1。
5. 若可直接回答,优先 family=query action=answer。
6. “直接新增信号”返回 family=project_signal action=create;“先分析候选让我确认”返回 family=project_signal action=discover。
7. “新增/接入信号源”返回 family=signal_source action=onboard。
8. “新增项目”返回 family=project_create action=create。
2. 若消息是短确认语气,优先结合 session_last_intent 判定。 2. 若消息是短确认语气,优先结合 session_last_intent 判定。
3. project_id 无法确定时返回 null,不要猜测无依据项目。 9. project_id 无法确定时返回 null,不要猜测无依据项目。
4. 噪音/寒暄/无关消息返回 ignore。 10. 噪音/寒暄/无关消息返回 family=query action=answer,intent=query.read_only,reasoning 包含 smalltalk_ignore。
5. 禁止输出 thinking、analysis、解释句、Markdown、代码块。 5. 禁止输出 thinking、analysis、解释句、Markdown、代码块。
6. 若消息表达“新增/增加/添加/新建/创建/建立 + 项目”(如“给我新增个项目”),优先判定 project.create;仅当明确是“信号源/来源/webhook/rss/github 接入或创建信号”时才判 signal_source.onboard 或 signal.create。 11. 若消息表达“新增/增加/添加/新建/创建/建立 + 项目”(如“给我新增个项目”),优先判定 project_create.create;仅当明确是“信号源/来源/webhook/rss/github 接入或创建信号”时才判 signal_source.onboard 或 project_signal.create。
7. 只输出 JSON,不要额外文本。 12. 只输出 JSON,不要额外文本。
13. 若消息要求直接执行数据库 DDL(建表/改表/删表等),禁止判定为可自动写入意图,必须返回 family=query action=answer,intent=query.read_only,并在 reasoning 包含 ddl_api_required。
""" """
PROMPT_AI_PMO_SIGNAL_BATCH_ANALYSIS = """
首字符必须是 {{,末字符必须是 }}。
你是 PMFlow 的高级信号分析器。请把用户指令拆成可入库的信号列表。
输入:
- message_text: {message_text}
- project_id: {project_id}
- requested_count: {requested_count}
输出 JSON(只输出 JSON):
{{
"signals": [
{{
"title": "信号标题(8-30字)",
"summary": "信号摘要(20-120字)",
"content": "信号详情(40-300字)",
"signal_type": "新需求|项目风险|竞品动态|用户反馈",
"priority": "high|medium|low",
"reasoning": ["依据1", "依据2"]
}}
]
}}
规则:
1. signals 数量必须等于 requested_count。
2. 标题和摘要禁止原样照抄整句用户指令。
3. 当信息不足时,允许基于指令语义做合理拆分,但必须给出可执行、可入库的信号文本。
4. 不要输出 Markdown,不要解释,不要代码块。
""".strip()
# ============================================================================ # ============================================================================
# Full Adaptive RAG # Full Adaptive RAG
# ============================================================================ # ============================================================================

View File

@ -14,9 +14,16 @@ BACKEND_DIR = Path(__file__).resolve().parents[2]
PROJECT_ROOT = BACKEND_DIR.parent PROJECT_ROOT = BACKEND_DIR.parent
load_dotenv(PROJECT_ROOT / ".env", override=False) load_dotenv(PROJECT_ROOT / ".env", override=False)
# 默认数据库固定到 backend/neural_sync.db,避免相对路径导致多库混用 # 默认数据库固定到 Docker PostgreSQL,不再提供 SQLite 回落
DEFAULT_SQLITE_DB = BACKEND_DIR / "neural_sync.db" DEFAULT_DB_HOST = os.getenv("DB_HOST", "127.0.0.1")
DEFAULT_DATABASE_URL = f"sqlite+aiosqlite:///{DEFAULT_SQLITE_DB.as_posix()}" DEFAULT_DB_PORT = os.getenv("DB_PORT", "5435")
DEFAULT_DB_NAME = os.getenv("POSTGRES_DB", "pmflow")
DEFAULT_DB_USER = os.getenv("POSTGRES_USER", "pmflow")
DEFAULT_DB_PASSWORD = os.getenv("POSTGRES_PASSWORD", "pmflow_pass")
DEFAULT_DATABASE_URL = (
f"postgresql+asyncpg://{DEFAULT_DB_USER}:{DEFAULT_DB_PASSWORD}"
f"@{DEFAULT_DB_HOST}:{DEFAULT_DB_PORT}/{DEFAULT_DB_NAME}"
)
class Settings: class Settings:

View File

@ -15,7 +15,6 @@ from sqlalchemy.ext.asyncio import (
create_async_engine, create_async_engine,
) )
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from sqlalchemy.pool import NullPool
from .core.config import settings from .core.config import settings
from .core.logging import get_logger from .core.logging import get_logger
@ -44,24 +43,21 @@ DDL_PREFIXES = (
) )
def _is_sqlite() -> bool: def _is_postgresql() -> bool:
return DATABASE_URL.lower().startswith("sqlite") normalized = str(DATABASE_URL or "").strip().lower()
return normalized.startswith("postgresql") or normalized.startswith("postgres")
if not _is_postgresql():
raise RuntimeError(
"仅支持 PostgreSQL 运行时连接,请设置 DATABASE_URL=postgresql+asyncpg://..."
)
engine_kwargs = { engine_kwargs = {
"echo": settings.debug, "echo": settings.debug,
"future": True, "future": True,
"pool_pre_ping": not _is_sqlite(), "pool_pre_ping": True,
}
if _is_sqlite():
engine_kwargs["connect_args"] = {
"timeout": 30,
"check_same_thread": False,
}
engine_kwargs["poolclass"] = NullPool
if not _is_sqlite():
engine_kwargs.update(
{
"pool_size": max(5, int(settings.db_pool_size)), "pool_size": max(5, int(settings.db_pool_size)),
"max_overflow": max(0, int(settings.db_max_overflow)), "max_overflow": max(0, int(settings.db_max_overflow)),
"pool_timeout": max(5, int(settings.db_pool_timeout)), "pool_timeout": max(5, int(settings.db_pool_timeout)),
@ -69,24 +65,12 @@ if not _is_sqlite():
# 强制数据库会话时区为北京时间,确保 now()/CURRENT_TIMESTAMP 为 Asia/Shanghai。 # 强制数据库会话时区为北京时间,确保 now()/CURRENT_TIMESTAMP 为 Asia/Shanghai。
"connect_args": {"server_settings": {"timezone": "Asia/Shanghai"}}, "connect_args": {"server_settings": {"timezone": "Asia/Shanghai"}},
} }
)
engine: AsyncEngine = create_async_engine( engine: AsyncEngine = create_async_engine(
DATABASE_URL, DATABASE_URL,
**engine_kwargs, **engine_kwargs,
) )
if _is_sqlite():
@event.listens_for(engine.sync_engine, "connect")
def _configure_sqlite_pragmas(dbapi_connection, _connection_record) -> None: # pragma: no cover
cursor = dbapi_connection.cursor()
try:
cursor.execute("PRAGMA journal_mode=WAL")
cursor.execute("PRAGMA synchronous=NORMAL")
cursor.execute("PRAGMA busy_timeout=30000")
finally:
cursor.close()
def _strip_sql_leading_comments(statement: str) -> str: def _strip_sql_leading_comments(statement: str) -> str:
text = str(statement or "") text = str(statement or "")

View File

@ -48,65 +48,12 @@ def _is_missing_table_error(exc: Exception) -> bool:
) )
def _sqlite_backfill_columns(sync_conn) -> None:
"""为历史 SQLite 库补齐新增列,避免运行时 500。"""
dialect_name = str(sync_conn.dialect.name or "").lower()
if dialect_name != "sqlite":
return
def _columns(table_name: str) -> set[str]:
try:
rows = sync_conn.exec_driver_sql(f"PRAGMA table_info({table_name})").fetchall()
return {str(row[1]) for row in rows}
except Exception:
return set()
def _add_column(table_name: str, column_name: str, column_ddl: str) -> None:
cols = _columns(table_name)
if not cols or column_name in cols:
return
sync_conn.exec_driver_sql(
f"ALTER TABLE {table_name} ADD COLUMN {column_name} {column_ddl}"
)
# truth_nodes
_add_column("truth_nodes", "signal_id", "BIGINT")
# signals
_add_column("signals", "feishu_item_id", "BIGINT")
_add_column("signals", "matched_project_id", "BIGINT")
_add_column("signals", "title", "VARCHAR(200)")
_add_column("signals", "signal_type", "VARCHAR(50)")
_add_column("signals", "summary", "TEXT")
_add_column("signals", "reasoning", "TEXT")
_add_column("signals", "confidence", "FLOAT")
_add_column("signals", "recommended_action", "VARCHAR(50)")
_add_column("signals", "status", "VARCHAR(50) NOT NULL DEFAULT '待处理'")
# qa_records
_add_column("qa_records", "session_id", "INTEGER")
_add_column("qa_records", "turn_index", "INTEGER NOT NULL DEFAULT 1")
# ai_processing_logs
_add_column("ai_processing_logs", "normalized_text", "TEXT")
_add_column("ai_processing_logs", "route", "VARCHAR(80)")
_add_column("ai_processing_logs", "intent", "VARCHAR(40)")
_add_column("ai_processing_logs", "executor", "VARCHAR(40)")
_add_column("ai_processing_logs", "operation_code", "VARCHAR(80)")
_add_column("ai_processing_logs", "operation_result", "JSON")
_add_column("ai_processing_logs", "reply_source", "VARCHAR(40)")
_add_column("feishu_reply_outbox", "source_message_id", "VARCHAR(128)")
_add_column("feishu_reply_outbox", "reply_hash", "VARCHAR(32)")
def _normalize_dialect(value: str) -> str: def _normalize_dialect(value: str) -> str:
raw = str(value or "").strip().lower() raw = str(value or "").strip().lower()
if not raw: if not raw:
return "" return ""
if raw.startswith("postgres"): if raw.startswith("postgres"):
return "postgresql" return "postgresql"
if raw.startswith("sqlite"):
return "sqlite"
if raw.startswith("mysql"): if raw.startswith("mysql"):
return "mysql" return "mysql"
if "+" in raw: if "+" in raw:
@ -131,7 +78,7 @@ def _mask_db_url(url: str) -> str:
def _check_database_dialect_guard(sync_conn) -> None: def _check_database_dialect_guard(sync_conn) -> None:
configured = _normalize_dialect(settings.database_url) configured = _normalize_dialect(settings.database_url)
actual = _normalize_dialect(sync_conn.dialect.name) actual = _normalize_dialect(sync_conn.dialect.name)
expected = _normalize_dialect(os.getenv("PMFLOW_EXPECTED_DB_DIALECT", "")) expected = _normalize_dialect(os.getenv("PMFLOW_EXPECTED_DB_DIALECT", "postgresql")) or "postgresql"
env_urls = [ env_urls = [
os.getenv("DATABASE_URL", ""), os.getenv("DATABASE_URL", ""),
@ -153,6 +100,10 @@ def _check_database_dialect_guard(sync_conn) -> None:
f"configured={configured}, actual={actual}, expected={expected or 'unset'}, " f"configured={configured}, actual={actual}, expected={expected or 'unset'}, "
f"url={_mask_db_url(settings.database_url)}" f"url={_mask_db_url(settings.database_url)}"
) )
if configured != "postgresql":
raise RuntimeError(f"DATABASE_URL 必须为 PostgreSQL,当前 configured={configured or 'unset'}。")
if actual != "postgresql":
raise RuntimeError(f"运行时数据库必须为 PostgreSQL,当前 actual={actual or 'unset'}。")
if configured and actual and configured != actual: if configured and actual and configured != actual:
raise RuntimeError( raise RuntimeError(
f"数据库方言不一致: configured={configured}, actual={actual}。" f"数据库方言不一致: configured={configured}, actual={actual}。"
@ -165,186 +116,7 @@ def _check_database_dialect_guard(sync_conn) -> None:
if len(configured_families) > 1: if len(configured_families) > 1:
raise RuntimeError( raise RuntimeError(
f"检测到多种数据库方言配置: {sorted(configured_families)}。" f"检测到多种数据库方言配置: {sorted(configured_families)}。"
"禁止同一服务混用 SQLite/Postgres。" "禁止同一服务混用多数据库方言。"
)
def _ensure_feishu_integrity_guards(sync_conn) -> None:
dialect = _normalize_dialect(sync_conn.dialect.name)
if dialect not in {"postgresql", "sqlite"}:
return
# Outbox 表兜底,兼容未跑 Alembic 的环境。
outbox_meta = sa.MetaData()
outbox_table = sa.Table(
"feishu_reply_outbox",
outbox_meta,
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
sa.Column("tenant_id", sa.BigInteger(), nullable=False, server_default=sa.text("1")),
sa.Column("created_at", sa.TIMESTAMP(), nullable=False, server_default=sa.func.now()),
sa.Column("updated_at", sa.TIMESTAMP(), nullable=False, server_default=sa.func.now(), onupdate=sa.func.now()),
sa.Column("trace_id", sa.String(length=64), nullable=True),
sa.Column("source_message_id", sa.String(length=128), nullable=True),
sa.Column("chat_id", sa.String(length=128), nullable=False),
sa.Column("message_text", sa.Text(), nullable=False),
sa.Column("reply_hash", sa.String(length=32), nullable=True),
sa.Column("status", sa.String(length=20), nullable=False, server_default=sa.text("'pending'")),
sa.Column("attempts", sa.Integer(), nullable=False, server_default=sa.text("0")),
sa.Column("next_retry_at", sa.TIMESTAMP(), nullable=True),
sa.Column("sent_at", sa.TIMESTAMP(), nullable=True),
sa.Column("last_error", sa.Text(), nullable=True),
)
outbox_table.create(sync_conn, checkfirst=True)
outbox_columns = {col["name"] for col in sa.inspect(sync_conn).get_columns("feishu_reply_outbox")}
if "source_message_id" not in outbox_columns:
sync_conn.exec_driver_sql("ALTER TABLE feishu_reply_outbox ADD COLUMN source_message_id VARCHAR(128)")
if "reply_hash" not in outbox_columns:
sync_conn.exec_driver_sql("ALTER TABLE feishu_reply_outbox ADD COLUMN reply_hash VARCHAR(32)")
sync_conn.exec_driver_sql(
"CREATE INDEX IF NOT EXISTS ix_feishu_reply_outbox_tenant_id ON feishu_reply_outbox (tenant_id)"
)
sync_conn.exec_driver_sql(
"CREATE INDEX IF NOT EXISTS ix_feishu_reply_outbox_trace_id ON feishu_reply_outbox (trace_id)"
)
sync_conn.exec_driver_sql(
"CREATE INDEX IF NOT EXISTS ix_feishu_reply_outbox_source_message_id ON feishu_reply_outbox (source_message_id)"
)
sync_conn.exec_driver_sql(
"CREATE INDEX IF NOT EXISTS ix_feishu_reply_outbox_chat_id ON feishu_reply_outbox (chat_id)"
)
sync_conn.exec_driver_sql(
"CREATE INDEX IF NOT EXISTS ix_feishu_reply_outbox_reply_hash ON feishu_reply_outbox (reply_hash)"
)
sync_conn.exec_driver_sql(
"CREATE INDEX IF NOT EXISTS ix_feishu_reply_outbox_status ON feishu_reply_outbox (status)"
)
sync_conn.exec_driver_sql(
"CREATE INDEX IF NOT EXISTS ix_feishu_reply_outbox_next_retry_at ON feishu_reply_outbox (next_retry_at)"
)
sync_conn.exec_driver_sql(
"CREATE UNIQUE INDEX IF NOT EXISTS uq_feishu_reply_outbox_sent_reply_hash "
"ON feishu_reply_outbox (tenant_id, reply_hash) "
"WHERE status = 'sent' AND reply_hash IS NOT NULL"
)
inspector = sa.inspect(sync_conn)
if not inspector.has_table("feishu_items"):
return
duplicate_groups = sync_conn.execute(
sa.text(
"""
SELECT tenant_id, feishu_message_id, split_from_item_id, MIN(id) AS keep_id
FROM feishu_items
WHERE feishu_message_id IS NOT NULL
GROUP BY tenant_id, feishu_message_id, split_from_item_id
HAVING COUNT(*) > 1
"""
)
).fetchall()
table_exists = {
"signals": inspector.has_table("signals"),
"project_suggestions": inspector.has_table("project_suggestions"),
"ai_processing_logs": inspector.has_table("ai_processing_logs"),
"feishu_items": True,
}
for tenant_id, message_id, split_from_item_id, keep_id in duplicate_groups:
if split_from_item_id is None:
duplicate_ids = sync_conn.execute(
sa.text(
"""
SELECT id
FROM feishu_items
WHERE tenant_id = :tenant_id
AND feishu_message_id = :message_id
AND split_from_item_id IS NULL
AND id <> :keep_id
ORDER BY id ASC
"""
),
{
"tenant_id": int(tenant_id),
"message_id": str(message_id),
"keep_id": int(keep_id),
},
).fetchall()
else:
duplicate_ids = sync_conn.execute(
sa.text(
"""
SELECT id
FROM feishu_items
WHERE tenant_id = :tenant_id
AND feishu_message_id = :message_id
AND split_from_item_id = :split_from_item_id
AND id <> :keep_id
ORDER BY id ASC
"""
),
{
"tenant_id": int(tenant_id),
"message_id": str(message_id),
"split_from_item_id": int(split_from_item_id),
"keep_id": int(keep_id),
},
).fetchall()
for (duplicate_id,) in duplicate_ids:
duplicate_id = int(duplicate_id)
canonical_id = int(keep_id)
if table_exists["feishu_items"]:
sync_conn.execute(
sa.text(
"UPDATE feishu_items SET split_from_item_id = :new_id "
"WHERE split_from_item_id = :old_id"
),
{"new_id": canonical_id, "old_id": duplicate_id},
)
if table_exists["signals"]:
sync_conn.execute(
sa.text(
"UPDATE signals SET feishu_item_id = :new_id "
"WHERE feishu_item_id = :old_id"
),
{"new_id": canonical_id, "old_id": duplicate_id},
)
if table_exists["project_suggestions"]:
sync_conn.execute(
sa.text(
"UPDATE project_suggestions SET feishu_item_id = :new_id "
"WHERE feishu_item_id = :old_id"
),
{"new_id": canonical_id, "old_id": duplicate_id},
)
if table_exists["ai_processing_logs"]:
sync_conn.execute(
sa.text(
"UPDATE ai_processing_logs SET feishu_item_id = :new_id "
"WHERE feishu_item_id = :old_id"
),
{"new_id": canonical_id, "old_id": duplicate_id},
)
sync_conn.execute(
sa.text("DELETE FROM feishu_items WHERE id = :id"),
{"id": duplicate_id},
)
# 幂等唯一约束(含 split 维度)硬化;若存在重复历史数据将直接失败,避免静默错乱。
sync_conn.exec_driver_sql(
"""
CREATE UNIQUE INDEX IF NOT EXISTS uq_feishu_items_tenant_message_root
ON feishu_items (tenant_id, feishu_message_id)
WHERE split_from_item_id IS NULL AND feishu_message_id IS NOT NULL
"""
)
sync_conn.exec_driver_sql(
"""
CREATE UNIQUE INDEX IF NOT EXISTS uq_feishu_items_tenant_message_split
ON feishu_items (tenant_id, feishu_message_id, split_from_item_id)
WHERE split_from_item_id IS NOT NULL AND feishu_message_id IS NOT NULL
"""
) )

View File

@ -31,7 +31,6 @@ class FeishuItem(Base):
"feishu_message_id", "feishu_message_id",
unique=True, unique=True,
postgresql_where=text("split_from_item_id IS NULL AND feishu_message_id IS NOT NULL"), postgresql_where=text("split_from_item_id IS NULL AND feishu_message_id IS NOT NULL"),
sqlite_where=text("split_from_item_id IS NULL AND feishu_message_id IS NOT NULL"),
), ),
Index( Index(
"uq_feishu_items_tenant_message_split", "uq_feishu_items_tenant_message_split",
@ -40,7 +39,6 @@ class FeishuItem(Base):
"split_from_item_id", "split_from_item_id",
unique=True, unique=True,
postgresql_where=text("split_from_item_id IS NOT NULL AND feishu_message_id IS NOT NULL"), postgresql_where=text("split_from_item_id IS NOT NULL AND feishu_message_id IS NOT NULL"),
sqlite_where=text("split_from_item_id IS NOT NULL AND feishu_message_id IS NOT NULL"),
), ),
) )

View File

@ -1,18 +1,14 @@
"""PostgreSQL / SQLite 类型兼容层 """PostgreSQL 类型兼容层。"""
仅当 DATABASE_URL 明确为 PostgreSQL 时使用 JSONB/ARRAY/Vector,
否则使用 SQLite 兼容的 TypeDecorator(与 database.py 默认一致)。
"""
import os import os
import json import json
# 与 database.py 默认一致:未设置或 sqlite 时按 SQLite 处理 # 与 database.py 保持一致:默认即 PostgreSQL
_database_url = os.getenv("DATABASE_URL", "sqlite+aiosqlite:///./neural_sync.db").lower() _database_url = os.getenv("DATABASE_URL", "postgresql+asyncpg://pmflow:pmflow_pass@127.0.0.1:5435/pmflow").lower()
_use_pg = "postgresql" in _database_url or "postgres" in _database_url _use_pg = "postgresql" in _database_url or "postgres" in _database_url
try: try:
if not _use_pg: if not _use_pg:
raise ImportError("SQLite in use") raise ImportError("PostgreSQL not configured")
from sqlalchemy.dialects.postgresql import JSONB, ARRAY from sqlalchemy.dialects.postgresql import JSONB, ARRAY
from pgvector.sqlalchemy import Vector from pgvector.sqlalchemy import Vector
HAS_PG = True HAS_PG = True

View File

@ -8,11 +8,10 @@ Seed Projects and Signals
import asyncio import asyncio
from datetime import datetime from datetime import datetime
from typing import Optional from typing import Optional
from sqlalchemy import func, select, or_, text from sqlalchemy import func, select, or_
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import sessionmaker from sqlalchemy.orm import sessionmaker
from app.database import _is_sqlite
from app.models import Signal, Project, ProjectSignalLink, TruthNode, Task, User from app.models import Signal, Project, ProjectSignalLink, TruthNode, Task, User
from app.services.auth import AuthService from app.services.auth import AuthService
@ -105,16 +104,9 @@ async def _ensure_pixelle_sstc_data(session: AsyncSession, pixelle_project: Proj
{"task_name": "Design Video Workflow UI", "type": "frontend", "estimated_hours": 12.0}, {"task_name": "Design Video Workflow UI", "type": "frontend", "estimated_hours": 12.0},
{"task_name": "Optimize Video Pipeline", "type": "ai_engineer", "estimated_hours": 16.0}, {"task_name": "Optimize Video Pipeline", "type": "ai_engineer", "estimated_hours": 16.0},
]) ])
if _is_sqlite():
next_id = (await session.execute(text("SELECT COALESCE(MAX(id),0)+1 FROM signals"))).scalar() or 1
for i, s in enumerate(pixelle_signals):
s.id = next_id + i
for signal in pixelle_signals: for signal in pixelle_signals:
session.add(signal) session.add(signal)
await session.flush() await session.flush()
next_link_id = None
if _is_sqlite():
next_link_id = (await session.execute(text("SELECT COALESCE(MAX(id),0)+1 FROM project_signal_links"))).scalar() or 1
for i, signal in enumerate(pixelle_signals): for i, signal in enumerate(pixelle_signals):
link = ProjectSignalLink( link = ProjectSignalLink(
project_id=pid, project_id=pid,
@ -122,8 +114,6 @@ async def _ensure_pixelle_sstc_data(session: AsyncSession, pixelle_project: Proj
link_type="related", link_type="related",
relevance_score=1.0, relevance_score=1.0,
) )
if _is_sqlite() and next_link_id is not None:
link.id = next_link_id + i
session.add(link) session.add(link)
pixelle_project.signal_count = len(pixelle_signals) pixelle_project.signal_count = len(pixelle_signals)
changed = True changed = True
@ -164,13 +154,8 @@ async def _ensure_pixelle_sstc_data(session: AsyncSession, pixelle_project: Proj
version=1, version=1,
status="aligned", status="aligned",
) )
if _is_sqlite():
charter.id = (await session.execute(text("SELECT COALESCE(MAX(id),0)+1 FROM truth_nodes"))).scalar() or 1
session.add(charter) session.add(charter)
await session.flush() await session.flush()
next_task_id = None
if _is_sqlite():
next_task_id = (await session.execute(text("SELECT COALESCE(MAX(id),0)+1 FROM tasks"))).scalar() or 1
for idx, task_item in enumerate([ for idx, task_item in enumerate([
{"title": "Integrate Pixelle-Video API", "priority": "high", "hours": 8.0}, {"title": "Integrate Pixelle-Video API", "priority": "high", "hours": 8.0},
{"title": "Design Video Workflow UI", "priority": "medium", "hours": 12.0}, {"title": "Design Video Workflow UI", "priority": "medium", "hours": 12.0},
@ -185,8 +170,6 @@ async def _ensure_pixelle_sstc_data(session: AsyncSession, pixelle_project: Proj
priority=task_item["priority"], priority=task_item["priority"],
estimated_hours=task_item["hours"], estimated_hours=task_item["hours"],
) )
if _is_sqlite() and next_task_id is not None:
task.id = next_task_id + idx
session.add(task) session.add(task)
changed = True changed = True
print(f"[SSTC] 已为「{PIXELLE_NAME}」恢复宪章 1 个、任务 3 个") print(f"[SSTC] 已为「{PIXELLE_NAME}」恢复宪章 1 个、任务 3 个")

View File

@ -31,7 +31,7 @@ class AIOrchestrationRunService:
response_mode: str, response_mode: str,
status: str, status: str,
planner_status: Optional[str], planner_status: Optional[str],
openclaw_status: Optional[str], execution_status: Optional[str],
worker_total: int, worker_total: int,
worker_completed: int, worker_completed: int,
worker_failed: int, worker_failed: int,
@ -51,7 +51,7 @@ class AIOrchestrationRunService:
response_mode=response_mode, response_mode=response_mode,
status=status, status=status,
planner_status=planner_status, planner_status=planner_status,
openclaw_status=openclaw_status, openclaw_status=execution_status,
worker_total=max(0, int(worker_total)), worker_total=max(0, int(worker_total)),
worker_completed=max(0, int(worker_completed)), worker_completed=max(0, int(worker_completed)),
worker_failed=max(0, int(worker_failed)), worker_failed=max(0, int(worker_failed)),
@ -85,7 +85,7 @@ class AIOrchestrationRunService:
trace_id: str, trace_id: str,
status: Optional[str] = None, status: Optional[str] = None,
planner_status: Optional[str] = None, planner_status: Optional[str] = None,
openclaw_status: Optional[str] = None, execution_status: Optional[str] = None,
worker_total: Optional[int] = None, worker_total: Optional[int] = None,
worker_completed: Optional[int] = None, worker_completed: Optional[int] = None,
worker_failed: Optional[int] = None, worker_failed: Optional[int] = None,
@ -101,8 +101,8 @@ class AIOrchestrationRunService:
row.status = str(status or "").strip()[:20] row.status = str(status or "").strip()[:20]
if planner_status is not None: if planner_status is not None:
row.planner_status = str(planner_status or "").strip()[:20] or None row.planner_status = str(planner_status or "").strip()[:20] or None
if openclaw_status is not None: if execution_status is not None:
row.openclaw_status = str(openclaw_status or "").strip()[:20] or None row.openclaw_status = str(execution_status or "").strip()[:20] or None
if worker_total is not None: if worker_total is not None:
row.worker_total = max(0, int(worker_total)) row.worker_total = max(0, int(worker_total))
if worker_completed is not None: if worker_completed is not None:
@ -165,7 +165,7 @@ class AIOrchestrationRunService:
"response_mode": str(row.response_mode or ""), "response_mode": str(row.response_mode or ""),
"run_status": str(row.status or ""), "run_status": str(row.status or ""),
"planner_status": str(row.planner_status or ""), "planner_status": str(row.planner_status or ""),
"openclaw_status": str(row.openclaw_status or ""), "execution_status": str(row.openclaw_status or ""),
"worker_total": worker_total, "worker_total": worker_total,
"worker_completed": worker_completed, "worker_completed": worker_completed,
"worker_failed": worker_failed, "worker_failed": worker_failed,

View File

@ -0,0 +1,355 @@
"""AI PMO decision brains (strategic/tactical/simulation/policy)."""
from __future__ import annotations
import re
from typing import Any
from .ai_pmo_intent_routing import (
INTENT_FOLLOWUP_CONFIRM,
INTENT_FOLLOWUP_CONTINUE,
INTENT_PROJECT_CREATE,
INTENT_QUERY,
INTENT_SIGNAL_CREATE,
INTENT_SIGNAL_SOURCE_ONBOARD,
ROUTE_PROJECT_SIGNAL_CANCEL,
ROUTE_PROJECT_SIGNAL_COMMIT,
ROUTE_PROJECT_SIGNAL_DISCOVER,
ROUTE_PROJECT_SIGNAL_REFRESH,
CanonicalIntent,
)
def _normalize_workload_ratio(value: Any) -> float:
try:
ratio = float(value or 0.0)
except (TypeError, ValueError):
return 0.0
if ratio <= 0:
return 0.0
return ratio / 100.0 if ratio > 1.0 else ratio
class StrategicBrain:
"""组合级判断。"""
@staticmethod
def evaluate(*, intent: CanonicalIntent, world_model: dict[str, Any], message_text: str) -> dict[str, Any]:
project = world_model.get("project") if isinstance(world_model.get("project"), dict) else None
budget = world_model.get("budget") if isinstance(world_model.get("budget"), dict) else {}
capacity = world_model.get("capacity") if isinstance(world_model.get("capacity"), list) else []
risk_count = int((world_model.get("risk_summary") or {}).get("open_risk_count") or 0)
active_projects = int((world_model.get("portfolio") or {}).get("active_project_count") or 0)
budget_ratio = float(budget.get("usage_ratio") or 0.0)
overloaded_people = [row for row in capacity if _normalize_workload_ratio(row.get("workload_ratio")) >= 0.85]
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_SIGNAL_SOURCE_ONBOARD:
risk_score = max(risk_score, 0.58)
return {
"decision_level": "project" if project else "portfolio",
"decision_object_type": "project" if project else "portfolio",
"decision_object_id": _safe_int((project or {}).get("id")),
"risk_score": round(risk_score, 3),
"budget_impact": {
"budget_id": budget.get("budget_id"),
"usage_ratio": budget_ratio,
"hard_limit_enabled": bool(budget.get("hard_limit_enabled")),
},
"resource_impact": {
"overloaded_user_ids": [row.get("user_id") for row in overloaded_people],
"active_project_count": active_projects,
},
"rationale": (
f"intent={intent.name}; "
f"project_scope={'project' if project else 'portfolio'}; "
f"budget_ratio={budget_ratio:.2f}; "
f"open_risks={risk_count}; "
f"overloaded={len(overloaded_people)}"
),
"message_excerpt": message_text[:240],
}
class TacticalBrain:
"""周/日执行计划。"""
@staticmethod
def plan(*, intent: CanonicalIntent, world_model: dict[str, Any], decision: dict[str, Any]) -> tuple[dict[str, Any], dict[str, Any]]:
project = world_model.get("project") if isinstance(world_model.get("project"), dict) else None
project_ref = f"项目#{project['id']}" if project and project.get("id") is not None else "项目群"
strategic_plan = {
"priority": "high" if float(decision.get("risk_score") or 0.0) >= 0.6 else "medium",
"objectives": [
f"围绕 {project_ref} 控制风险与节奏",
"优先保证关键路径和资源边界不被破坏",
],
"signals": [
f"budget_usage_ratio={float((decision.get('budget_impact') or {}).get('usage_ratio') or 0.0):.2f}",
f"overloaded_users={len((decision.get('resource_impact') or {}).get('overloaded_user_ids') or [])}",
],
}
execution_actions = []
workflow_route = str((intent.metadata or {}).get("workflow_route") or "").strip().lower()
if intent.name == INTENT_QUERY:
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_SIGNAL_SOURCE_ONBOARD:
execution_actions.append({"operation_code": "signal.enter", "mode": "write"})
execution_actions.append({"operation_code": "signal_source.onboard", "mode": "write"})
elif intent.name in {INTENT_FOLLOWUP_CONFIRM, INTENT_FOLLOWUP_CONTINUE}:
execution_actions.append({"operation_code": "work_item.resume", "mode": "write"})
elif intent.name == INTENT_SIGNAL_CREATE:
if workflow_route == ROUTE_PROJECT_SIGNAL_DISCOVER:
execution_actions.append({"operation_code": ROUTE_PROJECT_SIGNAL_DISCOVER, "mode": "write"})
elif workflow_route == ROUTE_PROJECT_SIGNAL_COMMIT:
execution_actions.append({"operation_code": ROUTE_PROJECT_SIGNAL_COMMIT, "mode": "write"})
elif workflow_route == ROUTE_PROJECT_SIGNAL_REFRESH:
execution_actions.append({"operation_code": ROUTE_PROJECT_SIGNAL_REFRESH, "mode": "write"})
elif workflow_route == ROUTE_PROJECT_SIGNAL_CANCEL:
execution_actions.append({"operation_code": ROUTE_PROJECT_SIGNAL_CANCEL, "mode": "write"})
else:
execution_actions.append({"operation_code": "signal.enter", "mode": "write"})
execution_plan = {
"intent": intent.name,
"project_id": intent.project_id,
"actions": execution_actions,
"summary": f"执行 {intent.name},共 {len(execution_actions)} 个 typed actions",
}
return strategic_plan, execution_plan
class SimulationEngine:
"""执行前仿真。"""
BUDGET_COST_BY_OPERATION = {
"query.answer": 1.0,
"project.create": 120.0,
"signal.enter": 8.0,
"signal_source.onboard": 40.0,
ROUTE_PROJECT_SIGNAL_DISCOVER: 3.0,
ROUTE_PROJECT_SIGNAL_COMMIT: 8.0,
ROUTE_PROJECT_SIGNAL_REFRESH: 3.0,
ROUTE_PROJECT_SIGNAL_CANCEL: 0.2,
}
HOURS_BY_OPERATION = {
"query.answer": 0.2,
"project.create": 12.0,
"signal.enter": 1.5,
"signal_source.onboard": 6.0,
ROUTE_PROJECT_SIGNAL_DISCOVER: 0.8,
ROUTE_PROJECT_SIGNAL_COMMIT: 1.5,
ROUTE_PROJECT_SIGNAL_REFRESH: 0.8,
ROUTE_PROJECT_SIGNAL_CANCEL: 0.1,
}
@classmethod
def simulate(cls, *, intent: CanonicalIntent, world_model: dict[str, Any], execution_plan: dict[str, Any]) -> dict[str, Any]:
actions = execution_plan.get("actions") if isinstance(execution_plan.get("actions"), list) else []
operation_codes = [str(item.get("operation_code") or "").strip() for item in actions if isinstance(item, dict)]
estimated_budget_delta = sum(cls.BUDGET_COST_BY_OPERATION.get(code, 0.0) for code in operation_codes)
estimated_capacity_delta = sum(cls.HOURS_BY_OPERATION.get(code, 0.0) for code in operation_codes)
budget = world_model.get("budget") if isinstance(world_model.get("budget"), dict) else {}
budget_amount = float(budget.get("budget_amount_usd") or 0.0)
current_ratio = float(budget.get("usage_ratio") or 0.0)
projected_budget_ratio = current_ratio + (estimated_budget_delta / budget_amount if budget_amount > 0 else 0.0)
capacity = world_model.get("capacity") if isinstance(world_model.get("capacity"), list) else []
current_peak_ratio = max([_normalize_workload_ratio(item.get("workload_ratio")) for item in capacity], default=0.0)
project_member_count = max(1, int(world_model.get("member_count") or len(capacity) or 1))
projected_capacity_peak_ratio = current_peak_ratio + (estimated_capacity_delta / (40.0 * project_member_count))
task_summary = world_model.get("task_summary") if isinstance(world_model.get("task_summary"), dict) else {}
blocked_count = int((task_summary.get("by_status") or {}).get("blocked") or 0)
overdue_count = int(task_summary.get("overdue_count") or 0)
missing_skill_tag_ids = task_summary.get("uncovered_skill_tag_ids") if isinstance(task_summary.get("uncovered_skill_tag_ids"), list) else []
charter = world_model.get("charter") if isinstance(world_model.get("charter"), list) else []
dependency_pressure = "high" if blocked_count > 0 or overdue_count > 0 else ("medium" if not charter else "low")
projected_risk_score = min(
1.0,
0.12
+ projected_budget_ratio * 0.24
+ projected_capacity_peak_ratio * 0.28
+ blocked_count * 0.08
+ overdue_count * 0.05
+ len(missing_skill_tag_ids) * 0.07,
)
return {
"intent": intent.name,
"operation_codes": operation_codes,
"estimated_budget_delta_usd": round(estimated_budget_delta, 2),
"projected_budget_usage_ratio": round(projected_budget_ratio, 4),
"estimated_capacity_delta_hours": round(estimated_capacity_delta, 2),
"projected_capacity_peak_ratio": round(projected_capacity_peak_ratio, 4),
"blocked_task_count": blocked_count,
"overdue_task_count": overdue_count,
"dependency_pressure": dependency_pressure,
"missing_skill_tag_ids": missing_skill_tag_ids[:20],
"missing_skill_count": len(missing_skill_tag_ids),
"projected_risk_score": round(projected_risk_score, 4),
}
class ExecutionPolicyBrain:
"""自动执行边界。"""
def __init__(self, *, policy_gate_rules: tuple[tuple[str, tuple[str, ...]], ...]) -> None:
self.policy_gate_rules = policy_gate_rules
def evaluate(
self,
*,
message_text: str,
world_model: dict[str, Any],
intent: CanonicalIntent,
simulation: dict[str, Any],
) -> dict[str, Any]:
normalized = re.sub(r"\s+", "", str(message_text or "")).lower()
for gate_code, tokens in self.policy_gate_rules:
if any(token.lower().replace(" ", "") in normalized for token in tokens):
return {
"auto_execute": False,
"policy_gate_hit": True,
"policy_gate_code": gate_code,
"approval_requirement": {
"required": True,
"reason": gate_code,
"message": "命中硬策略边界,必须进入显式审批/人工确认。",
},
}
if intent.name == INTENT_SIGNAL_SOURCE_ONBOARD and intent.project_id is None:
return {
"auto_execute": False,
"policy_gate_hit": True,
"policy_gate_code": "missing_project_scope",
"approval_requirement": {
"required": True,
"reason": "missing_project_scope",
"message": "信号源接入缺少项目归属,必须先确认项目。",
},
}
if intent.name in {INTENT_QUERY, INTENT_SIGNAL_CREATE}:
return {
"auto_execute": True,
"policy_gate_hit": False,
"policy_gate_code": None,
"approval_requirement": {"required": False},
}
budget = world_model.get("budget") if isinstance(world_model.get("budget"), dict) else {}
if bool(budget.get("hard_limit_enabled")) and float(budget.get("usage_ratio") or 0.0) >= 1.0:
return {
"auto_execute": False,
"policy_gate_hit": True,
"policy_gate_code": "budget_hard_limit",
"approval_requirement": {
"required": True,
"reason": "budget_hard_limit",
"message": "项目预算已触达硬限制,自动执行被拦截。",
},
}
if float(simulation.get("projected_budget_usage_ratio") or 0.0) >= 1.0:
return {
"auto_execute": False,
"policy_gate_hit": True,
"policy_gate_code": "simulation_budget_limit",
"approval_requirement": {
"required": True,
"reason": "simulation_budget_limit",
"message": "执行前仿真显示预算将触顶,自动执行被拦截。",
},
}
if float(simulation.get("projected_capacity_peak_ratio") or 0.0) >= 1.0:
return {
"auto_execute": False,
"policy_gate_hit": True,
"policy_gate_code": "simulation_capacity_limit",
"approval_requirement": {
"required": True,
"reason": "simulation_capacity_limit",
"message": "执行前仿真显示产能会超载,自动执行被拦截。",
},
}
if intent.name == INTENT_SIGNAL_SOURCE_ONBOARD and int(simulation.get("missing_skill_count") or 0) > 0:
return {
"auto_execute": False,
"policy_gate_hit": True,
"policy_gate_code": "simulation_skill_gap",
"approval_requirement": {
"required": True,
"reason": "simulation_skill_gap",
"message": "执行前仿真显示当前项目存在技能缺口,信号源接入先进入待确认。",
},
}
if intent.name in {INTENT_SIGNAL_SOURCE_ONBOARD, INTENT_SIGNAL_CREATE} and str(simulation.get("dependency_pressure") or "") == "high":
return {
"auto_execute": False,
"policy_gate_hit": True,
"policy_gate_code": "simulation_dependency_pressure",
"approval_requirement": {
"required": True,
"reason": "simulation_dependency_pressure",
"message": "执行前仿真显示关键依赖压力过高,当前先进入待人工确认。",
},
}
return {
"auto_execute": True,
"policy_gate_hit": False,
"policy_gate_code": None,
"approval_requirement": {"required": False},
}
class DecisionEngine:
"""汇总三脑输出。"""
def __init__(self, *, policy_gate_rules: tuple[tuple[str, tuple[str, ...]], ...]) -> None:
self.strategic_brain = StrategicBrain()
self.tactical_brain = TacticalBrain()
self.simulation_engine = SimulationEngine()
self.execution_policy_brain = ExecutionPolicyBrain(policy_gate_rules=policy_gate_rules)
def decide(self, *, intent: CanonicalIntent, world_model: dict[str, Any], message_text: str) -> dict[str, Any]:
strategic = self.strategic_brain.evaluate(intent=intent, world_model=world_model, message_text=message_text)
strategic_plan, execution_plan = self.tactical_brain.plan(intent=intent, world_model=world_model, decision=strategic)
simulation = self.simulation_engine.simulate(intent=intent, world_model=world_model, execution_plan=execution_plan)
strategic_plan["simulation"] = simulation
execution_plan["simulation"] = simulation
policy = self.execution_policy_brain.evaluate(
message_text=message_text,
world_model=world_model,
intent=intent,
simulation=simulation,
)
strategic["risk_score"] = max(float(strategic.get("risk_score") or 0.0), float(simulation.get("projected_risk_score") or 0.0))
return {
"strategic": strategic,
"strategic_plan": strategic_plan,
"execution_plan": execution_plan,
"simulation": simulation,
"policy": policy,
}
def _safe_int(value: Any) -> int | None:
try:
if value is None or value == "":
return None
return int(value)
except (TypeError, ValueError):
return None

View File

@ -44,7 +44,8 @@ class SignalSourceDomainWorkflow:
payload = raw_result if isinstance(raw_result, dict) else {} payload = raw_result if isinstance(raw_result, dict) else {}
signal_source = payload.get("signal_source") if isinstance(payload.get("signal_source"), dict) else {} signal_source = payload.get("signal_source") if isinstance(payload.get("signal_source"), dict) else {}
worker_executions = payload.get("worker_executions") if isinstance(payload.get("worker_executions"), list) else [] worker_executions = payload.get("worker_executions") if isinstance(payload.get("worker_executions"), list) else []
typed_actions = cls._extract_typed_actions(payload.get("openclaw")) execution_payload = payload.get("execution") if isinstance(payload.get("execution"), dict) else payload.get("openclaw")
typed_actions = cls._extract_typed_actions(execution_payload)
material_actions = [ material_actions = [
row row
for row in typed_actions for row in typed_actions
@ -56,9 +57,11 @@ class SignalSourceDomainWorkflow:
raw_status = str(payload.get("status") or "").strip().lower() raw_status = str(payload.get("status") or "").strip().lower()
registry_status = str(signal_source.get("status") or "").strip().lower() registry_status = str(signal_source.get("status") or "").strip().lower()
onboarding_status = str(signal_source.get("onboarding_status") or "").strip().lower() onboarding_status = str(signal_source.get("onboarding_status") or "").strip().lower()
openclaw_status = str((payload.get("openclaw") or {}).get("status") or "").strip().lower() execution_status = str(
((payload.get("execution") or payload.get("openclaw") or {}) if isinstance(payload, dict) else {}).get("status") or ""
).strip().lower()
if raw_status == "failed" or openclaw_status == "failed": if raw_status == "failed" or execution_status == "failed":
final_status = "failed_safe" final_status = "failed_safe"
elif onboarding_status != "completed": elif onboarding_status != "completed":
final_status = "pending_manual" final_status = "pending_manual"
@ -77,7 +80,7 @@ class SignalSourceDomainWorkflow:
"registry_id": signal_source.get("registry_id"), "registry_id": signal_source.get("registry_id"),
"registry_status": registry_status, "registry_status": registry_status,
"onboarding_status": onboarding_status, "onboarding_status": onboarding_status,
"openclaw_status": openclaw_status, "execution_status": execution_status,
"typed_actions": typed_actions, "typed_actions": typed_actions,
"material_action_count": len(material_actions), "material_action_count": len(material_actions),
"worker_executions": worker_executions, "worker_executions": worker_executions,

View File

@ -0,0 +1,676 @@
"""AI PMO intent/route constants and resolvers."""
from __future__ import annotations
import re
from dataclasses import dataclass
from typing import Any, Optional
from .project_command_utils import extract_project_create_title
from .signal_source_orchestration_service import SignalSourceOrchestrationService
INTENT_QUERY = "query.read_only"
INTENT_PROJECT_CREATE = "project.create"
INTENT_SIGNAL_CREATE = "signal.create"
INTENT_SIGNAL_SOURCE_ONBOARD = "signal_source.onboard"
INTENT_FOLLOWUP_CONFIRM = "followup.confirm"
INTENT_FOLLOWUP_CONTINUE = "followup.continue"
INTENT_IGNORE = "ignore"
FOLLOWUP_TARGET_INTENTS = {
INTENT_PROJECT_CREATE,
INTENT_SIGNAL_CREATE,
INTENT_SIGNAL_SOURCE_ONBOARD,
}
ROUTE_FAMILY_QUERY = "query"
ROUTE_FAMILY_PROJECT_CREATE = "project_create"
ROUTE_FAMILY_PROJECT_SIGNAL = "project_signal"
ROUTE_FAMILY_SIGNAL_SOURCE = "signal_source"
ROUTE_FAMILY_FOLLOWUP = "followup"
ROUTE_ACTION_ANSWER = "answer"
ROUTE_ACTION_CREATE = "create"
ROUTE_ACTION_DISCOVER = "discover"
ROUTE_ACTION_COMMIT = "commit"
ROUTE_ACTION_CANCEL = "cancel"
ROUTE_ACTION_REFRESH = "refresh"
ROUTE_ACTION_ONBOARD = "onboard"
ROUTE_ACTION_CONTINUE = "continue"
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_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}"
ROUTE_PROJECT_SIGNAL_CANCEL = f"{ROUTE_FAMILY_PROJECT_SIGNAL}.{ROUTE_ACTION_CANCEL}"
ROUTE_PROJECT_SIGNAL_REFRESH = f"{ROUTE_FAMILY_PROJECT_SIGNAL}.{ROUTE_ACTION_REFRESH}"
ROUTE_SIGNAL_SOURCE_ONBOARD = f"{ROUTE_FAMILY_SIGNAL_SOURCE}.{ROUTE_ACTION_ONBOARD}"
ROUTE_FOLLOWUP_CONFIRM = f"{ROUTE_FAMILY_FOLLOWUP}.{ROUTE_ACTION_CONFIRM}"
ROUTE_FOLLOWUP_CONTINUE = f"{ROUTE_FAMILY_FOLLOWUP}.{ROUTE_ACTION_CONTINUE}"
WORKFLOW_ROUTE_KEYS = {
ROUTE_QUERY_ANSWER,
ROUTE_PROJECT_CREATE,
ROUTE_PROJECT_SIGNAL_CREATE,
ROUTE_PROJECT_SIGNAL_DISCOVER,
ROUTE_PROJECT_SIGNAL_COMMIT,
ROUTE_PROJECT_SIGNAL_CANCEL,
ROUTE_PROJECT_SIGNAL_REFRESH,
ROUTE_SIGNAL_SOURCE_ONBOARD,
ROUTE_FOLLOWUP_CONFIRM,
ROUTE_FOLLOWUP_CONTINUE,
}
ROUTE_CANONICAL_INTENT_MAP: dict[str, str] = {
ROUTE_QUERY_ANSWER: INTENT_QUERY,
ROUTE_PROJECT_CREATE: INTENT_PROJECT_CREATE,
ROUTE_PROJECT_SIGNAL_CREATE: INTENT_SIGNAL_CREATE,
ROUTE_PROJECT_SIGNAL_DISCOVER: INTENT_SIGNAL_CREATE,
ROUTE_PROJECT_SIGNAL_COMMIT: INTENT_SIGNAL_CREATE,
ROUTE_PROJECT_SIGNAL_CANCEL: INTENT_SIGNAL_CREATE,
ROUTE_PROJECT_SIGNAL_REFRESH: INTENT_SIGNAL_CREATE,
ROUTE_SIGNAL_SOURCE_ONBOARD: INTENT_SIGNAL_SOURCE_ONBOARD,
ROUTE_FOLLOWUP_CONFIRM: INTENT_FOLLOWUP_CONFIRM,
ROUTE_FOLLOWUP_CONTINUE: INTENT_FOLLOWUP_CONTINUE,
}
LEGACY_INTENT_ROUTE_MAP: dict[str, str] = {
INTENT_QUERY: ROUTE_QUERY_ANSWER,
INTENT_PROJECT_CREATE: ROUTE_PROJECT_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,
"project_signal.create": ROUTE_PROJECT_SIGNAL_CREATE,
"project_signal.discover": ROUTE_PROJECT_SIGNAL_DISCOVER,
"project_signal.commit": ROUTE_PROJECT_SIGNAL_COMMIT,
"project_signal.cancel": ROUTE_PROJECT_SIGNAL_CANCEL,
"project_signal.refresh": ROUTE_PROJECT_SIGNAL_REFRESH,
"signal_source.onboard": ROUTE_SIGNAL_SOURCE_ONBOARD,
}
PROJECT_SIGNAL_DISCOVER_HINTS = (
"候选",
"先分析",
"先看候选",
"给我确认",
"待确认",
"再让我确认",
"确认后入库",
"先不要入库",
"先别入库",
)
FOLLOWUP_REFRESH_HINTS = ("换一批", "再来一批", "刷新候选", "重新找", "再找一组", "换一组")
FOLLOWUP_CANCEL_HINTS = ("取消", "先不做", "先别做", "不要了", "算了", "reject", "驳回")
FOLLOWUP_COMMIT_HINTS = ("确认", "继续处理", "继续执行", "同意", "批准", "approve")
QUESTION_HINTS = (
"?",
"?",
"怎么",
"如何",
"为何",
"为什么",
"什么",
"是什么",
"哪些",
"有哪些",
"特点",
"技术特点",
"能力",
"介绍",
"说明",
"多少",
"是否",
"帮我看",
"状态",
"风险",
"能做什么",
"可以做什么",
)
SMALLTALK_EXACT = {
"收到",
"收到啦",
"好的",
"ok",
"okk",
"yes",
"no",
"谢谢",
"thanks",
"thx",
"在吗",
"在不在",
"怎么样了",
"咋样了",
"辛苦了",
}
SMALLTALK_SHORT_HINTS = ("怎么样了", "咋样了", "在吗", "辛苦了", "收到", "谢谢", "ok")
SMALLTALK_STRUCTURED_HINTS = ("项目", "信号", "任务", "风险", "进度", "状态", "work_item", "工作项", "#")
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 = ("新增一个信号", "新增一条信号", "增加一个信号", "录入一个信号", "关注", "跟踪")
PROJECT_CREATE_HINTS = (
"创建项目",
"新建项目",
"建立项目",
"做个项目",
"立项",
"开个项目",
"新增项目",
"新增个项目",
"新增一个项目",
"增加项目",
"增加个项目",
"增加一个项目",
"添加项目",
"添加一个项目",
"加个项目",
"加一个项目",
)
SESSION_SHORT_CONFIRM_WINDOW_SECONDS = 5 * 60
SHORT_CONFIRM_MAX_MESSAGE_LENGTH = 10
SHORT_CONFIRM_HINTS = (
"是",
"好",
"对",
"确认",
"新增",
"加",
"嗯",
"yes",
"ok",
"好的",
"是的",
"行",
"可以",
"没问题",
)
INTENT_LLM_FUNCTION_NAME = "classify_intent_ai_pmo_kernel"
INTENT_CANDIDATES = (
INTENT_SIGNAL_SOURCE_ONBOARD,
INTENT_SIGNAL_CREATE,
INTENT_QUERY,
INTENT_PROJECT_CREATE,
INTENT_FOLLOWUP_CONFIRM,
INTENT_FOLLOWUP_CONTINUE,
INTENT_IGNORE,
)
WRITE_INTENTS = {
INTENT_PROJECT_CREATE,
INTENT_SIGNAL_CREATE,
INTENT_SIGNAL_SOURCE_ONBOARD,
INTENT_FOLLOWUP_CONFIRM,
INTENT_FOLLOWUP_CONTINUE,
}
INTENT_CONFLICT_POLICIES = {"pending_manual", "prefer_rule", "prefer_llm"}
def _safe_int(value: Any) -> Optional[int]:
try:
if value is None or value == "":
return None
return int(value)
except (TypeError, ValueError):
return None
@dataclass(slots=True)
class CanonicalIntent:
name: str
confidence: float
project_id: Optional[int]
signal_title: str
signal_summary: str
metadata: dict[str, Any]
@dataclass(slots=True)
class WorkflowRoute:
family: str
action: str
project_id: Optional[int]
requested_count: int
requires_confirmation: bool
metadata: dict[str, Any]
@property
def key(self) -> str:
return f"{self.family}.{self.action}"
class RouteResolver:
"""Map canonical intent + text into workflow route."""
@staticmethod
def _split_route_key(route_key: str) -> tuple[str, str]:
normalized = str(route_key or "").strip().lower()
if "." not in normalized:
return ROUTE_FAMILY_QUERY, ROUTE_ACTION_ANSWER
family, action = normalized.split(".", 1)
return family.strip() or ROUTE_FAMILY_QUERY, action.strip() or ROUTE_ACTION_ANSWER
@classmethod
def normalize_route_key(cls, value: str) -> str:
normalized = str(value or "").strip().lower()
if not normalized:
return ROUTE_QUERY_ANSWER
mapped = LEGACY_INTENT_ROUTE_MAP.get(normalized)
if mapped:
return mapped
if normalized in WORKFLOW_ROUTE_KEYS:
return normalized
family, action = cls._split_route_key(normalized)
if family and action:
return f"{family}.{action}"
return ROUTE_QUERY_ANSWER
@classmethod
def canonical_intent_for_route(cls, route_key: str) -> str:
normalized = cls.normalize_route_key(route_key)
mapped = ROUTE_CANONICAL_INTENT_MAP.get(normalized)
if mapped:
return mapped
family, _ = cls._split_route_key(normalized)
if family == ROUTE_FAMILY_PROJECT_SIGNAL:
return INTENT_SIGNAL_CREATE
if family == ROUTE_FAMILY_SIGNAL_SOURCE:
return INTENT_SIGNAL_SOURCE_ONBOARD
if family == ROUTE_FAMILY_PROJECT_CREATE:
return INTENT_PROJECT_CREATE
if family == ROUTE_FAMILY_FOLLOWUP:
return INTENT_FOLLOWUP_CONTINUE
return INTENT_QUERY
@staticmethod
def _parse_chinese_count_token(token: str) -> Optional[int]:
cleaned = str(token or "").strip()
if not cleaned:
return None
map_digit = {
"零": 0,
"一": 1,
"二": 2,
"两": 2,
"三": 3,
"四": 4,
"五": 5,
"六": 6,
"七": 7,
"八": 8,
"九": 9,
"十": 10,
}
if cleaned in map_digit:
return map_digit[cleaned]
if len(cleaned) == 2 and cleaned[0] == "十" and cleaned[1] in map_digit:
return 10 + map_digit[cleaned[1]]
if len(cleaned) == 2 and cleaned[1] == "十" and cleaned[0] in map_digit:
return map_digit[cleaned[0]] * 10
if len(cleaned) == 3 and cleaned[1] == "十" and cleaned[0] in map_digit and cleaned[2] in map_digit:
return map_digit[cleaned[0]] * 10 + map_digit[cleaned[2]]
return None
@classmethod
def extract_requested_count(cls, *, message_text: str, metadata: dict[str, Any]) -> int:
metadata_count = _safe_int(metadata.get("requested_count")) if isinstance(metadata, dict) else None
if metadata_count is not None and metadata_count > 0:
return min(max(metadata_count, 1), 5)
normalized_text = str(message_text or "")
digit_match = re.search(r"(\d{1,2})\s*(?:个|条|项)?\s*(?:信号|候选)", normalized_text, flags=re.IGNORECASE)
if digit_match:
return min(max(int(digit_match.group(1)), 1), 5)
chinese_match = re.search(r"([零一二两三四五六七八九十]{1,3})\s*(?:个|条|项)?\s*(?:信号|候选)", normalized_text)
if chinese_match:
parsed = cls._parse_chinese_count_token(chinese_match.group(1))
if parsed is not None and parsed > 0:
return min(max(parsed, 1), 5)
return 1
@staticmethod
def looks_like_project_signal_discover(*, message_text: str, metadata: dict[str, Any]) -> bool:
explicit_route = str(metadata.get("workflow_route") or "").strip().lower() if isinstance(metadata, dict) else ""
if explicit_route == ROUTE_PROJECT_SIGNAL_DISCOVER:
return True
normalized = re.sub(r"\s+", "", str(message_text or "")).lower()
if not normalized:
return False
has_discover_hint = any(token in normalized for token in PROJECT_SIGNAL_DISCOVER_HINTS)
has_signal_hint = any(token in normalized for token in ("信号", "候选", "分析", "拆分"))
return has_discover_hint and has_signal_hint
@staticmethod
def parse_followup_action(*, message_text: str, canonical_intent: str) -> str:
normalized = re.sub(r"\s+", "", str(message_text or "")).lower()
if any(token in normalized for token in FOLLOWUP_REFRESH_HINTS):
return ROUTE_ACTION_REFRESH
if any(token in normalized for token in FOLLOWUP_CANCEL_HINTS):
return ROUTE_ACTION_CANCEL
if any(token in normalized 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
@classmethod
def from_canonical(
cls,
*,
canonical_intent: CanonicalIntent,
message_text: str,
) -> WorkflowRoute:
metadata = dict(canonical_intent.metadata or {})
route_key = LEGACY_INTENT_ROUTE_MAP.get(canonical_intent.name, ROUTE_QUERY_ANSWER)
if canonical_intent.name == INTENT_SIGNAL_CREATE:
route_key = (
ROUTE_PROJECT_SIGNAL_DISCOVER
if cls.looks_like_project_signal_discover(message_text=message_text, metadata=metadata)
else ROUTE_PROJECT_SIGNAL_CREATE
)
elif canonical_intent.name in {INTENT_FOLLOWUP_CONFIRM, INTENT_FOLLOWUP_CONTINUE}:
action = cls.parse_followup_action(message_text=message_text, canonical_intent=canonical_intent.name)
if action == ROUTE_ACTION_CONFIRM:
route_key = ROUTE_FOLLOWUP_CONFIRM
else:
route_key = ROUTE_FOLLOWUP_CONTINUE
metadata["followup_action"] = action
family, action = cls._split_route_key(route_key)
requested_count = cls.extract_requested_count(message_text=message_text, metadata=metadata)
if family != ROUTE_FAMILY_PROJECT_SIGNAL:
requested_count = 1
requires_confirmation = bool(metadata.get("requires_confirmation")) or action in {
ROUTE_ACTION_DISCOVER,
ROUTE_ACTION_COMMIT,
ROUTE_ACTION_CANCEL,
ROUTE_ACTION_REFRESH,
}
metadata["workflow_route"] = f"{family}.{action}"
metadata["workflow_family"] = family
metadata["workflow_action"] = action
return WorkflowRoute(
family=family,
action=action,
project_id=canonical_intent.project_id,
requested_count=requested_count,
requires_confirmation=requires_confirmation,
metadata=metadata,
)
@classmethod
def from_structured_payload(
cls,
*,
payload: dict[str, Any],
message_text: str,
fallback_project_id: Optional[int],
) -> Optional[WorkflowRoute]:
family = str(payload.get("family") or "").strip().lower()
action = str(payload.get("action") or "").strip().lower()
if not family or not action:
intent_name = str(payload.get("intent") or "").strip().lower()
route_key = cls.normalize_route_key(intent_name)
family, action = cls._split_route_key(route_key)
route_key = cls.normalize_route_key(f"{family}.{action}")
family, action = cls._split_route_key(route_key)
if family == ROUTE_FAMILY_PROJECT_SIGNAL and action not in {
ROUTE_ACTION_CREATE,
ROUTE_ACTION_DISCOVER,
ROUTE_ACTION_COMMIT,
ROUTE_ACTION_CANCEL,
ROUTE_ACTION_REFRESH,
}:
return None
if f"{family}.{action}" not in WORKFLOW_ROUTE_KEYS and family != ROUTE_FAMILY_PROJECT_SIGNAL:
return None
requested_count = cls.extract_requested_count(
message_text=message_text,
metadata=payload if isinstance(payload, dict) else {},
)
if family != ROUTE_FAMILY_PROJECT_SIGNAL:
requested_count = 1
project_id = _safe_int(payload.get("project_id"))
if project_id is None:
project_id = fallback_project_id
if project_id is not None and int(project_id) <= 0:
project_id = None
requires_confirmation = bool(payload.get("requires_confirmation")) or action in {
ROUTE_ACTION_DISCOVER,
ROUTE_ACTION_COMMIT,
ROUTE_ACTION_CANCEL,
ROUTE_ACTION_REFRESH,
}
metadata = dict(payload)
metadata["workflow_route"] = f"{family}.{action}"
metadata["workflow_family"] = family
metadata["workflow_action"] = action
metadata["requested_count"] = requested_count
return WorkflowRoute(
family=family,
action=action,
project_id=project_id,
requested_count=requested_count,
requires_confirmation=requires_confirmation,
metadata=metadata,
)
class IntentResolver:
"""Resolve raw text to canonical intent."""
OPERATION_VERBS = (
"新增",
"增加",
"添加",
"加",
"加一个",
"加个",
"新建",
"创建",
"建立",
"立项",
"接入",
"修复",
"优化",
"改成",
"改为",
"更新",
"分配",
"指派",
"归档",
"执行",
)
@staticmethod
def _looks_like_negated_write_statement(*, text: str) -> bool:
normalized = re.sub(r"\s+", "", str(text or "")).lower()
if not normalized:
return False
negation_tokens = ("别", "别再", "别乱", "不要", "不要再", "先别", "先不要", "禁止", "不能", "无需", "不用")
if not any(token in normalized for token in negation_tokens):
return False
write_tokens = ("创建", "新建", "新增", "增加", "添加", "加", "接入", "执行", "分配", "指派", "立项")
object_tokens = ("信号", "需求", "任务", "项目", "事项", "工单", "流程", "信号源")
if any(w in normalized for w in write_tokens) and any(o in normalized for o in object_tokens):
return True
return bool(
re.search(
r"(?:别|不要|先别|先不要|禁止|不能).{0,8}(?:创建|新建|新增|增加|添加|加|接入|执行|分配|指派|立项)",
normalized,
re.IGNORECASE,
)
)
@staticmethod
def resolve(*, text: str, project_id: Optional[int]) -> CanonicalIntent:
normalized = re.sub(r"\s+", "", str(text or "")).lower()
pretty = re.sub(r"\s+", " ", str(text or "")).strip()
if not pretty:
return CanonicalIntent(INTENT_IGNORE, 1.0, project_id, "", "", {"reason": "empty"})
if IntentResolver._is_smalltalk_message(pretty=pretty, normalized=normalized):
return CanonicalIntent(INTENT_IGNORE, 0.95, project_id, "", "", {"reason": "smalltalk"})
if IntentResolver._looks_like_negated_write_statement(text=pretty):
return CanonicalIntent(
INTENT_QUERY,
0.9,
project_id,
"",
pretty[:300],
{"reason": "negated_write_statement"},
)
explicit_followup_target = FOLLOWUP_WORK_ITEM_ID_RE.search(pretty)
if explicit_followup_target is not None:
return CanonicalIntent(
INTENT_FOLLOWUP_CONTINUE,
0.92,
project_id,
"",
"",
{
"followup_by_work_item_id": True,
"target_work_item_id": _safe_int(explicit_followup_target.group(1)),
},
)
if any(token in pretty for token in FOLLOWUP_CONFIRM_HINTS):
return CanonicalIntent(INTENT_FOLLOWUP_CONFIRM, 0.82, project_id, "", "", {})
if any(token in pretty for token in FOLLOWUP_CONTINUE_HINTS):
return CanonicalIntent(INTENT_FOLLOWUP_CONTINUE, 0.82, project_id, "", "", {})
if SignalSourceOrchestrationService.looks_like_signal_source_request(pretty):
return CanonicalIntent(
INTENT_SIGNAL_SOURCE_ONBOARD,
0.92,
project_id,
IntentResolver._build_signal_title(pretty, default="新增信号源接入请求"),
pretty[:300],
{"source_request": True},
)
if (
IntentResolver._looks_like_project_create_request(text=pretty)
or extract_project_create_title(pretty)
):
return CanonicalIntent(
INTENT_PROJECT_CREATE,
0.9,
project_id,
IntentResolver._build_signal_title(pretty, default="新项目创建请求"),
pretty[:300],
{},
)
if IntentResolver._looks_like_signal_create_request(text=pretty):
return CanonicalIntent(
INTENT_SIGNAL_CREATE,
0.78,
project_id,
IntentResolver._build_signal_title(pretty, default="新信号"),
pretty[:300],
{},
)
if IntentResolver._looks_like_query(text=pretty):
return CanonicalIntent(
INTENT_QUERY,
0.88,
project_id,
"",
pretty[:300],
{},
)
return CanonicalIntent(
INTENT_QUERY,
0.6,
project_id,
"",
pretty[:300],
{"fallback": True, "reason": "default_query_fallback"},
)
@staticmethod
def _looks_like_signal_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
candidate_discover_hints = ("候选", "换一批", "刷新", "再来一批", "先分析")
if (
any(token in normalized for token in candidate_discover_hints)
and any(token in normalized for token in ("确认", "入库", "提交"))
and any(token in normalized for token in ("项目", "project"))
):
return True
if re.search(r"(?:抓|找|给).{0,8}(?:\d+|[一二两三四五])(?:条|个)?候选", normalized, re.IGNORECASE):
return True
if any(token in normalized for token in SIGNAL_CREATE_HINTS):
return True
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}(?:信号|需求|任务|事项)",
normalized,
re.IGNORECASE,
)
)
@staticmethod
def _is_smalltalk_message(*, pretty: str, normalized: str) -> bool:
compact = re.sub(r"[\\s\\??!!,,。~~、]+", "", str(pretty or "")).strip().lower()
if not compact:
return True
if any(keyword in compact for keyword in SMALLTALK_STRUCTURED_HINTS):
return False
if compact in SMALLTALK_EXACT:
return True
if len(compact) <= 8 and any(keyword in compact for keyword in SMALLTALK_SHORT_HINTS):
return True
return False
@staticmethod
def _looks_like_query(*, text: str) -> bool:
normalized = re.sub(r"\s+", "", str(text or "")).lower()
if not normalized:
return False
if IntentResolver._looks_like_signal_create_request(text=normalized):
return False
if any(token in normalized for token in QUESTION_HINTS):
return True
has_question_structure = (
"?" in normalized
or "?" in normalized
or any(token in normalized for token in ("吗", "呢", "吧", "是否", "是不是"))
)
if not has_question_structure:
return False
has_operation = any(token in normalized for token in IntentResolver.OPERATION_VERBS)
return not has_operation
@staticmethod
def _looks_like_project_create_request(*, text: str) -> bool:
normalized = re.sub(r"\s+", "", str(text or "")).lower()
if not normalized:
return False
if any(token in normalized for token in PROJECT_CREATE_HINTS):
return True
return bool(
re.search(
r"(?:新增|增加|添加|新建|创建|建立|开个|开一个|建个|建一个|做个|做一个|加个|加一个|立项)(?:一个|个)?项目",
normalized,
re.IGNORECASE,
)
)
@staticmethod
def _build_signal_title(text: str, *, default: str) -> str:
cleaned = re.sub(r"\s+", " ", str(text or "")).strip(" ,,::")
if not cleaned:
return default
for token in ("。", ";", ";", "!", "!", "?", "?"):
if token in cleaned:
cleaned = cleaned.split(token, 1)[0].strip()
break
return cleaned[:120] or default

View File

@ -0,0 +1,310 @@
"""AI PMO 决策中枢内核服务。"""
from __future__ import annotations
import asyncio
import hashlib
import json
import os
import re
import time
from datetime import datetime, timedelta, timezone
from typing import Any, Optional
from sqlalchemy import and_, func, or_, select
from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
from ..config.ai_prompts import (
PROMPT_AI_PMO_INTENT_CLASSIFICATION,
PROMPT_AI_PMO_SIGNAL_BATCH_ANALYSIS,
)
from ..core.logging import get_logger
from ..models import (
AIProcessingLog,
Budget,
ConversationSession,
DecisionRecord,
Experiment,
IngestEvent,
Notification,
OperationExecutionLedger,
PlanBundle,
PortfolioSnapshot,
Project,
ProjectDomain,
ProjectSignalLink,
QARecord,
ReplyReceipt,
Signal,
SignalSourceRegistry,
Task,
TruthNode,
User,
UserProject,
UserSkillBinding,
UserWorkload,
WorkItem,
)
from ..schemas.world_model import (
BlockedTask,
BudgetSummary,
CapacityItem,
CharterItem,
DependencySummary,
PortfolioSummary,
ProjectDomainSnapshot,
ProjectSnapshot,
RecentSignalItem,
RiskSummary,
SkillGraphItem,
TaskSummary,
WorldModel,
)
from .adaptive_rag_service import AdaptiveRAGService
from .ai_agent_kernel import (
build_decision_trace,
gate_execution as kernel_gate_execution,
resolve_correction as kernel_resolve_correction,
select_intent_route,
)
from .ai_pmo_cadence_service import AIPMOCadenceService
from .ai_pmo_decision_runtime import DecisionEngine
from .ai_pmo_reply_renderer import ReplyRenderer
from .ai_pmo_typed_executor import TypedExecutor
from .ai_steward import AIStewardService
from .ai_pmo_review_service import AIPMOReviewService
from .ai_pmo_intent_routing import (
FOLLOWUP_TARGET_INTENTS,
FOLLOWUP_WORK_ITEM_ID_RE,
INTENT_CANDIDATES,
INTENT_CONFLICT_POLICIES,
INTENT_FOLLOWUP_CONFIRM,
INTENT_FOLLOWUP_CONTINUE,
INTENT_IGNORE,
INTENT_LLM_FUNCTION_NAME,
INTENT_PROJECT_CREATE,
INTENT_QUERY,
INTENT_SIGNAL_CREATE,
INTENT_SIGNAL_SOURCE_ONBOARD,
LEGACY_INTENT_ROUTE_MAP,
ROUTE_ACTION_ANSWER,
ROUTE_ACTION_CANCEL,
ROUTE_ACTION_COMMIT,
ROUTE_ACTION_CONFIRM,
ROUTE_ACTION_CONTINUE,
ROUTE_ACTION_CREATE,
ROUTE_ACTION_DISCOVER,
ROUTE_ACTION_ONBOARD,
ROUTE_ACTION_REFRESH,
ROUTE_FAMILY_FOLLOWUP,
ROUTE_FAMILY_PROJECT_CREATE,
ROUTE_FAMILY_PROJECT_SIGNAL,
ROUTE_FAMILY_QUERY,
ROUTE_FAMILY_SIGNAL_SOURCE,
ROUTE_FOLLOWUP_CONFIRM,
ROUTE_FOLLOWUP_CONTINUE,
ROUTE_PROJECT_CREATE,
ROUTE_PROJECT_SIGNAL_CANCEL,
ROUTE_PROJECT_SIGNAL_COMMIT,
ROUTE_PROJECT_SIGNAL_CREATE,
ROUTE_PROJECT_SIGNAL_DISCOVER,
ROUTE_PROJECT_SIGNAL_REFRESH,
ROUTE_QUERY_ANSWER,
ROUTE_SIGNAL_SOURCE_ONBOARD,
SESSION_SHORT_CONFIRM_WINDOW_SECONDS,
SHORT_CONFIRM_HINTS,
SHORT_CONFIRM_MAX_MESSAGE_LENGTH,
WRITE_INTENTS,
CanonicalIntent,
IntentResolver,
RouteResolver,
WorkflowRoute,
)
from .ai_pmo_workflow_runtime import ContextAssembler, WorkflowRegistry
from .context_cache import ContextCacheService
from .notification_service import NotificationService
from .ai_pmo_domain_workflow import SignalSourceDomainWorkflow
from .llm import llm_service
from .model_context_bundle_service import ModelContextBundleService
from .signal_planner_service import SignalPlannerService
from ..utils.timezone import now_cn_naive
logger = get_logger(__name__)
STATE_RECEIVED = "received"
STATE_MODELED = "modeled"
STATE_DECIDED = "decided"
STATE_PLANNED = "planned"
STATE_EXECUTING = "executing"
STATE_WAITING_MANUAL = "waiting_manual"
STATE_SUCCEEDED = "succeeded"
STATE_FAILED = "failed"
STATE_IGNORED = "ignored"
RECEIPT_PHASE_ACTION = "action_applied"
RECEIPT_PHASE_PENDING = "pending_manual"
RECEIPT_PHASE_ERROR = "error"
def _looks_like_upstream_timeout_message(text: str) -> bool:
lowered = str(text or "").strip().lower()
if not lowered:
return False
timeout_signals = (
"request timed out before a response was generated",
"agents.defaults.timeoutseconds",
"timeoutseconds",
)
return any(signal in lowered for signal in timeout_signals)
TRANSPORT_MODE_CORRECTION_HINTS = ("不是", "不再", "改为", "改成", "替换为", "取代", "现在都", "现在是")
GENERIC_SIGNAL_SOURCE_MESSAGE_RE = re.compile(
r"^(?:请)?(?:按|按照)?(?:这个|上述|这条)?(?:分步|步骤)?(?:骤)?(?:把|将)?(?:它|该需求|这条需求)?(?:加入|接入|录入).{0,24}信号源(?:中)?$",
re.IGNORECASE,
)
MODULE_KEYWORD_RULES: tuple[tuple[str, tuple[str, ...]], ...] = (
("用户管理与权限", ("用户管理", "角色权限", "crud", "批量导入", "成员管理")),
("技能图谱", ("技能图谱", "技能标签", "能力图谱", "雷达图")),
("工作量管理", ("工作量", "负载", "产能", "workload")),
("SSTC 工作流", ("sstc", "charter", "signal", "task", "execution", "release", "review", "工作流")),
("AI 能力编排", ("ai能力", "智能", "自动化", "推荐", "编排", "agent", "llm")),
("信号源接入", ("信号源", "source", "onboard", "接入")),
("飞书集成", ("飞书", "feishu", "群聊", "机器人")),
("消息台与详情页", ("消息台", "详情页", "展开详情", "摘要", "时间戳", "按钮")),
)
POLICY_GATE_RULES: tuple[tuple[str, tuple[str, ...]], ...] = (
("delete_or_archive", ("删除项目", "删除信号", "归档项目", "归档", "archive", "清空项目")),
("budget_overrun", ("超预算", "突破预算", "额外预算", "加预算")),
("cross_project_staffing", ("跨项目抢人", "抽调", "抢占关键人力", "挪人")),
("charter_rewrite", ("改写charter", "success criteria", "成功标准改", "范围重大变更")),
("critical_path_change", ("关键路径", "里程碑改动", "影响多个项目", "跨项目依赖调整")),
)
BOOL_TRUE_VALUES = {"1", "true", "yes", "y", "on", "是"}
SESSION_PROJECT_INHERIT_WINDOW_SECONDS = 30 * 60
INTENT_LLM_TIMEOUT_DEFAULT_SECONDS = 30.0
INTENT_LLM_TIMEOUT_COMPLEX_DEFAULT_SECONDS = 30.0
INTENT_LLM_TIMEOUT_FALLBACK_DEFAULT_SECONDS = 35.0
INTENT_LLM_TOTAL_BUDGET_DEFAULT_SECONDS = 55.0
INTENT_LLM_STAGE2_CONFIDENCE_DEFAULT = 0.72
INTENT_LLM_STAGE2_MIN_REMAINING_DEFAULT_SECONDS = 12.0
WORLD_MODEL_STATIC_CACHE_TTL_SECONDS = 10 * 60
WORLD_MODEL_STATIC_CACHE_KEY_PREFIX = "wm:static"
def _utc_now_naive() -> datetime:
return now_cn_naive()
def _env_bool(name: str, default: bool = False) -> bool:
raw = os.getenv(name)
if raw is None:
return default
return str(raw).strip().lower() in BOOL_TRUE_VALUES
def _env_float(name: str, default: float) -> float:
raw = os.getenv(name)
if raw is None:
return float(default)
try:
return float(raw)
except (TypeError, ValueError):
return float(default)
def _intent_llm_timeout_seconds(*, function_name: str) -> float:
normalized = str(function_name or "").strip().lower()
if normalized == "classify_intent_complex":
default_timeout = INTENT_LLM_TIMEOUT_COMPLEX_DEFAULT_SECONDS
configured = _env_float(
"AI_PMO_INTENT_LLM_TIMEOUT_COMPLEX_SECONDS",
_env_float("AI_PMO_INTENT_LLM_TIMEOUT_SECONDS", default_timeout),
)
elif normalized == str(INTENT_LLM_FUNCTION_NAME or "").strip().lower():
default_timeout = INTENT_LLM_TIMEOUT_FALLBACK_DEFAULT_SECONDS
configured = _env_float(
"AI_PMO_INTENT_LLM_TIMEOUT_FALLBACK_SECONDS",
_env_float("AI_PMO_INTENT_LLM_TIMEOUT_SECONDS", default_timeout),
)
else:
default_timeout = INTENT_LLM_TIMEOUT_DEFAULT_SECONDS
configured = _env_float("AI_PMO_INTENT_LLM_TIMEOUT_SECONDS", default_timeout)
return min(max(float(configured), 12.0), 120.0)
def _intent_llm_total_budget_seconds() -> float:
configured = _env_float("AI_PMO_INTENT_LLM_TOTAL_BUDGET_SECONDS", INTENT_LLM_TOTAL_BUDGET_DEFAULT_SECONDS)
return min(max(float(configured), 20.0), 120.0)
def _intent_llm_stage2_confidence_threshold() -> float:
configured = _env_float("AI_PMO_INTENT_LLM_STAGE2_CONFIDENCE_THRESHOLD", INTENT_LLM_STAGE2_CONFIDENCE_DEFAULT)
return min(max(float(configured), 0.40), 0.95)
def _intent_llm_stage2_min_remaining_seconds() -> float:
configured = _env_float(
"AI_PMO_INTENT_LLM_STAGE2_MIN_REMAINING_SECONDS",
INTENT_LLM_STAGE2_MIN_REMAINING_DEFAULT_SECONDS,
)
return min(max(float(configured), 8.0), 30.0)
def _should_force_low_tier_intent_route(*, message_text: str, correction_action: str) -> bool:
if str(correction_action or "").strip().lower() != "none":
return True
normalized = re.sub(r"\s+", "", str(message_text or "")).lower()
if not normalized:
return False
return any(token in normalized for token in TRANSPORT_MODE_CORRECTION_HINTS)
def _should_escalate_to_high_intent_route(
*,
route_is_complex: bool,
low_intent: Optional["CanonicalIntent"],
rule_intent: Optional["CanonicalIntent"],
) -> tuple[bool, list[str]]:
reasons: list[str] = []
if route_is_complex:
reasons.append("route_complex")
if low_intent is None:
reasons.append("stage1_empty")
else:
low_confidence = float(low_intent.confidence or 0.0)
if low_confidence < _intent_llm_stage2_confidence_threshold():
reasons.append("stage1_low_confidence")
if rule_intent is not None and low_intent is not None and str(rule_intent.name or "") != str(low_intent.name or ""):
reasons.append("stage1_rule_conflict")
return bool(reasons), reasons
def _safe_int(value: Any) -> Optional[int]:
try:
if value is None or value == "":
return None
return int(value)
except (TypeError, ValueError):
return None
def _normalize_signal_id_list(value: Any) -> list[int]:
ids: list[int] = []
seen: set[int] = set()
if isinstance(value, list):
items = value
else:
items = [value]
for item in items:
signal_id = _safe_int(item)
if signal_id is None or signal_id <= 0:
continue
if signal_id in seen:
continue
seen.add(signal_id)
ids.append(signal_id)
return ids

View File

@ -0,0 +1,17 @@
"""AI PMO kernel closure mixin composition."""
from __future__ import annotations
from .ai_pmo_kernel_mixins_closure_data import KernelClosureDataMixin
from .ai_pmo_kernel_mixins_closure_helpers import KernelClosureHelpersMixin
from .ai_pmo_kernel_mixins_closure_payload import KernelClosurePayloadMixin
class KernelClosureMixin(
KernelClosureHelpersMixin,
KernelClosureDataMixin,
KernelClosurePayloadMixin,
):
"""Compose closure helpers, data hydration, and payload assembly."""
pass

View File

@ -0,0 +1,154 @@
"""AI PMO kernel closure data-source mixin."""
from __future__ import annotations
from . import ai_pmo_kernel_common as _common
for _name, _value in vars(_common).items():
if not _name.startswith("__"):
globals()[_name] = _value
class KernelClosureDataMixin:
async def _load_closure_turns_from_processing_logs(
self,
*,
source_message_id: Optional[str],
message_text: str,
) -> list[dict[str, str]]:
message_id = str(source_message_id or "").strip()
if not message_id:
return []
rows = (
await self.db.execute(
select(AIProcessingLog)
.where(
and_(
AIProcessingLog.tenant_id == self.tenant_id,
AIProcessingLog.feishu_message_id == message_id,
)
)
.order_by(AIProcessingLog.id.asc())
.limit(12)
)
).scalars().all()
if not rows:
return []
normalized_message = self._normalize_dialogue_text(message_text, max_len=600)
turns: list[dict[str, str]] = []
for row in rows:
user_text = self._normalize_feishu_user_text(
row.normalized_text or row.raw_message,
max_len=260,
)
if user_text and (not normalized_message or user_text != normalized_message):
turns = self._merge_closure_turns(turns, [{"role": "user", "text": user_text}])
assistant_text = self._extract_assistant_text_from_log_payload(row.operation_result)
if assistant_text:
turns = self._merge_closure_turns(turns, [{"role": "assistant", "text": assistant_text}])
return turns[-4:]
@classmethod
def _score_qa_row_for_closure(
cls,
*,
row: QARecord,
normalized_message: str,
reference_at: Optional[datetime],
) -> float:
question = cls._normalize_dialogue_text(row.question, max_len=500)
answer = cls._normalize_dialogue_text(row.answer, max_len=900)
if not question or not answer:
return -1.0
score = 0.0
merged = f"{question} {answer}"
if "信号源" in merged:
score += 3.0
if any(token in merged for token in ("模块", "后端", "前端", "流程", "SSTC", "任务", "执行", "接口")):
score += 2.0
if any(token in answer for token in ("暂时无法生成可靠回答", "模型服务异常", "请稍后重试")):
score -= 5.0
if normalized_message and cls._is_generic_signal_source_message(normalized_message):
if "信号源" in question:
score += 1.5
if "项目" in answer:
score += 1.0
created_at = cls._parse_datetime_like(getattr(row, "created_at", None))
if reference_at is not None and created_at is not None:
gap_seconds = abs((reference_at - created_at).total_seconds())
if gap_seconds > 7 * 24 * 3600:
score -= 4.0
elif gap_seconds <= 6 * 3600:
score += 1.5
elif gap_seconds <= 24 * 3600:
score += 0.8
return score
async def _load_closure_turns_from_qa_records(
self,
*,
owner_user_id: Optional[int],
project_id: Optional[int],
captured_at: Optional[datetime],
message_text: str,
) -> list[dict[str, str]]:
uid = _safe_int(owner_user_id)
pid = _safe_int(project_id)
if uid is None and pid is None:
return []
normalized_message = self._normalize_dialogue_text(message_text, max_len=600)
reference_at = self._parse_datetime_like(captured_at)
candidates: list[QARecord] = []
conditions: list[Any] = []
if uid is not None and pid is not None:
conditions.append(and_(QARecord.asked_by == int(uid), QARecord.project_id == int(pid)))
if pid is not None:
conditions.append(QARecord.project_id == int(pid))
if uid is not None:
conditions.append(QARecord.asked_by == int(uid))
for condition in conditions:
stmt = select(QARecord).where(
and_(
QARecord.tenant_id == self.tenant_id,
QARecord.asked_via == "feishu",
condition,
)
)
if reference_at is not None:
stmt = stmt.where(QARecord.created_at <= reference_at)
rows = (
await self.db.execute(
stmt.order_by(QARecord.created_at.desc(), QARecord.id.desc()).limit(12)
)
).scalars().all()
candidates.extend(rows)
if rows:
break
if not candidates:
return []
best_row: Optional[QARecord] = None
best_score = -999.0
seen_ids: set[int] = set()
for row in candidates:
row_id = int(getattr(row, "id", 0) or 0)
if row_id > 0 and row_id in seen_ids:
continue
seen_ids.add(row_id)
score = self._score_qa_row_for_closure(
row=row,
normalized_message=normalized_message,
reference_at=reference_at,
)
if score > best_score:
best_score = score
best_row = row
if best_row is None or best_score < 0.5:
return []
question = self._normalize_dialogue_text(best_row.question, max_len=260)
answer = self._normalize_dialogue_text(best_row.answer, max_len=420)
if not question or not answer:
return []
return [
{"role": "user", "text": question},
{"role": "assistant", "text": answer},
]

View File

@ -0,0 +1,92 @@
"""AI PMO kernel closure helpers mixin."""
from __future__ import annotations
from . import ai_pmo_kernel_common as _common
for _name, _value in vars(_common).items():
if not _name.startswith("__"):
globals()[_name] = _value
class KernelClosureHelpersMixin:
@staticmethod
def _normalize_dialogue_text(value: Any, *, max_len: int) -> str:
text = re.sub(r"\s+", " ", str(value or "")).strip()
if not text:
return ""
return text[:max_len]
@staticmethod
def _parse_datetime_like(value: Any) -> Optional[datetime]:
if isinstance(value, datetime):
dt = value
else:
raw = str(value or "").strip()
if not raw:
return None
try:
dt = datetime.fromisoformat(raw.replace("Z", "+00:00"))
except Exception:
return None
if dt.tzinfo is not None:
return dt.astimezone(timezone.utc).replace(tzinfo=None)
return dt
@staticmethod
def _normalize_feishu_user_text(value: Any, *, max_len: int) -> str:
text = str(value or "")
text = re.sub(r"^@_user_\d+\s*", "", text)
text = re.sub(r"</?at[^>]*>", " ", text, flags=re.IGNORECASE)
text = re.sub(r"\s+", " ", text).strip()
if not text:
return ""
return text[:max_len]
@classmethod
def _extract_assistant_text_from_log_payload(cls, payload: Any) -> str:
if not isinstance(payload, dict):
return ""
candidates = (
payload.get("followup_reply"),
payload.get("direct_reply_text"),
payload.get("clarification_message"),
payload.get("answer"),
payload.get("reason"),
payload.get("result_summary"),
payload.get("error_message"),
payload.get("message"),
)
for item in candidates:
text = cls._normalize_dialogue_text(item, max_len=420)
if not text:
continue
if any(token in text for token in ("chat_id", "message_type", "sender_name")) and len(text) < 48:
continue
return text
return ""
@staticmethod
def _merge_closure_turns(
base_turns: list[dict[str, str]],
extra_turns: list[dict[str, str]],
*,
prepend: bool = False,
) -> list[dict[str, str]]:
if not extra_turns:
return list(base_turns)
merged = list(extra_turns) + list(base_turns) if prepend else list(base_turns) + list(extra_turns)
compact: list[dict[str, str]] = []
for row in merged:
role = str(row.get("role") or "").strip().lower()
text = str(row.get("text") or "").strip()
if role not in {"user", "assistant"} or not text:
continue
if compact and compact[-1].get("role") == role and compact[-1].get("text") == text:
continue
compact.append({"role": role, "text": text})
return compact[-8:]
@staticmethod
def _turns_have_assistant(turns: list[dict[str, str]]) -> bool:
return any(str(turn.get("role") or "").strip().lower() == "assistant" and str(turn.get("text") or "").strip() for turn in turns)

View File

@ -0,0 +1,194 @@
"""AI PMO kernel closure payload mixin."""
from __future__ import annotations
from . import ai_pmo_kernel_common as _common
for _name, _value in vars(_common).items():
if not _name.startswith("__"):
globals()[_name] = _value
class KernelClosurePayloadMixin:
@staticmethod
def _is_generic_signal_source_message(text: str) -> bool:
compact = re.sub(r"\s+", "", str(text or "")).strip().lower()
if not compact:
return False
if bool(GENERIC_SIGNAL_SOURCE_MESSAGE_RE.match(compact)):
return True
return len(compact) <= 24 and "信号源" in compact and ("这个" in compact or "步骤" in compact)
@classmethod
def _extract_implementation_modules_from_text(cls, text: str) -> list[str]:
normalized = str(text or "").lower()
modules: list[str] = []
for module_name, keywords in MODULE_KEYWORD_RULES:
if any(str(keyword).lower() in normalized for keyword in keywords):
modules.append(module_name)
return modules[:6]
@staticmethod
def _build_closure_dialogue_excerpt(turns: list[dict[str, str]]) -> str:
if not turns:
return ""
lines: list[str] = []
for turn in turns[-4:]:
role = str(turn.get("role") or "").strip().lower()
text = str(turn.get("text") or "").strip()
if not text:
continue
if role == "assistant":
lines.append(f"机器人:{text[:380]}")
else:
lines.append(f"用户:{text[:260]}")
return "\n".join(lines)[:1600]
@classmethod
def _derive_closure_summary(
cls,
*,
turns: list[dict[str, str]],
modules: list[str],
fallback_text: str,
) -> str:
latest_user = ""
latest_assistant = ""
for turn in reversed(turns):
role = str(turn.get("role") or "").strip().lower()
text = str(turn.get("text") or "").strip()
if not text:
continue
if role == "user" and not latest_user:
latest_user = text
if role == "assistant" and not latest_assistant:
latest_assistant = text
if latest_user and latest_assistant:
break
if latest_assistant and any(token in latest_assistant for token in ("缺少项目归属", "不能创建", "无法创建", "先确认项目")):
if latest_user:
return f"机器人先提示条件不足,用户随后继续要求执行:{latest_user[:120]}"
return "机器人提示条件不足,需补齐项目归属后继续执行信号源接入。"
if latest_user and cls._is_generic_signal_source_message(latest_user) and modules:
if len(modules) == 1 and str(modules[0]).strip() == "信号源接入":
return "用户要求将需求按步骤接入信号源,需要先补齐项目归属再继续执行。"
return f"同一消息闭环内,用户要求将需求按步骤接入信号源,重点模块:{'、'.join(modules[:4])}。"
if modules:
return f"用户在本次消息闭环中推进信号源接入,涉及模块:{'、'.join(modules[:4])}。"
if latest_user:
return latest_user[:200]
return str(fallback_text or "").strip()[:200]
async def _build_message_closure_payload(
self,
*,
source_system: str,
channel_ref: Optional[str],
sender: str,
source_sender_open_id: Optional[str],
message_text: str,
source_message_id: Optional[str] = None,
owner_user_id: Optional[int] = None,
project_id: Optional[int] = None,
captured_at: Optional[Any] = None,
) -> dict[str, Any]:
normalized_message = self._normalize_dialogue_text(message_text, max_len=600)
turns: list[dict[str, str]] = []
normalized_source_system = str(source_system or "").strip().lower()
normalized_channel = str(channel_ref or "").strip()
if normalized_source_system == "feishu" and normalized_channel:
conversation_key = self._build_feishu_rag_conversation_key(
source_chat_id=normalized_channel,
source_sender_open_id=source_sender_open_id,
sender=sender,
)
session_id = AdaptiveRAGService._external_session_id_from_key(conversation_key)
if session_id is not None:
cached_messages = await self.context_cache.get_session_messages(
tenant_id=self.tenant_id,
session_id=int(session_id),
)
cached_turns = self.context_cache.messages_to_turns(cached_messages)
for row in cached_turns[-2:]:
question = self._normalize_dialogue_text(row.get("question"), max_len=260)
answer = self._normalize_dialogue_text(row.get("answer"), max_len=420)
if question:
turns.append({"role": "user", "text": question})
if answer:
turns.append({"role": "assistant", "text": answer})
if normalized_source_system == "feishu" and not self._turns_have_assistant(turns):
log_turns = await self._load_closure_turns_from_processing_logs(
source_message_id=source_message_id,
message_text=normalized_message,
)
turns = self._merge_closure_turns(turns, log_turns)
needs_semantic_turns = (
normalized_source_system == "feishu"
and (
(not self._turns_have_assistant(turns))
or (self._is_generic_signal_source_message(normalized_message) and len(turns) <= 2)
)
)
if needs_semantic_turns:
qa_turns = await self._load_closure_turns_from_qa_records(
owner_user_id=owner_user_id,
project_id=project_id,
captured_at=self._parse_datetime_like(captured_at),
message_text=normalized_message,
)
turns = self._merge_closure_turns(turns, qa_turns, prepend=True)
if normalized_message:
if not turns or str(turns[-1].get("text") or "").strip() != normalized_message:
turns.append({"role": "user", "text": normalized_message})
closure_dialogue = self._build_closure_dialogue_excerpt(turns)
modules = self._extract_implementation_modules_from_text(
" ".join(str(turn.get("text") or "") for turn in turns)
)
closure_summary = self._derive_closure_summary(
turns=turns,
modules=modules,
fallback_text=normalized_message,
)
return {
"closure_dialogue": closure_dialogue or None,
"closure_summary": closure_summary or None,
"closure_modules": modules,
"closure_turns": turns[-4:],
}
@classmethod
def _enrich_signal_source_intent_from_closure(
cls,
*,
intent: CanonicalIntent,
message_text: str,
closure_payload: dict[str, Any],
) -> CanonicalIntent:
if intent.name != INTENT_SIGNAL_SOURCE_ONBOARD:
return intent
modules = closure_payload.get("closure_modules") if isinstance(closure_payload.get("closure_modules"), list) else []
closure_summary = cls._normalize_dialogue_text(closure_payload.get("closure_summary"), max_len=300)
closure_dialogue = cls._normalize_dialogue_text(closure_payload.get("closure_dialogue"), max_len=1600)
if closure_summary:
intent.signal_summary = closure_summary
elif not str(intent.signal_summary or "").strip():
intent.signal_summary = cls._normalize_dialogue_text(message_text, max_len=300)
if cls._is_generic_signal_source_message(str(intent.signal_title or "")):
if modules:
intent.signal_title = f"信号源接入 · {str(modules[0])[:60]}"[:120]
else:
intent.signal_title = "信号源接入请求"
metadata = dict(intent.metadata or {})
metadata["closure_summary"] = closure_summary or None
metadata["closure_dialogue"] = closure_dialogue or None
metadata["closure_modules"] = [str(item) for item in modules[:6] if str(item).strip()]
intent.metadata = metadata
return intent

View File

@ -0,0 +1,19 @@
"""AI PMO kernel followup mixin composition."""
from __future__ import annotations
from .ai_pmo_kernel_mixins_followup_project_signal import KernelFollowupProjectSignalMixin
from .ai_pmo_kernel_mixins_followup_records import KernelFollowupRecordsMixin
from .ai_pmo_kernel_mixins_followup_routing import KernelFollowupRoutingMixin
from .ai_pmo_kernel_mixins_followup_verification import KernelFollowupVerificationMixin
class KernelFollowupMixin(
KernelFollowupRecordsMixin,
KernelFollowupRoutingMixin,
KernelFollowupProjectSignalMixin,
KernelFollowupVerificationMixin,
):
"""Compose followup concerns into a single kernel mixin."""
pass

View File

@ -0,0 +1,19 @@
"""AI PMO kernel project-signal followup mixin composition."""
from __future__ import annotations
from .ai_pmo_kernel_mixins_followup_project_signal_actions import (
KernelFollowupProjectSignalActionsMixin,
)
from .ai_pmo_kernel_mixins_followup_project_signal_resume import (
KernelFollowupProjectSignalResumeMixin,
)
class KernelFollowupProjectSignalMixin(
KernelFollowupProjectSignalActionsMixin,
KernelFollowupProjectSignalResumeMixin,
):
"""Compose project-signal followup actions and resume flow."""
pass

View File

@ -0,0 +1,244 @@
"""AI PMO kernel project-signal followup action mixin."""
from __future__ import annotations
from . import ai_pmo_kernel_common as _common
for _name, _value in vars(_common).items():
if not _name.startswith("__"):
globals()[_name] = _value
class KernelFollowupProjectSignalActionsMixin:
@staticmethod
def _project_signal_idempotency_key(*, work_item_id: int, dedupe_key: str) -> str:
base = f"{int(work_item_id)}:{str(dedupe_key or '').strip().lower()}"
return hashlib.sha1(base.encode("utf-8"), usedforsecurity=False).hexdigest()[:64]
async def _execute_project_signal_followup_action(
self,
*,
route: WorkflowRoute,
target_work_item: WorkItem,
target_ingest: IngestEvent,
followup_sender: str,
operator_user_id: Optional[int],
source_system: str,
source_message_id: str,
world_model: dict[str, Any],
) -> tuple[dict[str, Any], dict[str, Any], str]:
execution_payload = target_work_item.execution_result if isinstance(target_work_item.execution_result, dict) else {}
project_id = (
_safe_int(target_work_item.project_id)
or _safe_int(execution_payload.get("project_id"))
or _safe_int(world_model.get("resolved_project_id"))
)
if project_id is None:
return (
{
"status": "failed_safe",
"workflow_route": route.key,
"workflow_action": route.action,
"followup_reply": "未执行:目标工作项缺少项目归属,无法恢复项目候选工作流。",
"candidate_signals": [],
},
{"verified": False, "issues": ["waiting_manual"]},
STATE_WAITING_MANUAL,
)
if route.action == ROUTE_ACTION_CANCEL:
cancelled = {
**execution_payload,
"status": "cancelled",
"workflow_route": ROUTE_PROJECT_SIGNAL_CANCEL,
"workflow_action": ROUTE_ACTION_CANCEL,
"project_id": int(project_id),
"candidate_signals": execution_payload.get("candidate_signals") if isinstance(execution_payload.get("candidate_signals"), list) else [],
"cancelled_at": _utc_now_naive().isoformat(),
"followup_reply": f"已取消工作项#{int(target_work_item.id)} 的候选信号,本次不会入库。",
}
return cancelled, {"verified": True, "issues": []}, STATE_IGNORED
if route.action == ROUTE_ACTION_REFRESH:
original_payload = target_ingest.payload if isinstance(target_ingest.payload, dict) else {}
original_message = str(original_payload.get("message_text") or "").strip()
refresh_intent = CanonicalIntent(
name=INTENT_SIGNAL_CREATE,
confidence=0.9,
project_id=int(project_id),
signal_title="项目候选信号刷新",
signal_summary=original_message[:300],
metadata={
"workflow_route": ROUTE_PROJECT_SIGNAL_DISCOVER,
"workflow_family": ROUTE_FAMILY_PROJECT_SIGNAL,
"workflow_action": ROUTE_ACTION_DISCOVER,
"requested_count": (
_safe_int(execution_payload.get("requested_count"))
or route.requested_count
or 1
),
"world_model_capacity": world_model.get("capacity") if isinstance(world_model.get("capacity"), list) else [],
"world_model_skill_graph": world_model.get("skill_graph") if isinstance(world_model.get("skill_graph"), list) else [],
},
)
refreshed = await self.executor.discover_project_signal_candidates(
intent=refresh_intent,
message_text=original_message or f"项目ID:{int(project_id)} 候选信号刷新",
source_system=source_system,
source_message_id=f"{source_message_id}:refresh:{int(target_work_item.id)}",
channel_ref=str((original_payload or {}).get("channel_ref") or "").strip() or None,
)
refreshed["workflow_route"] = ROUTE_PROJECT_SIGNAL_REFRESH
refreshed["workflow_action"] = ROUTE_ACTION_REFRESH
refreshed["status"] = "pending_confirmation"
refreshed["followup_reply"] = (
f"已刷新工作项#{int(target_work_item.id)} 的候选信号。"
"请回复“确认”提交,回复“换一批”继续刷新,或回复“取消”结束。"
)
return refreshed, {"verified": False, "issues": ["waiting_manual"]}, STATE_WAITING_MANUAL
# commit
candidates = execution_payload.get("candidate_signals") if isinstance(execution_payload.get("candidate_signals"), list) else []
if not candidates:
return (
{
"status": "failed_safe",
"workflow_route": ROUTE_PROJECT_SIGNAL_COMMIT,
"workflow_action": ROUTE_ACTION_COMMIT,
"project_id": int(project_id),
"candidate_signals": [],
"followup_reply": "未执行:当前候选快照为空,请先执行“换一批”或重新发现候选。",
},
{"verified": False, "issues": ["candidate_missing"]},
STATE_FAILED,
)
commit_results: list[dict[str, Any]] = []
signal_ids: list[int] = []
success_count = 0
failed_count = 0
for index, row in enumerate(candidates):
if not isinstance(row, dict):
continue
dedupe_key = str(row.get("dedupe_key") or f"row-{index + 1}").strip() or f"row-{index + 1}"
idempotency_key = self._project_signal_idempotency_key(
work_item_id=int(target_work_item.id),
dedupe_key=dedupe_key,
)
ledger = (
await self.db.execute(
select(OperationExecutionLedger).where(
and_(
OperationExecutionLedger.tenant_id == self.tenant_id,
OperationExecutionLedger.operation_code == ROUTE_PROJECT_SIGNAL_COMMIT,
OperationExecutionLedger.idempotency_key == idempotency_key,
)
).limit(1)
)
).scalar_one_or_none()
if ledger is not None and str(ledger.status or "").strip().lower() == "applied":
existing_signal_id = _safe_int(ledger.entity_id)
if existing_signal_id is not None:
signal_ids.append(int(existing_signal_id))
success_count += 1
commit_results.append(
{
"dedupe_key": dedupe_key,
"status": "applied",
"signal_id": int(existing_signal_id),
"idempotent_reused": True,
}
)
continue
if ledger is None:
ledger = OperationExecutionLedger(
tenant_id=self.tenant_id,
operation_code=ROUTE_PROJECT_SIGNAL_COMMIT,
project_id=int(project_id),
idempotency_key=idempotency_key,
trace_id=f"work_item:{int(target_work_item.id)}",
status="processing",
payload={
"work_item_id": int(target_work_item.id),
"candidate_dedupe_key": dedupe_key,
},
entity_type="signal",
)
self.db.add(ledger)
await self.db.flush()
try:
signal = await self.executor._create_signal(
title=str(row.get("title") or f"候选信号 {index + 1}"),
summary=str(row.get("summary") or "")[:300],
content=str(row.get("content") or row.get("summary") or "")[:1800],
project_id=int(project_id),
source="PMFlow",
signal_type=str(row.get("signal_type") or "新需求"),
recommended_action="enter_only",
priority=str(row.get("priority") or "medium"),
reasoning_bullets=row.get("reasoning_bullets") if isinstance(row.get("reasoning_bullets"), list) else None,
strategic_analysis={
"source_context": {
"source_message_id": source_message_id,
"workflow_action": ROUTE_ACTION_COMMIT,
"from_work_item_id": int(target_work_item.id),
"candidate_dedupe_key": dedupe_key,
"followup_sender": followup_sender,
},
"ai_analysis": {
"summary": str(row.get("summary") or "")[:300],
"intent": ROUTE_PROJECT_SIGNAL_COMMIT,
"requirement_project_scope": "existing_project",
},
},
)
ledger.status = "applied"
ledger.entity_id = str(int(signal.id))
ledger.result_payload = {
"signal_id": int(signal.id),
"project_id": int(project_id),
}
success_count += 1
signal_ids.append(int(signal.id))
commit_results.append(
{
"dedupe_key": dedupe_key,
"status": "applied",
"signal_id": int(signal.id),
"idempotent_reused": False,
}
)
except Exception as exc:
failed_count += 1
ledger.status = "failed"
ledger.error_message = str(exc)[:500]
commit_results.append(
{
"dedupe_key": dedupe_key,
"status": "failed",
"error": str(exc)[:180],
}
)
execution_result = {
"status": "succeeded" if success_count > 0 else "failed_safe",
"workflow_route": ROUTE_PROJECT_SIGNAL_COMMIT,
"workflow_action": ROUTE_ACTION_COMMIT,
"project_id": int(project_id),
"signal_ids": signal_ids,
"signal_id": signal_ids[0] if signal_ids else None,
"candidate_signals": candidates,
"requested_count": _safe_int(execution_payload.get("requested_count")) or len(candidates),
"commit_results": commit_results,
"partial_failed_count": failed_count,
"success_count": success_count,
"followup_reply": (
f"已按候选快照提交完成:成功 {success_count} 条,失败 {failed_count} 条。"
if failed_count > 0
else f"已按候选快照提交完成,共入库 {success_count} 条信号。"
),
}
verification = {"verified": success_count > 0, "issues": [] if success_count > 0 else ["signal_missing"]}
state = STATE_SUCCEEDED if success_count > 0 else STATE_FAILED
return execution_result, verification, state

View File

@ -0,0 +1,273 @@
"""AI PMO kernel project-signal followup resume mixin."""
from __future__ import annotations
from . import ai_pmo_kernel_common as _common
for _name, _value in vars(_common).items():
if not _name.startswith("__"):
globals()[_name] = _value
class KernelFollowupProjectSignalResumeMixin:
async def _resume_followup_target(
self,
*,
target_work_item: WorkItem,
target_ingest: IngestEvent,
followup_intent: CanonicalIntent,
followup_sender: str,
operator_user_id: Optional[int],
source_system: str,
source_message_id: str,
world_model: dict[str, Any],
message_text: str,
workflow_route: WorkflowRoute,
) -> tuple[dict[str, Any], dict[str, Any]]:
payload = target_ingest.payload if isinstance(target_ingest.payload, dict) else {}
original_message = str(payload.get("message_text") or "").strip()
override_project_id = _safe_int(world_model.get("resolved_project_id"))
target_project_id = _safe_int(target_work_item.project_id) or override_project_id
rebuilt_intent = self.intent_resolver.resolve(text=original_message, project_id=target_project_id)
stored_route_key = self._normalize_route_from_stored_intent(target_work_item.intent)
target_intent_name = RouteResolver.canonical_intent_for_route(stored_route_key)
target_intent = CanonicalIntent(
name=target_intent_name or rebuilt_intent.name,
confidence=float(rebuilt_intent.confidence or 0.88),
project_id=target_project_id,
signal_title=rebuilt_intent.signal_title,
signal_summary=rebuilt_intent.signal_summary,
metadata={
**dict(rebuilt_intent.metadata or {}),
"workflow_route": workflow_route.key,
"workflow_family": workflow_route.family,
"workflow_action": workflow_route.action,
},
)
target_intent = self._enrich_signal_source_intent_from_closure(
intent=target_intent,
message_text=original_message,
closure_payload={
"closure_summary": payload.get("closure_summary"),
"closure_dialogue": payload.get("closure_dialogue"),
"closure_modules": payload.get("closure_modules"),
},
)
if target_intent.name not in FOLLOWUP_TARGET_INTENTS and workflow_route.family not in {
ROUTE_FAMILY_PROJECT_SIGNAL,
ROUTE_FAMILY_SIGNAL_SOURCE,
ROUTE_FAMILY_PROJECT_CREATE,
}:
reply_text = f"工作项#{int(target_work_item.id)} 当前类型为 {target_intent.name or 'unknown'},暂不支持继续处理。"
return {
"status": "failed_safe",
"followup_reply": reply_text,
"target_work_item_id": int(target_work_item.id),
"target_state": target_work_item.state,
"target_intent": target_intent.name,
"resolved_route": workflow_route.key,
}, {"verified": False, "issues": ["unsupported_intent"]}
if target_intent.name in {INTENT_SIGNAL_CREATE, INTENT_SIGNAL_SOURCE_ONBOARD} and target_project_id is None:
reply_text = "未执行:目标工作项仍缺少项目归属,请补充项目 ID 后再继续处理。"
return {
"status": "pending_manual",
"followup_reply": reply_text,
"target_work_item_id": int(target_work_item.id),
"target_state": STATE_WAITING_MANUAL,
"target_intent": target_intent.name,
"resolved_route": workflow_route.key,
}, {"verified": False, "issues": ["waiting_manual"]}
if workflow_route.family == ROUTE_FAMILY_PROJECT_SIGNAL and workflow_route.action in {
ROUTE_ACTION_COMMIT,
ROUTE_ACTION_CANCEL,
ROUTE_ACTION_REFRESH,
}:
target_execution_result, target_verification, state_override = await self._execute_project_signal_followup_action(
route=workflow_route,
target_work_item=target_work_item,
target_ingest=target_ingest,
followup_sender=followup_sender,
operator_user_id=operator_user_id,
source_system=source_system,
source_message_id=source_message_id,
world_model=target_work_item.context_snapshot if isinstance(target_work_item.context_snapshot, dict) else world_model,
)
target_work_item.execution_result = target_execution_result
if _safe_int(target_execution_result.get("project_id")) is not None:
target_work_item.project_id = _safe_int(target_execution_result.get("project_id"))
if _safe_int(target_execution_result.get("signal_id")) is not None:
target_work_item.signal_id = _safe_int(target_execution_result.get("signal_id"))
target_work_item.review_summary = await self.review_service.review_work_item(
work_item=target_work_item,
intent_name=target_intent.name,
execution_result=target_execution_result,
world_model=target_work_item.context_snapshot if isinstance(target_work_item.context_snapshot, dict) else world_model,
verification=target_verification,
simulation={
"followup_resumed": True,
"target_work_item_id": int(target_work_item.id),
"followup_intent": followup_intent.name,
"workflow_route": workflow_route.key,
"trigger_text": message_text[:300],
},
)
await self._maybe_emit_risk_cadence_snapshot(
project_id=_safe_int(target_work_item.project_id),
review_summary=target_work_item.review_summary,
)
target_work_item.state = state_override
if target_work_item.state == STATE_SUCCEEDED:
await self._project_success(intent=target_intent, execution_result=target_execution_result)
target_decision = (
await self.db.execute(
select(DecisionRecord)
.where(
and_(
DecisionRecord.tenant_id == self.tenant_id,
DecisionRecord.work_item_id == int(target_work_item.id),
)
)
.order_by(DecisionRecord.id.desc())
.limit(1)
)
).scalar_one_or_none()
if target_decision is not None:
target_decision.execution_result = target_execution_result
target_decision.final_outcome = target_work_item.state
reply_text = str(target_execution_result.get("followup_reply") or "").strip()
if not reply_text:
reply_text = "已恢复目标工作流。"
status = (
"succeeded"
if target_work_item.state in {STATE_SUCCEEDED, STATE_IGNORED}
else ("pending_manual" if target_work_item.state == STATE_WAITING_MANUAL else "failed_safe")
)
current_verification = {"verified": target_work_item.state in {STATE_SUCCEEDED, STATE_IGNORED}, "issues": []}
if target_work_item.state == STATE_WAITING_MANUAL:
current_verification = {"verified": False, "issues": ["waiting_manual"]}
elif target_work_item.state == STATE_FAILED:
current_verification = {"verified": False, "issues": ["executor_failed"]}
return {
"status": status,
"followup_reply": reply_text,
"target_work_item_id": int(target_work_item.id),
"target_state": target_work_item.state,
"target_intent": workflow_route.key,
"project_id": _safe_int(target_work_item.project_id),
"signal_id": _safe_int(target_execution_result.get("signal_id")) or _safe_int(target_work_item.signal_id),
"signal_ids": _normalize_signal_id_list(target_execution_result.get("signal_ids")),
"resolved_route": workflow_route.key,
"auto_tasks": target_execution_result.get("auto_tasks") if isinstance(target_execution_result.get("auto_tasks"), list) else [],
}, current_verification
target_work_item.project_id = target_project_id
target_work_item.state = STATE_EXECUTING
target_execution_result = await self.executor.execute(
intent=target_intent,
message_text=original_message,
sender=followup_sender,
operator_user_id=operator_user_id,
source_system=source_system,
source_message_id=f"{source_message_id}:resume:{int(target_work_item.id)}",
)
target_execution_result.setdefault("workflow_route", workflow_route.key)
target_execution_result.setdefault("workflow_action", workflow_route.action)
target_work_item.execution_result = target_execution_result
if _safe_int(target_execution_result.get("signal_id")) is not None:
target_work_item.signal_id = _safe_int(target_execution_result.get("signal_id"))
target_verification = await self._verify_execution(
intent=target_intent,
execution_result=target_execution_result,
world_model=target_work_item.context_snapshot if isinstance(target_work_item.context_snapshot, dict) else world_model,
)
target_issues = target_verification.get("issues") if isinstance(target_verification.get("issues"), list) else []
if (
not bool(target_verification.get("verified"))
and target_intent.name == INTENT_SIGNAL_SOURCE_ONBOARD
and any(issue in {"signal_source_pending_manual", "pending_manual"} for issue in target_issues)
and _safe_int(target_execution_result.get("signal_id")) is not None
and _safe_int(target_work_item.project_id) is not None
):
target_execution_result["post_process_status"] = str(target_execution_result.get("status") or "").strip() or "pending_manual"
target_execution_result["status"] = "succeeded"
target_execution_result["manual_gate_resolved"] = True
target_verification = {"verified": True, "issues": []}
target_issues = []
target_work_item.review_summary = await self.review_service.review_work_item(
work_item=target_work_item,
intent_name=target_intent.name,
execution_result=target_execution_result,
world_model=target_work_item.context_snapshot if isinstance(target_work_item.context_snapshot, dict) else world_model,
verification=target_verification,
simulation={
"followup_resumed": True,
"target_work_item_id": int(target_work_item.id),
"followup_intent": followup_intent.name,
},
)
await self._maybe_emit_risk_cadence_snapshot(
project_id=_safe_int(target_work_item.project_id),
review_summary=target_work_item.review_summary,
)
if target_verification.get("verified"):
target_work_item.state = STATE_SUCCEEDED
await self._project_success(intent=target_intent, execution_result=target_execution_result)
else:
if any(issue in {"pending_manual", "signal_source_pending_manual"} for issue in target_issues):
target_work_item.state = STATE_WAITING_MANUAL
else:
target_work_item.state = STATE_FAILED
target_decision = (
await self.db.execute(
select(DecisionRecord)
.where(
and_(
DecisionRecord.tenant_id == self.tenant_id,
DecisionRecord.work_item_id == int(target_work_item.id),
)
)
.order_by(DecisionRecord.id.desc())
.limit(1)
)
).scalar_one_or_none()
if target_decision is not None:
target_decision.execution_result = target_execution_result
target_decision.final_outcome = target_work_item.state
target_render = self.reply_renderer.render(
work_state=target_work_item.state,
intent=target_intent,
decision_record={},
execution_result=target_execution_result,
)
reply_text = str(target_render.get("text") or "").strip()
if target_work_item.state == STATE_SUCCEEDED:
reply_text = f"已继续处理:{reply_text}"
elif not reply_text:
reply_text = "未执行:继续处理后仍未形成可验证终态。"
project_id = _safe_int(target_execution_result.get("project_id")) or _safe_int(target_work_item.project_id)
signal_id = _safe_int(target_execution_result.get("signal_id")) or _safe_int(target_work_item.signal_id)
status = (
"succeeded"
if target_work_item.state == STATE_SUCCEEDED
else ("pending_manual" if target_work_item.state == STATE_WAITING_MANUAL else "failed_safe")
)
current_verification = {"verified": target_work_item.state == STATE_SUCCEEDED, "issues": []}
if target_work_item.state == STATE_WAITING_MANUAL:
current_verification = {"verified": False, "issues": ["waiting_manual"]}
elif target_work_item.state == STATE_FAILED:
current_verification = {"verified": False, "issues": ["executor_failed"]}
return {
"status": status,
"followup_reply": reply_text,
"target_work_item_id": int(target_work_item.id),
"target_state": target_work_item.state,
"target_intent": target_intent.name,
"project_id": project_id,
"signal_id": signal_id,
"resolved_route": workflow_route.key,
"auto_tasks": target_execution_result.get("auto_tasks") if isinstance(target_execution_result.get("auto_tasks"), list) else [],
}, current_verification

View File

@ -0,0 +1,114 @@
"""AI PMO kernel followup record mixin."""
from __future__ import annotations
from . import ai_pmo_kernel_common as _common
for _name, _value in vars(_common).items():
if not _name.startswith("__"):
globals()[_name] = _value
class KernelFollowupRecordsMixin:
async def _create_decision_record(
self,
*,
work_item: WorkItem,
world_model: dict[str, Any],
decision_payload: dict[str, Any],
) -> DecisionRecord:
strategic = decision_payload["strategic"]
policy = decision_payload["policy"]
row = DecisionRecord(
tenant_id=self.tenant_id,
work_item_id=int(work_item.id),
decision_object_type=str(strategic.get("decision_object_type") or "portfolio"),
decision_object_id=_safe_int(strategic.get("decision_object_id")),
decision_level=str(strategic.get("decision_level") or "portfolio"),
context_snapshot=world_model,
rationale=str(strategic.get("rationale") or ""),
confidence=0.82,
risk_score=float(strategic.get("risk_score") or 0.0),
budget_impact=strategic.get("budget_impact"),
resource_impact=strategic.get("resource_impact"),
simulation_result=decision_payload.get("simulation"),
policy_gate_hit=bool(policy.get("policy_gate_hit")),
policy_gate_code=(str(policy.get("policy_gate_code") or "")[:80] or None),
action_bundle=decision_payload["execution_plan"],
approval_requirement=policy.get("approval_requirement"),
final_outcome=STATE_DECIDED,
)
self.db.add(row)
await self.db.flush()
return row
async def _create_plan_bundle(
self,
*,
work_item_id: int,
plan_type: str,
summary: str,
payload: dict[str, Any],
) -> PlanBundle:
row = PlanBundle(
tenant_id=self.tenant_id,
work_item_id=int(work_item_id),
plan_type=plan_type[:20],
summary=summary[:2000],
payload=payload,
)
self.db.add(row)
await self.db.flush()
return row
async def _claim_reply_receipt(
self,
*,
work_item_id: int,
source_system: str,
source_message_id: str,
receipt_phase: str,
template_key: str,
payload: dict[str, Any],
) -> bool:
row = ReplyReceipt(
tenant_id=self.tenant_id,
work_item_id=int(work_item_id),
source_system=source_system[:32],
source_message_id=source_message_id[:128],
receipt_phase=receipt_phase[:32],
template_key=template_key[:64],
payload=payload,
status="ready",
)
self.db.add(row)
try:
await self.db.flush()
return True
except IntegrityError:
await self.db.rollback()
existing_ingest = (
await self.db.execute(
select(IngestEvent).where(
and_(
IngestEvent.tenant_id == self.tenant_id,
IngestEvent.channel == f"{source_system}_message",
IngestEvent.event_key == source_message_id,
)
).limit(1)
)
).scalar_one()
existing_work_item = (
await self.db.execute(
select(WorkItem).where(
and_(
WorkItem.tenant_id == self.tenant_id,
WorkItem.ingest_event_id == int(existing_ingest.id),
)
).limit(1)
)
).scalar_one_or_none()
if existing_work_item is not None:
self.db.add(existing_work_item)
return False

View File

@ -0,0 +1,240 @@
"""AI PMO kernel followup routing mixin."""
from __future__ import annotations
from . import ai_pmo_kernel_common as _common
for _name, _value in vars(_common).items():
if not _name.startswith("__"):
globals()[_name] = _value
class KernelFollowupRoutingMixin:
async def _project_waiting_manual(
self,
*,
work_item: WorkItem,
message_text: str,
render: dict[str, Any],
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(render.get("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-waiting:{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],
},
commit=False,
)
async def _project_success(self, *, intent: CanonicalIntent, execution_result: dict[str, Any]) -> None:
if intent.name == INTENT_SIGNAL_CREATE:
signal_ids = _normalize_signal_id_list(execution_result.get("signal_ids"))
signal_id = _safe_int(execution_result.get("signal_id"))
if signal_id is not None and signal_id not in signal_ids:
signal_ids = [signal_id, *signal_ids]
if not signal_ids:
return
summary_pool = execution_result.get("summaries") if isinstance(execution_result.get("summaries"), list) else []
for index, item_signal_id in enumerate(signal_ids):
signal_content = str(summary_pool[index] if index < len(summary_pool) else "")[:300]
if not signal_content:
signal_content = str(execution_result.get("summary") or item_signal_id)
await NotificationService(self.db).notify_signal_created(
tenant_id=self.tenant_id,
signal_id=int(item_signal_id),
signal_content=signal_content,
source="ai_pmo",
target_url=(
f"/projects/{int(execution_result['project_id'])}/board"
if _safe_int(execution_result.get("project_id")) is not None
else None
),
)
async def _execute_followup_intent(
self,
*,
work_item: WorkItem,
intent: CanonicalIntent,
message_text: str,
sender: str,
operator_user_id: Optional[int],
source_system: str,
source_message_id: str,
channel_ref: Optional[str],
source_sender_open_id: Optional[str],
world_model: dict[str, Any],
workflow_route: WorkflowRoute,
) -> tuple[dict[str, Any], dict[str, Any]]:
explicit_target_id = self._extract_followup_work_item_id(message_text)
followup_action = RouteResolver.parse_followup_action(
message_text=message_text,
canonical_intent=intent.name,
)
candidates = await self._load_recent_followup_candidates(
current_work_item_id=int(work_item.id),
source_system=source_system,
channel_ref=channel_ref,
sender=sender,
source_sender_open_id=source_sender_open_id,
explicit_target_id=explicit_target_id,
)
actionable = [
(target, ingest)
for target, ingest in candidates
if str(target.state or "") == STATE_WAITING_MANUAL
and (
str(target.intent or "") in FOLLOWUP_TARGET_INTENTS
or self._stored_intent_family(target.intent) in {
ROUTE_FAMILY_PROJECT_SIGNAL,
ROUTE_FAMILY_SIGNAL_SOURCE,
ROUTE_FAMILY_PROJECT_CREATE,
}
)
]
if explicit_target_id is not None and not actionable:
matched = next((target for target, _ in candidates if int(target.id) == int(explicit_target_id)), None)
if matched is not None:
reply_text = f"工作项#{int(matched.id)} 当前状态为 {matched.state},无需继续处理。"
return {
"status": "succeeded",
"followup_reply": reply_text,
"target_work_item_id": int(matched.id),
"target_state": matched.state,
"target_intent": matched.intent,
"project_id": _safe_int(matched.project_id),
"signal_id": _safe_int(matched.signal_id),
"resolved_route": workflow_route.key,
}, {"verified": True, "issues": []}
if not actionable:
reply_text = "当前群最近没有可继续处理的待确认工作项。"
return {
"status": "pending_manual",
"followup_reply": reply_text,
"candidate_work_item_ids": [],
"resolved_route": workflow_route.key,
}, {"verified": False, "issues": ["waiting_manual"]}
if len(actionable) > 1:
reply_text = self._build_followup_candidate_clarification([target for target, _ in actionable])
return {
"status": "pending_manual",
"followup_reply": reply_text,
"candidate_work_item_ids": [int(target.id) for target, _ in actionable[:5]],
"resolved_route": workflow_route.key,
}, {"verified": False, "issues": ["waiting_manual", "followup_ambiguous"]}
target_work_item, target_ingest = actionable[0]
target_route_key = self._normalize_route_from_stored_intent(target_work_item.intent)
target_family, target_action = RouteResolver._split_route_key(target_route_key)
resolved_target_route = workflow_route
if target_family == ROUTE_FAMILY_PROJECT_SIGNAL:
projected_action = ROUTE_ACTION_COMMIT
if followup_action == ROUTE_ACTION_REFRESH:
projected_action = ROUTE_ACTION_REFRESH
elif followup_action == ROUTE_ACTION_CANCEL:
projected_action = ROUTE_ACTION_CANCEL
resolved_target_route = WorkflowRoute(
family=ROUTE_FAMILY_PROJECT_SIGNAL,
action=projected_action,
project_id=_safe_int(target_work_item.project_id),
requested_count=(
_safe_int(
((target_work_item.execution_result or {}) if isinstance(target_work_item.execution_result, dict) else {}).get("requested_count")
)
or 1
),
requires_confirmation=projected_action in {ROUTE_ACTION_COMMIT, ROUTE_ACTION_CANCEL, ROUTE_ACTION_REFRESH},
metadata={
"workflow_route": f"{ROUTE_FAMILY_PROJECT_SIGNAL}.{projected_action}",
"followup_action": followup_action,
"target_work_item_id": int(target_work_item.id),
},
)
elif target_family in {ROUTE_FAMILY_SIGNAL_SOURCE, ROUTE_FAMILY_PROJECT_CREATE}:
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),
project_id=_safe_int(target_work_item.project_id),
requested_count=1,
requires_confirmation=True,
metadata={
"workflow_route": target_route_key,
"followup_action": followup_action,
"target_work_item_id": int(target_work_item.id),
},
)
handler = self.workflow_registry.get_handler(route=resolved_target_route)
return await handler.resume(
route=resolved_target_route,
target_work_item=target_work_item,
target_ingest=target_ingest,
followup_sender=sender,
operator_user_id=operator_user_id,
source_system=source_system,
source_message_id=source_message_id,
message_text=message_text,
world_model=world_model,
)
async def _load_recent_followup_candidates(
self,
*,
current_work_item_id: int,
source_system: str,
channel_ref: Optional[str],
sender: str,
source_sender_open_id: Optional[str],
explicit_target_id: Optional[int],
limit: int = 12,
) -> list[tuple[WorkItem, IngestEvent]]:
rows = (
await self.db.execute(
select(WorkItem, IngestEvent)
.join(IngestEvent, IngestEvent.id == WorkItem.ingest_event_id)
.where(
and_(
WorkItem.tenant_id == self.tenant_id,
WorkItem.id != int(current_work_item_id),
)
)
.order_by(WorkItem.id.desc())
.limit(max(limit * 3, 18))
)
).all()
selected: list[tuple[WorkItem, IngestEvent]] = []
normalized_channel = str(channel_ref or "").strip()
normalized_sender = str(sender or "").strip()
normalized_open_id = str(source_sender_open_id or "").strip()
for target_work_item, ingest in rows:
if explicit_target_id is not None:
if int(target_work_item.id) == int(explicit_target_id):
selected.append((target_work_item, ingest))
continue
payload = ingest.payload if isinstance(ingest.payload, dict) else {}
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
same_sender = normalized_sender and str(payload.get("sender") or "").strip() == normalized_sender
if not (same_channel or same_open_id or same_sender):
continue
selected.append((target_work_item, ingest))
if len(selected) >= limit:
break
return selected

View File

@ -0,0 +1,123 @@
"""AI PMO kernel followup verification mixin."""
from __future__ import annotations
from . import ai_pmo_kernel_common as _common
for _name, _value in vars(_common).items():
if not _name.startswith("__"):
globals()[_name] = _value
class KernelFollowupVerificationMixin:
async def _verify_execution(
self,
*,
intent: CanonicalIntent,
execution_result: dict[str, Any],
world_model: dict[str, Any],
) -> dict[str, Any]:
normalized_status = str(execution_result.get("status") or "").strip().lower()
if normalized_status == "failed_safe":
return {"verified": False, "issues": ["executor_failed"]}
if normalized_status in {"pending_manual", "pending_confirmation"}:
return {"verified": False, "issues": ["pending_manual"]}
if normalized_status == "cancelled":
return {"verified": True, "issues": []}
if intent.name in {INTENT_FOLLOWUP_CONFIRM, INTENT_FOLLOWUP_CONTINUE}:
return {
"verified": bool(
_safe_int(execution_result.get("target_work_item_id")) is not None
and str(execution_result.get("target_state") or "").strip() == STATE_SUCCEEDED
),
"issues": [] if str(execution_result.get("target_state") or "").strip() == STATE_SUCCEEDED else ["waiting_manual"],
}
if intent.name == INTENT_QUERY:
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 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"))
if signal_id is not None and signal_id not in signal_ids:
signal_ids = [signal_id, *signal_ids]
if not signal_ids:
return {"verified": False, "issues": ["signal_missing"]}
if intent.name == INTENT_SIGNAL_SOURCE_ONBOARD:
workflow = execution_result.get("signal_source_workflow") if isinstance(execution_result.get("signal_source_workflow"), dict) else {}
if str(workflow.get("status") or "").lower() != "completed":
return {"verified": False, "issues": ["signal_source_pending_manual"]}
if intent.project_id is None:
return {"verified": True, "issues": []}
linked_signal_ids = (
await self.db.execute(
select(ProjectSignalLink.signal_id).where(
and_(
ProjectSignalLink.tenant_id == self.tenant_id,
ProjectSignalLink.project_id == int(intent.project_id),
ProjectSignalLink.signal_id.in_(signal_ids),
)
)
)
).scalars().all()
linked_set = {int(item) for item in linked_signal_ids if _safe_int(item) is not None}
missing = [sid for sid in signal_ids if sid not in linked_set]
return {
"verified": not missing,
"issues": [] if not missing else ["project_signal_link_missing"],
}
return {"verified": False, "issues": ["unsupported_intent"]}
@staticmethod
def _should_bypass_policy_gate(*, intent: CanonicalIntent, workflow_route: WorkflowRoute) -> bool:
if intent.name in {INTENT_FOLLOWUP_CONFIRM, INTENT_FOLLOWUP_CONTINUE}:
return True
if workflow_route.family == ROUTE_FAMILY_PROJECT_SIGNAL and workflow_route.action in {
ROUTE_ACTION_DISCOVER,
ROUTE_ACTION_COMMIT,
ROUTE_ACTION_CANCEL,
ROUTE_ACTION_REFRESH,
}:
return True
if workflow_route.family == ROUTE_FAMILY_SIGNAL_SOURCE and workflow_route.action == ROUTE_ACTION_ONBOARD:
return True
return False
@classmethod
def _normalize_route_from_stored_intent(cls, value: Any) -> str:
return RouteResolver.normalize_route_key(str(value or ""))
@classmethod
def _stored_intent_family(cls, value: Any) -> str:
route_key = cls._normalize_route_from_stored_intent(value)
family, _ = RouteResolver._split_route_key(route_key)
return family
@staticmethod
def _extract_followup_work_item_id(message: str) -> Optional[int]:
match = FOLLOWUP_WORK_ITEM_ID_RE.search(str(message or ""))
return int(match.group(1)) if match else None
@staticmethod
def _build_followup_candidate_clarification(items: list[WorkItem]) -> str:
if not items:
return "当前没有可继续处理的待确认工作项。"
labels = [f"工作项#{int(item.id)}({str(item.intent or '').strip() or 'unknown'})" for item in items[:3]]
return f"当前存在多个待确认工作项:{'、'.join(labels)}。请补充工作项 ID 后再继续处理。"
@staticmethod
def _build_feishu_rag_conversation_key(
*,
source_chat_id: Optional[str],
source_sender_open_id: Optional[str],
sender: Optional[str],
) -> str:
chat = str(source_chat_id or "").strip()
if not chat:
return ""
actor = str(source_sender_open_id or "").strip()
if not actor:
actor = str(sender or "").strip().lower()
actor = re.sub(r"\s+", "", actor)
actor = actor[:96] if actor else "unknown"
return f"feishu:{chat}:{actor}"

View File

@ -0,0 +1,17 @@
"""AI PMO kernel ingress mixin composition."""
from __future__ import annotations
from .ai_pmo_kernel_mixins_ingress_flow import KernelIngressFlowMixin
from .ai_pmo_kernel_mixins_ingress_render import KernelIngressRenderMixin
from .ai_pmo_kernel_mixins_ingress_storage import KernelIngressStorageMixin
class KernelIngressMixin(
KernelIngressFlowMixin,
KernelIngressStorageMixin,
KernelIngressRenderMixin,
):
"""Compose ingress flow, storage, and render helpers."""
pass

View File

@ -0,0 +1,557 @@
"""AI PMO kernel ingress flow mixin."""
from __future__ import annotations
from . import ai_pmo_kernel_common as _common
for _name, _value in vars(_common).items():
if not _name.startswith("__"):
globals()[_name] = _value
class KernelIngressFlowMixin:
async def process_feishu_message(
self,
*,
group_chat_id: str,
message_content: str,
sender: str,
source_message_id: Optional[str],
source_sender_open_id: Optional[str] = None,
captured_at: Optional[datetime] = None,
chat_type: Optional[str] = None,
) -> dict[str, Any]:
return await self.process_ingress(
source_system="feishu",
source_message_id=source_message_id,
message_text=message_content,
sender=sender,
channel_ref=group_chat_id,
source_sender_open_id=source_sender_open_id,
captured_at=captured_at,
chat_type=chat_type,
)
async def process_ingress(
self,
*,
source_system: str,
source_message_id: Optional[str],
message_text: str,
sender: str,
channel_ref: Optional[str] = None,
source_sender_open_id: Optional[str] = None,
captured_at: Optional[datetime] = None,
chat_type: Optional[str] = None,
project_hint_id: Optional[int] = None,
) -> dict[str, Any]:
started = time.perf_counter()
perf_work_item_id: Optional[int] = None
perf_intent_name = ""
def _log_perf(stage_name: str, stage_started_at: float) -> None:
elapsed_seconds = time.perf_counter() - stage_started_at
perf_line = (
f"[PERF] {stage_name} took {elapsed_seconds:.2f}s "
f"work_item={perf_work_item_id if perf_work_item_id is not None else 'N/A'} "
f"intent={perf_intent_name or 'N/A'}"
)
logger.info(perf_line)
print(perf_line, flush=True)
clean_text = self._normalize_text(message_text)
if not clean_text:
return {"handled": True, "status": "ignored", "classification": "empty", "direct_reply_required": False}
transport_mode_reply = self._build_transport_mode_reply(clean_text)
if transport_mode_reply:
return {
"handled": True,
"status": "answered",
"classification": "question",
"linked_project_ids": [int(project_hint_id)] if project_hint_id is not None else [],
"signal_id": None,
"created_project_id": None,
"auto_tasks": [],
"ai_trace_id": None,
"results": [],
"intent": INTENT_QUERY,
"direct_reply_required": True,
"direct_reply_text": transport_mode_reply,
"reply_template_key": "transport_mode_ack",
"work_item_id": None,
"decision_level": None,
"review_summary": None,
}
synthetic_message_id = self._build_source_message_id(
source_system=source_system,
source_message_id=source_message_id,
channel_ref=channel_ref,
sender=sender,
message_text=clean_text,
)
closure_payload = await self._build_message_closure_payload(
source_system=source_system,
channel_ref=channel_ref,
sender=sender,
source_sender_open_id=source_sender_open_id,
message_text=clean_text,
source_message_id=synthetic_message_id,
captured_at=captured_at,
project_id=project_hint_id,
)
ingest_event, created = await self._claim_ingress_event(
source_system=source_system,
source_message_id=synthetic_message_id,
payload={
"sender": sender,
"source_sender_open_id": source_sender_open_id,
"channel_ref": channel_ref,
"chat_type": chat_type,
"captured_at": captured_at.isoformat() if isinstance(captured_at, datetime) else None,
"message_text": clean_text,
"closure_dialogue": closure_payload.get("closure_dialogue"),
"closure_summary": closure_payload.get("closure_summary"),
"closure_modules": closure_payload.get("closure_modules"),
"closure_turns": closure_payload.get("closure_turns"),
},
)
if not created:
return {
"handled": True,
"status": "ignored",
"reason": "duplicate_filtered",
"classification": "duplicate",
"direct_reply_required": False,
"source_message_id": synthetic_message_id,
}
operator = await self._resolve_operator_user(sender=sender, sender_open_id=source_sender_open_id)
work_item = await self._create_work_item(
ingest_event=ingest_event,
source_system=source_system,
source_message_id=synthetic_message_id,
owner_user_id=_safe_int(getattr(operator, "id", None)),
)
perf_work_item_id = int(work_item.id)
session = await self._get_or_create_session(tenant_id=self.tenant_id, sender=sender)
try:
stage_started_at = time.perf_counter()
project, world_model = await self.context_assembler.assemble_world_model(
sender=sender,
message_text=clean_text,
project_hint_id=project_hint_id,
session=session,
)
_log_perf("_build_world_model", stage_started_at)
self._schedule_portfolio_snapshot_persist(world_model=world_model)
work_item.state = STATE_MODELED
stage_started_at = time.perf_counter()
intent, intent_context_source = await self._resolve_intent(
message_text=clean_text,
session=session,
world_model=world_model,
)
intent = self._enrich_signal_source_intent_from_closure(
intent=intent,
message_text=clean_text,
closure_payload=closure_payload,
)
if intent.name in WRITE_INTENTS and IntentResolver._looks_like_negated_write_statement(text=clean_text):
metadata = dict(intent.metadata or {})
metadata["guard_reason"] = "negated_write_statement"
metadata["guarded_from_intent"] = intent.name
intent = CanonicalIntent(
INTENT_QUERY,
max(float(intent.confidence or 0.0), 0.9),
intent.project_id,
"",
clean_text[:300],
metadata,
)
workflow_route = self.route_resolver.from_canonical(
canonical_intent=intent,
message_text=clean_text,
)
route_metadata = dict(intent.metadata or {})
route_metadata.update(
{
"workflow_route": workflow_route.key,
"workflow_family": workflow_route.family,
"workflow_action": workflow_route.action,
"requested_count": workflow_route.requested_count,
"requires_confirmation": workflow_route.requires_confirmation,
"world_model_capacity": world_model.get("capacity") if isinstance(world_model.get("capacity"), list) else [],
"world_model_skill_graph": world_model.get("skill_graph") if isinstance(world_model.get("skill_graph"), list) else [],
}
)
intent.metadata = route_metadata
workflow_route.metadata = route_metadata
perf_intent_name = workflow_route.key
_log_perf("_resolve_intent", stage_started_at)
if intent_context_source:
world_model["context_source"] = intent_context_source
work_item.context_snapshot = world_model
resolved_project_for_session = _safe_int(workflow_route.project_id) or _safe_int(world_model.get("resolved_project_id"))
fallback_intent = self.intent_resolver.resolve(
text=clean_text,
project_id=_safe_int(world_model.get("resolved_project_id")),
)
if intent.name == INTENT_IGNORE:
work_item.state = STATE_IGNORED
work_item.intent = INTENT_IGNORE
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=clean_text,
route="ai_pmo.ignore",
operation_code=None,
operation_result={"reason": intent.metadata.get("reason")},
processing_status="success",
duration_ms=int((time.perf_counter() - started) * 1000),
)
await self._update_session(
tenant_id=self.tenant_id,
sender=sender,
current_project_id=resolved_project_for_session,
last_intent=intent.name,
last_message_text=clean_text[:200],
)
await self.db.commit()
return {
"handled": True,
"status": "ignored",
"classification": "ignored",
"direct_reply_required": False,
"work_item_id": int(work_item.id),
"ai_trace_id": f"ai-pmo-{int(work_item.id)}",
}
work_item.intent = workflow_route.key
work_item.project_id = workflow_route.project_id
stage_started_at = time.perf_counter()
decision_payload = self.decision_engine.decide(
intent=intent,
world_model=world_model,
message_text=clean_text,
)
_log_perf("DecisionEngine.decide", stage_started_at)
correction_action = str((intent.metadata or {}).get("correction_action") or "none").strip() or "none"
requires_manual_review = bool((intent.metadata or {}).get("requires_manual_review"))
manual_review_code = str((intent.metadata or {}).get("manual_review_code") or "manual_review_required").strip()
manual_review_reason = str((intent.metadata or {}).get("manual_review_reason") or "").strip()
llm_function = str((intent.metadata or {}).get("llm_function") or INTENT_LLM_FUNCTION_NAME).strip()
route_tier = str((intent.metadata or {}).get("route_tier") or "rule").strip()
route_reasons = (intent.metadata or {}).get("route_reasons")
gate = kernel_gate_execution(
confidence=float(intent.confidence or 0.0),
risk_level=self._risk_level_for_intent(intent.name),
correction_action=correction_action,
force_manual=requires_manual_review,
force_manual_code=manual_review_code,
force_manual_reason=manual_review_reason,
)
decision_trace = build_decision_trace(
intent=intent.name,
confidence=float(intent.confidence or 0.0),
route_tier=route_tier,
route_function=llm_function,
gate_action=gate.action,
gate_code=gate.gate_code,
correction_action=correction_action,
correction_keywords=(intent.metadata or {}).get("correction_keywords") if isinstance(intent.metadata, dict) else [],
complexity_reasons=route_reasons if isinstance(route_reasons, list) else [],
)
work_item.state = STATE_DECIDED
work_item.decision_level = str((decision_payload["strategic"] or {}).get("decision_level") or "portfolio")
decision_row = await self._create_decision_record(
work_item=work_item,
world_model=world_model,
decision_payload=decision_payload,
)
await self._create_plan_bundle(
work_item_id=int(work_item.id),
plan_type="strategic",
summary="Strategic Brain 产出的项目群/项目策略。",
payload=decision_payload["strategic_plan"],
)
await self._create_plan_bundle(
work_item_id=int(work_item.id),
plan_type="execution",
summary="Execution Plan typed action bundle。",
payload=decision_payload["execution_plan"],
)
policy = decision_payload["policy"]
if self._should_bypass_policy_gate(
intent=intent,
workflow_route=workflow_route,
):
policy["auto_execute"] = True
policy["policy_gate_hit"] = False
policy["policy_gate_code"] = None
policy["approval_requirement"] = {
"required": False,
"reason": "workflow_policy_bypass",
"message": "工作流恢复路径按快照执行,不触发仿真门禁拦截。",
}
if not gate.auto_execute:
policy["auto_execute"] = False
policy["policy_gate_hit"] = True
policy["policy_gate_code"] = gate.gate_code
policy["approval_requirement"] = {
"required": True,
"reason": gate.gate_code,
"message": gate.reason,
}
if not bool(policy.get("auto_execute")):
work_item.state = STATE_WAITING_MANUAL
work_item.action_bundle = decision_payload["execution_plan"]
work_item.execution_result = {
"status": "waiting_manual",
"reason": ((policy.get("approval_requirement") or {}).get("message") or "").strip(),
"workflow_route": workflow_route.key,
"workflow_action": workflow_route.action,
"decision_trace": decision_trace,
}
manual_issues = ["waiting_manual"]
policy_gate_code = str(policy.get("policy_gate_code") or "").strip()
if policy_gate_code:
manual_issues.append(policy_gate_code)
work_item.review_summary = await self.review_service.review_work_item(
work_item=work_item,
intent_name=intent.name,
execution_result=work_item.execution_result,
world_model=world_model,
verification={"verified": False, "issues": manual_issues},
simulation=decision_payload["simulation"],
)
render = self.reply_renderer.render(
work_state=STATE_WAITING_MANUAL,
intent=intent,
decision_record={
"approval_requirement": policy.get("approval_requirement"),
},
execution_result={},
)
work_item.reply_template_key = str(render["template_key"])
await self._project_waiting_manual(
work_item=work_item,
message_text=clean_text,
render=render,
intent=intent,
)
await self._maybe_emit_risk_cadence_snapshot(
project_id=_safe_int(work_item.project_id),
review_summary=work_item.review_summary,
)
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=clean_text,
route="ai_pmo.waiting_manual",
operation_code=None,
operation_result=work_item.execution_result,
processing_status="success",
duration_ms=int((time.perf_counter() - started) * 1000),
)
await self._update_session(
tenant_id=self.tenant_id,
sender=sender,
current_project_id=_safe_int(work_item.project_id),
last_intent=intent.name,
last_message_text=clean_text[:200],
)
await self.db.commit()
stage_started_at = time.perf_counter()
capture_result = self._build_capture_result(
work_item=work_item,
intent=intent,
execution_result=work_item.execution_result or {},
render=render,
direct_reply_required=direct_reply_required,
status="captured",
classification="command",
)
_log_perf("_build_capture_result", stage_started_at)
return capture_result
work_item.state = STATE_PLANNED
work_item.action_bundle = decision_payload["execution_plan"]
work_item.state = STATE_EXECUTING
if intent.name in {INTENT_FOLLOWUP_CONFIRM, INTENT_FOLLOWUP_CONTINUE}:
stage_started_at = time.perf_counter()
execution_result, verification = await self._execute_followup_intent(
work_item=work_item,
intent=intent,
message_text=clean_text,
sender=sender,
operator_user_id=_safe_int(getattr(operator, "id", None)),
source_system=source_system,
source_message_id=synthetic_message_id,
channel_ref=channel_ref,
source_sender_open_id=source_sender_open_id,
world_model=world_model,
workflow_route=workflow_route,
)
_log_perf("TypedExecutor.execute", stage_started_at)
else:
stage_started_at = time.perf_counter()
handler = self.workflow_registry.get_handler(route=workflow_route)
execution_result = await handler.start(
route=workflow_route,
intent=intent,
message_text=clean_text,
sender=sender,
operator_user_id=_safe_int(getattr(operator, "id", None)),
source_system=source_system,
source_message_id=synthetic_message_id,
channel_ref=channel_ref,
)
_log_perf("TypedExecutor.execute", stage_started_at)
verification = await self._verify_execution(intent=intent, execution_result=execution_result, world_model=world_model)
if isinstance(execution_result, dict):
execution_result.setdefault("decision_trace", decision_trace)
execution_result.setdefault("workflow_route", workflow_route.key)
execution_result.setdefault("workflow_action", workflow_route.action)
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)
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"))
if _safe_int(execution_result.get("signal_id")) is not None:
work_item.signal_id = _safe_int(execution_result.get("signal_id"))
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
issues = verification.get("issues") if isinstance(verification.get("issues"), list) else []
if verification["verified"]:
work_item.state = STATE_SUCCEEDED
await self._project_success(intent=intent, execution_result=execution_result)
elif any(
issue in {"waiting_manual", "followup_ambiguous", "signal_source_pending_manual"}
for issue in issues
) or str(execution_result.get("status") or "").strip().lower() in {"pending_manual", "pending_confirmation"}:
work_item.state = STATE_WAITING_MANUAL
else:
work_item.state = STATE_FAILED
decision_row.final_outcome = work_item.state
render = self.reply_renderer.render(
work_state=work_item.state,
intent=intent,
decision_record={
"approval_requirement": decision_payload["policy"].get("approval_requirement"),
},
execution_result=execution_result,
)
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=clean_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_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),
)
final_project_id = _safe_int(work_item.project_id) or resolved_project_for_session
final_intent_name = intent.name or fallback_intent.name
await self._update_session(
tenant_id=self.tenant_id,
sender=sender,
current_project_id=final_project_id,
last_intent=final_intent_name,
last_message_text=clean_text[:200],
)
await self.db.commit()
execution_route_key = RouteResolver.normalize_route_key(
str((execution_result or {}).get("workflow_route") or "")
) if isinstance(execution_result, dict) else ""
should_run_signal_planner = (
work_item.state == STATE_SUCCEEDED
and (
intent.name == INTENT_SIGNAL_CREATE
or execution_route_key == ROUTE_PROJECT_SIGNAL_COMMIT
)
)
if should_run_signal_planner:
signal_ids = _normalize_signal_id_list(execution_result.get("signal_ids"))
signal_id = _safe_int(execution_result.get("signal_id"))
if signal_id is not None and signal_id not in signal_ids:
signal_ids = [signal_id, *signal_ids]
for planner_signal_id in signal_ids:
asyncio.create_task(
SignalPlannerService.analyze(
signal_id=int(planner_signal_id),
tenant_id=int(self.tenant_id),
source_message_id=synthetic_message_id,
source_chat_id=channel_ref,
dedupe_seed=f"signal-planner-failed:{int(planner_signal_id)}",
session_factory=self._background_session_factory,
)
)
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="answered" if intent.name in {INTENT_QUERY, INTENT_FOLLOWUP_CONFIRM, INTENT_FOLLOWUP_CONTINUE} and work_item.state == STATE_SUCCEEDED else "captured",
classification=(
"question"
if intent.name == INTENT_QUERY
else ("signal" if intent.name == INTENT_SIGNAL_CREATE else "command")
),
)
_log_perf("_build_capture_result", stage_started_at)
return capture_result
except Exception as exc:
await self.db.rollback()
logger.warning(
"ai_pmo_kernel_failed",
tenant_id=self.tenant_id,
source_system=source_system,
source_message_id=synthetic_message_id,
error=str(exc),
)
return {"handled": False, "reason": "kernel_exception", "error": str(exc)}

View File

@ -0,0 +1,172 @@
"""AI PMO kernel ingress render mixin."""
from __future__ import annotations
from . import ai_pmo_kernel_common as _common
for _name, _value in vars(_common).items():
if not _name.startswith("__"):
globals()[_name] = _value
class KernelIngressRenderMixin:
def _build_capture_result(
self,
*,
work_item: WorkItem,
intent: CanonicalIntent,
execution_result: dict[str, Any],
render: dict[str, Any],
direct_reply_required: bool,
status: str,
classification: str,
) -> dict[str, Any]:
linked_project_ids = [int(work_item.project_id)] if work_item.project_id is not None else []
direct_reply_text = str(render.get("text") or "").strip()
if _looks_like_upstream_timeout_message(direct_reply_text):
logger.warning(
"ai_pmo_upstream_timeout_reply_detected",
tenant_id=int(self.tenant_id),
work_item_id=int(work_item.id),
intent=str(intent.name or ""),
source_message_id=str(work_item.source_message_id or "")[:128],
observed_at=now_cn_naive().isoformat(),
reply_preview=direct_reply_text[:260],
)
signal_ids = _normalize_signal_id_list(execution_result.get("signal_ids"))
primary_signal_id = _safe_int(execution_result.get("signal_id")) or _safe_int(work_item.signal_id)
if primary_signal_id is not None and primary_signal_id not in signal_ids:
signal_ids = [primary_signal_id, *signal_ids]
if signal_ids and primary_signal_id is None:
primary_signal_id = signal_ids[0]
result = {
"handled": True,
"status": status,
"classification": classification,
"linked_project_ids": linked_project_ids,
"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,
"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": [
{
"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",
}
],
"intent": intent.name,
"workflow_route": str(work_item.intent or execution_result.get("workflow_route") or "").strip() or None,
"direct_reply_required": bool(direct_reply_required),
"direct_reply_text": direct_reply_text,
"reply_template_key": str(render.get("template_key") or "").strip(),
"work_item_id": int(work_item.id),
"decision_level": work_item.decision_level,
"review_summary": work_item.review_summary,
"decision_trace": (
execution_result.get("decision_trace")
if isinstance(execution_result, dict)
else ((intent.metadata or {}).get("decision_trace") if isinstance(intent.metadata, dict) else None)
),
}
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:
raw_payload = execution_result.get("raw_payload") if isinstance(execution_result.get("raw_payload"), dict) else {}
result["project"] = raw_payload.get("project")
if intent.name == INTENT_SIGNAL_SOURCE_ONBOARD:
result["signal_source_workflow"] = execution_result.get("signal_source_workflow")
result["signal_source_orchestration"] = execution_result.get("signal_source_orchestration")
result["ai_execution"] = execution_result.get("signal_source_orchestration")
return result
@staticmethod
def _primary_operation_code(intent_name: str) -> Optional[str]:
normalized_route = RouteResolver.normalize_route_key(intent_name)
if normalized_route == ROUTE_QUERY_ANSWER:
return "query.answer"
if normalized_route == ROUTE_PROJECT_CREATE:
return "project.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
if normalized_route == ROUTE_PROJECT_SIGNAL_COMMIT:
return ROUTE_PROJECT_SIGNAL_COMMIT
return "signal.enter"
if normalized_route == ROUTE_PROJECT_SIGNAL_REFRESH:
return ROUTE_PROJECT_SIGNAL_REFRESH
if normalized_route == ROUTE_PROJECT_SIGNAL_CANCEL:
return ROUTE_PROJECT_SIGNAL_CANCEL
if normalized_route == ROUTE_SIGNAL_SOURCE_ONBOARD:
return "signal_source.onboard"
if normalized_route in {ROUTE_FOLLOWUP_CONFIRM, ROUTE_FOLLOWUP_CONTINUE}:
return "work_item.resume"
if intent_name == INTENT_QUERY:
return "query.answer"
if intent_name == INTENT_PROJECT_CREATE:
return "project.create"
if intent_name == INTENT_SIGNAL_CREATE:
return "signal.enter"
if intent_name == INTENT_SIGNAL_SOURCE_ONBOARD:
return "signal_source.onboard"
if intent_name in {INTENT_FOLLOWUP_CONFIRM, INTENT_FOLLOWUP_CONTINUE}:
return "work_item.resume"
return None
@staticmethod
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}:
return "medium"
return "low"
@staticmethod
def _build_source_message_id(
*,
source_system: str,
source_message_id: Optional[str],
channel_ref: Optional[str],
sender: str,
message_text: str,
) -> str:
explicit = str(source_message_id or "").strip()
if explicit:
return explicit[:128]
basis = f"{source_system}|{channel_ref or ''}|{sender}|{message_text}"
digest = hashlib.sha1(basis.encode("utf-8"), usedforsecurity=False).hexdigest()
return f"{source_system}-{digest[:32]}"
@staticmethod
def _normalize_text(text: str) -> str:
cleaned = re.sub(r"</?at[^>]*>", " ", str(text or ""), flags=re.IGNORECASE)
cleaned = re.sub(r"^@PMFlow\s*", "", cleaned, flags=re.IGNORECASE)
cleaned = re.sub(r"\s+", " ", cleaned).strip()
return cleaned[:4000]
@staticmethod
def _build_transport_mode_reply(message_text: str) -> Optional[str]:
normalized = re.sub(r"\s+", "", str(message_text or "")).lower()
has_long_connection = "长连接" in normalized or "longconnection" in normalized
has_webhook = "webhook" in normalized
if not (has_long_connection and has_webhook):
return None
if not any(token in normalized for token in TRANSPORT_MODE_CORRECTION_HINTS):
return None
return "已按长连接模式处理飞书接入,当前不会走 webhook;这条说明不会再被当成信号源类型变更。"
@staticmethod
def _serialize_project(project: Optional[Project]) -> Optional[ProjectSnapshot]:
if project is None:
return None
return {
"id": int(project.id),
"name": str(project.name or ""),
"title": str(project.title or project.name or ""),
"description": str(project.description or ""),
"status": str(project.status or ""),
"owner_id": _safe_int(project.owner_id),
}

View File

@ -0,0 +1,207 @@
"""AI PMO kernel ingress storage mixin."""
from __future__ import annotations
from . import ai_pmo_kernel_common as _common
for _name, _value in vars(_common).items():
if not _name.startswith("__"):
globals()[_name] = _value
class KernelIngressStorageMixin:
async def _claim_ingress_event(
self,
*,
source_system: str,
source_message_id: str,
payload: dict[str, Any],
) -> tuple[IngestEvent, bool]:
channel = f"{source_system}_message"
existing = (
await self.db.execute(
select(IngestEvent).where(
and_(
IngestEvent.tenant_id == self.tenant_id,
IngestEvent.channel == channel,
IngestEvent.event_key == source_message_id,
)
).limit(1)
)
).scalar_one_or_none()
if existing is not None:
return existing, False
row = IngestEvent(
tenant_id=self.tenant_id,
channel=channel,
event_key=source_message_id,
status="processing",
payload=payload,
retry_count=0,
error_message=None,
processed_at=None,
)
self.db.add(row)
try:
await self.db.flush()
except IntegrityError:
await self.db.rollback()
existing = (
await self.db.execute(
select(IngestEvent).where(
and_(
IngestEvent.tenant_id == self.tenant_id,
IngestEvent.channel == channel,
IngestEvent.event_key == source_message_id,
)
).limit(1)
)
).scalar_one()
return existing, False
return row, True
async def _create_work_item(
self,
*,
ingest_event: IngestEvent,
source_system: str,
source_message_id: str,
owner_user_id: Optional[int],
) -> WorkItem:
row = WorkItem(
tenant_id=self.tenant_id,
ingest_event_id=int(ingest_event.id),
source_system=source_system,
source_message_id=source_message_id,
state=STATE_RECEIVED,
owner_user_id=owner_user_id,
dedupe_key=f"{source_system}:{source_message_id}",
)
self.db.add(row)
await self.db.flush()
return row
@staticmethod
def _build_review_summary(
*,
intent: CanonicalIntent,
verification: dict[str, Any],
world_model: dict[str, Any],
) -> dict[str, Any]:
return {
"verified": bool(verification.get("verified")),
"issues": verification.get("issues") if isinstance(verification.get("issues"), list) else [],
"next_step": "进入下一轮调度" if verification.get("verified") else "等待人工复核",
"risk_score_after": float((world_model.get("risk_summary") or {}).get("open_risk_count") or 0.0),
"intent": intent.name,
}
async def _maybe_emit_risk_cadence_snapshot(
self,
*,
project_id: Optional[int],
review_summary: dict[str, Any],
) -> None:
if not isinstance(review_summary, dict):
return
risk_after = float(review_summary.get("risk_score_after") or 0.0)
divergences = review_summary.get("divergences") if isinstance(review_summary.get("divergences"), list) else []
if risk_after < 0.7 and not divergences:
return
await self.cadence_service.generate_snapshot(
cadence_type="risk_review",
project_id=int(project_id) if project_id is not None else None,
)
async def _resolve_operator_user(self, *, sender: str, sender_open_id: Optional[str]) -> Optional[User]:
cleaned_sender = str(sender or "").strip()
if cleaned_sender:
user = (
await self.db.execute(
select(User).where(
and_(
User.tenant_id == self.tenant_id,
User.is_active.is_(True),
or_(User.name == cleaned_sender, User.username == cleaned_sender),
)
).limit(1)
)
).scalar_one_or_none()
if user is not None:
return user
preferred = (
await self.db.execute(
select(User).where(
and_(
User.tenant_id == self.tenant_id,
User.is_active.is_(True),
User.status == "active",
User.role.in_(["BOSS", "ADMIN", "PM"]),
)
).order_by(User.id.asc()).limit(1)
)
).scalar_one_or_none()
if preferred is not None:
return preferred
return (
await self.db.execute(
select(User).where(
and_(
User.tenant_id == self.tenant_id,
User.is_active.is_(True),
)
).order_by(User.id.asc()).limit(1)
)
).scalar_one_or_none()
async def _write_processing_log(
self,
*,
source_message_id: str,
work_item: WorkItem,
message_text: str,
route: str,
operation_code: Optional[str],
operation_result: Any,
processing_status: str,
duration_ms: int,
) -> None:
self.db.add(
AIProcessingLog(
tenant_id=self.tenant_id,
feishu_message_id=source_message_id if work_item.source_system == "feishu" else None,
feishu_item_id=None,
raw_message=message_text[:8000],
ai_raw_output=json.dumps(
{
"work_item_id": int(work_item.id),
"intent": work_item.intent,
"state": work_item.state,
"reply_template_key": work_item.reply_template_key,
},
ensure_ascii=False,
),
project_score=None,
signal_confidence=None,
level="ai_pmo",
normalized_text=message_text[:8000],
route=route[:80],
intent=(str(work_item.intent or "")[:40] or None),
executor="ai_pmo_kernel",
operation_code=(operation_code[:80] if operation_code else None),
operation_result=operation_result if isinstance(operation_result, dict) else {"result": operation_result},
reply_source=(str(work_item.reply_template_key or "")[:40] or None),
routing_decision=route[:64],
routing_reason=f"AI PMO kernel route={route}",
reasoning_bullets=[
f"work_item_id={int(work_item.id)}",
f"state={work_item.state}",
f"intent={work_item.intent}",
],
processing_status=processing_status[:20],
error_message=None,
duration_ms=int(duration_ms),
)
)
await self.db.flush()

View File

@ -0,0 +1,17 @@
"""AI PMO kernel intent mixin composition."""
from __future__ import annotations
from .ai_pmo_kernel_mixins_intent_llm import KernelIntentLLMMixin
from .ai_pmo_kernel_mixins_intent_resolve import KernelIntentResolveMixin
from .ai_pmo_kernel_mixins_intent_rules import KernelIntentRulesMixin
class KernelIntentMixin(
KernelIntentRulesMixin,
KernelIntentLLMMixin,
KernelIntentResolveMixin,
):
"""Compose intent rules, llm arbitration, and final resolve flow."""
pass

View File

@ -0,0 +1,259 @@
"""AI PMO kernel intent llm-arbitration mixin."""
from __future__ import annotations
from . import ai_pmo_kernel_common as _common
for _name, _value in vars(_common).items():
if not _name.startswith("__"):
globals()[_name] = _value
class KernelIntentLLMMixin:
async def _resolve_intent_with_llm(
self,
*,
message_text: str,
session: Optional[ConversationSession],
world_model: dict[str, Any],
rule_intent: Optional[CanonicalIntent] = None,
) -> Optional[CanonicalIntent]:
project_snapshot = world_model.get("project") if isinstance(world_model.get("project"), dict) else {}
project_names = [
str(project_snapshot.get("title") or "").strip(),
str(project_snapshot.get("name") or "").strip(),
]
route_decision = select_intent_route(message_text, project_names=project_names)
correction_decision = kernel_resolve_correction(message_text)
forced_low_tier = _should_force_low_tier_intent_route(
message_text=message_text,
correction_action=correction_decision.action,
)
prompt = PROMPT_AI_PMO_INTENT_CLASSIFICATION.format(
message_text=str(message_text or "")[:1200],
session_last_intent=str(getattr(session, "last_intent", "") or "").strip() if session is not None else "",
session_project_id=_safe_int(getattr(session, "current_project_id", None)) if session is not None else None,
world_model_project_id=_safe_int(world_model.get("resolved_project_id")),
candidate_intents_json=json.dumps(list(INTENT_CANDIDATES), ensure_ascii=False),
candidate_routes_json=json.dumps(
[
ROUTE_QUERY_ANSWER,
ROUTE_PROJECT_CREATE,
ROUTE_PROJECT_SIGNAL_CREATE,
ROUTE_PROJECT_SIGNAL_DISCOVER,
ROUTE_SIGNAL_SOURCE_ONBOARD,
],
ensure_ascii=False,
),
).strip()
total_budget_seconds = _intent_llm_total_budget_seconds()
stage_started_at = time.perf_counter()
async def _invoke_intent_stage(
*,
function_name: str,
route_tier: str,
route_reasons: list[str],
stage_label: str,
timeout_cap_seconds: Optional[float] = None,
) -> Optional[CanonicalIntent]:
llm_timeout_seconds = _intent_llm_timeout_seconds(function_name=function_name)
if timeout_cap_seconds is not None:
llm_timeout_seconds = min(llm_timeout_seconds, max(float(timeout_cap_seconds), 0.0))
if llm_timeout_seconds <= 0.0:
return None
logger.info(
"ai_pmo_intent_llm_route",
tenant_id=self.tenant_id,
stage=stage_label,
function_name=function_name,
route_tier=route_tier,
timeout_seconds=llm_timeout_seconds,
forced_low_tier=forced_low_tier,
correction_action=correction_decision.action,
correction_keywords=correction_decision.matched_keywords,
route_reasons=route_reasons,
total_budget_seconds=total_budget_seconds,
)
try:
extraction = await asyncio.wait_for(
llm_service.generate_structured_object(
prompt,
db=self.db,
tenant_id=self.tenant_id,
function_name=function_name,
use_prompt_profile=False,
temperature=0.0,
max_tokens=260,
response_mode="fast",
),
timeout=llm_timeout_seconds,
)
except TimeoutError:
logger.warning(
"ai_pmo_intent_llm_timeout",
tenant_id=self.tenant_id,
stage=stage_label,
function_name=function_name,
timeout_seconds=llm_timeout_seconds,
)
return None
except Exception as exc:
logger.warning(
"ai_pmo_intent_llm_failed",
tenant_id=self.tenant_id,
stage=stage_label,
function_name=function_name,
error=str(exc),
)
return None
payload = extraction.payload if extraction and isinstance(extraction.payload, dict) else None
if payload is None:
logger.warning(
"ai_pmo_intent_llm_empty_payload",
tenant_id=self.tenant_id,
stage=stage_label,
function_name=function_name,
error_code=str(getattr(extraction, "error_code", "") or ""),
provider=str(getattr(extraction, "provider", "") or ""),
model=str(getattr(extraction, "model", "") or ""),
)
return None
structured_route = RouteResolver.from_structured_payload(
payload=payload,
message_text=message_text,
fallback_project_id=_safe_int(world_model.get("resolved_project_id")),
)
intent_name = str(payload.get("intent") or "").strip()
if structured_route is not None:
intent_name = RouteResolver.canonical_intent_for_route(structured_route.key)
if intent_name not in INTENT_CANDIDATES:
return None
confidence = float(payload.get("confidence") or 0.0)
project_id = _safe_int(payload.get("project_id"))
if project_id is not None and int(project_id) <= 0:
project_id = None
if project_id is None:
project_id = _safe_int(world_model.get("resolved_project_id"))
if structured_route is not None and structured_route.project_id is not None:
project_id = structured_route.project_id
reasoning = str(payload.get("reasoning") or "").strip()
decision_trace = build_decision_trace(
intent=intent_name,
confidence=confidence,
route_tier=route_tier,
route_function=function_name,
gate_action="unresolved",
gate_code="intent_only",
correction_action=correction_decision.action,
correction_keywords=correction_decision.matched_keywords,
complexity_reasons=route_reasons,
)
return self._build_canonical_intent(
intent_name=intent_name,
message_text=message_text,
project_id=project_id,
confidence=confidence,
metadata={
"context_source": "llm",
"stage": stage_label,
"reasoning": reasoning[:300],
"llm_function": function_name,
"route_tier": route_tier,
"route_complex": route_tier == "high",
"route_reasons": route_reasons,
"forced_low_tier": forced_low_tier,
"timeout_seconds": llm_timeout_seconds,
"total_budget_seconds": total_budget_seconds,
"correction_action": correction_decision.action,
"correction_keywords": correction_decision.matched_keywords,
"decision_trace": decision_trace,
"workflow_route": structured_route.key if structured_route is not None else LEGACY_INTENT_ROUTE_MAP.get(intent_name, ROUTE_QUERY_ANSWER),
"workflow_family": structured_route.family if structured_route is not None else RouteResolver._split_route_key(
LEGACY_INTENT_ROUTE_MAP.get(intent_name, ROUTE_QUERY_ANSWER)
)[0],
"workflow_action": structured_route.action if structured_route is not None else RouteResolver._split_route_key(
LEGACY_INTENT_ROUTE_MAP.get(intent_name, ROUTE_QUERY_ANSWER)
)[1],
"requested_count": (
structured_route.requested_count
if structured_route is not None
else RouteResolver.extract_requested_count(message_text=message_text, metadata=payload)
),
"requires_confirmation": (
structured_route.requires_confirmation
if structured_route is not None
else bool(payload.get("requires_confirmation"))
),
},
)
low_stage_reasons = ["stage1_low_first", *list(route_decision.reasons or [])]
if forced_low_tier:
low_stage_reasons.append("correction_forced_low_tier")
low_intent = await _invoke_intent_stage(
function_name=INTENT_LLM_FUNCTION_NAME,
route_tier="low",
route_reasons=low_stage_reasons,
stage_label="stage1_low",
)
if forced_low_tier:
return low_intent
should_escalate, escalate_reasons = _should_escalate_to_high_intent_route(
route_is_complex=bool(route_decision.is_complex),
low_intent=low_intent,
rule_intent=rule_intent,
)
if not should_escalate:
return low_intent
elapsed_seconds = time.perf_counter() - stage_started_at
remaining_budget_seconds = total_budget_seconds - elapsed_seconds
stage2_min_remaining_seconds = _intent_llm_stage2_min_remaining_seconds()
if remaining_budget_seconds < stage2_min_remaining_seconds:
logger.info(
"ai_pmo_intent_stage2_skipped",
tenant_id=self.tenant_id,
reason="budget_insufficient",
remaining_budget_seconds=max(0.0, remaining_budget_seconds),
min_remaining_seconds=stage2_min_remaining_seconds,
escalate_reasons=escalate_reasons,
)
if low_intent is not None:
return self._copy_intent_with_metadata(
low_intent,
stage2_skipped=True,
stage2_skip_reason="budget_insufficient",
stage2_reasons=escalate_reasons,
)
return None
high_stage_reasons = [*list(route_decision.reasons or []), *escalate_reasons, "stage2_high_retry"]
high_intent = await _invoke_intent_stage(
function_name="classify_intent_complex",
route_tier="high",
route_reasons=high_stage_reasons,
stage_label="stage2_high",
timeout_cap_seconds=remaining_budget_seconds,
)
if high_intent is not None:
if low_intent is not None:
return self._copy_intent_with_metadata(
high_intent,
stage2_promoted=True,
stage1_intent=low_intent.name,
stage1_confidence=float(low_intent.confidence or 0.0),
)
return high_intent
if low_intent is not None:
return self._copy_intent_with_metadata(
low_intent,
stage2_attempted=True,
stage2_failed=True,
stage2_reasons=escalate_reasons,
)
return None

View File

@ -0,0 +1,66 @@
"""AI PMO kernel intent resolve mixin."""
from __future__ import annotations
from . import ai_pmo_kernel_common as _common
for _name, _value in vars(_common).items():
if not _name.startswith("__"):
globals()[_name] = _value
class KernelIntentResolveMixin:
async def _resolve_intent(
self,
*,
message_text: str,
session: Optional[ConversationSession],
world_model: dict[str, Any],
) -> tuple[CanonicalIntent, str]:
project_id = _safe_int(world_model.get("resolved_project_id"))
raw_rule_intent = self.intent_resolver.resolve(text=message_text, project_id=project_id)
rule_intent = self._apply_slot_guard(intent=raw_rule_intent, message_text=message_text)
# 查询/噪音场景优先走规则意图,避免额外触发 LLM 分类链路导致不必要失败重试。
if rule_intent.name in {INTENT_QUERY, INTENT_IGNORE} and float(rule_intent.confidence or 0.0) >= 0.85:
return rule_intent, "rule_preferred"
if self._is_short_confirmation_message(message_text=message_text, session=session):
if session is None:
return rule_intent, "rule_fallback"
return (
self._build_short_confirm_intent(
message_text=message_text,
session=session,
world_model=world_model,
),
"session_inherited",
)
# 默认规则优先:仅在规则低置信/默认兜底时才触发 LLM。
if not self._intent_llm_enabled():
return rule_intent, "rule_fallback"
if not self._should_invoke_llm_last_resort(rule_intent=rule_intent):
return rule_intent, "rule_preferred"
llm_intent = await self._resolve_intent_with_llm(
message_text=message_text,
session=session,
world_model=world_model,
rule_intent=rule_intent,
)
if llm_intent is not None:
if self._intent_arbitration_enabled():
selected_intent, source = self._arbitrate_intent(
rule_intent=rule_intent,
llm_intent=llm_intent,
message_text=message_text,
)
else:
selected_intent, source = llm_intent, "llm"
return self._apply_slot_guard(intent=selected_intent, message_text=message_text), source
logger.info(
"ai_pmo_intent_rule_fallback",
tenant_id=self.tenant_id,
reason="intent_llm_empty_or_failed_or_timeout",
)
return rule_intent, "rule_fallback"

View File

@ -0,0 +1,241 @@
"""AI PMO kernel intent rules mixin."""
from __future__ import annotations
from . import ai_pmo_kernel_common as _common
for _name, _value in vars(_common).items():
if not _name.startswith("__"):
globals()[_name] = _value
class KernelIntentRulesMixin:
@staticmethod
def _intent_llm_enabled() -> bool:
return _env_bool("AI_PMO_INTENT_LLM_ENABLED", True)
def _should_invoke_llm_last_resort(self, *, rule_intent: CanonicalIntent) -> bool:
"""
LLM 仅作为末位兜底:
- 规则默认查询兜底(default_query_fallback)可升级到 LLM
- 规则低置信度可升级到 LLM
- 其余高置信规则结果直接执行,不再触发 LLM
"""
if self._is_default_rule_query(rule_intent):
return True
if rule_intent.name == INTENT_IGNORE:
return False
confidence = float(rule_intent.confidence or 0.0)
return confidence < _intent_llm_stage2_confidence_threshold()
@staticmethod
def _intent_arbitration_enabled() -> bool:
return _env_bool("AI_PMO_INTENT_ARBITRATION_ENABLED", True)
@staticmethod
def _intent_conflict_policy() -> str:
raw = str(os.getenv("AI_PMO_INTENT_CONFLICT_POLICY", "pending_manual") or "").strip().lower()
return raw if raw in INTENT_CONFLICT_POLICIES else "pending_manual"
@staticmethod
def _copy_intent_with_metadata(intent: CanonicalIntent, **metadata_updates: Any) -> CanonicalIntent:
metadata = dict(intent.metadata or {})
metadata.update(metadata_updates)
return CanonicalIntent(
intent.name,
float(intent.confidence or 0.0),
intent.project_id,
intent.signal_title,
intent.signal_summary,
metadata,
)
@staticmethod
def _is_default_rule_query(intent: CanonicalIntent) -> bool:
if intent.name != INTENT_QUERY:
return False
metadata = intent.metadata if isinstance(intent.metadata, dict) else {}
return bool(metadata.get("fallback")) and str(metadata.get("reason") or "") == "default_query_fallback"
@staticmethod
def _has_required_slots(*, intent: CanonicalIntent, message_text: str) -> tuple[bool, dict[str, Any]]:
if intent.name != INTENT_PROJECT_CREATE:
return True, {}
extracted_title = AIStewardService._extract_project_title_from_instruction(message_text)
has_title = bool(str(extracted_title or "").strip())
return has_title, {
"slot_check": "project_title",
"slot_ok": has_title,
"extracted_project_title": extracted_title[:50] if extracted_title else "",
}
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)
if slot_ok:
if slot_meta:
return self._copy_intent_with_metadata(intent, **slot_meta)
return intent
return self._copy_intent_with_metadata(
intent,
**slot_meta,
requires_manual_review=True,
manual_review_code="slot_incomplete",
manual_review_reason="关键信息不完整,需人工确认后执行。",
)
def _arbitrate_intent(
self,
*,
rule_intent: CanonicalIntent,
llm_intent: CanonicalIntent,
message_text: str,
) -> tuple[CanonicalIntent, str]:
if rule_intent.name == llm_intent.name:
return self._copy_intent_with_metadata(
llm_intent,
arbitration="consensus",
arbitration_rule_intent=rule_intent.name,
arbitration_llm_intent=llm_intent.name,
), "llm"
policy = self._intent_conflict_policy()
rule_is_write = rule_intent.name in WRITE_INTENTS
llm_is_write = llm_intent.name in WRITE_INTENTS
has_write_conflict = rule_is_write or llm_is_write
prefer_rule_by_default = (
not self._is_default_rule_query(rule_intent)
and (
rule_intent.confidence >= llm_intent.confidence
or self._is_default_rule_query(llm_intent)
)
)
if policy == "prefer_rule":
chosen = rule_intent
source = "rule_fallback"
elif policy == "prefer_llm":
chosen = llm_intent
source = "llm"
else:
chosen = rule_intent if prefer_rule_by_default else llm_intent
source = "rule_fallback" if chosen is rule_intent else "llm"
if has_write_conflict and policy == "pending_manual":
chosen = self._copy_intent_with_metadata(
chosen,
arbitration="conflict_manual_review",
arbitration_rule_intent=rule_intent.name,
arbitration_llm_intent=llm_intent.name,
arbitration_rule_confidence=float(rule_intent.confidence or 0.0),
arbitration_llm_confidence=float(llm_intent.confidence or 0.0),
requires_manual_review=True,
manual_review_code="intent_conflict_write",
manual_review_reason="规则与LLM意图冲突,且涉及写操作,需人工确认后执行。",
)
return chosen, source
return self._copy_intent_with_metadata(
chosen,
arbitration="prefer_rule" if source == "rule_fallback" else "prefer_llm",
arbitration_rule_intent=rule_intent.name,
arbitration_llm_intent=llm_intent.name,
arbitration_rule_confidence=float(rule_intent.confidence or 0.0),
arbitration_llm_confidence=float(llm_intent.confidence or 0.0),
), source
@classmethod
def _is_short_confirmation_message(
cls,
*,
message_text: str,
session: Optional[ConversationSession],
) -> bool:
if session is None:
return False
if not cls._within_recent_window(
getattr(session, "last_active_at", None),
window_seconds=SESSION_SHORT_CONFIRM_WINDOW_SECONDS,
):
return False
last_intent = str(getattr(session, "last_intent", "") or "").strip()
if not last_intent:
return False
normalized = re.sub(r"\s+", "", str(message_text or "")).lower()
if not normalized:
return False
if len(normalized) > SHORT_CONFIRM_MAX_MESSAGE_LENGTH:
return False
return any(token in normalized for token in SHORT_CONFIRM_HINTS)
@staticmethod
def _build_canonical_intent(
*,
intent_name: str,
message_text: str,
project_id: Optional[int],
confidence: float,
metadata: Optional[dict[str, Any]] = None,
) -> CanonicalIntent:
pretty = re.sub(r"\s+", " ", str(message_text or "")).strip()
extras = dict(metadata or {})
safe_confidence = max(0.0, min(1.0, float(confidence or 0.0)))
if intent_name == INTENT_SIGNAL_SOURCE_ONBOARD:
extras.setdefault("source_request", True)
return CanonicalIntent(
INTENT_SIGNAL_SOURCE_ONBOARD,
safe_confidence,
project_id,
IntentResolver._build_signal_title(pretty, default="新增信号源接入请求"),
pretty[:300],
extras,
)
if intent_name == INTENT_SIGNAL_CREATE:
return CanonicalIntent(
INTENT_SIGNAL_CREATE,
safe_confidence,
project_id,
IntentResolver._build_signal_title(pretty, default="新信号"),
pretty[:300],
extras,
)
if intent_name == INTENT_PROJECT_CREATE:
return CanonicalIntent(
INTENT_PROJECT_CREATE,
safe_confidence,
project_id,
IntentResolver._build_signal_title(pretty, default="新项目创建请求"),
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:
return CanonicalIntent(INTENT_FOLLOWUP_CONFIRM, safe_confidence, project_id, "", "", extras)
if intent_name == INTENT_FOLLOWUP_CONTINUE:
return CanonicalIntent(INTENT_FOLLOWUP_CONTINUE, safe_confidence, project_id, "", "", extras)
return CanonicalIntent(INTENT_IGNORE, safe_confidence, project_id, "", "", extras or {"reason": "unsupported_intent"})
def _build_short_confirm_intent(
self,
*,
message_text: str,
session: ConversationSession,
world_model: dict[str, Any],
) -> CanonicalIntent:
inherited_name = str(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"))
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
return self._build_canonical_intent(
intent_name=inherited_name,
message_text=inherited_text,
project_id=inherited_project_id,
confidence=0.86,
metadata={
"context_source": "session_inherited",
"inherited_from_intent": str(getattr(session, "last_intent", "") or "").strip(),
},
)

View File

@ -0,0 +1,17 @@
"""AI PMO kernel world-model mixin composition."""
from __future__ import annotations
from .ai_pmo_kernel_mixins_world_model_aggregates import KernelWorldModelAggregatesMixin
from .ai_pmo_kernel_mixins_world_model_core import KernelWorldModelCoreMixin
from .ai_pmo_kernel_mixins_world_model_session import KernelWorldModelSessionMixin
class KernelWorldModelMixin(
KernelWorldModelCoreMixin,
KernelWorldModelSessionMixin,
KernelWorldModelAggregatesMixin,
):
"""Compose world-model core, session, and aggregate builders."""
pass

View File

@ -0,0 +1,339 @@
"""AI PMO kernel world-model aggregates mixin."""
from __future__ import annotations
from . import ai_pmo_kernel_common as _common
for _name, _value in vars(_common).items():
if not _name.startswith("__"):
globals()[_name] = _value
class KernelWorldModelAggregatesMixin:
async def _build_portfolio_summary(
self,
*,
project_id: Optional[int],
query_tables: Optional[set[str]] = None,
) -> PortfolioSummary:
self._record_world_model_query_table(query_tables, "projects")
active_project_count = (
await self.db.execute(
select(func.count(Project.id)).where(
and_(
Project.tenant_id == self.tenant_id,
Project.status.in_(["active", "planning", "todo", "doing"]),
)
)
)
).scalar_one()
return {
"active_project_count": int(active_project_count or 0),
"focus_project_id": project_id,
}
async def _build_budget_summary(
self,
*,
project_id: Optional[int],
query_tables: Optional[set[str]] = None,
) -> BudgetSummary:
self._record_world_model_query_table(query_tables, "budgets")
stmt = select(Budget).where(Budget.tenant_id == self.tenant_id)
if project_id is not None:
stmt = stmt.where(or_(Budget.project_id == int(project_id), Budget.project_id.is_(None)))
row = (await self.db.execute(stmt.order_by(Budget.project_id.desc().nulls_last(), Budget.id.desc()).limit(1))).scalars().first()
if row is None:
return {"budget_id": None, "usage_ratio": 0.0, "hard_limit_enabled": False}
amount = float(row.budget_amount_usd or 0.0)
usage = float(row.current_usage_usd or 0.0)
ratio = (usage / amount) if amount > 0 else 0.0
return {
"budget_id": int(row.id),
"budget_type": str(row.budget_type or ""),
"period": str(row.period or ""),
"budget_amount_usd": amount,
"current_usage_usd": usage,
"usage_ratio": round(ratio, 4),
"hard_limit_enabled": bool(row.hard_limit_enabled),
}
async def _build_capacity_summary(
self,
*,
project_id: Optional[int],
query_tables: Optional[set[str]] = None,
) -> list[CapacityItem]:
user_ids: list[int] = []
if project_id is not None:
self._record_world_model_query_table(query_tables, "user_projects")
rows = (
await self.db.execute(
select(UserProject.user_id).where(
and_(
UserProject.tenant_id == self.tenant_id,
UserProject.project_id == int(project_id),
)
)
)
).scalars().all()
user_ids = [int(uid) for uid in rows]
if not user_ids and project_id is not None:
self._record_world_model_query_table(query_tables, "projects")
owner_id = (
await self.db.execute(
select(Project.owner_id).where(
and_(
Project.tenant_id == self.tenant_id,
Project.id == int(project_id),
)
)
)
).scalar_one_or_none()
if owner_id is not None:
user_ids = [int(owner_id)]
if not user_ids:
return []
self._record_world_model_query_table(query_tables, "user_workloads")
rows = (
await self.db.execute(
select(UserWorkload).where(
and_(
UserWorkload.tenant_id == self.tenant_id,
UserWorkload.user_id.in_(user_ids),
)
)
)
).scalars().all()
return [
{
"user_id": int(row.user_id),
"current_assigned_hours": float(row.current_assigned_hours or 0.0),
"max_available_hours_per_week": float(row.max_available_hours_per_week or 0.0),
"workload_ratio": float(row.workload_ratio or 0.0),
}
for row in rows
]
async def _build_skill_graph(
self,
*,
project_id: Optional[int],
query_tables: Optional[set[str]] = None,
) -> list[SkillGraphItem]:
user_ids: list[int] = []
if project_id is not None:
self._record_world_model_query_table(query_tables, "user_projects")
rows = (
await self.db.execute(
select(UserProject.user_id).where(
and_(
UserProject.tenant_id == self.tenant_id,
UserProject.project_id == int(project_id),
)
)
)
).scalars().all()
user_ids = [int(uid) for uid in rows]
if not user_ids:
return []
self._record_world_model_query_table(query_tables, "user_skill_bindings")
rows = (
await self.db.execute(
select(UserSkillBinding).where(
and_(
UserSkillBinding.tenant_id == self.tenant_id,
UserSkillBinding.user_id.in_(user_ids),
)
).limit(50)
)
).scalars().all()
return [
{
"user_id": int(row.user_id),
"skill_tag_id": int(row.skill_tag_id),
"proficiency_level": str(row.proficiency_level or ""),
"quality_score": float(row.quality_score or 0.0),
"efficiency_score": float(row.efficiency_score or 0.0),
}
for row in rows
]
async def _build_project_domain_snapshot(
self,
*,
project_id: Optional[int],
query_tables: Optional[set[str]] = None,
) -> Optional[ProjectDomainSnapshot]:
if project_id is None:
return None
self._record_world_model_query_table(query_tables, "projects")
self._record_world_model_query_table(query_tables, "project_domains")
domain_row = (
await self.db.execute(
select(ProjectDomain)
.join(Project, Project.domain_id == ProjectDomain.id)
.where(
and_(
Project.tenant_id == self.tenant_id,
Project.id == int(project_id),
ProjectDomain.tenant_id == self.tenant_id,
)
)
.limit(1)
)
).scalar_one_or_none()
if domain_row is None:
return None
return {
"id": int(domain_row.id),
"name": str(domain_row.name or ""),
"slug": str(domain_row.slug or ""),
"description": str(domain_row.description or ""),
"owner_id": domain_row.owner_id,
}
async def _build_charter_snapshot(
self,
*,
project_id: Optional[int],
query_tables: Optional[set[str]] = None,
) -> list[CharterItem]:
if project_id is None:
return []
self._record_world_model_query_table(query_tables, "truth_nodes")
rows = (
await self.db.execute(
select(TruthNode).where(
and_(
TruthNode.tenant_id == self.tenant_id,
TruthNode.project_id == int(project_id),
TruthNode.node_type == "charter",
)
).order_by(TruthNode.updated_at.desc(), TruthNode.id.desc()).limit(3)
)
).scalars().all()
return [
{
"id": int(row.id),
"title": str(row.title or ""),
"status": str(row.status or ""),
"content": row.content if isinstance(row.content, dict) else {},
}
for row in rows
]
async def _build_task_summary(
self,
*,
project_id: Optional[int],
skill_graph: list[SkillGraphItem],
query_tables: Optional[set[str]] = None,
) -> TaskSummary:
if project_id is None:
return {"total": 0, "by_status": {}, "overdue_count": 0, "uncovered_skill_tag_ids": [], "blocked_tasks": []}
self._record_world_model_query_table(query_tables, "tasks")
tasks = (
await self.db.execute(
select(Task).where(and_(Task.tenant_id == self.tenant_id, Task.project_id == int(project_id)))
)
).scalars().all()
by_status: dict[str, int] = {}
required_skill_tag_ids: set[int] = set()
blocked_tasks: list[BlockedTask] = []
overdue_count = 0
now = _utc_now_naive()
for row in tasks:
status = str(row.status or "unknown")
by_status[status] = by_status.get(status, 0) + 1
for skill_id in row.required_skill_tag_ids or []:
if skill_id is not None:
required_skill_tag_ids.add(int(skill_id))
if status == "blocked":
blocked_tasks.append({"id": int(row.id), "title": str(row.title or "")[:120]})
if row.due_date and str(row.status or "").lower() not in {"done", "completed", "closed"}:
due_date = row.due_date.replace(tzinfo=None) if row.due_date.tzinfo else row.due_date
if due_date < now:
overdue_count += 1
available_skill_ids = {int(item["skill_tag_id"]) for item in skill_graph if item.get("skill_tag_id") is not None}
uncovered_skill_ids = sorted(skill_id for skill_id in required_skill_tag_ids if skill_id not in available_skill_ids)
return {
"total": sum(by_status.values()),
"by_status": by_status,
"overdue_count": overdue_count,
"required_skill_tag_ids": sorted(required_skill_tag_ids),
"uncovered_skill_tag_ids": uncovered_skill_ids,
"blocked_tasks": blocked_tasks[:8],
}
async def _build_recent_signal_summary(
self,
*,
project_id: Optional[int],
query_tables: Optional[set[str]] = None,
) -> list[RecentSignalItem]:
if project_id is None:
return []
self._record_world_model_query_table(query_tables, "signals")
self._record_world_model_query_table(query_tables, "project_signal_links")
rows = (
await self.db.execute(
select(Signal).where(
and_(
Signal.tenant_id == self.tenant_id,
or_(
Signal.matched_project_id == int(project_id),
Signal.id.in_(
select(ProjectSignalLink.signal_id).where(
and_(
ProjectSignalLink.tenant_id == self.tenant_id,
ProjectSignalLink.project_id == int(project_id),
)
)
),
),
)
).order_by(Signal.updated_at.desc(), Signal.id.desc()).limit(5)
)
).scalars().all()
return [
{
"id": int(row.id),
"title": str(row.title or ""),
"status": str(row.status or ""),
"signal_type": str(row.signal_type or ""),
}
for row in rows
]
async def _build_risk_summary(
self,
*,
project_id: Optional[int],
query_tables: Optional[set[str]] = None,
) -> RiskSummary:
self._record_world_model_query_table(query_tables, "notifications")
stmt = select(func.count(Notification.id)).where(
and_(
Notification.tenant_id == self.tenant_id,
Notification.is_read.is_(False),
Notification.kind.in_(["risk", "alert", "action_required"]),
)
)
if project_id is not None:
stmt = stmt.where(or_(Notification.entity_id == int(project_id), Notification.payload.is_(None), Notification.entity_type == "project"))
open_risk_count = (await self.db.execute(stmt)).scalar_one()
return {"open_risk_count": int(open_risk_count or 0)}
@staticmethod
def _build_dependency_summary(*, charter: list[CharterItem], task_summary: TaskSummary) -> DependencySummary:
blocked_tasks = task_summary.get("blocked_tasks") if isinstance(task_summary.get("blocked_tasks"), list) else []
overdue_count = int(task_summary.get("overdue_count") or 0)
pressure = "high" if blocked_tasks or overdue_count > 0 else ("medium" if not charter else "low")
return {
"pressure": pressure,
"blocked_tasks": blocked_tasks[:5],
"overdue_count": overdue_count,
"charter_ready": bool(charter),
}

View File

@ -0,0 +1,331 @@
"""AI PMO kernel world-model core mixin."""
from __future__ import annotations
from . import ai_pmo_kernel_common as _common
for _name, _value in vars(_common).items():
if not _name.startswith("__"):
globals()[_name] = _value
class KernelWorldModelCoreMixin:
async def build_world_model(
self,
*,
tenant_id: int,
project_id: Optional[int],
sender: str = "",
project: Optional[Project] = None,
pre_queried_tables: Optional[set[str]] = None,
) -> WorldModel:
if int(tenant_id) != int(self.tenant_id):
raise ValueError(f"tenant mismatch for world model build: input={tenant_id} service={self.tenant_id}")
started = time.perf_counter()
queried_tables: set[str] = set(pre_queried_tables or set())
resolved_project_id: Optional[int] = project_id
status = "failed"
try:
if project is None and project_id is not None:
self._record_world_model_query_table(queried_tables, "projects")
project = (
await self.db.execute(
select(Project).where(
and_(
Project.tenant_id == self.tenant_id,
Project.id == int(project_id),
)
).limit(1)
)
).scalar_one_or_none()
resolved_project_id = _safe_int(getattr(project, "id", None)) if project is not None else project_id
project_snapshot = self._serialize_project(project)
domain: Optional[ProjectDomainSnapshot] = None
skill_graph: list[SkillGraphItem] = []
charter: list[CharterItem] = []
cached_static = await self._get_static_world_model_cache(tenant_id=self.tenant_id, project_id=resolved_project_id)
if cached_static is not None:
cached_project = cached_static.get("project")
if isinstance(cached_project, dict):
project_snapshot = cached_project
cached_domain = cached_static.get("project_domain")
if isinstance(cached_domain, dict):
domain = cached_domain
cached_skill_graph = cached_static.get("skill_graph")
if isinstance(cached_skill_graph, list):
skill_graph = [item for item in cached_skill_graph if isinstance(item, dict)]
cached_charter = cached_static.get("charter")
if isinstance(cached_charter, list):
charter = [item for item in cached_charter if isinstance(item, dict)]
else:
skill_graph = await self._build_skill_graph(project_id=resolved_project_id, query_tables=queried_tables)
charter = await self._build_charter_snapshot(project_id=resolved_project_id, query_tables=queried_tables)
domain = await self._build_project_domain_snapshot(project_id=resolved_project_id, query_tables=queried_tables)
await self._set_static_world_model_cache(
tenant_id=self.tenant_id,
project_id=resolved_project_id,
data={
"project": project_snapshot,
"project_domain": domain,
"skill_graph": skill_graph,
"charter": charter,
},
)
portfolio_summary = await self._build_portfolio_summary(project_id=resolved_project_id, query_tables=queried_tables)
budget = await self._build_budget_summary(project_id=resolved_project_id, query_tables=queried_tables)
capacity = await self._build_capacity_summary(project_id=resolved_project_id, query_tables=queried_tables)
task_summary = await self._build_task_summary(
project_id=resolved_project_id,
skill_graph=skill_graph,
query_tables=queried_tables,
)
risk_summary = await self._build_risk_summary(project_id=resolved_project_id, query_tables=queried_tables)
dependencies = self._build_dependency_summary(charter=charter, task_summary=task_summary)
recent_signals = await self._build_recent_signal_summary(project_id=resolved_project_id, query_tables=queried_tables)
world_model: WorldModel = {
"sender": sender,
"resolved_project_id": resolved_project_id,
"project": project_snapshot,
"project_domain": domain,
"portfolio": portfolio_summary,
"budget": budget,
"capacity": capacity,
"member_count": len(capacity),
"skill_graph": skill_graph,
"charter": charter,
"task_summary": task_summary,
"risk_summary": risk_summary,
"dependencies": dependencies,
"recent_signals": recent_signals,
"decision_context_snapshot": {
"captured_at": _utc_now_naive().isoformat(),
"project_id": resolved_project_id,
"budget": budget,
"capacity": capacity,
"skill_graph_size": len(skill_graph),
"dependency_pressure": dependencies.get("pressure"),
},
}
status = "success"
return world_model
finally:
duration_ms = int((time.perf_counter() - started) * 1000)
logger.info(
"ai_pmo_world_model_build",
tenant_id=self.tenant_id,
project_id=resolved_project_id,
status=status,
duration_ms=duration_ms,
queried_tables=sorted(queried_tables),
query_table_count=len(queried_tables),
)
def _schedule_portfolio_snapshot_persist(self, *, world_model: WorldModel) -> None:
portfolio_summary = world_model.get("portfolio")
if not isinstance(portfolio_summary, dict):
return
project_id = _safe_int(world_model.get("resolved_project_id"))
asyncio.create_task(
self._persist_portfolio_snapshot(world_model=world_model, portfolio_summary=portfolio_summary),
name=f"ai-pmo-portfolio-snapshot:{self.tenant_id}:{project_id or 'none'}",
)
async def _persist_portfolio_snapshot(
self,
*,
world_model: WorldModel,
portfolio_summary: PortfolioSummary,
) -> None:
if self._background_session_factory is None:
logger.warning(
"ai_pmo_portfolio_snapshot_persist_skipped",
tenant_id=self.tenant_id,
project_id=_safe_int(world_model.get("resolved_project_id")),
reason="background_session_factory_unavailable",
)
return
try:
async with self._background_session_factory() as bg_db:
bg_db.add(
PortfolioSnapshot(
tenant_id=self.tenant_id,
scope_type="tenant",
scope_ref=f"tenant:{self.tenant_id}",
summary=portfolio_summary,
payload=world_model,
)
)
await bg_db.commit()
except Exception as exc:
logger.warning(
"ai_pmo_portfolio_snapshot_persist_failed",
tenant_id=self.tenant_id,
project_id=_safe_int(world_model.get("resolved_project_id")),
error=str(exc),
)
@staticmethod
def _world_model_static_cache_key(*, tenant_id: int, project_id: int) -> str:
return f"{WORLD_MODEL_STATIC_CACHE_KEY_PREFIX}:{int(tenant_id)}:{int(project_id)}"
async def _get_static_world_model_cache(
self,
*,
tenant_id: int,
project_id: Optional[int],
) -> Optional[dict[str, Any]]:
if project_id is None:
return None
key = self._world_model_static_cache_key(tenant_id=tenant_id, project_id=int(project_id))
cached = await self.context_cache.get_json_key(key)
if not isinstance(cached, dict):
logger.debug(f"[WM_CACHE] MISS {tenant_id}:{project_id}")
return None
required_keys = {"project", "project_domain", "skill_graph", "charter"}
if not required_keys.issubset(set(cached.keys())):
logger.debug(f"[WM_CACHE] MISS {tenant_id}:{project_id}")
return None
logger.debug(f"[WM_CACHE] HIT {tenant_id}:{project_id}")
return cached
async def _set_static_world_model_cache(
self,
*,
tenant_id: int,
project_id: Optional[int],
data: dict[str, Any],
) -> None:
if project_id is None:
return
key = self._world_model_static_cache_key(tenant_id=tenant_id, project_id=int(project_id))
await self.context_cache.set_json_key(key, data, WORLD_MODEL_STATIC_CACHE_TTL_SECONDS)
async def _invalidate_world_model_cache(self, *, tenant_id: int, project_id: Optional[int]) -> None:
await self.invalidate_world_model_cache(tenant_id=tenant_id, project_id=project_id)
@classmethod
async def invalidate_world_model_cache(cls, *, tenant_id: int, project_id: Optional[int]) -> None:
if project_id is None:
return
cache = ContextCacheService()
key = cls._world_model_static_cache_key(tenant_id=int(tenant_id), project_id=int(project_id))
await cache.delete_keys(key)
@classmethod
async def invalidate_world_model_cache_for_user(
cls,
*,
db: AsyncSession,
tenant_id: int,
user_id: int,
) -> list[int]:
rows = (
await db.execute(
select(UserProject.project_id).where(
and_(
UserProject.tenant_id == int(tenant_id),
UserProject.user_id == int(user_id),
)
)
)
).scalars().all()
project_ids = sorted({int(pid) for pid in rows if pid is not None})
for pid in project_ids:
await cls.invalidate_world_model_cache(tenant_id=tenant_id, project_id=pid)
return project_ids
async def _build_world_model(
self,
*,
message_text: str,
sender: str,
project_hint_id: Optional[int],
session: Optional[ConversationSession] = None,
) -> WorldModel:
query_tables: set[str] = set()
project = await self._resolve_project(
message_text=message_text,
project_hint_id=project_hint_id,
query_tables=query_tables,
session=session,
)
return await self.build_world_model(
tenant_id=self.tenant_id,
project_id=_safe_int(getattr(project, "id", None)),
sender=sender,
project=project,
pre_queried_tables=query_tables,
)
@staticmethod
def _record_world_model_query_table(query_tables: Optional[set[str]], table_name: str) -> None:
if query_tables is not None:
query_tables.add(table_name)
async def _resolve_project(
self,
*,
message_text: str,
project_hint_id: Optional[int],
query_tables: Optional[set[str]] = None,
session: Optional[ConversationSession] = None,
) -> Optional[Project]:
if project_hint_id is not None:
self._record_world_model_query_table(query_tables, "projects")
return (
await self.db.execute(
select(Project).where(
and_(
Project.tenant_id == self.tenant_id,
Project.id == int(project_hint_id),
)
).limit(1)
)
).scalar_one_or_none()
id_match = re.search(r"(?:项目(?:id|ID)?|project(?:id)?)\s*[:#:]?\s*(\d+)", str(message_text or ""))
if id_match:
project_id = int(id_match.group(1))
self._record_world_model_query_table(query_tables, "projects")
return (
await self.db.execute(
select(Project).where(
and_(
Project.tenant_id == self.tenant_id,
Project.id == project_id,
)
).limit(1)
)
).scalar_one_or_none()
self._record_world_model_query_table(query_tables, "projects")
projects = (
await self.db.execute(
select(Project).where(Project.tenant_id == self.tenant_id).order_by(Project.updated_at.desc(), Project.id.desc()).limit(50)
)
).scalars().all()
normalized_message = re.sub(r"\s+", "", str(message_text or "")).lower()
for project in projects:
for candidate in (project.title, project.name):
normalized_candidate = re.sub(r"\s+", "", str(candidate or "")).lower()
if normalized_candidate and normalized_candidate in normalized_message:
return project
inherited_project_id = self._extract_inherited_project_id(session=session)
if inherited_project_id is not None:
self._record_world_model_query_table(query_tables, "projects")
return (
await self.db.execute(
select(Project).where(
and_(
Project.tenant_id == self.tenant_id,
Project.id == int(inherited_project_id),
)
).limit(1)
)
).scalar_one_or_none()
return None

View File

@ -0,0 +1,95 @@
"""AI PMO kernel world-model session mixin."""
from __future__ import annotations
from . import ai_pmo_kernel_common as _common
for _name, _value in vars(_common).items():
if not _name.startswith("__"):
globals()[_name] = _value
class KernelWorldModelSessionMixin:
@staticmethod
def _within_recent_window(last_active_at: Optional[datetime], *, window_seconds: int) -> bool:
if not isinstance(last_active_at, datetime):
return False
now = _utc_now_naive()
delta = now - last_active_at
return 0 <= delta.total_seconds() <= float(window_seconds)
def _extract_inherited_project_id(self, *, session: Optional[ConversationSession]) -> Optional[int]:
if session is None:
return None
session_project_id = _safe_int(getattr(session, "current_project_id", None))
if session_project_id is None or int(session_project_id) <= 0:
return None
if not self._within_recent_window(
getattr(session, "last_active_at", None),
window_seconds=SESSION_PROJECT_INHERIT_WINDOW_SECONDS,
):
return None
return session_project_id
async def _get_or_create_session(self, *, tenant_id: int, sender: str) -> ConversationSession:
sender_key = str(sender or "").strip()[:128] or "unknown"
existing = (
await self.db.execute(
select(ConversationSession).where(
and_(
ConversationSession.tenant_id == int(tenant_id),
ConversationSession.sender == sender_key,
)
).limit(1)
)
).scalar_one_or_none()
if existing is not None:
return existing
created = ConversationSession(
tenant_id=int(tenant_id),
sender=sender_key,
current_project_id=None,
last_intent=None,
last_message_text=None,
last_active_at=_utc_now_naive(),
)
nested_tx = await self.db.begin_nested()
try:
self.db.add(created)
await self.db.flush()
await nested_tx.commit()
return created
except IntegrityError:
await nested_tx.rollback()
existing = (
await self.db.execute(
select(ConversationSession).where(
and_(
ConversationSession.tenant_id == int(tenant_id),
ConversationSession.sender == sender_key,
)
).limit(1)
)
).scalar_one_or_none()
if existing is not None:
return existing
raise
async def _update_session(
self,
*,
tenant_id: int,
sender: str,
current_project_id: Optional[int],
last_intent: Optional[str],
last_message_text: Optional[str],
) -> None:
row = await self._get_or_create_session(tenant_id=tenant_id, sender=sender)
if current_project_id is not None:
row.current_project_id = int(current_project_id)
row.last_intent = (str(last_intent or "").strip()[:64] or None)
trimmed_message = str(last_message_text or "").strip()
row.last_message_text = trimmed_message[:200] if trimmed_message else None
row.last_active_at = _utc_now_naive()
await self.db.flush()

View File

@ -0,0 +1,57 @@
"""AI PMO kernel runtime composition."""
from __future__ import annotations
from . import ai_pmo_kernel_common as _common
for _name, _value in vars(_common).items():
if not _name.startswith("__"):
globals()[_name] = _value
from .ai_pmo_kernel_mixins_closure import KernelClosureMixin
from .ai_pmo_kernel_mixins_followup import KernelFollowupMixin
from .ai_pmo_kernel_mixins_ingress import KernelIngressMixin
from .ai_pmo_kernel_mixins_intent import KernelIntentMixin
from .ai_pmo_kernel_mixins_world_model import KernelWorldModelMixin
class AIPMOKernelService(
KernelIngressMixin,
KernelWorldModelMixin,
KernelIntentMixin,
KernelFollowupMixin,
KernelClosureMixin,
):
"""AI PMO kernel,作为新的统一事实链。"""
def __init__(self, db: AsyncSession, tenant_id: int = 1) -> None:
self.db = db
self.tenant_id = int(tenant_id)
self._background_session_factory = self._build_background_session_factory(db)
self.context_cache = ContextCacheService()
self.intent_resolver = IntentResolver()
self.route_resolver = RouteResolver()
self.decision_engine = DecisionEngine(policy_gate_rules=POLICY_GATE_RULES)
self.reply_renderer = ReplyRenderer(
state_waiting_manual=STATE_WAITING_MANUAL,
state_failed=STATE_FAILED,
receipt_phase_action=RECEIPT_PHASE_ACTION,
receipt_phase_pending=RECEIPT_PHASE_PENDING,
receipt_phase_error=RECEIPT_PHASE_ERROR,
)
self.executor = TypedExecutor(db, tenant_id=self.tenant_id)
self.context_assembler = ContextAssembler(self)
self.workflow_registry = WorkflowRegistry(self)
self.review_service = AIPMOReviewService(db, tenant_id=self.tenant_id)
self.cadence_service = AIPMOCadenceService(db, tenant_id=self.tenant_id)
@staticmethod
def _build_background_session_factory(db: AsyncSession) -> Optional[async_sessionmaker[AsyncSession]]:
bind = getattr(db, "bind", None)
if bind is None:
return None
try:
return async_sessionmaker(bind=bind, expire_on_commit=False, class_=AsyncSession)
except Exception:
return None

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,216 @@
"""AI PMO reply rendering runtime."""
from __future__ import annotations
from typing import Any, Optional
from .ai_pmo_intent_routing import (
INTENT_FOLLOWUP_CONFIRM,
INTENT_FOLLOWUP_CONTINUE,
INTENT_PROJECT_CREATE,
INTENT_QUERY,
INTENT_SIGNAL_CREATE,
INTENT_SIGNAL_SOURCE_ONBOARD,
ROUTE_PROJECT_SIGNAL_CANCEL,
ROUTE_PROJECT_SIGNAL_COMMIT,
ROUTE_PROJECT_SIGNAL_DISCOVER,
ROUTE_PROJECT_SIGNAL_REFRESH,
CanonicalIntent,
)
def _safe_int(value: Any) -> Optional[int]:
try:
if value is None or value == "":
return None
return int(value)
except (TypeError, ValueError):
return None
def _normalize_signal_id_list(value: Any) -> list[int]:
ids: list[int] = []
seen: set[int] = set()
if isinstance(value, list):
items = value
else:
items = [value]
for item in items:
signal_id = _safe_int(item)
if signal_id is None or signal_id <= 0:
continue
if signal_id in seen:
continue
seen.add(signal_id)
ids.append(signal_id)
return ids
class ReplyRenderer:
"""唯一回执出口。"""
def __init__(
self,
*,
state_waiting_manual: str,
state_failed: str,
receipt_phase_action: str,
receipt_phase_pending: str,
receipt_phase_error: str,
) -> None:
self.state_waiting_manual = state_waiting_manual
self.state_failed = state_failed
self.receipt_phase_action = receipt_phase_action
self.receipt_phase_pending = receipt_phase_pending
self.receipt_phase_error = receipt_phase_error
def render(
self,
*,
work_state: str,
intent: CanonicalIntent,
decision_record: dict[str, Any],
execution_result: dict[str, Any],
) -> dict[str, Any]:
if work_state == self.state_waiting_manual:
if intent.name in {INTENT_FOLLOWUP_CONFIRM, INTENT_FOLLOWUP_CONTINUE}:
followup_text = str(execution_result.get("followup_reply") or "").strip()
if followup_text:
return {
"receipt_phase": self.receipt_phase_pending,
"template_key": "pending_manual",
"payload": {"intent": intent.name, "reason": followup_text},
"text": followup_text,
}
if intent.name == INTENT_SIGNAL_CREATE:
pending_text = str(execution_result.get("followup_reply") or "").strip()
if pending_text:
return {
"receipt_phase": self.receipt_phase_pending,
"template_key": "pending_manual",
"payload": {
"intent": intent.name,
"workflow_route": execution_result.get("workflow_route"),
"reason": pending_text,
},
"text": pending_text,
}
gate_message = str(((decision_record.get("approval_requirement") or {}).get("message")) or "").strip()
if not gate_message:
gate_message = "已进入待人工确认队列。"
return {
"receipt_phase": self.receipt_phase_pending,
"template_key": "pending_manual",
"payload": {"intent": intent.name, "reason": gate_message},
"text": gate_message,
}
if work_state == self.state_failed:
if intent.name in {INTENT_FOLLOWUP_CONFIRM, INTENT_FOLLOWUP_CONTINUE}:
followup_text = str(execution_result.get("followup_reply") or "").strip()
if followup_text:
return {
"receipt_phase": self.receipt_phase_error,
"template_key": "safe_error",
"payload": {"intent": intent.name, "reason": followup_text},
"text": followup_text,
}
reason = str(execution_result.get("error_message") or "系统处理中出现异常,本次未执行。").strip()
return {
"receipt_phase": self.receipt_phase_error,
"template_key": "safe_error",
"payload": {"intent": intent.name, "reason": reason},
"text": reason,
}
if intent.name in {INTENT_FOLLOWUP_CONFIRM, INTENT_FOLLOWUP_CONTINUE}:
followup_text = str(execution_result.get("followup_reply") or "已继续处理。").strip()
return {
"receipt_phase": self.receipt_phase_action,
"template_key": "action_succeeded",
"payload": {
"intent": intent.name,
"target_work_item_id": _safe_int(execution_result.get("target_work_item_id")),
"target_state": execution_result.get("target_state"),
},
"text": followup_text,
}
if intent.name == INTENT_QUERY:
answer = str(execution_result.get("answer") or "当前暂无可用回答。").strip()
return {
"receipt_phase": self.receipt_phase_action,
"template_key": "action_succeeded",
"payload": {"intent": intent.name, "answer": answer},
"text": answer,
}
if intent.name == INTENT_PROJECT_CREATE:
project = execution_result.get("project") if isinstance(execution_result.get("project"), dict) else {}
project_id = _safe_int(project.get("id"))
project_title = str(project.get("title") or project.get("name") or "新项目").strip()
return {
"receipt_phase": self.receipt_phase_action,
"template_key": "action_succeeded",
"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_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 {}
status = str(workflow.get("status") or (execution_result.get("signal_source_orchestration") or {}).get("status") or "").strip()
if status == "pending_manual":
return {
"receipt_phase": self.receipt_phase_pending,
"template_key": "pending_manual",
"payload": {"intent": intent.name, "signal_id": signal_id, "status": status},
"text": f"已记录信号源接入请求,当前为待人工确认。信号#{signal_id} 已入库。" if signal_id else "已记录信号源接入请求,当前为待人工确认。",
}
return {
"receipt_phase": self.receipt_phase_action,
"template_key": "action_succeeded",
"payload": {"intent": intent.name, "signal_id": signal_id, "status": status},
"text": f"已接收入库并启动信号源接入流程。信号#{signal_id},状态:{status or 'running'}。",
}
workflow_route = str(execution_result.get("workflow_route") or "").strip().lower()
followup_text = str(execution_result.get("followup_reply") or "").strip()
if followup_text and workflow_route in {
ROUTE_PROJECT_SIGNAL_DISCOVER,
ROUTE_PROJECT_SIGNAL_COMMIT,
ROUTE_PROJECT_SIGNAL_CANCEL,
ROUTE_PROJECT_SIGNAL_REFRESH,
}:
return {
"receipt_phase": self.receipt_phase_action,
"template_key": "action_succeeded",
"payload": {
"intent": intent.name,
"workflow_route": workflow_route,
},
"text": followup_text,
}
signal_ids = _normalize_signal_id_list(execution_result.get("signal_ids"))
signal_id = _safe_int(execution_result.get("signal_id"))
if signal_id is not None and signal_id not in signal_ids:
signal_ids = [signal_id, *signal_ids]
primary_signal_id = signal_ids[0] if signal_ids else signal_id
project_id = _safe_int(execution_result.get("project_id"))
if len(signal_ids) > 1 and project_id is not None:
signal_labels = "、".join(f"#{sid}" for sid in signal_ids[:5])
text = f"已入库:已为项目#{project_id}创建{len(signal_ids)}条信号({signal_labels})。"
elif len(signal_ids) > 1:
signal_labels = "、".join(f"#{sid}" for sid in signal_ids[:5])
text = f"已入库:已创建{len(signal_ids)}条信号({signal_labels})。"
elif primary_signal_id is not None and project_id is not None:
text = f"已入库:已为项目#{project_id}创建信号#{primary_signal_id}。"
elif primary_signal_id is not None:
text = f"已入库:已创建信号#{primary_signal_id}。"
else:
text = "已收到并入库。"
return {
"receipt_phase": self.receipt_phase_action,
"template_key": "action_succeeded",
"payload": {
"intent": intent.name,
"signal_id": primary_signal_id,
"signal_ids": signal_ids,
"project_id": project_id,
},
"text": text,
}

View File

@ -0,0 +1,23 @@
"""AI PMO typed executor composition."""
from __future__ import annotations
from . import ai_pmo_typed_executor_common as _common
for _name, _value in vars(_common).items():
if not _name.startswith("__"):
globals()[_name] = _value
from .ai_pmo_typed_executor_mixins_discovery import TypedExecutorDiscoveryMixin
from .ai_pmo_typed_executor_mixins_execute import TypedExecutorExecuteMixin
from .ai_pmo_typed_executor_mixins_parse import TypedExecutorParseMixin
class TypedExecutor(TypedExecutorParseMixin, TypedExecutorDiscoveryMixin, TypedExecutorExecuteMixin):
"""执行层只做 typed action 落地。"""
def __init__(self, db: AsyncSession, tenant_id: int) -> None:
self.db = db
self.tenant_id = int(tenant_id)
self.signal_source_workflow = SignalSourceDomainWorkflow(db)

View File

@ -0,0 +1,79 @@
"""AI PMO typed executor runtime."""
from __future__ import annotations
import hashlib
import json
import re
from datetime import timedelta
from typing import Any, Optional
from sqlalchemy import and_, or_, select
from sqlalchemy.ext.asyncio import AsyncSession
from ..config.ai_prompts import PROMPT_AI_PMO_SIGNAL_BATCH_ANALYSIS
from ..core.logging import get_logger
from ..models import (
Experiment,
Project,
ProjectSignalLink,
Signal,
SignalSourceRegistry,
TruthNode,
UserSkillBinding,
UserWorkload,
)
from ..utils.timezone import now_cn_naive
from .adaptive_rag_service import AdaptiveRAGService
from .ai_pmo_domain_workflow import SignalSourceDomainWorkflow
from .ai_pmo_intent_routing import (
INTENT_PROJECT_CREATE,
INTENT_QUERY,
INTENT_SIGNAL_CREATE,
INTENT_SIGNAL_SOURCE_ONBOARD,
LEGACY_INTENT_ROUTE_MAP,
ROUTE_ACTION_CREATE,
ROUTE_ACTION_DISCOVER,
ROUTE_PROJECT_SIGNAL_CREATE,
ROUTE_PROJECT_SIGNAL_DISCOVER,
CanonicalIntent,
IntentResolver,
RouteResolver,
)
from .ai_steward import AIStewardService
from .llm import llm_service
from .model_context_bundle_service import ModelContextBundleService
from .signal_planner_service import SignalPlannerService
logger = get_logger(__name__)
GENERIC_SIGNAL_SOURCE_MESSAGE_RE = re.compile(
r"^(?:请)?(?:按|按照)?(?:这个|上述|这条)?(?:分步|步骤)?(?:骤)?(?:把|将)?(?:它|该需求|这条需求)?(?:加入|接入|录入).{0,24}信号源(?:中)?$",
re.IGNORECASE,
)
MODULE_KEYWORD_RULES: tuple[tuple[str, tuple[str, ...]], ...] = (
("用户管理与权限", ("用户管理", "角色权限", "crud", "批量导入", "成员管理")),
("技能图谱", ("技能图谱", "技能标签", "能力图谱", "雷达图")),
("工作量管理", ("工作量", "负载", "产能", "workload")),
("SSTC 工作流", ("sstc", "charter", "signal", "task", "execution", "release", "review", "工作流")),
("AI 能力编排", ("ai能力", "智能", "自动化", "推荐", "编排", "agent", "llm")),
("信号源接入", ("信号源", "source", "onboard", "接入")),
("飞书集成", ("飞书", "feishu", "群聊", "机器人")),
("消息台与详情页", ("消息台", "详情页", "展开详情", "摘要", "时间戳", "按钮")),
)
def _utc_now_naive():
return now_cn_naive()
def _safe_int(value: Any) -> Optional[int]:
try:
if value is None or value == "":
return None
return int(value)
except (TypeError, ValueError):
return None

View File

@ -0,0 +1,392 @@
"""AI PMO typed executor mixin."""
from __future__ import annotations
from . import ai_pmo_typed_executor_common as _common
for _name, _value in vars(_common).items():
if not _name.startswith("__"):
globals()[_name] = _value
class TypedExecutorDiscoveryMixin:
async def _analyze_signal_batch_with_high_tier_model(
self,
*,
intent: CanonicalIntent,
message_text: str,
requested_count: int,
) -> dict[str, Any]:
strict_analysis_required = self._requires_strict_signal_analysis(
message_text=message_text,
requested_count=requested_count,
)
prompt = PROMPT_AI_PMO_SIGNAL_BATCH_ANALYSIS.format(
message_text=str(message_text or "")[:1600],
project_id=intent.project_id if intent.project_id is not None else "null",
requested_count=max(requested_count, 1),
).strip()
raw_text = ""
try:
raw_text = await llm_service.generate(
prompt,
db=self.db,
tenant_id=self.tenant_id,
function_name="adaptive_rag_answer_deep_signal_batch_analysis",
use_prompt_profile=False,
temperature=0.1,
max_tokens=min(2800, 520 + max(requested_count, 1) * 420),
response_mode="complete",
)
except Exception as exc:
logger.warning(
"ai_pmo_signal_batch_analysis_failed",
tenant_id=self.tenant_id,
project_id=intent.project_id,
requested_count=requested_count,
error=str(exc),
)
payload = self._parse_signal_batch_payload(raw_text)
raw_items = payload.get("signals") if isinstance(payload.get("signals"), list) else []
if not raw_items:
logger.warning(
"ai_pmo_signal_batch_analysis_invalid_payload",
tenant_id=self.tenant_id,
project_id=intent.project_id,
requested_count=requested_count,
raw_preview=str(raw_text or "")[:260],
)
if strict_analysis_required:
error_message = "你要求先分析再入库,但当前信号分析结果不可解析,本次未自动入库。"
logger.warning(
"ai_pmo_signal_batch_analysis_strict_blocked",
tenant_id=self.tenant_id,
project_id=intent.project_id,
requested_count=requested_count,
message_preview=str(message_text or "")[:200],
)
return {
"items": [],
"used_fallback": False,
"analysis_error": error_message,
"strict_analysis_required": True,
}
return {
"items": self._build_fallback_signal_batch_items(
message_text=message_text,
requested_count=requested_count,
),
"used_fallback": True,
"analysis_error": "signal_batch_analysis_invalid_payload",
"strict_analysis_required": False,
}
return {
"items": self._normalize_signal_batch_items(
message_text=message_text,
requested_count=requested_count,
raw_items=raw_items,
),
"used_fallback": False,
"analysis_error": "",
"strict_analysis_required": strict_analysis_required,
}
@staticmethod
def _build_candidate_dedupe_key(*, project_id: int, candidate: dict[str, Any]) -> str:
title = str(candidate.get("title") or "").strip().lower()
summary = str(candidate.get("summary") or "").strip().lower()
signal_type = str(candidate.get("signal_type") or "新需求").strip().lower()
payload = f"{int(project_id)}|{title}|{summary}|{signal_type}"
return hashlib.sha1(payload.encode("utf-8"), usedforsecurity=False).hexdigest()[:40]
async def _collect_project_signal_discovery_context(
self,
*,
project_id: int,
message_text: str,
source_chat_id: Optional[str],
) -> dict[str, Any]:
rag_service = AdaptiveRAGService(self.db, tenant_id=self.tenant_id)
retrieval_result: dict[str, Any] = {}
entity_result: dict[str, Any] = {}
retrieval_error: Optional[str] = None
entity_error: Optional[str] = None
try:
retrieval_result = await rag_service.multi_step_search(
question=message_text,
project_id=int(project_id),
source_chat_id=source_chat_id,
)
except Exception as exc:
retrieval_error = str(exc)
logger.warning(
"project_signal_discover_multi_step_search_failed",
tenant_id=self.tenant_id,
project_id=project_id,
error=str(exc),
)
try:
entity_result = await rag_service.entity_resolution(
question=message_text,
project_id=int(project_id),
limit=8,
)
except Exception as exc:
entity_error = str(exc)
logger.warning(
"project_signal_discover_entity_resolution_failed",
tenant_id=self.tenant_id,
project_id=project_id,
error=str(exc),
)
if retrieval_error or entity_error:
raise RuntimeError(
"project_signal_discovery_retrieval_failed:"
f"multi_step={retrieval_error or 'ok'};"
f"entity={entity_error or 'ok'}"
)
project = (
await self.db.execute(
select(Project).where(
and_(
Project.tenant_id == self.tenant_id,
Project.id == int(project_id),
)
).limit(1)
)
).scalar_one_or_none()
if project is None:
raise ValueError(f"project_not_found:{project_id}")
truth_nodes = (
await self.db.execute(
select(TruthNode).where(
and_(
TruthNode.tenant_id == self.tenant_id,
TruthNode.project_id == int(project_id),
)
).order_by(TruthNode.updated_at.desc(), TruthNode.id.desc()).limit(6)
)
).scalars().all()
experiments = (
await self.db.execute(
select(Experiment).where(
and_(
Experiment.tenant_id == self.tenant_id,
Experiment.project_id == int(project_id),
)
).order_by(Experiment.updated_at.desc(), Experiment.id.desc()).limit(5)
)
).scalars().all()
recent_signals = (
await self.db.execute(
select(Signal).where(
and_(
Signal.tenant_id == self.tenant_id,
or_(
Signal.matched_project_id == int(project_id),
Signal.id.in_(
select(ProjectSignalLink.signal_id).where(
and_(
ProjectSignalLink.tenant_id == self.tenant_id,
ProjectSignalLink.project_id == int(project_id),
)
)
),
),
)
).order_by(Signal.updated_at.desc(), Signal.id.desc()).limit(8)
)
).scalars().all()
source_registries = (
await self.db.execute(
select(SignalSourceRegistry).where(
and_(
SignalSourceRegistry.tenant_id == self.tenant_id,
SignalSourceRegistry.project_id == int(project_id),
)
).order_by(SignalSourceRegistry.updated_at.desc(), SignalSourceRegistry.id.desc()).limit(8)
)
).scalars().all()
capability_bundle = await ModelContextBundleService.build_context_bundle(
self.db,
tenant_id=self.tenant_id,
function_name="classify_intent_complex",
)
return {
"project": {
"id": int(project.id),
"title": str(project.title or project.name or ""),
"status": str(project.status or ""),
},
"truth_nodes": [
{
"id": int(node.id),
"node_type": str(node.node_type or ""),
"title": str(node.title or "")[:120],
"status": str(node.status or ""),
}
for node in truth_nodes
],
"experiments": [
{
"id": int(exp.id),
"title": str(exp.title or "")[:120],
"status": str(exp.status or ""),
"target_metric": str(exp.target_metric or "")[:80],
}
for exp in experiments
],
"recent_signals": [
{
"id": int(sig.id),
"title": str(sig.title or "")[:120],
"status": str(sig.status or ""),
"signal_type": str(sig.signal_type or "")[:30],
}
for sig in recent_signals
],
"source_registries": [
{
"id": int(src.id),
"source_kind": str(src.source_kind or ""),
"source_name": str(src.source_name or "")[:120],
"status": str(src.status or ""),
"onboarding_status": str(src.onboarding_status or ""),
}
for src in source_registries
],
"retrieval_items": retrieval_result.get("items") if isinstance(retrieval_result.get("items"), list) else [],
"retrieval_metrics": retrieval_result.get("retrieval_metrics") if isinstance(retrieval_result.get("retrieval_metrics"), dict) else {},
"entity_matches": entity_result.get("matched_entities") if isinstance(entity_result.get("matched_entities"), list) else [],
"tool_trace": retrieval_result.get("tool_trace") if isinstance(retrieval_result.get("tool_trace"), list) else [],
"capability_bundle": capability_bundle if isinstance(capability_bundle, dict) else {},
}
async def discover_project_signal_candidates(
self,
*,
intent: CanonicalIntent,
message_text: str,
source_system: str,
source_message_id: str,
channel_ref: Optional[str],
) -> dict[str, Any]:
_ = source_system
_ = source_message_id
project_id = _safe_int(intent.project_id)
if project_id is None:
return {
"status": "failed_safe",
"workflow_action": ROUTE_ACTION_DISCOVER,
"workflow_route": ROUTE_PROJECT_SIGNAL_DISCOVER,
"error_message": "项目不存在或未提供项目ID,无法执行候选发现。",
"candidate_signals": [],
}
metadata = intent.metadata if isinstance(intent.metadata, dict) else {}
requested_count = self._extract_requested_signal_count(
message_text=message_text,
metadata=metadata,
)
analysis_result = await self._analyze_signal_batch_with_high_tier_model(
intent=intent,
message_text=message_text,
requested_count=requested_count,
)
analyzed_signals = analysis_result.get("items") if isinstance(analysis_result.get("items"), list) else []
if not analyzed_signals:
return {
"status": "failed_safe",
"workflow_action": ROUTE_ACTION_DISCOVER,
"workflow_route": ROUTE_PROJECT_SIGNAL_DISCOVER,
"error_message": str(analysis_result.get("analysis_error") or "候选发现失败,请稍后重试。"),
"candidate_signals": [],
"requested_count": requested_count,
}
try:
context = await self._collect_project_signal_discovery_context(
project_id=int(project_id),
message_text=message_text,
source_chat_id=channel_ref,
)
except Exception as exc:
logger.warning(
"project_signal_discover_context_failed",
tenant_id=self.tenant_id,
project_id=project_id,
error=str(exc),
)
return {
"status": "failed_safe",
"workflow_action": ROUTE_ACTION_DISCOVER,
"workflow_route": ROUTE_PROJECT_SIGNAL_DISCOVER,
"project_id": int(project_id),
"requested_count": requested_count,
"error_message": "项目候选检索失败,请稍后重试。",
"candidate_signals": [],
}
retrieval_items = context.get("retrieval_items") if isinstance(context.get("retrieval_items"), list) else []
candidate_signals: list[dict[str, Any]] = []
for index, row in enumerate(analyzed_signals[:requested_count]):
if not isinstance(row, dict):
continue
evidence_excerpt = ""
if index < len(retrieval_items) and isinstance(retrieval_items[index], dict):
evidence_excerpt = str(retrieval_items[index].get("content") or "")[:240]
dedupe_key = self._build_candidate_dedupe_key(project_id=int(project_id), candidate=row)
candidate_signals.append(
{
"title": str(row.get("title") or "")[:120],
"summary": str(row.get("summary") or "")[:300],
"content": str(row.get("content") or "")[:1800],
"signal_type": str(row.get("signal_type") or "新需求"),
"priority": str(row.get("priority") or "medium"),
"reasoning_bullets": row.get("reasoning_bullets") if isinstance(row.get("reasoning_bullets"), list) else [],
"dedupe_key": dedupe_key,
"evidence_excerpt": evidence_excerpt,
}
)
retrieval_metrics = context.get("retrieval_metrics") if isinstance(context.get("retrieval_metrics"), dict) else {}
search_summary = (
f"项目#{int(project_id)} 候选发现完成:候选{len(candidate_signals)}条,"
f"entity命中{len(context.get('entity_matches') if isinstance(context.get('entity_matches'), list) else [])},"
f"检索耗时db={int(retrieval_metrics.get('db_query_ms') or 0)}ms/"
f"vector={int(retrieval_metrics.get('vector_search_ms') or 0)}ms/"
f"graph={int(retrieval_metrics.get('graph_query_ms') or 0)}ms。"
)
expires_at = (_utc_now_naive() + timedelta(hours=24)).isoformat()
return {
"status": "pending_confirmation",
"workflow_action": ROUTE_ACTION_DISCOVER,
"workflow_route": ROUTE_PROJECT_SIGNAL_DISCOVER,
"project_id": int(project_id),
"requested_count": requested_count,
"candidate_signals": candidate_signals,
"search_summary": search_summary,
"discovery_sources": {
"tool_trace": context.get("tool_trace") if isinstance(context.get("tool_trace"), list) else [],
"entity_matches": context.get("entity_matches") if isinstance(context.get("entity_matches"), list) else [],
"source_registries": context.get("source_registries") if isinstance(context.get("source_registries"), list) else [],
},
"capability_context": {
"truth_nodes": context.get("truth_nodes") if isinstance(context.get("truth_nodes"), list) else [],
"experiments": context.get("experiments") if isinstance(context.get("experiments"), list) else [],
"recent_signals": context.get("recent_signals") if isinstance(context.get("recent_signals"), list) else [],
"model_context_bundle": context.get("capability_bundle") if isinstance(context.get("capability_bundle"), dict) else {},
"workload": metadata.get("world_model_capacity") if isinstance(metadata.get("world_model_capacity"), list) else [],
"skill_graph": metadata.get("world_model_skill_graph") if isinstance(metadata.get("world_model_skill_graph"), list) else [],
},
"confirmation_scope": "project_signal_commit",
"confirmation_expires_at": expires_at,
"followup_reply": (
f"已为项目#{int(project_id)} 找到 {len(candidate_signals)} 条候选信号,"
"请回复“确认”提交,回复“换一批”刷新,或回复“取消”结束本次候选。"
),
}

View File

@ -0,0 +1,424 @@
"""AI PMO typed executor mixin."""
from __future__ import annotations
from . import ai_pmo_typed_executor_common as _common
for _name, _value in vars(_common).items():
if not _name.startswith("__"):
globals()[_name] = _value
class TypedExecutorExecuteMixin:
async def execute(
self,
*,
intent: CanonicalIntent,
message_text: str,
sender: str,
operator_user_id: Optional[int],
source_system: str,
source_message_id: str,
channel_ref: Optional[str] = None,
) -> dict[str, Any]:
if intent.name == INTENT_QUERY:
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,
)
except Exception as exc:
return {
"status": "failed_safe",
"error_message": self._build_query_error_message(exc),
"project_id": intent.project_id,
}
return {
"status": "succeeded",
"answer": str(rag_result.get("answer") or "").strip(),
"rag_result": rag_result,
"project_id": intent.project_id,
}
if intent.name == INTENT_PROJECT_CREATE:
if operator_user_id is None:
return {
"status": "failed_safe",
"error_message": "未找到可用项目创建人,当前无法自动创建项目。",
}
steward = AIStewardService(self.db)
try:
payload = await steward.create_project_from_natural_language(
input_text=message_text,
creator_id=int(operator_user_id),
tenant_id=self.tenant_id,
)
except Exception as exc:
logger.warning(
"ai_pmo_project_create_local_fallback",
tenant_id=self.tenant_id,
reason=str(exc),
)
payload = await self._create_project_with_local_fallback(
message_text=message_text,
operator_user_id=int(operator_user_id),
fallback_reason=str(exc),
)
project = payload.get("project") if isinstance(payload.get("project"), dict) else {}
signal = payload.get("signal") if isinstance(payload.get("signal"), dict) else {}
return {
"status": "succeeded",
"project": project,
"signal": signal,
"project_id": _safe_int(project.get("id")),
"signal_id": _safe_int(signal.get("id")),
"tasks": payload.get("tasks") if isinstance(payload.get("tasks"), list) else [],
"raw_payload": payload,
}
if intent.name == INTENT_SIGNAL_SOURCE_ONBOARD:
semantic_payload = self._build_signal_source_semantic_payload(
intent=intent,
message_text=message_text,
)
strategic_payload = (
semantic_payload.get("strategic_analysis")
if isinstance(semantic_payload.get("strategic_analysis"), dict)
else {}
)
source_context = (
strategic_payload.get("source_context")
if isinstance(strategic_payload.get("source_context"), dict)
else {}
)
source_context["source_message_id"] = source_message_id
source_context["channel_ref"] = channel_ref
strategic_payload["source_context"] = source_context
signal = await self._create_signal(
title=str(semantic_payload.get("title") or intent.signal_title or "新增信号源接入请求"),
summary=str(semantic_payload.get("summary") or intent.signal_summary or message_text),
content=str(semantic_payload.get("content") or message_text),
project_id=intent.project_id,
source="PMFlow",
signal_type="新需求",
recommended_action="enter_only",
reasoning_bullets=(
semantic_payload.get("reasoning_bullets")
if isinstance(semantic_payload.get("reasoning_bullets"), list)
else None
),
strategic_analysis=(
strategic_payload
if isinstance(strategic_payload, dict)
else None
),
)
domain_workflow = await self.signal_source_workflow.run(
tenant_id=self.tenant_id,
project_id=int(intent.project_id or 0),
signal_id=int(signal.id),
initiating_text=message_text,
requested_via=source_system,
operator_user_id=operator_user_id,
)
orchestration = domain_workflow.get("raw_result") if isinstance(domain_workflow.get("raw_result"), dict) else {}
return {
"status": "succeeded" if str(domain_workflow.get("status") or "").lower() == "completed" else (
"pending_manual" if str(domain_workflow.get("status") or "").lower() == "pending_manual" else "failed_safe"
),
"signal_id": int(signal.id),
"project_id": intent.project_id,
"signal_source_workflow": domain_workflow,
"signal_source_orchestration": orchestration,
"auto_tasks": self._extract_auto_tasks(
orchestration.get("execution") if isinstance(orchestration.get("execution"), dict) else orchestration.get("openclaw")
),
}
if intent.name == INTENT_SIGNAL_CREATE:
metadata = intent.metadata if isinstance(intent.metadata, dict) else {}
workflow_route = RouteResolver.normalize_route_key(
str(metadata.get("workflow_route") or LEGACY_INTENT_ROUTE_MAP.get(INTENT_SIGNAL_CREATE, ROUTE_PROJECT_SIGNAL_CREATE))
)
if workflow_route == ROUTE_PROJECT_SIGNAL_DISCOVER:
return await self.discover_project_signal_candidates(
intent=intent,
message_text=message_text,
source_system=source_system,
source_message_id=source_message_id,
channel_ref=channel_ref,
)
requested_count = self._extract_requested_signal_count(
message_text=message_text,
metadata=metadata,
)
analysis_result = await self._analyze_signal_batch_with_high_tier_model(
intent=intent,
message_text=message_text,
requested_count=requested_count,
)
analyzed_signals = analysis_result.get("items") if isinstance(analysis_result.get("items"), list) else []
analysis_error_message = str(analysis_result.get("analysis_error") or "").strip()
analysis_fallback_used = bool(analysis_result.get("used_fallback"))
strict_analysis_required = bool(analysis_result.get("strict_analysis_required"))
signal_ids: list[int] = []
signal_summaries: list[str] = []
for signal_row in analyzed_signals:
summary_text = str(signal_row.get("summary") or "")[:300]
strategic_analysis = {
"source_context": {
"source_message_id": source_message_id,
"channel_ref": channel_ref,
},
"ai_analysis": {
"summary": summary_text,
"intent": INTENT_SIGNAL_CREATE,
"requirement_project_scope": (
"existing_project" if intent.project_id is not None else "pending_classification"
),
"requested_count": requested_count,
"analysis_method": (
"high_tier_batch_model_fallback" if analysis_fallback_used else "high_tier_batch_model"
),
"fallback_used": analysis_fallback_used,
},
}
signal = await self._create_signal(
title=str(signal_row.get("title") or intent.signal_title or "新信号"),
summary=summary_text or str(intent.signal_summary or message_text),
content=str(signal_row.get("content") or message_text),
project_id=intent.project_id,
source="PMFlow",
signal_type=str(signal_row.get("signal_type") or "新需求"),
recommended_action="enter_only",
reasoning_bullets=(
signal_row.get("reasoning_bullets")
if isinstance(signal_row.get("reasoning_bullets"), list)
else None
),
strategic_analysis=strategic_analysis,
priority=str(signal_row.get("priority") or "medium"),
)
signal_ids.append(int(signal.id))
signal_summaries.append(summary_text)
if not signal_ids:
return {
"status": "failed_safe",
"workflow_action": ROUTE_ACTION_CREATE,
"workflow_route": ROUTE_PROJECT_SIGNAL_CREATE,
"error_message": (
analysis_error_message
or (
"你要求先分析再入库,但当前分析未成功,本次未自动入库。"
if strict_analysis_required
else "信号分析结果为空,本次未执行入库。"
)
),
"project_id": intent.project_id,
"requested_signal_count": requested_count,
"analyzed_signal_count": 0,
}
primary_signal_id = signal_ids[0]
merged_summary = ";".join([item for item in signal_summaries if item][:2])[:300]
return {
"status": "succeeded",
"workflow_action": ROUTE_ACTION_CREATE,
"workflow_route": ROUTE_PROJECT_SIGNAL_CREATE,
"signal_id": int(primary_signal_id),
"signal_ids": signal_ids,
"project_id": intent.project_id,
"summary": merged_summary or str(intent.signal_summary or message_text)[:300],
"summaries": signal_summaries,
"auto_tasks": [],
"requested_signal_count": requested_count,
"analyzed_signal_count": len(signal_ids),
"analysis_fallback_used": analysis_fallback_used,
}
return {
"status": "failed_safe",
"error_message": "当前意图尚未迁移到 AI PMO kernel,已保留给兼容链路处理。",
}
@staticmethod
def _build_query_error_message(error: Exception) -> str:
reason = re.sub(r"\s+", " ", str(error or "")).strip().strip("'\"")
reason = re.sub(r"^(runtimeerror|valueerror|exception)\s*:\s*", "", reason, flags=re.IGNORECASE)
if len(reason) > 120:
reason = f"{reason[:120]}..."
marker = "模型调用失败:"
while reason.startswith(marker):
reason = reason[len(marker):].strip()
if not reason:
reason = "unknown_error"
message = f"{marker}{reason}"
return message if "请稍后重试" in message else f"{message}。请稍后重试。"
async def _create_project_with_local_fallback(
self,
*,
message_text: str,
operator_user_id: int,
fallback_reason: str,
) -> dict[str, Any]:
cleaned = re.sub(r"</?at[^>]*>", " ", str(message_text or ""), flags=re.IGNORECASE)
cleaned = re.sub(r"@_user_\\d+", " ", cleaned)
cleaned = re.sub(r"\\s+", " ", cleaned).strip()
title = ""
patterns = (
r"(?:请|帮我)?(?:新建|新增|创建|建立)(?:一个|个)?项目[::,,\\s《\"]*([^》\",。,;;]{2,80})",
r"(?:项目)[::,,\\s《\"]*([^》\",。,;;]{2,80})",
)
for pattern in patterns:
match = re.search(pattern, cleaned, re.IGNORECASE)
if not match:
continue
candidate = re.sub(r"\\s+", " ", str(match.group(1) or "")).strip(" 《》\"',,。::;;")
if candidate:
title = candidate
break
if not title:
title = f"AI-PMO项目-{_utc_now_naive().strftime('%m%d-%H%M%S')}"
project = Project(
tenant_id=self.tenant_id,
name=title[:255],
title=title[:255],
description=f"本地兜底创建:{cleaned[:500]}",
owner_id=int(operator_user_id),
status="active",
domain_id=None,
assigned_role=None,
signal_count=0,
)
self.db.add(project)
await self.db.flush()
signal = await self._create_signal(
title=f"{title} 初始信号",
summary=f"由 AI PMO 本地兜底创建,原始指令:{cleaned[:120]}",
content=f"project_create_fallback_reason={fallback_reason[:240]}",
project_id=int(project.id),
source="PMFlow",
signal_type="新需求",
recommended_action="enter_only",
)
project.signal_count = max(int(project.signal_count or 0), 1)
await self.db.flush()
return {
"project": {
"id": str(project.id),
"name": project.name,
"title": project.title,
"status": project.status,
},
"signal": {
"id": str(signal.id),
"content": signal.content,
"impact_score": signal.impact_score,
"priority": signal.priority,
},
"tasks": [],
"fallback": {
"enabled": True,
"reason": fallback_reason[:240],
},
}
async def _create_signal(
self,
*,
title: str,
summary: str,
content: str,
project_id: Optional[int],
source: str,
signal_type: str,
recommended_action: str,
priority: str = "medium",
reasoning_bullets: Optional[list[str]] = None,
strategic_analysis: Optional[dict[str, Any]] = None,
) -> Signal:
normalized_priority = str(priority or "medium").strip().lower()
if normalized_priority not in {"high", "medium", "low"}:
normalized_priority = "medium"
signal = Signal(
tenant_id=self.tenant_id,
source=source,
title=(title or "新信号")[:120],
summary=(summary or content or "")[:300],
content=(content or summary or "")[:2000],
signal_type=signal_type,
reasoning=json.dumps(reasoning_bullets or ["由 AI PMO kernel 统一入库。"], ensure_ascii=False),
strategic_analysis=(json.dumps(strategic_analysis, ensure_ascii=False) if isinstance(strategic_analysis, dict) else None),
confidence=0.78,
recommended_action=recommended_action,
matched_project_id=int(project_id) if project_id is not None else None,
impact_score=0.7,
priority=normalized_priority,
processed=False,
tasks_generated=json.dumps([], ensure_ascii=False),
status="待处理",
)
self.db.add(signal)
await self.db.flush()
if project_id is not None:
exists = (
await self.db.execute(
select(ProjectSignalLink.id).where(
and_(
ProjectSignalLink.tenant_id == self.tenant_id,
ProjectSignalLink.project_id == int(project_id),
ProjectSignalLink.signal_id == int(signal.id),
)
).limit(1)
)
).scalar_one_or_none()
if exists is None:
self.db.add(
ProjectSignalLink(
tenant_id=self.tenant_id,
project_id=int(project_id),
signal_id=int(signal.id),
link_type="related",
relevance_score=1.0,
)
)
await self.db.flush()
return signal
@staticmethod
def _extract_auto_tasks(openclaw_payload: Any) -> list[dict[str, Any]]:
if not isinstance(openclaw_payload, dict):
return []
result = openclaw_payload.get("result") if isinstance(openclaw_payload.get("result"), dict) else openclaw_payload
execution = result.get("execution") if isinstance(result, dict) and isinstance(result.get("execution"), dict) else {}
rows = execution.get("results") if isinstance(execution.get("results"), list) else []
items: list[dict[str, Any]] = []
for row in rows:
if not isinstance(row, dict):
continue
operation_code = str(row.get("operation_code") or "").strip()
if operation_code != "task.create":
continue
payload = row.get("payload") if isinstance(row.get("payload"), dict) else {}
items.append(
{
"operation_code": operation_code,
"status": str(row.get("status") or "").strip(),
"entity_id": row.get("entity_id"),
"title": payload.get("title"),
"task_id": payload.get("task_id"),
}
)
return items

View File

@ -0,0 +1,329 @@
"""AI PMO typed executor mixin."""
from __future__ import annotations
from . import ai_pmo_typed_executor_common as _common
for _name, _value in vars(_common).items():
if not _name.startswith("__"):
globals()[_name] = _value
class TypedExecutorParseMixin:
@staticmethod
def _normalize_short_text(value: Any, *, max_len: int) -> str:
text = re.sub(r"\s+", " ", str(value or "")).strip()
if not text:
return ""
return text[:max_len]
@staticmethod
def _is_generic_signal_source_title(value: str) -> bool:
compact = re.sub(r"\s+", "", str(value or "")).strip().lower()
if not compact:
return True
if bool(GENERIC_SIGNAL_SOURCE_MESSAGE_RE.match(compact)):
return True
return compact in {"信号源接入请求", "新增信号源接入请求", "新增信号源"}
@classmethod
def _build_signal_source_semantic_payload(
cls,
*,
intent: CanonicalIntent,
message_text: str,
) -> dict[str, Any]:
metadata = intent.metadata if isinstance(intent.metadata, dict) else {}
modules = metadata.get("closure_modules") if isinstance(metadata.get("closure_modules"), list) else []
normalized_modules = [str(item).strip() for item in modules if str(item).strip()][:6]
summary = cls._normalize_short_text(
metadata.get("closure_summary") or intent.signal_summary or message_text,
max_len=300,
)
dialogue = cls._normalize_short_text(
metadata.get("closure_dialogue") or message_text,
max_len=1800,
)
title_seed = cls._normalize_short_text(intent.signal_title or "信号源接入请求", max_len=120)
if cls._is_generic_signal_source_title(title_seed):
if normalized_modules:
title_seed = f"信号源接入 · {normalized_modules[0]}"[:120]
else:
title_seed = "信号源接入请求"
reasoning_bullets = ["由 AI PMO kernel 统一入库。"]
if normalized_modules:
reasoning_bullets.append(f"闭环提炼模块:{'、'.join(normalized_modules[:4])}")
if summary:
reasoning_bullets.append(f"闭环摘要:{summary[:120]}")
strategic_analysis = {
"source_context": {
"conversation_closure": dialogue or None,
"implementation_modules": normalized_modules,
},
"ai_analysis": {
"summary": summary or None,
"intent": INTENT_SIGNAL_SOURCE_ONBOARD,
"requirement_project_scope": "existing_project" if intent.project_id is not None else "pending_classification",
},
}
return {
"title": title_seed,
"summary": summary or cls._normalize_short_text(message_text, max_len=300),
"content": dialogue or cls._normalize_short_text(message_text, max_len=1800),
"modules": normalized_modules,
"reasoning_bullets": reasoning_bullets,
"strategic_analysis": strategic_analysis,
}
@staticmethod
def _compact_text(value: Any) -> str:
return re.sub(r"\s+", "", str(value or "")).strip().lower()
@classmethod
def _looks_like_raw_instruction(cls, *, candidate: str, source: str) -> bool:
normalized_candidate = cls._compact_text(candidate)
normalized_source = cls._compact_text(source)
if not normalized_candidate or not normalized_source:
return False
if normalized_candidate == normalized_source:
return True
if (
len(normalized_candidate) >= 8
and normalized_candidate in normalized_source
and len(normalized_candidate) / max(len(normalized_source), 1) >= 0.68
):
return True
if (
len(normalized_source) >= 8
and normalized_source in normalized_candidate
and len(normalized_source) / max(len(normalized_candidate), 1) >= 0.68
):
return True
return False
@staticmethod
def _strip_code_fence(text: str) -> str:
raw = str(text or "").strip()
if not raw.startswith("```"):
return raw
chunks = raw.split("```")
if len(chunks) >= 2:
raw = chunks[1].strip()
if raw.lower().startswith("json"):
raw = raw[4:].strip()
return raw.strip()
@classmethod
def _extract_balanced_json_object(cls, text: str) -> str:
raw = str(text or "")
start = raw.find("{")
if start < 0:
return ""
depth = 0
in_str = False
escaped = False
for idx in range(start, len(raw)):
ch = raw[idx]
if in_str:
if escaped:
escaped = False
elif ch == "\\":
escaped = True
elif ch == '"':
in_str = False
continue
if ch == '"':
in_str = True
continue
if ch == "{":
depth += 1
elif ch == "}":
depth -= 1
if depth == 0:
return raw[start : idx + 1].strip()
return ""
@classmethod
def _parse_signal_batch_payload(cls, raw_text: str) -> dict[str, Any]:
normalized = cls._strip_code_fence(raw_text)
candidate = normalized if normalized.startswith("{") else cls._extract_balanced_json_object(normalized)
if not candidate:
return {}
try:
payload = json.loads(candidate)
except Exception:
return {}
return payload if isinstance(payload, dict) else {}
@staticmethod
def _parse_chinese_count_token(token: str) -> Optional[int]:
cleaned = str(token or "").strip()
if not cleaned:
return None
map_digit = {
"零": 0,
"一": 1,
"二": 2,
"两": 2,
"三": 3,
"四": 4,
"五": 5,
"六": 6,
"七": 7,
"八": 8,
"九": 9,
"十": 10,
}
if cleaned in map_digit:
return map_digit[cleaned]
if len(cleaned) == 2 and cleaned[0] == "十" and cleaned[1] in map_digit:
return 10 + map_digit[cleaned[1]]
if len(cleaned) == 2 and cleaned[1] == "十" and cleaned[0] in map_digit:
return map_digit[cleaned[0]] * 10
if len(cleaned) == 3 and cleaned[1] == "十" and cleaned[0] in map_digit and cleaned[2] in map_digit:
return map_digit[cleaned[0]] * 10 + map_digit[cleaned[2]]
return None
@classmethod
def _extract_requested_signal_count(cls, *, message_text: str, metadata: dict[str, Any]) -> int:
metadata_count = _safe_int(metadata.get("requested_count")) if isinstance(metadata, dict) else None
if metadata_count is not None and metadata_count > 0:
return min(max(metadata_count, 1), 5)
normalized_text = str(message_text or "")
digit_match = re.search(r"(\d{1,2})\s*(?:个|条|项)?\s*(?:信号|候选)", normalized_text, flags=re.IGNORECASE)
if digit_match:
return min(max(int(digit_match.group(1)), 1), 5)
chinese_match = re.search(r"([零一二两三四五六七八九十]{1,3})\s*(?:个|条|项)?\s*(?:信号|候选)", normalized_text)
if chinese_match:
parsed = cls._parse_chinese_count_token(chinese_match.group(1))
if parsed is not None and parsed > 0:
return min(max(parsed, 1), 5)
return 1
@classmethod
def _requires_strict_signal_analysis(
cls,
*,
message_text: str,
requested_count: int,
) -> bool:
normalized = re.sub(r"\s+", "", str(message_text or "")).lower()
if not normalized:
return False
if re.search(r"(先(?:自行)?分析(?:再|后)|先分析后入库|自行分析)", normalized, re.IGNORECASE):
return True
if re.search(r"(分析|拆分).{0,12}(后|完).{0,8}(入库|新增|创建|加入|录入)", normalized, re.IGNORECASE):
return True
return requested_count > 1 and "分析" in normalized and any(token in normalized for token in ("信号", "需求"))
@classmethod
def _normalize_signal_batch_items(
cls,
*,
message_text: str,
requested_count: int,
raw_items: list[Any],
) -> list[dict[str, Any]]:
signal_type_pool = {"新需求", "项目风险", "竞品动态", "用户反馈"}
cleaned_message = cls._normalize_short_text(message_text, max_len=1200)
normalized_items: list[dict[str, Any]] = []
for index in range(max(requested_count, 1)):
row = raw_items[index] if index < len(raw_items) and isinstance(raw_items[index], dict) else {}
title = cls._normalize_short_text(row.get("title"), max_len=120)
summary = cls._normalize_short_text(row.get("summary"), max_len=300)
content = cls._normalize_short_text(row.get("content"), max_len=1800)
if cls._looks_like_raw_instruction(candidate=title, source=cleaned_message):
title = ""
if cls._looks_like_raw_instruction(candidate=summary, source=cleaned_message):
summary = ""
if not summary:
summary_seed = content or cleaned_message or f"信号拆分序号 {index + 1}"
summary = cls._normalize_short_text(summary_seed, max_len=300)
if not title:
title_seed = summary or content or f"信号拆分序号 {index + 1}"
title = IntentResolver._build_signal_title(title_seed, default=f"信号拆分 {index + 1}")
if not content:
content = f"{summary} 建议继续补充验收标准、里程碑与负责人。"
signal_type = str(row.get("signal_type") or "").strip()
if signal_type not in signal_type_pool:
signal_type = "新需求"
priority = str(row.get("priority") or "").strip().lower()
if priority not in {"high", "medium", "low"}:
priority = "medium"
reasoning = row.get("reasoning") if isinstance(row.get("reasoning"), list) else []
reasoning_bullets = [
cls._normalize_short_text(item, max_len=120)
for item in reasoning
if cls._normalize_short_text(item, max_len=120)
][:3]
if not reasoning_bullets:
reasoning_bullets = [
"由高级模型分析后拆分生成。",
f"信号拆分序号:{index + 1}。",
]
normalized_items.append(
{
"title": title,
"summary": summary,
"content": content,
"signal_type": signal_type,
"priority": priority,
"reasoning_bullets": reasoning_bullets,
}
)
return normalized_items
@classmethod
def _build_fallback_signal_batch_items(
cls,
*,
message_text: str,
requested_count: int,
) -> list[dict[str, Any]]:
cleaned = cls._normalize_short_text(message_text, max_len=1200)
segments = [
cls._normalize_short_text(chunk, max_len=220)
for chunk in re.split(r"[,,。;;\n]+", cleaned)
if cls._normalize_short_text(chunk, max_len=220)
]
fallback_rows: list[dict[str, Any]] = []
for index in range(max(requested_count, 1)):
seed = segments[index] if index < len(segments) else cleaned
if index == 0:
suffix = "目标与范围"
elif index == 1:
suffix = "执行与风险"
else:
suffix = f"落地步骤{index + 1}"
base_title = IntentResolver._build_signal_title(seed or f"信号拆分 {index + 1}", default=f"信号拆分 {index + 1}")
fallback_rows.append(
{
"title": cls._normalize_short_text(f"{base_title} · {suffix}", max_len=120),
"summary": cls._normalize_short_text(
f"围绕用户目标拆分出的第{index + 1}条执行信号,需继续补充可交付结果与验收标准。",
max_len=300,
),
"content": cls._normalize_short_text(
f"原始指令要点:{seed or cleaned}。建议按负责人、时间窗口、依赖项进一步细化后推进执行。",
max_len=1800,
),
"signal_type": "新需求",
"priority": "medium",
"reasoning_bullets": [
"高级模型输出不可解析,已按规则拆分。",
f"信号拆分序号:{index + 1}。",
],
}
)
return fallback_rows

View File

@ -0,0 +1,241 @@
"""AI PMO workflow handlers and registry."""
from __future__ import annotations
from typing import Optional, TYPE_CHECKING
from ..models import ConversationSession, IngestEvent, Project, WorkItem
from ..schemas.world_model import WorldModel
from .ai_pmo_intent_routing import (
INTENT_FOLLOWUP_CONTINUE,
ROUTE_ACTION_DISCOVER,
ROUTE_FAMILY_FOLLOWUP,
ROUTE_FAMILY_PROJECT_CREATE,
ROUTE_FAMILY_PROJECT_SIGNAL,
ROUTE_FAMILY_QUERY,
ROUTE_FAMILY_SIGNAL_SOURCE,
CanonicalIntent,
WorkflowRoute,
_safe_int,
)
if TYPE_CHECKING:
from .ai_pmo_kernel_service import AIPMOKernelService
class ContextAssembler:
"""拼装 world model 和工作流上下文,不承担执行逻辑。"""
def __init__(self, kernel: "AIPMOKernelService") -> None:
self.kernel = kernel
async def assemble_world_model(
self,
*,
sender: str,
message_text: str,
project_hint_id: Optional[int],
session: Optional[ConversationSession],
) -> tuple[Optional[Project], WorldModel]:
query_tables: set[str] = set()
project = await self.kernel._resolve_project(
message_text=message_text,
project_hint_id=project_hint_id,
query_tables=query_tables,
session=session,
)
world_model = await self.kernel.build_world_model(
tenant_id=self.kernel.tenant_id,
project_id=_safe_int(getattr(project, "id", None)),
sender=sender,
project=project,
pre_queried_tables=query_tables,
)
return project, world_model
class WorkflowHandler:
"""工作流处理器基类。"""
def __init__(self, kernel: "AIPMOKernelService") -> None:
self.kernel = kernel
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]:
raise NotImplementedError
async def resume(
self,
*,
route: WorkflowRoute,
target_work_item: WorkItem,
target_ingest: IngestEvent,
followup_sender: str,
operator_user_id: Optional[int],
source_system: str,
source_message_id: str,
message_text: str,
world_model: dict[str, object],
) -> tuple[dict[str, object], dict[str, object]]:
return await self.kernel._resume_followup_target(
target_work_item=target_work_item,
target_ingest=target_ingest,
followup_intent=CanonicalIntent(
name=INTENT_FOLLOWUP_CONTINUE,
confidence=0.9,
project_id=_safe_int(target_work_item.project_id),
signal_title="",
signal_summary="",
metadata={
"workflow_route": route.key,
"workflow_family": route.family,
"workflow_action": route.action,
},
),
followup_sender=followup_sender,
operator_user_id=operator_user_id,
source_system=source_system,
source_message_id=source_message_id,
world_model=world_model,
message_text=message_text,
workflow_route=route,
)
class QueryWorkflowHandler(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 ProjectCreateWorkflowHandler(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,
*,
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 ProjectSignalWorkflowHandler(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]:
_ = sender
_ = operator_user_id
if route.action == ROUTE_ACTION_DISCOVER:
return await self.kernel.executor.discover_project_signal_candidates(
intent=intent,
message_text=message_text,
source_system=source_system,
source_message_id=source_message_id,
channel_ref=channel_ref,
)
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 WorkflowRegistry:
"""按 family.action 路由处理器。"""
def __init__(self, kernel: "AIPMOKernelService") -> None:
self.kernel = kernel
self._handlers: dict[str, WorkflowHandler] = {
ROUTE_FAMILY_QUERY: QueryWorkflowHandler(kernel),
ROUTE_FAMILY_PROJECT_CREATE: ProjectCreateWorkflowHandler(kernel),
ROUTE_FAMILY_PROJECT_SIGNAL: ProjectSignalWorkflowHandler(kernel),
ROUTE_FAMILY_SIGNAL_SOURCE: SignalSourceWorkflowHandler(kernel),
ROUTE_FAMILY_FOLLOWUP: WorkflowHandler(kernel),
}
def get_handler(self, *, route: WorkflowRoute) -> WorkflowHandler:
handler = self._handlers.get(route.family)
if handler is None:
return self._handlers[ROUTE_FAMILY_QUERY]
return handler

View File

@ -57,12 +57,6 @@ PROJECT_CREATION_STEP_RETRY_COUNT = max(
0, 0,
_env_int("AI_STEWARD_PROJECT_CREATION_STEP_RETRY_COUNT", 1), _env_int("AI_STEWARD_PROJECT_CREATION_STEP_RETRY_COUNT", 1),
) )
PROJECT_CREATION_STEP_FALLBACK_FUNCTIONS = {
"project_from_text_analysis": "project_draft_analysis_glm47_fallback",
"project_from_text_signal": "project_draft_signal_glm47_fallback",
"project_from_text_charter": "project_draft_charter_glm47_fallback",
"project_from_text_task_chain": "project_draft_task_chain_glm47_fallback",
}
PROJECT_CREATION_STUB_MARKERS = ( PROJECT_CREATION_STUB_MARKERS = (
"我暂时无法生成可靠回答", "我暂时无法生成可靠回答",
"模型服务异常", "模型服务异常",
@ -250,17 +244,6 @@ class AIStewardService:
return True return True
return any(marker in normalized for marker in PROJECT_CREATION_STUB_MARKERS) return any(marker in normalized for marker in PROJECT_CREATION_STUB_MARKERS)
@staticmethod
def _project_creation_function_candidates(function_name: str) -> list[str]:
base = str(function_name or "").strip()
if not base:
return []
candidates = [base]
fallback = str(PROJECT_CREATION_STEP_FALLBACK_FUNCTIONS.get(base) or "").strip()
if fallback and fallback != base:
candidates.append(fallback)
return candidates
async def _generate_project_creation_text( async def _generate_project_creation_text(
self, self,
*, *,
@ -269,11 +252,8 @@ class AIStewardService:
function_name: str, function_name: str,
timeout_seconds: float = PROJECT_CREATION_DRAFT_TIMEOUT_SECONDS, timeout_seconds: float = PROJECT_CREATION_DRAFT_TIMEOUT_SECONDS,
) -> str: ) -> str:
candidates = self._project_creation_function_candidates(function_name=function_name)
if not candidates:
return ""
attempts_per_function = PROJECT_CREATION_STEP_RETRY_COUNT + 1 attempts_per_function = PROJECT_CREATION_STEP_RETRY_COUNT + 1
for candidate_function in candidates: last_error: Exception | None = None
for attempt_index in range(attempts_per_function): for attempt_index in range(attempts_per_function):
started_at = time.perf_counter() started_at = time.perf_counter()
try: try:
@ -282,49 +262,37 @@ class AIStewardService:
prompt, prompt,
db=self.db, db=self.db,
tenant_id=tenant_id, tenant_id=tenant_id,
function_name=candidate_function, function_name=function_name,
response_mode="fast", response_mode="fast",
), ),
timeout=timeout_seconds, timeout=timeout_seconds,
) )
except Exception as exc:
logger.warning(
"ai_steward_project_creation_step_retry",
tenant_id=int(tenant_id),
function_name=candidate_function,
attempt=attempt_index + 1,
elapsed_seconds=round(time.perf_counter() - started_at, 2),
error=str(exc),
)
continue
cleaned_response = str(response or "").strip() cleaned_response = str(response or "").strip()
elapsed_seconds = round(time.perf_counter() - started_at, 2) elapsed_seconds = round(time.perf_counter() - started_at, 2)
if self._looks_like_project_creation_stub_response(cleaned_response): if self._looks_like_project_creation_stub_response(cleaned_response):
logger.warning( raise RuntimeError("project_creation_stub_response")
"ai_steward_project_creation_step_stub_retry",
tenant_id=int(tenant_id),
function_name=candidate_function,
attempt=attempt_index + 1,
elapsed_seconds=elapsed_seconds,
)
continue
logger.info( logger.info(
"ai_steward_project_creation_step_ok", "ai_steward_project_creation_step_ok",
tenant_id=int(tenant_id), tenant_id=int(tenant_id),
function_name=candidate_function, function_name=function_name,
attempt=attempt_index + 1, attempt=attempt_index + 1,
elapsed_seconds=elapsed_seconds, elapsed_seconds=elapsed_seconds,
) )
return cleaned_response return cleaned_response
except Exception as exc:
last_error = exc if isinstance(exc, Exception) else RuntimeError(str(exc))
logger.warning( logger.warning(
"ai_steward_project_creation_step_fallback", "ai_steward_project_creation_step_retry",
tenant_id=int(tenant_id), tenant_id=int(tenant_id),
function_name=function_name, function_name=function_name,
error="all_attempts_exhausted", attempt=attempt_index + 1,
elapsed_seconds=round(time.perf_counter() - started_at, 2),
error=str(exc),
) )
return ""
raise RuntimeError(
f"AI 管家项目创建步骤失败:function={function_name}, reason={last_error or 'unknown_error'}"
) from last_error
async def _normalize_project_creation_draft( async def _normalize_project_creation_draft(
self, self,
@ -628,19 +596,10 @@ class AIStewardService:
if response.startswith("json"): if response.startswith("json"):
response = response[4:] response = response[4:]
analysis = json.loads(response.strip()) analysis = json.loads(response.strip())
except Exception: except Exception as exc:
analysis = { raise RuntimeError(f"AI 管家项目需求分析失败:{exc}") from exc
"project_name": input_text[:20], if not isinstance(analysis, dict):
"project_title": input_text[:50], raise RuntimeError("AI 管家项目需求分析失败:返回内容不是 JSON 对象")
"tech_stack": ["GPT-4o-mini"],
"target_audience": "通用",
"timeline": "3周",
"primary_role": "ai_engineer",
"domain_id": 1,
"key_features": [],
"competitors": [],
"success_metrics": [],
}
return analysis return analysis
async def _generate_competitive_signal_draft( async def _generate_competitive_signal_draft(
@ -683,14 +642,12 @@ class AIStewardService:
response = response.split("```")[1] response = response.split("```")[1]
if response.startswith("json"): if response.startswith("json"):
response = response[4:] response = response[4:]
return json.loads(response.strip()) payload = json.loads(response.strip())
except Exception: except Exception as exc:
return { raise RuntimeError(f"AI 管家竞品信号生成失败:{exc}") from exc
"content": f"竞品分析:{', '.join(competitors)} 的技术现状和差异化机会", if not isinstance(payload, dict):
"impact_score": 0.8, raise RuntimeError("AI 管家竞品信号生成失败:返回内容不是 JSON 对象")
"priority": "high", return payload
"strategic_analysis": {"is_strategic": True},
}
async def _generate_charter_draft( async def _generate_charter_draft(
self, self,
@ -732,13 +689,10 @@ class AIStewardService:
if response.startswith("json"): if response.startswith("json"):
response = response[4:] response = response[4:]
charter_data = json.loads(response.strip()) charter_data = json.loads(response.strip())
except Exception: except Exception as exc:
charter_data = { raise RuntimeError(f"AI 管家项目宪章生成失败:{exc}") from exc
"title": f"{analysis.get('project_name')}项目宪章", if not isinstance(charter_data, dict):
"content": f"项目目标:{analysis.get('project_title')}\n成功标准:按时上线", raise RuntimeError("AI 管家项目宪章生成失败:返回内容不是 JSON 对象")
"success_criteria": ["按时交付", "功能完整"],
"constraints": ["时间限制"],
}
raw_content = charter_data.get("content", "") raw_content = charter_data.get("content", "")
if isinstance(raw_content, dict): if isinstance(raw_content, dict):
@ -800,26 +754,9 @@ class AIStewardService:
tasks_list = tasks_data.get("tasks", []) tasks_list = tasks_data.get("tasks", [])
if isinstance(tasks_list, list) and tasks_list: if isinstance(tasks_list, list) and tasks_list:
return tasks_list return tasks_list
except Exception: except Exception as exc:
pass raise RuntimeError(f"AI 管家任务链生成失败:{exc}") from exc
return [ raise RuntimeError("AI 管家任务链生成失败:返回 tasks 为空")
{
"title": "技术方案设计",
"type": analysis.get("primary_role", "backend"),
"estimated_hours": 16,
"description": "完成项目技术方案设计",
"priority": "high",
"required_skills": ["系统设计"],
},
{
"title": "核心功能开发",
"type": analysis.get("primary_role", "backend"),
"estimated_hours": 40,
"description": "完成核心功能开发",
"priority": "high",
"required_skills": ["开发"],
},
]
@staticmethod @staticmethod
def _build_risk_alert_drafts(*, analysis: Dict[str, Any]) -> List[Dict[str, Any]]: def _build_risk_alert_drafts(*, analysis: Dict[str, Any]) -> List[Dict[str, Any]]:

View File

@ -237,8 +237,6 @@ class ContextCacheService:
return "PostgreSQL (pgvector-ready)" return "PostgreSQL (pgvector-ready)"
if "mysql" in url: if "mysql" in url:
return "MySQL" return "MySQL"
if "sqlite" in url:
return "SQLite"
return "Unknown" return "Unknown"
@staticmethod @staticmethod

View File

@ -371,6 +371,7 @@ class FeishuIntegrationService:
) )
reply_route = "none" reply_route = "none"
capability_reply = self._build_capability_reply(message_content)
if scene == "execute": if scene == "execute":
if self._is_duplicate_capture_result(result): if self._is_duplicate_capture_result(result):
self._emit_state_transition( self._emit_state_transition(
@ -384,7 +385,7 @@ class FeishuIntegrationService:
state="ROUTED", state="ROUTED",
route="tool_first_execute", route="tool_first_execute",
) )
deterministic_reply = str(result.get("direct_reply_text") or "").strip() deterministic_reply = capability_reply or str(result.get("direct_reply_text") or "").strip()
if not deterministic_reply: if not deterministic_reply:
deterministic_reply = self._build_execution_receipt_reply( deterministic_reply = self._build_execution_receipt_reply(
message_content=message_content, message_content=message_content,
@ -423,7 +424,18 @@ class FeishuIntegrationService:
state="ROUTED", state="ROUTED",
route="qa_consult", route="qa_consult",
) )
if chat_id and bool(result.get("direct_reply_required")): if chat_id and capability_reply:
await self._safe_reply_text(
chat_id=chat_id,
mention_open_id=sender_open_id,
mention_name=sender_name,
text=capability_reply,
trace_id=state_trace_id,
source_message_id=message_id,
perf_t0=perf_t0,
)
reply_route = "qa_capability"
elif chat_id and bool(result.get("direct_reply_required")):
await self._safe_reply_text( await self._safe_reply_text(
chat_id=chat_id, chat_id=chat_id,
mention_open_id=sender_open_id, mention_open_id=sender_open_id,
@ -696,6 +708,11 @@ class FeishuIntegrationService:
except Exception as exc: except Exception as exc:
print(f"[Feishu] generate mention reply failed: {exc}") print(f"[Feishu] generate mention reply failed: {exc}")
reply_text = self._fallback_reply_text(capture_result) reply_text = self._fallback_reply_text(capture_result)
reply_text, _ = self._guard_qa_reply_text_with_reason(
reply_text,
trace_id=str(trace_id or f"feishu-sm-{uuid4().hex[:12]}"),
capture_result=capture_result,
)
await self._safe_reply_text( await self._safe_reply_text(
chat_id=chat_id, chat_id=chat_id,
@ -1024,6 +1041,16 @@ class FeishuIntegrationService:
) )
return any(token in normalized for token in capability_tokens) return any(token in normalized for token in capability_tokens)
@classmethod
def _build_capability_reply(cls, message_content: Optional[str]) -> Optional[str]:
if not cls._is_capability_question(message_content):
return None
return (
"我是 PMFlow 的项目协同助手。"
"我可以识别需求并入库信号、关联项目和任务、跟进状态并回答项目问题。"
"你直接说目标、对象和期望动作即可。"
)
@staticmethod @staticmethod
def _strip_identity_lead(reply_text: str) -> str: def _strip_identity_lead(reply_text: str) -> str:
text = str(reply_text or "").strip() text = str(reply_text or "").strip()
@ -1241,6 +1268,30 @@ class FeishuIntegrationService:
return token return token
return None return None
@staticmethod
def _detect_forbidden_qa_keyword(text: Optional[str]) -> Optional[str]:
lowered = str(text or "").strip().lower()
if not lowered:
return None
forbidden = (
"模型失败了所以没分析",
"让我直接处理",
"直接操作数据库",
"先检查数据库",
"检查数据库迁移",
"数据库迁移状态",
"alembic",
"ddl",
"alter table",
"drop table",
"create table",
"truncate table",
)
for token in forbidden:
if token in lowered:
return token
return None
@classmethod @classmethod
def _contains_forbidden_execute_keywords(cls, text: Optional[str]) -> bool: def _contains_forbidden_execute_keywords(cls, text: Optional[str]) -> bool:
return cls._detect_forbidden_execute_keyword(text) is not None return cls._detect_forbidden_execute_keyword(text) is not None
@ -1272,6 +1323,27 @@ class FeishuIntegrationService:
sanitized, _ = self._guard_execute_reply_text_with_reason(text=text, trace_id=trace_id) sanitized, _ = self._guard_execute_reply_text_with_reason(text=text, trace_id=trace_id)
return sanitized return sanitized
def _guard_qa_reply_text_with_reason(
self,
text: Optional[str],
*,
trace_id: str,
capture_result: Dict[str, Any],
) -> tuple[Optional[str], Optional[str]]:
if not text:
return text, None
hit = self._detect_forbidden_qa_keyword(text)
if not hit:
return text, None
self._emit_state_transition(
trace_id=trace_id,
state="ROUTED",
route="qa_llm_guard",
reason="forbidden_keyword_detected",
keyword=hit,
)
return self._fallback_reply_text(capture_result), hit
async def _record_execute_guard_alert( async def _record_execute_guard_alert(
self, self,
*, *,

View File

@ -1,6 +1,7 @@
"""飞书消息分流与决策队列服务。""" """飞书消息分流与决策队列服务。"""
from __future__ import annotations from __future__ import annotations
import asyncio
import json import json
import hashlib import hashlib
import re import re
@ -29,6 +30,7 @@ from ..models import (
ProjectSuggestion, ProjectSuggestion,
Signal, Signal,
SignalEdit, SignalEdit,
TruthNode,
User, User,
) )
from .llm import LLMService from .llm import LLMService
@ -38,8 +40,16 @@ from .ai_agent_kernel import (
gate_execution as kernel_gate_execution, gate_execution as kernel_gate_execution,
resolve_correction as kernel_resolve_correction, resolve_correction as kernel_resolve_correction,
) )
from .context_cache import context_cache_service
from .notification_service import NotificationService from .notification_service import NotificationService
from .openclaw_commander_service import OpenClawCommanderService, OperationExecutorService from .operation_log_service import log_operation
from .project_command_utils import (
extract_project_create_description,
extract_project_create_title,
normalize_project_title_candidate,
)
from .sstc_service import ALIGNED_STATUSES
from .task_persistence import build_task_create_draft, persist_task
from ..utils.project_members import ensure_project_default_members from ..utils.project_members import ensure_project_default_members
from ..utils.sequence import ensure_project_id_sequence from ..utils.sequence import ensure_project_id_sequence
@ -89,15 +99,15 @@ PROJECT_ID_QUERY_RE = re.compile(
r"项目\s*id\s*[::]?\s*(\d+).{0,12}(有吗|存在吗|还在吗|是否存在|是不是)", r"项目\s*id\s*[::]?\s*(\d+).{0,12}(有吗|存在吗|还在吗|是否存在|是不是)",
re.IGNORECASE, re.IGNORECASE,
) )
OPENCLAW_PROJECT_CREATE_RE = re.compile( LEGACY_COMMAND_PROJECT_CREATE_RE = re.compile(
r"(创建|新建|建立|开个|开一个|建个|建一个|做个|做一个).{0,6}项目", r"(创建|新建|建立|开个|开一个|建个|建一个|做个|做一个).{0,6}项目",
re.IGNORECASE, re.IGNORECASE,
) )
OPENCLAW_COMMAND_VERB_RE = re.compile( LEGACY_COMMAND_VERB_RE = re.compile(
r"(创建|新建|建立|开个|开一个|建个|建一个|做个|做一个|修改|更新|改成|改为|设为|设成|分配|指派|归档|重开|重新打开|恢复|标记完成|完成|提醒|通知|调整工时|调整工作量)", r"(创建|新建|建立|开个|开一个|建个|建一个|做个|做一个|修改|更新|改成|改为|设为|设成|分配|指派|归档|重开|重新打开|恢复|标记完成|完成|提醒|通知|调整工时|调整工作量)",
re.IGNORECASE, re.IGNORECASE,
) )
OPENCLAW_COMMAND_OBJECT_RE = re.compile( LEGACY_COMMAND_OBJECT_RE = re.compile(
r"(项目|任务|负责人|优先级|状态|通知|提醒|工时|工作量)", r"(项目|任务|负责人|优先级|状态|通知|提醒|工时|工作量)",
re.IGNORECASE, re.IGNORECASE,
) )
@ -159,8 +169,8 @@ ROUTING_DUPLICATE_FILTERED = "duplicate_filtered"
ROUTING_NOISE_FILTERED = "noise_filtered" ROUTING_NOISE_FILTERED = "noise_filtered"
ROUTING_FAILED = "failed" ROUTING_FAILED = "failed"
ROUTING_DIRECT_QA = "direct_qa" ROUTING_DIRECT_QA = "direct_qa"
ROUTING_OPENCLAW_EXECUTED = "openclaw_executed" ROUTING_LEGACY_COMMAND_EXECUTED = "legacy_command_executed"
ROUTING_OPENCLAW_BLOCKED = "openclaw_blocked" ROUTING_LEGACY_COMMAND_BLOCKED = "legacy_command_blocked"
ROUTING_QUEUE_FOLLOWUP_QUERY = "queue_followup_query" ROUTING_QUEUE_FOLLOWUP_QUERY = "queue_followup_query"
ROUTING_QUEUE_FOLLOWUP_CONFIRM = "queue_followup_confirm" ROUTING_QUEUE_FOLLOWUP_CONFIRM = "queue_followup_confirm"
ROUTING_QUEUE_FOLLOWUP_BLOCKED = "queue_followup_blocked" ROUTING_QUEUE_FOLLOWUP_BLOCKED = "queue_followup_blocked"
@ -437,7 +447,7 @@ class FeishuSignalPipelineService:
return False return False
if PROJECT_CREATE_GATE_RE.search(text): if PROJECT_CREATE_GATE_RE.search(text):
return True return True
return bool(OPENCLAW_PROJECT_CREATE_RE.search(text)) return bool(LEGACY_COMMAND_PROJECT_CREATE_RE.search(text))
@staticmethod @staticmethod
def _looks_like_external_connect_request(message: str) -> bool: def _looks_like_external_connect_request(message: str) -> bool:
@ -564,6 +574,9 @@ class FeishuSignalPipelineService:
"correction_match": None, "correction_match": None,
} }
explicit_project_request = self._looks_like_project_create_request(text)
explicit_task_request = self._looks_like_task_create_request(text)
explicit_external_request = self._looks_like_external_connect_request(text)
correction_match: Optional[dict[str, Any]] = None correction_match: Optional[dict[str, Any]] = None
if self.db is not None and len(text) >= 4: if self.db is not None and len(text) >= 4:
try: try:
@ -577,7 +590,9 @@ class FeishuSignalPipelineService:
except Exception as exc: except Exception as exc:
logger.warning(f"intent_correction_recall_failed: tenant_id={self.tenant_id}, error={exc}") logger.warning(f"intent_correction_recall_failed: tenant_id={self.tenant_id}, error={exc}")
correction_decision = kernel_resolve_correction(text) correction_decision = kernel_resolve_correction(text)
if correction_decision.action != "none": if correction_decision.action != "none" and not (
explicit_project_request or explicit_task_request or explicit_external_request
):
decision_trace = build_decision_trace( decision_trace = build_decision_trace(
intent=INTENT_CLARIFICATION_REQUIRED, intent=INTENT_CLARIFICATION_REQUIRED,
confidence=correction_decision.confidence, confidence=correction_decision.confidence,
@ -618,7 +633,11 @@ class FeishuSignalPipelineService:
"correction_match": correction_match, "correction_match": correction_match,
} }
if correction_match and float(correction_match.get("score") or 0.0) >= 0.84: if (
correction_match
and float(correction_match.get("score") or 0.0) >= 0.84
and not (explicit_project_request or explicit_task_request or explicit_external_request)
):
gate = { gate = {
"intent": str(correction_match.get("correct_intent") or INTENT_SIGNAL_INTAKE).strip().lower(), "intent": str(correction_match.get("correct_intent") or INTENT_SIGNAL_INTAKE).strip().lower(),
"canonical_action": str(correction_match.get("canonical_action") or "").strip() or None, "canonical_action": str(correction_match.get("canonical_action") or "").strip() or None,
@ -640,7 +659,7 @@ class FeishuSignalPipelineService:
gate["intent"] = INTENT_SIGNAL_INTAKE gate["intent"] = INTENT_SIGNAL_INTAKE
return gate return gate
if self._looks_like_project_create_request(text): if explicit_project_request:
fields = self._extract_project_intent_fields(text) fields = self._extract_project_intent_fields(text)
fields = await self._maybe_prefill_project_fields_from_embedded_content( fields = await self._maybe_prefill_project_fields_from_embedded_content(
message=text, message=text,
@ -671,7 +690,7 @@ class FeishuSignalPipelineService:
"correction_match": correction_match, "correction_match": correction_match,
} }
if self._looks_like_task_create_request(text): if explicit_task_request:
fields = self._extract_task_intent_fields(text=text, inferred_project_id=inferred_project_id) fields = self._extract_task_intent_fields(text=text, inferred_project_id=inferred_project_id)
if fields["ready"]: if fields["ready"]:
return { return {
@ -691,7 +710,7 @@ class FeishuSignalPipelineService:
"correction_match": correction_match, "correction_match": correction_match,
} }
if self._looks_like_external_connect_request(text): if explicit_external_request:
return { return {
"intent": INTENT_EXTERNAL_CONNECT, "intent": INTENT_EXTERNAL_CONNECT,
"canonical_action": "source.connect", "canonical_action": "source.connect",
@ -748,7 +767,7 @@ class FeishuSignalPipelineService:
return "" return ""
def _extract_project_intent_fields(self, message: str) -> dict[str, Any]: def _extract_project_intent_fields(self, message: str) -> dict[str, Any]:
title = OpenClawCommanderService._extract_project_create_title(message) title = extract_project_create_title(message)
if not title: if not title:
cleaned = re.sub(r"\s+", " ", str(message or "")).strip() cleaned = re.sub(r"\s+", " ", str(message or "")).strip()
for pattern in ( for pattern in (
@ -762,7 +781,7 @@ class FeishuSignalPipelineService:
if candidate: if candidate:
title = candidate[:24] title = candidate[:24]
break break
description = OpenClawCommanderService._extract_project_create_description( description = extract_project_create_description(
instruction=message, instruction=message,
title=title, title=title,
) )
@ -941,9 +960,9 @@ class FeishuSignalPipelineService:
candidate = re.sub(r"[,,].*$", "", candidate).strip(" ,,。.!!??::;;") candidate = re.sub(r"[,,].*$", "", candidate).strip(" ,,。.!!??::;;")
candidate = re.sub(r"(?:里面|里)$", "", candidate).strip(" ,,。.!!??::;;") candidate = re.sub(r"(?:里面|里)$", "", candidate).strip(" ,,。.!!??::;;")
candidate = re.sub(r"[。;;!!??].*$", "", candidate).strip(" ,,。.!!??::;;") candidate = re.sub(r"[。;;!!??].*$", "", candidate).strip(" ,,。.!!??::;;")
normalized = OpenClawCommanderService._normalize_project_title_candidate(candidate) normalized = normalize_project_title_candidate(candidate)
if normalized.endswith(("方向", "规划", "创新")): if normalized.endswith(("方向", "规划", "创新")):
normalized = OpenClawCommanderService._normalize_project_title_candidate(f"{normalized}研究") normalized = normalize_project_title_candidate(f"{normalized}研究")
return normalized return normalized
title = _normalize(question) title = _normalize(question)
@ -1057,7 +1076,7 @@ class FeishuSignalPipelineService:
project_info = await self._extract_project_prefill( project_info = await self._extract_project_prefill(
f"当前消息里的项目材料:{embedded_context}\n\n用户动作:{message}\n\n请仅基于当前消息里的材料补全项目名称、项目描述、项目范围。" f"当前消息里的项目材料:{embedded_context}\n\n用户动作:{message}\n\n请仅基于当前消息里的材料补全项目名称、项目描述、项目范围。"
) )
suggested_name = OpenClawCommanderService._normalize_project_title_candidate( suggested_name = normalize_project_title_candidate(
str(project_info.get("suggested_name") or "").strip() str(project_info.get("suggested_name") or "").strip()
) )
if self._looks_like_placeholder_project_text(suggested_name): if self._looks_like_placeholder_project_text(suggested_name):
@ -1178,7 +1197,7 @@ class FeishuSignalPipelineService:
title = ( title = (
str(fields.get("title") or "").strip() str(fields.get("title") or "").strip()
or heuristic_title or heuristic_title
or OpenClawCommanderService._normalize_project_title_candidate(str(project_info.get("suggested_name") or "")) or normalize_project_title_candidate(str(project_info.get("suggested_name") or ""))
) )
description = current_description or str(project_info.get("suggested_description") or "").strip() description = current_description or str(project_info.get("suggested_description") or "").strip()
if not description and answer_hint: if not description and answer_hint:
@ -1269,7 +1288,7 @@ class FeishuSignalPipelineService:
description: str, description: str,
project_scope: str, project_scope: str,
) -> str: ) -> str:
normalized_title = OpenClawCommanderService._normalize_project_title_candidate(title) normalized_title = normalize_project_title_candidate(title)
if normalized_title and not cls._looks_like_placeholder_project_text(normalized_title): if normalized_title and not cls._looks_like_placeholder_project_text(normalized_title):
return normalized_title return normalized_title
@ -1277,7 +1296,7 @@ class FeishuSignalPipelineService:
cleaned = str(candidate or "").strip() cleaned = str(candidate or "").strip()
if not cleaned or cls._looks_like_placeholder_project_text(cleaned): if not cleaned or cls._looks_like_placeholder_project_text(cleaned):
continue continue
normalized = OpenClawCommanderService._normalize_project_title_candidate(cleaned[:48]) normalized = normalize_project_title_candidate(cleaned[:48])
if normalized: if normalized:
return normalized return normalized
return "项目创建请求" return "项目创建请求"
@ -1448,7 +1467,7 @@ class FeishuSignalPipelineService:
queue_visible=True, queue_visible=True,
result_payload={ result_payload={
"intent": INTENT_CREATE_PROJECT, "intent": INTENT_CREATE_PROJECT,
"executor": "operation_executor", "executor": "explicit_service",
"operation_code": "project.create", "operation_code": "project.create",
"operation_payload": { "operation_payload": {
"title": str(fields.get("title") or "").strip(), "title": str(fields.get("title") or "").strip(),
@ -1527,7 +1546,7 @@ class FeishuSignalPipelineService:
existing_item.result_payload = { existing_item.result_payload = {
**(existing_item.result_payload if isinstance(existing_item.result_payload, dict) else {}), **(existing_item.result_payload if isinstance(existing_item.result_payload, dict) else {}),
"intent": str(intent_gate.get("intent") or ""), "intent": str(intent_gate.get("intent") or ""),
"executor": "operation_executor", "executor": "explicit_service",
"operation_code": operation_code, "operation_code": operation_code,
"operation_payload": operation_payload, "operation_payload": operation_payload,
"result_summary": self._build_pending_operation_summary( "result_summary": self._build_pending_operation_summary(
@ -1569,7 +1588,7 @@ class FeishuSignalPipelineService:
queue_visible=True, queue_visible=True,
result_payload={ result_payload={
"intent": str(intent_gate.get("intent") or ""), "intent": str(intent_gate.get("intent") or ""),
"executor": "operation_executor", "executor": "explicit_service",
"operation_code": operation_code, "operation_code": operation_code,
"operation_payload": operation_payload, "operation_payload": operation_payload,
"result_summary": self._build_pending_operation_summary( "result_summary": self._build_pending_operation_summary(
@ -1636,7 +1655,7 @@ class FeishuSignalPipelineService:
processed_at=_utc_now().replace(tzinfo=None), processed_at=_utc_now().replace(tzinfo=None),
result_payload={ result_payload={
"intent": intent, "intent": intent,
"executor": "operation_executor", "executor": "explicit_service",
"operation_code": operation_code, "operation_code": operation_code,
"result_summary": str(execution_result.get("message") or "").strip() or operation_code, "result_summary": str(execution_result.get("message") or "").strip() or operation_code,
"operation_result": execution_result.get("result") if isinstance(execution_result.get("result"), dict) else {}, "operation_result": execution_result.get("result") if isinstance(execution_result.get("result"), dict) else {},
@ -1656,11 +1675,165 @@ class FeishuSignalPipelineService:
payload: dict[str, Any], payload: dict[str, Any],
trace_id: str, trace_id: str,
) -> dict[str, Any]: ) -> dict[str, Any]:
executor = OperationExecutorService(self.db) logger.info(
project_context = None "operation_started",
tenant_id=self.tenant_id,
trace_id=trace_id,
operation_code=operation_code,
payload=payload,
executor="explicit_service",
)
if operation_code == "project.create":
result = await self._commit_project_create(
operator_user=operator_user,
payload=payload,
trace_id=trace_id,
)
elif operation_code == "task.create":
result = await self._commit_task_create(
operator_user=operator_user,
payload=payload,
trace_id=trace_id,
)
else:
raise ValueError("operation_not_supported")
execution = self._wrap_committed_operation(operation_code=operation_code, payload=payload, result=result)
first = execution["results"][0] if execution.get("results") else {}
event_name = "operation_committed" if str(first.get("status") or "") in {"applied", "skipped"} else "operation_failed"
logger.info(
event_name,
tenant_id=self.tenant_id,
trace_id=trace_id,
operation_code=operation_code,
result=first,
executor="explicit_service",
)
return execution
@staticmethod
def _wrap_committed_operation(*, operation_code: str, payload: dict[str, Any], result: dict[str, Any]) -> dict[str, Any]:
status = str(result.get("status") or "").strip().lower()
return {
"dry_run": False,
"requested_count": 1,
"applied_count": 1 if status == "applied" else 0,
"skipped_count": 1 if status == "skipped" else 0,
"failed_count": 1 if status == "failed" else 0,
"results": [
{
"index": 1,
"operation_code": operation_code,
"payload": dict(payload),
**result,
}
],
}
async def _commit_project_create(
self,
*,
operator_user: User,
payload: dict[str, Any],
trace_id: str,
) -> dict[str, Any]:
operator_user_id = _safe_int(getattr(operator_user, "id", None))
if operator_user_id is None:
raise ValueError("operator_not_found")
title = normalize_project_title_candidate(str(payload.get("title") or ""))
if not title:
raise ValueError("project title required")
description = str(payload.get("description") or "").strip()[:4000] or None
existing = (
await self.db.execute(
select(Project).where(
and_(
Project.tenant_id == self.tenant_id,
or_(func.lower(Project.title) == title.lower(), func.lower(Project.name) == title.lower()),
Project.status != "inactive",
)
).limit(1)
)
).scalar_one_or_none()
if existing is not None:
return {
"status": "skipped",
"message": "project_already_exists",
"entity_type": "project",
"entity_id": str(existing.id),
"result": {
"project_id": int(existing.id),
"title": str(existing.title or existing.name or "").strip(),
"status": str(existing.status or "").strip(),
},
}
await ensure_project_id_sequence(self.db)
project = Project(
tenant_id=self.tenant_id,
name=title,
title=title,
description=description,
owner_id=operator_user_id,
status="active",
domain_id=None,
assigned_role="ai_engineer",
signal_count=0,
)
self.db.add(project)
await self.db.flush()
await ensure_project_default_members(
self.db,
tenant_id=self.tenant_id,
project_id=int(project.id),
owner_id=int(project.owner_id) if project.owner_id is not None else None,
assigned_role=project.assigned_role,
)
await log_operation(
self.db,
tenant_id=self.tenant_id,
user_id=operator_user_id,
project_id=int(project.id),
entity_type="project",
entity_id=str(project.id),
action="project.create",
before_data=None,
after_data={
"title": project.title,
"status": project.status,
"owner_id": int(project.owner_id) if project.owner_id is not None else None,
},
remark=trace_id[:1000],
)
await self.db.commit()
await context_cache_service.invalidate_system_context(tenant_id=self.tenant_id)
return {
"status": "applied",
"message": f"project created #{int(project.id)}",
"entity_type": "project",
"entity_id": str(project.id),
"result": {
"project_id": int(project.id),
"title": str(project.title or project.name or "").strip(),
"status": str(project.status or "").strip(),
},
}
async def _commit_task_create(
self,
*,
operator_user: User,
payload: dict[str, Any],
trace_id: str,
) -> dict[str, Any]:
operator_user_id = _safe_int(getattr(operator_user, "id", None))
if operator_user_id is None:
raise ValueError("operator_not_found")
project_id = _safe_int(payload.get("project_id")) project_id = _safe_int(payload.get("project_id"))
if operation_code != "project.create" and project_id is not None: if project_id is None:
project_context = ( raise ValueError("project_context_missing")
project = (
await self.db.execute( await self.db.execute(
select(Project).where( select(Project).where(
and_( and_(
@ -1670,42 +1843,73 @@ class FeishuSignalPipelineService:
) )
) )
).scalar_one_or_none() ).scalar_one_or_none()
if project_context is None: if project is None:
raise ValueError("project_context_missing") raise ValueError("project_context_missing")
logger.info( charter = (
"operation_started", await self.db.execute(
tenant_id=self.tenant_id, select(TruthNode).where(
trace_id=trace_id, and_(
operation_code=operation_code, TruthNode.tenant_id == self.tenant_id,
payload=payload, TruthNode.project_id == int(project_id),
executor="operation_executor", TruthNode.node_type == "charter",
TruthNode.status.in_(tuple(ALIGNED_STATUSES)),
) )
execution = await executor.execute_operations( ).order_by(TruthNode.updated_at.desc(), TruthNode.id.desc())
)
).scalars().first()
if charter is None:
raise ValueError("aligned charter required before task.create")
task = await persist_task(
self.db,
tenant_id=self.tenant_id, tenant_id=self.tenant_id,
operator_user=operator_user, truth_node_id=int(charter.id),
project=project_context, project_id=int(project_id),
operations=[ draft=build_task_create_draft(
{ title=str(payload.get("title") or ""),
"operation_code": operation_code, description=str(payload.get("description") or "").strip() or None,
"payload": payload, priority=payload.get("priority"),
"reason": "intent_gate", ),
actor_user_id=operator_user_id,
action="task.create",
remark=trace_id[:1000],
commit=True,
refresh_workload=False,
)
await context_cache_service.invalidate_system_context(tenant_id=self.tenant_id)
self._sync_task_rag(task=task)
return {
"status": "applied",
"message": f"task created #{int(task.id)}",
"entity_type": "task",
"entity_id": str(task.id),
"result": {
"task_id": int(task.id),
"title": str(task.title or "").strip(),
"project_id": int(project_id),
},
} }
],
dry_run=False, def _sync_task_rag(self, *, task: Any) -> None:
trace_id=trace_id, try:
content = (
(str(getattr(task, "description", "") or ""))
+ f"\n状态: {str(getattr(task, 'status', '') or '')}"
+ f"\n负责人: {int(getattr(task, 'assignee_id', 0) or 0) if getattr(task, 'assignee_id', None) else '未分配'}"
) )
first = execution["results"][0] if execution.get("results") else {} asyncio.create_task(
event_name = "operation_committed" if str(first.get("status") or "") in {"applied", "skipped"} else "operation_failed" AdaptiveRAGService.upsert_pmflow_record_async(
logger.info(
event_name,
tenant_id=self.tenant_id, tenant_id=self.tenant_id,
trace_id=trace_id, record_type="task",
operation_code=operation_code, record_id=int(task.id),
result=first, project_id=int(task.project_id),
executor="operation_executor", title=str(task.title or "任务"),
content=content,
) )
return execution )
except Exception:
return
@staticmethod @staticmethod
def _derive_wrong_interpretation_from_recent_item(item: Optional[FeishuItem]) -> str: def _derive_wrong_interpretation_from_recent_item(item: Optional[FeishuItem]) -> str:
@ -1845,7 +2049,7 @@ class FeishuSignalPipelineService:
normalized_text=message, normalized_text=message,
route=ROUTING_INTENT_GATE_CLARIFICATION, route=ROUTING_INTENT_GATE_CLARIFICATION,
intent=INTENT_CLARIFICATION_REQUIRED, intent=INTENT_CLARIFICATION_REQUIRED,
executor="operation_executor", executor="explicit_service",
operation_code="project.create", operation_code="project.create",
reply_source="deterministic_reply", reply_source="deterministic_reply",
) )
@ -1898,7 +2102,7 @@ class FeishuSignalPipelineService:
normalized_text=message, normalized_text=message,
route=ROUTING_INTENT_GATE_CLARIFICATION, route=ROUTING_INTENT_GATE_CLARIFICATION,
intent=INTENT_CLARIFICATION_REQUIRED if not fields.get("ready") else INTENT_CREATE_PROJECT, intent=INTENT_CLARIFICATION_REQUIRED if not fields.get("ready") else INTENT_CREATE_PROJECT,
executor="operation_executor", executor="explicit_service",
operation_code="project.create", operation_code="project.create",
reply_source="deterministic_reply", reply_source="deterministic_reply",
) )
@ -1952,7 +2156,7 @@ class FeishuSignalPipelineService:
execution_result=first, execution_result=first,
matched_project_id=project_id, matched_project_id=project_id,
) )
reply_text = self._build_openclaw_command_reply({"execution": {"results": [first], "applied_count": 1 if status == "applied" else 0, "skipped_count": 1 if status == "skipped" else 0, "failed_count": 0}}) reply_text = self._build_operation_commit_reply({"execution": {"results": [first], "applied_count": 1 if status == "applied" else 0, "skipped_count": 1 if status == "skipped" else 0, "failed_count": 0}})
await self._write_processing_log( await self._write_processing_log(
feishu_message_id=feishu_message_id, feishu_message_id=feishu_message_id,
feishu_item_id=item_id, feishu_item_id=item_id,
@ -1974,7 +2178,7 @@ class FeishuSignalPipelineService:
normalized_text=message, normalized_text=message,
route=ROUTING_OPERATION_COMMITTED, route=ROUTING_OPERATION_COMMITTED,
intent=INTENT_CREATE_PROJECT, intent=INTENT_CREATE_PROJECT,
executor="operation_executor", executor="explicit_service",
operation_code="project.create", operation_code="project.create",
operation_result=first if isinstance(first, dict) else None, operation_result=first if isinstance(first, dict) else None,
reply_source="db_commit", reply_source="db_commit",
@ -2051,7 +2255,7 @@ class FeishuSignalPipelineService:
normalized_text=message, normalized_text=message,
route=ROUTING_INTENT_GATE_CLARIFICATION, route=ROUTING_INTENT_GATE_CLARIFICATION,
intent=INTENT_CLARIFICATION_REQUIRED, intent=INTENT_CLARIFICATION_REQUIRED,
executor="operation_executor", executor="explicit_service",
operation_code="task.create", operation_code="task.create",
reply_source="deterministic_reply", reply_source="deterministic_reply",
) )
@ -2107,7 +2311,7 @@ class FeishuSignalPipelineService:
normalized_text=message, normalized_text=message,
route=ROUTING_INTENT_GATE_CLARIFICATION, route=ROUTING_INTENT_GATE_CLARIFICATION,
intent=INTENT_CLARIFICATION_REQUIRED if not fields.get("ready") else INTENT_CREATE_TASK, intent=INTENT_CLARIFICATION_REQUIRED if not fields.get("ready") else INTENT_CREATE_TASK,
executor="operation_executor", executor="explicit_service",
operation_code="task.create", operation_code="task.create",
reply_source="deterministic_reply", reply_source="deterministic_reply",
) )
@ -2151,7 +2355,7 @@ class FeishuSignalPipelineService:
execution_result=first, execution_result=first,
matched_project_id=project_id, matched_project_id=project_id,
) )
reply_text = self._build_openclaw_command_reply({"execution": {"results": [first], "applied_count": 1 if status == "applied" else 0, "skipped_count": 1 if status == "skipped" else 0, "failed_count": 0}}) reply_text = self._build_operation_commit_reply({"execution": {"results": [first], "applied_count": 1 if status == "applied" else 0, "skipped_count": 1 if status == "skipped" else 0, "failed_count": 0}})
await self._write_processing_log( await self._write_processing_log(
feishu_message_id=feishu_message_id, feishu_message_id=feishu_message_id,
feishu_item_id=item_id, feishu_item_id=item_id,
@ -2173,7 +2377,7 @@ class FeishuSignalPipelineService:
normalized_text=message, normalized_text=message,
route=ROUTING_OPERATION_COMMITTED, route=ROUTING_OPERATION_COMMITTED,
intent=INTENT_CREATE_TASK, intent=INTENT_CREATE_TASK,
executor="operation_executor", executor="explicit_service",
operation_code="task.create", operation_code="task.create",
operation_result=first if isinstance(first, dict) else None, operation_result=first if isinstance(first, dict) else None,
reply_source="db_commit", reply_source="db_commit",
@ -2597,7 +2801,7 @@ class FeishuSignalPipelineService:
db_mutations=self._summarize_db_mutations(external_capture), db_mutations=self._summarize_db_mutations(external_capture),
) )
command_capture = await self._maybe_execute_openclaw_command( command_capture = await self._maybe_block_legacy_command_request(
message=message, message=message,
sender=sender, sender=sender,
source_chat_id=source_chat_id, source_chat_id=source_chat_id,
@ -2988,7 +3192,7 @@ class FeishuSignalPipelineService:
return False return False
if cls._is_query_like_question(message): if cls._is_query_like_question(message):
return True return True
if cls._is_openclaw_business_command(text): if cls._is_legacy_business_command(text):
return False return False
if cls._looks_like_project_lookup_question(text): if cls._looks_like_project_lookup_question(text):
return True return True
@ -3003,7 +3207,7 @@ class FeishuSignalPipelineService:
has_question_structure = bool(OBVIOUS_QUESTION_RE.search(text) or QUERY_INTENT_RE.search(text)) has_question_structure = bool(OBVIOUS_QUESTION_RE.search(text) or QUERY_INTENT_RE.search(text))
if not has_question_structure: if not has_question_structure:
return False return False
has_operation_verb = bool(OPENCLAW_COMMAND_VERB_RE.search(text)) has_operation_verb = bool(LEGACY_COMMAND_VERB_RE.search(text))
imperative_tokens = ("帮我", "请", "麻烦", "直接", "立刻", "马上", "执行", "处理", "安排") imperative_tokens = ("帮我", "请", "麻烦", "直接", "立刻", "马上", "执行", "处理", "安排")
has_imperative = any(token in normalized for token in imperative_tokens) has_imperative = any(token in normalized for token in imperative_tokens)
return not has_operation_verb and not has_imperative return not has_operation_verb and not has_imperative
@ -3485,14 +3689,14 @@ class FeishuSignalPipelineService:
@staticmethod @staticmethod
def _looks_like_project_create_command(message: str) -> bool: def _looks_like_project_create_command(message: str) -> bool:
return bool(OPENCLAW_PROJECT_CREATE_RE.search(str(message or ""))) return bool(LEGACY_COMMAND_PROJECT_CREATE_RE.search(str(message or "")))
@classmethod @classmethod
def _looks_like_project_lookup_question(cls, message: str) -> bool: def _looks_like_project_lookup_question(cls, message: str) -> bool:
text = str(message or "").strip() text = str(message or "").strip()
if not text: if not text:
return False return False
if cls._is_openclaw_business_command(text): if cls._is_legacy_business_command(text):
return False return False
normalized = re.sub(r"\s+", "", text.lower()) normalized = re.sub(r"\s+", "", text.lower())
if PROJECT_LIST_QUERY_RE.search(text): if PROJECT_LIST_QUERY_RE.search(text):
@ -3504,7 +3708,7 @@ class FeishuSignalPipelineService:
return any(token in normalized for token in ("有吗", "有没有", "有无", "是否有", "是否存在", "存在吗", "是什么", "哪些")) return any(token in normalized for token in ("有吗", "有没有", "有无", "是否有", "是否存在", "存在吗", "是什么", "哪些"))
@classmethod @classmethod
def _is_openclaw_business_command(cls, message: str) -> bool: def _is_legacy_business_command(cls, message: str) -> bool:
text = str(message or "").strip() text = str(message or "").strip()
if not text: if not text:
return False return False
@ -3512,9 +3716,9 @@ class FeishuSignalPipelineService:
return False return False
if cls._looks_like_project_create_command(text): if cls._looks_like_project_create_command(text):
return True return True
return bool(OPENCLAW_COMMAND_VERB_RE.search(text) and OPENCLAW_COMMAND_OBJECT_RE.search(text)) return bool(LEGACY_COMMAND_VERB_RE.search(text) and LEGACY_COMMAND_OBJECT_RE.search(text))
async def _maybe_execute_openclaw_command( async def _maybe_block_legacy_command_request(
self, self,
*, *,
message: str, message: str,
@ -3527,119 +3731,17 @@ class FeishuSignalPipelineService:
projects: list[dict[str, Any]], projects: list[dict[str, Any]],
process_started_at: float, process_started_at: float,
) -> Optional[dict[str, Any]]: ) -> Optional[dict[str, Any]]:
if not self._is_openclaw_business_command(message): if not self._is_legacy_business_command(message):
return None return None
operator_user = await self._resolve_command_operator_user(sender=sender) _ = sender, source_chat_id, source_sender_open_id, captured_at, inferred_project_id, projects
if operator_user is None: return await self._build_legacy_command_blocked_capture(
return await self._build_openclaw_blocked_capture(
message=message, message=message,
feishu_message_id=feishu_message_id, feishu_message_id=feishu_message_id,
duration_ms=int((time.perf_counter() - process_started_at) * 1000), duration_ms=int((time.perf_counter() - process_started_at) * 1000),
reason="当前租户下没有可用操作者,系统未执行写操作。", reason="旧 command-first 规划链路已下线,请改用 GLM 分析后直接调用显式接口。",
) )
target_project_id = inferred_project_id or self._guess_matched_project_id_from_text(message, projects)
commander = OpenClawCommanderService(self.db)
if self._looks_like_project_create_command(message):
command_result = await commander.dispatch_global_command(
tenant_id=self.tenant_id,
operator_user=operator_user,
instruction=message,
dry_run=False,
max_operations=4,
response_mode="fast",
persist_run=False,
)
elif target_project_id is not None:
command_result = await commander.dispatch_command(
tenant_id=self.tenant_id,
operator_user=operator_user,
project_id=int(target_project_id),
instruction=message,
dry_run=False,
max_operations=6,
response_mode="fast",
persist_run=False,
)
else:
return await self._build_openclaw_blocked_capture(
message=message,
feishu_message_id=feishu_message_id,
duration_ms=int((time.perf_counter() - process_started_at) * 1000),
reason="缺少明确项目上下文。请指明项目名,或在对应项目群内发送该指令。",
)
execution = command_result.get("execution") if isinstance(command_result, dict) else {}
requested_count = int((execution or {}).get("requested_count") or 0)
if requested_count <= 0 and int(command_result.get("planned_operations") or 0) <= 0:
return await self._build_openclaw_blocked_capture(
message=message,
feishu_message_id=feishu_message_id,
duration_ms=int((time.perf_counter() - process_started_at) * 1000),
reason="未识别到可安全执行的业务操作,系统未写库。",
)
linked_project_ids = self._collect_command_project_ids(command_result)
created_project_id = self._extract_created_project_id(command_result)
primary_project_id = created_project_id or (linked_project_ids[0] if linked_project_ids else None)
route_status = self._resolve_command_route_status(command_result)
item_id = await self._record_command_context_item(
message=message,
sender=sender,
source_chat_id=source_chat_id,
source_sender_open_id=source_sender_open_id,
feishu_message_id=feishu_message_id,
captured_at=captured_at,
matched_project_id=primary_project_id,
route_status=route_status,
command_result=command_result,
)
reply_text = self._build_openclaw_command_reply(command_result)
duration_ms = int((time.perf_counter() - process_started_at) * 1000)
await self._write_processing_log(
feishu_message_id=feishu_message_id,
feishu_item_id=item_id,
raw_message=message,
ai_raw_output=json.dumps({"command_result": command_result}, ensure_ascii=False)[:120000],
project_score=1.0 if primary_project_id is not None else None,
signal_confidence=0.0,
level="command",
routing_decision=ROUTING_OPENCLAW_EXECUTED,
routing_reason="识别为明确业务命令,已直接进入 OpenClaw command 层并按真实执行结果回执。",
reasoning_bullets=[
"命中 command-first 路由,跳过 RAG 问答与信号分流。",
f"planner_status={str(command_result.get('planner_status') or '') or 'unknown'}",
f"openclaw_status={str(command_result.get('openclaw_status') or '') or 'unknown'}",
],
processing_status="success",
error_message=None,
duration_ms=duration_ms,
)
return {
"status": "captured",
"classification": "command",
"project_scope": "command",
"linked_project_ids": linked_project_ids,
"signal_id": None,
"created_project_id": created_project_id or primary_project_id,
"auto_tasks": [],
"ai_trace_id": str(command_result.get("trace_id") or ""),
"results": [
{
"item_id": item_id,
"level": "command",
"project_id": primary_project_id,
"signal_id": None,
"route_status": route_status,
}
],
"intent": "command",
"direct_reply_required": True,
"direct_reply_text": reply_text,
"command_result": command_result,
}
async def _maybe_answer_project_lookup_question( async def _maybe_answer_project_lookup_question(
self, self,
*, *,
@ -3714,7 +3816,7 @@ class FeishuSignalPipelineService:
"direct_reply_text": reply_text, "direct_reply_text": reply_text,
} }
async def _build_openclaw_blocked_capture( async def _build_legacy_command_blocked_capture(
self, self,
*, *,
message: str, message: str,
@ -3730,7 +3832,7 @@ class FeishuSignalPipelineService:
project_score=None, project_score=None,
signal_confidence=None, signal_confidence=None,
level="command", level="command",
routing_decision=ROUTING_OPENCLAW_BLOCKED, routing_decision=ROUTING_LEGACY_COMMAND_BLOCKED,
routing_reason=reason, routing_reason=reason,
reasoning_bullets=[ reasoning_bullets=[
"命中 command-first 路由,但当前缺少安全执行所需的上下文。", "命中 command-first 路由,但当前缺少安全执行所需的上下文。",
@ -3823,7 +3925,7 @@ class FeishuSignalPipelineService:
split_from_item_id=None, split_from_item_id=None,
level="command", level="command",
level_confidence=1.0, level_confidence=1.0,
level_reasoning="OpenClaw command-first execution", level_reasoning="legacy_command_first_execution",
card_type="command", card_type="command",
route_status=self._normalize_route_status(route_status or ROUTE_STATUS_AUTO_DONE)[:30], route_status=self._normalize_route_status(route_status or ROUTE_STATUS_AUTO_DONE)[:30],
matched_project_id=int(matched_project_id) if matched_project_id is not None else None, matched_project_id=int(matched_project_id) if matched_project_id is not None else None,
@ -3837,9 +3939,9 @@ class FeishuSignalPipelineService:
auto_execute_state="done", auto_execute_state="done",
result_payload={ result_payload={
"intent": "command", "intent": "command",
"executor": "openclaw_commander", "executor": "legacy_command_planner",
"operation_code": None, "operation_code": None,
"result_summary": self._build_openclaw_command_reply(command_result), "result_summary": self._build_operation_commit_reply(command_result),
"command_result": command_result, "command_result": command_result,
"project_id": int(matched_project_id) if matched_project_id is not None else None, "project_id": int(matched_project_id) if matched_project_id is not None else None,
"target_url": f"/projects/{int(matched_project_id)}/board" if matched_project_id is not None else None, "target_url": f"/projects/{int(matched_project_id)}/board" if matched_project_id is not None else None,
@ -3853,7 +3955,12 @@ class FeishuSignalPipelineService:
@staticmethod @staticmethod
def _resolve_command_route_status(command_result: dict[str, Any]) -> str: def _resolve_command_route_status(command_result: dict[str, Any]) -> str:
status = str(command_result.get("openclaw_status") or "").strip().lower() status = str(
command_result.get("command_status")
or command_result.get("run_status")
or command_result.get("execution_status")
or ""
).strip().lower()
if status in {"completed", "partial", "planned"}: if status in {"completed", "partial", "planned"}:
return ROUTE_STATUS_AUTO_DONE return ROUTE_STATUS_AUTO_DONE
return ROUTE_STATUS_FAILED return ROUTE_STATUS_FAILED
@ -3907,13 +4014,13 @@ class FeishuSignalPipelineService:
return None return None
@classmethod @classmethod
def _build_openclaw_command_reply(cls, command_result: dict[str, Any]) -> str: def _build_operation_commit_reply(cls, command_result: dict[str, Any]) -> str:
execution = command_result.get("execution") if isinstance(command_result, dict) else {} execution = command_result.get("execution") if isinstance(command_result, dict) else {}
results = execution.get("results") if isinstance(execution, dict) else [] results = execution.get("results") if isinstance(execution, dict) else []
if not isinstance(results, list) or not results: if not isinstance(results, list) or not results:
return "未执行:未识别到可安全落库的业务操作。" return "未执行:未识别到可安全落库的业务操作。"
fragments = [cls._format_openclaw_result_fragment(row) for row in results[:3] if isinstance(row, dict)] fragments = [cls._format_operation_result_fragment(row) for row in results[:3] if isinstance(row, dict)]
fragments = [item for item in fragments if item] fragments = [item for item in fragments if item]
applied_count = int((execution or {}).get("applied_count") or 0) applied_count = int((execution or {}).get("applied_count") or 0)
skipped_count = int((execution or {}).get("skipped_count") or 0) skipped_count = int((execution or {}).get("skipped_count") or 0)
@ -3933,7 +4040,7 @@ class FeishuSignalPipelineService:
return reply + "。" return reply + "。"
@staticmethod @staticmethod
def _format_openclaw_result_fragment(row: dict[str, Any]) -> str: def _format_operation_result_fragment(row: dict[str, Any]) -> str:
operation_code = str(row.get("operation_code") or "").strip() operation_code = str(row.get("operation_code") or "").strip()
status = str(row.get("status") or "").strip().lower() status = str(row.get("status") or "").strip().lower()
message = str(row.get("message") or "").strip() message = str(row.get("message") or "").strip()
@ -4243,7 +4350,7 @@ class FeishuSignalPipelineService:
item.matched_project_id = int(project_id) if project_id is not None else item.matched_project_id item.matched_project_id = int(project_id) if project_id is not None else item.matched_project_id
item.result_payload = { item.result_payload = {
"intent": INTENT_CREATE_PROJECT, "intent": INTENT_CREATE_PROJECT,
"executor": "operation_executor", "executor": "explicit_service",
"operation_code": "project.create", "operation_code": "project.create",
"result_summary": f"已创建项目 #{project_id}" if project_id else "已创建项目", "result_summary": f"已创建项目 #{project_id}" if project_id else "已创建项目",
"operation_result": project_result, "operation_result": project_result,
@ -4275,7 +4382,7 @@ class FeishuSignalPipelineService:
item.matched_project_id = int(project_id) if project_id is not None else item.matched_project_id item.matched_project_id = int(project_id) if project_id is not None else item.matched_project_id
item.result_payload = { item.result_payload = {
**payload, **payload,
"executor": "operation_executor", "executor": "explicit_service",
"result_summary": f"已创建任务 #{task_id}" if task_id is not None else "已创建任务", "result_summary": f"已创建任务 #{task_id}" if task_id is not None else "已创建任务",
"operation_result": first.get("result") if isinstance(first.get("result"), dict) else {}, "operation_result": first.get("result") if isinstance(first.get("result"), dict) else {},
"target_url": f"/projects/{project_id}/board" if project_id else None, "target_url": f"/projects/{project_id}/board" if project_id else None,
@ -5896,7 +6003,7 @@ class FeishuSignalPipelineService:
) -> None: ) -> None:
normalized_route = (route or routing_decision or "")[:80] or None normalized_route = (route or routing_decision or "")[:80] or None
normalized_intent = (intent or level or "")[:40] or None normalized_intent = (intent or level or "")[:40] or None
normalized_executor = (executor or ("operation_executor" if operation_code else "router"))[:40] or None normalized_executor = (executor or ("explicit_service" if operation_code else "router"))[:40] or None
normalized_reply_source = ( normalized_reply_source = (
reply_source reply_source
or ("db_commit" if operation_code else "deterministic_reply") or ("db_commit" if operation_code else "deterministic_reply")

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,435 @@
"""Model context bundle service.
统一提供三类上下文:
1. 知识图谱摘要(KG overview)
2. 任务模板(task briefs)
3. 契约同步状态(contract sync)
"""
from __future__ import annotations
import json
from datetime import datetime, timezone
from typing import Any, Optional
from uuid import uuid4
from sqlalchemy import and_, select
from sqlalchemy.ext.asyncio import AsyncSession
from ..models import AICallLog
from .model_routing_service import ModelRoutingService
from .project_knowledge_graph import ProjectKnowledgeGraphService
from .task_brief_service import (
HIGH_CONTRACT_TEMPLATE_ID,
HIGH_TEMPLATE_ID,
LOW_TEMPLATE_ID,
TaskBriefService,
)
CONTEXT_BUNDLE_MARKER = "PMFLOW_CONTEXT_BUNDLE_V1"
CONTEXT_BUNDLE_KEYS = ("knowledge_graph", "task_template", "contract_sync")
TARGET_FUNCTION_CONTEXT: dict[str, dict[str, Any]] = {
"classify_intent_ai_pmo_kernel": {
"tier": "low",
"template_ids": [LOW_TEMPLATE_ID],
},
"signal_planner_analyze": {
"tier": "low",
"template_ids": [LOW_TEMPLATE_ID],
},
"classify_intent_complex": {
"tier": "high",
"template_ids": [HIGH_TEMPLATE_ID, HIGH_CONTRACT_TEMPLATE_ID],
},
}
class ModelContextBundleService:
@staticmethod
def _normalize_function_name(function_name: str) -> str:
return str(function_name or "").strip().lower()
@classmethod
def target_functions(cls) -> list[str]:
return list(TARGET_FUNCTION_CONTEXT.keys())
@classmethod
def should_inject_context(cls, function_name: str) -> bool:
fn = cls._normalize_function_name(function_name)
return fn in TARGET_FUNCTION_CONTEXT
@staticmethod
def _template_snapshot(template: Optional[dict[str, Any]], *, include_content: bool = True) -> Optional[dict[str, Any]]:
if not isinstance(template, dict):
return None
content = str(template.get("content") or "")
payload: dict[str, Any] = {
"id": str(template.get("id") or ""),
"name": str(template.get("name") or ""),
"description": str(template.get("description") or ""),
"updated_at": str(template.get("updated_at") or ""),
}
if include_content:
payload["content"] = content[:2400]
return payload
@staticmethod
def _kg_snapshot(overview: dict[str, Any]) -> dict[str, Any]:
latest = overview.get("latest_sync_run") if isinstance(overview.get("latest_sync_run"), dict) else {}
return {
"entity_total": int(overview.get("entity_total") or 0),
"relation_total": int(overview.get("relation_total") or 0),
"evidence_total": int(overview.get("evidence_total") or 0),
"pending_events": int(overview.get("pending_events") or 0),
"latest_sync_run": {
"id": latest.get("id"),
"run_type": str(latest.get("run_type") or ""),
"status": str(latest.get("status") or ""),
"started_at": latest.get("started_at"),
"ended_at": latest.get("ended_at"),
}
if latest
else None,
}
@staticmethod
def _contract_snapshot(contract_sync: dict[str, Any]) -> dict[str, Any]:
files = contract_sync.get("files") if isinstance(contract_sync.get("files"), list) else []
normalized_files: list[dict[str, Any]] = []
missing_files: list[str] = []
for item in files:
if not isinstance(item, dict):
continue
exists = bool(item.get("exists"))
file_path = str(item.get("file") or "")
if file_path and not exists:
missing_files.append(file_path)
normalized_files.append(
{
"file": file_path,
"exists": exists,
"updated_at": item.get("updated_at"),
}
)
return {
"all_synced": bool(contract_sync.get("all_synced")),
"missing_files": missing_files,
"files": normalized_files,
}
@classmethod
async def build_context_bundle(
cls,
db: AsyncSession,
*,
tenant_id: int,
function_name: str,
) -> Optional[dict[str, Any]]:
fn = cls._normalize_function_name(function_name)
target = TARGET_FUNCTION_CONTEXT.get(fn)
if target is None:
return None
kg_service = ProjectKnowledgeGraphService(db, tenant_id=int(tenant_id))
overview = await kg_service.get_overview()
templates = await TaskBriefService.list_templates(db, tenant_id=int(tenant_id))
template_map = {
str(item.get("id") or ""): item
for item in templates
if isinstance(item, dict) and str(item.get("id") or "").strip()
}
configured_template_ids = [str(x or "").strip() for x in target.get("template_ids", []) if str(x or "").strip()]
primary_template_id = configured_template_ids[0] if configured_template_ids else ""
additional_ids = configured_template_ids[1:] if len(configured_template_ids) > 1 else []
missing_template_ids = [tid for tid in configured_template_ids if tid not in template_map]
contract_sync = await TaskBriefService.get_contract_sync_status(db, tenant_id=int(tenant_id))
bundle = {
"marker": CONTEXT_BUNDLE_MARKER,
"function_name": fn,
"tier": str(target.get("tier") or ""),
"knowledge_graph": cls._kg_snapshot(overview),
"task_template": {
"primary": cls._template_snapshot(template_map.get(primary_template_id), include_content=True),
"additional": [
snap
for snap in (
cls._template_snapshot(template_map.get(template_id), include_content=False)
for template_id in additional_ids
)
if snap is not None
],
"template_ids": configured_template_ids,
"missing_template_ids": missing_template_ids,
},
"contract_sync": cls._contract_snapshot(contract_sync),
}
return bundle
@classmethod
def render_bundle_marker_text(cls, bundle: dict[str, Any]) -> str:
payload = dict(bundle) if isinstance(bundle, dict) else {}
marker = f"[{CONTEXT_BUNDLE_MARKER}]"
return f"{marker}\n{json.dumps(payload, ensure_ascii=False, sort_keys=True)}"
@classmethod
async def inject_prompt_with_bundle(
cls,
db: AsyncSession,
*,
tenant_id: int,
function_name: str,
prompt: str,
) -> str:
raw_prompt = str(prompt or "")
if not cls.should_inject_context(function_name):
return raw_prompt
marker = f"[{CONTEXT_BUNDLE_MARKER}]"
if marker in raw_prompt:
return raw_prompt
bundle = await cls.build_context_bundle(
db,
tenant_id=int(tenant_id),
function_name=function_name,
)
if bundle is None:
return raw_prompt
bundle_text = cls.render_bundle_marker_text(bundle)
return f"{bundle_text}\n\n{raw_prompt}".strip()
@classmethod
async def build_capability_summary(cls, db: AsyncSession, *, tenant_id: int) -> dict[str, Any]:
kg_service = ProjectKnowledgeGraphService(db, tenant_id=int(tenant_id))
overview = await kg_service.get_overview()
templates = await TaskBriefService.list_templates(db, tenant_id=int(tenant_id))
template_map = {
str(item.get("id") or ""): item
for item in templates
if isinstance(item, dict) and str(item.get("id") or "").strip()
}
required_template_ids = sorted(
{
template_id
for spec in TARGET_FUNCTION_CONTEXT.values()
for template_id in spec.get("template_ids", [])
if str(template_id or "").strip()
}
)
present_template_ids = sorted(template_map.keys())
missing_template_ids = [template_id for template_id in required_template_ids if template_id not in template_map]
contract_sync_raw = await TaskBriefService.get_contract_sync_status(db, tenant_id=int(tenant_id))
contract_sync = cls._contract_snapshot(contract_sync_raw)
function_matrix: list[dict[str, Any]] = []
for function_name in cls.target_functions():
spec = TARGET_FUNCTION_CONTEXT[function_name]
routing = await ModelRoutingService.get_effective_routing(
db,
tenant_id=int(tenant_id),
function_name=function_name,
)
function_matrix.append(
{
"function_name": function_name,
"tier": str(spec.get("tier") or ""),
"context_sections": list(CONTEXT_BUNDLE_KEYS),
"template_ids": [str(tid) for tid in spec.get("template_ids", []) if str(tid or "").strip()],
"model_routing": {
"provider": str(routing.get("provider") or ""),
"model": str(routing.get("model") or ""),
"tier": str(routing.get("tier") or ""),
"source": str(routing.get("source") or ""),
"api_env": str(routing.get("api_env") or ""),
},
}
)
required_templates = [
cls._template_snapshot(template_map.get(template_id), include_content=False) or {"id": template_id}
for template_id in required_template_ids
]
return {
"marker": CONTEXT_BUNDLE_MARKER,
"knowledge_graph": cls._kg_snapshot(overview),
"task_templates": {
"total": len(templates),
"required_template_ids": required_template_ids,
"present_template_ids": present_template_ids,
"missing_template_ids": missing_template_ids,
"required_templates": required_templates,
},
"contract_sync": contract_sync,
"function_model_template_matrix": function_matrix,
"coverage": {
"template_ready": len(missing_template_ids) == 0,
"contract_ready": bool(contract_sync.get("all_synced")),
"target_function_count": len(function_matrix),
},
"generated_at": datetime.now(timezone.utc).isoformat(),
}
@classmethod
def _canary_prompt_for_function(cls, *, function_name: str, probe_id: str) -> str:
fn = cls._normalize_function_name(function_name)
if fn in {"classify_intent_ai_pmo_kernel", "classify_intent_complex"}:
return (
f"这是 PMFlow 上下文注入探针,probe_id={probe_id}。"
"请只返回 JSON:"
'{"intent":"query.read_only","confidence":0.91,"reasoning":"probe_context_ok"}'
)
if fn == "signal_planner_analyze":
return (
f"这是 PMFlow 上下文注入探针,probe_id={probe_id}。"
"请只返回 JSON:"
'{"summary":"probe","priority":"medium","execution_plan":[],"risks":[],"confidence":0.77}'
)
return f"probe_id={probe_id}"
@classmethod
async def _trigger_canary_call(
cls,
db: AsyncSession,
*,
tenant_id: int,
function_name: str,
probe_id: str,
) -> dict[str, Any]:
prompt = cls._canary_prompt_for_function(function_name=function_name, probe_id=probe_id)
try:
from .llm import llm_service # 延迟导入,避免循环依赖
result = await llm_service.generate_structured_object(
prompt,
db=db,
tenant_id=int(tenant_id),
function_name=function_name,
use_prompt_profile=False,
temperature=0.0,
max_tokens=220,
response_mode="fast",
)
return {
"called": True,
"error": None,
"error_code": str(result.error_code or ""),
"status": str(result.error_code or "success"),
"provider": str(result.provider or ""),
"model": str(result.model or ""),
}
except Exception as exc:
return {
"called": False,
"error": str(exc)[:500],
"error_code": "canary_call_failed",
"status": "failed",
"provider": "",
"model": "",
}
@classmethod
async def _find_prompt_evidence(
cls,
db: AsyncSession,
*,
tenant_id: int,
function_name: str,
probe_id: Optional[str],
) -> Optional[AICallLog]:
stmt = select(AICallLog).where(
and_(
AICallLog.tenant_id == int(tenant_id),
AICallLog.function_name == str(function_name),
AICallLog.prompt.like(f"%[{CONTEXT_BUNDLE_MARKER}]%"),
)
)
if probe_id:
stmt = stmt.where(AICallLog.prompt.like(f"%{probe_id}%"))
stmt = stmt.order_by(AICallLog.id.desc()).limit(1)
return (await db.execute(stmt)).scalar_one_or_none()
@classmethod
async def verify_context_consumption(
cls,
db: AsyncSession,
*,
tenant_id: int,
run_canary: bool = True,
) -> dict[str, Any]:
probe_id = uuid4().hex[:12] if run_canary else ""
function_results: list[dict[str, Any]] = []
for function_name in cls.target_functions():
target = TARGET_FUNCTION_CONTEXT.get(function_name) or {}
canary = {"called": False, "status": "skipped", "error": None, "error_code": "", "provider": "", "model": ""}
if run_canary:
canary = await cls._trigger_canary_call(
db,
tenant_id=int(tenant_id),
function_name=function_name,
probe_id=probe_id,
)
evidence = await cls._find_prompt_evidence(
db,
tenant_id=int(tenant_id),
function_name=function_name,
probe_id=probe_id if run_canary else None,
)
prompt_text = str(evidence.prompt or "") if evidence is not None else ""
checks = {
"marker": f"[{CONTEXT_BUNDLE_MARKER}]" in prompt_text,
"knowledge_graph": '"knowledge_graph"' in prompt_text,
"task_template": '"task_template"' in prompt_text,
"contract_sync": '"contract_sync"' in prompt_text,
}
route = await ModelRoutingService.get_effective_routing(
db,
tenant_id=int(tenant_id),
function_name=function_name,
)
function_results.append(
{
"function_name": function_name,
"tier": str(target.get("tier") or ""),
"template_ids": [str(x) for x in target.get("template_ids", []) if str(x or "").strip()],
"routing_snapshot": {
"provider": str(route.get("provider") or ""),
"model": str(route.get("model") or ""),
"tier": str(route.get("tier") or ""),
"source": str(route.get("source") or ""),
},
"canary": canary,
"checks": checks,
"pass": all(checks.values()),
"log_evidence": (
{
"id": int(evidence.id),
"status": str(evidence.status or ""),
"created_at": evidence.created_at.isoformat() if evidence.created_at else None,
"prompt_excerpt": prompt_text[:800],
}
if evidence is not None
else None
),
}
)
tier_result: dict[str, dict[str, Any]] = {"low": {"pass": False, "functions": []}, "high": {"pass": False, "functions": []}}
for tier_name in ("low", "high"):
tier_functions = [row for row in function_results if str(row.get("tier") or "") == tier_name]
tier_result[tier_name] = {
"pass": bool(tier_functions) and all(bool(row.get("pass")) for row in tier_functions),
"functions": tier_functions,
}
overall_pass = bool(tier_result["low"]["pass"]) and bool(tier_result["high"]["pass"])
return {
"marker": CONTEXT_BUNDLE_MARKER,
"run_canary": bool(run_canary),
"probe_id": probe_id or None,
"overall_pass": overall_pass,
"tiers": tier_result,
"functions": function_results,
}

View File

@ -3,8 +3,6 @@
from __future__ import annotations from __future__ import annotations
import json import json
import os
from pathlib import Path
from typing import Any from typing import Any
from sqlalchemy import and_, select from sqlalchemy import and_, select
@ -17,7 +15,6 @@ ALLOWED_PROVIDERS = {"openai", "glm", "deepseek", "ollama"}
ALLOWED_TIERS = {"low", "high", "safety"} ALLOWED_TIERS = {"low", "high", "safety"}
DEFAULT_FUNCTIONS = ("signal_planner_analyze",) DEFAULT_FUNCTIONS = ("signal_planner_analyze",)
INTEGRATION_TYPE = "model_routing" INTEGRATION_TYPE = "model_routing"
DEFAULT_RULES_RELATIVE_PATH = "../.openclaw/pmflow-routing-rules.json"
class ModelRoutingService: class ModelRoutingService:
@ -58,15 +55,6 @@ class ModelRoutingService:
raise ValueError("tier 仅支持 low/high/safety") raise ValueError("tier 仅支持 low/high/safety")
return t return t
@staticmethod
def _rules_path() -> Path:
custom = str(os.getenv("OPENCLAW_ROUTING_RULES_PATH", DEFAULT_RULES_RELATIVE_PATH) or "").strip()
raw = custom or DEFAULT_RULES_RELATIVE_PATH
path = Path(raw)
if not path.is_absolute():
path = (Path(__file__).resolve().parents[2] / path).resolve()
return path
@classmethod @classmethod
def _rule_matches_function(cls, *, rule: dict[str, Any], function_name: str) -> bool: def _rule_matches_function(cls, *, rule: dict[str, Any], function_name: str) -> bool:
if bool(rule.get("match_all", False)): if bool(rule.get("match_all", False)):
@ -89,27 +77,8 @@ class ModelRoutingService:
@classmethod @classmethod
def _load_default_rule(cls, *, function_name: str) -> dict[str, Any] | None: def _load_default_rule(cls, *, function_name: str) -> dict[str, Any] | None:
path = cls._rules_path() _ = function_name
if not path.exists():
return None return None
try:
payload = json.loads(path.read_text(encoding="utf-8"))
except Exception:
return None
rules = payload.get("rules") if isinstance(payload, dict) and isinstance(payload.get("rules"), list) else []
indexed: list[tuple[int, int, dict[str, Any]]] = []
for idx, row in enumerate(rules):
if not isinstance(row, dict):
continue
if not bool(row.get("enabled", True)):
continue
if not cls._rule_matches_function(rule=row, function_name=function_name):
continue
indexed.append((int(row.get("priority") or 0), -idx, row))
if not indexed:
return None
indexed.sort(reverse=True)
return dict(indexed[0][2])
@classmethod @classmethod
async def _get_or_init_bundle(cls, db: AsyncSession, *, tenant_id: int) -> tuple[Integration, dict[str, Any]]: async def _get_or_init_bundle(cls, db: AsyncSession, *, tenant_id: int) -> tuple[Integration, dict[str, Any]]:
@ -156,9 +125,9 @@ class ModelRoutingService:
if rule is None: if rule is None:
return { return {
"function_name": cls._normalize_function_name(function_name), "function_name": cls._normalize_function_name(function_name),
"provider": "openai", "provider": "glm",
"model": "GLM-5", "model": "GLM-4.7",
"api_env": "OPENAI_API_KEY", "api_env": "GLM_API_KEY",
"tier": "high", "tier": "high",
"base_url": "", "base_url": "",
"source": "default", "source": "default",

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,92 @@
"""项目命令解析辅助函数。"""
from __future__ import annotations
import re
from ..utils.title import compact_title
def normalize_project_title_candidate(candidate: str) -> str:
cleaned = re.sub(r"\s+", " ", str(candidate or "")).strip(" []【】()()::\"'“”")
if not cleaned:
return ""
cleaned = re.sub(
r"^(?:名称|名字|项目名|需求名)(?:叫|是|为|名为|名称为|名字为|名称是|名字是|名称叫|名字叫)\s*",
"",
cleaned,
flags=re.IGNORECASE,
).strip(" []【】()()::\"'“”")
if not cleaned:
return ""
cleaned = re.sub(
r"^(?:(?:项目|需求)(?:名字|名称|名)?(?:叫|是|为|名为|名称为|名字为|名称叫|名字叫)?|(?:项目|需求)?(?:名为|名称为|名字为|名称是|名字是|名称叫|名字叫|叫|为))\s*",
"",
cleaned,
flags=re.IGNORECASE,
).strip(" []【】()()::\"'“”")
if not cleaned:
return ""
normalized = re.sub(r"\s+", "", cleaned).lower()
generic_tokens = {
"项目",
"一个项目",
"新项目",
"项目列表",
"项目列表里面",
"项目库",
"项目台账",
"项目主数据",
"pmflow",
"pmflow项目列表",
}
if normalized in generic_tokens:
return ""
if normalized.startswith(("在项目列表", "到项目列表", "进项目列表", "在pmflow", "到pmflow", "进pmflow")):
return ""
return compact_title(cleaned, max_len=24)
def extract_project_create_title(text: str) -> str:
cleaned = re.sub(r"\s+", " ", str(text or "")).strip()
if not cleaned:
return ""
quoted_match = re.search(r"(?:项目|新项目)[^\"“”']{0,10}[\"“”']([^\"“”']{2,40})[\"“”']", cleaned)
if quoted_match:
return normalize_project_title_candidate(quoted_match.group(1))
for pattern in (
r"(?:项目|需求)(?:名字|名称|名)?(?:叫|名为|名称是|名称为|是|为)[::,,\s]*(.+)",
r"(?:帮我|请|麻烦|给我)?(?:新增|增加|添加|创建|新建|建立|开个|开一个|建个|建一个|做个|做一个|加个|加一个)(?:一个|个)?项目(?:叫|名为|名称是|名称为|是|为)?[::,,\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)
candidate = re.sub(
r"^(?:(?:项目|需求)(?:名字|名称|名)?(?:叫|是|为|名为|名称为|名字为|名称叫|名字叫)?|(?:项目|需求)?(?:名为|名称为|名字为|名称是|名字是|名称叫|名字叫|叫|为))\s*",
"",
candidate,
flags=re.IGNORECASE,
)
if candidate:
return normalize_project_title_candidate(candidate)
return ""
def extract_project_create_description(*, instruction: str, title: str) -> str:
cleaned = re.sub(r"\s+", " ", str(instruction or "")).strip()
if not cleaned:
return ""
if title:
cleaned = cleaned.replace(title, "", 1).strip(" ,,::")
cleaned = re.sub(
r"^(?:帮我|请|麻烦|给我)?(?:新增|增加|添加|创建|新建|建立|开个|开一个|建个|建一个|做个|做一个|加个|加一个)(?:一个|个)?项目(?:叫|名为|名称是|名称为|是|为)?",
"",
cleaned,
flags=re.IGNORECASE,
).strip(" ,,::")
if not cleaned:
return ""
return cleaned[:4000]

View File

@ -10,6 +10,8 @@
from __future__ import annotations from __future__ import annotations
import hashlib import hashlib
import json
import os
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
from pathlib import Path from pathlib import Path
from typing import Any, Optional from typing import Any, Optional
@ -41,6 +43,20 @@ def _utc_now_naive() -> datetime:
return now_cn_naive() return now_cn_naive()
DDL_BLOCKED_PREFIXES = [
"CREATE",
"ALTER",
"DROP",
"TRUNCATE",
"RENAME",
"COMMENT",
"GRANT",
"REVOKE",
]
DDL_GUARDRAIL_POLICY_KEY = "policy:ddl_via_interface_only"
DDL_GUARDRAIL_POLICY_NAME = "DDL 只能通过受控接口/迁移流程"
class ProjectKnowledgeGraphService: class ProjectKnowledgeGraphService:
def __init__(self, db: AsyncSession, *, tenant_id: int) -> None: def __init__(self, db: AsyncSession, *, tenant_id: int) -> None:
self.db = db self.db = db
@ -1093,6 +1109,284 @@ class ProjectKnowledgeGraphService:
counters["evidences"] += 1 counters["evidences"] += 1
return counters return counters
@staticmethod
def _routing_rule_candidate_paths() -> list[Path]:
candidates: list[Path] = []
configured = str(os.getenv("OPENCLAW_ROUTING_RULES_PATH", "") or "").strip()
if configured:
candidates.append(Path(configured).expanduser())
backend_root = Path(__file__).resolve().parents[2]
project_root = Path(__file__).resolve().parents[3]
candidates.extend(
[
project_root / ".openclaw" / "pmflow-routing-rules.json",
backend_root / ".openclaw" / "pmflow-routing-rules.json",
]
)
deduped: list[Path] = []
seen: set[str] = set()
for path in candidates:
key = str(path)
if key in seen:
continue
seen.add(key)
deduped.append(path)
return deduped
@classmethod
def _resolve_guardrail_target_models(cls) -> dict[str, dict[str, str]]:
specs: dict[str, dict[str, str]] = {
"low": {
"tier": "low",
"provider": str(os.getenv("LLM_ROUTING_LOW_PROVIDER", "glm") or "glm").strip(),
"model": str(os.getenv("LLM_ROUTING_LOW_MODEL", "GLM-4.7") or "GLM-4.7").strip(),
"api_env": str(os.getenv("LLM_ROUTING_LOW_API_ENV", "GLM_API_KEY") or "GLM_API_KEY").strip(),
"source": "env_default",
"source_path": "",
"rule_name": "",
},
"high": {
"tier": "high",
"provider": str(os.getenv("LLM_ROUTING_HIGH_PROVIDER", "openai") or "openai").strip(),
"model": str(os.getenv("LLM_ROUTING_HIGH_MODEL", "MiniMax-M2.5") or "MiniMax-M2.5").strip(),
"api_env": str(os.getenv("LLM_ROUTING_HIGH_API_ENV", "OPENAI_API_KEY") or "OPENAI_API_KEY").strip(),
"source": "env_default",
"source_path": "",
"rule_name": "",
},
}
for path in cls._routing_rule_candidate_paths():
try:
content = path.read_text(encoding="utf-8")
payload = json.loads(content)
except Exception:
continue
rows = payload.get("rules") if isinstance(payload, dict) and isinstance(payload.get("rules"), list) else []
if not rows:
continue
enabled_rows = [row for row in rows if isinstance(row, dict) and bool(row.get("enabled", True))]
enabled_rows.sort(key=lambda item: int(item.get("priority") or 0), reverse=True)
resolved_any = False
for tier in ("low", "high"):
for row in enabled_rows:
if str(row.get("tier") or "").strip().lower() != tier:
continue
model = str(row.get("model") or "").strip()
if not model:
continue
specs[tier] = {
"tier": tier,
"provider": str(row.get("provider") or specs[tier]["provider"]).strip(),
"model": model,
"api_env": str(row.get("api_env") or specs[tier]["api_env"]).strip(),
"source": "routing_rule",
"source_path": str(path),
"rule_name": str(row.get("name") or "").strip(),
}
resolved_any = True
break
if resolved_any and all(str(specs[tier].get("source") or "") == "routing_rule" for tier in ("low", "high")):
break
return specs
async def _sync_model_guardrail_entities(self) -> dict[str, Any]:
counters = {"models": 0, "policies": 0, "interfaces": 0, "relations": 0, "evidences": 0}
model_specs = self._resolve_guardrail_target_models()
policy_entity = await self.upsert_entity(
entity_type="governance_policy",
entity_key=DDL_GUARDRAIL_POLICY_KEY,
display_name=DDL_GUARDRAIL_POLICY_NAME,
payload={
"policy_code": "ddl_via_interface_only",
"enforcement_level": "hard_block",
"blocked_sql_prefixes": DDL_BLOCKED_PREFIXES,
"required_execution_path": "api_or_migration_interface",
"enforced_by": [
"backend/app/database.py",
"backend/app/services/llm.py",
"backend/app/config/ai_prompts.py",
],
},
source_type="governance",
source_ref="backend/app/database.py",
)
counters["policies"] += 1
await self.add_evidence(
evidence_type="policy_guardrail",
content_excerpt="Runtime DDL is blocked; model planning must route DDL via interface/migration only.",
entity_id=int(policy_entity.id),
source_type="governance",
source_ref="backend/app/database.py",
metadata={"blocked_sql_prefixes": DDL_BLOCKED_PREFIXES},
)
counters["evidences"] += 1
interface_specs = [
{
"entity_key": "interface:api:/ai-steward/command/dispatch",
"display_name": "POST /ai-steward/command/dispatch",
"payload": {"path": "/ai-steward/command/dispatch", "kind": "api"},
"source_ref": "backend/app/api/ai_steward.py",
},
{
"entity_key": "interface:migration:alembic",
"display_name": "Alembic Migration Interface",
"payload": {"path": "alembic", "kind": "migration"},
"source_ref": "backend/alembic/versions",
},
]
interface_ids: list[int] = []
for item in interface_specs:
interface_entity = await self.upsert_entity(
entity_type="interface",
entity_key=str(item["entity_key"]),
display_name=str(item["display_name"]),
payload=dict(item["payload"]),
source_type="governance",
source_ref=str(item["source_ref"]),
)
counters["interfaces"] += 1
interface_ids.append(int(interface_entity.id))
rel = await self.upsert_relation(
relation_type="policy_enforced_by_interface",
subject_entity_id=int(policy_entity.id),
object_entity_id=int(interface_entity.id),
source_type="governance",
source_ref=str(item["source_ref"]),
)
counters["relations"] += 1
await self.add_evidence(
evidence_type="policy_guardrail",
content_excerpt=f"{DDL_GUARDRAIL_POLICY_NAME} -> {item['display_name']}",
relation_id=int(rel.id),
source_type="governance",
source_ref=str(item["source_ref"]),
metadata=dict(item["payload"]),
)
counters["evidences"] += 1
for tier in ("low", "high"):
spec = model_specs.get(tier) if isinstance(model_specs.get(tier), dict) else {}
model_name = str(spec.get("model") or "").strip() or ("GLM-4.7" if tier == "low" else "MiniMax-M2.5")
provider = str(spec.get("provider") or "").strip()
api_env = str(spec.get("api_env") or "").strip()
source = str(spec.get("source") or "env_default").strip()
source_ref = str(spec.get("source_path") or spec.get("rule_name") or "llm_routing_env").strip()[:255]
entity = await self.upsert_entity(
entity_type="ai_model",
entity_key=f"ai_model:{tier}:{model_name.lower()[:160]}",
display_name=model_name[:255],
payload={
"tier": tier,
"provider": provider,
"model": model_name,
"api_env": api_env,
"source": source,
"routing_rule": str(spec.get("rule_name") or "").strip(),
},
source_type="model_routing",
source_ref=source_ref or None,
)
counters["models"] += 1
rel = await self.upsert_relation(
relation_type="model_must_follow_policy",
subject_entity_id=int(entity.id),
object_entity_id=int(policy_entity.id),
source_type="governance",
source_ref=source_ref or None,
payload={"tier": tier, "policy_code": "ddl_via_interface_only"},
)
counters["relations"] += 1
await self.add_evidence(
evidence_type="policy_guardrail",
content_excerpt=f"{tier} model {model_name} must follow {DDL_GUARDRAIL_POLICY_KEY}",
relation_id=int(rel.id),
source_type="governance",
source_ref=source_ref or None,
metadata={"tier": tier, "provider": provider, "model": model_name},
)
counters["evidences"] += 1
for interface_id in interface_ids:
rel = await self.upsert_relation(
relation_type="model_executes_via_interface",
subject_entity_id=int(entity.id),
object_entity_id=int(interface_id),
source_type="governance",
source_ref=source_ref or None,
payload={"tier": tier},
)
counters["relations"] += 1
await self.add_evidence(
evidence_type="policy_guardrail",
content_excerpt=f"{tier} model {model_name} executes via controlled interface",
relation_id=int(rel.id),
source_type="governance",
source_ref=source_ref or None,
metadata={"tier": tier},
)
counters["evidences"] += 1
return counters
async def sync_model_guardrails_if_due(
self,
*,
interval_hours: int = 6,
) -> dict[str, Any]:
hours = max(1, int(interval_hours))
offset_raw = await self.get_ingest_offset(channel="model_guardrail", offset_key="last_model_guardrail_sync_at")
last_sync_at = self._parse_iso_dt(offset_raw)
now = _utc_now_naive()
if last_sync_at is not None and now - last_sync_at < timedelta(hours=hours):
return {
"executed": False,
"reason": "not_due",
"last_sync_at": last_sync_at.isoformat(),
"next_due_at": (last_sync_at + timedelta(hours=hours)).isoformat(),
}
payload = await self._sync_model_guardrail_entities()
await self.enqueue_ingest_event(
channel="model_guardrail",
event_key=f"model_guardrail:{_utc_now_naive().strftime('%Y%m%d%H%M%S')}",
payload=payload,
status="done",
)
await self.set_ingest_offset(
channel="model_guardrail",
offset_key="last_model_guardrail_sync_at",
offset_value=_utc_now_naive().isoformat(),
payload=payload,
)
await self.db.commit()
return {
"executed": True,
"reason": "due",
"payload": payload,
}
async def sync_model_guardrails_now(self) -> dict[str, Any]:
payload = await self._sync_model_guardrail_entities()
await self.enqueue_ingest_event(
channel="model_guardrail",
event_key=f"model_guardrail:{_utc_now_naive().strftime('%Y%m%d%H%M%S')}",
payload=payload,
status="done",
)
await self.set_ingest_offset(
channel="model_guardrail",
offset_key="last_model_guardrail_sync_at",
offset_value=_utc_now_naive().isoformat(),
payload=payload,
)
await self.db.commit()
return payload
async def sync_feature_catalog_if_due( async def sync_feature_catalog_if_due(
self, self,
*, *,
@ -1403,8 +1697,18 @@ class ProjectKnowledgeGraphService:
stage_errors["feature_catalog"] = str(exc)[:500] stage_errors["feature_catalog"] = str(exc)[:500]
stage_results["feature_catalog"] = {"error": stage_errors["feature_catalog"]} stage_results["feature_catalog"] = {"error": stage_errors["feature_catalog"]}
try:
stage_results["model_guardrails"] = await self.sync_model_guardrails_if_due(
interval_hours=feature_scan_hours,
)
success_count += 1
except Exception as exc:
await self.db.rollback()
stage_errors["model_guardrails"] = str(exc)[:500]
stage_results["model_guardrails"] = {"error": stage_errors["model_guardrails"]}
ended = _utc_now_naive() ended = _utc_now_naive()
if success_count == 6: if success_count == 7:
final_status = "success" final_status = "success"
elif success_count == 0: elif success_count == 0:
final_status = "failed" final_status = "failed"
@ -1622,6 +1926,8 @@ class ProjectKnowledgeGraphService:
) )
counters["evidences"] += 1 counters["evidences"] += 1
counters["model_guardrails"] = await self._sync_model_guardrail_entities()
await self.enqueue_ingest_event( await self.enqueue_ingest_event(
channel="db_delta", channel="db_delta",
event_key=f"structured_sync:{_utc_now_naive().strftime('%Y%m%d%H%M%S')}", event_key=f"structured_sync:{_utc_now_naive().strftime('%Y%m%d%H%M%S')}",

View File

@ -638,7 +638,9 @@ class ProactiveSignalCaptureService:
orchestration_status = str(source_orchestration_result.get("status") or "").lower() orchestration_status = str(source_orchestration_result.get("status") or "").lower()
if orchestration_status in {"completed", "partial", "planned", "pending_manual"}: if orchestration_status in {"completed", "partial", "planned", "pending_manual"}:
auto_tasks = self._build_openclaw_auto_tasks( auto_tasks = self._build_openclaw_auto_tasks(
source_orchestration_result.get("openclaw"), source_orchestration_result.get("execution")
if isinstance(source_orchestration_result.get("execution"), dict)
else source_orchestration_result.get("openclaw"),
) )
try: try:
strategic_payload: Dict[str, Any] = {} strategic_payload: Dict[str, Any] = {}

View File

@ -7,7 +7,7 @@ import json
from typing import Any, Optional from typing import Any, Optional
from sqlalchemy import and_, select from sqlalchemy import and_, select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
from ..config.ai_prompts import PROMPT_SIGNAL_PLANNER from ..config.ai_prompts import PROMPT_SIGNAL_PLANNER
from ..core.logging import get_logger from ..core.logging import get_logger
@ -322,16 +322,16 @@ class SignalPlannerService:
} }
@classmethod @classmethod
async def analyze( async def _analyze_with_session(
cls, cls,
*, *,
db: AsyncSession,
signal_id: int, signal_id: int,
tenant_id: int, tenant_id: int,
source_message_id: Optional[str] = None, source_message_id: Optional[str],
source_chat_id: Optional[str] = None, source_chat_id: Optional[str],
dedupe_seed: Optional[str] = None, dedupe_seed: Optional[str],
) -> None: ) -> None:
async with AsyncSessionLocal() as db:
signal: Signal | None = None signal: Signal | None = None
try: try:
signal = await cls._load_signal(db, tenant_id=int(tenant_id), signal_id=int(signal_id)) signal = await cls._load_signal(db, tenant_id=int(tenant_id), signal_id=int(signal_id))
@ -381,7 +381,6 @@ class SignalPlannerService:
) )
extraction = None extraction = None
fallback_reason = ""
try: try:
extraction = await asyncio.wait_for( extraction = await asyncio.wait_for(
llm_service.generate_structured_object( llm_service.generate_structured_object(
@ -395,24 +394,11 @@ class SignalPlannerService:
timeout=PLANNER_MAX_WAIT_SECONDS, timeout=PLANNER_MAX_WAIT_SECONDS,
) )
except asyncio.TimeoutError: except asyncio.TimeoutError:
fallback_reason = f"planner_wait_timeout_{int(PLANNER_MAX_WAIT_SECONDS)}s" raise RuntimeError(f"signal planner timeout after {int(PLANNER_MAX_WAIT_SECONDS)}s") from None
payload = extraction.payload if extraction is not None and isinstance(extraction.payload, dict) else None payload = extraction.payload if extraction is not None and isinstance(extraction.payload, dict) else None
if payload is None: if payload is None:
if not fallback_reason: raise RuntimeError("signal planner empty payload")
fallback_reason = str((extraction.error_code if extraction is not None else "") or "planner_empty_payload")
payload = cls._build_fallback_planner_result(
signal=signal,
members=members,
reason=fallback_reason,
)
logger.warning(
"signal_planner_schema_fallback",
tenant_id=int(tenant_id),
signal_id=int(signal.id),
reason=fallback_reason,
raw_text=str((extraction.raw_text if extraction is not None else "") or "")[:300],
)
await SignalStrategicAnalysisService.merge_and_save( await SignalStrategicAnalysisService.merge_and_save(
db, db,
@ -447,7 +433,7 @@ class SignalPlannerService:
db, db,
tenant_id=int(tenant_id), tenant_id=int(tenant_id),
chat_id=chat_id, chat_id=chat_id,
text="信号已入库,AI 分析暂时失败,系统将自动重试。", text="信号已入库,但 AI 分析失败,请检查模型配置或稍后手动重试。",
source_message_id=source_mid, source_message_id=source_mid,
dedupe_seed=dedupe_seed, dedupe_seed=dedupe_seed,
) )
@ -460,3 +446,36 @@ class SignalPlannerService:
signal_id=int(signal_id), signal_id=int(signal_id),
error=str(exc)[:1000], error=str(exc)[:1000],
) )
@classmethod
async def analyze(
cls,
*,
signal_id: int,
tenant_id: int,
source_message_id: Optional[str] = None,
source_chat_id: Optional[str] = None,
dedupe_seed: Optional[str] = None,
session_factory: Optional[async_sessionmaker[AsyncSession]] = None,
) -> None:
if session_factory is not None:
async with session_factory() as db:
await cls._analyze_with_session(
db=db,
signal_id=signal_id,
tenant_id=tenant_id,
source_message_id=source_message_id,
source_chat_id=source_chat_id,
dedupe_seed=dedupe_seed,
)
return
async with AsyncSessionLocal() as db:
await cls._analyze_with_session(
db=db,
signal_id=signal_id,
tenant_id=tenant_id,
source_message_id=source_message_id,
source_chat_id=source_chat_id,
dedupe_seed=dedupe_seed,
)

View File

@ -57,7 +57,7 @@ def _utc_now_naive() -> datetime:
class SignalSourceOrchestrationService: class SignalSourceOrchestrationService:
"""把“新增信号源”转换为 OpenClaw 指挥与下级 AI worker 执行链。""" """把“新增信号源”转换为 GLM 规划与显式服务执行链。"""
session_factory = AsyncSessionLocal session_factory = AsyncSessionLocal
@ -137,7 +137,7 @@ class SignalSourceOrchestrationService:
"summary": "当前输入不属于信号源接入请求。", "summary": "当前输入不属于信号源接入请求。",
"pending_workers": [], "pending_workers": [],
"worker_executions": [], "worker_executions": [],
"openclaw": {"status": "skipped", "reason": "not_signal_source_request"}, "execution": {"status": "skipped", "reason": "execution_removed"},
"reason": "not_signal_source_request", "reason": "not_signal_source_request",
} }
if run_service is not None: if run_service is not None:
@ -150,7 +150,7 @@ class SignalSourceOrchestrationService:
response_mode=response_mode, response_mode=response_mode,
status="completed", status="completed",
planner_status="skipped", planner_status="skipped",
openclaw_status="skipped", execution_status="skipped",
worker_total=0, worker_total=0,
worker_completed=0, worker_completed=0,
worker_failed=0, worker_failed=0,
@ -183,7 +183,7 @@ class SignalSourceOrchestrationService:
"summary": str(plan.get("summary") or "").strip(), "summary": str(plan.get("summary") or "").strip(),
"pending_workers": [], "pending_workers": [],
"worker_executions": [], "worker_executions": [],
"openclaw": {"status": "skipped", "reason": "planner_rejected"}, "execution": {"status": "skipped", "reason": "planner_rejected"},
} }
if run_service is not None: if run_service is not None:
await run_service.create_run( await run_service.create_run(
@ -195,7 +195,7 @@ class SignalSourceOrchestrationService:
response_mode=response_mode, response_mode=response_mode,
status="completed", status="completed",
planner_status=planner_status, planner_status=planner_status,
openclaw_status="skipped", execution_status="skipped",
worker_total=0, worker_total=0,
worker_completed=0, worker_completed=0,
worker_failed=0, worker_failed=0,
@ -227,15 +227,15 @@ class SignalSourceOrchestrationService:
operator_user_id=operator_user_id, operator_user_id=operator_user_id,
) )
operator_ref = self._build_user_snapshot(operator_user) operator_ref = self._build_user_snapshot(operator_user)
openclaw_result = await self._dispatch_openclaw( execution_result = await self._execute_planned_operations(
tenant_id=tenant_id, tenant_id=tenant_id,
operator_user=operator_ref, operator_user=operator_ref,
project=project_ref, project=project_ref,
instruction=str(plan.get("openclaw_instruction") or "").strip(), instruction=str(plan.get("execution_instruction") or "").strip(),
dry_run=dry_run, dry_run=dry_run,
response_mode=response_mode, response_mode=response_mode,
) )
openclaw_status = str(openclaw_result.get("status") or "").strip().lower() execution_status = str(execution_result.get("status") or "").strip().lower()
base_result = self._build_orchestration_result( base_result = self._build_orchestration_result(
trace_id=trace_id, trace_id=trace_id,
response_mode=response_mode, response_mode=response_mode,
@ -244,7 +244,7 @@ class SignalSourceOrchestrationService:
summary=str(plan.get("summary") or "").strip(), summary=str(plan.get("summary") or "").strip(),
registry_result=registry_result, registry_result=registry_result,
source_payload=source_payload, source_payload=source_payload,
openclaw_result=openclaw_result, execution_result=execution_result,
worker_results=[], worker_results=[],
pending_workers=list(worker_role_keys), pending_workers=list(worker_role_keys),
status="planned" if dry_run else "partial", status="planned" if dry_run else "partial",
@ -261,7 +261,7 @@ class SignalSourceOrchestrationService:
summary=str(plan.get("summary") or "").strip(), summary=str(plan.get("summary") or "").strip(),
registry_result=registry_result, registry_result=registry_result,
source_payload=source_payload, source_payload=source_payload,
openclaw_result=openclaw_result, execution_result=execution_result,
worker_results=planned_results, worker_results=planned_results,
pending_workers=[], pending_workers=[],
status="planned", status="planned",
@ -277,7 +277,7 @@ class SignalSourceOrchestrationService:
response_mode=response_mode, response_mode=response_mode,
status="completed", status="completed",
planner_status=planner_status, planner_status=planner_status,
openclaw_status=openclaw_status, execution_status=execution_status,
worker_total=len(worker_role_keys), worker_total=len(worker_role_keys),
worker_completed=len(planned_results), worker_completed=len(planned_results),
worker_failed=0, worker_failed=0,
@ -293,10 +293,10 @@ class SignalSourceOrchestrationService:
if not worker_role_keys: if not worker_role_keys:
registry_status, onboarding_status = self._resolve_registry_activation( registry_status, onboarding_status = self._resolve_registry_activation(
source_payload=source_payload, source_payload=source_payload,
openclaw_result=openclaw_result, execution_result=execution_result,
worker_results=[], worker_results=[],
) )
final_status = self._resolve_orchestration_status(openclaw_status=openclaw_status, worker_results=[]) final_status = self._resolve_orchestration_status(openclaw_status=execution_status, worker_results=[])
if final_status == "completed" and onboarding_status != "completed": if final_status == "completed" and onboarding_status != "completed":
final_status = "pending_manual" final_status = "pending_manual"
final_result = self._build_orchestration_result( final_result = self._build_orchestration_result(
@ -311,7 +311,7 @@ class SignalSourceOrchestrationService:
"onboarding_status": onboarding_status, "onboarding_status": onboarding_status,
}, },
source_payload=source_payload, source_payload=source_payload,
openclaw_result=openclaw_result, execution_result=execution_result,
worker_results=[], worker_results=[],
pending_workers=[], pending_workers=[],
status=final_status, status=final_status,
@ -327,7 +327,7 @@ class SignalSourceOrchestrationService:
response_mode=response_mode, response_mode=response_mode,
status=final_status, status=final_status,
planner_status=planner_status, planner_status=planner_status,
openclaw_status=openclaw_status, execution_status=execution_status,
worker_total=0, worker_total=0,
worker_completed=0, worker_completed=0,
worker_failed=0, worker_failed=0,
@ -364,7 +364,7 @@ class SignalSourceOrchestrationService:
response_mode=response_mode, response_mode=response_mode,
status="running", status="running",
planner_status=planner_status, planner_status=planner_status,
openclaw_status=openclaw_status, execution_status=execution_status,
worker_total=len(worker_role_keys), worker_total=len(worker_role_keys),
worker_completed=0, worker_completed=0,
worker_failed=0, worker_failed=0,
@ -382,7 +382,7 @@ class SignalSourceOrchestrationService:
project_payload=self._serialize_snapshot(project_ref), project_payload=self._serialize_snapshot(project_ref),
signal_payload=self._serialize_snapshot(signal_ref), signal_payload=self._serialize_snapshot(signal_ref),
registry_result=dict(registry_result), registry_result=dict(registry_result),
openclaw_result=dict(openclaw_result), openclaw_result=dict(execution_result),
worker_role_keys=list(worker_role_keys), worker_role_keys=list(worker_role_keys),
operator_user_payload=self._serialize_snapshot(operator_ref), operator_user_payload=self._serialize_snapshot(operator_ref),
planner_status=planner_status, planner_status=planner_status,
@ -558,7 +558,7 @@ class SignalSourceOrchestrationService:
"summary": summary or "未识别为新增信号源请求。", "summary": summary or "未识别为新增信号源请求。",
"source": None, "source": None,
"worker_roles": [], "worker_roles": [],
"openclaw_instruction": "", "execution_instruction": "",
} }
normalized_source = self._normalize_source_payload( normalized_source = self._normalize_source_payload(
source_payload=source, source_payload=source,
@ -570,9 +570,9 @@ class SignalSourceOrchestrationService:
"summary": summary or f"准备接入信号源 {normalized_source['source_name']}。", "summary": summary or f"准备接入信号源 {normalized_source['source_name']}。",
"source": normalized_source, "source": normalized_source,
"worker_roles": worker_roles, "worker_roles": worker_roles,
"openclaw_instruction": ( "execution_instruction": (
str(plan.get("openclaw_instruction") or "").strip() str(plan.get("execution_instruction") or "").strip()
or self._build_openclaw_instruction(normalized_source, initiating_text) or self._build_execution_instruction(normalized_source, initiating_text)
)[:600], )[:600],
} }
@ -593,7 +593,7 @@ class SignalSourceOrchestrationService:
"summary": "系统已按规则兜底为信号源接入请求,并生成最小接入闭环。", "summary": "系统已按规则兜底为信号源接入请求,并生成最小接入闭环。",
"source": source, "source": source,
"worker_roles": list(DEFAULT_WORKER_ROLE_KEYS), "worker_roles": list(DEFAULT_WORKER_ROLE_KEYS),
"openclaw_instruction": self._build_openclaw_instruction(source, initiating_text), "execution_instruction": self._build_execution_instruction(source, initiating_text),
} }
def _normalize_source_payload( def _normalize_source_payload(
@ -800,7 +800,7 @@ class SignalSourceOrchestrationService:
return list(DEFAULT_WORKER_ROLE_KEYS) return list(DEFAULT_WORKER_ROLE_KEYS)
@staticmethod @staticmethod
def _build_openclaw_instruction(source: dict[str, Any], initiating_text: str) -> str: def _build_execution_instruction(source: dict[str, Any], initiating_text: str) -> str:
return ( return (
f"围绕新增信号源“{source.get('source_name') or '未命名信号源'}”完成接入准备。" f"围绕新增信号源“{source.get('source_name') or '未命名信号源'}”完成接入准备。"
"请创建必要任务、分配合适负责人,并通知相关成员。" "请创建必要任务、分配合适负责人,并通知相关成员。"
@ -907,7 +907,7 @@ class SignalSourceOrchestrationService:
"description": str(existing.description or ""), "description": str(existing.description or ""),
} }
async def _dispatch_openclaw( async def _execute_planned_operations(
self, self,
*, *,
tenant_id: int, tenant_id: int,
@ -917,71 +917,10 @@ class SignalSourceOrchestrationService:
dry_run: bool, dry_run: bool,
response_mode: str, response_mode: str,
) -> dict[str, Any]: ) -> dict[str, Any]:
if not instruction: _ = tenant_id, operator_user, project, instruction, dry_run, response_mode
return { return {
"status": "skipped", "status": "skipped",
"reason": "empty_instruction", "reason": "execution_removed",
}
if operator_user is None:
return {
"status": "skipped",
"reason": "no_manager_operator",
}
try:
from .openclaw_commander_service import OpenClawCommanderService
session_factory = getattr(type(self), "session_factory", AsyncSessionLocal)
async with session_factory() as isolated_db:
isolated_project = (
await isolated_db.execute(
select(Project).where(
and_(
Project.tenant_id == int(tenant_id),
Project.id == int(project.id),
)
)
)
).scalar_one_or_none()
if isolated_project is None:
return {
"status": "skipped",
"reason": "project_not_found",
}
isolated_operator = (
await isolated_db.execute(
select(User).where(
and_(
User.tenant_id == int(tenant_id),
User.id == int(operator_user.id),
User.is_active.is_(True),
)
)
)
).scalar_one_or_none()
if isolated_operator is None:
return {
"status": "skipped",
"reason": "no_manager_operator",
}
result = await OpenClawCommanderService(isolated_db).dispatch_command(
tenant_id=tenant_id,
operator_user=isolated_operator,
project_id=int(isolated_project.id),
instruction=instruction,
dry_run=dry_run,
max_operations=6,
response_mode=response_mode,
persist_run=False,
)
await isolated_db.commit()
return {
"status": "planned" if dry_run else "completed",
"result": result,
}
except Exception as exc:
return {
"status": "failed",
"reason": str(exc),
} }
async def _execute_worker_roles( async def _execute_worker_roles(
@ -1027,7 +966,7 @@ class SignalSourceOrchestrationService:
project_payload=self._serialize_snapshot(project), project_payload=self._serialize_snapshot(project),
signal_payload=self._serialize_snapshot(signal), signal_payload=self._serialize_snapshot(signal),
registry_result=dict(registry_result), registry_result=dict(registry_result),
openclaw_result=dict(openclaw_result), execution_result=dict(openclaw_result),
role=dict(role), role=dict(role),
trace_id=trace_id, trace_id=trace_id,
operator_user_payload=self._serialize_snapshot(operator_user), operator_user_payload=self._serialize_snapshot(operator_user),
@ -1092,7 +1031,7 @@ class SignalSourceOrchestrationService:
) )
self.db.add(execution_row) self.db.add(execution_row)
await self.db.flush() await self.db.flush()
# 先提交 running 状态,避免 SQLite 事务跨 LLM 调用长期持有写锁。 # 先提交 running 状态,避免事务跨 LLM 调用长期持有写锁。
await self.db.commit() await self.db.commit()
prompt = self._build_worker_prompt( prompt = self._build_worker_prompt(
@ -1100,7 +1039,7 @@ class SignalSourceOrchestrationService:
project=project, project=project,
signal=signal, signal=signal,
source_payload=source_payload, source_payload=source_payload,
openclaw_result=openclaw_result, execution_result=openclaw_result,
) )
if dry_run: if dry_run:
return { return {
@ -1183,7 +1122,7 @@ class SignalSourceOrchestrationService:
project=SimpleNamespace(**project_payload), project=SimpleNamespace(**project_payload),
signal=SimpleNamespace(**signal_payload), signal=SimpleNamespace(**signal_payload),
registry_result=registry_result, registry_result=registry_result,
openclaw_result=openclaw_result, execution_result=openclaw_result,
role=role, role=role,
trace_id=trace_id, trace_id=trace_id,
operator_user=SimpleNamespace(**operator_user_payload) if isinstance(operator_user_payload, dict) else None, operator_user=SimpleNamespace(**operator_user_payload) if isinstance(operator_user_payload, dict) else None,
@ -1215,7 +1154,7 @@ class SignalSourceOrchestrationService:
project_payload=project_payload, project_payload=project_payload,
signal_payload=signal_payload, signal_payload=signal_payload,
registry_result=registry_result, registry_result=registry_result,
openclaw_result=openclaw_result, execution_result=openclaw_result,
worker_role_keys=worker_role_keys, worker_role_keys=worker_role_keys,
operator_user_payload=operator_user_payload, operator_user_payload=operator_user_payload,
planner_status=planner_status, planner_status=planner_status,
@ -1258,7 +1197,7 @@ class SignalSourceOrchestrationService:
"source_name": source_payload.get("source_name"), "source_name": source_payload.get("source_name"),
"source_identifier": source_payload.get("source_identifier"), "source_identifier": source_payload.get("source_identifier"),
}, },
openclaw_result=openclaw_result, execution_result=openclaw_result,
worker_results=worker_results, worker_results=worker_results,
pending_workers=pending_workers, pending_workers=pending_workers,
status="failed", status="failed",
@ -1271,7 +1210,7 @@ class SignalSourceOrchestrationService:
trace_id=trace_id, trace_id=trace_id,
status="failed", status="failed",
planner_status=planner_status, planner_status=planner_status,
openclaw_status=str(openclaw_result.get("status") or row.openclaw_status or ""), execution_status=str(openclaw_result.get("status") or row.openclaw_status or ""),
worker_total=max(len(worker_role_keys), int(row.worker_total or 0)), worker_total=max(len(worker_role_keys), int(row.worker_total or 0)),
worker_completed=int(row.worker_completed or 0), worker_completed=int(row.worker_completed or 0),
worker_failed=int(row.worker_failed or 0), worker_failed=int(row.worker_failed or 0),
@ -1328,7 +1267,7 @@ class SignalSourceOrchestrationService:
project_payload=project_payload, project_payload=project_payload,
signal_payload=signal_payload, signal_payload=signal_payload,
registry_result=registry_result, registry_result=registry_result,
openclaw_result=openclaw_result, execution_result=openclaw_result,
role=dict(role), role=dict(role),
trace_id=trace_id, trace_id=trace_id,
operator_user_payload=operator_user_payload, operator_user_payload=operator_user_payload,
@ -1436,7 +1375,7 @@ class SignalSourceOrchestrationService:
summary: str, summary: str,
registry_result: dict[str, Any], registry_result: dict[str, Any],
source_payload: dict[str, Any], source_payload: dict[str, Any],
openclaw_result: dict[str, Any], execution_result: dict[str, Any],
worker_results: list[dict[str, Any]], worker_results: list[dict[str, Any]],
pending_workers: list[str], pending_workers: list[str],
status: str, status: str,
@ -1452,7 +1391,7 @@ class SignalSourceOrchestrationService:
"summary": summary, "summary": summary,
"pending_workers": list(pending_workers), "pending_workers": list(pending_workers),
"signal_source": self._build_signal_source_snapshot(registry_result, source_payload), "signal_source": self._build_signal_source_snapshot(registry_result, source_payload),
"openclaw": openclaw_result, "execution": execution_result,
"worker_executions": list(worker_results), "worker_executions": list(worker_results),
} }
@ -1599,7 +1538,7 @@ class SignalSourceOrchestrationService:
"source_name": (source_payload or {}).get("source_name"), "source_name": (source_payload or {}).get("source_name"),
"source_identifier": (source_payload or {}).get("source_identifier"), "source_identifier": (source_payload or {}).get("source_identifier"),
}, },
openclaw_result=openclaw_result, execution_result=openclaw_result,
worker_results=worker_results, worker_results=worker_results,
pending_workers=pending_workers, pending_workers=pending_workers,
status=status, status=status,
@ -1610,7 +1549,7 @@ class SignalSourceOrchestrationService:
trace_id=trace_id, trace_id=trace_id,
status="running" if pending_workers else status, status="running" if pending_workers else status,
planner_status=planner_status, planner_status=planner_status,
openclaw_status=str(openclaw_result.get("status") or ""), execution_status=str(openclaw_result.get("status") or ""),
worker_total=len(worker_role_keys), worker_total=len(worker_role_keys),
worker_completed=completed_count, worker_completed=completed_count,
worker_failed=failed_count, worker_failed=failed_count,
@ -1667,7 +1606,7 @@ class SignalSourceOrchestrationService:
"source_name": (source_payload or {}).get("source_name"), "source_name": (source_payload or {}).get("source_name"),
"source_identifier": (source_payload or {}).get("source_identifier"), "source_identifier": (source_payload or {}).get("source_identifier"),
}, },
openclaw_result=openclaw_result, execution_result=openclaw_result,
worker_results=worker_results, worker_results=worker_results,
pending_workers=[], pending_workers=[],
status=final_status, status=final_status,
@ -1678,7 +1617,7 @@ class SignalSourceOrchestrationService:
trace_id=trace_id, trace_id=trace_id,
status=final_status, status=final_status,
planner_status=planner_status, planner_status=planner_status,
openclaw_status=str(openclaw_result.get("status") or ""), execution_status=str(openclaw_result.get("status") or ""),
worker_total=len(worker_role_keys), worker_total=len(worker_role_keys),
worker_completed=completed_count, worker_completed=completed_count,
worker_failed=failed_count, worker_failed=failed_count,
@ -1761,7 +1700,7 @@ class SignalSourceOrchestrationService:
"summary": f"{role_name} 已为 {source_name} 生成兜底执行摘要。", "summary": f"{role_name} 已为 {source_name} 生成兜底执行摘要。",
"deliverables": [f"梳理 {source_name} 的接入目标与边界"], "deliverables": [f"梳理 {source_name} 的接入目标与边界"],
"risks": [f"{source_name} 的标识与接入细节仍需补充确认"], "risks": [f"{source_name} 的标识与接入细节仍需补充确认"],
"next_actions": ["等待 OpenClaw 生成任务并推进执行"], "next_actions": ["等待显式服务完成落库并继续推进执行"],
"used_fallback": True, "used_fallback": True,
} }
@ -1788,7 +1727,7 @@ class SignalSourceOrchestrationService:
signal.processed = orchestration_status in {"completed", "partial"} signal.processed = orchestration_status in {"completed", "partial"}
signal.status = self._map_orchestration_status_to_signal_status(orchestration_status) signal.status = self._map_orchestration_status_to_signal_status(orchestration_status)
signal.tasks_generated = json.dumps( signal.tasks_generated = json.dumps(
self._extract_task_operations(orchestration_result.get("openclaw")), self._extract_task_operations(orchestration_result.get("execution")),
ensure_ascii=False, ensure_ascii=False,
) )
await self.db.commit() await self.db.commit()

View File

@ -7,16 +7,14 @@ PMFlow 人员能力图谱 - 技能标签与用户技能绑定服务
- UserWorkload 自动更新 - UserWorkload 自动更新
""" """
import uuid as uuid_module
from datetime import datetime, timedelta from datetime import datetime, timedelta
from typing import List, Optional from typing import List, Optional
from sqlalchemy import and_, func, select, text from sqlalchemy import and_, func, select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload from sqlalchemy.orm import selectinload
from ..models import SkillTag, UserSkillBinding, UserWorkload, User, UserStatus, Task from ..models import SkillTag, UserSkillBinding, UserWorkload, User, UserStatus, Task
from ..database import _is_sqlite
from .ai_pmo_kernel_service import AIPMOKernelService from .ai_pmo_kernel_service import AIPMOKernelService
@ -42,7 +40,7 @@ async def init_system_skill_tags(db: AsyncSession, tenant_id: int = 1) -> int:
""" """
初始化系统预设 AI 开发技能标签。 初始化系统预设 AI 开发技能标签。
项目启动时自动执行,已存在的标签不会重复创建。 项目启动时自动执行,已存在的标签不会重复创建。
SQLite 下用 raw INSERT 显式指定 id,避免 NOT NULL constraint failed: skill_tags.id。 PostgreSQL 统一使用 ORM 批量写入,避免分方言分支。
""" """
result = await db.execute( result = await db.execute(
select(SkillTag).where( select(SkillTag).where(
@ -56,40 +54,6 @@ async def init_system_skill_tags(db: AsyncSession, tenant_id: int = 1) -> int:
if not to_create: if not to_create:
return 0 return 0
if _is_sqlite():
# SQLite 下部分表 id 无自增或为 UUID,用 raw SQL 显式指定 id
r = await db.execute(text("SELECT MAX(id) FROM skill_tags"))
max_id = r.scalar()
try:
next_id = int(max_id or 0) + 1
use_int_id = True
except (TypeError, ValueError):
use_int_id = False
for item in to_create:
if use_int_id:
tag_id = next_id
next_id += 1
else:
tag_id = str(uuid_module.uuid4()).replace("-", "")[:32]
await db.execute(
text("""
INSERT INTO skill_tags (id, name, slug, category, description, is_system, sort_order, tenant_id)
VALUES (:id, :name, :slug, :category, :description, :is_system, :sort_order, :tenant_id)
"""),
{
"id": tag_id,
"name": item["name"],
"slug": item["slug"],
"category": item.get("category") or "tool",
"description": None,
"is_system": 1,
"sort_order": item.get("sort_order", 0),
"tenant_id": tenant_id,
},
)
await db.commit()
return len(to_create)
created = 0 created = 0
for item in to_create: for item in to_create:
tag = SkillTag( tag = SkillTag(
@ -404,27 +368,6 @@ async def get_user_skill_task_history(
- assignee_id == user_id - assignee_id == user_id
- required_skill_tag_ids 包含 skill_tag_id - required_skill_tag_ids 包含 skill_tag_id
""" """
if _is_sqlite():
# SQLite 下 ARRAY 以 JSON 文本存储,先拉取后在 Python 侧做包含判断。
result = await db.execute(
select(Task)
.where(
and_(
Task.tenant_id == tenant_id,
Task.assignee_id == user_id,
)
)
.order_by(Task.updated_at.desc())
.limit(max(limit * 5, 100))
)
tasks = result.scalars().all()
filtered = [
t
for t in tasks
if isinstance(t.required_skill_tag_ids, list)
and skill_tag_id in t.required_skill_tag_ids
][:limit]
else:
result = await db.execute( result = await db.execute(
select(Task) select(Task)
.where( .where(

View File

@ -3,6 +3,7 @@
from __future__ import annotations from __future__ import annotations
import json import json
import subprocess
from datetime import datetime from datetime import datetime
from pathlib import Path from pathlib import Path
from typing import Any, Optional from typing import Any, Optional
@ -31,12 +32,148 @@ DEFAULT_TEMPLATE_FALLBACK = """# Task Brief
## Scope ## Scope
{{SCOPE_SUMMARY}} {{SCOPE_SUMMARY}}
## Risks
{{RISKS}}
"""
LOW_TEMPLATE_ID = "tpl_low_model_execution"
LOW_TEMPLATE_NAME = "Low Model 执行模板(收敛)"
LOW_TEMPLATE_DESCRIPTION = "面向 low 模型:聚焦确定性执行、最小改动、可回归。"
LOW_TEMPLATE_CONTENT = """# Task Brief(Low Model 执行版)
## Meta
- Generated At: {{GENERATED_AT}}
- Base Ref: {{BASE_REF}}
- Head Ref: {{HEAD_REF}}
## Goal
- 任务目标:{{SCOPE_SUMMARY}}
- 执行原则:只做最小必要改动,不扩散需求边界。
## Changed Files
{{CHANGED_FILES}}
## Atomic Actions
1. 明确本次唯一目标与目标验收标准(1-2 条)。
2. 逐文件执行最小改动,禁止顺手重构。
3. 写前后保持接口字段兼容,避免破坏已有调用。
4. 每次改动后进行快速验证并记录结果。
## Must-Run Verification
- Backend: `python3 -m py_compile backend/app`
- Frontend: `npm run build` + `npx tsc --noEmit`
- 必须给出失败项与修复动作,禁止只报“已测试”。
## Escalation Gate (Upgrade to High Model)
- 跨模块文件超过 5 个;
- 涉及数据库 schema / 事务边界调整;
- 涉及前后端契约新增或破坏性变更;
- 无法在当前信息下保证零回归。
## Risks
{{RISKS}}
"""
HIGH_TEMPLATE_ID = "tpl_high_model_architect"
HIGH_TEMPLATE_NAME = "High Model 架构模板(跨栈)"
HIGH_TEMPLATE_DESCRIPTION = "面向 high 模型:跨栈影响评估、契约矩阵、回滚策略。"
HIGH_TEMPLATE_CONTENT = """# Task Brief(High Model 架构版)
## Meta
- Generated At: {{GENERATED_AT}}
- Base Ref: {{BASE_REF}}
- Head Ref: {{HEAD_REF}}
## Strategic Goal
- 业务目标:{{SCOPE_SUMMARY}}
- 决策边界:优先保障可回滚、契约兼容、线上稳定性。
## Changed Files
{{CHANGED_FILES}}
## Cross-Stack Impact Matrix
| 领域 | 影响描述 | 契约同步动作 | 验证方式 |
| --- | --- | --- | --- |
| Backend API | | | |
| Frontend Type | | | |
| DB/Data | | | |
| Async/Job | | | |
## Controlled Write Plan
- 所有数据库写入必须经受控接口(HTTP 或 `internal://`);
- 不允许直连 SQL 路径作为最终执行方案;
- 需给出接口化落地路径与调用顺序。
## Rollback Plan
1. 回滚触发条件(明确监控阈值)。
2. 数据层回滚策略(兼容/补偿/回放)。
3. 应用层回滚策略(开关/降级/流量切换)。
## Verification Gate
- 编译、单测、关键链路回归全部通过;
- 前后端契约文档同步完成;
- 风险项有 owner 与截止时间。
## Risks
{{RISKS}}
"""
HIGH_CONTRACT_TEMPLATE_ID = "tpl_high_model_contract_guard"
HIGH_CONTRACT_TEMPLATE_NAME = "High Model 契约审计模板"
HIGH_CONTRACT_TEMPLATE_DESCRIPTION = "面向 high 模型:前后端契约覆盖率审计与缺口修复。"
HIGH_CONTRACT_TEMPLATE_CONTENT = """# Task Brief(High Model 契约审计版)
## Meta
- Generated At: {{GENERATED_AT}}
- Base Ref: {{BASE_REF}}
- Head Ref: {{HEAD_REF}}
## Audit Scope
- 目标范围:{{SCOPE_SUMMARY}}
## Changed Files
{{CHANGED_FILES}}
## Contract Coverage Checklist
- [ ] 后端新增/变更 API 已在契约文档登记;
- [ ] 前端调用与后端路由一一对应;
- [ ] 响应包络保持 `{code, message, data}`;
- [ ] 关键类型(TS/Pydantic)字段一致;
- [ ] 历史兼容策略(向后兼容或版本化)已说明。
## Gap Register
| Gap | Severity | Owner | Fix Plan | ETA |
| --- | --- | --- | --- | --- |
| | | | | |
## Delivery Artifacts
- 契约差异摘要(新增/删除/变更字段)
- 影响接口清单与调用方映射
- 回归测试结果与剩余风险
## Risks ## Risks
{{RISKS}} {{RISKS}}
""" """
class TaskBriefService: class TaskBriefService:
@classmethod
def _project_root_candidates(cls) -> list[Path]:
service_file = Path(__file__).resolve()
cwd = Path.cwd().resolve()
raw_candidates = [
service_file.parents[3],
service_file.parents[2],
cwd,
cwd.parent,
]
deduped: list[Path] = []
seen: set[str] = set()
for path in raw_candidates:
key = str(path)
if key in seen:
continue
seen.add(key)
deduped.append(path)
return deduped
@staticmethod @staticmethod
def _safe_dict(value: Any) -> dict[str, Any]: def _safe_dict(value: Any) -> dict[str, Any]:
if isinstance(value, dict): if isinstance(value, dict):
@ -53,9 +190,20 @@ class TaskBriefService:
return {} return {}
return {} return {}
@classmethod
def _project_root(cls) -> Path:
for root in cls._project_root_candidates():
if (root / "docs" / "architecture").exists():
return root
return cls._project_root_candidates()[0]
@staticmethod @staticmethod
def _project_root() -> Path: def _normalize_template_text(content: str) -> str:
return Path(__file__).resolve().parents[3] return "\n".join(line.rstrip() for line in str(content or "").strip().splitlines()).strip()
@classmethod
def _is_default_template_fallback(cls, content: str) -> bool:
return cls._normalize_template_text(content) == cls._normalize_template_text(DEFAULT_TEMPLATE_FALLBACK)
@classmethod @classmethod
def _load_default_template_content(cls) -> str: def _load_default_template_content(cls) -> str:
@ -67,6 +215,53 @@ class TaskBriefService:
return DEFAULT_TEMPLATE_FALLBACK return DEFAULT_TEMPLATE_FALLBACK
return DEFAULT_TEMPLATE_FALLBACK return DEFAULT_TEMPLATE_FALLBACK
@classmethod
def _resolve_git_ref(cls, ref: str) -> str:
try:
output = subprocess.check_output(
["git", "rev-parse", "--short", ref],
cwd=str(cls._project_root()),
stderr=subprocess.DEVNULL,
text=True,
timeout=1.5,
).strip()
return output or "-"
except Exception:
return "-"
@staticmethod
def _to_markdown_list(items: list[str], *, empty_text: str) -> str:
cleaned = [str(item).strip() for item in items if str(item).strip()]
if not cleaned:
return f"- {empty_text}"
return "\n".join(f"- {item}" for item in cleaned)
@classmethod
def _render_template_content(
cls,
*,
template_content: str,
generated_at: str,
base_ref: str,
head_ref: str,
changed_files: list[str],
scope_summary: str,
risks: list[str],
) -> str:
content = str(template_content or "").strip() or cls._load_default_template_content()
replacements = {
"{{GENERATED_AT}}": str(generated_at or "-"),
"{{BASE_REF}}": str(base_ref or "-"),
"{{HEAD_REF}}": str(head_ref or "-"),
"{{CHANGED_FILES}}": cls._to_markdown_list(changed_files, empty_text="(none)"),
"{{SCOPE_SUMMARY}}": str(scope_summary or "-"),
"{{RISKS}}": cls._to_markdown_list(risks, empty_text="待补充风险评估"),
}
rendered = content
for token, value in replacements.items():
rendered = rendered.replace(token, value)
return rendered.strip()
@classmethod @classmethod
def _build_default_template(cls) -> dict[str, Any]: def _build_default_template(cls) -> dict[str, Any]:
now_text = now_cn_naive().isoformat() now_text = now_cn_naive().isoformat()
@ -80,6 +275,70 @@ class TaskBriefService:
"created_at": now_text, "created_at": now_text,
} }
@staticmethod
def _build_template_item(
*,
template_id: str,
name: str,
description: str,
content: str,
now_text: str,
) -> dict[str, Any]:
return {
"id": template_id,
"name": name,
"description": description,
"content": content,
"updated_at": now_text,
"updated_by": 0,
"created_at": now_text,
}
@classmethod
def _build_system_templates(cls) -> list[dict[str, Any]]:
now_text = now_cn_naive().isoformat()
return [
cls._build_default_template(),
cls._build_template_item(
template_id=LOW_TEMPLATE_ID,
name=LOW_TEMPLATE_NAME,
description=LOW_TEMPLATE_DESCRIPTION,
content=LOW_TEMPLATE_CONTENT,
now_text=now_text,
),
cls._build_template_item(
template_id=HIGH_TEMPLATE_ID,
name=HIGH_TEMPLATE_NAME,
description=HIGH_TEMPLATE_DESCRIPTION,
content=HIGH_TEMPLATE_CONTENT,
now_text=now_text,
),
cls._build_template_item(
template_id=HIGH_CONTRACT_TEMPLATE_ID,
name=HIGH_CONTRACT_TEMPLATE_NAME,
description=HIGH_CONTRACT_TEMPLATE_DESCRIPTION,
content=HIGH_CONTRACT_TEMPLATE_CONTENT,
now_text=now_text,
),
]
@classmethod
def _ensure_system_templates(
cls,
templates: list[dict[str, Any]],
) -> tuple[list[dict[str, Any]], bool]:
merged = [item for item in templates if isinstance(item, dict)]
known_ids = {str(item.get("id") or "").strip() for item in merged if str(item.get("id") or "").strip()}
changed = False
for preset in cls._build_system_templates():
template_id = str(preset.get("id") or "").strip()
if not template_id or template_id in known_ids:
continue
merged.append(preset)
known_ids.add(template_id)
changed = True
return merged, changed
@staticmethod @staticmethod
def _extract_markdown_section_lines(content: str, *, heading: str) -> list[str]: def _extract_markdown_section_lines(content: str, *, heading: str) -> list[str]:
target = f"## {heading}".lower() target = f"## {heading}".lower()
@ -225,7 +484,33 @@ class TaskBriefService:
existing_templates = [item for item in bundle.get("templates", []) if isinstance(item, dict)] existing_templates = [item for item in bundle.get("templates", []) if isinstance(item, dict)]
if not existing_templates: if not existing_templates:
bundle["templates"] = [cls._build_default_template()] bundle["templates"] = cls._build_system_templates()
existing_templates = [item for item in bundle["templates"] if isinstance(item, dict)]
changed = True
else:
upgraded_default_template = False
latest_default_content = cls._load_default_template_content()
for item in existing_templates:
if str(item.get("id") or "") != DEFAULT_TEMPLATE_ID:
continue
current_content = str(item.get("content") or "").strip()
if current_content and not cls._is_default_template_fallback(current_content):
break
item["name"] = str(item.get("name") or "").strip() or DEFAULT_TEMPLATE_NAME
item["description"] = str(item.get("description") or "").strip() or DEFAULT_TEMPLATE_DESCRIPTION
item["content"] = latest_default_content
item["updated_at"] = now_cn_naive().isoformat()
if item.get("updated_by") is None:
item["updated_by"] = 0
upgraded_default_template = True
break
if upgraded_default_template:
bundle["templates"] = existing_templates
changed = True
existing_templates, added_presets = cls._ensure_system_templates(existing_templates)
if added_presets:
bundle["templates"] = existing_templates
changed = True changed = True
existing_briefs = [item for item in bundle.get("briefs", []) if isinstance(item, dict)] existing_briefs = [item for item in bundle.get("briefs", []) if isinstance(item, dict)]
@ -355,9 +640,25 @@ class TaskBriefService:
brief_id = f"brief_{uuid4().hex[:12]}" brief_id = f"brief_{uuid4().hex[:12]}"
run_id = f"run_{uuid4().hex[:12]}" run_id = f"run_{uuid4().hex[:12]}"
content = str((template_obj or {}).get("content") or "").strip()
file_lines = [str(path).strip() for path in changed_files if str(path).strip()] file_lines = [str(path).strip() for path in changed_files if str(path).strip()]
summary = str(scope_summary or "").strip() or f"共 {len(file_lines)} 个文件发生变更" summary = str(scope_summary or "").strip() or f"共 {len(file_lines)} 个文件发生变更"
risks = (
[
"跨文件改动可能引发契约/类型不一致,请补充联动核对。",
"合入前需完成 build/lint/test 并记录结果。",
]
if file_lines
else ["未提供变更文件列表,风险评估信息不足,请人工补充。"]
)
content = cls._render_template_content(
template_content=str((template_obj or {}).get("content") or "").strip(),
generated_at=now_text,
base_ref=cls._resolve_git_ref("HEAD~1"),
head_ref=cls._resolve_git_ref("HEAD"),
changed_files=file_lines,
scope_summary=summary,
risks=risks,
)
run = { run = {
"id": run_id, "id": run_id,

View File

@ -1,8 +1,5 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
""" """Initialize database metadata for PostgreSQL environments."""
Initialize/fix SQLite database by creating all missing tables.
Run this when the database schema is out of sync.
"""
import asyncio import asyncio
import sys import sys
from pathlib import Path from pathlib import Path
@ -31,7 +28,18 @@ async def init_database():
# Verify tables were created # Verify tables were created
async with engine.begin() as conn: async with engine.begin() as conn:
result = await conn.run_sync(lambda sync_conn: sync_conn.execute(text("SELECT name FROM sqlite_master WHERE type='table'")).fetchall()) result = await conn.run_sync(
lambda sync_conn: sync_conn.execute(
text(
"""
SELECT tablename
FROM pg_catalog.pg_tables
WHERE schemaname = 'public'
ORDER BY tablename
"""
)
).fetchall()
)
tables = [row[0] for row in result] tables = [row[0] for row in result]
print(f"📊 Tables in database: {', '.join(sorted(tables))}") print(f"📊 Tables in database: {', '.join(sorted(tables))}")

View File

@ -3,7 +3,6 @@ uvicorn[standard]>=0.30.0
sqlalchemy>=2.0.30 sqlalchemy>=2.0.30
alembic>=1.13.2 alembic>=1.13.2
asyncpg>=0.29.0 asyncpg>=0.29.0
aiosqlite>=0.20.0
psycopg[binary]>=3.2.0 psycopg[binary]>=3.2.0
pgvector>=0.3.5 pgvector>=0.3.5
python-dotenv>=1.0.1 python-dotenv>=1.0.1

View File

@ -1,290 +0,0 @@
#!/usr/bin/env python3
"""将本地 SQLite 数据迁移到当前 PostgreSQL(Docker)。
默认源库: /app/backend/neural_sync.db
默认目标库: 环境变量 DATABASE_URL
"""
from __future__ import annotations
import argparse
import asyncio
import json
import os
import sqlite3
from datetime import datetime
from typing import Any
import asyncpg
TABLE_ORDER = [
"skill_tags",
"users",
"projects",
"feishu_items",
"signals",
"truth_nodes",
"tasks",
"project_signal_links",
"user_skill_bindings",
"user_workloads",
"qa_sessions",
"qa_records",
"qa_feedback",
]
def _parse_json(value: Any, default: Any) -> Any:
if value is None:
return default
if isinstance(value, (dict, list)):
return value
text = str(value).strip()
if not text:
return default
try:
return json.loads(text)
except Exception:
return default
def _parse_ts(value: Any) -> Any:
if value is None:
return None
if isinstance(value, datetime):
return value
text = str(value).strip()
if not text:
return None
text = text.replace("T", " ").replace("Z", "")
try:
return datetime.fromisoformat(text)
except Exception:
return text
def _parse_int_array(value: Any) -> list[int]:
if value is None:
return []
if isinstance(value, list):
raw = value
else:
raw = _parse_json(value, default=[])
if not isinstance(raw, list):
raw = []
result: list[int] = []
for item in raw:
try:
num = int(item)
result.append(num)
except Exception:
continue
return result
def _transform(table: str, row: dict[str, Any], pg_types: dict[str, str]) -> dict[str, Any]:
data = dict(row)
if table == "users":
data["assigned_domains"] = _parse_json(data.get("assigned_domains"), default=[])
if table == "truth_nodes":
content = data.get("content")
parsed = _parse_json(content, default=None)
if parsed is None:
parsed = {"raw": str(content or "")}
data["content"] = parsed
if table == "tasks":
data["required_skill_tag_ids"] = _parse_int_array(data.get("required_skill_tag_ids"))
data["ai_recommended_assignees"] = _parse_json(data.get("ai_recommended_assignees"), default=[])
if table == "qa_records":
data["sources"] = _parse_json(data.get("sources"), default=[])
data["domains_used"] = _parse_json(data.get("domains_used"), default=[])
if table == "qa_feedback":
data["error_types"] = _parse_json(data.get("error_types"), default=[])
# 通用类型修正
for col, typ in pg_types.items():
if col not in data:
continue
if typ in {"json", "jsonb"}:
value = data[col]
if value is None:
continue
if isinstance(value, str):
data[col] = value
else:
data[col] = json.dumps(value, ensure_ascii=False)
continue
if typ == "boolean":
value = data[col]
if isinstance(value, bool):
continue
if value is None:
data[col] = False
else:
data[col] = str(value).strip().lower() in {"1", "true", "t", "yes", "y"}
elif typ == "timestamp without time zone":
data[col] = _parse_ts(data.get(col))
return data
async def _pg_table_columns(conn: asyncpg.Connection, table: str) -> list[str]:
rows = await conn.fetch(
"""
SELECT column_name
FROM information_schema.columns
WHERE table_schema='public' AND table_name=$1
ORDER BY ordinal_position
""",
table,
)
return [str(r["column_name"]) for r in rows]
async def _pg_table_types(conn: asyncpg.Connection, table: str) -> dict[str, str]:
rows = await conn.fetch(
"""
SELECT column_name, data_type
FROM information_schema.columns
WHERE table_schema='public' AND table_name=$1
""",
table,
)
return {str(r["column_name"]): str(r["data_type"]) for r in rows}
async def _pg_existing_tables(conn: asyncpg.Connection) -> set[str]:
rows = await conn.fetch(
"""
SELECT tablename
FROM pg_tables
WHERE schemaname='public'
"""
)
return {str(r["tablename"]) for r in rows}
def _sqlite_columns(sqlite_conn: sqlite3.Connection, table: str) -> list[str]:
rows = sqlite_conn.execute(f"PRAGMA table_info({table})").fetchall()
return [str(r[1]) for r in rows]
def _sqlite_rows(sqlite_conn: sqlite3.Connection, table: str) -> list[dict[str, Any]]:
sqlite_conn.row_factory = sqlite3.Row
rows = sqlite_conn.execute(f"SELECT * FROM {table} ORDER BY id ASC").fetchall()
return [dict(r) for r in rows]
async def migrate(sqlite_path: str, postgres_url: str, *, mode: str = "merge") -> None:
sqlite_conn = sqlite3.connect(sqlite_path)
sqlite_conn.row_factory = sqlite3.Row
pg_url = postgres_url.replace("postgresql+asyncpg://", "postgresql://")
pg = await asyncpg.connect(pg_url)
try:
pg_tables = await _pg_existing_tables(pg)
sqlite_tables = {
r[0]
for r in sqlite_conn.execute("SELECT name FROM sqlite_master WHERE type='table'").fetchall()
}
tables = [t for t in TABLE_ORDER if t in pg_tables and t in sqlite_tables]
if not tables:
raise RuntimeError("无可迁移表,请检查库路径与目标库")
print(f"[INFO] migrate tables: {tables}")
print(f"[INFO] mode: {mode}")
if mode == "replace":
# 清空目标核心表(含级联)
await pg.execute("TRUNCATE TABLE " + ", ".join(tables) + " RESTART IDENTITY CASCADE")
for table in tables:
pg_cols = await _pg_table_columns(pg, table)
pg_types = await _pg_table_types(pg, table)
sqlite_cols = _sqlite_columns(sqlite_conn, table)
common_cols = [c for c in pg_cols if c in sqlite_cols]
if not common_cols:
print(f"[WARN] skip {table}: no common cols")
continue
rows = _sqlite_rows(sqlite_conn, table)
inserted = 0
for row in rows:
row = _transform(table, row, pg_types)
values = [row.get(c) for c in common_cols]
placeholders = ", ".join(f"${i+1}" for i in range(len(common_cols)))
cols_sql = ", ".join(common_cols)
if mode == "merge" and "id" in common_cols and "id" in pg_cols:
sql = (
f"INSERT INTO {table} ({cols_sql}) VALUES ({placeholders}) "
"ON CONFLICT (id) DO NOTHING"
)
else:
sql = f"INSERT INTO {table} ({cols_sql}) VALUES ({placeholders})"
try:
await pg.execute(sql, *values)
inserted += 1
except Exception as exc:
print(f"[WARN] {table} row id={row.get('id')} failed: {exc}")
if "id" in common_cols:
await pg.execute(
f"SELECT setval(pg_get_serial_sequence('{table}','id'), COALESCE((SELECT MAX(id) FROM {table}), 1), true)"
)
print(f"[OK] {table}: {inserted}/{len(rows)}")
# 摘要
summary_tables = [
"users",
"projects",
"signals",
"truth_nodes",
"tasks",
"qa_sessions",
"qa_records",
"qa_feedback",
]
print("[SUMMARY]")
for table in summary_tables:
if table not in tables:
continue
count = await pg.fetchval(f"SELECT count(*) FROM {table}")
print(f" - {table}: {int(count or 0)}")
finally:
await pg.close()
sqlite_conn.close()
def main() -> int:
parser = argparse.ArgumentParser()
parser.add_argument("--sqlite", default="/app/backend/neural_sync.db")
parser.add_argument("--postgres", default=os.getenv("DATABASE_URL", ""))
parser.add_argument("--mode", choices=["merge", "replace"], default="merge")
args = parser.parse_args()
if not args.postgres:
print("[ERROR] DATABASE_URL 为空,请使用 --postgres 指定")
return 2
if not os.path.exists(args.sqlite):
print(f"[ERROR] SQLite 文件不存在: {args.sqlite}")
return 2
try:
asyncio.run(migrate(args.sqlite, args.postgres, mode=args.mode))
return 0
except Exception as exc:
print(f"[ERROR] migration failed: {exc}")
return 2
if __name__ == "__main__":
raise SystemExit(main())

View File

@ -1,36 +0,0 @@
import unittest
from app.services.adaptive_rag_service import AdaptiveRAGService
class AdaptiveRAGIntentCorrectionTest(unittest.TestCase):
def test_build_intent_correction_record_is_structured_and_stable(self) -> None:
first = AdaptiveRAGService._build_intent_correction_record(
user_utterance="不对,是新增进 PMFlow 的项目列表里面",
wrong_interpretation="signal_intake",
correct_intent="create_project",
canonical_action="project.create",
clarification_template="请补充项目名称、项目描述、项目范围。",
risk_level="high",
project_id=None,
)
second = AdaptiveRAGService._build_intent_correction_record(
user_utterance="不对,是新增进 PMFlow 的项目列表里面",
wrong_interpretation="signal_intake",
correct_intent="create_project",
canonical_action="project.create",
clarification_template="请补充项目名称、项目描述、项目范围。",
risk_level="high",
project_id=None,
)
self.assertEqual(first[0], second[0])
self.assertEqual(first[1], "意图纠错/create_project")
self.assertIn("用户原话:", first[2])
self.assertEqual(first[3]["record_type"], "intent_correction")
self.assertEqual(first[3]["correct_intent"], "create_project")
self.assertEqual(first[3]["canonical_action"], "project.create")
if __name__ == "__main__":
unittest.main()

View File

@ -1,86 +0,0 @@
import os
import unittest
from app.services.ai_agent_kernel import (
compute_capability_score,
gate_execution,
resolve_correction,
select_intent_route,
)
class AIAgentKernelTest(unittest.TestCase):
def test_select_intent_route_prefers_complex_for_multi_clause_instruction(self) -> None:
decision = select_intent_route(
"给我新增个项目,openclaw自动化部署,为了后续方便统一发布。",
project_names=["OpenClaw 自动化部署"],
)
self.assertEqual(decision.function_name, "classify_intent_complex")
self.assertEqual(decision.tier, "high")
self.assertTrue(decision.is_complex)
def test_select_intent_route_simple_instruction(self) -> None:
decision = select_intent_route("归档项目#51")
self.assertEqual(decision.function_name, "classify_intent_ai_pmo_kernel")
self.assertEqual(decision.tier, "low")
self.assertFalse(decision.is_complex)
def test_resolve_correction_detects_rollback(self) -> None:
result = resolve_correction("这条不是新增项目,回滚刚才那次操作。")
self.assertIn(result.action, {"rollback", "replace_target"})
self.assertGreaterEqual(result.confidence, 0.78)
self.assertTrue(result.matched_keywords)
def test_gate_execution_respects_threshold_and_risk(self) -> None:
old_auto = os.environ.get("AGENT_GATE_AUTO_THRESHOLD")
old_review = os.environ.get("AGENT_GATE_REVIEW_THRESHOLD")
old_review_below_auto = os.environ.get("AGENT_GATE_REVIEW_BELOW_AUTO")
os.environ["AGENT_GATE_AUTO_THRESHOLD"] = "0.82"
os.environ["AGENT_GATE_REVIEW_THRESHOLD"] = "0.60"
try:
low = gate_execution(confidence=0.42, risk_level="low")
self.assertEqual(low.action, "reject")
pending = gate_execution(confidence=0.70, risk_level="medium")
self.assertEqual(pending.action, "auto_execute")
high_risk = gate_execution(confidence=0.86, risk_level="high")
self.assertEqual(high_risk.action, "pending_manual")
allowed = gate_execution(confidence=0.93, risk_level="high")
self.assertEqual(allowed.action, "auto_execute")
os.environ["AGENT_GATE_REVIEW_BELOW_AUTO"] = "1"
pending_with_switch = gate_execution(confidence=0.70, risk_level="medium")
self.assertEqual(pending_with_switch.action, "pending_manual")
forced = gate_execution(
confidence=0.99,
risk_level="low",
force_manual=True,
force_manual_code="intent_conflict_write",
force_manual_reason="规则与LLM冲突",
)
self.assertEqual(forced.action, "pending_manual")
self.assertEqual(forced.gate_code, "intent_conflict_write")
finally:
if old_auto is None:
os.environ.pop("AGENT_GATE_AUTO_THRESHOLD", None)
else:
os.environ["AGENT_GATE_AUTO_THRESHOLD"] = old_auto
if old_review is None:
os.environ.pop("AGENT_GATE_REVIEW_THRESHOLD", None)
else:
os.environ["AGENT_GATE_REVIEW_THRESHOLD"] = old_review
if old_review_below_auto is None:
os.environ.pop("AGENT_GATE_REVIEW_BELOW_AUTO", None)
else:
os.environ["AGENT_GATE_REVIEW_BELOW_AUTO"] = old_review_below_auto
def test_compute_capability_score(self) -> None:
score = compute_capability_score(
intent_accuracy=0.82,
gate_precision=0.80,
exec_success=0.78,
correction_success=0.76,
)
self.assertAlmostEqual(score, 0.795, places=3)
if __name__ == "__main__":
unittest.main()

View File

@ -1,184 +0,0 @@
import os
import unittest
from tempfile import NamedTemporaryFile
from sqlalchemy import and_, select
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
from app.api.ai_logs import _apply_project_filter
from app.models import AIProcessingLog, Base, IngestEvent, Project, User, WorkItem
class AILogsApiTest(unittest.IsolatedAsyncioTestCase):
async def asyncSetUp(self) -> None:
tmp = NamedTemporaryFile(prefix="pmflow-ai-logs-test-", suffix=".db", delete=False)
tmp.close()
self.db_path = tmp.name
self.engine = create_async_engine(f"sqlite+aiosqlite:///{self.db_path}", future=True)
self.session_factory = async_sessionmaker(self.engine, expire_on_commit=False, class_=AsyncSession)
async with self.engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
async with self.session_factory() as db:
user = User(
tenant_id=1,
username="boss",
email="boss@example.com",
password_hash="hash",
name="老板",
role="BOSS",
assigned_domains=[],
workload=0,
status="active",
is_active=True,
)
db.add(user)
await db.flush()
project_a = Project(
tenant_id=1,
name="项目A",
title="项目A",
description="A",
owner_id=int(user.id),
status="active",
domain_id=None,
assigned_role="pm",
signal_count=0,
)
project_b = Project(
tenant_id=1,
name="项目B",
title="项目B",
description="B",
owner_id=int(user.id),
status="active",
domain_id=None,
assigned_role="pm",
signal_count=0,
)
db.add_all([project_a, project_b])
await db.flush()
ingest_a = IngestEvent(
tenant_id=1,
channel="feishu_message",
event_key="om-project-a",
status="done",
payload={},
retry_count=0,
error_message=None,
processed_at=None,
)
ingest_b = IngestEvent(
tenant_id=1,
channel="feishu_message",
event_key="om-project-b",
status="done",
payload={},
retry_count=0,
error_message=None,
processed_at=None,
)
db.add_all([ingest_a, ingest_b])
await db.flush()
db.add_all(
[
WorkItem(
tenant_id=1,
ingest_event_id=int(ingest_a.id),
source_system="feishu",
source_message_id="om-project-a",
state="succeeded",
intent="project.create",
decision_level="project",
project_id=int(project_a.id),
),
WorkItem(
tenant_id=1,
ingest_event_id=int(ingest_b.id),
source_system="feishu",
source_message_id="om-project-b",
state="succeeded",
intent="signal.create",
decision_level="project",
project_id=int(project_b.id),
),
]
)
db.add_all(
[
AIProcessingLog(
tenant_id=1,
feishu_message_id="om-project-a",
feishu_item_id=None,
raw_message="创建项目A",
ai_raw_output="{}",
project_score=None,
signal_confidence=None,
level="ai_pmo",
normalized_text="创建项目A",
route="ai_pmo.project.create",
intent="project.create",
executor="ai_pmo_kernel",
operation_code="project.create",
operation_result={"project_id": int(project_a.id)},
reply_source="action_succeeded",
routing_decision="ai_pmo.project.create",
routing_reason="AI PMO kernel route=ai_pmo.project.create",
reasoning_bullets=["work_item_id=1"],
processing_status="success",
error_message=None,
duration_ms=100,
),
AIProcessingLog(
tenant_id=1,
feishu_message_id="om-project-b",
feishu_item_id=None,
raw_message="创建信号B",
ai_raw_output="{}",
project_score=None,
signal_confidence=None,
level="ai_pmo",
normalized_text="创建信号B",
route="ai_pmo.signal.create",
intent="signal.create",
executor="ai_pmo_kernel",
operation_code="signal.enter",
operation_result={"project_id": int(project_b.id)},
reply_source="action_succeeded",
routing_decision="ai_pmo.signal.create",
routing_reason="AI PMO kernel route=ai_pmo.signal.create",
reasoning_bullets=["work_item_id=2"],
processing_status="success",
error_message=None,
duration_ms=100,
),
]
)
await db.commit()
async def asyncTearDown(self) -> None:
await self.engine.dispose()
if getattr(self, "db_path", None):
try:
os.remove(self.db_path)
except FileNotFoundError:
pass
async def test_apply_project_filter_includes_ai_pmo_work_item_message_ids(self) -> None:
async with self.session_factory() as db:
stmt = select(AIProcessingLog).where(AIProcessingLog.tenant_id == 1).order_by(AIProcessingLog.id.asc())
rows = (await db.execute(_apply_project_filter(stmt, tenant_id=1, project_id=1))).scalars().all()
self.assertEqual(len(rows), 1)
self.assertEqual(rows[0].feishu_message_id, "om-project-a")
async def test_apply_project_filter_keeps_other_project_logs_out(self) -> None:
async with self.session_factory() as db:
stmt = select(AIProcessingLog).where(AIProcessingLog.tenant_id == 1).order_by(AIProcessingLog.id.asc())
rows = (await db.execute(_apply_project_filter(stmt, tenant_id=1, project_id=2))).scalars().all()
self.assertEqual(len(rows), 1)
self.assertEqual(rows[0].feishu_message_id, "om-project-b")

View File

@ -1,312 +0,0 @@
import asyncio
import os
import time
import unittest
from datetime import datetime, timedelta, timezone
from tempfile import NamedTemporaryFile
from types import SimpleNamespace
from unittest.mock import AsyncMock, patch
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
from app.models import Base
from app.services.ai_orchestration_run_service import AIOrchestrationRunService
from app.services.signal_source_orchestration_service import SignalSourceOrchestrationService
def _utc_now_naive() -> datetime:
return datetime.now(timezone.utc).replace(tzinfo=None)
class _FastModeSignalService(SignalSourceOrchestrationService):
session_factory: async_sessionmaker[AsyncSession] | None = None
async def _load_project(self, **_: object):
return SimpleNamespace(id=3, title="项目A", name="项目A", description="desc", owner_id=1)
async def _load_signal(self, **_: object):
return SimpleNamespace(
id=9,
title="新增 GitHub 信号源",
summary="监控 issue",
signal_type="新需求",
recommended_action="enter_only",
content="新增 GitHub 信号源并自动拆任务",
)
async def _plan_onboarding(self, **_: object):
return (
{
"should_onboard": True,
"summary": "接入 GitHub 仓库信号源。",
"source": {
"source_kind": "github",
"source_name": "核心仓库",
"source_identifier": "github:openai/codex",
"description": "监控 issue 与 PR",
},
"worker_roles": ["pm_dispatcher", "tech_architect", "delivery_coach"],
"openclaw_instruction": "创建接入任务。",
},
"planned",
{
"used_fallback": False,
"error_code": None,
"source_field": "message.content",
"provider": "glm",
"model": "GLM-4.7",
},
)
async def _create_or_reuse_registry(self, **_: object):
return {
"status": "created",
"registry_id": 11,
"source_kind": "github",
"source_name": "核心仓库",
"source_identifier": "github:openai/codex",
"description": "监控 issue 与 PR",
}
async def _resolve_operator_user(self, **_: object):
return SimpleNamespace(id=1, role="BOSS", name="Boss", username="boss")
async def _dispatch_openclaw(self, **_: object):
return {
"status": "completed",
"result": {"trace_id": "openclaw-123"},
}
@classmethod
async def _execute_workers_and_finalize(cls, **kwargs: object):
await asyncio.sleep(0.2)
assert cls.session_factory is not None
async with cls.session_factory() as db:
service = cls(db)
trace_id = str(kwargs["trace_id"])
summary = str(kwargs["summary"])
planner_status = str(kwargs["planner_status"])
planner_diagnostics = kwargs["planner_diagnostics"]
registry_result = dict(kwargs["registry_result"])
openclaw_result = dict(kwargs["openclaw_result"])
worker_role_keys = list(kwargs["worker_role_keys"])
worker_results = [
{
"worker_role_key": role_key,
"worker_role_name": role_key,
"status": "completed",
"summary": f"{role_key} done",
}
for role_key in worker_role_keys
]
final_result = service._build_orchestration_result(
trace_id=trace_id,
response_mode="fast",
planner_status=planner_status,
planner_diagnostics=planner_diagnostics,
summary=summary,
registry_result=registry_result,
source_payload=registry_result,
openclaw_result=openclaw_result,
worker_results=worker_results,
pending_workers=[],
status="completed",
run_status="completed",
)
await AIOrchestrationRunService(db).update_run(
tenant_id=int(kwargs["tenant_id"]),
trace_id=trace_id,
status="completed",
planner_status=planner_status,
openclaw_status=str(openclaw_result.get("status") or ""),
worker_total=len(worker_role_keys),
worker_completed=len(worker_role_keys),
worker_failed=0,
summary=summary,
planner_diagnostics=planner_diagnostics if isinstance(planner_diagnostics, dict) else {},
result_payload=final_result,
completed_at=_utc_now_naive(),
)
await db.commit()
return final_result
class _ConcurrentWorkerSignalService(SignalSourceOrchestrationService):
@classmethod
async def _execute_single_worker_role_isolated(cls, **kwargs: object):
await asyncio.sleep(0.2)
role = dict(kwargs["role"])
return {
"worker_role_key": str(role["role_key"]),
"worker_role_name": str(role["role_name"]),
"status": "completed",
"summary": f"{role['role_key']} done",
}
class AIOrchestrationRunServiceTest(unittest.IsolatedAsyncioTestCase):
async def asyncSetUp(self) -> None:
tmp = NamedTemporaryFile(prefix="pmflow-orchestration-test-", suffix=".db", delete=False)
tmp.close()
self.db_path = tmp.name
self.engine = create_async_engine(f"sqlite+aiosqlite:///{self.db_path}", future=True)
self.session_factory = async_sessionmaker(self.engine, expire_on_commit=False, class_=AsyncSession)
async with self.engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
_FastModeSignalService.session_factory = self.session_factory
_ConcurrentWorkerSignalService.session_factory = self.session_factory
async def asyncTearDown(self) -> None:
await self.engine.dispose()
if getattr(self, "db_path", None):
try:
os.remove(self.db_path)
except FileNotFoundError:
pass
async def test_fast_mode_returns_before_background_workers_finish_and_run_is_queryable(self) -> None:
async with self.session_factory() as db:
service = _FastModeSignalService(db)
started = time.perf_counter()
result = await service.orchestrate_from_signal(
tenant_id=1,
project_id=3,
signal_id=9,
initiating_text="新增一个 GitHub 信号源,监控核心仓库 issue。",
requested_via="pmflow",
operator_user_id=1,
dry_run=False,
response_mode="fast",
)
elapsed = time.perf_counter() - started
self.assertLess(elapsed, 0.15)
self.assertEqual(result["run_status"], "running")
self.assertEqual(len(result["pending_workers"]), 3)
async with self.session_factory() as db:
row = await AIOrchestrationRunService(db).get_run(tenant_id=1, trace_id=result["trace_id"])
self.assertIsNotNone(row)
self.assertEqual(row.status, "running")
await asyncio.sleep(0.3)
async with self.session_factory() as db:
row = await AIOrchestrationRunService(db).get_run(tenant_id=1, trace_id=result["trace_id"])
snapshot = AIOrchestrationRunService.serialize_run(row)
self.assertEqual(snapshot["run_status"], "completed")
self.assertEqual(snapshot["worker_completed"], 3)
self.assertEqual(snapshot["worker_pending"], 0)
async def test_complete_mode_worker_execution_is_concurrent(self) -> None:
async with self.session_factory() as db:
service = _ConcurrentWorkerSignalService(db)
initial_result = service._build_orchestration_result(
trace_id="trace-concurrent",
response_mode="complete",
planner_status="planned",
planner_diagnostics={"used_fallback": False},
summary="接入 GitHub 仓库信号源。",
registry_result={"registry_id": 11, "status": "created"},
source_payload={
"source_kind": "github",
"source_name": "核心仓库",
"source_identifier": "github:openai/codex",
},
openclaw_result={"status": "completed", "result": {"trace_id": "openclaw-123"}},
worker_results=[],
pending_workers=["pm_dispatcher", "tech_architect", "delivery_coach"],
status="partial",
run_status="running",
)
await AIOrchestrationRunService(db).create_run(
tenant_id=1,
trace_id="trace-concurrent",
run_type="signal_source_onboarding",
project_id=3,
signal_id=9,
response_mode="complete",
status="running",
planner_status="planned",
openclaw_status="completed",
worker_total=3,
worker_completed=0,
worker_failed=0,
summary="接入 GitHub 仓库信号源。",
planner_diagnostics={"used_fallback": False},
result_payload=initial_result,
created_by_user_id=1,
)
await db.commit()
fake_roles = {
"roles": [
{"role_key": "pm_dispatcher", "role_name": "PM"},
{"role_key": "tech_architect", "role_name": "Arch"},
{"role_key": "delivery_coach", "role_name": "Coach"},
]
}
with patch("app.services.signal_source_orchestration_service.prompt_profile_service.get_profiles", AsyncMock(return_value=fake_roles)):
started = time.perf_counter()
final_result = await _ConcurrentWorkerSignalService._execute_workers_and_finalize(
tenant_id=1,
trace_id="trace-concurrent",
project_payload={"id": 3, "title": "项目A", "name": "项目A", "description": "desc", "owner_id": 1},
signal_payload={"id": 9, "title": "新增 GitHub 信号源", "summary": "监控 issue", "signal_type": "新需求", "recommended_action": "enter_only", "content": "content"},
registry_result={
"registry_id": 11,
"status": "created",
"source_kind": "github",
"source_name": "核心仓库",
"source_identifier": "github:openai/codex",
"description": "监控 issue 与 PR",
},
openclaw_result={"status": "completed", "result": {"trace_id": "openclaw-123"}},
worker_role_keys=["pm_dispatcher", "tech_architect", "delivery_coach"],
operator_user_payload={"id": 1, "name": "Boss", "username": "boss", "role": "BOSS"},
planner_status="planned",
planner_diagnostics={"used_fallback": False},
summary="接入 GitHub 仓库信号源。",
signal_id=None,
)
elapsed = time.perf_counter() - started
self.assertLess(elapsed, 0.45)
self.assertEqual(final_result["status"], "completed")
self.assertEqual(len(final_result["worker_executions"]), 3)
async def test_mark_stale_runs_as_interrupted(self) -> None:
async with self.session_factory() as db:
service = AIOrchestrationRunService(db)
await service.create_run(
tenant_id=1,
trace_id="trace-stale",
run_type="signal_source_onboarding",
project_id=3,
signal_id=9,
response_mode="fast",
status="running",
planner_status="planned",
openclaw_status="completed",
worker_total=3,
worker_completed=1,
worker_failed=0,
summary="stale run",
planner_diagnostics={"used_fallback": False},
result_payload={"pending_workers": ["pm_dispatcher"], "run_status": "running"},
created_by_user_id=1,
started_at=_utc_now_naive() - timedelta(minutes=11),
)
await db.commit()
updated = await service.mark_stale_runs_interrupted(older_than_minutes=10)
await db.commit()
row = await service.get_run(tenant_id=1, trace_id="trace-stale")
self.assertEqual(updated, 1)
self.assertEqual(row.status, "interrupted")
if __name__ == "__main__":
unittest.main()

View File

@ -1,640 +0,0 @@
import json
import os
import unittest
from datetime import datetime
from tempfile import NamedTemporaryFile
from unittest.mock import AsyncMock, patch
from sqlalchemy import and_, select
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
from app.api.users import _create_user_with_defaults
from app.models import (
Base,
Budget,
Project,
ProjectDomain,
ProjectSignalLink,
Signal,
SkillTag,
Task,
TruthNode,
User,
UserProject,
UserSkillBinding,
UserWorkload,
WorkItem,
)
from app.schemas import UserCreate
from app.services.ai_pmo_cadence_service import AIPMOCadenceService
from app.services.ai_pmo_kernel_service import AIPMOKernelService
from app.services.signal_capture import ProactiveSignalCaptureService
from app.services.skill_service import recommend_personnel
from app.services import workload_service
class AIPMOFullScenarioTest(unittest.IsolatedAsyncioTestCase):
async def asyncSetUp(self) -> None:
tmp = NamedTemporaryFile(prefix="pmflow-ai-pmo-scenario-", suffix=".db", delete=False)
tmp.close()
self.db_path = tmp.name
self.engine = create_async_engine(f"sqlite+aiosqlite:///{self.db_path}", future=True)
self.session_factory = async_sessionmaker(self.engine, expire_on_commit=False, class_=AsyncSession)
async with self.engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
async with self.session_factory() as db:
self.policy_skill_id = await self._create_skill_tag(db, name="政策分析", slug="policy-analysis")
self.research_skill_id = await self._create_skill_tag(db, name="行业研究", slug="market-research")
self.web_skill_id = await self._create_skill_tag(db, name="爬虫采集", slug="web-crawling")
self.graph_skill_id = await self._create_skill_tag(db, name="知识图谱", slug="knowledge-graph")
self.pipeline_skill_id = await self._create_skill_tag(db, name="数据管道", slug="data-pipeline")
boss = User(
tenant_id=1,
username="boss",
email="boss@example.com",
password_hash="hash",
name="老板",
role="BOSS",
assigned_domains=[],
workload=0,
status="active",
is_active=True,
)
db.add(boss)
await db.flush()
self.boss_id = int(boss.id)
domain = ProjectDomain(
tenant_id=1,
name="医药",
slug="pharma",
description="医药产业情报域",
config={},
is_active=True,
is_default=False,
signal_count=0,
context_node_count=0,
experiment_count=0,
owner_id=str(boss.id),
)
db.add(domain)
await db.flush()
self.domain_id = int(domain.id)
project = Project(
tenant_id=1,
name="医药行业十五五规划布局研究",
title="医药行业十五五规划布局研究",
description="跟踪医保支付改革、器械招采与创新药方向。",
owner_id=int(boss.id),
status="active",
domain_id=int(domain.id),
assigned_role="pm",
signal_count=0,
)
db.add(project)
await db.flush()
self.base_project_id = int(project.id)
charter = TruthNode(
tenant_id=1,
node_type="charter",
project_id=int(project.id),
signal_id=None,
created_by=int(boss.id),
title="医药研究项目宪章",
content={"success_criteria": ["两周内形成华东医保支付改革观察报告"]},
version=1,
status="active",
)
db.add(charter)
await db.flush()
self.base_charter_id = int(charter.id)
db.add(
Budget(
tenant_id=1,
budget_type="project",
project_id=int(project.id),
period="monthly",
budget_amount_usd=1500.0,
alert_threshold_percent=80.0,
hard_limit_enabled=True,
current_usage_usd=620.0,
current_period_start=datetime(2026, 3, 1),
current_period_end=datetime(2026, 3, 31),
status="active",
override_history={},
)
)
db.add(UserProject(tenant_id=1, user_id=int(boss.id), project_id=int(project.id)))
db.add(
UserWorkload(
tenant_id=1,
user_id=int(boss.id),
current_assigned_hours=18.0,
max_available_hours_per_week=40.0,
workload_ratio=0.0,
)
)
await self._bind_skill(db, user_id=int(boss.id), skill_tag_id=self.policy_skill_id, level="advanced", proficiency=86)
await self._bind_skill(db, user_id=int(boss.id), skill_tag_id=self.research_skill_id, level="expert", proficiency=92)
db.add(
Task(
tenant_id=1,
truth_node_id=int(charter.id),
project_id=int(project.id),
assignee_id=int(boss.id),
ai_recommended_assignees=None,
title="拆解华东医保支付改革样本",
description="需要比对江苏/浙江 DRG-DIP 改革口径和器械招采变化。",
required_skill_tag_ids=[self.policy_skill_id, self.web_skill_id],
status="blocked",
priority="high",
estimated_hours=12.0,
actual_hours=0.0,
quality_score=None,
due_date=datetime(2026, 3, 12),
completed_at=None,
)
)
await workload_service.recalculate_user_workload_ratio(db, tenant_id=1, user_id=int(boss.id), commit=False)
await db.commit()
async def asyncTearDown(self) -> None:
await self.engine.dispose()
if getattr(self, "db_path", None):
try:
os.remove(self.db_path)
except FileNotFoundError:
pass
async def _create_skill_tag(self, db: AsyncSession, *, name: str, slug: str) -> int:
row = SkillTag(
tenant_id=1,
name=name,
slug=slug,
category="skill",
description=f"{name}能力",
is_system=False,
sort_order=0,
)
db.add(row)
await db.flush()
return int(row.id)
async def _bind_skill(
self,
db: AsyncSession,
*,
user_id: int,
skill_tag_id: int,
level: str,
proficiency: int,
) -> None:
db.add(
UserSkillBinding(
tenant_id=1,
user_id=int(user_id),
skill_tag_id=int(skill_tag_id),
proficiency_level=level,
proficiency=int(proficiency),
level=level,
years_experience=5.0,
quality_score=8.5,
efficiency_score=8.0,
)
)
await db.flush()
async def _set_workload_hours(self, db: AsyncSession, *, user_id: int, assigned_hours: float) -> None:
workload = (
await db.execute(
select(UserWorkload).where(
and_(
UserWorkload.tenant_id == 1,
UserWorkload.user_id == int(user_id),
)
)
)
).scalar_one()
workload.current_assigned_hours = float(assigned_hours)
await workload_service.recalculate_user_workload_ratio(db, tenant_id=1, user_id=int(user_id), commit=False)
async def test_full_difficult_scenario_covers_feishu_context_project_signal_workflow_and_workload(self) -> None:
async with self.session_factory() as db:
strategy_user = await _create_user_with_defaults(
db,
tenant_id=1,
user_data=UserCreate(
username="hanqing",
password="password-1",
name="韩青",
email="hanqing@example.com",
role="ADMIN",
assigned_domains=["pharma"],
project_ids=[self.base_project_id],
),
actor_id=self.boss_id,
commit=False,
)
crawler_user = await _create_user_with_defaults(
db,
tenant_id=1,
user_data=UserCreate(
username="zhouzhan",
password="password-2",
name="周湛",
email="zhouzhan@example.com",
role="BE",
assigned_domains=["pharma"],
project_ids=[],
),
actor_id=self.boss_id,
commit=False,
)
graph_user = await _create_user_with_defaults(
db,
tenant_id=1,
user_data=UserCreate(
username="guyan",
password="password-3",
name="顾言",
email="guyan@example.com",
role="AI",
assigned_domains=["pharma"],
project_ids=[],
),
actor_id=self.boss_id,
commit=False,
)
await self._bind_skill(db, user_id=int(strategy_user.id), skill_tag_id=self.policy_skill_id, level="expert", proficiency=93)
await self._bind_skill(db, user_id=int(strategy_user.id), skill_tag_id=self.research_skill_id, level="expert", proficiency=90)
await self._bind_skill(db, user_id=int(crawler_user.id), skill_tag_id=self.web_skill_id, level="expert", proficiency=95)
await self._bind_skill(db, user_id=int(crawler_user.id), skill_tag_id=self.policy_skill_id, level="advanced", proficiency=84)
await self._bind_skill(db, user_id=int(crawler_user.id), skill_tag_id=self.pipeline_skill_id, level="advanced", proficiency=88)
await self._bind_skill(db, user_id=int(graph_user.id), skill_tag_id=self.graph_skill_id, level="expert", proficiency=91)
await self._bind_skill(db, user_id=int(graph_user.id), skill_tag_id=self.pipeline_skill_id, level="advanced", proficiency=82)
await self._set_workload_hours(db, user_id=int(strategy_user.id), assigned_hours=28.0)
await self._set_workload_hours(db, user_id=int(crawler_user.id), assigned_hours=8.0)
await self._set_workload_hours(db, user_id=int(graph_user.id), assigned_hours=10.0)
await db.commit()
kernel = AIPMOKernelService(db, tenant_id=1)
capture_service = ProactiveSignalCaptureService(db)
hard_question = (
"项目ID: 1 如果把样本窗口收紧到 2025Q4-2026Q1,只看江苏/浙江的 DRG-DIP 支付、"
"三甲院器械招采与设备更新预算,当前状态下最可能拖慢结论闭环的到底是预算、"
"blocked task 还是技能缺口?为什么?别给套话,按证据优先级直接判断。"
)
with (
patch(
"app.services.ai_pmo_kernel_service.AdaptiveRAGService.answer_question",
AsyncMock(
return_value={
"answer": "优先级 1 是 blocked task,其次是技能缺口,预算暂未触顶。",
"sources": [],
}
),
),
patch(
"app.services.signal_capture.context_cache_service.get_session_messages",
AsyncMock(return_value=[]),
),
patch(
"app.services.signal_capture.context_cache_service.append_session_messages",
AsyncMock(),
) as cache_append_mock,
patch(
"app.services.signal_capture.AdaptiveRAGService.ingest_feishu_message_async",
AsyncMock(),
),
):
qa_result = await capture_service.capture_from_feishu(
group_chat_id="oc_complex_case",
message_content=hard_question,
sender="老板",
tenant_id=1,
proposer_open_id="ou_boss",
captured_at=datetime(2026, 3, 9, 9, 0, 0),
feishu_message_id="om-qa-hard-1",
chat_type="group",
)
async def create_project_side_effect(*, input_text: str, creator_id: int, tenant_id: int) -> dict:
project = Project(
tenant_id=int(tenant_id),
name="华东医保控费与器械国产替代观察台",
title="华东医保控费与器械国产替代观察台",
description="聚焦华东 DRG-DIP、器械带量采购和国产替代节奏。",
owner_id=int(creator_id),
status="active",
domain_id=self.domain_id,
assigned_role="ai_engineer",
signal_count=0,
)
db.add(project)
await db.flush()
charter = TruthNode(
tenant_id=1,
node_type="charter",
project_id=int(project.id),
signal_id=None,
created_by=int(creator_id),
title="华东医保观察项目宪章",
content={"success_criteria": ["10 天内交付策略 memo、政策雷达和竞品跟踪框架"]},
version=1,
status="active",
)
db.add(charter)
await db.flush()
bootstrap_signal = Signal(
tenant_id=1,
source="PMFlow",
title="医保控费与器械国产替代立项信号",
content=input_text,
summary="立项信号",
signal_type="新需求",
priority="high",
impact_score=0.85,
matched_project_id=int(project.id),
reasoning=json.dumps(["来自高强度情景立项指令"], ensure_ascii=False),
confidence=0.92,
recommended_action="enter_only",
processed=True,
tasks_generated=json.dumps([], ensure_ascii=False),
status="已处理",
)
db.add(bootstrap_signal)
await db.flush()
db.add(
ProjectSignalLink(
tenant_id=1,
project_id=int(project.id),
signal_id=int(bootstrap_signal.id),
link_type="related",
relevance_score=1.0,
)
)
db.add(
Budget(
tenant_id=1,
budget_type="project",
project_id=int(project.id),
period="monthly",
budget_amount_usd=3200.0,
alert_threshold_percent=80.0,
hard_limit_enabled=True,
current_usage_usd=240.0,
current_period_start=datetime(2026, 3, 1),
current_period_end=datetime(2026, 3, 31),
status="active",
override_history={},
)
)
db.add(UserProject(tenant_id=1, user_id=int(creator_id), project_id=int(project.id)))
task_one = Task(
tenant_id=1,
truth_node_id=int(charter.id),
project_id=int(project.id),
assignee_id=None,
ai_recommended_assignees=None,
title="搭建政策抓取与公告解析管道",
description="每天跟踪江苏/浙江/安徽 医保支付改革、招采公告与价格体系拐点。",
required_skill_tag_ids=[self.policy_skill_id, self.web_skill_id],
status="todo",
priority="high",
estimated_hours=18.0,
actual_hours=0.0,
quality_score=None,
due_date=datetime(2026, 3, 19),
completed_at=None,
)
task_two = Task(
tenant_id=1,
truth_node_id=int(charter.id),
project_id=int(project.id),
assignee_id=None,
ai_recommended_assignees=None,
title="沉淀器械中标结构知识图谱",
description="把区域政策、招采公告和竞品动作串成结构化图谱。",
required_skill_tag_ids=[self.graph_skill_id],
status="todo",
priority="medium",
estimated_hours=12.0,
actual_hours=0.0,
quality_score=None,
due_date=datetime(2026, 3, 20),
completed_at=None,
)
db.add_all([task_one, task_two])
await db.flush()
return {
"project": {"id": int(project.id), "title": project.title, "name": project.name},
"signal": {"id": int(bootstrap_signal.id), "title": bootstrap_signal.title},
"tasks": [
{"id": int(task_one.id), "title": task_one.title},
{"id": int(task_two.id), "title": task_two.title},
],
}
with patch(
"app.services.ai_pmo_kernel_service.AIStewardService.create_project_from_natural_language",
AsyncMock(side_effect=create_project_side_effect),
):
project_result = await kernel.process_ingress(
source_system="feishu",
source_message_id="om-project-hard-1",
message_text=(
"不要复用现在这个宏观研究盘子,单独立一个项目,名字就叫"
"“华东医保控费与器械国产替代观察台”,目标不是写周报,"
"而是 10 天内拿出策略 memo、政策雷达和竞品跟踪框架。"
),
sender="老板",
channel_ref="oc_complex_case",
source_sender_open_id="ou_boss",
)
new_project_id = int(project_result["created_project_id"])
signal_result = await kernel.process_ingress(
source_system="feishu",
source_message_id="om-signal-hard-1",
message_text=(
f"给项目ID: {new_project_id} 新增一条信号:持续盯江苏、浙江、安徽 2026 年器械带量采购、"
"DRG-DIP 支付改革和国产替代中标结构,只要价格体系或准入口径出现拐点就预警,"
"不要只抓新闻标题。"
),
sender="老板",
channel_ref="oc_complex_case",
source_sender_open_id="ou_boss",
project_hint_id=new_project_id,
)
source_result = await kernel.process_ingress(
source_system="feishu",
source_message_id="om-source-hard-1",
message_text=(
f"给项目ID: {new_project_id} 接一个复合信号源,不是简单 RSS:每天自动抓医保支付改革、"
"器械招采公告、上市公司电话会和 GitHub 开源竞品动态,优先走自动接入。"
),
sender="老板",
channel_ref="oc_complex_case",
source_sender_open_id="ou_boss",
project_hint_id=new_project_id,
)
db.add_all(
[
UserProject(tenant_id=1, user_id=int(strategy_user.id), project_id=new_project_id),
UserProject(tenant_id=1, user_id=int(crawler_user.id), project_id=new_project_id),
UserProject(tenant_id=1, user_id=int(graph_user.id), project_id=new_project_id),
]
)
await db.flush()
with patch(
"app.services.ai_pmo_kernel_service.SignalSourceDomainWorkflow.run",
AsyncMock(
return_value={
"status": "completed",
"registry_id": 9,
"registry_status": "active",
"onboarding_status": "completed",
"openclaw_status": "completed",
"typed_actions": [{"operation_code": "task.create", "status": "applied"}],
"material_action_count": 1,
"worker_executions": [{"used_fallback": False}],
"pending_workers": [],
"next_step": "继续自动执行后续任务",
"fallback_used": False,
"raw_result": {},
}
),
) as onboarding_run_mock:
resume_result = await kernel.process_ingress(
source_system="ai_pmo_ui",
source_message_id="resume-work-item-hard-1",
message_text=f"继续处理 work_item #{int(source_result['work_item_id'])}",
sender="老板",
channel_ref="ai_pmo_ui",
project_hint_id=new_project_id,
)
target_work_item = (
await db.execute(
select(WorkItem).where(
and_(
WorkItem.tenant_id == 1,
WorkItem.id == int(source_result["work_item_id"]),
)
)
)
).scalar_one()
new_project_tasks = (
await db.execute(
select(Task)
.where(and_(Task.tenant_id == 1, Task.project_id == new_project_id))
.order_by(Task.id.asc())
)
).scalars().all()
task_policy_stream = next(task for task in new_project_tasks if "抓取" in task.title)
task_graph = next(task for task in new_project_tasks if "知识图谱" in task.title)
recommendation_result = await recommend_personnel(
db=db,
tenant_id=1,
required_skills=["policy-analysis", "web-crawling"],
task_description=task_policy_stream.description,
task_id=str(task_policy_stream.id),
limit=5,
)
with patch(
"app.services.workload_service.llm_service.generate",
AsyncMock(
return_value=json.dumps(
{
"proposals": [
{
"task_id": int(task_policy_stream.id),
"new_assignee_id": int(crawler_user.id),
"reason": "crawler engineer has exact skill mix and lower load",
},
{
"task_id": int(task_graph.id),
"new_assignee_id": int(graph_user.id),
"reason": "graph engineer owns ontology and extraction pipeline",
},
]
},
ensure_ascii=False,
)
),
):
optimize_preview = await workload_service.build_ai_optimize_preview(
db,
tenant_id=1,
project_id=new_project_id,
max_moves=5,
unassigned_only=True,
)
optimize_result = await workload_service.apply_ai_optimize_plan(
db,
tenant_id=1,
proposals=optimize_preview,
)
board_rows = await workload_service.get_team_workload_board(db, tenant_id=1, project_id=new_project_id)
cadence_service = AIPMOCadenceService(db, tenant_id=1)
program_view = await cadence_service.build_program_view()
self.assertEqual(qa_result["status"], "answered")
self.assertIn("blocked task", qa_result["direct_reply_text"])
cache_append_mock.assert_awaited_once()
self.assertEqual(cache_append_mock.await_args.kwargs["question"], hard_question)
self.assertEqual(project_result["status"], "captured")
self.assertIsNotNone(project_result["created_project_id"])
self.assertIn("已立项", project_result["direct_reply_text"])
self.assertEqual(signal_result["status"], "captured")
self.assertEqual(signal_result["linked_project_ids"], [new_project_id])
self.assertIsNotNone(signal_result["signal_id"])
self.assertEqual(source_result["status"], "captured")
self.assertIn("技能缺口", source_result["direct_reply_text"])
self.assertFalse(bool(source_result["review_summary"]["verified"]))
self.assertEqual(onboarding_run_mock.await_count, 1)
self.assertEqual(resume_result["status"], "answered")
self.assertIn("已继续处理", resume_result["direct_reply_text"])
self.assertEqual(target_work_item.state, "succeeded")
self.assertEqual(target_work_item.execution_result["signal_source_workflow"]["status"], "completed")
self.assertGreaterEqual(recommendation_result["total_count"], 1)
self.assertEqual(recommendation_result["recommendations"][0]["user_name"], "周湛")
self.assertGreaterEqual(len(optimize_preview), 2)
self.assertEqual(optimize_result["applied_count"], 2)
task_assignee_map = {int(task.id): int(task.assignee_id or 0) for task in new_project_tasks}
self.assertEqual(task_assignee_map[int(task_policy_stream.id)], int(crawler_user.id))
self.assertEqual(task_assignee_map[int(task_graph.id)], int(graph_user.id))
board_by_name = {str(row["user_name"]): row for row in board_rows}
self.assertIn("周湛", board_by_name)
self.assertIn("顾言", board_by_name)
self.assertGreater(float(board_by_name["周湛"]["current_assigned_hours"]), 8.0)
self.assertGreaterEqual(float(board_by_name["顾言"]["current_assigned_hours"]), 9.5)
self.assertGreaterEqual(program_view["summary"]["program_count"], 1)
self.assertTrue(
any(new_project_id in program["project_ids"] for program in program_view["programs"])
)

File diff suppressed because it is too large Load Diff

View File

@ -1,535 +0,0 @@
import os
import unittest
from tempfile import NamedTemporaryFile
from types import SimpleNamespace
from unittest.mock import patch
from sqlalchemy import and_, select
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
from app.api.ai_pmo import PMOWorkItemDecisionRequest, _normalize_resume_result, decide_work_item, list_work_items
from app.models import AIProcessingLog, Base, DecisionRecord, IngestEvent, QARecord, User, WorkItem
class AIPMOWorkItemDecisionApiTest(unittest.IsolatedAsyncioTestCase):
async def asyncSetUp(self) -> None:
tmp = NamedTemporaryFile(prefix="pmflow-ai-pmo-decision-api-test-", suffix=".db", delete=False)
tmp.close()
self.db_path = tmp.name
self.engine = create_async_engine(f"sqlite+aiosqlite:///{self.db_path}", future=True)
self.session_factory = async_sessionmaker(self.engine, expire_on_commit=False, class_=AsyncSession)
async with self.engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
async def asyncTearDown(self) -> None:
await self.engine.dispose()
if getattr(self, "db_path", None):
try:
os.remove(self.db_path)
except FileNotFoundError:
pass
def test_normalize_resume_result_maps_answered_to_succeeded(self) -> None:
target = SimpleNamespace(
id=99,
state="waiting_manual",
intent="signal.create",
project_id=7,
signal_id=None,
)
raw = {
"handled": True,
"status": "answered",
"direct_reply_text": "已继续处理:信号已入库。",
"results": [{"state": "succeeded"}],
"linked_project_ids": [7],
"signal_id": 13,
"auto_tasks": [],
}
normalized = _normalize_resume_result(raw=raw, target_work_item=target)
self.assertTrue(normalized["handled"])
self.assertEqual(normalized["status"], "succeeded")
self.assertEqual(normalized["target_work_item_id"], 99)
self.assertEqual(normalized["target_state"], "succeeded")
self.assertEqual(normalized["project_id"], 7)
self.assertEqual(normalized["signal_id"], 13)
async def test_decision_reject_marks_waiting_manual_item_ignored(self) -> None:
async with self.session_factory() as db:
user = User(
tenant_id=1,
username="decision_boss",
email="decision_boss@example.com",
password_hash="hash",
name="决策人",
role="BOSS",
status="active",
is_active=True,
)
db.add(user)
await db.flush()
ingest = IngestEvent(
tenant_id=1,
channel="ai_pmo_ui_message",
event_key="decision-reject-1",
status="done",
payload={"message_text": "删除项目id 52"},
)
db.add(ingest)
await db.flush()
work_item = WorkItem(
tenant_id=1,
ingest_event_id=int(ingest.id),
source_system="ai_pmo_ui",
source_message_id="decision-reject-item-1",
state="waiting_manual",
intent="query.read_only",
project_id=52,
signal_id=None,
execution_result={"status": "waiting_manual"},
)
db.add(work_item)
await db.flush()
decision = DecisionRecord(
tenant_id=1,
work_item_id=int(work_item.id),
decision_object_type="project",
decision_object_id=52,
decision_level="project",
policy_gate_hit=True,
policy_gate_code="delete_or_archive",
approval_requirement={"required": True},
)
db.add(decision)
await db.commit()
response = await decide_work_item(
work_item_id=int(work_item.id),
request=PMOWorkItemDecisionRequest(action="reject", reason="不同意执行,标记失效"),
current_user=user,
db=db,
tenant_id=1,
)
refreshed = (
await db.execute(
select(WorkItem).where(
and_(
WorkItem.tenant_id == 1,
WorkItem.id == int(work_item.id),
)
)
)
).scalar_one()
refreshed_decision = (
await db.execute(
select(DecisionRecord).where(
and_(
DecisionRecord.tenant_id == 1,
DecisionRecord.work_item_id == int(work_item.id),
)
)
)
).scalar_one()
self.assertEqual(response["code"], 0)
self.assertEqual(response["data"]["status"], "invalidated")
self.assertEqual(refreshed.state, "ignored")
self.assertEqual(refreshed.reply_template_key, "action_rejected")
self.assertEqual(refreshed_decision.final_outcome, "ignored")
async def test_list_work_items_returns_latest_policy_projection(self) -> None:
async with self.session_factory() as db:
user = User(
tenant_id=1,
username="decision_viewer",
email="decision_viewer@example.com",
password_hash="hash",
name="查看人",
role="BOSS",
status="active",
is_active=True,
)
db.add(user)
await db.flush()
ingest = IngestEvent(
tenant_id=1,
channel="ai_pmo_ui_message",
event_key="decision-list-1",
status="done",
payload={
"message_text": "按照这个分步骤加入到信号源中",
"closure_summary": "同一消息闭环内,用户要求将需求按步骤接入信号源,重点模块:SSTC 工作流、AI 能力编排。",
"closure_dialogue": "机器人:当前无法直接创建,请先确认项目。\n用户:按照这个分步骤加入到信号源中",
"closure_modules": ["SSTC 工作流", "AI 能力编排"],
},
)
db.add(ingest)
await db.flush()
work_item = WorkItem(
tenant_id=1,
ingest_event_id=int(ingest.id),
source_system="ai_pmo_ui",
source_message_id="decision-list-item-1",
state="waiting_manual",
intent="signal_source.onboard",
project_id=None,
signal_id=None,
execution_result={"status": "waiting_manual"},
)
db.add(work_item)
await db.flush()
db.add(
DecisionRecord(
tenant_id=1,
work_item_id=int(work_item.id),
decision_object_type="project",
decision_object_id=None,
decision_level="project",
policy_gate_hit=True,
policy_gate_code="legacy_gate",
approval_requirement={"required": True, "reason": "legacy_gate", "message": "旧规则"},
)
)
db.add(
DecisionRecord(
tenant_id=1,
work_item_id=int(work_item.id),
decision_object_type="project",
decision_object_id=None,
decision_level="project",
policy_gate_hit=True,
policy_gate_code="missing_project_scope",
approval_requirement={"required": True, "reason": "missing_project_scope", "message": "请选择关联项目"},
)
)
await db.commit()
response = await list_work_items(
limit=20,
state=None,
project_id=None,
current_user=user,
db=db,
tenant_id=1,
)
self.assertEqual(response["code"], 0)
items = response["data"]["items"]
self.assertEqual(len(items), 1)
self.assertEqual(items[0]["latest_policy_gate_code"], "missing_project_scope")
self.assertEqual(items[0]["approval_requirement"]["required"], True)
self.assertEqual(items[0]["approval_requirement"]["reason"], "missing_project_scope")
self.assertEqual(items[0]["approval_requirement"]["message"], "请选择关联项目")
self.assertEqual(items[0]["closure_summary"], "同一消息闭环内,用户要求将需求按步骤接入信号源,重点模块:SSTC 工作流、AI 能力编排。")
self.assertEqual(items[0]["implementation_modules"], ["SSTC 工作流", "AI 能力编排"])
self.assertIn("机器人:当前无法直接创建", str(items[0]["source_conversation_closure"]))
async def test_list_work_items_fallbacks_to_execution_result_when_decision_missing(self) -> None:
async with self.session_factory() as db:
user = User(
tenant_id=1,
username="decision_viewer_fallback",
email="decision_viewer_fallback@example.com",
password_hash="hash",
name="查看人",
role="BOSS",
status="active",
is_active=True,
)
db.add(user)
await db.flush()
ingest = IngestEvent(
tenant_id=1,
channel="ai_pmo_ui_message",
event_key="decision-list-fallback-1",
status="done",
payload={"message_text": "按步骤加入信号源"},
)
db.add(ingest)
await db.flush()
work_item = WorkItem(
tenant_id=1,
ingest_event_id=int(ingest.id),
source_system="ai_pmo_ui",
source_message_id="decision-list-fallback-item-1",
state="waiting_manual",
intent="signal_source.onboard",
project_id=None,
signal_id=None,
execution_result={
"status": "waiting_manual",
"policy_gate_code": "missing_project_scope",
"approval_requirement": {
"required": True,
"reason": "missing_project_scope",
"message": "请先确认项目归属",
},
},
)
db.add(work_item)
await db.commit()
response = await list_work_items(
limit=20,
state=None,
project_id=None,
current_user=user,
db=db,
tenant_id=1,
)
self.assertEqual(response["code"], 0)
items = response["data"]["items"]
self.assertEqual(len(items), 1)
self.assertEqual(items[0]["latest_policy_gate_code"], "missing_project_scope")
self.assertEqual(items[0]["approval_requirement"]["required"], True)
self.assertEqual(items[0]["approval_requirement"]["reason"], "missing_project_scope")
self.assertEqual(items[0]["approval_requirement"]["message"], "请先确认项目归属")
async def test_list_work_items_rebuilds_low_value_closure_from_persistent_logs(self) -> None:
async with self.session_factory() as db:
user = User(
tenant_id=1,
username="decision_viewer_closure_rebuild",
email="decision_viewer_closure_rebuild@example.com",
password_hash="hash",
name="查看人",
role="BOSS",
status="active",
is_active=True,
)
db.add(user)
await db.flush()
db.add(
QARecord(
tenant_id=1,
question="当前 PMFlow 项目已完成用户管理、SSTC 工作流和 AI 自动化编排。",
answer="核心模块包括用户管理、SSTC 工作流、AI 能力编排,建议按信号源接入流程继续推进。",
intent="question",
asked_by=int(user.id),
asked_via="feishu",
session_id=11,
turn_index=1,
)
)
ingest = IngestEvent(
tenant_id=1,
channel="feishu_message",
event_key="om-closure-rebuild-1",
status="done",
payload={
"message_text": "按照这个分步骤加入到信号源中",
"channel_ref": "oc_closure_rebuild",
"sender": "查看人",
"source_sender_open_id": "ou_closure_rebuild",
"closure_summary": "信号源接入缺少项目归属,必须先确认项目。",
"closure_dialogue": "用户:按照这个分步骤加入到信号源中",
"closure_modules": [],
"closure_turns": [{"role": "user", "text": "按照这个分步骤加入到信号源中"}],
},
)
db.add(ingest)
await db.flush()
work_item = WorkItem(
tenant_id=1,
ingest_event_id=int(ingest.id),
source_system="feishu",
source_message_id="om-closure-rebuild-1",
state="waiting_manual",
intent="signal_source.onboard",
owner_user_id=int(user.id),
project_id=None,
signal_id=None,
execution_result={"status": "waiting_manual"},
)
db.add(work_item)
await db.flush()
db.add(
AIProcessingLog(
tenant_id=1,
feishu_message_id="om-closure-rebuild-1",
raw_message="按照这个分步骤加入到信号源中",
normalized_text="按照这个分步骤加入到信号源中",
route="ai_pmo.waiting_manual",
intent="signal_source.onboard",
routing_decision="ai_pmo.waiting_manual",
processing_status="success",
operation_result={"reason": "信号源接入缺少项目归属,必须先确认项目。"},
)
)
await db.commit()
response = await list_work_items(
limit=20,
state=None,
project_id=None,
current_user=user,
db=db,
tenant_id=1,
)
self.assertEqual(response["code"], 0)
items = response["data"]["items"]
self.assertEqual(len(items), 1)
self.assertIn("机器人先提示条件不足,用户随后继续要求执行", str(items[0]["closure_summary"]))
self.assertIn("用户:按照这个分步骤加入到信号源中", str(items[0]["source_conversation_closure"]))
self.assertIn("机器人:信号源接入缺少项目归属,必须先确认项目。", str(items[0]["source_conversation_closure"]))
self.assertIn("SSTC 工作流", items[0]["implementation_modules"])
async def test_decision_approve_missing_project_scope_returns_fast_and_sets_executing(self) -> None:
async with self.session_factory() as db:
user = User(
tenant_id=1,
username="decision_approver",
email="decision_approver@example.com",
password_hash="hash",
name="审批人",
role="BOSS",
status="active",
is_active=True,
)
db.add(user)
await db.flush()
ingest = IngestEvent(
tenant_id=1,
channel="ai_pmo_ui_message",
event_key="decision-approve-fast-1",
status="done",
payload={"message_text": "按分步骤加入信号源"},
)
db.add(ingest)
await db.flush()
work_item = WorkItem(
tenant_id=1,
ingest_event_id=int(ingest.id),
source_system="ai_pmo_ui",
source_message_id="decision-approve-fast-item-1",
state="waiting_manual",
intent="signal_source.onboard",
project_id=None,
signal_id=None,
execution_result={"status": "waiting_manual"},
)
db.add(work_item)
await db.flush()
db.add(
DecisionRecord(
tenant_id=1,
work_item_id=int(work_item.id),
decision_object_type="project",
decision_object_id=None,
decision_level="project",
policy_gate_hit=True,
policy_gate_code="missing_project_scope",
approval_requirement={"required": True, "reason": "missing_project_scope", "message": "请选择项目"},
)
)
await db.commit()
with patch("app.api.ai_pmo._queue_resume_work_item") as mocked_queue:
response = await decide_work_item(
work_item_id=int(work_item.id),
request=PMOWorkItemDecisionRequest(action="approve", project_id=8),
current_user=user,
db=db,
tenant_id=1,
)
mocked_queue.assert_called_once()
refreshed = (
await db.execute(
select(WorkItem).where(
and_(
WorkItem.tenant_id == 1,
WorkItem.id == int(work_item.id),
)
)
)
).scalar_one()
refreshed_decision = (
await db.execute(
select(DecisionRecord).where(
and_(
DecisionRecord.tenant_id == 1,
DecisionRecord.work_item_id == int(work_item.id),
)
)
)
).scalar_one()
self.assertEqual(response["code"], 0)
self.assertEqual(response["data"]["status"], "pending_manual")
self.assertEqual(response["data"]["project_id"], 8)
self.assertEqual(refreshed.state, "executing")
self.assertEqual(refreshed.project_id, 8)
self.assertEqual(refreshed_decision.policy_gate_code, None)
self.assertEqual(bool((refreshed_decision.approval_requirement or {}).get("required")), False)
async def test_list_work_items_hides_smalltalk_query_rows(self) -> None:
async with self.session_factory() as db:
user = User(
tenant_id=1,
username="decision_smalltalk_viewer",
email="decision_smalltalk_viewer@example.com",
password_hash="hash",
name="查看人",
role="BOSS",
status="active",
is_active=True,
)
db.add(user)
await db.flush()
ingest = IngestEvent(
tenant_id=1,
channel="ai_pmo_ui_message",
event_key="decision-smalltalk-1",
status="done",
payload={"message_text": "怎么样了?"},
)
db.add(ingest)
await db.flush()
work_item = WorkItem(
tenant_id=1,
ingest_event_id=int(ingest.id),
source_system="ai_pmo_ui",
source_message_id="decision-smalltalk-item-1",
state="succeeded",
intent="query.read_only",
project_id=None,
signal_id=None,
execution_result={"status": "succeeded", "answer": "已收到"},
)
db.add(work_item)
await db.commit()
response = await list_work_items(
limit=20,
state=None,
project_id=None,
current_user=user,
db=db,
tenant_id=1,
)
self.assertEqual(response["code"], 0)
self.assertEqual(response["data"]["items"], [])

View File

@ -1,107 +0,0 @@
import asyncio
import unittest
from types import SimpleNamespace
from unittest.mock import AsyncMock, patch
from app.services.ai_steward import AIStewardService
from app.models import CognitiveConflict
class AIStewardFailFastTest(unittest.IsolatedAsyncioTestCase):
async def test_analyze_project_input_times_out_to_structured_fallback(self) -> None:
service = AIStewardService(db=None) # type: ignore[arg-type]
with patch.object(service.llm, "generate", AsyncMock(side_effect=asyncio.TimeoutError())):
result = await service._analyze_project_input(
"创建一个 3 周上线的 AI 客服项目,先出可运行 MVP。",
tenant_id=1,
)
self.assertEqual(result["primary_role"], "ai_engineer")
self.assertEqual(result["timeline"], "3周")
async def test_generate_task_chain_draft_times_out_to_default_tasks(self) -> None:
service = AIStewardService(db=None) # type: ignore[arg-type]
with patch.object(service.llm, "generate", AsyncMock(side_effect=asyncio.TimeoutError())):
tasks = await service._generate_task_chain_draft(
analysis={
"project_title": "AI 客服项目",
"tech_stack": ["FastAPI", "React"],
"timeline": "3周",
"primary_role": "ai_engineer",
},
tenant_id=1,
)
self.assertGreaterEqual(len(tasks), 2)
self.assertEqual(tasks[0]["title"], "技术方案设计")
async def test_persist_project_creation_draft_writes_conflict_user_ids_as_list(self) -> None:
added_rows = []
class FakeDB:
def add(self, row):
added_rows.append(row)
async def flush(self):
current = len(added_rows)
for index, row in enumerate(added_rows, start=1):
if getattr(row, "id", None) is None:
row.id = index
service = AIStewardService(db=FakeDB()) # type: ignore[arg-type]
with patch(
"app.services.ai_steward.persist_task",
AsyncMock(
return_value=SimpleNamespace(
id=101,
title="任务A",
description="desc",
estimated_hours=8.0,
priority="high",
status="todo",
)
),
):
payload = await service._persist_project_creation_draft(
normalized={
"project": {
"name": "测试项目",
"title": "测试项目",
"description": "desc",
"assigned_role": "ai_engineer",
"domain_id": 1,
},
"signal": {
"content": "signal",
"impact_score": 0.8,
"priority": "high",
"strategic_analysis": {},
},
"charter": {
"title": "项目宪章",
"content": {"success_criteria": ["ok"]},
},
"tasks": [SimpleNamespace(title="任务A", description="desc")],
"task_audit": [],
"risks": [
{
"type": "schedule_delay",
"description": "任务超时预警",
"threshold_hours": 48.0,
"severity": "medium",
}
],
"analysis": {},
},
input_text="创建测试项目",
creator_id=2,
tenant_id=1,
)
risk_rows = [row for row in added_rows if isinstance(row, CognitiveConflict)]
self.assertEqual(len(risk_rows), 1)
self.assertEqual(risk_rows[0].involved_user_ids, [])
self.assertEqual(payload["risks"][0]["description"], "任务超时预警")

View File

@ -1,68 +0,0 @@
import unittest
from unittest.mock import AsyncMock
from app.services.ai_steward import AIStewardService
class AIStewardTitleExtractTest(unittest.IsolatedAsyncioTestCase):
async def test_extract_title_from_plain_project_create_instruction(self) -> None:
title = AIStewardService._extract_project_title_from_instruction("给我新增个项目,openclaw自动化部署。")
self.assertEqual(title, "openclaw自动化部署")
async def test_extract_title_from_requirement_name_instruction(self) -> None:
title = AIStewardService._extract_project_title_from_instruction("给我新增个项目,需求名字为openclaw自动化部署。")
self.assertEqual(title, "openclaw自动化部署")
async def test_extract_title_from_project_name_instruction(self) -> None:
title = AIStewardService._extract_project_title_from_instruction("给我新增个项目,项目名为openclaw自动化部署。")
self.assertEqual(title, "openclaw自动化部署")
async def test_extract_title_from_correction_sentence(self) -> None:
title = AIStewardService._extract_project_title_from_instruction("不是创建信号,是创建项目名为openclaw自动化部署。")
self.assertEqual(title, "openclaw自动化部署")
async def test_normalize_project_draft_should_not_use_instruction_sentence_as_title(self) -> None:
service = AIStewardService(db=None) # type: ignore[arg-type]
normalized = await service._normalize_project_creation_draft(
draft={
"analysis": {},
"project": {
"title": "不是创建信号,是创建项目名为openclaw自动化部署。",
"description": "不是创建信号,是创建项目名为openclaw自动化部署。",
},
"tasks": [],
},
input_text="不是创建信号,是创建项目名为openclaw自动化部署。",
creator_id=1,
tenant_id=1,
)
self.assertEqual(normalized["project"]["title"], "openclaw自动化部署")
self.assertEqual(normalized["project"]["description"], "项目:openclaw自动化部署")
async def test_project_creation_text_retries_with_glm47_fallback_function(self) -> None:
service = AIStewardService(db=None) # type: ignore[arg-type]
called_functions: list[str] = []
async def _mock_generate(_: str, **kwargs):
fn = str(kwargs.get("function_name") or "")
called_functions.append(fn)
if len(called_functions) <= 2:
return "我暂时无法生成可靠回答(模型服务异常)。请稍后重试。"
return '{"project_name":"openclaw自动化部署"}'
service.llm.generate = AsyncMock(side_effect=_mock_generate) # type: ignore[method-assign]
text = await service._generate_project_creation_text(
prompt="生成分析结果",
tenant_id=1,
function_name="project_from_text_analysis",
timeout_seconds=5.0,
)
self.assertEqual(text, '{"project_name":"openclaw自动化部署"}')
self.assertEqual(
called_functions,
[
"project_from_text_analysis",
"project_from_text_analysis",
"project_draft_analysis_glm47_fallback",
],
)

View File

@ -1,22 +0,0 @@
from app.api.router import api_router
def test_project_signal_route_uses_single_sstc_canonical_endpoint() -> None:
canonical = []
legacy = []
for route in api_router.routes:
path = getattr(route, "path", "")
methods = set(getattr(route, "methods", set()) or set())
if path == "/projects/{project_id}/signals" and "GET" in methods:
endpoint = getattr(route, "endpoint", None)
canonical.append(
(
getattr(endpoint, "__module__", ""),
getattr(endpoint, "__name__", ""),
)
)
if path == "/projects/{project_id}/linked-signals" and "GET" in methods:
legacy.append(path)
assert canonical == [("app.api.sstc", "list_signals")]
assert legacy == []

View File

@ -1,40 +0,0 @@
from app.services.auth import AuthService
from app.services import auth as auth_module
def test_validate_password_rejects_over_72_utf8_bytes_with_friendly_message() -> None:
password = "a" * 73
try:
AuthService.hash_password(password)
raise AssertionError("expected ValueError for overlong password")
except ValueError as exc:
message = str(exc)
assert "72 个 UTF-8 字节" in message
assert "72 bytes" not in message.lower()
def test_validate_password_counts_utf8_bytes_not_only_character_length() -> None:
password = "密" * 25
try:
AuthService.validate_password(password)
raise AssertionError("expected ValueError for multibyte password overflow")
except ValueError as exc:
message = str(exc)
assert "72 个 UTF-8 字节" in message
def test_hash_password_falls_back_when_bcrypt_backend_misreports_72_bytes(monkeypatch) -> None:
class _BrokenPwdContext:
@staticmethod
def hash(_password: str) -> str:
raise ValueError("password cannot be longer than 72 bytes, truncate manually if necessary")
monkeypatch.setattr(auth_module, "HAS_PASSLIB", True)
monkeypatch.setattr(auth_module, "pwd_context", _BrokenPwdContext(), raising=False)
hashed = AuthService.hash_password("Passw0rd!")
assert hashed == AuthService._simple_hash("Passw0rd!")

View File

@ -1,131 +0,0 @@
import unittest
from unittest.mock import patch
from app.services.feishu_long_connection import FeishuLongConnectionManager
class _AliveThread:
def __init__(self) -> None:
self._alive = True
def is_alive(self) -> bool:
return self._alive
def join(self, timeout: float | None = None) -> None:
_ = timeout
self._alive = False
class _StubFeishuLongConnectionManager(FeishuLongConnectionManager):
def __init__(self, config: dict | None, preflight_exc: Exception | None = None) -> None:
super().__init__(session_factory=None) # type: ignore[arg-type]
self._sdk_available = True
self._stub_config = config
self._preflight_exc = preflight_exc
self.start_calls: list[tuple[str, str]] = []
async def _load_active_config(self) -> dict | None:
if self._stub_config is None:
return None
return dict(self._stub_config)
async def _preflight_auth(self, *, tenant_id: int) -> None:
_ = tenant_id
if self._preflight_exc is not None:
raise self._preflight_exc
def _start_ws_thread(self, app_id: str, app_secret: str) -> None:
self.start_calls.append((app_id, app_secret))
self._thread = _AliveThread()
class FeishuLongConnectionManagerTest(unittest.IsolatedAsyncioTestCase):
async def asyncTearDown(self) -> None:
manager = getattr(self, "manager", None)
if manager is not None and manager.status().get("running"):
await manager.stop()
async def test_auth_failure_is_classified_without_starting_thread(self) -> None:
self.manager = _StubFeishuLongConnectionManager(
config={
"tenant_id": 9,
"event_mode": "long_connection",
"app_id": "cli_xxx",
"app_secret": "secret_xxx",
},
preflight_exc=ValueError("获取 tenant_access_token 失败(http=401): {'code': 99991663}"),
)
await self.manager.start()
status = self.manager.status()
self.assertEqual(status["tenant_id"], 9)
self.assertEqual(status["last_error_code"], "auth_failed")
self.assertFalse(status["last_error_retryable"])
self.assertFalse(status["thread_alive"])
self.assertEqual(self.manager.start_calls, [])
async def test_network_failure_is_marked_retryable(self) -> None:
self.manager = _StubFeishuLongConnectionManager(
config={
"tenant_id": 3,
"event_mode": "long_connection",
"app_id": "cli_xxx",
"app_secret": "secret_xxx",
},
preflight_exc=ValueError("访问飞书 auth 接口失败: timed out"),
)
await self.manager.start()
status = self.manager.status()
self.assertEqual(status["last_error_code"], "network_error")
self.assertTrue(status["last_error_retryable"])
self.assertEqual(status["restart_attempts"], 1)
self.assertIsNotNone(status["next_retry_at"])
self.assertEqual(self.manager.start_calls, [])
async def test_successful_start_records_tenant_and_auth_timestamp(self) -> None:
self.manager = _StubFeishuLongConnectionManager(
config={
"tenant_id": 12,
"event_mode": "long_connection",
"app_id": "cli_xxx",
"app_secret": "secret_xxx",
}
)
await self.manager.start()
status = self.manager.status()
self.assertEqual(status["tenant_id"], 12)
self.assertTrue(status["thread_alive"])
self.assertIsNotNone(status["last_auth_at"])
self.assertEqual(self.manager.start_calls, [("cli_xxx", "secret_xxx")])
async def test_retry_strategy_is_1_2_4_and_stops_after_three_attempts(self) -> None:
self.manager = _StubFeishuLongConnectionManager(
config={
"tenant_id": 7,
"event_mode": "long_connection",
"app_id": "cli_xxx",
"app_secret": "secret_xxx",
}
)
self.manager._set_error("network", code="network_error", retryable=True)
with patch("app.services.feishu_long_connection.time.time", return_value=100.0):
self.manager._schedule_retry()
self.assertEqual(self.manager._restart_attempts, 1)
self.assertEqual(self.manager._next_retry_at, 101.0)
self.manager._schedule_retry()
self.assertEqual(self.manager._restart_attempts, 2)
self.assertEqual(self.manager._next_retry_at, 102.0)
self.manager._schedule_retry()
self.assertEqual(self.manager._restart_attempts, 3)
self.assertEqual(self.manager._next_retry_at, 104.0)
# 第 4 次触发应熔断,不再继续重试。
self.manager._schedule_retry()
self.assertEqual(self.manager._restart_attempts, 3)
self.assertEqual(self.manager._next_retry_at, 0.0)
self.assertFalse(self.manager._last_error_retryable)

View File

@ -1,177 +0,0 @@
import unittest
import asyncio
from types import SimpleNamespace
from unittest.mock import AsyncMock, patch
from app.services.feishu_integration import FeishuIntegrationService
from app.services.feishu_signal_pipeline import FeishuSignalPipelineService
from app.services.signal_capture import ProactiveSignalCaptureService
from app.services.context_cache import context_cache_service
class FeishuRoutingReceiptsTest(unittest.TestCase):
def test_normalize_incoming_text_strips_bot_mentions_and_quote_noise(self) -> None:
text = FeishuSignalPipelineService._normalize_incoming_text(
"@PMFlow\n回复:\n> 上一条消息\n[附带图片]\n麻烦看下统一通知入口"
)
self.assertEqual(text, "麻烦看下统一通知入口")
def test_pending_receipt_prefers_clarification_message(self) -> None:
reply = FeishuIntegrationService._build_execution_receipt_reply(
message_content="@PMFlow 继续处理",
capture_result={
"status": "captured",
"results": [{"route_status": "queued_for_decision"}],
"clarification_message": "当前群最近同时涉及项目#12、#18,请明确归属项目。",
},
)
self.assertEqual(reply, "当前群最近同时涉及项目#12、#18,请明确归属项目。")
def test_failed_receipt_uses_controlled_executor_message(self) -> None:
reply = FeishuIntegrationService._build_execution_receipt_reply(
message_content="@PMFlow 创建任务",
capture_result={
"status": "captured",
"results": [{"route_status": "failed"}],
},
)
self.assertEqual(reply, "未执行:受控执行器拒绝了这次动作,请补充缺失字段或检查项目上下文。")
def test_followup_confirmation_reply_uses_real_db_ids(self) -> None:
service = FeishuSignalPipelineService(db=None, tenant_id=1) # type: ignore[arg-type]
reply = service._build_followup_confirmation_reply(
target_item=SimpleNamespace(id=23, level="signal"),
confirm_result={"signal_id": "91", "project_id": "12"},
)
self.assertEqual(reply, "已继续处理:信号#91 已确认并关联项目#12。")
def test_provider_error_guard_blocks_aborted_error_text(self) -> None:
self.assertTrue(FeishuIntegrationService._looks_like_provider_error("Request was aborted."))
def test_normalize_outgoing_reply_text_replaces_provider_error(self) -> None:
self.assertEqual(
FeishuIntegrationService._normalize_outgoing_reply_text("Request was aborted."),
"系统处理中出现异常,本次未执行。请稍后重试,或补充更明确的目标、对象和动作。",
)
def test_pipeline_decorate_capture_result_sanitizes_provider_error_reply(self) -> None:
result = FeishuSignalPipelineService._decorate_capture_result(
{
"status": "answered",
"classification": "question",
"direct_reply_text": "Request was aborted.",
},
chat_type="group",
normalized_text="@PMFlow 现在状态如何",
route="group.qa_direct",
db_mutations=[],
)
self.assertEqual(
result["direct_reply_text"],
"系统处理中出现异常,本次未执行。请稍后重试,或补充更明确的目标、对象和动作。",
)
def test_handle_message_receive_skips_reply_for_duplicate_event(self) -> None:
service = FeishuIntegrationService(db=None) # type: ignore[arg-type]
event = {
"message": {
"message_id": "om_duplicate",
"message_type": "text",
"chat_id": "oc_test",
"chat_type": "group",
"content": '{"text":"@PMFlow 帮我继续处理"}',
},
"chat": {
"chat_id": "oc_test",
"chat_type": "group",
},
"sender": {
"sender_type": "user",
"sender_id": {"open_id": "ou_test"},
"sender_name": "林宇航",
},
"type": "im.message.receive_v1",
}
with (
patch.object(service, "_record_ingress_trace", AsyncMock()),
patch.object(service, "_record_message_trace", AsyncMock()),
patch.object(service, "_resolve_sender_display_name", AsyncMock(return_value="林宇航")),
patch.object(service, "_safe_reply_text", AsyncMock()) as reply_mock,
patch(
"app.services.feishu_integration.ProactiveSignalCaptureService.capture_from_feishu",
AsyncMock(
return_value={
"status": "ignored",
"reason": "duplicate_filtered",
"classification": "duplicate",
"direct_reply_required": True,
"direct_reply_text": "Request was aborted.",
}
),
),
):
asyncio.run(service._handle_message_receive(event))
reply_mock.assert_not_awaited()
def test_sync_feishu_recent_cache_backfills_missing_rag_turn(self) -> None:
service = ProactiveSignalCaptureService(db=None) # type: ignore[arg-type]
with (
patch.object(context_cache_service, "get_session_messages", AsyncMock(return_value=[])),
patch.object(context_cache_service, "append_session_messages", AsyncMock()) as append_mock,
):
asyncio.run(
service._sync_feishu_recent_cache(
tenant_id=1,
group_chat_id="oc_test",
sender="林宇航",
sender_open_id="ou_test",
message="目前医药方向值得关注什么?",
pipeline_result={
"rag_result": {"answer": "可关注创新药、器械和公共卫生。"},
"direct_reply_text": "可关注创新药、器械和公共卫生。",
},
)
)
append_mock.assert_awaited_once()
def test_sync_feishu_recent_cache_skips_duplicate_turn(self) -> None:
service = ProactiveSignalCaptureService(db=None) # type: ignore[arg-type]
with (
patch.object(
context_cache_service,
"get_session_messages",
AsyncMock(
return_value=[
{"role": "user", "content": "目前医药方向值得关注什么?"},
{"role": "assistant", "content": "可关注创新药、器械和公共卫生。"},
]
),
),
patch.object(context_cache_service, "append_session_messages", AsyncMock()) as append_mock,
):
asyncio.run(
service._sync_feishu_recent_cache(
tenant_id=1,
group_chat_id="oc_test",
sender="林宇航",
sender_open_id="ou_test",
message="目前医药方向值得关注什么?",
pipeline_result={
"rag_result": {"answer": "可关注创新药、器械和公共卫生。"},
"direct_reply_text": "可关注创新药、器械和公共卫生。",
},
)
)
append_mock.assert_not_awaited()

View File

@ -1,369 +0,0 @@
import unittest
import asyncio
from types import SimpleNamespace
from unittest.mock import AsyncMock
from app.models.feishu_item import FeishuItem
from app.services.feishu_signal_pipeline import FOLLOWUP_STATUS_RE, FeishuSignalPipelineService
class InboxQueueViewTest(unittest.TestCase):
def test_has_processed_message_ignores_ingress_trace_only(self) -> None:
service = FeishuSignalPipelineService(db=SimpleNamespace(), tenant_id=1) # type: ignore[arg-type]
async def fake_execute(stmt):
sql = str(stmt)
if "FROM feishu_items" in sql:
return SimpleNamespace(scalar_one_or_none=lambda: None)
self.assertIn("ai_processing_logs.level IS NULL OR ai_processing_logs.level !=", sql)
self.assertIn(
"ai_processing_logs.routing_decision IS NULL OR ai_processing_logs.routing_decision !=",
sql,
)
return SimpleNamespace(scalar_one_or_none=lambda: None)
service.db.execute = AsyncMock(side_effect=fake_execute) # type: ignore[attr-defined]
exists = asyncio.run(service._has_processed_message("om_ingress_only"))
self.assertFalse(exists)
def test_dedupe_queue_items_aggregates_occurrence_count(self) -> None:
service = FeishuSignalPipelineService(db=None, tenant_id=1) # type: ignore[arg-type]
first = FeishuItem(
tenant_id=1,
raw_message="接入 GitHub 信号源",
level="signal",
card_type="signal",
route_status="pending",
dedupe_key="signal:3:group:external_integration",
occurrence_count=2,
)
second = FeishuItem(
tenant_id=1,
raw_message="接入 GitHub 信号源",
level="signal",
card_type="signal",
route_status="pending",
dedupe_key="signal:3:group:external_integration",
occurrence_count=1,
)
rows = service._dedupe_queue_items([first, second])
self.assertEqual(len(rows), 1)
self.assertEqual(int(rows[0].occurrence_count), 3)
def test_resolve_message_class_falls_back_from_route_status(self) -> None:
pending_item = FeishuItem(
tenant_id=1,
raw_message="同步 GitHub issue",
level="signal",
card_type="signal",
route_status="pending",
)
auto_done_item = FeishuItem(
tenant_id=1,
raw_message="同步 GitHub issue",
level="signal",
card_type="signal",
route_status="auto_processed",
)
self.assertEqual(FeishuSignalPipelineService._resolve_message_class(pending_item), "action_required")
self.assertEqual(FeishuSignalPipelineService._resolve_message_class(auto_done_item), "auto_done")
def test_resolve_message_class_supports_new_status_machine(self) -> None:
queued_item = FeishuItem(
tenant_id=1,
raw_message="新增一个项目",
level="project",
card_type="new_project_candidate",
route_status="queued_for_decision",
)
undone_item = FeishuItem(
tenant_id=1,
raw_message="新增一个项目",
level="command",
card_type="project_create",
route_status="undone",
)
self.assertEqual(FeishuSignalPipelineService._resolve_message_class(queued_item), "action_required")
self.assertEqual(FeishuSignalPipelineService._resolve_message_class(undone_item), "auto_done")
def test_extract_project_intent_fields_requires_title_description_and_scope(self) -> None:
service = FeishuSignalPipelineService(db=None, tenant_id=1) # type: ignore[arg-type]
fields = service._extract_project_intent_fields("新增一个项目,名称叫飞书协同中台,用于统一多群消息分发,范围是 PMFlow 主数据入库。")
self.assertTrue(fields["ready"])
self.assertEqual(fields["title"], "飞书协同中台")
self.assertIn("统一多群消息分发", fields["description"])
self.assertIn("PMFlow 主数据入库", fields["project_scope"])
def test_resolve_intent_gate_recognizes_explicit_project_create(self) -> None:
service = FeishuSignalPipelineService(db=None, tenant_id=1) # type: ignore[arg-type]
gate = asyncio.run(
service._resolve_intent_gate(
message="不对,是新增进 PMFlow 的项目列表里面,名称叫飞书协同中台,用于统一多群消息分发,范围是 PMFlow 主数据入库。",
inferred_project_id=None,
)
)
self.assertEqual(gate["intent"], "create_project")
self.assertEqual(gate["canonical_action"], "project.create")
def test_resolve_intent_gate_uses_context_prefill_for_project_create(self) -> None:
service = FeishuSignalPipelineService(db=None, tenant_id=1) # type: ignore[arg-type]
service._maybe_prefill_project_fields_from_context = AsyncMock( # type: ignore[method-assign]
return_value={
"title": "医药十五五方向研究",
"description": "基于最近一轮医药方向问答整理投资与立项建议。",
"project_scope": "围绕创新药、医疗器械、公共卫生与老龄化方向做机会梳理。",
"missing_fields": [],
"ready": True,
}
)
gate = asyncio.run(
service._resolve_intent_gate(
message="汇总以上内容给我创建个项目在项目列表",
inferred_project_id=None,
source_chat_id="oc_test",
source_sender_open_id="ou_test",
sender="林宇航",
)
)
self.assertEqual(gate["intent"], "create_project")
self.assertEqual(gate["fields"]["title"], "医药十五五方向研究")
def test_resolve_intent_gate_uses_embedded_message_context_for_project_create(self) -> None:
service = FeishuSignalPipelineService(db=None, tenant_id=1) # type: ignore[arg-type]
gate = asyncio.run(
service._resolve_intent_gate(
message=(
'目前"十五五"规划(2026-2030)还在制定中,但从已有信号来看,医药行业这几个方向值得关注:'
" 创新药会是核心 中医药传承创新 应对老龄化 供应链安全 出海国际化"
" 根据这个汇总一下,给我创建一个项目在项目列表"
),
inferred_project_id=None,
)
)
self.assertEqual(gate["intent"], "create_project")
self.assertEqual(gate["fields"]["title"], "十五五医药方向研究")
self.assertIn("创新药会是核心", gate["fields"]["description"])
def test_resolve_intent_gate_blocks_project_create_when_recent_context_is_failure_text(self) -> None:
service = FeishuSignalPipelineService(db=None, tenant_id=1) # type: ignore[arg-type]
service._load_recent_chat_turns = AsyncMock( # type: ignore[method-assign]
return_value=[
{
"question": "目前十五五规划里,医药行业值得关注哪些方向?",
"answer": "我暂时无法生成可靠回答(模型服务异常)。为避免误导,本次不直接给结论;请稍后重试。",
}
]
)
service._load_recent_chat_reply_fallback_text = AsyncMock(return_value="") # type: ignore[method-assign]
gate = asyncio.run(
service._resolve_intent_gate(
message="根据上面汇总一下,给我创建一个项目在项目列表",
inferred_project_id=None,
source_chat_id="oc_test",
source_sender_open_id="ou_test",
sender="林宇航",
)
)
self.assertEqual(gate["intent"], "clarification_required")
self.assertIn("title", gate["fields"]["missing_fields"])
def test_followup_continue_accepts_project_reference_message(self) -> None:
self.assertTrue(
FeishuSignalPipelineService._looks_like_continue_followup("你应该继续创建我刚才要求的项目")
)
self.assertFalse(
FeishuSignalPipelineService._looks_like_continue_followup("继续创建一个项目,名称叫飞书协同中台")
)
def test_followup_status_regex_matches_creation_question(self) -> None:
self.assertTrue(bool(FOLLOWUP_STATUS_RE.search("是否已经创建")))
self.assertTrue(bool(FOLLOWUP_STATUS_RE.search("创建了吗")))
def test_select_followup_status_target_prefers_project_create(self) -> None:
source_item = FeishuItem(
tenant_id=1,
raw_message="新增一个Web搜索信号源",
level="command",
card_type="command",
route_status="auto_done",
result_payload={"operation_code": "source.connect"},
)
project_item = FeishuItem(
tenant_id=1,
raw_message="给我创建一个项目在项目列表",
level="command",
card_type="project_create",
route_status="auto_done",
result_payload={"operation_code": "project.create"},
)
target = FeishuSignalPipelineService._select_followup_status_target(
message="是否已经创建",
recent_items=[source_item, project_item],
)
self.assertIs(target, project_item)
def test_resolve_project_candidate_fields_blocks_placeholder_project(self) -> None:
service = FeishuSignalPipelineService(db=None, tenant_id=1) # type: ignore[arg-type]
item = FeishuItem(
tenant_id=1,
raw_message="给我创建一个项目在项目列表",
level="project",
card_type="new_project_candidate",
route_status="queued_for_decision",
result_payload={
"operation_code": "project.create",
"operation_payload": {
"title": "",
"description": "",
},
"project_scope": "",
},
blocking_reason="未执行:当前建项目请求还缺少 项目名称。",
)
fields = service._resolve_project_candidate_fields(item=item, suggestion=None)
self.assertFalse(fields["ready"])
self.assertIn("title", fields["missing_fields"])
def test_create_project_gate_missing_fields_only_returns_clarification(self) -> None:
service = FeishuSignalPipelineService(db=SimpleNamespace(), tenant_id=1) # type: ignore[arg-type]
service._resolve_command_operator_user = AsyncMock(return_value={"id": 1}) # type: ignore[method-assign]
service._can_execute_high_value_action = lambda _operator: True # type: ignore[method-assign]
service._queue_project_intent_item = AsyncMock() # type: ignore[method-assign]
service._write_processing_log = AsyncMock() # type: ignore[method-assign]
service._execute_controlled_operation = AsyncMock( # type: ignore[method-assign]
side_effect=AssertionError("executor should not run")
)
result = asyncio.run(
service._handle_create_project_gate(
message="汇总以上内容给我创建个项目在项目列表",
sender="林宇航",
source_chat_id="oc_test",
source_sender_open_id="ou_test",
feishu_message_id="om_test",
captured_at=None,
intent_gate={
"intent": "clarification_required",
"canonical_action": "project.create",
"fields": {
"title": "",
"description": "汇总以上内容给我创建个项目在项目列表",
"project_scope": "",
"missing_fields": ["title"],
"ready": False,
},
"clarification_template": "未执行:当前建项目请求还缺少 项目名称。",
},
process_started_at=0.0,
)
)
self.assertEqual(result["intent"], "clarification_required")
self.assertEqual(result["direct_reply_text"], "未执行:当前建项目请求还缺少 项目名称。")
self.assertEqual(result["results"], [])
service._queue_project_intent_item.assert_not_awaited()
service._execute_controlled_operation.assert_not_awaited()
def test_create_task_gate_missing_fields_only_returns_clarification(self) -> None:
service = FeishuSignalPipelineService(db=SimpleNamespace(), tenant_id=1) # type: ignore[arg-type]
service._resolve_command_operator_user = AsyncMock(return_value={"id": 1}) # type: ignore[method-assign]
service._can_execute_high_value_action = lambda _operator: True # type: ignore[method-assign]
service._queue_command_intent_item = AsyncMock() # type: ignore[method-assign]
service._write_processing_log = AsyncMock() # type: ignore[method-assign]
service._execute_controlled_operation = AsyncMock( # type: ignore[method-assign]
side_effect=AssertionError("executor should not run")
)
result = asyncio.run(
service._handle_create_task_gate(
message="帮我建个任务",
sender="林宇航",
source_chat_id="oc_test",
source_sender_open_id="ou_test",
feishu_message_id="om_test",
captured_at=None,
intent_gate={
"intent": "clarification_required",
"canonical_action": "task.create",
"fields": {
"project_id": None,
"title": "",
"description": "",
"missing_fields": ["project_id", "title"],
"ready": False,
},
"clarification_template": "未执行:当前建任务请求还缺少 归属项目、任务标题。",
},
process_started_at=0.0,
)
)
self.assertEqual(result["intent"], "clarification_required")
self.assertEqual(result["direct_reply_text"], "未执行:当前建任务请求还缺少 归属项目、任务标题。")
self.assertEqual(result["results"], [])
service._queue_command_intent_item.assert_not_awaited()
service._execute_controlled_operation.assert_not_awaited()
def test_should_hide_pending_item_from_inbox_for_query_and_missing_fields(self) -> None:
lookup_item = FeishuItem(
tenant_id=1,
raw_message="现在有哪些项目",
level="signal",
card_type="signal",
route_status="pending",
)
clarification_item = FeishuItem(
tenant_id=1,
raw_message="汇总以上内容给我创建个项目在项目列表",
level="project",
card_type="new_project_candidate",
route_status="queued_for_decision",
decision_reason="missing_required_fields",
)
status_item = FeishuItem(
tenant_id=1,
raw_message="你好 长连接时都已通",
level="signal",
card_type="signal",
route_status="pending",
)
self.assertTrue(FeishuSignalPipelineService._should_hide_pending_item_from_inbox(lookup_item))
self.assertTrue(FeishuSignalPipelineService._should_hide_pending_item_from_inbox(clarification_item))
self.assertTrue(FeishuSignalPipelineService._should_hide_pending_item_from_inbox(status_item))
def test_build_project_preview_prefers_structured_title_and_detail(self) -> None:
title = FeishuSignalPipelineService._build_project_preview_title(
title="",
description="基于最近一轮医药方向问答整理投资与立项建议。",
project_scope="围绕创新药、医疗器械、公共卫生与老龄化方向做机会梳理。",
)
summary = FeishuSignalPipelineService._build_project_preview_summary(
description="基于最近一轮医药方向问答整理投资与立项建议。",
project_scope="围绕创新药、医疗器械、公共卫生与老龄化方向做机会梳理。",
blocking_reason="未执行:当前建项目请求还缺少 项目名称。",
fallback_text="汇总以上内容给我创建个项目在项目列表",
)
self.assertEqual(title, "围绕创新药")
self.assertIn("投资与立项建议", summary)

View File

@ -1,40 +0,0 @@
import asyncio
from app.api.llm_providers import _build_provider_payload, get_llm_provider_presets
def test_build_provider_payload_reads_env(monkeypatch) -> None:
monkeypatch.setenv("LLM_ROUTING_LOW_MODEL", "GLM-4.7")
monkeypatch.setenv("LLM_ROUTING_HIGH_MODEL", "MiniMax-M2.5")
monkeypatch.setenv("OPENAI_BASE_URL", "https://api.edgefn.net/v1")
monkeypatch.setenv("GLM_BASE_URL", "https://api.edgefn.net/v1")
monkeypatch.setenv("GLM_MODEL", "GLM-4.7")
monkeypatch.setenv("OPENAI_MODEL", "MiniMax-M2.5")
monkeypatch.setenv("DEEPSEEK_BASE_URL", "https://api.deepseek.com/v1")
monkeypatch.setenv("DEEPSEEK_MODEL", "deepseek-chat")
monkeypatch.setenv("OLLAMA_BASE_URL", "http://host.docker.internal:11434")
monkeypatch.setenv("OLLAMA_MODEL", "llama3:latest")
payload = _build_provider_payload()
assert payload["low"] == "GLM-4.7"
assert payload["high"] == "MiniMax-M2.5"
assert payload["base_url"] == "https://api.edgefn.net/v1"
assert payload["providers"]["glm"]["api_env"] == "GLM_API_KEY"
assert payload["providers"]["openai"]["api_env"] == "OPENAI_API_KEY"
assert payload["providers"]["glm"]["model"] == "GLM-4.7"
assert payload["providers"]["openai"]["model"] == "MiniMax-M2.5"
def test_get_llm_provider_presets_returns_envelope(monkeypatch) -> None:
monkeypatch.setenv("LLM_ROUTING_LOW_MODEL", "GLM-4.7")
monkeypatch.setenv("LLM_ROUTING_HIGH_MODEL", "MiniMax-M2.5")
monkeypatch.setenv("OPENAI_BASE_URL", "https://api.edgefn.net/v1")
result = asyncio.run(get_llm_provider_presets(current_user=object()))
assert result["code"] == 0
assert result["message"] == "success"
assert isinstance(result["data"], dict)
assert result["data"]["low"] == "GLM-4.7"
assert result["data"]["high"] == "MiniMax-M2.5"

View File

@ -1,207 +0,0 @@
import unittest
from unittest.mock import AsyncMock
from app.services.llm import ChatCompletionResult, LLMService, StructuredExtractionResult
class LLMStructuredExtractionTest(unittest.TestCase):
def setUp(self) -> None:
self.service = LLMService()
def _extract(self, response: dict, *, function_name: str = "openclaw_command_dispatch") -> StructuredExtractionResult:
return self.service._extract_structured_object_from_response(
function_name=function_name,
response=response,
fallback_text="",
provider="glm",
model="GLM-4.7",
)
def test_extracts_object_from_plain_content(self) -> None:
result = self._extract(
{
"choices": [
{
"message": {
"content": '{"summary":"ok","confidence":0.9,"operations":[]}',
}
}
]
}
)
self.assertEqual(result.validation, "ok")
self.assertEqual(result.payload["summary"], "ok")
def test_extracts_object_from_typed_chunk_content(self) -> None:
result = self._extract(
{
"choices": [
{
"message": {
"content": [
{"type": "output_text", "text": '{"summary":"chunk","confidence":0.8,"operations":[]}'}
]
}
}
]
}
)
self.assertEqual(result.validation, "ok")
self.assertEqual(result.payload["summary"], "chunk")
def test_extracts_object_from_tool_call_arguments(self) -> None:
result = self._extract(
{
"choices": [
{
"message": {
"tool_calls": [
{
"function": {
"arguments": '{"summary":"args","confidence":0.7,"operations":[]}'
}
}
],
"content": "",
}
}
]
}
)
self.assertEqual(result.source_field, "tool_calls.function.arguments")
self.assertEqual(result.payload["summary"], "args")
def test_extracts_json_from_reasoning_content_with_prefix_text(self) -> None:
result = self._extract(
{
"choices": [
{
"message": {
"content": "",
"reasoning_content": "先分析上下文,再给结果:\n{\"summary\":\"from_reasoning\",\"confidence\":0.66,\"operations\":[]}",
}
}
]
}
)
self.assertEqual(result.source_field, "message.reasoning_content")
self.assertEqual(result.payload["summary"], "from_reasoning")
def test_returns_json_not_found_when_reasoning_has_only_natural_language(self) -> None:
result = self._extract(
{
"choices": [
{
"message": {
"content": "",
"reasoning_content": "我认为还需要更多上下文。",
}
}
]
}
)
self.assertEqual(result.error_code, "structured_json_not_found")
self.assertEqual(result.source_field, "message.reasoning_content")
def test_unwraps_nested_json_field_once(self) -> None:
result = self._extract(
{
"choices": [
{
"message": {
"content": '{"json":"{\\"summary\\":\\"nested\\",\\"confidence\\":0.55,\\"operations\\":[]}"}',
}
}
]
}
)
self.assertEqual(result.validation, "ok")
self.assertEqual(result.payload["summary"], "nested")
def test_rejects_top_level_array_for_object_only_function(self) -> None:
result = self._extract(
{
"choices": [
{
"message": {
"content": '[{"summary":"bad"}]',
}
}
]
}
)
self.assertEqual(result.error_code, "structured_schema_invalid")
class LLMStructuredCacheTest(unittest.IsolatedAsyncioTestCase):
async def test_generate_structured_object_uses_cache_after_first_hit(self) -> None:
service = LLMService()
calls: list[str] = []
cache: dict[str, str] = {}
class _FakeRedis:
async def get(self, key: str):
return cache.get(key)
async def setex(self, key: str, _ttl: int, value: str):
cache[key] = value
service._get_redis = AsyncMock(return_value=_FakeRedis())
service._resolve_engine_for_call = AsyncMock(
return_value={
"provider": "glm",
"model": "GLM-4.7",
"api_key": "fake",
"base_url": "http://unit.test",
}
)
service._resolve_active_prompt_profile = AsyncMock(return_value={"role_name": "", "system_prompt": ""})
service._log_ai_call = AsyncMock()
async def _fake_chat_completion_raw(**_: object) -> ChatCompletionResult:
calls.append("hit")
return ChatCompletionResult(
text='{"summary":"cached","confidence":0.9,"operations":[]}',
raw_response={
"choices": [
{
"message": {
"content": '{"summary":"cached","confidence":0.9,"operations":[]}',
}
}
]
},
)
service._chat_completion_raw = AsyncMock(side_effect=_fake_chat_completion_raw)
first = await service.generate_structured_object(
"planner prompt",
db=object(),
tenant_id=1,
function_name="openclaw_command_dispatch",
response_mode="fast",
)
second = await service.generate_structured_object(
"planner prompt",
db=object(),
tenant_id=1,
function_name="openclaw_command_dispatch",
response_mode="fast",
)
self.assertEqual(first.payload["summary"], "cached")
self.assertEqual(second.payload["summary"], "cached")
self.assertTrue(second.from_cache)
self.assertEqual(len(calls), 1)
if __name__ == "__main__":
unittest.main()

View File

@ -1,103 +0,0 @@
import json
import os
import tempfile
import unittest
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
from app.api.model_routing import ModelRoutingUpdateRequest, get_model_routing, update_model_routing
from app.models import Base, User
class ModelRoutingApiTest(unittest.IsolatedAsyncioTestCase):
async def asyncSetUp(self) -> None:
tmp_db = tempfile.NamedTemporaryFile(prefix="pmflow-model-routing-", suffix=".db", delete=False)
tmp_db.close()
self.db_path = tmp_db.name
self.engine = create_async_engine(f"sqlite+aiosqlite:///{self.db_path}", future=True)
self.session_factory = async_sessionmaker(self.engine, expire_on_commit=False, class_=AsyncSession)
async with self.engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
tmp_rules = tempfile.NamedTemporaryFile(prefix="pmflow-routing-rules-", suffix=".json", delete=False)
tmp_rules.write(
json.dumps(
{
"rules": [
{
"name": "planner-default",
"enabled": True,
"priority": 100,
"tier": "high",
"provider": "openai",
"model": "GLM-5",
"api_env": "OPENAI_API_KEY",
"function_exact": "signal_planner_analyze",
}
]
}
).encode("utf-8")
)
tmp_rules.flush()
tmp_rules.close()
self.rules_path = tmp_rules.name
os.environ["OPENCLAW_ROUTING_RULES_PATH"] = self.rules_path
async def asyncTearDown(self) -> None:
await self.engine.dispose()
for path in (getattr(self, "db_path", None), getattr(self, "rules_path", None)):
if path:
try:
os.remove(path)
except FileNotFoundError:
pass
os.environ.pop("OPENCLAW_ROUTING_RULES_PATH", None)
async def test_get_and_update_model_routing(self) -> None:
async with self.session_factory() as db:
user = User(
tenant_id=1,
username="routing_admin",
email="routing_admin@example.com",
password_hash="hash",
name="路由管理员",
role="BOSS",
status="active",
is_active=True,
)
db.add(user)
await db.commit()
await db.refresh(user)
current = await get_model_routing(
function_name="signal_planner_analyze",
db=db,
current_user=user,
)
self.assertEqual(current["code"], 0)
self.assertEqual(current["data"]["source"], "default")
updated = await update_model_routing(
function_name="signal_planner_analyze",
body=ModelRoutingUpdateRequest(
provider="glm",
model="GLM-4.7",
api_env="GLM_API_KEY",
tier="low",
),
db=db,
current_user=user,
)
self.assertEqual(updated["code"], 0)
self.assertEqual(updated["data"]["source"], "override")
self.assertEqual(updated["data"]["provider"], "glm")
self.assertEqual(updated["data"]["model"], "GLM-4.7")
refreshed = await get_model_routing(
function_name="signal_planner_analyze",
db=db,
current_user=user,
)
self.assertEqual(refreshed["code"], 0)
self.assertEqual(refreshed["data"]["source"], "override")
self.assertEqual(refreshed["data"]["tier"], "low")

View File

@ -1,470 +0,0 @@
import unittest
from types import SimpleNamespace
from unittest.mock import AsyncMock, patch
from app.models import Project
from app.services.openclaw_commander_service import (
OpenClawCommanderService,
OperationExecutorService,
)
from app.services.llm import StructuredExtractionResult
class _DuplicatePersistentExecutor(OperationExecutorService):
def __init__(self) -> None:
super().__init__(db=None) # type: ignore[arg-type]
async def _claim_persistent_idempotency(self, **_: object):
return None, {
"status": "skipped",
"message": "duplicate_persistent",
"entity_type": "notification",
"entity_id": "21",
"trace_id": "openclaw-dup-1",
}
async def _execute_single_operation(self, **_: object):
raise AssertionError("duplicate notification should not execute")
class _SignalSourceFallbackCommander(OpenClawCommanderService):
def __init__(self, *, charter_ready: bool) -> None:
super().__init__(db=None) # type: ignore[arg-type]
self.charter_ready = charter_ready
async def _load_project(self, **_: object):
return SimpleNamespace(id=3, owner_id=7, title="项目A", status="active")
async def _load_latest_aligned_charter(self, **_: object):
if not self.charter_ready:
return None
return SimpleNamespace(id=2, status="aligned")
async def _resolve_signal_source_notification_recipient(self, **_: object):
return 7
class _ProjectFallbackCommander(OpenClawCommanderService):
def __init__(self) -> None:
super().__init__(db=None) # type: ignore[arg-type]
async def _load_project_directory(self, **_: object):
return [{"project_id": 8, "title": "财务冲账协同", "status": "active"}]
class _MemoryDB:
def __init__(self) -> None:
self.added: list[object] = []
def add(self, obj: object) -> None:
self.added.append(obj)
async def flush(self) -> None:
for obj in self.added:
if isinstance(obj, Project) and getattr(obj, "id", None) in {None, 0}:
obj.id = 8
async def commit(self) -> None:
return None
async def rollback(self) -> None:
return None
class _ProjectExecutor(OperationExecutorService):
def __init__(self, *, existing_project: object | None = None) -> None:
self.memory_db = _MemoryDB()
super().__init__(db=self.memory_db) # type: ignore[arg-type]
self.existing_project = existing_project
async def _load_project_by_title(self, **_: object):
return self.existing_project
async def _load_active_user(self, *, tenant_id: int, user_id: int):
_ = tenant_id
return SimpleNamespace(id=user_id, status="active")
class OpenClawCommanderServiceTest(unittest.TestCase):
def setUp(self) -> None:
self.executor = OperationExecutorService(db=None) # type: ignore[arg-type]
def test_extract_project_create_title_handles_requirement_prefix(self) -> None:
title = OpenClawCommanderService._extract_project_create_title("给我新增个项目,需求名字为openclaw自动化部署。")
self.assertEqual(title, "openclaw自动化部署")
def test_extract_project_create_title_handles_project_name_prefix(self) -> None:
title = OpenClawCommanderService._extract_project_create_title("给我新增个项目,项目名为openclaw自动化部署。")
self.assertEqual(title, "openclaw自动化部署")
def test_plan_operations_accepts_tool_call_json(self) -> None:
service = OpenClawCommanderService(db=None) # type: ignore[arg-type]
service._commander_operation_catalog = lambda: [ # type: ignore[method-assign]
{
"operation_code": "task.assign",
"description": "assign",
"input_schema": {"properties": {"task_id": {}, "assignee_id": {}}},
}
]
with patch(
"app.services.openclaw_commander_service.llm_service.generate_structured_object",
AsyncMock(
return_value=StructuredExtractionResult(
payload={
"summary": "先补负责人,再发通知",
"confidence": 0.82,
"operations": [
{
"operation_code": "task.assign",
"payload": {"task_id": 12, "assignee_id": 5},
"reason": "技能更匹配",
}
],
},
source_field="tool_calls.function.arguments",
provider="glm",
model="GLM-4.7",
validation="ok",
)
),
):
plan, planner_status, diagnostics = self.async_run(
service._plan_operations(
tenant_id=1,
operator_user=SimpleNamespace(id=9, name="Boss", role="BOSS"),
project=SimpleNamespace(id=3, title="项目A", name="项目A", description="desc", status="active", assigned_role=""),
tasks=[],
task_summary={},
members=[],
instruction="把任务 12 分给张三",
dry_run=False,
max_operations=4,
response_mode="fast",
)
)
self.assertEqual(planner_status, "planned")
self.assertEqual(diagnostics["source_field"], "tool_calls.function.arguments")
self.assertEqual(plan["operations"][0]["payload"]["task_id"], 12)
def test_plan_operations_returns_schema_invalid_diagnostics_on_bad_payload(self) -> None:
service = OpenClawCommanderService(db=None) # type: ignore[arg-type]
service._commander_operation_catalog = lambda: [] # type: ignore[method-assign]
service._build_fallback_plan = AsyncMock(return_value={"summary": "等待补充上下文。", "confidence": 0.0, "operations": []}) # type: ignore[method-assign]
with patch(
"app.services.openclaw_commander_service.llm_service.generate_structured_object",
AsyncMock(
return_value=StructuredExtractionResult(
payload=None,
source_field="message.content",
provider="glm",
model="GLM-4.7",
error_code="structured_schema_invalid",
validation="invalid",
)
),
):
plan, planner_status, diagnostics = self.async_run(
service._plan_operations(
tenant_id=1,
operator_user=SimpleNamespace(id=9, name="Boss", role="BOSS"),
project=SimpleNamespace(id=3, title="项目A", name="项目A", description="desc", status="active", assigned_role=""),
tasks=[],
task_summary={},
members=[],
instruction="把任务 12 分给张三",
dry_run=False,
max_operations=4,
response_mode="fast",
)
)
self.assertEqual(planner_status, "fallback")
self.assertEqual(diagnostics["error_code"], "structured_schema_invalid")
self.assertEqual(plan["operations"], [])
def test_validate_payload_coerces_integer_and_rejects_extra_fields(self) -> None:
normalized = self.executor._validate_and_normalize_payload(
operation_code="task.assign",
payload={"task_id": "12", "assignee_id": "8"},
)
self.assertEqual(normalized, {"task_id": 12, "assignee_id": 8})
with self.assertRaisesRegex(ValueError, "unsupported fields"):
self.executor._validate_and_normalize_payload(
operation_code="task.assign",
payload={"task_id": 12, "assignee_id": 8, "foo": "bar"},
)
def test_validate_task_create_payload_supports_array_and_numeric_fields(self) -> None:
normalized = self.executor._validate_and_normalize_payload(
operation_code="task.create",
payload={
"project_id": "7",
"title": "补充告警兜底",
"required_skill_tag_ids": ["1", 2, "3"],
"estimated_hours": "4.5",
"priority": "P0",
},
)
self.assertEqual(normalized["project_id"], 7)
self.assertEqual(normalized["required_skill_tag_ids"], [1, 2, 3])
self.assertEqual(normalized["estimated_hours"], 4.5)
self.assertEqual(normalized["priority"], "P0")
def test_validate_project_create_payload_supports_optional_owner_and_description(self) -> None:
normalized = self.executor._validate_and_normalize_payload(
operation_code="project.create",
payload={
"title": "财务冲账协同",
"description": "用于协调财务冲账任务。",
"owner_id": "7",
},
)
self.assertEqual(
normalized,
{
"title": "财务冲账协同",
"description": "用于协调财务冲账任务。",
"owner_id": 7,
},
)
def test_workload_update_payload_requires_max_weekly_hours(self) -> None:
normalized = self.executor._validate_and_normalize_payload(
operation_code="personnel.workload_update",
payload={"user_id": "5", "max_weekly_hours": "32"},
)
self.assertEqual(normalized, {"user_id": 5, "max_weekly_hours": 32.0})
with self.assertRaisesRegex(ValueError, "unsupported fields"):
self.executor._validate_and_normalize_payload(
operation_code="personnel.workload_update",
payload={"user_id": 5, "workload": 70},
)
def test_notification_idempotency_key_uses_trimmed_content_hash(self) -> None:
base_payload = {
"user_id": 3,
"title": "请处理阻塞",
"content": " 任务 A 已重新分配给你 ",
}
same_after_trim = {
"user_id": 3,
"title": "请处理阻塞",
"content": "任务 A 已重新分配给你",
}
changed_content = {
"user_id": 3,
"title": "请处理阻塞",
"content": "任务 B 已重新分配给你",
}
key_a = self.executor._build_idempotency_key(
operation_code="notification.create",
payload=base_payload,
tenant_id=1,
project_id=9,
)
key_b = self.executor._build_idempotency_key(
operation_code="notification.create",
payload=same_after_trim,
tenant_id=1,
project_id=9,
)
key_c = self.executor._build_idempotency_key(
operation_code="notification.create",
payload=changed_content,
tenant_id=1,
project_id=9,
)
key_other_project = self.executor._build_idempotency_key(
operation_code="notification.create",
payload=same_after_trim,
tenant_id=1,
project_id=10,
)
self.assertEqual(key_a, key_b)
self.assertNotEqual(key_a, key_c)
self.assertNotEqual(key_a, key_other_project)
def test_execute_operations_skips_persistent_duplicate_notification(self) -> None:
executor = _DuplicatePersistentExecutor()
result = self.async_run(
executor.execute_operations(
tenant_id=1,
operator_user=SimpleNamespace(id=9),
project=SimpleNamespace(id=4),
operations=[
{
"operation_code": "notification.create",
"payload": {
"user_id": 3,
"title": "请处理阻塞",
"content": "任务 A 已重新分配给你",
},
"reason": "重复回放",
}
],
dry_run=False,
trace_id="openclaw-1",
)
)
self.assertEqual(result["requested_count"], 1)
self.assertEqual(result["skipped_count"], 1)
self.assertEqual(result["results"][0]["message"], "duplicate_persistent")
self.assertEqual(result["results"][0]["entity_id"], "21")
def test_normalize_task_priority_maps_p_levels(self) -> None:
self.assertEqual(self.executor._normalize_task_priority("P0"), "high")
self.assertEqual(self.executor._normalize_task_priority("P1"), "medium")
self.assertEqual(self.executor._normalize_task_priority("P3"), "low")
def test_signal_source_fallback_without_charter_creates_notification_only(self) -> None:
commander = _SignalSourceFallbackCommander(charter_ready=False)
plan = self.async_run(
commander._build_fallback_plan(
tenant_id=1,
project_id=3,
instruction="新增一个 GitHub 信号源,监控核心仓库 issue。",
max_operations=4,
error="llm offline",
)
)
self.assertEqual(plan["summary"], "OpenClaw 规划失败,已回退为信号源接入最小闭环。")
self.assertEqual(len(plan["operations"]), 1)
self.assertEqual(plan["operations"][0]["operation_code"], "notification.create")
self.assertEqual(plan["operations"][0]["payload"]["title"], "GitHub信号源接入待确认")
def test_signal_source_fallback_with_charter_creates_task_and_notification(self) -> None:
commander = _SignalSourceFallbackCommander(charter_ready=True)
plan = self.async_run(
commander._build_fallback_plan(
tenant_id=1,
project_id=3,
instruction="新增一个 GitHub 信号源,监控核心仓库 issue。",
max_operations=4,
error="llm offline",
)
)
self.assertEqual([item["operation_code"] for item in plan["operations"]], ["task.create", "notification.create"])
self.assertEqual(plan["operations"][0]["payload"]["project_id"], 3)
self.assertEqual(plan["operations"][0]["payload"]["title"], "接入GitHub信号源")
def test_project_create_fallback_builds_project_create_operation(self) -> None:
commander = _ProjectFallbackCommander()
plan = self.async_run(
commander._build_fallback_plan(
tenant_id=1,
project_id=None,
instruction="帮我创建一个项目,财务冲账协同",
max_operations=4,
error="structured_http_error",
project_directory=None,
)
)
self.assertEqual(plan["operations"][0]["operation_code"], "project.create")
self.assertEqual(plan["operations"][0]["payload"]["title"], "财务冲账协同")
def test_project_create_fallback_supports_add_project_phrase(self) -> None:
commander = _ProjectFallbackCommander()
plan = self.async_run(
commander._build_fallback_plan(
tenant_id=1,
project_id=None,
instruction="给我新增个项目,openclaw自动化部署。",
max_operations=4,
error="structured_http_error",
project_directory=None,
)
)
self.assertEqual(plan["operations"][0]["operation_code"], "project.create")
self.assertEqual(plan["operations"][0]["payload"]["title"], "openclaw自动化部署")
def test_project_status_fallback_resolves_project_id_from_directory(self) -> None:
commander = _ProjectFallbackCommander()
plan = self.async_run(
commander._build_fallback_plan(
tenant_id=1,
project_id=None,
instruction="把财务冲账协同项目归档",
max_operations=4,
error="structured_http_error",
project_directory=None,
)
)
self.assertEqual(plan["operations"][0]["operation_code"], "project.archive")
self.assertEqual(plan["operations"][0]["payload"]["project_id"], 8)
def test_execute_project_create_creates_active_project(self) -> None:
executor = _ProjectExecutor()
with patch(
"app.services.openclaw_commander_service.log_operation",
AsyncMock(),
), patch(
"app.services.openclaw_commander_service.context_cache_service.invalidate_system_context",
AsyncMock(),
):
result = self.async_run(
executor._execute_project_create(
tenant_id=1,
operator_user=SimpleNamespace(id=9, role="BOSS"),
payload={"title": "财务冲账协同", "description": "用于财务冲账。"},
reason="explicit_project_create",
trace_id="openclaw-1",
)
)
created_project = next(obj for obj in executor.memory_db.added if isinstance(obj, Project))
self.assertEqual(result["status"], "applied")
self.assertEqual(created_project.title, "财务冲账协同")
self.assertEqual(created_project.status, "active")
self.assertEqual(result["result"]["project_id"], 8)
def test_execute_project_create_skips_existing_project(self) -> None:
executor = _ProjectExecutor(
existing_project=SimpleNamespace(id=5, title="财务冲账协同", name="财务冲账协同", status="active")
)
result = self.async_run(
executor._execute_project_create(
tenant_id=1,
operator_user=SimpleNamespace(id=9, role="BOSS"),
payload={"title": "财务冲账协同"},
reason="explicit_project_create",
trace_id="openclaw-1",
)
)
self.assertEqual(result["status"], "skipped")
self.assertEqual(result["entity_id"], "5")
self.assertEqual(result["result"]["status"], "active")
def async_run(self, awaitable):
import asyncio
return asyncio.run(awaitable)
if __name__ == "__main__":
unittest.main()

View File

@ -1,338 +0,0 @@
import os
import unittest
from tempfile import NamedTemporaryFile
from unittest.mock import AsyncMock, patch
from sqlalchemy import and_, select
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
from app.api.projects import delete_project
from app.models import (
AIOrchestrationRun,
AIWorkerExecution,
Base,
Budget,
ConversationSession,
CostTracking,
Experiment,
IngestEvent,
OperationExecutionLedger,
OperationLog,
Project,
SignalSourceRegistry,
Task,
TruthNode,
User,
UserProject,
WorkItem,
)
class DeleteProjectApiTest(unittest.IsolatedAsyncioTestCase):
async def asyncSetUp(self) -> None:
tmp = NamedTemporaryFile(prefix="pmflow-delete-project-test-", suffix=".db", delete=False)
tmp.close()
self.db_path = tmp.name
self.engine = create_async_engine(f"sqlite+aiosqlite:///{self.db_path}", future=True)
self.session_factory = async_sessionmaker(self.engine, expire_on_commit=False, class_=AsyncSession)
async with self.engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
async def asyncTearDown(self) -> None:
await self.engine.dispose()
if getattr(self, "db_path", None):
try:
os.remove(self.db_path)
except FileNotFoundError:
pass
async def test_delete_project_cleans_fk_dependencies(self) -> None:
async with self.session_factory() as db:
user = User(
tenant_id=1,
username="boss-delete-project",
email="boss-delete-project@example.com",
password_hash="x",
name="Boss",
role="BOSS",
status="active",
is_active=True,
)
db.add(user)
await db.flush()
project = Project(
tenant_id=1,
name="P-Delete",
title="P-Delete",
description="for delete test",
status="active",
owner_id=int(user.id),
signal_count=0,
)
db.add(project)
await db.flush()
truth_node = TruthNode(
tenant_id=1,
node_type="task",
project_id=int(project.id),
created_by=int(user.id),
title="TN",
content={"k": "v"},
version=1,
status="active",
)
db.add(truth_node)
await db.flush()
task = Task(
tenant_id=1,
truth_node_id=int(truth_node.id),
project_id=int(project.id),
assignee_id=int(user.id),
title="task-a",
description="d",
required_skill_tag_ids=[1],
status="todo",
priority="p1",
)
db.add(task)
await db.flush()
cost_tracking = CostTracking(
tenant_id=1,
truth_node_id=int(truth_node.id),
task_id=int(task.id),
period="project",
estimated_tokens=1,
actual_tokens=1,
estimated_cost_usd=0.1,
actual_cost_usd=0.1,
roi_analysis={},
optimization_suggestions={},
)
db.add(cost_tracking)
registry = SignalSourceRegistry(
tenant_id=1,
project_id=int(project.id),
source_kind="github",
source_name="repo",
source_identifier="github:org/repo",
)
db.add(registry)
await db.flush()
worker_execution = AIWorkerExecution(
tenant_id=1,
project_id=int(project.id),
signal_source_registry_id=int(registry.id),
trace_id="trace-worker-delete",
worker_role_key="pm_dispatcher",
worker_role_name="PM Dispatcher",
)
db.add(worker_execution)
orchestration_run = AIOrchestrationRun(
tenant_id=1,
trace_id="trace-run-delete",
run_type="signal_source_onboarding",
project_id=int(project.id),
response_mode="fast",
status="queued",
worker_total=0,
worker_completed=0,
worker_failed=0,
)
db.add(orchestration_run)
ingest_event = IngestEvent(
tenant_id=1,
channel="feishu",
event_key="delete-project-event",
status="done",
)
db.add(ingest_event)
await db.flush()
work_item = WorkItem(
tenant_id=1,
ingest_event_id=int(ingest_event.id),
source_system="feishu",
source_message_id="msg-delete-project",
state="received",
project_id=int(project.id),
)
db.add(work_item)
operation_log = OperationLog(
tenant_id=1,
user_id=int(user.id),
project_id=int(project.id),
entity_type="project",
entity_id=str(project.id),
action="delete",
)
db.add(operation_log)
operation_ledger = OperationExecutionLedger(
tenant_id=1,
operation_code="project.delete",
project_id=int(project.id),
idempotency_key="project-delete-key",
status="processing",
)
db.add(operation_ledger)
experiment = Experiment(
tenant_id=1,
title="exp",
status="draft",
project_id=int(project.id),
)
db.add(experiment)
budget = Budget(
tenant_id=1,
budget_type="project",
project_id=int(project.id),
period="monthly",
budget_amount_usd=100.0,
alert_threshold_percent=80.0,
hard_limit_enabled=True,
current_usage_usd=10.0,
status="active",
override_history={},
)
db.add(budget)
conversation = ConversationSession(
tenant_id=1,
sender="ou_delete_project",
current_project_id=int(project.id),
last_intent="delete_or_archive",
)
db.add(conversation)
user_project = UserProject(
tenant_id=1,
user_id=int(user.id),
project_id=int(project.id),
)
db.add(user_project)
await db.commit()
with (
patch("app.api.projects.context_cache_service.invalidate_system_context", AsyncMock()),
patch("app.api.projects.AIPMOKernelService.invalidate_world_model_cache", AsyncMock()),
):
result = await delete_project(
project_id=int(project.id),
db=db,
current_user=user,
)
self.assertEqual(result, {"message": "Project deleted"})
project_row = (
await db.execute(
select(Project).where(
and_(Project.tenant_id == 1, Project.id == int(project.id))
)
)
).scalar_one_or_none()
self.assertIsNone(project_row)
worker_rows = (
await db.execute(
select(AIWorkerExecution).where(
and_(
AIWorkerExecution.tenant_id == 1,
AIWorkerExecution.project_id == int(project.id),
)
)
)
).scalars().all()
self.assertEqual(len(worker_rows), 0)
run_rows = (
await db.execute(
select(AIOrchestrationRun).where(
and_(
AIOrchestrationRun.tenant_id == 1,
AIOrchestrationRun.project_id == int(project.id),
)
)
)
).scalars().all()
self.assertEqual(len(run_rows), 0)
registry_rows = (
await db.execute(
select(SignalSourceRegistry).where(
and_(
SignalSourceRegistry.tenant_id == 1,
SignalSourceRegistry.project_id == int(project.id),
)
)
)
).scalars().all()
self.assertEqual(len(registry_rows), 0)
user_project_rows = (
await db.execute(
select(UserProject).where(
and_(
UserProject.tenant_id == 1,
UserProject.project_id == int(project.id),
)
)
)
).scalars().all()
self.assertEqual(len(user_project_rows), 0)
task_rows = (
await db.execute(
select(Task).where(and_(Task.tenant_id == 1, Task.project_id == int(project.id)))
)
).scalars().all()
self.assertEqual(len(task_rows), 0)
refreshed_cost_tracking = (
await db.execute(select(CostTracking).where(CostTracking.id == int(cost_tracking.id)))
).scalar_one()
self.assertIsNone(refreshed_cost_tracking.task_id)
refreshed_work_item = (
await db.execute(select(WorkItem).where(WorkItem.id == int(work_item.id)))
).scalar_one()
self.assertIsNone(refreshed_work_item.project_id)
refreshed_operation_log = (
await db.execute(select(OperationLog).where(OperationLog.id == int(operation_log.id)))
).scalar_one()
self.assertIsNone(refreshed_operation_log.project_id)
refreshed_ledger = (
await db.execute(
select(OperationExecutionLedger).where(OperationExecutionLedger.id == int(operation_ledger.id))
)
).scalar_one()
self.assertIsNone(refreshed_ledger.project_id)
refreshed_experiment = (
await db.execute(select(Experiment).where(Experiment.id == int(experiment.id)))
).scalar_one()
self.assertIsNone(refreshed_experiment.project_id)
refreshed_budget = (
await db.execute(select(Budget).where(Budget.id == int(budget.id)))
).scalar_one()
self.assertIsNone(refreshed_budget.project_id)
refreshed_conversation = (
await db.execute(select(ConversationSession).where(ConversationSession.id == int(conversation.id)))
).scalar_one()
self.assertIsNone(refreshed_conversation.current_project_id)

View File

@ -1,203 +0,0 @@
import os
import unittest
from tempfile import NamedTemporaryFile
from unittest.mock import AsyncMock, patch
from fastapi import HTTPException
from sqlalchemy import and_, select
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
from app.api.projects import (
ProjectCreateRequest,
ProjectMembersAssignRequest,
assign_project_members,
create_project,
)
from app.models import Base, Notification, Project, User, UserProject
class ProjectsMemberAssignmentApiTest(unittest.IsolatedAsyncioTestCase):
async def asyncSetUp(self) -> None:
tmp = NamedTemporaryFile(prefix="pmflow-project-members-test-", suffix=".db", delete=False)
tmp.close()
self.db_path = tmp.name
self.engine = create_async_engine(f"sqlite+aiosqlite:///{self.db_path}", future=True)
self.session_factory = async_sessionmaker(self.engine, expire_on_commit=False, class_=AsyncSession)
async with self.engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
async def asyncTearDown(self) -> None:
await self.engine.dispose()
if getattr(self, "db_path", None):
try:
os.remove(self.db_path)
except FileNotFoundError:
pass
async def _seed_users(self, db: AsyncSession) -> tuple[User, User, User]:
boss = User(
tenant_id=1,
username="boss-member-test",
email="boss-member-test@example.com",
password_hash="x",
name="Boss",
role="BOSS",
status="active",
is_active=True,
)
member_a = User(
tenant_id=1,
username="member-a-test",
email="member-a-test@example.com",
password_hash="x",
name="Member A",
role="BE",
status="active",
is_active=True,
)
member_b = User(
tenant_id=1,
username="member-b-test",
email="member-b-test@example.com",
password_hash="x",
name="Member B",
role="FE",
status="active",
is_active=True,
)
db.add_all([boss, member_a, member_b])
await db.flush()
return boss, member_a, member_b
async def test_create_project_assigns_requested_members_and_owner(self) -> None:
async with self.session_factory() as db:
boss, member_a, member_b = await self._seed_users(db)
with (
patch("app.api.projects.context_cache_service.invalidate_system_context", AsyncMock()),
patch("app.api.projects.AIPMOKernelService.invalidate_world_model_cache", AsyncMock()),
):
payload = await create_project(
body=ProjectCreateRequest(
title="成员分配测试项目",
member_user_ids=[int(member_a.id), int(member_b.id)],
),
db=db,
current_user=boss,
)
project_id = int(payload["id"])
assigned_rows = (
await db.execute(
select(UserProject.user_id).where(
and_(
UserProject.tenant_id == 1,
UserProject.project_id == project_id,
)
)
)
).scalars().all()
self.assertEqual(
sorted(int(uid) for uid in assigned_rows),
sorted([int(boss.id), int(member_a.id), int(member_b.id)]),
)
async def test_assign_project_members_empty_should_notify_and_raise(self) -> None:
async with self.session_factory() as db:
boss, member_a, _ = await self._seed_users(db)
project = Project(
tenant_id=1,
name="P-members-empty",
title="P-members-empty",
description="members test",
owner_id=int(boss.id),
status="active",
assigned_role="backend",
signal_count=0,
)
db.add(project)
await db.flush()
db.add(
UserProject(
tenant_id=1,
user_id=int(member_a.id),
project_id=int(project.id),
)
)
await db.commit()
with self.assertRaises(HTTPException) as exc_ctx:
await assign_project_members(
project_id=int(project.id),
body=ProjectMembersAssignRequest(member_user_ids=[]),
db=db,
current_user=boss,
)
self.assertEqual(exc_ctx.exception.status_code, 400)
notifications = (
await db.execute(
select(Notification).where(
and_(
Notification.tenant_id == 1,
Notification.user_id == int(boss.id),
Notification.title == "项目成员分配失败",
)
)
)
).scalars().all()
self.assertGreaterEqual(len(notifications), 1)
async def test_assign_project_members_replaces_member_list(self) -> None:
async with self.session_factory() as db:
boss, member_a, member_b = await self._seed_users(db)
project = Project(
tenant_id=1,
name="P-members-replace",
title="P-members-replace",
description="members replace test",
owner_id=int(boss.id),
status="active",
assigned_role="backend",
signal_count=0,
)
db.add(project)
await db.flush()
db.add(
UserProject(
tenant_id=1,
user_id=int(boss.id),
project_id=int(project.id),
)
)
await db.commit()
with (
patch("app.api.projects.context_cache_service.invalidate_system_context", AsyncMock()),
patch("app.api.projects.AIPMOKernelService.invalidate_world_model_cache", AsyncMock()),
):
payload = await assign_project_members(
project_id=int(project.id),
body=ProjectMembersAssignRequest(member_user_ids=[int(member_a.id), int(member_b.id)]),
db=db,
current_user=boss,
)
self.assertEqual(
sorted(payload["member_user_ids"]),
sorted([str(int(member_a.id)), str(int(member_b.id))]),
)
assigned_rows = (
await db.execute(
select(UserProject.user_id).where(
and_(
UserProject.tenant_id == 1,
UserProject.project_id == int(project.id),
)
)
)
).scalars().all()
self.assertEqual(
sorted(int(uid) for uid in assigned_rows),
sorted([int(member_a.id), int(member_b.id)]),
)

View File

@ -1,427 +0,0 @@
import unittest
from types import SimpleNamespace
from unittest.mock import AsyncMock, patch
from app.models.signal_source_registry import SignalSourceRegistry
from app.services.llm import StructuredExtractionResult
from app.services.signal_source_orchestration_service import SignalSourceOrchestrationService
class _StubSignalSourceOrchestrationService(SignalSourceOrchestrationService):
def __init__(self) -> None:
super().__init__(db=None) # type: ignore[arg-type]
async def _load_project(self, **_: object):
return SimpleNamespace(id=3, title="项目A", name="项目A", description="desc", owner_id=1)
async def _load_signal(self, **_: object):
return SimpleNamespace(
id=9,
title="新增 GitHub 信号源",
summary="监控关键仓库 issue/PR",
signal_type="新需求",
recommended_action="enter_only",
content="新增 GitHub 信号源并自动拆任务",
)
async def _plan_onboarding(self, **_: object):
return (
{
"should_onboard": True,
"summary": "接入 GitHub 仓库信号源并下发 AI worker。",
"source": {
"source_kind": "github",
"source_name": "核心仓库",
"source_identifier": "github:core-repo",
"description": "跟踪 issue、pr、release",
},
"worker_roles": ["pm_dispatcher", "tech_architect"],
"openclaw_instruction": "为 GitHub 信号源创建接入任务并通知负责人。",
},
"planned",
{
"used_fallback": False,
"error_code": None,
"source_field": "message.content",
"provider": "glm",
"model": "GLM-4.7",
},
)
async def _create_or_reuse_registry(self, **_: object):
row = SignalSourceRegistry(
tenant_id=1,
project_id=3,
signal_id=9,
source_kind="github",
source_name="核心仓库",
source_identifier="github:core-repo",
description="跟踪 issue、pr、release",
requested_via="pmflow",
status="active",
onboarding_status="pending",
)
row.id = 11 # type: ignore[assignment]
return {
"status": "created",
"registry_id": 11,
"row": row,
}
async def _resolve_operator_user(self, **_: object):
return SimpleNamespace(id=1, role="BOSS")
async def _dispatch_openclaw(self, **_: object):
return {
"status": "completed",
"result": {
"trace_id": "openclaw-123",
"execution": {
"results": [
{
"operation_code": "task.create",
"status": "applied",
"entity_id": "21",
"payload": {"title": "接入 GitHub 监听"},
}
]
},
},
}
async def _execute_worker_roles(self, **_: object):
return [
{
"worker_role_key": "pm_dispatcher",
"worker_role_name": "产品经理(任务分派)",
"status": "completed",
"summary": "已输出接入拆解。",
},
{
"worker_role_key": "tech_architect",
"worker_role_name": "技术架构师",
"status": "completed",
"summary": "已输出接入约束。",
},
]
class SignalSourceOrchestrationServiceTest(unittest.TestCase):
def async_run(self, awaitable):
import asyncio
return asyncio.run(awaitable)
def test_detect_signal_source_request(self) -> None:
self.assertTrue(
SignalSourceOrchestrationService.looks_like_signal_source_request(
"请新增一个 GitHub 信号源,监控核心仓库 issue。"
)
)
self.assertTrue(
SignalSourceOrchestrationService.looks_like_signal_source_request(
"给这个项目加个信号源。"
)
)
self.assertFalse(
SignalSourceOrchestrationService.looks_like_signal_source_request(
"把任务 12 分给张三。"
)
)
def test_orchestrate_from_signal_returns_completed_payload(self) -> None:
service = _StubSignalSourceOrchestrationService()
result = self.async_run(
service.orchestrate_from_signal(
tenant_id=1,
project_id=3,
signal_id=9,
initiating_text="新增一个 GitHub 信号源,监控核心仓库 issue。",
requested_via="pmflow",
operator_user_id=1,
dry_run=True,
)
)
self.assertEqual(result["status"], "planned")
self.assertEqual(result["signal_source"]["registry_id"], 11)
self.assertEqual(result["signal_source"]["source_kind"], "github")
self.assertEqual(len(result["worker_executions"]), 2)
self.assertEqual(result["openclaw"]["status"], "completed")
def test_orchestrate_from_signal_skips_non_source_request(self) -> None:
service = _StubSignalSourceOrchestrationService()
result = self.async_run(
service.orchestrate_from_signal(
tenant_id=1,
project_id=3,
signal_id=9,
initiating_text="把任务 12 调成 P0。",
requested_via="pmflow",
operator_user_id=1,
dry_run=True,
)
)
self.assertEqual(result["status"], "skipped")
self.assertEqual(result["reason"], "not_signal_source_request")
def test_normalize_source_payload_uses_request_hash_for_generic_source_name(self) -> None:
service = _StubSignalSourceOrchestrationService()
normalized = service._normalize_source_payload(
source_payload={},
fallback_text="请新增一个 GitHub 信号源,监控核心仓库 issue 和 PR。",
requested_via="pmflow",
)
self.assertEqual(normalized["source_kind"], "github")
self.assertEqual(normalized["source_name"], "GitHub")
self.assertTrue(normalized["source_identifier"].startswith("github:req-"))
def test_build_source_identifier_prefers_explicit_repo_locator(self) -> None:
identifier = SignalSourceOrchestrationService._build_source_identifier(
source_kind="github",
source_name="GitHub",
text="新增 GitHub 信号源,仓库是 openai/codex。",
source_payload={"repo": "OpenAI/Codex"},
)
self.assertEqual(identifier, "github:openai/codex")
def test_map_orchestration_status_updates_signal_status_label(self) -> None:
self.assertEqual(
SignalSourceOrchestrationService._map_orchestration_status_to_signal_status("completed"),
"已编排",
)
self.assertEqual(
SignalSourceOrchestrationService._map_orchestration_status_to_signal_status("pending_manual"),
"待确认接入",
)
self.assertEqual(
SignalSourceOrchestrationService._map_orchestration_status_to_signal_status("partial"),
"部分编排",
)
self.assertEqual(
SignalSourceOrchestrationService._map_orchestration_status_to_signal_status("failed"),
"编排失败",
)
def test_resolve_registry_activation_requires_real_config_and_non_fallback_result(self) -> None:
registry_status, onboarding_status = SignalSourceOrchestrationService._resolve_registry_activation(
source_payload={
"source_kind": "pmflow",
"source_name": "Web搜索",
"source_identifier": "pmflow:req-abc123",
"description": "新增一个Web搜索信号源,关键词:医药行业。",
"config": {},
},
openclaw_result={
"status": "completed",
"result": {
"execution": {
"results": [
{
"operation_code": "task.create",
"status": "applied",
"payload": {"title": "接入 GitHub 监听"},
}
]
}
},
},
worker_results=[
{
"worker_role_key": "pm_dispatcher",
"status": "completed",
"used_fallback": True,
}
],
)
self.assertEqual(registry_status, "pending")
self.assertEqual(onboarding_status, "pending_manual")
def test_resolve_registry_activation_allows_explicit_repo_with_real_worker_output(self) -> None:
registry_status, onboarding_status = SignalSourceOrchestrationService._resolve_registry_activation(
source_payload={
"source_kind": "github",
"source_name": "核心仓库",
"source_identifier": "github:openai/codex",
"description": "监控 openai/codex issue 和 PR",
"config": {},
"repo": "openai/codex",
},
openclaw_result={
"status": "completed",
"result": {
"execution": {
"results": [
{
"operation_code": "task.create",
"status": "applied",
"payload": {"title": "接入 GitHub 监听"},
}
]
}
},
},
worker_results=[
{
"worker_role_key": "tech_architect",
"status": "completed",
"used_fallback": False,
}
],
)
self.assertEqual(registry_status, "active")
self.assertEqual(onboarding_status, "completed")
def test_plan_onboarding_accepts_reasoning_only_json(self) -> None:
service = SignalSourceOrchestrationService(db=None) # type: ignore[arg-type]
with patch(
"app.services.signal_source_orchestration_service.llm_service.generate_structured_object",
AsyncMock(
return_value=StructuredExtractionResult(
payload={
"should_onboard": True,
"summary": "接入 GitHub 仓库信号源。",
"source": {
"source_kind": "github",
"source_name": "核心仓库",
"source_identifier": "github:openai/codex",
"description": "监控 issue 与 PR",
},
"worker_roles": ["pm_dispatcher"],
"openclaw_instruction": "创建接入任务。",
},
source_field="message.reasoning_content",
provider="glm",
model="GLM-4.7",
validation="ok",
)
),
):
plan, planner_status, diagnostics = self.async_run(
service._plan_onboarding(
tenant_id=1,
project=SimpleNamespace(id=3, title="项目A", name="项目A", description="desc"),
signal=SimpleNamespace(id=9, title="新增 GitHub 信号源", summary="监控 issue", signal_type="新需求", recommended_action="enter_only"),
initiating_text="新增一个 GitHub 信号源,监控核心仓库 issue。",
requested_via="pmflow",
response_mode="fast",
)
)
self.assertEqual(planner_status, "planned")
self.assertTrue(plan["should_onboard"])
self.assertEqual(diagnostics["source_field"], "message.reasoning_content")
def test_plan_onboarding_uses_tool_call_payload_when_content_is_empty(self) -> None:
service = SignalSourceOrchestrationService(db=None) # type: ignore[arg-type]
with patch(
"app.services.signal_source_orchestration_service.llm_service.generate_structured_object",
AsyncMock(
return_value=StructuredExtractionResult(
payload={
"should_onboard": True,
"summary": "接入 GitHub 仓库信号源。",
"source": {
"source_kind": "github",
"source_name": "核心仓库",
"source_identifier": "github:openai/codex",
"description": "监控 issue 与 PR",
},
"worker_roles": ["pm_dispatcher"],
"openclaw_instruction": "创建接入任务。",
},
source_field="tool_calls.function.arguments",
provider="glm",
model="GLM-4.7",
validation="ok",
)
),
):
_, planner_status, diagnostics = self.async_run(
service._plan_onboarding(
tenant_id=1,
project=SimpleNamespace(id=3, title="项目A", name="项目A", description="desc"),
signal=SimpleNamespace(id=9, title="新增 GitHub 信号源", summary="监控 issue", signal_type="新需求", recommended_action="enter_only"),
initiating_text="新增一个 GitHub 信号源,监控核心仓库 issue。",
requested_via="pmflow",
response_mode="fast",
)
)
self.assertEqual(planner_status, "planned")
self.assertEqual(diagnostics["source_field"], "tool_calls.function.arguments")
def test_plan_onboarding_returns_schema_invalid_diagnostics(self) -> None:
service = SignalSourceOrchestrationService(db=None) # type: ignore[arg-type]
with patch(
"app.services.signal_source_orchestration_service.llm_service.generate_structured_object",
AsyncMock(
return_value=StructuredExtractionResult(
payload=None,
source_field="message.content",
provider="glm",
model="GLM-4.7",
error_code="structured_schema_invalid",
validation="invalid",
)
),
):
plan, planner_status, diagnostics = self.async_run(
service._plan_onboarding(
tenant_id=1,
project=SimpleNamespace(id=3, title="项目A", name="项目A", description="desc"),
signal=SimpleNamespace(id=9, title="新增 GitHub 信号源", summary="监控 issue", signal_type="新需求", recommended_action="enter_only"),
initiating_text="新增一个 GitHub 信号源,监控核心仓库 issue。",
requested_via="pmflow",
response_mode="fast",
)
)
self.assertEqual(planner_status, "fallback")
self.assertEqual(diagnostics["error_code"], "structured_schema_invalid")
self.assertTrue(plan["should_onboard"])
def test_plan_onboarding_returns_json_not_found_diagnostics(self) -> None:
service = SignalSourceOrchestrationService(db=None) # type: ignore[arg-type]
with patch(
"app.services.signal_source_orchestration_service.llm_service.generate_structured_object",
AsyncMock(
return_value=StructuredExtractionResult(
payload=None,
source_field="response.reasoning_content",
provider="glm",
model="GLM-4.7",
error_code="structured_json_not_found",
validation="invalid",
)
),
):
_, planner_status, diagnostics = self.async_run(
service._plan_onboarding(
tenant_id=1,
project=SimpleNamespace(id=3, title="项目A", name="项目A", description="desc"),
signal=SimpleNamespace(id=9, title="新增 GitHub 信号源", summary="监控 issue", signal_type="新需求", recommended_action="enter_only"),
initiating_text="新增一个 GitHub 信号源,监控核心仓库 issue。",
requested_via="pmflow",
response_mode="fast",
)
)
self.assertEqual(planner_status, "fallback")
self.assertEqual(diagnostics["error_code"], "structured_json_not_found")
if __name__ == "__main__":
unittest.main()

View File

@ -1,162 +0,0 @@
from __future__ import annotations
import unittest
from datetime import datetime
from types import SimpleNamespace
from fastapi import HTTPException
from app.api.sstc import (
_build_signal_created_at_range,
_collect_locked_signal_conflicts,
_extract_source_attachments,
_match_signal_filters,
_normalize_signal_status_code,
)
from app.services.feishu_integration import FeishuIntegrationService
class SignalStateMachineHelpersTest(unittest.TestCase):
def test_normalize_signal_status_code_prefers_linked_state(self) -> None:
status_code = _normalize_signal_status_code(
linked_state="linked",
signal_status="待处理",
processed=False,
)
self.assertEqual(status_code, "linked")
def test_normalize_signal_status_code_uses_closed_for_processed(self) -> None:
status_code = _normalize_signal_status_code(
linked_state=None,
signal_status="",
processed=True,
)
self.assertEqual(status_code, "closed")
def test_collect_locked_signal_conflicts(self) -> None:
charters = [
SimpleNamespace(
id=101,
title="宪章A",
content={"signal_ids": [1, 2]},
signal_id=None,
),
SimpleNamespace(
id=102,
title="宪章B",
content={"signal_ids": [3]},
signal_id=5,
),
]
conflict_ids, conflict_map = _collect_locked_signal_conflicts(
charters=charters, # type: ignore[arg-type]
candidate_signal_ids=[2, 5, 9],
)
self.assertEqual(conflict_ids, [2, 5])
self.assertEqual(conflict_map[2][0]["charter_id"], 101)
self.assertEqual(conflict_map[5][0]["charter_id"], 102)
def test_build_signal_created_at_range_rejects_invalid_order(self) -> None:
with self.assertRaises(HTTPException):
_build_signal_created_at_range(
created_from="2026-03-11",
created_to="2026-03-10",
)
def test_match_signal_filters_keyword_and_status(self) -> None:
row = {
"id": "88",
"title": "新增飞书信号源",
"content": "支持附件同步",
"proposer_name": "老板",
"charter_name": "信号治理宪章",
"source": "Feishu",
"priority": "P1",
"status_code": "pending",
}
matched = _match_signal_filters(
row=row,
status_filter="pending",
keyword="附件",
source="Feishu",
priority="p1",
created_from_utc=datetime(2026, 3, 1, 0, 0, 0),
created_to_utc=datetime(2026, 3, 20, 0, 0, 0),
created_at_dt=datetime(2026, 3, 10, 12, 0, 0),
)
self.assertTrue(matched)
def test_extract_source_attachments_normalizes_and_deduplicates(self) -> None:
attachments = _extract_source_attachments(
{
"attachments": [
{"attachment_type": "image", "image_key": "img_a", "file_name": "截图A"},
{"attachment_type": "img", "image_key": "img_a", "file_name": "截图A重复"},
{"attachment_type": "file", "file_key": "file_b", "file_name": "需求文档.pdf"},
]
}
)
self.assertEqual(len(attachments), 2)
self.assertEqual(attachments[0]["attachment_type"], "image")
self.assertEqual(attachments[1]["file_name"], "需求文档.pdf")
class FeishuMessagePayloadParserTest(unittest.TestCase):
def setUp(self) -> None:
self.service = FeishuIntegrationService(db=None) # type: ignore[arg-type]
def test_extract_message_payload_for_image_message(self) -> None:
payload = self.service._extract_message_payload(
{
"message": {
"message_type": "image",
"content": '{"image_key":"img_001"}',
}
}
)
self.assertEqual(payload["message_type"], "image")
self.assertEqual(len(payload["attachments"]), 1)
self.assertIn("附件消息", str(payload["text"] or ""))
def test_extract_message_payload_for_file_message(self) -> None:
payload = self.service._extract_message_payload(
{
"message": {
"message_type": "file",
"content": '{"file_key":"file_001","file_name":"立项材料.docx","file_size":1024}',
}
}
)
self.assertEqual(len(payload["attachments"]), 1)
attachment = payload["attachments"][0]
self.assertEqual(attachment["attachment_type"], "file")
self.assertEqual(attachment["file_name"], "立项材料.docx")
def test_extract_message_payload_for_post_with_text_and_image(self) -> None:
payload = self.service._extract_message_payload(
{
"message": {
"message_type": "post",
"content": {
"post": {
"zh_cn": {
"content": [
[
{"tag": "text", "text": "请评估这张图的风险点"},
{"tag": "img", "image_key": "img_post_1"},
]
]
}
}
},
}
}
)
self.assertEqual(payload["message_type"], "post")
self.assertEqual(len(payload["attachments"]), 1)
self.assertIn("请评估这张图的风险点", str(payload["text"] or ""))
if __name__ == "__main__":
unittest.main()

View File

@ -1,97 +0,0 @@
import os
import tempfile
import unittest
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
from app.api.task_briefs import (
GenerateTaskBriefRequest,
TemplateCreateRequest,
create_task_brief_template,
generate_task_brief,
get_task_brief_contract_sync,
list_task_brief_center,
list_task_brief_runs,
list_task_brief_templates,
)
from app.models import Base, User
class TaskBriefApiTest(unittest.IsolatedAsyncioTestCase):
async def asyncSetUp(self) -> None:
tmp = tempfile.NamedTemporaryFile(prefix="pmflow-task-brief-api-", suffix=".db", delete=False)
tmp.close()
self.db_path = tmp.name
self.engine = create_async_engine(f"sqlite+aiosqlite:///{self.db_path}", future=True)
self.session_factory = async_sessionmaker(self.engine, expire_on_commit=False, class_=AsyncSession)
async with self.engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
async def asyncTearDown(self) -> None:
await self.engine.dispose()
if getattr(self, "db_path", None):
try:
os.remove(self.db_path)
except FileNotFoundError:
pass
async def test_template_generate_and_list(self) -> None:
async with self.session_factory() as db:
user = User(
tenant_id=1,
username="brief_admin",
email="brief_admin@example.com",
password_hash="hash",
name="模板管理员",
role="ADMIN",
status="active",
is_active=True,
)
db.add(user)
await db.commit()
await db.refresh(user)
created = await create_task_brief_template(
body=TemplateCreateRequest(
name="默认增量模板",
description="用于 planner 增量任务包",
content="# Template Body",
),
db=db,
current_user=user,
)
self.assertEqual(created["code"], 0)
template_id = str(created["data"]["id"])
templates = await list_task_brief_templates(db=db, current_user=user)
self.assertEqual(templates["code"], 0)
self.assertGreaterEqual(len(templates["data"]["items"]), 1)
generated = await generate_task_brief(
body=GenerateTaskBriefRequest(
title="Signal Planner Increment",
template_id=template_id,
changed_files=[
"backend/app/services/signal_planner_service.py",
"frontend/src/pages/ModelRoutingConfigPage.tsx",
],
scope_summary="后端 Planner + 前端配置页联动",
),
db=db,
current_user=user,
)
self.assertEqual(generated["code"], 0)
self.assertEqual(generated["data"]["run"]["status"], "completed")
center = await list_task_brief_center(db=db, current_user=user)
self.assertEqual(center["code"], 0)
self.assertGreaterEqual(len(center["data"]["items"]), 1)
runs = await list_task_brief_runs(db=db, current_user=user)
self.assertEqual(runs["code"], 0)
self.assertGreaterEqual(len(runs["data"]["items"]), 1)
sync_board = await get_task_brief_contract_sync(db=db, current_user=user)
self.assertEqual(sync_board["code"], 0)
self.assertIn("all_synced", sync_board["data"])
self.assertIsInstance(sync_board["data"].get("files"), list)

View File

@ -1,80 +0,0 @@
import unittest
from app.models import SkillTag
from app.services.task_persistence import (
build_task_create_draft,
resolve_skill_tag_ids_by_names,
)
class _ScalarRows:
def __init__(self, rows: list[object]) -> None:
self._rows = rows
def scalars(self):
return self
def all(self):
return self._rows
class _FakeDB:
def __init__(self, rows: list[object]) -> None:
self._rows = rows
async def execute(self, *_args, **_kwargs):
return _ScalarRows(self._rows)
class TaskPersistenceTest(unittest.IsolatedAsyncioTestCase):
def test_build_task_create_draft_normalizes_fields(self) -> None:
draft = build_task_create_draft(
title=" 补齐统一通知入口 ",
required_skill_tag_ids=["1", 2, "2"],
priority="P0",
status="pending",
estimated_hours="4.5",
due_date="2026-03-07T10:00:00Z",
)
self.assertEqual(draft.title, "补齐统一通知入口")
self.assertEqual(draft.required_skill_tag_ids, [1, 2])
self.assertEqual(draft.priority, "high")
self.assertEqual(draft.status, "todo")
self.assertEqual(draft.estimated_hours, 4.5)
self.assertIsNotNone(draft.due_date)
self.assertIsNone(draft.due_date.tzinfo)
async def test_resolve_skill_tag_ids_by_names_preserves_unknowns_for_audit(self) -> None:
db = _FakeDB(
[
SkillTag(id=11, tenant_id=1, name="系统设计", slug="system-design", category="skill"),
SkillTag(id=12, tenant_id=1, name="开发", slug="development", category="skill"),
]
)
resolution = await resolve_skill_tag_ids_by_names(
db,
tenant_id=1,
skill_names=["系统设计", "开发", "未知技能"],
)
self.assertEqual(resolution.matched_ids, [11, 12])
self.assertEqual(resolution.unmatched_names, ["未知技能"])
self.assertEqual(
resolution.matched_items,
[
{
"input_name": "系统设计",
"skill_tag_id": 11,
"skill_tag_name": "系统设计",
"skill_tag_slug": "system-design",
},
{
"input_name": "开发",
"skill_tag_id": 12,
"skill_tag_name": "开发",
"skill_tag_slug": "development",
},
],
)

View File

@ -9,13 +9,6 @@ function unwrap<T>(raw: ApiEnvelope<T> | T): T {
return raw as T; return raw as T;
} }
export interface OpenClawCommandDispatchRequest {
project_id: number;
instruction: string;
dry_run?: boolean;
max_operations?: number;
}
export interface SignalSourceRepairRequest { export interface SignalSourceRepairRequest {
project_id: number; project_id: number;
registry_ids: number[]; registry_ids: number[];
@ -35,53 +28,6 @@ export interface SignalSourceRepairResponse {
items: SignalSourceRepairItem[]; items: SignalSourceRepairItem[];
} }
export interface OpenClawCommandExecutionItem {
index: number;
operation_code: string;
payload: Record<string, unknown>;
idempotency_key?: string;
status: "planned" | "applied" | "skipped" | "failed";
message: string;
entity_type?: string | null;
entity_id?: string | null;
trace_id?: string | null;
}
export interface OpenClawCommandDispatchResponse {
trace_id: string;
planner_status: string;
project: {
id: string;
title: string;
status: string;
};
prompt_role: {
role_key: string;
role_name: string;
};
instruction: string;
dry_run: boolean;
summary: string;
confidence: number;
task_summary: {
total: number;
active: number;
done: number;
blocked: number;
sampled: number;
};
member_count: number;
planned_operations: number;
execution: {
dry_run: boolean;
requested_count: number;
applied_count: number;
skipped_count: number;
failed_count: number;
results: OpenClawCommandExecutionItem[];
};
}
export type LLMProvider = "deepseek" | "glm" | "openai" | "ollama"; export type LLMProvider = "deepseek" | "glm" | "openai" | "ollama";
export interface LLMProviderPreset { export interface LLMProviderPreset {
@ -97,16 +43,124 @@ export interface LLMProviderPresetResponse {
providers: Record<LLMProvider, LLMProviderPreset>; providers: Record<LLMProvider, LLMProviderPreset>;
} }
export interface LLMEngineSettings {
enable_local_kb_qa: boolean;
local_kb_qa_scope: "kb_qa_only";
}
export interface LLMEngine {
id: string;
name: string;
provider: LLMProvider;
model: string;
base_url: string;
api_key_masked: string;
updated_at?: string;
created_at?: string;
}
export interface LLMEngineListResponse {
active_engine_id: string | null;
engines: LLMEngine[];
settings: LLMEngineSettings;
}
export interface LLMEngineUpsertRequest {
name: string;
provider: LLMProvider;
api_key?: string;
model?: string;
base_url?: string;
set_active?: boolean;
}
export interface LLMEngineConnectionTestResponse {
ok: boolean;
engine_id: string;
name: string;
provider: LLMProvider;
model: string;
base_url: string;
api_key_masked: string;
latency_ms: number;
response_preview: string;
}
export const aiStewardApi = { export const aiStewardApi = {
async dispatchCommand(body: OpenClawCommandDispatchRequest): Promise<OpenClawCommandDispatchResponse> { async getLlmProviderPresets(): Promise<LLMProviderPresetResponse> {
const raw = await apiRequest<ApiEnvelope<OpenClawCommandDispatchResponse>>("/ai-steward/command/dispatch", { const raw = await apiRequest<ApiEnvelope<LLMProviderPresetResponse>>("/ai-steward/llm/providers");
return unwrap(raw);
},
async listLlmEngines(): Promise<LLMEngineListResponse> {
const raw = await apiRequest<ApiEnvelope<LLMEngineListResponse>>("/ai-steward/llm-engines");
return unwrap(raw);
},
async createLlmEngine(body: LLMEngineUpsertRequest): Promise<LLMEngineListResponse & { engine: LLMEngine }> {
const raw = await apiRequest<ApiEnvelope<LLMEngineListResponse & { engine: LLMEngine }>>("/ai-steward/llm-engines", {
method: "POST", method: "POST",
body: JSON.stringify(body), body: JSON.stringify(body),
}); });
return unwrap(raw); return unwrap(raw);
}, },
async getLlmProviderPresets(): Promise<LLMProviderPresetResponse> { async updateLlmEngine(
const raw = await apiRequest<ApiEnvelope<LLMProviderPresetResponse>>("/ai-steward/llm/providers"); engineId: string,
body: Partial<LLMEngineUpsertRequest>,
): Promise<LLMEngineListResponse & { engine: LLMEngine }> {
const raw = await apiRequest<ApiEnvelope<LLMEngineListResponse & { engine: LLMEngine }>>(
`/ai-steward/llm-engines/${engineId}`,
{
method: "PUT",
body: JSON.stringify(body),
}
);
return unwrap(raw);
},
async activateLlmEngine(engineId: string): Promise<LLMEngineListResponse & { active_engine_id: string }> {
const raw = await apiRequest<ApiEnvelope<LLMEngineListResponse & { active_engine_id: string }>>(
`/ai-steward/llm-engines/${engineId}/activate`,
{
method: "POST",
}
);
return unwrap(raw);
},
async deleteLlmEngine(
engineId: string,
): Promise<LLMEngineListResponse & { deleted_engine_id: string; remaining_count: number }> {
const raw = await apiRequest<
ApiEnvelope<LLMEngineListResponse & { deleted_engine_id: string; remaining_count: number }>
>(`/ai-steward/llm-engines/${engineId}`, {
method: "DELETE",
});
return unwrap(raw);
},
async updateLlmEngineSettings(
body: Pick<LLMEngineSettings, "enable_local_kb_qa">,
): Promise<LLMEngineListResponse & { settings: LLMEngineSettings }> {
const raw = await apiRequest<ApiEnvelope<LLMEngineListResponse & { settings: LLMEngineSettings }>>(
"/ai-steward/llm-engines/settings",
{
method: "PUT",
body: JSON.stringify(body),
}
);
return unwrap(raw);
},
async testActiveLlmEngine(): Promise<LLMEngineConnectionTestResponse> {
const raw = await apiRequest<ApiEnvelope<LLMEngineConnectionTestResponse>>("/ai-steward/llm-engines/test-active", {
method: "POST",
body: JSON.stringify({}),
});
return unwrap(raw);
},
async testLlmEngine(engineId: string): Promise<LLMEngineConnectionTestResponse> {
const raw = await apiRequest<ApiEnvelope<LLMEngineConnectionTestResponse>>(
`/ai-steward/llm-engines/${engineId}/test`,
{
method: "POST",
body: JSON.stringify({}),
}
);
return unwrap(raw); return unwrap(raw);
}, },
async repairDirtySignalSources(body: SignalSourceRepairRequest): Promise<SignalSourceRepairResponse> { async repairDirtySignalSources(body: SignalSourceRepairRequest): Promise<SignalSourceRepairResponse> {

View File

@ -13,3 +13,4 @@ export * from './modelCapability'
export * from './budget' export * from './budget'
export * from './recipes' export * from './recipes'
export * from './aiSteward' export * from './aiSteward'
export * from './knowledgeGraph'

View File

@ -0,0 +1,142 @@
import { api } from "@/lib/api";
type ApiEnvelope<T> = { code: number; message: string; data: T };
function unwrap<T>(raw: ApiEnvelope<T> | T): T {
if (raw && typeof raw === "object" && "data" in (raw as Record<string, unknown>)) {
return (raw as ApiEnvelope<T>).data;
}
return raw as T;
}
export interface KnowledgeGraphOverviewSnapshot {
entity_total: number;
relation_total: number;
evidence_total: number;
pending_events: number;
latest_sync_run: {
id?: number | null;
run_type?: string | null;
status?: string | null;
started_at?: string | null;
ended_at?: string | null;
} | null;
}
export interface TaskTemplateCoverage {
total: number;
required_template_ids: string[];
present_template_ids: string[];
missing_template_ids: string[];
required_templates: Array<{
id: string;
name?: string;
description?: string;
updated_at?: string;
}>;
}
export interface ContractSyncCoverage {
all_synced: boolean;
missing_files: string[];
files: Array<{
file: string;
exists: boolean;
updated_at?: string | null;
}>;
}
export interface FunctionModelTemplateBinding {
function_name: string;
tier: "low" | "high" | string;
context_sections: string[];
template_ids: string[];
model_routing: {
provider: string;
model: string;
tier: string;
source: string;
api_env: string;
};
}
export interface KnowledgeGraphCapabilitySummary {
marker: string;
knowledge_graph: KnowledgeGraphOverviewSnapshot;
task_templates: TaskTemplateCoverage;
contract_sync: ContractSyncCoverage;
function_model_template_matrix: FunctionModelTemplateBinding[];
coverage: {
template_ready: boolean;
contract_ready: boolean;
target_function_count: number;
};
generated_at?: string | null;
}
export interface ContextConsumptionFunctionResult {
function_name: string;
tier: string;
template_ids: string[];
routing_snapshot: {
provider: string;
model: string;
tier: string;
source: string;
};
canary: {
called: boolean;
status: string;
error?: string | null;
error_code?: string | null;
provider?: string | null;
model?: string | null;
};
checks: {
marker: boolean;
knowledge_graph: boolean;
task_template: boolean;
contract_sync: boolean;
};
pass: boolean;
log_evidence?: {
id: number;
status: string;
created_at?: string | null;
prompt_excerpt: string;
} | null;
}
export interface ContextConsumptionTierResult {
pass: boolean;
functions: ContextConsumptionFunctionResult[];
}
export interface ContextConsumptionVerification {
marker: string;
run_canary: boolean;
probe_id?: string | null;
overall_pass: boolean;
tiers: {
low: ContextConsumptionTierResult;
high: ContextConsumptionTierResult;
};
functions: ContextConsumptionFunctionResult[];
}
export const knowledgeGraphApi = {
async getCapabilitySummary(): Promise<KnowledgeGraphCapabilitySummary> {
const { data } = await api.get<ApiEnvelope<KnowledgeGraphCapabilitySummary>>(
"/ai-steward/knowledge-graph/capability-summary"
);
return unwrap(data);
},
async verifyContextConsumption(payload?: { run_canary?: boolean }): Promise<ContextConsumptionVerification> {
const { data } = await api.post<ApiEnvelope<ContextConsumptionVerification>>(
"/ai-steward/knowledge-graph/verify-context-consumption",
payload || {}
);
return unwrap(data);
},
};

View File

@ -0,0 +1,121 @@
import { useEffect, useMemo, useState } from "react";
import { Info } from "lucide-react";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
interface FeatureIntroGuideProps {
featureId: string;
title: string;
purpose: string;
whenToUse: string;
steps: string[];
outputs?: string[];
}
function todayTextLocal(): string {
const now = new Date();
const y = now.getFullYear();
const m = String(now.getMonth() + 1).padStart(2, "0");
const d = String(now.getDate()).padStart(2, "0");
return `${y}-${m}-${d}`;
}
export function FeatureIntroGuide({
featureId,
title,
purpose,
whenToUse,
steps,
outputs = [],
}: FeatureIntroGuideProps) {
const [open, setOpen] = useState(false);
const storageKey = useMemo(() => `pmflow:intro-dismiss:${featureId}`, [featureId]);
useEffect(() => {
try {
const saved = localStorage.getItem(storageKey);
if (saved === todayTextLocal()) {
return;
}
} catch {
// ignore storage errors
}
setOpen(true);
}, [storageKey]);
const dismissToday = () => {
try {
localStorage.setItem(storageKey, todayTextLocal());
} catch {
// ignore storage errors
}
setOpen(false);
};
return (
<div className="flex items-center justify-end">
<Button variant="outline" size="sm" onClick={() => setOpen(true)}>
<Info className="mr-1 h-4 w-4" />
查看功能介绍
</Button>
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent className="w-[min(680px,calc(100vw-1rem))] max-w-[calc(100vw-1rem)] sm:max-w-[680px]">
<DialogHeader>
<DialogTitle>{title} · 功能介绍</DialogTitle>
<DialogDescription>进页面先看这一段,知道“它是干嘛的、怎么用”。</DialogDescription>
</DialogHeader>
<div className="space-y-3 text-sm text-slate-700 dark:text-slate-300">
<div>
<p className="font-medium text-slate-900 dark:text-slate-100">这个功能是干嘛的</p>
<p className="mt-1">{purpose}</p>
</div>
<div>
<p className="font-medium text-slate-900 dark:text-slate-100">什么时候用</p>
<p className="mt-1">{whenToUse}</p>
</div>
<div>
<p className="font-medium text-slate-900 dark:text-slate-100">怎么操作(3 步)</p>
<ol className="mt-1 list-decimal space-y-1 pl-5">
{steps.map((step) => (
<li key={step}>{step}</li>
))}
</ol>
</div>
{outputs.length > 0 ? (
<div>
<p className="font-medium text-slate-900 dark:text-slate-100">你会看到什么结果</p>
<ul className="mt-1 list-disc space-y-1 pl-5">
{outputs.map((item) => (
<li key={item}>{item}</li>
))}
</ul>
</div>
) : null}
</div>
<DialogFooter className="gap-2 sm:justify-between">
<Button variant="outline" onClick={dismissToday}>
今天不再提示
</Button>
<Button className="bg-blue-600 text-white hover:bg-blue-700" onClick={() => setOpen(false)}>
我知道了
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}

View File

@ -1,390 +0,0 @@
import { useEffect, useMemo, useState } from "react";
import {
AlertCircle,
Bot,
CheckCircle2,
Loader2,
Play,
RefreshCcw,
ShieldAlert,
Wand2,
} from "lucide-react";
import { aiStewardApi, type OpenClawCommandDispatchResponse } from "@/api/aiSteward";
import { projectsApi, type ProjectSummary } from "@/api/projects";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Switch } from "@/components/ui/switch";
import { Textarea } from "@/components/ui/textarea";
import { formatProjectNameWithId, toZhStatus } from "@/lib/display";
const EXAMPLE_COMMANDS = [
"把当前未分配的高优先级任务分给最合适的人,并通知相关成员。",
"根据当前负载重新平衡本项目任务,先 dry run 给出调整方案。",
"检查 blocked 任务,能恢复推进的直接切状态,不能恢复的通知负责人补充信息。",
];
const MANAGER_ROLES = new Set(["BOSS", "ADMIN"]);
function resultTone(status: string): string {
const key = String(status || "").trim().toLowerCase();
if (key === "applied") return "bg-green-50 text-green-700 border-green-200 dark:bg-green-900/20 dark:text-green-300 dark:border-green-800";
if (key === "planned") return "bg-blue-50 text-blue-700 border-blue-200 dark:bg-blue-900/20 dark:text-blue-300 dark:border-blue-800";
if (key === "skipped") return "bg-amber-50 text-amber-700 border-amber-200 dark:bg-amber-900/20 dark:text-amber-300 dark:border-amber-800";
return "bg-red-50 text-red-700 border-red-200 dark:bg-red-900/20 dark:text-red-300 dark:border-red-800";
}
function renderOperationLabel(operationCode: string): string {
const code = String(operationCode || "").trim().toLowerCase();
if (code === "task.create") return "任务创建";
if (code === "task.assign") return "任务分配";
if (code === "task.prioritize") return "优先级调整";
if (code === "task.status_transition") return "状态迁移";
if (code === "notification.create") return "通知创建";
if (code === "personnel.workload_update") return "工时容量调整";
return operationCode || "-";
}
interface OpenClawCommanderPanelProps {
currentUserRole?: string | null;
}
export function OpenClawCommanderPanel({ currentUserRole }: OpenClawCommanderPanelProps) {
const [projects, setProjects] = useState<ProjectSummary[]>([]);
const [projectLoading, setProjectLoading] = useState(false);
const [projectId, setProjectId] = useState("");
const [instruction, setInstruction] = useState("");
const [dryRun, setDryRun] = useState(true);
const [maxOperations, setMaxOperations] = useState("6");
const [dispatching, setDispatching] = useState(false);
const [error, setError] = useState<string | null>(null);
const [result, setResult] = useState<OpenClawCommandDispatchResponse | null>(null);
const canDispatch = MANAGER_ROLES.has(String(currentUserRole || "").trim().toUpperCase());
const selectedProject = useMemo(
() => projects.find((item) => String(item.id) === String(projectId)) || null,
[projectId, projects]
);
const loadProjects = async () => {
setProjectLoading(true);
try {
const payload = await projectsApi.getSummary();
const items = payload.projects || [];
setProjects(items);
if (!projectId && items[0]?.id) {
setProjectId(String(items[0].id));
}
} catch (loadError) {
console.error("load commander projects failed", loadError);
setError("加载项目列表失败");
} finally {
setProjectLoading(false);
}
};
useEffect(() => {
if (!canDispatch) {
return;
}
void loadProjects();
}, [canDispatch]);
const handleDispatch = async () => {
const parsedProjectId = Number(projectId);
const parsedMaxOperations = Number(maxOperations);
if (!Number.isInteger(parsedProjectId) || parsedProjectId <= 0) {
setError("请选择有效项目");
return;
}
if (!instruction.trim()) {
setError("请输入调度指令");
return;
}
if (!Number.isInteger(parsedMaxOperations) || parsedMaxOperations < 1 || parsedMaxOperations > 20) {
setError("max operations 仅支持 1-20");
return;
}
setDispatching(true);
setError(null);
try {
const payload = await aiStewardApi.dispatchCommand({
project_id: parsedProjectId,
instruction: instruction.trim(),
dry_run: dryRun,
max_operations: parsedMaxOperations,
});
setResult(payload);
if (!dryRun) {
void loadProjects();
}
} catch (dispatchError: any) {
const text = typeof dispatchError?.message === "string" ? dispatchError.message : "指挥调度失败";
setError(text);
} finally {
setDispatching(false);
}
};
if (!canDispatch) {
return (
<Card className="border-amber-200 dark:border-amber-800">
<CardContent className="py-10 text-center space-y-3">
<ShieldAlert className="w-8 h-8 mx-auto text-amber-500" />
<div className="text-lg font-semibold">Commander 仅开放给管理角色</div>
<div className="text-sm text-slate-600 dark:text-slate-400">
当前接口要求 `BOSS / ADMIN`,普通成员仍可通过任务看板或 AI 问答拿建议,但不能直接下发执行。
</div>
</CardContent>
</Card>
);
}
return (
<div className="space-y-6">
<Card className="border-slate-200 dark:border-slate-800">
<CardHeader>
<div className="flex items-center gap-3">
<div className="w-11 h-11 rounded-2xl bg-slate-900 text-white dark:bg-slate-100 dark:text-slate-900 flex items-center justify-center">
<Bot className="w-5 h-5" />
</div>
<div>
<CardTitle>OpenClaw Commander</CardTitle>
<CardDescription>直接下达自然语言指令,让指挥层规划并驱动 AI 管家执行。</CardDescription>
</div>
</div>
</CardHeader>
<CardContent className="grid gap-6 lg:grid-cols-[320px_minmax(0,1fr)]">
<div className="space-y-4">
<div className="flex items-center justify-between">
<Label>项目范围</Label>
<Button variant="outline" size="sm" onClick={() => void loadProjects()} disabled={projectLoading}>
{projectLoading ? <Loader2 className="w-4 h-4 animate-spin" /> : <RefreshCcw className="w-4 h-4" />}
</Button>
</div>
<div className="space-y-2 max-h-[460px] overflow-y-auto pr-1">
{projects.length === 0 ? (
<div className="rounded-lg border border-dashed border-slate-300 p-4 text-sm text-slate-500 dark:border-slate-700">
{projectLoading ? "正在加载项目..." : "暂无可调度项目"}
</div>
) : (
projects.map((project) => {
const active = String(project.id) === String(projectId);
return (
<button
key={project.id}
type="button"
onClick={() => setProjectId(String(project.id))}
className={`w-full rounded-xl border p-4 text-left transition-colors ${
active
? "border-slate-900 bg-slate-900 text-white dark:border-slate-100 dark:bg-slate-100 dark:text-slate-900"
: "border-slate-200 bg-white hover:border-slate-300 dark:border-slate-800 dark:bg-slate-950 dark:hover:border-slate-700"
}`}
>
<div className="flex items-start justify-between gap-3">
<div className="font-medium">{formatProjectNameWithId(project)}</div>
<Badge variant={active ? "secondary" : "outline"}>{toZhStatus(project.status)}</Badge>
</div>
<div className={`mt-2 text-xs ${active ? "text-slate-200 dark:text-slate-700" : "text-slate-500 dark:text-slate-400"}`}>
任务 {project.total_tasks} / 完成 {project.completed_tasks} / 进行中 {project.in_progress_tasks}
</div>
{project.description ? (
<div className={`mt-2 line-clamp-2 text-xs ${active ? "text-slate-200 dark:text-slate-700" : "text-slate-500 dark:text-slate-400"}`}>
{project.description}
</div>
) : null}
</button>
);
})
)}
</div>
</div>
<div className="space-y-4">
<div className="grid gap-4 md:grid-cols-[minmax(0,1fr)_160px]">
<div className="space-y-2">
<Label htmlFor="openclaw-project-id">项目 ID</Label>
<Input
id="openclaw-project-id"
value={projectId}
onChange={(event) => setProjectId(event.target.value)}
placeholder="例如 1"
/>
</div>
<div className="space-y-2">
<Label htmlFor="openclaw-max-ops">Max Operations</Label>
<Input
id="openclaw-max-ops"
value={maxOperations}
onChange={(event) => setMaxOperations(event.target.value)}
inputMode="numeric"
placeholder="6"
/>
</div>
</div>
{selectedProject ? (
<div className="rounded-xl border border-slate-200 bg-slate-50 p-4 text-sm dark:border-slate-800 dark:bg-slate-900/60">
<div className="font-medium">{formatProjectNameWithId(selectedProject)}</div>
<div className="mt-1 text-slate-600 dark:text-slate-400">
Active {selectedProject.in_progress_tasks} / Pending {selectedProject.pending_tasks} / Blocked 由执行结果返回。
</div>
</div>
) : null}
<div className="space-y-2">
<Label htmlFor="openclaw-instruction">调度指令</Label>
<Textarea
id="openclaw-instruction"
rows={6}
value={instruction}
onChange={(event) => setInstruction(event.target.value)}
placeholder="例如:把当前未分配的高优先级任务分给最合适的人,并给新负责人发通知。"
/>
<div className="flex flex-wrap gap-2">
{EXAMPLE_COMMANDS.map((item) => (
<button
key={item}
type="button"
className="rounded-full border border-slate-200 px-3 py-1 text-xs text-slate-600 transition-colors hover:border-slate-300 hover:text-slate-900 dark:border-slate-800 dark:text-slate-300 dark:hover:border-slate-700 dark:hover:text-slate-100"
onClick={() => setInstruction(item)}
>
{item}
</button>
))}
</div>
</div>
<div className="flex flex-wrap items-center justify-between gap-4 rounded-xl border border-slate-200 p-4 dark:border-slate-800">
<div className="space-y-1">
<div className="text-sm font-medium">Dry Run</div>
<div className="text-xs text-slate-500 dark:text-slate-400">
开启后只生成计划,不落库执行。
</div>
</div>
<Switch checked={dryRun} onCheckedChange={setDryRun} />
</div>
{error ? (
<div className="rounded-xl border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700 dark:border-red-800 dark:bg-red-900/20 dark:text-red-300">
<div className="flex items-center gap-2">
<AlertCircle className="w-4 h-4" />
{error}
</div>
</div>
) : null}
<Button onClick={handleDispatch} disabled={dispatching || projectLoading} className="w-full bg-slate-900 hover:bg-slate-800 dark:bg-slate-100 dark:text-slate-900 dark:hover:bg-slate-200">
{dispatching ? (
<>
<Loader2 className="mr-2 w-4 h-4 animate-spin" />
Commander 执行中...
</>
) : (
<>
<Play className="mr-2 w-4 h-4" />
{dryRun ? "生成调度计划" : "执行调度指令"}
</>
)}
</Button>
</div>
</CardContent>
</Card>
{result ? (
<Card className="border-slate-200 dark:border-slate-800">
<CardHeader>
<div className="flex flex-wrap items-center gap-3">
<CardTitle className="mr-auto">执行结果</CardTitle>
<Badge variant="outline">{result.planner_status}</Badge>
<Badge variant="secondary">{result.prompt_role.role_name || result.prompt_role.role_key || "default"}</Badge>
<Badge variant={result.dry_run ? "outline" : "default"}>{result.dry_run ? "Dry Run" : "Executed"}</Badge>
</div>
<CardDescription>{result.summary || "OpenClaw 已完成规划。"}</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid gap-3 md:grid-cols-4">
<div className="rounded-xl border border-slate-200 p-4 dark:border-slate-800">
<div className="text-xs text-slate-500 dark:text-slate-400">Trace</div>
<div className="mt-1 break-all text-sm font-medium">{result.trace_id}</div>
</div>
<div className="rounded-xl border border-slate-200 p-4 dark:border-slate-800">
<div className="text-xs text-slate-500 dark:text-slate-400">项目成员</div>
<div className="mt-1 text-sm font-medium">{result.member_count}</div>
</div>
<div className="rounded-xl border border-slate-200 p-4 dark:border-slate-800">
<div className="text-xs text-slate-500 dark:text-slate-400">计划操作</div>
<div className="mt-1 text-sm font-medium">{result.planned_operations}</div>
</div>
<div className="rounded-xl border border-slate-200 p-4 dark:border-slate-800">
<div className="text-xs text-slate-500 dark:text-slate-400">可信度</div>
<div className="mt-1 text-sm font-medium">{Math.round((result.confidence || 0) * 100)}%</div>
</div>
</div>
<div className="grid gap-3 md:grid-cols-4">
<div className="rounded-xl border border-green-200 bg-green-50 p-4 dark:border-green-800 dark:bg-green-900/20">
<div className="text-xs text-green-700 dark:text-green-300">Applied</div>
<div className="mt-1 text-2xl font-semibold text-green-800 dark:text-green-100">{result.execution.applied_count}</div>
</div>
<div className="rounded-xl border border-blue-200 bg-blue-50 p-4 dark:border-blue-800 dark:bg-blue-900/20">
<div className="text-xs text-blue-700 dark:text-blue-300">Requested</div>
<div className="mt-1 text-2xl font-semibold text-blue-800 dark:text-blue-100">{result.execution.requested_count}</div>
</div>
<div className="rounded-xl border border-amber-200 bg-amber-50 p-4 dark:border-amber-800 dark:bg-amber-900/20">
<div className="text-xs text-amber-700 dark:text-amber-300">Skipped</div>
<div className="mt-1 text-2xl font-semibold text-amber-800 dark:text-amber-100">{result.execution.skipped_count}</div>
</div>
<div className="rounded-xl border border-red-200 bg-red-50 p-4 dark:border-red-800 dark:bg-red-900/20">
<div className="text-xs text-red-700 dark:text-red-300">Failed</div>
<div className="mt-1 text-2xl font-semibold text-red-800 dark:text-red-100">{result.execution.failed_count}</div>
</div>
</div>
<div className="space-y-3">
{result.execution.results.length === 0 ? (
<div className="rounded-xl border border-dashed border-slate-300 p-5 text-sm text-slate-500 dark:border-slate-700">
当前没有可执行 operation,通常意味着上下文不足,或者指令被安全约束拦住了。
</div>
) : (
result.execution.results.map((item) => (
<div key={`${item.index}-${item.operation_code}-${item.idempotency_key || "no-key"}`} className="rounded-xl border border-slate-200 p-4 dark:border-slate-800">
<div className="flex flex-wrap items-center gap-2">
<Badge variant="outline">#{item.index}</Badge>
<div className="font-medium">{renderOperationLabel(item.operation_code)}</div>
<div className={`rounded-full border px-2.5 py-0.5 text-xs ${resultTone(item.status)}`}>
{item.status}
</div>
{item.entity_type && item.entity_id ? (
<Badge variant="secondary">
{item.entity_type}:{item.entity_id}
</Badge>
) : null}
</div>
<div className="mt-2 flex items-start gap-2 text-sm text-slate-600 dark:text-slate-300">
{item.status === "applied" ? <CheckCircle2 className="mt-0.5 w-4 h-4 text-green-500" /> : <Wand2 className="mt-0.5 w-4 h-4 text-slate-400" />}
<div>{item.message}</div>
</div>
<pre className="mt-3 overflow-x-auto rounded-lg bg-slate-950 p-3 text-xs text-slate-100">
{JSON.stringify(item.payload, null, 2)}
</pre>
{item.trace_id ? (
<div className="mt-2 text-xs text-slate-500 dark:text-slate-400">trace: {item.trace_id}</div>
) : null}
</div>
))
)}
</div>
</CardContent>
</Card>
) : null}
</div>
);
}
export default OpenClawCommanderPanel;

View File

@ -1,4 +1,4 @@
import { NavLink, useLocation } from "react-router-dom"; import { NavLink } from "react-router-dom";
import { import {
LayoutDashboard, LayoutDashboard,
FolderKanban, FolderKanban,
@ -9,11 +9,6 @@ import {
Users, Users,
Sparkles, Sparkles,
Sliders, Sliders,
Files,
LayoutTemplate,
History,
ShieldCheck,
Settings2,
X, X,
} from "lucide-react"; } from "lucide-react";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
@ -31,28 +26,18 @@ const baseNavItems = [
{ to: "/skills", label: "能力管理", icon: BarChart3 }, { to: "/skills", label: "能力管理", icon: BarChart3 },
]; ];
const configToolItems = [
{ to: "/ai-config/task-brief-center", label: "增量任务包中心", icon: Files },
{ to: "/ai-config/task-brief-templates", label: "任务包模板管理", icon: LayoutTemplate },
{ to: "/ai-config/task-brief-runs", label: "任务包生成记录", icon: History },
{ to: "/ai-config/contract-sync-board", label: "契约同步看板", icon: ShieldCheck },
{ to: "/ai-config/model-routing-config", label: "模型路由配置", icon: Settings2 },
];
interface SidebarProps { interface SidebarProps {
open: boolean; open: boolean;
onClose: () => void; onClose: () => void;
} }
export function Sidebar({ open, onClose }: SidebarProps) { export function Sidebar({ open, onClose }: SidebarProps) {
const location = useLocation();
const { theme, toggleTheme } = useTheme(); const { theme, toggleTheme } = useTheme();
const user = useAuthStore((s) => s.user); const user = useAuthStore((s) => s.user);
const isManager = isManagerRole(user?.role); const isManager = isManagerRole(user?.role);
const navItems = isManager const navItems = isManager
? [...baseNavItems, { to: "/users", label: "用户管理", icon: Users }] ? [...baseNavItems, { to: "/users", label: "用户管理", icon: Users }]
: baseNavItems; : baseNavItems;
const showConfigTools = location.pathname.startsWith("/ai-config");
return ( return (
<aside <aside
@ -106,34 +91,6 @@ export function Sidebar({ open, onClose }: SidebarProps) {
{label} {label}
</NavLink> </NavLink>
))} ))}
{showConfigTools && (
<div className="mt-3 rounded-2xl border border-slate-200/80 bg-white/70 p-2 dark:border-slate-800 dark:bg-slate-900/60">
<p className="px-2 pb-2 text-[11px] font-semibold tracking-wide text-slate-500 dark:text-slate-400">
智能配置工具
</p>
<div className="space-y-1">
{configToolItems.map(({ to, label, icon: Icon }) => (
<NavLink
key={to}
to={to}
end
onClick={onClose}
className={({ isActive }) =>
cn(
"flex items-center gap-2 rounded-xl px-2.5 py-2 text-xs font-medium transition-colors",
isActive
? "bg-blue-100 text-blue-700 dark:bg-blue-900/40 dark:text-blue-200"
: "text-slate-600 hover:bg-slate-100 hover:text-slate-900 dark:text-slate-300 dark:hover:bg-slate-800 dark:hover:text-slate-100"
)
}
>
<Icon className="h-3.5 w-3.5" />
{label}
</NavLink>
))}
</div>
</div>
)}
</nav> </nav>
<div className="mt-4 rounded-2xl bg-white p-3 shadow-[0_8px_22px_rgba(15,23,42,0.08)] dark:bg-slate-900 dark:shadow-none"> <div className="mt-4 rounded-2xl bg-white p-3 shadow-[0_8px_22px_rgba(15,23,42,0.08)] dark:bg-slate-900 dark:shadow-none">
<Button <Button

View File

@ -1,5 +1,5 @@
import { useEffect, useMemo, useRef, useState } from "react"; import { useEffect, useMemo, useRef, useState } from "react";
import { Bell, CheckCircle2, LoaderCircle, LogOut, Menu, ShieldAlert, User } from "lucide-react"; import { ArrowLeft, Bell, CheckCircle2, LoaderCircle, LogOut, Menu, ShieldAlert, User } from "lucide-react";
import { Link, useLocation, useNavigate } from "react-router-dom"; import { Link, useLocation, useNavigate } from "react-router-dom";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { aiPmoApi, type AIPMOWorkItemListPayload } from "@/api/aiPmo"; import { aiPmoApi, type AIPMOWorkItemListPayload } from "@/api/aiPmo";
@ -62,6 +62,17 @@ const SECTION_CHROME: Record<InboxDisplayKind, string> = {
info: "bg-slate-100 text-slate-600 dark:bg-slate-800 dark:text-slate-300", info: "bg-slate-100 text-slate-600 dark:bg-slate-800 dark:text-slate-300",
}; };
const ROOT_LEVEL_PATHS = new Set([
"/dashboard",
"/workloads",
"/inbox",
"/projects",
"/ai-steward",
"/ai-config",
"/skills",
"/users",
]);
interface TopBarProps { interface TopBarProps {
onMenuToggle: () => void; onMenuToggle: () => void;
} }
@ -80,6 +91,22 @@ function formatRelativeTime(value?: string | null) {
return `${Math.floor(diffMs / day)} 天前`; return `${Math.floor(diffMs / day)} 天前`;
} }
function normalizePathname(pathname: string) {
const normalized = pathname.replace(/\/+$/, "");
return normalized || "/";
}
function resolveFallbackBackPath(pathname: string) {
if (pathname.startsWith("/ai-config/")) return "/ai-config";
if (pathname === "/feishu-config") return "/ai-config";
if (/^\/projects\/\d+\/(?:board|charter\/\d+)$/.test(pathname)) return "/projects";
if (/^\/workloads\/\d+$/.test(pathname)) return "/workloads";
const skillTaskMatch = pathname.match(/^\/users\/(\d+)\/skill-tasks\/\d+$/);
if (skillTaskMatch) return `/users/${skillTaskMatch[1]}/skills`;
if (/^\/users\/\d+\/(?:profile|skills)$/.test(pathname)) return "/users";
return "/dashboard";
}
export function TopBar({ onMenuToggle }: TopBarProps) { export function TopBar({ onMenuToggle }: TopBarProps) {
const { user, logout } = useAuthStore(); const { user, logout } = useAuthStore();
const navigate = useNavigate(); const navigate = useNavigate();
@ -94,6 +121,9 @@ export function TopBar({ onMenuToggle }: TopBarProps) {
}); });
const inboxData = useMemo(() => buildInboxView({ notifications, workItems }), [notifications, workItems]); const inboxData = useMemo(() => buildInboxView({ notifications, workItems }), [notifications, workItems]);
const normalizedPathname = useMemo(() => normalizePathname(location.pathname), [location.pathname]);
const showBackButton = useMemo(() => !ROOT_LEVEL_PATHS.has(normalizedPathname), [normalizedPathname]);
const fallbackBackPath = useMemo(() => resolveFallbackBackPath(normalizedPathname), [normalizedPathname]);
const currentProjectId = location.pathname.match(/^\/projects\/(\d+)/)?.[1] || null; const currentProjectId = location.pathname.match(/^\/projects\/(\d+)/)?.[1] || null;
const inboxHref = currentProjectId ? `/inbox?project_id=${currentProjectId}` : "/inbox"; const inboxHref = currentProjectId ? `/inbox?project_id=${currentProjectId}` : "/inbox";
const { data: boardOverviewPayload } = useQuery({ const { data: boardOverviewPayload } = useQuery({
@ -166,6 +196,14 @@ export function TopBar({ onMenuToggle }: TopBarProps) {
const badgeCount = inboxData.leaderSummary.decision_count + notifications.unread_count; const badgeCount = inboxData.leaderSummary.decision_count + notifications.unread_count;
const handleBack = () => {
if (window.history.length > 1) {
navigate(-1);
return;
}
navigate(fallbackBackPath, { replace: true });
};
return ( return (
<header className="flex h-16 min-w-0 items-center justify-between border-b border-slate-200 bg-white px-3 shadow-[0_1px_0_rgba(15,23,42,0.04)] dark:border-slate-800 dark:bg-slate-900 dark:shadow-none sm:px-4 md:px-6"> <header className="flex h-16 min-w-0 items-center justify-between border-b border-slate-200 bg-white px-3 shadow-[0_1px_0_rgba(15,23,42,0.04)] dark:border-slate-800 dark:bg-slate-900 dark:shadow-none sm:px-4 md:px-6">
<div className="flex min-w-0 items-center gap-2 sm:gap-4"> <div className="flex min-w-0 items-center gap-2 sm:gap-4">
@ -179,6 +217,19 @@ export function TopBar({ onMenuToggle }: TopBarProps) {
> >
<Menu className="h-5 w-5" /> <Menu className="h-5 w-5" />
</Button> </Button>
{showBackButton && (
<Button
type="button"
variant="ghost"
size="icon"
className="h-9 w-9 shrink-0 rounded-full text-slate-500 hover:bg-slate-100 hover:text-slate-700 dark:text-slate-300 dark:hover:bg-slate-800 dark:hover:text-slate-100"
onClick={handleBack}
aria-label="返回上一层"
title="返回上一层"
>
<ArrowLeft className="h-4 w-4" />
</Button>
)}
<div className="min-w-0"> <div className="min-w-0">
<div className="truncate text-sm font-semibold text-slate-900 dark:text-slate-100">PMFlow</div> <div className="truncate text-sm font-semibold text-slate-900 dark:text-slate-100">PMFlow</div>
<div className="hidden text-xs text-slate-500 dark:text-slate-400 sm:block">AI 原生项目管理操作台</div> <div className="hidden text-xs text-slate-500 dark:text-slate-400 sm:block">AI 原生项目管理操作台</div>

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