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
+} %}
+{{ ai_calls_payload | tojson }}
+
+
{% 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
+} %}
+{{ budget_payload | tojson }}
+
+
{% 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 %}
-
-
+{{ verdict_stats | tojson }}
+
+
{% 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([])
+} %}
+{{ host_health_payload | tojson }}
+
+
{% 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 @@
-
-
+{{ host_sparkline | tojson }}
+
+
{% 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 產線
-
+{{ audit_30d_stats | default({}) | tojson }}
+
+
{% 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 知識晉升閘
-
-
+{{ episode_distribution_30d | default({}) | tojson }}
+
+
{% 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 %}
+{{ rag_overall_dist | default([]) | tojson }}
+
+
{% 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();
+ }
+})();