From 5935a6512c1923bd421e486001a5dd9712acb49c Mon Sep 17 00:00:00 2001 From: OoO Date: Mon, 4 May 2026 18:58:30 +0800 Subject: [PATCH] =?UTF-8?q?feat(p38):=20Telegram=20=E8=A3=9C=204=20?= =?UTF-8?q?=E5=80=8B=20AI=20=E8=A7=80=E6=B8=AC=E5=8F=B0=E5=91=BD=E4=BB=A4?= =?UTF-8?q?=EF=BC=88B-3=20=E5=AE=8C=E6=88=90=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 統帥盤點要求: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) --- routes/openclaw_bot_routes.py | 212 ++++++++++++++++++++++++ services/openclaw_bot/menu_keyboards.py | 12 ++ 2 files changed, 224 insertions(+) diff --git a/routes/openclaw_bot_routes.py b/routes/openclaw_bot_routes.py index f8a549c..f067dd6 100644 --- a/routes/openclaw_bot_routes.py +++ b/routes/openclaw_bot_routes.py @@ -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( diff --git a/services/openclaw_bot/menu_keyboards.py b/services/openclaw_bot/menu_keyboards.py index f0196a4..175ec23 100644 --- a/services/openclaw_bot/menu_keyboards.py +++ b/services/openclaw_bot/menu_keyboards.py @@ -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, }