feat(p52): topbar 觀測台健康指示燈 + RAG 反饋圓餅圖
All checks were successful
CD Pipeline / deploy (push) Successful in 2m30s
All checks were successful
CD Pipeline / deploy (push) Successful in 2m30s
P-1: topbar AI 觀測台 indicator(全頁可見) - ewoooc_base.html topbar 加「🛰 AI 觀測台」icon button - 紅色 badge 顯示告警數量(4 維度任一觸發即計數): • 三主機任一掛掉 • 待審 episode > 0 • 過去 1h 錯誤率 ≥ 30% • 預算任一 ≥ 90% - 新 GET /observability/api/health_indicator 輕量 JSON API(4 query 跨 host_health_probes/learning_episodes/ ai_calls/ai_call_budgets) - topbar polling 每 60s 自動刷新 + tooltip 顯示具體告警內容 - 全部頁面(包括 / 商品看板、所有觀測頁)topbar 都看得到健康狀態 P-2: quality_trend RAG 反饋圓餅圖(doughnut) - 取代原本卡片網格佈局 - 1-5 星依綠→紅漸層著色(5=綠、3=黃、1=紅) - 圓餅 + 右側表格雙視角(chart 配對 raw 數字) - chart.js doughnut + tooltip 顯示筆數+佔比 效益: - 統帥從任何頁面(不限觀測台)都能瞄一眼右上角看當前 AI 健康 - 快樂路徑:「正常」綠色 icon · 異常路徑:「紅色 badge + 數字」立即吸睛 - 圓餅圖比原網格更直觀「分布」感 Phase 38→52 累計 17 commits / 10 觀測頁 / DB 100% / 4 chart.js / 全頁 indicator。 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1742,6 +1742,126 @@ def ppt_audit_trigger_aider_heal():
|
||||
return jsonify({'ok': False, 'error': f'{type(e).__name__}: {str(e)[:200]}'}), 500
|
||||
|
||||
|
||||
@admin_observability_bp.route('/api/health_indicator')
|
||||
@login_required
|
||||
def health_indicator_api():
|
||||
"""Phase 52 P-1:給 topbar 觀測台 indicator 用的輕量 JSON API。
|
||||
|
||||
回傳當前是否有「需要關注」的事件:
|
||||
- 三主機掛掉
|
||||
- 待審 episode > 0
|
||||
- 過去 1h 錯誤率 ≥ 30%
|
||||
- 預算 ≥ 90%
|
||||
"""
|
||||
try:
|
||||
session = get_session()
|
||||
try:
|
||||
# 三主機最新狀態
|
||||
host_unhealthy = 0
|
||||
try:
|
||||
rows = session.execute(
|
||||
sa_text("""
|
||||
WITH latest AS (
|
||||
SELECT host_label,
|
||||
FIRST_VALUE(healthy) OVER (
|
||||
PARTITION BY host_label ORDER BY probed_at DESC
|
||||
) AS healthy
|
||||
FROM host_health_probes
|
||||
WHERE probed_at >= NOW() - INTERVAL '1 hour'
|
||||
)
|
||||
SELECT host_label, BOOL_AND(NOT healthy) AS down
|
||||
FROM latest
|
||||
GROUP BY host_label
|
||||
"""),
|
||||
).fetchall()
|
||||
host_unhealthy = sum(1 for r in rows if r[1])
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# 待審 episode
|
||||
ep_pending = 0
|
||||
try:
|
||||
ep_pending = int(session.execute(
|
||||
sa_text("SELECT COUNT(*) FROM learning_episodes WHERE promotion_status = 'awaiting_review' AND reviewed_at IS NULL"),
|
||||
).fetchone()[0] or 0)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# 1h 錯誤率
|
||||
error_rate = 0
|
||||
try:
|
||||
row = session.execute(
|
||||
sa_text("""
|
||||
SELECT COUNT(*),
|
||||
COUNT(*) FILTER (WHERE status NOT IN ('ok','cache_only'))
|
||||
FROM ai_calls WHERE called_at >= NOW() - INTERVAL '1 hour'
|
||||
"""),
|
||||
).fetchone()
|
||||
total = int(row[0] or 0)
|
||||
errs = int(row[1] or 0)
|
||||
error_rate = (errs / total * 100) if total > 20 else 0
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# 預算告警(任一 ≥ 90%)
|
||||
budget_alert = False
|
||||
try:
|
||||
from datetime import datetime as _dt
|
||||
today = _dt.now()
|
||||
ms = _dt(today.year, today.month, 1)
|
||||
bgs = session.execute(
|
||||
sa_text("""
|
||||
SELECT b.budget_usd,
|
||||
COALESCE((SELECT SUM(cost_usd) FROM ai_calls
|
||||
WHERE called_at >= :ms
|
||||
AND (b.provider IS NULL OR provider = b.provider)), 0) AS spent
|
||||
FROM ai_call_budgets b
|
||||
"""),
|
||||
{'ms': ms},
|
||||
).fetchall()
|
||||
for budget, spent in bgs:
|
||||
if budget and float(budget) > 0 and float(spent) / float(budget) >= 0.9:
|
||||
budget_alert = True
|
||||
break
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
alert_count = (
|
||||
host_unhealthy
|
||||
+ (1 if ep_pending > 0 else 0)
|
||||
+ (1 if error_rate >= 30 else 0)
|
||||
+ (1 if budget_alert else 0)
|
||||
)
|
||||
return jsonify({
|
||||
'ok': True,
|
||||
'alert_count': alert_count,
|
||||
'host_unhealthy': host_unhealthy,
|
||||
'ep_pending': ep_pending,
|
||||
'error_rate_high': error_rate >= 30,
|
||||
'budget_alert': budget_alert,
|
||||
'tooltip': _build_indicator_tooltip(host_unhealthy, ep_pending, error_rate, budget_alert),
|
||||
})
|
||||
finally:
|
||||
session.close()
|
||||
except Exception as e:
|
||||
return jsonify({'ok': False, 'error': f'{type(e).__name__}: {str(e)[:200]}'}), 500
|
||||
|
||||
|
||||
def _build_indicator_tooltip(host_unhealthy, ep_pending, error_rate, budget_alert) -> str:
|
||||
parts = []
|
||||
if host_unhealthy:
|
||||
parts.append(f"{host_unhealthy} 主機異常")
|
||||
if ep_pending > 0:
|
||||
parts.append(f"{ep_pending} 待審")
|
||||
if error_rate >= 30:
|
||||
parts.append(f"錯誤率 {error_rate:.0f}%")
|
||||
if budget_alert:
|
||||
parts.append("預算 ≥ 90%")
|
||||
if not parts:
|
||||
return "AI 觀測台(一切正常)"
|
||||
return "AI 觀測台 — " + " / ".join(parts)
|
||||
|
||||
|
||||
@admin_observability_bp.route('/playbooks/toggle/<int:playbook_id>', methods=['POST'])
|
||||
@login_required
|
||||
def playbook_toggle(playbook_id: int):
|
||||
|
||||
@@ -150,27 +150,38 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Phase 47 K-5: RAG 整體 feedback 分布 -->
|
||||
<!-- Phase 47 K-5 + Phase 52 P-2: RAG 整體 feedback 圓餅圖 -->
|
||||
{% if rag_overall_dist %}
|
||||
<div class="card mb-3">
|
||||
<div class="card-header"><strong><i class="fas fa-poll me-2"></i>RAG 整體反饋分布(過去 {{ days }} 日)</strong>
|
||||
<small class="text-muted">資料來源:rag_query_log.feedback_score(含全 caller,1-5 分)</small>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="row g-2">
|
||||
{% set total_fb = (rag_overall_dist | sum(attribute='count')) or 1 %}
|
||||
{% for r in rag_overall_dist %}
|
||||
<div class="col-md-2 col-sm-4">
|
||||
<div class="border rounded p-2 text-center">
|
||||
<small class="text-muted d-block">
|
||||
{% for _ in range(r.score) %}<i class="fas fa-star text-warning"></i>{% endfor %}
|
||||
{% for _ in range(5 - r.score) %}<i class="far fa-star text-muted"></i>{% endfor %}
|
||||
</small>
|
||||
<strong style="font-size: 1.4em;">{{ r.count }}</strong>
|
||||
<small class="d-block text-muted">{{ "%.1f"|format(r.count / total_fb * 100) }}%</small>
|
||||
</div>
|
||||
<div class="row g-2 align-items-center">
|
||||
<div class="col-md-5">
|
||||
<canvas id="ragFeedbackPieChart" 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_fb = (rag_overall_dist | sum(attribute='count')) or 1 %}
|
||||
{% for r in rag_overall_dist %}
|
||||
<tr>
|
||||
<td>
|
||||
{% for _ in range(r.score) %}<i class="fas fa-star text-warning"></i>{% endfor %}
|
||||
{% for _ in range(5 - r.score) %}<i class="far fa-star text-muted"></i>{% endfor %}
|
||||
<small class="ms-1 text-muted">{{ r.score }} 分</small>
|
||||
</td>
|
||||
<td class="text-end"><strong>{{ r.count }}</strong></td>
|
||||
<td class="text-end">{{ "%.1f"|format(r.count / total_fb * 100) }}%</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -240,8 +251,39 @@
|
||||
{% endif %}
|
||||
|
||||
<p class="text-muted mt-3"><small>
|
||||
<i class="fas fa-robot me-1"></i>Operation Ollama-First v5.0 / Phase 47 — Caller 反饋趨勢
|
||||
<i class="fas fa-robot me-1"></i>Operation Ollama-First v5.0 / Phase 52 — Caller 反饋趨勢(含 RAG 圓餅圖)
|
||||
(6 表深挖:rag_query_log / learning_episodes / ai_insights / action_plans / action_outcomes / agent_strategy_weights)
|
||||
</small></p>
|
||||
</div>
|
||||
|
||||
{% if rag_overall_dist %}
|
||||
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.1/dist/chart.umd.min.js"></script>
|
||||
<script>
|
||||
(function() {
|
||||
const data = {{ rag_overall_dist | tojson }};
|
||||
const el = document.getElementById('ragFeedbackPieChart');
|
||||
if (!el || !data.length) return;
|
||||
// 1-5 分對應綠→紅漸層
|
||||
const colorMap = {1: '#dc3545', 2: '#fd7e14', 3: '#ffc107', 4: '#84c454', 5: '#198754'};
|
||||
new Chart(el, {
|
||||
type: 'doughnut',
|
||||
data: {
|
||||
labels: data.map(r => `${r.score} 星`),
|
||||
datasets: [{
|
||||
data: data.map(r => r.count),
|
||||
backgroundColor: data.map(r => colorMap[r.score] || '#6c757d'),
|
||||
borderWidth: 1, borderColor: '#fff',
|
||||
}]
|
||||
},
|
||||
options: {
|
||||
responsive: true, maintainAspectRatio: false,
|
||||
plugins: {
|
||||
legend: { position: 'right', labels: { font: { size: 12 } } },
|
||||
tooltip: { callbacks: { label: c => `${c.label}: ${c.parsed} 筆 (${(c.parsed / data.reduce((a,r)=>a+r.count,0) * 100).toFixed(1)}%)` } }
|
||||
}
|
||||
}
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
|
||||
@@ -46,6 +46,15 @@
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<a class="momo-icon-button" href="/observability/overview" title="AI 觀測台" id="momo-obs-link"
|
||||
style="text-decoration: none; position: relative;">
|
||||
<i class="fas fa-satellite-dish"></i>
|
||||
<span id="momo-obs-badge"
|
||||
style="display: none; position: absolute; top: -2px; right: -2px;
|
||||
background: #dc3545; color: white; border-radius: 50%;
|
||||
font-size: 0.6em; padding: 2px 5px; min-width: 16px;
|
||||
text-align: center; line-height: 1;"></span>
|
||||
</a>
|
||||
<button class="momo-icon-button" type="button" title="說明">
|
||||
<i class="fas fa-circle-question"></i>
|
||||
</button>
|
||||
@@ -323,6 +332,34 @@
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
<!-- Phase 52: 觀測台 indicator polling -->
|
||||
<script>
|
||||
(function() {
|
||||
const link = document.getElementById('momo-obs-link');
|
||||
const badge = document.getElementById('momo-obs-badge');
|
||||
if (!link || !badge) return;
|
||||
async function refresh() {
|
||||
try {
|
||||
const r = await fetch('/observability/api/health_indicator', {credentials: 'same-origin'});
|
||||
if (!r.ok) return;
|
||||
const d = await r.json();
|
||||
if (!d.ok) return;
|
||||
link.title = d.tooltip || 'AI 觀測台';
|
||||
if (d.alert_count > 0) {
|
||||
badge.textContent = d.alert_count;
|
||||
badge.style.display = 'inline-block';
|
||||
link.style.color = '#dc3545';
|
||||
} else {
|
||||
badge.style.display = 'none';
|
||||
link.style.color = '';
|
||||
}
|
||||
} catch (e) { /* silent */ }
|
||||
}
|
||||
refresh();
|
||||
setInterval(refresh, 60000); // 每 60 秒輪詢
|
||||
})();
|
||||
</script>
|
||||
|
||||
{% block extra_js %}{% endblock %}
|
||||
</body>
|
||||
</html>
|
||||
|
||||
Reference in New Issue
Block a user