fix(fires): recency-only ordering, exclude contained/tombstoned in LLM context and digest

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Matt Johnson (via Claude) 2026-06-12 04:17:50 +00:00
commit bf88bdc9e6
4 changed files with 219 additions and 75 deletions

View file

@ -179,34 +179,17 @@ class EnvReporter:
_cols = ("irwin_id, incident_name, current_acres, "
"current_contained_pct, lat, lon, county, state, "
"declared_at, last_event_at")
_where = "last_event_at >= ? AND tombstoned_at IS NULL"
# Query R: most-recent fires first.
recent = conn.execute(
_where = ("last_event_at >= ? AND tombstoned_at IS NULL "
"AND (current_contained_pct IS NULL OR current_contained_pct < 100)")
rows = conn.execute(
f"SELECT {_cols} FROM fires WHERE {_where} "
"ORDER BY last_event_at DESC LIMIT 6",
(_cutoff,),
"ORDER BY last_event_at DESC LIMIT ?",
(_cutoff, limit),
).fetchall()
# Query L: largest fires first.
largest = conn.execute(
f"SELECT {_cols} FROM fires WHERE {_where} "
"ORDER BY current_acres DESC NULLS LAST LIMIT 6",
(_cutoff,),
).fetchall()
# Merge: all of R in order, then L rows not already present.
seen_ids: set[str] = set()
merged: list[sqlite3.Row] = []
for r in recent:
if r["irwin_id"] not in seen_ids:
seen_ids.add(r["irwin_id"])
merged.append(r)
for r in largest:
if r["irwin_id"] not in seen_ids:
seen_ids.add(r["irwin_id"])
merged.append(r)
if merged:
lines.append("ACTIVE WILDFIRES (WFIGS, last 7d, most recent first, then largest):")
for r in merged:
if rows:
lines.append("ACTIVE WILDFIRES (WFIGS, last 7d, most recent first):")
for r in rows:
name = r["incident_name"] or "(unnamed)"
acres = "?" if r["current_acres"] is None else f"{int(r['current_acres']):,} ac"
cont = "?" if r["current_contained_pct"] is None else f"{r['current_contained_pct']}%"

View file

@ -82,43 +82,55 @@ async def render_digest(*, now: Optional[int] = None) -> tuple[str, str]:
now = now if now is not None else int(time.time())
conn = get_db()
cutoff = now - 7 * 86400
rows = conn.execute(
"SELECT incident_name, current_acres, current_contained_pct, county, state "
"FROM fires WHERE tombstoned_at IS NULL "
"FROM fires WHERE last_event_at >= ? "
"AND tombstoned_at IS NULL "
"AND (current_contained_pct IS NULL OR current_contained_pct < 100) "
"AND last_event_at > strftime('%s', 'now', '-7 days') "
"ORDER BY current_acres DESC NULLS LAST LIMIT 20"
"ORDER BY last_event_at DESC",
(cutoff,),
).fetchall()
if not rows:
return "", "no_fires"
n = len(rows)
header = f"\U0001f525 Fire Digest \u2014 {n} active wildfire(s) in Idaho"
total = len(rows)
top = rows[:2]
remaining = total - len(top)
header = f"\U0001f525 Fire Digest \u2014 {total} active wildfire(s)"
fire_lines: list[str] = []
for row in rows:
for row in top:
name = row["incident_name"] or "(unnamed)"
anchor = f"{row['county']} Co" if row["county"] else row["state"] or ""
contained = f"{int(row['current_contained_pct'])}% cont" if row["current_contained_pct"] is not None else "uncontained"
acres = f"{int(row['current_acres']):,} ac" if row["current_acres"] else "size unknown"
contained = (f"{int(row['current_contained_pct'])}% cont"
if row["current_contained_pct"] is not None else "uncontained")
acres = (f"{int(row['current_acres']):,} ac"
if row["current_acres"] else "size unknown")
fire_lines.append(f"{name}: {acres}, {contained}, {anchor}")
# 220-byte budget: greedily fit lines
tail = ""
if remaining > 0:
tail = f"There are {remaining} additional wildfires. DM me for the full list."
# Assemble within ~200-byte LoRa budget; trim fire lines, never the tail
shown: list[str] = []
for line in fire_lines:
remaining = n - len(shown) - 1
overflow = f"\n+ {remaining} more" if remaining > 0 else ""
candidate = "\n".join([header] + shown + [line]) + overflow
if len(candidate.encode("utf-8")) <= 220:
parts = [header] + shown + [line]
if tail:
parts.append(tail)
candidate = "\n".join(parts)
if len(candidate.encode("utf-8")) <= 200:
shown.append(line)
else:
break
remaining = n - len(shown)
lines = [header] + shown
if remaining > 0:
lines.append(f"+ {remaining} more")
wire = "\n".join(lines)
parts = [header] + shown
if tail:
parts.append(tail)
wire = "\n".join(parts)
return wire, "deterministic"

View file

@ -1,8 +1,8 @@
"""Tests for the hybrid recent+largest fire context in build_fires_detail.
"""Tests for recency-only fire ordering in build_fires_detail.
Validates that the env_reporter now surfaces small fresh fires alongside
large historic ones, excludes tombstoned fires, deduplicates, and respects
the block character cap.
Validates that the env_reporter surfaces fires ordered strictly by
last_event_at descending, excludes 100%-contained and tombstoned fires,
and respects the block character cap.
"""
from __future__ import annotations
@ -64,51 +64,56 @@ def _seed_scenario(conn):
county="Lincoln", state="ID")
class TestFireRecencyHybrid:
"""T1-T5: hybrid recent+largest query tests."""
class TestFireRecencyOnly:
"""T1-T5: recency-only ordering + contained exclusion."""
def test_t1_fresh_tiny_fires_appear(self, reporter):
"""T1: Both fresh tiny fires appear in build_fires_detail output."""
def test_t1_output_strictly_recency_ordered(self, reporter):
"""All listed fires appear in last_event_at descending order."""
conn = get_db()
_seed_scenario(conn)
text = reporter.build_fires_detail()
assert "Bingham Co. Assist 3" in text, (
"Fresh tiny fire 'Bingham Co. Assist 3' missing from output")
assert "IA 1" in text, (
"Fresh tiny fire 'IA 1' missing from output")
# Fresh fires (1h, 2h ago) must appear before older fires (3-6d)
pos_fresh1 = text.index("Bingham Co. Assist 3")
pos_fresh2 = text.index("IA 1")
assert pos_fresh1 < pos_fresh2, (
"Most recent fire should appear before second most recent")
# Any old fire that appears must come after both fresh fires
for i in range(5, 11): # only non-contained old fires
name = f"Old Fire {i}"
if name in text:
pos_old = text.index(name)
assert pos_fresh2 < pos_old, (
f"Fresh fires should appear before '{name}'")
def test_t2_largest_non_tombstoned_appears(self, reporter):
"""T2: The largest non-tombstoned fire appears (hybrid keeps big fires)."""
def test_t2_contained_100_excluded(self, reporter):
"""Fires with current_contained_pct == 100 are excluded."""
conn = get_db()
_seed_scenario(conn)
text = reporter.build_fires_detail()
# Old Fire 10 has acres=14050, the largest non-tombstoned
assert "Old Fire 10" in text, (
"Largest non-tombstoned fire 'Old Fire 10' missing from output")
# Old fires 0-4 have contained=100
for i in range(5):
assert f"Old Fire {i}:" not in text, (
f"100%-contained 'Old Fire {i}' should be excluded")
def test_t2b_null_containment_included(self, reporter):
"""Fires with NULL containment (uncontained) are included."""
conn = get_db()
_seed_scenario(conn)
text = reporter.build_fires_detail()
assert "Bingham Co. Assist 3" in text
assert "IA 1" in text
def test_t3_tombstoned_excluded(self, reporter):
"""T3: The tombstoned fire does NOT appear."""
"""Tombstoned fires do not appear."""
conn = get_db()
_seed_scenario(conn)
text = reporter.build_fires_detail()
assert "Tombstoned Blaze" not in text, (
"Tombstoned fire should be excluded")
assert "Tombstoned Blaze" not in text
def test_t4_no_duplicates(self, reporter):
"""T4: No incident is listed twice (dedup by irwin_id)."""
conn = get_db()
_seed_scenario(conn)
text = reporter.build_fires_detail()
# Each fire name should appear at most once in the output.
for name in ("Bingham Co. Assist 3", "IA 1", "Old Fire 10"):
count = text.count(name)
assert count <= 1, f"'{name}' appears {count} times (expected <=1)"
def test_t5_respects_block_cap(self, reporter):
"""T5: Output respects _block_cap() even with long names."""
def test_t4_respects_block_cap(self, reporter):
"""Output respects _block_cap() even with long names."""
conn = get_db()
now = int(time.time())
# Seed fires with very long names to exceed cap
for i in range(12):
long_name = f"Extremely Long Incident Name For Testing Purposes Number {i:02d} " + "X" * 100
_seed_fire(conn, irwin_id=f"LONG-{i:02d}", name=long_name,

View file

@ -0,0 +1,144 @@
"""Tests for the fire digest deterministic renderer recency ordering.
Validates that render_digest() lists the 2 most recent fires in
last_event_at descending order, excludes contained/tombstoned fires,
shows the correct "N additional" count, and stays within 200 bytes.
"""
from __future__ import annotations
import asyncio
import time
import pytest
from meshai.persistence import get_db
def _seed_fire(conn, *, irwin_id, name, acres, contained=None, lat=43.6, lon=-116.2,
county="Ada", state="ID", declared_at=None, last_event_at=None,
tombstoned_at=None):
now = int(time.time())
conn.execute(
"INSERT OR REPLACE INTO fires(irwin_id, incident_name, incident_type, "
"current_acres, current_contained_pct, lat, lon, county, state, "
"declared_at, last_event_at, tombstoned_at) "
"VALUES (?,?,?,?,?,?,?,?,?,?,?,?)",
(irwin_id, name, "WF", acres, contained, lat, lon, county, state,
declared_at or now, last_event_at or now, tombstoned_at),
)
def _seed_scenario(conn):
"""Seed fires: 3 active, 1 contained, 1 tombstoned."""
now = int(time.time())
day = 86400
# 3 active fires with distinct recency
_seed_fire(conn, irwin_id="F-01", name="Alpha Fire",
acres=500, contained=None,
last_event_at=now - 3600, # 1 hour ago (most recent)
county="Ada", state="ID")
_seed_fire(conn, irwin_id="F-02", name="Bravo Fire",
acres=200, contained=25,
last_event_at=now - 7200, # 2 hours ago
county="Boise", state="ID")
_seed_fire(conn, irwin_id="F-03", name="Charlie Fire",
acres=1000, contained=None,
last_event_at=now - 2 * day, # 2 days ago
county="Elmore", state="ID")
# 1 fire 100% contained (should be excluded)
_seed_fire(conn, irwin_id="F-04", name="Contained Fire",
acres=800, contained=100,
last_event_at=now - 1800, # 30 min ago — very recent!
county="Gem", state="ID")
# 1 tombstoned fire (should be excluded)
_seed_fire(conn, irwin_id="F-05", name="Tombstoned Fire",
acres=3000, contained=50,
last_event_at=now - day,
tombstoned_at=now - 3600,
county="Owyhee", state="ID")
class TestFireDigestRecency:
"""Deterministic fire digest renderer tests."""
def test_top_2_listed_in_recency_order(self):
"""The 2 most recent active fires are listed in order."""
conn = get_db()
now = int(time.time())
_seed_scenario(conn)
from meshai.notifications.scheduled.fire_digest import render_digest
wire, source = asyncio.run(render_digest(now=now))
assert source == "deterministic"
assert "Alpha Fire" in wire
assert "Bravo Fire" in wire
pos_alpha = wire.index("Alpha Fire")
pos_bravo = wire.index("Bravo Fire")
assert pos_alpha < pos_bravo, "Alpha Fire (most recent) should appear before Bravo Fire"
def test_contained_excluded(self):
"""100%-contained fires are excluded from the digest."""
conn = get_db()
now = int(time.time())
_seed_scenario(conn)
from meshai.notifications.scheduled.fire_digest import render_digest
wire, source = asyncio.run(render_digest(now=now))
assert "Contained Fire" not in wire
def test_tombstoned_excluded(self):
"""Tombstoned fires are excluded from the digest."""
conn = get_db()
now = int(time.time())
_seed_scenario(conn)
from meshai.notifications.scheduled.fire_digest import render_digest
wire, source = asyncio.run(render_digest(now=now))
assert "Tombstoned Fire" not in wire
def test_n_additional_count_correct(self):
"""The 'N additional' tail sentence has the right count."""
conn = get_db()
now = int(time.time())
_seed_scenario(conn)
from meshai.notifications.scheduled.fire_digest import render_digest
wire, source = asyncio.run(render_digest(now=now))
# 3 active fires total, 2 shown, 1 remaining
assert "There are 1 additional wildfires. DM me for the full list." in wire
def test_n_zero_omits_sentence(self):
"""When N == 0, the tail sentence is omitted entirely."""
conn = get_db()
now = int(time.time())
# Only 2 active fires
_seed_fire(conn, irwin_id="X-01", name="Fire One",
acres=100, contained=None,
last_event_at=now - 3600,
county="Ada", state="ID")
_seed_fire(conn, irwin_id="X-02", name="Fire Two",
acres=50, contained=10,
last_event_at=now - 7200,
county="Boise", state="ID")
from meshai.notifications.scheduled.fire_digest import render_digest
wire, source = asyncio.run(render_digest(now=now))
assert source == "deterministic"
assert "additional" not in wire
assert "Fire One" in wire
assert "Fire Two" in wire
def test_rendered_within_200_bytes(self):
"""Rendered output must be <= 200 bytes for LoRa budget."""
conn = get_db()
now = int(time.time())
_seed_scenario(conn)
from meshai.notifications.scheduled.fire_digest import render_digest
wire, source = asyncio.run(render_digest(now=now))
byte_len = len(wire.encode("utf-8"))
assert byte_len <= 200, f"Digest is {byte_len} bytes, exceeds 200-byte budget"
def test_no_fires_returns_empty(self):
"""No active fires -> empty wire, 'no_fires' source."""
from meshai.notifications.scheduled.fire_digest import render_digest
wire, source = asyncio.run(render_digest())
assert wire == ""
assert source == "no_fires"