Compare commits
2 Commits
5b7cf23aeb
...
d59b1489a1
| Author | SHA1 | Date |
|---|---|---|
|
|
d59b1489a1 | |
|
|
8dde37448e |
88
CLAUDE.md
88
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,19 +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/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
|
||||
|
|
@ -109,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 工作区主容器(组装各阶段组件)
|
||||
|
|
@ -190,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/` 封装层
|
||||
|
|
@ -197,19 +241,31 @@ uvicorn app.main:app --reload --host 0.0.0.0 --port 8000
|
|||
- 飞书回执发送仅允许 `main.py` 的 drain_loop 作为消费者,禁止在业务路径即时派发 outbox 发送任务
|
||||
- 所有写接口必须落审计日志(`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`,用于详情页展示“消息闭环 + 实现模块”
|
||||
|
|
@ -217,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`
|
||||
|
|
@ -230,21 +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
|
||||
- 双 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`
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -30,7 +30,7 @@ from ..services.daily_briefing import get_daily_briefing_service
|
|||
from ..services.event_workflow import get_event_workflow_engine, WorkflowEventType
|
||||
from ..services.adaptive_rag_service import AdaptiveRAGService
|
||||
from ..services.ai_orchestration_run_service import AIOrchestrationRunService
|
||||
from ..services.openclaw_commander_service import OpenClawCommanderService
|
||||
from ..services.feishu_outbox_service import FeishuOutboxService
|
||||
from ..services.signal_source_orchestration_service import SignalSourceOrchestrationService
|
||||
from ..services.signal_source_repair_service import (
|
||||
SignalSourceRepairMissingSignalError,
|
||||
|
|
@ -152,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)
|
||||
|
|
@ -230,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(
|
||||
|
|
@ -448,7 +411,31 @@ async def capture_feishu_signal(
|
|||
tenant_id=tenant_id,
|
||||
feishu_message_id=request.feishu_message_id,
|
||||
)
|
||||
return result
|
||||
response_payload = dict(result) if isinstance(result, dict) else {"result": result}
|
||||
|
||||
should_enqueue_reply = bool(response_payload.get("direct_reply_required"))
|
||||
reply_text = str(response_payload.get("direct_reply_text") or "").strip() or "已收到,正在处理。"
|
||||
source_message_id = (str(request.feishu_message_id or "").strip()[:128] or None)
|
||||
normalized_chat_id = str(request.group_chat_id or "").strip()
|
||||
if should_enqueue_reply and normalized_chat_id:
|
||||
try:
|
||||
outbox_id = await FeishuOutboxService.enqueue(
|
||||
db,
|
||||
tenant_id=int(tenant_id),
|
||||
chat_id=normalized_chat_id,
|
||||
text=reply_text,
|
||||
source_message_id=source_message_id,
|
||||
dedupe_seed=f"capture-feishu:{normalized_chat_id}:{str(request.sender or '').strip()[:64]}",
|
||||
trace_id=str(response_payload.get("ai_trace_id") or "").strip()[:64] or None,
|
||||
)
|
||||
await db.commit()
|
||||
response_payload["reply_outbox_enqueued"] = bool(outbox_id)
|
||||
response_payload["reply_outbox_id"] = int(outbox_id) if outbox_id else None
|
||||
except Exception as outbox_exc:
|
||||
await db.rollback()
|
||||
response_payload["reply_outbox_enqueued"] = False
|
||||
response_payload["reply_outbox_error"] = str(outbox_exc)[:200]
|
||||
return response_payload
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=f"飞书信号捕获失败: {str(e)}")
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,51 +383,6 @@ PROMPT_FEISHU_TASK_ORCHESTRATION = """
|
|||
"""
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# OpenClaw 指挥层
|
||||
# ============================================================================
|
||||
|
||||
PROMPT_OPENCLAW_COMMAND_DISPATCH = """
|
||||
首字符必须是 {{,末字符必须是 }}。
|
||||
你是 PMFlow 的 OpenClaw 指挥层。只做一件事:根据输入生成可执行 operations。
|
||||
|
||||
输入:
|
||||
- command_json: {command_json}
|
||||
- project_json: {project_json}
|
||||
- tasks_json: {tasks_json}
|
||||
- members_json: {members_json}
|
||||
- operation_catalog_json: {operation_catalog_json}
|
||||
|
||||
只输出 JSON:
|
||||
{{
|
||||
"summary": "一句话总结本次指挥决策",
|
||||
"confidence": 0.0-1.0,
|
||||
"operations": [
|
||||
{{
|
||||
"operation_code": "project.create|project.update|project.archive|project.reopen|task.create|task.assign|task.prioritize|task.status_transition|notification.create|personnel.workload_update",
|
||||
"payload": {{}},
|
||||
"reason": "为什么执行这一步"
|
||||
}}
|
||||
]
|
||||
}}
|
||||
|
||||
规则:
|
||||
1. operation_code 只能来自 operation_catalog_json。
|
||||
2. 总操作数 <= {max_operations}。
|
||||
3. 不编造 task_id / user_id / project_id。
|
||||
4. 若 project_json.scope=global,当前项目为空时可从 project_json.candidate_projects 里选择 project_id;需要新建项目时使用 project.create。
|
||||
5. project.create 缺少 title 时不要生成该操作。
|
||||
6. project.update / project.archive / project.reopen 只有在 project_id 明确时才能生成。
|
||||
7. 信息不足时返回空 operations,并在 summary 说明阻塞原因。
|
||||
8. reason 要短、直接、可执行。
|
||||
9. confidence 必须是 number,不能是字符串。
|
||||
10. 禁止输出 thinking、analysis、解释句、Markdown、代码块。
|
||||
11. 字段未知时返回空字符串、空数组或 null。
|
||||
12. 信息不足时也必须返回合法 JSON,operations=[]。
|
||||
13. 不要 Markdown,不要解释,只返回 JSON。
|
||||
"""
|
||||
|
||||
|
||||
PROMPT_SIGNAL_SOURCE_ONBOARDING = """
|
||||
首字符必须是 {{,末字符必须是 }}。
|
||||
你是 PMFlow 的信号源接入规划器。判断当前请求是不是“新增/接入一个信号源”,并返回结构化计划。
|
||||
|
|
@ -446,7 +404,7 @@ PROMPT_SIGNAL_SOURCE_ONBOARDING = """
|
|||
"config": {{}}
|
||||
}},
|
||||
"worker_roles": ["pm_dispatcher", "tech_architect", "delivery_coach"],
|
||||
"openclaw_instruction": "交给 OpenClaw 的自然语言指令"
|
||||
"execution_instruction": "交给显式服务执行层的自然语言指令"
|
||||
}}
|
||||
|
||||
若不是信号源接入请求,输出:
|
||||
|
|
@ -455,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。
|
||||
|
|
@ -510,26 +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,不要额外文本。
|
||||
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
|
||||
# ============================================================================
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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,24 +43,21 @@ 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(),
|
||||
}
|
||||
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_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)),
|
||||
|
|
@ -69,24 +65,12 @@ if not _is_sqlite():
|
|||
# 强制数据库会话时区为北京时间,确保 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 "")
|
||||
|
|
|
|||
|
|
@ -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,186 +116,7 @@ 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
|
||||
"""
|
||||
"禁止同一服务混用多数据库方言。"
|
||||
)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
),
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 个")
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
||||
|
||||
|
|
@ -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
|
||||
|
|
@ -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},
|
||||
]
|
||||
|
|
@ -0,0 +1,92 @@
|
|||
"""AI PMO kernel closure helpers mixin."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from . import ai_pmo_kernel_common as _common
|
||||
|
||||
for _name, _value in vars(_common).items():
|
||||
if not _name.startswith("__"):
|
||||
globals()[_name] = _value
|
||||
|
||||
|
||||
class KernelClosureHelpersMixin:
|
||||
@staticmethod
|
||||
def _normalize_dialogue_text(value: Any, *, max_len: int) -> str:
|
||||
text = re.sub(r"\s+", " ", str(value or "")).strip()
|
||||
if not text:
|
||||
return ""
|
||||
return text[:max_len]
|
||||
|
||||
@staticmethod
|
||||
def _parse_datetime_like(value: Any) -> Optional[datetime]:
|
||||
if isinstance(value, datetime):
|
||||
dt = value
|
||||
else:
|
||||
raw = str(value or "").strip()
|
||||
if not raw:
|
||||
return None
|
||||
try:
|
||||
dt = datetime.fromisoformat(raw.replace("Z", "+00:00"))
|
||||
except Exception:
|
||||
return None
|
||||
if dt.tzinfo is not None:
|
||||
return dt.astimezone(timezone.utc).replace(tzinfo=None)
|
||||
return dt
|
||||
|
||||
@staticmethod
|
||||
def _normalize_feishu_user_text(value: Any, *, max_len: int) -> str:
|
||||
text = str(value or "")
|
||||
text = re.sub(r"^@_user_\d+\s*", "", text)
|
||||
text = re.sub(r"</?at[^>]*>", " ", text, flags=re.IGNORECASE)
|
||||
text = re.sub(r"\s+", " ", text).strip()
|
||||
if not text:
|
||||
return ""
|
||||
return text[:max_len]
|
||||
|
||||
@classmethod
|
||||
def _extract_assistant_text_from_log_payload(cls, payload: Any) -> str:
|
||||
if not isinstance(payload, dict):
|
||||
return ""
|
||||
candidates = (
|
||||
payload.get("followup_reply"),
|
||||
payload.get("direct_reply_text"),
|
||||
payload.get("clarification_message"),
|
||||
payload.get("answer"),
|
||||
payload.get("reason"),
|
||||
payload.get("result_summary"),
|
||||
payload.get("error_message"),
|
||||
payload.get("message"),
|
||||
)
|
||||
for item in candidates:
|
||||
text = cls._normalize_dialogue_text(item, max_len=420)
|
||||
if not text:
|
||||
continue
|
||||
if any(token in text for token in ("chat_id", "message_type", "sender_name")) and len(text) < 48:
|
||||
continue
|
||||
return text
|
||||
return ""
|
||||
|
||||
@staticmethod
|
||||
def _merge_closure_turns(
|
||||
base_turns: list[dict[str, str]],
|
||||
extra_turns: list[dict[str, str]],
|
||||
*,
|
||||
prepend: bool = False,
|
||||
) -> list[dict[str, str]]:
|
||||
if not extra_turns:
|
||||
return list(base_turns)
|
||||
merged = list(extra_turns) + list(base_turns) if prepend else list(base_turns) + list(extra_turns)
|
||||
compact: list[dict[str, str]] = []
|
||||
for row in merged:
|
||||
role = str(row.get("role") or "").strip().lower()
|
||||
text = str(row.get("text") or "").strip()
|
||||
if role not in {"user", "assistant"} or not text:
|
||||
continue
|
||||
if compact and compact[-1].get("role") == role and compact[-1].get("text") == text:
|
||||
continue
|
||||
compact.append({"role": role, "text": text})
|
||||
return compact[-8:]
|
||||
|
||||
@staticmethod
|
||||
def _turns_have_assistant(turns: list[dict[str, str]]) -> bool:
|
||||
return any(str(turn.get("role") or "").strip().lower() == "assistant" and str(turn.get("text") or "").strip() for turn in turns)
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
||||
|
|
@ -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
|
||||
|
|
@ -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}"
|
||||
|
|
@ -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
|
||||
|
|
@ -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)}
|
||||
|
||||
|
|
@ -0,0 +1,172 @@
|
|||
"""AI PMO kernel ingress render mixin."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from . import ai_pmo_kernel_common as _common
|
||||
|
||||
for _name, _value in vars(_common).items():
|
||||
if not _name.startswith("__"):
|
||||
globals()[_name] = _value
|
||||
|
||||
|
||||
class KernelIngressRenderMixin:
|
||||
def _build_capture_result(
|
||||
self,
|
||||
*,
|
||||
work_item: WorkItem,
|
||||
intent: CanonicalIntent,
|
||||
execution_result: dict[str, Any],
|
||||
render: dict[str, Any],
|
||||
direct_reply_required: bool,
|
||||
status: str,
|
||||
classification: str,
|
||||
) -> dict[str, Any]:
|
||||
linked_project_ids = [int(work_item.project_id)] if work_item.project_id is not None else []
|
||||
direct_reply_text = str(render.get("text") or "").strip()
|
||||
if _looks_like_upstream_timeout_message(direct_reply_text):
|
||||
logger.warning(
|
||||
"ai_pmo_upstream_timeout_reply_detected",
|
||||
tenant_id=int(self.tenant_id),
|
||||
work_item_id=int(work_item.id),
|
||||
intent=str(intent.name or ""),
|
||||
source_message_id=str(work_item.source_message_id or "")[:128],
|
||||
observed_at=now_cn_naive().isoformat(),
|
||||
reply_preview=direct_reply_text[:260],
|
||||
)
|
||||
signal_ids = _normalize_signal_id_list(execution_result.get("signal_ids"))
|
||||
primary_signal_id = _safe_int(execution_result.get("signal_id")) or _safe_int(work_item.signal_id)
|
||||
if primary_signal_id is not None and primary_signal_id not in signal_ids:
|
||||
signal_ids = [primary_signal_id, *signal_ids]
|
||||
if signal_ids and primary_signal_id is None:
|
||||
primary_signal_id = signal_ids[0]
|
||||
result = {
|
||||
"handled": True,
|
||||
"status": status,
|
||||
"classification": classification,
|
||||
"linked_project_ids": linked_project_ids,
|
||||
"signal_id": primary_signal_id,
|
||||
"signal_ids": signal_ids,
|
||||
"created_project_id": _safe_int(execution_result.get("project_id")) if intent.name == INTENT_PROJECT_CREATE else None,
|
||||
"auto_tasks": execution_result.get("auto_tasks") if isinstance(execution_result.get("auto_tasks"), list) else execution_result.get("tasks", []),
|
||||
"ai_trace_id": f"ai-pmo-{int(work_item.id)}",
|
||||
"results": [
|
||||
{
|
||||
"work_item_id": int(work_item.id),
|
||||
"state": work_item.state,
|
||||
"intent": intent.name,
|
||||
"route_status": "queued_for_decision" if work_item.state == STATE_WAITING_MANUAL else "auto_done",
|
||||
}
|
||||
],
|
||||
"intent": intent.name,
|
||||
"workflow_route": str(work_item.intent or execution_result.get("workflow_route") or "").strip() or None,
|
||||
"direct_reply_required": bool(direct_reply_required),
|
||||
"direct_reply_text": direct_reply_text,
|
||||
"reply_template_key": str(render.get("template_key") or "").strip(),
|
||||
"work_item_id": int(work_item.id),
|
||||
"decision_level": work_item.decision_level,
|
||||
"review_summary": work_item.review_summary,
|
||||
"decision_trace": (
|
||||
execution_result.get("decision_trace")
|
||||
if isinstance(execution_result, dict)
|
||||
else ((intent.metadata or {}).get("decision_trace") if isinstance(intent.metadata, dict) else None)
|
||||
),
|
||||
}
|
||||
if intent.name == INTENT_QUERY and isinstance(execution_result.get("rag_result"), dict):
|
||||
result["rag_result"] = execution_result["rag_result"]
|
||||
if intent.name == INTENT_PROJECT_CREATE:
|
||||
raw_payload = execution_result.get("raw_payload") if isinstance(execution_result.get("raw_payload"), dict) else {}
|
||||
result["project"] = raw_payload.get("project")
|
||||
if intent.name == INTENT_SIGNAL_SOURCE_ONBOARD:
|
||||
result["signal_source_workflow"] = execution_result.get("signal_source_workflow")
|
||||
result["signal_source_orchestration"] = execution_result.get("signal_source_orchestration")
|
||||
result["ai_execution"] = execution_result.get("signal_source_orchestration")
|
||||
return result
|
||||
|
||||
@staticmethod
|
||||
def _primary_operation_code(intent_name: str) -> Optional[str]:
|
||||
normalized_route = RouteResolver.normalize_route_key(intent_name)
|
||||
if normalized_route == ROUTE_QUERY_ANSWER:
|
||||
return "query.answer"
|
||||
if normalized_route == ROUTE_PROJECT_CREATE:
|
||||
return "project.create"
|
||||
if normalized_route in {ROUTE_PROJECT_SIGNAL_CREATE, ROUTE_PROJECT_SIGNAL_DISCOVER, ROUTE_PROJECT_SIGNAL_COMMIT}:
|
||||
if normalized_route == ROUTE_PROJECT_SIGNAL_DISCOVER:
|
||||
return ROUTE_PROJECT_SIGNAL_DISCOVER
|
||||
if normalized_route == ROUTE_PROJECT_SIGNAL_COMMIT:
|
||||
return ROUTE_PROJECT_SIGNAL_COMMIT
|
||||
return "signal.enter"
|
||||
if normalized_route == ROUTE_PROJECT_SIGNAL_REFRESH:
|
||||
return ROUTE_PROJECT_SIGNAL_REFRESH
|
||||
if normalized_route == ROUTE_PROJECT_SIGNAL_CANCEL:
|
||||
return ROUTE_PROJECT_SIGNAL_CANCEL
|
||||
if normalized_route == ROUTE_SIGNAL_SOURCE_ONBOARD:
|
||||
return "signal_source.onboard"
|
||||
if normalized_route in {ROUTE_FOLLOWUP_CONFIRM, ROUTE_FOLLOWUP_CONTINUE}:
|
||||
return "work_item.resume"
|
||||
if intent_name == INTENT_QUERY:
|
||||
return "query.answer"
|
||||
if intent_name == INTENT_PROJECT_CREATE:
|
||||
return "project.create"
|
||||
if intent_name == INTENT_SIGNAL_CREATE:
|
||||
return "signal.enter"
|
||||
if intent_name == INTENT_SIGNAL_SOURCE_ONBOARD:
|
||||
return "signal_source.onboard"
|
||||
if intent_name in {INTENT_FOLLOWUP_CONFIRM, INTENT_FOLLOWUP_CONTINUE}:
|
||||
return "work_item.resume"
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _risk_level_for_intent(intent_name: str) -> str:
|
||||
if intent_name in {INTENT_PROJECT_CREATE, INTENT_SIGNAL_SOURCE_ONBOARD}:
|
||||
return "high"
|
||||
if intent_name in {INTENT_SIGNAL_CREATE, INTENT_FOLLOWUP_CONFIRM, INTENT_FOLLOWUP_CONTINUE}:
|
||||
return "medium"
|
||||
return "low"
|
||||
|
||||
@staticmethod
|
||||
def _build_source_message_id(
|
||||
*,
|
||||
source_system: str,
|
||||
source_message_id: Optional[str],
|
||||
channel_ref: Optional[str],
|
||||
sender: str,
|
||||
message_text: str,
|
||||
) -> str:
|
||||
explicit = str(source_message_id or "").strip()
|
||||
if explicit:
|
||||
return explicit[:128]
|
||||
basis = f"{source_system}|{channel_ref or ''}|{sender}|{message_text}"
|
||||
digest = hashlib.sha1(basis.encode("utf-8"), usedforsecurity=False).hexdigest()
|
||||
return f"{source_system}-{digest[:32]}"
|
||||
|
||||
@staticmethod
|
||||
def _normalize_text(text: str) -> str:
|
||||
cleaned = re.sub(r"</?at[^>]*>", " ", str(text or ""), flags=re.IGNORECASE)
|
||||
cleaned = re.sub(r"^@PMFlow\s*", "", cleaned, flags=re.IGNORECASE)
|
||||
cleaned = re.sub(r"\s+", " ", cleaned).strip()
|
||||
return cleaned[:4000]
|
||||
|
||||
@staticmethod
|
||||
def _build_transport_mode_reply(message_text: str) -> Optional[str]:
|
||||
normalized = re.sub(r"\s+", "", str(message_text or "")).lower()
|
||||
has_long_connection = "长连接" in normalized or "longconnection" in normalized
|
||||
has_webhook = "webhook" in normalized
|
||||
if not (has_long_connection and has_webhook):
|
||||
return None
|
||||
if not any(token in normalized for token in TRANSPORT_MODE_CORRECTION_HINTS):
|
||||
return None
|
||||
return "已按长连接模式处理飞书接入,当前不会走 webhook;这条说明不会再被当成信号源类型变更。"
|
||||
|
||||
@staticmethod
|
||||
def _serialize_project(project: Optional[Project]) -> Optional[ProjectSnapshot]:
|
||||
if project is None:
|
||||
return None
|
||||
return {
|
||||
"id": int(project.id),
|
||||
"name": str(project.name or ""),
|
||||
"title": str(project.title or project.name or ""),
|
||||
"description": str(project.description or ""),
|
||||
"status": str(project.status or ""),
|
||||
"owner_id": _safe_int(project.owner_id),
|
||||
}
|
||||
|
||||
|
|
@ -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()
|
||||
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
||||
|
|
@ -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"
|
||||
|
||||
|
|
@ -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(),
|
||||
},
|
||||
)
|
||||
|
|
@ -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
|
||||
|
|
@ -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),
|
||||
}
|
||||
|
||||
|
|
@ -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
|
||||
|
|
@ -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()
|
||||
|
|
@ -0,0 +1,57 @@
|
|||
"""AI PMO kernel runtime composition."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from . import ai_pmo_kernel_common as _common
|
||||
|
||||
for _name, _value in vars(_common).items():
|
||||
if not _name.startswith("__"):
|
||||
globals()[_name] = _value
|
||||
|
||||
from .ai_pmo_kernel_mixins_closure import KernelClosureMixin
|
||||
from .ai_pmo_kernel_mixins_followup import KernelFollowupMixin
|
||||
from .ai_pmo_kernel_mixins_ingress import KernelIngressMixin
|
||||
from .ai_pmo_kernel_mixins_intent import KernelIntentMixin
|
||||
from .ai_pmo_kernel_mixins_world_model import KernelWorldModelMixin
|
||||
|
||||
|
||||
class AIPMOKernelService(
|
||||
KernelIngressMixin,
|
||||
KernelWorldModelMixin,
|
||||
KernelIntentMixin,
|
||||
KernelFollowupMixin,
|
||||
KernelClosureMixin,
|
||||
):
|
||||
"""AI PMO kernel,作为新的统一事实链。"""
|
||||
|
||||
def __init__(self, db: AsyncSession, tenant_id: int = 1) -> None:
|
||||
self.db = db
|
||||
self.tenant_id = int(tenant_id)
|
||||
self._background_session_factory = self._build_background_session_factory(db)
|
||||
self.context_cache = ContextCacheService()
|
||||
self.intent_resolver = IntentResolver()
|
||||
self.route_resolver = RouteResolver()
|
||||
self.decision_engine = DecisionEngine(policy_gate_rules=POLICY_GATE_RULES)
|
||||
self.reply_renderer = ReplyRenderer(
|
||||
state_waiting_manual=STATE_WAITING_MANUAL,
|
||||
state_failed=STATE_FAILED,
|
||||
receipt_phase_action=RECEIPT_PHASE_ACTION,
|
||||
receipt_phase_pending=RECEIPT_PHASE_PENDING,
|
||||
receipt_phase_error=RECEIPT_PHASE_ERROR,
|
||||
)
|
||||
self.executor = TypedExecutor(db, tenant_id=self.tenant_id)
|
||||
self.context_assembler = ContextAssembler(self)
|
||||
self.workflow_registry = WorkflowRegistry(self)
|
||||
self.review_service = AIPMOReviewService(db, tenant_id=self.tenant_id)
|
||||
self.cadence_service = AIPMOCadenceService(db, tenant_id=self.tenant_id)
|
||||
|
||||
@staticmethod
|
||||
def _build_background_session_factory(db: AsyncSession) -> Optional[async_sessionmaker[AsyncSession]]:
|
||||
bind = getattr(db, "bind", None)
|
||||
if bind is None:
|
||||
return None
|
||||
try:
|
||||
return async_sessionmaker(bind=bind, expire_on_commit=False, class_=AsyncSession)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -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,
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
||||
|
|
@ -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
|
||||
|
||||
|
||||
|
|
@ -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)} 条候选信号,"
|
||||
"请回复“确认”提交,回复“换一批”刷新,或回复“取消”结束本次候选。"
|
||||
),
|
||||
}
|
||||
|
||||
|
|
@ -0,0 +1,424 @@
|
|||
"""AI PMO typed executor mixin."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from . import ai_pmo_typed_executor_common as _common
|
||||
|
||||
for _name, _value in vars(_common).items():
|
||||
if not _name.startswith("__"):
|
||||
globals()[_name] = _value
|
||||
|
||||
|
||||
class TypedExecutorExecuteMixin:
|
||||
async def execute(
|
||||
self,
|
||||
*,
|
||||
intent: CanonicalIntent,
|
||||
message_text: str,
|
||||
sender: str,
|
||||
operator_user_id: Optional[int],
|
||||
source_system: str,
|
||||
source_message_id: str,
|
||||
channel_ref: Optional[str] = None,
|
||||
) -> dict[str, Any]:
|
||||
if intent.name == INTENT_QUERY:
|
||||
rag_service = AdaptiveRAGService(self.db, tenant_id=self.tenant_id)
|
||||
try:
|
||||
rag_result = await rag_service.answer_question(
|
||||
question=message_text,
|
||||
asked_by=operator_user_id,
|
||||
asked_via=source_system,
|
||||
project_id=intent.project_id,
|
||||
source_chat_id=source_message_id,
|
||||
conversation_key=source_message_id,
|
||||
action_mode="read",
|
||||
sync_requirement=False,
|
||||
)
|
||||
except Exception as exc:
|
||||
return {
|
||||
"status": "failed_safe",
|
||||
"error_message": self._build_query_error_message(exc),
|
||||
"project_id": intent.project_id,
|
||||
}
|
||||
return {
|
||||
"status": "succeeded",
|
||||
"answer": str(rag_result.get("answer") or "").strip(),
|
||||
"rag_result": rag_result,
|
||||
"project_id": intent.project_id,
|
||||
}
|
||||
|
||||
if intent.name == INTENT_PROJECT_CREATE:
|
||||
if operator_user_id is None:
|
||||
return {
|
||||
"status": "failed_safe",
|
||||
"error_message": "未找到可用项目创建人,当前无法自动创建项目。",
|
||||
}
|
||||
steward = AIStewardService(self.db)
|
||||
try:
|
||||
payload = await steward.create_project_from_natural_language(
|
||||
input_text=message_text,
|
||||
creator_id=int(operator_user_id),
|
||||
tenant_id=self.tenant_id,
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.warning(
|
||||
"ai_pmo_project_create_local_fallback",
|
||||
tenant_id=self.tenant_id,
|
||||
reason=str(exc),
|
||||
)
|
||||
payload = await self._create_project_with_local_fallback(
|
||||
message_text=message_text,
|
||||
operator_user_id=int(operator_user_id),
|
||||
fallback_reason=str(exc),
|
||||
)
|
||||
project = payload.get("project") if isinstance(payload.get("project"), dict) else {}
|
||||
signal = payload.get("signal") if isinstance(payload.get("signal"), dict) else {}
|
||||
return {
|
||||
"status": "succeeded",
|
||||
"project": project,
|
||||
"signal": signal,
|
||||
"project_id": _safe_int(project.get("id")),
|
||||
"signal_id": _safe_int(signal.get("id")),
|
||||
"tasks": payload.get("tasks") if isinstance(payload.get("tasks"), list) else [],
|
||||
"raw_payload": payload,
|
||||
}
|
||||
|
||||
if intent.name == INTENT_SIGNAL_SOURCE_ONBOARD:
|
||||
semantic_payload = self._build_signal_source_semantic_payload(
|
||||
intent=intent,
|
||||
message_text=message_text,
|
||||
)
|
||||
strategic_payload = (
|
||||
semantic_payload.get("strategic_analysis")
|
||||
if isinstance(semantic_payload.get("strategic_analysis"), dict)
|
||||
else {}
|
||||
)
|
||||
source_context = (
|
||||
strategic_payload.get("source_context")
|
||||
if isinstance(strategic_payload.get("source_context"), dict)
|
||||
else {}
|
||||
)
|
||||
source_context["source_message_id"] = source_message_id
|
||||
source_context["channel_ref"] = channel_ref
|
||||
strategic_payload["source_context"] = source_context
|
||||
signal = await self._create_signal(
|
||||
title=str(semantic_payload.get("title") or intent.signal_title or "新增信号源接入请求"),
|
||||
summary=str(semantic_payload.get("summary") or intent.signal_summary or message_text),
|
||||
content=str(semantic_payload.get("content") or message_text),
|
||||
project_id=intent.project_id,
|
||||
source="PMFlow",
|
||||
signal_type="新需求",
|
||||
recommended_action="enter_only",
|
||||
reasoning_bullets=(
|
||||
semantic_payload.get("reasoning_bullets")
|
||||
if isinstance(semantic_payload.get("reasoning_bullets"), list)
|
||||
else None
|
||||
),
|
||||
strategic_analysis=(
|
||||
strategic_payload
|
||||
if isinstance(strategic_payload, dict)
|
||||
else None
|
||||
),
|
||||
)
|
||||
domain_workflow = await self.signal_source_workflow.run(
|
||||
tenant_id=self.tenant_id,
|
||||
project_id=int(intent.project_id or 0),
|
||||
signal_id=int(signal.id),
|
||||
initiating_text=message_text,
|
||||
requested_via=source_system,
|
||||
operator_user_id=operator_user_id,
|
||||
)
|
||||
orchestration = domain_workflow.get("raw_result") if isinstance(domain_workflow.get("raw_result"), dict) else {}
|
||||
return {
|
||||
"status": "succeeded" if str(domain_workflow.get("status") or "").lower() == "completed" else (
|
||||
"pending_manual" if str(domain_workflow.get("status") or "").lower() == "pending_manual" else "failed_safe"
|
||||
),
|
||||
"signal_id": int(signal.id),
|
||||
"project_id": intent.project_id,
|
||||
"signal_source_workflow": domain_workflow,
|
||||
"signal_source_orchestration": orchestration,
|
||||
"auto_tasks": self._extract_auto_tasks(
|
||||
orchestration.get("execution") if isinstance(orchestration.get("execution"), dict) else orchestration.get("openclaw")
|
||||
),
|
||||
}
|
||||
|
||||
if intent.name == INTENT_SIGNAL_CREATE:
|
||||
metadata = intent.metadata if isinstance(intent.metadata, dict) else {}
|
||||
workflow_route = RouteResolver.normalize_route_key(
|
||||
str(metadata.get("workflow_route") or LEGACY_INTENT_ROUTE_MAP.get(INTENT_SIGNAL_CREATE, ROUTE_PROJECT_SIGNAL_CREATE))
|
||||
)
|
||||
if workflow_route == ROUTE_PROJECT_SIGNAL_DISCOVER:
|
||||
return await self.discover_project_signal_candidates(
|
||||
intent=intent,
|
||||
message_text=message_text,
|
||||
source_system=source_system,
|
||||
source_message_id=source_message_id,
|
||||
channel_ref=channel_ref,
|
||||
)
|
||||
requested_count = self._extract_requested_signal_count(
|
||||
message_text=message_text,
|
||||
metadata=metadata,
|
||||
)
|
||||
analysis_result = await self._analyze_signal_batch_with_high_tier_model(
|
||||
intent=intent,
|
||||
message_text=message_text,
|
||||
requested_count=requested_count,
|
||||
)
|
||||
analyzed_signals = analysis_result.get("items") if isinstance(analysis_result.get("items"), list) else []
|
||||
analysis_error_message = str(analysis_result.get("analysis_error") or "").strip()
|
||||
analysis_fallback_used = bool(analysis_result.get("used_fallback"))
|
||||
strict_analysis_required = bool(analysis_result.get("strict_analysis_required"))
|
||||
signal_ids: list[int] = []
|
||||
signal_summaries: list[str] = []
|
||||
for signal_row in analyzed_signals:
|
||||
summary_text = str(signal_row.get("summary") or "")[:300]
|
||||
strategic_analysis = {
|
||||
"source_context": {
|
||||
"source_message_id": source_message_id,
|
||||
"channel_ref": channel_ref,
|
||||
},
|
||||
"ai_analysis": {
|
||||
"summary": summary_text,
|
||||
"intent": INTENT_SIGNAL_CREATE,
|
||||
"requirement_project_scope": (
|
||||
"existing_project" if intent.project_id is not None else "pending_classification"
|
||||
),
|
||||
"requested_count": requested_count,
|
||||
"analysis_method": (
|
||||
"high_tier_batch_model_fallback" if analysis_fallback_used else "high_tier_batch_model"
|
||||
),
|
||||
"fallback_used": analysis_fallback_used,
|
||||
},
|
||||
}
|
||||
signal = await self._create_signal(
|
||||
title=str(signal_row.get("title") or intent.signal_title or "新信号"),
|
||||
summary=summary_text or str(intent.signal_summary or message_text),
|
||||
content=str(signal_row.get("content") or message_text),
|
||||
project_id=intent.project_id,
|
||||
source="PMFlow",
|
||||
signal_type=str(signal_row.get("signal_type") or "新需求"),
|
||||
recommended_action="enter_only",
|
||||
reasoning_bullets=(
|
||||
signal_row.get("reasoning_bullets")
|
||||
if isinstance(signal_row.get("reasoning_bullets"), list)
|
||||
else None
|
||||
),
|
||||
strategic_analysis=strategic_analysis,
|
||||
priority=str(signal_row.get("priority") or "medium"),
|
||||
)
|
||||
signal_ids.append(int(signal.id))
|
||||
signal_summaries.append(summary_text)
|
||||
|
||||
if not signal_ids:
|
||||
return {
|
||||
"status": "failed_safe",
|
||||
"workflow_action": ROUTE_ACTION_CREATE,
|
||||
"workflow_route": ROUTE_PROJECT_SIGNAL_CREATE,
|
||||
"error_message": (
|
||||
analysis_error_message
|
||||
or (
|
||||
"你要求先分析再入库,但当前分析未成功,本次未自动入库。"
|
||||
if strict_analysis_required
|
||||
else "信号分析结果为空,本次未执行入库。"
|
||||
)
|
||||
),
|
||||
"project_id": intent.project_id,
|
||||
"requested_signal_count": requested_count,
|
||||
"analyzed_signal_count": 0,
|
||||
}
|
||||
|
||||
primary_signal_id = signal_ids[0]
|
||||
merged_summary = ";".join([item for item in signal_summaries if item][:2])[:300]
|
||||
return {
|
||||
"status": "succeeded",
|
||||
"workflow_action": ROUTE_ACTION_CREATE,
|
||||
"workflow_route": ROUTE_PROJECT_SIGNAL_CREATE,
|
||||
"signal_id": int(primary_signal_id),
|
||||
"signal_ids": signal_ids,
|
||||
"project_id": intent.project_id,
|
||||
"summary": merged_summary or str(intent.signal_summary or message_text)[:300],
|
||||
"summaries": signal_summaries,
|
||||
"auto_tasks": [],
|
||||
"requested_signal_count": requested_count,
|
||||
"analyzed_signal_count": len(signal_ids),
|
||||
"analysis_fallback_used": analysis_fallback_used,
|
||||
}
|
||||
|
||||
return {
|
||||
"status": "failed_safe",
|
||||
"error_message": "当前意图尚未迁移到 AI PMO kernel,已保留给兼容链路处理。",
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _build_query_error_message(error: Exception) -> str:
|
||||
reason = re.sub(r"\s+", " ", str(error or "")).strip().strip("'\"")
|
||||
reason = re.sub(r"^(runtimeerror|valueerror|exception)\s*:\s*", "", reason, flags=re.IGNORECASE)
|
||||
if len(reason) > 120:
|
||||
reason = f"{reason[:120]}..."
|
||||
marker = "模型调用失败:"
|
||||
while reason.startswith(marker):
|
||||
reason = reason[len(marker):].strip()
|
||||
if not reason:
|
||||
reason = "unknown_error"
|
||||
message = f"{marker}{reason}"
|
||||
return message if "请稍后重试" in message else f"{message}。请稍后重试。"
|
||||
|
||||
async def _create_project_with_local_fallback(
|
||||
self,
|
||||
*,
|
||||
message_text: str,
|
||||
operator_user_id: int,
|
||||
fallback_reason: str,
|
||||
) -> dict[str, Any]:
|
||||
cleaned = re.sub(r"</?at[^>]*>", " ", str(message_text or ""), flags=re.IGNORECASE)
|
||||
cleaned = re.sub(r"@_user_\\d+", " ", cleaned)
|
||||
cleaned = re.sub(r"\\s+", " ", cleaned).strip()
|
||||
title = ""
|
||||
patterns = (
|
||||
r"(?:请|帮我)?(?:新建|新增|创建|建立)(?:一个|个)?项目[::,,\\s《\"]*([^》\",。,;;]{2,80})",
|
||||
r"(?:项目)[::,,\\s《\"]*([^》\",。,;;]{2,80})",
|
||||
)
|
||||
for pattern in patterns:
|
||||
match = re.search(pattern, cleaned, re.IGNORECASE)
|
||||
if not match:
|
||||
continue
|
||||
candidate = re.sub(r"\\s+", " ", str(match.group(1) or "")).strip(" 《》\"',,。::;;")
|
||||
if candidate:
|
||||
title = candidate
|
||||
break
|
||||
if not title:
|
||||
title = f"AI-PMO项目-{_utc_now_naive().strftime('%m%d-%H%M%S')}"
|
||||
|
||||
project = Project(
|
||||
tenant_id=self.tenant_id,
|
||||
name=title[:255],
|
||||
title=title[:255],
|
||||
description=f"本地兜底创建:{cleaned[:500]}",
|
||||
owner_id=int(operator_user_id),
|
||||
status="active",
|
||||
domain_id=None,
|
||||
assigned_role=None,
|
||||
signal_count=0,
|
||||
)
|
||||
self.db.add(project)
|
||||
await self.db.flush()
|
||||
|
||||
signal = await self._create_signal(
|
||||
title=f"{title} 初始信号",
|
||||
summary=f"由 AI PMO 本地兜底创建,原始指令:{cleaned[:120]}",
|
||||
content=f"project_create_fallback_reason={fallback_reason[:240]}",
|
||||
project_id=int(project.id),
|
||||
source="PMFlow",
|
||||
signal_type="新需求",
|
||||
recommended_action="enter_only",
|
||||
)
|
||||
project.signal_count = max(int(project.signal_count or 0), 1)
|
||||
await self.db.flush()
|
||||
|
||||
return {
|
||||
"project": {
|
||||
"id": str(project.id),
|
||||
"name": project.name,
|
||||
"title": project.title,
|
||||
"status": project.status,
|
||||
},
|
||||
"signal": {
|
||||
"id": str(signal.id),
|
||||
"content": signal.content,
|
||||
"impact_score": signal.impact_score,
|
||||
"priority": signal.priority,
|
||||
},
|
||||
"tasks": [],
|
||||
"fallback": {
|
||||
"enabled": True,
|
||||
"reason": fallback_reason[:240],
|
||||
},
|
||||
}
|
||||
|
||||
async def _create_signal(
|
||||
self,
|
||||
*,
|
||||
title: str,
|
||||
summary: str,
|
||||
content: str,
|
||||
project_id: Optional[int],
|
||||
source: str,
|
||||
signal_type: str,
|
||||
recommended_action: str,
|
||||
priority: str = "medium",
|
||||
reasoning_bullets: Optional[list[str]] = None,
|
||||
strategic_analysis: Optional[dict[str, Any]] = None,
|
||||
) -> Signal:
|
||||
normalized_priority = str(priority or "medium").strip().lower()
|
||||
if normalized_priority not in {"high", "medium", "low"}:
|
||||
normalized_priority = "medium"
|
||||
signal = Signal(
|
||||
tenant_id=self.tenant_id,
|
||||
source=source,
|
||||
title=(title or "新信号")[:120],
|
||||
summary=(summary or content or "")[:300],
|
||||
content=(content or summary or "")[:2000],
|
||||
signal_type=signal_type,
|
||||
reasoning=json.dumps(reasoning_bullets or ["由 AI PMO kernel 统一入库。"], ensure_ascii=False),
|
||||
strategic_analysis=(json.dumps(strategic_analysis, ensure_ascii=False) if isinstance(strategic_analysis, dict) else None),
|
||||
confidence=0.78,
|
||||
recommended_action=recommended_action,
|
||||
matched_project_id=int(project_id) if project_id is not None else None,
|
||||
impact_score=0.7,
|
||||
priority=normalized_priority,
|
||||
processed=False,
|
||||
tasks_generated=json.dumps([], ensure_ascii=False),
|
||||
status="待处理",
|
||||
)
|
||||
self.db.add(signal)
|
||||
await self.db.flush()
|
||||
if project_id is not None:
|
||||
exists = (
|
||||
await self.db.execute(
|
||||
select(ProjectSignalLink.id).where(
|
||||
and_(
|
||||
ProjectSignalLink.tenant_id == self.tenant_id,
|
||||
ProjectSignalLink.project_id == int(project_id),
|
||||
ProjectSignalLink.signal_id == int(signal.id),
|
||||
)
|
||||
).limit(1)
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
if exists is None:
|
||||
self.db.add(
|
||||
ProjectSignalLink(
|
||||
tenant_id=self.tenant_id,
|
||||
project_id=int(project_id),
|
||||
signal_id=int(signal.id),
|
||||
link_type="related",
|
||||
relevance_score=1.0,
|
||||
)
|
||||
)
|
||||
await self.db.flush()
|
||||
return signal
|
||||
|
||||
@staticmethod
|
||||
def _extract_auto_tasks(openclaw_payload: Any) -> list[dict[str, Any]]:
|
||||
if not isinstance(openclaw_payload, dict):
|
||||
return []
|
||||
result = openclaw_payload.get("result") if isinstance(openclaw_payload.get("result"), dict) else openclaw_payload
|
||||
execution = result.get("execution") if isinstance(result, dict) and isinstance(result.get("execution"), dict) else {}
|
||||
rows = execution.get("results") if isinstance(execution.get("results"), list) else []
|
||||
items: list[dict[str, Any]] = []
|
||||
for row in rows:
|
||||
if not isinstance(row, dict):
|
||||
continue
|
||||
operation_code = str(row.get("operation_code") or "").strip()
|
||||
if operation_code != "task.create":
|
||||
continue
|
||||
payload = row.get("payload") if isinstance(row.get("payload"), dict) else {}
|
||||
items.append(
|
||||
{
|
||||
"operation_code": operation_code,
|
||||
"status": str(row.get("status") or "").strip(),
|
||||
"entity_id": row.get("entity_id"),
|
||||
"title": payload.get("title"),
|
||||
"task_id": payload.get("task_id"),
|
||||
}
|
||||
)
|
||||
return items
|
||||
|
|
@ -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
|
||||
|
||||
|
|
@ -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
|
||||
|
|
@ -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,11 +252,8 @@ 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:
|
||||
last_error: Exception | None = None
|
||||
for attempt_index in range(attempts_per_function):
|
||||
started_at = time.perf_counter()
|
||||
try:
|
||||
|
|
@ -282,49 +262,37 @@ class AIStewardService:
|
|||
prompt,
|
||||
db=self.db,
|
||||
tenant_id=tenant_id,
|
||||
function_name=candidate_function,
|
||||
function_name=function_name,
|
||||
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
|
||||
|
||||
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_fallback",
|
||||
"ai_steward_project_creation_step_retry",
|
||||
tenant_id=int(tenant_id),
|
||||
function_name=function_name,
|
||||
error="all_attempts_exhausted",
|
||||
attempt=attempt_index + 1,
|
||||
elapsed_seconds=round(time.perf_counter() - started_at, 2),
|
||||
error=str(exc),
|
||||
)
|
||||
return ""
|
||||
|
||||
raise RuntimeError(
|
||||
f"AI 管家项目创建步骤失败:function={function_name}, reason={last_error or 'unknown_error'}"
|
||||
) from last_error
|
||||
|
||||
async def _normalize_project_creation_draft(
|
||||
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]]:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -371,6 +371,7 @@ class FeishuIntegrationService:
|
|||
)
|
||||
|
||||
reply_route = "none"
|
||||
capability_reply = self._build_capability_reply(message_content)
|
||||
if scene == "execute":
|
||||
if self._is_duplicate_capture_result(result):
|
||||
self._emit_state_transition(
|
||||
|
|
@ -384,7 +385,7 @@ class FeishuIntegrationService:
|
|||
state="ROUTED",
|
||||
route="tool_first_execute",
|
||||
)
|
||||
deterministic_reply = str(result.get("direct_reply_text") or "").strip()
|
||||
deterministic_reply = capability_reply or str(result.get("direct_reply_text") or "").strip()
|
||||
if not deterministic_reply:
|
||||
deterministic_reply = self._build_execution_receipt_reply(
|
||||
message_content=message_content,
|
||||
|
|
@ -423,7 +424,18 @@ class FeishuIntegrationService:
|
|||
state="ROUTED",
|
||||
route="qa_consult",
|
||||
)
|
||||
if chat_id and bool(result.get("direct_reply_required")):
|
||||
if chat_id and capability_reply:
|
||||
await self._safe_reply_text(
|
||||
chat_id=chat_id,
|
||||
mention_open_id=sender_open_id,
|
||||
mention_name=sender_name,
|
||||
text=capability_reply,
|
||||
trace_id=state_trace_id,
|
||||
source_message_id=message_id,
|
||||
perf_t0=perf_t0,
|
||||
)
|
||||
reply_route = "qa_capability"
|
||||
elif chat_id and bool(result.get("direct_reply_required")):
|
||||
await self._safe_reply_text(
|
||||
chat_id=chat_id,
|
||||
mention_open_id=sender_open_id,
|
||||
|
|
@ -696,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,
|
||||
|
|
@ -1024,6 +1041,16 @@ class FeishuIntegrationService:
|
|||
)
|
||||
return any(token in normalized for token in capability_tokens)
|
||||
|
||||
@classmethod
|
||||
def _build_capability_reply(cls, message_content: Optional[str]) -> Optional[str]:
|
||||
if not cls._is_capability_question(message_content):
|
||||
return None
|
||||
return (
|
||||
"我是 PMFlow 的项目协同助手。"
|
||||
"我可以识别需求并入库信号、关联项目和任务、跟进状态并回答项目问题。"
|
||||
"你直接说目标、对象和期望动作即可。"
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _strip_identity_lead(reply_text: str) -> str:
|
||||
text = str(reply_text or "").strip()
|
||||
|
|
@ -1241,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
|
||||
|
|
@ -1272,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,
|
||||
*,
|
||||
|
|
|
|||
|
|
@ -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,11 +1675,165 @@ class FeishuSignalPipelineService:
|
|||
payload: dict[str, Any],
|
||||
trace_id: str,
|
||||
) -> dict[str, Any]:
|
||||
executor = OperationExecutorService(self.db)
|
||||
project_context = None
|
||||
logger.info(
|
||||
"operation_started",
|
||||
tenant_id=self.tenant_id,
|
||||
trace_id=trace_id,
|
||||
operation_code=operation_code,
|
||||
payload=payload,
|
||||
executor="explicit_service",
|
||||
)
|
||||
if operation_code == "project.create":
|
||||
result = await self._commit_project_create(
|
||||
operator_user=operator_user,
|
||||
payload=payload,
|
||||
trace_id=trace_id,
|
||||
)
|
||||
elif operation_code == "task.create":
|
||||
result = await self._commit_task_create(
|
||||
operator_user=operator_user,
|
||||
payload=payload,
|
||||
trace_id=trace_id,
|
||||
)
|
||||
else:
|
||||
raise ValueError("operation_not_supported")
|
||||
execution = self._wrap_committed_operation(operation_code=operation_code, payload=payload, result=result)
|
||||
first = execution["results"][0] if execution.get("results") else {}
|
||||
event_name = "operation_committed" if str(first.get("status") or "") in {"applied", "skipped"} else "operation_failed"
|
||||
logger.info(
|
||||
event_name,
|
||||
tenant_id=self.tenant_id,
|
||||
trace_id=trace_id,
|
||||
operation_code=operation_code,
|
||||
result=first,
|
||||
executor="explicit_service",
|
||||
)
|
||||
return execution
|
||||
|
||||
@staticmethod
|
||||
def _wrap_committed_operation(*, operation_code: str, payload: dict[str, Any], result: dict[str, Any]) -> dict[str, Any]:
|
||||
status = str(result.get("status") or "").strip().lower()
|
||||
return {
|
||||
"dry_run": False,
|
||||
"requested_count": 1,
|
||||
"applied_count": 1 if status == "applied" else 0,
|
||||
"skipped_count": 1 if status == "skipped" else 0,
|
||||
"failed_count": 1 if status == "failed" else 0,
|
||||
"results": [
|
||||
{
|
||||
"index": 1,
|
||||
"operation_code": operation_code,
|
||||
"payload": dict(payload),
|
||||
**result,
|
||||
}
|
||||
],
|
||||
}
|
||||
|
||||
async def _commit_project_create(
|
||||
self,
|
||||
*,
|
||||
operator_user: User,
|
||||
payload: dict[str, Any],
|
||||
trace_id: str,
|
||||
) -> dict[str, Any]:
|
||||
operator_user_id = _safe_int(getattr(operator_user, "id", None))
|
||||
if operator_user_id is None:
|
||||
raise ValueError("operator_not_found")
|
||||
|
||||
title = normalize_project_title_candidate(str(payload.get("title") or ""))
|
||||
if not title:
|
||||
raise ValueError("project title required")
|
||||
description = str(payload.get("description") or "").strip()[:4000] or None
|
||||
|
||||
existing = (
|
||||
await self.db.execute(
|
||||
select(Project).where(
|
||||
and_(
|
||||
Project.tenant_id == self.tenant_id,
|
||||
or_(func.lower(Project.title) == title.lower(), func.lower(Project.name) == title.lower()),
|
||||
Project.status != "inactive",
|
||||
)
|
||||
).limit(1)
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
if existing is not None:
|
||||
return {
|
||||
"status": "skipped",
|
||||
"message": "project_already_exists",
|
||||
"entity_type": "project",
|
||||
"entity_id": str(existing.id),
|
||||
"result": {
|
||||
"project_id": int(existing.id),
|
||||
"title": str(existing.title or existing.name or "").strip(),
|
||||
"status": str(existing.status or "").strip(),
|
||||
},
|
||||
}
|
||||
|
||||
await ensure_project_id_sequence(self.db)
|
||||
project = Project(
|
||||
tenant_id=self.tenant_id,
|
||||
name=title,
|
||||
title=title,
|
||||
description=description,
|
||||
owner_id=operator_user_id,
|
||||
status="active",
|
||||
domain_id=None,
|
||||
assigned_role="ai_engineer",
|
||||
signal_count=0,
|
||||
)
|
||||
self.db.add(project)
|
||||
await self.db.flush()
|
||||
await ensure_project_default_members(
|
||||
self.db,
|
||||
tenant_id=self.tenant_id,
|
||||
project_id=int(project.id),
|
||||
owner_id=int(project.owner_id) if project.owner_id is not None else None,
|
||||
assigned_role=project.assigned_role,
|
||||
)
|
||||
await log_operation(
|
||||
self.db,
|
||||
tenant_id=self.tenant_id,
|
||||
user_id=operator_user_id,
|
||||
project_id=int(project.id),
|
||||
entity_type="project",
|
||||
entity_id=str(project.id),
|
||||
action="project.create",
|
||||
before_data=None,
|
||||
after_data={
|
||||
"title": project.title,
|
||||
"status": project.status,
|
||||
"owner_id": int(project.owner_id) if project.owner_id is not None else None,
|
||||
},
|
||||
remark=trace_id[:1000],
|
||||
)
|
||||
await self.db.commit()
|
||||
await context_cache_service.invalidate_system_context(tenant_id=self.tenant_id)
|
||||
return {
|
||||
"status": "applied",
|
||||
"message": f"project created #{int(project.id)}",
|
||||
"entity_type": "project",
|
||||
"entity_id": str(project.id),
|
||||
"result": {
|
||||
"project_id": int(project.id),
|
||||
"title": str(project.title or project.name or "").strip(),
|
||||
"status": str(project.status or "").strip(),
|
||||
},
|
||||
}
|
||||
|
||||
async def _commit_task_create(
|
||||
self,
|
||||
*,
|
||||
operator_user: User,
|
||||
payload: dict[str, Any],
|
||||
trace_id: str,
|
||||
) -> dict[str, Any]:
|
||||
operator_user_id = _safe_int(getattr(operator_user, "id", None))
|
||||
if operator_user_id is None:
|
||||
raise ValueError("operator_not_found")
|
||||
project_id = _safe_int(payload.get("project_id"))
|
||||
if operation_code != "project.create" and project_id is not None:
|
||||
project_context = (
|
||||
if project_id is None:
|
||||
raise ValueError("project_context_missing")
|
||||
project = (
|
||||
await self.db.execute(
|
||||
select(Project).where(
|
||||
and_(
|
||||
|
|
@ -1670,42 +1843,73 @@ class FeishuSignalPipelineService:
|
|||
)
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
if project_context is None:
|
||||
if project 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",
|
||||
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)),
|
||||
)
|
||||
execution = await executor.execute_operations(
|
||||
).order_by(TruthNode.updated_at.desc(), TruthNode.id.desc())
|
||||
)
|
||||
).scalars().first()
|
||||
if charter is None:
|
||||
raise ValueError("aligned charter required before task.create")
|
||||
|
||||
task = await persist_task(
|
||||
self.db,
|
||||
tenant_id=self.tenant_id,
|
||||
operator_user=operator_user,
|
||||
project=project_context,
|
||||
operations=[
|
||||
{
|
||||
"operation_code": operation_code,
|
||||
"payload": payload,
|
||||
"reason": "intent_gate",
|
||||
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),
|
||||
},
|
||||
}
|
||||
],
|
||||
dry_run=False,
|
||||
trace_id=trace_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 '未分配'}"
|
||||
)
|
||||
first = execution["results"][0] if execution.get("results") else {}
|
||||
event_name = "operation_committed" if str(first.get("status") or "") in {"applied", "skipped"} else "operation_failed"
|
||||
logger.info(
|
||||
event_name,
|
||||
asyncio.create_task(
|
||||
AdaptiveRAGService.upsert_pmflow_record_async(
|
||||
tenant_id=self.tenant_id,
|
||||
trace_id=trace_id,
|
||||
operation_code=operation_code,
|
||||
result=first,
|
||||
executor="operation_executor",
|
||||
record_type="task",
|
||||
record_id=int(task.id),
|
||||
project_id=int(task.project_id),
|
||||
title=str(task.title or "任务"),
|
||||
content=content,
|
||||
)
|
||||
return execution
|
||||
)
|
||||
except Exception:
|
||||
return
|
||||
|
||||
@staticmethod
|
||||
def _derive_wrong_interpretation_from_recent_item(item: Optional[FeishuItem]) -> str:
|
||||
|
|
@ -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,119 +3731,17 @@ 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(
|
||||
_ = sender, source_chat_id, source_sender_open_id, captured_at, inferred_project_id, projects
|
||||
return await self._build_legacy_command_blocked_capture(
|
||||
message=message,
|
||||
feishu_message_id=feishu_message_id,
|
||||
duration_ms=int((time.perf_counter() - process_started_at) * 1000),
|
||||
reason="当前租户下没有可用操作者,系统未执行写操作。",
|
||||
reason="旧 command-first 规划链路已下线,请改用 GLM 分析后直接调用显式接口。",
|
||||
)
|
||||
|
||||
target_project_id = inferred_project_id or self._guess_matched_project_id_from_text(message, projects)
|
||||
commander = OpenClawCommanderService(self.db)
|
||||
if self._looks_like_project_create_command(message):
|
||||
command_result = await commander.dispatch_global_command(
|
||||
tenant_id=self.tenant_id,
|
||||
operator_user=operator_user,
|
||||
instruction=message,
|
||||
dry_run=False,
|
||||
max_operations=4,
|
||||
response_mode="fast",
|
||||
persist_run=False,
|
||||
)
|
||||
elif target_project_id is not None:
|
||||
command_result = await commander.dispatch_command(
|
||||
tenant_id=self.tenant_id,
|
||||
operator_user=operator_user,
|
||||
project_id=int(target_project_id),
|
||||
instruction=message,
|
||||
dry_run=False,
|
||||
max_operations=6,
|
||||
response_mode="fast",
|
||||
persist_run=False,
|
||||
)
|
||||
else:
|
||||
return await self._build_openclaw_blocked_capture(
|
||||
message=message,
|
||||
feishu_message_id=feishu_message_id,
|
||||
duration_ms=int((time.perf_counter() - process_started_at) * 1000),
|
||||
reason="缺少明确项目上下文。请指明项目名,或在对应项目群内发送该指令。",
|
||||
)
|
||||
|
||||
execution = command_result.get("execution") if isinstance(command_result, dict) else {}
|
||||
requested_count = int((execution or {}).get("requested_count") or 0)
|
||||
if requested_count <= 0 and int(command_result.get("planned_operations") or 0) <= 0:
|
||||
return await self._build_openclaw_blocked_capture(
|
||||
message=message,
|
||||
feishu_message_id=feishu_message_id,
|
||||
duration_ms=int((time.perf_counter() - process_started_at) * 1000),
|
||||
reason="未识别到可安全执行的业务操作,系统未写库。",
|
||||
)
|
||||
|
||||
linked_project_ids = self._collect_command_project_ids(command_result)
|
||||
created_project_id = self._extract_created_project_id(command_result)
|
||||
primary_project_id = created_project_id or (linked_project_ids[0] if linked_project_ids else None)
|
||||
route_status = self._resolve_command_route_status(command_result)
|
||||
item_id = await self._record_command_context_item(
|
||||
message=message,
|
||||
sender=sender,
|
||||
source_chat_id=source_chat_id,
|
||||
source_sender_open_id=source_sender_open_id,
|
||||
feishu_message_id=feishu_message_id,
|
||||
captured_at=captured_at,
|
||||
matched_project_id=primary_project_id,
|
||||
route_status=route_status,
|
||||
command_result=command_result,
|
||||
)
|
||||
reply_text = self._build_openclaw_command_reply(command_result)
|
||||
duration_ms = int((time.perf_counter() - process_started_at) * 1000)
|
||||
await self._write_processing_log(
|
||||
feishu_message_id=feishu_message_id,
|
||||
feishu_item_id=item_id,
|
||||
raw_message=message,
|
||||
ai_raw_output=json.dumps({"command_result": command_result}, ensure_ascii=False)[:120000],
|
||||
project_score=1.0 if primary_project_id is not None else None,
|
||||
signal_confidence=0.0,
|
||||
level="command",
|
||||
routing_decision=ROUTING_OPENCLAW_EXECUTED,
|
||||
routing_reason="识别为明确业务命令,已直接进入 OpenClaw command 层并按真实执行结果回执。",
|
||||
reasoning_bullets=[
|
||||
"命中 command-first 路由,跳过 RAG 问答与信号分流。",
|
||||
f"planner_status={str(command_result.get('planner_status') or '') or 'unknown'}",
|
||||
f"openclaw_status={str(command_result.get('openclaw_status') or '') or 'unknown'}",
|
||||
],
|
||||
processing_status="success",
|
||||
error_message=None,
|
||||
duration_ms=duration_ms,
|
||||
)
|
||||
return {
|
||||
"status": "captured",
|
||||
"classification": "command",
|
||||
"project_scope": "command",
|
||||
"linked_project_ids": linked_project_ids,
|
||||
"signal_id": None,
|
||||
"created_project_id": created_project_id or primary_project_id,
|
||||
"auto_tasks": [],
|
||||
"ai_trace_id": str(command_result.get("trace_id") or ""),
|
||||
"results": [
|
||||
{
|
||||
"item_id": item_id,
|
||||
"level": "command",
|
||||
"project_id": primary_project_id,
|
||||
"signal_id": None,
|
||||
"route_status": route_status,
|
||||
}
|
||||
],
|
||||
"intent": "command",
|
||||
"direct_reply_required": True,
|
||||
"direct_reply_text": reply_text,
|
||||
"command_result": command_result,
|
||||
}
|
||||
|
||||
async def _maybe_answer_project_lookup_question(
|
||||
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")
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -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,
|
||||
}
|
||||
|
|
@ -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():
|
||||
_ = function_name
|
||||
return None
|
||||
try:
|
||||
payload = json.loads(path.read_text(encoding="utf-8"))
|
||||
except Exception:
|
||||
return None
|
||||
rules = payload.get("rules") if isinstance(payload, dict) and isinstance(payload.get("rules"), list) else []
|
||||
indexed: list[tuple[int, int, dict[str, Any]]] = []
|
||||
for idx, row in enumerate(rules):
|
||||
if not isinstance(row, dict):
|
||||
continue
|
||||
if not bool(row.get("enabled", True)):
|
||||
continue
|
||||
if not cls._rule_matches_function(rule=row, function_name=function_name):
|
||||
continue
|
||||
indexed.append((int(row.get("priority") or 0), -idx, row))
|
||||
if not indexed:
|
||||
return None
|
||||
indexed.sort(reverse=True)
|
||||
return dict(indexed[0][2])
|
||||
|
||||
@classmethod
|
||||
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",
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -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]
|
||||
|
|
@ -10,6 +10,8 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any, Optional
|
||||
|
|
@ -41,6 +43,20 @@ def _utc_now_naive() -> datetime:
|
|||
return now_cn_naive()
|
||||
|
||||
|
||||
DDL_BLOCKED_PREFIXES = [
|
||||
"CREATE",
|
||||
"ALTER",
|
||||
"DROP",
|
||||
"TRUNCATE",
|
||||
"RENAME",
|
||||
"COMMENT",
|
||||
"GRANT",
|
||||
"REVOKE",
|
||||
]
|
||||
DDL_GUARDRAIL_POLICY_KEY = "policy:ddl_via_interface_only"
|
||||
DDL_GUARDRAIL_POLICY_NAME = "DDL 只能通过受控接口/迁移流程"
|
||||
|
||||
|
||||
class ProjectKnowledgeGraphService:
|
||||
def __init__(self, db: AsyncSession, *, tenant_id: int) -> None:
|
||||
self.db = db
|
||||
|
|
@ -1093,6 +1109,284 @@ class ProjectKnowledgeGraphService:
|
|||
counters["evidences"] += 1
|
||||
return counters
|
||||
|
||||
@staticmethod
|
||||
def _routing_rule_candidate_paths() -> list[Path]:
|
||||
candidates: list[Path] = []
|
||||
configured = str(os.getenv("OPENCLAW_ROUTING_RULES_PATH", "") or "").strip()
|
||||
if configured:
|
||||
candidates.append(Path(configured).expanduser())
|
||||
|
||||
backend_root = Path(__file__).resolve().parents[2]
|
||||
project_root = Path(__file__).resolve().parents[3]
|
||||
candidates.extend(
|
||||
[
|
||||
project_root / ".openclaw" / "pmflow-routing-rules.json",
|
||||
backend_root / ".openclaw" / "pmflow-routing-rules.json",
|
||||
]
|
||||
)
|
||||
|
||||
deduped: list[Path] = []
|
||||
seen: set[str] = set()
|
||||
for path in candidates:
|
||||
key = str(path)
|
||||
if key in seen:
|
||||
continue
|
||||
seen.add(key)
|
||||
deduped.append(path)
|
||||
return deduped
|
||||
|
||||
@classmethod
|
||||
def _resolve_guardrail_target_models(cls) -> dict[str, dict[str, str]]:
|
||||
specs: dict[str, dict[str, str]] = {
|
||||
"low": {
|
||||
"tier": "low",
|
||||
"provider": str(os.getenv("LLM_ROUTING_LOW_PROVIDER", "glm") or "glm").strip(),
|
||||
"model": str(os.getenv("LLM_ROUTING_LOW_MODEL", "GLM-4.7") or "GLM-4.7").strip(),
|
||||
"api_env": str(os.getenv("LLM_ROUTING_LOW_API_ENV", "GLM_API_KEY") or "GLM_API_KEY").strip(),
|
||||
"source": "env_default",
|
||||
"source_path": "",
|
||||
"rule_name": "",
|
||||
},
|
||||
"high": {
|
||||
"tier": "high",
|
||||
"provider": str(os.getenv("LLM_ROUTING_HIGH_PROVIDER", "openai") or "openai").strip(),
|
||||
"model": str(os.getenv("LLM_ROUTING_HIGH_MODEL", "MiniMax-M2.5") or "MiniMax-M2.5").strip(),
|
||||
"api_env": str(os.getenv("LLM_ROUTING_HIGH_API_ENV", "OPENAI_API_KEY") or "OPENAI_API_KEY").strip(),
|
||||
"source": "env_default",
|
||||
"source_path": "",
|
||||
"rule_name": "",
|
||||
},
|
||||
}
|
||||
|
||||
for path in cls._routing_rule_candidate_paths():
|
||||
try:
|
||||
content = path.read_text(encoding="utf-8")
|
||||
payload = json.loads(content)
|
||||
except Exception:
|
||||
continue
|
||||
rows = payload.get("rules") if isinstance(payload, dict) and isinstance(payload.get("rules"), list) else []
|
||||
if not rows:
|
||||
continue
|
||||
enabled_rows = [row for row in rows if isinstance(row, dict) and bool(row.get("enabled", True))]
|
||||
enabled_rows.sort(key=lambda item: int(item.get("priority") or 0), reverse=True)
|
||||
resolved_any = False
|
||||
for tier in ("low", "high"):
|
||||
for row in enabled_rows:
|
||||
if str(row.get("tier") or "").strip().lower() != tier:
|
||||
continue
|
||||
model = str(row.get("model") or "").strip()
|
||||
if not model:
|
||||
continue
|
||||
specs[tier] = {
|
||||
"tier": tier,
|
||||
"provider": str(row.get("provider") or specs[tier]["provider"]).strip(),
|
||||
"model": model,
|
||||
"api_env": str(row.get("api_env") or specs[tier]["api_env"]).strip(),
|
||||
"source": "routing_rule",
|
||||
"source_path": str(path),
|
||||
"rule_name": str(row.get("name") or "").strip(),
|
||||
}
|
||||
resolved_any = True
|
||||
break
|
||||
if resolved_any and all(str(specs[tier].get("source") or "") == "routing_rule" for tier in ("low", "high")):
|
||||
break
|
||||
return specs
|
||||
|
||||
async def _sync_model_guardrail_entities(self) -> dict[str, Any]:
|
||||
counters = {"models": 0, "policies": 0, "interfaces": 0, "relations": 0, "evidences": 0}
|
||||
model_specs = self._resolve_guardrail_target_models()
|
||||
|
||||
policy_entity = await self.upsert_entity(
|
||||
entity_type="governance_policy",
|
||||
entity_key=DDL_GUARDRAIL_POLICY_KEY,
|
||||
display_name=DDL_GUARDRAIL_POLICY_NAME,
|
||||
payload={
|
||||
"policy_code": "ddl_via_interface_only",
|
||||
"enforcement_level": "hard_block",
|
||||
"blocked_sql_prefixes": DDL_BLOCKED_PREFIXES,
|
||||
"required_execution_path": "api_or_migration_interface",
|
||||
"enforced_by": [
|
||||
"backend/app/database.py",
|
||||
"backend/app/services/llm.py",
|
||||
"backend/app/config/ai_prompts.py",
|
||||
],
|
||||
},
|
||||
source_type="governance",
|
||||
source_ref="backend/app/database.py",
|
||||
)
|
||||
counters["policies"] += 1
|
||||
await self.add_evidence(
|
||||
evidence_type="policy_guardrail",
|
||||
content_excerpt="Runtime DDL is blocked; model planning must route DDL via interface/migration only.",
|
||||
entity_id=int(policy_entity.id),
|
||||
source_type="governance",
|
||||
source_ref="backend/app/database.py",
|
||||
metadata={"blocked_sql_prefixes": DDL_BLOCKED_PREFIXES},
|
||||
)
|
||||
counters["evidences"] += 1
|
||||
|
||||
interface_specs = [
|
||||
{
|
||||
"entity_key": "interface:api:/ai-steward/command/dispatch",
|
||||
"display_name": "POST /ai-steward/command/dispatch",
|
||||
"payload": {"path": "/ai-steward/command/dispatch", "kind": "api"},
|
||||
"source_ref": "backend/app/api/ai_steward.py",
|
||||
},
|
||||
{
|
||||
"entity_key": "interface:migration:alembic",
|
||||
"display_name": "Alembic Migration Interface",
|
||||
"payload": {"path": "alembic", "kind": "migration"},
|
||||
"source_ref": "backend/alembic/versions",
|
||||
},
|
||||
]
|
||||
interface_ids: list[int] = []
|
||||
for item in interface_specs:
|
||||
interface_entity = await self.upsert_entity(
|
||||
entity_type="interface",
|
||||
entity_key=str(item["entity_key"]),
|
||||
display_name=str(item["display_name"]),
|
||||
payload=dict(item["payload"]),
|
||||
source_type="governance",
|
||||
source_ref=str(item["source_ref"]),
|
||||
)
|
||||
counters["interfaces"] += 1
|
||||
interface_ids.append(int(interface_entity.id))
|
||||
rel = await self.upsert_relation(
|
||||
relation_type="policy_enforced_by_interface",
|
||||
subject_entity_id=int(policy_entity.id),
|
||||
object_entity_id=int(interface_entity.id),
|
||||
source_type="governance",
|
||||
source_ref=str(item["source_ref"]),
|
||||
)
|
||||
counters["relations"] += 1
|
||||
await self.add_evidence(
|
||||
evidence_type="policy_guardrail",
|
||||
content_excerpt=f"{DDL_GUARDRAIL_POLICY_NAME} -> {item['display_name']}",
|
||||
relation_id=int(rel.id),
|
||||
source_type="governance",
|
||||
source_ref=str(item["source_ref"]),
|
||||
metadata=dict(item["payload"]),
|
||||
)
|
||||
counters["evidences"] += 1
|
||||
|
||||
for tier in ("low", "high"):
|
||||
spec = model_specs.get(tier) if isinstance(model_specs.get(tier), dict) else {}
|
||||
model_name = str(spec.get("model") or "").strip() or ("GLM-4.7" if tier == "low" else "MiniMax-M2.5")
|
||||
provider = str(spec.get("provider") or "").strip()
|
||||
api_env = str(spec.get("api_env") or "").strip()
|
||||
source = str(spec.get("source") or "env_default").strip()
|
||||
source_ref = str(spec.get("source_path") or spec.get("rule_name") or "llm_routing_env").strip()[:255]
|
||||
entity = await self.upsert_entity(
|
||||
entity_type="ai_model",
|
||||
entity_key=f"ai_model:{tier}:{model_name.lower()[:160]}",
|
||||
display_name=model_name[:255],
|
||||
payload={
|
||||
"tier": tier,
|
||||
"provider": provider,
|
||||
"model": model_name,
|
||||
"api_env": api_env,
|
||||
"source": source,
|
||||
"routing_rule": str(spec.get("rule_name") or "").strip(),
|
||||
},
|
||||
source_type="model_routing",
|
||||
source_ref=source_ref or None,
|
||||
)
|
||||
counters["models"] += 1
|
||||
rel = await self.upsert_relation(
|
||||
relation_type="model_must_follow_policy",
|
||||
subject_entity_id=int(entity.id),
|
||||
object_entity_id=int(policy_entity.id),
|
||||
source_type="governance",
|
||||
source_ref=source_ref or None,
|
||||
payload={"tier": tier, "policy_code": "ddl_via_interface_only"},
|
||||
)
|
||||
counters["relations"] += 1
|
||||
await self.add_evidence(
|
||||
evidence_type="policy_guardrail",
|
||||
content_excerpt=f"{tier} model {model_name} must follow {DDL_GUARDRAIL_POLICY_KEY}",
|
||||
relation_id=int(rel.id),
|
||||
source_type="governance",
|
||||
source_ref=source_ref or None,
|
||||
metadata={"tier": tier, "provider": provider, "model": model_name},
|
||||
)
|
||||
counters["evidences"] += 1
|
||||
|
||||
for interface_id in interface_ids:
|
||||
rel = await self.upsert_relation(
|
||||
relation_type="model_executes_via_interface",
|
||||
subject_entity_id=int(entity.id),
|
||||
object_entity_id=int(interface_id),
|
||||
source_type="governance",
|
||||
source_ref=source_ref or None,
|
||||
payload={"tier": tier},
|
||||
)
|
||||
counters["relations"] += 1
|
||||
await self.add_evidence(
|
||||
evidence_type="policy_guardrail",
|
||||
content_excerpt=f"{tier} model {model_name} executes via controlled interface",
|
||||
relation_id=int(rel.id),
|
||||
source_type="governance",
|
||||
source_ref=source_ref or None,
|
||||
metadata={"tier": tier},
|
||||
)
|
||||
counters["evidences"] += 1
|
||||
|
||||
return counters
|
||||
|
||||
async def sync_model_guardrails_if_due(
|
||||
self,
|
||||
*,
|
||||
interval_hours: int = 6,
|
||||
) -> dict[str, Any]:
|
||||
hours = max(1, int(interval_hours))
|
||||
offset_raw = await self.get_ingest_offset(channel="model_guardrail", offset_key="last_model_guardrail_sync_at")
|
||||
last_sync_at = self._parse_iso_dt(offset_raw)
|
||||
now = _utc_now_naive()
|
||||
if last_sync_at is not None and now - last_sync_at < timedelta(hours=hours):
|
||||
return {
|
||||
"executed": False,
|
||||
"reason": "not_due",
|
||||
"last_sync_at": last_sync_at.isoformat(),
|
||||
"next_due_at": (last_sync_at + timedelta(hours=hours)).isoformat(),
|
||||
}
|
||||
|
||||
payload = await self._sync_model_guardrail_entities()
|
||||
await self.enqueue_ingest_event(
|
||||
channel="model_guardrail",
|
||||
event_key=f"model_guardrail:{_utc_now_naive().strftime('%Y%m%d%H%M%S')}",
|
||||
payload=payload,
|
||||
status="done",
|
||||
)
|
||||
await self.set_ingest_offset(
|
||||
channel="model_guardrail",
|
||||
offset_key="last_model_guardrail_sync_at",
|
||||
offset_value=_utc_now_naive().isoformat(),
|
||||
payload=payload,
|
||||
)
|
||||
await self.db.commit()
|
||||
return {
|
||||
"executed": True,
|
||||
"reason": "due",
|
||||
"payload": payload,
|
||||
}
|
||||
|
||||
async def sync_model_guardrails_now(self) -> dict[str, Any]:
|
||||
payload = await self._sync_model_guardrail_entities()
|
||||
await self.enqueue_ingest_event(
|
||||
channel="model_guardrail",
|
||||
event_key=f"model_guardrail:{_utc_now_naive().strftime('%Y%m%d%H%M%S')}",
|
||||
payload=payload,
|
||||
status="done",
|
||||
)
|
||||
await self.set_ingest_offset(
|
||||
channel="model_guardrail",
|
||||
offset_key="last_model_guardrail_sync_at",
|
||||
offset_value=_utc_now_naive().isoformat(),
|
||||
payload=payload,
|
||||
)
|
||||
await self.db.commit()
|
||||
return payload
|
||||
|
||||
async def sync_feature_catalog_if_due(
|
||||
self,
|
||||
*,
|
||||
|
|
@ -1403,8 +1697,18 @@ class ProjectKnowledgeGraphService:
|
|||
stage_errors["feature_catalog"] = str(exc)[:500]
|
||||
stage_results["feature_catalog"] = {"error": stage_errors["feature_catalog"]}
|
||||
|
||||
try:
|
||||
stage_results["model_guardrails"] = await self.sync_model_guardrails_if_due(
|
||||
interval_hours=feature_scan_hours,
|
||||
)
|
||||
success_count += 1
|
||||
except Exception as exc:
|
||||
await self.db.rollback()
|
||||
stage_errors["model_guardrails"] = str(exc)[:500]
|
||||
stage_results["model_guardrails"] = {"error": stage_errors["model_guardrails"]}
|
||||
|
||||
ended = _utc_now_naive()
|
||||
if success_count == 6:
|
||||
if success_count == 7:
|
||||
final_status = "success"
|
||||
elif success_count == 0:
|
||||
final_status = "failed"
|
||||
|
|
@ -1622,6 +1926,8 @@ class ProjectKnowledgeGraphService:
|
|||
)
|
||||
counters["evidences"] += 1
|
||||
|
||||
counters["model_guardrails"] = await self._sync_model_guardrail_entities()
|
||||
|
||||
await self.enqueue_ingest_event(
|
||||
channel="db_delta",
|
||||
event_key=f"structured_sync:{_utc_now_naive().strftime('%Y%m%d%H%M%S')}",
|
||||
|
|
|
|||
|
|
@ -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] = {}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -322,16 +322,16 @@ class SignalPlannerService:
|
|||
}
|
||||
|
||||
@classmethod
|
||||
async def analyze(
|
||||
async def _analyze_with_session(
|
||||
cls,
|
||||
*,
|
||||
db: AsyncSession,
|
||||
signal_id: int,
|
||||
tenant_id: int,
|
||||
source_message_id: Optional[str] = None,
|
||||
source_chat_id: Optional[str] = None,
|
||||
dedupe_seed: Optional[str] = None,
|
||||
source_message_id: Optional[str],
|
||||
source_chat_id: Optional[str],
|
||||
dedupe_seed: Optional[str],
|
||||
) -> None:
|
||||
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))
|
||||
|
|
@ -381,7 +381,6 @@ class SignalPlannerService:
|
|||
)
|
||||
|
||||
extraction = None
|
||||
fallback_reason = ""
|
||||
try:
|
||||
extraction = await asyncio.wait_for(
|
||||
llm_service.generate_structured_object(
|
||||
|
|
@ -395,24 +394,11 @@ class SignalPlannerService:
|
|||
timeout=PLANNER_MAX_WAIT_SECONDS,
|
||||
)
|
||||
except asyncio.TimeoutError:
|
||||
fallback_reason = f"planner_wait_timeout_{int(PLANNER_MAX_WAIT_SECONDS)}s"
|
||||
raise RuntimeError(f"signal planner timeout after {int(PLANNER_MAX_WAIT_SECONDS)}s") from None
|
||||
|
||||
payload = extraction.payload if extraction is not None and isinstance(extraction.payload, dict) else None
|
||||
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],
|
||||
)
|
||||
raise RuntimeError("signal planner empty payload")
|
||||
|
||||
await SignalStrategicAnalysisService.merge_and_save(
|
||||
db,
|
||||
|
|
@ -447,7 +433,7 @@ class SignalPlannerService:
|
|||
db,
|
||||
tenant_id=int(tenant_id),
|
||||
chat_id=chat_id,
|
||||
text="信号已入库,AI 分析暂时失败,系统将自动重试。",
|
||||
text="信号已入库,但 AI 分析失败,请检查模型配置或稍后手动重试。",
|
||||
source_message_id=source_mid,
|
||||
dedupe_seed=dedupe_seed,
|
||||
)
|
||||
|
|
@ -460,3 +446,36 @@ class SignalPlannerService:
|
|||
signal_id=int(signal_id),
|
||||
error=str(exc)[:1000],
|
||||
)
|
||||
|
||||
@classmethod
|
||||
async def analyze(
|
||||
cls,
|
||||
*,
|
||||
signal_id: int,
|
||||
tenant_id: int,
|
||||
source_message_id: Optional[str] = None,
|
||||
source_chat_id: Optional[str] = None,
|
||||
dedupe_seed: Optional[str] = None,
|
||||
session_factory: Optional[async_sessionmaker[AsyncSession]] = None,
|
||||
) -> None:
|
||||
if session_factory is not None:
|
||||
async with session_factory() as db:
|
||||
await cls._analyze_with_session(
|
||||
db=db,
|
||||
signal_id=signal_id,
|
||||
tenant_id=tenant_id,
|
||||
source_message_id=source_message_id,
|
||||
source_chat_id=source_chat_id,
|
||||
dedupe_seed=dedupe_seed,
|
||||
)
|
||||
return
|
||||
|
||||
async with AsyncSessionLocal() as db:
|
||||
await cls._analyze_with_session(
|
||||
db=db,
|
||||
signal_id=signal_id,
|
||||
tenant_id=tenant_id,
|
||||
source_message_id=source_message_id,
|
||||
source_chat_id=source_chat_id,
|
||||
dedupe_seed=dedupe_seed,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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,71 +917,10 @@ class SignalSourceOrchestrationService:
|
|||
dry_run: bool,
|
||||
response_mode: str,
|
||||
) -> dict[str, Any]:
|
||||
if not instruction:
|
||||
_ = tenant_id, operator_user, project, instruction, dry_run, response_mode
|
||||
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),
|
||||
"reason": "execution_removed",
|
||||
}
|
||||
|
||||
async def _execute_worker_roles(
|
||||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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,27 +368,6 @@ 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,
|
||||
)
|
||||
)
|
||||
.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(
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import subprocess
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Any, Optional
|
||||
|
|
@ -31,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):
|
||||
|
|
@ -53,9 +190,20 @@ class TaskBriefService:
|
|||
return {}
|
||||
return {}
|
||||
|
||||
@classmethod
|
||||
def _project_root(cls) -> Path:
|
||||
for root in cls._project_root_candidates():
|
||||
if (root / "docs" / "architecture").exists():
|
||||
return root
|
||||
return cls._project_root_candidates()[0]
|
||||
|
||||
@staticmethod
|
||||
def _project_root() -> Path:
|
||||
return Path(__file__).resolve().parents[3]
|
||||
def _normalize_template_text(content: str) -> str:
|
||||
return "\n".join(line.rstrip() for line in str(content or "").strip().splitlines()).strip()
|
||||
|
||||
@classmethod
|
||||
def _is_default_template_fallback(cls, content: str) -> bool:
|
||||
return cls._normalize_template_text(content) == cls._normalize_template_text(DEFAULT_TEMPLATE_FALLBACK)
|
||||
|
||||
@classmethod
|
||||
def _load_default_template_content(cls) -> str:
|
||||
|
|
@ -67,6 +215,53 @@ class TaskBriefService:
|
|||
return DEFAULT_TEMPLATE_FALLBACK
|
||||
return DEFAULT_TEMPLATE_FALLBACK
|
||||
|
||||
@classmethod
|
||||
def _resolve_git_ref(cls, ref: str) -> str:
|
||||
try:
|
||||
output = subprocess.check_output(
|
||||
["git", "rev-parse", "--short", ref],
|
||||
cwd=str(cls._project_root()),
|
||||
stderr=subprocess.DEVNULL,
|
||||
text=True,
|
||||
timeout=1.5,
|
||||
).strip()
|
||||
return output or "-"
|
||||
except Exception:
|
||||
return "-"
|
||||
|
||||
@staticmethod
|
||||
def _to_markdown_list(items: list[str], *, empty_text: str) -> str:
|
||||
cleaned = [str(item).strip() for item in items if str(item).strip()]
|
||||
if not cleaned:
|
||||
return f"- {empty_text}"
|
||||
return "\n".join(f"- {item}" for item in cleaned)
|
||||
|
||||
@classmethod
|
||||
def _render_template_content(
|
||||
cls,
|
||||
*,
|
||||
template_content: str,
|
||||
generated_at: str,
|
||||
base_ref: str,
|
||||
head_ref: str,
|
||||
changed_files: list[str],
|
||||
scope_summary: str,
|
||||
risks: list[str],
|
||||
) -> str:
|
||||
content = str(template_content or "").strip() or cls._load_default_template_content()
|
||||
replacements = {
|
||||
"{{GENERATED_AT}}": str(generated_at or "-"),
|
||||
"{{BASE_REF}}": str(base_ref or "-"),
|
||||
"{{HEAD_REF}}": str(head_ref or "-"),
|
||||
"{{CHANGED_FILES}}": cls._to_markdown_list(changed_files, empty_text="(none)"),
|
||||
"{{SCOPE_SUMMARY}}": str(scope_summary or "-"),
|
||||
"{{RISKS}}": cls._to_markdown_list(risks, empty_text="待补充风险评估"),
|
||||
}
|
||||
rendered = content
|
||||
for token, value in replacements.items():
|
||||
rendered = rendered.replace(token, value)
|
||||
return rendered.strip()
|
||||
|
||||
@classmethod
|
||||
def _build_default_template(cls) -> dict[str, Any]:
|
||||
now_text = now_cn_naive().isoformat()
|
||||
|
|
@ -80,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()
|
||||
|
|
@ -225,7 +484,33 @@ 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
|
||||
latest_default_content = cls._load_default_template_content()
|
||||
for item in existing_templates:
|
||||
if str(item.get("id") or "") != DEFAULT_TEMPLATE_ID:
|
||||
continue
|
||||
current_content = str(item.get("content") or "").strip()
|
||||
if current_content and not cls._is_default_template_fallback(current_content):
|
||||
break
|
||||
item["name"] = str(item.get("name") or "").strip() or DEFAULT_TEMPLATE_NAME
|
||||
item["description"] = str(item.get("description") or "").strip() or DEFAULT_TEMPLATE_DESCRIPTION
|
||||
item["content"] = latest_default_content
|
||||
item["updated_at"] = now_cn_naive().isoformat()
|
||||
if item.get("updated_by") is None:
|
||||
item["updated_by"] = 0
|
||||
upgraded_default_template = True
|
||||
break
|
||||
if upgraded_default_template:
|
||||
bundle["templates"] = existing_templates
|
||||
changed = True
|
||||
|
||||
existing_templates, added_presets = cls._ensure_system_templates(existing_templates)
|
||||
if added_presets:
|
||||
bundle["templates"] = existing_templates
|
||||
changed = True
|
||||
|
||||
existing_briefs = [item for item in bundle.get("briefs", []) if isinstance(item, dict)]
|
||||
|
|
@ -355,9 +640,25 @@ class TaskBriefService:
|
|||
|
||||
brief_id = f"brief_{uuid4().hex[:12]}"
|
||||
run_id = f"run_{uuid4().hex[:12]}"
|
||||
content = str((template_obj or {}).get("content") or "").strip()
|
||||
file_lines = [str(path).strip() for path in changed_files if str(path).strip()]
|
||||
summary = str(scope_summary or "").strip() or f"共 {len(file_lines)} 个文件发生变更"
|
||||
risks = (
|
||||
[
|
||||
"跨文件改动可能引发契约/类型不一致,请补充联动核对。",
|
||||
"合入前需完成 build/lint/test 并记录结果。",
|
||||
]
|
||||
if file_lines
|
||||
else ["未提供变更文件列表,风险评估信息不足,请人工补充。"]
|
||||
)
|
||||
content = cls._render_template_content(
|
||||
template_content=str((template_obj or {}).get("content") or "").strip(),
|
||||
generated_at=now_text,
|
||||
base_ref=cls._resolve_git_ref("HEAD~1"),
|
||||
head_ref=cls._resolve_git_ref("HEAD"),
|
||||
changed_files=file_lines,
|
||||
scope_summary=summary,
|
||||
risks=risks,
|
||||
)
|
||||
|
||||
run = {
|
||||
"id": run_id,
|
||||
|
|
|
|||
|
|
@ -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))}")
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
|
|
@ -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()
|
||||
|
|
@ -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()
|
||||
|
|
@ -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")
|
||||
|
|
@ -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()
|
||||
|
|
@ -1,640 +0,0 @@
|
|||
import json
|
||||
import os
|
||||
import unittest
|
||||
from datetime import datetime
|
||||
from tempfile import NamedTemporaryFile
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
from sqlalchemy import and_, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
|
||||
|
||||
from app.api.users import _create_user_with_defaults
|
||||
from app.models import (
|
||||
Base,
|
||||
Budget,
|
||||
Project,
|
||||
ProjectDomain,
|
||||
ProjectSignalLink,
|
||||
Signal,
|
||||
SkillTag,
|
||||
Task,
|
||||
TruthNode,
|
||||
User,
|
||||
UserProject,
|
||||
UserSkillBinding,
|
||||
UserWorkload,
|
||||
WorkItem,
|
||||
)
|
||||
from app.schemas import UserCreate
|
||||
from app.services.ai_pmo_cadence_service import AIPMOCadenceService
|
||||
from app.services.ai_pmo_kernel_service import AIPMOKernelService
|
||||
from app.services.signal_capture import ProactiveSignalCaptureService
|
||||
from app.services.skill_service import recommend_personnel
|
||||
from app.services import workload_service
|
||||
|
||||
|
||||
class AIPMOFullScenarioTest(unittest.IsolatedAsyncioTestCase):
|
||||
async def asyncSetUp(self) -> None:
|
||||
tmp = NamedTemporaryFile(prefix="pmflow-ai-pmo-scenario-", suffix=".db", delete=False)
|
||||
tmp.close()
|
||||
self.db_path = tmp.name
|
||||
self.engine = create_async_engine(f"sqlite+aiosqlite:///{self.db_path}", future=True)
|
||||
self.session_factory = async_sessionmaker(self.engine, expire_on_commit=False, class_=AsyncSession)
|
||||
async with self.engine.begin() as conn:
|
||||
await conn.run_sync(Base.metadata.create_all)
|
||||
|
||||
async with self.session_factory() as db:
|
||||
self.policy_skill_id = await self._create_skill_tag(db, name="政策分析", slug="policy-analysis")
|
||||
self.research_skill_id = await self._create_skill_tag(db, name="行业研究", slug="market-research")
|
||||
self.web_skill_id = await self._create_skill_tag(db, name="爬虫采集", slug="web-crawling")
|
||||
self.graph_skill_id = await self._create_skill_tag(db, name="知识图谱", slug="knowledge-graph")
|
||||
self.pipeline_skill_id = await self._create_skill_tag(db, name="数据管道", slug="data-pipeline")
|
||||
|
||||
boss = User(
|
||||
tenant_id=1,
|
||||
username="boss",
|
||||
email="boss@example.com",
|
||||
password_hash="hash",
|
||||
name="老板",
|
||||
role="BOSS",
|
||||
assigned_domains=[],
|
||||
workload=0,
|
||||
status="active",
|
||||
is_active=True,
|
||||
)
|
||||
db.add(boss)
|
||||
await db.flush()
|
||||
self.boss_id = int(boss.id)
|
||||
|
||||
domain = ProjectDomain(
|
||||
tenant_id=1,
|
||||
name="医药",
|
||||
slug="pharma",
|
||||
description="医药产业情报域",
|
||||
config={},
|
||||
is_active=True,
|
||||
is_default=False,
|
||||
signal_count=0,
|
||||
context_node_count=0,
|
||||
experiment_count=0,
|
||||
owner_id=str(boss.id),
|
||||
)
|
||||
db.add(domain)
|
||||
await db.flush()
|
||||
self.domain_id = int(domain.id)
|
||||
|
||||
project = Project(
|
||||
tenant_id=1,
|
||||
name="医药行业十五五规划布局研究",
|
||||
title="医药行业十五五规划布局研究",
|
||||
description="跟踪医保支付改革、器械招采与创新药方向。",
|
||||
owner_id=int(boss.id),
|
||||
status="active",
|
||||
domain_id=int(domain.id),
|
||||
assigned_role="pm",
|
||||
signal_count=0,
|
||||
)
|
||||
db.add(project)
|
||||
await db.flush()
|
||||
self.base_project_id = int(project.id)
|
||||
|
||||
charter = TruthNode(
|
||||
tenant_id=1,
|
||||
node_type="charter",
|
||||
project_id=int(project.id),
|
||||
signal_id=None,
|
||||
created_by=int(boss.id),
|
||||
title="医药研究项目宪章",
|
||||
content={"success_criteria": ["两周内形成华东医保支付改革观察报告"]},
|
||||
version=1,
|
||||
status="active",
|
||||
)
|
||||
db.add(charter)
|
||||
await db.flush()
|
||||
self.base_charter_id = int(charter.id)
|
||||
|
||||
db.add(
|
||||
Budget(
|
||||
tenant_id=1,
|
||||
budget_type="project",
|
||||
project_id=int(project.id),
|
||||
period="monthly",
|
||||
budget_amount_usd=1500.0,
|
||||
alert_threshold_percent=80.0,
|
||||
hard_limit_enabled=True,
|
||||
current_usage_usd=620.0,
|
||||
current_period_start=datetime(2026, 3, 1),
|
||||
current_period_end=datetime(2026, 3, 31),
|
||||
status="active",
|
||||
override_history={},
|
||||
)
|
||||
)
|
||||
db.add(UserProject(tenant_id=1, user_id=int(boss.id), project_id=int(project.id)))
|
||||
db.add(
|
||||
UserWorkload(
|
||||
tenant_id=1,
|
||||
user_id=int(boss.id),
|
||||
current_assigned_hours=18.0,
|
||||
max_available_hours_per_week=40.0,
|
||||
workload_ratio=0.0,
|
||||
)
|
||||
)
|
||||
await self._bind_skill(db, user_id=int(boss.id), skill_tag_id=self.policy_skill_id, level="advanced", proficiency=86)
|
||||
await self._bind_skill(db, user_id=int(boss.id), skill_tag_id=self.research_skill_id, level="expert", proficiency=92)
|
||||
db.add(
|
||||
Task(
|
||||
tenant_id=1,
|
||||
truth_node_id=int(charter.id),
|
||||
project_id=int(project.id),
|
||||
assignee_id=int(boss.id),
|
||||
ai_recommended_assignees=None,
|
||||
title="拆解华东医保支付改革样本",
|
||||
description="需要比对江苏/浙江 DRG-DIP 改革口径和器械招采变化。",
|
||||
required_skill_tag_ids=[self.policy_skill_id, self.web_skill_id],
|
||||
status="blocked",
|
||||
priority="high",
|
||||
estimated_hours=12.0,
|
||||
actual_hours=0.0,
|
||||
quality_score=None,
|
||||
due_date=datetime(2026, 3, 12),
|
||||
completed_at=None,
|
||||
)
|
||||
)
|
||||
await workload_service.recalculate_user_workload_ratio(db, tenant_id=1, user_id=int(boss.id), commit=False)
|
||||
await db.commit()
|
||||
|
||||
async def asyncTearDown(self) -> None:
|
||||
await self.engine.dispose()
|
||||
if getattr(self, "db_path", None):
|
||||
try:
|
||||
os.remove(self.db_path)
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
|
||||
async def _create_skill_tag(self, db: AsyncSession, *, name: str, slug: str) -> int:
|
||||
row = SkillTag(
|
||||
tenant_id=1,
|
||||
name=name,
|
||||
slug=slug,
|
||||
category="skill",
|
||||
description=f"{name}能力",
|
||||
is_system=False,
|
||||
sort_order=0,
|
||||
)
|
||||
db.add(row)
|
||||
await db.flush()
|
||||
return int(row.id)
|
||||
|
||||
async def _bind_skill(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
*,
|
||||
user_id: int,
|
||||
skill_tag_id: int,
|
||||
level: str,
|
||||
proficiency: int,
|
||||
) -> None:
|
||||
db.add(
|
||||
UserSkillBinding(
|
||||
tenant_id=1,
|
||||
user_id=int(user_id),
|
||||
skill_tag_id=int(skill_tag_id),
|
||||
proficiency_level=level,
|
||||
proficiency=int(proficiency),
|
||||
level=level,
|
||||
years_experience=5.0,
|
||||
quality_score=8.5,
|
||||
efficiency_score=8.0,
|
||||
)
|
||||
)
|
||||
await db.flush()
|
||||
|
||||
async def _set_workload_hours(self, db: AsyncSession, *, user_id: int, assigned_hours: float) -> None:
|
||||
workload = (
|
||||
await db.execute(
|
||||
select(UserWorkload).where(
|
||||
and_(
|
||||
UserWorkload.tenant_id == 1,
|
||||
UserWorkload.user_id == int(user_id),
|
||||
)
|
||||
)
|
||||
)
|
||||
).scalar_one()
|
||||
workload.current_assigned_hours = float(assigned_hours)
|
||||
await workload_service.recalculate_user_workload_ratio(db, tenant_id=1, user_id=int(user_id), commit=False)
|
||||
|
||||
async def test_full_difficult_scenario_covers_feishu_context_project_signal_workflow_and_workload(self) -> None:
|
||||
async with self.session_factory() as db:
|
||||
strategy_user = await _create_user_with_defaults(
|
||||
db,
|
||||
tenant_id=1,
|
||||
user_data=UserCreate(
|
||||
username="hanqing",
|
||||
password="password-1",
|
||||
name="韩青",
|
||||
email="hanqing@example.com",
|
||||
role="ADMIN",
|
||||
assigned_domains=["pharma"],
|
||||
project_ids=[self.base_project_id],
|
||||
),
|
||||
actor_id=self.boss_id,
|
||||
commit=False,
|
||||
)
|
||||
crawler_user = await _create_user_with_defaults(
|
||||
db,
|
||||
tenant_id=1,
|
||||
user_data=UserCreate(
|
||||
username="zhouzhan",
|
||||
password="password-2",
|
||||
name="周湛",
|
||||
email="zhouzhan@example.com",
|
||||
role="BE",
|
||||
assigned_domains=["pharma"],
|
||||
project_ids=[],
|
||||
),
|
||||
actor_id=self.boss_id,
|
||||
commit=False,
|
||||
)
|
||||
graph_user = await _create_user_with_defaults(
|
||||
db,
|
||||
tenant_id=1,
|
||||
user_data=UserCreate(
|
||||
username="guyan",
|
||||
password="password-3",
|
||||
name="顾言",
|
||||
email="guyan@example.com",
|
||||
role="AI",
|
||||
assigned_domains=["pharma"],
|
||||
project_ids=[],
|
||||
),
|
||||
actor_id=self.boss_id,
|
||||
commit=False,
|
||||
)
|
||||
await self._bind_skill(db, user_id=int(strategy_user.id), skill_tag_id=self.policy_skill_id, level="expert", proficiency=93)
|
||||
await self._bind_skill(db, user_id=int(strategy_user.id), skill_tag_id=self.research_skill_id, level="expert", proficiency=90)
|
||||
await self._bind_skill(db, user_id=int(crawler_user.id), skill_tag_id=self.web_skill_id, level="expert", proficiency=95)
|
||||
await self._bind_skill(db, user_id=int(crawler_user.id), skill_tag_id=self.policy_skill_id, level="advanced", proficiency=84)
|
||||
await self._bind_skill(db, user_id=int(crawler_user.id), skill_tag_id=self.pipeline_skill_id, level="advanced", proficiency=88)
|
||||
await self._bind_skill(db, user_id=int(graph_user.id), skill_tag_id=self.graph_skill_id, level="expert", proficiency=91)
|
||||
await self._bind_skill(db, user_id=int(graph_user.id), skill_tag_id=self.pipeline_skill_id, level="advanced", proficiency=82)
|
||||
await self._set_workload_hours(db, user_id=int(strategy_user.id), assigned_hours=28.0)
|
||||
await self._set_workload_hours(db, user_id=int(crawler_user.id), assigned_hours=8.0)
|
||||
await self._set_workload_hours(db, user_id=int(graph_user.id), assigned_hours=10.0)
|
||||
await db.commit()
|
||||
|
||||
kernel = AIPMOKernelService(db, tenant_id=1)
|
||||
capture_service = ProactiveSignalCaptureService(db)
|
||||
|
||||
hard_question = (
|
||||
"项目ID: 1 如果把样本窗口收紧到 2025Q4-2026Q1,只看江苏/浙江的 DRG-DIP 支付、"
|
||||
"三甲院器械招采与设备更新预算,当前状态下最可能拖慢结论闭环的到底是预算、"
|
||||
"blocked task 还是技能缺口?为什么?别给套话,按证据优先级直接判断。"
|
||||
)
|
||||
with (
|
||||
patch(
|
||||
"app.services.ai_pmo_kernel_service.AdaptiveRAGService.answer_question",
|
||||
AsyncMock(
|
||||
return_value={
|
||||
"answer": "优先级 1 是 blocked task,其次是技能缺口,预算暂未触顶。",
|
||||
"sources": [],
|
||||
}
|
||||
),
|
||||
),
|
||||
patch(
|
||||
"app.services.signal_capture.context_cache_service.get_session_messages",
|
||||
AsyncMock(return_value=[]),
|
||||
),
|
||||
patch(
|
||||
"app.services.signal_capture.context_cache_service.append_session_messages",
|
||||
AsyncMock(),
|
||||
) as cache_append_mock,
|
||||
patch(
|
||||
"app.services.signal_capture.AdaptiveRAGService.ingest_feishu_message_async",
|
||||
AsyncMock(),
|
||||
),
|
||||
):
|
||||
qa_result = await capture_service.capture_from_feishu(
|
||||
group_chat_id="oc_complex_case",
|
||||
message_content=hard_question,
|
||||
sender="老板",
|
||||
tenant_id=1,
|
||||
proposer_open_id="ou_boss",
|
||||
captured_at=datetime(2026, 3, 9, 9, 0, 0),
|
||||
feishu_message_id="om-qa-hard-1",
|
||||
chat_type="group",
|
||||
)
|
||||
|
||||
async def create_project_side_effect(*, input_text: str, creator_id: int, tenant_id: int) -> dict:
|
||||
project = Project(
|
||||
tenant_id=int(tenant_id),
|
||||
name="华东医保控费与器械国产替代观察台",
|
||||
title="华东医保控费与器械国产替代观察台",
|
||||
description="聚焦华东 DRG-DIP、器械带量采购和国产替代节奏。",
|
||||
owner_id=int(creator_id),
|
||||
status="active",
|
||||
domain_id=self.domain_id,
|
||||
assigned_role="ai_engineer",
|
||||
signal_count=0,
|
||||
)
|
||||
db.add(project)
|
||||
await db.flush()
|
||||
|
||||
charter = TruthNode(
|
||||
tenant_id=1,
|
||||
node_type="charter",
|
||||
project_id=int(project.id),
|
||||
signal_id=None,
|
||||
created_by=int(creator_id),
|
||||
title="华东医保观察项目宪章",
|
||||
content={"success_criteria": ["10 天内交付策略 memo、政策雷达和竞品跟踪框架"]},
|
||||
version=1,
|
||||
status="active",
|
||||
)
|
||||
db.add(charter)
|
||||
await db.flush()
|
||||
|
||||
bootstrap_signal = Signal(
|
||||
tenant_id=1,
|
||||
source="PMFlow",
|
||||
title="医保控费与器械国产替代立项信号",
|
||||
content=input_text,
|
||||
summary="立项信号",
|
||||
signal_type="新需求",
|
||||
priority="high",
|
||||
impact_score=0.85,
|
||||
matched_project_id=int(project.id),
|
||||
reasoning=json.dumps(["来自高强度情景立项指令"], ensure_ascii=False),
|
||||
confidence=0.92,
|
||||
recommended_action="enter_only",
|
||||
processed=True,
|
||||
tasks_generated=json.dumps([], ensure_ascii=False),
|
||||
status="已处理",
|
||||
)
|
||||
db.add(bootstrap_signal)
|
||||
await db.flush()
|
||||
db.add(
|
||||
ProjectSignalLink(
|
||||
tenant_id=1,
|
||||
project_id=int(project.id),
|
||||
signal_id=int(bootstrap_signal.id),
|
||||
link_type="related",
|
||||
relevance_score=1.0,
|
||||
)
|
||||
)
|
||||
db.add(
|
||||
Budget(
|
||||
tenant_id=1,
|
||||
budget_type="project",
|
||||
project_id=int(project.id),
|
||||
period="monthly",
|
||||
budget_amount_usd=3200.0,
|
||||
alert_threshold_percent=80.0,
|
||||
hard_limit_enabled=True,
|
||||
current_usage_usd=240.0,
|
||||
current_period_start=datetime(2026, 3, 1),
|
||||
current_period_end=datetime(2026, 3, 31),
|
||||
status="active",
|
||||
override_history={},
|
||||
)
|
||||
)
|
||||
db.add(UserProject(tenant_id=1, user_id=int(creator_id), project_id=int(project.id)))
|
||||
task_one = Task(
|
||||
tenant_id=1,
|
||||
truth_node_id=int(charter.id),
|
||||
project_id=int(project.id),
|
||||
assignee_id=None,
|
||||
ai_recommended_assignees=None,
|
||||
title="搭建政策抓取与公告解析管道",
|
||||
description="每天跟踪江苏/浙江/安徽 医保支付改革、招采公告与价格体系拐点。",
|
||||
required_skill_tag_ids=[self.policy_skill_id, self.web_skill_id],
|
||||
status="todo",
|
||||
priority="high",
|
||||
estimated_hours=18.0,
|
||||
actual_hours=0.0,
|
||||
quality_score=None,
|
||||
due_date=datetime(2026, 3, 19),
|
||||
completed_at=None,
|
||||
)
|
||||
task_two = Task(
|
||||
tenant_id=1,
|
||||
truth_node_id=int(charter.id),
|
||||
project_id=int(project.id),
|
||||
assignee_id=None,
|
||||
ai_recommended_assignees=None,
|
||||
title="沉淀器械中标结构知识图谱",
|
||||
description="把区域政策、招采公告和竞品动作串成结构化图谱。",
|
||||
required_skill_tag_ids=[self.graph_skill_id],
|
||||
status="todo",
|
||||
priority="medium",
|
||||
estimated_hours=12.0,
|
||||
actual_hours=0.0,
|
||||
quality_score=None,
|
||||
due_date=datetime(2026, 3, 20),
|
||||
completed_at=None,
|
||||
)
|
||||
db.add_all([task_one, task_two])
|
||||
await db.flush()
|
||||
return {
|
||||
"project": {"id": int(project.id), "title": project.title, "name": project.name},
|
||||
"signal": {"id": int(bootstrap_signal.id), "title": bootstrap_signal.title},
|
||||
"tasks": [
|
||||
{"id": int(task_one.id), "title": task_one.title},
|
||||
{"id": int(task_two.id), "title": task_two.title},
|
||||
],
|
||||
}
|
||||
|
||||
with patch(
|
||||
"app.services.ai_pmo_kernel_service.AIStewardService.create_project_from_natural_language",
|
||||
AsyncMock(side_effect=create_project_side_effect),
|
||||
):
|
||||
project_result = await kernel.process_ingress(
|
||||
source_system="feishu",
|
||||
source_message_id="om-project-hard-1",
|
||||
message_text=(
|
||||
"不要复用现在这个宏观研究盘子,单独立一个项目,名字就叫"
|
||||
"“华东医保控费与器械国产替代观察台”,目标不是写周报,"
|
||||
"而是 10 天内拿出策略 memo、政策雷达和竞品跟踪框架。"
|
||||
),
|
||||
sender="老板",
|
||||
channel_ref="oc_complex_case",
|
||||
source_sender_open_id="ou_boss",
|
||||
)
|
||||
|
||||
new_project_id = int(project_result["created_project_id"])
|
||||
|
||||
signal_result = await kernel.process_ingress(
|
||||
source_system="feishu",
|
||||
source_message_id="om-signal-hard-1",
|
||||
message_text=(
|
||||
f"给项目ID: {new_project_id} 新增一条信号:持续盯江苏、浙江、安徽 2026 年器械带量采购、"
|
||||
"DRG-DIP 支付改革和国产替代中标结构,只要价格体系或准入口径出现拐点就预警,"
|
||||
"不要只抓新闻标题。"
|
||||
),
|
||||
sender="老板",
|
||||
channel_ref="oc_complex_case",
|
||||
source_sender_open_id="ou_boss",
|
||||
project_hint_id=new_project_id,
|
||||
)
|
||||
|
||||
source_result = await kernel.process_ingress(
|
||||
source_system="feishu",
|
||||
source_message_id="om-source-hard-1",
|
||||
message_text=(
|
||||
f"给项目ID: {new_project_id} 接一个复合信号源,不是简单 RSS:每天自动抓医保支付改革、"
|
||||
"器械招采公告、上市公司电话会和 GitHub 开源竞品动态,优先走自动接入。"
|
||||
),
|
||||
sender="老板",
|
||||
channel_ref="oc_complex_case",
|
||||
source_sender_open_id="ou_boss",
|
||||
project_hint_id=new_project_id,
|
||||
)
|
||||
|
||||
db.add_all(
|
||||
[
|
||||
UserProject(tenant_id=1, user_id=int(strategy_user.id), project_id=new_project_id),
|
||||
UserProject(tenant_id=1, user_id=int(crawler_user.id), project_id=new_project_id),
|
||||
UserProject(tenant_id=1, user_id=int(graph_user.id), project_id=new_project_id),
|
||||
]
|
||||
)
|
||||
await db.flush()
|
||||
|
||||
with patch(
|
||||
"app.services.ai_pmo_kernel_service.SignalSourceDomainWorkflow.run",
|
||||
AsyncMock(
|
||||
return_value={
|
||||
"status": "completed",
|
||||
"registry_id": 9,
|
||||
"registry_status": "active",
|
||||
"onboarding_status": "completed",
|
||||
"openclaw_status": "completed",
|
||||
"typed_actions": [{"operation_code": "task.create", "status": "applied"}],
|
||||
"material_action_count": 1,
|
||||
"worker_executions": [{"used_fallback": False}],
|
||||
"pending_workers": [],
|
||||
"next_step": "继续自动执行后续任务",
|
||||
"fallback_used": False,
|
||||
"raw_result": {},
|
||||
}
|
||||
),
|
||||
) as onboarding_run_mock:
|
||||
resume_result = await kernel.process_ingress(
|
||||
source_system="ai_pmo_ui",
|
||||
source_message_id="resume-work-item-hard-1",
|
||||
message_text=f"继续处理 work_item #{int(source_result['work_item_id'])}",
|
||||
sender="老板",
|
||||
channel_ref="ai_pmo_ui",
|
||||
project_hint_id=new_project_id,
|
||||
)
|
||||
|
||||
target_work_item = (
|
||||
await db.execute(
|
||||
select(WorkItem).where(
|
||||
and_(
|
||||
WorkItem.tenant_id == 1,
|
||||
WorkItem.id == int(source_result["work_item_id"]),
|
||||
)
|
||||
)
|
||||
)
|
||||
).scalar_one()
|
||||
|
||||
new_project_tasks = (
|
||||
await db.execute(
|
||||
select(Task)
|
||||
.where(and_(Task.tenant_id == 1, Task.project_id == new_project_id))
|
||||
.order_by(Task.id.asc())
|
||||
)
|
||||
).scalars().all()
|
||||
task_policy_stream = next(task for task in new_project_tasks if "抓取" in task.title)
|
||||
task_graph = next(task for task in new_project_tasks if "知识图谱" in task.title)
|
||||
|
||||
recommendation_result = await recommend_personnel(
|
||||
db=db,
|
||||
tenant_id=1,
|
||||
required_skills=["policy-analysis", "web-crawling"],
|
||||
task_description=task_policy_stream.description,
|
||||
task_id=str(task_policy_stream.id),
|
||||
limit=5,
|
||||
)
|
||||
|
||||
with patch(
|
||||
"app.services.workload_service.llm_service.generate",
|
||||
AsyncMock(
|
||||
return_value=json.dumps(
|
||||
{
|
||||
"proposals": [
|
||||
{
|
||||
"task_id": int(task_policy_stream.id),
|
||||
"new_assignee_id": int(crawler_user.id),
|
||||
"reason": "crawler engineer has exact skill mix and lower load",
|
||||
},
|
||||
{
|
||||
"task_id": int(task_graph.id),
|
||||
"new_assignee_id": int(graph_user.id),
|
||||
"reason": "graph engineer owns ontology and extraction pipeline",
|
||||
},
|
||||
]
|
||||
},
|
||||
ensure_ascii=False,
|
||||
)
|
||||
),
|
||||
):
|
||||
optimize_preview = await workload_service.build_ai_optimize_preview(
|
||||
db,
|
||||
tenant_id=1,
|
||||
project_id=new_project_id,
|
||||
max_moves=5,
|
||||
unassigned_only=True,
|
||||
)
|
||||
|
||||
optimize_result = await workload_service.apply_ai_optimize_plan(
|
||||
db,
|
||||
tenant_id=1,
|
||||
proposals=optimize_preview,
|
||||
)
|
||||
board_rows = await workload_service.get_team_workload_board(db, tenant_id=1, project_id=new_project_id)
|
||||
cadence_service = AIPMOCadenceService(db, tenant_id=1)
|
||||
program_view = await cadence_service.build_program_view()
|
||||
|
||||
self.assertEqual(qa_result["status"], "answered")
|
||||
self.assertIn("blocked task", qa_result["direct_reply_text"])
|
||||
cache_append_mock.assert_awaited_once()
|
||||
self.assertEqual(cache_append_mock.await_args.kwargs["question"], hard_question)
|
||||
|
||||
self.assertEqual(project_result["status"], "captured")
|
||||
self.assertIsNotNone(project_result["created_project_id"])
|
||||
self.assertIn("已立项", project_result["direct_reply_text"])
|
||||
|
||||
self.assertEqual(signal_result["status"], "captured")
|
||||
self.assertEqual(signal_result["linked_project_ids"], [new_project_id])
|
||||
self.assertIsNotNone(signal_result["signal_id"])
|
||||
|
||||
self.assertEqual(source_result["status"], "captured")
|
||||
self.assertIn("技能缺口", source_result["direct_reply_text"])
|
||||
self.assertFalse(bool(source_result["review_summary"]["verified"]))
|
||||
|
||||
self.assertEqual(onboarding_run_mock.await_count, 1)
|
||||
self.assertEqual(resume_result["status"], "answered")
|
||||
self.assertIn("已继续处理", resume_result["direct_reply_text"])
|
||||
self.assertEqual(target_work_item.state, "succeeded")
|
||||
self.assertEqual(target_work_item.execution_result["signal_source_workflow"]["status"], "completed")
|
||||
|
||||
self.assertGreaterEqual(recommendation_result["total_count"], 1)
|
||||
self.assertEqual(recommendation_result["recommendations"][0]["user_name"], "周湛")
|
||||
|
||||
self.assertGreaterEqual(len(optimize_preview), 2)
|
||||
self.assertEqual(optimize_result["applied_count"], 2)
|
||||
|
||||
task_assignee_map = {int(task.id): int(task.assignee_id or 0) for task in new_project_tasks}
|
||||
self.assertEqual(task_assignee_map[int(task_policy_stream.id)], int(crawler_user.id))
|
||||
self.assertEqual(task_assignee_map[int(task_graph.id)], int(graph_user.id))
|
||||
|
||||
board_by_name = {str(row["user_name"]): row for row in board_rows}
|
||||
self.assertIn("周湛", board_by_name)
|
||||
self.assertIn("顾言", board_by_name)
|
||||
self.assertGreater(float(board_by_name["周湛"]["current_assigned_hours"]), 8.0)
|
||||
self.assertGreaterEqual(float(board_by_name["顾言"]["current_assigned_hours"]), 9.5)
|
||||
|
||||
self.assertGreaterEqual(program_view["summary"]["program_count"], 1)
|
||||
self.assertTrue(
|
||||
any(new_project_id in program["project_ids"] for program in program_view["programs"])
|
||||
)
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -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"], [])
|
||||
|
|
@ -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"], "任务超时预警")
|
||||
|
|
@ -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",
|
||||
],
|
||||
)
|
||||
|
|
@ -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 == []
|
||||
|
|
@ -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!")
|
||||
|
|
@ -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)
|
||||
|
|
@ -1,177 +0,0 @@
|
|||
import unittest
|
||||
import asyncio
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
from app.services.feishu_integration import FeishuIntegrationService
|
||||
from app.services.feishu_signal_pipeline import FeishuSignalPipelineService
|
||||
from app.services.signal_capture import ProactiveSignalCaptureService
|
||||
from app.services.context_cache import context_cache_service
|
||||
|
||||
|
||||
class FeishuRoutingReceiptsTest(unittest.TestCase):
|
||||
def test_normalize_incoming_text_strips_bot_mentions_and_quote_noise(self) -> None:
|
||||
text = FeishuSignalPipelineService._normalize_incoming_text(
|
||||
"@PMFlow\n回复:\n> 上一条消息\n[附带图片]\n麻烦看下统一通知入口"
|
||||
)
|
||||
|
||||
self.assertEqual(text, "麻烦看下统一通知入口")
|
||||
|
||||
def test_pending_receipt_prefers_clarification_message(self) -> None:
|
||||
reply = FeishuIntegrationService._build_execution_receipt_reply(
|
||||
message_content="@PMFlow 继续处理",
|
||||
capture_result={
|
||||
"status": "captured",
|
||||
"results": [{"route_status": "queued_for_decision"}],
|
||||
"clarification_message": "当前群最近同时涉及项目#12、#18,请明确归属项目。",
|
||||
},
|
||||
)
|
||||
|
||||
self.assertEqual(reply, "当前群最近同时涉及项目#12、#18,请明确归属项目。")
|
||||
|
||||
def test_failed_receipt_uses_controlled_executor_message(self) -> None:
|
||||
reply = FeishuIntegrationService._build_execution_receipt_reply(
|
||||
message_content="@PMFlow 创建任务",
|
||||
capture_result={
|
||||
"status": "captured",
|
||||
"results": [{"route_status": "failed"}],
|
||||
},
|
||||
)
|
||||
|
||||
self.assertEqual(reply, "未执行:受控执行器拒绝了这次动作,请补充缺失字段或检查项目上下文。")
|
||||
|
||||
def test_followup_confirmation_reply_uses_real_db_ids(self) -> None:
|
||||
service = FeishuSignalPipelineService(db=None, tenant_id=1) # type: ignore[arg-type]
|
||||
|
||||
reply = service._build_followup_confirmation_reply(
|
||||
target_item=SimpleNamespace(id=23, level="signal"),
|
||||
confirm_result={"signal_id": "91", "project_id": "12"},
|
||||
)
|
||||
|
||||
self.assertEqual(reply, "已继续处理:信号#91 已确认并关联项目#12。")
|
||||
|
||||
def test_provider_error_guard_blocks_aborted_error_text(self) -> None:
|
||||
self.assertTrue(FeishuIntegrationService._looks_like_provider_error("Request was aborted."))
|
||||
|
||||
def test_normalize_outgoing_reply_text_replaces_provider_error(self) -> None:
|
||||
self.assertEqual(
|
||||
FeishuIntegrationService._normalize_outgoing_reply_text("Request was aborted."),
|
||||
"系统处理中出现异常,本次未执行。请稍后重试,或补充更明确的目标、对象和动作。",
|
||||
)
|
||||
|
||||
def test_pipeline_decorate_capture_result_sanitizes_provider_error_reply(self) -> None:
|
||||
result = FeishuSignalPipelineService._decorate_capture_result(
|
||||
{
|
||||
"status": "answered",
|
||||
"classification": "question",
|
||||
"direct_reply_text": "Request was aborted.",
|
||||
},
|
||||
chat_type="group",
|
||||
normalized_text="@PMFlow 现在状态如何",
|
||||
route="group.qa_direct",
|
||||
db_mutations=[],
|
||||
)
|
||||
|
||||
self.assertEqual(
|
||||
result["direct_reply_text"],
|
||||
"系统处理中出现异常,本次未执行。请稍后重试,或补充更明确的目标、对象和动作。",
|
||||
)
|
||||
|
||||
def test_handle_message_receive_skips_reply_for_duplicate_event(self) -> None:
|
||||
service = FeishuIntegrationService(db=None) # type: ignore[arg-type]
|
||||
event = {
|
||||
"message": {
|
||||
"message_id": "om_duplicate",
|
||||
"message_type": "text",
|
||||
"chat_id": "oc_test",
|
||||
"chat_type": "group",
|
||||
"content": '{"text":"@PMFlow 帮我继续处理"}',
|
||||
},
|
||||
"chat": {
|
||||
"chat_id": "oc_test",
|
||||
"chat_type": "group",
|
||||
},
|
||||
"sender": {
|
||||
"sender_type": "user",
|
||||
"sender_id": {"open_id": "ou_test"},
|
||||
"sender_name": "林宇航",
|
||||
},
|
||||
"type": "im.message.receive_v1",
|
||||
}
|
||||
|
||||
with (
|
||||
patch.object(service, "_record_ingress_trace", AsyncMock()),
|
||||
patch.object(service, "_record_message_trace", AsyncMock()),
|
||||
patch.object(service, "_resolve_sender_display_name", AsyncMock(return_value="林宇航")),
|
||||
patch.object(service, "_safe_reply_text", AsyncMock()) as reply_mock,
|
||||
patch(
|
||||
"app.services.feishu_integration.ProactiveSignalCaptureService.capture_from_feishu",
|
||||
AsyncMock(
|
||||
return_value={
|
||||
"status": "ignored",
|
||||
"reason": "duplicate_filtered",
|
||||
"classification": "duplicate",
|
||||
"direct_reply_required": True,
|
||||
"direct_reply_text": "Request was aborted.",
|
||||
}
|
||||
),
|
||||
),
|
||||
):
|
||||
asyncio.run(service._handle_message_receive(event))
|
||||
|
||||
reply_mock.assert_not_awaited()
|
||||
|
||||
def test_sync_feishu_recent_cache_backfills_missing_rag_turn(self) -> None:
|
||||
service = ProactiveSignalCaptureService(db=None) # type: ignore[arg-type]
|
||||
|
||||
with (
|
||||
patch.object(context_cache_service, "get_session_messages", AsyncMock(return_value=[])),
|
||||
patch.object(context_cache_service, "append_session_messages", AsyncMock()) as append_mock,
|
||||
):
|
||||
asyncio.run(
|
||||
service._sync_feishu_recent_cache(
|
||||
tenant_id=1,
|
||||
group_chat_id="oc_test",
|
||||
sender="林宇航",
|
||||
sender_open_id="ou_test",
|
||||
message="目前医药方向值得关注什么?",
|
||||
pipeline_result={
|
||||
"rag_result": {"answer": "可关注创新药、器械和公共卫生。"},
|
||||
"direct_reply_text": "可关注创新药、器械和公共卫生。",
|
||||
},
|
||||
)
|
||||
)
|
||||
|
||||
append_mock.assert_awaited_once()
|
||||
|
||||
def test_sync_feishu_recent_cache_skips_duplicate_turn(self) -> None:
|
||||
service = ProactiveSignalCaptureService(db=None) # type: ignore[arg-type]
|
||||
|
||||
with (
|
||||
patch.object(
|
||||
context_cache_service,
|
||||
"get_session_messages",
|
||||
AsyncMock(
|
||||
return_value=[
|
||||
{"role": "user", "content": "目前医药方向值得关注什么?"},
|
||||
{"role": "assistant", "content": "可关注创新药、器械和公共卫生。"},
|
||||
]
|
||||
),
|
||||
),
|
||||
patch.object(context_cache_service, "append_session_messages", AsyncMock()) as append_mock,
|
||||
):
|
||||
asyncio.run(
|
||||
service._sync_feishu_recent_cache(
|
||||
tenant_id=1,
|
||||
group_chat_id="oc_test",
|
||||
sender="林宇航",
|
||||
sender_open_id="ou_test",
|
||||
message="目前医药方向值得关注什么?",
|
||||
pipeline_result={
|
||||
"rag_result": {"answer": "可关注创新药、器械和公共卫生。"},
|
||||
"direct_reply_text": "可关注创新药、器械和公共卫生。",
|
||||
},
|
||||
)
|
||||
)
|
||||
|
||||
append_mock.assert_not_awaited()
|
||||
|
|
@ -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)
|
||||
|
|
@ -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"
|
||||
|
|
@ -1,207 +0,0 @@
|
|||
import unittest
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
from app.services.llm import ChatCompletionResult, LLMService, StructuredExtractionResult
|
||||
|
||||
|
||||
class LLMStructuredExtractionTest(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.service = LLMService()
|
||||
|
||||
def _extract(self, response: dict, *, function_name: str = "openclaw_command_dispatch") -> StructuredExtractionResult:
|
||||
return self.service._extract_structured_object_from_response(
|
||||
function_name=function_name,
|
||||
response=response,
|
||||
fallback_text="",
|
||||
provider="glm",
|
||||
model="GLM-4.7",
|
||||
)
|
||||
|
||||
def test_extracts_object_from_plain_content(self) -> None:
|
||||
result = self._extract(
|
||||
{
|
||||
"choices": [
|
||||
{
|
||||
"message": {
|
||||
"content": '{"summary":"ok","confidence":0.9,"operations":[]}',
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
)
|
||||
|
||||
self.assertEqual(result.validation, "ok")
|
||||
self.assertEqual(result.payload["summary"], "ok")
|
||||
|
||||
def test_extracts_object_from_typed_chunk_content(self) -> None:
|
||||
result = self._extract(
|
||||
{
|
||||
"choices": [
|
||||
{
|
||||
"message": {
|
||||
"content": [
|
||||
{"type": "output_text", "text": '{"summary":"chunk","confidence":0.8,"operations":[]}'}
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
)
|
||||
|
||||
self.assertEqual(result.validation, "ok")
|
||||
self.assertEqual(result.payload["summary"], "chunk")
|
||||
|
||||
def test_extracts_object_from_tool_call_arguments(self) -> None:
|
||||
result = self._extract(
|
||||
{
|
||||
"choices": [
|
||||
{
|
||||
"message": {
|
||||
"tool_calls": [
|
||||
{
|
||||
"function": {
|
||||
"arguments": '{"summary":"args","confidence":0.7,"operations":[]}'
|
||||
}
|
||||
}
|
||||
],
|
||||
"content": "",
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
)
|
||||
|
||||
self.assertEqual(result.source_field, "tool_calls.function.arguments")
|
||||
self.assertEqual(result.payload["summary"], "args")
|
||||
|
||||
def test_extracts_json_from_reasoning_content_with_prefix_text(self) -> None:
|
||||
result = self._extract(
|
||||
{
|
||||
"choices": [
|
||||
{
|
||||
"message": {
|
||||
"content": "",
|
||||
"reasoning_content": "先分析上下文,再给结果:\n{\"summary\":\"from_reasoning\",\"confidence\":0.66,\"operations\":[]}",
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
)
|
||||
|
||||
self.assertEqual(result.source_field, "message.reasoning_content")
|
||||
self.assertEqual(result.payload["summary"], "from_reasoning")
|
||||
|
||||
def test_returns_json_not_found_when_reasoning_has_only_natural_language(self) -> None:
|
||||
result = self._extract(
|
||||
{
|
||||
"choices": [
|
||||
{
|
||||
"message": {
|
||||
"content": "",
|
||||
"reasoning_content": "我认为还需要更多上下文。",
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
)
|
||||
|
||||
self.assertEqual(result.error_code, "structured_json_not_found")
|
||||
self.assertEqual(result.source_field, "message.reasoning_content")
|
||||
|
||||
def test_unwraps_nested_json_field_once(self) -> None:
|
||||
result = self._extract(
|
||||
{
|
||||
"choices": [
|
||||
{
|
||||
"message": {
|
||||
"content": '{"json":"{\\"summary\\":\\"nested\\",\\"confidence\\":0.55,\\"operations\\":[]}"}',
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
)
|
||||
|
||||
self.assertEqual(result.validation, "ok")
|
||||
self.assertEqual(result.payload["summary"], "nested")
|
||||
|
||||
def test_rejects_top_level_array_for_object_only_function(self) -> None:
|
||||
result = self._extract(
|
||||
{
|
||||
"choices": [
|
||||
{
|
||||
"message": {
|
||||
"content": '[{"summary":"bad"}]',
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
)
|
||||
|
||||
self.assertEqual(result.error_code, "structured_schema_invalid")
|
||||
|
||||
|
||||
class LLMStructuredCacheTest(unittest.IsolatedAsyncioTestCase):
|
||||
async def test_generate_structured_object_uses_cache_after_first_hit(self) -> None:
|
||||
service = LLMService()
|
||||
calls: list[str] = []
|
||||
cache: dict[str, str] = {}
|
||||
|
||||
class _FakeRedis:
|
||||
async def get(self, key: str):
|
||||
return cache.get(key)
|
||||
|
||||
async def setex(self, key: str, _ttl: int, value: str):
|
||||
cache[key] = value
|
||||
|
||||
service._get_redis = AsyncMock(return_value=_FakeRedis())
|
||||
service._resolve_engine_for_call = AsyncMock(
|
||||
return_value={
|
||||
"provider": "glm",
|
||||
"model": "GLM-4.7",
|
||||
"api_key": "fake",
|
||||
"base_url": "http://unit.test",
|
||||
}
|
||||
)
|
||||
service._resolve_active_prompt_profile = AsyncMock(return_value={"role_name": "", "system_prompt": ""})
|
||||
service._log_ai_call = AsyncMock()
|
||||
|
||||
async def _fake_chat_completion_raw(**_: object) -> ChatCompletionResult:
|
||||
calls.append("hit")
|
||||
return ChatCompletionResult(
|
||||
text='{"summary":"cached","confidence":0.9,"operations":[]}',
|
||||
raw_response={
|
||||
"choices": [
|
||||
{
|
||||
"message": {
|
||||
"content": '{"summary":"cached","confidence":0.9,"operations":[]}',
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
)
|
||||
|
||||
service._chat_completion_raw = AsyncMock(side_effect=_fake_chat_completion_raw)
|
||||
|
||||
first = await service.generate_structured_object(
|
||||
"planner prompt",
|
||||
db=object(),
|
||||
tenant_id=1,
|
||||
function_name="openclaw_command_dispatch",
|
||||
response_mode="fast",
|
||||
)
|
||||
second = await service.generate_structured_object(
|
||||
"planner prompt",
|
||||
db=object(),
|
||||
tenant_id=1,
|
||||
function_name="openclaw_command_dispatch",
|
||||
response_mode="fast",
|
||||
)
|
||||
|
||||
self.assertEqual(first.payload["summary"], "cached")
|
||||
self.assertEqual(second.payload["summary"], "cached")
|
||||
self.assertTrue(second.from_cache)
|
||||
self.assertEqual(len(calls), 1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
|
@ -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")
|
||||
|
|
@ -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()
|
||||
|
|
@ -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)
|
||||
|
|
@ -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)]),
|
||||
)
|
||||
|
|
@ -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()
|
||||
|
|
@ -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()
|
||||
|
||||
|
|
@ -1,97 +0,0 @@
|
|||
import os
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
|
||||
|
||||
from app.api.task_briefs import (
|
||||
GenerateTaskBriefRequest,
|
||||
TemplateCreateRequest,
|
||||
create_task_brief_template,
|
||||
generate_task_brief,
|
||||
get_task_brief_contract_sync,
|
||||
list_task_brief_center,
|
||||
list_task_brief_runs,
|
||||
list_task_brief_templates,
|
||||
)
|
||||
from app.models import Base, User
|
||||
|
||||
|
||||
class TaskBriefApiTest(unittest.IsolatedAsyncioTestCase):
|
||||
async def asyncSetUp(self) -> None:
|
||||
tmp = tempfile.NamedTemporaryFile(prefix="pmflow-task-brief-api-", suffix=".db", delete=False)
|
||||
tmp.close()
|
||||
self.db_path = tmp.name
|
||||
self.engine = create_async_engine(f"sqlite+aiosqlite:///{self.db_path}", future=True)
|
||||
self.session_factory = async_sessionmaker(self.engine, expire_on_commit=False, class_=AsyncSession)
|
||||
async with self.engine.begin() as conn:
|
||||
await conn.run_sync(Base.metadata.create_all)
|
||||
|
||||
async def asyncTearDown(self) -> None:
|
||||
await self.engine.dispose()
|
||||
if getattr(self, "db_path", None):
|
||||
try:
|
||||
os.remove(self.db_path)
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
|
||||
async def test_template_generate_and_list(self) -> None:
|
||||
async with self.session_factory() as db:
|
||||
user = User(
|
||||
tenant_id=1,
|
||||
username="brief_admin",
|
||||
email="brief_admin@example.com",
|
||||
password_hash="hash",
|
||||
name="模板管理员",
|
||||
role="ADMIN",
|
||||
status="active",
|
||||
is_active=True,
|
||||
)
|
||||
db.add(user)
|
||||
await db.commit()
|
||||
await db.refresh(user)
|
||||
|
||||
created = await create_task_brief_template(
|
||||
body=TemplateCreateRequest(
|
||||
name="默认增量模板",
|
||||
description="用于 planner 增量任务包",
|
||||
content="# Template Body",
|
||||
),
|
||||
db=db,
|
||||
current_user=user,
|
||||
)
|
||||
self.assertEqual(created["code"], 0)
|
||||
template_id = str(created["data"]["id"])
|
||||
|
||||
templates = await list_task_brief_templates(db=db, current_user=user)
|
||||
self.assertEqual(templates["code"], 0)
|
||||
self.assertGreaterEqual(len(templates["data"]["items"]), 1)
|
||||
|
||||
generated = await generate_task_brief(
|
||||
body=GenerateTaskBriefRequest(
|
||||
title="Signal Planner Increment",
|
||||
template_id=template_id,
|
||||
changed_files=[
|
||||
"backend/app/services/signal_planner_service.py",
|
||||
"frontend/src/pages/ModelRoutingConfigPage.tsx",
|
||||
],
|
||||
scope_summary="后端 Planner + 前端配置页联动",
|
||||
),
|
||||
db=db,
|
||||
current_user=user,
|
||||
)
|
||||
self.assertEqual(generated["code"], 0)
|
||||
self.assertEqual(generated["data"]["run"]["status"], "completed")
|
||||
|
||||
center = await list_task_brief_center(db=db, current_user=user)
|
||||
self.assertEqual(center["code"], 0)
|
||||
self.assertGreaterEqual(len(center["data"]["items"]), 1)
|
||||
|
||||
runs = await list_task_brief_runs(db=db, current_user=user)
|
||||
self.assertEqual(runs["code"], 0)
|
||||
self.assertGreaterEqual(len(runs["data"]["items"]), 1)
|
||||
|
||||
sync_board = await get_task_brief_contract_sync(db=db, current_user=user)
|
||||
self.assertEqual(sync_board["code"], 0)
|
||||
self.assertIn("all_synced", sync_board["data"])
|
||||
self.assertIsInstance(sync_board["data"].get("files"), list)
|
||||
|
|
@ -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",
|
||||
},
|
||||
],
|
||||
)
|
||||
|
|
@ -9,13 +9,6 @@ function unwrap<T>(raw: ApiEnvelope<T> | 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<string, unknown>;
|
||||
idempotency_key?: string;
|
||||
status: "planned" | "applied" | "skipped" | "failed";
|
||||
message: string;
|
||||
entity_type?: string | null;
|
||||
entity_id?: string | null;
|
||||
trace_id?: string | null;
|
||||
}
|
||||
|
||||
export interface OpenClawCommandDispatchResponse {
|
||||
trace_id: string;
|
||||
planner_status: string;
|
||||
project: {
|
||||
id: string;
|
||||
title: string;
|
||||
status: string;
|
||||
};
|
||||
prompt_role: {
|
||||
role_key: string;
|
||||
role_name: string;
|
||||
};
|
||||
instruction: string;
|
||||
dry_run: boolean;
|
||||
summary: string;
|
||||
confidence: number;
|
||||
task_summary: {
|
||||
total: number;
|
||||
active: number;
|
||||
done: number;
|
||||
blocked: number;
|
||||
sampled: number;
|
||||
};
|
||||
member_count: number;
|
||||
planned_operations: number;
|
||||
execution: {
|
||||
dry_run: boolean;
|
||||
requested_count: number;
|
||||
applied_count: number;
|
||||
skipped_count: number;
|
||||
failed_count: number;
|
||||
results: OpenClawCommandExecutionItem[];
|
||||
};
|
||||
}
|
||||
|
||||
export type LLMProvider = "deepseek" | "glm" | "openai" | "ollama";
|
||||
|
||||
export interface LLMProviderPreset {
|
||||
|
|
@ -97,16 +43,124 @@ export interface LLMProviderPresetResponse {
|
|||
providers: Record<LLMProvider, LLMProviderPreset>;
|
||||
}
|
||||
|
||||
export interface LLMEngineSettings {
|
||||
enable_local_kb_qa: boolean;
|
||||
local_kb_qa_scope: "kb_qa_only";
|
||||
}
|
||||
|
||||
export interface LLMEngine {
|
||||
id: string;
|
||||
name: string;
|
||||
provider: LLMProvider;
|
||||
model: string;
|
||||
base_url: string;
|
||||
api_key_masked: string;
|
||||
updated_at?: string;
|
||||
created_at?: string;
|
||||
}
|
||||
|
||||
export interface LLMEngineListResponse {
|
||||
active_engine_id: string | null;
|
||||
engines: LLMEngine[];
|
||||
settings: LLMEngineSettings;
|
||||
}
|
||||
|
||||
export interface LLMEngineUpsertRequest {
|
||||
name: string;
|
||||
provider: LLMProvider;
|
||||
api_key?: string;
|
||||
model?: string;
|
||||
base_url?: string;
|
||||
set_active?: boolean;
|
||||
}
|
||||
|
||||
export interface LLMEngineConnectionTestResponse {
|
||||
ok: boolean;
|
||||
engine_id: string;
|
||||
name: string;
|
||||
provider: LLMProvider;
|
||||
model: string;
|
||||
base_url: string;
|
||||
api_key_masked: string;
|
||||
latency_ms: number;
|
||||
response_preview: string;
|
||||
}
|
||||
|
||||
export const aiStewardApi = {
|
||||
async dispatchCommand(body: OpenClawCommandDispatchRequest): Promise<OpenClawCommandDispatchResponse> {
|
||||
const raw = await apiRequest<ApiEnvelope<OpenClawCommandDispatchResponse>>("/ai-steward/command/dispatch", {
|
||||
async getLlmProviderPresets(): Promise<LLMProviderPresetResponse> {
|
||||
const raw = await apiRequest<ApiEnvelope<LLMProviderPresetResponse>>("/ai-steward/llm/providers");
|
||||
return unwrap(raw);
|
||||
},
|
||||
async listLlmEngines(): Promise<LLMEngineListResponse> {
|
||||
const raw = await apiRequest<ApiEnvelope<LLMEngineListResponse>>("/ai-steward/llm-engines");
|
||||
return unwrap(raw);
|
||||
},
|
||||
async createLlmEngine(body: LLMEngineUpsertRequest): Promise<LLMEngineListResponse & { engine: LLMEngine }> {
|
||||
const raw = await apiRequest<ApiEnvelope<LLMEngineListResponse & { engine: LLMEngine }>>("/ai-steward/llm-engines", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
return unwrap(raw);
|
||||
},
|
||||
async getLlmProviderPresets(): Promise<LLMProviderPresetResponse> {
|
||||
const raw = await apiRequest<ApiEnvelope<LLMProviderPresetResponse>>("/ai-steward/llm/providers");
|
||||
async updateLlmEngine(
|
||||
engineId: string,
|
||||
body: Partial<LLMEngineUpsertRequest>,
|
||||
): Promise<LLMEngineListResponse & { engine: LLMEngine }> {
|
||||
const raw = await apiRequest<ApiEnvelope<LLMEngineListResponse & { engine: LLMEngine }>>(
|
||||
`/ai-steward/llm-engines/${engineId}`,
|
||||
{
|
||||
method: "PUT",
|
||||
body: JSON.stringify(body),
|
||||
}
|
||||
);
|
||||
return unwrap(raw);
|
||||
},
|
||||
async activateLlmEngine(engineId: string): Promise<LLMEngineListResponse & { active_engine_id: string }> {
|
||||
const raw = await apiRequest<ApiEnvelope<LLMEngineListResponse & { active_engine_id: string }>>(
|
||||
`/ai-steward/llm-engines/${engineId}/activate`,
|
||||
{
|
||||
method: "POST",
|
||||
}
|
||||
);
|
||||
return unwrap(raw);
|
||||
},
|
||||
async deleteLlmEngine(
|
||||
engineId: string,
|
||||
): Promise<LLMEngineListResponse & { deleted_engine_id: string; remaining_count: number }> {
|
||||
const raw = await apiRequest<
|
||||
ApiEnvelope<LLMEngineListResponse & { deleted_engine_id: string; remaining_count: number }>
|
||||
>(`/ai-steward/llm-engines/${engineId}`, {
|
||||
method: "DELETE",
|
||||
});
|
||||
return unwrap(raw);
|
||||
},
|
||||
async updateLlmEngineSettings(
|
||||
body: Pick<LLMEngineSettings, "enable_local_kb_qa">,
|
||||
): Promise<LLMEngineListResponse & { settings: LLMEngineSettings }> {
|
||||
const raw = await apiRequest<ApiEnvelope<LLMEngineListResponse & { settings: LLMEngineSettings }>>(
|
||||
"/ai-steward/llm-engines/settings",
|
||||
{
|
||||
method: "PUT",
|
||||
body: JSON.stringify(body),
|
||||
}
|
||||
);
|
||||
return unwrap(raw);
|
||||
},
|
||||
async testActiveLlmEngine(): Promise<LLMEngineConnectionTestResponse> {
|
||||
const raw = await apiRequest<ApiEnvelope<LLMEngineConnectionTestResponse>>("/ai-steward/llm-engines/test-active", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({}),
|
||||
});
|
||||
return unwrap(raw);
|
||||
},
|
||||
async testLlmEngine(engineId: string): Promise<LLMEngineConnectionTestResponse> {
|
||||
const raw = await apiRequest<ApiEnvelope<LLMEngineConnectionTestResponse>>(
|
||||
`/ai-steward/llm-engines/${engineId}/test`,
|
||||
{
|
||||
method: "POST",
|
||||
body: JSON.stringify({}),
|
||||
}
|
||||
);
|
||||
return unwrap(raw);
|
||||
},
|
||||
async repairDirtySignalSources(body: SignalSourceRepairRequest): Promise<SignalSourceRepairResponse> {
|
||||
|
|
|
|||
|
|
@ -13,3 +13,4 @@ export * from './modelCapability'
|
|||
export * from './budget'
|
||||
export * from './recipes'
|
||||
export * from './aiSteward'
|
||||
export * from './knowledgeGraph'
|
||||
|
|
|
|||
|
|
@ -0,0 +1,142 @@
|
|||
import { api } from "@/lib/api";
|
||||
|
||||
type ApiEnvelope<T> = { code: number; message: string; data: T };
|
||||
|
||||
function unwrap<T>(raw: ApiEnvelope<T> | T): T {
|
||||
if (raw && typeof raw === "object" && "data" in (raw as Record<string, unknown>)) {
|
||||
return (raw as ApiEnvelope<T>).data;
|
||||
}
|
||||
return raw as T;
|
||||
}
|
||||
|
||||
export interface KnowledgeGraphOverviewSnapshot {
|
||||
entity_total: number;
|
||||
relation_total: number;
|
||||
evidence_total: number;
|
||||
pending_events: number;
|
||||
latest_sync_run: {
|
||||
id?: number | null;
|
||||
run_type?: string | null;
|
||||
status?: string | null;
|
||||
started_at?: string | null;
|
||||
ended_at?: string | null;
|
||||
} | null;
|
||||
}
|
||||
|
||||
export interface TaskTemplateCoverage {
|
||||
total: number;
|
||||
required_template_ids: string[];
|
||||
present_template_ids: string[];
|
||||
missing_template_ids: string[];
|
||||
required_templates: Array<{
|
||||
id: string;
|
||||
name?: string;
|
||||
description?: string;
|
||||
updated_at?: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
export interface ContractSyncCoverage {
|
||||
all_synced: boolean;
|
||||
missing_files: string[];
|
||||
files: Array<{
|
||||
file: string;
|
||||
exists: boolean;
|
||||
updated_at?: string | null;
|
||||
}>;
|
||||
}
|
||||
|
||||
export interface FunctionModelTemplateBinding {
|
||||
function_name: string;
|
||||
tier: "low" | "high" | string;
|
||||
context_sections: string[];
|
||||
template_ids: string[];
|
||||
model_routing: {
|
||||
provider: string;
|
||||
model: string;
|
||||
tier: string;
|
||||
source: string;
|
||||
api_env: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface KnowledgeGraphCapabilitySummary {
|
||||
marker: string;
|
||||
knowledge_graph: KnowledgeGraphOverviewSnapshot;
|
||||
task_templates: TaskTemplateCoverage;
|
||||
contract_sync: ContractSyncCoverage;
|
||||
function_model_template_matrix: FunctionModelTemplateBinding[];
|
||||
coverage: {
|
||||
template_ready: boolean;
|
||||
contract_ready: boolean;
|
||||
target_function_count: number;
|
||||
};
|
||||
generated_at?: string | null;
|
||||
}
|
||||
|
||||
export interface ContextConsumptionFunctionResult {
|
||||
function_name: string;
|
||||
tier: string;
|
||||
template_ids: string[];
|
||||
routing_snapshot: {
|
||||
provider: string;
|
||||
model: string;
|
||||
tier: string;
|
||||
source: string;
|
||||
};
|
||||
canary: {
|
||||
called: boolean;
|
||||
status: string;
|
||||
error?: string | null;
|
||||
error_code?: string | null;
|
||||
provider?: string | null;
|
||||
model?: string | null;
|
||||
};
|
||||
checks: {
|
||||
marker: boolean;
|
||||
knowledge_graph: boolean;
|
||||
task_template: boolean;
|
||||
contract_sync: boolean;
|
||||
};
|
||||
pass: boolean;
|
||||
log_evidence?: {
|
||||
id: number;
|
||||
status: string;
|
||||
created_at?: string | null;
|
||||
prompt_excerpt: string;
|
||||
} | null;
|
||||
}
|
||||
|
||||
export interface ContextConsumptionTierResult {
|
||||
pass: boolean;
|
||||
functions: ContextConsumptionFunctionResult[];
|
||||
}
|
||||
|
||||
export interface ContextConsumptionVerification {
|
||||
marker: string;
|
||||
run_canary: boolean;
|
||||
probe_id?: string | null;
|
||||
overall_pass: boolean;
|
||||
tiers: {
|
||||
low: ContextConsumptionTierResult;
|
||||
high: ContextConsumptionTierResult;
|
||||
};
|
||||
functions: ContextConsumptionFunctionResult[];
|
||||
}
|
||||
|
||||
export const knowledgeGraphApi = {
|
||||
async getCapabilitySummary(): Promise<KnowledgeGraphCapabilitySummary> {
|
||||
const { data } = await api.get<ApiEnvelope<KnowledgeGraphCapabilitySummary>>(
|
||||
"/ai-steward/knowledge-graph/capability-summary"
|
||||
);
|
||||
return unwrap(data);
|
||||
},
|
||||
|
||||
async verifyContextConsumption(payload?: { run_canary?: boolean }): Promise<ContextConsumptionVerification> {
|
||||
const { data } = await api.post<ApiEnvelope<ContextConsumptionVerification>>(
|
||||
"/ai-steward/knowledge-graph/verify-context-consumption",
|
||||
payload || {}
|
||||
);
|
||||
return unwrap(data);
|
||||
},
|
||||
};
|
||||
|
|
@ -0,0 +1,121 @@
|
|||
import { useEffect, useMemo, useState } from "react";
|
||||
import { Info } from "lucide-react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
|
||||
interface FeatureIntroGuideProps {
|
||||
featureId: string;
|
||||
title: string;
|
||||
purpose: string;
|
||||
whenToUse: string;
|
||||
steps: string[];
|
||||
outputs?: string[];
|
||||
}
|
||||
|
||||
function todayTextLocal(): string {
|
||||
const now = new Date();
|
||||
const y = now.getFullYear();
|
||||
const m = String(now.getMonth() + 1).padStart(2, "0");
|
||||
const d = String(now.getDate()).padStart(2, "0");
|
||||
return `${y}-${m}-${d}`;
|
||||
}
|
||||
|
||||
export function FeatureIntroGuide({
|
||||
featureId,
|
||||
title,
|
||||
purpose,
|
||||
whenToUse,
|
||||
steps,
|
||||
outputs = [],
|
||||
}: FeatureIntroGuideProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const storageKey = useMemo(() => `pmflow:intro-dismiss:${featureId}`, [featureId]);
|
||||
|
||||
useEffect(() => {
|
||||
try {
|
||||
const saved = localStorage.getItem(storageKey);
|
||||
if (saved === todayTextLocal()) {
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
// ignore storage errors
|
||||
}
|
||||
setOpen(true);
|
||||
}, [storageKey]);
|
||||
|
||||
const dismissToday = () => {
|
||||
try {
|
||||
localStorage.setItem(storageKey, todayTextLocal());
|
||||
} catch {
|
||||
// ignore storage errors
|
||||
}
|
||||
setOpen(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-end">
|
||||
<Button variant="outline" size="sm" onClick={() => setOpen(true)}>
|
||||
<Info className="mr-1 h-4 w-4" />
|
||||
查看功能介绍
|
||||
</Button>
|
||||
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogContent className="w-[min(680px,calc(100vw-1rem))] max-w-[calc(100vw-1rem)] sm:max-w-[680px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{title} · 功能介绍</DialogTitle>
|
||||
<DialogDescription>进页面先看这一段,知道“它是干嘛的、怎么用”。</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-3 text-sm text-slate-700 dark:text-slate-300">
|
||||
<div>
|
||||
<p className="font-medium text-slate-900 dark:text-slate-100">这个功能是干嘛的</p>
|
||||
<p className="mt-1">{purpose}</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p className="font-medium text-slate-900 dark:text-slate-100">什么时候用</p>
|
||||
<p className="mt-1">{whenToUse}</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p className="font-medium text-slate-900 dark:text-slate-100">怎么操作(3 步)</p>
|
||||
<ol className="mt-1 list-decimal space-y-1 pl-5">
|
||||
{steps.map((step) => (
|
||||
<li key={step}>{step}</li>
|
||||
))}
|
||||
</ol>
|
||||
</div>
|
||||
|
||||
{outputs.length > 0 ? (
|
||||
<div>
|
||||
<p className="font-medium text-slate-900 dark:text-slate-100">你会看到什么结果</p>
|
||||
<ul className="mt-1 list-disc space-y-1 pl-5">
|
||||
{outputs.map((item) => (
|
||||
<li key={item}>{item}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<DialogFooter className="gap-2 sm:justify-between">
|
||||
<Button variant="outline" onClick={dismissToday}>
|
||||
今天不再提示
|
||||
</Button>
|
||||
<Button className="bg-blue-600 text-white hover:bg-blue-700" onClick={() => setOpen(false)}>
|
||||
我知道了
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,390 +0,0 @@
|
|||
import { useEffect, useMemo, useState } from "react";
|
||||
import {
|
||||
AlertCircle,
|
||||
Bot,
|
||||
CheckCircle2,
|
||||
Loader2,
|
||||
Play,
|
||||
RefreshCcw,
|
||||
ShieldAlert,
|
||||
Wand2,
|
||||
} from "lucide-react";
|
||||
|
||||
import { aiStewardApi, type OpenClawCommandDispatchResponse } from "@/api/aiSteward";
|
||||
import { projectsApi, type ProjectSummary } from "@/api/projects";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { formatProjectNameWithId, toZhStatus } from "@/lib/display";
|
||||
|
||||
const EXAMPLE_COMMANDS = [
|
||||
"把当前未分配的高优先级任务分给最合适的人,并通知相关成员。",
|
||||
"根据当前负载重新平衡本项目任务,先 dry run 给出调整方案。",
|
||||
"检查 blocked 任务,能恢复推进的直接切状态,不能恢复的通知负责人补充信息。",
|
||||
];
|
||||
|
||||
const MANAGER_ROLES = new Set(["BOSS", "ADMIN"]);
|
||||
|
||||
function resultTone(status: string): string {
|
||||
const key = String(status || "").trim().toLowerCase();
|
||||
if (key === "applied") return "bg-green-50 text-green-700 border-green-200 dark:bg-green-900/20 dark:text-green-300 dark:border-green-800";
|
||||
if (key === "planned") return "bg-blue-50 text-blue-700 border-blue-200 dark:bg-blue-900/20 dark:text-blue-300 dark:border-blue-800";
|
||||
if (key === "skipped") return "bg-amber-50 text-amber-700 border-amber-200 dark:bg-amber-900/20 dark:text-amber-300 dark:border-amber-800";
|
||||
return "bg-red-50 text-red-700 border-red-200 dark:bg-red-900/20 dark:text-red-300 dark:border-red-800";
|
||||
}
|
||||
|
||||
function renderOperationLabel(operationCode: string): string {
|
||||
const code = String(operationCode || "").trim().toLowerCase();
|
||||
if (code === "task.create") return "任务创建";
|
||||
if (code === "task.assign") return "任务分配";
|
||||
if (code === "task.prioritize") return "优先级调整";
|
||||
if (code === "task.status_transition") return "状态迁移";
|
||||
if (code === "notification.create") return "通知创建";
|
||||
if (code === "personnel.workload_update") return "工时容量调整";
|
||||
return operationCode || "-";
|
||||
}
|
||||
|
||||
interface OpenClawCommanderPanelProps {
|
||||
currentUserRole?: string | null;
|
||||
}
|
||||
|
||||
export function OpenClawCommanderPanel({ currentUserRole }: OpenClawCommanderPanelProps) {
|
||||
const [projects, setProjects] = useState<ProjectSummary[]>([]);
|
||||
const [projectLoading, setProjectLoading] = useState(false);
|
||||
const [projectId, setProjectId] = useState("");
|
||||
const [instruction, setInstruction] = useState("");
|
||||
const [dryRun, setDryRun] = useState(true);
|
||||
const [maxOperations, setMaxOperations] = useState("6");
|
||||
const [dispatching, setDispatching] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [result, setResult] = useState<OpenClawCommandDispatchResponse | null>(null);
|
||||
|
||||
const canDispatch = MANAGER_ROLES.has(String(currentUserRole || "").trim().toUpperCase());
|
||||
const selectedProject = useMemo(
|
||||
() => projects.find((item) => String(item.id) === String(projectId)) || null,
|
||||
[projectId, projects]
|
||||
);
|
||||
|
||||
const loadProjects = async () => {
|
||||
setProjectLoading(true);
|
||||
try {
|
||||
const payload = await projectsApi.getSummary();
|
||||
const items = payload.projects || [];
|
||||
setProjects(items);
|
||||
if (!projectId && items[0]?.id) {
|
||||
setProjectId(String(items[0].id));
|
||||
}
|
||||
} catch (loadError) {
|
||||
console.error("load commander projects failed", loadError);
|
||||
setError("加载项目列表失败");
|
||||
} finally {
|
||||
setProjectLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!canDispatch) {
|
||||
return;
|
||||
}
|
||||
void loadProjects();
|
||||
}, [canDispatch]);
|
||||
|
||||
const handleDispatch = async () => {
|
||||
const parsedProjectId = Number(projectId);
|
||||
const parsedMaxOperations = Number(maxOperations);
|
||||
|
||||
if (!Number.isInteger(parsedProjectId) || parsedProjectId <= 0) {
|
||||
setError("请选择有效项目");
|
||||
return;
|
||||
}
|
||||
if (!instruction.trim()) {
|
||||
setError("请输入调度指令");
|
||||
return;
|
||||
}
|
||||
if (!Number.isInteger(parsedMaxOperations) || parsedMaxOperations < 1 || parsedMaxOperations > 20) {
|
||||
setError("max operations 仅支持 1-20");
|
||||
return;
|
||||
}
|
||||
|
||||
setDispatching(true);
|
||||
setError(null);
|
||||
try {
|
||||
const payload = await aiStewardApi.dispatchCommand({
|
||||
project_id: parsedProjectId,
|
||||
instruction: instruction.trim(),
|
||||
dry_run: dryRun,
|
||||
max_operations: parsedMaxOperations,
|
||||
});
|
||||
setResult(payload);
|
||||
if (!dryRun) {
|
||||
void loadProjects();
|
||||
}
|
||||
} catch (dispatchError: any) {
|
||||
const text = typeof dispatchError?.message === "string" ? dispatchError.message : "指挥调度失败";
|
||||
setError(text);
|
||||
} finally {
|
||||
setDispatching(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (!canDispatch) {
|
||||
return (
|
||||
<Card className="border-amber-200 dark:border-amber-800">
|
||||
<CardContent className="py-10 text-center space-y-3">
|
||||
<ShieldAlert className="w-8 h-8 mx-auto text-amber-500" />
|
||||
<div className="text-lg font-semibold">Commander 仅开放给管理角色</div>
|
||||
<div className="text-sm text-slate-600 dark:text-slate-400">
|
||||
当前接口要求 `BOSS / ADMIN`,普通成员仍可通过任务看板或 AI 问答拿建议,但不能直接下发执行。
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<Card className="border-slate-200 dark:border-slate-800">
|
||||
<CardHeader>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-11 h-11 rounded-2xl bg-slate-900 text-white dark:bg-slate-100 dark:text-slate-900 flex items-center justify-center">
|
||||
<Bot className="w-5 h-5" />
|
||||
</div>
|
||||
<div>
|
||||
<CardTitle>OpenClaw Commander</CardTitle>
|
||||
<CardDescription>直接下达自然语言指令,让指挥层规划并驱动 AI 管家执行。</CardDescription>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="grid gap-6 lg:grid-cols-[320px_minmax(0,1fr)]">
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label>项目范围</Label>
|
||||
<Button variant="outline" size="sm" onClick={() => void loadProjects()} disabled={projectLoading}>
|
||||
{projectLoading ? <Loader2 className="w-4 h-4 animate-spin" /> : <RefreshCcw className="w-4 h-4" />}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="space-y-2 max-h-[460px] overflow-y-auto pr-1">
|
||||
{projects.length === 0 ? (
|
||||
<div className="rounded-lg border border-dashed border-slate-300 p-4 text-sm text-slate-500 dark:border-slate-700">
|
||||
{projectLoading ? "正在加载项目..." : "暂无可调度项目"}
|
||||
</div>
|
||||
) : (
|
||||
projects.map((project) => {
|
||||
const active = String(project.id) === String(projectId);
|
||||
return (
|
||||
<button
|
||||
key={project.id}
|
||||
type="button"
|
||||
onClick={() => setProjectId(String(project.id))}
|
||||
className={`w-full rounded-xl border p-4 text-left transition-colors ${
|
||||
active
|
||||
? "border-slate-900 bg-slate-900 text-white dark:border-slate-100 dark:bg-slate-100 dark:text-slate-900"
|
||||
: "border-slate-200 bg-white hover:border-slate-300 dark:border-slate-800 dark:bg-slate-950 dark:hover:border-slate-700"
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="font-medium">{formatProjectNameWithId(project)}</div>
|
||||
<Badge variant={active ? "secondary" : "outline"}>{toZhStatus(project.status)}</Badge>
|
||||
</div>
|
||||
<div className={`mt-2 text-xs ${active ? "text-slate-200 dark:text-slate-700" : "text-slate-500 dark:text-slate-400"}`}>
|
||||
任务 {project.total_tasks} / 完成 {project.completed_tasks} / 进行中 {project.in_progress_tasks}
|
||||
</div>
|
||||
{project.description ? (
|
||||
<div className={`mt-2 line-clamp-2 text-xs ${active ? "text-slate-200 dark:text-slate-700" : "text-slate-500 dark:text-slate-400"}`}>
|
||||
{project.description}
|
||||
</div>
|
||||
) : null}
|
||||
</button>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="grid gap-4 md:grid-cols-[minmax(0,1fr)_160px]">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="openclaw-project-id">项目 ID</Label>
|
||||
<Input
|
||||
id="openclaw-project-id"
|
||||
value={projectId}
|
||||
onChange={(event) => setProjectId(event.target.value)}
|
||||
placeholder="例如 1"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="openclaw-max-ops">Max Operations</Label>
|
||||
<Input
|
||||
id="openclaw-max-ops"
|
||||
value={maxOperations}
|
||||
onChange={(event) => setMaxOperations(event.target.value)}
|
||||
inputMode="numeric"
|
||||
placeholder="6"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{selectedProject ? (
|
||||
<div className="rounded-xl border border-slate-200 bg-slate-50 p-4 text-sm dark:border-slate-800 dark:bg-slate-900/60">
|
||||
<div className="font-medium">{formatProjectNameWithId(selectedProject)}</div>
|
||||
<div className="mt-1 text-slate-600 dark:text-slate-400">
|
||||
Active {selectedProject.in_progress_tasks} / Pending {selectedProject.pending_tasks} / Blocked 由执行结果返回。
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="openclaw-instruction">调度指令</Label>
|
||||
<Textarea
|
||||
id="openclaw-instruction"
|
||||
rows={6}
|
||||
value={instruction}
|
||||
onChange={(event) => setInstruction(event.target.value)}
|
||||
placeholder="例如:把当前未分配的高优先级任务分给最合适的人,并给新负责人发通知。"
|
||||
/>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{EXAMPLE_COMMANDS.map((item) => (
|
||||
<button
|
||||
key={item}
|
||||
type="button"
|
||||
className="rounded-full border border-slate-200 px-3 py-1 text-xs text-slate-600 transition-colors hover:border-slate-300 hover:text-slate-900 dark:border-slate-800 dark:text-slate-300 dark:hover:border-slate-700 dark:hover:text-slate-100"
|
||||
onClick={() => setInstruction(item)}
|
||||
>
|
||||
{item}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center justify-between gap-4 rounded-xl border border-slate-200 p-4 dark:border-slate-800">
|
||||
<div className="space-y-1">
|
||||
<div className="text-sm font-medium">Dry Run</div>
|
||||
<div className="text-xs text-slate-500 dark:text-slate-400">
|
||||
开启后只生成计划,不落库执行。
|
||||
</div>
|
||||
</div>
|
||||
<Switch checked={dryRun} onCheckedChange={setDryRun} />
|
||||
</div>
|
||||
|
||||
{error ? (
|
||||
<div className="rounded-xl border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700 dark:border-red-800 dark:bg-red-900/20 dark:text-red-300">
|
||||
<div className="flex items-center gap-2">
|
||||
<AlertCircle className="w-4 h-4" />
|
||||
{error}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<Button onClick={handleDispatch} disabled={dispatching || projectLoading} className="w-full bg-slate-900 hover:bg-slate-800 dark:bg-slate-100 dark:text-slate-900 dark:hover:bg-slate-200">
|
||||
{dispatching ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 w-4 h-4 animate-spin" />
|
||||
Commander 执行中...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Play className="mr-2 w-4 h-4" />
|
||||
{dryRun ? "生成调度计划" : "执行调度指令"}
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{result ? (
|
||||
<Card className="border-slate-200 dark:border-slate-800">
|
||||
<CardHeader>
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<CardTitle className="mr-auto">执行结果</CardTitle>
|
||||
<Badge variant="outline">{result.planner_status}</Badge>
|
||||
<Badge variant="secondary">{result.prompt_role.role_name || result.prompt_role.role_key || "default"}</Badge>
|
||||
<Badge variant={result.dry_run ? "outline" : "default"}>{result.dry_run ? "Dry Run" : "Executed"}</Badge>
|
||||
</div>
|
||||
<CardDescription>{result.summary || "OpenClaw 已完成规划。"}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid gap-3 md:grid-cols-4">
|
||||
<div className="rounded-xl border border-slate-200 p-4 dark:border-slate-800">
|
||||
<div className="text-xs text-slate-500 dark:text-slate-400">Trace</div>
|
||||
<div className="mt-1 break-all text-sm font-medium">{result.trace_id}</div>
|
||||
</div>
|
||||
<div className="rounded-xl border border-slate-200 p-4 dark:border-slate-800">
|
||||
<div className="text-xs text-slate-500 dark:text-slate-400">项目成员</div>
|
||||
<div className="mt-1 text-sm font-medium">{result.member_count}</div>
|
||||
</div>
|
||||
<div className="rounded-xl border border-slate-200 p-4 dark:border-slate-800">
|
||||
<div className="text-xs text-slate-500 dark:text-slate-400">计划操作</div>
|
||||
<div className="mt-1 text-sm font-medium">{result.planned_operations}</div>
|
||||
</div>
|
||||
<div className="rounded-xl border border-slate-200 p-4 dark:border-slate-800">
|
||||
<div className="text-xs text-slate-500 dark:text-slate-400">可信度</div>
|
||||
<div className="mt-1 text-sm font-medium">{Math.round((result.confidence || 0) * 100)}%</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-3 md:grid-cols-4">
|
||||
<div className="rounded-xl border border-green-200 bg-green-50 p-4 dark:border-green-800 dark:bg-green-900/20">
|
||||
<div className="text-xs text-green-700 dark:text-green-300">Applied</div>
|
||||
<div className="mt-1 text-2xl font-semibold text-green-800 dark:text-green-100">{result.execution.applied_count}</div>
|
||||
</div>
|
||||
<div className="rounded-xl border border-blue-200 bg-blue-50 p-4 dark:border-blue-800 dark:bg-blue-900/20">
|
||||
<div className="text-xs text-blue-700 dark:text-blue-300">Requested</div>
|
||||
<div className="mt-1 text-2xl font-semibold text-blue-800 dark:text-blue-100">{result.execution.requested_count}</div>
|
||||
</div>
|
||||
<div className="rounded-xl border border-amber-200 bg-amber-50 p-4 dark:border-amber-800 dark:bg-amber-900/20">
|
||||
<div className="text-xs text-amber-700 dark:text-amber-300">Skipped</div>
|
||||
<div className="mt-1 text-2xl font-semibold text-amber-800 dark:text-amber-100">{result.execution.skipped_count}</div>
|
||||
</div>
|
||||
<div className="rounded-xl border border-red-200 bg-red-50 p-4 dark:border-red-800 dark:bg-red-900/20">
|
||||
<div className="text-xs text-red-700 dark:text-red-300">Failed</div>
|
||||
<div className="mt-1 text-2xl font-semibold text-red-800 dark:text-red-100">{result.execution.failed_count}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
{result.execution.results.length === 0 ? (
|
||||
<div className="rounded-xl border border-dashed border-slate-300 p-5 text-sm text-slate-500 dark:border-slate-700">
|
||||
当前没有可执行 operation,通常意味着上下文不足,或者指令被安全约束拦住了。
|
||||
</div>
|
||||
) : (
|
||||
result.execution.results.map((item) => (
|
||||
<div key={`${item.index}-${item.operation_code}-${item.idempotency_key || "no-key"}`} className="rounded-xl border border-slate-200 p-4 dark:border-slate-800">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Badge variant="outline">#{item.index}</Badge>
|
||||
<div className="font-medium">{renderOperationLabel(item.operation_code)}</div>
|
||||
<div className={`rounded-full border px-2.5 py-0.5 text-xs ${resultTone(item.status)}`}>
|
||||
{item.status}
|
||||
</div>
|
||||
{item.entity_type && item.entity_id ? (
|
||||
<Badge variant="secondary">
|
||||
{item.entity_type}:{item.entity_id}
|
||||
</Badge>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="mt-2 flex items-start gap-2 text-sm text-slate-600 dark:text-slate-300">
|
||||
{item.status === "applied" ? <CheckCircle2 className="mt-0.5 w-4 h-4 text-green-500" /> : <Wand2 className="mt-0.5 w-4 h-4 text-slate-400" />}
|
||||
<div>{item.message}</div>
|
||||
</div>
|
||||
<pre className="mt-3 overflow-x-auto rounded-lg bg-slate-950 p-3 text-xs text-slate-100">
|
||||
{JSON.stringify(item.payload, null, 2)}
|
||||
</pre>
|
||||
{item.trace_id ? (
|
||||
<div className="mt-2 text-xs text-slate-500 dark:text-slate-400">trace: {item.trace_id}</div>
|
||||
) : null}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default OpenClawCommanderPanel;
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
import { NavLink, useLocation } from "react-router-dom";
|
||||
import { NavLink } from "react-router-dom";
|
||||
import {
|
||||
LayoutDashboard,
|
||||
FolderKanban,
|
||||
|
|
@ -9,11 +9,6 @@ import {
|
|||
Users,
|
||||
Sparkles,
|
||||
Sliders,
|
||||
Files,
|
||||
LayoutTemplate,
|
||||
History,
|
||||
ShieldCheck,
|
||||
Settings2,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
|
@ -31,28 +26,18 @@ const baseNavItems = [
|
|||
{ to: "/skills", label: "能力管理", icon: BarChart3 },
|
||||
];
|
||||
|
||||
const configToolItems = [
|
||||
{ to: "/ai-config/task-brief-center", label: "增量任务包中心", icon: Files },
|
||||
{ to: "/ai-config/task-brief-templates", label: "任务包模板管理", icon: LayoutTemplate },
|
||||
{ to: "/ai-config/task-brief-runs", label: "任务包生成记录", icon: History },
|
||||
{ to: "/ai-config/contract-sync-board", label: "契约同步看板", icon: ShieldCheck },
|
||||
{ to: "/ai-config/model-routing-config", label: "模型路由配置", icon: Settings2 },
|
||||
];
|
||||
|
||||
interface SidebarProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function Sidebar({ open, onClose }: SidebarProps) {
|
||||
const location = useLocation();
|
||||
const { theme, toggleTheme } = useTheme();
|
||||
const user = useAuthStore((s) => s.user);
|
||||
const isManager = isManagerRole(user?.role);
|
||||
const navItems = isManager
|
||||
? [...baseNavItems, { to: "/users", label: "用户管理", icon: Users }]
|
||||
: baseNavItems;
|
||||
const showConfigTools = location.pathname.startsWith("/ai-config");
|
||||
|
||||
return (
|
||||
<aside
|
||||
|
|
@ -106,34 +91,6 @@ export function Sidebar({ open, onClose }: SidebarProps) {
|
|||
{label}
|
||||
</NavLink>
|
||||
))}
|
||||
{showConfigTools && (
|
||||
<div className="mt-3 rounded-2xl border border-slate-200/80 bg-white/70 p-2 dark:border-slate-800 dark:bg-slate-900/60">
|
||||
<p className="px-2 pb-2 text-[11px] font-semibold tracking-wide text-slate-500 dark:text-slate-400">
|
||||
智能配置工具
|
||||
</p>
|
||||
<div className="space-y-1">
|
||||
{configToolItems.map(({ to, label, icon: Icon }) => (
|
||||
<NavLink
|
||||
key={to}
|
||||
to={to}
|
||||
end
|
||||
onClick={onClose}
|
||||
className={({ isActive }) =>
|
||||
cn(
|
||||
"flex items-center gap-2 rounded-xl px-2.5 py-2 text-xs font-medium transition-colors",
|
||||
isActive
|
||||
? "bg-blue-100 text-blue-700 dark:bg-blue-900/40 dark:text-blue-200"
|
||||
: "text-slate-600 hover:bg-slate-100 hover:text-slate-900 dark:text-slate-300 dark:hover:bg-slate-800 dark:hover:text-slate-100"
|
||||
)
|
||||
}
|
||||
>
|
||||
<Icon className="h-3.5 w-3.5" />
|
||||
{label}
|
||||
</NavLink>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</nav>
|
||||
<div className="mt-4 rounded-2xl bg-white p-3 shadow-[0_8px_22px_rgba(15,23,42,0.08)] dark:bg-slate-900 dark:shadow-none">
|
||||
<Button
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { Bell, CheckCircle2, LoaderCircle, LogOut, Menu, ShieldAlert, User } from "lucide-react";
|
||||
import { ArrowLeft, Bell, CheckCircle2, LoaderCircle, LogOut, Menu, ShieldAlert, User } from "lucide-react";
|
||||
import { Link, useLocation, useNavigate } from "react-router-dom";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { aiPmoApi, type AIPMOWorkItemListPayload } from "@/api/aiPmo";
|
||||
|
|
@ -62,6 +62,17 @@ const SECTION_CHROME: Record<InboxDisplayKind, string> = {
|
|||
info: "bg-slate-100 text-slate-600 dark:bg-slate-800 dark:text-slate-300",
|
||||
};
|
||||
|
||||
const ROOT_LEVEL_PATHS = new Set([
|
||||
"/dashboard",
|
||||
"/workloads",
|
||||
"/inbox",
|
||||
"/projects",
|
||||
"/ai-steward",
|
||||
"/ai-config",
|
||||
"/skills",
|
||||
"/users",
|
||||
]);
|
||||
|
||||
interface TopBarProps {
|
||||
onMenuToggle: () => void;
|
||||
}
|
||||
|
|
@ -80,6 +91,22 @@ function formatRelativeTime(value?: string | null) {
|
|||
return `${Math.floor(diffMs / day)} 天前`;
|
||||
}
|
||||
|
||||
function normalizePathname(pathname: string) {
|
||||
const normalized = pathname.replace(/\/+$/, "");
|
||||
return normalized || "/";
|
||||
}
|
||||
|
||||
function resolveFallbackBackPath(pathname: string) {
|
||||
if (pathname.startsWith("/ai-config/")) return "/ai-config";
|
||||
if (pathname === "/feishu-config") return "/ai-config";
|
||||
if (/^\/projects\/\d+\/(?:board|charter\/\d+)$/.test(pathname)) return "/projects";
|
||||
if (/^\/workloads\/\d+$/.test(pathname)) return "/workloads";
|
||||
const skillTaskMatch = pathname.match(/^\/users\/(\d+)\/skill-tasks\/\d+$/);
|
||||
if (skillTaskMatch) return `/users/${skillTaskMatch[1]}/skills`;
|
||||
if (/^\/users\/\d+\/(?:profile|skills)$/.test(pathname)) return "/users";
|
||||
return "/dashboard";
|
||||
}
|
||||
|
||||
export function TopBar({ onMenuToggle }: TopBarProps) {
|
||||
const { user, logout } = useAuthStore();
|
||||
const navigate = useNavigate();
|
||||
|
|
@ -94,6 +121,9 @@ export function TopBar({ onMenuToggle }: TopBarProps) {
|
|||
});
|
||||
|
||||
const inboxData = useMemo(() => buildInboxView({ notifications, workItems }), [notifications, workItems]);
|
||||
const normalizedPathname = useMemo(() => normalizePathname(location.pathname), [location.pathname]);
|
||||
const showBackButton = useMemo(() => !ROOT_LEVEL_PATHS.has(normalizedPathname), [normalizedPathname]);
|
||||
const fallbackBackPath = useMemo(() => resolveFallbackBackPath(normalizedPathname), [normalizedPathname]);
|
||||
const currentProjectId = location.pathname.match(/^\/projects\/(\d+)/)?.[1] || null;
|
||||
const inboxHref = currentProjectId ? `/inbox?project_id=${currentProjectId}` : "/inbox";
|
||||
const { data: boardOverviewPayload } = useQuery({
|
||||
|
|
@ -166,6 +196,14 @@ export function TopBar({ onMenuToggle }: TopBarProps) {
|
|||
|
||||
const badgeCount = inboxData.leaderSummary.decision_count + notifications.unread_count;
|
||||
|
||||
const handleBack = () => {
|
||||
if (window.history.length > 1) {
|
||||
navigate(-1);
|
||||
return;
|
||||
}
|
||||
navigate(fallbackBackPath, { replace: true });
|
||||
};
|
||||
|
||||
return (
|
||||
<header className="flex h-16 min-w-0 items-center justify-between border-b border-slate-200 bg-white px-3 shadow-[0_1px_0_rgba(15,23,42,0.04)] dark:border-slate-800 dark:bg-slate-900 dark:shadow-none sm:px-4 md:px-6">
|
||||
<div className="flex min-w-0 items-center gap-2 sm:gap-4">
|
||||
|
|
@ -179,6 +217,19 @@ export function TopBar({ onMenuToggle }: TopBarProps) {
|
|||
>
|
||||
<Menu className="h-5 w-5" />
|
||||
</Button>
|
||||
{showBackButton && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-9 w-9 shrink-0 rounded-full text-slate-500 hover:bg-slate-100 hover:text-slate-700 dark:text-slate-300 dark:hover:bg-slate-800 dark:hover:text-slate-100"
|
||||
onClick={handleBack}
|
||||
aria-label="返回上一层"
|
||||
title="返回上一层"
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
<div className="min-w-0">
|
||||
<div className="truncate text-sm font-semibold text-slate-900 dark:text-slate-100">PMFlow</div>
|
||||
<div className="hidden text-xs text-slate-500 dark:text-slate-400 sm:block">AI 原生项目管理操作台</div>
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue