This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
103
services/ppt_preview_service.py
Normal file
103
services/ppt_preview_service.py
Normal 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)
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user