強化 PChome accepted queue 變體防線
All checks were successful
CD Pipeline / deploy (push) Successful in 1m4s
All checks were successful
CD Pipeline / deploy (push) Successful in 1m4s
This commit is contained in:
@@ -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 茶樹)直接 veto;NOW 椰子油膏 vs 乳木果油、港香蘭漢本 vs 艾魔菈爽身粉改為商品線硬擋;多色/多香/數字區間 catalog 對單一款式(粉餅盒、眉筆、眼線膠筆、車用擴香蕊等)只進 `variant_selection_review`,不自動進 accepted queue。
|
||||
- V10.477 補 PChome 高分錯配防線:SPF 數值不同(如 SPF25 vs SPF50)直接 veto;MAKE UP FOR EVER 定妝噴霧 vs 活氧水不同線直接 veto;多款任選對單一款(私密潔浴露、身體去角質、乳液、染眉膏等)與單側色號改送 `variant_selection_review`,避免高分候選誤入 accepted queue。
|
||||
|
||||
@@ -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 # 用於模板顯示
|
||||
|
||||
|
||||
@@ -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 數值不同直接 veto,MAKE 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 veto;Relove 潔淨凝露若傳明酸/淨白活性只出現在單側,保留高分但進 `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-state,7 筆 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 潔淨凝露傳明酸/淨白變體不可自動 accepted;1990 融燭燈同色但不同燈座/結構不可互收。下一步先部署 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 決策信封整合
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 == []
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user