From 91601da3f818cd71045c6e54b8a2f54e612024ba Mon Sep 17 00:00:00 2001 From: OoO Date: Tue, 19 May 2026 23:35:16 +0800 Subject: [PATCH] =?UTF-8?q?[V10.289]=20=E9=87=8D=E6=8E=92=20EA=20HITL=20Te?= =?UTF-8?q?legram=20=E5=91=8A=E8=AD=A6=E6=A0=BC=E5=BC=8F=20|=20telegram=5F?= =?UTF-8?q?templates.py?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- TODO_NEXT_STEPS.txt | 1 + config.py | 2 +- docs/AI_INTELLIGENCE_MODULE_SOT.md | 3 +- services/telegram_templates.py | 226 ++++++++++++++++++-- tests/test_telegram_triaged_alert_format.py | 43 ++++ 5 files changed, 254 insertions(+), 21 deletions(-) create mode 100644 tests/test_telegram_triaged_alert_format.py diff --git a/TODO_NEXT_STEPS.txt b/TODO_NEXT_STEPS.txt index bbd6197..9784a3d 100644 --- a/TODO_NEXT_STEPS.txt +++ b/TODO_NEXT_STEPS.txt @@ -4,6 +4,7 @@ ================================================================================ 【已完成】 + - V10.289 重排 Elephant Alpha L3 HITL `ea_escalation` Telegram 告警:改成專業 incident brief 格式,分成決策狀態、背景摘要、風險摘要、TOP 待審 SKU 與建議處置;價格行動會拆出 MOMO/PChome 價格、價差、人工處置與 PChome ID,避免長 bullet 難讀。 - V10.284 關閉 Code Review Hermes LLM scan 預設路徑:Step 2 改 deterministic fast static scan,不再讓部署後先卡三段 Ollama timeout;若需要 LLM scan 可用 `CODE_REVIEW_HERMES_LLM_SCAN_ENABLED=true` 顯式開啟,仍只走本地矩陣、不走 Gemini。 - V10.283 將 Code Review Hermes scan 收斂為 fast compact prompt:預設 2 檔 × 900 字、輸出 384 tokens,仍走 GCP-A → GCP-B → 111 本地矩陣,避免部署後 code_review_hermes 先卡三段 timeout。 - V10.282 補齊 Code Review Hermes scan 本地模型矩陣:掃描階段也走 GCP-A `qwen2.5-coder:7b` → GCP-B `gemma3:4b` → 111 `hermes3:latest`,避免 `hermes3` 在三主機各卡 35s 後只留下 error;Hermes scan 不會啟用 Gemini。 diff --git a/config.py b/config.py index 4effab9..378419a 100644 --- a/config.py +++ b/config.py @@ -320,7 +320,7 @@ YOUTUBE_API_KEY = os.getenv('YOUTUBE_API_KEY', '') # ========================================== # 系統版本與路徑 # ========================================== -SYSTEM_VERSION = "V10.287" +SYSTEM_VERSION = "V10.289" 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 ca7adf2..43a3ba4 100644 --- a/docs/AI_INTELLIGENCE_MODULE_SOT.md +++ b/docs/AI_INTELLIGENCE_MODULE_SOT.md @@ -2,7 +2,7 @@ > **最後更新**: 2026-05-19 (台北時間) > **狀態**: 🟢 四 AI Agent 自動化閉環已落地;LLM 路由紅線升級為 Ollama-first 三主機級聯,Gemini 僅備援 / 鎖定場景 -> **適用版本**: V10.284 +> **適用版本**: V10.289 --- @@ -373,6 +373,7 @@ python3 -m services.competitor_identity_revalidator --limit 500 --apply 2. **倒金字塔結構** — 結論先行 → 核心數據 → AI 洞察 → 建議行動 → 運算足跡 3. **收斂行動呼籲 (Call to Action)** — 每則訊息只有一個明確的 👉 建議行動 4. **底部運算足跡** — FinOps + Observability,用分隔線隔開主訊息 +5. **EA HITL 專業 brief** — `ea_escalation` 必須分成決策狀態、背景摘要、風險摘要、TOP 待審 SKU 與建議處置;價格類行動不得用長 bullet 串接,必須拆出 MOMO/PChome 價格、價差、人工處置與 PChome ID。 ### 5.2 語意化 Emoji 字典 diff --git a/services/telegram_templates.py b/services/telegram_templates.py index a292b12..c445197 100644 --- a/services/telegram_templates.py +++ b/services/telegram_templates.py @@ -21,6 +21,7 @@ import io import json import logging import os +import re from html import escape from datetime import datetime from typing import Any, Dict, List, Optional @@ -467,6 +468,183 @@ def meta_analysis_msg(period: str, content: str) -> str: # 💡 洞察類模板 # ══════════════════════════════════════════════════════════════════════════════ +def _short_text(value: Any, limit: int = 120) -> str: + text = str(value or "").strip() + if len(text) <= limit: + return text + return text[: max(0, limit - 1)].rstrip() + "…" + + +def _split_action_parts(action: Any) -> List[str]: + return [ + part.strip() + for part in re.split(r"\s*[||]\s*", str(action or "")) + if part and part.strip() + ] + + +def _parse_ea_action(action: Any) -> Dict[str, Any]: + parts = _split_action_parts(action) + item: Dict[str, Any] = { + "raw": str(action or ""), + "title": parts[0] if parts else str(action or ""), + "notes": [], + } + title_match = re.match(r"^\[([^\]]+)\]\s*(.+)$", item["title"]) + if title_match: + item["sku"] = title_match.group(1).strip() + item["name"] = title_match.group(2).strip() + + for part in parts[1:]: + if "MOMO" in part and "PChome" in part: + item["comparison"] = part + momo = re.search(r"MOMO\s*\$([0-9,]+)", part) + pchome = re.search(r"PChome\s*\$([0-9,]+)", part) + pct = re.search(r"\(([+-]?\d+(?:\.\d+)?)%\)", part) + if momo: + item["momo_price"] = momo.group(1) + if pchome: + item["pchome_price"] = pchome.group(1) + if pct: + item["gap_pct"] = float(pct.group(1)) + item["gap_pct_text"] = f"{float(pct.group(1)):+.1f}%" + elif part.startswith("PChome "): + item["pchome_id"] = part.replace("PChome", "", 1).strip() + elif part.startswith("建議"): + item["action"] = part + elif "NT$" in part or "流失" in part or "價差" in part or "價格優勢" in part: + item["impact"] = part + amount = re.search(r"NT\$\s*([0-9,]+)", part) + if amount: + try: + item["impact_amount"] = int(amount.group(1).replace(",", "")) + except ValueError: + pass + else: + item["notes"].append(part) + return item + + +def _format_ea_risk_summary(actions: List[Dict[str, Any]]) -> List[str]: + count = len(actions) + gap_values = [a["gap_pct"] for a in actions if isinstance(a.get("gap_pct"), (int, float))] + amounts = [a["impact_amount"] for a in actions if isinstance(a.get("impact_amount"), int)] + lines = [f"• 待審 SKU:{count} 件"] + if gap_values: + low, high = min(gap_values), max(gap_values) + if low == high: + lines.append(f"• 價差幅度:{low:+.1f}%") + else: + lines.append(f"• 價差範圍:{low:+.1f}%~{high:+.1f}%") + if amounts: + lines.append(f"• 最大單件價差:NT$ {max(amounts):,}") + lines.append("• 核心判斷:先確認同款 identity_v2,再決定跟價、促銷或曝光") + return lines + + +def _format_ea_action_card(item: Dict[str, Any], index: int) -> List[str]: + sku = escape(str(item.get("sku") or "")) + name = escape(_short_text(item.get("name") or item.get("title") or "", 58)) + heading = f"{index}. [{sku}] {name}" if sku else f"{index}. {name}" + lines = [heading] + + momo_price = item.get("momo_price") + pchome_price = item.get("pchome_price") + if momo_price or pchome_price: + momo_text = f"${momo_price}" if momo_price else "—" + pchome_text = f"${pchome_price}" if pchome_price else "—" + lines.append(f" MOMO:{momo_text} PChome:{pchome_text}") + + gap_text = item.get("gap_pct_text") + impact = item.get("impact") + if gap_text or impact: + impact_text = escape(str(impact or "")) + if gap_text and impact_text: + lines.append(f" 價差:{escape(str(gap_text))} {impact_text}") + elif gap_text: + lines.append(f" 價差:{escape(str(gap_text))}") + else: + lines.append(f" 影響:{impact_text}") + + action = str(item.get("action") or "").replace("建議", "", 1).strip(" ::") + if action: + lines.append(f" 動作:{escape(_short_text(action, 86))}") + + if item.get("pchome_id"): + lines.append(f" PChome:{escape(_short_text(item['pchome_id'], 40))}") + return lines + + +def _format_ea_escalation_alert( + *, + base_event: Dict[str, Any], + tier_label: str, + ai_summary: str, + ai_cause: Optional[str], + ai_actions: Optional[list], +) -> str: + event_type = escape(str(base_event.get("event_type", "ea_escalation"))) + title = escape(str(base_event.get("title", "EA 升級審核"))) + summary = escape(str(base_event.get("summary", ""))) + cause_parts = [ + escape(part.strip()) + for part in str(ai_cause or "").split("|") + if part and part.strip() + ] + parsed_actions = [_parse_ea_action(action) for action in (ai_actions or [])] + shown_actions = parsed_actions[:5] + hidden_count = max(0, len(parsed_actions) - len(shown_actions)) + + lines = [ + f"⚡ {escape(str(tier_label))}", + f"📌 {title}", + f"{event_type}", + "━━━━━━━━━━━━━━━━━━━━", + "🧭 決策狀態", + ] + if summary: + lines.append(f"• {summary}") + for part in cause_parts[:3]: + lines.append(f"• {part}") + + if ai_summary: + lines += [ + "", + "🧠 背景摘要", + f"• {escape(_short_text(ai_summary, 280))}", + ] + + if parsed_actions: + lines += [ + "", + "📊 風險摘要", + *_format_ea_risk_summary(parsed_actions), + "", + "📋 TOP 待審 SKU", + ] + for idx, item in enumerate(shown_actions, start=1): + if idx > 1: + lines.append("") + lines.extend(_format_ea_action_card(item, idx)) + if hidden_count: + lines.append(f"\n另有 {hidden_count} 件,請至觀測台查看完整清單。") + lines += [ + "", + "✅ 建議處置", + "• 先人工確認 PChome identity_v2 與規格一致", + "• 同款:評估跟價、組合促銷或加強 MOMO 價格優勢曝光", + "• 非同款:標記待審,避免進入自動調價或簡報決策", + ] + else: + lines += [ + "", + "✅ 建議處置", + "• 先確認資料來源與最近錯誤紀錄", + "• 補齊可審核證據後再批准執行", + ] + + return "\n".join(lines) + def triaged_alert(base_event: Dict[str, Any], tier_label: str, ai_summary: str, ai_cause: Optional[str] = None, ai_actions: Optional[list] = None, @@ -481,25 +659,35 @@ def triaged_alert(base_event: Dict[str, Any], tier_label: str, safe_actions = [escape(str(a)) for a in (ai_actions or [])] safe_executed = [escape(str(a)) for a in (ai_executed or [])] - lines = [ - f"⚡ {tier_label} · {event_type}", - f"📌 {title}", - "", - ] - if summary: - lines += [f"🔍 概要:{summary}", ""] - if safe_ai_summary: - lines += [f"🧠 AI 摘要:{safe_ai_summary[:400]}", ""] - if safe_ai_cause: - lines += [f"💡 可能原因:{safe_ai_cause}", ""] - if safe_actions: - lines += ["📋 建議行動:"] + [f" • {a}" for a in safe_actions] + [""] - if safe_executed: - lines += ["✅ 已執行:"] + [f" • {a}" for a in safe_executed] + [""] + if event_type == "ea_escalation": + message = _format_ea_escalation_alert( + base_event=base_event, + tier_label=tier_label, + ai_summary=str(ai_summary or ""), + ai_cause=ai_cause, + ai_actions=ai_actions, + ) + else: + lines = [ + f"⚡ {tier_label} · {event_type}", + f"📌 {title}", + "", + ] + if summary: + lines += [f"🔍 概要:{summary}", ""] + if safe_ai_summary: + lines += [f"🧠 AI 摘要:{safe_ai_summary[:400]}", ""] + if safe_ai_cause: + lines += [f"💡 可能原因:{safe_ai_cause}", ""] + if safe_actions: + lines += ["📋 建議行動:"] + [f" • {a}" for a in safe_actions] + [""] + if safe_executed: + lines += ["✅ 已執行:"] + [f" • {a}" for a in safe_executed] + [""] - trace = base_event.get("trace") - if trace: - lines.append(f"
{trace[-400:]}
") + trace = base_event.get("trace") + if trace: + lines.append(f"
{trace[-400:]}
") + message = "\n".join(lines) # ADR-012: eig=event_ignore,event_id 截斷確保 ≤ 60 bytes(留 buffer) _eid = str(event_id)[:52] @@ -507,7 +695,7 @@ def triaged_alert(base_event: Dict[str, Any], tier_label: str, [{"text": "🛑 忽略此事件", "callback_data": f"momo:eig:{_eid}"}], ]} - return "\n".join(lines), keyboard + return message, keyboard def insight_summary_msg(insights: List[Dict], period: str = "近24h") -> str: diff --git a/tests/test_telegram_triaged_alert_format.py b/tests/test_telegram_triaged_alert_format.py new file mode 100644 index 0000000..eeb737c --- /dev/null +++ b/tests/test_telegram_triaged_alert_format.py @@ -0,0 +1,43 @@ +from services.telegram_templates import triaged_alert + + +def test_ea_escalation_uses_structured_incident_brief(): + msg, keyboard = triaged_alert( + base_event={ + "event_type": "ea_escalation", + "title": "🐘 EA 升級審核 · 價格下滑警報", + "summary": "自主決策信心度 0.82 低於門檻,需人工批准", + "id": "ea_review_test", + }, + tier_label="🐘 Elephant Alpha · L3 HITL", + ai_summary=( + "分析顯示 5 個代表性 SKU 的價格差異分別為 16.7%~38.3%," + "且每件價差至多 370 元。" + ), + ai_cause="觸發類型:價格下滑警報 | 信心度:0.82 | 參與模組:Hermes, NemoTron", + ai_actions=[ + "[5900068] [derma Angel 護妍天使] 集中抗痘精華|" + "MOMO $300 vs PChome $250 (+16.7%)|" + "每件價差 NT$ 50|" + "建議人工確認 PChome identity_v2 後評估跟價或促銷|" + "PChome DABC53-A9009OEF", + "[3518670] L'Occitane 歐舒丹 官方直營 乳油木|" + "MOMO $1,220 vs PChome $850 (+30.3%)|" + "每件價差 NT$ 370|" + "建議人工確認 PChome identity_v2 後評估跟價或促銷|" + "PChome DDADKS-A900HIG5Y", + ], + ) + + assert "🧭 決策狀態" in msg + assert "📊 風險摘要" in msg + assert "📋 TOP 待審 SKU" in msg + assert "✅ 建議處置" in msg + assert "• 待審 SKU:2 件" in msg + assert "• 價差範圍:+16.7%~+30.3%" in msg + assert "• 最大單件價差:NT$ 370" in msg + assert "1. [5900068]" in msg + assert "MOMO:$300 PChome:$250" in msg + assert "PChome:DABC53-A9009OEF" in msg + assert " • [5900068]" not in msg + assert keyboard["inline_keyboard"][0][0]["callback_data"] == "momo:eig:ea_review_test"