fetch_unnormalized() remonte maintenant la colonne `unit` (ex: "250 g", "20 sachets"). Le normaliseur concatène name_raw + unit avant d'envoyer au LLM, qui peut ainsi placer le poids dans le champ format. Résultat : "Haribo dragibus" → "Dragibus | Haribo | 250g" au lieu de "Haribo dragibus" → "Dragibus | Haribo | -" Améliore aussi la qualité du fuzzy matching Picnic ↔ Leclerc. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
286 lines
9.7 KiB
Python
286 lines
9.7 KiB
Python
"""
|
||
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>.+?)" # nom du produit
|
||
r"\s*\|\s*"
|
||
r"(?P<marque>.+?)" # marque
|
||
r"\s*\|\s*"
|
||
r"(?P<qte>.+?)" # 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]
|
||
# On inclut l'unité/poids (ex: "250 g", "20 sachets") dans le nom
|
||
# envoyé au LLM pour qu'il puisse le placer dans le champ format.
|
||
# Pour les articles sans unité (Leclerc OCR), unit est None ou "".
|
||
raw_names = [
|
||
f"{row['name_raw']} {row['unit']}".strip() if row["unit"] else 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] # raw_names contient déjà l'unité
|
||
|
||
# --- 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()
|