Files
agent/src/va_agent/notifications.py
Anibal Angulo ac27d12ed3
All checks were successful
CI / ci (push) Successful in 21s
Add notification model (#31)
Co-authored-by: Anibal Angulo <a8065384@banorte.com>
Reviewed-on: #31
2026-03-10 23:50:41 +00:00

279 lines
8.5 KiB
Python

"""Notification management for VAia agent."""
from __future__ import annotations
import logging
import time
from datetime import datetime
from typing import TYPE_CHECKING, Any, Protocol, runtime_checkable
from pydantic import AliasChoices, BaseModel, Field, field_validator
if TYPE_CHECKING:
from google.cloud.firestore_v1.async_client import AsyncClient
logger = logging.getLogger(__name__)
class Notification(BaseModel):
"""A single notification, normalised from either schema.
Handles snake_case (``id_notificacion``), camelCase
(``idNotificacion``), and English short names (``notificationId``)
transparently via ``AliasChoices``.
"""
id_notificacion: str = Field(
validation_alias=AliasChoices(
"id_notificacion", "idNotificacion", "notificationId"
),
)
texto: str = Field(
default="Sin texto",
validation_alias=AliasChoices("texto", "text"),
)
nombre_evento: str = Field(
default="notificacion",
validation_alias=AliasChoices(
"nombre_evento_dialogflow", "nombreEventoDialogflow", "event"
),
)
timestamp_creacion: float = Field(
default=0.0,
validation_alias=AliasChoices("timestamp_creacion", "timestampCreacion"),
)
status: str = "active"
parametros: dict[str, Any] = Field(
default_factory=dict,
validation_alias=AliasChoices("parametros", "parameters"),
)
@field_validator("timestamp_creacion", mode="before")
@classmethod
def _coerce_timestamp(cls, v: Any) -> float:
"""Normalise Firestore timestamps (float, str, datetime) to float."""
if isinstance(v, (int, float)):
return float(v)
if isinstance(v, datetime):
return v.timestamp()
if isinstance(v, str):
try:
return float(v)
except ValueError:
return 0.0
return 0.0
class NotificationDocument(BaseModel):
"""Top-level Firestore / Redis document that wraps a list of notifications.
Mirrors the schema used by ``utils/check_notifications.py``
(``NotificationSession``) but keeps only what the agent needs.
"""
notificaciones: list[Notification] = Field(default_factory=list)
@runtime_checkable
class NotificationBackend(Protocol):
"""Backend-agnostic interface for notification storage."""
async def get_recent_notifications(self, phone_number: str) -> list[Notification]:
"""Return recent notifications for *phone_number*."""
...
async def mark_as_notified(
self, phone_number: str, notification_ids: list[str]
) -> bool:
"""Mark the given notification IDs as notified. Return success."""
...
class FirestoreNotificationBackend:
"""Firestore-backed notification backend (read-only).
Reads notifications from a Firestore document keyed by phone number.
Filters by a configurable time window instead of tracking read/unread
state — the agent is awareness-only; delivery happens in the app.
"""
def __init__(
self,
*,
db: AsyncClient,
collection_path: str,
max_to_notify: int = 5,
window_hours: float = 48,
) -> None:
"""Initialize with Firestore client and collection path."""
self._db = db
self._collection_path = collection_path
self._max_to_notify = max_to_notify
self._window_hours = window_hours
async def get_recent_notifications(self, phone_number: str) -> list[Notification]:
"""Get recent notifications for a user.
Retrieves notifications created within the configured time window,
ordered by timestamp (most recent first), limited to max_to_notify.
Args:
phone_number: User's phone number (used as document ID)
Returns:
List of validated :class:`Notification` instances.
"""
try:
doc_ref = self._db.collection(self._collection_path).document(phone_number)
doc = await doc_ref.get()
if not doc.exists:
logger.info(
"No notification document found for phone: %s", phone_number
)
return []
data = doc.to_dict() or {}
document = NotificationDocument.model_validate(data)
if not document.notificaciones:
logger.info("No notifications in array for phone: %s", phone_number)
return []
cutoff = time.time() - (self._window_hours * 3600)
parsed = [
n for n in document.notificaciones if n.timestamp_creacion >= cutoff
]
if not parsed:
logger.info(
"No notifications within the last %.0fh for phone: %s",
self._window_hours,
phone_number,
)
return []
parsed.sort(key=lambda n: n.timestamp_creacion, reverse=True)
result = parsed[: self._max_to_notify]
logger.info(
"Found %d recent notifications for phone: %s (returning top %d)",
len(parsed),
phone_number,
len(result),
)
except Exception:
logger.exception(
"Failed to fetch notifications for phone: %s", phone_number
)
return []
else:
return result
async def mark_as_notified(
self,
phone_number: str, # noqa: ARG002
notification_ids: list[str], # noqa: ARG002
) -> bool:
"""No-op — the agent is not the delivery mechanism."""
return True
class RedisNotificationBackend:
"""Redis-backed notification backend (read-only)."""
def __init__(
self,
*,
host: str = "127.0.0.1",
port: int = 6379,
max_to_notify: int = 5,
window_hours: float = 48,
) -> None:
"""Initialize with Redis connection parameters."""
import redis.asyncio as aioredis # noqa: PLC0415
self._client = aioredis.Redis(
host=host,
port=port,
decode_responses=True,
socket_connect_timeout=5,
)
self._max_to_notify = max_to_notify
self._window_hours = window_hours
async def get_recent_notifications(self, phone_number: str) -> list[Notification]:
"""Get recent notifications for a user from Redis.
Reads from the ``notification:{phone}`` key, parses the JSON
payload, and returns notifications created within the configured
time window, sorted by creation timestamp (most recent first),
limited to *max_to_notify*.
"""
import json # noqa: PLC0415
try:
raw = await self._client.get(f"notification:{phone_number}")
if not raw:
logger.info(
"No notification data in Redis for phone: %s",
phone_number,
)
return []
document = NotificationDocument.model_validate(json.loads(raw))
if not document.notificaciones:
logger.info(
"No notifications in array for phone: %s",
phone_number,
)
return []
cutoff = time.time() - (self._window_hours * 3600)
parsed = [
n for n in document.notificaciones if n.timestamp_creacion >= cutoff
]
if not parsed:
logger.info(
"No notifications within the last %.0fh for phone: %s",
self._window_hours,
phone_number,
)
return []
parsed.sort(key=lambda n: n.timestamp_creacion, reverse=True)
result = parsed[: self._max_to_notify]
logger.info(
"Found %d recent notifications for phone: %s (returning top %d)",
len(parsed),
phone_number,
len(result),
)
except Exception:
logger.exception(
"Failed to fetch notifications from Redis for phone: %s",
phone_number,
)
return []
else:
return result
async def mark_as_notified(
self,
phone_number: str, # noqa: ARG002
notification_ids: list[str], # noqa: ARG002
) -> bool:
"""No-op — the agent is not the delivery mechanism."""
return True