All checks were successful
CD Pipeline / deploy (push) Successful in 2m35s
新頁 /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>
268 lines
11 KiB
HTML
268 lines
11 KiB
HTML
{% 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 表跨 JOIN:rag_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 => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));
|
||
}
|
||
</script>
|
||
{% endblock %}
|