FiberGate / src /guichetoi /api /main.py
AzizMiladi's picture
feat(api): redirect / to /docs so the HF Space App tab shows Swagger
495dc91
Raw
History Blame
15.9 kB
"""
GuichetOI ML β€” FastAPI HTTP service.
Wraps the inference pipeline, recommendation engine, and CMS generator
behind a small REST API. Designed to sit behind a Spring Boot backend
serving an Angular 17 frontend:
Angular 17 ──HTTP──► Spring Boot ──HTTP──► this service
Endpoints
─────────
GET /health liveness + readiness probe (use for Spring Boot's
org.springframework.boot.actuate.health)
GET /metadata doc classes, field labels, expected pieces β€” for the
Angular UI (dropdowns, i18n labels)
POST /analyze multipart upload (one or more files, or a single .zip)
β†’ JSON Verdict
POST /cms JSON Verdict body
β†’ .xlsx bytes. CMS warnings are returned in the
X-CMS-Warnings header (JSON-encoded).
POST /cms/preview JSON Verdict body
β†’ JSON summary of what /cms would fill (for the
"preview" panel above the download button)
Run locally
───────────
pip install -e . # installs guichetoi package
uvicorn guichetoi.api.main:app --host 0.0.0.0 --port 8000 --reload
Production
──────────
uvicorn guichetoi.api.main:app --host 0.0.0.0 --port 8000 --workers 1
WARNING: use exactly ONE worker. The LayoutLMv3 pipeline keeps ~1.2 GB of
weights resident; N workers = NΓ—RAM. Scale horizontally with container
replicas behind a load balancer, not with uvicorn workers.
Environment variables
─────────────────────
GUICHETOI_CORS_ORIGINS Comma-separated origins, or "*" (default).
Set to your Angular dev URL when calling Python
directly (e.g. "http://localhost:4200").
Spring Boot integration
───────────────────────
WebClient client = WebClient.builder()
.baseUrl("http://guichetoi-ml:8000")
.codecs(c -> c.defaultCodecs().maxInMemorySize(200 * 1024 * 1024))
.build();
// /analyze β€” multipart
MultipartBodyBuilder mb = new MultipartBodyBuilder();
mb.part("files", new ByteArrayResource(zipBytes)).filename(zipName);
Verdict v = client.post().uri("/analyze")
.contentType(MediaType.MULTIPART_FORM_DATA)
.bodyValue(mb.build())
.retrieve().bodyToMono(Verdict.class).block();
// /cms β€” verdict in, xlsx bytes out
byte[] xlsx = client.post().uri("/cms")
.contentType(MediaType.APPLICATION_JSON)
.bodyValue(v)
.retrieve().bodyToMono(byte[].class).block();
"""
from __future__ import annotations
import io
import json
import logging
import os
import tempfile
import zipfile
from contextlib import asynccontextmanager
from pathlib import Path
from fastapi import Depends, FastAPI, File, Header, HTTPException, Response, UploadFile, status
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse, RedirectResponse
from guichetoi import cms as cms_gen
from guichetoi import recommendation as reco
# ────────────────────────────────────────────────────────────────────────────
# API-key auth β€” enforced when GUICHETOI_API_KEY env var is set.
# On HuggingFace Spaces (public endpoint) set this secret and pass the same
# key in every request as X-Api-Key: <secret>.
# In local dev leave it unset β€” all requests pass through.
# ────────────────────────────────────────────────────────────────────────────
def _require_api_key(x_api_key: str | None = Header(default=None)) -> None:
expected = os.environ.get("GUICHETOI_API_KEY")
if expected and x_api_key != expected:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Missing or invalid X-Api-Key header.",
)
# ────────────────────────────────────────────────────────────────────────────
# Logging
# ────────────────────────────────────────────────────────────────────────────
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s %(levelname)-7s [api] %(message)s",
datefmt="%H:%M:%S",
)
log = logging.getLogger("guichetoi.api")
# ────────────────────────────────────────────────────────────────────────────
# UI metadata β€” kept in sync with streamlit_demo.py so Angular renders the
# same French labels and icons.
# ────────────────────────────────────────────────────────────────────────────
EXPECTED_CLASSES = ("fiche", "Autorisation", "PlanMasse", "PlanSituation", "Mandat")
SUPPORTED_EXTS = {".pdf", ".png", ".jpg", ".jpeg", ".bmp", ".tif", ".tiff"}
CLASS_LABEL_FR: dict[str, str] = {
"fiche": "Fiche de renseignement",
"Autorisation": "Autorisation d'urbanisme",
"Mandat": "Mandat",
"Certificat": "Certificat d'adressage",
"PlanMasse": "Plan de masse",
"PlanSituation": "Plan de situation",
}
CLASS_ICON: dict[str, str] = {
"fiche": "πŸ“‹",
"Autorisation": "πŸ“œ",
"Mandat": "✍️",
"Certificat": "πŸ“Œ",
"PlanMasse": "πŸ—ΊοΈ",
"PlanSituation": "πŸ“",
}
FIELD_LABEL_FR: dict[str, str] = {
"Reference_Urbanisme": "NΒ° d'urbanisme",
"DLPI": "Date de livraison (DLPI)",
"Disposition_Mandat": "Mandat de reprΓ©sentation",
"Nombre_Logement_Lot_MacroLot": "Nb logements/lots/macrolots",
"Nb_log_pro": "BΓ’timents professionnels",
"Nb_log_res": "BΓ’timents rΓ©sidentiels",
"nb_log_totale": "Nb total de logements",
"cabinet_conseil": "Cabinet conseil",
"Representant_Nom_Complet": "Nom du reprΓ©sentant",
"Representant_Telephone": "TΓ©lΓ©phone",
"Representant_Email": "Email",
"Batiment_Adresse": "Adresse du bΓ’timent",
}
# ────────────────────────────────────────────────────────────────────────────
# Lifespan β€” load the pipeline ONCE at startup (~30 s, ~1.2 GB resident).
# Spring Boot's readiness probe should poll /health and only route traffic
# once pipeline_loaded == true.
# ────────────────────────────────────────────────────────────────────────────
@asynccontextmanager
async def lifespan(app: FastAPI):
log.info("Loading ML pipeline (this takes ~30 s)…")
app.state.engine = reco.RecommendationEngine()
log.info("Pipeline ready β€” accepting requests.")
yield
log.info("Shutting down.")
app = FastAPI(
title="GuichetOI ML",
version="1.0.0",
description=(
"Inference + recommendation + CMS generation for Orange's Guichet "
"Accueil Infrastructures demande de localisation PAR workflow."
),
lifespan=lifespan,
dependencies=[Depends(_require_api_key)], # enforced on every route
)
# CORS β€” server-to-server deployments don't need it, but allow direct Angular
# calls when GUICHETOI_CORS_ORIGINS is set.
_cors = os.environ.get("GUICHETOI_CORS_ORIGINS", "*")
_origins = ["*"] if _cors.strip() == "*" else [o.strip() for o in _cors.split(",") if o.strip()]
app.add_middleware(
CORSMiddleware,
allow_origins=_origins,
allow_methods=["GET", "POST", "OPTIONS"],
allow_headers=["*"],
expose_headers=["Content-Disposition", "X-CMS-Warnings"],
allow_credentials=False,
)
# ────────────────────────────────────────────────────────────────────────────
# Upload materialisation β€” write multipart parts to a temp dir, walk ZIPs
# ────────────────────────────────────────────────────────────────────────────
def _materialise_uploads(files: list[UploadFile]) -> tuple[list[Path], tempfile.TemporaryDirectory]:
"""
Write uploaded files to a temp dir. ZIP archives are extracted in place.
Returns (list_of_paths, owning_tempdir). The caller MUST keep the tempdir
alive until the analysis is done (cleanup via tempdir.cleanup()).
"""
tmp = tempfile.TemporaryDirectory(prefix="guichetoi_api_")
base = Path(tmp.name)
out: list[Path] = []
for f in files:
name = f.filename or "upload"
suffix = Path(name).suffix.lower()
raw = f.file.read()
if suffix == ".zip":
zdir = base / f"zip_{len(out)}"
zdir.mkdir(parents=True, exist_ok=True)
try:
with zipfile.ZipFile(io.BytesIO(raw)) as zf:
zf.extractall(zdir)
except zipfile.BadZipFile as e:
tmp.cleanup()
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Invalid ZIP archive: {name}",
) from e
for p in zdir.rglob("*"):
if not p.is_file():
continue
if p.suffix.lower() not in SUPPORTED_EXTS:
continue
if p.name.startswith("._") or "__MACOSX" in p.parts:
continue
out.append(p)
elif suffix in SUPPORTED_EXTS:
target = base / Path(name).name
target.write_bytes(raw)
out.append(target)
else:
log.warning(f"Skipping unsupported file: {name}")
if not out:
tmp.cleanup()
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=(
"No supported documents found in upload. Accepted: "
f"{sorted(SUPPORTED_EXTS)} or a ZIP containing them."
),
)
return out, tmp
def _require_engine(app: FastAPI):
engine = getattr(app.state, "engine", None)
if engine is None:
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail="Pipeline still loading β€” retry in a few seconds.",
)
return engine
# ────────────────────────────────────────────────────────────────────────────
# Routes
# ────────────────────────────────────────────────────────────────────────────
@app.get("/", include_in_schema=False)
def root() -> RedirectResponse:
return RedirectResponse(url="/docs")
@app.get("/health", tags=["meta"])
def health() -> dict:
"""Liveness + readiness. Spring Boot polls this before routing traffic."""
loaded = getattr(app.state, "engine", None) is not None
return {
"status": "ok" if loaded else "starting",
"pipeline_loaded": loaded,
}
@app.get("/metadata", tags=["meta"])
def metadata() -> dict:
"""Static metadata for the Angular UI (dropdowns, i18n labels, icons)."""
return {
"doc_classes": [
{"id": k, "label": CLASS_LABEL_FR[k], "icon": CLASS_ICON.get(k, "πŸ“„")}
for k in CLASS_LABEL_FR
],
"fields": [
{"id": k, "label": FIELD_LABEL_FR[k]} for k in FIELD_LABEL_FR
],
"expected_classes": list(EXPECTED_CLASSES),
"supported_extensions": sorted(SUPPORTED_EXTS),
}
@app.post("/analyze", tags=["inference"])
def analyze(files: list[UploadFile] = File(...)) -> JSONResponse:
"""
Run the full ML pipeline on the uploaded files.
Accepts multipart/form-data with one or more `files` parts. Each part
may be a document (PDF/PNG/JPG/TIFF) or a ZIP archive containing
documents β€” sub-folders inside ZIPs are walked recursively.
Returns the Verdict as JSON (same shape as Verdict.to_dict()).
"""
engine = _require_engine(app)
paths, tmp = _materialise_uploads(files)
try:
log.info(f"/analyze Β· {len(paths)} document(s)")
verdict = engine.evaluate_files(paths)
return JSONResponse(content=verdict.to_dict())
finally:
tmp.cleanup()
@app.post("/cms", tags=["cms"])
def cms(verdict: dict) -> Response:
"""
Generate the pre-filled CMS xlsx from a verdict JSON.
Body: the verdict object returned by /analyze (sent as application/json).
Response: the xlsx bytes (application/vnd.openxmlformats-...spreadsheet).
Header `X-CMS-Warnings` carries a JSON object so the Angular UI can
render the "Γ  complΓ©ter manuellement" section without a second call:
X-CMS-Warnings: {"project_type":"PIM",
"missing_extractions":[…],
"manual_lookup":[…]}
"""
if not isinstance(verdict, dict):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Body must be the verdict JSON object.",
)
with tempfile.NamedTemporaryFile(suffix=".xlsx", delete=False) as f:
out_path = Path(f.name)
try:
result = cms_gen.fill_cms(verdict, out_path)
data = out_path.read_bytes()
except FileNotFoundError as e:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"CMS template missing: {e}",
) from e
finally:
out_path.unlink(missing_ok=True)
warnings = {
"project_type": result.get("project_type", ""),
"missing_extractions": result.get("missing_extractions", []),
"manual_lookup": result.get("manual_lookup", []),
}
return Response(
content=data,
media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
headers={
"Content-Disposition": 'attachment; filename="GuichetOI_CMS_prerempli.xlsx"',
"X-CMS-Warnings": json.dumps(warnings, ensure_ascii=False),
},
)
@app.post("/cms/preview", tags=["cms"])
def cms_preview(verdict: dict) -> dict:
"""
Return the human-readable preview of what /cms will write β€” same
summary the Streamlit demo shows above the download button.
Lets Angular render a "what's about to be filled" panel without
actually generating the xlsx.
"""
if not isinstance(verdict, dict):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Body must be the verdict JSON object.",
)
return cms_gen.summarise_cms_fields(verdict)