feat(p55): 3 個圓餅圖補齊 — promotion_review/ppt_audit/budget
All checks were successful
CD Pipeline / deploy (push) Successful in 7m39s

S-1: promotion_review 蒸餾池 30d doughnut
- 取代原 col-md-2 卡片網格
- 8 種狀態各自分色:
  pending(灰) / awaiting_review(黃) / approved(綠) /
  rejected_quality(紅) / rejected_hallucination(深紅) /
  rejected_duplicate(橘) / rejected_human(暗紅) / expired(灰)
- 左圓餅 + 右表格雙視角

S-2: ppt_audit 30d 結果 doughnut
- 取代部分 col-md-2 卡片佈局
- 通過(綠)/失敗(黃)/錯誤(紅)/跳過(灰) 圓餅
- 6 個 KPI 卡併入右側 col-6 grid(總筆數/通過率/通過/issue/失敗/錯誤)
- 統一視覺語言:「圖+表」雙視角

S-3: budget 當月各 provider 成本 doughnut
- 新加 query:ai_calls.cost_usd GROUP BY provider 月初至今
- 8 個 provider 分色(本地 Ollama 綠系 vs 付費 LLM 橘紫系)
- 左圓餅 + 右表格(供應商/成本/佔比)+ 總計列

chart.js 視覺化從 7 個 → 10 個:
- hourly trend line
- 30d cost stacked bar
- 三主機 sparkline × 3
- RAG feedback doughnut
- KPI sparkline × 3 (calls/cost/errors)
- verdict doughnut
- heal 7d trend
- **promotion_review status doughnut(新)**
- **ppt_audit pass/fail doughnut(新)**
- **provider cost doughnut(新)**

Phase 38→55 累計 20 commits / 10 觀測頁 / 10 chart.js / DB 100%。

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
OoO
2026-05-05 01:15:58 +08:00
parent 90e8366a8d
commit df2311d4f0
4 changed files with 241 additions and 55 deletions

View File

@@ -1575,6 +1575,17 @@ def budget_dashboard():
'cost': float(r[2] or 0),
})
# Phase 55 S-3: 當月各 provider cost 分布(給圓餅圖用)
provider_cost_month = session.execute(
sa_text("""
SELECT provider, COALESCE(SUM(cost_usd), 0) AS cost
FROM ai_calls
WHERE called_at >= :ms AND cost_usd > 0
GROUP BY provider ORDER BY cost DESC
"""),
{'ms': month_start},
).fetchall()
# Phase 47 K-3: top 5 cost-burning caller (當月)
top_cost_callers = session.execute(
sa_text("""
@@ -1643,6 +1654,10 @@ def budget_dashboard():
}
for r in top_cost_callers
],
provider_cost_month=[
{'provider': r[0], 'cost': float(r[1] or 0)}
for r in provider_cost_month
],
price_rec_7d=[
{
'strategy': r[0], 'count': int(r[1] or 0),
@@ -1656,6 +1671,7 @@ def budget_dashboard():
return render_template('admin/budget.html', active_page='obs_budget', rows=[],
budget_strategies=[], cost_trend_30d=[],
top_cost_callers=[], price_rec_7d=[],
provider_cost_month=[],
error=f'查詢失敗: {type(e).__name__}: {str(e)[:200]}')
finally:
session.close()

View File

@@ -99,6 +99,44 @@
</tbody>
</table>
<!-- Phase 55 S-3: 當月各 provider 成本分布圓餅 -->
{% if provider_cost_month %}
<div class="card mb-3">
<div class="card-header"><strong><i class="fas fa-chart-pie me-2"></i>當月各供應商成本分布</strong>
<small class="text-muted">資料來源ai_calls.cost_usd GROUP BY provider · 月初至今</small>
</div>
<div class="card-body">
<div class="row g-2 align-items-center">
<div class="col-md-5">
<canvas id="providerCostPieChart" height="180"></canvas>
</div>
<div class="col-md-7">
<table class="table table-sm mb-0" style="font-size: 0.9em;">
<thead class="table-light">
<tr><th>供應商</th><th class="text-end">成本</th><th class="text-end">佔比</th></tr>
</thead>
<tbody>
{% set total_pc = (provider_cost_month | sum(attribute='cost')) or 0.0001 %}
{% for p in provider_cost_month %}
<tr>
<td><code>{{ p.provider }}</code></td>
<td class="text-end">${{ "%.4f"|format(p.cost) }}</td>
<td class="text-end">{{ "%.1f"|format(p.cost / total_pc * 100) }}%</td>
</tr>
{% endfor %}
<tr style="border-top: 2px solid #dee2e6;">
<td><strong>總計</strong></td>
<td class="text-end"><strong>${{ "%.2f"|format(total_pc) }}</strong></td>
<td class="text-end"><strong>100%</strong></td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
{% endif %}
<!-- Phase 47 K-3: Top 5 cost-burning caller -->
{% if top_cost_callers %}
<div class="card mb-3">
@@ -173,6 +211,39 @@
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.1/dist/chart.umd.min.js"></script>
<script>
// Phase 55 S-3: provider 月度成本圓餅
(function() {
const data = {{ provider_cost_month | default([]) | tojson }};
const el = document.getElementById('providerCostPieChart');
if (!el || !data.length) return;
const colors = {
'gcp_ollama': '#28a745', 'ollama_secondary': '#5cb85c', 'ollama_111': '#a3d9a4',
'gemini': '#fd7e14', 'claude': '#6610f2', 'nim': '#0dcaf0',
'openrouter': '#6c757d', 'nim_via_elephant': '#20c997',
};
new Chart(el, {
type: 'doughnut',
data: {
labels: data.map(d => d.provider),
datasets: [{
data: data.map(d => d.cost),
backgroundColor: data.map((d, i) => colors[d.provider] || `hsl(${(i*47)%360}, 65%, 55%)`),
borderWidth: 1, borderColor: '#fff',
}]
},
options: {
responsive: true, maintainAspectRatio: false,
plugins: {
legend: { position: 'right', labels: { font: { size: 11 } } },
tooltip: { callbacks: { label: c => {
const total = data.reduce((a,b)=>a+b.cost, 0);
return `${c.label}: $${c.parsed.toFixed(4)} (${(c.parsed/total*100).toFixed(1)}%)`;
}}}
}
}
});
})();
// Phase 50 N-2: 30d cost trend stacked bar chart
(function() {
const raw = {{ cost_trend_30d | tojson }};

View File

@@ -130,48 +130,55 @@
<code>python3 -c "from services.ppt_vision_service import ppt_vision_service; print(ppt_vision_service.check_ppt_file('reports/xxx.pptx'))"</code>
</p>
<!-- Phase 47 K-6: 30d 統計 -->
<!-- Phase 47 K-6 + Phase 55 S-2: 30d 統計(含圓餅圖) -->
{% if audit_30d_stats and audit_30d_stats.total > 0 %}
<div class="card mb-3">
<div class="card-header"><strong><i class="fas fa-chart-pie me-2"></i>過去 30 日 PPT 審核統計</strong>
<small class="text-muted">資料來源ppt_audit_results 全表聚合</small>
</div>
<div class="card-body">
<div class="row g-2">
<div class="col-md-2 col-sm-4">
<div class="border rounded p-2 text-center">
<small class="text-muted d-block">總筆數</small>
<strong style="font-size: 1.4em;">{{ audit_30d_stats.total }}</strong>
</div>
<div class="row g-2 align-items-center">
<div class="col-md-5">
<canvas id="pptAuditPieChart" height="200"></canvas>
</div>
<div class="col-md-2 col-sm-4">
<div class="border rounded p-2 text-center">
<small class="text-muted d-block">通過</small>
<strong class="text-success" style="font-size: 1.4em;">{{ audit_30d_stats.passed }}</strong>
</div>
</div>
<div class="col-md-2 col-sm-4">
<div class="border rounded p-2 text-center">
<small class="text-muted d-block">失敗</small>
<strong class="{% if audit_30d_stats.failed > 0 %}text-warning{% endif %}" style="font-size: 1.4em;">{{ audit_30d_stats.failed }}</strong>
</div>
</div>
<div class="col-md-2 col-sm-4">
<div class="border rounded p-2 text-center">
<small class="text-muted d-block">錯誤</small>
<strong class="{% if audit_30d_stats.error > 0 %}text-danger{% endif %}" style="font-size: 1.4em;">{{ audit_30d_stats.error }}</strong>
</div>
</div>
<div class="col-md-2 col-sm-4">
<div class="border rounded p-2 text-center">
<small class="text-muted d-block">通過率</small>
<strong class="{% if audit_30d_stats.pass_rate >= 80 %}text-success{% elif audit_30d_stats.pass_rate >= 60 %}text-warning{% else %}text-danger{% endif %}" style="font-size: 1.4em;">{{ "%.0f"|format(audit_30d_stats.pass_rate) }}%</strong>
</div>
</div>
<div class="col-md-2 col-sm-4">
<div class="border rounded p-2 text-center">
<small class="text-muted d-block">總 issue 數</small>
<strong style="font-size: 1.4em;">{{ audit_30d_stats.total_issues }}</strong>
<div class="col-md-7">
<div class="row g-2">
<div class="col-6">
<div class="border rounded p-2 text-center">
<small class="text-muted d-block">總筆數</small>
<strong style="font-size: 1.4em;">{{ audit_30d_stats.total }}</strong>
</div>
</div>
<div class="col-6">
<div class="border rounded p-2 text-center">
<small class="text-muted d-block">通過率</small>
<strong class="{% if audit_30d_stats.pass_rate >= 80 %}text-success{% elif audit_30d_stats.pass_rate >= 60 %}text-warning{% else %}text-danger{% endif %}" style="font-size: 1.4em;">{{ "%.0f"|format(audit_30d_stats.pass_rate) }}%</strong>
</div>
</div>
<div class="col-6">
<div class="border rounded p-2 text-center">
<small class="text-muted d-block">通過</small>
<strong class="text-success" style="font-size: 1.4em;">{{ audit_30d_stats.passed }}</strong>
</div>
</div>
<div class="col-6">
<div class="border rounded p-2 text-center">
<small class="text-muted d-block">總 issue 數</small>
<strong style="font-size: 1.4em;">{{ audit_30d_stats.total_issues }}</strong>
</div>
</div>
<div class="col-6">
<div class="border rounded p-2 text-center">
<small class="text-muted d-block">失敗</small>
<strong class="{% if audit_30d_stats.failed > 0 %}text-warning{% endif %}" style="font-size: 1.4em;">{{ audit_30d_stats.failed }}</strong>
</div>
</div>
<div class="col-6">
<div class="border rounded p-2 text-center">
<small class="text-muted d-block">錯誤</small>
<strong class="{% if audit_30d_stats.error > 0 %}text-danger{% endif %}" style="font-size: 1.4em;">{{ audit_30d_stats.error }}</strong>
</div>
</div>
</div>
</div>
</div>
@@ -234,7 +241,43 @@
</small></p>
</div>
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.1/dist/chart.umd.min.js"></script>
<script>
// Phase 55 S-2: PPT audit doughnut
(function() {
const stats = {{ audit_30d_stats | default({}) | tojson }};
const el = document.getElementById('pptAuditPieChart');
if (!el || !stats.total) return;
const data = [
{ label: '通過', value: stats.passed || 0, color: '#198754' },
{ label: '失敗', value: stats.failed || 0, color: '#ffc107' },
{ label: '錯誤', value: stats.error || 0, color: '#dc3545' },
{ label: '跳過', value: stats.skipped || 0, color: '#6c757d' },
].filter(d => d.value > 0);
if (!data.length) return;
new Chart(el, {
type: 'doughnut',
data: {
labels: data.map(d => d.label),
datasets: [{
data: data.map(d => d.value),
backgroundColor: data.map(d => d.color),
borderWidth: 1, borderColor: '#fff',
}]
},
options: {
responsive: true, maintainAspectRatio: false,
plugins: {
legend: { position: 'right', labels: { font: { size: 12 } } },
tooltip: { callbacks: { label: c => {
const total = data.reduce((a,b)=>a+b.value, 0);
return `${c.label}: ${c.parsed} (${(c.parsed/total*100).toFixed(1)}%)`;
}}}
}
}
});
})();
async function triggerAiderHeal(pptxFilename, errorMsg) {
if (!confirm(`觸發 AiderHeal 自動修復?\n\n檔案:${pptxFilename}\n錯誤:${(errorMsg || '').substring(0, 200)}\n\nAiderHeal 會嘗試修 services/ppt_generator.py 並 git push 到 main 觸發 CD。`)) return;
try {

View File

@@ -76,32 +76,44 @@
</div>
{% endif %}
<!-- Phase 47 K-4: 蒸餾池 30d 分布 -->
<!-- Phase 47 K-4 + Phase 55 S-1: 蒸餾池 30d 圓餅 + 表格 -->
{% if episode_distribution_30d %}
<div class="card mb-3">
<div class="card-header"><strong><i class="fas fa-flask me-2"></i>蒸餾池 30 日狀態分布</strong>
<small class="text-muted">資料來源learning_episodes不只看 awaiting看完整流動</small>
<small class="text-muted">資料來源learning_episodesPromotionGate 4 階段流動全景</small>
</div>
<div class="card-body">
<div class="row g-2">
{% for status, cnt in episode_distribution_30d.items() %}
<div class="col-md-2 col-sm-4">
<div class="border rounded p-2 text-center">
<small class="text-muted d-block">
{% if status == 'pending' %}<i class="fas fa-hourglass-start"></i> 待處理
{% elif status == 'awaiting_review' %}<i class="fas fa-user-clock"></i> 待審核
{% elif status == 'approved' %}<i class="fas fa-check-circle text-success"></i> 已晉升
{% elif status == 'rejected_quality' %}<i class="fas fa-times text-danger"></i> 品質拒
{% elif status == 'rejected_hallucination' %}<i class="fas fa-times text-danger"></i> 幻覺拒
{% elif status == 'rejected_duplicate' %}<i class="fas fa-clone text-warning"></i> 重複拒
{% elif status == 'rejected_human' %}<i class="fas fa-user-times text-danger"></i> 人工拒
{% elif status == 'expired' %}<i class="fas fa-clock text-muted"></i> 已過期
{% else %}{{ status }}{% endif %}
</small>
<strong style="font-size: 1.4em;">{{ cnt }}</strong>
</div>
<div class="row g-2 align-items-center">
<div class="col-md-5">
<canvas id="episodeDistChart" height="200"></canvas>
</div>
<div class="col-md-7">
<table class="table table-sm mb-0" style="font-size: 0.9em;">
<thead class="table-light">
<tr><th>狀態</th><th class="text-end">筆數</th><th class="text-end">佔比</th></tr>
</thead>
<tbody>
{% set total_ep = (episode_distribution_30d.values() | sum) or 1 %}
{% for status, cnt in episode_distribution_30d.items() %}
<tr>
<td>
{% if status == 'pending' %}<i class="fas fa-hourglass-start me-1"></i>待處理
{% elif status == 'awaiting_review' %}<i class="fas fa-user-clock text-warning me-1"></i>待審核
{% elif status == 'approved' %}<i class="fas fa-check-circle text-success me-1"></i>已晉升
{% elif status == 'rejected_quality' %}<i class="fas fa-times text-danger me-1"></i>品質拒
{% elif status == 'rejected_hallucination' %}<i class="fas fa-times text-danger me-1"></i>幻覺拒
{% elif status == 'rejected_duplicate' %}<i class="fas fa-clone text-warning me-1"></i>重複拒
{% elif status == 'rejected_human' %}<i class="fas fa-user-times text-danger me-1"></i>人工拒
{% elif status == 'expired' %}<i class="fas fa-clock text-muted me-1"></i>已過期
{% else %}{{ status }}{% endif %}
</td>
<td class="text-end"><strong>{{ cnt }}</strong></td>
<td class="text-end">{{ "%.1f"|format(cnt / total_ep * 100) }}%</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% endfor %}
</div>
</div>
</div>
@@ -182,7 +194,51 @@
</small></p>
</div>
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.1/dist/chart.umd.min.js"></script>
<script>
// Phase 55 S-1: 蒸餾池 doughnut
(function() {
const dist = {{ episode_distribution_30d | default({}) | tojson }};
const el = document.getElementById('episodeDistChart');
if (!el || !Object.keys(dist).length) return;
const colorMap = {
'pending': '#6c757d', 'awaiting_review': '#ffc107',
'approved': '#198754',
'rejected_quality': '#dc3545', 'rejected_hallucination': '#bd2130',
'rejected_duplicate': '#fd7e14', 'rejected_human': '#a71d2a',
'expired': '#adb5bd',
};
const labelMap = {
'pending': '待處理', 'awaiting_review': '待審核',
'approved': '已晉升',
'rejected_quality': '品質拒', 'rejected_hallucination': '幻覺拒',
'rejected_duplicate': '重複拒', 'rejected_human': '人工拒',
'expired': '已過期',
};
const keys = Object.keys(dist);
new Chart(el, {
type: 'doughnut',
data: {
labels: keys.map(k => labelMap[k] || k),
datasets: [{
data: keys.map(k => dist[k]),
backgroundColor: keys.map(k => colorMap[k] || '#999'),
borderWidth: 1, borderColor: '#fff',
}]
},
options: {
responsive: true, maintainAspectRatio: false,
plugins: {
legend: { position: 'right', labels: { font: { size: 11 } } },
tooltip: { callbacks: { label: c => {
const total = Object.values(dist).reduce((a,b)=>a+b, 0);
return `${c.label}: ${c.parsed} (${(c.parsed/total*100).toFixed(1)}%)`;
}}}
}
}
});
})();
async function approveEpisode(id, btn) {
btn.disabled = true; btn.innerHTML = '<i class="fas fa-spinner fa-spin"></i> 處理中...';
try {