fix(grounding): confiance grounding dérivée sémantique (DETTE-019)
Le score/confidence figés à 0.85 dans _resolve_by_grounding rendaient le
garde-seuil (_RESOLUTION_MIN_SCORES["grounding"]=0.60) inopérant (0.85>0.60
toujours accepté). Le grounding VLM n'a pas de confiance modèle native (prompt
{"x","y"}, pas de logprob de localisation — confirmé QG Qwen 2026-06-15). On
dérive une confiance SÉMANTIQUE : le texte cible est-il à la position trouvée ?
(_validate_text_at_position). Confirmé→0.90, absent→0.45 (<seuil→rejet),
non vérifiable→0.70. Confiance contextuelle documentée, PAS une proba modèle.
TDD : 5 tests (score varie / présent accepté / absent rejeté / score==confidence
/ sans by_text neutre), RED→GREEN. Non-régression : 24 tests resolve_engine +
câblage qwen3vl + legacy bbox verts. E2E panel inchangé (15/15). Pré-check OCR
non impacté. DETTE-018 (legacy non gardé) reste séparée.
refs DETTE-019
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -870,6 +870,50 @@ def _vlm_quick_find(
|
|||||||
# Résolution par VLM Grounding Direct (configurable via RPA_VLM_MODEL)
|
# 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(
|
def _resolve_by_grounding(
|
||||||
screenshot_path: str,
|
screenshot_path: str,
|
||||||
@@ -1113,6 +1157,13 @@ def _resolve_by_grounding(
|
|||||||
_grounding_model, description[:50], x_pct, y_pct, elapsed,
|
_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 {
|
return {
|
||||||
"resolved": True,
|
"resolved": True,
|
||||||
# method gardée par _RESOLUTION_MIN_SCORES : en mode qwen3vl, "grounding"
|
# method gardée par _RESOLUTION_MIN_SCORES : en mode qwen3vl, "grounding"
|
||||||
@@ -1125,9 +1176,9 @@ def _resolve_by_grounding(
|
|||||||
"label": description[:60],
|
"label": description[:60],
|
||||||
"type": "grounding",
|
"type": "grounding",
|
||||||
"role": "grounding_vlm",
|
"role": "grounding_vlm",
|
||||||
"confidence": 0.85,
|
"confidence": _conf,
|
||||||
},
|
},
|
||||||
"score": 0.85,
|
"score": _conf,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
133
tests/unit/test_resolve_engine_dette019_confidence.py
Normal file
133
tests/unit/test_resolve_engine_dette019_confidence.py
Normal file
@@ -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"
|
||||||
Reference in New Issue
Block a user