強化 PPT 產線與線上預覽
Some checks failed
CD Pipeline / deploy (push) Has been cancelled

This commit is contained in:
OoO
2026-05-18 15:44:11 +08:00
parent 921e9eeb15
commit d2d8dbab65
16 changed files with 980 additions and 27 deletions

View File

@@ -312,7 +312,7 @@ RAG_DEFAULT_TOP_K=5
RAG_EMBED_MODEL=bge-m3:latest
RAG_EMBED_DIM=1024
RAG_EMBED_NORMALIZE=true
PPT_VISION_ENABLED=false
PPT_VISION_ENABLED=true
PPT_VISION_MODEL=minicpm-v:latest
PPT_VISION_TIMEOUT=60
PPT_AUTO_GENERATION_ENABLED=true

View File

@@ -32,6 +32,7 @@ RUN apt-get update && apt-get install -y \
fonts-liberation \
fonts-noto-cjk \
fonts-noto-cjk-extra \
libreoffice-impress \
openssh-client \
libappindicator3-1 || true \
&& rm -rf /var/lib/apt/lists/*

View File

@@ -4,6 +4,8 @@
================================================================================
【已完成】
- V10.190 補 `/observability/ppt_audit_file/<filename>` 站內線上預覽PPTX 由 LibreOffice 轉 PDF 快取後以 iframe 預覽,保留原始 PPTX 下載Dockerfile 加 `libreoffice-impress`compose 預設啟用 `PPT_VISION_ENABLED=true`PPT 產線頁新增視覺 QA 停用原因與更精簡的控制台式排版。
- V10.188 補強 `/observability/ppt_audit_history` PPT 視覺 QA 產線:頁面明確呈現每日、每週、每月、每季、每半年、每年定期產出節奏,並顯示 `ppt_generation_runs` DB 寫入紀錄;保留自動補齊缺漏與資料庫/檔案覆蓋狀態。
- V10.187 修正 `/daily_sales`、`/growth_analysis` 圖表空白Chart JSON 改從 `<template>.content.textContent` 讀取,補空資料診斷狀態;成長分析改用 realtime 明細新鮮度覆蓋過期月結摘要,並為 growth cache 加入資料指紋。
- V10.151 接續前端 V3 全站 UI/UX廠商缺貨 `/vendor-stockout/vendor-management`、`/vendor-stockout/send-email`、`/vendor-stockout/history` 改走新版 `ewoooc_base.html` shell 與 `page-vendor-tools.css`,移除舊紫藍 navbar/live route。
- V10.151 補 `/abc_analysis/detail` 新版 ABC 詳情頁與安全 loading state移除 raw HTML fallback資料表維持正式快取資料來源與匯出連結。

View File

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

View File

@@ -96,6 +96,9 @@ services:
- OLLAMA_HOST_FALLBACK=${OLLAMA_HOST_FALLBACK:-http://192.168.0.111:11434}
# EMBEDDING_HOST 若未設定,由 resolve_ollama_host() 自動決定(三主機級聯)
- EMBEDDING_HOST=${EMBEDDING_HOST:-}
# PPT 視覺 QA + 線上預覽:需要容器內 LibreOfficeDockerfile 安裝 libreoffice-impress
- PPT_VISION_ENABLED=${PPT_VISION_ENABLED:-true}
- PPT_VISION_MODEL=${PPT_VISION_MODEL:-minicpm-v:latest}
# ADR-020: Code Review 全自動修復主開關
# 預設 true任何 finding 一律觸發 AiderHeal可在 .env 顯式設 false 即時切斷
- CODE_REVIEW_AUTO_FIX_ENABLED=${CODE_REVIEW_AUTO_FIX_ENABLED:-true}
@@ -223,6 +226,8 @@ services:
- OLLAMA_HOST_SECONDARY=${OLLAMA_HOST_SECONDARY:-http://34.21.145.224:11434}
- OLLAMA_HOST_FALLBACK=${OLLAMA_HOST_FALLBACK:-http://192.168.0.111:11434}
- EMBEDDING_HOST=${EMBEDDING_HOST:-}
- PPT_VISION_ENABLED=${PPT_VISION_ENABLED:-true}
- PPT_VISION_MODEL=${PPT_VISION_MODEL:-minicpm-v:latest}
env_file:
- .env
command: ["python", "run_scheduler.py"]

View File

@@ -106,7 +106,7 @@ SQL漏斗(~300筆)
- CD rebuild 模式必須先 build image 成功,再短暫 stop/rm/recreate 三應用容器,避免 no-cache build 造成長時間 502。
- ElephantAlpha 使用 NVIDIA NIM hosted APIproduction 預設模型為 `nvidia/llama-3.3-nemotron-super-49b-v1.5``ELEPHANT_ALPHA_FALLBACK_MODELS` 需保留至少一個可呼叫備援403/404、408/409/425/429、5xx、timeout 與 connection error 必須嘗試下一個模型。
- OpenClaw/Hermes embedding 優先呼叫 Ollama `/api/embed`,只在舊節點不支援時 fallback `/api/embeddings`timeout 由 `EMBEDDING_TIMEOUT` / `OLLAMA_EMBED_TIMEOUT` 控制。
- PPT 自動產線由 `momo-scheduler` 依節奏執行 `run_ppt_auto_generation_task(schedule_kind)`:每日 20:30 產日報、週一 20:40 產週報/市場情報、每月 1 日 20:50 產月報與管理型簡報、季初 21:00 產季報、半年初 21:10 產半年報、年初 21:20 產年報,再交給 22:00 `ppt_vision_audit` 做視覺審核;每次嘗試會寫入 `ppt_generation_runs``/observability/ppt_audit_history` 以精準參數檢查目標版本是否已產生,並可用 `/observability/ppt_audit/generate_missing` 手動補齊缺漏,總開關為 `PPT_AUTO_GENERATION_ENABLED`
- PPT 自動產線由 `momo-scheduler` 依節奏執行 `run_ppt_auto_generation_task(schedule_kind)`:每日 20:30 產日報、週一 20:40 產週報/市場情報、每月 1 日 20:50 產月報與管理型簡報、季初 21:00 產季報、半年初 21:10 產半年報、年初 21:20 產年報,再交給 22:00 `ppt_vision_audit` 做視覺審核;每次嘗試會寫入 `ppt_generation_runs``/observability/ppt_audit_history` 以精準參數檢查目標版本是否已產生,並可用 `/observability/ppt_audit/generate_missing` 手動補齊缺漏,總開關為 `PPT_AUTO_GENERATION_ENABLED`PPT vision 需 `PPT_VISION_ENABLED=true` 與容器內 LibreOffice`/observability/ppt_audit_file/<filename>` 會把 PPTX 轉成 PDF 快取供站內線上預覽,原始 PPTX 仍保留下載。
---

View File

@@ -19,7 +19,7 @@ Operation Ollama-First v5.0 / Phase 27 — Admin Observability Dashboard
import logging
from datetime import datetime, timedelta
from flask import Blueprint, render_template, request, jsonify, send_file
from flask import Blueprint, render_template, request, jsonify, send_file, url_for
from sqlalchemy import text as sa_text
from auth import login_required, get_current_user
@@ -1866,6 +1866,7 @@ def ppt_audit_file(filename: str):
"""提供觀測台簡報檔案預覽/下載。
- action=view 開啟預覽(預設)
- action=pdf 產生/回傳線上預覽 PDF
- action=download 直接下載
"""
action = (request.args.get('action', 'view') or 'view').strip().lower()
@@ -1883,7 +1884,7 @@ def ppt_audit_file(filename: str):
if safe_path.suffix.lower() != '.pptx':
return '不支援的檔案格式', 400
if action == 'view':
if action in ('view', 'pdf'):
try:
with zipfile.ZipFile(safe_path, 'r') as zf:
bad = zf.testzip()
@@ -1894,6 +1895,32 @@ def ppt_audit_file(filename: str):
except Exception as e:
return f'預覽檢查失敗:{type(e).__name__}', 409
if action in ('view', 'pdf'):
from services.ppt_preview_service import build_ppt_preview
preview = build_ppt_preview(safe_path)
if action == 'pdf':
if not preview.ok or not preview.pdf_path:
return preview.error or '無法產生預覽', 409
return send_file(
preview.pdf_path,
mimetype='application/pdf',
as_attachment=False,
download_name=f'{safe_path.stem}.pdf',
)
return render_template(
'admin/ppt_audit_preview.html',
active_page='obs_ppt_audit',
filename=safe_path.name,
file_size_kb=round(safe_path.stat().st_size / 1024, 1),
file_mtime=datetime.fromtimestamp(safe_path.stat().st_mtime).strftime('%Y-%m-%d %H:%M'),
preview=preview,
pdf_url=url_for('admin_observability.ppt_audit_file', filename=safe_path.name, action='pdf'),
download_url=url_for('admin_observability.ppt_audit_file', filename=safe_path.name, action='download'),
back_url=url_for('admin_observability.ppt_audit_history'),
)
return send_file(
str(safe_path),
mimetype='application/vnd.openxmlformats-officedocument.presentationml.presentation',
@@ -2410,9 +2437,11 @@ def ppt_audit_history():
logger.debug("PPT audit history table unavailable; rendering empty audit history", exc_info=True)
# PPT vision 啟用狀態
vision_status = {'enabled': False, 'ready': False, 'blockers': ['視覺狀態讀取失敗']}
try:
from services.ppt_vision_service import is_ppt_vision_enabled
vision_enabled = is_ppt_vision_enabled()
from services.ppt_vision_service import get_ppt_vision_runtime_status
vision_status = get_ppt_vision_runtime_status()
vision_enabled = bool(vision_status.get('enabled'))
except Exception:
vision_enabled = False
@@ -2523,20 +2552,37 @@ def ppt_audit_history():
'total': 0,
'last_run': None,
'can_auto_start': False,
'cadences': [],
'cadence_summary': '',
}
generation_runs = []
try:
from services.ppt_auto_generation_service import get_defined_report_coverage
from services.ppt_auto_generation_service import (
get_defined_report_coverage,
get_generation_run_history,
get_schedule_cadence_status,
)
auto_generation = get_defined_report_coverage(
month_start=month_start,
month_end=month_end,
reports_dir=reports_dir,
)
auto_generation.setdefault('cadences', get_schedule_cadence_status(auto_generation.get('items', [])))
auto_generation.setdefault(
'cadence_summary',
''.join(c.get('schedule_text', '') for c in auto_generation.get('cadences', []) if c.get('schedule_text')),
)
auto_generation['can_auto_start'] = (
bool(auto_generation.get('enabled'))
and int(auto_generation.get('missing_count') or 0) > 0
and month_label == datetime.now().strftime('%Y-%m')
)
generation_runs = get_generation_run_history(
month_start=month_start,
month_end=month_end,
limit=24,
)
except Exception:
logger.debug("PPT auto-generation coverage unavailable", exc_info=True)
@@ -2556,9 +2602,11 @@ def ppt_audit_history():
audit_30d_stats=audit_30d_stats,
top_failure_files=top_failure_files,
vision_enabled=vision_enabled,
vision_status=vision_status,
auto_generation=auto_generation,
auto_generation_items=auto_generation.get('items', []),
auto_generation_missing_report_types=auto_generation.get('missing_report_types', []),
generation_runs=generation_runs,
error=error,
)

View File

@@ -93,6 +93,51 @@ SCHEDULE_PROFILES = {
"annual": ("annual",),
}
SCHEDULE_CADENCES = {
"daily": {
"label": "每日",
"schedule_text": "每日 20:30",
"gate": "每日固定產出",
"time": "20:30",
"description": "每日補齊營運日報,供 22:00 視覺 QA 接續審核。",
},
"weekly": {
"label": "每週",
"schedule_text": "每週一 20:40",
"gate": "週一固定產出",
"time": "20:40",
"description": "每週產出週報與市場情報,整理近 7 日變化。",
},
"monthly": {
"label": "每月",
"schedule_text": "每月 1 日 20:50",
"gate": "每月 1 日產出",
"time": "20:50",
"description": "每月產出月報、策略、競品、促銷、品類與管理型簡報。",
},
"quarterly": {
"label": "每季",
"schedule_text": "每季首日 21:00",
"gate": "1/4/7/10 月 1 日產出",
"time": "21:00",
"description": "每季首日產出季報,承接季度營運檢討。",
},
"half_yearly": {
"label": "每半年",
"schedule_text": "每半年首日 21:10",
"gate": "1/7 月 1 日產出",
"time": "21:10",
"description": "每半年首日產出半年報,彙整 H1 / H2 成果。",
},
"annual": {
"label": "每年",
"schedule_text": "每年 1/1 21:20",
"gate": "每年 1 月 1 日產出",
"time": "21:20",
"description": "每年首日產出年度總結,保留完整年度資料快照。",
},
}
_RUN_LOCK = threading.Lock()
_LAST_RUN: dict | None = None
@@ -224,6 +269,49 @@ def get_report_type_options() -> list[dict]:
] + [{"key": "all", "label": "全部", "prefix": "all"}]
def get_schedule_cadence_status(coverage_items: Sequence[dict] | None = None) -> list[dict]:
"""Return the scheduler contract with optional month coverage counts."""
item_by_type = {
str(item.get("key")): item
for item in (coverage_items or [])
if item.get("key")
}
cadences: list[dict] = []
for key, meta in SCHEDULE_CADENCES.items():
report_types = SCHEDULE_PROFILES.get(key, ())
related_items = [item_by_type[report_type] for report_type in report_types if report_type in item_by_type]
ready_count = sum(1 for item in related_items if item.get("ready"))
missing_types = [
report_type
for report_type in report_types
if not item_by_type.get(report_type, {}).get("ready")
]
total = len(report_types)
if total and not missing_types:
status = "ready"
elif ready_count > 0:
status = "partial"
else:
status = "missing"
cadences.append({
"key": key,
"label": meta["label"],
"schedule_text": meta["schedule_text"],
"gate": meta["gate"],
"time": meta["time"],
"description": meta["description"],
"report_types": list(report_types),
"report_labels": [REPORT_TYPE_LABELS.get(report_type, report_type) for report_type in report_types],
"ready_count": ready_count,
"missing_count": len(missing_types),
"missing_report_types": missing_types,
"total": total,
"progress_pct": round((ready_count / total * 100), 1) if total else 0,
"status": status,
})
return cadences
def build_defined_ppt_jobs(
*,
latest_date: str | None = None,
@@ -513,9 +601,12 @@ def get_defined_report_coverage(
for job in jobs
]
missing = [item for item in items if not item["ready"]]
cadences = get_schedule_cadence_status(items)
return {
"enabled": is_ppt_auto_generation_enabled(),
"items": items,
"cadences": cadences,
"cadence_summary": "".join(cadence["schedule_text"] for cadence in cadences),
"missing_report_types": [item["key"] for item in missing],
"missing_count": len(missing),
"ready_count": len(items) - len(missing),
@@ -524,6 +615,78 @@ def get_defined_report_coverage(
}
def get_generation_run_history(
*,
month_start: datetime | None = None,
month_end: datetime | None = None,
limit: int = 24,
) -> list[dict]:
"""Read persisted PPT generation runs without blocking the observability page."""
safe_limit = max(1, min(int(limit or 24), 100))
where_clauses = []
params: dict = {"limit": safe_limit}
if month_start is not None:
where_clauses.append("started_at >= :month_start")
params["month_start"] = month_start
if month_end is not None:
where_clauses.append("started_at < :month_end")
params["month_end"] = month_end
where_sql = f"WHERE {' AND '.join(where_clauses)}" if where_clauses else ""
try:
session = get_session()
try:
rows = session.execute(
sa_text(
f"""
SELECT schedule_kind, report_type, target_label, status,
file_path, file_size, error_msg, started_at, finished_at
FROM ppt_generation_runs
{where_sql}
ORDER BY started_at DESC NULLS LAST
LIMIT :limit
"""
),
params,
).fetchall()
finally:
session.close()
except Exception:
return []
def _format_dt(value) -> str:
if hasattr(value, "strftime"):
return value.strftime("%Y-%m-%d %H:%M")
return str(value or "")
items = []
for row in rows:
schedule_kind = row[0] or "manual"
report_type = row[1] or ""
file_path = row[4] or ""
status = row[3] or ""
items.append({
"schedule_kind": schedule_kind,
"schedule_label": SCHEDULE_CADENCES.get(schedule_kind, {}).get("label", "手動"),
"report_type": report_type,
"report_label": REPORT_TYPE_LABELS.get(report_type, report_type or "未知"),
"target_label": row[2] or "",
"status": status,
"status_label": {
"ready": "已產生",
"missing_file": "未落盤",
"error": "失敗",
"planned": "已規劃",
}.get(status, status or "未知"),
"file_name": os.path.basename(file_path) if file_path else "",
"file_size_kb": round(float(row[5] or 0) / 1024, 1) if row[5] else None,
"error_msg": row[6] or "",
"started_at": _format_dt(row[7]),
"finished_at": _format_dt(row[8]),
})
return items
def _generate_job(job: PPTAutoJob) -> str | None:
from routes import openclaw_bot_routes as bot_routes

View File

@@ -0,0 +1,103 @@
"""PPT online preview helpers.
Browsers cannot reliably render .pptx files inline, so the app converts a
deck to a cached PDF and embeds that PDF in the observability preview page.
"""
from __future__ import annotations
import hashlib
import os
import shutil
import subprocess
import tempfile
from dataclasses import dataclass
from pathlib import Path
@dataclass(frozen=True)
class PPTPreviewResult:
ok: bool
pdf_path: str | None = None
cache_hit: bool = False
converter: str | None = None
error: str | None = None
def find_libreoffice_binary() -> str | None:
return shutil.which("libreoffice") or shutil.which("soffice")
def _preview_cache_path(pptx_path: Path, cache_dir: Path) -> Path:
stat = pptx_path.stat()
cache_key = hashlib.sha256(
f"{pptx_path.resolve()}:{stat.st_size}:{int(stat.st_mtime)}".encode("utf-8")
).hexdigest()[:16]
safe_stem = "".join(ch if ch.isalnum() or ch in ("-", "_") else "_" for ch in pptx_path.stem)[:96]
return cache_dir / f"{safe_stem}_{cache_key}.pdf"
def build_ppt_preview(
pptx_path: str | os.PathLike[str],
*,
cache_dir: str | os.PathLike[str] | None = None,
timeout_sec: int = 90,
) -> PPTPreviewResult:
source = Path(pptx_path)
if not source.is_file():
return PPTPreviewResult(ok=False, error="pptx not found")
if source.suffix.lower() != ".pptx":
return PPTPreviewResult(ok=False, error="unsupported file type")
converter = find_libreoffice_binary()
if not converter:
return PPTPreviewResult(
ok=False,
error="LibreOffice is not installed in the app container.",
)
target_dir = Path(cache_dir or os.getenv("PPT_PREVIEW_CACHE_DIR", "/app/data/ppt_previews"))
target_dir.mkdir(parents=True, exist_ok=True)
target_pdf = _preview_cache_path(source, target_dir)
if target_pdf.is_file() and target_pdf.stat().st_size > 0:
return PPTPreviewResult(ok=True, pdf_path=str(target_pdf), cache_hit=True, converter=converter)
with tempfile.TemporaryDirectory(prefix="ppt_preview_") as tmp:
tmpdir = Path(tmp)
try:
proc = subprocess.run(
[
converter,
"--headless",
"--convert-to",
"pdf",
"--outdir",
str(tmpdir),
str(source),
],
capture_output=True,
timeout=timeout_sec,
check=False,
)
except subprocess.TimeoutExpired:
return PPTPreviewResult(ok=False, converter=converter, error="LibreOffice conversion timed out.")
except Exception as exc:
return PPTPreviewResult(ok=False, converter=converter, error=f"{type(exc).__name__}: {str(exc)[:200]}")
if proc.returncode != 0:
stderr = proc.stderr.decode("utf-8", errors="ignore").strip()
return PPTPreviewResult(
ok=False,
converter=converter,
error=f"LibreOffice conversion failed: {stderr[:240] or proc.returncode}",
)
generated = tmpdir / f"{source.stem}.pdf"
if not generated.is_file():
candidates = sorted(tmpdir.glob("*.pdf"))
generated = candidates[0] if candidates else generated
if not generated.is_file() or generated.stat().st_size <= 0:
return PPTPreviewResult(ok=False, converter=converter, error="LibreOffice did not produce a PDF.")
shutil.move(str(generated), str(target_pdf))
return PPTPreviewResult(ok=True, pdf_path=str(target_pdf), cache_hit=False, converter=converter)

View File

@@ -21,6 +21,7 @@ import os
import time
import base64
import logging
import shutil
from dataclasses import dataclass, field
from typing import Optional, Dict, Any, List
@@ -38,6 +39,27 @@ def is_ppt_vision_enabled() -> bool:
return os.getenv('PPT_VISION_ENABLED', 'false').strip().lower() in ('true', '1', 'yes', 'on')
def get_ppt_vision_runtime_status() -> Dict[str, Any]:
"""Expose why the PPT vision pipeline is or is not ready."""
env_value = os.getenv('PPT_VISION_ENABLED')
enabled = is_ppt_vision_enabled()
converter = shutil.which('libreoffice') or shutil.which('soffice')
blockers = []
if not enabled:
blockers.append('PPT_VISION_ENABLED 未設定為 true')
if not converter:
blockers.append('容器內缺少 LibreOffice無法轉換 PPT 做視覺審核')
return {
'enabled': enabled,
'env_value': env_value if env_value is not None else '未設定(預設 false',
'model': os.getenv('PPT_VISION_MODEL', PPT_VISION_MODEL),
'converter': converter,
'converter_ready': bool(converter),
'blockers': blockers,
'ready': enabled and bool(converter),
}
# ─────────────────────────────────────────────────────────────────────────────
# 結果容器
# ─────────────────────────────────────────────────────────────────────────────
@@ -115,11 +137,16 @@ class PPTVisionService:
result['error'] = f'pptx not found: {pptx_path}'
return result
converter = shutil.which('libreoffice') or shutil.which('soffice')
if not converter:
result['error'] = 'libreoffice not installed (skip vision check)'
return result
# 1. LibreOffice 轉 png
with tempfile.TemporaryDirectory() as tmpdir:
try:
proc = subprocess.run(
['libreoffice', '--headless', '--convert-to', 'png',
[converter, '--headless', '--convert-to', 'png',
'--outdir', tmpdir, pptx_path],
capture_output=True, timeout=60,
)
@@ -440,6 +467,7 @@ __all__ = [
'VisionResult',
'ppt_vision_service',
'is_ppt_vision_enabled',
'get_ppt_vision_runtime_status',
'PPT_VISION_SYSTEM_PROMPT',
'audit_recent_ppts',
'push_ppt_audit_to_telegram',

View File

@@ -49,6 +49,22 @@
</div>
</div>
</section>
{% if not vision_status.ready %}
<section class="ppt-diagnostic-strip">
<div>
<div class="ppt-label">視覺 QA 尚未就緒</div>
<strong>目前不是模型能力問題,而是執行環境尚未完整開啟。</strong>
</div>
<div class="ppt-diagnostic-items">
<span>PPT_VISION_ENABLED={{ vision_status.env_value }}</span>
<span>模型 {{ vision_status.model }}</span>
<span>{{ 'LibreOffice OK' if vision_status.converter_ready else '缺 LibreOffice' }}</span>
</div>
{% if vision_status.blockers %}
<small class="text-muted">{{ vision_status.blockers|join('') }}</small>
{% endif %}
</section>
{% endif %}
{% if error %}<div class="alert alert-warning mt-3"><strong><i class="fas fa-triangle-exclamation me-1"></i></strong>{{ error }}</div>{% endif %}
<section class="ppt-toolbar">
@@ -79,35 +95,98 @@
data-report-types="{{ auto_generation_missing_report_types | join(',') }}">
<div class="ppt-panel-head">
<div>
<div class="ppt-label">自動產生定義</div>
<h2 class="ppt-panel-title">簡報產線覆蓋狀態</h2>
<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>
<div class="ppt-panel-body">
<div class="ppt-auto-grid">
{% for item in auto_generation_items %}
<div class="ppt-mini">
<span class="ppt-label">{{ item.label }}</span>
<strong class="{% if item.ready %}status-good{% elif item.has_other_versions %}status-blue{% else %}status-warn{% endif %}">
{% if item.ready %}目標已產生{% elif item.has_other_versions %}有其他版本{% else %}待補齊{% endif %}
</strong>
<small class="text-muted">
{{ item.target_label or '最新資料' }} · {{ item.count }} 筆{% if item.sources %} · {{ item.sources|join(' + ') }}{% endif %}
{% if item.latest_generated_at %}<br>最近 {{ item.latest_generated_at }}{% endif %}
</small>
<div class="ppt-pipeline-layout">
<div class="ppt-cadence-rail" aria-label="PPT 定期產出節奏">
{% for cadence in auto_generation.cadences %}
<article class="ppt-cadence-tile is-{{ cadence.status }}">
<div class="ppt-cadence-top">
<span class="ppt-label">{{ cadence.label }}</span>
<strong>{{ cadence.schedule_text }}</strong>
</div>
<div class="ppt-cadence-meter" aria-hidden="true">
<span style="width: {{ cadence.progress_pct }}%"></span>
</div>
<div class="ppt-cadence-meta">
<span>{{ cadence.gate }}</span>
{% if cadence.missing_count > 0 %}<span>缺 {{ cadence.missing_count }} 類</span>{% else %}<span>完整</span>{% endif %}
</div>
</article>
{% endfor %}
</div>
<div class="ppt-coverage-board">
<div class="ppt-coverage-score">
<div>
<span class="ppt-label">定義簡報</span>
<strong>{{ auto_generation.ready_count }}/{{ auto_generation.total }}</strong>
<small class="text-muted">目前缺漏 {{ auto_generation.missing_count }} 類</small>
</div>
<div>
<span class="ppt-label">DB 紀錄</span>
<strong>{{ generation_runs|length }}</strong>
<small class="text-muted">本月最近寫入</small>
</div>
<div>
<span class="ppt-label">線上預覽</span>
<strong>PDF</strong>
<small class="text-muted">PPTX 轉檔快取</small>
</div>
</div>
<div class="ppt-coverage-list" aria-label="定義簡報覆蓋明細">
{% for item in auto_generation_items %}
<div class="ppt-coverage-row">
<div>
<strong>{{ item.label }}</strong>
<small>{{ item.target_label or '最新資料' }}{% if item.latest_generated_at %} · {{ item.latest_generated_at }}{% endif %}</small>
</div>
<span class="ppt-run-status {% if item.ready %}is-ready{% elif item.has_other_versions %}is-partial{% else %}is-missing_file{% endif %}">
{% if item.ready %}已產生{% elif item.has_other_versions %}其他版本{% else %}待補齊{% endif %}
</span>
</div>
{% endfor %}
</div>
</div>
{% endfor %}
</div>
<div class="ppt-auto-status small text-muted mt-3" data-ppt-auto-status>
{% if auto_generation.enabled %}
每日 20:30 會自動補齊定義簡報;目前缺漏 {{ auto_generation.missing_count }} 類。
{{ auto_generation.cadence_summary }} 會定期產出並寫入 DB;目前缺漏 {{ auto_generation.missing_count }} 類。
{% else %}
PPT_AUTO_GENERATION_ENABLED=false已停用自動補齊。
{% endif %}
</div>
<div class="ppt-run-log mt-3">
<div class="ppt-run-log-head">
<div>
<div class="ppt-label">DB 產出紀錄</div>
<h3>最近寫入 ppt_generation_runs</h3>
</div>
<small class="text-muted">產出檔案、參數、狀態會同步保留在資料庫</small>
</div>
{% if generation_runs %}
<div class="ppt-run-list">
{% for run in generation_runs %}
<div class="ppt-run-row">
<span class="ppt-run-kind">{{ run.schedule_label }}</span>
<strong>{{ run.report_label }}</strong>
<span class="ppt-run-target">{{ run.target_label or '最新資料' }}</span>
<span class="ppt-run-status is-{{ run.status }}">{{ run.status_label }}</span>
<small class="text-muted">{{ run.started_at }}{% if run.finished_at %} → {{ run.finished_at }}{% endif %}</small>
</div>
{% endfor %}
</div>
{% else %}
<div class="ppt-empty ppt-run-empty">
目前查無本月 DB 產出紀錄;下一次自動排程或手動補齊後,會寫入 ppt_generation_runs 並顯示在這裡。
</div>
{% endif %}
</div>
</div>
</section>
<section class="ppt-grid">
@@ -223,7 +302,7 @@
{% if f.file_exists %}
{% if f.is_valid_ppt %}
<a class="btn btn-outline-primary btn-sm" href="{{ url_for('admin_observability.ppt_audit_file', filename=f.name) }}" target="_blank" rel="noopener">
<i class="fas fa-file-powerpoint me-1"></i>開啟
<i class="fas fa-eye me-1"></i>線上預覽
</a>
{% else %}
<span class="small status-bad">檔案不可預覽</span>

View File

@@ -0,0 +1,48 @@
{% extends "ewoooc_base.html" %}
{% block title %}PPT 線上預覽{% endblock %}
{% block extra_css %}
<link rel="stylesheet" href="{{ url_for('static', filename='css/page-ppt-preview.css') }}">
{% endblock %}
{% block ewooo_content %}
<div class="container-fluid mt-3">
<section class="ppt-preview-hero">
<div>
<div class="ppt-preview-kicker"><i class="fas fa-file-powerpoint me-1"></i>PPT Online Preview</div>
<h1 class="ppt-preview-title">{{ filename }}</h1>
<p class="ppt-preview-subtitle">檔案大小 {{ file_size_kb }} KB · 修改時間 {{ file_mtime }} · 預覽以 PDF 快取呈現,原始 PPTX 仍可下載。</p>
</div>
<div class="ppt-preview-actions">
<a class="btn btn-outline-secondary btn-sm" href="{{ back_url }}"><i class="fas fa-angle-left me-1"></i>回產線</a>
<a class="btn btn-outline-primary btn-sm" href="{{ download_url }}"><i class="fas fa-download me-1"></i>下載 PPTX</a>
{% if preview.ok %}
<a class="btn btn-primary btn-sm" href="{{ pdf_url }}" target="_blank" rel="noopener"><i class="fas fa-up-right-from-square me-1"></i>開新視窗</a>
{% endif %}
</div>
</section>
{% if preview.ok %}
<section class="ppt-preview-frame">
<iframe src="{{ pdf_url }}" title="{{ filename }} 線上預覽"></iframe>
</section>
<p class="ppt-preview-note">
<span class="status-good">預覽已產生</span>
{% if preview.cache_hit %} · 使用既有快取{% else %} · 已建立新快取{% endif %}
{% if preview.converter %} · {{ preview.converter }}{% endif %}
</p>
{% else %}
<section class="ppt-preview-empty">
<div class="ppt-preview-kicker"><i class="fas fa-triangle-exclamation me-1"></i>Preview unavailable</div>
<h2>目前無法產生線上預覽</h2>
<p>{{ preview.error or '轉檔流程沒有回傳可用 PDF。' }}</p>
<div class="ppt-preview-diagnostics">
<span>需要容器內可執行 LibreOffice / soffice</span>
<span>部署後會用 PDF 快取避免每次重轉</span>
<span>原始 PPTX 可先下載檢查</span>
</div>
</section>
{% endif %}
</div>
{% endblock %}

View File

@@ -174,6 +174,81 @@ def test_ppt_audit_history_200(client):
assert r.status_code == 200
def test_ppt_audit_history_shows_ppt_schedule_and_db_runs(client, monkeypatch):
"""PPT 產線頁必須呈現六種固定週期與 DB 寫入紀錄。"""
from services import ppt_auto_generation_service as svc
cadences = svc.get_schedule_cadence_status([
{'key': key, 'ready': True}
for key in svc.DEFINED_REPORT_TYPES
])
monkeypatch.setattr(svc, 'get_defined_report_coverage', lambda **_kw: {
'enabled': True,
'items': [],
'missing_report_types': [],
'missing_count': 0,
'ready_count': len(svc.DEFINED_REPORT_TYPES),
'total': len(svc.DEFINED_REPORT_TYPES),
'last_run': None,
'cadences': cadences,
'cadence_summary': ''.join(c['schedule_text'] for c in cadences),
})
monkeypatch.setattr(svc, 'get_generation_run_history', lambda **_kw: [{
'schedule_kind': 'daily',
'schedule_label': '每日',
'report_type': 'daily',
'report_label': '每日日報',
'target_label': '2026/05/17',
'status': 'ready',
'status_label': '已產生',
'file_name': 'ocbot_daily_20260517.pptx',
'file_size_kb': 1024,
'error_msg': '',
'started_at': '2026-05-17 20:30',
'finished_at': '2026-05-17 20:31',
}])
r = client.get('/observability/ppt_audit_history')
html = r.data.decode('utf-8')
for text in ['每日 20:30', '每週一 20:40', '每月 1 日 20:50', '每季首日 21:00', '每半年首日 21:10', '每年 1/1 21:20']:
assert text in html
assert 'ppt_generation_runs' in html
assert '每日日報' in html
def test_ppt_audit_file_view_renders_online_preview(client, monkeypatch, tmp_path):
"""PPTX view 入口應回站內預覽頁,而不是把 PPTX 直接丟給瀏覽器。"""
import zipfile
from services import ppt_preview_service as preview_svc
reports_dir = tmp_path / 'reports'
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('REPORTS_DIR', str(reports_dir))
monkeypatch.setattr(
preview_svc,
'build_ppt_preview',
lambda *_args, **_kwargs: preview_svc.PPTPreviewResult(
ok=True,
pdf_path=str(reports_dir / 'preview.pdf'),
cache_hit=True,
converter='/usr/bin/libreoffice',
),
)
r = client.get('/observability/ppt_audit_file/ocbot_daily_20260517.pptx')
html = r.data.decode('utf-8')
assert r.status_code == 200
assert 'PPT 線上預覽' in html
assert 'action=pdf' in html
assert '下載 PPTX' in html
# ──────────────────────────────────────────────────────────────────────────
# /observability/host_health
# ──────────────────────────────────────────────────────────────────────────

View File

@@ -124,6 +124,30 @@ def test_due_schedule_kinds_include_periodic_boundaries():
]
def test_schedule_cadence_status_exposes_all_periodic_contracts():
from services.ppt_auto_generation_service import get_schedule_cadence_status
cadences = get_schedule_cadence_status([
{"key": "daily", "ready": True},
{"key": "weekly", "ready": False},
{"key": "market_intel", "ready": True},
])
by_key = {cadence["key"]: cadence for cadence in cadences}
assert [cadence["schedule_text"] for cadence in cadences] == [
"每日 20:30",
"每週一 20:40",
"每月 1 日 20:50",
"每季首日 21:00",
"每半年首日 21:10",
"每年 1/1 21:20",
]
assert by_key["weekly"]["report_types"] == ["weekly", "market_intel"]
assert by_key["weekly"]["ready_count"] == 1
assert by_key["weekly"]["missing_report_types"] == ["weekly"]
assert "TTM 滾動 12 月" in by_key["monthly"]["report_labels"]
def test_scheduled_generation_uses_profile_without_generating(monkeypatch):
from services import ppt_auto_generation_service as svc
@@ -144,3 +168,16 @@ def test_scheduled_generation_uses_profile_without_generating(monkeypatch):
"schedule_kind": "weekly",
"force": True,
}]
def test_ppt_preview_reports_missing_converter(monkeypatch, tmp_path):
from services import ppt_preview_service as svc
pptx = tmp_path / "demo.pptx"
pptx.write_bytes(b"fake pptx")
monkeypatch.setattr(svc.shutil, "which", lambda _name: None)
result = svc.build_ppt_preview(pptx, cache_dir=tmp_path / "cache")
assert result.ok is False
assert "LibreOffice" in (result.error or "")

View File

@@ -36,6 +36,43 @@
line-height: 1.7;
}
.ppt-diagnostic-strip {
display: grid;
grid-template-columns: minmax(220px, 0.8fr) minmax(260px, 1fr) minmax(260px, 1.2fr);
align-items: center;
gap: var(--momo-space-3, 12px);
margin-top: var(--momo-space-3, 12px);
padding: var(--momo-space-3, 12px) var(--momo-space-4, 16px);
border: 1px solid rgba(181, 111, 56, 0.35);
border-radius: var(--momo-radius-lg, 8px);
background:
radial-gradient(circle, rgba(181, 111, 56, 0.1) 1px, transparent 1.2px),
rgba(255, 250, 242, 0.78);
background-size: 10px 10px, auto;
}
.ppt-diagnostic-strip strong {
display: block;
margin-top: var(--momo-space-1, 4px);
color: var(--obs-ink);
}
.ppt-diagnostic-items {
display: flex;
flex-wrap: wrap;
gap: var(--momo-space-2, 8px);
}
.ppt-diagnostic-items span {
padding: var(--momo-space-1, 4px) var(--momo-space-2, 8px);
border: 1px solid rgba(181, 111, 56, 0.28);
border-radius: 999px;
color: var(--obs-accent);
background: rgba(255, 255, 255, 0.55);
font-size: var(--momo-text-caption, 12px);
font-weight: var(--momo-font-weight-bold, 700);
}
.ppt-command {
display: grid;
grid-template-columns: repeat(5, minmax(0, 1fr));
@@ -138,6 +175,211 @@
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: var(--momo-space-3, 12px);
margin-top: var(--momo-space-4, 16px);
}
.ppt-pipeline-layout {
display: grid;
grid-template-columns: minmax(260px, 0.38fr) minmax(0, 0.62fr);
gap: var(--momo-space-4, 16px);
}
.ppt-cadence-rail,
.ppt-coverage-board {
display: grid;
gap: var(--momo-space-3, 12px);
}
.ppt-cadence-tile {
padding: var(--momo-space-3, 12px);
border: 1px solid var(--obs-line);
border-radius: var(--momo-radius-lg, 8px);
background:
radial-gradient(circle, rgba(45, 40, 32, 0.08) 1px, transparent 1.2px),
rgba(255, 255, 255, 0.56);
background-size: 10px 10px, auto;
}
.ppt-cadence-top,
.ppt-cadence-meta,
.ppt-run-log-head,
.ppt-run-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--momo-space-2, 8px);
}
.ppt-cadence-top strong {
font-size: var(--momo-text-caption, 12px);
color: var(--obs-accent);
text-align: right;
}
.ppt-cadence-title {
margin-top: var(--momo-space-2, 8px);
color: var(--obs-ink);
font-size: var(--momo-text-body, 14px);
font-weight: var(--momo-font-weight-black, 800);
}
.ppt-cadence-gate {
margin-top: var(--momo-space-1, 4px);
color: var(--obs-muted);
font-size: var(--momo-text-caption, 12px);
}
.ppt-cadence-meter {
height: 5px;
margin: var(--momo-space-3, 12px) 0 var(--momo-space-2, 8px);
border-radius: 999px;
background: rgba(45, 40, 32, 0.1);
overflow: hidden;
}
.ppt-cadence-meter span {
display: block;
height: 100%;
border-radius: inherit;
background: var(--obs-green);
}
.ppt-cadence-tile.is-partial .ppt-cadence-meter span {
background: var(--obs-amber);
}
.ppt-cadence-tile.is-missing .ppt-cadence-meter span {
background: var(--obs-red);
}
.ppt-cadence-meta {
color: var(--obs-muted);
font-size: var(--momo-text-caption, 12px);
}
.ppt-coverage-score {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: var(--momo-space-3, 12px);
}
.ppt-coverage-score > div,
.ppt-coverage-row {
border: 1px solid var(--obs-line);
border-radius: var(--momo-radius-md, 6px);
background: rgba(255, 255, 255, 0.54);
}
.ppt-coverage-score > div {
padding: var(--momo-space-3, 12px);
}
.ppt-coverage-score strong {
display: block;
margin-top: var(--momo-space-1, 4px);
color: var(--obs-ink);
font-size: var(--momo-text-headline, 22px);
line-height: 1.1;
}
.ppt-coverage-list {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: var(--momo-space-2, 8px);
}
.ppt-coverage-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--momo-space-3, 12px);
min-height: 58px;
padding: var(--momo-space-2, 8px) var(--momo-space-3, 12px);
}
.ppt-coverage-row strong {
display: block;
color: var(--obs-ink);
font-size: var(--momo-text-body, 14px);
}
.ppt-coverage-row small {
display: block;
color: var(--obs-muted);
font-size: var(--momo-text-caption, 12px);
}
.ppt-run-log {
border-top: 1px solid var(--obs-line);
padding-top: var(--momo-space-4, 16px);
}
.ppt-run-log-head h3 {
margin: var(--momo-space-1, 4px) 0 0;
font-size: var(--momo-text-body, 14px);
font-weight: var(--momo-font-weight-black, 800);
}
.ppt-run-list {
display: grid;
gap: var(--momo-space-2, 8px);
margin-top: var(--momo-space-3, 12px);
}
.ppt-run-row {
padding: var(--momo-space-2, 8px) var(--momo-space-3, 12px);
border: 1px solid var(--obs-line);
border-radius: var(--momo-radius-md, 6px);
background: rgba(255, 255, 255, 0.5);
}
.ppt-run-kind,
.ppt-run-status {
display: inline-flex;
align-items: center;
min-height: 24px;
padding: 0 var(--momo-space-2, 8px);
border-radius: 999px;
border: 1px solid var(--obs-line);
color: var(--obs-accent);
font-size: var(--momo-text-caption, 12px);
font-weight: var(--momo-font-weight-bold, 700);
}
.ppt-run-status.is-ready {
color: var(--obs-green);
border-color: rgba(76, 137, 91, 0.35);
}
.ppt-run-status.is-partial {
color: var(--obs-blue);
border-color: rgba(72, 108, 149, 0.35);
}
.ppt-run-status.is-error,
.ppt-run-status.is-missing_file {
color: var(--obs-red);
border-color: rgba(196, 84, 75, 0.35);
}
.ppt-run-target {
color: var(--obs-muted);
min-width: 0;
}
.ppt-empty {
border: 1px dashed var(--obs-line);
border-radius: var(--momo-radius-lg, 8px);
background:
radial-gradient(circle, rgba(45, 40, 32, 0.08) 1px, transparent 1.2px),
rgba(255, 255, 255, 0.38);
background-size: 10px 10px, auto;
color: var(--obs-muted);
}
.ppt-run-empty {
margin-top: var(--momo-space-3, 12px);
padding: var(--momo-space-3, 12px);
}
.ppt-mini,
@@ -192,6 +434,11 @@
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.ppt-diagnostic-strip,
.ppt-pipeline-layout {
grid-template-columns: 1fr;
}
.ppt-auto-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
@@ -204,12 +451,18 @@
@media (max-width: 760px) {
.ppt-command,
.ppt-auto-grid,
.ppt-mini-grid {
.ppt-mini-grid,
.ppt-coverage-score,
.ppt-coverage-list {
grid-template-columns: 1fr;
}
.ppt-panel-head,
.ppt-table-title {
.ppt-table-title,
.ppt-run-log-head,
.ppt-run-row,
.ppt-coverage-row {
flex-direction: column;
align-items: flex-start;
}
}

View File

@@ -0,0 +1,111 @@
.ppt-preview-hero,
.ppt-preview-frame,
.ppt-preview-empty {
border: 1px solid var(--obs-line);
border-radius: var(--momo-radius-lg, 8px);
background:
radial-gradient(circle, rgba(45, 40, 32, 0.08) 1px, transparent 1.2px),
var(--obs-card);
background-size: 10px 10px, auto;
box-shadow: var(--momo-shadow-md, 0 16px 38px rgba(70, 46, 28, 0.08));
}
.ppt-preview-hero {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: var(--momo-space-4, 16px);
padding: var(--momo-space-5, 24px);
}
.ppt-preview-kicker {
color: var(--obs-accent);
font-size: var(--momo-text-caption, 12px);
font-weight: var(--momo-font-weight-bold, 700);
letter-spacing: 0;
}
.ppt-preview-title {
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);
line-height: var(--momo-line-height-tight, 1.08);
letter-spacing: 0;
overflow-wrap: anywhere;
}
.ppt-preview-subtitle,
.ppt-preview-note {
color: var(--obs-muted);
}
.ppt-preview-actions {
display: flex;
flex-wrap: wrap;
justify-content: flex-end;
gap: var(--momo-space-2, 8px);
}
.ppt-preview-frame {
height: min(76vh, 920px);
min-height: 560px;
margin-top: var(--momo-space-4, 16px);
overflow: hidden;
}
.ppt-preview-frame iframe {
display: block;
width: 100%;
height: 100%;
border: 0;
background: #fff;
}
.ppt-preview-empty {
margin-top: var(--momo-space-4, 16px);
padding: var(--momo-space-5, 24px);
}
.ppt-preview-empty h2 {
margin: var(--momo-space-2, 8px) 0;
color: var(--obs-ink);
font-size: var(--momo-text-title, 18px);
font-weight: var(--momo-font-weight-black, 800);
}
.ppt-preview-diagnostics {
display: flex;
flex-wrap: wrap;
gap: var(--momo-space-2, 8px);
margin-top: var(--momo-space-3, 12px);
}
.ppt-preview-diagnostics span {
padding: var(--momo-space-1, 4px) var(--momo-space-2, 8px);
border: 1px solid var(--obs-line);
border-radius: 999px;
color: var(--obs-muted);
background: rgba(255, 255, 255, 0.58);
font-size: var(--momo-text-caption, 12px);
}
.status-good {
color: var(--obs-green);
}
@media (max-width: 760px) {
.ppt-preview-hero {
flex-direction: column;
}
.ppt-preview-actions {
width: 100%;
justify-content: flex-start;
}
.ppt-preview-frame {
height: 70vh;
min-height: 420px;
}
}