diff --git a/routes/admin_observability_routes.py b/routes/admin_observability_routes.py
index bc4735b..c0bc878 100644
--- a/routes/admin_observability_routes.py
+++ b/routes/admin_observability_routes.py
@@ -580,6 +580,47 @@ def ai_calls_dashboard():
{'since': since},
).fetchall()
+ # 5b. Phase 47 K-2: by model 細分(不只 provider,到實際 model)
+ by_model = session.execute(
+ sa_text("""
+ SELECT model, provider, COUNT(*) AS calls,
+ COALESCE(SUM(input_tokens + output_tokens), 0) AS tokens,
+ COALESCE(SUM(cost_usd), 0) AS cost,
+ COALESCE(AVG(duration_ms), 0) AS avg_ms,
+ COUNT(*) FILTER (WHERE status NOT IN ('ok','cache_only')) AS errors
+ FROM ai_calls
+ WHERE called_at >= :since
+ AND model IS NOT NULL AND model != ''
+ GROUP BY model, provider
+ ORDER BY calls DESC
+ LIMIT 15
+ """),
+ {'since': since},
+ ).fetchall()
+
+ # 5c. Phase 47 K-2: hourly 呼叫量趨勢(24 個 bucket)
+ hourly_trend = session.execute(
+ sa_text("""
+ SELECT date_trunc('hour', called_at) AS hr,
+ COUNT(*) AS calls,
+ COALESCE(SUM(cost_usd), 0) AS cost,
+ COUNT(*) FILTER (WHERE status NOT IN ('ok','cache_only')) AS errors
+ FROM ai_calls
+ WHERE called_at >= NOW() - INTERVAL '24 hours'
+ GROUP BY hr ORDER BY hr ASC
+ """),
+ ).fetchall()
+
+ # 5d. Phase 47 K-2: agent_context 最近 10 筆(OpenClaw/Hermes 對話上下文)
+ recent_contexts = session.execute(
+ sa_text("""
+ SELECT created_at, agent_name, context_key, ttl_minutes,
+ LEFT(context_val, 120) AS preview
+ FROM agent_context
+ ORDER BY created_at DESC LIMIT 10
+ """),
+ ).fetchall()
+
# 5. Phase 39 D-3: caller × RAG 命中率 × MCP 編排率(跨表 JOIN)
# 展現「AI 自動化專業」核心:每個 caller 多大比例走了 RAG / MCP
caller_richness = session.execute(
@@ -638,6 +679,33 @@ def ai_calls_dashboard():
for r in recent
],
callers=[r[0] for r in callers],
+ by_model=[
+ {
+ 'model': r[0], 'provider': r[1],
+ 'calls': int(r[2] or 0), 'tokens': int(r[3] or 0),
+ 'cost': float(r[4] or 0), 'avg_ms': int(r[5] or 0),
+ 'errors': int(r[6] or 0),
+ }
+ for r in by_model
+ ],
+ hourly_trend=[
+ {
+ 'hour': r[0].strftime('%H:%M') if r[0] else '',
+ 'calls': int(r[1] or 0),
+ 'cost': float(r[2] or 0),
+ 'errors': int(r[3] or 0),
+ }
+ for r in hourly_trend
+ ],
+ recent_contexts=[
+ {
+ 'created_at': r[0].strftime('%Y-%m-%d %H:%M') if r[0] else '',
+ 'agent_name': r[1], 'context_key': r[2],
+ 'ttl_minutes': int(r[3] or 0),
+ 'preview': r[4] or '',
+ }
+ for r in recent_contexts
+ ],
caller_richness=[
{
'caller': r[0],
@@ -660,6 +728,7 @@ def ai_calls_dashboard():
hours=hours, caller_filter=caller_filter,
provider_filter=provider_filter,
summary={}, by_provider=[], recent=[], callers=[], caller_richness=[],
+ by_model=[], hourly_trend=[], recent_contexts=[],
error=f'查詢失敗: {type(e).__name__}: {str(e)[:200]}',
)
finally:
@@ -740,11 +809,63 @@ def promotion_review_list():
except Exception:
pass # rag_service import 失敗(feature flag OFF)→ 略過
+ # Phase 47 K-4: 蒸餾池 status 分布(30d)
+ ep_distribution = session.execute(
+ sa_text("""
+ SELECT promotion_status, COUNT(*) AS cnt
+ FROM learning_episodes
+ WHERE created_at >= NOW() - INTERVAL '30 days'
+ GROUP BY promotion_status ORDER BY cnt DESC
+ """),
+ ).fetchall()
+ episode_distribution_30d = {r[0]: int(r[1] or 0) for r in ep_distribution}
+
+ # Phase 47 K-4: ai_insights 最近 10 筆已晉升(type/created_at 視覺)
+ latest_insights = session.execute(
+ sa_text("""
+ SELECT id, insight_type, period, product_sku, created_at,
+ LEFT(content, 160) AS preview
+ FROM ai_insights
+ ORDER BY created_at DESC LIMIT 10
+ """),
+ ).fetchall()
+
+ # Phase 47 K-4: agent_strategy_weights TOP 12(OpenClaw 學習權重)
+ strategy_weights = session.execute(
+ sa_text("""
+ SELECT strategy_key, weight, success_cnt, fail_cnt, updated_at
+ FROM agent_strategy_weights
+ ORDER BY (success_cnt + fail_cnt) DESC
+ LIMIT 12
+ """),
+ ).fetchall()
+
return render_template(
'admin/promotion_review.html',
active_page='obs_promotion_review',
episodes=episodes,
kb_size=kb_size,
+ episode_distribution_30d=episode_distribution_30d,
+ latest_insights=[
+ {
+ 'id': r[0], 'insight_type': r[1], 'period': r[2],
+ 'product_sku': r[3],
+ 'created_at': r[4].strftime('%Y-%m-%d %H:%M') if r[4] else '',
+ 'preview': r[5] or '',
+ }
+ for r in latest_insights
+ ],
+ strategy_weights=[
+ {
+ 'strategy_key': r[0], 'weight': float(r[1] or 0),
+ 'success': int(r[2] or 0), 'fail': int(r[3] or 0),
+ 'updated_at': r[4].strftime('%Y-%m-%d') if r[4] else '',
+ 'success_rate': (
+ float(r[2] or 0) / float((r[2] or 0) + (r[3] or 0)) * 100
+ ) if ((r[2] or 0) + (r[3] or 0)) > 0 else 0,
+ }
+ for r in strategy_weights
+ ],
error=None,
)
except Exception as e:
@@ -753,6 +874,9 @@ def promotion_review_list():
active_page='obs_promotion_review',
episodes=[],
kb_size=0,
+ episode_distribution_30d={},
+ latest_insights=[],
+ strategy_weights=[],
error=f'查詢失敗: {type(e).__name__}: {str(e)[:200]}',
)
finally:
@@ -875,6 +999,54 @@ def quality_trend_dashboard():
except Exception:
pass
+ # Phase 47 K-5: action_outcomes verdict 統計(ADR-012 閉環學習結果)
+ action_outcomes_stats = []
+ action_plans_status = []
+ rag_overall_dist = []
+ try:
+ session = get_session()
+ try:
+ # action_outcomes verdict 分布(30d)
+ ao_rows = session.execute(
+ sa_text(f"""
+ SELECT verdict, COUNT(*) AS cnt
+ FROM action_outcomes
+ WHERE created_at >= NOW() - INTERVAL '{int(days)} days'
+ GROUP BY verdict ORDER BY cnt DESC
+ """),
+ ).fetchall()
+ action_outcomes_stats = [{'verdict': r[0] or 'unknown', 'count': int(r[1] or 0)} for r in ao_rows]
+
+ # action_plans status 分布(30d)
+ ap_rows = session.execute(
+ sa_text(f"""
+ SELECT status, plan_type, COUNT(*) AS cnt
+ FROM action_plans
+ WHERE created_at >= NOW() - INTERVAL '{int(days)} days'
+ GROUP BY status, plan_type ORDER BY cnt DESC
+ """),
+ ).fetchall()
+ action_plans_status = [
+ {'status': r[0], 'plan_type': r[1] or 'misc', 'count': int(r[2] or 0)}
+ for r in ap_rows
+ ]
+
+ # rag_query_log 整體 feedback 分布(不只 caller-level,整體)
+ rag_dist_rows = session.execute(
+ sa_text(f"""
+ SELECT feedback_score, COUNT(*) AS cnt
+ FROM rag_query_log
+ WHERE queried_at >= NOW() - INTERVAL '{int(days)} days'
+ AND feedback_score IS NOT NULL
+ GROUP BY feedback_score ORDER BY feedback_score
+ """),
+ ).fetchall()
+ rag_overall_dist = [{'score': int(r[0] or 0), 'count': int(r[1] or 0)} for r in rag_dist_rows]
+ finally:
+ session.close()
+ except Exception:
+ pass
+
return render_template(
'admin/quality_trend.html',
active_page='obs_quality_trend',
@@ -883,6 +1055,9 @@ def quality_trend_dashboard():
recommendations=recommendations,
episode_distribution=episode_distribution,
rag_root_causes=rag_root_causes,
+ action_outcomes_stats=action_outcomes_stats,
+ action_plans_status=action_plans_status,
+ rag_overall_dist=rag_overall_dist,
error=None,
)
except Exception as e:
@@ -891,6 +1066,7 @@ def quality_trend_dashboard():
active_page='obs_quality_trend',
days=days, trends=[], recommendations=[],
episode_distribution={}, rag_root_causes=[],
+ action_outcomes_stats=[], action_plans_status=[], rag_overall_dist=[],
error=f'查詢失敗: {type(e).__name__}: {str(e)[:200]}',
)
@@ -950,6 +1126,51 @@ def budget_dashboard():
'updated_at': b[5].strftime('%Y-%m-%d %H:%M') if b[5] else '-',
})
+ # Phase 47 K-3: 30d daily cost trend by provider
+ cost_30d = session.execute(
+ sa_text("""
+ SELECT date_trunc('day', called_at)::date AS d,
+ provider, COALESCE(SUM(cost_usd), 0) AS cost
+ FROM ai_calls
+ WHERE called_at >= NOW() - INTERVAL '30 days'
+ GROUP BY d, provider
+ ORDER BY d DESC, cost DESC
+ """),
+ ).fetchall()
+ cost_trend_30d = []
+ for r in cost_30d:
+ cost_trend_30d.append({
+ 'date': r[0].strftime('%m-%d') if r[0] else '',
+ 'provider': r[1],
+ 'cost': float(r[2] or 0),
+ })
+
+ # Phase 47 K-3: top 5 cost-burning caller (當月)
+ top_cost_callers = session.execute(
+ sa_text("""
+ SELECT caller, COUNT(*) AS calls,
+ COALESCE(SUM(cost_usd), 0) AS cost,
+ COALESCE(SUM(input_tokens + output_tokens), 0) AS tokens
+ FROM ai_calls
+ WHERE called_at >= :ms
+ AND cost_usd > 0
+ GROUP BY caller
+ ORDER BY cost DESC LIMIT 5
+ """),
+ {'ms': month_start},
+ ).fetchall()
+
+ # Phase 47 K-3: ai_price_recommendations 7d 統計
+ price_rec_7d = session.execute(
+ sa_text("""
+ SELECT strategy, COUNT(*) AS cnt,
+ COALESCE(AVG(confidence), 0) AS avg_conf
+ FROM ai_price_recommendations
+ WHERE created_at >= NOW() - INTERVAL '7 days'
+ GROUP BY strategy ORDER BY cnt DESC
+ """),
+ ).fetchall()
+
# Phase 39 D-4: RAG 自動建議策略(針對超 80% 的 row)
budget_strategies = []
over_threshold_rows = [r for r in rows if r.get('ratio', 0) >= 0.8]
@@ -984,11 +1205,27 @@ def budget_dashboard():
active_page='obs_budget',
rows=rows,
budget_strategies=budget_strategies,
+ cost_trend_30d=cost_trend_30d,
+ top_cost_callers=[
+ {
+ 'caller': r[0], 'calls': int(r[1] or 0),
+ 'cost': float(r[2] or 0), 'tokens': int(r[3] or 0),
+ }
+ for r in top_cost_callers
+ ],
+ price_rec_7d=[
+ {
+ 'strategy': r[0], 'count': int(r[1] or 0),
+ 'avg_confidence': round(float(r[2] or 0), 3),
+ }
+ for r in price_rec_7d
+ ],
error=None,
)
except Exception as e:
return render_template('admin/budget.html', active_page='obs_budget', rows=[],
- budget_strategies=[],
+ budget_strategies=[], cost_trend_30d=[],
+ top_cost_callers=[], price_rec_7d=[],
error=f'查詢失敗: {type(e).__name__}: {str(e)[:200]}')
finally:
session.close()
@@ -1262,6 +1499,62 @@ def ppt_audit_history():
except Exception:
vision_enabled = False
+ # Phase 47 K-6: 30d 統計 + top failure files
+ audit_30d_stats = {}
+ top_failure_files = []
+ try:
+ 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 >= 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 '',
+ }
+ for r in top_fail_rows
+ ]
+ finally:
+ s_ppt.close()
+ except Exception:
+ pass
+
# Phase 41 E-2: 對最近 3 筆 failed audit 跑 RAG 找相似修法
rag_fixes = []
failed_records = [r for r in audit_records if r.get('audit_status') in ('failed', 'error')][:3]
@@ -1303,6 +1596,8 @@ def ppt_audit_history():
files=files,
audit_records=audit_records,
rag_fixes=rag_fixes,
+ audit_30d_stats=audit_30d_stats,
+ top_failure_files=top_failure_files,
vision_enabled=vision_enabled,
error=error,
)
@@ -1504,6 +1799,115 @@ def host_health_dashboard():
except Exception:
pass # 表可能尚未 migration,失敗安全
+ # Phase 47 K-1: incidents + heal_logs 詳細列表 + playbooks 排行 + backup + embed queue
+ recent_incidents = []
+ recent_heals = []
+ playbook_ranking = []
+ backup_history = []
+ embed_queue_pending = 0
+ embed_queue_failed = 0
+ try:
+ s3 = get_session()
+ try:
+ inc_rows = s3.execute(
+ sa_text("""
+ SELECT id, created_at, task_name, error_type, severity,
+ status, error_message, retry_count, resolved_at
+ FROM incidents
+ ORDER BY created_at DESC LIMIT 10
+ """),
+ ).fetchall()
+ recent_incidents = [
+ {
+ 'id': r[0], 'created_at': r[1].strftime('%Y-%m-%d %H:%M'),
+ 'task_name': r[2], 'error_type': r[3], 'severity': r[4],
+ 'status': r[5], 'error_message': (r[6] or '')[:200],
+ 'retry_count': int(r[7] or 0),
+ 'resolved_at': r[8].strftime('%Y-%m-%d %H:%M') if r[8] else None,
+ }
+ for r in inc_rows
+ ]
+ heal_rows = s3.execute(
+ sa_text("""
+ SELECT h.id, h.created_at, h.action_type, h.result,
+ h.duration_ms, h.action_detail, h.incident_id,
+ i.error_type
+ FROM heal_logs h
+ LEFT JOIN incidents i ON i.id = h.incident_id
+ ORDER BY h.created_at DESC LIMIT 10
+ """),
+ ).fetchall()
+ recent_heals = [
+ {
+ 'id': r[0], 'created_at': r[1].strftime('%Y-%m-%d %H:%M'),
+ 'action_type': r[2], 'result': r[3],
+ 'duration_ms': int(r[4] or 0),
+ 'action_detail': (r[5] or '')[:160],
+ 'incident_id': r[6], 'error_type': r[7],
+ }
+ for r in heal_rows
+ ]
+
+ # playbooks 庫排行(success_count + fail_count + 是否 active)
+ pb_rows = s3.execute(
+ sa_text("""
+ SELECT name, error_type, action_type, severity_min,
+ success_count, fail_count, is_active, cooldown_min
+ FROM playbooks
+ ORDER BY (success_count + fail_count) DESC, success_count DESC
+ LIMIT 12
+ """),
+ ).fetchall()
+ playbook_ranking = [
+ {
+ 'name': r[0], 'error_type': r[1], 'action_type': r[2],
+ 'severity': r[3], 'success': int(r[4] or 0),
+ 'fail': int(r[5] or 0), 'is_active': bool(r[6]),
+ 'cooldown_min': int(r[7] or 0),
+ 'success_rate': (
+ float(r[4] or 0) / float((r[4] or 0) + (r[5] or 0)) * 100
+ ) if ((r[4] or 0) + (r[5] or 0)) > 0 else 0,
+ }
+ for r in pb_rows
+ ]
+
+ # backup_log 7d 歷史
+ bk_rows = s3.execute(
+ sa_text("""
+ SELECT created_at, backup_type, status, file_size_bytes,
+ duration_seconds, error_message
+ FROM backup_log
+ WHERE created_at >= NOW() - INTERVAL '7 days'
+ ORDER BY created_at DESC LIMIT 10
+ """),
+ ).fetchall()
+ backup_history = [
+ {
+ 'created_at': r[0].strftime('%Y-%m-%d %H:%M'),
+ 'backup_type': r[1], 'status': r[2],
+ 'size_mb': round(float(r[3] or 0) / (1024 * 1024), 1),
+ 'duration_s': round(float(r[4] or 0), 1),
+ 'error': (r[5] or '')[:120],
+ }
+ for r in bk_rows
+ ]
+
+ # embedding_retry_queue pending / failed
+ embed_q = s3.execute(
+ sa_text("""
+ SELECT
+ COUNT(*) FILTER (WHERE status = 'pending'),
+ COUNT(*) FILTER (WHERE status = 'failed')
+ FROM embedding_retry_queue
+ """),
+ ).fetchone()
+ embed_queue_pending = int(embed_q[0] or 0)
+ embed_queue_failed = int(embed_q[1] or 0)
+ finally:
+ s3.close()
+ except Exception:
+ pass
+
return render_template(
'admin/host_health.html',
active_page='obs_host_health',
@@ -1513,4 +1917,10 @@ def host_health_dashboard():
health_history=health_history,
mcp_24h=mcp_24h,
aiops_summary=aiops_summary,
+ recent_incidents=recent_incidents,
+ recent_heals=recent_heals,
+ playbook_ranking=playbook_ranking,
+ backup_history=backup_history,
+ embed_queue_pending=embed_queue_pending,
+ embed_queue_failed=embed_queue_failed,
)
diff --git a/templates/admin/ai_calls_dashboard.html b/templates/admin/ai_calls_dashboard.html
index 0fc8a96..eac1e1e 100644
--- a/templates/admin/ai_calls_dashboard.html
+++ b/templates/admin/ai_calls_dashboard.html
@@ -124,6 +124,111 @@
{% endif %}
+
+ {% if hourly_trend %}
+
+
+
+
+
+
+ | 時段 | 呼叫數 | 成本 USD | 錯誤 | 流量分布 |
+
+
+
+ {% set max_calls = (hourly_trend | map(attribute='calls') | max) or 1 %}
+ {% for h in hourly_trend %}
+
+ {{ h.hour }} |
+ {{ "{:,}".format(h.calls) }} |
+ ${{ "%.3f"|format(h.cost) }} |
+
+ {% if h.errors > 0 %}{{ h.errors }}
+ {% else %}0{% endif %}
+ |
+
+
+ |
+
+ {% endfor %}
+
+
+
+
+ {% endif %}
+
+
+ {% if by_model %}
+
+
+
+
+
+
+ | 模型 | 供應商 |
+ 呼叫 | Token |
+ 成本 USD | 平均耗時 |
+ 錯誤 |
+
+
+
+ {% for m in by_model %}
+
+ {{ m.model[:35] }} |
+ {{ m.provider }} |
+ {{ "{:,}".format(m.calls) }} |
+ {{ "{:,}".format(m.tokens) }} |
+ ${{ "%.4f"|format(m.cost) }} |
+ {{ m.avg_ms }} ms |
+
+ {% if m.errors > 0 %}{{ m.errors }}
+ {% else %}0{% endif %}
+ |
+
+ {% endfor %}
+
+
+
+
+ {% endif %}
+
+
+ {% if recent_contexts %}
+
+
+
+
+
+
+ | 時間 | Agent | Key |
+ TTL min | 預覽 |
+
+
+
+ {% for c in recent_contexts %}
+
+ | {{ c.created_at }} |
+ {{ c.agent_name }} |
+ {{ c.context_key }} |
+ {{ c.ttl_minutes }} |
+ {{ c.preview }}{% if c.preview|length >= 120 %}…{% endif %} |
+
+ {% endfor %}
+
+
+
+
+ {% endif %}
+
diff --git a/templates/admin/budget.html b/templates/admin/budget.html
index 3cffa5e..330364c 100644
--- a/templates/admin/budget.html
+++ b/templates/admin/budget.html
@@ -99,8 +99,88 @@
+
+ {% if top_cost_callers %}
+
+
+
+
+
+ | 呼叫端 | 呼叫 | Token | 成本 | 佔比 |
+
+
+ {% set max_cost = (top_cost_callers | map(attribute='cost') | max) or 1 %}
+ {% for c in top_cost_callers %}
+
+ {{ c.caller }} |
+ {{ "{:,}".format(c.calls) }} |
+ {{ "{:,}".format(c.tokens) }} |
+ ${{ "%.2f"|format(c.cost) }} |
+
+
+ |
+
+ {% endfor %}
+
+
+
+
+ {% endif %}
+
+
+ {% if cost_trend_30d %}
+
+
+
+
+
+ | 日期 | 供應商 | 成本 USD |
+
+
+ {% for r in cost_trend_30d %}
+
+ {{ r.date }} |
+ {{ r.provider }} |
+ ${{ "%.4f"|format(r.cost) }} |
+
+ {% endfor %}
+
+
+
+
+ {% endif %}
+
+
+ {% if price_rec_7d %}
+
+
+
+
+ {% for p in price_rec_7d %}
+
+
+ {{ p.strategy }}
+ {{ p.count }}
+ 信心 {{ "%.2f"|format(p.avg_confidence) }}
+
+
+ {% endfor %}
+
+
+
+ {% endif %}
+
- Operation Ollama-First v5.0 / Phase 29 — 預算控管
+ Operation Ollama-First v5.0 / Phase 47 — 預算控管
+ (5 表深挖:ai_call_budgets / ai_calls / ai_price_recommendations / ai_insights / cost_throttle_state)
diff --git a/templates/admin/host_health.html b/templates/admin/host_health.html
index 0275243..88a5cb6 100644
--- a/templates/admin/host_health.html
+++ b/templates/admin/host_health.html
@@ -267,8 +267,204 @@
{% endif %}
+
+
+
+
+ {% if recent_incidents %}
+
+
+
+ | 建立時間 | 任務 | 錯誤類型 |
+ 嚴重度 | 狀態 | 重試 |
+ 錯誤訊息 | 解決時間 |
+
+
+
+ {% for i in recent_incidents %}
+
+ | {{ i.created_at }} |
+ {{ i.task_name }} |
+ {{ i.error_type }} |
+
+ {% if i.severity in ('P0', 'P1') %}{{ i.severity }}
+ {% elif i.severity == 'P2' %}{{ i.severity }}
+ {% else %}{{ i.severity }}{% endif %}
+ |
+
+ {% if i.status == 'open' %}未解決
+ {% elif i.status == 'resolved' %}已解決
+ {% else %}{{ i.status }}{% endif %}
+ |
+ {{ i.retry_count }} |
+ {{ i.error_message }} |
+ {{ i.resolved_at or '—' }} |
+
+ {% endfor %}
+
+
+ {% else %}
+
+ 尚無 incident 紀錄(即系統尚未觸發過 AutoHeal)
+
+ {% endif %}
+
+
+
+
+
+
+
+ {% if recent_heals %}
+
+
+
+ | 時間 | 動作 | 結果 |
+ 耗時 ms | 關聯 Incident | 細節 |
+
+
+
+ {% for h in recent_heals %}
+
+ | {{ h.created_at }} |
+ {{ h.action_type or '—' }} |
+
+ {% if h.result == 'success' %}成功
+ {% elif h.result == 'failed' %}失敗
+ {% elif h.result == 'skipped' %}跳過
+ {% else %}{{ h.result }}{% endif %}
+ |
+ {{ h.duration_ms }} |
+ #{{ h.incident_id }} · {{ h.error_type or '—' }} |
+ {{ h.action_detail }} |
+
+ {% endfor %}
+
+
+ {% else %}
+
+ 尚無 heal log(一鍵 AutoHeal 觸發後將累積)
+
+ {% endif %}
+
+
+
+
+
+
+
+ {% if playbook_ranking %}
+
+
+
+ | Playbook | 錯誤類型 | 動作 |
+ 嚴重度 |
+ 成功 | 失敗 |
+ 成功率 |
+ 狀態 | 冷卻 min |
+
+
+
+ {% for p in playbook_ranking %}
+
+ | {{ p.name }} |
+ {{ p.error_type }} |
+ {{ p.action_type }} |
+ {{ p.severity }} |
+ {{ p.success }} |
+ {{ p.fail }} |
+
+ {% if (p.success + p.fail) > 0 %}
+
+ {{ "%.0f"|format(p.success_rate) }}%
+
+ {% else %}—{% endif %}
+ |
+
+ {% if p.is_active %}
+ 啟用
+ {% else %}
+ 停用
+ {% endif %}
+ |
+ {{ p.cooldown_min }} |
+
+ {% endfor %}
+
+
+ {% else %}
+
+ 尚無 playbook 資料(migration 013 + 020 是否已跑?)
+
+ {% endif %}
+
+
+
+
+
+
+
+ {% if backup_history %}
+
+
+
+ | 時間 | 類型 | 狀態 |
+ 大小 (MB) | 耗時 s |
+ 錯誤 |
+
+
+
+ {% for b in backup_history %}
+
+ | {{ b.created_at }} |
+ {{ b.backup_type }} |
+
+ {% if b.status == 'success' %}成功
+ {% elif b.status == 'failed' %}失敗
+ {% else %}{{ b.status }}{% endif %}
+ |
+ {{ "{:,}".format(b.size_mb) }} |
+ {{ b.duration_s }} |
+ {{ b.error }} |
+
+ {% endfor %}
+
+
+ {% else %}
+
+ 過去 7 日無備份紀錄(每日 03:00 cron 執行)
+
+ {% endif %}
+
+
+
+
+ {% if embed_queue_pending > 0 or embed_queue_failed > 0 %}
+
+ Embedding 重試佇列:
+ 待處理 {{ embed_queue_pending }} 筆 ·
+ 失敗 {{ embed_queue_failed }} 筆
+
+ 資料來源:embedding_retry_queue — 卡住的 embedding 工作,需檢查 Ollama 健康
+
+
+ {% endif %}
+
- Operation Ollama-First v5.0 / Phase 40 — 主機健康監控(含 24h 歷史 / MCP / AIOps / AutoHeal L2)
+ Operation Ollama-First v5.0 / Phase 47 — 主機健康監控
+ (8 表深挖:host_health_probes / mcp_calls / incidents / heal_logs / playbooks / backup_log / embedding_retry_queue / ai_call_budgets)
diff --git a/templates/admin/ppt_audit_history.html b/templates/admin/ppt_audit_history.html
index 9eb1002..ec9ef66 100644
--- a/templates/admin/ppt_audit_history.html
+++ b/templates/admin/ppt_audit_history.html
@@ -130,8 +130,107 @@
python3 -c "from services.ppt_vision_service import ppt_vision_service; print(ppt_vision_service.check_ppt_file('reports/xxx.pptx'))"
+
+ {% if audit_30d_stats and audit_30d_stats.total > 0 %}
+
+
+
+
+
+
+ 總筆數
+ {{ audit_30d_stats.total }}
+
+
+
+
+ 通過
+ {{ audit_30d_stats.passed }}
+
+
+
+
+ 失敗
+ {{ audit_30d_stats.failed }}
+
+
+
+
+ 錯誤
+ {{ audit_30d_stats.error }}
+
+
+
+
+ 通過率
+ {{ "%.0f"|format(audit_30d_stats.pass_rate) }}%
+
+
+
+
+ 總 issue 數
+ {{ audit_30d_stats.total_issues }}
+
+
+
+
+
+ 通過 audit 平均信心度:{{ "%.2f"|format(audit_30d_stats.avg_confidence) }}
+
+
+
+ {% endif %}
+
+
+ {% if top_failure_files %}
+
+
+
+
+
+
+ | 檔名 |
+ 失敗次數 |
+ 總 issue |
+ 最近審核 |
+
+
+
+ {% for f in top_failure_files %}
+
+ {{ f.filename }} |
+ {{ f.attempts }} |
+ {{ f.total_issues }} |
+ {{ f.last_audit }} |
+
+ {% endfor %}
+
+
+
+
+ {% endif %}
+
+ {% if (not audit_30d_stats or audit_30d_stats.total == 0) and not vision_enabled %}
+
+
+
為什麼這頁空?
+
+ - PPT_VISION_ENABLED=false(在 .env 設為 true 啟用)
+ - 188 主機需安裝 LibreOffice:
apt install libreoffice
+ - 需 Ollama 拉取 minicpm-v 模型(用於 PPT 視覺檢查)
+ - 啟用後每日 22:00 cron 自動掃當天新生 .pptx,審核結果寫入 ppt_audit_results 累積歷史
+
+
+ {% endif %}
+
- Operation Ollama-First v5.0 / Phase 40 — PPT 視覺審核歷史(含 AiderHeal L2)
+ Operation Ollama-First v5.0 / Phase 47 — PPT 視覺審核歷史
+ (3 表深挖:ppt_audit_results / reports/ 檔案系統 / ai_insights RAG)
diff --git a/templates/admin/promotion_review.html b/templates/admin/promotion_review.html
index c6693f3..fe60884 100644
--- a/templates/admin/promotion_review.html
+++ b/templates/admin/promotion_review.html
@@ -76,8 +76,109 @@
{% endif %}
+
+ {% if episode_distribution_30d %}
+
+
+
+
+ {% for status, cnt in episode_distribution_30d.items() %}
+
+
+
+ {% if status == 'pending' %} 待處理
+ {% elif status == 'awaiting_review' %} 待審核
+ {% elif status == 'approved' %} 已晉升
+ {% elif status == 'rejected_quality' %} 品質拒
+ {% elif status == 'rejected_hallucination' %} 幻覺拒
+ {% elif status == 'rejected_duplicate' %} 重複拒
+ {% elif status == 'rejected_human' %} 人工拒
+ {% elif status == 'expired' %} 已過期
+ {% else %}{{ status }}{% endif %}
+
+ {{ cnt }}
+
+
+ {% endfor %}
+
+
+
+ {% endif %}
+
+
+ {% if latest_insights %}
+
+
+
+
+
+ | # | 類型 | 期間 | SKU | 建立時間 | 預覽 |
+
+
+ {% for i in latest_insights %}
+
+ #{{ i.id }} |
+ {{ i.insight_type }} |
+ {{ i.period or '—' }} |
+ {{ i.product_sku or '—' }} |
+ {{ i.created_at }} |
+ {{ i.preview }}{% if i.preview|length >= 160 %}…{% endif %} |
+
+ {% endfor %}
+
+
+
+
+ {% endif %}
+
+
+ {% if strategy_weights %}
+
+
+
+
+
+
+ | 策略 Key |
+ 權重 |
+ 成功 |
+ 失敗 |
+ 成功率 |
+ 更新時間 |
+
+
+
+ {% for s in strategy_weights %}
+
+ {{ s.strategy_key }} |
+ {{ "%.2f"|format(s.weight) }} |
+ {{ s.success }} |
+ {{ s.fail }} |
+
+ {% if (s.success + s.fail) > 0 %}
+
+ {{ "%.0f"|format(s.success_rate) }}%
+
+ {% else %}—{% endif %}
+ |
+ {{ s.updated_at }} |
+
+ {% endfor %}
+
+
+
+
+ {% endif %}
+
- Operation Ollama-First v5.0 / Phase 29 — RAG 學習晉升審核
+ Operation Ollama-First v5.0 / Phase 47 — RAG 學習晉升審核
+ (4 表深挖:learning_episodes / ai_insights / agent_strategy_weights / rag_query_log)
diff --git a/templates/admin/quality_trend.html b/templates/admin/quality_trend.html
index ab214ae..2f44caa 100644
--- a/templates/admin/quality_trend.html
+++ b/templates/admin/quality_trend.html
@@ -150,8 +150,98 @@
+
+ {% if rag_overall_dist %}
+
+
+
+
+ {% set total_fb = (rag_overall_dist | sum(attribute='count')) or 1 %}
+ {% for r in rag_overall_dist %}
+
+
+
+ {% for _ in range(r.score) %}{% endfor %}
+ {% for _ in range(5 - r.score) %}{% endfor %}
+
+ {{ r.count }}
+ {{ "%.1f"|format(r.count / total_fb * 100) }}%
+
+
+ {% endfor %}
+
+
+
+ {% endif %}
+
+
+ {% if action_plans_status %}
+
+
+
+
+
+ | 狀態 | 計畫類型 | 數量 |
+
+
+ {% for a in action_plans_status %}
+
+ |
+ {% if a.status == 'pending' %}{{ a.status }}
+ {% elif a.status == 'approved' %}{{ a.status }}
+ {% elif a.status == 'executed' %}{{ a.status }}
+ {% elif a.status == 'rejected' %}{{ a.status }}
+ {% else %}{{ a.status }}{% endif %}
+ |
+ {{ a.plan_type }} |
+ {{ a.count }} |
+
+ {% endfor %}
+
+
+
+
+ {% endif %}
+
+
+ {% if action_outcomes_stats %}
+
+
+
+
+ {% set total_ao = (action_outcomes_stats | sum(attribute='count')) or 1 %}
+ {% for r in action_outcomes_stats %}
+
+
+
+ {% if r.verdict == 'effective' %} 有效
+ {% elif r.verdict == 'backfired' %} 適得其反
+ {% elif r.verdict == 'neutral' %} 無顯著效果
+ {% else %}{{ r.verdict }}{% endif %}
+
+ {{ r.count }}
+ {{ "%.1f"|format(r.count / total_ao * 100) }}%
+
+
+ {% endfor %}
+
+
+
+ {% endif %}
+
- Operation Ollama-First v5.0 / Phase 29 — Caller 反饋趨勢
+ Operation Ollama-First v5.0 / Phase 47 — Caller 反饋趨勢
+ (6 表深挖:rag_query_log / learning_episodes / ai_insights / action_plans / action_outcomes / agent_strategy_weights)
{% endblock %}