260 lines
7.8 KiB
Python
260 lines
7.8 KiB
Python
"""Abstract base class for message channels."""
|
|
|
|
import json
|
|
import time
|
|
from abc import ABC, abstractmethod
|
|
from dataclasses import dataclass
|
|
from datetime import datetime
|
|
from enum import Enum
|
|
from typing import Any, Awaitable, Callable, Optional
|
|
|
|
|
|
@dataclass
|
|
class ChannelHealth:
|
|
ok: bool
|
|
message: str = ""
|
|
|
|
|
|
@dataclass
|
|
class SendResult:
|
|
ok: bool
|
|
question_msg_id: Optional[str] = None
|
|
raw_response: Optional[dict[str, Any]] = None
|
|
error: Optional[str] = None
|
|
|
|
|
|
@dataclass
|
|
class Reply:
|
|
question_msg_id: str
|
|
content: Any
|
|
sender_name: Optional[str] = None
|
|
msg_time: Optional[datetime] = None
|
|
raw_message: Optional[dict[str, Any]] = None
|
|
|
|
|
|
class ExchangeStatus(str, Enum):
|
|
"""Outcome categories for one complete channel exchange."""
|
|
|
|
SUCCESS = "success"
|
|
SEND_FAILED = "send_failed"
|
|
REPLY_TIMEOUT = "reply_timeout"
|
|
POLL_FAILED = "poll_failed"
|
|
|
|
|
|
class ChannelTransportError(Exception):
|
|
"""Expected operational transport failure raised by a channel adapter.
|
|
|
|
Configuration and programming errors use their original exception types
|
|
and deliberately cross the exchange interface for diagnosis.
|
|
"""
|
|
|
|
|
|
def normalize_reply_text(content: Any) -> str:
|
|
"""Convert a channel reply payload into stable plain text.
|
|
|
|
Adapters may return a plain string, a Tutu-style ``msgBody`` object, or a
|
|
provider-specific mapping. The exchange interface exposes only text so
|
|
callers do not need to learn each provider's response shape.
|
|
"""
|
|
if content is None:
|
|
return ""
|
|
if isinstance(content, str):
|
|
return content
|
|
if isinstance(content, dict):
|
|
body = content.get("msgBody")
|
|
if isinstance(body, dict):
|
|
for key in ("content", "text", "message"):
|
|
value = body.get(key)
|
|
if isinstance(value, str) and value:
|
|
return value
|
|
elif isinstance(body, str) and body:
|
|
return body
|
|
|
|
for key in ("content", "text", "message"):
|
|
value = content.get(key)
|
|
if isinstance(value, str) and value:
|
|
return value
|
|
return json.dumps(content, ensure_ascii=False)
|
|
return str(content)
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class ExchangeOutcome:
|
|
"""Result of a complete send-and-reply exchange.
|
|
|
|
``diagnostic`` is intentionally opaque to callers. Adapters may retain
|
|
provider-specific data there without expanding the public interface.
|
|
"""
|
|
|
|
status: ExchangeStatus
|
|
correlation_id: Optional[str] = None
|
|
reply_text: Optional[str] = None
|
|
latency_ms: Optional[int] = None
|
|
reason: Optional[str] = None
|
|
diagnostic: Any = None
|
|
|
|
def __post_init__(self) -> None:
|
|
if self.status is ExchangeStatus.SUCCESS:
|
|
if not self.correlation_id:
|
|
raise ValueError("a successful exchange requires a correlation_id")
|
|
if self.reply_text is None:
|
|
raise ValueError("a successful exchange requires reply_text")
|
|
if self.latency_ms is None or self.latency_ms < 0:
|
|
raise ValueError("a successful exchange requires non-negative latency_ms")
|
|
|
|
@property
|
|
def ok(self) -> bool:
|
|
return self.status is ExchangeStatus.SUCCESS
|
|
|
|
@property
|
|
def expected_failure(self) -> bool:
|
|
return self.status in {
|
|
ExchangeStatus.SEND_FAILED,
|
|
ExchangeStatus.REPLY_TIMEOUT,
|
|
ExchangeStatus.POLL_FAILED,
|
|
}
|
|
|
|
@classmethod
|
|
def succeeded(
|
|
cls,
|
|
*,
|
|
correlation_id: str,
|
|
reply: Any,
|
|
latency_ms: int,
|
|
diagnostic: Any = None,
|
|
) -> "ExchangeOutcome":
|
|
return cls(
|
|
status=ExchangeStatus.SUCCESS,
|
|
correlation_id=correlation_id,
|
|
reply_text=normalize_reply_text(reply),
|
|
latency_ms=latency_ms,
|
|
diagnostic=diagnostic,
|
|
)
|
|
|
|
@classmethod
|
|
def send_failed(cls, reason: str, *, diagnostic: Any = None) -> "ExchangeOutcome":
|
|
return cls(status=ExchangeStatus.SEND_FAILED, reason=reason, diagnostic=diagnostic)
|
|
|
|
@classmethod
|
|
def reply_timeout(
|
|
cls,
|
|
*,
|
|
correlation_id: Optional[str] = None,
|
|
latency_ms: Optional[int] = None,
|
|
diagnostic: Any = None,
|
|
) -> "ExchangeOutcome":
|
|
return cls(
|
|
status=ExchangeStatus.REPLY_TIMEOUT,
|
|
correlation_id=correlation_id,
|
|
latency_ms=latency_ms,
|
|
reason="reply timeout",
|
|
diagnostic=diagnostic,
|
|
)
|
|
|
|
@classmethod
|
|
def poll_failed(
|
|
cls,
|
|
reason: str,
|
|
*,
|
|
correlation_id: Optional[str] = None,
|
|
latency_ms: Optional[int] = None,
|
|
diagnostic: Any = None,
|
|
) -> "ExchangeOutcome":
|
|
return cls(
|
|
status=ExchangeStatus.POLL_FAILED,
|
|
correlation_id=correlation_id,
|
|
latency_ms=latency_ms,
|
|
reason=reason,
|
|
diagnostic=diagnostic,
|
|
)
|
|
|
|
|
|
class EvalChannel(ABC):
|
|
"""Abstract message channel used to interact with an evaluation target.
|
|
|
|
All methods are async so push-based channels (WebSocket, SSE) and
|
|
poll-based channels (REST) share the same interface.
|
|
"""
|
|
|
|
@abstractmethod
|
|
async def health_check(self) -> ChannelHealth:
|
|
"""Verify the channel can reach the target."""
|
|
...
|
|
|
|
async def exchange(
|
|
self,
|
|
content: str,
|
|
*,
|
|
timeout: float = 30.0,
|
|
poll_interval: float = 1.0,
|
|
on_sent: Optional[Callable[[SendResult], Awaitable[None]]] = None,
|
|
**kwargs: Any,
|
|
) -> ExchangeOutcome:
|
|
"""Complete one send-and-reply exchange through this channel.
|
|
|
|
``on_sent`` runs after the adapter confirms a successful send and
|
|
before reply polling begins. A hook failure is deliberately allowed
|
|
to propagate so callers cannot observe a reply without a durable sent
|
|
record.
|
|
"""
|
|
started_at = time.monotonic()
|
|
try:
|
|
send_result = await self._send(content, **kwargs)
|
|
except ChannelTransportError as exc:
|
|
return ExchangeOutcome.send_failed(str(exc))
|
|
if not send_result.ok:
|
|
return ExchangeOutcome.send_failed(send_result.error or "channel send failed")
|
|
|
|
correlation_id = send_result.question_msg_id
|
|
if not correlation_id:
|
|
raise ValueError("a successful send must provide question_msg_id")
|
|
|
|
if on_sent is not None:
|
|
await on_sent(send_result)
|
|
|
|
try:
|
|
reply = await self._poll_reply(
|
|
correlation_id,
|
|
timeout=timeout,
|
|
poll_interval=poll_interval,
|
|
)
|
|
except ChannelTransportError as exc:
|
|
return ExchangeOutcome.poll_failed(
|
|
str(exc),
|
|
correlation_id=correlation_id,
|
|
latency_ms=_elapsed_ms(started_at),
|
|
)
|
|
|
|
latency_ms = _elapsed_ms(started_at)
|
|
if reply is None:
|
|
return ExchangeOutcome.reply_timeout(
|
|
correlation_id=correlation_id,
|
|
latency_ms=latency_ms,
|
|
)
|
|
|
|
return ExchangeOutcome.succeeded(
|
|
correlation_id=correlation_id,
|
|
reply=reply.content,
|
|
latency_ms=latency_ms,
|
|
diagnostic={"send": send_result.raw_response, "reply": reply.raw_message},
|
|
)
|
|
|
|
@abstractmethod
|
|
async def _send(self, content: str, **kwargs: Any) -> SendResult:
|
|
"""Transport primitive used by :meth:`exchange`."""
|
|
...
|
|
|
|
@abstractmethod
|
|
async def _poll_reply(
|
|
self,
|
|
question_msg_id: str,
|
|
timeout: float = 30.0,
|
|
poll_interval: float = 1.0,
|
|
) -> Optional[Reply]:
|
|
"""Transport primitive used by :meth:`exchange`."""
|
|
...
|
|
|
|
|
|
def _elapsed_ms(started_at: float) -> int:
|
|
return max(0, int((time.monotonic() - started_at) * 1000))
|