fix(observability): soften frontend error copy
All checks were successful
CD Pipeline / deploy (push) Successful in 1m2s

This commit is contained in:
OoO
2026-05-05 21:58:49 +08:00
parent d93ad659ba
commit b21b40cae2
8 changed files with 14 additions and 14 deletions

View File

@@ -65,7 +65,7 @@ TEMPLATE_RULES = [
Rule(
"raw_error_copy",
re.compile(
r"(查詢失敗:|ProgrammingError|UndefinedError|Traceback|Internal Server Error|relation\s+"|relation\s+\")"
r"(查詢失敗:|ProgrammingError|UndefinedError|Traceback|Internal Server Error|relation\s+"|relation\s+\"|alert\(['\"]Error[:]|載入錯誤:\$\{e\}|unknown)"
),
"不得把 SQL/Jinja exception 或 raw failure 文案直接顯示給使用者。",
),

View File

@@ -143,6 +143,6 @@
const el = document.getElementById('hourlyTrendChart'); if (!el || !labels.length) return;
new Chart(el, { data: { labels, datasets: [ { type: 'line', label: '呼叫數', data: calls, borderColor: '#c96442', backgroundColor: 'rgba(201,100,66,.12)', tension: .35, fill: true, yAxisID: 'y' }, { type: 'line', label: '錯誤', data: errors, borderColor: '#b94b45', backgroundColor: 'rgba(185,75,69,.1)', tension: .35, yAxisID: 'y' }, { type: 'bar', label: '成本 USD', data: costs, backgroundColor: 'rgba(184,121,47,.38)', borderColor: '#b8792f', yAxisID: 'y1' } ] }, options: { responsive: true, maintainAspectRatio: false, interaction: { mode: 'index', intersect: false }, scales: { y: { beginAtZero: true, title: { display: true, text: '次數' } }, y1: { position: 'right', beginAtZero: true, grid: { drawOnChartArea: false }, title: { display: true, text: 'USD' } } } } });
})();
async function triggerCodeReview() { if (!confirm('觸發 Code Review Pipeline\n\n會對最新 commit 跑 5 step 審查,背景執行。')) return; try { const r = await fetch('/observability/ai_calls/trigger_code_review', {method: 'POST'}); const d = await r.json(); if (d.ok) { alert(`${d.message}\n\nPipeline ID: ${d.pipeline_id}\nCommit: ${d.commit_sha}\n變更檔案: ${d.changed_files_count}`); } else { alert('❌ ' + (d.error || '觸發失敗')); } } catch (e) { alert('Error: ' + e); } }
async function triggerCodeReview() { if (!confirm('觸發 Code Review Pipeline\n\n會對最新 commit 跑 5 step 審查,背景執行。')) return; try { const r = await fetch('/observability/ai_calls/trigger_code_review', {method: 'POST'}); const d = await r.json(); if (d.ok) { alert(`${d.message}\n\nPipeline ID: ${d.pipeline_id}\nCommit: ${d.commit_sha}\n變更檔案: ${d.changed_files_count}`); } else { alert('❌ ' + (d.error || '觸發失敗')); } } catch (e) { console.warn('code_review_trigger_failed', e); alert('操作暫時無法完成,請稍後再試或查看系統日誌。'); } }
</script>
{% endblock %}

View File

@@ -89,7 +89,7 @@
<script>
(function() { const data = {{ provider_cost_month | default([]) | tojson }}; const el = document.getElementById('providerCostPieChart'); if (!el || !data.length) return; const colors = {'gcp_ollama':'#4f8a5b','ollama_secondary':'#7aaa82','ollama_111':'#a3cfa8','gemini':'#b8792f','claude':'#4f6f8f','nim':'#6aa6a6','openrouter':'#8b8077','nim_via_elephant':'#c96442'}; new Chart(el,{type:'doughnut',data:{labels:data.map(d=>d.provider),datasets:[{data:data.map(d=>d.cost),backgroundColor:data.map((d,i)=>colors[d.provider]||`hsl(${(i*47)%360},55%,55%)`),borderWidth:1,borderColor:'#fff'}]},options:{responsive:true,maintainAspectRatio:false,plugins:{legend:{position:'bottom',labels:{font:{size:11}}}}}}); })();
(function() { const raw = {{ cost_trend_30d | tojson }}; if (!raw || !raw.length) return; const dateSet = [...new Set(raw.map(r=>r.date))].sort(); const providerSet = [...new Set(raw.map(r=>r.provider))]; const palette = ['#c96442','#b8792f','#4f8a5b','#4f6f8f','#6aa6a6','#8b8077','#a66a4a']; const datasets = providerSet.map((p,i)=>({label:p,data:dateSet.map(d=>{const row=raw.find(r=>r.date===d&&r.provider===p);return row?row.cost:0;}),backgroundColor:palette[i%palette.length]})); const el=document.getElementById('costTrend30dChart'); if(!el)return; new Chart(el,{type:'bar',data:{labels:dateSet,datasets},options:{responsive:true,maintainAspectRatio:false,interaction:{mode:'index',intersect:false},scales:{x:{stacked:true},y:{stacked:true,beginAtZero:true,title:{display:true,text:'USD'}}}}}); })();
async function forceThrottle(){if(!confirm('立即重算所有 provider 的 throttle 狀態?'))return;try{const r=await fetch('/observability/budget/force_throttle',{method:'POST'});const d=await r.json();if(d.ok){alert(`✅ 已重算:被節流的 provider = ${(d.throttled_providers&&d.throttled_providers.length>0)?d.throttled_providers.join(', '):'(無)'}`);window.location.reload();}else{alert('❌ '+(d.error||'重算失敗'));}}catch(e){alert('Error: '+e);}}
async function saveBudget(id){const budgetInput=document.querySelector(`.budget-input[data-budget-id="${id}"]`);const alertInput=document.querySelector(`.alert-input[data-budget-id="${id}"]`);const btn=document.querySelector(`.save-budget-btn[data-budget-id="${id}"]`);btn.disabled=true;btn.innerHTML='<i class="fas fa-spinner fa-spin"></i>';try{const r=await fetch(`/observability/budget/update/${id}`,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({budget_usd:parseFloat(budgetInput.value),alert_pct:parseInt(alertInput.value)})});const d=await r.json();if(d.ok){btn.innerHTML='<i class="fas fa-check"></i> 已儲存';setTimeout(()=>{btn.innerHTML='<i class="fas fa-save me-1"></i>儲存';btn.disabled=false;},1500);}else{alert('更新失敗:'+(d.error||'unknown'));btn.disabled=false;btn.innerHTML='<i class="fas fa-save me-1"></i>儲存';}}catch(e){alert('Error'+e);btn.disabled=false;btn.innerHTML='<i class="fas fa-save me-1"></i>儲存';}}
async function forceThrottle(){if(!confirm('立即重算所有 provider 的 throttle 狀態?'))return;try{const r=await fetch('/observability/budget/force_throttle',{method:'POST'});const d=await r.json();if(d.ok){alert(`✅ 已重算:被節流的 provider = ${(d.throttled_providers&&d.throttled_providers.length>0)?d.throttled_providers.join(', '):'(無)'}`);window.location.reload();}else{alert('❌ '+(d.error||'重算失敗'));}}catch(e){console.warn('budget_force_throttle_failed',e);alert('操作暫時無法完成,請稍後再試或查看系統日誌。');}}
async function saveBudget(id){const budgetInput=document.querySelector(`.budget-input[data-budget-id="${id}"]`);const alertInput=document.querySelector(`.alert-input[data-budget-id="${id}"]`);const btn=document.querySelector(`.save-budget-btn[data-budget-id="${id}"]`);btn.disabled=true;btn.innerHTML='<i class="fas fa-spinner fa-spin"></i>';try{const r=await fetch(`/observability/budget/update/${id}`,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({budget_usd:parseFloat(budgetInput.value),alert_pct:parseInt(alertInput.value)})});const d=await r.json();if(d.ok){btn.innerHTML='<i class="fas fa-check"></i> 已儲存';setTimeout(()=>{btn.innerHTML='<i class="fas fa-save me-1"></i>儲存';btn.disabled=false;},1500);}else{alert('更新失敗:'+(d.error||'請稍後再試'));btn.disabled=false;btn.innerHTML='<i class="fas fa-save me-1"></i>儲存';}}catch(e){console.warn('budget_save_failed',e);alert('操作暫時無法完成,請稍後再試或查看系統日誌。');btn.disabled=false;btn.innerHTML='<i class="fas fa-save me-1"></i>儲存';}}
</script>
{% endblock %}

View File

@@ -423,7 +423,7 @@
{% set ns.conf_total = ns.conf_total + ((r.avg_confidence or 0) * (r.count or 0)) %}
{% endfor %}
{% for v in verdict_stats %}
{% set label = v.verdict or 'unknown' %}
{% set label = v.verdict or '未分類' %}
{% set ns.verdict_total = ns.verdict_total + (v.count or 0) %}
{% if label == 'effective' or label == 'success' or label == 'positive' %}
{% set ns.effective = ns.effective + (v.count or 0) %}
@@ -517,7 +517,7 @@
<div class="biz-strategy-grid">
{% for r in rec_by_strategy %}
<article class="biz-strategy-card">
<div class="strategy">{{ r.strategy or 'unknown' }}</div>
<div class="strategy">{{ r.strategy or '未分類策略' }}</div>
<div class="metrics">
<div class="biz-mini-metric"><b>{{ r.count }}</b><span>建議數</span></div>
<div class="biz-mini-metric"><b>{{ '%.0f'|format((r.avg_confidence or 0) * 100) }}%</b><span>信心</span></div>
@@ -629,7 +629,7 @@
<thead><tr><th>Verdict</th><th>Count</th><th>Avg Δ</th></tr></thead>
<tbody>
{% for v in verdict_stats %}
<tr><td>{{ v.verdict or 'unknown' }}</td><td>{{ v.count }}</td><td>{{ '%.1f'|format(v.avg_delta or 0) }}%</td></tr>
<tr><td>{{ v.verdict or '未分類' }}</td><td>{{ v.count }}</td><td>{{ '%.1f'|format(v.avg_delta or 0) }}%</td></tr>
{% endfor %}
</tbody>
</table>
@@ -712,7 +712,7 @@
new Chart(canvas, {
type: 'doughnut',
data: {
labels: verdictRows.map(row => row.verdict || 'unknown'),
labels: verdictRows.map(row => row.verdict || '未分類'),
datasets: [{
data: verdictRows.map(row => row.count || 0),
backgroundColor: ['#2f8f6b', '#c96442', '#f1b45a', '#6d4b3f', '#d9a06f'],

View File

@@ -178,7 +178,7 @@
if (!el || !data.length) return;
new Chart(el, { type: 'line', data: { labels: data.map(d => d.date), datasets: [{ data: data.map(d => d.rate), borderColor: '#c96442', backgroundColor: 'rgba(201,100,66,.14)', borderWidth: 2, fill: true, tension: .35, pointRadius: 2 }] }, options: { responsive: true, maintainAspectRatio: false, plugins: { legend: { display: false } }, scales: { x: { display: false }, y: { min: 0, max: 100, ticks: { callback: v => v + '%' } } } } });
})();
async function togglePlaybook(id, name) { if (!confirm(`切換 Playbook 「${name}」狀態?`)) return; try { const r = await fetch(`/observability/playbooks/toggle/${id}`, {method: 'POST'}); const d = await r.json(); if (d.ok) { alert(`${d.message}`); window.location.reload(); } else { alert('❌ ' + (d.error || '切換失敗')); } } catch (e) { alert('Error: ' + e); } }
async function triggerAutoHeal(hostLabel) { if (!confirm(`觸發 AutoHeal\n\n主機:${hostLabel}`)) return; try { const r = await fetch('/observability/host_health/trigger_autoheal', { method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({host_label: hostLabel}) }); const d = await r.json(); if (d.ok) { alert(`✅ AutoHeal 已派出\n動作:${d.action || '—'}\n訊息:${d.message || ''}`); window.location.reload(); } else { alert('❌ ' + (d.error || d.message || '觸發失敗')); } } catch (e) { alert('Error: ' + e); } }
async function togglePlaybook(id, name) { if (!confirm(`切換 Playbook 「${name}」狀態?`)) return; try { const r = await fetch(`/observability/playbooks/toggle/${id}`, {method: 'POST'}); const d = await r.json(); if (d.ok) { alert(`${d.message}`); window.location.reload(); } else { alert('❌ ' + (d.error || '切換失敗')); } } catch (e) { console.warn('playbook_toggle_failed', e); alert('操作暫時無法完成,請稍後再試或查看系統日誌。'); } }
async function triggerAutoHeal(hostLabel) { if (!confirm(`觸發 AutoHeal\n\n主機:${hostLabel}`)) return; try { const r = await fetch('/observability/host_health/trigger_autoheal', { method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({host_label: hostLabel}) }); const d = await r.json(); if (d.ok) { alert(`✅ AutoHeal 已派出\n動作:${d.action || '—'}\n訊息:${d.message || ''}`); window.location.reload(); } else { alert('❌ ' + (d.error || d.message || '觸發失敗')); } } catch (e) { console.warn('host_autoheal_failed', e); alert('操作暫時無法完成,請稍後再試或查看系統日誌。'); } }
</script>
{% endblock %}

View File

@@ -29,6 +29,6 @@
</div>
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.1/dist/chart.umd.min.js"></script><script>(function(){const stats={{ audit_30d_stats | default({}) | tojson }};const el=document.getElementById('pptAuditPieChart');if(!el||!stats.total)return;const data=[{label:'通過',value:stats.passed||0,color:'#4f8a5b'},{label:'失敗',value:stats.failed||0,color:'#b8792f'},{label:'錯誤',value:stats.error||0,color:'#b94b45'},{label:'跳過',value:stats.skipped||0,color:'#8b8077'}].filter(d=>d.value>0);if(!data.length)return;new Chart(el,{type:'doughnut',data:{labels:data.map(d=>d.label),datasets:[{data:data.map(d=>d.value),backgroundColor:data.map(d=>d.color),borderWidth:1,borderColor:'#fff'}]},options:{responsive:true,maintainAspectRatio:false,plugins:{legend:{position:'bottom',labels:{font:{size:12}}}}}});})();
async function triggerAiderHeal(pptxFilename,errorMsg){if(!confirm(`觸發 AiderHeal 自動修復?\n\n檔案:${pptxFilename}\n錯誤:${(errorMsg||'').substring(0,200)}`))return;try{const r=await fetch('/observability/ppt_audit/trigger_aider_heal',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({pptx_filename:pptxFilename,error_msg:errorMsg||''})});const d=await r.json();if(d.ok){alert(`✅ AiderHeal 已派出\n動作:${d.action||'—'}\n訊息:${d.message||''}`);}else{alert('❌ '+(d.error||d.message||'觸發失敗'));}}catch(e){alert('Error: '+e);}}
async function triggerAiderHeal(pptxFilename,errorMsg){if(!confirm(`觸發 AiderHeal 自動修復?\n\n檔案:${pptxFilename}\n錯誤:${(errorMsg||'').substring(0,200)}`))return;try{const r=await fetch('/observability/ppt_audit/trigger_aider_heal',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({pptx_filename:pptxFilename,error_msg:errorMsg||''})});const d=await r.json();if(d.ok){alert(`✅ AiderHeal 已派出\n動作:${d.action||'—'}\n訊息:${d.message||''}`);}else{alert('❌ '+(d.error||d.message||'觸發失敗'));}}catch(e){console.warn('ppt_audit_trigger_aider_heal_failed',e);alert('操作暫時無法完成,請稍後再試或查看系統日誌。');}}
</script>
{% endblock %}

View File

@@ -101,7 +101,7 @@
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.1/dist/chart.umd.min.js"></script>
<script>
(function(){const dist={{ episode_distribution_30d | default({}) | tojson }};const el=document.getElementById('episodeDistChart');if(!el||!Object.keys(dist).length)return;const colorMap={pending:'#8b8077',awaiting_review:'#b8792f',approved:'#4f8a5b',rejected_quality:'#b94b45',rejected_hallucination:'#9f3330',rejected_duplicate:'#c96442',rejected_human:'#8f2925',expired:'#b8aea5'};const labelMap={pending:'待處理',awaiting_review:'待審核',approved:'已晉升',rejected_quality:'品質拒',rejected_hallucination:'幻覺拒',rejected_duplicate:'重複拒',rejected_human:'人工拒',expired:'已過期'};const keys=Object.keys(dist);new Chart(el,{type:'doughnut',data:{labels:keys.map(k=>labelMap[k]||k),datasets:[{data:keys.map(k=>dist[k]),backgroundColor:keys.map(k=>colorMap[k]||'#999'),borderWidth:1,borderColor:'#fff'}]},options:{responsive:true,maintainAspectRatio:false,plugins:{legend:{position:'bottom',labels:{font:{size:11}}}}}});})();
async function approveEpisode(id,btn){btn.disabled=true;btn.innerHTML='<i class="fas fa-spinner fa-spin"></i> 處理中...';try{const r=await fetch(`/observability/promotion_review/approve/${id}`,{method:'POST'});const d=await r.json();if(d.ok){const card=document.querySelector(`.episode-card[data-episode-id="${id}"]`);card.classList.add('border-success');card.querySelector('.card-footer').innerHTML=`<span class="text-success"><i class="fas fa-check me-1"></i>已晉升 → ai_insights #${d.insight_id}(審核者:${d.approver}</span>`;}else{alert('晉升失敗:'+(d.error||'unknown'));btn.disabled=false;btn.innerHTML='<i class="fas fa-check me-1"></i>通過晉升';}}catch(e){alert('Error'+e);btn.disabled=false;btn.innerHTML='<i class="fas fa-check me-1"></i>通過晉升';}}
async function rejectEpisode(id,btn){if(!confirm(`拒絕學習片段 #${id}?此筆將永不晉升。`))return;btn.disabled=true;btn.innerHTML='<i class="fas fa-spinner fa-spin"></i> 處理中...';try{const r=await fetch(`/observability/promotion_review/reject/${id}`,{method:'POST'});const d=await r.json();if(d.ok){const card=document.querySelector(`.episode-card[data-episode-id="${id}"]`);card.classList.add('border-danger');card.querySelector('.card-footer').innerHTML='<span class="text-danger"><i class="fas fa-times me-1"></i>已拒絕rejected_human</span>';}else{alert('拒絕失敗:'+(d.error||'unknown'));btn.disabled=false;btn.innerHTML='<i class="fas fa-times me-1"></i>拒絕';}}catch(e){alert('Error'+e);btn.disabled=false;btn.innerHTML='<i class="fas fa-times me-1"></i>拒絕';}}
async function approveEpisode(id,btn){btn.disabled=true;btn.innerHTML='<i class="fas fa-spinner fa-spin"></i> 處理中...';try{const r=await fetch(`/observability/promotion_review/approve/${id}`,{method:'POST'});const d=await r.json();if(d.ok){const card=document.querySelector(`.episode-card[data-episode-id="${id}"]`);card.classList.add('border-success');card.querySelector('.card-footer').innerHTML=`<span class="text-success"><i class="fas fa-check me-1"></i>已晉升 → ai_insights #${d.insight_id}(審核者:${d.approver}</span>`;}else{alert('晉升失敗:'+(d.error||'請稍後再試'));btn.disabled=false;btn.innerHTML='<i class="fas fa-check me-1"></i>通過晉升';}}catch(e){console.warn('promotion_approve_failed',e);alert('操作暫時無法完成,請稍後再試或查看系統日誌。');btn.disabled=false;btn.innerHTML='<i class="fas fa-check me-1"></i>通過晉升';}}
async function rejectEpisode(id,btn){if(!confirm(`拒絕學習片段 #${id}?此筆將永不晉升。`))return;btn.disabled=true;btn.innerHTML='<i class="fas fa-spinner fa-spin"></i> 處理中...';try{const r=await fetch(`/observability/promotion_review/reject/${id}`,{method:'POST'});const d=await r.json();if(d.ok){const card=document.querySelector(`.episode-card[data-episode-id="${id}"]`);card.classList.add('border-danger');card.querySelector('.card-footer').innerHTML='<span class="text-danger"><i class="fas fa-times me-1"></i>已拒絕rejected_human</span>';}else{alert('拒絕失敗:'+(d.error||'請稍後再試'));btn.disabled=false;btn.innerHTML='<i class="fas fa-times me-1"></i>拒絕';}}catch(e){console.warn('promotion_reject_failed',e);alert('操作暫時無法完成,請稍後再試或查看系統日誌。');btn.disabled=false;btn.innerHTML='<i class="fas fa-times me-1"></i>拒絕';}}
</script>
{% endblock %}

View File

@@ -40,7 +40,7 @@
<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){body.innerHTML=`<div class="alert alert-danger">❌ 載入錯誤:${e}</div>`;}}
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 %}