""" 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: . # 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)