File size: 5,960 Bytes
a092bff c6dda10 a092bff aba2c64 a092bff 3f3658a a092bff c6dda10 a092bff c6dda10 a092bff c6dda10 a092bff | 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 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 | """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,
)
|