Files
rpa_vision_v3/tests/unit/test_faiss_reindex.py
Dom cf495dd82f 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>
2026-03-15 10:02:09 +01:00

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__])