From 0e87cec76d09bed9d0ca9242b89204bd16072166 Mon Sep 17 00:00:00 2001
From: Yuuuu <1569752928@qq.com>
Date: Fri, 27 Mar 2026 11:34:52 +0800
Subject: [PATCH] Centralize PMFlow workflow semantics and routing
---
.env.example | 9 +-
.github/workflows/feishu-replay-gate.yml | 3 -
.openclaw/pmflow-routing-rules.json | 115 ----
.openclaw/workspace-state.json | 2 +-
.tmp/feishu_case2_fail.html | 26 -
CLAUDE.md | 35 +-
backend/.openclaw/pmflow-routing-rules.json | 48 +-
backend/Dockerfile | 1 -
...20260326_add_ai_call_log_routing_fields.py | 68 +++
.../20260326_create_feedback_center_tables.py | 78 +--
...0327_cleanup_model_routing_legacy_names.py | 150 +++++
backend/app/api/llm_providers.py | 35 +-
backend/app/api/model_routing.py | 14 +-
backend/app/config/ai_prompt_registry.py | 2 +-
.../app/config/ai_prompts_catalog/part_01.py | 1 +
.../app/config/ai_prompts_catalog/part_02.py | 11 +
backend/app/core/startup/model_probe.py | 18 +-
backend/app/models/ai_call_log.py | 7 +
.../ai_pmo_kernel_ingress_flow/mixin_entry.py | 18 +-
.../mixin_analysis.py | 2 +-
.../mixin_helpers.py | 2 +-
.../services/ai_pmo/draft_confirm_handler.py | 143 ++++-
.../ai_service_core/mixin_runtime_core.py | 1 -
.../app/services/ai_service_core/shared.py | 6 +-
.../mixin_creation_normalize_persist.py | 2 +
.../mixin_creation_pipeline.py | 3 +-
.../mixin_draft_generation.py | 8 +-
.../ai_steward_service/mixin_json_parsing.py | 46 --
.../app/services/ai_steward_service/shared.py | 8 -
.../mixin_receive_capture_flow.py | 2 +-
.../mixin_receive_routing.py | 36 +-
.../mixin_reply_llm_core.py | 64 +++
.../services/llm_service/mixin_chat_openai.py | 18 +-
.../llm_service/mixin_chat_transport.py | 8 +-
.../mixin_engine_resolution_core.py | 69 ++-
.../llm_service/mixin_generate_db_chat.py | 25 +-
.../mixin_generate_db_structured.py | 8 +-
.../llm_service/mixin_guardrails_prompt.py | 14 +-
.../llm_service/mixin_openclaw_routing.py | 78 +--
.../mixin_prompt_profile_runtime.py | 16 +-
.../llm_service/mixin_structured_parse.py | 2 +-
.../mixin_tier_engine_selection.py | 108 +---
.../llm_service/mixin_tier_escalation.py | 159 +++---
.../services/llm_service/shared_part_01.py | 8 +-
.../services/llm_service/shared_part_02.py | 83 +++
backend/app/services/model/model_config.py | 113 +++-
.../model/model_context_bundle/mixin_build.py | 10 +-
.../model_context_bundle/mixin_verify.py | 11 +-
.../services/model/model_routing/service.py | 256 +++++----
.../mixin_guardrails.py | 4 +-
.../signal/draft_generator_service.py | 539 ++++++++++++++++++
.../signal/signal_planner/mixin_analyze.py | 8 +-
backend/tests/test_ai_pmo_ingress_mainline.py | 65 ++-
backend/tests/test_ai_service_core_runtime.py | 67 ++-
...t_ai_steward_project_from_text_contract.py | 18 +
backend/tests/test_draft_confirm_handler.py | 161 ++++++
backend/tests/test_draft_generator_service.py | 104 ++++
.../tests/test_feedback_center_migration.py | 100 ++++
...t_feishu_receive_orchestration_contract.py | 13 +
backend/tests/test_model_routing_migration.py | 53 ++
backend/tests/test_model_routing_service.py | 286 ++++++++++
backend/tests/test_signal_planner_contract.py | 91 +++
docker-compose.dev.yml | 17 +-
docker-compose.yml | 18 +-
docs/FEISHU_AI_FLOW_CURRENT.md | 12 +-
...5_SIGNAL_PLANNER_IMPLEMENTATION_PLAN_V4.md | 6 +-
docs/PMFLOW_THREE_ENGINE_UNIFIED_PLAN.md | 4 +-
docs/architecture/MASTER_CONTRACT.md | 4 +-
frontend/src/api/knowledgeGraph.ts | 19 +-
frontend/src/api/modelRouting.ts | 24 +-
frontend/src/pages/ai-config/constants.ts | 2 +-
frontend/src/pages/ai-steward/constants.ts | 2 +-
.../pages/ai-steward/sections/EngineTab.tsx | 2 +-
.../KnowledgeGraphContextBoardPage.tsx | 57 +-
.../ModelRoutingConfigPage.tsx | 124 ++--
scripts/model_probe.py | 65 ++-
scripts/openclaw_routing_lint.py | 32 +-
tmp_short_confirm_redesign_snapshot.html | 100 ----
tmp_short_confirm_redesign_snapshot.json | 87 ---
79 files changed, 2820 insertions(+), 1214 deletions(-)
delete mode 100644 .openclaw/pmflow-routing-rules.json
delete mode 100644 .tmp/feishu_case2_fail.html
create mode 100644 backend/alembic/versions/20260326_add_ai_call_log_routing_fields.py
create mode 100644 backend/alembic/versions/20260327_cleanup_model_routing_legacy_names.py
create mode 100644 backend/tests/test_feedback_center_migration.py
create mode 100644 backend/tests/test_model_routing_migration.py
create mode 100644 backend/tests/test_model_routing_service.py
delete mode 100644 tmp_short_confirm_redesign_snapshot.html
delete mode 100644 tmp_short_confirm_redesign_snapshot.json
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)}