feat(p55): 3 個圓餅圖補齊 — promotion_review/ppt_audit/budget
All checks were successful
CD Pipeline / deploy (push) Successful in 7m39s
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:
@@ -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()
|
||||
|
||||
@@ -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 }};
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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_episodes(PromotionGate 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 {
|
||||
|
||||
Reference in New Issue
Block a user