穩定 Ollama embedding GCP 失敗熔斷
All checks were successful
CD Pipeline / deploy (push) Successful in 1m5s
All checks were successful
CD Pipeline / deploy (push) Successful in 1m5s
This commit is contained in:
@@ -160,6 +160,8 @@ EMBEDDING_TIMEOUT=15
|
||||
OLLAMA_EMBED_MAX_TIMEOUT=15
|
||||
OLLAMA_EMBED_KEEP_ALIVE=1m
|
||||
OLLAMA_EMBED_MAX_CHARS=4000
|
||||
OLLAMA_EMBED_GCP_FAILURE_COOLDOWN_SEC=60
|
||||
OLLAMA_EMBED_GCP_FAILURE_NOTICE_SEC=30
|
||||
|
||||
# 111 Mac final fallback guardrail and allowlist proxy
|
||||
OLLAMA_111_CIRCUIT_BREAKER_ENABLED=true
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
================================================================================
|
||||
|
||||
【已完成】
|
||||
- V10.468 補 Ollama import-time 防凍結與背景 embedding GCP failure circuit,已部署正式環境並確認 `/health=V10.468`:`config.OLLAMA_HOST` / `HERMES_URL` / `EMBEDDING_HOST` 舊相容常數不再於 import 時 probe network,也不會因 GCP-A/GCP-B 暫時拒連而 freeze 到 111;動態 caller 仍走 `get_*()` / `OllamaService` 三主機級聯。當 `allow_111_fallback=False` 且 GCP-A/GCP-B 皆失敗時,短暫熔斷 60 秒,不重複打兩台 GCP、不落 111,降低 app/scheduler 因連續 embedding timeout 造成的 log 與 worker 壓力;已確認目前 110 可連 GCP-B SSH port 但現有 key 無權限,GCP-A 22/11434、GCP-B 11434 皆拒絕連線,需後續用 GCP 權限修復 Ollama 主機。
|
||||
- V10.467 補 PChome focused exact total-price 安全通道:針對正式近門檻樣本中已確認同品牌、同品名、同規格/同入數的 3W CLINIC 粉底液 2入、花美水凝膠 3支、The Ordinary 咖啡因 EGCG 30ml、KUSSEN 屁屁膏 3入、Bone 擴香禮盒、1990 融燭燈白色款與 CANMAKE 淚袋盤,從 `exact/manual_review` 收斂為 `exact/total_price`;未放寬 `MIN_MATCH_SCORE`,DASHING DIVA、唇彩、香味、色號/款式敏感商品仍維持 variant / veto 保護。Production pilot 已將 9 筆安全 SKU 送入 `rescore_accepted_current`,`true_low_confidence` 802→793、`rescore_accepted_current` 38→47;`6101784` 即期品保留在 `true_low_confidence`。
|
||||
- V10.466 修正 rescore audit duplicate 判斷:只在「最新 attempt 已是同候選 `rescore_accepted_current`」時跳過;若歷史曾 accepted、但後續 crawler 又追加低信心列,允許重新 materialize,避免 Dashboard latest-state 仍停在 `true_low_confidence`。Production pilot 已將 SKU `14756069`、`11159042`、`13842560`、`8394210`、`15192547`、`10509765`、`10603780` 送入人工覆核隊列;只寫 `competitor_match_attempts`,`competitor_prices` / `competitor_price_history` 未變。
|
||||
- V10.465 修正 embedding fallback-disabled 控制流:`allow_111_fallback=False` 時若 resolver 回 111,不再直接退出或只試單台 GCP-B,會強制改試尚未嘗試的 GCP-A/GCP-B;背景 embedding 仍不落 111。
|
||||
|
||||
43
config.py
43
config.py
@@ -229,6 +229,29 @@ GRIST_URL = os.getenv('GRIST_URL', '') # Grist 資料協作連結
|
||||
# ==========================================
|
||||
# AI 服務設定
|
||||
# ==========================================
|
||||
|
||||
_APPROVED_OLLAMA_HOST_SUBSTRINGS = (
|
||||
'34.143.170.20:11434',
|
||||
'34.21.145.224:11434',
|
||||
'192.168.0.111:11434',
|
||||
'192.168.0.110:11435',
|
||||
'192.168.0.110:11436',
|
||||
)
|
||||
|
||||
|
||||
def _static_approved_ollama_env(name: str, default: str = '') -> str:
|
||||
"""Import-time safe Ollama host env reader; never probes network."""
|
||||
value = os.getenv(name, '').strip()
|
||||
if value and any(approved in value for approved in _APPROVED_OLLAMA_HOST_SUBSTRINGS):
|
||||
return value
|
||||
return default
|
||||
|
||||
|
||||
_STATIC_OLLAMA_PRIMARY = _static_approved_ollama_env(
|
||||
'OLLAMA_HOST_PRIMARY',
|
||||
'http://34.143.170.20:11434',
|
||||
)
|
||||
|
||||
# Hermes AI Service (競價情報分析)
|
||||
# V-New (ADR-027 Phase 2):所有 host 解析必須 lazy,禁止 import-time freeze。
|
||||
# 理由:import 時 GCP 還沒探測(resolve_ollama_host 內部 cache 120s),
|
||||
@@ -288,23 +311,25 @@ def get_ollama_host():
|
||||
return 'http://192.168.0.111:11434'
|
||||
|
||||
|
||||
# 向下相容:舊 caller 仍可 `from config import HERMES_URL` 取得當下解析值。
|
||||
# ※ 重要:這仍是 import-time freeze。新 caller 應改用 `get_hermes_url()`。
|
||||
HERMES_URL = get_hermes_url()
|
||||
# 向下相容:舊 caller 仍可 `from config import HERMES_URL`,但此常數不得
|
||||
# import-time probe,也不得在 GCP 短暫不可用時 freeze 到 111;新 caller 應
|
||||
# 改用 `get_hermes_url()` 或 `OllamaService` 取得動態三主機級聯結果。
|
||||
HERMES_URL = _static_approved_ollama_env('HERMES_URL', _STATIC_OLLAMA_PRIMARY)
|
||||
HERMES_TIMEOUT = int(os.getenv('HERMES_TIMEOUT', '120')) # 秒;批量 300 筆預估 ~90s
|
||||
|
||||
# Embedding 服務(ADR-003 對齊:embedding 走 Hermes 主機,內網免認證)
|
||||
# 向下相容;新 caller 應改用 `get_embedding_host()`
|
||||
EMBEDDING_HOST = get_embedding_host()
|
||||
# 向下相容;新 caller 應改用 `get_embedding_host()` 或
|
||||
# `OllamaService.generate_embedding()`,不可依賴 import-time 探測結果。
|
||||
EMBEDDING_HOST = _static_approved_ollama_env('EMBEDDING_HOST', HERMES_URL)
|
||||
EMBEDDING_TIMEOUT = int(os.getenv('EMBEDDING_TIMEOUT', os.getenv('OLLAMA_EMBED_TIMEOUT', '45')))
|
||||
|
||||
# Ollama 本地 AI 服務
|
||||
# ADR-027 Phase 2:OLLAMA_HOST 改為 lazy resolve,禁止寫死 nginx URL 繞過 GCP 優先策略。
|
||||
# 舊行為:寫死 'https://ollama.wooo.work/ollama' → 任何 import 都跳過 GCP 探測。
|
||||
# 新行為:env OLLAMA_HOST 優先;否則走 resolve_ollama_host()(GCP 優先 / 111 備援)。
|
||||
# 向下相容:保留 OLLAMA_HOST module attribute,但於 import 時呼叫 get_ollama_host()。
|
||||
# 新行為:env OLLAMA_HOST 優先;否則動態 caller 走 resolve_ollama_host()(GCP 優先 / 111 備援)。
|
||||
# 向下相容:保留 OLLAMA_HOST module attribute,但不再於 import 時呼叫 get_ollama_host()。
|
||||
# 新 caller 應改用 `from config import get_ollama_host` + `host = get_ollama_host()`。
|
||||
OLLAMA_HOST = get_ollama_host()
|
||||
OLLAMA_HOST = _static_approved_ollama_env('OLLAMA_HOST', _STATIC_OLLAMA_PRIMARY)
|
||||
OLLAMA_MODEL = os.getenv('OLLAMA_MODEL', 'gemma3:4b')
|
||||
|
||||
# Google Gemini AI 雲端服務
|
||||
@@ -325,7 +350,7 @@ YOUTUBE_API_KEY = os.getenv('YOUTUBE_API_KEY', '')
|
||||
# ==========================================
|
||||
# 系統版本與路徑
|
||||
# ==========================================
|
||||
SYSTEM_VERSION = "V10.467"
|
||||
SYSTEM_VERSION = "V10.468"
|
||||
LOG_FILE_PATH = os.path.join(BASE_DIR, 'logs/system.log')
|
||||
public_url = PUBLIC_URL # 用於模板顯示
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
> **最後更新**: 2026-05-25 (台北時間)
|
||||
> **狀態**: 🟢 四 AI Agent 自動化閉環已落地;LLM 路由紅線升級為 Ollama-first 三主機級聯,Gemini 備援預設關閉
|
||||
> **適用版本**: V10.467
|
||||
> **適用版本**: V10.468
|
||||
|
||||
---
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
|
||||
- 所有 AI Agent、LLM 推理與 embedding 預設必須走 Ollama 三主機級聯:GCP-A `34.143.170.20:11434` → GCP-B `34.21.145.224:11434` → 111 `192.168.0.111:11434`。
|
||||
- `services/ollama_service.resolve_ollama_host()` 是主機解析契約;`OLLAMA_HOST`、`HERMES_URL`、`EMBEDDING_HOST`、`OLLAMA_API_BASE` 只接受 GCP-A / GCP-B / 111 或 110 的核准轉發端口。
|
||||
- `config.OLLAMA_HOST`、`config.HERMES_URL`、`config.EMBEDDING_HOST` 只保留為舊 caller 相容常數;import-time 不得 probe network,也不得因 GCP-A/GCP-B 短暫不可用而 freeze 到 111。需要即時路由時一律呼叫 `get_ollama_host()`、`get_hermes_url()`、`get_embedding_host()` 或 `OllamaService`。
|
||||
- Gemini 只能作為 Ollama 主路徑失敗後的備援;MCP Grounding、PPT/vision、週/月報、Code Review、EA HITL、複雜 SKU 升級等舊鎖定場景也必須先走 GCP-A → GCP-B → 111。
|
||||
- 188 `192.168.0.188` 僅是 App / DB / scheduler / Telegram bot 容器宿主與 AutoHeal target,不可作為 Ollama 節點。
|
||||
- 通用 AI 文案、關鍵字、商品洞察與 Telegram Q&A 第一響應不得 Gemini-first。
|
||||
@@ -22,6 +23,7 @@
|
||||
- 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、不走其他雲端模型。
|
||||
- Embedding / semantic RAG 背景任務預設只跑 GCP-A → GCP-B:`OpenClawLearningService` embedding worker 與 `RAGService` 查詢 embedding 呼叫 `OllamaService.generate_embedding(..., allow_111_fallback=False)`;111 只可作人工明確指定的救急路徑,不承接 `bge-m3` 背景批次。`OLLAMA_EMBED_TIMEOUT` / `OLLAMA_EMBED_MAX_TIMEOUT` 預設 `15s`、`OLLAMA_EMBED_KEEP_ALIVE=1m`、`OLLAMA_EMBED_MAX_CHARS=4000`,避免 embedding worker 長時間卡住 GCP-B 或 111。
|
||||
- `allow_111_fallback=False` 時,若 resolver 因 unhealthy cache 回傳 111,不得直接結束 embedding;必須強制改試尚未嘗試的 GCP-A / GCP-B,避免正式 log 出現 `tried=[]` 或只試單台 GCP-B。
|
||||
- `allow_111_fallback=False` 且 GCP-A / GCP-B 皆失敗時,背景 embedding 會開啟短暫 GCP failure circuit(預設 60 秒),期間不重複打兩台 GCP、不落 111,避免 worker 與 log 被連續失敗拖慢;GCP 恢復後會自然再試。
|
||||
- BGE-M3 一致性檢查是監測任務,不是 fallback 壓測;預設只比對 GCP-A / GCP-B。111 Mac fallback 只有 `EMBED_CONSISTENCY_INCLUDE_111=true` 時才納入,避免每週背景檢查把 `bge-m3` 載入 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 作為失敗後備援。
|
||||
|
||||
@@ -21,6 +21,7 @@
|
||||
- 2026-05-25 08:38 CST 狀態:`main` 已推 Gitea 並部署到 188,正式 `/health` 為 `V10.464`。本輪 recreate `momo-app`、`scheduler`、`telegram-bot`;未使用 `--remove-orphans`,未碰 `momo-db`。Smoke 通過:三個 app 容器 healthy、`/`、`/?filter=pchome_review`、`/daily_sales`、`/growth_analysis`、`/observability/ppt_audit_history`、PChome rescore queue API HTTP 200。DR.WU 三筆 SKU read-only rescore 全數 `gate_pass=3/3`,`--apply-accepted` 後 latest 狀態為 `rescore_accepted_current`、`best_match_score=1.0`、`price_basis=total_price`;整體 latest counts 變為 `true_low_confidence=778`、`rescore_accepted_current=34`。5 分鐘 log 未見 Traceback,但有既有 `[Embed] all hosts failed` 錯誤,需列入下一輪 Ollama embedding 健康檢查。
|
||||
- 2026-05-25 10:04 CST 狀態:`main` 已推 Gitea 並部署到 188,正式 `/health` 為 `V10.465`。本輪 recreate `momo-app`、`scheduler`、`telegram-bot`;未使用 `--remove-orphans`,未碰 `momo-db`。Smoke 通過:三個 app 容器 healthy、`/`、`/daily_sales`、`/growth_analysis`、`/observability/ppt_audit_history`、PChome rescore queue API HTTP 200;容器內 routing smoke 證明 resolver 回 111 且 `allow_111_fallback=false` 時會改試 GCP-A/GCP-B,輸出 `tried=['http://34.143.170.20:11434','http://34.21.145.224:11434']`;真實短 embedding 在 GCP-A `/api/version` timeout、GCP-B 200 情境下成功回 1024 維向量,耗時 4.59 秒。3 分鐘三容器錯誤 log 未見 Traceback / ERROR / CRITICAL。
|
||||
- 2026-05-25 12:10 CST 狀態:已部署 `V10.467` 到 188,正式 `/health` 為 `V10.467`。本輪 recreate `momo-app`、`scheduler`、`telegram-bot`;未使用 `--remove-orphans`,未碰 `momo-db`。Smoke 通過:三個 app 容器 healthy、`/`、`/daily_sales`、`/growth_analysis`、`/observability/ppt_audit_history`、PChome rescore queue API HTTP 200。Production pilot 將 9 筆 focused exact total-price SKU 追加為 `rescore_accepted_current`,整體 latest counts 從 `true_low_confidence=802` / `rescore_accepted_current=38` 變為 `true_low_confidence=793` / `rescore_accepted_current=47`;目標 SKU 的 `competitor_prices` 最新 `crawled_at` 仍停在 2026-05-22~2026-05-23,確認本輪未寫正式價差表。已知後續:GCP-A / GCP-B Ollama `/api/version` 目前連線失敗,背景 embedding 正確沒有落 111,但 app/scheduler log 仍會出現 `[Embed] all 2 hosts failed`,需另開 Ollama 健康處理。
|
||||
- 2026-05-25 12:27 CST 狀態:已部署 `V10.468` 到 188,正式 `/health` 為 `V10.468`。本輪 recreate `momo-app`、`scheduler`、`telegram-bot`;未使用 `--remove-orphans`,未碰 `momo-db`。Smoke 通過:三個 app 容器 healthy、`/`、`/daily_sales`、`/growth_analysis`、`/observability/ppt_audit_history`、PChome review queue API `/api/pchome-review/queue` HTTP 200;容器內 mock smoke 證明背景 embedding 在 GCP-A / GCP-B 全失敗後會開啟 60 秒 failure circuit,第二筆不再重複打兩台 GCP,且不落 111。GCP 維運盤點:110 proxy `11435/11436` 皆 502;110 直連 GCP-A `22/11434` refused,GCP-B `22` open 但現有 key publickey denied、`11434` refused;111 `/api/version` 可用,但 111 仍不得承接背景 `bge-m3`。
|
||||
- 2026-05-25 12:05 CST 狀態:`main` 已部署到 188,正式 `/health` 為 `V10.467`,待推 Gitea。兩段變更已合併驗證:V10.466 rescore duplicate 改看 latest-state,7 筆 SKU 最新 attempt 全為 `rescore_accepted_current`,`competitor_prices` / `competitor_price_history` 目標計數未變;V10.467 focused exact matcher 在容器內回 `exact / total_price / price_alert_exact`。本輪 recreate `momo-app`、`scheduler`、`telegram-bot`;未使用 `--remove-orphans`,未碰 `momo-db`。Smoke 通過:三容器 healthy、PChome rescore queue API HTTP 200、Gemini 24 小時無 provider 紀錄、Ollama env 順序維持 GCP-A → GCP-B → 111、3 分鐘三容器 log 未見 Traceback / ERROR / CRITICAL / IntegrityError。
|
||||
|
||||
## 1. MOMO / PChome 核心比價準確率
|
||||
@@ -74,6 +75,7 @@
|
||||
## 3.1 Ollama / Embedding 健康
|
||||
|
||||
- 2026-05-25 08:48 CST 起,`OllamaService.generate_embedding(..., allow_111_fallback=False)` 若 resolver 回 111,會強制改試尚未嘗試的 GCP-A/GCP-B,不再讓背景 embedding 在 111 disabled 情境直接退出或只試單台 GCP-B;111 仍不承接背景 `bge-m3`。
|
||||
- 2026-05-25 12:27 CST 起,背景 embedding 在 GCP-A/GCP-B 全掛時開啟短暫 failure circuit;這是降載保護,不代表 GCP 已恢復。下一步仍需恢復 GCP-A/GCP-B Ollama 或更新 110 的可用 SSH/GCP 操作憑證。
|
||||
|
||||
## 4. 業績分析資料與圖表修復
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
## 📅 詳細更新日誌 (考古存檔)
|
||||
|
||||
### 2026-05-24:PChome 近門檻身份回收第二輪
|
||||
- **V10.468 Ollama import-time / embedding 熔斷治理**: `config.OLLAMA_HOST`、`HERMES_URL`、`EMBEDDING_HOST` 舊相容常數改成靜態核准 env reader,不再於 import 時呼叫 `resolve_ollama_host()`,避免 GCP-A/GCP-B 短暫拒連時把 process 常數 freeze 到 111。`generate_embedding(..., allow_111_fallback=False)` 在 GCP-A/GCP-B 都失敗後會開短暫 GCP embedding circuit,避免背景任務每筆重打兩台故障主機;111 仍不承接背景 `bge-m3`。維運盤點確認 110 proxy 11435/11436 皆因 GCP 11434 refused 回 502,GCP-A 22/11434 refused,GCP-B 22 open 但現有 110 keys 均 publickey denied,後續需以 GCP 權限恢復 Ollama 主機或 SSH key。
|
||||
- **V10.467 Focused exact total-price 安全通道**: `marketplace_product_matcher` 新增窄範圍 `focused_exact_total_price_safe` lane,僅針對正式近門檻樣本中同品牌、同品名、同規格/同入數的 3W CLINIC 粉底液 2入、花美水凝膠 3支、The Ordinary 咖啡因 EGCG 30ml、KUSSEN 屁屁膏 3入、Bone 擴香禮盒、1990 融燭燈白色款與 CANMAKE 淚袋盤,讓 `exact/manual_review` 可升到 `exact/total_price/price_alert_exact`;未放寬 `MIN_MATCH_SCORE`,DASHING DIVA、唇彩、香味、色號/款式敏感商品仍維持 variant / veto 保護。Production pilot 已將 SKU `6101639`、`10074951`、`7760902`、`TP00074980000005`、`14774766`、`10142589`、`10262470`、`10262471`、`11308520` materialize 到人工覆核隊列,`true_low_confidence` 802→793、`rescore_accepted_current` 38→47;`6101784` 即期品因商業條件不同仍留在低信心覆核。
|
||||
- **V10.466 Rescore latest-state duplicate 修正與 7 SKU pilot**: `materialize_rescore_accept_reviews()` 的 duplicate 判斷改看最新 attempt,而不是歷史任一 accepted;若後續 crawler 又把同 SKU/候選覆蓋成 `true_low_confidence`,可重新追加 `rescore_accepted_current` 讓 Dashboard latest-state 正確進人工覆核。Production pilot 已將 SKU `14756069`、`11159042`、`13842560`、`8394210`、`15192547`、`10509765`、`10603780` materialize 到人工覆核隊列;`competitor_prices` 目標計數維持 7、`competitor_price_history` 目標計數維持 210,未寫正式價差表。
|
||||
- **V10.465 Embedding GCP fallback 修正**: `OllamaService.generate_embedding(..., allow_111_fallback=False)` 若 resolver 因 unhealthy cache 回 111,會強制改試尚未嘗試的 GCP-A/GCP-B,不再直接 `break` 造成 `tried=[]` 或只試單台 GCP-B;背景 embedding 仍不允許落 111。
|
||||
|
||||
@@ -59,6 +59,8 @@ EMBED_TIMEOUT = int(os.getenv('OLLAMA_EMBED_TIMEOUT', os.getenv('EMBEDDING_TIMEO
|
||||
EMBED_MAX_TIMEOUT = int(os.getenv('OLLAMA_EMBED_MAX_TIMEOUT', '15'))
|
||||
EMBED_KEEP_ALIVE = os.getenv('OLLAMA_EMBED_KEEP_ALIVE', '1m')
|
||||
EMBED_MAX_CHARS = int(os.getenv('OLLAMA_EMBED_MAX_CHARS', '4000'))
|
||||
EMBED_GCP_FAILURE_COOLDOWN_SEC = int(os.getenv('OLLAMA_EMBED_GCP_FAILURE_COOLDOWN_SEC', '60'))
|
||||
EMBED_GCP_FAILURE_NOTICE_SEC = int(os.getenv('OLLAMA_EMBED_GCP_FAILURE_NOTICE_SEC', '30'))
|
||||
FALLBACK_111_KEEP_ALIVE = os.getenv('OLLAMA_111_KEEP_ALIVE', '5m')
|
||||
FALLBACK_111_MAX_TIMEOUT = int(os.getenv('OLLAMA_111_MAX_TIMEOUT', '20'))
|
||||
FALLBACK_111_NUM_CTX = int(os.getenv('OLLAMA_111_NUM_CTX', '4096'))
|
||||
@@ -87,6 +89,7 @@ _RESOLVE_TTL = 120 # 主機健康狀態快取 120 秒
|
||||
_unhealthy_marks: dict = {} # host_url -> ts;30s 內被標記就跳過
|
||||
_UNHEALTHY_TTL = 30 # 主機被標 unhealthy 後 30 秒內跳過 resolve
|
||||
_fallback_111_circuit_cache: dict = {'blocked': False, 'reason': '', 'ts': 0}
|
||||
_embedding_gcp_failure_circuit: dict = {'blocked_until': 0.0, 'notice_ts': 0.0, 'tried': ()}
|
||||
|
||||
|
||||
def mark_unhealthy(host: str) -> None:
|
||||
@@ -132,6 +135,43 @@ def _clear_resolved_host_cache() -> None:
|
||||
_resolved_host_cache['ts'] = 0
|
||||
|
||||
|
||||
def _embedding_gcp_circuit_active() -> bool:
|
||||
"""背景 embedding 不落 111;GCP 全掛時短暫熔斷,避免每筆任務重打兩台。"""
|
||||
if EMBED_GCP_FAILURE_COOLDOWN_SEC <= 0:
|
||||
return False
|
||||
import time
|
||||
now = time.time()
|
||||
blocked_until = float(_embedding_gcp_failure_circuit.get('blocked_until') or 0)
|
||||
if now >= blocked_until:
|
||||
return False
|
||||
|
||||
notice_ts = float(_embedding_gcp_failure_circuit.get('notice_ts') or 0)
|
||||
if now - notice_ts >= EMBED_GCP_FAILURE_NOTICE_SEC:
|
||||
logger.warning(
|
||||
"[Embed] GCP embedding circuit open for %.1fs; tried=%s",
|
||||
blocked_until - now,
|
||||
list(_embedding_gcp_failure_circuit.get('tried') or ()),
|
||||
)
|
||||
_embedding_gcp_failure_circuit['notice_ts'] = now
|
||||
return True
|
||||
|
||||
|
||||
def _open_embedding_gcp_circuit(attempted_hosts: List[str]) -> None:
|
||||
if EMBED_GCP_FAILURE_COOLDOWN_SEC <= 0 or not attempted_hosts:
|
||||
return
|
||||
import time
|
||||
now = time.time()
|
||||
_embedding_gcp_failure_circuit.update({
|
||||
'blocked_until': now + EMBED_GCP_FAILURE_COOLDOWN_SEC,
|
||||
'notice_ts': now,
|
||||
'tried': tuple(attempted_hosts),
|
||||
})
|
||||
|
||||
|
||||
def _reset_embedding_gcp_circuit() -> None:
|
||||
_embedding_gcp_failure_circuit.update({'blocked_until': 0.0, 'notice_ts': 0.0, 'tried': ()})
|
||||
|
||||
|
||||
def _fallback_111_block_reason(host: str) -> Tuple[bool, str]:
|
||||
"""Return whether 111 fallback should be skipped for this request.
|
||||
|
||||
@@ -982,6 +1022,8 @@ class OllamaService:
|
||||
clean_text = (text or "").strip()
|
||||
if not clean_text:
|
||||
return []
|
||||
if not host and not allow_111_fallback and _embedding_gcp_circuit_active():
|
||||
return []
|
||||
if len(clean_text) > EMBED_MAX_CHARS:
|
||||
logger.info(
|
||||
"[Embed] input clipped from %s to %s chars for model=%s",
|
||||
@@ -1037,7 +1079,10 @@ class OllamaService:
|
||||
if blocked_111:
|
||||
logger.warning("[Embed] skip 111 fallback explicit host: %s", block_reason)
|
||||
return []
|
||||
return _embed_one(host.rstrip("/"))
|
||||
vec = _embed_one(host.rstrip("/"))
|
||||
if vec:
|
||||
_reset_embedding_gcp_circuit()
|
||||
return vec
|
||||
|
||||
# HOTFIX 三主機 retry 鏈(與 generate() 同模式)
|
||||
attempted_hosts: List[str] = []
|
||||
@@ -1086,9 +1131,12 @@ class OllamaService:
|
||||
|
||||
vec = _embed_one(target_host)
|
||||
if vec:
|
||||
_reset_embedding_gcp_circuit()
|
||||
return vec
|
||||
logger.info(f"[Embed] retry #{attempt+1}/{max_attempts} — {target_host} failed, mark_unhealthy + 取新主機")
|
||||
|
||||
if not allow_111_fallback:
|
||||
_open_embedding_gcp_circuit(attempted_hosts)
|
||||
logger.error(f"[Embed] all {len(attempted_hosts)} hosts failed; tried={attempted_hosts}")
|
||||
return []
|
||||
|
||||
|
||||
@@ -200,6 +200,21 @@ def test_get_ollama_host_falls_back_to_resolve_without_env(monkeypatch):
|
||||
assert host.startswith('http://')
|
||||
|
||||
|
||||
def test_config_ollama_compat_constants_do_not_probe_network(monkeypatch):
|
||||
monkeypatch.setenv('OLLAMA_HOST', 'http://192.168.0.188:11434')
|
||||
monkeypatch.setenv('HERMES_URL', 'http://192.168.0.188:11434')
|
||||
monkeypatch.setenv('EMBEDDING_HOST', 'http://192.168.0.188:11434')
|
||||
monkeypatch.setenv('OLLAMA_HOST_PRIMARY', 'http://34.143.170.20:11434')
|
||||
with patch('services.ollama_service.requests.get') as mock_get:
|
||||
import config
|
||||
importlib.reload(config)
|
||||
|
||||
mock_get.assert_not_called()
|
||||
assert config.OLLAMA_HOST == 'http://34.143.170.20:11434'
|
||||
assert config.HERMES_URL == 'http://34.143.170.20:11434'
|
||||
assert config.EMBEDDING_HOST == 'http://34.143.170.20:11434'
|
||||
|
||||
|
||||
def test_get_embedding_host_prefers_env(monkeypatch):
|
||||
monkeypatch.setenv('EMBEDDING_HOST', 'http://192.168.0.111:11434')
|
||||
import config
|
||||
|
||||
@@ -31,11 +31,13 @@ def _reset_state():
|
||||
oss._resolved_host_cache['host'] = None
|
||||
oss._resolved_host_cache['ts'] = 0
|
||||
oss._fallback_111_circuit_cache.update({'blocked': False, 'reason': '', 'ts': 0})
|
||||
oss._embedding_gcp_failure_circuit.update({'blocked_until': 0.0, 'notice_ts': 0.0, 'tried': ()})
|
||||
yield
|
||||
oss._unhealthy_marks.clear()
|
||||
oss._resolved_host_cache['host'] = None
|
||||
oss._resolved_host_cache['ts'] = 0
|
||||
oss._fallback_111_circuit_cache.update({'blocked': False, 'reason': '', 'ts': 0})
|
||||
oss._embedding_gcp_failure_circuit.update({'blocked_until': 0.0, 'notice_ts': 0.0, 'tried': ()})
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
@@ -407,6 +409,37 @@ def test_embedding_fallback_disabled_uses_gcp_chain_when_resolver_returns_111():
|
||||
assert oss.OLLAMA_HOST_FALLBACK not in posted_hosts
|
||||
|
||||
|
||||
def test_embedding_fallback_disabled_opens_short_gcp_failure_circuit():
|
||||
"""GCP-A/GCP-B 全掛時,背景 embedding 短暫熔斷,避免下一筆立刻重打兩台。"""
|
||||
import requests
|
||||
from services import ollama_service as oss
|
||||
from services.ollama_service import OllamaService
|
||||
|
||||
svc = OllamaService()
|
||||
|
||||
with patch('services.ollama_service.EMBED_GCP_FAILURE_COOLDOWN_SEC', 60), \
|
||||
patch('services.ollama_service.resolve_ollama_host', side_effect=[
|
||||
oss.OLLAMA_HOST_PRIMARY,
|
||||
oss.OLLAMA_HOST_SECONDARY,
|
||||
oss.OLLAMA_HOST_PRIMARY,
|
||||
]), \
|
||||
patch.dict('os.environ', {}, clear=False), \
|
||||
patch(
|
||||
'services.ollama_service.requests.post',
|
||||
side_effect=requests.Timeout('gcp timeout'),
|
||||
) as mock_post:
|
||||
import os
|
||||
os.environ.pop('EMBEDDING_HOST', None)
|
||||
first = svc.generate_embedding('test text', allow_111_fallback=False)
|
||||
second = svc.generate_embedding('another text', allow_111_fallback=False)
|
||||
|
||||
posted_hosts = [call.args[0].split('/api/embed')[0] for call in mock_post.call_args_list]
|
||||
assert first == []
|
||||
assert second == []
|
||||
assert posted_hosts == [oss.OLLAMA_HOST_PRIMARY, oss.OLLAMA_HOST_SECONDARY]
|
||||
assert oss._embedding_gcp_failure_circuit['blocked_until'] > 0
|
||||
|
||||
|
||||
def test_embedding_ignores_111_embedding_host_when_fallback_disabled():
|
||||
"""EMBEDDING_HOST 若誤設 111,背景 embedding 仍回 GCP resolver,不直接棄跑。"""
|
||||
from services import ollama_service as oss
|
||||
|
||||
Reference in New Issue
Block a user