v1.0 - Version stable: multi-PC, détection UI-DETR-1, 3 modes exécution
- 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>
This commit is contained in:
15
core/healing/strategies/__init__.py
Normal file
15
core/healing/strategies/__init__.py
Normal file
@@ -0,0 +1,15 @@
|
||||
"""Recovery strategies for self-healing workflows."""
|
||||
|
||||
from .base_strategy import RecoveryStrategy
|
||||
from .semantic_variants import SemanticVariantStrategy
|
||||
from .spatial_fallback import SpatialFallbackStrategy
|
||||
from .timing_adaptation import TimingAdaptationStrategy
|
||||
from .format_transformation import FormatTransformationStrategy
|
||||
|
||||
__all__ = [
|
||||
'RecoveryStrategy',
|
||||
'SemanticVariantStrategy',
|
||||
'SpatialFallbackStrategy',
|
||||
'TimingAdaptationStrategy',
|
||||
'FormatTransformationStrategy',
|
||||
]
|
||||
50
core/healing/strategies/base_strategy.py
Normal file
50
core/healing/strategies/base_strategy.py
Normal file
@@ -0,0 +1,50 @@
|
||||
"""Base class for recovery strategies."""
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
from typing import Optional
|
||||
from ..models import RecoveryContext, RecoveryResult
|
||||
|
||||
|
||||
class RecoveryStrategy(ABC):
|
||||
"""Abstract base class for all recovery strategies."""
|
||||
|
||||
def __init__(self):
|
||||
self.name = self.__class__.__name__
|
||||
|
||||
@abstractmethod
|
||||
def attempt_recovery(self, context: RecoveryContext) -> RecoveryResult:
|
||||
"""
|
||||
Attempt to recover from a workflow failure.
|
||||
|
||||
Args:
|
||||
context: Recovery context with failure information
|
||||
|
||||
Returns:
|
||||
RecoveryResult with outcome of recovery attempt
|
||||
"""
|
||||
pass
|
||||
|
||||
def can_handle(self, context: RecoveryContext) -> bool:
|
||||
"""
|
||||
Check if this strategy can handle the given failure context.
|
||||
|
||||
Args:
|
||||
context: Recovery context
|
||||
|
||||
Returns:
|
||||
True if strategy can handle this failure type
|
||||
"""
|
||||
return True
|
||||
|
||||
def get_priority(self, context: RecoveryContext) -> float:
|
||||
"""
|
||||
Get priority for this strategy given the context.
|
||||
Higher values = higher priority.
|
||||
|
||||
Args:
|
||||
context: Recovery context
|
||||
|
||||
Returns:
|
||||
Priority score (0.0 to 1.0)
|
||||
"""
|
||||
return 0.5
|
||||
222
core/healing/strategies/format_transformation.py
Normal file
222
core/healing/strategies/format_transformation.py
Normal file
@@ -0,0 +1,222 @@
|
||||
"""Format transformation recovery strategy."""
|
||||
|
||||
import re
|
||||
import time
|
||||
from typing import List, Optional
|
||||
from datetime import datetime
|
||||
from .base_strategy import RecoveryStrategy
|
||||
from ..models import RecoveryContext, RecoveryResult
|
||||
|
||||
|
||||
class FormatTransformationStrategy(RecoveryStrategy):
|
||||
"""Transform input formats to match validation requirements."""
|
||||
|
||||
def __init__(self):
|
||||
"""Initialize format transformation strategy."""
|
||||
super().__init__()
|
||||
|
||||
# Date format patterns
|
||||
self.date_formats = [
|
||||
'%Y-%m-%d', # 2024-11-30
|
||||
'%d/%m/%Y', # 30/11/2024
|
||||
'%m/%d/%Y', # 11/30/2024
|
||||
'%d-%m-%Y', # 30-11-2024
|
||||
'%Y/%m/%d', # 2024/11/30
|
||||
'%d.%m.%Y', # 30.11.2024
|
||||
'%B %d, %Y', # November 30, 2024
|
||||
'%d %B %Y', # 30 November 2024
|
||||
]
|
||||
|
||||
# Phone format patterns
|
||||
self.phone_formats = [
|
||||
lambda p: p, # Original
|
||||
lambda p: re.sub(r'\D', '', p), # Digits only
|
||||
lambda p: "+" + re.sub(r"\D", "", p), # +digits
|
||||
lambda p: self._format_phone_us(p), # (123) 456-7890
|
||||
lambda p: self._format_phone_intl(p), # +1-123-456-7890
|
||||
]
|
||||
|
||||
def attempt_recovery(self, context: RecoveryContext) -> RecoveryResult:
|
||||
"""
|
||||
Try to transform input format to match validation.
|
||||
|
||||
Args:
|
||||
context: Recovery context
|
||||
|
||||
Returns:
|
||||
RecoveryResult with outcome
|
||||
"""
|
||||
start_time = time.time()
|
||||
|
||||
# Only handle format validation failures
|
||||
if context.failure_reason not in ['validation_failed', 'format_error']:
|
||||
return RecoveryResult(
|
||||
success=False,
|
||||
strategy_used='format_transformation',
|
||||
error_message='Strategy only handles format/validation failures'
|
||||
)
|
||||
|
||||
# Get input value
|
||||
input_value = context.metadata.get('input_value', '')
|
||||
if not input_value:
|
||||
return RecoveryResult(
|
||||
success=False,
|
||||
strategy_used='format_transformation',
|
||||
error_message='No input value provided in context'
|
||||
)
|
||||
|
||||
# Detect input type and try transformations
|
||||
input_type = self._detect_input_type(input_value, context)
|
||||
|
||||
if input_type == 'date':
|
||||
result = self._try_date_formats(input_value, context)
|
||||
elif input_type == 'phone':
|
||||
result = self._try_phone_formats(input_value, context)
|
||||
elif input_type == 'text':
|
||||
result = self._try_text_adaptations(input_value, context)
|
||||
else:
|
||||
result = None
|
||||
|
||||
execution_time = time.time() - start_time
|
||||
|
||||
if result:
|
||||
return RecoveryResult(
|
||||
success=True,
|
||||
strategy_used='format_transformation',
|
||||
new_element=result['formatted_value'],
|
||||
confidence_score=result['confidence'],
|
||||
execution_time=execution_time,
|
||||
learned_pattern={
|
||||
'input_type': input_type,
|
||||
'original_format': input_value,
|
||||
'new_format': result['formatted_value'],
|
||||
'transformation': result['transformation']
|
||||
}
|
||||
)
|
||||
|
||||
return RecoveryResult(
|
||||
success=False,
|
||||
strategy_used='format_transformation',
|
||||
execution_time=execution_time,
|
||||
error_message=f'Could not find valid format transformation for: {input_value}'
|
||||
)
|
||||
|
||||
def can_handle(self, context: RecoveryContext) -> bool:
|
||||
"""Check if this strategy can handle the failure."""
|
||||
return context.failure_reason in ['validation_failed', 'format_error', 'input_rejected']
|
||||
|
||||
def _detect_input_type(self, value: str, context: RecoveryContext) -> str:
|
||||
"""Detect the type of input value."""
|
||||
# Check metadata first
|
||||
if 'input_type' in context.metadata:
|
||||
return context.metadata['input_type']
|
||||
|
||||
# Try to detect from value
|
||||
if self._looks_like_date(value):
|
||||
return 'date'
|
||||
elif self._looks_like_phone(value):
|
||||
return 'phone'
|
||||
else:
|
||||
return 'text'
|
||||
|
||||
def _looks_like_date(self, value: str) -> bool:
|
||||
"""Check if value looks like a date."""
|
||||
# Contains date-like patterns
|
||||
date_patterns = [
|
||||
r'\d{4}[-/]\d{1,2}[-/]\d{1,2}', # YYYY-MM-DD
|
||||
r'\d{1,2}[-/]\d{1,2}[-/]\d{4}', # DD-MM-YYYY or MM-DD-YYYY
|
||||
r'\d{1,2}\s+\w+\s+\d{4}', # DD Month YYYY
|
||||
]
|
||||
return any(re.search(pattern, value) for pattern in date_patterns)
|
||||
|
||||
def _looks_like_phone(self, value: str) -> bool:
|
||||
"""Check if value looks like a phone number."""
|
||||
# Contains mostly digits with optional formatting
|
||||
digits = re.sub(r'\D', '', value)
|
||||
return len(digits) >= 7 and len(digits) <= 15
|
||||
|
||||
def _try_date_formats(self, value: str, context: RecoveryContext) -> Optional[dict]:
|
||||
"""Try different date formats."""
|
||||
# Try to parse the date
|
||||
parsed_date = None
|
||||
for fmt in self.date_formats:
|
||||
try:
|
||||
parsed_date = datetime.strptime(value, fmt)
|
||||
break
|
||||
except ValueError:
|
||||
continue
|
||||
|
||||
if not parsed_date:
|
||||
return None
|
||||
|
||||
# Try different output formats
|
||||
for fmt in self.date_formats:
|
||||
formatted = parsed_date.strftime(fmt)
|
||||
# In real implementation, would try this format
|
||||
# For now, assume first different format works
|
||||
if formatted != value:
|
||||
return {
|
||||
'formatted_value': formatted,
|
||||
'confidence': 0.85,
|
||||
'transformation': f'date_format:{fmt}'
|
||||
}
|
||||
|
||||
return None
|
||||
|
||||
def _try_phone_formats(self, value: str, context: RecoveryContext) -> Optional[dict]:
|
||||
"""Try different phone formats."""
|
||||
for i, formatter in enumerate(self.phone_formats):
|
||||
try:
|
||||
formatted = formatter(value)
|
||||
if formatted != value:
|
||||
return {
|
||||
'formatted_value': formatted,
|
||||
'confidence': 0.75,
|
||||
'transformation': f'phone_format:{i}'
|
||||
}
|
||||
except:
|
||||
continue
|
||||
|
||||
return None
|
||||
|
||||
def _try_text_adaptations(self, value: str, context: RecoveryContext) -> Optional[dict]:
|
||||
"""Try text adaptations like truncation."""
|
||||
# Check if there's a max length constraint
|
||||
max_length = context.metadata.get('max_length')
|
||||
|
||||
if max_length and len(value) > max_length:
|
||||
# Try truncation
|
||||
truncated = value[:max_length]
|
||||
return {
|
||||
'formatted_value': truncated,
|
||||
'confidence': 0.6,
|
||||
'transformation': f'truncate:{max_length}'
|
||||
}
|
||||
|
||||
# Try other adaptations
|
||||
# Remove extra whitespace
|
||||
cleaned = ' '.join(value.split())
|
||||
if cleaned != value:
|
||||
return {
|
||||
'formatted_value': cleaned,
|
||||
'confidence': 0.7,
|
||||
'transformation': 'clean_whitespace'
|
||||
}
|
||||
|
||||
return None
|
||||
|
||||
def _format_phone_us(self, phone: str) -> str:
|
||||
"""Format phone number as US format: (123) 456-7890."""
|
||||
digits = re.sub(r'\D', '', phone)
|
||||
if len(digits) == 10:
|
||||
return f"({digits[:3]}) {digits[3:6]}-{digits[6:]}"
|
||||
return phone
|
||||
|
||||
def _format_phone_intl(self, phone: str) -> str:
|
||||
"""Format phone number as international: +1-123-456-7890."""
|
||||
digits = re.sub(r'\D', '', phone)
|
||||
if len(digits) == 10:
|
||||
return f"+1-{digits[:3]}-{digits[3:6]}-{digits[6:]}"
|
||||
elif len(digits) == 11 and digits[0] == '1':
|
||||
return f"+{digits[0]}-{digits[1:4]}-{digits[4:7]}-{digits[7:]}"
|
||||
return phone
|
||||
154
core/healing/strategies/semantic_variants.py
Normal file
154
core/healing/strategies/semantic_variants.py
Normal file
@@ -0,0 +1,154 @@
|
||||
"""Semantic variant recovery strategy."""
|
||||
|
||||
import re
|
||||
from typing import List, Dict, Optional
|
||||
from .base_strategy import RecoveryStrategy
|
||||
from ..models import RecoveryContext, RecoveryResult
|
||||
|
||||
|
||||
class SemanticVariantStrategy(RecoveryStrategy):
|
||||
"""Find semantic variants of UI elements."""
|
||||
|
||||
def __init__(self):
|
||||
"""Initialize semantic variant strategy."""
|
||||
super().__init__()
|
||||
|
||||
# Predefined semantic mappings (English and French)
|
||||
self.variant_mappings = {
|
||||
'submit': ['send', 'ok', 'confirm', 'apply', 'save', 'envoyer', 'valider', 'soumettre'],
|
||||
'cancel': ['close', 'abort', 'back', 'dismiss', 'annuler', 'fermer', 'retour'],
|
||||
'login': ['sign in', 'log in', 'connect', 'connexion', 'se connecter', 'authentifier'],
|
||||
'logout': ['sign out', 'log out', 'disconnect', 'déconnexion', 'se déconnecter'],
|
||||
'search': ['find', 'lookup', 'query', 'chercher', 'rechercher', 'trouver'],
|
||||
'delete': ['remove', 'trash', 'erase', 'supprimer', 'effacer', 'retirer'],
|
||||
'edit': ['modify', 'change', 'update', 'modifier', 'changer', 'éditer'],
|
||||
'add': ['create', 'new', 'insert', 'ajouter', 'créer', 'nouveau'],
|
||||
'next': ['continue', 'forward', 'suivant', 'continuer', 'avancer'],
|
||||
'previous': ['back', 'backward', 'précédent', 'retour', 'arrière'],
|
||||
'yes': ['ok', 'confirm', 'accept', 'oui', 'confirmer', 'accepter'],
|
||||
'no': ['cancel', 'decline', 'reject', 'non', 'refuser', 'décliner'],
|
||||
}
|
||||
|
||||
# Build reverse mapping
|
||||
self.reverse_mapping = {}
|
||||
for key, variants in self.variant_mappings.items():
|
||||
for variant in variants:
|
||||
if variant not in self.reverse_mapping:
|
||||
self.reverse_mapping[variant] = []
|
||||
self.reverse_mapping[variant].append(key)
|
||||
|
||||
def attempt_recovery(self, context: RecoveryContext) -> RecoveryResult:
|
||||
"""
|
||||
Try to find semantic variants of the target element.
|
||||
|
||||
Args:
|
||||
context: Recovery context
|
||||
|
||||
Returns:
|
||||
RecoveryResult with outcome
|
||||
"""
|
||||
import time
|
||||
start_time = time.time()
|
||||
|
||||
# Extract text from element
|
||||
original_text = self._extract_text_from_element(context.target_element)
|
||||
if not original_text:
|
||||
return RecoveryResult(
|
||||
success=False,
|
||||
strategy_used='semantic_variant',
|
||||
error_message='Could not extract text from element'
|
||||
)
|
||||
|
||||
# Get semantic variants
|
||||
variants = self._get_semantic_variants(original_text)
|
||||
|
||||
# Try each variant
|
||||
for variant in variants:
|
||||
# In real implementation, this would use UI detector
|
||||
# For now, we simulate finding the element
|
||||
element = self._find_element_by_text(variant, context)
|
||||
if element:
|
||||
confidence = self._calculate_semantic_confidence(original_text, variant)
|
||||
execution_time = time.time() - start_time
|
||||
|
||||
return RecoveryResult(
|
||||
success=True,
|
||||
strategy_used='semantic_variant',
|
||||
new_element=element,
|
||||
confidence_score=confidence,
|
||||
execution_time=execution_time,
|
||||
learned_pattern={
|
||||
'original_text': original_text,
|
||||
'found_variant': variant
|
||||
}
|
||||
)
|
||||
|
||||
execution_time = time.time() - start_time
|
||||
return RecoveryResult(
|
||||
success=False,
|
||||
strategy_used='semantic_variant',
|
||||
execution_time=execution_time,
|
||||
error_message=f'No semantic variants found for: {original_text}'
|
||||
)
|
||||
|
||||
def can_handle(self, context: RecoveryContext) -> bool:
|
||||
"""Check if this strategy can handle the failure."""
|
||||
return context.failure_reason in ['element_not_found', 'element_changed']
|
||||
|
||||
def _extract_text_from_element(self, element: str) -> str:
|
||||
"""Extract text from element identifier."""
|
||||
# Simple extraction - in real implementation would parse element structure
|
||||
if isinstance(element, str):
|
||||
# Remove common prefixes/suffixes
|
||||
text = re.sub(r'^(button|link|input|text):', '', element, flags=re.IGNORECASE)
|
||||
text = text.strip()
|
||||
return text
|
||||
return str(element)
|
||||
|
||||
def _get_semantic_variants(self, text: str) -> List[str]:
|
||||
"""Get semantic variants for the given text."""
|
||||
text_lower = text.lower().strip()
|
||||
variants = []
|
||||
|
||||
# Check direct mapping
|
||||
if text_lower in self.variant_mappings:
|
||||
variants.extend(self.variant_mappings[text_lower])
|
||||
|
||||
# Check reverse mapping
|
||||
if text_lower in self.reverse_mapping:
|
||||
for key in self.reverse_mapping[text_lower]:
|
||||
variants.extend(self.variant_mappings[key])
|
||||
|
||||
# Remove duplicates and original text
|
||||
variants = list(set(variants))
|
||||
if text_lower in variants:
|
||||
variants.remove(text_lower)
|
||||
|
||||
return variants
|
||||
|
||||
def _find_element_by_text(self, text: str, context: RecoveryContext) -> Optional[str]:
|
||||
"""
|
||||
Find element by text in screenshot.
|
||||
|
||||
This is a placeholder - real implementation would use UI detector.
|
||||
"""
|
||||
# TODO: Integrate with UIDetector to actually find elements
|
||||
# For now, return None to indicate not found
|
||||
return None
|
||||
|
||||
def _calculate_semantic_confidence(self, original: str, variant: str) -> float:
|
||||
"""Calculate confidence score for semantic variant match."""
|
||||
original_lower = original.lower().strip()
|
||||
variant_lower = variant.lower().strip()
|
||||
|
||||
# Higher confidence for direct mappings
|
||||
if original_lower in self.variant_mappings:
|
||||
if variant_lower in self.variant_mappings[original_lower]:
|
||||
return 0.85
|
||||
|
||||
# Medium confidence for reverse mappings
|
||||
if original_lower in self.reverse_mapping:
|
||||
return 0.75
|
||||
|
||||
# Lower confidence for fuzzy matches
|
||||
return 0.6
|
||||
174
core/healing/strategies/spatial_fallback.py
Normal file
174
core/healing/strategies/spatial_fallback.py
Normal file
@@ -0,0 +1,174 @@
|
||||
"""Spatial fallback recovery strategy."""
|
||||
|
||||
import time
|
||||
from typing import Optional, List, Tuple
|
||||
from .base_strategy import RecoveryStrategy
|
||||
from ..models import RecoveryContext, RecoveryResult
|
||||
|
||||
|
||||
class SpatialFallbackStrategy(RecoveryStrategy):
|
||||
"""Search in expanded areas around the original element position."""
|
||||
|
||||
def __init__(self):
|
||||
"""Initialize spatial fallback strategy."""
|
||||
super().__init__()
|
||||
self.search_radii = [50, 100, 200, 400] # pixels
|
||||
|
||||
def attempt_recovery(self, context: RecoveryContext) -> RecoveryResult:
|
||||
"""
|
||||
Search in progressively larger areas around original position.
|
||||
|
||||
Args:
|
||||
context: Recovery context
|
||||
|
||||
Returns:
|
||||
RecoveryResult with outcome
|
||||
"""
|
||||
start_time = time.time()
|
||||
|
||||
# Get original position
|
||||
original_pos = self._get_original_position(context)
|
||||
if not original_pos:
|
||||
return RecoveryResult(
|
||||
success=False,
|
||||
strategy_used='spatial_fallback',
|
||||
error_message='Could not determine original element position'
|
||||
)
|
||||
|
||||
# Try progressively larger search areas
|
||||
for radius in self.search_radii:
|
||||
search_area = self._expand_search_area(original_pos, radius)
|
||||
elements = self._find_similar_elements_in_area(search_area, context)
|
||||
|
||||
if elements:
|
||||
best_match = self._select_best_spatial_match(elements, original_pos)
|
||||
confidence = self._calculate_spatial_confidence(best_match, original_pos, radius)
|
||||
execution_time = time.time() - start_time
|
||||
|
||||
return RecoveryResult(
|
||||
success=True,
|
||||
strategy_used='spatial_fallback',
|
||||
new_element=best_match['element'],
|
||||
confidence_score=confidence,
|
||||
execution_time=execution_time,
|
||||
learned_pattern={
|
||||
'original_position': original_pos,
|
||||
'found_position': best_match['position'],
|
||||
'search_radius': radius
|
||||
}
|
||||
)
|
||||
|
||||
execution_time = time.time() - start_time
|
||||
return RecoveryResult(
|
||||
success=False,
|
||||
strategy_used='spatial_fallback',
|
||||
execution_time=execution_time,
|
||||
error_message='No similar elements found in expanded search areas'
|
||||
)
|
||||
|
||||
def can_handle(self, context: RecoveryContext) -> bool:
|
||||
"""Check if this strategy can handle the failure."""
|
||||
return context.failure_reason in ['element_not_found', 'element_moved']
|
||||
|
||||
def _get_original_position(self, context: RecoveryContext) -> Optional[Tuple[int, int]]:
|
||||
"""Extract original element position from context."""
|
||||
# Try to get position from metadata
|
||||
if 'position' in context.metadata:
|
||||
pos = context.metadata['position']
|
||||
if isinstance(pos, (list, tuple)) and len(pos) >= 2:
|
||||
return (int(pos[0]), int(pos[1]))
|
||||
|
||||
# Try to parse from element string
|
||||
# Format: "element@(x,y)"
|
||||
if '@(' in context.target_element:
|
||||
try:
|
||||
pos_str = context.target_element.split('@(')[1].split(')')[0]
|
||||
x, y = pos_str.split(',')
|
||||
return (int(x.strip()), int(y.strip()))
|
||||
except:
|
||||
pass
|
||||
|
||||
return None
|
||||
|
||||
def _expand_search_area(
|
||||
self,
|
||||
center: Tuple[int, int],
|
||||
radius: int
|
||||
) -> Tuple[int, int, int, int]:
|
||||
"""
|
||||
Expand search area around center point.
|
||||
|
||||
Returns:
|
||||
(x1, y1, x2, y2) bounding box
|
||||
"""
|
||||
x, y = center
|
||||
return (
|
||||
max(0, x - radius),
|
||||
max(0, y - radius),
|
||||
x + radius,
|
||||
y + radius
|
||||
)
|
||||
|
||||
def _find_similar_elements_in_area(
|
||||
self,
|
||||
search_area: Tuple[int, int, int, int],
|
||||
context: RecoveryContext
|
||||
) -> List[dict]:
|
||||
"""
|
||||
Find similar elements in the search area.
|
||||
|
||||
This is a placeholder - real implementation would use UI detector.
|
||||
"""
|
||||
# TODO: Integrate with UIDetector to find elements in area
|
||||
# For now, return empty list
|
||||
return []
|
||||
|
||||
def _select_best_spatial_match(
|
||||
self,
|
||||
elements: List[dict],
|
||||
original_pos: Tuple[int, int]
|
||||
) -> dict:
|
||||
"""Select the best matching element based on distance and similarity."""
|
||||
if not elements:
|
||||
return None
|
||||
|
||||
# Score each element
|
||||
scored_elements = []
|
||||
for element in elements:
|
||||
distance = self._calculate_distance(original_pos, element['position'])
|
||||
similarity = element.get('similarity', 0.5)
|
||||
|
||||
# Combined score (closer and more similar = better)
|
||||
score = similarity * (1.0 / (1.0 + distance / 100.0))
|
||||
scored_elements.append((score, element))
|
||||
|
||||
# Return element with highest score
|
||||
scored_elements.sort(key=lambda x: x[0], reverse=True)
|
||||
return scored_elements[0][1]
|
||||
|
||||
def _calculate_spatial_confidence(
|
||||
self,
|
||||
match: dict,
|
||||
original_pos: Tuple[int, int],
|
||||
radius: int
|
||||
) -> float:
|
||||
"""Calculate confidence score for spatial match."""
|
||||
distance = self._calculate_distance(original_pos, match['position'])
|
||||
similarity = match.get('similarity', 0.5)
|
||||
|
||||
# Distance factor (closer = higher confidence)
|
||||
distance_factor = 1.0 - (distance / (radius * 2))
|
||||
distance_factor = max(0.0, min(1.0, distance_factor))
|
||||
|
||||
# Combined confidence
|
||||
confidence = (similarity * 0.6 + distance_factor * 0.4)
|
||||
|
||||
return max(0.0, min(1.0, confidence))
|
||||
|
||||
def _calculate_distance(
|
||||
self,
|
||||
pos1: Tuple[int, int],
|
||||
pos2: Tuple[int, int]
|
||||
) -> float:
|
||||
"""Calculate Euclidean distance between two positions."""
|
||||
return ((pos1[0] - pos2[0])**2 + (pos1[1] - pos2[1])**2)**0.5
|
||||
150
core/healing/strategies/timing_adaptation.py
Normal file
150
core/healing/strategies/timing_adaptation.py
Normal file
@@ -0,0 +1,150 @@
|
||||
"""Timing adaptation recovery strategy."""
|
||||
|
||||
import time
|
||||
from typing import Dict
|
||||
from .base_strategy import RecoveryStrategy
|
||||
from ..models import RecoveryContext, RecoveryResult
|
||||
|
||||
|
||||
class TimingAdaptationStrategy(RecoveryStrategy):
|
||||
"""Adapt wait times and timeouts based on performance."""
|
||||
|
||||
def __init__(self):
|
||||
"""Initialize timing adaptation strategy."""
|
||||
super().__init__()
|
||||
self.performance_history: Dict[str, list] = {}
|
||||
self.min_wait = 0.5
|
||||
self.max_wait = 30.0
|
||||
self.adaptation_factor = 1.5
|
||||
|
||||
def attempt_recovery(self, context: RecoveryContext) -> RecoveryResult:
|
||||
"""
|
||||
Adapt timing based on historical performance.
|
||||
|
||||
Args:
|
||||
context: Recovery context
|
||||
|
||||
Returns:
|
||||
RecoveryResult with outcome
|
||||
"""
|
||||
start_time = time.time()
|
||||
|
||||
# Only handle timeout failures
|
||||
if context.failure_reason != 'timeout':
|
||||
return RecoveryResult(
|
||||
success=False,
|
||||
strategy_used='timing_adaptation',
|
||||
error_message='Strategy only handles timeout failures'
|
||||
)
|
||||
|
||||
# Get current wait time
|
||||
current_wait = self._get_current_wait_time(context)
|
||||
|
||||
# Calculate adapted wait time
|
||||
adapted_wait = min(current_wait * self.adaptation_factor, self.max_wait)
|
||||
|
||||
# Try with adapted timing
|
||||
success = self._retry_with_timing(context, adapted_wait)
|
||||
|
||||
execution_time = time.time() - start_time
|
||||
|
||||
if success:
|
||||
# Update performance history
|
||||
self._update_performance_history(context, adapted_wait)
|
||||
|
||||
return RecoveryResult(
|
||||
success=True,
|
||||
strategy_used='timing_adaptation',
|
||||
confidence_score=0.8,
|
||||
execution_time=execution_time,
|
||||
learned_pattern={
|
||||
'original_wait': current_wait,
|
||||
'new_wait_time': adapted_wait,
|
||||
'element': context.target_element
|
||||
}
|
||||
)
|
||||
|
||||
return RecoveryResult(
|
||||
success=False,
|
||||
strategy_used='timing_adaptation',
|
||||
execution_time=execution_time,
|
||||
error_message=f'Timeout even with adapted wait time: {adapted_wait}s'
|
||||
)
|
||||
|
||||
def can_handle(self, context: RecoveryContext) -> bool:
|
||||
"""Check if this strategy can handle the failure."""
|
||||
return context.failure_reason == 'timeout'
|
||||
|
||||
def get_optimized_wait_time(self, element_key: str, default: float = 5.0) -> float:
|
||||
"""
|
||||
Get optimized wait time based on historical performance.
|
||||
|
||||
Args:
|
||||
element_key: Key identifying the element/action
|
||||
default: Default wait time if no history
|
||||
|
||||
Returns:
|
||||
Optimized wait time in seconds
|
||||
"""
|
||||
if element_key not in self.performance_history:
|
||||
return default
|
||||
|
||||
history = self.performance_history[element_key]
|
||||
if not history:
|
||||
return default
|
||||
|
||||
# Use average of recent successful timings
|
||||
recent = history[-10:] # Last 10 attempts
|
||||
avg_time = sum(recent) / len(recent)
|
||||
|
||||
# Add 20% buffer for safety
|
||||
optimized = avg_time * 1.2
|
||||
|
||||
return max(self.min_wait, min(optimized, self.max_wait))
|
||||
|
||||
def _get_current_wait_time(self, context: RecoveryContext) -> float:
|
||||
"""Extract current wait time from context."""
|
||||
# Try to get from metadata
|
||||
if 'wait_time' in context.metadata:
|
||||
return float(context.metadata['wait_time'])
|
||||
|
||||
# Try to get from performance history
|
||||
element_key = self._get_element_key(context)
|
||||
if element_key in self.performance_history:
|
||||
history = self.performance_history[element_key]
|
||||
if history:
|
||||
return history[-1]
|
||||
|
||||
# Default
|
||||
return 5.0
|
||||
|
||||
def _retry_with_timing(self, context: RecoveryContext, wait_time: float) -> bool:
|
||||
"""
|
||||
Retry the action with adapted timing.
|
||||
|
||||
This is a placeholder - real implementation would retry the actual action.
|
||||
"""
|
||||
# TODO: Integrate with execution loop to actually retry
|
||||
# For now, simulate with sleep
|
||||
time.sleep(min(wait_time, 1.0)) # Cap at 1s for testing
|
||||
|
||||
# Simulate success based on wait time
|
||||
# In real implementation, this would actually retry the action
|
||||
return wait_time >= 3.0
|
||||
|
||||
def _update_performance_history(self, context: RecoveryContext, wait_time: float):
|
||||
"""Update performance history with successful timing."""
|
||||
element_key = self._get_element_key(context)
|
||||
|
||||
if element_key not in self.performance_history:
|
||||
self.performance_history[element_key] = []
|
||||
|
||||
self.performance_history[element_key].append(wait_time)
|
||||
|
||||
# Keep only recent history (last 50 entries)
|
||||
if len(self.performance_history[element_key]) > 50:
|
||||
self.performance_history[element_key] = self.performance_history[element_key][-50:]
|
||||
|
||||
def _get_element_key(self, context: RecoveryContext) -> str:
|
||||
"""Generate a key for the element/action."""
|
||||
return f"{context.workflow_id}:{context.node_id}:{context.target_element}"
|
||||
Reference in New Issue
Block a user