Compare commits

...

1 commit

Author SHA1 Message Date
Ubuntu
6653adba24 fix(nws): keep weather alerts in one packet without dropping storm path
Weather alerts ran ~250-310 chars and were blind-sliced to 200 in the
central consumer, silently dropping storm motion + the impacted-town
list. The town list was also pre-capped to 80 chars at parse time,
destroying the middle/end of the storm path before formatting.

- nws_handler: preserve the full impacted-town list; when the message
  overflows one mesh packet, sample the path (first -> middle -> last)
  instead of truncating the tail, so both path endpoints survive
- consumer: pass precomposed titles through verbatim (no [:200] chop)
- adapter_config: add nws.single_packet_max_chars (default 200)
- tests: path-sampling + short-list coverage

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-02 04:06:23 +00:00
4 changed files with 153 additions and 21 deletions

View file

@ -581,6 +581,11 @@ REGISTRY: dict[tuple[str, str], dict[str, Any]] = {
"type": "int",
"description": "Maximum characters for the area field on line 2 of NWS wire. Truncates at last word boundary.",
},
("nws", "single_packet_max_chars"): {
"default": 200,
"type": "int",
"description": "Maximum characters for a single NWS mesh packet. Budget enforced after all fields are assembled; only the locations town-list is trimmed to fit.",
},
# =================================================================
# AVALANCHE -- 1 setting (min danger level broadcast floor)

View file

@ -608,7 +608,7 @@ class CentralConsumer:
data["_meshai_precomposed"] = True
kwargs = dict(
title=str(title)[:200],
title=str(title) if data.get("_meshai_precomposed") else str(title)[:200],
summary="",
lat=lat,
lon=lon,

View file

@ -104,7 +104,9 @@ def _parse_nws_description(description: str) -> dict:
if m:
text = m.group(1).replace("\n", " ").strip()
if text:
result[key] = text[:80]
# Preserve the FULL town list for path-sampling in _render();
# all other fields keep the 80-char cap.
result[key] = text[:400] if key == "locations" else text[:80]
return result
@ -404,27 +406,57 @@ def _render(*, event_type, area_desc, geocoder_city, county, state,
cert_seg = f" | {certainty}"
line3 = f"{hazard_text}{cert_seg}" if hazard_text else ""
# Line 4: motion + locations
# Line 4: motion + locations (path-sampled if the full town list won't fit).
compass, speed_mph = _parse_motion(params)
motion = f"Moving {compass} {speed_mph} mph" if compass and speed_mph else ""
locations = (desc.get("locations") or "").rstrip("., ")
_loc_limit = int(adapter_config.nws.locations_max_chars)
if len(locations) > _loc_limit:
cut = locations[:_loc_limit].rsplit(" ", 1)[0]
if not cut:
cut = locations[:_loc_limit]
locations = cut + "\u2026"
if motion and locations:
line4 = f"{motion}{locations}"
elif motion:
line4 = motion
elif locations:
line4 = locations
else:
line4 = ""
lines = [l for l in (line1, line2, line3, line4) if l]
return "\n".join(lines)
# Parse the (now-full) locations string into an ordered town list. Path
# order = soonest-impact first ... farthest-along last. The tail element
# frequently starts with "and " (e.g. "and Shoshone") -> strip that.
raw = (desc.get("locations") or "").rstrip("., ")
towns = [t.strip() for t in raw.split(",") if t.strip()]
if towns:
towns[-1] = re.sub(r"^and\s+", "", towns[-1], flags=re.IGNORECASE).strip()
towns = [t for t in towns if t]
def _line4(locs: str) -> str:
if motion and locs:
return f"{motion}{locs}"
if motion:
return motion
return locs or ""
PACKET_LIMIT = int(getattr(adapter_config.nws, "single_packet_max_chars", 200))
# Prefer the FULL town list; use it verbatim if the whole message fits.
full_locs = ", ".join(towns)
line4 = _line4(full_locs)
msg = "\n".join(l for l in (line1, line2, line3, line4) if l)
if len(msg) > PACKET_LIMIT:
# Won't fit: collapse locations to a path sample that never loses the
# endpoints -> soonest-impact -> midway -> farthest-along.
if len(towns) >= 3:
sampled = [towns[0], towns[len(towns) // 2], towns[-1]]
elif len(towns) == 2:
sampled = [towns[0], towns[-1]]
else:
sampled = list(towns)
# De-dup consecutive repeats (a short list can make first == middle),
# so we never render "Buhl → Buhl → Shoshone".
deduped = []
for t in sampled:
if not deduped or deduped[-1] != t:
deduped.append(t)
path = "".join(deduped)
line4 = _line4(path)
msg = "\n".join(l for l in (line1, line2, line3, line4) if l)
# Final hard-cap safety net for the pathological case (extremely long town
# names overflow even the first->middle->last sample plus the other lines).
if len(msg) > PACKET_LIMIT:
msg = msg[:PACKET_LIMIT - 1].rstrip() + ""
return msg
def _category_to_event_type(category_raw: str) -> str:

View file

@ -1,7 +1,7 @@
"""Tests for v0.5.10 NWS handler."""
import pytest
from meshai.central.nws_handler import handle_nws, _emoji_for_event
from meshai.central.nws_handler import handle_nws, _emoji_for_event, _render
from meshai.persistence import close_thread_connection, init_db
from meshai.persistence import db as persistence_db
@ -199,3 +199,98 @@ def test_wire_includes_event_and_headline(mem_db):
wire = handle_nws(env, env["subject"], data={}, now=1_000_000)
assert "Severe Thunderstorm Warning" in wire
assert "Twin Falls County" in wire
# ---- packet-budget enforcement ----
def test_svr_long_locations_path_sampled(mem_db):
"""SVR with a long town list: render must fit in 200 chars, and the town
list must be represented as a PATH SAMPLE (first -> middle -> last) rather
than a tail-drop. The old bug dropped the final town ('Shoshone')."""
# Long list; first town "Buhl", last town "and Shoshone" (exercises the
# leading-"and " strip on the tail element).
long_locations = (
"Buhl, Eden, Hazelton, Murtaugh, Richfield, Dietrich, "
"Gooding, Hagerman, Wendell, and Shoshone"
)
description = (
"HAZARD...Damaging winds to 60 mph and quarter-size hail.\n\n"
f"Locations impacted include...{long_locations}"
)
d = {
"eventCode": {"SAME": ["SVR"]},
"certainty": "Observed",
"parameters": {
"maxWindGust": ["60 MPH"],
"maxHailSize": ["1.00"],
# 254 DEG 35 KT -> "Moving W 40 mph"
"eventMotionDescription": ["2200000T254DEG...35KT 42.5,-114.5"],
},
"description": description,
}
rendered = _render(
event_type="Severe Thunderstorm Warning",
area_desc="Twin Falls County",
geocoder_city=None,
county="Twin Falls",
state="ID",
expires_epoch=1_751_400_000,
lat=42.5,
lon=-114.46,
now=1_751_400_000,
d=d,
)
# (a) fits in one mesh packet
assert len(rendered) <= 200, (
f"rendered is {len(rendered)} chars (expected <= 200):\n{rendered!r}"
)
# (b) all data-point categories present
assert "Severe Thunderstorm Warning" in rendered, "event type missing"
assert "Until" in rendered, "expiry time segment missing"
assert "Twin Falls County" in rendered, "area missing"
assert "mph winds" in rendered or "hail" in rendered, "hazard segment missing"
assert "Moving" in rendered, "motion segment missing"
# (c) path-sampling, not tail-drop: first town, last town, and the arrow
assert "" in rendered, "no arrow -> not path-sampled"
assert "Buhl" in rendered, "first town missing"
assert "Shoshone" in rendered, "last town dropped (the old tail-trim bug)"
def test_svr_short_locations_shown_in_full(mem_db):
"""Short town list that fits in one packet: show the FULL comma-joined
list, and never emit the path-sample arrow."""
short_locations = "Buhl, Eden, and Hazelton"
description = (
"HAZARD...Damaging winds to 60 mph and quarter-size hail.\n\n"
f"Locations impacted include...{short_locations}"
)
d = {
"eventCode": {"SAME": ["SVR"]},
"certainty": "Observed",
"parameters": {
"maxWindGust": ["60 MPH"],
"maxHailSize": ["1.00"],
"eventMotionDescription": ["2200000T254DEG...35KT 42.5,-114.5"],
},
"description": description,
}
rendered = _render(
event_type="Severe Thunderstorm Warning",
area_desc="Twin Falls County",
geocoder_city=None,
county="Twin Falls",
state="ID",
expires_epoch=1_751_400_000,
lat=42.5,
lon=-114.46,
now=1_751_400_000,
d=d,
)
assert len(rendered) <= 200
assert "" not in rendered, "short list should not be path-sampled"
assert "Buhl, Eden, Hazelton" in rendered, "full comma-joined list expected"