Spaces:
Runtime error
Runtime error
Claude Opus 5.5
feat(embeddings): voyage-4-large remplace EmbeddingGemma (API Voyage, halfvec 1024)
57c8117 Download tests/test_services/test_embedding_service.py from rahim237cm/jurix-api: direct link, hf CLI and curl.
- Browser
- Download file 16.7 kB
-
https://huggingface.co/spaces/rahim237cm/jurix-api/resolve/main/tests/test_services/test_embedding_service.py
- Command line
-
hf download hf://spaces/rahim237cm/jurix-api/tests/test_services/test_embedding_service.py
-
curl -L -o test_embedding_service.py https://huggingface.co/spaces/rahim237cm/jurix-api/resolve/main/tests/test_services/test_embedding_service.py
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() | |
| 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 ==================== | |
| 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 | |
| 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}") | |
| 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." | |
| 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) | |