Files
ewoooc/services/telegram_templates.py
OoO c7d6db31f2
Some checks are pending
CD Pipeline / deploy (push) Has started running
feat(p11): RAG 自主學習 + Promotion Gate 4 階段護欄(feature flag OFF)
Operation Ollama-First v5.0 / Phase 11 / RAG 自主學習迴圈

services/rag_service.py (532 行)
- RAGService.query() — bge-m3 embed + cosine 0.85 threshold + top_k=5
- get_embedding_signature() — v5.0 護欄 #3 一致性檢查 (SHA1[:12])
- fire-and-forget rag_query_log INSERT (不阻塞主流程)
- feedback() — Telegram 👍/👎 寫回 feedback_score
- RAG_ENABLED 預設 OFF(戰前行為不變)

services/learning_pipeline.py (750 行)
- Distiller — 純 Hermes 規則引擎,零 LLM 成本
  Quality 規則:MCP >200 字 0.8 / LLM JSON ok 0.9 / TextRank 0.6 / 👍 1.0 / 👎 0.0
- PromotionGate — Owen v5.0 護欄 #1 鐵律
  Stage 1: quality_score >= 0.7
  Stage 2: 無幻覺檢測(規則引擎,零 LLM)
  Stage 3: 與既有 insight 相似度 < 0.95(Stage 3 在 episode embed 後啟用)
  Stage 4: weight >= 0.8 必經 Telegram 👍/👎
- expire_stale_reviews() — 24h 無回應自動降級 weight=0.5
- hash_human_approver — Telegram username SHA1[:8] PII 保護

services/hermes_analyst_service.py — 新增 analyze() RAG-first
- RAG hit → return synthesize(不燒 LLM)
- RAG miss → 既有 LLM 路徑 + enqueue learning_episodes

services/openclaw_strategist_service.py — Q&A 入口接 RAG-first
- 不動週/月/年報(敘事報告 RAG hit 機率低)

services/telegram_templates.py
- rag_feedback_keyboard() — 👍/👎 inline keyboard
- promotion_review_keyboard() — Stage 4 人工驗收按鈕

routes/openclaw_bot_routes.py — 3 組 callback handler
- rag_fb:{id}:{score} → rag_service.feedback()
- pg_ok:{episode_id} → PromotionGate.promote()
- pg_no:{episode_id} → PromotionGate.reject()

70 unit tests 全綠 + 全戰役 196 tests zero regression(4:17 跑完)

剩餘 limitations(Phase 12+ 補):
1. learning_episodes.embedding 寫入路徑(Stage 3 dedup 暫 skip)
2. PromotionGate worker cron 未掛
3. Telegram awaiting_review 推播未接(callback handler 已就位)

灰度開啟條件(建議 1 週後):
- ANTHROPIC_API_KEY 設定 + RAG_ENABLED=true + threshold=0.90 保守
- feedback_score >= 4 比率 > 70% → threshold 降至 0.85

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 23:56:12 +08:00

746 lines
33 KiB
Python
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
services/telegram_templates.py
Telegram 訊息模板系統 v2
═══ 訊息分類 ═══════════════════════════════════════════════
🚨 告警類 — price_alert_msg / threat_alert_msg / system_alert_msg
📊 報告類 — daily_report_msg / weekly_report_msg / monthly_report_msg
💰 決策類 — price_decision / batch_decision_msg
🤖 系統類 — deploy_msg / heal_msg / meta_analysis_msg
💡 洞察類 — triaged_alert / insight_summary_msg
═══════════════════════════════════════════════════════════
規範:
1. 所有模板使用 HTML parse_mode
2. 一律繁體中文Agent 名稱保留英文Hermes/NemoTron/OpenClaw/EA
3. 每則訊息須含:標題行 / 核心數據 / 建議行動(三段式)
4. 圖文訊息使用 send_photo_with_caption附說明文字
"""
import io
import json
import logging
import os
from html import escape
from datetime import datetime
from typing import Any, Dict, List, Optional
sys_log = logging.getLogger("TelegramTpl")
TELEGRAM_BOT_TOKEN_ENV = "TELEGRAM_BOT_TOKEN"
TELEGRAM_CHAT_IDS_ENV = "TELEGRAM_CHAT_IDS"
# ══════════════════════════════════════════════════════════════════════════════
# 基礎工具
# ══════════════════════════════════════════════════════════════════════════════
def _get_bot_token() -> Optional[str]:
from dotenv import load_dotenv
load_dotenv()
return os.getenv(TELEGRAM_BOT_TOKEN_ENV)
def _get_chat_ids() -> list:
token = _get_bot_token()
if not token:
sys_log.warning("[TelegramTpl] %s 未設定,跳過 Telegram 通知", TELEGRAM_BOT_TOKEN_ENV)
return []
raw = os.getenv(TELEGRAM_CHAT_IDS_ENV, "[]")
try:
return json.loads(raw)
except json.JSONDecodeError:
sys_log.warning("[TelegramTpl] %s 格式錯誤,應為 JSON 陣列", TELEGRAM_CHAT_IDS_ENV)
return []
def _send_telegram_raw(text: str, chat_ids: Optional[list] = None,
reply_markup: Optional[Dict[str, Any]] = None,
parse_mode: Optional[str] = "HTML") -> bool:
"""發送純文字訊息"""
import requests
token = _get_bot_token()
if not token:
return False
if chat_ids is None:
chat_ids = _get_chat_ids()
if not chat_ids:
chat_ids = [-1003940688311] # fallback
url = f"https://api.telegram.org/bot{token}/sendMessage"
payload = {"chat_id": chat_ids[0], "text": text}
if parse_mode:
payload["parse_mode"] = parse_mode
if reply_markup:
payload["reply_markup"] = json.dumps(reply_markup, ensure_ascii=False)
try:
r = requests.post(url, json=payload, timeout=10)
if not r.ok:
sys_log.warning("[TelegramTpl] sendMessage HTTP %s: %s", r.status_code, r.text[:200])
return False
return True
except Exception as e:
sys_log.error("[TelegramTpl] send 失敗: %s", e)
return False
def send_telegram_with_result(text: str, chat_ids: Optional[list] = None,
reply_markup: Optional[Dict[str, Any]] = None,
parse_mode: Optional[str] = "HTML") -> Dict[str, Any]:
"""發送 Telegram 並回傳結果明細,供 EventRouter / AIOps 使用。"""
token = _get_bot_token()
if not token:
return {"ok": False, "sent": 0, "failed": 0, "chat_ids": [], "errors": ["token_missing"]}
if chat_ids is None:
chat_ids = _get_chat_ids()
if not chat_ids:
chat_ids = [-1003940688311]
import requests
url = f"https://api.telegram.org/bot{token}/sendMessage"
sent = 0
failed = 0
errors: List[str] = []
for chat_id in chat_ids:
payload = {"chat_id": chat_id, "text": text}
if parse_mode:
payload["parse_mode"] = parse_mode
if reply_markup:
payload["reply_markup"] = json.dumps(reply_markup, ensure_ascii=False)
try:
response = requests.post(url, json=payload, timeout=10)
if response.ok:
sent += 1
else:
failed += 1
errors.append(f"{chat_id}:HTTP {response.status_code}")
sys_log.warning("[TelegramTpl] sendMessage chat=%s HTTP %s: %s",
chat_id, response.status_code, response.text[:200])
except Exception as e:
failed += 1
errors.append(f"{chat_id}:{type(e).__name__}")
sys_log.error("[TelegramTpl] send chat=%s 失敗: %s", chat_id, e)
return {
"ok": sent > 0 and failed == 0,
"sent": sent,
"failed": failed,
"chat_ids": list(chat_ids),
"errors": errors,
}
def send_photo(photo_bytes: bytes, caption: str = "",
chat_ids: Optional[list] = None,
parse_mode: str = "HTML") -> bool:
"""發送圖片(含說明文字),供圖表報告使用"""
import requests
token = _get_bot_token()
if not token or not photo_bytes:
return False
if chat_ids is None:
chat_ids = _get_chat_ids()
if not chat_ids:
chat_ids = [-1003940688311]
url = f"https://api.telegram.org/bot{token}/sendPhoto"
try:
r = requests.post(
url,
data={"chat_id": chat_ids[0], "caption": caption[:1024],
"parse_mode": parse_mode},
files={"photo": ("chart.png", photo_bytes, "image/png")},
timeout=30,
)
if not r.ok:
sys_log.warning("[TelegramTpl] sendPhoto HTTP %s: %s", r.status_code, r.text[:200])
return False
return True
except Exception as e:
sys_log.error("[TelegramTpl] sendPhoto 失敗: %s", e)
return False
def send_report_with_charts(text_msg: str, charts: List[Optional[bytes]],
chat_ids: Optional[list] = None) -> bool:
"""先發文字報告,再逐張發送圖表"""
ok = _send_telegram_raw(text_msg, chat_ids=chat_ids)
for i, chart in enumerate(charts):
if chart:
send_photo(chart, caption=f"圖表 {i+1}/{len(charts)}", chat_ids=chat_ids)
return ok
# ══════════════════════════════════════════════════════════════════════════════
# 🚨 告警類模板
# ══════════════════════════════════════════════════════════════════════════════
def price_alert_msg(threats: List[Dict], analysis_period: str = "近48h") -> str:
"""
競品削價告警Hermes + NemoTron 偵測結果)
┌─────────────────────────────────┐
│ 🚨 競品削價告警 · N 個 SKU │
│ ─────────────────────────────── │
│ ⚠️ SKU名稱 MOMO $X → 競品 $Y │
│ 價差 +X% 業績跌幅 -Y%
└─────────────────────────────────┘
"""
n = len(threats)
high = [t for t in threats if t.get("risk") == "HIGH"]
lines = [
f"🚨 <b>競品削價告警</b> · 偵測到 <b>{n}</b> 個 SKU [{analysis_period}]",
f"🔴 高危:{len(high)} 個  🟡 中危:{n - len(high)}",
"" * 32,
]
for t in threats[:8]:
risk_icon = "🔴" if t.get("risk") == "HIGH" else "🟡"
name = str(t.get("name", t.get("sku", "")))[:22]
gap = float(t.get("gap_pct", 0))
delta = float(t.get("sales_7d_delta_pct", t.get("sales_delta", 0)))
momo_p = t.get("momo_price")
pchome_p = t.get("pchome_price")
price_str = f" MOMO <b>${momo_p:,.0f}</b> vs 競品 <b>${pchome_p:,.0f}</b>" \
if momo_p and pchome_p else ""
lines.append(
f"{risk_icon} <b>{name}</b>\n"
f" 價差 <b>{gap:+.1f}%</b> 業績週跌 <b>{delta:+.1f}%</b>{price_str}"
)
if n > 8:
lines.append(f"<i>…另有 {n-8} 個 SKU查看完整報告</i>")
lines += ["" * 32,
"💡 <b>建議:</b>優先處理紅色高危品項,確認競品是否促銷或長期低價"]
return "\n".join(lines)
def threat_alert_msg(sku: str, name: str, momo_price: float,
comp_price: float, gap_pct: float,
sales_delta: float, action: str, confidence: float) -> str:
"""單品威脅告警NemoTron 派發)"""
risk = "🔴 高危" if gap_pct > 15 else "🟡 中危" if gap_pct > 5 else "🟢 低危"
return (
f"{risk} <b>競品威脅通報</b>\n"
f"━━━━━━━━━━━━━━━━━━━━\n"
f"🏷️ <b>{name}</b> <code>{sku}</code>\n\n"
f"💴 MOMO  <b>NT${momo_price:,.0f}</b>\n"
f"💴 競品PChome<b>NT${comp_price:,.0f}</b>\n"
f"📉 價差  <b>{gap_pct:+.1f}%</b>(我方較貴)\n"
f"📊 業績週變 <b>{sales_delta:+.1f}%</b>\n\n"
f"🤖 <b>AI 建議:</b>{action}\n"
f"📈 信心度:{confidence:.0%}\n"
f"━━━━━━━━━━━━━━━━━━━━"
)
def system_alert_msg(level: str, title: str, detail: str, source: str = "") -> str:
"""系統級告警AIOps / 部署失敗等)"""
icons = {"critical": "🚨", "error": "", "warning": "⚠️", "info": ""}
icon = icons.get(level, "⚠️")
src = f" [{source}]" if source else ""
return (
f"{icon} <b>{title}</b>{src}\n"
f"━━━━━━━━━━━━━━━━━━━━\n"
f"{detail[:600]}\n"
f"━━━━━━━━━━━━━━━━━━━━\n"
f"🕐 {datetime.now().strftime('%m/%d %H:%M')}"
)
# ══════════════════════════════════════════════════════════════════════════════
# 📊 報告類模板(三種週期)
# ══════════════════════════════════════════════════════════════════════════════
def daily_report_header(date_str: str, revenue: float, wow: float,
threat_count: int, opportunity_count: int) -> str:
"""
日報標題卡(附圖前發送的文字說明)
"""
wow_icon = "📈" if wow >= 0 else "📉"
wow_color = "+" if wow >= 0 else ""
return (
f"📊 <b>EwoooC 電商日報</b> · {date_str}\n"
f"══════════════════════════\n"
f"💰 今日業績  <b>NT${revenue/10000:.1f} 萬</b>\n"
f"{wow_icon} 週同比   <b>{wow_color}{wow:.1f}%</b>\n"
f"🔴 競品威脅  <b>{threat_count}</b> 個 SKU\n"
f"🟢 市場機會  <b>{opportunity_count}</b> 個 SKU\n"
f"══════════════════════════\n"
f"🤖 <i>Hermes + NemoTron + OpenClaw 聯合分析</i>"
)
def weekly_report_header(period: str, curr_rev: float, prev_rev: float,
wow: float, top_category: str) -> str:
"""週報標題卡"""
wow_icon = "📈" if wow >= 0 else "📉"
return (
f"📊 <b>EwoooC 電商週報</b> · {period}\n"
f"══════════════════════════\n"
f"💰 本週業績  <b>NT${curr_rev/10000:.1f} 萬</b>\n"
f"📦 前週業績  <b>NT${prev_rev/10000:.1f} 萬</b>\n"
f"{wow_icon} 週成長率  <b>{wow:+.1f}%</b>\n"
f"🏆 最強品類  <b>{top_category}</b>\n"
f"══════════════════════════\n"
f"🤖 <i>OpenClaw × MCP 全景策略分析</i>"
)
def monthly_report_header(month_str: str, revenue: float, mom: float,
yoy: float, top3_categories: List[str]) -> str:
"""月報標題卡"""
mom_icon = "📈" if mom >= 0 else "📉"
yoy_icon = "🚀" if yoy >= 10 else "📈" if yoy >= 0 else "📉"
cats = " / ".join(top3_categories[:3])
return (
f"📅 <b>EwoooC 電商月報</b> · {month_str}\n"
f"══════════════════════════\n"
f"💰 月度業績  <b>NT${revenue/10000:.0f} 萬</b>\n"
f"{mom_icon} 月成長率  <b>{mom:+.1f}%</b> {yoy_icon} 年成長 <b>{yoy:+.1f}%</b>\n"
f"🏆 TOP 3 品類 <b>{cats}</b>\n"
f"══════════════════════════\n"
f"🤖 <i>OpenClaw × Hermes × MCP 月度全景洞察</i>"
)
def report_section(icon: str, title: str, lines: List[str]) -> str:
"""通用報告節段(供日/週/月報各節使用)"""
body = "\n".join(f" {l}" for l in lines)
return f"\n{icon} <b>{title}</b>\n{body}"
# ══════════════════════════════════════════════════════════════════════════════
# 💰 決策類模板
# ══════════════════════════════════════════════════════════════════════════════
def price_decision(product_name: str, product_sku: str,
current_price: float, suggested_price: float,
reason: str, insight_id: Optional[int] = None) -> tuple:
"""降 / 提價決策通知(含 Inline Keyboard"""
diff = current_price - suggested_price
action_text = f"降價 NT${diff:,.0f}" if diff > 0 else \
f"提價 NT${-diff:,.0f}" if diff < 0 else "維持現價"
direction = "📉" if diff > 0 else "📈" if diff < 0 else "➡️"
message = (
f"💰 <b>AI 定價決策建議</b>\n"
f"━━━━━━━━━━━━━━━━━━━━\n"
f"🏷️ <b>{product_name}</b> <code>{product_sku}</code>\n\n"
f"現價:<b>NT${current_price:,.0f}</b>\n"
f"建議:<b>NT${suggested_price:,.0f}</b> {direction} {action_text}\n\n"
f"💡 <b>依據:</b>{reason}\n"
)
if insight_id:
message += f"🔗 洞察 ID<code>{insight_id}</code>\n"
message += f"━━━━━━━━━━━━━━━━━━━━"
# ADR-012: callback_data 採短 prefixmomo:pa/pr:{insight_id})— 64-byte 安全、與 L2 handler 一致
# 降級:若無 insight_id不應發生但保底以 sku 做後綴 — sku 仍可能超長,呼叫者有責任提供 insight_id
_cb_id = str(insight_id) if insight_id else f"sku_{product_sku}"[:40]
keyboard = {"inline_keyboard": [[
{"text": "✅ 確認執行", "callback_data": f"momo:pa:{_cb_id}"},
{"text": "❌ 拒絕", "callback_data": f"momo:pr:{_cb_id}"},
]]}
return message, keyboard
def batch_decision_msg(items: List[Dict], batch_id: str) -> tuple:
"""批次定價決策(多 SKU 一次確認)"""
lines = [f"💰 <b>批次定價決策</b> · 共 {len(items)} 個 SKU\n━━━━━━━━━━━━━━━━━━━━"]
for i, item in enumerate(items[:6], 1):
diff = item.get("current_price", 0) - item.get("suggested_price", 0)
direction = "📉" if diff > 0 else "📈"
lines.append(
f"{i}. {direction} <b>{item.get('name','')[:20]}</b>\n"
f" ${item.get('current_price',0):,.0f} → <b>${item.get('suggested_price',0):,.0f}</b>"
)
if len(items) > 6:
lines.append(f"<i>…另有 {len(items)-6} 個 SKU</i>")
lines.append("━━━━━━━━━━━━━━━━━━━━")
# ADR-012: 短 prefix bpa=batch_price_approve / bpr=batch_price_reject預留 64-byte buffer
_bid = str(batch_id)[:48]
keyboard = {"inline_keyboard": [[
{"text": f"✅ 全部確認({len(items)}項)",
"callback_data": f"momo:bpa:{_bid}"},
{"text": "❌ 取消",
"callback_data": f"momo:bpr:{_bid}"},
]]}
return "\n".join(lines), keyboard
# ══════════════════════════════════════════════════════════════════════════════
# 🧠 Phase 11 RAG 反饋v5.0 護欄 #1強制晉升門檻 — Stage 4 人工驗收)
# ══════════════════════════════════════════════════════════════════════════════
def rag_feedback_keyboard(rag_query_log_id: int) -> dict:
"""產生 RAG 命中後的 👍/👎 反饋鍵盤callback prefix 'rag_fb:')。
callback_data 設計(與 ADR-012 短 prefix 規範一致,<= 64 byte:
rag_fb:{log_id}:5 → 👍feedback_score=5
rag_fb:{log_id}:1 → 👎feedback_score=1
使用範例caller 拿 RAGResult 後):
from services.telegram_templates import rag_feedback_keyboard
send_message(chat_id, rag.synthesize(),
keyboard=rag_feedback_keyboard(rag.log_id))
"""
_id = int(rag_query_log_id) if rag_query_log_id else 0
return {
'inline_keyboard': [[
{'text': '👍 有用', 'callback_data': f'rag_fb:{_id}:5'},
{'text': '👎 沒用', 'callback_data': f'rag_fb:{_id}:1'},
]],
}
def promotion_review_keyboard(episode_id: int) -> dict:
"""蒸餾池高權重晉升人工驗收鍵盤PromotionGate Stage 4
callback_data:
pg_ok:{episode_id} → 通過 → 寫 ai_insights
pg_no:{episode_id} → 駁回 → rejected_human
"""
_id = int(episode_id) if episode_id else 0
return {
'inline_keyboard': [[
{'text': '✅ 通過晉升', 'callback_data': f'pg_ok:{_id}'},
{'text': '🚫 駁回', 'callback_data': f'pg_no:{_id}'},
]],
}
# ══════════════════════════════════════════════════════════════════════════════
# 🤖 系統類模板
# ══════════════════════════════════════════════════════════════════════════════
def deploy_msg(status: str, branch: str, commit: str, duration: str = "") -> str:
"""CI/CD 部署通知"""
icons = {"success": "", "failed": "", "started": "🚀"}
icon = icons.get(status, "")
dur = f" 耗時 {duration}" if duration else ""
return (
f"{icon} <b>部署{status.upper()}</b>{dur}\n"
f"━━━━━━━━━━━━━━━━━━━━\n"
f"🌿 分支:<code>{branch}</code>\n"
f"🔖 Commit<code>{commit[:12]}</code>\n"
f"🕐 {datetime.now().strftime('%m/%d %H:%M')}"
)
def heal_msg(status: str, error_type: str, target_file: str,
commit_sha: str = "", diff_lines: int = 0) -> str:
"""AiderHeal 自動修復通知"""
if status == "started":
icon, title = "🔧", "AiderHeal 啟動"
elif status == "success":
icon, title = "", "AiderHeal 修復完成"
elif status == "reverted":
icon, title = "🔄", "AiderHeal 自動回滾"
else:
icon, title = "", "AiderHeal 失敗"
lines = [f"{icon} <b>{title}</b>", "━━━━━━━━━━━━━━━━━━━━",
f"🐛 錯誤類型:<code>{error_type}</code>",
f"📄 目標檔案:<code>{target_file}</code>"]
if commit_sha:
lines.append(f"🔖 Commit<code>{commit_sha[:12]}</code>")
if diff_lines:
lines.append(f"📝 修改行數:{diff_lines}")
lines += ["━━━━━━━━━━━━━━━━━━━━",
f"🕐 {datetime.now().strftime('%m/%d %H:%M')}"]
return "\n".join(lines)
def meta_analysis_msg(period: str, content: str) -> str:
"""AI 系統自我審視報告"""
return (
f"🤖 <b>AI 系統效能自我審視</b> · {period}\n"
f"══════════════════════════\n"
f"{content[:1200]}\n"
f"══════════════════════════\n"
f"<i>由 OpenClaw 定期分析 · {datetime.now().strftime('%m/%d %H:%M')}</i>"
)
# ══════════════════════════════════════════════════════════════════════════════
# 💡 洞察類模板
# ══════════════════════════════════════════════════════════════════════════════
def triaged_alert(base_event: Dict[str, Any], tier_label: str,
ai_summary: str, ai_cause: Optional[str] = None,
ai_actions: Optional[list] = None,
ai_executed: Optional[list] = None) -> tuple:
"""EA L1/L2 自主執行通知(保留原有介面,升級排版)"""
event_type = base_event.get("event_type", "alert")
title = escape(str(base_event.get("title", "")))
summary = escape(str(base_event.get("summary", "")))
event_id = base_event.get("id", "unknown")
safe_ai_summary = escape(str(ai_summary or ""))
safe_ai_cause = escape(str(ai_cause or "")) if ai_cause else None
safe_actions = [escape(str(a)) for a in (ai_actions or [])]
safe_executed = [escape(str(a)) for a in (ai_executed or [])]
lines = [
f"⚡ <b>{tier_label} · {event_type}</b>",
f"📌 {title}",
"",
]
if summary:
lines += [f"🔍 <b>概要:</b>{summary}", ""]
if safe_ai_summary:
lines += [f"🧠 <b>AI 摘要:</b>{safe_ai_summary[:400]}", ""]
if safe_ai_cause:
lines += [f"💡 <b>可能原因:</b>{safe_ai_cause}", ""]
if safe_actions:
lines += ["<b>📋 建議行動:</b>"] + [f"{a}" for a in safe_actions] + [""]
if safe_executed:
lines += ["<b>✅ 已執行:</b>"] + [f"{a}" for a in safe_executed] + [""]
trace = base_event.get("trace")
if trace:
lines.append(f"<pre>{trace[-400:]}</pre>")
# ADR-012: eig=event_ignoreevent_id 截斷確保 ≤ 60 bytes留 buffer
_eid = str(event_id)[:52]
keyboard = {"inline_keyboard": [
[{"text": "🛑 忽略此事件",
"callback_data": f"momo:eig:{_eid}"}],
]}
return "\n".join(lines), keyboard
def insight_summary_msg(insights: List[Dict], period: str = "近24h") -> str:
"""AI 洞察摘要彙整(供定期推播)"""
n = len(insights)
lines = [
f"💡 <b>AI 洞察摘要</b> · {period}{n}",
"━━━━━━━━━━━━━━━━━━━━",
]
type_icons = {
"price_alert": "🔴", "recommendation": "💰", "weekly_strategy": "📊",
"meta_analysis": "🤖", "market_opportunity": "🟢", "mcp_cache": "🌐",
}
for ins in insights[:6]:
icon = type_icons.get(ins.get("insight_type", ""), "💡")
content_preview = str(ins.get("content", ""))[:80].replace("\n", " ")
conf = float(ins.get("confidence", 0))
lines.append(f"{icon} {content_preview}… <i>({conf:.0%})</i>")
if n > 6:
lines.append(f"<i>…另有 {n-6} 筆洞察</i>")
lines.append("━━━━━━━━━━━━━━━━━━━━")
return "\n".join(lines)
# ══════════════════════════════════════════════════════════════════════════════
# 舊版相容介面(保留供現有程式碼調用)
# ══════════════════════════════════════════════════════════════════════════════
def alert(title: str, content: str, actions: Optional[list] = None) -> str:
msg = system_alert_msg("error", title, content)
if actions:
msg += "\n" + "\n".join(f"{a}" for a in actions)
return msg
def warning(title: str, summary: str, details: Optional[dict] = None) -> str:
detail_str = "\n".join(f" {k}: {v}" for k, v in (details or {}).items())
return system_alert_msg("warning", title, summary + ("\n" + detail_str if detail_str else ""))
def info(title: str, module: str, content: str, time: Optional[Any] = None) -> str:
t_str = f" · {time}" if time else ""
return f" <b>{title}</b> [{module}]{t_str}\n\n{content}"
def success(title: str, module: str, stats: str = "") -> str:
return f"✅ <b>{title}</b> [{module}]\n{stats}"
def report(title: str, report_type: str, period: str, content_md: str) -> str:
icons = {"weekly_strategy": "📊", "daily": "📅", "monthly": "📆", "meta_analysis": "🤖"}
icon = icons.get(report_type, "📋")
return (
f"{icon} <b>{title}</b> ({report_type})\n"
f"📅 期間:{period}\n"
f"══════════════════════════\n"
f"{content_md}"
)
def _send_telegram(msg: str, chat_ids: Optional[list] = None,
reply_markup: Optional[Dict[str, Any]] = None) -> bool:
return _send_telegram_raw(msg, chat_ids=chat_ids, reply_markup=reply_markup)
# ══════════════════════════════════════════════════════════════════════════════
# LLM Token 日報模板Operation Ollama-First v5.0 — Phase 1 收尾)
# 對應 services/token_report_service.py
# ══════════════════════════════════════════════════════════════════════════════
# Telegram 單訊息上限保險絲sendMessage HTML 上限為 4096 字元)
_DAILY_TOKEN_REPORT_MAX_CHARS = 4096
def daily_token_report(report_html: str,
footer_url: Optional[str] = None) -> str:
"""LLM Token 日報訊息包裝HTML parse_mode
本函數只負責「附上 footer + 截斷至 Telegram 上限」;報表本體由
services/token_report_service.generate_daily_report() 產出,已含 HTML escape。
Args:
report_html: 已 escape 的 HTML 報表字串
footer_url: 選填 admin 後台連結,會自動 escape
Returns:
≤ 4096 字元的 HTML 字串,安全送 Telegram
"""
body = report_html or ""
if footer_url:
body = f"{body}\n📎 <a href=\"{escape(footer_url)}\">詳細日誌</a>"
if len(body) <= _DAILY_TOKEN_REPORT_MAX_CHARS:
return body
# 超長 → 截斷並加省略尾(保留 80 字給 trailing notice
truncated = body[: _DAILY_TOKEN_REPORT_MAX_CHARS - 80]
return truncated + "\n\n... <i>(訊息過長已截斷;完整內容存於 ai_insights)</i>"
# ══════════════════════════════════════════════════════════════════════════════
# 決策回執模板L2 / L3 按鈕點擊後編輯原訊息使用)
# ADR-012 Phase 4審計留痕 — 操作者、時間、action、結果
# ══════════════════════════════════════════════════════════════════════════════
# Asia/TaipeiUTC+8— 容器可能跑 UTC這裡手動偏移避免依賴 tzdata
_TAIPEI_UTC_OFFSET_HOURS = 8
def _now_taipei_hhmm() -> str:
"""回傳 Asia/Taipei 的 HH:MM容器 TZ 不可靠,手動加 8h"""
try:
from datetime import timedelta, timezone
tz = timezone(timedelta(hours=_TAIPEI_UTC_OFFSET_HOURS))
return datetime.now(tz).strftime("%H:%M")
except Exception:
return datetime.now().strftime("%H:%M")
def _html_escape(s: Any) -> str:
"""最小 HTML 轉義Telegram HTML parse_mode 允許 <b><i><code><pre><a>
故只轉 < > & 避免使用者輸入破版)。"""
text = "" if s is None else str(s)
return (text.replace("&", "&amp;")
.replace("<", "&lt;")
.replace(">", "&gt;"))
def decision_result(original_text: str, action: str, operator: str,
note: Optional[str] = None, **extras: Any) -> str:
"""L2 單品定價決策回執approve / reject 後編輯原訊息)。
Args:
original_text: 原 Telegram 訊息文字query.message.text
action: "approve" / "reject"
operator: 操作者顯示名稱user.full_name 或 id_<tg_id>
note: 選填補充說明(例:訓練保守策略)
**extras: 前向相容預留(目前忽略)
Returns:
原訊息 + 分隔線 + 稽核區塊HTML parse_mode 安全)
"""
act = (action or "").lower()
if act == "approve":
icon, label = "", "已執行"
elif act == "reject":
icon, label = "", "已拒絕"
else:
icon, label = "", f"已處理({_html_escape(action)}"
ts = _now_taipei_hhmm()
op_esc = _html_escape(operator or "unknown")
base = original_text or ""
lines = [
base,
"",
"━━━━━━━━━━━━━━━━━━━━",
f"{icon} <b>{label}</b> by <b>{op_esc}</b> at {ts}",
]
if note:
lines.append(f"📝 {_html_escape(note)}")
return "\n".join(lines)
def ops_action_result(original_text: str, action: str, operator: str,
result: Optional[Dict[str, Any]] = None,
**extras: Any) -> str:
"""L3 運維決策回執pause1h / pause6h / retry / resume 執行後編輯原訊息)。
Args:
original_text: 原 Telegram 訊息文字
action: "pause1h" / "pause6h" / "retry" / "resume" / 其他
operator: 操作者顯示名稱
result: OPS_ACTIONS 函數回傳 dict常見欄位
status: "ok" / "success" / "error" / "skipped"
error: 錯誤訊息(若 status=error
message / detail: 成功訊息
task_name, duration_min 等
**extras: 前向相容預留
Returns:
原訊息 + 分隔線 + 運維執行結果區塊
"""
action_labels = {
"pause1h": "暫停 1 小時",
"pause6h": "暫停 6 小時",
"retry": "立即重試",
"resume": "恢復執行",
"execute": "執行",
"skip": "略過",
"rollback": "回滾",
}
act_label = action_labels.get((action or "").lower(), _html_escape(action or "未知動作"))
result = result or {}
status = str(result.get("status", "")).lower()
if status in ("ok", "success", "done"):
status_icon, status_text = "", "成功"
elif status in ("error", "failed", "fail"):
status_icon, status_text = "", "失敗"
elif status == "skipped":
status_icon, status_text = "⏭️", "已略過"
else:
status_icon, status_text = "", status or "已提交"
ts = _now_taipei_hhmm()
op_esc = _html_escape(operator or "unknown")
# 擷取結果訊息err > message > detail
detail_msg: Optional[str] = None
for key in ("error", "message", "detail"):
if result.get(key):
detail_msg = str(result[key])[:300]
break
lines = [
original_text or "",
"",
"━━━━━━━━━━━━━━━━━━━━",
f"🛠️ <b>運維動作:</b>{act_label}",
f"👤 操作員:<b>{op_esc}</b> 🕐 {ts}",
f"{status_icon} <b>執行結果:</b>{_html_escape(status_text)}",
]
if detail_msg:
lines.append(f"📋 {_html_escape(detail_msg)}")
# 額外關鍵欄位task_name / duration_min 等)
task_name = result.get("task_name")
if task_name:
lines.append(f"📌 任務:<code>{_html_escape(task_name)}</code>")
duration_min = result.get("duration_min")
if duration_min:
lines.append(f"⏱️ 時長:{_html_escape(duration_min)} 分鐘")
return "\n".join(lines)