330 lines
11 KiB
Python
330 lines
11 KiB
Python
"""Versioned data contracts shared by extensions and the kernel."""
|
|
|
|
from __future__ import annotations
|
|
|
|
from dataclasses import dataclass, field
|
|
from enum import Enum
|
|
from types import MappingProxyType
|
|
from typing import Any, Mapping
|
|
|
|
|
|
API_VERSION = "1"
|
|
MAX_EVENT_TEXT = 5_000
|
|
MAX_REPLY_CHARS = 900
|
|
MAX_FACT_CHARS = 120
|
|
|
|
|
|
class ContractViolation(ValueError):
|
|
"""Raised when data crosses the extension boundary with an invalid shape."""
|
|
|
|
|
|
class PlanKind(str, Enum):
|
|
TEXT = "text"
|
|
MULTI_TEXT = "multi_text"
|
|
NO_REPLY = "no_reply"
|
|
|
|
|
|
def _required_text(value: object, *, name: str, limit: int) -> str:
|
|
text = str(value or "").strip()
|
|
if not text:
|
|
raise ContractViolation(f"{name} is required")
|
|
if len(text) > limit:
|
|
raise ContractViolation(f"{name} exceeds {limit} characters")
|
|
return text
|
|
|
|
|
|
def _confidence(value: object, *, name: str) -> float:
|
|
try:
|
|
number = float(value)
|
|
except (TypeError, ValueError) as error:
|
|
raise ContractViolation(f"{name} must be numeric") from error
|
|
if not 0.0 <= number <= 1.0:
|
|
raise ContractViolation(f"{name} must be between 0 and 1")
|
|
return number
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class Event:
|
|
"""Normalized, single-group incoming event exposed to extensions."""
|
|
|
|
event_id: str
|
|
observed_at: str
|
|
sender: str
|
|
text: str
|
|
group_fingerprint: str
|
|
mention_bot: bool
|
|
confidence: float = 0.0
|
|
sender_confidence: float = 0.0
|
|
body_confidence: float = 0.0
|
|
source: str = "jwebot"
|
|
version: int = 1
|
|
side: str = "incoming"
|
|
|
|
def __post_init__(self) -> None:
|
|
object.__setattr__(
|
|
self,
|
|
"event_id",
|
|
_required_text(self.event_id, name="event_id", limit=256),
|
|
)
|
|
object.__setattr__(
|
|
self,
|
|
"observed_at",
|
|
_required_text(self.observed_at, name="observed_at", limit=64),
|
|
)
|
|
object.__setattr__(
|
|
self,
|
|
"sender",
|
|
_required_text(self.sender, name="sender", limit=120),
|
|
)
|
|
object.__setattr__(
|
|
self,
|
|
"text",
|
|
_required_text(self.text, name="text", limit=MAX_EVENT_TEXT),
|
|
)
|
|
object.__setattr__(
|
|
self,
|
|
"group_fingerprint",
|
|
_required_text(
|
|
self.group_fingerprint,
|
|
name="group_fingerprint",
|
|
limit=256,
|
|
),
|
|
)
|
|
object.__setattr__(
|
|
self,
|
|
"source",
|
|
_required_text(self.source, name="source", limit=80),
|
|
)
|
|
object.__setattr__(
|
|
self,
|
|
"confidence",
|
|
_confidence(self.confidence, name="confidence"),
|
|
)
|
|
object.__setattr__(
|
|
self,
|
|
"sender_confidence",
|
|
_confidence(self.sender_confidence, name="sender_confidence"),
|
|
)
|
|
object.__setattr__(
|
|
self,
|
|
"body_confidence",
|
|
_confidence(self.body_confidence, name="body_confidence"),
|
|
)
|
|
if self.version != 1:
|
|
raise ContractViolation("unsupported event version")
|
|
if self.side != "incoming":
|
|
raise ContractViolation("extensions only receive incoming events")
|
|
|
|
@classmethod
|
|
def from_mapping(cls, value: Mapping[str, Any]) -> "Event":
|
|
if not isinstance(value, Mapping):
|
|
raise ContractViolation("event must be an object")
|
|
return cls(
|
|
event_id=value.get("event_id", ""),
|
|
observed_at=value.get("observed_at", ""),
|
|
sender=value.get("sender", ""),
|
|
text=value.get("text", ""),
|
|
group_fingerprint=value.get("group_fingerprint", ""),
|
|
mention_bot=bool(value.get("mention_bot")),
|
|
confidence=value.get("confidence", 0.0),
|
|
sender_confidence=value.get("sender_confidence", 0.0),
|
|
body_confidence=value.get("body_confidence", 0.0),
|
|
source=value.get("source", "jwebot"),
|
|
version=value.get("version", 1),
|
|
side=value.get("side", "incoming"),
|
|
)
|
|
|
|
def to_mapping(self) -> dict[str, Any]:
|
|
return {
|
|
"version": self.version,
|
|
"event_id": self.event_id,
|
|
"observed_at": self.observed_at,
|
|
"sender": self.sender,
|
|
"text": self.text,
|
|
"confidence": self.confidence,
|
|
"sender_confidence": self.sender_confidence,
|
|
"body_confidence": self.body_confidence,
|
|
"side": self.side,
|
|
"mention_bot": self.mention_bot,
|
|
"group_fingerprint": self.group_fingerprint,
|
|
"source": self.source,
|
|
}
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class ReplyPlan:
|
|
"""A structured request; only the kernel may turn it into a WeChat send."""
|
|
|
|
kind: PlanKind
|
|
message: str = ""
|
|
messages: tuple[str, ...] = ()
|
|
|
|
def __post_init__(self) -> None:
|
|
if self.kind is PlanKind.TEXT:
|
|
object.__setattr__(
|
|
self,
|
|
"message",
|
|
_required_text(
|
|
self.message,
|
|
name="reply message",
|
|
limit=MAX_REPLY_CHARS,
|
|
),
|
|
)
|
|
if self.messages:
|
|
raise ContractViolation("text plan cannot contain multiple messages")
|
|
elif self.kind is PlanKind.MULTI_TEXT:
|
|
if self.message:
|
|
raise ContractViolation("multi_text plan cannot contain text")
|
|
if not self.messages:
|
|
raise ContractViolation("multi_text plan requires messages")
|
|
if len(self.messages) > 8:
|
|
raise ContractViolation("multi_text plan exceeds 8 messages")
|
|
object.__setattr__(
|
|
self,
|
|
"messages",
|
|
tuple(
|
|
_required_text(
|
|
message,
|
|
name="reply message",
|
|
limit=MAX_REPLY_CHARS,
|
|
)
|
|
for message in self.messages
|
|
),
|
|
)
|
|
elif self.message:
|
|
raise ContractViolation("no_reply plan cannot contain text")
|
|
elif self.messages:
|
|
raise ContractViolation("no_reply plan cannot contain messages")
|
|
|
|
@classmethod
|
|
def text(cls, message: str) -> "ReplyPlan":
|
|
return cls(kind=PlanKind.TEXT, message=message)
|
|
|
|
@classmethod
|
|
def multi_text(cls, messages: tuple[str, ...] | list[str]) -> "ReplyPlan":
|
|
return cls(kind=PlanKind.MULTI_TEXT, messages=tuple(messages))
|
|
|
|
@classmethod
|
|
def no_reply(cls) -> "ReplyPlan":
|
|
return cls(kind=PlanKind.NO_REPLY)
|
|
|
|
@classmethod
|
|
def from_mapping(cls, value: Mapping[str, Any]) -> "ReplyPlan":
|
|
if not isinstance(value, Mapping):
|
|
raise ContractViolation("reply plan must be an object")
|
|
try:
|
|
kind = PlanKind(value.get("kind"))
|
|
except ValueError as error:
|
|
raise ContractViolation("reply plan kind is invalid") from error
|
|
if kind is PlanKind.TEXT:
|
|
return cls.text(str(value.get("text", "")))
|
|
if kind is PlanKind.MULTI_TEXT:
|
|
messages = value.get("texts", ())
|
|
if not isinstance(messages, list):
|
|
raise ContractViolation("multi_text plan messages must be a list")
|
|
return cls.multi_text([str(message) for message in messages])
|
|
if value.get("text"):
|
|
raise ContractViolation("no_reply plan cannot contain text")
|
|
if value.get("texts"):
|
|
raise ContractViolation("no_reply plan cannot contain messages")
|
|
return cls.no_reply()
|
|
|
|
def to_mapping(self) -> dict[str, Any]:
|
|
value = {"kind": self.kind.value}
|
|
if self.kind is PlanKind.TEXT:
|
|
value["text"] = self.message
|
|
if self.kind is PlanKind.MULTI_TEXT:
|
|
value["texts"] = list(self.messages)
|
|
return value
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class Fact:
|
|
"""A bounded, non-sensitive fact candidate emitted by an extension."""
|
|
|
|
kind: str
|
|
text: str
|
|
confidence: float = 1.0
|
|
|
|
def __post_init__(self) -> None:
|
|
object.__setattr__(
|
|
self,
|
|
"kind",
|
|
_required_text(self.kind, name="fact kind", limit=40),
|
|
)
|
|
object.__setattr__(
|
|
self,
|
|
"text",
|
|
_required_text(self.text, name="fact text", limit=MAX_FACT_CHARS),
|
|
)
|
|
object.__setattr__(
|
|
self,
|
|
"confidence",
|
|
_confidence(self.confidence, name="fact confidence"),
|
|
)
|
|
|
|
def to_mapping(self) -> dict[str, object]:
|
|
return {
|
|
"kind": self.kind,
|
|
"text": self.text,
|
|
"confidence": self.confidence,
|
|
}
|
|
|
|
@classmethod
|
|
def from_mapping(cls, value: Mapping[str, Any]) -> "Fact":
|
|
if not isinstance(value, Mapping):
|
|
raise ContractViolation("fact must be an object")
|
|
return cls(
|
|
kind=value.get("kind", ""),
|
|
text=value.get("text", ""),
|
|
confidence=value.get("confidence", 1.0),
|
|
)
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class ExtensionResult:
|
|
"""Bounded result returned by one extension invocation."""
|
|
|
|
reply: ReplyPlan = field(default_factory=ReplyPlan.no_reply)
|
|
facts: tuple[Fact, ...] = ()
|
|
metadata: Mapping[str, str] = field(default_factory=dict)
|
|
|
|
def __post_init__(self) -> None:
|
|
if len(self.facts) > 12:
|
|
raise ContractViolation("extension result exceeds 12 facts")
|
|
metadata = {str(key): str(value) for key, value in self.metadata.items()}
|
|
if len(metadata) > 16:
|
|
raise ContractViolation("extension metadata exceeds 16 entries")
|
|
if any(len(key) > 40 or len(value) > 120 for key, value in metadata.items()):
|
|
raise ContractViolation("extension metadata entry is too long")
|
|
object.__setattr__(self, "metadata", MappingProxyType(metadata))
|
|
|
|
def to_mapping(self) -> dict[str, object]:
|
|
return {
|
|
"api_version": API_VERSION,
|
|
"reply": self.reply.to_mapping(),
|
|
"facts": [fact.to_mapping() for fact in self.facts],
|
|
"metadata": dict(self.metadata),
|
|
}
|
|
|
|
@classmethod
|
|
def from_mapping(cls, value: Mapping[str, Any]) -> "ExtensionResult":
|
|
if not isinstance(value, Mapping):
|
|
raise ContractViolation("extension result must be an object")
|
|
if value.get("api_version") != API_VERSION:
|
|
raise ContractViolation("extension result API version is invalid")
|
|
facts = value.get("facts", [])
|
|
metadata = value.get("metadata", {})
|
|
if not isinstance(facts, list):
|
|
raise ContractViolation("extension facts must be a list")
|
|
if not isinstance(metadata, Mapping):
|
|
raise ContractViolation("extension metadata must be an object")
|
|
return cls(
|
|
reply=ReplyPlan.from_mapping(value.get("reply", {})),
|
|
facts=tuple(Fact.from_mapping(fact) for fact in facts),
|
|
metadata={
|
|
str(key): str(item)
|
|
for key, item in metadata.items()
|
|
},
|
|
)
|