Files
TicketTracker/tickettracker/db/matcher.py

96 lines
3.3 KiB
Python
Raw Normal View History

"""
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