From d59b1489a10524587f3fe7474aaf5c641f0c618a Mon Sep 17 00:00:00 2001 From: Yuuuu <1569752928@qq.com> Date: Sat, 14 Mar 2026 00:00:00 +0800 Subject: [PATCH] Review AI agent updates --- CLAUDE.md | 88 +- backend/Dockerfile | 1 + backend/alembic/env.py | 3 +- backend/app/api/ai_pmo.py | 14 +- backend/app/api/ai_steward.py | 38 - backend/app/api/knowledge_graph.py | 36 + backend/app/api/llm_engines.py | 24 + backend/app/config/ai_prompts.py | 113 +- backend/app/core/config.py | 13 +- backend/app/database.py | 48 +- backend/app/main.py | 240 +- backend/app/models/feishu_item.py | 2 - backend/app/models/types.py | 12 +- backend/app/seed_projects.py | 19 +- .../services/ai_orchestration_run_service.py | 12 +- .../app/services/ai_pmo_decision_runtime.py | 355 ++ .../app/services/ai_pmo_domain_workflow.py | 11 +- backend/app/services/ai_pmo_intent_routing.py | 676 +++ backend/app/services/ai_pmo_kernel_common.py | 310 ++ .../services/ai_pmo_kernel_mixins_closure.py | 17 + .../ai_pmo_kernel_mixins_closure_data.py | 154 + .../ai_pmo_kernel_mixins_closure_helpers.py | 92 + .../ai_pmo_kernel_mixins_closure_payload.py | 194 + .../services/ai_pmo_kernel_mixins_followup.py | 19 + ...o_kernel_mixins_followup_project_signal.py | 19 + ..._mixins_followup_project_signal_actions.py | 244 + ...l_mixins_followup_project_signal_resume.py | 273 ++ .../ai_pmo_kernel_mixins_followup_records.py | 114 + .../ai_pmo_kernel_mixins_followup_routing.py | 240 + ...pmo_kernel_mixins_followup_verification.py | 123 + .../services/ai_pmo_kernel_mixins_ingress.py | 17 + .../ai_pmo_kernel_mixins_ingress_flow.py | 557 +++ .../ai_pmo_kernel_mixins_ingress_render.py | 172 + .../ai_pmo_kernel_mixins_ingress_storage.py | 207 + .../services/ai_pmo_kernel_mixins_intent.py | 17 + .../ai_pmo_kernel_mixins_intent_llm.py | 259 + .../ai_pmo_kernel_mixins_intent_resolve.py | 66 + .../ai_pmo_kernel_mixins_intent_rules.py | 241 + .../ai_pmo_kernel_mixins_world_model.py | 17 + ...mo_kernel_mixins_world_model_aggregates.py | 339 ++ .../ai_pmo_kernel_mixins_world_model_core.py | 331 ++ ...i_pmo_kernel_mixins_world_model_session.py | 95 + backend/app/services/ai_pmo_kernel_runtime.py | 57 + backend/app/services/ai_pmo_kernel_service.py | 4252 +---------------- backend/app/services/ai_pmo_reply_renderer.py | 216 + backend/app/services/ai_pmo_typed_executor.py | 23 + .../services/ai_pmo_typed_executor_common.py | 79 + .../ai_pmo_typed_executor_mixins_discovery.py | 392 ++ .../ai_pmo_typed_executor_mixins_execute.py | 424 ++ .../ai_pmo_typed_executor_mixins_parse.py | 329 ++ .../app/services/ai_pmo_workflow_runtime.py | 241 + backend/app/services/ai_steward.py | 155 +- backend/app/services/context_cache.py | 2 - backend/app/services/feishu_integration.py | 50 + .../app/services/feishu_signal_pipeline.py | 491 +- backend/app/services/llm.py | 743 +-- .../services/model_context_bundle_service.py | 435 ++ backend/app/services/model_routing_service.py | 41 +- .../services/openclaw_commander_service.py | 2432 ---------- backend/app/services/project_command_utils.py | 92 + backend/app/services/signal_capture.py | 4 +- .../app/services/signal_planner_service.py | 277 +- .../signal_source_orchestration_service.py | 151 +- backend/app/services/skill_service.py | 83 +- backend/app/services/task_brief_service.py | 217 +- backend/init_db.py | 18 +- backend/requirements.txt | 1 - backend/scripts/migrate_sqlite_to_postgres.py | 290 -- .../test_adaptive_rag_intent_corrections.py | 36 - backend/tests/test_ai_agent_kernel.py | 86 - backend/tests/test_ai_logs_api.py | 184 - .../test_ai_orchestration_run_service.py | 312 -- backend/tests/test_ai_pmo_full_scenario.py | 640 --- backend/tests/test_ai_pmo_kernel_service.py | 1514 ------ .../test_ai_pmo_work_item_decision_api.py | 535 --- .../test_ai_steward_capture_feishu_api.py | 88 - backend/tests/test_ai_steward_fail_fast.py | 107 - .../tests/test_ai_steward_title_extract.py | 68 - backend/tests/test_api_router_order.py | 22 - .../tests/test_auth_password_validation.py | 40 - backend/tests/test_feishu_long_connection.py | 131 - backend/tests/test_feishu_routing_receipts.py | 229 - backend/tests/test_inbox_queue_view.py | 369 -- backend/tests/test_llm_providers_api.py | 40 - .../tests/test_llm_structured_extraction.py | 354 -- backend/tests/test_model_routing_api.py | 103 - .../tests/test_openclaw_commander_service.py | 470 -- ...test_project_knowledge_graph_guardrails.py | 117 - backend/tests/test_projects_delete_api.py | 338 -- .../test_projects_member_assignment_api.py | 203 - ...est_signal_source_orchestration_service.py | 427 -- .../tests/test_sstc_signal_state_machine.py | 162 - backend/tests/test_task_briefs_api.py | 162 - backend/tests/test_task_persistence.py | 80 - frontend/src/api/aiSteward.ts | 170 +- frontend/src/api/index.ts | 1 + frontend/src/api/knowledgeGraph.ts | 142 + .../ai-config/FeatureIntroGuide.tsx | 121 + .../ai-steward/OpenClawCommanderPanel.tsx | 390 -- .../components/sstc/SSTCReleaseStagePanel.tsx | 3 +- frontend/src/pages/AIConfigPage.tsx | 16 +- frontend/src/pages/AIStewardPage.tsx | 183 +- frontend/src/pages/CharterDetailPage.tsx | 3 +- frontend/src/pages/ContractSyncBoardPage.tsx | 19 +- .../pages/KnowledgeGraphContextBoardPage.tsx | 303 ++ frontend/src/pages/ModelRoutingConfigPage.tsx | 38 +- frontend/src/pages/TaskBriefCenterPage.tsx | 20 +- frontend/src/pages/TaskBriefRunsPage.tsx | 19 +- frontend/src/pages/TaskBriefTemplatePage.tsx | 19 +- frontend/src/router/index.tsx | 3 + frontend/tsconfig.tsbuildinfo | 2 +- scripts/verify_filter_combinations.py | 4 +- tests/__init__.py | 1 - tests/test_adaptive_rag_ack_first.py | 83 - tests/test_adaptive_rag_deep_policy.py | 48 - ...test_adaptive_rag_external_conversation.py | 52 - tests/test_dispatcher.py | 264 - tests/test_events.py | 340 -- tests/test_experiment_sync_flow.py | 118 - tests/test_feishu_pipeline_fallbacks.py | 51 - ...est_feishu_question_requirement_routing.py | 140 - tests/test_llm_fail_fast.py | 101 - ...est_model_capability_context_validation.py | 56 - tests/test_signal_capture_recent_cache.py | 68 - 用户使用手册.md | 8 +- 125 files changed, 9832 insertions(+), 17088 deletions(-) create mode 100644 backend/app/services/ai_pmo_decision_runtime.py create mode 100644 backend/app/services/ai_pmo_intent_routing.py create mode 100644 backend/app/services/ai_pmo_kernel_common.py create mode 100644 backend/app/services/ai_pmo_kernel_mixins_closure.py create mode 100644 backend/app/services/ai_pmo_kernel_mixins_closure_data.py create mode 100644 backend/app/services/ai_pmo_kernel_mixins_closure_helpers.py create mode 100644 backend/app/services/ai_pmo_kernel_mixins_closure_payload.py create mode 100644 backend/app/services/ai_pmo_kernel_mixins_followup.py create mode 100644 backend/app/services/ai_pmo_kernel_mixins_followup_project_signal.py create mode 100644 backend/app/services/ai_pmo_kernel_mixins_followup_project_signal_actions.py create mode 100644 backend/app/services/ai_pmo_kernel_mixins_followup_project_signal_resume.py create mode 100644 backend/app/services/ai_pmo_kernel_mixins_followup_records.py create mode 100644 backend/app/services/ai_pmo_kernel_mixins_followup_routing.py create mode 100644 backend/app/services/ai_pmo_kernel_mixins_followup_verification.py create mode 100644 backend/app/services/ai_pmo_kernel_mixins_ingress.py create mode 100644 backend/app/services/ai_pmo_kernel_mixins_ingress_flow.py create mode 100644 backend/app/services/ai_pmo_kernel_mixins_ingress_render.py create mode 100644 backend/app/services/ai_pmo_kernel_mixins_ingress_storage.py create mode 100644 backend/app/services/ai_pmo_kernel_mixins_intent.py create mode 100644 backend/app/services/ai_pmo_kernel_mixins_intent_llm.py create mode 100644 backend/app/services/ai_pmo_kernel_mixins_intent_resolve.py create mode 100644 backend/app/services/ai_pmo_kernel_mixins_intent_rules.py create mode 100644 backend/app/services/ai_pmo_kernel_mixins_world_model.py create mode 100644 backend/app/services/ai_pmo_kernel_mixins_world_model_aggregates.py create mode 100644 backend/app/services/ai_pmo_kernel_mixins_world_model_core.py create mode 100644 backend/app/services/ai_pmo_kernel_mixins_world_model_session.py create mode 100644 backend/app/services/ai_pmo_kernel_runtime.py create mode 100644 backend/app/services/ai_pmo_reply_renderer.py create mode 100644 backend/app/services/ai_pmo_typed_executor.py create mode 100644 backend/app/services/ai_pmo_typed_executor_common.py create mode 100644 backend/app/services/ai_pmo_typed_executor_mixins_discovery.py create mode 100644 backend/app/services/ai_pmo_typed_executor_mixins_execute.py create mode 100644 backend/app/services/ai_pmo_typed_executor_mixins_parse.py create mode 100644 backend/app/services/ai_pmo_workflow_runtime.py create mode 100644 backend/app/services/model_context_bundle_service.py delete mode 100644 backend/app/services/openclaw_commander_service.py create mode 100644 backend/app/services/project_command_utils.py delete mode 100644 backend/scripts/migrate_sqlite_to_postgres.py delete mode 100644 backend/tests/test_adaptive_rag_intent_corrections.py delete mode 100644 backend/tests/test_ai_agent_kernel.py delete mode 100644 backend/tests/test_ai_logs_api.py delete mode 100644 backend/tests/test_ai_orchestration_run_service.py delete mode 100644 backend/tests/test_ai_pmo_full_scenario.py delete mode 100644 backend/tests/test_ai_pmo_kernel_service.py delete mode 100644 backend/tests/test_ai_pmo_work_item_decision_api.py delete mode 100644 backend/tests/test_ai_steward_capture_feishu_api.py delete mode 100644 backend/tests/test_ai_steward_fail_fast.py delete mode 100644 backend/tests/test_ai_steward_title_extract.py delete mode 100644 backend/tests/test_api_router_order.py delete mode 100644 backend/tests/test_auth_password_validation.py delete mode 100644 backend/tests/test_feishu_long_connection.py delete mode 100644 backend/tests/test_feishu_routing_receipts.py delete mode 100644 backend/tests/test_inbox_queue_view.py delete mode 100644 backend/tests/test_llm_providers_api.py delete mode 100644 backend/tests/test_llm_structured_extraction.py delete mode 100644 backend/tests/test_model_routing_api.py delete mode 100644 backend/tests/test_openclaw_commander_service.py delete mode 100644 backend/tests/test_project_knowledge_graph_guardrails.py delete mode 100644 backend/tests/test_projects_delete_api.py delete mode 100644 backend/tests/test_projects_member_assignment_api.py delete mode 100644 backend/tests/test_signal_source_orchestration_service.py delete mode 100644 backend/tests/test_sstc_signal_state_machine.py delete mode 100644 backend/tests/test_task_briefs_api.py delete mode 100644 backend/tests/test_task_persistence.py create mode 100644 frontend/src/api/knowledgeGraph.ts create mode 100644 frontend/src/components/ai-config/FeatureIntroGuide.tsx delete mode 100644 frontend/src/components/ai-steward/OpenClawCommanderPanel.tsx create mode 100644 frontend/src/pages/KnowledgeGraphContextBoardPage.tsx delete mode 100644 tests/__init__.py delete mode 100644 tests/test_adaptive_rag_ack_first.py delete mode 100644 tests/test_adaptive_rag_deep_policy.py delete mode 100644 tests/test_adaptive_rag_external_conversation.py delete mode 100644 tests/test_dispatcher.py delete mode 100644 tests/test_events.py delete mode 100644 tests/test_experiment_sync_flow.py delete mode 100644 tests/test_feishu_pipeline_fallbacks.py delete mode 100644 tests/test_feishu_question_requirement_routing.py delete mode 100644 tests/test_llm_fail_fast.py delete mode 100644 tests/test_model_capability_context_validation.py delete mode 100644 tests/test_signal_capture_recent_cache.py diff --git a/CLAUDE.md b/CLAUDE.md index cb4b4dfb..22435448 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -23,7 +23,6 @@ AIProjectManger/ ├── backend/ # FastAPI 后端 ├── frontend/ # React 前端 ├── scripts/ # 根目录运维脚本 -├── tests/ # 根级测试(集成/脚本) └── docs/ # 额外文档 ``` - 新增:`docs/INBOX_INLINE_OPS_3_PHASE_CHECKLIST.md`(消息台内联操作三期执行清单) @@ -57,7 +56,7 @@ backend/ │ └── backfill_orphan_signal_links.py # 回填孤立 signal 的 project_signal_links ├── requirements.txt # Python 依赖 ├── Dockerfile -└── tests/ +└── (tests 已移除) ``` - 新增:`backend/app/api/llm_providers.py`(AI 管家 LLM provider preset 配置读取接口) - 新增:`backend/app/services/ai_agent_kernel.py`(双 Agent 共享决策内核:复杂度路由/执行门控/修正识别) @@ -68,22 +67,58 @@ backend/ - 新增:`backend/app/services/audit_query_service.py`(operation_logs 过滤与序列化) - 新增:`backend/app/services/signal_source_repair_service.py`(脏信号源回填服务) - 新增:`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/app/services/signal_strategic_analysis_service.py`(Signal Planner 写回收口) - 新增:`backend/app/services/feishu_outbox_service.py`(复用 reply_hash 的 outbox 入队收口) - 新增:`backend/app/services/signal_planner_service.py`(Signal Planner 异步分析服务) - 新增:`backend/app/services/model_routing_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/task_briefs.py`(任务包中心接口) -- 新增:`backend/tests/test_model_routing_api.py`(模型路由接口回归测试) -- 新增:`backend/tests/test_task_briefs_api.py`(任务包中心接口回归测试) -- 新增:`backend/tests/test_project_knowledge_graph_guardrails.py`(双模型 DDL 治理知识图谱同步回归测试) -- 更新:`backend/app/services/llm.py`(按 function_name 注入 DDL 接口化硬约束,覆盖 low/high 意图模型与 OpenClaw 规划模型) +- 更新:`backend/app/api/knowledge_graph.py`(新增 `capability-summary` 与 `verify-context-consumption` 接口) +- 更新:`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/.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/`) ```text @@ -112,11 +147,16 @@ frontend/ - 新增:`frontend/src/components/ui/SkillBadge.tsx`(技能徽章组件,默认显示 3 个并支持 +N) - 新增:`frontend/src/api/taskBriefs.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/TaskBriefTemplatePage.tsx`(任务包模板管理页面) - 新增:`frontend/src/pages/TaskBriefRunsPage.tsx`(任务包生成记录页面) - 新增:`frontend/src/pages/ContractSyncBoardPage.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/` 目录说明(模块化拆分后) - `SSTCWorkspace.tsx`:SSTC 工作区主容器(组装各阶段组件) @@ -193,6 +233,7 @@ uvicorn app.main:app --reload --host 0.0.0.0 --port 8000 ## 8. 服务边界约束(禁止越界) - 数据库写操作必须通过 Alembic migration,禁止直接 `ALTER TABLE` - 运行期禁止 DDL:`database.py` 钩子统一拦截 `CREATE/ALTER/DROP/...`,仅迁移路径允许执行 +- 运行环境数据库仅允许 PostgreSQL(`postgresql+asyncpg`);禁止回落 SQLite - 新增接口必须遵循响应包络:`{code, message, data}`,不得裸返回 - 禁止在 API 层写业务逻辑,业务逻辑归属 `services/` - 禁止在前端组件内直接调用 `fetch`,必须通过 `frontend/src/api/` 封装层 @@ -201,20 +242,30 @@ uvicorn app.main:app --reload --host 0.0.0.0 --port 8000 - 所有写接口必须落审计日志(`operation_logs`):至少包含 `trace_id/interface_path/caller` - `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. 新增代码的参考模式 - 新增后端接口:参考 `backend/app/api/sstc.py` 的结构 - 模型路由配置接口:参考 `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`) +- 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/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`) +- AI Steward 指挥规划接口已移除:`POST /ai-steward/command/dispatch` - 项目删除接口(`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` - 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 审批接口补充:`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 消息台列表接口补充:返回源消息上下文字段 `source_message_text/source_captured_at/source_sender/source_channel_ref`,用于详情页展示“原始输入”与事件时间 - AI PMO 消息台列表接口补充:返回闭环语义字段 `source_conversation_closure/closure_summary/implementation_modules/source_closure_turns`,用于详情页展示“消息闭环 + 实现模块” @@ -222,7 +273,7 @@ uvicorn app.main:app --reload --host 0.0.0.0 --port 8000 - AI PMO 消息台列表接口补充:过滤短口水话 `query.read_only` 项(如“怎么样了/在吗”),保留后端记录但不下发前端收件流 - 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` 去前缀文本 -- 信号源编排事务补充:`SignalSourceOrchestrationService` 内部调用 OpenClaw 指挥链使用隔离 Session,禁止把子链路 rollback 传播到外层 `signal/work_item` 事务 +- 信号源编排补充:`SignalSourceOrchestrationService` 已停止运行时调用旧 command-first 执行链,统一改为“GLM 分析后直接调用服务写入”,编排结果字段对外收敛为 `execution` - 新增前端 API 调用:参考 `frontend/src/api/sstc.ts` - 新增数据库表:参考 `alembic/versions/` 最新 migration - 新增飞书事件处理:参考 `backend/app/services/feishu_integration.py` @@ -235,22 +286,27 @@ uvicorn app.main:app --reload --host 0.0.0.0 --port 8000 ## 11. 已知 BOUNDARY(不要幻构行为) - 飞书回调字段可能缺失文件名/大小,仅保证 `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) - 写操作后主动 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` 已移除且不再读取 -- OpenClaw 路由规则统一由根目录 `.openclaw/pmflow-routing-rules.json` 管理,容器通过 `OPENCLAW_ROUTING_RULES_PATH` 相对路径 `../.openclaw/pmflow-routing-rules.json` 读取 -- Signal Planner 函数路由支持 `integrations(type='model_routing')` 覆盖配置;配置缺失时回退 OpenClaw 默认规则并记录告警 -- Signal Planner 结构化调用采用 `25s` 硬超时:超时或结构化不合法时自动写入降级 `ai_planner(fallback_mode=true)` 并收敛为 `planner_status=completed` +- Signal Planner 函数路由支持 `integrations(type='model_routing')` 覆盖配置;配置缺失时默认直连 GLM +- Signal Planner 结构化调用采用 `25s` 硬超时:超时或结构化不合法时标记失败并通知人工重试,不再写入降级 `ai_planner(fallback_mode=true)` +- AI Steward 用户侧已移除 OpenClaw 指挥规划入口;后续项目分析主链以 GLM 严格失败直出为准 +- 飞书业务命令命中旧 command-first 路由时,仅返回“已下线”阻断提示,不再触发实际执行 - 模型命名统一使用裸模型名(如 `GLM-4.7`、`MiniMax-M2.5`),不使用供应商前缀模型名 -- 双 Agent 路由治理:默认启用 `OPENCLAW_ROUTING_ENABLED=true`、关闭 `LLM_MIXED_ROUTING_ENABLED`,复杂意图使用 `classify_intent_complex` 走 high-tier -- 双模型 DDL 治理图谱的模型来源优先读取 `OPENCLAW_ROUTING_RULES_PATH` 指向的路由规则;读取失败时回退 `LLM_ROUTING_LOW_MODEL/LLM_ROUTING_HIGH_MODEL` +- 双 Agent 路由治理:默认直连 `GLM`,复杂意图仍使用 `classify_intent_complex`,不再经过 OpenClaw 控制平面 +- 双模型 DDL 治理图谱的模型来源优先读取显式模型配置;缺失时回退 `LLM_ROUTING_LOW_MODEL/LLM_ROUTING_HIGH_MODEL` - 写操作链路统一产出 `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` 强制进入人工确认,不直接自动执行 +- 飞书 `qa_llm` 回复已增加事实熔断:命中“数据库迁移/DDL/Alembic/让我直接处理/模型失败了所以没分析”等漂移语句时,自动回退固定模板,禁止对外宣称未证实执行动作 - `project.create` 启用槽位完整性闸门:无法从指令提取有效项目名时标记 `slot_incomplete` 并进入人工确认 - 数据库与核心写入链路统一使用北京时间(`Asia/Shanghai`):PostgreSQL 会话时区、数据库默认时区与核心 `_utc_now*` 写入入口均已切换到北京时间 - 后端与脚本层已清零 `datetime.utcnow()/timezone.utc`:统一使用 `backend/app/utils/timezone.py`(`now_cn_aware/now_cn_naive/CN_TZ`)管理时间语义 - `operation_logs.created_at_beijing` 为应用层计算字段(非 DB 列),审计查询接口返回时统一补齐 - 历史脏信号源修复优先复用同项目已存在 `signal_id`,仅无法复用时才触发 `capture-feishu` 补录 - 兼容 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` diff --git a/backend/Dockerfile b/backend/Dockerfile index 4193707d..86121f5b 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -13,6 +13,7 @@ COPY backend/requirements.txt /tmp/requirements.txt RUN pip install --no-cache-dir -r /tmp/requirements.txt COPY backend /app/backend +COPY docs /app/docs COPY .openclaw /app/.openclaw EXPOSE 8000 diff --git a/backend/alembic/env.py b/backend/alembic/env.py index 32792f11..ea47e3ae 100644 --- a/backend/alembic/env.py +++ b/backend/alembic/env.py @@ -24,8 +24,7 @@ if config.config_file_name is not None: # Set the database URL from settings # Replace async driver with sync driver for migrations -db_url = settings.database_url.replace("sqlite+aiosqlite", "sqlite") -db_url = db_url.replace("postgresql+asyncpg", "postgresql+psycopg") +db_url = settings.database_url.replace("postgresql+asyncpg", "postgresql+psycopg") config.set_main_option("sqlalchemy.url", db_url) # add your model's MetaData object here diff --git a/backend/app/api/ai_pmo.py b/backend/app/api/ai_pmo.py index fb81f253..e4d3f13c 100644 --- a/backend/app/api/ai_pmo.py +++ b/backend/app/api/ai_pmo.py @@ -23,7 +23,7 @@ from ..services.ai_pmo_kernel_service import AIPMOKernelService router = APIRouter(prefix="/ai-pmo", tags=["AI PMO"]) logger = get_logger(__name__) -FRONT_NOISE_INTENT = "query.read_only" +FRONT_NOISE_INTENTS = {"query.read_only", "query.answer"} FRONT_NOISE_EXACT = { "收到", "收到啦", @@ -63,6 +63,7 @@ class PMOCadenceRunRequest(BaseModel): class PMOResumeWorkItemRequest(BaseModel): + action: Literal["continue", "commit", "refresh", "cancel"] = Field(default="continue") project_id: Optional[int] = Field(default=None) source_message_id: Optional[str] = Field(default=None, max_length=128) @@ -271,11 +272,18 @@ async def resume_work_item( ).scalar_one_or_none() if target is None: 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) raw_result = await service.process_ingress( source_system="ai_pmo_ui", 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用户"), channel_ref="ai_pmo_ui", 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: intent_name = str(row.intent or "").strip().lower() - if intent_name != FRONT_NOISE_INTENT: + if intent_name not in FRONT_NOISE_INTENTS: return False payload = ingest.payload if isinstance(ingest.payload, dict) else {} message_text = str(payload.get("message_text") or "").strip() diff --git a/backend/app/api/ai_steward.py b/backend/app/api/ai_steward.py index 43738851..f7e97649 100644 --- a/backend/app/api/ai_steward.py +++ b/backend/app/api/ai_steward.py @@ -31,7 +31,6 @@ from ..services.event_workflow import get_event_workflow_engine, WorkflowEventTy from ..services.adaptive_rag_service import AdaptiveRAGService from ..services.ai_orchestration_run_service import AIOrchestrationRunService from ..services.feishu_outbox_service import FeishuOutboxService -from ..services.openclaw_commander_service import OpenClawCommanderService from ..services.signal_source_orchestration_service import SignalSourceOrchestrationService from ..services.signal_source_repair_service import ( SignalSourceRepairMissingSignalError, @@ -153,15 +152,6 @@ class ConflictDetectRequest(BaseModel): 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): project_id: int = Field(..., ge=1) instruction: str = Field(..., min_length=6, max_length=2000) @@ -231,34 +221,6 @@ async def ask_ai_steward_rag( 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") @audited_write(caller="user") async def onboard_signal_source( diff --git a/backend/app/api/knowledge_graph.py b/backend/app/api/knowledge_graph.py index 0ed005dc..1a94ba20 100644 --- a/backend/app/api/knowledge_graph.py +++ b/backend/app/api/knowledge_graph.py @@ -15,6 +15,7 @@ from ..dependencies.auth import get_current_user from ..dependencies.tenant import get_tenant_id from ..models import User from ..services.auth import has_any_role +from ..services.model_context_bundle_service import ModelContextBundleService from ..services.project_knowledge_graph import ProjectKnowledgeGraphService 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) +class VerifyContextConsumptionRequest(BaseModel): + run_canary: bool = Field(default=True) + + def _require_manager(user: User) -> None: if not has_any_role(str(user.role or ""), ["BOSS", "ADMIN"]): raise HTTPException(status_code=403, detail="仅管理员可操作知识图谱同步") @@ -93,6 +98,37 @@ async def get_sync_slo( 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") async def sync_structured( body: StructuredSyncRequest, diff --git a/backend/app/api/llm_engines.py b/backend/app/api/llm_engines.py index f3e20ba1..a76b4e1b 100644 --- a/backend/app/api/llm_engines.py +++ b/backend/app/api/llm_engines.py @@ -40,6 +40,10 @@ class LLMEngineTestRequest(BaseModel): prompt: Optional[str] = Field(default=None, max_length=1000) +class LLMEngineSettingsUpdateRequest(BaseModel): + enable_local_kb_qa: bool = False + + def _require_manager(user: User) -> None: if not has_any_role(str(user.role or ""), ["BOSS", "ADMIN"]): 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 +@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}") async def update_llm_engine( engine_id: str, diff --git a/backend/app/config/ai_prompts.py b/backend/app/config/ai_prompts.py index 45ebdb27..02c2446a 100644 --- a/backend/app/config/ai_prompts.py +++ b/backend/app/config/ai_prompts.py @@ -225,6 +225,9 @@ PROMPT_FEISHU_HUMANIZED_REPLY = """ 4. 若信息不足,请明确缺失信息并给出 1-3 条可执行下一步。 5. 如果用户询问日期/时间/星期,必须基于“当前系统时间(北京时间)”直接回答,不得编造。 6. 不要输出 Markdown,不要输出 JSON。 +7. 禁止宣称“已执行/将执行”任何未在上下文明确完成的动作。 +8. 禁止输出数据库内部实施细节(如数据库迁移、DDL、SQL、ALTER TABLE、DROP TABLE、Alembic)。 +9. 当 status/classification 无法证明写入动作已成功时,禁止编造处理步骤。 只输出最终回复正文。 """.strip() @@ -380,53 +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。 -14. 严禁输出任何 DDL 直连数据库执行方案(CREATE/ALTER/DROP/TRUNCATE/RENAME/COMMENT/GRANT/REVOKE)。 -15. 若输入涉及数据库 DDL 变更,必须返回 operations=[],并在 summary 明确写出 ddl_api_required(表示必须走受控接口/迁移流程)。 -""" - - PROMPT_SIGNAL_SOURCE_ONBOARDING = """ 首字符必须是 {{,末字符必须是 }}。 你是 PMFlow 的信号源接入规划器。判断当前请求是不是“新增/接入一个信号源”,并返回结构化计划。 @@ -448,7 +404,7 @@ PROMPT_SIGNAL_SOURCE_ONBOARDING = """ "config": {{}} }}, "worker_roles": ["pm_dispatcher", "tech_architect", "delivery_coach"], - "openclaw_instruction": "交给 OpenClaw 的自然语言指令" + "execution_instruction": "交给显式服务执行层的自然语言指令" }} 若不是信号源接入请求,输出: @@ -457,14 +413,14 @@ PROMPT_SIGNAL_SOURCE_ONBOARDING = """ "summary": "不是信号源接入请求的原因", "source": null, "worker_roles": [], - "openclaw_instruction": "" + "execution_instruction": "" }} 规则: 1. 只有明确新增/接入/配置信号源时才返回 should_onboard=true。 2. 不编造 url / chat_id / repo / webhook。 3. worker_roles 只允许 pm_dispatcher / tech_architect / delivery_coach。 -4. openclaw_instruction 保持简短,只说接入准备、任务拆解、负责人通知。 +4. execution_instruction 保持简短,只说接入准备、任务拆解、负责人通知。 5. 无法确定 source_identifier 时,返回短且稳定的键,不要输出解释。 6. 禁止输出 thinking、analysis、解释句、Markdown、代码块。 7. 字段未知时返回空字符串、空数组或 null。 @@ -512,27 +468,70 @@ PROMPT_AI_PMO_INTENT_CLASSIFICATION = """ - session_project_id: {session_project_id} - world_model_project_id: {world_model_project_id} - candidate_intents_json: {candidate_intents_json} +- candidate_routes_json: {candidate_routes_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, "project_id": 0, + "requested_count": 1, + "requires_confirmation": false, "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 判定。 -3. project_id 无法确定时返回 null,不要猜测无依据项目。 -4. 噪音/寒暄/无关消息返回 ignore。 +9. project_id 无法确定时返回 null,不要猜测无依据项目。 +10. 噪音/寒暄/无关消息返回 family=query action=answer,intent=query.read_only,reasoning 包含 smalltalk_ignore。 5. 禁止输出 thinking、analysis、解释句、Markdown、代码块。 -6. 若消息表达“新增/增加/添加/新建/创建/建立 + 项目”(如“给我新增个项目”),优先判定 project.create;仅当明确是“信号源/来源/webhook/rss/github 接入或创建信号”时才判 signal_source.onboard 或 signal.create。 -7. 只输出 JSON,不要额外文本。 -8. 若消息要求直接执行数据库 DDL(建表/改表/删表等),禁止判定为可自动写入意图,必须返回 query.read_only,并在 reasoning 包含 ddl_api_required。 +11. 若消息表达“新增/增加/添加/新建/创建/建立 + 项目”(如“给我新增个项目”),优先判定 project_create.create;仅当明确是“信号源/来源/webhook/rss/github 接入或创建信号”时才判 signal_source.onboard 或 project_signal.create。 +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 # ============================================================================ diff --git a/backend/app/core/config.py b/backend/app/core/config.py index b002f76c..cc415a8a 100644 --- a/backend/app/core/config.py +++ b/backend/app/core/config.py @@ -14,9 +14,16 @@ BACKEND_DIR = Path(__file__).resolve().parents[2] PROJECT_ROOT = BACKEND_DIR.parent load_dotenv(PROJECT_ROOT / ".env", override=False) -# 默认数据库固定到 backend/neural_sync.db,避免相对路径导致多库混用 -DEFAULT_SQLITE_DB = BACKEND_DIR / "neural_sync.db" -DEFAULT_DATABASE_URL = f"sqlite+aiosqlite:///{DEFAULT_SQLITE_DB.as_posix()}" +# 默认数据库固定到 Docker PostgreSQL,不再提供 SQLite 回落 +DEFAULT_DB_HOST = os.getenv("DB_HOST", "127.0.0.1") +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: diff --git a/backend/app/database.py b/backend/app/database.py index 546f37b0..99fef2a9 100644 --- a/backend/app/database.py +++ b/backend/app/database.py @@ -15,7 +15,6 @@ from sqlalchemy.ext.asyncio import ( create_async_engine, ) from sqlalchemy.orm import Session -from sqlalchemy.pool import NullPool from .core.config import settings from .core.logging import get_logger @@ -44,49 +43,34 @@ DDL_PREFIXES = ( ) -def _is_sqlite() -> bool: - return DATABASE_URL.lower().startswith("sqlite") +def _is_postgresql() -> bool: + 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 = { "echo": settings.debug, "future": True, - "pool_pre_ping": not _is_sqlite(), + "pool_pre_ping": True, + "pool_size": max(5, int(settings.db_pool_size)), + "max_overflow": max(0, int(settings.db_max_overflow)), + "pool_timeout": max(5, int(settings.db_pool_timeout)), + "pool_recycle": max(60, int(settings.db_pool_recycle)), + # 强制数据库会话时区为北京时间,确保 now()/CURRENT_TIMESTAMP 为 Asia/Shanghai。 + "connect_args": {"server_settings": {"timezone": "Asia/Shanghai"}}, } -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)), - "max_overflow": max(0, int(settings.db_max_overflow)), - "pool_timeout": max(5, int(settings.db_pool_timeout)), - "pool_recycle": max(60, int(settings.db_pool_recycle)), - # 强制数据库会话时区为北京时间,确保 now()/CURRENT_TIMESTAMP 为 Asia/Shanghai。 - "connect_args": {"server_settings": {"timezone": "Asia/Shanghai"}}, - } - ) engine: AsyncEngine = create_async_engine( DATABASE_URL, **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: text = str(statement or "") diff --git a/backend/app/main.py b/backend/app/main.py index dcdab3df..0d367f6b 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -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: raw = str(value or "").strip().lower() if not raw: return "" if raw.startswith("postgres"): return "postgresql" - if raw.startswith("sqlite"): - return "sqlite" if raw.startswith("mysql"): return "mysql" if "+" in raw: @@ -131,7 +78,7 @@ def _mask_db_url(url: str) -> str: def _check_database_dialect_guard(sync_conn) -> None: configured = _normalize_dialect(settings.database_url) 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 = [ 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"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: raise RuntimeError( f"数据库方言不一致: configured={configured}, actual={actual}。" @@ -165,189 +116,10 @@ def _check_database_dialect_guard(sync_conn) -> None: if len(configured_families) > 1: raise RuntimeError( 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 - """ - ) - - def _is_postgresql_runtime() -> bool: return _normalize_dialect(engine.dialect.name) == "postgresql" diff --git a/backend/app/models/feishu_item.py b/backend/app/models/feishu_item.py index 7d3e502c..544456df 100644 --- a/backend/app/models/feishu_item.py +++ b/backend/app/models/feishu_item.py @@ -31,7 +31,6 @@ class FeishuItem(Base): "feishu_message_id", unique=True, 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( "uq_feishu_items_tenant_message_split", @@ -40,7 +39,6 @@ class FeishuItem(Base): "split_from_item_id", unique=True, 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"), ), ) diff --git a/backend/app/models/types.py b/backend/app/models/types.py index 3f579804..18cfbe34 100644 --- a/backend/app/models/types.py +++ b/backend/app/models/types.py @@ -1,18 +1,14 @@ -"""PostgreSQL / SQLite 类型兼容层 - -仅当 DATABASE_URL 明确为 PostgreSQL 时使用 JSONB/ARRAY/Vector, -否则使用 SQLite 兼容的 TypeDecorator(与 database.py 默认一致)。 -""" +"""PostgreSQL 类型兼容层。""" import os import json -# 与 database.py 默认一致:未设置或 sqlite 时按 SQLite 处理 -_database_url = os.getenv("DATABASE_URL", "sqlite+aiosqlite:///./neural_sync.db").lower() +# 与 database.py 保持一致:默认即 PostgreSQL +_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 try: if not _use_pg: - raise ImportError("SQLite in use") + raise ImportError("PostgreSQL not configured") from sqlalchemy.dialects.postgresql import JSONB, ARRAY from pgvector.sqlalchemy import Vector HAS_PG = True diff --git a/backend/app/seed_projects.py b/backend/app/seed_projects.py index 9be6f4a0..fb2957d8 100644 --- a/backend/app/seed_projects.py +++ b/backend/app/seed_projects.py @@ -8,11 +8,10 @@ Seed Projects and Signals import asyncio from datetime import datetime 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.orm import sessionmaker -from app.database import _is_sqlite from app.models import Signal, Project, ProjectSignalLink, TruthNode, Task, User 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": "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: session.add(signal) 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): link = ProjectSignalLink( project_id=pid, @@ -122,8 +114,6 @@ async def _ensure_pixelle_sstc_data(session: AsyncSession, pixelle_project: Proj link_type="related", relevance_score=1.0, ) - if _is_sqlite() and next_link_id is not None: - link.id = next_link_id + i session.add(link) pixelle_project.signal_count = len(pixelle_signals) changed = True @@ -164,13 +154,8 @@ async def _ensure_pixelle_sstc_data(session: AsyncSession, pixelle_project: Proj version=1, 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) 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([ {"title": "Integrate Pixelle-Video API", "priority": "high", "hours": 8.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"], estimated_hours=task_item["hours"], ) - if _is_sqlite() and next_task_id is not None: - task.id = next_task_id + idx session.add(task) changed = True print(f"[SSTC] 已为「{PIXELLE_NAME}」恢复宪章 1 个、任务 3 个") diff --git a/backend/app/services/ai_orchestration_run_service.py b/backend/app/services/ai_orchestration_run_service.py index 6fac0b8f..faedd2d4 100644 --- a/backend/app/services/ai_orchestration_run_service.py +++ b/backend/app/services/ai_orchestration_run_service.py @@ -31,7 +31,7 @@ class AIOrchestrationRunService: response_mode: str, status: str, planner_status: Optional[str], - openclaw_status: Optional[str], + execution_status: Optional[str], worker_total: int, worker_completed: int, worker_failed: int, @@ -51,7 +51,7 @@ class AIOrchestrationRunService: response_mode=response_mode, status=status, planner_status=planner_status, - openclaw_status=openclaw_status, + openclaw_status=execution_status, worker_total=max(0, int(worker_total)), worker_completed=max(0, int(worker_completed)), worker_failed=max(0, int(worker_failed)), @@ -85,7 +85,7 @@ class AIOrchestrationRunService: trace_id: str, 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_completed: Optional[int] = None, worker_failed: Optional[int] = None, @@ -101,8 +101,8 @@ class AIOrchestrationRunService: row.status = str(status or "").strip()[:20] if planner_status is not None: row.planner_status = str(planner_status or "").strip()[:20] or None - if openclaw_status is not None: - row.openclaw_status = str(openclaw_status or "").strip()[:20] or None + if execution_status is not None: + row.openclaw_status = str(execution_status or "").strip()[:20] or None if worker_total is not None: row.worker_total = max(0, int(worker_total)) if worker_completed is not None: @@ -165,7 +165,7 @@ class AIOrchestrationRunService: "response_mode": str(row.response_mode or ""), "run_status": str(row.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_completed": worker_completed, "worker_failed": worker_failed, diff --git a/backend/app/services/ai_pmo_decision_runtime.py b/backend/app/services/ai_pmo_decision_runtime.py new file mode 100644 index 00000000..054a3865 --- /dev/null +++ b/backend/app/services/ai_pmo_decision_runtime.py @@ -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 diff --git a/backend/app/services/ai_pmo_domain_workflow.py b/backend/app/services/ai_pmo_domain_workflow.py index 0cbb672d..5a8d73a1 100644 --- a/backend/app/services/ai_pmo_domain_workflow.py +++ b/backend/app/services/ai_pmo_domain_workflow.py @@ -44,7 +44,8 @@ class SignalSourceDomainWorkflow: payload = raw_result if isinstance(raw_result, 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 [] - 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 = [ row for row in typed_actions @@ -56,9 +57,11 @@ class SignalSourceDomainWorkflow: raw_status = str(payload.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() - 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" elif onboarding_status != "completed": final_status = "pending_manual" @@ -77,7 +80,7 @@ class SignalSourceDomainWorkflow: "registry_id": signal_source.get("registry_id"), "registry_status": registry_status, "onboarding_status": onboarding_status, - "openclaw_status": openclaw_status, + "execution_status": execution_status, "typed_actions": typed_actions, "material_action_count": len(material_actions), "worker_executions": worker_executions, diff --git a/backend/app/services/ai_pmo_intent_routing.py b/backend/app/services/ai_pmo_intent_routing.py new file mode 100644 index 00000000..17536960 --- /dev/null +++ b/backend/app/services/ai_pmo_intent_routing.py @@ -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 diff --git a/backend/app/services/ai_pmo_kernel_common.py b/backend/app/services/ai_pmo_kernel_common.py new file mode 100644 index 00000000..cab5b39b --- /dev/null +++ b/backend/app/services/ai_pmo_kernel_common.py @@ -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 + + diff --git a/backend/app/services/ai_pmo_kernel_mixins_closure.py b/backend/app/services/ai_pmo_kernel_mixins_closure.py new file mode 100644 index 00000000..dfe8e169 --- /dev/null +++ b/backend/app/services/ai_pmo_kernel_mixins_closure.py @@ -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 diff --git a/backend/app/services/ai_pmo_kernel_mixins_closure_data.py b/backend/app/services/ai_pmo_kernel_mixins_closure_data.py new file mode 100644 index 00000000..178f8d38 --- /dev/null +++ b/backend/app/services/ai_pmo_kernel_mixins_closure_data.py @@ -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}, + ] diff --git a/backend/app/services/ai_pmo_kernel_mixins_closure_helpers.py b/backend/app/services/ai_pmo_kernel_mixins_closure_helpers.py new file mode 100644 index 00000000..8a374db6 --- /dev/null +++ b/backend/app/services/ai_pmo_kernel_mixins_closure_helpers.py @@ -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"]*>", " ", 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) diff --git a/backend/app/services/ai_pmo_kernel_mixins_closure_payload.py b/backend/app/services/ai_pmo_kernel_mixins_closure_payload.py new file mode 100644 index 00000000..796e6804 --- /dev/null +++ b/backend/app/services/ai_pmo_kernel_mixins_closure_payload.py @@ -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 diff --git a/backend/app/services/ai_pmo_kernel_mixins_followup.py b/backend/app/services/ai_pmo_kernel_mixins_followup.py new file mode 100644 index 00000000..facc70ef --- /dev/null +++ b/backend/app/services/ai_pmo_kernel_mixins_followup.py @@ -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 diff --git a/backend/app/services/ai_pmo_kernel_mixins_followup_project_signal.py b/backend/app/services/ai_pmo_kernel_mixins_followup_project_signal.py new file mode 100644 index 00000000..bcff61b0 --- /dev/null +++ b/backend/app/services/ai_pmo_kernel_mixins_followup_project_signal.py @@ -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 diff --git a/backend/app/services/ai_pmo_kernel_mixins_followup_project_signal_actions.py b/backend/app/services/ai_pmo_kernel_mixins_followup_project_signal_actions.py new file mode 100644 index 00000000..dc0b6dc1 --- /dev/null +++ b/backend/app/services/ai_pmo_kernel_mixins_followup_project_signal_actions.py @@ -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 + diff --git a/backend/app/services/ai_pmo_kernel_mixins_followup_project_signal_resume.py b/backend/app/services/ai_pmo_kernel_mixins_followup_project_signal_resume.py new file mode 100644 index 00000000..73a118f9 --- /dev/null +++ b/backend/app/services/ai_pmo_kernel_mixins_followup_project_signal_resume.py @@ -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 diff --git a/backend/app/services/ai_pmo_kernel_mixins_followup_records.py b/backend/app/services/ai_pmo_kernel_mixins_followup_records.py new file mode 100644 index 00000000..117074ea --- /dev/null +++ b/backend/app/services/ai_pmo_kernel_mixins_followup_records.py @@ -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 + diff --git a/backend/app/services/ai_pmo_kernel_mixins_followup_routing.py b/backend/app/services/ai_pmo_kernel_mixins_followup_routing.py new file mode 100644 index 00000000..7d1ed51d --- /dev/null +++ b/backend/app/services/ai_pmo_kernel_mixins_followup_routing.py @@ -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 diff --git a/backend/app/services/ai_pmo_kernel_mixins_followup_verification.py b/backend/app/services/ai_pmo_kernel_mixins_followup_verification.py new file mode 100644 index 00000000..4d34d36c --- /dev/null +++ b/backend/app/services/ai_pmo_kernel_mixins_followup_verification.py @@ -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}" diff --git a/backend/app/services/ai_pmo_kernel_mixins_ingress.py b/backend/app/services/ai_pmo_kernel_mixins_ingress.py new file mode 100644 index 00000000..f97e1f1f --- /dev/null +++ b/backend/app/services/ai_pmo_kernel_mixins_ingress.py @@ -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 diff --git a/backend/app/services/ai_pmo_kernel_mixins_ingress_flow.py b/backend/app/services/ai_pmo_kernel_mixins_ingress_flow.py new file mode 100644 index 00000000..5c5f9718 --- /dev/null +++ b/backend/app/services/ai_pmo_kernel_mixins_ingress_flow.py @@ -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)} + diff --git a/backend/app/services/ai_pmo_kernel_mixins_ingress_render.py b/backend/app/services/ai_pmo_kernel_mixins_ingress_render.py new file mode 100644 index 00000000..0371b016 --- /dev/null +++ b/backend/app/services/ai_pmo_kernel_mixins_ingress_render.py @@ -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"]*>", " ", 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), + } + diff --git a/backend/app/services/ai_pmo_kernel_mixins_ingress_storage.py b/backend/app/services/ai_pmo_kernel_mixins_ingress_storage.py new file mode 100644 index 00000000..247d50a0 --- /dev/null +++ b/backend/app/services/ai_pmo_kernel_mixins_ingress_storage.py @@ -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() + diff --git a/backend/app/services/ai_pmo_kernel_mixins_intent.py b/backend/app/services/ai_pmo_kernel_mixins_intent.py new file mode 100644 index 00000000..87ee2a5f --- /dev/null +++ b/backend/app/services/ai_pmo_kernel_mixins_intent.py @@ -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 diff --git a/backend/app/services/ai_pmo_kernel_mixins_intent_llm.py b/backend/app/services/ai_pmo_kernel_mixins_intent_llm.py new file mode 100644 index 00000000..d7d85830 --- /dev/null +++ b/backend/app/services/ai_pmo_kernel_mixins_intent_llm.py @@ -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 + diff --git a/backend/app/services/ai_pmo_kernel_mixins_intent_resolve.py b/backend/app/services/ai_pmo_kernel_mixins_intent_resolve.py new file mode 100644 index 00000000..e69c298c --- /dev/null +++ b/backend/app/services/ai_pmo_kernel_mixins_intent_resolve.py @@ -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" + diff --git a/backend/app/services/ai_pmo_kernel_mixins_intent_rules.py b/backend/app/services/ai_pmo_kernel_mixins_intent_rules.py new file mode 100644 index 00000000..78b3b427 --- /dev/null +++ b/backend/app/services/ai_pmo_kernel_mixins_intent_rules.py @@ -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(), + }, + ) diff --git a/backend/app/services/ai_pmo_kernel_mixins_world_model.py b/backend/app/services/ai_pmo_kernel_mixins_world_model.py new file mode 100644 index 00000000..40c6e920 --- /dev/null +++ b/backend/app/services/ai_pmo_kernel_mixins_world_model.py @@ -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 diff --git a/backend/app/services/ai_pmo_kernel_mixins_world_model_aggregates.py b/backend/app/services/ai_pmo_kernel_mixins_world_model_aggregates.py new file mode 100644 index 00000000..674873cb --- /dev/null +++ b/backend/app/services/ai_pmo_kernel_mixins_world_model_aggregates.py @@ -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), + } + diff --git a/backend/app/services/ai_pmo_kernel_mixins_world_model_core.py b/backend/app/services/ai_pmo_kernel_mixins_world_model_core.py new file mode 100644 index 00000000..4952f716 --- /dev/null +++ b/backend/app/services/ai_pmo_kernel_mixins_world_model_core.py @@ -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 diff --git a/backend/app/services/ai_pmo_kernel_mixins_world_model_session.py b/backend/app/services/ai_pmo_kernel_mixins_world_model_session.py new file mode 100644 index 00000000..848ed1b3 --- /dev/null +++ b/backend/app/services/ai_pmo_kernel_mixins_world_model_session.py @@ -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() diff --git a/backend/app/services/ai_pmo_kernel_runtime.py b/backend/app/services/ai_pmo_kernel_runtime.py new file mode 100644 index 00000000..532301c4 --- /dev/null +++ b/backend/app/services/ai_pmo_kernel_runtime.py @@ -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 + diff --git a/backend/app/services/ai_pmo_kernel_service.py b/backend/app/services/ai_pmo_kernel_service.py index 8b5a0b19..3d169f43 100644 --- a/backend/app/services/ai_pmo_kernel_service.py +++ b/backend/app/services/ai_pmo_kernel_service.py @@ -1,4251 +1,5 @@ -"""AI PMO 决策中枢内核服务。""" +"""Backward-compatible AI PMO kernel service entrypoint.""" -from __future__ import annotations +from .ai_pmo_kernel_runtime import AIPMOKernelService -import asyncio -import hashlib -import json -import os -import re -import time -from dataclasses import dataclass -from datetime import datetime, timezone -from typing import Any, Optional - -from sqlalchemy import and_, func, or_, select -from sqlalchemy.exc import IntegrityError -from sqlalchemy.ext.asyncio import AsyncSession - -from ..config.ai_prompts import PROMPT_AI_PMO_INTENT_CLASSIFICATION -from ..core.logging import get_logger -from ..database import AsyncSessionLocal -from ..models import ( - AIProcessingLog, - Budget, - ConversationSession, - DecisionRecord, - IngestEvent, - Notification, - PlanBundle, - PortfolioSnapshot, - Project, - ProjectDomain, - ProjectSignalLink, - QARecord, - ReplyReceipt, - Signal, - 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_steward import AIStewardService -from .ai_pmo_review_service import AIPMOReviewService -from .context_cache import ContextCacheService -from .notification_service import NotificationService -from .openclaw_commander_service import OpenClawCommanderService -from .ai_pmo_domain_workflow import SignalSourceDomainWorkflow -from .llm import llm_service -from .signal_source_orchestration_service import SignalSourceOrchestrationService -from .signal_planner_service import SignalPlannerService - -from ..utils.timezone import now_cn_naive -logger = get_logger(__name__) - -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, -} - -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) - -QUESTION_HINTS = ( - "?", - "?", - "怎么", - "如何", - "为何", - "为什么", - "什么", - "是什么", - "哪些", - "有哪些", - "特点", - "技术特点", - "能力", - "介绍", - "说明", - "多少", - "是否", - "帮我看", - "状态", - "风险", - "能做什么", - "可以做什么", -) -SMALLTALK_EXACT = { - "收到", - "收到啦", - "好的", - "ok", - "okk", - "yes", - "no", - "谢谢", - "thanks", - "thx", - "在吗", - "在不在", - "怎么样了", - "咋样了", - "辛苦了", -} -SMALLTALK_SHORT_HINTS = ("怎么样了", "咋样了", "在吗", "辛苦了", "收到", "谢谢", "ok") -SMALLTALK_STRUCTURED_HINTS = ("项目", "信号", "任务", "风险", "进度", "状态", "work_item", "工作项", "#") -TRANSPORT_MODE_CORRECTION_HINTS = ("不是", "不再", "改为", "改成", "替换为", "取代", "现在都", "现在是") -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 = ("新增一个信号", "新增一条信号", "增加一个信号", "录入一个信号", "关注", "跟踪") -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", "群聊", "机器人")), - ("消息台与详情页", ("消息台", "详情页", "展开详情", "摘要", "时间戳", "按钮")), -) -PROJECT_CREATE_HINTS = ( - "创建项目", - "新建项目", - "建立项目", - "做个项目", - "立项", - "开个项目", - "新增项目", - "新增个项目", - "新增一个项目", - "增加项目", - "增加个项目", - "增加一个项目", - "添加项目", - "添加一个项目", - "加个项目", - "加一个项目", -) -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 -SESSION_SHORT_CONFIRM_WINDOW_SECONDS = 5 * 60 -SHORT_CONFIRM_MAX_MESSAGE_LENGTH = 10 -SHORT_CONFIRM_HINTS = ( - "是", - "好", - "对", - "确认", - "新增", - "加", - "嗯", - "yes", - "ok", - "好的", - "是的", - "行", - "可以", - "没问题", -) -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 -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"} - -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_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 - - -@dataclass(slots=True) -class CanonicalIntent: - name: str - confidence: float - project_id: Optional[int] - signal_title: str - signal_summary: str - metadata: dict[str, Any] - - -class IntentResolver: - """把输入收口为 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"}, - ) - 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 OpenClawCommanderService._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 - 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 - - -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 = [] - 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: - 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, - } - HOURS_BY_OPERATION = { - "query.answer": 0.2, - "project.create": 12.0, - "signal.enter": 1.5, - "signal_source.onboard": 6.0, - } - - @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: - """自动执行边界。""" - - @staticmethod - def evaluate( - *, - 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 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": "信号源接入缺少项目归属,必须先确认项目。", - }, - } - - # 轻量级事实写入/只读查询不应被项目当前依赖或产能压力阻塞。 - # 否则“AI 雷达”会在最需要先收集事实时失去自动化能力。 - 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) -> None: - self.strategic_brain = StrategicBrain() - self.tactical_brain = TacticalBrain() - self.simulation_engine = SimulationEngine() - self.execution_policy_brain = ExecutionPolicyBrain() - - 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, - } - - -class ReplyRenderer: - """唯一回执出口。""" - - @staticmethod - def render( - *, - work_state: str, - intent: CanonicalIntent, - decision_record: dict[str, Any], - execution_result: dict[str, Any], - ) -> dict[str, Any]: - if work_state == 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": RECEIPT_PHASE_PENDING, - "template_key": "pending_manual", - "payload": {"intent": intent.name, "reason": followup_text}, - "text": followup_text, - } - gate_message = str(((decision_record.get("approval_requirement") or {}).get("message")) or "").strip() - if not gate_message: - gate_message = "已进入待人工确认队列。" - return { - "receipt_phase": RECEIPT_PHASE_PENDING, - "template_key": "pending_manual", - "payload": {"intent": intent.name, "reason": gate_message}, - "text": gate_message, - } - if work_state == 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": 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": 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": 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": 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": 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": 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": RECEIPT_PHASE_ACTION, - "template_key": "action_succeeded", - "payload": {"intent": intent.name, "signal_id": signal_id, "status": status}, - "text": f"已接收入库并启动信号源接入流程。信号#{signal_id},状态:{status or 'running'}。", - } - signal_id = _safe_int(execution_result.get("signal_id")) - project_id = _safe_int(execution_result.get("project_id")) - if signal_id is not None and project_id is not None: - text = f"已入库:已为项目#{project_id}创建信号#{signal_id}。" - elif signal_id is not None: - text = f"已入库:已创建信号#{signal_id}。" - else: - text = "已收到并入库。" - return { - "receipt_phase": RECEIPT_PHASE_ACTION, - "template_key": "action_succeeded", - "payload": {"intent": intent.name, "signal_id": signal_id, "project_id": project_id}, - "text": text, - } - - -class TypedExecutor: - """执行层只做 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) - - @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, - } - - 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) - payload = await steward.create_project_from_natural_language( - input_text=message_text, - creator_id=int(operator_user_id), - tenant_id=self.tenant_id, - ) - 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("openclaw")), - } - - if intent.name == INTENT_SIGNAL_CREATE: - strategic_analysis = { - "source_context": { - "source_message_id": source_message_id, - "channel_ref": channel_ref, - }, - "ai_analysis": { - "summary": str(intent.signal_summary or message_text)[:300], - "intent": INTENT_SIGNAL_CREATE, - "requirement_project_scope": ( - "existing_project" if intent.project_id is not None else "pending_classification" - ), - }, - } - signal = await self._create_signal( - title=intent.signal_title or "新信号", - summary=intent.signal_summary or message_text, - content=message_text, - project_id=intent.project_id, - source="PMFlow", - signal_type="新需求", - recommended_action="enter_only", - strategic_analysis=strategic_analysis, - ) - return { - "status": "succeeded", - "signal_id": int(signal.id), - "project_id": intent.project_id, - "summary": str(intent.signal_summary or message_text)[:300], - "auto_tasks": [], - } - - 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_signal( - self, - *, - title: str, - summary: str, - content: str, - project_id: Optional[int], - source: str, - signal_type: str, - recommended_action: str, - reasoning_bullets: Optional[list[str]] = None, - strategic_analysis: Optional[dict[str, Any]] = None, - ) -> Signal: - 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="medium", - 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 - - -class AIPMOKernelService: - """AI PMO kernel,作为新的统一事实链。""" - - def __init__(self, db: AsyncSession, tenant_id: int = 1) -> None: - self.db = db - self.tenant_id = int(tenant_id) - self.context_cache = ContextCacheService() - self.intent_resolver = IntentResolver() - self.decision_engine = DecisionEngine() - self.reply_renderer = ReplyRenderer() - self.executor = TypedExecutor(db, tenant_id=self.tenant_id) - self.review_service = AIPMOReviewService(db, tenant_id=self.tenant_id) - self.cadence_service = AIPMOCadenceService(db, tenant_id=self.tenant_id) - - 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: - world_model_query_tables: set[str] = set() - project = await self._resolve_project( - message_text=clean_text, - project_hint_id=project_hint_id, - query_tables=world_model_query_tables, - session=session, - ) - stage_started_at = time.perf_counter() - world_model = 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=world_model_query_tables, - ) - _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, - ) - perf_intent_name = intent.name - _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(intent.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.name - 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 = intent.name - work_item.project_id = intent.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 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(), - "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, - ) - _log_perf("TypedExecutor.execute", stage_started_at) - else: - stage_started_at = time.perf_counter() - execution_result = await self.executor.execute( - 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) - 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() == "pending_manual": - 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.{intent.name}", - operation_code=self._primary_operation_code(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 = str(work_item.intent or fallback_intent.name or "").strip() or 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() - if ( - intent.name == INTENT_SIGNAL_CREATE - and work_item.state == STATE_SUCCEEDED - and _safe_int(execution_result.get("signal_id")) is not None - ): - asyncio.create_task( - SignalPlannerService.analyze( - signal_id=int(execution_result["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(execution_result['signal_id'])}", - ) - ) - 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)} - - 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 - - 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: - try: - async with AsyncSessionLocal() 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 - - @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() - - @staticmethod - def _intent_llm_enabled() -> bool: - return _env_bool("AI_PMO_INTENT_LLM_ENABLED", True) - - @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(), - }, - ) - - 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), - ).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 - - intent_name = str(payload.get("intent") or "").strip() - 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")) - 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, - }, - ) - - 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 - - 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")) - rule_intent = self.intent_resolver.resolve(text=message_text, project_id=project_id) - # 查询/噪音场景优先走规则意图,避免额外触发 LLM 分类链路导致不必要失败重试。 - if rule_intent.name in {INTENT_QUERY, INTENT_IGNORE} and float(rule_intent.confidence or 0.0) >= 0.85: - return self._apply_slot_guard(intent=rule_intent, message_text=message_text), "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", - ) - - if self._intent_llm_enabled(): - 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 self._apply_slot_guard(intent=rule_intent, message_text=message_text), "rule_fallback" - - 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), - } - - 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 - - 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 and _safe_int(execution_result.get("signal_id")) is not None: - await NotificationService(self.db).notify_signal_created( - tenant_id=self.tenant_id, - signal_id=int(execution_result["signal_id"]), - signal_content=str(execution_result.get("summary") or execution_result.get("signal_id") or ""), - 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], - ) -> tuple[dict[str, Any], dict[str, Any]]: - explicit_target_id = self._extract_followup_work_item_id(message_text) - 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 - ] - 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), - }, {"verified": True, "issues": []} - - if not actionable: - reply_text = "当前群最近没有可继续处理的待确认工作项。" - return { - "status": "pending_manual", - "followup_reply": reply_text, - "candidate_work_item_ids": [], - }, {"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]], - }, {"verified": False, "issues": ["waiting_manual", "followup_ambiguous"]} - - target_work_item, target_ingest = actionable[0] - return await self._resume_followup_target( - target_work_item=target_work_item, - target_ingest=target_ingest, - followup_intent=intent, - followup_sender=sender, - operator_user_id=operator_user_id, - source_system=source_system, - source_message_id=source_message_id, - 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 and 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 - - 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], - ) -> 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) - target_intent_name = str(target_work_item.intent or rebuilt_intent.name or "").strip() - 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), - ) - 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: - 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, - }, {"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, - }, {"verified": False, "issues": ["waiting_manual"]} - - 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_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, - "auto_tasks": target_execution_result.get("auto_tasks") if isinstance(target_execution_result.get("auto_tasks"), list) else [], - }, current_verification - - async def _verify_execution( - self, - *, - intent: CanonicalIntent, - execution_result: dict[str, Any], - world_model: dict[str, Any], - ) -> dict[str, Any]: - if str(execution_result.get("status") or "").lower() == "failed_safe": - return {"verified": False, "issues": ["executor_failed"]} - if str(execution_result.get("status") or "").lower() == "pending_manual": - return {"verified": False, "issues": ["pending_manual"]} - 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_id = _safe_int(execution_result.get("signal_id")) - if signal_id is None: - 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": []} - link_exists = ( - await self.db.execute( - select(ProjectSignalLink.id).where( - and_( - ProjectSignalLink.tenant_id == self.tenant_id, - ProjectSignalLink.project_id == int(intent.project_id), - ProjectSignalLink.signal_id == int(signal_id), - ) - ).limit(1) - ) - ).scalar_one_or_none() - return {"verified": link_exists is not None, "issues": [] if link_exists is not None else ["project_signal_link_missing"]} - return {"verified": False, "issues": ["unsupported_intent"]} - - @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() - - 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], - ) - result = { - "handled": True, - "status": status, - "classification": classification, - "linked_project_ids": linked_project_ids, - "signal_id": _safe_int(execution_result.get("signal_id")) or _safe_int(work_item.signal_id), - "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, - "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]: - 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 _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}" - - @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"]*>", " ", 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) - - 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}, - ] - - @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 - - @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"]*>", " ", 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), - } +__all__ = ["AIPMOKernelService"] diff --git a/backend/app/services/ai_pmo_reply_renderer.py b/backend/app/services/ai_pmo_reply_renderer.py new file mode 100644 index 00000000..97882b0a --- /dev/null +++ b/backend/app/services/ai_pmo_reply_renderer.py @@ -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, + } diff --git a/backend/app/services/ai_pmo_typed_executor.py b/backend/app/services/ai_pmo_typed_executor.py new file mode 100644 index 00000000..f9060073 --- /dev/null +++ b/backend/app/services/ai_pmo_typed_executor.py @@ -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) + diff --git a/backend/app/services/ai_pmo_typed_executor_common.py b/backend/app/services/ai_pmo_typed_executor_common.py new file mode 100644 index 00000000..ef162c73 --- /dev/null +++ b/backend/app/services/ai_pmo_typed_executor_common.py @@ -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 + + diff --git a/backend/app/services/ai_pmo_typed_executor_mixins_discovery.py b/backend/app/services/ai_pmo_typed_executor_mixins_discovery.py new file mode 100644 index 00000000..f28ea7bc --- /dev/null +++ b/backend/app/services/ai_pmo_typed_executor_mixins_discovery.py @@ -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)} 条候选信号," + "请回复“确认”提交,回复“换一批”刷新,或回复“取消”结束本次候选。" + ), + } + diff --git a/backend/app/services/ai_pmo_typed_executor_mixins_execute.py b/backend/app/services/ai_pmo_typed_executor_mixins_execute.py new file mode 100644 index 00000000..5fcc48b2 --- /dev/null +++ b/backend/app/services/ai_pmo_typed_executor_mixins_execute.py @@ -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"]*>", " ", 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 diff --git a/backend/app/services/ai_pmo_typed_executor_mixins_parse.py b/backend/app/services/ai_pmo_typed_executor_mixins_parse.py new file mode 100644 index 00000000..05052b98 --- /dev/null +++ b/backend/app/services/ai_pmo_typed_executor_mixins_parse.py @@ -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 + diff --git a/backend/app/services/ai_pmo_workflow_runtime.py b/backend/app/services/ai_pmo_workflow_runtime.py new file mode 100644 index 00000000..7c1de13c --- /dev/null +++ b/backend/app/services/ai_pmo_workflow_runtime.py @@ -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 diff --git a/backend/app/services/ai_steward.py b/backend/app/services/ai_steward.py index a8f579a3..ecbddb1d 100644 --- a/backend/app/services/ai_steward.py +++ b/backend/app/services/ai_steward.py @@ -57,12 +57,6 @@ PROJECT_CREATION_STEP_RETRY_COUNT = max( 0, _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 = ( "我暂时无法生成可靠回答", "模型服务异常", @@ -250,17 +244,6 @@ class AIStewardService: return True 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( self, *, @@ -269,62 +252,47 @@ class AIStewardService: function_name: str, timeout_seconds: float = PROJECT_CREATION_DRAFT_TIMEOUT_SECONDS, ) -> str: - candidates = self._project_creation_function_candidates(function_name=function_name) - if not candidates: - return "" attempts_per_function = PROJECT_CREATION_STEP_RETRY_COUNT + 1 - for candidate_function in candidates: - for attempt_index in range(attempts_per_function): - started_at = time.perf_counter() - try: - response = await asyncio.wait_for( - self.llm.generate( - prompt, - db=self.db, - tenant_id=tenant_id, - function_name=candidate_function, - response_mode="fast", - ), - 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 - + last_error: Exception | None = None + for attempt_index in range(attempts_per_function): + started_at = time.perf_counter() + try: + response = await asyncio.wait_for( + self.llm.generate( + prompt, + db=self.db, + tenant_id=tenant_id, + function_name=function_name, + response_mode="fast", + ), + timeout=timeout_seconds, + ) cleaned_response = str(response or "").strip() elapsed_seconds = round(time.perf_counter() - started_at, 2) if self._looks_like_project_creation_stub_response(cleaned_response): - logger.warning( - "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 + raise RuntimeError("project_creation_stub_response") logger.info( "ai_steward_project_creation_step_ok", tenant_id=int(tenant_id), - function_name=candidate_function, + function_name=function_name, attempt=attempt_index + 1, elapsed_seconds=elapsed_seconds, ) return cleaned_response + except Exception as exc: + last_error = exc if isinstance(exc, Exception) else RuntimeError(str(exc)) + logger.warning( + "ai_steward_project_creation_step_retry", + tenant_id=int(tenant_id), + function_name=function_name, + attempt=attempt_index + 1, + elapsed_seconds=round(time.perf_counter() - started_at, 2), + error=str(exc), + ) - logger.warning( - "ai_steward_project_creation_step_fallback", - tenant_id=int(tenant_id), - function_name=function_name, - error="all_attempts_exhausted", - ) - return "" + raise RuntimeError( + f"AI 管家项目创建步骤失败:function={function_name}, reason={last_error or 'unknown_error'}" + ) from last_error async def _normalize_project_creation_draft( self, @@ -628,19 +596,10 @@ class AIStewardService: if response.startswith("json"): response = response[4:] analysis = json.loads(response.strip()) - except Exception: - analysis = { - "project_name": input_text[:20], - "project_title": input_text[:50], - "tech_stack": ["GPT-4o-mini"], - "target_audience": "通用", - "timeline": "3周", - "primary_role": "ai_engineer", - "domain_id": 1, - "key_features": [], - "competitors": [], - "success_metrics": [], - } + except Exception as exc: + raise RuntimeError(f"AI 管家项目需求分析失败:{exc}") from exc + if not isinstance(analysis, dict): + raise RuntimeError("AI 管家项目需求分析失败:返回内容不是 JSON 对象") return analysis async def _generate_competitive_signal_draft( @@ -683,14 +642,12 @@ class AIStewardService: response = response.split("```")[1] if response.startswith("json"): response = response[4:] - return json.loads(response.strip()) - except Exception: - return { - "content": f"竞品分析:{', '.join(competitors)} 的技术现状和差异化机会", - "impact_score": 0.8, - "priority": "high", - "strategic_analysis": {"is_strategic": True}, - } + payload = json.loads(response.strip()) + except Exception as exc: + raise RuntimeError(f"AI 管家竞品信号生成失败:{exc}") from exc + if not isinstance(payload, dict): + raise RuntimeError("AI 管家竞品信号生成失败:返回内容不是 JSON 对象") + return payload async def _generate_charter_draft( self, @@ -732,13 +689,10 @@ class AIStewardService: if response.startswith("json"): response = response[4:] charter_data = json.loads(response.strip()) - except Exception: - charter_data = { - "title": f"{analysis.get('project_name')}项目宪章", - "content": f"项目目标:{analysis.get('project_title')}\n成功标准:按时上线", - "success_criteria": ["按时交付", "功能完整"], - "constraints": ["时间限制"], - } + except Exception as exc: + raise RuntimeError(f"AI 管家项目宪章生成失败:{exc}") from exc + if not isinstance(charter_data, dict): + raise RuntimeError("AI 管家项目宪章生成失败:返回内容不是 JSON 对象") raw_content = charter_data.get("content", "") if isinstance(raw_content, dict): @@ -800,26 +754,9 @@ class AIStewardService: tasks_list = tasks_data.get("tasks", []) if isinstance(tasks_list, list) and tasks_list: return tasks_list - except Exception: - pass - return [ - { - "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": ["开发"], - }, - ] + except Exception as exc: + raise RuntimeError(f"AI 管家任务链生成失败:{exc}") from exc + raise RuntimeError("AI 管家任务链生成失败:返回 tasks 为空") @staticmethod def _build_risk_alert_drafts(*, analysis: Dict[str, Any]) -> List[Dict[str, Any]]: diff --git a/backend/app/services/context_cache.py b/backend/app/services/context_cache.py index 0fde86c8..2a9d9e14 100644 --- a/backend/app/services/context_cache.py +++ b/backend/app/services/context_cache.py @@ -237,8 +237,6 @@ class ContextCacheService: return "PostgreSQL (pgvector-ready)" if "mysql" in url: return "MySQL" - if "sqlite" in url: - return "SQLite" return "Unknown" @staticmethod diff --git a/backend/app/services/feishu_integration.py b/backend/app/services/feishu_integration.py index 44446e8b..89a4f67f 100644 --- a/backend/app/services/feishu_integration.py +++ b/backend/app/services/feishu_integration.py @@ -708,6 +708,11 @@ class FeishuIntegrationService: except Exception as exc: print(f"[Feishu] generate mention reply failed: {exc}") 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( chat_id=chat_id, @@ -1263,6 +1268,30 @@ class FeishuIntegrationService: return token 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 def _contains_forbidden_execute_keywords(cls, text: Optional[str]) -> bool: return cls._detect_forbidden_execute_keyword(text) is not None @@ -1294,6 +1323,27 @@ class FeishuIntegrationService: sanitized, _ = self._guard_execute_reply_text_with_reason(text=text, trace_id=trace_id) 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( self, *, diff --git a/backend/app/services/feishu_signal_pipeline.py b/backend/app/services/feishu_signal_pipeline.py index 340fdb0e..2a61b6e4 100644 --- a/backend/app/services/feishu_signal_pipeline.py +++ b/backend/app/services/feishu_signal_pipeline.py @@ -1,6 +1,7 @@ """飞书消息分流与决策队列服务。""" from __future__ import annotations +import asyncio import json import hashlib import re @@ -29,6 +30,7 @@ from ..models import ( ProjectSuggestion, Signal, SignalEdit, + TruthNode, User, ) from .llm import LLMService @@ -38,8 +40,16 @@ from .ai_agent_kernel import ( gate_execution as kernel_gate_execution, resolve_correction as kernel_resolve_correction, ) +from .context_cache import context_cache_service 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.sequence import ensure_project_id_sequence @@ -89,15 +99,15 @@ PROJECT_ID_QUERY_RE = re.compile( r"项目\s*id\s*[::]?\s*(\d+).{0,12}(有吗|存在吗|还在吗|是否存在|是不是)", re.IGNORECASE, ) -OPENCLAW_PROJECT_CREATE_RE = re.compile( +LEGACY_COMMAND_PROJECT_CREATE_RE = re.compile( r"(创建|新建|建立|开个|开一个|建个|建一个|做个|做一个).{0,6}项目", re.IGNORECASE, ) -OPENCLAW_COMMAND_VERB_RE = re.compile( +LEGACY_COMMAND_VERB_RE = re.compile( r"(创建|新建|建立|开个|开一个|建个|建一个|做个|做一个|修改|更新|改成|改为|设为|设成|分配|指派|归档|重开|重新打开|恢复|标记完成|完成|提醒|通知|调整工时|调整工作量)", re.IGNORECASE, ) -OPENCLAW_COMMAND_OBJECT_RE = re.compile( +LEGACY_COMMAND_OBJECT_RE = re.compile( r"(项目|任务|负责人|优先级|状态|通知|提醒|工时|工作量)", re.IGNORECASE, ) @@ -159,8 +169,8 @@ ROUTING_DUPLICATE_FILTERED = "duplicate_filtered" ROUTING_NOISE_FILTERED = "noise_filtered" ROUTING_FAILED = "failed" ROUTING_DIRECT_QA = "direct_qa" -ROUTING_OPENCLAW_EXECUTED = "openclaw_executed" -ROUTING_OPENCLAW_BLOCKED = "openclaw_blocked" +ROUTING_LEGACY_COMMAND_EXECUTED = "legacy_command_executed" +ROUTING_LEGACY_COMMAND_BLOCKED = "legacy_command_blocked" ROUTING_QUEUE_FOLLOWUP_QUERY = "queue_followup_query" ROUTING_QUEUE_FOLLOWUP_CONFIRM = "queue_followup_confirm" ROUTING_QUEUE_FOLLOWUP_BLOCKED = "queue_followup_blocked" @@ -437,7 +447,7 @@ class FeishuSignalPipelineService: return False if PROJECT_CREATE_GATE_RE.search(text): return True - return bool(OPENCLAW_PROJECT_CREATE_RE.search(text)) + return bool(LEGACY_COMMAND_PROJECT_CREATE_RE.search(text)) @staticmethod def _looks_like_external_connect_request(message: str) -> bool: @@ -564,6 +574,9 @@ class FeishuSignalPipelineService: "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 if self.db is not None and len(text) >= 4: try: @@ -577,7 +590,9 @@ class FeishuSignalPipelineService: except Exception as exc: logger.warning(f"intent_correction_recall_failed: tenant_id={self.tenant_id}, error={exc}") 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( intent=INTENT_CLARIFICATION_REQUIRED, confidence=correction_decision.confidence, @@ -618,7 +633,11 @@ class FeishuSignalPipelineService: "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 = { "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, @@ -640,7 +659,7 @@ class FeishuSignalPipelineService: gate["intent"] = INTENT_SIGNAL_INTAKE return gate - if self._looks_like_project_create_request(text): + if explicit_project_request: fields = self._extract_project_intent_fields(text) fields = await self._maybe_prefill_project_fields_from_embedded_content( message=text, @@ -671,7 +690,7 @@ class FeishuSignalPipelineService: "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) if fields["ready"]: return { @@ -691,7 +710,7 @@ class FeishuSignalPipelineService: "correction_match": correction_match, } - if self._looks_like_external_connect_request(text): + if explicit_external_request: return { "intent": INTENT_EXTERNAL_CONNECT, "canonical_action": "source.connect", @@ -748,7 +767,7 @@ class FeishuSignalPipelineService: return "" 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: cleaned = re.sub(r"\s+", " ", str(message or "")).strip() for pattern in ( @@ -762,7 +781,7 @@ class FeishuSignalPipelineService: if candidate: title = candidate[:24] break - description = OpenClawCommanderService._extract_project_create_description( + description = extract_project_create_description( instruction=message, 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(" ,,。.!!??::;;") - normalized = OpenClawCommanderService._normalize_project_title_candidate(candidate) + normalized = normalize_project_title_candidate(candidate) if normalized.endswith(("方向", "规划", "创新")): - normalized = OpenClawCommanderService._normalize_project_title_candidate(f"{normalized}研究") + normalized = normalize_project_title_candidate(f"{normalized}研究") return normalized title = _normalize(question) @@ -1057,7 +1076,7 @@ class FeishuSignalPipelineService: project_info = await self._extract_project_prefill( 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() ) if self._looks_like_placeholder_project_text(suggested_name): @@ -1178,7 +1197,7 @@ class FeishuSignalPipelineService: title = ( str(fields.get("title") or "").strip() 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() if not description and answer_hint: @@ -1269,7 +1288,7 @@ class FeishuSignalPipelineService: description: str, project_scope: 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): return normalized_title @@ -1277,7 +1296,7 @@ class FeishuSignalPipelineService: cleaned = str(candidate or "").strip() if not cleaned or cls._looks_like_placeholder_project_text(cleaned): continue - normalized = OpenClawCommanderService._normalize_project_title_candidate(cleaned[:48]) + normalized = normalize_project_title_candidate(cleaned[:48]) if normalized: return normalized return "项目创建请求" @@ -1448,7 +1467,7 @@ class FeishuSignalPipelineService: queue_visible=True, result_payload={ "intent": INTENT_CREATE_PROJECT, - "executor": "operation_executor", + "executor": "explicit_service", "operation_code": "project.create", "operation_payload": { "title": str(fields.get("title") or "").strip(), @@ -1527,7 +1546,7 @@ class FeishuSignalPipelineService: existing_item.result_payload = { **(existing_item.result_payload if isinstance(existing_item.result_payload, dict) else {}), "intent": str(intent_gate.get("intent") or ""), - "executor": "operation_executor", + "executor": "explicit_service", "operation_code": operation_code, "operation_payload": operation_payload, "result_summary": self._build_pending_operation_summary( @@ -1569,7 +1588,7 @@ class FeishuSignalPipelineService: queue_visible=True, result_payload={ "intent": str(intent_gate.get("intent") or ""), - "executor": "operation_executor", + "executor": "explicit_service", "operation_code": operation_code, "operation_payload": operation_payload, "result_summary": self._build_pending_operation_summary( @@ -1636,7 +1655,7 @@ class FeishuSignalPipelineService: processed_at=_utc_now().replace(tzinfo=None), result_payload={ "intent": intent, - "executor": "operation_executor", + "executor": "explicit_service", "operation_code": 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 {}, @@ -1656,45 +1675,29 @@ class FeishuSignalPipelineService: payload: dict[str, Any], trace_id: str, ) -> dict[str, Any]: - executor = OperationExecutorService(self.db) - project_context = None - project_id = _safe_int(payload.get("project_id")) - if operation_code != "project.create" and project_id is not None: - project_context = ( - await self.db.execute( - select(Project).where( - and_( - Project.tenant_id == self.tenant_id, - Project.id == int(project_id), - ) - ) - ) - ).scalar_one_or_none() - if project_context is None: - raise ValueError("project_context_missing") - logger.info( "operation_started", tenant_id=self.tenant_id, trace_id=trace_id, operation_code=operation_code, payload=payload, - executor="operation_executor", - ) - execution = await executor.execute_operations( - tenant_id=self.tenant_id, - operator_user=operator_user, - project=project_context, - operations=[ - { - "operation_code": operation_code, - "payload": payload, - "reason": "intent_gate", - } - ], - dry_run=False, - trace_id=trace_id, + 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( @@ -1703,10 +1706,211 @@ class FeishuSignalPipelineService: trace_id=trace_id, operation_code=operation_code, result=first, - executor="operation_executor", + 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")) + if project_id is None: + raise ValueError("project_context_missing") + project = ( + await self.db.execute( + select(Project).where( + and_( + Project.tenant_id == self.tenant_id, + Project.id == int(project_id), + ) + ) + ) + ).scalar_one_or_none() + if project is None: + raise ValueError("project_context_missing") + + charter = ( + await self.db.execute( + select(TruthNode).where( + and_( + TruthNode.tenant_id == self.tenant_id, + TruthNode.project_id == int(project_id), + TruthNode.node_type == "charter", + TruthNode.status.in_(tuple(ALIGNED_STATUSES)), + ) + ).order_by(TruthNode.updated_at.desc(), TruthNode.id.desc()) + ) + ).scalars().first() + if charter is None: + raise ValueError("aligned charter required before task.create") + + task = await persist_task( + self.db, + tenant_id=self.tenant_id, + truth_node_id=int(charter.id), + project_id=int(project_id), + draft=build_task_create_draft( + title=str(payload.get("title") or ""), + description=str(payload.get("description") or "").strip() or None, + priority=payload.get("priority"), + ), + 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), + }, + } + + def _sync_task_rag(self, *, task: Any) -> None: + 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 '未分配'}" + ) + asyncio.create_task( + AdaptiveRAGService.upsert_pmflow_record_async( + tenant_id=self.tenant_id, + record_type="task", + record_id=int(task.id), + project_id=int(task.project_id), + title=str(task.title or "任务"), + content=content, + ) + ) + except Exception: + return + @staticmethod def _derive_wrong_interpretation_from_recent_item(item: Optional[FeishuItem]) -> str: if item is None: @@ -1845,7 +2049,7 @@ class FeishuSignalPipelineService: normalized_text=message, route=ROUTING_INTENT_GATE_CLARIFICATION, intent=INTENT_CLARIFICATION_REQUIRED, - executor="operation_executor", + executor="explicit_service", operation_code="project.create", reply_source="deterministic_reply", ) @@ -1898,7 +2102,7 @@ class FeishuSignalPipelineService: normalized_text=message, route=ROUTING_INTENT_GATE_CLARIFICATION, intent=INTENT_CLARIFICATION_REQUIRED if not fields.get("ready") else INTENT_CREATE_PROJECT, - executor="operation_executor", + executor="explicit_service", operation_code="project.create", reply_source="deterministic_reply", ) @@ -1952,7 +2156,7 @@ class FeishuSignalPipelineService: execution_result=first, 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( feishu_message_id=feishu_message_id, feishu_item_id=item_id, @@ -1974,7 +2178,7 @@ class FeishuSignalPipelineService: normalized_text=message, route=ROUTING_OPERATION_COMMITTED, intent=INTENT_CREATE_PROJECT, - executor="operation_executor", + executor="explicit_service", operation_code="project.create", operation_result=first if isinstance(first, dict) else None, reply_source="db_commit", @@ -2051,7 +2255,7 @@ class FeishuSignalPipelineService: normalized_text=message, route=ROUTING_INTENT_GATE_CLARIFICATION, intent=INTENT_CLARIFICATION_REQUIRED, - executor="operation_executor", + executor="explicit_service", operation_code="task.create", reply_source="deterministic_reply", ) @@ -2107,7 +2311,7 @@ class FeishuSignalPipelineService: normalized_text=message, route=ROUTING_INTENT_GATE_CLARIFICATION, intent=INTENT_CLARIFICATION_REQUIRED if not fields.get("ready") else INTENT_CREATE_TASK, - executor="operation_executor", + executor="explicit_service", operation_code="task.create", reply_source="deterministic_reply", ) @@ -2151,7 +2355,7 @@ class FeishuSignalPipelineService: execution_result=first, 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( feishu_message_id=feishu_message_id, feishu_item_id=item_id, @@ -2173,7 +2377,7 @@ class FeishuSignalPipelineService: normalized_text=message, route=ROUTING_OPERATION_COMMITTED, intent=INTENT_CREATE_TASK, - executor="operation_executor", + executor="explicit_service", operation_code="task.create", operation_result=first if isinstance(first, dict) else None, reply_source="db_commit", @@ -2597,7 +2801,7 @@ class FeishuSignalPipelineService: 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, sender=sender, source_chat_id=source_chat_id, @@ -2988,7 +3192,7 @@ class FeishuSignalPipelineService: return False if cls._is_query_like_question(message): return True - if cls._is_openclaw_business_command(text): + if cls._is_legacy_business_command(text): return False if cls._looks_like_project_lookup_question(text): return True @@ -3003,7 +3207,7 @@ class FeishuSignalPipelineService: has_question_structure = bool(OBVIOUS_QUESTION_RE.search(text) or QUERY_INTENT_RE.search(text)) if not has_question_structure: return False - has_operation_verb = bool(OPENCLAW_COMMAND_VERB_RE.search(text)) + has_operation_verb = bool(LEGACY_COMMAND_VERB_RE.search(text)) imperative_tokens = ("帮我", "请", "麻烦", "直接", "立刻", "马上", "执行", "处理", "安排") has_imperative = any(token in normalized for token in imperative_tokens) return not has_operation_verb and not has_imperative @@ -3485,14 +3689,14 @@ class FeishuSignalPipelineService: @staticmethod 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 def _looks_like_project_lookup_question(cls, message: str) -> bool: text = str(message or "").strip() if not text: return False - if cls._is_openclaw_business_command(text): + if cls._is_legacy_business_command(text): return False normalized = re.sub(r"\s+", "", text.lower()) if PROJECT_LIST_QUERY_RE.search(text): @@ -3504,7 +3708,7 @@ class FeishuSignalPipelineService: return any(token in normalized for token in ("有吗", "有没有", "有无", "是否有", "是否存在", "存在吗", "是什么", "哪些")) @classmethod - def _is_openclaw_business_command(cls, message: str) -> bool: + def _is_legacy_business_command(cls, message: str) -> bool: text = str(message or "").strip() if not text: return False @@ -3512,9 +3716,9 @@ class FeishuSignalPipelineService: return False if cls._looks_like_project_create_command(text): 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, *, message: str, @@ -3527,118 +3731,16 @@ class FeishuSignalPipelineService: projects: list[dict[str, Any]], process_started_at: float, ) -> Optional[dict[str, Any]]: - if not self._is_openclaw_business_command(message): + if not self._is_legacy_business_command(message): return None - operator_user = await self._resolve_command_operator_user(sender=sender) - if operator_user is None: - 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="当前租户下没有可用操作者,系统未执行写操作。", - ) - - 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( + _ = sender, source_chat_id, source_sender_open_id, captured_at, inferred_project_id, projects + return await self._build_legacy_command_blocked_capture( 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, + duration_ms=int((time.perf_counter() - process_started_at) * 1000), + reason="旧 command-first 规划链路已下线,请改用 GLM 分析后直接调用显式接口。", ) - 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( self, @@ -3714,7 +3816,7 @@ class FeishuSignalPipelineService: "direct_reply_text": reply_text, } - async def _build_openclaw_blocked_capture( + async def _build_legacy_command_blocked_capture( self, *, message: str, @@ -3730,7 +3832,7 @@ class FeishuSignalPipelineService: project_score=None, signal_confidence=None, level="command", - routing_decision=ROUTING_OPENCLAW_BLOCKED, + routing_decision=ROUTING_LEGACY_COMMAND_BLOCKED, routing_reason=reason, reasoning_bullets=[ "命中 command-first 路由,但当前缺少安全执行所需的上下文。", @@ -3823,7 +3925,7 @@ class FeishuSignalPipelineService: split_from_item_id=None, level="command", level_confidence=1.0, - level_reasoning="OpenClaw command-first execution", + level_reasoning="legacy_command_first_execution", card_type="command", 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, @@ -3837,9 +3939,9 @@ class FeishuSignalPipelineService: auto_execute_state="done", result_payload={ "intent": "command", - "executor": "openclaw_commander", + "executor": "legacy_command_planner", "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, "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, @@ -3853,7 +3955,12 @@ class FeishuSignalPipelineService: @staticmethod 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"}: return ROUTE_STATUS_AUTO_DONE return ROUTE_STATUS_FAILED @@ -3907,13 +4014,13 @@ class FeishuSignalPipelineService: return None @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 {} results = execution.get("results") if isinstance(execution, dict) else [] if not isinstance(results, list) or not results: 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] applied_count = int((execution or {}).get("applied_count") or 0) skipped_count = int((execution or {}).get("skipped_count") or 0) @@ -3933,7 +4040,7 @@ class FeishuSignalPipelineService: return reply + "。" @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() status = str(row.get("status") or "").strip().lower() 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.result_payload = { "intent": INTENT_CREATE_PROJECT, - "executor": "operation_executor", + "executor": "explicit_service", "operation_code": "project.create", "result_summary": f"已创建项目 #{project_id}" if project_id else "已创建项目", "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.result_payload = { **payload, - "executor": "operation_executor", + "executor": "explicit_service", "result_summary": f"已创建任务 #{task_id}" if task_id is not None 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, @@ -5896,7 +6003,7 @@ class FeishuSignalPipelineService: ) -> None: normalized_route = (route or routing_decision or "")[:80] 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 = ( reply_source or ("db_commit" if operation_code else "deterministic_reply") diff --git a/backend/app/services/llm.py b/backend/app/services/llm.py index 28542ba9..f0185da2 100644 --- a/backend/app/services/llm.py +++ b/backend/app/services/llm.py @@ -30,6 +30,7 @@ from sqlalchemy.ext.asyncio import AsyncSession from ..config.ai_prompts import build_default_role_prompt_bundle from ..database import AsyncSessionLocal from ..models import AICallLog, Integration +from .model_context_bundle_service import ModelContextBundleService from ..utils.timezone import now_cn_aware try: @@ -43,7 +44,6 @@ except Exception: # pragma: no cover logger = logging.getLogger(__name__) SUPPORTED_PROVIDERS = {"deepseek", "glm", "openai", "ollama"} -OPENCLAW_PRIMARY_GATEWAY_URL = "http://openclaw:18789/v1" OPENCLAW_DEFAULT_LOW_MODEL = "GLM-4.7" OPENCLAW_DEFAULT_HIGH_MODEL = "MiniMax-M2.5" OPENCLAW_DEFAULT_SAFETY_MODEL = "deepseek-chat" @@ -62,8 +62,8 @@ PROVIDER_DEFAULTS: dict[str, dict[str, str]] = { }, "openai": { "name": "OpenAI", - "base_url": OPENCLAW_PRIMARY_GATEWAY_URL, - "model": OPENCLAW_DEFAULT_LOW_MODEL, + "base_url": "https://api.openai.com/v1", + "model": "gpt-4o-mini", "api_env": "OPENAI_API_KEY", }, "ollama": { @@ -114,7 +114,6 @@ DEFAULT_LOW_TIER_HINTS = ( "feishu_signal_extraction", "feishu_humanized_reply", "signal_source_onboarding_plan", - "openclaw_command_dispatch", "signal_source_ai_worker_execution", "extract_context_insights", "project_draft_", @@ -131,7 +130,6 @@ STRUCTURED_OUTPUT_FUNCTION_HINTS = ( "classify_intent", "feishu_project_prefill", "feishu_task_orchestration", - "openclaw_command_dispatch", "generate_task_breakdown", "generate_charter_from_signal", "decompose_tasks_from_charter", @@ -168,17 +166,26 @@ LOW_TO_HIGH_ESCALATION_FALLBACK_MARKERS = ( ) FAST_LOCAL_FALLBACK_FUNCTION_HINTS = ( "signal_source_onboarding_plan", - "openclaw_command_dispatch", "signal_source_ai_worker_execution", ) FAST_LOCAL_PLANNER_FUNCTION_HINTS = ( "signal_source_onboarding_plan", - "openclaw_command_dispatch", ) FAST_LOCAL_WORKER_FUNCTION_HINTS = ("signal_source_ai_worker_execution",) FAST_LOCAL_FALLBACK_FAST_TIMEOUT_SECONDS = 3.0 FAST_LOCAL_FALLBACK_COMPLETE_TIMEOUT_SECONDS = 6.0 STRUCTURED_CACHE_TTL_SECONDS = 300 +LLM_ENGINE_SETTINGS_DEFAULTS = { + "enable_local_kb_qa": False, + "local_kb_qa_scope": "kb_qa_only", +} +LOCAL_KB_QA_FUNCTION_PREFIXES = ( + "adaptive_rag_answer_", +) +LOCAL_KB_QA_FUNCTION_EXACT = { + "rag_intent_classification", + "rag_db_project_semantic_retrieval", +} STRICT_MODEL_ERROR_FUNCTION_PREFIXES = ( "adaptive_rag_answer_", ) @@ -198,7 +205,6 @@ CIRCUIT_BREAKER_COOLDOWN_SECONDS = 300.0 DDL_API_GUARDRAIL_FUNCTION_EXACT = { "classify_intent_ai_pmo_kernel", "classify_intent_complex", - "openclaw_command_dispatch", } DDL_API_GUARDRAIL_SYSTEM_PROMPT = """ 硬性约束(最高优先级): @@ -206,29 +212,20 @@ DDL_API_GUARDRAIL_SYSTEM_PROMPT = """ 2. 所有数据库 DDL 变更必须通过受控接口或迁移流程(例如 Alembic migration)。 3. 若输入要求直接改库执行 DDL: - `classify_intent_ai_pmo_kernel` / `classify_intent_complex` 必须返回 `query.read_only`,并在 reasoning 包含 `ddl_api_required`。 - - `openclaw_command_dispatch` 必须返回 `operations=[]`,并在 summary 包含 `ddl_api_required`。 """.strip() STRUCTURED_OBJECT_SCHEMAS: dict[str, dict[str, Any]] = { "signal_source_onboarding_plan": { - "required_keys": ("should_onboard", "summary", "worker_roles", "openclaw_instruction"), + "required_keys": ("should_onboard", "summary", "worker_roles", "execution_instruction"), "type_checks": { "should_onboard": "bool", "summary": "str", "worker_roles": "list", - "openclaw_instruction": "str", + "execution_instruction": "str", }, "optional_type_checks": { "source": ("dict_or_none",), }, }, - "openclaw_command_dispatch": { - "required_keys": ("summary", "confidence", "operations"), - "type_checks": { - "summary": "str", - "confidence": "number", - "operations": "list", - }, - }, "signal_source_ai_worker_execution": { "required_keys": ("summary", "deliverables", "risks", "next_actions"), "type_checks": { @@ -239,25 +236,33 @@ STRUCTURED_OBJECT_SCHEMAS: dict[str, dict[str, Any]] = { }, }, "classify_intent_ai_pmo_kernel": { - "required_keys": ("intent", "confidence", "reasoning"), + "required_keys": ("confidence", "reasoning"), "type_checks": { - "intent": "str", "confidence": "number", "reasoning": "str", }, "optional_type_checks": { + "intent": ("str_or_none",), + "family": ("str_or_none",), + "action": ("str_or_none",), "project_id": ("number_or_none",), + "requested_count": ("number_or_none",), + "requires_confirmation": ("bool",), }, }, "classify_intent_complex": { - "required_keys": ("intent", "confidence", "reasoning"), + "required_keys": ("confidence", "reasoning"), "type_checks": { - "intent": "str", "confidence": "number", "reasoning": "str", }, "optional_type_checks": { + "intent": ("str_or_none",), + "family": ("str_or_none",), + "action": ("str_or_none",), "project_id": ("number_or_none",), + "requested_count": ("number_or_none",), + "requires_confirmation": ("bool",), }, }, "signal_planner_analyze": { @@ -432,6 +437,42 @@ class LLMService: return None return None + @staticmethod + def _is_retryable_http_status(status_code: Optional[int]) -> bool: + if status_code is None: + return False + return int(status_code) in {408, 409, 429, 500, 502, 503, 504} + + @staticmethod + def _parse_retry_after_seconds(raw_value: Optional[str]) -> Optional[float]: + value = str(raw_value or "").strip() + if not value: + return None + try: + seconds = float(value) + except Exception: + return None + if seconds <= 0: + return None + return min(seconds, 10.0) + + @classmethod + def _http_retry_delay_seconds( + cls, + *, + status_code: Optional[int], + attempt: int, + retry_after_header: Optional[str], + ) -> float: + retry_after = cls._parse_retry_after_seconds(retry_after_header) + if retry_after is not None: + return retry_after + base = 0.8 + if int(status_code or 0) == 429: + base = 1.2 + capped_attempt = max(0, min(int(attempt), 6)) + return min(base * (2 ** capped_attempt), 6.0) + @classmethod def _is_fail_fast_exception(cls, exc: Exception) -> bool: status_code = cls._http_status_from_exception(exc) @@ -447,7 +488,7 @@ class LLMService: if isinstance(exc, httpx.RequestError): return True status_code = cls._http_status_from_exception(exc) - return status_code in {408, 409, 429, 500, 502, 503, 504} + return cls._is_retryable_http_status(status_code) @classmethod def _is_provider_circuit_open(cls, circuit_key: str) -> bool: @@ -495,8 +536,25 @@ class LLMService: return { "active_engine_id": bundle.get("active_engine_id"), "engines": [self._sanitize_engine_for_response(item) for item in engines], + "settings": self._normalize_engine_settings(bundle.get("settings")), } + async def update_engine_settings( + self, + db: AsyncSession, + *, + tenant_id: int, + enable_local_kb_qa: Optional[bool] = None, + ) -> dict[str, Any]: + integration, bundle = await self._get_or_init_bundle(db, tenant_id=tenant_id) + settings = self._normalize_engine_settings(bundle.get("settings")) + if enable_local_kb_qa is not None: + settings["enable_local_kb_qa"] = bool(enable_local_kb_qa) + bundle["settings"] = settings + integration.config = json.loads(json.dumps(bundle)) + await db.commit() + return settings + async def create_engine( self, db: AsyncSession, @@ -644,16 +702,52 @@ class LLMService: status = "success" started = time.perf_counter() try: - response_text = await self._chat_completion( - provider=str(engine.get("provider") or ""), - prompt=test_prompt, - system_prompt="", - api_key=str(engine.get("api_key") or ""), - base_url=str(engine.get("base_url") or ""), - model=str(engine.get("model") or ""), - temperature=0.0, - max_tokens=max_tokens, - ) + transient_retry_budget = 2 + transient_retry_attempt = 0 + while True: + try: + response_text = await self._chat_completion( + provider=str(engine.get("provider") or ""), + prompt=test_prompt, + system_prompt="", + api_key=str(engine.get("api_key") or ""), + base_url=str(engine.get("base_url") or ""), + model=str(engine.get("model") or ""), + temperature=0.0, + max_tokens=max_tokens, + ) + break + except Exception as transient_exc: + status_code = self._http_status_from_exception(transient_exc) + can_retry_transient = ( + transient_retry_budget > 0 + and ( + self._is_retryable_http_status(status_code) + or isinstance(transient_exc, httpx.RequestError) + ) + ) + if not can_retry_transient: + raise + retry_after_header = None + if isinstance(transient_exc, httpx.HTTPStatusError) and transient_exc.response is not None: + retry_after_header = transient_exc.response.headers.get("Retry-After") + retry_delay = self._http_retry_delay_seconds( + status_code=status_code, + attempt=transient_retry_attempt, + retry_after_header=retry_after_header, + ) + _runtime_log( + "LLM", + ( + "连通测试重试:" + f"engine={engine.get('provider')}/{engine.get('model')}, " + f"status={status_code or 'request_error'}, " + f"retry_in={retry_delay:.2f}s, left={transient_retry_budget - 1}" + ), + ) + transient_retry_budget -= 1 + transient_retry_attempt += 1 + await asyncio.sleep(retry_delay) if not response_text.strip(): raise ValueError("测试响应为空") latency_ms = int((time.perf_counter() - started) * 1000) @@ -671,6 +765,9 @@ class LLMService: except Exception as exc: status = "failed" response_text = f"[error] {exc}" + status_code = self._http_status_from_exception(exc) + if int(status_code or 0) == 429: + raise ValueError("引擎连通测试失败: 上游限流(429),请稍后重试或更换 API Key。") from exc raise ValueError(f"引擎连通测试失败: {exc}") from exc finally: try: @@ -705,6 +802,32 @@ class LLMService: return await self._generate_structured_with_db(session, prompt, **call_kwargs) return await self._generate_structured_with_db(db, prompt, **call_kwargs) + async def _inject_model_context_bundle_prompt( + self, + *, + db: Any, + tenant_id: int, + function_name: str, + prompt: str, + ) -> str: + raw_prompt = str(prompt or "") + normalized_function = str(function_name or "").strip().lower() + if not ModelContextBundleService.should_inject_context(normalized_function): + return raw_prompt + # 单测里可能传入 object()/mock 作为 db 占位,此时直接跳过注入。 + if not hasattr(db, "execute"): + return raw_prompt + try: + return await ModelContextBundleService.inject_prompt_with_bundle( + db, + tenant_id=int(tenant_id), + function_name=normalized_function, + prompt=raw_prompt, + ) + except Exception as exc: + _runtime_log("LLM上下文", f"上下文注入失败,已降级原始 prompt:function={normalized_function}, error={exc}") + return raw_prompt + async def _generate_with_db(self, db: AsyncSession, prompt: str, **kwargs: Any) -> str: started_at = time.perf_counter() tenant_id = int(kwargs.get("tenant_id") or 1) @@ -718,14 +841,19 @@ class LLMService: routing_decision = "primary_engine" engine_meta: dict[str, Any] = {} prompt_profile = {"role_name": "", "system_prompt": ""} - merged_prompt = self._merge_prompt_with_profile( + prompt_with_context = await self._inject_model_context_bundle_prompt( + db=db, + tenant_id=tenant_id, + function_name=function_name, prompt=prompt, + ) + merged_prompt = self._merge_prompt_with_profile( + prompt=prompt_with_context, prompt_profile=prompt_profile, function_name=function_name, ) - safety_attempted = False - def _log_stub_failure(error_obj: Exception, *, used_stub: bool = True) -> None: + def _log_stub_failure(error_obj: Exception, *, used_stub: bool = False) -> None: elapsed_ms = (time.perf_counter() - started_at) * 1000 provider = str(engine_meta.get("provider") or "") model = str(engine_meta.get("model") or "") @@ -744,7 +872,7 @@ class LLMService: if use_prompt_profile: prompt_profile = await self._resolve_active_prompt_profile(db, tenant_id=tenant_id) merged_prompt = self._merge_prompt_with_profile( - prompt=prompt, + prompt=prompt_with_context, prompt_profile=prompt_profile, function_name=function_name, ) @@ -758,7 +886,7 @@ class LLMService: model = str(engine.get("model") or "") response_text = await self._chat_completion( provider=provider, - prompt=prompt, + prompt=prompt_with_context, system_prompt=prompt_profile.get("system_prompt") or "", api_key=str(engine.get("api_key") or ""), base_url=str(engine.get("base_url") or ""), @@ -771,222 +899,24 @@ class LLMService: if not response_text.strip(): raise ValueError("primary_engine_empty_response") - invalid_structured_output = self._is_invalid_structured_output( - function_name=function_name, - response_text=response_text, - ) - should_upgrade_to_high = False - if not self._prefer_fast_local_fallback(function_name): - should_upgrade_to_high = self._should_escalate_low_to_high( - function_name=function_name, - response_text=response_text, - ) or (invalid_structured_output and self._is_low_tier_function(function_name)) - - if should_upgrade_to_high: - high_engine = await self._resolve_high_tier_engine_for_escalation( - db, - tenant_id=tenant_id, - primary_engine=engine, - ) - if high_engine is None: - raise ValueError("high_tier_engine_unavailable") - high_provider = str(high_engine.get("provider") or "") - high_model = str(high_engine.get("model") or "") - _runtime_log( - "LLM升级", - ( - "低阶引擎触发升级:" - f"function={function_name}, from={provider}/{model}, to={high_provider}/{high_model}" - ), - ) - try: - escalated_text = await self._chat_completion( - provider=high_provider, - prompt=prompt, - system_prompt=prompt_profile.get("system_prompt") or "", - api_key=str(high_engine.get("api_key") or ""), - base_url=str(high_engine.get("base_url") or ""), - model=high_model, - temperature=temperature, - max_tokens=max_tokens, - function_name=function_name, - response_mode=response_mode, - ) - if not escalated_text.strip(): - raise ValueError("high_tier_empty_response") - if self._is_invalid_structured_output(function_name=function_name, response_text=escalated_text): - raise ValueError("high_tier_invalid_structured_output") - response_text = escalated_text - engine_meta = high_engine - except Exception as escalation_exc: - safety_attempted = True - safety_text, safety_engine = await self._try_safety_engine( - db, - tenant_id=tenant_id, - function_name=function_name, - prompt=prompt, - system_prompt=prompt_profile.get("system_prompt") or "", - temperature=temperature, - max_tokens=max_tokens, - primary_engine=high_engine, - reason=f"high_tier_retry_failed:{escalation_exc}", - ) - if safety_engine is not None and safety_text.strip(): - routing_decision = "fallback_safety_engine" - status = "fallback_safety_engine" - engine_meta = safety_engine - response_text = safety_text - return response_text - raise ValueError(f"high_tier_retry_failed:{escalation_exc}") from escalation_exc - if self._is_invalid_structured_output(function_name=function_name, response_text=response_text): raise ValueError("invalid_structured_output") return response_text except Exception as exc: - upgraded_exc: Exception = exc - strict_model_error = self._should_raise_model_error(function_name) - if self._is_fail_fast_exception(exc) and not strict_model_error: - status = "failed_fast" - response_text = self._stub_response(prompt) - _runtime_log("LLM", f"生成快速失败,已跳过升级重试:{exc}") - _log_stub_failure(exc) - return response_text - if self._is_fail_fast_exception(exc) and strict_model_error: - status = "failed_fast" - _runtime_log("LLM", f"生成快速失败,严格模式继续升级重试:{exc}") - should_try_high_tier_retry = self._is_low_tier_function(function_name) or strict_model_error - if should_try_high_tier_retry: - if self._prefer_fast_local_fallback(function_name): - raise upgraded_exc - high_engine = await self._resolve_high_tier_engine_for_escalation( - db, - tenant_id=tenant_id, - primary_engine=engine_meta, - ) - if high_engine is not None: - high_provider = str(high_engine.get("provider") or "") - high_model = str(high_engine.get("model") or "") - _runtime_log( - "LLM升级", - ( - "低阶引擎异常,转高阶兜底:" - f"function={function_name}, from={engine_meta.get('provider')}/{engine_meta.get('model')}, " - f"to={high_provider}/{high_model}, error={exc}" - ), - ) - try: - escalated_text = await self._chat_completion( - provider=high_provider, - prompt=prompt, - system_prompt=prompt_profile.get("system_prompt") or "", - api_key=str(high_engine.get("api_key") or ""), - base_url=str(high_engine.get("base_url") or ""), - model=high_model, - temperature=temperature, - max_tokens=max_tokens, - function_name=function_name, - response_mode=response_mode, - ) - if not escalated_text.strip(): - raise ValueError("high_tier_empty_response") - if self._is_invalid_structured_output(function_name=function_name, response_text=escalated_text): - raise ValueError("high_tier_invalid_structured_output") - routing_decision = "fallback_high_tier_engine" - status = "fallback_high_tier_engine" - engine_meta = high_engine - response_text = escalated_text - return response_text - except Exception as high_exc: - _runtime_log( - "LLM升级", - ( - "高阶兜底失败:" - f"function={function_name}, provider={high_provider}, " - f"model={high_model}, error={high_exc}" - ), - ) - upgraded_exc = ValueError(f"high_tier_retry_failed:{high_exc}") - engine_meta = high_engine - - if strict_model_error: - status = "failed" - reason_text = self._build_model_error_message( - function_name=function_name, - provider=str(engine_meta.get("provider") or ""), - model=str(engine_meta.get("model") or ""), - error=upgraded_exc, - ) - response_text = reason_text - _log_stub_failure(upgraded_exc, used_stub=False) - raise RuntimeError(reason_text) from upgraded_exc - - if not safety_attempted: - safety_attempted = True - safety_text, safety_engine = await self._try_safety_engine( - db, - tenant_id=tenant_id, - function_name=function_name, - prompt=prompt, - system_prompt=prompt_profile.get("system_prompt") or "", - temperature=temperature, - max_tokens=max_tokens, - primary_engine=engine_meta, - reason=str(upgraded_exc), - ) - if safety_engine is not None and safety_text.strip(): - routing_decision = "fallback_safety_engine" - status = "fallback_safety_engine" - engine_meta = safety_engine - response_text = safety_text - return response_text - - retry_text = "" - if self._direct_emergency_enabled(): - fallback_engines = self._build_fallback_engines(engine_meta) - for fallback_engine in fallback_engines: - try: - fb_provider = str(fallback_engine.get("provider") or "") - fb_model = str(fallback_engine.get("model") or "") - _runtime_log( - "LLM", - f"应急直连引擎重试:provider={fb_provider}, model={fb_model}, error={exc}", - ) - retry_text = await self._chat_completion( - provider=fb_provider, - prompt=prompt, - system_prompt=prompt_profile.get("system_prompt") or "", - api_key=str(fallback_engine.get("api_key") or ""), - base_url=str(fallback_engine.get("base_url") or ""), - model=fb_model, - temperature=temperature, - max_tokens=max_tokens, - function_name=function_name, - response_mode=response_mode, - ) - if retry_text.strip(): - routing_decision = "fallback_emergency_direct_engine" - status = "fallback_emergency_direct_engine" - engine_meta = fallback_engine - response_text = retry_text - return response_text - except Exception as retry_exc: - _runtime_log( - "LLM", - ( - "应急直连重试失败:" - f"provider={fallback_engine.get('provider')}, " - f"model={fallback_engine.get('model')}, error={retry_exc}" - ), - ) - status = "failed" - response_text = self._stub_response(prompt) - _log_stub_failure(upgraded_exc) - return response_text + reason_text = self._build_model_error_message( + function_name=function_name, + provider=str(engine_meta.get("provider") or ""), + model=str(engine_meta.get("model") or ""), + error=exc, + ) + response_text = reason_text + _log_stub_failure(exc, used_stub=False) + raise RuntimeError(reason_text) from exc finally: try: merged_prompt = self._merge_prompt_with_profile( - prompt=prompt, + prompt=prompt_with_context, prompt_profile=prompt_profile, function_name=function_name, ) @@ -1023,7 +953,13 @@ class LLMService: use_prompt_profile = bool(kwargs.get("use_prompt_profile", True)) response_mode = str(kwargs.get("response_mode") or "complete").strip().lower() or "complete" prompt_profile = {"role_name": "", "system_prompt": ""} - merged_prompt = prompt + prompt_with_context = await self._inject_model_context_bundle_prompt( + db=db, + tenant_id=tenant_id, + function_name=function_name, + prompt=prompt, + ) + merged_prompt = prompt_with_context provider = "" model = "" status = "success" @@ -1032,7 +968,7 @@ class LLMService: if use_prompt_profile: prompt_profile = await self._resolve_active_prompt_profile(db, tenant_id=tenant_id) merged_prompt = self._merge_prompt_with_profile( - prompt=prompt, + prompt=prompt_with_context, prompt_profile=prompt_profile, function_name=function_name, ) @@ -1070,7 +1006,7 @@ class LLMService: completion = await self._chat_completion_raw( provider=provider, - prompt=prompt, + prompt=prompt_with_context, system_prompt=prompt_profile.get("system_prompt") or "", api_key=str(engine.get("api_key") or ""), base_url=str(engine.get("base_url") or ""), @@ -1088,14 +1024,15 @@ class LLMService: provider=provider, model=model, ) - is_fallback = extraction.error_code is not None _runtime_log( "LLM结构化", ( f"function={function_name}, structured_source={extraction.source_field or 'none'}, " - f"validation={extraction.validation}, fallback={'true' if is_fallback else 'false'}" + f"validation={extraction.validation}, fallback=false" ), ) + if extraction.payload is None: + raise ValueError(str(extraction.error_code or "structured_schema_invalid")) if extraction.payload is not None and cache_key: await self._structured_cache_set(key=cache_key, value=extraction.payload) status = extraction.error_code or "success" @@ -1103,20 +1040,18 @@ class LLMService: except Exception as exc: error_code = self._classify_structured_exception(exc) status = error_code - result = StructuredExtractionResult( - payload=None, - source_field="", + reason_text = self._build_model_error_message( + function_name=function_name, provider=provider, model=model, - error_code=error_code, - raw_text=str(exc)[:1000], - validation="invalid", + error=exc, ) + response_text = reason_text _runtime_log( "LLM结构化", - f"function={function_name}, structured_source=none, validation=invalid, fallback=true", + f"function={function_name}, structured_source=none, validation=invalid, fallback=false", ) - return result + raise RuntimeError(reason_text) from exc finally: try: log_payload = response_text or status @@ -1254,6 +1189,8 @@ class LLMService: } current_max_tokens = max(int(max_tokens or 0), 1) empty_content_retry_budget = 1 + http_status_retry_budget = 2 + http_status_retry_attempt = 0 while True: payload = { "model": model, @@ -1288,6 +1225,30 @@ class LLMService: f"function={function_label} status={resp.status_code} body={body_preview}" ), ) + status_code = int(resp.status_code) + can_retry_http_status = ( + http_status_retry_budget > 0 + and self._is_retryable_http_status(status_code) + ) + if can_retry_http_status: + retry_delay = self._http_retry_delay_seconds( + status_code=status_code, + attempt=http_status_retry_attempt, + retry_after_header=resp.headers.get("Retry-After"), + ) + _runtime_log( + "LLM", + ( + "HTTP状态重试:" + f"function={function_label}, provider={provider_name}, model={model}, " + f"status={status_code}, retry_in={retry_delay:.2f}s, " + f"left={http_status_retry_budget - 1}" + ), + ) + http_status_retry_budget -= 1 + http_status_retry_attempt += 1 + await asyncio.sleep(retry_delay) + continue resp.raise_for_status() data = resp.json() choices = data.get("choices") or [] @@ -1301,6 +1262,26 @@ class LLMService: response=data, structured_output=structured_output, ) + if ( + not text + and not structured_output + and self._reasoning_content_fallback_enabled(function_name=function_label) + ): + fallback_text = self._extract_reasoning_fallback_text( + message=message, + choice=first_choice, + response=data, + ) + if fallback_text: + text = fallback_text + _runtime_log( + "LLM", + ( + "空content回退reasoning_content:" + f"function={function_label}, provider={provider_name}, model={model}, " + f"text_len={len(text)}" + ), + ) if text or structured_output: break @@ -1531,6 +1512,60 @@ class LLMService: "created_at": item.get("created_at"), } + @staticmethod + def _normalize_engine_settings(raw: Any) -> dict[str, Any]: + settings = dict(LLM_ENGINE_SETTINGS_DEFAULTS) + if isinstance(raw, dict): + settings["enable_local_kb_qa"] = bool(raw.get("enable_local_kb_qa", settings["enable_local_kb_qa"])) + return settings + + @staticmethod + def _is_local_kb_qa_function(function_name: str) -> bool: + fn = str(function_name or "").strip().lower() + if not fn: + return False + if fn in LOCAL_KB_QA_FUNCTION_EXACT: + return True + return any(prefix and fn.startswith(prefix) for prefix in LOCAL_KB_QA_FUNCTION_PREFIXES) + + def _ensure_engine_allowed_for_function( + self, + *, + engine: dict[str, Any], + function_name: str, + settings: dict[str, Any], + engine_label: str, + ) -> dict[str, Any]: + runtime = self._normalize_engine_runtime(engine) + provider = str(runtime.get("source_provider") or runtime.get("provider") or "") + if provider == "ollama": + if not bool(settings.get("enable_local_kb_qa")): + raise ValueError("本地 LLM 未启用,请先在引擎配置中打开“本地 LLM(仅知识库问答)”开关") + if not self._is_local_kb_qa_function(function_name): + raise ValueError(f"本地 LLM 仅允许用于知识库问答,当前 function={function_name}") + return runtime + + if not str(runtime.get("api_key") or "").strip(): + raise ValueError(f"{engine_label} 未配置 API Key") + return runtime + + def _ensure_engine_allowed_for_test( + self, + *, + engine: dict[str, Any], + settings: dict[str, Any], + engine_label: str, + ) -> dict[str, Any]: + runtime = self._normalize_engine_runtime(engine) + provider = str(runtime.get("source_provider") or runtime.get("provider") or "") + if provider == "ollama": + if not bool(settings.get("enable_local_kb_qa")): + raise ValueError("本地 LLM 未启用,请先在引擎配置中打开“本地 LLM(仅知识库问答)”开关") + raise ValueError("本地 LLM 仅允许用于知识库问答,连通性测试请通过知识库问答链路验证") + if not str(runtime.get("api_key") or "").strip(): + raise ValueError(f"{engine_label} 未配置 API Key") + return runtime + async def _get_or_init_bundle( self, db: AsyncSession, @@ -1549,7 +1584,7 @@ class LLMService: if not integration: integration = Integration( type="llm_engine", - config={"active_engine_id": None, "engines": []}, + config={"active_engine_id": None, "engines": [], "settings": self._normalize_engine_settings(None)}, is_active=True, tenant_id=tenant_id, ) @@ -1562,6 +1597,7 @@ class LLMService: bundle["engines"] = engines if "active_engine_id" not in bundle: bundle["active_engine_id"] = None + bundle["settings"] = self._normalize_engine_settings(bundle.get("settings")) return integration, bundle async def _resolve_active_engine( @@ -1581,6 +1617,7 @@ class LLMService: function_name: str, ) -> dict[str, Any]: _, bundle = await self._get_or_init_bundle(db, tenant_id=tenant_id) + settings = self._normalize_engine_settings(bundle.get("settings")) active_engine = self._resolve_active_engine_from_bundle(bundle) active_engine = self._ensure_openclaw_control_plane_engine(active_engine) engines: list[dict[str, Any]] = [e for e in bundle.get("engines", []) if isinstance(e, dict)] @@ -1593,7 +1630,12 @@ class LLMService: active_engine=active_engine, ) if override_engine is not None: - return override_engine + return self._ensure_engine_allowed_for_function( + engine=override_engine, + function_name=function_name, + settings=settings, + engine_label="override_engine", + ) openclaw_routed = self._resolve_openclaw_engine( function_name=function_name, @@ -1602,25 +1644,29 @@ class LLMService: ) if openclaw_routed is not None: openclaw_routed = self._ensure_openclaw_control_plane_engine(openclaw_routed) - provider = str(openclaw_routed.get("provider") or "") - api_key = str(openclaw_routed.get("api_key") or "").strip() - if provider != "ollama" and not api_key: - _runtime_log( - "OpenClaw路由", - ( - "命中 OpenClaw 路由但目标引擎缺少 API Key,已回退 active:" - f"function={function_name}, provider={provider}, model={openclaw_routed.get('model')}" - ), - ) - else: - return openclaw_routed + return self._ensure_engine_allowed_for_function( + engine=openclaw_routed, + function_name=function_name, + settings=settings, + engine_label="openclaw_routed_engine", + ) if not self._mixed_routing_enabled(): - return active_engine + return self._ensure_engine_allowed_for_function( + engine=active_engine, + function_name=function_name, + settings=settings, + engine_label="active_engine", + ) tier = self._resolve_function_tier(function_name) if tier not in {"high", "low"}: - return active_engine + return self._ensure_engine_allowed_for_function( + engine=active_engine, + function_name=function_name, + settings=settings, + engine_label="active_engine", + ) routed = self._resolve_tier_engine( tier=tier, @@ -1628,26 +1674,13 @@ class LLMService: active_engine=active_engine, ) if not routed: - return active_engine - + return self._ensure_engine_allowed_for_function( + engine=active_engine, + function_name=function_name, + settings=settings, + engine_label="active_engine", + ) provider = str(routed.get("provider") or "") - api_key = str(routed.get("api_key") or "").strip() - if provider != "ollama" and not api_key: - _runtime_log( - "LLM路由", - ( - "命中分层路由但目标引擎缺少 API Key,回退可用引擎:" - f"function={function_name}, tier={tier}, provider={provider}, model={routed.get('model')}" - ), - ) - fallback_engine = self._pick_tier_fallback_engine( - tier=tier, - engines=engines, - active_engine=active_engine, - ) - if fallback_engine: - return fallback_engine - return active_engine _runtime_log( "LLM路由", ( @@ -1655,7 +1688,12 @@ class LLMService: f"function={function_name}, tier={tier}, provider={provider}, model={routed.get('model')}" ), ) - return self._ensure_openclaw_control_plane_engine(routed) + return self._ensure_engine_allowed_for_function( + engine=self._ensure_openclaw_control_plane_engine(routed), + function_name=function_name, + settings=settings, + engine_label=f"{tier}_tier_engine", + ) async def _resolve_function_override_engine( self, @@ -1701,14 +1739,10 @@ class LLMService: return None provider = str(routed.get("provider") or "") if provider != "ollama" and not str(routed.get("api_key") or "").strip(): - _runtime_log( - "LLM路由", - ( - "函数覆盖路由命中但缺少 API Key,回退默认路由:" - f"function={fn}, provider={provider}, model={routed.get('model')}" - ), + raise ValueError( + "函数覆盖路由命中但目标引擎缺少 API Key:" + f"function={fn}, provider={provider}, model={routed.get('model')}" ) - return None _runtime_log( "LLM路由", ( @@ -1717,6 +1751,8 @@ class LLMService: ), ) return self._ensure_openclaw_control_plane_engine(routed) + except ValueError: + raise except Exception as exc: _runtime_log("LLM路由", f"函数覆盖路由读取失败:function={fn}, error={exc}") return None @@ -1736,34 +1772,14 @@ class LLMService: return _env_bool("LLM_MIXED_ROUTING_ENABLED", False) def _openclaw_routing_enabled(self) -> bool: - return _env_bool("OPENCLAW_ROUTING_ENABLED", True) + return False def _openclaw_control_plane_only(self) -> bool: - return self._openclaw_routing_enabled() and not self._direct_emergency_enabled() + return False def _ensure_openclaw_control_plane_engine(self, engine: dict[str, Any]) -> dict[str, Any]: runtime = dict(engine or {}) - if not self._openclaw_control_plane_only(): - return runtime - - openai_default = self._env_provider_runtime("openai") - runtime["provider"] = "openai" - runtime["base_url"] = ( - str(os.getenv("OPENAI_BASE_URL", openai_default.get("base_url") or OPENCLAW_PRIMARY_GATEWAY_URL) or "") - .strip() - .rstrip("/") - or OPENCLAW_PRIMARY_GATEWAY_URL - ) - runtime["api_key"] = ( - str(os.getenv("OPENAI_API_KEY", "") or "").strip() - or str(runtime.get("api_key") or "").strip() - ) - runtime["model"] = ( - str(runtime.get("model") or "").strip() - or str(openai_default.get("model") or OPENCLAW_DEFAULT_LOW_MODEL).strip() - ) - runtime["name"] = str(runtime.get("name") or "OpenClaw Gateway").strip() or "OpenClaw Gateway" - runtime["id"] = str(runtime.get("id") or "openclaw-gateway") + runtime["source_provider"] = str(runtime.get("source_provider") or runtime.get("provider") or "").strip().lower() return runtime def _openclaw_rules_path(self) -> str: @@ -2080,7 +2096,6 @@ class LLMService: fn = str(function_name or "").strip().lower() return fn in { "signal_source_onboarding_plan", - "openclaw_command_dispatch", } def _request_timeout_for_function(self, function_name: str, *, response_mode: str = "complete") -> float: @@ -2292,6 +2307,45 @@ class LLMService: return text return "" + def _extract_reasoning_fallback_text( + self, + *, + message: Any, + choice: Any, + response: Any, + ) -> str: + candidates: list[Any] = [] + if isinstance(message, dict): + candidates.append(message.get("reasoning_content")) + candidates.append(message.get("reasoning")) + if isinstance(choice, dict): + candidates.append(choice.get("reasoning_content")) + candidates.append(choice.get("reasoning")) + if isinstance(response, dict): + candidates.append(response.get("reasoning_content")) + candidates.append(response.get("reasoning")) + for raw in candidates: + text = self._sanitize_visible_answer(self._extract_text_from_value(raw)) + if text: + return text + return "" + + @staticmethod + def _reasoning_content_fallback_enabled(*, function_name: str) -> bool: + raw = str(os.getenv("LLM_REASONING_CONTENT_FALLBACK", "0")).strip().lower() + if raw not in {"1", "true", "yes", "on"}: + return False + fn = str(function_name or "").strip().lower() + if not fn: + return True + if fn.startswith("generate_structured") or fn.startswith("llm_generate_structured"): + return False + if "signal_batch_analysis" in fn: + return False + if any(token in fn for token in ("answer", "reply", "qa")): + return True + return False + def _iter_structured_candidates( self, *, @@ -2668,16 +2722,16 @@ class LLMService: tier = str(tier or "").strip().lower() upper = tier.upper() default_provider_by_tier = { - "low": "openai", - "high": "openai", - "safety": "openai", + "low": "glm", + "high": "glm", + "safety": "glm", } provider_env = str( os.getenv( f"LLM_ROUTING_{upper}_PROVIDER", default_provider_by_tier.get( tier, - os.getenv("LLM_PROVIDER", "openai"), + os.getenv("LLM_PROVIDER", "glm"), ), ) or "" @@ -2723,7 +2777,7 @@ class LLMService: routed["base_url"] = base_url_override elif self._openclaw_routing_enabled() and provider == "openai": configured_openai_base = str(os.getenv("OPENAI_BASE_URL", "") or "").strip().rstrip("/") - routed["base_url"] = configured_openai_base or OPENCLAW_PRIMARY_GATEWAY_URL + routed["base_url"] = configured_openai_base or "https://api.openai.com/v1" if str(routed.get("id") or "") != str(active_engine.get("id") or ""): _runtime_log( @@ -2742,20 +2796,25 @@ class LLMService: tenant_id: int, engine_id: Optional[str], ) -> dict[str, Any]: - if not engine_id: - engine = await self._resolve_active_engine(db, tenant_id=tenant_id) - if str(engine.get("provider") or "") != "ollama" and not str(engine.get("api_key") or "").strip(): - raise ValueError("当前激活引擎未配置 API Key") - return engine - _, bundle = await self._get_or_init_bundle(db, tenant_id=tenant_id) + settings = self._normalize_engine_settings(bundle.get("settings")) + if not engine_id: + engine = self._resolve_active_engine_from_bundle(bundle) + return self._ensure_engine_allowed_for_test( + engine=engine, + settings=settings, + engine_label="当前激活引擎", + ) + engines: list[dict[str, Any]] = [e for e in bundle.get("engines", []) if isinstance(e, dict)] for item in engines: if str(item.get("id") or "") == str(engine_id): engine = self._normalize_engine_runtime(item) - if str(engine.get("provider") or "") != "ollama" and not str(engine.get("api_key") or "").strip(): - raise ValueError("目标引擎未配置 API Key") - return engine + return self._ensure_engine_allowed_for_test( + engine=engine, + settings=settings, + engine_label="目标引擎", + ) raise ValueError("engine 不存在") async def _resolve_active_prompt_profile(self, db: AsyncSession, *, tenant_id: int) -> dict[str, str]: @@ -2873,7 +2932,7 @@ class LLMService: } def _env_engine_runtime(self) -> dict[str, Any]: - provider = _normalize_provider(os.getenv("LLM_PROVIDER", "deepseek")) + provider = _normalize_provider(os.getenv("LLM_PROVIDER", "glm")) runtime = self._env_provider_runtime(provider) runtime["id"] = "env-default" return runtime diff --git a/backend/app/services/model_context_bundle_service.py b/backend/app/services/model_context_bundle_service.py new file mode 100644 index 00000000..7403b61f --- /dev/null +++ b/backend/app/services/model_context_bundle_service.py @@ -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, + } diff --git a/backend/app/services/model_routing_service.py b/backend/app/services/model_routing_service.py index af55304b..3420b072 100644 --- a/backend/app/services/model_routing_service.py +++ b/backend/app/services/model_routing_service.py @@ -3,8 +3,6 @@ from __future__ import annotations import json -import os -from pathlib import Path from typing import Any from sqlalchemy import and_, select @@ -17,7 +15,6 @@ ALLOWED_PROVIDERS = {"openai", "glm", "deepseek", "ollama"} ALLOWED_TIERS = {"low", "high", "safety"} DEFAULT_FUNCTIONS = ("signal_planner_analyze",) INTEGRATION_TYPE = "model_routing" -DEFAULT_RULES_RELATIVE_PATH = "../.openclaw/pmflow-routing-rules.json" class ModelRoutingService: @@ -58,15 +55,6 @@ class ModelRoutingService: raise ValueError("tier 仅支持 low/high/safety") 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 def _rule_matches_function(cls, *, rule: dict[str, Any], function_name: str) -> bool: if bool(rule.get("match_all", False)): @@ -89,27 +77,8 @@ class ModelRoutingService: @classmethod def _load_default_rule(cls, *, function_name: str) -> dict[str, Any] | None: - path = cls._rules_path() - if not path.exists(): - 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]) + _ = function_name + return None @classmethod 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: return { "function_name": cls._normalize_function_name(function_name), - "provider": "openai", - "model": "GLM-5", - "api_env": "OPENAI_API_KEY", + "provider": "glm", + "model": "GLM-4.7", + "api_env": "GLM_API_KEY", "tier": "high", "base_url": "", "source": "default", diff --git a/backend/app/services/openclaw_commander_service.py b/backend/app/services/openclaw_commander_service.py deleted file mode 100644 index 85c418ce..00000000 --- a/backend/app/services/openclaw_commander_service.py +++ /dev/null @@ -1,2432 +0,0 @@ -"""OpenClaw 指挥层与操作执行服务。""" - -from __future__ import annotations - -import asyncio -from datetime import datetime, timezone -import hashlib -import json -import re -import time -from types import SimpleNamespace -from typing import Any, Optional -from uuid import uuid4 - -from sqlalchemy import and_, func, or_, select -from sqlalchemy.exc import IntegrityError -from sqlalchemy.ext.asyncio import AsyncSession - -from ..config.ai_prompts import PROMPT_OPENCLAW_COMMAND_DISPATCH -from ..models import ( - OperationExecutionLedger, - Project, - SkillTag, - Task, - TruthNode, - User, - UserProject, - UserSkillBinding, - UserWorkload, -) -from . import workload_service -from .adaptive_rag_service import AdaptiveRAGService -from .ai_orchestration_run_service import AIOrchestrationRunService -from .context_cache import context_cache_service -from .llm import llm_service -from .operation_catalog_service import operation_catalog_service -from .operation_log_service import log_operation -from .notification_service import NotificationService -from .skill_score_service import process_task_completion -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.sequence import ensure_project_id_sequence -from ..utils.title import compact_title - -from ..utils.timezone import now_cn_naive -SUPPORTED_COMMANDER_OPERATION_CODES = ( - "project.create", - "project.update", - "project.archive", - "project.reopen", - "task.create", - "task.assign", - "task.prioritize", - "task.status_transition", - "notification.create", - "personnel.workload_update", -) -PERSISTENT_IDEMPOTENCY_OPERATION_CODES = {"notification.create", "project.create"} -SUPPORTED_PROJECT_STATUSES = {"active", "archived", "done"} -SUPPORTED_TASK_STATUSES = {"todo", "in_progress", "review", "done", "blocked", "closed"} -DONE_TASK_STATUSES = {"done", "completed", "closed"} -SOURCE_ONBOARDING_FALLBACK_RE = re.compile( - r"(新增|增加|添加|接入|配置|注册|建立).{0,10}(信号源|来源|监控源|github|飞书|webhook|rss|url|source|channel)", - re.IGNORECASE, -) - - -def _utc_now_naive() -> datetime: - return now_cn_naive() - - -class OpenClawCommanderService: - """将自然语言指令转成可执行 operation plan。""" - - def __init__(self, db: AsyncSession) -> None: - self.db = db - - async def dispatch_command( - self, - *, - tenant_id: int, - operator_user: User, - project_id: int, - instruction: str, - dry_run: bool, - max_operations: int, - response_mode: str = "fast", - trace_id: Optional[str] = None, - persist_run: bool = True, - ) -> dict[str, Any]: - project_row = await self._load_project(tenant_id=tenant_id, project_id=project_id) - if project_row is None: - raise ValueError("project not found") - project = self._build_project_snapshot(project_row) - - members = await self._load_project_team_context( - tenant_id=tenant_id, - project_id=project_id, - owner_id=int(project.owner_id) if project.owner_id else None, - ) - tasks, task_summary = await self._load_project_tasks(tenant_id=tenant_id, project_id=project_id, members=members) - planner_role = await llm_service._resolve_active_prompt_profile(self.db, tenant_id=tenant_id) - trace_id = str(trace_id or f"openclaw-{tenant_id}-{project_id}-{int(time.time() * 1000)}-{uuid4().hex[:8]}") - - plan, planner_status, planner_diagnostics = await self._plan_operations( - tenant_id=tenant_id, - operator_user=operator_user, - project=project, - tasks=tasks, - task_summary=task_summary, - members=members, - instruction=instruction, - dry_run=dry_run, - max_operations=max_operations, - response_mode=response_mode, - project_directory=[self._serialize_project_directory_item(project_row)], - ) - - executor = OperationExecutorService(self.db) - execution = await executor.execute_operations( - tenant_id=tenant_id, - operator_user=operator_user, - project=project, - operations=plan.get("operations") or [], - dry_run=dry_run, - trace_id=trace_id, - ) - openclaw_status = self._resolve_openclaw_status(execution=execution, dry_run=dry_run) - run_status = openclaw_status if openclaw_status in {"completed", "partial", "failed"} else "completed" - - result = { - "trace_id": trace_id, - "response_mode": response_mode, - "run_status": run_status, - "planner_status": planner_status, - "planner_diagnostics": planner_diagnostics, - "openclaw_status": openclaw_status, - "project": { - "id": str(project.id), - "title": project.title or project.name, - "status": project.status, - }, - "prompt_role": { - "role_key": str(planner_role.get("role_key") or ""), - "role_name": str(planner_role.get("role_name") or ""), - }, - "instruction": instruction, - "dry_run": bool(dry_run), - "summary": str(plan.get("summary") or "").strip(), - "confidence": self._to_float(plan.get("confidence"), default=0.0), - "task_summary": task_summary, - "member_count": len(members), - "planned_operations": len(plan.get("operations") or []), - "execution": execution, - "pending_workers": [], - } - if persist_run: - run_service = AIOrchestrationRunService(self.db) - await run_service.create_run( - tenant_id=tenant_id, - trace_id=trace_id, - run_type="openclaw_dispatch", - project_id=int(project.id), - signal_id=None, - response_mode=response_mode, - status=run_status, - planner_status=planner_status, - openclaw_status=openclaw_status, - worker_total=0, - worker_completed=0, - worker_failed=0, - summary=str(result.get("summary") or "").strip(), - planner_diagnostics=planner_diagnostics, - result_payload=dict(result), - created_by_user_id=int(operator_user.id) if getattr(operator_user, "id", None) else None, - completed_at=_utc_now_naive() if run_status in {"completed", "partial", "failed"} else None, - ) - await self.db.commit() - return result - - async def dispatch_global_command( - self, - *, - tenant_id: int, - operator_user: User, - instruction: str, - dry_run: bool, - max_operations: int, - response_mode: str = "fast", - trace_id: Optional[str] = None, - persist_run: bool = False, - ) -> dict[str, Any]: - project = self._build_global_project_snapshot() - project_directory = await self._load_project_directory(tenant_id=tenant_id) - planner_role = await llm_service._resolve_active_prompt_profile(self.db, tenant_id=tenant_id) - trace_id = str(trace_id or f"openclaw-{tenant_id}-global-{int(time.time() * 1000)}-{uuid4().hex[:8]}") - - plan, planner_status, planner_diagnostics = await self._plan_operations( - tenant_id=tenant_id, - operator_user=operator_user, - project=project, - tasks=[], - task_summary={"total": 0, "active": 0, "done": 0, "blocked": 0, "sampled": 0}, - members=[], - instruction=instruction, - dry_run=dry_run, - max_operations=max_operations, - response_mode=response_mode, - project_directory=project_directory, - ) - - executor = OperationExecutorService(self.db) - execution = await executor.execute_operations( - tenant_id=tenant_id, - operator_user=operator_user, - project=None, - operations=plan.get("operations") or [], - dry_run=dry_run, - trace_id=trace_id, - ) - openclaw_status = self._resolve_openclaw_status(execution=execution, dry_run=dry_run) - run_status = openclaw_status if openclaw_status in {"completed", "partial", "failed"} else "completed" - project_ref = self._extract_project_reference_from_execution(execution) or { - "id": None, - "title": "", - "status": "", - } - result = { - "trace_id": trace_id, - "response_mode": response_mode, - "run_status": run_status, - "planner_status": planner_status, - "planner_diagnostics": planner_diagnostics, - "openclaw_status": openclaw_status, - "project": project_ref, - "prompt_role": { - "role_key": str(planner_role.get("role_key") or ""), - "role_name": str(planner_role.get("role_name") or ""), - }, - "instruction": instruction, - "dry_run": bool(dry_run), - "summary": str(plan.get("summary") or "").strip(), - "confidence": self._to_float(plan.get("confidence"), default=0.0), - "task_summary": {"total": 0, "active": 0, "done": 0, "blocked": 0, "sampled": 0}, - "member_count": 0, - "planned_operations": len(plan.get("operations") or []), - "execution": execution, - "pending_workers": [], - } - resolved_project_id = self._to_int(project_ref.get("id")) if isinstance(project_ref, dict) else None - if persist_run and resolved_project_id is not None and resolved_project_id > 0: - run_service = AIOrchestrationRunService(self.db) - await run_service.create_run( - tenant_id=tenant_id, - trace_id=trace_id, - run_type="openclaw_dispatch", - project_id=int(resolved_project_id), - signal_id=None, - response_mode=response_mode, - status=run_status, - planner_status=planner_status, - openclaw_status=openclaw_status, - worker_total=0, - worker_completed=0, - worker_failed=0, - summary=str(result.get("summary") or "").strip(), - planner_diagnostics=planner_diagnostics, - result_payload=dict(result), - created_by_user_id=int(operator_user.id) if getattr(operator_user, "id", None) else None, - completed_at=_utc_now_naive() if run_status in {"completed", "partial", "failed"} else None, - ) - await self.db.commit() - return result - - @staticmethod - def _build_project_snapshot(project: Project) -> SimpleNamespace: - return SimpleNamespace( - id=int(project.id), - owner_id=int(project.owner_id) if getattr(project, "owner_id", None) else None, - title=str(project.title or "").strip(), - name=str(project.name or "").strip(), - status=str(project.status or "").strip(), - assigned_role=str(project.assigned_role or "").strip(), - description=str(project.description or "").strip(), - ) - - @staticmethod - def _build_global_project_snapshot() -> SimpleNamespace: - return SimpleNamespace( - id=0, - owner_id=None, - title="", - name="", - status="", - assigned_role="", - description="", - ) - - @staticmethod - def _serialize_project_directory_item(project: Project) -> dict[str, Any]: - return { - "project_id": int(project.id), - "title": str(project.title or project.name or "").strip(), - "status": str(project.status or "").strip(), - } - - async def _plan_operations( - self, - *, - tenant_id: int, - operator_user: User, - project: Project, - tasks: list[dict[str, Any]], - task_summary: dict[str, Any], - members: list[dict[str, Any]], - instruction: str, - dry_run: bool, - max_operations: int, - response_mode: str, - project_directory: Optional[list[dict[str, Any]]] = None, - ) -> tuple[dict[str, Any], str, dict[str, Any]]: - command_payload = { - "instruction": instruction, - "operator_user_id": int(operator_user.id), - "operator_name": str(operator_user.name or operator_user.username or operator_user.id), - "operator_role": str(operator_user.role or ""), - "dry_run": bool(dry_run), - "max_operations": int(max_operations), - } - project_payload = { - "scope": "project" if self._to_int(project.id) and int(project.id) > 0 else "global", - "project_id": int(project.id) if self._to_int(project.id) and int(project.id) > 0 else None, - "title": str(project.title or project.name or "").strip(), - "description": str(project.description or "").strip()[:400], - "status": str(project.status or "").strip(), - "assigned_role": str(project.assigned_role or "").strip(), - "task_summary": task_summary, - "candidate_projects": project_directory or [], - } - operation_catalog_payload = [ - { - "operation_code": item["operation_code"], - "description": item["description"], - "allowed_fields": sorted( - list( - ( - (item.get("input_schema") or {}).get("properties") or {} - ).keys() - ) - ), - } - for item in self._commander_operation_catalog() - ] - - prompt = PROMPT_OPENCLAW_COMMAND_DISPATCH.format( - command_json=json.dumps(command_payload, ensure_ascii=False), - project_json=json.dumps(project_payload, ensure_ascii=False), - tasks_json=json.dumps(tasks, ensure_ascii=False), - members_json=json.dumps(members, ensure_ascii=False), - operation_catalog_json=json.dumps(operation_catalog_payload, ensure_ascii=False), - max_operations=int(max_operations), - ) - - try: - extraction = await llm_service.generate_structured_object( - prompt, - db=self.db, - tenant_id=tenant_id, - function_name="openclaw_command_dispatch", - use_prompt_profile=False, - temperature=0.0, - max_tokens=520, - response_mode=response_mode, - ) - diagnostics = self._build_planner_diagnostics(extraction) - if extraction.payload is None: - fallback = await self._build_fallback_plan( - tenant_id=tenant_id, - project_id=self._to_int(project.id), - instruction=instruction, - max_operations=max_operations, - error=str(extraction.error_code or "structured_unknown"), - project_directory=project_directory, - ) - return fallback, "fallback", diagnostics - plan = extraction.payload - normalized_ops = self._normalize_operations(plan.get("operations"), max_operations=max_operations) - return ( - { - "summary": str(plan.get("summary") or "").strip() or "OpenClaw 已生成操作计划。", - "confidence": self._to_float(plan.get("confidence"), default=0.0), - "operations": normalized_ops, - }, - "planned", - diagnostics, - ) - except Exception as exc: - fallback = await self._build_fallback_plan( - tenant_id=tenant_id, - project_id=self._to_int(project.id), - instruction=instruction, - max_operations=max_operations, - error=str(exc), - project_directory=project_directory, - ) - return ( - fallback, - "fallback", - { - "used_fallback": True, - "error_code": "structured_empty", - "source_field": None, - "provider": "", - "model": "", - }, - ) - - async def _build_fallback_plan( - self, - *, - tenant_id: int, - project_id: Optional[int], - instruction: str, - max_operations: int, - error: str, - project_directory: Optional[list[dict[str, Any]]] = None, - ) -> dict[str, Any]: - instruction_text = str(instruction or "").strip() - text = instruction_text.lower() - project_ops = await self._build_project_fallback_operations( - tenant_id=tenant_id, - project_id=project_id, - instruction=instruction_text, - max_operations=max_operations, - project_directory=project_directory, - ) - if project_ops: - return { - "summary": "OpenClaw 规划失败,已回退到项目命令最小执行闭环。", - "confidence": 0.58, - "operations": project_ops, - } - if self._looks_like_signal_source_instruction(instruction_text): - if project_id is None: - return { - "summary": "当前信号源指令缺少项目上下文,系统未自动执行。", - "confidence": 0.0, - "operations": [], - } - operations = await self._build_signal_source_fallback_operations( - tenant_id=tenant_id, - project_id=int(project_id), - instruction=instruction_text, - max_operations=max_operations, - ) - if operations: - return { - "summary": "OpenClaw 规划失败,已回退为信号源接入最小闭环。", - "confidence": 0.28, - "operations": operations, - } - return { - "summary": "OpenClaw 未生成可安全执行的信号源接入动作,等待人工补充上下文。", - "confidence": 0.0, - "operations": [], - } - - unassigned_only = any(token in text for token in ("未分配", "没人负责", "unassigned")) - rebalance = any(token in text for token in ("重平衡", "优化负载", "重新分配", "调度")) - assign_like = rebalance or any(token in text for token in ("分配", "负责人", "指派", "接管")) - - if not assign_like: - return { - "summary": "当前指令缺少足够上下文,系统未执行任何动作。", - "confidence": 0.0, - "operations": [], - } - if project_id is None: - return { - "summary": "当前指令缺少项目上下文,系统未自动执行分配动作。", - "confidence": 0.0, - "operations": [], - } - - proposals = await workload_service.build_ai_optimize_preview( - self.db, - tenant_id=tenant_id, - project_id=int(project_id), - max_moves=max_operations, - unassigned_only=unassigned_only and not rebalance, - ) - operations = [ - { - "operation_code": "task.assign", - "payload": { - "task_id": int(item.get("task_id")), - "assignee_id": int(item.get("new_assignee_id")), - }, - "reason": str(item.get("reason") or "fallback_workload_optimizer"), - } - for item in proposals[:max_operations] - if self._to_int(item.get("task_id")) and self._to_int(item.get("new_assignee_id")) - ] - - if operations: - return { - "summary": "OpenClaw 规划失败,已回退到内置负载优化器生成分配计划。", - "confidence": 0.35, - "operations": operations, - } - return { - "summary": "系统未生成可安全执行的分配动作,等待补充更明确的负责人或任务上下文。", - "confidence": 0.0, - "operations": [], - } - - @staticmethod - def _build_planner_diagnostics(extraction: Any) -> dict[str, Any]: - return { - "used_fallback": extraction.error_code is not None, - "error_code": extraction.error_code, - "source_field": extraction.source_field or None, - "provider": str(extraction.provider or ""), - "model": str(extraction.model or ""), - } - - @staticmethod - def _resolve_openclaw_status(*, execution: dict[str, Any], dry_run: bool) -> str: - if dry_run: - return "planned" - failed_count = int(execution.get("failed_count") or 0) - applied_count = int(execution.get("applied_count") or 0) - skipped_count = int(execution.get("skipped_count") or 0) - if failed_count > 0 and applied_count == 0 and skipped_count == 0: - return "failed" - if failed_count > 0: - return "partial" - return "completed" - - @staticmethod - def _looks_like_signal_source_instruction(text: str) -> bool: - normalized = re.sub(r"\s+", "", str(text or "")) - if len(normalized) < 6: - return False - if "信号源" in normalized and any(token in normalized for token in ("新增", "增加", "添加", "接入", "配置", "注册", "建立")): - return True - return bool(SOURCE_ONBOARDING_FALLBACK_RE.search(normalized)) - - async def _build_signal_source_fallback_operations( - self, - *, - tenant_id: int, - project_id: int, - instruction: str, - max_operations: int, - ) -> list[dict[str, Any]]: - if max_operations <= 0: - return [] - project = await self._load_project(tenant_id=tenant_id, project_id=project_id) - if project is None: - return [] - - source_name = self._extract_signal_source_name(instruction) - charter = await self._load_latest_aligned_charter(tenant_id=tenant_id, project_id=project_id) - recipient_id = await self._resolve_signal_source_notification_recipient( - tenant_id=tenant_id, - owner_id=int(project.owner_id) if project.owner_id else None, - ) - operations: list[dict[str, Any]] = [] - - if charter is not None and len(operations) < max_operations: - operations.append( - { - "operation_code": "task.create", - "payload": { - "project_id": int(project_id), - "title": f"接入{source_name}信号源"[:120], - "description": self._build_signal_source_task_description( - source_name=source_name, - instruction=instruction, - ), - "priority": "high", - }, - "reason": "fallback_signal_source_onboarding", - } - ) - - if recipient_id is not None and len(operations) < max_operations: - operations.append( - { - "operation_code": "notification.create", - "payload": { - "user_id": int(recipient_id), - "title": f"{source_name}信号源接入待确认"[:120], - "content": self._build_signal_source_notification_content( - source_name=source_name, - instruction=instruction, - charter_ready=charter is not None, - ), - }, - "reason": "fallback_signal_source_onboarding", - } - ) - - return operations[:max_operations] - - @staticmethod - def _extract_signal_source_name(text: str) -> str: - cleaned = re.sub(r"\s+", " ", str(text or "")).strip() - if not cleaned: - return "未命名" - repo_match = re.search(r"\b([A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+)\b", cleaned) - if repo_match: - return repo_match.group(1)[:80] - quoted_match = re.search(r"[\"'“](.+?)[\"'”]", cleaned) - if quoted_match: - candidate = quoted_match.group(1).strip(" []【】()()::\"'“”") - if candidate: - return candidate[:80] - for pattern in ( - r"(?:新增|增加|添加|接入|配置|注册|建立)(?:一个|1个|一条|一组|新的|一个新的)?\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"^(?:一个|1个|一条|一组|一个新的|新的|新增的)\s+", - "", - candidate, - flags=re.IGNORECASE, - ) - candidate = re.sub(r"(?:信号源|来源|监控源|source|channel)$", "", candidate, flags=re.IGNORECASE).strip() - if candidate: - return candidate[:80] - lower = cleaned.lower() - if "github" in lower: - return "GitHub" - if "飞书" in cleaned or "lark" in lower: - return "Feishu" - if "webhook" in lower: - return "Webhook" - return cleaned[:80] - - @staticmethod - def _build_signal_source_task_description(*, source_name: str, instruction: str) -> str: - return ( - f"为信号源“{source_name}”完成最小接入任务。" - "包含接入配置、权限校验、数据字段确认和回流验证。" - f"原始指令:{instruction[:400]}" - )[:5000] - - @staticmethod - def _build_signal_source_notification_content(*, source_name: str, instruction: str, charter_ready: bool) -> str: - suffix = ( - "已自动生成接入任务,请确认负责人和接入边界。" - if charter_ready - else "当前项目尚未有 aligned charter,系统只先发出确认通知,避免无宪章自动建任务。" - ) - return f"OpenClaw 已识别到新增信号源请求:{source_name}。{suffix} 原始指令:{instruction[:300]}".strip() - - async def _resolve_signal_source_notification_recipient( - self, - *, - tenant_id: int, - owner_id: Optional[int], - ) -> Optional[int]: - if owner_id is not None: - owner = ( - await self.db.execute( - select(User.id).where( - and_( - User.tenant_id == tenant_id, - User.id == int(owner_id), - User.is_active.is_(True), - User.status == "active", - ) - ) - ) - ).scalar_one_or_none() - if owner is not None: - return int(owner) - - manager = ( - await self.db.execute( - select(User.id).where( - and_( - User.tenant_id == tenant_id, - User.is_active.is_(True), - User.status == "active", - User.role.in_(["BOSS", "ADMIN"]), - ) - ).order_by(User.id.asc()).limit(1) - ) - ).scalar_one_or_none() - return int(manager) if manager is not None else None - - def _commander_operation_catalog(self) -> list[dict[str, Any]]: - items: list[dict[str, Any]] = [] - for code in SUPPORTED_COMMANDER_OPERATION_CODES: - row = operation_catalog_service.get_operation(code) - if row is not None: - items.append(row) - return items - - async def _load_project(self, *, tenant_id: int, project_id: int) -> Optional[Project]: - return ( - await self.db.execute( - select(Project).where( - and_( - Project.tenant_id == tenant_id, - Project.id == project_id, - ) - ) - ) - ).scalar_one_or_none() - - async def _load_project_directory(self, *, tenant_id: int, limit: int = 20) -> list[dict[str, Any]]: - rows = ( - await self.db.execute( - select(Project).where( - and_( - Project.tenant_id == tenant_id, - Project.status != "inactive", - ) - ).order_by(Project.updated_at.desc(), Project.id.desc()).limit(limit) - ) - ).scalars().all() - return [self._serialize_project_directory_item(row) for row in rows] - - async def _build_project_fallback_operations( - self, - *, - tenant_id: int, - project_id: Optional[int], - instruction: str, - max_operations: int, - project_directory: Optional[list[dict[str, Any]]], - ) -> list[dict[str, Any]]: - if max_operations <= 0: - return [] - title = self._extract_project_create_title(instruction) - if title: - payload: dict[str, Any] = {"title": title} - description = self._extract_project_create_description(instruction=instruction, title=title) - if description: - payload["description"] = description - return [ - { - "operation_code": "project.create", - "payload": payload, - "reason": "explicit_project_create", - } - ] - - resolved_project_id = project_id or await self._resolve_project_id_from_instruction( - tenant_id=tenant_id, - instruction=instruction, - project_directory=project_directory, - ) - if resolved_project_id is None: - return [] - - status_operation = self._build_project_status_fallback_operation( - project_id=int(resolved_project_id), - instruction=instruction, - ) - return [status_operation] if status_operation else [] - - async def _resolve_project_id_from_instruction( - self, - *, - tenant_id: int, - instruction: str, - project_directory: Optional[list[dict[str, Any]]], - ) -> Optional[int]: - directory = project_directory or await self._load_project_directory(tenant_id=tenant_id, limit=50) - normalized = re.sub(r"\s+", "", str(instruction or "")).lower() - for item in directory: - title = re.sub(r"\s+", "", str(item.get("title") or "")).lower() - project_id = self._to_int(item.get("project_id")) - if project_id is None or not title or len(title) < 2: - continue - if title in normalized: - return int(project_id) - return None - - @staticmethod - 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 "" - 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) - - @staticmethod - 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 OpenClawCommanderService._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 OpenClawCommanderService._normalize_project_title_candidate(candidate) - return "" - - @staticmethod - 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] - - @staticmethod - def _build_project_status_fallback_operation(*, project_id: int, instruction: str) -> Optional[dict[str, Any]]: - normalized = re.sub(r"\s+", "", str(instruction or "")).lower() - if any(token in normalized for token in ("归档", "archive", "停用项目", "项目归档", "状态改成archived", "状态改为archived")): - return { - "operation_code": "project.archive", - "payload": {"project_id": int(project_id)}, - "reason": "explicit_project_archive", - } - if any( - token in normalized - for token in ( - "重新打开", - "重开", - "恢复active", - "恢复为active", - "恢复项目", - "激活项目", - "设为active", - "设成active", - "改成active", - "改为active", - "状态改成active", - "状态改为active", - "状态设为active", - ) - ): - return { - "operation_code": "project.reopen", - "payload": {"project_id": int(project_id)}, - "reason": "explicit_project_reopen", - } - if any( - token in normalized - for token in ( - "设为done", - "设成done", - "改成done", - "改为done", - "状态改成done", - "状态改为done", - "状态设为done", - "标记完成", - ) - ): - return { - "operation_code": "project.update", - "payload": {"project_id": int(project_id), "status": "done"}, - "reason": "explicit_project_done", - } - return None - - @staticmethod - def _extract_project_reference_from_execution(execution: dict[str, Any]) -> Optional[dict[str, Any]]: - results = execution.get("results") if isinstance(execution, dict) else None - if not isinstance(results, list): - return None - for row in results: - if not isinstance(row, dict): - continue - if str(row.get("entity_type") or "").strip() != "project": - continue - payload = row.get("result") if isinstance(row.get("result"), dict) else {} - project_id = payload.get("project_id") or row.get("entity_id") - title = payload.get("title") or payload.get("project_title") or "" - status = payload.get("status") or "" - if project_id is None: - continue - return { - "id": str(project_id), - "title": str(title or "").strip(), - "status": str(status or "").strip(), - } - return None - - async def _load_latest_aligned_charter(self, *, tenant_id: int, project_id: int) -> Optional[TruthNode]: - return ( - await self.db.execute( - select(TruthNode).where( - and_( - TruthNode.tenant_id == tenant_id, - TruthNode.project_id == int(project_id), - TruthNode.node_type == "charter", - TruthNode.status.in_(tuple(ALIGNED_STATUSES)), - ) - ).order_by(TruthNode.updated_at.desc(), TruthNode.id.desc()) - ) - ).scalars().first() - - async def _load_project_tasks( - self, - *, - tenant_id: int, - project_id: int, - members: list[dict[str, Any]], - ) -> tuple[list[dict[str, Any]], dict[str, Any]]: - task_rows = ( - await self.db.execute( - select(Task).where( - and_( - Task.tenant_id == tenant_id, - Task.project_id == project_id, - ) - ).order_by(Task.id.asc()) - ) - ).scalars().all() - - member_name_map = {int(item["user_id"]): str(item.get("name") or "") for item in members if self._to_int(item.get("user_id"))} - active_tasks = [task for task in task_rows if str(task.status or "").strip().lower() not in DONE_TASK_STATUSES] - selected_tasks = active_tasks[:40] if active_tasks else task_rows[:20] - serialized = [ - { - "task_id": int(task.id), - "title": str(task.title or "").strip(), - "status": str(task.status or "").strip().lower(), - "priority": str(task.priority or "").strip().lower(), - "assignee_id": int(task.assignee_id) if task.assignee_id else None, - "assignee_name": member_name_map.get(int(task.assignee_id), "") if task.assignee_id else "", - "required_skill_tag_ids": [int(item) for item in (task.required_skill_tag_ids or []) if self._to_int(item)], - "estimated_hours": float(task.estimated_hours or 0.0), - } - for task in selected_tasks - ] - summary = { - "total": len(task_rows), - "active": len(active_tasks), - "done": len([task for task in task_rows if str(task.status or "").strip().lower() in DONE_TASK_STATUSES]), - "blocked": len([task for task in task_rows if str(task.status or "").strip().lower() == "blocked"]), - "sampled": len(serialized), - } - return serialized, summary - - async def _load_project_team_context( - self, - *, - tenant_id: int, - project_id: int, - owner_id: Optional[int], - ) -> list[dict[str, Any]]: - member_ids_result = await self.db.execute( - select(UserProject.user_id).where( - and_( - UserProject.tenant_id == tenant_id, - UserProject.project_id == project_id, - ) - ) - ) - member_ids = {int(uid) for uid in member_ids_result.scalars().all()} - if owner_id: - member_ids.add(int(owner_id)) - if not member_ids: - manager_result = await self.db.execute( - select(User.id).where( - and_( - User.tenant_id == tenant_id, - User.status == "active", - User.is_active.is_(True), - User.role.in_(["BOSS", "ADMIN"]), - ) - ).limit(5) - ) - member_ids = {int(uid) for uid in manager_result.scalars().all()} - if not member_ids: - return [] - - users = ( - await self.db.execute( - select(User).where( - and_( - User.tenant_id == tenant_id, - User.id.in_(sorted(member_ids)), - User.status == "active", - User.is_active.is_(True), - ) - ) - ) - ).scalars().all() - if not users: - return [] - - user_ids = [int(user.id) for user in users] - workload_rows = ( - await self.db.execute( - select(UserWorkload).where( - and_( - UserWorkload.tenant_id == tenant_id, - UserWorkload.user_id.in_(user_ids), - ) - ) - ) - ).scalars().all() - workload_map = {int(item.user_id): item for item in workload_rows} - - skill_rows = ( - await self.db.execute( - select( - UserSkillBinding.user_id, - SkillTag.id, - SkillTag.name, - SkillTag.slug, - UserSkillBinding.proficiency, - ) - .join(SkillTag, SkillTag.id == UserSkillBinding.skill_tag_id) - .where( - and_( - UserSkillBinding.tenant_id == tenant_id, - UserSkillBinding.user_id.in_(user_ids), - SkillTag.tenant_id == tenant_id, - ) - ) - ) - ).all() - - skill_map: dict[int, list[dict[str, Any]]] = {} - for uid, sid, name, slug, proficiency in skill_rows: - skill_map.setdefault(int(uid), []).append( - { - "skill_id": int(sid), - "name": str(name or "").strip(), - "slug": str(slug or "").strip(), - "proficiency": int(proficiency or 0), - } - ) - - members: list[dict[str, Any]] = [] - for user in users: - uid = int(user.id) - workload_row = workload_map.get(uid) - member_skills = skill_map.get(uid, []) - members.append( - { - "user_id": uid, - "name": str(user.name or user.username or uid), - "role": str(user.role or ""), - "active": bool(user.is_active and str(user.status or "").strip().lower() == "active"), - "workload_ratio": round( - float(workload_row.workload_ratio if workload_row is not None else float(user.workload or 0.0)), - 2, - ), - "current_assigned_hours": round( - float(workload_row.current_assigned_hours or 0.0) if workload_row else 0.0, - 2, - ), - "max_available_hours_per_week": round( - float(workload_row.max_available_hours_per_week or 40.0) if workload_row else 40.0, - 2, - ), - "skill_tag_ids": [int(item["skill_id"]) for item in member_skills], - "skill_names": [str(item["name"]) for item in member_skills], - } - ) - members.sort(key=lambda item: (float(item.get("workload_ratio") or 0.0), int(item.get("user_id") or 0))) - return members - - @classmethod - def _parse_plan_response(cls, raw_response: str) -> dict[str, Any]: - content = (raw_response or "").strip() - if not content: - raise ValueError("empty llm response") - if content.startswith("```"): - parts = content.split("```") - if len(parts) >= 2: - content = parts[1].strip() - if content.startswith("json"): - content = content[4:].strip() - try: - parsed = json.loads(content) - if isinstance(parsed, dict): - return parsed - except Exception: - pass - object_text = cls._extract_first_json_object(content) - if not object_text: - raise ValueError("llm json not found") - parsed = json.loads(object_text) - if not isinstance(parsed, dict): - raise ValueError("llm json is not object") - return parsed - - @classmethod - def _extract_first_json_object(cls, text: str) -> Optional[str]: - raw = str(text or "") - start = raw.find("{") - if start < 0: - return None - 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] - return None - - def _normalize_operations(self, raw_operations: Any, *, max_operations: int) -> list[dict[str, Any]]: - if not isinstance(raw_operations, list): - return [] - normalized: list[dict[str, Any]] = [] - for item in raw_operations: - if not isinstance(item, dict): - continue - operation_code = str(item.get("operation_code") or "").strip().lower() - payload = item.get("payload") - if not operation_code or not isinstance(payload, dict): - continue - normalized.append( - { - "operation_code": operation_code, - "payload": dict(payload), - "reason": str(item.get("reason") or "").strip()[:300], - } - ) - if len(normalized) >= max_operations: - break - return normalized - - @staticmethod - def _to_int(value: Any) -> Optional[int]: - if isinstance(value, bool): - return None - try: - return int(str(value).strip()) - except Exception: - return None - - @staticmethod - def _to_float(value: Any, *, default: float = 0.0) -> float: - try: - return float(value) - except Exception: - return float(default) - - -class OperationExecutorService: - """对白名单操作做 schema 校验并执行。""" - - def __init__(self, db: AsyncSession) -> None: - self.db = db - self._project_member_cache: dict[int, set[int]] = {} - - async def execute_operations( - self, - *, - tenant_id: int, - operator_user: User, - project: Optional[Project | SimpleNamespace], - operations: list[dict[str, Any]], - dry_run: bool, - trace_id: str, - ) -> dict[str, Any]: - seen_idempotency_keys: set[str] = set() - results: list[dict[str, Any]] = [] - applied_count = 0 - skipped_count = 0 - failed_count = 0 - context_project_id = self._project_context_id(project) - - for index, item in enumerate(operations, start=1): - operation_code = str(item.get("operation_code") or "").strip().lower() - raw_payload = item.get("payload") - reason = str(item.get("reason") or "").strip() - persistent_claim: OperationExecutionLedger | None = None - - try: - payload = self._validate_and_normalize_payload(operation_code=operation_code, payload=raw_payload) - idempotency_key = self._build_idempotency_key( - operation_code=operation_code, - payload=payload, - tenant_id=tenant_id, - project_id=context_project_id, - ) - if idempotency_key in seen_idempotency_keys: - skipped_count += 1 - results.append( - { - "index": index, - "operation_code": operation_code, - "payload": payload, - "idempotency_key": idempotency_key, - "status": "skipped", - "message": "duplicate_in_request", - } - ) - continue - seen_idempotency_keys.add(idempotency_key) - - if dry_run: - results.append( - { - "index": index, - "operation_code": operation_code, - "payload": payload, - "idempotency_key": idempotency_key, - "status": "planned", - "message": reason or "dry_run", - } - ) - continue - - persistent_claim, duplicate_result = await self._claim_persistent_idempotency( - tenant_id=tenant_id, - project_id=context_project_id if context_project_id > 0 else None, - operation_code=operation_code, - payload=payload, - idempotency_key=idempotency_key, - trace_id=trace_id, - ) - if duplicate_result is not None: - skipped_count += 1 - results.append( - { - "index": index, - "operation_code": operation_code, - "payload": payload, - "idempotency_key": idempotency_key, - **duplicate_result, - } - ) - continue - - result = await self._execute_single_operation( - tenant_id=tenant_id, - operator_user=operator_user, - project=project, - operation_code=operation_code, - payload=payload, - reason=reason, - trace_id=trace_id, - idempotency_key=idempotency_key, - persistent_claim=persistent_claim, - ) - status = str(result.get("status") or "applied") - if status == "applied": - applied_count += 1 - elif status == "skipped": - skipped_count += 1 - results.append( - { - "index": index, - "operation_code": operation_code, - "payload": payload, - "idempotency_key": idempotency_key, - **result, - } - ) - except Exception as exc: - await self._safe_rollback() - failed_count += 1 - results.append( - { - "index": index, - "operation_code": operation_code, - "payload": raw_payload if isinstance(raw_payload, dict) else {}, - "status": "failed", - "message": str(exc), - } - ) - - return { - "dry_run": bool(dry_run), - "requested_count": len(operations), - "applied_count": applied_count, - "skipped_count": skipped_count, - "failed_count": failed_count, - "results": results, - } - - async def _claim_persistent_idempotency( - self, - *, - tenant_id: int, - project_id: Optional[int], - operation_code: str, - payload: dict[str, Any], - idempotency_key: str, - trace_id: str, - ) -> tuple[Optional[OperationExecutionLedger], Optional[dict[str, Any]]]: - if operation_code not in PERSISTENT_IDEMPOTENCY_OPERATION_CODES: - return None, None - - claim = OperationExecutionLedger( - tenant_id=tenant_id, - operation_code=operation_code, - project_id=int(project_id) if project_id is not None and int(project_id) > 0 else None, - idempotency_key=idempotency_key, - trace_id=trace_id, - status="processing", - payload=dict(payload), - ) - self.db.add(claim) - try: - await self.db.flush() - return claim, None - except IntegrityError: - await self._safe_rollback() - existing = await self._load_execution_claim( - tenant_id=tenant_id, - operation_code=operation_code, - idempotency_key=idempotency_key, - ) - if existing is None: - raise - if str(existing.status or "").strip().lower() == "failed": - await self.db.delete(existing) - await self.db.flush() - return await self._claim_persistent_idempotency( - tenant_id=tenant_id, - project_id=project_id, - operation_code=operation_code, - payload=payload, - idempotency_key=idempotency_key, - trace_id=trace_id, - ) - return ( - None, - { - "status": "skipped", - "message": "duplicate_persistent" - if str(existing.status or "").strip().lower() == "applied" - else "duplicate_in_progress", - "entity_type": str(existing.entity_type or "").strip() or None, - "entity_id": str(existing.entity_id or "").strip() or None, - "trace_id": str(existing.trace_id or "").strip() or None, - }, - ) - - async def _load_execution_claim( - self, - *, - tenant_id: int, - operation_code: str, - idempotency_key: str, - ) -> Optional[OperationExecutionLedger]: - return ( - await self.db.execute( - select(OperationExecutionLedger).where( - and_( - OperationExecutionLedger.tenant_id == tenant_id, - OperationExecutionLedger.operation_code == operation_code, - OperationExecutionLedger.idempotency_key == idempotency_key, - ) - ) - ) - ).scalar_one_or_none() - - async def _safe_rollback(self) -> None: - try: - await self.db.rollback() - except Exception: - return - - def _validate_and_normalize_payload(self, *, operation_code: str, payload: Any) -> dict[str, Any]: - row = operation_catalog_service.get_operation(operation_code) - if row is None or operation_code not in SUPPORTED_COMMANDER_OPERATION_CODES: - raise ValueError(f"unsupported operation_code: {operation_code}") - if not isinstance(payload, dict): - raise ValueError(f"{operation_code} payload must be object") - - input_schema = row.get("input_schema") if isinstance(row.get("input_schema"), dict) else {} - properties = input_schema.get("properties") if isinstance(input_schema.get("properties"), dict) else {} - required = [str(item) for item in (input_schema.get("required") or [])] - normalized: dict[str, Any] = {} - - if input_schema.get("additionalProperties") is False: - extra_keys = sorted(set(payload.keys()) - set(properties.keys())) - if extra_keys: - raise ValueError(f"{operation_code} payload has unsupported fields: {', '.join(extra_keys)}") - - for field, spec in properties.items(): - if field not in payload: - continue - normalized[field] = self._coerce_value(field=field, value=payload[field], spec=spec if isinstance(spec, dict) else {}) - - missing = [field for field in required if field not in normalized] - if missing: - raise ValueError(f"{operation_code} missing required fields: {', '.join(missing)}") - return normalized - - @classmethod - def _coerce_value(cls, *, field: str, value: Any, spec: dict[str, Any]) -> Any: - value_type = str(spec.get("type") or "").strip().lower() - if value_type == "integer": - parsed = cls._to_int(value) - if parsed is None: - raise ValueError(f"{field} must be integer") - return parsed - if value_type == "number": - if isinstance(value, bool): - raise ValueError(f"{field} must be number") - try: - parsed_float = float(value) - except Exception as exc: - raise ValueError(f"{field} must be number") from exc - minimum = spec.get("minimum") - maximum = spec.get("maximum") - if minimum is not None and parsed_float < float(minimum): - raise ValueError(f"{field} below minimum") - if maximum is not None and parsed_float > float(maximum): - raise ValueError(f"{field} above maximum") - return parsed_float - if value_type == "string": - text = str(value or "").strip() - if not text: - raise ValueError(f"{field} must be non-empty string") - enum_values = spec.get("enum") - if isinstance(enum_values, list) and enum_values: - enum_map = {str(item).strip().lower(): str(item) for item in enum_values} - matched = enum_map.get(text.lower()) - if matched is None: - raise ValueError(f"{field} must be one of {', '.join(str(item) for item in enum_values)}") - text = matched - min_length = spec.get("minLength") - max_length = spec.get("maxLength") - if min_length is not None and len(text) < int(min_length): - raise ValueError(f"{field} shorter than minLength") - if max_length is not None and len(text) > int(max_length): - raise ValueError(f"{field} longer than maxLength") - return text - if value_type == "array": - if not isinstance(value, list): - raise ValueError(f"{field} must be array") - max_items = spec.get("maxItems") - if max_items is not None and len(value) > int(max_items): - raise ValueError(f"{field} too many items") - item_spec = spec.get("items") if isinstance(spec.get("items"), dict) else {} - return [ - cls._coerce_value(field=field, value=item, spec=item_spec) - for item in value - ] - return value - - async def _execute_single_operation( - self, - *, - tenant_id: int, - operator_user: User, - project: Optional[Project | SimpleNamespace], - operation_code: str, - payload: dict[str, Any], - reason: str, - trace_id: str, - idempotency_key: str, - persistent_claim: Optional[OperationExecutionLedger], - ) -> dict[str, Any]: - if operation_code == "project.create": - return await self._execute_project_create( - tenant_id=tenant_id, - operator_user=operator_user, - payload=payload, - reason=reason, - trace_id=trace_id, - ) - if operation_code == "project.update": - return await self._execute_project_update( - tenant_id=tenant_id, - operator_user=operator_user, - project=project, - payload=payload, - reason=reason, - trace_id=trace_id, - ) - if operation_code == "project.archive": - return await self._execute_project_archive( - tenant_id=tenant_id, - operator_user=operator_user, - project=project, - payload=payload, - reason=reason, - trace_id=trace_id, - ) - if operation_code == "project.reopen": - return await self._execute_project_reopen( - tenant_id=tenant_id, - operator_user=operator_user, - project=project, - payload=payload, - reason=reason, - trace_id=trace_id, - ) - if operation_code == "task.assign": - return await self._execute_task_assign( - tenant_id=tenant_id, - operator_user=operator_user, - project=project, - payload=payload, - reason=reason, - trace_id=trace_id, - ) - if operation_code == "task.create": - return await self._execute_task_create( - tenant_id=tenant_id, - operator_user=operator_user, - project=project, - payload=payload, - reason=reason, - trace_id=trace_id, - ) - if operation_code == "task.prioritize": - return await self._execute_task_prioritize( - tenant_id=tenant_id, - operator_user=operator_user, - project=project, - payload=payload, - reason=reason, - trace_id=trace_id, - ) - if operation_code == "task.status_transition": - return await self._execute_task_status_transition( - tenant_id=tenant_id, - operator_user=operator_user, - project=project, - payload=payload, - reason=reason, - trace_id=trace_id, - ) - if operation_code == "notification.create": - return await self._execute_notification_create( - tenant_id=tenant_id, - operator_user=operator_user, - project=project, - payload=payload, - reason=reason, - trace_id=trace_id, - idempotency_key=idempotency_key, - persistent_claim=persistent_claim, - ) - if operation_code == "personnel.workload_update": - return await self._execute_personnel_workload_update( - tenant_id=tenant_id, - operator_user=operator_user, - project=project, - payload=payload, - reason=reason, - trace_id=trace_id, - ) - raise ValueError(f"unsupported operation_code: {operation_code}") - - async def _execute_project_create( - self, - *, - tenant_id: int, - operator_user: User, - payload: dict[str, Any], - reason: str, - trace_id: str, - ) -> dict[str, Any]: - title = compact_title(str(payload["title"]), max_len=24) - if not title: - raise ValueError("project title required") - existing = await self._load_project_by_title(tenant_id=tenant_id, title=title) - 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(), - }, - } - - owner_id = self._to_int(payload.get("owner_id")) or int(operator_user.id) - if owner_id is not None: - owner = await self._load_active_user(tenant_id=tenant_id, user_id=int(owner_id)) - if owner is None: - owner_id = int(operator_user.id) - - await ensure_project_id_sequence(self.db) - project = Project( - tenant_id=tenant_id, - name=title, - title=title, - description=str(payload.get("description") or "").strip()[:4000] or None, - owner_id=int(owner_id) if owner_id is not None else None, - 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=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=tenant_id, - user_id=int(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=f"{trace_id}:{reason}"[:1000] if reason else trace_id, - ) - await self.db.commit() - await context_cache_service.invalidate_system_context(tenant_id=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 _execute_project_update( - self, - *, - tenant_id: int, - operator_user: User, - project: Optional[Project | SimpleNamespace], - payload: dict[str, Any], - reason: str, - trace_id: str, - ) -> dict[str, Any]: - target_project = await self._load_project_for_operation( - tenant_id=tenant_id, - project=project, - payload_project_id=int(payload["project_id"]), - operation_code="project.update", - ) - before = { - "title": str(target_project.title or target_project.name or "").strip(), - "description": str(target_project.description or "").strip(), - "status": str(target_project.status or "").strip(), - } - changed = False - if "title" in payload: - next_title = compact_title(str(payload.get("title") or ""), max_len=24) - if not next_title: - raise ValueError("project title required") - if next_title != str(target_project.title or target_project.name or "").strip(): - target_project.title = next_title - target_project.name = next_title - changed = True - if "description" in payload: - next_description = str(payload.get("description") or "").strip()[:4000] or None - if next_description != (target_project.description or None): - target_project.description = next_description - changed = True - if "status" in payload: - next_status = self._normalize_project_status(payload.get("status")) - if next_status != str(target_project.status or "").strip().lower(): - target_project.status = next_status - changed = True - if not changed: - return { - "status": "skipped", - "message": "already_in_target_state", - "entity_type": "project", - "entity_id": str(target_project.id), - "result": { - "project_id": int(target_project.id), - "title": str(target_project.title or target_project.name or "").strip(), - "status": str(target_project.status or "").strip(), - }, - } - - await log_operation( - self.db, - tenant_id=tenant_id, - user_id=int(operator_user.id), - project_id=int(target_project.id), - entity_type="project", - entity_id=str(target_project.id), - action="project.update", - before_data=before, - after_data={ - "title": str(target_project.title or target_project.name or "").strip(), - "description": str(target_project.description or "").strip(), - "status": str(target_project.status or "").strip(), - }, - remark=f"{trace_id}:{reason}"[:1000] if reason else trace_id, - ) - await self.db.commit() - await context_cache_service.invalidate_system_context(tenant_id=tenant_id) - return { - "status": "applied", - "message": f"project updated #{int(target_project.id)}", - "entity_type": "project", - "entity_id": str(target_project.id), - "result": { - "project_id": int(target_project.id), - "title": str(target_project.title or target_project.name or "").strip(), - "status": str(target_project.status or "").strip(), - }, - } - - async def _execute_project_archive( - self, - *, - tenant_id: int, - operator_user: User, - project: Optional[Project | SimpleNamespace], - payload: dict[str, Any], - reason: str, - trace_id: str, - ) -> dict[str, Any]: - return await self._execute_project_update( - tenant_id=tenant_id, - operator_user=operator_user, - project=project, - payload={"project_id": int(payload["project_id"]), "status": "archived"}, - reason=reason, - trace_id=trace_id, - ) - - async def _execute_project_reopen( - self, - *, - tenant_id: int, - operator_user: User, - project: Optional[Project | SimpleNamespace], - payload: dict[str, Any], - reason: str, - trace_id: str, - ) -> dict[str, Any]: - return await self._execute_project_update( - tenant_id=tenant_id, - operator_user=operator_user, - project=project, - payload={"project_id": int(payload["project_id"]), "status": "active"}, - reason=reason, - trace_id=trace_id, - ) - - async def _execute_task_create( - self, - *, - tenant_id: int, - operator_user: User, - project: Optional[Project | SimpleNamespace], - payload: dict[str, Any], - reason: str, - trace_id: str, - ) -> dict[str, Any]: - project = self._require_project_context(project=project, operation_code="task.create") - project_id = int(payload["project_id"]) - if project_id != int(project.id): - raise ValueError("task.create project_id mismatch") - - charter = await self._load_latest_aligned_charter(tenant_id=tenant_id, project_id=project_id) - if charter is None: - raise ValueError("aligned charter required before task.create") - - assignee_id = self._to_int(payload.get("assignee_id")) - touched_user_ids: list[int] = [] - if assignee_id is not None: - assignee = await self._load_active_user(tenant_id=tenant_id, user_id=assignee_id) - if assignee is None: - raise ValueError("assignee not found or inactive") - member_ids = await self._get_project_member_ids( - tenant_id=tenant_id, - project_id=project_id, - owner_id=int(project.owner_id) if project.owner_id else None, - ) - if int(assignee.id) not in member_ids: - raise ValueError("assignee is not project member") - touched_user_ids.append(int(assignee.id)) - - due_date = self._parse_due_date_value(payload.get("due_date")) - required_skill_tag_ids = self._safe_int_list(payload.get("required_skill_tag_ids")) - task = await persist_task( - self.db, - tenant_id=tenant_id, - truth_node_id=int(charter.id), - project_id=project_id, - draft=build_task_create_draft( - title=str(payload["title"]), - description=str(payload.get("description") or "").strip() or None, - assignee_id=assignee_id, - required_skill_tag_ids=required_skill_tag_ids, - priority=payload.get("priority"), - status="todo", - estimated_hours=self._to_float(payload.get("estimated_hours"), default=0.0) if payload.get("estimated_hours") is not None else None, - due_date=due_date, - ), - actor_user_id=int(operator_user.id), - action="task.create", - remark=f"{trace_id}:{reason}"[:1000] if reason else trace_id, - commit=not touched_user_ids, - refresh_workload=bool(touched_user_ids), - ) - - await context_cache_service.invalidate_system_context(tenant_id=tenant_id) - for user_id in touched_user_ids: - await context_cache_service.invalidate_user_context(tenant_id=tenant_id, user_id=user_id) - self._sync_task_rag(task=task, tenant_id=tenant_id) - - return { - "status": "applied", - "message": f"task created #{int(task.id)}", - "entity_type": "task", - "entity_id": str(task.id), - } - - async def _execute_task_assign( - self, - *, - tenant_id: int, - operator_user: User, - project: Optional[Project | SimpleNamespace], - payload: dict[str, Any], - reason: str, - trace_id: str, - ) -> dict[str, Any]: - project = self._require_project_context(project=project, operation_code="task.assign") - task = await self._load_task(tenant_id=tenant_id, task_id=int(payload["task_id"])) - if task is None or int(task.project_id) != int(project.id): - raise ValueError("task not found in target project") - - assignee = await self._load_active_user(tenant_id=tenant_id, user_id=int(payload["assignee_id"])) - if assignee is None: - raise ValueError("assignee not found or inactive") - - member_ids = await self._get_project_member_ids(tenant_id=tenant_id, project_id=int(project.id), owner_id=int(project.owner_id) if project.owner_id else None) - if int(assignee.id) not in member_ids: - raise ValueError("assignee is not project member") - - current_assignee_id = int(task.assignee_id) if task.assignee_id else None - if current_assignee_id == int(assignee.id): - return { - "status": "skipped", - "message": "already_assigned", - "entity_type": "task", - "entity_id": str(task.id), - } - - before = { - "assignee_id": current_assignee_id, - "status": task.status, - } - task.assignee_id = int(assignee.id) - await log_operation( - self.db, - tenant_id=tenant_id, - user_id=int(operator_user.id), - project_id=int(project.id), - entity_type="task", - entity_id=str(task.id), - action="task.assign", - before_data=before, - after_data={"assignee_id": int(assignee.id), "status": task.status}, - remark=f"{trace_id}:{reason}"[:1000] if reason else trace_id, - ) - - touched_user_ids: list[int] = [] - if current_assignee_id: - touched_user_ids.append(current_assignee_id) - touched_user_ids.append(int(assignee.id)) - for uid in touched_user_ids: - await workload_service.refresh_user_assigned_hours_from_tasks(self.db, tenant_id=tenant_id, user_id=uid) - - await context_cache_service.invalidate_system_context(tenant_id=tenant_id) - for uid in sorted(set(touched_user_ids)): - await context_cache_service.invalidate_user_context(tenant_id=tenant_id, user_id=uid) - self._sync_task_rag(task=task, tenant_id=tenant_id) - - return { - "status": "applied", - "message": f"task assigned to user#{int(assignee.id)}", - "entity_type": "task", - "entity_id": str(task.id), - } - - async def _execute_task_prioritize( - self, - *, - tenant_id: int, - operator_user: User, - project: Optional[Project | SimpleNamespace], - payload: dict[str, Any], - reason: str, - trace_id: str, - ) -> dict[str, Any]: - project = self._require_project_context(project=project, operation_code="task.prioritize") - task = await self._load_task(tenant_id=tenant_id, task_id=int(payload["task_id"])) - if task is None or int(task.project_id) != int(project.id): - raise ValueError("task not found in target project") - - before_priority = self._normalize_task_priority(task.priority) - next_priority = self._normalize_task_priority(payload.get("priority")) - if before_priority == next_priority: - return { - "status": "skipped", - "message": "already_in_target_priority", - "entity_type": "task", - "entity_id": str(task.id), - } - - task.priority = next_priority - await log_operation( - self.db, - tenant_id=tenant_id, - user_id=int(operator_user.id), - project_id=int(project.id), - entity_type="task", - entity_id=str(task.id), - action="task.prioritize", - before_data={"priority": before_priority}, - after_data={"priority": next_priority}, - remark=f"{trace_id}:{reason}"[:1000] if reason else trace_id, - ) - await self.db.commit() - await context_cache_service.invalidate_system_context(tenant_id=tenant_id) - if task.assignee_id: - await context_cache_service.invalidate_user_context(tenant_id=tenant_id, user_id=int(task.assignee_id)) - self._sync_task_rag(task=task, tenant_id=tenant_id) - - return { - "status": "applied", - "message": f"task priority updated to {next_priority}", - "entity_type": "task", - "entity_id": str(task.id), - } - - async def _execute_task_status_transition( - self, - *, - tenant_id: int, - operator_user: User, - project: Optional[Project | SimpleNamespace], - payload: dict[str, Any], - reason: str, - trace_id: str, - ) -> dict[str, Any]: - project = self._require_project_context(project=project, operation_code="task.status_transition") - task = await self._load_task(tenant_id=tenant_id, task_id=int(payload["task_id"])) - if task is None or int(task.project_id) != int(project.id): - raise ValueError("task not found in target project") - - status = str(payload["status"]).strip().lower() - if status not in SUPPORTED_TASK_STATUSES: - raise ValueError("unsupported task status") - before_status = str(task.status or "").strip().lower() - if before_status == status: - return { - "status": "skipped", - "message": "already_in_target_status", - "entity_type": "task", - "entity_id": str(task.id), - } - - task.status = status - if status in DONE_TASK_STATUSES: - task.completed_at = now_cn_naive() - - await log_operation( - self.db, - tenant_id=tenant_id, - user_id=int(operator_user.id), - project_id=int(project.id), - entity_type="task", - entity_id=str(task.id), - action="task.status_transition", - before_data={"status": before_status}, - after_data={"status": status}, - remark=f"{trace_id}:{reason}"[:1000] if reason else trace_id, - ) - - if task.assignee_id: - await workload_service.refresh_user_assigned_hours_from_tasks( - self.db, - tenant_id=tenant_id, - user_id=int(task.assignee_id), - ) - else: - await self.db.commit() - - await context_cache_service.invalidate_system_context(tenant_id=tenant_id) - if task.assignee_id: - await context_cache_service.invalidate_user_context(tenant_id=tenant_id, user_id=int(task.assignee_id)) - self._sync_task_rag(task=task, tenant_id=tenant_id) - - if status in DONE_TASK_STATUSES and before_status not in DONE_TASK_STATUSES and task.assignee_id: - asyncio.create_task( - process_task_completion( - task_id=int(task.id), - tenant_id=tenant_id, - operator_user_id=int(operator_user.id), - ) - ) - - return { - "status": "applied", - "message": f"task status updated to {status}", - "entity_type": "task", - "entity_id": str(task.id), - } - - async def _execute_notification_create( - self, - *, - tenant_id: int, - operator_user: User, - project: Optional[Project | SimpleNamespace], - payload: dict[str, Any], - reason: str, - trace_id: str, - idempotency_key: str, - persistent_claim: Optional[OperationExecutionLedger], - ) -> dict[str, Any]: - project = self._require_project_context(project=project, operation_code="notification.create") - user = await self._load_active_user(tenant_id=tenant_id, user_id=int(payload["user_id"])) - if user is None: - raise ValueError("notification target user not found or inactive") - - notification = await NotificationService(self.db).create_notification( - tenant_id=tenant_id, - user_id=int(user.id), - category="system", - title=str(payload["title"]).strip(), - content=str(payload["content"]).strip(), - source="openclaw_commander", - entity_type="project", - entity_id=int(project.id), - target_url=f"/projects/{int(project.id)}/board", - commit=False, - ) - if persistent_claim is not None: - persistent_claim.status = "applied" - persistent_claim.entity_type = "notification" - persistent_claim.entity_id = str(notification.id) - persistent_claim.result_payload = { - "status": "applied", - "notification_id": int(notification.id), - "idempotency_key": idempotency_key, - } - persistent_claim.error_message = None - await log_operation( - self.db, - tenant_id=tenant_id, - user_id=int(operator_user.id), - project_id=int(project.id), - entity_type="notification", - entity_id=str(notification.id), - action="notification.create", - before_data=None, - after_data={ - "user_id": int(user.id), - "title": notification.title, - "source": notification.source, - }, - remark=f"{trace_id}:{reason}"[:1000] if reason else trace_id, - ) - await self.db.commit() - - return { - "status": "applied", - "message": f"notification created for user#{int(user.id)}", - "entity_type": "notification", - "entity_id": str(notification.id), - } - - async def _execute_personnel_workload_update( - self, - *, - tenant_id: int, - operator_user: User, - project: Optional[Project | SimpleNamespace], - payload: dict[str, Any], - reason: str, - trace_id: str, - ) -> dict[str, Any]: - project = self._require_project_context(project=project, operation_code="personnel.workload_update") - user = await self._load_active_user(tenant_id=tenant_id, user_id=int(payload["user_id"])) - if user is None: - raise ValueError("target user not found or inactive") - - member_ids = await self._get_project_member_ids( - tenant_id=tenant_id, - project_id=int(project.id), - owner_id=int(project.owner_id) if project.owner_id else None, - ) - if int(user.id) not in member_ids: - raise ValueError("target user is not project member") - - before_workload = await self._load_user_workload(tenant_id=tenant_id, user_id=int(user.id)) - result = await workload_service.update_user_max_weekly_hours( - self.db, - tenant_id=tenant_id, - user_id=int(user.id), - max_weekly_hours=float(payload["max_weekly_hours"]), - ) - if result is None: - raise ValueError("target user workload not found") - - await log_operation( - self.db, - tenant_id=tenant_id, - user_id=int(operator_user.id), - project_id=int(project.id), - entity_type="user_workload", - entity_id=str(user.id), - action="personnel.workload_update", - before_data=before_workload, - after_data=result, - remark=f"{trace_id}:{reason}"[:1000] if reason else trace_id, - ) - await self.db.commit() - await context_cache_service.invalidate_system_context(tenant_id=tenant_id) - await context_cache_service.invalidate_user_context(tenant_id=tenant_id, user_id=int(user.id)) - - return { - "status": "applied", - "message": f"user#{int(user.id)} max weekly hours updated", - "entity_type": "user_workload", - "entity_id": str(user.id), - } - - async def _load_task(self, *, tenant_id: int, task_id: int) -> Optional[Task]: - return ( - await self.db.execute( - select(Task).where( - and_( - Task.tenant_id == tenant_id, - Task.id == int(task_id), - ) - ) - ) - ).scalar_one_or_none() - - async def _load_project_by_title(self, *, tenant_id: int, title: str) -> Optional[Project]: - normalized = compact_title(str(title or ""), max_len=24) - if not normalized: - return None - return ( - await self.db.execute( - select(Project).where( - and_( - Project.tenant_id == tenant_id, - or_( - func.lower(Project.title) == normalized.lower(), - func.lower(Project.name) == normalized.lower(), - ), - Project.status != "inactive", - ) - ).limit(1) - ) - ).scalar_one_or_none() - - async def _load_project_for_operation( - self, - *, - tenant_id: int, - project: Optional[Project | SimpleNamespace], - payload_project_id: int, - operation_code: str, - ) -> Project: - context_project_id = self._project_context_id(project) - if context_project_id > 0 and context_project_id != int(payload_project_id): - raise ValueError(f"{operation_code} project_id mismatch") - row = await self._load_project(tenant_id=tenant_id, project_id=int(payload_project_id)) - if row is None: - raise ValueError("project not found") - return row - - async def _load_active_user(self, *, tenant_id: int, user_id: int) -> Optional[User]: - return ( - await self.db.execute( - select(User).where( - and_( - User.tenant_id == tenant_id, - User.id == int(user_id), - User.is_active.is_(True), - User.status == "active", - ) - ) - ) - ).scalar_one_or_none() - - async def _load_user_workload(self, *, tenant_id: int, user_id: int) -> Optional[dict[str, Any]]: - row = ( - await self.db.execute( - select(UserWorkload).where( - and_( - UserWorkload.tenant_id == tenant_id, - UserWorkload.user_id == int(user_id), - ) - ) - ) - ).scalar_one_or_none() - if row is None: - return None - return { - "user_id": str(user_id), - "current_assigned_hours": float(row.current_assigned_hours or 0.0), - "max_weekly_hours": float(row.max_available_hours_per_week or 40.0), - "workload_ratio": float(row.workload_ratio or 0.0), - } - - async def _get_project_member_ids(self, *, tenant_id: int, project_id: int, owner_id: Optional[int]) -> set[int]: - cached = self._project_member_cache.get(int(project_id)) - if cached is not None: - return set(cached) - rows = await self.db.execute( - select(UserProject.user_id).where( - and_( - UserProject.tenant_id == tenant_id, - UserProject.project_id == int(project_id), - ) - ) - ) - member_ids = {int(uid) for uid in rows.scalars().all()} - if owner_id: - member_ids.add(int(owner_id)) - self._project_member_cache[int(project_id)] = set(member_ids) - return member_ids - - async def _load_latest_aligned_charter(self, *, tenant_id: int, project_id: int) -> Optional[TruthNode]: - return ( - await self.db.execute( - select(TruthNode).where( - and_( - TruthNode.tenant_id == tenant_id, - TruthNode.project_id == int(project_id), - TruthNode.node_type == "charter", - TruthNode.status.in_(tuple(ALIGNED_STATUSES)), - ) - ).order_by(TruthNode.updated_at.desc(), TruthNode.id.desc()) - ) - ).scalars().first() - - @staticmethod - def _project_context_id(project: Optional[Project | SimpleNamespace]) -> int: - if project is None: - return 0 - try: - return max(0, int(getattr(project, "id", 0) or 0)) - except Exception: - return 0 - - def _require_project_context( - self, - *, - project: Optional[Project | SimpleNamespace], - operation_code: str, - ) -> Project | SimpleNamespace: - if self._project_context_id(project) <= 0: - raise ValueError(f"{operation_code} requires project context") - if project is None: - raise ValueError(f"{operation_code} requires project context") - return project - - def _build_idempotency_key( - self, - *, - operation_code: str, - payload: dict[str, Any], - tenant_id: int, - project_id: int, - ) -> str: - row = operation_catalog_service.get_operation(operation_code) - fields = [] - if isinstance(row, dict): - fields = [str(item) for item in ((row.get("idempotency_key") or {}).get("fields") or [])] - - parts: dict[str, Any] = {} - for field in fields: - if field == "tenant_id": - parts[field] = int(tenant_id) - elif field == "project_id": - parts[field] = int(project_id) - elif field == "content_hash": - parts[field] = hashlib.sha256(str(payload.get("content") or "").strip().encode("utf-8")).hexdigest()[:16] - else: - parts[field] = payload.get(field) - encoded = json.dumps(parts, ensure_ascii=False, sort_keys=True, default=str) - return hashlib.sha256(f"{operation_code}|{encoded}".encode("utf-8")).hexdigest()[:24] - - def _sync_task_rag(self, *, task: Task, tenant_id: int) -> None: - try: - content = ( - (task.description or "") - + f"\n状态: {task.status}" - + f"\n负责人: {int(task.assignee_id) if task.assignee_id else '未分配'}" - ) - asyncio.create_task( - AdaptiveRAGService.upsert_pmflow_record_async( - tenant_id=tenant_id, - record_type="task", - record_id=int(task.id), - project_id=int(task.project_id), - title=str(task.title or "任务"), - content=content, - ) - ) - except Exception: - return - - @staticmethod - def _to_int(value: Any) -> Optional[int]: - if isinstance(value, bool): - return None - try: - return int(str(value).strip()) - except Exception: - return None - - @staticmethod - def _safe_int_list(value: Any) -> list[int]: - if not isinstance(value, list): - return [] - items: list[int] = [] - for raw in value: - try: - items.append(int(raw)) - except Exception: - continue - return items - - @classmethod - def _normalize_task_priority(cls, value: Any) -> str: - text = str(value or "medium").strip().lower() - if text in {"p0", "critical", "high"}: - return "high" - if text in {"p2", "p3", "low"}: - return "low" - return "medium" - - @staticmethod - def _normalize_project_status(value: Any) -> str: - text = str(value or "active").strip().lower() - if text not in SUPPORTED_PROJECT_STATUSES: - raise ValueError("unsupported project status") - return text - - @staticmethod - def _parse_due_date_value(value: Any) -> Optional[datetime]: - text = str(value or "").strip() - if not text: - return None - try: - return datetime.fromisoformat(text.replace("Z", "+00:00")).replace(tzinfo=None) - except ValueError as exc: - raise ValueError("invalid due_date") from exc diff --git a/backend/app/services/project_command_utils.py b/backend/app/services/project_command_utils.py new file mode 100644 index 00000000..77dae6ed --- /dev/null +++ b/backend/app/services/project_command_utils.py @@ -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] diff --git a/backend/app/services/signal_capture.py b/backend/app/services/signal_capture.py index 18b29adc..e77deb59 100644 --- a/backend/app/services/signal_capture.py +++ b/backend/app/services/signal_capture.py @@ -638,7 +638,9 @@ class ProactiveSignalCaptureService: orchestration_status = str(source_orchestration_result.get("status") or "").lower() if orchestration_status in {"completed", "partial", "planned", "pending_manual"}: 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: strategic_payload: Dict[str, Any] = {} diff --git a/backend/app/services/signal_planner_service.py b/backend/app/services/signal_planner_service.py index 77c01c88..fd68eeea 100644 --- a/backend/app/services/signal_planner_service.py +++ b/backend/app/services/signal_planner_service.py @@ -7,7 +7,7 @@ import json from typing import Any, Optional 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 ..core.logging import get_logger @@ -321,6 +321,132 @@ class SignalPlannerService: "fallback_mode": True, } + @classmethod + async def _analyze_with_session( + cls, + *, + db: AsyncSession, + signal_id: int, + tenant_id: int, + source_message_id: Optional[str], + source_chat_id: Optional[str], + dedupe_seed: Optional[str], + ) -> None: + signal: Signal | None = None + try: + signal = await cls._load_signal(db, tenant_id=int(tenant_id), signal_id=int(signal_id)) + if signal is None: + logger.warning( + "signal_planner_signal_missing", + tenant_id=int(tenant_id), + signal_id=int(signal_id), + ) + return + + project_id = cls._safe_int(getattr(signal, "matched_project_id", None)) + project_title = await cls._load_project_title( + db, + tenant_id=int(tenant_id), + project_id=project_id, + ) + recent_signals = await cls._load_recent_signal_summaries( + db, + tenant_id=int(tenant_id), + project_id=project_id, + exclude_signal_id=int(signal.id), + ) + members = await cls._load_member_profiles( + db, + tenant_id=int(tenant_id), + project_id=project_id, + ) + prompt = cls._build_prompt( + signal=signal, + project_title=project_title, + recent_signals=recent_signals, + members=members, + ) + route = await ModelRoutingService.get_effective_routing( + db, + tenant_id=int(tenant_id), + function_name="signal_planner_analyze", + ) + if str(route.get("source") or "") != "override": + logger.warning( + "signal_planner_routing_override_missing", + tenant_id=int(tenant_id), + signal_id=int(signal.id), + fallback_provider=str(route.get("provider") or ""), + fallback_model=str(route.get("model") or ""), + ) + + extraction = None + try: + extraction = await asyncio.wait_for( + llm_service.generate_structured_object( + prompt, + db=db, + tenant_id=int(tenant_id), + function_name="signal_planner_analyze", + temperature=0.0, + max_tokens=1600, + ), + timeout=PLANNER_MAX_WAIT_SECONDS, + ) + except asyncio.TimeoutError: + 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 + if payload is None: + raise RuntimeError("signal planner empty payload") + + await SignalStrategicAnalysisService.merge_and_save( + db, + signal_id=int(signal.id), + planner_result=payload, + tenant_id=int(tenant_id), + ) + await db.commit() + logger.info( + "signal_planner_completed", + tenant_id=int(tenant_id), + signal_id=int(signal.id), + ) + except Exception as exc: + try: + if signal is None: + signal = await cls._load_signal(db, tenant_id=int(tenant_id), signal_id=int(signal_id)) + if signal is not None: + await SignalStrategicAnalysisService.mark_failed( + db, + signal_id=int(signal.id), + tenant_id=int(tenant_id), + reason=str(exc), + ) + chat_id, source_mid = cls._resolve_outbox_targets( + signal=signal, + source_message_id=source_message_id, + source_chat_id=source_chat_id, + ) + if chat_id: + await FeishuOutboxService.enqueue( + db, + tenant_id=int(tenant_id), + chat_id=chat_id, + text="信号已入库,但 AI 分析失败,请检查模型配置或稍后手动重试。", + source_message_id=source_mid, + dedupe_seed=dedupe_seed, + ) + await db.commit() + except Exception: + await db.rollback() + logger.warning( + "signal_planner_failed", + tenant_id=int(tenant_id), + signal_id=int(signal_id), + error=str(exc)[:1000], + ) + @classmethod async def analyze( cls, @@ -330,133 +456,26 @@ class SignalPlannerService: 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: - signal: Signal | None = None - try: - signal = await cls._load_signal(db, tenant_id=int(tenant_id), signal_id=int(signal_id)) - if signal is None: - logger.warning( - "signal_planner_signal_missing", - tenant_id=int(tenant_id), - signal_id=int(signal_id), - ) - return - - project_id = cls._safe_int(getattr(signal, "matched_project_id", None)) - project_title = await cls._load_project_title( - db, - tenant_id=int(tenant_id), - project_id=project_id, - ) - recent_signals = await cls._load_recent_signal_summaries( - db, - tenant_id=int(tenant_id), - project_id=project_id, - exclude_signal_id=int(signal.id), - ) - members = await cls._load_member_profiles( - db, - tenant_id=int(tenant_id), - project_id=project_id, - ) - prompt = cls._build_prompt( - signal=signal, - project_title=project_title, - recent_signals=recent_signals, - members=members, - ) - route = await ModelRoutingService.get_effective_routing( - db, - tenant_id=int(tenant_id), - function_name="signal_planner_analyze", - ) - if str(route.get("source") or "") != "override": - logger.warning( - "signal_planner_routing_override_missing", - tenant_id=int(tenant_id), - signal_id=int(signal.id), - fallback_provider=str(route.get("provider") or ""), - fallback_model=str(route.get("model") or ""), - ) - - extraction = None - fallback_reason = "" - try: - extraction = await asyncio.wait_for( - llm_service.generate_structured_object( - prompt, - db=db, - tenant_id=int(tenant_id), - function_name="signal_planner_analyze", - temperature=0.0, - max_tokens=1600, - ), - timeout=PLANNER_MAX_WAIT_SECONDS, - ) - except asyncio.TimeoutError: - fallback_reason = f"planner_wait_timeout_{int(PLANNER_MAX_WAIT_SECONDS)}s" - - payload = extraction.payload if extraction is not None and isinstance(extraction.payload, dict) else None - if payload is None: - if not fallback_reason: - 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( - db, - signal_id=int(signal.id), - planner_result=payload, - tenant_id=int(tenant_id), - ) - await db.commit() - logger.info( - "signal_planner_completed", - tenant_id=int(tenant_id), - signal_id=int(signal.id), - ) - except Exception as exc: - try: - if signal is None: - signal = await cls._load_signal(db, tenant_id=int(tenant_id), signal_id=int(signal_id)) - if signal is not None: - await SignalStrategicAnalysisService.mark_failed( - db, - signal_id=int(signal.id), - tenant_id=int(tenant_id), - reason=str(exc), - ) - chat_id, source_mid = cls._resolve_outbox_targets( - signal=signal, - source_message_id=source_message_id, - source_chat_id=source_chat_id, - ) - if chat_id: - await FeishuOutboxService.enqueue( - db, - tenant_id=int(tenant_id), - chat_id=chat_id, - text="信号已入库,AI 分析暂时失败,系统将自动重试。", - source_message_id=source_mid, - dedupe_seed=dedupe_seed, - ) - await db.commit() - except Exception: - await db.rollback() - logger.warning( - "signal_planner_failed", - tenant_id=int(tenant_id), - signal_id=int(signal_id), - error=str(exc)[:1000], - ) + 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, + ) diff --git a/backend/app/services/signal_source_orchestration_service.py b/backend/app/services/signal_source_orchestration_service.py index 9aa00931..9feb4385 100644 --- a/backend/app/services/signal_source_orchestration_service.py +++ b/backend/app/services/signal_source_orchestration_service.py @@ -57,7 +57,7 @@ def _utc_now_naive() -> datetime: class SignalSourceOrchestrationService: - """把“新增信号源”转换为 OpenClaw 指挥与下级 AI worker 执行链。""" + """把“新增信号源”转换为 GLM 规划与显式服务执行链。""" session_factory = AsyncSessionLocal @@ -137,7 +137,7 @@ class SignalSourceOrchestrationService: "summary": "当前输入不属于信号源接入请求。", "pending_workers": [], "worker_executions": [], - "openclaw": {"status": "skipped", "reason": "not_signal_source_request"}, + "execution": {"status": "skipped", "reason": "execution_removed"}, "reason": "not_signal_source_request", } if run_service is not None: @@ -150,7 +150,7 @@ class SignalSourceOrchestrationService: response_mode=response_mode, status="completed", planner_status="skipped", - openclaw_status="skipped", + execution_status="skipped", worker_total=0, worker_completed=0, worker_failed=0, @@ -183,7 +183,7 @@ class SignalSourceOrchestrationService: "summary": str(plan.get("summary") or "").strip(), "pending_workers": [], "worker_executions": [], - "openclaw": {"status": "skipped", "reason": "planner_rejected"}, + "execution": {"status": "skipped", "reason": "planner_rejected"}, } if run_service is not None: await run_service.create_run( @@ -195,7 +195,7 @@ class SignalSourceOrchestrationService: response_mode=response_mode, status="completed", planner_status=planner_status, - openclaw_status="skipped", + execution_status="skipped", worker_total=0, worker_completed=0, worker_failed=0, @@ -227,15 +227,15 @@ class SignalSourceOrchestrationService: operator_user_id=operator_user_id, ) 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, operator_user=operator_ref, project=project_ref, - instruction=str(plan.get("openclaw_instruction") or "").strip(), + instruction=str(plan.get("execution_instruction") or "").strip(), dry_run=dry_run, 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( trace_id=trace_id, response_mode=response_mode, @@ -244,7 +244,7 @@ class SignalSourceOrchestrationService: summary=str(plan.get("summary") or "").strip(), registry_result=registry_result, source_payload=source_payload, - openclaw_result=openclaw_result, + execution_result=execution_result, worker_results=[], pending_workers=list(worker_role_keys), status="planned" if dry_run else "partial", @@ -261,7 +261,7 @@ class SignalSourceOrchestrationService: summary=str(plan.get("summary") or "").strip(), registry_result=registry_result, source_payload=source_payload, - openclaw_result=openclaw_result, + execution_result=execution_result, worker_results=planned_results, pending_workers=[], status="planned", @@ -277,7 +277,7 @@ class SignalSourceOrchestrationService: response_mode=response_mode, status="completed", planner_status=planner_status, - openclaw_status=openclaw_status, + execution_status=execution_status, worker_total=len(worker_role_keys), worker_completed=len(planned_results), worker_failed=0, @@ -293,10 +293,10 @@ class SignalSourceOrchestrationService: if not worker_role_keys: registry_status, onboarding_status = self._resolve_registry_activation( source_payload=source_payload, - openclaw_result=openclaw_result, + execution_result=execution_result, 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": final_status = "pending_manual" final_result = self._build_orchestration_result( @@ -311,7 +311,7 @@ class SignalSourceOrchestrationService: "onboarding_status": onboarding_status, }, source_payload=source_payload, - openclaw_result=openclaw_result, + execution_result=execution_result, worker_results=[], pending_workers=[], status=final_status, @@ -327,7 +327,7 @@ class SignalSourceOrchestrationService: response_mode=response_mode, status=final_status, planner_status=planner_status, - openclaw_status=openclaw_status, + execution_status=execution_status, worker_total=0, worker_completed=0, worker_failed=0, @@ -364,7 +364,7 @@ class SignalSourceOrchestrationService: response_mode=response_mode, status="running", planner_status=planner_status, - openclaw_status=openclaw_status, + execution_status=execution_status, worker_total=len(worker_role_keys), worker_completed=0, worker_failed=0, @@ -382,7 +382,7 @@ class SignalSourceOrchestrationService: project_payload=self._serialize_snapshot(project_ref), signal_payload=self._serialize_snapshot(signal_ref), registry_result=dict(registry_result), - openclaw_result=dict(openclaw_result), + openclaw_result=dict(execution_result), worker_role_keys=list(worker_role_keys), operator_user_payload=self._serialize_snapshot(operator_ref), planner_status=planner_status, @@ -558,7 +558,7 @@ class SignalSourceOrchestrationService: "summary": summary or "未识别为新增信号源请求。", "source": None, "worker_roles": [], - "openclaw_instruction": "", + "execution_instruction": "", } normalized_source = self._normalize_source_payload( source_payload=source, @@ -570,9 +570,9 @@ class SignalSourceOrchestrationService: "summary": summary or f"准备接入信号源 {normalized_source['source_name']}。", "source": normalized_source, "worker_roles": worker_roles, - "openclaw_instruction": ( - str(plan.get("openclaw_instruction") or "").strip() - or self._build_openclaw_instruction(normalized_source, initiating_text) + "execution_instruction": ( + str(plan.get("execution_instruction") or "").strip() + or self._build_execution_instruction(normalized_source, initiating_text) )[:600], } @@ -593,7 +593,7 @@ class SignalSourceOrchestrationService: "summary": "系统已按规则兜底为信号源接入请求,并生成最小接入闭环。", "source": source, "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( @@ -800,7 +800,7 @@ class SignalSourceOrchestrationService: return list(DEFAULT_WORKER_ROLE_KEYS) @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 ( f"围绕新增信号源“{source.get('source_name') or '未命名信号源'}”完成接入准备。" "请创建必要任务、分配合适负责人,并通知相关成员。" @@ -907,7 +907,7 @@ class SignalSourceOrchestrationService: "description": str(existing.description or ""), } - async def _dispatch_openclaw( + async def _execute_planned_operations( self, *, tenant_id: int, @@ -917,72 +917,11 @@ class SignalSourceOrchestrationService: dry_run: bool, response_mode: str, ) -> dict[str, Any]: - if not instruction: - return { - "status": "skipped", - "reason": "empty_instruction", - } - 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), - } + _ = tenant_id, operator_user, project, instruction, dry_run, response_mode + return { + "status": "skipped", + "reason": "execution_removed", + } async def _execute_worker_roles( self, @@ -1027,7 +966,7 @@ class SignalSourceOrchestrationService: project_payload=self._serialize_snapshot(project), signal_payload=self._serialize_snapshot(signal), registry_result=dict(registry_result), - openclaw_result=dict(openclaw_result), + execution_result=dict(openclaw_result), role=dict(role), trace_id=trace_id, operator_user_payload=self._serialize_snapshot(operator_user), @@ -1092,7 +1031,7 @@ class SignalSourceOrchestrationService: ) self.db.add(execution_row) await self.db.flush() - # 先提交 running 状态,避免 SQLite 事务跨 LLM 调用长期持有写锁。 + # 先提交 running 状态,避免事务跨 LLM 调用长期持有写锁。 await self.db.commit() prompt = self._build_worker_prompt( @@ -1100,7 +1039,7 @@ class SignalSourceOrchestrationService: project=project, signal=signal, source_payload=source_payload, - openclaw_result=openclaw_result, + execution_result=openclaw_result, ) if dry_run: return { @@ -1183,7 +1122,7 @@ class SignalSourceOrchestrationService: project=SimpleNamespace(**project_payload), signal=SimpleNamespace(**signal_payload), registry_result=registry_result, - openclaw_result=openclaw_result, + execution_result=openclaw_result, role=role, trace_id=trace_id, operator_user=SimpleNamespace(**operator_user_payload) if isinstance(operator_user_payload, dict) else None, @@ -1215,7 +1154,7 @@ class SignalSourceOrchestrationService: project_payload=project_payload, signal_payload=signal_payload, registry_result=registry_result, - openclaw_result=openclaw_result, + execution_result=openclaw_result, worker_role_keys=worker_role_keys, operator_user_payload=operator_user_payload, planner_status=planner_status, @@ -1258,7 +1197,7 @@ class SignalSourceOrchestrationService: "source_name": source_payload.get("source_name"), "source_identifier": source_payload.get("source_identifier"), }, - openclaw_result=openclaw_result, + execution_result=openclaw_result, worker_results=worker_results, pending_workers=pending_workers, status="failed", @@ -1271,7 +1210,7 @@ class SignalSourceOrchestrationService: trace_id=trace_id, status="failed", 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_completed=int(row.worker_completed or 0), worker_failed=int(row.worker_failed or 0), @@ -1328,7 +1267,7 @@ class SignalSourceOrchestrationService: project_payload=project_payload, signal_payload=signal_payload, registry_result=registry_result, - openclaw_result=openclaw_result, + execution_result=openclaw_result, role=dict(role), trace_id=trace_id, operator_user_payload=operator_user_payload, @@ -1436,7 +1375,7 @@ class SignalSourceOrchestrationService: summary: str, registry_result: dict[str, Any], source_payload: dict[str, Any], - openclaw_result: dict[str, Any], + execution_result: dict[str, Any], worker_results: list[dict[str, Any]], pending_workers: list[str], status: str, @@ -1452,7 +1391,7 @@ class SignalSourceOrchestrationService: "summary": summary, "pending_workers": list(pending_workers), "signal_source": self._build_signal_source_snapshot(registry_result, source_payload), - "openclaw": openclaw_result, + "execution": execution_result, "worker_executions": list(worker_results), } @@ -1599,7 +1538,7 @@ class SignalSourceOrchestrationService: "source_name": (source_payload or {}).get("source_name"), "source_identifier": (source_payload or {}).get("source_identifier"), }, - openclaw_result=openclaw_result, + execution_result=openclaw_result, worker_results=worker_results, pending_workers=pending_workers, status=status, @@ -1610,7 +1549,7 @@ class SignalSourceOrchestrationService: trace_id=trace_id, status="running" if pending_workers else 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_completed=completed_count, worker_failed=failed_count, @@ -1667,7 +1606,7 @@ class SignalSourceOrchestrationService: "source_name": (source_payload or {}).get("source_name"), "source_identifier": (source_payload or {}).get("source_identifier"), }, - openclaw_result=openclaw_result, + execution_result=openclaw_result, worker_results=worker_results, pending_workers=[], status=final_status, @@ -1678,7 +1617,7 @@ class SignalSourceOrchestrationService: trace_id=trace_id, status=final_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_completed=completed_count, worker_failed=failed_count, @@ -1761,7 +1700,7 @@ class SignalSourceOrchestrationService: "summary": f"{role_name} 已为 {source_name} 生成兜底执行摘要。", "deliverables": [f"梳理 {source_name} 的接入目标与边界"], "risks": [f"{source_name} 的标识与接入细节仍需补充确认"], - "next_actions": ["等待 OpenClaw 生成任务并推进执行"], + "next_actions": ["等待显式服务完成落库并继续推进执行"], "used_fallback": True, } @@ -1788,7 +1727,7 @@ class SignalSourceOrchestrationService: signal.processed = orchestration_status in {"completed", "partial"} signal.status = self._map_orchestration_status_to_signal_status(orchestration_status) signal.tasks_generated = json.dumps( - self._extract_task_operations(orchestration_result.get("openclaw")), + self._extract_task_operations(orchestration_result.get("execution")), ensure_ascii=False, ) await self.db.commit() diff --git a/backend/app/services/skill_service.py b/backend/app/services/skill_service.py index 26ae3398..981c4dfd 100644 --- a/backend/app/services/skill_service.py +++ b/backend/app/services/skill_service.py @@ -7,16 +7,14 @@ PMFlow 人员能力图谱 - 技能标签与用户技能绑定服务 - UserWorkload 自动更新 """ -import uuid as uuid_module from datetime import datetime, timedelta 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.orm import selectinload from ..models import SkillTag, UserSkillBinding, UserWorkload, User, UserStatus, Task -from ..database import _is_sqlite 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 开发技能标签。 项目启动时自动执行,已存在的标签不会重复创建。 - SQLite 下用 raw INSERT 显式指定 id,避免 NOT NULL constraint failed: skill_tags.id。 + PostgreSQL 统一使用 ORM 批量写入,避免分方言分支。 """ result = await db.execute( select(SkillTag).where( @@ -56,40 +54,6 @@ async def init_system_skill_tags(db: AsyncSession, tenant_id: int = 1) -> int: if not to_create: 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 for item in to_create: tag = SkillTag( @@ -404,40 +368,19 @@ async def get_user_skill_task_history( - assignee_id == user_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, - ) + result = await db.execute( + select(Task) + .where( + and_( + Task.tenant_id == tenant_id, + Task.assignee_id == user_id, + Task.required_skill_tag_ids.contains([skill_tag_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( - select(Task) - .where( - and_( - Task.tenant_id == tenant_id, - Task.assignee_id == user_id, - Task.required_skill_tag_ids.contains([skill_tag_id]), - ) - ) - .order_by(Task.updated_at.desc()) - .limit(limit) - ) - filtered = list(result.scalars().all()) + .order_by(Task.updated_at.desc()) + .limit(limit) + ) + filtered = list(result.scalars().all()) return [ { diff --git a/backend/app/services/task_brief_service.py b/backend/app/services/task_brief_service.py index f1a06d10..24fb966f 100644 --- a/backend/app/services/task_brief_service.py +++ b/backend/app/services/task_brief_service.py @@ -32,12 +32,148 @@ DEFAULT_TEMPLATE_FALLBACK = """# Task Brief ## Scope {{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}} """ 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 def _safe_dict(value: Any) -> dict[str, Any]: if isinstance(value, dict): @@ -54,9 +190,12 @@ class TaskBriefService: return {} return {} - @staticmethod - def _project_root() -> Path: - return Path(__file__).resolve().parents[3] + @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 def _normalize_template_text(content: str) -> str: @@ -136,6 +275,70 @@ class TaskBriefService: "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 def _extract_markdown_section_lines(content: str, *, heading: str) -> list[str]: target = f"## {heading}".lower() @@ -281,7 +484,8 @@ class TaskBriefService: existing_templates = [item for item in bundle.get("templates", []) if isinstance(item, dict)] 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 @@ -304,6 +508,11 @@ class TaskBriefService: 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 + existing_briefs = [item for item in bundle.get("briefs", []) if isinstance(item, dict)] existing_runs = [item for item in bundle.get("runs", []) if isinstance(item, dict)] if not existing_briefs and not existing_runs: diff --git a/backend/init_db.py b/backend/init_db.py index 1a0306a8..ba13404d 100644 --- a/backend/init_db.py +++ b/backend/init_db.py @@ -1,8 +1,5 @@ #!/usr/bin/env python3 -""" -Initialize/fix SQLite database by creating all missing tables. -Run this when the database schema is out of sync. -""" +"""Initialize database metadata for PostgreSQL environments.""" import asyncio import sys from pathlib import Path @@ -31,7 +28,18 @@ async def init_database(): # Verify tables were created 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] print(f"📊 Tables in database: {', '.join(sorted(tables))}") diff --git a/backend/requirements.txt b/backend/requirements.txt index 0c0701e8..fdbac24e 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -3,7 +3,6 @@ uvicorn[standard]>=0.30.0 sqlalchemy>=2.0.30 alembic>=1.13.2 asyncpg>=0.29.0 -aiosqlite>=0.20.0 psycopg[binary]>=3.2.0 pgvector>=0.3.5 python-dotenv>=1.0.1 diff --git a/backend/scripts/migrate_sqlite_to_postgres.py b/backend/scripts/migrate_sqlite_to_postgres.py deleted file mode 100644 index 95a9c112..00000000 --- a/backend/scripts/migrate_sqlite_to_postgres.py +++ /dev/null @@ -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()) diff --git a/backend/tests/test_adaptive_rag_intent_corrections.py b/backend/tests/test_adaptive_rag_intent_corrections.py deleted file mode 100644 index 2401989d..00000000 --- a/backend/tests/test_adaptive_rag_intent_corrections.py +++ /dev/null @@ -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() diff --git a/backend/tests/test_ai_agent_kernel.py b/backend/tests/test_ai_agent_kernel.py deleted file mode 100644 index c07080ee..00000000 --- a/backend/tests/test_ai_agent_kernel.py +++ /dev/null @@ -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() diff --git a/backend/tests/test_ai_logs_api.py b/backend/tests/test_ai_logs_api.py deleted file mode 100644 index 1ae3d2d0..00000000 --- a/backend/tests/test_ai_logs_api.py +++ /dev/null @@ -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") diff --git a/backend/tests/test_ai_orchestration_run_service.py b/backend/tests/test_ai_orchestration_run_service.py deleted file mode 100644 index 11b41c7a..00000000 --- a/backend/tests/test_ai_orchestration_run_service.py +++ /dev/null @@ -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() diff --git a/backend/tests/test_ai_pmo_full_scenario.py b/backend/tests/test_ai_pmo_full_scenario.py deleted file mode 100644 index ae513df2..00000000 --- a/backend/tests/test_ai_pmo_full_scenario.py +++ /dev/null @@ -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"]) - ) diff --git a/backend/tests/test_ai_pmo_kernel_service.py b/backend/tests/test_ai_pmo_kernel_service.py deleted file mode 100644 index ab3ab4a0..00000000 --- a/backend/tests/test_ai_pmo_kernel_service.py +++ /dev/null @@ -1,1514 +0,0 @@ -import json -import os -import unittest -from datetime import datetime -from tempfile import NamedTemporaryFile -from types import SimpleNamespace -from unittest.mock import AsyncMock, patch - -from sqlalchemy import and_, func, select -from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine - -from app.models import ( - Base, - Budget, - ConversationSession, - PortfolioSnapshot, - Project, - ProjectDomain, - Signal, - SignalSourceRegistry, - Task, - TruthNode, - User, - UserProject, - UserSkillBinding, - UserWorkload, - WorkItem, -) -from app.services.ai_pmo_cadence_service import AIPMOCadenceService -from app.services.ai_pmo_kernel_service import ( - AIPMOKernelService, - INTENT_LLM_FUNCTION_NAME, - INTENT_PROJECT_CREATE, - INTENT_QUERY, - INTENT_SIGNAL_CREATE, - INTENT_SIGNAL_SOURCE_ONBOARD, - IntentResolver, - _intent_llm_timeout_seconds, -) -from app.services.ai_pmo_domain_workflow import SignalSourceDomainWorkflow -from app.services.llm import StructuredExtractionResult -from app.services.signal_source_orchestration_service import SignalSourceOrchestrationService - - -class AIPMOKernelServiceTest(unittest.IsolatedAsyncioTestCase): - async def asyncSetUp(self) -> None: - self._old_intent_llm_enabled = os.environ.get("AI_PMO_INTENT_LLM_ENABLED") - os.environ["AI_PMO_INTENT_LLM_ENABLED"] = "0" - tmp = NamedTemporaryFile(prefix="pmflow-ai-pmo-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() - - 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(user.id), - ) - db.add(domain) - await db.flush() - - project = Project( - tenant_id=1, - name="医药行业十五五规划布局研究", - title="医药行业十五五规划布局研究", - description="研究医药行业方向和机会。", - owner_id=int(user.id), - status="active", - domain_id=int(domain.id), - assigned_role="pm", - 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(user.id), - title="项目宪章", - content={"success_criteria": ["两周内形成研究报告"]}, - version=1, - status="active", - ) - db.add(charter) - await db.flush() - db.add( - Budget( - tenant_id=1, - budget_type="project", - project_id=int(project.id), - period="monthly", - budget_amount_usd=1000.0, - alert_threshold_percent=80.0, - hard_limit_enabled=True, - current_usage_usd=500.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(user.id), project_id=int(project.id))) - db.add( - UserWorkload( - tenant_id=1, - user_id=int(user.id), - current_assigned_hours=20.0, - max_available_hours_per_week=40.0, - workload_ratio=0.5, - ) - ) - db.add( - UserSkillBinding( - tenant_id=1, - user_id=int(user.id), - skill_tag_id=1, - proficiency_level="expert", - proficiency=5, - level="expert", - years_experience=8.0, - quality_score=9.0, - efficiency_score=8.5, - ) - ) - db.add( - Task( - tenant_id=1, - truth_node_id=int(charter.id), - project_id=int(project.id), - assignee_id=int(user.id), - ai_recommended_assignees=None, - title="梳理外部依赖", - description="当前依赖较多,任务处于 blocked。", - required_skill_tag_ids=[2], - status="blocked", - priority="high", - estimated_hours=8.0, - actual_hours=0.0, - quality_score=None, - due_date=datetime(2026, 3, 1), - completed_at=None, - ) - ) - await db.commit() - - async def asyncTearDown(self) -> None: - await self.engine.dispose() - if self._old_intent_llm_enabled is None: - os.environ.pop("AI_PMO_INTENT_LLM_ENABLED", None) - else: - os.environ["AI_PMO_INTENT_LLM_ENABLED"] = self._old_intent_llm_enabled - if getattr(self, "db_path", None): - try: - os.remove(self.db_path) - except FileNotFoundError: - pass - - def test_intent_resolver_detects_signal_source_request(self) -> None: - intent = IntentResolver.resolve( - text="给项目ID: 1 新增一个 GitHub 信号源,监控 openai/codex 仓库。", - project_id=1, - ) - self.assertEqual(intent.name, INTENT_SIGNAL_SOURCE_ONBOARD) - - def test_intent_resolver_detects_project_create_for_add_project_phrase(self) -> None: - intent = IntentResolver.resolve( - text="给我新增个项目,openclaw自动化部署。", - project_id=None, - ) - self.assertEqual(intent.name, INTENT_PROJECT_CREATE) - - def test_intent_resolver_filters_smalltalk_phrase(self) -> None: - intent = IntentResolver.resolve( - text="怎么样了?", - project_id=None, - ) - self.assertEqual(intent.name, "ignore") - - def test_intent_resolver_prefers_signal_create_for_operation_with_query_words(self) -> None: - intent = IntentResolver.resolve( - text="给AI劝退助手加一个需求,劝退完后第二天问情况如何跟进?", - project_id=1, - ) - self.assertEqual(intent.name, INTENT_SIGNAL_CREATE) - - def test_intent_resolver_negated_write_statement_prefers_query(self) -> None: - intent = IntentResolver.resolve( - text="别乱创建信号,说明你是谁", - project_id=1, - ) - self.assertEqual(intent.name, INTENT_QUERY) - - async def test_build_world_model_includes_budget_capacity_and_skill_graph(self) -> None: - async with self.session_factory() as db: - service = AIPMOKernelService(db, tenant_id=1) - world_model = await service._build_world_model( - message_text="给项目ID: 1 新增一个医药信号。", - sender="老板", - project_hint_id=1, - ) - - self.assertEqual(world_model["budget"]["budget_id"], 1) - self.assertEqual(len(world_model["capacity"]), 1) - self.assertEqual(len(world_model["skill_graph"]), 1) - self.assertEqual(world_model["resolved_project_id"], 1) - self.assertEqual(world_model["project_domain"]["slug"], "pharma") - self.assertEqual(world_model["dependencies"]["pressure"], "high") - self.assertEqual(world_model["task_summary"]["uncovered_skill_tag_ids"], [2]) - - async def test_process_query_message_persists_work_item_and_reply_receipt(self) -> None: - async with self.session_factory() as db: - service = AIPMOKernelService(db, tenant_id=1) - with patch( - "app.services.ai_pmo_kernel_service.AdaptiveRAGService.answer_question", - AsyncMock(return_value={"answer": "当前项目状态正常。", "sources": []}), - ): - result = await service.process_ingress( - source_system="pmflow_manual", - source_message_id="msg-query-1", - message_text="项目ID: 1 现在状态如何?", - sender="老板", - channel_ref="pmflow", - project_hint_id=1, - ) - - work_item = ( - await db.execute( - select(WorkItem).where( - and_( - WorkItem.tenant_id == 1, - WorkItem.source_message_id == "msg-query-1", - ) - ) - ) - ).scalar_one() - review_cycle = ( - await db.execute( - select(PortfolioSnapshot).where( - and_( - PortfolioSnapshot.tenant_id == 1, - PortfolioSnapshot.scope_type == "review_cycle", - PortfolioSnapshot.scope_ref == f"work_item:{int(work_item.id)}", - ) - ) - ) - ).scalar_one() - - self.assertTrue(result["handled"]) - self.assertEqual(result["status"], "answered") - self.assertTrue(result["direct_reply_required"]) - self.assertEqual(result["direct_reply_text"], "当前项目状态正常。") - self.assertEqual(result["decision_level"], "project") - self.assertIn("agent_quality_score", work_item.review_summary) - self.assertTrue(bool(work_item.review_summary.get("verified"))) - self.assertEqual(review_cycle.summary["work_item_id"], int(work_item.id)) - - async def test_process_ingress_negated_write_statement_forces_query(self) -> None: - async with self.session_factory() as db: - service = AIPMOKernelService(db, tenant_id=1) - with patch( - "app.services.ai_pmo_kernel_service.AdaptiveRAGService.answer_question", - AsyncMock(return_value={"answer": "我是 PMFlow 项目协同助手。", "sources": []}), - ): - result = await service.process_ingress( - source_system="pmflow_manual", - source_message_id="msg-negated-write-1", - message_text="别乱创建信号,说明你是谁", - sender="老板", - channel_ref="pmflow", - ) - - work_item = ( - await db.execute( - select(WorkItem).where( - and_( - WorkItem.tenant_id == 1, - WorkItem.source_message_id == "msg-negated-write-1", - ) - ) - ) - ).scalar_one() - signal_count = ( - await db.execute( - select(func.count()).select_from(Signal).where(Signal.tenant_id == 1) - ) - ).scalar_one() - - self.assertEqual(result["status"], "answered") - self.assertEqual(work_item.intent, INTENT_QUERY) - self.assertEqual(int(signal_count or 0), 0) - - async def test_process_query_message_returns_failed_safe_when_rag_raises(self) -> None: - async with self.session_factory() as db: - service = AIPMOKernelService(db, tenant_id=1) - with patch( - "app.services.ai_pmo_kernel_service.AdaptiveRAGService.answer_question", - AsyncMock(side_effect=RuntimeError("模型调用失败:模型调用失败:function=adaptive_rag_answer_normal")), - ): - result = await service.process_ingress( - source_system="pmflow_manual", - source_message_id="msg-query-failed-safe-1", - message_text="项目ID: 1 现在状态如何?", - sender="老板", - channel_ref="pmflow", - project_hint_id=1, - ) - - self.assertTrue(result["handled"]) - self.assertEqual(result["status"], "captured") - self.assertEqual(result["classification"], "question") - reply_text = str(result.get("direct_reply_text") or "") - self.assertIn("模型调用失败:function=adaptive_rag_answer_normal", reply_text) - self.assertNotIn("模型调用失败:模型调用失败:", reply_text) - - async def test_resolve_intent_prefers_rule_for_query_without_llm_call(self) -> None: - prev = os.environ.get("AI_PMO_INTENT_LLM_ENABLED") - os.environ["AI_PMO_INTENT_LLM_ENABLED"] = "1" - try: - async with self.session_factory() as db: - service = AIPMOKernelService(db, tenant_id=1) - with patch.object(service, "_resolve_intent_with_llm", AsyncMock(return_value=None)) as llm_mock: - intent, source = await service._resolve_intent( - message_text="你是谁?", - session=None, - world_model={"resolved_project_id": 1}, - ) - - self.assertEqual(intent.name, INTENT_QUERY) - self.assertEqual(source, "rule_preferred") - llm_mock.assert_not_awaited() - finally: - if prev is None: - os.environ.pop("AI_PMO_INTENT_LLM_ENABLED", None) - else: - os.environ["AI_PMO_INTENT_LLM_ENABLED"] = prev - - async def test_duplicate_message_is_filtered(self) -> None: - async with self.session_factory() as db: - service = AIPMOKernelService(db, tenant_id=1) - with patch( - "app.services.ai_pmo_kernel_service.AdaptiveRAGService.answer_question", - AsyncMock(return_value={"answer": "OK", "sources": []}), - ): - first = await service.process_ingress( - source_system="pmflow_manual", - source_message_id="dup-1", - message_text="项目ID: 1 现在状态如何?", - sender="老板", - channel_ref="pmflow", - project_hint_id=1, - ) - second = await service.process_ingress( - source_system="pmflow_manual", - source_message_id="dup-1", - message_text="项目ID: 1 现在状态如何?", - sender="老板", - channel_ref="pmflow", - project_hint_id=1, - ) - - self.assertEqual(first["status"], "answered") - self.assertEqual(second["status"], "ignored") - self.assertFalse(second["direct_reply_required"]) - - async def test_short_confirmation_inherits_last_intent_and_project(self) -> None: - async with self.session_factory() as db: - service = AIPMOKernelService(db, tenant_id=1) - await service.process_ingress( - source_system="feishu", - source_message_id="ctx-source-1", - message_text="给项目ID: 1 新增一个 GitHub 信号源,监控 openai/codex。", - sender="老板", - channel_ref="oc_ctx", - source_sender_open_id="ou_boss", - project_hint_id=1, - ) - second = await service.process_ingress( - source_system="feishu", - source_message_id="ctx-source-2", - message_text="是的新增吧", - sender="老板", - channel_ref="oc_ctx", - source_sender_open_id="ou_boss", - ) - second_item = ( - await db.execute( - select(WorkItem).where( - and_( - WorkItem.tenant_id == 1, - WorkItem.source_message_id == "ctx-source-2", - ) - ) - ) - ).scalar_one() - session_row = ( - await db.execute( - select(ConversationSession).where( - and_( - ConversationSession.tenant_id == 1, - ConversationSession.sender == "老板", - ) - ) - ) - ).scalar_one() - - self.assertEqual(second["status"], "captured") - self.assertEqual(second_item.intent, INTENT_SIGNAL_SOURCE_ONBOARD) - self.assertEqual(int(second_item.project_id or 0), 1) - self.assertEqual((second_item.context_snapshot or {}).get("context_source"), "session_inherited") - self.assertEqual(session_row.last_intent, INTENT_SIGNAL_SOURCE_ONBOARD) - self.assertEqual(int(session_row.current_project_id or 0), 1) - - async def test_signal_source_execute_persists_closure_summary_and_modules(self) -> None: - async with self.session_factory() as db: - service = AIPMOKernelService(db, tenant_id=1) - intent = service._build_canonical_intent( - intent_name=INTENT_SIGNAL_SOURCE_ONBOARD, - message_text="按照这个分步骤加入到信号源中", - project_id=1, - confidence=0.93, - metadata={ - "closure_summary": "同一消息闭环内,用户要求将需求按步骤接入信号源,重点模块:用户管理与权限、SSTC 工作流。", - "closure_dialogue": "机器人:当前无法直接创建,请先确认项目。\n用户:按照这个分步骤加入到信号源中", - "closure_modules": ["用户管理与权限", "SSTC 工作流"], - }, - ) - with patch.object( - SignalSourceDomainWorkflow, - "run", - AsyncMock(return_value={"status": "completed", "raw_result": {}}), - ): - result = await service.executor.execute( - intent=intent, - message_text="按照这个分步骤加入到信号源中", - sender="老板", - operator_user_id=1, - source_system="feishu", - source_message_id="semantic-signal-source-1", - ) - - signal_row = ( - await db.execute( - select(Signal).where( - and_( - Signal.tenant_id == 1, - Signal.id == int(result["signal_id"]), - ) - ) - ) - ).scalar_one() - - strategic = json.loads(str(signal_row.strategic_analysis or "{}") or "{}") - source_context = strategic.get("source_context") if isinstance(strategic, dict) else {} - self.assertEqual(signal_row.title, "信号源接入 · 用户管理与权限") - self.assertIn("重点模块:用户管理与权限", str(signal_row.summary or "")) - self.assertIn("机器人:当前无法直接创建", str(signal_row.content or "")) - self.assertEqual(source_context.get("implementation_modules"), ["用户管理与权限", "SSTC 工作流"]) - - async def test_project_id_inherits_from_recent_session_when_message_has_no_project_text(self) -> None: - async with self.session_factory() as db: - service = AIPMOKernelService(db, tenant_id=1) - await service.process_ingress( - source_system="pmflow_manual", - source_message_id="project-inherit-1", - message_text="给项目ID: 1 新增一条信号:跟踪江苏医保支付改革。", - sender="老板", - channel_ref="pmflow", - project_hint_id=1, - ) - second = await service.process_ingress( - source_system="pmflow_manual", - source_message_id="project-inherit-2", - message_text="新增一条信号:跟踪医药招采节奏变化。", - sender="老板", - channel_ref="pmflow", - ) - second_item = ( - await db.execute( - select(WorkItem).where( - and_( - WorkItem.tenant_id == 1, - WorkItem.source_message_id == "project-inherit-2", - ) - ) - ) - ).scalar_one() - - self.assertTrue(second["handled"]) - self.assertEqual(second_item.intent, "signal.create") - self.assertEqual(int(second_item.project_id or 0), 1) - self.assertEqual(second["linked_project_ids"], [1]) - - async def test_intent_llm_primary_path_marks_context_source(self) -> None: - prev = os.environ.get("AI_PMO_INTENT_LLM_ENABLED") - os.environ["AI_PMO_INTENT_LLM_ENABLED"] = "1" - try: - async with self.session_factory() as db: - service = AIPMOKernelService(db, tenant_id=1) - with patch( - "app.services.ai_pmo_kernel_service.llm_service.generate_structured_object", - AsyncMock( - return_value=StructuredExtractionResult( - payload={ - "intent": "signal_source.onboard", - "confidence": 0.95, - "project_id": 1, - "reasoning": "用户要新增信号源", - }, - source_field="content", - provider="openai", - model="GLM-4.7", - raw_text='{"intent":"signal_source.onboard"}', - validation="ok", - ) - ), - ): - result = await service.process_ingress( - source_system="pmflow_manual", - source_message_id="llm-intent-1", - message_text="这个项目每天看竞品动态,帮我接入一下。", - sender="老板", - channel_ref="pmflow", - project_hint_id=1, - ) - item = ( - await db.execute( - select(WorkItem).where( - and_( - WorkItem.tenant_id == 1, - WorkItem.source_message_id == "llm-intent-1", - ) - ) - ) - ).scalar_one() - - self.assertTrue(result["handled"]) - self.assertEqual(item.intent, INTENT_SIGNAL_SOURCE_ONBOARD) - self.assertEqual((item.context_snapshot or {}).get("context_source"), "llm") - finally: - if prev is None: - os.environ.pop("AI_PMO_INTENT_LLM_ENABLED", None) - else: - os.environ["AI_PMO_INTENT_LLM_ENABLED"] = prev - - def test_intent_llm_timeout_default_is_no_longer_eight_seconds(self) -> None: - self.assertGreaterEqual(_intent_llm_timeout_seconds(function_name="classify_intent_complex"), 30.0) - self.assertGreaterEqual(_intent_llm_timeout_seconds(function_name=INTENT_LLM_FUNCTION_NAME), 30.0) - - async def test_correction_message_forces_low_tier_glm47_intent_function(self) -> None: - prev = os.environ.get("AI_PMO_INTENT_LLM_ENABLED") - os.environ["AI_PMO_INTENT_LLM_ENABLED"] = "1" - captured_function_name: dict[str, str] = {} - try: - async with self.session_factory() as db: - service = AIPMOKernelService(db, tenant_id=1) - - async def _mock_generate(_: str, **kwargs): - captured_function_name["value"] = str(kwargs.get("function_name") or "") - return StructuredExtractionResult( - payload={ - "intent": INTENT_PROJECT_CREATE, - "confidence": 0.93, - "project_id": None, - "reasoning": "否定纠错后回退项目创建", - }, - source_field="content", - provider="glm", - model="GLM-4.7", - validation="ok", - ) - - with patch( - "app.services.ai_pmo_kernel_service.select_intent_route", - return_value=SimpleNamespace( - function_name="classify_intent_complex", - tier="high", - reasons=["complex_multi_clause"], - is_complex=True, - ), - ), patch( - "app.services.ai_pmo_kernel_service.kernel_resolve_correction", - return_value=SimpleNamespace(action="rewrite", matched_keywords=["不是"], confidence=0.92), - ), patch( - "app.services.ai_pmo_kernel_service.llm_service.generate_structured_object", - AsyncMock(side_effect=_mock_generate), - ): - llm_intent = await service._resolve_intent_with_llm( - message_text="不是创建信号,是创建项目名为openclaw自动化部署。", - session=None, - world_model={"resolved_project_id": None, "project": {}}, - ) - - self.assertIsNotNone(llm_intent) - self.assertEqual(captured_function_name.get("value"), INTENT_LLM_FUNCTION_NAME) - self.assertTrue(bool((llm_intent.metadata or {}).get("forced_low_tier"))) - finally: - if prev is None: - os.environ.pop("AI_PMO_INTENT_LLM_ENABLED", None) - else: - os.environ["AI_PMO_INTENT_LLM_ENABLED"] = prev - - async def test_two_stage_intent_escalates_to_high_on_conflict(self) -> None: - prev_enabled = os.environ.get("AI_PMO_INTENT_LLM_ENABLED") - prev_budget = os.environ.get("AI_PMO_INTENT_LLM_TOTAL_BUDGET_SECONDS") - os.environ["AI_PMO_INTENT_LLM_ENABLED"] = "1" - os.environ["AI_PMO_INTENT_LLM_TOTAL_BUDGET_SECONDS"] = "80" - called_functions: list[str] = [] - try: - async with self.session_factory() as db: - service = AIPMOKernelService(db, tenant_id=1) - - async def _mock_generate(_: str, **kwargs): - fn = str(kwargs.get("function_name") or "") - called_functions.append(fn) - if fn == INTENT_LLM_FUNCTION_NAME: - return StructuredExtractionResult( - payload={ - "intent": INTENT_SIGNAL_CREATE, - "confidence": 0.58, - "project_id": None, - "reasoning": "低阶先判成信号创建", - }, - source_field="content", - provider="glm", - model="GLM-4.7", - validation="ok", - ) - return StructuredExtractionResult( - payload={ - "intent": INTENT_PROJECT_CREATE, - "confidence": 0.93, - "project_id": None, - "reasoning": "高阶复判为项目创建", - }, - source_field="content", - provider="openai", - model="GLM-5", - validation="ok", - ) - - with patch( - "app.services.ai_pmo_kernel_service.select_intent_route", - return_value=SimpleNamespace( - function_name="classify_intent_complex", - tier="high", - reasons=["complex_multi_clause"], - is_complex=True, - ), - ), patch( - "app.services.ai_pmo_kernel_service.kernel_resolve_correction", - return_value=SimpleNamespace(action="none", matched_keywords=[], confidence=0.0), - ), patch( - "app.services.ai_pmo_kernel_service.llm_service.generate_structured_object", - AsyncMock(side_effect=_mock_generate), - ): - llm_intent = await service._resolve_intent_with_llm( - message_text="给我新增个项目,需求名字为openclaw自动化部署。", - session=None, - world_model={"resolved_project_id": None, "project": {}}, - rule_intent=service._build_canonical_intent( - intent_name=INTENT_PROJECT_CREATE, - message_text="给我新增个项目,需求名字为openclaw自动化部署。", - project_id=None, - confidence=0.88, - metadata={"context_source": "rule"}, - ), - ) - - self.assertIsNotNone(llm_intent) - self.assertEqual(called_functions, [INTENT_LLM_FUNCTION_NAME, "classify_intent_complex"]) - self.assertEqual(llm_intent.name, INTENT_PROJECT_CREATE) - self.assertEqual((llm_intent.metadata or {}).get("llm_function"), "classify_intent_complex") - self.assertTrue(bool((llm_intent.metadata or {}).get("stage2_promoted"))) - finally: - if prev_enabled is None: - os.environ.pop("AI_PMO_INTENT_LLM_ENABLED", None) - else: - os.environ["AI_PMO_INTENT_LLM_ENABLED"] = prev_enabled - if prev_budget is None: - os.environ.pop("AI_PMO_INTENT_LLM_TOTAL_BUDGET_SECONDS", None) - else: - os.environ["AI_PMO_INTENT_LLM_TOTAL_BUDGET_SECONDS"] = prev_budget - - async def test_two_stage_intent_keeps_low_when_confident_and_no_conflict(self) -> None: - prev_enabled = os.environ.get("AI_PMO_INTENT_LLM_ENABLED") - os.environ["AI_PMO_INTENT_LLM_ENABLED"] = "1" - called_functions: list[str] = [] - try: - async with self.session_factory() as db: - service = AIPMOKernelService(db, tenant_id=1) - - async def _mock_generate(_: str, **kwargs): - fn = str(kwargs.get("function_name") or "") - called_functions.append(fn) - return StructuredExtractionResult( - payload={ - "intent": INTENT_QUERY, - "confidence": 0.95, - "project_id": None, - "reasoning": "低阶高置信查询", - }, - source_field="content", - provider="glm", - model="GLM-4.7", - validation="ok", - ) - - with patch( - "app.services.ai_pmo_kernel_service.select_intent_route", - return_value=SimpleNamespace( - function_name=INTENT_LLM_FUNCTION_NAME, - tier="low", - reasons=["default_simple"], - is_complex=False, - ), - ), patch( - "app.services.ai_pmo_kernel_service.kernel_resolve_correction", - return_value=SimpleNamespace(action="none", matched_keywords=[], confidence=0.0), - ), patch( - "app.services.ai_pmo_kernel_service.llm_service.generate_structured_object", - AsyncMock(side_effect=_mock_generate), - ): - llm_intent = await service._resolve_intent_with_llm( - message_text="项目进展如何?", - session=None, - world_model={"resolved_project_id": None, "project": {}}, - rule_intent=service._build_canonical_intent( - intent_name=INTENT_QUERY, - message_text="项目进展如何?", - project_id=None, - confidence=0.90, - metadata={"context_source": "rule"}, - ), - ) - - self.assertIsNotNone(llm_intent) - self.assertEqual(called_functions, [INTENT_LLM_FUNCTION_NAME]) - self.assertEqual(llm_intent.name, INTENT_QUERY) - self.assertEqual((llm_intent.metadata or {}).get("stage"), "stage1_low") - finally: - if prev_enabled is None: - os.environ.pop("AI_PMO_INTENT_LLM_ENABLED", None) - else: - os.environ["AI_PMO_INTENT_LLM_ENABLED"] = prev_enabled - - async def test_intent_llm_failure_falls_back_to_rule_resolver(self) -> None: - prev = os.environ.get("AI_PMO_INTENT_LLM_ENABLED") - os.environ["AI_PMO_INTENT_LLM_ENABLED"] = "1" - try: - async with self.session_factory() as db: - service = AIPMOKernelService(db, tenant_id=1) - with patch( - "app.services.ai_pmo_kernel_service.llm_service.generate_structured_object", - AsyncMock( - return_value=StructuredExtractionResult( - payload=None, - source_field="", - provider="openai", - model="GLM-4.7", - error_code="model_not_allowed", - raw_text="403 ModelNotAllowed", - validation="invalid", - ) - ), - ): - await service.process_ingress( - source_system="pmflow_manual", - source_message_id="llm-intent-fallback-1", - message_text="给项目ID: 1 新增一个 GitHub 信号源,监控 openai/codex。", - sender="老板", - channel_ref="pmflow", - project_hint_id=1, - ) - item = ( - await db.execute( - select(WorkItem).where( - and_( - WorkItem.tenant_id == 1, - WorkItem.source_message_id == "llm-intent-fallback-1", - ) - ) - ) - ).scalar_one() - - self.assertEqual(item.intent, INTENT_SIGNAL_SOURCE_ONBOARD) - self.assertEqual((item.context_snapshot or {}).get("context_source"), "rule_fallback") - finally: - if prev is None: - os.environ.pop("AI_PMO_INTENT_LLM_ENABLED", None) - else: - os.environ["AI_PMO_INTENT_LLM_ENABLED"] = prev - - async def test_intent_arbitration_conflict_write_requires_manual_review(self) -> None: - old_llm = os.environ.get("AI_PMO_INTENT_LLM_ENABLED") - old_arb = os.environ.get("AI_PMO_INTENT_ARBITRATION_ENABLED") - old_policy = os.environ.get("AI_PMO_INTENT_CONFLICT_POLICY") - os.environ["AI_PMO_INTENT_LLM_ENABLED"] = "1" - os.environ["AI_PMO_INTENT_ARBITRATION_ENABLED"] = "1" - os.environ["AI_PMO_INTENT_CONFLICT_POLICY"] = "pending_manual" - try: - async with self.session_factory() as db: - service = AIPMOKernelService(db, tenant_id=1) - with patch( - "app.services.ai_pmo_kernel_service.llm_service.generate_structured_object", - AsyncMock( - return_value=StructuredExtractionResult( - payload={ - "intent": INTENT_SIGNAL_CREATE, - "confidence": 0.95, - "project_id": None, - "reasoning": "用户像是在提新增记录", - }, - source_field="content", - provider="openai", - model="MiniMax-M2.5", - raw_text='{"intent":"signal.create"}', - validation="ok", - ) - ), - ): - intent, source = await service._resolve_intent( - message_text="给我新增个项目,openclaw自动化部署。", - session=None, - world_model={"resolved_project_id": None, "project": {}}, - ) - - self.assertIn(source, {"llm", "rule_fallback"}) - self.assertTrue(bool((intent.metadata or {}).get("requires_manual_review"))) - self.assertEqual((intent.metadata or {}).get("manual_review_code"), "intent_conflict_write") - self.assertEqual((intent.metadata or {}).get("arbitration"), "conflict_manual_review") - finally: - if old_llm is None: - os.environ.pop("AI_PMO_INTENT_LLM_ENABLED", None) - else: - os.environ["AI_PMO_INTENT_LLM_ENABLED"] = old_llm - if old_arb is None: - os.environ.pop("AI_PMO_INTENT_ARBITRATION_ENABLED", None) - else: - os.environ["AI_PMO_INTENT_ARBITRATION_ENABLED"] = old_arb - if old_policy is None: - os.environ.pop("AI_PMO_INTENT_CONFLICT_POLICY", None) - else: - os.environ["AI_PMO_INTENT_CONFLICT_POLICY"] = old_policy - - async def test_project_create_slot_guard_requires_title(self) -> None: - old_llm = os.environ.get("AI_PMO_INTENT_LLM_ENABLED") - os.environ["AI_PMO_INTENT_LLM_ENABLED"] = "0" - try: - async with self.session_factory() as db: - service = AIPMOKernelService(db, tenant_id=1) - intent, source = await service._resolve_intent( - message_text="请创建项目。", - session=None, - world_model={"resolved_project_id": None, "project": {}}, - ) - - self.assertEqual(source, "rule_fallback") - self.assertEqual(intent.name, INTENT_PROJECT_CREATE) - self.assertFalse(bool((intent.metadata or {}).get("slot_ok"))) - self.assertTrue(bool((intent.metadata or {}).get("requires_manual_review"))) - self.assertEqual((intent.metadata or {}).get("manual_review_code"), "slot_incomplete") - finally: - if old_llm is None: - os.environ.pop("AI_PMO_INTENT_LLM_ENABLED", None) - else: - os.environ["AI_PMO_INTENT_LLM_ENABLED"] = old_llm - - async def test_policy_gate_blocks_archive_like_request(self) -> None: - async with self.session_factory() as db: - service = AIPMOKernelService(db, tenant_id=1) - result = await service.process_ingress( - source_system="pmflow_manual", - source_message_id="gate-1", - message_text="请把项目ID: 1 直接归档。", - sender="老板", - channel_ref="pmflow", - project_hint_id=1, - ) - - self.assertTrue(result["handled"]) - self.assertEqual(result["status"], "captured") - self.assertIn("人工确认", result["direct_reply_text"]) - - async def test_process_project_create_uses_new_kernel(self) -> None: - async with self.session_factory() as db: - service = AIPMOKernelService(db, tenant_id=1) - with patch( - "app.services.ai_pmo_kernel_service.AIStewardService.create_project_from_natural_language", - AsyncMock( - return_value={ - "project": {"id": 9, "title": "医药新项目", "name": "医药新项目"}, - "signal": {"id": 11}, - "tasks": [{"id": 21, "title": "任务A"}], - } - ), - ): - result = await service.process_ingress( - source_system="pmflow_manual", - source_message_id="project-1", - message_text="请创建项目:医药新项目,做医药趋势研究。", - sender="老板", - channel_ref="pmflow", - ) - work_item = ( - await db.execute( - select(WorkItem).where( - and_( - WorkItem.tenant_id == 1, - WorkItem.source_message_id == "project-1", - ) - ) - ) - ).scalar_one() - - self.assertEqual(result["status"], "captured") - self.assertEqual(result["created_project_id"], 9) - self.assertEqual(result["linked_project_ids"], [9]) - self.assertTrue(result["direct_reply_required"]) - self.assertEqual(work_item.project_id, 9) - - async def test_process_add_project_phrase_routes_to_project_create(self) -> None: - async with self.session_factory() as db: - service = AIPMOKernelService(db, tenant_id=1) - with patch( - "app.services.ai_pmo_kernel_service.AIStewardService.create_project_from_natural_language", - AsyncMock( - return_value={ - "project": {"id": 12, "title": "OpenClaw 自动化部署", "name": "OpenClaw 自动化部署"}, - "signal": {"id": 31}, - "tasks": [], - } - ), - ): - result = await service.process_ingress( - source_system="pmflow_manual", - source_message_id="project-phrase-1", - message_text="给我新增个项目,openclaw自动化部署。", - sender="老板", - channel_ref="pmflow", - ) - work_item = ( - await db.execute( - select(WorkItem).where( - and_( - WorkItem.tenant_id == 1, - WorkItem.source_message_id == "project-phrase-1", - ) - ) - ) - ).scalar_one() - - self.assertEqual(result["status"], "captured") - self.assertEqual(result["created_project_id"], 12) - self.assertEqual(result["linked_project_ids"], [12]) - self.assertEqual(work_item.intent, INTENT_PROJECT_CREATE) - self.assertEqual(work_item.project_id, 12) - - async def test_signal_source_request_hits_simulation_policy_gate_when_skill_gap_exists(self) -> None: - async with self.session_factory() as db: - service = AIPMOKernelService(db, tenant_id=1) - result = await service.process_ingress( - source_system="pmflow_manual", - source_message_id="source-1", - message_text="给项目ID: 1 新增一个 GitHub 信号源,监控 openai/codex。", - sender="老板", - channel_ref="pmflow", - project_hint_id=1, - ) - - self.assertTrue(result["handled"]) - self.assertEqual(result["status"], "captured") - self.assertIn("技能缺口", result["direct_reply_text"]) - - async def test_signal_create_stays_auto_executable_under_dependency_pressure(self) -> None: - async with self.session_factory() as db: - service = AIPMOKernelService(db, tenant_id=1) - result = await service.process_ingress( - source_system="pmflow_manual", - source_message_id="signal-auto-1", - message_text="给项目ID: 1 新增一条信号:跟踪江苏医保支付改革和器械招采拐点。", - sender="老板", - channel_ref="pmflow", - project_hint_id=1, - ) - - self.assertTrue(result["handled"]) - self.assertEqual(result["status"], "captured") - self.assertIsNotNone(result["signal_id"]) - self.assertEqual(result["linked_project_ids"], [1]) - self.assertIn("已入库", result["direct_reply_text"]) - - async def test_final_phase_generates_risk_cadence_and_program_view(self) -> None: - async with self.session_factory() as db: - service = AIPMOKernelService(db, tenant_id=1) - result = await service.process_ingress( - source_system="pmflow_manual", - source_message_id="source-risk-1", - message_text="给项目ID: 1 新增一个 GitHub 信号源,监控 openai/codex。", - sender="老板", - channel_ref="pmflow", - project_hint_id=1, - ) - cadence_service = AIPMOCadenceService(db, tenant_id=1) - latest_risk = await cadence_service.get_latest_snapshot( - cadence_type="risk_review", - project_id=1, - ) - program_view = await cadence_service.build_program_view() - - self.assertTrue(result["handled"]) - self.assertEqual(result["status"], "captured") - self.assertIsNotNone(latest_risk) - self.assertEqual(latest_risk["scope_type"], "risk_review") - self.assertGreaterEqual(int(latest_risk["summary"]["blocked_task_count"]), 1) - self.assertEqual(program_view["summary"]["program_count"], 1) - self.assertGreaterEqual(int(program_view["summary"]["pending_manual_total"]), 1) - - async def test_followup_continue_resumes_single_waiting_manual_work_item(self) -> None: - async with self.session_factory() as db: - service = AIPMOKernelService(db, tenant_id=1) - first = await service.process_ingress( - source_system="feishu", - source_message_id="feishu-source-1", - message_text="给项目ID: 1 新增一个 GitHub 信号源,监控 openai/codex。", - sender="老板", - channel_ref="oc_phase3", - source_sender_open_id="ou_boss", - project_hint_id=1, - ) - with patch( - "app.services.ai_pmo_kernel_service.SignalSourceDomainWorkflow.run", - AsyncMock( - return_value={ - "status": "completed", - "registry_id": 7, - "typed_actions": [{"operation_code": "task.create", "status": "applied"}], - "material_action_count": 1, - "fallback_used": False, - "raw_result": {}, - } - ), - ): - followup = await service.process_ingress( - source_system="feishu", - source_message_id="feishu-followup-1", - message_text="继续处理", - sender="老板", - channel_ref="oc_phase3", - source_sender_open_id="ou_boss", - project_hint_id=1, - ) - original = ( - await db.execute( - select(WorkItem).where( - and_( - WorkItem.tenant_id == 1, - WorkItem.source_message_id == "feishu-source-1", - ) - ) - ) - ).scalar_one() - - self.assertEqual(first["status"], "captured") - self.assertEqual(followup["status"], "answered") - self.assertIn("已继续处理", followup["direct_reply_text"]) - self.assertEqual(original.state, "succeeded") - - async def test_followup_continue_requires_explicit_target_when_multiple_waiting_manual_items(self) -> None: - async with self.session_factory() as db: - service = AIPMOKernelService(db, tenant_id=1) - await service.process_ingress( - source_system="feishu", - source_message_id="feishu-source-2a", - message_text="给项目ID: 1 新增一个 GitHub 信号源,监控 openai/codex。", - sender="老板", - channel_ref="oc_phase3_multi", - source_sender_open_id="ou_boss", - project_hint_id=1, - ) - await service.process_ingress( - source_system="feishu", - source_message_id="feishu-source-2b", - message_text="给项目ID: 1 新增一个 Web 搜索信号源,监控医保动态。", - sender="老板", - channel_ref="oc_phase3_multi", - source_sender_open_id="ou_boss", - project_hint_id=1, - ) - followup = await service.process_ingress( - source_system="feishu", - source_message_id="feishu-followup-2", - message_text="继续处理", - sender="老板", - channel_ref="oc_phase3_multi", - source_sender_open_id="ou_boss", - project_hint_id=1, - ) - followup_item = ( - await db.execute( - select(WorkItem).where( - and_( - WorkItem.tenant_id == 1, - WorkItem.source_message_id == "feishu-followup-2", - ) - ) - ) - ).scalar_one() - - self.assertEqual(followup["status"], "captured") - self.assertIn("多个待确认工作项", followup["direct_reply_text"]) - self.assertEqual(followup_item.state, "waiting_manual") - - async def test_followup_explicit_work_item_id_can_resume_across_source_system(self) -> None: - async with self.session_factory() as db: - service = AIPMOKernelService(db, tenant_id=1) - first = await service.process_ingress( - source_system="feishu", - source_message_id="feishu-source-3", - message_text="给项目ID: 1 新增一个 GitHub 信号源,监控 openai/codex。", - sender="老板", - channel_ref="oc_phase3_explicit", - source_sender_open_id="ou_boss", - project_hint_id=1, - ) - target_id = int(first["work_item_id"]) - with patch( - "app.services.ai_pmo_kernel_service.SignalSourceDomainWorkflow.run", - AsyncMock( - return_value={ - "status": "completed", - "registry_id": 9, - "typed_actions": [{"operation_code": "task.create", "status": "applied"}], - "material_action_count": 1, - "fallback_used": False, - "raw_result": {}, - } - ), - ): - followup = await service.process_ingress( - source_system="ai_pmo_ui", - source_message_id="resume-ui-1", - message_text=f"继续处理 work_item #{target_id}", - sender="老板", - channel_ref="ai_pmo_ui", - project_hint_id=1, - ) - - self.assertEqual(followup["status"], "answered") - self.assertIn("已继续处理", followup["direct_reply_text"]) - - async def test_followup_resume_marks_signal_source_pending_manual_as_succeeded_when_gate_resolved(self) -> None: - async with self.session_factory() as db: - service = AIPMOKernelService(db, tenant_id=1) - with patch( - "app.services.ai_pmo_kernel_service.SignalSourceDomainWorkflow.run", - AsyncMock( - return_value={ - "status": "pending_manual", - "registry_id": 12, - "typed_actions": [ - { - "operation_code": "notification.create", - "status": "applied", - "payload": {"title": "待确认"}, - } - ], - "material_action_count": 0, - "fallback_used": True, - "raw_result": { - "signal_source": { - "registry_id": 12, - "status": "pending", - "onboarding_status": "pending_manual", - }, - "openclaw": { - "status": "completed", - "result": { - "execution": { - "results": [ - { - "operation_code": "notification.create", - "status": "applied", - "payload": {"title": "待确认"}, - } - ] - } - }, - }, - "worker_executions": [], - "pending_workers": [], - }, - } - ), - ): - first = await service.process_ingress( - source_system="feishu", - source_message_id="feishu-source-pending-manual-1", - message_text="给项目ID: 1 新增一个 GitHub 信号源,监控 openai/codex。", - sender="老板", - channel_ref="oc_phase3_resume_pending", - source_sender_open_id="ou_boss", - project_hint_id=1, - ) - target_id = int(first["work_item_id"]) - followup = await service.process_ingress( - source_system="ai_pmo_ui", - source_message_id="resume-ui-pending-manual-1", - message_text=f"继续处理 work_item #{target_id}", - sender="老板", - channel_ref="ai_pmo_ui", - project_hint_id=1, - ) - original = ( - await db.execute( - select(WorkItem).where( - and_( - WorkItem.tenant_id == 1, - WorkItem.source_message_id == "feishu-source-pending-manual-1", - ) - ) - ) - ).scalar_one() - followup_item = ( - await db.execute( - select(WorkItem).where( - and_( - WorkItem.tenant_id == 1, - WorkItem.source_message_id == "resume-ui-pending-manual-1", - ) - ) - ) - ).scalar_one() - - self.assertEqual(first["status"], "captured") - self.assertEqual(followup["status"], "answered") - self.assertIn("已继续处理", followup["direct_reply_text"]) - self.assertEqual(original.state, "succeeded") - self.assertEqual(original.execution_result.get("status"), "succeeded") - self.assertTrue(bool(original.execution_result.get("manual_gate_resolved"))) - self.assertEqual(original.execution_result.get("post_process_status"), "pending_manual") - self.assertEqual(followup_item.state, "succeeded") - - def test_signal_source_domain_workflow_keeps_notification_only_pending_manual(self) -> None: - normalized = SignalSourceDomainWorkflow.normalize( - { - "status": "partial", - "signal_source": { - "registry_id": 7, - "status": "pending", - "onboarding_status": "pending_manual", - }, - "openclaw": { - "status": "completed", - "result": { - "execution": { - "results": [ - { - "operation_code": "notification.create", - "status": "applied", - "payload": {"title": "待确认"}, - } - ] - } - }, - }, - "worker_executions": [ - { - "worker_role_key": "pm_dispatcher", - "status": "completed", - "used_fallback": True, - } - ], - "pending_workers": [], - } - ) - - self.assertEqual(normalized["status"], "pending_manual") - self.assertEqual(normalized["material_action_count"], 0) - - async def test_transport_mode_statement_is_answered_without_creating_work_item(self) -> None: - async with self.session_factory() as db: - service = AIPMOKernelService(db, tenant_id=1) - result = await service.process_ingress( - source_system="pmflow_manual", - source_message_id="transport-mode-1", - message_text="现在都是以长连接的形式,并不是webhook。", - sender="老板", - channel_ref="pmflow", - project_hint_id=1, - ) - persisted = ( - await db.execute( - select(WorkItem).where( - and_( - WorkItem.tenant_id == 1, - WorkItem.source_message_id == "transport-mode-1", - ) - ) - ) - ).scalar_one_or_none() - - self.assertTrue(result["handled"]) - self.assertEqual(result["status"], "answered") - self.assertTrue(result["direct_reply_required"]) - self.assertIn("长连接模式", result["direct_reply_text"]) - self.assertIsNone(persisted) - - async def test_signal_source_registry_write_re_normalizes_invalid_kind_and_generic_name(self) -> None: - async with self.session_factory() as db: - service = SignalSourceOrchestrationService(db) - result = await service._create_or_reuse_registry( - tenant_id=1, - project_id=1, - signal_id=None, - operator_user_id=None, - requested_via="feishu", - source_payload={ - "source_kind": "long", - "source_name": "给项目ID", - "source_identifier": "long:给项目id", - "description": "每日搜寻最新的医药行业变革动态", - "config": {"keywords": ["医药行业", "变革"]}, - }, - initiating_text="给项目ID: 1 接一个复合信号源:每日搜寻最新的医药行业变革动态。", - dry_run=False, - ) - - self.assertEqual(result["source_kind"], "feishu") - self.assertNotEqual(result["source_name"], "给项目ID") - self.assertTrue(str(result["source_identifier"]).startswith("feishu:")) - - async def test_signal_source_registry_reuse_does_not_rollback_new_signal_in_outer_tx(self) -> None: - async with self.session_factory() as db: - registry_service = SignalSourceOrchestrationService(db) - source_payload = { - "source_kind": "github", - "source_name": "openai/codex", - "source_identifier": "github:openai/codex", - "description": "监控仓库动态", - "config": {"repo": "openai/codex"}, - } - created = await registry_service._create_or_reuse_registry( - tenant_id=1, - project_id=1, - signal_id=None, - operator_user_id=1, - requested_via="feishu", - source_payload=source_payload, - initiating_text="新增 GitHub 信号源 openai/codex", - dry_run=False, - ) - signal = Signal( - tenant_id=1, - source="PMFlow", - title="重复信号源续跑校验", - summary="用于校验事务回滚边界", - content="用于校验事务回滚边界", - signal_type="新需求", - reasoning="[]", - confidence=0.78, - recommended_action="enter_only", - matched_project_id=1, - impact_score=0.7, - priority="medium", - processed=False, - tasks_generated="[]", - status="待处理", - ) - db.add(signal) - await db.flush() - reused = await registry_service._create_or_reuse_registry( - tenant_id=1, - project_id=1, - signal_id=int(signal.id), - operator_user_id=1, - requested_via="ai_pmo_ui", - source_payload=source_payload, - initiating_text="继续处理同一信号源", - dry_run=False, - ) - persisted_signal = ( - await db.execute( - select(Signal).where( - and_( - Signal.tenant_id == 1, - Signal.id == int(signal.id), - ) - ) - ) - ).scalar_one_or_none() - registry_row = ( - await db.execute( - select(SignalSourceRegistry).where( - and_( - SignalSourceRegistry.tenant_id == 1, - SignalSourceRegistry.project_id == 1, - SignalSourceRegistry.source_kind == "github", - SignalSourceRegistry.source_identifier == "github:openai/codex", - ) - ) - ) - ).scalar_one() - await db.commit() - - self.assertEqual(created["status"], "created") - self.assertEqual(reused["status"], "reused") - self.assertIsNotNone(persisted_signal) - self.assertEqual(int(registry_row.signal_id or 0), int(signal.id)) - - async def test_dispatch_openclaw_isolated_session_does_not_rollback_outer_signal(self) -> None: - async with self.session_factory() as db: - service = SignalSourceOrchestrationService(db) - project = ( - await db.execute( - select(Project).where( - and_( - Project.tenant_id == 1, - Project.id == 1, - ) - ) - ) - ).scalar_one() - operator = ( - await db.execute( - select(User).where( - and_( - User.tenant_id == 1, - User.id == 1, - ) - ) - ) - ).scalar_one() - signal = Signal( - tenant_id=1, - source="PMFlow", - title="OpenClaw 事务隔离校验", - summary="验证回滚不影响外层事务", - content="验证回滚不影响外层事务", - signal_type="新需求", - reasoning="[]", - confidence=0.78, - recommended_action="enter_only", - matched_project_id=1, - impact_score=0.7, - priority="medium", - processed=False, - tasks_generated="[]", - status="待处理", - ) - db.add(signal) - await db.flush() - signal_id = int(signal.id) - - async def _fake_dispatch(commander_self, **_: object): # type: ignore[no-untyped-def] - await commander_self.db.rollback() - return {"execution": {"results": []}} - - with patch( - "app.services.openclaw_commander_service.OpenClawCommanderService.dispatch_command", - new=_fake_dispatch, - ): - result = await service._dispatch_openclaw( - tenant_id=1, - operator_user=operator, - project=project, - instruction="生成任务并通知负责人", - dry_run=False, - response_mode="fast", - ) - - persisted_signal = ( - await db.execute( - select(Signal).where( - and_( - Signal.tenant_id == 1, - Signal.id == signal_id, - ) - ) - ) - ).scalar_one_or_none() - await db.commit() - - self.assertIn(result["status"], {"completed", "failed"}) - self.assertIsNotNone(persisted_signal) diff --git a/backend/tests/test_ai_pmo_work_item_decision_api.py b/backend/tests/test_ai_pmo_work_item_decision_api.py deleted file mode 100644 index 4aef4c4b..00000000 --- a/backend/tests/test_ai_pmo_work_item_decision_api.py +++ /dev/null @@ -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"], []) diff --git a/backend/tests/test_ai_steward_capture_feishu_api.py b/backend/tests/test_ai_steward_capture_feishu_api.py deleted file mode 100644 index 975eaa0e..00000000 --- a/backend/tests/test_ai_steward_capture_feishu_api.py +++ /dev/null @@ -1,88 +0,0 @@ -import os -import tempfile -import unittest -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.ai_steward import FeishuCaptureRequest, capture_feishu_signal -from app.models import Base, FeishuReplyOutbox, User - - -class CaptureFeishuSignalApiTest(unittest.IsolatedAsyncioTestCase): - async def asyncSetUp(self) -> None: - tmp = tempfile.NamedTemporaryFile(prefix="pmflow-ai-steward-capture-", 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_capture_feishu_signal_enqueues_outbox_when_direct_reply_required(self) -> None: - async with self.session_factory() as db: - user = User( - tenant_id=1, - username="capture_admin", - email="capture_admin@example.com", - password_hash="hash", - name="捕获管理员", - role="ADMIN", - status="active", - is_active=True, - ) - db.add(user) - await db.commit() - await db.refresh(user) - - with patch( - "app.api.ai_steward.ProactiveSignalCaptureService.capture_from_feishu", - AsyncMock( - return_value={ - "handled": True, - "status": "captured", - "classification": "signal", - "ai_trace_id": "ai-pmo-123", - "direct_reply_required": True, - "direct_reply_text": "已收到,正在执行。", - } - ), - ): - payload = await capture_feishu_signal( - request=FeishuCaptureRequest( - group_chat_id="oc_test_001", - message_content="给 AI 劝退助手加一个复杂需求", - sender="老板", - feishu_message_id="om_test_001", - ), - current_user=user, - db=db, - tenant_id=1, - ) - - self.assertTrue(payload["reply_outbox_enqueued"]) - self.assertIsInstance(payload["reply_outbox_id"], int) - row = ( - await db.execute( - select(FeishuReplyOutbox).where( - and_( - FeishuReplyOutbox.tenant_id == 1, - FeishuReplyOutbox.source_message_id == "om_test_001", - ) - ) - ) - ).scalar_one_or_none() - self.assertIsNotNone(row) - assert row is not None - self.assertEqual(row.chat_id, "oc_test_001") - self.assertEqual(row.message_text, "已收到,正在执行。") - diff --git a/backend/tests/test_ai_steward_fail_fast.py b/backend/tests/test_ai_steward_fail_fast.py deleted file mode 100644 index b0852ccd..00000000 --- a/backend/tests/test_ai_steward_fail_fast.py +++ /dev/null @@ -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"], "任务超时预警") diff --git a/backend/tests/test_ai_steward_title_extract.py b/backend/tests/test_ai_steward_title_extract.py deleted file mode 100644 index 29a56683..00000000 --- a/backend/tests/test_ai_steward_title_extract.py +++ /dev/null @@ -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", - ], - ) diff --git a/backend/tests/test_api_router_order.py b/backend/tests/test_api_router_order.py deleted file mode 100644 index 575c228e..00000000 --- a/backend/tests/test_api_router_order.py +++ /dev/null @@ -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 == [] diff --git a/backend/tests/test_auth_password_validation.py b/backend/tests/test_auth_password_validation.py deleted file mode 100644 index 2b7747cc..00000000 --- a/backend/tests/test_auth_password_validation.py +++ /dev/null @@ -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!") diff --git a/backend/tests/test_feishu_long_connection.py b/backend/tests/test_feishu_long_connection.py deleted file mode 100644 index 1f80f2ec..00000000 --- a/backend/tests/test_feishu_long_connection.py +++ /dev/null @@ -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) diff --git a/backend/tests/test_feishu_routing_receipts.py b/backend/tests/test_feishu_routing_receipts.py deleted file mode 100644 index 7ecbec08..00000000 --- a/backend/tests/test_feishu_routing_receipts.py +++ /dev/null @@ -1,229 +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_build_capability_reply_returns_stable_text(self) -> None: - reply = FeishuIntegrationService._build_capability_reply("别乱创建信号,说明你是谁") - self.assertIsNotNone(reply) - self.assertIn("PMFlow 的项目协同助手", str(reply)) - - 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_handle_message_receive_capability_reply_overrides_execute_direct_reply(self) -> None: - service = FeishuIntegrationService(db=None) # type: ignore[arg-type] - event = { - "message": { - "message_id": "om_capability_guard", - "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": "captured", - "classification": "signal", - "direct_reply_required": True, - "direct_reply_text": "我在等 Interactive Card 的内容。", - "results": [{"route_status": "queued_for_decision"}], - } - ), - ), - ): - asyncio.run(service._handle_message_receive(event)) - - reply_mock.assert_awaited_once() - sent_text = str(reply_mock.await_args.kwargs.get("text") or "") - self.assertIn("PMFlow 的项目协同助手", sent_text) - self.assertNotIn("Interactive Card", sent_text) - - 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() diff --git a/backend/tests/test_inbox_queue_view.py b/backend/tests/test_inbox_queue_view.py deleted file mode 100644 index e78bc9cb..00000000 --- a/backend/tests/test_inbox_queue_view.py +++ /dev/null @@ -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) diff --git a/backend/tests/test_llm_providers_api.py b/backend/tests/test_llm_providers_api.py deleted file mode 100644 index 4f3e1f65..00000000 --- a/backend/tests/test_llm_providers_api.py +++ /dev/null @@ -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" diff --git a/backend/tests/test_llm_structured_extraction.py b/backend/tests/test_llm_structured_extraction.py deleted file mode 100644 index 5d98c23b..00000000 --- a/backend/tests/test_llm_structured_extraction.py +++ /dev/null @@ -1,354 +0,0 @@ -import os -import unittest -from unittest.mock import AsyncMock, MagicMock, patch - -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) - - -class LLMQueryFailureBehaviorTest(unittest.IsolatedAsyncioTestCase): - async def test_generate_strict_query_retries_high_tier_even_when_rule_not_low(self) -> None: - service = LLMService() - 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._is_low_tier_function = MagicMock(return_value=False) - service._resolve_high_tier_engine_for_escalation = AsyncMock( - return_value={ - "provider": "openai", - "model": "GLM-5", - "api_key": "fake-openai", - "base_url": "http://unit.test/openai", - } - ) - service._chat_completion = AsyncMock( - side_effect=[ValueError("provider_circuit_open:glm:GLM-4.7"), "高阶模型正常响应"] - ) - service._try_safety_engine = AsyncMock(return_value=("", None)) - service._log_ai_call = AsyncMock() - - text = await service.generate( - "query prompt", - db=object(), - tenant_id=1, - function_name="adaptive_rag_answer_normal", - use_prompt_profile=False, - ) - - self.assertEqual(text, "高阶模型正常响应") - self.assertEqual(service._chat_completion.await_count, 2) - - async def test_generate_raises_runtime_error_for_query_answer_fail_fast(self) -> None: - service = LLMService() - 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._chat_completion = AsyncMock(side_effect=ValueError("primary_engine_empty_response")) - service._resolve_high_tier_engine_for_escalation = AsyncMock(return_value=None) - service._try_safety_engine = AsyncMock(return_value=("", None)) - service._log_ai_call = AsyncMock() - - with self.assertRaises(RuntimeError) as ctx: - await service.generate( - "query prompt", - db=object(), - tenant_id=1, - function_name="adaptive_rag_answer_normal", - use_prompt_profile=False, - ) - - self.assertIn("模型调用失败:", str(ctx.exception)) - service._try_safety_engine.assert_not_awaited() - - async def test_generate_keeps_stub_behavior_for_non_query_function(self) -> None: - service = LLMService() - 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._chat_completion = AsyncMock(side_effect=ValueError("primary_engine_empty_response")) - service._log_ai_call = AsyncMock() - - text = await service.generate( - "reply prompt", - db=object(), - tenant_id=1, - function_name="feishu_humanized_reply", - use_prompt_profile=False, - ) - - self.assertIn("模型服务异常", text) - - -class LLMRoutingSelectionTest(unittest.TestCase): - def test_inject_function_guardrails_for_dual_model_functions(self) -> None: - injected = LLMService._inject_function_guardrails( - system_prompt="", - function_name="classify_intent_complex", - ) - self.assertIn("ddl_api_required", injected) - self.assertIn("DDL", injected) - - def test_merge_prompt_with_profile_appends_guardrail_for_openclaw_dispatch(self) -> None: - merged = LLMService._merge_prompt_with_profile( - prompt='{"instruction":"db ddl"}', - prompt_profile={"role_name": "planner", "system_prompt": "你是规划器。"}, - function_name="openclaw_command_dispatch", - ) - self.assertIn("你是规划器。", merged) - self.assertIn("ddl_api_required", merged) - - def test_resolve_tier_engine_keeps_configured_openai_base_url(self) -> None: - service = LLMService() - env_updates = { - "OPENCLAW_ROUTING_ENABLED": "1", - "OPENAI_BASE_URL": "https://api.edgefn.net/v1", - "LLM_ROUTING_HIGH_PROVIDER": "openai", - } - with patch.dict(os.environ, env_updates, clear=False): - engine = service._resolve_tier_engine( - tier="high", - engines=[], - active_engine={}, - ) - - self.assertIsNotNone(engine) - assert engine is not None - self.assertEqual(str(engine.get("base_url")), "https://api.edgefn.net/v1") - - def test_should_retry_empty_chat_response_for_reasoning_length(self) -> None: - should_retry = LLMService._should_retry_empty_chat_response( - structured_output=False, - message={"content": "", "reasoning_content": "先分析上下文"}, - choice={"finish_reason": "length"}, - current_max_tokens=900, - ) - self.assertTrue(should_retry) - - def test_should_not_retry_empty_chat_response_when_not_length(self) -> None: - should_retry = LLMService._should_retry_empty_chat_response( - structured_output=False, - message={"content": "", "reasoning_content": "先分析上下文"}, - choice={"finish_reason": "stop"}, - current_max_tokens=900, - ) - self.assertFalse(should_retry) - - -if __name__ == "__main__": - unittest.main() diff --git a/backend/tests/test_model_routing_api.py b/backend/tests/test_model_routing_api.py deleted file mode 100644 index 36f9fbb9..00000000 --- a/backend/tests/test_model_routing_api.py +++ /dev/null @@ -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") diff --git a/backend/tests/test_openclaw_commander_service.py b/backend/tests/test_openclaw_commander_service.py deleted file mode 100644 index 36780cc5..00000000 --- a/backend/tests/test_openclaw_commander_service.py +++ /dev/null @@ -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() diff --git a/backend/tests/test_project_knowledge_graph_guardrails.py b/backend/tests/test_project_knowledge_graph_guardrails.py deleted file mode 100644 index 1bb968cd..00000000 --- a/backend/tests/test_project_knowledge_graph_guardrails.py +++ /dev/null @@ -1,117 +0,0 @@ -import json -import os -import tempfile -import unittest - -from sqlalchemy import and_, select -from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine - -from app.models import Base, KGEntity, KGRelation -from app.services.project_knowledge_graph import DDL_GUARDRAIL_POLICY_KEY, ProjectKnowledgeGraphService - - -class ProjectKnowledgeGraphGuardrailSyncTest(unittest.IsolatedAsyncioTestCase): - async def asyncSetUp(self) -> None: - self._old_rules_path = os.environ.get("OPENCLAW_ROUTING_RULES_PATH") - - tmp_db = tempfile.NamedTemporaryFile(prefix="pmflow-kg-guardrail-", 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": "low-intent-rule", - "enabled": True, - "priority": 300, - "tier": "low", - "provider": "glm", - "model": "GLM-4.7", - "api_env": "GLM_API_KEY", - }, - { - "name": "high-intent-rule", - "enabled": True, - "priority": 280, - "tier": "high", - "provider": "openai", - "model": "GLM-5", - "api_env": "OPENAI_API_KEY", - }, - ] - } - ).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 - - if self._old_rules_path is None: - os.environ.pop("OPENCLAW_ROUTING_RULES_PATH", None) - else: - os.environ["OPENCLAW_ROUTING_RULES_PATH"] = self._old_rules_path - - async def test_sync_model_guardrails_now_builds_dual_model_policy_graph(self) -> None: - async with self.session_factory() as db: - service = ProjectKnowledgeGraphService(db, tenant_id=1) - payload = await service.sync_model_guardrails_now() - - self.assertEqual(payload["models"], 2) - self.assertGreaterEqual(payload["relations"], 4) - - policy_entity = ( - await db.execute( - select(KGEntity).where( - and_( - KGEntity.tenant_id == 1, - KGEntity.entity_type == "governance_policy", - KGEntity.entity_key == DDL_GUARDRAIL_POLICY_KEY, - ) - ) - ) - ).scalar_one_or_none() - self.assertIsNotNone(policy_entity) - - model_entities = ( - await db.execute( - select(KGEntity).where( - and_( - KGEntity.tenant_id == 1, - KGEntity.entity_type == "ai_model", - ) - ) - ) - ).scalars().all() - self.assertEqual(len(model_entities), 2) - model_names = {str(row.display_name or "") for row in model_entities} - self.assertEqual(model_names, {"GLM-4.7", "GLM-5"}) - - policy_relations = ( - await db.execute( - select(KGRelation).where( - and_( - KGRelation.tenant_id == 1, - KGRelation.relation_type == "model_must_follow_policy", - KGRelation.object_entity_id == int(policy_entity.id), - ) - ) - ) - ).scalars().all() - self.assertEqual(len(policy_relations), 2) diff --git a/backend/tests/test_projects_delete_api.py b/backend/tests/test_projects_delete_api.py deleted file mode 100644 index 98757536..00000000 --- a/backend/tests/test_projects_delete_api.py +++ /dev/null @@ -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) diff --git a/backend/tests/test_projects_member_assignment_api.py b/backend/tests/test_projects_member_assignment_api.py deleted file mode 100644 index 25dc3fc8..00000000 --- a/backend/tests/test_projects_member_assignment_api.py +++ /dev/null @@ -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)]), - ) diff --git a/backend/tests/test_signal_source_orchestration_service.py b/backend/tests/test_signal_source_orchestration_service.py deleted file mode 100644 index c679affc..00000000 --- a/backend/tests/test_signal_source_orchestration_service.py +++ /dev/null @@ -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() diff --git a/backend/tests/test_sstc_signal_state_machine.py b/backend/tests/test_sstc_signal_state_machine.py deleted file mode 100644 index a0090368..00000000 --- a/backend/tests/test_sstc_signal_state_machine.py +++ /dev/null @@ -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() - diff --git a/backend/tests/test_task_briefs_api.py b/backend/tests/test_task_briefs_api.py deleted file mode 100644 index 6532ab15..00000000 --- a/backend/tests/test_task_briefs_api.py +++ /dev/null @@ -1,162 +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, Integration, User -from app.services.task_brief_service import DEFAULT_TEMPLATE_FALLBACK - - -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) - - async def test_default_template_upgrade_and_placeholder_render(self) -> None: - async with self.session_factory() as db: - user = User( - tenant_id=1, - username="brief_admin_upgrade", - email="brief_admin_upgrade@example.com", - password_hash="hash", - name="模板管理员", - role="ADMIN", - status="active", - is_active=True, - ) - db.add(user) - await db.commit() - await db.refresh(user) - - db.add( - Integration( - tenant_id=1, - type="task_briefs", - is_active=True, - config={ - "templates": [ - { - "id": "tpl_default_signal_planner", - "name": "Signal Planner 默认模板", - "description": "系统初始化模板(可按需修改)", - "content": DEFAULT_TEMPLATE_FALLBACK, - "updated_at": "2026-01-01T00:00:00", - "updated_by": 0, - "created_at": "2026-01-01T00:00:00", - } - ], - "runs": [], - "briefs": [], - }, - ) - ) - await db.commit() - - templates = await list_task_brief_templates(db=db, current_user=user) - self.assertEqual(templates["code"], 0) - default_tpl = next((item for item in templates["data"]["items"] if item.get("id") == "tpl_default_signal_planner"), None) - self.assertIsNotNone(default_tpl) - self.assertIn("## Verification", str(default_tpl["content"])) - self.assertNotEqual(str(default_tpl["content"]).strip(), DEFAULT_TEMPLATE_FALLBACK.strip()) - - generated = await generate_task_brief( - body=GenerateTaskBriefRequest( - title="Default Template Render", - changed_files=["backend/app/services/task_brief_service.py"], - scope_summary="验证默认模板占位符渲染", - ), - db=db, - current_user=user, - ) - self.assertEqual(generated["code"], 0) - content = str(generated["data"]["brief"]["content"] or "") - self.assertIn("backend/app/services/task_brief_service.py", content) - self.assertIn("验证默认模板占位符渲染", content) - self.assertNotIn("{{CHANGED_FILES}}", content) - self.assertNotIn("{{SCOPE_SUMMARY}}", content) - self.assertNotIn("{{GENERATED_AT}}", content) diff --git a/backend/tests/test_task_persistence.py b/backend/tests/test_task_persistence.py deleted file mode 100644 index 54eaf05c..00000000 --- a/backend/tests/test_task_persistence.py +++ /dev/null @@ -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", - }, - ], - ) diff --git a/frontend/src/api/aiSteward.ts b/frontend/src/api/aiSteward.ts index 04552b6e..7ea8dc2e 100644 --- a/frontend/src/api/aiSteward.ts +++ b/frontend/src/api/aiSteward.ts @@ -9,13 +9,6 @@ function unwrap(raw: ApiEnvelope | T): T { return raw as T; } -export interface OpenClawCommandDispatchRequest { - project_id: number; - instruction: string; - dry_run?: boolean; - max_operations?: number; -} - export interface SignalSourceRepairRequest { project_id: number; registry_ids: number[]; @@ -35,53 +28,6 @@ export interface SignalSourceRepairResponse { items: SignalSourceRepairItem[]; } -export interface OpenClawCommandExecutionItem { - index: number; - operation_code: string; - payload: Record; - 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 interface LLMProviderPreset { @@ -97,16 +43,124 @@ export interface LLMProviderPresetResponse { providers: Record; } +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 = { - async dispatchCommand(body: OpenClawCommandDispatchRequest): Promise { - const raw = await apiRequest>("/ai-steward/command/dispatch", { + async getLlmProviderPresets(): Promise { + const raw = await apiRequest>("/ai-steward/llm/providers"); + return unwrap(raw); + }, + async listLlmEngines(): Promise { + const raw = await apiRequest>("/ai-steward/llm-engines"); + return unwrap(raw); + }, + async createLlmEngine(body: LLMEngineUpsertRequest): Promise { + const raw = await apiRequest>("/ai-steward/llm-engines", { method: "POST", body: JSON.stringify(body), }); return unwrap(raw); }, - async getLlmProviderPresets(): Promise { - const raw = await apiRequest>("/ai-steward/llm/providers"); + async updateLlmEngine( + engineId: string, + body: Partial, + ): Promise { + const raw = await apiRequest>( + `/ai-steward/llm-engines/${engineId}`, + { + method: "PUT", + body: JSON.stringify(body), + } + ); + return unwrap(raw); + }, + async activateLlmEngine(engineId: string): Promise { + const raw = await apiRequest>( + `/ai-steward/llm-engines/${engineId}/activate`, + { + method: "POST", + } + ); + return unwrap(raw); + }, + async deleteLlmEngine( + engineId: string, + ): Promise { + const raw = await apiRequest< + ApiEnvelope + >(`/ai-steward/llm-engines/${engineId}`, { + method: "DELETE", + }); + return unwrap(raw); + }, + async updateLlmEngineSettings( + body: Pick, + ): Promise { + const raw = await apiRequest>( + "/ai-steward/llm-engines/settings", + { + method: "PUT", + body: JSON.stringify(body), + } + ); + return unwrap(raw); + }, + async testActiveLlmEngine(): Promise { + const raw = await apiRequest>("/ai-steward/llm-engines/test-active", { + method: "POST", + body: JSON.stringify({}), + }); + return unwrap(raw); + }, + async testLlmEngine(engineId: string): Promise { + const raw = await apiRequest>( + `/ai-steward/llm-engines/${engineId}/test`, + { + method: "POST", + body: JSON.stringify({}), + } + ); return unwrap(raw); }, async repairDirtySignalSources(body: SignalSourceRepairRequest): Promise { diff --git a/frontend/src/api/index.ts b/frontend/src/api/index.ts index 518126aa..31cb4ff9 100644 --- a/frontend/src/api/index.ts +++ b/frontend/src/api/index.ts @@ -13,3 +13,4 @@ export * from './modelCapability' export * from './budget' export * from './recipes' export * from './aiSteward' +export * from './knowledgeGraph' diff --git a/frontend/src/api/knowledgeGraph.ts b/frontend/src/api/knowledgeGraph.ts new file mode 100644 index 00000000..644ef3b2 --- /dev/null +++ b/frontend/src/api/knowledgeGraph.ts @@ -0,0 +1,142 @@ +import { api } from "@/lib/api"; + +type ApiEnvelope = { code: number; message: string; data: T }; + +function unwrap(raw: ApiEnvelope | T): T { + if (raw && typeof raw === "object" && "data" in (raw as Record)) { + return (raw as ApiEnvelope).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 { + const { data } = await api.get>( + "/ai-steward/knowledge-graph/capability-summary" + ); + return unwrap(data); + }, + + async verifyContextConsumption(payload?: { run_canary?: boolean }): Promise { + const { data } = await api.post>( + "/ai-steward/knowledge-graph/verify-context-consumption", + payload || {} + ); + return unwrap(data); + }, +}; diff --git a/frontend/src/components/ai-config/FeatureIntroGuide.tsx b/frontend/src/components/ai-config/FeatureIntroGuide.tsx new file mode 100644 index 00000000..94f636dd --- /dev/null +++ b/frontend/src/components/ai-config/FeatureIntroGuide.tsx @@ -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 ( +
+ + + + + + {title} · 功能介绍 + 进页面先看这一段,知道“它是干嘛的、怎么用”。 + + +
+
+

这个功能是干嘛的

+

{purpose}

+
+ +
+

什么时候用

+

{whenToUse}

+
+ +
+

怎么操作(3 步)

+
    + {steps.map((step) => ( +
  1. {step}
  2. + ))} +
+
+ + {outputs.length > 0 ? ( +
+

你会看到什么结果

+
    + {outputs.map((item) => ( +
  • {item}
  • + ))} +
+
+ ) : null} +
+ + + + + +
+
+
+ ); +} diff --git a/frontend/src/components/ai-steward/OpenClawCommanderPanel.tsx b/frontend/src/components/ai-steward/OpenClawCommanderPanel.tsx deleted file mode 100644 index 27464b2d..00000000 --- a/frontend/src/components/ai-steward/OpenClawCommanderPanel.tsx +++ /dev/null @@ -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([]); - 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(null); - const [result, setResult] = useState(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 ( - - - -
Commander 仅开放给管理角色
-
- 当前接口要求 `BOSS / ADMIN`,普通成员仍可通过任务看板或 AI 问答拿建议,但不能直接下发执行。 -
-
-
- ); - } - - return ( -
- - -
-
- -
-
- OpenClaw Commander - 直接下达自然语言指令,让指挥层规划并驱动 AI 管家执行。 -
-
-
- -
-
- - -
-
- {projects.length === 0 ? ( -
- {projectLoading ? "正在加载项目..." : "暂无可调度项目"} -
- ) : ( - projects.map((project) => { - const active = String(project.id) === String(projectId); - return ( - - ); - }) - )} -
-
- -
-
-
- - setProjectId(event.target.value)} - placeholder="例如 1" - /> -
-
- - setMaxOperations(event.target.value)} - inputMode="numeric" - placeholder="6" - /> -
-
- - {selectedProject ? ( -
-
{formatProjectNameWithId(selectedProject)}
-
- Active {selectedProject.in_progress_tasks} / Pending {selectedProject.pending_tasks} / Blocked 由执行结果返回。 -
-
- ) : null} - -
- -