"""Hermes 自然語言閘道 — ADR-094 Layer 1 意圖路由(關鍵字正則)→ Ollama 本地模型(111)→ Telegram 格式化輸出。 2026-04-24 Claude Sonnet 4.6 (WS4 Hermes NL) 2026-04-24 Claude Sonnet 4.6 (WS4 Hermes NL T1+T2+T3): hermes_dispatch_log DB 寫入 / Redis per-chat_id 速率限制 / Multi-turn session (Redis Hash TTL=300s) 2026-04-25 Claude Sonnet 4.6: 改用 Ollama 本地模型(111),按 agent 類型選模型,零費用 debugger/vuln → deepseek-r1:14b(推理); code agents → qwen2.5-coder:7b; 其他 → qwen2.5:7b-instruct """ from __future__ import annotations import asyncio import re import time import httpx import structlog from sqlalchemy import text from src.core.redis_client import get_redis from src.db.base import get_db_context from src.hermes.agent_loader import get_agent_system_prompt from src.hermes.display_names import DEFAULT_AGENT, format_response_header from src.hermes.safety_hooks import is_dangerous_input, is_mutate_intent from src.services.ollama_endpoint_resolver import resolve_ollama_order logger = structlog.get_logger(__name__) # ───────────────────────────────────────────────────────────────────────────── # Layer 1 意圖路由(關鍵字正則,<10ms) # ───────────────────────────────────────────────────────────────────────────── _ROUTING_RULES: list[tuple[re.Pattern, str]] = [ (re.compile(r"(資料庫|postgres|sql|index|query|migration|schema)", re.IGNORECASE), "db-expert"), (re.compile(r"(漏洞|CVE|injection|XSS|CSRF|security|安全)", re.IGNORECASE), "vuln-verifier"), (re.compile(r"(bug|crash|error|exception|fail|失敗|崩潰|為什麼壞|不通)", re.IGNORECASE), "debugger"), (re.compile(r"(重構|refactor|clean|重寫)", re.IGNORECASE), "refactor-specialist"), (re.compile(r"(升級|upgrade|migration|migrate|版本)", re.IGNORECASE), "migration-engineer"), (re.compile(r"(設計|UI|frontend|頁面|按鈕|樣式)", re.IGNORECASE), "frontend-designer"), (re.compile(r"(工具|tool|hook|MCP|plugin)", re.IGNORECASE), "tool-expert"), (re.compile(r"(文件|document|官方|API spec|how to)", re.IGNORECASE), "web-researcher"), (re.compile(r"(導覽|介紹|架構|codebase|overview)", re.IGNORECASE), "onboarder"), (re.compile(r"(拆解|任務|plan|規劃)", re.IGNORECASE), "planner"), (re.compile(r"(審查|review|code review|找問題)", re.IGNORECASE), "critic"), (re.compile(r"(實作|implement|develop|功能|feature)", re.IGNORECASE), "fullstack-engineer"), ] # ───────────────────────────────────────────────────────────────────────────── # T2:速率限制常數(ADR-094) # ───────────────────────────────────────────────────────────────────────────── _RATE_LIMIT_MAX = 20 _RATE_LIMIT_WINDOW_SEC = 60 # ───────────────────────────────────────────────────────────────────────────── # Ollama 模型路由(按 agent 專業選最適模型,111 主機) # ───────────────────────────────────────────────────────────────────────────── _MODEL_BY_AGENT: dict[str, str] = { # 推理型(找根因 / 安全分析)→ deepseek-r1:14b(CoT 推理) "debugger": "deepseek-r1:14b", "vuln-verifier": "deepseek-r1:14b", # 程式碼 + 通用(review / 實作 / 重構 / DB / 前端 / 工具 / 規劃 / 文件)→ qwen3:8b # 2026-04-25 ogt + Claude Sonnet 4.6: qwen2.5-coder:7b + qwen2.5:7b-instruct → qwen3:8b # qwen3:8b Hybrid Thinking 同時勝任程式碼與指令;gemma4 尚未在 Ollama 釋出 "critic": "qwen3:8b", "db-expert": "qwen3:8b", "fullstack-engineer": "qwen3:8b", "refactor-specialist":"qwen3:8b", "migration-engineer": "qwen3:8b", "frontend-designer": "qwen3:8b", "tool-expert": "qwen3:8b", "planner": "qwen3:8b", "onboarder": "qwen3:8b", "web-researcher": "qwen3:8b", } _DEFAULT_MODEL = "deepseek-r1:14b" _OLLAMA_TIMEOUT = 90.0 # deepseek-r1:14b 推理較慢,給 90s def _pick_model(agent_name: str) -> str: return _MODEL_BY_AGENT.get(agent_name, _DEFAULT_MODEL) def _strip_think_tags(text: str) -> str: """移除 deepseek-r1 的 ... 內部推理塊,只留最終回答。""" return re.sub(r".*?", "", text, flags=re.DOTALL).strip() def _route_intent_layer1(msg: str) -> str: """Layer 1: 關鍵字正則路由,回傳 agent 名稱""" for pattern, agent in _ROUTING_RULES: if pattern.search(msg): return agent return DEFAULT_AGENT # ───────────────────────────────────────────────────────────────────────────── # T1:hermes_dispatch_log DB 寫入(ADR-094,非阻擋) # ───────────────────────────────────────────────────────────────────────────── async def _write_dispatch_log( *, chat_id: str, user_id: int, username: str, agent_name: str, input_preview: str, latency_ms: int, success: bool, error_type: str | None, ) -> None: """寫入派發審計日誌;失敗只 warning,不影響主流程。""" try: async with get_db_context() as db: await db.execute( text(""" INSERT INTO hermes_dispatch_log (chat_id, user_id, username, agent_name, input_preview, latency_ms, success, error_type) VALUES (:chat_id, :user_id, :username, :agent_name, :input_preview, :latency_ms, :success, :error_type) """), { "chat_id": chat_id, "user_id": user_id, "username": username, "agent_name": agent_name, "input_preview": input_preview, "latency_ms": latency_ms, "success": success, "error_type": error_type, }, ) await db.commit() except Exception as exc: logger.warning("hermes_dispatch_log_write_failed", error=str(exc)) # ───────────────────────────────────────────────────────────────────────────── # T2:per-chat_id 速率限制(ADR-094,fail-open) # ───────────────────────────────────────────────────────────────────────────── async def _check_rate_limit(chat_id: str, project_id: str = "awoooi") -> bool: """True = 允許;False = 超過限制(20 req/min per chat_id)。Redis 不可用時放行。""" try: redis = get_redis() key = f"{project_id}:hermes:rl:{chat_id}" count = await redis.incr(key) if count == 1: await redis.expire(key, _RATE_LIMIT_WINDOW_SEC) return count <= _RATE_LIMIT_MAX except Exception: return True # Redis 不可用 → fail open # ───────────────────────────────────────────────────────────────────────────── # T3:Multi-turn session(Redis Hash TTL=300s,ADR-094) # ───────────────────────────────────────────────────────────────────────────── async def _load_session_context(chat_id: str, user_id: int, project_id: str = "awoooi") -> str: """載入最近 3 輪對話歷史(最多 600 字),組成 context prefix。Redis 不可用時回空字串。""" try: redis = get_redis() key = f"{project_id}:hermes:session:{chat_id}:{user_id}" data = await redis.hgetall(key) if not data: # Phase A: fallback 到舊 key(滾動部署相容) data = await redis.hgetall(f"hermes:session:{chat_id}:{user_id}") if not data: return "" turns = sorted( [(k, v) for k, v in data.items() if (k if isinstance(k, str) else k.decode()).startswith("turn_")], key=lambda x: x[0], )[-3:] parts = [v.decode() if isinstance(v, bytes) else v for _, v in turns] return "【近期對話記錄】\n" + "\n".join(parts) + "\n\n" except Exception: return "" async def _save_session_turn( chat_id: str, user_id: int, user_msg: str, assistant_reply: str, project_id: str = "awoooi" ) -> None: """將本輪對話存入 Redis Hash,並重置 TTL=300s。Redis 不可用時靜默忽略。""" try: redis = get_redis() key = f"{project_id}:hermes:session:{chat_id}:{user_id}" legacy_key = f"hermes:session:{chat_id}:{user_id}" # Phase A dual-write turn_key = f"turn_{int(time.time())}" value = f"用戶:{user_msg[:100]}\nHermes:{assistant_reply[:200]}" await redis.hset(key, turn_key, value) await redis.expire(key, 300) await redis.hset(legacy_key, turn_key, value) await redis.expire(legacy_key, 300) except Exception: pass # ───────────────────────────────────────────────────────────────────────────── # 主入口 # ───────────────────────────────────────────────────────────────────────────── async def process_nl_message( user_message: str, *, chat_id: str, user_id: int, username: str = "", project_id: str = "awoooi", ) -> str: """ 處理 NL 訊息,回傳 Telegram 格式的回覆文字。 流程: 1. 安全守門(DENY + MUTATE) 2. T2 速率限制(20 req/min per chat_id) 3. Layer 1 關鍵字路由 → agent_name 4. 讀取 agent system prompt(.claude/agents/*.md) 5. T3 載入 session context(最近 3 輪) 6. 呼叫 Claude Agent SDK query() 7. T3 儲存本輪對話 8. 格式化為 Telegram MarkdownV2 訊息 9. T1 非阻擋寫入 hermes_dispatch_log """ # 安全守門 if is_dangerous_input(user_message): logger.warning( "hermes_nl_dangerous_input", user_id=user_id, chat_id=chat_id, preview=user_message[:80], ) return "⛔ 偵測到危險指令,拒絕處理。" if is_mutate_intent(user_message): return ( "⚠️ 此操作涉及變更,需透過正式審批流程執行。\n" "請在 Telegram 告警卡片上操作,或聯繫值班 SRE。" ) # T2:速率限制 if not await _check_rate_limit(chat_id, project_id): return "⚠️ 請求太頻繁,請稍後再試(每分鐘上限 20 次)。" # Layer 1 意圖路由 agent_name = _route_intent_layer1(user_message) # 確認 agent 存在,否則 fallback system_prompt = get_agent_system_prompt(agent_name) if system_prompt is None: logger.warning( "hermes_nl_agent_not_found", agent=agent_name, fallback=DEFAULT_AGENT, ) agent_name = DEFAULT_AGENT system_prompt = get_agent_system_prompt(agent_name) or "" # T3:載入 session context(最近 3 輪) session_ctx = await _load_session_context(chat_id, user_id, project_id) prompt_with_ctx = f"{session_ctx}{user_message}" if session_ctx else user_message t0 = time.monotonic() # 呼叫 Ollama 模型(GCP-A → GCP-B → 111,零費用,按 agent 選模型) model = _pick_model(agent_name) success = False error_type: str | None = None result_text = "" async with httpx.AsyncClient(timeout=_OLLAMA_TIMEOUT) as _hc: for endpoint in resolve_ollama_order("hermes"): if not endpoint.url: continue try: resp = await _hc.post( f"{endpoint.url}/api/chat", json={ "model": model, # Keep Hermes responses in message.content across Ollama 0.24+. "think": False, "messages": [ {"role": "system", "content": system_prompt}, {"role": "user", "content": prompt_with_ctx}, ], "stream": False, "options": {"num_predict": 1500, "temperature": 0.3}, }, ) resp.raise_for_status() result_text = resp.json().get("message", {}).get("content", "") result_text = _strip_think_tags(result_text) if not result_text: result_text = "_Agent 回應為空,請稍後再試。_" success = True break except Exception as exc: error_type = type(exc).__name__ logger.error( "hermes_nl_ollama_error", error=str(exc), agent=agent_name, model=model, provider=endpoint.provider_name, exc_type=error_type, ) if not success: result_text = f"_Hermes 暫時無法連線({error_type}),請稍後再試。_" latency_ms = int((time.monotonic() - t0) * 1000) logger.info( "hermes_nl_dispatch", agent=agent_name, model=model, user_id=user_id, chat_id=chat_id, username=username, latency_ms=latency_ms, success=success, ) # T3:儲存本輪對話(只在成功時存) if success: await _save_session_turn(chat_id, user_id, user_message, result_text, project_id) # T1:非阻擋寫入 hermes_dispatch_log(失敗不影響回覆) asyncio.create_task( _write_dispatch_log( chat_id=chat_id, user_id=user_id, username=username, agent_name=agent_name, input_preview=user_message[:200], latency_ms=latency_ms, success=success, error_type=error_type, ) ) header = format_response_header(agent_name) # Telegram 訊息上限 4096 字元,超過截斷 body = result_text[:3800] return f"{header}{body}"