diff --git a/.env.example b/.env.example
index 0f04104..e2b75e9 100644
--- a/.env.example
+++ b/.env.example
@@ -179,10 +179,10 @@ GEMINI_MODEL=gemini-1.5-flash
OPENCLAW_MODEL=gemini-2.5-flash-preview-05-20
# ── Anthropic Claude API(Phase 7 Frontier 升級)───────────────────────────
-# 用途:Code Review 升級到 Claude Opus 4.7(程式碼能力 #1,Arena Elo 1548)
+# 用途:Code Review 的 Ollama-first 主路徑失敗後,可選擇 Claude Opus 4.7 作雲端備援
# 取得方式:https://console.anthropic.com/settings/keys
-# feature flag CODE_REVIEW_USE_CLAUDE 預設 false → 行為與 Phase 6 完全相同
-# 啟用步驟:(1) 設 ANTHROPIC_API_KEY (2) CODE_REVIEW_USE_CLAUDE=true
+# feature flag CODE_REVIEW_USE_CLAUDE 預設 false;即使設 true 也必須先跑 Ollama 三主機
+# 啟用備援步驟:(1) 設 ANTHROPIC_API_KEY (2) CODE_REVIEW_USE_CLAUDE=true
ANTHROPIC_API_KEY=
CODE_REVIEW_USE_CLAUDE=false
CLAUDE_MODEL=claude-opus-4-7
@@ -213,6 +213,8 @@ BOT_API_TOKEN=your_bot_api_token_here
# [必填] Post-deploy AI code review pipeline 自動修復主開關
# ADR-020 規定預設 true(任何 finding 一律自動觸發 AiderHeal,安全網=Git+CI/CD 回滾)
# 僅在需要短期關閉自動修復鏈時設為 false
+CODE_REVIEW_OLLAMA_MODEL=qwen3:14b
+CODE_REVIEW_OLLAMA_TIMEOUT=120
CODE_REVIEW_AUTO_FIX_ENABLED=true
# [選填] 僅本機開發可設 true;正式環境不得允許不安全 internal webhook
diff --git a/config.py b/config.py
index dd7d29f..17c621a 100644
--- a/config.py
+++ b/config.py
@@ -320,7 +320,7 @@ YOUTUBE_API_KEY = os.getenv('YOUTUBE_API_KEY', '')
# ==========================================
# 系統版本與路徑
# ==========================================
-SYSTEM_VERSION = "V10.124"
+SYSTEM_VERSION = "V10.125"
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 1626f46..88d15ae 100644
--- a/docs/AI_INTELLIGENCE_MODULE_SOT.md
+++ b/docs/AI_INTELLIGENCE_MODULE_SOT.md
@@ -13,6 +13,7 @@
- Gemini 只能作為 Ollama 主路徑失敗後的備援,或 ADR-028 明確鎖定的 MCP Grounding、PPT/vision、週/月報、Code Review、EA HITL、複雜 SKU 升級等低頻場景。
- 188 `192.168.0.188` 僅是 App / DB / scheduler / Telegram bot 容器宿主與 AutoHeal target,不可作為 Ollama 節點。
- 通用 AI 文案、關鍵字、商品洞察與 Telegram Q&A 第一響應不得 Gemini-first。
+- Code Review pipeline 也必須 Ollama-first:Hermes scan 與 OpenClaw assessment 都走 `OllamaService` 三主機 retry;Gemini telemetry 只能以 `code_review_openclaw_gemini` 出現,表示 Ollama/可選 Claude 備援都失敗後才啟用。
## 一、四 AI Agent 路由架構
diff --git a/docs/memory/claude_inventory_validation_20260513.md b/docs/memory/claude_inventory_validation_20260513.md
index 4563fd0..11c4219 100644
--- a/docs/memory/claude_inventory_validation_20260513.md
+++ b/docs/memory/claude_inventory_validation_20260513.md
@@ -52,6 +52,7 @@
- `qwen3:14b` 不是未使用 Ollama 模型:OpenClaw QA、NemoTron dispatch 與 LLM model router 仍有現役路徑;`tests/test_qwen3_runtime_usage.py` 已守住,不能只因體積大就三主機移除。
- Ollama host env 已加白名單護欄:`OLLAMA_HOST*` / `EMBEDDING_HOST` 只接受 GCP-A、GCP-B、111 或 110 proxy,誤設 188/localhost 會回到核准主機。
- OpenClaw QA / daily Hermes template / NemoTron qwen3 的 flag 文件與測試已對齊 Ollama-first 預設 ON;顯式 `false` 才是 Gemini/NIM legacy 緊急退路。
+- Code Review pipeline 已對齊 Ollama-first:`_hermes_scan()` 與 `_openclaw_assess()` 都先走 `OllamaService` 的 GCP-A → GCP-B → 111 retry;Gemini 僅在 Ollama(與可選 Claude)失敗後以 `code_review_openclaw_gemini` caller 記錄備援,不再以 `code_review_openclaw` 直接 Gemini-first。
- `.env.example` 已補齊 Python runtime 實際讀取的環境變數,`tests/test_phase3f_cleanup_contracts.py::test_env_example_documents_runtime_os_env_keys` 會掃 `app.py/config.py/scheduler.py/run_scheduler.py/routes/services/utils` 的 `os.getenv()` / `os.environ.get()`;只允許 `PYTEST_CURRENT_TEST` 與 `MOMO_ALLOW_INSECURE_CONFIG_FOR_TESTS` 兩個測試內部 key 不進範例。
- `docker-compose*.yml` 使用的 `${VAR}` 也已納入 `.env.example` 契約,包含 MCP compose 的 `TAVILY_API_KEY`、`EXA_API_KEY`、`MCP_POSTGRES_PASSWORD`、`FIRECRAWL_AUTH_KEY`,以及 image tag / Grafana / pgAdmin / Metabase / Grist 變數;`test_env_example_documents_docker_compose_variables` 會守住。
- Market Intel `seed_writer_cli_status` route 已補 API 層回歸:即使 `execute=true` 且環境有 `MARKET_INTEL_SEED_WRITE_APPROVAL`,API 仍不得回吐 token / `approval_token_hint` / 固定 token 文案,且不得 ready 或寫入;`tests/test_market_intel_skeleton.py::test_seed_writer_cli_status_route_never_leaks_approval_token` 會守住。
diff --git a/services/ai_call_logger.py b/services/ai_call_logger.py
index 9c91b16..a4326a6 100644
--- a/services/ai_call_logger.py
+++ b/services/ai_call_logger.py
@@ -170,6 +170,11 @@ class _CallState:
except (TypeError, ValueError):
self.output_tokens = 0
+ def set_provider(self, provider: str) -> None:
+ """更新實際 provider。適用於 Ollama 三主機 retry 後才知道落點的 caller。"""
+ if provider:
+ self.provider = provider[:32]
+
def set_cache_hit(self, hit: bool = True) -> None:
self.cache_hit = bool(hit)
diff --git a/services/code_review_pipeline_service.py b/services/code_review_pipeline_service.py
index df19dca..bb1f5fe 100644
--- a/services/code_review_pipeline_service.py
+++ b/services/code_review_pipeline_service.py
@@ -8,7 +8,7 @@ Post-Deploy AI Agent Code Review Pipeline
Pipeline:
Step 1 system 讀取變更檔案內容
Step 2 Hermes 程式碼掃描(bugs / security / performance)
- Step 3 OpenClaw 架構品質評估(Gemini 2.5 Flash)
+ Step 3 OpenClaw 架構品質評估(Ollama-first,Gemini 僅備援)
Step 4 ElephantAlpha 決策協調(severity 判定 + auto-fix 裁量)
Step 5 NemoTron 行動派遣(action_plans 寫入 + AiderHeal 觸發)
@@ -29,11 +29,8 @@ from typing import Any, Dict, List, Optional
from database.manager import get_session
from sqlalchemy import text
-# ADR-027 Phase 2 N3:HERMES_MODEL 仍 import(純常數),HERMES_URL 改 lazy
-# 每次 _hermes_scan 才透過 get_hermes_url() 取最新解析(GCP 優先 / 111 備援),
-# 避免 import-time freeze 導致主機切換不生效。
+# ADR-027:Code Review 走 OllamaService 取得三主機級聯 retry。
from services.hermes_analyst_service import HERMES_MODEL as _HERMES_MODEL
-from config import get_hermes_url
from services.ai_call_logger import log_ai_call # Operation Ollama-First v5.0 P1
logger = logging.getLogger(__name__)
@@ -43,15 +40,18 @@ _current_pipeline: Dict[str, Any] = {}
_pipeline_lock = threading.Lock()
GEMINI_API_KEY = os.getenv("GEMINI_API_KEY", "")
-# LOCKED-GEMINI: Code Review 全 repo diff 可達 100K+ tokens,超過 Ollama 32K context
-# Phase 7 升級:CODE_REVIEW_USE_CLAUDE=true 時改走 Claude Opus 4.7(200K context, Arena code Elo 1548)
-# 預設 OFF,行為與 Phase 6 完全相同;ADR-028 鎖定場景 #5
+# Gemini 僅作 Code Review Ollama/Claude 主路徑都失敗後的最後雲端備援。
REVIEW_MODEL = os.getenv("OPENCLAW_MODEL", "gemini-2.5-flash")
+CODE_REVIEW_OLLAMA_MODEL = os.getenv(
+ "CODE_REVIEW_OLLAMA_MODEL",
+ os.getenv("OPENCLAW_OLLAMA_MODEL", "qwen3:14b"),
+)
+CODE_REVIEW_OLLAMA_TIMEOUT = int(os.getenv("CODE_REVIEW_OLLAMA_TIMEOUT", "120"))
INTERNAL_TOKEN = os.getenv("INTERNAL_WEBHOOK_TOKEN", "")
AUTO_FIX_ENABLED = os.getenv("CODE_REVIEW_AUTO_FIX_ENABLED", "true").lower() == "true"
ALLOW_INSECURE_WEBHOOK = os.getenv("MOMO_ALLOW_INSECURE_INTERNAL_WEBHOOK_FOR_DEV", "").lower() == "true"
-# Phase 7 Frontier 升級 feature flag — 預設 OFF,啟用後 _openclaw_assess 改走 Claude Opus 4.7
+# Phase 7 Frontier 升級 feature flag — 預設 OFF;啟用後只作 Ollama 失敗後的雲端備援。
CODE_REVIEW_USE_CLAUDE = os.getenv("CODE_REVIEW_USE_CLAUDE", "false").lower() == "true"
CLAUDE_REVIEW_MODEL = os.getenv("CLAUDE_MODEL", "claude-opus-4-7")
@@ -163,7 +163,7 @@ class CodeReviewPipeline:
self._step_start(3, "OpenClaw 架構品質評估", "OpenClaw")
openclaw_report = self._openclaw_assess(file_contents, findings)
self.state["openclaw_report"] = openclaw_report
- self._step_done(3, openclaw_report[:120] if openclaw_report else "(Gemini 未回應)")
+ self._step_done(3, openclaw_report[:120] if openclaw_report else "(OpenClaw 未回應)")
# Step 4 ─ ElephantAlpha orchestration
self._step_start(4, "ElephantAlpha 決策協調", "ElephantAlpha")
@@ -204,10 +204,8 @@ class CodeReviewPipeline:
# ── Step 2:Hermes 掃描 ───────────────────────────────────────────────────
def _hermes_scan(self, files: Dict[str, str]) -> List[Dict]:
- """走 resolve_ollama_host() 三主機級聯:GCP-A → GCP-B → 111(ADR-027 Phase 2)"""
+ """走 OllamaService 三主機級聯:GCP-A → GCP-B → 111。"""
try:
- import requests as _req
-
files_text = "\n\n".join(
f"### {name}\n```python\n{content}\n```"
for name, content in list(files.items())[:4]
@@ -227,34 +225,36 @@ class CodeReviewPipeline:
只輸出 JSON 陣列,不含其他文字。無問題時輸出 []"""
- # ADR-027 Phase 2 N3:lazy resolve Hermes 主機(GCP 優先 / 111 備援),
- # 避開 import-time freeze。Phase 53:改用 services.ollama_service.get_provider_tag
- # 統一函式,支援 110 Nginx Proxy(11435/11436)。
- hermes_url = get_hermes_url()
- from services.ollama_service import get_provider_tag
- provider_tag = get_provider_tag(hermes_url)
- # Phase 1 v5.0: 包 ai_call_logger 追蹤 Code Review Hermes scan
+ from services.ollama_service import OllamaService, get_host_label, get_provider_tag
+
with log_ai_call(
caller='code_review_hermes',
- provider=provider_tag,
+ provider='gcp_ollama',
model=_HERMES_MODEL,
request_id=f"cr-{self.commit_sha[:8]}",
meta={'commit': self.commit_sha[:8], 'branch': self.branch,
- 'files': len(files), 'host': hermes_url},
+ 'files': len(files), 'route': 'ollama_first'},
) as _ctx:
- resp = _req.post(
- f"{hermes_url}/api/generate",
- json={"model": _HERMES_MODEL, "prompt": prompt,
- "stream": False, "options": {"temperature": 0.1}},
- timeout=120,
+ ollama = OllamaService(model=_HERMES_MODEL)
+ resp = ollama.generate(
+ prompt=prompt,
+ model=_HERMES_MODEL,
+ temperature=0.1,
+ timeout=CODE_REVIEW_OLLAMA_TIMEOUT,
)
- resp.raise_for_status()
- body = resp.json()
+ actual_provider = get_provider_tag(resp.host or '')
+ _ctx.set_provider(actual_provider)
_ctx.set_tokens(
- input=body.get("prompt_eval_count", 0),
- output=body.get("eval_count", 0),
+ input=resp.input_tokens,
+ output=resp.output_tokens,
)
- raw = body.get("response", "").strip()
+ _ctx.add_meta('host', resp.host)
+ _ctx.add_meta('host_label', get_host_label(resp.host or ''))
+ if not resp.success:
+ _ctx.set_error(resp.error or 'ollama generate failed')
+ logger.warning("[CodeReview] Hermes Ollama 掃描失敗: %s", resp.error)
+ return []
+ raw = (resp.content or "").strip()
match = re.search(r"\[.*\]", raw, re.DOTALL)
if not match:
@@ -279,10 +279,10 @@ class CodeReviewPipeline:
def _openclaw_assess(self, files: Dict[str, str], findings: List[Dict]) -> str:
"""
路由優先序:
- L1 (Phase 7, flag CODE_REVIEW_USE_CLAUDE=true) → Claude Opus 4.7 (Arena code #1)
- L2 (預設) → Gemini(GEMINI_API_KEY)
- L3 (降級) → ElephantAlpha via OpenRouter
- feature flag 預設 OFF,行為與 Phase 6 完全相同。
+ L1 (預設) → Ollama GCP-A → GCP-B → 111
+ L2 (flag CODE_REVIEW_USE_CLAUDE=true) → Claude Opus 4.7 雲端備援
+ L3 (GEMINI_API_KEY 存在) → Gemini 雲端備援
+ L4 (降級) → ElephantAlpha via NIM/OpenRouter
"""
sev = self.state["severity_summary"]
findings_json = json.dumps(findings[:8], ensure_ascii=False, indent=2)
@@ -306,13 +306,49 @@ class CodeReviewPipeline:
💡 架構優化方向(1條長期建議)
✅ 本次部署亮點"""
+ # ── L1:Ollama-first — GCP-A → GCP-B → 111 ──────────────────────────
+ from services.ollama_service import OllamaService, get_host_label, get_provider_tag
+
+ with log_ai_call(
+ caller='code_review_openclaw',
+ provider='gcp_ollama',
+ model=CODE_REVIEW_OLLAMA_MODEL,
+ request_id=f"cr-{self.commit_sha[:8]}",
+ meta={
+ 'commit': self.commit_sha[:8],
+ 'branch': self.branch,
+ 'route': 'ollama_first',
+ },
+ ) as _ctx:
+ ollama = OllamaService(model=CODE_REVIEW_OLLAMA_MODEL)
+ resp = ollama.generate(
+ prompt=user_prompt,
+ system_prompt=system,
+ model=CODE_REVIEW_OLLAMA_MODEL,
+ temperature=0.2,
+ timeout=CODE_REVIEW_OLLAMA_TIMEOUT,
+ )
+ actual_provider = get_provider_tag(resp.host or '')
+ _ctx.set_provider(actual_provider)
+ _ctx.set_tokens(input=resp.input_tokens, output=resp.output_tokens)
+ _ctx.add_meta('host', resp.host)
+ _ctx.add_meta('host_label', get_host_label(resp.host or ''))
+ if resp.success and (resp.content or '').strip():
+ return resp.content or ""
+ _ctx.set_error(resp.error or 'ollama generate failed')
+ _ctx.fallback_to_caller('code_review_openclaw')
+ logger.warning(
+ "[CodeReview] OpenClaw Ollama 三主機皆失敗,才啟用雲端備援: %s",
+ resp.error,
+ )
+
# ── L1:Phase 7 Frontier — Claude Opus 4.7(程式碼能力 #1)────────────
- # feature flag 預設 OFF;ON 時優先走,失敗 fallback 到 L2 Gemini
+ # feature flag 預設 OFF;ON 時只作 Ollama 失敗後的雲端備援。
if CODE_REVIEW_USE_CLAUDE:
try:
from services.anthropic_service import anthropic_service
except Exception as e:
- logger.warning("[CodeReview] Claude service import 失敗,退回 Gemini: %s", e)
+ logger.warning("[CodeReview] Claude service import 失敗,改走 Gemini 備援: %s", e)
anthropic_service = None # type: ignore
if anthropic_service is not None and anthropic_service.is_available():
@@ -342,25 +378,29 @@ class CodeReviewPipeline:
_ctx.add_meta('cache_creation_tokens', resp.cache_creation_tokens)
_ctx.add_meta('cache_read_tokens', resp.cache_read_tokens)
return resp.content or ""
- # Claude 失敗 → fallback 到 Gemini(L2)
+ # Claude 失敗 → fallback 到 Gemini 備援(L3)
_ctx.set_error(resp.error or 'claude generate failed')
_ctx.fallback_to_caller('code_review_openclaw_gemini')
logger.warning(
- "[CodeReview] Claude 失敗,降級 Gemini: %s", resp.error,
+ "[CodeReview] Claude 失敗,改走 Gemini 備援: %s", resp.error,
)
else:
logger.info(
- "[CodeReview] CODE_REVIEW_USE_CLAUDE=true 但 Claude 不可用(缺 API key 或 SDK),退回 Gemini",
+ "[CodeReview] CODE_REVIEW_USE_CLAUDE=true 但 Claude 不可用(缺 API key 或 SDK),改走 Gemini 備援",
)
- # ── L2:Gemini — Phase 1 v5.0 logger 追蹤 ────────────────────────────
+ # ── L3:Gemini — 僅作 Ollama/Claude 都失敗後的備援 ───────────────────
if GEMINI_API_KEY:
with log_ai_call(
- caller='code_review_openclaw',
+ caller='code_review_openclaw_gemini',
provider='gemini',
model=REVIEW_MODEL,
request_id=f"cr-{self.commit_sha[:8]}",
- meta={'commit': self.commit_sha[:8], 'branch': self.branch},
+ meta={
+ 'commit': self.commit_sha[:8],
+ 'branch': self.branch,
+ 'fallback_from': 'code_review_openclaw_ollama',
+ },
) as _ctx:
try:
import google.generativeai as genai
diff --git a/services/llm_caller_registry.py b/services/llm_caller_registry.py
index ebcd1ba..5ca5c8d 100644
--- a/services/llm_caller_registry.py
+++ b/services/llm_caller_registry.py
@@ -55,9 +55,9 @@ CALLER_REGISTRY: frozenset = frozenset({
# Code Review(code_review_pipeline_service)
'code_review_hermes', # Step 2 Hermes 掃描
- 'code_review_openclaw', # Step 3 OpenClaw 評估(Gemini 或 Claude)
+ 'code_review_openclaw', # Step 3 OpenClaw 評估(Ollama-first;Claude optional fallback)
'code_review_elephant', # Step 4 ElephantAlpha 49B
- 'code_review_openclaw_gemini', # Phase 7 Claude 失敗 fallback Gemini
+ 'code_review_openclaw_gemini', # Ollama/Claude 失敗後的 Gemini fallback
# ElephantAlpha(elephant_alpha_*)
'ea_engine', # _execute_autonomous_decision (Gemini orchestrate)
diff --git a/tests/test_ai_call_logger.py b/tests/test_ai_call_logger.py
index 595cd13..8a31cc8 100644
--- a/tests/test_ai_call_logger.py
+++ b/tests/test_ai_call_logger.py
@@ -175,6 +175,16 @@ def test_context_manager_marks_rag_hit(reset_state):
assert rec['rag_hit'] is True
+def test_context_manager_can_update_actual_provider_after_retry(reset_state):
+ captured = reset_state
+ with log_ai_call('code_review_openclaw', 'gcp_ollama', 'qwen3:14b') as ctx:
+ ctx.set_provider('ollama_secondary')
+
+ assert _wait_for_async(captured, 1)
+ rec = captured[0]
+ assert rec['provider'] == 'ollama_secondary'
+
+
# ─────────────────────────────────────────────────────────────────────────────
# decorator 測試
# ─────────────────────────────────────────────────────────────────────────────
diff --git a/tests/test_code_review_claude_routing.py b/tests/test_code_review_claude_routing.py
index cc074ef..4f82905 100644
--- a/tests/test_code_review_claude_routing.py
+++ b/tests/test_code_review_claude_routing.py
@@ -2,13 +2,13 @@
# -*- coding: utf-8 -*-
"""
tests/test_code_review_claude_routing.py
-Phase 7 Frontier 升級:CodeReviewPipeline._openclaw_assess Claude 路由測試
+CodeReviewPipeline._openclaw_assess Ollama-first / cloud fallback 路由測試
驗收項目:
- - flag=false → Claude 路徑零觸發(regression 防護)
- - flag=true + Claude 可用 → 走 Claude,回傳 Claude content
- - flag=true + Claude 失敗 → fallback 進入 Gemini/Elephant 路徑
- - flag=true + Claude 不可用(is_available=False)→ 跳過 Claude
+ - Ollama 成功 → 不呼叫 Claude / Gemini / Elephant
+ - Ollama 失敗 + flag=true + Claude 可用 → 走 Claude
+ - Ollama 失敗 + Claude 不可用/失敗 → Gemini 只作備援
+ - Ollama + Gemini 都失敗 → 最終 Elephant 接手
"""
from __future__ import annotations
@@ -77,6 +77,27 @@ def _stub_logger(monkeypatch):
monkeypatch.setenv('AI_CALL_LOGGING_ENABLED', 'false')
+def _stub_ollama(monkeypatch, *, success: bool = True,
+ content: str = "OLLAMA-RESULT",
+ error: str = "all hosts failed"):
+ import services.ollama_service as ollama_mod
+
+ fake_resp = MagicMock()
+ fake_resp.success = success
+ fake_resp.content = content if success else ""
+ fake_resp.model = "qwen3:14b"
+ fake_resp.error = None if success else error
+ fake_resp.host = "http://34.143.170.20:11434"
+ fake_resp.input_tokens = 30 if success else 0
+ fake_resp.output_tokens = 12 if success else 0
+
+ fake_service = MagicMock()
+ fake_service.generate.return_value = fake_resp
+ fake_cls = MagicMock(return_value=fake_service)
+ monkeypatch.setattr(ollama_mod, "OllamaService", fake_cls)
+ return fake_cls, fake_service
+
+
def _stub_gemini_and_elephant(monkeypatch, *,
gemini_text: str = "GEMINI-RESULT",
elephant_text: str = "ELEPHANT-RESULT",
@@ -123,15 +144,16 @@ def _stub_gemini_and_elephant(monkeypatch, *,
# Tests
# ─────────────────────────────────────────────────────────────────────────────
-def test_flag_false_uses_gemini(monkeypatch):
- """CODE_REVIEW_USE_CLAUDE=false(預設)→ Claude 路徑完全不觸發"""
+def test_default_uses_ollama_first(monkeypatch):
+ """CODE_REVIEW_USE_CLAUDE=false(預設)→ Ollama 成功時 Gemini 不被呼叫"""
monkeypatch.setenv('CODE_REVIEW_USE_CLAUDE', 'false')
monkeypatch.setenv('GEMINI_API_KEY', 'test-key')
_stub_logger(monkeypatch)
svc_mod = _reload_pipeline()
+ _ollama_cls, fake_ollama = _stub_ollama(monkeypatch)
fake_claude = _stub_anthropic(monkeypatch, svc_mod, available=True)
- _stub_gemini_and_elephant(monkeypatch)
+ fake_genai, fake_elephant = _stub_gemini_and_elephant(monkeypatch)
pipeline = _make_pipeline(svc_mod)
result = pipeline._openclaw_assess(
@@ -139,17 +161,21 @@ def test_flag_false_uses_gemini(monkeypatch):
findings=[],
)
- assert result == "GEMINI-RESULT"
+ assert result == "OLLAMA-RESULT"
+ fake_ollama.generate.assert_called_once()
fake_claude.generate.assert_not_called()
+ fake_genai.GenerativeModel.assert_not_called()
+ fake_elephant.generate.assert_not_called()
-def test_flag_true_uses_claude(monkeypatch):
- """flag=true + Claude 可用 → 走 Claude,Gemini 不被呼叫"""
+def test_flag_true_still_uses_ollama_before_claude(monkeypatch):
+ """flag=true 也不得跳過 Ollama;Ollama 成功時 Claude/Gemini 都不呼叫"""
monkeypatch.setenv('CODE_REVIEW_USE_CLAUDE', 'true')
monkeypatch.setenv('GEMINI_API_KEY', 'test-key')
_stub_logger(monkeypatch)
svc_mod = _reload_pipeline()
+ _stub_ollama(monkeypatch)
fake_claude = _stub_anthropic(monkeypatch, svc_mod, available=True,
success=True, content="CLAUDE-RESULT")
fake_genai, fake_elephant = _stub_gemini_and_elephant(monkeypatch)
@@ -161,31 +187,50 @@ def test_flag_true_uses_claude(monkeypatch):
"description": "x", "type": "bug"}],
)
- assert result == "CLAUDE-RESULT"
- fake_claude.generate.assert_called_once()
- # Gemini 不應被觸發(Claude 成功時短路)
+ assert result == "OLLAMA-RESULT"
+ fake_claude.generate.assert_not_called()
fake_genai.GenerativeModel.assert_not_called()
fake_elephant.generate.assert_not_called()
- # 確認 Claude 呼叫參數正確
- call_kwargs = fake_claude.generate.call_args.kwargs
- assert call_kwargs['cache_system'] is True # 啟用 ephemeral cache
- assert call_kwargs['temperature'] == 0.2 # code review 精確設定
- assert call_kwargs['model'] == 'claude-opus-4-7' # 預設 Opus 4.7
- assert call_kwargs['system_prompt'] # system prompt 有傳
- assert 'commit ' + 'abc12345'[:8] in call_kwargs['prompt'].lower() or \
- 'abc12345' in call_kwargs['prompt']
-
-def test_flag_true_claude_fails_falls_back_to_gemini(monkeypatch):
- """Claude 回 success=False → fallback Gemini 接手"""
+def test_ollama_failure_flag_true_uses_claude_backup(monkeypatch):
+ """Ollama 失敗 + flag=true + Claude 可用 → Claude 才接手"""
monkeypatch.setenv('CODE_REVIEW_USE_CLAUDE', 'true')
monkeypatch.setenv('GEMINI_API_KEY', 'test-key')
_stub_logger(monkeypatch)
svc_mod = _reload_pipeline()
+ _stub_ollama(monkeypatch, success=False)
fake_claude = _stub_anthropic(monkeypatch, svc_mod, available=True,
- success=False, error="RateLimitError: too many")
+ success=True, content="CLAUDE-RESULT")
+ 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 result == "CLAUDE-RESULT"
+ fake_claude.generate.assert_called_once()
+ fake_genai.GenerativeModel.assert_not_called()
+ fake_elephant.generate.assert_not_called()
+
+ call_kwargs = fake_claude.generate.call_args.kwargs
+ assert call_kwargs['cache_system'] is True
+ assert call_kwargs['temperature'] == 0.2
+ assert call_kwargs['model'] == 'claude-opus-4-7'
+
+
+def test_ollama_failure_flag_false_uses_gemini_backup(monkeypatch):
+ """Ollama 失敗且 Claude flag=false → Gemini 才作備援"""
+ monkeypatch.setenv('CODE_REVIEW_USE_CLAUDE', 'false')
+ monkeypatch.setenv('GEMINI_API_KEY', 'test-key')
+ _stub_logger(monkeypatch)
+
+ svc_mod = _reload_pipeline()
+ _stub_ollama(monkeypatch, success=False)
+ fake_claude = _stub_anthropic(monkeypatch, svc_mod, available=True)
fake_genai, fake_elephant = _stub_gemini_and_elephant(monkeypatch)
pipeline = _make_pipeline(svc_mod)
@@ -195,17 +240,19 @@ def test_flag_true_claude_fails_falls_back_to_gemini(monkeypatch):
)
assert result == "GEMINI-RESULT"
- fake_claude.generate.assert_called_once()
+ fake_claude.generate.assert_not_called()
fake_genai.GenerativeModel.assert_called_once()
+ fake_elephant.generate.assert_not_called()
-def test_flag_true_claude_unavailable_uses_gemini(monkeypatch):
- """flag=true 但 is_available()=False → 跳 L1 直走 L2"""
+def test_ollama_failure_claude_unavailable_uses_gemini_backup(monkeypatch):
+ """Ollama 失敗 + flag=true 但 Claude unavailable → Gemini 才作備援"""
monkeypatch.setenv('CODE_REVIEW_USE_CLAUDE', 'true')
monkeypatch.setenv('GEMINI_API_KEY', 'test-key')
_stub_logger(monkeypatch)
svc_mod = _reload_pipeline()
+ _stub_ollama(monkeypatch, success=False)
fake_claude = _stub_anthropic(monkeypatch, svc_mod, available=False)
fake_genai, fake_elephant = _stub_gemini_and_elephant(monkeypatch)
@@ -218,15 +265,17 @@ def test_flag_true_claude_unavailable_uses_gemini(monkeypatch):
assert result == "GEMINI-RESULT"
fake_claude.generate.assert_not_called()
fake_genai.GenerativeModel.assert_called_once()
+ fake_elephant.generate.assert_not_called()
-def test_flag_true_full_fallback_chain(monkeypatch):
- """Claude 失敗 + Gemini 也失敗 → 最終 Elephant 接手"""
+def test_full_fallback_chain_after_ollama_failure(monkeypatch):
+ """Ollama 失敗 + Claude 失敗 + Gemini 失敗 → 最終 Elephant 接手"""
monkeypatch.setenv('CODE_REVIEW_USE_CLAUDE', 'true')
monkeypatch.setenv('GEMINI_API_KEY', 'test-key')
_stub_logger(monkeypatch)
svc_mod = _reload_pipeline()
+ _stub_ollama(monkeypatch, success=False)
fake_claude = _stub_anthropic(monkeypatch, svc_mod, available=True,
success=False, error="claude down")
fake_genai, fake_elephant = _stub_gemini_and_elephant(
diff --git a/tests/test_phase3f_cleanup_contracts.py b/tests/test_phase3f_cleanup_contracts.py
index 95cf550..fa219c7 100644
--- a/tests/test_phase3f_cleanup_contracts.py
+++ b/tests/test_phase3f_cleanup_contracts.py
@@ -92,6 +92,8 @@ def test_env_example_documents_runtime_and_ai_automation_variables():
"AUTO_FIX_ENABLED",
"AWAITING_REVIEW_PUSH_BATCH",
"CODE_REVIEW_AUTO_FIX_ENABLED",
+ "CODE_REVIEW_OLLAMA_MODEL",
+ "CODE_REVIEW_OLLAMA_TIMEOUT",
"COST_THROTTLE_ENABLED",
"COST_THROTTLE_PROJECT_RATIO",
"COST_UNTHROTTLE_PROJECT_RATIO",
diff --git a/web/static/css/page-vendor-list.css b/web/static/css/page-vendor-list.css
index 081c87c..cb7784b 100644
--- a/web/static/css/page-vendor-list.css
+++ b/web/static/css/page-vendor-list.css
@@ -406,6 +406,10 @@
white-space: nowrap;
}
+ .stockout-table th {
+ display: none;
+ }
+
.stockout-table tbody {
display: grid;
gap: 10px;