mirror of
https://github.com/zvx-echo6/meshai.git
synced 2026-06-11 09:24:44 +02:00
feat(v0.7-fire-tracker-4): fix LLM DM path + daily fire digest + ?status queries
Phase 4 of FIRMS+WFIGS fusion. Foundation: every direct LLM DM
mentioning a fire/weather/quake/avalanche/flood/etc. keyword was
failing silently in prod with UnboundLocalError because router.py
referenced scope_type before assigning it. With that path restored,
two new features land: a twice-daily fire-digest scheduled broadcast
(LLM-rendered) and a ?status <fire_name> on-demand mesh-DM intent.
BUG-FIX ROOT CAUSE (Job Zero):
router.py:745 ("if should_inject_mesh and scope_type == 'env'") read
`scope_type` -- a local variable bound only at line 761 inside an
unrelated `if self.source_manager and self.mesh_reporter` block.
Python's lexical scoping made scope_type a local of the whole
generate_llm_response function, so reading it before the assignment
raised UnboundLocalError on every env-keyword DM. The exception
propagated to main.py's outer except, no response went out, bot
appeared dead on fire/weather/quake/avalanche/flood queries.
Evidence (synthetic in-process trace against the live container's
config + GoogleBackend):
"are there any fires near me?" -> UnboundLocalError (pre-fix)
-> real LLM answer (post-fix)
"Yes, there are a few active
fires reported in the region.
Salmon River: 4,200 acres, 78%
contained. Cache Peak: 1,847
acres, 23% contained. ..."
"what's the weather?" -> UnboundLocalError (pre-fix)
-> "I do not have current weather
information. I can tell you
about active fires, stream gauge
levels, space weather, or band
conditions if you'd like." (post-fix)
"hi there" -> normal LLM answer in both cases
Fix: hoist `scope_type, scope_value = self._detect_mesh_scope(query)`
to right after `should_inject_mesh` is computed; remove the
now-duplicate detection inside the source_manager block.
Secondary mitigation: tightened the "do not invent commands" prompt
with an explicit "if no list appears above, you have NO commands"
clause. The prior prompt told the LLM "answer based on the command
list provided below" without always providing one, so the LLM
hallucinated plausible-sounding !commands (the "use ! commands"
canned-looking response Matt was seeing on non-env queries).
PHASE 4 FEATURES:
1. Fire-digest scheduler (meshai/notifications/scheduled/fire_digest.py).
Modeled after BandConditionsScheduler. Runs in the pipeline's
start_pipeline coroutine alongside band_conditions + reminders.
On each slot (default 06:00 + 18:00 America/Boise):
- Queries active fires (tombstoned_at IS NULL) + last 24h passes.
- Builds a prompt asking for a single mesh-wire summary <= 200
chars.
- Calls the LLM (Google/Anthropic/OpenAI per config).
- Falls back to a terse "Fires today (N): Cache Peak 1847 ac;
Twin Peaks 320 ac; +N more" line when the LLM is unavailable.
- Dispatches via dispatcher.dispatch_scheduled_broadcast (same
path band_conditions uses).
Idempotency: v16.sql adds fire_digest_broadcasts(slot_epoch PK,
sent_at, summary, source). INSERT OR IGNORE pattern blocks the same
slot firing twice (matters when container restarts mid-day).
2. ?status <fire_name> on-demand intent (router.py).
Before falling through to the LLM, route() now checks for a leading
"?status" / "status:" sigil or natural-language triggers like
"how is X fire?". On match:
- _lookup_fire_fuzzy walks fires by exact -> startswith ->
contains -> word-overlap (skipping a trailing " fire" word so
"cache peak fire" matches "Cache Peak"). Active fires rank
above tombstoned ones.
- _build_fire_status_context composes a small context block
(name, acres, containment, county/state, last 3 passes with
drift).
- The query is REWRITTEN into an LLM prompt with that context
inlined; the rest of the normal LLM path (chunking, history,
summary persistence) runs unchanged.
Live verification: "?status Cache Peak" -> "The Cache Peak fire is
1,847 acres and 23% contained. It's located in Probe / ID.";
"?status Salmon" -> word-overlap matches "Salmon River" ->
"The Salmon River fire is 4,200 acres and 78% contained, located
in Probe / ID."
3. adapter_config rows (GUI-editable per CONFIG-vs-CODE rule):
fires.digest_enabled = true (master toggle)
fires.digest_schedule = ["06:00", "18:00"]
fires.digest_timezone = "America/Boise"
fires.digest_max_chars = 200
Schema (v16.sql):
- fire_digest_broadcasts(slot_epoch INTEGER PK, sent_at, summary,
source) with source in {'llm', 'fallback_terse', 'skipped_no_fires'}.
- Index on sent_at for ops queries.
Tests (tests/test_fire_tracker_phase4.py, 10 cases all green):
- Regression guard: scope_type appears as an assignment BEFORE the
env_reporter check (prevents the UnboundLocalError from coming back).
- adapter_config seeds all 4 digest keys with expected defaults.
- render_digest returns ('', 'no_fires') when no active fires.
- render_digest falls back to terse line when LLM is None; wire fits cap.
- render_digest with a stub LLM returns ('<llm text>', 'llm').
- _lookup_fire_fuzzy: exact, "X fire" trim, word-overlap, no-match.
- _maybe_rewrite_status_query: builds context-bearing prompt; returns
None on non-status queries.
Combined suite: 60 passed in 3.81s across phase1+phase2+phase3+phase4
+or-arch+include-roundtrip.
Live verification on CT108 after rebuild:
- v16 migration applied (schema_meta=16, no Traceback in 3 min).
- FireDigestScheduler started: enabled=True schedule=['06:00','18:00']
tz=America/Boise.
- LLM DM probe (real Gemini) returns real answers on env queries
(Bug A fixed end-to-end).
- ?status Cache Peak + ?status Salmon return fire-specific summaries.
- render_digest with real LLM returns source=llm + non-empty wire.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
31e543ca04
commit
f69a05dd6d
8 changed files with 769 additions and 12 deletions
187
meshai/router.py
187
meshai/router.py
|
|
@ -373,6 +373,16 @@ class MessageRouter:
|
|||
if not query:
|
||||
return RouteResult(RouteType.IGNORE)
|
||||
|
||||
# v0.7-fire-tracker-4: ?status <fire_name> intent.
|
||||
# Matches the leading "?status" sigil or a bare "status <name>";
|
||||
# falls through to the normal LLM path on no match. We do the
|
||||
# fire lookup here but return RouteType.LLM with a synthesized
|
||||
# query so generate_llm_response runs the normal injection +
|
||||
# chunking path with the fire's context attached.
|
||||
status_query = _maybe_rewrite_status_query(query, self)
|
||||
if status_query is not None:
|
||||
return RouteResult(RouteType.LLM, query=status_query)
|
||||
|
||||
# Route to LLM
|
||||
return RouteResult(RouteType.LLM, query=query)
|
||||
|
||||
|
|
@ -682,7 +692,9 @@ class MessageRouter:
|
|||
cmd_lines.append("")
|
||||
cmd_lines.append(
|
||||
"CRITICAL: ONLY mention commands in the list above when asked about commands. "
|
||||
"If a command is not listed here, it does NOT exist. Do not invent commands."
|
||||
"If a command is not listed here, it does NOT exist. Do not invent commands. "
|
||||
"If no command list appears above, you have NO commands -- say so plainly "
|
||||
"instead of guessing names."
|
||||
)
|
||||
system_prompt += "\n".join(cmd_lines)
|
||||
|
||||
|
|
@ -739,6 +751,26 @@ class MessageRouter:
|
|||
|
||||
should_inject_mesh = is_direct_mesh_question or is_followup
|
||||
|
||||
# v0.7-fire-tracker-4: scope detection hoisted above its first
|
||||
# use. Pre-fix, the env_reporter check below referenced scope_type
|
||||
# while the assignment lived ~15 lines later inside the
|
||||
# source_manager branch -- UnboundLocalError on every env query
|
||||
# ("are there any fires?", "what's the weather?", etc.), the
|
||||
# exception got caught in main.py and the bot went silent.
|
||||
scope_type: str = "mesh"
|
||||
scope_value = None
|
||||
if should_inject_mesh:
|
||||
scope_type, scope_value = self._detect_mesh_scope(query)
|
||||
# For follow-ups with no detected scope, use previous scope.
|
||||
if is_followup and scope_type == "mesh" and scope_value is None:
|
||||
prev_scope = user_ctx.get("last_scope", ("mesh", None))
|
||||
if prev_scope[0] != "mesh" or prev_scope[1] is not None:
|
||||
scope_type, scope_value = prev_scope
|
||||
logger.debug(
|
||||
f"Using previous scope for follow-up: "
|
||||
f"{scope_type}, {scope_value}"
|
||||
)
|
||||
|
||||
# v0.6-5 env_reporter: when scope is "env" OR when injecting mesh
|
||||
# context, append the env_reporter blocks. The reporter itself gates
|
||||
# per-adapter via adapter_meta.include_in_llm_context.
|
||||
|
|
@ -757,15 +789,8 @@ class MessageRouter:
|
|||
logger.exception("env_reporter injection failed")
|
||||
|
||||
if self.source_manager and self.mesh_reporter and should_inject_mesh:
|
||||
# Detect scope from current message
|
||||
scope_type, scope_value = self._detect_mesh_scope(query)
|
||||
|
||||
# For follow-ups with no detected scope, use previous scope
|
||||
if is_followup and scope_type == "mesh" and scope_value is None:
|
||||
prev_scope = user_ctx.get("last_scope", ("mesh", None))
|
||||
if prev_scope[0] != "mesh" or prev_scope[1] is not None:
|
||||
scope_type, scope_value = prev_scope
|
||||
logger.debug(f"Using previous scope for follow-up: {scope_type}, {scope_value}")
|
||||
# v0.7-fire-tracker-4: scope already detected above; no
|
||||
# second call needed.
|
||||
|
||||
# Always include Tier 1 summary for mesh questions
|
||||
tier1 = self.mesh_reporter.build_tier1_summary()
|
||||
|
|
@ -933,3 +958,145 @@ class MessageRouter:
|
|||
history=self.history,
|
||||
)
|
||||
|
||||
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# v0.7-fire-tracker-4: ?status <fire> intent helper
|
||||
# ============================================================================
|
||||
|
||||
|
||||
_STATUS_PREFIXES = ("?status ", "status ", "?status:", "status:")
|
||||
|
||||
|
||||
def _maybe_rewrite_status_query(query: str, router) -> "Optional[str]":
|
||||
"""If `query` looks like a fire status request, rewrite it with the
|
||||
fire's persisted context inlined. Return None to let the normal LLM
|
||||
path handle the message verbatim.
|
||||
|
||||
Triggers on the leading word patterns in _STATUS_PREFIXES OR an
|
||||
interrogative referencing a known fire (e.g. "how is the X fire?").
|
||||
"""
|
||||
q = query.strip()
|
||||
ql = q.lower()
|
||||
target_phrase = None
|
||||
for prefix in _STATUS_PREFIXES:
|
||||
if ql.startswith(prefix):
|
||||
target_phrase = q[len(prefix):].strip()
|
||||
break
|
||||
|
||||
if target_phrase is None:
|
||||
# Heuristic for "how is <name> fire?" style without a sigil.
|
||||
triggers = ("how is ", "tell me about ", "status of ",
|
||||
"what about ", "any update on ")
|
||||
for t in triggers:
|
||||
if ql.startswith(t):
|
||||
target_phrase = q[len(t):].rstrip("?!. ").strip()
|
||||
if "fire" in target_phrase.lower():
|
||||
break
|
||||
target_phrase = None
|
||||
if target_phrase is None:
|
||||
return None
|
||||
|
||||
if not target_phrase:
|
||||
return None
|
||||
|
||||
fire = _lookup_fire_fuzzy(target_phrase)
|
||||
if fire is None:
|
||||
# No match -- leave the query alone; the LLM with env_reporter
|
||||
# injection may still answer reasonably.
|
||||
return None
|
||||
|
||||
context = _build_fire_status_context(fire)
|
||||
return (
|
||||
f"User asked for the status of {fire['incident_name']}. "
|
||||
f"Reply with ONE short paragraph (<= 300 chars total) for mesh "
|
||||
f"radio operators. No markdown.\n\n"
|
||||
f"FIRE DATA:\n{context}\n\n"
|
||||
f"Original question: {query}"
|
||||
)
|
||||
|
||||
|
||||
def _lookup_fire_fuzzy(phrase: str):
|
||||
"""Find a fire whose incident_name fuzzy-matches phrase. Returns the
|
||||
sqlite3.Row or None.
|
||||
|
||||
Match priority: exact (case-insensitive) -> startswith ->
|
||||
contains -> word-overlap. Active fires (tombstoned_at IS NULL)
|
||||
rank above closed ones."""
|
||||
from meshai.persistence import get_db
|
||||
conn = get_db()
|
||||
phrase_l = phrase.lower().strip().rstrip("?!.").rstrip()
|
||||
# Drop trailing " fire" so "cache peak fire" matches "Cache Peak".
|
||||
if phrase_l.endswith(" fire"):
|
||||
phrase_l = phrase_l[:-5].strip()
|
||||
|
||||
candidates = conn.execute(
|
||||
"SELECT irwin_id, incident_name, current_acres, "
|
||||
"current_contained_pct, state, county, "
|
||||
"tombstoned_at, last_pass_at "
|
||||
"FROM fires "
|
||||
"ORDER BY (tombstoned_at IS NULL) DESC, "
|
||||
"COALESCE(current_acres, 0) DESC",
|
||||
).fetchall()
|
||||
if not candidates:
|
||||
return None
|
||||
|
||||
# Tier 1: exact match.
|
||||
for c in candidates:
|
||||
if (c["incident_name"] or "").lower() == phrase_l:
|
||||
return c
|
||||
# Tier 2: startswith.
|
||||
for c in candidates:
|
||||
if (c["incident_name"] or "").lower().startswith(phrase_l):
|
||||
return c
|
||||
# Tier 3: contains.
|
||||
for c in candidates:
|
||||
if phrase_l in (c["incident_name"] or "").lower():
|
||||
return c
|
||||
# Tier 4: word-overlap (>= 1 token).
|
||||
tokens = set(phrase_l.split())
|
||||
if tokens:
|
||||
best = None
|
||||
best_overlap = 0
|
||||
for c in candidates:
|
||||
name_tokens = set((c["incident_name"] or "").lower().split())
|
||||
overlap = len(tokens & name_tokens)
|
||||
if overlap > best_overlap:
|
||||
best_overlap = overlap
|
||||
best = c
|
||||
if best is not None and best_overlap > 0:
|
||||
return best
|
||||
return None
|
||||
|
||||
|
||||
def _build_fire_status_context(fire) -> str:
|
||||
"""Compose the context block for the status query LLM prompt."""
|
||||
from meshai.persistence import get_db
|
||||
conn = get_db()
|
||||
passes = conn.execute(
|
||||
"SELECT pass_id, drift_mi_from_prev, drift_direction, "
|
||||
"drift_mi_per_hour, pixel_count, pass_ended_at "
|
||||
"FROM fire_passes WHERE irwin_id=? "
|
||||
"ORDER BY pass_ended_at DESC LIMIT 3",
|
||||
(fire["irwin_id"],),
|
||||
).fetchall()
|
||||
lines = [
|
||||
f"name: {fire['incident_name']}",
|
||||
f"acres: {fire['current_acres'] or 0}",
|
||||
f"contained: {fire['current_contained_pct'] or 0}%",
|
||||
f"county/state: {fire['county'] or '?'}/{fire['state'] or '?'}",
|
||||
f"closed: {bool(fire['tombstoned_at'])}",
|
||||
]
|
||||
if passes:
|
||||
lines.append("recent passes (newest first):")
|
||||
for p in passes:
|
||||
drift = ""
|
||||
if (p["drift_mi_from_prev"] is not None
|
||||
and p["drift_direction"] is not None):
|
||||
drift = (f", drift {p['drift_mi_from_prev']:.1f}mi "
|
||||
f"{p['drift_direction']}")
|
||||
lines.append(
|
||||
f" - pass {p['pass_id']}: {p['pixel_count']} pixel(s)"
|
||||
f"{drift}")
|
||||
return "\n".join(lines)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue