This commit is contained in:
@@ -402,7 +402,7 @@ YOUTUBE_API_KEY = os.getenv('YOUTUBE_API_KEY', '')
|
||||
# ==========================================
|
||||
# 系統版本與路徑
|
||||
# ==========================================
|
||||
SYSTEM_VERSION = "V10.611"
|
||||
SYSTEM_VERSION = "V10.612"
|
||||
LOG_FILE_PATH = os.path.join(BASE_DIR, 'logs/system.log')
|
||||
public_url = PUBLIC_URL # 用於模板顯示
|
||||
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
# PChome 業績成長自動化作戰系統 — AI 競價情報模組 Single Source of Truth
|
||||
|
||||
> **最後更新**: 2026-06-16 (台北時間)
|
||||
> **狀態**: 🟢 四 AI Agent 自動化閉環已落地;LLM 路由紅線升級為 Ollama-first 三主機級聯;PChome 後台業績匯入韌性已補強;產品定位正名為「PChome 業績成長自動化作戰系統」;外部市場來源正規化層、自動同步、作戰清單優先讀取、CSV 備援預檢與前台操作入口已建立
|
||||
> **適用版本**: V10.611
|
||||
> **狀態**: 🟢 四 AI Agent 自動化閉環已落地;LLM 路由紅線升級為 Ollama-first 三主機級聯;PChome 後台業績匯入韌性已補強;產品定位正名為「PChome 業績成長自動化作戰系統」;外部市場來源正規化層、自動同步、作戰清單與價格參考表優先讀取、CSV 備援預檢與前台操作入口已建立
|
||||
> **適用版本**: V10.612
|
||||
|
||||
---
|
||||
|
||||
@@ -58,6 +58,7 @@
|
||||
- V10.609 明確把外部報價主路徑改為自動化:`run_external_offer_sync_task` 每 4 小時將已確認同款的既有比價快取同步進 `external_offers`。CSV 只保留為 API / crawler / provider 失敗時的備援預檢入口,不是日常營運主流程。
|
||||
- V10.610 起 `/api/ai/pchome-growth/opportunities` 優先讀取 `external_offers` 的自動同步資料;只有新資料層缺資料時才 fallback 舊 `competitor_prices`。API stats 會回傳資料來源計數,方便確認作戰清單是否已走新資料層。
|
||||
- V10.611 起 `/ai_intelligence` 是營運使用者主入口:頁首提供「今日作戰入口」,依序連到 PChome 成長作戰、補商品對應、MOMO 外部價格參考與外部報價預檢;作戰清單左側會直接顯示今日優先動作與資料來源摘要。
|
||||
- V10.612 起 `/api/ai/icaim/dashboard` 的「MOMO 外部價格參考」表格也優先讀 `external_offers`,缺資料才 fallback `competitor_prices`;價差與風險改採 PChome 視角,正數代表 PChome 比 MOMO 外部參考價高。
|
||||
|
||||
## 零之一、12 Agent 決策信封(2026-05-24)
|
||||
|
||||
|
||||
@@ -219,3 +219,10 @@
|
||||
- PChome 成長作戰區塊會依 API stats 顯示今日優先動作,例如先補商品對應、先處理可直接比價商品,避免使用者只看到數字不知道下一步。
|
||||
- 同區塊新增資料來源摘要,直接顯示「自動同步資料層」或「舊比價快取」各有多少筆,方便確認 V10.610 新資料層是否真的被作戰清單採用。
|
||||
- CSV 預檢在 UI 文案上維持備援定位;日常主流程仍是自動同步外部報價。
|
||||
|
||||
## 15. 2026-06-16 V10.612 MOMO 外部價格參考優先讀新資料層
|
||||
|
||||
- `/api/ai/icaim/dashboard` 的「MOMO 外部價格參考」已改為優先使用 `external_offers`,同一 MOMO SKU 若有自動同步資料就不再先讀舊 `competitor_prices`。
|
||||
- 表格 stats 新增 `competitor_data_source_counts`,前端 footer 顯示「自動同步資料層 / 舊比價快取」各幾筆;每列狀態會顯示「自動同步」或「舊資料」。
|
||||
- 價差與高風險統計改採 PChome 視角:正數代表 PChome 比 MOMO 外部參考價高,才列入需檢查價格。
|
||||
- 下一步:把 Hermes / ElephantAlpha / AI product pick 等後端價格分析逐步改讀 `external_offers`,讓告警與頁面使用同一份資料來源。
|
||||
|
||||
@@ -1728,7 +1728,7 @@ def api_icaim_dashboard():
|
||||
"""
|
||||
try:
|
||||
from config import DATABASE_PATH
|
||||
from sqlalchemy import text as sa_text
|
||||
from sqlalchemy import inspect, text as sa_text
|
||||
|
||||
force_refresh = str(request.args.get('refresh') or '').strip().lower() in {'1', 'true', 'yes'}
|
||||
if not force_refresh:
|
||||
@@ -1738,9 +1738,74 @@ def api_icaim_dashboard():
|
||||
|
||||
engine = _create_icaim_dashboard_engine(DATABASE_PATH)
|
||||
|
||||
# ── 統計摘要 ────────────────────────────────────────────
|
||||
# high_risk_count:MOMO 售價比 PChome 貴 > 15%
|
||||
compared_cte = f"""
|
||||
with engine.connect() as conn:
|
||||
inspector = inspect(conn)
|
||||
has_external_offers = inspector.has_table("external_offers")
|
||||
normalized_cte = """
|
||||
valid_normalized AS (
|
||||
SELECT NULL::text AS sku,
|
||||
NULL::numeric AS pchome_price,
|
||||
NULL::numeric AS momo_price,
|
||||
NULL::numeric AS match_score,
|
||||
'[]'::jsonb AS tags,
|
||||
NULL::timestamp AS crawled_at,
|
||||
'external_offers'::text AS data_source,
|
||||
'自動同步資料層'::text AS data_source_label,
|
||||
1 AS source_priority
|
||||
WHERE FALSE
|
||||
),
|
||||
"""
|
||||
if has_external_offers:
|
||||
normalized_cte = """
|
||||
normalized_raw AS (
|
||||
SELECT DISTINCT ON (COALESCE(NULLIF(eo.momo_sku, ''), NULLIF(eo.source_product_id, '')))
|
||||
COALESCE(NULLIF(eo.momo_sku, ''), NULLIF(eo.source_product_id, '')) AS sku,
|
||||
NULLIF(
|
||||
regexp_replace(
|
||||
COALESCE(eo.raw_payload_json::jsonb ->> 'pchome_public_price', ''),
|
||||
'[^0-9.-]',
|
||||
'',
|
||||
'g'
|
||||
),
|
||||
''
|
||||
)::numeric AS pchome_price,
|
||||
eo.price AS momo_price,
|
||||
CASE
|
||||
WHEN COALESCE(eo.quality_score, 0) > 1 THEN eo.quality_score / 100.0
|
||||
ELSE COALESCE(eo.quality_score, 0)
|
||||
END AS match_score,
|
||||
to_jsonb(ARRAY['identity_v2', 'external_offers']) AS tags,
|
||||
eo.observed_at AS crawled_at,
|
||||
'external_offers'::text AS data_source,
|
||||
'自動同步資料層'::text AS data_source_label,
|
||||
1 AS source_priority
|
||||
FROM external_offers eo
|
||||
WHERE eo.source_code = 'momo_reference'
|
||||
AND COALESCE(NULLIF(eo.momo_sku, ''), NULLIF(eo.source_product_id, '')) IS NOT NULL
|
||||
AND eo.price IS NOT NULL
|
||||
AND eo.price > 0
|
||||
AND COALESCE(eo.quality_score, 0) >= 76
|
||||
AND eo.match_status IN ('verified', 'usable', 'reviewed', 'exact', 'confirmed')
|
||||
AND eo.data_quality_status IN ('verified', 'usable', 'reviewed')
|
||||
AND (eo.expires_at IS NULL OR eo.expires_at > CURRENT_TIMESTAMP)
|
||||
ORDER BY
|
||||
COALESCE(NULLIF(eo.momo_sku, ''), NULLIF(eo.source_product_id, '')),
|
||||
eo.observed_at DESC NULLS LAST,
|
||||
eo.id DESC
|
||||
),
|
||||
valid_normalized AS (
|
||||
SELECT *
|
||||
FROM normalized_raw
|
||||
WHERE pchome_price IS NOT NULL
|
||||
AND pchome_price > 0
|
||||
AND momo_price IS NOT NULL
|
||||
AND momo_price > 0
|
||||
),
|
||||
"""
|
||||
|
||||
# ── 統計摘要 ────────────────────────────────────────────
|
||||
# high_risk_count:以 PChome 視角計算,PChome 比 MOMO 外部參考價高 > 15%
|
||||
compared_cte = f"""
|
||||
WITH latest_momo AS (
|
||||
SELECT
|
||||
p.i_code AS sku,
|
||||
@@ -1757,13 +1822,18 @@ def api_icaim_dashboard():
|
||||
) latest_price ON TRUE
|
||||
WHERE p.status = 'ACTIVE'
|
||||
),
|
||||
valid_competitor AS (
|
||||
{normalized_cte}
|
||||
valid_legacy AS (
|
||||
SELECT DISTINCT ON (cp.sku)
|
||||
cp.sku,
|
||||
cp.price AS pchome_price,
|
||||
NULL::numeric AS momo_price,
|
||||
cp.match_score,
|
||||
cp.tags,
|
||||
cp.crawled_at
|
||||
COALESCE(cp.tags, '[]'::jsonb) || '["legacy_competitor_cache"]'::jsonb AS tags,
|
||||
cp.crawled_at,
|
||||
'competitor_prices'::text AS data_source,
|
||||
'舊比價快取'::text AS data_source_label,
|
||||
2 AS source_priority
|
||||
FROM competitor_prices cp
|
||||
WHERE cp.source = 'pchome'
|
||||
AND cp.expires_at > CURRENT_TIMESTAMP
|
||||
@@ -1773,24 +1843,47 @@ def api_icaim_dashboard():
|
||||
AND COALESCE(cp.tags, '[]'::jsonb) ? 'identity_v2'
|
||||
ORDER BY cp.sku, cp.crawled_at DESC NULLS LAST
|
||||
),
|
||||
valid_competitor AS (
|
||||
SELECT DISTINCT ON (sku)
|
||||
sku,
|
||||
pchome_price,
|
||||
momo_price,
|
||||
match_score,
|
||||
tags,
|
||||
crawled_at,
|
||||
data_source,
|
||||
data_source_label
|
||||
FROM (
|
||||
SELECT * FROM valid_normalized
|
||||
UNION ALL
|
||||
SELECT * FROM valid_legacy
|
||||
) src
|
||||
WHERE sku IS NOT NULL
|
||||
AND pchome_price IS NOT NULL
|
||||
AND pchome_price > 0
|
||||
ORDER BY sku, source_priority ASC, crawled_at DESC NULLS LAST
|
||||
),
|
||||
compared AS (
|
||||
SELECT
|
||||
lm.sku,
|
||||
lm.name,
|
||||
lm.category,
|
||||
lm.momo_price,
|
||||
COALESCE(vc.momo_price, lm.momo_price) AS momo_price,
|
||||
vc.pchome_price,
|
||||
ROUND(((lm.momo_price - vc.pchome_price) / vc.pchome_price * 100)::numeric, 1) AS gap_pct,
|
||||
ROUND(((vc.pchome_price - COALESCE(vc.momo_price, lm.momo_price)) / COALESCE(vc.momo_price, lm.momo_price) * 100)::numeric, 1) AS gap_pct,
|
||||
vc.match_score,
|
||||
vc.tags,
|
||||
vc.crawled_at
|
||||
vc.crawled_at,
|
||||
vc.data_source,
|
||||
vc.data_source_label
|
||||
FROM latest_momo lm
|
||||
JOIN valid_competitor vc ON vc.sku = lm.sku
|
||||
WHERE lm.momo_price IS NOT NULL
|
||||
WHERE COALESCE(vc.momo_price, lm.momo_price) IS NOT NULL
|
||||
AND COALESCE(vc.momo_price, lm.momo_price) > 0
|
||||
)
|
||||
"""
|
||||
|
||||
stats_sql = sa_text(compared_cte + """
|
||||
stats_sql = sa_text(compared_cte + """
|
||||
SELECT
|
||||
(SELECT COUNT(*) FROM products WHERE status = 'ACTIVE') AS total_skus,
|
||||
(SELECT COUNT(*) FROM compared) AS valid_competitor_prices,
|
||||
@@ -1798,19 +1891,25 @@ def api_icaim_dashboard():
|
||||
(SELECT COUNT(*) FROM ai_price_recommendations) AS total_ai_recs,
|
||||
(SELECT COUNT(*) FROM ai_price_recommendations
|
||||
WHERE strategy = 'product_pick' AND status = 'pending') AS product_pick_count,
|
||||
(SELECT MAX(crawled_at) FROM competitor_prices WHERE source='pchome') AS last_feeder_run
|
||||
(SELECT MAX(crawled_at) FROM valid_competitor) AS last_feeder_run,
|
||||
(SELECT COALESCE(jsonb_object_agg(data_source_label, source_count), '{}'::jsonb)
|
||||
FROM (
|
||||
SELECT data_source_label, COUNT(*) AS source_count
|
||||
FROM compared
|
||||
GROUP BY data_source_label
|
||||
) data_sources) AS competitor_data_source_counts
|
||||
""")
|
||||
|
||||
# ── 競品比價(每個 SKU 只取最新且通過身份比對的一筆)──
|
||||
competitor_sql = sa_text(compared_cte + """
|
||||
# ── 競品比價(每個 SKU 只取最新且通過身份比對的一筆)──
|
||||
competitor_sql = sa_text(compared_cte + """
|
||||
SELECT *
|
||||
FROM compared
|
||||
ORDER BY gap_pct DESC NULLS LAST, crawled_at DESC NULLS LAST
|
||||
LIMIT 200
|
||||
""")
|
||||
|
||||
# ── AI 決策日誌 ─────────────────────────────────────────
|
||||
ai_sql = sa_text("""
|
||||
# ── AI 決策日誌 ─────────────────────────────────────────
|
||||
ai_sql = sa_text("""
|
||||
SELECT id, sku, name, strategy, confidence,
|
||||
momo_price, pchome_price, gap_pct, sales_7d_delta,
|
||||
reason, status, model_footprint, created_at
|
||||
@@ -1819,7 +1918,6 @@ def api_icaim_dashboard():
|
||||
LIMIT 50
|
||||
""")
|
||||
|
||||
with engine.connect() as conn:
|
||||
stats_row = conn.execute(stats_sql).fetchone()
|
||||
comp_rows = conn.execute(competitor_sql).fetchall()
|
||||
ai_rows = conn.execute(ai_sql).fetchall()
|
||||
@@ -1845,6 +1943,8 @@ def api_icaim_dashboard():
|
||||
'tags': tags,
|
||||
'crawled_at': r.crawled_at.strftime('%m/%d %H:%M') if r.crawled_at else '',
|
||||
'risk': 'HIGH' if gap > 15 else ('MED' if gap > 5 else 'LOW'),
|
||||
'data_source': r.data_source or 'competitor_prices',
|
||||
'data_source_label': r.data_source_label or '舊比價快取',
|
||||
})
|
||||
|
||||
# 格式化 AI 決策記錄
|
||||
@@ -1875,6 +1975,12 @@ def api_icaim_dashboard():
|
||||
})
|
||||
|
||||
stats = dict(stats_row._mapping) if stats_row else {}
|
||||
source_counts = stats.get('competitor_data_source_counts') or {}
|
||||
if isinstance(source_counts, str):
|
||||
try:
|
||||
source_counts = json.loads(source_counts)
|
||||
except Exception:
|
||||
source_counts = {}
|
||||
last_feeder_run = stats.get('last_feeder_run')
|
||||
last_feeder = (
|
||||
last_feeder_run.strftime('%Y-%m-%d %H:%M')
|
||||
@@ -1892,6 +1998,7 @@ def api_icaim_dashboard():
|
||||
'product_pick_count': int(stats.get('product_pick_count') or 0),
|
||||
'match_rate': round(valid_competitor_prices / max(total_skus, 1) * 100, 1),
|
||||
'last_feeder_run': last_feeder,
|
||||
'competitor_data_source_counts': source_counts,
|
||||
},
|
||||
'competitors': competitors,
|
||||
'ai_recs': ai_recs,
|
||||
|
||||
@@ -938,9 +938,9 @@
|
||||
</div>
|
||||
<!-- 熱力圖圖例 -->
|
||||
<div class="px-3 pt-2 pb-1 d-flex gap-3 small text-muted ai-legend" style="font-size:0.73rem">
|
||||
<span><span style="display:inline-block;width:12px;height:12px;background:#fee2e2;border-radius:2px" class="me-1"></span>貴 >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>
|
||||
<span><span style="display:inline-block;width:12px;height:12px;background:#fee2e2;border-radius:2px" class="me-1"></span>PChome 貴 >20%</span>
|
||||
<span><span style="display:inline-block;width:12px;height:12px;background:#fef9c3;border-radius:2px" class="me-1"></span>PChome 貴 10~20%</span>
|
||||
<span><span style="display:inline-block;width:12px;height:12px;background:#dcfce7;border-radius:2px" class="me-1"></span>PChome 便宜</span>
|
||||
</div>
|
||||
<div class="card-body p-0 ai-table-scroll">
|
||||
<table class="table table-sm table-hover mb-0 align-middle" id="competitorTable">
|
||||
@@ -964,7 +964,7 @@
|
||||
</div>
|
||||
<div class="card-footer bg-white py-2 d-flex justify-content-between small text-muted">
|
||||
<span id="compCount">—</span>
|
||||
<span>僅顯示已確認同款的商品</span>
|
||||
<span id="compSourceSummary">僅顯示已確認同款的商品</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1046,6 +1046,7 @@ function renderKPIs(stats) {
|
||||
document.getElementById('kpiCompetitors').textContent = (stats.valid_competitor_prices || 0).toLocaleString();
|
||||
document.getElementById('kpiAiRecs').textContent = (stats.total_ai_recs || 0).toLocaleString();
|
||||
document.getElementById('kpiMatchRate').textContent = stats.match_rate ? `(${stats.match_rate}%)` : '';
|
||||
renderCompetitorSourceSummary(stats);
|
||||
|
||||
const hr = stats.high_risk_count || 0;
|
||||
document.getElementById('kpiHighRisk').textContent = hr;
|
||||
@@ -1117,6 +1118,20 @@ async function loadGrowthOps(forceRefresh = false) {
|
||||
}
|
||||
}
|
||||
|
||||
function renderCompetitorSourceSummary(stats) {
|
||||
const target = document.getElementById('compSourceSummary');
|
||||
if (!target) return;
|
||||
|
||||
const counts = stats.competitor_data_source_counts || {};
|
||||
const entries = Object.entries(counts)
|
||||
.filter(([, count]) => Number(count || 0) > 0)
|
||||
.map(([label, count]) => `${label} ${Number(count).toLocaleString()} 筆`);
|
||||
|
||||
target.textContent = entries.length
|
||||
? `資料來源:${entries.join('、')}`
|
||||
: '僅顯示已確認同款的商品';
|
||||
}
|
||||
|
||||
function renderGrowthActionHint(stats) {
|
||||
const hint = document.getElementById('growthActionHint');
|
||||
if (!hint) return;
|
||||
@@ -1358,6 +1373,8 @@ function renderCompetitorTable(rows) {
|
||||
'identity_v2': ['bg-success text-white', '同款確認'],
|
||||
'match_type_exact':['bg-success text-white', '同款確認'],
|
||||
'price_alert_exact':['bg-danger text-white', '價差告警'],
|
||||
'external_offers': ['bg-success text-white', '自動同步'],
|
||||
'legacy_competitor_cache':['bg-light text-dark','舊資料'],
|
||||
'evidence_brand': ['bg-light text-dark', '品牌一致'],
|
||||
'evidence_identity':['bg-light text-dark', '同款證據'],
|
||||
'match_shared_model_token':['bg-light text-dark','型號一致'],
|
||||
@@ -1374,7 +1391,7 @@ function renderCompetitorTable(rows) {
|
||||
: r.match_score >= 0.55 ? 'text-warning'
|
||||
: 'text-danger';
|
||||
|
||||
return `<tr data-risk="${r.risk}" data-name="${r.name.toLowerCase()}" style="${rowBg}">
|
||||
return `<tr data-risk="${r.risk}" data-source="${escapeHtml(r.data_source || '')}" 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>
|
||||
@@ -1444,7 +1461,7 @@ function renderAiRecs(recs) {
|
||||
<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">
|
||||
<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>
|
||||
|
||||
@@ -381,6 +381,11 @@ def test_ai_intelligence_uses_v2_shell_and_real_runtime_apis():
|
||||
assert "ai-status-badge" in template
|
||||
assert "PChome 業績成長自動化作戰系統" in template
|
||||
assert "MOMO 外部價格參考" in template
|
||||
assert "PChome 貴 >20%" in template
|
||||
assert "PChome 便宜" in template
|
||||
assert "compSourceSummary" in template
|
||||
assert "'external_offers':" in template
|
||||
assert "'自動同步'" in template
|
||||
assert "/api/ai/pchome-growth/opportunities" in template
|
||||
assert "作戰建議紀錄" in template
|
||||
assert "fetch('/api/ai/icaim/dashboard')" in template
|
||||
@@ -398,6 +403,9 @@ def test_ai_intelligence_uses_v2_shell_and_real_runtime_apis():
|
||||
assert "@ai_bp.route('/ai_intelligence')" in route_source
|
||||
assert "render_template('ai_intelligence.html', active_page='ai_intelligence')" in route_source
|
||||
assert "@ai_bp.route('/api/ai/icaim/dashboard')" in route_source
|
||||
assert "FROM external_offers eo" in route_source
|
||||
assert "自動同步資料層" in route_source
|
||||
assert "competitor_data_source_counts" in route_source
|
||||
assert "competitor_prices" in route_source
|
||||
assert "ai_price_recommendations" in route_source
|
||||
assert "_ICAIM_DASHBOARD_TTL_SECONDS" in route_source
|
||||
|
||||
@@ -159,6 +159,7 @@ def test_ai_intelligence_template_uses_pchome_growth_name_and_endpoint():
|
||||
assert "growthActionHint" in template
|
||||
assert "growthDataSourceSummary" in template
|
||||
assert "external_data_source_counts" in template
|
||||
assert "compSourceSummary" in template
|
||||
assert "scrollToPanel('externalPricePanel')" in template
|
||||
assert "外部報價預檢" in template
|
||||
assert "待補對應" in template
|
||||
|
||||
Reference in New Issue
Block a user