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.8 KB · 176 lines python
Raw Blame History
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