""" Normalisation des noms de produits via LLM. Ce module orchestre les appels au LLM pour transformer les noms bruts (OCR Leclerc, HTML Picnic) en noms normalisés au format : "Nom du produit | Marque | Quantité" Exemples : "NOIX CAJOU" → "Noix de cajou | MDD | -" "COCA COLA CHERRY 1.25L" → "Coca-Cola Cherry | Coca-Cola | 1,25L" "PQ LOTUS CONFORT X6" → "Papier toilette confort | Lotus | x6" """ import logging import re from pathlib import Path from typing import Union from tickettracker import config from tickettracker.llm.client import LLMError, LLMUnavailable, call_llm logger = logging.getLogger(__name__) # --------------------------------------------------------------------------- # System prompt calibré pour Mistral # --------------------------------------------------------------------------- _SYSTEM_PROMPT = """\ Tu es un assistant de normalisation de noms de produits alimentaires et ménagers. Pour chaque nom de produit, réponds au format strict : Nom du produit | Marque | Quantité Règles : - Nom : en français, lisible, sans abréviation, avec accents et majuscules correctes - Marque : nom exact de la marque, ou "MDD" si marque de distributeur ou inconnue - Quantité : format court (50cl, 1L, 200g, x6, 1kg) ou "-" si absente du nom brut Réponds UNIQUEMENT avec les lignes numérotées demandées. Aucun commentaire, aucune explication.\ """ # --------------------------------------------------------------------------- # Parsing de la réponse LLM # --------------------------------------------------------------------------- # Accepte : "1. Nom | Marque | Qté" ou "1) Nom | Marque | Qté" _LINE_RE = re.compile( r"^\d+[.)]\s*" # numéro suivi de . ou ) r"(?P.+?)" # nom du produit r"\s*\|\s*" r"(?P.+?)" # marque r"\s*\|\s*" r"(?P.+?)" # quantité r"\s*$", re.UNICODE, ) def _parse_normalized_line(line: str) -> str | None: """Extrait le nom normalisé d'une ligne numérotée. Args: line: Ligne de réponse LLM, ex: "1. Crème fraîche | MDD | 50cl" Returns: "Crème fraîche | MDD | 50cl" si la ligne est valide, None sinon. """ m = _LINE_RE.match(line.strip()) if not m: return None nom = m.group("nom").strip() marque = m.group("marque").strip() qte = m.group("qte").strip() # Valide que les trois champs ne sont pas vides if not nom or not marque or not qte: return None return f"{nom} | {marque} | {qte}" def _parse_batch_response(response_text: str, expected_count: int) -> list[str | None]: """Transforme la réponse brute du LLM en liste de noms normalisés. Si le nombre de lignes valides ne correspond pas à expected_count, retourne une liste de None pour déclencher le fallback. Args: response_text: Texte brut retourné par le LLM. expected_count: Nombre d'items attendus. Returns: Liste de longueur expected_count — chaque élément est le nom normalisé ou None si la ligne correspondante est invalide. """ # Garde uniquement les lignes non vides lines = [l for l in response_text.splitlines() if l.strip()] # Extrait les lignes numérotées (ignore le bruit éventuel) parsed = [_parse_normalized_line(l) for l in lines if _LINE_RE.match(l.strip())] if len(parsed) != expected_count: logger.warning( "Batch LLM : attendu %d lignes, reçu %d — fallback unitaire.", expected_count, len(parsed), ) return [None] * expected_count return parsed # --------------------------------------------------------------------------- # Fonctions publiques # --------------------------------------------------------------------------- def normalize_product_name(raw_name: str) -> str | None: """Normalise un seul nom de produit via le LLM. Args: raw_name: Nom brut issu du parser (OCR ou HTML). Returns: Nom normalisé "Nom | Marque | Quantité", ou None si le LLM échoue ou retourne une réponse non parsable. """ try: response = call_llm([ {"role": "system", "content": _SYSTEM_PROMPT}, { "role": "user", "content": f"Normalise ce nom de produit :\n1. {raw_name}", }, ]) except (LLMError, LLMUnavailable) as e: logger.warning("Normalisation unitaire échouée pour %r : %s", raw_name, e) return None lines = [l for l in response.splitlines() if l.strip()] for line in lines: result = _parse_normalized_line(line) if result: return result logger.warning( "Réponse LLM non parsable pour %r : %r", raw_name, response[:100] ) return None def normalize_batch(raw_names: list[str]) -> list[str | None]: """Normalise une liste de noms en un seul appel LLM. Envoie tous les noms dans un seul prompt numéroté. Si la réponse ne contient pas exactement len(raw_names) lignes, retourne une liste de None (le fallback unitaire sera utilisé). Args: raw_names: Liste de noms bruts. Returns: Liste de même longueur : nom normalisé ou None. Raises: LLMUnavailable: Si le serveur est injoignable (propagé pour que normalize_all_in_db puisse distinguer erreur réseau vs parsing). """ if not raw_names: return [] numbered = "\n".join(f"{i + 1}. {name}" for i, name in enumerate(raw_names)) user_content = ( f"Normalise ces {len(raw_names)} noms de produits :\n" f"{numbered}\n\n" f"Retourne exactement {len(raw_names)} lignes numérotées." ) try: response = call_llm([ {"role": "system", "content": _SYSTEM_PROMPT}, {"role": "user", "content": user_content}, ]) except LLMUnavailable: raise # propagé : erreur réseau, pas la peine de retenter except LLMError as e: logger.warning("Batch LLM échoué : %s — fallback unitaire.", e) return [None] * len(raw_names) return _parse_batch_response(response, len(raw_names)) def normalize_all_in_db( db_path: Union[str, Path], batch_size: int = config.LLM_BATCH_SIZE, dry_run: bool = False, ) -> tuple[int, int]: """Normalise tous les articles dont name_normalized est NULL. Algorithme : 1. Récupère les items NULL depuis la DB 2. Traite par batch de batch_size 3. Pour chaque batch : appel LLM groupé - Si le batch réussit → UPDATE en base (sauf dry_run) - Si le batch échoue (mauvais count) → fallback un par un 4. Affiche la progression Args: db_path: Chemin vers la base SQLite. batch_size: Nombre d'articles par appel LLM. dry_run: Si True, calcule mais n'écrit pas en base. Returns: (nb_normalisés, nb_erreurs) — erreurs = items restés NULL. """ from tickettracker.db import schema, repository schema.init_db(db_path) conn = schema.get_connection(db_path) try: items = repository.fetch_unnormalized(conn) total = len(items) if total == 0: print("Aucun article à normaliser.") return (0, 0) mode = "[DRY-RUN] " if dry_run else "" print(f"{mode}{total} article(s) à normaliser (batchs de {batch_size})...") nb_ok = 0 nb_err = 0 done = 0 for start in range(0, total, batch_size): batch = items[start: start + batch_size] raw_names = [row["name_raw"] for row in batch] # --- Tentative batch --- try: results = normalize_batch(raw_names) except LLMUnavailable as e: logger.error("LLM injoignable : %s", e) print( f"\nErreur réseau — arrêt. {nb_ok} articles normalisés, " f"{total - done} restants." ) return (nb_ok, total - done) # Si normalize_batch a retourné que des None (batch échoué), # tente le fallback un par un if all(r is None for r in results): logger.debug("Fallback unitaire pour le batch %d–%d.", start, start + len(batch)) results = [normalize_product_name(name) for name in raw_names] # --- Mise à jour ou affichage --- for item, normalized in zip(batch, results): done += 1 if normalized: print( f"[{done}/{total}] {item['name_raw']!r} " f"→ {normalized}" ) if not dry_run: repository.update_normalized(conn, item["id"], normalized) nb_ok += 1 else: logger.warning( "[%d/%d] Impossible de normaliser %r — item ignoré.", done, total, item["name_raw"], ) nb_err += 1 # Commit final (toutes les mises à jour sont dans la même transaction implicite) if not dry_run: conn.commit() print( f"\n{mode}Terminé : {nb_ok} normalisé(s), {nb_err} erreur(s)." ) return (nb_ok, nb_err) finally: conn.close()