修正 smoke fallback 與 EventRouter 回放
All checks were successful
CD Pipeline / deploy (push) Successful in 1m8s

This commit is contained in:
OoO
2026-05-21 15:32:13 +08:00
committed by AiderHeal Bot
parent 52790f3f6d
commit 4174c90ab0
9 changed files with 107 additions and 15 deletions

View File

@@ -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 # 用於模板顯示

View File

@@ -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 |

View File

@@ -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 正常卻被誤報 criticalEventRouter 失敗佇列回放改為重建短版 HTML-safe 訊息escape 標題/摘要/trace/error 並限制長度,避免舊 Selenium stacktrace 的 `<unknown>` 造成 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 安全斷言。

View File

@@ -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,

View File

@@ -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"♻️ <b>{title}</b>\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 = [
"♻️ <b>EventRouter Queue Replay</b>",
"━━━━━━━━━━━━━━━━━━━━",
f"📌 <b>{escape(_clip_text(title, 120))}</b>",
f"🔖 <code>{escape(_clip_text(record.get('event_key', 'unknown'), 120))}</code>",
f"🕒 queued_at={escape(_clip_text(record.get('ts', 'unknown'), 80))}",
"",
f"🔍 <b>概要:</b>{escape(_clip_text(summary, _REPLAY_TEXT_LIMIT))}",
]
if errors:
lines.extend([
"",
f"⚠️ <b>上次錯誤:</b>{escape(_clip_text('; '.join(str(item) for item in errors), 300))}",
])
if trace:
lines.extend([
"",
"<pre>" + escape(_clip_text(trace, _REPLAY_TRACE_LIMIT)) + "</pre>",
])
lines.extend([
"",
"💡 <b>處置:</b>此為失敗佇列安全回放;原始長訊息已濃縮,避免 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]:

View File

@@ -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

View File

@@ -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

View File

@@ -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不存在 <unknown>",
"trace": "#0 0xabc <unknown>\n" * 200,
},
"message": "<pre>原始壞訊息 <unknown></pre>",
"errors": ["-1003940688311:HTTP 400 <bad html>"],
}
message = event_router._queued_record_message(record)
assert "EventRouter Queue Replay" in message
assert "原始壞訊息" not in message
assert "<unknown>" not in message
assert "&lt;unknown&gt;" in message
assert "&lt;失敗&gt;" in message
assert len(message) < 4096
def test_replay_failed_deliveries_keeps_failed_records(tmp_path, monkeypatch):
import services.event_router as event_router

View File

@@ -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])