From 35ad29b04d3e8d90feb38572593b6093152de5df Mon Sep 17 00:00:00 2001 From: OoO Date: Thu, 4 Jun 2026 14:03:21 +0800 Subject: [PATCH] =?UTF-8?q?V10.582=20=E5=BC=B7=E5=8C=96=E6=AF=94=E5=83=B9?= =?UTF-8?q?=E9=80=9A=E7=9F=A5=E8=88=87=E8=BA=AB=E4=BB=BD=E8=AD=89=E6=93=9A?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- TODO_NEXT_STEPS.txt | 1 + config.py | 2 +- .../current_execution_queue_20260524.md | 1 + docs/memory/history_logs.md | 1 + services/competitor_price_feeder.py | 7 +- services/nemoton_dispatcher_service.py | 6 ++ services/pchome_crawler.py | 22 ++++- services/telegram_templates.py | 85 +++++++++++++++++++ ...t_competitor_match_attempts_persistence.py | 36 ++++++++ tests/test_event_router.py | 46 ++++++++++ tests/test_nemotron_decision_envelope.py | 6 ++ tests/test_pchome_crawler_search.py | 11 +++ 12 files changed, 220 insertions(+), 4 deletions(-) diff --git a/TODO_NEXT_STEPS.txt b/TODO_NEXT_STEPS.txt index 462cfae..1c359c3 100644 --- a/TODO_NEXT_STEPS.txt +++ b/TODO_NEXT_STEPS.txt @@ -4,6 +4,7 @@ ================================================================================ 【已完成】 + - V10.582 補 PChome 比價通知專業分級與 Nick 副標身份證據:NemoTron 價格決策信封現在保留 `momo_price`、`competitor_price`、`candidate_gap_pct` 與 `sales_7d_delta_pct`,EventRouter / Telegram 模板會把 `match_type / price_basis / alert_tier` 翻成「直接價格威脅、單位價覆核、身份覆核、壓制告警」與操作邊界;PChome crawler 會保留 `Nick` 副標為 `match_name` 給 matcher 使用,UI/DB 顯示仍維持原品名,讓容量、入數、濃度資訊可參與比對。 - V10.581 將 V10.580 的重複單品組安全線接進 PChome retryable revalidation 窄門:只允許 `true_low_confidence`、舊診斷為 `match_type=exact / price_basis=manual_review`、無阻擋原因且命中具名安全商品線(Bioneo 150ml x2、Cetaphil 150ml x2、Avene 300ml x4、Schick 2+1 入、KOSE 雪肌精 500ml x2 禮盒)的候選進小批次重評;仍由最新版 matcher 最終判斷是否寫入正式 `competitor_prices`。 - V10.580 補 PChome 重複單品組 total-price 窄門:同品牌、同入數、同基礎規格且名稱高度對齊的 150ml x2、300ml x4、2+1 入等候選,可由 `exact / manual_review` 進 `exact / total_price / price_alert_exact`,正式部署前估算 213 筆高分 `true_low_confidence` 中只有 7 筆會轉自動寫入。同步新增 NEW DIRECTIONS 甜杏仁油 vs 杏桃核仁油核心油種 hard veto,避免規格一樣但油種不同的錯配污染正式價差;Paula's Choice 這類 PChome 端缺 30ml 規格的雙入組仍保留 manual review,不放寬全域門檻。 - V10.579 補 PChome 高信心 total-price safe family:SAB 私密防護舒緩噴霧 30ml 與 Herbacin 小甘菊 20ml 護手霜在同款式、同規格、無 variant/commercial gap 時可由 focused matcher 進 `exact / total_price / price_alert_exact`,讓近門檻重評能真正寫入正式比價;Herbacin 柔皙 vs 野生玫瑰等跨 variant 仍保留在 review,不放寬全域門檻。同版將 Code Review GCP-B secondary timeout 預設由 60 秒收斂到 25 秒,GCP-A preflight 不通且 GCP-B 生成卡住時更快回 deterministic local degraded,不呼叫 Gemini/111。 diff --git a/config.py b/config.py index bae9ee1..27b34ed 100644 --- a/config.py +++ b/config.py @@ -402,7 +402,7 @@ YOUTUBE_API_KEY = os.getenv('YOUTUBE_API_KEY', '') # ========================================== # 系統版本與路徑 # ========================================== -SYSTEM_VERSION = "V10.581" +SYSTEM_VERSION = "V10.582" LOG_FILE_PATH = os.path.join(BASE_DIR, 'logs/system.log') public_url = PUBLIC_URL # 用於模板顯示 diff --git a/docs/memory/current_execution_queue_20260524.md b/docs/memory/current_execution_queue_20260524.md index 7e1f33c..611aa2d 100644 --- a/docs/memory/current_execution_queue_20260524.md +++ b/docs/memory/current_execution_queue_20260524.md @@ -107,6 +107,7 @@ - 2026-06-04 起,`V10.579` 補 PChome 高信心 total-price safe family:SAB 私密防護舒緩噴霧 30ml、Herbacin 小甘菊 20ml 護手霜在同款式同規格且無 variant/commercial gap 時可進 `exact / total_price / price_alert_exact`;跨款式反測仍擋在 review,`MIN_MATCH_SCORE` 不變。同版將 Code Review GCP-B secondary timeout 收斂到 25 秒,GCP-A/GCP-B 都慢時更快回 local degraded。 - 2026-06-04 起,`V10.580` 補 PChome 重複單品組 total-price 窄門與核心油種 veto:同品牌、同入數、同基礎規格且名稱高度對齊的重複單品組(例如 Bioneo 150ml x2、Cetaphil 150ml x2、Avene 300ml x4、Schick 2+1 入)可進 `exact / total_price / price_alert_exact`;正式部署前估算 213 筆高分 `true_low_confidence` 中僅 7 筆會被自動寫入。NEW DIRECTIONS 甜杏仁油 vs 杏桃核仁油改 hard veto,Paula's Choice 缺 30ml 規格的雙入組仍留 manual review;`MIN_MATCH_SCORE` 不變。 - 2026-06-04 起,`V10.581` 將重複單品組安全線接進 retryable revalidation:只收 `true_low_confidence` 中舊診斷為 `match_type=exact / price_basis=manual_review`、無 commercial / variant / count / bundle 等阻擋,且命中具名安全商品線的候選;最後仍由最新版 matcher 與 overwrite protection 決定是否寫正式比價。 +- 2026-06-04 起,`V10.582` 補 PChome 比價通知專業分級與 Nick 副標身份證據:NemoTron 決策信封保留 MOMO / PChome 價格、價差與 7 日業績變化;Telegram decision envelope 將 `exact / total_price / price_alert_exact` 等工程路徑翻成直接價格威脅、單位價覆核、身份覆核或壓制告警,並把「單位價/身份未確認不得用總價直接告警」寫進操作邊界。PChome `Nick` 副標會以 `match_name` 參與 matcher,比價可用到容量、入數、濃度資訊,但不改 UI/DB 正式顯示品名。 - 2026-06-04 起,`V10.578` 修正 Code Review deterministic scan 的 timeout 判定,多行 `requests.*(... timeout=...)` 不再被誤報為未設定 timeout。 - 2026-06-04 起,`V10.577` Code Review OpenClaw 會在 explicit Ollama host generate 前先做短 `/api/version` preflight;GCP-A 不通時快速跳 GCP-B,避免 15 秒 timeout 後才降級,且仍不呼叫 Gemini / 111。 - 2026-06-04 起,`V10.576` 修正 GCP-only Ollama retry:caller 禁用 111 fallback 時,resolver 若回到 111 會改試 GCP-A/GCP-B allowlist,不再讓 Hermes / Code Review 類任務因 resolver 快取到 111 而 `all 0 hosts failed`。 diff --git a/docs/memory/history_logs.md b/docs/memory/history_logs.md index 281d7c1..f25089a 100644 --- a/docs/memory/history_logs.md +++ b/docs/memory/history_logs.md @@ -13,6 +13,7 @@ ## 📅 詳細更新日誌 (考古存檔) ### 2026-06-01:PChome 比價新鮮度操作閉環 +- **V10.582 PChome 比價通知專業分級 + Nick 副標身份證據**: NemoTron 價格決策信封補齊 `momo_price`、`competitor_price`、`candidate_gap_pct` 與 `sales_7d_delta_pct`,避免 EventRouter / Telegram 模板拿不到核心價格事實。價格決策模板新增「通知分級」,將 `match_type / price_basis / alert_tier` 翻成直接價格威脅、單位價覆核、身份覆核或壓制告警,並同步顯示操作邊界;單位價或 identity 未確認時明確禁止以總價直接判定價格威脅。PChome crawler 另保留 `Nick` 副標為 `match_name` 給 matcher 使用,UI/DB 顯示仍用原品名,讓容量、入數、濃度資訊可參與比對。 - **V10.581 重複單品組 retryable revalidation 接線**: 將 V10.580 的安全 matcher 線接到 `run_retryable_candidate_revalidation()`,但只收 `true_low_confidence` 中舊診斷已是 `match_type=exact / price_basis=manual_review`、沒有 review block,且命中具名安全商品線的候選。這讓 Bioneo / Cetaphil / Avene / Schick / KOSE 等重複單品組可以小批次重評並由最新版 matcher 決定是否寫入正式 `competitor_prices`,仍不開放泛用高分掃描。 - **V10.580 PChome 重複單品組 total-price 窄門 + 核心油種 veto**: matcher 將同品牌、同入數、同基礎規格且名稱高度對齊的重複單品組,從 `exact / manual_review` 收斂到 `exact / total_price / price_alert_exact`;正式部署前以最新 `true_low_confidence` 重算,213 筆高分候選中僅 7 筆符合自動寫入安全條件。同步新增 NEW DIRECTIONS 甜杏仁油 vs 杏桃核仁油 hard veto,避免同容量同按壓頭但核心油種不同的候選被誤放行;PChome 端缺規格的 Paula's Choice 雙入組仍停在 manual review。 - **V10.579 PChome 高信心 total-price safe family + Code Review timeout 收斂**: matcher 新增 SAB 私密防護舒緩噴霧 30ml 與 Herbacin 小甘菊 20ml 護手霜的窄範圍 total-price safe 路徑。這些候選仍必須通過既有 score、hard veto、variant/commercial gap 與 overwrite protection;Herbacin 柔皙 vs 野生玫瑰跨 variant 反測維持不進正式價差。目的在不放寬 `MIN_MATCH_SCORE` 的前提下,把可證明同款的高信心 `true_low_confidence` 轉進正式比價覆蓋。同版將 Code Review GCP-B secondary timeout 預設由 60 秒收斂到 25 秒,GCP-A preflight 不通且 GCP-B 生成卡住時更快回 deterministic local degraded,不呼叫 Gemini/111。 diff --git a/services/competitor_price_feeder.py b/services/competitor_price_feeder.py index 47597b9..2183d30 100644 --- a/services/competitor_price_feeder.py +++ b/services/competitor_price_feeder.py @@ -832,6 +832,11 @@ def _product_id_key(product_id: str) -> str: return re.sub(r"[^A-Z0-9]", "", str(product_id or "").upper()) +def _candidate_match_name(product) -> str: + """Return the identity-rich PChome text used for scoring, falling back to display name.""" + return (getattr(product, "match_name", None) or getattr(product, "name", None) or "").strip() + + def _find_best_match_detail( momo_name: str, pchome_products: list, @@ -863,7 +868,7 @@ def _rank_match_details( for p in pchome_products: diagnostics = score_marketplace_match( momo_name, - p.name, + _candidate_match_name(p), momo_price=momo_price, competitor_price=getattr(p, "price", None), ) diff --git a/services/nemoton_dispatcher_service.py b/services/nemoton_dispatcher_service.py index 526b619..5437670 100644 --- a/services/nemoton_dispatcher_service.py +++ b/services/nemoton_dispatcher_service.py @@ -543,6 +543,8 @@ def _build_price_decision_envelope( "sku": sku, "name": name, "event_type": "price_competition", + "momo_price": momo_value if momo_value > 0 else None, + "competitor_price": comp_value if comp_value > 0 else None, "competitor_product_id": competitor_product_id, "competitor_product_name": str(competitor_product_name or "")[:120], }, @@ -554,6 +556,10 @@ def _build_price_decision_envelope( "requires_hitl": True, }, "expected_impact": { + "momo_price": momo_value if momo_value > 0 else None, + "competitor_price": comp_value if comp_value > 0 else None, + "candidate_gap_pct": round(gap_value, 1), + "sales_7d_delta_pct": round(sales_value, 1), "revenue_loss_7d": round(loss_value, 2), "gap_amount": gap_amount, "recommended_price": recommended_price, diff --git a/services/pchome_crawler.py b/services/pchome_crawler.py index bb554e1..f8cc43f 100644 --- a/services/pchome_crawler.py +++ b/services/pchome_crawler.py @@ -39,6 +39,8 @@ class PChomeProduct: review_count: int # 評論數 is_on_sale: bool # 是否特價中 crawled_at: datetime # 爬取時間 + subtitle: str = '' # PChome Nick / 副標,常含容量、入數與濃度 + match_name: str = '' # 給 matcher 使用的身份文字;UI/DB 顯示仍用 name def to_dict(self) -> dict: """轉換為字典""" @@ -47,6 +49,17 @@ class PChomeProduct: return data +def _build_match_name(name: str, subtitle: str) -> str: + """Build an identity-rich title without duplicating the PChome display name.""" + display_name = str(name or '').strip() + nick = str(subtitle or '').strip() + if not nick or nick == display_name: + return display_name + if display_name and nick.startswith(display_name): + return nick + return f"{display_name} {nick}".strip() + + class PChomeCrawler: """PChome 24h 爬蟲""" @@ -334,9 +347,12 @@ class PChomeCrawler: image_url = f"{self.IMAGE_BASE_URL}{pic_path}" if pic_path else '' + name = data.get('Name', '') or '' + subtitle = data.get('Nick', '') or '' + return PChomeProduct( product_id=product_id, - name=data.get('Name', ''), + name=name, price=price, original_price=original_price, discount=discount, @@ -347,7 +363,9 @@ class PChomeCrawler: rating=data.get('RatingValue'), review_count=data.get('ReviewCount', 0), is_on_sale=data.get('isOnSale', False), - crawled_at=crawled_at + crawled_at=crawled_at, + subtitle=subtitle, + match_name=_build_match_name(name, subtitle), ) except Exception as e: diff --git a/services/telegram_templates.py b/services/telegram_templates.py index e5b55c3..960221c 100644 --- a/services/telegram_templates.py +++ b/services/telegram_templates.py @@ -776,7 +776,9 @@ def _action_label(action_code: str) -> str: labels = { "price_follow_review": "確認是否跟價或改用促銷防守", "review_accept_identity": "人工確認同款後採納 identity", + "review_catalog_comparable": "依型錄證據覆核可比性", "unit_price_required": "改用單位價覆核,不寫總價型價差", + "identity_or_price_review": "先確認身份,再判斷價格處置", "verify_or_reject_identity": "確認候選是否同款;非同款即駁回", "compare_existing_identity": "比較既有正式 identity 與新候選", "refresh_or_compare_identity": "刷新過期 identity 後再覆核", @@ -786,6 +788,55 @@ def _action_label(action_code: str) -> str: return labels.get(action_code or "", action_code or "人工覆核") +_PRICE_MATCH_TYPE_LABELS = { + "exact": "高信心同款", + "same_product_different_pack": "同商品不同包裝", + "same_line_variant": "同系列不同款", + "comparable": "可比但需覆核", + "no_match": "非同款", +} +_PRICE_BASIS_LABELS = { + "total_price": "總價可比", + "unit_price": "單位價可比", + "manual_review": "人工覆核後可比", + "none": "不可比", +} +_PRICE_ALERT_TIER_LABELS = { + "price_alert_exact": "可直接價格告警", + "unit_price_review": "單位價覆核", + "identity_review": "身份覆核", + "suppress": "壓制告警", +} + + +def _price_match_path(envelope: Dict[str, Any]) -> tuple[str, str, str]: + guardrails = envelope.get("guardrails") if isinstance(envelope.get("guardrails"), dict) else {} + match_type = str(guardrails.get("match_type") or "") + price_basis = str(guardrails.get("price_basis") or "") + alert_tier = str(guardrails.get("alert_tier") or "") + if match_type and price_basis and alert_tier: + return match_type, price_basis, alert_tier + + match_evidence = _find_evidence(envelope, "match_score") or {} + basis = str(match_evidence.get("basis") or "") + parts = [part.strip() for part in basis.split("/") if part.strip()] + if len(parts) >= 3: + return match_type or parts[0], price_basis or parts[1], alert_tier or parts[2] + return match_type, price_basis, alert_tier + + +def _price_notification_guidance(match_type: str, price_basis: str, alert_tier: str) -> tuple[str, str]: + if match_type == "exact" and price_basis == "total_price" and alert_tier == "price_alert_exact": + return "直接價格威脅", "可用總價比較;先確認庫存與促銷期,再人工決定跟價或促銷防守。" + if price_basis == "unit_price" or alert_tier == "unit_price_review": + return "單位價覆核", "先換算單位價與入數,禁止用總價直接判定價格威脅。" + if alert_tier == "identity_review" or price_basis == "manual_review": + return "身份覆核", "先確認同款、規格、組合與前台狀態,人工採納後才可寫入正式價差。" + if alert_tier == "suppress" or match_type == "no_match" or price_basis == "none": + return "壓制告警", "目前不可作為價格威脅;保留診斷紀錄,避免誤報。" + return "可比性待判讀", "依比對證據人工覆核,未確認前不自動調價、不覆蓋正式 identity。" + + def _format_price_decision_envelope(envelope: Dict[str, Any]) -> List[str]: """將價格/競品決策信封排成可讀的專業 brief。""" severity = escape(str(envelope.get("severity") or "info")) @@ -809,6 +860,22 @@ def _format_price_decision_envelope(envelope: Dict[str, Any]) -> List[str]: if blocked_reason: lines.append(f"• 邊界:{blocked_reason}") + match_type, price_basis, alert_tier = _price_match_path(envelope) + if match_type or price_basis or alert_tier: + guidance_title, guidance_text = _price_notification_guidance(match_type, price_basis, alert_tier) + path_labels = [ + _PRICE_MATCH_TYPE_LABELS.get(match_type, match_type) if match_type else "", + _PRICE_BASIS_LABELS.get(price_basis, price_basis) if price_basis else "", + _PRICE_ALERT_TIER_LABELS.get(alert_tier, alert_tier) if alert_tier else "", + ] + lines += [ + "", + "🚦 通知分級", + f"• 判讀:{escape(guidance_title)}", + f"• 路徑:{escape(' / '.join(part for part in path_labels if part))}", + f"• 邊界:{escape(guidance_text)}", + ] + sku = escape(str(subject.get("sku") or "")) name = escape(_short_text(subject.get("name") or "", 96)) competitor_id = escape(str(subject.get("competitor_product_id") or subject.get("pchome_id") or "")) @@ -891,6 +958,24 @@ def _format_price_decision_envelope(envelope: Dict[str, Any]) -> List[str]: if evidence_lines: lines += ["", "🧩 比對證據", *evidence_lines] + difference_highlights = envelope.get("difference_highlights") + if isinstance(difference_highlights, list) and difference_highlights: + diff_lines = [] + for row in difference_highlights[:3]: + if not isinstance(row, dict): + continue + dimension = escape(str(row.get("dimension") or row.get("label") or "差異")) + left = escape(_short_text(row.get("left") or row.get("momo") or "", 42)) + right = escape(_short_text(row.get("right") or row.get("pchome") or "", 42)) + if left or right: + diff_lines.append(f"• {dimension}:MOMO {left or '—'} / PChome {right or '—'}") + else: + note = escape(_short_text(row.get("note") or row.get("summary") or "", 84)) + if note: + diff_lines.append(f"• {dimension}:{note}") + if diff_lines: + lines += ["", "⚖️ 差異提醒", *diff_lines] + action_code = str(recommended_action.get("action") or "human_review") owner = escape(str(recommended_action.get("owner") or "未指定")) requires_hitl = bool(recommended_action.get("requires_hitl", True)) diff --git a/tests/test_competitor_match_attempts_persistence.py b/tests/test_competitor_match_attempts_persistence.py index 0e247ce..f0a8603 100644 --- a/tests/test_competitor_match_attempts_persistence.py +++ b/tests/test_competitor_match_attempts_persistence.py @@ -84,6 +84,42 @@ def test_competitor_review_queue_starts_from_latest_attempts_not_all_products(): assert "valid_competitor AS" not in review_cte_body +def test_competitor_feeder_scores_with_pchome_match_name(monkeypatch): + from services.competitor_price_feeder import _rank_match_details + from services.pchome_crawler import PChomeProduct + + product = PChomeProduct( + product_id="DDDE15-A900JZ4GR", + name="【寶拉珍選】水楊酸身體乳雙入組", + price=1777, + original_price=2640, + discount=33, + image_url="", + product_url="https://24h.pchome.com.tw/prod/DDDE15-A900JZ4GR", + stock=20, + store="DDDE15", + rating=None, + review_count=0, + is_on_sale=True, + crawled_at=datetime.now(), + subtitle="【寶拉珍選】水楊酸身體乳雙入組 (2%水楊酸身體乳 210ml x2)", + match_name="【寶拉珍選】水楊酸身體乳雙入組 (2%水楊酸身體乳 210ml x2)", + ) + captured = {} + + def fake_score(momo_name, competitor_name, **kwargs): + captured["competitor_name"] = competitor_name + return SimpleNamespace(score=0.91) + + monkeypatch.setattr("services.marketplace_product_matcher.score_marketplace_match", fake_score) + + ranked = _rank_match_details("【Paulas Choice 寶拉珍選】2%水楊酸身體乳210ml二入", [product]) + + assert ranked[0][0] is product + assert ranked[0][1] == 0.91 + assert "210ml x2" in captured["competitor_name"] + + def test_competitor_feeder_persists_all_match_attempt_outcomes(): source = (ROOT / "services/competitor_price_feeder.py").read_text(encoding="utf-8") migration = (ROOT / "migrations/023_competitor_match_attempts.sql").read_text(encoding="utf-8") diff --git a/tests/test_event_router.py b/tests/test_event_router.py index f9cf941..e24d4e6 100644 --- a/tests/test_event_router.py +++ b/tests/test_event_router.py @@ -194,6 +194,9 @@ def test_dispatch_decision_envelope_skips_ai_handler(monkeypatch): assert "📊 價格證據" in message assert "🧩 比對證據" in message assert "✅ 人工下一步" in message + assert "🚦 通知分級" in message + assert "直接價格威脅" in message + assert "高信心同款 / 總價可比 / 可直接價格告警" in message assert "SKU:SKU-1" in message assert "MOMO:NT$ 120" in message assert "PChome:NT$ 100" in message @@ -204,6 +207,49 @@ def test_dispatch_decision_envelope_skips_ai_handler(monkeypatch): assert kwargs["reply_markup"]["inline_keyboard"][0][0]["callback_data"].startswith("momo:eig:") +def test_price_decision_template_marks_unit_price_review_as_non_direct_alert(): + from services.telegram_templates import _format_decision_envelope + + envelope = { + "decision_id": "review_queue:SKU-UNIT", + "decision_type": "pchome_match_review", + "severity": "P2", + "confidence": 0.82, + "subject": { + "sku": "SKU-UNIT", + "name": "測試乳液 500ml x2", + "momo_price": 1200, + "competitor_price": 950, + "competitor_product_id": "PCH-UNIT", + "competitor_product_name": "測試乳液 1000ml", + }, + "evidence": [ + { + "type": "match", + "metric": "match_score", + "value": 0.82, + "basis": "same_product_different_pack/unit_price/unit_price_review", + } + ], + "recommended_action": {"action": "unit_price_required", "owner": "營運", "requires_hitl": True}, + "guardrails": { + "can_auto_execute": False, + "data_quality": "partial", + "match_type": "same_product_different_pack", + "price_basis": "unit_price", + "alert_tier": "unit_price_review", + }, + } + + message = "\n".join(_format_decision_envelope(envelope)) + + assert "🚦 通知分級" in message + assert "單位價覆核" in message + assert "同商品不同包裝 / 單位價可比 / 單位價覆核" in message + assert "禁止用總價直接判定價格威脅" in message + assert "改用單位價覆核,不寫總價型價差" in message + + def test_replay_failed_deliveries_removes_successful_records(tmp_path, monkeypatch): import services.event_router as event_router diff --git a/tests/test_nemotron_decision_envelope.py b/tests/test_nemotron_decision_envelope.py index c303d51..90f59a8 100644 --- a/tests/test_nemotron_decision_envelope.py +++ b/tests/test_nemotron_decision_envelope.py @@ -29,6 +29,8 @@ def test_price_decision_envelope_has_hilt_guardrails(): assert envelope["severity"] == "P1" assert envelope["confidence"] == 0.91 assert envelope["subject"]["sku"] == "10413050" + assert envelope["subject"]["momo_price"] == 1200 + assert envelope["subject"]["competitor_price"] == 999 assert envelope["subject"]["competitor_product_id"] == "PCHOME-1" assert envelope["guardrails"]["can_auto_execute"] is False assert envelope["guardrails"]["data_quality"] == "complete" @@ -38,6 +40,10 @@ def test_price_decision_envelope_has_hilt_guardrails(): assert envelope["recommended_action"]["requires_hitl"] is True assert envelope["expected_impact"]["revenue_loss_7d"] == 65000 assert envelope["expected_impact"]["gap_amount"] == 201 + assert envelope["expected_impact"]["momo_price"] == 1200 + assert envelope["expected_impact"]["competitor_price"] == 999 + assert envelope["expected_impact"]["candidate_gap_pct"] == 16.7 + assert envelope["expected_impact"]["sales_7d_delta_pct"] == -31.2 assert any(e["metric"] == "match_score" and e["value"] == 0.93 for e in envelope["evidence"]) diff --git a/tests/test_pchome_crawler_search.py b/tests/test_pchome_crawler_search.py index ed73e86..598b10e 100644 --- a/tests/test_pchome_crawler_search.py +++ b/tests/test_pchome_crawler_search.py @@ -107,6 +107,7 @@ def test_pchome_fetch_product_details_accepts_list_payload(): { "Id": "DDABCD-12345678", "Name": "測試商品 50ml", + "Nick": "測試商品 50ml x2 限量組", "Price": {"P": 799, "M": 999}, "Pic": {"B": "/items/DDABCD12345678.jpg"}, "Qty": 8, @@ -124,6 +125,16 @@ def test_pchome_fetch_product_details_accepts_list_payload(): assert len(calls) == 1 assert [product.product_id for product in products] == ["DDABCD-12345678"] assert products[0].price == 799 + assert products[0].subtitle == "測試商品 50ml x2 限量組" + assert products[0].match_name == "測試商品 50ml x2 限量組" + + +def test_pchome_match_name_combines_non_duplicate_nick(): + from services.pchome_crawler import _build_match_name + + assert _build_match_name("水楊酸身體乳雙入組", "2% 水楊酸身體乳 210ml x2") == ( + "水楊酸身體乳雙入組 2% 水楊酸身體乳 210ml x2" + ) def test_feeder_search_cleanup_preserves_bracket_brand_and_specs():