Files

315 lines
9.7 KiB
Python
Raw Permalink Normal View History

"""
Point d'entrée CLI pour TicketTracker.
Utilisation :
python -m tickettracker.cli import fichier.html --source picnic
python -m tickettracker.cli import fichier.pdf --source leclerc [--db /chemin/db]
python -m tickettracker.cli stats
python -m tickettracker.cli stats --db /chemin/db
python -m tickettracker.cli normalize [--dry-run] [--batch-size N] [--db /chemin/db]
"""
import argparse
import logging
import sys
from pathlib import Path
from tickettracker.db.schema import DEFAULT_DB_PATH
from tickettracker import pipeline
# Affiche les messages INFO dans le terminal (utile pour voir les doublons skippés)
logging.basicConfig(level=logging.INFO, format="%(message)s")
def build_parser() -> argparse.ArgumentParser:
"""Construit le parseur d'arguments CLI.
Structure :
tickettracker.cli
import <file> --source {picnic,leclerc} [--db PATH]
stats [--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",
description="TicketTracker — import et analyse de tickets de courses",
)
subparsers = parser.add_subparsers(dest="command", required=True)
# --- Sous-commande : import ---
import_parser = subparsers.add_parser(
"import",
help="Parse et importe un ticket dans la base SQLite",
)
import_parser.add_argument(
"file",
type=Path,
help="Chemin vers le fichier à importer (.html pour Picnic, .pdf pour Leclerc)",
)
import_parser.add_argument(
"--source",
required=True,
choices=["picnic", "leclerc"],
help="Format du fichier",
)
import_parser.add_argument(
"--db",
type=Path,
default=DEFAULT_DB_PATH,
metavar="PATH",
help=f"Chemin vers la base SQLite (défaut : {DEFAULT_DB_PATH})",
)
# --- Sous-commande : stats ---
stats_parser = subparsers.add_parser(
"stats",
help="Affiche un résumé de la base de données",
)
stats_parser.add_argument(
"--db",
type=Path,
default=DEFAULT_DB_PATH,
metavar="PATH",
help=f"Chemin vers la base SQLite (défaut : {DEFAULT_DB_PATH})",
)
# --- Sous-commande : normalize ---
from tickettracker import config as _cfg
normalize_parser = subparsers.add_parser(
"normalize",
help="Normalise les noms de produits via le LLM",
)
normalize_parser.add_argument(
"--db",
type=Path,
default=DEFAULT_DB_PATH,
metavar="PATH",
help=f"Chemin vers la base SQLite (défaut : {DEFAULT_DB_PATH})",
)
normalize_parser.add_argument(
"--dry-run",
action="store_true",
help="Calcule les normalisations sans écrire en base",
)
normalize_parser.add_argument(
"--batch-size",
type=int,
default=_cfg.LLM_BATCH_SIZE,
metavar="N",
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
def cmd_import(args: argparse.Namespace) -> int:
"""Exécute la sous-commande 'import'.
Returns:
0 si succès (ticket inséré ou déjà présent), 1 si erreur.
"""
try:
inserted = pipeline.import_receipt(args.file, args.source, args.db)
if inserted:
print(f"OK Ticket importé depuis {args.file}")
else:
print(f"[skip] Ticket déjà présent en base — import ignoré.")
return 0
except (FileNotFoundError, ValueError) as e:
print(f"Erreur : {e}", file=sys.stderr)
return 1
except Exception as e:
print(f"Erreur inattendue : {e}", file=sys.stderr)
return 1
def cmd_stats(args: argparse.Namespace) -> int:
"""Exécute la sous-commande 'stats'.
Returns:
0 si succès, 1 si la base est absente ou vide.
"""
from tickettracker.db import schema, repository
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:
stats = repository.get_stats(conn)
total_receipts = sum(stats["receipts_by_store"].values())
if total_receipts == 0:
print("Aucun ticket en base.")
return 0
print("--- TicketTracker : résumé ---")
print("Tickets par enseigne :")
for store, nb in sorted(stats["receipts_by_store"].items()):
print(f" {store:<10}: {nb} ticket(s)")
print(f"Total dépensé : {stats['total_spent']:.2f}")
print(f"Nombre d'articles : {stats['total_items']} lignes")
normalized = stats["distinct_normalized"]
null_count = stats["null_normalized"]
total_items = stats["total_items"]
print(f"Noms normalisés : {normalized} distincts / {total_items} articles")
if null_count > 0:
print(f" ({null_count} articles sans nom normalisé)")
print(" Lancez : python -m tickettracker.cli normalize")
return 0
def cmd_normalize(args: argparse.Namespace) -> int:
"""Exécute la sous-commande 'normalize'.
Normalise les articles dont name_normalized est NULL en appelant
le LLM par batchs. Avec --dry-run, affiche sans écrire en base.
Returns:
0 si succès ou dry-run, 1 si erreur (LLM injoignable, clé manquante).
"""
from tickettracker import config
from tickettracker.llm.client import LLMError, LLMUnavailable
from tickettracker.llm import normalizer
# Vérification préalable de la clé API
if not config.LLM_API_KEY:
print(
"Erreur : clé API LLM manquante.\n"
"Définissez la variable d'environnement TICKETTRACKER_LLM_API_KEY.",
file=sys.stderr,
)
return 1
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
try:
nb_ok, nb_err = normalizer.normalize_all_in_db(
db_path=args.db,
batch_size=args.batch_size,
dry_run=args.dry_run,
)
return 0 if nb_err == 0 else 1
except LLMUnavailable as e:
print(f"LLM injoignable : {e}", file=sys.stderr)
return 1
except LLMError as e:
print(f"Erreur LLM : {e}", file=sys.stderr)
return 1
except Exception as e:
print(f"Erreur inattendue : {e}", file=sys.stderr)
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()
args = parser.parse_args()
if args.command == "import":
sys.exit(cmd_import(args))
elif args.command == "stats":
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__":
main()