fix: align ppt audit history UI and reporting flow
All checks were successful
CD Pipeline / deploy (push) Successful in 59s

This commit is contained in:
OoO
2026-05-15 14:08:15 +08:00
parent 9dfb7d1514
commit 34b1fdf829
4 changed files with 523 additions and 97 deletions

View File

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

View File

@@ -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(

View File

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

View File

@@ -4,28 +4,374 @@
{% block ewooo_content %}
<style>
.ppt-hero,.ppt-panel,.ppt-table-shell{border:1px solid var(--obs-line);border-radius:26px;background:var(--obs-card);box-shadow:0 16px 38px rgba(70,46,28,.08)}
.ppt-hero{padding:clamp(1.2rem,2.4vw,2rem);background:radial-gradient(circle at 12% 14%,rgba(201,100,66,.18),transparent 24rem),radial-gradient(circle at 88% 8%,rgba(79,111,143,.14),transparent 22rem),linear-gradient(135deg,rgba(255,248,239,.98),rgba(255,255,255,.74))}.ppt-kicker{color:var(--obs-accent);font-size:.76rem;letter-spacing:.13em;text-transform:uppercase;font-weight:850}.ppt-title{margin:.45rem 0 .25rem;font-family:'Noto Sans TC','Inter',sans-serif;font-size:var(--obs-title-size);letter-spacing:-.055em;line-height:.98}.ppt-subtitle{color:var(--obs-muted);max-width:860px;line-height:1.7}.ppt-command{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:.75rem;margin-top:1rem}.ppt-signal{padding:.95rem;border:1px solid var(--obs-line);border-radius:20px;background:rgba(255,255,255,.62)}.ppt-label{color:var(--obs-muted);font-size:.72rem;letter-spacing:.1em;text-transform:uppercase}.ppt-value{display:block;margin-top:.28rem;font-size:var(--obs-value-size);font-weight:880;letter-spacing:-.045em}.ppt-grid{display:grid;grid-template-columns:minmax(0,1.2fr) minmax(330px,.8fr);gap:1rem;margin-top:1rem}.ppt-stack{display:grid;gap:1rem}.ppt-panel-head,.ppt-table-title{display:flex;justify-content:space-between;align-items:flex-start;gap:1rem;padding:1.05rem 1.1rem .25rem}.ppt-panel-title,.ppt-table-title h3{margin:.15rem 0 0;font-size:1.1rem;font-weight:850;letter-spacing:-.025em}.ppt-panel-body{padding:1rem 1.1rem 1.1rem}.ppt-table-shell{overflow:hidden;margin-top:1rem}.ppt-mini-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:.7rem}.ppt-mini{padding:.85rem;border:1px solid var(--obs-line);border-radius:18px;background:rgba(255,255,255,.58)}.ppt-mini strong{display:block;margin-top:.24rem;font-size:1.35rem;letter-spacing:-.04em}.fix-card{padding:.85rem;border:1px solid var(--obs-line);border-radius:18px;background:rgba(255,255,255,.58);margin-bottom:.7rem}.status-good{color:var(--obs-green)}.status-warn{color:var(--obs-amber)}.status-bad{color:var(--obs-red)}.status-blue{color:var(--obs-blue)}@media(max-width:1100px){.ppt-command{grid-template-columns:repeat(2,minmax(0,1fr))}.ppt-grid{grid-template-columns:1fr}}@media(max-width:720px){.ppt-command,.ppt-mini-grid{grid-template-columns:1fr}}
.ppt-hero, .ppt-panel, .ppt-table-shell {
border: 1px solid var(--obs-line);
border-radius: 26px;
background: var(--obs-card);
box-shadow: 0 16px 38px rgba(70, 46, 28, .08);
}
.ppt-hero {
padding: 1.4rem;
background: radial-gradient(circle at 12% 14%, rgba(201,100,66,.18), transparent 24rem),
radial-gradient(circle at 88% 8%, rgba(79,111,143,.14), transparent 22rem),
linear-gradient(135deg, rgba(255,248,239,.98), rgba(255,255,255,.74));
}
.ppt-kicker {
color: var(--obs-accent);
font-size: .76rem;
letter-spacing: .13em;
text-transform: uppercase;
font-weight: 850;
}
.ppt-title {
margin: .45rem 0 .25rem;
font-family: 'Noto Sans TC', 'Inter', sans-serif;
font-size: var(--obs-title-size);
letter-spacing: -0.055em;
line-height: .98;
}
.ppt-subtitle {
color: var(--obs-muted);
max-width: 860px;
line-height: 1.7;
}
.ppt-command {
display: grid;
grid-template-columns: repeat(4, minmax(0,1fr));
gap: .75rem;
margin-top: 1rem;
}
.ppt-signal {
padding: .95rem;
border: 1px solid var(--obs-line);
border-radius: 20px;
background: rgba(255,255,255,.62);
}
.ppt-label {
color: var(--obs-muted);
font-size: .72rem;
letter-spacing: .1em;
text-transform: uppercase;
}
.ppt-value {
display: block;
margin-top: .28rem;
font-size: var(--obs-value-size);
font-weight: 880;
letter-spacing: -.045em;
}
.ppt-toolbar {
margin-top: 1rem;
display: flex;
justify-content: space-between;
align-items: center;
gap: .75rem;
flex-wrap: wrap;
}
.ppt-type-tabs {
display: flex;
flex-wrap: wrap;
gap: .45rem;
}
.ppt-type-chip {
display: inline-flex;
align-items: center;
gap: .35rem;
}
.ppt-grid {
display: grid;
grid-template-columns: minmax(0,1.2fr) minmax(330px,.8fr);
gap: 1rem;
margin-top: 1rem;
}
.ppt-stack { display: grid; gap: 1rem; }
.ppt-panel-head, .ppt-table-title {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 1rem;
padding: 1.05rem 1.1rem .25rem;
}
.ppt-panel-title, .ppt-table-title h3 {
margin: .15rem 0 0;
font-size: 1.1rem;
font-weight: 850;
letter-spacing: -.025em;
}
.ppt-panel-body {
padding: 1rem 1.1rem 1.1rem;
}
.ppt-table-shell {
overflow: hidden;
margin-top: 1rem;
}
.ppt-table-shell .table {
min-width: 760px;
}
.ppt-mini-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0,1fr));
gap: .7rem;
}
.ppt-mini {
padding: .85rem;
border: 1px solid var(--obs-line);
border-radius: 18px;
background: rgba(255,255,255,.58);
}
.ppt-mini strong {
display: block;
margin-top: .24rem;
font-size: 1.35rem;
letter-spacing: -.04em;
}
.fix-card {
padding: .85rem;
border: 1px solid var(--obs-line);
border-radius: 18px;
background: rgba(255,255,255,.58);
margin-bottom: .7rem;
}
.status-good { color: var(--obs-green); }
.status-warn { color: var(--obs-amber); }
.status-bad { color: var(--obs-red); }
.status-blue { color: var(--obs-blue); }
@media(max-width:1100px) {
.ppt-command, .ppt-mini-grid { grid-template-columns: repeat(2,minmax(0,1fr)); }
.ppt-grid { grid-template-columns: 1fr; }
}
@media(max-width:720px) {
.ppt-command { grid-template-columns: 1fr; }
}
</style>
{% import "admin/_observability_labels.html" as obs_label %}
{% set report_is_daily = report_type == 'daily' %}
<div class="container-fluid mt-3">
<section class="ppt-hero"><div class="ppt-kicker"><i class="fas fa-search me-1"></i> PPT 視覺 QA 產線 · minicpm-v / AiderHeal / RAG 修法</div><h1 class="ppt-title">PPT 視覺 QA 產線</h1><p class="ppt-subtitle">這頁追蹤每份自動簡報是否通過視覺審核檔案產出、minicpm-v 審核、Telegram 推送、RAG 修法建議與 AiderHeal 自動修產生器。</p><div class="ppt-command"><div class="ppt-signal"><div class="ppt-label">視覺模型</div><span class="ppt-value {% if vision_enabled %}status-good{% else %}status-warn{% endif %}">{{ '啟用' if vision_enabled else '停用' }}</span><small class="text-muted">PPT_VISION_ENABLED</small></div><div class="ppt-signal"><div class="ppt-label">30 日總量</div><span class="ppt-value">{{ audit_30d_stats.total if audit_30d_stats else 0 }}</span><small class="text-muted">審核紀錄</small></div><div class="ppt-signal"><div class="ppt-label">通過率</div><span class="ppt-value {% if audit_30d_stats and audit_30d_stats.pass_rate >= 80 %}status-good{% elif audit_30d_stats and audit_30d_stats.pass_rate >= 60 %}status-warn{% else %}status-bad{% endif %}">{{ "%.0f"|format(audit_30d_stats.pass_rate) if audit_30d_stats else '—' }}{% if audit_30d_stats %}%{% endif %}</span><small class="text-muted">過去 30 日</small></div><div class="ppt-signal"><div class="ppt-label">問題數</div><span class="ppt-value {% if audit_30d_stats and audit_30d_stats.total_issues > 0 %}status-warn{% else %}status-good{% endif %}">{{ audit_30d_stats.total_issues if audit_30d_stats else 0 }}</span><small class="text-muted">視覺問題數</small></div></div></section>
<section class="ppt-hero">
<div class="ppt-kicker"><i class="fas fa-search me-1"></i> PPT 視覺 QA 產線 · minicpm-v / AiderHeal / RAG 修法</div>
<h1 class="ppt-title">PPT 視覺 QA 產線</h1>
<p class="ppt-subtitle">這頁追蹤每份自動簡報是否通過視覺審核檔案產出、minicpm-v 審核、Telegram 推送、RAG 修法建議與 AiderHeal 自動修產生器。</p>
<div class="ppt-command">
<div class="ppt-signal">
<div class="ppt-label">視覺模型</div>
<span class="ppt-value {% if vision_enabled %}status-good{% else %}status-warn{% endif %}">{{ '啟用' if vision_enabled else '停用' }}</span>
<small class="text-muted">PPT_VISION_ENABLED</small>
</div>
<div class="ppt-signal">
<div class="ppt-label">{{ report_month }} {{ selected_report_type.label }}</div>
<span class="ppt-value">{{ files|length }}</span>
<small class="text-muted">檔案數</small>
</div>
<div class="ppt-signal">
<div class="ppt-label">審核紀錄</div>
<span class="ppt-value {% if report_is_daily and audit_30d_stats and audit_30d_stats.pass_rate >= 80 %}status-good{% elif report_is_daily and audit_30d_stats and audit_30d_stats.pass_rate >= 60 %}status-warn{% elif report_is_daily and audit_30d_stats %}status-bad{% else %}status-blue{% endif %}">
{% if report_is_daily %}{{ audit_30d_stats.total if audit_30d_stats else '—' }}{% else %}—{% endif %}
</span>
<small class="text-muted">{{ report_is_daily and '僅 daily' or '切到 daily 可查看' }}</small>
</div>
<div class="ppt-signal">
<div class="ppt-label">問題數</div>
<span class="ppt-value {% if report_is_daily and audit_30d_stats and audit_30d_stats.total_issues > 0 %}status-warn{% elif report_is_daily and audit_30d_stats %}status-good{% else %}status-blue{% endif %}">
{% if report_is_daily %}{{ audit_30d_stats.total_issues if audit_30d_stats else '—' }}{% else %}—{% endif %}
</span>
<small class="text-muted">視覺問題數</small>
</div>
</div>
</section>
{% 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">
<div class="d-flex align-items-center gap-2">
<a class="btn btn-sm btn-outline-secondary" href="{{ url_for('admin_observability.ppt_audit_history', month=prev_month_label, report_type=report_type) }}">
<i class="fas fa-angle-left me-1"></i>上個月
</a>
<span class="badge bg-light text-dark border py-2 px-3">{{ report_month }}</span>
{% if show_next_month %}
<a class="btn btn-sm btn-outline-secondary" href="{{ url_for('admin_observability.ppt_audit_history', month=next_month_label, report_type=report_type) }}">
下個月<i class="fas fa-angle-right ms-1"></i>
</a>
{% else %}
<button class="btn btn-sm btn-outline-secondary" type="button" disabled>下個月<i class="fas fa-angle-right ms-1"></i></button>
{% endif %}
</div>
<div class="ppt-type-tabs">
{% for opt in report_type_options %}
<a class="btn btn-sm {% if report_type == opt.key %}btn-primary{% else %}btn-outline-primary{% endif %} ppt-type-chip" href="{{ url_for('admin_observability.ppt_audit_history', month=report_month, report_type=opt.key) }}">
{{ opt.label }}
</a>
{% endfor %}
</div>
</section>
<section class="ppt-grid">
<div class="ppt-stack">
<article class="ppt-table-shell"><div class="ppt-table-title"><div><div class="ppt-label">審核歷史</div><h3>視覺審核歷史 100 筆</h3></div></div><div class="table-responsive"><table class="table table-sm mb-0"><thead class="table-light"><tr><th>時間</th><th>檔名</th><th>結果</th><th class="text-end">問題</th><th class="text-end">信心</th><th class="text-end">耗時</th><th>錯誤</th><th>動作</th></tr></thead><tbody>{% for r in audit_records %}<tr><td><small>{{ r.audited_at }}</small></td><td><code>{{ r.pptx_filename }}</code></td><td>{% if r.audit_status == 'passed' %}<span class="badge bg-success">通過</span>{% elif r.audit_status == 'failed' %}<span class="badge bg-warning">有問題</span>{% elif r.audit_status == 'error' %}<span class="badge bg-danger">錯誤</span>{% elif r.audit_status == 'skipped' %}<span class="badge bg-secondary">跳過</span>{% else %}<span class="badge bg-light text-dark">{{ r.audit_status }}</span>{% endif %}</td><td class="text-end">{{ r.issues_count }}</td><td class="text-end">{{ "%.2f"|format(r.confidence) }}</td><td class="text-end">{{ r.duration_ms }}</td><td><small class="text-muted">{{ (r.error_msg or '')[:80] }}</small></td><td>{% if r.audit_status in ('failed','error') %}<button class="btn btn-sm btn-outline-warning" onclick="triggerAiderHeal({{ r.pptx_filename|tojson }}, {{ (r.error_msg or '')|tojson }})"><i class="fas fa-wrench me-1"></i>AiderHeal</button>{% endif %}</td></tr>{% else %}<tr><td colspan="8" class="text-center text-muted">尚無審核紀錄</td></tr>{% endfor %}</tbody></table></div></article>
<article class="ppt-table-shell"><div class="ppt-table-title"><div><div class="ppt-label">已產檔案</div><h3>過去 7 日 PPT 檔案</h3></div></div><div class="table-responsive"><table class="table table-sm mb-0"><thead class="table-light"><tr><th>檔名</th><th class="text-end">KB</th><th>修改時間</th><th>狀態</th></tr></thead><tbody>{% for f in files %}<tr><td><code>{{ f.name }}</code></td><td class="text-end">{{ f.size_kb }}</td><td><small>{{ f.mtime }}</small></td><td><small class="text-muted">22:00 排程自動審核</small></td></tr>{% else %}<tr><td colspan="4" class="text-center text-muted">過去 7 日無 PPT 生成</td></tr>{% endfor %}</tbody></table></div></article>
<article class="ppt-table-shell">
<div class="ppt-table-title">
<div>
<div class="ppt-label">審核歷史</div>
<h3>視覺審核歷史({{ report_month }}</h3>
</div>
</div>
<div class="table-responsive">
{% if report_is_daily %}
<table class="table table-sm mb-0">
<thead class="table-light">
<tr><th>時間</th><th>檔名</th><th>結果</th><th class="text-end">問題</th><th class="text-end">信心</th><th class="text-end">耗時</th><th>錯誤</th><th>動作</th></tr>
</thead>
<tbody>
{% for r in audit_records %}
<tr>
<td><small>{{ r.audited_at }}</small></td>
<td><code>{{ r.pptx_filename }}</code></td>
<td>
{% if r.audit_status == 'passed' %}<span class="badge bg-success">通過</span>
{% elif r.audit_status == 'failed' %}<span class="badge bg-warning">有問題</span>
{% elif r.audit_status == 'error' %}<span class="badge bg-danger">錯誤</span>
{% elif r.audit_status == 'skipped' %}<span class="badge bg-secondary">跳過</span>
{% else %}<span class="badge bg-light text-dark">{{ r.audit_status }}</span>{% endif %}
</td>
<td class="text-end">{{ r.issues_count }}</td>
<td class="text-end">{{ "%.2f"|format(r.confidence) }}</td>
<td class="text-end">{{ r.duration_ms }}</td>
<td><small class="text-muted">{{ (r.error_msg or '')[:80] }}</small></td>
<td>{% if r.audit_status in ('failed','error') %}<button class="btn btn-sm btn-outline-warning" onclick="triggerAiderHeal({{ r.pptx_filename|tojson }}, {{ (r.error_msg or '')|tojson }})"><i class="fas fa-wrench me-1"></i>AiderHeal</button>{% endif %}</td>
</tr>
{% else %}
<tr><td colspan="8" class="text-center text-muted">目前無 daily 審核歷史;請確認 {{ report_month }} 是否已完成 22:00 排程。</td></tr>
{% endfor %}
</tbody>
</table>
{% else %}
<div class="p-3">
<div class="ppt-empty p-3">
<div class="mb-2"><span class="badge bg-info text-dark">非每日型資料</span></div>
<p class="mb-2"><strong>只有「每日日報」會進入視覺審核流程。</strong></p>
<p class="mb-2">目前此頁只顯示每日以外的簡報檔案;若要追蹤視覺結果,請切到「每日日報」。</p>
<a class="btn btn-outline-primary btn-sm" href="{{ url_for('admin_observability.ppt_audit_history', month=report_month, report_type='daily') }}">
<i class="fas fa-calendar-day me-1"></i>切到每日日報
</a>
</div>
</div>
{% endif %}
</div>
</article>
<article class="ppt-table-shell">
<div class="ppt-table-title">
<div>
<div class="ppt-label">已產檔案</div>
<h3>{{ report_month }} {{ selected_report_type.label }}</h3>
</div>
</div>
<div class="table-responsive">
<table class="table table-sm mb-0">
<thead class="table-light">
<tr><th>檔名</th><th class="text-end">KB</th><th>修改時間</th><th>狀態</th></tr>
</thead>
<tbody>
{% for f in files %}
<tr>
<td><code>{{ f.name }}</code></td>
<td class="text-end">{{ f.size_kb }}</td>
<td><small>{{ f.mtime }}</small></td>
<td><small class="text-muted">22:00 排程掃描</small></td>
</tr>
{% else %}
<tr><td colspan="4" class="text-center text-muted">本月無 {{ selected_report_type.label }} 簡報</td></tr>
{% endfor %}
</tbody>
</table>
</div>
</article>
</div>
<aside class="ppt-stack">
{% if audit_30d_stats and audit_30d_stats.total > 0 %}<article class="ppt-panel"><div class="ppt-panel-head"><div><div class="ppt-label">30 日審核分布</div><h2 class="ppt-panel-title">審核結果分布</h2></div></div><div class="ppt-panel-body"><div class="obs-chart-frame"><canvas id="pptAuditPieChart"></canvas></div><div class="ppt-mini-grid mt-3"><div class="ppt-mini"><span class="ppt-label">通過</span><strong class="status-good">{{ audit_30d_stats.passed }}</strong></div><div class="ppt-mini"><span class="ppt-label">失敗</span><strong class="{% if audit_30d_stats.failed > 0 %}status-warn{% endif %}">{{ audit_30d_stats.failed }}</strong></div><div class="ppt-mini"><span class="ppt-label">錯誤</span><strong class="{% if audit_30d_stats.error > 0 %}status-bad{% endif %}">{{ audit_30d_stats.error }}</strong></div><div class="ppt-mini"><span class="ppt-label">信心分</span><strong>{{ "%.2f"|format(audit_30d_stats.avg_confidence) }}</strong></div></div></div></article>{% endif %}
{% if top_failure_files %}<article class="ppt-panel"><div class="ppt-panel-head"><div><div class="ppt-label">失敗熱點</div><h2 class="ppt-panel-title">Top 失敗檔案</h2></div></div><div class="ppt-panel-body">{% for f in top_failure_files %}<div class="fix-card"><code>{{ f.filename }}</code><div class="d-flex justify-content-between mt-1"><small class="text-muted">{{ f.last_audit }}</small><span class="badge bg-warning">{{ f.attempts }} 次</span></div><small class="text-muted">問題 {{ f.total_issues }}</small></div>{% endfor %}</div></article>{% endif %}
{% if report_is_daily %}
{% if audit_30d_stats and audit_30d_stats.total > 0 %}
<article class="ppt-panel">
<div class="ppt-panel-head">
<div><div class="ppt-label">{{ report_month }} 審核分布</div><h2 class="ppt-panel-title">審核結果分布</h2></div>
</div>
<div class="ppt-panel-body">
<div class="obs-chart-frame"><canvas id="pptAuditPieChart"></canvas></div>
<div class="ppt-mini-grid mt-3">
<div class="ppt-mini"><span class="ppt-label">通過</span><strong class="status-good">{{ audit_30d_stats.passed }}</strong></div>
<div class="ppt-mini"><span class="ppt-label">失敗</span><strong class="{% if audit_30d_stats.failed > 0 %}status-warn{% endif %}">{{ audit_30d_stats.failed }}</strong></div>
<div class="ppt-mini"><span class="ppt-label">錯誤</span><strong class="{% if audit_30d_stats.error > 0 %}status-bad{% endif %}">{{ audit_30d_stats.error }}</strong></div>
<div class="ppt-mini"><span class="ppt-label">信心分</span><strong>{{ "%.2f"|format(audit_30d_stats.avg_confidence) }}</strong></div>
</div>
</div>
</article>
{% endif %}
{% if top_failure_files %}
<article class="ppt-panel">
<div class="ppt-panel-head">
<div><div class="ppt-label">失敗熱點</div><h2 class="ppt-panel-title">Top 失敗檔案</h2></div>
</div>
<div class="ppt-panel-body">
{% for f in top_failure_files %}<div class="fix-card"><code>{{ f.filename }}</code><div class="d-flex justify-content-between mt-1"><small class="text-muted">{{ f.last_audit }}</small><span class="badge bg-warning">{{ f.attempts }} 次</span></div><small class="text-muted">問題 {{ f.total_issues }}</small></div>{% endfor %}
</div>
</article>
{% endif %}
{% if not (audit_30d_stats and audit_30d_stats.total > 0) %}
<article class="ppt-panel">
<div class="ppt-panel-head">
<div><div class="ppt-label">審核摘要</div><h2 class="ppt-panel-title">本月審核狀態</h2></div>
</div>
<div class="ppt-panel-body">
<div class="ppt-empty p-3">
<p class="mb-1"><strong>尚未形成 daily 審核統計。</strong></p>
<p class="mb-0">排程完成後,這裡會顯示本月通過率、失敗檔案與修復建議。</p>
</div>
</div>
</article>
{% endif %}
{% endif %}
</aside>
</section>
{% if rag_fixes %}<section class="ppt-panel mt-3"><div class="ppt-panel-head"><div><div class="ppt-label">RAG 修法建議</div><h2 class="ppt-panel-title">RAG 自動修法建議</h2></div></div><div class="ppt-panel-body">{% for fix in rag_fixes %}<div class="fix-card"><strong><code>{{ fix.pptx_filename }}</code></strong><small class="text-muted ms-2">{{ fix.audited_at }}</small><div class="small status-bad mt-1">{{ fix.error_msg }}</div><ul class="list-unstyled mt-2 mb-0 small">{% for h in fix.hits %}<li class="mb-1"><span class="badge bg-info me-1">{{ obs_label.insight(h.insight_type) }}</span><span class="badge bg-light text-dark me-1">相似度 {{ "%.2f"|format(h.similarity) }}</span>{{ h.content }}{% if h.content|length >= 200 %}…{% endif %}</li>{% endfor %}</ul></div>{% endfor %}</div></section>{% endif %}
{% if (not audit_30d_stats or audit_30d_stats.total == 0) and not vision_enabled %}<div class="alert alert-info mt-3"><strong>為什麼這頁空?</strong><ul class="mb-0 small mt-2"><li>PPT_VISION_ENABLED=false</li><li>188 主機需安裝 LibreOffice</li><li>需 Ollama 拉取 minicpm-v 模型</li><li>啟用後每日 22:00 排程寫入 ppt_audit_results</li></ul></div>{% endif %}
{% if rag_fixes %}
<section class="ppt-panel mt-3">
<div class="ppt-panel-head">
<div><div class="ppt-label">RAG 修法建議</div><h2 class="ppt-panel-title">RAG 自動修法建議</h2></div>
</div>
<div class="ppt-panel-body">
{% for fix in rag_fixes %}
<div class="fix-card">
<strong><code>{{ fix.pptx_filename }}</code></strong><small class="text-muted ms-2">{{ fix.audited_at }}</small>
<div class="small status-bad mt-1">{{ fix.error_msg }}</div>
<ul class="list-unstyled mt-2 mb-0 small">
{% for h in fix.hits %}
<li class="mb-1">
<span class="badge bg-info me-1">{{ obs_label.insight(h.insight_type) }}</span>
<span class="badge bg-light text-dark me-1">相似度 {{ "%.2f"|format(h.similarity) }}</span>{{ h.content }}{% if h.content|length >= 200 %}…{% endif %}
</li>
{% endfor %}
</ul>
</div>
{% endfor %}
</div>
</section>
{% endif %}
{% if not vision_enabled %}
<div class="alert alert-info mt-3">
<strong>為什麼這頁空?</strong>
<ul class="mb-0 small mt-2">
<li>PPT_VISION_ENABLED=false</li>
<li>188 主機需安裝 LibreOffice</li>
<li>需 Ollama 拉取 minicpm-v 模型</li>
<li>啟用後每日 22:00 排程寫入 ppt_audit_results</li>
</ul>
</div>
{% elif files|length == 0 %}
<div class="alert alert-warning mt-3">
<strong>本月無資料</strong>
<ul class="mb-0 small mt-2">
<li>若為「每日日報」,檢查 {{ report_month }} 是否由 188 排程成功落盤。</li>
<li>若為「週報 / 月報 / 策略 / 競品 / 促銷」,請確認 Telegram 任務是否有對應產出。</li>
<li>可回到上方月份切換器看先前月份,或調整報表類型重新查詢。</li>
</ul>
</div>
{% endif %}
<p class="text-muted mt-3"><small><i class="fas fa-robot me-1"></i>Ollama 優先策略 v5.0 — PPT 視覺 QA 產線</small></p>
</div>