Files
TicketTracker/tickettracker/llm/normalizer.py
laurent 1e5fc97bb7 feat: migration Windows → Ubuntu, stabilisation suite de tests
- Ajout venv Python (.venv) avec pip bootstrap (python3-venv absent)
- Correction OCR Linux : marqueur TTC/TVA tolère la confusion T↔I
  (Tesseract 5.3.4 Linux lit parfois "TIc" au lieu de "TTC")
- test_leclerc.py : skipif si Tesseract absent, xfail pour test de somme
  (précision OCR variable entre plateformes, solution LLM vision prévue)
- Résultat : 77 passent, 1 xfail, 0 échec (vs 78 sur Windows)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-24 18:53:41 +01:00

280 lines
9.3 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
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]
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()