Vortex-Flux / src /core /kg_data.py
jaimalklyde's picture
Claude Opus 5.5
feat(ontology): module serieux (vue 360, fraude, coherence, terminal IA fiable)
c6c39e9
Raw History Blame Contribute Delete
34 kB
"""
KG DATA — coeur metier (pur, sans Streamlit) du module Ontology
================================================================
Tout ce que le module Ontology affiche ou transmet a l'agent IA part d'ici :
- consolider_prets() : une seule ligne par pret (Prets_Master + derniere
version Prets_Update), pour ne plus compter deux fois
un pret modifie.
- extraire_triplets() : triplets sujet/predicat/objet pilotes par la feuille
Ontology, avec classes canoniques et identifiants
normalises (telephone, email, piece d'identite...).
- controles_coherence() : references orphelines, doublons d'ID, chaine des soldes
de remboursement, statuts incoherents.
- identifiants_partages() : meme telephone / email / piece d'identite chez des
personnes differentes (fraude, prete-noms).
- reseaux_garantie() : auto-garantie, garantie croisee, garant multi-emprunteurs.
- rechercher_personnes() / fiche_personne() / fiche_texte() : vue 360 d'un client
ou d'un garant.
Les fonctions recoivent les feuilles deja lues ({nom_feuille: DataFrame}) : elles
sont testables sans Google Sheets.
"""
import math
import re
import unicodedata
import pandas as pd
try:
from rapidfuzz import fuzz as _fuzz
except ImportError: # dependance declaree dans requirements.txt ; repli tolerant
_fuzz = None
# Feuilles des personnes : classe, colonne ID.
PERSONNES = {"Clients_KYC": ("Client", "ID_Client"), "Garants_KYC": ("Garant", "ID_Garant")}
COL_NOM = "Nom_Complet"
FEUILLE_PRETS, FEUILLE_VERSIONS, FEUILLE_REMB = "Prets_Master", "Prets_Update", "Remboursements"
# Colonnes d'identifiants comparees entre personnes, et nombre de personnes distinctes
# tolere par valeur (adresse : colocation / famille).
TYPES_IDENTIFIANTS = ("Telephone", "Email", "ID_Officiel", "Numero_Fiscal", "Reseau_sociaux", "Adresse")
SEUILS_PARTAGE = {"Adresse": 3}
# Valeurs jamais transmises en clair a un service externe (LLM, Jev) ni a l'export RDF.
COLONNES_SENSIBLES = ("ID_Officiel", "Numero_Fiscal", "Telephone", "Email", "Reference_Externe", "Reseau_sociaux")
_VALEURS_BIDON = {"", "NA", "N/A", "NAN", "NONE", "NULL", "AUCUN", "AUCUNE", "NEANT", "0", "00", "-", "X", "XX", "XXX"}
GRAVITES = ("Erreur", "Avertissement", "Info")
COLONNES_CONTROLES = ["Gravité", "Contrôle", "Feuille", "Référence", "Détail"]
TOLERANCE_XOF = 1.0
SEUIL_GARANT_MULTI = 3
# ============================================================================
# NORMALISATION
# ============================================================================
def vide(valeur) -> bool:
if valeur is None:
return True
if isinstance(valeur, float) and math.isnan(valeur):
return True
return str(valeur).strip().lower() in ("", "nan", "none", "null", "n/a", "nat")
def sans_accents(texte) -> str:
return "".join(c for c in unicodedata.normalize("NFKD", str(texte)) if not unicodedata.combining(c))
def cle_texte(texte) -> str:
"""Cle de comparaison : minuscules, sans accents ni ponctuation, espaces simples."""
if vide(texte):
return ""
return re.sub(r"[^a-z0-9]+", " ", sans_accents(texte).lower()).strip()
def nombre(valeur) -> float:
"""Montant lu depuis Sheets (deja numerise ou texte '12 990', '1,5', '12990 XOF'). NaN si illisible."""
if vide(valeur):
return float("nan")
if isinstance(valeur, (int, float)):
return float(valeur)
texte = str(valeur).replace("XOF", "").replace("FCFA", "").replace("\xa0", "").replace(" ", "").replace(" ", "")
if "," in texte and "." in texte:
texte = texte.replace(",", "")
else:
texte = texte.replace(",", ".")
try:
return float(texte)
except ValueError:
return float("nan")
def id_entite(valeur) -> str:
return "" if vide(valeur) else str(valeur).strip().upper()
def normaliser_identifiant(type_id, valeur) -> str:
"""Forme comparable d'un identifiant ; '' si absent ou valeur de remplissage."""
if vide(valeur):
return ""
brut = str(valeur).strip()
if re.fullmatch(r"\d+\.0", brut): # entier numerise par Sheets
brut = brut[:-2]
if type_id == "Telephone":
chiffres = re.sub(r"\D", "", brut)
if len(chiffres) < 8:
return ""
norme = chiffres[-9:]
elif type_id == "Email":
norme = brut.lower()
if "@" not in norme:
return ""
elif type_id in ("ID_Officiel", "Numero_Fiscal", "Reference_Externe"):
norme = re.sub(r"[^0-9A-Za-z]", "", brut).upper()
else:
norme = cle_texte(brut)
return "" if norme.upper() in _VALEURS_BIDON or len(set(norme)) <= 1 else norme
def masquer(type_id, valeur) -> str:
"""Valeur affichable/transmissible sans exposer l'identifiant complet."""
if vide(valeur):
return ""
brut = str(valeur).strip()
if type_id == "Email":
nom, _, domaine = brut.partition("@")
return f"{nom[:1]}***@{domaine}" if domaine else "***"
return "••••" + brut[-4:] if len(brut) > 4 else "••••"
def proteger_proprietes(props: dict) -> dict:
"""Copie de `props` ou chaque colonne sensible est masquee."""
return {k: (masquer(k, v) if k in COLONNES_SENSIBLES else v) for k, v in props.items()}
def texte_sur(valeur, longueur=300) -> str:
"""Texte destine a un rendu HTML/JS tiers (infobulles du graphe) : sans balise possible."""
return str(valeur).replace("<", "‹").replace(">", "›")[:longueur]
def _controle(gravite, controle, feuille, reference, detail):
return {"Gravité": gravite, "Contrôle": controle, "Feuille": feuille, "Référence": reference, "Détail": detail}
def _df(feuilles, nom):
df = feuilles.get(nom)
return df if isinstance(df, pd.DataFrame) else pd.DataFrame()
# ============================================================================
# PRETS : UNE LIGNE PAR PRET
# ============================================================================
def consolider_prets(master: pd.DataFrame, versions: pd.DataFrame):
"""Une ligne par pret racine (ID_Pret de Prets_Master), portant les valeurs de sa
derniere version Prets_Update. Ajoute ID_Pret_Courant, Nb_Versions.
Retourne (DataFrame, info) ; info = {prets_consolides, versions_orphelines,
montants_versions: {racine: [Montant_Total de chaque version]}}."""
info = {"prets_consolides": 0, "versions_orphelines": [], "montants_versions": {}}
if master is None or master.empty or "ID_Pret" not in master.columns:
return (master.copy() if isinstance(master, pd.DataFrame) else pd.DataFrame()), info
par_racine = {}
if versions is not None and not versions.empty and "ID_Pret" in versions.columns:
v = versions.copy()
v["_racine"] = v["ID_Pret"].map(id_entite)
v["_ordre"] = range(len(v))
v["_version"] = pd.to_numeric(v["Version"], errors="coerce").fillna(0) if "Version" in v.columns else 0
v = v[v["_racine"] != ""].sort_values(["_racine", "_version", "_ordre"])
par_racine = {racine: grp for racine, grp in v.groupby("_racine", sort=False)}
lignes, racines = [], set()
for rec in master.to_dict("records"):
racine = id_entite(rec.get("ID_Pret"))
if not racine:
continue
racines.add(racine)
rec["ID_Pret"] = racine
montants = [nombre(rec.get("Montant_Total"))]
grp = par_racine.get(racine)
if grp is not None:
for col, val in grp.iloc[-1].to_dict().items():
if col.startswith("_") or col == "ID_Pret" or vide(val):
continue
if col in rec:
rec[col] = val
rec["ID_Pret_Courant"] = id_entite(grp.iloc[-1].get("ID_Pret_Updated")) or racine
rec["Nb_Versions"] = 1 + len(grp)
montants += [nombre(x) for x in grp.get("Montant_Total", pd.Series(dtype=object))]
info["prets_consolides"] += 1
else:
rec["ID_Pret_Courant"] = racine
rec["Nb_Versions"] = 1
info["montants_versions"][racine] = [m for m in montants if not math.isnan(m)]
lignes.append(rec)
for racine, grp in par_racine.items():
if racine not in racines:
info["versions_orphelines"].append(racine)
return pd.DataFrame(lignes), info
def preparer_feuilles(feuilles: dict):
"""Remplace Prets_Master par sa version consolidee. Retourne (feuilles, info)."""
sortie = dict(feuilles)
prets, info = consolider_prets(_df(feuilles, FEUILLE_PRETS), _df(feuilles, FEUILLE_VERSIONS))
if FEUILLE_PRETS in feuilles:
sortie[FEUILLE_PRETS] = prets
return sortie, info
# ============================================================================
# TRIPLETS (PILOTES PAR LA FEUILLE ONTOLOGY)
# ============================================================================
COLONNES_REGLES = ["Sheet", "SubjectCol", "SubjectClass", "Predicate", "ObjectType", "ObjectColOrConcept"]
def normaliser_regles(regles: pd.DataFrame) -> pd.DataFrame:
"""Regles exploitables (colonnes nettoyees) ; les lignes incompletes sont ecartees."""
if regles is None or regles.empty:
return pd.DataFrame(columns=COLONNES_REGLES)
r = regles.copy()
r.columns = [str(c).strip() for c in r.columns]
for col in COLONNES_REGLES:
if col not in r.columns:
r[col] = ""
r[col] = r[col].map(lambda x: "" if vide(x) else str(x).strip())
r["ObjectType"] = r["ObjectType"].str.lower()
# Noms de classe et de predicat en identifiants ASCII : ce sont aussi des noms RDF
# ("Prêt" et "Pret" designent la meme classe ; la premiere casse rencontree est retenue).
r["SubjectClass"] = r["SubjectClass"].map(nom_rdf)
r["Predicate"] = r["Predicate"].map(nom_rdf)
r = r[(r["Sheet"] != "") & (r["SubjectCol"] != "") & (r["SubjectClass"] != "") & (r["Predicate"] != "")]
canon = {}
for cls in r["SubjectClass"]:
canon.setdefault(cls.lower(), cls)
r["SubjectClass"] = r["SubjectClass"].map(lambda c: canon[c.lower()])
return r.reset_index(drop=True)
def nom_rdf(nom) -> str:
"""Identifiant RDF sur : sans accents, [A-Za-z0-9_] uniquement."""
return re.sub(r"[^A-Za-z0-9_]+", "_", sans_accents(str(nom)).strip()).strip("_")
def classe_objet(colonne, classes) -> str:
"""Classe de l'objet d'une relation : 'ID_Pret' -> 'Pret' SEULEMENT si 'Pret' est une
classe connue ; sinon la colonne elle-meme ('ID_Officiel' reste 'ID_Officiel')."""
col = nom_rdf(colonne)
canon = {c.lower(): c for c in classes}
if col.upper().startswith("ID_") and col[3:].lower() in canon:
return canon[col[3:].lower()]
return canon.get(col.lower(), col)
def extraire_triplets(regles: pd.DataFrame, feuilles: dict):
"""Triplets a partir des regles Ontology et des feuilles PREPAREES (preparer_feuilles).
Retourne (DataFrame[subject, predicate, object, object_type, sheet, colonne, subject_props],
info = {classes, regles_ignorees: [{regle, raison}]})."""
regles = normaliser_regles(regles)
classes = list(dict.fromkeys(regles["SubjectClass"]))
ignorees, triplets = [], []
consolide = FEUILLE_PRETS in feuilles and FEUILLE_VERSIONS in feuilles
for feuille, rs in regles.groupby("Sheet", sort=False):
libelles = [f"{feuille}.{r['SubjectCol']} -> {r['Predicate']}" for r in rs.to_dict("records")]
if consolide and feuille == FEUILLE_VERSIONS:
ignorees += [{"regle": l, "raison": "versions fusionnees dans Prets_Master (un noeud par pret)"} for l in libelles]
continue
df = _df(feuilles, feuille)
if df.empty:
ignorees += [{"regle": l, "raison": "feuille absente ou vide"} for l in libelles]
continue
cols = set(df.columns)
valides = []
for r in rs.to_dict("records"):
libelle = f"{feuille}.{r['SubjectCol']} -> {r['Predicate']}"
if r["SubjectCol"] not in cols:
ignorees.append({"regle": libelle, "raison": f"colonne sujet '{r['SubjectCol']}' absente"})
elif r["ObjectType"] not in ("relation", "data_property"):
ignorees.append({"regle": libelle, "raison": f"ObjectType '{r['ObjectType']}' inconnu"})
elif r["ObjectColOrConcept"] not in cols:
ignorees.append({"regle": libelle, "raison": f"colonne objet '{r['ObjectColOrConcept']}' absente"})
else:
r["_classe_objet"] = classe_objet(r["ObjectColOrConcept"], classes)
valides.append(r)
if not valides:
continue
for row in df.to_dict("records"):
props = {k: v for k, v in row.items() if not vide(v) and not str(k).startswith("_")}
for r in valides:
s_val = id_entite(row.get(r["SubjectCol"]))
if not s_val:
continue
oval = row.get(r["ObjectColOrConcept"])
if vide(oval):
continue
base = {"subject": f"{r['SubjectClass']}:{s_val}", "predicate": r["Predicate"],
"sheet": feuille, "colonne": r["ObjectColOrConcept"], "subject_props": props}
if r["ObjectType"] == "relation":
ocls = r["_classe_objet"]
if ocls in classes:
cible = id_entite(oval)
else:
cible = normaliser_identifiant(ocls, oval)
if cible:
triplets.append({**base, "object": f"{ocls}:{cible}", "object_type": "entity"})
else:
triplets.append({**base, "object": str(oval).strip(), "object_type": "literal"})
colonnes = ["subject", "predicate", "object", "object_type", "sheet", "colonne", "subject_props"]
return pd.DataFrame(triplets, columns=colonnes), {"classes": classes, "regles_ignorees": ignorees}
# ============================================================================
# CONTROLES DE COHERENCE
# ============================================================================
def _colonne_id_transaction(df):
for col in df.columns:
if str(col).upper().startswith("ID_TR"):
return col
return None
def controles_coherence(feuilles: dict, info_prets: dict) -> pd.DataFrame:
"""Anomalies de donnees, triees par gravite. `feuilles` = sortie de preparer_feuilles."""
out = []
clients, garants = _df(feuilles, "Clients_KYC"), _df(feuilles, "Garants_KYC")
prets, remb = _df(feuilles, FEUILLE_PRETS), _df(feuilles, FEUILLE_REMB)
# 1. Doublons d'identifiants
cibles = [("Clients_KYC", "ID_Client"), ("Garants_KYC", "ID_Garant"), (FEUILLE_PRETS, "ID_Pret")]
col_trx = _colonne_id_transaction(remb) if not remb.empty else None
if col_trx:
cibles.append((FEUILLE_REMB, col_trx))
for feuille, col in cibles:
df = _df(feuilles, feuille)
if df.empty or col not in df.columns:
continue
ids = df[col].map(id_entite)
for valeur, n in ids[ids != ""].value_counts().items():
if n > 1:
out.append(_controle("Erreur", "Identifiant en double", feuille, valeur, f"{col} present {n} fois."))
ids_clients = set(clients["ID_Client"].map(id_entite)) if "ID_Client" in clients.columns else None
ids_garants = set(garants["ID_Garant"].map(id_entite)) if "ID_Garant" in garants.columns else None
# 2. References des prets
prets_par_id = {}
if not prets.empty and "ID_Pret" in prets.columns:
for p in prets.to_dict("records"):
pid = id_entite(p.get("ID_Pret"))
prets_par_id[pid] = p
cli, gar = id_entite(p.get("ID_Client")), id_entite(p.get("ID_Garant"))
if ids_clients is not None and (not cli or cli not in ids_clients):
out.append(_controle("Erreur", "Prêt sans client connu", FEUILLE_PRETS, pid,
f"ID_Client '{cli or 'vide'}' introuvable dans Clients_KYC."))
if ids_garants is not None and gar and gar not in ids_garants:
out.append(_controle("Erreur", "Garant introuvable", FEUILLE_PRETS, pid,
f"ID_Garant '{gar}' introuvable dans Garants_KYC."))
statut = str(p.get("Statut", "")).strip().upper()
if statut == "UPDATED":
out.append(_controle("Erreur", "Version de prêt manquante", FEUILLE_PRETS, pid,
"Statut UPDATED mais aucune version dans Prets_Update."))
for racine in info_prets.get("versions_orphelines", []):
out.append(_controle("Erreur", "Version orpheline", FEUILLE_VERSIONS, racine,
"Version de prêt sans ligne d'origine dans Prets_Master."))
# 3. Remboursements : references et chaine des soldes
if not remb.empty and "ID_Pret" in remb.columns:
r = remb.copy()
r["_pret"] = r["ID_Pret"].map(id_entite)
r["_ordre"] = range(len(r))
for pid, grp in r.groupby("_pret", sort=False):
if not pid:
continue
pret = prets_par_id.get(pid)
if prets_par_id and pret is None:
out.append(_controle("Erreur", "Paiement sur prêt inconnu", FEUILLE_REMB, pid,
f"{len(grp)} paiement(s) rattaché(s) à un prêt absent de Prets_Master."))
if pret is not None and "ID_Client" in grp.columns:
autres = {id_entite(c) for c in grp["ID_Client"]} - {"", id_entite(pret.get("ID_Client"))}
if autres:
out.append(_controle("Avertissement", "Client du paiement différent du prêt", FEUILLE_REMB, pid,
f"Paiements enregistrés au nom de {', '.join(sorted(autres))}."))
out += _controles_soldes(pid, grp.sort_values("_ordre"), pret, info_prets, col_trx)
ordre = {g: i for i, g in enumerate(GRAVITES)}
df = pd.DataFrame(out, columns=COLONNES_CONTROLES)
if not df.empty:
df = df.sort_values(["Gravité", "Contrôle", "Référence"], key=lambda s: s.map(ordre) if s.name == "Gravité" else s)
return df.reset_index(drop=True)
def _controles_soldes(pid, grp, pret, info_prets, col_trx):
"""Regles d'ecriture de repayments.py / AnalyseRepayment.calculer_soldes :
Solde_Avant(1er) = Montant_Total ; Solde_Apres = max(0, Solde_Avant - (Principal + Interets)) ;
Solde_Avant(n) = Solde_Apres(n-1). Les penalites ne reduisent pas le solde."""
out = []
requis = {"Solde_Avant", "Solde_Apres"}
if not requis.issubset(grp.columns):
return out
modifie = pret is not None and int(nombre(pret.get("Nb_Versions", 1)) or 1) > 1
precedent = None
for i, ligne in enumerate(grp.to_dict("records")):
ref = f"{pid} / {id_entite(ligne.get(col_trx)) or 'ligne ' + str(i + 1)}" if col_trx else pid
avant, apres = nombre(ligne.get("Solde_Avant")), nombre(ligne.get("Solde_Apres"))
if math.isnan(avant) or math.isnan(apres):
out.append(_controle("Avertissement", "Solde illisible", FEUILLE_REMB, ref, "Solde_Avant ou Solde_Apres vide."))
precedent = None
continue
dette = sum(0.0 if math.isnan(x) else x for x in (nombre(ligne.get("Montant_Principal")), nombre(ligne.get("Montant_Interets"))))
if {"Montant_Principal", "Montant_Interets"}.issubset(grp.columns):
attendu = max(0.0, avant - dette)
if abs(attendu - apres) > TOLERANCE_XOF:
out.append(_controle("Erreur", "Solde après incohérent", FEUILLE_REMB, ref,
f"Solde_Avant {avant:,.0f} − (principal + intérêts) {dette:,.0f} = {attendu:,.0f}, "
f"mais Solde_Apres = {apres:,.0f}.".replace(",", " ")))
if i == 0 and pret is not None:
montants = info_prets.get("montants_versions", {}).get(pid) or [nombre(pret.get("Montant_Total"))]
montants = [m for m in montants if not math.isnan(m)]
if montants and all(abs(avant - m) > TOLERANCE_XOF for m in montants):
connus = " / ".join(f"{m:,.0f}" for m in montants).replace(",", " ")
out.append(_controle("Erreur", "Solde de départ ≠ montant du prêt", FEUILLE_REMB, ref,
f"1er Solde_Avant = {avant:,.0f} ; Montant_Total du prêt = {connus}.".replace(",", " ")))
if precedent is not None and abs(avant - precedent) > TOLERANCE_XOF:
out.append(_controle("Avertissement" if modifie else "Erreur", "Rupture de la chaîne des soldes", FEUILLE_REMB, ref,
f"Solde_Avant {avant:,.0f} ≠ Solde_Apres précédent {precedent:,.0f}".replace(",", " ")
+ (" (prêt modifié entre-temps)." if modifie else ".")))
precedent = apres
if pret is not None and precedent is not None:
statut = str(pret.get("Statut", "")).strip().upper()
if statut == "TERMINE" and precedent > TOLERANCE_XOF:
out.append(_controle("Erreur", "Prêt terminé avec solde restant", FEUILLE_PRETS, pid,
f"Statut TERMINE mais dernier Solde_Apres = {precedent:,.0f} XOF.".replace(",", " ")))
elif statut == "ACTIF" and precedent <= TOLERANCE_XOF:
out.append(_controle("Avertissement", "Prêt soldé encore actif", FEUILLE_PRETS, pid,
"Dernier Solde_Apres = 0 mais statut ACTIF."))
return out
# ============================================================================
# PERSONNES, IDENTIFIANTS PARTAGES, RESEAUX DE GARANTIE
# ============================================================================
def personnes(feuilles: dict) -> list:
"""Clients et garants : [{classe, id, nom, cle_nom, id_off, cle, ligne}] ; `cle` identifie
l'etre humain (meme piece d'identite = meme personne, sinon meme nom, sinon l'ID)."""
sortie = []
for feuille, (classe, col_id) in PERSONNES.items():
df = _df(feuilles, feuille)
if df.empty or col_id not in df.columns:
continue
for ligne in df.to_dict("records"):
pid = id_entite(ligne.get(col_id))
if not pid:
continue
nom = "" if vide(ligne.get(COL_NOM)) else str(ligne.get(COL_NOM)).strip()
id_off = normaliser_identifiant("ID_Officiel", ligne.get("ID_Officiel"))
cle = f"OFF:{id_off}" if id_off else (f"NOM:{cle_texte(nom)}" if cle_texte(nom) else f"{classe}:{pid}")
sortie.append({"classe": classe, "id": pid, "nom": nom, "cle_nom": cle_texte(nom),
"id_off": id_off, "cle": cle, "ligne": ligne})
return sortie
def _libelle(p):
return f"{p['classe']} {p['id']}" + (f" ({p['nom']})" if p["nom"] else "")
def identifiants_partages(feuilles: dict) -> pd.DataFrame:
"""Un identifiant porte par plus de personnes distinctes que le seuil tolere.
Pour la piece d'identite, deux personnes sont distinctes si leurs noms different ;
pour les autres identifiants, si leur piece d'identite (sinon leur nom) differe."""
pers = personnes(feuilles)
lignes = []
for type_id in TYPES_IDENTIFIANTS:
groupes = {}
for p in pers:
valeur = p["ligne"].get(type_id)
norme = normaliser_identifiant(type_id, valeur)
if norme:
groupes.setdefault(norme, []).append((p, valeur))
seuil = SEUILS_PARTAGE.get(type_id, 1)
for norme, membres in groupes.items():
if type_id == "ID_Officiel":
distincts = {(p["cle_nom"] or f"{p['classe']}:{p['id']}") for p, _ in membres}
else:
distincts = {p["cle"] for p, _ in membres}
if len(distincts) > seuil:
lignes.append({
"Type": type_id, "Valeur": masquer(type_id, membres[0][1]), "_norme": norme,
"Nb personnes": len(distincts), "Seuil": seuil,
"Dossiers": ", ".join(sorted({_libelle(p) for p, _ in membres})),
"_ids": sorted({f"{p['classe']}:{p['id']}" for p, _ in membres}),
})
colonnes = ["Type", "Valeur", "Nb personnes", "Seuil", "Dossiers", "_norme", "_ids"]
return pd.DataFrame(lignes, columns=colonnes).sort_values(["Nb personnes", "Type"], ascending=[False, True]).reset_index(drop=True) \
if lignes else pd.DataFrame(columns=colonnes)
def reseaux_garantie(feuilles: dict) -> pd.DataFrame:
"""Auto-garantie, garantie croisee (A garantit B et B garantit A), garant de nombreux emprunteurs."""
colonnes = ["Gravité", "Signal", "Détail", "_ids"]
prets = _df(feuilles, FEUILLE_PRETS)
if prets.empty or not {"ID_Client", "ID_Garant"}.issubset(prets.columns):
return pd.DataFrame(columns=colonnes)
pers = personnes(feuilles)
par_id = {(p["classe"], p["id"]): p for p in pers}
liens = {} # (cle emprunteur, cle garant) -> [prets]
for p in prets.to_dict("records"):
emp = par_id.get(("Client", id_entite(p.get("ID_Client"))))
gar = par_id.get(("Garant", id_entite(p.get("ID_Garant"))))
if emp and gar:
liens.setdefault((emp["cle"], gar["cle"]), []).append((id_entite(p.get("ID_Pret")), emp, gar))
lignes, vus = [], set()
emprunteurs_par_garant = {}
for (c_emp, c_gar), items in liens.items():
_, emp, gar = items[0]
ids_prets = sorted(i[0] for i in items)
if c_emp == c_gar:
lignes.append({"Gravité": "Erreur", "Signal": "Auto-garantie",
"Détail": f"{_libelle(emp)} se porte garant de son propre prêt via {_libelle(gar)} ({', '.join(ids_prets)}).",
"_ids": [f"Client:{emp['id']}", f"Garant:{gar['id']}"]})
emprunteurs_par_garant.setdefault(c_gar, {})[c_emp] = (emp, gar)
inverse = (c_gar, c_emp)
if inverse in liens and frozenset((c_emp, c_gar)) not in vus and c_emp != c_gar:
vus.add(frozenset((c_emp, c_gar)))
_, emp2, gar2 = liens[inverse][0]
lignes.append({"Gravité": "Avertissement", "Signal": "Garantie croisée",
"Détail": f"{_libelle(emp)} et {_libelle(emp2)} se garantissent mutuellement.",
"_ids": [f"Client:{emp['id']}", f"Client:{emp2['id']}", f"Garant:{gar['id']}", f"Garant:{gar2['id']}"]})
for c_gar, emps in emprunteurs_par_garant.items():
if len(emps) >= SEUIL_GARANT_MULTI:
gar = next(iter(emps.values()))[1]
noms = ", ".join(sorted(_libelle(e) for e, _ in emps.values()))
lignes.append({"Gravité": "Avertissement", "Signal": "Garant multi-emprunteurs",
"Détail": f"{_libelle(gar)} garantit {len(emps)} emprunteurs : {noms}.",
"_ids": [f"Garant:{gar['id']}"] + [f"Client:{e['id']}" for e, _ in emps.values()]})
return pd.DataFrame(lignes, columns=colonnes)
# ============================================================================
# VUE 360
# ============================================================================
def rechercher_personnes(feuilles: dict, recherche: str, limite: int = 10) -> list:
"""Clients/garants correspondant a un ID exact ou a un nom (tolerant aux fautes)."""
requete = str(recherche or "").strip()
if not requete:
return []
pers = personnes(feuilles)
exact = [p for p in pers if p["id"] == requete.upper()]
if exact:
return exact
cle = cle_texte(requete)
if not cle:
return []
scores = []
for p in pers:
if not p["cle_nom"]:
continue
if cle in p["cle_nom"] or all(m in p["cle_nom"].split() for m in cle.split()):
score = 100
elif _fuzz is not None:
score = max(_fuzz.token_set_ratio(cle, p["cle_nom"]), _fuzz.partial_ratio(cle, p["cle_nom"]))
else:
score = 0
if score >= 85:
scores.append((score, p))
scores.sort(key=lambda x: (-x[0], x[1]["nom"]))
return [p for _, p in scores[:limite]]
def fiche_personne(feuilles: dict, personne: dict, partages: pd.DataFrame = None,
reseaux: pd.DataFrame = None, controles: pd.DataFrame = None) -> dict:
"""Vue 360 d'une personne (toutes ses identites client/garant, meme etre humain)."""
pers = personnes(feuilles)
identites = [p for p in pers if p["cle"] == personne["cle"]] or [personne]
ids_client = {p["id"] for p in identites if p["classe"] == "Client"}
ids_garant = {p["id"] for p in identites if p["classe"] == "Garant"}
noeuds = {f"{p['classe']}:{p['id']}" for p in identites}
prets = _df(feuilles, FEUILLE_PRETS)
remb = _df(feuilles, FEUILLE_REMB)
clients_par_id = {p["id"]: p for p in pers if p["classe"] == "Client"}
garants_par_id = {p["id"]: p for p in pers if p["classe"] == "Garant"}
def _resume_pret(p, role):
pid = id_entite(p.get("ID_Pret"))
paiements = remb[remb["ID_Pret"].map(id_entite) == pid] if not remb.empty and "ID_Pret" in remb.columns else pd.DataFrame()
verse = paiements["Montant_Verse"].map(nombre).sum() if "Montant_Verse" in paiements.columns else float("nan")
dernier = nombre(paiements["Solde_Apres"].iloc[-1]) if "Solde_Apres" in paiements.columns and len(paiements) else float("nan")
retards = int((paiements["Jours_Retard"].map(nombre) > 0).sum()) if "Jours_Retard" in paiements.columns else 0
emp = clients_par_id.get(id_entite(p.get("ID_Client")))
gar = garants_par_id.get(id_entite(p.get("ID_Garant")))
return {
"Rôle": role, "Prêt": pid, "Version courante": p.get("ID_Pret_Courant", pid),
"Statut": str(p.get("Statut", "")).strip(), "Déblocage": p.get("Date_Deblocage", ""),
"Capital": nombre(p.get("Montant_Capital")), "Montant total": nombre(p.get("Montant_Total")),
"Versé": verse, "Solde restant": dernier, "Paiements": len(paiements), "En retard": retards,
"Emprunteur": _libelle(emp) if emp else id_entite(p.get("ID_Client")),
"Garant": _libelle(gar) if gar else (id_entite(p.get("ID_Garant")) or "—"),
}
lignes_prets = []
if not prets.empty:
for p in prets.to_dict("records"):
if id_entite(p.get("ID_Client")) in ids_client:
lignes_prets.append(_resume_pret(p, "Emprunteur"))
if id_entite(p.get("ID_Garant")) in ids_garant:
lignes_prets.append(_resume_pret(p, "Garant"))
ids_prets = {l["Prêt"] for l in lignes_prets}
paiements = pd.DataFrame()
if not remb.empty and "ID_Pret" in remb.columns and ids_prets:
paiements = remb[remb["ID_Pret"].map(id_entite).isin(ids_prets)].copy()
def _filtre_ids(df):
if df is None or df.empty or "_ids" not in df.columns:
return pd.DataFrame()
return df[df["_ids"].map(lambda ids: bool(noeuds & set(ids)))]
controles_lies = pd.DataFrame()
if controles is not None and not controles.empty:
motifs = ids_prets | ids_client | ids_garant
controles_lies = controles[controles["Référence"].map(lambda r: any(m and m in str(r) for m in motifs))]
principal = identites[0]
identite = proteger_proprietes({k: v for k, v in principal["ligne"].items() if not vide(v)})
return {
"nom": principal["nom"], "identites": [_libelle(p) for p in identites], "noeuds": sorted(noeuds),
"identite": identite, "prets": pd.DataFrame(lignes_prets), "paiements": paiements,
"identifiants_partages": _filtre_ids(partages), "reseaux": _filtre_ids(reseaux),
"controles": controles_lies,
}
def _xof(v):
return "—" if v is None or (isinstance(v, float) and math.isnan(v)) else f"{v:,.0f} XOF".replace(",", " ")
def fiche_texte(fiche: dict) -> str:
"""Resume texte (donnees sensibles masquees) d'une fiche 360, pour l'agent IA."""
lignes = [f"PERSONNE : {fiche['nom'] or '?'}", f"Identités : {', '.join(fiche['identites'])}"]
for k in ("Telephone", "Email", "ID_Officiel", "Profession", "Secteur_Activite", "Ville", "Adresse"):
if k in fiche["identite"]:
lignes.append(f"{k} : {fiche['identite'][k]}")
prets = fiche["prets"]
if prets.empty:
lignes.append("Aucun prêt (ni comme emprunteur, ni comme garant).")
else:
lignes.append(f"PRÊTS ({len(prets)}) :")
for p in prets.to_dict("records"):
lignes.append(
f"- {p['Prêt']} [{p['Rôle']}] statut {p['Statut'] or '?'}, capital {_xof(p['Capital'])}, "
f"total dû {_xof(p['Montant total'])}, versé {_xof(p['Versé'])}, solde {_xof(p['Solde restant'])}, "
f"{p['Paiements']} paiement(s) dont {p['En retard']} en retard ; emprunteur {p['Emprunteur']}, garant {p['Garant']}")
for titre, cle, champ in (("IDENTIFIANTS PARTAGÉS", "identifiants_partages", None), ("RÉSEAUX DE GARANTIE", "reseaux", "Détail")):
df = fiche[cle]
if not df.empty:
lignes.append(f"{titre} :")
for r in df.to_dict("records"):
lignes.append(f"- {r[champ]}" if champ else f"- {r['Type']} {r['Valeur']} partagé par {r['Dossiers']}")
if not fiche["controles"].empty:
lignes.append("ANOMALIES DE DONNÉES :")
for r in fiche["controles"].head(10).to_dict("records"):
lignes.append(f"- [{r['Gravité']}] {r['Contrôle']} — {r['Référence']} : {r['Détail']}")
return "\n".join(lignes)