Spaces:
Running
Running
Claude Opus 5.5
feat(ontology): module serieux (vue 360, fraude, coherence, terminal IA fiable)
c6c39e9 Download src/core/kg_data.py from klydekushy/Vortex-Flux: direct link, hf CLI and curl.
- Browser
- Download file 34 kB
-
https://huggingface.co/spaces/klydekushy/Vortex-Flux/resolve/main/src/core/kg_data.py
- Command line
-
hf download hf://spaces/klydekushy/Vortex-Flux/src/core/kg_data.py
-
curl -L -o kg_data.py https://huggingface.co/spaces/klydekushy/Vortex-Flux/resolve/main/src/core/kg_data.py
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) | |