diff --git a/TODO_NEXT_STEPS.txt b/TODO_NEXT_STEPS.txt index ea438ca..0e52db1 100644 --- a/TODO_NEXT_STEPS.txt +++ b/TODO_NEXT_STEPS.txt @@ -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 寫入集中成工作隊列,讓使用者不用在多張卡與表格間找下一個處理點。 diff --git a/config.py b/config.py index f428956..3e6a0f7 100644 --- a/config.py +++ b/config.py @@ -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 # 用於模板顯示 diff --git a/routes/admin_observability_routes.py b/routes/admin_observability_routes.py index a765250..cc1fba8 100644 --- a/routes/admin_observability_routes.py +++ b/routes/admin_observability_routes.py @@ -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() diff --git a/services/ppt_preview_service.py b/services/ppt_preview_service.py index dba0875..9a9b5bd 100644 --- a/services/ppt_preview_service.py +++ b/services/ppt_preview_service.py @@ -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], *, diff --git a/templates/admin/ppt_audit_history.html b/templates/admin/ppt_audit_history.html index e03f977..f695b88 100644 --- a/templates/admin/ppt_audit_history.html +++ b/templates/admin/ppt_audit_history.html @@ -110,6 +110,9 @@ {{ f.size_kb if f.size_kb is not none else '—' }} KB {{ f.source }} {% if f.file_exists and f.is_valid_ppt %}可預覽{% else %}需回補{% endif %} + {% if f.file_exists and f.is_valid_ppt %} + {% if f.preview_cache_ready %}PDF 快取{% else %}首次轉檔{% endif %} + {% endif %}
{% if f.file_exists and f.is_valid_ppt %} @@ -136,6 +139,7 @@
{{ pipeline_view.ready_count }}/{{ pipeline_view.total_count }} 定義覆蓋 {{ pipeline_view.valid_preview_count }} 份可預覽 + {{ pipeline_view.cached_preview_count }} 份 PDF 快取 {{ pipeline_view.audit_total }} 筆視覺 QA
@@ -417,6 +421,17 @@ {% if f.file_error %}
{{ f.file_error }}
{% endif %} + {% if f.file_exists and f.is_valid_ppt %} +
+ + {% if f.preview_cache_ready %} + PDF 預覽快取已建立{% if f.preview_cache_mtime %} · {{ f.preview_cache_mtime }}{% endif %} + {% else %} + PDF 預覽尚未快取,首次開啟會自動轉檔 + {% endif %} + +
+ {% endif %}
diff --git a/tests/test_admin_observability_routes.py b/tests/test_admin_observability_routes.py index 43ebfa6..a533dfb 100644 --- a/tests/test_admin_observability_routes.py +++ b/tests/test_admin_observability_routes.py @@ -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', '') + 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 份 PDF 快取' in html def test_ppt_audit_file_view_renders_online_preview(client, monkeypatch, tmp_path): diff --git a/tests/test_ppt_auto_generation_service.py b/tests/test_ppt_auto_generation_service.py index 96640d6..c27258f 100644 --- a/tests/test_ppt_auto_generation_service.py +++ b/tests/test_ppt_auto_generation_service.py @@ -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 diff --git a/web/static/css/page-ppt-audit-history.css b/web/static/css/page-ppt-audit-history.css index 639611c..f2e418c 100644 --- a/web/static/css/page-ppt-audit-history.css +++ b/web/static/css/page-ppt-audit-history.css @@ -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); diff --git a/web/static/css/page-ppt-preview.css b/web/static/css/page-ppt-preview.css index 9ec13cd..16e4fee 100644 --- a/web/static/css/page-ppt-preview.css +++ b/web/static/css/page-ppt-preview.css @@ -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;