Files
rpa_vision_v3/core/graph/node_matcher.py
Dom a27b74cf22 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>
2026-01-29 11:23:51 +01:00

306 lines
11 KiB
Python

"""NodeMatcher - Matching de ScreenStates contre WorkflowNodes en temps réel."""
import logging
import json
from pathlib import Path
from datetime import datetime
from typing import List, Optional, Tuple, Dict, Any
import numpy as np
from core.models.screen_state import ScreenState
from core.models.workflow_graph import WorkflowNode
from core.embedding.state_embedding_builder import StateEmbeddingBuilder
from core.embedding.faiss_manager import FAISSManager
from core.execution.error_handler import ErrorHandler, ErrorType, RecoveryStrategy
logger = logging.getLogger(__name__)
class NodeMatcher:
"""Matcher pour trouver le WorkflowNode correspondant à un ScreenState."""
def __init__(
self,
embedding_builder: Optional[StateEmbeddingBuilder] = None,
faiss_manager: Optional[FAISSManager] = None,
error_handler: Optional[ErrorHandler] = None,
similarity_threshold: float = 0.85,
failed_matches_dir: str = "data/failed_matches"
):
self.embedding_builder = embedding_builder or StateEmbeddingBuilder()
self.faiss_manager = faiss_manager
self.error_handler = error_handler or ErrorHandler()
self.similarity_threshold = similarity_threshold
self.failed_matches_dir = Path(failed_matches_dir)
self.failed_matches_dir.mkdir(parents=True, exist_ok=True)
logger.info(f"NodeMatcher initialized with threshold={similarity_threshold}")
def match(
self,
current_state: ScreenState,
candidate_nodes: List[WorkflowNode]
) -> Optional[Tuple[WorkflowNode, float]]:
"""
Trouver le WorkflowNode qui matche le mieux le ScreenState actuel.
Returns:
Tuple (node, confidence) si match trouvé, None sinon
"""
if not candidate_nodes:
logger.warning("No candidate nodes provided")
return None
state_embedding = self.embedding_builder.build(current_state)
current_vector = state_embedding.get_vector()
if self.faiss_manager:
return self._match_with_faiss(current_vector, candidate_nodes)
return self._match_linear(current_state, current_vector, candidate_nodes)
def _match_with_faiss(
self,
query_vector: np.ndarray,
candidate_nodes: List[WorkflowNode]
) -> Optional[Tuple[WorkflowNode, float]]:
"""Matcher avec recherche FAISS."""
results = self.faiss_manager.search(query_vector, k=5)
if not results:
return None
best_match = None
best_confidence = 0.0
for result in results:
similarity = result['similarity']
if similarity < self.similarity_threshold:
continue
for node in candidate_nodes:
if result['metadata'].get('node_id') == node.node_id:
if similarity > best_confidence:
best_match = node
best_confidence = similarity
if best_match:
logger.info(f"Matched node {best_match.node_id} with confidence {best_confidence:.3f}")
return (best_match, best_confidence)
return None
def _match_linear(
self,
current_state: ScreenState,
current_vector: np.ndarray,
candidate_nodes: List[WorkflowNode]
) -> Optional[Tuple[WorkflowNode, float]]:
"""Matcher avec recherche linéaire."""
best_match = None
best_confidence = 0.0
for node in candidate_nodes:
matches, confidence = node.matches(current_state, current_vector)
if matches and confidence > best_confidence:
best_match = node
best_confidence = confidence
if best_match and best_confidence >= self.similarity_threshold:
logger.info(f"Matched node {best_match.node_id} with confidence {best_confidence:.3f}")
return (best_match, best_confidence)
# Échec de matching - utiliser ErrorHandler
recovery = self.error_handler.handle_matching_failure(
current_state,
candidate_nodes,
best_confidence,
self.similarity_threshold
)
logger.warning(
f"No match found (best confidence: {best_confidence:.3f}, threshold: {self.similarity_threshold})"
)
logger.info(f"Recovery strategy: {recovery.strategy_used.value} - {recovery.message}")
# Logger aussi les détails localement pour compatibilité
self._log_failed_match(current_state, current_vector, candidate_nodes, best_confidence)
return None
def validate_constraints(
self,
state: ScreenState,
node: WorkflowNode
) -> bool:
"""Valider les contraintes du node contre l'état."""
template = node.screen_template
if template.window_title_pattern:
if not state.raw_level or not state.raw_level.window_title:
return False
return True
def _log_failed_match(
self,
state: ScreenState,
state_vector: np.ndarray,
candidate_nodes: List[WorkflowNode],
best_confidence: float
):
"""
Logger un échec de matching avec tous les détails pour analyse.
Sauvegarde:
- Screenshot de l'état non matché
- Vecteur d'embedding
- Similarités avec tous les nodes candidats
- Suggestions de mise à jour ou création de node
"""
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
failed_match_id = f"failed_match_{timestamp}"
failed_match_dir = self.failed_matches_dir / failed_match_id
failed_match_dir.mkdir(parents=True, exist_ok=True)
# Sauvegarder le screenshot
if state.raw_level and state.raw_level.screenshot_path:
import shutil
screenshot_dest = failed_match_dir / "screenshot.png"
try:
shutil.copy(state.raw_level.screenshot_path, screenshot_dest)
logger.debug(f"Screenshot saved to {screenshot_dest}")
except Exception as e:
logger.error(f"Failed to copy screenshot: {e}")
# Sauvegarder le vecteur d'embedding
vector_path = failed_match_dir / "state_embedding.npy"
np.save(vector_path, state_vector)
# Calculer similarités avec tous les nodes
similarities = []
for node in candidate_nodes:
if node.screen_template.embedding_prototype_path:
try:
prototype = np.load(node.screen_template.embedding_prototype_path)
similarity = float(np.dot(state_vector, prototype))
similarities.append({
'node_id': node.node_id,
'node_label': node.label,
'similarity': similarity,
'threshold': self.similarity_threshold,
'matched': similarity >= self.similarity_threshold
})
except Exception as e:
logger.error(f"Failed to load prototype for node {node.node_id}: {e}")
# Trier par similarité décroissante
similarities.sort(key=lambda x: x['similarity'], reverse=True)
# Générer suggestions
suggestions = self._generate_suggestions(similarities, best_confidence)
# Sauvegarder le rapport
report = {
'timestamp': timestamp,
'failed_match_id': failed_match_id,
'state': {
'window_title': state.raw_level.window_title if state.raw_level else None,
'screenshot_path': str(state.raw_level.screenshot_path) if state.raw_level else None,
'ui_elements_count': len(state.perception_level.ui_elements) if state.perception_level else 0
},
'matching_results': {
'best_confidence': best_confidence,
'threshold': self.similarity_threshold,
'num_candidates': len(candidate_nodes),
'similarities': similarities
},
'suggestions': suggestions
}
report_path = failed_match_dir / "report.json"
with open(report_path, 'w') as f:
json.dump(report, f, indent=2)
logger.info(f"Failed match logged to {failed_match_dir}")
logger.info(f"Suggestions: {', '.join(suggestions)}")
def _generate_suggestions(
self,
similarities: List[Dict[str, Any]],
best_confidence: float
) -> List[str]:
"""Générer des suggestions d'action basées sur les similarités."""
suggestions = []
if not similarities:
suggestions.append("CREATE_NEW_NODE: Aucun node candidat, créer un nouveau node")
return suggestions
best_match = similarities[0]
if best_confidence < 0.70:
suggestions.append(
f"CREATE_NEW_NODE: Similarité très faible ({best_confidence:.3f}), "
"probablement un nouvel état"
)
elif best_confidence < self.similarity_threshold:
suggestions.append(
f"UPDATE_NODE: Similarité proche ({best_confidence:.3f}) avec node "
f"'{best_match['node_label']}', considérer mise à jour du prototype"
)
suggestions.append(
f"ADJUST_THRESHOLD: Ou réduire le seuil de {self.similarity_threshold} "
f"à {best_confidence - 0.02:.3f}"
)
# Vérifier si plusieurs nodes ont des similarités proches
if len(similarities) >= 2:
diff = similarities[0]['similarity'] - similarities[1]['similarity']
if diff < 0.05:
suggestions.append(
f"AMBIGUOUS_MATCH: Deux nodes très similaires "
f"({similarities[0]['node_label']}: {similarities[0]['similarity']:.3f}, "
f"{similarities[1]['node_label']}: {similarities[1]['similarity']:.3f}), "
"affiner les prototypes"
)
return suggestions
def detect_ui_change(
self,
current_state: ScreenState,
expected_node: WorkflowNode,
current_similarity: float
) -> Tuple[bool, Optional[Any]]:
"""
Détecter si l'UI a changé de manière significative.
Args:
current_state: État actuel
expected_node: Node attendu
current_similarity: Similarité actuelle avec le prototype
Returns:
Tuple (ui_changed, recovery_result)
"""
return self.error_handler.detect_ui_change(
current_state,
expected_node,
current_similarity
)
def get_error_statistics(self) -> Dict[str, Any]:
"""
Obtenir les statistiques d'erreurs depuis l'ErrorHandler.
Returns:
Dict avec statistiques d'erreurs
"""
return self.error_handler.get_error_statistics()
if __name__ == '__main__':
logging.basicConfig(level=logging.INFO)
matcher = NodeMatcher()
logger.info(f"NodeMatcher initialized: {matcher}")