- Frontend v4 accessible sur réseau local (192.168.1.40) - Ports ouverts: 3002 (frontend), 5001 (backend), 5004 (dashboard) - Ollama GPU fonctionnel - Self-healing interactif - Dashboard confiance Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
399 lines
12 KiB
Python
399 lines
12 KiB
Python
"""
|
|
Screen Signature - Génération de signatures d'écran pour apprentissage persistant
|
|
|
|
Fiche #18 - Utilitaire pour générer des signatures stables d'écrans
|
|
permettant de reconnaître des layouts similaires entre sessions.
|
|
|
|
Auteur: Dom, Alice Kiro - 22 décembre 2025
|
|
"""
|
|
|
|
import hashlib
|
|
import logging
|
|
from typing import List, Optional, Dict, Any
|
|
from dataclasses import dataclass
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
@dataclass
|
|
class LayoutElement:
|
|
"""Élément simplifié pour signature de layout"""
|
|
role: str
|
|
bbox: tuple # (x, y, w, h)
|
|
area: float
|
|
text_length: int = 0
|
|
|
|
|
|
def screen_signature(
|
|
screen_state,
|
|
ui_elements: List,
|
|
mode: str = "layout"
|
|
) -> str:
|
|
"""
|
|
Générer une signature stable d'un écran.
|
|
|
|
Modes disponibles:
|
|
- "layout": Basé sur la disposition des éléments UI (positions relatives)
|
|
- "content": Basé sur le contenu textuel et les rôles
|
|
- "hybrid": Combinaison layout + content
|
|
|
|
Args:
|
|
screen_state: ScreenState actuel
|
|
ui_elements: Liste des éléments UI détectés
|
|
mode: Mode de signature ("layout", "content", "hybrid")
|
|
|
|
Returns:
|
|
Signature hexadécimale (MD5)
|
|
"""
|
|
if mode == "layout":
|
|
return _layout_signature(screen_state, ui_elements)
|
|
elif mode == "content":
|
|
return _content_signature(screen_state, ui_elements)
|
|
elif mode == "hybrid":
|
|
layout_sig = _layout_signature(screen_state, ui_elements)
|
|
content_sig = _content_signature(screen_state, ui_elements)
|
|
combined = f"{layout_sig}|{content_sig}"
|
|
return hashlib.md5(combined.encode('utf-8')).hexdigest()
|
|
else:
|
|
raise ValueError(f"Unknown signature mode: {mode}")
|
|
|
|
|
|
def _layout_signature(screen_state, ui_elements: List) -> str:
|
|
"""
|
|
Signature basée sur la disposition des éléments.
|
|
|
|
Utilise:
|
|
- Positions relatives des éléments (normalisées)
|
|
- Tailles relatives
|
|
- Rôles des éléments
|
|
- Structure hiérarchique approximative
|
|
|
|
Résistant aux petits changements de position mais sensible
|
|
aux changements de layout majeurs.
|
|
"""
|
|
if not ui_elements:
|
|
return hashlib.md5(b"empty_layout").hexdigest()
|
|
|
|
# Obtenir la résolution d'écran pour normalisation
|
|
try:
|
|
screen_width = screen_state.window.screen_resolution[0]
|
|
screen_height = screen_state.window.screen_resolution[1]
|
|
except (AttributeError, IndexError):
|
|
screen_width, screen_height = 1920, 1080 # Fallback
|
|
|
|
# Convertir les éléments en format simplifié
|
|
layout_elements = []
|
|
|
|
for elem in ui_elements:
|
|
try:
|
|
# Extraire bbox (format XYWH)
|
|
if hasattr(elem, 'bbox'):
|
|
bbox = elem.bbox
|
|
if hasattr(bbox, 'to_tuple'):
|
|
x, y, w, h = bbox.to_tuple()
|
|
else:
|
|
x, y, w, h = bbox
|
|
else:
|
|
continue # Skip si pas de bbox
|
|
|
|
# Normaliser les coordonnées (0-1)
|
|
norm_x = x / screen_width
|
|
norm_y = y / screen_height
|
|
norm_w = w / screen_width
|
|
norm_h = h / screen_height
|
|
|
|
# Calculer l'aire normalisée
|
|
area = norm_w * norm_h
|
|
|
|
# Extraire le rôle
|
|
role = getattr(elem, 'role', '') or getattr(elem, 'type', '') or 'unknown'
|
|
|
|
# Longueur du texte (approximative)
|
|
label = getattr(elem, 'label', '') or ''
|
|
text_length = len(label.strip()) if label else 0
|
|
|
|
layout_elements.append(LayoutElement(
|
|
role=role.lower(),
|
|
bbox=(norm_x, norm_y, norm_w, norm_h),
|
|
area=area,
|
|
text_length=text_length
|
|
))
|
|
|
|
except Exception as e:
|
|
logger.debug(f"Error processing element for layout signature: {e}")
|
|
continue
|
|
|
|
if not layout_elements:
|
|
return hashlib.md5(b"no_valid_elements").hexdigest()
|
|
|
|
# Trier par position (top-left à bottom-right) pour stabilité
|
|
layout_elements.sort(key=lambda e: (e.bbox[1], e.bbox[0])) # y puis x
|
|
|
|
# Construire la signature
|
|
signature_parts = []
|
|
|
|
# 1. Nombre total d'éléments par rôle
|
|
role_counts = {}
|
|
for elem in layout_elements:
|
|
role_counts[elem.role] = role_counts.get(elem.role, 0) + 1
|
|
|
|
signature_parts.append(f"roles:{','.join(f'{r}:{c}' for r, c in sorted(role_counts.items()))}")
|
|
|
|
# 2. Grille approximative (diviser l'écran en 4x4)
|
|
grid_signature = _compute_grid_signature(layout_elements)
|
|
signature_parts.append(f"grid:{grid_signature}")
|
|
|
|
# 3. Éléments dominants (les plus gros)
|
|
dominant_elements = sorted(layout_elements, key=lambda e: e.area, reverse=True)[:5]
|
|
dominant_sig = []
|
|
for elem in dominant_elements:
|
|
# Position approximative (arrondie)
|
|
x, y, w, h = elem.bbox
|
|
grid_x = int(x * 4) # 0-3
|
|
grid_y = int(y * 4) # 0-3
|
|
size_class = "L" if elem.area > 0.1 else "M" if elem.area > 0.01 else "S"
|
|
dominant_sig.append(f"{elem.role}@{grid_x},{grid_y}:{size_class}")
|
|
|
|
signature_parts.append(f"dominant:{','.join(dominant_sig)}")
|
|
|
|
# Combiner et hasher
|
|
signature_string = "|".join(signature_parts)
|
|
return hashlib.md5(signature_string.encode('utf-8')).hexdigest()
|
|
|
|
|
|
def _content_signature(screen_state, ui_elements: List) -> str:
|
|
"""
|
|
Signature basée sur le contenu textuel et les rôles.
|
|
|
|
Utilise:
|
|
- Textes détectés (normalisés)
|
|
- Rôles des éléments
|
|
- Titre de fenêtre
|
|
- Mots-clés importants
|
|
|
|
Résistant aux changements de position mais sensible
|
|
aux changements de contenu.
|
|
"""
|
|
signature_parts = []
|
|
|
|
# 1. Titre de fenêtre (normalisé)
|
|
try:
|
|
window_title = screen_state.window.window_title or ""
|
|
# Normaliser: enlever timestamps, numéros de version, etc.
|
|
normalized_title = _normalize_text_for_signature(window_title)
|
|
if normalized_title:
|
|
signature_parts.append(f"title:{normalized_title}")
|
|
except AttributeError:
|
|
pass
|
|
|
|
# 2. Textes des éléments UI
|
|
ui_texts = []
|
|
role_text_pairs = []
|
|
|
|
for elem in ui_elements:
|
|
try:
|
|
# Extraire le texte
|
|
label = getattr(elem, 'label', '') or ''
|
|
if label and len(label.strip()) > 0:
|
|
normalized_text = _normalize_text_for_signature(label)
|
|
if normalized_text:
|
|
ui_texts.append(normalized_text)
|
|
|
|
# Associer avec le rôle
|
|
role = getattr(elem, 'role', '') or 'unknown'
|
|
role_text_pairs.append(f"{role}:{normalized_text}")
|
|
except Exception:
|
|
continue
|
|
|
|
# 3. Textes détectés par OCR
|
|
try:
|
|
detected_texts = screen_state.perception.detected_text or []
|
|
for text in detected_texts:
|
|
if isinstance(text, str) and len(text.strip()) > 2:
|
|
normalized_text = _normalize_text_for_signature(text)
|
|
if normalized_text:
|
|
ui_texts.append(normalized_text)
|
|
except AttributeError:
|
|
pass
|
|
|
|
# Construire la signature
|
|
if ui_texts:
|
|
# Trier pour stabilité
|
|
ui_texts.sort()
|
|
signature_parts.append(f"texts:{','.join(ui_texts[:10])}") # Limiter à 10
|
|
|
|
if role_text_pairs:
|
|
role_text_pairs.sort()
|
|
signature_parts.append(f"role_texts:{','.join(role_text_pairs[:8])}") # Limiter à 8
|
|
|
|
# 4. Mots-clés importants (boutons, liens, etc.)
|
|
keywords = _extract_keywords(ui_elements)
|
|
if keywords:
|
|
signature_parts.append(f"keywords:{','.join(sorted(keywords))}")
|
|
|
|
if not signature_parts:
|
|
return hashlib.md5(b"no_content").hexdigest()
|
|
|
|
# Combiner et hasher
|
|
signature_string = "|".join(signature_parts)
|
|
return hashlib.md5(signature_string.encode('utf-8')).hexdigest()
|
|
|
|
|
|
def _compute_grid_signature(layout_elements: List[LayoutElement]) -> str:
|
|
"""
|
|
Calculer une signature de grille 4x4.
|
|
|
|
Divise l'écran en 16 cellules et compte les éléments par cellule.
|
|
"""
|
|
grid = [[0 for _ in range(4)] for _ in range(4)]
|
|
|
|
for elem in layout_elements:
|
|
x, y, w, h = elem.bbox
|
|
|
|
# Centre de l'élément
|
|
center_x = x + w / 2
|
|
center_y = y + h / 2
|
|
|
|
# Cellule de grille
|
|
grid_x = min(3, int(center_x * 4))
|
|
grid_y = min(3, int(center_y * 4))
|
|
|
|
grid[grid_y][grid_x] += 1
|
|
|
|
# Convertir en string compacte
|
|
grid_str = ""
|
|
for row in grid:
|
|
for count in row:
|
|
grid_str += str(min(9, count)) # Limiter à 9
|
|
|
|
return grid_str
|
|
|
|
|
|
def _normalize_text_for_signature(text: str) -> str:
|
|
"""
|
|
Normaliser un texte pour signature stable.
|
|
|
|
Enlève:
|
|
- Timestamps
|
|
- Numéros de version
|
|
- Espaces multiples
|
|
- Caractères spéciaux
|
|
- Casse
|
|
"""
|
|
if not text:
|
|
return ""
|
|
|
|
import re
|
|
|
|
# Convertir en minuscules
|
|
text = text.lower().strip()
|
|
|
|
# Enlever timestamps communs
|
|
text = re.sub(r'\d{1,2}:\d{2}(:\d{2})?', '', text) # HH:MM ou HH:MM:SS
|
|
text = re.sub(r'\d{1,2}/\d{1,2}/\d{2,4}', '', text) # Dates
|
|
text = re.sub(r'\d{4}-\d{2}-\d{2}', '', text) # Dates ISO
|
|
|
|
# Enlever numéros de version
|
|
text = re.sub(r'v?\d+\.\d+(\.\d+)?', '', text)
|
|
|
|
# Enlever numéros génériques
|
|
text = re.sub(r'\b\d+\b', '', text)
|
|
|
|
# Normaliser espaces
|
|
text = re.sub(r'\s+', ' ', text)
|
|
|
|
# Garder seulement lettres, espaces et quelques caractères
|
|
text = re.sub(r'[^a-z\s\-_]', '', text)
|
|
|
|
# Enlever mots très courts ou communs
|
|
words = text.split()
|
|
filtered_words = []
|
|
|
|
stop_words = {'the', 'and', 'or', 'but', 'in', 'on', 'at', 'to', 'for', 'of', 'with', 'by'}
|
|
|
|
for word in words:
|
|
if len(word) >= 3 and word not in stop_words:
|
|
filtered_words.append(word)
|
|
|
|
result = ' '.join(filtered_words).strip()
|
|
|
|
# Limiter la longueur
|
|
if len(result) > 50:
|
|
result = result[:50]
|
|
|
|
return result
|
|
|
|
|
|
def _extract_keywords(ui_elements: List) -> List[str]:
|
|
"""
|
|
Extraire des mots-clés importants des éléments UI.
|
|
|
|
Se concentre sur:
|
|
- Boutons avec texte significatif
|
|
- Liens
|
|
- Titres/headers
|
|
- Labels de formulaires
|
|
"""
|
|
keywords = set()
|
|
|
|
important_roles = {'button', 'link', 'heading', 'label', 'tab', 'menuitem'}
|
|
|
|
for elem in ui_elements:
|
|
try:
|
|
role = getattr(elem, 'role', '') or ''
|
|
label = getattr(elem, 'label', '') or ''
|
|
|
|
if role.lower() in important_roles and label:
|
|
normalized = _normalize_text_for_signature(label)
|
|
if normalized and len(normalized) >= 3:
|
|
# Prendre le premier mot significatif
|
|
first_word = normalized.split()[0] if normalized.split() else ""
|
|
if len(first_word) >= 3:
|
|
keywords.add(first_word)
|
|
except Exception:
|
|
continue
|
|
|
|
return list(keywords)
|
|
|
|
|
|
def compare_signatures(sig1: str, sig2: str) -> float:
|
|
"""
|
|
Comparer deux signatures et retourner un score de similarité.
|
|
|
|
Args:
|
|
sig1: Première signature
|
|
sig2: Deuxième signature
|
|
|
|
Returns:
|
|
Score de similarité (0.0 = différent, 1.0 = identique)
|
|
"""
|
|
if sig1 == sig2:
|
|
return 1.0
|
|
|
|
# Pour des signatures MD5, on ne peut que comparer l'égalité exacte
|
|
# Dans une version plus avancée, on pourrait comparer les composants
|
|
# avant le hashage pour une similarité partielle
|
|
return 0.0
|
|
|
|
|
|
def signature_stats(signatures: List[str]) -> Dict[str, Any]:
|
|
"""
|
|
Calculer des statistiques sur un ensemble de signatures.
|
|
|
|
Args:
|
|
signatures: Liste de signatures
|
|
|
|
Returns:
|
|
Dictionnaire avec statistiques
|
|
"""
|
|
if not signatures:
|
|
return {"total": 0, "unique": 0, "duplicates": 0}
|
|
|
|
unique_signatures = set(signatures)
|
|
|
|
return {
|
|
"total": len(signatures),
|
|
"unique": len(unique_signatures),
|
|
"duplicates": len(signatures) - len(unique_signatures),
|
|
"uniqueness_ratio": len(unique_signatures) / len(signatures)
|
|
} |