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,
    )