Spaces:
Build error
fix(paths): analyse systémique et correction exhaustive des chemins en production
Browse filesProblè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 +1 -0
- backend/app/main.py +24 -0
- backend/app/services/job_runner.py +8 -3
- backend/tests/test_api_profiles.py +108 -0
- backend/tests/test_profiles.py +1 -1
- infra/Dockerfile +1 -0
|
@@ -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 |
|
|
@@ -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")
|
|
@@ -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
|
| 43 |
-
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
| 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}. "
|
|
@@ -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() == []
|
|
@@ -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",
|
|
@@ -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 |
|