This commit is contained in:
@@ -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 寫入集中成工作隊列,讓使用者不用在多張卡與表格間找下一個處理點。
|
||||
|
||||
@@ -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 # 用於模板顯示
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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],
|
||||
*,
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user