""" 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 0–100 (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