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"