補強 PPT 視覺 QA 補跑與全類型審核
All checks were successful
CD Pipeline / deploy (push) Successful in 1m5s

This commit is contained in:
OoO
2026-05-18 20:57:34 +08:00
parent be0dd41472
commit aa8c2c7148
8 changed files with 414 additions and 132 deletions

View File

@@ -4,6 +4,7 @@
================================================================================
【已完成】
- V10.211 補 `/observability/ppt_audit_history` 全類型視覺 QA審核歷史不再限 daily頁面新增「立即視覺 QA」非阻塞補跑結果寫入 `ppt_audit_results`;模型失敗時也保留 slide error避免產線狀態只剩空白。
- V10.210 補 `/observability/ppt_audit_history` 審核歷史同頁回放:每筆 daily 視覺審核紀錄的動作欄新增「回放」按鈕,沿用 PDF 預覽抽屜並保留下載/開新頁,讓問題追查不必再回檔案表找簡報。
- V10.208 修正 `/observability/ppt_audit_history` 同頁預覽抽屜 selectorModal 標題改用獨立 `data-ppt-preview-modal-title`,避免與多個預覽連結的資料屬性衝突。
- V10.207 強化 `/observability/ppt_audit_history` 同頁線上預覽:所有可預覽簡報按鈕改為開啟頁內 PDF 預覽抽屜,保留開新頁與下載,降低產線頁來回跳轉成本並改善手機操作。

View File

@@ -320,7 +320,7 @@ YOUTUBE_API_KEY = os.getenv('YOUTUBE_API_KEY', '')
# ==========================================
# 系統版本與路徑
# ==========================================
SYSTEM_VERSION = "V10.210"
SYSTEM_VERSION = "V10.211"
LOG_FILE_PATH = os.path.join(BASE_DIR, 'logs/system.log')
public_url = PUBLIC_URL # 用於模板顯示

View File

@@ -1860,6 +1860,35 @@ def ppt_audit_generate_missing():
return jsonify({'ok': False, 'error': f'{type(e).__name__}: {str(e)[:200]}'}), 500
@admin_observability_bp.route('/ppt_audit/run_vision', methods=['POST'])
@login_required
def ppt_audit_run_vision():
"""Queue a non-blocking visual QA run for selected generated PPT files."""
try:
from services.ppt_vision_service import start_ppt_vision_audit_background
data = request.get_json(silent=True) or {}
filenames = data.get('filenames') or []
if isinstance(filenames, str):
filenames = [filenames]
filenames = [str(name) for name in filenames if str(name).lower().endswith('.pptx')]
max_files = data.get('max_files') or (len(filenames) if filenames else 10)
try:
max_files = max(1, min(int(max_files), 20))
except Exception:
max_files = 10
result = start_ppt_vision_audit_background(
reports_dir=None,
filenames=filenames,
max_files=max_files,
hours=24,
)
return jsonify(result), 202 if result.get('status') == 'queued' else 200
except Exception as e:
return jsonify({'ok': False, 'error': f'{type(e).__name__}: {str(e)[:200]}'}), 500
def _resolve_ppt_report_path(filename: str):
"""在 REPORTS_DIR 內解析簡報檔名,並阻擋路徑逃逸。"""
import os
@@ -2349,7 +2378,7 @@ def _build_ppt_pipeline_view(files, auto_generation, audit_stats, generation_run
qa_status = 'ready' if pass_rate >= 80 and audit_issues == 0 else 'partial'
else:
qa_value = '待審核'
qa_meta = '每日報表才進入 minicpm-v 視覺 QA'
qa_meta = '可立即補跑,或等待 22:00 排程'
qa_status = 'planned'
stages = [
@@ -2757,40 +2786,45 @@ def ppt_audit_history():
except Exception as e:
error = f'{type(e).__name__}: {str(e)[:200]}'
# Phase 38讀指定月份 daily audit 歷史(僅限 daily 類型)
if report_type == 'daily':
audit_filter_sql = ""
audit_params = {'month_start': month_start, 'month_end': month_end}
if report_prefix != 'all':
audit_filter_sql = " AND pptx_filename LIKE :audit_prefix"
audit_params['audit_prefix'] = f"{report_prefix}%"
# Phase 38+:讀指定月份 / 指定簡報類型 audit 歷史
try:
session = get_session()
try:
session = get_session()
try:
audit_rows = session.execute(
sa_text("""
SELECT audited_at, pptx_filename, audit_status,
issues_count, confidence, duration_ms, error_msg
FROM ppt_audit_results
WHERE audited_at >= :month_start
AND audited_at < :month_end
AND pptx_filename LIKE 'ocbot_daily_%'
ORDER BY audited_at DESC
LIMIT 1000
"""),
{'month_start': month_start, 'month_end': month_end},
).fetchall()
audit_records = [
{
'audited_at': r[0].strftime('%Y-%m-%d %H:%M'),
'pptx_filename': r[1],
'audit_status': r[2],
'issues_count': int(r[3] or 0),
'confidence': float(r[4] or 0),
'duration_ms': int(r[5] or 0),
'error_msg': r[6],
}
for r in audit_rows
]
finally:
session.close()
except Exception:
logger.debug("PPT audit history table unavailable; rendering empty audit history", exc_info=True)
audit_rows = session.execute(
sa_text(f"""
SELECT audited_at, pptx_filename, audit_status,
issues_count, confidence, duration_ms, error_msg
FROM ppt_audit_results
WHERE audited_at >= :month_start
AND audited_at < :month_end
{audit_filter_sql}
ORDER BY audited_at DESC
LIMIT 1000
"""),
audit_params,
).fetchall()
audit_records = [
{
'audited_at': r[0].strftime('%Y-%m-%d %H:%M'),
'pptx_filename': r[1],
'audit_status': r[2],
'issues_count': int(r[3] or 0),
'confidence': float(r[4] or 0),
'duration_ms': int(r[5] or 0),
'error_msg': r[6],
}
for r in audit_rows
]
finally:
session.close()
except Exception:
logger.debug("PPT audit history table unavailable; rendering empty audit history", exc_info=True)
# PPT vision 啟用狀態
vision_status = {'enabled': False, 'ready': False, 'blockers': ['視覺狀態讀取失敗']}
@@ -2804,65 +2838,64 @@ def ppt_audit_history():
# Phase 47 K-6: 月報表統計 + top failure files
audit_30d_stats = {}
top_failure_files = []
if report_type == 'daily':
try:
s_ppt = get_session()
try:
s_ppt = get_session()
try:
stat_row = s_ppt.execute(
sa_text("""
SELECT COUNT(*),
COUNT(*) FILTER (WHERE audit_status = 'passed'),
COUNT(*) FILTER (WHERE audit_status = 'failed'),
COUNT(*) FILTER (WHERE audit_status = 'skipped'),
COUNT(*) FILTER (WHERE audit_status = 'error'),
COALESCE(AVG(confidence) FILTER (WHERE audit_status = 'passed'), 0),
COALESCE(SUM(issues_count), 0)
FROM ppt_audit_results
WHERE audited_at >= :month_start
AND audited_at < :month_end
AND pptx_filename LIKE 'ocbot_daily_%'
"""),
{'month_start': month_start, 'month_end': month_end},
).fetchone()
total_30d = int(stat_row[0] or 0)
audit_30d_stats = {
'total': total_30d,
'passed': int(stat_row[1] or 0),
'failed': int(stat_row[2] or 0),
'skipped': int(stat_row[3] or 0),
'error': int(stat_row[4] or 0),
'avg_confidence': round(float(stat_row[5] or 0), 3),
'total_issues': int(stat_row[6] or 0),
'pass_rate': (float(stat_row[1] or 0) / total_30d * 100) if total_30d else 0,
}
stat_row = s_ppt.execute(
sa_text(f"""
SELECT COUNT(*),
COUNT(*) FILTER (WHERE audit_status = 'passed'),
COUNT(*) FILTER (WHERE audit_status = 'failed'),
COUNT(*) FILTER (WHERE audit_status = 'skipped'),
COUNT(*) FILTER (WHERE audit_status = 'error'),
COALESCE(AVG(confidence) FILTER (WHERE audit_status = 'passed'), 0),
COALESCE(SUM(issues_count), 0)
FROM ppt_audit_results
WHERE audited_at >= :month_start
AND audited_at < :month_end
{audit_filter_sql}
"""),
audit_params,
).fetchone()
total_30d = int(stat_row[0] or 0)
audit_30d_stats = {
'total': total_30d,
'passed': int(stat_row[1] or 0),
'failed': int(stat_row[2] or 0),
'skipped': int(stat_row[3] or 0),
'error': int(stat_row[4] or 0),
'avg_confidence': round(float(stat_row[5] or 0), 3),
'total_issues': int(stat_row[6] or 0),
'pass_rate': (float(stat_row[1] or 0) / total_30d * 100) if total_30d else 0,
}
top_fail_rows = s_ppt.execute(
sa_text("""
SELECT pptx_filename, COUNT(*) AS attempts,
SUM(issues_count) AS total_issues,
MAX(audited_at) AS last_audit
FROM ppt_audit_results
WHERE audit_status IN ('failed', 'error')
AND audited_at >= :month_start
AND audited_at < :month_end
AND pptx_filename LIKE 'ocbot_daily_%'
GROUP BY pptx_filename
ORDER BY attempts DESC, total_issues DESC LIMIT 10
"""),
{'month_start': month_start, 'month_end': month_end},
).fetchall()
top_failure_files = [
{
'filename': r[0], 'attempts': int(r[1] or 0),
'total_issues': int(r[2] or 0),
'last_audit': r[3].strftime('%Y-%m-%d %H:%M') if r[3] else '',
}
for r in top_fail_rows
]
finally:
s_ppt.close()
except Exception:
pass
top_fail_rows = s_ppt.execute(
sa_text(f"""
SELECT pptx_filename, COUNT(*) AS attempts,
SUM(issues_count) AS total_issues,
MAX(audited_at) AS last_audit
FROM ppt_audit_results
WHERE audit_status IN ('failed', 'error')
AND audited_at >= :month_start
AND audited_at < :month_end
{audit_filter_sql}
GROUP BY pptx_filename
ORDER BY attempts DESC, total_issues DESC LIMIT 10
"""),
audit_params,
).fetchall()
top_failure_files = [
{
'filename': r[0], 'attempts': int(r[1] or 0),
'total_issues': int(r[2] or 0),
'last_audit': r[3].strftime('%Y-%m-%d %H:%M') if r[3] else '',
}
for r in top_fail_rows
]
finally:
s_ppt.close()
except Exception:
pass
# Phase 41 E-2: 對最近 3 筆 failed audit 跑 RAG 找相似修法
rag_fixes = []
@@ -2950,6 +2983,11 @@ def ppt_audit_history():
vision_status=vision_status,
audit_records=audit_records,
)
vision_audit_filenames = [
item.get('name')
for item in files
if item.get('file_exists') and item.get('is_valid_ppt') and item.get('name')
][:10]
return render_template(
'admin/ppt_audit_history.html',
@@ -2973,6 +3011,7 @@ def ppt_audit_history():
auto_generation_missing_report_types=auto_generation.get('missing_report_types', []),
generation_runs=generation_runs,
pipeline_view=pipeline_view,
vision_audit_filenames=vision_audit_filenames,
error=error,
)

View File

@@ -22,8 +22,9 @@ import time
import base64
import logging
import shutil
import threading
from dataclasses import dataclass, field
from typing import Optional, Dict, Any, List
from typing import Optional, Dict, Any, List, Sequence
logger = logging.getLogger(__name__)
@@ -32,6 +33,8 @@ logger = logging.getLogger(__name__)
# ─────────────────────────────────────────────────────────────────────────────
PPT_VISION_MODEL = os.getenv('PPT_VISION_MODEL', 'minicpm-v:latest')
PPT_VISION_TIMEOUT = int(os.getenv('PPT_VISION_TIMEOUT', '60'))
_AUDIT_LOCK = threading.Lock()
_LAST_AUDIT_RUN: Dict[str, Any] | None = None
def is_ppt_vision_enabled() -> bool:
@@ -142,8 +145,22 @@ class PPTVisionService:
result['error'] = 'libreoffice not installed (skip vision check)'
return result
def _finish_with_error(message: str, duration_ms: int = 0) -> Dict[str, Any]:
result['error'] = message
try:
self._persist_audit_result(
pptx_path=pptx_path,
result=result,
avg_confidence=0.0,
duration_ms=duration_ms,
)
except Exception as e:
logger.warning(f"[PPTVision] persist audit result failed: {e}")
return result
# 1. LibreOffice 轉 png
with tempfile.TemporaryDirectory() as tmpdir:
convert_started = time.monotonic()
try:
proc = subprocess.run(
[converter, '--headless', '--convert-to', 'png',
@@ -151,17 +168,23 @@ class PPTVisionService:
capture_output=True, timeout=60,
)
if proc.returncode != 0:
result['error'] = f'libreoffice convert failed: {proc.stderr.decode()[:200]}'
return result
return _finish_with_error(
f'libreoffice convert failed: {proc.stderr.decode()[:200]}',
int((time.monotonic() - convert_started) * 1000),
)
except FileNotFoundError:
result['error'] = 'libreoffice not installed (skip vision check)'
return result
except subprocess.TimeoutExpired:
result['error'] = 'libreoffice convert timeout (60s)'
return result
return _finish_with_error(
'libreoffice convert timeout (60s)',
int((time.monotonic() - convert_started) * 1000),
)
except Exception as e:
result['error'] = f'{type(e).__name__}: {str(e)[:200]}'
return result
return _finish_with_error(
f'{type(e).__name__}: {str(e)[:200]}',
int((time.monotonic() - convert_started) * 1000),
)
# LibreOffice 對 .pptx 預設只輸出第一頁;多頁需 --convert-to png:impress_png_Export
png_files = sorted([
@@ -170,13 +193,16 @@ class PPTVisionService:
])
if not png_files:
result['error'] = 'libreoffice 未產出 png (可能需要 --convert-to png:impress_png_Export)'
return result
return _finish_with_error(
'libreoffice 未產出 png (可能需要 --convert-to png:impress_png_Export)',
int((time.monotonic() - convert_started) * 1000),
)
# 2. 對前 N 張跑 check_image
import time as _time
t0 = _time.monotonic()
confidences = []
slide_errors = []
for idx, png in enumerate(png_files[:max_slides]):
try:
vr = self.check_image(png)
@@ -186,10 +212,18 @@ class PPTVisionService:
if vr.issues_found:
result['total_issues'] += len(vr.issues_found)
result['issues_by_slide'].append((idx + 1, vr.issues_found))
else:
slide_errors.append(f"slide {idx + 1}: {vr.error or 'vision model failed'}")
except Exception as exc:
message = f"slide {idx + 1}: {type(exc).__name__}: {str(exc)[:160]}"
slide_errors.append(message)
logger.warning(f"[PPTVision] slide {idx+1} check failed: {exc}")
result['success'] = result['slides_checked'] > 0
if not result['success'] and slide_errors:
result['error'] = ''.join(slide_errors[:3])
if slide_errors:
result['slide_errors'] = slide_errors
duration_ms = int((_time.monotonic() - t0) * 1000)
# Phase 38寫入 ppt_audit_results 留歷史(失敗安全)
@@ -365,7 +399,8 @@ ppt_vision_service = PPTVisionService()
def audit_recent_ppts(reports_dir: str | None = None, hours: int = 24,
max_files: int = 10) -> Dict[str, Any]:
max_files: int = 10,
filenames: Sequence[str] | None = None) -> Dict[str, Any]:
"""Phase 26 整合 hook — 每日 22:00 cron 跑:掃 reports/ 當天新增 .pptx 跑視覺檢查。
Args:
@@ -396,18 +431,30 @@ def audit_recent_ppts(reports_dir: str | None = None, hours: int = 24,
summary['errors'].append(f'{reports_dir} not found')
return summary
# 掃當天新增 .pptx
requested_names = {
os.path.basename(str(name))
for name in (filenames or [])
if str(name).lower().endswith('.pptx')
}
# 掃當天新增 .pptx若指定 filenames直接審指定檔不受 hours 視窗限制。
cutoff = time.time() - hours * 3600
pptx_files = []
for f in os.listdir(reports_dir):
if not f.lower().endswith('.pptx'):
continue
if requested_names and f not in requested_names:
continue
full = os.path.join(reports_dir, f)
try:
if os.path.getmtime(full) >= cutoff:
if requested_names or os.path.getmtime(full) >= cutoff:
pptx_files.append((os.path.getmtime(full), full))
except OSError:
continue
if requested_names:
found_names = {os.path.basename(path) for _mtime, path in pptx_files}
for missing in sorted(requested_names - found_names):
summary['errors'].append(f'{missing}: file not found')
pptx_files.sort(reverse=True)
pptx_files = pptx_files[:max_files]
@@ -432,6 +479,69 @@ def audit_recent_ppts(reports_dir: str | None = None, hours: int = 24,
return summary
def start_ppt_vision_audit_background(
*,
reports_dir: str | None = None,
hours: int = 24,
max_files: int = 10,
filenames: Sequence[str] | None = None,
) -> Dict[str, Any]:
"""Queue a non-blocking PPT vision audit run for the admin UI."""
global _LAST_AUDIT_RUN
if _AUDIT_LOCK.locked():
return {
'ok': True,
'status': 'already_running',
'message': 'PPT vision audit is already running.',
'last_run': _LAST_AUDIT_RUN,
}
clean_filenames = [
os.path.basename(str(name))
for name in (filenames or [])
if str(name).lower().endswith('.pptx')
]
def _run():
global _LAST_AUDIT_RUN
with _AUDIT_LOCK:
started_at = time.strftime('%Y-%m-%d %H:%M:%S')
try:
summary = audit_recent_ppts(
reports_dir=reports_dir,
hours=hours,
max_files=max_files,
filenames=clean_filenames or None,
)
_LAST_AUDIT_RUN = {
'ok': True,
'status': 'completed',
'started_at': started_at,
'finished_at': time.strftime('%Y-%m-%d %H:%M:%S'),
'summary': summary,
}
except Exception as exc:
_LAST_AUDIT_RUN = {
'ok': False,
'status': 'error',
'started_at': started_at,
'finished_at': time.strftime('%Y-%m-%d %H:%M:%S'),
'error': f'{type(exc).__name__}: {str(exc)[:200]}',
}
logger.error("[PPTVision] background audit failed: %s", exc, exc_info=True)
thread = threading.Thread(target=_run, name='ppt-vision-audit', daemon=True)
thread.start()
return {
'ok': True,
'status': 'queued',
'message': 'PPT vision audit queued.',
'filenames': clean_filenames,
'max_files': max_files,
}
def push_ppt_audit_to_telegram(summary: Dict[str, Any]) -> bool:
"""有 issues 才推 Telegram避免靜默報「無問題」洗版"""
if summary['total_issues'] <= 0:
@@ -470,5 +580,6 @@ __all__ = [
'get_ppt_vision_runtime_status',
'PPT_VISION_SYSTEM_PROMPT',
'audit_recent_ppts',
'start_ppt_vision_audit_background',
'push_ppt_audit_to_telegram',
]

View File

@@ -8,7 +8,6 @@
{% block ewooo_content %}
{% import "admin/_observability_labels.html" as obs_label %}
{% set report_is_daily = report_type == 'daily' %}
<div class="container-fluid mt-3">
<section class="ppt-hero">
@@ -28,15 +27,15 @@
</div>
<div class="ppt-signal">
<div class="ppt-label">審核紀錄</div>
<span class="ppt-value {% if report_is_daily and audit_30d_stats and audit_30d_stats.pass_rate >= 80 %}status-good{% elif report_is_daily and audit_30d_stats and audit_30d_stats.pass_rate >= 60 %}status-warn{% elif report_is_daily and audit_30d_stats %}status-bad{% else %}status-blue{% endif %}">
{% if report_is_daily %}{{ audit_30d_stats.total if audit_30d_stats else '—' }}{% else %}—{% endif %}
<span class="ppt-value {% if audit_30d_stats and audit_30d_stats.pass_rate >= 80 %}status-good{% elif audit_30d_stats and audit_30d_stats.pass_rate >= 60 %}status-warn{% elif audit_30d_stats %}status-bad{% else %}status-blue{% endif %}">
{{ audit_30d_stats.total if audit_30d_stats else '—' }}
</span>
<small class="text-muted">{{ report_is_daily and '僅 daily' or '切到 daily 可查看' }}</small>
<small class="text-muted">{{ selected_report_type.label }}</small>
</div>
<div class="ppt-signal">
<div class="ppt-label">問題數</div>
<span class="ppt-value {% if report_is_daily and audit_30d_stats and audit_30d_stats.total_issues > 0 %}status-warn{% elif report_is_daily and audit_30d_stats %}status-good{% else %}status-blue{% endif %}">
{% if report_is_daily %}{{ audit_30d_stats.total_issues if audit_30d_stats else '—' }}{% else %}—{% endif %}
<span class="ppt-value {% if audit_30d_stats and audit_30d_stats.total_issues > 0 %}status-warn{% elif audit_30d_stats %}status-good{% else %}status-blue{% endif %}">
{{ audit_30d_stats.total_issues if audit_30d_stats else '—' }}
</span>
<small class="text-muted">視覺問題數</small>
</div>
@@ -98,6 +97,9 @@
</div>
<div class="ppt-workbench-actions">
<small class="text-muted">最新 {{ files[:4]|length }} 份,直接線上預覽或下載原始 PPTX</small>
<button class="btn btn-outline-primary btn-sm" type="button" data-ppt-run-vision {% if not vision_status.ready or not vision_audit_filenames %}disabled{% endif %}>
<i class="fas fa-eye me-1"></i>立即視覺 QA
</button>
{% if pipeline_view.uncached_preview_count > 0 %}
<button class="btn btn-outline-primary btn-sm" type="button" data-ppt-prewarm-all>
<i class="fas fa-fire me-1"></i>預熱本頁 PDF
@@ -242,9 +244,14 @@
<div class="ppt-label">Production Command Center</div>
<h2 class="ppt-panel-title">簡報產線控制台</h2>
</div>
<button class="btn btn-sm btn-outline-primary" type="button" data-ppt-generate-missing {% if not auto_generation.enabled or auto_generation.missing_count == 0 %}disabled{% endif %}>
<i class="fas fa-wand-magic-sparkles me-1" aria-hidden="true"></i>補齊缺漏簡報
</button>
<div class="ppt-panel-actions">
<button class="btn btn-sm btn-outline-primary" type="button" data-ppt-run-vision {% if not vision_status.ready or not vision_audit_filenames %}disabled{% endif %}>
<i class="fas fa-eye me-1" aria-hidden="true"></i>立即視覺 QA
</button>
<button class="btn btn-sm btn-outline-primary" type="button" data-ppt-generate-missing {% if not auto_generation.enabled or auto_generation.missing_count == 0 %}disabled{% endif %}>
<i class="fas fa-wand-magic-sparkles me-1" aria-hidden="true"></i>補齊缺漏簡報
</button>
</div>
</div>
<div class="ppt-panel-body">
<div class="ppt-pipeline-layout">
@@ -307,7 +314,7 @@
</div>
<div class="ppt-auto-status small text-muted mt-3" data-ppt-auto-status>
{% if auto_generation.enabled %}
{{ auto_generation.cadence_summary }} 會定期產出並寫入 DB目前缺漏 {{ auto_generation.missing_count }} 類。
{{ auto_generation.cadence_summary }} 會定期產出並寫入 DB目前缺漏 {{ auto_generation.missing_count }} 類。視覺 QA 可立即補跑,或等待每日 22:00 排程。
{% else %}
PPT_AUTO_GENERATION_ENABLED=false已停用自動補齊。
{% endif %}
@@ -364,7 +371,6 @@
</div>
</div>
<div class="table-responsive">
{% if report_is_daily %}
<table class="table table-sm mb-0">
<thead class="table-light">
<tr><th>時間</th><th>檔名</th><th>結果</th><th class="text-end">問題</th><th class="text-end">信心</th><th class="text-end">耗時</th><th>錯誤</th><th>動作</th></tr>
@@ -412,22 +418,10 @@
</td>
</tr>
{% else %}
<tr><td colspan="8" class="text-center text-muted">目前無 daily 審核歷史;請確認 {{ report_month }} 是否已完成 22:00 排程。</td></tr>
<tr><td colspan="8" class="text-center text-muted">目前無 {{ selected_report_type.label }} 審核歷史;可按「立即視覺 QA」補跑或等待每日 22:00 排程。</td></tr>
{% endfor %}
</tbody>
</table>
{% else %}
<div class="p-3">
<div class="ppt-empty p-3">
<div class="mb-2"><span class="badge bg-info text-dark">非每日型資料</span></div>
<p class="mb-2"><strong>只有「每日日報」會進入視覺審核流程。</strong></p>
<p class="mb-2">目前此頁只顯示每日以外的簡報檔案;若要追蹤視覺結果,請切到「每日日報」。</p>
<a class="btn btn-outline-primary btn-sm" href="{{ url_for('admin_observability.ppt_audit_history', month=report_month, report_type='daily') }}">
<i class="fas fa-calendar-day me-1"></i>切到每日日報
</a>
</div>
</div>
{% endif %}
</div>
</article>
<article class="ppt-table-shell">
@@ -529,7 +523,6 @@
</article>
</div>
<aside class="ppt-stack">
{% if report_is_daily %}
{% if audit_30d_stats and audit_30d_stats.total > 0 %}
<article class="ppt-panel">
<div class="ppt-panel-head">
@@ -564,12 +557,11 @@
<div class="ppt-panel-body">
<div class="ppt-empty p-3">
<p class="mb-1"><strong>尚未形成 daily 審核統計。</strong></p>
<p class="mb-0">排程完成後,這裡會顯示本月通過率、失敗檔案與修復建議。</p>
<p class="mb-0">排程或立即視覺 QA 完成後,這裡會顯示本月通過率、失敗檔案與修復建議。</p>
</div>
</div>
</article>
{% endif %}
{% endif %}
</aside>
</section>
@@ -603,7 +595,7 @@
<li>PPT_VISION_ENABLED=false</li>
<li>188 主機需安裝 LibreOffice</li>
<li>需 Ollama 拉取 minicpm-v 模型</li>
<li>啟用後每日 22:00 排程寫入 ppt_audit_results</li>
<li>啟用後可立即補跑,或由每日 22:00 排程寫入 ppt_audit_results</li>
</ul>
</div>
{% elif files|length == 0 %}
@@ -648,6 +640,7 @@
</div>
<template id="obs-ppt-audit-data">{{ audit_30d_stats | default({}) | tojson }}</template>
<template id="obs-ppt-audit-filenames">{{ vision_audit_filenames | default([]) | tojson }}</template>
<script src="{{ url_for('static', filename='js/analysis-chart-theme.js') }}"></script>
<script src="{{ url_for('static', filename='js/observability-charts.js') }}"></script>
{% endblock %}

View File

@@ -373,6 +373,55 @@ def test_ppt_audit_history_audit_rows_include_inline_replay(client, monkeypatch,
assert '回放' in html
def test_ppt_audit_history_weekly_rows_include_visual_audit(client, monkeypatch, tmp_path):
"""非 daily 簡報也應顯示自己的視覺 QA 歷史,不再要求切回 daily。"""
import zipfile
from datetime import datetime
from unittest.mock import MagicMock
from routes import admin_observability_routes as mod
reports_dir = tmp_path / 'reports'
reports_dir.mkdir()
pptx = reports_dir / 'ocbot_weekly_20260518.pptx'
with zipfile.ZipFile(pptx, 'w') as zf:
zf.writestr('[Content_Types].xml', '<Types></Types>')
monkeypatch.setenv('REPORTS_DIR', str(reports_dir))
class FakeSession:
def execute(self, statement, _params=None):
sql = str(statement)
result = MagicMock()
if 'FROM ppt_reports' in sql:
result.fetchall.return_value = []
elif 'SELECT audited_at, pptx_filename' in sql:
result.fetchall.return_value = [
(datetime(2026, 5, 18, 22, 7), 'ocbot_weekly_20260518.pptx', 'passed', 0, 0.91, 1800, '')
]
elif 'COALESCE(AVG(confidence)' in sql:
result.fetchone.return_value = (1, 1, 0, 0, 0, 0.91, 0)
elif 'GROUP BY pptx_filename' in sql:
result.fetchall.return_value = []
else:
result.fetchall.return_value = []
result.fetchone.return_value = (0,)
return result
def close(self):
return None
monkeypatch.setattr(mod, 'get_session', lambda: FakeSession())
r = client.get('/observability/ppt_audit_history?month=2026-05&report_type=weekly')
html = r.data.decode('utf-8')
assert r.status_code == 200
assert 'ocbot_weekly_20260518.pptx' in html
assert '審核回放 · ocbot_weekly_20260518.pptx' in html
assert '只有「每日日報」會進入視覺審核流程' not in html
assert 'data-ppt-run-vision' in html
def test_ppt_audit_history_shows_preview_prewarm_action(client, monkeypatch, tmp_path):
"""未快取 PDF 的 PPTX 要能在產線清單直接預熱預覽。"""
import zipfile
@@ -432,6 +481,30 @@ def test_ppt_audit_file_prewarm_builds_preview_cache(client, monkeypatch, tmp_pa
assert data['message'] == 'PDF 預覽快取已建立'
def test_ppt_audit_run_vision_queues_background_audit(client, monkeypatch):
"""立即視覺 QA 端點只排入背景任務,不讓瀏覽器等待模型跑完。"""
from services import ppt_vision_service as svc
captured = {}
def fake_start(**kwargs):
captured.update(kwargs)
return {'ok': True, 'status': 'queued', 'message': 'PPT vision audit queued.'}
monkeypatch.setattr(svc, 'start_ppt_vision_audit_background', fake_start)
r = client.post(
'/observability/ppt_audit/run_vision',
json={'filenames': ['ocbot_daily_20260518.pptx'], 'max_files': 1},
)
data = r.get_json()
assert r.status_code == 202
assert data['ok'] is True
assert captured['filenames'] == ['ocbot_daily_20260518.pptx']
assert captured['max_files'] == 1
# ──────────────────────────────────────────────────────────────────────────
# /observability/host_health
# ──────────────────────────────────────────────────────────────────────────

View File

@@ -567,6 +567,13 @@ body.ppt-preview-open {
letter-spacing: 0;
}
.ppt-panel-actions {
display: flex;
justify-content: flex-end;
gap: var(--momo-space-2, 8px);
flex-wrap: wrap;
}
.ppt-panel-body {
padding: var(--momo-space-4, 16px);
}
@@ -901,6 +908,7 @@ body.ppt-preview-open {
}
.ppt-panel-head,
.ppt-panel-actions,
.ppt-table-title,
.ppt-workbench-head,
.ppt-workbench-actions,

View File

@@ -604,6 +604,7 @@
const previewDownload = previewModal ? previewModal.querySelector('[data-ppt-preview-download]') : null;
const previewLoading = previewModal ? previewModal.querySelector('[data-ppt-preview-loading]') : null;
const previewFrameWrap = previewModal ? previewModal.querySelector('.ppt-preview-frame-wrap') : null;
const visionAuditFilenames = readJson('obs-ppt-audit-filenames', []);
function closePreviewModal() {
if (!previewModal) return;
@@ -668,6 +669,62 @@
window.triggerAiderHeal(button.dataset.pptFilename || '', button.dataset.pptError || '');
});
});
async function triggerVisionAudit(button) {
const filenames = Array.isArray(visionAuditFilenames) ? visionAuditFilenames.filter(Boolean) : [];
if (!filenames.length) {
if (pageStatus) pageStatus.textContent = '目前沒有可送進視覺 QA 的 PPTX 檔案。';
return;
}
const buttons = Array.from(document.querySelectorAll('[data-ppt-run-vision]'));
const originalHtml = button ? button.innerHTML : '';
buttons.forEach(item => {
item.disabled = true;
item.innerHTML = '<i class="fas fa-spinner fa-spin me-1"></i>QA 排入中';
});
if (pageStatus) {
pageStatus.classList.add('is-working');
pageStatus.textContent = `已準備送出 ${filenames.length} 份簡報進行視覺 QA完成後會寫入資料庫。`;
}
try {
const response = await postJson('/observability/ppt_audit/run_vision', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ filenames, max_files: filenames.length })
});
const data = await response.json();
if (!response.ok || !data.ok) {
throw new Error(data.error || data.message || '視覺 QA 送出失敗');
}
buttons.forEach(item => {
item.innerHTML = data.status === 'already_running'
? '<i class="fas fa-clock me-1"></i>QA 執行中'
: '<i class="fas fa-check me-1"></i>QA 已排入';
});
if (pageStatus) {
pageStatus.textContent = data.status === 'already_running'
? '視覺 QA 已在執行中,請稍後重新整理查看資料庫結果。'
: `視覺 QA 已排入 ${filenames.length} 份簡報;審核結果會寫入 ppt_audit_results。`;
}
} catch (error) {
console.warn('ppt_vision_audit_queue_failed', error);
buttons.forEach(item => {
item.disabled = false;
item.innerHTML = item === button && originalHtml ? originalHtml : '<i class="fas fa-eye me-1"></i>立即視覺 QA';
});
if (pageStatus) {
pageStatus.classList.remove('is-working');
pageStatus.textContent = '視覺 QA 送出失敗,請稍後再試或查看系統日誌。';
}
}
}
document.querySelectorAll('[data-ppt-run-vision]').forEach(button => {
if (button.dataset.bound === '1') return;
button.dataset.bound = '1';
button.addEventListener('click', () => triggerVisionAudit(button));
});
function markPreviewCacheReady(filename) {
document.querySelectorAll('[data-ppt-preview-state]').forEach(node => {
if (node.dataset.pptPreviewState !== filename) return;