- 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>
833 lines
31 KiB
Python
833 lines
31 KiB
Python
"""
|
|
Recovery Strategies - Stratégies de récupération pour ErrorHandler
|
|
|
|
Ce module implémente les stratégies de récupération spécialisées pour différents types d'erreurs:
|
|
- SpatialFallbackStrategy pour TargetNotFoundError
|
|
- SemanticVariantStrategy pour UIElementChangedError
|
|
- RetryWithBackoffStrategy pour NetworkError
|
|
- DataNormalizationStrategy pour ValidationError
|
|
|
|
Chaque stratégie implémente l'interface BaseRecoveryStrategy et fournit une logique
|
|
de récupération spécialisée pour son type d'erreur.
|
|
"""
|
|
|
|
import logging
|
|
import time
|
|
import re
|
|
from abc import ABC, abstractmethod
|
|
from typing import Dict, Any, Optional, List, Tuple
|
|
from dataclasses import dataclass
|
|
from datetime import datetime, timedelta
|
|
from enum import Enum
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
class RecoveryStrategyType(Enum):
|
|
"""Types de stratégies de récupération"""
|
|
SPATIAL_FALLBACK = "spatial_fallback"
|
|
SEMANTIC_VARIANT = "semantic_variant"
|
|
RETRY_WITH_BACKOFF = "retry_with_backoff"
|
|
DATA_NORMALIZATION = "data_normalization"
|
|
|
|
|
|
@dataclass
|
|
class RecoveryContext:
|
|
"""Contexte pour une tentative de récupération"""
|
|
error_type: str
|
|
error_message: str
|
|
original_data: Dict[str, Any]
|
|
attempt_number: int
|
|
max_attempts: int
|
|
timestamp: datetime
|
|
additional_context: Dict[str, Any]
|
|
|
|
|
|
@dataclass
|
|
class RecoveryResult:
|
|
"""Résultat d'une tentative de récupération"""
|
|
success: bool
|
|
should_retry: bool
|
|
strategy_used: RecoveryStrategyType
|
|
recovery_data: Dict[str, Any]
|
|
message: str
|
|
escalation_reason: Optional[str] = None
|
|
duration_ms: float = 0.0
|
|
|
|
@classmethod
|
|
def success_with_retry(cls, strategy: RecoveryStrategyType, data: Dict[str, Any],
|
|
message: str) -> 'RecoveryResult':
|
|
"""Créer un résultat de succès avec retry recommandé"""
|
|
return cls(
|
|
success=True,
|
|
should_retry=True,
|
|
strategy_used=strategy,
|
|
recovery_data=data,
|
|
message=message
|
|
)
|
|
|
|
@classmethod
|
|
def success_no_retry(cls, strategy: RecoveryStrategyType, data: Dict[str, Any],
|
|
message: str) -> 'RecoveryResult':
|
|
"""Créer un résultat de succès sans retry"""
|
|
return cls(
|
|
success=True,
|
|
should_retry=False,
|
|
strategy_used=strategy,
|
|
recovery_data=data,
|
|
message=message
|
|
)
|
|
|
|
@classmethod
|
|
def failure_with_escalation(cls, strategy: RecoveryStrategyType, reason: str,
|
|
message: str) -> 'RecoveryResult':
|
|
"""Créer un résultat d'échec avec escalade"""
|
|
return cls(
|
|
success=False,
|
|
should_retry=False,
|
|
strategy_used=strategy,
|
|
recovery_data={},
|
|
message=message,
|
|
escalation_reason=reason
|
|
)
|
|
|
|
|
|
class BaseRecoveryStrategy(ABC):
|
|
"""Interface de base pour les stratégies de récupération"""
|
|
|
|
def __init__(self, max_attempts: int = 3):
|
|
self.max_attempts = max_attempts
|
|
self.strategy_type = None # À définir dans les sous-classes
|
|
|
|
@abstractmethod
|
|
def can_handle(self, error_type: str, context: Dict[str, Any]) -> bool:
|
|
"""Détermine si cette stratégie peut gérer ce type d'erreur"""
|
|
pass
|
|
|
|
@abstractmethod
|
|
def recover(self, context: RecoveryContext) -> RecoveryResult:
|
|
"""Exécute la stratégie de récupération"""
|
|
pass
|
|
|
|
def _log_recovery_attempt(self, context: RecoveryContext, result: RecoveryResult):
|
|
"""Log une tentative de récupération"""
|
|
logger.info(
|
|
f"Recovery attempt {context.attempt_number}/{context.max_attempts} "
|
|
f"using {self.strategy_type.value}: {result.message}"
|
|
)
|
|
|
|
|
|
class SpatialFallbackStrategy(BaseRecoveryStrategy):
|
|
"""
|
|
Stratégie de récupération spatiale pour TargetNotFoundError
|
|
|
|
Utilise des critères spatiaux alternatifs quand un élément cible n'est pas trouvé:
|
|
- Recherche par position relative (à droite, en dessous, etc.)
|
|
- Recherche par zone élargie
|
|
- Recherche par similarité visuelle dans une zone plus large
|
|
"""
|
|
|
|
def __init__(self, max_attempts: int = 3, expand_factor: float = 1.5):
|
|
super().__init__(max_attempts)
|
|
self.strategy_type = RecoveryStrategyType.SPATIAL_FALLBACK
|
|
self.expand_factor = expand_factor
|
|
|
|
def can_handle(self, error_type: str, context: Dict[str, Any]) -> bool:
|
|
"""Peut gérer les erreurs de type TargetNotFoundError"""
|
|
return error_type in ["TargetNotFoundError", "target_not_found"]
|
|
|
|
def recover(self, context: RecoveryContext) -> RecoveryResult:
|
|
"""
|
|
Récupération par fallback spatial
|
|
|
|
Stratégies appliquées dans l'ordre:
|
|
1. Recherche dans une zone élargie
|
|
2. Recherche par position relative
|
|
3. Recherche par similarité visuelle élargie
|
|
"""
|
|
start_time = time.time()
|
|
|
|
try:
|
|
# Extraire les données du contexte
|
|
target_info = context.original_data.get('target', {})
|
|
screen_state = context.additional_context.get('screen_state')
|
|
|
|
if not target_info or not screen_state:
|
|
return RecoveryResult.failure_with_escalation(
|
|
self.strategy_type,
|
|
"Missing target info or screen state",
|
|
"Cannot perform spatial fallback without target and screen data"
|
|
)
|
|
|
|
# Stratégie 1: Zone élargie
|
|
if context.attempt_number == 1:
|
|
recovery_data = self._expand_search_area(target_info, screen_state)
|
|
message = f"Expanded search area by factor {self.expand_factor}"
|
|
|
|
# Stratégie 2: Position relative
|
|
elif context.attempt_number == 2:
|
|
recovery_data = self._search_relative_position(target_info, screen_state)
|
|
message = "Searching by relative position (nearby elements)"
|
|
|
|
# Stratégie 3: Similarité visuelle élargie
|
|
elif context.attempt_number == 3:
|
|
recovery_data = self._visual_similarity_fallback(target_info, screen_state)
|
|
message = "Using visual similarity fallback with relaxed criteria"
|
|
|
|
else:
|
|
return RecoveryResult.failure_with_escalation(
|
|
self.strategy_type,
|
|
f"Max spatial fallback attempts reached ({self.max_attempts})",
|
|
"All spatial fallback strategies exhausted"
|
|
)
|
|
|
|
duration_ms = (time.time() - start_time) * 1000
|
|
|
|
result = RecoveryResult.success_with_retry(
|
|
self.strategy_type,
|
|
recovery_data,
|
|
message
|
|
)
|
|
result.duration_ms = duration_ms
|
|
|
|
self._log_recovery_attempt(context, result)
|
|
return result
|
|
|
|
except Exception as e:
|
|
duration_ms = (time.time() - start_time) * 1000
|
|
logger.error(f"Spatial fallback strategy failed: {e}")
|
|
|
|
result = RecoveryResult.failure_with_escalation(
|
|
self.strategy_type,
|
|
f"Strategy execution error: {e}",
|
|
f"Spatial fallback failed with exception: {e}"
|
|
)
|
|
result.duration_ms = duration_ms
|
|
return result
|
|
|
|
def _expand_search_area(self, target_info: Dict[str, Any], screen_state) -> Dict[str, Any]:
|
|
"""Élargir la zone de recherche"""
|
|
original_bbox = target_info.get('bbox', {})
|
|
if not original_bbox:
|
|
return {'strategy': 'expand_area', 'success': False, 'reason': 'No bbox available'}
|
|
|
|
# Élargir la bbox par le facteur d'expansion
|
|
expanded_bbox = {
|
|
'x': max(0, original_bbox.get('x', 0) - int(original_bbox.get('width', 0) * (self.expand_factor - 1) / 2)),
|
|
'y': max(0, original_bbox.get('y', 0) - int(original_bbox.get('height', 0) * (self.expand_factor - 1) / 2)),
|
|
'width': int(original_bbox.get('width', 0) * self.expand_factor),
|
|
'height': int(original_bbox.get('height', 0) * self.expand_factor)
|
|
}
|
|
|
|
return {
|
|
'strategy': 'expand_area',
|
|
'original_bbox': original_bbox,
|
|
'expanded_bbox': expanded_bbox,
|
|
'expand_factor': self.expand_factor
|
|
}
|
|
|
|
def _search_relative_position(self, target_info: Dict[str, Any], screen_state) -> Dict[str, Any]:
|
|
"""Rechercher par position relative"""
|
|
# Rechercher des éléments proches qui pourraient servir de référence
|
|
target_text = target_info.get('text_pattern', '')
|
|
target_role = target_info.get('role', '')
|
|
|
|
relative_positions = ['right', 'below', 'above', 'left']
|
|
|
|
return {
|
|
'strategy': 'relative_position',
|
|
'target_text': target_text,
|
|
'target_role': target_role,
|
|
'search_positions': relative_positions,
|
|
'search_radius': 100 # pixels
|
|
}
|
|
|
|
def _visual_similarity_fallback(self, target_info: Dict[str, Any], screen_state) -> Dict[str, Any]:
|
|
"""Fallback par similarité visuelle avec critères relaxés"""
|
|
return {
|
|
'strategy': 'visual_similarity',
|
|
'relaxed_threshold': 0.6, # Seuil plus bas
|
|
'use_partial_matching': True,
|
|
'ignore_color_variations': True,
|
|
'target_info': target_info
|
|
}
|
|
|
|
|
|
class SemanticVariantStrategy(BaseRecoveryStrategy):
|
|
"""
|
|
Stratégie de récupération sémantique pour UIElementChangedError
|
|
|
|
Essaie des variantes sémantiques du texte quand un élément UI a changé:
|
|
- Variantes linguistiques (synonymes, traductions)
|
|
- Variantes de format (casse, espaces, ponctuation)
|
|
- Variantes contextuelles (texte partiel, mots-clés)
|
|
"""
|
|
|
|
def __init__(self, max_attempts: int = 3):
|
|
super().__init__(max_attempts)
|
|
self.strategy_type = RecoveryStrategyType.SEMANTIC_VARIANT
|
|
|
|
# Dictionnaire de synonymes courants
|
|
self.synonyms = {
|
|
'submit': ['send', 'confirm', 'ok', 'apply', 'save'],
|
|
'cancel': ['close', 'abort', 'dismiss', 'back'],
|
|
'delete': ['remove', 'erase', 'clear'],
|
|
'edit': ['modify', 'change', 'update'],
|
|
'search': ['find', 'lookup', 'query'],
|
|
'login': ['sign in', 'connect', 'authenticate'],
|
|
'logout': ['sign out', 'disconnect', 'exit']
|
|
}
|
|
|
|
def can_handle(self, error_type: str, context: Dict[str, Any]) -> bool:
|
|
"""Peut gérer les erreurs de changement d'UI"""
|
|
return error_type in ["UIElementChangedError", "ui_element_changed", "ui_changed"]
|
|
|
|
def recover(self, context: RecoveryContext) -> RecoveryResult:
|
|
"""
|
|
Récupération par variantes sémantiques
|
|
|
|
Stratégies appliquées dans l'ordre:
|
|
1. Variantes de format (casse, espaces)
|
|
2. Synonymes et variantes linguistiques
|
|
3. Correspondance partielle et mots-clés
|
|
"""
|
|
start_time = time.time()
|
|
|
|
try:
|
|
# Extraire le texte original
|
|
original_text = context.original_data.get('text_pattern', '')
|
|
if not original_text:
|
|
return RecoveryResult.failure_with_escalation(
|
|
self.strategy_type,
|
|
"No text pattern available",
|
|
"Cannot generate semantic variants without original text"
|
|
)
|
|
|
|
# Générer variantes selon le numéro de tentative
|
|
if context.attempt_number == 1:
|
|
variants = self._generate_format_variants(original_text)
|
|
message = f"Generated {len(variants)} format variants"
|
|
|
|
elif context.attempt_number == 2:
|
|
variants = self._generate_semantic_variants(original_text)
|
|
message = f"Generated {len(variants)} semantic variants"
|
|
|
|
elif context.attempt_number == 3:
|
|
variants = self._generate_partial_variants(original_text)
|
|
message = f"Generated {len(variants)} partial matching variants"
|
|
|
|
else:
|
|
return RecoveryResult.failure_with_escalation(
|
|
self.strategy_type,
|
|
f"Max semantic variant attempts reached ({self.max_attempts})",
|
|
"All semantic variant strategies exhausted"
|
|
)
|
|
|
|
duration_ms = (time.time() - start_time) * 1000
|
|
|
|
recovery_data = {
|
|
'original_text': original_text,
|
|
'variants': variants,
|
|
'strategy_type': f'attempt_{context.attempt_number}'
|
|
}
|
|
|
|
result = RecoveryResult.success_with_retry(
|
|
self.strategy_type,
|
|
recovery_data,
|
|
message
|
|
)
|
|
result.duration_ms = duration_ms
|
|
|
|
self._log_recovery_attempt(context, result)
|
|
return result
|
|
|
|
except Exception as e:
|
|
duration_ms = (time.time() - start_time) * 1000
|
|
logger.error(f"Semantic variant strategy failed: {e}")
|
|
|
|
result = RecoveryResult.failure_with_escalation(
|
|
self.strategy_type,
|
|
f"Strategy execution error: {e}",
|
|
f"Semantic variant generation failed: {e}"
|
|
)
|
|
result.duration_ms = duration_ms
|
|
return result
|
|
|
|
def _generate_format_variants(self, text: str) -> List[str]:
|
|
"""Générer des variantes de format"""
|
|
variants = []
|
|
|
|
# Variantes de casse
|
|
variants.extend([
|
|
text.lower(),
|
|
text.upper(),
|
|
text.title(),
|
|
text.capitalize()
|
|
])
|
|
|
|
# Variantes d'espaces et ponctuation
|
|
variants.extend([
|
|
text.strip(),
|
|
text.replace(' ', ''),
|
|
text.replace('-', ' '),
|
|
text.replace('_', ' '),
|
|
re.sub(r'[^\w\s]', '', text), # Supprimer ponctuation
|
|
re.sub(r'\s+', ' ', text) # Normaliser espaces
|
|
])
|
|
|
|
# Supprimer doublons et texte vide
|
|
return list(filter(None, set(variants)))
|
|
|
|
def _generate_semantic_variants(self, text: str) -> List[str]:
|
|
"""Générer des variantes sémantiques"""
|
|
variants = []
|
|
text_lower = text.lower()
|
|
|
|
# Chercher des synonymes
|
|
for word, synonyms in self.synonyms.items():
|
|
if word in text_lower:
|
|
for synonym in synonyms:
|
|
variants.append(text_lower.replace(word, synonym))
|
|
|
|
# Variantes communes
|
|
common_replacements = {
|
|
'btn': 'button',
|
|
'button': 'btn',
|
|
'&': 'and',
|
|
'and': '&',
|
|
'ok': 'okay',
|
|
'okay': 'ok'
|
|
}
|
|
|
|
for old, new in common_replacements.items():
|
|
if old in text_lower:
|
|
variants.append(text_lower.replace(old, new))
|
|
|
|
return variants
|
|
|
|
def _generate_partial_variants(self, text: str) -> List[str]:
|
|
"""Générer des variantes de correspondance partielle"""
|
|
variants = []
|
|
words = text.split()
|
|
|
|
if len(words) > 1:
|
|
# Mots individuels
|
|
variants.extend(words)
|
|
|
|
# Combinaisons de mots
|
|
for i in range(len(words)):
|
|
for j in range(i + 1, len(words) + 1):
|
|
variants.append(' '.join(words[i:j]))
|
|
|
|
# Préfixes et suffixes
|
|
if len(text) > 3:
|
|
variants.extend([
|
|
text[:len(text)//2], # Première moitié
|
|
text[len(text)//2:], # Deuxième moitié
|
|
text[:3], # 3 premiers caractères
|
|
text[-3:] # 3 derniers caractères
|
|
])
|
|
|
|
return list(filter(lambda x: len(x) > 1, set(variants)))
|
|
|
|
|
|
class RetryWithBackoffStrategy(BaseRecoveryStrategy):
|
|
"""
|
|
Stratégie de retry avec backoff exponentiel pour NetworkError
|
|
|
|
Implémente un retry intelligent avec délais croissants pour les erreurs réseau:
|
|
- Backoff exponentiel avec jitter
|
|
- Détection de types d'erreurs réseau
|
|
- Adaptation du délai selon le type d'erreur
|
|
"""
|
|
|
|
def __init__(self, max_attempts: int = 5, base_delay: float = 1.0, max_delay: float = 60.0):
|
|
super().__init__(max_attempts)
|
|
self.strategy_type = RecoveryStrategyType.RETRY_WITH_BACKOFF
|
|
self.base_delay = base_delay
|
|
self.max_delay = max_delay
|
|
|
|
def can_handle(self, error_type: str, context: Dict[str, Any]) -> bool:
|
|
"""Peut gérer les erreurs réseau"""
|
|
network_errors = [
|
|
"NetworkError", "ConnectionError", "TimeoutError", "HTTPError",
|
|
"network_error", "connection_error", "timeout_error", "http_error"
|
|
]
|
|
return error_type in network_errors
|
|
|
|
def recover(self, context: RecoveryContext) -> RecoveryResult:
|
|
"""
|
|
Récupération par retry avec backoff
|
|
|
|
Calcule le délai approprié et recommande un retry après attente
|
|
"""
|
|
start_time = time.time()
|
|
|
|
try:
|
|
if context.attempt_number > self.max_attempts:
|
|
return RecoveryResult.failure_with_escalation(
|
|
self.strategy_type,
|
|
f"Max retry attempts reached ({self.max_attempts})",
|
|
"Network retry limit exceeded"
|
|
)
|
|
|
|
# Calculer délai avec backoff exponentiel
|
|
delay = min(
|
|
self.base_delay * (2 ** (context.attempt_number - 1)),
|
|
self.max_delay
|
|
)
|
|
|
|
# Ajouter jitter (±25%)
|
|
import random
|
|
jitter = delay * 0.25 * (random.random() - 0.5)
|
|
final_delay = max(0.1, delay + jitter)
|
|
|
|
# Analyser le type d'erreur pour adapter la stratégie
|
|
error_analysis = self._analyze_network_error(context.error_message)
|
|
|
|
duration_ms = (time.time() - start_time) * 1000
|
|
|
|
recovery_data = {
|
|
'delay_seconds': final_delay,
|
|
'attempt_number': context.attempt_number,
|
|
'base_delay': self.base_delay,
|
|
'calculated_delay': delay,
|
|
'jitter': jitter,
|
|
'error_analysis': error_analysis
|
|
}
|
|
|
|
message = f"Retry #{context.attempt_number} after {final_delay:.1f}s delay ({error_analysis['category']})"
|
|
|
|
result = RecoveryResult.success_with_retry(
|
|
self.strategy_type,
|
|
recovery_data,
|
|
message
|
|
)
|
|
result.duration_ms = duration_ms
|
|
|
|
self._log_recovery_attempt(context, result)
|
|
|
|
# Attendre le délai calculé
|
|
logger.info(f"Waiting {final_delay:.1f}s before retry...")
|
|
time.sleep(final_delay)
|
|
|
|
return result
|
|
|
|
except Exception as e:
|
|
duration_ms = (time.time() - start_time) * 1000
|
|
logger.error(f"Retry with backoff strategy failed: {e}")
|
|
|
|
result = RecoveryResult.failure_with_escalation(
|
|
self.strategy_type,
|
|
f"Strategy execution error: {e}",
|
|
f"Backoff calculation failed: {e}"
|
|
)
|
|
result.duration_ms = duration_ms
|
|
return result
|
|
|
|
def _analyze_network_error(self, error_message: str) -> Dict[str, Any]:
|
|
"""Analyser le type d'erreur réseau pour adapter la stratégie"""
|
|
error_lower = error_message.lower()
|
|
|
|
# Catégoriser l'erreur
|
|
if any(term in error_lower for term in ['timeout', 'timed out']):
|
|
category = 'timeout'
|
|
severity = 'medium'
|
|
recommendation = 'Increase timeout and retry'
|
|
|
|
elif any(term in error_lower for term in ['connection refused', 'connection failed']):
|
|
category = 'connection_refused'
|
|
severity = 'high'
|
|
recommendation = 'Service may be down, longer backoff recommended'
|
|
|
|
elif any(term in error_lower for term in ['dns', 'name resolution']):
|
|
category = 'dns_error'
|
|
severity = 'high'
|
|
recommendation = 'DNS issue, check network connectivity'
|
|
|
|
elif any(term in error_lower for term in ['ssl', 'certificate', 'tls']):
|
|
category = 'ssl_error'
|
|
severity = 'high'
|
|
recommendation = 'SSL/TLS issue, may need manual intervention'
|
|
|
|
elif any(term in error_lower for term in ['500', '502', '503', '504']):
|
|
category = 'server_error'
|
|
severity = 'medium'
|
|
recommendation = 'Server error, retry with backoff'
|
|
|
|
elif any(term in error_lower for term in ['401', '403']):
|
|
category = 'auth_error'
|
|
severity = 'high'
|
|
recommendation = 'Authentication issue, check credentials'
|
|
|
|
else:
|
|
category = 'unknown_network'
|
|
severity = 'medium'
|
|
recommendation = 'Generic network error, standard retry'
|
|
|
|
return {
|
|
'category': category,
|
|
'severity': severity,
|
|
'recommendation': recommendation,
|
|
'original_message': error_message
|
|
}
|
|
|
|
|
|
class DataNormalizationStrategy(BaseRecoveryStrategy):
|
|
"""
|
|
Stratégie de normalisation des données pour ValidationError
|
|
|
|
Normalise et convertit les données pour résoudre les erreurs de validation:
|
|
- Conversion de types
|
|
- Normalisation de formats
|
|
- Nettoyage de données
|
|
- Validation et correction automatique
|
|
"""
|
|
|
|
def __init__(self, max_attempts: int = 3):
|
|
super().__init__(max_attempts)
|
|
self.strategy_type = RecoveryStrategyType.DATA_NORMALIZATION
|
|
|
|
def can_handle(self, error_type: str, context: Dict[str, Any]) -> bool:
|
|
"""Peut gérer les erreurs de validation"""
|
|
validation_errors = [
|
|
"ValidationError", "ValueError", "TypeError", "FormatError",
|
|
"validation_error", "value_error", "type_error", "format_error"
|
|
]
|
|
return error_type in validation_errors
|
|
|
|
def recover(self, context: RecoveryContext) -> RecoveryResult:
|
|
"""
|
|
Récupération par normalisation des données
|
|
|
|
Stratégies appliquées dans l'ordre:
|
|
1. Conversion de types automatique
|
|
2. Normalisation de formats
|
|
3. Nettoyage et correction de données
|
|
"""
|
|
start_time = time.time()
|
|
|
|
try:
|
|
# Extraire les données à normaliser
|
|
invalid_data = context.original_data.get('invalid_data')
|
|
expected_type = context.original_data.get('expected_type')
|
|
field_name = context.original_data.get('field_name', 'unknown')
|
|
|
|
if invalid_data is None:
|
|
return RecoveryResult.failure_with_escalation(
|
|
self.strategy_type,
|
|
"No invalid data provided",
|
|
"Cannot normalize data without input"
|
|
)
|
|
|
|
# Appliquer stratégie selon tentative
|
|
if context.attempt_number == 1:
|
|
normalized_data = self._type_conversion(invalid_data, expected_type)
|
|
message = f"Applied type conversion for field '{field_name}'"
|
|
|
|
elif context.attempt_number == 2:
|
|
normalized_data = self._format_normalization(invalid_data, expected_type)
|
|
message = f"Applied format normalization for field '{field_name}'"
|
|
|
|
elif context.attempt_number == 3:
|
|
normalized_data = self._data_cleaning(invalid_data, expected_type)
|
|
message = f"Applied data cleaning for field '{field_name}'"
|
|
|
|
else:
|
|
return RecoveryResult.failure_with_escalation(
|
|
self.strategy_type,
|
|
f"Max normalization attempts reached ({self.max_attempts})",
|
|
"All data normalization strategies exhausted"
|
|
)
|
|
|
|
duration_ms = (time.time() - start_time) * 1000
|
|
|
|
recovery_data = {
|
|
'original_data': invalid_data,
|
|
'normalized_data': normalized_data,
|
|
'field_name': field_name,
|
|
'expected_type': expected_type,
|
|
'normalization_type': f'attempt_{context.attempt_number}'
|
|
}
|
|
|
|
result = RecoveryResult.success_with_retry(
|
|
self.strategy_type,
|
|
recovery_data,
|
|
message
|
|
)
|
|
result.duration_ms = duration_ms
|
|
|
|
self._log_recovery_attempt(context, result)
|
|
return result
|
|
|
|
except Exception as e:
|
|
duration_ms = (time.time() - start_time) * 1000
|
|
logger.error(f"Data normalization strategy failed: {e}")
|
|
|
|
result = RecoveryResult.failure_with_escalation(
|
|
self.strategy_type,
|
|
f"Strategy execution error: {e}",
|
|
f"Data normalization failed: {e}"
|
|
)
|
|
result.duration_ms = duration_ms
|
|
return result
|
|
|
|
def _type_conversion(self, data: Any, expected_type: Optional[str]) -> Any:
|
|
"""Conversion de type automatique"""
|
|
if expected_type is None:
|
|
return data
|
|
|
|
try:
|
|
if expected_type == 'int':
|
|
if isinstance(data, str):
|
|
# Nettoyer les caractères non-numériques
|
|
cleaned = re.sub(r'[^\d.-]', '', data)
|
|
return int(float(cleaned)) if cleaned else 0
|
|
return int(data)
|
|
|
|
elif expected_type == 'float':
|
|
if isinstance(data, str):
|
|
cleaned = re.sub(r'[^\d.-]', '', data)
|
|
return float(cleaned) if cleaned else 0.0
|
|
return float(data)
|
|
|
|
elif expected_type == 'str':
|
|
return str(data)
|
|
|
|
elif expected_type == 'bool':
|
|
if isinstance(data, str):
|
|
return data.lower() in ['true', '1', 'yes', 'on', 'enabled']
|
|
return bool(data)
|
|
|
|
elif expected_type == 'datetime':
|
|
from datetime import datetime
|
|
if isinstance(data, str):
|
|
# Essayer plusieurs formats de date
|
|
formats = [
|
|
'%Y-%m-%d %H:%M:%S',
|
|
'%Y-%m-%d',
|
|
'%d/%m/%Y',
|
|
'%m/%d/%Y',
|
|
'%Y-%m-%dT%H:%M:%S'
|
|
]
|
|
for fmt in formats:
|
|
try:
|
|
return datetime.strptime(data, fmt)
|
|
except ValueError:
|
|
continue
|
|
return data
|
|
|
|
except (ValueError, TypeError) as e:
|
|
logger.warning(f"Type conversion failed: {e}")
|
|
|
|
return data
|
|
|
|
def _format_normalization(self, data: Any, expected_type: Optional[str]) -> Any:
|
|
"""Normalisation de format"""
|
|
if not isinstance(data, str):
|
|
return data
|
|
|
|
# Normalisation générale des chaînes
|
|
normalized = data.strip()
|
|
|
|
# Normalisation spécifique selon le type attendu
|
|
if expected_type == 'email':
|
|
normalized = normalized.lower()
|
|
|
|
elif expected_type == 'phone':
|
|
# Supprimer tous les caractères non-numériques sauf +
|
|
normalized = re.sub(r'[^\d+]', '', normalized)
|
|
|
|
elif expected_type == 'url':
|
|
if not normalized.startswith(('http://', 'https://')):
|
|
normalized = 'https://' + normalized
|
|
|
|
elif expected_type in ['bbox', 'coordinates']:
|
|
# Normaliser les coordonnées au format (x,y,w,h)
|
|
numbers = re.findall(r'-?\d+\.?\d*', normalized)
|
|
if len(numbers) >= 4:
|
|
normalized = f"({numbers[0]},{numbers[1]},{numbers[2]},{numbers[3]})"
|
|
|
|
return normalized
|
|
|
|
def _data_cleaning(self, data: Any, expected_type: Optional[str]) -> Any:
|
|
"""Nettoyage et correction de données"""
|
|
if isinstance(data, str):
|
|
# Nettoyage général
|
|
cleaned = data.strip()
|
|
|
|
# Supprimer caractères de contrôle
|
|
cleaned = re.sub(r'[\x00-\x1f\x7f-\x9f]', '', cleaned)
|
|
|
|
# Normaliser espaces multiples
|
|
cleaned = re.sub(r'\s+', ' ', cleaned)
|
|
|
|
# Corrections spécifiques
|
|
if expected_type == 'text':
|
|
# Corriger encodage commun
|
|
replacements = {
|
|
'’': "'",
|
|
'“': '"',
|
|
'â€': '"',
|
|
'…': '...'
|
|
}
|
|
for old, new in replacements.items():
|
|
cleaned = cleaned.replace(old, new)
|
|
|
|
return cleaned
|
|
|
|
return data
|
|
|
|
|
|
# Factory pour créer les stratégies
|
|
class RecoveryStrategyFactory:
|
|
"""Factory pour créer les stratégies de récupération appropriées"""
|
|
|
|
@staticmethod
|
|
def create_strategies() -> List[BaseRecoveryStrategy]:
|
|
"""Créer toutes les stratégies de récupération disponibles"""
|
|
return [
|
|
SpatialFallbackStrategy(),
|
|
SemanticVariantStrategy(),
|
|
RetryWithBackoffStrategy(),
|
|
DataNormalizationStrategy()
|
|
]
|
|
|
|
@staticmethod
|
|
def get_strategy_for_error(error_type: str, context: Dict[str, Any]) -> Optional[BaseRecoveryStrategy]:
|
|
"""Obtenir la stratégie appropriée pour un type d'erreur"""
|
|
strategies = RecoveryStrategyFactory.create_strategies()
|
|
|
|
for strategy in strategies:
|
|
if strategy.can_handle(error_type, context):
|
|
return strategy
|
|
|
|
return None
|
|
|
|
|
|
if __name__ == '__main__':
|
|
# Test des stratégies
|
|
logging.basicConfig(level=logging.INFO)
|
|
|
|
# Test SpatialFallbackStrategy
|
|
spatial_strategy = SpatialFallbackStrategy()
|
|
context = RecoveryContext(
|
|
error_type="TargetNotFoundError",
|
|
error_message="Target not found",
|
|
original_data={'target': {'bbox': {'x': 100, 'y': 100, 'width': 50, 'height': 20}}},
|
|
attempt_number=1,
|
|
max_attempts=3,
|
|
timestamp=datetime.now(),
|
|
additional_context={'screen_state': 'mock_state'}
|
|
)
|
|
|
|
result = spatial_strategy.recover(context)
|
|
print(f"Spatial strategy result: {result}")
|
|
|
|
# Test SemanticVariantStrategy
|
|
semantic_strategy = SemanticVariantStrategy()
|
|
context.error_type = "UIElementChangedError"
|
|
context.original_data = {'text_pattern': 'Submit Button'}
|
|
|
|
result = semantic_strategy.recover(context)
|
|
print(f"Semantic strategy result: {result}") |