制定双Agent能力提升与路由治理方案

This commit is contained in:
Yuuuu 2026-03-11 12:05:16 +08:00
parent 331e9109e2
commit 2fd932e395
29 changed files with 540 additions and 304 deletions

View File

@ -1,3 +1,6 @@
# 唯一配置文件,backend/.env 已移除
# 所有环境变量在此统一管理
# Docker Compose Runtime Secrets / Config # Docker Compose Runtime Secrets / Config
POSTGRES_DB=pmflow POSTGRES_DB=pmflow
POSTGRES_USER=pmflow POSTGRES_USER=pmflow
@ -8,37 +11,53 @@ DB_PORT=5435
REDIS_PORT=6379 REDIS_PORT=6379
BACKEND_PORT=8000 BACKEND_PORT=8000
FRONTEND_PORT=5174 FRONTEND_PORT=5174
PMFLOW_EXPECTED_DB_DIALECT=postgresql
# Backend Runtime # Backend Runtime
CORS_ORIGINS=http://localhost:5174,http://127.0.0.1:5174 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
OPENAI_BASE_URL=https://api.edgefn.net/v1
OPENAI_API_KEY=
OPENAI_MODEL=MiniMax-M2.5
OPENCLAW_ROUTING_ENABLED=false OPENCLAW_ROUTING_ENABLED=false
OPENCLAW_ROUTING_RULES_PATH=/app/backend/.openclaw/pmflow-routing-rules.json OPENCLAW_ROUTING_RULES_PATH=../.openclaw/pmflow-routing-rules.json
OPENCLAW_AGENTS_DEFAULT_TIMEOUT_SECONDS=120 OPENCLAW_AGENTS_DEFAULT_TIMEOUT_SECONDS=120
LLM_DIRECT_EMERGENCY_ENABLED=true
LLM_MIXED_ROUTING_ENABLED=true LLM_MIXED_ROUTING_ENABLED=true
LLM_ROUTING_DEFAULT_TIER=active
LLM_LOW_TO_HIGH_ESCALATION_ENABLED=true LLM_LOW_TO_HIGH_ESCALATION_ENABLED=true
LLM_REQUEST_TIMEOUT=90
# 模型名统一使用裸名,不带供应商前缀
# GLM-4.7 配 GLM_API_KEY
# MiniMax-M2.5 配 OPENAI_API_KEY
GLM_API_KEY=
GLM_BASE_URL=https://api.edgefn.net/v1
GLM_MODEL=GLM-4.7
OPENAI_API_KEY=
OPENAI_BASE_URL=https://api.edgefn.net/v1
OPENAI_MODEL=MiniMax-M2.5
LLM_ROUTING_LOW_PROVIDER=glm LLM_ROUTING_LOW_PROVIDER=glm
LLM_ROUTING_LOW_MODEL=GLM-4.7 LLM_ROUTING_LOW_MODEL=GLM-4.7
LLM_ROUTING_HIGH_PROVIDER=openai LLM_ROUTING_HIGH_PROVIDER=openai
LLM_ROUTING_HIGH_MODEL=MiniMax-M2.5 LLM_ROUTING_HIGH_MODEL=MiniMax-M2.5
LLM_ROUTING_SAFETY_PROVIDER=glm LLM_ROUTING_SAFETY_PROVIDER=glm
LLM_ROUTING_SAFETY_MODEL=GLM-4.7 LLM_ROUTING_SAFETY_MODEL=GLM-4.7
LLM_DIRECT_EMERGENCY_ENABLED=true
DEEPSEEK_API_KEY=
DEEPSEEK_BASE_URL=https://api.deepseek.com/v1
DEEPSEEK_MODEL=deepseek-chat
OLLAMA_HOST=http://host.docker.internal:11434
OLLAMA_BASE_URL=http://host.docker.internal:11434
OLLAMA_MODEL=llama3:latest
OLLAMA_CHAT_MODEL=llama3:8b
OLLAMA_EMBEDDING_MODEL=nomic-embed-text
# Adaptive RAG 深入思考模式
RAG_DEEP_RETRIEVAL_STEPS=5 RAG_DEEP_RETRIEVAL_STEPS=5
RAG_DEEP_VECTOR_TOP_K=12 RAG_DEEP_VECTOR_TOP_K=12
RAG_DEEP_CONTEXT_BLOCKS=16 RAG_DEEP_CONTEXT_BLOCKS=16
RAG_DEEP_ANSWER_MAX_TOKENS=1400 RAG_DEEP_ANSWER_MAX_TOKENS=1400
GLM_API_KEY=
GLM_BASE_URL=https://api.edgefn.net/v1
GLM_MODEL=GLM-4.7
DEEPSEEK_API_KEY=
OLLAMA_HOST=http://host.docker.internal:11434
OLLAMA_BASE_URL=http://host.docker.internal:11434
OLLAMA_MODEL=llama3:latest
# Feishu long connection retry policy (1s -> 2s -> 4s, max 3 attempts) # Feishu long connection retry policy (1s -> 2s -> 4s, max 3 attempts)
FEISHU_LONG_CONNECTION_RETRY_BASE_SECONDS=1 FEISHU_LONG_CONNECTION_RETRY_BASE_SECONDS=1

View File

@ -1,6 +1,6 @@
{ {
"version": "v2", "version": "v2",
"updated_at": "2026-03-05T00:00:00Z", "updated_at": "2026-03-11T00:00:00Z",
"source_of_truth": "openclaw", "source_of_truth": "openclaw",
"description": "PMFlow 三引擎统一路由:所有主链路统一经 OpenClaw OpenAI-Compatible 网关。", "description": "PMFlow 三引擎统一路由:所有主链路统一经 OpenClaw OpenAI-Compatible 网关。",
"rules": [ "rules": [
@ -9,8 +9,9 @@
"enabled": true, "enabled": true,
"priority": 300, "priority": 300,
"tier": "low", "tier": "low",
"provider": "openai", "provider": "glm",
"model": "zai/glm-4-flash", "model": "GLM-4.7",
"api_env": "GLM_API_KEY",
"match_any": [ "match_any": [
"feishu_level_classification", "feishu_level_classification",
"feishu_signal_extraction", "feishu_signal_extraction",
@ -26,7 +27,8 @@
"priority": 220, "priority": 220,
"tier": "high", "tier": "high",
"provider": "openai", "provider": "openai",
"model": "minimax/MiniMax-M2.5", "model": "MiniMax-M2.5",
"api_env": "OPENAI_API_KEY",
"match_any": [ "match_any": [
"adaptive_rag_answer", "adaptive_rag_answer",
"feishu_project_prefill", "feishu_project_prefill",
@ -49,7 +51,8 @@
"priority": 200, "priority": 200,
"tier": "high", "tier": "high",
"provider": "openai", "provider": "openai",
"model": "minimax/MiniMax-M2.5", "model": "MiniMax-M2.5",
"api_env": "OPENAI_API_KEY",
"match_any": [ "match_any": [
"analyze_food_calories", "analyze_food_calories",
"feishu_new_project_brief", "feishu_new_project_brief",
@ -67,7 +70,8 @@
"priority": 50, "priority": 50,
"tier": "safety", "tier": "safety",
"provider": "openai", "provider": "openai",
"model": "deepseek/deepseek-chat", "model": "deepseek-chat",
"api_env": "OPENAI_API_KEY",
"match_all": true "match_all": true
} }
] ]

View File

@ -50,6 +50,9 @@ backend/
├── Dockerfile ├── Dockerfile
└── tests/ └── tests/
``` ```
- 新增:`backend/app/api/llm_providers.py`(AI 管家 LLM provider preset 配置读取接口)
- 移除:`backend/.env.example`(配置模板统一到根目录 `.env.example`)
- 移除:`backend/.openclaw/pmflow-routing-rules.json`(路由规则统一到根目录 `.openclaw/`)
## 5. 前端结构(`frontend/`) ## 5. 前端结构(`frontend/`)
```text ```text
@ -140,6 +143,7 @@ uvicorn app.main:app --reload --host 0.0.0.0 --port 8000
## 9. 新增代码的参考模式 ## 9. 新增代码的参考模式
- 新增后端接口:参考 `backend/app/api/sstc.py` 的结构 - 新增后端接口:参考 `backend/app/api/sstc.py` 的结构
- 配置读取类接口:参考 `backend/app/api/llm_providers.py`(`GET /ai-steward/llm/providers`)
- 新增前端 API 调用:参考 `frontend/src/api/sstc.ts` - 新增前端 API 调用:参考 `frontend/src/api/sstc.ts`
- 新增数据库表:参考 `alembic/versions/` 最新 migration - 新增数据库表:参考 `alembic/versions/` 最新 migration
- 新增飞书事件处理:参考 `backend/app/services/feishu_integration.py` - 新增飞书事件处理:参考 `backend/app/services/feishu_integration.py`
@ -156,3 +160,6 @@ uvicorn app.main:app --reload --host 0.0.0.0 --port 8000
- 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 意图识别已启用 LLM 主路径(`AI_PMO_INTENT_LLM_ENABLED`,默认开启);当模型超时/不可用/结构化解析失败时自动降级到规则链路,不中断主流程 - AI PMO 意图识别已启用 LLM 主路径(`AI_PMO_INTENT_LLM_ENABLED`,默认开启);当模型超时/不可用/结构化解析失败时自动降级到规则链路,不中断主流程
- 环境变量以根目录 `.env` / `.env.example` 为唯一配置源;`backend/.env` 已移除且不再读取
- OpenClaw 路由规则统一由根目录 `.openclaw/pmflow-routing-rules.json` 管理,容器通过 `OPENCLAW_ROUTING_RULES_PATH` 相对路径 `../.openclaw/pmflow-routing-rules.json` 读取
- 模型命名统一使用裸模型名(如 `GLM-4.7`、`MiniMax-M2.5`),不使用供应商前缀模型名

View File

@ -120,11 +120,11 @@ source venv/bin/activate
pip install -r requirements.txt pip install -r requirements.txt
# 配置环境变量 # 配置环境变量
cp .env.example .env cp /opt/neural-sync/.env.example /opt/neural-sync/.env
nano .env nano /opt/neural-sync/.env
``` ```
编辑 `.env` 文件: 编辑项目根目录 `.env` 文件:
```env ```env
# 数据库 - PostgreSQL # 数据库 - PostgreSQL
@ -227,7 +227,7 @@ Type=simple
User=root User=root
WorkingDirectory=/opt/neural-sync/backend WorkingDirectory=/opt/neural-sync/backend
Environment="PATH=/opt/neural-sync/backend/venv/bin" Environment="PATH=/opt/neural-sync/backend/venv/bin"
EnvironmentFile=/opt/neural-sync/backend/.env EnvironmentFile=/opt/neural-sync/.env
ExecStart=/opt/neural-sync/backend/venv/bin/uvicorn app.main:app --host 127.0.0.1 --port 8000 ExecStart=/opt/neural-sync/backend/venv/bin/uvicorn app.main:app --host 127.0.0.1 --port 8000
Restart=always Restart=always
RestartSec=10 RestartSec=10

View File

@ -1,96 +0,0 @@
# Database Configuration
DATABASE_URL=postgresql+asyncpg://pmflow:pmflow_pass@db:5432/pmflow
# 可选:启动方言守卫(postgresql / sqlite)。配置后若不一致将拒绝启动。
PMFLOW_EXPECTED_DB_DIALECT=postgresql
# For PostgreSQL with pgvector:
# DATABASE_URL=postgresql+asyncpg://postgres:postgres@localhost:5435/neural_sync
# For local SQLite quick-start:
# DATABASE_URL=sqlite+aiosqlite:///./neural_sync.db
# Redis Configuration
REDIS_URL=redis://:pmflow_redis_pass@redis:6379/0
# Knowledge Graph Incremental Scheduler
KG_INCREMENTAL_INTERVAL_MINUTES=5
KG_INCREMENTAL_DELTA_LIMIT=1000
KG_INCREMENTAL_EVENT_BATCH=200
KG_CODE_SCAN_INTERVAL_HOURS=6
# ========================================
# LLM / OpenClaw Control Plane
# ========================================
# 单一控制面:PMFlow 仅调用 OpenClaw 网关(OpenAI-Compatible)
LLM_PROVIDER=openai
OPENAI_BASE_URL=http://openclaw:18789/v1
OPENAI_API_KEY=<OPENCLAW_GATEWAY_TOKEN>
# OpenClaw 函数路由
OPENCLAW_ROUTING_ENABLED=true
OPENCLAW_ROUTING_RULES_PATH=/app/backend/.openclaw/pmflow-routing-rules.json
# OpenClaw agent 默认超时(秒):用于注入 chat/completions -> agents.defaults.timeoutSeconds
OPENCLAW_AGENTS_DEFAULT_TIMEOUT_SECONDS=120
# 关闭 PMFlow 内部 mixed routing,优先以 OpenClaw 规则为准
LLM_MIXED_ROUTING_ENABLED=false
LLM_ROUTING_DEFAULT_TIER=active
LLM_LOW_TO_HIGH_ESCALATION_ENABLED=true
# low/high/safety tier 默认模型(由网关解释)
LLM_ROUTING_LOW_PROVIDER=openai
LLM_ROUTING_LOW_MODEL=zai/glm-4-flash
LLM_ROUTING_HIGH_PROVIDER=openai
LLM_ROUTING_HIGH_MODEL=minimax/MiniMax-M2.5
LLM_ROUTING_SAFETY_PROVIDER=openai
LLM_ROUTING_SAFETY_MODEL=deepseek/deepseek-chat
# 应急开关:仅在网关不可用时允许直连 provider(默认关闭)
LLM_DIRECT_EMERGENCY_ENABLED=false
LLM_FALLBACK_PROVIDER=ollama
# Adaptive RAG 深入思考模式(前端开关=deep 时生效)
RAG_DEEP_RETRIEVAL_STEPS=5
RAG_DEEP_VECTOR_TOP_K=12
RAG_DEEP_CONTEXT_BLOCKS=16
RAG_DEEP_ANSWER_MAX_TOKENS=1400
# --- Emergency Direct Provider Keys (optional) ---
GLM_API_KEY=
GLM_BASE_URL=https://open.bigmodel.cn/api/paas/v4
GLM_MODEL=glm-4-flash
DEEPSEEK_API_KEY=
DEEPSEEK_BASE_URL=https://api.deepseek.com/v1
# --- Ollama 本地配置(应急本地兜底) ---
OLLAMA_HOST=http://127.0.0.1:11434
OLLAMA_CHAT_MODEL=llama3:8b
OLLAMA_EMBEDDING_MODEL=nomic-embed-text
OLLAMA_BASE_URL=http://127.0.0.1:11434
OLLAMA_MODEL=llama3:latest
# ========================================
# Multi-Model Voting Configuration (仅 Ollama 支持)
# For high-impact signals (impact_score >= VOTING_THRESHOLD), multiple models will vote
# Available models: llama3, llama3.2, qwen2.5, mistral, deepseek-coder, etc.
VOTING_MODELS=llama3,qwen2.5,mistral
VOTING_THRESHOLD=0.8
# Business Context for AI Strategic Analysis
# Customize this to match your actual product/team
# This context is used by the AI to evaluate external signals
BUSINESS_CONTEXT=Pixelle-Video: An AI-powered automated short video generation platform. Core features: topic-to-video automation (script generation → AI images/videos → TTS → BGM → final video). Tech stack: Python, Streamlit, FastAPI, ComfyUI workflows. AI models: FLUX/Qwen/SDXL for images, WAN 2.1/2.2 for videos, Edge TTS/Index TTS for voice. Supports multiple pipelines: standard generation, custom assets, digital human, image-to-video, action transfer.
# Plane PM Tool Integration (optional, simulation mode if not set)
PLANE_API_URL=https://api.plane.so/api/v1
PLANE_API_KEY=
PLANE_WORKSPACE_SLUG=
PLANE_PROJECT_ID=
# Lark/Feishu Bot Integration (optional)
LARK_APP_ID=
LARK_APP_SECRET=
LARK_VERIFICATION_TOKEN=
# 飞书长连接重试策略(指数退避 1s -> 2s -> 4s,最多 3 次)
FEISHU_LONG_CONNECTION_RETRY_BASE_SECONDS=1
FEISHU_LONG_CONNECTION_RETRY_MAX_ATTEMPTS=3

View File

@ -1,74 +0,0 @@
{
"version": "v2",
"updated_at": "2026-03-05T00:00:00Z",
"source_of_truth": "openclaw",
"description": "PMFlow 三引擎统一路由:所有主链路统一经 OpenClaw OpenAI-Compatible 网关。",
"rules": [
{
"name": "engine_low_execute_glm_flash",
"enabled": true,
"priority": 300,
"tier": "low",
"provider": "glm",
"model": "GLM-4.7",
"match_any": [
"feishu_level_classification",
"feishu_signal_extraction",
"rag_intent_classification",
"classify_intent",
"feishu_humanized_reply",
"extract_context_insights"
]
},
{
"name": "engine_high_reasoning_minimax",
"enabled": true,
"priority": 220,
"tier": "high",
"provider": "openai",
"model": "MiniMax-M2.5",
"match_any": [
"adaptive_rag_answer",
"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"
]
},
{
"name": "engine_high_extended_reasoning",
"enabled": true,
"priority": 200,
"tier": "high",
"provider": "openai",
"model": "MiniMax-M2.5",
"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/deepseek-chat",
"match_all": true
}
]
}

View File

@ -13,6 +13,7 @@ COPY backend/requirements.txt /tmp/requirements.txt
RUN pip install --no-cache-dir -r /tmp/requirements.txt RUN pip install --no-cache-dir -r /tmp/requirements.txt
COPY backend /app/backend COPY backend /app/backend
COPY .openclaw /app/.openclaw
EXPOSE 8000 EXPOSE 8000

View File

@ -22,6 +22,7 @@ from .budget import router as budget_router
from .scheduler import router as scheduler_router from .scheduler import router as scheduler_router
from .notifications import router as notifications_router from .notifications import router as notifications_router
from .llm_engines import router as llm_engines_router from .llm_engines import router as llm_engines_router
from .llm_providers import router as llm_providers_router
from .prompt_profiles import router as prompt_profiles_router from .prompt_profiles import router as prompt_profiles_router
from .knowledge_graph import router as knowledge_graph_router from .knowledge_graph import router as knowledge_graph_router
from .operation_catalog import router as operation_catalog_router from .operation_catalog import router as operation_catalog_router
@ -46,6 +47,7 @@ __all__ = [
"scheduler_router", "scheduler_router",
"notifications_router", "notifications_router",
"llm_engines_router", "llm_engines_router",
"llm_providers_router",
"prompt_profiles_router", "prompt_profiles_router",
"knowledge_graph_router", "knowledge_graph_router",
"operation_catalog_router", "operation_catalog_router",

View File

@ -0,0 +1,58 @@
"""LLM provider preset API."""
from __future__ import annotations
import os
from typing import Any
from fastapi import APIRouter, Depends
from ..core.response import success_response
from ..dependencies.auth import get_current_user
from ..models import User
router = APIRouter(prefix="/ai-steward/llm", tags=["AI Steward"])
def _build_provider_payload() -> dict[str, Any]:
low_model = str(os.getenv("LLM_ROUTING_LOW_MODEL", "GLM-4.7") or "GLM-4.7").strip()
high_model = str(os.getenv("LLM_ROUTING_HIGH_MODEL", "MiniMax-M2.5") or "MiniMax-M2.5").strip()
base_url = str(os.getenv("OPENAI_BASE_URL", "https://api.edgefn.net/v1") or "https://api.edgefn.net/v1").strip()
ollama_base_url = str(
os.getenv("OLLAMA_BASE_URL", os.getenv("OLLAMA_HOST", "http://127.0.0.1:11434")) or "http://127.0.0.1:11434"
).strip()
return {
"low": low_model,
"high": high_model,
"base_url": base_url,
"providers": {
"glm": {
"model": str(os.getenv("GLM_MODEL", low_model) or low_model).strip(),
"base_url": str(os.getenv("GLM_BASE_URL", base_url) or base_url).strip(),
"api_env": "GLM_API_KEY",
},
"openai": {
"model": str(os.getenv("OPENAI_MODEL", high_model) or high_model).strip(),
"base_url": base_url,
"api_env": "OPENAI_API_KEY",
},
"deepseek": {
"model": str(os.getenv("DEEPSEEK_MODEL", "deepseek-chat") or "deepseek-chat").strip(),
"base_url": str(os.getenv("DEEPSEEK_BASE_URL", "https://api.deepseek.com/v1") or "").strip(),
"api_env": "DEEPSEEK_API_KEY",
},
"ollama": {
"model": str(os.getenv("OLLAMA_MODEL", "llama3:latest") or "llama3:latest").strip(),
"base_url": ollama_base_url,
"api_env": "",
},
},
}
@router.get("/providers")
async def get_llm_provider_presets(
current_user: User = Depends(get_current_user),
):
_ = current_user
return success_response(_build_provider_payload())

View File

@ -25,6 +25,7 @@ from .feishu import router as feishu_router
from .wecom import router as wecom_router from .wecom import router as wecom_router
from .notifications import router as notifications_router from .notifications import router as notifications_router
from .llm_engines import router as llm_engines_router from .llm_engines import router as llm_engines_router
from .llm_providers import router as llm_providers_router
from .ai_logs import router as ai_logs_router from .ai_logs import router as ai_logs_router
from .prompt_profiles import router as prompt_profiles_router from .prompt_profiles import router as prompt_profiles_router
from .rag import router as rag_router from .rag import router as rag_router
@ -57,6 +58,7 @@ api_router.include_router(feishu_router)
api_router.include_router(wecom_router) api_router.include_router(wecom_router)
api_router.include_router(notifications_router) api_router.include_router(notifications_router)
api_router.include_router(llm_engines_router) api_router.include_router(llm_engines_router)
api_router.include_router(llm_providers_router)
api_router.include_router(ai_logs_router) api_router.include_router(ai_logs_router)
api_router.include_router(prompt_profiles_router) api_router.include_router(prompt_profiles_router)
api_router.include_router(rag_router) api_router.include_router(rag_router)

View File

@ -525,7 +525,8 @@ PROMPT_AI_PMO_INTENT_CLASSIFICATION = """
3. project_id 无法确定时返回 null,不要猜测无依据项目。 3. project_id 无法确定时返回 null,不要猜测无依据项目。
4. 噪音/寒暄/无关消息返回 ignore。 4. 噪音/寒暄/无关消息返回 ignore。
5. 禁止输出 thinking、analysis、解释句、Markdown、代码块。 5. 禁止输出 thinking、analysis、解释句、Markdown、代码块。
6. 只输出 JSON,不要额外文本。 6. 若消息表达“新增/增加/添加/新建/创建/建立 + 项目”(如“给我新增个项目”),优先判定 project.create;仅当明确是“信号源/来源/webhook/rss/github 接入或创建信号”时才判 signal_source.onboard 或 signal.create。
7. 只输出 JSON,不要额外文本。
""" """

View File

@ -9,10 +9,9 @@ from pathlib import Path
from typing import Optional from typing import Optional
from dotenv import load_dotenv from dotenv import load_dotenv
# 统一从 backend/.env 加载配置,避免因启动目录不同读到错误环境 # 仅从项目根目录 .env 加载,作为唯一配置源
BACKEND_DIR = Path(__file__).resolve().parents[2] BACKEND_DIR = Path(__file__).resolve().parents[2]
PROJECT_ROOT = BACKEND_DIR.parent PROJECT_ROOT = BACKEND_DIR.parent
load_dotenv(BACKEND_DIR / ".env", override=False)
load_dotenv(PROJECT_ROOT / ".env", override=False) load_dotenv(PROJECT_ROOT / ".env", override=False)
# 默认数据库固定到 backend/neural_sync.db,避免相对路径导致多库混用 # 默认数据库固定到 backend/neural_sync.db,避免相对路径导致多库混用

View File

@ -139,7 +139,24 @@ FOLLOWUP_CONFIRM_HINTS = ("确认归属", "确认项目", "确认到项目", "
FOLLOWUP_CONTINUE_HINTS = ("继续处理", "继续执行", "接着处理", "往下做", "继续推进") FOLLOWUP_CONTINUE_HINTS = ("继续处理", "继续执行", "接着处理", "往下做", "继续推进")
FOLLOWUP_WORK_ITEM_ID_RE = re.compile(r"(?:work[_ -]?item|工作项|队列项)\s*(?:id|ID)?\s*[:#:]?\s*(\d+)", re.IGNORECASE) FOLLOWUP_WORK_ITEM_ID_RE = re.compile(r"(?:work[_ -]?item|工作项|队列项)\s*(?:id|ID)?\s*[:#:]?\s*(\d+)", re.IGNORECASE)
SIGNAL_CREATE_HINTS = ("新增一个信号", "新增一条信号", "增加一个信号", "录入一个信号", "关注", "跟踪") SIGNAL_CREATE_HINTS = ("新增一个信号", "新增一条信号", "增加一个信号", "录入一个信号", "关注", "跟踪")
PROJECT_CREATE_HINTS = ("创建项目", "新建项目", "建立项目", "做个项目", "立项", "开个项目") PROJECT_CREATE_HINTS = (
"创建项目",
"新建项目",
"建立项目",
"做个项目",
"立项",
"开个项目",
"新增项目",
"新增个项目",
"新增一个项目",
"增加项目",
"增加个项目",
"增加一个项目",
"添加项目",
"添加一个项目",
"加个项目",
"加一个项目",
)
POLICY_GATE_RULES: tuple[tuple[str, tuple[str, ...]], ...] = ( POLICY_GATE_RULES: tuple[tuple[str, tuple[str, ...]], ...] = (
("delete_or_archive", ("删除项目", "删除信号", "归档项目", "归档", "archive", "清空项目")), ("delete_or_archive", ("删除项目", "删除信号", "归档项目", "归档", "archive", "清空项目")),
("budget_overrun", ("超预算", "突破预算", "额外预算", "加预算")), ("budget_overrun", ("超预算", "突破预算", "额外预算", "加预算")),
@ -268,7 +285,10 @@ class IntentResolver:
pretty[:300], pretty[:300],
{"source_request": True}, {"source_request": True},
) )
if any(token in pretty for token in PROJECT_CREATE_HINTS) or OpenClawCommanderService._extract_project_create_title(pretty): if (
IntentResolver._looks_like_project_create_request(text=pretty)
or OpenClawCommanderService._extract_project_create_title(pretty)
):
return CanonicalIntent( return CanonicalIntent(
INTENT_PROJECT_CREATE, INTENT_PROJECT_CREATE,
0.9, 0.9,
@ -321,6 +341,21 @@ class IntentResolver:
has_operation = any(token in normalized for token in IntentResolver.OPERATION_VERBS) has_operation = any(token in normalized for token in IntentResolver.OPERATION_VERBS)
return not has_operation return not has_operation
@staticmethod
def _looks_like_project_create_request(*, text: str) -> bool:
normalized = re.sub(r"\s+", "", str(text or "")).lower()
if not normalized:
return False
if any(token in normalized for token in PROJECT_CREATE_HINTS):
return True
return bool(
re.search(
r"(?:新增|增加|添加|新建|创建|建立|开个|开一个|建个|建一个|做个|做一个|加个|加一个|立项)(?:一个|个)?项目",
normalized,
re.IGNORECASE,
)
)
@staticmethod @staticmethod
def _build_signal_title(text: str, *, default: str) -> str: def _build_signal_title(text: str, *, default: str) -> str:
cleaned = re.sub(r"\s+", " ", str(text or "")).strip(" ,,::") cleaned = re.sub(r"\s+", " ", str(text or "")).strip(" ,,::")

View File

@ -2,7 +2,7 @@
PMFlow AI 核心能力封装 PMFlow AI 核心能力封装
技术栈:LangChain 0.3+、OpenAI SDK、Redis 技术栈:LangChain 0.3+、OpenAI SDK、Redis
- 封装 GPT-4o mini 调用(流式/非流式) - 封装统一 LLM 路由入口调用(流式/非流式)
- Prompt 集中在 config/ai_prompts - Prompt 集中在 config/ai_prompts
- AI 调用日志写入 ai_call_logs - AI 调用日志写入 ai_call_logs
- Redis 结果缓存(1 小时 TTL) - Redis 结果缓存(1 小时 TTL)
@ -39,6 +39,9 @@ INPUT_PRICE_PER_1K = 0.00015
OUTPUT_PRICE_PER_1K = 0.0006 OUTPUT_PRICE_PER_1K = 0.0006
CACHE_TTL_SECONDS = 3600 # 1 小时 CACHE_TTL_SECONDS = 3600 # 1 小时
PROMPT_PROFILE_CACHE_TTL_SECONDS = 60 PROMPT_PROFILE_CACHE_TTL_SECONDS = 60
DEFAULT_OPENAI_BASE_URL = "https://api.edgefn.net/v1"
DEFAULT_OPENAI_MODEL = "MiniMax-M2.5"
DEFAULT_GLM_MODEL = "GLM-4.7"
from ..config.ai_prompts import build_default_role_prompt_bundle from ..config.ai_prompts import build_default_role_prompt_bundle
from ..database import AsyncSessionLocal from ..database import AsyncSessionLocal
@ -73,27 +76,95 @@ class AIService:
def __init__( def __init__(
self, self,
api_key: Optional[str] = None, api_key: Optional[str] = None,
model: str = "gpt-4o-mini", model: Optional[str] = None,
base_url: Optional[str] = None,
redis_url: Optional[str] = None, redis_url: Optional[str] = None,
max_retries: int = 3, max_retries: int = 3,
): ):
self.api_key = api_key or os.getenv("OPENAI_API_KEY", "") runtime = self._resolve_llm_runtime(
self.model = model api_key=api_key,
model=model,
base_url=base_url,
)
self.api_key = runtime["api_key"]
self.model = runtime["model"]
self.base_url = runtime["base_url"]
self.redis_url = redis_url or os.getenv("REDIS_URL") self.redis_url = redis_url or os.getenv("REDIS_URL")
self.max_retries = max_retries self.max_retries = max_retries
self._llm: Optional[BaseChatModel] = None self._llm: Optional[BaseChatModel] = None
self._redis: Optional[Any] = None self._redis: Optional[Any] = None
self._prompt_profile_cache: dict[int, tuple[float, tuple[str, str]]] = {} self._prompt_profile_cache: dict[int, tuple[float, tuple[str, str]]] = {}
@staticmethod
def _is_env_true(name: str, default: bool) -> bool:
raw = str(os.getenv(name, str(default)) or "").strip().lower()
if not raw:
return default
return raw in {"1", "true", "yes", "y", "on", "是"}
def _resolve_llm_runtime(
self,
*,
api_key: Optional[str],
model: Optional[str],
base_url: Optional[str],
) -> dict[str, str]:
routing_enabled = self._is_env_true("OPENCLAW_ROUTING_ENABLED", True)
direct_emergency_enabled = self._is_env_true("LLM_DIRECT_EMERGENCY_ENABLED", False)
if routing_enabled and not direct_emergency_enabled:
return {
"api_key": str(api_key or os.getenv("OPENAI_API_KEY", "") or "").strip(),
"model": str(
model
or os.getenv("LLM_ROUTING_HIGH_MODEL")
or os.getenv("OPENAI_MODEL")
or DEFAULT_OPENAI_MODEL
or ""
).strip(),
"base_url": str(base_url or os.getenv("OPENAI_BASE_URL", DEFAULT_OPENAI_BASE_URL) or "").strip(),
}
provider = str(os.getenv("LLM_PROVIDER", "openai") or "openai").strip().lower()
if provider == "glm":
return {
"api_key": str(api_key or os.getenv("GLM_API_KEY", "") or "").strip(),
"model": str(model or os.getenv("GLM_MODEL", DEFAULT_GLM_MODEL) or "").strip(),
"base_url": str(base_url or os.getenv("GLM_BASE_URL", DEFAULT_OPENAI_BASE_URL) or "").strip(),
}
if provider == "deepseek":
return {
"api_key": str(api_key or os.getenv("DEEPSEEK_API_KEY", "") or "").strip(),
"model": str(model or os.getenv("DEEPSEEK_MODEL", "deepseek-chat") or "").strip(),
"base_url": str(base_url or os.getenv("DEEPSEEK_BASE_URL", "https://api.deepseek.com/v1") or "").strip(),
}
if provider == "ollama":
return {
"api_key": str(api_key or "").strip(),
"model": str(model or os.getenv("OLLAMA_MODEL", "llama3:latest") or "").strip(),
"base_url": str(
base_url
or os.getenv("OLLAMA_BASE_URL")
or os.getenv("OLLAMA_HOST", "http://127.0.0.1:11434")
or ""
).strip(),
}
return {
"api_key": str(api_key or os.getenv("OPENAI_API_KEY", "") or "").strip(),
"model": str(model or os.getenv("OPENAI_MODEL", DEFAULT_OPENAI_MODEL) or "").strip(),
"base_url": str(base_url or os.getenv("OPENAI_BASE_URL", DEFAULT_OPENAI_BASE_URL) or "").strip(),
}
def _get_llm(self) -> BaseChatModel: def _get_llm(self) -> BaseChatModel:
if not LANGCHAIN_AVAILABLE: if not LANGCHAIN_AVAILABLE:
raise RuntimeError("请安装 langchain-openai: pip install langchain-openai langchain-core") raise RuntimeError("请安装 langchain-openai: pip install langchain-openai langchain-core")
if not self.api_key: if not self.api_key:
raise RuntimeError("请设置 OPENAI_API_KEY 环境变量") raise RuntimeError("请设置统一路由所需 API Key 环境变量")
if self._llm is None: if self._llm is None:
self._llm = ChatOpenAI( self._llm = ChatOpenAI(
model=self.model, model=self.model,
api_key=self.api_key, api_key=self.api_key,
base_url=self.base_url,
temperature=0.3, temperature=0.3,
) )
return self._llm return self._llm

View File

@ -43,9 +43,9 @@ logger = logging.getLogger(__name__)
SUPPORTED_PROVIDERS = {"deepseek", "glm", "openai", "ollama"} SUPPORTED_PROVIDERS = {"deepseek", "glm", "openai", "ollama"}
OPENCLAW_PRIMARY_GATEWAY_URL = "http://openclaw:18789/v1" OPENCLAW_PRIMARY_GATEWAY_URL = "http://openclaw:18789/v1"
OPENCLAW_DEFAULT_LOW_MODEL = "zai/glm-4-flash" OPENCLAW_DEFAULT_LOW_MODEL = "GLM-4.7"
OPENCLAW_DEFAULT_HIGH_MODEL = "minimax/MiniMax-M2.5" OPENCLAW_DEFAULT_HIGH_MODEL = "MiniMax-M2.5"
OPENCLAW_DEFAULT_SAFETY_MODEL = "deepseek/deepseek-chat" OPENCLAW_DEFAULT_SAFETY_MODEL = "deepseek-chat"
PROVIDER_DEFAULTS: dict[str, dict[str, str]] = { PROVIDER_DEFAULTS: dict[str, dict[str, str]] = {
"deepseek": { "deepseek": {
"name": "DeepSeek", "name": "DeepSeek",
@ -56,7 +56,7 @@ PROVIDER_DEFAULTS: dict[str, dict[str, str]] = {
"glm": { "glm": {
"name": "GLM", "name": "GLM",
"base_url": "https://open.bigmodel.cn/api/paas/v4", "base_url": "https://open.bigmodel.cn/api/paas/v4",
"model": "glm-4-flash", "model": "GLM-4.7",
"api_env": "GLM_API_KEY", "api_env": "GLM_API_KEY",
}, },
"openai": { "openai": {
@ -81,8 +81,7 @@ 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_BACKEND_RULES_PATH = Path(__file__).resolve().parents[2] / ".openclaw" / "pmflow-routing-rules.json" OPENCLAW_DEFAULT_RULES_RELATIVE_PATH = "../.openclaw/pmflow-routing-rules.json"
OPENCLAW_PROJECT_RULES_PATH = Path(__file__).resolve().parents[3] / ".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 = {
@ -1456,14 +1455,18 @@ class LLMService:
return runtime return runtime
def _openclaw_rules_path(self) -> str: def _openclaw_rules_path(self) -> str:
custom = str(os.getenv("OPENCLAW_ROUTING_RULES_PATH", "") or "").strip() custom = str(
if custom: os.getenv(
return custom "OPENCLAW_ROUTING_RULES_PATH",
if OPENCLAW_BACKEND_RULES_PATH.exists(): OPENCLAW_DEFAULT_RULES_RELATIVE_PATH,
return str(OPENCLAW_BACKEND_RULES_PATH) )
if OPENCLAW_PROJECT_RULES_PATH.exists(): or ""
return str(OPENCLAW_PROJECT_RULES_PATH) ).strip()
return str(OPENCLAW_BACKEND_RULES_PATH) raw_path = custom or OPENCLAW_DEFAULT_RULES_RELATIVE_PATH
candidate = Path(raw_path)
if not candidate.is_absolute():
candidate = (Path(__file__).resolve().parents[2] / candidate).resolve()
return str(candidate)
def _load_openclaw_routing_bundle(self) -> dict[str, Any]: def _load_openclaw_routing_bundle(self) -> dict[str, Any]:
now = time.time() now = time.time()
@ -1580,6 +1583,7 @@ class LLMService:
engine_id = str(rule.get("engine_id") or "").strip() engine_id = str(rule.get("engine_id") or "").strip()
model_override = str(rule.get("model") or "").strip() model_override = str(rule.get("model") or "").strip()
base_url_override = str(rule.get("base_url") or "").strip().rstrip("/") base_url_override = str(rule.get("base_url") or "").strip().rstrip("/")
api_env_override = str(rule.get("api_env") or "").strip()
routed: Optional[dict[str, Any]] = None routed: Optional[dict[str, Any]] = None
if engine_id: if engine_id:
@ -1618,6 +1622,9 @@ class LLMService:
routed["model"] = model_override routed["model"] = model_override
if base_url_override: if base_url_override:
routed["base_url"] = base_url_override routed["base_url"] = base_url_override
if api_env_override:
routed["api_key"] = str(os.getenv(api_env_override, "") or "").strip()
routed["api_env"] = api_env_override
return routed return routed
def _resolve_function_tier(self, function_name: str) -> str: def _resolve_function_tier(self, function_name: str) -> str:

View File

@ -34,8 +34,8 @@ class LLMClient:
elif self.provider == "openai": elif self.provider == "openai":
return OpenAICompatibleClient( return OpenAICompatibleClient(
api_key=os.getenv("OPENAI_API_KEY"), api_key=os.getenv("OPENAI_API_KEY"),
base_url=os.getenv("OPENAI_BASE_URL", "https://api.openai.com/v1"), base_url=os.getenv("OPENAI_BASE_URL", "https://api.edgefn.net/v1"),
model=os.getenv("OPENAI_MODEL", "gpt-4o-mini") model=os.getenv("OPENAI_MODEL", "MiniMax-M2.5")
) )
elif self.provider == "qwen": elif self.provider == "qwen":
return OpenAICompatibleClient( return OpenAICompatibleClient(

View File

@ -819,8 +819,8 @@ class OpenClawCommanderService:
if quoted_match: if quoted_match:
return OpenClawCommanderService._normalize_project_title_candidate(quoted_match.group(1)) return OpenClawCommanderService._normalize_project_title_candidate(quoted_match.group(1))
for pattern in ( for pattern in (
r"(?:帮我|请|麻烦)?(?:创建|新建|建立|开个|开一个|建个|建一个|做个|做一个)(?:一个|个)?项目(?:叫|名为|名称是|名称为|是|为)?[::,,\s]*(.+)", r"(?:帮我|请|麻烦|给我)?(?:新增|增加|添加|创建|新建|建立|开个|开一个|建个|建一个|做个|做一个|加个|加一个)(?:一个|个)?项目(?:叫|名为|名称是|名称为|是|为)?[::,,\s]*(.+)",
r"(?:创建|新建|建立)项目[::,,\s]*(.+)", r"(?:新增|增加|添加|创建|新建|建立)项目[::,,\s]*(.+)",
r"项目(?:叫|名为|名称是|名称为)[::,,\s]*(.+)", r"项目(?:叫|名为|名称是|名称为)[::,,\s]*(.+)",
): ):
match = re.search(pattern, cleaned, re.IGNORECASE) match = re.search(pattern, cleaned, re.IGNORECASE)
@ -840,7 +840,7 @@ class OpenClawCommanderService:
if title: if title:
cleaned = cleaned.replace(title, "", 1).strip(" ,,::") cleaned = cleaned.replace(title, "", 1).strip(" ,,::")
cleaned = re.sub( cleaned = re.sub(
r"^(?:帮我|请|麻烦)?(?:创建|新建|建立|开个|开一个|建个|建一个|做个|做一个)(?:一个|个)?项目(?:叫|名为|名称是|名称为|是|为)?", r"^(?:帮我|请|麻烦|给我)?(?:新增|增加|添加|创建|新建|建立|开个|开一个|建个|建一个|做个|做一个|加个|加一个)(?:一个|个)?项目(?:叫|名为|名称是|名称为|是|为)?",
"", "",
cleaned, cleaned,
flags=re.IGNORECASE, flags=re.IGNORECASE,

View File

@ -25,6 +25,7 @@ from app.models import (
from app.services.ai_pmo_cadence_service import AIPMOCadenceService from app.services.ai_pmo_cadence_service import AIPMOCadenceService
from app.services.ai_pmo_kernel_service import ( from app.services.ai_pmo_kernel_service import (
AIPMOKernelService, AIPMOKernelService,
INTENT_PROJECT_CREATE,
INTENT_SIGNAL_SOURCE_ONBOARD, INTENT_SIGNAL_SOURCE_ONBOARD,
IntentResolver, IntentResolver,
) )
@ -183,6 +184,13 @@ class AIPMOKernelServiceTest(unittest.IsolatedAsyncioTestCase):
) )
self.assertEqual(intent.name, INTENT_SIGNAL_SOURCE_ONBOARD) self.assertEqual(intent.name, INTENT_SIGNAL_SOURCE_ONBOARD)
def test_intent_resolver_detects_project_create_for_add_project_phrase(self) -> None:
intent = IntentResolver.resolve(
text="给我新增个项目,openclaw自动化部署。",
project_id=None,
)
self.assertEqual(intent.name, INTENT_PROJECT_CREATE)
async def test_build_world_model_includes_budget_capacity_and_skill_graph(self) -> None: async def test_build_world_model_includes_budget_capacity_and_skill_graph(self) -> None:
async with self.session_factory() as db: async with self.session_factory() as db:
service = AIPMOKernelService(db, tenant_id=1) service = AIPMOKernelService(db, tenant_id=1)
@ -375,7 +383,7 @@ class AIPMOKernelServiceTest(unittest.IsolatedAsyncioTestCase):
}, },
source_field="content", source_field="content",
provider="openai", provider="openai",
model="zai/glm-4.7", model="GLM-4.7",
raw_text='{"intent":"signal_source.onboard"}', raw_text='{"intent":"signal_source.onboard"}',
validation="ok", validation="ok",
) )
@ -422,7 +430,7 @@ class AIPMOKernelServiceTest(unittest.IsolatedAsyncioTestCase):
payload=None, payload=None,
source_field="", source_field="",
provider="openai", provider="openai",
model="zai/glm-4.7", model="GLM-4.7",
error_code="model_not_allowed", error_code="model_not_allowed",
raw_text="403 ModelNotAllowed", raw_text="403 ModelNotAllowed",
validation="invalid", validation="invalid",
@ -509,6 +517,43 @@ class AIPMOKernelServiceTest(unittest.IsolatedAsyncioTestCase):
self.assertTrue(result["direct_reply_required"]) self.assertTrue(result["direct_reply_required"])
self.assertEqual(work_item.project_id, 9) self.assertEqual(work_item.project_id, 9)
async def test_process_add_project_phrase_routes_to_project_create(self) -> None:
async with self.session_factory() as db:
service = AIPMOKernelService(db, tenant_id=1)
with patch(
"app.services.ai_pmo_kernel_service.AIStewardService.create_project_from_natural_language",
AsyncMock(
return_value={
"project": {"id": 12, "title": "OpenClaw 自动化部署", "name": "OpenClaw 自动化部署"},
"signal": {"id": 31},
"tasks": [],
}
),
):
result = await service.process_ingress(
source_system="pmflow_manual",
source_message_id="project-phrase-1",
message_text="给我新增个项目,openclaw自动化部署。",
sender="老板",
channel_ref="pmflow",
)
work_item = (
await db.execute(
select(WorkItem).where(
and_(
WorkItem.tenant_id == 1,
WorkItem.source_message_id == "project-phrase-1",
)
)
)
).scalar_one()
self.assertEqual(result["status"], "captured")
self.assertEqual(result["created_project_id"], 12)
self.assertEqual(result["linked_project_ids"], [12])
self.assertEqual(work_item.intent, INTENT_PROJECT_CREATE)
self.assertEqual(work_item.project_id, 12)
async def test_signal_source_request_hits_simulation_policy_gate_when_skill_gap_exists(self) -> None: async def test_signal_source_request_hits_simulation_policy_gate_when_skill_gap_exists(self) -> None:
async with self.session_factory() as db: async with self.session_factory() as db:
service = AIPMOKernelService(db, tenant_id=1) service = AIPMOKernelService(db, tenant_id=1)

View File

@ -0,0 +1,40 @@
import asyncio
from app.api.llm_providers import _build_provider_payload, get_llm_provider_presets
def test_build_provider_payload_reads_env(monkeypatch) -> None:
monkeypatch.setenv("LLM_ROUTING_LOW_MODEL", "GLM-4.7")
monkeypatch.setenv("LLM_ROUTING_HIGH_MODEL", "MiniMax-M2.5")
monkeypatch.setenv("OPENAI_BASE_URL", "https://api.edgefn.net/v1")
monkeypatch.setenv("GLM_BASE_URL", "https://api.edgefn.net/v1")
monkeypatch.setenv("GLM_MODEL", "GLM-4.7")
monkeypatch.setenv("OPENAI_MODEL", "MiniMax-M2.5")
monkeypatch.setenv("DEEPSEEK_BASE_URL", "https://api.deepseek.com/v1")
monkeypatch.setenv("DEEPSEEK_MODEL", "deepseek-chat")
monkeypatch.setenv("OLLAMA_BASE_URL", "http://host.docker.internal:11434")
monkeypatch.setenv("OLLAMA_MODEL", "llama3:latest")
payload = _build_provider_payload()
assert payload["low"] == "GLM-4.7"
assert payload["high"] == "MiniMax-M2.5"
assert payload["base_url"] == "https://api.edgefn.net/v1"
assert payload["providers"]["glm"]["api_env"] == "GLM_API_KEY"
assert payload["providers"]["openai"]["api_env"] == "OPENAI_API_KEY"
assert payload["providers"]["glm"]["model"] == "GLM-4.7"
assert payload["providers"]["openai"]["model"] == "MiniMax-M2.5"
def test_get_llm_provider_presets_returns_envelope(monkeypatch) -> None:
monkeypatch.setenv("LLM_ROUTING_LOW_MODEL", "GLM-4.7")
monkeypatch.setenv("LLM_ROUTING_HIGH_MODEL", "MiniMax-M2.5")
monkeypatch.setenv("OPENAI_BASE_URL", "https://api.edgefn.net/v1")
result = asyncio.run(get_llm_provider_presets(current_user=object()))
assert result["code"] == 0
assert result["message"] == "success"
assert isinstance(result["data"], dict)
assert result["data"]["low"] == "GLM-4.7"
assert result["data"]["high"] == "MiniMax-M2.5"

View File

@ -373,6 +373,23 @@ class OpenClawCommanderServiceTest(unittest.TestCase):
self.assertEqual(plan["operations"][0]["operation_code"], "project.create") self.assertEqual(plan["operations"][0]["operation_code"], "project.create")
self.assertEqual(plan["operations"][0]["payload"]["title"], "财务冲账协同") self.assertEqual(plan["operations"][0]["payload"]["title"], "财务冲账协同")
def test_project_create_fallback_supports_add_project_phrase(self) -> None:
commander = _ProjectFallbackCommander()
plan = self.async_run(
commander._build_fallback_plan(
tenant_id=1,
project_id=None,
instruction="给我新增个项目,openclaw自动化部署。",
max_operations=4,
error="structured_http_error",
project_directory=None,
)
)
self.assertEqual(plan["operations"][0]["operation_code"], "project.create")
self.assertEqual(plan["operations"][0]["payload"]["title"], "openclaw自动化部署")
def test_project_status_fallback_resolves_project_id_from_directory(self) -> None: def test_project_status_fallback_resolves_project_id_from_directory(self) -> None:
commander = _ProjectFallbackCommander() commander = _ProjectFallbackCommander()

View File

@ -300,7 +300,7 @@ User=root
WorkingDirectory=$PROJECT_DIR/backend WorkingDirectory=$PROJECT_DIR/backend
Environment="PATH=$PROJECT_DIR/backend/venv/bin" Environment="PATH=$PROJECT_DIR/backend/venv/bin"
Environment="PYTHONPATH=$PROJECT_DIR/backend" Environment="PYTHONPATH=$PROJECT_DIR/backend"
EnvironmentFile=$PROJECT_DIR/backend/.env EnvironmentFile=$PROJECT_DIR/.env
ExecStart=$PROJECT_DIR/backend/venv/bin/uvicorn app.main:app --host 127.0.0.1 --port 8000 ExecStart=$PROJECT_DIR/backend/venv/bin/uvicorn app.main:app --host 127.0.0.1 --port 8000
Restart=always Restart=always
RestartSec=10 RestartSec=10

View File

@ -55,41 +55,45 @@ services:
redis: redis:
condition: service_healthy condition: service_healthy
environment: environment:
DATABASE_URL: postgresql+asyncpg://${POSTGRES_USER:-pmflow}:${POSTGRES_PASSWORD:-pmflow_pass}@db:5432/${POSTGRES_DB:-pmflow} DATABASE_URL: postgresql+asyncpg://${POSTGRES_USER:?missing_POSTGRES_USER}:${POSTGRES_PASSWORD:?missing_POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB:?missing_POSTGRES_DB}
REDIS_URL: redis://:${REDIS_PASSWORD:-pmflow_redis_pass}@redis:6379/0 REDIS_URL: redis://:${REDIS_PASSWORD:?missing_REDIS_PASSWORD}@redis:6379/0
CORS_ORIGINS: ${CORS_ORIGINS:-http://localhost:5174,http://127.0.0.1:5174} PMFLOW_EXPECTED_DB_DIALECT: ${PMFLOW_EXPECTED_DB_DIALECT:?missing_PMFLOW_EXPECTED_DB_DIALECT}
LLM_PROVIDER: ${LLM_PROVIDER:-openai} CORS_ORIGINS: ${CORS_ORIGINS:?missing_CORS_ORIGINS}
LLM_FALLBACK_PROVIDER: ${LLM_FALLBACK_PROVIDER:-ollama} LLM_PROVIDER: ${LLM_PROVIDER:?missing_LLM_PROVIDER}
OPENAI_BASE_URL: ${OPENAI_BASE_URL:-http://openclaw:18789/v1} LLM_FALLBACK_PROVIDER: ${LLM_FALLBACK_PROVIDER:?missing_LLM_FALLBACK_PROVIDER}
OPENCLAW_ROUTING_ENABLED: ${OPENCLAW_ROUTING_ENABLED:-true} OPENAI_BASE_URL: ${OPENAI_BASE_URL:?missing_OPENAI_BASE_URL}
OPENCLAW_ROUTING_RULES_PATH: ${OPENCLAW_ROUTING_RULES_PATH:-/app/backend/.openclaw/pmflow-routing-rules.json} OPENCLAW_ROUTING_ENABLED: ${OPENCLAW_ROUTING_ENABLED:?missing_OPENCLAW_ROUTING_ENABLED}
LLM_MIXED_ROUTING_ENABLED: ${LLM_MIXED_ROUTING_ENABLED:-false} OPENCLAW_ROUTING_RULES_PATH: ${OPENCLAW_ROUTING_RULES_PATH:?missing_OPENCLAW_ROUTING_RULES_PATH}
LLM_LOW_TO_HIGH_ESCALATION_ENABLED: ${LLM_LOW_TO_HIGH_ESCALATION_ENABLED:-true} OPENCLAW_AGENTS_DEFAULT_TIMEOUT_SECONDS: ${OPENCLAW_AGENTS_DEFAULT_TIMEOUT_SECONDS:?missing_OPENCLAW_AGENTS_DEFAULT_TIMEOUT_SECONDS}
LLM_ROUTING_LOW_PROVIDER: ${LLM_ROUTING_LOW_PROVIDER:-glm} LLM_MIXED_ROUTING_ENABLED: ${LLM_MIXED_ROUTING_ENABLED:?missing_LLM_MIXED_ROUTING_ENABLED}
LLM_ROUTING_LOW_MODEL: ${LLM_ROUTING_LOW_MODEL:-GLM-4.7} LLM_ROUTING_DEFAULT_TIER: ${LLM_ROUTING_DEFAULT_TIER:?missing_LLM_ROUTING_DEFAULT_TIER}
LLM_ROUTING_HIGH_PROVIDER: ${LLM_ROUTING_HIGH_PROVIDER:-openai} LLM_LOW_TO_HIGH_ESCALATION_ENABLED: ${LLM_LOW_TO_HIGH_ESCALATION_ENABLED:?missing_LLM_LOW_TO_HIGH_ESCALATION_ENABLED}
LLM_ROUTING_HIGH_MODEL: ${LLM_ROUTING_HIGH_MODEL:-MiniMax-M2.5} LLM_ROUTING_LOW_PROVIDER: ${LLM_ROUTING_LOW_PROVIDER:?missing_LLM_ROUTING_LOW_PROVIDER}
LLM_ROUTING_SAFETY_PROVIDER: ${LLM_ROUTING_SAFETY_PROVIDER:-glm} LLM_ROUTING_LOW_MODEL: ${LLM_ROUTING_LOW_MODEL:?missing_LLM_ROUTING_LOW_MODEL}
LLM_ROUTING_SAFETY_MODEL: ${LLM_ROUTING_SAFETY_MODEL:-GLM-4.7} LLM_ROUTING_HIGH_PROVIDER: ${LLM_ROUTING_HIGH_PROVIDER:?missing_LLM_ROUTING_HIGH_PROVIDER}
LLM_DIRECT_EMERGENCY_ENABLED: ${LLM_DIRECT_EMERGENCY_ENABLED:-false} LLM_ROUTING_HIGH_MODEL: ${LLM_ROUTING_HIGH_MODEL:?missing_LLM_ROUTING_HIGH_MODEL}
LLM_REQUEST_TIMEOUT: ${LLM_REQUEST_TIMEOUT:-90} LLM_ROUTING_SAFETY_PROVIDER: ${LLM_ROUTING_SAFETY_PROVIDER:?missing_LLM_ROUTING_SAFETY_PROVIDER}
LLM_ROUTING_SAFETY_MODEL: ${LLM_ROUTING_SAFETY_MODEL:?missing_LLM_ROUTING_SAFETY_MODEL}
LLM_DIRECT_EMERGENCY_ENABLED: ${LLM_DIRECT_EMERGENCY_ENABLED:?missing_LLM_DIRECT_EMERGENCY_ENABLED}
LLM_REQUEST_TIMEOUT: ${LLM_REQUEST_TIMEOUT:?missing_LLM_REQUEST_TIMEOUT}
AUTO_CREATE_TABLES: "true" AUTO_CREATE_TABLES: "true"
GLM_API_KEY: ${GLM_API_KEY:-} GLM_API_KEY: ${GLM_API_KEY:?missing_GLM_API_KEY}
GLM_BASE_URL: ${GLM_BASE_URL:-https://open.bigmodel.cn/api/paas/v4} GLM_BASE_URL: ${GLM_BASE_URL:?missing_GLM_BASE_URL}
GLM_MODEL: ${GLM_MODEL:-GLM-4.7} GLM_MODEL: ${GLM_MODEL:?missing_GLM_MODEL}
OPENAI_API_KEY: ${OPENAI_API_KEY:-} OPENAI_API_KEY: ${OPENAI_API_KEY:?missing_OPENAI_API_KEY}
OPENAI_MODEL: ${OPENAI_MODEL:-MiniMax-M2.5} OPENAI_MODEL: ${OPENAI_MODEL:?missing_OPENAI_MODEL}
DEEPSEEK_API_KEY: ${DEEPSEEK_API_KEY:-} DEEPSEEK_API_KEY: ${DEEPSEEK_API_KEY}
OLLAMA_HOST: ${OLLAMA_HOST:-http://host.docker.internal:11434} OLLAMA_HOST: ${OLLAMA_HOST:?missing_OLLAMA_HOST}
OLLAMA_BASE_URL: ${OLLAMA_BASE_URL:-http://host.docker.internal:11434} OLLAMA_BASE_URL: ${OLLAMA_BASE_URL:?missing_OLLAMA_BASE_URL}
OLLAMA_MODEL: ${OLLAMA_MODEL:-llama3:latest} OLLAMA_MODEL: ${OLLAMA_MODEL:?missing_OLLAMA_MODEL}
PYTHONUNBUFFERED: "1" PYTHONUNBUFFERED: "1"
command: ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--reload", "--reload-dir", "app"] command: ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--reload", "--reload-dir", "app"]
volumes: volumes:
- ./backend:/app/backend - ./backend:/app/backend
- ./.openclaw:/app/.openclaw:ro
- ./logs:/app/logs - ./logs:/app/logs
ports: ports:
- "${BACKEND_PORT:-8000}:8000" - "${BACKEND_PORT:?missing_BACKEND_PORT}:8000"
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"]
interval: 10s interval: 10s

View File

@ -55,37 +55,41 @@ services:
redis: redis:
condition: service_healthy condition: service_healthy
environment: environment:
DATABASE_URL: postgresql+asyncpg://${POSTGRES_USER:-pmflow}:${POSTGRES_PASSWORD:-pmflow_pass}@db:5432/${POSTGRES_DB:-pmflow} DATABASE_URL: postgresql+asyncpg://${POSTGRES_USER:?missing_POSTGRES_USER}:${POSTGRES_PASSWORD:?missing_POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB:?missing_POSTGRES_DB}
REDIS_URL: redis://:${REDIS_PASSWORD:-pmflow_redis_pass}@redis:6379/0 REDIS_URL: redis://:${REDIS_PASSWORD:?missing_REDIS_PASSWORD}@redis:6379/0
CORS_ORIGINS: ${CORS_ORIGINS:-http://localhost:5174,http://127.0.0.1:5174} PMFLOW_EXPECTED_DB_DIALECT: ${PMFLOW_EXPECTED_DB_DIALECT:?missing_PMFLOW_EXPECTED_DB_DIALECT}
LLM_PROVIDER: ${LLM_PROVIDER:-openai} CORS_ORIGINS: ${CORS_ORIGINS:?missing_CORS_ORIGINS}
LLM_FALLBACK_PROVIDER: ${LLM_FALLBACK_PROVIDER:-ollama} LLM_PROVIDER: ${LLM_PROVIDER:?missing_LLM_PROVIDER}
OPENAI_BASE_URL: ${OPENAI_BASE_URL:-http://openclaw:18789/v1} LLM_FALLBACK_PROVIDER: ${LLM_FALLBACK_PROVIDER:?missing_LLM_FALLBACK_PROVIDER}
OPENCLAW_ROUTING_ENABLED: ${OPENCLAW_ROUTING_ENABLED:-true} OPENAI_BASE_URL: ${OPENAI_BASE_URL:?missing_OPENAI_BASE_URL}
OPENCLAW_ROUTING_RULES_PATH: ${OPENCLAW_ROUTING_RULES_PATH:-/app/backend/.openclaw/pmflow-routing-rules.json} OPENCLAW_ROUTING_ENABLED: ${OPENCLAW_ROUTING_ENABLED:?missing_OPENCLAW_ROUTING_ENABLED}
LLM_MIXED_ROUTING_ENABLED: ${LLM_MIXED_ROUTING_ENABLED:-false} OPENCLAW_ROUTING_RULES_PATH: ${OPENCLAW_ROUTING_RULES_PATH:?missing_OPENCLAW_ROUTING_RULES_PATH}
LLM_LOW_TO_HIGH_ESCALATION_ENABLED: ${LLM_LOW_TO_HIGH_ESCALATION_ENABLED:-true} OPENCLAW_AGENTS_DEFAULT_TIMEOUT_SECONDS: ${OPENCLAW_AGENTS_DEFAULT_TIMEOUT_SECONDS:?missing_OPENCLAW_AGENTS_DEFAULT_TIMEOUT_SECONDS}
LLM_ROUTING_LOW_PROVIDER: ${LLM_ROUTING_LOW_PROVIDER:-glm} LLM_MIXED_ROUTING_ENABLED: ${LLM_MIXED_ROUTING_ENABLED:?missing_LLM_MIXED_ROUTING_ENABLED}
LLM_ROUTING_LOW_MODEL: ${LLM_ROUTING_LOW_MODEL:-GLM-4.7} LLM_ROUTING_DEFAULT_TIER: ${LLM_ROUTING_DEFAULT_TIER:?missing_LLM_ROUTING_DEFAULT_TIER}
LLM_ROUTING_HIGH_PROVIDER: ${LLM_ROUTING_HIGH_PROVIDER:-openai} LLM_LOW_TO_HIGH_ESCALATION_ENABLED: ${LLM_LOW_TO_HIGH_ESCALATION_ENABLED:?missing_LLM_LOW_TO_HIGH_ESCALATION_ENABLED}
LLM_ROUTING_HIGH_MODEL: ${LLM_ROUTING_HIGH_MODEL:-MiniMax-M2.5} LLM_ROUTING_LOW_PROVIDER: ${LLM_ROUTING_LOW_PROVIDER:?missing_LLM_ROUTING_LOW_PROVIDER}
LLM_ROUTING_SAFETY_PROVIDER: ${LLM_ROUTING_SAFETY_PROVIDER:-glm} LLM_ROUTING_LOW_MODEL: ${LLM_ROUTING_LOW_MODEL:?missing_LLM_ROUTING_LOW_MODEL}
LLM_ROUTING_SAFETY_MODEL: ${LLM_ROUTING_SAFETY_MODEL:-GLM-4.7} LLM_ROUTING_HIGH_PROVIDER: ${LLM_ROUTING_HIGH_PROVIDER:?missing_LLM_ROUTING_HIGH_PROVIDER}
LLM_DIRECT_EMERGENCY_ENABLED: ${LLM_DIRECT_EMERGENCY_ENABLED:-false} LLM_ROUTING_HIGH_MODEL: ${LLM_ROUTING_HIGH_MODEL:?missing_LLM_ROUTING_HIGH_MODEL}
LLM_REQUEST_TIMEOUT: ${LLM_REQUEST_TIMEOUT:-90} LLM_ROUTING_SAFETY_PROVIDER: ${LLM_ROUTING_SAFETY_PROVIDER:?missing_LLM_ROUTING_SAFETY_PROVIDER}
LLM_ROUTING_SAFETY_MODEL: ${LLM_ROUTING_SAFETY_MODEL:?missing_LLM_ROUTING_SAFETY_MODEL}
LLM_DIRECT_EMERGENCY_ENABLED: ${LLM_DIRECT_EMERGENCY_ENABLED:?missing_LLM_DIRECT_EMERGENCY_ENABLED}
LLM_REQUEST_TIMEOUT: ${LLM_REQUEST_TIMEOUT:?missing_LLM_REQUEST_TIMEOUT}
AUTO_CREATE_TABLES: "true" AUTO_CREATE_TABLES: "true"
GLM_API_KEY: ${GLM_API_KEY:-} GLM_API_KEY: ${GLM_API_KEY:?missing_GLM_API_KEY}
GLM_BASE_URL: ${GLM_BASE_URL:-https://open.bigmodel.cn/api/paas/v4} GLM_BASE_URL: ${GLM_BASE_URL:?missing_GLM_BASE_URL}
GLM_MODEL: ${GLM_MODEL:-GLM-4.7} GLM_MODEL: ${GLM_MODEL:?missing_GLM_MODEL}
OPENAI_API_KEY: ${OPENAI_API_KEY:-} OPENAI_API_KEY: ${OPENAI_API_KEY:?missing_OPENAI_API_KEY}
OPENAI_MODEL: ${OPENAI_MODEL:-MiniMax-M2.5} OPENAI_MODEL: ${OPENAI_MODEL:?missing_OPENAI_MODEL}
DEEPSEEK_API_KEY: ${DEEPSEEK_API_KEY:-} DEEPSEEK_API_KEY: ${DEEPSEEK_API_KEY}
OLLAMA_HOST: ${OLLAMA_HOST:-http://host.docker.internal:11434} OLLAMA_HOST: ${OLLAMA_HOST:?missing_OLLAMA_HOST}
OLLAMA_BASE_URL: ${OLLAMA_BASE_URL:-http://host.docker.internal:11434} OLLAMA_BASE_URL: ${OLLAMA_BASE_URL:?missing_OLLAMA_BASE_URL}
OLLAMA_MODEL: ${OLLAMA_MODEL:-llama3:latest} OLLAMA_MODEL: ${OLLAMA_MODEL:?missing_OLLAMA_MODEL}
ports: ports:
- "${BACKEND_PORT:-8000}:8000" - "${BACKEND_PORT:?missing_BACKEND_PORT}:8000"
volumes: volumes:
- ./.openclaw:/app/.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"]

View File

@ -63,6 +63,21 @@ export interface OpenClawCommandDispatchResponse {
}; };
} }
export type LLMProvider = "deepseek" | "glm" | "openai" | "ollama";
export interface LLMProviderPreset {
model: string;
base_url: string;
api_env: string;
}
export interface LLMProviderPresetResponse {
low: string;
high: string;
base_url: string;
providers: Record<LLMProvider, LLMProviderPreset>;
}
export const aiStewardApi = { export const aiStewardApi = {
async dispatchCommand(body: OpenClawCommandDispatchRequest): Promise<OpenClawCommandDispatchResponse> { async dispatchCommand(body: OpenClawCommandDispatchRequest): Promise<OpenClawCommandDispatchResponse> {
const raw = await apiRequest<ApiEnvelope<OpenClawCommandDispatchResponse>>("/ai-steward/command/dispatch", { const raw = await apiRequest<ApiEnvelope<OpenClawCommandDispatchResponse>>("/ai-steward/command/dispatch", {
@ -71,4 +86,8 @@ export const aiStewardApi = {
}); });
return unwrap(raw); return unwrap(raw);
}, },
async getLlmProviderPresets(): Promise<LLMProviderPresetResponse> {
const raw = await apiRequest<ApiEnvelope<LLMProviderPresetResponse>>("/ai-steward/llm/providers");
return unwrap(raw);
},
}; };

View File

@ -20,6 +20,7 @@ import { formatProjectNameWithId, toZhPriority, toZhSeverity, toZhStatus } from
import { formatBeijingDate } from "@/lib/datetime"; import { formatBeijingDate } from "@/lib/datetime";
import { QAHistoryPanel } from "@/components/ai-steward/QAHistoryPanel"; import { QAHistoryPanel } from "@/components/ai-steward/QAHistoryPanel";
import { OpenClawCommanderPanel } from "@/components/ai-steward/OpenClawCommanderPanel"; import { OpenClawCommanderPanel } from "@/components/ai-steward/OpenClawCommanderPanel";
import { aiStewardApi } from "@/api/aiSteward";
// ==================== Types ==================== // ==================== Types ====================
@ -83,7 +84,7 @@ const PROVIDER_ZH: Record<LLMEngine["provider"], string> = {
ollama: "Ollama(本地)", ollama: "Ollama(本地)",
}; };
const ENGINE_PROVIDER_PRESETS: Record<LLMEngine["provider"], { model: string; base_url: string }> = { const ENGINE_PROVIDER_FALLBACK_PRESETS: Record<LLMEngine["provider"], { model: string; base_url: string }> = {
deepseek: { deepseek: {
model: "deepseek-chat", model: "deepseek-chat",
base_url: "https://api.deepseek.com/v1", base_url: "https://api.deepseek.com/v1",
@ -119,12 +120,15 @@ export default function AIStewardPage() {
const [briefing, setBriefing] = useState<DailyBriefing | null>(null); const [briefing, setBriefing] = useState<DailyBriefing | null>(null);
const [featureLoading, setFeatureLoading] = useState<string | null>(null); const [featureLoading, setFeatureLoading] = useState<string | null>(null);
const [engineData, setEngineData] = useState<LLMEngineListResponse>({ active_engine_id: null, engines: [] }); const [engineData, setEngineData] = useState<LLMEngineListResponse>({ active_engine_id: null, engines: [] });
const [engineProviderPresets, setEngineProviderPresets] = useState<Record<LLMEngine["provider"], { model: string; base_url: string }>>(
ENGINE_PROVIDER_FALLBACK_PRESETS
);
const [engineForm, setEngineForm] = useState({ const [engineForm, setEngineForm] = useState({
name: "", name: "",
provider: "glm" as "deepseek" | "glm" | "openai" | "ollama", provider: "glm" as "deepseek" | "glm" | "openai" | "ollama",
api_key: "", api_key: "",
model: ENGINE_PROVIDER_PRESETS.glm.model, model: ENGINE_PROVIDER_FALLBACK_PRESETS.glm.model,
base_url: ENGINE_PROVIDER_PRESETS.glm.base_url, base_url: ENGINE_PROVIDER_FALLBACK_PRESETS.glm.base_url,
}); });
const [engineEditingId, setEngineEditingId] = useState<string | null>(null); const [engineEditingId, setEngineEditingId] = useState<string | null>(null);
const [engineMessage, setEngineMessage] = useState<{ type: "success" | "error"; text: string } | null>(null); const [engineMessage, setEngineMessage] = useState<{ type: "success" | "error"; text: string } | null>(null);
@ -276,14 +280,51 @@ export default function AIStewardPage() {
} }
}; };
const loadEngineProviderPresets = async () => {
try {
const data = await aiStewardApi.getLlmProviderPresets();
const providers = data?.providers ?? {};
const nextPresets: Record<LLMEngine["provider"], { model: string; base_url: string }> = {
deepseek: {
model: providers.deepseek?.model || ENGINE_PROVIDER_FALLBACK_PRESETS.deepseek.model,
base_url: providers.deepseek?.base_url || ENGINE_PROVIDER_FALLBACK_PRESETS.deepseek.base_url,
},
glm: {
model: providers.glm?.model || data?.low || ENGINE_PROVIDER_FALLBACK_PRESETS.glm.model,
base_url: providers.glm?.base_url || data?.base_url || ENGINE_PROVIDER_FALLBACK_PRESETS.glm.base_url,
},
openai: {
model: providers.openai?.model || data?.high || ENGINE_PROVIDER_FALLBACK_PRESETS.openai.model,
base_url: providers.openai?.base_url || data?.base_url || ENGINE_PROVIDER_FALLBACK_PRESETS.openai.base_url,
},
ollama: {
model: providers.ollama?.model || ENGINE_PROVIDER_FALLBACK_PRESETS.ollama.model,
base_url: providers.ollama?.base_url || ENGINE_PROVIDER_FALLBACK_PRESETS.ollama.base_url,
},
};
setEngineProviderPresets(nextPresets);
setEngineForm((prev) => {
if (engineEditingId) return prev;
const preset = nextPresets[prev.provider];
return {
...prev,
model: preset.model,
base_url: preset.base_url,
};
});
} catch (err) {
console.error("Failed to load provider presets:", err);
}
};
const resetEngineForm = () => { const resetEngineForm = () => {
setEngineEditingId(null); setEngineEditingId(null);
setEngineForm({ setEngineForm({
name: "", name: "",
provider: "glm", provider: "glm",
api_key: "", api_key: "",
model: ENGINE_PROVIDER_PRESETS.glm.model, model: engineProviderPresets.glm.model,
base_url: ENGINE_PROVIDER_PRESETS.glm.base_url, base_url: engineProviderPresets.glm.base_url,
}); });
}; };
@ -479,7 +520,10 @@ export default function AIStewardPage() {
if (activeTab === "conflicts") loadConflicts(); if (activeTab === "conflicts") loadConflicts();
if (activeTab === "blockers") loadBlockers(); if (activeTab === "blockers") loadBlockers();
if (activeTab === "briefing") loadBriefing(); if (activeTab === "briefing") loadBriefing();
if (activeTab === "engine") loadEngineConfigs(); if (activeTab === "engine") {
loadEngineProviderPresets();
loadEngineConfigs();
}
}, [activeTab]); }, [activeTab]);
// ==================== Render ==================== // ==================== Render ====================
@ -1559,7 +1603,7 @@ export default function AIStewardPage() {
onChange={(e) => onChange={(e) =>
setEngineForm((prev) => { setEngineForm((prev) => {
const provider = e.target.value as "deepseek" | "glm" | "openai" | "ollama"; const provider = e.target.value as "deepseek" | "glm" | "openai" | "ollama";
const preset = ENGINE_PROVIDER_PRESETS[provider]; const preset = engineProviderPresets[provider];
return { return {
...prev, ...prev,
provider, provider,

View File

@ -142,7 +142,7 @@ Return answer
- [x] Added DB/Redis health checks and `depends_on` health gating. - [x] Added DB/Redis health checks and `depends_on` health gating.
- [x] Added backend container build/runtime file: `backend/Dockerfile`. - [x] Added backend container build/runtime file: `backend/Dockerfile`.
- [x] Added backend dependency lock baseline: `backend/requirements.txt`. - [x] Added backend dependency lock baseline: `backend/requirements.txt`.
- [x] Added docker env template: `.env.example` and updated `backend/.env.example`. - [x] Added docker env template: `.env.example` as the single environment template source.
- [x] Redis policy configured to `maxmemory 512mb` + `allkeys-lru`. - [x] Redis policy configured to `maxmemory 512mb` + `allkeys-lru`.
### Part 2 — Database Migration Steps ### Part 2 — Database Migration Steps

View File

@ -5,11 +5,15 @@ from __future__ import annotations
import argparse import argparse
import json import json
import os
from pathlib import Path 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", "safety"}
DEFAULT_RULES_RELATIVE_PATH = "../.openclaw/pmflow-routing-rules.json"
SCRIPT_ROOT = Path(__file__).resolve().parents[1]
BACKEND_DIR = SCRIPT_ROOT / "backend"
LOW_REQUIRED = { LOW_REQUIRED = {
"feishu_level_classification", "feishu_level_classification",
@ -76,6 +80,14 @@ def _resolve_primary_tier(function_name: str, rules: list[dict[str, Any]]) -> Op
return scored[0][2] return scored[0][2]
def _resolve_rules_path(raw: str) -> Path:
token = str(raw or "").strip() or DEFAULT_RULES_RELATIVE_PATH
path = Path(token)
if path.is_absolute():
return path
return (BACKEND_DIR / path).resolve()
def _lint(path: Path) -> tuple[bool, dict[str, Any]]: def _lint(path: Path) -> tuple[bool, dict[str, Any]]:
if not path.exists(): if not path.exists():
return False, {"error": f"rules file not found: {path}"} return False, {"error": f"rules file not found: {path}"}
@ -135,9 +147,14 @@ def _lint(path: Path) -> tuple[bool, dict[str, Any]]:
break break
model_hints = { model_hints = {
"low": "zai/glm-4-flash", "low": "GLM-4.7",
"high": "minimax/MiniMax-M2.5", "high": "MiniMax-M2.5",
"safety": "deepseek/deepseek-chat", "safety": "deepseek-chat",
}
api_env_hints = {
"low": "GLM_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 = [
@ -146,6 +163,9 @@ def _lint(path: Path) -> tuple[bool, dict[str, Any]]:
] ]
if tier_rows and not any(str(row.get("model") or "").strip() == expected_model for row in tier_rows): if tier_rows and not any(str(row.get("model") or "").strip() == expected_model for row in tier_rows):
problems.append(f"tier={tier} missing expected model hint: {expected_model}") problems.append(f"tier={tier} missing expected model hint: {expected_model}")
expected_api_env = api_env_hints[tier]
if tier_rows and not any(str(row.get("api_env") or "").strip() == expected_api_env for row in tier_rows):
problems.append(f"tier={tier} missing expected api_env hint: {expected_api_env}")
return len(problems) == 0, { return len(problems) == 0, {
"path": str(path), "path": str(path),
@ -159,15 +179,22 @@ def _lint(path: Path) -> tuple[bool, dict[str, Any]]:
def main() -> None: def main() -> None:
default_input = str(
os.getenv(
"OPENCLAW_ROUTING_RULES_PATH",
DEFAULT_RULES_RELATIVE_PATH,
)
or DEFAULT_RULES_RELATIVE_PATH
).strip() or DEFAULT_RULES_RELATIVE_PATH
parser = argparse.ArgumentParser(description="OpenClaw routing lint") parser = argparse.ArgumentParser(description="OpenClaw routing lint")
parser.add_argument( parser.add_argument(
"--input", "--input",
default=".openclaw/pmflow-routing-rules.json", default=default_input,
help="Rules file path", help="Rules file path",
) )
args = parser.parse_args() args = parser.parse_args()
ok, report = _lint(Path(args.input)) ok, report = _lint(_resolve_rules_path(args.input))
print(json.dumps(report, ensure_ascii=False, indent=2)) print(json.dumps(report, ensure_ascii=False, indent=2))
raise SystemExit(0 if ok else 2) raise SystemExit(0 if ok else 2)

View File

@ -30,7 +30,7 @@ def test_provider_circuit_breaker_opens_after_threshold():
circuit_key = service._provider_circuit_key( circuit_key = service._provider_circuit_key(
provider="openai", provider="openai",
base_url="https://example.com/v1", base_url="https://example.com/v1",
model="gpt-4o-mini", model="MiniMax-M2.5",
) )
LLMService._provider_circuit_state.pop(circuit_key, None) LLMService._provider_circuit_state.pop(circuit_key, None)

View File

@ -60,7 +60,7 @@
**选项 A:使用云端 API(推荐,电脑配置低)** **选项 A:使用云端 API(推荐,电脑配置低)**
编辑 `backend/.env` 文件: 编辑根目录 `.env` 文件:
```bash ```bash
# 使用 DeepSeek(便宜,¥1/百万 tokens) # 使用 DeepSeek(便宜,¥1/百万 tokens)
@ -96,7 +96,7 @@ ollama pull llama3:8b
ollama pull nomic-embed-text ollama pull nomic-embed-text
``` ```
编辑 `backend/.env` 文件: 编辑根目录 `.env` 文件:
```bash ```bash
LLM_PROVIDER=ollama LLM_PROVIDER=ollama
@ -116,10 +116,10 @@ source venv/bin/activate # Windows: venv\Scripts\activate
pip install -r requirements.txt pip install -r requirements.txt
# 配置环境变量 (可选,使用默认配置) # 配置环境变量 (可选,使用默认配置)
cp .env.example .env cp ../.env.example ../.env
# 编辑 .env 设置你的 LLM 提供商和 API 密钥 # 编辑项目根目录 .env 设置你的 LLM 提供商和 API 密钥
vim .env vim ../.env
# 运行数据库迁移 # 运行数据库迁移
alembic upgrade head alembic upgrade head
@ -302,7 +302,7 @@ curl -X POST http://localhost:8000/signals/ingest \
### 5.1 环境变量配置 ### 5.1 环境变量配置
编辑 `backend/.env` 文件: 编辑根目录 `.env` 文件:
```bash ```bash
# 数据库配置 # 数据库配置
@ -399,7 +399,7 @@ pip install -r requirements.txt
**解决方案 A - 使用 SQLite(开发环境)**: **解决方案 A - 使用 SQLite(开发环境)**:
编辑 `backend/.env`: 编辑根目录 `.env`:
```bash ```bash
DATABASE_URL=sqlite+aiosqlite:///./neural_sync.db DATABASE_URL=sqlite+aiosqlite:///./neural_sync.db
``` ```
@ -604,8 +604,7 @@ Neural Sync/
│ │ ├── database.py # 数据库连接 │ │ ├── database.py # 数据库连接
│ │ └── services/ # 业务逻辑 │ │ └── services/ # 业务逻辑
│ ├── alembic/ # 数据库迁移 │ ├── alembic/ # 数据库迁移
│ ├── requirements.txt │ └── requirements.txt
│ └── .env.example # 环境变量模板
│ │
├── frontend/ # Vue 3 前端 ├── frontend/ # Vue 3 前端
│ ├── src/ │ ├── src/
@ -615,6 +614,7 @@ Neural Sync/
│ ├── package.json │ ├── package.json
│ └── vite.config.ts │ └── vite.config.ts
│ │
├── .env.example # 环境变量模板
├── CLAUDE.md # Claude Code 指导 ├── CLAUDE.md # Claude Code 指导
└── README.md # 项目说明 └── README.md # 项目说明
``` ```