Spaces:
Build error
Build error
| """ | |
| Application FastAPI — point d'entrée de IIIF Studio. | |
| Tous les endpoints sont sous /api/v1/ (R10). | |
| CORS ouvert pour le développement local (origins=["*"]). | |
| La BDD SQLite est créée automatiquement au démarrage (lifespan). | |
| """ | |
| # 1. stdlib | |
| import logging | |
| from contextlib import asynccontextmanager | |
| from pathlib import Path | |
| # 2. third-party | |
| from fastapi import FastAPI, HTTPException | |
| from fastapi.middleware.cors import CORSMiddleware | |
| from fastapi.responses import FileResponse, RedirectResponse | |
| # 3. local — on importe les modèles pour que Base.metadata les connaisse | |
| import app.models # noqa: F401 (enregistrement des modèles SQLAlchemy) | |
| from app.api.v1 import corpora, export, ingest, jobs, manuscripts, models_api, pages, profiles, search | |
| from app.models.database import Base, engine | |
| logger = logging.getLogger(__name__) | |
| def _migrate_model_configs(connection) -> None: | |
| """Ajoute la colonne supports_vision si absente (migration BDD existantes).""" | |
| from sqlalchemy import inspect, text | |
| inspector = inspect(connection) | |
| columns = {c["name"] for c in inspector.get_columns("model_configs")} | |
| if "supports_vision" not in columns: | |
| connection.execute( | |
| text("ALTER TABLE model_configs ADD COLUMN supports_vision BOOLEAN NOT NULL DEFAULT 1") | |
| ) | |
| logger.info("Migration : colonne supports_vision ajoutée à model_configs") | |
| def _migrate_page_search(connection) -> None: | |
| """Ajoute la colonne normalized_text si absente (recherche SQL LIKE).""" | |
| from sqlalchemy import inspect, text | |
| inspector = inspect(connection) | |
| if "page_search" not in inspector.get_table_names(): | |
| return | |
| columns = {c["name"] for c in inspector.get_columns("page_search")} | |
| if "normalized_text" not in columns: | |
| connection.execute( | |
| text("ALTER TABLE page_search ADD COLUMN normalized_text TEXT NOT NULL DEFAULT ''") | |
| ) | |
| logger.info("Migration : colonne normalized_text ajoutée à page_search") | |
| async def lifespan(application: FastAPI): | |
| """Crée les tables SQLite au démarrage, libère l'engine à l'arrêt.""" | |
| from app.config import settings | |
| # ── Diagnostic des chemins au démarrage ────────────────────────────────── | |
| # Ces logs apparaissent dans la console HuggingFace et permettent de | |
| # diagnostiquer instantanément tout problème de chemin en production. | |
| logger.info( | |
| "Démarrage IIIF Studio — chemins configurés", | |
| extra={ | |
| "profiles_dir": str(settings.profiles_dir), | |
| "profiles_dir_ok": settings.profiles_dir.is_dir(), | |
| "prompts_dir": str(settings.prompts_dir), | |
| "prompts_dir_ok": settings.prompts_dir.is_dir(), | |
| "data_dir": str(settings.data_dir), | |
| "data_dir_ok": settings.data_dir.exists(), | |
| }, | |
| ) | |
| if not settings.profiles_dir.is_dir(): | |
| logger.error( | |
| "ERREUR CRITIQUE : profiles_dir introuvable au démarrage. " | |
| "GET /api/v1/profiles retournera []. " | |
| "Vérifier PROFILES_DIR ou la structure du container.", | |
| extra={"profiles_dir": str(settings.profiles_dir)}, | |
| ) | |
| async with engine.begin() as conn: | |
| await conn.run_sync(Base.metadata.create_all) | |
| # Migrations : create_all ne fait pas d'ALTER TABLE sur les tables existantes | |
| await conn.run_sync(_migrate_model_configs) | |
| await conn.run_sync(_migrate_page_search) | |
| logger.info("Tables SQLite initialisées") | |
| yield | |
| await engine.dispose() | |
| logger.info("Engine SQLite fermé") | |
| app = FastAPI( | |
| title="IIIF Studio", | |
| description="Plateforme générique de génération d'éditions savantes augmentées", | |
| version="0.1.0", | |
| lifespan=lifespan, | |
| ) | |
| # ── CORS (configurable via CORS_ORIGINS ; défaut : ["*"] pour le dev) ───────── | |
| from app.config import settings as _settings | |
| app.add_middleware( | |
| CORSMiddleware, | |
| allow_origins=_settings.cors_origins, | |
| allow_credentials=False, | |
| allow_methods=["*"], | |
| allow_headers=["*"], | |
| ) | |
| # ── Routers (préfixe /api/v1/ — R10) ───────────────────────────────────────── | |
| _V1_PREFIX = "/api/v1" | |
| app.include_router(corpora.router, prefix=_V1_PREFIX) | |
| app.include_router(manuscripts.router, prefix=_V1_PREFIX) | |
| app.include_router(pages.router, prefix=_V1_PREFIX) | |
| app.include_router(export.router, prefix=_V1_PREFIX) | |
| app.include_router(profiles.router, prefix=_V1_PREFIX) | |
| app.include_router(jobs.router, prefix=_V1_PREFIX) | |
| app.include_router(ingest.router, prefix=_V1_PREFIX) | |
| app.include_router(models_api.router, prefix=_V1_PREFIX) | |
| app.include_router(search.router, prefix=_V1_PREFIX) | |
| # ── Serving frontend SPA (production) ou redirect /docs (dev) ──────────────── | |
| _STATIC_DIR = Path("/app/static") | |
| async def serve_frontend(full_path: str) -> FileResponse | RedirectResponse: | |
| """En production sert le frontend React (SPA). En dev redirige vers /docs.""" | |
| if full_path.startswith("api/"): | |
| raise HTTPException(status_code=404, detail=f"Endpoint not found: /{full_path}") | |
| if _STATIC_DIR.is_dir(): | |
| candidate = (_STATIC_DIR / full_path).resolve() | |
| # Empêcher le path traversal : le fichier résolu doit être sous _STATIC_DIR | |
| if candidate.is_file() and candidate.is_relative_to(_STATIC_DIR.resolve()): | |
| return FileResponse(candidate) | |
| index = _STATIC_DIR / "index.html" | |
| if index.exists(): | |
| return FileResponse(index) | |
| return RedirectResponse(url="/docs") | |