mirror of
https://github.com/zvx-echo6/meshai.git
synced 2026-08-26 17:31:34 +00:00
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>
This commit is contained in:
parent
6ed8ad4945
commit
6653adba24
4 changed files with 153 additions and 21 deletions
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue