From 4174c90ab07e42c559075ca67a1b7449253508e2 Mon Sep 17 00:00:00 2001 From: OoO Date: Thu, 21 May 2026 15:32:13 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E6=AD=A3=20smoke=20fallback=20?= =?UTF-8?q?=E8=88=87=20EventRouter=20=E5=9B=9E=E6=94=BE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- config.py | 2 +- .../code_modularization_inventory_20260430.md | 3 +- docs/memory/history_logs.md | 1 + services/ai_automation_smoke_service.py | 6 ++- services/event_router.py | 47 +++++++++++++++---- services/marketplace_product_matcher.py | 4 ++ tests/test_ai_automation_smoke_service.py | 14 ++++++ tests/test_event_router.py | 29 +++++++++++- tests/test_marketplace_product_matcher.py | 16 ++++++- 9 files changed, 107 insertions(+), 15 deletions(-) diff --git a/config.py b/config.py index df06d53..d1bf77b 100644 --- a/config.py +++ b/config.py @@ -325,7 +325,7 @@ YOUTUBE_API_KEY = os.getenv('YOUTUBE_API_KEY', '') # ========================================== # 系統版本與路徑 # ========================================== -SYSTEM_VERSION = "V10.371" +SYSTEM_VERSION = "V10.372" LOG_FILE_PATH = os.path.join(BASE_DIR, 'logs/system.log') public_url = PUBLIC_URL # 用於模板顯示 diff --git a/docs/memory/code_modularization_inventory_20260430.md b/docs/memory/code_modularization_inventory_20260430.md index d87c6ca..06f3fe8 100644 --- a/docs/memory/code_modularization_inventory_20260430.md +++ b/docs/memory/code_modularization_inventory_20260430.md @@ -36,6 +36,7 @@ - 2026-05-21 追記:同步市場情報 MCP runtime smoke receipt gate 後的 `routes/market_intel_routes.py` 與 `services/market_intel/deployment_readiness.py` 行數;本次 route 只承接既有 Blueprint glue,後續新增 MCP/UI gate 應優先拆出子 Blueprint 或 route registration helper。 - 2026-05-21 追記:同步 111 fallback context/resource guard 合併後的 `services/ollama_service.py` 行數;此處只更新 inventory,不變更 Ollama 路由行為。 - 2026-05-21 追記:同步專業比價分級連動合併後的 `services/competitor_intel_repository.py` 與 `services/nemoton_dispatcher_service.py` 行數;此處只更新 inventory,不變更比價或告警行為。 +- 2026-05-21 追記:同步 PChome/LUDEYA 商品線名稱漂移比對更新後的 `services/marketplace_product_matcher.py` 行數;此處只更新 inventory,不變更模組化決策。 ## 達到或超過 800 行檔案清單 @@ -62,7 +63,7 @@ | 940 | `services/import_service.py` | P2 import service | validators / import writers / report builders | | 933 | `services/telegram_templates.py` | P2 Telegram templates | alert template groups / channel-specific formatting / reusable render helpers | | 867 | `services/token_report_service.py` | P2 token report service | query / aggregation / chart payload / notification formatting | -| 1902 | `services/marketplace_product_matcher.py` | P2 marketplace matcher | identity parsing / unit-comparable scoring / search term quality / persistence normalization | +| 2225 | `services/marketplace_product_matcher.py` | P2 marketplace matcher | identity parsing / unit-comparable scoring / search term quality / persistence normalization | | 865 | `routes/daily_sales_routes.py` | P2 Daily Sales Blueprint | route glue / export helpers / daily query and formatting service | | 961 | `services/ollama_service.py` | P2 Ollama client | host health / request client / fallback policy / response parsing | | 849 | `services/pchome_crawler.py` | P2 PChome crawler | search fetch / parsing / fallback source handling / rate limit policy | diff --git a/docs/memory/history_logs.md b/docs/memory/history_logs.md index 2366fec..b4c323a 100644 --- a/docs/memory/history_logs.md +++ b/docs/memory/history_logs.md @@ -13,6 +13,7 @@ ## 📅 詳細更新日誌 (考古存檔) ### 2026-05-21:瀏覽器測試守門與 PChome 熱路徑優化 +- **V10.372 Smoke 與 EventRouter queue 修復**: 修正 AI automation smoke 對 NemoTron fallback 的 class 判斷,改接受實際存在的 `NemotronDispatcher._hermes_rule_fallback`,避免 Hermes fallback 正常卻被誤報 critical;EventRouter 失敗佇列回放改為重建短版 HTML-safe 訊息,escape 標題/摘要/trace/error 並限制長度,避免舊 Selenium stacktrace 的 `` 造成 Telegram HTTP 400 反覆卡住;同版整合 LUDEYA 蜂王玫瑰商品線在 MOMO/PChome 名稱漂移時的 identity anchor alias。 - **V10.371 品牌缺失同款放行**: marketplace matcher 新增 `brandless_exact_identity` 加分,只限「一側有品牌、一側缺品牌」但 shared identity anchor 夠長、規格/序列/中文名相似度都高且無 hard veto 的案例;覆蓋小米有品小浪智能感應自動噴香機,讓 PChome 標題省略品牌時仍可進入同款告警候選。 - **V10.370 Gemini runtime sentinel**: AI automation smoke 新增 `Gemini 出站費用 sentinel`,每天檢查近 24h `ai_calls.provider='gemini'` 的 calls/tokens/cost/top callers;若 `GEMINI_API_HARD_DISABLED=true` 仍有 Gemini 記錄,smoke 直接升為 critical。scheduler 09:10 摘要推播前會先執行一次只讀 smoke,讓 Gemini 費用異常不再依賴人工打開 `/ai_automation_smoke` 才被發現。 - **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 安全斷言。 diff --git a/services/ai_automation_smoke_service.py b/services/ai_automation_smoke_service.py index c205adb..27409dd 100644 --- a/services/ai_automation_smoke_service.py +++ b/services/ai_automation_smoke_service.py @@ -379,7 +379,10 @@ def _nemotron_check() -> Dict[str, Any]: try: import services.nemoton_dispatcher_service as nemotron - dispatcher_cls = getattr(nemotron, "NemotronDispatcherService", None) + dispatcher_cls = ( + getattr(nemotron, "NemotronDispatcherService", None) + or getattr(nemotron, "NemotronDispatcher", None) + ) fallback_ready = bool(dispatcher_cls and hasattr(dispatcher_cls, "_hermes_rule_fallback")) api_key_configured = bool(getattr(nemotron, "NIM_API_KEY", "")) call_count = getattr(nemotron, "_nim_call_count", {}).get("count", 0) @@ -402,6 +405,7 @@ def _nemotron_check() -> Dict[str, Any]: summary, { "fallback_ready": fallback_ready, + "dispatcher_class": getattr(dispatcher_cls, "__name__", None), "api_key_configured": api_key_configured, "call_count": call_count, "daily_limit": daily_limit, diff --git a/services/event_router.py b/services/event_router.py index 52c33e2..1194cc9 100644 --- a/services/event_router.py +++ b/services/event_router.py @@ -11,6 +11,7 @@ import threading import traceback import time from datetime import datetime +from html import escape from typing import Any, Dict, Optional from services.ai_orchestrator import AIOrchestrator @@ -32,6 +33,8 @@ _EVENT_DEDUP: Dict[str, float] = {} _DEFAULT_DEDUP_SEC = int(os.getenv("MOMO_EVENT_ROUTER_DEFAULT_DEDUP_SEC", "0")) _REPLAY_ON_SUCCESS = os.getenv("MOMO_EVENT_ROUTER_REPLAY_ON_SUCCESS", "true").lower() == "true" _REPLAY_LIMIT = int(os.getenv("MOMO_EVENT_ROUTER_REPLAY_LIMIT", "3")) +_REPLAY_TEXT_LIMIT = 700 +_REPLAY_TRACE_LIMIT = 1200 async def _handle_l1(event: Dict[str, Any], session_id: str) -> Dict[str, Any]: @@ -174,19 +177,43 @@ def _queue_failed_delivery( def _queued_record_message(record: Dict[str, Any]) -> str: - message = record.get("message") - if message: - return str(message) event = record.get("event") if isinstance(record.get("event"), dict) else {} title = event.get("title") or record.get("event_key") or "EventRouter queued event" summary = event.get("summary") or event.get("status") or record.get("reason") or "queued delivery replay" - return ( - f"♻️ {title}\n" - f"━━━━━━━━━━━━━━━━━━━━\n" - f"Queue replay: {summary}\n" - f"event_key={record.get('event_key', 'unknown')}\n" - f"queued_at={record.get('ts', 'unknown')}" - ) + trace = event.get("trace") or event.get("traceback") or event.get("error_traceback") + errors = record.get("errors") if isinstance(record.get("errors"), list) else [] + + lines = [ + "♻️ EventRouter Queue Replay", + "━━━━━━━━━━━━━━━━━━━━", + f"📌 {escape(_clip_text(title, 120))}", + f"🔖 {escape(_clip_text(record.get('event_key', 'unknown'), 120))}", + f"🕒 queued_at={escape(_clip_text(record.get('ts', 'unknown'), 80))}", + "", + f"🔍 概要:{escape(_clip_text(summary, _REPLAY_TEXT_LIMIT))}", + ] + if errors: + lines.extend([ + "", + f"⚠️ 上次錯誤:{escape(_clip_text('; '.join(str(item) for item in errors), 300))}", + ]) + if trace: + lines.extend([ + "", + "
" + escape(_clip_text(trace, _REPLAY_TRACE_LIMIT)) + "
", + ]) + lines.extend([ + "", + "💡 處置:此為失敗佇列安全回放;原始長訊息已濃縮,避免 Telegram HTML 400 重複卡住。", + ]) + return "\n".join(lines) + + +def _clip_text(value: Any, limit: int) -> str: + text = str(value or "").strip() + if limit <= 0 or len(text) <= limit: + return text + return text[: max(0, limit - 1)].rstrip() + "…" def replay_failed_deliveries(limit: int = 20, admin_chat_ids: Optional[list] = None) -> Dict[str, Any]: diff --git a/services/marketplace_product_matcher.py b/services/marketplace_product_matcher.py index ad5af4f..2fb9b40 100644 --- a/services/marketplace_product_matcher.py +++ b/services/marketplace_product_matcher.py @@ -1868,6 +1868,10 @@ def _extract_anchor_phrases(token: str) -> list[str]: return [] phrases: list[str] = [] + if "蜂王玫瑰" in cleaned and any( + keyword in cleaned for keyword in ("外泌微臻霜", "微泌新生霜", "瑰泌霜") + ): + phrases.append("蜂王玫瑰瑰泌霜") for anchor in SEARCH_IDENTITY_ANCHORS: if anchor not in cleaned: continue diff --git a/tests/test_ai_automation_smoke_service.py b/tests/test_ai_automation_smoke_service.py index d79c219..c892ddc 100644 --- a/tests/test_ai_automation_smoke_service.py +++ b/tests/test_ai_automation_smoke_service.py @@ -173,6 +173,20 @@ def test_gemini_egress_check_critical_when_hard_disabled_has_calls(monkeypatch): assert result["details"]["top_callers"][0]["caller"] == "code_review_openclaw_gemini" +def test_nemotron_smoke_detects_current_dispatcher_fallback(monkeypatch): + from services import ai_automation_smoke_service as smoke + import services.nemoton_dispatcher_service as nemotron + + monkeypatch.delattr(nemotron, "NemotronDispatcherService", raising=False) + monkeypatch.setattr(nemotron, "NIM_API_KEY", "") + + result = smoke._nemotron_check() + + assert result["status"] == "warning" + assert result["details"]["fallback_ready"] is True + assert result["details"]["dispatcher_class"] == "NemotronDispatcher" + + def test_build_smoke_daily_summary_message_escapes_history(tmp_path, monkeypatch): from services import ai_automation_smoke_service as smoke diff --git a/tests/test_event_router.py b/tests/test_event_router.py index a456d7f..553be64 100644 --- a/tests/test_event_router.py +++ b/tests/test_event_router.py @@ -144,10 +144,37 @@ def test_replay_failed_deliveries_removes_successful_records(tmp_path, monkeypat result = event_router.replay_failed_deliveries(limit=10) assert result == {"attempted": 1, "sent": 1, "failed": 0, "dropped": 0} - assert sent == ["queued message"] + assert "EventRouter Queue Replay" in sent[0] + assert "queued message" not in sent[0] assert not queue_path.exists() +def test_queued_record_message_rebuilds_safe_compact_html(): + import services.event_router as event_router + + record = { + "ts": "2026-05-21T13:00:00", + "reason": "telegram_delivery_failed", + "event_key": "Scheduler.Festival:festival_task_failure", + "event": { + "title": "促銷爬蟲 <失敗>", + "summary": "Alert Text: 很抱歉此EDM不存在 ", + "trace": "#0 0xabc \n" * 200, + }, + "message": "
原始壞訊息 
", + "errors": ["-1003940688311:HTTP 400 "], + } + + message = event_router._queued_record_message(record) + + assert "EventRouter Queue Replay" in message + assert "原始壞訊息" not in message + assert "" not in message + assert "<unknown>" in message + assert "<失敗>" in message + assert len(message) < 4096 + + def test_replay_failed_deliveries_keeps_failed_records(tmp_path, monkeypatch): import services.event_router as event_router diff --git a/tests/test_marketplace_product_matcher.py b/tests/test_marketplace_product_matcher.py index 6002bf2..a8842a4 100644 --- a/tests/test_marketplace_product_matcher.py +++ b/tests/test_marketplace_product_matcher.py @@ -456,6 +456,20 @@ def test_marketplace_matcher_promotes_brandless_exact_identity_when_anchor_is_st assert "brandless_exact_identity" in diagnostics.reasons +def test_marketplace_matcher_promotes_ludeya_line_with_platform_name_drift(): + from services.marketplace_product_matcher import score_marketplace_match + + diagnostics = score_marketplace_match( + "【LUDEYA】蜂王玫瑰外泌微臻霜超值兩入組(瑰泌霜60mlx2)", + "LUDEYA 蜂王玫瑰微泌新生霜 60ml x2", + momo_price=2000, + competitor_price=2000, + ) + + assert diagnostics.score >= 0.76 + assert "shared_identity_anchor" in diagnostics.reasons or "shared_identity_anchor_no_spec" in diagnostics.reasons + + def test_marketplace_matcher_rejects_same_count_different_unit_family(): from services.marketplace_product_matcher import score_marketplace_match @@ -1100,7 +1114,7 @@ def test_marketplace_search_terms_prioritize_exact_identity_for_low_score_fronti max_terms=5, ) - assert ludeya_terms[0] == "ludeya 蜂王玫瑰外泌微臻霜 60ml" + assert ludeya_terms[0] == "ludeya 蜂王玫瑰瑰泌霜 60ml" assert "兩入組" not in " ".join(ludeya_terms[:3]) assert estee_terms[0] == "雅詩蘭黛 微分子肌底原生露 200ml" assert "櫻花輕盈版" not in " ".join(estee_terms[:3])