feat(p51): RAG 召回詳情新頁 + overview 三主機 24h sparkline
All checks were successful
CD Pipeline / deploy (push) Successful in 2m35s
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:
@@ -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 編排
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
267
templates/admin/rag_queries.html
Normal file
267
templates/admin/rag_queries.html
Normal 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 表跨 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 %}
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user