修正 Ollama fallback 與 PPT vision payload
All checks were successful
CD Pipeline / deploy (push) Successful in 1m30s

This commit is contained in:
OoO
2026-05-18 21:32:15 +08:00
parent aa8c2c7148
commit 1cf1fd01b1
6 changed files with 119 additions and 12 deletions

View File

@@ -90,17 +90,32 @@ def _mark_unhealthy_best_effort(host: str) -> None:
logger.debug("[OllamaHost] mark_unhealthy failed for host=%s", host, exc_info=True)
def _normalize_host(host: str) -> str:
return (host or '').rstrip('/')
def _canonical_host_chain() -> List[str]:
"""Return the approved static fallback chain without duplicates."""
chain: List[str] = []
for host in (OLLAMA_HOST_PRIMARY, OLLAMA_HOST_SECONDARY, OLLAMA_HOST_FALLBACK):
clean = _normalize_host(host)
if clean and clean not in chain:
chain.append(clean)
return chain
def _is_unhealthy(host: str) -> bool:
"""檢查 host 是否在 unhealthy TTL 內"""
import time
if not host:
return False
ts = _unhealthy_marks.get(host.rstrip('/'))
clean_host = _normalize_host(host)
ts = _unhealthy_marks.get(clean_host)
if ts is None:
return False
if time.time() - ts >= _UNHEALTHY_TTL:
# TTL 過期,清除
_unhealthy_marks.pop(host.rstrip('/'), None)
_unhealthy_marks.pop(clean_host, None)
return False
return True
@@ -323,12 +338,27 @@ class OllamaService:
# HOTFIX 三主機 retry 鏈
attempted_hosts: List[str] = []
last_error: Optional[str] = None
canonical_hosts = _canonical_host_chain()
for attempt in range(3):
current_host = self.host # property 每次 lazy resolve
current_host = _normalize_host(self.host) # property 每次 lazy resolve
if current_host in attempted_hosts:
# 已試過同主機cache 還沒過期),跳出避免無限迴圈
break
# 已試過同主機時,若是標準三主機鏈且 caller 沒指定 host
# 改走尚未嘗試的下一台。避免 request timeout(60s) 大於
# unhealthy TTL(30s) 時第三輪又 resolve 回 primary導致 111
# final fallback 永遠沒被打到。
next_host = None
if self._explicit_host is None and current_host in canonical_hosts:
next_host = next((host for host in canonical_hosts if host not in attempted_hosts), None)
if not next_host:
# 非標準 host 或 explicit host 維持原行為:跳出避免無限迴圈。
break
logger.info(
"[Ollama] resolver returned previously attempted host=%s; forcing next fallback host=%s",
current_host,
next_host,
)
current_host = next_host
attempted_hosts.append(current_host)
logger.info(f"[Ollama] 嘗試 #{attempt+1}/3 host={current_host} model={model} timeout={request_timeout}s")

View File

@@ -23,6 +23,7 @@ import base64
import logging
import shutil
import threading
from io import BytesIO
from dataclasses import dataclass, field
from typing import Optional, Dict, Any, List, Sequence
@@ -32,7 +33,9 @@ logger = logging.getLogger(__name__)
# Feature flag + 配置
# ─────────────────────────────────────────────────────────────────────────────
PPT_VISION_MODEL = os.getenv('PPT_VISION_MODEL', 'minicpm-v:latest')
PPT_VISION_TIMEOUT = int(os.getenv('PPT_VISION_TIMEOUT', '60'))
PPT_VISION_TIMEOUT = int(os.getenv('PPT_VISION_TIMEOUT', '45'))
PPT_VISION_IMAGE_MAX_EDGE = int(os.getenv('PPT_VISION_IMAGE_MAX_EDGE', '1280'))
PPT_VISION_IMAGE_QUALITY = int(os.getenv('PPT_VISION_IMAGE_QUALITY', '82'))
_AUDIT_LOCK = threading.Lock()
_LAST_AUDIT_RUN: Dict[str, Any] | None = None
@@ -105,6 +108,27 @@ class PPTVisionService:
def is_available(self) -> bool:
return is_ppt_vision_enabled()
def _encode_image_for_vision(self, image_path: str) -> str:
"""Compress slide screenshots before sending to Ollama vision."""
try:
from PIL import Image
with Image.open(image_path) as im:
image = im.convert('RGB')
image.thumbnail((PPT_VISION_IMAGE_MAX_EDGE, PPT_VISION_IMAGE_MAX_EDGE))
buffer = BytesIO()
image.save(
buffer,
format='JPEG',
quality=max(50, min(PPT_VISION_IMAGE_QUALITY, 95)),
optimize=True,
)
return base64.b64encode(buffer.getvalue()).decode('ascii')
except Exception:
# Pillow is an optimization, not a hard dependency for the vision path.
with open(image_path, 'rb') as f:
return base64.b64encode(f.read()).decode('ascii')
def check_ppt_file(self, pptx_path: str, max_slides: int = 5) -> Dict[str, Any]:
"""檢查整份 .pptx — Phase 26 整合到 PPT 生成流程。
@@ -330,11 +354,9 @@ class PPTVisionService:
error=f'image not found: {image_path}',
)
# 讀檔並 base64 編碼
# 讀檔並 base64 編碼;可用時先壓縮縮圖,避免 Ollama vision 被大圖拖慢。
try:
with open(image_path, 'rb') as f:
img_bytes = f.read()
img_b64 = base64.b64encode(img_bytes).decode('ascii')
img_b64 = self._encode_image_for_vision(image_path)
except Exception as e:
return VisionResult(
success=False,
@@ -351,7 +373,8 @@ class PPTVisionService:
system_prompt=PPT_VISION_SYSTEM_PROMPT,
temperature=0.2,
timeout=PPT_VISION_TIMEOUT,
options={'num_predict': 512},
keep_alive='5m',
options={'num_predict': 256},
images=[img_b64],
)
duration_ms = int((time.monotonic() - start) * 1000)