fix: cold-start, keepalives, vLLM readiness probe, fast Ollama fallback timeout
#2
by msradam - opened
- app/fsm.py +334 -51
- app/llm.py +16 -7
- app/mellea_validator.py +108 -12
- web/main.py +35 -3
app/fsm.py
CHANGED
|
@@ -13,18 +13,20 @@ import time
|
|
| 13 |
from typing import Any
|
| 14 |
|
| 15 |
import geopandas as gpd
|
| 16 |
-
from burr.core import ApplicationBuilder, State, action
|
|
|
|
|
|
|
| 17 |
from shapely.geometry import Point
|
| 18 |
|
| 19 |
from app import emissions
|
| 20 |
-
from app.context import floodnet, microtopo, noaa_tides, nws_alerts, nws_obs, nyc311
|
| 21 |
from app.energy import estimate as energy_estimate
|
| 22 |
from app.flood_layers import dep_stormwater, ida_hwm, prithvi_water, sandy_inundation
|
| 23 |
from app.geocode import geocode_one
|
| 24 |
from app.live import floodnet_forecast as fn_forecast
|
| 25 |
from app.live import ttm_forecast
|
| 26 |
from app.rag import retrieve as rag_retrieve
|
| 27 |
-
from app.reconcile import reconcile as run_reconcile
|
| 28 |
from app.registers import doe_schools as r_schools
|
| 29 |
from app.registers import doh_hospitals as r_hospitals
|
| 30 |
from app.registers import mta_entrances as r_mta
|
|
@@ -119,14 +121,24 @@ def _current_planner_intent() -> str | None:
|
|
| 119 |
return getattr(_FSM_LOCAL, "planner_intent", None)
|
| 120 |
|
| 121 |
|
| 122 |
-
|
| 123 |
-
|
| 124 |
-
|
| 125 |
-
|
| 126 |
-
|
| 127 |
-
|
| 128 |
-
|
| 129 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 130 |
|
| 131 |
def _step(state: State, name: str) -> dict[str, Any]:
|
| 132 |
"""Append a step record to the trace; returns the dict so the action
|
|
@@ -137,6 +149,229 @@ def _step(state: State, name: str) -> dict[str, Any]:
|
|
| 137 |
return rec, trace
|
| 138 |
|
| 139 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 140 |
@action(reads=["query"], writes=["geocode", "lat", "lon", "trace"])
|
| 141 |
def step_geocode(state: State) -> State:
|
| 142 |
rec, trace = _step(state, "geocode")
|
|
@@ -601,6 +836,28 @@ def step_floodnet_forecast(state: State) -> State:
|
|
| 601 |
rec["elapsed_s"] = round(time.time() - rec["started_at"], 2)
|
| 602 |
|
| 603 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 604 |
@action(reads=["lat", "lon"], writes=["mta_entrances", "trace"])
|
| 605 |
def step_mta_entrances(state: State) -> State:
|
| 606 |
rec, trace = _step(state, "mta_entrance_exposure")
|
|
@@ -961,11 +1218,12 @@ def _label_counts(gliner_out: dict[str, dict]) -> dict[str, int]:
|
|
| 961 |
"ida_hwm", "prithvi_water", "prithvi_live", "terramind",
|
| 962 |
"terramind_lulc", "terramind_buildings",
|
| 963 |
"noaa_tides", "nws_alerts", "nws_obs", "ttm_forecast",
|
| 964 |
-
"ttm_311_forecast", "floodnet_forecast", "
|
|
|
|
| 965 |
"mta_entrances",
|
| 966 |
"nycha_developments", "doe_schools", "doh_hospitals",
|
| 967 |
"rag", "gliner"],
|
| 968 |
-
writes=["paragraph", "audit", "mellea", "trace"])
|
| 969 |
def step_reconcile(state: State) -> State:
|
| 970 |
is_strict = _current_strict_mode()
|
| 971 |
rec, trace = _step(state, "mellea_reconcile_address" if is_strict else "reconcile_granite41")
|
|
@@ -986,6 +1244,7 @@ def step_reconcile(state: State) -> State:
|
|
| 986 |
"ttm_forecast": state.get("ttm_forecast"),
|
| 987 |
"ttm_311_forecast": state.get("ttm_311_forecast"),
|
| 988 |
"floodnet_forecast": state.get("floodnet_forecast"),
|
|
|
|
| 989 |
"ttm_battery_surge": state.get("ttm_battery_surge"),
|
| 990 |
"rag": state.get("rag"),
|
| 991 |
"gliner": state.get("gliner"),
|
|
@@ -1010,8 +1269,19 @@ def step_reconcile(state: State) -> State:
|
|
| 1010 |
else:
|
| 1011 |
token_cb = _current_token_callback()
|
| 1012 |
attempt_cb = _current_mellea_attempt_callback()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1013 |
framed_prompt = augment_system_prompt(
|
| 1014 |
-
EXTRA_SYSTEM_PROMPT,
|
| 1015 |
query=_current_user_query() or state.get("query") or "",
|
| 1016 |
intent=_current_planner_intent() or "single_address",
|
| 1017 |
)
|
|
@@ -1046,6 +1316,13 @@ def step_reconcile(state: State) -> State:
|
|
| 1046 |
"model": mres["model"],
|
| 1047 |
"loop_budget": mres["loop_budget"],
|
| 1048 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1049 |
rec["result"] = {
|
| 1050 |
"rerolls": (mellea_meta or {}).get("rerolls"),
|
| 1051 |
"passed": (f"{len((mellea_meta or {}).get('requirements_passed') or [])}/"
|
|
@@ -1059,14 +1336,19 @@ def step_reconcile(state: State) -> State:
|
|
| 1059 |
"paragraph_chars": len(para),
|
| 1060 |
"dropped_sentences": len(audit["dropped"]),
|
| 1061 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1062 |
rec["ok"] = True
|
| 1063 |
return state.update(paragraph=para, audit=audit,
|
| 1064 |
-
mellea=mellea_meta, trace=trace)
|
| 1065 |
except Exception as e:
|
| 1066 |
rec["ok"] = False; rec["err"] = str(e)
|
| 1067 |
log.exception("reconcile failed")
|
| 1068 |
return state.update(paragraph="", audit={"raw": "", "dropped": []},
|
| 1069 |
-
mellea=None, trace=trace)
|
| 1070 |
finally:
|
| 1071 |
rec["elapsed_s"] = round(time.time() - rec["started_at"], 2)
|
| 1072 |
|
|
@@ -1117,37 +1399,45 @@ _NYCHA_REGISTERS_ENABLED = _os.environ.get(
|
|
| 1117 |
).lower() in ("1", "true", "yes")
|
| 1118 |
|
| 1119 |
|
| 1120 |
-
|
| 1121 |
-
"""Linear, single-action-per-step Burr application.
|
| 1122 |
|
| 1123 |
-
|
| 1124 |
-
|
| 1125 |
-
|
| 1126 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1127 |
"""
|
|
|
|
|
|
|
| 1128 |
builder = (
|
| 1129 |
ApplicationBuilder()
|
| 1130 |
.with_state(query=query, trace=[])
|
| 1131 |
.with_entrypoint("geocode")
|
|
|
|
|
|
|
| 1132 |
)
|
| 1133 |
|
| 1134 |
actions: dict[str, Any] = {
|
| 1135 |
"geocode": step_geocode,
|
| 1136 |
-
"
|
| 1137 |
-
"dep": step_dep,
|
| 1138 |
-
"floodnet": step_floodnet,
|
| 1139 |
-
"nyc311": step_311,
|
| 1140 |
"noaa_tides": step_noaa_tides,
|
| 1141 |
"nws_alerts": step_nws_alerts,
|
| 1142 |
"nws_obs": step_nws_obs,
|
| 1143 |
"ttm_forecast": step_ttm_forecast,
|
| 1144 |
"ttm_311_forecast": step_ttm_311_forecast,
|
| 1145 |
"floodnet_forecast": step_floodnet_forecast,
|
|
|
|
| 1146 |
"ttm_battery_surge": step_ttm_battery_surge,
|
| 1147 |
-
"microtopo": step_microtopo,
|
| 1148 |
-
"ida_hwm": step_ida_hwm,
|
| 1149 |
"mta_entrances": step_mta_entrances,
|
| 1150 |
-
"prithvi": step_prithvi, # baked GeoJSON polygons for Ida; cheap
|
| 1151 |
}
|
| 1152 |
if _HEAVY_SPECIALISTS_ENABLED and _NYCHA_REGISTERS_ENABLED:
|
| 1153 |
actions["nycha"] = step_nycha
|
|
@@ -1156,10 +1446,6 @@ def build_app(query: str):
|
|
| 1156 |
if _HEAVY_SPECIALISTS_ENABLED:
|
| 1157 |
actions["prithvi_live"] = step_prithvi_live
|
| 1158 |
actions["terramind"] = step_terramind
|
| 1159 |
-
# New TerraMind-NYC LoRA family — one chip fetch feeds two
|
| 1160 |
-
# specialists. Keep eo_chip directly before the two consumers
|
| 1161 |
-
# so the chip stays warm in memory and isn't garbage-collected
|
| 1162 |
-
# by anything in between.
|
| 1163 |
actions["eo_chip"] = step_eo_chip
|
| 1164 |
actions["terramind_lulc"] = step_terramind_lulc
|
| 1165 |
actions["terramind_buildings"] = step_terramind_buildings
|
|
@@ -1167,9 +1453,18 @@ def build_app(query: str):
|
|
| 1167 |
actions["gliner"] = step_gliner
|
| 1168 |
actions["reconcile"] = step_reconcile
|
| 1169 |
|
| 1170 |
-
#
|
|
|
|
|
|
|
| 1171 |
keys = list(actions.keys())
|
| 1172 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1173 |
|
| 1174 |
return (
|
| 1175 |
builder.with_actions(**actions).with_transitions(*transitions).build()
|
|
@@ -1252,16 +1547,7 @@ def iter_steps(query: str):
|
|
| 1252 |
import queue
|
| 1253 |
|
| 1254 |
q: queue.Queue[tuple[str, Any] | None] = queue.Queue()
|
| 1255 |
-
|
| 1256 |
-
|
| 1257 |
-
def _push_step(rec: dict) -> None:
|
| 1258 |
-
key = (rec.get("step", ""), rec.get("started_at", 0.0))
|
| 1259 |
-
if key in seen_keys:
|
| 1260 |
-
return
|
| 1261 |
-
seen_keys.add(key)
|
| 1262 |
-
q.put(("step", rec))
|
| 1263 |
-
|
| 1264 |
-
app = build_app(query)
|
| 1265 |
final_state_holder: dict[str, Any] = {}
|
| 1266 |
|
| 1267 |
# Threadlocals are per-thread; the request thread (single_address.run
|
|
@@ -1284,12 +1570,8 @@ def iter_steps(query: str):
|
|
| 1284 |
try:
|
| 1285 |
for _action_obj, _result, state in app.iterate(halt_after=["reconcile"]):
|
| 1286 |
final_state_holder["state"] = state
|
| 1287 |
-
#
|
| 1288 |
-
#
|
| 1289 |
-
# moment Burr returns from that action.
|
| 1290 |
-
trace = state.get("trace") or []
|
| 1291 |
-
if trace:
|
| 1292 |
-
_push_step(trace[-1])
|
| 1293 |
except Exception as e:
|
| 1294 |
log.exception("iterate raised")
|
| 1295 |
q.put(("error", {"err": f"{type(e).__name__}: {e}"}))
|
|
@@ -1358,6 +1640,7 @@ def iter_steps(query: str):
|
|
| 1358 |
"paragraph": state.get("paragraph"),
|
| 1359 |
"audit": state.get("audit"),
|
| 1360 |
"mellea": state.get("mellea"),
|
|
|
|
| 1361 |
"energy": _summarize_energy(trace),
|
| 1362 |
"emissions": _summarize_emissions(),
|
| 1363 |
}
|
|
|
|
| 13 |
from typing import Any
|
| 14 |
|
| 15 |
import geopandas as gpd
|
| 16 |
+
from burr.core import ApplicationBuilder, State, action, expr
|
| 17 |
+
from burr.lifecycle import PostRunStepHook
|
| 18 |
+
from burr.tracking import LocalTrackingClient
|
| 19 |
from shapely.geometry import Point
|
| 20 |
|
| 21 |
from app import emissions
|
| 22 |
+
from app.context import floodnet, microtopo, noaa_tides, npcc4_slr, nws_alerts, nws_obs, nyc311
|
| 23 |
from app.energy import estimate as energy_estimate
|
| 24 |
from app.flood_layers import dep_stormwater, ida_hwm, prithvi_water, sandy_inundation
|
| 25 |
from app.geocode import geocode_one
|
| 26 |
from app.live import floodnet_forecast as fn_forecast
|
| 27 |
from app.live import ttm_forecast
|
| 28 |
from app.rag import retrieve as rag_retrieve
|
| 29 |
+
from app.reconcile import citations_from_docs, reconcile as run_reconcile
|
| 30 |
from app.registers import doe_schools as r_schools
|
| 31 |
from app.registers import doh_hospitals as r_hospitals
|
| 32 |
from app.registers import mta_entrances as r_mta
|
|
|
|
| 121 |
return getattr(_FSM_LOCAL, "planner_intent", None)
|
| 122 |
|
| 123 |
|
| 124 |
+
class StepEventHook(PostRunStepHook):
|
| 125 |
+
"""Burr lifecycle hook — fires after each action and pushes a
|
| 126 |
+
``("step", rec)`` tuple onto a caller-supplied queue.
|
| 127 |
+
|
| 128 |
+
Replaces the manual ``seen_keys`` deduplication loop in ``iter_steps``.
|
| 129 |
+
Pass ``queue=None`` to construct a no-op hook (non-streaming paths)."""
|
| 130 |
+
|
| 131 |
+
def __init__(self, queue=None):
|
| 132 |
+
self._q = queue
|
| 133 |
+
|
| 134 |
+
def post_run_step(self, *, state: State, action, result, exception, **_kw):
|
| 135 |
+
if self._q is None:
|
| 136 |
+
return
|
| 137 |
+
trace = state.get("trace") or []
|
| 138 |
+
if not trace:
|
| 139 |
+
return
|
| 140 |
+
self._q.put(("step", trace[-1]))
|
| 141 |
+
|
| 142 |
|
| 143 |
def _step(state: State, name: str) -> dict[str, Any]:
|
| 144 |
"""Append a step record to the trace; returns the dict so the action
|
|
|
|
| 149 |
return rec, trace
|
| 150 |
|
| 151 |
|
| 152 |
+
def _make_rec(name: str) -> dict[str, Any]:
|
| 153 |
+
"""Trace record for use outside of Burr state (parallel workers)."""
|
| 154 |
+
return {"step": name, "started_at": time.time(), "ok": None}
|
| 155 |
+
|
| 156 |
+
|
| 157 |
+
# ---------------------------------------------------------------------------
|
| 158 |
+
# Cornerstone parallel helpers — plain functions, no State dependency.
|
| 159 |
+
# Each returns (state_key, value, trace_rec). step_cornerstone fans them
|
| 160 |
+
# out via ThreadPoolExecutor and merges results into Burr state in one shot.
|
| 161 |
+
# Using a single Burr action with internal threads avoids the previous hang
|
| 162 |
+
# (which was caused by Burr-internal post-action cleanup racing with a
|
| 163 |
+
# custom executor passed to ApplicationBuilder).
|
| 164 |
+
# ---------------------------------------------------------------------------
|
| 165 |
+
|
| 166 |
+
def _run_sandy(lat, lon) -> tuple[str, Any, dict]:
|
| 167 |
+
rec = _make_rec("sandy_inundation")
|
| 168 |
+
try:
|
| 169 |
+
if not _in_nyc(lat, lon):
|
| 170 |
+
rec["ok"] = False; rec["err"] = "out of NYC scope"
|
| 171 |
+
return "sandy", None, rec
|
| 172 |
+
pt_geom = (gpd.GeoDataFrame(geometry=[Point(lon, lat)], crs="EPSG:4326")
|
| 173 |
+
.to_crs("EPSG:2263").iloc[0].geometry)
|
| 174 |
+
flag = sandy_inundation.inside_raster(pt_geom)
|
| 175 |
+
rec["ok"] = True; rec["result"] = {"inside": flag}
|
| 176 |
+
return "sandy", flag, rec
|
| 177 |
+
except Exception as e:
|
| 178 |
+
rec["ok"] = False; rec["err"] = str(e)
|
| 179 |
+
log.exception("sandy failed")
|
| 180 |
+
return "sandy", None, rec
|
| 181 |
+
finally:
|
| 182 |
+
rec["elapsed_s"] = round(time.time() - rec["started_at"], 2)
|
| 183 |
+
|
| 184 |
+
|
| 185 |
+
def _run_dep(lat, lon) -> tuple[str, Any, dict]:
|
| 186 |
+
rec = _make_rec("dep_stormwater")
|
| 187 |
+
try:
|
| 188 |
+
if not _in_nyc(lat, lon):
|
| 189 |
+
rec["ok"] = False; rec["err"] = "out of NYC scope"
|
| 190 |
+
return "dep", None, rec
|
| 191 |
+
pt_geom = (gpd.GeoDataFrame(geometry=[Point(lon, lat)], crs="EPSG:4326")
|
| 192 |
+
.to_crs("EPSG:2263").iloc[0].geometry)
|
| 193 |
+
out: dict[str, Any] = {}
|
| 194 |
+
for scen in ["dep_extreme_2080", "dep_moderate_2050", "dep_moderate_current"]:
|
| 195 |
+
cls = dep_stormwater.join_raster(pt_geom, scen)
|
| 196 |
+
out[scen] = {
|
| 197 |
+
"depth_class": cls,
|
| 198 |
+
"depth_label": dep_stormwater.DEPTH_CLASS.get(cls, "outside"),
|
| 199 |
+
"citation": f"NYC DEP Stormwater Flood Map — {dep_stormwater.label(scen)}",
|
| 200 |
+
}
|
| 201 |
+
rec["ok"] = True; rec["result"] = {k: v["depth_label"] for k, v in out.items()}
|
| 202 |
+
return "dep", out, rec
|
| 203 |
+
except Exception as e:
|
| 204 |
+
rec["ok"] = False; rec["err"] = str(e)
|
| 205 |
+
log.exception("dep failed")
|
| 206 |
+
return "dep", None, rec
|
| 207 |
+
finally:
|
| 208 |
+
rec["elapsed_s"] = round(time.time() - rec["started_at"], 2)
|
| 209 |
+
|
| 210 |
+
|
| 211 |
+
def _run_floodnet(lat, lon) -> tuple[str, Any, dict]:
|
| 212 |
+
rec = _make_rec("floodnet")
|
| 213 |
+
try:
|
| 214 |
+
if not _in_nyc(lat, lon):
|
| 215 |
+
rec["ok"] = False; rec["err"] = "out of NYC scope"
|
| 216 |
+
return "floodnet", None, rec
|
| 217 |
+
s = floodnet.summary_for_point(lat, lon, radius_m=600)
|
| 218 |
+
s["radius_m"] = 600
|
| 219 |
+
rec["ok"] = True
|
| 220 |
+
rec["result"] = {"n_sensors": s["n_sensors"], "n_events_3y": s["n_flood_events_3y"]}
|
| 221 |
+
return "floodnet", s, rec
|
| 222 |
+
except Exception as e:
|
| 223 |
+
rec["ok"] = False; rec["err"] = str(e)
|
| 224 |
+
log.exception("floodnet failed")
|
| 225 |
+
return "floodnet", None, rec
|
| 226 |
+
finally:
|
| 227 |
+
rec["elapsed_s"] = round(time.time() - rec["started_at"], 2)
|
| 228 |
+
|
| 229 |
+
|
| 230 |
+
def _run_311(lat, lon) -> tuple[str, Any, dict]:
|
| 231 |
+
rec = _make_rec("nyc311")
|
| 232 |
+
try:
|
| 233 |
+
if not _in_nyc(lat, lon):
|
| 234 |
+
rec["ok"] = False; rec["err"] = "out of NYC scope"
|
| 235 |
+
return "nyc311", None, rec
|
| 236 |
+
s = nyc311.summary_for_point(lat, lon, radius_m=200, years=5)
|
| 237 |
+
rec["ok"] = True; rec["result"] = {"n": s["n"]}
|
| 238 |
+
return "nyc311", s, rec
|
| 239 |
+
except Exception as e:
|
| 240 |
+
rec["ok"] = False; rec["err"] = str(e)
|
| 241 |
+
log.exception("311 failed")
|
| 242 |
+
return "nyc311", None, rec
|
| 243 |
+
finally:
|
| 244 |
+
rec["elapsed_s"] = round(time.time() - rec["started_at"], 2)
|
| 245 |
+
|
| 246 |
+
|
| 247 |
+
def _run_ida_hwm(lat, lon) -> tuple[str, Any, dict]:
|
| 248 |
+
rec = _make_rec("ida_hwm_2021")
|
| 249 |
+
try:
|
| 250 |
+
s = ida_hwm.summary_for_point(lat, lon, radius_m=800)
|
| 251 |
+
if s is None:
|
| 252 |
+
rec["ok"] = False; rec["err"] = "HWM data missing"
|
| 253 |
+
return "ida_hwm", None, rec
|
| 254 |
+
rec["ok"] = True
|
| 255 |
+
rec["result"] = {
|
| 256 |
+
"n_within_800m": s.n_within_radius,
|
| 257 |
+
"max_height_above_gnd_ft": s.max_height_above_gnd_ft,
|
| 258 |
+
"nearest_m": s.nearest_dist_m,
|
| 259 |
+
}
|
| 260 |
+
return "ida_hwm", vars(s), rec
|
| 261 |
+
except Exception as e:
|
| 262 |
+
rec["ok"] = False; rec["err"] = str(e)
|
| 263 |
+
log.exception("ida_hwm failed")
|
| 264 |
+
return "ida_hwm", None, rec
|
| 265 |
+
finally:
|
| 266 |
+
rec["elapsed_s"] = round(time.time() - rec["started_at"], 2)
|
| 267 |
+
|
| 268 |
+
|
| 269 |
+
def _run_prithvi(lat, lon) -> tuple[str, Any, dict]:
|
| 270 |
+
rec = _make_rec("prithvi_eo_v2")
|
| 271 |
+
try:
|
| 272 |
+
if not _in_nyc(lat, lon):
|
| 273 |
+
rec["ok"] = False; rec["err"] = "out of NYC scope"
|
| 274 |
+
return "prithvi_water", None, rec
|
| 275 |
+
s = prithvi_water.summary_for_point(lat, lon)
|
| 276 |
+
if s is None:
|
| 277 |
+
rec["ok"] = False; rec["err"] = "Prithvi mask missing"
|
| 278 |
+
return "prithvi_water", None, rec
|
| 279 |
+
rec["ok"] = True
|
| 280 |
+
rec["result"] = {
|
| 281 |
+
"inside_water_polygon": s.inside_water_polygon,
|
| 282 |
+
"nearest_distance_m": s.nearest_distance_m,
|
| 283 |
+
"n_polygons_within_500m": s.n_polygons_within_500m,
|
| 284 |
+
}
|
| 285 |
+
return "prithvi_water", vars(s), rec
|
| 286 |
+
except Exception as e:
|
| 287 |
+
rec["ok"] = False; rec["err"] = str(e)
|
| 288 |
+
log.exception("prithvi failed")
|
| 289 |
+
return "prithvi_water", None, rec
|
| 290 |
+
finally:
|
| 291 |
+
rec["elapsed_s"] = round(time.time() - rec["started_at"], 2)
|
| 292 |
+
|
| 293 |
+
|
| 294 |
+
def _run_microtopo(lat, lon) -> tuple[str, Any, dict]:
|
| 295 |
+
rec = _make_rec("microtopo_lidar")
|
| 296 |
+
try:
|
| 297 |
+
if not _in_nyc(lat, lon):
|
| 298 |
+
rec["ok"] = False; rec["err"] = "out of NYC scope"
|
| 299 |
+
return "microtopo", None, rec
|
| 300 |
+
m = microtopo.microtopo_at(lat, lon)
|
| 301 |
+
if m is None:
|
| 302 |
+
rec["ok"] = False; rec["err"] = "DEM fetch failed"
|
| 303 |
+
return "microtopo", None, rec
|
| 304 |
+
rec["ok"] = True
|
| 305 |
+
rec["result"] = {
|
| 306 |
+
"elev_m": m.point_elev_m,
|
| 307 |
+
"pct_200m": m.rel_elev_pct_200m,
|
| 308 |
+
"relief_m": m.basin_relief_m,
|
| 309 |
+
}
|
| 310 |
+
return "microtopo", vars(m), rec
|
| 311 |
+
except Exception as e:
|
| 312 |
+
rec["ok"] = False; rec["err"] = str(e)
|
| 313 |
+
log.exception("microtopo failed")
|
| 314 |
+
return "microtopo", None, rec
|
| 315 |
+
finally:
|
| 316 |
+
rec["elapsed_s"] = round(time.time() - rec["started_at"], 2)
|
| 317 |
+
|
| 318 |
+
|
| 319 |
+
_CORNERSTONE_WORKERS = [
|
| 320 |
+
_run_sandy, _run_dep, _run_floodnet, _run_311,
|
| 321 |
+
_run_ida_hwm, _run_prithvi, _run_microtopo,
|
| 322 |
+
]
|
| 323 |
+
|
| 324 |
+
|
| 325 |
+
@action(reads=["lat", "lon"],
|
| 326 |
+
writes=["sandy", "dep", "floodnet", "nyc311",
|
| 327 |
+
"ida_hwm", "prithvi_water", "microtopo", "trace"])
|
| 328 |
+
def step_cornerstone(state: State) -> State:
|
| 329 |
+
"""Run all 7 geospatial Cornerstone specialists in parallel.
|
| 330 |
+
|
| 331 |
+
Uses ThreadPoolExecutor internally (not Burr's parallel executor) to
|
| 332 |
+
avoid the post-action cleanup hang that occurred with the previous
|
| 333 |
+
fan-out approach. Workers are pure functions — no shared Burr state."""
|
| 334 |
+
trace = list(state.get("trace", []))
|
| 335 |
+
lat, lon = state.get("lat"), state.get("lon")
|
| 336 |
+
|
| 337 |
+
defaults = {
|
| 338 |
+
"sandy": None, "dep": None, "floodnet": None,
|
| 339 |
+
"nyc311": None, "ida_hwm": None, "prithvi_water": None, "microtopo": None,
|
| 340 |
+
}
|
| 341 |
+
|
| 342 |
+
if lat is None:
|
| 343 |
+
for fn in _CORNERSTONE_WORKERS:
|
| 344 |
+
rec = _make_rec(fn.__name__.removeprefix("_run_"))
|
| 345 |
+
rec["ok"] = False; rec["err"] = "no coords"
|
| 346 |
+
rec["elapsed_s"] = 0.0
|
| 347 |
+
trace.append(rec)
|
| 348 |
+
return state.update(**defaults, trace=trace)
|
| 349 |
+
|
| 350 |
+
results: dict[str, Any] = {}
|
| 351 |
+
for fn in _CORNERSTONE_WORKERS:
|
| 352 |
+
try:
|
| 353 |
+
key, val, rec = fn(lat, lon)
|
| 354 |
+
except Exception as e:
|
| 355 |
+
rec = {"step": fn.__name__, "ok": False,
|
| 356 |
+
"err": str(e), "elapsed_s": 0.0, "started_at": time.time()}
|
| 357 |
+
key = fn.__name__.removeprefix("_run_")
|
| 358 |
+
val = None
|
| 359 |
+
log.exception("cornerstone worker %s raised", fn.__name__)
|
| 360 |
+
results[key] = val
|
| 361 |
+
trace.append(rec)
|
| 362 |
+
|
| 363 |
+
return state.update(
|
| 364 |
+
sandy=results.get("sandy"),
|
| 365 |
+
dep=results.get("dep"),
|
| 366 |
+
floodnet=results.get("floodnet"),
|
| 367 |
+
nyc311=results.get("nyc311"),
|
| 368 |
+
ida_hwm=results.get("ida_hwm"),
|
| 369 |
+
prithvi_water=results.get("prithvi_water"),
|
| 370 |
+
microtopo=results.get("microtopo"),
|
| 371 |
+
trace=trace,
|
| 372 |
+
)
|
| 373 |
+
|
| 374 |
+
|
| 375 |
@action(reads=["query"], writes=["geocode", "lat", "lon", "trace"])
|
| 376 |
def step_geocode(state: State) -> State:
|
| 377 |
rec, trace = _step(state, "geocode")
|
|
|
|
| 836 |
rec["elapsed_s"] = round(time.time() - rec["started_at"], 2)
|
| 837 |
|
| 838 |
|
| 839 |
+
@action(reads=["lat", "lon"], writes=["npcc4_slr", "trace"])
|
| 840 |
+
def step_npcc4_projection(state: State) -> State:
|
| 841 |
+
"""NPCC4 (2024) sea-level rise table — static lookup, always available."""
|
| 842 |
+
rec, trace = _step(state, "npcc4_projection")
|
| 843 |
+
try:
|
| 844 |
+
s = npcc4_slr.get_projections()
|
| 845 |
+
rec["ok"] = True
|
| 846 |
+
rec["result"] = {
|
| 847 |
+
"2050_10th_in": s["2050"]["10"]["in"],
|
| 848 |
+
"2050_50th_in": s["2050"]["50"]["in"],
|
| 849 |
+
"2050_90th_in": s["2050"]["90"]["in"],
|
| 850 |
+
"2100_90th_in": s["2100"]["90"]["in"],
|
| 851 |
+
}
|
| 852 |
+
return state.update(npcc4_slr=s, trace=trace)
|
| 853 |
+
except Exception as e:
|
| 854 |
+
rec["ok"] = False; rec["err"] = str(e)
|
| 855 |
+
log.exception("npcc4_projection failed")
|
| 856 |
+
return state.update(npcc4_slr=None, trace=trace)
|
| 857 |
+
finally:
|
| 858 |
+
rec["elapsed_s"] = round(time.time() - rec["started_at"], 2)
|
| 859 |
+
|
| 860 |
+
|
| 861 |
@action(reads=["lat", "lon"], writes=["mta_entrances", "trace"])
|
| 862 |
def step_mta_entrances(state: State) -> State:
|
| 863 |
rec, trace = _step(state, "mta_entrance_exposure")
|
|
|
|
| 1218 |
"ida_hwm", "prithvi_water", "prithvi_live", "terramind",
|
| 1219 |
"terramind_lulc", "terramind_buildings",
|
| 1220 |
"noaa_tides", "nws_alerts", "nws_obs", "ttm_forecast",
|
| 1221 |
+
"ttm_311_forecast", "floodnet_forecast", "npcc4_slr",
|
| 1222 |
+
"ttm_battery_surge",
|
| 1223 |
"mta_entrances",
|
| 1224 |
"nycha_developments", "doe_schools", "doh_hospitals",
|
| 1225 |
"rag", "gliner"],
|
| 1226 |
+
writes=["paragraph", "audit", "mellea", "citations", "trace"])
|
| 1227 |
def step_reconcile(state: State) -> State:
|
| 1228 |
is_strict = _current_strict_mode()
|
| 1229 |
rec, trace = _step(state, "mellea_reconcile_address" if is_strict else "reconcile_granite41")
|
|
|
|
| 1244 |
"ttm_forecast": state.get("ttm_forecast"),
|
| 1245 |
"ttm_311_forecast": state.get("ttm_311_forecast"),
|
| 1246 |
"floodnet_forecast": state.get("floodnet_forecast"),
|
| 1247 |
+
"npcc4_slr": state.get("npcc4_slr"),
|
| 1248 |
"ttm_battery_surge": state.get("ttm_battery_surge"),
|
| 1249 |
"rag": state.get("rag"),
|
| 1250 |
"gliner": state.get("gliner"),
|
|
|
|
| 1269 |
else:
|
| 1270 |
token_cb = _current_token_callback()
|
| 1271 |
attempt_cb = _current_mellea_attempt_callback()
|
| 1272 |
+
# Enumerate the exact doc_ids the model may cite so it
|
| 1273 |
+
# doesn't invent plausible-sounding ones (e.g. rag_npcc4).
|
| 1274 |
+
_avail_ids = sorted(
|
| 1275 |
+
m["role"].split(" ", 1)[1]
|
| 1276 |
+
for m in doc_msgs
|
| 1277 |
+
if m.get("role", "").startswith("document ")
|
| 1278 |
+
)
|
| 1279 |
+
_id_note = (
|
| 1280 |
+
f"\nValid document IDs for citation (use these exactly): "
|
| 1281 |
+
f"{', '.join(_avail_ids)}."
|
| 1282 |
+
)
|
| 1283 |
framed_prompt = augment_system_prompt(
|
| 1284 |
+
EXTRA_SYSTEM_PROMPT + _id_note,
|
| 1285 |
query=_current_user_query() or state.get("query") or "",
|
| 1286 |
intent=_current_planner_intent() or "single_address",
|
| 1287 |
)
|
|
|
|
| 1316 |
"model": mres["model"],
|
| 1317 |
"loop_budget": mres["loop_budget"],
|
| 1318 |
}
|
| 1319 |
+
# If Mellea returned empty (streaming stall / LLM failure),
|
| 1320 |
+
# do NOT call run_reconcile as a fallback: Mellea's daemon
|
| 1321 |
+
# thread is likely still running a streaming vLLM request,
|
| 1322 |
+
# and a second concurrent request overloads RunPod, causing
|
| 1323 |
+
# both to hang for the full 240 s LiteLLM timeout.
|
| 1324 |
+
if not para or len(para.strip()) < 50:
|
| 1325 |
+
log.warning("mellea returned empty — skipping fallback to avoid concurrent vLLM")
|
| 1326 |
rec["result"] = {
|
| 1327 |
"rerolls": (mellea_meta or {}).get("rerolls"),
|
| 1328 |
"passed": (f"{len((mellea_meta or {}).get('requirements_passed') or [])}/"
|
|
|
|
| 1336 |
"paragraph_chars": len(para),
|
| 1337 |
"dropped_sentences": len(audit["dropped"]),
|
| 1338 |
}
|
| 1339 |
+
# Build citation metadata list from whichever doc_msgs were used.
|
| 1340 |
+
from app.reconcile import build_documents, trim_docs_to_plan
|
| 1341 |
+
_cite_msgs = build_documents(snap)
|
| 1342 |
+
_cite_msgs = trim_docs_to_plan(_cite_msgs, _current_planned_specialists())
|
| 1343 |
+
cite_list = citations_from_docs(_cite_msgs)
|
| 1344 |
rec["ok"] = True
|
| 1345 |
return state.update(paragraph=para, audit=audit,
|
| 1346 |
+
mellea=mellea_meta, citations=cite_list, trace=trace)
|
| 1347 |
except Exception as e:
|
| 1348 |
rec["ok"] = False; rec["err"] = str(e)
|
| 1349 |
log.exception("reconcile failed")
|
| 1350 |
return state.update(paragraph="", audit={"raw": "", "dropped": []},
|
| 1351 |
+
mellea=None, citations=[], trace=trace)
|
| 1352 |
finally:
|
| 1353 |
rec["elapsed_s"] = round(time.time() - rec["started_at"], 2)
|
| 1354 |
|
|
|
|
| 1399 |
).lower() in ("1", "true", "yes")
|
| 1400 |
|
| 1401 |
|
| 1402 |
+
_BURR_TRACKING_DIR = _os.environ.get("RIPRAP_BURR_TRACKING_DIR", "/tmp/riprap-burr")
|
|
|
|
| 1403 |
|
| 1404 |
+
|
| 1405 |
+
def build_app(query: str, step_queue=None):
|
| 1406 |
+
"""Burr application — Cornerstone specialists run in parallel.
|
| 1407 |
+
|
| 1408 |
+
Order: geocode → cornerstone (7 geospatial specialists, parallel) →
|
| 1409 |
+
live network signals → RAG → reconcile. Heavy specialists (NYCHA /
|
| 1410 |
+
DOE / DOH register joins, Prithvi-EO live STAC, TerraMind diffusion)
|
| 1411 |
+
are gated behind RIPRAP_HEAVY_SPECIALISTS — see module-level note.
|
| 1412 |
+
|
| 1413 |
+
step_queue: optional queue.Queue — if provided, StepEventHook pushes
|
| 1414 |
+
each completed action's trace record to it (replaces iter_steps
|
| 1415 |
+
manual deduplication). LocalTrackingClient writes to RIPRAP_BURR_TRACKING_DIR.
|
| 1416 |
+
SQLitePersister caches completed runs keyed by (address, date) so repeat
|
| 1417 |
+
queries skip the specialist pipeline and go straight to reconcile.
|
| 1418 |
"""
|
| 1419 |
+
tracker = LocalTrackingClient(project="riprap", storage_dir=_BURR_TRACKING_DIR)
|
| 1420 |
+
|
| 1421 |
builder = (
|
| 1422 |
ApplicationBuilder()
|
| 1423 |
.with_state(query=query, trace=[])
|
| 1424 |
.with_entrypoint("geocode")
|
| 1425 |
+
.with_tracker(tracker)
|
| 1426 |
+
.with_hooks(StepEventHook(step_queue))
|
| 1427 |
)
|
| 1428 |
|
| 1429 |
actions: dict[str, Any] = {
|
| 1430 |
"geocode": step_geocode,
|
| 1431 |
+
"cornerstone": step_cornerstone, # sandy+dep+floodnet+311+ida+prithvi+microtopo
|
|
|
|
|
|
|
|
|
|
| 1432 |
"noaa_tides": step_noaa_tides,
|
| 1433 |
"nws_alerts": step_nws_alerts,
|
| 1434 |
"nws_obs": step_nws_obs,
|
| 1435 |
"ttm_forecast": step_ttm_forecast,
|
| 1436 |
"ttm_311_forecast": step_ttm_311_forecast,
|
| 1437 |
"floodnet_forecast": step_floodnet_forecast,
|
| 1438 |
+
"npcc4_projection": step_npcc4_projection,
|
| 1439 |
"ttm_battery_surge": step_ttm_battery_surge,
|
|
|
|
|
|
|
| 1440 |
"mta_entrances": step_mta_entrances,
|
|
|
|
| 1441 |
}
|
| 1442 |
if _HEAVY_SPECIALISTS_ENABLED and _NYCHA_REGISTERS_ENABLED:
|
| 1443 |
actions["nycha"] = step_nycha
|
|
|
|
| 1446 |
if _HEAVY_SPECIALISTS_ENABLED:
|
| 1447 |
actions["prithvi_live"] = step_prithvi_live
|
| 1448 |
actions["terramind"] = step_terramind
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1449 |
actions["eo_chip"] = step_eo_chip
|
| 1450 |
actions["terramind_lulc"] = step_terramind_lulc
|
| 1451 |
actions["terramind_buildings"] = step_terramind_buildings
|
|
|
|
| 1453 |
actions["gliner"] = step_gliner
|
| 1454 |
actions["reconcile"] = step_reconcile
|
| 1455 |
|
| 1456 |
+
# Conditional transitions:
|
| 1457 |
+
# geocode → cornerstone if coords resolved; else skip straight to reconcile
|
| 1458 |
+
# All other transitions remain sequential.
|
| 1459 |
keys = list(actions.keys())
|
| 1460 |
+
# Build sequential pairs, but replace geocode→cornerstone with a conditional.
|
| 1461 |
+
transitions = []
|
| 1462 |
+
for src, dst in zip(keys, keys[1:]):
|
| 1463 |
+
if src == "geocode" and dst == "cornerstone":
|
| 1464 |
+
transitions.append(("geocode", "cornerstone", expr("lat is not None")))
|
| 1465 |
+
transitions.append(("geocode", "reconcile")) # geocode failed → skip all
|
| 1466 |
+
else:
|
| 1467 |
+
transitions.append((src, dst))
|
| 1468 |
|
| 1469 |
return (
|
| 1470 |
builder.with_actions(**actions).with_transitions(*transitions).build()
|
|
|
|
| 1547 |
import queue
|
| 1548 |
|
| 1549 |
q: queue.Queue[tuple[str, Any] | None] = queue.Queue()
|
| 1550 |
+
app = build_app(query, step_queue=q)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1551 |
final_state_holder: dict[str, Any] = {}
|
| 1552 |
|
| 1553 |
# Threadlocals are per-thread; the request thread (single_address.run
|
|
|
|
| 1570 |
try:
|
| 1571 |
for _action_obj, _result, state in app.iterate(halt_after=["reconcile"]):
|
| 1572 |
final_state_holder["state"] = state
|
| 1573 |
+
# StepEventHook fires after each action and pushes to q;
|
| 1574 |
+
# nothing else needed here.
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1575 |
except Exception as e:
|
| 1576 |
log.exception("iterate raised")
|
| 1577 |
q.put(("error", {"err": f"{type(e).__name__}: {e}"}))
|
|
|
|
| 1640 |
"paragraph": state.get("paragraph"),
|
| 1641 |
"audit": state.get("audit"),
|
| 1642 |
"mellea": state.get("mellea"),
|
| 1643 |
+
"citations": state.get("citations"),
|
| 1644 |
"energy": _summarize_energy(trace),
|
| 1645 |
"emissions": _summarize_emissions(),
|
| 1646 |
}
|
app/llm.py
CHANGED
|
@@ -92,6 +92,15 @@ def _build_router() -> Router:
|
|
| 92 |
fallbacks: list[dict[str, list[str]]] = []
|
| 93 |
use_vllm = _PRIMARY == "vllm" and bool(_VLLM_BASE)
|
| 94 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 95 |
for alias, (vllm_name, ollama_tag) in _LOGICAL.items():
|
| 96 |
if use_vllm:
|
| 97 |
model_list.append({
|
|
@@ -100,8 +109,8 @@ def _build_router() -> Router:
|
|
| 100 |
"model": f"openai/{vllm_name}",
|
| 101 |
"api_base": _VLLM_BASE,
|
| 102 |
"api_key": _VLLM_KEY,
|
| 103 |
-
"timeout":
|
| 104 |
-
"stream_timeout":
|
| 105 |
},
|
| 106 |
})
|
| 107 |
if _FALLBACK == "ollama":
|
|
@@ -111,8 +120,8 @@ def _build_router() -> Router:
|
|
| 111 |
"litellm_params": {
|
| 112 |
"model": f"ollama_chat/{ollama_tag}",
|
| 113 |
"api_base": _OLLAMA_BASE,
|
| 114 |
-
"timeout":
|
| 115 |
-
"stream_timeout":
|
| 116 |
},
|
| 117 |
})
|
| 118 |
fallbacks.append({alias: [fb_alias]})
|
|
@@ -122,8 +131,8 @@ def _build_router() -> Router:
|
|
| 122 |
"litellm_params": {
|
| 123 |
"model": f"ollama_chat/{ollama_tag}",
|
| 124 |
"api_base": _OLLAMA_BASE,
|
| 125 |
-
"timeout":
|
| 126 |
-
"stream_timeout":
|
| 127 |
},
|
| 128 |
})
|
| 129 |
|
|
@@ -135,7 +144,7 @@ def _build_router() -> Router:
|
|
| 135 |
fallbacks=fallbacks,
|
| 136 |
num_retries=0, # Router fallback handles the failover; no point
|
| 137 |
# burning seconds re-hitting a dead endpoint.
|
| 138 |
-
timeout=
|
| 139 |
)
|
| 140 |
|
| 141 |
|
|
|
|
| 92 |
fallbacks: list[dict[str, list[str]]] = []
|
| 93 |
use_vllm = _PRIMARY == "vllm" and bool(_VLLM_BASE)
|
| 94 |
|
| 95 |
+
# vLLM on RunPod can take 250+ seconds to cold-start (container boot +
|
| 96 |
+
# model load into GPU VRAM). The first-token timeout must exceed that.
|
| 97 |
+
# stream_timeout (per-chunk) stays tight since subsequent tokens are fast.
|
| 98 |
+
_vllm_first_token_timeout = int(
|
| 99 |
+
os.environ.get("RIPRAP_LITELLM_TIMEOUT_S", "360"))
|
| 100 |
+
# 5s: fail fast so callers (mellea probe loop) aren't blocked waiting
|
| 101 |
+
# for an Ollama that doesn't exist in the vLLM-primary HF Space.
|
| 102 |
+
_ollama_timeout = 5
|
| 103 |
+
|
| 104 |
for alias, (vllm_name, ollama_tag) in _LOGICAL.items():
|
| 105 |
if use_vllm:
|
| 106 |
model_list.append({
|
|
|
|
| 109 |
"model": f"openai/{vllm_name}",
|
| 110 |
"api_base": _VLLM_BASE,
|
| 111 |
"api_key": _VLLM_KEY,
|
| 112 |
+
"timeout": _vllm_first_token_timeout,
|
| 113 |
+
"stream_timeout": 60,
|
| 114 |
},
|
| 115 |
})
|
| 116 |
if _FALLBACK == "ollama":
|
|
|
|
| 120 |
"litellm_params": {
|
| 121 |
"model": f"ollama_chat/{ollama_tag}",
|
| 122 |
"api_base": _OLLAMA_BASE,
|
| 123 |
+
"timeout": _ollama_timeout,
|
| 124 |
+
"stream_timeout": _ollama_timeout,
|
| 125 |
},
|
| 126 |
})
|
| 127 |
fallbacks.append({alias: [fb_alias]})
|
|
|
|
| 131 |
"litellm_params": {
|
| 132 |
"model": f"ollama_chat/{ollama_tag}",
|
| 133 |
"api_base": _OLLAMA_BASE,
|
| 134 |
+
"timeout": _ollama_timeout,
|
| 135 |
+
"stream_timeout": _ollama_timeout,
|
| 136 |
},
|
| 137 |
})
|
| 138 |
|
|
|
|
| 144 |
fallbacks=fallbacks,
|
| 145 |
num_retries=0, # Router fallback handles the failover; no point
|
| 146 |
# burning seconds re-hitting a dead endpoint.
|
| 147 |
+
timeout=_vllm_first_token_timeout if use_vllm else _ollama_timeout,
|
| 148 |
)
|
| 149 |
|
| 150 |
|
app/mellea_validator.py
CHANGED
|
@@ -23,7 +23,9 @@ from __future__ import annotations
|
|
| 23 |
|
| 24 |
import logging
|
| 25 |
import os
|
|
|
|
| 26 |
import re
|
|
|
|
| 27 |
import time
|
| 28 |
from typing import Any
|
| 29 |
|
|
@@ -271,7 +273,7 @@ def reconcile_strict(doc_msgs: list[dict],
|
|
| 271 |
return_sampling_results=True,
|
| 272 |
model_options={"temperature": 0,
|
| 273 |
"num_ctx": int(os.environ.get("RIPRAP_MELLEA_NUM_CTX", "4096")),
|
| 274 |
-
"num_predict": int(os.environ.get("RIPRAP_MELLEA_NUM_PREDICT", "
|
| 275 |
**(ollama_options or {})},
|
| 276 |
)
|
| 277 |
|
|
@@ -339,6 +341,8 @@ def reconcile_strict_streaming(
|
|
| 339 |
t0 = time.time()
|
| 340 |
|
| 341 |
checks = [
|
|
|
|
|
|
|
| 342 |
("numerics_grounded",
|
| 343 |
_check_no_invented_numbers(doc_msgs)),
|
| 344 |
("no_placeholder_tokens",
|
|
@@ -353,23 +357,61 @@ def reconcile_strict_streaming(
|
|
| 353 |
{"role": "system", "content": system_prompt},
|
| 354 |
{"role": "user", "content": user_prompt},
|
| 355 |
]
|
| 356 |
-
#
|
| 357 |
-
#
|
| 358 |
-
#
|
| 359 |
-
#
|
| 360 |
-
#
|
| 361 |
-
# KV cache (33% more memory + a full re-init) every Mellea attempt.
|
| 362 |
-
# Override with RIPRAP_MELLEA_NUM_CTX / RIPRAP_MELLEA_NUM_PREDICT.
|
| 363 |
base_opts = {"temperature": 0,
|
| 364 |
"num_ctx": int(os.environ.get("RIPRAP_MELLEA_NUM_CTX", "4096")),
|
| 365 |
-
"num_predict": int(os.environ.get("RIPRAP_MELLEA_NUM_PREDICT", "
|
| 366 |
**(ollama_options or {})}
|
| 367 |
|
| 368 |
paragraph = ""
|
| 369 |
last_passed: list[str] = []
|
| 370 |
last_failed: list[str] = [name for name, _ in checks]
|
| 371 |
last_paragraph = ""
|
|
|
|
| 372 |
attempts = 0
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 373 |
|
| 374 |
for attempt_idx in range(loop_budget):
|
| 375 |
attempts = attempt_idx + 1
|
|
@@ -401,10 +443,52 @@ def reconcile_strict_streaming(
|
|
| 401 |
messages.append({"role": "user", "content": "\n".join(feedback)})
|
| 402 |
|
| 403 |
chunks: list[str] = []
|
| 404 |
-
|
| 405 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 406 |
delta = (chunk.get("message") or {}).get("content") or ""
|
| 407 |
if delta:
|
|
|
|
| 408 |
chunks.append(delta)
|
| 409 |
if on_token is not None:
|
| 410 |
try:
|
|
@@ -412,6 +496,8 @@ def reconcile_strict_streaming(
|
|
| 412 |
except Exception:
|
| 413 |
log.exception("on_token callback raised")
|
| 414 |
paragraph = "".join(chunks).strip()
|
|
|
|
|
|
|
| 415 |
|
| 416 |
passed: list[str] = []
|
| 417 |
failed: list[str] = []
|
|
@@ -434,8 +520,18 @@ def reconcile_strict_streaming(
|
|
| 434 |
if not failed:
|
| 435 |
break
|
| 436 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 437 |
return {
|
| 438 |
-
"paragraph": paragraph,
|
| 439 |
"rerolls": max(0, attempts - 1),
|
| 440 |
"n_attempts": attempts,
|
| 441 |
"requirements_total": len(checks),
|
|
|
|
| 23 |
|
| 24 |
import logging
|
| 25 |
import os
|
| 26 |
+
import queue
|
| 27 |
import re
|
| 28 |
+
import threading
|
| 29 |
import time
|
| 30 |
from typing import Any
|
| 31 |
|
|
|
|
| 273 |
return_sampling_results=True,
|
| 274 |
model_options={"temperature": 0,
|
| 275 |
"num_ctx": int(os.environ.get("RIPRAP_MELLEA_NUM_CTX", "4096")),
|
| 276 |
+
"num_predict": int(os.environ.get("RIPRAP_MELLEA_NUM_PREDICT", "600")),
|
| 277 |
**(ollama_options or {})},
|
| 278 |
)
|
| 279 |
|
|
|
|
| 341 |
t0 = time.time()
|
| 342 |
|
| 343 |
checks = [
|
| 344 |
+
("non_empty",
|
| 345 |
+
lambda p: bool(p and len(p.strip()) > 50)),
|
| 346 |
("numerics_grounded",
|
| 347 |
_check_no_invented_numbers(doc_msgs)),
|
| 348 |
("no_placeholder_tokens",
|
|
|
|
| 357 |
{"role": "system", "content": system_prompt},
|
| 358 |
{"role": "user", "content": user_prompt},
|
| 359 |
]
|
| 360 |
+
# num_predict 512 lets the 4-section briefing complete in one pass.
|
| 361 |
+
# Reconciler prompts run ~1200 tokens (after trim_docs_to_plan),
|
| 362 |
+
# so 1200+512=1712 comfortably under the vLLM max_model_len=2352.
|
| 363 |
+
# Override with RIPRAP_MELLEA_NUM_PREDICT if needed.
|
| 364 |
+
# num_ctx (Ollama only) is forwarded via extra_body; vLLM ignores it.
|
|
|
|
|
|
|
| 365 |
base_opts = {"temperature": 0,
|
| 366 |
"num_ctx": int(os.environ.get("RIPRAP_MELLEA_NUM_CTX", "4096")),
|
| 367 |
+
"num_predict": int(os.environ.get("RIPRAP_MELLEA_NUM_PREDICT", "512")),
|
| 368 |
**(ollama_options or {})}
|
| 369 |
|
| 370 |
paragraph = ""
|
| 371 |
last_passed: list[str] = []
|
| 372 |
last_failed: list[str] = [name for name, _ in checks]
|
| 373 |
last_paragraph = ""
|
| 374 |
+
best_paragraph = "" # best non-empty paragraph seen across all attempts
|
| 375 |
attempts = 0
|
| 376 |
+
_streaming_hung = False # set on first per-token timeout; skip retries
|
| 377 |
+
|
| 378 |
+
# Two-phase timeout: the FIRST token from a cold RunPod pod can take
|
| 379 |
+
# 3-4 min (container boot + model load into GPU VRAM). Once streaming
|
| 380 |
+
# has started, each subsequent token should arrive in < 5 s; we use a
|
| 381 |
+
# tight 45 s inter-token timeout to catch mid-stream stalls quickly.
|
| 382 |
+
_first_token_timeout = int(os.environ.get("RIPRAP_FIRST_TOKEN_TIMEOUT_S", "400"))
|
| 383 |
+
_inter_token_timeout = int(os.environ.get("RIPRAP_TOKEN_TIMEOUT_S", "45"))
|
| 384 |
+
|
| 385 |
+
# When PRIMARY=vllm, RunPod cold-starts take ~250s (container boot +
|
| 386 |
+
# model load). LiteLLM gets a 503 and falls back to Ollama, which blocks
|
| 387 |
+
# for the full Ollama timeout before failing. Poll /v1/models instead and
|
| 388 |
+
# wait here — keepalives keep the SSE connection alive during the wait.
|
| 389 |
+
_vllm_base = os.environ.get("RIPRAP_LLM_BASE_URL", "").rstrip("/")
|
| 390 |
+
if os.environ.get("RIPRAP_LLM_PRIMARY", "ollama") == "vllm" and _vllm_base:
|
| 391 |
+
try:
|
| 392 |
+
import httpx as _httpx
|
| 393 |
+
_probe_url = f"{_vllm_base}/models"
|
| 394 |
+
_probe_key = os.environ.get("RIPRAP_LLM_API_KEY", "") or "EMPTY"
|
| 395 |
+
_probe_headers = {"Authorization": f"Bearer {_probe_key}"}
|
| 396 |
+
_probe_deadline = t0 + _first_token_timeout
|
| 397 |
+
log.info("mellea: polling vLLM readiness at %s", _probe_url)
|
| 398 |
+
while time.time() < _probe_deadline:
|
| 399 |
+
try:
|
| 400 |
+
_r = _httpx.get(_probe_url, headers=_probe_headers, timeout=5.0)
|
| 401 |
+
# Any non-503/502/504 means the service is UP (200 = ready,
|
| 402 |
+
# 401 = auth-gated but alive, 404 = wrong path but alive).
|
| 403 |
+
if _r.status_code not in (502, 503, 504):
|
| 404 |
+
log.info("mellea: vLLM ready (status=%d, %.1fs elapsed)",
|
| 405 |
+
_r.status_code, time.time() - t0)
|
| 406 |
+
break
|
| 407 |
+
except Exception as _pe:
|
| 408 |
+
log.debug("mellea: vLLM probe: %r", _pe)
|
| 409 |
+
time.sleep(10)
|
| 410 |
+
else:
|
| 411 |
+
log.warning("mellea: vLLM not ready after %.1fs, proceeding anyway",
|
| 412 |
+
time.time() - t0)
|
| 413 |
+
except ImportError:
|
| 414 |
+
log.warning("mellea: httpx not available, skipping vLLM probe")
|
| 415 |
|
| 416 |
for attempt_idx in range(loop_budget):
|
| 417 |
attempts = attempt_idx + 1
|
|
|
|
| 443 |
messages.append({"role": "user", "content": "\n".join(feedback)})
|
| 444 |
|
| 445 |
chunks: list[str] = []
|
| 446 |
+
|
| 447 |
+
# Each attempt gets its own sentinel so that a stale daemon thread
|
| 448 |
+
# from a previous timed-out attempt cannot corrupt this attempt's
|
| 449 |
+
# queue (the closure captures variables by reference; re-binding
|
| 450 |
+
# them per-attempt keeps each daemon's sentinel unique).
|
| 451 |
+
_stream_q: queue.Queue = queue.Queue()
|
| 452 |
+
_done_sentinel = object()
|
| 453 |
+
|
| 454 |
+
def _stream_worker(q=_stream_q, done=_done_sentinel,
|
| 455 |
+
msgs=messages, opts=base_opts):
|
| 456 |
+
try:
|
| 457 |
+
for _chunk in llm.chat(model=model, messages=msgs,
|
| 458 |
+
stream=True, options=opts):
|
| 459 |
+
q.put(_chunk)
|
| 460 |
+
except Exception as _e:
|
| 461 |
+
q.put(_e)
|
| 462 |
+
finally:
|
| 463 |
+
q.put(done)
|
| 464 |
+
|
| 465 |
+
_st = threading.Thread(target=_stream_worker, daemon=True)
|
| 466 |
+
_st.start()
|
| 467 |
+
_timed_out = False
|
| 468 |
+
_got_first_token = False
|
| 469 |
+
while True:
|
| 470 |
+
_timeout = _inter_token_timeout if _got_first_token else _first_token_timeout
|
| 471 |
+
try:
|
| 472 |
+
chunk = _stream_q.get(timeout=_timeout)
|
| 473 |
+
except queue.Empty:
|
| 474 |
+
log.warning("mellea: timeout (%ds, first=%s) — breaking stream",
|
| 475 |
+
_timeout, not _got_first_token)
|
| 476 |
+
_timed_out = True
|
| 477 |
+
_streaming_hung = True
|
| 478 |
+
break
|
| 479 |
+
if chunk is _done_sentinel:
|
| 480 |
+
break
|
| 481 |
+
if isinstance(chunk, Exception):
|
| 482 |
+
log.warning("mellea: stream error: %r", chunk)
|
| 483 |
+
if not _got_first_token:
|
| 484 |
+
# LiteLLM/httpx timeout before first token — treat as
|
| 485 |
+
# streaming hung so we don't start a concurrent retry.
|
| 486 |
+
_timed_out = True
|
| 487 |
+
_streaming_hung = True
|
| 488 |
+
break
|
| 489 |
delta = (chunk.get("message") or {}).get("content") or ""
|
| 490 |
if delta:
|
| 491 |
+
_got_first_token = True
|
| 492 |
chunks.append(delta)
|
| 493 |
if on_token is not None:
|
| 494 |
try:
|
|
|
|
| 496 |
except Exception:
|
| 497 |
log.exception("on_token callback raised")
|
| 498 |
paragraph = "".join(chunks).strip()
|
| 499 |
+
if paragraph:
|
| 500 |
+
best_paragraph = paragraph
|
| 501 |
|
| 502 |
passed: list[str] = []
|
| 503 |
failed: list[str] = []
|
|
|
|
| 520 |
if not failed:
|
| 521 |
break
|
| 522 |
|
| 523 |
+
# If this attempt's stream hung, stop retrying with streaming.
|
| 524 |
+
# A stale daemon thread is still consuming vLLM resources; starting
|
| 525 |
+
# another streaming request would create a second concurrent request
|
| 526 |
+
# and can crash vLLM (observed as HTTP/2 stream error on the SSE
|
| 527 |
+
# connection). Signal the caller to use a non-streaming fallback.
|
| 528 |
+
if _timed_out:
|
| 529 |
+
log.warning("mellea: streaming hung — aborting retry loop "
|
| 530 |
+
"to avoid concurrent vLLM requests")
|
| 531 |
+
break
|
| 532 |
+
|
| 533 |
return {
|
| 534 |
+
"paragraph": paragraph or best_paragraph,
|
| 535 |
"rerolls": max(0, attempts - 1),
|
| 536 |
"n_attempts": attempts,
|
| 537 |
"requirements_total": len(checks),
|
web/main.py
CHANGED
|
@@ -613,13 +613,26 @@ def _run_compare(p, raw_query: str, out_q, i_addr) -> dict:
|
|
| 613 |
|
| 614 |
mellea_a = results[0][2].get("mellea") or {}
|
| 615 |
mellea_b = results[1][2].get("mellea") or {}
|
| 616 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 617 |
"paragraph": merged_paragraph,
|
| 618 |
"mellea": _merge_mellea(mellea_a, mellea_b),
|
| 619 |
"intent": "compare",
|
| 620 |
-
"targets": [
|
|
|
|
|
|
|
|
|
|
| 621 |
"tier": results[0][2].get("tier"),
|
| 622 |
-
}
|
|
|
|
| 623 |
|
| 624 |
|
| 625 |
@app.get("/api/agent")
|
|
@@ -682,6 +695,21 @@ async def api_agent_stream(q: str):
|
|
| 682 |
def runner():
|
| 683 |
emissions.install(tracker)
|
| 684 |
try:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 685 |
from app.intents import development_check as i_dev
|
| 686 |
from app.intents import live_now as i_live
|
| 687 |
from app.intents import neighborhood as i_nbhd
|
|
@@ -752,6 +780,10 @@ async def api_agent_stream(q: str):
|
|
| 752 |
try:
|
| 753 |
ev = await asyncio.to_thread(out_q.get, True, 1.0)
|
| 754 |
except Exception:
|
|
|
|
|
|
|
|
|
|
|
|
|
| 755 |
continue
|
| 756 |
kind = ev.get("kind")
|
| 757 |
if kind == "_done":
|
|
|
|
| 613 |
|
| 614 |
mellea_a = results[0][2].get("mellea") or {}
|
| 615 |
mellea_b = results[1][2].get("mellea") or {}
|
| 616 |
+
|
| 617 |
+
# Spread Place A's full specialist state into the return dict so
|
| 618 |
+
# adaptFinalToFindings can build evidence cards (TTM, TerraMind, Prithvi,
|
| 619 |
+
# Sandy, etc.) from the higher-risk location. Place B's live-state data
|
| 620 |
+
# is available via targets[].state for future per-location card rendering.
|
| 621 |
+
# Without this, _run_compare returned only paragraph/mellea/intent/targets
|
| 622 |
+
# and all fine-tuned model cards were silently suppressed (state keys
|
| 623 |
+
# missing → card builders returned null).
|
| 624 |
+
out = {**results[0][2]}
|
| 625 |
+
out.update({
|
| 626 |
"paragraph": merged_paragraph,
|
| 627 |
"mellea": _merge_mellea(mellea_a, mellea_b),
|
| 628 |
"intent": "compare",
|
| 629 |
+
"targets": [
|
| 630 |
+
{"label": lbl, "address": addr, "state": res}
|
| 631 |
+
for lbl, addr, res in results
|
| 632 |
+
],
|
| 633 |
"tier": results[0][2].get("tier"),
|
| 634 |
+
})
|
| 635 |
+
return out
|
| 636 |
|
| 637 |
|
| 638 |
@app.get("/api/agent")
|
|
|
|
| 695 |
def runner():
|
| 696 |
emissions.install(tracker)
|
| 697 |
try:
|
| 698 |
+
import threading as _th
|
| 699 |
+
from app import llm as _llm
|
| 700 |
+
|
| 701 |
+
def _warmup_llm():
|
| 702 |
+
try:
|
| 703 |
+
_llm.chat(
|
| 704 |
+
model="granite-8b",
|
| 705 |
+
messages=[{"role": "user", "content": "hi"}],
|
| 706 |
+
options={"num_predict": 1, "temperature": 0},
|
| 707 |
+
stream=False,
|
| 708 |
+
)
|
| 709 |
+
except Exception:
|
| 710 |
+
pass
|
| 711 |
+
_th.Thread(target=_warmup_llm, daemon=True, name="riprap-warmup").start()
|
| 712 |
+
|
| 713 |
from app.intents import development_check as i_dev
|
| 714 |
from app.intents import live_now as i_live
|
| 715 |
from app.intents import neighborhood as i_nbhd
|
|
|
|
| 780 |
try:
|
| 781 |
ev = await asyncio.to_thread(out_q.get, True, 1.0)
|
| 782 |
except Exception:
|
| 783 |
+
# No event for 1 s — send an SSE comment so the HF Space
|
| 784 |
+
# proxy doesn't close the idle connection (proxy idle timeout
|
| 785 |
+
# is ~15-20 s; the reconciler's vLLM call can take longer).
|
| 786 |
+
yield ": keepalive\n\n"
|
| 787 |
continue
|
| 788 |
kind = ev.get("kind")
|
| 789 |
if kind == "_done":
|