V10.612 讓價格參考表優先讀外部報價層
All checks were successful
CD Pipeline / deploy (push) Successful in 1m4s

This commit is contained in:
OoO
2026-06-16 09:43:49 +08:00
parent cac9752aac
commit 56ebba045b
7 changed files with 168 additions and 27 deletions

View File

@@ -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 # 用於模板顯示

View File

@@ -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

View File

@@ -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`,讓告警與頁面使用同一份資料來源。

View File

@@ -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_countMOMO 售價比 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,

View File

@@ -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>&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>
<span><span style="display:inline-block;width:12px;height:12px;background:#fee2e2;border-radius:2px" class="me-1"></span>PChome &gt;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>

View File

@@ -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 貴 &gt;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

View File

@@ -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