Files
rpa_vision_v3/tests/unit/test_workflow_ir.py
Dom 4dc7d840d6 feat(p1x): de-hardcode VLM models/endpoints to vlm_config (DGX-ready)
Migre les call-sites VLM serveur vers la configuration centrale pour
fonctionner sur DGX (tunnel Ollama 11434), où gemma4:* est absent et le
port Docker 11435 est mort.

- task_planner, replay_verifier, domain_context, ir_builder, resolve_engine
  (popup): modele -> vlm_config.get_vlm_model(), defaut 11435 -> 11434
  (override GEMMA4_PORT legacy conserve)
- resolve_engine (grounding bbox x2): nouvel helper
  vlm_config.get_bbox_grounding_model() (var dediee RPA_BBOX_GROUNDING_MODEL,
  fallback RPA_GROUNDING_MODEL puis qwen2.5vl:7b-rpa) -> desambiguise le
  conflit D5-v3b, bbox_2d + num_ctx 4096 preserves
- safety_checks_provider: defaut -> get_vlm_model(), override
  RPA_SAFETY_CHECKS_LLM_MODEL preserve
- ui_detector: default_factory + resolution lazy (corrige aussi un gel a
  l'import), pas d'appel reseau a l'import
- field_extractor: property lazy via vlm_config

TDD strict (RED->GREEN), 305 tests verts, tests mockes HTTP (zero dependance
DGX reel), aucun alias Ollama.

Hors perimetre (arbitrage Dom): client Lea agent_v1/executor.py (gele),
chemin V4 observe_reason_act (RPA_REASONING_MODEL), core/config.py defaults.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 14:06:03 +02:00

302 lines
11 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
Tests du WorkflowIR et de l'IRBuilder.
Vérifie que :
- Le format WorkflowIR est correct (sérialisation, désérialisation, versioning)
- L'IRBuilder segmente et comprend les traces brutes
- Les variables sont détectées et substituables
- Le tout fonctionne sans gemma4 (fallback gracieux)
"""
import json
import shutil
import sys
import tempfile
from pathlib import Path
from unittest.mock import MagicMock, patch
import pytest
_ROOT = str(Path(__file__).resolve().parents[2])
if _ROOT not in sys.path:
sys.path.insert(0, _ROOT)
from core.workflow.workflow_ir import WorkflowIR, Step, Action, Variable
from core.workflow.ir_builder import IRBuilder
# =========================================================================
# WorkflowIR — format et sérialisation
# =========================================================================
class TestWorkflowIR:
def test_creation_vide(self):
ir = WorkflowIR.new("Test workflow")
assert ir.workflow_id.startswith("wf_")
assert ir.version == 1
assert ir.name == "Test workflow"
assert ir.steps == []
assert ir.variables == []
def test_ajout_etapes(self):
ir = WorkflowIR.new("Test")
ir.add_step("Ouvrir l'application", actions=[
{"type": "click", "target": "icône app"},
{"type": "wait", "duration_ms": 2000},
])
ir.add_step("Saisir les données", actions=[
{"type": "type", "text": "bonjour"},
])
assert len(ir.steps) == 2
assert ir.steps[0].intent == "Ouvrir l'application"
assert len(ir.steps[0].actions) == 2
assert ir.steps[0].actions[0].type == "click"
def test_ajout_variables(self):
ir = WorkflowIR.new("Test")
ir.add_variable("patient", description="Nom du patient", source="screen")
ir.add_variable("code", description="Code à saisir", default="A00.0")
assert len(ir.variables) == 2
assert ir.variables[0].name == "patient"
assert ir.variables[1].default == "A00.0"
def test_serialisation_json(self):
ir = WorkflowIR.new("Mon workflow", domain="tim_codage")
ir.add_step("Étape 1")
ir.add_variable("var1", description="Une variable")
json_str = ir.to_json()
data = json.loads(json_str)
assert data["name"] == "Mon workflow"
assert data["domain"] == "tim_codage"
assert len(data["steps"]) == 1
assert len(data["variables"]) == 1
def test_deserialisation_json(self):
ir = WorkflowIR.new("Test roundtrip")
ir.add_step("Ouvrir", actions=[{"type": "click", "target": "bouton"}])
ir.add_variable("v1", description="test")
json_str = ir.to_json()
ir2 = WorkflowIR.from_json(json_str)
assert ir2.name == "Test roundtrip"
assert len(ir2.steps) == 1
assert ir2.steps[0].intent == "Ouvrir"
assert ir2.steps[0].actions[0].type == "click"
assert len(ir2.variables) == 1
def test_save_et_load(self):
tmpdir = tempfile.mkdtemp()
try:
ir = WorkflowIR.new("Save test")
ir.add_step("Étape 1")
path = ir.save(tmpdir)
assert path.is_file()
ir2 = WorkflowIR.load(str(path))
assert ir2.name == "Save test"
assert len(ir2.steps) == 1
finally:
shutil.rmtree(tmpdir)
def test_increment_version(self):
ir = WorkflowIR.new("Versionning")
assert ir.version == 1
ir2 = ir.increment_version()
assert ir2.version == 2
assert ir.version == 1 # Original inchangé
assert ir2.name == "Versionning"
def test_domaine_generique(self):
"""Le WorkflowIR est générique — pas lié à un métier."""
for domain in ["tim_codage", "comptabilite", "rh_paie", "stocks", "generic"]:
ir = WorkflowIR.new("Test", domain=domain)
assert ir.domain == domain
def test_etape_optionnelle(self):
ir = WorkflowIR.new("Test")
ir.add_step("Vérification facultative", is_optional=True)
assert ir.steps[0].is_optional is True
def test_etape_boucle(self):
ir = WorkflowIR.new("Test")
ir.add_step("Traiter chaque dossier", is_loop=True, loop_variable="dossier")
assert ir.steps[0].is_loop is True
assert ir.steps[0].loop_variable == "dossier"
# =========================================================================
# IRBuilder — construction depuis RawTrace
# =========================================================================
class TestIRBuilder:
def _make_events(self):
"""Créer des événements bruts simulés (comme live_events.jsonl)."""
return [
{"event": {"type": "mouse_click", "pos": [400, 580], "window": {"title": "Lea : Explorateur"}, "timestamp": 100.0, "vision_info": {"text": "Rechercher"}}},
{"event": {"type": "text_input", "text": "blocnote", "window": {"title": "Rechercher"}, "timestamp": 102.0}},
{"event": {"type": "key_combo", "keys": ["enter"], "window": {"title": "Rechercher"}, "timestamp": 103.0}},
{"event": {"type": "heartbeat", "timestamp": 104.0}}, # Parasite — doit être filtré
{"event": {"type": "mouse_click", "pos": [300, 200], "window": {"title": "Rechercher"}, "timestamp": 105.0, "vision_info": {"text": "Bloc-notes"}}},
{"event": {"type": "mouse_click", "pos": [500, 300], "window": {"title": "Sans titre Bloc-notes"}, "timestamp": 112.0, "vision_info": {"text": ""}}},
{"event": {"type": "text_input", "text": "Bonjour le monde", "window": {"title": "*Sans titre Bloc-notes"}, "timestamp": 113.0}},
{"event": {"type": "key_combo", "keys": ["ctrl", "s"], "window": {"title": "*Sans titre Bloc-notes"}, "timestamp": 115.0}},
]
def test_analyze_intent_modele_via_vlm_config(self):
"""Le payload _analyze_intent utilise le modèle résolu par vlm_config."""
captured = {}
def capture_post(url, json=None, **kwargs):
captured["url"] = url
captured["model"] = (json or {}).get("model")
resp = MagicMock()
resp.ok = True
resp.json.return_value = {"message": {"content": "INTENTION: x\nAVANT: y\nAPRÈS: z"}}
return resp
with patch(
"core.workflow.ir_builder.vlm_config.get_vlm_model",
return_value="modele-resolu:test",
), patch("requests.post", side_effect=capture_post):
builder = IRBuilder()
builder._analyze_intent("clic Rechercher", 0, 1, "Test", "generic")
assert captured["model"] == "modele-resolu:test"
def test_analyze_intent_endpoint_par_defaut_11434(self, monkeypatch):
"""Sans GEMMA4_PORT, _analyze_intent vise 11434, pas le port mort 11435."""
monkeypatch.delenv("GEMMA4_PORT", raising=False)
captured = {}
def capture_post(url, json=None, **kwargs):
captured["url"] = url
resp = MagicMock()
resp.ok = True
resp.json.return_value = {"message": {"content": "INTENTION: x\nAVANT: y\nAPRÈS: z"}}
return resp
with patch("requests.post", side_effect=capture_post):
builder = IRBuilder()
builder._analyze_intent("clic Rechercher", 0, 1, "Test", "generic")
assert ":11434" in captured["url"]
assert ":11435" not in captured["url"]
def test_builder_sans_gemma4(self):
"""Le builder fonctionne même sans gemma4 (fallback gracieux)."""
builder = IRBuilder(gemma4_port="99999") # Port invalide
events = self._make_events()
ir = builder.build(events, session_id="test_sess", domain="generic", name="Test")
assert ir.name == "Test"
assert ir.learned_from == "test_sess"
assert len(ir.steps) >= 1
assert len(ir.applications) >= 1
def test_filtre_heartbeat(self):
"""Les heartbeat sont filtrés."""
builder = IRBuilder(gemma4_port="99999")
events = self._make_events()
ir = builder.build(events, name="Test")
# Vérifier qu'aucune action n'est de type heartbeat
for step in ir.steps:
for action in step.actions:
assert action.type != "heartbeat"
def test_detection_applications(self):
"""Les applications utilisées sont détectées."""
builder = IRBuilder(gemma4_port="99999")
events = self._make_events()
ir = builder.build(events, name="Test")
assert "Bloc-notes" in ir.applications or "Explorateur" in ir.applications
def test_detection_variables(self):
"""Le texte saisi est détecté comme variable."""
builder = IRBuilder(gemma4_port="99999")
events = self._make_events()
ir = builder.build(events, name="Test")
# Le texte "blocnote" et "Bonjour le monde" doivent être des variables
assert len(ir.variables) >= 1
var_defaults = [v.default for v in ir.variables]
assert any("blocnote" in d or "Bonjour" in d for d in var_defaults)
def test_segmentation_par_application(self):
"""Les événements sont segmentés par changement d'application."""
builder = IRBuilder(gemma4_port="99999")
events = self._make_events()
ir = builder.build(events, name="Test")
# Au moins 2 étapes (Explorateur → Bloc-notes)
assert len(ir.steps) >= 2
def test_actions_dans_les_etapes(self):
"""Chaque étape contient les bonnes actions."""
builder = IRBuilder(gemma4_port="99999")
events = self._make_events()
ir = builder.build(events, name="Test")
all_actions = []
for step in ir.steps:
all_actions.extend(step.actions)
types = [a.type for a in all_actions]
assert "click" in types
assert "type" in types
assert "key_combo" in types
def test_workflow_ir_complet_roundtrip(self):
"""Build → JSON → reload → même contenu."""
builder = IRBuilder(gemma4_port="99999")
events = self._make_events()
ir = builder.build(events, name="Roundtrip test", domain="compta")
json_str = ir.to_json()
ir2 = WorkflowIR.from_json(json_str)
assert ir2.name == "Roundtrip test"
assert ir2.domain == "compta"
assert len(ir2.steps) == len(ir.steps)
assert len(ir2.variables) == len(ir.variables)
@patch("requests.post")
def test_builder_avec_gemma4_mock(self, mock_post):
"""Avec gemma4, le builder enrichit les intentions."""
mock_resp = MagicMock()
mock_resp.ok = True
mock_resp.json.return_value = {
"message": {"content": (
"INTENTION: Rechercher et ouvrir le Bloc-notes\n"
"AVANT: L'explorateur de fichiers est ouvert\n"
"APRÈS: Le Bloc-notes est ouvert et actif"
)}
}
mock_post.return_value = mock_resp
builder = IRBuilder()
events = self._make_events()
ir = builder.build(events, name="Test gemma4")
# Au moins une étape doit avoir une intention enrichie
intents = [s.intent for s in ir.steps]
has_enriched = any("Bloc-notes" in i or "Rechercher" in i for i in intents)
assert has_enriched or len(ir.steps) >= 1 # Fallback acceptable