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:
1
tickettracker/llm/__init__.py
Normal file
1
tickettracker/llm/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
# Module LLM — normalisation des noms de produits
|
||||
99
tickettracker/llm/client.py
Normal file
99
tickettracker/llm/client.py
Normal file
@@ -0,0 +1,99 @@
|
||||
"""
|
||||
Client HTTP bas niveau pour l'API LLM compatible OpenAI.
|
||||
|
||||
Ce module ne contient qu'une seule fonction publique : call_llm().
|
||||
Il ne connaît pas la logique de normalisation — c'est le rôle de normalizer.py.
|
||||
|
||||
Exceptions levées :
|
||||
LLMUnavailable — serveur injoignable (timeout, connexion refusée)
|
||||
LLMError — réponse HTTP ≥ 400 ou format de réponse inattendu
|
||||
"""
|
||||
|
||||
import logging
|
||||
|
||||
import requests
|
||||
|
||||
from tickettracker import config
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class LLMUnavailable(Exception):
|
||||
"""Le serveur LLM est injoignable (réseau, timeout)."""
|
||||
|
||||
|
||||
class LLMError(Exception):
|
||||
"""L'API LLM a retourné une erreur (HTTP ≥ 400 ou réponse malformée)."""
|
||||
|
||||
|
||||
def call_llm(
|
||||
messages: list[dict],
|
||||
*,
|
||||
model: str | None = None,
|
||||
timeout: int | None = None,
|
||||
) -> str:
|
||||
"""Appelle l'API LLM et retourne le texte brut de la réponse.
|
||||
|
||||
Args:
|
||||
messages: Liste de messages au format OpenAI
|
||||
[{"role": "system", "content": "..."}, {"role": "user", "content": "..."}]
|
||||
model: Nom du modèle (défaut : config.LLM_MODEL)
|
||||
timeout: Timeout en secondes (défaut : config.LLM_TIMEOUT)
|
||||
|
||||
Returns:
|
||||
Le texte du premier choix de la réponse.
|
||||
|
||||
Raises:
|
||||
LLMUnavailable: Si le serveur est injoignable.
|
||||
LLMError: Si l'API retourne une erreur ou une réponse inattendue.
|
||||
"""
|
||||
_model = model or config.LLM_MODEL
|
||||
_timeout = timeout if timeout is not None else config.LLM_TIMEOUT
|
||||
|
||||
if not config.LLM_API_KEY:
|
||||
raise LLMError(
|
||||
"Clé API LLM manquante. "
|
||||
"Définissez la variable d'environnement TICKETTRACKER_LLM_API_KEY."
|
||||
)
|
||||
|
||||
headers = {
|
||||
"Authorization": f"Bearer {config.LLM_API_KEY}",
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
payload = {
|
||||
"model": _model,
|
||||
"messages": messages,
|
||||
"temperature": 0.1, # faible variabilité : on veut un format stable
|
||||
}
|
||||
|
||||
logger.debug("Appel LLM %s (model=%s, timeout=%ds)", config.LLM_URL, _model, _timeout)
|
||||
|
||||
try:
|
||||
response = requests.post(
|
||||
config.LLM_URL,
|
||||
json=payload,
|
||||
headers=headers,
|
||||
timeout=_timeout,
|
||||
)
|
||||
except requests.exceptions.Timeout:
|
||||
raise LLMUnavailable(
|
||||
f"Timeout après {_timeout}s lors de l'appel au LLM ({config.LLM_URL})."
|
||||
)
|
||||
except requests.exceptions.ConnectionError as e:
|
||||
raise LLMUnavailable(
|
||||
f"Impossible de joindre le serveur LLM ({config.LLM_URL}) : {e}"
|
||||
)
|
||||
|
||||
if not response.ok:
|
||||
raise LLMError(
|
||||
f"Erreur API LLM : HTTP {response.status_code} — {response.text[:200]}"
|
||||
)
|
||||
|
||||
try:
|
||||
data = response.json()
|
||||
return data["choices"][0]["message"]["content"]
|
||||
except (KeyError, IndexError, ValueError) as e:
|
||||
raise LLMError(
|
||||
f"Réponse LLM inattendue (impossible d'extraire le contenu) : {e}\n"
|
||||
f"Réponse brute : {response.text[:300]}"
|
||||
) from e
|
||||
279
tickettracker/llm/normalizer.py
Normal file
279
tickettracker/llm/normalizer.py
Normal 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()
|
||||
Reference in New Issue
Block a user