Spaces:
Running
Running
| """ | |
| 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. | |
| # ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| 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 | |
| # ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def root() -> RedirectResponse: | |
| return RedirectResponse(url="/docs") | |
| 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, | |
| } | |
| 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), | |
| } | |
| 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() | |
| 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), | |
| }, | |
| ) | |
| 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) | |