Centralize PMFlow workflow semantics and routing

This commit is contained in:
Yuuuu 2026-03-27 11:34:52 +08:00
parent d4691f1276
commit 0e87cec76d
79 changed files with 2820 additions and 1214 deletions

View File

@ -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

View File

@ -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

View File

@ -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
}
]
}

View File

@ -1,4 +1,4 @@
{
"version": 1,
"onboardingCompletedAt": "2026-03-04T07:50:38.457Z"
"onboardingCompletedAt": "2026-03-26T16:00:06.817Z"
}

View File

@ -1,26 +0,0 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<title>Feishu 验收失败样本</title>
<style>
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; background:#f5f7fb; padding:24px; }
.card { background:#fff; border:1px solid #dfe4ea; border-radius:12px; padding:16px; max-width:900px; margin:0 auto; }
h1 { font-size:20px; margin:0 0 12px; }
.meta { color:#556; font-size:13px; margin-bottom:12px; }
.bubble { border-radius:10px; padding:12px 14px; margin:10px 0; white-space:pre-wrap; line-height:1.5; }
.user { background:#e9f2ff; border:1px solid #c8dcff; }
.bot { background:#f8f9fb; border:1px solid #e4e8ef; }
.fail { color:#b42318; font-weight:600; }
</style>
</head>
<body>
<div class="card">
<h1>真实飞书验收 - 失败证据(Case 2)</h1>
<div class="meta">source_message_id: real-scene-p1-v2-2-1773749870</div>
<div class="bubble user">最近AI行业有什么动态</div>
<div class="bubble bot">已收到,正在处理。</div>
<div class="bubble bot fail">模型调用失败:function=adaptive_rag_answer_normal, provider=glm, model=GLM-4.7, reason=LLM 返回空内容...</div>
</div>
</body>
</html>

View File

@ -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/让我直接处理/模型失败了所以没分析”等漂移语句时,自动回退固定模板,禁止对外宣称未证实执行动作

View File

@ -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
}
]

View File

@ -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

View File

@ -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)

View File

@ -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

View File

@ -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)
)

View File

@ -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(),
},
},
}

View File

@ -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:

View File

@ -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(

View File

@ -165,6 +165,7 @@ PROMPT_FEISHU_HUMANIZED_REPLY = """
5. 如果用户询问日期/时间/星期,必须基于“当前系统时间(北京时间)”直接回答,不得编造。
6. 禁止输出数据库内部实施细节(如数据库迁移、DDL、SQL、ALTER TABLE、DROP TABLE、Alembic)。
7. 当 status/classification 无法证明写入动作已成功时,禁止编造处理步骤。
8. 对于创建项目、接入信号源、创建信号这类动作请求,禁止输出“我来帮你/让我先/我先检查/通过 API 创建”等过程性前缀,必须直接给出执行结果或缺失字段。
【输出规范】
- 严禁输出任何思考过程或推理步骤。

View File

@ -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,

View File

@ -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:

View File

@ -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)

View File

@ -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":

View File

@ -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),

View File

@ -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,

View File

@ -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,
*,

View File

@ -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(

View File

@ -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

View File

@ -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"]

View File

@ -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),
}

View File

@ -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,
)

View File

@ -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

View File

@ -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

View File

@ -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",

View File

@ -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"}:

View File

@ -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()

View File

@ -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],

View File

@ -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)

View File

@ -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()

View File

@ -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}")

View File

@ -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

View File

@ -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:

View File

@ -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"

View File

@ -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):

View File

@ -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"}:

View File

@ -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)

View File

@ -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'}"
),
)

View File

@ -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",

View File

@ -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:

View File

@ -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",
]

View File

@ -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")),

View File

@ -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,

View File

@ -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,
)

View File

@ -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()

View File

@ -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):

View File

@ -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

View File

@ -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

View File

@ -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)

View File

@ -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"

View File

@ -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})()

View File

@ -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] = {}

View File

@ -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"]

View File

@ -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())

View File

@ -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

View File

@ -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

View File

@ -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)

View File

@ -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"

View File

@ -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"]

View File

@ -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) |

View File

@ -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`

View File

@ -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 只做同层灾备。

View File

@ -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

View File

@ -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;

View File

@ -9,24 +9,24 @@ function unwrap<T>(raw: ApiEnvelope<T> | 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 = {

View File

@ -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,
},

View File

@ -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: {

View File

@ -229,7 +229,7 @@ export default function EngineTab({
<Input
value={engineForm.model}
onChange={(e) => 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"
/>
</div>
<div className="space-y-1">

View File

@ -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 (
<div className="space-y-6">
<ConfigSubpageHeader
title="知识图谱与模型上下文看板"
description="统一展示 KG/任务模板/契约同步与双模型上下文消费验证状态。"
title="知识图谱与路由上下文看板"
description="统一展示 KG/任务模板/契约同步与 low/high 路由上下文消费验证状态。"
icon={<Boxes className="h-5 w-5" />}
tone="blue"
meta={
@ -75,16 +75,16 @@ export default function KnowledgeGraphContextBoardPage() {
/>
<FeatureIntroGuide
featureId="knowledge-graph-board"
title="知识图谱与模型上下文看板"
purpose="确认 low/high 两个模型是否真正吃到 KG、模板和契约三类上下文。"
title="知识图谱与路由上下文看板"
purpose="确认 low/high 两个逻辑层是否真正吃到 KG、模板和契约三类上下文。"
whenToUse="你要判断“模型为什么表现异常、上下文是否生效”时优先看这里。"
steps={[
"先看 KG 状态、模板覆盖、契约同步三个基础区块是否就绪。",
"点击“运行双模型验证”,触发 canary 与日志证据回查。",
"点击“运行 low/high 验证”,触发 canary 与日志证据回查。",
"查看函数矩阵和验证结果,定位是路由问题还是上下文注入问题。",
]}
outputs={[
"双模型上下文消费通过/失败结论",
"双层路由上下文消费通过/失败结论",
"按函数可追溯的证据与路由快照",
]}
/>
@ -107,7 +107,7 @@ export default function KnowledgeGraphContextBoardPage() {
disabled={verifyMutation.isPending}
>
<ShieldCheck className={`mr-1 h-4 w-4 ${verifyMutation.isPending ? "animate-pulse" : ""}`} />
{verifyMutation.isPending ? "验证中..." : "运行双模型验证"}
{verifyMutation.isPending ? "验证中..." : "运行 low/high 验证"}
</Button>
) : (
<span className="text-xs text-slate-500 dark:text-slate-400">仅管理员可执行验证操作。</span>
@ -148,7 +148,7 @@ export default function KnowledgeGraphContextBoardPage() {
<Card className="border-slate-200/80 bg-white/90 shadow-sm backdrop-blur-sm dark:border-slate-800 dark:bg-slate-900/85">
<CardHeader>
<CardTitle className="text-base">任务模板覆盖</CardTitle>
<CardDescription>双模型链路要求模板的覆盖与缺口。</CardDescription>
<CardDescription>low/high 链路要求模板的覆盖与缺口。</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
<div className="flex items-center gap-2">
@ -200,13 +200,13 @@ export default function KnowledgeGraphContextBoardPage() {
</Card>
<Card className="border-slate-200/80 bg-white/90 shadow-sm backdrop-blur-sm dark:border-slate-800 dark:bg-slate-900/85">
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
<GitBranch className="h-4 w-4" />
函数-模型-模板矩阵
</CardTitle>
<CardDescription>固定四条函数链路的路由与模板绑定关系。</CardDescription>
</CardHeader>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
<GitBranch className="h-4 w-4" />
函数-路由-模板矩阵
</CardTitle>
<CardDescription>固定四条函数链路的路由与模板绑定关系。</CardDescription>
</CardHeader>
<CardContent>
<div className="overflow-x-auto rounded-xl border border-slate-200/90 dark:border-slate-700">
<Table>
@ -214,20 +214,25 @@ export default function KnowledgeGraphContextBoardPage() {
<TableRow>
<TableHead>函数</TableHead>
<TableHead>层级</TableHead>
<TableHead>模型</TableHead>
<TableHead>当前路由</TableHead>
<TableHead>模板</TableHead>
<TableHead>来源</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{matrixRows.map((row) => (
<TableRow key={row.function_name} className="hover:bg-slate-50/70 dark:hover:bg-slate-800/60">
<TableCell className="font-medium">{row.function_name}</TableCell>
<TableCell>{row.tier}</TableCell>
<TableCell>{`${row.model_routing.provider}/${row.model_routing.model}`}</TableCell>
<TableCell>{row.template_ids.join(", ") || "-"}</TableCell>
<TableCell>{row.model_routing.source || "-"}</TableCell>
</TableRow>
<TableRow key={row.function_name} className="hover:bg-slate-50/70 dark:hover:bg-slate-800/60">
<TableCell className="font-medium">{row.function_name}</TableCell>
<TableCell>{row.tier}</TableCell>
<TableCell>
<div className="font-medium">{row.routing_snapshot.tier_alias || "-"}</div>
<div className="mt-1 text-xs text-slate-500 dark:text-slate-400">
{row.routing_snapshot.rule_name || "来自配置真源"}
</div>
</TableCell>
<TableCell>{row.template_ids.join(", ") || "-"}</TableCell>
<TableCell>{row.routing_snapshot.source || "-"}</TableCell>
</TableRow>
))}
{matrixRows.length === 0 ? (
<TableRow>
@ -244,8 +249,8 @@ export default function KnowledgeGraphContextBoardPage() {
<Card className="border-slate-200/80 bg-white/90 shadow-sm backdrop-blur-sm dark:border-slate-800 dark:bg-slate-900/85">
<CardHeader>
<CardTitle className="text-base">双模型上下文消费验证</CardTitle>
<CardDescription>通过 canary + ai_call_logs 标记校验模型是否吃到三类上下文。</CardDescription>
<CardTitle className="text-base">low/high 上下文消费验证</CardTitle>
<CardDescription>通过 canary + ai_call_logs 标记校验当前 tier 是否吃到三类上下文。</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
<div className="flex items-center gap-3">

View File

@ -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<string, string> = {
adaptive_rag_answer_deep: "复杂问题的深度回答阶段:会做更细的推理和更完整的结论组织。",
adaptive_rag_answer_normal: "常规问答阶段:基于已检索到的信息快速生成可读回复。",
@ -24,17 +24,25 @@ const FUNCTION_USAGE_HINTS: Record<string, string> = {
};
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() {
<div className="space-y-6">
<ConfigSubpageHeader
title="模型路由配置"
description="按 function_name 动态调整 provider / model / api_env / tier,立即生效。"
description="按 function_name 选择逻辑 tier 槽位,只暴露 low/high,真实模型由配置真源解析。"
icon={<Settings2 className="h-5 w-5" />}
tone="blue"
meta={<span>{routingQuery.isError ? "读取失败" : `已加载 ${items.length} 条路由`}</span>}
@ -94,16 +91,16 @@ export default function ModelRoutingConfigPage() {
<FeatureIntroGuide
featureId="model-routing-config"
title="模型路由配置"
purpose="给不同函数指定不同模型,控制成本、速度和效果。"
whenToUse="某个功能效果差、成本高、超时多时,用它定向切换函数路由。"
purpose="给不同函数选择 low/high 路由槽位,统一控制成本、速度和效果。"
whenToUse="某个功能效果差、成本高、超时多时,用它切换函数当前使用的 tier。"
steps={[
"在右侧选中目标函数,左侧会自动回填当前配置。",
"调整 provider / tier / model / api_env,点“保存路由”。",
"触发一次该功能调用,观察日志是否按新路由生效。",
"在右侧选中目标函数,左侧会自动回填当前逻辑 tier。",
"只调整 low / high tier_alias,点“保存路由”。",
"触发一次该功能调用,观察最近一次执行是否出现 low -> high 升级。",
]}
outputs={[
"函数级模型路由覆盖项(override)",
"可快速回滚到更稳定的模型组合",
"函数级 tier override",
"命中规则与最近执行结果",
]}
/>
@ -111,7 +108,7 @@ export default function ModelRoutingConfigPage() {
<Card className="border-slate-200/80 bg-white/90 shadow-sm backdrop-blur-sm dark:border-slate-800 dark:bg-slate-900/85">
<CardHeader>
<CardTitle className="text-base">编辑器</CardTitle>
<CardDescription>选择函数并更新路由参数。</CardDescription>
<CardDescription>选择函数并更新逻辑 tier。</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
<Input
@ -120,36 +117,23 @@ export default function ModelRoutingConfigPage() {
placeholder="function_name"
/>
<p className="text-xs text-slate-500 dark:text-slate-400">{getFunctionUsageHint(selectedFunction)}</p>
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-1">
<select
value={provider}
onChange={(e) => setProvider(e.target.value as (typeof PROVIDER_OPTIONS)[number])}
className="h-10 rounded-md border border-input bg-background px-3 text-sm"
>
{PROVIDER_OPTIONS.map((item) => (
<option key={item} value={item}>
{item}
</option>
))}
</select>
<select
value={tier}
onChange={(e) => setTier(e.target.value as (typeof TIER_OPTIONS)[number])}
className="h-10 rounded-md border border-input bg-background px-3 text-sm"
>
{TIER_OPTIONS.map((item) => (
<option key={item} value={item}>
{item}
</option>
))}
</select>
<select
value={tierAlias}
onChange={(e) => setTierAlias(e.target.value as (typeof TIER_OPTIONS)[number])}
className="h-10 rounded-md border border-input bg-background px-3 text-sm"
>
{TIER_OPTIONS.map((item) => (
<option key={item} value={item}>
{item}
</option>
))}
</select>
<div className="rounded-xl border border-slate-200/80 bg-slate-50/80 p-3 text-xs text-slate-600 dark:border-slate-800 dark:bg-slate-950/50 dark:text-slate-300">
真实 provider / model 只存在于配置真源,不在调用视图直接展示。
</div>
<Input value={model} onChange={(e) => setModel(e.target.value)} placeholder="模型名" />
<Input value={apiEnv} onChange={(e) => setApiEnv(e.target.value)} placeholder="API Key 环境变量名" />
<Input value={baseURL} onChange={(e) => setBaseURL(e.target.value)} placeholder="Base URL(可选)" />
<Button
onClick={() => updateMutation.mutate()}
disabled={updateMutation.isPending || !selectedFunction || !model || !apiEnv}
disabled={updateMutation.isPending || !selectedFunction}
className="w-full bg-blue-600 text-white hover:bg-blue-700"
>
{updateMutation.isPending ? "保存中..." : "保存路由"}
@ -191,10 +175,11 @@ export default function ModelRoutingConfigPage() {
<TableHeader>
<TableRow>
<TableHead>Function</TableHead>
<TableHead>Provider</TableHead>
<TableHead>Model</TableHead>
<TableHead>Tier</TableHead>
<TableHead>Source</TableHead>
<TableHead>Rule</TableHead>
<TableHead>Last</TableHead>
<TableHead>升级</TableHead>
<TableHead className="text-right">操作</TableHead>
</TableRow>
</TableHeader>
@ -214,10 +199,21 @@ export default function ModelRoutingConfigPage() {
{getFunctionUsageHint(item.function_name)}
</div>
</TableCell>
<TableCell>{item.provider}</TableCell>
<TableCell>{item.model}</TableCell>
<TableCell>{item.tier}</TableCell>
<TableCell>{item.tier_alias}</TableCell>
<TableCell>{item.source}</TableCell>
<TableCell>
<div className="font-medium">{item.rule_name || "来自配置真源"}</div>
<div className="mt-1 text-xs text-slate-500 dark:text-slate-400">
{typeof item.rule_priority === "number" ? `priority ${item.rule_priority}` : "tier default"}
</div>
</TableCell>
<TableCell>
<div className="font-medium">{describeLastExecution(item)}</div>
<div className="mt-1 text-xs text-slate-500 dark:text-slate-400">
{item.last_execution?.created_at ? formatBeijingDateTime(item.last_execution.created_at) : "-"}
</div>
</TableCell>
<TableCell>{describeUpgrade(item)}</TableCell>
<TableCell className="text-right">
<Button size="sm" variant="outline" onClick={() => setSelectedFunction(item.function_name)}>
编辑

View File

@ -14,6 +14,13 @@ import urllib.request
from datetime import datetime
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parents[1]
BACKEND_DIR = REPO_ROOT / "backend"
if str(BACKEND_DIR) not in sys.path:
sys.path.insert(0, str(BACKEND_DIR))
from app.services.model.model_config import get_provider_defaults, get_tier_defaults
def load_env_file(env_path: Path) -> None:
if not env_path.exists():
@ -30,33 +37,18 @@ def load_env_file(env_path: Path) -> None:
os.environ.setdefault(key, value)
def provider_runtime(provider: str) -> dict[str, str]:
p = provider.strip().lower()
if p in {"glm", "zhipu"}:
return {
"provider": p,
"base_url": str(os.getenv("GLM_BASE_URL", "")).strip(),
"api_key": str(os.getenv("GLM_API_KEY", "")).strip(),
}
if p == "openai":
return {
"provider": p,
"base_url": str(os.getenv("OPENAI_BASE_URL", "https://api.openai.com/v1")).strip(),
"api_key": str(os.getenv("OPENAI_API_KEY", "")).strip(),
}
if p == "deepseek":
return {
"provider": p,
"base_url": str(os.getenv("DEEPSEEK_BASE_URL", "https://api.deepseek.com/v1")).strip(),
"api_key": str(os.getenv("DEEPSEEK_API_KEY", "")).strip(),
}
if p == "ollama":
return {
"provider": p,
"base_url": str(os.getenv("OLLAMA_BASE_URL", os.getenv("OLLAMA_HOST", "http://127.0.0.1:11434"))).strip(),
"api_key": "",
}
return {"provider": p, "base_url": "", "api_key": ""}
def provider_runtime(provider: str, provider_defaults: dict[str, dict[str, str]]) -> dict[str, str]:
aliases = {"zhipu": "glm", "chatglm": "glm", "deep-seek": "deepseek", "local": "ollama"}
p = aliases.get(provider.strip().lower(), provider.strip().lower())
defaults = dict(provider_defaults.get(p) or {})
api_env = str(defaults.get("api_env") or "").strip()
api_key = str(os.getenv(api_env, "")).strip() if api_env else ""
if p == "glm":
api_key = str(os.getenv("DASHSCOPE_API_KEY", api_key)).strip()
base_url = str(defaults.get("base_url") or "").strip()
if p == "ollama" and not base_url:
base_url = str(os.getenv("OLLAMA_BASE_URL", os.getenv("OLLAMA_HOST", "http://127.0.0.1:11434"))).strip()
return {"provider": p, "provider_name": str(defaults.get("name") or p).strip(), "base_url": base_url, "api_key": api_key}
def extract_json_detail(text: str) -> dict[str, str]:
@ -101,8 +93,9 @@ def probe_model(
model: str,
timeout: float,
insecure: bool,
provider_defaults: dict[str, dict[str, str]],
) -> dict[str, object]:
runtime = provider_runtime(provider)
runtime = provider_runtime(provider, provider_defaults)
base_url = runtime["base_url"].rstrip("/")
api_key = runtime["api_key"]
started = time.perf_counter()
@ -200,7 +193,7 @@ def render_result_line(item: dict[str, object]) -> str:
status_text = "✓ 正常" if bool(item["ok"]) else "✗ 失败"
elapsed_text = f"{float(item['elapsed']):.2f}s"
return (
f"[{item['level']}] {item['model']} ({item['provider']}) "
f"[{item['level']}] {item['model']} ({item.get('provider_name') or item['provider']}) "
f"{status_text} ({elapsed_text})"
)
@ -236,10 +229,15 @@ def main() -> int:
insecure = bool(args.insecure and not args.strict_tls)
low_provider = str(os.getenv("LLM_ROUTING_LOW_PROVIDER", os.getenv("LLM_PROVIDER", "glm")) or "").strip() or "glm"
low_model = str(os.getenv("LLM_ROUTING_LOW_MODEL", os.getenv("GLM_MODEL", "GLM-4.7")) or "").strip() or "GLM-4.7"
high_provider = str(os.getenv("LLM_ROUTING_HIGH_PROVIDER", os.getenv("LLM_PROVIDER", "openai")) or "").strip() or "openai"
high_model = str(os.getenv("LLM_ROUTING_HIGH_MODEL", os.getenv("OPENAI_MODEL", "GLM-5")) or "").strip() or "GLM-5"
provider_defaults = get_provider_defaults(refresh=True)
tier_defaults = get_tier_defaults(refresh=True)
low_tier = dict(tier_defaults.get("low") or {})
high_tier = dict(tier_defaults.get("high") or {})
low_provider = str(low_tier.get("provider") or "glm").strip() or "glm"
low_model = str(low_tier.get("model") or provider_defaults.get(low_provider, {}).get("model") or "qwen-plus").strip() or "qwen-plus"
high_provider = str(high_tier.get("provider") or "openai").strip() or "openai"
high_model = str(high_tier.get("model") or provider_defaults.get(high_provider, {}).get("model") or "GLM-5").strip() or "GLM-5"
targets: list[tuple[str, str, str]] = [("LOW", low_provider, low_model), ("HIGH", high_provider, high_model)]
deduped: list[tuple[str, str, str]] = []
@ -266,6 +264,7 @@ def main() -> int:
model=model,
timeout=args.timeout,
insecure=insecure,
provider_defaults=provider_defaults,
)
results.append(result)
print(f"│ {render_result_line(result)}")

View File

@ -1,5 +1,5 @@
#!/usr/bin/env python3
"""OpenClaw 路由规则静态校验(PMFlow 三引擎版)。"""
"""OpenClaw 路由规则静态校验(PMFlow 双层版)。"""
from __future__ import annotations
@ -10,8 +10,8 @@ from pathlib import Path
from typing import Any, Optional
VALID_PROVIDERS = {"glm", "openai", "deepseek", "ollama"}
VALID_TIERS = {"low", "high", "safety"}
DEFAULT_RULES_RELATIVE_PATH = "../.openclaw/pmflow-routing-rules.json"
VALID_TIERS = {"low", "high"}
DEFAULT_RULES_RELATIVE_PATH = "backend/.openclaw/pmflow-routing-rules.json"
SCRIPT_ROOT = Path(__file__).resolve().parents[1]
BACKEND_DIR = SCRIPT_ROOT / "backend"
@ -85,7 +85,10 @@ def _resolve_rules_path(raw: str) -> Path:
path = Path(token)
if path.is_absolute():
return path
return (BACKEND_DIR / path).resolve()
for candidate in ((SCRIPT_ROOT / path).resolve(), (BACKEND_DIR / path).resolve()):
if candidate.exists():
return candidate
return (SCRIPT_ROOT / path).resolve()
def _lint(path: Path) -> tuple[bool, dict[str, Any]]:
@ -133,28 +136,13 @@ def _lint(path: Path) -> tuple[bool, dict[str, Any]]:
if actual_tier != "high":
problems.append(f"function {fn} expected tier=high, actual={actual_tier}")
safety_rules = [
row
for row in rules
if bool(row.get("enabled", True)) and str(row.get("tier") or "").strip().lower() == "safety"
]
if not safety_rules:
problems.append("missing safety tier rule")
else:
for fn in sorted(LOW_REQUIRED | HIGH_REQUIRED):
if not any(_rule_matches_function(row, fn) for row in safety_rules):
problems.append(f"function {fn} not covered by safety tier rule")
break
model_hints = {
"low": "GLM-4.7",
"high": "MiniMax-M2.5",
"safety": "deepseek-chat",
"low": "qwen-plus",
"high": "GLM-5",
}
api_env_hints = {
"low": "GLM_API_KEY",
"low": "DASHSCOPE_API_KEY",
"high": "OPENAI_API_KEY",
"safety": "OPENAI_API_KEY",
}
for tier, expected_model in model_hints.items():
tier_rows = [

View File

@ -1,100 +0,0 @@
<!doctype html>
<html><head><meta charset="utf-8"><title>short-confirm-redesign-snapshot</title>
<style>
body{font-family: Menlo, Monaco, 'SF Mono', monospace; background:#f7f7f8; margin:24px;}
.card{background:white; border:1px solid #ddd; border-radius:12px; padding:16px;}
h1{font-size:20px;margin:0 0 12px 0;}
pre{white-space:pre-wrap; word-break:break-word; font-size:12px; line-height:1.45;}
</style></head>
<body>
<div class="card">
<h1>短确认重设计验收结果快照</h1>
<pre>{
&quot;messages&quot;: {
&quot;msg1_id&quot;: &quot;accept-shortconfirm-redesign-msg1-1773935717&quot;,
&quot;msg2_id&quot;: &quot;accept-shortconfirm-redesign-msg2-1773935717&quot;
},
&quot;checks&quot;: {
&quot;msg1_intent&quot;: &quot;query.answer&quot;,
&quot;msg2_intent&quot;: &quot;query.answer&quot;,
&quot;msg2_context_source&quot;: &quot;session_inherited&quot;,
&quot;msg2_has_classify_call&quot;: false,
&quot;msg2_has_any_llm_call&quot;: true
},
&quot;runs&quot;: [
{
&quot;id&quot;: 106,
&quot;source_message_id&quot;: &quot;accept-shortconfirm-redesign-msg1-1773935717&quot;,
&quot;status&quot;: &quot;succeeded&quot;,
&quot;retry_count&quot;: 0,
&quot;work_item_id&quot;: 397,
&quot;summary&quot;: &quot;模型调用失败:function=adaptive_rag_answer_normal, provider=glm, model=GLM-4.7, reason=LLM 返回空内容: message_keys=[&#x27;role&#x27;, &#x27;conten...&quot;,
&quot;created_at&quot;: &quot;2026-03-19 23:55:17.132742&quot;,
&quot;updated_at&quot;: &quot;2026-03-19 23:56:50.205344&quot;,
&quot;completed_at&quot;: &quot;2026-03-19 23:56:50.205773&quot;
},
{
&quot;id&quot;: 107,
&quot;source_message_id&quot;: &quot;accept-shortconfirm-redesign-msg2-1773935717&quot;,
&quot;status&quot;: &quot;succeeded&quot;,
&quot;retry_count&quot;: 0,
&quot;work_item_id&quot;: 398,
&quot;summary&quot;: &quot;好的,已收到您的确认。如果后续有需要记录或处理的事项,请随时告诉我。&quot;,
&quot;created_at&quot;: &quot;2026-03-19 23:55:22.233825&quot;,
&quot;updated_at&quot;: &quot;2026-03-19 23:56:52.832398&quot;,
&quot;completed_at&quot;: &quot;2026-03-19 23:56:52.832584&quot;
}
],
&quot;work_items&quot;: [
{
&quot;id&quot;: 397,
&quot;source_message_id&quot;: &quot;accept-shortconfirm-redesign-msg1-1773935717&quot;,
&quot;state&quot;: &quot;failed&quot;,
&quot;intent&quot;: &quot;query.answer&quot;,
&quot;project_id&quot;: 51,
&quot;signal_id&quot;: null,
&quot;updated_at&quot;: &quot;2026-03-19 23:55:17.185230&quot;,
&quot;context_source&quot;: &quot;llm_only&quot;,
&quot;execution_signal_id&quot;: null
},
{
&quot;id&quot;: 398,
&quot;source_message_id&quot;: &quot;accept-shortconfirm-redesign-msg2-1773935717&quot;,
&quot;state&quot;: &quot;succeeded&quot;,
&quot;intent&quot;: &quot;query.answer&quot;,
&quot;project_id&quot;: 51,
&quot;signal_id&quot;: null,
&quot;updated_at&quot;: &quot;2026-03-19 23:56:52.816953&quot;,
&quot;context_source&quot;: &quot;session_inherited&quot;,
&quot;execution_signal_id&quot;: null
}
],
&quot;ai_calls&quot;: [
{
&quot;id&quot;: 1932,
&quot;function_name&quot;: &quot;classify_intent_ai_pmo_kernel&quot;,
&quot;created_at&quot;: &quot;2026-03-19 23:55:39.954021&quot;,
&quot;hit_m1&quot;: 1,
&quot;hit_m2&quot;: 0,
&quot;hit_msg2_classify&quot;: 0
},
{
&quot;id&quot;: 1933,
&quot;function_name&quot;: &quot;adaptive_rag_answer_normal&quot;,
&quot;created_at&quot;: &quot;2026-03-19 23:56:50.143564&quot;,
&quot;hit_m1&quot;: 1,
&quot;hit_m2&quot;: 0,
&quot;hit_msg2_classify&quot;: 0
},
{
&quot;id&quot;: 1934,
&quot;function_name&quot;: &quot;adaptive_rag_answer_normal&quot;,
&quot;created_at&quot;: &quot;2026-03-19 23:56:52.810858&quot;,
&quot;hit_m1&quot;: 0,
&quot;hit_m2&quot;: 1,
&quot;hit_msg2_classify&quot;: 0
}
]
}</pre>
</div>
</body></html>

View File

@ -1,87 +0,0 @@
{
"messages": {
"msg1_id": "accept-shortconfirm-redesign-msg1-1773935717",
"msg2_id": "accept-shortconfirm-redesign-msg2-1773935717"
},
"checks": {
"msg1_intent": "query.answer",
"msg2_intent": "query.answer",
"msg2_context_source": "session_inherited",
"msg2_has_classify_call": false,
"msg2_has_any_llm_call": true
},
"runs": [
{
"id": 106,
"source_message_id": "accept-shortconfirm-redesign-msg1-1773935717",
"status": "succeeded",
"retry_count": 0,
"work_item_id": 397,
"summary": "模型调用失败:function=adaptive_rag_answer_normal, provider=glm, model=GLM-4.7, reason=LLM 返回空内容: message_keys=['role', 'conten...",
"created_at": "2026-03-19 23:55:17.132742",
"updated_at": "2026-03-19 23:56:50.205344",
"completed_at": "2026-03-19 23:56:50.205773"
},
{
"id": 107,
"source_message_id": "accept-shortconfirm-redesign-msg2-1773935717",
"status": "succeeded",
"retry_count": 0,
"work_item_id": 398,
"summary": "好的,已收到您的确认。如果后续有需要记录或处理的事项,请随时告诉我。",
"created_at": "2026-03-19 23:55:22.233825",
"updated_at": "2026-03-19 23:56:52.832398",
"completed_at": "2026-03-19 23:56:52.832584"
}
],
"work_items": [
{
"id": 397,
"source_message_id": "accept-shortconfirm-redesign-msg1-1773935717",
"state": "failed",
"intent": "query.answer",
"project_id": 51,
"signal_id": null,
"updated_at": "2026-03-19 23:55:17.185230",
"context_source": "llm_only",
"execution_signal_id": null
},
{
"id": 398,
"source_message_id": "accept-shortconfirm-redesign-msg2-1773935717",
"state": "succeeded",
"intent": "query.answer",
"project_id": 51,
"signal_id": null,
"updated_at": "2026-03-19 23:56:52.816953",
"context_source": "session_inherited",
"execution_signal_id": null
}
],
"ai_calls": [
{
"id": 1932,
"function_name": "classify_intent_ai_pmo_kernel",
"created_at": "2026-03-19 23:55:39.954021",
"hit_m1": 1,
"hit_m2": 0,
"hit_msg2_classify": 0
},
{
"id": 1933,
"function_name": "adaptive_rag_answer_normal",
"created_at": "2026-03-19 23:56:50.143564",
"hit_m1": 1,
"hit_m2": 0,
"hit_msg2_classify": 0
},
{
"id": 1934,
"function_name": "adaptive_rag_answer_normal",
"created_at": "2026-03-19 23:56:52.810858",
"hit_m1": 0,
"hit_m2": 1,
"hit_msg2_classify": 0
}
]
}