diff --git a/TODO_NEXT_STEPS.txt b/TODO_NEXT_STEPS.txt index fc3f834..5d336d8 100644 --- a/TODO_NEXT_STEPS.txt +++ b/TODO_NEXT_STEPS.txt @@ -4,6 +4,7 @@ ================================================================================ 【已完成】 + - V10.263 強化核心 MOMO/PChome 比價鏈路:新增 `marketplace_product_matcher.py` 身份比對、只讓 `identity_v2` 高信心配對進 Dashboard/AI/Excel/Daily/Growth/PPT,並建立 `competitor_intel_repository.py` 統一圖表與簡報資料出口。 - V10.254 續補 `/growth_analysis` 快取命中效能:PostgreSQL source fingerprint 加 60 秒短 TTL,匯入 realtime_sales_monthly 後同步清除 growth shared cache 與短快取,避免快取命中仍頻繁掃大表 COUNT。 - V10.253 修正 Elephant Alpha L3 HITL 空告警:價格類與資源調配低信心事件若沒有 Hermes/實證資料,只記 suppressed telemetry 與 cooldown,不寫 pending human_review、不發 Telegram;`resource_optimization` 會保留 queue/load 原始指標供追查。 - V10.251 修正 OpenClaw Q&A 備援遙測:Ollama 主路徑仍為 GCP-A → GCP-B → 111,Gemini 只記為 `openclaw_qa_gemini_fallback`,NIM 只記為 `openclaw_qa_nim`;AI Calls 會把 legacy `openclaw_qa + gemini` 標成 Gemini 備援,避免再次誤判 Gemini-first。 diff --git a/config.py b/config.py index 44ff44f..d1127a9 100644 --- a/config.py +++ b/config.py @@ -320,7 +320,7 @@ YOUTUBE_API_KEY = os.getenv('YOUTUBE_API_KEY', '') # ========================================== # 系統版本與路徑 # ========================================== -SYSTEM_VERSION = "V10.262" +SYSTEM_VERSION = "V10.263" 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 f467bd2..bbb09f8 100644 --- a/docs/AI_INTELLIGENCE_MODULE_SOT.md +++ b/docs/AI_INTELLIGENCE_MODULE_SOT.md @@ -188,7 +188,7 @@ SKU/商品ID = find_col(['商品ID', 'Product ID', 'ID', 'i_code', 'Item Code' | `discount_pct` | INTEGER | 折扣 %(NULL=未折扣) | | `competitor_product_id` | VARCHAR(100) | PChome 商品 ID | | `competitor_product_name` | TEXT | PChome 商品名稱(核對用) | -| `match_score` | NUMERIC(4,3) | 模糊比對分數(0~1),< 0.45 不寫入 | +| `match_score` | NUMERIC(4,3) | 商品身份比對分數(0~1),< 0.62 不寫入正式快取 | | `tags` | JSONB | 語意標籤,如 `["on_sale","discount_20pct"]` | | `crawled_at` | TIMESTAMP | 爬取時間 | | `expires_at` | TIMESTAMP | TTL = crawled_at + 6h,過期後 Hermes 忽略 | @@ -280,13 +280,13 @@ LIMIT 300 ``` [competitor_price_feeder.py Worker] ←← 每 4 小時獨立運行 ↓ 搜尋 PChome(search_products) - ↓ 模糊比對(price_comparison.py) + ↓ 商品身份比對(marketplace_product_matcher.py) ↓ 提取語意標籤 ↓ UPSERT competitor_prices(TTL 6h) ↓ [HermesAnalystService.fetch_candidates()] ←← AI Pipeline 消費端 ↓ LEFT JOIN competitor_prices(零網路等待) - ↓ 有效期內(expires_at > NOW())+ match_score ≥ 0.45 才 JOIN + ↓ 有效期內(expires_at > NOW())+ match_score ≥ 0.62 + tags 含 identity_v2 才 JOIN ↓ pchome_price + competitor_tags 一起傳給 Hermes ``` @@ -296,19 +296,21 @@ LIMIT 300 |------|------|------| | 解耦方式 | DB 表快取(非 Redis) | PostgreSQL 已是核心,無需額外依賴;支援 JOIN | | TTL | 6 小時 | 與 AI Pipeline 排程週期對齊 | -| 比對算法 | 品牌(0.4) + 規格(0.3) + 關鍵字(0.3) | 依賴現有 `price_comparison.py` | -| 最低比對門檻 | 0.45 | 低於此分數不寫入,避免張冠李戴影響 AI 決策 | +| 比對算法 | 品牌 + 核心 token + 容量/重量/包數 + 品類 + 價格 sanity check | 由 `marketplace_product_matcher.py` 統一供 feeder、legacy crawler、AI/PPT 鏈路使用 | +| 最低比對門檻 | 0.62 | 核心比價寧可待審,不允許低信心錯配影響 AI 決策 | +| 已有不同 PChome 商品覆蓋門檻 | 0.84 | 新候選與既有正式配對不同時,除非超高信心,否則寫入 `needs_review` attempt 不覆蓋 | | 語意標籤 | JSONB 陣列 | 傳給 Hermes 提升情境感知品質 | ### 競品比對邏輯(`competitor_price_feeder.py`) ``` -MOMO 商品名稱[:20字] - → PChomeCrawler.search_products(keyword, limit=10) - → _find_best_match(momo_name, results) - → ProductNameParser(品牌 + 規格 + 關鍵字) - → _structural_similarity() → score - → score ≥ 0.45 → _upsert_competitor_price() +MOMO 商品名稱 + → marketplace_product_matcher.build_search_terms() + → PChomeCrawler.search_products(keyword, limit=12) + → marketplace_product_matcher.score_marketplace_match() + → 品牌衝突 / 容量衝突 / 包數衝突 hard veto + → 同款高信心 score ≥ 0.62 才進 competitor_prices + → 低信心、規格衝突、既有配對衝突寫入 competitor_match_attempts ``` ### `fetch_candidates()` v2 漏斗(已更新) @@ -318,10 +320,17 @@ LEFT JOIN competitor_prices cp ON cp.sku = lmp.sku AND cp.source = 'pchome' AND cp.expires_at > NOW() - AND cp.match_score >= 0.45 + AND cp.match_score >= 0.62 + AND COALESCE(cp.tags, '[]'::jsonb) ? 'identity_v2' ``` → 無競品資料的商品仍回傳,`pchome_price=NULL`,`_batch_analyze` 自動跳過 +### 下游消費規範(2026-05-19 更新) + +- Dashboard、AI pick、Hermes、Excel export、daily/growth 圖表與 competitor PPT 必須以 `competitor_prices + competitor_price_history + competitor_match_attempts` 為短期唯一生產真相源,且只消費 `identity_v2` matcher 驗證過的配對;舊版僅靠 `match_score` 的快取不可直接進入決策或簡報。 +- `pchome_matches` 與 live `pchome_batch()` 僅保留 legacy compatibility,不得作為新簡報或 AI 決策主來源。 +- `services/competitor_intel_repository.py` 是下游頁面、圖表、簡報的共用查詢出口;新增消費端不得各自硬寫不同 match threshold。 + ### 執行方式 ```bash diff --git a/routes/admin_observability_routes.py b/routes/admin_observability_routes.py index 944954d..8e418d6 100644 --- a/routes/admin_observability_routes.py +++ b/routes/admin_observability_routes.py @@ -694,6 +694,7 @@ def business_intel_dashboard(): FROM competitor_price_history cph WHERE cph.crawled_at >= NOW() - INTERVAL '24 hours' AND cph.match_score >= 0.7 + AND COALESCE(cph.tags, '[]'::jsonb) ? 'identity_v2' ORDER BY cph.crawled_at DESC LIMIT 12 """), ).fetchall() diff --git a/routes/ai_routes.py b/routes/ai_routes.py index f42ace1..1756ff3 100644 --- a/routes/ai_routes.py +++ b/routes/ai_routes.py @@ -1518,14 +1518,18 @@ def api_icaim_dashboard(): ON cp.sku = lm.sku AND cp.source = 'pchome' AND cp.expires_at > NOW() - AND cp.match_score >= 0.45 + AND cp.match_score >= 0.62 + AND COALESCE(cp.tags, '[]'::jsonb) ? 'identity_v2' WHERE lm.rn = 1 AND (lm.momo_price - cp.price) / cp.price * 100 > 15 ) SELECT (SELECT COUNT(*) FROM products WHERE status = 'ACTIVE') AS total_skus, (SELECT COUNT(*) FROM competitor_prices - WHERE expires_at > NOW() AND source = 'pchome') AS valid_competitor_prices, + WHERE expires_at > NOW() + AND source = 'pchome' + AND COALESCE(match_score, 0) >= 0.62 + AND COALESCE(tags, '[]'::jsonb) ? 'identity_v2') AS valid_competitor_prices, (SELECT COUNT(*) FROM high_risk) AS high_risk_count, (SELECT COUNT(*) FROM ai_price_recommendations) AS total_ai_recs, (SELECT COUNT(*) FROM ai_price_recommendations @@ -1561,6 +1565,8 @@ def api_icaim_dashboard(): ON cp.sku = lp.sku AND cp.source = 'pchome' AND cp.expires_at > NOW() + AND cp.match_score >= 0.62 + AND COALESCE(cp.tags, '[]'::jsonb) ? 'identity_v2' WHERE lp.rn = 1 ORDER BY gap_pct DESC NULLS LAST LIMIT 200 diff --git a/routes/api_routes.py b/routes/api_routes.py index cf34bd5..6829eb9 100644 --- a/routes/api_routes.py +++ b/routes/api_routes.py @@ -334,6 +334,8 @@ def _build_price_history_payload(session, product): WHERE sku = :sku AND source = 'pchome' AND crawled_at >= :start_date + AND COALESCE(match_score, 0) >= 0.62 + AND COALESCE(tags, '[]'::jsonb) ? 'identity_v2' ORDER BY crawled_at """), { "sku": str(product.i_code), diff --git a/routes/daily_sales_routes.py b/routes/daily_sales_routes.py index 47654cf..f75b1cf 100644 --- a/routes/daily_sales_routes.py +++ b/routes/daily_sales_routes.py @@ -26,6 +26,7 @@ from services.daily_sales_service import ( prepare_calendar_data, prepare_marketing_summary, ) +from services.competitor_intel_repository import build_competitor_intel_payload from services.cache_manager import ( _DAILY_SALES_PROCESSED_CACHE as _SALES_PROCESSED_CACHE, _DAILY_SALES_VIEW_CACHE_DIR, @@ -644,6 +645,11 @@ def daily_sales(): month_kpi['avg_price'] = 0 chart_data = prepare_daily_charts(df, selected_date, days=30) + try: + competitor_intel = build_competitor_intel_payload(engine, days=30) + except Exception as exc: + sys_log.warning(f"[DailySales] PChome 競價情報讀取失敗,略過圖表串接: {exc}") + competitor_intel = None category_list = prepare_category_summary( df, date_str=selected_date, @@ -674,6 +680,7 @@ def daily_sales(): 'month_kpi': month_kpi, 'is_month_view': is_month_view, 'chart_data': chart_data, + 'competitor_intel': competitor_intel, 'categories': visible_categories, 'category_total_count': category_total_count, 'category_visible_count': len(visible_categories), diff --git a/routes/dashboard_routes.py b/routes/dashboard_routes.py index 89f0b5c..8342931 100644 --- a/routes/dashboard_routes.py +++ b/routes/dashboard_routes.py @@ -40,6 +40,7 @@ sys_log = SystemLogger("DashboardRoutes").get_logger() dashboard_bp = Blueprint('dashboard', __name__) PRODUCT_PICK_LIST_LIMIT = 50 +PCHOME_MATCH_SCORE_FLOOR = 0.62 def _build_pchome_product_url(product_id): @@ -61,14 +62,78 @@ def _to_float(value): return None -def _build_competitor_decision(momo_price, pchome_price): - if not pchome_price: +def _build_pchome_match_status(attempt=None): + if not attempt: return { - 'label': '待比對', + 'label': '尚未搜尋', 'tone': 'neutral', + 'summary': '尚未進入 PChome 補抓佇列', + 'detail': None, + } + + status = attempt.get('attempt_status') or 'unknown' + score = _to_float(attempt.get('best_match_score')) + candidate_count = int(attempt.get('candidate_count') or 0) + score_text = f"最佳候選 {round(score * 100)}%" if score is not None else "尚無候選分數" + + if status == 'low_score': + diagnostic_text = attempt.get('error_message') or '' + if 'brand_conflict' in diagnostic_text: + label = '品牌衝突待審' + summary = f'{score_text},品牌不一致,已停止自動採用' + elif any(token in diagnostic_text for token in ('volume_conflict', 'weight_conflict', 'count_conflict')): + label = '規格衝突待審' + summary = f'{score_text},容量/件數不一致,已停止自動採用' + elif 'type_conflict' in diagnostic_text: + label = '品類衝突待審' + summary = f'{score_text},品類不一致,已停止自動採用' + else: + label = '低信心待審' + summary = f'{score_text},不自動採用以避免錯配' + return { + 'label': label, + 'tone': 'neutral', + 'summary': summary, + 'detail': f'{candidate_count} 筆候選', + } + if status == 'needs_review': + return { + 'label': '配對衝突待審', + 'tone': 'neutral', + 'summary': '新候選與既有配對不同,需人工確認後再覆蓋', + 'detail': f'{score_text} / {candidate_count} 筆候選', + } + if status in {'no_result', 'no_match'}: + return { + 'label': '找不到同款', + 'tone': 'neutral', + 'summary': 'PChome 搜尋無可信候選,需補關鍵字或人工覆核', + 'detail': f'{candidate_count} 筆候選', + } + if status == 'error': + return { + 'label': '抓取異常', + 'tone': 'risk', + 'summary': 'PChome 比對流程發生錯誤,請查看嘗試紀錄', + 'detail': attempt.get('error_message'), + } + return { + 'label': '待比對', + 'tone': 'neutral', + 'summary': '尚無有效 PChome 對應商品或價格快取', + 'detail': score_text, + } + + +def _build_competitor_decision(momo_price, pchome_price, match_status=None): + if not pchome_price: + status = match_status or _build_pchome_match_status() + return { + 'label': status['label'], + 'tone': status['tone'], 'gap_amount': None, 'gap_pct': None, - 'summary': '尚無 PChome 對應商品或價格快取' + 'summary': status['summary'] } momo_price = float(momo_price or 0) @@ -123,8 +188,13 @@ def _load_pchome_competitor_map(session, skus): WHERE source = 'pchome' AND sku IN :skus AND (expires_at IS NULL OR expires_at > CURRENT_TIMESTAMP) + AND COALESCE(match_score, 0) >= :match_score_floor + AND COALESCE(tags, '[]'::jsonb) ? 'identity_v2' """).bindparams(bindparam("skus", expanding=True)) - rows = session.execute(stmt, {"skus": sku_list}).mappings().all() + rows = session.execute( + stmt, + {"skus": sku_list, "match_score_floor": PCHOME_MATCH_SCORE_FLOOR}, + ).mappings().all() except Exception as exc: sys_log.warning(f"[Dashboard] PChome 競品價格資料讀取略過: {exc}") return {} @@ -148,6 +218,41 @@ def _load_pchome_competitor_map(session, skus): return result +def _load_pchome_match_attempt_map(session, skus): + sku_list = [str(sku) for sku in skus if sku] + if not sku_list: + return {} + + try: + stmt = text(""" + WITH ranked AS ( + SELECT + sku, + attempt_status, + candidate_count, + best_competitor_product_id, + best_competitor_product_name, + best_competitor_price, + best_match_score, + error_message, + attempted_at, + ROW_NUMBER() OVER (PARTITION BY sku ORDER BY attempted_at DESC) AS rn + FROM competitor_match_attempts + WHERE source = 'pchome' + AND sku IN :skus + ) + SELECT * + FROM ranked + WHERE rn = 1 + """).bindparams(bindparam("skus", expanding=True)) + rows = session.execute(stmt, {"skus": sku_list}).mappings().all() + except Exception as exc: + sys_log.warning(f"[Dashboard] PChome 比對嘗試資料讀取略過: {exc}") + return {} + + return {str(row.get('sku')): dict(row) for row in rows} + + def _format_dashboard_dt(value): if not value: return None @@ -366,7 +471,7 @@ def _load_competitor_decision_overview(session, latest_items=None): except Exception: pass - latest_compared_cte = """ + latest_compared_cte = f""" WITH latest_momo AS ( SELECT p.id AS product_id, @@ -396,7 +501,8 @@ def _load_competitor_decision_overview(session, latest_items=None): AND (cp.expires_at IS NULL OR cp.expires_at > CURRENT_TIMESTAMP) AND cp.price IS NOT NULL AND cp.price > 0 - AND COALESCE(cp.match_score, 0) >= 0.42 + AND COALESCE(cp.match_score, 0) >= {PCHOME_MATCH_SCORE_FLOOR} + AND COALESCE(cp.tags, '[]'::jsonb) ? 'identity_v2' ORDER BY cp.sku, cp.crawled_at DESC NULLS LAST ), compared AS ( @@ -442,7 +548,7 @@ def _load_competitor_decision_overview(session, latest_items=None): LIMIT 3 """) - pending_sql = text(""" + pending_sql = text(f""" WITH latest_momo AS ( SELECT p.i_code AS sku, @@ -463,14 +569,15 @@ def _load_competitor_decision_overview(session, latest_items=None): AND (cp.expires_at IS NULL OR cp.expires_at > CURRENT_TIMESTAMP) AND cp.price IS NOT NULL AND cp.price > 0 - AND COALESCE(cp.match_score, 0) >= 0.42 + AND COALESCE(cp.match_score, 0) >= {PCHOME_MATCH_SCORE_FLOOR} + AND COALESCE(cp.tags, '[]'::jsonb) ? 'identity_v2' WHERE lm.rn = 1 AND cp.sku IS NULL ORDER BY lm.momo_price DESC NULLS LAST LIMIT 3 """) - picks_sql = text(""" + picks_sql = text(f""" WITH valid_competitor AS ( SELECT DISTINCT ON (cp.sku) cp.sku, @@ -482,7 +589,8 @@ def _load_competitor_decision_overview(session, latest_items=None): AND (cp.expires_at IS NULL OR cp.expires_at > CURRENT_TIMESTAMP) AND cp.price IS NOT NULL AND cp.price > 0 - AND COALESCE(cp.match_score, 0) >= 0.42 + AND COALESCE(cp.match_score, 0) >= {PCHOME_MATCH_SCORE_FLOOR} + AND COALESCE(cp.tags, '[]'::jsonb) ? 'identity_v2' ORDER BY cp.sku, cp.crawled_at DESC NULLS LAST ) SELECT @@ -1446,13 +1554,23 @@ def index(): session, [item['record'].product.i_code for item in paged_items] ) + pchome_attempt_map = _load_pchome_match_attempt_map( + session, + [item['record'].product.i_code for item in paged_items] + ) for item in paged_items: product = item['record'].product - competitor = pchome_map.get(str(product.i_code)) + sku = str(product.i_code) + competitor = pchome_map.get(sku) + attempt = pchome_attempt_map.get(sku) + match_status = _build_pchome_match_status(attempt) item['pchome_competitor'] = competitor + item['pchome_match_attempt'] = attempt + item['pchome_match_status'] = match_status item['competitor_decision'] = _build_competitor_decision( item['record'].price, - competitor.get('price') if competitor else None + competitor.get('price') if competitor else None, + match_status=match_status, ) competitor_overview = data.get('competitor_overview') diff --git a/routes/export_routes.py b/routes/export_routes.py index 8e70467..f313f03 100644 --- a/routes/export_routes.py +++ b/routes/export_routes.py @@ -137,7 +137,8 @@ def export_excel_ai_picks(): AND (cp.expires_at IS NULL OR cp.expires_at > CURRENT_TIMESTAMP) AND cp.price IS NOT NULL AND cp.price > 0 - AND COALESCE(cp.match_score, 0) >= 0.42 + AND COALESCE(cp.match_score, 0) >= 0.62 + AND COALESCE(cp.tags, '[]'::jsonb) ? 'identity_v2' ORDER BY cp.sku, cp.crawled_at DESC NULLS LAST ) SELECT diff --git a/routes/openclaw_bot_routes.py b/routes/openclaw_bot_routes.py index 5e0f5fd..4cb607a 100644 --- a/routes/openclaw_bot_routes.py +++ b/routes/openclaw_bot_routes.py @@ -3327,27 +3327,31 @@ def _generate_ppt_cmd(sub_type: str, sub_arg: str, _chat_id: int, target: str, if not cached_ai: mcp_text_c = _fetch_mcp_context() - results = pchome_batch(_db(), top_n=30, date_str=date_str_for_query) - if results: - pchome_save(_db(), results) + from services.competitor_intel_repository import fetch_competitor_comparison_results + results = fetch_competitor_comparison_results( + _db(), + start_date=start_d.strftime('%Y-%m-%d'), + end_date=end_d.strftime('%Y-%m-%d'), + limit=30, + ) found_c = [r for r in results if r.get('found')] - pc_wins_c = [r for r in found_c if r.get('price_diff', 0) > 10] - mo_wins_c = [r for r in found_c if r.get('price_diff', 0) < -10] + pc_wins_c = [r for r in found_c if r.get('price_diff', 0) < -10] + mo_wins_c = [r for r in found_c if r.get('price_diff', 0) > 10] avg_diff_c = (sum(r.get('price_diff_pct', 0) for r in found_c) / len(found_c) if found_c else 0) data_summary = ( - f"【我方=PChome,競品=momo】\n" + f"【可信資料源=competitor_prices 高信心配對,MOMO vs PChome】\n" f"分析週期:{period_label}\n" f"掃描商品:{len(results)} 件 | 比對成功:{len(found_c)} 件\n" f"PChome定價優勢(比momo便宜):{len(pc_wins_c)} 件 | momo威脅(比PChome便宜):{len(mo_wins_c)} 件\n" - f"平均價差:{avg_diff_c:+.1f}%(正值=momo偏貴=PChome具優勢)\n\n" + f"平均價差:{avg_diff_c:+.1f}%(正值=PChome較貴、MOMO具價格優勢;負值=PChome較便宜)\n\n" f"PChome優勢 TOP3(應加強曝光宣傳):" + " / ".join( - f"{r['momo_name'][:15]}(PChome便宜NT${r['price_diff']:,.0f})" + f"{r['momo_name'][:15]}(PChome便宜NT${abs(r['price_diff']):,.0f})" for r in pc_wins_c[:3]) + "\n" f"momo威脅 TOP3(需研擬因應策略):" + " / ".join( - f"{r['momo_name'][:15]}(momo便宜NT${abs(r['price_diff']):,.0f})" + f"{r['momo_name'][:15]}(MOMO便宜NT${r['price_diff']:,.0f})" for r in mo_wins_c[:3]) + "\n\n" f"外部情報:{mcp_text_c[:400]}" ) diff --git a/routes/sales_routes.py b/routes/sales_routes.py index 6f0d893..2e55590 100644 --- a/routes/sales_routes.py +++ b/routes/sales_routes.py @@ -406,6 +406,44 @@ def _build_growth_chart_data(monthly_rows): } +def _attach_growth_competitor_intel(engine, chart_data): + """把 PChome 價格壓力對齊 growth_analysis 月份序列。""" + if not chart_data: + return chart_data + enriched = dict(chart_data) + try: + from services.competitor_intel_repository import ( + fetch_competitor_coverage, + fetch_competitor_monthly_pressure, + ) + pressure = fetch_competitor_monthly_pressure(engine, months=max(len(enriched.get('labels') or []), 12)) + by_month = { + label: { + 'avg_gap_pct': gap, + 'risk_count': risk, + 'match_count': match_count, + } + for label, gap, risk, match_count in zip( + pressure.get('labels', []), + pressure.get('avg_gap_pct', []), + pressure.get('risk_count', []), + pressure.get('match_count', []), + ) + } + labels = enriched.get('labels') or [] + enriched['competitor_gap_pct'] = [by_month.get(label, {}).get('avg_gap_pct', 0) for label in labels] + enriched['competitor_risk_count'] = [by_month.get(label, {}).get('risk_count', 0) for label in labels] + enriched['competitor_match_count'] = [by_month.get(label, {}).get('match_count', 0) for label in labels] + enriched['competitor_coverage'] = fetch_competitor_coverage(engine) + except Exception as exc: + sys_log.warning(f"[GrowthAnalysis] PChome 競價情報串接失敗,略過: {exc}") + enriched['competitor_gap_pct'] = [0 for _ in enriched.get('labels', [])] + enriched['competitor_risk_count'] = [0 for _ in enriched.get('labels', [])] + enriched['competitor_match_count'] = [0 for _ in enriched.get('labels', [])] + enriched['competitor_coverage'] = {} + return enriched + + def _build_growth_kpi(kpi_row): if not kpi_row: return None @@ -1954,8 +1992,9 @@ def growth_analysis(): sys_log.debug(f"[GrowthAnalysis] [Cache] 使用快取 | 快取年齡: {cache_age}秒") now_taipei = datetime.now(TAIPEI_TZ) + chart_data = _attach_growth_competitor_intel(db.engine, cache['chart_data']) return render_template('growth_analysis.html', - chart_data=cache['chart_data'], + chart_data=chart_data, kpi=cache['kpi'], datetime_now=now_taipei.strftime('%Y-%m-%d %H:%M:%S'), cache_hit=True, @@ -1984,6 +2023,7 @@ def growth_analysis(): # 儲存快取 set_growth_cache(chart_data, kpi, source_fingerprint) + chart_data = _attach_growth_competitor_intel(db.engine, chart_data) elapsed = time.time() - start_time sys_log.debug(f"[GrowthAnalysis] [Cache] 數據計算完成 | 耗時: {elapsed:.3f}秒") diff --git a/services/ai_product_pick_agent.py b/services/ai_product_pick_agent.py index b666fa9..5385305 100644 --- a/services/ai_product_pick_agent.py +++ b/services/ai_product_pick_agent.py @@ -101,11 +101,18 @@ def _quote_identifier(identifier: str) -> str: return '"' + identifier.replace('"', '""') + '"' +def _identity_match_condition(conn, alias: str = "cp") -> str: + if conn.dialect.name == "postgresql": + return f"AND COALESCE({alias}.tags, '[]'::jsonb) ? 'identity_v2'" + return f"AND COALESCE({alias}.tags, '') LIKE '%identity_v2%'" + + def _fetch_candidates_without_sales(conn, limit: int) -> List[Dict[str, Any]]: """DB-portable fallback query used when sales-aware PostgreSQL SQL is unavailable.""" from sqlalchemy import text - fallback = text(""" + identity_condition = _identity_match_condition(conn, "cp") + fallback = text(f""" WITH latest_momo AS ( SELECT p.id AS product_id, @@ -147,7 +154,8 @@ def _fetch_candidates_without_sales(conn, limit: int) -> List[Dict[str, Any]]: ON cp.sku = lm.sku AND cp.source = 'pchome' AND (cp.expires_at IS NULL OR cp.expires_at > CURRENT_TIMESTAMP) - AND cp.match_score >= 0.42 + AND cp.match_score >= 0.62 + {identity_condition} WHERE lm.rn = 1 ORDER BY cp.match_score DESC, cp.crawled_at DESC LIMIT :limit @@ -216,6 +224,7 @@ def _fetch_candidates(conn, limit: int) -> List[Dict[str, Any]]: COALESCE(sales.cost_7d, 0) AS cost_7d """ + identity_condition = _identity_match_condition(conn, "cp") sql = text(f""" WITH latest_momo AS ( SELECT @@ -240,6 +249,8 @@ def _fetch_candidates(conn, limit: int) -> List[Dict[str, Any]]: FROM competitor_price_history WHERE source = 'pchome' AND crawled_at >= CURRENT_TIMESTAMP - INTERVAL '30 days' + AND COALESCE(match_score, 0) >= 0.62 + AND COALESCE(tags, '[]'::jsonb) ? 'identity_v2' GROUP BY sku, source ) SELECT @@ -266,7 +277,8 @@ def _fetch_candidates(conn, limit: int) -> List[Dict[str, Any]]: ON cp.sku = lm.sku AND cp.source = 'pchome' AND (cp.expires_at IS NULL OR cp.expires_at > CURRENT_TIMESTAMP) - AND cp.match_score >= 0.42 + AND cp.match_score >= 0.62 + {identity_condition} LEFT JOIN history_stats hs ON hs.sku = lm.sku AND hs.source = cp.source @@ -503,7 +515,7 @@ def generate_product_pick_list(engine, limit: int = 50) -> ProductPickResult: scored = [_score_candidate(row) for row in rows if _to_float(row.get("pchome_price")) > 0] picks = [ pick for pick in scored - if pick["pick_score"] >= 45 and (_to_float(pick.get("match_score")) >= 0.42) + if pick["pick_score"] >= 45 and (_to_float(pick.get("match_score")) >= 0.62) ] picks.sort(key=lambda item: item["pick_score"], reverse=True) picks = picks[:limit] diff --git a/services/chart_generator_service.py b/services/chart_generator_service.py index adc4f38..3a85a19 100644 --- a/services/chart_generator_service.py +++ b/services/chart_generator_service.py @@ -107,6 +107,8 @@ def _fetch_price_history(sku: str, days: int = 30) -> Dict[str, Any]: WHERE sku = :sku AND source = 'pchome' AND crawled_at >= NOW() - INTERVAL ':days days' + AND COALESCE(match_score, 0) >= 0.62 + AND COALESCE(tags, '[]'::jsonb) ? 'identity_v2' GROUP BY dt ORDER BY dt """.replace(":days", str(days))), {"sku": sku}).fetchall() @@ -441,6 +443,8 @@ def price_history_heatmap(days: int = 30) -> Optional[bytes]: ) pr ON pr.product_id = p.id WHERE cp.crawled_at >= NOW() - INTERVAL '{days} days' AND p.category IS NOT NULL + AND COALESCE(cp.match_score, 0) >= 0.62 + AND COALESCE(cp.tags, '[]'::jsonb) ? 'identity_v2' GROUP BY p.category, dt ORDER BY p.category, dt """)).fetchall() diff --git a/services/competitor_intel_repository.py b/services/competitor_intel_repository.py new file mode 100644 index 0000000..eeeffb7 --- /dev/null +++ b/services/competitor_intel_repository.py @@ -0,0 +1,408 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +"""PChome / MOMO 競價情報共用資料出口。 + +短期 canonical source: +- competitor_prices:目前有效配對 +- competitor_price_history:價格歷史趨勢 +- competitor_match_attempts:未配對與低信心診斷 +""" + +from __future__ import annotations + +from datetime import date, datetime, timedelta +from typing import Any, Optional, Union + +from sqlalchemy import inspect, text + + +PCHOME_MATCH_SCORE_FLOOR = 0.62 + + +def _num(value: Any) -> float: + try: + return float(value or 0) + except (TypeError, ValueError): + return 0.0 + + +def _date_label(value: Any) -> str: + if hasattr(value, "strftime"): + return value.strftime("%Y-%m-%d") + return str(value or "") + + +def _month_label(value: Any) -> str: + if hasattr(value, "strftime"): + return value.strftime("%Y-%m") + return str(value or "")[:7] + + +def fetch_competitor_coverage(engine) -> dict: + """讀取目前 PChome 比價覆蓋率與待審分類。""" + if not inspect(engine).has_table("competitor_prices"): + return { + "active_with_price": 0, + "valid_matches": 0, + "pending": 0, + "match_rate": 0, + "attempt_status": {}, + } + + sql = text(f""" + WITH latest_momo AS ( + SELECT + p.id AS product_id, + p.i_code AS sku, + pr.price AS momo_price, + ROW_NUMBER() OVER (PARTITION BY p.id ORDER BY pr.timestamp DESC, pr.id DESC) AS rn + FROM products p + JOIN price_records pr ON pr.product_id = p.id + WHERE p.status = 'ACTIVE' + ), + valid_competitor AS ( + SELECT DISTINCT ON (cp.sku) + cp.sku + FROM competitor_prices cp + WHERE cp.source = 'pchome' + AND (cp.expires_at IS NULL OR cp.expires_at > CURRENT_TIMESTAMP) + AND cp.price IS NOT NULL + AND cp.price > 0 + AND COALESCE(cp.match_score, 0) >= {PCHOME_MATCH_SCORE_FLOOR} + AND COALESCE(cp.tags, '[]'::jsonb) ? 'identity_v2' + ORDER BY cp.sku, cp.crawled_at DESC NULLS LAST + ), + latest_attempt AS ( + SELECT DISTINCT ON (sku) + sku, + attempt_status + FROM competitor_match_attempts + WHERE source = 'pchome' + ORDER BY sku, attempted_at DESC NULLS LAST + ) + SELECT + (SELECT COUNT(*) FROM latest_momo WHERE rn = 1) AS active_with_price, + (SELECT COUNT(*) FROM valid_competitor) AS valid_matches, + (SELECT COUNT(*) + FROM latest_momo lm + LEFT JOIN valid_competitor vc ON vc.sku = lm.sku + WHERE lm.rn = 1 AND vc.sku IS NULL) AS pending, + COALESCE(la.attempt_status, 'never_attempted') AS attempt_status, + COUNT(*) AS status_count + FROM latest_momo lm + LEFT JOIN valid_competitor vc ON vc.sku = lm.sku + LEFT JOIN latest_attempt la ON la.sku = lm.sku + WHERE lm.rn = 1 + AND vc.sku IS NULL + GROUP BY COALESCE(la.attempt_status, 'never_attempted') + """) + with engine.connect() as conn: + rows = conn.execute(sql).mappings().all() + + active = int(rows[0].get("active_with_price") or 0) if rows else 0 + valid = int(rows[0].get("valid_matches") or 0) if rows else 0 + pending = int(rows[0].get("pending") or 0) if rows else 0 + statuses = { + str(row.get("attempt_status")): int(row.get("status_count") or 0) + for row in rows + } + return { + "active_with_price": active, + "valid_matches": valid, + "pending": pending, + "match_rate": round(valid / max(active, 1) * 100, 1), + "attempt_status": statuses, + "match_score_floor": PCHOME_MATCH_SCORE_FLOOR, + } + + +def fetch_competitor_gap_trend(engine, days: int = 30) -> dict: + """近 N 天 PChome 價差壓力趨勢。""" + if not inspect(engine).has_table("competitor_price_history"): + return {"labels": [], "avg_gap_pct": [], "risk_count": [], "momo_advantage_count": [], "match_count": []} + + days = max(7, min(int(days or 30), 120)) + sql = text(f""" + WITH latest_history AS ( + SELECT + date_trunc('day', cph.crawled_at)::date AS bucket_date, + cph.sku, + cph.momo_price, + cph.price AS pchome_price, + ROW_NUMBER() OVER ( + PARTITION BY date_trunc('day', cph.crawled_at)::date, cph.sku + ORDER BY cph.crawled_at DESC + ) AS rn + FROM competitor_price_history cph + WHERE cph.source = 'pchome' + AND cph.crawled_at >= CURRENT_DATE - (:days * INTERVAL '1 day') + AND cph.momo_price IS NOT NULL + AND cph.momo_price > 0 + AND cph.price IS NOT NULL + AND cph.price > 0 + AND COALESCE(cph.match_score, 0) >= {PCHOME_MATCH_SCORE_FLOOR} + AND COALESCE(cph.tags, '[]'::jsonb) ? 'identity_v2' + ) + SELECT + bucket_date, + COUNT(*) AS match_count, + ROUND(AVG((momo_price - pchome_price) / pchome_price * 100)::numeric, 2) AS avg_gap_pct, + SUM(CASE WHEN momo_price > pchome_price * 1.05 THEN 1 ELSE 0 END) AS risk_count, + SUM(CASE WHEN momo_price < pchome_price * 0.95 THEN 1 ELSE 0 END) AS momo_advantage_count + FROM latest_history + WHERE rn = 1 + GROUP BY bucket_date + ORDER BY bucket_date + """) + with engine.connect() as conn: + rows = conn.execute(sql, {"days": days}).mappings().all() + + return { + "labels": [_date_label(row.get("bucket_date")) for row in rows], + "avg_gap_pct": [_num(row.get("avg_gap_pct")) for row in rows], + "risk_count": [int(row.get("risk_count") or 0) for row in rows], + "momo_advantage_count": [int(row.get("momo_advantage_count") or 0) for row in rows], + "match_count": [int(row.get("match_count") or 0) for row in rows], + } + + +def fetch_competitor_monthly_pressure(engine, months: int = 12) -> dict: + """月度競品價格壓力,用於 growth analysis。""" + if not inspect(engine).has_table("competitor_price_history"): + return {"labels": [], "avg_gap_pct": [], "risk_count": [], "match_count": []} + + months = max(3, min(int(months or 12), 36)) + sql = text(f""" + WITH latest_history AS ( + SELECT + date_trunc('month', cph.crawled_at)::date AS bucket_month, + cph.sku, + cph.momo_price, + cph.price AS pchome_price, + ROW_NUMBER() OVER ( + PARTITION BY date_trunc('month', cph.crawled_at)::date, cph.sku + ORDER BY cph.crawled_at DESC + ) AS rn + FROM competitor_price_history cph + WHERE cph.source = 'pchome' + AND cph.crawled_at >= date_trunc('month', CURRENT_DATE) - (:months * INTERVAL '1 month') + AND cph.momo_price IS NOT NULL + AND cph.momo_price > 0 + AND cph.price IS NOT NULL + AND cph.price > 0 + AND COALESCE(cph.match_score, 0) >= {PCHOME_MATCH_SCORE_FLOOR} + AND COALESCE(cph.tags, '[]'::jsonb) ? 'identity_v2' + ) + SELECT + bucket_month, + COUNT(*) AS match_count, + ROUND(AVG((momo_price - pchome_price) / pchome_price * 100)::numeric, 2) AS avg_gap_pct, + SUM(CASE WHEN momo_price > pchome_price * 1.05 THEN 1 ELSE 0 END) AS risk_count + FROM latest_history + WHERE rn = 1 + GROUP BY bucket_month + ORDER BY bucket_month + """) + with engine.connect() as conn: + rows = conn.execute(sql, {"months": months}).mappings().all() + + return { + "labels": [_month_label(row.get("bucket_month")) for row in rows], + "avg_gap_pct": [_num(row.get("avg_gap_pct")) for row in rows], + "risk_count": [int(row.get("risk_count") or 0) for row in rows], + "match_count": [int(row.get("match_count") or 0) for row in rows], + } + + +def fetch_top_competitor_risks(engine, limit: int = 10) -> list[dict]: + """目前 MOMO 比 PChome 貴的高風險商品。""" + if not inspect(engine).has_table("competitor_prices"): + return [] + + limit = max(1, min(int(limit or 10), 50)) + sql = text(f""" + 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, pr.id DESC) AS rn + FROM products p + JOIN price_records pr ON pr.product_id = p.id + WHERE p.status = 'ACTIVE' + ), + valid_competitor AS ( + SELECT DISTINCT ON (cp.sku) + cp.sku, + cp.price AS pchome_price, + cp.competitor_product_id, + cp.competitor_product_name, + cp.match_score, + cp.crawled_at + FROM competitor_prices cp + WHERE cp.source = 'pchome' + AND (cp.expires_at IS NULL OR cp.expires_at > CURRENT_TIMESTAMP) + AND cp.price IS NOT NULL + AND cp.price > 0 + AND COALESCE(cp.match_score, 0) >= {PCHOME_MATCH_SCORE_FLOOR} + AND COALESCE(cp.tags, '[]'::jsonb) ? 'identity_v2' + ORDER BY cp.sku, cp.crawled_at DESC NULLS LAST + ) + SELECT + lm.sku, + lm.name, + lm.category, + lm.momo_price, + vc.pchome_price, + vc.competitor_product_id, + vc.competitor_product_name, + vc.match_score, + vc.crawled_at, + (lm.momo_price - vc.pchome_price) AS gap_amount, + ((lm.momo_price - vc.pchome_price) / vc.pchome_price * 100) AS gap_pct + FROM latest_momo lm + JOIN valid_competitor vc ON vc.sku = lm.sku + WHERE lm.rn = 1 + AND lm.momo_price > vc.pchome_price * 1.05 + ORDER BY gap_pct DESC NULLS LAST, gap_amount DESC NULLS LAST + LIMIT :limit + """) + with engine.connect() as conn: + rows = conn.execute(sql, {"limit": limit}).mappings().all() + + result = [] + for row in rows: + result.append({ + "sku": str(row.get("sku") or ""), + "name": row.get("name") or "", + "category": row.get("category") or "", + "momo_price": _num(row.get("momo_price")), + "pchome_price": _num(row.get("pchome_price")), + "gap_amount": _num(row.get("gap_amount")), + "gap_pct": _num(row.get("gap_pct")), + "match_score": _num(row.get("match_score")), + "pchome_id": row.get("competitor_product_id"), + "pchome_name": row.get("competitor_product_name") or "", + "crawled_at": _date_label(row.get("crawled_at")), + }) + return result + + +def fetch_competitor_comparison_results( + engine, + start_date: Optional[Union[date, datetime, str]] = None, + end_date: Optional[Union[date, datetime, str]] = None, + limit: int = 30, +) -> list[dict]: + """輸出與 legacy competitor PPT 相容的比價結果,不再 live crawl。""" + limit = max(1, min(int(limit or 30), 100)) + has_daily_sales = inspect(engine).has_table("daily_sales") + sales_cte = "" + sales_join = "" + sales_select = "0 AS momo_revenue," + order_expr = "ABS((lm.momo_price - vc.pchome_price) / vc.pchome_price * 100) DESC NULLS LAST" + params: dict[str, Any] = {"limit": limit} + + if has_daily_sales: + where = [] + if start_date: + where.append("DATE(s.date) >= DATE(:start_date)") + params["start_date"] = str(start_date).replace("/", "-")[:10] + if end_date: + where.append("DATE(s.date) <= DATE(:end_date)") + params["end_date"] = str(end_date).replace("/", "-")[:10] + sales_where = "WHERE " + " AND ".join(where) if where else "" + sales_cte = f""", + sales_rank AS ( + SELECT + s.product_id, + SUM(COALESCE(s.revenue, 0)) AS momo_revenue + FROM daily_sales s + {sales_where} + GROUP BY s.product_id + ) + """ + sales_join = "LEFT JOIN sales_rank sr ON sr.product_id = lm.product_id" + sales_select = "COALESCE(sr.momo_revenue, 0) AS momo_revenue," + order_expr = "COALESCE(sr.momo_revenue, 0) DESC, " + order_expr + + sql = text(f""" + WITH latest_momo AS ( + SELECT + p.id AS product_id, + p.i_code AS sku, + p.name, + pr.price AS momo_price, + ROW_NUMBER() OVER (PARTITION BY p.id ORDER BY pr.timestamp DESC, pr.id DESC) AS rn + FROM products p + JOIN price_records pr ON pr.product_id = p.id + WHERE p.status = 'ACTIVE' + ), + valid_competitor AS ( + SELECT DISTINCT ON (cp.sku) + cp.sku, + cp.price AS pchome_price, + cp.competitor_product_id, + cp.competitor_product_name, + cp.match_score + FROM competitor_prices cp + WHERE cp.source = 'pchome' + AND (cp.expires_at IS NULL OR cp.expires_at > CURRENT_TIMESTAMP) + AND cp.price IS NOT NULL + AND cp.price > 0 + AND COALESCE(cp.match_score, 0) >= {PCHOME_MATCH_SCORE_FLOOR} + AND COALESCE(cp.tags, '[]'::jsonb) ? 'identity_v2' + ORDER BY cp.sku, cp.crawled_at DESC NULLS LAST + ) + {sales_cte} + SELECT + lm.sku, + lm.name, + lm.momo_price, + vc.pchome_price, + vc.competitor_product_id, + vc.competitor_product_name, + vc.match_score, + {sales_select} + (vc.pchome_price - lm.momo_price) AS price_diff, + ((vc.pchome_price - lm.momo_price) / lm.momo_price * 100) AS price_diff_pct + FROM latest_momo lm + JOIN valid_competitor vc ON vc.sku = lm.sku + {sales_join} + WHERE lm.rn = 1 + AND lm.momo_price > 0 + ORDER BY {order_expr} + LIMIT :limit + """) + with engine.connect() as conn: + rows = conn.execute(sql, params).mappings().all() + + results = [] + for row in rows: + pchome_id = row.get("competitor_product_id") + results.append({ + "found": True, + "momo_icode": str(row.get("sku") or ""), + "momo_name": row.get("name") or "", + "momo_price": _num(row.get("momo_price")), + "pc_name": row.get("competitor_product_name") or "", + "pc_price": _num(row.get("pchome_price")), + "pc_url": f"https://24h.pchome.com.tw/prod/{pchome_id}" if pchome_id else "", + "price_diff": _num(row.get("price_diff")), + "price_diff_pct": _num(row.get("price_diff_pct")), + "match_score": _num(row.get("match_score")), + "momo_revenue": _num(row.get("momo_revenue")), + }) + return results + + +def build_competitor_intel_payload(engine, days: int = 30) -> dict: + """頁面、AI、PPT 可共用的摘要 payload。""" + return { + "coverage": fetch_competitor_coverage(engine), + "trend": fetch_competitor_gap_trend(engine, days=days), + "top_risks": fetch_top_competitor_risks(engine, limit=10), + "match_score_floor": PCHOME_MATCH_SCORE_FLOOR, + } diff --git a/services/competitor_price_feeder.py b/services/competitor_price_feeder.py index cc79e39..b48716b 100644 --- a/services/competitor_price_feeder.py +++ b/services/competitor_price_feeder.py @@ -34,7 +34,8 @@ from typing import Optional logger = logging.getLogger(__name__) # ── 比對參數 ───────────────────────────────────────── -MIN_MATCH_SCORE = 0.42 # 低於此分數不寫入(避免張冠李戴) +MIN_MATCH_SCORE = 0.62 # 低於此分數不寫入;核心比價寧可待審也不能錯配 +REPLACE_DIFFERENT_PRODUCT_SCORE = 0.84 # 已有不同 PChome 商品時,需超高信心才覆蓋 SEARCH_LIMIT = 12 # 每個搜尋詞取 PChome 前 N 筆 MAX_SEARCH_TERMS = 3 # 每個 MOMO 商品最多嘗試幾組搜尋詞 BATCH_SIZE = 30 # 每批 DB 寫入筆數 @@ -124,37 +125,41 @@ def _dedupe_terms(terms: list) -> list: def _build_search_keywords(momo_name: str) -> list: """ - 用多組真實商品名線索搜尋 PChome,提高命中率,但仍交給相似度門檻把關。 + 用多組商品身份線索搜尋 PChome,提高命中率,但仍交給身份比對門檻把關。 """ - cleaned = _clean_search_text(momo_name) - terms = [cleaned[:28], cleaned[:18]] - try: - from services.price_comparison import ProductNameParser, BRAND_ALIASES - parser = ProductNameParser() - parsed = parser.parse(momo_name, "momo", 0, "", "") - if parsed.brand: - brand_terms = BRAND_ALIASES.get(parsed.brand, [parsed.brand]) - brand_label = next((term for term in brand_terms if any('\u4e00' <= c <= '\u9fff' for c in term)), brand_terms[0]) - if parsed.product_type: - terms.append(f"{brand_label} {parsed.product_type}") - if parsed.specs.get("volume"): - terms.append(f"{brand_label} {parsed.specs['volume']}") - if parsed.keywords: - terms.append(f"{brand_label} {' '.join(parsed.keywords[:3])}") - elif parsed.keywords: - terms.append(" ".join(parsed.keywords[:4])) + from services.marketplace_product_matcher import build_search_terms + terms = build_search_terms(momo_name, max_terms=MAX_SEARCH_TERMS) except Exception: logger.debug( - "[Feeder] ProductNameParser failed while building search keywords; " + "[Feeder] marketplace matcher failed while building search keywords; " "fallback to cleaned product name", exc_info=True, ) + cleaned = _clean_search_text(momo_name) + terms = [cleaned[:36], cleaned[:24]] return _dedupe_terms(terms) -def _find_best_match(momo_name: str, pchome_products: list) -> Optional[tuple]: +def _format_match_diagnostics(diagnostics) -> str: + if not diagnostics: + return "" + reasons = ",".join(getattr(diagnostics, "reasons", ()) or ()) + return ( + f"score={diagnostics.score}; brand={diagnostics.brand_score}; " + f"token={diagnostics.token_score}; spec={diagnostics.spec_score}; " + f"seq={diagnostics.sequence_score}; type={diagnostics.type_score}; " + f"penalty={diagnostics.price_penalty}; veto={diagnostics.hard_veto}; " + f"reasons={reasons}" + ) + + +def _find_best_match_detail( + momo_name: str, + pchome_products: list, + momo_price: float = None, +) -> Optional[tuple]: """ 從 PChome 搜尋結果中找出與 MOMO 商品名稱最接近的一筆 @@ -163,35 +168,35 @@ def _find_best_match(momo_name: str, pchome_products: list) -> Optional[tuple]: pchome_products: PChomeProduct 列表 Returns: - (PChomeProduct, score) or None + (PChomeProduct, score, diagnostics) or None """ - try: - from services.price_comparison import ProductNameParser - parser = ProductNameParser() - except ImportError: - # Fallback:用 difflib 直接比字串 - from difflib import SequenceMatcher - best, best_score = None, 0.0 - for p in pchome_products: - score = SequenceMatcher(None, momo_name.lower(), p.name.lower()).ratio() - if score > best_score: - best, best_score = p, score - return (best, best_score) if best else None + from services.marketplace_product_matcher import score_marketplace_match - # 使用 ProductNameParser 的結構化比對 - momo_parsed = parser.parse(momo_name, "momo", 0, "", "") - - best, best_score = None, 0.0 + best, best_score, best_diagnostics = None, 0.0, None for p in pchome_products: - pchome_parsed = parser.parse(p.name, "pchome", p.price, p.product_id, p.product_url) - score = _structural_similarity(momo_parsed, pchome_parsed) + diagnostics = score_marketplace_match( + momo_name, + p.name, + momo_price=momo_price, + competitor_price=getattr(p, "price", None), + ) + score = diagnostics.score if score > best_score: - best, best_score = p, score + best, best_score, best_diagnostics = p, score, diagnostics - return (best, best_score) if best else None + return (best, best_score, best_diagnostics) if best else None -def _search_pchome_candidates(crawler, momo_name: str, keywords: list = None) -> list: +def _find_best_match(momo_name: str, pchome_products: list) -> Optional[tuple]: + """Backward-compatible helper for smoke scripts.""" + result = _find_best_match_detail(momo_name, pchome_products) + if not result: + return None + best, score, _diagnostics = result + return best, score + + +def _search_pchome_candidates(crawler, momo_name: str, keywords: list = None, momo_price: float = None) -> list: """以多組搜尋詞擴大 PChome 候選池,找到可信候選後提早停止。""" candidates = [] seen_ids = set() @@ -204,8 +209,8 @@ def _search_pchome_candidates(crawler, momo_name: str, keywords: list = None) -> continue seen_ids.add(product.product_id) candidates.append(product) - best = _find_best_match(momo_name, candidates) - if best and best[1] >= 0.62: + best = _find_best_match_detail(momo_name, candidates, momo_price=momo_price) + if best and best[1] >= 0.76: break return candidates @@ -507,16 +512,21 @@ class CompetitorPriceFeeder: lm.momo_price FROM latest_momo lm LEFT JOIN competitor_prices cp - ON cp.sku = lm.sku + ON cp.sku = lm.sku AND cp.source = 'pchome' AND (cp.expires_at IS NULL OR cp.expires_at > CURRENT_TIMESTAMP) + AND COALESCE(cp.match_score, 0) >= :match_score_floor + AND COALESCE(cp.tags, '[]'::jsonb) ? 'identity_v2' 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() + rows = conn.execute( + sql, + {"limit": max(1, min(int(limit), 300)), "match_score_floor": MIN_MATCH_SCORE}, + ).fetchall() return [dict(r._mapping) for r in rows] def _upsert_competitor_price( @@ -592,6 +602,59 @@ class CompetitorPriceFeeder: "tags": tags_json, }) + def _should_upsert_competitor_price( + self, + sku: str, + product, + match_score: float, + source: str = "pchome", + ) -> tuple[bool, str]: + """ + 保護正式 competitor_prices:若既有配對是不同 PChome 商品, + 只有超高信心才允許覆蓋,避免新 matcher 一次污染核心比價資料。 + """ + from sqlalchemy import text + + with self.engine.connect() as conn: + row = conn.execute(text(""" + SELECT competitor_product_id, match_score, tags + FROM competitor_prices + WHERE sku = :sku + AND source = :source + LIMIT 1 + """), {"sku": sku, "source": source}).mappings().first() + + if not row: + return True, "new_match" + + existing_id = str(row.get("competitor_product_id") or "") + incoming_id = str(getattr(product, "product_id", "") or "") + try: + existing_score = float(row.get("match_score") or 0) + except (TypeError, ValueError): + existing_score = 0.0 + existing_tags = row.get("tags") or [] + if isinstance(existing_tags, str): + try: + existing_tags = json.loads(existing_tags) + except Exception: + existing_tags = [] + if "identity_v2" not in existing_tags: + return True, "replace_legacy_unverified" + + if not existing_id or existing_id == incoming_id: + return True, "same_or_empty_existing" + if existing_score < MIN_MATCH_SCORE: + return True, f"replace_low_existing_score={existing_score:.3f}" + if match_score >= REPLACE_DIFFERENT_PRODUCT_SCORE: + return True, f"replace_high_confidence_score={match_score:.3f}" + return ( + False, + f"existing_match_conflict;existing_id={existing_id};" + f"incoming_id={incoming_id};existing_score={existing_score:.3f};" + f"incoming_score={match_score:.3f}", + ) + def _run_sku_items(self, skus: list, source: str = "pchome", label: str = "PChome 競品價格") -> FeederResult: start = time.time() @@ -619,7 +682,7 @@ class CompetitorPriceFeeder: search_terms = _build_search_keywords(momo_name) try: - products = _search_pchome_candidates(crawler, momo_name, search_terms) + products = _search_pchome_candidates(crawler, momo_name, search_terms, momo_price=momo_price) if not products: logger.debug(f"[Feeder] {sku} 無搜尋結果,跳過") self._record_match_attempt( @@ -636,7 +699,7 @@ class CompetitorPriceFeeder: skipped_no += 1 continue - result = _find_best_match(momo_name, products) + result = _find_best_match_detail(momo_name, products, momo_price=momo_price) if not result: self._record_match_attempt( sku, @@ -652,11 +715,12 @@ class CompetitorPriceFeeder: skipped_no += 1 continue - best_product, score = result + best_product, score, diagnostics = result if score < MIN_MATCH_SCORE: logger.debug( - f"[Feeder] {sku} 比對分數過低 ({score:.3f} < {MIN_MATCH_SCORE}),跳過" + f"[Feeder] {sku} 比對分數過低 ({score:.3f} < {MIN_MATCH_SCORE})," + f"{_format_match_diagnostics(diagnostics)}" ) self._record_match_attempt( sku, @@ -668,6 +732,7 @@ class CompetitorPriceFeeder: attempt_status="low_score", best_product=best_product, best_score=score, + error_message=_format_match_diagnostics(diagnostics), source=source, ) attempts_written += 1 @@ -675,6 +740,36 @@ class CompetitorPriceFeeder: continue tags = _extract_tags(best_product) + tags.extend(getattr(diagnostics, "tags", [])) + for reason in getattr(diagnostics, "reasons", ()) or (): + tags.append(f"match_{reason}") + tags = list(dict.fromkeys(tags)) + should_write, write_reason = self._should_upsert_competitor_price( + sku, + best_product, + score, + source=source, + ) + if not should_write: + logger.info(f"[Feeder] {sku} 進入人工覆核,不覆蓋既有配對 | {write_reason}") + self._record_match_attempt( + sku, + momo_name, + momo_product_id=momo_product_id, + momo_price=momo_price, + search_terms=search_terms, + candidate_count=len(products), + attempt_status="needs_review", + best_product=best_product, + best_score=score, + error_message=f"{write_reason}; {_format_match_diagnostics(diagnostics)}", + source=source, + ) + attempts_written += 1 + skipped_low += 1 + continue + + tags.append(write_reason) self._upsert_competitor_price( sku, best_product, diff --git a/services/elephant_alpha_autonomous_engine.py b/services/elephant_alpha_autonomous_engine.py index ff74e38..dfedc45 100644 --- a/services/elephant_alpha_autonomous_engine.py +++ b/services/elephant_alpha_autonomous_engine.py @@ -369,6 +369,8 @@ class ElephantAlphaAutonomousEngine: ) pr ON pr.product_id = p.id JOIN competitor_prices cp ON cp.sku = p.i_code WHERE cp.expires_at > NOW() + AND COALESCE(cp.match_score, 0) >= 0.62 + AND COALESCE(cp.tags, '[]'::jsonb) ? 'identity_v2' AND cp.price < pr.price * 0.85 AND cp.crawled_at >= NOW() - INTERVAL '2 hours' LIMIT 10 @@ -392,6 +394,8 @@ class ElephantAlphaAutonomousEngine: ) pr ON pr.product_id = p.id JOIN competitor_prices cp ON cp.sku = p.i_code WHERE cp.expires_at > NOW() + AND COALESCE(cp.match_score, 0) >= 0.62 + AND COALESCE(cp.tags, '[]'::jsonb) ? 'identity_v2' AND cp.price > pr.price * 1.05 AND cp.crawled_at >= NOW() - INTERVAL '1 hour' LIMIT 5 diff --git a/services/hermes_analyst_service.py b/services/hermes_analyst_service.py index 842aae9..7289526 100644 --- a/services/hermes_analyst_service.py +++ b/services/hermes_analyst_service.py @@ -396,7 +396,8 @@ class HermesAnalystService: ON cp.sku = lmp.sku AND cp.source = 'pchome' AND cp.expires_at > NOW() - AND cp.match_score >= 0.45 + AND cp.match_score >= 0.62 + AND COALESCE(cp.tags, '[]'::jsonb) ? 'identity_v2' WHERE lmp.rn = 1 AND rs.sales_7d_prev > 0 AND (rs.sales_7d_curr - rs.sales_7d_prev) / rs.sales_7d_prev < -0.10 diff --git a/services/marketplace_product_matcher.py b/services/marketplace_product_matcher.py new file mode 100644 index 0000000..3dee19f --- /dev/null +++ b/services/marketplace_product_matcher.py @@ -0,0 +1,527 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +"""跨電商商品身份比對工具。 + +這裡處理「是否為同一個商品」;價格只作為 sanity check,不能主導配對。 +""" + +from __future__ import annotations + +import re +import unicodedata +from dataclasses import dataclass +from difflib import SequenceMatcher +from typing import Iterable, Optional + + +NOISE_PHRASES = ( + "momo", + "pchome", + "24h", + "官方直營", + "官方", + "公司貨", + "台灣公司貨", + "專櫃公司貨", + "正貨", + "原廠", + "限時", + "特惠", + "優惠", + "超值", + "加贈", + "贈品", + "送禮", + "送", + "買一送一", + "買1送1", + "任選", + "即期品", + "福利品", + "預購", + "免運", + "熱銷", + "人氣", + "必買", + "推薦", + "新品", + "升級版", + "經典", + "獨家", + "囤貨組", + "超值組", + "優惠組", + "分享包", + "組合", +) + +GENERIC_TOKENS = { + "官方", + "直營", + "公司貨", + "專櫃", + "正貨", + "原廠", + "限時", + "特惠", + "優惠", + "超值", + "加贈", + "贈品", + "送禮", + "即期品", + "新品", + "升級版", + "經典", + "人氣", + "熱銷", + "必買", + "推薦", + "組", + "入", + "瓶", + "盒", + "包", + "片", + "支", + "條", + "ml", + "g", + "la", + "paris", +} + +PRODUCT_TYPES = { + "精華": ("精華", "精華液", "essence", "serum", "安瓶"), + "化妝水": ("化妝水", "機能水", "toner", "lotion"), + "乳液": ("乳液", "emulsion", "milk"), + "面霜": ("面霜", "乳霜", "霜", "cream"), + "防曬": ("防曬", "spf", "uv", "sunscreen"), + "洗面乳": ("洗面乳", "洗顏", "潔面", "cleanser", "foam"), + "面膜": ("面膜", "mask"), + "眼霜": ("眼霜", "眼部", "眼膜", "eye"), + "卸妝": ("卸妝", "cleansing", "remover"), + "粉底": ("粉底", "粉霜", "粉凝露", "foundation"), + "蜜粉": ("蜜粉", "powder"), + "精油": ("精油", "香氛", "擴香"), + "保健": ("錠", "膠囊", "粉", "飲", "包", "健康食品"), +} + +COUNT_UNITS = {"入", "組", "瓶", "支", "條", "盒", "包", "片", "顆", "錠", "枚"} +PIECE_UNITS = {"包", "片", "顆", "錠", "枚"} +CONTAINER_UNITS = {"入", "組", "盒"} +CHINESE_COUNT = { + "一": 1, + "二": 2, + "兩": 2, + "雙": 2, + "三": 3, + "四": 4, + "五": 5, + "六": 6, + "七": 7, + "八": 8, + "九": 9, + "十": 10, +} + + +@dataclass(frozen=True) +class ProductIdentity: + original_name: str + normalized_name: str + searchable_name: str + brand_tokens: frozenset[str] + product_type: Optional[str] + tokens: frozenset[str] + core_tokens: frozenset[str] + volumes_ml: tuple[float, ...] + weights_g: tuple[float, ...] + counts: tuple[tuple[int, str], ...] + total_piece_count: Optional[int] + + +@dataclass(frozen=True) +class MatchDiagnostics: + score: float + brand_score: float + token_score: float + spec_score: float + sequence_score: float + type_score: float + price_penalty: float + hard_veto: bool + reasons: tuple[str, ...] + + @property + def tags(self) -> list[str]: + tags: list[str] = ["identity_v2"] + if self.brand_score >= 0.95: + tags.append("brand_match") + if self.spec_score >= 0.85: + tags.append("spec_match") + if self.hard_veto: + tags.append("identity_veto") + return tags + + +def normalize_product_text(value: str) -> str: + text = unicodedata.normalize("NFKC", value or "") + text = "".join( + char for char in unicodedata.normalize("NFKD", text) + if not unicodedata.combining(char) + ) + text = text.replace("×", "x").replace("*", "x").replace("*", "x") + text = text.replace("/", "/").replace("&", "&") + text = re.sub(r"[\u3000\r\n\t]+", " ", text) + text = text.lower() + text = re.sub(r"[??]+", " ", text) + text = re.sub(r"[【】\[\]{}「」『』]", " ", text) + text = re.sub(r"[()()]", " ", text) + text = re.sub(r"\s+", " ", text).strip() + return text + + +def _strip_noise(value: str) -> str: + text = value + for phrase in sorted(NOISE_PHRASES, key=len, reverse=True): + text = text.replace(phrase.lower(), " ") + text = re.sub(r"\s+", " ", text).strip() + return text + + +def _tokenize(value: str) -> list[str]: + raw_tokens = re.findall(r"[a-z0-9]+|[\u4e00-\u9fff]+", value) + tokens: list[str] = [] + for token in raw_tokens: + if len(token) <= 1 and not token.isdigit(): + continue + tokens.append(token) + return tokens + + +def _known_brand_tokens(text: str) -> set[str]: + tokens: set[str] = set() + try: + from services.price_comparison import BRAND_ALIASES, BRAND_NORMALIZE_MAP + except Exception: + BRAND_ALIASES = {} + BRAND_NORMALIZE_MAP = {} + + for alias, canonical in BRAND_NORMALIZE_MAP.items(): + alias_norm = normalize_product_text(alias) + if alias_norm and alias_norm in text: + tokens.add(canonical) + tokens.update( + token for token in _tokenize(alias_norm) + if not re.fullmatch(r"[a-z]{1,2}", token) + ) + for related in BRAND_ALIASES.get(canonical, []): + tokens.update( + token for token in _tokenize(normalize_product_text(related)) + if not re.fullmatch(r"[a-z]{1,2}", token) + ) + + return {token for token in tokens if token and token not in GENERIC_TOKENS} + + +def _leading_brand_tokens(original: str, normalized: str) -> set[str]: + tokens: set[str] = set() + bracket_match = re.match(r"\s*[【\[]([^】\]]{2,40})[】\]]", original or "") + if bracket_match: + content = normalize_product_text(bracket_match.group(1)) + for token in _tokenize(_strip_noise(content)): + if token not in GENERIC_TOKENS: + tokens.add(token) + + leading = normalized[:48] + for token in _tokenize(leading): + if re.fullmatch(r"[a-z][a-z0-9\-']{2,}", token): + tokens.add(token) + return tokens + + +def _extract_product_type(text: str) -> Optional[str]: + for product_type, aliases in PRODUCT_TYPES.items(): + if any(alias.lower() in text for alias in aliases): + return product_type + return None + + +def _convert_volume(value: str, unit: str) -> Optional[tuple[str, float]]: + try: + number = float(value) + except (TypeError, ValueError): + return None + unit = unit.lower() + if unit in {"ml", "毫升"}: + return ("ml", number) + if unit == "l": + return ("ml", number * 1000) + if unit in {"g", "公克"}: + return ("g", number) + if unit == "kg": + return ("g", number * 1000) + return None + + +def _extract_specs(text: str) -> tuple[tuple[float, ...], tuple[float, ...], tuple[tuple[int, str], ...], Optional[int]]: + volumes_ml: list[float] = [] + weights_g: list[float] = [] + for match in re.finditer(r"(\d+(?:\.\d+)?)\s*(ml|毫升|l|g|公克|kg)", text, re.I): + converted = _convert_volume(match.group(1), match.group(2)) + if not converted: + continue + unit, number = converted + if unit == "ml": + volumes_ml.append(number) + else: + weights_g.append(number) + + counts: list[tuple[int, str]] = [] + for match in re.finditer(r"(\d+)\s*([入組瓶支條盒包片顆錠枚])", text): + counts.append((int(match.group(1)), match.group(2))) + for match in re.finditer(r"([一二兩雙三四五六七八九十])\s*([入組瓶支條盒包片顆錠枚])", text): + counts.append((CHINESE_COUNT[match.group(1)], match.group(2))) + for match in re.finditer(r"(?:x|乘)\s*(\d+)\s*([入組瓶支條盒包片顆錠枚])?", text, re.I): + unit = match.group(2) or "入" + counts.append((int(match.group(1)), unit)) + + total_piece_count = None + explicit_total = re.search(r"共\s*(\d+)\s*([包片顆錠枚])", text) + if explicit_total: + total_piece_count = int(explicit_total.group(1)) + else: + piece_counts = [count for count, unit in counts if unit in PIECE_UNITS] + container_counts = [count for count, unit in counts if unit in CONTAINER_UNITS] + if piece_counts and container_counts: + total_piece_count = max(piece_counts) * max(container_counts) + elif piece_counts: + total_piece_count = max(piece_counts) + + unique_counts = tuple(sorted(set(counts))) + return ( + tuple(sorted(set(volumes_ml))), + tuple(sorted(set(weights_g))), + unique_counts, + total_piece_count, + ) + + +def parse_product_identity(name: str) -> ProductIdentity: + normalized = normalize_product_text(name) + searchable = _strip_noise(normalized) + tokens = set(_tokenize(searchable)) + product_type = _extract_product_type(searchable) + brand_tokens = _known_brand_tokens(searchable) | _leading_brand_tokens(name, normalized) + + core_tokens = { + token + for token in tokens + if token not in GENERIC_TOKENS + and not token.isdigit() + and not re.fullmatch(r"\d+(ml|g|kg|l)?", token) + } + core_tokens -= brand_tokens + + volumes_ml, weights_g, counts, total_piece_count = _extract_specs(searchable) + return ProductIdentity( + original_name=name or "", + normalized_name=normalized, + searchable_name=searchable, + brand_tokens=frozenset(brand_tokens), + product_type=product_type, + tokens=frozenset(tokens), + core_tokens=frozenset(core_tokens), + volumes_ml=volumes_ml, + weights_g=weights_g, + counts=counts, + total_piece_count=total_piece_count, + ) + + +def _weighted_token_score(left: ProductIdentity, right: ProductIdentity) -> float: + left_tokens = left.brand_tokens | left.core_tokens + right_tokens = right.brand_tokens | right.core_tokens + if not left_tokens or not right_tokens: + return SequenceMatcher(None, left.searchable_name, right.searchable_name).ratio() * 0.6 + + def weight(token: str) -> float: + if token in left.brand_tokens or token in right.brand_tokens: + return 1.4 + if re.search(r"\d", token): + return 1.2 + if len(token) >= 4: + return 1.25 + return 1.0 + + overlap = left_tokens & right_tokens + overlap_weight = sum(weight(token) for token in overlap) + total_weight = sum(weight(token) for token in left_tokens) + sum(weight(token) for token in right_tokens) + dice = (2 * overlap_weight / total_weight) if total_weight else 0 + sequence = SequenceMatcher(None, " ".join(sorted(left_tokens)), " ".join(sorted(right_tokens))).ratio() + return min(1.0, dice * 0.72 + sequence * 0.28) + + +def _brand_score(left: ProductIdentity, right: ProductIdentity) -> tuple[float, bool, str | None]: + if not left.brand_tokens or not right.brand_tokens: + return 0.55, False, None + if left.brand_tokens & right.brand_tokens: + return 1.0, False, None + return 0.0, True, "brand_conflict" + + +def _close_number(left: float, right: float, tolerance: float = 0.04) -> bool: + denominator = max(abs(left), abs(right), 1.0) + return abs(left - right) / denominator <= tolerance + + +def _spec_component(left_values: Iterable[float], right_values: Iterable[float]) -> tuple[float, bool]: + left_tuple = tuple(left_values) + right_tuple = tuple(right_values) + if not left_tuple and not right_tuple: + return 0.55, False + if not left_tuple or not right_tuple: + return 0.45, False + for left_value in left_tuple: + if any(_close_number(left_value, right_value) for right_value in right_tuple): + return 1.0, False + return 0.0, True + + +def _count_score(left: ProductIdentity, right: ProductIdentity) -> tuple[float, bool]: + if left.total_piece_count and right.total_piece_count: + if left.total_piece_count == right.total_piece_count: + return 1.0, False + ratio = max(left.total_piece_count, right.total_piece_count) / max(min(left.total_piece_count, right.total_piece_count), 1) + return (0.0, True) if ratio >= 1.5 else (0.45, False) + if left.counts and right.counts: + if set(left.counts) & set(right.counts): + return 0.85, False + return 0.35, False + return 0.5, False + + +def _spec_score(left: ProductIdentity, right: ProductIdentity) -> tuple[float, bool, tuple[str, ...]]: + volume_score, volume_conflict = _spec_component(left.volumes_ml, right.volumes_ml) + weight_score, weight_conflict = _spec_component(left.weights_g, right.weights_g) + count_score, count_conflict = _count_score(left, right) + + available = [] + if left.volumes_ml or right.volumes_ml: + available.append(volume_score) + if left.weights_g or right.weights_g: + available.append(weight_score) + if left.counts or right.counts: + available.append(count_score) + if not available: + return 0.55, False, () + + score = sum(available) / len(available) + conflicts = [] + if volume_conflict: + conflicts.append("volume_conflict") + if weight_conflict: + conflicts.append("weight_conflict") + if count_conflict: + conflicts.append("count_conflict") + return score, bool(conflicts), tuple(conflicts) + + +def score_marketplace_match( + momo_name: str, + competitor_name: str, + momo_price: Optional[float] = None, + competitor_price: Optional[float] = None, +) -> MatchDiagnostics: + left = parse_product_identity(momo_name) + right = parse_product_identity(competitor_name) + + brand_score, brand_conflict, brand_reason = _brand_score(left, right) + token_score = _weighted_token_score(left, right) + spec_score, spec_conflict, spec_reasons = _spec_score(left, right) + sequence_score = SequenceMatcher(None, left.searchable_name, right.searchable_name).ratio() + if left.product_type and right.product_type: + type_score = 1.0 if left.product_type == right.product_type else 0.0 + else: + type_score = 0.55 + + reasons = [] + if brand_reason: + reasons.append(brand_reason) + reasons.extend(spec_reasons) + if left.product_type and right.product_type and left.product_type != right.product_type: + reasons.append("type_conflict") + + hard_veto = brand_conflict or spec_conflict + if left.product_type and right.product_type and left.product_type != right.product_type and token_score < 0.55: + hard_veto = True + + price_penalty = 0.0 + try: + if momo_price and competitor_price: + ratio = float(competitor_price) / max(float(momo_price), 1.0) + if (ratio < 0.3 or ratio > 3.2) and token_score < 0.78: + price_penalty = 0.12 + reasons.append("price_ratio_extreme") + elif (ratio < 0.48 or ratio > 2.2) and token_score < 0.68: + price_penalty = 0.06 + reasons.append("price_ratio_wide") + except (TypeError, ValueError, ZeroDivisionError): + price_penalty = 0.0 + + score = ( + brand_score * 0.20 + + token_score * 0.36 + + spec_score * 0.25 + + sequence_score * 0.12 + + type_score * 0.07 + - price_penalty + ) + + if token_score >= 0.72 and spec_score >= 0.82 and not brand_conflict: + score += 0.08 + if hard_veto: + score = min(score, 0.32) + score = max(0.0, min(1.0, score)) + + return MatchDiagnostics( + score=round(score, 3), + brand_score=round(brand_score, 3), + token_score=round(token_score, 3), + spec_score=round(spec_score, 3), + sequence_score=round(sequence_score, 3), + type_score=round(type_score, 3), + price_penalty=round(price_penalty, 3), + hard_veto=hard_veto, + reasons=tuple(reasons), + ) + + +def build_search_terms(name: str, max_terms: int = 3) -> list[str]: + identity = parse_product_identity(name) + terms: list[str] = [] + + brand_part = " ".join(sorted(identity.brand_tokens))[:24] + core = " ".join(sorted(identity.core_tokens, key=lambda token: (-len(token), token))[:4]) + specs = [] + if identity.volumes_ml: + specs.append(f"{int(identity.volumes_ml[0])}ml") + if identity.weights_g: + specs.append(f"{int(identity.weights_g[0])}g") + if identity.total_piece_count: + specs.append(f"{identity.total_piece_count}包") + + for value in ( + " ".join(part for part in (brand_part, core, " ".join(specs)) if part), + " ".join(part for part in (brand_part, core) if part), + identity.searchable_name, + ): + cleaned = re.sub(r"[^\w\u4e00-\u9fff]+", " ", value) + cleaned = re.sub(r"\s+", " ", cleaned).strip() + if cleaned and cleaned not in terms: + terms.append(cleaned[:42]) + if len(terms) >= max_terms: + break + + return terms diff --git a/services/openclaw_strategist_service.py b/services/openclaw_strategist_service.py index c0e5010..99181a9 100644 --- a/services/openclaw_strategist_service.py +++ b/services/openclaw_strategist_service.py @@ -555,6 +555,8 @@ def _fetch_competitor_summary() -> Dict[str, Any]: FROM price_records ORDER BY product_id, timestamp DESC ) pr ON pr.product_id = p.id WHERE cp.expires_at > NOW() + AND COALESCE(cp.match_score, 0) >= 0.62 + AND COALESCE(cp.tags, '[]'::jsonb) ? 'identity_v2' """)).fetchone() if row and row[0]: return { diff --git a/services/pchome_crawler.py b/services/pchome_crawler.py index c4cf04f..81af9b1 100644 --- a/services/pchome_crawler.py +++ b/services/pchome_crawler.py @@ -483,12 +483,36 @@ def find_best_match(keyword: str, momo_price: float) -> Optional[dict]: 在 PChome 搜尋最接近 keyword 的商品並回傳最佳匹配。 Returns: - {'name', 'price', 'url', 'price_diff'} or None + {'name', 'price', 'url', 'price_diff', 'match_score'} or None """ results = search_pchome(keyword, limit=5) if not results: return None - best = min(results, key=lambda r: abs(r['price'] - momo_price)) + + try: + from services.marketplace_product_matcher import score_marketplace_match + best = None + best_score = 0.0 + best_diagnostics = None + for result in results: + diagnostics = score_marketplace_match( + keyword, + result.get('name', ''), + momo_price=momo_price, + competitor_price=result.get('price'), + ) + if diagnostics.score > best_score: + best = result + best_score = diagnostics.score + best_diagnostics = diagnostics + if not best or best_score < 0.62: + return None + best['match_score'] = best_score + best['match_reasons'] = list(getattr(best_diagnostics, 'reasons', ()) or ()) + except Exception: + logger.warning("[PChome] identity matcher unavailable, fallback to price distance", exc_info=True) + best = min(results, key=lambda r: abs(r['price'] - momo_price)) + best['price_diff'] = best['price'] - momo_price return best @@ -535,6 +559,7 @@ def compare_product( 'pc_url': match.get('url', ''), 'price_diff': diff, 'price_diff_pct': pct, + 'match_score': match.get('match_score', 0), }) except Exception as e: logger.warning("[PChome] compare_product error: %s", e) @@ -569,7 +594,14 @@ def batch_compare_top( sql = f""" SELECT p.name, p.i_code, - COALESCE(SUM(s.revenue), 0) AS total_rev + COALESCE(SUM(s.revenue), 0) AS total_rev, + ( + SELECT pr.price + FROM price_records pr + WHERE pr.product_id = p.id + ORDER BY pr.timestamp DESC, pr.id DESC + LIMIT 1 + ) AS momo_price FROM products p JOIN daily_sales s ON p.id = s.product_id {date_filter} @@ -581,9 +613,12 @@ def batch_compare_top( rows = conn.execute(_text(sql), params).fetchall() for row in rows: - name, icode, rev = row[0], row[1], float(row[2] or 0) + name, icode, rev, momo_price = row[0], row[1], float(row[2] or 0), float(row[3] or 0) + if momo_price <= 0: + logger.warning("[PChome] skip %s because latest momo price is missing; total_rev=%s", icode, rev) + continue try: - cmp = compare_product(name, rev / max(1, 1), icode) + cmp = compare_product(name, momo_price, icode) results.append(cmp) time.sleep(0.4) # 限速 except Exception as e: @@ -741,4 +776,3 @@ def fmt_daily_report(results: List[dict], date_str: str = '') -> str: lines.append("_資料來源:PChome 24h 即時爬取_") return "\n".join(lines) - diff --git a/services/ppt_generator.py b/services/ppt_generator.py index f1d6938..8b642ec 100644 --- a/services/ppt_generator.py +++ b/services/ppt_generator.py @@ -2530,8 +2530,8 @@ def generate_competitor_ppt(period_label: str, db_data: dict, ai_text: str) -> s results = db_data.get("results", []) found = [r for r in results if r.get("found")] - pc_wins = [r for r in found if r.get("price_diff", 0) > 10] - mo_wins = [r for r in found if r.get("price_diff", 0) < -10] + pc_wins = [r for r in found if r.get("price_diff", 0) < -10] + mo_wins = [r for r in found if r.get("price_diff", 0) > 10] tie = [r for r in found if abs(r.get("price_diff", 0)) <= 10] not_found = [r for r in results if not r.get("found")] total = len(results) @@ -2563,9 +2563,9 @@ def generate_competitor_ppt(period_label: str, db_data: dict, ai_text: str) -> s _kpi_card(s2, i * (card_w + card_gap) + 0.5, card_t, card_w, 3.2, col, lbl, val, sub) trend_color = _RED_WARN if avg_pct < -3 else (_BRAND_OG if avg_pct > 3 else "FFA726") - trend_icon = "⚠️ momo 整體偏貴" if avg_pct < -3 else ("✅ momo 整體具優勢" if avg_pct > 3 else "➖ 整體持平") + trend_icon = "⚠️ PChome 整體較便宜" if avg_pct < -3 else ("✅ momo 整體具價格優勢" if avg_pct > 3 else "➖ 整體持平") _add_rect(s2, 0.5, 5.3, W - 1, 0.85, trend_color) - _add_text(s2, f"整體定價態勢:{trend_icon} 平均價差 {avg_pct:+.1f}%(正值=momo偏貴)", + _add_text(s2, f"整體定價態勢:{trend_icon} 平均價差 {avg_pct:+.1f}%(PChome - momo)", 0.7, 5.35, W - 1.4, 0.75, bold=True, size=13, color=_WHITE) bar_data = [ @@ -2608,8 +2608,8 @@ def generate_competitor_ppt(period_label: str, db_data: dict, ai_text: str) -> s x = 0.3 diff = r.get("price_diff", 0) pct = r.get("price_diff_pct", 0) - diff_c = _RED_WARN if diff < -10 else (_GREEN_KPI if diff > 10 else _DARK_TEXT) - trend = "📈 PChome貴" if diff > 10 else ("📉 momo貴" if diff < -10 else "➖") + diff_c = _GREEN_KPI if diff < -10 else (_RED_WARN if diff > 10 else _DARK_TEXT) + trend = "PChome便宜" if diff < -10 else ("momo便宜" if diff > 10 else "持平") cells = [ (str(ri + 1), _DARK_TEXT), (r.get("momo_name", "")[:24], _DARK_TEXT), diff --git a/templates/daily_sales.html b/templates/daily_sales.html index 9b13736..2877549 100644 --- a/templates/daily_sales.html +++ b/templates/daily_sales.html @@ -318,6 +318,62 @@ + + {% if competitor_intel %} + {% set comp_trend = competitor_intel.trend %} + {% set comp_coverage = competitor_intel.coverage %} +
目前沒有高信心 PChome 價格壓力商品。