Add SKU-scoped PChome rescore pilot
All checks were successful
CD Pipeline / deploy (push) Successful in 1m4s

This commit is contained in:
OoO
2026-05-25 08:33:05 +08:00
parent b877805a4c
commit da7bc88b5e
8 changed files with 47 additions and 2 deletions

View File

@@ -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 補抓」,並補前端守門測試禁止回退成籠統「待比對」,避免操作員把未搜尋誤判成已有候選待人工覆核。

View File

@@ -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 # 用於模板顯示

View File

@@ -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` reasonFeeder 只能寫入 `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>` 可只重算指定 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`,不得自動批次寫正式價差。

View File

@@ -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 決策信封整合

View File

@@ -13,6 +13,7 @@
## 📅 詳細更新日誌 (考古存檔)
### 2026-05-24PChome 近門檻身份回收第二輪
- **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 補抓」,避免操作員誤以為已有候選但尚未人工覆核;前端守門測試鎖住不得回退成舊文案。

View File

@@ -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,

View File

@@ -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,
)

View File

@@ -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