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:
@@ -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__":
|
||||
|
||||
Reference in New Issue
Block a user