Spaces:
Running
Running
Claude Haiku 5.5
feat(jasmine): chat et contenu de la carte Logistique (Mapbox) transmis au chat
57c9004 Download src/modules/map_dashboard.py from klydekushy/Vortex-Flux: direct link, hf CLI and curl.
- Browser
- Download file 12.8 kB
-
https://huggingface.co/spaces/klydekushy/Vortex-Flux/resolve/main/src/modules/map_dashboard.py
- Command line
-
hf download hf://spaces/klydekushy/Vortex-Flux/src/modules/map_dashboard.py
-
curl -L -o map_dashboard.py https://huggingface.co/spaces/klydekushy/Vortex-Flux/resolve/main/src/modules/map_dashboard.py
12.8 kB
| import streamlit as st | |
| import pandas as pd | |
| import time | |
| import requests | |
| import datetime | |
| from core.sheets_cache import get_cached_dfs_batch, get_cached_df | |
| from core import liens_logistique | |
| from modules import carte_html | |
| from core.email_client import get_credential as _get_credential | |
| from core import graphiques_contexte | |
| from modules.jasmine_widget import render_scoped_widget | |
| from core.errors import log_swallowed, est_quota | |
| # --- CONSTANTES --- | |
| GEOLOC_OVERRIDES_SHEET = "Geoloc_Overrides" | |
| GEOLOC_OVERRIDES_HEADERS = ["ID", "Lat", "Lon", "Source", "Date", "Note"] | |
| # Feuilles lues pour le croisement (adresses, IP, telephones...) — toutes via le cache. | |
| FEUILLES_RENSEIGNEMENT = ["Clients_KYC", "Garants_KYC", "Prets_Master", "Prets_Requests", | |
| "Signatures_Contrats", "Geoloc_Verifications", "AUDIT_LOG"] | |
| # Colonnes d'identite gardees en TEXTE brut (pas de numerisation : telephones, pieces, IP). | |
| COLONNES_TEXTE = {"Telephone", "Tel_Urgence", "WhatsApp_Urgence", "ID_Officiel", "Numero_Fiscal", | |
| "Employeur", "Email", "Email_Urgence", "IP_Client", "IP_Demande", "IP_Signature", | |
| "IP_Preteur", "IP", "GPS_Signature", "GPS_Preteur"} | |
| # Mapping nom de pays (LISTE_PAYS de kyc_form.py) -> code ISO2, pour cibler | |
| # correctement le geocodeur. Un pays absent de ce mapping (ex: "Autre") ne | |
| # doit JAMAIS retomber sur 'sn' par defaut : ca casse le geocodage de tous | |
| # les clients hors Senegal. | |
| PAYS_VERS_ISO2 = { | |
| "sénégal": "sn", "senegal": "sn", | |
| "cameroun": "cm", | |
| "gabon": "ga", | |
| "congo": "cg", | |
| "guinée": "gn", "guinee": "gn", | |
| "côte d'ivoire": "ci", "cote d'ivoire": "ci", | |
| "mali": "ml", | |
| "tchad": "td", | |
| "france": "fr", | |
| "canada": "ca", | |
| "maroc": "ma", | |
| } | |
| def _pays_to_iso2(pays: str): | |
| """Traduit un nom de pays (LISTE_PAYS de kyc_form.py) en code ISO2 pour | |
| LocationIQ. Retourne None si inconnu — NE JAMAIS retomber sur 'sn' par | |
| defaut, ca casse le geocodage des clients hors Senegal (Gabon, Cameroun, | |
| France, Maroc, Nigeria... presents dans les donnees reelles).""" | |
| if not pays: | |
| return None | |
| return PAYS_VERS_ISO2.get(str(pays).strip().lower()) | |
| def _build_full_address(row) -> str: | |
| """Concatene Adresse + Quartier + Ville + Pays_Residence pour lever | |
| l'ambiguite d'une adresse seule (ex: 'Fass batiment' existe a la fois a | |
| Dakar et pres de Kaolack — sans le quartier/ville, le geocodeur tape au | |
| hasard).""" | |
| parts = [] | |
| for col in ("Adresse", "Quartier", "Ville", "Pays_Residence"): | |
| val = str(row.get(col, "")).strip() | |
| if val and val.upper() not in ("", "-- À SÉLECTIONNER --", "NAN"): | |
| parts.append(val) | |
| return ", ".join(parts) | |
| def geocode_addresses(df_clients, id_col='ID_Client'): | |
| """Géocodage via LocationIQ, sur l'adresse complete (adresse + quartier + | |
| ville + pays) et un code pays dynamique par client.""" | |
| if 'Adresse' not in df_clients.columns or id_col not in df_clients.columns: | |
| return pd.DataFrame() | |
| LOCATIONIQ_TOKEN = _get_credential("LOCATIONIQ_API_KEY") | |
| if not LOCATIONIQ_TOKEN: | |
| return pd.DataFrame() | |
| geocoded_data = [] | |
| for index, row in df_clients.iterrows(): | |
| full_address = _build_full_address(row) | |
| address = str(row['Adresse']).strip() | |
| client_id = row[id_col] | |
| if not full_address: | |
| continue | |
| try: | |
| url = "https://us1.locationiq.com/v1/search.php" | |
| params = { | |
| 'key': LOCATIONIQ_TOKEN, | |
| 'q': full_address, | |
| 'format': 'json', | |
| 'limit': 1, | |
| 'accept-language': 'fr' | |
| } | |
| iso2 = _pays_to_iso2(row.get('Pays_Residence')) | |
| if iso2: | |
| params['countrycodes'] = iso2 | |
| response = requests.get(url, params=params, timeout=10) | |
| if response.status_code == 200: | |
| data = response.json() | |
| if data and len(data) > 0: | |
| result = data[0] | |
| lat = float(result['lat']) | |
| lon = float(result['lon']) | |
| geocoded_data.append({ | |
| "ID": client_id, | |
| "Nom": row.get('Nom_Complet', 'Client Inconnu'), | |
| "Adresse": address, | |
| "lat": lat, | |
| "lon": lon, | |
| "Revenus": row.get('Revenus_Mensuels', 0), | |
| "Email": row.get('Email', ''), | |
| }) | |
| time.sleep(1.2) | |
| except Exception as e: | |
| pass | |
| return pd.DataFrame(geocoded_data) | |
| def _ensure_worksheet(sh, title, headers): | |
| """Ouvre l'onglet ; le cree avec ses en-tetes s'il n'existe pas encore | |
| (meme convention que Prefiltre_Logs dans kyc_form.py).""" | |
| try: | |
| return sh.worksheet(title) | |
| except Exception: | |
| ws = sh.add_worksheet(title=title, rows=500, cols=max(len(headers), 5)) | |
| ws.append_row(headers) | |
| return ws | |
| def load_geoloc_overrides(_client, sheet_name): | |
| """Charge les corrections manuelles de position — tolerant a l'absence | |
| de l'onglet (la carte fonctionne sans, avec le geocodage automatique).""" | |
| try: | |
| sh = _client.open(sheet_name) | |
| ws = sh.worksheet(GEOLOC_OVERRIDES_SHEET) | |
| return pd.DataFrame(ws.get_all_records()) | |
| except Exception: | |
| return pd.DataFrame(columns=GEOLOC_OVERRIDES_HEADERS) | |
| def save_geoloc_override(client, sheet_name, id_client, lat, lon, note=""): | |
| """Enregistre/actualise une correction manuelle de position, persistante | |
| (prioritaire sur le geocodage automatique a chaque rechargement).""" | |
| sh = client.open(sheet_name) | |
| ws = _ensure_worksheet(sh, GEOLOC_OVERRIDES_SHEET, GEOLOC_OVERRIDES_HEADERS) | |
| records = ws.get_all_records() | |
| now_str = datetime.datetime.now().strftime("%d-%m-%Y %H:%M:%S") | |
| new_row = [id_client, lat, lon, "correction_manuelle", now_str, note] | |
| for i, rec in enumerate(records): | |
| if str(rec.get("ID", "")).strip() == str(id_client).strip(): | |
| ws.update(f"A{i + 2}:F{i + 2}", [new_row]) | |
| break | |
| else: | |
| ws.append_row(new_row) | |
| load_geoloc_overrides.clear() | |
| def _lire_feuilles(sh, sheet_name) -> dict: | |
| """Toutes les feuilles de renseignement en UN appel API (cache 60 s). Seules les feuilles | |
| existantes sont demandees : une feuille absente (ex. Geoloc_Verifications jamais creee) | |
| donne un DataFrame vide au lieu de faire echouer toute la lecture groupee.""" | |
| titres = {f["properties"]["title"] for f in sh.fetch_sheet_metadata().get("sheets", [])} | |
| presentes = [nom for nom in FEUILLES_RENSEIGNEMENT if nom in titres] | |
| dfs = {nom: pd.DataFrame() for nom in FEUILLES_RENSEIGNEMENT} | |
| if presentes: | |
| ignore = {nom: COLONNES_TEXTE for nom in presentes} | |
| dfs.update(get_cached_dfs_batch(sh, sheet_name, presentes, numericise_ignore_columns=ignore)) | |
| return dfs | |
| def resume_carte(donnees: dict, max_lieux: int = 40, max_groupes: int = 20) -> dict: | |
| """Résumé compact de la carte (lieux, personnes, liens) envoyé au chat JASMINE.""" | |
| lieux = sorted(donnees["lieux"], key=lambda l: (-{"critique": 3, "eleve": 2, "attention": 1}.get(l["niveau"], 0), -l["n"])) | |
| return { | |
| "lieux": [{ | |
| "label": l["label"], "lat": l["lat"], "lon": l["lon"], "niveau_risque": l["niveau"], | |
| "prets_en_risque": l["risque"], "adresse_declaree": l["declare"], "nb_personnes": l["n"], | |
| "personnes": [{"id": m["id"], "nom": m["nom"], "type": m["kind"], "statut_pret": m["statut"], | |
| "roles": m["roles"], "tel": m["tel"], "ip": m["ip"], "email": m["email"]} | |
| for m in l["membres"][:8]], | |
| } for l in lieux[:max_lieux]], | |
| "liens": [{ | |
| "type": g["libelle"], "valeur": g["valeur"], "niveau": g["niveau"], "score": g["score"], | |
| "garant_emprunteur": g["garant_emprunteur"], | |
| "personnes": [e["id"] for e in g["entites"]], "nb_lieux": len(g["lieux"]), | |
| } for g in donnees["groupes"][:max_groupes]], | |
| } | |
| def _positions_declarees(df_geo, df_overrides): | |
| """{id: (lat, lon)} : geocodage automatique, corrige par les positions manuelles.""" | |
| pos = {} | |
| if df_geo is not None and not df_geo.empty: | |
| for _, r in df_geo.iterrows(): | |
| pos[str(r["ID"])] = (float(r["lat"]), float(r["lon"])) | |
| if df_overrides is not None and not df_overrides.empty and "ID" in df_overrides.columns: | |
| for _, r in df_overrides.iterrows(): | |
| try: | |
| pos[str(r["ID"])] = (float(str(r["Lat"]).replace(",", ".")), float(str(r["Lon"]).replace(",", "."))) | |
| except (TypeError, ValueError): | |
| continue | |
| return pos | |
| def show_map_dashboard(client, sheet_name): | |
| """Vue de renseignement : une carte plein ecran ou chaque point est un lieu (adresse | |
| declaree ou position observee). Clic = dossier du lieu + liens vers les autres lieux | |
| partageant une IP, un telephone, un email, une piece, une adresse ou un employeur.""" | |
| st.markdown('<div id="renseignement-logistique">', unsafe_allow_html=True) | |
| st.markdown("### RENSEIGNEMENT TERRAIN — CARTE DES LIENS") | |
| st.caption("Croisement des adresses, IP, téléphones, emails et pièces (KYC, demandes, signatures, " | |
| "vérifications, journal d'audit). Un lien est un indice à vérifier, jamais une preuve.") | |
| try: | |
| sh = client.open(sheet_name) | |
| dfs = _lire_feuilles(sh, sheet_name) | |
| except Exception as e: | |
| if est_quota(e): | |
| raise # quota Sheets : bandeau jaune gere par streamlit_app.py | |
| log_swallowed("Lecture des feuilles de renseignement echouee", e) | |
| st.error(f"Erreur de lecture : {e}") | |
| st.markdown('</div>', unsafe_allow_html=True) | |
| return | |
| df_clients = dfs["Clients_KYC"] | |
| if df_clients.empty: | |
| st.info("Aucune donnée client.") | |
| st.markdown('</div>', unsafe_allow_html=True) | |
| return | |
| # === GEOCODAGE (clients + garants) — cache 1 h, corrections manuelles prioritaires === | |
| with st.spinner("Triangulation des adresses..."): | |
| df_geo_clients = geocode_addresses(df_clients) | |
| df_geo_garants = geocode_addresses(dfs["Garants_KYC"], 'ID_Garant') if not dfs["Garants_KYC"].empty else None | |
| if not _get_credential("LOCATIONIQ_API_KEY"): | |
| st.warning("[ATTENTION] Clé LOCATIONIQ_API_KEY absente des secrets — seules les positions GPS observées sont placées.") | |
| geo = [d for d in (df_geo_clients, df_geo_garants) if d is not None and not d.empty] | |
| positions = _positions_declarees(pd.concat(geo, ignore_index=True) if geo else None, | |
| load_geoloc_overrides(client, sheet_name)) | |
| # === ANALYSE DES LIENS === | |
| resultat = liens_logistique.analyser({ | |
| "clients": df_clients, "garants": dfs["Garants_KYC"], "prets": dfs["Prets_Master"], | |
| "requests": dfs["Prets_Requests"], "signatures": dfs["Signatures_Contrats"], | |
| "verifs": dfs["Geoloc_Verifications"], "audit": dfs["AUDIT_LOG"], | |
| }, positions) | |
| stats = resultat["stats"] | |
| m1, m2, m3, m4 = st.columns(4) | |
| m1.metric("LIEUX", f"{stats['lieux']}") | |
| m2.metric("PERSONNES", f"{stats['entites']}") | |
| m3.metric("LIENS ÉLEVÉS / CRITIQUES", f"{stats['critiques']}") | |
| m4.metric("PRÊTS EN RETARD / LITIGE", f"{stats['a_risque']}") | |
| if not resultat["lieux"]: | |
| st.info("Aucun lieu à afficher : ni adresse géocodée, ni position observée.") | |
| st.markdown('</div>', unsafe_allow_html=True) | |
| return | |
| # === CARTE PLEIN ECRAN (tout en HTML/JS, aucune lecture Sheets au clic) === | |
| donnees = carte_html.donnees_carte(resultat) | |
| # Contenu de la carte pour le chat JASMINE (la carte est un components.html, pas un graphique Streamlit) | |
| carte_resume = resume_carte(donnees) | |
| graphiques_contexte.enregistrer_carte("Carte de renseignement — liens entre lieux", carte_resume) | |
| token = _get_credential("MAPBOX_ACCESS_TOKEN") | |
| if not token: | |
| st.warning("[ATTENTION] Clé MAPBOX_ACCESS_TOKEN absente des secrets — carte indisponible.") | |
| else: | |
| st.components.v1.html(carte_html.construire_html(token, donnees, hauteur=760), height=780) | |
| render_scoped_widget( | |
| client, sheet_name, | |
| scope_label="Carte de renseignement logistique (lieux, personnes, IP, téléphones, liens entre dossiers)", | |
| context_data={"statistiques": stats, "carte": carte_resume}, | |
| key_prefix="logistique_carte", | |
| ) | |
| st.markdown('</div>', unsafe_allow_html=True) | |