From 01119c2c8217a6346c62c5fef57ee721f11044a6 Mon Sep 17 00:00:00 2001 From: OoO Date: Tue, 2 Jun 2026 11:18:52 +0800 Subject: [PATCH] =?UTF-8?q?V10.570=20=E8=A3=9C=20PChome=20=E8=BA=AB?= =?UTF-8?q?=E4=BB=BD=E5=A0=B1=E5=83=B9=E8=AD=89=E6=93=9A=E5=A5=91=E7=B4=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- TODO_NEXT_STEPS.txt | 1 + config.py | 2 +- docs/memory/history_logs.md | 1 + services/competitor_intel_repository.py | 200 ++++++++++++++++++ services/competitor_price_feeder.py | 2 + services/marketplace_product_matcher.py | 187 +++++++++++++++- tests/test_competitor_intel_cache.py | 37 ++++ ...t_competitor_match_attempts_persistence.py | 6 + 8 files changed, 434 insertions(+), 2 deletions(-) diff --git a/TODO_NEXT_STEPS.txt b/TODO_NEXT_STEPS.txt index 8a2e672..e74a65e 100644 --- a/TODO_NEXT_STEPS.txt +++ b/TODO_NEXT_STEPS.txt @@ -4,6 +4,7 @@ ================================================================================ 【已完成】 + - V10.570 補 PChome 身份 / 報價證據契約:matcher 的 `match_diagnostic_json` 新增 `identity_evidence`、`offer_evidence`,把品牌、品類、identity anchor、型號、規格、入數與 variant guardrail 拆成結構化證據;覆核隊列與 decision envelope 新增 `difference_highlights`,可直接指出容量、入數、色號、香味、款式、補充包、檔期組合等差異。價格明確標記為 offer evidence,不再被誤當身份證據,Dashboard / PPT / OpenClaw / Webcrumbs 能共用同一份比對證據。 - 外部專業 benchmark 固定節奏:已建立每週一 09:30 自動檢視,並新增 `docs/guides/external_professional_benchmark.md`,把 Google Merchant Center、Google Product structured data、Schema.org Product/Offer/AggregateOffer 與 Baymard 電商 UX 做法轉成可落地準則:identity evidence、fresh offer、review 差異高亮、PPT/AI evidence 分層。 - V10.565 補 PChome 覆蓋率操作建議:`/api/ai/pchome-match/backfill/status` 會把低覆蓋率拆成 `operation_backlog`,分別列出刷新舊 identity、重評近門檻、補抓未配對、人工覆核、單位價覆核與過期搜尋救援預覽;同時回傳 `recommended_next_action`,Dashboard 狀態摘要會顯示「建議執行比價補強 / 刷新過期 identity / 處理覆核」等下一步,讓覆蓋率 KPI 直接連到可執行行動。 - V10.563 收斂正式 preview 假可救候選:M.A.C 超持妝輕透濾鏡蜜粉若只有 PChome 端出現明確色號(例如 `#絕絕紫`),會標成 `variant_selection_review` 並維持 `true_low_confidence`,不再佔 recoverable 池;SAUGELLA 賽吉兒菁萃潔浴凝露新增潤澤 / 日用型 / 加強 / 黃金女郎型變體互斥,避免同品線不同私密清潔款式被誤救成 matched。 diff --git a/config.py b/config.py index f0843a0..79b5c87 100644 --- a/config.py +++ b/config.py @@ -402,7 +402,7 @@ YOUTUBE_API_KEY = os.getenv('YOUTUBE_API_KEY', '') # ========================================== # 系統版本與路徑 # ========================================== -SYSTEM_VERSION = "V10.569" +SYSTEM_VERSION = "V10.570" LOG_FILE_PATH = os.path.join(BASE_DIR, 'logs/system.log') public_url = PUBLIC_URL # 用於模板顯示 diff --git a/docs/memory/history_logs.md b/docs/memory/history_logs.md index 8886f72..0b6b420 100644 --- a/docs/memory/history_logs.md +++ b/docs/memory/history_logs.md @@ -13,6 +13,7 @@ ## 📅 詳細更新日誌 (考古存檔) ### 2026-06-01:PChome 比價新鮮度操作閉環 +- **V10.570 PChome 身份 / 報價證據契約**: `score_marketplace_match()` 現在會在 `match_diagnostic_json` 內輸出 `identity_evidence` 與 `offer_evidence`,把品牌、品類、identity anchor、型號、規格、入數、variant guardrail 與價格 offer 拆層保存。`competitor_intel_repository` 會把這些證據轉成 `difference_highlights` 與 decision envelope 的 identity / offer evidence,讓覆核頁、PPT、OpenClaw、Webcrumbs 與 Telegram 摘要都能理解「為何同款 / 為何不同 / 價格只是報價證據不是身份證據」。 - **V10.569 Webcrumbs 比價信封摘要串接**: `build_webcrumbs_marketplace_host_data()` 讀取 `fetch_competitor_review_queue()` 後統一走 `summarize_review_decision_envelopes()`,在 host data payload 輸出 `reviewDecisionBrief`,並於 metadata 增加 `review_queue_count`、`hitl_count`、`auto_execute_blocked_count` 與 `decision_envelope_source`。Webcrumbs / Shared UI 現在和 Telegram、OpenClaw、PPT 共用同一份 PChome 覆核信封摘要,仍維持只讀、不呼叫 LLM、不抓外站、不寫 DB;同版收錄 `docs/guides/external_professional_benchmark.md` 作為外部專業做法週巡檢落地準則入口。 - **V10.568 價格類決策信封專業 brief**: `decision_envelope` 的價格 / PChome 覆核事件在 Telegram EventRouter 直送時,改以「標的、價格證據、比對證據、人工下一步」四段式排版呈現,保留 `momo:eig:` 忽略按鈕且不進 L1/L2 AI 重摘要。`competitor_intel_repository` 同步在 review queue 信封 subject 補上 `momo_price` / `competitor_price`,讓 Telegram、PPT、Webcrumbs 與 AI 摘要可共用同一份價格證據,不再各自補查或重組。 - **V10.567 MCP 市場洞察 GCP-only fallback**: `MCPCollectorService._ollama_topic_fallback()` 改成只使用 GCP-A / GCP-B Ollama,失敗後保守回本地 fallback,不再把市場洞察長分析轉嫁到 111。預設 timeout 收斂為 25 秒、`num_predict` 收斂為 500,避免 Elephant Alpha 在 GCP-A/GCP-B 短暫不可用時撞 60 秒總上限或造成 111 負載尖峰;Gemini 仍維持 `GEMINI_API_HARD_DISABLED=true` 預設硬封鎖。 diff --git a/services/competitor_intel_repository.py b/services/competitor_intel_repository.py index e4679df..dcf131a 100644 --- a/services/competitor_intel_repository.py +++ b/services/competitor_intel_repository.py @@ -178,6 +178,32 @@ ALERT_TIER_LABELS = { "identity_review": "身份覆核", "suppress": "不告警", } +DIFFERENCE_DIMENSION_LABELS = { + "volume_conflict": "容量差異", + "weight_conflict": "重量差異", + "dosage_conflict": "劑量差異", + "count_conflict": "入數/件數差異", + "component_count_conflict": "組合件數差異", + "multi_component_conflict": "組合內容差異", + "multi_component_count_conflict": "組合件數差異", + "pack_quantity_difference": "包裝數量差異", + "catalog_count_omission": "型錄入數待確認", + "unit_comparable": "單位價待確認", + "variant_selection_review": "色號/香味/款式待確認", + "variant_option_conflict": "選項款式不同", + "variant_descriptor_conflict": "款式描述不同", + "makeup_finish_conflict": "妝效/質地不同", + "makeup_usage_conflict": "彩妝用途不同", + "aroma_scent_variant_conflict": "香味/香型不同", + "unknown_scent_variant_conflict": "香味未明確對齊", + "nail_polish_color_name_conflict": "指彩色號不同", + "nail_polish_model_code_conflict": "指彩型號不同", + "saugella_variant_conflict": "私密清潔款式不同", + "lactacyd_variant_conflict": "私密清潔款式不同", + "refill_pack_conflict": "補充包/正裝差異", + "bundle_offer_conflict": "檔期組合條件差異", + "accessory_case_conflict": "配件/盒裝差異", +} COMPETITOR_INTEL_CACHE_TTL_SECONDS = int(os.getenv("COMPETITOR_INTEL_CACHE_TTL_SECONDS", "1800")) _BASE_DIR = Path(__file__).resolve().parents[1] _CACHE_FILE = _BASE_DIR / "data" / "competitor_intel_cache.pkl" @@ -299,6 +325,119 @@ def _extract_match_diagnostic_reasons( return reasons +def _build_identity_evidence_summary(identity_evidence: Any) -> str: + if not isinstance(identity_evidence, dict) or not identity_evidence: + return "" + parts: list[str] = [] + brand = identity_evidence.get("brand") + if isinstance(brand, dict): + shared_brand = brand.get("shared") + if isinstance(shared_brand, list) and shared_brand: + parts.append(f"品牌 {'/'.join(str(item) for item in shared_brand[:3])}") + product_type = identity_evidence.get("product_type") + if isinstance(product_type, dict) and product_type.get("matched"): + parts.append(f"品類 {product_type.get('momo') or product_type.get('competitor')}") + anchor = str(identity_evidence.get("identity_anchor") or "") + if anchor: + parts.append(f"身份錨點 {anchor[:18]}") + models = identity_evidence.get("shared_model_tokens") + if isinstance(models, list) and models: + parts.append(f"型號 {'/'.join(str(item) for item in models[:3])}") + specs = identity_evidence.get("specs") + if isinstance(specs, dict): + mismatches = specs.get("mismatches") + if isinstance(mismatches, list) and mismatches: + labels = [ + str(item.get("label") or item.get("field") or "") + for item in mismatches + if isinstance(item, dict) + ] + labels = [label for label in labels if label] + if labels: + parts.append(f"差異 {', '.join(labels[:3])}") + return ";".join(parts) + + +def _build_review_difference_highlights( + diagnostic_reasons: list[dict[str, str]], + identity_evidence: Any, +) -> list[dict[str, Any]]: + highlights: list[dict[str, Any]] = [] + seen: set[str] = set() + + def add( + *, + code: str, + label: str, + dimension: str, + severity: str = "review", + momo: Any = None, + competitor: Any = None, + source: str = "matcher_reason", + ) -> None: + key = f"{source}:{code}:{dimension}" + if key in seen: + return + seen.add(key) + row: dict[str, Any] = { + "code": code, + "label": label, + "dimension": dimension, + "severity": severity, + "source": source, + } + if momo not in (None, "", []): + row["momo"] = momo + if competitor not in (None, "", []): + row["competitor"] = competitor + highlights.append(row) + + for reason in diagnostic_reasons or []: + code = str(reason.get("code") or "") + if not code: + continue + dimension = DIFFERENCE_DIMENSION_LABELS.get(code) + if dimension: + add( + code=code, + label=reason.get("label") or dimension, + dimension=dimension, + severity="blocker" if "conflict" in code else "review", + ) + + if isinstance(identity_evidence, dict): + specs = identity_evidence.get("specs") + if isinstance(specs, dict): + for mismatch in specs.get("mismatches") or []: + if not isinstance(mismatch, dict): + continue + field = str(mismatch.get("field") or "spec") + label = str(mismatch.get("label") or field) + add( + code=f"spec_{field}", + label=label, + dimension=label, + severity="review" if mismatch.get("needs_review") else "blocker", + momo=mismatch.get("momo"), + competitor=mismatch.get("competitor"), + source="identity_evidence", + ) + guardrails = identity_evidence.get("variant_guardrails") + if isinstance(guardrails, dict): + for reason_code in guardrails.get("conflict_reasons") or []: + code = str(reason_code or "") + dimension = DIFFERENCE_DIMENSION_LABELS.get(code) + if dimension: + add( + code=code, + label=MATCH_DIAGNOSTIC_REASON_LABELS.get(code, dimension), + dimension=dimension, + severity="blocker" if "conflict" in code else "review", + source="identity_evidence", + ) + return highlights + + def _build_unit_comparison_for_attempt(row: dict[str, Any]) -> Optional[dict[str, Any]]: status = str(row.get("attempt_status") or "") if status not in UNIT_PRICE_DECISION_STATUSES: @@ -475,6 +614,25 @@ def _build_review_decision_envelope(item: dict[str, Any]) -> dict[str, Any]: "value": f"{gap_pct:+.1f}%", "basis": "MOMO latest price + PChome review candidate", }) + identity_evidence = item.get("identity_evidence") + identity_summary = _build_identity_evidence_summary(identity_evidence) + if identity_summary: + evidence.append({ + "type": "identity", + "metric": "identity_evidence", + "value": identity_summary, + "basis": "match_diagnostic_json.identity_evidence", + }) + offer_evidence = item.get("offer_evidence") + if isinstance(offer_evidence, dict) and offer_evidence: + offer_basis = "price is offer evidence, not identity evidence" + offer_gap = offer_evidence.get("gap_pct") + evidence.append({ + "type": "offer", + "metric": "offer_evidence", + "value": f"{_num(offer_gap):+.1f}%" if isinstance(offer_gap, (int, float)) else item.get("price_basis_label") or "", + "basis": offer_basis, + }) reason_text = item.get("diagnostic_reason_text") if reason_text: evidence.append({ @@ -483,6 +641,14 @@ def _build_review_decision_envelope(item: dict[str, Any]) -> dict[str, Any]: "value": reason_text, "basis": "match_diagnostic_json.reasons", }) + difference_highlights = item.get("difference_highlights") + if isinstance(difference_highlights, list) and difference_highlights: + evidence.append({ + "type": "identity_diff", + "metric": "difference_highlights", + "value": "、".join(str(row.get("dimension") or row.get("label") or "") for row in difference_highlights[:4]), + "basis": "structured identity evidence + matcher guardrails", + }) unit_price_insight = item.get("unit_price_insight") if isinstance(unit_price_insight, dict) and unit_price_insight: evidence.append({ @@ -528,6 +694,9 @@ def _build_review_decision_envelope(item: dict[str, Any]) -> dict[str, Any]: "competitor_product_name": item.get("candidate_pc_name") or "", }, "evidence": evidence, + "identity_evidence": identity_evidence if isinstance(identity_evidence, dict) else {}, + "offer_evidence": offer_evidence if isinstance(offer_evidence, dict) else {}, + "difference_highlights": difference_highlights if isinstance(difference_highlights, list) else [], "recommended_action": { "action": _review_action_code(attempt_status), "owner": "營運", @@ -550,6 +719,12 @@ def _build_review_decision_envelope(item: dict[str, Any]) -> dict[str, Any]: "match_type": item.get("match_type") or "", "price_basis": item.get("price_basis") or "", "alert_tier": item.get("alert_tier") or "", + "identity_evidence_version": ( + identity_evidence.get("version") + if isinstance(identity_evidence, dict) + else "" + ), + "price_is_identity_evidence": False, }, "trace": { "source": "competitor_match_attempts", @@ -605,6 +780,17 @@ def summarize_review_decision_envelopes( if isinstance(unit_gap_pct, (int, float)) else "" ) + difference_highlights = envelope.get("difference_highlights") + diff_text = "" + if isinstance(difference_highlights, list) and difference_highlights: + diff_dimensions = [ + str(item.get("dimension") or item.get("label") or "") + for item in difference_highlights + if isinstance(item, dict) + ] + diff_dimensions = [item for item in diff_dimensions if item] + if diff_dimensions: + diff_text = "差異 " + "、".join(diff_dimensions[:3]) evidence_basis = "" for evidence_row in evidence: if isinstance(evidence_row, dict) and evidence_row.get("metric") == "match_score": @@ -630,6 +816,8 @@ def summarize_review_decision_envelopes( line_parts.append(gap_text) if unit_text: line_parts.append(unit_text) + if diff_text: + line_parts.append(diff_text) if evidence_basis: line_parts.append(evidence_basis) line = " | ".join(part for part in line_parts if part) @@ -648,6 +836,7 @@ def summarize_review_decision_envelopes( "can_auto_execute": can_auto_execute, "candidate_gap_pct": gap_pct, "unit_price_gap_pct": unit_gap_pct, + "difference_highlights": difference_highlights if isinstance(difference_highlights, list) else [], "line": line, }) @@ -677,7 +866,14 @@ def _format_competitor_review_item(row: dict[str, Any]) -> dict[str, Any]: price_basis = diagnostic_payload.get("price_basis") or _tag_suffix(tags, "price_basis") or "" alert_tier = diagnostic_payload.get("alert_tier") or _tag_suffix(tags, "alert_tier") or "" evidence_flags = diagnostic_payload.get("evidence_flags") or [] + identity_evidence = diagnostic_payload.get("identity_evidence") + if not isinstance(identity_evidence, dict): + identity_evidence = {} + offer_evidence = diagnostic_payload.get("offer_evidence") + if not isinstance(offer_evidence, dict): + offer_evidence = {} diagnostic_reasons = _extract_match_diagnostic_reasons(match_diagnostic, diagnostic_payload) + difference_highlights = _build_review_difference_highlights(diagnostic_reasons, identity_evidence) existing_match_conflict = _parse_existing_match_conflict(match_diagnostic) formatted = { "sku": str(item.get("sku") or ""), @@ -700,6 +896,10 @@ def _format_competitor_review_item(row: dict[str, Any]) -> dict[str, Any]: "alert_tier": alert_tier, "alert_tier_label": ALERT_TIER_LABELS.get(alert_tier, alert_tier or "待判讀"), "evidence_flags": list(evidence_flags) if isinstance(evidence_flags, list) else [], + "identity_evidence": identity_evidence, + "identity_evidence_summary": _build_identity_evidence_summary(identity_evidence), + "offer_evidence": offer_evidence, + "difference_highlights": difference_highlights, "diagnostic_reasons": diagnostic_reasons, "diagnostic_reason_text": "、".join(reason["label"] for reason in diagnostic_reasons), "existing_match_conflict": existing_match_conflict, diff --git a/services/competitor_price_feeder.py b/services/competitor_price_feeder.py index 21a31b6..dd9765a 100644 --- a/services/competitor_price_feeder.py +++ b/services/competitor_price_feeder.py @@ -591,6 +591,8 @@ def _match_diagnostics_payload(diagnostics) -> dict: "alert_tier": getattr(diagnostics, "alert_tier", None), "evidence_flags": list(getattr(diagnostics, "evidence_flags", ()) or ()), "reasons": list(getattr(diagnostics, "reasons", ()) or ()), + "identity_evidence": getattr(diagnostics, "identity_evidence", None) or {}, + "offer_evidence": getattr(diagnostics, "offer_evidence", None) or {}, } diff --git a/services/marketplace_product_matcher.py b/services/marketplace_product_matcher.py index 95ec80a..17832e3 100644 --- a/services/marketplace_product_matcher.py +++ b/services/marketplace_product_matcher.py @@ -9,7 +9,7 @@ from __future__ import annotations import re import unicodedata -from dataclasses import dataclass +from dataclasses import dataclass, field from difflib import SequenceMatcher from typing import Iterable, Optional @@ -938,6 +938,8 @@ class MatchDiagnostics: price_basis: str = "total_price" alert_tier: str = "price_alert_exact" evidence_flags: tuple[str, ...] = () + identity_evidence: dict[str, object] = field(default_factory=dict) + offer_evidence: dict[str, object] = field(default_factory=dict) @property def tags(self) -> list[str]: @@ -1995,6 +1997,162 @@ def _build_evidence_flags( return _dedupe_tuple(flags) +def _number_values(values: Iterable[float]) -> list[float | int]: + result: list[float | int] = [] + for value in values or (): + try: + number = float(value) + except (TypeError, ValueError): + continue + result.append(int(number) if number.is_integer() else round(number, 3)) + return result + + +def _count_values(values: Iterable[tuple[int, str]]) -> list[str]: + return [f"{count}{unit}" for count, unit in sorted(set(values or ()))] + + +def _identity_spec_payload(identity: ProductIdentity) -> dict[str, object]: + return { + "volumes_ml": _number_values(identity.volumes_ml), + "weights_g": _number_values(identity.weights_g), + "dosages_mg": _number_values(identity.dosages_mg), + "counts": _count_values(identity.counts), + "total_piece_count": identity.total_piece_count, + } + + +def _spec_mismatch_payload(left: ProductIdentity, right: ProductIdentity) -> list[dict[str, object]]: + specs = ( + ("volume_ml", "容量", _number_values(left.volumes_ml), _number_values(right.volumes_ml)), + ("weight_g", "重量", _number_values(left.weights_g), _number_values(right.weights_g)), + ("dosage_mg", "劑量", _number_values(left.dosages_mg), _number_values(right.dosages_mg)), + ("count", "入數/件數", _count_values(left.counts), _count_values(right.counts)), + ) + mismatches: list[dict[str, object]] = [] + for field_name, label, momo_values, competitor_values in specs: + if momo_values and competitor_values and set(momo_values).isdisjoint(set(competitor_values)): + mismatches.append({ + "field": field_name, + "label": label, + "momo": momo_values, + "competitor": competitor_values, + }) + elif bool(momo_values) != bool(competitor_values): + mismatches.append({ + "field": field_name, + "label": f"{label}單側缺漏", + "momo": momo_values, + "competitor": competitor_values, + "needs_review": True, + }) + return mismatches + + +def _identity_evidence_payload( + left: ProductIdentity, + right: ProductIdentity, + *, + brand_score: float, + token_score: float, + spec_score: float, + sequence_score: float, + type_score: float, + hard_veto: bool, + comparison_mode: str, + match_type: str, + price_basis: str, + alert_tier: str, + shared_anchor: str, + shared_models: set[str], + reasons: Iterable[str], + catalog_count_omission: bool, +) -> dict[str, object]: + reason_set = set(reasons or ()) + conflict_reasons = [ + reason for reason in reason_set + if "conflict" in reason + or reason in { + "variant_selection_review", + "catalog_count_omission", + "pack_quantity_difference", + "unit_comparable", + } + ] + shared_brand = sorted(left.brand_tokens & right.brand_tokens) + shared_core = sorted((left.core_tokens & right.core_tokens) - left.brand_tokens - right.brand_tokens)[:20] + return { + "version": "identity_evidence_v1", + "lane": { + "comparison_mode": comparison_mode, + "match_type": match_type, + "price_basis": price_basis, + "alert_tier": alert_tier, + }, + "confidence_components": { + "brand_score": round(brand_score, 3), + "token_score": round(token_score, 3), + "spec_score": round(spec_score, 3), + "sequence_score": round(sequence_score, 3), + "type_score": round(type_score, 3), + }, + "brand": { + "momo": sorted(left.brand_tokens), + "competitor": sorted(right.brand_tokens), + "shared": shared_brand, + }, + "product_type": { + "momo": left.product_type or "", + "competitor": right.product_type or "", + "matched": bool(left.product_type and right.product_type and left.product_type == right.product_type), + }, + "identity_anchor": shared_anchor or "", + "shared_model_tokens": sorted(shared_models), + "shared_core_tokens": shared_core, + "specs": { + "momo": _identity_spec_payload(left), + "competitor": _identity_spec_payload(right), + "mismatches": _spec_mismatch_payload(left, right), + }, + "variant_guardrails": { + "hard_veto": bool(hard_veto), + "conflict_reasons": sorted(conflict_reasons), + "catalog_count_omission": bool(catalog_count_omission), + }, + } + + +def _offer_evidence_payload( + momo_price: Optional[float], + competitor_price: Optional[float], + *, + price_penalty: float, + price_basis: str, + alert_tier: str, +) -> dict[str, object]: + payload: dict[str, object] = { + "version": "offer_evidence_v1", + "price_basis": price_basis, + "alert_tier": alert_tier, + "price_is_identity_evidence": False, + "price_penalty": round(price_penalty, 3), + } + try: + momo_value = float(momo_price) if momo_price is not None else None + competitor_value = float(competitor_price) if competitor_price is not None else None + except (TypeError, ValueError): + momo_value = None + competitor_value = None + if momo_value is not None: + payload["momo_price"] = round(momo_value, 2) + if competitor_value is not None: + payload["competitor_price"] = round(competitor_value, 2) + if momo_value is not None and competitor_value and competitor_value > 0: + payload["gap_amount"] = round(momo_value - competitor_value, 2) + payload["gap_pct"] = round((momo_value - competitor_value) / max(competitor_value, 1) * 100, 2) + return payload + + def _has_safe_multi_component_exact_total_price( left: ProductIdentity, right: ProductIdentity, @@ -2982,6 +3140,31 @@ def score_marketplace_match( reasons=reason_tuple, catalog_count_omission=catalog_count_omission, ) + identity_evidence = _identity_evidence_payload( + left, + right, + brand_score=brand_score, + token_score=token_score, + spec_score=spec_score, + sequence_score=sequence_score, + type_score=type_score, + hard_veto=hard_veto, + comparison_mode=comparison_mode, + match_type=match_type, + price_basis=price_basis, + alert_tier=alert_tier, + shared_anchor=shared_anchor, + shared_models=shared_models, + reasons=reason_tuple, + catalog_count_omission=catalog_count_omission, + ) + offer_evidence = _offer_evidence_payload( + momo_price, + competitor_price, + price_penalty=price_penalty, + price_basis=price_basis, + alert_tier=alert_tier, + ) return MatchDiagnostics( score=round(score, 3), @@ -2998,6 +3181,8 @@ def score_marketplace_match( price_basis=price_basis, alert_tier=alert_tier, evidence_flags=evidence_flags, + identity_evidence=identity_evidence, + offer_evidence=offer_evidence, ) diff --git a/tests/test_competitor_intel_cache.py b/tests/test_competitor_intel_cache.py index acb7152..dbfdad2 100644 --- a/tests/test_competitor_intel_cache.py +++ b/tests/test_competitor_intel_cache.py @@ -202,6 +202,32 @@ def test_competitor_review_reasons_prefer_json_payload_labels(): "match_type": "no_match", "price_basis": "none", "alert_tier": "suppress", + "identity_evidence": { + "version": "identity_evidence_v1", + "brand": {"momo": ["mac"], "competitor": ["mac"], "shared": ["mac"]}, + "product_type": {"momo": "唇膏", "competitor": "唇膏", "matched": True}, + "identity_anchor": "macximal 柔霧唇膏", + "shared_model_tokens": [], + "specs": { + "momo": {"volumes_ml": [], "weights_g": [], "dosages_mg": [], "counts": [], "total_piece_count": None}, + "competitor": {"volumes_ml": [], "weights_g": [], "dosages_mg": [], "counts": [], "total_piece_count": None}, + "mismatches": [], + }, + "variant_guardrails": { + "hard_veto": True, + "conflict_reasons": ["makeup_finish_conflict"], + "catalog_count_omission": False, + }, + }, + "offer_evidence": { + "version": "offer_evidence_v1", + "price_basis": "none", + "alert_tier": "suppress", + "momo_price": 990, + "competitor_price": 880, + "gap_pct": 12.5, + "price_is_identity_evidence": False, + }, "reasons": [ "makeup_finish_conflict", "nail_tool_function_conflict", @@ -215,6 +241,9 @@ def test_competitor_review_reasons_prefer_json_payload_labels(): assert item["price_basis_label"] == "不可比" assert item["alert_tier_label"] == "不告警" assert item["diagnostic_reason_text"] == "妝效質地不同、工具功能不同、除毛刀品線不同" + assert item["identity_evidence_summary"].startswith("品牌 mac") + assert item["offer_evidence"]["price_is_identity_evidence"] is False + assert item["difference_highlights"][0]["dimension"] == "妝效/質地不同" assert [reason["code"] for reason in item["diagnostic_reasons"]] == [ "makeup_finish_conflict", "nail_tool_function_conflict", @@ -227,9 +256,17 @@ def test_competitor_review_reasons_prefer_json_payload_labels(): assert envelope["guardrails"]["can_auto_execute"] is False assert envelope["guardrails"]["data_quality"] == "partial" assert envelope["guardrails"]["match_type"] == "no_match" + assert envelope["guardrails"]["identity_evidence_version"] == "identity_evidence_v1" + assert envelope["guardrails"]["price_is_identity_evidence"] is False + assert envelope["identity_evidence"]["brand"]["shared"] == ["mac"] + assert envelope["offer_evidence"]["gap_pct"] == 12.5 + assert envelope["difference_highlights"][0]["dimension"] == "妝效/質地不同" assert envelope["recommended_action"]["requires_hitl"] is True assert envelope["recommended_action"]["action"] == "verify_or_reject_identity" assert any(evidence["metric"] == "reasons" for evidence in envelope["evidence"]) + assert any(evidence["metric"] == "identity_evidence" for evidence in envelope["evidence"]) + assert any(evidence["metric"] == "offer_evidence" for evidence in envelope["evidence"]) + assert any(evidence["metric"] == "difference_highlights" for evidence in envelope["evidence"]) def test_rescore_accepted_review_item_has_actionable_decision_envelope(): diff --git a/tests/test_competitor_match_attempts_persistence.py b/tests/test_competitor_match_attempts_persistence.py index 727b9dd..706b41a 100644 --- a/tests/test_competitor_match_attempts_persistence.py +++ b/tests/test_competitor_match_attempts_persistence.py @@ -564,6 +564,12 @@ def test_match_diagnostics_payload_carries_professional_match_lanes(): assert payload["price_basis"] == "unit_price" assert payload["alert_tier"] == "unit_price_review" assert "unit_comparable" in payload["evidence_flags"] + assert payload["identity_evidence"]["version"] == "identity_evidence_v1" + assert payload["identity_evidence"]["lane"]["price_basis"] == "unit_price" + assert payload["identity_evidence"]["specs"]["mismatches"][0]["field"] == "count" + assert payload["offer_evidence"]["version"] == "offer_evidence_v1" + assert payload["offer_evidence"]["price_is_identity_evidence"] is False + assert payload["offer_evidence"]["gap_pct"] == 76.58 assert "match_type_same_product_different_pack" in tags assert "price_basis_unit_price" in tags assert "alert_tier_unit_price_review" in tags