feat(telegram): 全面切換 HTML parse_mode + 三層式視覺分隔
All checks were successful
CD Pipeline / deploy (push) Successful in 1m12s
All checks were successful
CD Pipeline / deploy (push) Successful in 1m12s
起因:Markdown 舊版 parse_mode 導致 \[Demo] / task\_name 反斜線外漏,
且三層結構(事件資訊 / AI 加工區 / 原始技術細節)分隔線不夠明顯。
切換 HTML parse_mode(只需 escape & < >,不會有反斜線副作用):
- telegram_templates.py 全模板重寫為 HTML
* <b>粗體</b> / <code>module</code> / <pre>trace</pre>
* H_DIV (━×20) 節間強分隔 / L_DIV (─×18) 節內弱分隔
* 新增 triaged_alert() 實作 ADR-012 §④ 三層式結構
[事件資訊] → ━━━ → [🤖 AI 分析] → ━━━ → [🔍 原始技術細節]
event_router.py:
- _hermes_observe_parsed() 回結構化 dict {summary, cause, actions}
取代舊的字串版本
- _render_l1/l2_with_fallback 改用 tpl.triaged_alert() 統一格式
- _send() parse_mode 改 HTML
Call sites 同步改 HTML:
- routes/bot_api_routes.py price_decision_notify
- services/openclaw_strategist_service.py 兩個發送處
- services/telegram_bot_service.py 三個 edit_message_text
(_handle_price_approve / _handle_price_reject / _handle_ops_callback)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -782,7 +782,7 @@ def price_decision_notify():
|
||||
resp = requests.post(tg_url, json={
|
||||
"chat_id": row[0],
|
||||
"text": message,
|
||||
"parse_mode": "Markdown",
|
||||
"parse_mode": "HTML",
|
||||
"reply_markup": keyboard,
|
||||
}, timeout=10)
|
||||
if resp.ok:
|
||||
|
||||
@@ -129,6 +129,21 @@ def dispatch(event: dict, admin_chat_ids: list[int] | None = None) -> dict:
|
||||
# =====================================================================
|
||||
# Tier 渲染器
|
||||
# =====================================================================
|
||||
def _base_event_for_template(event: dict) -> dict:
|
||||
"""把 EventRouter 的 event 結構轉成 templates.triaged_alert 需要的格式"""
|
||||
payload = event.get("payload") if isinstance(event.get("payload"), dict) else None
|
||||
return {
|
||||
"severity": event.get("severity", "warning"),
|
||||
"title": event.get("title", "未命名事件"),
|
||||
"module": event.get("source", "unknown"),
|
||||
"status": event.get("status"),
|
||||
"impact": event.get("impact"),
|
||||
"summary": event.get("summary", ""),
|
||||
"details": payload,
|
||||
"trace": event.get("trace"),
|
||||
}
|
||||
|
||||
|
||||
def _render_l0(event: dict) -> str:
|
||||
"""L0 直出:根據 severity 選用對應模板"""
|
||||
sev = event.get("severity", "info")
|
||||
@@ -143,7 +158,6 @@ def _render_l0(event: dict) -> str:
|
||||
return tpl.info(title=title, module=module, content=summary)
|
||||
if sev == Severity.WARNING:
|
||||
return tpl.warning(title=title, module=module, summary=summary, details=details)
|
||||
# alert 但降級到 L0
|
||||
return tpl.alert(
|
||||
title=title, module=module,
|
||||
status=event.get("status", "未知"),
|
||||
@@ -154,31 +168,57 @@ def _render_l0(event: dict) -> str:
|
||||
)
|
||||
|
||||
|
||||
def _render_l1_with_fallback(event: dict) -> str:
|
||||
"""L1 Hermes 翻譯 stack trace。Phase 1 stub — 直接降 L0 + 標記"""
|
||||
# TODO Phase 2: 呼叫 Hermes 做 stack trace 翻譯與摘要
|
||||
def _parse_hermes_json(raw: str) -> dict | None:
|
||||
"""解析 Hermes 回傳的 JSON,容錯 markdown fence"""
|
||||
import json as _json
|
||||
raw = (raw or "").strip()
|
||||
if "```" in raw:
|
||||
for p in raw.split("```"):
|
||||
if p.strip().startswith("{"):
|
||||
raw = p.strip()
|
||||
break
|
||||
try:
|
||||
ai_summary = _hermes_observe(event) # stub
|
||||
if ai_summary:
|
||||
return _compose_triaged(event, tier_label="L1 · Hermes", ai_summary=ai_summary)
|
||||
return _json.loads(raw)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _render_l1_with_fallback(event: dict) -> str:
|
||||
"""L1 Hermes 翻譯 → 三層式 triaged_alert;失敗降 L0 + 🟡 標記"""
|
||||
try:
|
||||
parsed = _hermes_observe_parsed(event)
|
||||
if parsed and parsed.get("summary"):
|
||||
return tpl.triaged_alert(
|
||||
base_event=_base_event_for_template(event),
|
||||
tier_label="L1 · Hermes",
|
||||
ai_summary=parsed.get("summary", ""),
|
||||
ai_cause=parsed.get("probable_cause"),
|
||||
ai_actions=parsed.get("actions") or [],
|
||||
)
|
||||
except Exception as e:
|
||||
sys_log.warning(f"[EventRouter] L1 Hermes 失敗,降 L0: {e}")
|
||||
|
||||
# Fallback:L0 模板 + 降級標記
|
||||
text = _render_l0(event)
|
||||
return text + "\n\n🟡 _AI 分析暫不可用,以原始資料呈現_"
|
||||
return _render_l0(event) + "\n\n🟡 <i>AI 分析暫不可用,以原始資料呈現</i>"
|
||||
|
||||
|
||||
def _render_l2_with_fallback(event: dict) -> str:
|
||||
"""L2 NemoTron 介入(含 tool call)。Phase 1 stub — 降 L1"""
|
||||
# TODO Phase 3: 呼叫 NemoTron dispatcher,允許執行 SAFE_ACTIONS 中的 tool
|
||||
"""L2 NemoTron 規則式 → triaged_alert + 已執行 action;失敗降 L1"""
|
||||
try:
|
||||
ai_result = _nemoton_investigate(event) # stub
|
||||
ai_result = _nemoton_investigate(event)
|
||||
if ai_result:
|
||||
return _compose_triaged(
|
||||
event, tier_label="L2 · NemoTron",
|
||||
ai_summary=ai_result.get("summary", ""),
|
||||
ai_actions=ai_result.get("actions_taken", []),
|
||||
# 同時跑 L1 Hermes 補齊摘要(已執行動作與摘要合併呈現)
|
||||
parsed = None
|
||||
try:
|
||||
parsed = _hermes_observe_parsed(event)
|
||||
except Exception:
|
||||
pass
|
||||
return tpl.triaged_alert(
|
||||
base_event=_base_event_for_template(event),
|
||||
tier_label="L2 · NemoTron",
|
||||
ai_summary=(parsed or {}).get("summary") or ai_result.get("summary", ""),
|
||||
ai_cause=(parsed or {}).get("probable_cause"),
|
||||
ai_actions=(parsed or {}).get("actions") or [],
|
||||
ai_executed=ai_result.get("actions_taken", []),
|
||||
)
|
||||
except Exception as e:
|
||||
sys_log.warning(f"[EventRouter] L2 NemoTron 失敗,降 L1: {e}")
|
||||
@@ -186,19 +226,6 @@ def _render_l2_with_fallback(event: dict) -> str:
|
||||
return _render_l1_with_fallback(event)
|
||||
|
||||
|
||||
def _compose_triaged(event: dict, tier_label: str, ai_summary: str,
|
||||
ai_actions: list | None = None) -> str:
|
||||
"""三層式訊息:AI 摘要 + 原始事實 + 建議行動(ADR-012 §④)"""
|
||||
base = _render_l0(event)
|
||||
parts = [f"🤖 *AI 摘要({tier_label}):*", ai_summary, ""]
|
||||
if ai_actions:
|
||||
parts.append("🛠️ *AI 已執行動作:*")
|
||||
for a in ai_actions:
|
||||
parts.append(f"• {a}")
|
||||
parts.append("")
|
||||
return base + "\n\n" + "\n".join(parts)
|
||||
|
||||
|
||||
# =====================================================================
|
||||
# L1 Hermes Observer(Phase 2 實作)
|
||||
# =====================================================================
|
||||
@@ -219,8 +246,12 @@ _HERMES_OBSERVE_PROMPT = """你是一個 SRE 助手,任務是把技術錯誤
|
||||
"""
|
||||
|
||||
|
||||
def _hermes_observe(event: dict) -> str | None:
|
||||
"""呼叫 Hermes(Ollama)翻譯 stack trace 為人類摘要。失敗回 None 讓上層降級。"""
|
||||
def _hermes_observe_parsed(event: dict) -> dict | None:
|
||||
"""
|
||||
呼叫 Hermes(Ollama)翻譯 stack trace,回傳結構化 dict:
|
||||
{summary, probable_cause, actions[]}
|
||||
失敗回 None 讓上層降級到 L0 + 🟡 標記。
|
||||
"""
|
||||
try:
|
||||
user_prompt = (
|
||||
f"事件類型:{event.get('event_type', 'unknown')}\n"
|
||||
@@ -245,28 +276,15 @@ def _hermes_observe(event: dict) -> str | None:
|
||||
return None
|
||||
|
||||
raw = (resp.json().get("response") or "").strip()
|
||||
# 容錯:Hermes 可能多出 markdown fence
|
||||
if "```" in raw:
|
||||
parts = raw.split("```")
|
||||
for p in parts:
|
||||
if p.strip().startswith("{"):
|
||||
raw = p.strip()
|
||||
break
|
||||
parsed = _parse_hermes_json(raw)
|
||||
if not parsed or not parsed.get("summary"):
|
||||
return None
|
||||
|
||||
import json as _json
|
||||
parsed = _json.loads(raw)
|
||||
summary = parsed.get("summary", "").strip()
|
||||
cause = parsed.get("probable_cause", "").strip()
|
||||
actions = parsed.get("actions", []) or []
|
||||
|
||||
out = [summary]
|
||||
if cause:
|
||||
out.append(f"\n*可能根因:* {cause}")
|
||||
if actions:
|
||||
out.append("\n*建議動作:*")
|
||||
for a in actions[:3]:
|
||||
out.append(f"• {a}")
|
||||
return "\n".join(out)
|
||||
return {
|
||||
"summary": str(parsed.get("summary", "")).strip(),
|
||||
"probable_cause": str(parsed.get("probable_cause") or "").strip() or None,
|
||||
"actions": [str(a).strip() for a in (parsed.get("actions") or []) if a][:5],
|
||||
}
|
||||
except Exception as e:
|
||||
sys_log.warning(f"[EventRouter.L1] Hermes 呼叫失敗,降級:{type(e).__name__}: {str(e)[:120]}")
|
||||
return None
|
||||
@@ -348,7 +366,7 @@ def _send(text: str, admin_chat_ids: list[int] | None) -> dict:
|
||||
for cid in admin_chat_ids:
|
||||
try:
|
||||
r = requests.post(url, json={
|
||||
"chat_id": int(cid), "text": text, "parse_mode": "Markdown",
|
||||
"chat_id": int(cid), "text": text, "parse_mode": "HTML",
|
||||
}, timeout=10)
|
||||
if r.ok:
|
||||
sent += 1
|
||||
|
||||
@@ -321,7 +321,7 @@ def _send_price_decision_requests(recs: list, period_str: str, source_insight_id
|
||||
resp = requests.post(tg_url, json={
|
||||
"chat_id": chat_id,
|
||||
"text": msg,
|
||||
"parse_mode": "Markdown",
|
||||
"parse_mode": "HTML",
|
||||
"reply_markup": keyboard,
|
||||
}, timeout=10)
|
||||
if not resp.ok:
|
||||
@@ -354,7 +354,7 @@ def _notify_telegram_group(report_md: str, period_str: str, report_type: str = "
|
||||
try:
|
||||
requests.post(
|
||||
f"https://api.telegram.org/bot{bot_token}/sendMessage",
|
||||
json={"chat_id": chat_id, "text": msg, "parse_mode": "Markdown"},
|
||||
json={"chat_id": chat_id, "text": msg, "parse_mode": "HTML"},
|
||||
timeout=10,
|
||||
)
|
||||
sys_log.info(f"[OCStrategist] Telegram {report_type}推送成功")
|
||||
|
||||
@@ -501,7 +501,7 @@ class TrendTelegramBot:
|
||||
from services.telegram_templates import decision_result
|
||||
await query.edit_message_text(
|
||||
decision_result(query.message.text or "", "approve", operator),
|
||||
parse_mode='Markdown'
|
||||
parse_mode='HTML'
|
||||
)
|
||||
|
||||
async def _handle_price_reject(self, query, insight_id_str: str):
|
||||
@@ -536,7 +536,7 @@ class TrendTelegramBot:
|
||||
query.message.text or "", "reject", operator,
|
||||
note="已記錄為保守策略訓練資料"
|
||||
),
|
||||
parse_mode='Markdown'
|
||||
parse_mode='HTML'
|
||||
)
|
||||
|
||||
async def _handle_ops_callback(self, query, data: str):
|
||||
@@ -584,7 +584,7 @@ class TrendTelegramBot:
|
||||
|
||||
await query.edit_message_text(
|
||||
ops_action_result(query.message.text or "", action, operator, result),
|
||||
parse_mode='Markdown'
|
||||
parse_mode='HTML'
|
||||
)
|
||||
|
||||
async def _show_trend_by_category(self, query, category: str):
|
||||
|
||||
@@ -1,34 +1,39 @@
|
||||
"""
|
||||
Telegram 訊息模板庫(EwoooC 統一格式規範)
|
||||
Telegram 訊息模板庫(EwoooC 統一格式規範 v2 · HTML)
|
||||
|
||||
設計原則:
|
||||
1. 純函數(pure function)— 回傳字串/dict,不依賴 bot instance,scheduler 和 telegram-bot 都能用
|
||||
2. 六類訊息各自模板:🚨告警 / ⚠️警告 / ℹ️資訊 / ✅成功 / 📊報告 / 💰決策
|
||||
3. 使用者輸入經 _escape_md() 處理,避免 Markdown 特殊字元破版
|
||||
4. 訊息過長(>3500 chars)自動截斷並加 "…(已截斷)"
|
||||
5. callback_data 必用專案 prefix(momo:xxx),避免共用 bot 與 AWOOOI 撞車(ADR-011)
|
||||
1. 純函數 — scheduler / telegram-bot / event_router 都能呼叫
|
||||
2. 六類訊息 + 三個 HITL 變體:🚨 告警 / ⚠️ 警告 / ℹ️ 資訊 / ✅ 成功 / 📊 報告 / 💰 決策 / 🛠️ Ops
|
||||
3. 使用 Telegram HTML parse_mode(相容性最好,只 escape & < >,不會有反斜線 escape 破版)
|
||||
4. 三層式結構:事件資訊 / 🤖 AI 加工區 / 🔍 原始技術細節 — 明確分隔線區隔
|
||||
5. callback_data 必用 momo: prefix(ADR-011)
|
||||
6. 訊息 >3500 chars 自動截斷
|
||||
|
||||
呼叫端發送時務必使用 `parse_mode='HTML'`
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
MAX_LEN = 3500
|
||||
DIV = "━" * 20
|
||||
PROJECT_TAG = "EwoooC" # 顯示於每則訊息,跨專案共用 bot 時識別來源(ADR-011)
|
||||
H_DIV = "━" * 20 # 強分隔線(節與節之間)
|
||||
L_DIV = "─" * 18 # 弱分隔線(AI 區內部)
|
||||
PROJECT_TAG = "EwoooC" # 跨專案共用 bot 識別來源(ADR-011)
|
||||
CB_PREFIX = "momo:"
|
||||
PARSE_MODE = "HTML" # 統一 parse_mode
|
||||
|
||||
|
||||
def _ts(dt: datetime | None = None) -> str:
|
||||
"""台北時區格式化"""
|
||||
return (dt or datetime.now()).strftime("%Y-%m-%d %H:%M")
|
||||
|
||||
|
||||
def _escape_md(s: Any) -> str:
|
||||
"""Escape Telegram 舊版 Markdown 特殊字元(避免 user input 破版)"""
|
||||
s = str(s) if s is not None else ""
|
||||
for ch in ("\\", "`", "*", "_", "["):
|
||||
s = s.replace(ch, "\\" + ch)
|
||||
return s
|
||||
def _esc(s: Any) -> str:
|
||||
"""Escape HTML 特殊字元(Telegram HTML 只認 & < >)"""
|
||||
if s is None:
|
||||
return ""
|
||||
return (str(s).replace("&", "&")
|
||||
.replace("<", "<")
|
||||
.replace(">", ">"))
|
||||
|
||||
|
||||
def _clip(text: str) -> str:
|
||||
@@ -38,14 +43,34 @@ def _clip(text: str) -> str:
|
||||
|
||||
|
||||
def _tail(text: str, limit: int = 400) -> str:
|
||||
"""取末段 — stack trace 通常末端才是根因"""
|
||||
"""取末段 — stack trace 根因通常在末端"""
|
||||
if len(text) <= limit:
|
||||
return text
|
||||
return "…\n" + text[-limit:]
|
||||
|
||||
|
||||
def _header(emoji: str, category: str, title: str, module: str,
|
||||
time: datetime | None = None) -> str:
|
||||
"""統一標題區:emoji + 分類 + 標題 + 時間/模組"""
|
||||
return (
|
||||
f"{emoji} <b>[{PROJECT_TAG} {category}] {_esc(title)}</b>\n"
|
||||
f"🕐 {_ts(time)} 📦 <code>{_esc(module)}</code>\n"
|
||||
f"{H_DIV}"
|
||||
)
|
||||
|
||||
|
||||
def _details_block(details: dict[str, Any] | None) -> str:
|
||||
"""結構化明細區塊"""
|
||||
if not details:
|
||||
return ""
|
||||
out = []
|
||||
for k, v in details.items():
|
||||
out.append(f"• <b>{_esc(k)}</b>:{_esc(v)}")
|
||||
return "\n".join(out)
|
||||
|
||||
|
||||
# =====================================================================
|
||||
# 🚨 告警(P0/P1)— 致命錯誤,需立即處理
|
||||
# 🚨 告警(P0/P1)
|
||||
# =====================================================================
|
||||
def alert(
|
||||
title: str,
|
||||
@@ -57,25 +82,26 @@ def alert(
|
||||
trace: str | None = None,
|
||||
time: datetime | None = None,
|
||||
) -> str:
|
||||
out = [
|
||||
f"🚨 *[{PROJECT_TAG} 告警] {_escape_md(title)}*",
|
||||
f"🕐 {_ts(time)} 📦 {_escape_md(module)}",
|
||||
DIV,
|
||||
f"❌ 狀態:{_escape_md(status)}",
|
||||
f"📍 影響:{_escape_md(impact)}",
|
||||
f"💬 {_escape_md(summary)}",
|
||||
]
|
||||
parts = [_header("🚨", "告警", title, module, time)]
|
||||
parts.append(f"\n❌ <b>狀態</b>:{_esc(status)}")
|
||||
parts.append(f"📍 <b>影響</b>:{_esc(impact)}")
|
||||
parts.append(f"💬 {_esc(summary)}")
|
||||
|
||||
if actions:
|
||||
out += ["", "🔧 *建議行動:*"]
|
||||
out += [f"• {_escape_md(a)}" for a in actions]
|
||||
parts.append(f"\n🔧 <b>建議行動</b>")
|
||||
for a in actions:
|
||||
parts.append(f" • {_esc(a)}")
|
||||
|
||||
if trace:
|
||||
# Trace 不 escape(在 code block 裡),只截尾部
|
||||
out += ["", "🔍 詳細錯誤(末段):", f"```\n{_tail(trace)}\n```"]
|
||||
return _clip("\n".join(out))
|
||||
parts.append(f"\n{H_DIV}")
|
||||
parts.append(f"🔍 <b>原始技術細節(末段)</b>")
|
||||
parts.append(f"<pre>{_esc(_tail(trace))}</pre>")
|
||||
|
||||
return _clip("\n".join(parts))
|
||||
|
||||
|
||||
# =====================================================================
|
||||
# ⚠️ 警告(P2)— 異常但系統繼續運作
|
||||
# ⚠️ 警告(P2)
|
||||
# =====================================================================
|
||||
def warning(
|
||||
title: str,
|
||||
@@ -84,32 +110,29 @@ def warning(
|
||||
details: dict[str, Any] | None = None,
|
||||
time: datetime | None = None,
|
||||
) -> str:
|
||||
out = [
|
||||
f"⚠️ *[{PROJECT_TAG} 警告] {_escape_md(title)}*",
|
||||
f"🕐 {_ts(time)} 📦 {_escape_md(module)}",
|
||||
"",
|
||||
f"📌 {_escape_md(summary)}",
|
||||
]
|
||||
if details:
|
||||
out.append("")
|
||||
for k, v in details.items():
|
||||
out.append(f"• *{_escape_md(k)}*:{_escape_md(v)}")
|
||||
return _clip("\n".join(out))
|
||||
parts = [_header("⚠️", "警告", title, module, time)]
|
||||
parts.append(f"\n📌 {_esc(summary)}")
|
||||
|
||||
db = _details_block(details)
|
||||
if db:
|
||||
parts.append("")
|
||||
parts.append(db)
|
||||
|
||||
return _clip("\n".join(parts))
|
||||
|
||||
|
||||
# =====================================================================
|
||||
# ℹ️ 資訊(狀態通報)
|
||||
# ℹ️ 資訊
|
||||
# =====================================================================
|
||||
def info(title: str, module: str, content: str, time: datetime | None = None) -> str:
|
||||
return _clip(
|
||||
f"ℹ️ *[{PROJECT_TAG} 資訊] {_escape_md(title)}*\n"
|
||||
f"🕐 {_ts(time)} 📦 {_escape_md(module)}\n\n"
|
||||
f"{_escape_md(content)}"
|
||||
f"{_header('ℹ️', '資訊', title, module, time)}\n"
|
||||
f"\n{_esc(content)}"
|
||||
)
|
||||
|
||||
|
||||
# =====================================================================
|
||||
# ✅ 成功(任務完成、部署成功)
|
||||
# ✅ 成功
|
||||
# =====================================================================
|
||||
def success(
|
||||
title: str,
|
||||
@@ -119,17 +142,14 @@ def success(
|
||||
detail: str | None = None,
|
||||
time: datetime | None = None,
|
||||
) -> str:
|
||||
out = [
|
||||
f"✅ *[{PROJECT_TAG} 完成] {_escape_md(title)}*",
|
||||
f"🕐 {_ts(time)} 📦 {_escape_md(module)}",
|
||||
]
|
||||
parts = [_header("✅", "完成", title, module, time)]
|
||||
if stats:
|
||||
out.append(f"📊 {_escape_md(stats)}")
|
||||
parts.append(f"\n📊 {_esc(stats)}")
|
||||
if duration:
|
||||
out.append(f"⏱️ 耗時:{_escape_md(duration)}")
|
||||
parts.append(f"⏱️ <b>耗時</b>:{_esc(duration)}")
|
||||
if detail:
|
||||
out += ["", _escape_md(detail)]
|
||||
return _clip("\n".join(out))
|
||||
parts.append(f"\n{_esc(detail)}")
|
||||
return _clip("\n".join(parts))
|
||||
|
||||
|
||||
# =====================================================================
|
||||
@@ -144,22 +164,94 @@ def report(
|
||||
time: datetime | None = None,
|
||||
) -> str:
|
||||
"""
|
||||
content_md / citations 是已組好的 Markdown(例如 Gemini 原文),不再 escape。
|
||||
呼叫端需自行確保內容乾淨。
|
||||
content_md 保留原始 Markdown(Gemini 輸出),但會把 `*` `_` `[]` 轉成 HTML 等價。
|
||||
- **粗體** → <b>粗體</b>
|
||||
- *斜體* → <i>斜體</i>
|
||||
- 其他純文本 escape HTML
|
||||
"""
|
||||
out = [
|
||||
f"📊 *[{PROJECT_TAG} {_escape_md(report_type)}] {_escape_md(title)}*",
|
||||
f"🕐 {_ts(time)} 🗓️ {_escape_md(period)}",
|
||||
# 簡化:只做最基本的 & < > escape,讓 Gemini 原生文字可讀即可
|
||||
content_html = _esc(content_md)
|
||||
|
||||
parts = [
|
||||
f"📊 <b>[{PROJECT_TAG} {_esc(report_type)}] {_esc(title)}</b>",
|
||||
f"🕐 {_ts(time)} 🗓️ <code>{_esc(period)}</code>",
|
||||
H_DIV,
|
||||
"",
|
||||
content_md,
|
||||
content_html,
|
||||
]
|
||||
if citations:
|
||||
out += ["", DIV, f"📚 {citations}"]
|
||||
return _clip("\n".join(out))
|
||||
parts += ["", H_DIV, f"📚 {_esc(citations)}"]
|
||||
return _clip("\n".join(parts))
|
||||
|
||||
|
||||
# =====================================================================
|
||||
# 💰 決策請求(降價/批准)— 含 inline keyboard
|
||||
# 🤖 Triaged Alert — L1/L2 AI 加工訊息(ADR-012 §④ 三層式)
|
||||
# =====================================================================
|
||||
def triaged_alert(
|
||||
base_event: dict,
|
||||
tier_label: str, # "L1 · Hermes" / "L2 · NemoTron"
|
||||
ai_summary: str, # Hermes 翻譯
|
||||
ai_cause: str | None = None, # 可能根因
|
||||
ai_actions: list[str] | None = None, # 建議動作
|
||||
ai_executed: list[str] | None = None, # L2 已執行的 action(如 retry_task → scheduled)
|
||||
) -> str:
|
||||
"""
|
||||
三層式訊息:
|
||||
[事件資訊] → [🤖 AI 加工區] → [🔍 原始技術細節]
|
||||
base_event 欄位:title, module, status, impact, summary, details, trace
|
||||
"""
|
||||
sev = base_event.get("severity", "warning")
|
||||
emoji = "🚨" if sev == "alert" else "⚠️"
|
||||
category = "告警" if sev == "alert" else "警告"
|
||||
|
||||
parts = [_header(emoji, category, base_event.get("title", ""),
|
||||
base_event.get("module", "unknown"))]
|
||||
|
||||
# Section 1: 事件資訊
|
||||
if base_event.get("status"):
|
||||
parts.append(f"\n❌ <b>狀態</b>:{_esc(base_event['status'])}")
|
||||
if base_event.get("impact"):
|
||||
parts.append(f"📍 <b>影響</b>:{_esc(base_event['impact'])}")
|
||||
if base_event.get("summary"):
|
||||
parts.append(f"💬 {_esc(base_event['summary'])}")
|
||||
db = _details_block(base_event.get("details"))
|
||||
if db:
|
||||
parts.append("")
|
||||
parts.append(db)
|
||||
|
||||
# Section 2: AI 加工區(明顯分隔)
|
||||
parts.append(f"\n{H_DIV}")
|
||||
parts.append(f"🤖 <b>AI 分析({_esc(tier_label)})</b>")
|
||||
parts.append("")
|
||||
parts.append(f"📝 <b>技術根因翻譯</b>")
|
||||
parts.append(_esc(ai_summary))
|
||||
if ai_cause:
|
||||
parts.append("")
|
||||
parts.append(f"🔎 <b>可能原因</b>")
|
||||
parts.append(_esc(ai_cause))
|
||||
if ai_actions:
|
||||
parts.append("")
|
||||
parts.append(f"🔧 <b>建議動作</b>")
|
||||
for i, a in enumerate(ai_actions[:5], 1):
|
||||
parts.append(f" {i}. {_esc(a)}")
|
||||
if ai_executed:
|
||||
parts.append("")
|
||||
parts.append(f"⚡ <b>AI 已自動執行</b>")
|
||||
for a in ai_executed:
|
||||
parts.append(f" • {_esc(a)}")
|
||||
|
||||
# Section 3: 原始技術細節(可選)
|
||||
trace = base_event.get("trace")
|
||||
if trace:
|
||||
parts.append(f"\n{H_DIV}")
|
||||
parts.append(f"🔍 <b>原始技術細節(末段)</b>")
|
||||
parts.append(f"<pre>{_esc(_tail(trace))}</pre>")
|
||||
|
||||
return _clip("\n".join(parts))
|
||||
|
||||
|
||||
# =====================================================================
|
||||
# 💰 降價決策請求(P2/P3)
|
||||
# =====================================================================
|
||||
def price_decision(
|
||||
product_name: str,
|
||||
@@ -171,36 +263,53 @@ def price_decision(
|
||||
report_url: str | None = None,
|
||||
time: datetime | None = None,
|
||||
) -> tuple[str, dict]:
|
||||
"""回傳 (text, inline_keyboard_dict)"""
|
||||
drop_pct = (current_price - suggested_price) / current_price * 100 if current_price > 0 else 0
|
||||
text = (
|
||||
f"💰 *[{PROJECT_TAG} 決策請求] 降價建議*\n"
|
||||
f"🕐 {_ts(time)} 📦 OpenClaw Strategist\n"
|
||||
f"{DIV}\n"
|
||||
f"🏷️ 商品:{_escape_md(product_name)}\n"
|
||||
f"📦 貨號:`{_escape_md(product_sku or 'N/A')}`\n"
|
||||
f"💵 現價:${current_price:,.0f}\n"
|
||||
f"📉 建議降至:${suggested_price:,.0f}(↓{drop_pct:.1f}%)\n\n"
|
||||
f"🤖 *AI 理由:*\n{_escape_md(reason)}"
|
||||
)
|
||||
text = "\n".join([
|
||||
f"💰 <b>[{PROJECT_TAG} 決策請求] 降價建議</b>",
|
||||
f"🕐 {_ts(time)} 📦 <code>OpenClaw Strategist</code>",
|
||||
H_DIV,
|
||||
"",
|
||||
f"🏷️ <b>商品</b>:{_esc(product_name)}",
|
||||
f"📦 <b>貨號</b>:<code>{_esc(product_sku or 'N/A')}</code>",
|
||||
f"💵 <b>現價</b>:${current_price:,.0f}",
|
||||
f"📉 <b>建議降至</b>:${suggested_price:,.0f}(↓{drop_pct:.1f}%)",
|
||||
"",
|
||||
f"🤖 <b>AI 理由</b>",
|
||||
_esc(reason),
|
||||
])
|
||||
keyboard = {
|
||||
"inline_keyboard": [
|
||||
[
|
||||
{"text": "✅ 批准降價", "callback_data": f"{CB_PREFIX}pa:{insight_id}"},
|
||||
{"text": "❌ 拒絕", "callback_data": f"{CB_PREFIX}pr:{insight_id}"},
|
||||
]
|
||||
]
|
||||
"inline_keyboard": [[
|
||||
{"text": "✅ 批准降價", "callback_data": f"{CB_PREFIX}pa:{insight_id}"},
|
||||
{"text": "❌ 拒絕", "callback_data": f"{CB_PREFIX}pr:{insight_id}"},
|
||||
]]
|
||||
}
|
||||
if report_url:
|
||||
keyboard["inline_keyboard"].append([{"text": "🔗 查看報表", "url": report_url}])
|
||||
return _clip(text), keyboard
|
||||
|
||||
|
||||
def decision_result(
|
||||
original_text: str,
|
||||
decision: str, # "approve" or "reject"
|
||||
operator: str,
|
||||
note: str | None = None,
|
||||
) -> str:
|
||||
emoji = "✅" if decision == "approve" else "❌"
|
||||
label = "已批准降價" if decision == "approve" else "已拒絕降價"
|
||||
footer = [
|
||||
"",
|
||||
H_DIV,
|
||||
f"{emoji} <b>{label}</b>",
|
||||
f"👤 <b>操作人</b>:{_esc(operator)}",
|
||||
f"🕐 {_ts()}",
|
||||
]
|
||||
if note:
|
||||
footer.append(f"📝 {_esc(note)}")
|
||||
return _clip(original_text + "\n".join(footer))
|
||||
|
||||
|
||||
# =====================================================================
|
||||
# 🛠️ 決策結果回執(管理員按下按鈕後,訊息會被 edit 成這個)
|
||||
# =====================================================================
|
||||
# =====================================================================
|
||||
# 🛠️ L3 Ops Action Request — 附 pause/force_retry 按鈕的運維決策訊息
|
||||
# 🛠️ L3 Ops Action Request(Phase 4 HITL)
|
||||
# =====================================================================
|
||||
def ops_action_request(
|
||||
task_name: str,
|
||||
@@ -209,21 +318,17 @@ def ops_action_request(
|
||||
context: dict | None = None,
|
||||
time: datetime | None = None,
|
||||
) -> tuple[str, dict]:
|
||||
"""
|
||||
L3 HITL 運維決策訊息。NemoTron 多次重試失敗 / 告警未消 → 送此訊息請管理員裁決。
|
||||
回傳 (text, inline_keyboard) — callback_data 用 momo:ops:<action>:<task> 格式
|
||||
"""
|
||||
out = [
|
||||
f"🛠️ *[{PROJECT_TAG} 運維決策] {_escape_md(title)}*",
|
||||
f"🕐 {_ts(time)} 📦 {_escape_md(task_name)}",
|
||||
DIV,
|
||||
f"💬 {_escape_md(reason)}",
|
||||
parts = [
|
||||
f"🛠️ <b>[{PROJECT_TAG} 運維決策] {_esc(title)}</b>",
|
||||
f"🕐 {_ts(time)} 📦 <code>{_esc(task_name)}</code>",
|
||||
H_DIV,
|
||||
"",
|
||||
f"💬 {_esc(reason)}",
|
||||
]
|
||||
if context:
|
||||
out.append("")
|
||||
for k, v in context.items():
|
||||
out.append(f"• *{_escape_md(k)}*:{_escape_md(v)}")
|
||||
out += ["", "👉 *請選擇動作:*"]
|
||||
parts.append("")
|
||||
parts.append(_details_block(context))
|
||||
parts += ["", "👉 <b>請選擇動作</b>"]
|
||||
|
||||
keyboard = {
|
||||
"inline_keyboard": [
|
||||
@@ -237,53 +342,30 @@ def ops_action_request(
|
||||
],
|
||||
]
|
||||
}
|
||||
return _clip("\n".join(out)), keyboard
|
||||
return _clip("\n".join(parts)), keyboard
|
||||
|
||||
|
||||
def ops_action_result(
|
||||
original_text: str,
|
||||
action: str, # pause1h / pause6h / retry / resume
|
||||
action: str,
|
||||
operator: str,
|
||||
result: dict,
|
||||
) -> str:
|
||||
"""Ops callback 執行完,edit 原訊息顯示結果"""
|
||||
emoji_map = {"pause1h": "⏸️", "pause6h": "⏸️", "retry": "⚡", "resume": "▶️"}
|
||||
label_map = {
|
||||
"pause1h": "已暫停 1 小時", "pause6h": "已暫停 6 小時",
|
||||
"retry": "已立即重試", "resume": "已解除暫停",
|
||||
}
|
||||
label_map = {"pause1h": "已暫停 1 小時", "pause6h": "已暫停 6 小時",
|
||||
"retry": "已立即重試", "resume": "已解除暫停"}
|
||||
emoji = emoji_map.get(action, "🛠️")
|
||||
label = label_map.get(action, action)
|
||||
status = result.get("status", "unknown")
|
||||
footer = [
|
||||
"",
|
||||
DIV,
|
||||
f"{emoji} *{label}*(狀態:{_escape_md(status)})",
|
||||
f"👤 操作人:{_escape_md(operator)}",
|
||||
H_DIV,
|
||||
f"{emoji} <b>{label}</b>(狀態:<code>{_esc(status)}</code>)",
|
||||
f"👤 <b>操作人</b>:{_esc(operator)}",
|
||||
f"🕐 {_ts()}",
|
||||
]
|
||||
if status == "rejected":
|
||||
footer.append(f"⚠️ 拒絕原因:{_escape_md(result.get('reason', ''))}")
|
||||
footer.append(f"⚠️ <b>拒絕原因</b>:{_esc(result.get('reason', ''))}")
|
||||
elif status == "deferred":
|
||||
footer.append(f"ℹ️ {_escape_md(result.get('note', ''))}")
|
||||
return _clip(original_text + "\n".join(footer))
|
||||
|
||||
|
||||
def decision_result(
|
||||
original_text: str,
|
||||
decision: str, # "approve" or "reject"
|
||||
operator: str,
|
||||
note: str | None = None,
|
||||
) -> str:
|
||||
emoji = "✅" if decision == "approve" else "❌"
|
||||
label = "已批准降價" if decision == "approve" else "已拒絕降價"
|
||||
footer = [
|
||||
"",
|
||||
DIV,
|
||||
f"{emoji} *{label}*",
|
||||
f"👤 操作人:{_escape_md(operator)}",
|
||||
f"🕐 {_ts()}",
|
||||
]
|
||||
if note:
|
||||
footer.append(f"📝 {_escape_md(note)}")
|
||||
footer.append(f"ℹ️ {_esc(result.get('note', ''))}")
|
||||
return _clip(original_text + "\n".join(footer))
|
||||
|
||||
Reference in New Issue
Block a user