From 2635b22ebcbfc8c9ffc5b51d443f4cf739b343b5 Mon Sep 17 00:00:00 2001 From: OoO Date: Wed, 13 May 2026 20:16:30 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E6=AD=A3=E7=BC=BA=E8=B2=A8=E6=B8=85?= =?UTF-8?q?=E5=96=AE=E6=89=8B=E6=A9=9F=E8=A1=A8=E9=A0=AD=E6=BA=A2=E5=87=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env.example | 8 +- config.py | 2 +- docs/AI_INTELLIGENCE_MODULE_SOT.md | 1 + .../claude_inventory_validation_20260513.md | 1 + services/ai_call_logger.py | 5 + services/code_review_pipeline_service.py | 128 ++++++++++++------ services/llm_caller_registry.py | 4 +- tests/test_ai_call_logger.py | 10 ++ tests/test_code_review_claude_routing.py | 111 ++++++++++----- tests/test_phase3f_cleanup_contracts.py | 2 + web/static/css/page-vendor-list.css | 4 + 11 files changed, 195 insertions(+), 81 deletions(-) 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;