Files
TicketTracker/tickettracker/db/matcher.py
laurent be4d4a7076 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>
2026-02-25 18:02:48 +01:00

91 lines
3.0 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
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