Files
hermes-agent/plugins/teams_pipeline/models.py
T

252 lines
8.8 KiB
Python

"""Normalized models for the Teams meeting pipeline plugin."""
from __future__ import annotations
from dataclasses import dataclass, field, fields
from datetime import datetime, timezone
from typing import Any, Callable, ClassVar, Literal
ArtifactType = Literal["transcript", "recording", "call_record"]
def _parse_datetime(value: Any) -> datetime | None:
"""Parse ISO-8601 (``Z`` accepted); naive values are assumed UTC, aware values keep their offset."""
if value is None or isinstance(value, datetime):
return value
text = str(value).strip()
if not text:
return None
if text.endswith("Z"):
text = f"{text[:-1]}+00:00"
parsed = datetime.fromisoformat(text)
return parsed.replace(tzinfo=timezone.utc) if parsed.tzinfo is None else parsed
def _serialize_datetime(value: datetime | None) -> str | None:
if value is None:
return None
return value.astimezone(timezone.utc).isoformat().replace("+00:00", "Z")
def _camel(name: str) -> str:
head, *rest = name.split("_")
return head + "".join(part.title() for part in rest)
def _pick(payload: dict[str, Any], *keys: str) -> Any:
"""Equivalent of ``payload.get(k1) or payload.get(k2) or ...`` (returns the last value when all are falsy)."""
value = None
for key in keys:
value = payload.get(key)
if value:
return value
return value
def _str(value: Any) -> str:
return str(value or "").strip()
def _list(value: Any) -> list[Any]:
return list(value or [])
def _dict(value: Any) -> dict[str, Any]:
return dict(value or {})
def _nested(model: type["_Model"]) -> Callable[[Any], Any]:
return lambda value: model.from_dict(value) if value else None
class _Model:
"""Shared snake/camelCase ``from_dict`` and None-dropping ``to_dict`` for the dataclasses below.
``_ALIASES`` overrides the default ``(snake_name, camelName)`` lookup keys; ``_CONVERT`` post-processes
the picked raw value; ``_REQUIRED`` string fields must be non-blank; ``_DATETIMES`` are parsed in place.
"""
_ALIASES: ClassVar[dict[str, tuple[str, ...]]] = {}
_CONVERT: ClassVar[dict[str, Callable[[Any], Any]]] = {}
_REQUIRED: ClassVar[tuple[str, ...]] = ()
_DATETIMES: ClassVar[tuple[str, ...]] = ()
@classmethod
def from_dict(cls, payload: dict[str, Any]):
kwargs = {}
for spec in fields(cls):
value = _pick(payload, *cls._ALIASES.get(spec.name, (spec.name, _camel(spec.name))))
convert = cls._CONVERT.get(spec.name)
kwargs[spec.name] = convert(value) if convert else value
return cls(**kwargs)
def to_dict(self) -> dict[str, Any]:
result: dict[str, Any] = {}
for spec in fields(self):
value = getattr(self, spec.name)
if isinstance(value, datetime):
value = _serialize_datetime(value)
elif isinstance(value, list):
value = [item.to_dict() if isinstance(item, _Model) else item for item in value] or None
elif isinstance(value, dict):
value = value or None
elif isinstance(value, _Model):
value = value.to_dict()
if value is not None:
result[spec.name] = value
return result
def __post_init__(self) -> None:
for name in self._REQUIRED:
if not getattr(self, name).strip():
raise ValueError(f"{type(self).__name__}.{name} is required.")
for name in self._DATETIMES:
setattr(self, name, _parse_datetime(getattr(self, name)))
@dataclass
class GraphSubscription(_Model):
subscription_id: str
resource: str
change_type: str
notification_url: str
expiration_datetime: datetime
client_state: str | None = None
latest_renewal_at: datetime | None = None
status: str | None = None
_ALIASES = {
"subscription_id": ("subscription_id", "id"),
"expiration_datetime": ("expiration_datetime", "expirationDateTime"),
}
_REQUIRED = ("subscription_id", "resource", "change_type", "notification_url")
_CONVERT = dict.fromkeys(_REQUIRED, _str)
_DATETIMES = ("expiration_datetime", "latest_renewal_at")
def __post_init__(self) -> None:
super().__post_init__()
if self.expiration_datetime is None:
raise ValueError("GraphSubscription.expiration_datetime is required.")
@dataclass
class TeamsMeetingRef(_Model):
meeting_id: str
organizer_user_id: str | None = None
join_web_url: str | None = None
calendar_event_id: str | None = None
thread_id: str | None = None
tenant_id: str | None = None
metadata: dict[str, Any] = field(default_factory=dict)
_ALIASES = {"meeting_id": ("meeting_id", "id")}
_CONVERT = {"meeting_id": _str, "metadata": _dict}
_REQUIRED = ("meeting_id",)
@dataclass
class MeetingArtifact(_Model):
artifact_type: ArtifactType
artifact_id: str
display_name: str | None = None
content_type: str | None = None
source_url: str | None = None
download_url: str | None = None
created_at: datetime | None = None
available_at: datetime | None = None
size_bytes: int | None = None
metadata: dict[str, Any] = field(default_factory=dict)
_ALIASES = {
"artifact_id": ("artifact_id", "id"),
"display_name": ("display_name", "displayName", "name"),
"source_url": ("source_url", "sourceUrl", "webUrl"),
"download_url": ("download_url", "downloadUrl", "@microsoft.graph.downloadUrl"),
"created_at": ("created_at", "createdDateTime"),
"available_at": ("available_at", "availableDateTime", "lastModifiedDateTime"),
"size_bytes": ("size_bytes", "size"),
}
_CONVERT = {"artifact_id": _str, "metadata": _dict}
_REQUIRED = ("artifact_id",)
_DATETIMES = ("created_at", "available_at")
def __post_init__(self) -> None:
if self.artifact_type not in {"transcript", "recording", "call_record"}:
raise ValueError("MeetingArtifact.artifact_type must be transcript, recording, or call_record.")
super().__post_init__()
if self.size_bytes is not None:
self.size_bytes = int(self.size_bytes)
@dataclass
class TeamsMeetingSummaryPayload(_Model):
meeting_ref: TeamsMeetingRef
title: str | None = None
start_time: datetime | None = None
end_time: datetime | None = None
participants: list[str] = field(default_factory=list)
transcript_text: str | None = None
summary: str | None = None
key_decisions: list[str] = field(default_factory=list)
action_items: list[str] = field(default_factory=list)
risks: list[str] = field(default_factory=list)
call_metrics: dict[str, Any] = field(default_factory=dict)
source_artifacts: list[MeetingArtifact] = field(default_factory=list)
confidence: str | None = None
confidence_notes: str | None = None
notion_target: str | None = None
linear_target: str | None = None
teams_target: str | None = None
# Nested payloads are only read from their snake_case keys.
_ALIASES = {"meeting_ref": ("meeting_ref",), "source_artifacts": ("source_artifacts",)}
_CONVERT = {
**dict.fromkeys(("participants", "key_decisions", "action_items", "risks"), _list),
"call_metrics": _dict,
"meeting_ref": TeamsMeetingRef.from_dict,
"source_artifacts": lambda value: [MeetingArtifact.from_dict(item) for item in value or []],
}
_DATETIMES = ("start_time", "end_time")
@classmethod
def from_dict(cls, payload: dict[str, Any]) -> "TeamsMeetingSummaryPayload":
# meeting_ref is mandatory: a missing key raises KeyError rather than building a half-empty payload.
return super().from_dict({**payload, "meeting_ref": payload["meeting_ref"]})
@dataclass
class TeamsMeetingPipelineJob(_Model):
job_id: str
event_id: str
source_event_type: str
dedupe_key: str
status: str
retry_count: int = 0
created_at: datetime | None = None
updated_at: datetime | None = None
meeting_ref: TeamsMeetingRef | None = None
selected_artifact_strategy: str | None = None
summary_payload: TeamsMeetingSummaryPayload | None = None
error_info: dict[str, Any] = field(default_factory=dict)
_REQUIRED = ("job_id", "event_id", "source_event_type", "dedupe_key", "status")
_CONVERT = {
**dict.fromkeys(_REQUIRED, _str),
"retry_count": lambda value: value or 0,
"meeting_ref": _nested(TeamsMeetingRef),
"summary_payload": _nested(TeamsMeetingSummaryPayload),
"error_info": _dict,
}
_DATETIMES = ("created_at", "updated_at")
def __post_init__(self) -> None:
super().__post_init__()
self.retry_count = int(self.retry_count)
__all__ = [
"ArtifactType", "GraphSubscription", "MeetingArtifact",
"TeamsMeetingPipelineJob", "TeamsMeetingRef", "TeamsMeetingSummaryPayload",
]