diff --git a/routes/admin_observability_routes.py b/routes/admin_observability_routes.py
index f4b9db6..92dadaa 100644
--- a/routes/admin_observability_routes.py
+++ b/routes/admin_observability_routes.py
@@ -2135,29 +2135,84 @@ def budget_update(budget_id: int):
@admin_observability_bp.route('/ppt_audit_history')
@login_required
def ppt_audit_history():
- """掃 reports/ 目錄列近 7 日 .pptx 檔 + 從 ppt_audit_results 表讀 audit 歷史(Phase 38)"""
+ """掃 reports/ 目錄列指定月份 daily 報表 + 從 ppt_audit_results 讀審核歷史(Phase 38)"""
import os
- import time
- reports_dir = 'reports'
+ reports_dir = os.environ.get('REPORTS_DIR', '/app/data/reports')
files = []
audit_records = []
error = None
+ month_arg = request.args.get('month', '').strip()
+ report_type = request.args.get('report_type', 'daily').strip().lower() or 'daily'
+ report_type_options = [
+ {'key': 'daily', 'label': '每日日報', 'prefix': 'ocbot_daily_'},
+ {'key': 'weekly', 'label': '週報', 'prefix': 'ocbot_weekly_'},
+ {'key': 'monthly', 'label': '月報', 'prefix': 'ocbot_monthly_'},
+ {'key': 'strategy', 'label': '策略', 'prefix': 'ocbot_strategy_'},
+ {'key': 'competitor', 'label': '競品', 'prefix': 'ocbot_competitor_'},
+ {'key': 'promo', 'label': '促銷', 'prefix': 'ocbot_promo_'},
+ {'key': 'all', 'label': '全部', 'prefix': 'all'},
+ ]
+ report_type_map = {opt['key']: opt for opt in report_type_options}
+ if report_type not in report_type_map:
+ report_type = 'daily'
+ selected_report_type = report_type_map[report_type]
+ report_prefix = selected_report_type['prefix']
+
+ now = datetime.now()
+ target_year = now.year
+ target_month = now.month
+ if month_arg:
+ sep = '-' if '-' in month_arg else '/' if '/' in month_arg else None
+ parts = month_arg.split(sep) if sep else [month_arg]
+ try:
+ if len(parts) == 2:
+ target_year = int(parts[0])
+ target_month = int(parts[1])
+ elif len(parts) == 1 and 1 <= len(parts[0]) <= 2:
+ target_month = int(parts[0])
+ else:
+ raise ValueError
+ if not (1 <= target_month <= 12):
+ raise ValueError
+ except Exception:
+ target_year = now.year
+ target_month = now.month
+
+ month_start = datetime(target_year, target_month, 1)
+ month_end = datetime(target_year + 1, 1, 1) if target_month == 12 else datetime(target_year, target_month + 1, 1)
+ month_start_ts = int(month_start.timestamp())
+ month_end_ts = int(month_end.timestamp())
+ month_label = month_start.strftime('%Y-%m')
+ prev_month = target_month - 1
+ prev_year = target_year
+ if prev_month == 0:
+ prev_month = 12
+ prev_year -= 1
+ next_month = target_month + 1
+ next_year = target_year
+ if next_month == 13:
+ next_month = 1
+ next_year += 1
+ prev_month_label = f"{prev_year:04d}-{prev_month:02d}"
+ next_month_label = f"{next_year:04d}-{next_month:02d}"
+ show_next_month = (next_year < now.year) or (next_year == now.year and next_month <= now.month)
try:
if not os.path.isdir(reports_dir):
error = f'{reports_dir} 目錄不存在'
else:
- cutoff = time.time() - 7 * 86400
for f in os.listdir(reports_dir):
if not f.lower().endswith('.pptx'):
continue
+ if report_prefix != 'all' and not f.startswith(report_prefix):
+ continue
full = os.path.join(reports_dir, f)
# symlink 防護:reports/ 內不接受 symlink,避免目錄逃逸(Critic MEDIUM #2)
if os.path.islink(full):
continue
try:
mtime = os.path.getmtime(full)
- if mtime >= cutoff:
+ if month_start_ts <= mtime < month_end_ts:
files.append({
'name': f,
'size_kb': round(os.path.getsize(full) / 1024, 1),
@@ -2170,36 +2225,40 @@ def ppt_audit_history():
except Exception as e:
error = f'{type(e).__name__}: {str(e)[:200]}'
- # Phase 38:讀過去 7 日 audit 歷史
- try:
- session = get_session()
+ # Phase 38:讀指定月份 daily audit 歷史(僅限 daily 類型)
+ if report_type == 'daily':
try:
- audit_rows = session.execute(
- sa_text("""
- SELECT audited_at, pptx_filename, audit_status,
- issues_count, confidence, duration_ms, error_msg
- FROM ppt_audit_results
- WHERE audited_at >= NOW() - INTERVAL '7 days'
- ORDER BY audited_at DESC
- LIMIT 100
- """),
- ).fetchall()
- audit_records = [
- {
- 'audited_at': r[0].strftime('%Y-%m-%d %H:%M'),
- 'pptx_filename': r[1],
- 'audit_status': r[2],
- 'issues_count': int(r[3] or 0),
- 'confidence': float(r[4] or 0),
- 'duration_ms': int(r[5] or 0),
- 'error_msg': r[6],
- }
- for r in audit_rows
- ]
- finally:
- session.close()
- except Exception:
- logger.debug("PPT audit history table unavailable; rendering empty audit history", exc_info=True)
+ session = get_session()
+ try:
+ audit_rows = session.execute(
+ sa_text("""
+ SELECT audited_at, pptx_filename, audit_status,
+ issues_count, confidence, duration_ms, error_msg
+ FROM ppt_audit_results
+ WHERE audited_at >= :month_start
+ AND audited_at < :month_end
+ AND pptx_filename LIKE 'ocbot_daily_%'
+ ORDER BY audited_at DESC
+ LIMIT 1000
+ """),
+ {'month_start': month_start, 'month_end': month_end},
+ ).fetchall()
+ audit_records = [
+ {
+ 'audited_at': r[0].strftime('%Y-%m-%d %H:%M'),
+ 'pptx_filename': r[1],
+ 'audit_status': r[2],
+ 'issues_count': int(r[3] or 0),
+ 'confidence': float(r[4] or 0),
+ 'duration_ms': int(r[5] or 0),
+ 'error_msg': r[6],
+ }
+ for r in audit_rows
+ ]
+ finally:
+ session.close()
+ except Exception:
+ logger.debug("PPT audit history table unavailable; rendering empty audit history", exc_info=True)
# PPT vision 啟用狀態
try:
@@ -2208,61 +2267,68 @@ def ppt_audit_history():
except Exception:
vision_enabled = False
- # Phase 47 K-6: 30d 統計 + top failure files
+ # Phase 47 K-6: 月報表統計 + top failure files
audit_30d_stats = {}
top_failure_files = []
- try:
- s_ppt = get_session()
+ if report_type == 'daily':
try:
- stat_row = s_ppt.execute(
- sa_text("""
- SELECT COUNT(*),
- COUNT(*) FILTER (WHERE audit_status = 'passed'),
- COUNT(*) FILTER (WHERE audit_status = 'failed'),
- COUNT(*) FILTER (WHERE audit_status = 'skipped'),
- COUNT(*) FILTER (WHERE audit_status = 'error'),
- COALESCE(AVG(confidence) FILTER (WHERE audit_status = 'passed'), 0),
- COALESCE(SUM(issues_count), 0)
- FROM ppt_audit_results
- WHERE audited_at >= NOW() - INTERVAL '30 days'
- """),
- ).fetchone()
- total_30d = int(stat_row[0] or 0)
- audit_30d_stats = {
- 'total': total_30d,
- 'passed': int(stat_row[1] or 0),
- 'failed': int(stat_row[2] or 0),
- 'skipped': int(stat_row[3] or 0),
- 'error': int(stat_row[4] or 0),
- 'avg_confidence': round(float(stat_row[5] or 0), 3),
- 'total_issues': int(stat_row[6] or 0),
- 'pass_rate': (float(stat_row[1] or 0) / total_30d * 100) if total_30d else 0,
- }
-
- top_fail_rows = s_ppt.execute(
- sa_text("""
- SELECT pptx_filename, COUNT(*) AS attempts,
- SUM(issues_count) AS total_issues,
- MAX(audited_at) AS last_audit
- FROM ppt_audit_results
- WHERE audit_status IN ('failed', 'error')
- AND audited_at >= NOW() - INTERVAL '30 days'
- GROUP BY pptx_filename
- ORDER BY attempts DESC, total_issues DESC LIMIT 10
- """),
- ).fetchall()
- top_failure_files = [
- {
- 'filename': r[0], 'attempts': int(r[1] or 0),
- 'total_issues': int(r[2] or 0),
- 'last_audit': r[3].strftime('%Y-%m-%d %H:%M') if r[3] else '',
+ s_ppt = get_session()
+ try:
+ stat_row = s_ppt.execute(
+ sa_text("""
+ SELECT COUNT(*),
+ COUNT(*) FILTER (WHERE audit_status = 'passed'),
+ COUNT(*) FILTER (WHERE audit_status = 'failed'),
+ COUNT(*) FILTER (WHERE audit_status = 'skipped'),
+ COUNT(*) FILTER (WHERE audit_status = 'error'),
+ COALESCE(AVG(confidence) FILTER (WHERE audit_status = 'passed'), 0),
+ COALESCE(SUM(issues_count), 0)
+ FROM ppt_audit_results
+ WHERE audited_at >= :month_start
+ AND audited_at < :month_end
+ AND pptx_filename LIKE 'ocbot_daily_%'
+ """),
+ {'month_start': month_start, 'month_end': month_end},
+ ).fetchone()
+ total_30d = int(stat_row[0] or 0)
+ audit_30d_stats = {
+ 'total': total_30d,
+ 'passed': int(stat_row[1] or 0),
+ 'failed': int(stat_row[2] or 0),
+ 'skipped': int(stat_row[3] or 0),
+ 'error': int(stat_row[4] or 0),
+ 'avg_confidence': round(float(stat_row[5] or 0), 3),
+ 'total_issues': int(stat_row[6] or 0),
+ 'pass_rate': (float(stat_row[1] or 0) / total_30d * 100) if total_30d else 0,
}
- for r in top_fail_rows
- ]
- finally:
- s_ppt.close()
- except Exception:
- pass
+
+ top_fail_rows = s_ppt.execute(
+ sa_text("""
+ SELECT pptx_filename, COUNT(*) AS attempts,
+ SUM(issues_count) AS total_issues,
+ MAX(audited_at) AS last_audit
+ FROM ppt_audit_results
+ WHERE audit_status IN ('failed', 'error')
+ AND audited_at >= :month_start
+ AND audited_at < :month_end
+ AND pptx_filename LIKE 'ocbot_daily_%'
+ GROUP BY pptx_filename
+ ORDER BY attempts DESC, total_issues DESC LIMIT 10
+ """),
+ {'month_start': month_start, 'month_end': month_end},
+ ).fetchall()
+ top_failure_files = [
+ {
+ 'filename': r[0], 'attempts': int(r[1] or 0),
+ 'total_issues': int(r[2] or 0),
+ 'last_audit': r[3].strftime('%Y-%m-%d %H:%M') if r[3] else '',
+ }
+ for r in top_fail_rows
+ ]
+ finally:
+ s_ppt.close()
+ except Exception:
+ pass
# Phase 41 E-2: 對最近 3 筆 failed audit 跑 RAG 找相似修法
rag_fixes = []
@@ -2302,6 +2368,13 @@ def ppt_audit_history():
return render_template(
'admin/ppt_audit_history.html',
active_page='obs_ppt_audit',
+ report_month=month_label,
+ report_type=report_type,
+ report_type_options=report_type_options,
+ selected_report_type=selected_report_type,
+ prev_month_label=prev_month_label,
+ next_month_label=next_month_label,
+ show_next_month=show_next_month,
files=files,
audit_records=audit_records,
rag_fixes=rag_fixes,
diff --git a/run_scheduler.py b/run_scheduler.py
index bc4dd1f..85f5d9a 100644
--- a/run_scheduler.py
+++ b/run_scheduler.py
@@ -827,7 +827,11 @@ def run_ppt_vision_audit():
"""
try:
from services.ppt_vision_service import audit_recent_ppts, push_ppt_audit_to_telegram
- summary = audit_recent_ppts(reports_dir='reports', hours=24, max_files=10)
+ summary = audit_recent_ppts(
+ reports_dir=os.getenv('REPORTS_DIR', '/app/data/reports'),
+ hours=24,
+ max_files=10,
+ )
if summary['total_issues'] > 0:
pushed = push_ppt_audit_to_telegram(summary)
logger.info(
diff --git a/services/ppt_vision_service.py b/services/ppt_vision_service.py
index e22f038..7a5f4ff 100644
--- a/services/ppt_vision_service.py
+++ b/services/ppt_vision_service.py
@@ -337,12 +337,12 @@ class PPTVisionService:
ppt_vision_service = PPTVisionService()
-def audit_recent_ppts(reports_dir: str = 'reports', hours: int = 24,
+def audit_recent_ppts(reports_dir: str | None = None, hours: int = 24,
max_files: int = 10) -> Dict[str, Any]:
"""Phase 26 整合 hook — 每日 22:00 cron 跑:掃 reports/ 當天新增 .pptx 跑視覺檢查。
Args:
- reports_dir: PPT 輸出目錄
+ reports_dir: PPT 輸出目錄,未提供時改用 REPORTS_DIR 環境變數
hours: 掃過去 N 小時內的檔
max_files: 一次最多查 N 個檔(避免一次跑太久)
@@ -358,6 +358,9 @@ def audit_recent_ppts(reports_dir: str = 'reports', hours: int = 24,
summary = {'audited_files': [], 'total_issues': 0, 'errors': []}
+ if reports_dir is None:
+ reports_dir = os.environ.get('REPORTS_DIR', '/app/data/reports')
+
if not is_ppt_vision_enabled():
summary['errors'].append('PPT_VISION_ENABLED=false')
return summary
diff --git a/templates/admin/ppt_audit_history.html b/templates/admin/ppt_audit_history.html
index f592263..110a634 100644
--- a/templates/admin/ppt_audit_history.html
+++ b/templates/admin/ppt_audit_history.html
@@ -4,28 +4,374 @@
{% block ewooo_content %}
{% import "admin/_observability_labels.html" as obs_label %}
+{% set report_is_daily = report_type == 'daily' %}
+
-
PPT 視覺 QA 產線 · minicpm-v / AiderHeal / RAG 修法
PPT 視覺 QA 產線
這頁追蹤每份自動簡報是否通過視覺審核:檔案產出、minicpm-v 審核、Telegram 推送、RAG 修法建議與 AiderHeal 自動修產生器。
視覺模型
{{ '啟用' if vision_enabled else '停用' }}PPT_VISION_ENABLED30 日總量
{{ audit_30d_stats.total if audit_30d_stats else 0 }}審核紀錄通過率
{{ "%.0f"|format(audit_30d_stats.pass_rate) if audit_30d_stats else '—' }}{% if audit_30d_stats %}%{% endif %}過去 30 日問題數
{{ audit_30d_stats.total_issues if audit_30d_stats else 0 }}視覺問題數
+
+ PPT 視覺 QA 產線 · minicpm-v / AiderHeal / RAG 修法
+ PPT 視覺 QA 產線
+ 這頁追蹤每份自動簡報是否通過視覺審核:檔案產出、minicpm-v 審核、Telegram 推送、RAG 修法建議與 AiderHeal 自動修產生器。
+
+
+
視覺模型
+
{{ '啟用' if vision_enabled else '停用' }}
+
PPT_VISION_ENABLED
+
+
+
{{ report_month }} {{ selected_report_type.label }}
+
{{ files|length }}
+
檔案數
+
+
+
審核紀錄
+
+ {% if report_is_daily %}{{ audit_30d_stats.total if audit_30d_stats else '—' }}{% else %}—{% endif %}
+
+
{{ report_is_daily and '僅 daily' or '切到 daily 可查看' }}
+
+
+
問題數
+
+ {% if report_is_daily %}{{ audit_30d_stats.total_issues if audit_30d_stats else '—' }}{% else %}—{% endif %}
+
+
視覺問題數
+
+
+
{% if error %}
{{ error }}
{% endif %}
+
-
| 時間 | 檔名 | 結果 | 問題 | 信心 | 耗時 | 錯誤 | 動作 |
{% for r in audit_records %}| {{ r.audited_at }} | {{ r.pptx_filename }} | {% if r.audit_status == 'passed' %}通過{% elif r.audit_status == 'failed' %}有問題{% elif r.audit_status == 'error' %}錯誤{% elif r.audit_status == 'skipped' %}跳過{% else %}{{ r.audit_status }}{% endif %} | {{ r.issues_count }} | {{ "%.2f"|format(r.confidence) }} | {{ r.duration_ms }} | {{ (r.error_msg or '')[:80] }} | {% if r.audit_status in ('failed','error') %}{% endif %} |
{% else %}| 尚無審核紀錄 |
{% endfor %}
-
| 檔名 | KB | 修改時間 | 狀態 |
{% for f in files %}{{ f.name }} | {{ f.size_kb }} | {{ f.mtime }} | 22:00 排程自動審核 |
{% else %}| 過去 7 日無 PPT 生成 |
{% endfor %}
+
+
+
+
審核歷史
+
視覺審核歷史({{ report_month }})
+
+
+
+ {% if report_is_daily %}
+
+
+ | 時間 | 檔名 | 結果 | 問題 | 信心 | 耗時 | 錯誤 | 動作 |
+
+
+ {% for r in audit_records %}
+
+ | {{ r.audited_at }} |
+ {{ r.pptx_filename }} |
+
+ {% if r.audit_status == 'passed' %}通過
+ {% elif r.audit_status == 'failed' %}有問題
+ {% elif r.audit_status == 'error' %}錯誤
+ {% elif r.audit_status == 'skipped' %}跳過
+ {% else %}{{ r.audit_status }}{% endif %}
+ |
+ {{ r.issues_count }} |
+ {{ "%.2f"|format(r.confidence) }} |
+ {{ r.duration_ms }} |
+ {{ (r.error_msg or '')[:80] }} |
+ {% if r.audit_status in ('failed','error') %}{% endif %} |
+
+ {% else %}
+ | 目前無 daily 審核歷史;請確認 {{ report_month }} 是否已完成 22:00 排程。 |
+ {% endfor %}
+
+
+ {% else %}
+
+
+
非每日型資料
+
只有「每日日報」會進入視覺審核流程。
+
目前此頁只顯示每日以外的簡報檔案;若要追蹤視覺結果,請切到「每日日報」。
+
+ 切到每日日報
+
+
+
+ {% endif %}
+
+
+
+
+
+
已產檔案
+
{{ report_month }} {{ selected_report_type.label }}
+
+
+
+
+
+ | 檔名 | KB | 修改時間 | 狀態 |
+
+
+ {% for f in files %}
+
+ {{ f.name }} |
+ {{ f.size_kb }} |
+ {{ f.mtime }} |
+ 22:00 排程掃描 |
+
+ {% else %}
+ | 本月無 {{ selected_report_type.label }} 簡報 |
+ {% endfor %}
+
+
+
+
- {% if rag_fixes %}
{% for fix in rag_fixes %}
{{ fix.pptx_filename }}{{ fix.audited_at }}{{ fix.error_msg }}
{% for h in fix.hits %}- {{ obs_label.insight(h.insight_type) }}相似度 {{ "%.2f"|format(h.similarity) }}{{ h.content }}{% if h.content|length >= 200 %}…{% endif %}
{% endfor %}
{% endfor %}
{% endif %}
- {% if (not audit_30d_stats or audit_30d_stats.total == 0) and not vision_enabled %}
為什麼這頁空?- PPT_VISION_ENABLED=false
- 188 主機需安裝 LibreOffice
- 需 Ollama 拉取 minicpm-v 模型
- 啟用後每日 22:00 排程寫入 ppt_audit_results
{% endif %}
+ {% if rag_fixes %}
+
+
+
+ {% for fix in rag_fixes %}
+
+
{{ fix.pptx_filename }}{{ fix.audited_at }}
+
{{ fix.error_msg }}
+
+ {% for h in fix.hits %}
+ -
+ {{ obs_label.insight(h.insight_type) }}
+ 相似度 {{ "%.2f"|format(h.similarity) }}{{ h.content }}{% if h.content|length >= 200 %}…{% endif %}
+
+ {% endfor %}
+
+
+ {% endfor %}
+
+
+ {% endif %}
+ {% if not vision_enabled %}
+
+
為什麼這頁空?
+
+ - PPT_VISION_ENABLED=false
+ - 188 主機需安裝 LibreOffice
+ - 需 Ollama 拉取 minicpm-v 模型
+ - 啟用後每日 22:00 排程寫入 ppt_audit_results
+
+
+ {% elif files|length == 0 %}
+
+
本月無資料
+
+ - 若為「每日日報」,檢查 {{ report_month }} 是否由 188 排程成功落盤。
+ - 若為「週報 / 月報 / 策略 / 競品 / 促銷」,請確認 Telegram 任務是否有對應產出。
+ - 可回到上方月份切換器看先前月份,或調整報表類型重新查詢。
+
+
+ {% endif %}
Ollama 優先策略 v5.0 — PPT 視覺 QA 產線