""" Notifications de transition de mode et alertes système pour informer l'utilisateur des changements d'état et problèmes détectés """ from PyQt5.QtWidgets import ( QWidget, QLabel, QVBoxLayout, QHBoxLayout, QPushButton, QDialog ) from PyQt5.QtCore import Qt, QTimer, pyqtSignal, QPropertyAnimation, QEasingCurve, QPoint from PyQt5.QtGui import QFont, QPainter, QColor from typing import Optional, Dict, Any, Callable import logging class TransitionNotification(QWidget): """ Notification pour les transitions de mode et alertes système Supporte différents types: transition autopilot, baisse confiance, violation liste blanche, changement UI """ # Types de notification TYPE_AUTOPILOT_PROPOSAL = "autopilot_proposal" TYPE_CONFIDENCE_DROP = "confidence_drop" TYPE_WHITELIST_VIOLATION = "whitelist_violation" TYPE_UI_CHANGE = "ui_change" TYPE_MODE_TRANSITION = "mode_transition" # Signaux action_taken = pyqtSignal(str) # "accept", "reject", "dismiss" def __init__(self, notification_type: str, data: Dict[str, Any], requires_action: bool = False, timeout_ms: int = 10000, parent=None): """ Initialiser la notification de transition Args: notification_type: Type de notification (voir constantes TYPE_*) data: Données spécifiques au type de notification requires_action: Nécessite une action utilisateur (défaut: False) timeout_ms: Timeout en millisecondes (défaut: 10000) parent: Widget parent (optionnel) """ super().__init__(parent) self.notification_type = notification_type self.data = data self.requires_action = requires_action self.timeout_ms = timeout_ms self.logger = logging.getLogger(__name__) self.is_closed = False self.init_ui() self.setup_animation() if not requires_action: self.setup_timer() self.logger.info(f"TransitionNotification créée: {notification_type}") def init_ui(self): """Initialiser l'interface de la notification""" # Fenêtre sans bordure, toujours au-dessus self.setWindowFlags( Qt.WindowStaysOnTopHint | Qt.FramelessWindowHint | Qt.Tool ) self.setAttribute(Qt.WA_TranslucentBackground) # Taille variable selon le type if self.requires_action: self.setFixedSize(400, 180) else: self.setFixedSize(380, 140) # Positionner en haut à droite de l'écran from PyQt5.QtWidgets import QApplication screen = QApplication.primaryScreen().geometry() x = screen.width() - self.width() - 20 y = 20 self.move(x, y) # Widget conteneur avec fond self.container = QWidget(self) self.container.setGeometry(0, 0, self.width(), self.height()) # Déterminer le style selon le type style_config = self._get_style_config() self.container.setStyleSheet(f""" QWidget {{ background-color: {style_config['bg_color']}; border-radius: 10px; border: 2px solid {style_config['border_color']}; }} """) # Layout principal main_layout = QVBoxLayout() self.container.setLayout(main_layout) main_layout.setContentsMargins(15, 12, 15, 12) main_layout.setSpacing(10) # En-tête avec icône et titre header_layout = QHBoxLayout() icon_label = QLabel(style_config['icon']) icon_label.setFont(QFont("Arial", 18)) header_layout.addWidget(icon_label) title_label = QLabel(style_config['title']) title_label.setFont(QFont("Arial", 12, QFont.Bold)) title_label.setStyleSheet(f"color: {style_config['text_color']};") header_layout.addWidget(title_label) header_layout.addStretch() # Bouton de fermeture (si pas d'action requise) if not self.requires_action: close_button = QPushButton("×") close_button.setFont(QFont("Arial", 16, QFont.Bold)) close_button.setFixedSize(25, 25) close_button.setStyleSheet(f""" QPushButton {{ background-color: transparent; color: {style_config['text_color']}; border: none; }} QPushButton:hover {{ background-color: rgba(0, 0, 0, 30); border-radius: 12px; }} """) close_button.clicked.connect(self.close_notification) header_layout.addWidget(close_button) main_layout.addLayout(header_layout) # Message principal message = self._get_message() message_label = QLabel(message) message_label.setFont(QFont("Arial", 10)) message_label.setStyleSheet(f"color: {style_config['text_color']};") message_label.setWordWrap(True) main_layout.addWidget(message_label) # Détails additionnels details = self._get_details() if details: details_label = QLabel(details) details_label.setFont(QFont("Arial", 9)) details_label.setStyleSheet(f"color: {style_config['details_color']};") details_label.setWordWrap(True) main_layout.addWidget(details_label) # Boutons d'action (si requis) if self.requires_action: button_layout = QHBoxLayout() button_layout.setSpacing(10) accept_button = QPushButton(style_config['accept_text']) accept_button.setFont(QFont("Arial", 10)) accept_button.setStyleSheet(""" QPushButton { background-color: #4CAF50; color: white; border: none; padding: 8px 15px; border-radius: 5px; } QPushButton:hover { background-color: #45a049; } """) accept_button.clicked.connect(lambda: self.on_action("accept")) button_layout.addWidget(accept_button) reject_button = QPushButton(style_config['reject_text']) reject_button.setFont(QFont("Arial", 10)) reject_button.setStyleSheet(""" QPushButton { background-color: #f44336; color: white; border: none; padding: 8px 15px; border-radius: 5px; } QPushButton:hover { background-color: #da190b; } """) reject_button.clicked.connect(lambda: self.on_action("reject")) button_layout.addWidget(reject_button) main_layout.addLayout(button_layout) def _get_style_config(self) -> Dict[str, str]: """Obtenir la configuration de style selon le type""" configs = { self.TYPE_AUTOPILOT_PROPOSAL: { 'bg_color': '#E8F5E9', 'border_color': '#4CAF50', 'text_color': '#2E7D32', 'details_color': '#558B2F', 'icon': '🤖', 'title': 'Passage en Autopilot Proposé', 'accept_text': '✓ Activer Autopilot', 'reject_text': '✗ Rester en Assisté' }, self.TYPE_CONFIDENCE_DROP: { 'bg_color': '#FFF3E0', 'border_color': '#FF9800', 'text_color': '#E65100', 'details_color': '#F57C00', 'icon': '⚠️', 'title': 'Baisse de Confiance Détectée', 'accept_text': '✓ Continuer', 'reject_text': '✗ Arrêter' }, self.TYPE_WHITELIST_VIOLATION: { 'bg_color': '#FFEBEE', 'border_color': '#f44336', 'text_color': '#C62828', 'details_color': '#D32F2F', 'icon': '🚫', 'title': 'Violation de Liste Blanche', 'accept_text': '✓ Ajouter à la Liste', 'reject_text': '✗ Bloquer' }, self.TYPE_UI_CHANGE: { 'bg_color': '#E3F2FD', 'border_color': '#2196F3', 'text_color': '#1565C0', 'details_color': '#1976D2', 'icon': '🔄', 'title': 'Changement d\'Interface Détecté', 'accept_text': '✓ Ré-observer', 'reject_text': '✗ Ignorer' }, self.TYPE_MODE_TRANSITION: { 'bg_color': '#F3E5F5', 'border_color': '#9C27B0', 'text_color': '#6A1B9A', 'details_color': '#7B1FA2', 'icon': '🔀', 'title': 'Transition de Mode', 'accept_text': '✓ OK', 'reject_text': '✗ Annuler' } } return configs.get(self.notification_type, configs[self.TYPE_MODE_TRANSITION]) def _get_message(self) -> str: """Obtenir le message principal selon le type""" if self.notification_type == self.TYPE_AUTOPILOT_PROPOSAL: task_name = self.data.get('task_name', 'Tâche') observations = self.data.get('observation_count', 0) concordance = self.data.get('concordance_rate', 0.0) return (f"La tâche '{task_name}' a atteint les critères pour le mode Autopilot:\n" f"• {observations} observations\n" f"• {concordance * 100:.1f}% de concordance") elif self.notification_type == self.TYPE_CONFIDENCE_DROP: task_name = self.data.get('task_name', 'Tâche') confidence = self.data.get('confidence_score', 0.0) threshold = self.data.get('threshold', 0.90) return (f"La confiance pour '{task_name}' est tombée à {confidence * 100:.1f}% " f"(seuil: {threshold * 100:.0f}%). Retour au mode Assisté recommandé.") elif self.notification_type == self.TYPE_WHITELIST_VIOLATION: window = self.data.get('window_title', 'Fenêtre inconnue') action = self.data.get('action_type', 'action') return (f"Tentative d'{action} dans une fenêtre non autorisée:\n" f"'{window}'\n" f"Cette action a été bloquée pour des raisons de sécurité.") elif self.notification_type == self.TYPE_UI_CHANGE: task_name = self.data.get('task_name', 'Tâche') similarity = self.data.get('similarity', 0.0) return (f"Changement d'interface détecté pour '{task_name}'.\n" f"Similarité visuelle: {similarity * 100:.1f}% (seuil: 70%)\n" f"Une ré-observation est recommandée.") elif self.notification_type == self.TYPE_MODE_TRANSITION: from_mode = self.data.get('from_mode', 'inconnu') to_mode = self.data.get('to_mode', 'inconnu') task_name = self.data.get('task_name', 'Tâche') mode_names = { 'shadow': 'Shadow 👀', 'assist': 'Assisté 🤝', 'auto': 'Autopilot 🤖' } return (f"Transition de mode pour '{task_name}':\n" f"{mode_names.get(from_mode, from_mode)} → " f"{mode_names.get(to_mode, to_mode)}") return "Notification système" def _get_details(self) -> Optional[str]: """Obtenir les détails additionnels selon le type""" if self.notification_type == self.TYPE_CONFIDENCE_DROP: reason = self.data.get('reason', '') if reason: return f"Raison: {reason}" elif self.notification_type == self.TYPE_MODE_TRANSITION: reason = self.data.get('reason', '') if reason: reasons_fr = { 'low_confidence': 'Confiance insuffisante', 'high_concordance': 'Concordance élevée atteinte', 'user_request': 'Demande utilisateur', 'ui_change': 'Changement d\'interface', 'error': 'Erreur détectée' } return f"Raison: {reasons_fr.get(reason, reason)}" return None def setup_animation(self): """Configurer l'animation d'entrée""" from PyQt5.QtWidgets import QApplication screen = QApplication.primaryScreen().geometry() start_x = screen.width() end_x = screen.width() - self.width() - 20 self.animation = QPropertyAnimation(self, b"pos") self.animation.setDuration(400) self.animation.setStartValue(QPoint(start_x, 20)) self.animation.setEndValue(QPoint(end_x, 20)) self.animation.setEasingCurve(QEasingCurve.OutCubic) def setup_timer(self): """Configurer le timer de fermeture automatique""" self.close_timer = QTimer(self) self.close_timer.timeout.connect(self.close_notification) self.close_timer.setSingleShot(True) def show_notification(self): """Afficher la notification avec animation""" self.show() self.animation.start() if not self.requires_action: self.close_timer.start(self.timeout_ms) self.logger.info(f"Notification affichée: {self.notification_type}") def close_notification(self): """Fermer la notification""" if self.is_closed: return self.is_closed = True if hasattr(self, 'close_timer'): self.close_timer.stop() self.action_taken.emit("dismiss") self.logger.info("Notification fermée") self.close() def on_action(self, action: str): """ Gestionnaire d'action utilisateur Args: action: Type d'action ("accept" ou "reject") """ self.is_closed = True self.action_taken.emit(action) self.logger.info(f"Action prise: {action}") self.close() @staticmethod def show_autopilot_proposal(task_name: str, observation_count: int, concordance_rate: float, parent=None) -> 'TransitionNotification': """ Afficher une proposition de passage en Autopilot Args: task_name: Nom de la tâche observation_count: Nombre d'observations concordance_rate: Taux de concordance parent: Widget parent Returns: Instance de TransitionNotification """ data = { 'task_name': task_name, 'observation_count': observation_count, 'concordance_rate': concordance_rate } notification = TransitionNotification( TransitionNotification.TYPE_AUTOPILOT_PROPOSAL, data, requires_action=True, parent=parent ) notification.show_notification() return notification @staticmethod def show_confidence_drop(task_name: str, confidence_score: float, threshold: float = 0.90, reason: str = "", parent=None) -> 'TransitionNotification': """ Afficher une alerte de baisse de confiance Args: task_name: Nom de la tâche confidence_score: Score de confiance actuel threshold: Seuil de confiance reason: Raison de la baisse (optionnel) parent: Widget parent Returns: Instance de TransitionNotification """ data = { 'task_name': task_name, 'confidence_score': confidence_score, 'threshold': threshold, 'reason': reason } notification = TransitionNotification( TransitionNotification.TYPE_CONFIDENCE_DROP, data, requires_action=False, timeout_ms=8000, parent=parent ) notification.show_notification() return notification @staticmethod def show_whitelist_violation(window_title: str, action_type: str, parent=None) -> 'TransitionNotification': """ Afficher une alerte de violation de liste blanche Args: window_title: Titre de la fenêtre action_type: Type d'action tentée parent: Widget parent Returns: Instance de TransitionNotification """ data = { 'window_title': window_title, 'action_type': action_type } notification = TransitionNotification( TransitionNotification.TYPE_WHITELIST_VIOLATION, data, requires_action=True, parent=parent ) notification.show_notification() return notification @staticmethod def show_ui_change(task_name: str, similarity: float, parent=None) -> 'TransitionNotification': """ Afficher une alerte de changement d'interface Args: task_name: Nom de la tâche similarity: Score de similarité visuelle parent: Widget parent Returns: Instance de TransitionNotification """ data = { 'task_name': task_name, 'similarity': similarity } notification = TransitionNotification( TransitionNotification.TYPE_UI_CHANGE, data, requires_action=True, parent=parent ) notification.show_notification() return notification @staticmethod def show_mode_transition(task_name: str, from_mode: str, to_mode: str, reason: str = "", parent=None) -> 'TransitionNotification': """ Afficher une notification de transition de mode Args: task_name: Nom de la tâche from_mode: Mode d'origine to_mode: Mode de destination reason: Raison de la transition parent: Widget parent Returns: Instance de TransitionNotification """ data = { 'task_name': task_name, 'from_mode': from_mode, 'to_mode': to_mode, 'reason': reason } notification = TransitionNotification( TransitionNotification.TYPE_MODE_TRANSITION, data, requires_action=False, timeout_ms=6000, parent=parent ) notification.show_notification() return notification if __name__ == "__main__": """Test des notifications de transition""" from PyQt5.QtWidgets import QApplication import sys app = QApplication(sys.argv) def test_autopilot_proposal(): """Test proposition autopilot""" notification = TransitionNotification.show_autopilot_proposal( task_name="Ouvrir Facture", observation_count=25, concordance_rate=0.97 ) notification.action_taken.connect( lambda action: print(f"Autopilot proposal: {action}") ) def test_confidence_drop(): """Test baisse de confiance""" notification = TransitionNotification.show_confidence_drop( task_name="Saisie Données", confidence_score=0.85, threshold=0.90, reason="Changements UI détectés" ) notification.action_taken.connect( lambda action: print(f"Confidence drop: {action}") ) def test_whitelist_violation(): """Test violation liste blanche""" notification = TransitionNotification.show_whitelist_violation( window_title="Application Non Autorisée", action_type="click" ) notification.action_taken.connect( lambda action: print(f"Whitelist violation: {action}") ) def test_ui_change(): """Test changement UI""" notification = TransitionNotification.show_ui_change( task_name="Navigation Menu", similarity=0.65 ) notification.action_taken.connect( lambda action: print(f"UI change: {action}") ) def test_mode_transition(): """Test transition de mode""" notification = TransitionNotification.show_mode_transition( task_name="Export Données", from_mode="auto", to_mode="assist", reason="low_confidence" ) notification.action_taken.connect( lambda action: print(f"Mode transition: {action}") ) # Tester séquentiellement test_autopilot_proposal() QTimer.singleShot(2000, test_confidence_drop) QTimer.singleShot(4000, test_whitelist_violation) QTimer.singleShot(6000, test_ui_change) QTimer.singleShot(8000, test_mode_transition) sys.exit(app.exec_())