diff --git a/config.py b/config.py index 70d7707..f9183e7 100644 --- a/config.py +++ b/config.py @@ -325,7 +325,7 @@ YOUTUBE_API_KEY = os.getenv('YOUTUBE_API_KEY', '') # ========================================== # 系統版本與路徑 # ========================================== -SYSTEM_VERSION = "V10.368" +SYSTEM_VERSION = "V10.369" 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 9e37faa..3cf4318 100644 --- a/docs/memory/history_logs.md +++ b/docs/memory/history_logs.md @@ -13,6 +13,7 @@ ## 📅 詳細更新日誌 (考古存檔) ### 2026-05-21:瀏覽器測試守門與 PChome 熱路徑優化 +- **V10.369 Gemini 防復發測試與極端價差同款放行**: 新增靜態測試禁止 production code 在 `services.gemini_guard` / `config.py` 之外直接讀 `GEMINI_API_KEY`,並要求所有 Gemini SDK/REST 出站點必須經 `get_gemini_api_key()`;比價 matcher 針對「同品牌 + 明確 identity anchor + 規格完全一致」但競品價格極端偏低的原生露/眉筆案例抑制價格懲罰,避免真同款因價格差被錯降級,同時補回既有 hard-veto 安全斷言。 - **V10.368 比價搜尋錨點強化**: marketplace matcher 補 LUDEYA 蜂王玫瑰外泌微臻霜、雅詩蘭黛微分子肌底原生露、Za / PERIPERA 眉筆眉彩等低信心邊界品牌的 identity anchor,並把「兩入組 / 任選色號 / 多色可選 / 櫻花輕盈版」歸為搜尋噪音,讓 MOMO → PChome 搜尋詞更聚焦於同款身份與規格,不被包裝組合或色號選項帶偏。 - **V10.367 Gemini hard egress kill switch**: 新增 `GEMINI_API_HARD_DISABLED=true` 預設硬封鎖,中央 `services.gemini_guard` 會在 hard switch 未解鎖時拒絕 `GEMINI_API_KEY`,即使 `GEMINI_FALLBACK_ENABLED=true` 也不會初始化 SDK 或 REST 出站。Code Review/OpenClaw/MCP/通用 AI fallback 保留 emergency path,但必須同時設 `GEMINI_API_HARD_DISABLED=false` 與 `GEMINI_FALLBACK_ENABLED=true`,必要時再用 `GEMINI_ALLOWED_CONTEXTS` 限定 caller。 - **V10.366 MCP runtime smoke receipt review**: 新增 `mcp_runtime_smoke_receipt` read-only builder、GET/POST endpoint、UI receipt JSON 審核面板與 deployment readiness smoke target,讓操作員貼上 `/api/market_intel/mcp_readiness?execute=true&timeout=3` 的實際收據後,判斷 external/internal MCP runtime 是否可升級為已驗收。 diff --git a/services/marketplace_product_matcher.py b/services/marketplace_product_matcher.py index 0acec84..31adc32 100644 --- a/services/marketplace_product_matcher.py +++ b/services/marketplace_product_matcher.py @@ -447,6 +447,16 @@ VARIANT_OPTION_COLOR_WORDS = { "月光銀影", } +VARIANT_DESCRIPTOR_NOISE_KEYWORDS = { + "平輸航空版", + "多色任選", + "色號任選", + "任選色號", + "極細筆頭", + "筆頭", + "官方直營", +} + SEARCH_AMBIGUOUS_PRODUCT_TERMS = { "保護膜", "保護貼", @@ -478,6 +488,8 @@ BRAND_ALIAS_OVERRIDES = { "febreze": ("febreze", "風倍清"), "jo malone": ("jo malone",), "prada": ("prada", "普拉達"), + "za": ("za",), + "xiaomi": ("小米有品", "小米", "xiaomi"), } PRODUCT_TYPES = { @@ -1559,9 +1571,22 @@ def score_marketplace_match( try: if momo_price and competitor_price: ratio = float(competitor_price) / max(float(momo_price), 1.0) + allow_price_penalty_suppression = ( + shared_anchor + and len(shared_anchor.replace(" ", "")) >= 7 + and brand_score >= 0.95 + and not hard_veto + and type_score >= 0.55 + and spec_score >= 0.99 + and token_score >= 0.68 + and sequence_score >= 0.72 + ) if (ratio < 0.3 or ratio > 3.2) and token_score < 0.78: - price_penalty = 0.12 - reasons.append("price_ratio_extreme") + if allow_price_penalty_suppression: + reasons.append("price_penalty_suppressed_exact_identity") + else: + price_penalty = 0.12 + reasons.append("price_ratio_extreme") elif (ratio < 0.48 or ratio > 2.2) and token_score < 0.68: price_penalty = 0.06 reasons.append("price_ratio_wide") @@ -1662,6 +1687,35 @@ def score_marketplace_match( ): score += 0.02 reasons.append("shared_identity_anchor_core_line") + if ( + shared_anchor + and len(shared_anchor.replace(" ", "")) >= 6 + and brand_score >= 0.95 + and not hard_veto + and price_penalty == 0 + and type_score >= 0.55 + and spec_score >= 0.45 + and token_score >= 0.86 + and sequence_score >= 0.75 + and not variant_descriptor_conflict + ): + score += 0.07 + reasons.append("shared_identity_anchor_exact_line") + if ( + shared_anchor + and len(shared_anchor.replace(" ", "")) >= 5 + and brand_score >= 0.95 + and not hard_veto + and price_penalty == 0 + and type_score >= 0.55 + and spec_score >= 0.45 + and token_score >= 0.74 + and sequence_score >= 0.60 + and _shared_variant_descriptors(left, right) + and not variant_descriptor_conflict + ): + score += 0.05 + reasons.append("shared_variant_descriptor_alignment") if ( shared_anchor and len(shared_anchor.replace(" ", "")) >= 6 @@ -1870,12 +1924,30 @@ def _variant_descriptors(identity: ProductIdentity) -> set[str]: continue if compact in SEARCH_NOISE_TOKENS or compact in SEARCH_BROAD_ANCHORS: continue + if any(keyword in compact for keyword in VARIANT_DESCRIPTOR_NOISE_KEYWORDS): + continue if re.fullmatch(r"[a-z0-9-]+", compact): continue descriptors.add(compact.removesuffix("款")) return {token for token in descriptors if token} +def _shared_variant_descriptors(left: ProductIdentity, right: ProductIdentity) -> set[str]: + left_descriptors = _variant_descriptors(left) + right_descriptors = _variant_descriptors(right) + shared: set[str] = set() + for left_descriptor in left_descriptors: + for right_descriptor in right_descriptors: + if left_descriptor == right_descriptor: + shared.add(left_descriptor) + continue + if len(left_descriptor) >= 2 and left_descriptor in right_descriptor: + shared.add(left_descriptor) + elif len(right_descriptor) >= 2 and right_descriptor in left_descriptor: + shared.add(right_descriptor) + return shared + + def _is_variant_sensitive_identity( left: ProductIdentity, right: ProductIdentity, diff --git a/tests/test_gemini_fallback_guard.py b/tests/test_gemini_fallback_guard.py index dfe72cc..9be18f1 100644 --- a/tests/test_gemini_fallback_guard.py +++ b/tests/test_gemini_fallback_guard.py @@ -2,9 +2,27 @@ # -*- coding: utf-8 -*- """Gemini fallback kill-switch contract.""" +import re +from pathlib import Path + from services.ai_provider import AIProviderService, AIResponse from services.gemini_service import GeminiService +ROOT = Path(__file__).resolve().parents[1] + + +def _production_python_files(): + for folder in ("services", "routes"): + yield from sorted((ROOT / folder).rglob("*.py")) + for filename in ("scheduler.py", "config.py"): + path = ROOT / filename + if path.exists(): + yield path + + +def _rel(path: Path) -> str: + return path.relative_to(ROOT).as_posix() + def test_gemini_guard_defaults_disabled(monkeypatch): from services.gemini_guard import get_gemini_api_key, is_gemini_fallback_enabled @@ -110,3 +128,50 @@ def test_openclaw_direct_gemini_call_is_blocked_by_default(monkeypatch): monkeypatch.setenv("GEMINI_API_KEY", "test-key") assert svc._call_gemini("system", "user", caller="openclaw_qa_gemini_fallback") is None + + +def test_no_direct_gemini_api_key_env_read_outside_guard_or_config(): + allowed = {"config.py", "services/gemini_guard.py"} + offenders = [] + pattern = re.compile(r"os\.getenv\(\s*['\"]GEMINI_API_KEY['\"]") + + for path in _production_python_files(): + if _rel(path) in allowed: + continue + if pattern.search(path.read_text(encoding="utf-8")): + offenders.append(_rel(path)) + + assert offenders == [] + + +def test_gemini_outbound_files_are_guarded(): + allowed = { + "routes/openclaw_bot_routes.py", + "services/code_review_pipeline_service.py", + "services/gemini_service.py", + "services/mcp_collector_service.py", + "services/openclaw_strategist_service.py", + } + outbound_markers = ( + "google.generativeai", + "genai.configure", + "GenerativeModel", + "generate_content(", + "generateContent?key=", + ) + offenders = [] + unguarded = [] + + for path in _production_python_files(): + text = path.read_text(encoding="utf-8") + has_outbound = any(marker in text for marker in outbound_markers) + if not has_outbound: + continue + rel = _rel(path) + if rel not in allowed: + offenders.append(rel) + if "get_gemini_api_key" not in text: + unguarded.append(rel) + + assert offenders == [] + assert unguarded == [] diff --git a/tests/test_marketplace_product_matcher.py b/tests/test_marketplace_product_matcher.py index f768871..be396de 100644 --- a/tests/test_marketplace_product_matcher.py +++ b/tests/test_marketplace_product_matcher.py @@ -386,6 +386,62 @@ def test_marketplace_matcher_promotes_precise_cosmetics_and_skincare_lines(): assert diagnostics.hard_veto is False +def test_marketplace_matcher_suppresses_price_penalty_for_exact_identity_toner(): + from services.marketplace_product_matcher import score_marketplace_match + + diagnostics = score_marketplace_match( + "【Estee Lauder 雅詩蘭黛】微分子肌底原生露/櫻花版200ml任選(新上市/化妝水/水精華/無酒精)", + "ESTEE LAUDER 雅詩蘭黛 微分子肌底原生露 200ml", + momo_price=4750, + competitor_price=999, + ) + + assert diagnostics.score >= 0.76 + assert "price_penalty_suppressed_exact_identity" in diagnostics.reasons + + +def test_marketplace_matcher_promotes_exact_line_near_threshold_without_global_threshold_change(): + from services.marketplace_product_matcher import score_marketplace_match + + za = score_marketplace_match( + "【Za】官方直營 細芯睛彩雙頭眉筆(色號任選)", + "Za 細芯睛彩雙頭眉筆0.1g", + momo_price=158, + competitor_price=158, + ) + + assert za.score >= 0.76 + assert "shared_identity_anchor_exact_line" in za.reasons + + +def test_marketplace_matcher_promotes_shared_variant_descriptor_alignment_for_shu(): + from services.marketplace_product_matcher import score_marketplace_match + + diagnostics = score_marketplace_match( + "【Shu uemura 植村秀】武士刀眉筆(平輸航空版/多色任選/橡棕.暗灰. 灰棕)", + "《Shu Uemura 植村秀》武士刀眉筆(H9) 4g -#橡棕06", + momo_price=699, + competitor_price=699, + ) + + assert diagnostics.score >= 0.76 + assert "shared_variant_descriptor_alignment" in diagnostics.reasons + + +def test_marketplace_matcher_ignores_generic_variant_noise_for_peripera_brow_pencil(): + from services.marketplace_product_matcher import score_marketplace_match + + diagnostics = score_marketplace_match( + "【peripera官方直營】雙頭旋轉極細眉筆_多色任選(1.5mm極細筆頭)", + "PERIPERA 雙頭旋轉極細眉筆 09灰褐棕 0.05g", + momo_price=180, + competitor_price=180, + ) + + assert "variant_descriptor_conflict" not in diagnostics.reasons + assert diagnostics.score < 0.76 + + def test_marketplace_matcher_rejects_same_count_different_unit_family(): from services.marketplace_product_matcher import score_marketplace_match