Initial commit
This commit is contained in:
485
tests/test_vectorization.py
Normal file
485
tests/test_vectorization.py
Normal file
@@ -0,0 +1,485 @@
|
||||
"""
|
||||
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"])
|
||||
Reference in New Issue
Block a user