Centralize PMFlow workflow semantics and routing
This commit is contained in:
parent
d4691f1276
commit
0e87cec76d
|
|
@ -18,18 +18,21 @@ CORS_ORIGINS=http://localhost:5174,http://127.0.0.1:5174
|
||||||
LLM_PROVIDER=glm
|
LLM_PROVIDER=glm
|
||||||
LLM_FALLBACK_PROVIDER=glm
|
LLM_FALLBACK_PROVIDER=glm
|
||||||
OPENCLAW_ROUTING_ENABLED=true
|
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
|
OPENCLAW_AGENTS_DEFAULT_TIMEOUT_SECONDS=120
|
||||||
LLM_DIRECT_EMERGENCY_ENABLED=true
|
LLM_DIRECT_EMERGENCY_ENABLED=true
|
||||||
LLM_MIXED_ROUTING_ENABLED=false
|
LLM_MIXED_ROUTING_ENABLED=false
|
||||||
LLM_ROUTING_DEFAULT_TIER=active
|
LLM_ROUTING_DEFAULT_TIER=low
|
||||||
LLM_LOW_TO_HIGH_ESCALATION_ENABLED=true
|
LLM_LOW_TO_HIGH_ESCALATION_ENABLED=true
|
||||||
LLM_REQUEST_TIMEOUT=90
|
LLM_REQUEST_TIMEOUT=90
|
||||||
|
|
||||||
# 模型默认值统一由 backend/.openclaw/pmflow-routing-rules.json 管理
|
# 模型默认值统一由 backend/.openclaw/pmflow-routing-rules.json 管理,不再依赖 LLM_ROUTING_* / *_MODEL 环境变量
|
||||||
DASHSCOPE_API_KEY=
|
DASHSCOPE_API_KEY=
|
||||||
OPENAI_API_KEY=
|
OPENAI_API_KEY=
|
||||||
DEEPSEEK_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 深入思考模式
|
# Adaptive RAG 深入思考模式
|
||||||
RAG_DEEP_RETRIEVAL_STEPS=5
|
RAG_DEEP_RETRIEVAL_STEPS=5
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,6 @@ on:
|
||||||
- "backend/app/main.py"
|
- "backend/app/main.py"
|
||||||
- "backend/evals/feishu_replay_eval.py"
|
- "backend/evals/feishu_replay_eval.py"
|
||||||
- "backend/evals/golden_feishu_replay_v1.jsonl"
|
- "backend/evals/golden_feishu_replay_v1.jsonl"
|
||||||
- ".openclaw/pmflow-routing-rules.json"
|
|
||||||
- "backend/.openclaw/pmflow-routing-rules.json"
|
- "backend/.openclaw/pmflow-routing-rules.json"
|
||||||
- "scripts/openclaw_routing_lint.py"
|
- "scripts/openclaw_routing_lint.py"
|
||||||
- ".github/workflows/feishu-replay-gate.yml"
|
- ".github/workflows/feishu-replay-gate.yml"
|
||||||
|
|
@ -21,7 +20,6 @@ on:
|
||||||
paths:
|
paths:
|
||||||
- "backend/evals/feishu_replay_eval.py"
|
- "backend/evals/feishu_replay_eval.py"
|
||||||
- "backend/evals/golden_feishu_replay_v1.jsonl"
|
- "backend/evals/golden_feishu_replay_v1.jsonl"
|
||||||
- ".openclaw/pmflow-routing-rules.json"
|
|
||||||
- "backend/.openclaw/pmflow-routing-rules.json"
|
- "backend/.openclaw/pmflow-routing-rules.json"
|
||||||
- "scripts/openclaw_routing_lint.py"
|
- "scripts/openclaw_routing_lint.py"
|
||||||
- ".github/workflows/feishu-replay-gate.yml"
|
- ".github/workflows/feishu-replay-gate.yml"
|
||||||
|
|
@ -66,7 +64,6 @@ jobs:
|
||||||
|
|
||||||
- name: Lint OpenClaw routing rules
|
- name: Lint OpenClaw routing rules
|
||||||
run: |
|
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
|
python scripts/openclaw_routing_lint.py --input backend/.openclaw/pmflow-routing-rules.json
|
||||||
|
|
||||||
- name: Run replay eval
|
- name: Run replay eval
|
||||||
|
|
|
||||||
|
|
@ -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
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
{
|
{
|
||||||
"version": 1,
|
"version": 1,
|
||||||
"onboardingCompletedAt": "2026-03-04T07:50:38.457Z"
|
"onboardingCompletedAt": "2026-03-26T16:00:06.817Z"
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
|
||||||
35
CLAUDE.md
35
CLAUDE.md
|
|
@ -57,7 +57,8 @@ backend/
|
||||||
│ ├── 20260311_add_conversation_sessions_table.py # 飞书多轮会话上下文持久化表
|
│ ├── 20260311_add_conversation_sessions_table.py # 飞书多轮会话上下文持久化表
|
||||||
│ ├── 20260316_add_ai_orchestration_ingress_async_fields.py # ai_orchestration_runs 入站异步重试字段扩展
|
│ ├── 20260316_add_ai_orchestration_ingress_async_fields.py # ai_orchestration_runs 入站异步重试字段扩展
|
||||||
│ ├── 20260323_add_work_item_draft_confirmation_fields.py # work_items 草稿确认字段与索引
|
│ ├── 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/ # 后端运维/数据修复脚本
|
├── scripts/ # 后端运维/数据修复脚本
|
||||||
│ ├── backfill_orphan_signal_links.py # 回填孤立 signal 的 project_signal_links
|
│ ├── backfill_orphan_signal_links.py # 回填孤立 signal 的 project_signal_links
|
||||||
│ ├── dev/ # 开发辅助脚本(check_users/seed_all)
|
│ ├── dev/ # 开发辅助脚本(check_users/seed_all)
|
||||||
|
|
@ -79,6 +80,8 @@ backend/
|
||||||
│ ├── test_feishu_receive_orchestration_contract.py
|
│ ├── test_feishu_receive_orchestration_contract.py
|
||||||
│ ├── test_signal_planner_contract.py
|
│ ├── test_signal_planner_contract.py
|
||||||
│ ├── test_ai_prompt_registry_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
|
│ └── test_sstc_tasks_ai_breakdown_contract.py
|
||||||
├── requirements.txt # Python 依赖
|
├── requirements.txt # Python 依赖
|
||||||
└── Dockerfile
|
└── Dockerfile
|
||||||
|
|
@ -141,22 +144,27 @@ backend/
|
||||||
- 新增:`backend/alembic/versions/20260324_add_project_create_idempotency_indexes.py`(活动项目标题大小写无关唯一索引,收口项目创建重复写入)
|
- 新增:`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_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_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/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/models/feedback_center.py`(`FeedbackCenterRecord/FeedbackCenterRecordRevision` 统一反馈中心模型)
|
||||||
- 新增:`backend/app/schemas/feedback_center.py`(反馈中心 summary/list/detail/review/reason-catalog 对应 Pydantic 模型)
|
- 新增:`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/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_service.py`(反馈中心服务回归:tenant/project 过滤、review 修订、映射与原因候选)
|
||||||
- 新增:`backend/tests/test_feedback_center_api.py`(反馈中心 API 回归:路由、权限与三类来源回填)
|
- 新增:`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_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_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_signal_source_requirement_materialization.py`(需求点 -> 信号源注册项归一/去重/原始上下文回归)
|
||||||
- 新增:`backend/tests/test_feishu_new_project_signal_sources.py`(飞书 `new_project` 自动立项回归:摘要失败显式报错、信号源随项目同步落库)
|
- 新增:`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/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/schemas/feedback_center.py`(summary 增加 metrics / trend / insights 结构,并回传 `source_group`)
|
||||||
- 新增:`backend/app/services/signal_planner_service.py`(Signal Planner 异步分析服务)
|
- 新增:`backend/app/services/signal_planner_service.py`(Signal Planner 异步分析服务)
|
||||||
- 新增:`backend/app/services/model/model_routing/service.py`(函数级模型路由配置服务)
|
- 新增:`backend/app/services/model/model_routing/service.py`(函数级模型路由配置服务)
|
||||||
- 新增:`backend/app/services/task_brief_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/model_routing.py`(模型路由配置接口)
|
||||||
- 新增:`backend/app/api/task_briefs.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`)
|
- 更新:`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/components/ui/SkillBadge.tsx`(技能徽章组件,默认显示 3 个并支持 +N)
|
||||||
- 新增:`frontend/src/api/taskBriefs.ts`(任务包中心 API 封装)
|
- 新增:`frontend/src/api/taskBriefs.ts`(任务包中心 API 封装)
|
||||||
- 新增:`frontend/src/api/modelRouting.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/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/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`(剩余核心页面目录化收敛)
|
- 新增:`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
|
- 运行环境数据库仅允许 PostgreSQL(`postgresql+asyncpg`);禁止回落 SQLite
|
||||||
- 新增接口必须遵循响应包络:`{code, message, data}`,不得裸返回
|
- 新增接口必须遵循响应包络:`{code, message, data}`,不得裸返回
|
||||||
- 项目创建时自动生成的信号源必须落到现有 `SignalSourceRegistry`,前端仅允许在现有 AI Steward 创建结果区内联展示,禁止新增独立信号源页面或独立持久化模型
|
- 项目创建时自动生成的信号源必须落到现有 `SignalSourceRegistry`,前端仅允许在现有 AI Steward 创建结果区内联展示,禁止新增独立信号源页面或独立持久化模型
|
||||||
|
- `create_project_from_natural_language` 支持透传 `requested_member_user_ids`;AI PMO 草稿确认时必须把推荐负责人并入项目创建事务,确保项目、主信号、信号源、成员关系一次性落库,禁止确认后再事后补成员
|
||||||
- 禁止在 API 层写业务逻辑,业务逻辑归属 `services/`
|
- 禁止在 API 层写业务逻辑,业务逻辑归属 `services/`
|
||||||
- 禁止在前端组件内直接调用 `fetch`,必须通过 `frontend/src/api/` 封装层
|
- 禁止在前端组件内直接调用 `fetch`,必须通过 `frontend/src/api/` 封装层
|
||||||
- 前端状态管理:服务端数据用 React Query,本地 UI 状态用 `useState`
|
- 前端状态管理:服务端数据用 React Query,本地 UI 状态用 `useState`
|
||||||
- 飞书回执发送仅允许 `main.py` 的 drain_loop 作为消费者,禁止在业务路径即时派发 outbox 发送任务
|
- 飞书回执发送仅允许 `main.py` 的 drain_loop 作为消费者,禁止在业务路径即时派发 outbox 发送任务
|
||||||
- 所有写接口必须落审计日志(`operation_logs`):至少包含 `trace_id/interface_path/caller`
|
- 所有写接口必须落审计日志(`operation_logs`):至少包含 `trace_id/interface_path/caller`
|
||||||
- `created_at_beijing` 为计算属性返回,禁止新增数据库物理列
|
- `created_at_beijing` 为计算属性返回,禁止新增数据库物理列
|
||||||
- low/high 双模型(`classify_intent_ai_pmo_kernel`、`classify_intent_complex`)与 `openclaw_command_dispatch` 必须遵循 DDL 接口化约束:禁止直连 SQL DDL,命中时强制输出 `ddl_api_required`
|
- 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 上下文注入仅允许通过 `ModelContextBundleService` 统一组包:固定三段 `knowledge_graph/task_template/contract_sync`,禁止在业务服务散落拼接 prompt 上下文
|
||||||
- AI PMO `signal.create` 在用户显式要求“先分析再入库 / 自行分析”时,若批量信号结构化分析失败,禁止回退规则标题直接入库,必须返回失败安全态
|
- 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` 回退,避免思考过程外漏
|
- 用户可见问答链路(`function_name` 含 `answer/reply/qa`)禁止使用 `reasoning_content` 回退,避免思考过程外漏
|
||||||
- AdaptiveRAG 场景化 Prompt 选择器必须在 `mixin_deep_guard_answer.py` 组装 Prompt 前执行,并遵循固定优先级:成员负载 > 趋势(项目关联 external_trend / 通用 industry_trend) > mixed > AI PMO 入口 > 默认 RAG
|
- AdaptiveRAG 场景化 Prompt 选择器必须在 `mixin_deep_guard_answer.py` 组装 Prompt 前执行,并遵循固定优先级:成员负载 > 趋势(项目关联 external_trend / 通用 industry_trend) > mixed > AI PMO 入口 > 默认 RAG
|
||||||
- 通用行业趋势问答(如“宠物行业最新动态”)不得注入数据库、项目历史、用户历史或外部实时搜索结果;只有显式带项目上下文时才允许走 `external_trend`,对“不要提项目/别提项目”这类否定语句不能误判为项目上下文
|
- 通用行业趋势问答(如“宠物行业最新动态”)不得注入数据库、项目历史、用户历史或外部实时搜索结果;只有显式带项目上下文时才允许走 `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/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/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`)
|
- 模型路由配置接口:参考 `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`)
|
- 任务包中心接口:参考 `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`)
|
- 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` 持久化)
|
- 需求点落信号源:参考 `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_*`
|
- 飞书长连接状态分为两层:`last_received_at/last_received_message_id` 表示 SDK 回调已收到事件,`last_event_at` 表示 worker 完成处理;排查“没吃进去”时必须优先看 `last_received_*`
|
||||||
- 飞书长连接会话应复用 `Integration.config` 中已有的 `encrypt_key/verification_token`;配置保存时若未显式修改这两个字段,必须保留现有值,避免 SDK 因缺失事件密钥而拒收消息
|
- 飞书长连接会话应复用 `Integration.config` 中已有的 `encrypt_key/verification_token`;配置保存时若未显式修改这两个字段,必须保留现有值,避免 SDK 因缺失事件密钥而拒收消息
|
||||||
- LLM 主链按 `OPENCLAW_ROUTING_ENABLED` 走 OpenClaw 路由规则(`backend/.openclaw/pmflow-routing-rules.json`);请求超时统一以 `LLM_REQUEST_TIMEOUT` 环境变量为准
|
- 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` 链路一律禁用该回退
|
- LLM 非结构化响应仅在非用户可见链路且显式开启 `LLM_REASONING_CONTENT_FALLBACK` 时,才允许从 `reasoning_content` 回退;`answer/reply/qa` 链路一律禁用该回退
|
||||||
- World Model 静态层已接入 Redis 缓存(key: `wm:static:{tenant_id}:{project_id}`,TTL 600s)
|
- World Model 静态层已接入 Redis 缓存(key: `wm:static:{tenant_id}:{project_id}`,TTL 600s)
|
||||||
- 写操作后主动 invalidate,动态层不缓存
|
- 写操作后主动 invalidate,动态层不缓存
|
||||||
- AI PMO 意图识别已切换 `model-only`:`_resolve_intent` 仅接受 LLM 结构化结果,不再走规则/正则兜底链路
|
- AI PMO 意图识别已切换 `model-only`:`_resolve_intent` 仅接受 LLM 结构化结果,不再走规则/正则兜底链路
|
||||||
- AI PMO / Feishu 执行门已取消置信度阈值拦截与高风险人工复核:`confidence` 仅保留在 `decision_trace` / processing log 中作观测,不再作为自动执行的 reject 条件;当前门禁仅保留 `force_manual` 与 `correction_action`
|
- 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` 只用于定位这份文件
|
- 模型默认值与 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)`
|
- Signal Planner 结构化调用采用 `25s` 硬超时:超时或结构化不合法时标记失败并通知人工重试,不再写入降级 `ai_planner(fallback_mode=true)`
|
||||||
- AI Steward 用户侧已移除 OpenClaw 指挥规划入口;后续项目分析主链以 GLM 严格失败直出为准
|
- AI Steward 用户侧已移除 OpenClaw 指挥规划入口;后续项目分析主链以 GLM 严格失败直出为准
|
||||||
- 飞书业务命令命中旧 command-first 路由时,仅返回“已下线”阻断提示,不再触发实际执行
|
- 飞书业务命令命中旧 command-first 路由时,仅返回“已下线”阻断提示,不再触发实际执行
|
||||||
- 模型命名统一使用裸模型名(如 `GLM-4.7`、`MiniMax-M2.5`),不使用供应商前缀模型名
|
- 真实模型名仅保留在 `backend/.openclaw/pmflow-routing-rules.json` 与内部解析流程中;调用页面、查询接口、`ai_call_logs` 不直接暴露 provider/model
|
||||||
- 双 Agent 路由治理:低/高层函数均可由 OpenClaw 规则命中到指定 provider/model(含 `classify_intent_ai_pmo_kernel`)
|
- 双层路由治理:低/高层函数统一由 OpenClaw 规则命中到 `low/high` 逻辑槽位(含 `classify_intent_ai_pmo_kernel` 的双层命中)
|
||||||
- 双模型 DDL 治理图谱的模型来源统一读取 `backend/.openclaw/pmflow-routing-rules.json` 中的 `provider_defaults/tier_defaults`;不再依赖 `LLM_ROUTING_LOW_MODEL/LLM_ROUTING_HIGH_MODEL` 作为主配置
|
- 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)用于离线回放与分桶评分
|
- 写操作链路统一产出 `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` 强制进入人工确认,不直接自动执行
|
- AI PMO 意图仲裁默认开启(`AI_PMO_INTENT_ARBITRATION_ENABLED=true`):规则与 LLM 写操作冲突时按 `AI_PMO_INTENT_CONFLICT_POLICY=pending_manual` 强制进入人工确认,不直接自动执行
|
||||||
- 飞书 `qa_llm` 回复已增加事实熔断:命中“数据库迁移/DDL/Alembic/让我直接处理/模型失败了所以没分析”等漂移语句时,自动回退固定模板,禁止对外宣称未证实执行动作
|
- 飞书 `qa_llm` 回复已增加事实熔断:命中“数据库迁移/DDL/Alembic/让我直接处理/模型失败了所以没分析”等漂移语句时,自动回退固定模板,禁止对外宣称未证实执行动作
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,8 @@
|
||||||
{
|
{
|
||||||
"version": "v3",
|
"version": "v3",
|
||||||
"updated_at": "2026-03-25T00:00:00Z",
|
"updated_at": "2026-03-26T21:30:00+08:00",
|
||||||
"source_of_truth": "openclaw",
|
"source_of_truth": "openclaw",
|
||||||
"description": "PMFlow 三引擎统一路由:所有主链路统一经 OpenClaw OpenAI-Compatible 网关。",
|
"description": "PMFlow 双层统一路由:调用层仅暴露 low/high,真实模型名只保留在配置真源。",
|
||||||
"provider_defaults": {
|
"provider_defaults": {
|
||||||
"deepseek": {
|
"deepseek": {
|
||||||
"name": "DeepSeek",
|
"name": "DeepSeek",
|
||||||
|
|
@ -19,7 +19,7 @@
|
||||||
"openai": {
|
"openai": {
|
||||||
"name": "OpenAI",
|
"name": "OpenAI",
|
||||||
"base_url": "https://api.edgefn.net/v1",
|
"base_url": "https://api.edgefn.net/v1",
|
||||||
"model": "MiniMax-M2.5",
|
"model": "GLM-5",
|
||||||
"api_env": "OPENAI_API_KEY"
|
"api_env": "OPENAI_API_KEY"
|
||||||
},
|
},
|
||||||
"ollama": {
|
"ollama": {
|
||||||
|
|
@ -41,32 +41,9 @@
|
||||||
"model": "GLM-5",
|
"model": "GLM-5",
|
||||||
"base_url": "https://api.edgefn.net/v1",
|
"base_url": "https://api.edgefn.net/v1",
|
||||||
"api_env": "OPENAI_API_KEY"
|
"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": [
|
"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",
|
"name": "engine_high_intent_complex_exact",
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
|
|
@ -112,6 +89,7 @@
|
||||||
"feishu_signal_extraction",
|
"feishu_signal_extraction",
|
||||||
"rag_intent_classification",
|
"rag_intent_classification",
|
||||||
"classify_intent",
|
"classify_intent",
|
||||||
|
"ai_pmo_signal_title_refine",
|
||||||
"feishu_humanized_reply",
|
"feishu_humanized_reply",
|
||||||
"extract_context_insights"
|
"extract_context_insights"
|
||||||
]
|
]
|
||||||
|
|
@ -127,6 +105,10 @@
|
||||||
"match_any": [
|
"match_any": [
|
||||||
"adaptive_rag_answer",
|
"adaptive_rag_answer",
|
||||||
"classify_intent_complex",
|
"classify_intent_complex",
|
||||||
|
"project_from_text_analysis",
|
||||||
|
"project_from_text_signal",
|
||||||
|
"project_from_text_charter",
|
||||||
|
"project_from_text_task_chain",
|
||||||
"project_requirement_extraction",
|
"project_requirement_extraction",
|
||||||
"feishu_project_prefill",
|
"feishu_project_prefill",
|
||||||
"feishu_task_orchestration",
|
"feishu_task_orchestration",
|
||||||
|
|
@ -137,6 +119,7 @@
|
||||||
"evaluate_model_requirement",
|
"evaluate_model_requirement",
|
||||||
"calculate_alignment_score",
|
"calculate_alignment_score",
|
||||||
"detect_cognitive_conflicts",
|
"detect_cognitive_conflicts",
|
||||||
|
"ai_pmo_signal_batch_analysis",
|
||||||
"rag_db_project_semantic_retrieval",
|
"rag_db_project_semantic_retrieval",
|
||||||
"workload_optimize_prompt",
|
"workload_optimize_prompt",
|
||||||
"analyze_cost_roi",
|
"analyze_cost_roi",
|
||||||
|
|
@ -149,7 +132,7 @@
|
||||||
"priority": 200,
|
"priority": 200,
|
||||||
"tier": "high",
|
"tier": "high",
|
||||||
"provider": "openai",
|
"provider": "openai",
|
||||||
"model": "MiniMax-M2.5",
|
"model": "GLM-5",
|
||||||
"api_env": "OPENAI_API_KEY",
|
"api_env": "OPENAI_API_KEY",
|
||||||
"match_any": [
|
"match_any": [
|
||||||
"analyze_food_calories",
|
"analyze_food_calories",
|
||||||
|
|
@ -163,13 +146,14 @@
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "engine_safety_fallback",
|
"name": "engine_low_fallback",
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
"priority": 50,
|
"priority": 50,
|
||||||
"tier": "safety",
|
"tier": "low",
|
||||||
"provider": "openai",
|
"provider": "glm",
|
||||||
"model": "deepseek-chat",
|
"model": "qwen-plus",
|
||||||
"api_env": "OPENAI_API_KEY",
|
"api_env": "DASHSCOPE_API_KEY",
|
||||||
|
"base_url": "https://dashscope.aliyuncs.com/compatible-mode/v1",
|
||||||
"match_all": true
|
"match_all": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,6 @@ RUN pip install --no-cache-dir -r /tmp/requirements.txt
|
||||||
|
|
||||||
COPY backend /app/backend
|
COPY backend /app/backend
|
||||||
COPY docs /app/docs
|
COPY docs /app/docs
|
||||||
COPY .openclaw /app/.openclaw
|
|
||||||
|
|
||||||
EXPOSE 8000
|
EXPOSE 8000
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
@ -125,57 +125,35 @@ def _record_exists(
|
||||||
return row is not None
|
return row is not None
|
||||||
|
|
||||||
|
|
||||||
|
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:
|
def _insert_record(bind, payload: dict[str, Any]) -> None:
|
||||||
bind.execute(
|
bind.execute(sa.insert(_feedback_center_records_table(bind)).values(**payload))
|
||||||
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 _backfill_from_qa_feedback(bind) -> None:
|
def _backfill_from_qa_feedback(bind) -> None:
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
)
|
||||||
|
|
@ -19,30 +19,35 @@ def _build_provider_payload() -> dict[str, Any]:
|
||||||
tier_defaults = get_tier_defaults()
|
tier_defaults = get_tier_defaults()
|
||||||
low_tier = dict(tier_defaults.get("low") or {})
|
low_tier = dict(tier_defaults.get("low") or {})
|
||||||
high_tier = dict(tier_defaults.get("high") 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 {
|
return {
|
||||||
"low": str(low_tier.get("model") or provider_defaults.get("glm", {}).get("model") or "qwen-plus").strip(),
|
"low": str(low_tier.get("model") or glm_defaults.get("model") or "qwen-plus").strip(),
|
||||||
"high": str(high_tier.get("model") or "GLM-5").strip(),
|
"high": high_model,
|
||||||
"base_url": str(high_tier.get("base_url") or provider_defaults.get("openai", {}).get("base_url") or "https://api.edgefn.net/v1").strip(),
|
"base_url": str(high_tier.get("base_url") or openai_defaults.get("base_url") or "https://api.edgefn.net/v1").strip(),
|
||||||
"providers": {
|
"providers": {
|
||||||
"glm": {
|
"glm": {
|
||||||
"model": str(provider_defaults.get("glm", {}).get("model") or "qwen-plus").strip(),
|
"model": str(glm_defaults.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(),
|
"base_url": str(glm_defaults.get("base_url") or "https://dashscope.aliyuncs.com/compatible-mode/v1").strip(),
|
||||||
"api_env": "DASHSCOPE_API_KEY",
|
"api_env": str(glm_defaults.get("api_env") or "DASHSCOPE_API_KEY").strip(),
|
||||||
},
|
},
|
||||||
"openai": {
|
"openai": {
|
||||||
"model": str(provider_defaults.get("openai", {}).get("model") or "MiniMax-M2.5").strip(),
|
"model": high_model,
|
||||||
"base_url": str(provider_defaults.get("openai", {}).get("base_url") or "https://api.edgefn.net/v1").strip(),
|
"base_url": str(openai_defaults.get("base_url") or "https://api.edgefn.net/v1").strip(),
|
||||||
"api_env": "OPENAI_API_KEY",
|
"api_env": str(openai_defaults.get("api_env") or "OPENAI_API_KEY").strip(),
|
||||||
},
|
},
|
||||||
"deepseek": {
|
"deepseek": {
|
||||||
"model": str(provider_defaults.get("deepseek", {}).get("model") or "deepseek-chat").strip(),
|
"model": str(deepseek_defaults.get("model") or "deepseek-chat").strip(),
|
||||||
"base_url": str(provider_defaults.get("deepseek", {}).get("base_url") or "https://api.deepseek.com/v1").strip(),
|
"base_url": str(deepseek_defaults.get("base_url") or "https://api.deepseek.com/v1").strip(),
|
||||||
"api_env": "DEEPSEEK_API_KEY",
|
"api_env": str(deepseek_defaults.get("api_env") or "DEEPSEEK_API_KEY").strip(),
|
||||||
},
|
},
|
||||||
"ollama": {
|
"ollama": {
|
||||||
"model": str(provider_defaults.get("ollama", {}).get("model") or "llama3:latest").strip(),
|
"model": str(ollama_defaults.get("model") or "llama3:latest").strip(),
|
||||||
"base_url": str(provider_defaults.get("ollama", {}).get("base_url") or "http://127.0.0.1:11434").strip(),
|
"base_url": str(ollama_defaults.get("base_url") or "http://127.0.0.1:11434").strip(),
|
||||||
"api_env": "",
|
"api_env": str(ollama_defaults.get("api_env") or "").strip(),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from typing import Optional
|
from typing import Literal
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends
|
from fastapi import APIRouter, Depends
|
||||||
from fastapi.responses import JSONResponse
|
from fastapi.responses import JSONResponse
|
||||||
|
|
@ -21,11 +21,7 @@ router = APIRouter(prefix="/model-routing", tags=["model_routing"])
|
||||||
|
|
||||||
|
|
||||||
class ModelRoutingUpdateRequest(BaseModel):
|
class ModelRoutingUpdateRequest(BaseModel):
|
||||||
provider: str
|
tier_alias: Literal["low", "high"] = Field(default="high")
|
||||||
model: str
|
|
||||||
api_env: str
|
|
||||||
tier: str = Field(default="high")
|
|
||||||
base_url: Optional[str] = None
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/functions")
|
@router.get("/functions")
|
||||||
|
|
@ -74,11 +70,7 @@ async def update_model_routing(
|
||||||
db,
|
db,
|
||||||
tenant_id=tenant_id,
|
tenant_id=tenant_id,
|
||||||
function_name=function_name,
|
function_name=function_name,
|
||||||
provider=body.provider,
|
tier_alias=body.tier_alias,
|
||||||
model=body.model,
|
|
||||||
api_env=body.api_env,
|
|
||||||
tier=body.tier,
|
|
||||||
base_url=body.base_url,
|
|
||||||
updated_by=int(current_user.id),
|
updated_by=int(current_user.id),
|
||||||
)
|
)
|
||||||
except ValueError as exc:
|
except ValueError as exc:
|
||||||
|
|
|
||||||
|
|
@ -317,7 +317,7 @@ def _part_02_entries() -> tuple[dict[str, Any], ...]:
|
||||||
module="part_02",
|
module="part_02",
|
||||||
business_scenario="立项草案生成",
|
business_scenario="立项草案生成",
|
||||||
input_contract="输入 request / project / active_projects / similar_projects / charter / members / recent_signals。",
|
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,
|
call_sites=DRAFT_GENERATOR_CALL_SITES,
|
||||||
),
|
),
|
||||||
_entry(
|
_entry(
|
||||||
|
|
|
||||||
|
|
@ -165,6 +165,7 @@ PROMPT_FEISHU_HUMANIZED_REPLY = """
|
||||||
5. 如果用户询问日期/时间/星期,必须基于“当前系统时间(北京时间)”直接回答,不得编造。
|
5. 如果用户询问日期/时间/星期,必须基于“当前系统时间(北京时间)”直接回答,不得编造。
|
||||||
6. 禁止输出数据库内部实施细节(如数据库迁移、DDL、SQL、ALTER TABLE、DROP TABLE、Alembic)。
|
6. 禁止输出数据库内部实施细节(如数据库迁移、DDL、SQL、ALTER TABLE、DROP TABLE、Alembic)。
|
||||||
7. 当 status/classification 无法证明写入动作已成功时,禁止编造处理步骤。
|
7. 当 status/classification 无法证明写入动作已成功时,禁止编造处理步骤。
|
||||||
|
8. 对于创建项目、接入信号源、创建信号这类动作请求,禁止输出“我来帮你/让我先/我先检查/通过 API 创建”等过程性前缀,必须直接给出执行结果或缺失字段。
|
||||||
|
|
||||||
【输出规范】
|
【输出规范】
|
||||||
- 严禁输出任何思考过程或推理步骤。
|
- 严禁输出任何思考过程或推理步骤。
|
||||||
|
|
|
||||||
|
|
@ -380,8 +380,18 @@ PROMPT_DRAFT_GENERATOR = """
|
||||||
请输出 JSON,包含:
|
请输出 JSON,包含:
|
||||||
- title: 语义化标题(10字以内,必须概括目标,不得复述原话)
|
- title: 语义化标题(10字以内,必须概括目标,不得复述原话)
|
||||||
- summary: 项目定位与需求摘要(80字以内)
|
- summary: 项目定位与需求摘要(80字以内)
|
||||||
|
- positioning_one_liner: 一句话定位(帮助用户快速理解这个项目做什么)
|
||||||
|
- target_customers: 目标客户与使用场景(40字以内)
|
||||||
- similar_projects: 同名/相似项目列表,元素含 project_id/title/difference/risk/reuse_suggestion
|
- similar_projects: 同名/相似项目列表,元素含 project_id/title/difference/risk/reuse_suggestion
|
||||||
|
- core_pain_points: 核心痛点数组,元素含 pain/status/opportunity
|
||||||
- mvp_items: MVP 建议数组,3-5 条
|
- 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 句)
|
- prospect_assessment: 前景判断(1-3 句)
|
||||||
- requirement_items: 需求清单数组,3-8 条
|
- requirement_items: 需求清单数组,3-8 条
|
||||||
- missing_info: 还缺的信息数组,0-5 条
|
- missing_info: 还缺的信息数组,0-5 条
|
||||||
|
|
@ -399,6 +409,7 @@ PROMPT_DRAFT_GENERATOR = """
|
||||||
5. 推荐负责人优先选择负载 <80% 成员;若都 >=80%,选最低负载并明确负载风险。
|
5. 推荐负责人优先选择负载 <80% 成员;若都 >=80%,选最低负载并明确负载风险。
|
||||||
6. followup_plan 最少 1 项,最多 3 项。
|
6. followup_plan 最少 1 项,最多 3 项。
|
||||||
7. 无法确定推荐人时,recommended_assignee_id 返回 null。
|
7. 无法确定推荐人时,recommended_assignee_id 返回 null。
|
||||||
|
8. 如果某个 section 缺少信息,保留空数组或空字符串,不要省略字段。
|
||||||
"""
|
"""
|
||||||
PROMPT_DRAFT_GENERATOR = _compose_prompt(
|
PROMPT_DRAFT_GENERATOR = _compose_prompt(
|
||||||
PMFLOW_PMO_STYLE_BASE,
|
PMFLOW_PMO_STYLE_BASE,
|
||||||
|
|
|
||||||
|
|
@ -62,8 +62,6 @@ async def probe_model_connectivity_on_startup() -> None:
|
||||||
logger.warning(
|
logger.warning(
|
||||||
"startup_model_probe_runtime_resolve_failed",
|
"startup_model_probe_runtime_resolve_failed",
|
||||||
level=level,
|
level=level,
|
||||||
provider=provider,
|
|
||||||
model=model,
|
|
||||||
error=str(resolve_exc),
|
error=str(resolve_exc),
|
||||||
)
|
)
|
||||||
if not isinstance(runtime, dict) or not runtime:
|
if not isinstance(runtime, dict) or not runtime:
|
||||||
|
|
@ -78,11 +76,9 @@ async def probe_model_connectivity_on_startup() -> None:
|
||||||
logger.error(
|
logger.error(
|
||||||
"startup_model_probe_runtime_invalid",
|
"startup_model_probe_runtime_invalid",
|
||||||
level=level,
|
level=level,
|
||||||
provider=provider,
|
|
||||||
model=model,
|
|
||||||
error=str(exc),
|
error=str(exc),
|
||||||
)
|
)
|
||||||
lines.append(f"│ {model:<12} ✗ 失败 (0.0s) │")
|
lines.append(f"│ {level:<12} ✗ 失败 (0.0s) │")
|
||||||
continue
|
continue
|
||||||
|
|
||||||
started_at = time.perf_counter()
|
started_at = time.perf_counter()
|
||||||
|
|
@ -114,8 +110,6 @@ async def probe_model_connectivity_on_startup() -> None:
|
||||||
logger.error(
|
logger.error(
|
||||||
"startup_model_probe_failed",
|
"startup_model_probe_failed",
|
||||||
level=level,
|
level=level,
|
||||||
provider=provider,
|
|
||||||
model=model,
|
|
||||||
error=error_text,
|
error=error_text,
|
||||||
)
|
)
|
||||||
finally:
|
finally:
|
||||||
|
|
@ -123,11 +117,11 @@ async def probe_model_connectivity_on_startup() -> None:
|
||||||
|
|
||||||
elapsed = time.perf_counter() - started_at
|
elapsed = time.perf_counter() - started_at
|
||||||
if ok:
|
if ok:
|
||||||
lines.append(f"│ {model:<12} ✓ 正常 ({elapsed:.1f}s) │")
|
lines.append(f"│ {level:<12} ✓ 正常 ({elapsed:.1f}s) │")
|
||||||
else:
|
else:
|
||||||
lines.append(f"│ {model:<12} ✗ 失败 ({elapsed:.1f}s) │")
|
lines.append(f"│ {level:<12} ✗ 失败 ({elapsed:.1f}s) │")
|
||||||
if error_text:
|
if error_text:
|
||||||
startup_log("模型连通性失败(不阻断启动)", model=model, provider=provider, error=error_text)
|
startup_log("模型连通性失败(不阻断启动)", tier=level.lower(), error=error_text)
|
||||||
|
|
||||||
lines.append("└──────────────────────────────┘")
|
lines.append("└──────────────────────────────┘")
|
||||||
for line in lines:
|
for line in lines:
|
||||||
|
|
|
||||||
|
|
@ -32,3 +32,10 @@ class AICallLog(BaseLog):
|
||||||
tokens_used: Mapped[Optional[int]] = mapped_column(Integer, nullable=True)
|
tokens_used: Mapped[Optional[int]] = mapped_column(Integer, nullable=True)
|
||||||
cost: Mapped[Optional[float]] = mapped_column(DECIMAL(10, 6), nullable=True)
|
cost: Mapped[Optional[float]] = mapped_column(DECIMAL(10, 6), nullable=True)
|
||||||
status: Mapped[str] = mapped_column(VARCHAR(50), nullable=False)
|
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)
|
||||||
|
|
|
||||||
|
|
@ -460,13 +460,17 @@ class KernelIngressFlowEntryMixin:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
result_status = str(decision.get("status") or "").strip().lower()
|
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
|
work_item.state = STATE_SUCCEEDED
|
||||||
else:
|
else:
|
||||||
work_item.state = STATE_FAILED
|
work_item.state = STATE_FAILED
|
||||||
work_item.intent = ROUTE_FOLLOWUP_CONFIRM
|
work_item.intent = ROUTE_FOLLOWUP_CONFIRM
|
||||||
work_item.project_id = _safe_int(decision.get("project_id"))
|
work_item.project_id = _safe_int(decision.get("project_id"))
|
||||||
work_item.signal_id = _safe_int(decision.get("signal_id"))
|
work_item.signal_id = _safe_int(decision.get("signal_id"))
|
||||||
|
draft_pending = work_item.state == STATE_DRAFT_PENDING_CONFIRM
|
||||||
execution_result = {
|
execution_result = {
|
||||||
"status": result_status or "failed_safe",
|
"status": result_status or "failed_safe",
|
||||||
"followup_reply": str(decision.get("followup_reply") or "").strip(),
|
"followup_reply": str(decision.get("followup_reply") or "").strip(),
|
||||||
|
|
@ -480,8 +484,16 @@ class KernelIngressFlowEntryMixin:
|
||||||
work_item.execution_result = execution_result
|
work_item.execution_result = execution_result
|
||||||
work_item.review_summary = {
|
work_item.review_summary = {
|
||||||
"verified": work_item.state == STATE_SUCCEEDED,
|
"verified": work_item.state == STATE_SUCCEEDED,
|
||||||
"issues": [] if work_item.state == STATE_SUCCEEDED else ["draft_confirm_failed"],
|
"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 "请检查草稿确认状态",
|
"next_step": (
|
||||||
|
"已处理草稿确认请求"
|
||||||
|
if work_item.state == STATE_SUCCEEDED
|
||||||
|
else (
|
||||||
|
"等待用户补充修改意见"
|
||||||
|
if draft_pending
|
||||||
|
else "请检查草稿确认状态"
|
||||||
|
)
|
||||||
|
),
|
||||||
}
|
}
|
||||||
followup_text = str(decision.get("followup_reply") or "").strip() or "草稿状态已更新。"
|
followup_text = str(decision.get("followup_reply") or "").strip() or "草稿状态已更新。"
|
||||||
if result_status == "draft_pending_confirm":
|
if result_status == "draft_pending_confirm":
|
||||||
|
|
|
||||||
|
|
@ -30,7 +30,7 @@ class DiscoveryAnalysisMixin:
|
||||||
prompt,
|
prompt,
|
||||||
db=self.db,
|
db=self.db,
|
||||||
tenant_id=self.tenant_id,
|
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,
|
use_prompt_profile=False,
|
||||||
temperature=0.1,
|
temperature=0.1,
|
||||||
max_tokens=min(2800, 520 + max(requested_count, 1) * 420),
|
max_tokens=min(2800, 520 + max(requested_count, 1) * 420),
|
||||||
|
|
|
||||||
|
|
@ -121,7 +121,7 @@ class ExecuteHelpersMixin:
|
||||||
prompt,
|
prompt,
|
||||||
db=self.db,
|
db=self.db,
|
||||||
tenant_id=self.tenant_id,
|
tenant_id=self.tenant_id,
|
||||||
function_name="project_draft_analysis_glm47_fallback",
|
function_name="ai_pmo_signal_title_refine",
|
||||||
use_prompt_profile=False,
|
use_prompt_profile=False,
|
||||||
temperature=0.0,
|
temperature=0.0,
|
||||||
max_tokens=240,
|
max_tokens=240,
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@ for _name, _value in vars(_common).items():
|
||||||
if not _name.startswith("__"):
|
if not _name.startswith("__"):
|
||||||
globals()[_name] = _value
|
globals()[_name] = _value
|
||||||
|
|
||||||
|
from ..ai_steward_service import AIStewardService
|
||||||
from ..signal.draft_generator_service import DraftGeneratorService
|
from ..signal.draft_generator_service import DraftGeneratorService
|
||||||
from ...models.feishu_reply_outbox import FeishuReplyOutbox
|
from ...models.feishu_reply_outbox import FeishuReplyOutbox
|
||||||
|
|
||||||
|
|
@ -327,8 +328,31 @@ class DraftConfirmHandler:
|
||||||
_, route_action = RouteResolver._split_route_key(route_key)
|
_, route_action = RouteResolver._split_route_key(route_key)
|
||||||
draft_payload = locked.draft_content if isinstance(locked.draft_content, dict) else {}
|
draft_payload = locked.draft_content if isinstance(locked.draft_content, dict) else {}
|
||||||
existing_execution = locked.execution_result if isinstance(locked.execution_result, 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:
|
try:
|
||||||
|
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(
|
signal = await self._persist_signal_from_draft(
|
||||||
work_item=locked,
|
work_item=locked,
|
||||||
draft=draft_payload,
|
draft=draft_payload,
|
||||||
|
|
@ -368,7 +392,15 @@ class DraftConfirmHandler:
|
||||||
"workflow_route": route_key,
|
"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:
|
if signal_id is not None:
|
||||||
locked.signal_id = int(signal_id)
|
locked.signal_id = int(signal_id)
|
||||||
locked.state = STATE_SUCCEEDED
|
locked.state = STATE_SUCCEEDED
|
||||||
|
|
@ -378,6 +410,8 @@ class DraftConfirmHandler:
|
||||||
reply_text = self._build_confirm_success_text(
|
reply_text = self._build_confirm_success_text(
|
||||||
draft=draft_payload,
|
draft=draft_payload,
|
||||||
signal_id=_safe_int(locked.signal_id),
|
signal_id=_safe_int(locked.signal_id),
|
||||||
|
project_id=_safe_int(locked.project_id),
|
||||||
|
signal_source_count=signal_source_count,
|
||||||
)
|
)
|
||||||
locked.execution_result = {
|
locked.execution_result = {
|
||||||
**existing_execution,
|
**existing_execution,
|
||||||
|
|
@ -386,6 +420,10 @@ class DraftConfirmHandler:
|
||||||
"workflow_action": route_action,
|
"workflow_action": route_action,
|
||||||
"signal_id": _safe_int(locked.signal_id),
|
"signal_id": _safe_int(locked.signal_id),
|
||||||
"project_id": _safe_int(locked.project_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,
|
"draft_version": current_version,
|
||||||
"followup_reply": reply_text,
|
"followup_reply": reply_text,
|
||||||
"committed_from_draft": True,
|
"committed_from_draft": True,
|
||||||
|
|
@ -471,6 +509,21 @@ class DraftConfirmHandler:
|
||||||
"workflow_route": RouteResolver.normalize_route_key(str(locked.intent or "")),
|
"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 {}
|
ingest_payload = target_ingest.payload if isinstance(target_ingest.payload, dict) else {}
|
||||||
original_message = str(ingest_payload.get("message_text") or "").strip()
|
original_message = str(ingest_payload.get("message_text") or "").strip()
|
||||||
if not original_message:
|
if not original_message:
|
||||||
|
|
@ -776,9 +829,24 @@ class DraftConfirmHandler:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
@staticmethod
|
@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 "需求"
|
title = str(draft.get("title") or "需求").strip() or "需求"
|
||||||
assignee_name = str(draft.get("recommended_assignee_name") or "待指定").strip() or "待指定"
|
assignee_name = str(draft.get("recommended_assignee_name") or "待指定").strip() or "待指定"
|
||||||
|
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 = ["✅ 已入库", "", f"📌 {title}"]
|
lines = ["✅ 已入库", "", f"📌 {title}"]
|
||||||
if signal_id is not None:
|
if signal_id is not None:
|
||||||
lines.append(f"信号 #{int(signal_id)} 已创建")
|
lines.append(f"信号 #{int(signal_id)} 已创建")
|
||||||
|
|
@ -787,6 +855,53 @@ class DraftConfirmHandler:
|
||||||
lines.append(f"负责人:{assignee_name}")
|
lines.append(f"负责人:{assignee_name}")
|
||||||
return "\n".join(lines)
|
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(
|
async def _persist_signal_from_draft(
|
||||||
self,
|
self,
|
||||||
*,
|
*,
|
||||||
|
|
|
||||||
|
|
@ -63,7 +63,6 @@ class AIServiceRuntimeCoreMixin:
|
||||||
"api_key": str(
|
"api_key": str(
|
||||||
api_key
|
api_key
|
||||||
or os.getenv("DASHSCOPE_API_KEY")
|
or os.getenv("DASHSCOPE_API_KEY")
|
||||||
or os.getenv("GLM_API_KEY", "")
|
|
||||||
or ""
|
or ""
|
||||||
).strip(),
|
).strip(),
|
||||||
"model": str(
|
"model": str(
|
||||||
|
|
|
||||||
|
|
@ -47,7 +47,11 @@ _TIER_DEFAULTS = get_tier_defaults()
|
||||||
PROVIDER_DEFAULTS = _PROVIDER_DEFAULTS
|
PROVIDER_DEFAULTS = _PROVIDER_DEFAULTS
|
||||||
TIER_DEFAULTS = _TIER_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_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_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_DASHSCOPE_MODEL = str(_PROVIDER_DEFAULTS.get("glm", {}).get("model") or "qwen-plus")
|
||||||
DEFAULT_GLM_MODEL = DEFAULT_DASHSCOPE_MODEL
|
DEFAULT_GLM_MODEL = DEFAULT_DASHSCOPE_MODEL
|
||||||
|
|
|
||||||
|
|
@ -121,6 +121,7 @@ class AIStewardCreationPersistMixin:
|
||||||
input_text: str,
|
input_text: str,
|
||||||
creator_id: int,
|
creator_id: int,
|
||||||
tenant_id: int,
|
tenant_id: int,
|
||||||
|
requested_member_user_ids: list[int] | None = None,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
project_data = normalized["project"]
|
project_data = normalized["project"]
|
||||||
await ensure_project_id_sequence(self.db)
|
await ensure_project_id_sequence(self.db)
|
||||||
|
|
@ -143,6 +144,7 @@ class AIStewardCreationPersistMixin:
|
||||||
project_id=int(project.id),
|
project_id=int(project.id),
|
||||||
owner_id=int(creator_id),
|
owner_id=int(creator_id),
|
||||||
assigned_role=project.assigned_role,
|
assigned_role=project.assigned_role,
|
||||||
|
requested_member_user_ids=requested_member_user_ids or [],
|
||||||
)
|
)
|
||||||
|
|
||||||
signal_data = normalized["signal"]
|
signal_data = normalized["signal"]
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ class AIStewardCreationPipelineMixin:
|
||||||
input_text: str,
|
input_text: str,
|
||||||
creator_id: int,
|
creator_id: int,
|
||||||
tenant_id: int = 1,
|
tenant_id: int = 1,
|
||||||
|
requested_member_user_ids: list[int] | None = None,
|
||||||
) -> Dict[str, Any]:
|
) -> Dict[str, Any]:
|
||||||
"""零配置项目创建,统一走 draft -> normalize -> persist 三段式。"""
|
"""零配置项目创建,统一走 draft -> normalize -> persist 三段式。"""
|
||||||
analysis = await self._analyze_project_input(input_text, tenant_id)
|
analysis = await self._analyze_project_input(input_text, tenant_id)
|
||||||
|
|
@ -27,6 +28,7 @@ class AIStewardCreationPipelineMixin:
|
||||||
input_text=input_text,
|
input_text=input_text,
|
||||||
creator_id=creator_id,
|
creator_id=creator_id,
|
||||||
tenant_id=tenant_id,
|
tenant_id=tenant_id,
|
||||||
|
requested_member_user_ids=requested_member_user_ids,
|
||||||
)
|
)
|
||||||
|
|
||||||
await self.db.commit()
|
await self.db.commit()
|
||||||
|
|
@ -94,4 +96,3 @@ class AIStewardCreationPipelineMixin:
|
||||||
"tasks": task_drafts,
|
"tasks": task_drafts,
|
||||||
"risks": self._build_risk_alert_drafts(analysis=analysis),
|
"risks": self._build_risk_alert_drafts(analysis=analysis),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,7 @@ class AIStewardDraftGenerationMixin:
|
||||||
analysis = await self._generate_project_creation_object(
|
analysis = await self._generate_project_creation_object(
|
||||||
prompt=prompt,
|
prompt=prompt,
|
||||||
tenant_id=tenant_id,
|
tenant_id=tenant_id,
|
||||||
function_name="project_requirement_extraction_glm47_fallback",
|
function_name="project_requirement_extraction",
|
||||||
stage_label="AI 管家项目需求分析失败",
|
stage_label="AI 管家项目需求分析失败",
|
||||||
)
|
)
|
||||||
return analysis
|
return analysis
|
||||||
|
|
@ -54,7 +54,7 @@ class AIStewardDraftGenerationMixin:
|
||||||
payload = await self._generate_project_creation_object(
|
payload = await self._generate_project_creation_object(
|
||||||
prompt=prompt,
|
prompt=prompt,
|
||||||
tenant_id=tenant_id,
|
tenant_id=tenant_id,
|
||||||
function_name="project_draft_signal_glm47_fallback",
|
function_name="project_from_text_signal",
|
||||||
stage_label="AI 管家竞品信号生成失败",
|
stage_label="AI 管家竞品信号生成失败",
|
||||||
)
|
)
|
||||||
return payload
|
return payload
|
||||||
|
|
@ -97,7 +97,7 @@ class AIStewardDraftGenerationMixin:
|
||||||
charter_data = await self._generate_project_creation_object(
|
charter_data = await self._generate_project_creation_object(
|
||||||
prompt=prompt,
|
prompt=prompt,
|
||||||
tenant_id=tenant_id,
|
tenant_id=tenant_id,
|
||||||
function_name="project_draft_charter_glm47_fallback",
|
function_name="project_from_text_charter",
|
||||||
stage_label="AI 管家项目宪章生成失败",
|
stage_label="AI 管家项目宪章生成失败",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -152,7 +152,7 @@ class AIStewardDraftGenerationMixin:
|
||||||
tasks_data = await self._generate_project_creation_object(
|
tasks_data = await self._generate_project_creation_object(
|
||||||
prompt=prompt,
|
prompt=prompt,
|
||||||
tenant_id=tenant_id,
|
tenant_id=tenant_id,
|
||||||
function_name="project_draft_task_chain_glm47_fallback",
|
function_name="project_from_text_task_chain",
|
||||||
stage_label="AI 管家任务链生成失败",
|
stage_label="AI 管家任务链生成失败",
|
||||||
timeout_seconds=PROJECT_CREATION_TASK_CHAIN_TIMEOUT_SECONDS,
|
timeout_seconds=PROJECT_CREATION_TASK_CHAIN_TIMEOUT_SECONDS,
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -10,10 +10,6 @@ class AIStewardJsonParsingMixin:
|
||||||
return True
|
return True
|
||||||
return any(marker in normalized for marker in PROJECT_CREATION_STUB_MARKERS)
|
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
|
@staticmethod
|
||||||
def _strip_code_fence(text: str) -> str:
|
def _strip_code_fence(text: str) -> str:
|
||||||
raw = str(text or "").strip()
|
raw = str(text or "").strip()
|
||||||
|
|
@ -190,47 +186,6 @@ class AIStewardJsonParsingMixin:
|
||||||
return cleaned_response
|
return cleaned_response
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
last_error = exc if isinstance(exc, Exception) else RuntimeError(str(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(
|
logger.warning(
|
||||||
"ai_steward_project_creation_step_retry",
|
"ai_steward_project_creation_step_retry",
|
||||||
tenant_id=int(tenant_id),
|
tenant_id=int(tenant_id),
|
||||||
|
|
@ -244,4 +199,3 @@ class AIStewardJsonParsingMixin:
|
||||||
raise RuntimeError(
|
raise RuntimeError(
|
||||||
f"AI 管家项目创建步骤失败:function={function_name}, reason={last_error or 'unknown_error'}"
|
f"AI 管家项目创建步骤失败:function={function_name}, reason={last_error or 'unknown_error'}"
|
||||||
) from last_error
|
) from last_error
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
PROJECT_CREATION_SERIAL_DRAFT_STEPS = _env_int("AI_STEWARD_PROJECT_CREATION_SERIAL_DRAFT_STEPS", 1) == 1
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -101,7 +101,7 @@ class FeishuReceiveCaptureFlowMixin:
|
||||||
f"auto_tasks={len(result.get('auto_tasks', []) if isinstance(result.get('auto_tasks', []), list) else [])}, "
|
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')}"
|
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(
|
self._emit_state_transition(
|
||||||
trace_id=state_trace_id,
|
trace_id=state_trace_id,
|
||||||
state="CLASSIFIED",
|
state="CLASSIFIED",
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from .shared import *
|
from .shared import *
|
||||||
|
from ...ai_pmo.ai_pmo_intent import IntentResolver
|
||||||
|
|
||||||
class FeishuReceiveRoutingMixin:
|
class FeishuReceiveRoutingMixin:
|
||||||
async def _route_scene_reply(
|
async def _route_scene_reply(
|
||||||
|
|
@ -147,11 +148,44 @@ class FeishuReceiveRoutingMixin:
|
||||||
return reply_route
|
return reply_route
|
||||||
|
|
||||||
@staticmethod
|
@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):
|
if not isinstance(capture_result, dict):
|
||||||
return "error"
|
return "error"
|
||||||
|
intent = str(capture_result.get("intent") or "").strip().lower()
|
||||||
status = str(capture_result.get("status") or "").strip().lower()
|
status = str(capture_result.get("status") or "").strip().lower()
|
||||||
classification = str(capture_result.get("classification") 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"}:
|
if status in {"captured", "ignored"}:
|
||||||
return "execute"
|
return "execute"
|
||||||
if status == "answered" or classification in {"question", "qa"}:
|
if status == "answered" or classification in {"question", "qa"}:
|
||||||
|
|
|
||||||
|
|
@ -62,6 +62,22 @@ class FeishuReplyLLMCoreMixin:
|
||||||
)
|
)
|
||||||
return
|
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:
|
try:
|
||||||
status = str(capture_result.get("status") or "unknown")
|
status = str(capture_result.get("status") or "unknown")
|
||||||
classification = str(capture_result.get("classification") or "unknown")
|
classification = str(capture_result.get("classification") or "unknown")
|
||||||
|
|
@ -151,12 +167,20 @@ class FeishuReplyLLMCoreMixin:
|
||||||
paragraphs = lines
|
paragraphs = lines
|
||||||
|
|
||||||
thinking_prefixes = (
|
thinking_prefixes = (
|
||||||
|
"好的,我来帮你",
|
||||||
|
"我来帮你",
|
||||||
|
"我先帮你",
|
||||||
"让我先",
|
"让我先",
|
||||||
"让我尝试",
|
"让我尝试",
|
||||||
"我来看看",
|
"我来看看",
|
||||||
"我先查",
|
"我先查",
|
||||||
|
"我先检查",
|
||||||
"让我看看",
|
"让我看看",
|
||||||
"我需要先",
|
"我需要先",
|
||||||
|
"我会先",
|
||||||
|
"通过api创建",
|
||||||
|
"通过 api 创建",
|
||||||
|
"后端服务是否运行",
|
||||||
)
|
)
|
||||||
|
|
||||||
first_clean_idx: Optional[int] = None
|
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
|
@staticmethod
|
||||||
def _strip_identity_lead(reply_text: str) -> str:
|
def _strip_identity_lead(reply_text: str) -> str:
|
||||||
text = str(reply_text or "").strip()
|
text = str(reply_text or "").strip()
|
||||||
|
|
|
||||||
|
|
@ -83,18 +83,15 @@ class LLMChatOpenAIMixin:
|
||||||
"LLM",
|
"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"retry_in={retry_delay:.2f}s, left={network_retry_budget - 1}, "
|
||||||
f"error={type(exc).__name__}"
|
f"error={type(exc).__name__}"
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
logger.warning(
|
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,
|
request_trace_id,
|
||||||
function_label,
|
function_label,
|
||||||
provider_name,
|
|
||||||
model,
|
|
||||||
str(base_url or "")[:120],
|
|
||||||
network_retry_attempt,
|
network_retry_attempt,
|
||||||
retry_delay,
|
retry_delay,
|
||||||
type(exc).__name__,
|
type(exc).__name__,
|
||||||
|
|
@ -140,7 +137,7 @@ class LLMChatOpenAIMixin:
|
||||||
"LLM",
|
"LLM",
|
||||||
(
|
(
|
||||||
"HTTP状态重试:"
|
"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"status={status_code}, retry_in={retry_delay:.2f}s, "
|
||||||
f"left={http_status_retry_budget - 1}"
|
f"left={http_status_retry_budget - 1}"
|
||||||
),
|
),
|
||||||
|
|
@ -156,8 +153,7 @@ class LLMChatOpenAIMixin:
|
||||||
"LLM",
|
"LLM",
|
||||||
(
|
(
|
||||||
"结构化参数降级重试:"
|
"结构化参数降级重试:"
|
||||||
f"function={function_label}, provider={provider_name}, model={model}, "
|
f"function={function_label}, status={status_code}"
|
||||||
f"status={status_code}"
|
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
use_json_response_format = False
|
use_json_response_format = False
|
||||||
|
|
@ -196,8 +192,7 @@ class LLMChatOpenAIMixin:
|
||||||
"LLM",
|
"LLM",
|
||||||
(
|
(
|
||||||
"空content定向重试:"
|
"空content定向重试:"
|
||||||
f"function={function_label}, provider={provider_name}, model={model}, "
|
f"function={function_label}, retry_with=请直接输出答案"
|
||||||
"retry_with=请直接输出答案"
|
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
continue
|
continue
|
||||||
|
|
@ -216,9 +211,6 @@ class LLMChatOpenAIMixin:
|
||||||
"llm_timeout_payload_detected",
|
"llm_timeout_payload_detected",
|
||||||
trace_id=request_trace_id,
|
trace_id=request_trace_id,
|
||||||
function_name=function_label,
|
function_name=function_label,
|
||||||
provider=provider_name,
|
|
||||||
model=model,
|
|
||||||
base_url=str(base_url or "")[:120],
|
|
||||||
request_timeout_seconds=float(request_timeout),
|
request_timeout_seconds=float(request_timeout),
|
||||||
agent_timeout_seconds=openclaw_agent_timeout,
|
agent_timeout_seconds=openclaw_agent_timeout,
|
||||||
text_preview=text.replace("\n", " ")[:260],
|
text_preview=text.replace("\n", " ")[:260],
|
||||||
|
|
|
||||||
|
|
@ -63,7 +63,7 @@ class LLMChatTransportMixin:
|
||||||
model=model,
|
model=model,
|
||||||
)
|
)
|
||||||
if use_provider_circuit_breaker and self._is_provider_circuit_open(circuit_key):
|
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()
|
key = api_key.strip()
|
||||||
messages: list[dict[str, str]] = []
|
messages: list[dict[str, str]] = []
|
||||||
cleaned_system_prompt = self._inject_function_guardrails(
|
cleaned_system_prompt = self._inject_function_guardrails(
|
||||||
|
|
@ -83,7 +83,7 @@ class LLMChatTransportMixin:
|
||||||
"OpenClaw调用",
|
"OpenClaw调用",
|
||||||
(
|
(
|
||||||
f"start ts={_cn_now_text_ms()} trace_id={request_trace_id} "
|
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"request_timeout={float(request_timeout):.2f}s "
|
||||||
f"agent_timeout={openclaw_agent_timeout if openclaw_agent_timeout is not None else 'N/A'}"
|
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",
|
"llm_timeout_exception_detected",
|
||||||
trace_id=request_trace_id,
|
trace_id=request_trace_id,
|
||||||
function_name=function_label,
|
function_name=function_label,
|
||||||
provider=provider_name,
|
|
||||||
model=model,
|
|
||||||
base_url=str(base_url or "")[:120],
|
|
||||||
request_timeout_seconds=float(request_timeout),
|
request_timeout_seconds=float(request_timeout),
|
||||||
agent_timeout_seconds=openclaw_agent_timeout,
|
agent_timeout_seconds=openclaw_agent_timeout,
|
||||||
error_type=type(exc).__name__,
|
error_type=type(exc).__name__,
|
||||||
|
|
@ -191,4 +188,3 @@ class LLMChatTransportMixin:
|
||||||
if not text and not isinstance(data, dict):
|
if not text and not isinstance(data, dict):
|
||||||
raise ValueError("Ollama 返回空内容")
|
raise ValueError("Ollama 返回空内容")
|
||||||
return ChatCompletionResult(text=text, raw_response=data)
|
return ChatCompletionResult(text=text, raw_response=data)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -163,6 +163,15 @@ class LLMEngineResolutionCoreMixin:
|
||||||
)
|
)
|
||||||
|
|
||||||
if not self._mixed_routing_enabled():
|
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(
|
return self._ensure_engine_allowed_for_function(
|
||||||
engine=active_engine,
|
engine=active_engine,
|
||||||
function_name=function_name,
|
function_name=function_name,
|
||||||
|
|
@ -172,6 +181,14 @@ class LLMEngineResolutionCoreMixin:
|
||||||
|
|
||||||
tier = self._resolve_function_tier(function_name)
|
tier = self._resolve_function_tier(function_name)
|
||||||
if tier not in {"high", "low"}:
|
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(
|
return self._ensure_engine_allowed_for_function(
|
||||||
engine=active_engine,
|
engine=active_engine,
|
||||||
function_name=function_name,
|
function_name=function_name,
|
||||||
|
|
@ -191,12 +208,22 @@ class LLMEngineResolutionCoreMixin:
|
||||||
settings=settings,
|
settings=settings,
|
||||||
engine_label="active_engine",
|
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(
|
_runtime_log(
|
||||||
"LLM路由",
|
"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(
|
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
|
override = mappings.get(fn) if isinstance(mappings.get(fn), dict) else None
|
||||||
if not override or not bool(override.get("enabled", True)):
|
if not override or not bool(override.get("enabled", True)):
|
||||||
return None
|
return None
|
||||||
rule = {
|
tier_alias = _optional_tier_alias(override.get("tier_alias") or override.get("tier")) or "high"
|
||||||
"tier": str(override.get("tier") or "high").strip().lower() or "high",
|
config_bundle = load_model_config()
|
||||||
"provider": str(override.get("provider") or "").strip().lower(),
|
matched_rule = find_matching_rule(
|
||||||
"model": str(override.get("model") or "").strip(),
|
fn,
|
||||||
"api_env": str(override.get("api_env") or "").strip(),
|
tier_filter={tier_alias},
|
||||||
"base_url": str(override.get("base_url") or "").strip(),
|
bundle=config_bundle,
|
||||||
}
|
)
|
||||||
routed = self._resolve_engine_from_rule(
|
routed = self._resolve_tier_engine(
|
||||||
rule=rule,
|
tier=tier_alias,
|
||||||
engines=engines,
|
engines=engines,
|
||||||
active_engine=active_engine,
|
active_engine=active_engine,
|
||||||
)
|
)
|
||||||
if routed is None:
|
if routed is None:
|
||||||
return 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 "")
|
provider = str(routed.get("provider") or "")
|
||||||
if provider != "ollama" and not str(routed.get("api_key") or "").strip():
|
if provider != "ollama" and not str(routed.get("api_key") or "").strip():
|
||||||
raise ValueError(
|
raise ValueError(
|
||||||
"函数覆盖路由命中但目标引擎缺少 API Key:"
|
"函数覆盖路由命中但目标 tier 缺少 API Key:"
|
||||||
f"function={fn}, provider={provider}, model={routed.get('model')}"
|
f"function={fn}, tier_alias={tier_alias}"
|
||||||
)
|
)
|
||||||
_runtime_log(
|
_runtime_log(
|
||||||
"LLM路由",
|
"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)
|
return self._ensure_openclaw_control_plane_engine(routed)
|
||||||
|
|
@ -278,4 +318,3 @@ class LLMEngineResolutionCoreMixin:
|
||||||
if engines:
|
if engines:
|
||||||
return self._normalize_engine_runtime(engines[0])
|
return self._normalize_engine_runtime(engines[0])
|
||||||
return self._env_engine_runtime()
|
return self._env_engine_runtime()
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -30,15 +30,13 @@ class LLMGenerateDbChatMixin:
|
||||||
|
|
||||||
def _log_stub_failure(error_obj: Exception, *, used_stub: bool = False) -> None:
|
def _log_stub_failure(error_obj: Exception, *, used_stub: bool = False) -> None:
|
||||||
elapsed_ms = (time.perf_counter() - started_at) * 1000
|
elapsed_ms = (time.perf_counter() - started_at) * 1000
|
||||||
provider = str(engine_meta.get("provider") or "")
|
routing_meta = _read_engine_routing_meta(engine_meta)
|
||||||
model = str(engine_meta.get("model") or "")
|
|
||||||
outcome = "兜底 stub" if used_stub else "显式报错"
|
outcome = "兜底 stub" if used_stub else "显式报错"
|
||||||
logger.warning(
|
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,
|
outcome,
|
||||||
function_name,
|
function_name,
|
||||||
provider,
|
_routing_meta_log_text(routing_meta) or "unknown",
|
||||||
model,
|
|
||||||
error_obj,
|
error_obj,
|
||||||
elapsed_ms,
|
elapsed_ms,
|
||||||
)
|
)
|
||||||
|
|
@ -91,7 +89,7 @@ class LLMGenerateDbChatMixin:
|
||||||
error=exc,
|
error=exc,
|
||||||
)
|
)
|
||||||
if failover_response and isinstance(failover_engine, dict):
|
if failover_response and isinstance(failover_engine, dict):
|
||||||
routing_decision = "glm47_error_escalate_to_high"
|
routing_decision = "low_to_high_escalated"
|
||||||
status = "success_failover"
|
status = "success_failover"
|
||||||
response_text = failover_response
|
response_text = failover_response
|
||||||
engine_meta = failover_engine
|
engine_meta = failover_engine
|
||||||
|
|
@ -99,8 +97,7 @@ class LLMGenerateDbChatMixin:
|
||||||
status = "failed"
|
status = "failed"
|
||||||
reason_text = self._build_model_error_message(
|
reason_text = self._build_model_error_message(
|
||||||
function_name=function_name,
|
function_name=function_name,
|
||||||
provider=str(engine_meta.get("provider") or ""),
|
routing_meta=_read_engine_routing_meta(engine_meta),
|
||||||
model=str(engine_meta.get("model") or ""),
|
|
||||||
error=exc,
|
error=exc,
|
||||||
)
|
)
|
||||||
response_text = reason_text
|
response_text = reason_text
|
||||||
|
|
@ -121,15 +118,19 @@ class LLMGenerateDbChatMixin:
|
||||||
status=status,
|
status=status,
|
||||||
provider=str(engine_meta.get("provider") or ""),
|
provider=str(engine_meta.get("provider") or ""),
|
||||||
model=str(engine_meta.get("model") 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(
|
_runtime_log(
|
||||||
"LLM路由",
|
"LLM路由",
|
||||||
(
|
(
|
||||||
f"routing_decision={routing_decision}, "
|
f"function={function_name}, {_routing_meta_log_text(routing_meta)}"
|
||||||
f"function={function_name}, provider={engine_meta.get('provider')}, model={engine_meta.get('model')}"
|
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
except Exception as log_exc:
|
except Exception as log_exc:
|
||||||
_runtime_log("LLM", f"调用日志写入失败:{log_exc}")
|
_runtime_log("LLM", f"调用日志写入失败:{log_exc}")
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -136,6 +136,7 @@ class LLMGenerateDbStructuredMixin:
|
||||||
error=exc,
|
error=exc,
|
||||||
)
|
)
|
||||||
if isinstance(failover_result, StructuredExtractionResult) and isinstance(failover_engine, dict):
|
if isinstance(failover_result, StructuredExtractionResult) and isinstance(failover_engine, dict):
|
||||||
|
engine_meta = failover_engine
|
||||||
provider = str(failover_engine.get("provider") or "")
|
provider = str(failover_engine.get("provider") or "")
|
||||||
model = str(failover_engine.get("model") or "")
|
model = str(failover_engine.get("model") or "")
|
||||||
response_text = failover_result.raw_text or json.dumps(failover_result.payload or {}, ensure_ascii=False)
|
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
|
status = error_code
|
||||||
reason_text = self._build_model_error_message(
|
reason_text = self._build_model_error_message(
|
||||||
function_name=function_name,
|
function_name=function_name,
|
||||||
provider=provider,
|
routing_meta=_read_engine_routing_meta(engine_meta),
|
||||||
model=model,
|
|
||||||
error=exc,
|
error=exc,
|
||||||
)
|
)
|
||||||
response_text = reason_text
|
response_text = reason_text
|
||||||
|
|
@ -166,6 +166,7 @@ class LLMGenerateDbStructuredMixin:
|
||||||
status=status,
|
status=status,
|
||||||
provider=provider,
|
provider=provider,
|
||||||
model=model,
|
model=model,
|
||||||
|
routing_meta=_read_engine_routing_meta(engine_meta),
|
||||||
)
|
)
|
||||||
except Exception as log_exc:
|
except Exception as log_exc:
|
||||||
_runtime_log("LLM", f"结构化调用日志写入失败:{log_exc}")
|
_runtime_log("LLM", f"结构化调用日志写入失败:{log_exc}")
|
||||||
|
|
@ -247,8 +248,7 @@ class LLMGenerateDbStructuredMixin:
|
||||||
"LLM结构化",
|
"LLM结构化",
|
||||||
(
|
(
|
||||||
"模型结构化修复失败:"
|
"模型结构化修复失败:"
|
||||||
f"function={function_name}, provider={provider}, model={model}, "
|
f"function={function_name}, error={type(exc).__name__}:{str(exc)[:180]}"
|
||||||
f"error={type(exc).__name__}:{str(exc)[:180]}"
|
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
return None
|
return None
|
||||||
|
|
|
||||||
|
|
@ -64,22 +64,20 @@ class LLMGuardrailsPromptMixin:
|
||||||
def _build_model_error_message(
|
def _build_model_error_message(
|
||||||
*,
|
*,
|
||||||
function_name: str,
|
function_name: str,
|
||||||
provider: str,
|
routing_meta: dict[str, Any] | None = None,
|
||||||
model: str,
|
|
||||||
error: Exception,
|
error: Exception,
|
||||||
) -> str:
|
) -> str:
|
||||||
clean_error = re.sub(r"\s+", " ", str(error or "")).strip()
|
clean_error = re.sub(r"\s+", " ", str(error or "")).strip()
|
||||||
if len(clean_error) > 160:
|
if len(clean_error) > 160:
|
||||||
clean_error = f"{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"
|
fn_text = str(function_name or "llm_generate").strip() or "llm_generate"
|
||||||
|
meta = _normalize_routing_meta(routing_meta)
|
||||||
if not clean_error:
|
if not clean_error:
|
||||||
clean_error = "upstream_empty_or_invalid_response"
|
clean_error = "upstream_empty_or_invalid_response"
|
||||||
return (
|
route_bits = _routing_meta_log_text(meta)
|
||||||
f"模型调用失败:function={fn_text}, provider={provider_text}, model={model_text}, "
|
if route_bits:
|
||||||
f"reason={clean_error}"
|
return f"模型调用失败:function={fn_text}, {route_bits}, reason={clean_error}"
|
||||||
)
|
return f"模型调用失败:function={fn_text}, reason={clean_error}"
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _is_fast_local_planner_function(function_name: str) -> bool:
|
def _is_fast_local_planner_function(function_name: str) -> bool:
|
||||||
|
|
|
||||||
|
|
@ -32,26 +32,7 @@ class LLMOpenclawRoutingMixin:
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _rule_matches_function(rule: dict[str, Any], function_name: str) -> bool:
|
def _rule_matches_function(rule: dict[str, Any], function_name: str) -> bool:
|
||||||
if bool(rule.get("match_all", False)):
|
return rule_matches_function(rule, function_name)
|
||||||
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
|
|
||||||
|
|
||||||
def _match_openclaw_rule(
|
def _match_openclaw_rule(
|
||||||
self,
|
self,
|
||||||
|
|
@ -62,29 +43,11 @@ class LLMOpenclawRoutingMixin:
|
||||||
if not self._openclaw_routing_enabled():
|
if not self._openclaw_routing_enabled():
|
||||||
return None
|
return None
|
||||||
bundle = self._load_openclaw_routing_bundle()
|
bundle = self._load_openclaw_routing_bundle()
|
||||||
rules = bundle.get("rules") if isinstance(bundle.get("rules"), list) else []
|
return find_matching_rule(
|
||||||
if not rules:
|
function_name,
|
||||||
return None
|
tier_filter=tier_filter,
|
||||||
|
bundle=bundle,
|
||||||
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])
|
|
||||||
|
|
||||||
def _resolve_openclaw_engine(
|
def _resolve_openclaw_engine(
|
||||||
self,
|
self,
|
||||||
|
|
@ -104,13 +67,27 @@ class LLMOpenclawRoutingMixin:
|
||||||
)
|
)
|
||||||
if routed is None:
|
if routed is None:
|
||||||
return 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(
|
_runtime_log(
|
||||||
"OpenClaw路由",
|
"OpenClaw路由",
|
||||||
(
|
(
|
||||||
"规则命中:"
|
"规则命中:"
|
||||||
f"function={function_name}, provider={routed.get('provider')}, "
|
f"function={function_name}, {_routing_meta_log_text(_read_engine_routing_meta(routed))}"
|
||||||
f"model={routed.get('model')}, rule={rule.get('name') or 'unnamed'}"
|
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
return routed
|
return routed
|
||||||
|
|
@ -152,7 +129,7 @@ class LLMOpenclawRoutingMixin:
|
||||||
except Exception:
|
except Exception:
|
||||||
routed = None
|
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(
|
routed = self._resolve_tier_engine(
|
||||||
tier=tier,
|
tier=tier,
|
||||||
engines=engines,
|
engines=engines,
|
||||||
|
|
@ -176,7 +153,7 @@ class LLMOpenclawRoutingMixin:
|
||||||
def _resolve_function_tier(self, function_name: str) -> str:
|
def _resolve_function_tier(self, function_name: str) -> str:
|
||||||
fn = str(function_name or "").strip().lower()
|
fn = str(function_name or "").strip().lower()
|
||||||
if not fn:
|
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", ""))
|
high_hints = _parse_csv_items(os.getenv("LLM_ROUTING_HIGH_FUNCTIONS", ""))
|
||||||
low_hints = _parse_csv_items(os.getenv("LLM_ROUTING_LOW_FUNCTIONS", ""))
|
low_hints = _parse_csv_items(os.getenv("LLM_ROUTING_LOW_FUNCTIONS", ""))
|
||||||
|
|
@ -187,8 +164,7 @@ class LLMOpenclawRoutingMixin:
|
||||||
return "high"
|
return "high"
|
||||||
if any(hint and hint in fn for hint in low_pool):
|
if any(hint and hint in fn for hint in low_pool):
|
||||||
return "low"
|
return "low"
|
||||||
default_tier = str(os.getenv("LLM_ROUTING_DEFAULT_TIER", "active") or "active").strip().lower()
|
default_tier = str(os.getenv("LLM_ROUTING_DEFAULT_TIER", "low") or "low").strip().lower()
|
||||||
if default_tier != "active":
|
if default_tier in {"low", "high"}:
|
||||||
return default_tier
|
return default_tier
|
||||||
# mixed routing 开启时,未知函数默认走 high,避免被 active 引擎“全吞”。
|
return "high" if self._mixed_routing_enabled() else "low"
|
||||||
return "high" if self._mixed_routing_enabled() else "active"
|
|
||||||
|
|
|
||||||
|
|
@ -105,15 +105,13 @@ class LLMPromptProfileRuntimeMixin:
|
||||||
api_env = str(defaults.get("api_env") or "").strip()
|
api_env = str(defaults.get("api_env") or "").strip()
|
||||||
api_key = os.getenv(api_env, "") if api_env else ""
|
api_key = os.getenv(api_env, "") if api_env else ""
|
||||||
if provider_name == "glm":
|
if provider_name == "glm":
|
||||||
api_key = os.getenv(
|
api_key = os.getenv("DASHSCOPE_API_KEY", api_key)
|
||||||
"DASHSCOPE_API_KEY",
|
|
||||||
os.getenv("GLM_API_KEY", os.getenv("ZHIPUAI_API_KEY", api_key)),
|
|
||||||
)
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"id": f"env-{provider_name}",
|
"id": f"env-{provider_name}",
|
||||||
"name": f"{defaults['name']} (ENV)",
|
"name": f"{defaults['name']} (ENV)",
|
||||||
"provider": provider_name,
|
"provider": provider_name,
|
||||||
|
"api_env": api_env,
|
||||||
"api_key": str(api_key or "").strip(),
|
"api_key": str(api_key or "").strip(),
|
||||||
"base_url": str(defaults["base_url"]).strip().rstrip("/"),
|
"base_url": str(defaults["base_url"]).strip().rstrip("/"),
|
||||||
"model": str(defaults["model"]).strip(),
|
"model": str(defaults["model"]).strip(),
|
||||||
|
|
@ -136,6 +134,7 @@ class LLMPromptProfileRuntimeMixin:
|
||||||
runtime["name"] = str(runtime.get("name") or defaults["name"]).strip()
|
runtime["name"] = str(runtime.get("name") or defaults["name"]).strip()
|
||||||
if not runtime.get("id"):
|
if not runtime.get("id"):
|
||||||
runtime["id"] = str(uuid4())
|
runtime["id"] = str(uuid4())
|
||||||
|
runtime = _overlay_engine_api_key_from_env(runtime)
|
||||||
return runtime
|
return runtime
|
||||||
|
|
||||||
async def _log_ai_call(
|
async def _log_ai_call(
|
||||||
|
|
@ -148,9 +147,11 @@ class LLMPromptProfileRuntimeMixin:
|
||||||
status: str,
|
status: str,
|
||||||
provider: str = "",
|
provider: str = "",
|
||||||
model: str = "",
|
model: str = "",
|
||||||
|
routing_meta: dict[str, Any] | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
tokens_used = max(len(prompt) // 4, 1) + max(len(response) // 4, 0)
|
tokens_used = max(len(prompt) // 4, 1) + max(len(response) // 4, 0)
|
||||||
cost = self._estimate_cost(provider=provider, model=model, tokens=tokens_used)
|
cost = self._estimate_cost(provider=provider, model=model, tokens=tokens_used)
|
||||||
|
meta = _normalize_routing_meta(routing_meta)
|
||||||
row_payload = {
|
row_payload = {
|
||||||
"tenant_id": tenant_id,
|
"tenant_id": tenant_id,
|
||||||
"user_id": None,
|
"user_id": None,
|
||||||
|
|
@ -160,6 +161,13 @@ class LLMPromptProfileRuntimeMixin:
|
||||||
"tokens_used": tokens_used,
|
"tokens_used": tokens_used,
|
||||||
"cost": cost,
|
"cost": cost,
|
||||||
"status": status,
|
"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:
|
async with AsyncSessionLocal() as session:
|
||||||
for attempt in range(2):
|
for attempt in range(2):
|
||||||
|
|
|
||||||
|
|
@ -172,7 +172,7 @@ class LLMStructuredParseMixin:
|
||||||
# P0 guardrail: user-facing answer/reply/qa must never expose reasoning_content.
|
# 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")):
|
if fn and any(token in fn for token in ("answer", "reply", "qa")):
|
||||||
return False
|
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
|
return True
|
||||||
raw = str(os.getenv("LLM_REASONING_CONTENT_FALLBACK", "0")).strip().lower()
|
raw = str(os.getenv("LLM_REASONING_CONTENT_FALLBACK", "0")).strip().lower()
|
||||||
if raw not in {"1", "true", "yes", "on"}:
|
if raw not in {"1", "true", "yes", "on"}:
|
||||||
|
|
|
||||||
|
|
@ -38,99 +38,6 @@ class LLMTierEngineSelectionMixin:
|
||||||
return None
|
return None
|
||||||
return routed
|
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(
|
def _pick_tier_fallback_engine(
|
||||||
self,
|
self,
|
||||||
*,
|
*,
|
||||||
|
|
@ -152,7 +59,7 @@ class LLMTierEngineSelectionMixin:
|
||||||
"LLM路由",
|
"LLM路由",
|
||||||
(
|
(
|
||||||
"分层路由回退引擎生效:"
|
"分层路由回退引擎生效:"
|
||||||
f"tier={tier}, provider={provider}, model={candidate.get('model')}"
|
f"tier={tier}, decision=tier_fallback_engine"
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
return candidate
|
return candidate
|
||||||
|
|
@ -166,6 +73,8 @@ class LLMTierEngineSelectionMixin:
|
||||||
active_engine: dict[str, Any],
|
active_engine: dict[str, Any],
|
||||||
) -> Optional[dict[str, Any]]:
|
) -> Optional[dict[str, Any]]:
|
||||||
tier = str(tier or "").strip().lower()
|
tier = str(tier or "").strip().lower()
|
||||||
|
if tier not in {"low", "high"}:
|
||||||
|
return None
|
||||||
tier_defaults = dict(TIER_DEFAULTS.get(tier) or {})
|
tier_defaults = dict(TIER_DEFAULTS.get(tier) or {})
|
||||||
provider_env = str(tier_defaults.get("provider") or "glm").strip()
|
provider_env = str(tier_defaults.get("provider") or "glm").strip()
|
||||||
|
|
||||||
|
|
@ -195,12 +104,21 @@ class LLMTierEngineSelectionMixin:
|
||||||
if tier_defaults.get("base_url"):
|
if tier_defaults.get("base_url"):
|
||||||
routed["base_url"] = str(tier_defaults.get("base_url") or routed.get("base_url") or "").strip().rstrip("/")
|
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 ""):
|
if str(routed.get("id") or "") != str(active_engine.get("id") or ""):
|
||||||
_runtime_log(
|
_runtime_log(
|
||||||
"LLM路由",
|
"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)
|
return self._ensure_openclaw_control_plane_engine(routed)
|
||||||
|
|
|
||||||
|
|
@ -33,6 +33,23 @@ class LLMTierEscalationMixin:
|
||||||
return True
|
return True
|
||||||
return any(marker in lowered for marker in LOW_TO_HIGH_ESCALATION_FALLBACK_MARKERS)
|
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:
|
def _should_escalate_low_to_high(self, *, function_name: str, response_text: str) -> bool:
|
||||||
if not self._low_to_high_escalation_enabled():
|
if not self._low_to_high_escalation_enabled():
|
||||||
return False
|
return False
|
||||||
|
|
@ -40,23 +57,7 @@ class LLMTierEscalationMixin:
|
||||||
return False
|
return False
|
||||||
return self._looks_like_unanswerable_response(response_text)
|
return self._looks_like_unanswerable_response(response_text)
|
||||||
|
|
||||||
@staticmethod
|
def _should_retry_low_tier_with_high_tier(
|
||||||
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(
|
|
||||||
self,
|
self,
|
||||||
*,
|
*,
|
||||||
function_name: str,
|
function_name: str,
|
||||||
|
|
@ -64,16 +65,10 @@ class LLMTierEscalationMixin:
|
||||||
) -> bool:
|
) -> bool:
|
||||||
if not self._low_to_high_escalation_enabled():
|
if not self._low_to_high_escalation_enabled():
|
||||||
return False
|
return False
|
||||||
failed_model = str(failed_engine.get("model") or "").strip()
|
_ = failed_engine
|
||||||
if self._is_glm_47_model(failed_model):
|
return self._is_low_tier_function(function_name)
|
||||||
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
|
|
||||||
|
|
||||||
def _prefer_glm5_high_tier_retry_engine(
|
def _prefer_high_tier_retry_engine(
|
||||||
self,
|
self,
|
||||||
*,
|
*,
|
||||||
bundle: dict[str, Any],
|
bundle: dict[str, Any],
|
||||||
|
|
@ -82,12 +77,12 @@ class LLMTierEscalationMixin:
|
||||||
failed_engine: dict[str, Any],
|
failed_engine: dict[str, Any],
|
||||||
current_engine: dict[str, Any],
|
current_engine: dict[str, Any],
|
||||||
) -> Optional[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
|
return current_engine
|
||||||
settings = self._normalize_engine_settings(bundle.get("settings"))
|
settings = self._normalize_engine_settings(bundle.get("settings"))
|
||||||
for item in engines:
|
for item in engines:
|
||||||
candidate = dict(item)
|
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
|
continue
|
||||||
if self._same_runtime_engine(candidate, failed_engine):
|
if self._same_runtime_engine(candidate, failed_engine):
|
||||||
continue
|
continue
|
||||||
|
|
@ -96,13 +91,13 @@ class LLMTierEscalationMixin:
|
||||||
engine=candidate,
|
engine=candidate,
|
||||||
function_name=function_name,
|
function_name=function_name,
|
||||||
settings=settings,
|
settings=settings,
|
||||||
engine_label="forced_glm5_failover",
|
engine_label="forced_high_tier_retry",
|
||||||
)
|
)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
_runtime_log(
|
_runtime_log(
|
||||||
"LLM路由",
|
"LLM路由",
|
||||||
(
|
(
|
||||||
"GLM-5强制升级候选被拦截:"
|
"高阶重试候选被拦截:"
|
||||||
f"function={function_name}, error={type(exc).__name__}:{str(exc)[:180]}"
|
f"function={function_name}, error={type(exc).__name__}:{str(exc)[:180]}"
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
@ -110,8 +105,8 @@ class LLMTierEscalationMixin:
|
||||||
_runtime_log(
|
_runtime_log(
|
||||||
"LLM路由",
|
"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
|
return candidate
|
||||||
|
|
@ -119,31 +114,19 @@ class LLMTierEscalationMixin:
|
||||||
|
|
||||||
def _match_openclaw_high_tier_rule_for_escalation(self, function_name: str) -> Optional[dict[str, Any]]:
|
def _match_openclaw_high_tier_rule_for_escalation(self, function_name: str) -> Optional[dict[str, Any]]:
|
||||||
bundle = self._load_openclaw_routing_bundle()
|
bundle = self._load_openclaw_routing_bundle()
|
||||||
rules = bundle.get("rules") if isinstance(bundle.get("rules"), list) else []
|
rule = find_matching_rule(
|
||||||
indexed: list[tuple[int, int, dict[str, Any]]] = []
|
function_name,
|
||||||
for idx, row in enumerate(rules):
|
tier_filter={"high"},
|
||||||
if not isinstance(row, dict):
|
bundle=bundle,
|
||||||
continue
|
)
|
||||||
if not bool(row.get("enabled", True)):
|
if not isinstance(rule, dict):
|
||||||
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:
|
|
||||||
_runtime_log("LLM路由", f"OpenClaw高阶规则未命中:function={function_name}")
|
_runtime_log("LLM路由", f"OpenClaw高阶规则未命中:function={function_name}")
|
||||||
return None
|
return None
|
||||||
indexed.sort(reverse=True)
|
|
||||||
rule = dict(indexed[0][2])
|
|
||||||
_runtime_log(
|
_runtime_log(
|
||||||
"LLM路由",
|
"LLM路由",
|
||||||
(
|
(
|
||||||
"OpenClaw高阶规则候选:"
|
"OpenClaw高阶规则候选:"
|
||||||
f"function={function_name}, rule={rule.get('name') or 'unnamed'}, "
|
f"function={function_name}, rule={rule.get('name') or 'unnamed'}, priority={rule.get('priority')}"
|
||||||
f"priority={rule.get('priority')}, provider={rule.get('provider')}, model={rule.get('model')}"
|
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
return rule
|
return rule
|
||||||
|
|
@ -180,7 +163,7 @@ class LLMTierEscalationMixin:
|
||||||
"LLM路由",
|
"LLM路由",
|
||||||
(
|
(
|
||||||
"OpenClaw高阶升级命中但与失败引擎同源,跳过:"
|
"OpenClaw高阶升级命中但与失败引擎同源,跳过:"
|
||||||
f"function={function_name}, provider={routed.get('provider')}, model={routed.get('model')}"
|
f"function={function_name}, tier=high"
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
return None
|
return None
|
||||||
|
|
@ -201,12 +184,24 @@ class LLMTierEscalationMixin:
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
return None
|
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(
|
_runtime_log(
|
||||||
"LLM路由",
|
"LLM路由",
|
||||||
(
|
(
|
||||||
"OpenClaw高阶升级命中:"
|
"OpenClaw高阶升级命中:"
|
||||||
f"function={function_name}, rule={rule.get('name') or 'unnamed'}, "
|
f"function={function_name}, rule={rule.get('name') or 'unnamed'}, tier=high"
|
||||||
f"provider={routed.get('provider')}, model={routed.get('model')}"
|
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
return routed
|
return routed
|
||||||
|
|
@ -225,7 +220,7 @@ class LLMTierEscalationMixin:
|
||||||
response_mode: str,
|
response_mode: str,
|
||||||
error: Exception,
|
error: Exception,
|
||||||
) -> tuple[Optional[str], Optional[dict[str, Any]]]:
|
) -> 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
|
return None, None
|
||||||
try:
|
try:
|
||||||
_, bundle = await self._get_or_init_bundle(db, tenant_id=tenant_id)
|
_, bundle = await self._get_or_init_bundle(db, tenant_id=tenant_id)
|
||||||
|
|
@ -235,8 +230,7 @@ class LLMTierEscalationMixin:
|
||||||
"LLM路由",
|
"LLM路由",
|
||||||
(
|
(
|
||||||
"低阶模型失败准备升级高阶:"
|
"低阶模型失败准备升级高阶:"
|
||||||
f"function={function_name}, from={str(failed_engine.get('provider') or '').strip().lower()}:"
|
f"function={function_name}, from_tier=low, to_tier=high, reason={str(error)[:120]}"
|
||||||
f"{str(failed_engine.get('model') or '').strip()}, reason={str(error)[:120]}"
|
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
high_engine = await self._resolve_openclaw_high_tier_engine_for_escalation(
|
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")),
|
settings=self._normalize_engine_settings(bundle.get("settings")),
|
||||||
engine_label="high_tier_failover",
|
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,
|
bundle=bundle,
|
||||||
engines=engines,
|
engines=engines,
|
||||||
function_name=function_name,
|
function_name=function_name,
|
||||||
failed_engine=failed_engine,
|
failed_engine=failed_engine,
|
||||||
current_engine=high_engine,
|
current_engine=high_engine,
|
||||||
)
|
)
|
||||||
if isinstance(forced_glm5_engine, dict):
|
if isinstance(forced_high_tier_engine, dict):
|
||||||
high_engine = forced_glm5_engine
|
high_engine = forced_high_tier_engine
|
||||||
failed_provider = str(failed_engine.get("provider") or "").strip().lower()
|
failed_provider = str(failed_engine.get("provider") or "").strip().lower()
|
||||||
failed_model = str(failed_engine.get("model") or "").strip()
|
failed_model = str(failed_engine.get("model") or "").strip()
|
||||||
high_provider = str(high_engine.get("provider") or "").strip().lower()
|
high_provider = str(high_engine.get("provider") or "").strip().lower()
|
||||||
|
|
@ -285,7 +279,7 @@ class LLMTierEscalationMixin:
|
||||||
"LLM路由",
|
"LLM路由",
|
||||||
(
|
(
|
||||||
"高阶升级引擎与失败引擎相同,跳过:"
|
"高阶升级引擎与失败引擎相同,跳过:"
|
||||||
f"function={function_name}, provider={high_provider}, model={high_model}"
|
f"function={function_name}, tier=high"
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
return None, None
|
return None, None
|
||||||
|
|
@ -293,8 +287,8 @@ class LLMTierEscalationMixin:
|
||||||
"LLM路由",
|
"LLM路由",
|
||||||
(
|
(
|
||||||
"低阶模型失败自动升级:"
|
"低阶模型失败自动升级:"
|
||||||
f"function={function_name}, from={failed_provider}:{failed_model}, "
|
f"function={function_name}, planned_tier=low, executed_tier=high, "
|
||||||
f"to={high_provider}:{high_model}, reason={str(error)[:120]}"
|
f"decision=low_to_high_escalated, reason={str(error)[:120]}"
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
text = await self._chat_completion(
|
text = await self._chat_completion(
|
||||||
|
|
@ -313,11 +307,18 @@ class LLMTierEscalationMixin:
|
||||||
raise ValueError("high_tier_empty_response")
|
raise ValueError("high_tier_empty_response")
|
||||||
if self._is_invalid_structured_output(function_name=function_name, response_text=text):
|
if self._is_invalid_structured_output(function_name=function_name, response_text=text):
|
||||||
raise ValueError("high_tier_invalid_structured_output")
|
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(
|
_runtime_log(
|
||||||
"LLM路由",
|
"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
|
return text, high_engine
|
||||||
|
|
@ -346,8 +347,8 @@ class LLMTierEscalationMixin:
|
||||||
error: Exception,
|
error: Exception,
|
||||||
) -> tuple[Optional[StructuredExtractionResult], Optional[dict[str, Any]]]:
|
) -> tuple[Optional[StructuredExtractionResult], Optional[dict[str, Any]]]:
|
||||||
fn = str(function_name or "").strip().lower()
|
fn = str(function_name or "").strip().lower()
|
||||||
force_intent_retry = fn in {"classify_intent_ai_pmo_kernel", "classify_intent_complex"}
|
force_intent_retry = False
|
||||||
if not force_intent_retry and not self._should_retry_glm47_with_high_tier(
|
if not self._should_retry_low_tier_with_high_tier(
|
||||||
function_name=function_name,
|
function_name=function_name,
|
||||||
failed_engine=failed_engine,
|
failed_engine=failed_engine,
|
||||||
):
|
):
|
||||||
|
|
@ -360,8 +361,7 @@ class LLMTierEscalationMixin:
|
||||||
"LLM路由",
|
"LLM路由",
|
||||||
(
|
(
|
||||||
"低阶结构化失败准备升级高阶:"
|
"低阶结构化失败准备升级高阶:"
|
||||||
f"function={function_name}, from={str(failed_engine.get('provider') or '').strip().lower()}:"
|
f"function={function_name}, from_tier=low, to_tier=high, reason={str(error)[:120]}, "
|
||||||
f"{str(failed_engine.get('model') or '').strip()}, reason={str(error)[:120]}, "
|
|
||||||
f"force_intent_retry={force_intent_retry}"
|
f"force_intent_retry={force_intent_retry}"
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
@ -394,15 +394,15 @@ class LLMTierEscalationMixin:
|
||||||
settings=self._normalize_engine_settings(bundle.get("settings")),
|
settings=self._normalize_engine_settings(bundle.get("settings")),
|
||||||
engine_label="high_tier_structured_failover",
|
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,
|
bundle=bundle,
|
||||||
engines=engines,
|
engines=engines,
|
||||||
function_name=function_name,
|
function_name=function_name,
|
||||||
failed_engine=failed_engine,
|
failed_engine=failed_engine,
|
||||||
current_engine=high_engine,
|
current_engine=high_engine,
|
||||||
)
|
)
|
||||||
if isinstance(forced_glm5_engine, dict):
|
if isinstance(forced_high_tier_engine, dict):
|
||||||
high_engine = forced_glm5_engine
|
high_engine = forced_high_tier_engine
|
||||||
failed_provider = str(failed_engine.get("provider") or "").strip().lower()
|
failed_provider = str(failed_engine.get("provider") or "").strip().lower()
|
||||||
failed_model = str(failed_engine.get("model") or "").strip()
|
failed_model = str(failed_engine.get("model") or "").strip()
|
||||||
high_provider = str(high_engine.get("provider") or "").strip().lower()
|
high_provider = str(high_engine.get("provider") or "").strip().lower()
|
||||||
|
|
@ -413,12 +413,12 @@ class LLMTierEscalationMixin:
|
||||||
"LLM路由",
|
"LLM路由",
|
||||||
(
|
(
|
||||||
"高阶结构化升级引擎与失败引擎相同,跳过:"
|
"高阶结构化升级引擎与失败引擎相同,跳过:"
|
||||||
f"function={function_name}, provider={high_provider}, model={high_model}"
|
f"function={function_name}, tier=high"
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
return None, None
|
return None, None
|
||||||
fallback_engine: Optional[dict[str, Any]] = None
|
fallback_engine: Optional[dict[str, Any]] = None
|
||||||
for fallback_tier in ("safety", "low"):
|
for fallback_tier in ("low",):
|
||||||
candidate = self._resolve_tier_engine(
|
candidate = self._resolve_tier_engine(
|
||||||
tier=fallback_tier,
|
tier=fallback_tier,
|
||||||
engines=engines,
|
engines=engines,
|
||||||
|
|
@ -447,8 +447,8 @@ class LLMTierEscalationMixin:
|
||||||
"LLM路由",
|
"LLM路由",
|
||||||
(
|
(
|
||||||
"低阶结构化失败自动升级:"
|
"低阶结构化失败自动升级:"
|
||||||
f"function={function_name}, from={failed_provider}:{failed_model}, "
|
f"function={function_name}, planned_tier=low, executed_tier=high, "
|
||||||
f"to={high_provider}:{high_model}, reason={str(error)[:120]}, "
|
f"decision=low_to_high_escalated, reason={str(error)[:120]}, "
|
||||||
f"force_intent_retry={force_intent_retry}"
|
f"force_intent_retry={force_intent_retry}"
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
@ -484,11 +484,18 @@ class LLMTierEscalationMixin:
|
||||||
f"validation={extraction.validation}, fallback=true"
|
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(
|
_runtime_log(
|
||||||
"LLM路由",
|
"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'}"
|
f"structured_source={extraction.source_field or 'none'}"
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -28,11 +28,15 @@ from sqlalchemy.exc import OperationalError
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from ..model.model_config import (
|
from ..model.model_config import (
|
||||||
|
VALID_TIER_ALIASES,
|
||||||
build_provider_runtime,
|
build_provider_runtime,
|
||||||
build_tier_runtime,
|
build_tier_runtime,
|
||||||
|
build_routing_config_snapshot,
|
||||||
|
find_matching_rule,
|
||||||
get_provider_defaults,
|
get_provider_defaults,
|
||||||
get_tier_defaults,
|
get_tier_defaults,
|
||||||
load_model_config,
|
load_model_config,
|
||||||
|
rule_matches_function,
|
||||||
resolve_model_config_path,
|
resolve_model_config_path,
|
||||||
)
|
)
|
||||||
from ...config.ai_prompts import build_default_role_prompt_bundle
|
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()
|
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_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_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")
|
OPENCLAW_DASHSCOPE_BASE_URL = str(PROVIDER_DEFAULTS.get("glm", {}).get("base_url") or "https://dashscope.aliyuncs.com/compatible-mode/v1")
|
||||||
PROVIDER_ALIASES = {
|
PROVIDER_ALIASES = {
|
||||||
"zhipu": "glm",
|
"zhipu": "glm",
|
||||||
|
|
@ -68,7 +71,6 @@ PROVIDER_ALIASES = {
|
||||||
PROMPT_PROFILE_CACHE_TTL_SECONDS = 60.0
|
PROMPT_PROFILE_CACHE_TTL_SECONDS = 60.0
|
||||||
BOOL_TRUE_VALUES = {"1", "true", "yes", "y", "on", "是"}
|
BOOL_TRUE_VALUES = {"1", "true", "yes", "y", "on", "是"}
|
||||||
OPENCLAW_ROUTING_CACHE_TTL_SECONDS = 30.0
|
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_DEFAULT_TIMEOUT_SECONDS = 120
|
||||||
OPENCLAW_AGENTS_TIMEOUT_SECONDS_MAX = 120
|
OPENCLAW_AGENTS_TIMEOUT_SECONDS_MAX = 120
|
||||||
DEFAULT_TIER_MODEL_OVERRIDES = {
|
DEFAULT_TIER_MODEL_OVERRIDES = {
|
||||||
|
|
@ -82,6 +84,7 @@ DEFAULT_HIGH_TIER_HINTS = (
|
||||||
"feishu_task_orchestration",
|
"feishu_task_orchestration",
|
||||||
"feishu_project_signal_classification",
|
"feishu_project_signal_classification",
|
||||||
"adaptive_rag_answer_deep",
|
"adaptive_rag_answer_deep",
|
||||||
|
"ai_pmo_signal_batch_analysis",
|
||||||
"rag_db_project_semantic_retrieval",
|
"rag_db_project_semantic_retrieval",
|
||||||
"analyze_food_calories",
|
"analyze_food_calories",
|
||||||
"sstc_ai_breakdown",
|
"sstc_ai_breakdown",
|
||||||
|
|
@ -98,6 +101,7 @@ DEFAULT_LOW_TIER_HINTS = (
|
||||||
"feishu_level_classification",
|
"feishu_level_classification",
|
||||||
"feishu_signal_extraction",
|
"feishu_signal_extraction",
|
||||||
"feishu_humanized_reply",
|
"feishu_humanized_reply",
|
||||||
|
"ai_pmo_signal_title_refine",
|
||||||
"signal_source_onboarding_plan",
|
"signal_source_onboarding_plan",
|
||||||
"signal_source_ai_worker_execution",
|
"signal_source_ai_worker_execution",
|
||||||
"extract_context_insights",
|
"extract_context_insights",
|
||||||
|
|
|
||||||
|
|
@ -32,6 +32,89 @@ def _safe_json(value: Any) -> dict[str, Any]:
|
||||||
return {}
|
return {}
|
||||||
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:
|
def _env_bool(name: str, default: bool = False) -> bool:
|
||||||
raw = os.getenv(name)
|
raw = os.getenv(name)
|
||||||
if raw is None:
|
if raw is None:
|
||||||
|
|
|
||||||
|
|
@ -6,9 +6,9 @@ from copy import deepcopy
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
DEFAULT_ROUTING_RULES_RELATIVE_PATH = "../.openclaw/pmflow-routing-rules.json"
|
DEFAULT_ROUTING_RULES_WORKSPACE_PATH = "backend/.openclaw/pmflow-routing-rules.json"
|
||||||
_APP_ROOT = Path(__file__).resolve().parents[2]
|
_WORKSPACE_ROOT = Path(__file__).resolve().parents[4]
|
||||||
_DEFAULT_ROUTING_RULES_PATH = (_APP_ROOT / DEFAULT_ROUTING_RULES_RELATIVE_PATH).resolve()
|
_DEFAULT_ROUTING_RULES_PATH = (_WORKSPACE_ROOT / DEFAULT_ROUTING_RULES_WORKSPACE_PATH).resolve()
|
||||||
|
|
||||||
_PROVIDER_ALIASES = {
|
_PROVIDER_ALIASES = {
|
||||||
"zhipu": "glm",
|
"zhipu": "glm",
|
||||||
|
|
@ -33,7 +33,7 @@ _DEFAULT_PROVIDER_DEFAULTS: dict[str, dict[str, str]] = {
|
||||||
"openai": {
|
"openai": {
|
||||||
"name": "OpenAI",
|
"name": "OpenAI",
|
||||||
"base_url": "https://api.edgefn.net/v1",
|
"base_url": "https://api.edgefn.net/v1",
|
||||||
"model": "MiniMax-M2.5",
|
"model": "GLM-5",
|
||||||
"api_env": "OPENAI_API_KEY",
|
"api_env": "OPENAI_API_KEY",
|
||||||
},
|
},
|
||||||
"ollama": {
|
"ollama": {
|
||||||
|
|
@ -57,13 +57,8 @@ _DEFAULT_TIER_DEFAULTS: dict[str, dict[str, str]] = {
|
||||||
"base_url": "https://api.edgefn.net/v1",
|
"base_url": "https://api.edgefn.net/v1",
|
||||||
"api_env": "OPENAI_API_KEY",
|
"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}
|
_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:
|
def _normalize_tier(tier: str) -> str:
|
||||||
value = str(tier or "").strip().lower()
|
value = str(tier or "").strip().lower()
|
||||||
if value not in _DEFAULT_TIER_DEFAULTS:
|
if value not in _DEFAULT_TIER_DEFAULTS:
|
||||||
raise ValueError("tier 仅支持 low/high/safety")
|
raise ValueError("tier 仅支持 low/high")
|
||||||
return value
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -91,7 +86,7 @@ def resolve_model_config_path(path: str | Path | None = None) -> Path:
|
||||||
candidate = Path(raw_text).expanduser()
|
candidate = Path(raw_text).expanduser()
|
||||||
if candidate.is_absolute():
|
if candidate.is_absolute():
|
||||||
return candidate
|
return candidate
|
||||||
return (_APP_ROOT / candidate).resolve()
|
return (_WORKSPACE_ROOT / candidate).resolve()
|
||||||
|
|
||||||
|
|
||||||
def _normalize_provider_defaults(raw: Any) -> dict[str, dict[str, str]]:
|
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]]:
|
def _normalize_tier_defaults(raw: Any) -> dict[str, dict[str, str]]:
|
||||||
payload = raw if isinstance(raw, dict) else {}
|
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]] = {}
|
result: dict[str, dict[str, str]] = {}
|
||||||
for tier, defaults in _DEFAULT_TIER_DEFAULTS.items():
|
for tier, defaults in _DEFAULT_TIER_DEFAULTS.items():
|
||||||
item = payload.get(tier) if isinstance(payload.get(tier), dict) else {}
|
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
|
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]:
|
def _normalize_bundle(raw: Any, *, config_path: Path) -> dict[str, Any]:
|
||||||
payload = raw if isinstance(raw, dict) else {}
|
payload = raw if isinstance(raw, dict) else {}
|
||||||
bundle = {
|
bundle = {
|
||||||
|
|
@ -134,7 +217,7 @@ def _normalize_bundle(raw: Any, *, config_path: Path) -> dict[str, Any]:
|
||||||
"config_path": str(config_path),
|
"config_path": str(config_path),
|
||||||
"provider_defaults": _normalize_provider_defaults(payload.get("provider_defaults")),
|
"provider_defaults": _normalize_provider_defaults(payload.get("provider_defaults")),
|
||||||
"tier_defaults": _normalize_tier_defaults(payload.get("tier_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
|
return bundle
|
||||||
|
|
||||||
|
|
@ -229,10 +312,14 @@ def build_tier_runtime(
|
||||||
|
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
|
"VALID_TIER_ALIASES",
|
||||||
"build_provider_runtime",
|
"build_provider_runtime",
|
||||||
|
"build_routing_config_snapshot",
|
||||||
"build_tier_runtime",
|
"build_tier_runtime",
|
||||||
|
"find_matching_rule",
|
||||||
"get_provider_defaults",
|
"get_provider_defaults",
|
||||||
"get_tier_defaults",
|
"get_tier_defaults",
|
||||||
"load_model_config",
|
"load_model_config",
|
||||||
|
"rule_matches_function",
|
||||||
"resolve_model_config_path",
|
"resolve_model_config_path",
|
||||||
]
|
]
|
||||||
|
|
|
||||||
|
|
@ -145,12 +145,10 @@ class ModelContextBundleBuildMixin:
|
||||||
for tid in spec.get("template_ids", [])
|
for tid in spec.get("template_ids", [])
|
||||||
if str(tid or "").strip()
|
if str(tid or "").strip()
|
||||||
],
|
],
|
||||||
"model_routing": {
|
"routing_snapshot": {
|
||||||
"provider": str(routing.get("provider") or ""),
|
"tier_alias": str(routing.get("tier_alias") or ""),
|
||||||
"model": str(routing.get("model") or ""),
|
|
||||||
"tier": str(routing.get("tier") or ""),
|
|
||||||
"source": str(routing.get("source") 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,
|
"required_templates": required_templates,
|
||||||
},
|
},
|
||||||
"contract_sync": contract_sync,
|
"contract_sync": contract_sync,
|
||||||
"function_model_template_matrix": function_matrix,
|
"function_routing_template_matrix": function_matrix,
|
||||||
"coverage": {
|
"coverage": {
|
||||||
"template_ready": len(missing_template_ids) == 0,
|
"template_ready": len(missing_template_ids) == 0,
|
||||||
"contract_ready": bool(contract_sync.get("all_synced")),
|
"contract_ready": bool(contract_sync.get("all_synced")),
|
||||||
|
|
|
||||||
|
|
@ -36,8 +36,6 @@ class ModelContextBundleVerifyMixin:
|
||||||
"error": None,
|
"error": None,
|
||||||
"error_code": str(result.error_code or ""),
|
"error_code": str(result.error_code or ""),
|
||||||
"status": str(result.error_code or "success"),
|
"status": str(result.error_code or "success"),
|
||||||
"provider": str(result.provider or ""),
|
|
||||||
"model": str(result.model or ""),
|
|
||||||
}
|
}
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
return {
|
return {
|
||||||
|
|
@ -45,8 +43,6 @@ class ModelContextBundleVerifyMixin:
|
||||||
"error": str(exc)[:500],
|
"error": str(exc)[:500],
|
||||||
"error_code": "canary_call_failed",
|
"error_code": "canary_call_failed",
|
||||||
"status": "failed",
|
"status": "failed",
|
||||||
"provider": "",
|
|
||||||
"model": "",
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
|
|
@ -88,8 +84,6 @@ class ModelContextBundleVerifyMixin:
|
||||||
"status": "skipped",
|
"status": "skipped",
|
||||||
"error": None,
|
"error": None,
|
||||||
"error_code": "",
|
"error_code": "",
|
||||||
"provider": "",
|
|
||||||
"model": "",
|
|
||||||
}
|
}
|
||||||
if run_canary:
|
if run_canary:
|
||||||
canary = await cls._trigger_canary_call(
|
canary = await cls._trigger_canary_call(
|
||||||
|
|
@ -127,10 +121,9 @@ class ModelContextBundleVerifyMixin:
|
||||||
if str(x or "").strip()
|
if str(x or "").strip()
|
||||||
],
|
],
|
||||||
"routing_snapshot": {
|
"routing_snapshot": {
|
||||||
"provider": str(route.get("provider") or ""),
|
"tier_alias": str(route.get("tier_alias") or ""),
|
||||||
"model": str(route.get("model") or ""),
|
|
||||||
"tier": str(route.get("tier") or ""),
|
|
||||||
"source": str(route.get("source") or ""),
|
"source": str(route.get("source") or ""),
|
||||||
|
"rule_name": str(route.get("rule_name") or ""),
|
||||||
},
|
},
|
||||||
"canary": canary,
|
"canary": canary,
|
||||||
"checks": checks,
|
"checks": checks,
|
||||||
|
|
|
||||||
|
|
@ -8,13 +8,31 @@ from typing import Any
|
||||||
from sqlalchemy import and_, select
|
from sqlalchemy import and_, select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from ....models import Integration
|
from ....models import AICallLog, Integration
|
||||||
from ..model_config import get_tier_defaults
|
from ..model_config import (
|
||||||
|
VALID_TIER_ALIASES,
|
||||||
|
find_matching_rule,
|
||||||
|
load_model_config,
|
||||||
|
)
|
||||||
from ....utils.timezone import now_cn_naive
|
from ....utils.timezone import now_cn_naive
|
||||||
|
|
||||||
ALLOWED_PROVIDERS = {"openai", "glm", "deepseek", "ollama"}
|
ALLOWED_TIERS = set(VALID_TIER_ALIASES)
|
||||||
ALLOWED_TIERS = {"low", "high", "safety"}
|
DEFAULT_FUNCTIONS = (
|
||||||
DEFAULT_FUNCTIONS = ("signal_planner_analyze",)
|
"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"
|
INTEGRATION_TYPE = "model_routing"
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -43,43 +61,29 @@ class ModelRoutingService:
|
||||||
return fn
|
return fn
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _normalize_provider(provider: str) -> str:
|
def _normalize_tier_alias(tier: 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:
|
|
||||||
t = str(tier or "").strip().lower() or "high"
|
t = str(tier or "").strip().lower() or "high"
|
||||||
if t not in ALLOWED_TIERS:
|
if t not in ALLOWED_TIERS:
|
||||||
raise ValueError("tier 仅支持 low/high/safety")
|
raise ValueError("tier_alias 仅支持 low/high")
|
||||||
return t
|
return t
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def _rule_matches_function(cls, *, rule: dict[str, Any], function_name: str) -> bool:
|
def _normalize_override(cls, override: dict[str, Any] | None) -> dict[str, Any] | None:
|
||||||
if bool(rule.get("match_all", False)):
|
if not isinstance(override, dict):
|
||||||
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
|
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
|
@classmethod
|
||||||
async def _get_or_init_bundle(cls, db: AsyncSession, *, tenant_id: int) -> tuple[Integration, dict[str, Any]]:
|
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
|
return row, bundle
|
||||||
|
|
||||||
@classmethod
|
@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 {
|
return {
|
||||||
"function_name": cls._normalize_function_name(function_name),
|
"planned_tier": str(getattr(row, "planned_tier", "") or "").strip().lower() or None,
|
||||||
"provider": str(override.get("provider") or "").strip().lower(),
|
"executed_tier": str(getattr(row, "executed_tier", "") or "").strip().lower() or None,
|
||||||
"model": str(override.get("model") or "").strip(),
|
"routing_decision": str(getattr(row, "routing_decision", "") or "").strip() or None,
|
||||||
"api_env": str(override.get("api_env") or "").strip(),
|
"created_at": row.created_at.isoformat() if row.created_at else None,
|
||||||
"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"),
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def _effective_from_rule(cls, *, function_name: str, rule: dict[str, Any] | None) -> dict[str, Any]:
|
async def _compose_effective_routing(
|
||||||
if rule is None:
|
cls,
|
||||||
low_defaults = get_tier_defaults("low")
|
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 {
|
return {
|
||||||
"function_name": cls._normalize_function_name(function_name),
|
"function_name": fn,
|
||||||
"provider": str(low_defaults.get("provider") or "glm").strip().lower(),
|
"tier_alias": resolved_tier,
|
||||||
"model": str(low_defaults.get("model") or "qwen-plus").strip(),
|
"source": source,
|
||||||
"api_env": str(low_defaults.get("api_env") or "DASHSCOPE_API_KEY").strip(),
|
"rule_name": (str(rule.get("name") or "").strip() or None) if isinstance(rule, dict) else None,
|
||||||
"tier": "high",
|
"rule_priority": int(rule.get("priority") or 0) if isinstance(rule, dict) else None,
|
||||||
"base_url": str(low_defaults.get("base_url") or "https://dashscope.aliyuncs.com/compatible-mode/v1").strip(),
|
"last_execution": last_execution,
|
||||||
"source": "default",
|
"updated_at": str(normalized_override.get("updated_at") or "") if normalized_override else "",
|
||||||
"updated_at": "",
|
"updated_by": normalized_override.get("updated_by") if normalized_override else None,
|
||||||
"updated_by": None,
|
|
||||||
}
|
|
||||||
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,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
|
|
@ -160,9 +218,14 @@ class ModelRoutingService:
|
||||||
_, bundle = await cls._get_or_init_bundle(db, tenant_id=int(tenant_id))
|
_, 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 = bundle.get("mappings") if isinstance(bundle.get("mappings"), dict) else {}
|
||||||
override = mappings.get(fn) if isinstance(mappings.get(fn), dict) else None
|
override = mappings.get(fn) if isinstance(mappings.get(fn), dict) else None
|
||||||
if override and bool(override.get("enabled", True)):
|
routing_bundle = load_model_config()
|
||||||
return cls._effective_from_override(function_name=fn, override=override)
|
return await cls._compose_effective_routing(
|
||||||
return cls._effective_from_rule(function_name=fn, rule=cls._load_default_rule(function_name=fn))
|
db,
|
||||||
|
tenant_id=int(tenant_id),
|
||||||
|
function_name=fn,
|
||||||
|
override=override,
|
||||||
|
routing_bundle=routing_bundle,
|
||||||
|
)
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
async def list_effective_routing(
|
async def list_effective_routing(
|
||||||
|
|
@ -173,11 +236,20 @@ class ModelRoutingService:
|
||||||
) -> list[dict[str, Any]]:
|
) -> list[dict[str, Any]]:
|
||||||
_, bundle = await cls._get_or_init_bundle(db, tenant_id=int(tenant_id))
|
_, 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 = 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()}
|
routing_bundle = load_model_config()
|
||||||
keys.update(DEFAULT_FUNCTIONS)
|
keys = cls._candidate_functions(routing_bundle=routing_bundle, mappings=mappings)
|
||||||
items: list[dict[str, Any]] = []
|
items: list[dict[str, Any]] = []
|
||||||
for fn in sorted(keys):
|
for fn in keys:
|
||||||
items.append(await cls.get_effective_routing(db, tenant_id=int(tenant_id), function_name=fn))
|
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
|
return items
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
|
|
@ -187,31 +259,16 @@ class ModelRoutingService:
|
||||||
*,
|
*,
|
||||||
tenant_id: int,
|
tenant_id: int,
|
||||||
function_name: str,
|
function_name: str,
|
||||||
provider: str,
|
tier_alias: str,
|
||||||
model: str,
|
|
||||||
api_env: str,
|
|
||||||
tier: str,
|
|
||||||
updated_by: int,
|
updated_by: int,
|
||||||
base_url: str | None = None,
|
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
fn = cls._normalize_function_name(function_name)
|
fn = cls._normalize_function_name(function_name)
|
||||||
normalized_provider = cls._normalize_provider(provider)
|
normalized_tier = cls._normalize_tier_alias(tier_alias)
|
||||||
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 不能为空")
|
|
||||||
|
|
||||||
row, bundle = await cls._get_or_init_bundle(db, tenant_id=int(tenant_id))
|
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 = bundle.get("mappings") if isinstance(bundle.get("mappings"), dict) else {}
|
||||||
mappings[fn] = {
|
mappings[fn] = {
|
||||||
"provider": normalized_provider,
|
"tier_alias": normalized_tier,
|
||||||
"model": normalized_model,
|
|
||||||
"api_env": normalized_api_env,
|
|
||||||
"tier": normalized_tier,
|
|
||||||
"base_url": str(base_url or "").strip(),
|
|
||||||
"enabled": True,
|
"enabled": True,
|
||||||
"updated_at": now_cn_naive().isoformat(),
|
"updated_at": now_cn_naive().isoformat(),
|
||||||
"updated_by": int(updated_by),
|
"updated_by": int(updated_by),
|
||||||
|
|
@ -219,4 +276,11 @@ class ModelRoutingService:
|
||||||
bundle["mappings"] = mappings
|
bundle["mappings"] = mappings
|
||||||
row.config = bundle
|
row.config = bundle
|
||||||
await db.flush()
|
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,
|
||||||
|
)
|
||||||
|
|
|
||||||
|
|
@ -11,10 +11,8 @@ class PKGGuardrailsMixin:
|
||||||
candidates.append(Path(configured).expanduser())
|
candidates.append(Path(configured).expanduser())
|
||||||
|
|
||||||
backend_root = Path(__file__).resolve().parents[2]
|
backend_root = Path(__file__).resolve().parents[2]
|
||||||
project_root = Path(__file__).resolve().parents[3]
|
|
||||||
candidates.extend(
|
candidates.extend(
|
||||||
[
|
[
|
||||||
project_root / ".openclaw" / "pmflow-routing-rules.json",
|
|
||||||
backend_root / ".openclaw" / "pmflow-routing-rules.json",
|
backend_root / ".openclaw" / "pmflow-routing-rules.json",
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
|
|
@ -130,7 +128,7 @@ class PKGGuardrailsMixin:
|
||||||
|
|
||||||
for tier in ("low", "high"):
|
for tier in ("low", "high"):
|
||||||
spec = model_specs.get(tier) if isinstance(model_specs.get(tier), dict) else {}
|
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()
|
provider = str(spec.get("provider") or "").strip()
|
||||||
api_env = str(spec.get("api_env") or "").strip()
|
api_env = str(spec.get("api_env") or "").strip()
|
||||||
source = str(spec.get("source") or "env_default").strip()
|
source = str(spec.get("source") or "env_default").strip()
|
||||||
|
|
|
||||||
|
|
@ -288,6 +288,42 @@ class DraftGeneratorService:
|
||||||
{"when": "明天", "action": "确认触发时机与验收口径"},
|
{"when": "明天", "action": "确认触发时机与验收口径"},
|
||||||
{"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
|
workload = 0.0
|
||||||
if assignee is not None:
|
if assignee is not None:
|
||||||
|
|
@ -296,8 +332,18 @@ class DraftGeneratorService:
|
||||||
return {
|
return {
|
||||||
"title": title,
|
"title": title,
|
||||||
"summary": summary,
|
"summary": summary,
|
||||||
|
"positioning_one_liner": positioning_one_liner or summary,
|
||||||
|
"target_customers": target_customers,
|
||||||
"similar_projects": similar_projects,
|
"similar_projects": similar_projects,
|
||||||
"mvp_items": mvp_items,
|
"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,
|
"prospect_assessment": prospect_assessment,
|
||||||
"requirement_items": requirement_items,
|
"requirement_items": requirement_items,
|
||||||
"missing_info": missing_info,
|
"missing_info": missing_info,
|
||||||
|
|
@ -318,6 +364,16 @@ class DraftGeneratorService:
|
||||||
summary = str(draft.get("summary") or "暂无摘要").strip() or "暂无摘要"
|
summary = str(draft.get("summary") or "暂无摘要").strip() or "暂无摘要"
|
||||||
similar_projects = draft.get("similar_projects") if isinstance(draft.get("similar_projects"), list) else []
|
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 []
|
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()
|
prospect_assessment = str(draft.get("prospect_assessment") or "").strip()
|
||||||
requirement_items = draft.get("requirement_items") if isinstance(draft.get("requirement_items"), list) else []
|
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 []
|
missing_info = draft.get("missing_info") if isinstance(draft.get("missing_info"), list) else []
|
||||||
|
|
@ -338,6 +394,141 @@ class DraftGeneratorService:
|
||||||
if not plan_lines:
|
if not plan_lines:
|
||||||
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)}"
|
workload_text = f"{assignee_workload:.1f}" if assignee_workload % 1 else f"{int(assignee_workload)}"
|
||||||
feasibility_reason_line = feasibility_reason if feasibility_reason else "需补充约束后再确认。"
|
feasibility_reason_line = feasibility_reason if feasibility_reason else "需补充约束后再确认。"
|
||||||
assignee_reason_line = assignee_reason if assignee_reason else "建议结合成员技能与当前负载综合判断。"
|
assignee_reason_line = assignee_reason if assignee_reason else "建议结合成员技能与当前负载综合判断。"
|
||||||
|
|
@ -381,6 +572,34 @@ class DraftGeneratorService:
|
||||||
"🚀 MVP 建议:",
|
"🚀 MVP 建议:",
|
||||||
*mvp_lines,
|
*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,
|
*prospect_lines,
|
||||||
"",
|
"",
|
||||||
|
|
@ -438,6 +657,326 @@ class DraftGeneratorService:
|
||||||
normalized.append({"when": when, "action": action})
|
normalized.append({"when": when, "action": action})
|
||||||
return normalized
|
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
|
@staticmethod
|
||||||
def _normalize_active_projects(value: Any) -> list[dict[str, Any]]:
|
def _normalize_active_projects(value: Any) -> list[dict[str, Any]]:
|
||||||
if not isinstance(value, list):
|
if not isinstance(value, list):
|
||||||
|
|
|
||||||
|
|
@ -77,13 +77,13 @@ class SignalPlannerAnalyzeMixin:
|
||||||
tenant_id=int(tenant_id),
|
tenant_id=int(tenant_id),
|
||||||
function_name="signal_planner_analyze",
|
function_name="signal_planner_analyze",
|
||||||
)
|
)
|
||||||
if str(route.get("source") or "") != "override":
|
if not str(route.get("tier_alias") or "").strip():
|
||||||
logger.warning(
|
logger.warning(
|
||||||
"signal_planner_routing_override_missing",
|
"signal_planner_routing_resolution_incomplete",
|
||||||
tenant_id=int(tenant_id),
|
tenant_id=int(tenant_id),
|
||||||
signal_id=int(signal.id),
|
signal_id=int(signal.id),
|
||||||
fallback_provider=str(route.get("provider") or ""),
|
route_source=str(route.get("source") or ""),
|
||||||
fallback_model=str(route.get("model") or ""),
|
route_rule_name=str(route.get("rule_name") or ""),
|
||||||
)
|
)
|
||||||
|
|
||||||
extraction = None
|
extraction = None
|
||||||
|
|
|
||||||
|
|
@ -568,7 +568,7 @@ async def test_execute_query_intent_feishu_fallback_uses_thread_key(monkeypatch)
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@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": []}
|
captured: dict[str, object] = {"logs": []}
|
||||||
|
|
||||||
monkeypatch.setattr(
|
monkeypatch.setattr(
|
||||||
|
|
@ -695,16 +695,18 @@ async def test_low_tier_rag_failure_prefers_glm5_openclaw_failover(monkeypatch)
|
||||||
|
|
||||||
assert text == "GLM-5 answer"
|
assert text == "GLM-5 answer"
|
||||||
assert isinstance(resolved_engine, dict)
|
assert isinstance(resolved_engine, dict)
|
||||||
assert resolved_engine["provider"] == "openai"
|
resolved_meta = resolved_engine["_routing_meta"]
|
||||||
assert resolved_engine["model"] == "GLM-5"
|
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"
|
assert captured["chat_kwargs"]["model"] == "GLM-5"
|
||||||
log_text = "\n".join(message for _, message in captured["logs"])
|
log_text = "\n".join(message for _, message in captured["logs"])
|
||||||
assert "OpenClaw高阶升级命中" in log_text
|
|
||||||
assert "高阶升级成功" in log_text
|
assert "高阶升级成功" in log_text
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@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": []}
|
captured: dict[str, object] = {"logs": []}
|
||||||
|
|
||||||
monkeypatch.setattr(
|
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",
|
"base_url": "https://open.bigmodel.cn/api/paas/v4",
|
||||||
"api_key": "low-key",
|
"api_key": "low-key",
|
||||||
}
|
}
|
||||||
mini_engine = {
|
alternate_high_engine = {
|
||||||
"id": "high-mini-1",
|
"id": "high-deepseek-1",
|
||||||
"provider": "openai",
|
"provider": "deepseek",
|
||||||
"model": "MiniMax-M2.5",
|
"model": "deepseek-chat",
|
||||||
"base_url": "https://api.openai.com/v1",
|
"base_url": "https://api.deepseek.com/v1",
|
||||||
"api_key": "mini-key",
|
"api_key": "deepseek-key",
|
||||||
}
|
}
|
||||||
glm5_engine = {
|
glm5_engine = {
|
||||||
"id": "high-glm5-1",
|
"id": "high-glm5-1",
|
||||||
|
|
@ -737,7 +739,7 @@ async def test_low_tier_intent_failure_forces_glm5_retry_even_when_generic_high_
|
||||||
bundle = {
|
bundle = {
|
||||||
"settings": {},
|
"settings": {},
|
||||||
"active_engine_id": "low-1",
|
"active_engine_id": "low-1",
|
||||||
"engines": [low_engine, mini_engine, glm5_engine],
|
"engines": [low_engine, alternate_high_engine, glm5_engine],
|
||||||
}
|
}
|
||||||
openclaw_bundle = {
|
openclaw_bundle = {
|
||||||
"rules": [
|
"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):
|
def _resolve_tier_engine(self, *, tier, engines, active_engine):
|
||||||
_ = (engines, active_engine)
|
_ = (engines, active_engine)
|
||||||
if str(tier).strip().lower() == "high":
|
if str(tier).strip().lower() == "high":
|
||||||
return dict(mini_engine)
|
return dict(alternate_high_engine)
|
||||||
return dict(low_engine)
|
return dict(low_engine)
|
||||||
|
|
||||||
def _ensure_openclaw_control_plane_engine(self, 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 text == "GLM-5 answer"
|
||||||
assert isinstance(resolved_engine, dict)
|
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"
|
assert captured["chat_kwargs"]["model"] == "GLM-5"
|
||||||
log_text = "\n".join(message for _, message in captured["logs"])
|
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
|
assert "高阶升级成功" in log_text
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@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": []}
|
captured: dict[str, object] = {"logs": []}
|
||||||
|
|
||||||
monkeypatch.setattr(
|
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",
|
"base_url": "https://open.bigmodel.cn/api/paas/v4",
|
||||||
"api_key": "low-key",
|
"api_key": "low-key",
|
||||||
}
|
}
|
||||||
mini_engine = {
|
alternate_high_engine = {
|
||||||
"id": "high-mini-1",
|
"id": "high-deepseek-1",
|
||||||
"provider": "openai",
|
"provider": "deepseek",
|
||||||
"model": "MiniMax-M2.5",
|
"model": "deepseek-chat",
|
||||||
"base_url": "https://api.openai.com/v1",
|
"base_url": "https://api.deepseek.com/v1",
|
||||||
"api_key": "mini-key",
|
"api_key": "deepseek-key",
|
||||||
}
|
}
|
||||||
glm5_engine = {
|
glm5_engine = {
|
||||||
"id": "high-glm5-1",
|
"id": "high-glm5-1",
|
||||||
|
|
@ -885,7 +890,7 @@ async def test_low_tier_structured_failure_forces_glm5_retry(monkeypatch) -> Non
|
||||||
bundle = {
|
bundle = {
|
||||||
"settings": {},
|
"settings": {},
|
||||||
"active_engine_id": "low-1",
|
"active_engine_id": "low-1",
|
||||||
"engines": [low_engine, mini_engine, glm5_engine],
|
"engines": [low_engine, alternate_high_engine, glm5_engine],
|
||||||
}
|
}
|
||||||
openclaw_bundle = {
|
openclaw_bundle = {
|
||||||
"rules": [
|
"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):
|
def _resolve_tier_engine(self, *, tier, engines, active_engine):
|
||||||
_ = (engines, active_engine)
|
_ = (engines, active_engine)
|
||||||
if str(tier).strip().lower() == "high":
|
if str(tier).strip().lower() == "high":
|
||||||
return dict(mini_engine)
|
return dict(alternate_high_engine)
|
||||||
return dict(low_engine)
|
return dict(low_engine)
|
||||||
|
|
||||||
def _ensure_openclaw_control_plane_engine(self, 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 返回空内容"),
|
error=ValueError("LLM 返回空内容"),
|
||||||
)
|
)
|
||||||
|
|
||||||
assert getattr(result, "payload", None) is not None
|
assert result is not None
|
||||||
assert result.payload["confidence"] == pytest.approx(0.91)
|
assert result.payload["intent"] == "query.read_only"
|
||||||
assert result.model == "GLM-5"
|
|
||||||
assert isinstance(resolved_engine, dict)
|
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"
|
assert captured["chat_kwargs"]["model"] == "GLM-5"
|
||||||
log_text = "\n".join(message for _, message in captured["logs"])
|
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
|
assert "高阶结构化升级成功" in log_text
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,7 @@ from app.services.llm_service import mixin_chat_openai
|
||||||
from app.services.ai_service_core import AIService
|
from app.services.ai_service_core import AIService
|
||||||
from app.services.llm_service import mixin_cache_circuit
|
from app.services.llm_service import mixin_cache_circuit
|
||||||
from app.services.llm_service import llm_service
|
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
|
@pytest.mark.asyncio
|
||||||
|
|
@ -84,9 +84,15 @@ def test_model_config_bundle_centralizes_provider_and_tier_defaults() -> None:
|
||||||
tier_defaults = bundle.get("tier_defaults")
|
tier_defaults = bundle.get("tier_defaults")
|
||||||
|
|
||||||
assert provider_defaults["glm"]["model"] == "qwen-plus"
|
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["low"]["model"] == "qwen-plus"
|
||||||
assert tier_defaults["high"]["model"] == "GLM-5"
|
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:
|
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["base_url"] == "https://api.edgefn.net/v1"
|
||||||
assert payload["providers"]["glm"]["model"] == "qwen-plus"
|
assert payload["providers"]["glm"]["model"] == "qwen-plus"
|
||||||
assert payload["providers"]["glm"]["base_url"] == "https://dashscope.aliyuncs.com/compatible-mode/v1"
|
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
|
@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"
|
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:
|
def test_openclaw_rule_keeps_existing_api_key_when_env_missing(monkeypatch) -> None:
|
||||||
monkeypatch.delenv("DASHSCOPE_API_KEY", raising=False)
|
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",
|
"id": "low-1",
|
||||||
"name": "Qwen 低阶引擎",
|
"name": "Qwen 低阶引擎",
|
||||||
"provider": "glm",
|
"provider": "glm",
|
||||||
"model": "GLM-4.7",
|
"model": "qwen-turbo",
|
||||||
"api_key": "db-key",
|
"api_key": "db-key",
|
||||||
"base_url": "https://api.edgefn.net/v1",
|
"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"
|
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:
|
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"
|
circuit_key = "glm|https://dashscope.aliyuncs.com/compatible-mode/v1|qwen-plus"
|
||||||
original_state = dict(llm_service._provider_circuit_state)
|
original_state = dict(llm_service._provider_circuit_state)
|
||||||
|
|
|
||||||
|
|
@ -76,3 +76,21 @@ async def test_project_from_text_returns_enveloped_signal_sources(monkeypatch) -
|
||||||
assert payload["message"] == "success"
|
assert payload["message"] == "success"
|
||||||
assert payload["data"]["project"]["id"] == "11"
|
assert payload["data"]["project"]["id"] == "11"
|
||||||
assert payload["data"]["signal_sources"][0]["source_name"] == "核心痛点 - 内容本地化"
|
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"
|
||||||
|
|
|
||||||
|
|
@ -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.ai_pmo_kernel_common_core.constants import STATE_SUCCEEDED
|
||||||
from app.services.ai_pmo.draft_confirm_handler import DraftConfirmHandler
|
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.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.adaptive_rag_service.mixin_deep_guard_answer import AdaptiveRAGDeepGuardAnswerMixin
|
||||||
from app.services.integrations.feishu_integration_service.mixin_reply_llm_core import (
|
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"
|
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
|
@pytest.mark.asyncio
|
||||||
async def test_draft_confirm_handler_repeated_confirm_returns_inbox_success_copy() -> None:
|
async def test_draft_confirm_handler_repeated_confirm_returns_inbox_success_copy() -> None:
|
||||||
class _FakeResult:
|
class _FakeResult:
|
||||||
|
|
@ -147,6 +157,157 @@ async def test_draft_confirm_handler_repeated_confirm_returns_inbox_success_copy
|
||||||
assert result["followup_reply"] == "已入库成功,无需重复确认。"
|
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
|
@pytest.mark.asyncio
|
||||||
async def test_draft_confirm_handler_ignores_non_draft_reply_without_target(monkeypatch) -> None:
|
async def test_draft_confirm_handler_ignores_non_draft_reply_without_target(monkeypatch) -> None:
|
||||||
kernel = type("Kernel", (), {"db": object(), "tenant_id": 1})()
|
kernel = type("Kernel", (), {"db": object(), "tenant_id": 1})()
|
||||||
|
|
|
||||||
|
|
@ -93,6 +93,110 @@ async def test_generate_and_render_draft_includes_pm_sections(monkeypatch) -> No
|
||||||
assert generated["draft"]["recommended_assignee_workload"] == 25.0
|
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
|
@pytest.mark.asyncio
|
||||||
async def test_generate_charter_seed_uses_project_context(monkeypatch) -> None:
|
async def test_generate_charter_seed_uses_project_context(monkeypatch) -> None:
|
||||||
captured: dict[str, str] = {}
|
captured: dict[str, str] = {}
|
||||||
|
|
|
||||||
|
|
@ -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"]
|
||||||
|
|
@ -58,6 +58,19 @@ async def test_handle_message_receive_routes_empty_text_branch(monkeypatch) -> N
|
||||||
assert called["empty_reply"]["chat_id"] == "chat-empty"
|
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
|
@pytest.mark.asyncio
|
||||||
async def test_handle_message_receive_orchestrates_capture_and_finalize(monkeypatch) -> None:
|
async def test_handle_message_receive_orchestrates_capture_and_finalize(monkeypatch) -> None:
|
||||||
service = FeishuIntegrationService(db=object())
|
service = FeishuIntegrationService(db=object())
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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
|
||||||
|
|
@ -204,3 +204,94 @@ async def test_signal_planner_analyze_degrades_without_charter(monkeypatch) -> N
|
||||||
assert "charter_json" in str(captured["prompt"])
|
assert "charter_json" in str(captured["prompt"])
|
||||||
assert "{}" in str(captured["prompt"])
|
assert "{}" in str(captured["prompt"])
|
||||||
assert captured["planner_result"]["summary"] == "聚焦支付失败重试"
|
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)
|
||||||
|
|
|
||||||
|
|
@ -65,19 +65,12 @@ services:
|
||||||
CORS_ORIGINS: ${CORS_ORIGINS:?missing_CORS_ORIGINS}
|
CORS_ORIGINS: ${CORS_ORIGINS:?missing_CORS_ORIGINS}
|
||||||
LLM_PROVIDER: ${LLM_PROVIDER:?missing_LLM_PROVIDER}
|
LLM_PROVIDER: ${LLM_PROVIDER:?missing_LLM_PROVIDER}
|
||||||
LLM_FALLBACK_PROVIDER: ${LLM_FALLBACK_PROVIDER:?missing_LLM_FALLBACK_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_ENABLED: ${OPENCLAW_ROUTING_ENABLED:?missing_OPENCLAW_ROUTING_ENABLED}
|
||||||
OPENCLAW_ROUTING_RULES_PATH: ${OPENCLAW_ROUTING_RULES_PATH:?missing_OPENCLAW_ROUTING_RULES_PATH}
|
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}
|
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_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_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_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_DIRECT_EMERGENCY_ENABLED: ${LLM_DIRECT_EMERGENCY_ENABLED:?missing_LLM_DIRECT_EMERGENCY_ENABLED}
|
||||||
LLM_REQUEST_TIMEOUT: ${LLM_REQUEST_TIMEOUT:?missing_LLM_REQUEST_TIMEOUT}
|
LLM_REQUEST_TIMEOUT: ${LLM_REQUEST_TIMEOUT:?missing_LLM_REQUEST_TIMEOUT}
|
||||||
AI_PMO_INTENT_LLM_ENABLED: ${AI_PMO_INTENT_LLM_ENABLED:-true}
|
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_EMPTY_RETRY_BUDGET: ${LLM_INTENT_EMPTY_RETRY_BUDGET:-2}
|
||||||
LLM_INTENT_STRUCTURED_REPAIR_ENABLED: ${LLM_INTENT_STRUCTURED_REPAIR_ENABLED:-true}
|
LLM_INTENT_STRUCTURED_REPAIR_ENABLED: ${LLM_INTENT_STRUCTURED_REPAIR_ENABLED:-true}
|
||||||
AUTO_CREATE_TABLES: "false"
|
AUTO_CREATE_TABLES: "false"
|
||||||
GLM_API_KEY: ${GLM_API_KEY:?missing_GLM_API_KEY}
|
DASHSCOPE_API_KEY: ${DASHSCOPE_API_KEY:?missing_DASHSCOPE_API_KEY}
|
||||||
GLM_BASE_URL: ${GLM_BASE_URL:?missing_GLM_BASE_URL}
|
|
||||||
GLM_MODEL: ${GLM_MODEL:?missing_GLM_MODEL}
|
|
||||||
OPENAI_API_KEY: ${OPENAI_API_KEY:?missing_OPENAI_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}
|
DEEPSEEK_API_KEY: ${DEEPSEEK_API_KEY}
|
||||||
OLLAMA_HOST: ${OLLAMA_HOST:?missing_OLLAMA_HOST}
|
OLLAMA_HOST: ${OLLAMA_HOST:?missing_OLLAMA_HOST}
|
||||||
OLLAMA_BASE_URL: ${OLLAMA_BASE_URL:?missing_OLLAMA_BASE_URL}
|
OLLAMA_BASE_URL: ${OLLAMA_BASE_URL:-http://host.docker.internal:11434}
|
||||||
OLLAMA_MODEL: ${OLLAMA_MODEL:?missing_OLLAMA_MODEL}
|
OLLAMA_MODEL: ${OLLAMA_MODEL:-llama3:latest}
|
||||||
PYTHONUNBUFFERED: "1"
|
PYTHONUNBUFFERED: "1"
|
||||||
HTTP_PROXY: ""
|
HTTP_PROXY: ""
|
||||||
HTTPS_PROXY: ""
|
HTTPS_PROXY: ""
|
||||||
|
|
@ -135,7 +125,6 @@ services:
|
||||||
]
|
]
|
||||||
volumes:
|
volumes:
|
||||||
- ./backend:/app/backend
|
- ./backend:/app/backend
|
||||||
- ./.openclaw:/app/.openclaw:ro
|
|
||||||
- ./logs:/app/logs
|
- ./logs:/app/logs
|
||||||
ports:
|
ports:
|
||||||
- "${BACKEND_PORT:?missing_BACKEND_PORT}:8000"
|
- "${BACKEND_PORT:?missing_BACKEND_PORT}:8000"
|
||||||
|
|
|
||||||
|
|
@ -65,19 +65,12 @@ services:
|
||||||
CORS_ORIGINS: ${CORS_ORIGINS:?missing_CORS_ORIGINS}
|
CORS_ORIGINS: ${CORS_ORIGINS:?missing_CORS_ORIGINS}
|
||||||
LLM_PROVIDER: ${LLM_PROVIDER:?missing_LLM_PROVIDER}
|
LLM_PROVIDER: ${LLM_PROVIDER:?missing_LLM_PROVIDER}
|
||||||
LLM_FALLBACK_PROVIDER: ${LLM_FALLBACK_PROVIDER:?missing_LLM_FALLBACK_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_ENABLED: ${OPENCLAW_ROUTING_ENABLED:?missing_OPENCLAW_ROUTING_ENABLED}
|
||||||
OPENCLAW_ROUTING_RULES_PATH: ${OPENCLAW_ROUTING_RULES_PATH:?missing_OPENCLAW_ROUTING_RULES_PATH}
|
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}
|
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_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_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_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_DIRECT_EMERGENCY_ENABLED: ${LLM_DIRECT_EMERGENCY_ENABLED:?missing_LLM_DIRECT_EMERGENCY_ENABLED}
|
||||||
LLM_REQUEST_TIMEOUT: ${LLM_REQUEST_TIMEOUT:?missing_LLM_REQUEST_TIMEOUT}
|
LLM_REQUEST_TIMEOUT: ${LLM_REQUEST_TIMEOUT:?missing_LLM_REQUEST_TIMEOUT}
|
||||||
AI_PMO_INTENT_LLM_ENABLED: ${AI_PMO_INTENT_LLM_ENABLED:-true}
|
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_EMPTY_RETRY_BUDGET: ${LLM_INTENT_EMPTY_RETRY_BUDGET:-2}
|
||||||
LLM_INTENT_STRUCTURED_REPAIR_ENABLED: ${LLM_INTENT_STRUCTURED_REPAIR_ENABLED:-true}
|
LLM_INTENT_STRUCTURED_REPAIR_ENABLED: ${LLM_INTENT_STRUCTURED_REPAIR_ENABLED:-true}
|
||||||
AUTO_CREATE_TABLES: "false"
|
AUTO_CREATE_TABLES: "false"
|
||||||
GLM_API_KEY: ${GLM_API_KEY:?missing_GLM_API_KEY}
|
DASHSCOPE_API_KEY: ${DASHSCOPE_API_KEY:?missing_DASHSCOPE_API_KEY}
|
||||||
GLM_BASE_URL: ${GLM_BASE_URL:?missing_GLM_BASE_URL}
|
|
||||||
GLM_MODEL: ${GLM_MODEL:?missing_GLM_MODEL}
|
|
||||||
OPENAI_API_KEY: ${OPENAI_API_KEY:?missing_OPENAI_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}
|
DEEPSEEK_API_KEY: ${DEEPSEEK_API_KEY}
|
||||||
OLLAMA_HOST: ${OLLAMA_HOST:?missing_OLLAMA_HOST}
|
OLLAMA_HOST: ${OLLAMA_HOST:?missing_OLLAMA_HOST}
|
||||||
OLLAMA_BASE_URL: ${OLLAMA_BASE_URL:?missing_OLLAMA_BASE_URL}
|
OLLAMA_BASE_URL: ${OLLAMA_BASE_URL:-http://host.docker.internal:11434}
|
||||||
OLLAMA_MODEL: ${OLLAMA_MODEL:?missing_OLLAMA_MODEL}
|
OLLAMA_MODEL: ${OLLAMA_MODEL:-llama3:latest}
|
||||||
HTTP_PROXY: ""
|
HTTP_PROXY: ""
|
||||||
HTTPS_PROXY: ""
|
HTTPS_PROXY: ""
|
||||||
ALL_PROXY: ""
|
ALL_PROXY: ""
|
||||||
|
|
@ -117,7 +107,7 @@ services:
|
||||||
ports:
|
ports:
|
||||||
- "${BACKEND_PORT:?missing_BACKEND_PORT}:8000"
|
- "${BACKEND_PORT:?missing_BACKEND_PORT}:8000"
|
||||||
volumes:
|
volumes:
|
||||||
- ./.openclaw:/app/.openclaw:ro
|
- ./backend/.openclaw:/app/backend/.openclaw:ro
|
||||||
- ./logs:/app/logs
|
- ./logs:/app/logs
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD-SHELL", "curl -fsS http://localhost:8000/healthz >/dev/null || exit 1"]
|
test: ["CMD-SHELL", "curl -fsS http://localhost:8000/healthz >/dev/null || exit 1"]
|
||||||
|
|
|
||||||
|
|
@ -221,8 +221,8 @@ sequenceDiagram
|
||||||
| --- | --- | --- | --- |
|
| --- | --- | --- | --- |
|
||||||
| 飞书 @ 人设回复 | `feishu_humanized_reply` | `zai/glm-4-flash` | 把执行结果转成短中文回复,控制语气与可执行建议 |
|
| 飞书 @ 人设回复 | `feishu_humanized_reply` | `zai/glm-4-flash` | 把执行结果转成短中文回复,控制语气与可执行建议 |
|
||||||
| RAG 意图判定 | `rag_intent_classification` | `zai/glm-4-flash` | 把用户输入判定为 question / mixed / statement |
|
| RAG 意图判定 | `rag_intent_classification` | `zai/glm-4-flash` | 把用户输入判定为 question / mixed / statement |
|
||||||
| RAG 项目语义检索 | `rag_db_project_semantic_retrieval` | `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` | `minimax/MiniMax-M2.5` | 生成最终回答(结论+证据+不确定项) |
|
| RAG 最终答案生成(normal/deep) | `adaptive_rag_answer_normal` / `adaptive_rag_answer_deep` | `openai-compatible/GLM-5` | 生成最终回答(结论+证据+不确定项) |
|
||||||
|
|
||||||
#### B. 主链路中的“信号源接入子流程”(命中时)
|
#### B. 主链路中的“信号源接入子流程”(命中时)
|
||||||
|
|
||||||
|
|
@ -240,7 +240,7 @@ sequenceDiagram
|
||||||
| --- | --- | --- | --- |
|
| --- | --- | --- | --- |
|
||||||
| 层级判断 | `feishu_level_classification` | `zai/glm-4-flash` | 判定 project-level 还是 signal-level |
|
| 层级判断 | `feishu_level_classification` | `zai/glm-4-flash` | 判定 project-level 还是 signal-level |
|
||||||
| 信号抽取 | `feishu_signal_extraction` | `zai/glm-4-flash` | 抽取标题、类型、优先级、置信度 |
|
| 信号抽取 | `feishu_signal_extraction` | `zai/glm-4-flash` | 抽取标题、类型、优先级、置信度 |
|
||||||
| 项目预填充 | `feishu_project_prefill` | `minimax/MiniMax-M2.5` | 生成建议项目名/描述/owner/timeline |
|
| 项目预填充 | `feishu_project_prefill` | `openai-compatible/GLM-5` | 生成建议项目名/描述/owner/timeline |
|
||||||
| 任务编排 | `feishu_task_orchestration` | `minimax/MiniMax-M2.5` | 生成任务拆解与分配建议 |
|
| 任务编排 | `feishu_task_orchestration` | `openai-compatible/GLM-5` | 生成任务拆解与分配建议 |
|
||||||
| 项目信号分类 | `feishu_project_signal_classification` | `minimax/MiniMax-M2.5` | 对新项目/已有项目/非需求做高层分类 |
|
| 项目信号分类 | `feishu_project_signal_classification` | `openai-compatible/GLM-5` | 对新项目/已有项目/非需求做高层分类 |
|
||||||
| 新项目摘要 | `feishu_new_project_brief` | `minimax/MiniMax-M2.5` | 生成项目简报(brief) |
|
| 新项目摘要 | `feishu_new_project_brief` | `openai-compatible/GLM-5` | 生成项目简报(brief) |
|
||||||
|
|
|
||||||
|
|
@ -108,7 +108,7 @@
|
||||||
- [x] `signal.create` 成功提交后 `create_task(SignalPlannerService.analyze(...))`
|
- [x] `signal.create` 成功提交后 `create_task(SignalPlannerService.analyze(...))`
|
||||||
- [x] 在 `backend/app/config/ai_prompts.py` 增加 `PROMPT_SIGNAL_PLANNER`
|
- [x] 在 `backend/app/config/ai_prompts.py` 增加 `PROMPT_SIGNAL_PLANNER`
|
||||||
- [x] 在 LLM 结构化 schema 注册 `signal_planner_analyze`
|
- [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 后
|
- [x] 触发点仅在 commit 后
|
||||||
|
|
@ -133,7 +133,7 @@
|
||||||
|
|
||||||
### 完成标志
|
### 完成标志
|
||||||
- [x] 通过接口切换模型后,Planner 无需改代码即可生效
|
- [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`
|
- `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`
|
- 重复消息幂等:`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/sidebar_new_entries_20260312.png`
|
||||||
- `docs/task_briefs/model_routing_override_effective_20260312.png`
|
- `docs/task_briefs/model_routing_override_effective_20260312.png`
|
||||||
|
|
|
||||||
|
|
@ -27,7 +27,7 @@
|
||||||
## 4. 三引擎职责分配(推荐)
|
## 4. 三引擎职责分配(推荐)
|
||||||
- Engine-L:`zai/glm-4-flash`
|
- Engine-L:`zai/glm-4-flash`
|
||||||
- 场景:执行态分类、信号抽取、简短回执、轻量意图识别。
|
- 场景:执行态分类、信号抽取、简短回执、轻量意图识别。
|
||||||
- Engine-H:`minimax/MiniMax-M2.5`
|
- Engine-H:`openai-compatible/GLM-5`
|
||||||
- 场景:复杂问答、多约束规划、任务编排、项目方案生成。
|
- 场景:复杂问答、多约束规划、任务编排、项目方案生成。
|
||||||
- Engine-S:`deepseek/deepseek-chat`(或你可用的稳定备份模型)
|
- Engine-S:`deepseek/deepseek-chat`(或你可用的稳定备份模型)
|
||||||
- 场景:H 不可用/超时/限流后的稳定兜底。
|
- 场景:H 不可用/超时/限流后的稳定兜底。
|
||||||
|
|
@ -112,7 +112,7 @@ LLM_LOW_TO_HIGH_ESCALATION_ENABLED=true
|
||||||
|
|
||||||
### 9.2 OpenClaw Agent(main)
|
### 9.2 OpenClaw Agent(main)
|
||||||
- Primary model:`zai/glm-4-flash`(Engine-L)
|
- 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 只做同层灾备。
|
> 如果你要严格“低->高->安全”,可在 OpenClaw 路由中按函数直接指定模型,fallback 只做同层灾备。
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,7 @@
|
||||||
## 2. Cross-Stack Contract Ownership
|
## 2. Cross-Stack Contract Ownership
|
||||||
- Backend source: `backend/app/api/`, `backend/app/schemas/`
|
- Backend source: `backend/app/api/`, `backend/app/schemas/`
|
||||||
- Frontend source: `frontend/src/api/`, `frontend/src/types/`
|
- 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/`
|
- Architecture docs source: `docs/architecture/`
|
||||||
|
|
||||||
## 3. Sync Rules
|
## 3. Sync Rules
|
||||||
|
|
@ -15,7 +15,7 @@
|
||||||
- `docs/architecture/API_CONTRACTS.md`
|
- `docs/architecture/API_CONTRACTS.md`
|
||||||
- frontend TypeScript interfaces (if impacted)
|
- frontend TypeScript interfaces (if impacted)
|
||||||
- Any routing/function-name change must update:
|
- Any routing/function-name change must update:
|
||||||
- `.openclaw/pmflow-routing-rules.json`
|
- `backend/.openclaw/pmflow-routing-rules.json`
|
||||||
- `docs/architecture/DATAFLOW.md`
|
- `docs/architecture/DATAFLOW.md`
|
||||||
- Any new module/page/API must update:
|
- Any new module/page/API must update:
|
||||||
- `CLAUDE.md` structure/reference sections
|
- `CLAUDE.md` structure/reference sections
|
||||||
|
|
|
||||||
|
|
@ -46,17 +46,15 @@ export interface ContractSyncCoverage {
|
||||||
}>;
|
}>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface FunctionModelTemplateBinding {
|
export interface FunctionRoutingTemplateBinding {
|
||||||
function_name: string;
|
function_name: string;
|
||||||
tier: "low" | "high" | string;
|
tier: "low" | "high" | string;
|
||||||
context_sections: string[];
|
context_sections: string[];
|
||||||
template_ids: string[];
|
template_ids: string[];
|
||||||
model_routing: {
|
routing_snapshot: {
|
||||||
provider: string;
|
tier_alias: string;
|
||||||
model: string;
|
|
||||||
tier: string;
|
|
||||||
source: string;
|
source: string;
|
||||||
api_env: string;
|
rule_name: string;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -65,7 +63,7 @@ export interface KnowledgeGraphCapabilitySummary {
|
||||||
knowledge_graph: KnowledgeGraphOverviewSnapshot;
|
knowledge_graph: KnowledgeGraphOverviewSnapshot;
|
||||||
task_templates: TaskTemplateCoverage;
|
task_templates: TaskTemplateCoverage;
|
||||||
contract_sync: ContractSyncCoverage;
|
contract_sync: ContractSyncCoverage;
|
||||||
function_model_template_matrix: FunctionModelTemplateBinding[];
|
function_routing_template_matrix: FunctionRoutingTemplateBinding[];
|
||||||
coverage: {
|
coverage: {
|
||||||
template_ready: boolean;
|
template_ready: boolean;
|
||||||
contract_ready: boolean;
|
contract_ready: boolean;
|
||||||
|
|
@ -79,18 +77,15 @@ export interface ContextConsumptionFunctionResult {
|
||||||
tier: string;
|
tier: string;
|
||||||
template_ids: string[];
|
template_ids: string[];
|
||||||
routing_snapshot: {
|
routing_snapshot: {
|
||||||
provider: string;
|
tier_alias: string;
|
||||||
model: string;
|
|
||||||
tier: string;
|
|
||||||
source: string;
|
source: string;
|
||||||
|
rule_name: string;
|
||||||
};
|
};
|
||||||
canary: {
|
canary: {
|
||||||
called: boolean;
|
called: boolean;
|
||||||
status: string;
|
status: string;
|
||||||
error?: string | null;
|
error?: string | null;
|
||||||
error_code?: string | null;
|
error_code?: string | null;
|
||||||
provider?: string | null;
|
|
||||||
model?: string | null;
|
|
||||||
};
|
};
|
||||||
checks: {
|
checks: {
|
||||||
marker: boolean;
|
marker: boolean;
|
||||||
|
|
|
||||||
|
|
@ -9,24 +9,24 @@ function unwrap<T>(raw: ApiEnvelope<T> | T): T {
|
||||||
return raw as 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 {
|
export interface ModelRoutingItem {
|
||||||
function_name: string;
|
function_name: string;
|
||||||
provider: string;
|
tier_alias: "low" | "high";
|
||||||
model: string;
|
|
||||||
api_env: string;
|
|
||||||
tier: "low" | "high" | "safety";
|
|
||||||
base_url?: string;
|
|
||||||
source: "default" | "override";
|
source: "default" | "override";
|
||||||
updated_at?: string;
|
rule_name?: string | null;
|
||||||
updated_by?: number | null;
|
rule_priority?: number | null;
|
||||||
|
last_execution?: ModelRoutingLastExecution | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ModelRoutingUpdateRequest {
|
export interface ModelRoutingUpdateRequest {
|
||||||
provider: string;
|
tier_alias: "low" | "high";
|
||||||
model: string;
|
|
||||||
api_env: string;
|
|
||||||
tier: "low" | "high" | "safety";
|
|
||||||
base_url?: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const modelRoutingApi = {
|
export const modelRoutingApi = {
|
||||||
|
|
|
||||||
|
|
@ -52,7 +52,7 @@ export const GOVERNANCE_TOOLS = [
|
||||||
{
|
{
|
||||||
id: "knowledge-graph-board",
|
id: "knowledge-graph-board",
|
||||||
name: "知识图谱上下文看板",
|
name: "知识图谱上下文看板",
|
||||||
description: "查看 KG 汇总与双模型上下文消费验证",
|
description: "查看 KG 汇总与 low/high 上下文消费验证",
|
||||||
to: "/ai-config/knowledge-graph-board",
|
to: "/ai-config/knowledge-graph-board",
|
||||||
icon: Boxes,
|
icon: Boxes,
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -23,7 +23,7 @@ export const ENGINE_PROVIDER_FALLBACK_PRESETS: EngineProviderPresets = {
|
||||||
base_url: "https://dashscope.aliyuncs.com/compatible-mode/v1",
|
base_url: "https://dashscope.aliyuncs.com/compatible-mode/v1",
|
||||||
},
|
},
|
||||||
openai: {
|
openai: {
|
||||||
model: "MiniMax-M2.5",
|
model: "GLM-5",
|
||||||
base_url: "https://api.edgefn.net/v1",
|
base_url: "https://api.edgefn.net/v1",
|
||||||
},
|
},
|
||||||
ollama: {
|
ollama: {
|
||||||
|
|
|
||||||
|
|
@ -229,7 +229,7 @@ export default function EngineTab({
|
||||||
<Input
|
<Input
|
||||||
value={engineForm.model}
|
value={engineForm.model}
|
||||||
onChange={(e) => setEngineForm((prev) => ({ ...prev, model: e.target.value }))}
|
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>
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
|
|
|
||||||
|
|
@ -49,15 +49,15 @@ export default function KnowledgeGraphContextBoardPage() {
|
||||||
const errorText = summaryQuery.error instanceof Error ? summaryQuery.error.message : "请检查后端服务。";
|
const errorText = summaryQuery.error instanceof Error ? summaryQuery.error.message : "请检查后端服务。";
|
||||||
|
|
||||||
const matrixRows = useMemo(
|
const matrixRows = useMemo(
|
||||||
() => summary?.function_model_template_matrix || [],
|
() => summary?.function_routing_template_matrix || [],
|
||||||
[summary]
|
[summary]
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<ConfigSubpageHeader
|
<ConfigSubpageHeader
|
||||||
title="知识图谱与模型上下文看板"
|
title="知识图谱与路由上下文看板"
|
||||||
description="统一展示 KG/任务模板/契约同步与双模型上下文消费验证状态。"
|
description="统一展示 KG/任务模板/契约同步与 low/high 路由上下文消费验证状态。"
|
||||||
icon={<Boxes className="h-5 w-5" />}
|
icon={<Boxes className="h-5 w-5" />}
|
||||||
tone="blue"
|
tone="blue"
|
||||||
meta={
|
meta={
|
||||||
|
|
@ -75,16 +75,16 @@ export default function KnowledgeGraphContextBoardPage() {
|
||||||
/>
|
/>
|
||||||
<FeatureIntroGuide
|
<FeatureIntroGuide
|
||||||
featureId="knowledge-graph-board"
|
featureId="knowledge-graph-board"
|
||||||
title="知识图谱与模型上下文看板"
|
title="知识图谱与路由上下文看板"
|
||||||
purpose="确认 low/high 两个模型是否真正吃到 KG、模板和契约三类上下文。"
|
purpose="确认 low/high 两个逻辑层是否真正吃到 KG、模板和契约三类上下文。"
|
||||||
whenToUse="你要判断“模型为什么表现异常、上下文是否生效”时优先看这里。"
|
whenToUse="你要判断“模型为什么表现异常、上下文是否生效”时优先看这里。"
|
||||||
steps={[
|
steps={[
|
||||||
"先看 KG 状态、模板覆盖、契约同步三个基础区块是否就绪。",
|
"先看 KG 状态、模板覆盖、契约同步三个基础区块是否就绪。",
|
||||||
"点击“运行双模型验证”,触发 canary 与日志证据回查。",
|
"点击“运行 low/high 验证”,触发 canary 与日志证据回查。",
|
||||||
"查看函数矩阵和验证结果,定位是路由问题还是上下文注入问题。",
|
"查看函数矩阵和验证结果,定位是路由问题还是上下文注入问题。",
|
||||||
]}
|
]}
|
||||||
outputs={[
|
outputs={[
|
||||||
"双模型上下文消费通过/失败结论",
|
"双层路由上下文消费通过/失败结论",
|
||||||
"按函数可追溯的证据与路由快照",
|
"按函数可追溯的证据与路由快照",
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
|
|
@ -107,7 +107,7 @@ export default function KnowledgeGraphContextBoardPage() {
|
||||||
disabled={verifyMutation.isPending}
|
disabled={verifyMutation.isPending}
|
||||||
>
|
>
|
||||||
<ShieldCheck className={`mr-1 h-4 w-4 ${verifyMutation.isPending ? "animate-pulse" : ""}`} />
|
<ShieldCheck className={`mr-1 h-4 w-4 ${verifyMutation.isPending ? "animate-pulse" : ""}`} />
|
||||||
{verifyMutation.isPending ? "验证中..." : "运行双模型验证"}
|
{verifyMutation.isPending ? "验证中..." : "运行 low/high 验证"}
|
||||||
</Button>
|
</Button>
|
||||||
) : (
|
) : (
|
||||||
<span className="text-xs text-slate-500 dark:text-slate-400">仅管理员可执行验证操作。</span>
|
<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">
|
<Card className="border-slate-200/80 bg-white/90 shadow-sm backdrop-blur-sm dark:border-slate-800 dark:bg-slate-900/85">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="text-base">任务模板覆盖</CardTitle>
|
<CardTitle className="text-base">任务模板覆盖</CardTitle>
|
||||||
<CardDescription>双模型链路要求模板的覆盖与缺口。</CardDescription>
|
<CardDescription>low/high 链路要求模板的覆盖与缺口。</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-3">
|
<CardContent className="space-y-3">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
|
|
@ -203,7 +203,7 @@ export default function KnowledgeGraphContextBoardPage() {
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="flex items-center gap-2 text-base">
|
<CardTitle className="flex items-center gap-2 text-base">
|
||||||
<GitBranch className="h-4 w-4" />
|
<GitBranch className="h-4 w-4" />
|
||||||
函数-模型-模板矩阵
|
函数-路由-模板矩阵
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
<CardDescription>固定四条函数链路的路由与模板绑定关系。</CardDescription>
|
<CardDescription>固定四条函数链路的路由与模板绑定关系。</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
|
|
@ -214,7 +214,7 @@ export default function KnowledgeGraphContextBoardPage() {
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableHead>函数</TableHead>
|
<TableHead>函数</TableHead>
|
||||||
<TableHead>层级</TableHead>
|
<TableHead>层级</TableHead>
|
||||||
<TableHead>模型</TableHead>
|
<TableHead>当前路由</TableHead>
|
||||||
<TableHead>模板</TableHead>
|
<TableHead>模板</TableHead>
|
||||||
<TableHead>来源</TableHead>
|
<TableHead>来源</TableHead>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
|
|
@ -224,9 +224,14 @@ export default function KnowledgeGraphContextBoardPage() {
|
||||||
<TableRow key={row.function_name} className="hover:bg-slate-50/70 dark:hover:bg-slate-800/60">
|
<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 className="font-medium">{row.function_name}</TableCell>
|
||||||
<TableCell>{row.tier}</TableCell>
|
<TableCell>{row.tier}</TableCell>
|
||||||
<TableCell>{`${row.model_routing.provider}/${row.model_routing.model}`}</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.template_ids.join(", ") || "-"}</TableCell>
|
||||||
<TableCell>{row.model_routing.source || "-"}</TableCell>
|
<TableCell>{row.routing_snapshot.source || "-"}</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
))}
|
))}
|
||||||
{matrixRows.length === 0 ? (
|
{matrixRows.length === 0 ? (
|
||||||
|
|
@ -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">
|
<Card className="border-slate-200/80 bg-white/90 shadow-sm backdrop-blur-sm dark:border-slate-800 dark:bg-slate-900/85">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="text-base">双模型上下文消费验证</CardTitle>
|
<CardTitle className="text-base">low/high 上下文消费验证</CardTitle>
|
||||||
<CardDescription>通过 canary + ai_call_logs 标记校验模型是否吃到三类上下文。</CardDescription>
|
<CardDescription>通过 canary + ai_call_logs 标记校验当前 tier 是否吃到三类上下文。</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-3">
|
<CardContent className="space-y-3">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
|
|
|
||||||
|
|
@ -9,9 +9,9 @@ import { Button } from "@/components/ui/button";
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
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 = ["low", "high"] as const;
|
||||||
const TIER_OPTIONS = ["high", "low", "safety"] as const;
|
|
||||||
const FUNCTION_USAGE_HINTS: Record<string, string> = {
|
const FUNCTION_USAGE_HINTS: Record<string, string> = {
|
||||||
adaptive_rag_answer_deep: "复杂问题的深度回答阶段:会做更细的推理和更完整的结论组织。",
|
adaptive_rag_answer_deep: "复杂问题的深度回答阶段:会做更细的推理和更完整的结论组织。",
|
||||||
adaptive_rag_answer_normal: "常规问答阶段:基于已检索到的信息快速生成可读回复。",
|
adaptive_rag_answer_normal: "常规问答阶段:基于已检索到的信息快速生成可读回复。",
|
||||||
|
|
@ -24,17 +24,25 @@ const FUNCTION_USAGE_HINTS: Record<string, string> = {
|
||||||
};
|
};
|
||||||
|
|
||||||
function getFunctionUsageHint(functionName: 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() {
|
export default function ModelRoutingConfigPage() {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const [selectedFunction, setSelectedFunction] = useState("signal_planner_analyze");
|
const [selectedFunction, setSelectedFunction] = useState("signal_planner_analyze");
|
||||||
const [provider, setProvider] = useState<(typeof PROVIDER_OPTIONS)[number]>("openai");
|
const [tierAlias, setTierAlias] = useState<(typeof TIER_OPTIONS)[number]>("high");
|
||||||
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 routingQuery = useQuery({
|
const routingQuery = useQuery({
|
||||||
queryKey: ["model-routing-functions"],
|
queryKey: ["model-routing-functions"],
|
||||||
|
|
@ -53,27 +61,16 @@ export default function ModelRoutingConfigPage() {
|
||||||
if (!current && fallback.function_name) {
|
if (!current && fallback.function_name) {
|
||||||
setSelectedFunction(fallback.function_name);
|
setSelectedFunction(fallback.function_name);
|
||||||
}
|
}
|
||||||
const nextProvider = PROVIDER_OPTIONS.includes(fallback.provider as (typeof PROVIDER_OPTIONS)[number])
|
const nextTier = TIER_OPTIONS.includes(fallback.tier_alias as (typeof TIER_OPTIONS)[number])
|
||||||
? (fallback.provider as (typeof PROVIDER_OPTIONS)[number])
|
? (fallback.tier_alias as (typeof TIER_OPTIONS)[number])
|
||||||
: "openai";
|
|
||||||
const nextTier = TIER_OPTIONS.includes(fallback.tier as (typeof TIER_OPTIONS)[number])
|
|
||||||
? (fallback.tier as (typeof TIER_OPTIONS)[number])
|
|
||||||
: "high";
|
: "high";
|
||||||
setProvider(nextProvider);
|
setTierAlias(nextTier);
|
||||||
setTier(nextTier);
|
|
||||||
setModel(fallback.model || "");
|
|
||||||
setApiEnv(fallback.api_env || "");
|
|
||||||
setBaseURL(fallback.base_url || "");
|
|
||||||
}, [current, items]);
|
}, [current, items]);
|
||||||
|
|
||||||
const updateMutation = useMutation({
|
const updateMutation = useMutation({
|
||||||
mutationFn: () =>
|
mutationFn: () =>
|
||||||
modelRoutingApi.updateFunction(selectedFunction, {
|
modelRoutingApi.updateFunction(selectedFunction, {
|
||||||
provider,
|
tier_alias: tierAlias,
|
||||||
model,
|
|
||||||
api_env: apiEnv,
|
|
||||||
tier,
|
|
||||||
base_url: baseURL || undefined,
|
|
||||||
}),
|
}),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries({ queryKey: ["model-routing-functions"] });
|
queryClient.invalidateQueries({ queryKey: ["model-routing-functions"] });
|
||||||
|
|
@ -86,7 +83,7 @@ export default function ModelRoutingConfigPage() {
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<ConfigSubpageHeader
|
<ConfigSubpageHeader
|
||||||
title="模型路由配置"
|
title="模型路由配置"
|
||||||
description="按 function_name 动态调整 provider / model / api_env / tier,立即生效。"
|
description="按 function_name 选择逻辑 tier 槽位,只暴露 low/high,真实模型由配置真源解析。"
|
||||||
icon={<Settings2 className="h-5 w-5" />}
|
icon={<Settings2 className="h-5 w-5" />}
|
||||||
tone="blue"
|
tone="blue"
|
||||||
meta={<span>{routingQuery.isError ? "读取失败" : `已加载 ${items.length} 条路由`}</span>}
|
meta={<span>{routingQuery.isError ? "读取失败" : `已加载 ${items.length} 条路由`}</span>}
|
||||||
|
|
@ -94,16 +91,16 @@ export default function ModelRoutingConfigPage() {
|
||||||
<FeatureIntroGuide
|
<FeatureIntroGuide
|
||||||
featureId="model-routing-config"
|
featureId="model-routing-config"
|
||||||
title="模型路由配置"
|
title="模型路由配置"
|
||||||
purpose="给不同函数指定不同模型,控制成本、速度和效果。"
|
purpose="给不同函数选择 low/high 路由槽位,统一控制成本、速度和效果。"
|
||||||
whenToUse="某个功能效果差、成本高、超时多时,用它定向切换函数路由。"
|
whenToUse="某个功能效果差、成本高、超时多时,用它切换函数当前使用的 tier。"
|
||||||
steps={[
|
steps={[
|
||||||
"在右侧选中目标函数,左侧会自动回填当前配置。",
|
"在右侧选中目标函数,左侧会自动回填当前逻辑 tier。",
|
||||||
"调整 provider / tier / model / api_env,点“保存路由”。",
|
"只调整 low / high tier_alias,点“保存路由”。",
|
||||||
"触发一次该功能调用,观察日志是否按新路由生效。",
|
"触发一次该功能调用,观察最近一次执行是否出现 low -> high 升级。",
|
||||||
]}
|
]}
|
||||||
outputs={[
|
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">
|
<Card className="border-slate-200/80 bg-white/90 shadow-sm backdrop-blur-sm dark:border-slate-800 dark:bg-slate-900/85">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="text-base">编辑器</CardTitle>
|
<CardTitle className="text-base">编辑器</CardTitle>
|
||||||
<CardDescription>选择函数并更新路由参数。</CardDescription>
|
<CardDescription>选择函数并更新逻辑 tier。</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-3">
|
<CardContent className="space-y-3">
|
||||||
<Input
|
<Input
|
||||||
|
|
@ -120,21 +117,9 @@ export default function ModelRoutingConfigPage() {
|
||||||
placeholder="function_name"
|
placeholder="function_name"
|
||||||
/>
|
/>
|
||||||
<p className="text-xs text-slate-500 dark:text-slate-400">{getFunctionUsageHint(selectedFunction)}</p>
|
<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
|
<select
|
||||||
value={provider}
|
value={tierAlias}
|
||||||
onChange={(e) => setProvider(e.target.value as (typeof PROVIDER_OPTIONS)[number])}
|
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"
|
|
||||||
>
|
|
||||||
{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"
|
className="h-10 rounded-md border border-input bg-background px-3 text-sm"
|
||||||
>
|
>
|
||||||
{TIER_OPTIONS.map((item) => (
|
{TIER_OPTIONS.map((item) => (
|
||||||
|
|
@ -143,13 +128,12 @@ export default function ModelRoutingConfigPage() {
|
||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</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>
|
</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
|
<Button
|
||||||
onClick={() => updateMutation.mutate()}
|
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"
|
className="w-full bg-blue-600 text-white hover:bg-blue-700"
|
||||||
>
|
>
|
||||||
{updateMutation.isPending ? "保存中..." : "保存路由"}
|
{updateMutation.isPending ? "保存中..." : "保存路由"}
|
||||||
|
|
@ -191,10 +175,11 @@ export default function ModelRoutingConfigPage() {
|
||||||
<TableHeader>
|
<TableHeader>
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableHead>Function</TableHead>
|
<TableHead>Function</TableHead>
|
||||||
<TableHead>Provider</TableHead>
|
|
||||||
<TableHead>Model</TableHead>
|
|
||||||
<TableHead>Tier</TableHead>
|
<TableHead>Tier</TableHead>
|
||||||
<TableHead>Source</TableHead>
|
<TableHead>Source</TableHead>
|
||||||
|
<TableHead>Rule</TableHead>
|
||||||
|
<TableHead>Last</TableHead>
|
||||||
|
<TableHead>升级</TableHead>
|
||||||
<TableHead className="text-right">操作</TableHead>
|
<TableHead className="text-right">操作</TableHead>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
|
|
@ -214,10 +199,21 @@ export default function ModelRoutingConfigPage() {
|
||||||
{getFunctionUsageHint(item.function_name)}
|
{getFunctionUsageHint(item.function_name)}
|
||||||
</div>
|
</div>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>{item.provider}</TableCell>
|
<TableCell>{item.tier_alias}</TableCell>
|
||||||
<TableCell>{item.model}</TableCell>
|
|
||||||
<TableCell>{item.tier}</TableCell>
|
|
||||||
<TableCell>{item.source}</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">
|
<TableCell className="text-right">
|
||||||
<Button size="sm" variant="outline" onClick={() => setSelectedFunction(item.function_name)}>
|
<Button size="sm" variant="outline" onClick={() => setSelectedFunction(item.function_name)}>
|
||||||
编辑
|
编辑
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,13 @@ import urllib.request
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from pathlib import Path
|
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:
|
def load_env_file(env_path: Path) -> None:
|
||||||
if not env_path.exists():
|
if not env_path.exists():
|
||||||
|
|
@ -30,33 +37,18 @@ def load_env_file(env_path: Path) -> None:
|
||||||
os.environ.setdefault(key, value)
|
os.environ.setdefault(key, value)
|
||||||
|
|
||||||
|
|
||||||
def provider_runtime(provider: str) -> dict[str, str]:
|
def provider_runtime(provider: str, provider_defaults: dict[str, dict[str, str]]) -> dict[str, str]:
|
||||||
p = provider.strip().lower()
|
aliases = {"zhipu": "glm", "chatglm": "glm", "deep-seek": "deepseek", "local": "ollama"}
|
||||||
if p in {"glm", "zhipu"}:
|
p = aliases.get(provider.strip().lower(), provider.strip().lower())
|
||||||
return {
|
defaults = dict(provider_defaults.get(p) or {})
|
||||||
"provider": p,
|
api_env = str(defaults.get("api_env") or "").strip()
|
||||||
"base_url": str(os.getenv("GLM_BASE_URL", "")).strip(),
|
api_key = str(os.getenv(api_env, "")).strip() if api_env else ""
|
||||||
"api_key": str(os.getenv("GLM_API_KEY", "")).strip(),
|
if p == "glm":
|
||||||
}
|
api_key = str(os.getenv("DASHSCOPE_API_KEY", api_key)).strip()
|
||||||
if p == "openai":
|
base_url = str(defaults.get("base_url") or "").strip()
|
||||||
return {
|
if p == "ollama" and not base_url:
|
||||||
"provider": p,
|
base_url = str(os.getenv("OLLAMA_BASE_URL", os.getenv("OLLAMA_HOST", "http://127.0.0.1:11434"))).strip()
|
||||||
"base_url": str(os.getenv("OPENAI_BASE_URL", "https://api.openai.com/v1")).strip(),
|
return {"provider": p, "provider_name": str(defaults.get("name") or p).strip(), "base_url": base_url, "api_key": api_key}
|
||||||
"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 extract_json_detail(text: str) -> dict[str, str]:
|
def extract_json_detail(text: str) -> dict[str, str]:
|
||||||
|
|
@ -101,8 +93,9 @@ def probe_model(
|
||||||
model: str,
|
model: str,
|
||||||
timeout: float,
|
timeout: float,
|
||||||
insecure: bool,
|
insecure: bool,
|
||||||
|
provider_defaults: dict[str, dict[str, str]],
|
||||||
) -> dict[str, object]:
|
) -> dict[str, object]:
|
||||||
runtime = provider_runtime(provider)
|
runtime = provider_runtime(provider, provider_defaults)
|
||||||
base_url = runtime["base_url"].rstrip("/")
|
base_url = runtime["base_url"].rstrip("/")
|
||||||
api_key = runtime["api_key"]
|
api_key = runtime["api_key"]
|
||||||
started = time.perf_counter()
|
started = time.perf_counter()
|
||||||
|
|
@ -200,7 +193,7 @@ def render_result_line(item: dict[str, object]) -> str:
|
||||||
status_text = "✓ 正常" if bool(item["ok"]) else "✗ 失败"
|
status_text = "✓ 正常" if bool(item["ok"]) else "✗ 失败"
|
||||||
elapsed_text = f"{float(item['elapsed']):.2f}s"
|
elapsed_text = f"{float(item['elapsed']):.2f}s"
|
||||||
return (
|
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})"
|
f"{status_text} ({elapsed_text})"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -236,10 +229,15 @@ def main() -> int:
|
||||||
|
|
||||||
insecure = bool(args.insecure and not args.strict_tls)
|
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"
|
provider_defaults = get_provider_defaults(refresh=True)
|
||||||
low_model = str(os.getenv("LLM_ROUTING_LOW_MODEL", os.getenv("GLM_MODEL", "GLM-4.7")) or "").strip() or "GLM-4.7"
|
tier_defaults = get_tier_defaults(refresh=True)
|
||||||
high_provider = str(os.getenv("LLM_ROUTING_HIGH_PROVIDER", os.getenv("LLM_PROVIDER", "openai")) or "").strip() or "openai"
|
low_tier = dict(tier_defaults.get("low") or {})
|
||||||
high_model = str(os.getenv("LLM_ROUTING_HIGH_MODEL", os.getenv("OPENAI_MODEL", "GLM-5")) or "").strip() or "GLM-5"
|
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)]
|
targets: list[tuple[str, str, str]] = [("LOW", low_provider, low_model), ("HIGH", high_provider, high_model)]
|
||||||
deduped: list[tuple[str, str, str]] = []
|
deduped: list[tuple[str, str, str]] = []
|
||||||
|
|
@ -266,6 +264,7 @@ def main() -> int:
|
||||||
model=model,
|
model=model,
|
||||||
timeout=args.timeout,
|
timeout=args.timeout,
|
||||||
insecure=insecure,
|
insecure=insecure,
|
||||||
|
provider_defaults=provider_defaults,
|
||||||
)
|
)
|
||||||
results.append(result)
|
results.append(result)
|
||||||
print(f"│ {render_result_line(result)}")
|
print(f"│ {render_result_line(result)}")
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
"""OpenClaw 路由规则静态校验(PMFlow 三引擎版)。"""
|
"""OpenClaw 路由规则静态校验(PMFlow 双层版)。"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
|
@ -10,8 +10,8 @@ from pathlib import Path
|
||||||
from typing import Any, Optional
|
from typing import Any, Optional
|
||||||
|
|
||||||
VALID_PROVIDERS = {"glm", "openai", "deepseek", "ollama"}
|
VALID_PROVIDERS = {"glm", "openai", "deepseek", "ollama"}
|
||||||
VALID_TIERS = {"low", "high", "safety"}
|
VALID_TIERS = {"low", "high"}
|
||||||
DEFAULT_RULES_RELATIVE_PATH = "../.openclaw/pmflow-routing-rules.json"
|
DEFAULT_RULES_RELATIVE_PATH = "backend/.openclaw/pmflow-routing-rules.json"
|
||||||
SCRIPT_ROOT = Path(__file__).resolve().parents[1]
|
SCRIPT_ROOT = Path(__file__).resolve().parents[1]
|
||||||
BACKEND_DIR = SCRIPT_ROOT / "backend"
|
BACKEND_DIR = SCRIPT_ROOT / "backend"
|
||||||
|
|
||||||
|
|
@ -85,7 +85,10 @@ def _resolve_rules_path(raw: str) -> Path:
|
||||||
path = Path(token)
|
path = Path(token)
|
||||||
if path.is_absolute():
|
if path.is_absolute():
|
||||||
return path
|
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]]:
|
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":
|
if actual_tier != "high":
|
||||||
problems.append(f"function {fn} expected tier=high, actual={actual_tier}")
|
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 = {
|
model_hints = {
|
||||||
"low": "GLM-4.7",
|
"low": "qwen-plus",
|
||||||
"high": "MiniMax-M2.5",
|
"high": "GLM-5",
|
||||||
"safety": "deepseek-chat",
|
|
||||||
}
|
}
|
||||||
api_env_hints = {
|
api_env_hints = {
|
||||||
"low": "GLM_API_KEY",
|
"low": "DASHSCOPE_API_KEY",
|
||||||
"high": "OPENAI_API_KEY",
|
"high": "OPENAI_API_KEY",
|
||||||
"safety": "OPENAI_API_KEY",
|
|
||||||
}
|
}
|
||||||
for tier, expected_model in model_hints.items():
|
for tier, expected_model in model_hints.items():
|
||||||
tier_rows = [
|
tier_rows = [
|
||||||
|
|
|
||||||
|
|
@ -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>{
|
|
||||||
"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
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}</pre>
|
|
||||||
</div>
|
|
||||||
</body></html>
|
|
||||||
|
|
@ -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
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
Loading…
Reference in New Issue