From 34b1fdf82997ac62c4bdabfe0996d7c98edc147a Mon Sep 17 00:00:00 2001 From: OoO Date: Fri, 15 May 2026 14:08:15 +0800 Subject: [PATCH] fix: align ppt audit history UI and reporting flow --- routes/admin_observability_routes.py | 243 +++++++++++------ run_scheduler.py | 6 +- services/ppt_vision_service.py | 7 +- templates/admin/ppt_audit_history.html | 364 ++++++++++++++++++++++++- 4 files changed, 523 insertions(+), 97 deletions(-) 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_ENABLED
30 日總量
{{ 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 %} +
+
+ + 上個月 + + {{ report_month }} + {% if show_next_month %} + + 下個月 + + {% else %} + + {% endif %} +
+
+ {% for opt in report_type_options %} + + {{ opt.label }} + + {% endfor %} +
+
-
審核歷史

視覺審核歷史 100 筆

{% for r in audit_records %}{% else %}{% endfor %}
時間檔名結果問題信心耗時錯誤動作
{{ 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 %}
尚無審核紀錄
-
已產檔案

過去 7 日 PPT 檔案

{% for f in files %}{% else %}{% endfor %}
檔名KB修改時間狀態
{{ f.name }}{{ f.size_kb }}{{ f.mtime }}22:00 排程自動審核
過去 7 日無 PPT 生成
+
+
+
+
審核歷史
+

視覺審核歷史({{ report_month }})

+
+
+
+ {% if report_is_daily %} + + + + + + {% for r in audit_records %} + + + + + + + + + + + {% else %} + + {% endfor %} + +
時間檔名結果問題信心耗時錯誤動作
{{ 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 %}
目前無 daily 審核歷史;請確認 {{ report_month }} 是否已完成 22:00 排程。
+ {% else %} +
+
+
非每日型資料
+

只有「每日日報」會進入視覺審核流程。

+

目前此頁只顯示每日以外的簡報檔案;若要追蹤視覺結果,請切到「每日日報」。

+ + 切到每日日報 + +
+
+ {% endif %} +
+
+
+
+
+
已產檔案
+

{{ report_month }} {{ selected_report_type.label }}

+
+
+
+ + + + + + {% for f in files %} + + + + + + + {% else %} + + {% endfor %} + +
檔名KB修改時間狀態
{{ f.name }}{{ f.size_kb }}{{ f.mtime }}22:00 排程掃描
本月無 {{ selected_report_type.label }} 簡報
+
+
- {% if rag_fixes %}
RAG 修法建議

RAG 自動修法建議

{% 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 %} +
+
+
RAG 修法建議

RAG 自動修法建議

+
+
+ {% 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 產線