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 %}