diff --git a/TODO_NEXT_STEPS.txt b/TODO_NEXT_STEPS.txt index 200cfce..eee30da 100644 --- a/TODO_NEXT_STEPS.txt +++ b/TODO_NEXT_STEPS.txt @@ -4,6 +4,7 @@ ================================================================================ 【已完成】 + - V10.556 修 Ollama GCP-B model fallback:GCP-B 若缺 coder/large 指定模型,先用 host-compatible fallback `gemma3:4b` 留在 GCP-B,不直接把流量推到 111;`model not found` 404 視為模型缺失,不再把整台 GCP-B 標 unhealthy。主機順序仍維持 GCP-A → GCP-B → 111。 - V10.555 補 focused total-price reason-based 回刷:`_fetch_retryable_candidate_skus()` 新增一條結構化 reason 窄門,只要舊 attempt 已帶 `focused_exact_total_price_safe` 且命中 matcher 的 `FOCUSED_IDENTITY_TOTAL_PRICE_REASONS`,即可進近門檻重評;仍要求無 hard veto、`exact_identity`、分數下限,並排除 commercial / variant / count / bundle 等阻擋理由。這讓已經被 matcher 明確判為 total-price exact 的舊候選不再依賴手寫商品名 SQL 才能回刷,同時 rom&nd / Solone / Summer’s Eve 等 review-only 品線仍不會進自動價差線。 - V10.554 接線香氛 / 精油 focused exact 回刷:Herb24 晨霧純精油擴香儀黑色、Pavaruni 40 香味 10ml 精油、Pavaruni 20 香味 450g 香氛蠟燭、Derma 大地有機植萃護膚油 150ml 現在明確標記 `focused_exact_total_price_safe`,並接進 `_fetch_retryable_candidate_skus()` 近門檻舊候選回刷。此入口只收 `low_score / refresh_low_score / true_low_confidence` 中命中精準名稱錨點、無 hard veto、`exact_identity` 且沒有變體 / 商業條件 / 件數衝突的候選;Laundrin、好物良品融蠟燈、Yuskin 等仍保留人工覆核,不為了拉覆蓋率強推自動價差。 - V10.553 優化 current PPT/AI 比價結果查詢:`fetch_competitor_comparison_results()` 的 current/latest MOMO 價格改用 `JOIN LATERAL` 取單品最新價,移除 `ROW_NUMBER() OVER (PARTITION BY p.id ...)` window scan;歷史報表的 `end_date` cutoff 仍保留在 lateral 子查詢內,維持「指定期間截止日前最新 MOMO 價」語意不變。這能降低簡報、OpenClaw/AI payload 與比價匯出在大量 price_records 下的查詢成本。 diff --git a/config.py b/config.py index da606ce..d839f2e 100644 --- a/config.py +++ b/config.py @@ -402,7 +402,7 @@ YOUTUBE_API_KEY = os.getenv('YOUTUBE_API_KEY', '') # ========================================== # 系統版本與路徑 # ========================================== -SYSTEM_VERSION = "V10.555" +SYSTEM_VERSION = "V10.556" 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 e851bff..7e033eb 100644 --- a/docs/AI_INTELLIGENCE_MODULE_SOT.md +++ b/docs/AI_INTELLIGENCE_MODULE_SOT.md @@ -33,6 +33,7 @@ - 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。 - OpenClaw 週報、月報、Meta analysis、日報洞察與每日報告的 Gemini/NIM 備援 caller 必須登錄在 caller registry、AI 觀測台 agent group 與 Telegram 狀態統計,避免 fallback 用量被歸類為未知或漏算。 - `ai_calls.provider='ollama_other'` 只允許作為 unresolved/unknown Ollama telemetry bucket,例如全 host 失敗、尚未選定實際 GCP-A/GCP-B/111 host 或舊 caller 未帶 host;不得把 `ollama_other` 當成實際路由目標或新增非核准 Ollama host。 +- GCP-B 若缺 caller 指定的 coder/large 模型,`OllamaService` 必須先在 GCP-B 改用 `OLLAMA_SECONDARY_MODEL_FALLBACK`(預設 `gemma3:4b`),不可因 model 404 把整台 GCP-B 標成 unhealthy 後直接推到 111;真正 timeout / HTTP 5xx 才標 host unhealthy。 - Gemini API 出站有第二道 kill switch:`GEMINI_FALLBACK_ENABLED` 預設為 `false`。即使 `GEMINI_API_KEY` 存在,通用 AI fallback、OpenClaw 報告/QA/PPT/圖片、MCP Grounding 與 Code Review L3 都不得呼叫 Gemini;只有操作員明確設為 `true` 時,Gemini 才能作緊急備援。 - `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`。 diff --git a/docs/memory/history_logs.md b/docs/memory/history_logs.md index deab91b..1e68b75 100644 --- a/docs/memory/history_logs.md +++ b/docs/memory/history_logs.md @@ -13,6 +13,7 @@ ## 📅 詳細更新日誌 (考古存檔) ### 2026-06-01:PChome 比價新鮮度操作閉環 +- **V10.556 GCP-B model fallback 防 111 過載**: `OllamaService.generate()` 現在會在 GCP-B 對 coder/large 模型使用 host-compatible fallback(預設 `gemma3:4b`),避免 GCP-B 缺 `qwen2.5-coder:7b` 時直接被標成 unhealthy 並把流量推到 111。HTTP 404 且訊息為 model not found 時視為模型缺失,不再 mark 整台 host unhealthy;其他 HTTP / timeout 仍照舊標 unhealthy。主機順序仍是 GCP-A → GCP-B → 111。 - **V10.555 focused total-price reason-based 回刷窄門**: `_fetch_retryable_candidate_skus()` 新增結構化 diagnostics reason 回刷入口,舊 attempt 若已帶 `focused_exact_total_price_safe` 且命中 matcher 的 `FOCUSED_IDENTITY_TOTAL_PRICE_REASONS`,即可進近門檻重評,不再完全依賴手寫商品名 SQL。此路徑仍要求分數下限、無 hard veto、`exact_identity`,並套用 commercial / variant / count / bundle 等阻擋理由;rom&nd、Solone、Summer’s Eve 等 review-only focused line 不在 total-price reason set,仍不會被推入自動價差。 - **V10.554 香氛 / 精油 focused exact 回刷接線**: Herb24 晨霧純精油擴香儀黑色、Pavaruni 40 香味 10ml 精油、Pavaruni 20 香味 450g 香氛蠟燭、Derma 大地有機植萃護膚油 150ml 已明確列入 matcher 的 `focused_exact_total_price_safe`,並接入 `_fetch_retryable_candidate_skus()` 的近門檻舊候選回刷入口。SQL 仍要求 `low_score / refresh_low_score / true_low_confidence`、分數下限、無 hard veto、`exact_identity`,且排除變體、商業條件與件數衝突;Laundrin、好物良品融蠟燈、Yuskin 等帶人工覆核訊號的品線不納入本輪自動回刷。 - **V10.553 current PPT/AI 比價結果查詢瘦身**: `fetch_competitor_comparison_results()` 不再用 `ROW_NUMBER() OVER (PARTITION BY p.id ...)` 掃 `price_records` 取得 current/latest MOMO 價格,改為 `JOIN LATERAL` 逐商品取最新價;有指定 `end_date` 的歷史報表仍把 `pr.timestamp < DATE(:end_date) + INTERVAL '1 day'` 放在 lateral 子查詢中,保留「截止日前最新 MOMO 價」語意。這降低簡報、OpenClaw/AI payload 與比價匯出在大量價格紀錄下的查詢成本。 diff --git a/services/ollama_service.py b/services/ollama_service.py index 087aa4c..22438ee 100644 --- a/services/ollama_service.py +++ b/services/ollama_service.py @@ -80,6 +80,15 @@ FALLBACK_111_MODEL_PATTERNS = tuple( ).split(',') if pattern.strip() ) +SECONDARY_MODEL_FALLBACK = os.getenv('OLLAMA_SECONDARY_MODEL_FALLBACK', 'gemma3:4b') +SECONDARY_MODEL_FALLBACK_PATTERNS = tuple( + pattern.strip().lower() + for pattern in os.getenv( + 'OLLAMA_SECONDARY_MODEL_FALLBACK_PATTERNS', + 'qwen2.5-coder:*,qwen2.5:*,qwen3:14b,deepseek-r1:14b', + ).split(',') + if pattern.strip() +) # ── GCP 優先 / 111 備援:解析實際可用的 Ollama 主機 ────────────────────────── # ADR-027 Phase 2 強化: @@ -124,6 +133,10 @@ def _is_111_fallback_host(host: str) -> bool: return '192.168.0.111:11434' in (host or '') +def _is_secondary_ollama_host(host: str) -> bool: + return '34.21.145.224:11434' in (host or '') or '192.168.0.110:11436' in (host or '') + + def _env_flag(name: str, default: bool = False) -> bool: raw = os.getenv(name) if raw is None: @@ -321,12 +334,25 @@ def _fallback_111_block_reason(host: str) -> Tuple[bool, str]: def _effective_model_for_host(model: str, host: str) -> str: """ 111 是 Mac/HDD final fallback,不承接 7B+ / vision / long-context 等模型。 - GCP-A/GCP-B 仍照 caller 指定模型;只有落到 111 才降級,避免 16GB RAM - 被 hermes3/qwen/gemma 的大 context runner 長時間壓到 swap。 + GCP-B 是第二順位運算節點;若該節點未安裝 caller 指定的 coder/large + 模型,先用 host-compatible fallback 留在 GCP-B,避免直接把流量推到 + 111。111 仍是最後救急節點,且會更嚴格降級。 """ + model_lower = (model or '').lower() + if ( + _is_secondary_ollama_host(host) + and SECONDARY_MODEL_FALLBACK + and any(fnmatch.fnmatch(model_lower, pattern) for pattern in SECONDARY_MODEL_FALLBACK_PATTERNS) + ): + logger.warning( + "[Ollama] Secondary host model fallback model=%s → %s host=%s", + model, + SECONDARY_MODEL_FALLBACK, + host, + ) + return SECONDARY_MODEL_FALLBACK if not _is_111_fallback_host(host): return model - model_lower = (model or '').lower() if any(fnmatch.fnmatch(model_lower, pattern) for pattern in FALLBACK_111_MODEL_PATTERNS): logger.warning( "[Ollama] 111 fallback 不承接重模型 model=%s,改用 %s", @@ -682,8 +708,16 @@ class OllamaService: ) # HTTP 非 200:標 unhealthy + 嘗試下一台 last_error = f"HTTP {response.status_code}: {response.text[:200]}" - logger.warning(f"[Ollama] {current_host} HTTP 失敗 → mark_unhealthy + retry: {last_error}") - _mark_unhealthy_best_effort(current_host) + if response.status_code == 404 and "model" in (response.text or "").lower(): + logger.warning( + "[Ollama] %s model unavailable model=%s → retry next host without marking host unhealthy: %s", + current_host, + effective_model, + last_error, + ) + else: + logger.warning(f"[Ollama] {current_host} HTTP 失敗 → mark_unhealthy + retry: {last_error}") + _mark_unhealthy_best_effort(current_host) except requests.Timeout: last_error = f"timeout ({effective_timeout}s)" logger.warning(f"[Ollama] {current_host} timeout → mark_unhealthy + retry") diff --git a/tests/test_ollama_retry_chain.py b/tests/test_ollama_retry_chain.py index 72408cf..7dc5025 100644 --- a/tests/test_ollama_retry_chain.py +++ b/tests/test_ollama_retry_chain.py @@ -167,6 +167,50 @@ def test_generate_forces_final_fallback_when_unhealthy_ttl_expires_mid_request() assert 'all 3 hosts failed' in (resp.error or '') +def test_generate_uses_secondary_model_fallback_before_111(): + """GCP-B 缺 coder 模型時,先改用 secondary fallback,不直接推到 111。""" + from services import ollama_service as oss + from services.ollama_service import OllamaService + + svc = OllamaService() + fake_ok = MagicMock(status_code=200) + fake_ok.json.return_value = { + 'response': 'OK from secondary fallback', + 'prompt_eval_count': 10, + 'eval_count': 5, + } + + with patch('services.ollama_service.resolve_ollama_host', return_value=oss.OLLAMA_HOST_SECONDARY), \ + patch('services.ollama_service.requests.post', return_value=fake_ok) as mock_post: + resp = svc.generate('test prompt', model='qwen2.5-coder:7b') + + assert resp.success is True + assert resp.host == oss.OLLAMA_HOST_SECONDARY + assert resp.model == 'gemma3:4b' + payload = mock_post.call_args.kwargs['json'] + assert payload['model'] == 'gemma3:4b' + + +def test_generate_model_404_does_not_mark_host_unhealthy(): + """模型不存在是 model availability,不應把整台 GCP-B 標成 unhealthy。""" + from services import ollama_service as oss + from services.ollama_service import OllamaService + + svc = OllamaService() + fake_404 = MagicMock(status_code=404, text='{"error":"model not found"}') + fake_ok = MagicMock(status_code=200) + fake_ok.json.return_value = {'response': 'OK', 'prompt_eval_count': 3, 'eval_count': 2} + + with patch('services.ollama_service.resolve_ollama_host', side_effect=[ + oss.OLLAMA_HOST_SECONDARY, + oss.OLLAMA_HOST_FALLBACK, + ]), patch('services.ollama_service.requests.post', side_effect=[fake_404, fake_ok]): + resp = svc.generate('test prompt', model='tiny-missing-model') + + assert resp.success is True + assert oss.OLLAMA_HOST_SECONDARY not in oss._unhealthy_marks + + def test_generate_can_disable_111_fallback_for_batch_llm_work(): """批量 LLM 任務可選擇只跑 GCP-A/GCP-B,避免 111 承接長分析。""" import requests