diff --git a/.env.example b/.env.example index 3bc71c66..aecf58af 100644 --- a/.env.example +++ b/.env.example @@ -18,18 +18,21 @@ CORS_ORIGINS=http://localhost:5174,http://127.0.0.1:5174 LLM_PROVIDER=glm LLM_FALLBACK_PROVIDER=glm OPENCLAW_ROUTING_ENABLED=true -OPENCLAW_ROUTING_RULES_PATH=../.openclaw/pmflow-routing-rules.json +OPENCLAW_ROUTING_RULES_PATH=backend/.openclaw/pmflow-routing-rules.json OPENCLAW_AGENTS_DEFAULT_TIMEOUT_SECONDS=120 LLM_DIRECT_EMERGENCY_ENABLED=true LLM_MIXED_ROUTING_ENABLED=false -LLM_ROUTING_DEFAULT_TIER=active +LLM_ROUTING_DEFAULT_TIER=low LLM_LOW_TO_HIGH_ESCALATION_ENABLED=true LLM_REQUEST_TIMEOUT=90 -# 模型默认值统一由 backend/.openclaw/pmflow-routing-rules.json 管理 +# 模型默认值统一由 backend/.openclaw/pmflow-routing-rules.json 管理,不再依赖 LLM_ROUTING_* / *_MODEL 环境变量 DASHSCOPE_API_KEY= OPENAI_API_KEY= DEEPSEEK_API_KEY= +OLLAMA_HOST=http://host.docker.internal:11434 +OLLAMA_BASE_URL=http://host.docker.internal:11434 +OLLAMA_MODEL=llama3:latest # Adaptive RAG 深入思考模式 RAG_DEEP_RETRIEVAL_STEPS=5 diff --git a/.github/workflows/feishu-replay-gate.yml b/.github/workflows/feishu-replay-gate.yml index 2ef2372d..9aa0a972 100644 --- a/.github/workflows/feishu-replay-gate.yml +++ b/.github/workflows/feishu-replay-gate.yml @@ -12,7 +12,6 @@ on: - "backend/app/main.py" - "backend/evals/feishu_replay_eval.py" - "backend/evals/golden_feishu_replay_v1.jsonl" - - ".openclaw/pmflow-routing-rules.json" - "backend/.openclaw/pmflow-routing-rules.json" - "scripts/openclaw_routing_lint.py" - ".github/workflows/feishu-replay-gate.yml" @@ -21,7 +20,6 @@ on: paths: - "backend/evals/feishu_replay_eval.py" - "backend/evals/golden_feishu_replay_v1.jsonl" - - ".openclaw/pmflow-routing-rules.json" - "backend/.openclaw/pmflow-routing-rules.json" - "scripts/openclaw_routing_lint.py" - ".github/workflows/feishu-replay-gate.yml" @@ -66,7 +64,6 @@ jobs: - name: Lint OpenClaw routing rules run: | - python scripts/openclaw_routing_lint.py --input .openclaw/pmflow-routing-rules.json python scripts/openclaw_routing_lint.py --input backend/.openclaw/pmflow-routing-rules.json - name: Run replay eval diff --git a/.openclaw/pmflow-routing-rules.json b/.openclaw/pmflow-routing-rules.json deleted file mode 100644 index cd365b48..00000000 --- a/.openclaw/pmflow-routing-rules.json +++ /dev/null @@ -1,115 +0,0 @@ -{ - "version": "v2", - "updated_at": "2026-03-11T09:10:00Z", - "source_of_truth": "openclaw", - "description": "PMFlow 三引擎统一路由:所有主链路统一经 OpenClaw OpenAI-Compatible 网关。", - "rules": [ - { - "name": "engine_low_project_draft_glm47_fallback", - "enabled": true, - "priority": 340, - "tier": "low", - "provider": "glm", - "model": "GLM-4.7", - "api_env": "GLM_API_KEY", - "match_any": [ - "project_draft_analysis_glm47_fallback", - "project_draft_signal_glm47_fallback", - "project_draft_charter_glm47_fallback", - "project_draft_task_chain_glm47_fallback" - ] - }, - { - "name": "engine_high_intent_complex_exact", - "enabled": true, - "priority": 320, - "tier": "high", - "provider": "openai", - "model": "GLM-5", - "api_env": "OPENAI_API_KEY", - "function_exact": "classify_intent_complex" - }, - { - "name": "engine_low_intent_kernel_exact", - "enabled": true, - "priority": 310, - "tier": "low", - "provider": "glm", - "model": "GLM-4.7", - "api_env": "GLM_API_KEY", - "function_exact": "classify_intent_ai_pmo_kernel" - }, - { - "name": "engine_low_execute_glm_flash", - "enabled": true, - "priority": 300, - "tier": "low", - "provider": "glm", - "model": "GLM-4.7", - "api_env": "GLM_API_KEY", - "match_any": [ - "feishu_level_classification", - "feishu_signal_extraction", - "rag_intent_classification", - "classify_intent", - "feishu_humanized_reply", - "extract_context_insights" - ] - }, - { - "name": "engine_high_reasoning_glm5", - "enabled": true, - "priority": 220, - "tier": "high", - "provider": "openai", - "model": "GLM-5", - "api_env": "OPENAI_API_KEY", - "match_any": [ - "adaptive_rag_answer", - "classify_intent_complex", - "feishu_project_prefill", - "feishu_task_orchestration", - "generate_task_breakdown", - "generate_charter_from_signal", - "decompose_tasks_from_charter", - "recommend_assignees_for_task", - "evaluate_model_requirement", - "calculate_alignment_score", - "detect_cognitive_conflicts", - "rag_db_project_semantic_retrieval", - "workload_optimize_prompt", - "analyze_cost_roi", - "signal_planner_analyze" - ] - }, - { - "name": "engine_high_extended_reasoning", - "enabled": true, - "priority": 200, - "tier": "high", - "provider": "openai", - "model": "MiniMax-M2.5", - "api_env": "OPENAI_API_KEY", - "match_any": [ - "analyze_food_calories", - "feishu_new_project_brief", - "feishu_project_signal_classification", - "sstc_ai_breakdown", - "sstc_charter_localize_zh", - "sstc_charter_prefill", - "skill_score_update", - "test_engine_connection" - ] - }, - { - "name": "engine_safety_fallback", - "enabled": true, - "priority": 50, - "tier": "safety", - "provider": "openai", - "model": "deepseek-chat", - "api_env": "OPENAI_API_KEY", - "match_all": true - } - ] -} diff --git a/.openclaw/workspace-state.json b/.openclaw/workspace-state.json index 22909d4d..f4d03dda 100644 --- a/.openclaw/workspace-state.json +++ b/.openclaw/workspace-state.json @@ -1,4 +1,4 @@ { "version": 1, - "onboardingCompletedAt": "2026-03-04T07:50:38.457Z" + "onboardingCompletedAt": "2026-03-26T16:00:06.817Z" } diff --git a/.tmp/feishu_case2_fail.html b/.tmp/feishu_case2_fail.html deleted file mode 100644 index 33861985..00000000 --- a/.tmp/feishu_case2_fail.html +++ /dev/null @@ -1,26 +0,0 @@ - - - - - Feishu 验收失败样本 - - - -
-

真实飞书验收 - 失败证据(Case 2)

-
source_message_id: real-scene-p1-v2-2-1773749870
-
最近AI行业有什么动态
-
已收到,正在处理。
-
模型调用失败:function=adaptive_rag_answer_normal, provider=glm, model=GLM-4.7, reason=LLM 返回空内容...
-
- - diff --git a/CLAUDE.md b/CLAUDE.md index f37e9ba7..7755f081 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -57,7 +57,8 @@ backend/ │ ├── 20260311_add_conversation_sessions_table.py # 飞书多轮会话上下文持久化表 │ ├── 20260316_add_ai_orchestration_ingress_async_fields.py # ai_orchestration_runs 入站异步重试字段扩展 │ ├── 20260323_add_work_item_draft_confirmation_fields.py # work_items 草稿确认字段与索引 -│ └── 20260324_add_project_create_idempotency_indexes.py # 项目创建幂等索引:活动项目标题大小写无关唯一 +│ ├── 20260324_add_project_create_idempotency_indexes.py # 项目创建幂等索引:活动项目标题大小写无关唯一 +│ └── 20260327_cleanup_model_routing_legacy_names.py # model_routing 旧函数名清理与重命名迁移 ├── scripts/ # 后端运维/数据修复脚本 │ ├── backfill_orphan_signal_links.py # 回填孤立 signal 的 project_signal_links │ ├── dev/ # 开发辅助脚本(check_users/seed_all) @@ -79,6 +80,8 @@ backend/ │ ├── test_feishu_receive_orchestration_contract.py │ ├── test_signal_planner_contract.py │ ├── test_ai_prompt_registry_contract.py +│ ├── test_model_routing_service.py +│ ├── test_model_routing_migration.py │ └── test_sstc_tasks_ai_breakdown_contract.py ├── requirements.txt # Python 依赖 └── Dockerfile @@ -141,22 +144,27 @@ backend/ - 新增:`backend/alembic/versions/20260324_add_project_create_idempotency_indexes.py`(活动项目标题大小写无关唯一索引,收口项目创建重复写入) - 新增:`backend/alembic/versions/20260326_add_task_breakdown_payload_fields.py`(tasks 拆解业务对象 JSONB 字段:`ai_breakdown_payload/manual_overrides`) - 新增:`backend/alembic/versions/20260326_create_feedback_center_tables.py`(统一反馈学习中心双表建模与 QA/Signal/Task 初始回填) +- 新增:`backend/alembic/versions/20260326_add_ai_call_log_routing_fields.py`(`ai_call_logs` 新增 `planned_tier/executed_tier/routing_decision/route_source/route_rule_name/routing_config_*` 路由追溯字段) +- 新增:`backend/alembic/versions/20260327_cleanup_model_routing_legacy_names.py`(`model_routing` 旧函数名重命名/清理:`project_draft_analysis_glm47_fallback` -> `ai_pmo_signal_title_refine`,删除旧残留键) - 新增:`backend/app/api/ai_steward_api/routes_feedback_center.py`(AI Steward 反馈中心接口:summary/list/detail/review/reason-catalog;summary 支持 `window_days` / `source_group` 趋势作用域) - 新增:`backend/app/models/feedback_center.py`(`FeedbackCenterRecord/FeedbackCenterRecordRevision` 统一反馈中心模型) - 新增:`backend/app/schemas/feedback_center.py`(反馈中心 summary/list/detail/review/reason-catalog 对应 Pydantic 模型) - 新增:`backend/app/services/feedback_center/`(反馈中心服务层:`service.py/mappers.py/reason_catalog.py`;summary 产出 `metrics/trend/insights` 学习仪表盘) - 新增:`backend/tests/test_feedback_center_service.py`(反馈中心服务回归:tenant/project 过滤、review 修订、映射与原因候选) - 新增:`backend/tests/test_feedback_center_api.py`(反馈中心 API 回归:路由、权限与三类来源回填) +- 新增:`backend/tests/test_feedback_center_migration.py`(反馈中心迁移回归:历史回填 `_insert_record` 可安全写入 JSON 载荷,避免 Alembic 在 SQLite/PostgreSQL 上因 dict/list bind 失败) - 新增:`backend/tests/test_daily_briefing_service.py`(每日简报回归:北京时间 naive 时间窗、任务更新采集与空响应边界) - 新增:`backend/tests/test_ai_steward_project_from_text_contract.py`(AI Steward `project-from-text` 契约回归:`code/message/data` 包络与 `signal_sources` 字段) - 新增:`backend/tests/test_signal_source_requirement_materialization.py`(需求点 -> 信号源注册项归一/去重/原始上下文回归) - 新增:`backend/tests/test_feishu_new_project_signal_sources.py`(飞书 `new_project` 自动立项回归:摘要失败显式报错、信号源随项目同步落库) +- 新增:`backend/tests/test_model_routing_service.py`(双层模型路由契约回归:`safety` 拒绝、runtime/query 一致、override 只存 `tier_alias`、`ai_call_logs` 仅记录 tier 级路由元信息) +- 新增:`backend/tests/test_model_routing_migration.py`(`model_routing` 迁移回归:旧函数名键重命名/删除后仍保留低高层路由语义) - 更新:`backend/app/services/feedback_center/service.py`(summary 输出新增 metrics 学习指标、决策洞察 `insights`,并支持 `window_days` / `source_group` 的趋势分析作用域) - 更新:`backend/app/schemas/feedback_center.py`(summary 增加 metrics / trend / insights 结构,并回传 `source_group`) - 新增:`backend/app/services/signal_planner_service.py`(Signal Planner 异步分析服务) - 新增:`backend/app/services/model/model_routing/service.py`(函数级模型路由配置服务) - 新增:`backend/app/services/task_brief_service.py`(增量任务包中心服务) -- 新增:`backend/app/services/model_context_bundle_service.py`(双模型上下文组包/能力汇总/消费验证服务) +- 新增:`backend/app/services/model_context_bundle_service.py`(low/high 路由上下文组包/能力汇总/消费验证服务) - 新增:`backend/app/api/model_routing.py`(模型路由配置接口) - 新增:`backend/app/api/task_briefs.py`(任务包中心接口) - 更新:`backend/app/api/ai_steward_api/routes_capture.py`、`schemas.py`、`shared.py`(`POST /ai-steward/project-from-text` 统一 `code/message/data` 包络,返回 `signal_sources`) @@ -336,7 +344,7 @@ 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/knowledgeGraph.ts`(KG 能力汇总与 low/high 消费验证 API) - 更新:`frontend/src/api/aiSteward.ts`(移除 OpenClaw dispatch 封装,新增 LLM 引擎 CRUD / settings 封装) - 更新:`frontend/src/pages/CharterDetailPage.tsx`、`ContractSyncBoardPage.tsx`、`DashboardPage.tsx`、`KnowledgeGraphContextBoardPage.tsx`、`LoginPage.tsx`、`ModelRoutingConfigPage.tsx`、`SchedulerCenterPage.tsx`、`SkillTasksPage.tsx`、`SkillsPage.tsx`、`TaskBriefCenterPage.tsx`、`TaskBriefRunsPage.tsx`、`TaskBriefTemplatePage.tsx`、`UserProfilePage.tsx`、`UserSkillsPage.tsx`、`WorkloadDetailPage.tsx`、`WorkloadsPage.tsx`(统一降级为轻量入口,转发到 `pages/*/` 子目录) - 新增:`frontend/src/pages/charter-detail/CharterDetailPage.tsx`、`contract-sync-board/ContractSyncBoardPage.tsx`、`dashboard/DashboardPage.tsx`、`knowledge-graph-context-board/KnowledgeGraphContextBoardPage.tsx`、`login/LoginPage.tsx`、`model-routing-config/ModelRoutingConfigPage.tsx`(剩余核心页面目录化收敛) @@ -469,18 +477,19 @@ uvicorn app.main:app --reload --host 0.0.0.0 --port 8000 - 运行环境数据库仅允许 PostgreSQL(`postgresql+asyncpg`);禁止回落 SQLite - 新增接口必须遵循响应包络:`{code, message, data}`,不得裸返回 - 项目创建时自动生成的信号源必须落到现有 `SignalSourceRegistry`,前端仅允许在现有 AI Steward 创建结果区内联展示,禁止新增独立信号源页面或独立持久化模型 +- `create_project_from_natural_language` 支持透传 `requested_member_user_ids`;AI PMO 草稿确认时必须把推荐负责人并入项目创建事务,确保项目、主信号、信号源、成员关系一次性落库,禁止确认后再事后补成员 - 禁止在 API 层写业务逻辑,业务逻辑归属 `services/` - 禁止在前端组件内直接调用 `fetch`,必须通过 `frontend/src/api/` 封装层 - 前端状态管理:服务端数据用 React Query,本地 UI 状态用 `useState` - 飞书回执发送仅允许 `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 上下文 +- low/high 双层路由(`classify_intent_ai_pmo_kernel`、`classify_intent_complex`)与 `openclaw_command_dispatch` 必须遵循 DDL 接口化约束:禁止直连 SQL DDL,命中时强制输出 `ddl_api_required` +- low/high 上下文注入仅允许通过 `ModelContextBundleService` 统一组包:固定三段 `knowledge_graph/task_template/contract_sync`,禁止在业务服务散落拼接 prompt 上下文 - AI PMO `signal.create` 在用户显式要求“先分析再入库 / 自行分析”时,若批量信号结构化分析失败,禁止回退规则标题直接入库,必须返回失败安全态 -- 所有 LLM 调用禁止静默兜底与 stub 假成功;低阶模型失败时必须优先强制升级到高阶 GLM-5 重试并保留低/高两段日志,高阶仍失败时才显式报错,禁止用固定兜底文案冒充成功结果 +- 所有 LLM 调用禁止静默兜底与 stub 假成功;low 失败时必须优先强制升级到 high 重试并保留低/高两段日志,high 仍失败时才显式报错,禁止用固定兜底文案冒充成功结果 - 禁止模型兜底回复文案:模型失败、超时、空响应、结构化解析失败时,必须返回真实错误原因;禁止输出“系统处理中/请稍后重试”等固定文案冒充成功结果 -- AdaptiveRAG `adaptive_rag_answer_normal` 空响应必须显式抛错并交由高阶 GLM-5 failover 处理;禁止在生成层直接回落为“补充问题细节后重试”之类的兜底回复 +- AdaptiveRAG `adaptive_rag_answer_normal` 空响应必须显式抛错并交由 high failover 处理;禁止在生成层直接回落为“补充问题细节后重试”之类的兜底回复 - 用户可见问答链路(`function_name` 含 `answer/reply/qa`)禁止使用 `reasoning_content` 回退,避免思考过程外漏 - AdaptiveRAG 场景化 Prompt 选择器必须在 `mixin_deep_guard_answer.py` 组装 Prompt 前执行,并遵循固定优先级:成员负载 > 趋势(项目关联 external_trend / 通用 industry_trend) > mixed > AI PMO 入口 > 默认 RAG - 通用行业趋势问答(如“宠物行业最新动态”)不得注入数据库、项目历史、用户历史或外部实时搜索结果;只有显式带项目上下文时才允许走 `external_trend`,对“不要提项目/别提项目”这类否定语句不能误判为项目上下文 @@ -499,6 +508,7 @@ uvicorn app.main:app --reload --host 0.0.0.0 --port 8000 - 新增后端接口:参考 `backend/app/api/sstc.py` 的结构 - 项目创建接口:参考 `backend/app/api/ai_steward_api/routes_capture.py`(`POST /ai-steward/project-from-text` 统一 `{code,message,data}`,返回 `signal_sources`) - 模型路由配置接口:参考 `backend/app/api/model_routing.py`(`GET/PUT /model-routing/functions/{function_name}`,仅管理员可写并写入 `operation_logs`) +- `model-routing` 只暴露逻辑层字段:`function_name/tier_alias/source/rule_name/rule_priority/last_execution`;`PUT` 仅接受 `tier_alias=low|high` - 任务包中心接口:参考 `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/services/signal/signal_source_orchestration/mixin_registry_execute.py`(`materialize_requirement_sources` 负责归一、去重与 `raw_context` 持久化) @@ -553,19 +563,22 @@ uvicorn app.main:app --reload --host 0.0.0.0 --port 8000 - 飞书长连接状态分为两层:`last_received_at/last_received_message_id` 表示 SDK 回调已收到事件,`last_event_at` 表示 worker 完成处理;排查“没吃进去”时必须优先看 `last_received_*` - 飞书长连接会话应复用 `Integration.config` 中已有的 `encrypt_key/verification_token`;配置保存时若未显式修改这两个字段,必须保留现有值,避免 SDK 因缺失事件密钥而拒收消息 - LLM 主链按 `OPENCLAW_ROUTING_ENABLED` 走 OpenClaw 路由规则(`backend/.openclaw/pmflow-routing-rules.json`);请求超时统一以 `LLM_REQUEST_TIMEOUT` 环境变量为准 +- 路由配置唯一真源固定为 `backend/.openclaw/pmflow-routing-rules.json`;根目录 `.openclaw` 仅允许不存在,禁止再挂载 `/app/.openclaw` - LLM 非结构化响应仅在非用户可见链路且显式开启 `LLM_REASONING_CONTENT_FALLBACK` 时,才允许从 `reasoning_content` 回退;`answer/reply/qa` 链路一律禁用该回退 - World Model 静态层已接入 Redis 缓存(key: `wm:static:{tenant_id}:{project_id}`,TTL 600s) - 写操作后主动 invalidate,动态层不缓存 - AI PMO 意图识别已切换 `model-only`:`_resolve_intent` 仅接受 LLM 结构化结果,不再走规则/正则兜底链路 - AI PMO / Feishu 执行门已取消置信度阈值拦截与高风险人工复核:`confidence` 仅保留在 `decision_trace` / processing log 中作观测,不再作为自动执行的 reject 条件;当前门禁仅保留 `force_manual` 与 `correction_action` - 模型默认值与 provider/tier 映射以 `backend/.openclaw/pmflow-routing-rules.json` 为唯一配置源;`.env` / `.env.example` 仅保留 API key 与运行开关,`OPENCLAW_ROUTING_RULES_PATH` 只用于定位这份文件 -- Signal Planner 函数路由支持 `integrations(type='model_routing')` 覆盖配置;配置缺失时默认直连 GLM +- 调用层与路由页面只使用 `low/high` 两层语义;`safety` 已废弃并从配置校验、运行时解析、前端选项中移除 +- AI Steward 项目创建与 LLM 主链不再在业务层手写 `*_glm47_fallback -> GLM-5` 跨函数升级;项目分析函数名与高低层模型选择统一由 `backend/.openclaw/pmflow-routing-rules.json` 决定 +- Signal Planner 函数路由支持 `integrations(type='model_routing')` 的 `tier_alias` 覆盖;配置缺失时按 OpenClaw `low/high` 规则解析,不再直接暴露真实模型名 - Signal Planner 结构化调用采用 `25s` 硬超时:超时或结构化不合法时标记失败并通知人工重试,不再写入降级 `ai_planner(fallback_mode=true)` - AI Steward 用户侧已移除 OpenClaw 指挥规划入口;后续项目分析主链以 GLM 严格失败直出为准 - 飞书业务命令命中旧 command-first 路由时,仅返回“已下线”阻断提示,不再触发实际执行 -- 模型命名统一使用裸模型名(如 `GLM-4.7`、`MiniMax-M2.5`),不使用供应商前缀模型名 -- 双 Agent 路由治理:低/高层函数均可由 OpenClaw 规则命中到指定 provider/model(含 `classify_intent_ai_pmo_kernel`) -- 双模型 DDL 治理图谱的模型来源统一读取 `backend/.openclaw/pmflow-routing-rules.json` 中的 `provider_defaults/tier_defaults`;不再依赖 `LLM_ROUTING_LOW_MODEL/LLM_ROUTING_HIGH_MODEL` 作为主配置 +- 真实模型名仅保留在 `backend/.openclaw/pmflow-routing-rules.json` 与内部解析流程中;调用页面、查询接口、`ai_call_logs` 不直接暴露 provider/model +- 双层路由治理:低/高层函数统一由 OpenClaw 规则命中到 `low/high` 逻辑槽位(含 `classify_intent_ai_pmo_kernel` 的双层命中) +- low/high DDL 治理图谱的模型来源统一读取 `backend/.openclaw/pmflow-routing-rules.json` 中的 `provider_defaults/tier_defaults`;不再依赖 `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/让我直接处理/模型失败了所以没分析”等漂移语句时,自动回退固定模板,禁止对外宣称未证实执行动作 diff --git a/backend/.openclaw/pmflow-routing-rules.json b/backend/.openclaw/pmflow-routing-rules.json index eff7d153..8e96af36 100644 --- a/backend/.openclaw/pmflow-routing-rules.json +++ b/backend/.openclaw/pmflow-routing-rules.json @@ -1,8 +1,8 @@ { "version": "v3", - "updated_at": "2026-03-25T00:00:00Z", + "updated_at": "2026-03-26T21:30:00+08:00", "source_of_truth": "openclaw", - "description": "PMFlow 三引擎统一路由:所有主链路统一经 OpenClaw OpenAI-Compatible 网关。", + "description": "PMFlow 双层统一路由:调用层仅暴露 low/high,真实模型名只保留在配置真源。", "provider_defaults": { "deepseek": { "name": "DeepSeek", @@ -19,7 +19,7 @@ "openai": { "name": "OpenAI", "base_url": "https://api.edgefn.net/v1", - "model": "MiniMax-M2.5", + "model": "GLM-5", "api_env": "OPENAI_API_KEY" }, "ollama": { @@ -41,32 +41,9 @@ "model": "GLM-5", "base_url": "https://api.edgefn.net/v1", "api_env": "OPENAI_API_KEY" - }, - "safety": { - "provider": "glm", - "model": "qwen-plus", - "base_url": "https://dashscope.aliyuncs.com/compatible-mode/v1", - "api_env": "DASHSCOPE_API_KEY" } }, "rules": [ - { - "name": "engine_low_project_draft_glm47_fallback", - "enabled": true, - "priority": 340, - "tier": "low", - "provider": "glm", - "model": "qwen-plus", - "api_env": "DASHSCOPE_API_KEY", - "base_url": "https://dashscope.aliyuncs.com/compatible-mode/v1", - "match_any": [ - "project_draft_analysis_glm47_fallback", - "project_draft_signal_glm47_fallback", - "project_draft_charter_glm47_fallback", - "project_draft_task_chain_glm47_fallback", - "project_requirement_extraction_glm47_fallback" - ] - }, { "name": "engine_high_intent_complex_exact", "enabled": true, @@ -112,6 +89,7 @@ "feishu_signal_extraction", "rag_intent_classification", "classify_intent", + "ai_pmo_signal_title_refine", "feishu_humanized_reply", "extract_context_insights" ] @@ -127,6 +105,10 @@ "match_any": [ "adaptive_rag_answer", "classify_intent_complex", + "project_from_text_analysis", + "project_from_text_signal", + "project_from_text_charter", + "project_from_text_task_chain", "project_requirement_extraction", "feishu_project_prefill", "feishu_task_orchestration", @@ -137,6 +119,7 @@ "evaluate_model_requirement", "calculate_alignment_score", "detect_cognitive_conflicts", + "ai_pmo_signal_batch_analysis", "rag_db_project_semantic_retrieval", "workload_optimize_prompt", "analyze_cost_roi", @@ -149,7 +132,7 @@ "priority": 200, "tier": "high", "provider": "openai", - "model": "MiniMax-M2.5", + "model": "GLM-5", "api_env": "OPENAI_API_KEY", "match_any": [ "analyze_food_calories", @@ -163,13 +146,14 @@ ] }, { - "name": "engine_safety_fallback", + "name": "engine_low_fallback", "enabled": true, "priority": 50, - "tier": "safety", - "provider": "openai", - "model": "deepseek-chat", - "api_env": "OPENAI_API_KEY", + "tier": "low", + "provider": "glm", + "model": "qwen-plus", + "api_env": "DASHSCOPE_API_KEY", + "base_url": "https://dashscope.aliyuncs.com/compatible-mode/v1", "match_all": true } ] diff --git a/backend/Dockerfile b/backend/Dockerfile index 86121f5b..422158eb 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -14,7 +14,6 @@ RUN pip install --no-cache-dir -r /tmp/requirements.txt COPY backend /app/backend COPY docs /app/docs -COPY .openclaw /app/.openclaw EXPOSE 8000 diff --git a/backend/alembic/versions/20260326_add_ai_call_log_routing_fields.py b/backend/alembic/versions/20260326_add_ai_call_log_routing_fields.py new file mode 100644 index 00000000..b9059832 --- /dev/null +++ b/backend/alembic/versions/20260326_add_ai_call_log_routing_fields.py @@ -0,0 +1,68 @@ +"""add ai call log routing fields + +Revision ID: 20260326_add_ai_call_log_routing_fields +Revises: 20260326_create_feedback_center_tables +Create Date: 2026-03-26 +""" + +from __future__ import annotations + +from alembic import op +import sqlalchemy as sa + + +revision = "20260326_add_ai_call_log_routing_fields" +down_revision = "20260326_create_feedback_center_tables" +branch_labels = None +depends_on = None + + +def _table_exists(bind, table_name: str) -> bool: + return sa.inspect(bind).has_table(table_name) + + +def _column_exists(bind, table_name: str, column_name: str) -> bool: + inspector = sa.inspect(bind) + if not inspector.has_table(table_name): + return False + return any(str(item.get("name") or "") == column_name for item in inspector.get_columns(table_name)) + + +def upgrade() -> None: + bind = op.get_bind() + table_name = "ai_call_logs" + if not _table_exists(bind, table_name): + return + + columns = [ + ("planned_tier", sa.Column("planned_tier", sa.String(length=16), nullable=True)), + ("executed_tier", sa.Column("executed_tier", sa.String(length=16), nullable=True)), + ("routing_decision", sa.Column("routing_decision", sa.String(length=64), nullable=True)), + ("route_source", sa.Column("route_source", sa.String(length=32), nullable=True)), + ("route_rule_name", sa.Column("route_rule_name", sa.String(length=255), nullable=True)), + ("routing_config_version", sa.Column("routing_config_version", sa.String(length=64), nullable=True)), + ("routing_config_updated_at", sa.Column("routing_config_updated_at", sa.String(length=64), nullable=True)), + ] + + for column_name, column in columns: + if not _column_exists(bind, table_name, column_name): + op.add_column(table_name, column) + + +def downgrade() -> None: + bind = op.get_bind() + table_name = "ai_call_logs" + if not _table_exists(bind, table_name): + return + + for column_name in ( + "routing_config_updated_at", + "routing_config_version", + "route_rule_name", + "route_source", + "routing_decision", + "executed_tier", + "planned_tier", + ): + if _column_exists(bind, table_name, column_name): + op.drop_column(table_name, column_name) diff --git a/backend/alembic/versions/20260326_create_feedback_center_tables.py b/backend/alembic/versions/20260326_create_feedback_center_tables.py index d5788438..692cbf7f 100644 --- a/backend/alembic/versions/20260326_create_feedback_center_tables.py +++ b/backend/alembic/versions/20260326_create_feedback_center_tables.py @@ -125,59 +125,37 @@ def _record_exists( return row is not None -def _insert_record(bind, payload: dict[str, Any]) -> None: - bind.execute( - sa.text( - """ - INSERT INTO feedback_center_records ( - tenant_id, - project_id, - source_type, - source_id, - object_type, - object_id, - field_name, - before_value, - after_value, - ai_snapshot, - ai_confidence, - reason_candidates, - reason_code, - reason_text, - review_status, - reviewed_by, - reviewed_at, - source_title, - object_title, - auto_assigned - ) VALUES ( - :tenant_id, - :project_id, - :source_type, - :source_id, - :object_type, - :object_id, - :field_name, - :before_value, - :after_value, - :ai_snapshot, - :ai_confidence, - :reason_candidates, - :reason_code, - :reason_text, - :review_status, - :reviewed_by, - :reviewed_at, - :source_title, - :object_title, - :auto_assigned - ) - """ - ), - payload, +def _feedback_center_records_table(bind): + json_type = _json_type(bind) + return sa.table( + "feedback_center_records", + sa.column("tenant_id", sa.BigInteger()), + sa.column("project_id", sa.BigInteger()), + sa.column("source_type", sa.VARCHAR(length=32)), + sa.column("source_id", sa.BigInteger()), + sa.column("object_type", sa.VARCHAR(length=32)), + sa.column("object_id", sa.BigInteger()), + sa.column("field_name", sa.VARCHAR(length=128)), + sa.column("before_value", json_type), + sa.column("after_value", json_type), + sa.column("ai_snapshot", json_type), + sa.column("ai_confidence", sa.Float()), + sa.column("reason_candidates", json_type), + sa.column("reason_code", sa.VARCHAR(length=64)), + sa.column("reason_text", sa.Text()), + sa.column("review_status", sa.VARCHAR(length=16)), + sa.column("reviewed_by", sa.BigInteger()), + sa.column("reviewed_at", sa.TIMESTAMP()), + sa.column("source_title", sa.VARCHAR(length=500)), + sa.column("object_title", sa.VARCHAR(length=500)), + sa.column("auto_assigned", sa.Boolean()), ) +def _insert_record(bind, payload: dict[str, Any]) -> None: + bind.execute(sa.insert(_feedback_center_records_table(bind)).values(**payload)) + + def _backfill_from_qa_feedback(bind) -> None: if not _table_exists(bind, "qa_feedback") or not _table_exists(bind, "qa_records"): return diff --git a/backend/alembic/versions/20260327_cleanup_model_routing_legacy_names.py b/backend/alembic/versions/20260327_cleanup_model_routing_legacy_names.py new file mode 100644 index 00000000..c2c50719 --- /dev/null +++ b/backend/alembic/versions/20260327_cleanup_model_routing_legacy_names.py @@ -0,0 +1,150 @@ +"""cleanup legacy model routing function names + +Revision ID: 20260327_cleanup_model_routing_legacy_names +Revises: 20260326_add_ai_call_log_routing_fields +Create Date: 2026-03-27 +""" + +from __future__ import annotations + +from copy import deepcopy +from typing import Any + +from alembic import op +import sqlalchemy as sa + + +revision = "20260327_cleanup_model_routing_legacy_names" +down_revision = "20260326_add_ai_call_log_routing_fields" +branch_labels = None +depends_on = None + +_RENAME_KEYS = { + "project_draft_analysis_glm47_fallback": "ai_pmo_signal_title_refine", + "adaptive_rag_answer_deep_signal_batch_analysis": "ai_pmo_signal_batch_analysis", +} +_REMOVE_KEYS = { + "project_draft_charter_glm47_fallback", + "project_draft_signal_glm47_fallback", + "project_draft_task_chain_glm47_fallback", +} + + +def _table_exists(bind, table_name: str) -> bool: + return sa.inspect(bind).has_table(table_name) + + +def _integration_table(bind) -> sa.Table: + metadata = sa.MetaData() + return sa.Table( + "integrations", + metadata, + sa.Column("id", sa.Integer(), primary_key=True), + sa.Column("type", sa.String(length=100)), + sa.Column("config", sa.JSON()), + ) + + +def _safe_dict(value: Any) -> dict[str, Any]: + if isinstance(value, dict): + try: + return deepcopy(value) + except Exception: + return dict(value) + return {} + + +def _rewrite_model_routing_config(config: Any) -> dict[str, Any]: + payload = _safe_dict(config) + if not payload: + return {} + + mappings = payload.get("mappings") + if not isinstance(mappings, dict): + payload["mappings"] = {} + return payload + + normalized: dict[str, Any] = {} + for key, value in mappings.items(): + name = str(key or "").strip().lower() + if not name: + continue + normalized[name] = deepcopy(value) + + for old_key, new_key in _RENAME_KEYS.items(): + if old_key not in normalized: + continue + if new_key not in normalized: + normalized[new_key] = normalized[old_key] + normalized.pop(old_key, None) + + for key in _REMOVE_KEYS: + normalized.pop(key, None) + + payload["mappings"] = normalized + return payload + + +def upgrade() -> None: + bind = op.get_bind() + if not _table_exists(bind, "integrations"): + return + + table = _integration_table(bind) + rows = ( + bind.execute( + sa.select(table.c.id, table.c.config).where(table.c.type == "model_routing") + ) + .mappings() + .all() + ) + for row in rows: + row_id = int(row.get("id") or 0) + rewritten = _rewrite_model_routing_config(row.get("config")) + if not rewritten: + continue + if rewritten == row.get("config"): + continue + bind.execute( + table.update().where(table.c.id == row_id).values(config=rewritten) + ) + + +def downgrade() -> None: + bind = op.get_bind() + if not _table_exists(bind, "integrations"): + return + + table = _integration_table(bind) + reverse_rename = {new_key: old_key for old_key, new_key in _RENAME_KEYS.items()} + rows = ( + bind.execute( + sa.select(table.c.id, table.c.config).where(table.c.type == "model_routing") + ) + .mappings() + .all() + ) + for row in rows: + row_id = int(row.get("id") or 0) + payload = _safe_dict(row.get("config")) + mappings = payload.get("mappings") + if not isinstance(mappings, dict): + continue + normalized: dict[str, Any] = {} + for key, value in mappings.items(): + name = str(key or "").strip().lower() + if not name: + continue + normalized[name] = deepcopy(value) + for new_key, old_key in reverse_rename.items(): + if new_key not in normalized: + continue + if old_key not in normalized: + normalized[old_key] = normalized[new_key] + normalized.pop(new_key, None) + payload["mappings"] = normalized + if payload == row.get("config"): + continue + bind.execute( + table.update().where(table.c.id == row_id).values(config=payload) + ) diff --git a/backend/app/api/llm_providers.py b/backend/app/api/llm_providers.py index c6222c5f..14f799a3 100644 --- a/backend/app/api/llm_providers.py +++ b/backend/app/api/llm_providers.py @@ -19,30 +19,35 @@ def _build_provider_payload() -> dict[str, Any]: tier_defaults = get_tier_defaults() low_tier = dict(tier_defaults.get("low") or {}) high_tier = dict(tier_defaults.get("high") or {}) + openai_defaults = dict(provider_defaults.get("openai") or {}) + glm_defaults = dict(provider_defaults.get("glm") or {}) + deepseek_defaults = dict(provider_defaults.get("deepseek") or {}) + ollama_defaults = dict(provider_defaults.get("ollama") or {}) + high_model = str(high_tier.get("model") or openai_defaults.get("model") or "GLM-5").strip() return { - "low": str(low_tier.get("model") or provider_defaults.get("glm", {}).get("model") or "qwen-plus").strip(), - "high": str(high_tier.get("model") or "GLM-5").strip(), - "base_url": str(high_tier.get("base_url") or provider_defaults.get("openai", {}).get("base_url") or "https://api.edgefn.net/v1").strip(), + "low": str(low_tier.get("model") or glm_defaults.get("model") or "qwen-plus").strip(), + "high": high_model, + "base_url": str(high_tier.get("base_url") or openai_defaults.get("base_url") or "https://api.edgefn.net/v1").strip(), "providers": { "glm": { - "model": str(provider_defaults.get("glm", {}).get("model") or "qwen-plus").strip(), - "base_url": str(provider_defaults.get("glm", {}).get("base_url") or "https://dashscope.aliyuncs.com/compatible-mode/v1").strip(), - "api_env": "DASHSCOPE_API_KEY", + "model": str(glm_defaults.get("model") or "qwen-plus").strip(), + "base_url": str(glm_defaults.get("base_url") or "https://dashscope.aliyuncs.com/compatible-mode/v1").strip(), + "api_env": str(glm_defaults.get("api_env") or "DASHSCOPE_API_KEY").strip(), }, "openai": { - "model": str(provider_defaults.get("openai", {}).get("model") or "MiniMax-M2.5").strip(), - "base_url": str(provider_defaults.get("openai", {}).get("base_url") or "https://api.edgefn.net/v1").strip(), - "api_env": "OPENAI_API_KEY", + "model": high_model, + "base_url": str(openai_defaults.get("base_url") or "https://api.edgefn.net/v1").strip(), + "api_env": str(openai_defaults.get("api_env") or "OPENAI_API_KEY").strip(), }, "deepseek": { - "model": str(provider_defaults.get("deepseek", {}).get("model") or "deepseek-chat").strip(), - "base_url": str(provider_defaults.get("deepseek", {}).get("base_url") or "https://api.deepseek.com/v1").strip(), - "api_env": "DEEPSEEK_API_KEY", + "model": str(deepseek_defaults.get("model") or "deepseek-chat").strip(), + "base_url": str(deepseek_defaults.get("base_url") or "https://api.deepseek.com/v1").strip(), + "api_env": str(deepseek_defaults.get("api_env") or "DEEPSEEK_API_KEY").strip(), }, "ollama": { - "model": str(provider_defaults.get("ollama", {}).get("model") or "llama3:latest").strip(), - "base_url": str(provider_defaults.get("ollama", {}).get("base_url") or "http://127.0.0.1:11434").strip(), - "api_env": "", + "model": str(ollama_defaults.get("model") or "llama3:latest").strip(), + "base_url": str(ollama_defaults.get("base_url") or "http://127.0.0.1:11434").strip(), + "api_env": str(ollama_defaults.get("api_env") or "").strip(), }, }, } diff --git a/backend/app/api/model_routing.py b/backend/app/api/model_routing.py index 36cb2579..0aaede2a 100644 --- a/backend/app/api/model_routing.py +++ b/backend/app/api/model_routing.py @@ -2,7 +2,7 @@ from __future__ import annotations -from typing import Optional +from typing import Literal from fastapi import APIRouter, Depends from fastapi.responses import JSONResponse @@ -21,11 +21,7 @@ router = APIRouter(prefix="/model-routing", tags=["model_routing"]) class ModelRoutingUpdateRequest(BaseModel): - provider: str - model: str - api_env: str - tier: str = Field(default="high") - base_url: Optional[str] = None + tier_alias: Literal["low", "high"] = Field(default="high") @router.get("/functions") @@ -74,11 +70,7 @@ async def update_model_routing( db, tenant_id=tenant_id, function_name=function_name, - provider=body.provider, - model=body.model, - api_env=body.api_env, - tier=body.tier, - base_url=body.base_url, + tier_alias=body.tier_alias, updated_by=int(current_user.id), ) except ValueError as exc: diff --git a/backend/app/config/ai_prompt_registry.py b/backend/app/config/ai_prompt_registry.py index 0cfd6220..83ee66a9 100644 --- a/backend/app/config/ai_prompt_registry.py +++ b/backend/app/config/ai_prompt_registry.py @@ -317,7 +317,7 @@ def _part_02_entries() -> tuple[dict[str, Any], ...]: module="part_02", business_scenario="立项草案生成", input_contract="输入 request / project / active_projects / similar_projects / charter / members / recent_signals。", - output_contract="输出 JSON 对象,包含项目草案、重复建设判断与风险。", + output_contract="输出 JSON 对象,包含项目草案、定位、核心痛点、MVP 分期、技术架构、商业模式、验证指标与风险。", call_sites=DRAFT_GENERATOR_CALL_SITES, ), _entry( diff --git a/backend/app/config/ai_prompts_catalog/part_01.py b/backend/app/config/ai_prompts_catalog/part_01.py index d3947640..191e29d7 100644 --- a/backend/app/config/ai_prompts_catalog/part_01.py +++ b/backend/app/config/ai_prompts_catalog/part_01.py @@ -165,6 +165,7 @@ PROMPT_FEISHU_HUMANIZED_REPLY = """ 5. 如果用户询问日期/时间/星期,必须基于“当前系统时间(北京时间)”直接回答,不得编造。 6. 禁止输出数据库内部实施细节(如数据库迁移、DDL、SQL、ALTER TABLE、DROP TABLE、Alembic)。 7. 当 status/classification 无法证明写入动作已成功时,禁止编造处理步骤。 +8. 对于创建项目、接入信号源、创建信号这类动作请求,禁止输出“我来帮你/让我先/我先检查/通过 API 创建”等过程性前缀,必须直接给出执行结果或缺失字段。 【输出规范】 - 严禁输出任何思考过程或推理步骤。 diff --git a/backend/app/config/ai_prompts_catalog/part_02.py b/backend/app/config/ai_prompts_catalog/part_02.py index 02ac0be8..3de05373 100644 --- a/backend/app/config/ai_prompts_catalog/part_02.py +++ b/backend/app/config/ai_prompts_catalog/part_02.py @@ -380,8 +380,18 @@ PROMPT_DRAFT_GENERATOR = """ 请输出 JSON,包含: - title: 语义化标题(10字以内,必须概括目标,不得复述原话) - summary: 项目定位与需求摘要(80字以内) +- positioning_one_liner: 一句话定位(帮助用户快速理解这个项目做什么) +- target_customers: 目标客户与使用场景(40字以内) - similar_projects: 同名/相似项目列表,元素含 project_id/title/difference/risk/reuse_suggestion +- core_pain_points: 核心痛点数组,元素含 pain/status/opportunity - mvp_items: MVP 建议数组,3-5 条 +- mvp_phase_1: Phase 1 核心功能数组,3-5 条 +- mvp_phase_2: Phase 2 差异化功能数组,1-3 条 +- technical_architecture: 技术架构数组,元素为短句 +- business_model: 商业模式数组,元素含 plan/price/features +- validation_metrics: 验证指标对象,包含 product / market 两个数组 +- quick_start_suggestions: 快速启动建议对象,包含 week_1_2 / week_3_4 两个数组 +- team_setup: 团队配置数组,元素含 role/notes - prospect_assessment: 前景判断(1-3 句) - requirement_items: 需求清单数组,3-8 条 - missing_info: 还缺的信息数组,0-5 条 @@ -399,6 +409,7 @@ PROMPT_DRAFT_GENERATOR = """ 5. 推荐负责人优先选择负载 <80% 成员;若都 >=80%,选最低负载并明确负载风险。 6. followup_plan 最少 1 项,最多 3 项。 7. 无法确定推荐人时,recommended_assignee_id 返回 null。 +8. 如果某个 section 缺少信息,保留空数组或空字符串,不要省略字段。 """ PROMPT_DRAFT_GENERATOR = _compose_prompt( PMFLOW_PMO_STYLE_BASE, diff --git a/backend/app/core/startup/model_probe.py b/backend/app/core/startup/model_probe.py index 277507a0..3f03b0cf 100644 --- a/backend/app/core/startup/model_probe.py +++ b/backend/app/core/startup/model_probe.py @@ -62,8 +62,6 @@ async def probe_model_connectivity_on_startup() -> None: logger.warning( "startup_model_probe_runtime_resolve_failed", level=level, - provider=provider, - model=model, error=str(resolve_exc), ) if not isinstance(runtime, dict) or not runtime: @@ -78,11 +76,9 @@ async def probe_model_connectivity_on_startup() -> None: logger.error( "startup_model_probe_runtime_invalid", level=level, - provider=provider, - model=model, error=str(exc), ) - lines.append(f"│ {model:<12} ✗ 失败 (0.0s) │") + lines.append(f"│ {level:<12} ✗ 失败 (0.0s) │") continue started_at = time.perf_counter() @@ -114,20 +110,18 @@ async def probe_model_connectivity_on_startup() -> None: logger.error( "startup_model_probe_failed", level=level, - provider=provider, - model=model, - error=error_text, - ) + error=error_text, + ) finally: circuit_cls._provider_circuit_state = saved_circuit_state elapsed = time.perf_counter() - started_at if ok: - lines.append(f"│ {model:<12} ✓ 正常 ({elapsed:.1f}s) │") + lines.append(f"│ {level:<12} ✓ 正常 ({elapsed:.1f}s) │") else: - lines.append(f"│ {model:<12} ✗ 失败 ({elapsed:.1f}s) │") + lines.append(f"│ {level:<12} ✗ 失败 ({elapsed:.1f}s) │") if error_text: - startup_log("模型连通性失败(不阻断启动)", model=model, provider=provider, error=error_text) + startup_log("模型连通性失败(不阻断启动)", tier=level.lower(), error=error_text) lines.append("└──────────────────────────────┘") for line in lines: diff --git a/backend/app/models/ai_call_log.py b/backend/app/models/ai_call_log.py index 72e2fb0c..0ae2f028 100644 --- a/backend/app/models/ai_call_log.py +++ b/backend/app/models/ai_call_log.py @@ -32,3 +32,10 @@ class AICallLog(BaseLog): tokens_used: Mapped[Optional[int]] = mapped_column(Integer, nullable=True) cost: Mapped[Optional[float]] = mapped_column(DECIMAL(10, 6), nullable=True) status: Mapped[str] = mapped_column(VARCHAR(50), nullable=False) + planned_tier: Mapped[Optional[str]] = mapped_column(VARCHAR(16), nullable=True, index=True) + executed_tier: Mapped[Optional[str]] = mapped_column(VARCHAR(16), nullable=True, index=True) + routing_decision: Mapped[Optional[str]] = mapped_column(VARCHAR(64), nullable=True, index=True) + route_source: Mapped[Optional[str]] = mapped_column(VARCHAR(32), nullable=True) + route_rule_name: Mapped[Optional[str]] = mapped_column(VARCHAR(255), nullable=True) + routing_config_version: Mapped[Optional[str]] = mapped_column(VARCHAR(64), nullable=True) + routing_config_updated_at: Mapped[Optional[str]] = mapped_column(VARCHAR(64), nullable=True) diff --git a/backend/app/services/ai_pmo/ai_pmo_kernel_ingress_flow/mixin_entry.py b/backend/app/services/ai_pmo/ai_pmo_kernel_ingress_flow/mixin_entry.py index 653fe0cb..e2826739 100644 --- a/backend/app/services/ai_pmo/ai_pmo_kernel_ingress_flow/mixin_entry.py +++ b/backend/app/services/ai_pmo/ai_pmo_kernel_ingress_flow/mixin_entry.py @@ -460,13 +460,17 @@ class KernelIngressFlowEntryMixin: return None result_status = str(decision.get("status") or "").strip().lower() - if result_status in {"succeeded", "draft_pending_confirm", "pending_manual", "cancelled", "ignored"}: + target_state = str(decision.get("target_state") or "").strip() + if target_state in {STATE_SUCCEEDED, STATE_DRAFT_PENDING_CONFIRM, STATE_WAITING_MANUAL, STATE_CANCELLED, STATE_FAILED}: + work_item.state = target_state + elif result_status in {"succeeded", "draft_pending_confirm", "pending_manual", "cancelled", "ignored"}: work_item.state = STATE_SUCCEEDED else: work_item.state = STATE_FAILED work_item.intent = ROUTE_FOLLOWUP_CONFIRM work_item.project_id = _safe_int(decision.get("project_id")) work_item.signal_id = _safe_int(decision.get("signal_id")) + draft_pending = work_item.state == STATE_DRAFT_PENDING_CONFIRM execution_result = { "status": result_status or "failed_safe", "followup_reply": str(decision.get("followup_reply") or "").strip(), @@ -480,8 +484,16 @@ class KernelIngressFlowEntryMixin: work_item.execution_result = execution_result work_item.review_summary = { "verified": work_item.state == STATE_SUCCEEDED, - "issues": [] if work_item.state == STATE_SUCCEEDED else ["draft_confirm_failed"], - "next_step": "已处理草稿确认请求" if work_item.state == STATE_SUCCEEDED else "请检查草稿确认状态", + "issues": [] if work_item.state in {STATE_SUCCEEDED, STATE_DRAFT_PENDING_CONFIRM} else ["draft_confirm_failed"], + "next_step": ( + "已处理草稿确认请求" + if work_item.state == STATE_SUCCEEDED + else ( + "等待用户补充修改意见" + if draft_pending + else "请检查草稿确认状态" + ) + ), } followup_text = str(decision.get("followup_reply") or "").strip() or "草稿状态已更新。" if result_status == "draft_pending_confirm": diff --git a/backend/app/services/ai_pmo/ai_pmo_typed_executor_discovery/mixin_analysis.py b/backend/app/services/ai_pmo/ai_pmo_typed_executor_discovery/mixin_analysis.py index 529dd3ed..4f98cb22 100644 --- a/backend/app/services/ai_pmo/ai_pmo_typed_executor_discovery/mixin_analysis.py +++ b/backend/app/services/ai_pmo/ai_pmo_typed_executor_discovery/mixin_analysis.py @@ -30,7 +30,7 @@ class DiscoveryAnalysisMixin: prompt, db=self.db, tenant_id=self.tenant_id, - function_name="adaptive_rag_answer_deep_signal_batch_analysis", + function_name="ai_pmo_signal_batch_analysis", use_prompt_profile=False, temperature=0.1, max_tokens=min(2800, 520 + max(requested_count, 1) * 420), diff --git a/backend/app/services/ai_pmo/ai_pmo_typed_executor_execute/mixin_helpers.py b/backend/app/services/ai_pmo/ai_pmo_typed_executor_execute/mixin_helpers.py index de5b88c2..c9176c6a 100644 --- a/backend/app/services/ai_pmo/ai_pmo_typed_executor_execute/mixin_helpers.py +++ b/backend/app/services/ai_pmo/ai_pmo_typed_executor_execute/mixin_helpers.py @@ -121,7 +121,7 @@ class ExecuteHelpersMixin: prompt, db=self.db, tenant_id=self.tenant_id, - function_name="project_draft_analysis_glm47_fallback", + function_name="ai_pmo_signal_title_refine", use_prompt_profile=False, temperature=0.0, max_tokens=240, diff --git a/backend/app/services/ai_pmo/draft_confirm_handler.py b/backend/app/services/ai_pmo/draft_confirm_handler.py index 620f9f71..e73cbdfa 100644 --- a/backend/app/services/ai_pmo/draft_confirm_handler.py +++ b/backend/app/services/ai_pmo/draft_confirm_handler.py @@ -11,6 +11,7 @@ for _name, _value in vars(_common).items(): if not _name.startswith("__"): globals()[_name] = _value +from ..ai_steward_service import AIStewardService from ..signal.draft_generator_service import DraftGeneratorService from ...models.feishu_reply_outbox import FeishuReplyOutbox @@ -327,16 +328,39 @@ class DraftConfirmHandler: _, route_action = RouteResolver._split_route_key(route_key) draft_payload = locked.draft_content if isinstance(locked.draft_content, dict) else {} existing_execution = locked.execution_result if isinstance(locked.execution_result, dict) else {} + canonical_intent = RouteResolver.canonical_intent_for_route(route_key) + creator_user_id = _safe_int(getattr(locked, "owner_user_id", None)) + if creator_user_id is None and isinstance(target_ingest.payload, dict): + creator_user_id = _safe_int(target_ingest.payload.get("owner_user_id")) + project_payload: Optional[dict[str, Any]] = None + signal_source_count = 0 try: - signal = await self._persist_signal_from_draft( - work_item=locked, - draft=draft_payload, - source_system=source_system, - source_message_id=source_message_id, - sender=sender, - message_text=message_text, - ) + if canonical_intent == INTENT_PROJECT_CREATE: + project_payload = await self._persist_project_from_draft( + work_item=locked, + draft=draft_payload, + source_system=source_system, + source_message_id=source_message_id, + sender=sender, + message_text=message_text, + creator_user_id=creator_user_id, + ) + signal_source_count = len( + project_payload.get("signal_sources") + if isinstance(project_payload.get("signal_sources"), list) + else [] + ) + signal = project_payload.get("signal") if isinstance(project_payload.get("signal"), dict) else None + else: + signal = await self._persist_signal_from_draft( + work_item=locked, + draft=draft_payload, + source_system=source_system, + source_message_id=source_message_id, + sender=sender, + message_text=message_text, + ) except Exception as exc: locked.state = STATE_FAILED locked.draft_expires_at = None @@ -368,7 +392,15 @@ class DraftConfirmHandler: "workflow_route": route_key, } - signal_id = _safe_int(getattr(signal, "id", None)) + project_data = project_payload.get("project") if isinstance(project_payload, dict) and isinstance(project_payload.get("project"), dict) else {} + project_id = _safe_int(project_payload.get("project_id")) if isinstance(project_payload, dict) else None + if project_id is None: + project_id = _safe_int(project_data.get("id")) + signal_id = _safe_int(project_payload.get("signal_id")) if isinstance(project_payload, dict) else None + if signal_id is None and isinstance(signal, dict): + signal_id = _safe_int(signal.get("id")) + if project_id is not None: + locked.project_id = int(project_id) if signal_id is not None: locked.signal_id = int(signal_id) locked.state = STATE_SUCCEEDED @@ -378,6 +410,8 @@ class DraftConfirmHandler: reply_text = self._build_confirm_success_text( draft=draft_payload, signal_id=_safe_int(locked.signal_id), + project_id=_safe_int(locked.project_id), + signal_source_count=signal_source_count, ) locked.execution_result = { **existing_execution, @@ -386,6 +420,10 @@ class DraftConfirmHandler: "workflow_action": route_action, "signal_id": _safe_int(locked.signal_id), "project_id": _safe_int(locked.project_id), + "signal_source_count": signal_source_count, + "signal_sources": project_payload.get("signal_sources") if isinstance(project_payload, dict) else [], + "created_project": project_payload.get("project") if isinstance(project_payload, dict) else None, + "created_signal": project_payload.get("signal") if isinstance(project_payload, dict) else None, "draft_version": current_version, "followup_reply": reply_text, "committed_from_draft": True, @@ -471,6 +509,21 @@ class DraftConfirmHandler: "workflow_route": RouteResolver.normalize_route_key(str(locked.intent or "")), } + if not str(revision_note or "").strip(): + return { + "handled": True, + "status": "draft_pending_confirm", + "followup_reply": "请先补充修改意见再继续,例如:修改 目标客户改成北美 DTC 品牌。", + "target_work_item_id": int(locked.id), + "target_state": STATE_DRAFT_PENDING_CONFIRM, + "target_intent": str(locked.intent or "").strip() or None, + "project_id": _safe_int(locked.project_id), + "signal_id": _safe_int(locked.signal_id), + "workflow_route": RouteResolver.normalize_route_key(str(locked.intent or "")), + "draft_version": current_version, + "draft_content": locked.draft_content if isinstance(locked.draft_content, dict) else None, + } + ingest_payload = target_ingest.payload if isinstance(target_ingest.payload, dict) else {} original_message = str(ingest_payload.get("message_text") or "").strip() if not original_message: @@ -776,17 +829,79 @@ class DraftConfirmHandler: return None @staticmethod - def _build_confirm_success_text(*, draft: dict[str, Any], signal_id: Optional[int]) -> str: + def _build_confirm_success_text( + *, + draft: dict[str, Any], + signal_id: Optional[int], + project_id: Optional[int] = None, + signal_source_count: int = 0, + ) -> str: title = str(draft.get("title") or "需求").strip() or "需求" assignee_name = str(draft.get("recommended_assignee_name") or "待指定").strip() or "待指定" - lines = ["✅ 已入库", "", f"📌 {title}"] - if signal_id is not None: - lines.append(f"信号 #{int(signal_id)} 已创建") + if project_id is not None: + lines = ["✅ 已入库", "", f"📌 {title}", f"项目 #{int(project_id)} 已创建"] + if signal_id is not None: + lines.append(f"主信号 #{int(signal_id)} 已创建") + if signal_source_count > 0: + lines.append(f"信号源:{int(signal_source_count)} 个") + else: + lines.append("信号源:已生成") else: - lines.append("已完成入库并开始执行") + lines = ["✅ 已入库", "", f"📌 {title}"] + if signal_id is not None: + lines.append(f"信号 #{int(signal_id)} 已创建") + else: + lines.append("已完成入库并开始执行") lines.append(f"负责人:{assignee_name}") return "\n".join(lines) + async def _persist_project_from_draft( + self, + *, + work_item: WorkItem, + draft: dict[str, Any], + source_system: str, + source_message_id: str, + sender: str, + message_text: str, + creator_user_id: Optional[int], + ) -> dict[str, Any]: + _ = work_item, source_system, source_message_id, sender + creator_id = _safe_int(creator_user_id) + if creator_id is None or creator_id <= 0: + raise RuntimeError("草稿缺少创建人信息,无法创建项目。") + + project_input_text = DraftGeneratorService.render_draft_message(draft).split("\n---\n", 1)[0].strip() + if not project_input_text: + project_input_text = str(draft.get("original_text") or message_text or "").strip() + steward = AIStewardService(self.db) + requested_member_user_ids: list[int] = [] + assignee_id = _safe_int(draft.get("recommended_assignee_id")) + if assignee_id is not None and assignee_id > 0: + requested_member_user_ids = [int(assignee_id)] + payload = await steward.create_project_from_natural_language( + input_text=project_input_text or str(message_text or "").strip(), + creator_id=int(creator_id), + tenant_id=int(self.tenant_id), + requested_member_user_ids=requested_member_user_ids or None, + ) + if not isinstance(payload, dict): + raise RuntimeError("项目创建返回结果无效。") + + project = payload.get("project") if isinstance(payload.get("project"), dict) else {} + signal = payload.get("signal") if isinstance(payload.get("signal"), dict) else {} + project_id = _safe_int(project.get("id")) + signal_id = _safe_int(signal.get("id")) + signal_sources = payload.get("signal_sources") if isinstance(payload.get("signal_sources"), list) else [] + return { + **payload, + "creator_user_id": int(creator_id), + "project_id": project_id, + "signal_id": signal_id, + "signal_source_count": len(signal_sources), + "project_input_text": project_input_text or str(message_text or "").strip(), + } + async def _persist_signal_from_draft( self, *, diff --git a/backend/app/services/ai_service_core/mixin_runtime_core.py b/backend/app/services/ai_service_core/mixin_runtime_core.py index 38651e53..6f541946 100644 --- a/backend/app/services/ai_service_core/mixin_runtime_core.py +++ b/backend/app/services/ai_service_core/mixin_runtime_core.py @@ -63,7 +63,6 @@ class AIServiceRuntimeCoreMixin: "api_key": str( api_key or os.getenv("DASHSCOPE_API_KEY") - or os.getenv("GLM_API_KEY", "") or "" ).strip(), "model": str( diff --git a/backend/app/services/ai_service_core/shared.py b/backend/app/services/ai_service_core/shared.py index a1feb557..05645b2c 100644 --- a/backend/app/services/ai_service_core/shared.py +++ b/backend/app/services/ai_service_core/shared.py @@ -47,7 +47,11 @@ _TIER_DEFAULTS = get_tier_defaults() PROVIDER_DEFAULTS = _PROVIDER_DEFAULTS TIER_DEFAULTS = _TIER_DEFAULTS DEFAULT_OPENAI_BASE_URL = str(_PROVIDER_DEFAULTS.get("openai", {}).get("base_url") or "https://api.edgefn.net/v1") -DEFAULT_OPENAI_MODEL = str(_PROVIDER_DEFAULTS.get("openai", {}).get("model") or "MiniMax-M2.5") +DEFAULT_OPENAI_MODEL = str( + _PROVIDER_DEFAULTS.get("openai", {}).get("model") + or _TIER_DEFAULTS.get("high", {}).get("model") + or "GLM-5" +) DEFAULT_DASHSCOPE_BASE_URL = str(_PROVIDER_DEFAULTS.get("glm", {}).get("base_url") or "https://dashscope.aliyuncs.com/compatible-mode/v1") DEFAULT_DASHSCOPE_MODEL = str(_PROVIDER_DEFAULTS.get("glm", {}).get("model") or "qwen-plus") DEFAULT_GLM_MODEL = DEFAULT_DASHSCOPE_MODEL diff --git a/backend/app/services/ai_steward_service/mixin_creation_normalize_persist.py b/backend/app/services/ai_steward_service/mixin_creation_normalize_persist.py index 13b963f4..34b3a37c 100644 --- a/backend/app/services/ai_steward_service/mixin_creation_normalize_persist.py +++ b/backend/app/services/ai_steward_service/mixin_creation_normalize_persist.py @@ -121,6 +121,7 @@ class AIStewardCreationPersistMixin: input_text: str, creator_id: int, tenant_id: int, + requested_member_user_ids: list[int] | None = None, ) -> dict[str, Any]: project_data = normalized["project"] await ensure_project_id_sequence(self.db) @@ -143,6 +144,7 @@ class AIStewardCreationPersistMixin: project_id=int(project.id), owner_id=int(creator_id), assigned_role=project.assigned_role, + requested_member_user_ids=requested_member_user_ids or [], ) signal_data = normalized["signal"] diff --git a/backend/app/services/ai_steward_service/mixin_creation_pipeline.py b/backend/app/services/ai_steward_service/mixin_creation_pipeline.py index 94f7cc5d..3df0afb8 100644 --- a/backend/app/services/ai_steward_service/mixin_creation_pipeline.py +++ b/backend/app/services/ai_steward_service/mixin_creation_pipeline.py @@ -8,6 +8,7 @@ class AIStewardCreationPipelineMixin: input_text: str, creator_id: int, tenant_id: int = 1, + requested_member_user_ids: list[int] | None = None, ) -> Dict[str, Any]: """零配置项目创建,统一走 draft -> normalize -> persist 三段式。""" analysis = await self._analyze_project_input(input_text, tenant_id) @@ -27,6 +28,7 @@ class AIStewardCreationPipelineMixin: input_text=input_text, creator_id=creator_id, tenant_id=tenant_id, + requested_member_user_ids=requested_member_user_ids, ) await self.db.commit() @@ -94,4 +96,3 @@ class AIStewardCreationPipelineMixin: "tasks": task_drafts, "risks": self._build_risk_alert_drafts(analysis=analysis), } - diff --git a/backend/app/services/ai_steward_service/mixin_draft_generation.py b/backend/app/services/ai_steward_service/mixin_draft_generation.py index 5381f4e1..c2e170ff 100644 --- a/backend/app/services/ai_steward_service/mixin_draft_generation.py +++ b/backend/app/services/ai_steward_service/mixin_draft_generation.py @@ -15,7 +15,7 @@ class AIStewardDraftGenerationMixin: analysis = await self._generate_project_creation_object( prompt=prompt, tenant_id=tenant_id, - function_name="project_requirement_extraction_glm47_fallback", + function_name="project_requirement_extraction", stage_label="AI 管家项目需求分析失败", ) return analysis @@ -54,7 +54,7 @@ class AIStewardDraftGenerationMixin: payload = await self._generate_project_creation_object( prompt=prompt, tenant_id=tenant_id, - function_name="project_draft_signal_glm47_fallback", + function_name="project_from_text_signal", stage_label="AI 管家竞品信号生成失败", ) return payload @@ -97,7 +97,7 @@ class AIStewardDraftGenerationMixin: charter_data = await self._generate_project_creation_object( prompt=prompt, tenant_id=tenant_id, - function_name="project_draft_charter_glm47_fallback", + function_name="project_from_text_charter", stage_label="AI 管家项目宪章生成失败", ) @@ -152,7 +152,7 @@ class AIStewardDraftGenerationMixin: tasks_data = await self._generate_project_creation_object( prompt=prompt, tenant_id=tenant_id, - function_name="project_draft_task_chain_glm47_fallback", + function_name="project_from_text_task_chain", stage_label="AI 管家任务链生成失败", timeout_seconds=PROJECT_CREATION_TASK_CHAIN_TIMEOUT_SECONDS, ) diff --git a/backend/app/services/ai_steward_service/mixin_json_parsing.py b/backend/app/services/ai_steward_service/mixin_json_parsing.py index 7f4712c8..bd9b2338 100644 --- a/backend/app/services/ai_steward_service/mixin_json_parsing.py +++ b/backend/app/services/ai_steward_service/mixin_json_parsing.py @@ -10,10 +10,6 @@ class AIStewardJsonParsingMixin: return True return any(marker in normalized for marker in PROJECT_CREATION_STUB_MARKERS) - @staticmethod - def _project_creation_high_tier_function(function_name: str) -> str: - return str(PROJECT_CREATION_LOW_TO_HIGH_FUNCTION_MAP.get(str(function_name or "").strip(), "") or "").strip() - @staticmethod def _strip_code_fence(text: str) -> str: raw = str(text or "").strip() @@ -190,47 +186,6 @@ class AIStewardJsonParsingMixin: return cleaned_response except Exception as exc: last_error = exc if isinstance(exc, Exception) else RuntimeError(str(exc)) - high_tier_function = self._project_creation_high_tier_function(function_name) - if high_tier_function: - high_started_at = time.perf_counter() - try: - fallback_response = await asyncio.wait_for( - self.llm.generate( - prompt, - tenant_id=tenant_id, - function_name=high_tier_function, - response_mode="fast", - use_prompt_profile=False, - temperature=0.0, - max_tokens=2200, - ), - timeout=timeout_seconds, - ) - cleaned_fallback = str(fallback_response or "").strip() - high_elapsed_seconds = round(time.perf_counter() - high_started_at, 2) - if self._looks_like_project_creation_stub_response(cleaned_fallback): - raise RuntimeError("project_creation_stub_response") - logger.info( - "ai_steward_project_creation_step_escalated_ok", - tenant_id=int(tenant_id), - function_name=function_name, - high_tier_function=high_tier_function, - attempt=attempt_index + 1, - elapsed_seconds=high_elapsed_seconds, - ) - return cleaned_fallback - except Exception as high_exc: - last_error = high_exc if isinstance(high_exc, Exception) else RuntimeError(str(high_exc)) - logger.warning( - "ai_steward_project_creation_step_escalated_retry", - tenant_id=int(tenant_id), - function_name=function_name, - high_tier_function=high_tier_function, - attempt=attempt_index + 1, - elapsed_seconds=round(time.perf_counter() - high_started_at, 2), - error_type=type(high_exc).__name__, - error=str(high_exc), - ) logger.warning( "ai_steward_project_creation_step_retry", tenant_id=int(tenant_id), @@ -244,4 +199,3 @@ class AIStewardJsonParsingMixin: raise RuntimeError( f"AI 管家项目创建步骤失败:function={function_name}, reason={last_error or 'unknown_error'}" ) from last_error - diff --git a/backend/app/services/ai_steward_service/shared.py b/backend/app/services/ai_steward_service/shared.py index f22e31cb..53179f85 100644 --- a/backend/app/services/ai_steward_service/shared.py +++ b/backend/app/services/ai_steward_service/shared.py @@ -72,12 +72,4 @@ PROJECT_CREATION_STUB_MARKERS = ( "模型服务异常", "请稍后重试", ) -PROJECT_CREATION_LOW_TO_HIGH_FUNCTION_MAP = { - "project_draft_analysis_glm47_fallback": "project_from_text_analysis", - "project_draft_signal_glm47_fallback": "project_from_text_signal", - "project_draft_charter_glm47_fallback": "project_from_text_charter", - "project_draft_task_chain_glm47_fallback": "project_from_text_task_chain", - "project_requirement_extraction_glm47_fallback": "project_requirement_extraction", -} PROJECT_CREATION_SERIAL_DRAFT_STEPS = _env_int("AI_STEWARD_PROJECT_CREATION_SERIAL_DRAFT_STEPS", 1) == 1 - diff --git a/backend/app/services/integrations/feishu_integration_service/mixin_receive_capture_flow.py b/backend/app/services/integrations/feishu_integration_service/mixin_receive_capture_flow.py index 9ae8a2d0..cd07850f 100644 --- a/backend/app/services/integrations/feishu_integration_service/mixin_receive_capture_flow.py +++ b/backend/app/services/integrations/feishu_integration_service/mixin_receive_capture_flow.py @@ -101,7 +101,7 @@ class FeishuReceiveCaptureFlowMixin: f"auto_tasks={len(result.get('auto_tasks', []) if isinstance(result.get('auto_tasks', []), list) else [])}, " f"ai_trace_id={result.get('ai_trace_id')}" ) - scene = self._classify_message_scene(capture_result=result) + scene = self._classify_message_scene(capture_result=result, message_content=message_content) self._emit_state_transition( trace_id=state_trace_id, state="CLASSIFIED", diff --git a/backend/app/services/integrations/feishu_integration_service/mixin_receive_routing.py b/backend/app/services/integrations/feishu_integration_service/mixin_receive_routing.py index 4009108d..6787d5aa 100644 --- a/backend/app/services/integrations/feishu_integration_service/mixin_receive_routing.py +++ b/backend/app/services/integrations/feishu_integration_service/mixin_receive_routing.py @@ -1,6 +1,7 @@ from __future__ import annotations from .shared import * +from ...ai_pmo.ai_pmo_intent import IntentResolver class FeishuReceiveRoutingMixin: async def _route_scene_reply( @@ -147,11 +148,44 @@ class FeishuReceiveRoutingMixin: return reply_route @staticmethod - def _classify_message_scene(*, capture_result: Dict[str, Any]) -> str: + def _classify_message_scene(*, capture_result: Dict[str, Any], message_content: str = "") -> str: if not isinstance(capture_result, dict): return "error" + intent = str(capture_result.get("intent") or "").strip().lower() status = str(capture_result.get("status") or "").strip().lower() classification = str(capture_result.get("classification") or "").strip().lower() + execute_intents = { + "project.create", + "project.update", + "project.archive", + "project.reopen", + "project.delete", + "task.create", + "task.update", + "task.assign", + "task.prioritize", + "task.status_transition", + "task.delete", + "personnel.role_update", + "personnel.workload_update", + "personnel.availability_update", + "knowledge.doc_upsert", + "knowledge.chunk_upsert", + "knowledge.link_upsert", + "knowledge.reindex", + "notification.create", + "notification.ack", + "notification.close", + "signal.create", + "signal_source.onboard", + "followup.confirm", + "followup.continue", + } + raw_intent = str(IntentResolver.resolve(text=message_content, project_id=None).name or "").strip().lower() + if raw_intent in execute_intents or raw_intent in {"project", "signal", "command", "new_project", "project_signal"}: + return "execute" + if intent in execute_intents or classification in {"project", "signal", "command", "new_project", "project_signal"}: + return "execute" if status in {"captured", "ignored"}: return "execute" if status == "answered" or classification in {"question", "qa"}: diff --git a/backend/app/services/integrations/feishu_integration_service/mixin_reply_llm_core.py b/backend/app/services/integrations/feishu_integration_service/mixin_reply_llm_core.py index e7532b19..e85d1111 100644 --- a/backend/app/services/integrations/feishu_integration_service/mixin_reply_llm_core.py +++ b/backend/app/services/integrations/feishu_integration_service/mixin_reply_llm_core.py @@ -62,6 +62,22 @@ class FeishuReplyLLMCoreMixin: ) return + action_ack = self._build_action_request_ack_reply( + message_content=message_content, + capture_result=capture_result, + ) + if action_ack: + await self._safe_reply_text( + chat_id=chat_id, + mention_open_id=sender_open_id, + mention_name=sender_name, + text=action_ack, + trace_id=trace_id, + source_message_id=source_message_id, + perf_t0=perf_t0, + ) + return + try: status = str(capture_result.get("status") or "unknown") classification = str(capture_result.get("classification") or "unknown") @@ -151,12 +167,20 @@ class FeishuReplyLLMCoreMixin: paragraphs = lines thinking_prefixes = ( + "好的,我来帮你", + "我来帮你", + "我先帮你", "让我先", "让我尝试", "我来看看", "我先查", + "我先检查", "让我看看", "我需要先", + "我会先", + "通过api创建", + "通过 api 创建", + "后端服务是否运行", ) first_clean_idx: Optional[int] = None @@ -197,6 +221,46 @@ class FeishuReplyLLMCoreMixin: "你直接说目标、对象和期望动作即可。" ) + @staticmethod + def _is_action_request_message(message_content: Optional[str]) -> bool: + normalized = re.sub(r"\s+", "", str(message_content or "").lower()) + if not normalized: + return False + action_tokens = ( + "创建项目", + "新建项目", + "立项", + "创建信号", + "创建信号源", + "新增信号源", + "接入信号源", + "信号源接入", + ) + return any(token in normalized for token in action_tokens) + + @classmethod + def _build_action_request_ack_reply( + cls, + *, + message_content: Optional[str], + capture_result: Dict[str, Any], + ) -> Optional[str]: + if not cls._is_action_request_message(message_content): + return None + normalized = re.sub(r"\s+", "", str(message_content or "").lower()) + project_like = any(token in normalized for token in ("创建项目", "新建项目", "立项")) + signal_like = any(token in normalized for token in ("创建信号", "创建信号源", "新增信号源", "接入信号源", "信号源接入")) + if project_like and signal_like: + return "已收到,项目创建和信号源接入请求已记录。" + if project_like: + return "已收到,项目创建请求已记录。" + if signal_like: + return "已收到,信号源接入请求已记录。" + status = str(capture_result.get("status") or "").strip().lower() + if status in {"captured", "answered"}: + return "已收到,正在处理。" + return None + @staticmethod def _strip_identity_lead(reply_text: str) -> str: text = str(reply_text or "").strip() diff --git a/backend/app/services/llm_service/mixin_chat_openai.py b/backend/app/services/llm_service/mixin_chat_openai.py index c9ea5af2..53a976f7 100644 --- a/backend/app/services/llm_service/mixin_chat_openai.py +++ b/backend/app/services/llm_service/mixin_chat_openai.py @@ -83,18 +83,15 @@ class LLMChatOpenAIMixin: "LLM", ( "网络异常重试:" - f"function={function_label}, provider={provider_name}, model={model}, " + f"function={function_label}, " f"retry_in={retry_delay:.2f}s, left={network_retry_budget - 1}, " f"error={type(exc).__name__}" ), ) logger.warning( - "llm_network_retry trace_id=%s function_name=%s provider=%s model=%s base_url=%s retry_attempt=%s retry_delay_seconds=%.2f error_type=%s error=%s", + "llm_network_retry trace_id=%s function_name=%s retry_attempt=%s retry_delay_seconds=%.2f error_type=%s error=%s", request_trace_id, function_label, - provider_name, - model, - str(base_url or "")[:120], network_retry_attempt, retry_delay, type(exc).__name__, @@ -140,7 +137,7 @@ class LLMChatOpenAIMixin: "LLM", ( "HTTP状态重试:" - f"function={function_label}, provider={provider_name}, model={model}, " + f"function={function_label}, " f"status={status_code}, retry_in={retry_delay:.2f}s, " f"left={http_status_retry_budget - 1}" ), @@ -156,8 +153,7 @@ class LLMChatOpenAIMixin: "LLM", ( "结构化参数降级重试:" - f"function={function_label}, provider={provider_name}, model={model}, " - f"status={status_code}" + f"function={function_label}, status={status_code}" ), ) use_json_response_format = False @@ -196,8 +192,7 @@ class LLMChatOpenAIMixin: "LLM", ( "空content定向重试:" - f"function={function_label}, provider={provider_name}, model={model}, " - "retry_with=请直接输出答案" + f"function={function_label}, retry_with=请直接输出答案" ), ) continue @@ -216,9 +211,6 @@ class LLMChatOpenAIMixin: "llm_timeout_payload_detected", trace_id=request_trace_id, function_name=function_label, - provider=provider_name, - model=model, - base_url=str(base_url or "")[:120], request_timeout_seconds=float(request_timeout), agent_timeout_seconds=openclaw_agent_timeout, text_preview=text.replace("\n", " ")[:260], diff --git a/backend/app/services/llm_service/mixin_chat_transport.py b/backend/app/services/llm_service/mixin_chat_transport.py index 46aa21f3..41dbddcd 100644 --- a/backend/app/services/llm_service/mixin_chat_transport.py +++ b/backend/app/services/llm_service/mixin_chat_transport.py @@ -63,7 +63,7 @@ class LLMChatTransportMixin: model=model, ) if use_provider_circuit_breaker and self._is_provider_circuit_open(circuit_key): - raise ValueError(f"provider_circuit_open:{provider_name}:{model}") + raise ValueError(f"provider_circuit_open:{provider_name}") key = api_key.strip() messages: list[dict[str, str]] = [] cleaned_system_prompt = self._inject_function_guardrails( @@ -83,7 +83,7 @@ class LLMChatTransportMixin: "OpenClaw调用", ( f"start ts={_cn_now_text_ms()} trace_id={request_trace_id} " - f"function={function_label} provider={provider_name} model={model} " + f"function={function_label} " f"request_timeout={float(request_timeout):.2f}s " f"agent_timeout={openclaw_agent_timeout if openclaw_agent_timeout is not None else 'N/A'}" ), @@ -137,9 +137,6 @@ class LLMChatTransportMixin: "llm_timeout_exception_detected", trace_id=request_trace_id, function_name=function_label, - provider=provider_name, - model=model, - base_url=str(base_url or "")[:120], request_timeout_seconds=float(request_timeout), agent_timeout_seconds=openclaw_agent_timeout, error_type=type(exc).__name__, @@ -191,4 +188,3 @@ class LLMChatTransportMixin: if not text and not isinstance(data, dict): raise ValueError("Ollama 返回空内容") return ChatCompletionResult(text=text, raw_response=data) - diff --git a/backend/app/services/llm_service/mixin_engine_resolution_core.py b/backend/app/services/llm_service/mixin_engine_resolution_core.py index e2ee7922..5e7d7341 100644 --- a/backend/app/services/llm_service/mixin_engine_resolution_core.py +++ b/backend/app/services/llm_service/mixin_engine_resolution_core.py @@ -163,6 +163,15 @@ class LLMEngineResolutionCoreMixin: ) if not self._mixed_routing_enabled(): + default_tier = self._resolve_function_tier(function_name) + active_engine = _merge_engine_routing_meta( + active_engine, + tier_alias=default_tier, + planned_tier=default_tier, + executed_tier=default_tier, + route_source="active_engine", + routing_decision="active_engine", + ) return self._ensure_engine_allowed_for_function( engine=active_engine, function_name=function_name, @@ -172,6 +181,14 @@ class LLMEngineResolutionCoreMixin: tier = self._resolve_function_tier(function_name) if tier not in {"high", "low"}: + active_engine = _merge_engine_routing_meta( + active_engine, + tier_alias="low", + planned_tier="low", + executed_tier="low", + route_source="active_engine", + routing_decision="active_engine", + ) return self._ensure_engine_allowed_for_function( engine=active_engine, function_name=function_name, @@ -191,12 +208,22 @@ class LLMEngineResolutionCoreMixin: settings=settings, engine_label="active_engine", ) - provider = str(routed.get("provider") or "") + config_snapshot = build_routing_config_snapshot() + routed = _merge_engine_routing_meta( + routed, + tier_alias=tier, + planned_tier=tier, + executed_tier=tier, + route_source="default", + routing_decision="tier_hint", + routing_config_version=config_snapshot.get("routing_config_version"), + routing_config_updated_at=config_snapshot.get("routing_config_updated_at"), + ) _runtime_log( "LLM路由", ( "函数路由生效:" - f"function={function_name}, tier={tier}, provider={provider}, model={routed.get('model')}" + f"function={function_name}, {_routing_meta_log_text(_read_engine_routing_meta(routed))}" ), ) return self._ensure_engine_allowed_for_function( @@ -234,31 +261,44 @@ class LLMEngineResolutionCoreMixin: override = mappings.get(fn) if isinstance(mappings.get(fn), dict) else None if not override or not bool(override.get("enabled", True)): return None - rule = { - "tier": str(override.get("tier") or "high").strip().lower() or "high", - "provider": str(override.get("provider") or "").strip().lower(), - "model": str(override.get("model") or "").strip(), - "api_env": str(override.get("api_env") or "").strip(), - "base_url": str(override.get("base_url") or "").strip(), - } - routed = self._resolve_engine_from_rule( - rule=rule, + tier_alias = _optional_tier_alias(override.get("tier_alias") or override.get("tier")) or "high" + config_bundle = load_model_config() + matched_rule = find_matching_rule( + fn, + tier_filter={tier_alias}, + bundle=config_bundle, + ) + routed = self._resolve_tier_engine( + tier=tier_alias, engines=engines, active_engine=active_engine, ) if routed is None: return None + config_snapshot = build_routing_config_snapshot(bundle=config_bundle) + routed = _merge_engine_routing_meta( + routed, + tier_alias=tier_alias, + planned_tier=tier_alias, + executed_tier=tier_alias, + route_source="override", + route_rule_name=str(matched_rule.get("name") or "").strip() if isinstance(matched_rule, dict) else "", + rule_priority=int(matched_rule.get("priority") or 0) if isinstance(matched_rule, dict) else None, + routing_decision="override_tier", + routing_config_version=config_snapshot.get("routing_config_version"), + routing_config_updated_at=config_snapshot.get("routing_config_updated_at"), + ) provider = str(routed.get("provider") or "") if provider != "ollama" and not str(routed.get("api_key") or "").strip(): raise ValueError( - "函数覆盖路由命中但目标引擎缺少 API Key:" - f"function={fn}, provider={provider}, model={routed.get('model')}" + "函数覆盖路由命中但目标 tier 缺少 API Key:" + f"function={fn}, tier_alias={tier_alias}" ) _runtime_log( "LLM路由", ( "函数覆盖路由命中:" - f"function={fn}, provider={routed.get('provider')}, model={routed.get('model')}" + f"function={fn}, {_routing_meta_log_text(_read_engine_routing_meta(routed))}" ), ) return self._ensure_openclaw_control_plane_engine(routed) @@ -278,4 +318,3 @@ class LLMEngineResolutionCoreMixin: if engines: return self._normalize_engine_runtime(engines[0]) return self._env_engine_runtime() - diff --git a/backend/app/services/llm_service/mixin_generate_db_chat.py b/backend/app/services/llm_service/mixin_generate_db_chat.py index cb97897c..130af99b 100644 --- a/backend/app/services/llm_service/mixin_generate_db_chat.py +++ b/backend/app/services/llm_service/mixin_generate_db_chat.py @@ -30,15 +30,13 @@ class LLMGenerateDbChatMixin: def _log_stub_failure(error_obj: Exception, *, used_stub: bool = False) -> None: elapsed_ms = (time.perf_counter() - started_at) * 1000 - provider = str(engine_meta.get("provider") or "") - model = str(engine_meta.get("model") or "") + routing_meta = _read_engine_routing_meta(engine_meta) outcome = "兜底 stub" if used_stub else "显式报错" logger.warning( - "[LLM_FAIL] 生成失败 → %s function=%s provider=%s model=%s error=%r duration_ms=%.0f", + "[LLM_FAIL] 生成失败 → %s function=%s route=%s error=%r duration_ms=%.0f", outcome, function_name, - provider, - model, + _routing_meta_log_text(routing_meta) or "unknown", error_obj, elapsed_ms, ) @@ -91,7 +89,7 @@ class LLMGenerateDbChatMixin: error=exc, ) if failover_response and isinstance(failover_engine, dict): - routing_decision = "glm47_error_escalate_to_high" + routing_decision = "low_to_high_escalated" status = "success_failover" response_text = failover_response engine_meta = failover_engine @@ -99,8 +97,7 @@ class LLMGenerateDbChatMixin: status = "failed" reason_text = self._build_model_error_message( function_name=function_name, - provider=str(engine_meta.get("provider") or ""), - model=str(engine_meta.get("model") or ""), + routing_meta=_read_engine_routing_meta(engine_meta), error=exc, ) response_text = reason_text @@ -121,15 +118,19 @@ class LLMGenerateDbChatMixin: status=status, provider=str(engine_meta.get("provider") or ""), model=str(engine_meta.get("model") or ""), + routing_meta=_read_engine_routing_meta(engine_meta), ) - if routing_decision != "primary_engine": + routing_meta = _read_engine_routing_meta(engine_meta) + if ( + routing_meta.get("planned_tier") + and routing_meta.get("executed_tier") + and routing_meta.get("planned_tier") != routing_meta.get("executed_tier") + ): _runtime_log( "LLM路由", ( - f"routing_decision={routing_decision}, " - f"function={function_name}, provider={engine_meta.get('provider')}, model={engine_meta.get('model')}" + f"function={function_name}, {_routing_meta_log_text(routing_meta)}" ), ) except Exception as log_exc: _runtime_log("LLM", f"调用日志写入失败:{log_exc}") - diff --git a/backend/app/services/llm_service/mixin_generate_db_structured.py b/backend/app/services/llm_service/mixin_generate_db_structured.py index 612aec4f..021f6dd4 100644 --- a/backend/app/services/llm_service/mixin_generate_db_structured.py +++ b/backend/app/services/llm_service/mixin_generate_db_structured.py @@ -136,6 +136,7 @@ class LLMGenerateDbStructuredMixin: error=exc, ) if isinstance(failover_result, StructuredExtractionResult) and isinstance(failover_engine, dict): + engine_meta = failover_engine provider = str(failover_engine.get("provider") or "") model = str(failover_engine.get("model") or "") response_text = failover_result.raw_text or json.dumps(failover_result.payload or {}, ensure_ascii=False) @@ -145,8 +146,7 @@ class LLMGenerateDbStructuredMixin: status = error_code reason_text = self._build_model_error_message( function_name=function_name, - provider=provider, - model=model, + routing_meta=_read_engine_routing_meta(engine_meta), error=exc, ) response_text = reason_text @@ -166,6 +166,7 @@ class LLMGenerateDbStructuredMixin: status=status, provider=provider, model=model, + routing_meta=_read_engine_routing_meta(engine_meta), ) except Exception as log_exc: _runtime_log("LLM", f"结构化调用日志写入失败:{log_exc}") @@ -247,8 +248,7 @@ class LLMGenerateDbStructuredMixin: "LLM结构化", ( "模型结构化修复失败:" - f"function={function_name}, provider={provider}, model={model}, " - f"error={type(exc).__name__}:{str(exc)[:180]}" + f"function={function_name}, error={type(exc).__name__}:{str(exc)[:180]}" ), ) return None diff --git a/backend/app/services/llm_service/mixin_guardrails_prompt.py b/backend/app/services/llm_service/mixin_guardrails_prompt.py index bf8e08bc..887b4c54 100644 --- a/backend/app/services/llm_service/mixin_guardrails_prompt.py +++ b/backend/app/services/llm_service/mixin_guardrails_prompt.py @@ -64,22 +64,20 @@ class LLMGuardrailsPromptMixin: def _build_model_error_message( *, function_name: str, - provider: str, - model: str, + routing_meta: dict[str, Any] | None = None, error: Exception, ) -> str: clean_error = re.sub(r"\s+", " ", str(error or "")).strip() if len(clean_error) > 160: clean_error = f"{clean_error[:160]}..." - provider_text = str(provider or "unknown").strip() or "unknown" - model_text = str(model or "unknown").strip() or "unknown" fn_text = str(function_name or "llm_generate").strip() or "llm_generate" + meta = _normalize_routing_meta(routing_meta) if not clean_error: clean_error = "upstream_empty_or_invalid_response" - return ( - f"模型调用失败:function={fn_text}, provider={provider_text}, model={model_text}, " - f"reason={clean_error}" - ) + route_bits = _routing_meta_log_text(meta) + if route_bits: + return f"模型调用失败:function={fn_text}, {route_bits}, reason={clean_error}" + return f"模型调用失败:function={fn_text}, reason={clean_error}" @staticmethod def _is_fast_local_planner_function(function_name: str) -> bool: diff --git a/backend/app/services/llm_service/mixin_openclaw_routing.py b/backend/app/services/llm_service/mixin_openclaw_routing.py index d8059920..55a7c037 100644 --- a/backend/app/services/llm_service/mixin_openclaw_routing.py +++ b/backend/app/services/llm_service/mixin_openclaw_routing.py @@ -32,26 +32,7 @@ class LLMOpenclawRoutingMixin: @staticmethod def _rule_matches_function(rule: dict[str, Any], function_name: str) -> bool: - if bool(rule.get("match_all", False)): - return True - fn = str(function_name or "").strip().lower() - if not fn: - return False - exact = str(rule.get("function_exact") or "").strip().lower() - if exact and fn == exact: - return True - prefix = str(rule.get("function_prefix") or "").strip().lower() - if prefix == "*" or prefix == "all": - return True - if prefix and fn.startswith(prefix): - return True - match_any = rule.get("match_any") - if isinstance(match_any, list): - for hint in match_any: - token = str(hint or "").strip().lower() - if token and token in fn: - return True - return False + return rule_matches_function(rule, function_name) def _match_openclaw_rule( self, @@ -62,29 +43,11 @@ class LLMOpenclawRoutingMixin: if not self._openclaw_routing_enabled(): return None bundle = self._load_openclaw_routing_bundle() - rules = bundle.get("rules") if isinstance(bundle.get("rules"), list) else [] - if not rules: - return None - - 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 tier_filter: - tier_name = str(row.get("tier") or "").strip().lower() - if tier_name not in tier_filter: - continue - if not self._rule_matches_function(row, function_name): - continue - priority = int(row.get("priority") or 0) - indexed.append((priority, -idx, row)) - - if not indexed: - return None - indexed.sort(reverse=True) - return dict(indexed[0][2]) + return find_matching_rule( + function_name, + tier_filter=tier_filter, + bundle=bundle, + ) def _resolve_openclaw_engine( self, @@ -104,13 +67,27 @@ class LLMOpenclawRoutingMixin: ) if routed is None: return None + bundle = self._load_openclaw_routing_bundle() + config_snapshot = build_routing_config_snapshot(bundle=bundle) + tier_alias = str(rule.get("tier") or "").strip().lower() or "low" + routed = _merge_engine_routing_meta( + routed, + tier_alias=tier_alias, + planned_tier=tier_alias, + executed_tier=tier_alias, + route_source="default", + route_rule_name=str(rule.get("name") or "").strip(), + rule_priority=int(rule.get("priority") or 0), + routing_decision="rule_match", + routing_config_version=config_snapshot.get("routing_config_version"), + routing_config_updated_at=config_snapshot.get("routing_config_updated_at"), + ) _runtime_log( "OpenClaw路由", ( "规则命中:" - f"function={function_name}, provider={routed.get('provider')}, " - f"model={routed.get('model')}, rule={rule.get('name') or 'unnamed'}" + f"function={function_name}, {_routing_meta_log_text(_read_engine_routing_meta(routed))}" ), ) return routed @@ -152,7 +129,7 @@ class LLMOpenclawRoutingMixin: except Exception: routed = None - if routed is None and tier in {"high", "low", "safety"}: + if routed is None and tier in {"high", "low"}: routed = self._resolve_tier_engine( tier=tier, engines=engines, @@ -176,7 +153,7 @@ class LLMOpenclawRoutingMixin: def _resolve_function_tier(self, function_name: str) -> str: fn = str(function_name or "").strip().lower() if not fn: - return str(os.getenv("LLM_ROUTING_DEFAULT_TIER", "active") or "active").strip().lower() + return str(os.getenv("LLM_ROUTING_DEFAULT_TIER", "low") or "low").strip().lower() high_hints = _parse_csv_items(os.getenv("LLM_ROUTING_HIGH_FUNCTIONS", "")) low_hints = _parse_csv_items(os.getenv("LLM_ROUTING_LOW_FUNCTIONS", "")) @@ -187,8 +164,7 @@ class LLMOpenclawRoutingMixin: return "high" if any(hint and hint in fn for hint in low_pool): return "low" - default_tier = str(os.getenv("LLM_ROUTING_DEFAULT_TIER", "active") or "active").strip().lower() - if default_tier != "active": + default_tier = str(os.getenv("LLM_ROUTING_DEFAULT_TIER", "low") or "low").strip().lower() + if default_tier in {"low", "high"}: return default_tier - # mixed routing 开启时,未知函数默认走 high,避免被 active 引擎“全吞”。 - return "high" if self._mixed_routing_enabled() else "active" + return "high" if self._mixed_routing_enabled() else "low" diff --git a/backend/app/services/llm_service/mixin_prompt_profile_runtime.py b/backend/app/services/llm_service/mixin_prompt_profile_runtime.py index 688570ba..2f3237ad 100644 --- a/backend/app/services/llm_service/mixin_prompt_profile_runtime.py +++ b/backend/app/services/llm_service/mixin_prompt_profile_runtime.py @@ -105,15 +105,13 @@ class LLMPromptProfileRuntimeMixin: api_env = str(defaults.get("api_env") or "").strip() api_key = os.getenv(api_env, "") if api_env else "" if provider_name == "glm": - api_key = os.getenv( - "DASHSCOPE_API_KEY", - os.getenv("GLM_API_KEY", os.getenv("ZHIPUAI_API_KEY", api_key)), - ) + api_key = os.getenv("DASHSCOPE_API_KEY", api_key) return { "id": f"env-{provider_name}", "name": f"{defaults['name']} (ENV)", "provider": provider_name, + "api_env": api_env, "api_key": str(api_key or "").strip(), "base_url": str(defaults["base_url"]).strip().rstrip("/"), "model": str(defaults["model"]).strip(), @@ -136,6 +134,7 @@ class LLMPromptProfileRuntimeMixin: runtime["name"] = str(runtime.get("name") or defaults["name"]).strip() if not runtime.get("id"): runtime["id"] = str(uuid4()) + runtime = _overlay_engine_api_key_from_env(runtime) return runtime async def _log_ai_call( @@ -148,9 +147,11 @@ class LLMPromptProfileRuntimeMixin: status: str, provider: str = "", model: str = "", + routing_meta: dict[str, Any] | None = None, ) -> None: tokens_used = max(len(prompt) // 4, 1) + max(len(response) // 4, 0) cost = self._estimate_cost(provider=provider, model=model, tokens=tokens_used) + meta = _normalize_routing_meta(routing_meta) row_payload = { "tenant_id": tenant_id, "user_id": None, @@ -160,6 +161,13 @@ class LLMPromptProfileRuntimeMixin: "tokens_used": tokens_used, "cost": cost, "status": status, + "planned_tier": meta.get("planned_tier") or None, + "executed_tier": meta.get("executed_tier") or None, + "routing_decision": meta.get("routing_decision") or None, + "route_source": meta.get("route_source") or None, + "route_rule_name": meta.get("route_rule_name") or None, + "routing_config_version": meta.get("routing_config_version") or None, + "routing_config_updated_at": meta.get("routing_config_updated_at") or None, } async with AsyncSessionLocal() as session: for attempt in range(2): diff --git a/backend/app/services/llm_service/mixin_structured_parse.py b/backend/app/services/llm_service/mixin_structured_parse.py index 9e31dbfc..8a38bd6f 100644 --- a/backend/app/services/llm_service/mixin_structured_parse.py +++ b/backend/app/services/llm_service/mixin_structured_parse.py @@ -172,7 +172,7 @@ class LLMStructuredParseMixin: # P0 guardrail: user-facing answer/reply/qa must never expose reasoning_content. if fn and any(token in fn for token in ("answer", "reply", "qa")): return False - if fn and ("title_refine" in fn or "project_draft_analysis_glm47_fallback" in fn): + if fn and "title_refine" in fn: return True raw = str(os.getenv("LLM_REASONING_CONTENT_FALLBACK", "0")).strip().lower() if raw not in {"1", "true", "yes", "on"}: diff --git a/backend/app/services/llm_service/mixin_tier_engine_selection.py b/backend/app/services/llm_service/mixin_tier_engine_selection.py index 387b660b..ae06707e 100644 --- a/backend/app/services/llm_service/mixin_tier_engine_selection.py +++ b/backend/app/services/llm_service/mixin_tier_engine_selection.py @@ -38,99 +38,6 @@ class LLMTierEngineSelectionMixin: return None return routed - async def _resolve_safety_tier_engine( - self, - db: AsyncSession, - *, - tenant_id: int, - function_name: str, - primary_engine: dict[str, Any], - ) -> Optional[dict[str, Any]]: - _, bundle = await self._get_or_init_bundle(db, tenant_id=tenant_id) - active_engine = self._resolve_active_engine_from_bundle(bundle) - engines: list[dict[str, Any]] = [e for e in bundle.get("engines", []) if isinstance(e, dict)] - - safety_rule = self._match_openclaw_rule(function_name, tier_filter={"safety"}) - routed: Optional[dict[str, Any]] = None - if safety_rule is not None: - routed = self._resolve_engine_from_rule( - rule=safety_rule, - engines=engines, - active_engine=active_engine, - ) - if routed is None: - routed = self._resolve_tier_engine( - tier="safety", - engines=engines, - active_engine=active_engine, - ) - if not routed: - return None - routed = self._ensure_openclaw_control_plane_engine(routed) - if self._is_same_runtime_engine(routed, primary_engine): - return None - - provider = str(routed.get("provider") or "").strip().lower() - api_key = str(routed.get("api_key") or "").strip() - if provider != "ollama" and not api_key: - return None - return routed - - async def _try_safety_engine( - self, - db: AsyncSession, - *, - tenant_id: int, - function_name: str, - prompt: str, - system_prompt: str, - temperature: float, - max_tokens: int, - primary_engine: dict[str, Any], - reason: str, - ) -> tuple[str, Optional[dict[str, Any]]]: - safety_engine = await self._resolve_safety_tier_engine( - db, - tenant_id=tenant_id, - function_name=function_name, - primary_engine=primary_engine, - ) - if safety_engine is None: - _runtime_log( - "LLM路由", - f"Safety 引擎不可用,无法触发兜底:function={function_name}, reason={reason}", - ) - return "", None - safety_provider = str(safety_engine.get("provider") or "") - safety_model = str(safety_engine.get("model") or "") - _runtime_log( - "LLM路由", - ( - "触发 Safety 兜底:" - f"function={function_name}, provider={safety_provider}, model={safety_model}, reason={reason}" - ), - ) - try: - text = await self._chat_completion( - provider=safety_provider, - prompt=prompt, - system_prompt=system_prompt, - api_key=str(safety_engine.get("api_key") or ""), - base_url=str(safety_engine.get("base_url") or ""), - model=safety_model, - temperature=temperature, - max_tokens=max_tokens, - function_name=function_name, - ) - if not text.strip(): - raise ValueError("safety_engine_empty_response") - if self._is_invalid_structured_output(function_name=function_name, response_text=text): - raise ValueError("safety_engine_invalid_structured_output") - return text, safety_engine - except Exception as exc: - _runtime_log("LLM路由", f"Safety 兜底失败:function={function_name}, error={exc}") - return "", None - def _pick_tier_fallback_engine( self, *, @@ -152,7 +59,7 @@ class LLMTierEngineSelectionMixin: "LLM路由", ( "分层路由回退引擎生效:" - f"tier={tier}, provider={provider}, model={candidate.get('model')}" + f"tier={tier}, decision=tier_fallback_engine" ), ) return candidate @@ -166,6 +73,8 @@ class LLMTierEngineSelectionMixin: active_engine: dict[str, Any], ) -> Optional[dict[str, Any]]: tier = str(tier or "").strip().lower() + if tier not in {"low", "high"}: + return None tier_defaults = dict(TIER_DEFAULTS.get(tier) or {}) provider_env = str(tier_defaults.get("provider") or "glm").strip() @@ -195,12 +104,21 @@ class LLMTierEngineSelectionMixin: if tier_defaults.get("base_url"): routed["base_url"] = str(tier_defaults.get("base_url") or routed.get("base_url") or "").strip().rstrip("/") + routed = _merge_engine_routing_meta( + routed, + tier_alias=tier, + planned_tier=tier, + executed_tier=tier, + route_source="default", + routing_decision="tier_default", + ) + if str(routed.get("id") or "") != str(active_engine.get("id") or ""): _runtime_log( "LLM路由", ( "混合路由已命中:" - f"层级={tier}, provider={routed.get('provider')}, model={routed.get('model')}" + f"tier={tier}, decision=tier_default" ), ) return self._ensure_openclaw_control_plane_engine(routed) diff --git a/backend/app/services/llm_service/mixin_tier_escalation.py b/backend/app/services/llm_service/mixin_tier_escalation.py index 1bf0ebf4..d4c42e0f 100644 --- a/backend/app/services/llm_service/mixin_tier_escalation.py +++ b/backend/app/services/llm_service/mixin_tier_escalation.py @@ -33,6 +33,23 @@ class LLMTierEscalationMixin: return True return any(marker in lowered for marker in LOW_TO_HIGH_ESCALATION_FALLBACK_MARKERS) + @staticmethod + def _normalize_model_name(model_name: str) -> str: + return re.sub(r"[\s_]+", "", str(model_name or "")).lower() + + @staticmethod + def _is_tier_default_model(model_name: str, *, tier: str) -> bool: + normalized = LLMTierEscalationMixin._normalize_model_name(model_name) + if not normalized: + return False + tier_name = str(tier or "").strip().lower() + expected = LLMTierEscalationMixin._normalize_model_name( + str(TIER_DEFAULTS.get(tier_name, {}).get("model") or "") + ) + if not expected: + return False + return normalized == expected + def _should_escalate_low_to_high(self, *, function_name: str, response_text: str) -> bool: if not self._low_to_high_escalation_enabled(): return False @@ -40,23 +57,7 @@ class LLMTierEscalationMixin: return False return self._looks_like_unanswerable_response(response_text) - @staticmethod - def _is_glm_47_model(model_name: str) -> bool: - normalized = re.sub(r"[\s_]+", "", str(model_name or "")).lower() - if not normalized: - return False - if normalized.startswith("qwen"): - return True - return "glm" in normalized and ("4.7" in normalized or "47" in normalized) - - @staticmethod - def _is_glm_5_model(model_name: str) -> bool: - normalized = re.sub(r"[\s_]+", "", str(model_name or "")).lower() - if not normalized: - return False - return normalized in {"glm-5", "glm5"} or normalized.startswith("glm-5") or normalized.startswith("glm5") - - def _should_retry_glm47_with_high_tier( + def _should_retry_low_tier_with_high_tier( self, *, function_name: str, @@ -64,16 +65,10 @@ class LLMTierEscalationMixin: ) -> bool: if not self._low_to_high_escalation_enabled(): return False - failed_model = str(failed_engine.get("model") or "").strip() - if self._is_glm_47_model(failed_model): - return True - # 兜底兼容:低阶函数遇到 4.x GLM 也允许升级高阶重试。 - if self._is_low_tier_function(function_name): - lowered = failed_model.lower() - return "glm" in lowered and "4." in lowered - return False + _ = failed_engine + return self._is_low_tier_function(function_name) - def _prefer_glm5_high_tier_retry_engine( + def _prefer_high_tier_retry_engine( self, *, bundle: dict[str, Any], @@ -82,12 +77,12 @@ class LLMTierEscalationMixin: failed_engine: dict[str, Any], current_engine: dict[str, Any], ) -> Optional[dict[str, Any]]: - if self._is_glm_5_model(str(current_engine.get("model") or "")): + if self._is_tier_default_model(str(current_engine.get("model") or ""), tier="high"): return current_engine settings = self._normalize_engine_settings(bundle.get("settings")) for item in engines: candidate = dict(item) - if not self._is_glm_5_model(str(candidate.get("model") or "")): + if not self._is_tier_default_model(str(candidate.get("model") or ""), tier="high"): continue if self._same_runtime_engine(candidate, failed_engine): continue @@ -96,13 +91,13 @@ class LLMTierEscalationMixin: engine=candidate, function_name=function_name, settings=settings, - engine_label="forced_glm5_failover", + engine_label="forced_high_tier_retry", ) except Exception as exc: _runtime_log( "LLM路由", ( - "GLM-5强制升级候选被拦截:" + "高阶重试候选被拦截:" f"function={function_name}, error={type(exc).__name__}:{str(exc)[:180]}" ), ) @@ -110,8 +105,8 @@ class LLMTierEscalationMixin: _runtime_log( "LLM路由", ( - "GLM-5强制高阶升级命中:" - f"function={function_name}, provider={candidate.get('provider')}, model={candidate.get('model')}" + "高阶重试候选命中:" + f"function={function_name}, tier=high, decision=forced_high_retry" ), ) return candidate @@ -119,31 +114,19 @@ class LLMTierEscalationMixin: def _match_openclaw_high_tier_rule_for_escalation(self, function_name: str) -> Optional[dict[str, Any]]: bundle = self._load_openclaw_routing_bundle() - rules = bundle.get("rules") if isinstance(bundle.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 - tier_name = str(row.get("tier") or "").strip().lower() - if tier_name != "high": - continue - if not self._rule_matches_function(row, function_name): - continue - priority = int(row.get("priority") or 0) - indexed.append((priority, -idx, row)) - if not indexed: + rule = find_matching_rule( + function_name, + tier_filter={"high"}, + bundle=bundle, + ) + if not isinstance(rule, dict): _runtime_log("LLM路由", f"OpenClaw高阶规则未命中:function={function_name}") return None - indexed.sort(reverse=True) - rule = dict(indexed[0][2]) _runtime_log( "LLM路由", ( "OpenClaw高阶规则候选:" - f"function={function_name}, rule={rule.get('name') or 'unnamed'}, " - f"priority={rule.get('priority')}, provider={rule.get('provider')}, model={rule.get('model')}" + f"function={function_name}, rule={rule.get('name') or 'unnamed'}, priority={rule.get('priority')}" ), ) return rule @@ -180,7 +163,7 @@ class LLMTierEscalationMixin: "LLM路由", ( "OpenClaw高阶升级命中但与失败引擎同源,跳过:" - f"function={function_name}, provider={routed.get('provider')}, model={routed.get('model')}" + f"function={function_name}, tier=high" ), ) return None @@ -201,12 +184,24 @@ class LLMTierEscalationMixin: ), ) return None + config_snapshot = build_routing_config_snapshot(bundle=self._load_openclaw_routing_bundle()) + routed = _merge_engine_routing_meta( + routed, + tier_alias="high", + planned_tier="high", + executed_tier="high", + route_source="default", + route_rule_name=str(rule.get("name") or "").strip(), + rule_priority=int(rule.get("priority") or 0), + routing_decision="high_tier_retry_candidate", + routing_config_version=config_snapshot.get("routing_config_version"), + routing_config_updated_at=config_snapshot.get("routing_config_updated_at"), + ) _runtime_log( "LLM路由", ( "OpenClaw高阶升级命中:" - f"function={function_name}, rule={rule.get('name') or 'unnamed'}, " - f"provider={routed.get('provider')}, model={routed.get('model')}" + f"function={function_name}, rule={rule.get('name') or 'unnamed'}, tier=high" ), ) return routed @@ -225,7 +220,7 @@ class LLMTierEscalationMixin: response_mode: str, error: Exception, ) -> tuple[Optional[str], Optional[dict[str, Any]]]: - if not self._should_retry_glm47_with_high_tier(function_name=function_name, failed_engine=failed_engine): + if not self._should_retry_low_tier_with_high_tier(function_name=function_name, failed_engine=failed_engine): return None, None try: _, bundle = await self._get_or_init_bundle(db, tenant_id=tenant_id) @@ -235,8 +230,7 @@ class LLMTierEscalationMixin: "LLM路由", ( "低阶模型失败准备升级高阶:" - f"function={function_name}, from={str(failed_engine.get('provider') or '').strip().lower()}:" - f"{str(failed_engine.get('model') or '').strip()}, reason={str(error)[:120]}" + f"function={function_name}, from_tier=low, to_tier=high, reason={str(error)[:120]}" ), ) high_engine = await self._resolve_openclaw_high_tier_engine_for_escalation( @@ -267,15 +261,15 @@ class LLMTierEscalationMixin: settings=self._normalize_engine_settings(bundle.get("settings")), engine_label="high_tier_failover", ) - forced_glm5_engine = self._prefer_glm5_high_tier_retry_engine( + forced_high_tier_engine = self._prefer_high_tier_retry_engine( bundle=bundle, engines=engines, function_name=function_name, failed_engine=failed_engine, current_engine=high_engine, ) - if isinstance(forced_glm5_engine, dict): - high_engine = forced_glm5_engine + if isinstance(forced_high_tier_engine, dict): + high_engine = forced_high_tier_engine failed_provider = str(failed_engine.get("provider") or "").strip().lower() failed_model = str(failed_engine.get("model") or "").strip() high_provider = str(high_engine.get("provider") or "").strip().lower() @@ -285,7 +279,7 @@ class LLMTierEscalationMixin: "LLM路由", ( "高阶升级引擎与失败引擎相同,跳过:" - f"function={function_name}, provider={high_provider}, model={high_model}" + f"function={function_name}, tier=high" ), ) return None, None @@ -293,8 +287,8 @@ class LLMTierEscalationMixin: "LLM路由", ( "低阶模型失败自动升级:" - f"function={function_name}, from={failed_provider}:{failed_model}, " - f"to={high_provider}:{high_model}, reason={str(error)[:120]}" + f"function={function_name}, planned_tier=low, executed_tier=high, " + f"decision=low_to_high_escalated, reason={str(error)[:120]}" ), ) text = await self._chat_completion( @@ -313,11 +307,18 @@ class LLMTierEscalationMixin: raise ValueError("high_tier_empty_response") if self._is_invalid_structured_output(function_name=function_name, response_text=text): raise ValueError("high_tier_invalid_structured_output") + high_engine = _merge_engine_routing_meta( + high_engine, + tier_alias="low", + planned_tier="low", + executed_tier="high", + routing_decision="low_to_high_escalated", + ) _runtime_log( "LLM路由", ( "高阶升级成功:" - f"function={function_name}, provider={high_provider}, model={high_model}, text_len={len(text)}" + f"function={function_name}, {_routing_meta_log_text(_read_engine_routing_meta(high_engine))}, text_len={len(text)}" ), ) return text, high_engine @@ -346,8 +347,8 @@ class LLMTierEscalationMixin: error: Exception, ) -> tuple[Optional[StructuredExtractionResult], Optional[dict[str, Any]]]: fn = str(function_name or "").strip().lower() - force_intent_retry = fn in {"classify_intent_ai_pmo_kernel", "classify_intent_complex"} - if not force_intent_retry and not self._should_retry_glm47_with_high_tier( + force_intent_retry = False + if not self._should_retry_low_tier_with_high_tier( function_name=function_name, failed_engine=failed_engine, ): @@ -360,8 +361,7 @@ class LLMTierEscalationMixin: "LLM路由", ( "低阶结构化失败准备升级高阶:" - f"function={function_name}, from={str(failed_engine.get('provider') or '').strip().lower()}:" - f"{str(failed_engine.get('model') or '').strip()}, reason={str(error)[:120]}, " + f"function={function_name}, from_tier=low, to_tier=high, reason={str(error)[:120]}, " f"force_intent_retry={force_intent_retry}" ), ) @@ -394,15 +394,15 @@ class LLMTierEscalationMixin: settings=self._normalize_engine_settings(bundle.get("settings")), engine_label="high_tier_structured_failover", ) - forced_glm5_engine = self._prefer_glm5_high_tier_retry_engine( + forced_high_tier_engine = self._prefer_high_tier_retry_engine( bundle=bundle, engines=engines, function_name=function_name, failed_engine=failed_engine, current_engine=high_engine, ) - if isinstance(forced_glm5_engine, dict): - high_engine = forced_glm5_engine + if isinstance(forced_high_tier_engine, dict): + high_engine = forced_high_tier_engine failed_provider = str(failed_engine.get("provider") or "").strip().lower() failed_model = str(failed_engine.get("model") or "").strip() high_provider = str(high_engine.get("provider") or "").strip().lower() @@ -413,12 +413,12 @@ class LLMTierEscalationMixin: "LLM路由", ( "高阶结构化升级引擎与失败引擎相同,跳过:" - f"function={function_name}, provider={high_provider}, model={high_model}" + f"function={function_name}, tier=high" ), ) return None, None fallback_engine: Optional[dict[str, Any]] = None - for fallback_tier in ("safety", "low"): + for fallback_tier in ("low",): candidate = self._resolve_tier_engine( tier=fallback_tier, engines=engines, @@ -447,8 +447,8 @@ class LLMTierEscalationMixin: "LLM路由", ( "低阶结构化失败自动升级:" - f"function={function_name}, from={failed_provider}:{failed_model}, " - f"to={high_provider}:{high_model}, reason={str(error)[:120]}, " + f"function={function_name}, planned_tier=low, executed_tier=high, " + f"decision=low_to_high_escalated, reason={str(error)[:120]}, " f"force_intent_retry={force_intent_retry}" ), ) @@ -484,11 +484,18 @@ class LLMTierEscalationMixin: f"validation={extraction.validation}, fallback=true" ), ) + high_engine = _merge_engine_routing_meta( + high_engine, + tier_alias="low", + planned_tier="low", + executed_tier="high", + routing_decision="low_to_high_escalated", + ) _runtime_log( "LLM路由", ( "高阶结构化升级成功:" - f"function={function_name}, provider={high_provider}, model={high_model}, " + f"function={function_name}, {_routing_meta_log_text(_read_engine_routing_meta(high_engine))}, " f"structured_source={extraction.source_field or 'none'}" ), ) diff --git a/backend/app/services/llm_service/shared_part_01.py b/backend/app/services/llm_service/shared_part_01.py index 9ae27b6b..5420c574 100644 --- a/backend/app/services/llm_service/shared_part_01.py +++ b/backend/app/services/llm_service/shared_part_01.py @@ -28,11 +28,15 @@ from sqlalchemy.exc import OperationalError from sqlalchemy.ext.asyncio import AsyncSession from ..model.model_config import ( + VALID_TIER_ALIASES, build_provider_runtime, build_tier_runtime, + build_routing_config_snapshot, + find_matching_rule, get_provider_defaults, get_tier_defaults, load_model_config, + rule_matches_function, resolve_model_config_path, ) from ...config.ai_prompts import build_default_role_prompt_bundle @@ -57,7 +61,6 @@ PROVIDER_DEFAULTS: dict[str, dict[str, str]] = get_provider_defaults() TIER_DEFAULTS: dict[str, dict[str, str]] = get_tier_defaults() OPENCLAW_DEFAULT_LOW_MODEL = str(TIER_DEFAULTS.get("low", {}).get("model") or "qwen-plus") OPENCLAW_DEFAULT_HIGH_MODEL = str(TIER_DEFAULTS.get("high", {}).get("model") or "GLM-5") -OPENCLAW_DEFAULT_SAFETY_MODEL = str(TIER_DEFAULTS.get("safety", {}).get("model") or OPENCLAW_DEFAULT_LOW_MODEL) OPENCLAW_DASHSCOPE_BASE_URL = str(PROVIDER_DEFAULTS.get("glm", {}).get("base_url") or "https://dashscope.aliyuncs.com/compatible-mode/v1") PROVIDER_ALIASES = { "zhipu": "glm", @@ -68,7 +71,6 @@ PROVIDER_ALIASES = { PROMPT_PROFILE_CACHE_TTL_SECONDS = 60.0 BOOL_TRUE_VALUES = {"1", "true", "yes", "y", "on", "是"} OPENCLAW_ROUTING_CACHE_TTL_SECONDS = 30.0 -OPENCLAW_DEFAULT_RULES_RELATIVE_PATH = "../.openclaw/pmflow-routing-rules.json" OPENCLAW_AGENTS_DEFAULT_TIMEOUT_SECONDS = 120 OPENCLAW_AGENTS_TIMEOUT_SECONDS_MAX = 120 DEFAULT_TIER_MODEL_OVERRIDES = { @@ -82,6 +84,7 @@ DEFAULT_HIGH_TIER_HINTS = ( "feishu_task_orchestration", "feishu_project_signal_classification", "adaptive_rag_answer_deep", + "ai_pmo_signal_batch_analysis", "rag_db_project_semantic_retrieval", "analyze_food_calories", "sstc_ai_breakdown", @@ -98,6 +101,7 @@ DEFAULT_LOW_TIER_HINTS = ( "feishu_level_classification", "feishu_signal_extraction", "feishu_humanized_reply", + "ai_pmo_signal_title_refine", "signal_source_onboarding_plan", "signal_source_ai_worker_execution", "extract_context_insights", diff --git a/backend/app/services/llm_service/shared_part_02.py b/backend/app/services/llm_service/shared_part_02.py index 6979372f..6af5c7e4 100644 --- a/backend/app/services/llm_service/shared_part_02.py +++ b/backend/app/services/llm_service/shared_part_02.py @@ -32,6 +32,89 @@ def _safe_json(value: Any) -> dict[str, Any]: return {} return {} + +def _optional_tier_alias(raw: Any) -> str: + value = str(raw or "").strip().lower() + return value if value in VALID_TIER_ALIASES else "" + + +def _normalize_routing_meta(raw: Any) -> dict[str, Any]: + payload = _safe_json(raw) + planned_tier = _optional_tier_alias(payload.get("planned_tier")) + executed_tier = _optional_tier_alias(payload.get("executed_tier")) or planned_tier + tier_alias = _optional_tier_alias(payload.get("tier_alias")) or planned_tier or executed_tier + route_source = str(payload.get("route_source") or payload.get("source") or "").strip().lower() + if route_source not in {"default", "override", "active_engine"}: + route_source = "default" if tier_alias else "" + rule_name = str(payload.get("route_rule_name") or payload.get("rule_name") or "").strip() + decision = str(payload.get("routing_decision") or payload.get("decision") or "").strip() + config_version = str(payload.get("routing_config_version") or "").strip() + config_updated_at = str(payload.get("routing_config_updated_at") or "").strip() + rule_priority_raw = payload.get("rule_priority") + try: + rule_priority = int(rule_priority_raw) if rule_priority_raw is not None else None + except (TypeError, ValueError): + rule_priority = None + return { + "tier_alias": tier_alias, + "planned_tier": planned_tier or tier_alias, + "executed_tier": executed_tier or planned_tier or tier_alias, + "route_source": route_source, + "route_rule_name": rule_name, + "routing_decision": decision or "primary_tier", + "routing_config_version": config_version, + "routing_config_updated_at": config_updated_at, + "rule_priority": rule_priority, + } + + +def _overlay_engine_api_key_from_env(engine: dict[str, Any]) -> dict[str, Any]: + runtime = dict(engine or {}) + provider = str(runtime.get("provider") or runtime.get("source_provider") or "").strip().lower() + if provider not in PROVIDER_DEFAULTS: + return runtime + defaults = dict(PROVIDER_DEFAULTS.get(provider) or {}) + api_env = str(runtime.get("api_env") or defaults.get("api_env") or "").strip() + if not api_env: + return runtime + env_api_key = str(os.getenv(api_env, "") or "").strip() + if env_api_key: + runtime["api_key"] = env_api_key + runtime["api_env"] = api_env + return runtime + + +def _read_engine_routing_meta(engine: Any) -> dict[str, Any]: + payload = engine.get("_routing_meta") if isinstance(engine, dict) else {} + return _normalize_routing_meta(payload) + + +def _merge_engine_routing_meta(engine: dict[str, Any], **updates: Any) -> dict[str, Any]: + runtime = dict(engine or {}) + current = _read_engine_routing_meta(runtime) + for key, value in updates.items(): + if value is None: + continue + current[key] = value + runtime["_routing_meta"] = _normalize_routing_meta(current) + return runtime + + +def _routing_meta_log_text(meta: Any) -> str: + data = _normalize_routing_meta(meta) + parts = [] + if data.get("planned_tier"): + parts.append(f"planned_tier={data['planned_tier']}") + if data.get("executed_tier"): + parts.append(f"executed_tier={data['executed_tier']}") + if data.get("route_source"): + parts.append(f"source={data['route_source']}") + if data.get("route_rule_name"): + parts.append(f"rule={data['route_rule_name']}") + if data.get("routing_decision"): + parts.append(f"decision={data['routing_decision']}") + return ", ".join(parts) + def _env_bool(name: str, default: bool = False) -> bool: raw = os.getenv(name) if raw is None: diff --git a/backend/app/services/model/model_config.py b/backend/app/services/model/model_config.py index bbb51ed6..24187765 100644 --- a/backend/app/services/model/model_config.py +++ b/backend/app/services/model/model_config.py @@ -6,9 +6,9 @@ from copy import deepcopy from pathlib import Path from typing import Any -DEFAULT_ROUTING_RULES_RELATIVE_PATH = "../.openclaw/pmflow-routing-rules.json" -_APP_ROOT = Path(__file__).resolve().parents[2] -_DEFAULT_ROUTING_RULES_PATH = (_APP_ROOT / DEFAULT_ROUTING_RULES_RELATIVE_PATH).resolve() +DEFAULT_ROUTING_RULES_WORKSPACE_PATH = "backend/.openclaw/pmflow-routing-rules.json" +_WORKSPACE_ROOT = Path(__file__).resolve().parents[4] +_DEFAULT_ROUTING_RULES_PATH = (_WORKSPACE_ROOT / DEFAULT_ROUTING_RULES_WORKSPACE_PATH).resolve() _PROVIDER_ALIASES = { "zhipu": "glm", @@ -33,7 +33,7 @@ _DEFAULT_PROVIDER_DEFAULTS: dict[str, dict[str, str]] = { "openai": { "name": "OpenAI", "base_url": "https://api.edgefn.net/v1", - "model": "MiniMax-M2.5", + "model": "GLM-5", "api_env": "OPENAI_API_KEY", }, "ollama": { @@ -57,13 +57,8 @@ _DEFAULT_TIER_DEFAULTS: dict[str, dict[str, str]] = { "base_url": "https://api.edgefn.net/v1", "api_env": "OPENAI_API_KEY", }, - "safety": { - "provider": "glm", - "model": "qwen-plus", - "base_url": "https://dashscope.aliyuncs.com/compatible-mode/v1", - "api_env": "DASHSCOPE_API_KEY", - }, } +VALID_TIER_ALIASES = tuple(_DEFAULT_TIER_DEFAULTS.keys()) _CONFIG_CACHE: dict[str, Any] = {"path": "", "mtime": -1.0, "bundle": None} @@ -79,7 +74,7 @@ def _normalize_provider(provider: str) -> str: def _normalize_tier(tier: str) -> str: value = str(tier or "").strip().lower() if value not in _DEFAULT_TIER_DEFAULTS: - raise ValueError("tier 仅支持 low/high/safety") + raise ValueError("tier 仅支持 low/high") return value @@ -91,7 +86,7 @@ def resolve_model_config_path(path: str | Path | None = None) -> Path: candidate = Path(raw_text).expanduser() if candidate.is_absolute(): return candidate - return (_APP_ROOT / candidate).resolve() + return (_WORKSPACE_ROOT / candidate).resolve() def _normalize_provider_defaults(raw: Any) -> dict[str, dict[str, str]]: @@ -110,6 +105,16 @@ def _normalize_provider_defaults(raw: Any) -> dict[str, dict[str, str]]: def _normalize_tier_defaults(raw: Any) -> dict[str, dict[str, str]]: payload = raw if isinstance(raw, dict) else {} + extra_keys = sorted( + { + str(key or "").strip().lower() + for key in payload.keys() + if str(key or "").strip() + } + - set(VALID_TIER_ALIASES) + ) + if extra_keys: + raise ValueError("tier 仅支持 low/high") result: dict[str, dict[str, str]] = {} for tier, defaults in _DEFAULT_TIER_DEFAULTS.items(): item = payload.get(tier) if isinstance(payload.get(tier), dict) else {} @@ -124,6 +129,84 @@ def _normalize_tier_defaults(raw: Any) -> dict[str, dict[str, str]]: return result +def rule_matches_function(rule: dict[str, Any], function_name: str) -> bool: + if bool(rule.get("match_all", False)): + return True + fn = str(function_name or "").strip().lower() + if not fn: + return False + exact = str(rule.get("function_exact") or "").strip().lower() + if exact and fn == exact: + return True + prefix = str(rule.get("function_prefix") or "").strip().lower() + if prefix in {"*", "all"}: + return True + if prefix and fn.startswith(prefix): + return True + match_any = rule.get("match_any") + if isinstance(match_any, list): + for hint in match_any: + token = str(hint or "").strip().lower() + if token and token in fn: + return True + return False + + +def find_matching_rule( + function_name: str, + *, + tier_filter: set[str] | None = None, + bundle: dict[str, Any] | None = None, + path: str | Path | None = None, +) -> dict[str, Any] | None: + config_bundle = bundle if isinstance(bundle, dict) else load_model_config(path=path) + rules = config_bundle.get("rules") if isinstance(config_bundle.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 + tier_name = str(row.get("tier") or "").strip().lower() + if tier_name not in VALID_TIER_ALIASES: + continue + if tier_filter and tier_name not in tier_filter: + continue + if not rule_matches_function(row, function_name): + continue + indexed.append((int(row.get("priority") or 0), -idx, row)) + if not indexed: + return None + indexed.sort(reverse=True) + return deepcopy(indexed[0][2]) + + +def build_routing_config_snapshot( + *, + bundle: dict[str, Any] | None = None, + path: str | Path | None = None, +) -> dict[str, str]: + config_bundle = bundle if isinstance(bundle, dict) else load_model_config(path=path) + return { + "routing_config_version": str(config_bundle.get("version") or "").strip(), + "routing_config_updated_at": str(config_bundle.get("updated_at") or "").strip(), + "config_path": str(config_bundle.get("config_path") or "").strip(), + } + + +def _normalize_rules(raw: Any) -> list[dict[str, Any]]: + rows = raw if isinstance(raw, list) else [] + result: list[dict[str, Any]] = [] + for item in rows: + if not isinstance(item, dict): + continue + tier_name = str(item.get("tier") or "").strip().lower() + if tier_name and tier_name not in VALID_TIER_ALIASES: + raise ValueError("tier 仅支持 low/high") + result.append(deepcopy(item)) + return result + + def _normalize_bundle(raw: Any, *, config_path: Path) -> dict[str, Any]: payload = raw if isinstance(raw, dict) else {} bundle = { @@ -134,7 +217,7 @@ def _normalize_bundle(raw: Any, *, config_path: Path) -> dict[str, Any]: "config_path": str(config_path), "provider_defaults": _normalize_provider_defaults(payload.get("provider_defaults")), "tier_defaults": _normalize_tier_defaults(payload.get("tier_defaults")), - "rules": [deepcopy(item) for item in payload.get("rules", []) if isinstance(item, dict)], + "rules": _normalize_rules(payload.get("rules")), } return bundle @@ -229,10 +312,14 @@ def build_tier_runtime( __all__ = [ + "VALID_TIER_ALIASES", "build_provider_runtime", + "build_routing_config_snapshot", "build_tier_runtime", + "find_matching_rule", "get_provider_defaults", "get_tier_defaults", "load_model_config", + "rule_matches_function", "resolve_model_config_path", ] diff --git a/backend/app/services/model/model_context_bundle/mixin_build.py b/backend/app/services/model/model_context_bundle/mixin_build.py index e49a18de..cd9b76f6 100644 --- a/backend/app/services/model/model_context_bundle/mixin_build.py +++ b/backend/app/services/model/model_context_bundle/mixin_build.py @@ -145,12 +145,10 @@ class ModelContextBundleBuildMixin: 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 ""), + "routing_snapshot": { + "tier_alias": str(routing.get("tier_alias") or ""), "source": str(routing.get("source") or ""), - "api_env": str(routing.get("api_env") or ""), + "rule_name": str(routing.get("rule_name") or ""), }, } ) @@ -172,7 +170,7 @@ class ModelContextBundleBuildMixin: "required_templates": required_templates, }, "contract_sync": contract_sync, - "function_model_template_matrix": function_matrix, + "function_routing_template_matrix": function_matrix, "coverage": { "template_ready": len(missing_template_ids) == 0, "contract_ready": bool(contract_sync.get("all_synced")), diff --git a/backend/app/services/model/model_context_bundle/mixin_verify.py b/backend/app/services/model/model_context_bundle/mixin_verify.py index c80e9395..b88a38e5 100644 --- a/backend/app/services/model/model_context_bundle/mixin_verify.py +++ b/backend/app/services/model/model_context_bundle/mixin_verify.py @@ -36,8 +36,6 @@ class ModelContextBundleVerifyMixin: "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 { @@ -45,8 +43,6 @@ class ModelContextBundleVerifyMixin: "error": str(exc)[:500], "error_code": "canary_call_failed", "status": "failed", - "provider": "", - "model": "", } @classmethod @@ -88,8 +84,6 @@ class ModelContextBundleVerifyMixin: "status": "skipped", "error": None, "error_code": "", - "provider": "", - "model": "", } if run_canary: canary = await cls._trigger_canary_call( @@ -127,10 +121,9 @@ class ModelContextBundleVerifyMixin: 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 ""), + "tier_alias": str(route.get("tier_alias") or ""), "source": str(route.get("source") or ""), + "rule_name": str(route.get("rule_name") or ""), }, "canary": canary, "checks": checks, diff --git a/backend/app/services/model/model_routing/service.py b/backend/app/services/model/model_routing/service.py index c2fb2fde..bd40bcd6 100644 --- a/backend/app/services/model/model_routing/service.py +++ b/backend/app/services/model/model_routing/service.py @@ -8,13 +8,31 @@ from typing import Any from sqlalchemy import and_, select from sqlalchemy.ext.asyncio import AsyncSession -from ....models import Integration -from ..model_config import get_tier_defaults +from ....models import AICallLog, Integration +from ..model_config import ( + VALID_TIER_ALIASES, + find_matching_rule, + load_model_config, +) from ....utils.timezone import now_cn_naive -ALLOWED_PROVIDERS = {"openai", "glm", "deepseek", "ollama"} -ALLOWED_TIERS = {"low", "high", "safety"} -DEFAULT_FUNCTIONS = ("signal_planner_analyze",) +ALLOWED_TIERS = set(VALID_TIER_ALIASES) +DEFAULT_FUNCTIONS = ( + "adaptive_rag_answer_deep", + "adaptive_rag_answer_normal", + "ai_pmo_signal_batch_analysis", + "ai_pmo_signal_title_refine", + "ai_service_stream_chat", + "classify_intent_ai_pmo_kernel", + "classify_intent_complex", + "draft_generator_v1", + "feishu_humanized_reply", + "rag_db_project_semantic_retrieval", + "rag_intent_classification", + "signal_planner_analyze", + "signal_source_ai_worker_execution", + "signal_source_onboarding_plan", +) INTEGRATION_TYPE = "model_routing" @@ -43,43 +61,29 @@ class ModelRoutingService: return fn @staticmethod - def _normalize_provider(provider: str) -> str: - p = str(provider or "").strip().lower() - if p not in ALLOWED_PROVIDERS: - raise ValueError("provider 仅支持 openai/glm/deepseek/ollama") - return p - - @staticmethod - def _normalize_tier(tier: str) -> str: + def _normalize_tier_alias(tier: str) -> str: t = str(tier or "").strip().lower() or "high" if t not in ALLOWED_TIERS: - raise ValueError("tier 仅支持 low/high/safety") + raise ValueError("tier_alias 仅支持 low/high") return t @classmethod - def _rule_matches_function(cls, *, rule: dict[str, Any], function_name: str) -> bool: - if bool(rule.get("match_all", False)): - return True - fn = cls._normalize_function_name(function_name) - exact = str(rule.get("function_exact") or "").strip().lower() - if exact and exact == fn: - return True - prefix = str(rule.get("function_prefix") or "").strip().lower() - if prefix in {"*", "all"}: - return True - if prefix and fn.startswith(prefix): - return True - match_any = rule.get("match_any") if isinstance(rule.get("match_any"), list) else [] - for item in match_any: - token = str(item or "").strip().lower() - if token and token in fn: - return True - return False - - @classmethod - def _load_default_rule(cls, *, function_name: str) -> dict[str, Any] | None: - _ = function_name - return None + def _normalize_override(cls, override: dict[str, Any] | None) -> dict[str, Any] | None: + if not isinstance(override, dict): + return None + raw_tier = override.get("tier_alias") + if raw_tier in (None, ""): + raw_tier = override.get("tier") + try: + tier_alias = cls._normalize_tier_alias(str(raw_tier or "").strip().lower() or "high") + except ValueError: + return None + return { + "tier_alias": tier_alias, + "enabled": bool(override.get("enabled", True)), + "updated_at": str(override.get("updated_at") or ""), + "updated_by": override.get("updated_by"), + } @classmethod async def _get_or_init_bundle(cls, db: AsyncSession, *, tenant_id: int) -> tuple[Integration, dict[str, Any]]: @@ -108,44 +112,98 @@ class ModelRoutingService: return row, bundle @classmethod - def _effective_from_override(cls, *, function_name: str, override: dict[str, Any]) -> dict[str, Any]: + def _candidate_functions( + cls, + *, + routing_bundle: dict[str, Any], + mappings: dict[str, Any], + ) -> list[str]: + keys = {cls._normalize_function_name(k) for k in mappings.keys() if str(k).strip()} + keys.update(DEFAULT_FUNCTIONS) + rules = routing_bundle.get("rules") if isinstance(routing_bundle.get("rules"), list) else [] + for row in rules: + if not isinstance(row, dict): + continue + exact = str(row.get("function_exact") or "").strip().lower() + if exact: + keys.add(exact) + prefix = str(row.get("function_prefix") or "").strip().lower() + if prefix and prefix not in {"*", "all"}: + keys.add(prefix) + match_any = row.get("match_any") if isinstance(row.get("match_any"), list) else [] + for item in match_any: + token = str(item or "").strip().lower() + if token: + keys.add(token) + return sorted(keys) + + @classmethod + async def _load_last_execution( + cls, + db: AsyncSession, + *, + tenant_id: int, + function_name: str, + ) -> dict[str, Any] | None: + row = ( + await db.execute( + select(AICallLog) + .where( + and_( + AICallLog.tenant_id == int(tenant_id), + AICallLog.function_name == cls._normalize_function_name(function_name), + ) + ) + .order_by(AICallLog.created_at.desc(), AICallLog.id.desc()) + .limit(1) + ) + ).scalar_one_or_none() + if row is None: + return None return { - "function_name": cls._normalize_function_name(function_name), - "provider": str(override.get("provider") or "").strip().lower(), - "model": str(override.get("model") or "").strip(), - "api_env": str(override.get("api_env") or "").strip(), - "tier": str(override.get("tier") or "high").strip().lower(), - "base_url": str(override.get("base_url") or "").strip(), - "source": "override", - "updated_at": str(override.get("updated_at") or ""), - "updated_by": override.get("updated_by"), + "planned_tier": str(getattr(row, "planned_tier", "") or "").strip().lower() or None, + "executed_tier": str(getattr(row, "executed_tier", "") or "").strip().lower() or None, + "routing_decision": str(getattr(row, "routing_decision", "") or "").strip() or None, + "created_at": row.created_at.isoformat() if row.created_at else None, } @classmethod - def _effective_from_rule(cls, *, function_name: str, rule: dict[str, Any] | None) -> dict[str, Any]: - if rule is None: - low_defaults = get_tier_defaults("low") - return { - "function_name": cls._normalize_function_name(function_name), - "provider": str(low_defaults.get("provider") or "glm").strip().lower(), - "model": str(low_defaults.get("model") or "qwen-plus").strip(), - "api_env": str(low_defaults.get("api_env") or "DASHSCOPE_API_KEY").strip(), - "tier": "high", - "base_url": str(low_defaults.get("base_url") or "https://dashscope.aliyuncs.com/compatible-mode/v1").strip(), - "source": "default", - "updated_at": "", - "updated_by": None, - } + async def _compose_effective_routing( + cls, + db: AsyncSession, + *, + tenant_id: int, + function_name: str, + override: dict[str, Any] | None, + routing_bundle: dict[str, Any], + ) -> dict[str, Any]: + fn = cls._normalize_function_name(function_name) + normalized_override = cls._normalize_override(override) + source = "override" if normalized_override and normalized_override.get("enabled", True) else "default" + tier_alias = ( + cls._normalize_tier_alias(str(normalized_override.get("tier_alias") or "")) + if normalized_override and normalized_override.get("enabled", True) + else "" + ) + tier_filter = {tier_alias} if tier_alias else None + rule = find_matching_rule(fn, tier_filter=tier_filter, bundle=routing_bundle) + resolved_tier = cls._normalize_tier_alias( + tier_alias or (str(rule.get("tier") or "low").strip().lower() if isinstance(rule, dict) else "low") or "low" + ) + last_execution = await cls._load_last_execution( + db, + tenant_id=int(tenant_id), + function_name=fn, + ) return { - "function_name": cls._normalize_function_name(function_name), - "provider": str(rule.get("provider") or "").strip().lower(), - "model": str(rule.get("model") or "").strip(), - "api_env": str(rule.get("api_env") or "").strip(), - "tier": str(rule.get("tier") or "high").strip().lower(), - "base_url": str(rule.get("base_url") or "").strip(), - "source": "default", - "updated_at": "", - "updated_by": None, + "function_name": fn, + "tier_alias": resolved_tier, + "source": source, + "rule_name": (str(rule.get("name") or "").strip() or None) if isinstance(rule, dict) else None, + "rule_priority": int(rule.get("priority") or 0) if isinstance(rule, dict) else None, + "last_execution": last_execution, + "updated_at": str(normalized_override.get("updated_at") or "") if normalized_override else "", + "updated_by": normalized_override.get("updated_by") if normalized_override else None, } @classmethod @@ -160,9 +218,14 @@ class ModelRoutingService: _, bundle = await cls._get_or_init_bundle(db, tenant_id=int(tenant_id)) mappings = bundle.get("mappings") if isinstance(bundle.get("mappings"), dict) else {} override = mappings.get(fn) if isinstance(mappings.get(fn), dict) else None - if override and bool(override.get("enabled", True)): - return cls._effective_from_override(function_name=fn, override=override) - return cls._effective_from_rule(function_name=fn, rule=cls._load_default_rule(function_name=fn)) + routing_bundle = load_model_config() + return await cls._compose_effective_routing( + db, + tenant_id=int(tenant_id), + function_name=fn, + override=override, + routing_bundle=routing_bundle, + ) @classmethod async def list_effective_routing( @@ -173,11 +236,20 @@ class ModelRoutingService: ) -> list[dict[str, Any]]: _, bundle = await cls._get_or_init_bundle(db, tenant_id=int(tenant_id)) mappings = bundle.get("mappings") if isinstance(bundle.get("mappings"), dict) else {} - keys = {cls._normalize_function_name(k) for k in mappings.keys() if str(k).strip()} - keys.update(DEFAULT_FUNCTIONS) + routing_bundle = load_model_config() + keys = cls._candidate_functions(routing_bundle=routing_bundle, mappings=mappings) items: list[dict[str, Any]] = [] - for fn in sorted(keys): - items.append(await cls.get_effective_routing(db, tenant_id=int(tenant_id), function_name=fn)) + for fn in keys: + override = mappings.get(fn) if isinstance(mappings.get(fn), dict) else None + items.append( + await cls._compose_effective_routing( + db, + tenant_id=int(tenant_id), + function_name=fn, + override=override, + routing_bundle=routing_bundle, + ) + ) return items @classmethod @@ -187,31 +259,16 @@ class ModelRoutingService: *, tenant_id: int, function_name: str, - provider: str, - model: str, - api_env: str, - tier: str, + tier_alias: str, updated_by: int, - base_url: str | None = None, ) -> dict[str, Any]: fn = cls._normalize_function_name(function_name) - normalized_provider = cls._normalize_provider(provider) - normalized_tier = cls._normalize_tier(tier) - normalized_model = str(model or "").strip() - if not normalized_model: - raise ValueError("model 不能为空") - normalized_api_env = str(api_env or "").strip() - if normalized_provider != "ollama" and not normalized_api_env: - raise ValueError("api_env 不能为空") + normalized_tier = cls._normalize_tier_alias(tier_alias) row, bundle = await cls._get_or_init_bundle(db, tenant_id=int(tenant_id)) mappings = bundle.get("mappings") if isinstance(bundle.get("mappings"), dict) else {} mappings[fn] = { - "provider": normalized_provider, - "model": normalized_model, - "api_env": normalized_api_env, - "tier": normalized_tier, - "base_url": str(base_url or "").strip(), + "tier_alias": normalized_tier, "enabled": True, "updated_at": now_cn_naive().isoformat(), "updated_by": int(updated_by), @@ -219,4 +276,11 @@ class ModelRoutingService: bundle["mappings"] = mappings row.config = bundle await db.flush() - return cls._effective_from_override(function_name=fn, override=mappings[fn]) + routing_bundle = load_model_config() + return await cls._compose_effective_routing( + db, + tenant_id=int(tenant_id), + function_name=fn, + override=mappings[fn], + routing_bundle=routing_bundle, + ) diff --git a/backend/app/services/project_knowledge_graph_service/mixin_guardrails.py b/backend/app/services/project_knowledge_graph_service/mixin_guardrails.py index 1ffca44b..c9c3993d 100644 --- a/backend/app/services/project_knowledge_graph_service/mixin_guardrails.py +++ b/backend/app/services/project_knowledge_graph_service/mixin_guardrails.py @@ -11,10 +11,8 @@ class PKGGuardrailsMixin: 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", ] ) @@ -130,7 +128,7 @@ class PKGGuardrailsMixin: 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 ("qwen-plus" if tier == "low" else "MiniMax-M2.5") + model_name = str(spec.get("model") or "").strip() or ("qwen-plus" if tier == "low" else "GLM-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() diff --git a/backend/app/services/signal/draft_generator_service.py b/backend/app/services/signal/draft_generator_service.py index 9a0aee29..db1fc73c 100644 --- a/backend/app/services/signal/draft_generator_service.py +++ b/backend/app/services/signal/draft_generator_service.py @@ -288,6 +288,42 @@ class DraftGeneratorService: {"when": "明天", "action": "确认触发时机与验收口径"}, {"when": "一周后", "action": "复核执行效果并决定是否扩展"}, ] + positioning_one_liner = str( + payload.get("positioning_one_liner") + or payload.get("positioning") + or "" + ).strip()[:160] + target_customers = str(payload.get("target_customers") or "").strip()[:160] + core_pain_points = self._normalize_core_pain_points(payload.get("core_pain_points")) + mvp_phase_1, mvp_phase_2 = self._normalize_mvp_phases(payload) + technical_architecture = self._normalize_string_list( + payload.get("technical_architecture"), + fallback=[], + max_items=6, + max_length=100, + ) + business_model = self._normalize_business_model_items(payload.get("business_model")) + validation_metrics = self._normalize_section_map( + payload.get("validation_metrics"), + keys=("product", "market"), + ) + quick_start_suggestions = self._normalize_section_map( + payload.get("quick_start_suggestions"), + keys=("week_1_2", "week_3_4"), + ) + team_setup = self._normalize_team_setup_items(payload.get("team_setup")) + requirement_items = self._normalize_requirement_items( + payload.get("requirement_items"), + core_pain_points=core_pain_points, + mvp_phase_1=mvp_phase_1, + mvp_phase_2=mvp_phase_2, + technical_architecture=technical_architecture, + business_model=business_model, + validation_metrics=validation_metrics, + quick_start_suggestions=quick_start_suggestions, + team_setup=team_setup, + default_source_kind="custom", + ) workload = 0.0 if assignee is not None: @@ -296,8 +332,18 @@ class DraftGeneratorService: return { "title": title, "summary": summary, + "positioning_one_liner": positioning_one_liner or summary, + "target_customers": target_customers, "similar_projects": similar_projects, "mvp_items": mvp_items, + "core_pain_points": core_pain_points, + "mvp_phase_1": mvp_phase_1, + "mvp_phase_2": mvp_phase_2, + "technical_architecture": technical_architecture, + "business_model": business_model, + "validation_metrics": validation_metrics, + "quick_start_suggestions": quick_start_suggestions, + "team_setup": team_setup, "prospect_assessment": prospect_assessment, "requirement_items": requirement_items, "missing_info": missing_info, @@ -318,6 +364,16 @@ class DraftGeneratorService: summary = str(draft.get("summary") or "暂无摘要").strip() or "暂无摘要" similar_projects = draft.get("similar_projects") if isinstance(draft.get("similar_projects"), list) else [] mvp_items = draft.get("mvp_items") if isinstance(draft.get("mvp_items"), list) else [] + positioning_one_liner = str(draft.get("positioning_one_liner") or summary).strip() or summary + target_customers = str(draft.get("target_customers") or "").strip() or "待补充" + core_pain_points = draft.get("core_pain_points") if isinstance(draft.get("core_pain_points"), list) else [] + mvp_phase_1 = draft.get("mvp_phase_1") if isinstance(draft.get("mvp_phase_1"), list) else [] + mvp_phase_2 = draft.get("mvp_phase_2") if isinstance(draft.get("mvp_phase_2"), list) else [] + technical_architecture = draft.get("technical_architecture") if isinstance(draft.get("technical_architecture"), list) else [] + business_model = draft.get("business_model") if isinstance(draft.get("business_model"), list) else [] + validation_metrics = draft.get("validation_metrics") if isinstance(draft.get("validation_metrics"), dict) else {} + quick_start_suggestions = draft.get("quick_start_suggestions") if isinstance(draft.get("quick_start_suggestions"), dict) else {} + team_setup = draft.get("team_setup") if isinstance(draft.get("team_setup"), list) else [] prospect_assessment = str(draft.get("prospect_assessment") or "").strip() requirement_items = draft.get("requirement_items") if isinstance(draft.get("requirement_items"), list) else [] missing_info = draft.get("missing_info") if isinstance(draft.get("missing_info"), list) else [] @@ -338,6 +394,141 @@ class DraftGeneratorService: if not plan_lines: plan_lines = ["· 待定:补充跟进计划"] + if not mvp_phase_1 and mvp_items: + mvp_phase_1 = mvp_items[:3] + if not mvp_phase_2 and len(mvp_items) > 3: + mvp_phase_2 = mvp_items[3:6] + + def _format_text_items(items: list[Any], *, default_line: str, max_items: int = 4) -> list[str]: + lines = DraftGeneratorService._render_bullet_items( + items, + default_line=default_line, + formatter=DraftGeneratorService._render_text_item_line, + max_items=max_items, + ) + return lines + + def _format_pain_point_table(items: list[Any]) -> list[str]: + rows: list[list[str]] = [] + for item in items[:3]: + if not isinstance(item, dict): + continue + pain = str(item.get("pain") or "").strip()[:40] + status = str(item.get("status") or "").strip()[:60] or "待补充" + opportunity = str(item.get("opportunity") or "").strip()[:60] or "待补充" + if pain: + rows.append([pain, status, opportunity]) + if not rows: + return [] + lines = [ + "痛点 | 现状 | 机会", + "---|---|---", + ] + for pain, status, opportunity in rows: + lines.append(f"{pain} | {status} | {opportunity}") + return lines + + def _format_business_model_lines(items: list[Any]) -> list[str]: + rows: list[str] = [] + for item in items[:3]: + if not isinstance(item, dict): + text = str(item or "").strip() + if text: + rows.append(f"· {text[:120]}") + continue + plan_name = str(item.get("plan") or item.get("name") or "").strip()[:40] + price = str(item.get("price") or "").strip()[:40] or "待定" + features = str(item.get("features") or item.get("value") or "").strip()[:120] or "待补充" + if plan_name: + rows.append(f"· {plan_name}:{price};{features}") + if not rows: + rows = ["· 待补充商业模式"] + return rows + + def _format_section_map_lines(title_text: str, section_map: dict[str, Any], keys: tuple[str, ...]) -> list[str]: + lines: list[str] = [title_text] + has_any = False + for key in keys: + values = section_map.get(key) if isinstance(section_map.get(key), list) else [] + if not values: + continue + has_any = True + label = { + "product": "产品验证", + "market": "市场验证", + "week_1_2": "Week 1-2", + "week_3_4": "Week 3-4", + }.get(key, key) + lines.append(f"{label}:") + lines.extend(_format_text_items(values, default_line="· 待补充", max_items=3)) + if not has_any: + lines.append("· 待补充") + return lines + + def _format_team_lines(items: list[Any]) -> list[str]: + rows: list[str] = [] + for item in items[:5]: + if not isinstance(item, dict): + text = str(item or "").strip() + if text: + rows.append(f"· {text[:120]}") + continue + role = str(item.get("role") or item.get("title") or item.get("name") or "").strip()[:60] + notes = str(item.get("notes") or item.get("description") or "").strip()[:120] or "待补充" + if role: + rows.append(f"· {role}:{notes}") + if not rows: + rows = ["· 待补充团队配置"] + return rows + + pain_table_lines = _format_pain_point_table(core_pain_points) + if not pain_table_lines: + requirement_pain_items = [ + item + for item in requirement_items + if isinstance(item, dict) and str(item.get("section") or "").strip() == "核心痛点" + ] + pain_table_lines = _format_pain_point_table( + [ + { + "pain": str(item.get("title") or "").strip(), + "status": str(item.get("description") or "").strip(), + "opportunity": "", + } + for item in requirement_pain_items + ] + ) + if not pain_table_lines: + pain_table_lines = ["· 待补充核心痛点"] + + phase_1_lines = _format_text_items( + mvp_phase_1, + default_line="· 待补充:先明确核心闭环", + max_items=4, + ) + phase_2_lines = _format_text_items( + mvp_phase_2, + default_line="· 待补充:确认差异化方向后再展开", + max_items=3, + ) + tech_lines = _format_text_items( + technical_architecture, + default_line="· 待补充技术架构", + max_items=6, + ) + business_lines = _format_business_model_lines(business_model) + validation_lines = _format_section_map_lines( + "验证指标:", + validation_metrics, + keys=("product", "market"), + ) + quick_start_lines = _format_section_map_lines( + "快速启动建议:", + quick_start_suggestions, + keys=("week_1_2", "week_3_4"), + ) + team_lines = _format_team_lines(team_setup) + workload_text = f"{assignee_workload:.1f}" if assignee_workload % 1 else f"{int(assignee_workload)}" feasibility_reason_line = feasibility_reason if feasibility_reason else "需补充约束后再确认。" assignee_reason_line = assignee_reason if assignee_reason else "建议结合成员技能与当前负载综合判断。" @@ -381,6 +572,34 @@ class DraftGeneratorService: "🚀 MVP 建议:", *mvp_lines, "", + "一、定位", + f"· 一句话描述:{positioning_one_liner}", + f"· 目标客户:{target_customers}", + "", + "二、核心痛点", + *pain_table_lines, + "", + "三、MVP 功能范围(8周交付)", + "Phase 1:核心功能(Week 1-4)", + *phase_1_lines, + "Phase 2:差异化功能(Week 5-8)", + *phase_2_lines, + "", + "四、技术架构(精简版)", + *tech_lines, + "", + "五、商业模式", + *business_lines, + "", + "六、验证指标", + *validation_lines, + "", + "七、快速启动建议", + *quick_start_lines, + "", + "八、团队配置", + *team_lines, + "", "📈 前景判断:", *prospect_lines, "", @@ -438,6 +657,326 @@ class DraftGeneratorService: normalized.append({"when": when, "action": action}) return normalized + @staticmethod + def _normalize_core_pain_points(value: Any) -> list[dict[str, str]]: + if not isinstance(value, list): + return [] + normalized: list[dict[str, str]] = [] + for row in value[:5]: + if isinstance(row, dict): + pain = "" + for key in ("pain", "title", "name"): + candidate = str(row.get(key) or "").strip() + if candidate: + pain = candidate[:80] + break + if not pain: + pain = " ".join(str(row.get("value") or "").split()).strip()[:80] + if not pain: + continue + normalized.append( + { + "pain": pain, + "status": str(row.get("status") or row.get("current_status") or "").strip()[:120], + "opportunity": str(row.get("opportunity") or row.get("chance") or "").strip()[:120], + } + ) + elif isinstance(row, str): + pain = " ".join(row.split()).strip()[:80] + if pain: + normalized.append({"pain": pain, "status": "", "opportunity": ""}) + return normalized + + @staticmethod + def _normalize_business_model_items(value: Any) -> list[dict[str, str]]: + if not isinstance(value, list): + return [] + normalized: list[dict[str, str]] = [] + for row in value[:5]: + if isinstance(row, dict): + plan = "" + for key in ("plan", "name"): + candidate = str(row.get(key) or "").strip() + if candidate: + plan = candidate[:40] + break + price = str(row.get("price") or "").strip()[:40] + features = "" + for key in ("features", "value"): + candidate = str(row.get(key) or "").strip() + if candidate: + features = candidate[:160] + break + if not any((plan, price, features)): + continue + normalized.append({"plan": plan, "price": price, "features": features}) + elif isinstance(row, str): + text = " ".join(row.split()).strip()[:120] + if text: + normalized.append({"plan": text, "price": "", "features": ""}) + return normalized + + @staticmethod + def _normalize_section_map(value: Any, *, keys: tuple[str, ...]) -> dict[str, list[str]]: + result = {key: [] for key in keys} + if not isinstance(value, dict): + return result + for key in keys: + result[key] = DraftGeneratorService._normalize_string_list( + value.get(key), + fallback=[], + max_items=5, + max_length=120, + ) + return result + + @staticmethod + def _normalize_team_setup_items(value: Any) -> list[dict[str, str]]: + if not isinstance(value, list): + return [] + normalized: list[dict[str, str]] = [] + for row in value[:6]: + if isinstance(row, dict): + role = "" + for key in ("role", "title", "name"): + candidate = str(row.get(key) or "").strip() + if candidate: + role = candidate[:60] + break + notes = "" + for key in ("notes", "description"): + candidate = str(row.get(key) or "").strip() + if candidate: + notes = candidate[:120] + break + if not any((role, notes)): + continue + normalized.append({"role": role, "notes": notes}) + elif isinstance(row, str): + text = " ".join(row.split()).strip()[:120] + if text: + normalized.append({"role": text, "notes": ""}) + return normalized + + @staticmethod + def _normalize_mvp_phases(payload: dict[str, Any]) -> tuple[list[str], list[str]]: + mvp_phases = payload.get("mvp_phases") if isinstance(payload.get("mvp_phases"), dict) else {} + phase_1 = DraftGeneratorService._normalize_string_list( + payload.get("mvp_phase_1") or payload.get("phase_1") or mvp_phases.get("phase_1") or mvp_phases.get("week_1_4"), + fallback=[], + max_items=5, + max_length=100, + ) + phase_2 = DraftGeneratorService._normalize_string_list( + payload.get("mvp_phase_2") or payload.get("phase_2") or mvp_phases.get("phase_2") or mvp_phases.get("week_5_8"), + fallback=[], + max_items=5, + max_length=100, + ) + return phase_1, phase_2 + + @staticmethod + def _normalize_requirement_items( + requirement_items: Any, + *, + core_pain_points: list[dict[str, str]], + mvp_phase_1: list[str], + mvp_phase_2: list[str], + technical_architecture: list[str], + business_model: list[dict[str, str]], + validation_metrics: dict[str, list[str]], + quick_start_suggestions: dict[str, list[str]], + team_setup: list[dict[str, str]], + default_source_kind: str, + ) -> list[dict[str, Any]]: + normalized: list[dict[str, Any]] = [] + if isinstance(requirement_items, list): + for raw_item in requirement_items: + if not isinstance(raw_item, dict): + continue + section = str(raw_item.get("section") or raw_item.get("category") or "").strip()[:40] or "MVP 功能" + title = str(raw_item.get("title") or raw_item.get("name") or raw_item.get("feature") or "").strip()[:120] + description = str(raw_item.get("description") or raw_item.get("summary") or title).strip()[:600] + if not title: + continue + normalized.append( + { + "section": section, + "title": title, + "description": description or title, + "priority": str(raw_item.get("priority") or "medium").strip().lower() or "medium", + "source_kind": str(raw_item.get("source_kind") or default_source_kind).strip() or default_source_kind, + "source_name": str(raw_item.get("source_name") or f"{section} - {title}").strip()[:120], + "source_identifier": str(raw_item.get("source_identifier") or "").strip()[:180], + } + ) + if normalized: + return normalized[:8] + + derived: list[dict[str, Any]] = [] + for point in core_pain_points[:3]: + title = str(point.get("pain") or "").strip()[:120] + if not title: + continue + description_parts: list[str] = [] + status_text = str(point.get("status") or "").strip()[:120] + if status_text: + description_parts.append(f"现状:{status_text}") + opportunity_text = str(point.get("opportunity") or "").strip()[:120] + if opportunity_text: + description_parts.append(f"机会:{opportunity_text}") + derived.append( + { + "section": "核心痛点", + "title": title, + "description": ";".join(description_parts) or title, + "priority": "high", + "source_kind": default_source_kind, + "source_name": f"核心痛点 - {title}"[:120], + "source_identifier": "", + } + ) + for item in mvp_phase_1[:3]: + if not item: + continue + derived.append( + { + "section": "MVP 功能", + "title": item[:120], + "description": item[:600], + "priority": "medium", + "source_kind": default_source_kind, + "source_name": f"MVP 功能 - {item}"[:120], + "source_identifier": "", + } + ) + for item in mvp_phase_2[:3]: + if not item: + continue + derived.append( + { + "section": "差异化功能", + "title": item[:120], + "description": item[:600], + "priority": "low", + "source_kind": default_source_kind, + "source_name": f"差异化功能 - {item}"[:120], + "source_identifier": "", + } + ) + for item in technical_architecture[:3]: + if not item: + continue + derived.append( + { + "section": "技术架构", + "title": item[:120], + "description": item[:600], + "priority": "medium", + "source_kind": default_source_kind, + "source_name": f"技术架构 - {item}"[:120], + "source_identifier": "", + } + ) + for row in business_model[:3]: + title = str(row.get("plan") or row.get("price") or "").strip()[:120] + features = str(row.get("features") or "").strip()[:600] + if not title: + continue + derived.append( + { + "section": "商业模式", + "title": title, + "description": features or title, + "priority": "medium", + "source_kind": default_source_kind, + "source_name": f"商业模式 - {title}"[:120], + "source_identifier": "", + } + ) + for metric in validation_metrics.get("product", [])[:3]: + if not metric: + continue + derived.append( + { + "section": "验证指标", + "title": metric[:120], + "description": metric[:600], + "priority": "low", + "source_kind": default_source_kind, + "source_name": f"验证指标 - {metric}"[:120], + "source_identifier": "", + } + ) + for metric in validation_metrics.get("market", [])[:3]: + if not metric: + continue + derived.append( + { + "section": "市场验证", + "title": metric[:120], + "description": metric[:600], + "priority": "low", + "source_kind": default_source_kind, + "source_name": f"市场验证 - {metric}"[:120], + "source_identifier": "", + } + ) + for key, rows in quick_start_suggestions.items(): + section = "快速启动建议" + for item in rows[:2]: + if not item: + continue + derived.append( + { + "section": section, + "title": item[:120], + "description": item[:600], + "priority": "medium", + "source_kind": default_source_kind, + "source_name": f"{section} - {item}"[:120], + "source_identifier": "", + } + ) + for row in team_setup[:4]: + title = str(row.get("role") or "").strip()[:120] + notes = str(row.get("notes") or "").strip()[:600] + if not title: + continue + derived.append( + { + "section": "团队配置", + "title": title, + "description": notes or title, + "priority": "low", + "source_kind": default_source_kind, + "source_name": f"团队配置 - {title}"[:120], + "source_identifier": "", + } + ) + if derived: + return derived[:8] + return [ + { + "section": "项目定位", + "title": "项目定位与目标", + "description": "需确认项目定位与目标。", + "priority": "medium", + "source_kind": default_source_kind, + "source_name": "项目定位 - 项目定位与目标", + "source_identifier": "", + }, + { + "section": "MVP 功能", + "title": "MVP 功能范围", + "description": "需确认 MVP 功能范围。", + "priority": "medium", + "source_kind": default_source_kind, + "source_name": "MVP 功能 - MVP 功能范围", + "source_identifier": "", + }, + ] + @staticmethod def _normalize_active_projects(value: Any) -> list[dict[str, Any]]: if not isinstance(value, list): diff --git a/backend/app/services/signal/signal_planner/mixin_analyze.py b/backend/app/services/signal/signal_planner/mixin_analyze.py index 38485c1e..32b17c38 100644 --- a/backend/app/services/signal/signal_planner/mixin_analyze.py +++ b/backend/app/services/signal/signal_planner/mixin_analyze.py @@ -77,13 +77,13 @@ class SignalPlannerAnalyzeMixin: tenant_id=int(tenant_id), function_name="signal_planner_analyze", ) - if str(route.get("source") or "") != "override": + if not str(route.get("tier_alias") or "").strip(): logger.warning( - "signal_planner_routing_override_missing", + "signal_planner_routing_resolution_incomplete", tenant_id=int(tenant_id), signal_id=int(signal.id), - fallback_provider=str(route.get("provider") or ""), - fallback_model=str(route.get("model") or ""), + route_source=str(route.get("source") or ""), + route_rule_name=str(route.get("rule_name") or ""), ) extraction = None diff --git a/backend/tests/test_ai_pmo_ingress_mainline.py b/backend/tests/test_ai_pmo_ingress_mainline.py index 873e91cf..41285880 100644 --- a/backend/tests/test_ai_pmo_ingress_mainline.py +++ b/backend/tests/test_ai_pmo_ingress_mainline.py @@ -568,7 +568,7 @@ async def test_execute_query_intent_feishu_fallback_uses_thread_key(monkeypatch) @pytest.mark.asyncio -async def test_low_tier_rag_failure_prefers_glm5_openclaw_failover(monkeypatch) -> None: +async def test_low_tier_rag_failure_retries_high_tier(monkeypatch) -> None: captured: dict[str, object] = {"logs": []} monkeypatch.setattr( @@ -695,16 +695,18 @@ async def test_low_tier_rag_failure_prefers_glm5_openclaw_failover(monkeypatch) assert text == "GLM-5 answer" assert isinstance(resolved_engine, dict) - assert resolved_engine["provider"] == "openai" - assert resolved_engine["model"] == "GLM-5" + resolved_meta = resolved_engine["_routing_meta"] + assert resolved_meta["planned_tier"] == "low" + assert resolved_meta["executed_tier"] == "high" + assert resolved_meta["routing_decision"] == "low_to_high_escalated" + assert captured["chat_kwargs"]["provider"] == "openai" assert captured["chat_kwargs"]["model"] == "GLM-5" log_text = "\n".join(message for _, message in captured["logs"]) - assert "OpenClaw高阶升级命中" in log_text assert "高阶升级成功" in log_text @pytest.mark.asyncio -async def test_low_tier_intent_failure_forces_glm5_retry_even_when_generic_high_tier_differs(monkeypatch) -> None: +async def test_low_tier_intent_failure_retries_high_tier(monkeypatch) -> None: captured: dict[str, object] = {"logs": []} monkeypatch.setattr( @@ -720,12 +722,12 @@ async def test_low_tier_intent_failure_forces_glm5_retry_even_when_generic_high_ "base_url": "https://open.bigmodel.cn/api/paas/v4", "api_key": "low-key", } - mini_engine = { - "id": "high-mini-1", - "provider": "openai", - "model": "MiniMax-M2.5", - "base_url": "https://api.openai.com/v1", - "api_key": "mini-key", + alternate_high_engine = { + "id": "high-deepseek-1", + "provider": "deepseek", + "model": "deepseek-chat", + "base_url": "https://api.deepseek.com/v1", + "api_key": "deepseek-key", } glm5_engine = { "id": "high-glm5-1", @@ -737,7 +739,7 @@ async def test_low_tier_intent_failure_forces_glm5_retry_even_when_generic_high_ bundle = { "settings": {}, "active_engine_id": "low-1", - "engines": [low_engine, mini_engine, glm5_engine], + "engines": [low_engine, alternate_high_engine, glm5_engine], } openclaw_bundle = { "rules": [ @@ -806,7 +808,7 @@ async def test_low_tier_intent_failure_forces_glm5_retry_even_when_generic_high_ def _resolve_tier_engine(self, *, tier, engines, active_engine): _ = (engines, active_engine) if str(tier).strip().lower() == "high": - return dict(mini_engine) + return dict(alternate_high_engine) return dict(low_engine) def _ensure_openclaw_control_plane_engine(self, engine): @@ -844,15 +846,18 @@ async def test_low_tier_intent_failure_forces_glm5_retry_even_when_generic_high_ assert text == "GLM-5 answer" assert isinstance(resolved_engine, dict) - assert resolved_engine["model"] == "GLM-5" + resolved_meta = resolved_engine["_routing_meta"] + assert resolved_meta["planned_tier"] == "low" + assert resolved_meta["executed_tier"] == "high" + assert resolved_meta["routing_decision"] == "low_to_high_escalated" + assert captured["chat_kwargs"]["provider"] == "openai" assert captured["chat_kwargs"]["model"] == "GLM-5" log_text = "\n".join(message for _, message in captured["logs"]) - assert "GLM-5强制高阶升级命中" in log_text or "OpenClaw高阶升级命中" in log_text assert "高阶升级成功" in log_text @pytest.mark.asyncio -async def test_low_tier_structured_failure_forces_glm5_retry(monkeypatch) -> None: +async def test_low_tier_structured_failure_retries_high_tier(monkeypatch) -> None: captured: dict[str, object] = {"logs": []} monkeypatch.setattr( @@ -868,12 +873,12 @@ async def test_low_tier_structured_failure_forces_glm5_retry(monkeypatch) -> Non "base_url": "https://open.bigmodel.cn/api/paas/v4", "api_key": "low-key", } - mini_engine = { - "id": "high-mini-1", - "provider": "openai", - "model": "MiniMax-M2.5", - "base_url": "https://api.openai.com/v1", - "api_key": "mini-key", + alternate_high_engine = { + "id": "high-deepseek-1", + "provider": "deepseek", + "model": "deepseek-chat", + "base_url": "https://api.deepseek.com/v1", + "api_key": "deepseek-key", } glm5_engine = { "id": "high-glm5-1", @@ -885,7 +890,7 @@ async def test_low_tier_structured_failure_forces_glm5_retry(monkeypatch) -> Non bundle = { "settings": {}, "active_engine_id": "low-1", - "engines": [low_engine, mini_engine, glm5_engine], + "engines": [low_engine, alternate_high_engine, glm5_engine], } openclaw_bundle = { "rules": [ @@ -954,7 +959,7 @@ async def test_low_tier_structured_failure_forces_glm5_retry(monkeypatch) -> Non def _resolve_tier_engine(self, *, tier, engines, active_engine): _ = (engines, active_engine) if str(tier).strip().lower() == "high": - return dict(mini_engine) + return dict(alternate_high_engine) return dict(low_engine) def _ensure_openclaw_control_plane_engine(self, engine): @@ -1010,12 +1015,14 @@ async def test_low_tier_structured_failure_forces_glm5_retry(monkeypatch) -> Non error=ValueError("LLM 返回空内容"), ) - assert getattr(result, "payload", None) is not None - assert result.payload["confidence"] == pytest.approx(0.91) - assert result.model == "GLM-5" + assert result is not None + assert result.payload["intent"] == "query.read_only" assert isinstance(resolved_engine, dict) - assert resolved_engine["model"] == "GLM-5" + resolved_meta = resolved_engine["_routing_meta"] + assert resolved_meta["planned_tier"] == "low" + assert resolved_meta["executed_tier"] == "high" + assert resolved_meta["routing_decision"] == "low_to_high_escalated" + assert captured["chat_kwargs"]["provider"] == "openai" assert captured["chat_kwargs"]["model"] == "GLM-5" log_text = "\n".join(message for _, message in captured["logs"]) - assert "GLM-5强制高阶升级命中" in log_text or "OpenClaw高阶升级命中" in log_text assert "高阶结构化升级成功" in log_text diff --git a/backend/tests/test_ai_service_core_runtime.py b/backend/tests/test_ai_service_core_runtime.py index 50cc39b1..2c00a892 100644 --- a/backend/tests/test_ai_service_core_runtime.py +++ b/backend/tests/test_ai_service_core_runtime.py @@ -10,7 +10,7 @@ from app.services.llm_service import mixin_chat_openai from app.services.ai_service_core import AIService from app.services.llm_service import mixin_cache_circuit from app.services.llm_service import llm_service -from app.services.model.model_config import load_model_config +from app.services.model.model_config import load_model_config, resolve_model_config_path @pytest.mark.asyncio @@ -84,9 +84,15 @@ def test_model_config_bundle_centralizes_provider_and_tier_defaults() -> None: tier_defaults = bundle.get("tier_defaults") assert provider_defaults["glm"]["model"] == "qwen-plus" - assert provider_defaults["openai"]["model"] == "MiniMax-M2.5" + assert provider_defaults["openai"]["model"] == "GLM-5" assert tier_defaults["low"]["model"] == "qwen-plus" assert tier_defaults["high"]["model"] == "GLM-5" + assert "safety" not in tier_defaults + assert str(bundle.get("config_path") or "").endswith("backend/.openclaw/pmflow-routing-rules.json") + + +def test_model_config_default_path_points_to_backend_openclaw() -> None: + assert str(resolve_model_config_path()).endswith("backend/.openclaw/pmflow-routing-rules.json") def test_provider_payload_reads_canonical_model_config(monkeypatch) -> None: @@ -100,7 +106,7 @@ def test_provider_payload_reads_canonical_model_config(monkeypatch) -> None: assert payload["base_url"] == "https://api.edgefn.net/v1" assert payload["providers"]["glm"]["model"] == "qwen-plus" assert payload["providers"]["glm"]["base_url"] == "https://dashscope.aliyuncs.com/compatible-mode/v1" - assert payload["providers"]["openai"]["model"] == "MiniMax-M2.5" + assert payload["providers"]["openai"]["model"] == "GLM-5" @pytest.mark.asyncio @@ -118,6 +124,19 @@ async def test_ai_service_runtime_prefers_canonical_config_over_env(monkeypatch) assert runtime["base_url"] == "https://dashscope.aliyuncs.com/compatible-mode/v1" +def test_ai_service_runtime_ignores_legacy_glm_api_key(monkeypatch) -> None: + service = AIService() + monkeypatch.setenv("OPENCLAW_ROUTING_ENABLED", "false") + monkeypatch.setenv("LLM_DIRECT_EMERGENCY_ENABLED", "true") + monkeypatch.setenv("LLM_PROVIDER", "glm") + monkeypatch.delenv("DASHSCOPE_API_KEY", raising=False) + monkeypatch.setenv("GLM_API_KEY", "legacy-glm-key") + + runtime = service._resolve_llm_runtime(api_key=None, model=None, base_url=None) + + assert runtime["api_key"] == "" + + def test_openclaw_rule_keeps_existing_api_key_when_env_missing(monkeypatch) -> None: monkeypatch.delenv("DASHSCOPE_API_KEY", raising=False) @@ -133,7 +152,7 @@ def test_openclaw_rule_keeps_existing_api_key_when_env_missing(monkeypatch) -> N "id": "low-1", "name": "Qwen 低阶引擎", "provider": "glm", - "model": "GLM-4.7", + "model": "qwen-turbo", "api_key": "db-key", "base_url": "https://api.edgefn.net/v1", } @@ -160,6 +179,46 @@ def test_openclaw_rule_keeps_existing_api_key_when_env_missing(monkeypatch) -> N assert routed["api_key"] == "db-key" +def test_openclaw_rule_prefers_env_api_key_over_db_key(monkeypatch) -> None: + monkeypatch.setenv("DASHSCOPE_API_KEY", "env-dashscope-key") + + rule = { + "tier": "low", + "provider": "glm", + "model": "qwen-plus", + "base_url": "https://dashscope.aliyuncs.com/compatible-mode/v1", + "api_env": "DASHSCOPE_API_KEY", + } + engines = [ + { + "id": "low-1", + "name": "Qwen 低阶引擎", + "provider": "glm", + "model": "qwen-turbo", + "api_key": "db-key", + "base_url": "https://api.edgefn.net/v1", + } + ] + active_engine = { + "id": "high-1", + "name": "OpenAI 高阶引擎", + "provider": "openai", + "model": "GLM-5", + "api_key": "high-key", + "base_url": "https://api.edgefn.net/v1", + } + + routed = llm_service._resolve_engine_from_rule( + rule=rule, + engines=engines, + active_engine=active_engine, + ) + + assert routed is not None + assert routed["provider"] == "glm" + assert routed["api_key"] == "env-dashscope-key" + + def test_provider_circuit_state_clears_when_model_config_changes(monkeypatch, tmp_path) -> None: circuit_key = "glm|https://dashscope.aliyuncs.com/compatible-mode/v1|qwen-plus" original_state = dict(llm_service._provider_circuit_state) diff --git a/backend/tests/test_ai_steward_project_from_text_contract.py b/backend/tests/test_ai_steward_project_from_text_contract.py index fa41239c..1c226f51 100644 --- a/backend/tests/test_ai_steward_project_from_text_contract.py +++ b/backend/tests/test_ai_steward_project_from_text_contract.py @@ -76,3 +76,21 @@ async def test_project_from_text_returns_enveloped_signal_sources(monkeypatch) - assert payload["message"] == "success" assert payload["data"]["project"]["id"] == "11" assert payload["data"]["signal_sources"][0]["source_name"] == "核心痛点 - 内容本地化" + + +@pytest.mark.asyncio +async def test_project_requirement_analysis_uses_canonical_function_name(monkeypatch) -> None: + service = AIStewardService(object()) + captured: dict[str, object] = {} + + async def fake_generate(prompt: str, **kwargs): # noqa: ANN001 + _ = prompt + captured["function_name"] = kwargs.get("function_name") + return '{"project_title":"AI 客服","requirement_items":[]}' + + monkeypatch.setattr(service.llm, "generate", fake_generate) + + analysis = await service._analyze_project_input("请创建一个 AI 客服项目", tenant_id=1) + + assert analysis["project_title"] == "AI 客服" + assert captured["function_name"] == "project_requirement_extraction" diff --git a/backend/tests/test_draft_confirm_handler.py b/backend/tests/test_draft_confirm_handler.py index cd53902f..be8b526e 100644 --- a/backend/tests/test_draft_confirm_handler.py +++ b/backend/tests/test_draft_confirm_handler.py @@ -10,6 +10,7 @@ from app.config.ai_prompts import ( ) from app.services.ai_pmo.ai_pmo_kernel_common_core.constants import STATE_SUCCEEDED from app.services.ai_pmo.draft_confirm_handler import DraftConfirmHandler +from app.services.ai_pmo.ai_pmo_kernel_common_core.constants import STATE_DRAFT_PENDING_CONFIRM from app.services.ai_pmo.ai_pmo_kernel_ingress_flow import mixin_auto from app.services.adaptive_rag_service.mixin_deep_guard_answer import AdaptiveRAGDeepGuardAnswerMixin from app.services.integrations.feishu_integration_service.mixin_reply_llm_core import ( @@ -105,6 +106,15 @@ def test_reply_outbox_extract_sent_message_id() -> None: assert FeishuReplyOutboxMixin._extract_sent_message_id({"message_id": "msg_456"}) == "msg_456" +def test_feishu_reply_llm_core_strips_action_process_prefaces() -> None: + reply = ( + "好的,我来帮你创建项目和信号源。\n\n" + "让我先检查一下后端服务是否运行,然后通过API创建项目。" + ) + + assert FeishuReplyLLMCoreMixin._strip_leading_thinking_paragraphs(reply) == "" + + @pytest.mark.asyncio async def test_draft_confirm_handler_repeated_confirm_returns_inbox_success_copy() -> None: class _FakeResult: @@ -147,6 +157,157 @@ async def test_draft_confirm_handler_repeated_confirm_returns_inbox_success_copy assert result["followup_reply"] == "已入库成功,无需重复确认。" +@pytest.mark.asyncio +async def test_draft_confirm_handler_project_create_confirm_uses_project_creation_flow(monkeypatch) -> None: + captured: dict[str, object] = {} + + class _FakeResult: + def __init__(self, locked): + self._locked = locked + + def scalar_one_or_none(self): + return self._locked + + class _FakeDB: + def __init__(self, locked): + self._locked = locked + + async def execute(self, query): # noqa: ARG002 + return _FakeResult(self._locked) + + async def flush(self): + captured["flushed"] = True + + class _FakeStewardService: + def __init__(self, db): + captured["steward_db"] = db + + async def create_project_from_natural_language(self, **kwargs): # noqa: ANN001 + captured["steward_kwargs"] = kwargs + return { + "project": {"id": "101", "title": "跨境 DTC 品牌 AI 运营平台", "name": "跨境 DTC 品牌 AI 运营平台"}, + "signal": {"id": "202", "content": "main signal"}, + "charter": {"id": "303", "title": "宪章", "content": {"summary": "demo"}}, + "tasks": [{"id": "401", "title": "任务 1"}], + "risks": [], + "signal_sources": [ + { + "id": "501", + "source_kind": "custom", + "source_name": "核心痛点 - 内容本地化", + "source_identifier": "custom:core-pain-content-localization", + } + ], + } + + locked = SimpleNamespace( + id=42, + state=STATE_DRAFT_PENDING_CONFIRM, + intent="project.create", + project_id=None, + signal_id=None, + draft_version=2, + draft_expires_at=None, + confirm_message_id=None, + owner_user_id=77, + draft_content={ + "title": "跨境 DTC 品牌 AI 运营平台", + "summary": "帮助中国 DTC 品牌用 AI 快速完成海外市场的内容本地化、社媒运营、用户洞察。", + "original_text": "帮助中国 DTC 品牌用 AI 快速完成海外市场的内容本地化、社媒运营、用户洞察。", + "recommended_assignee_id": 88, + "recommended_assignee_name": "Alice", + "requirement_items": [ + { + "section": "核心痛点", + "title": "内容本地化", + "description": "翻译公司贵、慢、不地道", + } + ], + }, + execution_result={}, + ) + handler = DraftConfirmHandler(type("Kernel", (), {"db": _FakeDB(locked), "tenant_id": 1})()) + + monkeypatch.setattr("app.services.ai_pmo.draft_confirm_handler.AIStewardService", _FakeStewardService) + + result = await handler.confirm_draft( + target_work_item=SimpleNamespace( + id=42, + state=STATE_DRAFT_PENDING_CONFIRM, + intent="project.create", + project_id=None, + signal_id=None, + owner_user_id=77, + ), + target_ingest=SimpleNamespace(payload={"message_text": "帮我做一个出海 DTC 项目"}), + sender="测试用户", + source_system="feishu", + source_message_id="msg-1", + message_text="确认", + expected_version=2, + ) + + assert result["handled"] is True + assert result["status"] == "succeeded" + assert result["project_id"] == 101 + assert result["signal_id"] == 202 + assert "信号源" in result["followup_reply"] + assert "steward_kwargs" in captured + assert captured["steward_kwargs"]["creator_id"] == 77 + assert captured["steward_kwargs"]["requested_member_user_ids"] == [88] + assert "跨境 DTC 品牌 AI 运营平台" in str(captured["steward_kwargs"]["input_text"]) + + +@pytest.mark.asyncio +async def test_draft_confirm_handler_revise_without_note_requests_feedback(monkeypatch) -> None: + class _FakeResult: + def __init__(self, locked): + self._locked = locked + + def scalar_one_or_none(self): + return self._locked + + class _FakeDB: + def __init__(self, locked): + self._locked = locked + + async def execute(self, query): # noqa: ARG002 + return _FakeResult(self._locked) + + async def flush(self): + return None + + locked = SimpleNamespace( + id=42, + state=STATE_DRAFT_PENDING_CONFIRM, + intent="project.create", + project_id=None, + signal_id=None, + draft_version=2, + draft_expires_at=None, + draft_content={"title": "跨境 DTC 品牌 AI 运营平台", "original_text": "原始需求"}, + execution_result={}, + ) + handler = DraftConfirmHandler(type("Kernel", (), {"db": _FakeDB(locked), "tenant_id": 1})()) + + async def _fail_generate_and_persist(**kwargs): # noqa: ANN001 + raise AssertionError(f"draft generator should not run without a revision note: {kwargs}") + + monkeypatch.setattr(handler.draft_generator, "generate_and_persist", _fail_generate_and_persist) + + result = await handler.revise_draft( + target_work_item=SimpleNamespace(id=42, state=STATE_DRAFT_PENDING_CONFIRM, intent="project.create", project_id=None, signal_id=None), + target_ingest=SimpleNamespace(payload={"message_text": "原始需求"}), + revision_note="", + expected_version=2, + ) + + assert result["handled"] is True + assert result["status"] == "draft_pending_confirm" + assert result["target_state"] == STATE_DRAFT_PENDING_CONFIRM + assert "修改" in result["followup_reply"] + + @pytest.mark.asyncio async def test_draft_confirm_handler_ignores_non_draft_reply_without_target(monkeypatch) -> None: kernel = type("Kernel", (), {"db": object(), "tenant_id": 1})() diff --git a/backend/tests/test_draft_generator_service.py b/backend/tests/test_draft_generator_service.py index 36847ebf..00706191 100644 --- a/backend/tests/test_draft_generator_service.py +++ b/backend/tests/test_draft_generator_service.py @@ -93,6 +93,110 @@ async def test_generate_and_render_draft_includes_pm_sections(monkeypatch) -> No assert generated["draft"]["recommended_assignee_workload"] == 25.0 +@pytest.mark.asyncio +async def test_generate_and_render_draft_includes_rich_mvp_template(monkeypatch) -> None: + service = DraftGeneratorService(db=object(), tenant_id=1) + captured: dict[str, str] = {} + + async def fake_load_members(*, world_model): + _ = world_model + return [{"id": 7, "name": "Alice", "workload_percent": 25.0}] + + async def fake_generate_draft_payload(*, prompt): + captured["prompt"] = prompt + return { + "title": "跨境 DTC 品牌 AI 运营平台", + "summary": "帮助中国 DTC 品牌用 AI 快速完成海外市场的内容本地化、社媒运营、用户洞察。", + "core_pain_points": [ + { + "pain": "内容本地化", + "status": "翻译公司贵、慢、不地道", + "opportunity": "AI 快速生成 + 人工微调", + }, + { + "pain": "社媒运营", + "status": "雇海外运营成本高", + "opportunity": "AI 自动生成 + 排期发布", + }, + ], + "mvp_items": [ + "多语言内容本地化", + "社媒内容生成器", + "用户反馈分析", + ], + "technical_architecture": [ + "前端:React + TypeScript + Tailwind", + "后端:FastAPI + PostgreSQL + Redis", + ], + "business_model": [ + {"plan": "Starter", "price": "$49/月", "features": "1 个品牌,1000 次 AI 调用/月"}, + ], + "validation_metrics": { + "product": ["注册转化率 > 15%", "7 日留存 > 40%"], + "market": ["找到 5 个愿意付费的种子用户"], + }, + "quick_start_suggestions": { + "week_1_2": ["做 10 个目标用户的深度访谈"], + "week_3_4": ["如果反馈正面,开始写代码"], + }, + "team_setup": [ + {"role": "1 个产品", "notes": "兼职 CEO"}, + {"role": "2 个后端", "notes": "AI 集成 + 数据处理"}, + ], + "similar_projects": [], + "missing_info": [], + "followup_plan": [ + {"when": "明天", "action": "确认触发时机与验收口径"}, + ], + } + + monkeypatch.setattr(service, "_load_members", fake_load_members) + monkeypatch.setattr(service, "_generate_draft_payload", fake_generate_draft_payload) + + work_item = SimpleNamespace( + draft_version=0, + draft_content={}, + draft_expires_at=None, + confirm_message_id=None, + execution_result={}, + ) + world_model = { + "project": {"title": "跨境 DTC 品牌 AI 运营平台", "description": "出海运营项目"}, + "active_projects": [], + "recent_signals": [], + "capacity": [], + } + + generated = await service.generate_and_persist( + work_item=work_item, + intent_name="project.create", + message_text="帮我起一个出海 DTC 品牌项目 MVP", + world_model=world_model, + ) + + prompt = generated["draft_text"] + assert "一、定位" in prompt + assert "二、核心痛点" in prompt + assert "三、MVP 功能范围" in prompt + assert "四、技术架构" in prompt + assert "五、商业模式" in prompt + assert "六、验证指标" in prompt + assert "七、快速启动建议" in prompt + assert "八、团队配置" in prompt + assert "内容本地化" in prompt + assert "Starter" in prompt + assert "1 个产品" in prompt + assert "positioning_one_liner" in captured["prompt"] + assert "core_pain_points" in captured["prompt"] + assert "mvp_phase_1" in captured["prompt"] + assert "technical_architecture" in captured["prompt"] + assert "business_model" in captured["prompt"] + assert "validation_metrics" in captured["prompt"] + assert "quick_start_suggestions" in captured["prompt"] + assert "team_setup" in captured["prompt"] + assert any(item in generated["draft"]["requirement_items"][0]["title"] for item in ("内容本地化", "多语言内容本地化")) + + @pytest.mark.asyncio async def test_generate_charter_seed_uses_project_context(monkeypatch) -> None: captured: dict[str, str] = {} diff --git a/backend/tests/test_feedback_center_migration.py b/backend/tests/test_feedback_center_migration.py new file mode 100644 index 00000000..2005b4ec --- /dev/null +++ b/backend/tests/test_feedback_center_migration.py @@ -0,0 +1,100 @@ +import importlib.util +from pathlib import Path + +import sqlalchemy as sa + + +def _load_migration_module(): + path = ( + Path(__file__).resolve().parents[1] + / "alembic" + / "versions" + / "20260326_create_feedback_center_tables.py" + ) + spec = importlib.util.spec_from_file_location("feedback_center_migration", path) + module = importlib.util.module_from_spec(spec) + assert spec is not None + assert spec.loader is not None + spec.loader.exec_module(module) + return module + + +def _feedback_center_records_table(metadata: sa.MetaData) -> sa.Table: + return sa.Table( + "feedback_center_records", + metadata, + sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True), + sa.Column("tenant_id", sa.BigInteger(), nullable=False), + sa.Column("project_id", sa.BigInteger(), nullable=True), + sa.Column("source_type", sa.VARCHAR(length=32), nullable=False), + sa.Column("source_id", sa.BigInteger(), nullable=False), + sa.Column("object_type", sa.VARCHAR(length=32), nullable=False), + sa.Column("object_id", sa.BigInteger(), nullable=False), + sa.Column("field_name", sa.VARCHAR(length=128), nullable=False), + sa.Column("before_value", sa.JSON(), nullable=True), + sa.Column("after_value", sa.JSON(), nullable=True), + sa.Column("ai_snapshot", sa.JSON(), nullable=True), + sa.Column("ai_confidence", sa.Float(), nullable=True), + sa.Column("reason_candidates", sa.JSON(), nullable=True), + sa.Column("reason_code", sa.VARCHAR(length=64), nullable=True), + sa.Column("reason_text", sa.Text(), nullable=True), + sa.Column("review_status", sa.VARCHAR(length=16), nullable=False), + sa.Column("reviewed_by", sa.BigInteger(), nullable=True), + sa.Column("reviewed_at", sa.TIMESTAMP(), nullable=True), + sa.Column("source_title", sa.VARCHAR(length=500), nullable=True), + sa.Column("object_title", sa.VARCHAR(length=500), nullable=True), + sa.Column("auto_assigned", sa.Boolean(), nullable=False), + ) + + +def test_insert_record_accepts_json_payloads_for_feedback_center_backfill() -> None: + migration = _load_migration_module() + metadata = sa.MetaData() + table = _feedback_center_records_table(metadata) + engine = sa.create_engine("sqlite:///:memory:") + metadata.create_all(engine) + + payload = { + "tenant_id": 1, + "project_id": None, + "source_type": "qa_feedback", + "source_id": 118, + "object_type": "qa_record", + "object_id": 230, + "field_name": "qa_feedback", + "before_value": {"answer": "stub answer"}, + "after_value": {"rating": "positive", "helpfulness": "fully"}, + "ai_snapshot": {"question": "what happened", "answer": "stub answer"}, + "ai_confidence": None, + "reason_candidates": [ + { + "code": "answer_incorrect", + "label": "答案不准确", + "description": "AI 输出与业务事实不一致", + } + ], + "reason_code": "answer_incorrect", + "reason_text": None, + "review_status": "reviewed", + "reviewed_by": 2, + "reviewed_at": None, + "source_title": "what happened", + "object_title": "what happened", + "auto_assigned": False, + } + + with engine.begin() as conn: + migration._insert_record(conn, payload) + row = conn.execute( + sa.select( + table.c.before_value, + table.c.after_value, + table.c.ai_snapshot, + table.c.reason_candidates, + ) + ).mappings().one() + + assert row["before_value"] == payload["before_value"] + assert row["after_value"] == payload["after_value"] + assert row["ai_snapshot"] == payload["ai_snapshot"] + assert row["reason_candidates"] == payload["reason_candidates"] diff --git a/backend/tests/test_feishu_receive_orchestration_contract.py b/backend/tests/test_feishu_receive_orchestration_contract.py index 1a4b1374..a1535db5 100644 --- a/backend/tests/test_feishu_receive_orchestration_contract.py +++ b/backend/tests/test_feishu_receive_orchestration_contract.py @@ -58,6 +58,19 @@ async def test_handle_message_receive_routes_empty_text_branch(monkeypatch) -> N assert called["empty_reply"]["chat_id"] == "chat-empty" +def test_classify_message_scene_prefers_execute_for_action_like_raw_text() -> None: + scene = FeishuIntegrationService._classify_message_scene( + capture_result={ + "status": "answered", + "classification": "question", + "intent": "question", + }, + message_content="帮我创建项目和信号源", + ) + + assert scene == "execute" + + @pytest.mark.asyncio async def test_handle_message_receive_orchestrates_capture_and_finalize(monkeypatch) -> None: service = FeishuIntegrationService(db=object()) diff --git a/backend/tests/test_model_routing_migration.py b/backend/tests/test_model_routing_migration.py new file mode 100644 index 00000000..9f4bce01 --- /dev/null +++ b/backend/tests/test_model_routing_migration.py @@ -0,0 +1,53 @@ +from __future__ import annotations + +import importlib.util +from pathlib import Path + + +def _load_migration_module(): + path = ( + Path(__file__).resolve().parents[1] + / "alembic" + / "versions" + / "20260327_cleanup_model_routing_legacy_names.py" + ) + spec = importlib.util.spec_from_file_location("model_routing_cleanup_migration", path) + module = importlib.util.module_from_spec(spec) + assert spec is not None + assert spec.loader is not None + spec.loader.exec_module(module) + return module + + +def test_rewrite_model_routing_config_renames_and_prunes_legacy_keys() -> None: + migration = _load_migration_module() + config = { + "mappings": { + "project_draft_analysis_glm47_fallback": { + "tier_alias": "low", + "updated_by": 1, + }, + "project_draft_charter_glm47_fallback": { + "tier_alias": "low", + }, + "adaptive_rag_answer_deep_signal_batch_analysis": { + "tier_alias": "high", + "updated_by": 2, + }, + "ai_pmo_signal_title_refine": { + "tier_alias": "high", + "updated_by": 9, + }, + }, + } + + rewritten = migration._rewrite_model_routing_config(config) + mappings = rewritten["mappings"] + + assert mappings["ai_pmo_signal_title_refine"]["tier_alias"] == "high" + assert mappings["ai_pmo_signal_title_refine"]["updated_by"] == 9 + assert mappings["ai_pmo_signal_batch_analysis"]["tier_alias"] == "high" + assert mappings["ai_pmo_signal_batch_analysis"]["updated_by"] == 2 + assert "project_draft_analysis_glm47_fallback" not in mappings + assert "project_draft_charter_glm47_fallback" not in mappings + assert "adaptive_rag_answer_deep_signal_batch_analysis" not in mappings diff --git a/backend/tests/test_model_routing_service.py b/backend/tests/test_model_routing_service.py new file mode 100644 index 00000000..34a39dd6 --- /dev/null +++ b/backend/tests/test_model_routing_service.py @@ -0,0 +1,286 @@ +from __future__ import annotations + +import json +import os + +import pytest +import pytest_asyncio +from sqlalchemy import JSON, and_, select +from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine + +os.environ["DATABASE_URL"] = "postgresql+asyncpg://pmflow:pmflow_pass@127.0.0.1:5435/pmflow" + +from app.models.ai_call_log import AICallLog +from app.models.base import Base +from app.models.integration import Integration +from app.models.user import User +from app.services.llm_service import llm_service +from app.services.llm_service import mixin_prompt_profile_runtime +from app.services.model.model_config import load_model_config +from app.services.model.model_context_bundle import ModelContextBundleService +from app.services.model.model_routing import ModelRoutingService + + +def _use_sqlite_json_types() -> None: + for table in (Integration.__table__, User.__table__): + for column in table.columns: + if column.name in {"config", "assigned_domains"}: + column.type = JSON() + + +@pytest_asyncio.fixture +async def db_env(): + engine = create_async_engine("sqlite+aiosqlite:///:memory:") + _use_sqlite_json_types() + async with engine.begin() as conn: + await conn.run_sync( + lambda sync_conn: Base.metadata.create_all( + sync_conn, + tables=[ + User.__table__, + Integration.__table__, + AICallLog.__table__, + ], + ) + ) + maker = async_sessionmaker(engine, expire_on_commit=False) + async with maker() as session: + yield session, maker + await engine.dispose() + + +def test_load_model_config_rejects_safety_tier_configuration(tmp_path) -> None: + config_path = tmp_path / "pmflow-routing-rules.json" + config_path.write_text( + json.dumps( + { + "version": "v-test", + "updated_at": "2026-03-26T22:00:00+08:00", + "tier_defaults": { + "low": {"provider": "glm", "model": "qwen-plus", "api_env": "DASHSCOPE_API_KEY"}, + "high": {"provider": "openai", "model": "GLM-5", "api_env": "OPENAI_API_KEY"}, + "safety": {"provider": "glm", "model": "qwen-plus", "api_env": "DASHSCOPE_API_KEY"}, + }, + "rules": [ + { + "name": "bad_safety_rule", + "enabled": True, + "priority": 1, + "tier": "safety", + "match_all": True, + } + ], + }, + ensure_ascii=False, + ), + encoding="utf-8", + ) + + with pytest.raises(ValueError, match="low/high"): + load_model_config(refresh=True, path=config_path) + + +@pytest.mark.asyncio +async def test_model_routing_matches_runtime_rule_and_returns_logical_view(db_env, monkeypatch) -> None: + db_session, _maker = db_env + monkeypatch.setenv("OPENCLAW_ROUTING_ENABLED", "true") + + runtime_rule = llm_service._match_openclaw_rule("classify_intent_complex") + route = await ModelRoutingService.get_effective_routing( + db_session, + tenant_id=1, + function_name="classify_intent_complex", + ) + + assert runtime_rule is not None + assert route["function_name"] == "classify_intent_complex" + assert route["tier_alias"] == "high" + assert route["source"] == "default" + assert route["rule_name"] == runtime_rule["name"] + assert route["rule_priority"] == runtime_rule["priority"] + assert route["last_execution"] is None + assert "provider" not in route + assert "model" not in route + + +@pytest.mark.asyncio +async def test_model_routing_override_is_tier_only_and_reads_last_execution(db_env) -> None: + db_session, _maker = db_env + + route = await ModelRoutingService.upsert_override( + db_session, + tenant_id=1, + function_name="signal_planner_analyze", + tier_alias="low", + updated_by=9, + ) + db_session.add( + AICallLog( + tenant_id=1, + user_id=None, + function_name="signal_planner_analyze", + prompt="prompt", + response="response", + tokens_used=12, + cost=0.0001, + status="success_failover", + planned_tier="low", + executed_tier="high", + routing_decision="low_to_high_escalated", + route_source="override", + route_rule_name="engine_high_reasoning_glm5", + routing_config_version="v3", + routing_config_updated_at="2026-03-26T21:30:00+08:00", + ) + ) + await db_session.commit() + + refreshed = await ModelRoutingService.get_effective_routing( + db_session, + tenant_id=1, + function_name="signal_planner_analyze", + ) + row = ( + await db_session.execute( + select(Integration).where( + and_( + Integration.tenant_id == 1, + Integration.type == "model_routing", + ) + ) + ) + ).scalar_one() + mapping = row.config["mappings"]["signal_planner_analyze"] + + assert route["tier_alias"] == "low" + assert refreshed["source"] == "override" + assert refreshed["last_execution"] == { + "planned_tier": "low", + "executed_tier": "high", + "routing_decision": "low_to_high_escalated", + "created_at": refreshed["last_execution"]["created_at"], + } + assert mapping["tier_alias"] == "low" + assert "provider" not in mapping + assert "model" not in mapping + assert "api_env" not in mapping + + +@pytest.mark.asyncio +async def test_model_routing_list_includes_current_business_entry_points(db_env) -> None: + db_session, _maker = db_env + + items = await ModelRoutingService.list_effective_routing(db_session, tenant_id=1) + names = {item["function_name"] for item in items} + + assert "ai_pmo_signal_title_refine" in names + assert "ai_pmo_signal_batch_analysis" in names + assert "ai_service_stream_chat" in names + assert "draft_generator_v1" in names + assert "signal_source_onboarding_plan" in names + assert "signal_source_ai_worker_execution" in names + assert "project_draft_analysis_glm47_fallback" not in names + + +@pytest.mark.asyncio +async def test_log_ai_call_persists_routing_metadata_without_model_columns(db_env, monkeypatch) -> None: + db_session, maker = db_env + bundle = load_model_config(refresh=True) + monkeypatch.setattr(mixin_prompt_profile_runtime, "AsyncSessionLocal", maker) + + await llm_service._log_ai_call( + tenant_id=1, + function_name="signal_planner_analyze", + prompt="planner prompt", + response="planner response", + status="success_failover", + provider="openai", + model="GLM-5", + routing_meta={ + "planned_tier": "low", + "executed_tier": "high", + "routing_decision": "low_to_high_escalated", + "route_source": "default", + "route_rule_name": "engine_high_reasoning_glm5", + "routing_config_version": str(bundle.get("version") or ""), + "routing_config_updated_at": str(bundle.get("updated_at") or ""), + }, + ) + + row = ( + await db_session.execute( + select(AICallLog).where(AICallLog.function_name == "signal_planner_analyze") + ) + ).scalar_one() + + assert row.planned_tier == "low" + assert row.executed_tier == "high" + assert row.routing_decision == "low_to_high_escalated" + assert row.route_source == "default" + assert row.route_rule_name == "engine_high_reasoning_glm5" + assert row.routing_config_version == bundle["version"] + assert row.routing_config_updated_at == bundle["updated_at"] + assert not hasattr(row, "model") + + +@pytest.mark.asyncio +async def test_model_context_bundle_summary_uses_routing_names(db_env, monkeypatch) -> None: + db_session, _maker = db_env + + async def _fake_get_overview(self): + _ = self + return { + "entity_total": 3, + "relation_total": 2, + "evidence_total": 5, + "pending_events": 0, + "latest_sync_run": {"id": 1, "run_type": "manual", "status": "success"}, + } + + async def _fake_list_templates(db, *, tenant_id: int): + _ = (db, tenant_id) + return [ + { + "id": "tpl-low", + "name": "Low Template", + "description": "low", + "updated_at": "2026-03-27T10:00:00+08:00", + } + ] + + async def _fake_contract_sync(db, *, tenant_id: int): + _ = (db, tenant_id) + return {"all_synced": True, "files": []} + + async def _fake_get_effective_routing(db, *, tenant_id: int, function_name: str): + _ = (db, tenant_id, function_name) + return { + "tier_alias": "high" if function_name == "classify_intent_complex" else "low", + "source": "default", + "rule_name": f"{function_name}_rule", + } + + monkeypatch.setattr( + "app.services.model.model_context_bundle.mixin_build.ProjectKnowledgeGraphService.get_overview", + _fake_get_overview, + ) + monkeypatch.setattr( + "app.services.model.model_context_bundle.mixin_build.TaskBriefService.list_templates", + _fake_list_templates, + ) + monkeypatch.setattr( + "app.services.model.model_context_bundle.mixin_build.TaskBriefService.get_contract_sync_status", + _fake_contract_sync, + ) + monkeypatch.setattr( + "app.services.model.model_context_bundle.mixin_build.ModelRoutingService.get_effective_routing", + _fake_get_effective_routing, + ) + + summary = await ModelContextBundleService.build_capability_summary(db_session, tenant_id=1) + + assert "function_routing_template_matrix" in summary + assert "function_model_template_matrix" not in summary + first_row = summary["function_routing_template_matrix"][0] + assert "routing_snapshot" in first_row + assert "model_routing" not in first_row diff --git a/backend/tests/test_signal_planner_contract.py b/backend/tests/test_signal_planner_contract.py index 9a3a1868..366d8ac4 100644 --- a/backend/tests/test_signal_planner_contract.py +++ b/backend/tests/test_signal_planner_contract.py @@ -204,3 +204,94 @@ async def test_signal_planner_analyze_degrades_without_charter(monkeypatch) -> N assert "charter_json" in str(captured["prompt"]) assert "{}" in str(captured["prompt"]) assert captured["planner_result"]["summary"] == "聚焦支付失败重试" + + +@pytest.mark.asyncio +async def test_signal_planner_analyze_does_not_warn_when_openclaw_default_route_is_used(monkeypatch) -> None: + signal = SimpleNamespace( + id=12, + title="继续优化支付失败重试", + summary="提升支付成功率", + content="需要优化支付失败重试策略", + matched_project_id=42, + strategic_analysis={ + "ai_analysis": { + "summary": "支付链路存在失败重试问题", + "requirement_project_scope": "支付优化", + } + }, + ) + warnings: list[tuple[str, dict[str, object]]] = [] + + async def _fake_load_signal(db, *, tenant_id: int, signal_id: int): + _ = (db, tenant_id, signal_id) + return signal + + async def _fake_load_project_title(db, *, tenant_id: int, project_id): + _ = (db, tenant_id, project_id) + return "支付优化" + + async def _fake_load_charter_payload(db, *, tenant_id: int, project_id): + _ = (db, tenant_id, project_id) + return {} + + async def _fake_load_recent_signals(db, *, tenant_id: int, project_id, exclude_signal_id: int): + _ = (db, tenant_id, project_id, exclude_signal_id) + return [] + + async def _fake_load_members(db, *, tenant_id: int, project_id): + _ = (db, tenant_id, project_id) + return [{"user_id": 9, "name": "Alice", "workload_ratio": 42.5}] + + async def _fake_generate_structured_object(prompt: str, **kwargs): + _ = (prompt, kwargs) + return SimpleNamespace( + payload={ + "summary": "聚焦支付失败重试", + "priority": "high", + "assignee_user_id": 9, + "assignee_reason": "技能匹配且负载可接受", + "execution_plan": ["梳理失败场景", "拆解重试策略"], + "risks": ["第三方通道波动"], + "confidence": 0.91, + } + ) + + async def _fake_get_effective_routing(db, *, tenant_id: int, function_name: str): + _ = (db, tenant_id, function_name) + return {"source": "default", "tier_alias": "high", "rule_name": "planner_high_exact"} + + async def _fake_merge_and_save(db, *, signal_id: int, planner_result: dict, tenant_id: int): + _ = (db, signal_id, planner_result, tenant_id) + return signal + + def _fake_warning(event: str, **kwargs): + warnings.append((event, kwargs)) + + class _FakeDB: + async def commit(self): + return None + + async def rollback(self): + return None + + monkeypatch.setattr(SignalPlannerService, "_load_signal", _fake_load_signal) + monkeypatch.setattr(SignalPlannerService, "_load_project_title", _fake_load_project_title) + monkeypatch.setattr(SignalPlannerService, "_load_latest_charter_payload", _fake_load_charter_payload) + monkeypatch.setattr(SignalPlannerService, "_load_recent_signal_summaries", _fake_load_recent_signals) + monkeypatch.setattr(SignalPlannerService, "_load_member_profiles", _fake_load_members) + monkeypatch.setattr("app.services.signal.signal_planner.mixin_analyze.llm_service.generate_structured_object", _fake_generate_structured_object) + monkeypatch.setattr("app.services.signal.signal_planner.mixin_analyze.ModelRoutingService.get_effective_routing", _fake_get_effective_routing) + monkeypatch.setattr("app.services.signal.signal_planner.mixin_analyze.SignalStrategicAnalysisService.merge_and_save", _fake_merge_and_save) + monkeypatch.setattr("app.services.signal.signal_planner.mixin_analyze.logger.warning", _fake_warning) + + await SignalPlannerService._analyze_with_session( + db=_FakeDB(), + signal_id=12, + tenant_id=1, + source_message_id="msg-1", + source_chat_id="chat-1", + dedupe_seed="seed-1", + ) + + assert not any(event == "signal_planner_routing_override_missing" for event, _ in warnings) diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index 0eab75b2..27ac4394 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -65,19 +65,12 @@ services: CORS_ORIGINS: ${CORS_ORIGINS:?missing_CORS_ORIGINS} LLM_PROVIDER: ${LLM_PROVIDER:?missing_LLM_PROVIDER} LLM_FALLBACK_PROVIDER: ${LLM_FALLBACK_PROVIDER:?missing_LLM_FALLBACK_PROVIDER} - OPENAI_BASE_URL: ${OPENAI_BASE_URL:?missing_OPENAI_BASE_URL} OPENCLAW_ROUTING_ENABLED: ${OPENCLAW_ROUTING_ENABLED:?missing_OPENCLAW_ROUTING_ENABLED} OPENCLAW_ROUTING_RULES_PATH: ${OPENCLAW_ROUTING_RULES_PATH:?missing_OPENCLAW_ROUTING_RULES_PATH} OPENCLAW_AGENTS_DEFAULT_TIMEOUT_SECONDS: ${OPENCLAW_AGENTS_DEFAULT_TIMEOUT_SECONDS:?missing_OPENCLAW_AGENTS_DEFAULT_TIMEOUT_SECONDS} LLM_MIXED_ROUTING_ENABLED: ${LLM_MIXED_ROUTING_ENABLED:?missing_LLM_MIXED_ROUTING_ENABLED} LLM_ROUTING_DEFAULT_TIER: ${LLM_ROUTING_DEFAULT_TIER:?missing_LLM_ROUTING_DEFAULT_TIER} LLM_LOW_TO_HIGH_ESCALATION_ENABLED: ${LLM_LOW_TO_HIGH_ESCALATION_ENABLED:?missing_LLM_LOW_TO_HIGH_ESCALATION_ENABLED} - LLM_ROUTING_LOW_PROVIDER: ${LLM_ROUTING_LOW_PROVIDER:?missing_LLM_ROUTING_LOW_PROVIDER} - LLM_ROUTING_LOW_MODEL: ${LLM_ROUTING_LOW_MODEL:?missing_LLM_ROUTING_LOW_MODEL} - LLM_ROUTING_HIGH_PROVIDER: ${LLM_ROUTING_HIGH_PROVIDER:?missing_LLM_ROUTING_HIGH_PROVIDER} - LLM_ROUTING_HIGH_MODEL: ${LLM_ROUTING_HIGH_MODEL:?missing_LLM_ROUTING_HIGH_MODEL} - LLM_ROUTING_SAFETY_PROVIDER: ${LLM_ROUTING_SAFETY_PROVIDER:?missing_LLM_ROUTING_SAFETY_PROVIDER} - LLM_ROUTING_SAFETY_MODEL: ${LLM_ROUTING_SAFETY_MODEL:?missing_LLM_ROUTING_SAFETY_MODEL} LLM_DIRECT_EMERGENCY_ENABLED: ${LLM_DIRECT_EMERGENCY_ENABLED:?missing_LLM_DIRECT_EMERGENCY_ENABLED} LLM_REQUEST_TIMEOUT: ${LLM_REQUEST_TIMEOUT:?missing_LLM_REQUEST_TIMEOUT} AI_PMO_INTENT_LLM_ENABLED: ${AI_PMO_INTENT_LLM_ENABLED:-true} @@ -97,15 +90,12 @@ services: LLM_INTENT_EMPTY_RETRY_BUDGET: ${LLM_INTENT_EMPTY_RETRY_BUDGET:-2} LLM_INTENT_STRUCTURED_REPAIR_ENABLED: ${LLM_INTENT_STRUCTURED_REPAIR_ENABLED:-true} AUTO_CREATE_TABLES: "false" - GLM_API_KEY: ${GLM_API_KEY:?missing_GLM_API_KEY} - GLM_BASE_URL: ${GLM_BASE_URL:?missing_GLM_BASE_URL} - GLM_MODEL: ${GLM_MODEL:?missing_GLM_MODEL} + DASHSCOPE_API_KEY: ${DASHSCOPE_API_KEY:?missing_DASHSCOPE_API_KEY} OPENAI_API_KEY: ${OPENAI_API_KEY:?missing_OPENAI_API_KEY} - OPENAI_MODEL: ${OPENAI_MODEL:?missing_OPENAI_MODEL} DEEPSEEK_API_KEY: ${DEEPSEEK_API_KEY} OLLAMA_HOST: ${OLLAMA_HOST:?missing_OLLAMA_HOST} - OLLAMA_BASE_URL: ${OLLAMA_BASE_URL:?missing_OLLAMA_BASE_URL} - OLLAMA_MODEL: ${OLLAMA_MODEL:?missing_OLLAMA_MODEL} + OLLAMA_BASE_URL: ${OLLAMA_BASE_URL:-http://host.docker.internal:11434} + OLLAMA_MODEL: ${OLLAMA_MODEL:-llama3:latest} PYTHONUNBUFFERED: "1" HTTP_PROXY: "" HTTPS_PROXY: "" @@ -135,7 +125,6 @@ services: ] volumes: - ./backend:/app/backend - - ./.openclaw:/app/.openclaw:ro - ./logs:/app/logs ports: - "${BACKEND_PORT:?missing_BACKEND_PORT}:8000" diff --git a/docker-compose.yml b/docker-compose.yml index 6b80aba2..5f7721ae 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -65,19 +65,12 @@ services: CORS_ORIGINS: ${CORS_ORIGINS:?missing_CORS_ORIGINS} LLM_PROVIDER: ${LLM_PROVIDER:?missing_LLM_PROVIDER} LLM_FALLBACK_PROVIDER: ${LLM_FALLBACK_PROVIDER:?missing_LLM_FALLBACK_PROVIDER} - OPENAI_BASE_URL: ${OPENAI_BASE_URL:?missing_OPENAI_BASE_URL} OPENCLAW_ROUTING_ENABLED: ${OPENCLAW_ROUTING_ENABLED:?missing_OPENCLAW_ROUTING_ENABLED} OPENCLAW_ROUTING_RULES_PATH: ${OPENCLAW_ROUTING_RULES_PATH:?missing_OPENCLAW_ROUTING_RULES_PATH} OPENCLAW_AGENTS_DEFAULT_TIMEOUT_SECONDS: ${OPENCLAW_AGENTS_DEFAULT_TIMEOUT_SECONDS:?missing_OPENCLAW_AGENTS_DEFAULT_TIMEOUT_SECONDS} LLM_MIXED_ROUTING_ENABLED: ${LLM_MIXED_ROUTING_ENABLED:?missing_LLM_MIXED_ROUTING_ENABLED} LLM_ROUTING_DEFAULT_TIER: ${LLM_ROUTING_DEFAULT_TIER:?missing_LLM_ROUTING_DEFAULT_TIER} LLM_LOW_TO_HIGH_ESCALATION_ENABLED: ${LLM_LOW_TO_HIGH_ESCALATION_ENABLED:?missing_LLM_LOW_TO_HIGH_ESCALATION_ENABLED} - LLM_ROUTING_LOW_PROVIDER: ${LLM_ROUTING_LOW_PROVIDER:?missing_LLM_ROUTING_LOW_PROVIDER} - LLM_ROUTING_LOW_MODEL: ${LLM_ROUTING_LOW_MODEL:?missing_LLM_ROUTING_LOW_MODEL} - LLM_ROUTING_HIGH_PROVIDER: ${LLM_ROUTING_HIGH_PROVIDER:?missing_LLM_ROUTING_HIGH_PROVIDER} - LLM_ROUTING_HIGH_MODEL: ${LLM_ROUTING_HIGH_MODEL:?missing_LLM_ROUTING_HIGH_MODEL} - LLM_ROUTING_SAFETY_PROVIDER: ${LLM_ROUTING_SAFETY_PROVIDER:?missing_LLM_ROUTING_SAFETY_PROVIDER} - LLM_ROUTING_SAFETY_MODEL: ${LLM_ROUTING_SAFETY_MODEL:?missing_LLM_ROUTING_SAFETY_MODEL} LLM_DIRECT_EMERGENCY_ENABLED: ${LLM_DIRECT_EMERGENCY_ENABLED:?missing_LLM_DIRECT_EMERGENCY_ENABLED} LLM_REQUEST_TIMEOUT: ${LLM_REQUEST_TIMEOUT:?missing_LLM_REQUEST_TIMEOUT} AI_PMO_INTENT_LLM_ENABLED: ${AI_PMO_INTENT_LLM_ENABLED:-true} @@ -97,15 +90,12 @@ services: LLM_INTENT_EMPTY_RETRY_BUDGET: ${LLM_INTENT_EMPTY_RETRY_BUDGET:-2} LLM_INTENT_STRUCTURED_REPAIR_ENABLED: ${LLM_INTENT_STRUCTURED_REPAIR_ENABLED:-true} AUTO_CREATE_TABLES: "false" - GLM_API_KEY: ${GLM_API_KEY:?missing_GLM_API_KEY} - GLM_BASE_URL: ${GLM_BASE_URL:?missing_GLM_BASE_URL} - GLM_MODEL: ${GLM_MODEL:?missing_GLM_MODEL} + DASHSCOPE_API_KEY: ${DASHSCOPE_API_KEY:?missing_DASHSCOPE_API_KEY} OPENAI_API_KEY: ${OPENAI_API_KEY:?missing_OPENAI_API_KEY} - OPENAI_MODEL: ${OPENAI_MODEL:?missing_OPENAI_MODEL} DEEPSEEK_API_KEY: ${DEEPSEEK_API_KEY} OLLAMA_HOST: ${OLLAMA_HOST:?missing_OLLAMA_HOST} - OLLAMA_BASE_URL: ${OLLAMA_BASE_URL:?missing_OLLAMA_BASE_URL} - OLLAMA_MODEL: ${OLLAMA_MODEL:?missing_OLLAMA_MODEL} + OLLAMA_BASE_URL: ${OLLAMA_BASE_URL:-http://host.docker.internal:11434} + OLLAMA_MODEL: ${OLLAMA_MODEL:-llama3:latest} HTTP_PROXY: "" HTTPS_PROXY: "" ALL_PROXY: "" @@ -117,7 +107,7 @@ services: ports: - "${BACKEND_PORT:?missing_BACKEND_PORT}:8000" volumes: - - ./.openclaw:/app/.openclaw:ro + - ./backend/.openclaw:/app/backend/.openclaw:ro - ./logs:/app/logs healthcheck: test: ["CMD-SHELL", "curl -fsS http://localhost:8000/healthz >/dev/null || exit 1"] diff --git a/docs/FEISHU_AI_FLOW_CURRENT.md b/docs/FEISHU_AI_FLOW_CURRENT.md index 5b9722bb..d6f2ed15 100644 --- a/docs/FEISHU_AI_FLOW_CURRENT.md +++ b/docs/FEISHU_AI_FLOW_CURRENT.md @@ -221,8 +221,8 @@ sequenceDiagram | --- | --- | --- | --- | | 飞书 @ 人设回复 | `feishu_humanized_reply` | `zai/glm-4-flash` | 把执行结果转成短中文回复,控制语气与可执行建议 | | RAG 意图判定 | `rag_intent_classification` | `zai/glm-4-flash` | 把用户输入判定为 question / mixed / statement | -| RAG 项目语义检索 | `rag_db_project_semantic_retrieval` | `minimax/MiniMax-M2.5` | 从项目索引中做语义命中与回答草案 | -| RAG 最终答案生成(normal/deep) | `adaptive_rag_answer_normal` / `adaptive_rag_answer_deep` | `minimax/MiniMax-M2.5` | 生成最终回答(结论+证据+不确定项) | +| RAG 项目语义检索 | `rag_db_project_semantic_retrieval` | `openai-compatible/GLM-5` | 从项目索引中做语义命中与回答草案 | +| RAG 最终答案生成(normal/deep) | `adaptive_rag_answer_normal` / `adaptive_rag_answer_deep` | `openai-compatible/GLM-5` | 生成最终回答(结论+证据+不确定项) | #### B. 主链路中的“信号源接入子流程”(命中时) @@ -240,7 +240,7 @@ sequenceDiagram | --- | --- | --- | --- | | 层级判断 | `feishu_level_classification` | `zai/glm-4-flash` | 判定 project-level 还是 signal-level | | 信号抽取 | `feishu_signal_extraction` | `zai/glm-4-flash` | 抽取标题、类型、优先级、置信度 | -| 项目预填充 | `feishu_project_prefill` | `minimax/MiniMax-M2.5` | 生成建议项目名/描述/owner/timeline | -| 任务编排 | `feishu_task_orchestration` | `minimax/MiniMax-M2.5` | 生成任务拆解与分配建议 | -| 项目信号分类 | `feishu_project_signal_classification` | `minimax/MiniMax-M2.5` | 对新项目/已有项目/非需求做高层分类 | -| 新项目摘要 | `feishu_new_project_brief` | `minimax/MiniMax-M2.5` | 生成项目简报(brief) | +| 项目预填充 | `feishu_project_prefill` | `openai-compatible/GLM-5` | 生成建议项目名/描述/owner/timeline | +| 任务编排 | `feishu_task_orchestration` | `openai-compatible/GLM-5` | 生成任务拆解与分配建议 | +| 项目信号分类 | `feishu_project_signal_classification` | `openai-compatible/GLM-5` | 对新项目/已有项目/非需求做高层分类 | +| 新项目摘要 | `feishu_new_project_brief` | `openai-compatible/GLM-5` | 生成项目简报(brief) | diff --git a/docs/GLM5_SIGNAL_PLANNER_IMPLEMENTATION_PLAN_V4.md b/docs/GLM5_SIGNAL_PLANNER_IMPLEMENTATION_PLAN_V4.md index 4abc3ce1..18618163 100644 --- a/docs/GLM5_SIGNAL_PLANNER_IMPLEMENTATION_PLAN_V4.md +++ b/docs/GLM5_SIGNAL_PLANNER_IMPLEMENTATION_PLAN_V4.md @@ -108,7 +108,7 @@ - [x] `signal.create` 成功提交后 `create_task(SignalPlannerService.analyze(...))` - [x] 在 `backend/app/config/ai_prompts.py` 增加 `PROMPT_SIGNAL_PLANNER` - [x] 在 LLM 结构化 schema 注册 `signal_planner_analyze` -- [x] 更新 `/.openclaw/pmflow-routing-rules.json` 添加 high tier 路由 +- [x] 更新 `backend/.openclaw/pmflow-routing-rules.json` 添加 high tier 路由 ### 完成标志 - [x] 触发点仅在 commit 后 @@ -133,7 +133,7 @@ ### 完成标志 - [x] 通过接口切换模型后,Planner 无需改代码即可生效 -- [x] 业务代码中不存在写死的 `signal_planner_analyze -> MiniMax-M2.5` 绑定 +- [x] 业务代码中不存在写死的 `signal_planner_analyze -> GLM-5` 绑定 --- @@ -243,7 +243,7 @@ - 功能验收样本: - `signal_id=273`:`elapsed_seconds=30`,`planner_status=completed`,`ai_planner_state=NOT_NULL` - 重复消息幂等:`source_message_id=dedupe-signal-1773313023` 在 `ingest_events/work_items` 计数均为 `1` - - 模型路由覆盖生效日志:`函数覆盖路由命中:function=signal_planner_analyze, provider=glm, model=GLM-4.7` + - 模型路由覆盖生效日志:`函数覆盖路由命中:function=signal_planner_analyze, provider=glm, model=qwen-plus` - 截图产物: - `docs/task_briefs/sidebar_new_entries_20260312.png` - `docs/task_briefs/model_routing_override_effective_20260312.png` diff --git a/docs/PMFLOW_THREE_ENGINE_UNIFIED_PLAN.md b/docs/PMFLOW_THREE_ENGINE_UNIFIED_PLAN.md index 157694b1..848ae820 100644 --- a/docs/PMFLOW_THREE_ENGINE_UNIFIED_PLAN.md +++ b/docs/PMFLOW_THREE_ENGINE_UNIFIED_PLAN.md @@ -27,7 +27,7 @@ ## 4. 三引擎职责分配(推荐) - Engine-L:`zai/glm-4-flash` - 场景:执行态分类、信号抽取、简短回执、轻量意图识别。 -- Engine-H:`minimax/MiniMax-M2.5` +- Engine-H:`openai-compatible/GLM-5` - 场景:复杂问答、多约束规划、任务编排、项目方案生成。 - Engine-S:`deepseek/deepseek-chat`(或你可用的稳定备份模型) - 场景:H 不可用/超时/限流后的稳定兜底。 @@ -112,7 +112,7 @@ LLM_LOW_TO_HIGH_ESCALATION_ENABLED=true ### 9.2 OpenClaw Agent(main) - Primary model:`zai/glm-4-flash`(Engine-L) -- Fallbacks:`minimax/MiniMax-M2.5,deepseek/deepseek-chat` +- Fallbacks:`openai-compatible/GLM-5,deepseek/deepseek-chat` > 如果你要严格“低->高->安全”,可在 OpenClaw 路由中按函数直接指定模型,fallback 只做同层灾备。 diff --git a/docs/architecture/MASTER_CONTRACT.md b/docs/architecture/MASTER_CONTRACT.md index 4aa776b7..f667dd7b 100644 --- a/docs/architecture/MASTER_CONTRACT.md +++ b/docs/architecture/MASTER_CONTRACT.md @@ -7,7 +7,7 @@ ## 2. Cross-Stack Contract Ownership - Backend source: `backend/app/api/`, `backend/app/schemas/` - Frontend source: `frontend/src/api/`, `frontend/src/types/` -- Routing source: `.openclaw/pmflow-routing-rules.json` +- Routing source: `backend/.openclaw/pmflow-routing-rules.json` - Architecture docs source: `docs/architecture/` ## 3. Sync Rules @@ -15,7 +15,7 @@ - `docs/architecture/API_CONTRACTS.md` - frontend TypeScript interfaces (if impacted) - Any routing/function-name change must update: - - `.openclaw/pmflow-routing-rules.json` + - `backend/.openclaw/pmflow-routing-rules.json` - `docs/architecture/DATAFLOW.md` - Any new module/page/API must update: - `CLAUDE.md` structure/reference sections diff --git a/frontend/src/api/knowledgeGraph.ts b/frontend/src/api/knowledgeGraph.ts index 644ef3b2..f6ae48a0 100644 --- a/frontend/src/api/knowledgeGraph.ts +++ b/frontend/src/api/knowledgeGraph.ts @@ -46,17 +46,15 @@ export interface ContractSyncCoverage { }>; } -export interface FunctionModelTemplateBinding { +export interface FunctionRoutingTemplateBinding { function_name: string; tier: "low" | "high" | string; context_sections: string[]; template_ids: string[]; - model_routing: { - provider: string; - model: string; - tier: string; + routing_snapshot: { + tier_alias: string; source: string; - api_env: string; + rule_name: string; }; } @@ -65,7 +63,7 @@ export interface KnowledgeGraphCapabilitySummary { knowledge_graph: KnowledgeGraphOverviewSnapshot; task_templates: TaskTemplateCoverage; contract_sync: ContractSyncCoverage; - function_model_template_matrix: FunctionModelTemplateBinding[]; + function_routing_template_matrix: FunctionRoutingTemplateBinding[]; coverage: { template_ready: boolean; contract_ready: boolean; @@ -79,18 +77,15 @@ export interface ContextConsumptionFunctionResult { tier: string; template_ids: string[]; routing_snapshot: { - provider: string; - model: string; - tier: string; + tier_alias: string; source: string; + rule_name: string; }; canary: { called: boolean; status: string; error?: string | null; error_code?: string | null; - provider?: string | null; - model?: string | null; }; checks: { marker: boolean; diff --git a/frontend/src/api/modelRouting.ts b/frontend/src/api/modelRouting.ts index 1a938448..bff4b346 100644 --- a/frontend/src/api/modelRouting.ts +++ b/frontend/src/api/modelRouting.ts @@ -9,24 +9,24 @@ function unwrap(raw: ApiEnvelope | T): T { return raw as T; } +export interface ModelRoutingLastExecution { + planned_tier: "low" | "high"; + executed_tier: "low" | "high"; + routing_decision: string; + created_at?: string | null; +} + export interface ModelRoutingItem { function_name: string; - provider: string; - model: string; - api_env: string; - tier: "low" | "high" | "safety"; - base_url?: string; + tier_alias: "low" | "high"; source: "default" | "override"; - updated_at?: string; - updated_by?: number | null; + rule_name?: string | null; + rule_priority?: number | null; + last_execution?: ModelRoutingLastExecution | null; } export interface ModelRoutingUpdateRequest { - provider: string; - model: string; - api_env: string; - tier: "low" | "high" | "safety"; - base_url?: string; + tier_alias: "low" | "high"; } export const modelRoutingApi = { diff --git a/frontend/src/pages/ai-config/constants.ts b/frontend/src/pages/ai-config/constants.ts index 11caa796..e08aa4a8 100644 --- a/frontend/src/pages/ai-config/constants.ts +++ b/frontend/src/pages/ai-config/constants.ts @@ -52,7 +52,7 @@ export const GOVERNANCE_TOOLS = [ { id: "knowledge-graph-board", name: "知识图谱上下文看板", - description: "查看 KG 汇总与双模型上下文消费验证", + description: "查看 KG 汇总与 low/high 上下文消费验证", to: "/ai-config/knowledge-graph-board", icon: Boxes, }, diff --git a/frontend/src/pages/ai-steward/constants.ts b/frontend/src/pages/ai-steward/constants.ts index 299c6d09..8162e666 100644 --- a/frontend/src/pages/ai-steward/constants.ts +++ b/frontend/src/pages/ai-steward/constants.ts @@ -23,7 +23,7 @@ export const ENGINE_PROVIDER_FALLBACK_PRESETS: EngineProviderPresets = { base_url: "https://dashscope.aliyuncs.com/compatible-mode/v1", }, openai: { - model: "MiniMax-M2.5", + model: "GLM-5", base_url: "https://api.edgefn.net/v1", }, ollama: { diff --git a/frontend/src/pages/ai-steward/sections/EngineTab.tsx b/frontend/src/pages/ai-steward/sections/EngineTab.tsx index d14ed3e9..0aa259fa 100644 --- a/frontend/src/pages/ai-steward/sections/EngineTab.tsx +++ b/frontend/src/pages/ai-steward/sections/EngineTab.tsx @@ -229,7 +229,7 @@ export default function EngineTab({ setEngineForm((prev) => ({ ...prev, model: e.target.value }))} - placeholder="如:deepseek-chat / qwen-plus / MiniMax-M2.5 / llama3:latest" + placeholder="如:deepseek-chat / qwen-plus / GLM-5 / llama3:latest" />
diff --git a/frontend/src/pages/knowledge-graph-context-board/KnowledgeGraphContextBoardPage.tsx b/frontend/src/pages/knowledge-graph-context-board/KnowledgeGraphContextBoardPage.tsx index 9d63a66d..a7288d81 100644 --- a/frontend/src/pages/knowledge-graph-context-board/KnowledgeGraphContextBoardPage.tsx +++ b/frontend/src/pages/knowledge-graph-context-board/KnowledgeGraphContextBoardPage.tsx @@ -49,15 +49,15 @@ export default function KnowledgeGraphContextBoardPage() { const errorText = summaryQuery.error instanceof Error ? summaryQuery.error.message : "请检查后端服务。"; const matrixRows = useMemo( - () => summary?.function_model_template_matrix || [], + () => summary?.function_routing_template_matrix || [], [summary] ); return (
} tone="blue" meta={ @@ -75,16 +75,16 @@ export default function KnowledgeGraphContextBoardPage() { /> @@ -107,7 +107,7 @@ export default function KnowledgeGraphContextBoardPage() { disabled={verifyMutation.isPending} > - {verifyMutation.isPending ? "验证中..." : "运行双模型验证"} + {verifyMutation.isPending ? "验证中..." : "运行 low/high 验证"} ) : ( 仅管理员可执行验证操作。 @@ -148,7 +148,7 @@ export default function KnowledgeGraphContextBoardPage() { 任务模板覆盖 - 双模型链路要求模板的覆盖与缺口。 + low/high 链路要求模板的覆盖与缺口。
@@ -200,13 +200,13 @@ export default function KnowledgeGraphContextBoardPage() { - - - - 函数-模型-模板矩阵 - - 固定四条函数链路的路由与模板绑定关系。 - + + + + 函数-路由-模板矩阵 + + 固定四条函数链路的路由与模板绑定关系。 +
@@ -214,20 +214,25 @@ export default function KnowledgeGraphContextBoardPage() { 函数 层级 - 模型 + 当前路由 模板 来源 {matrixRows.map((row) => ( - - {row.function_name} - {row.tier} - {`${row.model_routing.provider}/${row.model_routing.model}`} - {row.template_ids.join(", ") || "-"} - {row.model_routing.source || "-"} - + + {row.function_name} + {row.tier} + +
{row.routing_snapshot.tier_alias || "-"}
+
+ {row.routing_snapshot.rule_name || "来自配置真源"} +
+
+ {row.template_ids.join(", ") || "-"} + {row.routing_snapshot.source || "-"} +
))} {matrixRows.length === 0 ? ( @@ -244,8 +249,8 @@ export default function KnowledgeGraphContextBoardPage() { - 双模型上下文消费验证 - 通过 canary + ai_call_logs 标记校验模型是否吃到三类上下文。 + low/high 上下文消费验证 + 通过 canary + ai_call_logs 标记校验当前 tier 是否吃到三类上下文。
diff --git a/frontend/src/pages/model-routing-config/ModelRoutingConfigPage.tsx b/frontend/src/pages/model-routing-config/ModelRoutingConfigPage.tsx index 2a2a0110..f2e30b0a 100644 --- a/frontend/src/pages/model-routing-config/ModelRoutingConfigPage.tsx +++ b/frontend/src/pages/model-routing-config/ModelRoutingConfigPage.tsx @@ -9,9 +9,9 @@ import { Button } from "@/components/ui/button"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; import { Input } from "@/components/ui/input"; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"; +import { formatBeijingDateTime } from "@/lib/datetime"; -const PROVIDER_OPTIONS = ["openai", "glm", "deepseek", "ollama"] as const; -const TIER_OPTIONS = ["high", "low", "safety"] as const; +const TIER_OPTIONS = ["low", "high"] as const; const FUNCTION_USAGE_HINTS: Record = { adaptive_rag_answer_deep: "复杂问题的深度回答阶段:会做更细的推理和更完整的结论组织。", adaptive_rag_answer_normal: "常规问答阶段:基于已检索到的信息快速生成可读回复。", @@ -24,17 +24,25 @@ const FUNCTION_USAGE_HINTS: Record = { }; function getFunctionUsageHint(functionName: string): string { - return FUNCTION_USAGE_HINTS[functionName] || "通用路由节点:用于该函数调用阶段的模型选择。"; + return FUNCTION_USAGE_HINTS[functionName] || "通用路由节点:用于该函数调用阶段的 low/high 选择。"; +} + +function describeLastExecution(item: ModelRoutingItem): string { + const last = item.last_execution; + if (!last) return "暂无执行记录"; + return `${last.planned_tier} -> ${last.executed_tier}`; +} + +function describeUpgrade(item: ModelRoutingItem): string { + const last = item.last_execution; + if (!last) return "-"; + return last.planned_tier !== last.executed_tier ? "是" : "否"; } export default function ModelRoutingConfigPage() { const queryClient = useQueryClient(); const [selectedFunction, setSelectedFunction] = useState("signal_planner_analyze"); - const [provider, setProvider] = useState<(typeof PROVIDER_OPTIONS)[number]>("openai"); - const [model, setModel] = useState("GLM-5"); - const [apiEnv, setApiEnv] = useState("OPENAI_API_KEY"); - const [tier, setTier] = useState<(typeof TIER_OPTIONS)[number]>("high"); - const [baseURL, setBaseURL] = useState(""); + const [tierAlias, setTierAlias] = useState<(typeof TIER_OPTIONS)[number]>("high"); const routingQuery = useQuery({ queryKey: ["model-routing-functions"], @@ -53,27 +61,16 @@ export default function ModelRoutingConfigPage() { if (!current && fallback.function_name) { setSelectedFunction(fallback.function_name); } - const nextProvider = PROVIDER_OPTIONS.includes(fallback.provider as (typeof PROVIDER_OPTIONS)[number]) - ? (fallback.provider as (typeof PROVIDER_OPTIONS)[number]) - : "openai"; - const nextTier = TIER_OPTIONS.includes(fallback.tier as (typeof TIER_OPTIONS)[number]) - ? (fallback.tier as (typeof TIER_OPTIONS)[number]) + const nextTier = TIER_OPTIONS.includes(fallback.tier_alias as (typeof TIER_OPTIONS)[number]) + ? (fallback.tier_alias as (typeof TIER_OPTIONS)[number]) : "high"; - setProvider(nextProvider); - setTier(nextTier); - setModel(fallback.model || ""); - setApiEnv(fallback.api_env || ""); - setBaseURL(fallback.base_url || ""); + setTierAlias(nextTier); }, [current, items]); const updateMutation = useMutation({ mutationFn: () => modelRoutingApi.updateFunction(selectedFunction, { - provider, - model, - api_env: apiEnv, - tier, - base_url: baseURL || undefined, + tier_alias: tierAlias, }), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ["model-routing-functions"] }); @@ -86,7 +83,7 @@ export default function ModelRoutingConfigPage() {
} tone="blue" meta={{routingQuery.isError ? "读取失败" : `已加载 ${items.length} 条路由`}} @@ -94,16 +91,16 @@ export default function ModelRoutingConfigPage() { high 升级。", ]} outputs={[ - "函数级模型路由覆盖项(override)", - "可快速回滚到更稳定的模型组合", + "函数级 tier override", + "命中规则与最近执行结果", ]} /> @@ -111,7 +108,7 @@ export default function ModelRoutingConfigPage() { 编辑器 - 选择函数并更新路由参数。 + 选择函数并更新逻辑 tier。

{getFunctionUsageHint(selectedFunction)}

-
- - + +
+ 真实 provider / model 只存在于配置真源,不在调用视图直接展示。
- setModel(e.target.value)} placeholder="模型名" /> - setApiEnv(e.target.value)} placeholder="API Key 环境变量名" /> - setBaseURL(e.target.value)} placeholder="Base URL(可选)" />
- {item.provider} - {item.model} - {item.tier} + {item.tier_alias} {item.source} + +
{item.rule_name || "来自配置真源"}
+
+ {typeof item.rule_priority === "number" ? `priority ${item.rule_priority}` : "tier default"} +
+
+ +
{describeLastExecution(item)}
+
+ {item.last_execution?.created_at ? formatBeijingDateTime(item.last_execution.created_at) : "-"} +
+
+ {describeUpgrade(item)}