feat(p38): Telegram 補 4 個 AI 觀測台命令(B-3 完成)
All checks were successful
CD Pipeline / deploy (push) Successful in 2m31s

統帥盤點要求:6 個觀測頁是否都有 Telegram 對應?
盤點結果:promotion_review 已有 (pg_ok/pg_no inline button),剩 4 個缺。

新增 4 個 cmd handler 對應 4 個觀測頁面:
1. cmd:obs_ai_calls — AI 呼叫總覽(24h)
   - 總呼叫 / Token / cost / errors / RAG 命中 / cache 命中
   - Top 5 provider 分組
2. cmd:obs_health — 主機健康監控
   - 三主機 GCP-A / GCP-B / 111 即時 HTTP probe
   - 過去 24h uptime % + 平均 response_ms(讀 host_health_probes)
3. cmd:obs_budget — 預算控管
   - 當月 spent vs budget 各 provider
   - 超 alert_pct 自動標記 ⚠️ / 超 100% 標記 🚨
4. cmd:obs_quality — Caller 反饋趨勢
   - 過去 30 日 avg_score 最低 8 名
   - 含 thumbs_up/down + trend 圖示
   - 含 智能建議(feedback_quality_tracker)

UI/UX:
- main_menu_keyboard 加「🛰 AI 觀測台」入口
- 新 _submenu_observability() 在 menu_keyboards.py
- _SUBMENUS 註冊 'observability' key
- titles 映射加 observability 標題
- 4 個命令 cross-link(彼此互通 + 返回主選單)

Telegram 6/6 對應達成:
- promotion_review: pg_ok/pg_no inline button (既有)
- ai_calls: cmd:obs_ai_calls (新增)
- host_health: cmd:obs_health (新增)
- budget: cmd:obs_budget (新增)
- quality_trend: cmd:obs_quality (新增)
- ppt_audit: 既有「有 issues 才推 Telegram」推送行為(不需查詢命令)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
OoO
2026-05-04 18:58:30 +08:00
parent 0b13055466
commit 5935a6512c
2 changed files with 224 additions and 0 deletions

View File

@@ -8085,6 +8085,217 @@ def handle_cmd(cmd, arg, chat_id, reply_to):
)
send_message(chat_id, photo_help, reply_to, _submenu_market())
# ── Phase 38: AI 觀測台 4 個指令(對應 /observability/* 6 頁的 4 個關鍵指標)───
elif cmd == 'obs_ai_calls':
# 24h AI 呼叫統計:總次數 / token / cost / RAG 命中 / 錯誤
try:
from database.manager import DatabaseManager
from sqlalchemy import text as _sa
session = DatabaseManager().get_session()
row = session.execute(
_sa("""
SELECT COUNT(*),
COALESCE(SUM(input_tokens + output_tokens), 0),
COALESCE(SUM(cost_usd), 0),
COUNT(*) FILTER (WHERE status NOT IN ('ok','cache_only')),
COUNT(*) FILTER (WHERE rag_hit),
COUNT(*) FILTER (WHERE cache_hit)
FROM ai_calls
WHERE called_at >= NOW() - INTERVAL '24 hours'
"""),
).fetchone()
top_provider = session.execute(
_sa("""
SELECT provider, COUNT(*) AS calls, COALESCE(SUM(cost_usd), 0) AS cost
FROM ai_calls
WHERE called_at >= NOW() - INTERVAL '24 hours'
GROUP BY provider ORDER BY calls DESC LIMIT 5
"""),
).fetchall()
session.close()
calls, tokens, cost, errors, rag, cache = row or (0, 0, 0, 0, 0, 0)
err_rate = (errors / calls * 100) if calls else 0
cache_rate = (cache / calls * 100) if calls else 0
rag_rate = (rag / calls * 100) if calls else 0
lines = [
"📊 *AI 呼叫總覽(過去 24 小時)*", "",
f"• 總呼叫:*{calls:,}* 次",
f"• Token 用量:*{tokens:,}*",
f"• 成本:*${cost:.2f} USD*",
f"• 錯誤:*{errors}* 次({err_rate:.1f}%",
f"• RAG 命中:*{rag}* 次({rag_rate:.1f}%",
f"• 快取命中:*{cache}* 次({cache_rate:.1f}%",
"",
"*依供應商分組Top 5*",
]
for p, c, ct in top_provider:
lines.append(f"• `{p}`{c} 次 · ${ct:.2f}")
lines.append("")
lines.append("詳細查詢mo.wooo.work/observability/ai\\_calls")
kb = [_row(('🏥 主機健康', 'cmd:obs_health'), ('💰 預算控管', 'cmd:obs_budget')),
_row(('💬 反饋趨勢', 'cmd:obs_quality'), ('← 返回主選單', 'menu:main'))]
send_message(chat_id, '\n'.join(lines), reply_to, kb, parse_mode='Markdown')
except Exception as e:
send_message(chat_id, f"❌ 查詢 AI 呼叫統計失敗:{e}", reply_to, parse_mode=None)
elif cmd == 'obs_health':
# 三主機 Ollama 即時 + 24h uptime
try:
from services.ollama_service import (
OLLAMA_HOST_PRIMARY, OLLAMA_HOST_SECONDARY, OLLAMA_HOST_FALLBACK,
_is_unhealthy,
)
import requests as _req
from database.manager import DatabaseManager
from sqlalchemy import text as _sa
lines = ["🏥 *主機健康監控*", ""]
lines.append("*三主機即時狀態:*")
for label, host in [
('GCP-A (Primary)', OLLAMA_HOST_PRIMARY),
('GCP-B (Secondary)', OLLAMA_HOST_SECONDARY),
('111 (Fallback)', OLLAMA_HOST_FALLBACK),
]:
healthy = False
try:
r = _req.get(f"{host.rstrip('/')}/api/tags", timeout=3)
healthy = (r.status_code == 200)
except Exception:
pass
emoji = "" if healthy else ""
mark = " ⚠️標記異常" if _is_unhealthy(host) else ""
lines.append(f"{emoji} *{label}*`{host}`{mark}")
# 24h uptime
try:
session = DatabaseManager().get_session()
rows = session.execute(
_sa("""
SELECT host_label,
COUNT(*) AS total,
COUNT(*) FILTER (WHERE healthy) AS up,
COALESCE(AVG(response_ms) FILTER (WHERE healthy), 0) AS avg_ms
FROM host_health_probes
WHERE probed_at >= NOW() - INTERVAL '24 hours'
GROUP BY host_label ORDER BY host_label
"""),
).fetchall()
session.close()
if rows:
lines.append("")
lines.append("*過去 24h 在線率:*")
for label, total, up, avg_ms in rows:
pct = (up / total * 100) if total else 0
lines.append(f"{label}*{pct:.1f}%* · {int(avg_ms)}ms 平均")
except Exception:
pass
lines.append("")
lines.append("詳細查詢mo.wooo.work/observability/host\\_health")
kb = [_row(('📊 AI 呼叫', 'cmd:obs_ai_calls'), ('💰 預算控管', 'cmd:obs_budget')),
_row(('💬 反饋趨勢', 'cmd:obs_quality'), ('← 返回主選單', 'menu:main'))]
send_message(chat_id, '\n'.join(lines), reply_to, kb, parse_mode='Markdown')
except Exception as e:
send_message(chat_id, f"❌ 查詢主機健康失敗:{e}", reply_to, parse_mode=None)
elif cmd == 'obs_budget':
# 當月預算 vs 實際 spent
try:
from database.manager import DatabaseManager
from sqlalchemy import text as _sa
from datetime import datetime as _dt
today = _dt.now()
month_start = _dt(today.year, today.month, 1)
session = DatabaseManager().get_session()
budgets = session.execute(
_sa("""
SELECT period, provider, budget_usd, alert_pct
FROM ai_call_budgets
ORDER BY period, provider NULLS FIRST
"""),
).fetchall()
spent_rows = session.execute(
_sa("""
SELECT provider, COALESCE(SUM(cost_usd), 0)
FROM ai_calls
WHERE called_at >= :ms
GROUP BY provider
"""),
{'ms': month_start},
).fetchall()
session.close()
spent_map = {r[0]: float(r[1] or 0) for r in spent_rows}
lines = [f"💰 *預算控管({today.year}-{today.month:02d}*", ""]
warn = False
for period, provider, budget, alert_pct in budgets:
spent = spent_map.get(provider, 0.0) if provider else sum(spent_map.values())
ratio = (spent / float(budget)) if float(budget) > 0 else 0
pct = ratio * 100
if ratio >= 1.0:
icon = "🚨"
warn = True
elif ratio >= float(alert_pct or 80) / 100:
icon = "⚠️"
warn = True
else:
icon = ""
lines.append(f"{icon} `{period}` `{provider or '(全部)'}`${spent:.2f} / ${float(budget):.2f} ({pct:.0f}%)")
if not budgets:
lines.append("(尚無預算設定,需先跑 migrations/025")
if warn:
lines.append("")
lines.append("⚠️ *已有供應商超出告警閾值*,請至 Web 介面確認。")
lines.append("")
lines.append("詳細查詢mo.wooo.work/observability/budget")
kb = [_row(('📊 AI 呼叫', 'cmd:obs_ai_calls'), ('🏥 主機健康', 'cmd:obs_health')),
_row(('💬 反饋趨勢', 'cmd:obs_quality'), ('← 返回主選單', 'menu:main'))]
send_message(chat_id, '\n'.join(lines), reply_to, kb, parse_mode='Markdown')
except Exception as e:
send_message(chat_id, f"❌ 查詢預算失敗:{e}", reply_to, parse_mode=None)
elif cmd == 'obs_quality':
# 30d caller 反饋趨勢
try:
from services.feedback_quality_tracker import (
compute_caller_quality_trend, get_caller_recommendations,
)
trends = compute_caller_quality_trend(days=30)
recs = get_caller_recommendations(days=30)
sorted_trends = sorted(trends.items(), key=lambda kv: kv[1].get('avg_score', 5))[:8]
lines = ["💬 *Caller 反饋趨勢(過去 30 日)*", ""]
if not sorted_trends:
lines.append("(過去 30 日無反饋資料)")
else:
lines.append("*平均分數最低 8 名:*")
for caller, info in sorted_trends:
avg = info.get('avg_score', 0)
up = info.get('thumbs_up', 0)
dn = info.get('thumbs_down', 0)
n = info.get('total_feedback', 0)
trend = info.get('trend', 'unknown')
icon = {'positive': '📈', 'negative': '📉', 'neutral': ''}.get(trend, '')
lines.append(f"{icon} `{caller}` *{avg:.2f}*/5 · 👍{up} 👎{dn} · N={n}")
if recs:
lines.append("")
lines.append("*智能建議:*")
for r in recs[:5]:
icon = "⚠️" if r.get('action') == 'review' else ""
lines.append(f"{icon} `{r.get('caller')}`{r.get('reason')}")
lines.append("")
lines.append("詳細查詢mo.wooo.work/observability/quality\\_trend")
kb = [_row(('📊 AI 呼叫', 'cmd:obs_ai_calls'), ('🏥 主機健康', 'cmd:obs_health')),
_row(('💰 預算控管', 'cmd:obs_budget'), ('← 返回主選單', 'menu:main'))]
send_message(chat_id, '\n'.join(lines), reply_to, kb, parse_mode='Markdown')
except Exception as e:
send_message(chat_id, f"❌ 查詢反饋趨勢失敗:{e}", reply_to, parse_mode=None)
else:
# 不認識的指令 → 自然語言
txt, kb = openclaw_answer(cmd + (' ' + arg if arg else ''), chat_id=chat_id)
@@ -8213,6 +8424,7 @@ def telegram_webhook():
'competitor': '📊 *競品比價日報* — 選擇分析日期',
'competitor_ppt': '📄 *競品比價簡報* — 選擇時間範圍',
'category': '🗂 *分類業績鑽取* — 點選分類深入分析',
'observability': '🛰 *AI 觀測台* — 系統指標與成本控管',
}
if cq_message_id:
result = edit_message_text(

View File

@@ -84,6 +84,7 @@ def main_menu_keyboard():
('📄 簡報報表', 'menu:reports'),
('🌐 市場情報', 'menu:market'),
('🔍 競品日報', 'menu:competitor'),
('🛰 AI 觀測台', 'menu:observability'),
('❓ 使用說明', 'cmd:help'),
],
row_size=2,
@@ -269,6 +270,16 @@ def _submenu_competitor_ppt():
])
def _submenu_observability():
"""Phase 38 AI 觀測台 — 對應 /observability/* 6 頁。"""
return _menu_with_back([
_row(('📊 AI 呼叫總覽 (24h)', 'cmd:obs_ai_calls'),
('🏥 主機健康狀態', 'cmd:obs_health')),
_row(('💰 預算控管 (當月)', 'cmd:obs_budget'),
('💬 反饋趨勢 (30d)', 'cmd:obs_quality')),
])
_SUBMENUS = {
'main': main_menu_keyboard,
'sales': _submenu_sales,
@@ -281,4 +292,5 @@ _SUBMENUS = {
'competitor': _submenu_competitor,
'competitor_ppt': _submenu_competitor_ppt,
'category': _submenu_category,
'observability': _submenu_observability,
}