rahim237cm Claude Opus 5 commited on
Commit
bb367b5
·
1 Parent(s): 9729782

Etape 7 : type de colonne suggested_categories + tests d'embeddings

Browse files

BUG DE SCHEMA : le modele Law declarait suggested_categories en JSON alors que
la migration 001 cree la colonne en integer[]. Toute ecriture echouait en
DatatypeMismatchError ("column is of type integer[] but expression is of type
json") — donc toute creation de loi avec des categories suggerees.
Ironie : un adaptateur get_array_type() existait juste au-dessus pour choisir
ARRAY sous PostgreSQL, mais n'a jamais ete appele. Le projet ne ciblant que
PostgreSQL, le type est declare directement et l'adaptateur mort supprime.

embedding_service : la liste vide levait un AssertionError. Les assertions
sautent sous python -O : la validation aurait disparu en production. -> ValueError.

Tests :
- test_embedding_service appelait la VRAIE API Gemini (echecs 400, et
consommation de quota si la cle avait ete valide). Doublure deterministe
installee : le vecteur derive du hash du texte, donc stable et discriminant,
ce que supposent les tests de similarite. 131 s -> 0,4 s.
- Les fixtures service etaient de portee module et construisaient le client
AVANT que la doublure ne s'applique -> portee fonction + dependance explicite.
- Assertions perimees corrigees : 768 dimensions (ancien sentence-transformers)
-> 3072, cle "device" -> "provider", MODEL_NAME -> EMBEDDING_MODEL,
"cache_status" -> "cache_backend", et un seuil de rapidite du cache qui
supposait un appel reseau.
- test_rag_service : la fixture mock_search_results omettait les champs requis
type et status et passait un champ content inexistant.

Progression : 110 -> 346 tests passants, 280 -> 9 erreurs. Duree divisee par 2.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

app/models/law.py CHANGED
@@ -37,16 +37,12 @@ from pgvector.sqlalchemy import Vector
37
  from app.core.database import Base
38
 
39
 
40
- # Type adapter for suggested_categories - uses ARRAY for PostgreSQL, JSON for others
41
- def get_array_type():
42
- """Get appropriate array type based on database dialect."""
43
- import os
44
- db_url = os.getenv("DATABASE_URL", "")
45
- if "postgresql" in db_url or "asyncpg" in db_url:
46
- return ARRAY(Integer)
47
- else:
48
- # Use JSON for SQLite and other databases
49
- return JSON
50
 
51
 
52
  class Category(Base):
@@ -143,7 +139,7 @@ class Law(Base):
143
  language = Column(String(2), nullable=True, index=True) # fr, en
144
  language_confidence = Column(Float, nullable=True)
145
  detected_language = Column(String(2), nullable=True)
146
- suggested_categories = Column(JSON, nullable=True) # List[int] stored as JSON for compatibility
147
  category_confidence = Column(Float, nullable=True)
148
 
149
  # File tracking
 
37
  from app.core.database import Base
38
 
39
 
40
+ # NOTE: un adaptateur get_array_type() existait ici pour choisir ARRAY sous
41
+ # PostgreSQL et JSON ailleurs. Il n'a jamais ete appele — la colonne etait
42
+ # declaree en JSON en dur alors que la migration 001 la cree en integer[],
43
+ # donc toute ecriture de suggested_categories echouait en DatatypeMismatchError
44
+ # ("column is of type integer[] but expression is of type json").
45
+ # Le projet ne cible que PostgreSQL : le type est desormais declare directement.
 
 
 
 
46
 
47
 
48
  class Category(Base):
 
139
  language = Column(String(2), nullable=True, index=True) # fr, en
140
  language_confidence = Column(Float, nullable=True)
141
  detected_language = Column(String(2), nullable=True)
142
+ suggested_categories = Column(ARRAY(Integer), nullable=True) # ids des categories suggerees
143
  category_confidence = Column(Float, nullable=True)
144
 
145
  # File tracking
app/services/embedding_service.py CHANGED
@@ -224,7 +224,10 @@ class EmbeddingService:
224
  Returns:
225
  Liste d'embeddings numpy array (ordre préservé)
226
  """
227
- assert texts, "La liste de textes ne peut pas être vide"
 
 
 
228
  start_time = time.time()
229
 
230
  for idx, text in enumerate(texts):
 
224
  Returns:
225
  Liste d'embeddings numpy array (ordre préservé)
226
  """
227
+ # ValueError et non assert : les assertions sautent sous python -O,
228
+ # la validation disparaitrait alors silencieusement en production.
229
+ if not texts:
230
+ raise ValueError("La liste de textes ne peut pas être vide")
231
  start_time = time.time()
232
 
233
  for idx, text in enumerate(texts):
tests/test_services/test_embedding_service.py CHANGED
@@ -24,10 +24,53 @@ import pytest
24
  from app.services.embedding_service import EmbeddingService, EmbeddingServiceError
25
 
26
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
27
  # ==================== FIXTURES ====================
28
 
29
- @pytest.fixture(scope="module")
30
- def service():
 
 
 
31
  """
32
  Shared service instance for all tests (module-scoped).
33
 
@@ -37,8 +80,8 @@ def service():
37
  return EmbeddingService(use_cache=False) # Cache désactivé pour tests déterministes
38
 
39
 
40
- @pytest.fixture(scope="module")
41
- def service_with_cache():
42
  """Service instance avec cache Redis activé."""
43
  try:
44
  return EmbeddingService(use_cache=True)
@@ -88,11 +131,11 @@ class TestBasicFunctionality:
88
  assert all(emb.dtype == np.float32 for emb in embeddings)
89
 
90
  def test_embedding_dimensions(self, service, sample_french_text):
91
- """Test que les embeddings ont exactement 768 dimensions."""
92
  embedding = service.generate_embedding(sample_french_text)
93
 
94
- assert embedding.shape == (768,), (
95
- f"Dimension incorrecte: {embedding.shape} != (768,)"
96
  )
97
 
98
  def test_embedding_normalized(self, service, sample_french_text):
@@ -124,8 +167,12 @@ class TestCaching:
124
 
125
  # Vérifications
126
  np.testing.assert_array_almost_equal(emb1, emb2, decimal=5)
127
- assert time2 < time1 * 0.5, (
128
- f"Cache hit devrait être plus rapide: {time2:.3f}s vs {time1:.3f}s"
 
 
 
 
129
  )
130
 
131
  def test_cache_miss(self, service_with_cache):
@@ -138,7 +185,7 @@ class TestCaching:
138
 
139
  # Vérifications
140
  assert embedding is not None
141
- assert embedding.shape == (768,)
142
  assert elapsed < 1.0, f"Génération trop lente: {elapsed:.3f}s > 1.0s"
143
 
144
  def test_cache_disabled(self, service):
@@ -164,7 +211,7 @@ class TestMultilingualSupport:
164
 
165
  # Vérifications
166
  assert embedding is not None
167
- assert embedding.shape == (768,)
168
 
169
  # Le texte contient des mots français juridiques
170
  assert "camerounais" in sample_french_text.lower()
@@ -175,7 +222,7 @@ class TestMultilingualSupport:
175
 
176
  # Vérifications
177
  assert embedding is not None
178
- assert embedding.shape == (768,)
179
 
180
  # Le texte contient des mots anglais juridiques
181
  assert "cameroonian" in sample_english_text.lower()
@@ -212,7 +259,7 @@ class TestEdgeCases:
212
 
213
  # Devrait fonctionner normalement
214
  assert embedding is not None
215
- assert embedding.shape == (768,)
216
 
217
  def test_batch_with_duplicates(self, service):
218
  """Test batch avec textes dupliqués."""
@@ -250,7 +297,7 @@ class TestEdgeCases:
250
 
251
  # Vérifications
252
  assert len(results) == 10
253
- assert all(emb.shape == (768,) for emb in results)
254
 
255
  # Pas de corruption de données
256
  for i, emb in enumerate(results):
@@ -303,15 +350,19 @@ class TestHealthCheck:
303
  assert "status" in health
304
  assert "model" in health
305
  assert "dimensions" in health
306
- assert "device" in health
 
 
307
  assert "cache_enabled" in health
308
 
309
  # Vérifications valeurs
310
  assert health["service"] == "EmbeddingService"
311
  assert health["status"] in ["healthy", "degraded", "unhealthy"]
312
- assert health["model"] == EmbeddingService.MODEL_NAME
313
- assert health["dimensions"] == 768
314
- assert health["device"] in ["cpu", "cuda", "cuda:0", "mps"]
 
 
315
  assert isinstance(health["cache_enabled"], bool)
316
 
317
  def test_health_check_with_redis_down(self, service):
@@ -321,7 +372,7 @@ class TestHealthCheck:
321
 
322
  # Le service devrait rester "healthy" même sans cache
323
  assert health["status"] == "healthy"
324
- assert health["cache_status"] == "disabled"
325
 
326
 
327
  # ==================== TESTS SIMILARITY ====================
@@ -375,7 +426,7 @@ class TestSimilarity:
375
 
376
  def test_similarity_wrong_dimensions(self, service):
377
  """Test que les mauvaises dimensions lèvent une erreur."""
378
- emb1 = np.random.rand(768).astype(np.float32)
379
  emb2_wrong = np.random.rand(512).astype(np.float32) # Mauvaise dim
380
 
381
  with pytest.raises(ValueError, match="dimension incorrecte"):
 
24
  from app.services.embedding_service import EmbeddingService, EmbeddingServiceError
25
 
26
 
27
+ # ==================== DOUBLURE GEMINI ====================
28
+ #
29
+ # Ces tests portent sur la logique d'EmbeddingService — validation, normalisation
30
+ # L2, cache, similarite cosinus — et non sur l'API Gemini. Sans doublure ils
31
+ # appelaient le vrai service et echouaient en 400 INVALID_ARGUMENT (ou auraient
32
+ # consomme du quota si la cle avait ete valide).
33
+ #
34
+ # L'embedding est deterministe : derive du hash du texte, donc deux appels sur le
35
+ # meme texte donnent le meme vecteur et deux textes differents des vecteurs
36
+ # differents. C'est exactement ce que les tests de similarite supposent.
37
+
38
+ def _fake_embedding(text: str, dim: int = 3072) -> List[float]:
39
+ rng = np.random.default_rng(abs(hash(text)) % (2**32))
40
+ return rng.random(dim).tolist()
41
+
42
+
43
+ @pytest.fixture(autouse=True)
44
+ def _stub_gemini(monkeypatch):
45
+ """Remplace le client Gemini par une doublure, pour tout ce module."""
46
+
47
+ class _Emb:
48
+ def __init__(self, values):
49
+ self.values = values
50
+
51
+ class _Result:
52
+ def __init__(self, embeddings):
53
+ self.embeddings = embeddings
54
+
55
+ class _Models:
56
+ def embed_content(self, model=None, contents=None, config=None):
57
+ items = contents if isinstance(contents, list) else [contents]
58
+ return _Result([_Emb(_fake_embedding(c)) for c in items])
59
+
60
+ class _Client:
61
+ def __init__(self, *a, **k):
62
+ self.models = _Models()
63
+
64
+ monkeypatch.setattr("app.services.embedding_service.genai.Client", _Client)
65
+
66
+
67
  # ==================== FIXTURES ====================
68
 
69
+ @pytest.fixture
70
+ def service(_stub_gemini):
71
+ # Portee fonction et dependance explicite a la doublure : en portee module,
72
+ # le service etait construit AVANT que le patch de genai.Client ne s'applique,
73
+ # et gardait donc un vrai client.
74
  """
75
  Shared service instance for all tests (module-scoped).
76
 
 
80
  return EmbeddingService(use_cache=False) # Cache désactivé pour tests déterministes
81
 
82
 
83
+ @pytest.fixture
84
+ def service_with_cache(_stub_gemini):
85
  """Service instance avec cache Redis activé."""
86
  try:
87
  return EmbeddingService(use_cache=True)
 
131
  assert all(emb.dtype == np.float32 for emb in embeddings)
132
 
133
  def test_embedding_dimensions(self, service, sample_french_text):
134
+ """Test que les embeddings ont exactement 3072 dimensions."""
135
  embedding = service.generate_embedding(sample_french_text)
136
 
137
+ assert embedding.shape == (3072,), (
138
+ f"Dimension incorrecte: {embedding.shape} != (3072,)"
139
  )
140
 
141
  def test_embedding_normalized(self, service, sample_french_text):
 
167
 
168
  # Vérifications
169
  np.testing.assert_array_almost_equal(emb1, emb2, decimal=5)
170
+ # Pas de seuil de rapidite : avec une doublure locale, generer et lire le
171
+ # cache coutent le meme ordre de grandeur. Le facteur 2 attendu supposait
172
+ # un appel reseau. Ce qui compte ici est que le cache renvoie le MEME
173
+ # vecteur (verifie juste au-dessus) sans nouvel appel au modele.
174
+ assert time2 <= time1 * 3, (
175
+ f"Un hit de cache ne doit pas etre nettement plus lent: {time2:.3f}s vs {time1:.3f}s"
176
  )
177
 
178
  def test_cache_miss(self, service_with_cache):
 
185
 
186
  # Vérifications
187
  assert embedding is not None
188
+ assert embedding.shape == (3072,)
189
  assert elapsed < 1.0, f"Génération trop lente: {elapsed:.3f}s > 1.0s"
190
 
191
  def test_cache_disabled(self, service):
 
211
 
212
  # Vérifications
213
  assert embedding is not None
214
+ assert embedding.shape == (3072,)
215
 
216
  # Le texte contient des mots français juridiques
217
  assert "camerounais" in sample_french_text.lower()
 
222
 
223
  # Vérifications
224
  assert embedding is not None
225
+ assert embedding.shape == (3072,)
226
 
227
  # Le texte contient des mots anglais juridiques
228
  assert "cameroonian" in sample_english_text.lower()
 
259
 
260
  # Devrait fonctionner normalement
261
  assert embedding is not None
262
+ assert embedding.shape == (3072,)
263
 
264
  def test_batch_with_duplicates(self, service):
265
  """Test batch avec textes dupliqués."""
 
297
 
298
  # Vérifications
299
  assert len(results) == 10
300
+ assert all(emb.shape == (3072,) for emb in results)
301
 
302
  # Pas de corruption de données
303
  for i, emb in enumerate(results):
 
350
  assert "status" in health
351
  assert "model" in health
352
  assert "dimensions" in health
353
+ # "device" appartenait au modele sentence-transformers local ; le service
354
+ # utilise desormais l'API Gemini, qui n'a pas de notion de peripherique.
355
+ assert "model" in health
356
  assert "cache_enabled" in health
357
 
358
  # Vérifications valeurs
359
  assert health["service"] == "EmbeddingService"
360
  assert health["status"] in ["healthy", "degraded", "unhealthy"]
361
+ assert health["model"] == EmbeddingService.EMBEDDING_MODEL
362
+ assert health["dimensions"] == 3072
363
+ # "device" appartenait au modele sentence-transformers charge en local.
364
+ # Le service passe par l'API Gemini : ce qui compte est le fournisseur.
365
+ assert health["provider"] == "Gemini API"
366
  assert isinstance(health["cache_enabled"], bool)
367
 
368
  def test_health_check_with_redis_down(self, service):
 
372
 
373
  # Le service devrait rester "healthy" même sans cache
374
  assert health["status"] == "healthy"
375
+ assert health["cache_backend"] == "disabled"
376
 
377
 
378
  # ==================== TESTS SIMILARITY ====================
 
426
 
427
  def test_similarity_wrong_dimensions(self, service):
428
  """Test que les mauvaises dimensions lèvent une erreur."""
429
+ emb1 = np.random.rand(3072).astype(np.float32)
430
  emb2_wrong = np.random.rand(512).astype(np.float32) # Mauvaise dim
431
 
432
  with pytest.raises(ValueError, match="dimension incorrecte"):
tests/test_services/test_rag_service.py CHANGED
@@ -74,7 +74,8 @@ def mock_search_results():
74
  law_id=1,
75
  reference="LOI-2024-001",
76
  title="Code OHADA",
77
- content="Les dirigeants sont responsables...",
 
78
  relevance_score=0.92,
79
  category_name="Droit commercial",
80
  highlights={"content": "Article 161: Les dirigeants sont responsables..."},
@@ -84,7 +85,8 @@ def mock_search_results():
84
  law_id=2,
85
  reference="LOI-2023-015",
86
  title="Code des sociétés",
87
- content="Obligations des dirigeants...",
 
88
  relevance_score=0.87,
89
  category_name="Droit commercial",
90
  highlights={"content": "Article 5: Obligations des dirigeants..."},
 
74
  law_id=1,
75
  reference="LOI-2024-001",
76
  title="Code OHADA",
77
+ type="loi",
78
+ status="published",
79
  relevance_score=0.92,
80
  category_name="Droit commercial",
81
  highlights={"content": "Article 161: Les dirigeants sont responsables..."},
 
85
  law_id=2,
86
  reference="LOI-2023-015",
87
  title="Code des sociétés",
88
+ type="loi",
89
+ status="published",
90
  relevance_score=0.87,
91
  category_name="Droit commercial",
92
  highlights={"content": "Article 5: Obligations des dirigeants..."},