Files
ewoooc/web/static/js/observability-charts.js
OoO c420d48263
Some checks failed
CD Pipeline / deploy (push) Failing after 26s
Fix PPT auto generation and analytics fallbacks
2026-05-18 11:52:31 +08:00

750 lines
28 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* Observability chart rendering and page actions.
* Chart.js is loaded lazily through analysis-chart-theme.js so observability
* pages do not block initial HTML parsing on a chart library.
*/
(function () {
'use strict';
const providerLabelMap = {
gcp_ollama: '主力 Ollama',
ollama_secondary: '備援 Ollama',
ollama_111: '111 Ollama',
gemini: 'Gemini',
claude: 'Claude',
nim: 'NIM',
openrouter: 'OpenRouter',
nim_via_elephant: 'NIM Elephant'
};
function readJson(id, fallback) {
const node = document.getElementById(id);
if (!node) return fallback;
try {
return JSON.parse(node.textContent || 'null') ?? fallback;
} catch (error) {
console.error(`[observability] JSON parse failed: ${id}`, error);
return fallback;
}
}
function loadChartJs() {
if (window.EwoooCChartTheme && window.EwoooCChartTheme.loadChartJs) {
return window.EwoooCChartTheme.loadChartJs();
}
if (window.Chart) return Promise.resolve(window.Chart);
return Promise.reject(new Error('Chart.js loader unavailable'));
}
function runWhenVisible(selector, render) {
const targets = Array.from(document.querySelectorAll(selector));
if (!targets.length) return;
if (!('IntersectionObserver' in window)) {
render();
return;
}
const observer = new IntersectionObserver(entries => {
if (!entries.some(entry => entry.isIntersecting)) return;
observer.disconnect();
render();
}, { rootMargin: '240px 0px' });
targets.forEach(target => observer.observe(target));
}
function mountChart(selector, render) {
runWhenVisible(selector, () => {
loadChartJs()
.then(render)
.catch(error => console.error('[observability] Chart.js 載入失敗:', error));
});
}
function renderOverviewHostSparklines() {
const data = readJson('obs-host-sparkline-data', {});
mountChart('canvas[data-host-sparkline]', () => {
document.querySelectorAll('canvas[data-host-sparkline]').forEach(el => {
if (el.dataset.chartReady === '1') return;
const label = el.getAttribute('data-host-sparkline');
const sp = data[label];
if (!sp || !sp.hours || !sp.hours.length) return;
el.dataset.chartReady = '1';
new Chart(el, {
type: 'line',
data: {
labels: sp.hours,
datasets: [{
data: sp.uptime_pct,
borderColor: '#c96442',
backgroundColor: 'rgba(201, 100, 66, 0.14)',
borderWidth: 1.8,
fill: true,
tension: 0.42,
pointRadius: 0
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: { display: false },
tooltip: {
displayColors: false,
callbacks: { label: c => `${c.label}: ${c.parsed.y.toFixed(0)}% 可用率` }
}
},
scales: { x: { display: false }, y: { display: false, min: 0, max: 100 } }
}
});
});
});
}
function renderBusinessVerdict() {
const verdictRows = readJson('obs-business-verdict-data', []);
const canvas = document.getElementById('verdictPieChart');
if (!canvas || !verdictRows.length) return;
const verdictLabelMap = {
effective: '有效',
success: '成功',
positive: '正向',
backfired: '反效果',
negative: '負向',
failed: '失敗',
neutral: '中性',
pending: '待回收',
inconclusive: '尚未定論',
no_data: '無資料'
};
mountChart('#verdictPieChart', () => {
new Chart(canvas, {
type: 'doughnut',
data: {
labels: verdictRows.map(row => verdictLabelMap[row.verdict] || row.verdict || '未分類'),
datasets: [{
data: verdictRows.map(row => row.count || 0),
backgroundColor: ['#2f8f6b', '#c96442', '#f1b45a', '#6d4b3f', '#d9a06f'],
borderColor: '#fff8ee',
borderWidth: 4,
hoverOffset: 10
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: {
position: 'bottom',
labels: { usePointStyle: true, boxWidth: 8, color: '#6f564b', font: { weight: 700 } }
}
},
cutout: '66%'
}
});
});
}
function renderAiCalls() {
const data = readJson('obs-ai-calls-data', {});
const labels = data.labels || [];
const calls = data.calls || [];
const costs = data.costs || [];
const errors = data.errors || [];
if (!labels.length) return;
mountChart('#hourlyTrendChart, canvas[data-spark]', () => {
const sparkColors = { calls: '#c96442', cost: '#b8792f', errors: '#b94b45' };
const sparkData = { calls, cost: costs, errors };
document.querySelectorAll('canvas[data-spark]').forEach(el => {
if (el.dataset.chartReady === '1') return;
const key = el.getAttribute('data-spark');
const series = sparkData[key];
if (!series || !series.length) return;
el.dataset.chartReady = '1';
new Chart(el, {
type: 'line',
data: {
labels,
datasets: [{
data: series,
borderColor: sparkColors[key],
backgroundColor: `${sparkColors[key]}24`,
borderWidth: 1.4,
fill: true,
tension: 0.42,
pointRadius: 0
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: { legend: { display: false }, tooltip: { enabled: false } },
scales: { x: { display: false }, y: { display: false, beginAtZero: true } }
}
});
});
const el = document.getElementById('hourlyTrendChart');
if (!el || el.dataset.chartReady === '1') return;
el.dataset.chartReady = '1';
new Chart(el, {
data: {
labels,
datasets: [
{ type: 'line', label: '呼叫數', data: calls, borderColor: '#c96442', backgroundColor: 'rgba(201,100,66,.12)', tension: 0.35, fill: true, yAxisID: 'y' },
{ type: 'line', label: '錯誤', data: errors, borderColor: '#b94b45', backgroundColor: 'rgba(185,75,69,.1)', tension: 0.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' } }
}
}
});
});
}
function renderBudgetCharts() {
const data = readJson('obs-budget-data', {});
const providerCostMonth = data.providerCostMonth || [];
const costTrend30d = data.costTrend30d || [];
if (providerCostMonth.length) {
mountChart('#providerCostPieChart', () => {
const el = document.getElementById('providerCostPieChart');
if (!el || el.dataset.chartReady === '1') return;
el.dataset.chartReady = '1';
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: providerCostMonth.map(d => providerLabelMap[d.provider] || d.provider),
datasets: [{
data: providerCostMonth.map(d => d.cost),
backgroundColor: providerCostMonth.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 } } } }
}
});
});
}
if (costTrend30d.length) {
mountChart('#costTrend30dChart', () => {
const el = document.getElementById('costTrend30dChart');
if (!el || el.dataset.chartReady === '1') return;
el.dataset.chartReady = '1';
const dateSet = [...new Set(costTrend30d.map(row => row.date))].sort();
const providerSet = [...new Set(costTrend30d.map(row => row.provider))];
const palette = ['#c96442', '#b8792f', '#4f8a5b', '#4f6f8f', '#6aa6a6', '#8b8077', '#a66a4a'];
const datasets = providerSet.map((provider, index) => ({
label: providerLabelMap[provider] || provider,
data: dateSet.map(date => {
const row = costTrend30d.find(item => item.date === date && item.provider === provider);
return row ? row.cost : 0;
}),
backgroundColor: palette[index % palette.length]
}));
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' } } }
}
});
});
}
}
function renderHostHealth() {
const data = readJson('obs-host-health-data', {});
const healSparkline = data.healSparkline || [];
if (!healSparkline.length) return;
mountChart('#healSparkline', () => {
const el = document.getElementById('healSparkline');
if (!el || el.dataset.chartReady === '1') return;
el.dataset.chartReady = '1';
new Chart(el, {
type: 'line',
data: {
labels: healSparkline.map(d => d.date),
datasets: [{
data: healSparkline.map(d => d.rate),
borderColor: '#c96442',
backgroundColor: 'rgba(201,100,66,.14)',
borderWidth: 2,
fill: true,
tension: 0.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}%` } } }
}
});
});
}
function renderPromotionReview() {
const dist = readJson('obs-promotion-review-data', {});
const keys = Object.keys(dist || {});
if (!keys.length) return;
mountChart('#episodeDistChart', () => {
const el = document.getElementById('episodeDistChart');
if (!el || el.dataset.chartReady === '1') return;
el.dataset.chartReady = '1';
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: '已過期'
};
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 } } } }
}
});
});
}
function renderQualityTrend() {
const rows = readJson('obs-quality-trend-data', []);
if (!rows.length) return;
mountChart('#ragFeedbackPieChart', () => {
const el = document.getElementById('ragFeedbackPieChart');
if (!el || el.dataset.chartReady === '1') return;
el.dataset.chartReady = '1';
const colorMap = { 1: '#b94b45', 2: '#c96442', 3: '#b8792f', 4: '#7aaa82', 5: '#4f8a5b' };
new Chart(el, {
type: 'doughnut',
data: {
labels: rows.map(row => `${row.score}`),
datasets: [{ data: rows.map(row => row.count), backgroundColor: rows.map(row => colorMap[row.score] || '#8b8077'), borderWidth: 1, borderColor: '#fff' }]
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: { legend: { position: 'bottom', labels: { font: { size: 12 } } } }
}
});
});
}
function renderPptAudit() {
const stats = readJson('obs-ppt-audit-data', {});
if (!stats.total) return;
mountChart('#pptAuditPieChart', () => {
const el = document.getElementById('pptAuditPieChart');
if (!el || el.dataset.chartReady === '1') return;
el.dataset.chartReady = '1';
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(item => item.value > 0);
if (!data.length) return;
new Chart(el, {
type: 'doughnut',
data: {
labels: data.map(item => item.label),
datasets: [{ data: data.map(item => item.value), backgroundColor: data.map(item => item.color), borderWidth: 1, borderColor: '#fff' }]
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: { legend: { position: 'bottom', labels: { font: { size: 12 } } } }
}
});
});
}
async function postJson(url, options) {
const fetcher = window.fetchWithCSRF || window.fetch.bind(window);
return fetcher(url, options || {});
}
window.triggerCodeReview = async function triggerCodeReview() {
if (!confirm('觸發程式碼審查管線?\n\n會對最新 commit 跑 5 步驟審查,背景執行。')) return;
try {
const response = await postJson('/observability/ai_calls/trigger_code_review', { method: 'POST' });
const data = await response.json();
if (data.ok) {
alert(`${data.message}\n\n管線 ID: ${data.pipeline_id}\nCommit: ${data.commit_sha}\n變更檔案: ${data.changed_files_count}`);
} else {
alert(`${data.error || '觸發失敗'}`);
}
} catch (error) {
console.warn('code_review_trigger_failed', error);
alert('操作暫時無法完成,請稍後再試或查看系統日誌。');
}
};
window.forceThrottle = async function forceThrottle() {
if (!confirm('立即重算所有供應商的節流狀態?')) return;
try {
const response = await postJson('/observability/budget/force_throttle', { method: 'POST' });
const data = await response.json();
if (data.ok) {
const providers = data.throttled_providers && data.throttled_providers.length > 0
? data.throttled_providers.join(', ')
: '(無)';
alert(`✅ 已重算:被節流的供應商 = ${providers}`);
window.location.reload();
} else {
alert(`${data.error || '重算失敗'}`);
}
} catch (error) {
console.warn('budget_force_throttle_failed', error);
alert('操作暫時無法完成,請稍後再試或查看系統日誌。');
}
};
window.saveBudget = 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 button = document.querySelector(`.save-budget-btn[data-budget-id="${id}"]`);
if (!budgetInput || !alertInput || !button) return;
button.disabled = true;
button.innerHTML = '<i class="fas fa-spinner fa-spin"></i>';
try {
const response = await postJson(`/observability/budget/update/${id}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
budget_usd: parseFloat(budgetInput.value),
alert_pct: parseInt(alertInput.value, 10)
})
});
const data = await response.json();
if (data.ok) {
button.innerHTML = '<i class="fas fa-check"></i> 已儲存';
setTimeout(() => {
button.innerHTML = '<i class="fas fa-save me-1"></i>儲存';
button.disabled = false;
}, 1500);
} else {
alert(`更新失敗:${data.error || '請稍後再試'}`);
button.disabled = false;
button.innerHTML = '<i class="fas fa-save me-1"></i>儲存';
}
} catch (error) {
console.warn('budget_save_failed', error);
alert('操作暫時無法完成,請稍後再試或查看系統日誌。');
button.disabled = false;
button.innerHTML = '<i class="fas fa-save me-1"></i>儲存';
}
};
window.togglePlaybook = async function togglePlaybook(id, name) {
if (!confirm(`切換 Playbook 「${name}」狀態?`)) return;
try {
const response = await postJson(`/observability/playbooks/toggle/${id}`, { method: 'POST' });
const data = await response.json();
if (data.ok) {
alert(`${data.message}`);
window.location.reload();
} else {
alert(`${data.error || '切換失敗'}`);
}
} catch (error) {
console.warn('playbook_toggle_failed', error);
alert('操作暫時無法完成,請稍後再試或查看系統日誌。');
}
};
window.triggerAutoHeal = async function triggerAutoHeal(hostLabel) {
if (!confirm(`觸發 AutoHeal\n\n主機:${hostLabel}`)) return;
try {
const response = await postJson('/observability/host_health/trigger_autoheal', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ host_label: hostLabel })
});
const data = await response.json();
if (data.ok) {
alert(`✅ AutoHeal 已派出\n動作:${data.action || '—'}\n訊息:${data.message || ''}`);
window.location.reload();
} else {
alert(`${data.error || data.message || '觸發失敗'}`);
}
} catch (error) {
console.warn('host_autoheal_failed', error);
alert('操作暫時無法完成,請稍後再試或查看系統日誌。');
}
};
window.approveEpisode = async function approveEpisode(id, button) {
button.disabled = true;
button.innerHTML = '<i class="fas fa-spinner fa-spin"></i> 處理中...';
try {
const response = await postJson(`/observability/promotion_review/approve/${id}`, { method: 'POST' });
const data = await response.json();
if (data.ok) {
const card = document.querySelector(`.episode-card[data-episode-id="${id}"]`);
const footer = card ? card.querySelector('.card-footer') : null;
if (card && footer) {
card.classList.add('border-success');
footer.innerHTML = `<span class="text-success"><i class="fas fa-check me-1"></i>已晉升 → ai_insights #${data.insight_id}(審核者:${data.approver}</span>`;
} else {
window.location.reload();
}
} else {
alert(`晉升失敗:${data.error || '請稍後再試'}`);
button.disabled = false;
button.innerHTML = '<i class="fas fa-check me-1"></i>通過晉升';
}
} catch (error) {
console.warn('promotion_approve_failed', error);
alert('操作暫時無法完成,請稍後再試或查看系統日誌。');
button.disabled = false;
button.innerHTML = '<i class="fas fa-check me-1"></i>通過晉升';
}
};
window.rejectEpisode = async function rejectEpisode(id, button) {
if (!confirm(`拒絕學習片段 #${id}?此筆將永不晉升。`)) return;
button.disabled = true;
button.innerHTML = '<i class="fas fa-spinner fa-spin"></i> 處理中...';
try {
const response = await postJson(`/observability/promotion_review/reject/${id}`, { method: 'POST' });
const data = await response.json();
if (data.ok) {
const card = document.querySelector(`.episode-card[data-episode-id="${id}"]`);
const footer = card ? card.querySelector('.card-footer') : null;
if (card && footer) {
card.classList.add('border-danger');
footer.innerHTML = '<span class="text-danger"><i class="fas fa-times me-1"></i>已拒絕(人工拒絕)</span>';
} else {
window.location.reload();
}
} else {
alert(`拒絕失敗:${data.error || '請稍後再試'}`);
button.disabled = false;
button.innerHTML = '<i class="fas fa-times me-1"></i>拒絕';
}
} catch (error) {
console.warn('promotion_reject_failed', error);
alert('操作暫時無法完成,請稍後再試或查看系統日誌。');
button.disabled = false;
button.innerHTML = '<i class="fas fa-times me-1"></i>拒絕';
}
};
window.triggerAiderHeal = async function triggerAiderHeal(pptxFilename, errorMsg) {
if (!confirm(`觸發 AiderHeal 自動修復?\n\n檔案:${pptxFilename}\n錯誤:${(errorMsg || '').substring(0, 200)}`)) return;
try {
const response = await postJson('/observability/ppt_audit/trigger_aider_heal', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ pptx_filename: pptxFilename, error_msg: errorMsg || '' })
});
const data = await response.json();
if (data.ok) {
alert(`✅ AiderHeal 已派出\n動作:${data.action || '—'}\n訊息:${data.message || ''}`);
} else {
alert(`${data.error || data.message || '觸發失敗'}`);
}
} catch (error) {
console.warn('ppt_audit_trigger_aider_heal_failed', error);
alert('操作暫時無法完成,請稍後再試或查看系統日誌。');
}
};
function initPptAutoGeneration() {
const panel = document.querySelector('[data-ppt-auto-generation]');
document.querySelectorAll('[data-ppt-aider-heal]').forEach(button => {
if (button.dataset.bound === '1') return;
button.dataset.bound = '1';
button.addEventListener('click', () => {
window.triggerAiderHeal(button.dataset.pptFilename || '', button.dataset.pptError || '');
});
});
if (!panel) return;
const button = panel.querySelector('[data-ppt-generate-missing]');
const status = panel.querySelector('[data-ppt-auto-status]');
const reportTypes = (panel.dataset.reportTypes || '')
.split(',')
.map(value => value.trim())
.filter(Boolean);
async function triggerGeneration(isAuto) {
if (button) {
button.disabled = true;
button.innerHTML = '<i class="fas fa-spinner fa-spin me-1"></i>補齊中';
}
if (status) {
status.classList.add('is-working');
status.textContent = isAuto
? '偵測到本月定義簡報缺漏,已排入背景補齊。'
: '已排入背景補齊,產出完成後重新整理即可看到最新檔案。';
}
try {
const response = await postJson('/observability/ppt_audit/generate_missing', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ report_types: reportTypes })
});
const data = await response.json();
if (status) {
status.textContent = data.message || (data.status === 'queued'
? '已排入背景補齊,請稍後重新整理。'
: `狀態:${data.status || '已送出'}`);
}
} catch (error) {
console.warn('ppt_auto_generation_failed', error);
if (status) {
status.classList.remove('is-working');
status.textContent = '補齊任務送出失敗,請稍後再試或查看系統日誌。';
}
if (button) button.disabled = false;
}
}
if (button) {
button.addEventListener('click', () => triggerGeneration(false));
}
if (panel.dataset.autoStart === 'true') {
const key = `ppt-auto-generation:${new Date().toISOString().slice(0, 10)}`;
let last = 0;
const now = Date.now();
try {
last = Number(window.localStorage.getItem(key) || 0);
} catch (_error) {
last = 0;
}
if (reportTypes.length && (!last || now - last > 6 * 60 * 60 * 1000)) {
try {
window.localStorage.setItem(key, String(now));
} catch (_error) {
// Ignore storage failures; the server-side generation lock still protects the job.
}
triggerGeneration(true);
}
}
}
function escapeHtml(value) {
if (!value) return '';
return String(value).replace(/[&<>"']/g, char => ({
'&': '&amp;',
'<': '&lt;',
'>': '&gt;',
'"': '&quot;',
"'": '&#39;'
})[char]);
}
function insightLabel(value) {
const insightLabelMap = {
product_pick: '選品攻擊',
price_recommendation: '價格建議',
competitor_price: '競品價格',
sales_anomaly: '業績異常',
budget_strategy: '預算策略',
rag_feedback: 'RAG 反饋',
ppt_audit: 'PPT 審核',
quality_issue: '品質問題',
promotion: '活動促銷',
market_signal: '市場訊號',
strategy: '策略洞察'
};
return insightLabelMap[value] || String(value || '未分類洞察').replaceAll('_', ' ');
}
window.showHits = async function showHits(queryId) {
const modalEl = document.getElementById('hitsModal');
const body = document.getElementById('hitsModalBody');
if (!modalEl || !body) return;
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 response = await fetch(`/observability/rag_queries/${queryId}/hits`);
const data = await response.json();
if (!data.ok) {
body.innerHTML = `<div class="alert alert-danger">❌ ${escapeHtml(data.error || '載入失敗')}</div>`;
return;
}
let html = `<div class="mb-3"><small class="text-muted">查詢 #${data.query_id} · 門檻 ${data.threshold} · 命中 ${data.hit_count}</small><div class="p-2 mt-1 obs-modal-preview"><small><strong>查詢內容:</strong></small><br><code>${escapeHtml(data.query_text || '')}</code></div></div>`;
if (!data.hits || data.hits.length === 0) {
html += '<div class="alert alert-warning">無命中詳細資料</div>';
} else {
html += '<h6 class="mb-2">主要命中內容預覽:</h6>';
data.hits.forEach(hit => {
html += `<div class="mb-2 p-2 obs-modal-preview"><div class="mb-1"><span class="badge bg-light text-dark me-1">#${hit.id}</span><span class="badge bg-info me-1">${escapeHtml(insightLabel(hit.insight_type))}</span>${hit.period ? `<span class="badge bg-secondary me-1">${escapeHtml(hit.period)}</span>` : ''}${hit.product_sku ? `<small class="text-muted me-1">SKU: ${escapeHtml(hit.product_sku)}</small>` : ''}<small class="text-muted">${escapeHtml(hit.created_at || '')}</small></div><small>${escapeHtml(hit.content || '')}${hit.content && hit.content.length >= 300 ? '…' : ''}</small></div>`;
});
}
body.innerHTML = html;
} catch (error) {
console.warn('rag_query_hits_load_failed', error);
body.innerHTML = '<div class="alert alert-danger">❌ 召回詳情暫時無法載入,請稍後再試或查看系統日誌。</div>';
}
};
function boot() {
renderOverviewHostSparklines();
renderBusinessVerdict();
renderAiCalls();
renderBudgetCharts();
renderHostHealth();
renderPromotionReview();
renderQualityTrend();
renderPptAudit();
initPptAutoGeneration();
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', boot, { once: true });
} else {
boot();
}
})();