From e68c6c22d18f6fc6f1a9d0fac1866765a62be0f1 Mon Sep 17 00:00:00 2001 From: OoO Date: Mon, 1 Jun 2026 13:09:15 +0800 Subject: [PATCH] =?UTF-8?q?V10.551=20=E6=94=B6=E6=96=82=E6=9C=AA=E7=9F=A5?= =?UTF-8?q?=E6=96=B0=E9=AE=AE=E5=BA=A6=E5=88=B7=E6=96=B0=E5=85=A5=E5=8F=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- TODO_NEXT_STEPS.txt | 1 + config.py | 2 +- docs/memory/history_logs.md | 1 + services/competitor_price_feeder.py | 126 ++++++++++-------- ...t_competitor_match_attempts_persistence.py | 21 +++ 5 files changed, 97 insertions(+), 54 deletions(-) diff --git a/TODO_NEXT_STEPS.txt b/TODO_NEXT_STEPS.txt index 43ec96e..5ab3e01 100644 --- a/TODO_NEXT_STEPS.txt +++ b/TODO_NEXT_STEPS.txt @@ -4,6 +4,7 @@ ================================================================================ 【已完成】 + - V10.551 收斂未知新鮮度刷新與補抓排序:`_fetch_expired_identity_skus()` / `_fetch_expired_identity_recovery_skus()` 將 `expires_at IS NULL` 視為必須刷新或可搜尋救援的未知新鮮度 identity,和 V10.549「未知新鮮度不算可決策覆蓋率」口徑對齊;兩條路徑改用 `JOIN LATERAL` 取最新 MOMO 價,移除 product-wide window scan。`_fetch_unmatched_priority_skus()` 也改用 lateral 最新價,並優先重搜低風險 `no_result / refresh_no_result`,讓 V10.550 的安全召回詞先用在最可能被救回的商品。 - V10.550 補安全搜尋召回詞:`_build_variant_recall_search_plan()` 對低風險穩定品類新增 `品牌 + 品類` 的補搜尋詞,讓 `no_result / refresh_no_result` 更有機會找到 PChome 候選後再交給 matcher 安全判斷;美甲片、指甲油、唇彩、香氛/精油、粉底、防曬、任選/色號/款式等高 variant 風險商品不走通用召回,DASHING DIVA 仍只走既有 line-specific recall + sort fallback。此變更不改 `MIN_MATCH_SCORE`、hard veto、fresh-search write safety 或 stronger existing match 覆寫保護。 - V10.549 收斂比價新鮮度 KPI 口徑:coverage cache 升到 v10,`expires_at IS NULL` 不再算進「可用比價 / decision ready」,改拆成 `unknown_freshness_matches` / `unknown_freshness_count`,避免沒有到期時間的舊資料被當成可直接決策的新鮮價格。Dashboard / daily / growth 同步顯示未知新鮮度與「未形成有效身份配對」,並把 PChome/MOMO 價格方向文案改成 `PChome 價格壓力` / `MOMO 價格優勢`,降低誤讀。 - V10.548 接線更多 focused exact 舊候選回刷:把 matcher 已驗證可安全走 total-price 的 3W CLINIC 膠原蛋白粉底液 50ml x2、花美水 Moisture/Inclear 1.7g x3、KUSSEN 寶寶益菌屁屁膏 50ml 3 入、Lab52 齒妍堂嬰幼兒/汪汪隊牙刷 2 入接進 `_fetch_retryable_candidate_skus()` focused true-low / rescore 窄門。這只擴大「舊候選可被新版 matcher 重評」的入口,不改 `MIN_MATCH_SCORE`、hard veto、auto price write safety 或既有覆寫保護。 diff --git a/config.py b/config.py index 8e8e445..c5734f3 100644 --- a/config.py +++ b/config.py @@ -402,7 +402,7 @@ YOUTUBE_API_KEY = os.getenv('YOUTUBE_API_KEY', '') # ========================================== # 系統版本與路徑 # ========================================== -SYSTEM_VERSION = "V10.550" +SYSTEM_VERSION = "V10.551" LOG_FILE_PATH = os.path.join(BASE_DIR, 'logs/system.log') public_url = PUBLIC_URL # 用於模板顯示 diff --git a/docs/memory/history_logs.md b/docs/memory/history_logs.md index e632253..d94b8e4 100644 --- a/docs/memory/history_logs.md +++ b/docs/memory/history_logs.md @@ -13,6 +13,7 @@ ## 📅 詳細更新日誌 (考古存檔) ### 2026-06-01:PChome 比價新鮮度操作閉環 +- **V10.551 未知新鮮度刷新與補抓排序收斂**: `expires_at IS NULL` 的 identity_v2 價格現在會被 `_fetch_expired_identity_skus()` 與 `_fetch_expired_identity_recovery_skus()` 視為需要刷新 / 可搜尋救援的未知新鮮度資料,避免 V10.549 已排除出可決策覆蓋率後卻沒有刷新入口;兩條路徑都改用 `JOIN LATERAL` 取最新 MOMO 價,不再做 product-wide window scan。`_fetch_unmatched_priority_skus()` 同步改為 lateral latest price,且把低風險 `no_result / refresh_no_result` 排到補抓前段,讓安全召回詞優先投入最可能回收的 SKU。 - **V10.550 安全搜尋召回詞補強**: `competitor_price_feeder` 在既有精準搜尋詞之外,對低風險穩定品類補上一組 `品牌 + 品類` recall keyword,提升 `no_result / refresh_no_result` 找到候選的機率;高 variant 風險商品如美甲片、指甲油、唇彩、香氛/精油、粉底、防曬與含任選/色號/款式/香味的商品不走通用召回。DASHING DIVA 仍保留既有 line-specific recall 與 PChome sort fallback;本次不更動 `MIN_MATCH_SCORE`、hard veto、auto write safety 或 stronger existing match 保護。 - **V10.549 比價新鮮度 KPI 口徑收斂**: `fetch_competitor_coverage()` cache 升到 v10,`expires_at IS NULL` 不再混入 `fresh_matches` / `decision_ready_rate`,改拆成 `unknown_freshness_matches` 與 `unknown_freshness_rate`,讓「可用比價覆蓋率」只代表有明確未過期時間的 identity 價格。Dashboard、daily、growth 同步顯示未知新鮮度與「未形成有效身份配對」,第一屏資料時間改看最新有效 PChome 價格抓取,並把價格方向文案改為 `PChome 價格壓力` / `MOMO 價格優勢`。 - **V10.548 focused exact 舊候選回刷接線**: `_fetch_retryable_candidate_skus()` 的 focused true-low / rescore 窄門新增 3W CLINIC 膠原蛋白粉底液 50ml x2、花美水 Moisture/Inclear 1.7g x3、KUSSEN 寶寶益菌屁屁膏 50ml 3 入、Lab52 齒妍堂嬰幼兒 / 汪汪隊牙刷 2 入。這些品線在 matcher 測試中已是 `exact / total_price / price_alert_exact`,本次只讓舊 `true_low_confidence` / `rescore_accepted_current` 候選能被新版 matcher 重新判斷;仍不放寬 `MIN_MATCH_SCORE`、hard veto、auto write safety 與 stronger existing match 保護。 diff --git a/services/competitor_price_feeder.py b/services/competitor_price_feeder.py index 6ae7b00..28af955 100644 --- a/services/competitor_price_feeder.py +++ b/services/competitor_price_feeder.py @@ -94,6 +94,7 @@ GENERIC_RECALL_BLOCK_TERMS = ( "防曬", "護手霜", ) +GENERIC_RECALL_BLOCK_NAME_PATTERN = "|".join(re.escape(term) for term in GENERIC_RECALL_BLOCK_TERMS) BROWSE_SH_DIAGNOSTIC_ENABLED = os.getenv("PCHOME_FEEDER_BROWSE_SH_DIAGNOSTIC_ENABLED", "true").lower() in {"1", "true", "yes", "on"} BROWSE_SH_EXECUTE_ENABLED = os.getenv("PCHOME_FEEDER_BROWSE_SH_EXECUTE_ENABLED", "false").lower() in {"1", "true", "yes", "on"} BROWSE_SH_TIMEOUT_SECONDS = int(os.getenv("PCHOME_FEEDER_BROWSE_SH_TIMEOUT", "20")) @@ -1205,40 +1206,61 @@ class CompetitorPriceFeeder: 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' + WITH latest_attempt AS ( + SELECT DISTINCT ON (cma.sku) + cma.sku, + cma.attempt_status, + cma.attempted_at + FROM competitor_match_attempts cma + WHERE cma.source = 'pchome' + ORDER BY cma.sku, cma.attempted_at DESC, cma.id DESC ) SELECT - lm.product_id, - lm.sku, - lm.name, - lm.category, - lm.momo_price - FROM latest_momo lm + p.id AS product_id, + p.i_code AS sku, + p.name, + p.category, + latest_price.price AS momo_price, + COALESCE(la.attempt_status, 'never_attempted') AS latest_attempt_status + FROM products p + JOIN LATERAL ( + SELECT pr.price + FROM price_records pr + WHERE pr.product_id = p.id + ORDER BY pr.timestamp DESC, pr.id DESC + LIMIT 1 + ) latest_price ON TRUE + LEFT JOIN latest_attempt la + ON la.sku = p.i_code LEFT JOIN competitor_prices cp - ON cp.sku = lm.sku + ON cp.sku = p.i_code AND cp.source = 'pchome' - AND (cp.expires_at IS NULL OR cp.expires_at > CURRENT_TIMESTAMP) + AND 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 + WHERE p.status = 'ACTIVE' AND cp.sku IS NULL - ORDER BY lm.momo_price DESC NULLS LAST, lm.sku + ORDER BY + CASE + WHEN la.attempt_status IN ('no_result', 'refresh_no_result') + AND p.name !~* :generic_recall_block_pattern THEN 0 + WHEN la.sku IS NULL THEN 1 + WHEN la.attempt_status IN ('no_result', 'refresh_no_result') THEN 2 + WHEN la.attempt_status IN ('low_score', 'refresh_low_score', 'recoverable_low_score') THEN 3 + ELSE 4 + END, + latest_price.price DESC NULLS LAST, + p.i_code LIMIT :limit """) with self.engine.connect() as conn: rows = conn.execute( sql, - {"limit": max(1, min(int(limit), 300)), "match_score_floor": MIN_MATCH_SCORE}, + { + "limit": max(1, min(int(limit), 300)), + "match_score_floor": MIN_MATCH_SCORE, + "generic_recall_block_pattern": GENERIC_RECALL_BLOCK_NAME_PATTERN, + }, ).fetchall() return [dict(r._mapping) for r in rows] @@ -1884,8 +1906,7 @@ class CompetitorPriceFeeder: WHERE cp.source = 'pchome' AND cp.competitor_product_id IS NOT NULL AND cp.competitor_product_id <> '' - AND cp.expires_at IS NOT NULL - AND cp.expires_at <= CURRENT_TIMESTAMP + 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' AND ( @@ -1898,7 +1919,10 @@ class CompetitorPriceFeeder: ) AND COALESCE(cp.match_diagnostic_json->>'comparison_mode', 'exact_identity') = 'exact_identity' AND COALESCE(cp.hard_veto, false) = false - ORDER BY cp.expires_at ASC, cp.sku + ORDER BY + CASE WHEN cp.expires_at IS NULL THEN 0 ELSE 1 END, + cp.expires_at ASC NULLS FIRST, + cp.sku LIMIT :candidate_limit ) SELECT @@ -1924,7 +1948,8 @@ class CompetitorPriceFeeder: LIMIT 1 ) latest_price ON TRUE ORDER BY - ec.expires_at ASC, + CASE WHEN ec.expires_at IS NULL THEN 0 ELSE 1 END, + ec.expires_at ASC NULLS FIRST, latest_price.price DESC NULLS LAST, p.i_code """) @@ -1957,41 +1982,36 @@ class CompetitorPriceFeeder: 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, + p.id AS product_id, + p.i_code AS sku, + p.name, + p.category, + latest_price.price AS momo_price, cp.competitor_product_id, cp.competitor_product_name, cp.match_score, cp.expires_at - FROM latest_momo lm - JOIN competitor_prices cp - ON cp.sku = lm.sku - AND cp.source = 'pchome' + FROM competitor_prices cp + JOIN products p + ON p.i_code = cp.sku + AND p.status = 'ACTIVE' + JOIN LATERAL ( + SELECT pr.price + FROM price_records pr + WHERE pr.product_id = p.id + ORDER BY pr.timestamp DESC, pr.id DESC + LIMIT 1 + ) latest_price ON TRUE + WHERE cp.source = 'pchome' AND cp.competitor_product_id IS NOT NULL AND cp.competitor_product_id <> '' - AND cp.expires_at IS NOT NULL - AND cp.expires_at <= CURRENT_TIMESTAMP + 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 ORDER BY CASE + WHEN cp.expires_at IS NULL THEN -1 WHEN ( COALESCE(cp.tags, '[]'::jsonb) ? 'price_basis_total_price' OR cp.match_diagnostic_json->>'price_basis' = 'total_price' @@ -2003,9 +2023,9 @@ class CompetitorPriceFeeder: THEN 0 ELSE 1 END, - cp.expires_at ASC, - lm.momo_price DESC NULLS LAST, - lm.sku + cp.expires_at ASC NULLS FIRST, + latest_price.price DESC NULLS LAST, + p.i_code LIMIT :limit """) with self.engine.connect() as conn: diff --git a/tests/test_competitor_match_attempts_persistence.py b/tests/test_competitor_match_attempts_persistence.py index 83de1fd..aa313b1 100644 --- a/tests/test_competitor_match_attempts_persistence.py +++ b/tests/test_competitor_match_attempts_persistence.py @@ -121,8 +121,29 @@ def test_competitor_feeder_persists_all_match_attempt_outcomes(): assert "expired_competitor AS" in recovery_source assert "JOIN LATERAL" in recovery_source assert "ORDER BY pr.timestamp DESC, pr.id DESC" in recovery_source + assert "cp.expires_at IS NULL OR cp.expires_at <= CURRENT_TIMESTAMP" in recovery_source + assert "cp.expires_at ASC NULLS FIRST" in recovery_source assert "ROW_NUMBER() OVER (PARTITION BY p.id" not in recovery_source assert "candidate_limit" in recovery_source + unmatched_source = source.split("def _fetch_unmatched_priority_skus", 1)[1].split( + "def _fetch_retryable_candidate_skus", 1 + )[0] + assert "latest_attempt AS" in unmatched_source + assert "JOIN LATERAL" in unmatched_source + assert "ORDER BY pr.timestamp DESC, pr.id DESC" in unmatched_source + assert "cp.expires_at > CURRENT_TIMESTAMP" in unmatched_source + assert "COALESCE(la.attempt_status, 'never_attempted')" in unmatched_source + assert "generic_recall_block_pattern" in unmatched_source + assert "p.name !~* :generic_recall_block_pattern" in unmatched_source + assert "ROW_NUMBER() OVER (PARTITION BY p.id" not in unmatched_source + expired_source = source.split("def _fetch_expired_identity_skus", 1)[1].split( + "def _upsert_competitor_price", 1 + )[0] + assert "JOIN LATERAL" in expired_source + assert "ORDER BY pr.timestamp DESC, pr.id DESC" in expired_source + assert "cp.expires_at IS NULL OR cp.expires_at <= CURRENT_TIMESTAMP" in expired_source + assert "cp.expires_at ASC NULLS FIRST" in expired_source + assert "ROW_NUMBER() OVER (PARTITION BY p.id" not in expired_source retryable_source = source.split("def _fetch_retryable_candidate_skus", 1)[1].split( "def preview_retryable_candidate_revalidation", 1 )[0]