Resto·Ka — tous les restaurants du Québec, menus complets et prix réels (famille ·Ka)
Python 69.3%
TypeScript 16.7%
CSS 7.9%
JavaScript 4.7%
HTML 1.4%
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