# ============================================================================== # Author: Simon-Pierre Boucher # File: restoka/menullm.py # Desc: Structuration des menus « maison » (HTML texte ou PDF) en schéma # sections → items → prix via Claude Haiku 4.5 (claude-haiku-4-5, # le modèle le plus économique) avec SORTIES STRUCTURÉES # (output_config.format = json_schema : JSON garanti valide) et # VALIDATION STRICTE en aval — un prix mal parsé est pire que pas de # prix (CLAUDE.md §10). PDF envoyé nativement en bloc `document`. # ============================================================================== from __future__ import annotations import base64 import json import os MODEL = "claude-haiku-4-5" MAX_TEXT_CHARS = 14000 # ~4k tokens de texte de menu MAX_PDF_BYTES = 8_000_000 # PDF > 8 Mo : ignoré MAX_OUTPUT_TOKENS = 8000 # Garde-fous qualité (validation aval) — un menu qui ne les passe pas est rejeté MIN_ITEMS_PRICED = 5 # au moins 5 plats avec prix MIN_PRICED_RATIO = 0.4 # au moins 40 % des items ont un prix PRICE_MIN, PRICE_MAX = 0.5, 500.0 MENU_SCHEMA = { "type": "object", "properties": { "is_menu": { "type": "boolean", "description": "true seulement si le contenu est bien un menu de " "restaurant avec des plats", }, "sections": { "type": "array", "items": { "type": "object", "properties": { "name": {"type": "string"}, "items": { "type": "array", "items": { "type": "object", "properties": { "name": {"type": "string"}, "description": {"type": "string"}, "price": {"type": ["number", "null"]}, }, "required": ["name", "price"], "additionalProperties": False, }, }, }, "required": ["name", "items"], "additionalProperties": False, }, }, }, "required": ["is_menu", "sections"], "additionalProperties": False, } _SYSTEM = """Tu extrais le menu d'un restaurant québécois vers un JSON structuré. Règles STRICTES : - N'INVENTE JAMAIS un prix. Si le prix d'un plat n'est pas affiché, mets null. - Les prix sont en dollars canadiens ; convertis « 12,95 » en 12.95. - Si un plat a plusieurs formats (petit/grand), prends le prix le PLUS BAS. - Ignore la navigation, les heures d'ouverture, les avis, les textes marketing. - Regroupe les plats par section du menu (Entrées, Pâtes, Desserts…). - Si le contenu N'EST PAS un menu de restaurant (page d'accueil, blogue, page de contact), mets is_menu=false et sections=[]. - Ne traduis pas les noms de plats ; garde-les tels quels.""" def _client(): import anthropic key = os.environ.get("ANTHROPIC_API_KEY") if not key: raise RuntimeError("ANTHROPIC_API_KEY manquant (voir .env)") return anthropic.Anthropic(api_key=key) def _extract(content_blocks: list) -> dict | None: """Appel Haiku avec sortie structurée ; retourne le dict validé ou None.""" import anthropic try: response = _client().messages.create( model=MODEL, max_tokens=MAX_OUTPUT_TOKENS, system=_SYSTEM, messages=[{"role": "user", "content": content_blocks}], output_config={"format": {"type": "json_schema", "schema": MENU_SCHEMA}}, ) except anthropic.APIStatusError as exc: print(f"[resto-ka] menullm: erreur API {exc.status_code}") return None except anthropic.APIConnectionError: return None if response.stop_reason == "refusal": return None if response.stop_reason == "max_tokens": return None # menu tronqué : mieux vaut rien text = next((b.text for b in response.content if b.type == "text"), "") try: return json.loads(text) except ValueError: return None def menu_from_text(text: str) -> dict | None: """Structure le TEXTE brut d'une page menu. None si pas de menu fiable.""" text = (text or "").strip() if len(text) < 200: return None blocks = [{"type": "text", "text": f"Voici le contenu textuel d'une page web de restaurant." f" Extrais le menu.\n\n\n{text[:MAX_TEXT_CHARS]}\n"}] return validate(_extract(blocks)) def menu_from_pdf(pdf_bytes: bytes) -> dict | None: """Structure un menu PDF (envoyé nativement à Claude). None si rejeté.""" if not pdf_bytes or len(pdf_bytes) > MAX_PDF_BYTES: return None data = base64.standard_b64encode(pdf_bytes).decode() blocks = [ {"type": "document", "source": {"type": "base64", "media_type": "application/pdf", "data": data}}, {"type": "text", "text": "Ce PDF est le menu d'un restaurant. Extrais-le."}, ] return validate(_extract(blocks)) def validate(raw: dict | None) -> dict | None: """Validation aval STRICTE : structure, volume, plausibilité des prix. Retourne {"sections": [...]} prêt pour le schéma Resto·Ka, ou None si le menu ne passe pas la barre de qualité (jamais de menu douteux en base). """ if not raw or not raw.get("is_menu"): return None sections_out = [] total = priced = 0 for sec in raw.get("sections") or []: name = (sec.get("name") or "").strip() items_out = [] for it in sec.get("items") or []: iname = (it.get("name") or "").strip() if not iname or len(iname) > 120: continue price = it.get("price") if isinstance(price, (int, float)): if not (PRICE_MIN <= price <= PRICE_MAX): price = None # prix implausible -> pas de prix else: price = round(float(price), 2) else: price = None total += 1 if price is not None: priced += 1 item = {"name": iname, "price": price, "currency": "CAD", "tags": []} desc = (it.get("description") or "").strip() if desc: item["description"] = desc[:400] items_out.append(item) if name and items_out: sections_out.append({"name": name[:80], "items": items_out}) if priced < MIN_ITEMS_PRICED or total == 0 or priced / total < MIN_PRICED_RATIO: return None return {"sections": sections_out}