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>
330 lines
13 KiB
HTML
330 lines
13 KiB
HTML
{% 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_usd(caller 級彙總)</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 %}
|