Claude commited on
Commit
4d7e198
·
unverified ·
1 Parent(s): a43daf6

fix(paths): analyse systémique et correction exhaustive des chemins en production

Browse files

Problème racine
───────────────
Path(__file__) peut être relatif selon l'environnement d'exécution.
Sans .resolve(), la traversée .parent×N ne remonte pas à la racine absolue
du dépôt. Les chemins vers profiles/, prompts/, data/ s'en trouvent cassés.

Corrections
───────────
1. job_runner.py
• _PROJECT_ROOT : ajout de .resolve() pour garantir un chemin absolu
• Chargement des profils JSON : remplace _PROJECT_ROOT / "profiles"
par settings.profiles_dir (source canonique — config.py)
→ Fix du même bogue que profiles.py, mais pour le pipeline IA complet

2. main.py (lifespan)
• Logging de démarrage : affiche profiles_dir, prompts_dir, data_dir
et leur existence (bool)
• Log ERREUR CRITIQUE si profiles_dir manquant → diagnostic immédiat
dans les logs HuggingFace Spaces sans avoir à appeler l'API

3. Dockerfiles (racine + infra/)
• ENV DATA_DIR=/app/data — rend data_dir explicite et absolu,
comme PROFILES_DIR et PROMPTS_DIR (déjà ajoutés précédemment)
→ Élimine toute dépendance au CWD du processus uvicorn

4. tests/test_api_profiles.py (NOUVEAU — 6 tests)
• test_list_profiles_returns_nonempty_list — régression principale :
vérifie que l'endpoint retourne ≥1 profil (échoue si chemin cassé)
• test_list_profiles_returns_all_four_profiles
• test_list_profiles_each_has_required_fields
• test_get_profile_by_id
• test_get_profile_by_id_not_found (404)
• test_list_profiles_empty_when_dir_missing — reproduit exactement
la condition Docker : settings.profiles_dir → chemin inexistant
Ces tests auraient détecté la régression dès le sprint 1.

5. tests/test_profiles.py
• PROFILES_DIR : ajout de .resolve() (cohérence défensive)

Résultat : 509 tests, 3 skipped

https://claude.ai/code/session_018woyEHc8HG2th7V4ewJ4Kg

Dockerfile CHANGED
@@ -53,6 +53,7 @@ RUN mkdir -p /app/data
53
  ENV PYTHONPATH=/app/backend
54
  ENV PROFILES_DIR=/app/profiles
55
  ENV PROMPTS_DIR=/app/prompts
 
56
 
57
  EXPOSE 7860
58
 
 
53
  ENV PYTHONPATH=/app/backend
54
  ENV PROFILES_DIR=/app/profiles
55
  ENV PROMPTS_DIR=/app/prompts
56
+ ENV DATA_DIR=/app/data
57
 
58
  EXPOSE 7860
59
 
backend/app/main.py CHANGED
@@ -26,6 +26,30 @@ logger = logging.getLogger(__name__)
26
  @asynccontextmanager
27
  async def lifespan(application: FastAPI):
28
  """Crée les tables SQLite au démarrage, libère l'engine à l'arrêt."""
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
29
  async with engine.begin() as conn:
30
  await conn.run_sync(Base.metadata.create_all)
31
  logger.info("Tables SQLite initialisées")
 
26
  @asynccontextmanager
27
  async def lifespan(application: FastAPI):
28
  """Crée les tables SQLite au démarrage, libère l'engine à l'arrêt."""
29
+ from app.config import settings
30
+
31
+ # ── Diagnostic des chemins au démarrage ──────────────────────────────────
32
+ # Ces logs apparaissent dans la console HuggingFace et permettent de
33
+ # diagnostiquer instantanément tout problème de chemin en production.
34
+ logger.info(
35
+ "Démarrage Scriptorium AI — chemins configurés",
36
+ extra={
37
+ "profiles_dir": str(settings.profiles_dir),
38
+ "profiles_dir_ok": settings.profiles_dir.is_dir(),
39
+ "prompts_dir": str(settings.prompts_dir),
40
+ "prompts_dir_ok": settings.prompts_dir.is_dir(),
41
+ "data_dir": str(settings.data_dir),
42
+ "data_dir_ok": settings.data_dir.exists(),
43
+ },
44
+ )
45
+ if not settings.profiles_dir.is_dir():
46
+ logger.error(
47
+ "ERREUR CRITIQUE : profiles_dir introuvable au démarrage. "
48
+ "GET /api/v1/profiles retournera []. "
49
+ "Vérifier PROFILES_DIR ou la structure du container.",
50
+ extra={"profiles_dir": str(settings.profiles_dir)},
51
+ )
52
+
53
  async with engine.begin() as conn:
54
  await conn.run_sync(Base.metadata.create_all)
55
  logger.info("Tables SQLite initialisées")
backend/app/services/job_runner.py CHANGED
@@ -39,8 +39,11 @@ from app.services.image.normalizer import create_derivatives, fetch_and_normaliz
39
 
40
  logger = logging.getLogger(__name__)
41
 
42
- # Racine du projet : backend/app/services/job_runner.py 3 parents project root
43
- _PROJECT_ROOT = Path(__file__).parent.parent.parent.parent
 
 
 
44
 
45
 
46
  # ── Point d'entrée public ──────────────────────────────────────────────────
@@ -96,7 +99,9 @@ async def _run_job_impl(job_id: str, db: AsyncSession) -> None:
96
  raise ValueError(f"Corpus introuvable en BDD : {manuscript.corpus_id}")
97
 
98
  # ── 3. Charger le CorpusProfile ──────────────────────────────────────
99
- profile_path = _PROJECT_ROOT / "profiles" / f"{corpus.profile_id}.json"
 
 
100
  if not profile_path.exists():
101
  raise FileNotFoundError(
102
  f"Fichier de profil introuvable : {profile_path}. "
 
39
 
40
  logger = logging.getLogger(__name__)
41
 
42
+ # Racine du projet résolue via .resolve() pour garantir un chemin absolu
43
+ # même si __file__ est relatif au CWD (comportement variable selon l'environnement
44
+ # d'exécution : Docker, HuggingFace Spaces, tests...).
45
+ # job_runner.py est à backend/app/services/job_runner.py → 4 parents → racine.
46
+ _PROJECT_ROOT = Path(__file__).resolve().parent.parent.parent.parent
47
 
48
 
49
  # ── Point d'entrée public ──────────────────────────────────────────────────
 
99
  raise ValueError(f"Corpus introuvable en BDD : {manuscript.corpus_id}")
100
 
101
  # ── 3. Charger le CorpusProfile ──────────────────────────────────────
102
+ # settings.profiles_dir est la source canonique du chemin (config.py).
103
+ # Résolu depuis PROFILES_DIR en Docker, ou _REPO_ROOT/profiles en local.
104
+ profile_path = _config_module.settings.profiles_dir / f"{corpus.profile_id}.json"
105
  if not profile_path.exists():
106
  raise FileNotFoundError(
107
  f"Fichier de profil introuvable : {profile_path}. "
backend/tests/test_api_profiles.py ADDED
@@ -0,0 +1,108 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Tests HTTP de l'endpoint GET /api/v1/profiles (Sprint 1 — régression Docker).
3
+
4
+ Pourquoi ces tests existent :
5
+ GET /api/v1/profiles retournait [] en production (HuggingFace) alors qu'il
6
+ fonctionnait en local. Cause : chemin relatif vers profiles/ non résolu dans
7
+ le container Docker. Ces tests vérifient l'endpoint HTTP complet (pas seulement
8
+ le schéma Pydantic) pour détecter toute régression de chemin en CI.
9
+
10
+ Stratégie :
11
+ - Utilise le dossier profiles/ réel du dépôt via settings.profiles_dir
12
+ (déjà résolu correctement en local par config.py via Path(__file__).resolve())
13
+ - Teste le cas de régression : retourne [] si profiles_dir est manquant
14
+ """
15
+ # 1. stdlib
16
+ from pathlib import Path
17
+
18
+ # 2. third-party
19
+ import pytest
20
+
21
+ # 3. local
22
+ import app.config as config_mod
23
+ from tests.conftest_api import async_client, db_session # noqa: F401
24
+
25
+
26
+ # ── Tests "happy path" — utilise le vrai dossier profiles/ ────────────────────
27
+
28
+ @pytest.mark.asyncio
29
+ async def test_list_profiles_returns_nonempty_list(async_client):
30
+ """L'endpoint retourne une liste non vide — régression Docker principale."""
31
+ resp = await async_client.get("/api/v1/profiles")
32
+ assert resp.status_code == 200
33
+ data = resp.json()
34
+ assert isinstance(data, list)
35
+ assert len(data) > 0, (
36
+ f"profiles_dir={config_mod.settings.profiles_dir} "
37
+ f"(exists={config_mod.settings.profiles_dir.is_dir()}) — "
38
+ "l'endpoint retourne [] alors qu'il devrait retourner les 4 profils"
39
+ )
40
+
41
+
42
+ @pytest.mark.asyncio
43
+ async def test_list_profiles_returns_all_four_profiles(async_client):
44
+ """Les 4 profils du dépôt sont tous retournés."""
45
+ resp = await async_client.get("/api/v1/profiles")
46
+ assert resp.status_code == 200
47
+ ids = {p["profile_id"] for p in resp.json()}
48
+ expected = {
49
+ "medieval-illuminated",
50
+ "medieval-textual",
51
+ "early-modern-print",
52
+ "modern-handwritten",
53
+ }
54
+ assert ids == expected
55
+
56
+
57
+ @pytest.mark.asyncio
58
+ async def test_list_profiles_each_has_required_fields(async_client):
59
+ """Chaque profil expose les champs obligatoires de CorpusProfile."""
60
+ resp = await async_client.get("/api/v1/profiles")
61
+ assert resp.status_code == 200
62
+ for profile in resp.json():
63
+ assert "profile_id" in profile
64
+ assert "label" in profile
65
+ assert "active_layers" in profile
66
+ assert "prompt_templates" in profile
67
+ assert "script_type" in profile
68
+ assert "language_hints" in profile
69
+
70
+
71
+ @pytest.mark.asyncio
72
+ async def test_get_profile_by_id(async_client):
73
+ """GET /api/v1/profiles/{id} retourne le profil correspondant."""
74
+ resp = await async_client.get("/api/v1/profiles/medieval-illuminated")
75
+ assert resp.status_code == 200
76
+ data = resp.json()
77
+ assert data["profile_id"] == "medieval-illuminated"
78
+ assert data["script_type"] == "caroline"
79
+
80
+
81
+ @pytest.mark.asyncio
82
+ async def test_get_profile_by_id_not_found(async_client):
83
+ """GET /api/v1/profiles/{id} retourne 404 pour un identifiant inconnu."""
84
+ resp = await async_client.get("/api/v1/profiles/nonexistent-xyz")
85
+ assert resp.status_code == 404
86
+
87
+
88
+ # ── Test de régression Docker — simule un profiles_dir manquant ───────────────
89
+
90
+ @pytest.mark.asyncio
91
+ async def test_list_profiles_empty_when_dir_missing(async_client):
92
+ """Retourne [] si profiles_dir n'existe pas (régression Docker).
93
+
94
+ Ce test reproduit exactement la condition du bug de production :
95
+ settings.profiles_dir pointe vers un répertoire qui n'existe pas dans
96
+ le container (mauvais chemin résolu). L'endpoint doit retourner [] sans
97
+ erreur 500, mais le test vérifie aussi que le cas ne se produit pas en
98
+ conditions normales (voir test_list_profiles_returns_nonempty_list).
99
+ """
100
+ original = config_mod.settings.profiles_dir
101
+ config_mod.settings.profiles_dir = Path("/nonexistent/path/profiles")
102
+ try:
103
+ resp = await async_client.get("/api/v1/profiles")
104
+ finally:
105
+ config_mod.settings.profiles_dir = original
106
+
107
+ assert resp.status_code == 200
108
+ assert resp.json() == []
backend/tests/test_profiles.py CHANGED
@@ -12,7 +12,7 @@ from pydantic import ValidationError
12
  # 3. local
13
  from app.schemas.corpus_profile import CorpusProfile, LayerType, ScriptType
14
 
15
- PROFILES_DIR = Path(__file__).parent.parent.parent / "profiles"
16
  PROFILE_FILES = [
17
  "medieval-illuminated.json",
18
  "medieval-textual.json",
 
12
  # 3. local
13
  from app.schemas.corpus_profile import CorpusProfile, LayerType, ScriptType
14
 
15
+ PROFILES_DIR = Path(__file__).resolve().parent.parent.parent / "profiles"
16
  PROFILE_FILES = [
17
  "medieval-illuminated.json",
18
  "medieval-textual.json",
infra/Dockerfile CHANGED
@@ -58,6 +58,7 @@ RUN mkdir -p /app/data
58
  ENV PYTHONPATH=/app/backend
59
  ENV PROFILES_DIR=/app/profiles
60
  ENV PROMPTS_DIR=/app/prompts
 
61
 
62
  EXPOSE 7860
63
 
 
58
  ENV PYTHONPATH=/app/backend
59
  ENV PROFILES_DIR=/app/profiles
60
  ENV PROMPTS_DIR=/app/prompts
61
+ ENV DATA_DIR=/app/data
62
 
63
  EXPOSE 7860
64