feat(scoring): EdgeScorer utilise la vraie source_similarity (Lot B)

Avant : source_similarity=1.0 hardcodé dans _check_preconditions
-> la contrainte EdgeConstraints.min_source_similarity était
silencieusement désactivée. Un edge passait toujours.

Après : propagation ExecutionLoop -> workflow_pipeline -> EdgeScorer
  - select_best/rank/score_edge/_check_preconditions acceptent
    source_similarity: float (kwargs-only)
  - get_next_action() le propage
  - execution_loop passe la confidence issue de match_current_state

La contrainte min_source_similarity est opérationnelle pour la
première fois. Preuve concrète par test_min_source_similarity_fail
et test_low_similarity_blocks_edge (edge rejeté si sim < seuil).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Dom
2026-04-15 09:06:28 +02:00
parent af4ffa189a
commit 8c7b6e5696
2 changed files with 717 additions and 0 deletions

View File

@@ -0,0 +1,380 @@
"""
EdgeScorer — Sélection robuste d'un edge parmi plusieurs candidats.
Au lieu de prendre "le premier edge sortant" (comportement legacy),
ce module :
1. Applique un **filtre dur** : rejette les edges dont les `pre_conditions`
(EdgeConstraints) échouent étant donné le ScreenState courant.
2. Applique un **ranking léger** : score composite
- `stats.success_rate` (pondéré fort)
- match du `target_spec` (présence d'un UI element compatible)
- récence (dernière exécution réussie)
3. Retourne le meilleur edge, ou `None` si aucun ne passe le filtre.
API principale :
>>> scorer = EdgeScorer()
>>> edge = scorer.select_best(edges, screen_state=state)
Les scores individuels sont exposés via `score_edge()` pour les tests
et la télémétrie.
"""
from __future__ import annotations
import logging
from dataclasses import dataclass
from datetime import datetime
from typing import List, Optional, Sequence
from core.models.screen_state import ScreenState
from core.models.workflow_graph import WorkflowEdge
logger = logging.getLogger(__name__)
# =============================================================================
# Résultat de scoring (utile pour la télémétrie / debug)
# =============================================================================
@dataclass
class EdgeScore:
"""Résultat détaillé du scoring d'un edge."""
edge: WorkflowEdge
total: float
success_rate: float
target_match: float
recency: float
passed_preconditions: bool
precondition_reason: str = "OK"
def __lt__(self, other: "EdgeScore") -> bool:
# Utilisé par sorted() : plus grand score = meilleur
return self.total < other.total
# =============================================================================
# Scorer
# =============================================================================
class EdgeScorer:
"""
Sélectionne le meilleur edge sortant étant donné un ScreenState.
Les poids par défaut peuvent être ajustés à la construction.
"""
def __init__(
self,
weight_success_rate: float = 0.55,
weight_target_match: float = 0.35,
weight_recency: float = 0.10,
default_success_rate: float = 0.5,
):
"""
Args:
weight_success_rate: poids du `edge.stats.success_rate`
weight_target_match: poids du match `target_spec` / `ui_elements`
weight_recency: poids de la récence de la dernière exécution
default_success_rate: valeur quand l'edge n'a jamais été exécuté
"""
total = weight_success_rate + weight_target_match + weight_recency
if total <= 0:
raise ValueError("La somme des poids doit être > 0")
# Normalisation silencieuse
self.w_success = weight_success_rate / total
self.w_target = weight_target_match / total
self.w_recency = weight_recency / total
self.default_success_rate = default_success_rate
# -------------------------------------------------------------------------
# API publique
# -------------------------------------------------------------------------
def select_best(
self,
edges: Sequence[WorkflowEdge],
screen_state: Optional[ScreenState] = None,
strategy: str = "best",
source_similarity: float = 1.0,
) -> Optional[WorkflowEdge]:
"""
Sélectionne le meilleur edge.
Args:
edges: Liste des edges candidats (généralement les sortants d'un node)
screen_state: État courant pour évaluer pre_conditions et target_spec
strategy: "best" (défaut, score complet) ou "first" (legacy, premier edge)
source_similarity: confiance du matching qui a identifié le node
source courant (valeur propagée depuis `match_current_state`).
Utilisée pour évaluer la précondition ``min_source_similarity``
de chaque edge. Défaut à ``1.0`` pour compat avec les appelants
qui ne la fournissent pas encore.
Returns:
Meilleur edge ou None si aucun ne passe les pre_conditions
"""
if not edges:
return None
if strategy == "first":
# Comportement legacy — retourne le premier edge quoi qu'il arrive
return edges[0]
scores = self.rank(
edges, screen_state=screen_state, source_similarity=source_similarity
)
# Filtrer ceux qui ont passé les pre_conditions
valid = [s for s in scores if s.passed_preconditions]
if not valid:
# Aucun edge valide → log pour debug, retourner None
reasons = "; ".join(
f"{s.edge.edge_id}: {s.precondition_reason}" for s in scores[:5]
)
logger.warning(
f"[EdgeScorer] Aucun edge valide parmi {len(edges)} candidats. "
f"Raisons: {reasons}"
)
return None
best = valid[0].edge # déjà trié par score décroissant
logger.debug(
f"[EdgeScorer] Sélection {best.edge_id} "
f"(score={valid[0].total:.3f}, parmi {len(valid)} valides)"
)
return best
def rank(
self,
edges: Sequence[WorkflowEdge],
screen_state: Optional[ScreenState] = None,
source_similarity: float = 1.0,
) -> List[EdgeScore]:
"""
Retourne la liste des edges triés par score décroissant,
avec le détail pour chaque edge.
Tiebreak : `success_rate` le plus haut.
Args:
edges: edges candidats
screen_state: état courant (pour pre_conditions + target_match)
source_similarity: confiance du match courant, propagée aux
pre_conditions pour vérifier ``min_source_similarity``
"""
scored = [
self.score_edge(edge, screen_state, source_similarity=source_similarity)
for edge in edges
]
# Tri : score total décroissant, puis success_rate décroissant
scored.sort(key=lambda s: (s.total, s.success_rate), reverse=True)
return scored
# -------------------------------------------------------------------------
# Scoring par edge
# -------------------------------------------------------------------------
def score_edge(
self,
edge: WorkflowEdge,
screen_state: Optional[ScreenState] = None,
source_similarity: float = 1.0,
) -> EdgeScore:
"""
Calcule le score d'un edge.
Les pre_conditions sont évaluées ici mais servent uniquement de filtre
dur (le score total reste calculé, mais `passed_preconditions` est à False).
Args:
edge: edge à scorer
screen_state: état courant (fenêtre, textes, ui_elements)
source_similarity: confiance du matching courant, injectée dans
``EdgeConstraints.check_preconditions`` pour évaluer
``min_source_similarity``.
"""
# 1. Pre-conditions : filtre dur
passed, reason = self._check_preconditions(
edge, screen_state, source_similarity=source_similarity
)
# 2. Success rate (dépend des stats existantes)
success_rate = self._score_success_rate(edge)
# 3. Target match (UI element présent ?)
target_match = self._score_target_match(edge, screen_state)
# 4. Récence
recency = self._score_recency(edge)
total = (
self.w_success * success_rate
+ self.w_target * target_match
+ self.w_recency * recency
)
return EdgeScore(
edge=edge,
total=total,
success_rate=success_rate,
target_match=target_match,
recency=recency,
passed_preconditions=passed,
precondition_reason=reason,
)
# -------------------------------------------------------------------------
# Composantes du score
# -------------------------------------------------------------------------
def _check_preconditions(
self,
edge: WorkflowEdge,
screen_state: Optional[ScreenState],
source_similarity: float = 1.0,
) -> tuple[bool, str]:
"""
Vérifier les pre_conditions de l'edge.
Si pas de ScreenState, on ne peut rien vérifier → on laisse passer
(mais on loggue).
Args:
edge: edge à évaluer
screen_state: état courant (None si non dispo)
source_similarity: confiance du matching courant propagée par
l'appelant (EdgeScorer.score_edge/rank/select_best). Elle
alimente ``EdgeConstraints.check_preconditions`` pour rendre
effective la contrainte ``min_source_similarity``.
"""
constraints = edge.constraints
if constraints is None:
return True, "OK (pas de contraintes)"
if screen_state is None:
# Pas de ScreenState → on ne peut évaluer ni fenêtre, ni textes,
# mais la similarité source reste vérifiable.
try:
ok, reason = constraints.check_preconditions(
window_title="",
app_name="",
detected_texts=[],
source_similarity=source_similarity,
)
if not ok:
return ok, reason
except Exception as e:
logger.warning(f"[EdgeScorer] Erreur check_preconditions: {e}")
return True, f"Erreur ignorée: {e}"
return True, "OK (pas de ScreenState pour évaluer)"
window_title = screen_state.window.window_title if screen_state.window else ""
app_name = screen_state.window.app_name if screen_state.window else ""
detected_texts = (
screen_state.perception.detected_text
if screen_state.perception
else []
)
try:
ok, reason = constraints.check_preconditions(
window_title=window_title,
app_name=app_name,
detected_texts=detected_texts,
source_similarity=source_similarity,
)
return ok, reason
except Exception as e:
logger.warning(f"[EdgeScorer] Erreur check_preconditions: {e}")
# En cas d'erreur, on ne bloque pas l'edge
return True, f"Erreur ignorée: {e}"
def _score_success_rate(self, edge: WorkflowEdge) -> float:
"""Score basé sur `edge.stats.success_rate`."""
if edge.stats is None or edge.stats.execution_count == 0:
return self.default_success_rate
return max(0.0, min(1.0, edge.stats.success_rate))
def _score_target_match(
self,
edge: WorkflowEdge,
screen_state: Optional[ScreenState],
) -> float:
"""
Score de correspondance entre le `target_spec` de l'action et
les `ui_elements` de l'écran courant.
Retourne :
- 1.0 si un élément matche strictement (texte ou rôle)
- 0.5 si aucun screen_state fourni (neutre, pas pénalisant)
- 0.0 si aucun élément compatible
"""
if screen_state is None:
return 0.5
target = edge.action.target if edge.action else None
if target is None:
return 0.5
ui_elements = screen_state.ui_elements or []
if not ui_elements:
# Pas d'UI détectée → on ne peut pas trancher, neutre
return 0.5
target_text = (target.by_text or "").lower().strip()
target_role = (target.by_role or "").lower().strip()
best = 0.0
for el in ui_elements:
score = 0.0
el_label = getattr(el, "label", "") or ""
el_role = getattr(el, "role", "") or ""
el_type = getattr(el, "type", "") or ""
if target_text:
if target_text == el_label.lower().strip():
score = max(score, 1.0)
elif target_text in el_label.lower():
score = max(score, 0.8)
if target_role:
if target_role == el_role.lower() or target_role == el_type.lower():
score = max(score, 0.9)
if not target_text and not target_role and target.by_position:
# Si seule la position est fournie, on considère toujours match possible
score = 0.6
if score > best:
best = score
# Si on n'a rien trouvé mais qu'un target est demandé → 0.0 (fort négatif)
if best == 0.0 and (target_text or target_role):
return 0.0
return best if best > 0 else 0.5
def _score_recency(self, edge: WorkflowEdge) -> float:
"""
Score de récence basé sur `edge.stats.last_executed`.
Échelle :
- exécuté dans les dernières 24h : 1.0
- exécuté dans les 7 derniers jours : 0.7
- exécuté il y a plus longtemps : 0.3
- jamais exécuté : 0.5 (neutre)
"""
if edge.stats is None or edge.stats.last_executed is None:
return 0.5
delta = datetime.now() - edge.stats.last_executed
seconds = delta.total_seconds()
if seconds < 24 * 3600:
return 1.0
if seconds < 7 * 24 * 3600:
return 0.7
return 0.3