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/menullm.py4# Desc: Structuration des menus « maison » (HTML texte ou PDF) en schéma5# sections → items → prix via Claude Haiku 4.5 (claude-haiku-4-5,6# le modèle le plus économique) avec SORTIES STRUCTURÉES7# (output_config.format = json_schema : JSON garanti valide) et8# VALIDATION STRICTE en aval — un prix mal parsé est pire que pas de9# prix (CLAUDE.md §10). PDF envoyé nativement en bloc `document`.10# ==============================================================================11from __future__ import annotations1213import base6414import json15import os1617MODEL = "claude-haiku-4-5"18MAX_TEXT_CHARS = 14000 # ~4k tokens de texte de menu19MAX_PDF_BYTES = 8_000_000 # PDF > 8 Mo : ignoré20MAX_OUTPUT_TOKENS = 80002122# Garde-fous qualité (validation aval) — un menu qui ne les passe pas est rejeté23MIN_ITEMS_PRICED = 5 # au moins 5 plats avec prix24MIN_PRICED_RATIO = 0.4 # au moins 40 % des items ont un prix25PRICE_MIN, PRICE_MAX = 0.5, 500.02627MENU_SCHEMA = {28 "type": "object",29 "properties": {30 "is_menu": {31 "type": "boolean",32 "description": "true seulement si le contenu est bien un menu de "33 "restaurant avec des plats",34 },35 "sections": {36 "type": "array",37 "items": {38 "type": "object",39 "properties": {40 "name": {"type": "string"},41 "items": {42 "type": "array",43 "items": {44 "type": "object",45 "properties": {46 "name": {"type": "string"},47 "description": {"type": "string"},48 "price": {"type": ["number", "null"]},49 },50 "required": ["name", "price"],51 "additionalProperties": False,52 },53 },54 },55 "required": ["name", "items"],56 "additionalProperties": False,57 },58 },59 },60 "required": ["is_menu", "sections"],61 "additionalProperties": False,62}6364_SYSTEM = """Tu extrais le menu d'un restaurant québécois vers un JSON structuré.6566Règles STRICTES :67- N'INVENTE JAMAIS un prix. Si le prix d'un plat n'est pas affiché, mets null.68- Les prix sont en dollars canadiens ; convertis « 12,95 » en 12.95.69- Si un plat a plusieurs formats (petit/grand), prends le prix le PLUS BAS.70- Ignore la navigation, les heures d'ouverture, les avis, les textes marketing.71- Regroupe les plats par section du menu (Entrées, Pâtes, Desserts…).72- Si le contenu N'EST PAS un menu de restaurant (page d'accueil, blogue,73 page de contact), mets is_menu=false et sections=[].74- Ne traduis pas les noms de plats ; garde-les tels quels."""757677def _client():78 import anthropic79 key = os.environ.get("ANTHROPIC_API_KEY")80 if not key:81 raise RuntimeError("ANTHROPIC_API_KEY manquant (voir .env)")82 return anthropic.Anthropic(api_key=key)838485def _extract(content_blocks: list) -> dict | None:86 """Appel Haiku avec sortie structurée ; retourne le dict validé ou None."""87 import anthropic88 try:89 response = _client().messages.create(90 model=MODEL,91 max_tokens=MAX_OUTPUT_TOKENS,92 system=_SYSTEM,93 messages=[{"role": "user", "content": content_blocks}],94 output_config={"format": {"type": "json_schema",95 "schema": MENU_SCHEMA}},96 )97 except anthropic.APIStatusError as exc:98 print(f"[resto-ka] menullm: erreur API {exc.status_code}")99 return None100 except anthropic.APIConnectionError:101 return None102 if response.stop_reason == "refusal":103 return None104 if response.stop_reason == "max_tokens":105 return None # menu tronqué : mieux vaut rien106 text = next((b.text for b in response.content if b.type == "text"), "")107 try:108 return json.loads(text)109 except ValueError:110 return None111112113def menu_from_text(text: str) -> dict | None:114 """Structure le TEXTE brut d'une page menu. None si pas de menu fiable."""115 text = (text or "").strip()116 if len(text) < 200:117 return None118 blocks = [{"type": "text",119 "text": f"Voici le contenu textuel d'une page web de restaurant."120 f" Extrais le menu.\n\n<page>\n{text[:MAX_TEXT_CHARS]}\n</page>"}]121 return validate(_extract(blocks))122123124def menu_from_pdf(pdf_bytes: bytes) -> dict | None:125 """Structure un menu PDF (envoyé nativement à Claude). None si rejeté."""126 if not pdf_bytes or len(pdf_bytes) > MAX_PDF_BYTES:127 return None128 data = base64.standard_b64encode(pdf_bytes).decode()129 blocks = [130 {"type": "document",131 "source": {"type": "base64", "media_type": "application/pdf",132 "data": data}},133 {"type": "text", "text": "Ce PDF est le menu d'un restaurant. Extrais-le."},134 ]135 return validate(_extract(blocks))136137138def validate(raw: dict | None) -> dict | None:139 """Validation aval STRICTE : structure, volume, plausibilité des prix.140141 Retourne {"sections": [...]} prêt pour le schéma Resto·Ka, ou None si le142 menu ne passe pas la barre de qualité (jamais de menu douteux en base).143 """144 if not raw or not raw.get("is_menu"):145 return None146 sections_out = []147 total = priced = 0148 for sec in raw.get("sections") or []:149 name = (sec.get("name") or "").strip()150 items_out = []151 for it in sec.get("items") or []:152 iname = (it.get("name") or "").strip()153 if not iname or len(iname) > 120:154 continue155 price = it.get("price")156 if isinstance(price, (int, float)):157 if not (PRICE_MIN <= price <= PRICE_MAX):158 price = None # prix implausible -> pas de prix159 else:160 price = round(float(price), 2)161 else:162 price = None163 total += 1164 if price is not None:165 priced += 1166 item = {"name": iname, "price": price, "currency": "CAD", "tags": []}167 desc = (it.get("description") or "").strip()168 if desc:169 item["description"] = desc[:400]170 items_out.append(item)171 if name and items_out:172 sections_out.append({"name": name[:80], "items": items_out})173 if priced < MIN_ITEMS_PRICED or total == 0 or priced / total < MIN_PRICED_RATIO:174 return None175 return {"sections": sections_out}176