Files
ewoooc/templates/admin/rag_queries.html
OoO b21b40cae2
All checks were successful
CD Pipeline / deploy (push) Successful in 1m2s
fix(observability): soften frontend error copy
2026-05-05 21:58:49 +08:00

47 lines
10 KiB
HTML

{% extends "ewoooc_base.html" %}
{% block title %}RAG 召回雷達{% endblock %}
{% block ewooo_content %}
<style>
.qa-hero,.qa-panel,.qa-table-shell{border:1px solid var(--obs-line);border-radius:26px;background:var(--obs-card);box-shadow:0 16px 38px rgba(70,46,28,.08)}
.qa-hero{padding:clamp(1.2rem,2.4vw,2rem);background:radial-gradient(circle at 12% 14%,rgba(201,100,66,.18),transparent 24rem),radial-gradient(circle at 88% 8%,rgba(79,111,143,.14),transparent 22rem),linear-gradient(135deg,rgba(255,248,239,.98),rgba(255,255,255,.74))}
.qa-kicker{color:var(--obs-accent);font-size:.76rem;letter-spacing:.13em;text-transform:uppercase;font-weight:850}.qa-title{margin:.45rem 0 .25rem;font-family:'Noto Sans TC','Inter',sans-serif;font-size:var(--obs-title-size);letter-spacing:-.055em;line-height:.98}.qa-subtitle{color:var(--obs-muted);max-width:860px;line-height:1.7}
.qa-filter{display:flex;flex-wrap:wrap;gap:.55rem;margin-top:1rem;padding:.8rem;border:1px solid var(--obs-line);border-radius:20px;background:rgba(255,255,255,.58)}
.qa-command{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:.75rem;margin-top:1rem}.qa-signal{padding:.95rem;border:1px solid var(--obs-line);border-radius:20px;background:rgba(255,255,255,.62)}.qa-label{color:var(--obs-muted);font-size:.72rem;letter-spacing:.1em;text-transform:uppercase}.qa-value{display:block;margin-top:.28rem;font-size:var(--obs-value-size);font-weight:880;letter-spacing:-.045em}.qa-note{color:var(--obs-muted);font-size:.8rem;margin-top:.25rem}
.qa-grid{display:grid;grid-template-columns:minmax(0,1.2fr) minmax(330px,.8fr);gap:1rem;margin-top:1rem}.qa-stack{display:grid;gap:1rem}.qa-panel-head,.qa-table-title{display:flex;justify-content:space-between;align-items:flex-start;gap:1rem;padding:1.05rem 1.1rem .25rem}.qa-panel-title,.qa-table-title h3{margin:.15rem 0 0;font-size:1.1rem;font-weight:850;letter-spacing:-.025em}.qa-panel-body{padding:1rem 1.1rem 1.1rem}.qa-table-shell{overflow:hidden;margin-top:1rem}.status-good{color:var(--obs-green)}.status-warn{color:var(--obs-amber)}.status-bad{color:var(--obs-red)}.status-blue{color:var(--obs-blue)}
.caller-card{padding:.85rem;border:1px solid var(--obs-line);border-radius:18px;background:rgba(255,255,255,.58);margin-bottom:.65rem}.caller-top{display:flex;justify-content:space-between;gap:.8rem}.caller-meter{height:7px;border-radius:999px;background:rgba(86,64,48,.1);overflow:hidden;margin-top:.55rem}.caller-meter span{display:block;height:100%;background:var(--obs-accent)}
@media(max-width:1100px){.qa-command{grid-template-columns:repeat(2,minmax(0,1fr))}.qa-grid{grid-template-columns:1fr}}@media(max-width:720px){.qa-command{grid-template-columns:1fr}}
</style>
{% set total = summary.total if summary else 0 %}
<div class="container-fluid mt-3">
<section class="qa-hero">
<div class="qa-kicker"><i class="fas fa-magnifying-glass-chart me-1"></i> RAG Recall Radar · {{ hours }}h Window</div>
<h1 class="qa-title">RAG 召回雷達</h1>
<p class="qa-subtitle">這裡追蹤每次 RAG 查詢是否真的命中、是否省下 LLM call、哪些 caller 用得好、哪些 query 沒找到知識。RAG 如果要成為武器,這裡就是雷達螢幕。</p>
<form method="get" class="qa-filter"><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><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><label class="form-check-label small d-flex align-items-center gap-2"><input class="form-check-input" type="checkbox" name="saved_only" value="1" {% if saved_only %}checked{% endif %} onchange="this.form.submit()">僅看 saved_call=true</label></form>
{% if summary and summary.total > 0 %}<div class="qa-command"><div class="qa-signal"><div class="qa-label">Queries</div><span class="qa-value">{{ "{:,}".format(summary.total) }}</span><div class="qa-note">{{ summary.distinct_callers }} callers</div></div><div class="qa-signal"><div class="qa-label">Hit Rate</div><span class="qa-value {% if summary.hit_rate >= 70 %}status-good{% elif summary.hit_rate >= 40 %}status-warn{% else %}status-bad{% endif %}">{{ "%.1f"|format(summary.hit_rate) }}%</span><div class="qa-note">{{ summary.with_hits }} hit · {{ summary.no_hits }} miss</div></div><div class="qa-signal"><div class="qa-label">Saved Call</div><span class="qa-value status-blue">{{ "%.1f"|format(summary.saved_rate) }}%</span><div class="qa-note">{{ summary.saved }} 次省下 LLM</div></div><div class="qa-signal"><div class="qa-label">Feedback</div><span class="qa-value {% if summary.avg_score >= 4 %}status-good{% elif summary.avg_score >= 3 %}status-warn{% else %}status-bad{% endif %}">{{ "%.2f"|format(summary.avg_score) }}</span><div class="qa-note">{{ summary.feedback_count }} 筆 · 平均 {{ summary.avg_hits }} hits</div></div></div>{% endif %}
</section>
{% if error %}<div class="alert alert-warning mt-3"><strong><i class="fas fa-triangle-exclamation me-1"></i></strong>{{ error }}</div>{% endif %}
<section class="qa-grid">
<div class="qa-stack">
<article class="qa-table-shell"><div class="qa-table-title"><div><div class="qa-label">Query Stream</div><h3>最近 50 筆查詢詳情</h3></div></div><div class="table-responsive">{% if queries %}<table class="table table-sm mb-0"><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="status-good">{{ q.hit_count }}</strong>{% else %}<small class="text-muted">0</small>{% endif %}</td><td>{% if q.saved_call %}<span class="badge bg-success">saved</span>{% else %}<small class="text-muted"></small>{% endif %}</td><td>{% if q.feedback_score is not none %}<span class="badge {% if q.feedback_score >= 4 %}bg-success{% elif q.feedback_score >= 3 %}bg-warning{% else %}bg-danger{% endif %}">{{ q.feedback_score }}/5</span>{% 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>{% else %}<div class="alert alert-info m-3">過去 {{ hours }} 小時無符合條件的 RAG 查詢紀錄。</div>{% endif %}</div></article>
</div>
<aside class="qa-stack">
{% if by_caller %}<article class="qa-panel"><div class="qa-panel-head"><div><div class="qa-label">Caller Quality</div><h2 class="qa-panel-title">各呼叫端 RAG 表現</h2></div></div><div class="qa-panel-body">{% for c in by_caller %}<div class="caller-card"><div class="caller-top"><code>{{ c.caller }}</code><strong class="{% if c.hit_rate >= 70 %}status-good{% elif c.hit_rate >= 40 %}status-warn{% else %}text-muted{% endif %}">{{ "%.1f"|format(c.hit_rate) }}%</strong></div><div class="caller-meter"><span style="width: {{ c.hit_rate|round|int }}%"></span></div><small class="text-muted">{{ c.total }} queries · saved {{ "%.1f"|format(c.saved_rate) }}% · feedback {{ c.fb_count }}</small></div>{% endfor %}</div></article>{% endif %}
</aside>
</section>
<p class="text-muted mt-3"><small><i class="fas fa-robot me-1"></i>Operation Ollama-First v5.0 — RAG 召回雷達</small></p>
</div>
<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 obs-modal-preview"><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 詳細資料</div>';}else{html+='<h6 class="mb-2">Top hits 內容預覽:</h6>';d.hits.forEach(h=>{html+=`<div class="mb-2 p-2 obs-modal-preview"><div class="mb-1"><span class="badge bg-light text-dark me-1">#${h.id}</span><span class="badge bg-info 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){console.warn('rag_query_hits_load_failed',e);body.innerHTML='<div class="alert alert-danger">❌ 召回詳情暫時無法載入,請稍後再試或查看系統日誌。</div>';}}
function escapeHtml(s){if(!s)return'';return s.replace(/[&<>"']/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));}
</script>
{% endblock %}