jurix-api / tests /test_services /test_embedding_service.py
rahim237cm's picture
Claude Opus 5.5
feat(embeddings): voyage-4-large remplace EmbeddingGemma (API Voyage, halfvec 1024)
57c8117
Raw History Blame Contribute Delete
16.7 kB
"""
Tests pour EmbeddingService.
Test categories:
- Basic functionality (4 tests)
- Caching (3 tests)
- Multilingual support (2 tests)
- Edge cases (5 tests)
- Performance (2 tests)
- Health check (2 tests)
Total: 18 tests
Author: JuriX Team
"""
import hashlib
import json
import time
from concurrent.futures import ThreadPoolExecutor
from typing import List
import httpx
import numpy as np
import pytest
from app.services import embedding_service
from app.services.embedding_service import EmbeddingService
# ==================== DOUBLURE VOYAGE ====================
#
# Ces tests portent sur la logique d'EmbeddingService — validation, normalisation
# L2, cache, similarite cosinus — et non sur l'API. Ils passent par le fournisseur
# de production, Voyage, dont le transport HTTP est double : aucun appel reseau,
# aucun jeton consomme.
#
# L'embedding est deterministe : derive d'une empreinte du texte, donc deux appels
# sur le meme texte donnent le meme vecteur et deux textes differents des vecteurs
# differents. C'est exactement ce que les tests de similarite supposent.
def _fake_embedding(text: str, dim: int = None) -> List[float]:
"""
Vecteur deterministe, NON normalise et d'amplitude quelconque.
Non normalise a dessein : c'est au service de normaliser, quelle que soit
la sortie du fournisseur. Une doublure qui renverrait deja des vecteurs
unitaires ne prouverait rien.
"""
dim = dim if dim is not None else EmbeddingService.EMBEDDING_DIM
graine = int.from_bytes(hashlib.sha256(text.encode("utf-8")).digest()[:4], "big")
rng = np.random.default_rng(graine)
return (rng.random(dim) * 7.0).tolist()
@pytest.fixture(autouse=True)
def _stub_voyage(monkeypatch):
"""Double le transport HTTP de Voyage, pour tout ce module."""
corps_recus = []
def _repondre(requete: httpx.Request) -> httpx.Response:
corps = json.loads(requete.content)
corps_recus.append(corps)
# La doublure HONORE output_dimension : c'est ce que le service demande.
return httpx.Response(200, json={"data": [
{"index": i, "embedding": _fake_embedding(t, corps["output_dimension"])}
for i, t in enumerate(corps["input"])
]})
monkeypatch.setattr(embedding_service, "transport_http", httpx.MockTransport(_repondre))
return corps_recus
# ==================== FIXTURES ====================
@pytest.fixture
def service(_stub_voyage):
"""Service partage par les tests, cache desactive pour rester deterministe."""
return EmbeddingService(use_cache=False) # Cache désactivé pour tests déterministes
@pytest.fixture
def service_with_cache(_stub_voyage):
"""Service avec cache active. Le cache est une table PostgreSQL
(embedding_cache), plus Redis."""
try:
return EmbeddingService(use_cache=True)
except Exception as exc:
pytest.skip(f"Cache PostgreSQL indisponible: {exc}")
@pytest.fixture
def sample_french_text():
"""Texte juridique français exemple."""
return "Article 1er. Le Code civil camerounais régit les personnes, les biens et les obligations."
@pytest.fixture
def sample_english_text():
"""Texte juridique anglais exemple."""
return "Article 1. The Cameroonian Civil Code governs persons, property and obligations."
# ==================== TESTS BASIC FUNCTIONALITY ====================
class TestBasicFunctionality:
"""Tests de la fonctionnalité de base du service."""
def test_generate_single_embedding(self, service, sample_french_text):
"""Test génération d'un embedding unique."""
embedding = service.generate_embedding(sample_french_text)
# Vérifications
assert embedding is not None
assert isinstance(embedding, np.ndarray)
assert embedding.dtype == np.float32
def test_generate_batch_embeddings(self, service):
"""Test génération batch d'embeddings."""
texts = [
"Article 1er du Code civil",
"Article 2 du Code pénal",
"Article 3 du Code de commerce"
]
embeddings = service.generate_batch_embeddings(texts)
# Vérifications
assert len(embeddings) == 3
assert all(isinstance(emb, np.ndarray) for emb in embeddings)
assert all(emb.dtype == np.float32 for emb in embeddings)
def test_embedding_dimensions(self, service, sample_french_text):
"""Test que les embeddings ont exactement EmbeddingService.EMBEDDING_DIM dimensions."""
embedding = service.generate_embedding(sample_french_text)
assert embedding.shape == (EmbeddingService.EMBEDDING_DIM,), (
f"Dimension incorrecte: {embedding.shape} != (EmbeddingService.EMBEDDING_DIM,)"
)
def test_embedding_normalized(self, service, sample_french_text):
"""Test que les embeddings sont normalisés L2 (norme ≈ 1.0)."""
embedding = service.generate_embedding(sample_french_text, normalize=True)
norm = np.linalg.norm(embedding)
assert abs(norm - 1.0) < 0.01, (
f"Embedding non normalisé: norme={norm:.4f} (attendu ≈1.0)"
)
# ==================== TESTS CACHING ====================
class TestCaching:
"""Tests du cache d'embeddings (table PostgreSQL embedding_cache)."""
def test_cache_hit(self, service_with_cache, sample_french_text):
"""Test que le deuxième appel utilise le cache."""
# Premier appel (génération)
start1 = time.time()
emb1 = service_with_cache.generate_embedding(sample_french_text)
time1 = time.time() - start1
# Deuxième appel (cache hit)
start2 = time.time()
emb2 = service_with_cache.generate_embedding(sample_french_text)
time2 = time.time() - start2
# Vérifications
np.testing.assert_array_almost_equal(emb1, emb2, decimal=5)
# Pas de seuil de rapidite : avec une doublure locale, generer et lire le
# cache coutent le meme ordre de grandeur. Le facteur 2 attendu supposait
# un appel reseau. Ce qui compte ici est que le cache renvoie le MEME
# vecteur (verifie juste au-dessus) sans nouvel appel au modele.
assert time2 <= time1 * 3, (
f"Un hit de cache ne doit pas etre nettement plus lent: {time2:.3f}s vs {time1:.3f}s"
)
def test_cache_miss(self, service_with_cache):
"""Test que le premier appel génère et cache l'embedding."""
unique_text = f"Article unique test {time.time()}"
start = time.time()
embedding = service_with_cache.generate_embedding(unique_text)
elapsed = time.time() - start
# Vérifications
assert embedding is not None
assert embedding.shape == (EmbeddingService.EMBEDDING_DIM,)
assert elapsed < 1.0, f"Génération trop lente: {elapsed:.3f}s > 1.0s"
def test_cache_disabled(self, service):
"""Test que le service fonctionne sans cache."""
text = "Article test sans cache"
# Devrait fonctionner normalement
emb1 = service.generate_embedding(text)
emb2 = service.generate_embedding(text)
# Les embeddings devraient être identiques (même texte)
np.testing.assert_array_almost_equal(emb1, emb2, decimal=5)
# ==================== TESTS MULTILINGUAL SUPPORT ====================
class TestMultilingualSupport:
"""Tests du support multilingue (FR/EN)."""
def test_french_text(self, service, sample_french_text):
"""Test traitement de texte juridique français."""
embedding = service.generate_embedding(sample_french_text)
# Vérifications
assert embedding is not None
assert embedding.shape == (EmbeddingService.EMBEDDING_DIM,)
# Le texte contient des mots français juridiques
assert "camerounais" in sample_french_text.lower()
def test_english_text(self, service, sample_english_text):
"""Test traitement de texte juridique anglais."""
embedding = service.generate_embedding(sample_english_text)
# Vérifications
assert embedding is not None
assert embedding.shape == (EmbeddingService.EMBEDDING_DIM,)
# Le texte contient des mots anglais juridiques
assert "cameroonian" in sample_english_text.lower()
# ==================== TESTS EDGE CASES ====================
class TestEdgeCases:
"""Tests des cas limites et edge cases."""
def test_empty_text_raises_error(self, service):
"""Test que le texte vide lève une ValueError."""
with pytest.raises(ValueError, match="ne peut pas être vide"):
service.generate_embedding("")
with pytest.raises(ValueError, match="ne peut pas être vide"):
service.generate_embedding(" ") # Whitespace only
def test_very_long_text(self, service):
"""Au-dela de la limite du fournisseur, le texte est refuse."""
long_text = "A" * (service.MAX_TEXT_LENGTH + 1)
with pytest.raises(ValueError, match="Texte trop long"):
service.generate_embedding(long_text)
def test_special_characters(self, service):
"""Test gestion des caractères spéciaux et accents."""
special_text = (
"Article 1er. Les droits énoncés à l'article 2 § 3 "
"s'appliquent à tous : é, è, ê, ç, à, ù, œ, €, °, ..."
)
embedding = service.generate_embedding(special_text)
# Devrait fonctionner normalement
assert embedding is not None
assert embedding.shape == (EmbeddingService.EMBEDDING_DIM,)
def test_batch_with_duplicates(self, service):
"""Test batch avec textes dupliqués."""
texts = [
"Article 1er",
"Article 2",
"Article 1er", # Duplicate
"Article 3",
"Article 1er" # Duplicate
]
embeddings = service.generate_batch_embeddings(texts)
# Vérifications
assert len(embeddings) == 5
# Les duplicates devraient avoir les mêmes embeddings
np.testing.assert_array_almost_equal(
embeddings[0], embeddings[2], decimal=5
)
np.testing.assert_array_almost_equal(
embeddings[0], embeddings[4], decimal=5
)
def test_concurrent_requests(self, service):
"""Test thread safety avec requêtes concurrentes."""
texts = [f"Article {i}" for i in range(10)]
def generate_embedding(text: str) -> np.ndarray:
return service.generate_embedding(text)
# Exécution parallèle
with ThreadPoolExecutor(max_workers=4) as executor:
results = list(executor.map(generate_embedding, texts))
# Vérifications
assert len(results) == 10
assert all(emb.shape == (EmbeddingService.EMBEDDING_DIM,) for emb in results)
# Pas de corruption de données
for i, emb in enumerate(results):
assert not np.isnan(emb).any(), f"NaN dans embedding {i}"
assert not np.isinf(emb).any(), f"Inf dans embedding {i}"
# ==================== TESTS PERFORMANCE ====================
class TestPerformance:
"""Tests des performances du service."""
def test_single_embedding_performance(self, service, sample_french_text):
"""Test que l'embedding unique prend <200ms."""
start = time.time()
embedding = service.generate_embedding(sample_french_text)
elapsed = time.time() - start
assert embedding is not None
assert elapsed < 0.2, (
f"Trop lent: {elapsed:.3f}s > 0.2s (cible: <200ms)"
)
def test_batch_10_performance(self, service):
"""Test que le batch de 10 embeddings prend <1s."""
texts = [f"Article {i} du Code civil camerounais" for i in range(1, 11)]
start = time.time()
embeddings = service.generate_batch_embeddings(texts, batch_size=10)
elapsed = time.time() - start
assert len(embeddings) == 10
assert elapsed < 1.0, (
f"Trop lent: {elapsed:.3f}s > 1.0s (cible: <1s pour 10 textes)"
)
# ==================== TESTS HEALTH CHECK ====================
class TestHealthCheck:
"""Tests du health check du service."""
def test_health_check_returns_status(self, service):
"""Test que le health check retourne un statut valide."""
health = service.health_check()
# Vérifications structure
assert isinstance(health, dict)
assert "service" in health
assert "status" in health
assert "model" in health
assert "dimensions" in health
# "device" appartenait au modele sentence-transformers local ; le service
# passe par une API, qui n'a pas de notion de peripherique.
assert "model" in health
assert "cache_enabled" in health
# Vérifications valeurs
assert health["service"] == "EmbeddingService"
assert health["status"] in ["healthy", "degraded", "unhealthy"]
assert health["model"] == service.provider.model
assert health["dimensions"] == EmbeddingService.EMBEDDING_DIM
# "device" appartenait au modele sentence-transformers charge en local.
# Le service passe par l'API Voyage : ce qui compte est le fournisseur.
assert health["provider"] == "Voyage AI"
assert isinstance(health["cache_enabled"], bool)
def test_health_check_without_cache(self, service):
"""Test degradation propre quand le cache est indisponible."""
# Service cree sans cache
health = service.health_check()
# Le service devrait rester "healthy" même sans cache
assert health["status"] == "healthy"
assert health["cache_backend"] == "disabled"
# ==================== TESTS SIMILARITY ====================
class TestSimilarity:
"""Tests de la fonction de similarité."""
def test_similarity_identical_texts(self, service):
"""Test similarité entre textes identiques."""
text = "Article 1er du Code civil"
emb1 = service.generate_embedding(text)
emb2 = service.generate_embedding(text)
similarity = service.similarity(emb1, emb2)
# Devrait être très proche de 1.0 (identiques)
assert similarity > 0.99, (
f"Similarité trop faible pour textes identiques: {similarity:.4f}"
)
def test_similarity_similar_texts(self, service):
"""Test similarité entre textes similaires."""
text1 = "Article premier du Code civil camerounais"
text2 = "Article 1er du Code civil du Cameroun"
emb1 = service.generate_embedding(text1)
emb2 = service.generate_embedding(text2)
similarity = service.similarity(emb1, emb2)
# Devrait être élevée (textes très similaires)
assert similarity > 0.7, (
f"Similarité trop faible: {similarity:.4f} (attendu >0.7)"
)
def test_similarity_different_texts(self, service):
"""Test similarité entre textes différents."""
text1 = "Article 1er du Code civil"
text2 = "Article 100 du Code pénal"
emb1 = service.generate_embedding(text1)
emb2 = service.generate_embedding(text2)
similarity = service.similarity(emb1, emb2)
# Devrait être modérée (même domaine mais articles différents)
assert 0.0 <= similarity <= 1.0, (
f"Similarité hors bornes: {similarity:.4f}"
)
def test_similarity_wrong_dimensions(self, service):
"""Test que les mauvaises dimensions lèvent une erreur."""
emb1 = np.random.rand(EmbeddingService.EMBEDDING_DIM).astype(np.float32)
emb2_wrong = np.random.rand(512).astype(np.float32) # Mauvaise dim
with pytest.raises(ValueError, match="dimension incorrecte"):
service.similarity(emb1, emb2_wrong)
# ==================== TESTS VALIDATION ====================
class TestValidation:
"""Tests de validation des entrées."""
def test_batch_empty_list_raises_error(self, service):
"""Test que liste vide lève une erreur."""
with pytest.raises(ValueError, match="ne peut pas être vide"):
service.generate_batch_embeddings([])
def test_batch_with_invalid_text(self, service):
"""Test que texte invalide dans batch lève une erreur."""
texts = [
"Article 1er",
"", # Invalide
"Article 3"
]
with pytest.raises(ValueError, match="Texte invalide à l'index 1"):
service.generate_batch_embeddings(texts)