Files
TicketTracker/tickettracker/db/matcher.py
laurent 8af474c928 feat: support .eml Picnic + correction fuzzy matching
Support .eml :
  - pipeline._eml_to_html() extrait le HTML des emails Picnic
  - Déposer un .eml dans inbox/picnic/ fonctionne comme un .html
  - Pas de nouvelle dépendance (module email stdlib)
  - 5 tests ajoutés (test_eml.py)

Correction fuzzy matching :
  - Le score est maintenant calculé sur le nom seul (avant " | ")
  - Évite que les différences de marque/poids pénalisent le score
  - Résultat : 8 paires trouvées vs 0 avant la correction

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-25 18:23:57 +01:00

96 lines
3.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.
"""
Fuzzy matching entre produits Picnic et Leclerc.
Utilise RapidFuzz (token_sort_ratio) pour rapprocher des produits dont le nom
n'est pas identique mais désigne la même chose
(ex : "Lait demi-écremé""LAIT DEMI ECREME").
Workflow :
1. find_fuzzy_matches() — calcule les paires candidates
2. save_fuzzy_matches() — les insère dans product_matches (ignoring duplicates)
3. L'utilisateur valide/rejette via le dashboard /matches
"""
import sqlite3
from datetime import datetime, timezone
from rapidfuzz import fuzz
def find_fuzzy_matches(
conn: sqlite3.Connection,
threshold: float = 85.0,
) -> list[dict]:
"""Calcule les paires de produits similaires entre Picnic et Leclerc.
Utilise rapidfuzz.fuzz.token_sort_ratio (insensible à l'ordre des mots).
Ne retourne que les paires avec score >= threshold.
Les noms identiques sont exclus (ils sont déjà traités par get_compare_prices).
Args:
conn: Connexion SQLite ouverte.
threshold: Score minimum 0100 (défaut 85).
Returns:
Liste de dicts {name_picnic, name_leclerc, score}, triée par score décroissant.
"""
# Noms normalisés distincts par enseigne
picnic_names = [
r[0]
for r in conn.execute(
"SELECT DISTINCT name_normalized FROM price_history "
"WHERE store='picnic' AND name_normalized IS NOT NULL"
)
]
leclerc_names = [
r[0]
for r in conn.execute(
"SELECT DISTINCT name_normalized FROM price_history "
"WHERE store='leclerc' AND name_normalized IS NOT NULL"
)
]
# Produit cartésien filtré par seuil
# On compare uniquement le nom (avant le premier " | ") pour éviter que
# les différences de marque/quantité ("| MDD | 1kg" vs "| - | -") ne
# pénalisent artificiellement le score.
matches = []
for p in picnic_names:
p_name = p.split(" | ")[0].strip()
for lec in leclerc_names:
if p == lec:
continue # exact match déjà géré par get_compare_prices
lec_name = lec.split(" | ")[0].strip()
score = fuzz.token_sort_ratio(p_name, lec_name)
if score >= threshold:
matches.append({"name_picnic": p, "name_leclerc": lec, "score": score})
return sorted(matches, key=lambda x: -x["score"])
def save_fuzzy_matches(conn: sqlite3.Connection, matches: list[dict]) -> int:
"""Insère les nouvelles paires dans product_matches (ignore les doublons).
Utilise INSERT OR IGNORE pour ne pas écraser les paires déjà en base
(statut 'validated' ou 'rejected' conservé).
Args:
conn: Connexion SQLite ouverte.
matches: Résultat de find_fuzzy_matches().
Returns:
Nombre de nouvelles paires réellement insérées.
"""
created_at = datetime.now(timezone.utc).isoformat()
inserted = 0
with conn:
for m in matches:
cur = conn.execute(
"INSERT OR IGNORE INTO product_matches "
"(name_picnic, name_leclerc, score, status, created_at) "
"VALUES (?, ?, ?, 'pending', ?)",
(m["name_picnic"], m["name_leclerc"], m["score"], created_at),
)
inserted += cur.rowcount
return inserted