制定双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
POSTGRES_DB=pmflow
POSTGRES_USER=pmflow
@ -8,37 +11,53 @@ DB_PORT=5435
REDIS_PORT=6379
BACKEND_PORT=8000
FRONTEND_PORT=5174
PMFLOW_EXPECTED_DB_DIALECT=postgresql
# Backend Runtime
CORS_ORIGINS=http://localhost:5174,http://127.0.0.1:5174
LLM_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_RULES_PATH=/app/backend/.openclaw/pmflow-routing-rules.json
OPENCLAW_ROUTING_RULES_PATH=../.openclaw/pmflow-routing-rules.json
OPENCLAW_AGENTS_DEFAULT_TIMEOUT_SECONDS=120
LLM_DIRECT_EMERGENCY_ENABLED=true
LLM_MIXED_ROUTING_ENABLED=true
LLM_ROUTING_DEFAULT_TIER=active
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_MODEL=GLM-4.7
LLM_ROUTING_HIGH_PROVIDER=openai
LLM_ROUTING_HIGH_MODEL=MiniMax-M2.5
LLM_ROUTING_SAFETY_PROVIDER=glm
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_VECTOR_TOP_K=12
RAG_DEEP_CONTEXT_BLOCKS=16
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_BASE_SECONDS=1

View File

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

View File

@ -50,6 +50,9 @@ backend/
├── Dockerfile
└── 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/`)
```text
@ -140,6 +143,7 @@ uvicorn app.main:app --reload --host 0.0.0.0 --port 8000
## 9. 新增代码的参考模式
- 新增后端接口:参考 `backend/app/api/sstc.py` 的结构
- 配置读取类接口:参考 `backend/app/api/llm_providers.py`(`GET /ai-steward/llm/providers`)
- 新增前端 API 调用:参考 `frontend/src/api/sstc.ts`
- 新增数据库表:参考 `alembic/versions/` 最新 migration
- 新增飞书事件处理:参考 `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)
- 写操作后主动 invalidate,动态层不缓存
- 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
# 配置环境变量
cp .env.example .env
nano .env
cp /opt/neural-sync/.env.example /opt/neural-sync/.env
nano /opt/neural-sync/.env
```
编辑 `.env` 文件:
编辑项目根目录 `.env` 文件:
```env
# 数据库 - PostgreSQL
@ -227,7 +227,7 @@ Type=simple
User=root
WorkingDirectory=/opt/neural-sync/backend
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
Restart=always
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
COPY backend /app/backend
COPY .openclaw /app/.openclaw
EXPOSE 8000

View File

@ -22,6 +22,7 @@ from .budget import router as budget_router
from .scheduler import router as scheduler_router
from .notifications import router as notifications_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 .knowledge_graph import router as knowledge_graph_router
from .operation_catalog import router as operation_catalog_router
@ -46,6 +47,7 @@ __all__ = [
"scheduler_router",
"notifications_router",
"llm_engines_router",
"llm_providers_router",
"prompt_profiles_router",
"knowledge_graph_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 .notifications import router as notifications_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 .prompt_profiles import router as prompt_profiles_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(notifications_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(prompt_profiles_router)
api_router.include_router(rag_router)

View File

@ -525,7 +525,8 @@ PROMPT_AI_PMO_INTENT_CLASSIFICATION = """
3. project_id 无法确定时返回 null,不要猜测无依据项目。
4. 噪音/寒暄/无关消息返回 ignore。
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 dotenv import load_dotenv
# 统一从 backend/.env 加载配置,避免因启动目录不同读到错误环境
# 仅从项目根目录 .env 加载,作为唯一配置源
BACKEND_DIR = Path(__file__).resolve().parents[2]
PROJECT_ROOT = BACKEND_DIR.parent
load_dotenv(BACKEND_DIR / ".env", override=False)
load_dotenv(PROJECT_ROOT / ".env", override=False)
# 默认数据库固定到 backend/neural_sync.db,避免相对路径导致多库混用

View File

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

View File

@ -2,7 +2,7 @@
PMFlow AI 核心能力封装
技术栈:LangChain 0.3+、OpenAI SDK、Redis
- 封装 GPT-4o mini 调用(流式/非流式)
- 封装统一 LLM 路由入口调用(流式/非流式)
- Prompt 集中在 config/ai_prompts
- AI 调用日志写入 ai_call_logs
- Redis 结果缓存(1 小时 TTL)
@ -39,6 +39,9 @@ INPUT_PRICE_PER_1K = 0.00015
OUTPUT_PRICE_PER_1K = 0.0006
CACHE_TTL_SECONDS = 3600 # 1 小时
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 ..database import AsyncSessionLocal
@ -73,27 +76,95 @@ class AIService:
def __init__(
self,
api_key: Optional[str] = None,
model: str = "gpt-4o-mini",
model: Optional[str] = None,
base_url: Optional[str] = None,
redis_url: Optional[str] = None,
max_retries: int = 3,
):
self.api_key = api_key or os.getenv("OPENAI_API_KEY", "")
self.model = model
runtime = self._resolve_llm_runtime(
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.max_retries = max_retries
self._llm: Optional[BaseChatModel] = None
self._redis: Optional[Any] = None
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:
if not LANGCHAIN_AVAILABLE:
raise RuntimeError("请安装 langchain-openai: pip install langchain-openai langchain-core")
if not self.api_key:
raise RuntimeError("请设置 OPENAI_API_KEY 环境变量")
raise RuntimeError("请设置统一路由所需 API Key 环境变量")
if self._llm is None:
self._llm = ChatOpenAI(
model=self.model,
api_key=self.api_key,
base_url=self.base_url,
temperature=0.3,
)
return self._llm

View File

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

View File

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

View File

@ -819,8 +819,8 @@ class OpenClawCommanderService:
if quoted_match:
return OpenClawCommanderService._normalize_project_title_candidate(quoted_match.group(1))
for pattern in (
r"(?:帮我|请|麻烦)?(?:创建|新建|建立|开个|开一个|建个|建一个|做个|做一个)(?:一个|个)?项目(?:叫|名为|名称是|名称为|是|为)?[::,,\s]*(.+)",
r"(?:创建|新建|建立)项目[::,,\s]*(.+)",
r"(?:帮我|请|麻烦|给我)?(?:新增|增加|添加|创建|新建|建立|开个|开一个|建个|建一个|做个|做一个|加个|加一个)(?:一个|个)?项目(?:叫|名为|名称是|名称为|是|为)?[::,,\s]*(.+)",
r"(?:新增|增加|添加|创建|新建|建立)项目[::,,\s]*(.+)",
r"项目(?:叫|名为|名称是|名称为)[::,,\s]*(.+)",
):
match = re.search(pattern, cleaned, re.IGNORECASE)
@ -840,7 +840,7 @@ class OpenClawCommanderService:
if title:
cleaned = cleaned.replace(title, "", 1).strip(" ,,::")
cleaned = re.sub(
r"^(?:帮我|请|麻烦)?(?:创建|新建|建立|开个|开一个|建个|建一个|做个|做一个)(?:一个|个)?项目(?:叫|名为|名称是|名称为|是|为)?",
r"^(?:帮我|请|麻烦|给我)?(?:新增|增加|添加|创建|新建|建立|开个|开一个|建个|建一个|做个|做一个|加个|加一个)(?:一个|个)?项目(?:叫|名为|名称是|名称为|是|为)?",
"",
cleaned,
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_kernel_service import (
AIPMOKernelService,
INTENT_PROJECT_CREATE,
INTENT_SIGNAL_SOURCE_ONBOARD,
IntentResolver,
)
@ -183,6 +184,13 @@ class AIPMOKernelServiceTest(unittest.IsolatedAsyncioTestCase):
)
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 with self.session_factory() as db:
service = AIPMOKernelService(db, tenant_id=1)
@ -375,7 +383,7 @@ class AIPMOKernelServiceTest(unittest.IsolatedAsyncioTestCase):
},
source_field="content",
provider="openai",
model="zai/glm-4.7",
model="GLM-4.7",
raw_text='{"intent":"signal_source.onboard"}',
validation="ok",
)
@ -422,7 +430,7 @@ class AIPMOKernelServiceTest(unittest.IsolatedAsyncioTestCase):
payload=None,
source_field="",
provider="openai",
model="zai/glm-4.7",
model="GLM-4.7",
error_code="model_not_allowed",
raw_text="403 ModelNotAllowed",
validation="invalid",
@ -509,6 +517,43 @@ class AIPMOKernelServiceTest(unittest.IsolatedAsyncioTestCase):
self.assertTrue(result["direct_reply_required"])
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 with self.session_factory() as db:
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]["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:
commander = _ProjectFallbackCommander()

View File

@ -300,7 +300,7 @@ User=root
WorkingDirectory=$PROJECT_DIR/backend
Environment="PATH=$PROJECT_DIR/backend/venv/bin"
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
Restart=always
RestartSec=10

View File

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

View File

@ -55,37 +55,41 @@ services:
redis:
condition: service_healthy
environment:
DATABASE_URL: postgresql+asyncpg://${POSTGRES_USER:-pmflow}:${POSTGRES_PASSWORD:-pmflow_pass}@db:5432/${POSTGRES_DB:-pmflow}
REDIS_URL: redis://:${REDIS_PASSWORD:-pmflow_redis_pass}@redis:6379/0
CORS_ORIGINS: ${CORS_ORIGINS:-http://localhost:5174,http://127.0.0.1:5174}
LLM_PROVIDER: ${LLM_PROVIDER:-openai}
LLM_FALLBACK_PROVIDER: ${LLM_FALLBACK_PROVIDER:-ollama}
OPENAI_BASE_URL: ${OPENAI_BASE_URL:-http://openclaw:18789/v1}
OPENCLAW_ROUTING_ENABLED: ${OPENCLAW_ROUTING_ENABLED:-true}
OPENCLAW_ROUTING_RULES_PATH: ${OPENCLAW_ROUTING_RULES_PATH:-/app/backend/.openclaw/pmflow-routing-rules.json}
LLM_MIXED_ROUTING_ENABLED: ${LLM_MIXED_ROUTING_ENABLED:-false}
LLM_LOW_TO_HIGH_ESCALATION_ENABLED: ${LLM_LOW_TO_HIGH_ESCALATION_ENABLED:-true}
LLM_ROUTING_LOW_PROVIDER: ${LLM_ROUTING_LOW_PROVIDER:-glm}
LLM_ROUTING_LOW_MODEL: ${LLM_ROUTING_LOW_MODEL:-GLM-4.7}
LLM_ROUTING_HIGH_PROVIDER: ${LLM_ROUTING_HIGH_PROVIDER:-openai}
LLM_ROUTING_HIGH_MODEL: ${LLM_ROUTING_HIGH_MODEL:-MiniMax-M2.5}
LLM_ROUTING_SAFETY_PROVIDER: ${LLM_ROUTING_SAFETY_PROVIDER:-glm}
LLM_ROUTING_SAFETY_MODEL: ${LLM_ROUTING_SAFETY_MODEL:-GLM-4.7}
LLM_DIRECT_EMERGENCY_ENABLED: ${LLM_DIRECT_EMERGENCY_ENABLED:-false}
LLM_REQUEST_TIMEOUT: ${LLM_REQUEST_TIMEOUT:-90}
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:?missing_REDIS_PASSWORD}@redis:6379/0
PMFLOW_EXPECTED_DB_DIALECT: ${PMFLOW_EXPECTED_DB_DIALECT:?missing_PMFLOW_EXPECTED_DB_DIALECT}
CORS_ORIGINS: ${CORS_ORIGINS:?missing_CORS_ORIGINS}
LLM_PROVIDER: ${LLM_PROVIDER:?missing_LLM_PROVIDER}
LLM_FALLBACK_PROVIDER: ${LLM_FALLBACK_PROVIDER:?missing_LLM_FALLBACK_PROVIDER}
OPENAI_BASE_URL: ${OPENAI_BASE_URL:?missing_OPENAI_BASE_URL}
OPENCLAW_ROUTING_ENABLED: ${OPENCLAW_ROUTING_ENABLED:?missing_OPENCLAW_ROUTING_ENABLED}
OPENCLAW_ROUTING_RULES_PATH: ${OPENCLAW_ROUTING_RULES_PATH:?missing_OPENCLAW_ROUTING_RULES_PATH}
OPENCLAW_AGENTS_DEFAULT_TIMEOUT_SECONDS: ${OPENCLAW_AGENTS_DEFAULT_TIMEOUT_SECONDS:?missing_OPENCLAW_AGENTS_DEFAULT_TIMEOUT_SECONDS}
LLM_MIXED_ROUTING_ENABLED: ${LLM_MIXED_ROUTING_ENABLED:?missing_LLM_MIXED_ROUTING_ENABLED}
LLM_ROUTING_DEFAULT_TIER: ${LLM_ROUTING_DEFAULT_TIER:?missing_LLM_ROUTING_DEFAULT_TIER}
LLM_LOW_TO_HIGH_ESCALATION_ENABLED: ${LLM_LOW_TO_HIGH_ESCALATION_ENABLED:?missing_LLM_LOW_TO_HIGH_ESCALATION_ENABLED}
LLM_ROUTING_LOW_PROVIDER: ${LLM_ROUTING_LOW_PROVIDER:?missing_LLM_ROUTING_LOW_PROVIDER}
LLM_ROUTING_LOW_MODEL: ${LLM_ROUTING_LOW_MODEL:?missing_LLM_ROUTING_LOW_MODEL}
LLM_ROUTING_HIGH_PROVIDER: ${LLM_ROUTING_HIGH_PROVIDER:?missing_LLM_ROUTING_HIGH_PROVIDER}
LLM_ROUTING_HIGH_MODEL: ${LLM_ROUTING_HIGH_MODEL:?missing_LLM_ROUTING_HIGH_MODEL}
LLM_ROUTING_SAFETY_PROVIDER: ${LLM_ROUTING_SAFETY_PROVIDER:?missing_LLM_ROUTING_SAFETY_PROVIDER}
LLM_ROUTING_SAFETY_MODEL: ${LLM_ROUTING_SAFETY_MODEL:?missing_LLM_ROUTING_SAFETY_MODEL}
LLM_DIRECT_EMERGENCY_ENABLED: ${LLM_DIRECT_EMERGENCY_ENABLED:?missing_LLM_DIRECT_EMERGENCY_ENABLED}
LLM_REQUEST_TIMEOUT: ${LLM_REQUEST_TIMEOUT:?missing_LLM_REQUEST_TIMEOUT}
AUTO_CREATE_TABLES: "true"
GLM_API_KEY: ${GLM_API_KEY:-}
GLM_BASE_URL: ${GLM_BASE_URL:-https://open.bigmodel.cn/api/paas/v4}
GLM_MODEL: ${GLM_MODEL:-GLM-4.7}
OPENAI_API_KEY: ${OPENAI_API_KEY:-}
OPENAI_MODEL: ${OPENAI_MODEL:-MiniMax-M2.5}
DEEPSEEK_API_KEY: ${DEEPSEEK_API_KEY:-}
OLLAMA_HOST: ${OLLAMA_HOST:-http://host.docker.internal:11434}
OLLAMA_BASE_URL: ${OLLAMA_BASE_URL:-http://host.docker.internal:11434}
OLLAMA_MODEL: ${OLLAMA_MODEL:-llama3:latest}
GLM_API_KEY: ${GLM_API_KEY:?missing_GLM_API_KEY}
GLM_BASE_URL: ${GLM_BASE_URL:?missing_GLM_BASE_URL}
GLM_MODEL: ${GLM_MODEL:?missing_GLM_MODEL}
OPENAI_API_KEY: ${OPENAI_API_KEY:?missing_OPENAI_API_KEY}
OPENAI_MODEL: ${OPENAI_MODEL:?missing_OPENAI_MODEL}
DEEPSEEK_API_KEY: ${DEEPSEEK_API_KEY}
OLLAMA_HOST: ${OLLAMA_HOST:?missing_OLLAMA_HOST}
OLLAMA_BASE_URL: ${OLLAMA_BASE_URL:?missing_OLLAMA_BASE_URL}
OLLAMA_MODEL: ${OLLAMA_MODEL:?missing_OLLAMA_MODEL}
ports:
- "${BACKEND_PORT:-8000}:8000"
- "${BACKEND_PORT:?missing_BACKEND_PORT}:8000"
volumes:
- ./.openclaw:/app/.openclaw:ro
- ./logs:/app/logs
healthcheck:
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 = {
async dispatchCommand(body: OpenClawCommandDispatchRequest): Promise<OpenClawCommandDispatchResponse> {
const raw = await apiRequest<ApiEnvelope<OpenClawCommandDispatchResponse>>("/ai-steward/command/dispatch", {
@ -71,4 +86,8 @@ export const aiStewardApi = {
});
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 { QAHistoryPanel } from "@/components/ai-steward/QAHistoryPanel";
import { OpenClawCommanderPanel } from "@/components/ai-steward/OpenClawCommanderPanel";
import { aiStewardApi } from "@/api/aiSteward";
// ==================== Types ====================
@ -83,7 +84,7 @@ const PROVIDER_ZH: Record<LLMEngine["provider"], string> = {
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: {
model: "deepseek-chat",
base_url: "https://api.deepseek.com/v1",
@ -119,12 +120,15 @@ export default function AIStewardPage() {
const [briefing, setBriefing] = useState<DailyBriefing | null>(null);
const [featureLoading, setFeatureLoading] = useState<string | null>(null);
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({
name: "",
provider: "glm" as "deepseek" | "glm" | "openai" | "ollama",
api_key: "",
model: ENGINE_PROVIDER_PRESETS.glm.model,
base_url: ENGINE_PROVIDER_PRESETS.glm.base_url,
model: ENGINE_PROVIDER_FALLBACK_PRESETS.glm.model,
base_url: ENGINE_PROVIDER_FALLBACK_PRESETS.glm.base_url,
});
const [engineEditingId, setEngineEditingId] = useState<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 = () => {
setEngineEditingId(null);
setEngineForm({
name: "",
provider: "glm",
api_key: "",
model: ENGINE_PROVIDER_PRESETS.glm.model,
base_url: ENGINE_PROVIDER_PRESETS.glm.base_url,
model: engineProviderPresets.glm.model,
base_url: engineProviderPresets.glm.base_url,
});
};
@ -479,7 +520,10 @@ export default function AIStewardPage() {
if (activeTab === "conflicts") loadConflicts();
if (activeTab === "blockers") loadBlockers();
if (activeTab === "briefing") loadBriefing();
if (activeTab === "engine") loadEngineConfigs();
if (activeTab === "engine") {
loadEngineProviderPresets();
loadEngineConfigs();
}
}, [activeTab]);
// ==================== Render ====================
@ -1559,7 +1603,7 @@ export default function AIStewardPage() {
onChange={(e) =>
setEngineForm((prev) => {
const provider = e.target.value as "deepseek" | "glm" | "openai" | "ollama";
const preset = ENGINE_PROVIDER_PRESETS[provider];
const preset = engineProviderPresets[provider];
return {
...prev,
provider,

View File

@ -142,7 +142,7 @@ Return answer
- [x] Added DB/Redis health checks and `depends_on` health gating.
- [x] Added backend container build/runtime file: `backend/Dockerfile`.
- [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`.
### Part 2 — Database Migration Steps

View File

@ -5,11 +5,15 @@ from __future__ import annotations
import argparse
import json
import os
from pathlib import Path
from typing import Any, Optional
VALID_PROVIDERS = {"glm", "openai", "deepseek", "ollama"}
VALID_TIERS = {"low", "high", "safety"}
DEFAULT_RULES_RELATIVE_PATH = "../.openclaw/pmflow-routing-rules.json"
SCRIPT_ROOT = Path(__file__).resolve().parents[1]
BACKEND_DIR = SCRIPT_ROOT / "backend"
LOW_REQUIRED = {
"feishu_level_classification",
@ -76,6 +80,14 @@ def _resolve_primary_tier(function_name: str, rules: list[dict[str, Any]]) -> Op
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]]:
if not path.exists():
return False, {"error": f"rules file not found: {path}"}
@ -135,9 +147,14 @@ def _lint(path: Path) -> tuple[bool, dict[str, Any]]:
break
model_hints = {
"low": "zai/glm-4-flash",
"high": "minimax/MiniMax-M2.5",
"safety": "deepseek/deepseek-chat",
"low": "GLM-4.7",
"high": "MiniMax-M2.5",
"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():
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):
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, {
"path": str(path),
@ -159,15 +179,22 @@ def _lint(path: Path) -> tuple[bool, dict[str, Any]]:
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.add_argument(
"--input",
default=".openclaw/pmflow-routing-rules.json",
default=default_input,
help="Rules file path",
)
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))
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(
provider="openai",
base_url="https://example.com/v1",
model="gpt-4o-mini",
model="MiniMax-M2.5",
)
LLMService._provider_circuit_state.pop(circuit_key, None)

View File

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