feat(p51): RAG 召回詳情新頁 + overview 三主機 24h sparkline
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>
This commit is contained in:
OoO
2026-05-04 20:09:28 +08:00
parent 87d460e243
commit e0a8d87c2c
4 changed files with 555 additions and 4 deletions

View File

@@ -239,14 +239,245 @@ def observability_overview():
finally:
session.close()
# Phase 51 O-3: 24h 三主機健康 sparkline 資料(每小時 bucket × 3 host
host_sparkline = {}
try:
s_sp = get_session()
try:
sp_rows = s_sp.execute(
sa_text("""
SELECT host_label,
date_trunc('hour', probed_at) AS hr,
COUNT(*) AS total,
COUNT(*) FILTER (WHERE healthy) AS up
FROM host_health_probes
WHERE probed_at >= NOW() - INTERVAL '24 hours'
GROUP BY host_label, hr
ORDER BY host_label, hr ASC
"""),
).fetchall()
for r in sp_rows:
label, hr, total, up = r[0], r[1], int(r[2] or 0), int(r[3] or 0)
if label not in host_sparkline:
host_sparkline[label] = {'hours': [], 'uptime_pct': []}
host_sparkline[label]['hours'].append(
hr.strftime('%H:00') if hr else ''
)
host_sparkline[label]['uptime_pct'].append(
(up / total * 100) if total else 0
)
finally:
s_sp.close()
except Exception:
pass
return render_template(
'admin/observability_overview.html',
active_page='obs_overview',
summary=summary,
host_sparkline=host_sparkline,
today=today.strftime('%Y-%m-%d'),
)
# ─────────────────────────────────────────────────────────────────────────────
# /observability/rag_queries — Phase 51 RAG 召回詳情
# ─────────────────────────────────────────────────────────────────────────────
@admin_observability_bp.route('/rag_queries')
@login_required
def rag_queries_dashboard():
"""Phase 51 — RAG 召回詳情:每筆 query 的命中、saved_call、反饋。
補完 RAG 觀測深度:之前只看 caller 級命中率,現在看每筆查詢的真實內容。
"""
hours = int(request.args.get('hours', '24'))
caller_filter = request.args.get('caller', '').strip()
saved_only = request.args.get('saved_only', '').strip() == '1'
session = get_session()
try:
# 整體統計
summary_row = session.execute(
sa_text("""
SELECT COUNT(*) AS total,
COUNT(*) FILTER (WHERE saved_call) AS saved,
COUNT(*) FILTER (WHERE hit_count > 0) AS with_hits,
COALESCE(AVG(hit_count), 0) AS avg_hits,
COALESCE(AVG(feedback_score) FILTER (WHERE feedback_score IS NOT NULL), 0) AS avg_score,
COUNT(*) FILTER (WHERE feedback_score IS NOT NULL) AS feedback_count,
COUNT(DISTINCT caller) AS distinct_callers
FROM rag_query_log
WHERE queried_at >= NOW() - (:h * INTERVAL '1 hour')
"""),
{'h': hours},
).fetchone()
total = int(summary_row[0] or 0)
saved = int(summary_row[1] or 0)
with_hits = int(summary_row[2] or 0)
summary = {
'total': total,
'saved': saved,
'with_hits': with_hits,
'no_hits': total - with_hits,
'avg_hits': round(float(summary_row[3] or 0), 2),
'avg_score': round(float(summary_row[4] or 0), 2),
'feedback_count': int(summary_row[5] or 0),
'distinct_callers': int(summary_row[6] or 0),
'saved_rate': (float(saved) / total * 100) if total else 0,
'hit_rate': (float(with_hits) / total * 100) if total else 0,
}
# caller 列表dropdown
callers = session.execute(
sa_text("""
SELECT DISTINCT caller FROM rag_query_log
WHERE queried_at >= NOW() - (:h * INTERVAL '1 hour')
ORDER BY caller
"""),
{'h': hours},
).fetchall()
caller_list = [r[0] for r in callers]
# by caller 統計
by_caller = session.execute(
sa_text("""
SELECT caller,
COUNT(*) AS total,
COUNT(*) FILTER (WHERE saved_call) AS saved,
COUNT(*) FILTER (WHERE hit_count > 0) AS with_hits,
COALESCE(AVG(feedback_score) FILTER (WHERE feedback_score IS NOT NULL), 0) AS avg_score,
COUNT(*) FILTER (WHERE feedback_score IS NOT NULL) AS fb_count
FROM rag_query_log
WHERE queried_at >= NOW() - (:h * INTERVAL '1 hour')
GROUP BY caller
ORDER BY total DESC
"""),
{'h': hours},
).fetchall()
# 最近 50 筆查詢(套 caller filter + saved_only
params = {'h': hours, 'caller_f': caller_filter}
recent_queries = session.execute(
sa_text(f"""
SELECT id, queried_at, caller, LEFT(query_text, 200) AS qtext,
top_k, threshold, hit_count, used_results, saved_call,
feedback_score, request_id
FROM rag_query_log
WHERE queried_at >= NOW() - (:h * INTERVAL '1 hour')
AND (:caller_f = '' OR caller = :caller_f)
{"AND saved_call = TRUE" if saved_only else ""}
ORDER BY queried_at DESC
LIMIT 50
"""),
params,
).fetchall()
queries = []
for r in recent_queries:
used_ids = list(r[7]) if r[7] else []
queries.append({
'id': int(r[0]),
'queried_at': r[1].strftime('%Y-%m-%d %H:%M:%S') if r[1] else '',
'caller': r[2],
'query_text': r[3] or '',
'top_k': int(r[4] or 0),
'threshold': round(float(r[5] or 0), 3),
'hit_count': int(r[6] or 0),
'used_results': used_ids,
'saved_call': bool(r[8]),
'feedback_score': int(r[9]) if r[9] is not None else None,
'request_id': r[10],
})
return render_template(
'admin/rag_queries.html',
active_page='obs_rag_queries',
hours=hours,
caller_filter=caller_filter,
saved_only=saved_only,
summary=summary,
callers=caller_list,
by_caller=[
{
'caller': r[0], 'total': int(r[1] or 0),
'saved': int(r[2] or 0), 'with_hits': int(r[3] or 0),
'avg_score': round(float(r[4] or 0), 2),
'fb_count': int(r[5] or 0),
'saved_rate': (float(r[2] or 0) / float(r[1]) * 100) if r[1] else 0,
'hit_rate': (float(r[3] or 0) / float(r[1]) * 100) if r[1] else 0,
}
for r in by_caller
],
queries=queries,
error=None,
)
except Exception as e:
return render_template(
'admin/rag_queries.html',
active_page='obs_rag_queries', hours=hours,
caller_filter=caller_filter, saved_only=saved_only,
summary={}, callers=[], by_caller=[], queries=[],
error=f'查詢失敗: {type(e).__name__}: {str(e)[:200]}',
)
finally:
session.close()
@admin_observability_bp.route('/rag_queries/<int:query_id>/hits', methods=['GET'])
@login_required
def rag_query_hits(query_id: int):
"""Phase 51 — JSON API回傳單筆 query 的 hits 詳細內容(給 modal 展開)。"""
try:
session = get_session()
try:
row = session.execute(
sa_text("""
SELECT id, query_text, used_results, hit_count, threshold
FROM rag_query_log WHERE id = :id
"""),
{'id': query_id},
).fetchone()
if not row:
return jsonify({'ok': False, 'error': 'not found'}), 404
used_ids = list(row[2]) if row[2] else []
hits = []
if used_ids:
rows = session.execute(
sa_text("""
SELECT id, insight_type, period, product_sku,
LEFT(content, 300) AS preview, created_at
FROM ai_insights
WHERE id = ANY(:ids)
ORDER BY created_at DESC
"""),
{'ids': used_ids},
).fetchall()
hits = [
{
'id': int(h[0]),
'insight_type': h[1],
'period': h[2],
'product_sku': h[3],
'content': h[4] or '',
'created_at': h[5].strftime('%Y-%m-%d') if h[5] else '',
}
for h in rows
]
return jsonify({
'ok': True,
'query_id': query_id,
'query_text': row[1],
'hit_count': int(row[3] or 0),
'threshold': round(float(row[4] or 0), 3),
'hits': hits,
})
finally:
session.close()
except Exception as e:
return jsonify({'ok': False, 'error': f'{type(e).__name__}: {str(e)[:200]}'}), 500
# ─────────────────────────────────────────────────────────────────────────────
# /observability/business_intel — Phase 48 商業面 × AI 編排
# ─────────────────────────────────────────────────────────────────────────────

View File

@@ -8,7 +8,7 @@
<small class="text-muted">{{ today }} · 全景一頁看(資料來源 8 表跨 JOIN</small>
</h2>
<!-- 三主機健康卡片 -->
<!-- 三主機健康卡片(含 24h sparkline-->
<div class="row g-3 mb-3">
{% if summary.hosts %}
{% for h in summary.hosts %}
@@ -29,6 +29,11 @@
<div class="mt-2"><small class="text-muted">{{ h.avg_ms }} ms</small></div>
</div>
</div>
{% if host_sparkline.get(h.label) %}
<div class="mt-2" style="height: 60px;">
<canvas data-host-sparkline="{{ h.label }}"></canvas>
</div>
{% endif %}
</div>
</div>
</div>
@@ -219,9 +224,9 @@
</div>
{% endif %}
<!-- 8 大入口 -->
<!-- 9 大入口 -->
<div class="card">
<div class="card-header"><strong><i class="fas fa-th me-1"></i>8 大子頁入口</strong></div>
<div class="card-header"><strong><i class="fas fa-th me-1"></i>9 大子頁入口</strong></div>
<div class="card-body">
<div class="row g-2">
<div class="col-lg-4 col-md-6">
@@ -260,6 +265,12 @@
<small class="d-block text-muted ms-4">待審 episode + RAG Top 3 相似已晉升輔助</small>
</a>
</div>
<div class="col-lg-4 col-md-6">
<a href="/observability/rag_queries" class="btn btn-outline-primary w-100 text-start">
<i class="fas fa-magnifying-glass-chart me-2"></i>RAG 召回詳情
<small class="d-block text-muted ms-4">每筆 query 的 hits / saved_call / 反饋追蹤</small>
</a>
</div>
<div class="col-lg-4 col-md-6">
<a href="/observability/quality_trend" class="btn btn-outline-primary w-100 text-start">
<i class="fas fa-comments me-2"></i>Caller 反饋趨勢
@@ -277,9 +288,46 @@
</div>
<p class="text-muted mt-3"><small>
<i class="fas fa-robot me-1"></i>Operation Ollama-First v5.0 / Phase 45 — AI 觀測台總覽
<i class="fas fa-robot me-1"></i>Operation Ollama-First v5.0 / Phase 51 — AI 觀測台總覽(含 24h sparkline
資料來源host_health_probes / ai_calls / ai_call_budgets / learning_episodes / ai_insights /
rag_query_log / mcp_calls / incidents / heal_logs / ppt_audit_results
</small></p>
</div>
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.1/dist/chart.umd.min.js"></script>
<script>
// Phase 51 O-3: 三主機 24h sparkline
(function() {
const data = {{ host_sparkline | tojson }};
document.querySelectorAll('canvas[data-host-sparkline]').forEach(el => {
const label = el.getAttribute('data-host-sparkline');
const sp = data[label];
if (!sp || !sp.hours || !sp.hours.length) return;
new Chart(el, {
type: 'line',
data: {
labels: sp.hours,
datasets: [{
data: sp.uptime_pct,
borderColor: '#0d6efd',
backgroundColor: 'rgba(13,110,253,0.15)',
borderWidth: 1.5,
fill: true,
tension: 0.4,
pointRadius: 0,
}]
},
options: {
responsive: true, maintainAspectRatio: false,
plugins: { legend: { display: false }, tooltip: { enabled: true,
callbacks: { label: c => `${c.label}: ${c.parsed.y.toFixed(0)}%` } } },
scales: {
x: { display: false },
y: { display: false, min: 0, max: 100 }
}
}
});
});
})();
</script>
{% endblock %}

View File

@@ -0,0 +1,267 @@
{% 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 %}

View File

@@ -111,6 +111,11 @@
<span class="momo-nav-label">RAG 晉升審核</span>
<span class="momo-nav-code momo-mono">11</span>
</a>
<a class="momo-nav-link {% if _active_page == 'obs_rag_queries' %}is-active{% endif %}" href="/observability/rag_queries">
<span class="momo-nav-icon"><i class="fas fa-magnifying-glass-chart"></i></span>
<span class="momo-nav-label">RAG 召回詳情</span>
<span class="momo-nav-code momo-mono">11b</span>
</a>
<a class="momo-nav-link {% if _active_page == 'obs_quality_trend' %}is-active{% endif %}" href="/observability/quality_trend">
<span class="momo-nav-icon"><i class="fas fa-comments"></i></span>
<span class="momo-nav-label">反饋趨勢</span>