From 5a5f268358c70f13da0e51d68a2c8af5c5a42df2 Mon Sep 17 00:00:00 2001 From: OoO Date: Thu, 21 May 2026 18:58:52 +0800 Subject: [PATCH] =?UTF-8?q?=E5=BC=B7=E5=8C=96=20EA=20JSON=20fallback=20?= =?UTF-8?q?=E8=88=87=20EDM=20cache=20=E8=87=AA=E7=99=92?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- config.py | 2 +- docs/AI_INTELLIGENCE_MODULE_SOT.md | 2 + docs/memory/history_logs.md | 1 + routes/edm_routes.py | 27 +++-- services/elephant_alpha_orchestrator.py | 118 +++++++++++++++++++--- services/marketplace_product_matcher.py | 4 + tests/test_cache_manager.py | 25 +++++ tests/test_elephant_alpha_orchestrator.py | 99 ++++++++++++++++++ tests/test_marketplace_product_matcher.py | 32 ++++++ 9 files changed, 288 insertions(+), 22 deletions(-) create mode 100644 tests/test_elephant_alpha_orchestrator.py diff --git a/config.py b/config.py index faee148..d2bfeb5 100644 --- a/config.py +++ b/config.py @@ -325,7 +325,7 @@ YOUTUBE_API_KEY = os.getenv('YOUTUBE_API_KEY', '') # ========================================== # 系統版本與路徑 # ========================================== -SYSTEM_VERSION = "V10.382" +SYSTEM_VERSION = "V10.383" LOG_FILE_PATH = os.path.join(BASE_DIR, 'logs/system.log') public_url = PUBLIC_URL # 用於模板顯示 diff --git a/docs/AI_INTELLIGENCE_MODULE_SOT.md b/docs/AI_INTELLIGENCE_MODULE_SOT.md index 807b898..7996f93 100644 --- a/docs/AI_INTELLIGENCE_MODULE_SOT.md +++ b/docs/AI_INTELLIGENCE_MODULE_SOT.md @@ -117,6 +117,7 @@ SQL漏斗(~300筆) - ElephantAlpha 使用 NVIDIA NIM hosted API;production 預設模型為 `nvidia/llama-3.3-nemotron-super-49b-v1.5`,`ELEPHANT_ALPHA_FALLBACK_MODELS` 需保留至少一個可呼叫備援;403/404、408/409/425/429、5xx、timeout 與 connection error 必須嘗試下一個模型。 - ElephantAlpha L3 HITL 只允許發送有實證、可審核、可行動的升級告警;價格類 trigger 無 Hermes 具體威脅時,只記錄 suppressed escalation telemetry 與 cooldown,不寫 pending `human_review`,不發 Telegram 空告警。 - ElephantAlpha 價格類 trigger 的 HITL / 決策 prefetch 必須先使用觸發 SQL 與 `competitor_prices` / `price_records` 的 DB 實證生成 SKU、MOMO / PChome 價差與建議 action lines;完整 Hermes LLM prefetch 預設關閉(`ELEPHANT_ALPHA_HERMES_LLM_PREFETCH_ENABLED=false`),避免 5s timeout 後落入無實證摘要或雲端備援。若無 DB 實證,只記錄 suppressed telemetry / cooldown,不發 Telegram 空告警。 +- ElephantAlpha 協調器收到非純 JSON、fenced JSON 或混文字 JSON 時,必須先做容錯抽取;仍無法解析時,只能使用 DB/Hermes 實證生成保守 HITL fallback。fallback 不得放入 OpenClaw `generate_*` 類舊策略步驟,也不得暗示已自動調價。 - `resource_optimization` 不再交給 LLM 生成「預期效益 / 已執行」敘事,顯示名稱統一為「資源壓力治理」。此 trigger 必須先由程式量測 `action_plans` backlog、P1/P2 數、pending_review、逾時項目與 CPU load;只有 CPU 達門檻、P1/P2 積壓或逾時積壓才發 Telegram「資源壓力告警」。單純 queue 大但 CPU 正常只記錄 telemetry,不派發 Hermes/NemoTron、不宣稱 48 小時效益;Telegram 段落使用「系統處置紀錄」而非泛稱「已執行」,避免暗示 AI 已完成未經驗證的外部動作。 - `resource_optimization` 會先執行 `ActionPlanHygieneService` 清理過期噪音:只關閉超過 72 小時的 `code_review_fix` / `openclaw_recommendation` 類 advisory action_plans,以及 NemoTron `direct_response/reply_simple` 舊聊天回覆計畫;將狀態改為 `auto_disabled` 或 `rejected` 並寫入 `metadata_json.hygiene_history`。不刪資料,也不碰 NemoTron human_review / pricing / tool action 類業務行動。 - `momo-scheduler` 每 6 小時固定執行 `run_action_plan_hygiene_task()`,讓過期 advisory action_plans 的關閉不再依賴 `resource_optimization` 告警觸發;排程失敗會經 EventRouter 發送 `action_plan_hygiene_failure`。 @@ -617,5 +618,6 @@ POSTGRES_HOST=momo-db | 2026-05-20 | 指定日期競品簡報可能混用目前 `competitor_prices` 快取價 | V10.315 起 `fetch_competitor_comparison_results()` 有 start/end date 時改用 `competitor_price_history` 期間快照,MOMO 價格取報表結束日前最新價;即時報表才使用目前有效 `competitor_prices` | | 2026-05-20 | PChome 覆蓋率分子可能被非活躍或無 MOMO 現價 SKU 膨脹 | V10.317 起 `fetch_competitor_coverage()` 的 `valid_matches` 改為 active MOMO latest price 與有效 PChome `identity_v2` 價格交集,確保 daily/growth/PPT/AI 看到的比價資料品質不被舊快取列高估 | | 2026-05-20 | EA HITL 告警可能把非 SKU 診斷誤排成待審 SKU,或在缺少 DB/Hermes 實證時打擾人工 | V10.318 起 `ea_escalation` 僅對含 SKU/價格比較的 actions 使用競價卡片;非 SKU 診斷改為「待確認事項」。價格類低信心事件若無 DB/Hermes 實證,測試鎖定只 suppress、不寫 human_review、不發 Telegram | +| 2026-05-21 | ElephantAlpha NIM/LLM 回應偶爾不是純 JSON,會觸發 `json.loads()` 失敗並落入舊式空泛策略 fallback | V10.383 起協調器容忍 fenced/混文字 JSON;無法解析時改用 DB/Hermes 實證 fallback,且 fallback 不再包含 OpenClaw `generate_*` 舊步驟或自動調價暗示 | | 2026-05-20 | Telegram HTML parse mode 不支援 `
`,可能導致告警或報告送出 400 | V10.321 起 Telegram template 發送前會把 `
` / `
` / `
` 轉為換行;保留其他 HTML 標籤,非 HTML parse mode 不改寫 | | 2026-05-20 | 部分舊 Telegram 入口繞過中央 sanitizer,且 RAG awaiting review 使用錯誤 `chat_id=` 參數會讓人工審核推播失敗 | V10.322 起 Bot API price decision 走 `send_telegram_with_result()`;`price_decision()` 補 `report_url` 相容並 escape 動態欄位;RAG awaiting review 改用 `chat_ids=[...]` 呼叫 `_send_telegram_raw()` | diff --git a/docs/memory/history_logs.md b/docs/memory/history_logs.md index ee716f3..1ba2f8a 100644 --- a/docs/memory/history_logs.md +++ b/docs/memory/history_logs.md @@ -13,6 +13,7 @@ ## 📅 詳細更新日誌 (考古存檔) ### 2026-05-21:瀏覽器測試守門與 PChome 熱路徑優化 +- **V10.383 EA JSON fallback / EDM cache 自癒 / 比對別名補強**: Elephant Alpha 協調器現在可容忍 fenced JSON 與混文字 JSON;若仍無法解析,會改用 DB/Hermes 實證產生保守人工覆核決策,不再輸出舊式 OpenClaw 策略 plan 或自動調價暗示。EDM promo dashboard shared cache 遇到損毀 pickle 會自動刪除並重建,避免每個 worker 重複噴 `UnicodeDecodeError`。Marketplace matcher 補上 Curel/珂潤、Karadium 與兩個強 identity anchor 測試,降低真同款漏報。 - **V10.382 唇膏 exact identity 寬價差豁免**: marketplace matcher 對「同品牌 + 共享唇膏 identity anchor + 規格完全一致 + 無色號/變體衝突」的唇膏類商品,允許 sequence score 略低時仍套用 `price_penalty_suppressed_wide_exact_identity`;這只處理 PChome/MOMO 標題順序與行銷字差異造成的真同款漏報,不放寬顯性色號不同的 hard veto。 - **V10.381 browse.sh 比價診斷計畫**: PChome feeder 在 `no_result`、`no_match`、低信心、單位價覆核、既有配對保護與爬蟲錯誤時,會把 read-only `browse_diagnostic_json` 寫入 `competitor_match_attempts`,內含 PChome search URL 與建議 `browse get/open` 命令;正式排程仍 API-first,`PCHOME_FEEDER_BROWSE_SH_EXECUTE_ENABLED=false` 預設不自動開瀏覽器,避免瀏覽器彈窗、登入或密碼提示干擾。 - **V10.380 111 Ollama final fallback 收斂**: 111 Mac fallback 從救急路徑改成更短的保護路徑,`OLLAMA_111_MAX_TIMEOUT` 預設由 45s 收緊到 20s,並新增 `OLLAMA_111_NUM_PREDICT=512` 輸出上限;落到 111 時仍會降級重模型到 `llama3.2:latest`、縮 `num_ctx=4096`、`keep_alive=5m`,避免 GCP-A/GCP-B 短暫 timeout 後把長篇 Hermes/OpenClaw 工作轉嫁到 111 造成 swap 與 load 飆高。 diff --git a/routes/edm_routes.py b/routes/edm_routes.py index 15837c3..7a5627b 100644 --- a/routes/edm_routes.py +++ b/routes/edm_routes.py @@ -120,11 +120,22 @@ def _load_shared_promo_dashboard_cache(cache_key): if not isinstance(entries, dict): return None return entries.get(cache_key) - except Exception: - sys_log.debug("promo dashboard shared cache load failed", exc_info=True) + except Exception as exc: + _discard_shared_promo_dashboard_cache("load_corrupt", exc) return None +def _discard_shared_promo_dashboard_cache(reason, exc=None): + """刪除損毀的跨 worker 快取,避免每個 worker 重複噴 traceback。""" + try: + os.remove(_PROMO_SHARED_CACHE_FILE) + sys_log.info("promo dashboard shared cache discarded | reason=%s | error=%s", reason, exc) + except FileNotFoundError: + return + except OSError: + sys_log.debug("promo dashboard shared cache discard failed", exc_info=True) + + def _write_shared_promo_dashboard_cache(cache_key, data): """原子寫入促銷 dashboard 共享快取;失敗不阻斷頁面回應。""" cache_file = str(_PROMO_SHARED_CACHE_FILE) @@ -132,10 +143,14 @@ def _write_shared_promo_dashboard_cache(cache_key, data): entries = {} try: if os.path.exists(_PROMO_SHARED_CACHE_FILE): - with open(_PROMO_SHARED_CACHE_FILE, 'rb') as f: - payload = pickle.load(f) - if payload.get('version') == SYSTEM_VERSION and isinstance(payload.get('entries'), dict): - entries = payload['entries'] + try: + with open(_PROMO_SHARED_CACHE_FILE, 'rb') as f: + payload = pickle.load(f) + if payload.get('version') == SYSTEM_VERSION and isinstance(payload.get('entries'), dict): + entries = payload['entries'] + except Exception as exc: + _discard_shared_promo_dashboard_cache("write_corrupt", exc) + entries = {} entries[cache_key] = data while len(entries) > _PROMO_DASHBOARD_CACHE_MAX: entries.pop(next(iter(entries))) diff --git a/services/elephant_alpha_orchestrator.py b/services/elephant_alpha_orchestrator.py index 8abe1aa..a179e13 100644 --- a/services/elephant_alpha_orchestrator.py +++ b/services/elephant_alpha_orchestrator.py @@ -15,6 +15,7 @@ Position: Super Orchestrator above Hermes/NemoTron/OpenClaw import os import json import asyncio +from json import JSONDecodeError from datetime import datetime, timedelta from typing import Dict, List, Any, Optional from dataclasses import dataclass @@ -241,8 +242,22 @@ BUSINESS OBJECTIVE: Optimize e-commerce performance through intelligent automati if not response.success: raise RuntimeError(response.error) - # Parse and validate response - decision_data = json.loads(response.content) + # Parse and validate response. Some NIM-compatible models still wrap + # JSON in fenced blocks or prepend reasoning text even with json_mode. + try: + decision_data = self._extract_json_object(response.content) + except ValueError as parse_error: + logger.warning( + "[ElephantAlpha] Coordination JSON parse failed; using evidence fallback. " + "model=%s error=%s preview=%r", + response.model, + parse_error, + (response.content or "")[:240], + ) + return self._fallback_decision( + business_context, + reason="Elephant Alpha 回應不是可解析 JSON,已改用實證 fallback。", + ) decision = self._parse_strategic_decision(decision_data) # Log decision for learning @@ -253,7 +268,46 @@ BUSINESS OBJECTIVE: Optimize e-commerce performance through intelligent automati except Exception as e: logger.error(f"[ElephantAlpha] Coordination failed: {e}") # Fallback to conservative decision - return self._fallback_decision(business_context) + return self._fallback_decision( + business_context, + reason=f"Elephant Alpha 協調失敗:{type(e).__name__}", + ) + + @staticmethod + def _extract_json_object(raw: str) -> Dict[str, Any]: + """Extract one JSON object from tolerant LLM output.""" + text_value = (raw or "").strip() + if not text_value: + raise ValueError("empty response") + + if text_value.startswith("```"): + lines = text_value.splitlines() + if lines and lines[0].strip().startswith("```"): + lines = lines[1:] + if lines and lines[-1].strip().startswith("```"): + lines = lines[:-1] + text_value = "\n".join(lines).strip() + + try: + parsed = json.loads(text_value) + if isinstance(parsed, dict): + return parsed + raise ValueError("JSON root is not an object") + except JSONDecodeError: + pass + + decoder = json.JSONDecoder() + for idx, char in enumerate(text_value): + if char != "{": + continue + try: + parsed, _end = decoder.raw_decode(text_value[idx:]) + except JSONDecodeError: + continue + if isinstance(parsed, dict): + return parsed + + raise ValueError("no JSON object found") def _build_coordination_prompt(self, context: Dict[str, Any]) -> str: """Build detailed coordination prompt for Elephant Alpha""" @@ -431,22 +485,56 @@ Provide your strategic decision in the specified JSON format. finally: session.close() - def _fallback_decision(self, context: Dict[str, Any]) -> StrategicDecision: + @staticmethod + def _context_concrete_actions(context: Dict[str, Any]) -> List[str]: + conditions = context.get("conditions") if isinstance(context, dict) else {} + if not isinstance(conditions, dict): + return [] + for key in ("_prefetched_hermes_threats", "_db_evidence_actions"): + actions = conditions.get(key) + if isinstance(actions, list): + cleaned = [str(action).strip() for action in actions if str(action).strip()] + if cleaned: + return cleaned[:5] + return [] + + def _fallback_decision(self, context: Dict[str, Any], *, reason: str = "Elephant Alpha unavailable") -> StrategicDecision: """Fallback decision if Elephant Alpha fails""" + concrete_actions = self._context_concrete_actions(context) + trigger_type = str((context or {}).get("trigger_type") or "unknown") + if concrete_actions: + return StrategicDecision( + priority="high", + agents_required=["hermes", "elephant_alpha"], + reasoning=( + f"{reason} 已保留 {len(concrete_actions)} 筆 DB/Hermes 價格比對實證;" + "僅送人工覆核,不執行自動調價。" + ), + expected_outcome="產生可稽核的人工覆核告警,避免使用無法解析的 LLM 推論文字。", + confidence=0.74, + execution_plan=[], + resource_requirements={ + "compute_cost": "$0.00", + "time_estimate": "人工覆核", + "human_oversight": "required", + }, + ) + return StrategicDecision( priority="medium", - agents_required=["openclaw"], - reasoning="Elephant Alpha unavailable, using conservative OpenClaw strategy", - expected_outcome="Basic strategic analysis", + agents_required=["elephant_alpha"], + reasoning=( + f"{reason} trigger={trigger_type},且沒有可稽核的 DB/Hermes 實證;" + "不產生策略型行動計畫,避免把推測當成事實。" + ), + expected_outcome="不執行自動動作;需要先確認資料來源或等待下一輪具體實證。", confidence=0.6, - execution_plan=[{ - "step": 1, - "agent": "openclaw", - "action": "generate_market_analysis", - "parameters": context, - "expected_duration": "2-3 minutes" - }], - resource_requirements={"compute_cost": "$0.00", "time_estimate": "5 minutes"} + execution_plan=[], + resource_requirements={ + "compute_cost": "$0.00", + "time_estimate": "等待實證", + "human_oversight": "required", + }, ) # Singleton instance diff --git a/services/marketplace_product_matcher.py b/services/marketplace_product_matcher.py index 4488811..b0cf6aa 100644 --- a/services/marketplace_product_matcher.py +++ b/services/marketplace_product_matcher.py @@ -304,6 +304,8 @@ SEARCH_NOISE_TOKENS = { } SEARCH_IDENTITY_ANCHORS = ( + "潤浸保濕清爽身體乳液", + "閃亮珍珠眼影棒", "智能光感應無線自動除臭芳香噴霧機", "usb精油薰香機", "超音波水氧機", @@ -520,6 +522,8 @@ BRAND_ALIAS_OVERRIDES = { "xiaomi": ("小米有品", "小米", "xiaomi"), "mac": ("m.a.c", "mac", "m a c"), "opi": ("o.p.i", "opi", "o p i"), + "curel": ("curel", "珂潤"), + "karadium": ("karadium",), "st雞仔牌": ("日本雞仔牌st", "日本st雞仔牌", "st雞仔牌", "雞仔牌st", "雞仔牌"), } diff --git a/tests/test_cache_manager.py b/tests/test_cache_manager.py index c725c82..59b4fb9 100644 --- a/tests/test_cache_manager.py +++ b/tests/test_cache_manager.py @@ -316,6 +316,31 @@ def test_promo_dashboard_shared_cache_ignores_other_versions(tmp_path, monkeypat assert edm_routes._load_shared_promo_dashboard_cache(cache_key) is None +def test_promo_dashboard_shared_cache_discards_corrupt_payload(tmp_path, monkeypatch): + from routes import edm_routes + + shared_cache = tmp_path / "promo_dashboard_cache.pkl" + shared_cache.write_bytes(b"\x96not-a-pickle") + monkeypatch.setattr(edm_routes, "_PROMO_SHARED_CACHE_FILE", shared_cache) + + assert edm_routes._load_shared_promo_dashboard_cache(("edm",)) is None + assert not shared_cache.exists() + + +def test_promo_dashboard_shared_cache_write_recovers_from_corrupt_payload(tmp_path, monkeypatch): + from routes import edm_routes + + shared_cache = tmp_path / "promo_dashboard_cache.pkl" + shared_cache.write_bytes(b"\x96not-a-pickle") + cache_key = ("edm", "default", "desc", "", "bucket", (1, "ts", 1)) + data = {"items_in_batch": [{"sku": "SKU-1"}]} + monkeypatch.setattr(edm_routes, "_PROMO_SHARED_CACHE_FILE", shared_cache) + + edm_routes._write_shared_promo_dashboard_cache(cache_key, data) + + assert edm_routes._load_shared_promo_dashboard_cache(cache_key) == data + + def test_sales_analysis_preview_context_cache_avoids_reloading_options(tmp_path, monkeypatch): from routes import sales_routes diff --git a/tests/test_elephant_alpha_orchestrator.py b/tests/test_elephant_alpha_orchestrator.py new file mode 100644 index 0000000..cc7a750 --- /dev/null +++ b/tests/test_elephant_alpha_orchestrator.py @@ -0,0 +1,99 @@ +import asyncio + + +def test_elephant_orchestrator_extracts_fenced_json_object(): + from services.elephant_alpha_orchestrator import ElephantAlphaOrchestrator + + payload = ElephantAlphaOrchestrator._extract_json_object( + """```json +{"priority":"high","confidence":0.91} +```""" + ) + + assert payload == {"priority": "high", "confidence": 0.91} + + +def test_elephant_orchestrator_extracts_json_object_from_prefaced_text(): + from services.elephant_alpha_orchestrator import ElephantAlphaOrchestrator + + payload = ElephantAlphaOrchestrator._extract_json_object( + '先做分析:\n{"priority":"medium","agents_required":["hermes"]}\n以上。' + ) + + assert payload["priority"] == "medium" + assert payload["agents_required"] == ["hermes"] + + +def test_elephant_orchestrator_fallback_uses_concrete_evidence_without_plan(): + from services.elephant_alpha_orchestrator import ElephantAlphaOrchestrator + + orchestrator = ElephantAlphaOrchestrator() + decision = orchestrator._fallback_decision( + { + "trigger_type": "market_opportunity", + "conditions": { + "_prefetched_hermes_threats": [ + "[SKU-1] 測試商品|MOMO $1,200 vs PChome $990|每件價差 NT$ 210" + ] + }, + }, + reason="測試解析失敗", + ) + + assert decision.priority == "high" + assert decision.confidence == 0.74 + assert decision.execution_plan == [] + assert "1 筆 DB/Hermes 價格比對實證" in decision.reasoning + assert "自動調價" in decision.reasoning + + +def test_elephant_orchestrator_fallback_without_evidence_has_no_openclaw_action(): + from services.elephant_alpha_orchestrator import ElephantAlphaOrchestrator + + orchestrator = ElephantAlphaOrchestrator() + decision = orchestrator._fallback_decision( + {"trigger_type": "market_opportunity", "conditions": {}}, + reason="測試解析失敗", + ) + + assert decision.confidence == 0.6 + assert decision.execution_plan == [] + assert decision.agents_required == ["elephant_alpha"] + assert "沒有可稽核的 DB/Hermes 實證" in decision.reasoning + + +def test_elephant_orchestrator_uses_evidence_fallback_on_non_json_response(monkeypatch): + from services.elephant_service import ElephantResponse + from services.elephant_alpha_orchestrator import ElephantAlphaOrchestrator + + class FakeElephant: + def generate(self, **_kwargs): + return ElephantResponse( + success=True, + content="\n我無法輸出 JSON,但建議先處理。", + model="fake-model", + ) + + async def _noop_log(*_args, **_kwargs): + raise AssertionError("parse fallback should not log an LLM decision") + + orchestrator = ElephantAlphaOrchestrator() + orchestrator.elephant = FakeElephant() + monkeypatch.setattr(orchestrator, "_get_recent_performance_metrics", lambda: {}) + monkeypatch.setattr(orchestrator, "_get_agent_status", lambda: {}) + monkeypatch.setattr(orchestrator, "_get_system_load", lambda: "normal") + monkeypatch.setattr(orchestrator, "_get_pending_actions_count", lambda: 0) + monkeypatch.setattr(orchestrator, "_log_decision", _noop_log) + + decision = asyncio.run(orchestrator.analyze_and_coordinate({ + "trigger_type": "price_drop_alert", + "conditions": { + "_prefetched_hermes_threats": [ + "[SKU-9] 實證商品|MOMO $1,000 vs PChome $800|每件價差 NT$ 200" + ] + }, + })) + + assert decision.priority == "high" + assert decision.execution_plan == [] + assert "1 筆 DB/Hermes 價格比對實證" in decision.reasoning diff --git a/tests/test_marketplace_product_matcher.py b/tests/test_marketplace_product_matcher.py index 24f02f7..eb153e2 100644 --- a/tests/test_marketplace_product_matcher.py +++ b/tests/test_marketplace_product_matcher.py @@ -531,6 +531,38 @@ def test_marketplace_matcher_promotes_nivea_dry_lotion_with_long_shared_anchor() assert "shared_identity_anchor_nivea_dry_lotion" in diagnostics.reasons +def test_marketplace_matcher_promotes_curel_body_lotion_with_brand_alias(): + from services.marketplace_product_matcher import score_marketplace_match + + diagnostics = score_marketplace_match( + "【Curel 珂潤】潤浸保濕清爽身體乳液 220ml", + "珂潤 Curel 潤浸保濕清爽身體乳液220ml", + momo_price=399, + competitor_price=399, + ) + + assert diagnostics.score >= 0.76 + assert diagnostics.hard_veto is False + assert "brand_match" in diagnostics.tags + assert "shared_identity_anchor" in diagnostics.reasons + + +def test_marketplace_matcher_promotes_karadium_pearl_shadow_stick_anchor(): + from services.marketplace_product_matcher import score_marketplace_match + + diagnostics = score_marketplace_match( + "【KARADIUM】閃亮珍珠眼影棒 1.4g", + "karadium 閃亮珍珠眼影棒 1.4g", + momo_price=259, + competitor_price=259, + ) + + assert diagnostics.score >= 0.76 + assert diagnostics.hard_veto is False + assert "brand_match" in diagnostics.tags + assert "shared_identity_anchor" in diagnostics.reasons + + def test_marketplace_matcher_rejects_refill_core_vs_case_only_pack(): from services.marketplace_product_matcher import score_marketplace_match