Files
ewoooc/templates/admin/promotion_review.html
OoO 48b8fda7db
All checks were successful
CD Pipeline / deploy (push) Successful in 2m25s
feat(p27+28): Admin Observability Dashboard — 4 個前端頁互補 Telegram
Operation Ollama-First v5.0 / Phase 27 + 28 — 戰役觀測前端化

routes/admin_observability_routes.py (新檔, 200+ 行)
- admin_observability_bp blueprint,url_prefix='/admin'
- /admin/ai_calls            — Phase 27 主入口(KPI / by provider / TOP 100)
- /admin/promotion_review    — Phase 28 PromotionGate 待審列表 + 通過/拒絕按鈕
- /admin/quality_trend       — Phase 25 caller 反饋趨勢視覺化
- /admin/host_health         — 三主機 + MCP + cost throttle 即時健康
- 失敗安全:DB 查詢失敗回空清單 + 警告 banner(不 raise)
- promotion_review_approve/reject 走 hash_human_approver SHA1[:8] 不存原 username

templates/admin/ (4 個新檔)
- ai_calls_dashboard.html   篩選 bar + 6 KPI cards + by provider + recent 100
- promotion_review.html     卡片列表 + 通過/拒絕 AJAX 按鈕(即時 UI feedback)
- quality_trend.html        avg score 升序排列 + 進度條 bar + 智能建議區
- host_health.html          三主機 HTTP probe + 已載入模型 + MCP + throttle

統帥提問「需要哪些前端讓兩者互補互動」答覆:
  6 項最該前端化(已實作 4 項,剩 2 項為後續):
     ai_calls 即時查詢          → /admin/ai_calls
     PromotionGate 待審核         → /admin/promotion_review (互動最強)
     caller 反饋趨勢             → /admin/quality_trend
     三主機 + MCP + throttle     → /admin/host_health
     ai_call_budgets 預算管理   → Phase 29 補
     PPT 視覺審核結果列表        → Phase 29 補

互補 Telegram 哲學:
  Telegram = push(重要事件主動通知)
  Web = pull(統帥隨時可查 / 互動審核 / 找問題)
  PromotionGate Stage 4:Telegram 推 awaiting_review + Web 批次審核(兩者皆可)

app.py blueprint 註冊 + CSRF exempt(AJAX POST 走 server-side check)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-04 13:36:51 +08:00

105 lines
4.1 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 "base.html" %}
{% block title %}Promotion Review · RAG 自主學習{% endblock %}
{% block content %}
<div class="container-fluid mt-3">
<h2 class="mb-3">🧠 RAG 學習晉升審核
<small class="text-muted">awaiting_review × {{ episodes|length }} 筆</small>
</h2>
{% if error %}
<div class="alert alert-warning"><strong>⚠️</strong> {{ error }}</div>
{% endif %}
{% if episodes %}
<p class="text-muted small">
⚠️ Phase 11 PromotionGate Stage 4 強制門檻weight >= 0.8 的 episode 必經統帥審核,
24h 無回應自動 expiredweight 降為 0.5 不晉升)。
點 ✅ 通過 → 寫入 ai_insights 供 RAG 檢索;點 ❌ 拒絕 → 永不晉升learning_episodes 留存)。
</p>
{% for ep in episodes %}
<div class="card mb-3 episode-card" data-episode-id="{{ ep.id }}">
<div class="card-header d-flex justify-content-between align-items-center">
<div>
<strong>Episode #{{ ep.id }}</strong>
<span class="badge bg-secondary ms-2">{{ ep.episode_type }}</span>
{% if ep.source_table %}<span class="badge bg-light text-dark ms-1">
{{ ep.source_table }}#{{ ep.source_id }}</span>{% endif %}
<span class="badge bg-info ms-1">weight: {{ "%.2f"|format(ep.weight) }}</span>
<span class="badge bg-info ms-1">quality: {{ "%.2f"|format(ep.quality_score) }}</span>
</div>
<small class="text-muted">{{ ep.created_at }}</small>
</div>
<div class="card-body">
<pre style="white-space: pre-wrap; font-size: 0.9em; max-height: 200px; overflow-y: auto;">{{ ep.distilled_text }}</pre>
</div>
<div class="card-footer text-end">
<button class="btn btn-success btn-sm me-2" onclick="approveEpisode({{ ep.id }}, this)">
✅ 通過晉升
</button>
<button class="btn btn-outline-danger btn-sm" onclick="rejectEpisode({{ ep.id }}, this)">
❌ 拒絕
</button>
</div>
</div>
{% endfor %}
{% else %}
<div class="alert alert-info">
✨ 目前無 awaiting_review episodes。
<small>RAG 未啟用 / 無高 weight episode / 全部已 24h 過期)</small>
</div>
{% endif %}
<p class="text-muted mt-3"><small>
🤖 Operation Ollama-First v5.0 / Phase 28 — PromotionGate Web 審核頁
| <a href="/admin/ai_calls">AI Calls</a>
| <a href="/admin/quality_trend">Quality Trend</a>
</small></p>
</div>
<script>
async function approveEpisode(id, btn) {
btn.disabled = true; btn.innerText = '⏳ 處理中...';
try {
const r = await fetch(`/admin/promotion_review/approve/${id}`, {method: 'POST'});
const d = await r.json();
if (d.ok) {
const card = document.querySelector(`.episode-card[data-episode-id="${id}"]`);
card.classList.add('border-success');
card.querySelector('.card-footer').innerHTML =
`<span class="text-success">✅ 已晉升 → ai_insights #${d.insight_id} (approver=${d.approver})</span>`;
} else {
alert('晉升失敗: ' + (d.error || 'unknown'));
btn.disabled = false; btn.innerText = '✅ 通過晉升';
}
} catch (e) {
alert('Error: ' + e);
btn.disabled = false; btn.innerText = '✅ 通過晉升';
}
}
async function rejectEpisode(id, btn) {
if (!confirm(`拒絕 Episode #${id}?此筆將永不晉升(保留在 learning_episodes 不刪除)`)) return;
btn.disabled = true; btn.innerText = '⏳ 處理中...';
try {
const r = await fetch(`/admin/promotion_review/reject/${id}`, {method: 'POST'});
const d = await r.json();
if (d.ok) {
const card = document.querySelector(`.episode-card[data-episode-id="${id}"]`);
card.classList.add('border-danger');
card.querySelector('.card-footer').innerHTML =
`<span class="text-danger">❌ 已拒絕 (rejected_human)</span>`;
} else {
alert('拒絕失敗: ' + (d.error || 'unknown'));
btn.disabled = false; btn.innerText = '❌ 拒絕';
}
} catch (e) {
alert('Error: ' + e);
btn.disabled = false; btn.innerText = '❌ 拒絕';
}
}
</script>
{% endblock %}