mirror of
https://github.com/zvx-echo6/meshai.git
synced 2026-08-26 17:31:34 +00:00
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:
parent
fa79fa1c9e
commit
bf88bdc9e6
4 changed files with 219 additions and 75 deletions
|
|
@ -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']}%"
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
144
tests/test_fire_digest_recency.py
Normal file
144
tests/test_fire_digest_recency.py
Normal 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"
|
||||
Loading…
Add table
Add a link
Reference in a new issue