diff --git a/config.py b/config.py index 782aca6..676f187 100644 --- a/config.py +++ b/config.py @@ -320,7 +320,7 @@ YOUTUBE_API_KEY = os.getenv('YOUTUBE_API_KEY', '') # ========================================== # 系統版本與路徑 # ========================================== -SYSTEM_VERSION = "V10.167" +SYSTEM_VERSION = "V10.168" LOG_FILE_PATH = os.path.join(BASE_DIR, 'logs/system.log') public_url = PUBLIC_URL # 用於模板顯示 diff --git a/templates/admin/ai_calls_dashboard.html b/templates/admin/ai_calls_dashboard.html index 383d699..fffea22 100644 --- a/templates/admin/ai_calls_dashboard.html +++ b/templates/admin/ai_calls_dashboard.html @@ -131,19 +131,13 @@

Ollama 優先策略 v5.0 — AI 流量控制塔

- - +{% set ai_calls_payload = { + 'labels': hourly_trend | map(attribute='hour') | list, + 'calls': hourly_trend | map(attribute='calls') | list, + 'costs': hourly_trend | map(attribute='cost') | list, + 'errors': hourly_trend | map(attribute='errors') | list +} %} + + + {% endblock %} diff --git a/templates/admin/budget.html b/templates/admin/budget.html index b9f2f10..d74a66a 100644 --- a/templates/admin/budget.html +++ b/templates/admin/budget.html @@ -86,12 +86,11 @@

Ollama 優先策略 v5.0 — AI 成本治理艙

- - +{% set budget_payload = { + 'providerCostMonth': provider_cost_month | default([]), + 'costTrend30d': cost_trend_30d +} %} + + + {% endblock %} diff --git a/templates/admin/business_intel.html b/templates/admin/business_intel.html index 756d1ca..3789c23 100644 --- a/templates/admin/business_intel.html +++ b/templates/admin/business_intel.html @@ -703,49 +703,7 @@ {% endblock %} {% block extra_js %} - - + + + {% endblock %} diff --git a/templates/admin/host_health.html b/templates/admin/host_health.html index 00a1223..2884dd7 100644 --- a/templates/admin/host_health.html +++ b/templates/admin/host_health.html @@ -170,15 +170,10 @@

Ollama 優先策略 v5.0 — 基礎設施生命線

- - +{% set host_health_payload = { + 'healSparkline': aiops_summary.heal_sparkline | default([]) +} %} + + + {% endblock %} diff --git a/templates/admin/observability_overview.html b/templates/admin/observability_overview.html index 5bd6748..e80456f 100644 --- a/templates/admin/observability_overview.html +++ b/templates/admin/observability_overview.html @@ -623,45 +623,7 @@

- - + + + {% endblock %} diff --git a/templates/admin/ppt_audit_history.html b/templates/admin/ppt_audit_history.html index a271603..7d5e8bf 100644 --- a/templates/admin/ppt_audit_history.html +++ b/templates/admin/ppt_audit_history.html @@ -430,7 +430,7 @@

Ollama 優先策略 v5.0 — PPT 視覺 QA 產線

- + + + {% endblock %} diff --git a/templates/admin/promotion_review.html b/templates/admin/promotion_review.html index 3666863..a000e6a 100644 --- a/templates/admin/promotion_review.html +++ b/templates/admin/promotion_review.html @@ -99,10 +99,7 @@

Ollama 優先策略 v5.0 — RAG 知識晉升閘

- - + + + {% endblock %} diff --git a/templates/admin/quality_trend.html b/templates/admin/quality_trend.html index e55dad9..9d6ebee 100644 --- a/templates/admin/quality_trend.html +++ b/templates/admin/quality_trend.html @@ -38,5 +38,7 @@

Ollama 優先策略 v5.0 — AI 品質診斷台

-{% if rag_overall_dist %}{% endif %} + + + {% endblock %} diff --git a/templates/admin/rag_queries.html b/templates/admin/rag_queries.html index 95184ff..b4530f4 100644 --- a/templates/admin/rag_queries.html +++ b/templates/admin/rag_queries.html @@ -39,10 +39,5 @@ - + {% endblock %} diff --git a/web/static/js/observability-charts.js b/web/static/js/observability-charts.js new file mode 100644 index 0000000..f4ea120 --- /dev/null +++ b/web/static/js/observability-charts.js @@ -0,0 +1,671 @@ +/* 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 = ''; + 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 = ' 已儲存'; + setTimeout(() => { + button.innerHTML = '儲存'; + button.disabled = false; + }, 1500); + } else { + alert(`更新失敗:${data.error || '請稍後再試'}`); + button.disabled = false; + button.innerHTML = '儲存'; + } + } catch (error) { + console.warn('budget_save_failed', error); + alert('操作暫時無法完成,請稍後再試或查看系統日誌。'); + button.disabled = false; + button.innerHTML = '儲存'; + } + }; + + 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 = ' 處理中...'; + 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 = `已晉升 → ai_insights #${data.insight_id}(審核者:${data.approver})`; + } else { + window.location.reload(); + } + } else { + alert(`晉升失敗:${data.error || '請稍後再試'}`); + button.disabled = false; + button.innerHTML = '通過晉升'; + } + } catch (error) { + console.warn('promotion_approve_failed', error); + alert('操作暫時無法完成,請稍後再試或查看系統日誌。'); + button.disabled = false; + button.innerHTML = '通過晉升'; + } + }; + + window.rejectEpisode = async function rejectEpisode(id, button) { + if (!confirm(`拒絕學習片段 #${id}?此筆將永不晉升。`)) return; + button.disabled = true; + button.innerHTML = ' 處理中...'; + 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 = '已拒絕(人工拒絕)'; + } else { + window.location.reload(); + } + } else { + alert(`拒絕失敗:${data.error || '請稍後再試'}`); + button.disabled = false; + button.innerHTML = '拒絕'; + } + } catch (error) { + console.warn('promotion_reject_failed', error); + alert('操作暫時無法完成,請稍後再試或查看系統日誌。'); + button.disabled = false; + button.innerHTML = '拒絕'; + } + }; + + 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 escapeHtml(value) { + if (!value) return ''; + return String(value).replace(/[&<>"']/g, char => ({ + '&': '&', + '<': '<', + '>': '>', + '"': '"', + "'": ''' + })[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 = '
載入中...
'; + 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 = `
❌ ${escapeHtml(data.error || '載入失敗')}
`; + return; + } + + let html = `
查詢 #${data.query_id} · 門檻 ${data.threshold} · 命中 ${data.hit_count}
查詢內容:
${escapeHtml(data.query_text || '')}
`; + if (!data.hits || data.hits.length === 0) { + html += '
無命中詳細資料
'; + } else { + html += '
主要命中內容預覽:
'; + data.hits.forEach(hit => { + html += `
#${hit.id}${escapeHtml(insightLabel(hit.insight_type))}${hit.period ? `${escapeHtml(hit.period)}` : ''}${hit.product_sku ? `SKU: ${escapeHtml(hit.product_sku)}` : ''}${escapeHtml(hit.created_at || '')}
${escapeHtml(hit.content || '')}${hit.content && hit.content.length >= 300 ? '…' : ''}
`; + }); + } + body.innerHTML = html; + } catch (error) { + console.warn('rag_query_hits_load_failed', error); + body.innerHTML = '
❌ 召回詳情暫時無法載入,請稍後再試或查看系統日誌。
'; + } + }; + + function boot() { + renderOverviewHostSparklines(); + renderBusinessVerdict(); + renderAiCalls(); + renderBudgetCharts(); + renderHostHealth(); + renderPromotionReview(); + renderQualityTrend(); + renderPptAudit(); + } + + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', boot, { once: true }); + } else { + boot(); + } +})();