613 lines
22 KiB
Python
613 lines
22 KiB
Python
"""
|
||
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_())
|