Files
ewoooc/services/ollama_service.py
OoO 6572d521ba
All checks were successful
CD Pipeline / deploy (push) Successful in 2m42s
fix(embed): generate_embedding 三主機 retry — 修同類「111 死則全死」bug
承前 commit e862a90(generate retry)的同類修補:
generate_embedding 之前邏輯:
  target_host = host or env or resolve  # 一次解析
  try: post → mark_unhealthy + return []  # 失敗無 retry

修補後:
  caller 顯式 host → 凍結不 retry(向下相容)
  caller 走 lazy → 三主機 retry 鏈:
    每次 self.host 走 resolve_ollama_host()
    失敗 mark_unhealthy + cache 失效 + 取新主機
    最多 3 次(避免同主機無限迴圈)

影響範圍:KM embedding worker / RAG query embedding / openclaw_learning

regression: 57 unit tests 全綠

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-04 08:56:38 +08:00

760 lines
29 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
Ollama LLM 服務模組
負責與 Ollama API 互動,提供文案生成、關鍵字提取等功能
"""
import os
import requests
import json
import logging
from typing import Optional, Dict, Any, List, Tuple
from dataclasses import dataclass
logger = logging.getLogger(__name__)
# Ollama 設定 - 支援環境變數覆蓋
OLLAMA_HOST_PRIMARY = os.getenv('OLLAMA_HOST_PRIMARY', 'http://34.143.170.20:11434')
OLLAMA_HOST_SECONDARY = os.getenv('OLLAMA_HOST_SECONDARY', 'http://34.21.145.224:11434')
OLLAMA_HOST_FALLBACK = os.getenv('OLLAMA_HOST_FALLBACK', 'http://192.168.0.111:11434')
# OLLAMA_HOST 優先使用舊環境變數(向下相容),若未設定則以 PRIMARY 為主
OLLAMA_HOST = os.getenv('OLLAMA_HOST', OLLAMA_HOST_PRIMARY)
DEFAULT_MODEL = os.getenv('OLLAMA_MODEL', 'llama3.1:8b') # 較快速的模型
TIMEOUT = int(os.getenv('OLLAMA_TIMEOUT', '120')) # 秒 - 2 分鐘
COPY_TIMEOUT = int(os.getenv('OLLAMA_COPY_TIMEOUT', '180')) # 文案生成專用超時 - 3 分鐘
EMBED_TIMEOUT = int(os.getenv('OLLAMA_EMBED_TIMEOUT', os.getenv('EMBEDDING_TIMEOUT', '45')))
# ── GCP 優先 / 111 備援:解析實際可用的 Ollama 主機 ──────────────────────────
# ADR-027 Phase 2 強化:
# B3 — _is_reachable 從純 TCP 改 HTTP probe/api/version避免 process 掛但 port listen 中誤判。
# B4 — 新增 mark_unhealthy(host)generate/embedding 失敗時呼叫,下次 resolve 30s 內跳過該主機。
_resolved_host_cache: dict = {'host': None, 'ts': 0}
_RESOLVE_TTL = 120 # 主機健康狀態快取 120 秒
_unhealthy_marks: dict = {} # host_url -> ts30s 內被標記就跳過
_UNHEALTHY_TTL = 30 # 主機被標 unhealthy 後 30 秒內跳過 resolve
def mark_unhealthy(host: str) -> None:
"""
將指定主機標記為近期不健康,使下一次 resolve_ollama_host 跳過該主機直接 fallback。
呼叫時機generate / generate_embedding / 任何 Ollama HTTP 互動失敗。
過 _UNHEALTHY_TTL30s後會自然 expire恢復正常 resolve 流程。
同時清空 resolved cache強制下次 resolve 重新評估。
"""
import time
if not host:
return
_unhealthy_marks[host.rstrip('/')] = time.time()
# 同時讓 resolved cache 失效,下次 resolve 才會重新評估
_resolved_host_cache['host'] = None
_resolved_host_cache['ts'] = 0
logger.warning(f"[OllamaHost] 主機標記為 unhealthy30s 跳過):{host}")
def _is_unhealthy(host: str) -> bool:
"""檢查 host 是否在 unhealthy TTL 內"""
import time
if not host:
return False
ts = _unhealthy_marks.get(host.rstrip('/'))
if ts is None:
return False
if time.time() - ts >= _UNHEALTHY_TTL:
# TTL 過期,清除
_unhealthy_marks.pop(host.rstrip('/'), None)
return False
return True
def resolve_ollama_host(primary: str = OLLAMA_HOST_PRIMARY,
secondary: str = OLLAMA_HOST_SECONDARY,
fallback: str = OLLAMA_HOST_FALLBACK) -> str:
"""
V-New: 自動探測 GCP Ollama 主機是否可用。
- 可用 → 回傳 primaryGCP
- 不可用 → fallback 到 111 內網 Hermes
結果快取 120 秒,避免每次請求都觸發 HTTP 探測。
ADR-027 Phase 2:
- HTTP probe/api/version取代純 TCP socket避免 process 掛但 port listen 假活。
- 若主機被 mark_unhealthy 標記在 30s 內,跳過該主機直接 fallback。
"""
import time
now = time.time()
if (_resolved_host_cache['host'] is not None and
now - _resolved_host_cache['ts'] < _RESOLVE_TTL):
return _resolved_host_cache['host']
def _is_reachable(url: str, timeout: float = 2.0) -> bool:
"""
HTTP probe呼叫 /api/version 檢查 Ollama 服務真的健康。
回傳 200 才算可用timeout 收緊到 2s從 3s以加快失敗探測。
相比純 TCP可避免 process 掛但 port 仍 listen 中的假活情境。
"""
try:
resp = requests.get(f"{url.rstrip('/')}/api/version", timeout=timeout)
return resp.status_code == 200
except Exception:
return False
def _is_tcp_reachable(url: str, timeout: float = 1.5) -> bool:
"""
次級備援探測:純 TCP socket。
當 HTTP 失敗但 TCP 通時,紀錄為「網路層健康但 HTTP 掛」的觀測點,
仍視為 unreachable因為 Ollama 提供的是 HTTP 服務TCP 通沒意義),
但留在程式碼裡作為診斷工具。
"""
try:
import socket
from urllib.parse import urlparse
parsed = urlparse(url)
host = parsed.hostname
port = parsed.port or 11434
sock = socket.create_connection((host, port), timeout=timeout)
sock.close()
return True
except Exception:
return False
# B4: primary 若被標 unhealthy嘗試 secondary
if not _is_unhealthy(primary) and _is_reachable(primary):
selected = primary
logger.info(f"[OllamaHost] Primary 主機可用: {primary}")
elif not _is_unhealthy(secondary) and _is_reachable(secondary):
selected = secondary
logger.info(f"[OllamaHost] Primary 不可用,使用 Secondary: {secondary}")
else:
selected = fallback
logger.warning(f"[OllamaHost] Primary 與 Secondary 皆無法連線,切換 Fallback: {fallback}")
_resolved_host_cache['host'] = selected
_resolved_host_cache['ts'] = now
return selected
def get_host_label(host: str) -> str:
"""將 IP/URL 轉換為易讀的主機標籤"""
if not host:
return "未知"
if "34.143.170.20" in host:
return "GCP-SSD"
if "34.21.145.224" in host:
return "GCP-SSD-2"
if "192.168.0.111" in host:
return "111 備援"
if "192.168.0.188" in host or "localhost" in host:
return "188 本地"
return host.split('//')[-1].split(':')[0]
@dataclass
class OllamaResponse:
"""Ollama 回應結構"""
success: bool
content: str
model: str
error: Optional[str] = None
total_duration: Optional[float] = None
host: Optional[str] = None
class OllamaService:
"""Ollama LLM 服務"""
# V-Opt: 連線狀態快取,避免重複檢查
_connection_cache = {'status': None, 'timestamp': 0}
_CACHE_TTL = 60 # 快取 60 秒
def __init__(self, host: str = None, model: str = DEFAULT_MODEL):
# HOTFIX 2026-05-04 統帥反饋「111 關機 → GCP 也斷」根因修補:
# 原邏輯 self.host = resolve_ollama_host() 是 __init__ 凍結,容器啟動時若
# GCP cold start 觸發 fallback 111self.host 永遠卡 111。即使 cache 過期,
# OllamaService instance 不會重新 resolve。
# 修補self._explicit_host 只在 caller 顯式指定時凍結;否則 self.host 改 property
# 每次 access 走 resolve_ollama_host()(內部 120s cache 控制成本)。
self._explicit_host = host # None 表示走 lazy resolve
self.model = model
self.available_models = []
@property
def host(self) -> str:
"""每次存取 lazy resolve120s cachecaller 顯式指定 host 才凍結。"""
if self._explicit_host:
return self._explicit_host
return resolve_ollama_host()
def check_connection(self) -> bool:
"""檢查 Ollama 服務是否可用(含快取)"""
import time
# V-Opt: 使用快取避免頻繁檢查
now = time.time()
if (OllamaService._connection_cache['status'] is not None and
now - OllamaService._connection_cache['timestamp'] < OllamaService._CACHE_TTL):
return OllamaService._connection_cache['status']
try:
# V-Opt: 增加超時時間避免 Ollama 負載高時誤判為離線
response = requests.get(f"{self.host}/api/tags", timeout=10)
if response.status_code == 200:
data = response.json()
self.available_models = [m['name'] for m in data.get('models', [])]
logger.info(f"Ollama 連線成功,可用模型: {self.available_models}")
OllamaService._connection_cache = {'status': True, 'timestamp': now}
return True
OllamaService._connection_cache = {'status': False, 'timestamp': now}
return False
except Exception as e:
logger.error(f"Ollama 連線失敗: {e}")
OllamaService._connection_cache = {'status': False, 'timestamp': now}
return False
def list_models(self) -> List[str]:
"""列出可用模型"""
if not self.available_models:
self.check_connection()
return self.available_models
def generate(self, prompt: str, model: str = None,
system_prompt: str = None, temperature: float = 0.7,
timeout: int = None) -> OllamaResponse:
"""
生成文字 — 含三主機自動 retryHOTFIX 2026-05-04
失敗時自動嘗試下一台主機(最多 3 次Primary → Secondary → 111
每次失敗 mark_unhealthy 觸發 resolve cache 失效。
"""
model = model or self.model
request_timeout = timeout or TIMEOUT
payload = {
"model": model,
"prompt": prompt,
"stream": False,
"options": {"temperature": temperature},
}
if system_prompt:
payload["system"] = system_prompt
# HOTFIX 三主機 retry 鏈
attempted_hosts: List[str] = []
last_error: Optional[str] = None
for attempt in range(3):
current_host = self.host # property 每次 lazy resolve
if current_host in attempted_hosts:
# 已試過同主機cache 還沒過期),跳出避免無限迴圈
break
attempted_hosts.append(current_host)
logger.info(f"[Ollama] 嘗試 #{attempt+1}/3 host={current_host} model={model} timeout={request_timeout}s")
try:
response = requests.post(
f"{current_host}/api/generate",
json=payload,
timeout=request_timeout,
)
if response.status_code == 200:
data = response.json()
return OllamaResponse(
success=True,
content=data.get('response', ''),
model=model,
total_duration=data.get('total_duration', 0) / 1e9,
host=current_host,
)
# 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(current_host)
except requests.Timeout:
last_error = f"timeout ({request_timeout}s)"
logger.warning(f"[Ollama] {current_host} timeout → mark_unhealthy + retry")
mark_unhealthy(current_host)
except Exception as e:
last_error = f"{type(e).__name__}: {str(e)[:200]}"
logger.error(f"[Ollama] {current_host} error → mark_unhealthy + retry: {last_error}")
mark_unhealthy(current_host)
# 三台都失敗
return OllamaResponse(
success=False,
content='',
model=model,
error=f"all {len(attempted_hosts)} hosts failed; last={last_error}; tried={attempted_hosts}",
host=attempted_hosts[-1] if attempted_hosts else 'unknown',
)
def generate_sales_copy(self, product_name: str, trend_keywords: List[str] = None,
style: str = "吸睛", upcoming_holidays: List[Dict] = None,
bestseller_products: List[Dict] = None) -> OllamaResponse:
"""
生成銷售文案
Args:
product_name: 商品名稱
trend_keywords: 相關趨勢關鍵字
style: 文案風格 (吸睛/專業/溫馨/急迫)
upcoming_holidays: 即將到來的假期 [{"name": "春節", "date": "2026-01-29", "days_until": 8}]
bestseller_products: 競品熱銷商品 [{"name": "xxx", "price": 999}]
Returns:
OllamaResponse
"""
style_prompts = {
"吸睛": "使用吸引眼球的標題和表情符號",
"專業": "使用專業術語,強調成分和功效",
"溫馨": "使用溫暖的語氣,強調呵護和關愛",
"急迫": "使用限時優惠的語氣,創造緊迫感"
}
# 趨勢關鍵字
trend_context = ""
if trend_keywords:
trend_context = f"\n目前的熱門趨勢關鍵字:{', '.join(trend_keywords)}。請嘗試將這些趨勢融入文案中。"
# 即將到來的假期
holiday_context = ""
if upcoming_holidays:
holidays_text = []
for h in upcoming_holidays[:3]: # 最多取 3 個
name = h.get('name', '')
days = h.get('days_until', 0)
if days == 0:
holidays_text.append(f"{name}(今天)")
elif days == 1:
holidays_text.append(f"{name}(明天)")
else:
holidays_text.append(f"{name}{days}天後)")
if holidays_text:
holiday_context = f"\n即將到來的假期:{', '.join(holidays_text)}。可以考慮結合節慶氛圍或送禮情境。"
# 競品熱銷參考
bestseller_context = ""
if bestseller_products:
products_text = [f"{p.get('name', '')}${p.get('price', '')}" for p in bestseller_products[:3]]
if products_text:
bestseller_context = f"\n市場熱銷參考:{', '.join(products_text)}。可參考熱銷趨勢但要突出自家商品特色。"
system_prompt = """你是一位專業的電商銷售文案寫手和行銷策略專家,專門為台灣電商平台撰寫商品文案。
你的文案特點:
- 使用繁體中文
- 善用表情符號增加吸引力
- 強調商品賣點和消費者利益
- 適時使用行動呼籲 (CTA)
- 若有即將到來的節日,可適度融入節慶元素
- 提供完整的行銷建議"""
prompt = f"""請為以下商品撰寫完整的銷售文案套組:
商品名稱:{product_name}
文案風格:{style_prompts.get(style, style_prompts['吸睛'])}
{trend_context}{holiday_context}{bestseller_context}
請按照以下格式生成完整的銷售文案套組:
【大標題】
15字以內的主打標語吸引眼球適合用於廣告Banner
【中標題】
30字以內的副標題補充說明賣點
【小標題】
20字以內的精簡標語適合用於社群貼文
【詳細文案】
100-150字的完整銷售文案包含商品特色、使用情境、行動呼籲
【推廣建議】
• 社群推廣Facebook/Instagram/LINE 等社群平台的建議策略)
• 影音內容:(短影音/直播/開箱影片等建議)
• 其他建議EDM、部落格、KOL合作等專業建議
請確保所有內容使用繁體中文,風格一致,並突出商品價值:"""
# 文案生成使用更長的超時時間
return self.generate(prompt, system_prompt=system_prompt, temperature=0.8, timeout=COPY_TIMEOUT)
def extract_keywords(self, text: str, max_keywords: int = 10) -> OllamaResponse:
"""
從文字中提取關鍵字
Args:
text: 要分析的文字
max_keywords: 最大關鍵字數量
Returns:
OllamaResponsecontent 為逗號分隔的關鍵字)
"""
system_prompt = "你是一位關鍵字提取專家。請從給定的文字中提取最重要的關鍵字。"
prompt = f"""請從以下文字中提取最多 {max_keywords} 個關鍵字,這些關鍵字應該能代表文章的主題和重點。
文字內容:
{text}
請只輸出關鍵字,用逗號分隔,不要輸出其他內容:"""
return self.generate(prompt, system_prompt=system_prompt, temperature=0.3)
def match_products_to_trend(self, trend_topic: str, trend_description: str,
products: List[Dict[str, Any]]) -> OllamaResponse:
"""
根據趨勢話題匹配適合的商品
Args:
trend_topic: 趨勢話題
trend_description: 趨勢描述
products: 商品列表 [{"name": "...", "category": "...", "description": "..."}, ...]
Returns:
OllamaResponsecontent 為 JSON 格式的推薦結果)
"""
# 只取前 50 個商品避免 prompt 過長
products_text = "\n".join([
f"- {p.get('name', '')} (分類: {p.get('category', '未分類')})"
for p in products[:50]
])
system_prompt = """你是一位電商行銷專家,擅長將熱門話題與商品進行關聯。
你的任務是從商品列表中找出最適合搭配當前趨勢話題進行行銷的商品。"""
prompt = f"""當前熱門話題:{trend_topic}
話題描述:{trend_description}
商品列表:
{products_text}
請從上述商品中選出最適合搭配這個話題進行行銷的前 5 個商品。
對於每個推薦的商品,請說明:
1. 為什麼這個商品適合這個話題
2. 建議的行銷角度
請用以下 JSON 格式回覆:
{{
"recommendations": [
{{"product_name": "商品名稱", "reason": "推薦原因", "marketing_angle": "行銷角度"}},
...
]
}}"""
return self.generate(prompt, system_prompt=system_prompt, temperature=0.5)
def analyze_trend_relevance(self, trend_info: str, product_categories: List[str]) -> OllamaResponse:
"""
分析趨勢與商品分類的相關性
Args:
trend_info: 趨勢資訊
product_categories: 商品分類列表
Returns:
OllamaResponse
"""
categories_text = ", ".join(product_categories)
system_prompt = "你是一位市場分析師,擅長分析消費趨勢與商品之間的關聯。"
prompt = f"""趨勢資訊:
{trend_info}
可用的商品分類:
{categories_text}
請分析這個趨勢與哪些商品分類最相關並給出相關性評分1-10分
請用 JSON 格式回覆:
{{
"analysis": "簡短的分析說明",
"relevant_categories": [
{{"category": "分類名稱", "score": 8, "reason": "相關原因"}},
...
]
}}"""
return self.generate(prompt, system_prompt=system_prompt, temperature=0.4)
def web_search(self, query: str, num_results: int = 5,
search_type: str = "general") -> OllamaResponse:
"""
使用 Ollama 進行網路搜尋並整理結果
注意:這個功能需要 Ollama 支援工具調用 (tool calling)
或使用支援搜尋的模型 (如 llama3.2 with tools)
Args:
query: 搜尋關鍵字
num_results: 返回結果數量
search_type: 搜尋類型 (general/news/shopping/trends)
Returns:
OllamaResponse
"""
search_prompts = {
"general": "請搜尋並整理關於此主題的最新資訊",
"news": "請搜尋並整理此主題的最新新聞和報導",
"shopping": "請搜尋並整理此商品的市場資訊、價格和評價",
"trends": "請搜尋並分析此主題的市場趨勢和熱門程度"
}
system_prompt = """你是一位專業的市場研究分析師。
你的任務是根據使用者的搜尋需求,整理出結構化的資訊。
請用以下 JSON 格式回覆:
{
"query": "原始搜尋關鍵字",
"summary": "搜尋結果摘要50字以內",
"results": [
{
"title": "結果標題",
"description": "簡短描述",
"relevance": "與搜尋的相關性說明",
"keywords": ["相關關鍵字1", "關鍵字2"]
}
],
"insights": ["洞察1", "洞察2"],
"recommended_actions": ["建議行動1", "建議行動2"]
}"""
search_context = search_prompts.get(search_type, search_prompts["general"])
prompt = f"""搜尋需求:{query}
搜尋類型:{search_type}
期望結果數:{num_results}
{search_context}
請根據你對這個主題的了解,提供結構化的分析結果。
包含主要的市場趨勢、相關關鍵字、以及對電商銷售的建議。"""
return self.generate(prompt, system_prompt=system_prompt, temperature=0.5, timeout=120)
def search_product_insights(self, product_name: str,
include_competitors: bool = True,
include_trends: bool = True,
web_context: str = "") -> OllamaResponse:
"""
搜尋商品相關的市場洞察
Args:
product_name: 商品名稱
include_competitors: 是否包含競品分析
include_trends: 是否包含趨勢分析
web_context: 網路搜尋結果(用於提供即時市場資訊)
Returns:
OllamaResponse
"""
system_prompt = """你是一位資深的電商市場分析師,專精於台灣市場。
你擅長分析商品的市場定位、競爭對手、以及銷售趨勢。
請提供全面但簡潔的市場洞察,使用繁體中文。
若有提供網路搜尋結果,請優先參考這些最新資訊進行分析。"""
analysis_parts = ["市場定位分析"]
if include_competitors:
analysis_parts.append("主要競爭對手分析")
if include_trends:
analysis_parts.append("市場趨勢分析")
# 建構動態 JSON 區塊(避免 f-string 中使用 backslash
competitors_json = '"competitors": [{"name": "競品名稱", "strength": "優勢", "weakness": "劣勢"}],' if include_competitors else ""
trends_json = '"trends": {"current": "當前趨勢", "forecast": "趨勢預測", "seasonality": "季節性因素"},' if include_trends else ""
analysis_list = chr(10).join([f'{i+1}. {part}' for i, part in enumerate(analysis_parts)])
# 加入網路搜尋結果(如果有)
web_context_section = ""
if web_context and web_context.strip():
web_context_section = f"""
【參考資料 - 網路搜尋最新結果】
{web_context.strip()}
請根據以上網路搜尋結果,結合你的知識,提供更精準的市場分析。
"""
prompt = f"""請為以下商品提供市場洞察分析:
商品名稱:{product_name}
{web_context_section}
請分析以下面向:
{analysis_list}
請用以下 JSON 格式回覆(務必輸出有效的 JSON
{{
"product_name": "{product_name}",
"market_position": {{
"target_audience": "目標客群描述",
"price_range": "價格區間建議",
"positioning": "市場定位建議"
}},
{competitors_json}
{trends_json}
"recommendations": ["銷售建議1", "銷售建議2", "銷售建議3"],
"keywords": ["行銷關鍵字1", "關鍵字2", "關鍵字3"]
}}"""
return self.generate(prompt, system_prompt=system_prompt, temperature=0.6, timeout=180)
def search_trend_keywords(self, category: str, time_range: str = "week") -> OllamaResponse:
"""
搜尋特定分類的熱門關鍵字和趨勢
Args:
category: 商品分類
time_range: 時間範圍 (day/week/month)
Returns:
OllamaResponse
"""
time_desc = {
"day": "今天",
"week": "本週",
"month": "本月"
}
system_prompt = """你是一位社群媒體和搜尋趨勢分析專家,專注於台灣電商市場。
你熟悉各大平台的熱門話題、關鍵字趨勢、以及消費者行為。"""
prompt = f"""請分析「{category}」這個商品分類在{time_desc.get(time_range, '近期')}的熱門關鍵字和趨勢。
請提供:
1. 熱門搜尋關鍵字5-10個
2. 社群討論熱點3-5個話題
3. 消費者關注點
4. 行銷建議
請用以下 JSON 格式回覆:
{{
"category": "{category}",
"time_range": "{time_range}",
"hot_keywords": [
{{"keyword": "關鍵字", "trend": "上升/穩定/下降", "relevance": "高/中/低"}}
],
"social_topics": [
{{"topic": "話題", "platform": "平台", "engagement": "互動度描述"}}
],
"consumer_concerns": ["關注點1", "關注點2"],
"marketing_suggestions": ["建議1", "建議2"]
}}"""
return self.generate(prompt, system_prompt=system_prompt, temperature=0.5, timeout=120)
@staticmethod
def _extract_embedding(payload: Dict[str, Any]) -> List[float]:
"""Normalize Ollama /api/embed and legacy /api/embeddings response shapes."""
embeddings = payload.get("embeddings")
if isinstance(embeddings, list) and embeddings:
first = embeddings[0]
if isinstance(first, list):
return first
if all(isinstance(value, (int, float)) for value in embeddings):
return embeddings
embedding = payload.get("embedding")
if isinstance(embedding, list):
return embedding
return []
def generate_embedding(self, text: str, model: str = "bge-m3:latest",
host: str = None, timeout: int = None) -> List[float]:
"""
[ADR-007] Embedding — 含三主機自動 retryHOTFIX 2026-05-04
失敗時自動嘗試下一台主機(最多 3 次Primary → Secondary → 111
每次失敗 mark_unhealthy 觸發 resolve cache 失效,下次 resolve 取新主機。
caller 顯式 host=... 時凍結(不 retry
"""
request_timeout = timeout or EMBED_TIMEOUT
def _embed_one(target_host: str) -> List[float]:
"""單次 embedding 嘗試 — 成功回 vec失敗回 [] + mark_unhealthy"""
try:
# /api/embed 主路徑
response = requests.post(
f"{target_host}/api/embed",
json={"model": model, "input": text},
timeout=request_timeout,
)
if response.status_code == 200:
vec = self._extract_embedding(response.json())
if vec:
return vec
logger.warning(f"[Embed] empty response @ {target_host}/api/embed")
elif response.status_code not in (404, 405):
logger.warning(f"[Embed] HTTP {response.status_code} @ {target_host}/api/embed: {response.text[:200]}")
mark_unhealthy(target_host)
return []
# /api/embeddings legacy fallback
legacy = requests.post(
f"{target_host}/api/embeddings",
json={"model": model, "prompt": text},
timeout=request_timeout,
)
if legacy.status_code == 200:
return self._extract_embedding(legacy.json())
logger.warning(f"[Embed] both endpoints failed @ {target_host}: {legacy.status_code}")
mark_unhealthy(target_host)
return []
except Exception as e:
logger.warning(f"[Embed] exception @ {target_host}: {e}")
mark_unhealthy(target_host)
return []
# caller 顯式指定 host → 凍結不 retry
if host:
return _embed_one(host.rstrip("/"))
# HOTFIX 三主機 retry 鏈(與 generate() 同模式)
attempted_hosts: List[str] = []
for attempt in range(3):
target_host = (os.getenv("EMBEDDING_HOST") or resolve_ollama_host()).rstrip("/")
if target_host in attempted_hosts:
break # cache 還沒過期或同主機,避免無限迴圈
attempted_hosts.append(target_host)
vec = _embed_one(target_host)
if vec:
return vec
logger.info(f"[Embed] retry #{attempt+1}/3 — {target_host} failed, mark_unhealthy + 取新主機")
logger.error(f"[Embed] all {len(attempted_hosts)} hosts failed; tried={attempted_hosts}")
return []
# 建立全域服務實例
ollama_service = OllamaService()
if __name__ == "__main__":
# 測試程式碼
logging.basicConfig(level=logging.INFO)
service = OllamaService()
# 測試連線
print("測試 Ollama 連線...")
if service.check_connection():
print(f"連線成功!可用模型: {service.available_models}")
# 測試文案生成
print("\n測試文案生成...")
result = service.generate_sales_copy(
"玻尿酸保濕面膜",
trend_keywords=["換季保養", "敏感肌"],
style="吸睛"
)
if result.success:
print(f"生成結果: {result.content}")
print(f"耗時: {result.total_duration:.2f}")
else:
print(f"生成失敗: {result.error}")
else:
print("連線失敗")