diff --git a/config.py b/config.py index 2050d58..c28a97a 100644 --- a/config.py +++ b/config.py @@ -325,7 +325,7 @@ YOUTUBE_API_KEY = os.getenv('YOUTUBE_API_KEY', '') # ========================================== # 系統版本與路徑 # ========================================== -SYSTEM_VERSION = "V10.430" +SYSTEM_VERSION = "V10.431" 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 f34e6f4..c4118ac 100644 --- a/docs/AI_INTELLIGENCE_MODULE_SOT.md +++ b/docs/AI_INTELLIGENCE_MODULE_SOT.md @@ -2,7 +2,7 @@ > **最後更新**: 2026-05-24 (台北時間) > **狀態**: 🟢 四 AI Agent 自動化閉環已落地;LLM 路由紅線升級為 Ollama-first 三主機級聯,Gemini 備援預設關閉 -> **適用版本**: V10.430 +> **適用版本**: V10.431 --- @@ -44,7 +44,7 @@ - 證據不足時不得輸出空泛效益預測;必須標記 `data_quality=missing|partial|stale`,並把建議行動降級成 `human_review`、`needs_research` 或 `silence_alert`。 - Telegram `triaged_alert()` 已支援渲染 `decision_envelope`,讓告警固定呈現嚴重度、證據、建議行動、預期影響、信心度與追蹤 ID;後續觀測台與 PPT 也應共用同一份欄位語意。 - NemoTron `price_alert` / `human_review` 派發會把同款證據、價差、七日銷量變化、營收流失、HITL 邊界與資料品質寫入同一份 `decision_envelope`,並同步放入 EventRouter event 與 KM metadata;12 Agent 後續只能沿用此信封補充分析,不得繞過 matcher / feeder / review service 直接改價或覆寫比價資料。 -- EventRouter / Telegram 的 HITL callback 必須優先使用 `decision_envelope.decision_id` 作為事件追蹤 ID;若上游未帶 `event.id`,`triaged_alert()` 仍會用 `decision_id` 產生 `momo:eig:*` callback,避免價格決策審核落成 `unknown`。 +- EventRouter / Telegram 的 HITL callback 必須優先使用 `decision_envelope.decision_id` 作為事件追蹤 ID;若上游未帶 `event.id`,`triaged_alert()` 仍會用 `decision_id` 產生 `momo:eig:*` callback,避免價格決策審核落成 `unknown`。所有 `momo:eig:*` callback 必須以 UTF-8 byte-safe 截斷,確保 `callback_data` 不超過 Telegram 64-byte 限制。 - 競品比價相關的 Agent 建議只能讀 `competitor_match_attempts` / review queue / `competitor_prices` 的既有證據;不得直接寫 `competitor_prices` 或覆蓋 `_should_upsert_competitor_price()` 的保護規則。 ## 一、四 AI Agent 路由架構 diff --git a/docs/memory/history_logs.md b/docs/memory/history_logs.md index 6c5050c..037aecf 100644 --- a/docs/memory/history_logs.md +++ b/docs/memory/history_logs.md @@ -13,6 +13,7 @@ ## 📅 詳細更新日誌 (考古存檔) ### 2026-05-24:PChome 近門檻身份回收第二輪 +- **V10.431 Telegram callback byte-safe**: `triaged_alert()` 的 `momo:eig:*` HITL callback 改為依 UTF-8 byte 長度截斷,不再用字元數截斷;中文或過長 `event.id` / `decision_envelope.decision_id` 仍會保留可追蹤 payload,且保證 `callback_data` 不超過 Telegram 64-byte 限制,避免專業排版告警因 callback 太長而整則送出失敗。 - **V10.430 NemoTron 決策 callback 追蹤 ID 修補**: `NemoTronDispatcher._send_telegram()` 會把 `decision_envelope.decision_id` 提升為 EventRouter `event.id`;`triaged_alert()` 也會在上游缺 `event.id` 時改用 `decision_id` 產生 `momo:eig:*` callback,避免價格決策通知的「忽略此事件」audit 落成 `unknown` 而無法追查。 - **V10.429 111 / NemoTron 治理回歸補齊**: 補齊 `.env.example` 中 111 circuit breaker、111 allowlist proxy、部署 smoke、資料庫與 Redis runtime keys,並同步大檔 inventory 行數,讓完整測試可覆蓋最新 `V10.425`–`V10.428` 變更;此版不放寬商品比對門檻、不修改 `competitor_prices` 寫入規則。 - **V10.428 NemoTron 價格決策信封落地**: `NemoTronDispatcher` 的 `price_alert` 與 `human_review` 事件現在會產生 12 Agent 共用 `decision_envelope`,把同款證據、價差、七日銷量變化、營收流失、建議行動、HITL guardrails、資料品質與 trace 同步寫入 EventRouter event 與 KM metadata;這讓 Telegram、AI 觀測台、PPT QA 與後續 Agent 協作能讀同一份可稽核證據,而不是各自解析告警文字。 diff --git a/services/telegram_templates.py b/services/telegram_templates.py index 5ed64d6..5fa0648 100644 --- a/services/telegram_templates.py +++ b/services/telegram_templates.py @@ -64,6 +64,23 @@ def _sanitize_telegram_html(text: str, parse_mode: Optional[str] = "HTML") -> st return value +def _callback_payload_utf8(value: Any, max_bytes: int = 52) -> str: + """Clamp callback payload by UTF-8 bytes without splitting multibyte chars.""" + text = str(value or "unknown").strip() or "unknown" + encoded = text.encode("utf-8") + if len(encoded) <= max_bytes: + return text + + clipped = encoded[:max_bytes] + while clipped: + try: + decoded = clipped.decode("utf-8").strip() + return decoded or "unknown" + except UnicodeDecodeError: + clipped = clipped[:-1] + return "unknown" + + def _send_telegram_raw(text: str, chat_ids: Optional[list] = None, reply_markup: Optional[Dict[str, Any]] = None, parse_mode: Optional[str] = "HTML") -> bool: @@ -829,8 +846,8 @@ def triaged_alert(base_event: Dict[str, Any], tier_label: str, lines.append(f"
{trace[-400:]}")
message = "\n".join(lines)
- # ADR-012: eig=event_ignore,event_id 截斷確保 ≤ 60 bytes(留 buffer)
- _eid = str(event_id or "unknown")[:52]
+ # ADR-012: eig=event_ignore,callback_data 需小於 Telegram 64-byte 限制。
+ _eid = _callback_payload_utf8(event_id, max_bytes=52)
keyboard = {"inline_keyboard": [
[{"text": "🛑 忽略此事件",
"callback_data": f"momo:eig:{_eid}"}],
diff --git a/tests/test_telegram_triaged_alert_format.py b/tests/test_telegram_triaged_alert_format.py
index 2c7a6bf..bcf0672 100644
--- a/tests/test_telegram_triaged_alert_format.py
+++ b/tests/test_telegram_triaged_alert_format.py
@@ -217,3 +217,44 @@ def test_triaged_alert_uses_decision_id_when_event_id_missing():
keyboard["inline_keyboard"][0][0]["callback_data"]
== "momo:eig:nemotron:price_alert:SKU-1:abcdef12"
)
+
+
+def test_triaged_alert_clamps_long_ascii_callback_to_telegram_limit():
+ _msg, keyboard = triaged_alert(
+ base_event={
+ "event_type": "price_alert",
+ "title": "MOMO / PChome 價格威脅",
+ "summary": "高信心同款且 PChome 低價。",
+ "id": "ascii-event-id-" + ("x" * 120),
+ },
+ tier_label="NemoTron · P2",
+ ai_summary="建議進人工價格審核。",
+ )
+
+ callback_data = keyboard["inline_keyboard"][0][0]["callback_data"]
+ assert callback_data.startswith("momo:eig:")
+ assert len(callback_data.encode("utf-8")) <= 64
+
+
+def test_triaged_alert_clamps_multibyte_callback_to_telegram_limit():
+ _msg, keyboard = triaged_alert(
+ base_event={
+ "event_type": "price_alert",
+ "title": "MOMO / PChome 價格威脅",
+ "summary": "高信心同款且 PChome 低價。",
+ "decision_envelope": {
+ "decision_id": "價格決策" * 20,
+ "decision_type": "price_alert",
+ "severity": "P2",
+ "guardrails": {"can_auto_execute": False, "data_quality": "complete"},
+ },
+ },
+ tier_label="NemoTron · P2",
+ ai_summary="建議進人工價格審核。",
+ )
+
+ callback_data = keyboard["inline_keyboard"][0][0]["callback_data"]
+ assert callback_data.startswith("momo:eig:")
+ assert callback_data != "momo:eig:unknown"
+ assert len(callback_data.encode("utf-8")) <= 64
+ assert callback_data.encode("utf-8").decode("utf-8") == callback_data