diff --git a/TODO_NEXT_STEPS.txt b/TODO_NEXT_STEPS.txt index 0ee7679..862463c 100644 --- a/TODO_NEXT_STEPS.txt +++ b/TODO_NEXT_STEPS.txt @@ -4,6 +4,7 @@ ================================================================================ 【已完成】 + - V10.464 補 rescore audit 精準 SKU pilot:`audit_competitor_match_attempt_rescore.py --sku` 可只掃指定 SKU,再搭配 `--apply-accepted` 只把通過新版 matcher 的目標 SKU 追加到 `rescore_accepted_current` 人工覆核隊列,不寫正式價格表。 - V10.463 補 DR.WU / 達爾膚品牌 alias:同規格 `DR.WU 達爾膚` 與 `DR.WU` 候選不再被當成 brandless identity review,會以既有 exact_identity / total_price / price_alert_exact 閘門處理;未調整 `MIN_MATCH_SCORE`,保留 variant / hard veto 保護。 - V10.462 進一步收斂 PChome 補抓 UI 語意:Dashboard 區塊標題改為「PChome 補抓產線」,AI 中樞按鈕、前端確認與 API 訊息改為「補抓未搜尋 / 未搜尋補抓」,避免操作員把尚未搜尋的工作誤判成已有候選待審。 - V10.461 修正商品看板 PChome 補抓優先清單的狀態語意:尚未進入搜尋/補抓的品項改顯示「尚未搜尋」與「尚未進入 PChome 補抓」,並補前端守門測試禁止回退成籠統「待比對」,避免操作員把未搜尋誤判成已有候選待人工覆核。 diff --git a/config.py b/config.py index 4cbf91d..8f53c9c 100644 --- a/config.py +++ b/config.py @@ -325,7 +325,7 @@ YOUTUBE_API_KEY = os.getenv('YOUTUBE_API_KEY', '') # ========================================== # 系統版本與路徑 # ========================================== -SYSTEM_VERSION = "V10.463" +SYSTEM_VERSION = "V10.464" 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 2044dc6..2233ef6 100644 --- a/docs/AI_INTELLIGENCE_MODULE_SOT.md +++ b/docs/AI_INTELLIGENCE_MODULE_SOT.md @@ -2,7 +2,7 @@ > **最後更新**: 2026-05-24 (台北時間) > **狀態**: 🟢 四 AI Agent 自動化閉環已落地;LLM 路由紅線升級為 Ollama-first 三主機級聯,Gemini 備援預設關閉 -> **適用版本**: V10.463 +> **適用版本**: V10.464 --- @@ -388,6 +388,7 @@ LEFT JOIN competitor_prices cp - 套組/買送/件數不同但品牌、核心商品線與單一基礎規格一致時,matcher 必須回傳 `comparison_mode='unit_comparable'` 與 `unit_comparable` reason;Feeder 只能寫入 `competitor_match_attempts.attempt_status='unit_comparable'` 或 `refresh_unit_comparable`,不得寫入 `competitor_prices`。Dashboard 與 `competitor_intel_repository` 必須用 `build_unit_price_comparison()` 產生每 ml / 每 g / 每入單位價證據,讓 PPT / AI 報表可說明「需單位價比較」而不是把總價當同款價差。商品看板在正式配對尚未成立時,仍必須顯示最佳候選 PChome 商品名稱、候選價與「候選價,需單位換算」說明,讓人工覆核可直接看見下一步;daily/growth、PPT 與 OpenClaw 摘要不得自建查詢,需消費 `fetch_competitor_review_queue()` 與 coverage 的 `unit_comparable_count`。若任一側含多個不同容量/重量規格,視為多品項套組,不可進 `unit_comparable`。 - PChome feeder 的外部 request timeout 由 `PCHOME_FEEDER_TIMEOUT` 控制,預設 12 秒;排程不得因單一 PChome 搜尋 API timeout 被拖到數分鐘。 - 品牌 alias 屬於正向身份證據,不是門檻放寬;`DR.WU / DR WU / DRWU / 達爾膚` 這類同品牌中英混寫必須正規化後再進 matcher,避免同規格真同款被誤降成 brandless identity review。 +- 近門檻 rescore pilot 必須支援明確 SKU 篩選;`audit_competitor_match_attempt_rescore.py --sku ` 可只重算指定 SKU,避免為了小批次驗證而掃整批 `true_low_confidence`。 - 商品看板的 PChome 狀態必須把 matcher 診斷原因翻成可行動語意:品牌不符已排除、規格不符已排除、補充包不相容、組合規格不相容、系列不符已排除、需單位價比較、低信心待補強等,不可只顯示籠統「待比對」或「身份否決」。 - PChome 補抓產線與 priority list 若尚未進入搜尋/補抓,必須顯示「PChome 補抓產線」、「尚未搜尋」與「尚未進入 PChome 補抓」,不得使用「待比對」這類會被誤解成已有候選待人工審核的字眼。 - 商品看板、PChome review queue 與 `/api/export/excel/pchome-review` 必須優先讀取 `match_diagnostic_json.reasons` 並轉成操作員可讀標籤;文字版 `error_message` 只作 legacy fallback。商品列的 PChome 狀態摘要也必須使用同一套專業標籤,避免 overview 顯示「妝效質地不同」但列表仍顯示籠統身份不符。新增 matcher reason 時需同步更新 `MATCH_DIAGNOSTIC_REASON_LABELS` 與 dashboard 狀態翻譯,避免 UI 顯示 `makeup_finish_conflict` 這類 machine code。PChome 標題缺品牌但有窄範圍 exact identity anchor 的商品,只能透過具名 brandless recovery 進 manual-review identity;多色任選 / 單一色號 gap 必須標記 `variant_selection_review`,並從 `recoverable_low_score` 降回 `true_low_confidence`,不得自動批次寫正式價差。 diff --git a/docs/memory/current_execution_queue_20260524.md b/docs/memory/current_execution_queue_20260524.md index b20874c..8351110 100644 --- a/docs/memory/current_execution_queue_20260524.md +++ b/docs/memory/current_execution_queue_20260524.md @@ -48,6 +48,7 @@ ## 2.1 近門檻 / 高信心待審 matcher 補強 +- 2026-05-25 08:30 CST 起,rescore audit 支援 `--sku` repeatable 精準篩選;production pilot 可只指定 3-10 個 SKU 執行 read-only audit 或 `--apply-accepted`,避免寬範圍掃描誤把不同 cohort 混在同一次驗證。 - 2026-05-25 08:25 CST 起,`DR.WU / DR WU / DRWU / 達爾膚` 視為同一品牌 alias;正式樣本中的 DR.WU 玻尿酸保濕精華乳 50ML、2入組與杏仁酸亮白煥膚精華 18% 30ML 2入組,在不調整全域門檻下可由 brandless identity review 回到 exact total-price lane。 ## 3. 12 Agent 決策信封整合 diff --git a/docs/memory/history_logs.md b/docs/memory/history_logs.md index c8feda6..2832ee5 100644 --- a/docs/memory/history_logs.md +++ b/docs/memory/history_logs.md @@ -13,6 +13,7 @@ ## 📅 詳細更新日誌 (考古存檔) ### 2026-05-24:PChome 近門檻身份回收第二輪 +- **V10.464 Rescore SKU pilot 篩選**: `audit_competitor_match_attempt_rescore.py` 與 `fetch_match_attempt_rescore_rows()` 增加 `--sku` / `skus` 篩選,可針對 DR.WU 這類明確 cohort 做 3-10 筆精準 materialize,不必為了 pilot 掃整批 `true_low_confidence`。 - **V10.463 DR.WU / 達爾膚品牌 alias**: `marketplace_product_matcher` 補 `DR.WU / DR WU / DRWU / 達爾膚` 正規化,讓正式樣本中同規格玻尿酸保濕精華乳、杏仁酸亮白煥膚精華不再因品牌 token 不同被降成 brandless identity review;測試鎖住 exact / total_price / price_alert_exact。 - **V10.462 PChome 補抓 UI 語意收斂**: Dashboard 補抓區塊標題、AI 中樞按鈕、前端 confirm 與 API 回覆全數改用「PChome 補抓產線 / 補抓未搜尋 / 未搜尋補抓」,避免「待比對」殘留在操作入口,和低信心待人工覆核混淆。 - **V10.461 Dashboard 未搜尋語意修正**: 商品看板未進入 PChome 搜尋/補抓的品項不再顯示籠統「待比對」,改成「尚未搜尋」與「尚未進入 PChome 補抓」,避免操作員誤以為已有候選但尚未人工覆核;前端守門測試鎖住不得回退成舊文案。 diff --git a/scripts/audit_competitor_match_attempt_rescore.py b/scripts/audit_competitor_match_attempt_rescore.py index 86ceb13..e5d2529 100755 --- a/scripts/audit_competitor_match_attempt_rescore.py +++ b/scripts/audit_competitor_match_attempt_rescore.py @@ -63,6 +63,7 @@ def main(argv: list[str] | None = None) -> int: parser.add_argument("--limit", type=int, default=100) parser.add_argument("--sample-limit", type=int, default=20) parser.add_argument("--min-score", type=float, default=MIN_MATCH_SCORE) + parser.add_argument("--sku", action="append", dest="skus", help="Limit DB scan to a specific SKU; repeatable.") parser.add_argument( "--include-historical-candidates", action="store_true", @@ -127,6 +128,7 @@ def main(argv: list[str] | None = None) -> int: source=args.source, statuses=statuses, reason_filter=args.reason_filter or None, + skus=args.skus, limit=args.limit, latest_sku_only=not args.include_historical_candidates, ) @@ -152,6 +154,7 @@ def main(argv: list[str] | None = None) -> int: source=args.source, statuses=statuses, reason_filter=args.reason_filter or None, + skus=args.skus, limit=args.limit, min_score=args.min_score, sample_limit=args.sample_limit, diff --git a/services/competitor_match_attempt_rescore_audit.py b/services/competitor_match_attempt_rescore_audit.py index 1c4cec2..2b28c4c 100644 --- a/services/competitor_match_attempt_rescore_audit.py +++ b/services/competitor_match_attempt_rescore_audit.py @@ -514,6 +514,7 @@ def fetch_match_attempt_rescore_rows( source: str = "pchome", statuses: Sequence[str] = DEFAULT_RESCAN_STATUSES, reason_filter: str | None = None, + skus: Sequence[str] | None = None, limit: int = 100, latest_sku_only: bool = True, ) -> list[dict[str, Any]]: @@ -525,6 +526,8 @@ def fetch_match_attempt_rescore_rows( already moved to a newer review state. """ status_values = tuple(status for status in statuses if status) or DEFAULT_RESCAN_STATUSES + sku_values = tuple(str(sku).strip() for sku in (skus or ()) if str(sku).strip()) + sku_predicate = "AND sku IN :skus" if sku_values else "" if latest_sku_only: reason_predicate = "AND diagnostic_codes::text LIKE :reason_filter" if ( @@ -576,6 +579,7 @@ def fetch_match_attempt_rescore_rows( WHERE rn = 1 AND attempt_status IN :statuses {reason_predicate} + {sku_predicate} ORDER BY attempted_at DESC{nulls_last} LIMIT :limit """).bindparams(bindparam("statuses", expanding=True)) @@ -602,6 +606,7 @@ def fetch_match_attempt_rescore_rows( WHERE source = :source AND attempt_status IN :statuses {reason_predicate} + {sku_predicate} ORDER BY sku, best_competitor_product_id, attempted_at DESC NULLS LAST, id DESC LIMIT :limit """).bindparams(bindparam("statuses", expanding=True)) @@ -633,6 +638,7 @@ def fetch_match_attempt_rescore_rows( WHERE source = :source AND attempt_status IN :statuses {reason_predicate} + {sku_predicate} ) SELECT * FROM ranked @@ -640,6 +646,8 @@ def fetch_match_attempt_rescore_rows( ORDER BY attempted_at DESC LIMIT :limit """).bindparams(bindparam("statuses", expanding=True)) + if sku_values: + sql = sql.bindparams(bindparam("skus", expanding=True)) params = { "source": source, @@ -648,6 +656,8 @@ def fetch_match_attempt_rescore_rows( } if reason_filter: params["reason_filter"] = f"%{reason_filter}%" + if sku_values: + params["skus"] = sku_values return [dict(row) for row in conn.execute(sql, params).mappings().all()] @@ -658,6 +668,7 @@ def build_match_attempt_rescore_audit( source: str = "pchome", statuses: Sequence[str] = DEFAULT_RESCAN_STATUSES, reason_filter: str | None = None, + skus: Sequence[str] | None = None, limit: int = 100, min_score: float = MIN_MATCH_SCORE, sample_limit: int = 20, @@ -669,6 +680,7 @@ def build_match_attempt_rescore_audit( source=source, statuses=statuses, reason_filter=reason_filter, + skus=skus, limit=limit, latest_sku_only=latest_sku_only, ) diff --git a/tests/test_competitor_match_attempt_rescore_audit.py b/tests/test_competitor_match_attempt_rescore_audit.py index a4c554b..48ea5fe 100644 --- a/tests/test_competitor_match_attempt_rescore_audit.py +++ b/tests/test_competitor_match_attempt_rescore_audit.py @@ -145,6 +145,32 @@ def test_fetch_match_attempt_rescore_rows_defaults_to_latest_sku_state(): assert {row["sku"] for row in historical_rows} == {"SKU-A", "SKU-B"} +def test_fetch_match_attempt_rescore_rows_can_limit_to_specific_skus(): + from services.competitor_match_attempt_rescore_audit import fetch_match_attempt_rescore_rows + + engine = create_engine("sqlite:///:memory:") + with engine.begin() as conn: + _create_match_attempts_table(conn) + conn.execute(text(""" + INSERT INTO competitor_match_attempts + (sku, source, attempt_status, momo_product_name, best_competitor_product_id, + best_competitor_product_name, best_match_score, diagnostic_codes, attempted_at) + VALUES + ('SKU-A', 'pchome', 'true_low_confidence', 'MOMO A', 'P-A', 'PChome A', 0.82, '["current_low"]', '2026-05-24 09:00:00'), + ('SKU-B', 'pchome', 'true_low_confidence', 'MOMO B', 'P-B', 'PChome B', 0.83, '["current_low"]', '2026-05-24 10:00:00'), + ('SKU-C', 'pchome', 'true_low_confidence', 'MOMO C', 'P-C', 'PChome C', 0.84, '["current_low"]', '2026-05-24 11:00:00') + """)) + + rows = fetch_match_attempt_rescore_rows( + conn, + statuses=("true_low_confidence",), + skus=("SKU-A", "SKU-C"), + limit=10, + ) + + assert [row["sku"] for row in rows] == ["SKU-C", "SKU-A"] + + def test_match_attempt_rescore_materializes_accepted_current_for_manual_review(): from services.competitor_match_attempt_rescore_audit import materialize_rescore_accept_reviews