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>
377 lines
13 KiB
Python
377 lines
13 KiB
Python
"""
|
|
Tests unitaires pour FAISS Rebuild Propre
|
|
|
|
Auteur : Dom, Alice Kiro - 22 décembre 2025
|
|
|
|
Tests pour les nouvelles fonctionnalités:
|
|
- FAISSManager.clear() amélioré
|
|
- FAISSManager.reindex() nouveau
|
|
- WorkflowPipeline._extract_node_vector() multi-version
|
|
"""
|
|
|
|
import pytest
|
|
import numpy as np
|
|
import tempfile
|
|
from pathlib import Path
|
|
from unittest.mock import Mock, patch, MagicMock
|
|
|
|
# Import conditionnel pour éviter les erreurs de dépendances
|
|
try:
|
|
from core.embedding.faiss_manager import FAISSManager
|
|
FAISS_AVAILABLE = True
|
|
except ImportError:
|
|
FAISS_AVAILABLE = False
|
|
FAISSManager = None
|
|
|
|
try:
|
|
from core.pipeline.workflow_pipeline import WorkflowPipeline
|
|
PIPELINE_AVAILABLE = True
|
|
except ImportError:
|
|
PIPELINE_AVAILABLE = False
|
|
WorkflowPipeline = None
|
|
|
|
|
|
@pytest.mark.skipif(not FAISS_AVAILABLE, reason="FAISS not available")
|
|
class TestFAISSManagerClear:
|
|
"""Tests pour la méthode clear() améliorée"""
|
|
|
|
def test_faiss_clear_resets_state_flat(self):
|
|
"""Test que clear() reset complètement l'état pour index Flat"""
|
|
manager = FAISSManager(dimensions=128, index_type="Flat")
|
|
|
|
# Ajouter quelques embeddings
|
|
vector1 = np.random.rand(128).astype(np.float32)
|
|
vector2 = np.random.rand(128).astype(np.float32)
|
|
|
|
manager.add_embedding("test1", vector1, {"meta": "data1"})
|
|
manager.add_embedding("test2", vector2, {"meta": "data2"})
|
|
|
|
# Vérifier état avant clear
|
|
assert manager.index.ntotal == 2
|
|
assert len(manager.metadata_store) == 2
|
|
assert manager.next_id == 2
|
|
assert manager.is_trained == True # Flat est toujours trained
|
|
|
|
# Clear
|
|
manager.clear()
|
|
|
|
# Vérifier reset complet
|
|
assert manager.index.ntotal == 0
|
|
assert len(manager.metadata_store) == 0
|
|
assert manager.next_id == 0
|
|
assert len(manager.training_vectors) == 0
|
|
assert manager.is_trained == True # Flat reste trained
|
|
|
|
def test_faiss_clear_resets_state_ivf(self):
|
|
"""Test que clear() reset complètement l'état IVF training"""
|
|
manager = FAISSManager(dimensions=128, index_type="IVF")
|
|
|
|
# Simuler des vecteurs d'entraînement
|
|
for i in range(5):
|
|
vector = np.random.rand(128).astype(np.float32)
|
|
manager.training_vectors.append(vector)
|
|
|
|
manager.next_id = 5
|
|
manager.metadata_store[0] = {"test": "data"}
|
|
|
|
# Clear
|
|
manager.clear()
|
|
|
|
# Vérifier reset complet IVF
|
|
assert manager.index.ntotal == 0
|
|
assert len(manager.metadata_store) == 0
|
|
assert manager.next_id == 0
|
|
assert len(manager.training_vectors) == 0
|
|
assert manager.is_trained == False # IVF pas trained après clear
|
|
|
|
|
|
@pytest.mark.skipif(not FAISS_AVAILABLE, reason="FAISS not available")
|
|
class TestFAISSManagerReindex:
|
|
"""Tests pour la méthode reindex() nouvelle"""
|
|
|
|
def test_faiss_reindex_flat_removes_old_entries(self):
|
|
"""Test que reindex() supprime complètement les anciennes entrées (Flat)"""
|
|
manager = FAISSManager(dimensions=128, index_type="Flat")
|
|
|
|
# Ajouter des embeddings initiaux
|
|
old_vector1 = np.random.rand(128).astype(np.float32)
|
|
old_vector2 = np.random.rand(128).astype(np.float32)
|
|
|
|
manager.add_embedding("old1", old_vector1, {"type": "old"})
|
|
manager.add_embedding("old2", old_vector2, {"type": "old"})
|
|
|
|
assert manager.index.ntotal == 2
|
|
|
|
# Préparer nouveaux items
|
|
new_vector1 = np.random.rand(128).astype(np.float32)
|
|
new_vector2 = np.random.rand(128).astype(np.float32)
|
|
|
|
items = [
|
|
("new1", new_vector1, {"type": "new"}),
|
|
("new2", new_vector2, {"type": "new"})
|
|
]
|
|
|
|
# Reindex
|
|
count = manager.reindex(items, force_train_ivf=False)
|
|
|
|
# Vérifier résultats
|
|
assert count == 2
|
|
assert manager.index.ntotal == 2
|
|
assert len(manager.metadata_store) == 2
|
|
|
|
# Vérifier que seules les nouvelles métadonnées sont présentes
|
|
for meta in manager.metadata_store.values():
|
|
assert meta["metadata"]["type"] == "new"
|
|
|
|
def test_faiss_reindex_ivf_trains_even_small(self):
|
|
"""Test que reindex() force training IVF même avec petit dataset"""
|
|
manager = FAISSManager(dimensions=128, index_type="IVF")
|
|
|
|
# Préparer petit dataset (< 100 vecteurs)
|
|
items = []
|
|
for i in range(10):
|
|
vector = np.random.rand(128).astype(np.float32)
|
|
items.append((f"item_{i}", vector, {"index": i}))
|
|
|
|
# Mock _train_ivf_index pour vérifier qu'il est appelé
|
|
with patch.object(manager, '_train_ivf_index') as mock_train:
|
|
count = manager.reindex(items, force_train_ivf=True)
|
|
|
|
# Vérifier que training a été forcé
|
|
assert count == 10
|
|
mock_train.assert_called_once()
|
|
|
|
def test_faiss_reindex_handles_invalid_vectors(self):
|
|
"""Test que reindex() ignore les vecteurs invalides et continue"""
|
|
manager = FAISSManager(dimensions=128, index_type="Flat")
|
|
|
|
# Mélanger vecteurs valides et invalides
|
|
valid_vector1 = np.random.rand(128).astype(np.float32)
|
|
valid_vector2 = np.random.rand(128).astype(np.float32)
|
|
|
|
items = [
|
|
("valid1", valid_vector1, {"type": "valid"}),
|
|
("invalid1", None, {"type": "invalid"}), # None vector
|
|
("valid2", valid_vector2, {"type": "valid"}),
|
|
("invalid2", None, {"type": "invalid"}) # None vector
|
|
]
|
|
|
|
# Reindex
|
|
count = manager.reindex(items)
|
|
|
|
# Vérifier que seuls les vecteurs valides ont été indexés
|
|
assert count == 2
|
|
assert manager.index.ntotal == 2
|
|
assert len(manager.metadata_store) == 2
|
|
|
|
def test_faiss_reindex_returns_correct_count(self):
|
|
"""Test que reindex() retourne le bon nombre d'items traités"""
|
|
manager = FAISSManager(dimensions=128, index_type="Flat")
|
|
|
|
# Préparer items
|
|
items = []
|
|
for i in range(15):
|
|
vector = np.random.rand(128).astype(np.float32)
|
|
items.append((f"item_{i}", vector, {"index": i}))
|
|
|
|
# Reindex
|
|
count = manager.reindex(items)
|
|
|
|
# Vérifier count
|
|
assert count == 15
|
|
assert manager.index.ntotal == 15
|
|
|
|
|
|
@pytest.mark.skipif(not PIPELINE_AVAILABLE, reason="Pipeline not available")
|
|
class TestWorkflowPipelineExtractNodeVector:
|
|
"""Tests pour _extract_node_vector() multi-version"""
|
|
|
|
def test_extract_node_vector_v1_list_format(self):
|
|
"""Test extraction vecteur format v1 (liste directe)"""
|
|
pipeline = WorkflowPipeline()
|
|
|
|
# Mock node avec template.embedding_prototype en liste
|
|
node = Mock()
|
|
template = Mock()
|
|
template.embedding_prototype = [0.1, 0.2, 0.3, 0.4]
|
|
node.template = template
|
|
|
|
# Extraire vecteur
|
|
vector = pipeline._extract_node_vector(node)
|
|
|
|
# Vérifier résultat
|
|
assert vector is not None
|
|
assert isinstance(vector, np.ndarray)
|
|
assert vector.dtype == np.float32
|
|
assert len(vector) == 4
|
|
assert np.allclose(vector, [0.1, 0.2, 0.3, 0.4])
|
|
|
|
def test_extract_node_vector_v2_file_format(self):
|
|
"""Test extraction vecteur format v2 (fichier sur disque)"""
|
|
pipeline = WorkflowPipeline()
|
|
|
|
# Créer fichier temporaire avec vecteur
|
|
with tempfile.NamedTemporaryFile(suffix='.npy', delete=False) as tmp:
|
|
test_vector = np.array([0.5, 0.6, 0.7, 0.8], dtype=np.float32)
|
|
np.save(tmp.name, test_vector)
|
|
tmp_path = tmp.name
|
|
|
|
try:
|
|
# Mock node avec embedding.vector_id
|
|
node = Mock()
|
|
template = Mock()
|
|
embedding = Mock()
|
|
embedding.vector_id = tmp_path
|
|
template.embedding = embedding
|
|
template.embedding_prototype = None # Pas de liste directe
|
|
node.template = template
|
|
|
|
# Extraire vecteur
|
|
vector = pipeline._extract_node_vector(node)
|
|
|
|
# Vérifier résultat
|
|
assert vector is not None
|
|
assert isinstance(vector, np.ndarray)
|
|
assert vector.dtype == np.float32
|
|
assert np.allclose(vector, [0.5, 0.6, 0.7, 0.8])
|
|
|
|
finally:
|
|
# Nettoyer fichier temporaire
|
|
Path(tmp_path).unlink(missing_ok=True)
|
|
|
|
def test_extract_node_vector_v2_format(self):
|
|
"""Test extraction vecteur format v2 (template.embedding.vector_id)"""
|
|
pipeline = WorkflowPipeline()
|
|
|
|
# Créer fichier temporaire avec vecteur
|
|
with tempfile.NamedTemporaryFile(suffix='.npy', delete=False) as tmp:
|
|
test_vector = np.array([0.9, 1.0, 1.1, 1.2], dtype=np.float32)
|
|
np.save(tmp.name, test_vector)
|
|
tmp_path = tmp.name
|
|
|
|
try:
|
|
# Mock node avec template.embedding.vector_id (format v2)
|
|
node = Mock()
|
|
node.metadata = {}
|
|
embedding = Mock()
|
|
embedding.vector_id = tmp_path
|
|
template = Mock()
|
|
template.embedding = embedding
|
|
template.embedding_prototype = None
|
|
node.template = template
|
|
|
|
# Extraire vecteur
|
|
vector = pipeline._extract_node_vector(node)
|
|
|
|
# Vérifier résultat
|
|
assert vector is not None
|
|
assert isinstance(vector, np.ndarray)
|
|
assert vector.dtype == np.float32
|
|
assert np.allclose(vector, [0.9, 1.0, 1.1, 1.2])
|
|
|
|
finally:
|
|
# Nettoyer fichier temporaire
|
|
Path(tmp_path).unlink(missing_ok=True)
|
|
|
|
def test_extract_node_vector_graceful_failure(self):
|
|
"""Test que _extract_node_vector() retourne None gracieusement"""
|
|
pipeline = WorkflowPipeline()
|
|
|
|
# Test avec node sans vecteur
|
|
node = Mock()
|
|
node.template = None
|
|
node.metadata = {}
|
|
|
|
vector = pipeline._extract_node_vector(node)
|
|
assert vector is None
|
|
|
|
# Test avec template mais pas de vecteur
|
|
node2 = Mock()
|
|
template = Mock()
|
|
template.embedding_prototype = None
|
|
template.embedding = None
|
|
node2.template = template
|
|
node2.metadata = {}
|
|
|
|
vector2 = pipeline._extract_node_vector(node2)
|
|
assert vector2 is None
|
|
|
|
# Test avec fichier inexistant
|
|
node3 = Mock()
|
|
template3 = Mock()
|
|
embedding3 = Mock()
|
|
embedding3.vector_id = "/path/that/does/not/exist.npy"
|
|
template3.embedding = embedding3
|
|
template3.embedding_prototype = None
|
|
node3.template = template3
|
|
|
|
vector3 = pipeline._extract_node_vector(node3)
|
|
assert vector3 is None
|
|
|
|
|
|
@pytest.mark.skipif(not PIPELINE_AVAILABLE, reason="Pipeline not available")
|
|
class TestWorkflowPipelineIndexWorkflowEmbeddings:
|
|
"""Tests pour _index_workflow_embeddings() amélioré"""
|
|
|
|
def test_index_workflow_embeddings_completeness(self):
|
|
"""Test que tous les vecteurs valides sont extraits et indexés"""
|
|
pipeline = WorkflowPipeline()
|
|
|
|
# Mock workflow avec plusieurs nodes
|
|
workflow = Mock()
|
|
workflow.workflow_id = "test_workflow"
|
|
|
|
# Node avec vecteur valide
|
|
node1 = Mock()
|
|
node1.node_id = "node1"
|
|
node1.name = "Node 1"
|
|
template1 = Mock()
|
|
template1.embedding_prototype = [0.1, 0.2, 0.3]
|
|
node1.template = template1
|
|
|
|
# Node avec vecteur valide
|
|
node2 = Mock()
|
|
node2.node_id = "node2"
|
|
node2.name = "Node 2"
|
|
template2 = Mock()
|
|
template2.embedding_prototype = [0.4, 0.5, 0.6]
|
|
node2.template = template2
|
|
|
|
# Node sans vecteur
|
|
node3 = Mock()
|
|
node3.node_id = "node3"
|
|
node3.name = "Node 3"
|
|
node3.template = None
|
|
|
|
workflow.nodes = [node1, node2, node3]
|
|
|
|
# Mock faiss_manager.reindex
|
|
with patch.object(pipeline.faiss_manager, 'reindex') as mock_reindex:
|
|
mock_reindex.return_value = 2
|
|
|
|
# Indexer
|
|
pipeline._index_workflow_embeddings(workflow)
|
|
|
|
# Vérifier appel reindex
|
|
mock_reindex.assert_called_once()
|
|
args, kwargs = mock_reindex.call_args
|
|
items = args[0]
|
|
|
|
# Vérifier items
|
|
items_list = list(items)
|
|
assert len(items_list) == 2 # Seulement les 2 nodes avec vecteurs
|
|
|
|
# Vérifier premier item
|
|
embedding_id1, vector1, metadata1 = items_list[0]
|
|
assert embedding_id1 == "node1"
|
|
assert np.allclose(vector1, [0.1, 0.2, 0.3])
|
|
assert metadata1["workflow_id"] == "test_workflow"
|
|
assert metadata1["node_id"] == "node1"
|
|
assert metadata1["node_name"] == "Node 1"
|
|
|
|
# Vérifier force_train_ivf
|
|
assert kwargs["force_train_ivf"] == True
|
|
|
|
|
|
if __name__ == "__main__":
|
|
pytest.main([__file__]) |