feat: fuzzy matching Picnic ↔ Leclerc + page /matches dans le dashboard
Nouvelle table product_matches (status: pending/validated/rejected).
Matching via RapidFuzz token_sort_ratio, seuil configurable (défaut 85%).
Workflow :
1. python -m tickettracker.cli match [--threshold 85]
→ calcule et stocke les paires candidates
2. http://localhost:8000/matches
→ l'utilisateur valide ou rejette chaque paire
3. La comparaison de prix enrichie avec les paires validées
Nouvelles dépendances : rapidfuzz, watchdog (requirements.txt).
10 tests ajoutés (test_matcher.py), tous passent.
Suite complète : 129 passent, 1 xfail, 0 échec.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
90
tickettracker/db/matcher.py
Normal file
90
tickettracker/db/matcher.py
Normal file
@@ -0,0 +1,90 @@
|
||||
"""
|
||||
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
|
||||
matches = []
|
||||
for p in picnic_names:
|
||||
for lec in leclerc_names:
|
||||
if p == lec:
|
||||
continue # exact match déjà géré par get_compare_prices
|
||||
score = fuzz.token_sort_ratio(p, lec)
|
||||
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
|
||||
Reference in New Issue
Block a user