File size: 5,796 Bytes
bd09498
e0fd571
bd09498
 
 
 
 
 
 
 
eec7490
bd09498
 
021c7a8
bd09498
eec7490
bd09498
 
 
f842a06
bd09498
 
 
 
 
f8ed5d0
 
 
 
 
 
 
 
 
 
 
 
 
9097545
 
 
 
 
 
 
 
 
 
 
 
 
 
 
bd09498
 
 
4d7e198
 
 
 
 
 
e0fd571
4d7e198
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
bd09498
 
9097545
f8ed5d0
9097545
bd09498
 
 
 
 
 
 
e0fd571
bd09498
 
 
 
 
965e8e0
 
 
bd09498
 
965e8e0
d03b796
bd09498
 
 
 
 
 
 
 
eec7490
bd09498
 
 
ed52286
 
 
f842a06
eec7490
 
 
 
 
27355ae
eec7490
 
021c7a8
 
eec7490
d03b796
 
9097545
eec7490
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
"""
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")


@asynccontextmanager
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")


@app.get("/{full_path:path}", include_in_schema=False, response_model=None)
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")