H022329's picture
Upload folder using huggingface_hub
6b62834 verified
Raw
History Blame Contribute Delete
8.54 kB
"""Base adaptor interface and shared models for messaging platform gateways."""
from __future__ import annotations
import uuid
from abc import ABC, abstractmethod
from dataclasses import dataclass, field
from typing import TYPE_CHECKING, Any
from fastapi import Request, Response
if TYPE_CHECKING:
from agentic_rag.data.models import AgentOutput
# ═══════════════════════════════════════════════════════════════════
# Normalized Message / Response Models
# ═══════════════════════════════════════════════════════════════════
@dataclass
class PlatformMessage:
"""Normalized inbound message from any messaging platform."""
platform: str # "wechat_work" | "dingtalk" | "feishu"
sender_id: str # Platform-specific user ID
sender_name: str = "" # Display name (optional)
chat_id: str = "" # Group chat ID or individual chat ID
chat_type: str = "single" # "single" | "group"
text: str = "" # Extracted text content
msg_type: str = "text" # "text" | "image" | "voice" | "event"
raw_payload: dict[str, Any] = field(default_factory=dict) # Original platform payload
reply_token: str = "" # Token/URL needed to send a reply
@dataclass
class PlatformResponse:
"""Normalized outbound response to be sent back to a platform."""
content: str # Formatted reply text
msg_type: str = "text" # "text" | "markdown" | "news" | "image"
status_code: int = 200
extra: dict[str, Any] = field(default_factory=dict) # Platform-specific extras (at_list, buttons, etc.)
# ═══════════════════════════════════════════════════════════════════
# Base Adaptor (Template Method)
# ═══════════════════════════════════════════════════════════════════
class BasePlatformAdaptor(ABC):
"""Template-method adaptor for a messaging platform.
Subclasses override the platform-specific steps; the pipeline
(verify β†’ parse β†’ route β†’ format) is shared.
"""
platform_name: str = ""
def __init__(self, config: Any) -> None:
self.config = config
# ── Template method ──────────────────────────────────────
async def process(self, request: Request) -> Response:
"""Full pipeline: verify β†’ parse β†’ run agent β†’ format β†’ respond."""
# 1. Verify
if not await self.verify_request(request):
return Response(status_code=403, content="Signature verification failed")
# 2. Parse
msg = await self.parse_message(request)
# Skip non-text messages gracefully
if msg.msg_type not in ("text",):
return Response(status_code=200, content=self._empty_ack())
if not msg.text.strip():
return Response(status_code=200, content=self._empty_ack())
# 3. Quick ACK if async mode
try:
from agentic_rag.config.settings import get_settings
response_mode = get_settings().gateway.response_mode
except Exception:
response_mode = "sync"
# 4. Run agent (may be long)
if response_mode == "async":
# Fire-and-forget: return 200 immediately, push result later
import asyncio
asyncio.create_task(self._process_async(msg))
return Response(status_code=200, content=self._empty_ack())
# 5. Sync: run agent inline and return result
output = await self._run_agent(msg)
presp = await self.format_response(output, msg)
return await self._build_http_response(presp)
# ── Steps subclasses must implement ──────────────────────
@abstractmethod
async def verify_request(self, request: Request) -> bool:
"""Verify the incoming webhook signature/token."""
...
@abstractmethod
async def parse_message(self, request: Request) -> PlatformMessage:
"""Parse the platform-specific payload into a PlatformMessage."""
...
@abstractmethod
async def format_response(
self, output: "AgentOutput", msg: PlatformMessage
) -> PlatformResponse:
"""Convert AgentOutput to a platform-compatible response."""
...
@abstractmethod
async def _build_http_response(self, presp: PlatformResponse) -> Response:
"""Build the HTTP response object for this platform."""
...
@abstractmethod
async def push_message(self, msg: PlatformMessage, text: str) -> None:
"""Send a message to the platform's push API (used in async mode)."""
...
# ── Shared agent invocation ──────────────────────────────
async def _run_agent(self, msg: PlatformMessage) -> "AgentOutput":
"""Route the message to the RAG agent engine and return the result."""
from agentic_rag.services.llm.factory import get_llm
from agentic_rag.orchestration.l1_tools.registry import get_tool_registry
from agentic_rag.agent.router import AgentRouter
from agentic_rag.data.models import AgentInput
# Resolve session
sid = await self._resolve_session(msg)
llm = get_llm()
registry = get_tool_registry()
# Register RAG search tool if not present
from agentic_rag.orchestration.l1_tools.rag_tools import RAGSearchTool
try:
registry.get("rag_search")
except Exception:
registry.register(RAGSearchTool())
router = AgentRouter(llm, registry)
engine = await router.route(query=msg.text)
input_data = AgentInput(
query=msg.text,
parameters={"session_id": sid, "platform": msg.platform},
)
return await engine.run(input_data, turn_id=uuid.uuid4().hex)
async def _resolve_session(self, msg: PlatformMessage) -> str:
"""Map (platform, sender_id, chat_id) β†’ internal session_id."""
from agentic_rag.entrypoints.gateway.session import get_platform_session_map
session_map = get_platform_session_map()
return session_map.get_or_create(
platform=msg.platform,
user_id=msg.sender_id,
chat_id=msg.chat_id,
)
async def _process_async(self, msg: PlatformMessage) -> None:
"""Background: run agent and push result to platform."""
try:
output = await self._run_agent(msg)
presp = await self.format_response(output, msg)
await self.push_message(msg, presp.content)
except Exception:
import sys
print(f" [Gateway/{self.platform_name}] ⚠ Async process failed", flush=True)
sys.stdout.flush()
def _empty_ack(self) -> str:
return ""
# ── Content helpers ──────────────────────────────────────
def _chunk_text(self, text: str, max_len: int | None = None) -> list[str]:
"""Split long text into platform-friendly chunks."""
if max_len is None:
try:
from agentic_rag.config.settings import get_settings
max_len = get_settings().gateway.max_reply_length
except Exception:
max_len = 2000
chunks = []
while len(text) > max_len:
split_at = text.rfind("\n", 0, max_len)
if split_at < max_len // 2:
split_at = text.rfind("。", 0, max_len)
if split_at < max_len // 2:
split_at = text.rfind(". ", 0, max_len)
if split_at < max_len // 2:
split_at = max_len
chunks.append(text[: split_at + 1])
text = text[split_at + 1 :].lstrip()
if text.strip():
chunks.append(text)
return chunks