diff --git a/.env.example b/.env.example
index 4ba9d69..8af31fb 100644
--- a/.env.example
+++ b/.env.example
@@ -227,6 +227,8 @@ CODE_REVIEW_OLLAMA_FALLBACK_MODEL=hermes3:latest
CODE_REVIEW_OLLAMA_FALLBACK_TIMEOUT=20
CODE_REVIEW_OLLAMA_NUM_PREDICT=384
CODE_REVIEW_OLLAMA_KEEP_ALIVE=5m
+# 預設保護 111:Code Review 這類部署後重分析只跑 GCP-A/GCP-B;需明確救急才設 true。
+CODE_REVIEW_ALLOW_111_FALLBACK=false
CODE_REVIEW_HERMES_TIMEOUT=35
CODE_REVIEW_HERMES_PRIMARY_MODEL=qwen2.5-coder:7b
CODE_REVIEW_HERMES_PRIMARY_TIMEOUT=15
diff --git a/config.py b/config.py
index a11af99..8db8202 100644
--- a/config.py
+++ b/config.py
@@ -325,7 +325,7 @@ YOUTUBE_API_KEY = os.getenv('YOUTUBE_API_KEY', '')
# ==========================================
# 系統版本與路徑
# ==========================================
-SYSTEM_VERSION = "V10.412"
+SYSTEM_VERSION = "V10.413"
LOG_FILE_PATH = os.path.join(BASE_DIR, 'logs/system.log')
public_url = PUBLIC_URL # 用於模板顯示
diff --git a/docs/AI_INTELLIGENCE_MODULE_SOT.md b/docs/AI_INTELLIGENCE_MODULE_SOT.md
index 80ae187..897154b 100644
--- a/docs/AI_INTELLIGENCE_MODULE_SOT.md
+++ b/docs/AI_INTELLIGENCE_MODULE_SOT.md
@@ -18,8 +18,8 @@
- PPT vision、PPT 文案 final fallback、MCP 離線 final fallback 等特殊 Ollama 路徑也不得只打單一 host;如需 `/api/generate`,一律透過 `OllamaService.generate()`。
- Code Review pipeline 也必須 Ollama-first:Hermes scan 與 OpenClaw assessment 都走 `OllamaService` 三主機 retry;Gemini telemetry 只能以 `code_review_openclaw_gemini` 出現,表示 Ollama/可選 Claude 備援都失敗後才啟用。
- Code Review Hermes scan 預設不呼叫 LLM,改用 deterministic fast static scan,避免部署後先卡三段 Ollama timeout;需要 LLM 掃描時才以 `CODE_REVIEW_HERMES_LLM_SCAN_ENABLED=true` 啟用本地矩陣。
-- Code Review Hermes LLM scan 啟用時才使用本地模型矩陣:GCP-A `qwen2.5-coder:7b`、GCP-B `gemma3:4b`;落到 111 時由 `OllamaService` 降級到 `llama3.2:latest`。不啟用 Gemini 備援,三段本地掃描失敗時只回空 findings 並交由 OpenClaw 本地矩陣續跑。
-- Code Review OpenClaw assessment 保持主機順序 GCP-A → GCP-B → 111,但可使用主機適配本地模型:GCP-A `qwen2.5-coder:7b`、GCP-B `gemma3:4b`;primary timeout 預設 `15s`、secondary timeout 預設 `60s`,讓 A 掛時快速讓位給 B,且 B 有足夠時間完成審查 prompt。落到 111 時由 `OllamaService` 降級到 `llama3.2:latest`。Code Review 的 Ollama `keep_alive` 預設為 `5m`,不得再用 `24h` 長駐 runner 壓住 GCP-B/111。三段本地 Ollama 全失敗後才允許雲端備援。
+- Code Review Hermes LLM scan 啟用時才使用本地模型矩陣,且預設只跑 GCP-A `qwen2.5-coder:7b` → GCP-B `gemma3:4b`;`CODE_REVIEW_ALLOW_111_FALLBACK=true` 時才允許落到 111,並由 `OllamaService` 降級到 `llama3.2:latest`。不啟用 Gemini 備援,本地掃描失敗時只回空 findings 並交由 OpenClaw 本地矩陣續跑。
+- Code Review OpenClaw assessment 預設只跑 GCP-A → GCP-B:GCP-A `qwen2.5-coder:7b`、GCP-B `gemma3:4b`;primary timeout 預設 `15s`、secondary timeout 預設 `60s`,讓 A 掛時快速讓位給 B,且 B 有足夠時間完成審查 prompt。111 是最後救急節點,但部署後重分析預設不打 111;只有 `CODE_REVIEW_ALLOW_111_FALLBACK=true` 才允許 111 接手,並降級到 `llama3.2:latest`。Code Review 的 Ollama `keep_alive` 預設為 `5m`,不得再用 `24h` 長駐 runner 壓住 GCP-B/111。GCP-A/GCP-B 都失敗且 Claude/Gemini 未顯式開啟時,必須回 deterministic 本地降級摘要,不呼叫 Gemini、不落 111、不走其他雲端模型。
- OpenClaw Telegram Q&A 主路徑也不得綁單一 host:`_call_qwen3_qa()` 必須透過 `OllamaService` 跑 GCP-A → GCP-B → 111,並把實際落點寫入 `ai_calls.provider`。
- OpenClaw Telegram 圖片商品辨識也必須 Ollama-first:`_identify_product_name_with_ollama_vision()` 透過 `OllamaService` 嘗試 GCP-A → GCP-B → 111;Gemini 只允許以 `openclaw_bot_image_gemini` caller 作為失敗後備援。
- OpenClaw 週報、月報、Meta analysis、日報洞察、Telegram PPT 分析與 MCP fallback 也必須 Ollama-first;Gemini caller 只能帶 `_gemini_fallback` 或明確 fallback caller 語意,且不得先於 Ollama/NIM 被呼叫。OpenClaw strategy 的 Ollama `keep_alive` 預設為 `5m`,避免報告型任務把 GCP-B/111 runner 長駐 24h。
@@ -28,7 +28,7 @@
- `docker-compose.yml` 的 `momo-app`、`scheduler`、`telegram-bot` 必須明確設定 `GEMINI_API_HARD_DISABLED=${GEMINI_API_HARD_DISABLED:-true}` 與 `GEMINI_FALLBACK_ENABLED=${GEMINI_FALLBACK_ENABLED:-false}`;`.env` 可保留 `GEMINI_API_KEY`,但不得因 key 存在就讓核心容器產生 Gemini 付費出站。
- Gemini 不可被任何狀態面板或 router 推薦為主提供者:`AIProviderService._get_recommended_provider()` 不得回傳 `gemini`,只能顯示為 fallback 狀態;`llm_model_router` 的 `ea_engine` 若收到 `gemini-*` default 必須改回 `hermes3:latest`,需要深推理時才升本地 `deepseek-r1:14b`。
- ElephantAlpha prompt / agent registry 不得再把 OpenClaw 描述為 Gemini 主模型;OpenClaw 是 `qwen2.5-coder:7b` / `qwen3:14b` Ollama-first 策略師,Gemini 僅能在 guard 顯式解鎖後作 emergency fallback。
-- 111 `192.168.0.111` 只是最後一道 Mac fallback,不承接 7B+、vision、long-context 模型長駐;`OllamaService.generate()` 落到 111 時會將 `qwen3`、`deepseek-r1`、`hermes3`、`qwen2.5*`、`gemma3`、`llava`、`minicpm-v` 與 7B+ 模型依 `OLLAMA_111_MODEL_DOWNGRADE_PATTERNS` 降級到 `OLLAMA_111_MODEL_FALLBACK=llama3.2:latest`,並以 `OLLAMA_111_KEEP_ALIVE=5m`、`OLLAMA_111_MAX_TIMEOUT=20`、`OLLAMA_111_NUM_CTX=4096`、`OLLAMA_111_NUM_PREDICT=512` 封頂。Hermes / OpenClaw / Code Review 路徑的業務 keep-alive 也預設 `5m`,避免 16GB RAM 主機與 GCP-B 被長駐 runner、長輸出與 24h keep-alive 壓到高 load。
+- 111 `192.168.0.111` 只是最後一道 Mac fallback,不承接 7B+、vision、long-context 模型長駐;`OllamaService.generate()` 落到 111 時會將 `qwen3`、`deepseek-r1`、`hermes3`、`qwen2.5*`、`gemma3`、`llava`、`minicpm-v` 與 7B+ 模型依 `OLLAMA_111_MODEL_DOWNGRADE_PATTERNS` 降級到 `OLLAMA_111_MODEL_FALLBACK=llama3.2:latest`,並以 `OLLAMA_111_KEEP_ALIVE=5m`、`OLLAMA_111_MAX_TIMEOUT=20`、`OLLAMA_111_NUM_CTX=4096`、`OLLAMA_111_NUM_PREDICT=512` 封頂。Hermes / OpenClaw 報告型路徑的業務 keep-alive 也預設 `5m`;Code Review 另以 `CODE_REVIEW_ALLOW_111_FALLBACK=false` 預設跳過 111,避免 16GB RAM 主機與 GCP-B 被長駐 runner、長輸出與 24h keep-alive 壓到高 load。
- ElephantAlpha 的 `price_drop_alert` / `market_opportunity` Telegram HITL 告警必須把同款證據獨立呈現,至少包含 `match_type`、`price_basis`、`alert_tier` 與 `match_score`;沒有高信心同款與總價可比證據時,不得把 PChome/MOMO 價差寫成可直接跟價建議。
## 一、四 AI Agent 路由架構
diff --git a/docs/memory/history_logs.md b/docs/memory/history_logs.md
index d5ec269..49132cd 100644
--- a/docs/memory/history_logs.md
+++ b/docs/memory/history_logs.md
@@ -13,6 +13,7 @@
## 📅 詳細更新日誌 (考古存檔)
### 2026-05-24:PChome 近門檻身份回收第二輪
+- **V10.413 Code Review 預設保護 111 fallback**: production `ai_calls` 顯示 GCP-A 不可達時,Code Review OpenClaw 會先耗掉 primary timeout,再讓 GCP-B 撐到 60s,最後落到 111 `llama3.2` 成功,造成 111 與 GCP-B 高負載。新增 `CODE_REVIEW_ALLOW_111_FALLBACK=false` 預設:Code Review 的 Hermes LLM scan / OpenClaw assessment 只跑 GCP-A → GCP-B;只有明確設 true 才把部署後重分析丟給 111。若 GCP-A/GCP-B 都失敗且 Claude/Gemini 未顯式開啟,改回 deterministic 本地降級摘要,不呼叫 Gemini,也不再用 111 承接非即時重分析。
- **V10.412 MCP fetch run package gate**: 新增 `mcp_fetch_run_package` read-only builder、獨立 route extension、GET/POST endpoint、UI run package 審核面板與 deployment readiness smoke target,將已通過的 target review 轉成操作員可覆核的 command argv preview 與 receipt path 契約;API/UI 不執行 CLI、不抓外站、不寫檔、不開 DB、不掛 scheduler,只放行到後續 run readiness review。
- **V10.411 rom&nd / Summer’s Eve / Solone 近門檻 review-only 回收**: marketplace matcher 追加三條窄範圍 focused identity:rom&nd 果汁唇釉 2.0 catalog、Summer’s Eve 舒摩兒全肌防護浴潔露 2入、Solone 持久眼線筆;皆只進 `identity_review` / manual-review,不直接價格告警。production pilot 已回刷 3/3,`matched` 1616→1619、`true_low_confidence` 763→760;rom&nd 染眉膏 ZO&FRIENDS 色號、Summer’s Eve 雙天王任選、Lactacyd 清新舒涼 vs 生理呵護、MAC 柔霧 vs 緞光、NIVEA / 曼秀雷敦包數差異仍不自動救回,維持準確率優先。
- **V10.410 Code Review timeout 梯度改為保護 111**: 部署後實測顯示 GCP-A 從 188 失聯時,Code Review 仍會先等 primary 45s,GCP-B 完整審查 prompt 又常因 25s 太短而 timeout,最後轉落 111。`CODE_REVIEW_OLLAMA_TIMEOUT` 預設收斂為 `15s`,`CODE_REVIEW_OLLAMA_SECONDARY_TIMEOUT` 放寬為 `60s`;Hermes LLM scan 若啟用則 primary `15s`、secondary `45s`。目標是 A 掛時更快讓位給 B,並給 B 足夠時間完成,避免過早壓到 111。
diff --git a/services/code_review_pipeline_service.py b/services/code_review_pipeline_service.py
index 65d66a7..5d2e33d 100644
--- a/services/code_review_pipeline_service.py
+++ b/services/code_review_pipeline_service.py
@@ -37,6 +37,11 @@ from services.gemini_guard import gemini_disabled_message, get_gemini_api_key
logger = logging.getLogger(__name__)
+
+def _env_bool(name: str, default: str = "false") -> bool:
+ return os.getenv(name, default).strip().lower() in {"1", "true", "yes", "on"}
+
+
# ── Pipeline 全域狀態(供前端 polling)─────────────────────────────────────
_current_pipeline: Dict[str, Any] = {}
_pipeline_lock = threading.Lock()
@@ -64,6 +69,7 @@ CODE_REVIEW_OLLAMA_FALLBACK_TIMEOUT = int(
)
CODE_REVIEW_OLLAMA_NUM_PREDICT = int(os.getenv("CODE_REVIEW_OLLAMA_NUM_PREDICT", "384"))
CODE_REVIEW_OLLAMA_KEEP_ALIVE = os.getenv("CODE_REVIEW_OLLAMA_KEEP_ALIVE", "5m")
+CODE_REVIEW_ALLOW_111_FALLBACK = _env_bool("CODE_REVIEW_ALLOW_111_FALLBACK", "false")
CODE_REVIEW_HERMES_TIMEOUT = int(os.getenv("CODE_REVIEW_HERMES_TIMEOUT", "35"))
CODE_REVIEW_HERMES_PRIMARY_MODEL = os.getenv(
"CODE_REVIEW_HERMES_PRIMARY_MODEL",
@@ -245,7 +251,7 @@ class CodeReviewPipeline:
# ── Step 2:Hermes 掃描 ───────────────────────────────────────────────────
def _hermes_scan(self, files: Dict[str, str]) -> List[Dict]:
- """走 OllamaService 三主機級聯:GCP-A → GCP-B → 111。"""
+ """走 GCP-A → GCP-B;只有 CODE_REVIEW_ALLOW_111_FALLBACK=true 才落到 111。"""
try:
if not CODE_REVIEW_HERMES_LLM_SCAN_ENABLED:
findings = self._static_code_scan(files)
@@ -301,13 +307,14 @@ class CodeReviewPipeline:
CODE_REVIEW_HERMES_SECONDARY_MODEL,
CODE_REVIEW_HERMES_SECONDARY_TIMEOUT,
),
- (
+ ]
+ if CODE_REVIEW_ALLOW_111_FALLBACK:
+ hermes_attempts.append((
"lan_111_hermes_scan",
OLLAMA_HOST_FALLBACK,
CODE_REVIEW_HERMES_FALLBACK_MODEL,
CODE_REVIEW_HERMES_FALLBACK_TIMEOUT,
- ),
- ]
+ ))
findings = None
last_error = None
@@ -437,10 +444,12 @@ class CodeReviewPipeline:
def _openclaw_assess(self, files: Dict[str, str], findings: List[Dict]) -> str:
"""
路由優先序:
- L1 (預設) → Ollama GCP-A → GCP-B → 111
+ L1 (預設) → Ollama GCP-A → GCP-B
+ L1b (flag CODE_REVIEW_ALLOW_111_FALLBACK=true) → 111 最後備援
L2 (flag CODE_REVIEW_USE_CLAUDE=true) → Claude Opus 4.7 雲端備援
L3 (Gemini guard 顯式解鎖) → Gemini 雲端備援
- L4 (降級) → ElephantAlpha via NIM/OpenRouter
+ L4 (雲端未顯式開啟時) → deterministic 本地降級摘要
+ L5 (雲端顯式開啟但失敗時) → ElephantAlpha via NIM/OpenRouter
"""
sev = self.state["severity_summary"]
findings_json = json.dumps(findings[:8], ensure_ascii=False, indent=2)
@@ -464,7 +473,7 @@ class CodeReviewPipeline:
💡 架構優化方向(1條長期建議)
✅ 本次部署亮點"""
- # ── L1:Ollama-first — GCP-A → GCP-B → 111 ──────────────────────────
+ # ── L1:Ollama-first — GCP-A → GCP-B(111 需顯式開 flag)──────────────
from services.ollama_service import (
OLLAMA_HOST_FALLBACK,
OLLAMA_HOST_PRIMARY,
@@ -475,9 +484,12 @@ class CodeReviewPipeline:
)
gemini_api_key = get_gemini_api_key("code_review")
+ cloud_fallback_available = CODE_REVIEW_USE_CLAUDE or bool(gemini_api_key)
fallback_caller = 'code_review_openclaw' if CODE_REVIEW_USE_CLAUDE else (
'code_review_openclaw_gemini' if gemini_api_key else 'code_review_elephant'
)
+ if not cloud_fallback_available:
+ fallback_caller = 'code_review_local_degraded'
ollama_attempts = [
(
"primary_code",
@@ -491,13 +503,14 @@ class CodeReviewPipeline:
CODE_REVIEW_OLLAMA_SECONDARY_MODEL,
CODE_REVIEW_OLLAMA_SECONDARY_TIMEOUT,
),
- (
+ ]
+ if CODE_REVIEW_ALLOW_111_FALLBACK:
+ ollama_attempts.append((
"lan_111_hermes",
OLLAMA_HOST_FALLBACK,
CODE_REVIEW_OLLAMA_FALLBACK_MODEL,
CODE_REVIEW_OLLAMA_FALLBACK_TIMEOUT,
- ),
- ]
+ ))
last_ollama_error = None
for attempt_index, (attempt_key, host, model_name, timeout_s) in enumerate(
@@ -544,10 +557,20 @@ class CodeReviewPipeline:
_ctx.fallback_to_caller(fallback_caller)
logger.warning(
- "[CodeReview] OpenClaw 本地 Ollama 鏈全部失敗,才啟用雲端備援: %s",
+ "[CodeReview] OpenClaw 本地 Ollama 鏈全部失敗: %s",
last_ollama_error,
)
+ if not cloud_fallback_available:
+ logger.warning(
+ "[CodeReview] 111/cloud fallback 未啟用,回傳 deterministic 本地降級評估",
+ )
+ return self._build_local_openclaw_degraded_report(
+ files=files,
+ findings=findings,
+ last_error=last_ollama_error,
+ )
+
# ── L1:Phase 7 Frontier — Claude Opus 4.7(程式碼能力 #1)────────────
# feature flag 預設 OFF;ON 時只作 Ollama 失敗後的雲端備援。
if CODE_REVIEW_USE_CLAUDE:
@@ -668,6 +691,35 @@ class CodeReviewPipeline:
return ""
+ def _build_local_openclaw_degraded_report(
+ self,
+ *,
+ files: Dict[str, str],
+ findings: List[Dict],
+ last_error: Optional[str],
+ ) -> str:
+ sev = self.state["severity_summary"]
+ risk = "低"
+ if sev["critical"]:
+ risk = "嚴重"
+ elif sev["high"]:
+ risk = "高"
+ elif sev["medium"]:
+ risk = "中"
+ top = [f for f in findings[:2] if f.get("description")]
+ top_text = ";".join(
+ f"{f.get('file', 'unknown')}:{f.get('description')}" for f in top
+ ) or "本次 deterministic scan 未發現高風險問題"
+ error_text = (last_error or "local Ollama unavailable")[:120]
+ return (
+ f"🔍 整體風險等級 {risk}。"
+ f"本地掃描完成 {len(files)} 檔,CRITICAL={sev['critical']}、HIGH={sev['high']}、"
+ f"MEDIUM={sev['medium']}、LOW={sev['low']}。"
+ f"⚠️ 最需關注問題 {top_text}。"
+ f"💡 架構優化方向 GCP-A/GCP-B OpenClaw 不可用時暫停 111 重分析,避免拖高 fallback 主機負載。"
+ f"✅ 本次部署亮點 已以本地降級報告收斂,未呼叫 Gemini;最後錯誤:{error_text}"
+ )
+
# ── Step 4:ElephantAlpha 決策 ─────────────────────────────────────────────
def _ea_orchestrate(self, findings: List[Dict], openclaw_report: str) -> Dict:
diff --git a/tests/test_code_review_claude_routing.py b/tests/test_code_review_claude_routing.py
index 1f4d527..5d47b9f 100644
--- a/tests/test_code_review_claude_routing.py
+++ b/tests/test_code_review_claude_routing.py
@@ -201,6 +201,7 @@ def test_code_review_ollama_defaults_use_fast_local_model(monkeypatch):
"CODE_REVIEW_OLLAMA_FALLBACK_TIMEOUT",
"CODE_REVIEW_OLLAMA_NUM_PREDICT",
"CODE_REVIEW_OLLAMA_KEEP_ALIVE",
+ "CODE_REVIEW_ALLOW_111_FALLBACK",
"CODE_REVIEW_HERMES_TIMEOUT",
"CODE_REVIEW_HERMES_PRIMARY_MODEL",
"CODE_REVIEW_HERMES_PRIMARY_TIMEOUT",
@@ -225,6 +226,7 @@ def test_code_review_ollama_defaults_use_fast_local_model(monkeypatch):
assert svc_mod.CODE_REVIEW_OLLAMA_FALLBACK_TIMEOUT == 20
assert svc_mod.CODE_REVIEW_OLLAMA_NUM_PREDICT == 384
assert svc_mod.CODE_REVIEW_OLLAMA_KEEP_ALIVE == "5m"
+ assert svc_mod.CODE_REVIEW_ALLOW_111_FALLBACK is False
assert svc_mod.CODE_REVIEW_HERMES_TIMEOUT == 35
assert svc_mod.CODE_REVIEW_HERMES_PRIMARY_MODEL == "qwen2.5-coder:7b"
assert svc_mod.CODE_REVIEW_HERMES_PRIMARY_TIMEOUT == 15
@@ -304,6 +306,62 @@ def test_openclaw_uses_secondary_local_model_before_gemini(monkeypatch):
fake_elephant.generate.assert_not_called()
+def test_openclaw_skips_111_and_cloud_by_default_when_gcp_pair_fails(monkeypatch):
+ """GCP-A/B 都失敗時,預設不把 Code Review 重分析丟給 111 或雲端。"""
+ monkeypatch.setenv('CODE_REVIEW_USE_CLAUDE', 'false')
+ monkeypatch.delenv('CODE_REVIEW_ALLOW_111_FALLBACK', raising=False)
+ monkeypatch.setenv('GEMINI_API_HARD_DISABLED', 'true')
+ monkeypatch.setenv('GEMINI_FALLBACK_ENABLED', 'false')
+ monkeypatch.delenv('GEMINI_API_KEY', raising=False)
+ _stub_logger(monkeypatch)
+
+ svc_mod = _reload_pipeline()
+ import services.ollama_service as ollama_mod
+
+ calls = []
+
+ class FakeResp:
+ success = False
+ content = ""
+ error = "timeout"
+ input_tokens = 0
+ output_tokens = 0
+
+ def __init__(self, *, host, model):
+ self.host = host
+ self.model = model
+
+ class FakeOllama:
+ def __init__(self, host=None, model=None):
+ self.host = host
+ self.model = model
+
+ def generate(self, **kwargs):
+ calls.append({
+ "host": self.host,
+ "model": kwargs["model"],
+ "timeout": kwargs["timeout"],
+ })
+ return FakeResp(host=self.host, model=kwargs["model"])
+
+ monkeypatch.setattr(ollama_mod, "OllamaService", FakeOllama)
+ fake_claude = _stub_anthropic(monkeypatch, svc_mod, available=True)
+ fake_genai, fake_elephant = _stub_gemini_and_elephant(monkeypatch)
+
+ pipeline = _make_pipeline(svc_mod)
+ result = pipeline._openclaw_assess(
+ files={"services/foo.py": "def x(): pass"},
+ findings=[],
+ )
+
+ assert "本地降級報告" in result
+ assert [call["model"] for call in calls] == ["qwen2.5-coder:7b", "gemma3:4b"]
+ assert not any("192.168.0.111" in call["host"] for call in calls)
+ fake_claude.generate.assert_not_called()
+ fake_genai.GenerativeModel.assert_not_called()
+ fake_elephant.generate.assert_not_called()
+
+
def test_hermes_scan_uses_compact_prompt_and_short_timeout(monkeypatch):
"""Hermes scan 只送 compact snippet,避免大檔讓三主機各卡 120 秒。"""
monkeypatch.setenv("CODE_REVIEW_HERMES_LLM_SCAN_ENABLED", "true")