""" MODULE: COCKPIT DE DIRECTION - PALANTIR GOTHAM EDITION ====================================================== Responsabilité : Supervision stratégique temps réel et simulation de crise avancée Architecture : Command & Control Center pour décideurs hospitaliers Fonctionnalités : - Supervision multi-niveaux (Opérationnel / Tactique / Stratégique) - Simulateur de crise avec 7 scénarios (épidémie, grève, cyberattaque, etc.) - Analyse prédictive d'impact multi-variables - Recommandations automatiques basées sur l'IA - Génération de rapports exécutifs - Système d'alerte cascadé par criticité """ import streamlit as st import pandas as pd import numpy as np import plotly.graph_objects as go from plotly.subplots import make_subplots from datetime import datetime, timedelta import warnings warnings.filterwarnings('ignore') # ===================================================== # ============= MOTEUR DE SIMULATION ================== # ===================================================== class CrisisSimulator: """ Moteur de simulation de crise multi-scénarios avec analyse d'impact. """ # Définition des scénarios de crise SCENARIOS = { 'EPIDEMIE_GRIPPE': { 'label': 'Épidémie de Grippe Saisonnière', 'admissions_multiplier': 2.5, 'duree_jours': 45, 'services_impacts': ['URG', 'PED', 'REA', 'INF'], 'personnel_absence': 0.15, 'stock_consommation': 2.0, 'criticite': 'HIGH' }, 'PANDEMIE_COVID': { 'label': 'Pandémie Type COVID-19', 'admissions_multiplier': 4.0, 'duree_jours': 90, 'services_impacts': ['URG', 'REA', 'ISO', 'MED'], 'personnel_absence': 0.25, 'stock_consommation': 3.5, 'criticite': 'CRITICAL' }, 'CANICULE': { 'label': 'Canicule Extrême (>40°C)', 'admissions_multiplier': 1.8, 'duree_jours': 21, 'services_impacts': ['URG', 'REA', 'CAR'], 'personnel_absence': 0.10, 'stock_consommation': 1.5, 'criticite': 'HIGH' }, 'ACCIDENT_MASSE': { 'label': 'Accident de Grande Ampleur', 'admissions_multiplier': 3.5, 'duree_jours': 3, 'services_impacts': ['URG', 'REA', 'TRA'], 'personnel_absence': 0.05, 'stock_consommation': 4.0, 'criticite': 'CRITICAL' }, 'GREVE_PERSONNEL': { 'label': 'Grève du Personnel Soignant', 'admissions_multiplier': 0.6, 'duree_jours': 14, 'services_impacts': ['ALL'], 'personnel_absence': 0.40, 'stock_consommation': 0.7, 'criticite': 'HIGH' }, 'CYBERATTAQUE': { 'label': 'Cyberattaque Système Informatique', 'admissions_multiplier': 0.3, 'duree_jours': 7, 'services_impacts': ['ALL'], 'personnel_absence': 0.08, 'stock_consommation': 1.0, 'criticite': 'HIGH' }, 'RUPTURE_APPROVISIONNEMENT': { 'label': 'Rupture Critique Approvisionnement', 'admissions_multiplier': 0.8, 'duree_jours': 30, 'services_impacts': ['REA', 'TRA', 'CAR'], 'personnel_absence': 0.05, 'stock_consommation': 0.2, 'criticite': 'MEDIUM' } } def __init__(self, forecaster, ml_dataset): self.forecaster = forecaster self.df = ml_dataset self.baseline_metrics = self._compute_baseline() def _compute_baseline(self): """Calcul des métriques de référence (état actuel)""" recent = self.df.tail(30) # 30 derniers jours return { 'admissions_moy': recent['nb_admissions'].mean(), 'admissions_max': recent['nb_admissions'].max(), 'taux_occupation_moy': recent['taux_occupation'].mean(), 'tension_personnel_moy': recent['tension_personnel'].mean(), 'services_actifs': recent['service_code'].nunique() } def simulate_scenario(self, scenario_key, intensity_factor=1.0): """ Simule l'impact d'un scénario de crise. Args: scenario_key: Clé du scénario à simuler intensity_factor: Facteur d'intensité (0.5 à 2.0) Returns: Dictionnaire avec projections et impact """ scenario = self.SCENARIOS[scenario_key].copy() # Application du facteur d'intensité scenario['admissions_multiplier'] *= intensity_factor scenario['personnel_absence'] *= intensity_factor scenario['stock_consommation'] *= intensity_factor # Génération prédictions futures (baseline) future_dates, baseline_predictions, _ = self.forecaster.generate_future_forecast_recursive( months=int(scenario['duree_jours'] / 30) + 1 ) # Application de l'impact du scénario impact_curve = self._generate_impact_curve( len(baseline_predictions), scenario['duree_jours'], scenario['admissions_multiplier'] ) crisis_predictions = baseline_predictions * impact_curve # Calcul des métriques d'impact impact_metrics = self._compute_impact_metrics( baseline_predictions, crisis_predictions, scenario ) # Génération recommandations recommendations = self._generate_recommendations(scenario, impact_metrics) return { 'scenario': scenario, 'dates': future_dates[:scenario['duree_jours']], 'baseline': baseline_predictions[:scenario['duree_jours']], 'crisis': crisis_predictions[:scenario['duree_jours']], 'impact_curve': impact_curve[:scenario['duree_jours']], 'metrics': impact_metrics, 'recommendations': recommendations } def _generate_impact_curve(self, n_days, crisis_duration, multiplier): """ Génère une courbe d'impact réaliste (montée progressive, pic, décrue). """ curve = np.ones(n_days) if crisis_duration >= n_days: crisis_duration = n_days # Phase 1: Montée (20% de la durée) ramp_up = int(crisis_duration * 0.2) curve[:ramp_up] = np.linspace(1.0, multiplier, ramp_up) # Phase 2: Pic (60% de la durée) peak_duration = int(crisis_duration * 0.6) curve[ramp_up:ramp_up + peak_duration] = multiplier # Phase 3: Décrue (20% de la durée) ramp_down = crisis_duration - ramp_up - peak_duration if ramp_down > 0: curve[ramp_up + peak_duration:crisis_duration] = np.linspace( multiplier, 1.0, ramp_down ) return curve def _compute_impact_metrics(self, baseline, crisis, scenario): """Calcul des métriques d'impact détaillées""" # Admissions baseline_total = baseline.sum() crisis_total = crisis.sum() admissions_delta = crisis_total - baseline_total admissions_peak = crisis.max() # Estimation taux d'occupation (règle simple) baseline_occupation = self.baseline_metrics['taux_occupation_moy'] crisis_occupation = min(baseline_occupation * scenario['admissions_multiplier'], 1.0) occupation_delta = crisis_occupation - baseline_occupation # Personnel personnel_disponible_baseline = 1.0 personnel_disponible_crisis = 1.0 - scenario['personnel_absence'] personnel_delta = scenario['personnel_absence'] # Estimation lits nécessaires lits_necessaires_crisis = int(crisis.mean() * 1.5) # Heuristique # Criticité globale if occupation_delta > 0.3 or scenario['criticite'] == 'CRITICAL': overall_criticite = 'CRITICAL' elif occupation_delta > 0.15 or scenario['criticite'] == 'HIGH': overall_criticite = 'HIGH' else: overall_criticite = 'MEDIUM' return { 'admissions_delta': admissions_delta, 'admissions_delta_pct': (admissions_delta / baseline_total) * 100, 'admissions_peak': admissions_peak, 'occupation_baseline': baseline_occupation, 'occupation_crisis': crisis_occupation, 'occupation_delta': occupation_delta, 'occupation_delta_pct': (occupation_delta / baseline_occupation) * 100, 'personnel_delta': personnel_delta, 'personnel_delta_pct': personnel_delta * 100, 'lits_necessaires': lits_necessaires_crisis, 'stock_multiplier': scenario['stock_consommation'], 'criticite': overall_criticite, 'duree_jours': scenario['duree_jours'] } def _generate_recommendations(self, scenario, metrics): """Génère des recommandations automatiques basées sur l'impact""" recs = [] # Recommandations admissions if metrics['admissions_delta_pct'] > 50: recs.append({ 'categorie': 'CAPACITE', 'priorite': 'IMMEDIATE', 'action': f"Augmenter capacité de {int(metrics['lits_necessaires'])} lits", 'justification': f"Pic d'admissions prévu : +{metrics['admissions_delta_pct']:.0f}%" }) # Recommandations personnel if metrics['personnel_delta_pct'] > 20: recs.append({ 'categorie': 'PERSONNEL', 'priorite': 'IMMEDIATE', 'action': f"Mobiliser {int(metrics['personnel_delta_pct'])}% personnel de réserve", 'justification': f"Absence personnel critique : {metrics['personnel_delta_pct']:.0f}%" }) # Recommandations stock if metrics['stock_multiplier'] > 2.0: recs.append({ 'categorie': 'LOGISTIQUE', 'priorite': 'URGENT', 'action': f"Multiplier stocks par {metrics['stock_multiplier']:.1f}", 'justification': "Consommation matériel exponentiellement accrue" }) # Recommandations occupation if metrics['occupation_delta_pct'] > 30: recs.append({ 'categorie': 'OPERATIONS', 'priorite': 'IMMEDIATE', 'action': "Activer plan blanc ou plan bleu", 'justification': f"Saturation prévue : {metrics['occupation_crisis']*100:.0f}%" }) # Recommandations services impactés services_str = ', '.join(scenario['services_impacts'][:3]) if len(scenario['services_impacts']) > 3: services_str += f" (+{len(scenario['services_impacts'])-3} autres)" recs.append({ 'categorie': 'COORDINATION', 'priorite': 'URGENT', 'action': f"Renforcer services : {services_str}", 'justification': "Services en première ligne de la crise" }) # Recommandation communication if metrics['criticite'] == 'CRITICAL': recs.append({ 'categorie': 'COMMUNICATION', 'priorite': 'IMMEDIATE', 'action': "Activer cellule de crise et communication externe", 'justification': "Niveau de criticité maximal atteint" }) return recs # ===================================================== # ============= SUPERVISION TEMPS RÉEL ================ # ===================================================== class RealtimeMonitor: """ Système de supervision temps réel multi-niveaux. """ def __init__(self, ml_dataset, forecaster): self.df = ml_dataset self.forecaster = forecaster self.current_state = self._analyze_current_state() def _analyze_current_state(self): """Analyse de l'état actuel (72 dernières heures)""" recent = self.df.tail(3) # 3 derniers jours last_24h = self.df.tail(1).iloc[0] # Métriques opérationnelles admissions_24h = last_24h['nb_admissions'] taux_occupation = last_24h['taux_occupation'] tension_personnel = last_24h['tension_personnel'] # Calcul des tendances (72h) if len(recent) >= 3: trend_admissions = ((recent['nb_admissions'].iloc[-1] / recent['nb_admissions'].iloc[0]) - 1) * 100 trend_occupation = ((recent['taux_occupation'].iloc[-1] / recent['taux_occupation'].iloc[0]) - 1) * 100 else: trend_admissions = 0 trend_occupation = 0 # Détection d'alertes alertes = [] if taux_occupation > 0.85: alertes.append({ 'niveau': 'CRITICAL', 'categorie': 'CAPACITE', 'message': f"Taux d'occupation critique : {taux_occupation*100:.1f}%" }) elif taux_occupation > 0.75: alertes.append({ 'niveau': 'WARNING', 'categorie': 'CAPACITE', 'message': f"Taux d'occupation élevé : {taux_occupation*100:.1f}%" }) if tension_personnel > 1.2: alertes.append({ 'niveau': 'CRITICAL', 'categorie': 'PERSONNEL', 'message': f"Tension personnel critique : {tension_personnel:.2f}" }) elif tension_personnel > 1.0: alertes.append({ 'niveau': 'WARNING', 'categorie': 'PERSONNEL', 'message': f"Tension personnel élevée : {tension_personnel:.2f}" }) if trend_admissions > 20: alertes.append({ 'niveau': 'WARNING', 'categorie': 'TENDANCE', 'message': f"Hausse admissions 72h : +{trend_admissions:.0f}%" }) # Score de santé global (0-100) health_score = 100 health_score -= min((taux_occupation - 0.6) * 100, 30) health_score -= min((tension_personnel - 0.8) * 50, 30) health_score -= min(abs(trend_occupation) * 0.5, 20) health_score = max(health_score, 0) return { 'admissions_24h': admissions_24h, 'taux_occupation': taux_occupation, 'tension_personnel': tension_personnel, 'trend_admissions': trend_admissions, 'trend_occupation': trend_occupation, 'alertes': alertes, 'health_score': health_score, 'timestamp': datetime.now() } def get_service_breakdown(self): """Répartition par service (Avec traduction automatique des codes numériques)""" recent = self.df.tail(30).copy() if 'service_code' not in recent.columns: return None # Mapping de secours (basé sur ton injecteur de données) # Permet de retraduire 0, 1, 2 en noms lisibles si l'IA a encodé les données SERVICE_MAP = { 0: "URG", 1: "MED", 2: "PED", 3: "REA", 4: "INF", 5: "ISO", 6: "CAR", 7: "NEU", 8: "TRA" } # Agrégation service_stats = recent.groupby('service_code', as_index=True).agg({ 'taux_occupation': 'mean', 'tension_personnel': 'mean', 'nb_admissions': 'sum' }).round(4) # --- BLOC DE CORRECTION D'AFFICHAGE --- # Si l'index est numérique (ex: 0, 1, 2), on applique le mapping if pd.api.types.is_numeric_dtype(service_stats.index): # On essaie de traduire, sinon on met "Svc N" pour ne pas planter new_index = service_stats.index.map(lambda x: SERVICE_MAP.get(int(x), f"Svc {x}")) service_stats.index = new_index # -------------------------------------- service_stats.columns = ['Taux Occupation', 'Tension Personnel', 'Admissions (30j)'] service_stats = service_stats.sort_values('Taux Occupation', ascending=False) return service_stats def predict_next_48h(self): """Prédiction 48h avec niveau de confiance""" future_dates, predictions, conf = self.forecaster.generate_future_forecast_recursive(months=1) # On ne garde que les 2 premiers jours next_48h = { 'dates': future_dates[:2], 'predictions': predictions[:2], 'lower': conf['lower'][:2], 'upper': conf['upper'][:2] } # Détection d'anomalies prévues current_avg = self.df['nb_admissions'].tail(7).mean() anomalies_48h = [] for i, pred in enumerate(next_48h['predictions']): if pred > current_avg * 1.3: anomalies_48h.append({ 'jour': i + 1, 'valeur': pred, 'ecart_pct': ((pred / current_avg) - 1) * 100 }) return next_48h, anomalies_48h # ===================================================== # ============= INTERFACE STREAMLIT =================== # ===================================================== def show_dashboard(client, sheet_name): """ Cockpit de Direction Niveau Palantir Gotham. """ st.markdown("# COCKPIT DE DIRECTION") st.caption("Supervision Temps Réel & Simulation Stratégique") # Vérification données if 'ml_features' not in st.session_state or 'forecaster' not in st.session_state: st.error("DONNEES ML OU MODELE MANQUANT. Executer d'abord Feature Engineering et Forecasting.") return df = st.session_state['ml_features'] forecaster = st.session_state['forecaster'] # Initialisation des moteurs monitor = RealtimeMonitor(df, forecaster) simulator = CrisisSimulator(forecaster, df) # ===== ONGLETS PRINCIPAUX ===== tab1, tab2 = st.tabs([ "Supervision Temps Reel", "Simulateur de Crise" ]) # ================================================================ # ONGLET 1 : SUPERVISION TEMPS RÉEL # ================================================================ with tab1: # ===== KPI HEADER ===== st.markdown("### Indicateurs Stratégiques") state = monitor.current_state col1, col2, col3, col4 = st.columns(4) # Taux Occupation occupation_color = "normal" if state['taux_occupation'] > 0.85: occupation_color = "inverse" elif state['taux_occupation'] > 0.75: occupation_color = "off" col1.metric( "Taux Occupation", f"{state['taux_occupation']*100:.1f}%", f"{state['trend_occupation']:+.1f}%", delta_color=occupation_color ) # Admissions col2.metric( "Admissions (Total)", f"{int(df['nb_admissions'].sum()):,}", help="Cumul toutes périodes" ) # Services Tendus services_data = monitor.get_service_breakdown() if services_data is not None: services_tendus = (services_data['Tension Personnel'] > 1.0).sum() else: services_tendus = 0 alertes_color = "normal" if services_tendus > 5: alertes_color = "inverse" elif services_tendus > 2: alertes_color = "off" col3.metric( "Services Tendus", services_tendus, delta_color=alertes_color, help="Services avec tension personnel > 1.0" ) # Ruptures Stock col4.metric( "Ruptures Stock", "0", delta_color="normal", help="Alertes stock critiques" ) st.divider() # ===== GRAPHIQUES PRINCIPAUX ===== col_left, col_right = st.columns([2, 1]) with col_left: st.markdown("### Taux d'Occupation : Realite vs IA") # Graphique historique + prédiction 48h hist = df.tail(90) next_48h, anomalies_48h = monitor.predict_next_48h() fig = go.Figure() # Historique fig.add_trace(go.Scatter( x=hist['date'], y=hist['taux_occupation'] * 100, name="Historique", line=dict(color='#4A9EFF', width=2), mode='lines' )) # Ligne de saturation fig.add_hline( y=100, line_dash="dash", line_color="#FF4444", annotation_text="Saturation", annotation_position="right" ) # Prédiction 48h fig.add_trace(go.Scatter( x=next_48h['dates'], y=[p * 100 for p in next_48h['predictions']], name="Tendance IA", line=dict(color='#00FF88', width=3, dash='dot'), mode='lines+markers', marker=dict(size=8) )) fig.update_layout( template="plotly_dark", height=400, hovermode="x unified", xaxis_title="", yaxis_title="Taux Occupation (%)", showlegend=True, legend=dict(orientation="h", yanchor="bottom", y=1.02, xanchor="right", x=1) ) st.plotly_chart(fig, use_container_width=True) with col_right: st.markdown("### Etat Services (31/12)") if services_data is not None: # Affichage sous forme de barres horizontales services_top = services_data.head(9) fig_services = go.Figure() # Barres de taux d'occupation colors = [] for val in services_top['Taux Occupation']: if val > 0.85: colors.append('#FF4444') elif val > 0.75: colors.append('#FFA500') else: colors.append('#00FF88') fig_services.add_trace(go.Bar( y=services_top.index, x=services_top['Taux Occupation'] * 100, orientation='h', marker=dict(color=colors), text=[f"{v*100:.1f}%" for v in services_top['Taux Occupation']], textposition='inside', textfont=dict(color='white', size=11), showlegend=False )) fig_services.update_layout( template="plotly_dark", height=400, xaxis_title="Taux Occupation (%)", yaxis_title="", xaxis=dict(range=[0, 15]), margin=dict(l=0, r=0, t=20, b=0) ) st.plotly_chart(fig_services, use_container_width=True) else: st.info("Donnees services non disponibles") st.divider() # ===== FLASH REPORT & RECOMMANDATIONS ===== st.markdown("### Flash Report & Recommandations") # Détermination du statut global if state['health_score'] > 80: status_label = "NOMINAL" status_color = "#00FF88" status_icon = "circle" elif state['health_score'] > 60: status_label = "SOUS SURVEILLANCE" status_color = "#FFA500" status_icon = "triangle-up" else: status_label = "SITUATION CRITIQUE" status_color = "#FF4444" status_icon = "triangle-up" col_status, col_actions = st.columns([2, 1]) with col_status: st.markdown( f"
" f"" f"{status_icon.upper()} {status_label} : Activite hospitaliere sous controle." f"
", unsafe_allow_html=True ) st.markdown("") # Recommandations if len(state['alertes']) == 0: st.success("Aucune action critique requise a court terme.") else: st.warning(f"{len(state['alertes'])} alerte(s) detectee(s)") for alerte in state['alertes']: niveau_emoji = { 'CRITICAL': 'red_circle', 'WARNING': 'large_orange_diamond' } st.write(f":{niveau_emoji.get(alerte['niveau'], 'white_circle')}: " f"**{alerte['categorie']}** - {alerte['message']}") with col_actions: if st.button("Alerter Equipes", type="primary", use_container_width=True): st.success("Notification envoyee aux responsables de services") if st.button("Export PDF", use_container_width=True): st.info("Rapport executif en preparation...") # ===== PRÉDICTIONS 48H ===== st.divider() st.markdown("### Anticipation 48h") if anomalies_48h: st.warning(f"ALERTE PREDICTIVE : {len(anomalies_48h)} anomalie(s) detectee(s) dans les 48h") for anom in anomalies_48h: st.write( f"J+{anom['jour']} : Pic prevu a {anom['valeur']:.0f} admissions " f"(+{anom['ecart_pct']:.0f}% vs moyenne actuelle)" ) else: st.success("Aucune anomalie majeure prevue dans les 48 prochaines heures") # ================================================================ # ONGLET 2 : SIMULATEUR DE CRISE # ================================================================ with tab2: st.markdown("### Bac a Sable (What-If Analysis)") col_params, col_results = st.columns([1, 2]) with col_params: st.markdown("#### 1. Parametres") st.caption("Modifiez les variables pour voir l'impact futur.") # Sélection du scénario scenario_options = { 'EPIDEMIE_GRIPPE': 'Epidemie de Grippe Saisonniere', 'PANDEMIE_COVID': 'Pandemie Type COVID-19', 'CANICULE': 'Canicule Extreme (>40C)', 'ACCIDENT_MASSE': 'Accident de Grande Ampleur', 'GREVE_PERSONNEL': 'Greve du Personnel Soignant', 'CYBERATTAQUE': 'Cyberattaque Systeme Informatique', 'RUPTURE_APPROVISIONNEMENT': 'Rupture Critique Approvisionnement' } selected_scenario = st.selectbox( "Choc d'Admissions", options=list(scenario_options.keys()), format_func=lambda x: scenario_options[x], index=0 ) # Intensité intensity = st.slider( "Intensite", min_value=0.5, max_value=2.0, value=1.0, step=0.1, help="Multiplicateur d'intensite du scenario" ) # Ouverture lits (simulation) lits_additionnels = st.slider( "Ouverture Lits", min_value=-10, max_value=20, value=0, step=1, help="Pourcentage de lits supplementaires" ) # Impact peak st.markdown("#### Impact Peak") st.metric("", "+0.0 pts", "Gain", delta_color="normal") with col_results: st.markdown("#### 2. Projection d'Impact (3 Mois)") # Simulation with st.spinner("Calcul de l'impact..."): result = simulator.simulate_scenario(selected_scenario, intensity) # Graphique d'impact fig_impact = go.Figure() # Ligne capacité max fig_impact.add_hline( y=100, line_dash="solid", line_color="#FF4444", line_width=1, annotation_text="Capacite Max", annotation_position="right" ) # Scénario actuel (baseline) fig_impact.add_trace(go.Scatter( x=result['dates'], y=result['baseline'], name="Scenario Actuel", line=dict(color='gray', width=1, dash='dot'), mode='lines', opacity=0.6 )) # Scénario simulé fig_impact.add_trace(go.Scatter( x=result['dates'], y=result['crisis'], name="Scenario Simule", line=dict(color='#00FF88', width=3), mode='lines', fill='tonexty' )) fig_impact.update_layout( template="plotly_dark", height=400, xaxis_title="", yaxis_title="Impact sur le Taux d'Occupation", showlegend=True, hovermode="x unified" ) st.plotly_chart(fig_impact, use_container_width=True) # ===== RÉSULTAT DE LA SIMULATION ===== st.divider() metrics = result['metrics'] # Message de robustesse if metrics['criticite'] == 'CRITICAL': st.error( f"CRITIQUE : L'hopital NE PEUT PAS absorber ce choc. " f"Occupation prevue : {metrics['occupation_crisis']*100:.0f}% " f"(max: {metrics['admissions_peak']:.0f} admissions/jour)." ) elif metrics['criticite'] == 'HIGH': st.warning( f"ATTENTION : L'hopital est en tension elevee. " f"Occupation prevue : {metrics['occupation_crisis']*100:.0f}%." ) else: st.success( f"ROBUSTE : L'hopital tient le choc ({metrics['occupation_crisis']*100:.0f}% max)." ) # ===== RECOMMANDATIONS AUTOMATIQUES ===== st.markdown("### Recommandations Strategiques") if result['recommendations']: # Tri par priorité priority_order = {'IMMEDIATE': 0, 'URGENT': 1, 'NORMAL': 2} sorted_recs = sorted( result['recommendations'], key=lambda x: priority_order.get(x['priorite'], 3) ) for rec in sorted_recs: priority_color = { 'IMMEDIATE': '#FF4444', 'URGENT': '#FFA500', 'NORMAL': '#4A9EFF' } color = priority_color.get(rec['priorite'], '#888888') st.markdown( f"
" f"
" f"{rec['categorie']} - {rec['priorite']}
" f"
" f"Action : {rec['action']}
" f"
" f"{rec['justification']}
" f"
", unsafe_allow_html=True ) else: st.info("Aucune recommandation critique pour ce scenario") # Point d'entrée principal if __name__ == "__main__": show_dashboard(None, None)