Files
ewoooc/templates/admin/budget.html
OoO df2311d4f0
All checks were successful
CD Pipeline / deploy (push) Successful in 7m39s
feat(p55): 3 個圓餅圖補齊 — promotion_review/ppt_audit/budget
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>
2026-05-05 01:15:58 +08:00

330 lines
13 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
{% extends "ewoooc_base.html" %}
{% block title %}預算控管{% endblock %}
{% block ewooo_content %}
<div class="container-fluid mt-3">
<h2 class="mb-3"><i class="fas fa-wallet me-2"></i>預算控管
<small class="text-muted">ai_call_budgets × 當月實際支出即時對比</small>
</h2>
{% if error %}<div class="alert alert-warning"><strong><i class="fas fa-exclamation-triangle me-1"></i></strong> {{ error }}</div>{% endif %}
<p class="text-muted small">
依 ADR-028 預算 + Phase 20 成本節流:每小時 cron 檢查當月支出,
線性外推月底成本超 110% → 自動節流claude→gemini fallback
手動編輯預算後立即生效(不需重啟)。
</p>
<!-- Phase 39 D-4: 一鍵立即重算 throttle 狀態L2 自動化)-->
<div class="mb-3">
<button class="btn btn-warning btn-sm" onclick="forceThrottle()">
<i class="fas fa-bolt me-1"></i>立即重算節流狀態(不等 cron
</button>
<small class="text-muted ms-2">用途:發現某 provider 飆超 110% 時立即 evaluate毋需等下次每小時 cron。</small>
</div>
<!-- Phase 39 D-4: RAG 自動建議策略 -->
{% if budget_strategies %}
<div class="card mb-3" style="border-left: 4px solid #6f42c1;">
<div class="card-header bg-light">
<strong><i class="fas fa-lightbulb me-2"></i>RAG 自動策略建議</strong>
<small class="text-muted">— 從知識庫 ai_insights 召回過去類似超支情境的應對策略</small>
</div>
<div class="card-body p-2">
<ul class="list-unstyled small mb-0">
{% for s in budget_strategies %}
<li class="mb-2 p-2" style="background: #fafafa; border-radius: 4px;">
<span class="badge bg-info text-dark me-1">{{ s.insight_type }}</span>
<span class="badge bg-secondary me-1">相似度 {{ "%.2f"|format(s.similarity) }}</span>
<span>{{ s.content }}{% if s.content|length >= 240 %}…{% endif %}</span>
</li>
{% endfor %}
</ul>
</div>
</div>
{% endif %}
<table class="table table-hover">
<thead class="table-light">
<tr>
<th>週期</th><th>供應商</th>
<th class="text-end">已花費 (USD)</th>
<th>預算 (USD)</th><th>警示閾值 %</th>
<th class="text-end">使用率</th><th>狀態</th>
<th>更新時間</th><th>動作</th>
</tr>
</thead>
<tbody>
{% for r in rows %}
<tr {% if r.throttled %}class="table-danger"{% elif r.ratio >= 0.8 %}class="table-warning"{% endif %}>
<td><span class="badge bg-secondary">{{ r.period }}</span></td>
<td><code>{{ r.provider }}</code></td>
<td class="text-end">${{ "%.2f"|format(r.spent) }}</td>
<td>
<input type="number" step="0.01" min="0.01" value="{{ "%.2f"|format(r.budget_usd) }}"
class="form-control form-control-sm budget-input"
data-budget-id="{{ r.id }}" style="width: 110px;">
</td>
<td>
<input type="number" min="1" max="100" value="{{ r.alert_pct }}"
class="form-control form-control-sm alert-input"
data-budget-id="{{ r.id }}" style="width: 80px;">
</td>
<td class="text-end">
<strong class="{% if r.ratio >= 1.10 %}text-danger{% elif r.ratio >= 0.8 %}text-warning{% else %}text-success{% endif %}">
{{ "%.0f"|format(r.ratio * 100) }}%
</strong>
</td>
<td>
{% if r.throttled %}
<span class="badge bg-danger"><i class="fas fa-exclamation-triangle me-1"></i>已節流</span>
{% elif r.ratio >= 0.8 %}
<span class="badge bg-warning"><i class="fas fa-exclamation me-1"></i>接近上限</span>
{% else %}
<span class="badge bg-success"><i class="fas fa-check me-1"></i>正常</span>
{% endif %}
</td>
<td><small>{{ r.updated_at }}</small></td>
<td>
<button class="btn btn-primary btn-sm save-budget-btn"
data-budget-id="{{ r.id }}" onclick="saveBudget({{ r.id }})">
<i class="fas fa-save me-1"></i>儲存
</button>
</td>
</tr>
{% else %}
<tr><td colspan="9" class="text-center text-muted">無預算資料(需先跑 migrations/025</td></tr>
{% endfor %}
</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">
<div class="card-header"><strong><i class="fas fa-fire me-2"></i>當月 Top 5 燒錢呼叫端</strong>
<small class="text-muted">資料來源ai_calls.cost_usdcaller 級彙總)</small>
</div>
<div class="card-body p-0">
<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">Token</th><th class="text-end">成本</th><th>佔比</th></tr>
</thead>
<tbody>
{% set max_cost = (top_cost_callers | map(attribute='cost') | max) or 1 %}
{% for c in top_cost_callers %}
<tr>
<td><code>{{ c.caller }}</code></td>
<td class="text-end">{{ "{:,}".format(c.calls) }}</td>
<td class="text-end">{{ "{:,}".format(c.tokens) }}</td>
<td class="text-end"><strong>${{ "%.2f"|format(c.cost) }}</strong></td>
<td>
<div class="progress" style="height: 6px;">
<div class="progress-bar bg-warning" style="width: {{ (c.cost / max_cost * 100) | round | int }}%"></div>
</div>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
{% endif %}
<!-- Phase 47 K-3 + Phase 50 N-2: 30d cost trend by provider (chart.js stacked) -->
{% if cost_trend_30d %}
<div class="card mb-3">
<div class="card-header"><strong><i class="fas fa-chart-line me-2"></i>過去 30 日每日成本(依 provider</strong>
<small class="text-muted">堆疊柱圖:依 provider 分色 · 資料來源 ai_calls 每日 SUM(cost_usd)</small>
</div>
<div class="card-body">
<canvas id="costTrend30dChart" height="80"></canvas>
</div>
</div>
{% endif %}
<!-- Phase 47 K-3: AI 價格決策 7d -->
{% if price_rec_7d %}
<div class="card mb-3">
<div class="card-header"><strong><i class="fas fa-tag me-2"></i>AI 價格決策 7 日ai_price_recommendations</strong>
<small class="text-muted">展現 AI 編排的商業面產出 — 不只 cost看實際決策數量</small>
</div>
<div class="card-body">
<div class="row g-2">
{% for p in price_rec_7d %}
<div class="col-md-3 col-sm-4">
<div class="border rounded p-2 text-center">
<small class="text-muted d-block">{{ p.strategy }}</small>
<strong style="font-size: 1.4em;">{{ p.count }}</strong>
<small class="d-block text-muted">信心 {{ "%.2f"|format(p.avg_confidence) }}</small>
</div>
</div>
{% endfor %}
</div>
</div>
</div>
{% endif %}
<p class="text-muted mt-3"><small>
<i class="fas fa-robot me-1"></i>Operation Ollama-First v5.0 / Phase 47 — 預算控管
5 表深挖ai_call_budgets / ai_calls / ai_price_recommendations / ai_insights / cost_throttle_state
</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-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 }};
if (!raw || !raw.length) return;
const dateSet = [...new Set(raw.map(r => r.date))].sort();
const providerSet = [...new Set(raw.map(r => r.provider))];
const colors = {
'gcp_ollama': '#28a745', 'ollama_secondary': '#5cb85c', 'ollama_111': '#a3d9a4',
'gemini': '#fd7e14', 'claude': '#6610f2', 'nim': '#0dcaf0',
'openrouter': '#6c757d', 'nim_via_elephant': '#20c997',
};
const datasets = providerSet.map((p, i) => {
const bg = colors[p] || `hsl(${(i*47)%360}, 65%, 55%)`;
return {
label: p,
data: dateSet.map(d => {
const row = raw.find(r => r.date === d && r.provider === p);
return row ? row.cost : 0;
}),
backgroundColor: bg,
};
});
const el = document.getElementById('costTrend30dChart');
if (!el) return;
new Chart(el, {
type: 'bar',
data: { labels: dateSet, datasets: datasets },
options: {
responsive: true,
interaction: { mode: 'index', intersect: false },
plugins: { tooltip: { callbacks: { label: c => `${c.dataset.label}: $${c.parsed.y.toFixed(4)}` } } },
scales: { x: { stacked: true }, y: { stacked: true, beginAtZero: true, title: { display: true, text: 'USD' } } }
}
});
})();
async function forceThrottle() {
if (!confirm('立即重算所有 provider 的 throttle 狀態?\n不等下次每小時 cron')) return;
try {
const r = await fetch('/observability/budget/force_throttle', {method: 'POST'});
const d = await r.json();
if (d.ok) {
const list = (d.throttled_providers && d.throttled_providers.length > 0)
? d.throttled_providers.join(', ') : '(無)';
alert(`✅ 已重算:被節流的 provider = ${list}`);
window.location.reload();
} else {
alert('❌ ' + (d.error || '重算失敗'));
}
} catch (e) {
alert('Error: ' + e);
}
}
async function saveBudget(id) {
const budgetInput = document.querySelector(`.budget-input[data-budget-id="${id}"]`);
const alertInput = document.querySelector(`.alert-input[data-budget-id="${id}"]`);
const btn = document.querySelector(`.save-budget-btn[data-budget-id="${id}"]`);
btn.disabled = true; btn.innerHTML = '<i class="fas fa-spinner fa-spin"></i>';
try {
const r = await fetch(`/observability/budget/update/${id}`, {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({
budget_usd: parseFloat(budgetInput.value),
alert_pct: parseInt(alertInput.value),
}),
});
const d = await r.json();
if (d.ok) {
btn.innerHTML = '<i class="fas fa-check"></i> 已儲存';
setTimeout(() => { btn.innerHTML = '<i class="fas fa-save me-1"></i>儲存'; btn.disabled = false; }, 1500);
} else {
alert('更新失敗:' + (d.error || 'unknown'));
btn.disabled = false; btn.innerHTML = '<i class="fas fa-save me-1"></i>儲存';
}
} catch (e) {
alert('Error' + e);
btn.disabled = false; btn.innerHTML = '<i class="fas fa-save me-1"></i>儲存';
}
}
</script>
{% endblock %}