Files
ewoooc/templates/ai_intelligence.html
ogt 1b4f3a7bbe
Some checks failed
CD Pipeline / deploy (push) Failing after 59s
feat: EwoooC 初始化 — 完整專案推版至 Gitea
- 建立 Gitea Actions CD pipeline (.gitea/workflows/cd.yaml)
- 部署模式: rsync Python 檔案至 188 → docker restart (volume mount)
- Dockerfile/requirements 變動時自動重建 Docker image
- 部署通知: Telegram (開始/成功/失敗)
- 健康檢查: https://mo.wooo.work/health (最多 5 次重試)
- 同步最新 CLAUDE.md / ADR-008 / memory (2026-04-19)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-19 01:21:13 +08:00

386 lines
19 KiB
HTML
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.
{% extends 'base.html' %}
{% block title %}AI 競情中樞 - WOOO TECH{% endblock %}
{% block content %}
<div class="container-fluid py-3">
<!-- ── 頁首 ── -->
<div class="row mb-3 align-items-center">
<div class="col">
<h4 class="mb-0">
<i class="fas fa-brain text-danger me-2"></i>AI 競情中樞
<span class="badge bg-danger ms-2 fs-6">ICAIM</span>
</h4>
<small class="text-muted">Hermes 3 分析師 × NemoTron 派發器 — 全天候競品監控</small>
</div>
<div class="col-auto d-flex gap-2 align-items-center">
<span id="lastUpdateBadge" class="badge bg-secondary">
<i class="fas fa-sync me-1"></i>載入中...
</span>
<button class="btn btn-outline-danger btn-sm" id="btnTrigger" onclick="triggerAnalysis()">
<i class="fas fa-bolt me-1"></i>立即分析
</button>
<button class="btn btn-outline-secondary btn-sm" onclick="loadDashboard()">
<i class="fas fa-redo me-1"></i>重新整理
</button>
</div>
</div>
<!-- ── KPI 卡片 ── -->
<div class="row g-3 mb-4" id="kpiRow">
<div class="col-6 col-md-3">
<div class="card border-0 shadow-sm h-100">
<div class="card-body text-center py-3">
<div class="fs-2 fw-bold text-primary" id="kpiSkus"></div>
<div class="small text-muted mt-1"><i class="fas fa-box me-1"></i>監控商品數</div>
</div>
</div>
</div>
<div class="col-6 col-md-3">
<div class="card border-0 shadow-sm h-100">
<div class="card-body text-center py-3">
<div class="fs-2 fw-bold text-success" id="kpiCompetitors"></div>
<div class="small text-muted mt-1"><i class="fas fa-store me-1"></i>有效競品比價</div>
</div>
</div>
</div>
<div class="col-6 col-md-3">
<!-- 高風險卡 — 數值來自全量 CTE非前端截斷的 200 筆 -->
<div class="card border-0 shadow-sm h-100" id="kpiHighRiskCard">
<div class="card-body text-center py-3">
<div class="fs-2 fw-bold text-danger" id="kpiHighRisk"></div>
<div class="small text-muted mt-1">
<i class="fas fa-exclamation-triangle me-1"></i>高風險商品
<span class="text-muted" style="font-size:0.7rem">(貴&gt;15%)</span>
</div>
</div>
</div>
</div>
<div class="col-6 col-md-3">
<div class="card border-0 shadow-sm h-100">
<div class="card-body text-center py-3">
<div class="fs-2 fw-bold text-info" id="kpiAiRecs"></div>
<div class="small text-muted mt-1"><i class="fas fa-robot me-1"></i>AI 決策記錄</div>
</div>
</div>
</div>
</div>
<!-- ── 主體分兩欄(競品比價 + AI 決策) ── -->
<div class="row g-3">
<!-- ── 左PChome 競品比價 ── -->
<div class="col-xl-7">
<div class="card shadow-sm h-100">
<div class="card-header d-flex justify-content-between align-items-center py-2 bg-white border-bottom">
<span class="fw-bold">
<i class="fas fa-balance-scale text-warning me-2"></i>PChome 競品比價監控
</span>
<div class="d-flex gap-2 align-items-center">
<select class="form-select form-select-sm" id="riskFilter" onchange="filterTable()" style="width:100px">
<option value="all">全部</option>
<option value="HIGH">高風險</option>
<option value="MED">中風險</option>
<option value="LOW">低風險</option>
</select>
<input type="text" class="form-control form-control-sm" id="searchInput"
placeholder="搜尋商品..." oninput="filterTable()" style="width:130px">
</div>
</div>
<!-- 熱力圖圖例 -->
<div class="px-3 pt-2 pb-1 d-flex gap-3 small text-muted" style="font-size:0.73rem">
<span><span style="display:inline-block;width:12px;height:12px;background:#fee2e2;border-radius:2px" class="me-1"></span>&gt;20%</span>
<span><span style="display:inline-block;width:12px;height:12px;background:#fef9c3;border-radius:2px" class="me-1"></span>貴 10~20%</span>
<span><span style="display:inline-block;width:12px;height:12px;background:#dcfce7;border-radius:2px" class="me-1"></span>我便宜</span>
</div>
<div class="card-body p-0" style="overflow-x:auto; max-height:500px; overflow-y:auto;">
<table class="table table-sm table-hover mb-0 align-middle" id="competitorTable">
<thead class="table-light sticky-top" style="font-size:0.78rem;">
<tr>
<th class="ps-3" style="min-width:200px">商品</th>
<th class="text-end" style="min-width:75px">MOMO</th>
<th class="text-end" style="min-width:75px">PChome</th>
<th class="text-end" style="min-width:70px">價差</th>
<th style="min-width:90px">競品標籤</th>
<th class="text-center" style="min-width:55px">分數</th>
<th class="text-muted" style="min-width:80px">更新</th>
</tr>
</thead>
<tbody id="competitorTbody">
<tr><td colspan="7" class="text-center py-5 text-muted">
<div class="spinner-border spinner-border-sm me-2"></div>載入中...
</td></tr>
</tbody>
</table>
</div>
<div class="card-footer bg-white py-2 d-flex justify-content-between small text-muted">
<span id="compCount"></span>
<span>TTL: 6h | 比對門檻: 0.45</span>
</div>
</div>
</div>
<!-- ── 右AI 決策日誌 ── -->
<div class="col-xl-5">
<div class="card shadow-sm h-100">
<div class="card-header py-2 bg-white border-bottom">
<span class="fw-bold">
<i class="fas fa-robot text-danger me-2"></i>AI 決策日誌
<small class="text-muted fw-normal ms-2">Hermes × NemoTron</small>
</span>
</div>
<div class="card-body p-0" style="overflow-y:auto; max-height:548px;">
<div id="aiRecsList" class="p-2">
<div class="text-center py-5 text-muted">
<div class="spinner-border spinner-border-sm me-2"></div>載入中...
</div>
</div>
</div>
<div class="card-footer bg-white py-2 d-flex justify-content-between small text-muted">
<span id="aiRecsCount"></span>
<span>每 6h 自動更新</span>
</div>
</div>
</div>
</div><!-- /row -->
<!-- ── Trigger 進度 Toast ── -->
<div class="position-fixed bottom-0 end-0 p-3" style="z-index:9999">
<div id="triggerToast" class="toast align-items-center text-white border-0" role="alert">
<div class="d-flex">
<div class="toast-body" id="triggerToastMsg">
<i class="fas fa-bolt me-1"></i>分析已啟動...
</div>
<button type="button" class="btn-close btn-close-white me-2 m-auto" data-bs-dismiss="toast"></button>
</div>
</div>
</div>
</div><!-- /container -->
<script>
// ── 全域資料 ────────────────────────────────────────
let allCompetitors = [];
// ── 頁面載入 ────────────────────────────────────────
document.addEventListener('DOMContentLoaded', loadDashboard);
async function loadDashboard() {
try {
const res = await fetch('/api/ai/icaim/dashboard');
const data = await res.json();
if (!data.success) throw new Error(data.error || '載入失敗');
renderKPIs(data.stats);
allCompetitors = data.competitors;
renderCompetitorTable(allCompetitors);
renderAiRecs(data.ai_recs);
document.getElementById('lastUpdateBadge').innerHTML =
'<i class="fas fa-check-circle me-1"></i>上次更新 ' + new Date().toLocaleTimeString('zh-TW');
document.getElementById('lastUpdateBadge').className = 'badge bg-success';
} catch (e) {
document.getElementById('lastUpdateBadge').innerHTML =
'<i class="fas fa-exclamation-circle me-1"></i>載入失敗';
document.getElementById('lastUpdateBadge').className = 'badge bg-danger';
console.error(e);
}
}
// ── KPIhigh_risk_count 來自後端全量 CTE─────────
function renderKPIs(stats) {
document.getElementById('kpiSkus').textContent = (stats.total_skus || 0).toLocaleString();
document.getElementById('kpiCompetitors').textContent = (stats.valid_competitor_prices || 0).toLocaleString();
document.getElementById('kpiAiRecs').textContent = (stats.total_ai_recs || 0).toLocaleString();
const hr = stats.high_risk_count || 0;
document.getElementById('kpiHighRisk').textContent = hr;
// 高風險卡:數值 > 0 加紅底強調
document.getElementById('kpiHighRiskCard').className =
hr > 0
? 'card border-2 border-danger shadow-sm h-100'
: 'card border-0 shadow-sm h-100';
}
// ── 競品比價表格(熱力圖底色)──────────────────────
function renderCompetitorTable(rows) {
const tbody = document.getElementById('competitorTbody');
if (!rows.length) {
tbody.innerHTML = `<tr><td colspan="7" class="text-center py-5 text-muted">
<i class="fas fa-info-circle me-2"></i>暫無競品比價資料
</td></tr>`;
document.getElementById('compCount').textContent = '0 筆';
return;
}
tbody.innerHTML = rows.map(r => {
// 熱力圖底色(行層級)
let rowBg = '';
if (r.gap_pct > 20) rowBg = 'background:#fee2e2'; // 淺紅 — 嚴重貴
else if (r.gap_pct > 10) rowBg = 'background:#fef9c3'; // 淺黃 — 有風險
else if (r.gap_pct < 0) rowBg = 'background:#f0fdf4'; // 淺綠 — 我便宜
// 價差文字顏色
const gapClass = r.gap_pct > 15 ? 'text-danger fw-bold'
: r.gap_pct > 5 ? 'text-warning fw-bold'
: r.gap_pct < 0 ? 'text-success fw-bold'
: 'text-muted';
const gapSign = r.gap_pct > 0 ? '+' : '';
const tagHtml = (r.tags || []).map(t => {
const tagMap = {
'on_sale': ['bg-info text-dark', '促銷中'],
'discount_30pct': ['bg-danger text-white', '折30%+'],
'discount_20pct': ['bg-warning text-dark', '折20%+'],
'discount_10pct': ['bg-secondary text-white','折10%+'],
'low_stock': ['bg-dark text-white', '低庫存'],
'high_rating': ['bg-primary text-white', '高評分'],
};
const [cls, label] = tagMap[t] || ['bg-light text-dark', t];
return `<span class="badge ${cls} me-1" style="font-size:0.68rem">${label}</span>`;
}).join('');
const scoreColor = r.match_score >= 0.7 ? 'text-success'
: r.match_score >= 0.55 ? 'text-warning'
: 'text-danger';
return `<tr data-risk="${r.risk}" data-name="${r.name.toLowerCase()}" style="${rowBg}">
<td class="ps-3">
<div style="font-size:0.82rem;font-weight:500" title="${r.name}">${r.name}</div>
<small class="text-muted">${r.category} · ${r.sku}</small>
</td>
<td class="text-end text-dark fw-bold">$${r.momo_price.toLocaleString()}</td>
<td class="text-end text-secondary">$${r.pchome_price.toLocaleString()}</td>
<td class="text-end ${gapClass}">${gapSign}${r.gap_pct}%</td>
<td>${tagHtml || '<span class="text-muted small">—</span>'}</td>
<td class="text-center ${scoreColor}" style="font-size:0.8rem">${r.match_score.toFixed(2)}</td>
<td class="text-muted" style="font-size:0.75rem">${r.crawled_at}</td>
</tr>`;
}).join('');
document.getElementById('compCount').textContent = `${rows.length}`;
}
// ── 篩選 ─────────────────────────────────────────────
function filterTable() {
const risk = document.getElementById('riskFilter').value;
const search = document.getElementById('searchInput').value.toLowerCase().trim();
const filtered = allCompetitors.filter(r => {
const riskOk = risk === 'all' || r.risk === risk;
const searchOk = !search || r.name.toLowerCase().includes(search) || r.sku.includes(search);
return riskOk && searchOk;
});
renderCompetitorTable(filtered);
}
// ── AI 決策日誌(含推理足跡)───────────────────────
function renderAiRecs(recs) {
const container = document.getElementById('aiRecsList');
document.getElementById('aiRecsCount').textContent =
recs.length ? `${recs.length} 筆決策記錄` : '尚無決策記錄';
if (!recs.length) {
container.innerHTML = `
<div class="text-center py-5">
<i class="fas fa-brain fa-3x text-muted mb-3 d-block"></i>
<p class="text-muted mb-1">AI 決策日誌尚為空</p>
<p class="small text-muted mb-3">
排程每 6 小時執行一次 Hermes 分析 + NemoTron 派發<br>
或點擊「立即分析」手動觸發
</p>
<button class="btn btn-sm btn-outline-danger" onclick="triggerAnalysis()">
<i class="fas fa-bolt me-1"></i>立即觸發分析
</button>
</div>`;
return;
}
const strategyMap = {
'price_cut': ['bg-danger', '降價'],
'promote': ['bg-primary', '主推'],
'monitor': ['bg-secondary', '觀察'],
'flag': ['bg-warning text-dark', '覆核'],
};
container.innerHTML = recs.map(r => {
const [sBg, sLabel] = strategyMap[r.strategy] || ['bg-secondary', r.strategy];
const confPct = Math.round(r.confidence * 100);
const confColor = confPct >= 80 ? 'bg-success' : confPct >= 60 ? 'bg-warning' : 'bg-danger';
const gapSign = r.gap_pct > 0 ? '+' : '';
// 推理足跡
const hermesInfo = r.hermes_duration
? `${r.analyst} ${r.hermes_duration}s${r.hermes_tokens ? ' / ' + r.hermes_tokens + 'tok' : ''}`
: r.analyst;
const nimInfo = r.nim_tokens
? `${r.dispatcher} ${r.nim_tokens}tok`
: r.dispatcher;
return `<div class="border rounded mb-2 p-2" style="font-size:0.83rem">
<div class="d-flex justify-content-between align-items-start mb-1">
<span class="fw-bold text-truncate me-2" style="max-width:200px" title="${r.name}">${r.name}</span>
<span class="badge ${sBg} flex-shrink-0">${sLabel}</span>
</div>
<div class="d-flex gap-3 mb-1 text-muted small">
<span>MOMO <strong class="text-dark">$${r.momo_price.toLocaleString()}</strong></span>
<span>PChome <strong class="text-secondary">$${r.pchome_price.toLocaleString()}</strong></span>
<span class="${r.gap_pct > 10 ? 'text-danger fw-bold' : 'text-muted'}">${gapSign}${r.gap_pct}%</span>
</div>
<div class="mb-1 text-muted" style="font-size:0.78rem;line-height:1.4">
${r.reason ? r.reason.substring(0, 90) + (r.reason.length > 90 ? '…' : '') : ''}
</div>
<div class="d-flex justify-content-between align-items-center">
<div class="d-flex align-items-center gap-1">
<div class="progress" style="width:60px;height:6px">
<div class="progress-bar ${confColor}" style="width:${confPct}%"></div>
</div>
<small class="text-muted">${confPct}%</small>
</div>
<small class="text-muted" title="推理足跡">
<i class="fas fa-microchip me-1" style="font-size:0.68rem"></i>${hermesInfo}${nimInfo}
· ${r.created_at}
</small>
</div>
</div>`;
}).join('');
}
// ── 手動觸發分析 ────────────────────────────────────
async function triggerAnalysis() {
const btn = document.getElementById('btnTrigger');
const toast = document.getElementById('triggerToast');
const msg = document.getElementById('triggerToastMsg');
btn.disabled = true;
btn.innerHTML = '<span class="spinner-border spinner-border-sm me-1"></span>啟動中...';
try {
const res = await fetch('/api/ai/icaim/trigger', { method: 'POST' });
const data = await res.json();
msg.innerHTML = data.success
? `<i class="fas fa-check-circle me-1"></i>${data.message}`
: `<i class="fas fa-times-circle me-1"></i>${data.error}`;
toast.className = 'toast align-items-center text-white border-0 ' +
(data.success ? 'bg-success' : 'bg-danger');
new bootstrap.Toast(toast, { delay: 6000 }).show();
if (data.success) {
// 60 秒後自動重新整理儀表板
setTimeout(loadDashboard, 60000);
}
} catch (e) {
msg.innerHTML = '<i class="fas fa-times-circle me-1"></i>觸發失敗:' + e.message;
toast.className = 'toast align-items-center text-white border-0 bg-danger';
new bootstrap.Toast(toast, { delay: 4000 }).show();
} finally {
btn.disabled = false;
btn.innerHTML = '<i class="fas fa-bolt me-1"></i>立即分析';
}
}
</script>
{% endblock %}