280 lines
9.3 KiB
Python
280 lines
9.3 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]
|
|||
|
|
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()
|