Files
ewoooc/templates/admin/rag_queries.html
OoO e0a8d87c2c
All checks were successful
CD Pipeline / deploy (push) Successful in 2m35s
feat(p51): RAG 召回詳情新頁 + overview 三主機 24h sparkline
新頁 /observability/rag_queries:補完 RAG 觀測深度
之前只看 caller 級命中率,現在能看每筆查詢的真實內容。

O-1: route + template
- 篩選:時段(1/6/24/72/168h)/ caller / saved_only flag
- 整體 KPI 4 卡:總查詢 / 命中率 / saved_call 率 / 反饋平均分
- by caller 表:每個 caller 的查詢/命中/saved/反饋細節
- 最近 50 筆查詢詳情表
- 「查 hits」按鈕 → 彈 modal 載入 ai_insights JOIN 內容預覽
  (新 endpoint /observability/rag_queries/<id>/hits 回傳 JSON)

O-2: 入口
- sidebar AI 觀測 group 加「RAG 召回詳情」(11b)
- /observability/overview 入口卡升級為 9 項

O-3: overview 三主機 24h sparkline
- 每張主機卡片下方加 60px 高 chart.js sparkline
- 折線:每小時 uptime % bucket(0-100% Y 軸隱藏,純視覺)
- routes/admin_observability_routes.py::observability_overview
  新加 host_sparkline 查詢(GROUP BY host_label, hour)
- 三主機卡片視覺化升級:原本只有「100%」字,現在加趨勢線

Phase 38→51 累計 16 commits / 10 觀測頁。
觀測台戰役從「raw stats」到「視覺方格 UI 完整體」。

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

268 lines
11 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 "ewoooc_base.html" %}
{% block title %}RAG 召回詳情{% endblock %}
{% block ewooo_content %}
<div class="container-fluid mt-3">
<h2 class="mb-3"><i class="fas fa-magnifying-glass-chart me-2"></i>RAG 召回詳情
<small class="text-muted">過去 {{ hours }} 小時 · 每筆 query 的 hits / saved_call / 反饋</small>
</h2>
{% if error %}
<div class="alert alert-warning"><strong><i class="fas fa-exclamation-triangle me-1"></i></strong> {{ error }}</div>
{% endif %}
<!-- 篩選 bar -->
<form method="get" class="row g-2 mb-3">
<div class="col-auto">
<select name="hours" class="form-select form-select-sm" onchange="this.form.submit()">
{% for h in [1, 6, 24, 72, 168] %}
<option value="{{ h }}" {% if hours == h %}selected{% endif %}>
{% if h < 24 %}{{ h }} 小時{% else %}{{ (h//24) }} {% endif %}
</option>
{% endfor %}
</select>
</div>
<div class="col-auto">
<select name="caller" class="form-select form-select-sm" onchange="this.form.submit()">
<option value="">全部呼叫端</option>
{% for c in callers %}
<option value="{{ c }}" {% if caller_filter == c %}selected{% endif %}>{{ c }}</option>
{% endfor %}
</select>
</div>
<div class="col-auto">
<div class="form-check">
<input class="form-check-input" type="checkbox" id="savedOnly" name="saved_only" value="1"
{% if saved_only %}checked{% endif %} onchange="this.form.submit()">
<label class="form-check-label small" for="savedOnly">僅看 saved_call=true</label>
</div>
</div>
</form>
<!-- 整體 KPI -->
{% if summary and summary.total > 0 %}
<div class="row g-3 mb-3">
<div class="col-lg-3 col-md-6">
<div class="card h-100">
<div class="card-body">
<small class="text-muted d-block"><i class="fas fa-search me-1"></i>總查詢</small>
<h3 class="mb-0">{{ "{:,}".format(summary.total) }}</h3>
<small class="text-muted">{{ summary.distinct_callers }} 個呼叫端</small>
</div>
</div>
</div>
<div class="col-lg-3 col-md-6">
<div class="card h-100" style="border-left: 4px solid #198754;">
<div class="card-body">
<small class="text-muted d-block"><i class="fas fa-check-circle me-1"></i>命中率</small>
<h3 class="mb-0 text-success">{{ "%.1f"|format(summary.hit_rate) }}<small>%</small></h3>
<small class="text-muted">{{ summary.with_hits }} hit · {{ summary.no_hits }} 未命中</small>
</div>
</div>
</div>
<div class="col-lg-3 col-md-6">
<div class="card h-100" style="border-left: 4px solid #6f42c1;">
<div class="card-body">
<small class="text-muted d-block"><i class="fas fa-piggy-bank me-1"></i>saved_call 率</small>
<h3 class="mb-0" style="color: #6f42c1;">{{ "%.1f"|format(summary.saved_rate) }}<small>%</small></h3>
<small class="text-muted">{{ summary.saved }} 次省下 LLM</small>
</div>
</div>
</div>
<div class="col-lg-3 col-md-6">
<div class="card h-100" style="border-left: 4px solid #ffc107;">
<div class="card-body">
<small class="text-muted d-block"><i class="fas fa-star me-1"></i>反饋平均分</small>
<h3 class="mb-0">{{ "%.2f"|format(summary.avg_score) }}<small class="text-muted">/5</small></h3>
<small class="text-muted">{{ summary.feedback_count }} 筆反饋 · 平均 {{ summary.avg_hits }} hits</small>
</div>
</div>
</div>
</div>
{% endif %}
<!-- by caller 統計 -->
{% if by_caller %}
<div class="card mb-3">
<div class="card-header"><strong><i class="fas fa-users me-2"></i>各呼叫端 RAG 表現</strong>
<small class="text-muted">資料來源rag_query_log GROUP BY 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">命中</th>
<th class="text-end">命中率</th>
<th class="text-end">saved</th>
<th class="text-end">saved 率</th>
<th class="text-end">反饋</th>
<th class="text-end">平均分</th>
</tr>
</thead>
<tbody>
{% for c in by_caller %}
<tr>
<td><code>{{ c.caller }}</code></td>
<td class="text-end">{{ "{:,}".format(c.total) }}</td>
<td class="text-end">{{ c.with_hits }}</td>
<td class="text-end">
<strong class="{% if c.hit_rate >= 70 %}text-success{% elif c.hit_rate >= 40 %}text-warning{% else %}text-muted{% endif %}">
{{ "%.1f"|format(c.hit_rate) }}%
</strong>
</td>
<td class="text-end">{{ c.saved }}</td>
<td class="text-end">
<span style="color: #6f42c1;">
<strong>{{ "%.1f"|format(c.saved_rate) }}%</strong>
</span>
</td>
<td class="text-end">{{ c.fb_count }}</td>
<td class="text-end">
{% if c.fb_count > 0 %}
<strong class="{% if c.avg_score >= 4 %}text-success{% elif c.avg_score >= 3 %}text-warning{% else %}text-danger{% endif %}">
{{ "%.2f"|format(c.avg_score) }}
</strong>
{% else %}<small class="text-muted"></small>{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
{% endif %}
<!-- 最近 50 筆 query 詳情 -->
{% if queries %}
<div class="card mb-3">
<div class="card-header"><strong><i class="fas fa-list me-2"></i>最近 50 筆查詢詳情</strong>
<small class="text-muted">點「查 hits」展開命中的 ai_insights 內容</small>
</div>
<div class="card-body p-0">
<table class="table table-sm mb-0" style="font-size: 0.85em;">
<thead class="table-light">
<tr>
<th>時間</th><th>呼叫端</th><th>查詢</th>
<th class="text-end">top_k</th>
<th class="text-end">門檻</th>
<th class="text-end">命中</th>
<th>saved</th><th>反饋</th>
<th>動作</th>
</tr>
</thead>
<tbody>
{% for q in queries %}
<tr>
<td><small>{{ q.queried_at }}</small></td>
<td><code>{{ q.caller }}</code></td>
<td><small>{{ q.query_text }}{% if q.query_text|length >= 200 %}…{% endif %}</small></td>
<td class="text-end">{{ q.top_k }}</td>
<td class="text-end">{{ q.threshold }}</td>
<td class="text-end">
{% if q.hit_count > 0 %}<strong class="text-success">{{ q.hit_count }}</strong>
{% else %}<small class="text-muted">0</small>{% endif %}
</td>
<td>
{% if q.saved_call %}<span class="badge bg-success"><i class="fas fa-piggy-bank me-1"></i>saved</span>
{% else %}<small class="text-muted"></small>{% endif %}
</td>
<td>
{% if q.feedback_score is not none %}
{% if q.feedback_score >= 4 %}<span class="badge bg-success">{{ q.feedback_score }}/5</span>
{% elif q.feedback_score >= 3 %}<span class="badge bg-warning text-dark">{{ q.feedback_score }}/5</span>
{% else %}<span class="badge bg-danger">{{ q.feedback_score }}/5</span>{% endif %}
{% else %}<small class="text-muted"></small>{% endif %}
</td>
<td>
{% if q.hit_count > 0 %}
<button class="btn btn-sm btn-outline-info" onclick="showHits({{ q.id }})">
<i class="fas fa-eye me-1"></i>查 hits
</button>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
{% else %}
<div class="alert alert-info">
<i class="fas fa-info-circle me-1"></i>過去 {{ hours }} 小時無符合條件的 RAG 查詢紀錄。
</div>
{% endif %}
<p class="text-muted mt-3"><small>
<i class="fas fa-robot me-1"></i>Operation Ollama-First v5.0 / Phase 51 — RAG 召回詳情
3 表跨 JOINrag_query_log × ai_insights × ai_calls.request_id
</small></p>
</div>
<!-- Modal for hits 詳情 -->
<div class="modal fade" id="hitsModal" tabindex="-1">
<div class="modal-dialog modal-lg">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title"><i class="fas fa-eye me-2"></i>RAG 命中內容</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body" id="hitsModalBody">
<div class="text-center"><i class="fas fa-spinner fa-spin"></i> 載入中...</div>
</div>
</div>
</div>
</div>
<script>
async function showHits(queryId) {
const modalEl = document.getElementById('hitsModal');
const body = document.getElementById('hitsModalBody');
body.innerHTML = '<div class="text-center"><i class="fas fa-spinner fa-spin"></i> 載入中...</div>';
const modal = new bootstrap.Modal(modalEl);
modal.show();
try {
const r = await fetch(`/observability/rag_queries/${queryId}/hits`);
const d = await r.json();
if (!d.ok) {
body.innerHTML = `<div class="alert alert-danger">❌ ${d.error || '載入失敗'}</div>`;
return;
}
let html = `<div class="mb-3">
<small class="text-muted">查詢 #${d.query_id} · 門檻 ${d.threshold} · 命中 ${d.hit_count}</small>
<div class="p-2 mt-1" style="background: #f7f7f9; border-radius: 6px;">
<small><strong>查詢內容:</strong></small><br>
<code>${escapeHtml(d.query_text || '')}</code>
</div>
</div>`;
if (d.hits.length === 0) {
html += '<div class="alert alert-warning">無 hits 詳細資料used_results 為空或 ai_insights 已刪除)</div>';
} else {
html += '<h6 class="mb-2">Top hits 內容預覽:</h6>';
d.hits.forEach((h, i) => {
html += `<div class="mb-2 p-2" style="background: #fafafa; border-radius: 6px;">
<div class="mb-1">
<span class="badge bg-light text-dark me-1">#${h.id}</span>
<span class="badge bg-info text-dark me-1">${escapeHtml(h.insight_type || '')}</span>
${h.period ? `<span class="badge bg-secondary me-1">${escapeHtml(h.period)}</span>` : ''}
${h.product_sku ? `<small class="text-muted me-1">SKU: ${escapeHtml(h.product_sku)}</small>` : ''}
<small class="text-muted">${h.created_at}</small>
</div>
<small>${escapeHtml(h.content || '')}${h.content && h.content.length >= 300 ? '…' : ''}</small>
</div>`;
});
}
body.innerHTML = html;
} catch (e) {
body.innerHTML = `<div class="alert alert-danger">❌ 載入錯誤:${e}</div>`;
}
}
function escapeHtml(s) {
if (!s) return '';
return s.replace(/[&<>"']/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
}
</script>
{% endblock %}