#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
services/ppt_vision_service.py
Operation Ollama-First v5.0 / Phase 14 — PPT 視覺自審
設計原則:
- 用 minicpm-v(GCP Primary 已拉,5.5GB)對 PPT 截圖做品質檢查
- 替代 qwen2-vl:7b(Ollama registry 暫無)
- 用途:PPT 生成後自動跑視覺檢查,找:
1. 圖表 layout 異常(被切掉、重疊)
2. 文字溢出框
3. 空白區塊(資料未填滿)
4. 配色衝突
- feature flag PPT_VISION_ENABLED 預設 OFF
- 失敗自動 skip(不阻擋 PPT 生成主流程)
"""
from __future__ import annotations
import os
import time
import base64
import logging
from dataclasses import dataclass, field
from typing import Optional, Dict, Any, List
import requests
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'))
def is_ppt_vision_enabled() -> bool:
"""Runtime check(避免 import-time freeze)"""
return os.getenv('PPT_VISION_ENABLED', 'false').strip().lower() in ('true', '1', 'yes', 'on')
# ─────────────────────────────────────────────────────────────────────────────
# 結果容器
# ─────────────────────────────────────────────────────────────────────────────
@dataclass
class VisionResult:
success: bool
issues_found: List[str] = field(default_factory=list) # 問題清單
confidence: float = 0.0 # 0-1,模型自評
raw_response: str = ''
duration_ms: int = 0
error: Optional[str] = None
# ─────────────────────────────────────────────────────────────────────────────
# Vision 檢查 prompt(繁中強制)
# ─────────────────────────────────────────────────────────────────────────────
PPT_VISION_SYSTEM_PROMPT = """你是 momo 電商 PPT 排版品質審核員。
【任務】檢查截圖找出視覺異常,回繁中清單格式:
- 圖表被切掉 / 元素重疊 / 文字溢出框 / 空白區塊(資料未填滿)/ 配色衝突
- 商品名稱顯示不完整 / 數字單位錯誤 / 標題遮擋
【輸出格式】
若無問題:回「✅ 無視覺異常」
若有問題:每行一個問題,格式「⚠️ <問題類型>:<具體描述>」
【限制】
- 只檢查視覺,不評估內容對錯
- 用繁體中文(台灣用語),絕對禁止簡體字
- 不要寫過多解釋,每個問題一行精簡描述
"""
class PPTVisionService:
"""minicpm-v 視覺檢查服務."""
def __init__(self, model: str = PPT_VISION_MODEL):
self.model = model
def is_available(self) -> bool:
return is_ppt_vision_enabled()
def check_ppt_file(self, pptx_path: str, max_slides: int = 5) -> Dict[str, Any]:
"""檢查整份 .pptx — Phase 26 整合到 PPT 生成流程。
流程:
1. LibreOffice headless 轉 png(每張 slide 一張)
2. 對前 N 張跑 check_image
3. 彙總 issues + 平均 confidence
4. fail-safe:LibreOffice 不在 / 轉檔失敗 → 回 skip 不阻擋主流程
Returns:
{
'success': bool,
'slides_checked': int,
'total_issues': int,
'issues_by_slide': [(slide_num, [issues...]), ...],
'error': str | None,
}
"""
import os
import subprocess
import tempfile
result = {
'success': False, 'slides_checked': 0, 'total_issues': 0,
'issues_by_slide': [], 'error': None,
}
if not self.is_available():
result['error'] = 'PPT_VISION_ENABLED=false'
return result
if not os.path.isfile(pptx_path):
result['error'] = f'pptx not found: {pptx_path}'
return result
# 1. LibreOffice 轉 png
with tempfile.TemporaryDirectory() as tmpdir:
try:
proc = subprocess.run(
['libreoffice', '--headless', '--convert-to', 'png',
'--outdir', tmpdir, pptx_path],
capture_output=True, timeout=60,
)
if proc.returncode != 0:
result['error'] = f'libreoffice convert failed: {proc.stderr.decode()[:200]}'
return result
except FileNotFoundError:
result['error'] = 'libreoffice not installed (skip vision check)'
return result
except subprocess.TimeoutExpired:
result['error'] = 'libreoffice convert timeout (60s)'
return result
except Exception as e:
result['error'] = f'{type(e).__name__}: {str(e)[:200]}'
return result
# LibreOffice 對 .pptx 預設只輸出第一頁;多頁需 --convert-to png:impress_png_Export
png_files = sorted([
os.path.join(tmpdir, f) for f in os.listdir(tmpdir)
if f.lower().endswith('.png')
])
if not png_files:
result['error'] = 'libreoffice 未產出 png (可能需要 --convert-to png:impress_png_Export)'
return result
# 2. 對前 N 張跑 check_image
import time as _time
t0 = _time.monotonic()
confidences = []
for idx, png in enumerate(png_files[:max_slides]):
try:
vr = self.check_image(png)
if vr.success:
result['slides_checked'] += 1
confidences.append(vr.confidence)
if vr.issues_found:
result['total_issues'] += len(vr.issues_found)
result['issues_by_slide'].append((idx + 1, vr.issues_found))
except Exception as exc:
logger.warning(f"[PPTVision] slide {idx+1} check failed: {exc}")
result['success'] = result['slides_checked'] > 0
duration_ms = int((_time.monotonic() - t0) * 1000)
# Phase 38:寫入 ppt_audit_results 留歷史(失敗安全)
try:
self._persist_audit_result(
pptx_path=pptx_path,
result=result,
avg_confidence=(sum(confidences) / len(confidences)) if confidences else 0.0,
duration_ms=duration_ms,
)
except Exception as e:
logger.warning(f"[PPTVision] persist audit result failed: {e}")
return result
def _persist_audit_result(self, pptx_path: str, result: Dict[str, Any],
avg_confidence: float, duration_ms: int) -> None:
"""Phase 38: 把每次 audit 結果寫入 ppt_audit_results 表。
失敗安全:DB 寫入失敗只 log warning,不擋主流程。
"""
import os
from datetime import datetime as _dt
from sqlalchemy import text as _sa_text
from database.manager import get_session
# 推論 audit_status
if result.get('error'):
err = result['error']
if 'libreoffice not installed' in err or 'PPT_VISION_ENABLED' in err:
status = 'skipped'
else:
status = 'error'
elif result.get('total_issues', 0) > 0:
status = 'failed'
elif result.get('success'):
status = 'passed'
else:
status = 'error'
# issues_found JSONB 序列化
import json as _json
issues_json = _json.dumps([
{'slide': slide_num, 'issues': issues}
for slide_num, issues in result.get('issues_by_slide', [])
], ensure_ascii=False)
try:
size_kb = round(os.path.getsize(pptx_path) / 1024, 1) if os.path.isfile(pptx_path) else None
mtime = _dt.fromtimestamp(os.path.getmtime(pptx_path)) if os.path.isfile(pptx_path) else None
except OSError:
size_kb = None
mtime = None
session = get_session()
try:
session.execute(
_sa_text("""
INSERT INTO ppt_audit_results
(pptx_filename, pptx_size_kb, pptx_mtime, vision_enabled,
audit_status, issues_count, issues_found, confidence,
duration_ms, error_msg)
VALUES
(:fname, :sz, :mt, :ve, :st, :ic, CAST(:if AS JSONB),
:cf, :du, :em)
"""),
{
'fname': os.path.basename(pptx_path),
'sz': size_kb,
'mt': mtime,
've': True, # 進到這裡代表 vision 已 enabled
'st': status,
'ic': result.get('total_issues', 0),
'if': issues_json,
'cf': round(avg_confidence, 3),
'du': duration_ms,
'em': result.get('error', None),
},
)
session.commit()
finally:
session.close()
def check_image(self, image_path: str) -> VisionResult:
"""檢查單張 PPT 截圖。
Args:
image_path: 本地檔案路徑(jpg/png)
Returns:
VisionResult.issues_found 含問題清單;無問題則空 list + confidence=1.0
"""
start = time.monotonic()
if not self.is_available():
return VisionResult(
success=False,
error='PPT_VISION_ENABLED=false (Phase 14 預設 OFF)',
)
if not os.path.isfile(image_path):
return VisionResult(
success=False,
error=f'image not found: {image_path}',
)
# 讀檔並 base64 編碼
try:
with open(image_path, 'rb') as f:
img_bytes = f.read()
img_b64 = base64.b64encode(img_bytes).decode('ascii')
except Exception as e:
return VisionResult(
success=False,
error=f'read image failed: {type(e).__name__}: {str(e)[:200]}',
)
# 透過 resolve_ollama_host 取主機(享受三主機 retry 鏈)
try:
from services.ollama_service import resolve_ollama_host, mark_unhealthy
host = resolve_ollama_host()
except Exception as e:
return VisionResult(
success=False,
error=f'resolve host failed: {e}',
)
# Ollama /api/generate 支援 images 欄位(base64 list)
payload = {
'model': self.model,
'system': PPT_VISION_SYSTEM_PROMPT,
'prompt': '請檢查這張 momo 電商 PPT 截圖,找出視覺異常。',
'images': [img_b64],
'stream': False,
'options': {'temperature': 0.2, 'num_predict': 512},
}
try:
resp = requests.post(
f"{host.rstrip('/')}/api/generate",
json=payload,
timeout=PPT_VISION_TIMEOUT,
)
duration_ms = int((time.monotonic() - start) * 1000)
if resp.status_code != 200:
# mark_unhealthy 讓下次自動切其他主機
mark_unhealthy(host)
return VisionResult(
success=False, duration_ms=duration_ms,
error=f'HTTP {resp.status_code}: {resp.text[:200]}',
)
data = resp.json()
raw = (data.get('response') or '').strip()
# 解析輸出:每行一個 ⚠️ 開頭的視為 issue;✅ 無視覺異常則空 list
issues = []
for line in raw.split('\n'):
line = line.strip()
if line.startswith('⚠️') or line.startswith('warning:') or line.startswith('警告'):
issues.append(line)
if '✅' in raw and '無視覺異常' in raw and not issues:
# 確認是 OK
return VisionResult(
success=True, issues_found=[],
confidence=1.0, raw_response=raw,
duration_ms=duration_ms,
)
return VisionResult(
success=True, issues_found=issues,
confidence=0.85 if issues else 0.5,
raw_response=raw,
duration_ms=duration_ms,
)
except requests.Timeout:
try:
mark_unhealthy(host)
except Exception:
pass
duration_ms = int((time.monotonic() - start) * 1000)
return VisionResult(
success=False, duration_ms=duration_ms,
error=f'timeout ({PPT_VISION_TIMEOUT}s)',
)
except Exception as e:
try:
mark_unhealthy(host)
except Exception:
pass
duration_ms = int((time.monotonic() - start) * 1000)
return VisionResult(
success=False, duration_ms=duration_ms,
error=f'{type(e).__name__}: {str(e)[:200]}',
)
# 全域單例
ppt_vision_service = PPTVisionService()
def audit_recent_ppts(reports_dir: str = 'reports', hours: int = 24,
max_files: int = 10) -> Dict[str, Any]:
"""Phase 26 整合 hook — 每日 22:00 cron 跑:掃 reports/ 當天新增 .pptx 跑視覺檢查。
Args:
reports_dir: PPT 輸出目錄
hours: 掃過去 N 小時內的檔
max_files: 一次最多查 N 個檔(避免一次跑太久)
Returns:
{
'audited_files': [...],
'total_issues': int,
'errors': [...],
}
"""
import os
import time
summary = {'audited_files': [], 'total_issues': 0, 'errors': []}
if not is_ppt_vision_enabled():
summary['errors'].append('PPT_VISION_ENABLED=false')
return summary
if not os.path.isdir(reports_dir):
summary['errors'].append(f'{reports_dir} not found')
return summary
# 掃當天新增 .pptx
cutoff = time.time() - hours * 3600
pptx_files = []
for f in os.listdir(reports_dir):
if not f.lower().endswith('.pptx'):
continue
full = os.path.join(reports_dir, f)
try:
if os.path.getmtime(full) >= cutoff:
pptx_files.append((os.path.getmtime(full), full))
except OSError:
continue
pptx_files.sort(reverse=True)
pptx_files = pptx_files[:max_files]
svc = PPTVisionService()
for mtime, path in pptx_files:
try:
result = svc.check_ppt_file(path)
entry = {
'path': path,
'slides_checked': result.get('slides_checked', 0),
'issues': result.get('total_issues', 0),
'issues_by_slide': result.get('issues_by_slide', []),
'error': result.get('error'),
}
summary['audited_files'].append(entry)
summary['total_issues'] += entry['issues']
if entry['error']:
summary['errors'].append(f"{path}: {entry['error']}")
except Exception as exc:
summary['errors'].append(f'{path}: {type(exc).__name__}: {str(exc)[:150]}')
return summary
def push_ppt_audit_to_telegram(summary: Dict[str, Any]) -> bool:
"""有 issues 才推 Telegram(避免靜默報「無問題」洗版)"""
if summary['total_issues'] <= 0:
return False
try:
from services.telegram_templates import _send_telegram_raw
except Exception:
return False
lines = [f"🔍 PPT 視覺審核({len(summary['audited_files'])} 份)"]
lines.append('━' * 18)
for entry in summary['audited_files']:
if entry['issues'] > 0:
fname = os.path.basename(entry['path']) if hasattr(__import__('os'), 'path') else entry['path']
import os as _os
fname = _os.path.basename(entry['path'])
lines.append(f"\n📊 {fname} ({entry['slides_checked']} slides, "
f"{entry['issues']} issues)")
for slide_num, issues in entry['issues_by_slide'][:3]: # 每檔最多列 3 張
for iss in issues[:2]: # 每張 slide 最多列 2 個 issue
lines.append(f" Slide {slide_num}: {iss[:120]}")
msg = '\n'.join(lines)
try:
_send_telegram_raw(msg)
return True
except Exception:
return False
__all__ = [
'PPTVisionService',
'VisionResult',
'ppt_vision_service',
'is_ppt_vision_enabled',
'PPT_VISION_SYSTEM_PROMPT',
'audit_recent_ppts',
'push_ppt_audit_to_telegram',
]