# ============================================================================== # Author: Simon-Pierre Boucher # File: restoka/schema.py # Desc: Modèle de données standardisé Restaurant + Menu imbriqué # (catégories → plats → prix → options) et normalisation centrale. # Calqué sur louka/schema.py — le schéma est simplement plus profond. # ============================================================================== """Schéma standard d'un restaurant (CLAUDE.md §5) et validation du menu. Chaque connecteur, peu importe la source, produit des objets `Restaurant` conformes à ce schéma. `finalize()` applique la couche commune : nettoyage, région (obligatoire), cuisines, type d'établissement, fourchette de prix, diètes, validation stricte du menu (`price_context`/`price_source` requis). """ from __future__ import annotations import hashlib import json from dataclasses import dataclass, field, asdict from .normalize import ( PRICE_CONTEXTS, classify_cuisines, classify_establishment, clean_address, clean_name, infer_dietary, price_range_from_menu, ) from .regions import attach_region __all__ = ["Restaurant", "MenuValidationError"] class MenuValidationError(ValueError): """Menu rejeté (contexte/source de prix manquant, structure invalide).""" @dataclass class Restaurant: """Restaurant standardisé Resto·Ka (une fiche par adresse/succursale).""" source: str # id de la source (data/sources.json) external_id: str # identifiant chez la source name: str # nom normalisé url: str = "" # page du resto chez la source chain: str | None = None # marque/chaîne si multi-succursales cuisines: list[str] = field(default_factory=list) establishment_type: str = "" # restaurant|fast-food|cafe|bar|... price_range: str = "" # $ à $$$$ (estimé du menu) address: str = "" city: str = "" region: str = "" # une des 17 régions (regions.py) postal_code: str = "" lat: float | None = None lng: float | None = None phone: str = "" website: str = "" hours: dict = field(default_factory=dict) # horaires par jour services: list[str] = field(default_factory=list) # dine-in|takeout|delivery dietary_options: list[str] = field(default_factory=list) languages: list[str] = field(default_factory=list) images: list[str] = field(default_factory=list) # logo + photos (URLs absolues) menu: dict | None = None # voir CLAUDE.md §5.2 status: str = "open" # open|temporarily_closed|closed @property def uid(self) -> str: return f"{self.source}:{self.external_id}" def content_hash(self) -> str: """Hash du contenu pour la détection de changements. `captured_at` est exclu : un menu identique recapturé ne doit pas compter comme une mise à jour.""" payload = asdict(self) if payload.get("menu"): payload["menu"] = dict(payload["menu"], captured_at="") blob = json.dumps(payload, sort_keys=True, ensure_ascii=False) return hashlib.sha256(blob.encode("utf-8")).hexdigest() # -- validation du menu ---------------------------------------------------- def _validate_menu(self) -> None: m = self.menu if m is None: return if m.get("price_context") not in PRICE_CONTEXTS: raise MenuValidationError( f"{self.uid}: price_context invalide ou manquant " f"({m.get('price_context')!r}) — un prix sans contexte est " "inutilisable (livraison ≠ salle)") if not m.get("price_source"): raise MenuValidationError(f"{self.uid}: price_source manquant") if not m.get("captured_at"): raise MenuValidationError(f"{self.uid}: captured_at manquant") m.setdefault("currency", "CAD") for sec in m.get("sections") or []: if not sec.get("name"): raise MenuValidationError(f"{self.uid}: section sans nom") for it in sec.get("items") or []: if not it.get("name"): raise MenuValidationError( f"{self.uid}: item sans nom dans « {sec['name']} »") price = it.get("price") if price is not None and (not isinstance(price, (int, float)) or price < 0 or price > 10000): raise MenuValidationError( f"{self.uid}: prix implausible {price!r} pour " f"« {it['name']} »") it.setdefault("currency", m["currency"]) def _menu_text(self) -> str: if not self.menu: return "" parts = [] for sec in self.menu.get("sections") or []: parts.append(sec.get("name") or "") parts.extend(it.get("name") or "" for it in sec.get("items") or []) return " ".join(parts) def finalize(self) -> "Restaurant": """Applique la normalisation commune. Appelé par le pipeline d'ingestion. Idempotent ; ne remplace jamais une valeur explicite du connecteur. Lève MenuValidationError si le menu viole le schéma (le pipeline rejette alors la fiche plutôt que de stocker un prix sans contexte). """ self.name = clean_name(self.name) self.address = clean_address(self.address) self.city = self.city.strip() self.postal_code = (self.postal_code or "").strip().upper() self._validate_menu() # coordonnées fournies par la source : rejeter tout point hors de la # province (lat/lng inversés, 0/0) — le géocodeur prendra le relais if self.lat is not None and self.lng is not None: if not (44.5 <= self.lat <= 63.0 and -80.0 <= self.lng <= -56.0): self.lat = self.lng = None if not self.region: self.region = attach_region(self.city, self.postal_code, self.lat, self.lng) if not self.cuisines: self.cuisines = classify_cuisines(self.name, self._menu_text()) if not self.establishment_type: self.establishment_type = classify_establishment(self.name, self.cuisines) if not self.price_range: self.price_range = price_range_from_menu(self.menu) if not self.dietary_options: self.dietary_options = infer_dietary(self.menu) if not self.languages: self.languages = ["fr"] return self