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:
2026-02-25 18:02:48 +01:00
parent f360332626
commit be4d4a7076
13 changed files with 804 additions and 23 deletions

View File

@@ -28,7 +28,9 @@ def build_parser() -> argparse.ArgumentParser:
tickettracker.cli
├── import <file> --source {picnic,leclerc} [--db PATH]
├── stats [--db PATH]
── normalize [--dry-run] [--batch-size N] [--db PATH]
── normalize [--dry-run] [--batch-size N] [--db PATH]
├── match [--threshold N] [--db PATH]
└── watch [--inbox PATH] [--db PATH]
"""
parser = argparse.ArgumentParser(
prog="python -m tickettracker.cli",
@@ -99,6 +101,47 @@ def build_parser() -> argparse.ArgumentParser:
help=f"Articles par appel LLM (défaut : {_cfg.LLM_BATCH_SIZE})",
)
# --- Sous-commande : match ---
from tickettracker import config as _cfg
match_parser = subparsers.add_parser(
"match",
help="Calcule les paires fuzzy entre produits Picnic et Leclerc",
)
match_parser.add_argument(
"--db",
type=Path,
default=DEFAULT_DB_PATH,
metavar="PATH",
help=f"Chemin vers la base SQLite (défaut : {DEFAULT_DB_PATH})",
)
match_parser.add_argument(
"--threshold",
type=float,
default=_cfg.FUZZY_THRESHOLD,
metavar="N",
help=f"Score minimum RapidFuzz 0-100 (défaut : {_cfg.FUZZY_THRESHOLD})",
)
# --- Sous-commande : watch ---
watch_parser = subparsers.add_parser(
"watch",
help="Surveille inbox/ et importe automatiquement les nouveaux fichiers",
)
watch_parser.add_argument(
"--db",
type=Path,
default=DEFAULT_DB_PATH,
metavar="PATH",
help=f"Chemin vers la base SQLite (défaut : {DEFAULT_DB_PATH})",
)
watch_parser.add_argument(
"--inbox",
type=Path,
default=Path("inbox"),
metavar="PATH",
help="Répertoire inbox/ à surveiller (défaut : ./inbox)",
)
return parser
@@ -205,6 +248,51 @@ def cmd_normalize(args: argparse.Namespace) -> int:
return 1
def cmd_match(args: argparse.Namespace) -> int:
"""Exécute la sous-commande 'match'.
Calcule les paires fuzzy entre produits Picnic et Leclerc,
les insère dans product_matches et affiche un résumé.
Returns:
0 si succès, 1 si la base est absente.
"""
from tickettracker.db import schema
from tickettracker.db.matcher import find_fuzzy_matches, save_fuzzy_matches
if not Path(args.db).exists():
print(f"Base de données absente : {args.db}", file=sys.stderr)
print("Importez d'abord un ticket avec la commande 'import'.", file=sys.stderr)
return 1
with schema.get_connection(args.db) as conn:
matches = find_fuzzy_matches(conn, threshold=args.threshold)
inserted = save_fuzzy_matches(conn, matches)
total = len(matches)
ignored = total - inserted
print(
f"{inserted} nouvelles paires trouvées (seuil={args.threshold:.0f}%). "
f"{ignored} ignorées (déjà connues)."
)
return 0
def cmd_watch(args: argparse.Namespace) -> int:
"""Exécute la sous-commande 'watch'.
Lance la surveillance du dossier inbox/ (bloquant — Ctrl+C pour arrêter).
Returns:
0 après interruption par l'utilisateur.
"""
from tickettracker.watcher import watch
inbox_path = args.inbox.resolve()
watch(inbox_path, args.db)
return 0
def main() -> None:
"""Point d'entrée principal."""
parser = build_parser()
@@ -216,6 +304,10 @@ def main() -> None:
sys.exit(cmd_stats(args))
elif args.command == "normalize":
sys.exit(cmd_normalize(args))
elif args.command == "match":
sys.exit(cmd_match(args))
elif args.command == "watch":
sys.exit(cmd_watch(args))
if __name__ == "__main__":