SPB Git forge

spb/resto-ka

Public

Resto·Ka — tous les restaurants du Québec, menus complets et prix réels (famille ·Ka)

52commits 1branches 0releases
11.6 MBsize
maindefault branch
19 days agolast push
Python 69.3% TypeScript 16.7% CSS 7.9% JavaScript 4.7% HTML 1.4%
6.7 KB · 155 lines python
Raw Blame History
1# ==============================================================================2# Author: Simon-Pierre Boucher <contact@spboucher.ai>3# File:   restoka/schema.py4# Desc:   Modèle de données standardisé Restaurant + Menu imbriqué5#         (catégories → plats → prix → options) et normalisation centrale.6#         Calqué sur louka/schema.py — le schéma est simplement plus profond.7# ==============================================================================8"""Schéma standard d'un restaurant (CLAUDE.md §5) et validation du menu.910Chaque connecteur, peu importe la source, produit des objets `Restaurant`11conformes à ce schéma. `finalize()` applique la couche commune : nettoyage,12région (obligatoire), cuisines, type d'établissement, fourchette de prix,13diètes, validation stricte du menu (`price_context`/`price_source` requis).14"""15from __future__ import annotations1617import hashlib18import json19from dataclasses import dataclass, field, asdict2021from .normalize import (22    PRICE_CONTEXTS,23    classify_cuisines,24    classify_establishment,25    clean_address,26    clean_name,27    infer_dietary,28    price_range_from_menu,29)30from .regions import attach_region3132__all__ = ["Restaurant", "MenuValidationError"]333435class MenuValidationError(ValueError):36    """Menu rejeté (contexte/source de prix manquant, structure invalide)."""373839@dataclass40class Restaurant:41    """Restaurant standardisé Resto·Ka (une fiche par adresse/succursale)."""4243    source: str                          # id de la source (data/sources.json)44    external_id: str                     # identifiant chez la source45    name: str                            # nom normalisé46    url: str = ""                        # page du resto chez la source47    chain: str | None = None             # marque/chaîne si multi-succursales48    cuisines: list[str] = field(default_factory=list)49    establishment_type: str = ""         # restaurant|fast-food|cafe|bar|...50    price_range: str = ""                # $ à $$$$ (estimé du menu)51    address: str = ""52    city: str = ""53    region: str = ""                     # une des 17 régions (regions.py)54    postal_code: str = ""55    lat: float | None = None56    lng: float | None = None57    phone: str = ""58    website: str = ""59    hours: dict = field(default_factory=dict)      # horaires par jour60    services: list[str] = field(default_factory=list)   # dine-in|takeout|delivery61    dietary_options: list[str] = field(default_factory=list)62    languages: list[str] = field(default_factory=list)63    images: list[str] = field(default_factory=list)  # logo + photos (URLs absolues)64    menu: dict | None = None             # voir CLAUDE.md §5.265    status: str = "open"                 # open|temporarily_closed|closed6667    @property68    def uid(self) -> str:69        return f"{self.source}:{self.external_id}"7071    def content_hash(self) -> str:72        """Hash du contenu pour la détection de changements. `captured_at`73        est exclu : un menu identique recapturé ne doit pas compter comme74        une mise à jour."""75        payload = asdict(self)76        if payload.get("menu"):77            payload["menu"] = dict(payload["menu"], captured_at="")78        blob = json.dumps(payload, sort_keys=True, ensure_ascii=False)79        return hashlib.sha256(blob.encode("utf-8")).hexdigest()8081    # -- validation du menu ----------------------------------------------------82    def _validate_menu(self) -> None:83        m = self.menu84        if m is None:85            return86        if m.get("price_context") not in PRICE_CONTEXTS:87            raise MenuValidationError(88                f"{self.uid}: price_context invalide ou manquant "89                f"({m.get('price_context')!r}) — un prix sans contexte est "90                "inutilisable (livraison ≠ salle)")91        if not m.get("price_source"):92            raise MenuValidationError(f"{self.uid}: price_source manquant")93        if not m.get("captured_at"):94            raise MenuValidationError(f"{self.uid}: captured_at manquant")95        m.setdefault("currency", "CAD")96        for sec in m.get("sections") or []:97            if not sec.get("name"):98                raise MenuValidationError(f"{self.uid}: section sans nom")99            for it in sec.get("items") or []:100                if not it.get("name"):101                    raise MenuValidationError(102                        f"{self.uid}: item sans nom dans « {sec['name']} »")103                price = it.get("price")104                if price is not None and (not isinstance(price, (int, float))105                                          or price < 0 or price > 10000):106                    raise MenuValidationError(107                        f"{self.uid}: prix implausible {price!r} pour "108                        f"« {it['name']} »")109                it.setdefault("currency", m["currency"])110111    def _menu_text(self) -> str:112        if not self.menu:113            return ""114        parts = []115        for sec in self.menu.get("sections") or []:116            parts.append(sec.get("name") or "")117            parts.extend(it.get("name") or "" for it in sec.get("items") or [])118        return " ".join(parts)119120    def finalize(self) -> "Restaurant":121        """Applique la normalisation commune. Appelé par le pipeline d'ingestion.122123        Idempotent ; ne remplace jamais une valeur explicite du connecteur.124        Lève MenuValidationError si le menu viole le schéma (le pipeline125        rejette alors la fiche plutôt que de stocker un prix sans contexte).126        """127        self.name = clean_name(self.name)128        self.address = clean_address(self.address)129        self.city = self.city.strip()130        self.postal_code = (self.postal_code or "").strip().upper()131132        self._validate_menu()133134        # coordonnées fournies par la source : rejeter tout point hors de la135        # province (lat/lng inversés, 0/0) — le géocodeur prendra le relais136        if self.lat is not None and self.lng is not None:137            if not (44.5 <= self.lat <= 63.0 and -80.0 <= self.lng <= -56.0):138                self.lat = self.lng = None139140        if not self.region:141            self.region = attach_region(self.city, self.postal_code,142                                        self.lat, self.lng)143        if not self.cuisines:144            self.cuisines = classify_cuisines(self.name, self._menu_text())145        if not self.establishment_type:146            self.establishment_type = classify_establishment(self.name,147                                                             self.cuisines)148        if not self.price_range:149            self.price_range = price_range_from_menu(self.menu)150        if not self.dietary_options:151            self.dietary_options = infer_dietary(self.menu)152        if not self.languages:153            self.languages = ["fr"]154        return self155