fix: cold-start, keepalives, vLLM readiness probe, fast Ollama fallback timeout

#2
Files changed (4) hide show
  1. app/fsm.py +334 -51
  2. app/llm.py +16 -7
  3. app/mellea_validator.py +108 -12
  4. 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
- # Canonical Burr: one action per specialist, sequential transitions.
123
- # A previous version of this module wrapped 16 specialists in a single
124
- # fan-out action that ran them concurrently in a ThreadPoolExecutor;
125
- # that path was removed because it sometimes hung after the fan-out
126
- # completed (Burr-internal post-action cleanup with custom executors)
127
- # and made the trace UI's per-step timing harder to reason about.
128
- # Parallelism, when wanted, belongs at the inference layer
129
- # (vLLM / Ollama NUM_PARALLEL), not the FSM.
 
 
 
 
 
 
 
 
 
 
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", "ttm_battery_surge",
 
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
- def build_app(query: str):
1121
- """Linear, single-action-per-step Burr application.
1122
 
1123
- Order: cheap-first geo + flood layers, then live live network signals,
1124
- then RAG → reconcile. Heavy specialists (NYCHA / DOE / DOH register
1125
- joins, Prithvi-EO live STAC, TerraMind diffusion) are gated behind
1126
- RIPRAP_HEAVY_SPECIALISTS — see the module-level note above.
 
 
 
 
 
 
 
 
 
 
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
- "sandy": step_sandy,
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
- # Sequential transitions — pair every adjacent action in the dict order.
 
 
1171
  keys = list(actions.keys())
1172
- transitions = list(zip(keys, keys[1:]))
 
 
 
 
 
 
 
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
- seen_keys: set[tuple[str, float]] = set()
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
- # Each action appends one record to state.trace; emit the
1288
- # most recent so the SSE client gets the step event the
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": 240,
104
- "stream_timeout": 240,
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": 240,
115
- "stream_timeout": 240,
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": 240,
126
- "stream_timeout": 240,
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=240,
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", "400")),
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
- # num_ctx 4096 fits a typical trimmed prompt (≈700 system + ≈2500 docs);
357
- # num_predict 400 caps the 4-section briefing at ≈300-350 tokens. With
358
- # RIPRAP_TRIM_DOCS=1 and the planner picking 6-9 specialists, the 4096
359
- # window has been sufficient on every probe; the previous 6144/600 was
360
- # sized for the *untrimmed* fan-out and was forcing Ollama to grow the
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", "400")),
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
- for chunk in llm.chat(model=model, messages=messages,
405
- stream=True, options=base_opts):
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- return {
 
 
 
 
 
 
 
 
 
617
  "paragraph": merged_paragraph,
618
  "mellea": _merge_mellea(mellea_a, mellea_b),
619
  "intent": "compare",
620
- "targets": [{"label": lbl, "address": addr} for lbl, addr, _ in results],
 
 
 
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":