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>
96 lines
3.3 KiB
Python
96 lines
3.3 KiB
Python
"""
|
||
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
|