"""FastAPI entry for PRO materialization (IMP-113 P0–P2).""" from __future__ import annotations from contextlib import asynccontextmanager from typing import NoReturn from fastapi import FastAPI, HTTPException from pro_materialization_service import __version__ from pro_materialization_service.geospatial.asset_version import s2_asset_mapping_version from pro_materialization_service.geospatial.pipeline import materialize from pro_materialization_service.inference_hmac import hmac_secret, install_hmac_middleware, require_inbound_hmac from pro_materialization_service.models import ( MaterializeRequest, MaterializeResult, MaterializeStubIn, MaterializeStubOut, ) @asynccontextmanager async def _lifespan(app: FastAPI): if require_inbound_hmac() and not hmac_secret(): raise RuntimeError( "NUTONIC_INFERENCE_REQUIRE_INBOUND_HMAC is enabled but " "NUTONIC_INFERENCE_HMAC_SECRET / INFERENCE_HMAC_SECRET is empty", ) yield app = FastAPI(title="NU:TONIC PRO materialization service", version=__version__, lifespan=_lifespan) install_hmac_middleware(app) @app.get("/health") def health() -> dict[str, str]: return { "status": "ok", "service": "pro_materialization_service", "version": __version__, } @app.get("/internal/v1/healthz") def internal_healthz() -> dict[str, object]: """Control-plane probe (`plans/2026-04-12-...` §7.2).""" return { "ok": True, "version": __version__, "s2_asset_mapping_version": s2_asset_mapping_version(), } def _materialize_http_errors(e: ValueError) -> NoReturn: code = str(e) if code == "VLM_CONTRACT_INCLUDES_MAPBOX_RGB": raise HTTPException( status_code=422, detail={ "code": code, "message": "vlm_contract_id must not include mapbox_rgb; use nutonic.pro.vlm.v1_512_s2_only.", }, ) from e if code == "MINIMAL_RGB_UNSUPPORTED_USE_TERRAMIND_SPECTRAL": raise HTTPException( status_code=422, detail={ "code": code, "message": "sentinel_fetch_mode MINIMAL_RGB is not supported; use TERRAMIND_SPECTRAL or FULL_STAC.", }, ) from e if code.startswith("MAPBOX_HTTP_") or code == "MAPBOX_TRANSPORT_ERROR": raise HTTPException(status_code=502, detail={"code": code}) from e if code == "MAPBOX_TOKEN_MISSING": raise HTTPException(status_code=422, detail={"code": code}) from e if code == "UNKNOWN_VLM_CONTRACT": raise HTTPException(status_code=422, detail={"code": code}) from e if code == "VLM_CONTRACT_REQUIRES_SENTINEL_STACK": raise HTTPException( status_code=422, detail={ "code": code, "message": "This vlm_contract_id includes sentinel_fc / cloud_mask_thumb; use TERRAMIND_SPECTRAL or FULL_STAC.", }, ) from e if code in ("VLM_ROLE_SENTINEL_FC_WITHOUT_STACK", "VLM_ROLE_CLOUD_MASK_WITHOUT_SCL"): raise HTTPException(status_code=422, detail={"code": code}) from e if code.startswith("UNSUPPORTED_VLM_ROLE_"): raise HTTPException(status_code=422, detail={"code": code}) from e if code in ( "TIM_BRANCH_REQUIRES_RGB_MAPBOX", "TIM_BRANCH_REQUIRES_S2L2A_FULL", "TIM_BRANCH_INVALID", "TIM_RGB_REQUIRES_MAPBOX_VLM_CONTRACT", "PROFILE_REQUIRES_TIM", "PROFILE_REQUIRES_SENTINEL_STACK", "PROFILE_REQUIRES_S2L2A_FULL", ): raise HTTPException(status_code=422, detail={"code": code}) from e if code in ("STAC_NO_ITEMS", "S2L2A_INCOMPLETE"): raise HTTPException(status_code=422, detail={"code": code}) from e if code == "S2_DEPENDENCIES_MISSING": raise HTTPException( status_code=503, detail={ "code": code, "message": ( "Sentinel/STAC imports failed (need pystac-client and rasterio). " "They are package dependencies; check the container/runtime environment." ), }, ) from e if code == "SENTINEL_PIPELINE_NOT_AVAILABLE": raise HTTPException( status_code=422, detail={ "code": code, "message": "Deprecated error code — use a supported sentinel_fetch_mode.", }, ) from e raise HTTPException(status_code=400, detail={"code": code}) from e @app.post("/internal/v1/materialize", response_model=MaterializeResult) def internal_materialize(body: MaterializeRequest) -> MaterializeResult: """ Sentinel-2–backed materialization (``TERRAMIND_SPECTRAL`` / ``FULL_STAC``); Mapbox VLM contracts are rejected. """ try: return materialize(body) except ValueError as e: _materialize_http_errors(e) @app.post("/api/v1/materialize/stub", response_model=MaterializeStubOut) def materialize_stub(body: MaterializeStubIn) -> MaterializeStubOut: """ Back-compat alias: same as ``POST /internal/v1/materialize`` with defaults. Prefer ``/internal/v1/materialize`` for orchestration (`docs/PRO-TAB-VLM-ORCHESTRATION-SPEC.md`). """ req = MaterializeRequest( latitude=body.latitude, longitude=body.longitude, tim_branch="S2L2A_full", sentinel_fetch_mode="TERRAMIND_SPECTRAL", enable_tim=False, vlm_contract_id="nutonic.pro.vlm.v1_512_s2_only", ) try: out = materialize(req) except ValueError as e: _materialize_http_errors(e) roles = [a.role for a in out.vlm_artifacts] return MaterializeStubOut( materialization_id=out.materialization_id, latitude=body.latitude, longitude=body.longitude, tim_input_branch=body.tim_input_branch, cache_key=out.cache_key, vlm_roles=roles, )