From 56ebba045b5fb06953195e682abfe7ca41df5b46 Mon Sep 17 00:00:00 2001 From: OoO Date: Tue, 16 Jun 2026 09:43:49 +0800 Subject: [PATCH] =?UTF-8?q?V10.612=20=E8=AE=93=E5=83=B9=E6=A0=BC=E5=8F=83?= =?UTF-8?q?=E8=80=83=E8=A1=A8=E5=84=AA=E5=85=88=E8=AE=80=E5=A4=96=E9=83=A8?= =?UTF-8?q?=E5=A0=B1=E5=83=B9=E5=B1=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- config.py | 2 +- docs/AI_INTELLIGENCE_MODULE_SOT.md | 5 +- .../current_execution_queue_20260524.md | 7 + routes/ai_routes.py | 143 +++++++++++++++--- templates/ai_intelligence.html | 29 +++- tests/test_frontend_v2_assets.py | 8 + tests/test_pchome_revenue_growth_service.py | 1 + 7 files changed, 168 insertions(+), 27 deletions(-) diff --git a/config.py b/config.py index 9ed6302..d296ea5 100644 --- a/config.py +++ b/config.py @@ -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 # 用於模板顯示 diff --git a/docs/AI_INTELLIGENCE_MODULE_SOT.md b/docs/AI_INTELLIGENCE_MODULE_SOT.md index cd1b11b..d37c99d 100644 --- a/docs/AI_INTELLIGENCE_MODULE_SOT.md +++ b/docs/AI_INTELLIGENCE_MODULE_SOT.md @@ -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) diff --git a/docs/memory/current_execution_queue_20260524.md b/docs/memory/current_execution_queue_20260524.md index 88702d6..27c097c 100644 --- a/docs/memory/current_execution_queue_20260524.md +++ b/docs/memory/current_execution_queue_20260524.md @@ -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`,讓告警與頁面使用同一份資料來源。 diff --git a/routes/ai_routes.py b/routes/ai_routes.py index b518bf3..804b13a 100644 --- a/routes/ai_routes.py +++ b/routes/ai_routes.py @@ -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, diff --git a/templates/ai_intelligence.html b/templates/ai_intelligence.html index fd97e08..6d8d942 100644 --- a/templates/ai_intelligence.html +++ b/templates/ai_intelligence.html @@ -938,9 +938,9 @@
- 貴 >20% - 貴 10~20% - 我便宜 + PChome 貴 >20% + PChome 貴 10~20% + PChome 便宜
@@ -964,7 +964,7 @@ @@ -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 ` + return `
${r.name}
${r.category} · ${r.sku} @@ -1444,7 +1461,7 @@ function renderAiRecs(recs) { ${r.name} ${sLabel} -
+
MOMO $${r.momo_price.toLocaleString()} PChome $${r.pchome_price.toLocaleString()} ${gapSign}${r.gap_pct}% diff --git a/tests/test_frontend_v2_assets.py b/tests/test_frontend_v2_assets.py index 6ab1824..dc0ecc4 100644 --- a/tests/test_frontend_v2_assets.py +++ b/tests/test_frontend_v2_assets.py @@ -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 diff --git a/tests/test_pchome_revenue_growth_service.py b/tests/test_pchome_revenue_growth_service.py index e3ddd95..cb63924 100644 --- a/tests/test_pchome_revenue_growth_service.py +++ b/tests/test_pchome_revenue_growth_service.py @@ -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