diff --git a/agent_v0/server_v1/resolve_engine.py b/agent_v0/server_v1/resolve_engine.py index 4e4b99486..e31d1e0d3 100644 --- a/agent_v0/server_v1/resolve_engine.py +++ b/agent_v0/server_v1/resolve_engine.py @@ -870,6 +870,50 @@ def _vlm_quick_find( # Résolution par VLM Grounding Direct (configurable via RPA_VLM_MODEL) # --------------------------------------------------------------------------- +# DETTE-019 — confiance grounding DÉRIVÉE (et NON une confiance modèle native). +# Le grounding VLM ne fournit aucune confiance exploitable : le prompt demande +# {"x","y"} et aucun logprob de localisation n'est extrait (confirmé QG Qwen +# 2026-06-15). Le seul signal de confiance RÉEL est sémantique : le texte cible +# est-il bien à la position trouvée ? On le dérive via la même vérif OCR que le +# pré-check aval (`_validate_text_at_position`). Approche validée par Dom. +# ⚠ Confiance CONTEXTUELLE, pas une probabilité du modèle : ne pas l'afficher +# comme « confiance du VLM » côté dashboard. +_GROUNDING_CONF_TEXT_CONFIRMED = 0.90 # texte cible retrouvé à la position +_GROUNDING_CONF_UNVERIFIABLE = 0.70 # pas de texte vérifiable → neutre (> seuil 0.60) +_GROUNDING_CONF_TEXT_ABSENT = 0.45 # texte cible absent → < seuil 0.60 → rejeté + + +def _grounding_semantic_confidence( + screenshot_path: str, + x_pct: float, + y_pct: float, + by_text: str, + screen_width: int, + screen_height: int, +) -> float: + """Confiance DÉRIVÉE (sémantique) d'un grounding — DETTE-019. + + Mesure contextuelle, PAS une confiance du modèle : le texte cible `by_text` + est-il présent à la position (x_pct, y_pct) ? Réutilise la garde OCR du + pré-check aval (`_validate_text_at_position`). + + - texte confirmé → CONFIRMED (accepté) + - texte absent → ABSENT (< seuil → rejeté par + `_validate_resolution_quality`) + - pas de by_text / OCR KO → UNVERIFIABLE (neutre, > seuil : pas de faux rejet) + """ + by_text = (by_text or "").strip() + if not by_text: + return _GROUNDING_CONF_UNVERIFIABLE + try: + is_valid, _observed, _ms = _validate_text_at_position( + screenshot_path, x_pct, y_pct, by_text, screen_width, screen_height, + ) + except Exception as e: # OCR indisponible : dégradation gracieuse, pas de pénalité + logger.debug("Grounding confidence : vérif sémantique indisponible (%s) → neutre", e) + return _GROUNDING_CONF_UNVERIFIABLE + return _GROUNDING_CONF_TEXT_CONFIRMED if is_valid else _GROUNDING_CONF_TEXT_ABSENT + def _resolve_by_grounding( screenshot_path: str, @@ -1113,6 +1157,13 @@ def _resolve_by_grounding( _grounding_model, description[:50], x_pct, y_pct, elapsed, ) + # DETTE-019 : confiance DÉRIVÉE sémantique (le texte cible est-il à la + # position ?), plus de score figé. Cohérence score == confidence. + _conf = _grounding_semantic_confidence( + screenshot_path, round(x_pct, 6), round(y_pct, 6), + by_text, screen_width, screen_height, + ) + return { "resolved": True, # method gardée par _RESOLUTION_MIN_SCORES : en mode qwen3vl, "grounding" @@ -1125,9 +1176,9 @@ def _resolve_by_grounding( "label": description[:60], "type": "grounding", "role": "grounding_vlm", - "confidence": 0.85, + "confidence": _conf, }, - "score": 0.85, + "score": _conf, } diff --git a/tests/unit/test_resolve_engine_dette019_confidence.py b/tests/unit/test_resolve_engine_dette019_confidence.py new file mode 100644 index 000000000..2dddf99e2 --- /dev/null +++ b/tests/unit/test_resolve_engine_dette019_confidence.py @@ -0,0 +1,133 @@ +"""DETTE-019 — confiance grounding RÉELLE (dérivée) vs score figé 0.85. + +Constat (confirmé par QG Qwen 2026-06-15) : le grounding VLM n'a PAS de confiance +modèle native (prompt = {"x","y"}, pas de logprob exploitable). La seule confiance +RÉELLE disponible est **sémantique/contextuelle** : le texte cible est-il bien à la +position trouvée ? On la dérive via `_validate_text_at_position` (même garde que le +pré-check aval). Approche validée par Dom (2026-06-15). + +Contrat : + - le score n'est PLUS la constante 0.85 ; il VARIE selon la vérif sémantique ; + - texte confirmé à la position → score haut (≥ seuil 0.60, accepté) ; + - texte absent → score bas (< 0.60) → rejeté par `_validate_resolution_quality` + (`rejected_low_score_grounding`) ; + - `score == matched_element["confidence"]` (cohérence) ; + - `method="grounding"` reste gardée par `_RESOLUTION_MIN_SCORES`. +""" +from __future__ import annotations + +import sys +from pathlib import Path +from unittest.mock import MagicMock + +import pytest + +ROOT = Path(__file__).resolve().parents[2] +if str(ROOT) not in sys.path: + sys.path.insert(0, str(ROOT)) + + +def _make_vllm_post(captured: list): + """vLLM renvoie un point Qwen3-VL 0-1000 centré (500,500) → (0.5, 0.5).""" + def fake_post(url, json=None, timeout=None): + captured.append({"url": url, "payload": json}) + resp = MagicMock() + if "/v1/chat/completions" in url: + resp.ok = True + resp.json.return_value = { + "choices": [{"message": {"content": '{"x": 500, "y": 500}'}}] + } + else: + resp.ok = False + resp.json.return_value = {"message": {"content": ""}} + return resp + return fake_post + + +def _resolve_with_text_validation(monkeypatch, tmp_path, is_valid: bool): + """Lance _resolve_by_grounding (mode qwen3vl) en forçant le verdict OCR + sémantique (`_validate_text_at_position`) à `is_valid`.""" + from PIL import Image + shot = tmp_path / "shot.png" + Image.new("RGB", (200, 120), (255, 255, 255)).save(shot) + + monkeypatch.setenv("RPA_GROUNDING_ENGINE", "qwen3vl_vllm") + import requests + monkeypatch.setattr(requests, "post", _make_vllm_post([])) + + from agent_v0.server_v1 import resolve_engine as re_module + # Forcer le signal sémantique (pas de vrai OCR en unit). + monkeypatch.setattr( + re_module, "_validate_text_at_position", + lambda *a, **k: (is_valid, "Synthèse" if is_valid else "", 1.0), + ) + result = re_module._resolve_by_grounding( + screenshot_path=str(shot), + target_spec={"by_text": "Synthèse", "by_text_source": "ocr"}, + screen_width=200, + screen_height=120, + ) + return re_module, result + + +@pytest.mark.unit +def test_dette019_score_varie_selon_verif_semantique(monkeypatch, tmp_path): + """Le score n'est plus une constante : texte confirmé ≠ texte absent.""" + _, res_ok = _resolve_with_text_validation(monkeypatch, tmp_path, is_valid=True) + _, res_ko = _resolve_with_text_validation(monkeypatch, tmp_path, is_valid=False) + assert res_ok is not None and res_ko is not None + assert res_ok["score"] != res_ko["score"], ( + f"score identique ({res_ok['score']}) → toujours figé, DETTE-019 non corrigée" + ) + + +@pytest.mark.unit +def test_dette019_texte_present_score_accepte(monkeypatch, tmp_path): + """Texte confirmé à la position → score ≥ seuil 0.60 (chemin nominal accepté).""" + re_module, res = _resolve_with_text_validation(monkeypatch, tmp_path, is_valid=True) + assert res is not None + assert res["score"] >= 0.60, f"score={res['score']} < 0.60 alors que texte confirmé" + out = re_module._validate_resolution_quality(res, 0.0, 0.0) + assert out.get("resolved") is True, "grounding confirmé rejeté à tort par le validateur" + + +@pytest.mark.unit +def test_dette019_texte_absent_score_bas_rejete(monkeypatch, tmp_path): + """Texte absent à la position → score < 0.60 → rejeté par le validateur.""" + re_module, res = _resolve_with_text_validation(monkeypatch, tmp_path, is_valid=False) + assert res is not None + assert res["score"] < 0.60, ( + f"score={res['score']} ≥ 0.60 alors que texte ABSENT → garde-seuil inopérant (DETTE-019)" + ) + out = re_module._validate_resolution_quality(res, 0.0, 0.0) + assert out["resolved"] is False + assert out["method"] == "rejected_low_score_grounding" + + +@pytest.mark.unit +def test_dette019_score_egal_confidence(monkeypatch, tmp_path): + """Cohérence interne : score == matched_element.confidence.""" + _, res = _resolve_with_text_validation(monkeypatch, tmp_path, is_valid=True) + assert res is not None + assert res["score"] == res["matched_element"]["confidence"] + + +@pytest.mark.unit +def test_dette019_sans_by_text_score_neutre_au_dessus_seuil(monkeypatch, tmp_path): + """Sans texte vérifiable (grounding par vlm_description) → confiance neutre, + au-dessus du seuil (comportement non régressé, pas de faux rejet).""" + from PIL import Image + shot = tmp_path / "shot.png" + Image.new("RGB", (200, 120), (255, 255, 255)).save(shot) + monkeypatch.setenv("RPA_GROUNDING_ENGINE", "qwen3vl_vllm") + import requests + monkeypatch.setattr(requests, "post", _make_vllm_post([])) + from agent_v0.server_v1 import resolve_engine as re_module + res = re_module._resolve_by_grounding( + screenshot_path=str(shot), + target_spec={"vlm_description": "le bouton de validation"}, # pas de by_text + screen_width=200, + screen_height=120, + ) + assert res is not None + assert res["score"] >= 0.60, f"score neutre={res['score']} < seuil → faux rejet sans by_text"