All checks were successful
CD Pipeline / deploy (push) Successful in 2m25s
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>
105 lines
4.1 KiB
HTML
105 lines
4.1 KiB
HTML
{% 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 無回應自動 expired(weight 降為 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 %}
|