標示 PPT 預覽快取狀態
All checks were successful
CD Pipeline / deploy (push) Successful in 1m5s

This commit is contained in:
OoO
2026-05-18 19:48:14 +08:00
parent 96533a1c20
commit 7e2f1ac671
9 changed files with 128 additions and 5 deletions

View File

@@ -4,6 +4,7 @@
================================================================================
【已完成】
- V10.201 強化 `/observability/ppt_audit_history` 線上預覽可診斷性:產線清單不觸發轉檔即可顯示 PDF 預覽快取狀態Pipeline Health、Preview Workbench 與已產檔案表同步標記「PDF 快取 / 首次轉檔」。
- V10.199 讓 `/observability/ppt_audit_history` Action Queue 可直接處理異常:待補齊與異常優先項目新增單一報表「重跑」按鈕,透過既有非阻塞背景產線排入指定 report_type。
- V10.197 強化 `/observability/ppt_audit_history` Action Queue新增「異常優先」lane將產出失敗、PPTX 檔案異常、視覺 QA 失敗拉到最前面,並顯示錯誤訊息與可預覽入口。
- V10.196 補 `/observability/ppt_audit_history` Action Queue把待補齊、可預覽、視覺 QA、DB 寫入集中成工作隊列,讓使用者不用在多張卡與表格間找下一個處理點。

View File

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

View File

@@ -2255,6 +2255,10 @@ def _build_ppt_pipeline_view(files, auto_generation, audit_stats, generation_run
missing_count = _as_int(auto_generation.get('missing_count'))
coverage_pct = round((ready_count / total_count * 100), 1) if total_count else 0
valid_preview_count = sum(1 for item in files if item.get('file_exists') and item.get('is_valid_ppt'))
cached_preview_count = sum(
1 for item in files
if item.get('file_exists') and item.get('is_valid_ppt') and item.get('preview_cache_ready')
)
broken_file_count = sum(1 for item in files if item.get('file_exists') and not item.get('is_valid_ppt'))
db_backed_count = sum(1 for item in files if item.get('source') in ('database', 'both'))
run_error_count = sum(1 for item in generation_runs if item.get('status') == 'error')
@@ -2332,7 +2336,7 @@ def _build_ppt_pipeline_view(files, auto_generation, audit_stats, generation_run
'icon': 'desktop',
'label': '線上預覽',
'value': f'{valid_preview_count}',
'meta': f'{db_backed_count}含 DB 紀錄',
'meta': f'{cached_preview_count} PDF 快取',
'detail': latest_file.get('name') or '尚無可預覽檔案',
'status': 'error' if broken_file_count else ('ready' if valid_preview_count else 'planned'),
},
@@ -2435,8 +2439,12 @@ def _build_ppt_pipeline_view(files, auto_generation, audit_stats, generation_run
{
'title': item.get('name') or '未命名檔案',
'meta': item.get('mtime') or '時間未知',
'detail': f"{item.get('size_kb') if item.get('size_kb') is not None else ''} KB · {item.get('source') or 'filesystem'}",
'status_label': '線上預覽',
'detail': (
f"{item.get('size_kb') if item.get('size_kb') is not None else ''} KB · "
f"{item.get('source') or 'filesystem'} · "
f"{'PDF 已快取' if item.get('preview_cache_ready') else '開啟時轉檔'}"
),
'status_label': 'PDF 快取' if item.get('preview_cache_ready') else '線上預覽',
'filename': item.get('name'),
}
for item in preview_items[:4]
@@ -2487,6 +2495,7 @@ def _build_ppt_pipeline_view(files, auto_generation, audit_stats, generation_run
'missing_count': missing_count,
'coverage_pct': coverage_pct,
'valid_preview_count': valid_preview_count,
'cached_preview_count': cached_preview_count,
'broken_file_count': broken_file_count,
'db_backed_count': db_backed_count,
'run_error_count': run_error_count,
@@ -2578,6 +2587,31 @@ def ppt_audit_history():
except Exception as e:
return False, f'檢查失敗:{str(e)[:60]}'
def _guess_report_type(filename: str) -> str:
for opt in report_type_options:
prefix = opt.get('prefix')
if prefix and prefix != 'all' and filename.startswith(prefix):
return opt.get('key') or ''
return report_type if report_type != 'all' else ''
def _preview_cache_payload(file_path: str):
payload = {
'preview_cache_ready': False,
'preview_cache_size_kb': None,
'preview_cache_mtime': '',
}
try:
from services.ppt_preview_service import get_ppt_preview_cache_info
info = get_ppt_preview_cache_info(file_path)
payload['preview_cache_ready'] = bool(info.cache_exists)
payload['preview_cache_size_kb'] = info.cache_size_kb
if info.cache_mtime_ts:
payload['preview_cache_mtime'] = datetime.fromtimestamp(info.cache_mtime_ts).strftime('%Y-%m-%d %H:%M')
except Exception:
logger.debug("PPT preview cache state unavailable", exc_info=True)
return payload
try:
if not os.path.isdir(reports_dir):
error = f'{reports_dir} 目錄不存在'
@@ -2604,8 +2638,10 @@ def ppt_audit_history():
'mtime_ts': mtime,
'file_exists': True,
'file_path': full,
'report_type': _guess_report_type(f),
'is_valid_ppt': is_valid,
'file_error': check_msg,
**_preview_cache_payload(full),
}
except OSError:
continue
@@ -2659,6 +2695,7 @@ def ppt_audit_history():
'report_type': rpt_type,
'is_valid_ppt': is_valid,
'file_error': None if exists else check_msg,
**(_preview_cache_payload(candidate_path) if exists else {}),
}
finally:
session.close()

View File

@@ -24,6 +24,14 @@ class PPTPreviewResult:
error: str | None = None
@dataclass(frozen=True)
class PPTPreviewCacheInfo:
pdf_path: str | None = None
cache_exists: bool = False
cache_size_kb: float | None = None
cache_mtime_ts: float | None = None
def find_libreoffice_binary() -> str | None:
return shutil.which("libreoffice") or shutil.which("soffice")
@@ -37,6 +45,30 @@ def _preview_cache_path(pptx_path: Path, cache_dir: Path) -> Path:
return cache_dir / f"{safe_stem}_{cache_key}.pdf"
def get_ppt_preview_cache_info(
pptx_path: str | os.PathLike[str],
*,
cache_dir: str | os.PathLike[str] | None = None,
) -> PPTPreviewCacheInfo:
"""只讀取預期 PDF 快取狀態,不啟動 LibreOffice 轉檔。"""
source = Path(pptx_path)
if not source.is_file() or source.suffix.lower() != ".pptx":
return PPTPreviewCacheInfo()
target_dir = Path(cache_dir or os.getenv("PPT_PREVIEW_CACHE_DIR", "/app/data/ppt_previews"))
target_pdf = _preview_cache_path(source, target_dir)
if not target_pdf.is_file() or target_pdf.stat().st_size <= 0:
return PPTPreviewCacheInfo(pdf_path=str(target_pdf), cache_exists=False)
stat = target_pdf.stat()
return PPTPreviewCacheInfo(
pdf_path=str(target_pdf),
cache_exists=True,
cache_size_kb=round(stat.st_size / 1024, 1),
cache_mtime_ts=stat.st_mtime,
)
def build_ppt_preview(
pptx_path: str | os.PathLike[str],
*,

View File

@@ -110,6 +110,9 @@
<span>{{ f.size_kb if f.size_kb is not none else '—' }} KB</span>
<span>{{ f.source }}</span>
{% if f.file_exists and f.is_valid_ppt %}<span class="status-good">可預覽</span>{% else %}<span class="status-bad">需回補</span>{% endif %}
{% if f.file_exists and f.is_valid_ppt %}
{% if f.preview_cache_ready %}<span class="status-blue">PDF 快取</span>{% else %}<span class="status-warn">首次轉檔</span>{% endif %}
{% endif %}
</div>
<div class="ppt-file-actions">
{% if f.file_exists and f.is_valid_ppt %}
@@ -136,6 +139,7 @@
<div class="ppt-health-facts">
<span><strong>{{ pipeline_view.ready_count }}/{{ pipeline_view.total_count }}</strong> 定義覆蓋</span>
<span><strong>{{ pipeline_view.valid_preview_count }}</strong> 份可預覽</span>
<span><strong>{{ pipeline_view.cached_preview_count }}</strong> 份 PDF 快取</span>
<span><strong>{{ pipeline_view.audit_total }}</strong> 筆視覺 QA</span>
</div>
</div>
@@ -417,6 +421,17 @@
{% if f.file_error %}
<div><small class="text-muted">{{ f.file_error }}</small></div>
{% endif %}
{% if f.file_exists and f.is_valid_ppt %}
<div>
<small class="{{ 'status-blue' if f.preview_cache_ready else 'status-warn' }}">
{% if f.preview_cache_ready %}
PDF 預覽快取已建立{% if f.preview_cache_mtime %} · {{ f.preview_cache_mtime }}{% endif %}
{% else %}
PDF 預覽尚未快取,首次開啟會自動轉檔
{% endif %}
</small>
</div>
{% endif %}
</td>
<td>
<div class="ppt-file-actions">

View File

@@ -256,12 +256,19 @@ def test_ppt_audit_history_shows_ppt_schedule_and_db_runs(client, monkeypatch):
def test_ppt_audit_history_shows_recent_preview_workbench(client, monkeypatch, tmp_path):
"""有檔案時,頁面上方要先給可預覽簡報入口。"""
import zipfile
from pathlib import Path
from services import ppt_preview_service as preview_svc
reports_dir = tmp_path / 'reports'
cache_dir = tmp_path / 'preview-cache'
reports_dir.mkdir()
pptx = reports_dir / 'ocbot_daily_20260517.pptx'
with zipfile.ZipFile(pptx, 'w') as zf:
zf.writestr('[Content_Types].xml', '<Types></Types>')
monkeypatch.setenv('PPT_PREVIEW_CACHE_DIR', str(cache_dir))
cache_info = preview_svc.get_ppt_preview_cache_info(pptx)
Path(cache_info.pdf_path).parent.mkdir(parents=True, exist_ok=True)
Path(cache_info.pdf_path).write_bytes(b'%PDF-1.4\n')
monkeypatch.setenv('REPORTS_DIR', str(reports_dir))
@@ -272,6 +279,8 @@ def test_ppt_audit_history_shows_recent_preview_workbench(client, monkeypatch, t
assert '最近可預覽簡報' in html
assert 'ocbot_daily_20260517.pptx' in html
assert '線上預覽' in html
assert 'PDF 快取' in html
assert '1</strong> 份 PDF 快取' in html
def test_ppt_audit_file_view_renders_online_preview(client, monkeypatch, tmp_path):

View File

@@ -188,3 +188,23 @@ def test_ppt_preview_reports_missing_converter(monkeypatch, tmp_path):
assert result.ok is False
assert "LibreOffice" in (result.error or "")
def test_ppt_preview_cache_info_is_read_only(tmp_path):
from pathlib import Path
from services import ppt_preview_service as svc
pptx = tmp_path / "demo.pptx"
pptx.write_bytes(b"fake pptx")
cache_dir = tmp_path / "cache"
miss = svc.get_ppt_preview_cache_info(pptx, cache_dir=cache_dir)
assert miss.pdf_path
assert miss.cache_exists is False
Path(miss.pdf_path).parent.mkdir(parents=True, exist_ok=True)
Path(miss.pdf_path).write_bytes(b"%PDF-1.4\n")
hit = svc.get_ppt_preview_cache_info(pptx, cache_dir=cache_dir)
assert hit.cache_exists is True
assert hit.cache_size_kb is not None

View File

@@ -173,6 +173,15 @@
font-size: var(--momo-text-caption, 12px);
}
.ppt-deck-facts {
justify-content: flex-start;
flex-wrap: wrap;
}
.ppt-deck-facts span {
min-height: 22px;
}
.ppt-deck-card h3 {
margin: var(--momo-space-2, 8px) 0;
color: var(--obs-ink);

View File

@@ -29,7 +29,7 @@
margin: var(--momo-space-2, 8px) 0 var(--momo-space-1, 4px);
color: var(--obs-ink);
font-family: var(--momo-font-display, "Inter", "Noto Sans TC", system-ui, sans-serif);
font-size: clamp(22px, 2vw, 30px);
font-size: var(--momo-text-headline, 22px);
line-height: var(--momo-line-height-tight, 1.08);
letter-spacing: 0;
overflow-wrap: anywhere;