""" Tests pour la vectorisation et l'indexation des référentiels. Ce module teste: - Le chargement du modèle d'embeddings - La vectorisation des chunks - La construction de l'index HNSW avec FAISS - La vectorisation des index alphabétiques - La génération de hash d'index pour versionnement """ import json import tempfile from pathlib import Path import faiss import numpy as np import pytest from pipeline_mco_pmsi.rag.referentiels_manager import Chunk, ReferentielsManager, VectorIndex class TestEmbeddingsModel: """Tests pour le chargement du modèle d'embeddings.""" def test_load_embeddings_model_success(self): """Test que le modèle d'embeddings se charge correctement.""" manager = ReferentielsManager() model = manager._load_embeddings_model() assert model is not None # Vérifier que le modèle peut encoder du texte embedding = model.encode("Test médical", convert_to_numpy=True) assert embedding is not None assert len(embedding.shape) == 1 # Vecteur 1D assert embedding.shape[0] > 0 # Dimension > 0 def test_embeddings_model_produces_consistent_vectors(self): """Test que le modèle produit des vecteurs cohérents pour le même texte.""" manager = ReferentielsManager() model = manager._load_embeddings_model() text = "Gastrite aiguë avec hémorragie" embedding1 = model.encode(text, convert_to_numpy=True, normalize_embeddings=True) embedding2 = model.encode(text, convert_to_numpy=True, normalize_embeddings=True) # Les embeddings doivent être identiques (ou très proches) np.testing.assert_array_almost_equal(embedding1, embedding2, decimal=5) def test_embeddings_model_normalized(self): """Test que les embeddings sont normalisés (L2 norm = 1).""" manager = ReferentielsManager() model = manager._load_embeddings_model() text = "Diagnostic principal" embedding = model.encode(text, convert_to_numpy=True, normalize_embeddings=True) # Calculer la norme L2 norm = np.linalg.norm(embedding) # La norme doit être proche de 1 (normalisé) assert abs(norm - 1.0) < 0.01 class TestVectorization: """Tests pour la vectorisation des chunks.""" def test_vectorize_single_chunk(self): """Test la vectorisation d'un seul chunk.""" manager = ReferentielsManager() chunk = Chunk( chunk_id="test_001", referentiel_type="cim10", referentiel_version="2026", content="K29.7 Gastrite, sans précision", metadata={"chunk_type": "code_block"}, chunk_index=0, ) # Vectoriser le chunk model = manager._load_embeddings_model() vector = model.encode(chunk.content, convert_to_numpy=True, normalize_embeddings=True) assert vector is not None assert vector.shape[0] > 0 assert isinstance(vector, np.ndarray) def test_vectorize_multiple_chunks(self): """Test la vectorisation de plusieurs chunks.""" manager = ReferentielsManager() chunks = [ Chunk( chunk_id=f"test_{i:03d}", referentiel_type="cim10", referentiel_version="2026", content=f"Code test {i}", metadata={"chunk_type": "code_block"}, chunk_index=i, ) for i in range(10) ] model = manager._load_embeddings_model() vectors = [ model.encode(chunk.content, convert_to_numpy=True, normalize_embeddings=True) for chunk in chunks ] assert len(vectors) == 10 # Tous les vecteurs doivent avoir la même dimension dimensions = [v.shape[0] for v in vectors] assert len(set(dimensions)) == 1 # Toutes les dimensions sont identiques class TestBuildIndex: """Tests pour la construction de l'index HNSW.""" def test_build_index_success(self, tmp_path): """Test la construction réussie d'un index HNSW.""" manager = ReferentielsManager(data_dir=tmp_path) # Créer des chunks de test chunks = [ Chunk( chunk_id=f"cim10_2026_{i}", referentiel_type="cim10", referentiel_version="2026", content=f"K29.{i} Gastrite type {i}", metadata={"chunk_type": "code_block"}, chunk_index=i, ) for i in range(20) ] # Construire l'index vector_index = manager.build_index(chunks) # Vérifications assert isinstance(vector_index, VectorIndex) assert vector_index.index_hash is not None assert len(vector_index.index_hash) == 64 # SHA-256 hex assert vector_index.dimension > 0 assert vector_index.num_vectors == 20 assert vector_index.index_type == "HNSW" assert vector_index.created_at is not None def test_build_index_saves_to_disk(self, tmp_path): """Test que l'index est sauvegardé sur disque.""" manager = ReferentielsManager(data_dir=tmp_path) chunks = [ Chunk( chunk_id=f"cim10_2026_{i}", referentiel_type="cim10", referentiel_version="2026", content=f"Code {i}", metadata={}, chunk_index=i, ) for i in range(10) ] manager.build_index(chunks) # Vérifier que les fichiers sont créés index_file = tmp_path / "cim10_2026_index.faiss" chunks_file = tmp_path / "cim10_2026_chunks.json" assert index_file.exists() assert chunks_file.exists() # Vérifier que l'index peut être rechargé loaded_index = faiss.read_index(str(index_file)) assert loaded_index.ntotal == 10 def test_build_index_chunks_json_valid(self, tmp_path): """Test que le fichier JSON des chunks est valide.""" manager = ReferentielsManager(data_dir=tmp_path) chunks = [ Chunk( chunk_id=f"ccam_2025_{i}", referentiel_type="ccam", referentiel_version="2025", content=f"YYYY00{i} Acte {i}", metadata={"section": "Section A"}, chunk_index=i, ) for i in range(5) ] manager.build_index(chunks) # Charger et vérifier le JSON chunks_file = tmp_path / "ccam_2025_chunks.json" with open(chunks_file, "r", encoding="utf-8") as f: loaded_chunks = json.load(f) assert len(loaded_chunks) == 5 assert loaded_chunks[0]["chunk_id"] == "ccam_2025_0" assert loaded_chunks[0]["referentiel_type"] == "ccam" assert loaded_chunks[0]["content"] == "YYYY000 Acte 0" def test_build_index_empty_chunks_raises_error(self): """Test qu'une liste vide de chunks lève une erreur.""" manager = ReferentielsManager() with pytest.raises(ValueError, match="ne peut pas être vide"): manager.build_index([]) def test_build_index_hash_consistency(self, tmp_path): """Test que le hash de l'index est cohérent.""" manager = ReferentielsManager(data_dir=tmp_path) chunks = [ Chunk( chunk_id=f"test_{i}", referentiel_type="cim10", referentiel_version="2026", content=f"Content {i}", metadata={}, chunk_index=i, ) for i in range(10) ] # Construire l'index deux fois index1 = manager.build_index(chunks) # Le hash devrait être le même (même paramètres, même nombre de vecteurs) # Note: En pratique, le hash dépend des paramètres, pas du contenu exact assert len(index1.index_hash) == 64 class TestAlphabeticalIndexVectorization: """Tests pour la vectorisation des index alphabétiques.""" def test_vectorize_alphabetical_index_cim10(self): """Test la vectorisation d'un index alphabétique CIM-10.""" manager = ReferentielsManager() # Simuler un index alphabétique CIM-10 alpha_text = """A Abcès - voir A00.1 Abdomen aigu - voir R10.0 B Bronchite - voir J20.9 Bronchopneumonie - voir J18.0 C Cataracte - voir H26.9 Cholécystite - voir K81.9 """ chunks = manager.vectorize_alphabetical_indexes( alpha_text, "cim10", "2026" ) assert len(chunks) > 0 # Vérifier que les chunks ont le bon type for chunk in chunks: assert chunk.referentiel_type == "cim10" assert chunk.referentiel_version == "2026" assert chunk.metadata["chunk_type"] == "alphabetical_index" assert "letter" in chunk.metadata def test_vectorize_alphabetical_index_ccam(self): """Test la vectorisation d'un index alphabétique CCAM.""" manager = ReferentielsManager() # Simuler un index alphabétique CCAM alpha_text = """A Ablation - YYYY001 Amputation - YYYY002 B Biopsie - ZZZZ001 Bronchoscopie - ZZZZ002 """ chunks = manager.vectorize_alphabetical_indexes( alpha_text, "ccam", "2025" ) assert len(chunks) > 0 for chunk in chunks: assert chunk.referentiel_type == "ccam" assert chunk.referentiel_version == "2025" assert chunk.metadata["chunk_type"] == "alphabetical_index" def test_alphabetical_index_extracts_codes(self): """Test que les codes sont extraits dans les métadonnées.""" manager = ReferentielsManager() alpha_text = """Gastrite - voir K29.7 Gastro-entérite - voir A09 Glaucome - voir H40.9 """ chunks = manager.vectorize_alphabetical_indexes( alpha_text, "cim10", "2026" ) # Au moins un chunk devrait contenir des codes dans les métadonnées codes_found = False for chunk in chunks: if chunk.metadata.get("codes"): codes_found = True # Vérifier que les codes sont au format CIM-10 codes = chunk.metadata["codes"].split(",") for code in codes: assert len(code) >= 3 # Au moins A00 assert codes_found def test_alphabetical_index_chunk_size(self): """Test que les chunks d'index alphabétique respectent la taille cible.""" manager = ReferentielsManager() # Créer un long index alphabétique entries = [] for i in range(100): entries.append(f"Terme médical {i} - voir K{i:02d}.{i % 10}") alpha_text = "\n".join(entries) chunks = manager.vectorize_alphabetical_indexes( alpha_text, "cim10", "2026" ) # Vérifier que les chunks ne sont pas trop grands for chunk in chunks: assert len(chunk.content) <= 2048 # max_chunk_size class TestIntegrationVectorizationIndexation: """Tests d'intégration pour le workflow complet.""" def test_full_workflow_import_chunk_vectorize_index(self, tmp_path): """Test le workflow complet: import → chunk → vectorize → index.""" # Créer un fichier PDF de test pdf_path = tmp_path / "test_ref.pdf" # Pour ce test, on va créer un fichier texte au lieu d'un PDF # car créer un PDF valide est complexe text_content = """CHAPITRE I - Maladies infectieuses A00-A09 Maladies intestinales infectieuses A00 Choléra A00.0 Choléra dû à Vibrio cholerae 01, biovar cholerae A00.1 Choléra dû à Vibrio cholerae 01, biovar El Tor A00.9 Choléra, sans précision Inclus: infection à Vibrio cholerae Exclut: intoxication alimentaire (A05.-) """ # Sauvegarder le texte directement (simuler l'extraction PDF) manager = ReferentielsManager(data_dir=tmp_path) text_file = tmp_path / "cim10_2026_text.txt" with open(text_file, "w", encoding="utf-8") as f: f.write(text_content) # Créer une version de référentiel manuellement from pipeline_mco_pmsi.models.metadata import ReferentielVersion from datetime import datetime import hashlib ref_version = ReferentielVersion( type="cim10", version="2026", import_date=datetime.now(), file_hash=hashlib.sha256(text_content.encode()).hexdigest(), chunk_count=0, index_hash="0" * 64, ) # Chunker chunks = manager.chunk_referentiel(ref_version) assert len(chunks) > 0 # Construire l'index vector_index = manager.build_index(chunks) assert vector_index.num_vectors == len(chunks) # Vérifier que les fichiers sont créés assert (tmp_path / "cim10_2026_index.faiss").exists() assert (tmp_path / "cim10_2026_chunks.json").exists() class TestIndexSearch: """Tests pour la recherche dans l'index vectoriel.""" def test_index_search_basic(self, tmp_path): """Test une recherche basique dans l'index.""" manager = ReferentielsManager(data_dir=tmp_path) # Créer des chunks avec du contenu médical chunks = [ Chunk( chunk_id=f"cim10_2026_{i}", referentiel_type="cim10", referentiel_version="2026", content=content, metadata={}, chunk_index=i, ) for i, content in enumerate([ "K29.7 Gastrite, sans précision", "K29.0 Gastrite hémorragique aiguë", "J18.0 Bronchopneumonie, sans précision", "I10 Hypertension essentielle", "E11 Diabète sucré non insulino-dépendant", ]) ] # Construire l'index manager.build_index(chunks) # Charger l'index index_file = tmp_path / "cim10_2026_index.faiss" index = faiss.read_index(str(index_file)) # Effectuer une recherche model = manager._load_embeddings_model() query = "gastrite" query_vector = model.encode(query, convert_to_numpy=True, normalize_embeddings=True) query_vector = query_vector.reshape(1, -1).astype(np.float32) # Rechercher les 2 plus proches voisins distances, indices = index.search(query_vector, 2) # Les résultats devraient inclure les chunks sur la gastrite assert len(indices[0]) == 2 # Les indices devraient être 0 ou 1 (les deux chunks sur la gastrite) assert indices[0][0] in [0, 1] assert indices[0][1] in [0, 1] def test_index_search_similarity_scores(self, tmp_path): """Test que les scores de similarité sont cohérents.""" manager = ReferentielsManager(data_dir=tmp_path) chunks = [ Chunk( chunk_id=f"test_{i}", referentiel_type="cim10", referentiel_version="2026", content=content, metadata={}, chunk_index=i, ) for i, content in enumerate([ "Gastrite aiguë avec hémorragie", "Gastrite chronique", "Pneumonie bactérienne", ]) ] manager.build_index(chunks) # Charger l'index index_file = tmp_path / "cim10_2026_index.faiss" index = faiss.read_index(str(index_file)) # Rechercher avec une requête proche du premier chunk model = manager._load_embeddings_model() query = "gastrite hémorragique" query_vector = model.encode(query, convert_to_numpy=True, normalize_embeddings=True) query_vector = query_vector.reshape(1, -1).astype(np.float32) distances, indices = index.search(query_vector, 3) # Le premier résultat devrait être le plus proche (distance la plus petite) # Avec des embeddings normalisés, la distance est 1 - cosine_similarity # Donc une distance plus petite = plus similaire assert distances[0][0] <= distances[0][1] assert distances[0][1] <= distances[0][2] if __name__ == "__main__": pytest.main([__file__, "-v"])