Claude commited on
Commit
99f23f1
·
unverified ·
1 Parent(s): 568e457

fix: self-audit — 7 issues found and corrected

Browse files

1. Profiles cache invalidated when profiles_dir changes (fixed flaky test)
2. Missing db.commit() after index_page in apply_corrections (data was lost)
3. Client IA cached per provider instance (Google AI + Vertex SA)
4. prompt_loader resolves paths before LRU cache (avoids duplicate entries)
5. search.py import comments follow project conventions
6. Integration test resets _providers_cache + correct mock target
7. All 643 tests pass, 0 failures

https://claude.ai/code/session_012NCh8yLxMXkRmBYQgHCTik

backend/app/api/v1/pages.py CHANGED
@@ -375,6 +375,7 @@ async def apply_corrections(
375
  # ── Mise à jour de l'index de recherche ──────────────────────────────
376
  from app.services.search.indexer import index_page
377
  await index_page(db, new_master)
 
378
 
379
  logger.info(
380
  "Corrections appliquées",
 
375
  # ── Mise à jour de l'index de recherche ──────────────────────────────
376
  from app.services.search.indexer import index_page
377
  await index_page(db, new_master)
378
+ await db.commit()
379
 
380
  logger.info(
381
  "Corrections appliquées",
backend/app/api/v1/profiles.py CHANGED
@@ -27,24 +27,28 @@ logger = logging.getLogger(__name__)
27
  router = APIRouter(prefix="/profiles", tags=["profiles"])
28
 
29
  _profiles_cache: dict[str, CorpusProfile] | None = None
 
30
 
31
 
32
  def _load_all_profiles() -> dict[str, CorpusProfile]:
33
- """Charge tous les profils depuis le disque (cache singleton)."""
34
- global _profiles_cache
35
- if _profiles_cache is not None:
 
 
36
  return _profiles_cache
37
 
38
  result: dict[str, CorpusProfile] = {}
39
- if settings.profiles_dir.is_dir():
40
- for path in sorted(settings.profiles_dir.glob("*.json")):
41
  profile = _load_profile(path)
42
  if profile is not None:
43
  result[profile.profile_id] = profile
44
  else:
45
- logger.warning("profiles_dir introuvable : %s", settings.profiles_dir)
46
 
47
  _profiles_cache = result
 
48
  return _profiles_cache
49
 
50
 
 
27
  router = APIRouter(prefix="/profiles", tags=["profiles"])
28
 
29
  _profiles_cache: dict[str, CorpusProfile] | None = None
30
+ _profiles_cache_dir: Path | None = None
31
 
32
 
33
  def _load_all_profiles() -> dict[str, CorpusProfile]:
34
+ """Charge tous les profils depuis le disque (cache invalidé si le répertoire change)."""
35
+ global _profiles_cache, _profiles_cache_dir
36
+
37
+ current_dir = settings.profiles_dir
38
+ if _profiles_cache is not None and _profiles_cache_dir == current_dir:
39
  return _profiles_cache
40
 
41
  result: dict[str, CorpusProfile] = {}
42
+ if current_dir.is_dir():
43
+ for path in sorted(current_dir.glob("*.json")):
44
  profile = _load_profile(path)
45
  if profile is not None:
46
  result[profile.profile_id] = profile
47
  else:
48
+ logger.warning("profiles_dir introuvable : %s", current_dir)
49
 
50
  _profiles_cache = result
51
+ _profiles_cache_dir = current_dir
52
  return _profiles_cache
53
 
54
 
backend/app/api/v1/search.py CHANGED
@@ -7,12 +7,15 @@ POST /api/v1/search/reindex
7
  Implémentation indexée : les données sont dans la table page_search,
8
  mises à jour à chaque écriture de master.json.
9
  """
 
10
  import logging
11
 
 
12
  from fastapi import APIRouter, Depends, Query
13
  from pydantic import BaseModel
14
  from sqlalchemy.ext.asyncio import AsyncSession
15
 
 
16
  from app import config as _config_module
17
  from app.models.database import get_db
18
  from app.services.search.indexer import reindex_all, search_pages
 
7
  Implémentation indexée : les données sont dans la table page_search,
8
  mises à jour à chaque écriture de master.json.
9
  """
10
+ # 1. stdlib
11
  import logging
12
 
13
+ # 2. third-party
14
  from fastapi import APIRouter, Depends, Query
15
  from pydantic import BaseModel
16
  from sqlalchemy.ext.asyncio import AsyncSession
17
 
18
+ # 3. local
19
  from app import config as _config_module
20
  from app.models.database import get_db
21
  from app.services.search.indexer import reindex_all, search_pages
backend/app/services/ai/prompt_loader.py CHANGED
@@ -39,7 +39,7 @@ def load_and_render_prompt(template_path: str | Path, context: dict[str, str]) -
39
  Raises:
40
  FileNotFoundError: si le fichier template n'existe pas.
41
  """
42
- path = Path(template_path)
43
 
44
  template = _read_template(str(path))
45
 
 
39
  Raises:
40
  FileNotFoundError: si le fichier template n'existe pas.
41
  """
42
+ path = Path(template_path).resolve()
43
 
44
  template = _read_template(str(path))
45
 
backend/app/services/ai/provider_google_ai.py CHANGED
@@ -21,6 +21,9 @@ _ENV_KEY = "GOOGLE_AI_STUDIO_API_KEY"
21
  class GoogleAIProvider(AIProvider):
22
  """Provider Google AI Studio (clé API GOOGLE_AI_STUDIO_API_KEY)."""
23
 
 
 
 
24
  @property
25
  def provider_type(self) -> ProviderType:
26
  return ProviderType.GOOGLE_AI_STUDIO
@@ -28,11 +31,17 @@ class GoogleAIProvider(AIProvider):
28
  def is_configured(self) -> bool:
29
  return bool(os.environ.get(_ENV_KEY))
30
 
 
 
 
 
 
 
31
  def list_models(self) -> list[ModelInfo]:
32
  if not self.is_configured():
33
  raise RuntimeError(f"Variable d'environnement manquante : {_ENV_KEY}")
34
 
35
- client = genai.Client(api_key=os.environ[_ENV_KEY])
36
  result: list[ModelInfo] = []
37
 
38
  for model in client.models.list():
@@ -58,7 +67,7 @@ class GoogleAIProvider(AIProvider):
58
  def generate_content(self, image_bytes: bytes, prompt: str, model_id: str, supports_vision: bool = True) -> str:
59
  if not self.is_configured():
60
  raise RuntimeError(f"Variable d'environnement manquante : {_ENV_KEY}")
61
- client = genai.Client(api_key=os.environ[_ENV_KEY])
62
 
63
  if supports_vision:
64
  image_part = types.Part.from_bytes(data=image_bytes, mime_type="image/jpeg")
 
21
  class GoogleAIProvider(AIProvider):
22
  """Provider Google AI Studio (clé API GOOGLE_AI_STUDIO_API_KEY)."""
23
 
24
+ def __init__(self) -> None:
25
+ self._client: genai.Client | None = None
26
+
27
  @property
28
  def provider_type(self) -> ProviderType:
29
  return ProviderType.GOOGLE_AI_STUDIO
 
31
  def is_configured(self) -> bool:
32
  return bool(os.environ.get(_ENV_KEY))
33
 
34
+ def _get_client(self) -> genai.Client:
35
+ """Retourne un client cached (réutilise la connexion SSL)."""
36
+ if self._client is None:
37
+ self._client = genai.Client(api_key=os.environ[_ENV_KEY])
38
+ return self._client
39
+
40
  def list_models(self) -> list[ModelInfo]:
41
  if not self.is_configured():
42
  raise RuntimeError(f"Variable d'environnement manquante : {_ENV_KEY}")
43
 
44
+ client = self._get_client()
45
  result: list[ModelInfo] = []
46
 
47
  for model in client.models.list():
 
67
  def generate_content(self, image_bytes: bytes, prompt: str, model_id: str, supports_vision: bool = True) -> str:
68
  if not self.is_configured():
69
  raise RuntimeError(f"Variable d'environnement manquante : {_ENV_KEY}")
70
+ client = self._get_client()
71
 
72
  if supports_vision:
73
  image_part = types.Part.from_bytes(data=image_bytes, mime_type="image/jpeg")
backend/app/services/ai/provider_vertex_sa.py CHANGED
@@ -29,6 +29,9 @@ class VertexServiceAccountProvider(AIProvider):
29
  Le project_id est extrait du JSON ; la localisation par défaut est us-central1.
30
  """
31
 
 
 
 
32
  @property
33
  def provider_type(self) -> ProviderType:
34
  return ProviderType.VERTEX_SERVICE_ACCOUNT
@@ -36,7 +39,10 @@ class VertexServiceAccountProvider(AIProvider):
36
  def is_configured(self) -> bool:
37
  return bool(os.environ.get(_ENV_KEY))
38
 
39
- def _build_client(self) -> genai.Client:
 
 
 
40
  sa_json_str = os.environ[_ENV_KEY]
41
  try:
42
  sa_info = json.loads(sa_json_str)
@@ -51,18 +57,19 @@ class VertexServiceAccountProvider(AIProvider):
51
  sa_info,
52
  scopes=_VERTEX_SCOPES,
53
  )
54
- return genai.Client(
55
  vertexai=True,
56
  project=project_id,
57
  location=_DEFAULT_LOCATION,
58
  credentials=credentials,
59
  )
 
60
 
61
  def list_models(self) -> list[ModelInfo]:
62
  if not self.is_configured():
63
  raise RuntimeError(f"Variable d'environnement manquante : {_ENV_KEY}")
64
 
65
- client = self._build_client()
66
  result: list[ModelInfo] = []
67
 
68
  for model in client.models.list():
@@ -88,7 +95,7 @@ class VertexServiceAccountProvider(AIProvider):
88
  def generate_content(self, image_bytes: bytes, prompt: str, model_id: str, supports_vision: bool = True) -> str:
89
  if not self.is_configured():
90
  raise RuntimeError(f"Variable d'environnement manquante : {_ENV_KEY}")
91
- client = self._build_client()
92
 
93
  if supports_vision:
94
  image_part = types.Part.from_bytes(data=image_bytes, mime_type="image/jpeg")
 
29
  Le project_id est extrait du JSON ; la localisation par défaut est us-central1.
30
  """
31
 
32
+ def __init__(self) -> None:
33
+ self._client: genai.Client | None = None
34
+
35
  @property
36
  def provider_type(self) -> ProviderType:
37
  return ProviderType.VERTEX_SERVICE_ACCOUNT
 
39
  def is_configured(self) -> bool:
40
  return bool(os.environ.get(_ENV_KEY))
41
 
42
+ def _get_client(self) -> genai.Client:
43
+ """Retourne un client cached (évite de re-parser le JSON à chaque appel)."""
44
+ if self._client is not None:
45
+ return self._client
46
  sa_json_str = os.environ[_ENV_KEY]
47
  try:
48
  sa_info = json.loads(sa_json_str)
 
57
  sa_info,
58
  scopes=_VERTEX_SCOPES,
59
  )
60
+ self._client = genai.Client(
61
  vertexai=True,
62
  project=project_id,
63
  location=_DEFAULT_LOCATION,
64
  credentials=credentials,
65
  )
66
+ return self._client
67
 
68
  def list_models(self) -> list[ModelInfo]:
69
  if not self.is_configured():
70
  raise RuntimeError(f"Variable d'environnement manquante : {_ENV_KEY}")
71
 
72
+ client = self._get_client()
73
  result: list[ModelInfo] = []
74
 
75
  for model in client.models.list():
 
95
  def generate_content(self, image_bytes: bytes, prompt: str, model_id: str, supports_vision: bool = True) -> str:
96
  if not self.is_configured():
97
  raise RuntimeError(f"Variable d'environnement manquante : {_ENV_KEY}")
98
+ client = self._get_client()
99
 
100
  if supports_vision:
101
  image_part = types.Part.from_bytes(data=image_bytes, mime_type="image/jpeg")
backend/tests/test_integration_pipeline.py CHANGED
@@ -140,12 +140,17 @@ async def test_full_pipeline(pipeline_fixtures, tmp_path):
140
  mock_provider = MagicMock()
141
  mock_provider.generate_content.return_value = _FAKE_AI_RESPONSE
142
 
 
 
 
 
 
143
  try:
144
  with patch(
145
  "app.services.job_runner.fetch_ai_derivative_bytes",
146
  return_value=(_FAKE_JPEG, 1500, 1000),
147
  ), patch(
148
- "app.services.ai.model_registry.get_provider",
149
  return_value=mock_provider,
150
  ):
151
  from app.services.job_runner import _run_job_impl
@@ -153,6 +158,7 @@ async def test_full_pipeline(pipeline_fixtures, tmp_path):
153
  finally:
154
  config_mod.settings.__dict__["data_dir"] = original_data_dir
155
  config_mod.settings.__dict__["profiles_dir"] = original_profiles_dir
 
156
 
157
  # -- Assertions ----------------------------------------------------------
158
  # Job should be done
 
140
  mock_provider = MagicMock()
141
  mock_provider.generate_content.return_value = _FAKE_AI_RESPONSE
142
 
143
+ # Reset le cache global des providers pour éviter les interférences
144
+ import app.services.ai.model_registry as _reg
145
+ old_providers_cache = _reg._providers_cache
146
+ _reg._providers_cache = None
147
+
148
  try:
149
  with patch(
150
  "app.services.job_runner.fetch_ai_derivative_bytes",
151
  return_value=(_FAKE_JPEG, 1500, 1000),
152
  ), patch(
153
+ "app.services.ai.analyzer.get_provider",
154
  return_value=mock_provider,
155
  ):
156
  from app.services.job_runner import _run_job_impl
 
158
  finally:
159
  config_mod.settings.__dict__["data_dir"] = original_data_dir
160
  config_mod.settings.__dict__["profiles_dir"] = original_profiles_dir
161
+ _reg._providers_cache = old_providers_cache
162
 
163
  # -- Assertions ----------------------------------------------------------
164
  # Job should be done