修正 Ollama fallback 與 PPT vision payload
All checks were successful
CD Pipeline / deploy (push) Successful in 1m30s
All checks were successful
CD Pipeline / deploy (push) Successful in 1m30s
This commit is contained in:
@@ -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")
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user