feat: add depense tagging

This commit is contained in:
2026-01-19 05:19:11 +01:00
parent c5e51a7513
commit 0fd6bdaeb3
8 changed files with 711 additions and 43 deletions

View File

@@ -0,0 +1 @@
"""Services for business logic."""

View File

@@ -0,0 +1,165 @@
"""Service de prédiction de tags pour les dépenses basé sur l'historique."""
from typing import Optional
from collections import Counter
from sqlalchemy import select, func
from sqlalchemy.orm import Session
from ..database.models import Depense, Tag
class TagPrediction:
"""Représente une prédiction de tag."""
def __init__(
self,
tag_id: Optional[int],
tag_name: Optional[str],
confidence: float,
reason: str,
):
self.tag_id = tag_id
self.tag_name = tag_name
self.confidence = confidence
self.reason = reason
def to_dict(self) -> dict:
"""Convertit en dictionnaire."""
return {
"tag_id": self.tag_id,
"tag_name": self.tag_name,
"confidence": self.confidence,
"reason": self.reason,
}
class TagPredictor:
"""Service de prédiction de tags basé sur l'historique."""
def __init__(self, session: Session):
self.session = session
def predict_for_depense(self, depense_data: dict) -> TagPrediction:
"""Prédit le tag pour une dépense donnée.
Args:
depense_data: Dict contenant 'fournisseur', 'sous_categorie', 'description'
Returns:
TagPrediction avec le tag suggéré et la confiance
"""
fournisseur = (depense_data.get("fournisseur") or "").strip()
sous_categorie = (depense_data.get("sous_categorie") or "").strip()
# Stratégie 1: Recherche par fournisseur exact
if fournisseur:
prediction = self._predict_by_fournisseur(fournisseur)
if prediction:
return prediction
# Stratégie 2: Recherche par sous-catégorie
if sous_categorie:
prediction = self._predict_by_sous_categorie(sous_categorie)
if prediction:
return prediction
# Aucune prédiction trouvée
return TagPrediction(
tag_id=None,
tag_name=None,
confidence=0.0,
reason="Aucun historique trouvé",
)
def _predict_by_fournisseur(self, fournisseur: str) -> Optional[TagPrediction]:
"""Prédit le tag basé sur le fournisseur.
Retourne le tag le plus fréquemment utilisé pour ce fournisseur.
"""
# Requête pour trouver toutes les dépenses avec ce fournisseur et un tag
stmt = (
select(Depense.tag_id, Tag.nom, func.count(Depense.id))
.join(Tag, Depense.tag_id == Tag.id)
.where(
Depense.fournisseur.ilike(f"%{fournisseur}%"),
Depense.tag_id.is_not(None),
)
.group_by(Depense.tag_id, Tag.nom)
.order_by(func.count(Depense.id).desc())
)
result = self.session.execute(stmt).first()
if result:
tag_id, tag_name, count = result
total_stmt = select(func.count(Depense.id)).where(
Depense.fournisseur.ilike(f"%{fournisseur}%")
)
total = self.session.execute(total_stmt).scalar() or 0
confidence = (count / total * 100) if total > 0 else 0
return TagPrediction(
tag_id=tag_id,
tag_name=tag_name,
confidence=round(confidence, 1),
reason=f"Basé sur {count} occurrence(s) pour ce fournisseur",
)
return None
def _predict_by_sous_categorie(
self, sous_categorie: str
) -> Optional[TagPrediction]:
"""Prédit le tag basé sur la sous-catégorie.
Retourne le tag le plus fréquemment utilisé pour cette sous-catégorie.
"""
# Requête pour trouver toutes les dépenses avec cette sous-catégorie et un tag
stmt = (
select(Depense.tag_id, Tag.nom, func.count(Depense.id))
.join(Tag, Depense.tag_id == Tag.id)
.where(
Depense.sous_categorie.ilike(f"%{sous_categorie}%"),
Depense.tag_id.is_not(None),
)
.group_by(Depense.tag_id, Tag.nom)
.order_by(func.count(Depense.id).desc())
)
result = self.session.execute(stmt).first()
if result:
tag_id, tag_name, count = result
total_stmt = select(func.count(Depense.id)).where(
Depense.sous_categorie.ilike(f"%{sous_categorie}%")
)
total = self.session.execute(total_stmt).scalar() or 0
confidence = (count / total * 100) if total > 0 else 0
return TagPrediction(
tag_id=tag_id,
tag_name=tag_name,
confidence=round(confidence, 1),
reason=f"Basé sur {count} occurrence(s) pour cette sous-catégorie",
)
return None
def predict_batch(self, depenses_data: list[dict]) -> list[TagPrediction]:
"""Prédit les tags pour une liste de dépenses.
Args:
depenses_data: Liste de dicts contenant les infos des dépenses
Returns:
Liste de TagPrediction dans le même ordre
"""
predictions = []
for depense in depenses_data:
prediction = self.predict_for_depense(depense)
predictions.append(prediction)
return predictions