強化 PChome accepted queue 變體防線
All checks were successful
CD Pipeline / deploy (push) Successful in 1m4s

This commit is contained in:
OoO
2026-05-25 21:35:41 +08:00
parent 50daad84a7
commit e6b48cefa8
7 changed files with 170 additions and 12 deletions

View File

@@ -4,6 +4,8 @@
================================================================================
【已完成】
- V10.481 補 rescore accepted retraction 工具缺口:`--retract-variant-accepted` 不只看舊 row 已存的 `diagnostic_codes`,也會用當前 matcher 重判 latest `rescore_accepted_current`;若新版規則已變成 `variant_selection_review / low_score_current`,會追加退回 `true_low_confidence`,避免舊 accepted queue 殘留不該採用候選。Production 已先保守 materialize 15 筆安全 SKU再退回 7 筆舊 accepted 變體風險;最終 `rescore_accepted_current=89`accepted audit `gate_pass=89 / still_low=0`。
- V10.480 依 production accepted-current 風險樣本補安全閘門rom&nd 零絲絨/果凍唇釉不可被果汁唇釉多款 listing 誤收為同款Relove 潔淨凝露若一側為傳明酸/淨白活性變體改送 `variant_selection_review`1990 融燭燈不同設計(歐式可彎 vs 韓風原木底座)改 hard veto。此版先清 accepted queue 風險,再做保守 materialize。
- V10.479 依 production audit 再補二階風險Cetaphil 修護乳 vs 潔膚露改 hard veto私密防護慕絲二款可選 vs 單一香型、雪芙蘭滋養霜 vs 單側清爽型改走 `variant_selection_review`,避免仍殘留在 accepted queue。
- V10.478 補 PChome 高分錯配 / catalog 變體防線:精油/香氛類若兩側明確香味不同(如檸檬草 vs 茶樹)直接 vetoNOW 椰子油膏 vs 乳木果油、港香蘭漢本 vs 艾魔菈爽身粉改為商品線硬擋;多色/多香/數字區間 catalog 對單一款式(粉餅盒、眉筆、眼線膠筆、車用擴香蕊等)只進 `variant_selection_review`,不自動進 accepted queue。
- V10.477 補 PChome 高分錯配防線SPF 數值不同(如 SPF25 vs SPF50直接 vetoMAKE UP FOR EVER 定妝噴霧 vs 活氧水不同線直接 veto多款任選對單一款私密潔浴露、身體去角質、乳液、染眉膏等與單側色號改送 `variant_selection_review`,避免高分候選誤入 accepted queue。

View File

@@ -350,7 +350,7 @@ YOUTUBE_API_KEY = os.getenv('YOUTUBE_API_KEY', '')
# ==========================================
# 系統版本與路徑
# ==========================================
SYSTEM_VERSION = "V10.479"
SYSTEM_VERSION = "V10.481"
LOG_FILE_PATH = os.path.join(BASE_DIR, 'logs/system.log')
public_url = PUBLIC_URL # 用於模板顯示

View File

@@ -31,6 +31,8 @@
- 2026-05-25 15:20 CST 起,`V10.475` 補 rescore CLI 與高分錯配防線audit CLI 預設不再只掃 `strong_exact_spec_match`,避免新版 `focused_exact_*` 理由漏掃matcher 對香氛暖燈 S/M/L 尺寸差、NITORI 香氛噴霧器型號差直接 hard veto彩妝色號單邊出現時送 `variant_selection_review`,避免高分但不同 variant 的候選被誤推入 accepted queue。
- 2026-05-25 16:15 CST 起,`V10.476` 補商業條件差防線:即期品、效期/保存期限、盒損、福利品等條件只出現在單側時matcher 加 `commercial_condition_gap` 並送 `variant_selection_review`,不讓同名但商品狀態不同的候選自動進 accepted queue。
- 2026-05-25 19:20 CST 起,`V10.477` 補高分錯配防線SPF 數值不同直接 vetoMAKE UP FOR EVER 定妝噴霧 vs 活氧水不同線直接 veto多款任選對單一款與單側色號改送 `variant_selection_review`,涵蓋私密潔浴露、身體去角質、美體乳液與染眉膏等。
- 2026-05-25 21:05 CST 起,`V10.480` 補 accepted-current 風險樣本防線rom&nd 零絲絨/果凍唇釉 vs 果汁唇釉多款 listing 直接 `romand_lip_line_conflict` hard vetoRelove 潔淨凝露若傳明酸/淨白活性只出現在單側,保留高分但進 `variant_selection_review`1990 融燭燈不同設計(歐式可彎 vs 韓風原木底座)直接 `selection1990_wax_lamp_design_conflict` hard veto。
- 2026-05-25 21:25 CST 起,`V10.481` 補 rescore accepted retraction 工具缺口:退回工具會用當前 matcher 重判 latest `rescore_accepted_current`,凡新版已變 `variant_selection_review / low_score_current` 的舊 accepted 會追加退回 `true_low_confidence`避免人工覆核隊列保留舊版安全閘門放行的候選。Production 已部署 `/health=V10.481`;保守 materialize 15 筆安全 SKU 後再退回 7 筆舊 accepted 變體風險,最新 accepted audit 為 `scanned=89 / gate_pass=89 / still_low=0`
- 2026-05-25 12:05 CST 狀態:`main` 已部署到 188正式 `/health``V10.467`,待推 Gitea。兩段變更已合併驗證V10.466 rescore duplicate 改看 latest-state7 筆 SKU 最新 attempt 全為 `rescore_accepted_current``competitor_prices` / `competitor_price_history` 目標計數未變V10.467 focused exact matcher 在容器內回 `exact / total_price / price_alert_exact`。本輪 recreate `momo-app``scheduler``telegram-bot`;未使用 `--remove-orphans`,未碰 `momo-db`。Smoke 通過:三容器 healthy、PChome rescore queue API HTTP 200、Gemini 24 小時無 provider 紀錄、Ollama env 順序維持 GCP-A → GCP-B → 111、3 分鐘三容器 log 未見 Traceback / ERROR / CRITICAL / IntegrityError。
## 1. MOMO / PChome 核心比價準確率
@@ -74,6 +76,8 @@
- 2026-05-25 19:20 CST 起新增高分負例Jealousness SPF25 vs SPF50、MAKE UP FOR EVER 超光肌控油定妝噴霧 vs 超光肌活氧水、rom&nd 染眉膏通用 listing vs `03 摩登米`、Lactacyd 多款潔浴露 vs 單一亮肌柔滑、我的心機多款身體去角質 vs 單一香型。前兩者 hard veto其餘進 identity review。
- 2026-05-25 20:05 CST 起,新增高分錯配 / catalog 變體防線AUS LIFE 檸檬草 vs 茶樹滾珠精油、NOW 椰子油膏 vs 乳木果油、港香蘭漢本 vs 艾魔菈爽身粉改為 hard veto多色 / 多香 / 數字區間 catalog 對單一款式KATE 粉餅盒、植村秀眉筆、PERIPERA 01~07 眼線膠筆、Jo Malone 車用擴香蕊芯等)只進 `variant_selection_review`
- 2026-05-25 20:35 CST 起,依 production audit 續補二階風險:同規格但一側為潔膚露、一側為修護乳/乳液直接 `cleanser_lotion_line_conflict` hard veto私密防護慕絲多款可選 vs 單一香型、滋養霜單側清爽型只進 `variant_selection_review`
- 2026-05-25 21:05 CST 起,依 accepted-current 二次抽樣續補三類風險rom&nd 唇釉不同品線不可互收Relove 潔淨凝露傳明酸/淨白變體不可自動 accepted1990 融燭燈同色但不同燈座/結構不可互收。下一步先部署 V10.480,再回查 accepted-current 是否仍有上述 4 筆風險 SKU。
- 2026-05-25 21:25 CST 起accepted queue 清潔不再只靠舊 `diagnostic_codes``--retract-variant-accepted` 改為先抓 latest accepted再用當前 matcher 判斷是否需要退回。這能清掉 V10.480 後才被新規則判為 `variant_selection_review` 的舊 accepted。正式最新狀態`true_low_confidence=751``rescore_accepted_current=89``identity_veto=3994``matched=1570``unit_comparable=379`
## 3. 12 Agent 決策信封整合

View File

@@ -350,11 +350,6 @@ def fetch_variant_rescore_accept_review_rows(
limit: int = 100,
) -> list[dict[str, Any]]:
"""Fetch latest rescore-accepted rows that should be retracted to review."""
reason_predicate = (
"diagnostic_codes::text LIKE :reason_filter"
if conn.dialect.name == "postgresql"
else "CAST(diagnostic_codes AS TEXT) LIKE :reason_filter"
)
nulls_last = " NULLS LAST" if conn.dialect.name == "postgresql" else ""
sql = text(f"""
WITH ranked AS (
@@ -400,19 +395,30 @@ def fetch_variant_rescore_accept_review_rows(
FROM ranked
WHERE rn = 1
AND attempt_status = :attempt_status
AND {reason_predicate}
ORDER BY attempted_at DESC{nulls_last}
LIMIT :limit
""")
return [
rows = [
dict(row)
for row in conn.execute(sql, {
"source": source,
"attempt_status": RESCORE_ACCEPTED_CURRENT_STATUS,
"reason_filter": "%variant_selection_review%",
"limit": max(1, int(limit)),
}).mappings().all()
]
retractable_rows: list[dict[str, Any]] = []
for row in rows:
diagnostic_text = json.dumps(row.get("diagnostic_codes"), ensure_ascii=False, default=str)
if "variant_selection_review" in diagnostic_text:
retractable_rows.append(row)
continue
decision = classify_match_attempt_row(row)
if (
"variant_selection_review" in set(decision.reasons)
and decision.suggested_status == "low_score_current"
):
retractable_rows.append(row)
return retractable_rows
def retract_variant_rescore_accept_reviews(

View File

@@ -1978,6 +1978,9 @@ def score_marketplace_match(
makeup_spray_line_conflict = _has_makeup_spray_line_conflict(left, right)
if makeup_spray_line_conflict:
reasons.append("makeup_spray_line_conflict")
romand_lip_line_conflict = _has_romand_lip_line_conflict(left, right)
if romand_lip_line_conflict:
reasons.append("romand_lip_line_conflict")
nail_tool_function_conflict = _has_nail_tool_function_conflict(left, right)
if nail_tool_function_conflict:
reasons.append("nail_tool_function_conflict")
@@ -2011,6 +2014,9 @@ def score_marketplace_match(
cleanser_lotion_line_conflict = _has_cleanser_lotion_line_conflict(left, right)
if cleanser_lotion_line_conflict:
reasons.append("cleanser_lotion_line_conflict")
selection1990_wax_lamp_design_conflict = _has_selection1990_wax_lamp_design_conflict(left, right)
if selection1990_wax_lamp_design_conflict:
reasons.append("selection1990_wax_lamp_design_conflict")
wax_lamp_size_letter_conflict = _has_wax_lamp_size_letter_conflict(left, right)
if wax_lamp_size_letter_conflict:
reasons.append("size_letter_variant_conflict")
@@ -2020,9 +2026,13 @@ def score_marketplace_match(
commercial_condition_gap = _has_commercial_condition_gap(left, right)
if commercial_condition_gap:
reasons.append("commercial_condition_gap")
relove_private_cleanser_variant_gap = _has_relove_private_cleanser_variant_gap(left, right)
if relove_private_cleanser_variant_gap:
reasons.append("relove_private_cleanser_variant_gap")
variant_selection_review = (
_has_named_variant_selection_review(left, right, shared_anchor)
or commercial_condition_gap
or relove_private_cleanser_variant_gap
)
if variant_selection_review:
reasons.append("variant_selection_review")
@@ -2062,6 +2072,8 @@ def score_marketplace_match(
hard_veto = True
if makeup_spray_line_conflict:
hard_veto = True
if romand_lip_line_conflict:
hard_veto = True
if nail_tool_function_conflict:
hard_veto = True
if schick_razor_line_conflict:
@@ -2084,6 +2096,8 @@ def score_marketplace_match(
hard_veto = True
if cleanser_lotion_line_conflict:
hard_veto = True
if selection1990_wax_lamp_design_conflict:
hard_veto = True
if wax_lamp_size_letter_conflict:
hard_veto = True
if nitori_diffuser_model_conflict:
@@ -2973,6 +2987,35 @@ def _has_makeup_spray_variant_selection_gap(left: ProductIdentity, right: Produc
return left_groups != right_groups
def _romand_lip_line_groups(identity: ProductIdentity) -> set[str]:
text = identity.searchable_name
groups: set[str] = set()
if "果汁唇釉" in text or "juicy" in text:
groups.add("juicy")
if "零絲絨" in text or "zero velvet" in text or "霧面唇釉" in text:
groups.add("zero_velvet")
if "果凍唇釉" in text or "glasting" in text or "唇凍" in text:
groups.add("glasting")
if "水感唇釉" in text:
groups.add("water_gloss")
return groups
def _has_romand_lip_line_conflict(left: ProductIdentity, right: ProductIdentity) -> bool:
pair_text = f"{left.searchable_name} {right.searchable_name}"
if not (
{"rom", "romand"} & (left.brand_tokens | right.brand_tokens)
or "rom&nd" in pair_text
or "romand" in pair_text
):
return False
if "" not in left.searchable_name or "" not in right.searchable_name:
return False
left_groups = _romand_lip_line_groups(left)
right_groups = _romand_lip_line_groups(right)
return bool(left_groups and right_groups and left_groups.isdisjoint(right_groups))
def _has_nail_tool_function_conflict(left: ProductIdentity, right: ProductIdentity) -> bool:
left_text = left.searchable_name
right_text = right.searchable_name
@@ -3202,6 +3245,31 @@ def _has_cleanser_lotion_line_conflict(left: ProductIdentity, right: ProductIden
return bool((left_cleanser and right_lotion) or (right_cleanser and left_lotion))
def _selection1990_wax_lamp_design_groups(identity: ProductIdentity) -> set[str]:
text = identity.searchable_name
groups: set[str] = set()
if "現代簡約半圓罩融燭燈" in text or "半圓罩" in text:
groups.add("half_dome")
if "歐式可彎融燭燈" in text or "可彎融燭燈" in text:
groups.add("bendable")
if "韓風原木底座融燭燈" in text or "原木底座融燭燈" in text:
groups.add("wood_base")
if "北歐簡樸融蠟燈" in text or "北歐簡樸" in text:
groups.add("nordic")
return groups
def _has_selection1990_wax_lamp_design_conflict(left: ProductIdentity, right: ProductIdentity) -> bool:
if not ({"1990", "選物"} <= (left.brand_tokens & right.brand_tokens)):
return False
pair_text = f"{left.searchable_name} {right.searchable_name}"
if not any(term in pair_text for term in ("融燭燈", "蠟燭暖燈", "融蠟燈")):
return False
left_groups = _selection1990_wax_lamp_design_groups(left)
right_groups = _selection1990_wax_lamp_design_groups(right)
return bool(left_groups and right_groups and left_groups.isdisjoint(right_groups))
def _standalone_size_letter_tokens(identity: ProductIdentity) -> set[str]:
text = identity.searchable_name
return {
@@ -3282,6 +3350,15 @@ def _has_commercial_condition_gap(left: ProductIdentity, right: ProductIdentity)
return bool(left_terms or right_terms) and left_terms != right_terms
def _has_relove_private_cleanser_variant_gap(left: ProductIdentity, right: ProductIdentity) -> bool:
if not _is_relove_cleanser_gel_like(left, right):
return False
brightening_terms = ("傳明酸", "淨白", "美白", "亮白", "菸鹼醯胺", "niacinamide")
left_brightening = any(term in left.searchable_name for term in brightening_terms)
right_brightening = any(term in right.searchable_name for term in brightening_terms)
return left_brightening != right_brightening
def _has_catalog_specific_variant_selection_gap(left: ProductIdentity, right: ProductIdentity) -> bool:
pair_text = f"{left.searchable_name} {right.searchable_name}"
if not any(
@@ -3888,6 +3965,18 @@ def _is_relove_private_cleanser_line(left: ProductIdentity, right: ProductIdenti
)
def _is_relove_cleanser_gel_like(left: ProductIdentity, right: ProductIdentity) -> bool:
if "relove" not in (left.brand_tokens | right.brand_tokens):
return False
cleanser_terms = ("私密", "潔淨", "清潔")
return (
"凝露" in left.searchable_name
and "凝露" in right.searchable_name
and any(term in left.searchable_name for term in cleanser_terms)
and any(term in right.searchable_name for term in cleanser_terms)
)
def _is_multi_variant_catalog_listing(identity: ProductIdentity) -> bool:
text = identity.normalized_name
return any(phrase in text for phrase in MULTI_VARIANT_LISTING_PHRASES)

View File

@@ -320,6 +320,15 @@ def test_match_attempt_rescore_retracts_variant_review_from_accepted_queue():
89, 0.872,
'["focused_exact_identity_herbacin_classic_hand_cream_20ml_brandless"]',
'matcher_rescore=accepted_current', '2026-05-24 14:24:17'
),
(
'10524966', 'pchome', 'rescore_accepted_current', 10524966,
'【Derma 丹麥德瑪】寶寶有機水嫩洗髮沐浴露 150ml(寶寶沐浴、嬰兒沐浴)',
399, 1, 'DERMA-150ML',
'Derma 寶寶洗髮沐浴露 150ml',
349, 0.82,
'["strong_exact_spec_match", "spec_name_alignment"]',
'matcher_rescore=accepted_current', '2026-05-24 14:24:17'
)
"""))
@@ -343,15 +352,16 @@ def test_match_attempt_rescore_retracts_variant_review_from_accepted_queue():
""")).mappings().all()
after_rows = fetch_variant_rescore_accept_review_rows(conn, limit=10)
assert [row["sku"] for row in rows] == ["8884618"]
assert stats["retracted"] == 1
assert [row["sku"] for row in rows] == ["10922465", "8884618"]
assert stats["retracted"] == 2
latest_by_sku = {row["sku"]: row for row in latest}
assert latest_by_sku["8884618"]["attempt_status"] == "true_low_confidence"
assert "matcher_rescore:retracted_variant_selection_review" in json.loads(
latest_by_sku["8884618"]["search_terms"]
)
assert "rescore_retracted_variant_selection_review" in latest_by_sku["8884618"]["error_message"]
assert latest_by_sku["10922465"]["attempt_status"] == "rescore_accepted_current"
assert latest_by_sku["10922465"]["attempt_status"] == "true_low_confidence"
assert latest_by_sku["10524966"]["attempt_status"] == "rescore_accepted_current"
assert after_rows == []

View File

@@ -2371,6 +2371,53 @@ def test_marketplace_matcher_sends_single_sided_private_mousse_and_cream_variant
assert "variant_selection_review" in diagnostics.reasons
def test_marketplace_matcher_blocks_romand_lip_line_conflicts():
from services.marketplace_product_matcher import score_marketplace_match
zero_velvet = score_marketplace_match(
"【rom&nd】零絲絨 霧面唇釉 5.5g",
"rom&nd 果汁唇釉5.5g_多款可選",
)
glasting = score_marketplace_match(
"【rom&nd】果凍唇釉 5.5g",
"rom&nd 果汁唇釉5.5g_多款可選",
)
for diagnostics in (zero_velvet, glasting):
assert diagnostics.hard_veto is True
assert diagnostics.comparison_mode == "not_comparable"
assert "romand_lip_line_conflict" in diagnostics.reasons
def test_marketplace_matcher_sends_relove_brightening_cleanser_variant_to_review():
from services.marketplace_product_matcher import score_marketplace_match
diagnostics = score_marketplace_match(
"【Relove】胺基酸私密潔淨精華凝露120ml",
"RELOVE傳明酸美白潔淨精華凝露120ml",
)
assert diagnostics.hard_veto is False
assert diagnostics.score >= 0.76
assert diagnostics.price_basis == "manual_review"
assert diagnostics.alert_tier == "identity_review"
assert "relove_private_cleanser_variant_gap" in diagnostics.reasons
assert "variant_selection_review" in diagnostics.reasons
def test_marketplace_matcher_blocks_selection1990_wax_lamp_design_conflict():
from services.marketplace_product_matcher import score_marketplace_match
diagnostics = score_marketplace_match(
"【1990選物】歐式可彎融燭燈 香氛蠟燭暖燈-黑色款",
"【1990選物】韓風原木底座融燭燈 香氛蠟燭暖燈-黑色款",
)
assert diagnostics.hard_veto is True
assert diagnostics.comparison_mode == "not_comparable"
assert "selection1990_wax_lamp_design_conflict" in diagnostics.reasons
def test_marketplace_matcher_promotes_eaoron_classic_tone_up_cream_exact_line():
from services.marketplace_product_matcher import score_marketplace_match