"""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"