強化 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

@@ -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',