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:
Dom
2026-03-15 10:02:09 +01:00
parent 74a1cb4e03
commit cf495dd82f
93 changed files with 12463 additions and 1080 deletions

View File

@@ -0,0 +1,342 @@
"""
Tests de compatibilité Client (Agent V1) ↔ Serveur (api_stream).
Vérifie que les payloads envoyés par le TraceStreamer correspondent
exactement à ce que l'API serveur attend (formats, champs, endpoints).
Sans réseau réel : on mocke requests.post et on valide les appels.
"""
import sys
from pathlib import Path
from unittest.mock import MagicMock, call, patch
import pytest
_ROOT = str(Path(__file__).resolve().parents[2])
if _ROOT not in sys.path:
sys.path.insert(0, _ROOT)
# =========================================================================
# TraceStreamer ↔ API endpoints
# =========================================================================
class TestStreamerEndpoints:
"""Vérifie que le client appelle les bons endpoints."""
def test_register_endpoint(self):
"""start() appelle POST /register avec session_id."""
from agent_v0.agent_v1.network.streamer import TraceStreamer
with patch("agent_v0.agent_v1.network.streamer.requests") as mock_req:
mock_req.post.return_value = MagicMock(ok=True)
streamer = TraceStreamer("sess_test_001")
streamer.start()
streamer.stop()
# Trouver l'appel register
register_calls = [
c for c in mock_req.post.call_args_list
if "/register" in str(c)
]
assert len(register_calls) >= 1, "register endpoint jamais appelé"
_, kwargs = register_calls[0]
assert kwargs["params"]["session_id"] == "sess_test_001"
def test_finalize_endpoint(self):
"""stop() appelle POST /finalize avec session_id."""
from agent_v0.agent_v1.network.streamer import TraceStreamer
with patch("agent_v0.agent_v1.network.streamer.requests") as mock_req:
mock_req.post.return_value = MagicMock(ok=True, json=lambda: {"status": "ok"})
streamer = TraceStreamer("sess_test_002")
streamer._server_available = True
streamer.running = False
streamer._finalize_session()
finalize_calls = [
c for c in mock_req.post.call_args_list
if "/finalize" in str(c)
]
assert len(finalize_calls) >= 1, "finalize endpoint jamais appelé"
_, kwargs = finalize_calls[0]
assert kwargs["params"]["session_id"] == "sess_test_002"
# =========================================================================
# Payload formats
# =========================================================================
class TestEventPayloadFormat:
"""Vérifie que les événements envoyés ont le bon format."""
def test_event_payload_matches_server_model(self):
"""Le payload event doit contenir session_id, timestamp, event."""
from agent_v0.agent_v1.network.streamer import TraceStreamer
captured_payload = {}
with patch("agent_v0.agent_v1.network.streamer.requests") as mock_req:
mock_req.post.return_value = MagicMock(ok=True)
streamer = TraceStreamer("sess_test_003")
streamer._server_available = True
# Envoyer directement (sans thread)
test_event = {
"type": "mouse_click",
"button": "left",
"pos": (500, 300),
"timestamp": 1234567890.0,
"window": {"title": "Firefox", "app_name": "firefox"},
}
streamer._send_event(test_event)
# Vérifier le payload envoyé
event_calls = [
c for c in mock_req.post.call_args_list
if "/event" in str(c)
]
assert len(event_calls) == 1
_, kwargs = event_calls[0]
payload = kwargs["json"]
# Champs requis par le modèle Pydantic StreamEvent du serveur
assert "session_id" in payload
assert "timestamp" in payload
assert "event" in payload
assert payload["session_id"] == "sess_test_003"
assert isinstance(payload["timestamp"], float)
assert payload["event"]["type"] == "mouse_click"
def test_event_with_window_info(self):
"""Le serveur utilise event.window pour last_window_info."""
from agent_v0.agent_v1.network.streamer import TraceStreamer
with patch("agent_v0.agent_v1.network.streamer.requests") as mock_req:
mock_req.post.return_value = MagicMock(ok=True)
streamer = TraceStreamer("sess_test_004")
streamer._server_available = True
event_with_window = {
"type": "mouse_click",
"window": {"title": "Chrome", "app_name": "chrome"},
}
streamer._send_event(event_with_window)
event_calls = [
c for c in mock_req.post.call_args_list
if "/event" in str(c)
]
payload = event_calls[0][1]["json"]
# Le champ window doit être transmis au serveur
assert "window" in payload["event"]
assert payload["event"]["window"]["title"] == "Chrome"
assert payload["event"]["window"]["app_name"] == "chrome"
class TestImagePayloadFormat:
"""Vérifie le format d'envoi des screenshots."""
def test_image_params_match_server(self, tmp_path):
"""L'envoi image utilise les bons params query (session_id, shot_id)."""
from agent_v0.agent_v1.network.streamer import TraceStreamer
# Créer un faux fichier image
fake_img = tmp_path / "test.png"
fake_img.write_bytes(b"\x89PNG\r\n\x1a\n" + b"\x00" * 100)
with patch("agent_v0.agent_v1.network.streamer.requests") as mock_req:
mock_req.post.return_value = MagicMock(ok=True)
streamer = TraceStreamer("sess_test_005")
streamer._server_available = True
streamer._send_image(str(fake_img), "shot_0001_full")
img_calls = [
c for c in mock_req.post.call_args_list
if "/image" in str(c)
]
assert len(img_calls) == 1
_, kwargs = img_calls[0]
# Vérifier les params query
assert kwargs["params"]["session_id"] == "sess_test_005"
assert kwargs["params"]["shot_id"] == "shot_0001_full"
# Vérifier que le fichier est envoyé
assert "files" in kwargs
assert "file" in kwargs["files"]
def test_empty_path_ignored(self):
"""push_image avec chemin vide ne doit pas enqueue."""
from agent_v0.agent_v1.network.streamer import TraceStreamer
streamer = TraceStreamer("sess_test_006")
streamer.push_image("", "heartbeat_empty")
assert streamer.queue.empty(), "Chemin vide ne doit pas être enfilé"
def test_crop_naming_convention(self, tmp_path):
"""Le serveur distingue full/crop par '_crop' dans le shot_id."""
from agent_v0.agent_v1.network.streamer import TraceStreamer
fake_img = tmp_path / "crop.png"
fake_img.write_bytes(b"\x89PNG\r\n\x1a\n" + b"\x00" * 50)
with patch("agent_v0.agent_v1.network.streamer.requests") as mock_req:
mock_req.post.return_value = MagicMock(ok=True)
streamer = TraceStreamer("sess_test_007")
streamer._server_available = True
# Full screenshot
streamer._send_image(str(fake_img), "shot_0001_full")
# Crop screenshot
streamer._send_image(str(fake_img), "shot_0001_crop")
img_calls = [
c for c in mock_req.post.call_args_list
if "/image" in str(c)
]
assert len(img_calls) == 2
shot_ids = [c[1]["params"]["shot_id"] for c in img_calls]
assert "shot_0001_full" in shot_ids
assert "shot_0001_crop" in shot_ids
# Vérifier que le serveur pourra distinguer
# (api_stream.py check "_crop" in shot_id)
assert "_crop" in "shot_0001_crop"
assert "_crop" not in "shot_0001_full"
# =========================================================================
# Server-side validation (StreamEvent model)
# =========================================================================
class TestServerModelValidation:
"""Vérifie que les payloads client passent la validation Pydantic côté serveur."""
def test_streamevent_model_accepts_client_payload(self):
"""Le payload client est accepté par le modèle StreamEvent du serveur."""
import agent_v0.server_v1 # noqa: F401 — force le bon import
from agent_v0.server_v1.api_stream import StreamEvent
# Payload typique envoyé par le client
payload = {
"session_id": "sess_20260311T100530_abc123",
"timestamp": 1741689930.123,
"event": {
"type": "mouse_click",
"button": "left",
"pos": [500, 300],
"timestamp": 1741689930.123,
"window": {"title": "Firefox", "app_name": "firefox"},
"screenshot_id": "shot_0001",
},
}
model = StreamEvent(**payload)
assert model.session_id == "sess_20260311T100530_abc123"
assert model.event["type"] == "mouse_click"
assert model.event["window"]["title"] == "Firefox"
def test_streamevent_heartbeat(self):
"""Heartbeat events passent la validation."""
import agent_v0.server_v1 # noqa: F401
from agent_v0.server_v1.api_stream import StreamEvent
payload = {
"session_id": "sess_heartbeat",
"timestamp": 1741689935.0,
"event": {
"type": "heartbeat",
"image": "/tmp/shots/context_1741689935_heartbeat.png",
"timestamp": 1741689935.0,
},
}
model = StreamEvent(**payload)
assert model.event["type"] == "heartbeat"
def test_streamevent_window_focus_change(self):
"""Window focus change events passent la validation."""
import agent_v0.server_v1 # noqa: F401
from agent_v0.server_v1.api_stream import StreamEvent
payload = {
"session_id": "sess_focus",
"timestamp": 1741689940.0,
"event": {
"type": "window_focus_change",
"from": {"title": "Terminal", "app_name": "gnome-terminal"},
"to": {"title": "Firefox", "app_name": "firefox"},
"timestamp": 1741689940.0,
},
}
model = StreamEvent(**payload)
assert model.event["type"] == "window_focus_change"
# =========================================================================
# Server processes client data correctly
# =========================================================================
class TestServerProcessesClientData:
"""Vérifie que le serveur traite correctement les données du client."""
def test_window_info_extracted_from_event(self):
"""Le LiveSessionManager extrait window info des événements."""
import agent_v0.server_v1 # noqa: F401
from agent_v0.server_v1.live_session_manager import LiveSessionManager
mgr = LiveSessionManager()
# Événement typique envoyé par l'Agent V1
mgr.add_event("sess_client", {
"type": "mouse_click",
"button": "left",
"pos": [500, 300],
"window": {"title": "Firefox", "app_name": "firefox"},
})
session = mgr.get_session("sess_client")
assert session.last_window_info["title"] == "Firefox"
assert session.last_window_info["app_name"] == "firefox"
def test_crop_filtered_in_raw_session(self):
"""Les crops sont filtrés lors de la conversion RawSession."""
import agent_v0.server_v1 # noqa: F401
from agent_v0.server_v1.live_session_manager import LiveSessionManager
mgr = LiveSessionManager()
# Le client envoie full + crop
mgr.add_screenshot("sess_raw", "shot_0001_full", "/tmp/full.png")
mgr.add_screenshot("sess_raw", "shot_0001_crop", "/tmp/crop.png")
raw = mgr.to_raw_session("sess_raw")
# Seul le full doit apparaître dans RawSession
assert len(raw["screenshots"]) == 1
assert raw["screenshots"][0]["screenshot_id"] == "shot_0001_full"
def test_server_failure_tracking(self):
"""Le streamer désactive les envois après 10 échecs consécutifs."""
from agent_v0.agent_v1.network.streamer import TraceStreamer
with patch("agent_v0.agent_v1.network.streamer.requests") as mock_req:
mock_req.post.return_value = MagicMock(ok=False, status_code=500)
streamer = TraceStreamer("sess_fail")
streamer._server_available = True
# 10 échecs consécutifs
for _ in range(10):
streamer._send_event({"type": "test"})
# Le streamer est toujours _server_available=True car
# c'est la boucle _stream_loop qui fait le tracking.
# Mais _send_event retourne False
assert not streamer._send_event({"type": "test"})