feat: chat unifié, GestureCatalog, Copilot, Léa UI, extraction données, vérification replay
Refonte majeure du système Agent Chat et ajout de nombreux modules : - Chat unifié : suppression du dual Workflows/Agent Libre, tout passe par /api/chat avec résolution en 3 niveaux (workflow → geste → "montre-moi") - GestureCatalog : 38 raccourcis clavier universels Windows avec matching sémantique, substitution automatique dans les replays, et endpoint /api/gestures - Mode Copilot : exécution pas-à-pas des workflows avec validation humaine via WebSocket (approve/skip/abort) avant chaque action - Léa UI (agent_v0/lea_ui/) : interface PyQt5 pour Windows avec overlay transparent pour feedback visuel pendant le replay - Data Extraction (core/extraction/) : moteur d'extraction visuelle de données (OCR + VLM → SQLite), avec schémas YAML et export CSV/Excel - ReplayVerifier (agent_v0/server_v1/) : vérification post-action par comparaison de screenshots, avec logique de retry (max 3) - IntentParser durci : meilleur fallback regex, type GREETING, patterns améliorés - Dashboard : nouvelles pages gestures, streaming, extractions - Tests : 63 tests GestureCatalog, 47 tests extraction, corrections tests existants - Dépréciation : /api/agent/plan et /api/agent/execute retournent HTTP 410, suppression du code hardcodé _plan_to_replay_actions Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
524
tests/integration/test_stream_processor.py
Normal file
524
tests/integration/test_stream_processor.py
Normal file
@@ -0,0 +1,524 @@
|
||||
"""
|
||||
Tests d'intégration pour StreamProcessor + LiveSessionManager + StreamWorker.
|
||||
|
||||
Vérifie le pipeline complet : session → événements → screenshots → workflow.
|
||||
Sans GPU/modèles lourds (mocks pour ScreenAnalyzer et CLIP).
|
||||
"""
|
||||
|
||||
import json
|
||||
import shutil
|
||||
import sys
|
||||
import tempfile
|
||||
import threading
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
# Garantir que la racine du projet est dans sys.path (nécessaire pour les
|
||||
# imports relatifs de agent_v0.server_v1)
|
||||
_ROOT = str(Path(__file__).resolve().parents[2])
|
||||
if _ROOT not in sys.path:
|
||||
sys.path.insert(0, _ROOT)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def temp_dir():
|
||||
d = tempfile.mkdtemp(prefix="test_stream_")
|
||||
yield d
|
||||
shutil.rmtree(d, ignore_errors=True)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def processor(temp_dir):
|
||||
from agent_v0.server_v1.stream_processor import StreamProcessor
|
||||
return StreamProcessor(data_dir=temp_dir)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def worker(temp_dir, processor):
|
||||
from agent_v0.server_v1.worker_stream import StreamWorker
|
||||
return StreamWorker(live_dir=temp_dir, processor=processor)
|
||||
|
||||
|
||||
# =========================================================================
|
||||
# LiveSessionManager
|
||||
# =========================================================================
|
||||
|
||||
|
||||
class TestLiveSessionManager:
|
||||
def test_register_and_get(self):
|
||||
from agent_v0.server_v1.live_session_manager import LiveSessionManager
|
||||
mgr = LiveSessionManager()
|
||||
s = mgr.register_session("sess_001")
|
||||
assert s.session_id == "sess_001"
|
||||
assert mgr.get_session("sess_001") is s
|
||||
|
||||
def test_get_or_create(self):
|
||||
from agent_v0.server_v1.live_session_manager import LiveSessionManager
|
||||
mgr = LiveSessionManager()
|
||||
s1 = mgr.get_or_create("sess_002")
|
||||
s2 = mgr.get_or_create("sess_002")
|
||||
assert s1 is s2
|
||||
|
||||
def test_add_event_updates_window_info(self):
|
||||
from agent_v0.server_v1.live_session_manager import LiveSessionManager
|
||||
mgr = LiveSessionManager()
|
||||
mgr.add_event("sess_003", {
|
||||
"type": "mouse_click",
|
||||
"window": {"title": "Firefox", "app_name": "firefox"},
|
||||
})
|
||||
session = mgr.get_session("sess_003")
|
||||
assert session.last_window_info["title"] == "Firefox"
|
||||
assert len(session.events) == 1
|
||||
|
||||
def test_add_screenshot(self):
|
||||
from agent_v0.server_v1.live_session_manager import LiveSessionManager
|
||||
mgr = LiveSessionManager()
|
||||
mgr.add_screenshot("sess_004", "shot_001", "/tmp/shot_001.png")
|
||||
session = mgr.get_session("sess_004")
|
||||
assert session.shot_paths["shot_001"] == "/tmp/shot_001.png"
|
||||
|
||||
def test_finalize(self):
|
||||
from agent_v0.server_v1.live_session_manager import LiveSessionManager
|
||||
mgr = LiveSessionManager()
|
||||
mgr.register_session("sess_005")
|
||||
session = mgr.finalize("sess_005")
|
||||
assert session.finalized is True
|
||||
|
||||
def test_active_session_count(self):
|
||||
from agent_v0.server_v1.live_session_manager import LiveSessionManager
|
||||
mgr = LiveSessionManager()
|
||||
mgr.register_session("a")
|
||||
mgr.register_session("b")
|
||||
assert mgr.active_session_count == 2
|
||||
mgr.finalize("a")
|
||||
assert mgr.active_session_count == 1
|
||||
|
||||
def test_to_raw_session(self):
|
||||
from agent_v0.server_v1.live_session_manager import LiveSessionManager
|
||||
mgr = LiveSessionManager()
|
||||
mgr.add_event("sess_006", {"type": "click", "timestamp": 1000})
|
||||
mgr.add_screenshot("sess_006", "shot_full_001", "/tmp/full.png")
|
||||
mgr.add_screenshot("sess_006", "shot_001_crop", "/tmp/crop.png")
|
||||
|
||||
raw = mgr.to_raw_session("sess_006")
|
||||
assert raw is not None
|
||||
assert raw["session_id"] == "sess_006"
|
||||
assert len(raw["events"]) == 1
|
||||
# Les crops sont filtrés
|
||||
assert len(raw["screenshots"]) == 1
|
||||
assert raw["screenshots"][0]["screenshot_id"] == "shot_full_001"
|
||||
|
||||
|
||||
# =========================================================================
|
||||
# StreamProcessor
|
||||
# =========================================================================
|
||||
|
||||
|
||||
class TestStreamProcessor:
|
||||
def test_process_event(self, processor):
|
||||
result = processor.process_event("sess_010", {
|
||||
"type": "mouse_click",
|
||||
"timestamp": 1234,
|
||||
"window": {"title": "Chrome", "app_name": "chrome"},
|
||||
})
|
||||
assert result["status"] == "event_recorded"
|
||||
session = processor.session_manager.get_session("sess_010")
|
||||
assert session.last_window_info["title"] == "Chrome"
|
||||
|
||||
def test_process_crop(self, processor):
|
||||
result = processor.process_crop("sess_011", "shot_001_crop", "/tmp/crop.png")
|
||||
assert result["status"] == "crop_stored"
|
||||
|
||||
def test_process_screenshot_no_analyzer(self, processor):
|
||||
"""Sans ScreenAnalyzer, retourne un résultat minimal."""
|
||||
# Forcer l'initialisation sans modèles GPU
|
||||
processor._initialized = True
|
||||
processor._screen_analyzer = None
|
||||
processor._faiss_manager = None
|
||||
|
||||
result = processor.process_screenshot("sess_012", "shot_001", "/tmp/full.png")
|
||||
assert result["shot_id"] == "shot_001"
|
||||
assert result["state_id"] is None # Pas d'analyse
|
||||
assert result["ui_elements_count"] == 0
|
||||
|
||||
@patch("agent_v0.server_v1.stream_processor.StreamProcessor._ensure_initialized")
|
||||
def test_process_screenshot_with_mock_analyzer(self, mock_init, processor):
|
||||
"""Avec un ScreenAnalyzer mocké, vérifie le flux complet."""
|
||||
from core.models.screen_state import (
|
||||
ScreenState, WindowContext, RawLevel,
|
||||
PerceptionLevel, ContextLevel, EmbeddingRef,
|
||||
)
|
||||
|
||||
mock_state = ScreenState(
|
||||
screen_state_id="state_001",
|
||||
timestamp="2026-01-01T00:00:00",
|
||||
session_id="sess_013",
|
||||
window=WindowContext(app_name="test", window_title="Test", screen_resolution=[1920, 1080]),
|
||||
raw=RawLevel(screenshot_path="/tmp/test.png", capture_method="mss", file_size_bytes=0),
|
||||
perception=PerceptionLevel(
|
||||
embedding=EmbeddingRef(provider="test", vector_id="v1", dimensions=512),
|
||||
detected_text=["Bonjour", "Valider"],
|
||||
text_detection_method="mock",
|
||||
confidence_avg=0.9,
|
||||
),
|
||||
context=ContextLevel(),
|
||||
ui_elements=[MagicMock(), MagicMock(), MagicMock()],
|
||||
)
|
||||
|
||||
processor._screen_analyzer = MagicMock()
|
||||
processor._screen_analyzer.analyze.return_value = mock_state
|
||||
processor._faiss_manager = None
|
||||
processor._initialized = True
|
||||
|
||||
result = processor.process_screenshot("sess_013", "shot_full", "/tmp/full.png")
|
||||
assert result["state_id"] == "state_001"
|
||||
assert result["ui_elements_count"] == 3
|
||||
assert result["text_detected"] == 2
|
||||
|
||||
# Le ScreenState est stocké pour le build final
|
||||
assert len(processor._screen_states["sess_013"]) == 1
|
||||
|
||||
def test_finalize_insufficient_data(self, processor):
|
||||
"""Finalisation avec pas assez de données."""
|
||||
processor._initialized = True
|
||||
processor.session_manager.register_session("sess_014")
|
||||
result = processor.finalize_session("sess_014")
|
||||
assert result["status"] == "insufficient_data"
|
||||
|
||||
def test_stats(self, processor):
|
||||
stats = processor.stats
|
||||
assert stats["active_sessions"] == 0
|
||||
assert stats["total_workflows"] == 0
|
||||
assert stats["initialized"] is False
|
||||
|
||||
|
||||
# =========================================================================
|
||||
# StreamWorker
|
||||
# =========================================================================
|
||||
|
||||
|
||||
class TestStreamWorker:
|
||||
def test_process_event_direct(self, worker):
|
||||
result = worker.process_event_direct("sess_020", {"type": "click"})
|
||||
assert result["status"] == "event_recorded"
|
||||
|
||||
def test_process_crop_direct(self, worker):
|
||||
result = worker.process_crop_direct("sess_021", "crop_001", "/tmp/crop.png")
|
||||
assert result["status"] == "crop_stored"
|
||||
|
||||
def test_stats(self, worker):
|
||||
stats = worker.stats
|
||||
assert "active_sessions" in stats
|
||||
|
||||
def test_poll_reads_events_from_disk(self, worker, temp_dir):
|
||||
"""Le worker lit les événements JSONL depuis le disque."""
|
||||
session_dir = Path(temp_dir) / "test_sess"
|
||||
session_dir.mkdir()
|
||||
event_file = session_dir / "live_events.jsonl"
|
||||
event_file.write_text(
|
||||
json.dumps({"type": "click", "timestamp": 100}) + "\n"
|
||||
+ json.dumps({"type": "key_press", "timestamp": 200}) + "\n"
|
||||
)
|
||||
|
||||
# Simuler un tour de polling
|
||||
worker._check_live_sessions()
|
||||
|
||||
session = worker.processor.session_manager.get_session("test_sess")
|
||||
assert session is not None
|
||||
assert len(session.events) == 2
|
||||
|
||||
|
||||
# =========================================================================
|
||||
# GraphBuilder precomputed_states
|
||||
# =========================================================================
|
||||
|
||||
|
||||
class TestGraphBuilderPrecomputed:
|
||||
def test_accepts_precomputed_states(self):
|
||||
"""GraphBuilder.build_from_session accepte precomputed_states."""
|
||||
import inspect
|
||||
from core.graph.graph_builder import GraphBuilder
|
||||
sig = inspect.signature(GraphBuilder.build_from_session)
|
||||
assert "precomputed_states" in sig.parameters
|
||||
|
||||
def test_raises_without_screenshots_or_states(self):
|
||||
"""Erreur si ni screenshots ni precomputed_states."""
|
||||
from core.graph.graph_builder import GraphBuilder
|
||||
from core.models.raw_session import RawSession
|
||||
|
||||
builder = GraphBuilder(min_pattern_repetitions=2)
|
||||
session = MagicMock(spec=RawSession)
|
||||
session.screenshots = []
|
||||
session.session_id = "empty"
|
||||
|
||||
with pytest.raises(ValueError, match="no screenshots"):
|
||||
builder.build_from_session(session)
|
||||
|
||||
def test_skips_screen_state_creation_with_precomputed(self):
|
||||
"""Avec precomputed_states, _create_screen_states n'est pas appelé."""
|
||||
from core.graph.graph_builder import GraphBuilder
|
||||
from core.models.raw_session import RawSession
|
||||
|
||||
builder = GraphBuilder(min_pattern_repetitions=2)
|
||||
builder._create_screen_states = MagicMock()
|
||||
|
||||
# Mock du reste du pipeline
|
||||
fake_embedding = np.random.randn(512).astype(np.float32)
|
||||
fake_embedding /= np.linalg.norm(fake_embedding)
|
||||
builder._compute_embeddings = MagicMock(return_value=[fake_embedding, fake_embedding])
|
||||
builder._detect_patterns = MagicMock(return_value={})
|
||||
builder._build_nodes = MagicMock(return_value=[])
|
||||
builder._build_edges = MagicMock(return_value=[])
|
||||
|
||||
session = MagicMock(spec=RawSession)
|
||||
session.session_id = "precomp"
|
||||
session.screenshots = []
|
||||
|
||||
fake_states = [MagicMock(), MagicMock()]
|
||||
builder.build_from_session(session, precomputed_states=fake_states)
|
||||
|
||||
# _create_screen_states ne doit PAS être appelé
|
||||
builder._create_screen_states.assert_not_called()
|
||||
# _compute_embeddings doit recevoir les precomputed states
|
||||
builder._compute_embeddings.assert_called_once_with(fake_states)
|
||||
|
||||
|
||||
# =========================================================================
|
||||
# Thread-safety de StreamProcessor
|
||||
# =========================================================================
|
||||
|
||||
|
||||
class TestStreamProcessorThreadSafety:
|
||||
"""Vérifie que les accès concurrents aux dicts internes sont protégés."""
|
||||
|
||||
def test_has_data_lock(self, processor):
|
||||
"""StreamProcessor possède un _data_lock dédié."""
|
||||
assert hasattr(processor, "_data_lock")
|
||||
assert isinstance(processor._data_lock, type(threading.Lock()))
|
||||
|
||||
def test_concurrent_screen_states_access(self, processor):
|
||||
"""Accès concurrent à _screen_states ne lève pas d'erreur."""
|
||||
processor._initialized = True
|
||||
processor._screen_analyzer = None
|
||||
|
||||
errors = []
|
||||
|
||||
def add_states(session_id):
|
||||
try:
|
||||
for i in range(50):
|
||||
with processor._data_lock:
|
||||
if session_id not in processor._screen_states:
|
||||
processor._screen_states[session_id] = []
|
||||
processor._screen_states[session_id].append(f"state_{i}")
|
||||
except Exception as e:
|
||||
errors.append(e)
|
||||
|
||||
threads = [
|
||||
threading.Thread(target=add_states, args=(f"sess_{t}",))
|
||||
for t in range(5)
|
||||
]
|
||||
for t in threads:
|
||||
t.start()
|
||||
for t in threads:
|
||||
t.join()
|
||||
|
||||
assert len(errors) == 0
|
||||
assert len(processor._screen_states) == 5
|
||||
|
||||
def test_concurrent_embeddings_access(self, processor):
|
||||
"""Accès concurrent à _embeddings ne lève pas d'erreur."""
|
||||
errors = []
|
||||
|
||||
def add_embeddings(session_id):
|
||||
try:
|
||||
for i in range(50):
|
||||
with processor._data_lock:
|
||||
if session_id not in processor._embeddings:
|
||||
processor._embeddings[session_id] = []
|
||||
processor._embeddings[session_id].append(
|
||||
np.random.randn(512).astype(np.float32)
|
||||
)
|
||||
except Exception as e:
|
||||
errors.append(e)
|
||||
|
||||
threads = [
|
||||
threading.Thread(target=add_embeddings, args=(f"sess_{t}",))
|
||||
for t in range(5)
|
||||
]
|
||||
for t in threads:
|
||||
t.start()
|
||||
for t in threads:
|
||||
t.join()
|
||||
|
||||
assert len(errors) == 0
|
||||
assert len(processor._embeddings) == 5
|
||||
|
||||
def test_concurrent_workflows_access(self, processor):
|
||||
"""Accès concurrent à _workflows ne lève pas d'erreur."""
|
||||
errors = []
|
||||
|
||||
def add_workflow(wf_id):
|
||||
try:
|
||||
mock_wf = MagicMock()
|
||||
mock_wf.nodes = [1, 2]
|
||||
mock_wf.edges = [1]
|
||||
with processor._data_lock:
|
||||
processor._workflows[wf_id] = mock_wf
|
||||
except Exception as e:
|
||||
errors.append(e)
|
||||
|
||||
threads = [
|
||||
threading.Thread(target=add_workflow, args=(f"wf_{t}",))
|
||||
for t in range(10)
|
||||
]
|
||||
for t in threads:
|
||||
t.start()
|
||||
for t in threads:
|
||||
t.join()
|
||||
|
||||
assert len(errors) == 0
|
||||
assert len(processor._workflows) == 10
|
||||
|
||||
|
||||
# =========================================================================
|
||||
# list_sessions / list_workflows
|
||||
# =========================================================================
|
||||
|
||||
|
||||
class TestStreamProcessorListMethods:
|
||||
"""Tests pour list_sessions() et list_workflows()."""
|
||||
|
||||
def test_list_sessions_empty(self, processor):
|
||||
result = processor.list_sessions()
|
||||
assert result == []
|
||||
|
||||
def test_list_sessions_with_data(self, processor):
|
||||
processor.session_manager.register_session("sess_ls_1")
|
||||
processor.session_manager.add_event("sess_ls_1", {
|
||||
"type": "click",
|
||||
"window": {"title": "App", "app_name": "app"},
|
||||
})
|
||||
processor.session_manager.add_screenshot("sess_ls_1", "shot_1", "/tmp/s.png")
|
||||
|
||||
with processor._data_lock:
|
||||
processor._screen_states["sess_ls_1"] = ["state_a", "state_b"]
|
||||
processor._embeddings["sess_ls_1"] = [np.zeros(512)]
|
||||
|
||||
sessions = processor.list_sessions()
|
||||
assert len(sessions) == 1
|
||||
s = sessions[0]
|
||||
assert s["session_id"] == "sess_ls_1"
|
||||
assert s["events_count"] == 1
|
||||
assert s["screenshots_count"] == 1
|
||||
assert s["states_count"] == 2
|
||||
assert s["embeddings_count"] == 1
|
||||
assert s["finalized"] is False
|
||||
|
||||
def test_list_sessions_multiple(self, processor):
|
||||
processor.session_manager.register_session("a")
|
||||
processor.session_manager.register_session("b")
|
||||
processor.session_manager.finalize("b")
|
||||
|
||||
sessions = processor.list_sessions()
|
||||
assert len(sessions) == 2
|
||||
by_id = {s["session_id"]: s for s in sessions}
|
||||
assert by_id["a"]["finalized"] is False
|
||||
assert by_id["b"]["finalized"] is True
|
||||
|
||||
def test_list_workflows_empty(self, processor):
|
||||
result = processor.list_workflows()
|
||||
assert result == []
|
||||
|
||||
def test_list_workflows_with_data(self, processor):
|
||||
mock_wf = MagicMock()
|
||||
mock_wf.nodes = [1, 2, 3]
|
||||
mock_wf.edges = [1, 2]
|
||||
mock_wf.name = "test_workflow"
|
||||
with processor._data_lock:
|
||||
processor._workflows["wf_001"] = mock_wf
|
||||
|
||||
workflows = processor.list_workflows()
|
||||
assert len(workflows) == 1
|
||||
wf = workflows[0]
|
||||
assert wf["workflow_id"] == "wf_001"
|
||||
assert wf["nodes"] == 3
|
||||
assert wf["edges"] == 2
|
||||
assert wf["name"] == "test_workflow"
|
||||
|
||||
|
||||
# =========================================================================
|
||||
# API endpoints (sessions / workflows)
|
||||
# =========================================================================
|
||||
|
||||
|
||||
class TestAPIEndpoints:
|
||||
"""Tests pour les endpoints GET sessions et workflows."""
|
||||
|
||||
@pytest.fixture
|
||||
def client(self, temp_dir):
|
||||
"""Client de test FastAPI."""
|
||||
from fastapi.testclient import TestClient
|
||||
from agent_v0.server_v1 import api_stream
|
||||
from agent_v0.server_v1.stream_processor import StreamProcessor
|
||||
from agent_v0.server_v1.worker_stream import StreamWorker
|
||||
|
||||
# Remplacer le processor global par un processor de test
|
||||
original_processor = api_stream.processor
|
||||
original_worker = api_stream.worker
|
||||
test_processor = StreamProcessor(data_dir=temp_dir)
|
||||
api_stream.processor = test_processor
|
||||
api_stream.worker = StreamWorker(
|
||||
live_dir=temp_dir, processor=test_processor
|
||||
)
|
||||
|
||||
client = TestClient(api_stream.app, raise_server_exceptions=False)
|
||||
yield client, test_processor
|
||||
|
||||
# Restaurer
|
||||
api_stream.processor = original_processor
|
||||
api_stream.worker = original_worker
|
||||
|
||||
def test_get_sessions_empty(self, client):
|
||||
c, _ = client
|
||||
resp = c.get("/api/v1/traces/stream/sessions")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["sessions"] == []
|
||||
|
||||
def test_get_sessions_with_data(self, client):
|
||||
c, proc = client
|
||||
proc.session_manager.register_session("api_sess_1")
|
||||
proc.session_manager.add_event("api_sess_1", {"type": "click"})
|
||||
|
||||
resp = c.get("/api/v1/traces/stream/sessions")
|
||||
assert resp.status_code == 200
|
||||
sessions = resp.json()["sessions"]
|
||||
assert len(sessions) == 1
|
||||
assert sessions[0]["session_id"] == "api_sess_1"
|
||||
assert sessions[0]["events_count"] == 1
|
||||
|
||||
def test_get_workflows_empty(self, client):
|
||||
c, _ = client
|
||||
resp = c.get("/api/v1/traces/stream/workflows")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["workflows"] == []
|
||||
|
||||
def test_get_workflows_with_data(self, client):
|
||||
c, proc = client
|
||||
mock_wf = MagicMock()
|
||||
mock_wf.nodes = [1, 2]
|
||||
mock_wf.edges = [1]
|
||||
mock_wf.name = "api_test_wf"
|
||||
with proc._data_lock:
|
||||
proc._workflows["wf_api_001"] = mock_wf
|
||||
|
||||
resp = c.get("/api/v1/traces/stream/workflows")
|
||||
assert resp.status_code == 200
|
||||
workflows = resp.json()["workflows"]
|
||||
assert len(workflows) == 1
|
||||
assert workflows[0]["workflow_id"] == "wf_api_001"
|
||||
assert workflows[0]["nodes"] == 2
|
||||
Reference in New Issue
Block a user