From 043a7dc915d666dbd247d0a1e2470ae95aaab187 Mon Sep 17 00:00:00 2001 From: OoO Date: Fri, 1 May 2026 13:40:37 +0800 Subject: [PATCH] =?UTF-8?q?feat(ai):=20=E8=A3=9C=E6=8A=93=20PChome=20?= =?UTF-8?q?=E5=BE=85=E6=AF=94=E5=B0=8D=E5=95=86=E5=93=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CONSTITUTION.md | 2 +- app.py | 4 +- docs/AI_INTELLIGENCE_MODULE_SOT.md | 1 + routes/ai_routes.py | 40 +++++++++++++ services/competitor_price_feeder.py | 89 +++++++++++++++++++++++------ templates/ai_intelligence.html | 38 ++++++++++++ tests/test_frontend_v2_assets.py | 7 +++ 7 files changed, 160 insertions(+), 21 deletions(-) diff --git a/CONSTITUTION.md b/CONSTITUTION.md index d229519..491adfd 100644 --- a/CONSTITUTION.md +++ b/CONSTITUTION.md @@ -2,7 +2,7 @@ > 本文件定義專案開發的核心準則與不可違反的規範 > **建立日期**: 2026-01-12 -> **當前版本**: V10.47 (Auto-detect sales columns for product picks) +> **當前版本**: V10.48 (Priority backfill for unmatched PChome products) > **最後更新**: 2026-05-01 --- diff --git a/app.py b/app.py index 65cfa50..3c15d0d 100644 --- a/app.py +++ b/app.py @@ -95,8 +95,8 @@ except Exception as e: sys_log.error(f"無法檢測磁碟空間: {e}") # 🚩 系統版本定義 (備份與顯示用) -# 🚩 2026-05-01 V10.47: Auto-detect sales columns for product picks -SYSTEM_VERSION = "V10.47" +# 🚩 2026-05-01 V10.48: Priority backfill for unmatched PChome products +SYSTEM_VERSION = "V10.48" # ========================================== # 🔒 SQL Injection 防護函數 diff --git a/docs/AI_INTELLIGENCE_MODULE_SOT.md b/docs/AI_INTELLIGENCE_MODULE_SOT.md index a504e6a..0b7aad4 100644 --- a/docs/AI_INTELLIGENCE_MODULE_SOT.md +++ b/docs/AI_INTELLIGENCE_MODULE_SOT.md @@ -35,6 +35,7 @@ SQL漏斗(~300筆) - 寫入策略使用 `strategy='product_pick'`,保留在既有 AI 決策表,不新增假頁面或暫存 JSON。 - 後台入口:`POST /api/ai/product-picks/generate`,`/ai_intelligence` 可手動產生清單。 - 配對來源仍以 PChome crawler 真實搜尋結果為準;無競品資料時不生成挑品。 +- 比對覆蓋率補強入口:`POST /api/ai/pchome-match/backfill`,優先補抓仍無有效 PChome 配對的高價 ACTIVE 商品。 | 角色 | 模型 | 主機 | 成本 | 每日限額 | |------|------|------|------|---------| diff --git a/routes/ai_routes.py b/routes/ai_routes.py index 4345801..2cb9709 100644 --- a/routes/ai_routes.py +++ b/routes/ai_routes.py @@ -1647,6 +1647,46 @@ def api_generate_product_picks(): return jsonify({'success': False, 'error': str(e)}), 500 +@ai_bp.route('/api/ai/pchome-match/backfill', methods=['POST']) +@login_required +def api_pchome_match_backfill(): + """背景補抓尚未有有效 PChome 配對的高價商品,提高比價覆蓋率。""" + import threading + + payload = request.get_json(silent=True) or {} + limit = max(5, min(int(payload.get('limit', 60)), 160)) + + def _run_backfill(): + try: + from config import DATABASE_PATH + from sqlalchemy import create_engine + from services.competitor_price_feeder import CompetitorPriceFeeder + + engine = create_engine(DATABASE_PATH) + result = CompetitorPriceFeeder(engine=engine).run_unmatched_priority(limit=limit) + logger.info( + "[PChomeBackfill] done total=%s matched=%s no=%s low=%s errors=%s history=%s duration=%ss", + result.total_skus, + result.matched, + result.skipped_no_result, + result.skipped_low_score, + result.errors, + result.history_written, + result.duration_sec, + ) + except Exception as exc: + logger.error(f"[PChomeBackfill] 背景補抓失敗: {exc}") + + thread = threading.Thread(target=_run_backfill, daemon=True) + thread.start() + + return jsonify({ + 'success': True, + 'message': f'已啟動 PChome 待比對補抓,優先處理 {limit} 筆高價未配對商品', + 'limit': limit, + }), 202 + + @ai_bp.route('/api/ai/icaim/trigger', methods=['POST']) @login_required def api_icaim_trigger(): diff --git a/services/competitor_price_feeder.py b/services/competitor_price_feeder.py index f106b4b..79b17bb 100644 --- a/services/competitor_price_feeder.py +++ b/services/competitor_price_feeder.py @@ -358,6 +358,47 @@ class CompetitorPriceFeeder: rows = conn.execute(sql).fetchall() return [dict(r._mapping) for r in rows] + def _fetch_unmatched_priority_skus(self, limit: int = 80) -> list: + """ + 取得目前沒有有效 PChome 配對的高價 ACTIVE 商品,供補強流程優先處理。 + """ + if self.engine is None: + raise RuntimeError("需要注入 SQLAlchemy engine") + + from sqlalchemy import text + sql = text(""" + WITH latest_momo AS ( + SELECT + p.id AS product_id, + p.i_code AS sku, + p.name, + p.category, + pr.price AS momo_price, + ROW_NUMBER() OVER (PARTITION BY p.id ORDER BY pr.timestamp DESC) AS rn + FROM products p + JOIN price_records pr ON pr.product_id = p.id + WHERE p.status = 'ACTIVE' + ) + SELECT + lm.product_id, + lm.sku, + lm.name, + lm.category, + lm.momo_price + FROM latest_momo lm + LEFT JOIN competitor_prices cp + ON cp.sku = lm.sku + AND cp.source = 'pchome' + AND (cp.expires_at IS NULL OR cp.expires_at > CURRENT_TIMESTAMP) + WHERE lm.rn = 1 + AND cp.sku IS NULL + ORDER BY lm.momo_price DESC NULLS LAST, lm.sku + LIMIT :limit + """) + with self.engine.connect() as conn: + rows = conn.execute(sql, {"limit": max(1, min(int(limit), 300))}).fetchall() + return [dict(r._mapping) for r in rows] + def _upsert_competitor_price( self, sku: str, @@ -431,16 +472,7 @@ class CompetitorPriceFeeder: "tags": tags_json, }) - def run(self, source: str = "pchome") -> FeederResult: - """ - 執行一輪競品價格抓取與寫入 - - Args: - source: 競品來源代碼(目前支援 'pchome') - - Returns: - FeederResult - """ + def _run_sku_items(self, skus: list, source: str = "pchome", label: str = "PChome 競品價格") -> FeederResult: start = time.time() if source != "pchome": @@ -450,14 +482,7 @@ class CompetitorPriceFeeder: from services.pchome_crawler import PChomeCrawler crawler = PChomeCrawler(timeout=30, delay=RATE_DELAY) - # Step 1: 取得監控清單 - try: - skus = self._fetch_active_skus() - except Exception as e: - logger.error(f"[Feeder] 讀取商品清單失敗: {e}") - return FeederResult(0, 0, 0, 0, 1, time.time() - start) - - logger.info(f"[Feeder] 開始抓取 {len(skus)} 支商品的 PChome 競品價格") + logger.info(f"[Feeder] 開始抓取 {len(skus)} 支商品的 {label}") matched = 0 skipped_no = 0 @@ -529,6 +554,34 @@ class CompetitorPriceFeeder: history_written=history_written, ) + def run(self, source: str = "pchome") -> FeederResult: + """ + 執行一輪競品價格抓取與寫入 + + Args: + source: 競品來源代碼(目前支援 'pchome') + + Returns: + FeederResult + """ + try: + skus = self._fetch_active_skus() + except Exception as e: + logger.error(f"[Feeder] 讀取商品清單失敗: {e}") + return FeederResult(0, 0, 0, 0, 1, 0.0) + + return self._run_sku_items(skus, source=source, label="PChome 競品價格") + + def run_unmatched_priority(self, limit: int = 80, source: str = "pchome") -> FeederResult: + """優先補抓尚未有有效 PChome 配對的高價商品。""" + try: + skus = self._fetch_unmatched_priority_skus(limit=limit) + except Exception as e: + logger.error(f"[Feeder] 讀取待比對優先商品失敗: {e}") + return FeederResult(0, 0, 0, 0, 1, 0.0) + + return self._run_sku_items(skus, source=source, label="待比對優先補抓") + # ───────────────────────────────────────────── # CLI 測試(不依賴 DB,直接測試爬蟲 + 比對邏輯) diff --git a/templates/ai_intelligence.html b/templates/ai_intelligence.html index e6ced35..5424e88 100644 --- a/templates/ai_intelligence.html +++ b/templates/ai_intelligence.html @@ -23,6 +23,9 @@ + @@ -390,6 +393,41 @@ async function generatePickList() { } } +// ── 補抓 PChome 待比對商品 ───────────────────────── +async function backfillPchomeMatches() { + const btn = document.getElementById('btnBackfill'); + const toast = document.getElementById('triggerToast'); + const msg = document.getElementById('triggerToastMsg'); + + btn.disabled = true; + btn.innerHTML = '補抓中...'; + + try { + const res = await fetch('/api/ai/pchome-match/backfill', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ limit: 60 }) + }); + const data = await res.json(); + + msg.innerHTML = data.success + ? `${data.message}` + : `${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) setTimeout(loadDashboard, 90000); + } catch (e) { + msg.innerHTML = '補抓失敗:' + 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 = '補抓待比對'; + } +} + // ── 手動觸發分析 ──────────────────────────────────── async function triggerAnalysis() { const btn = document.getElementById('btnTrigger'); diff --git a/tests/test_frontend_v2_assets.py b/tests/test_frontend_v2_assets.py index b5d144f..a7d608a 100644 --- a/tests/test_frontend_v2_assets.py +++ b/tests/test_frontend_v2_assets.py @@ -139,6 +139,8 @@ def test_ai_product_pick_agent_uses_real_competitor_data_and_dashboard_action(): assert "_build_search_keywords" in feeder_source assert "_search_pchome_candidates" in feeder_source assert "crawler.search_products(keyword, limit=SEARCH_LIMIT)" in feeder_source + assert "_fetch_unmatched_priority_skus" in feeder_source + assert "run_unmatched_priority" in feeder_source assert "generate_product_pick_list" in agent_source assert "competitor_prices" in agent_source @@ -155,12 +157,17 @@ def test_ai_product_pick_agent_uses_real_competitor_data_and_dashboard_action(): assert "@ai_bp.route('/api/ai/product-picks/generate', methods=['POST'])" in route_source assert "generate_product_pick_list(engine" in route_source + assert "@ai_bp.route('/api/ai/pchome-match/backfill', methods=['POST'])" in route_source + assert "run_unmatched_priority(limit=limit)" in route_source assert "match_rate" in route_source assert "product_pick_count" in route_source assert "產生挑品清單" in template + assert "補抓待比對" in template assert "generatePickList" in template + assert "backfillPchomeMatches" in template assert "/api/ai/product-picks/generate" in template + assert "/api/ai/pchome-match/backfill" in template assert "'product_pick':['bg-success'" in template assert "kpiMatchRate" in template