feat(p29): 預算管理頁 + PPT vision 歷史頁 — 完成 6 個 admin 觀測頁
All checks were successful
CD Pipeline / deploy (push) Successful in 2m23s

承接 Phase 27/28(48b8fda)剩 2 個前端頁:

1. /admin/budget — 預算編輯器
   - GET: ai_call_budgets × 當月 spent 即時對比 + throttle 狀態
   - POST /admin/budget/update/<id>: AJAX 編輯 budget_usd / alert_pct
   - 不需 restart 立即生效(cost_throttle hourly cron 自動讀新值)
   - ratio ≥80% 黃 / ≥110% 紅 / throttled 標 ⚠️ THROTTLED

2. /admin/ppt_audit_history — PPT 視覺審核歷史
   - 掃 reports/ 過去 7 日 .pptx 檔(檔名/大小/修改時間)
   - 顯示 PPT_VISION_ENABLED 狀態(true=daily 22:00 cron 自動跑)
   - 手動觸發 SOP 提示(SSH 188 跑單檔審核)

完工里程碑:6 個 admin 頁 + 1 個導覽
- /admin/ai_calls          (Phase 27)
- /admin/promotion_review  (Phase 27)
- /admin/quality_trend     (Phase 28)
- /admin/host_health       (Phase 28)
- /admin/budget            (Phase 29) ← 新增
- /admin/ppt_audit_history (Phase 29) ← 新增

Operation Ollama-First v5.0 — 前端互補互動系列收官
This commit is contained in:
OoO
2026-05-04 13:44:08 +08:00
parent 48b8fda7db
commit 69ccf8029b
3 changed files with 308 additions and 0 deletions

View File

@@ -261,6 +261,146 @@ def quality_trend_dashboard():
)
# ─────────────────────────────────────────────────────────────────────────────
# /admin/budget — Phase 29 預算管理 + 手動 throttle
# ─────────────────────────────────────────────────────────────────────────────
@admin_observability_bp.route('/budget')
def budget_dashboard():
"""ai_call_budgets 編輯 + 當月 spent 即時對比"""
from datetime import datetime as _dt
today = _dt.now()
month_start = _dt(today.year, today.month, 1)
session = get_session()
try:
budgets = session.execute(
sa_text("""
SELECT id, period, provider, budget_usd, alert_pct, updated_at
FROM ai_call_budgets
ORDER BY period, provider NULLS FIRST
"""),
).fetchall()
spent_rows = session.execute(
sa_text("""
SELECT provider, COALESCE(SUM(cost_usd), 0) AS spent
FROM ai_calls
WHERE called_at >= :ms
GROUP BY provider
"""),
{'ms': month_start},
).fetchall()
spent_map = {r[0]: float(r[1] or 0) for r in spent_rows}
# throttle 狀態
throttle_state = {}
try:
from services.cost_throttle_service import get_throttle_state
throttle_state = get_throttle_state()
except Exception:
pass
rows = []
for b in budgets:
provider = b[2] # 可能 None全供應商總額
spent = spent_map.get(provider, 0.0) if provider else sum(spent_map.values())
budget_usd = float(b[3] or 0)
ratio = (spent / budget_usd) if budget_usd > 0 else 0
rows.append({
'id': b[0], 'period': b[1], 'provider': provider or '(all)',
'budget_usd': budget_usd, 'alert_pct': int(b[4] or 80),
'spent': spent, 'ratio': ratio,
'throttled': throttle_state.get(provider, {}).get('throttled', False) if provider else False,
'updated_at': b[5].strftime('%Y-%m-%d %H:%M') if b[5] else '-',
})
return render_template('admin/budget.html', rows=rows, error=None)
except Exception as e:
return render_template('admin/budget.html', rows=[],
error=f'查詢失敗: {type(e).__name__}: {str(e)[:200]}')
finally:
session.close()
@admin_observability_bp.route('/budget/update/<int:budget_id>', methods=['POST'])
def budget_update(budget_id: int):
"""更新 budget_usd / alert_pct"""
try:
new_budget = float(request.json.get('budget_usd'))
new_alert = int(request.json.get('alert_pct', 80))
if new_budget <= 0 or not (1 <= new_alert <= 100):
return jsonify({'ok': False, 'error': 'invalid range'}), 400
session = get_session()
try:
session.execute(
sa_text("""
UPDATE ai_call_budgets
SET budget_usd = :b, alert_pct = :a, updated_at = NOW()
WHERE id = :id
"""),
{'b': new_budget, 'a': new_alert, 'id': budget_id},
)
session.commit()
return jsonify({'ok': True})
finally:
session.close()
except Exception as e:
return jsonify({'ok': False, 'error': str(e)[:200]}), 500
# ─────────────────────────────────────────────────────────────────────────────
# /admin/ppt_audit_history — Phase 29 PPT 視覺審核歷史
# ─────────────────────────────────────────────────────────────────────────────
@admin_observability_bp.route('/ppt_audit_history')
def ppt_audit_history():
"""掃 reports/ 目錄列近 7 日 .pptx 檔 + 即時跑 audit如已啟用"""
import os
reports_dir = 'reports'
files = []
error = None
try:
if not os.path.isdir(reports_dir):
error = f'{reports_dir} 目錄不存在'
else:
cutoff = __import__('time').time() - 7 * 86400
for f in os.listdir(reports_dir):
if not f.lower().endswith('.pptx'):
continue
full = os.path.join(reports_dir, f)
try:
mtime = os.path.getmtime(full)
if mtime >= cutoff:
files.append({
'name': f,
'size_kb': round(os.path.getsize(full) / 1024, 1),
'mtime': __import__('datetime').datetime.fromtimestamp(mtime).strftime('%Y-%m-%d %H:%M'),
'mtime_ts': mtime,
})
except OSError:
continue
files.sort(key=lambda x: x['mtime_ts'], reverse=True)
except Exception as e:
error = f'{type(e).__name__}: {str(e)[:200]}'
# PPT vision 啟用狀態
try:
from services.ppt_vision_service import is_ppt_vision_enabled
vision_enabled = is_ppt_vision_enabled()
except Exception:
vision_enabled = False
return render_template(
'admin/ppt_audit_history.html',
files=files,
vision_enabled=vision_enabled,
error=error,
)
# ─────────────────────────────────────────────────────────────────────────────
# /admin/host_health — 三主機 + MCP 健康度
# ─────────────────────────────────────────────────────────────────────────────

110
templates/admin/budget.html Normal file
View File

@@ -0,0 +1,110 @@
{% extends "base.html" %}
{% block title %}Budget Manager{% endblock %}
{% block content %}
<div class="container-fluid mt-3">
<h2 class="mb-3">💰 Budget Manager
<small class="text-muted">ai_call_budgets × 當月 spent 即時對比</small>
</h2>
{% if error %}<div class="alert alert-warning"><strong>⚠️</strong> {{ error }}</div>{% endif %}
<p class="text-muted small">
依 ADR-028 預算 + Phase 20 cost_throttle每小時 cron 檢查當月 spent
線性外推月底成本超 110% → 自動 throttleclaude→gemini fallback
手動編輯 budget 後立即生效(不需 restart
</p>
<table class="table table-hover">
<thead class="table-light">
<tr>
<th>Period</th><th>Provider</th>
<th class="text-end">Spent (USD)</th>
<th>Budget (USD)</th><th>Alert %</th>
<th class="text-end">Ratio</th><th>狀態</th>
<th>Last Update</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">⚠️ THROTTLED</span>
{% elif r.ratio >= 0.8 %}
<span class="badge bg-warning">⚠ 接近上限</span>
{% else %}
<span class="badge bg-success">✅ 正常</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 }})">
💾 儲存
</button>
</td>
</tr>
{% else %}
<tr><td colspan="9" class="text-center text-muted">無預算資料(先跑 migrations/025</td></tr>
{% endfor %}
</tbody>
</table>
<p class="text-muted mt-3"><small>
🤖 Operation Ollama-First v5.0 / Phase 29 — Budget Manager
| <a href="/admin/ai_calls">AI Calls</a>
| <a href="/admin/host_health">Host Health</a>
| <a href="/admin/promotion_review">Promotion Review</a>
</small></p>
</div>
<script>
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.innerText = '⏳';
try {
const r = await fetch(`/admin/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.innerText = '✅';
setTimeout(() => { btn.innerText = '💾 儲存'; btn.disabled = false; }, 1500);
} else {
alert('更新失敗: ' + (d.error || 'unknown'));
btn.disabled = false; btn.innerText = '💾 儲存';
}
} catch (e) {
alert('Error: ' + e);
btn.disabled = false; btn.innerText = '💾 儲存';
}
}
</script>
{% endblock %}

View File

@@ -0,0 +1,58 @@
{% extends "base.html" %}
{% block title %}PPT Audit History{% endblock %}
{% block content %}
<div class="container-fluid mt-3">
<h2 class="mb-3">🔍 PPT 視覺審核歷史
<small class="text-muted">reports/ 過去 7 日 .pptx</small>
</h2>
{% if error %}<div class="alert alert-warning"><strong>⚠️</strong> {{ error }}</div>{% endif %}
<div class="alert {% if vision_enabled %}alert-success{% else %}alert-secondary{% endif %} small">
<strong>PPT_VISION_ENABLED:</strong>
{% if vision_enabled %}
✅ 已啟用 — daily 22:00 cron 自動跑 minicpm-v 視覺檢查,有 issues 推 Telegram
{% else %}
⏸ 未啟用 — 設 PPT_VISION_ENABLED=true + 188 安裝 LibreOffice 即生效
{% endif %}
</div>
<table class="table table-sm">
<thead class="table-light">
<tr>
<th>檔名</th><th class="text-end">大小 (KB)</th>
<th>修改時間</th><th>動作</th>
</tr>
</thead>
<tbody>
{% for f in files %}
<tr>
<td><code>{{ f.name }}</code></td>
<td class="text-end">{{ f.size_kb }}</td>
<td><small>{{ f.mtime }}</small></td>
<td>
<small class="text-muted">audit cron 22:00 自動跑</small>
</td>
</tr>
{% else %}
<tr><td colspan="4" class="text-center text-muted">過去 7 日無 PPT 生成</td></tr>
{% endfor %}
</tbody>
</table>
<p class="text-muted mt-2 small">
審核結果:<strong>有 issues 才推 Telegram</strong>(避免靜默無問題洗版)。
手動觸發單檔審核需 SSH 188 跑:
<code>python3 -c "from services.ppt_vision_service import ppt_vision_service; print(ppt_vision_service.check_ppt_file('reports/xxx.pptx'))"</code>
</p>
<p class="text-muted mt-3"><small>
🤖 Operation Ollama-First v5.0 / Phase 29 — PPT Audit History
| <a href="/admin/ai_calls">AI Calls</a>
| <a href="/admin/host_health">Host Health</a>
| <a href="/admin/budget">Budget</a>
</small></p>
</div>
{% endblock %}