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>
This commit is contained in:
2026-02-24 18:53:41 +01:00
parent bb62bd6eb6
commit 1e5fc97bb7
24 changed files with 3181 additions and 0 deletions

View File

@@ -0,0 +1,279 @@
"""
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()