mirror of
https://github.com/zvx-echo6/meshai.git
synced 2026-08-26 17:31:34 +00:00
fire: remove fire digest feature and drop out-of-coverage fires at ingest (#107)
Two fire-scope cleanups: 1. Remove the fire digest feature entirely -- scheduler (notifications/scheduled/fire_digest.py), pipeline wiring, the fires.digest_* adapter_config key registrations, and the Fire Digest dashboard UI (ScheduledBroadcasts / Environment / Reference / AdapterConfig / ActivityLog). The unrelated generic per-rule notification digest is kept. Orphaned fires.digest_* config rows and the fire_digest_broadcasts table are left as inert data (v16 migration untouched). 2. Add a coverage-scope gate at fire ingest: _ingest_fires now skips any fire whose coordinates fall outside all configured coverage areas (same areas_from_config + classify_geom_areas membership the dispatch-level CoverageFilter uses), so out-of-coverage fires are never stored, tracked, alerted, reminded, or re-ingested. Fails open when coverage is disabled, has no areas, or excludes the fires adapter. Co-authored-by: Matt Johnson <mj@k7zvx.com> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
af826319c8
commit
b0b0697bac
16 changed files with 189 additions and 960 deletions
|
|
@ -112,7 +112,6 @@ const CATEGORIES = [
|
|||
{ value: 'all', label: 'All types' },
|
||||
{ value: 'nws_alerts', label: 'Weather' },
|
||||
{ value: 'fires', label: 'Fires' },
|
||||
{ value: 'fire_digest_broadcasts', label: 'Fire digest' },
|
||||
{ value: 'satpass_events', label: 'Satellite' },
|
||||
{ value: 'band_conditions_broadcasts', label: 'Band' },
|
||||
{ value: 'traffic_events', label: 'Traffic' },
|
||||
|
|
|
|||
|
|
@ -22,7 +22,6 @@ import {
|
|||
// Advanced (raw) view to avoid double-editing.
|
||||
export const CURATED_KEYS: Record<string, string[]> = {
|
||||
wfigs: ['allowed_incident_types', 'freshness_seconds', 'cooldown_seconds', 'broadcast_on_acres', 'broadcast_on_contained'],
|
||||
fires: ['digest_enabled', 'digest_schedule', 'digest_timezone'],
|
||||
tomtom_incidents: ['min_magnitude', 'drop_non_present', 'drop_zero_magnitude'],
|
||||
itd_511: ['min_severity', 'enabled_categories', 'enabled_sub_types'],
|
||||
wzdx: ['broadcast', 'min_severity', 'sub_types'],
|
||||
|
|
|
|||
|
|
@ -74,13 +74,6 @@ interface WfigsConfig {
|
|||
broadcast_on_contained: boolean
|
||||
}
|
||||
|
||||
// Fires adapter config shape (digest settings)
|
||||
interface FiresConfig {
|
||||
digest_enabled: boolean
|
||||
digest_schedule: string[]
|
||||
digest_timezone: string
|
||||
}
|
||||
|
||||
// ITD 511 adapter config shape
|
||||
interface Roads511Config {
|
||||
min_severity: string
|
||||
|
|
@ -345,12 +338,6 @@ export default function Environment() {
|
|||
broadcast_on_contained: true,
|
||||
})
|
||||
const [wfigsOriginal, setWfigsOriginal] = useState<string>("")
|
||||
const [firesConfig, setFiresConfig] = useState<FiresConfig>({
|
||||
digest_enabled: true,
|
||||
digest_schedule: ["06:00", "18:00"],
|
||||
digest_timezone: "America/Boise",
|
||||
})
|
||||
const [firesOriginal, setFiresOriginal] = useState<string>("")
|
||||
const [tomtomConfig, setTomtomConfig] = useState<TomtomConfig>({
|
||||
min_magnitude: 4,
|
||||
drop_non_present: true,
|
||||
|
|
@ -457,21 +444,6 @@ export default function Environment() {
|
|||
}
|
||||
} catch { /* adapter-config optional */ }
|
||||
|
||||
// Load adapter-config for fires/digest (array → object fix: line ~367)
|
||||
try {
|
||||
const firesRes = await fetch("/api/adapter-config/fires")
|
||||
if (firesRes.ok) {
|
||||
const firesData = toMap(await firesRes.json())
|
||||
const cfg: FiresConfig = {
|
||||
digest_enabled: (firesData.digest_enabled?.value as boolean) ?? true,
|
||||
digest_schedule: (firesData.digest_schedule?.value as string[]) ?? ["06:00", "18:00"],
|
||||
digest_timezone: (firesData.digest_timezone?.value as string) ?? "America/Boise",
|
||||
}
|
||||
setFiresConfig(cfg)
|
||||
setFiresOriginal(JSON.stringify(cfg))
|
||||
}
|
||||
} catch { /* adapter-config optional */ }
|
||||
|
||||
// Load adapter-config for tomtom_incidents (array → object fix: line ~382)
|
||||
try {
|
||||
const ttRes = await fetch("/api/adapter-config/tomtom_incidents")
|
||||
|
|
@ -658,7 +630,6 @@ export default function Environment() {
|
|||
|
||||
const hasEnvChanges = env !== null && JSON.stringify(env) !== original
|
||||
const hasWfigsChanges = JSON.stringify(wfigsConfig) !== wfigsOriginal
|
||||
const hasFiresChanges = JSON.stringify(firesConfig) !== firesOriginal
|
||||
const hasTomtomChanges = JSON.stringify(tomtomConfig) !== tomtomOriginal
|
||||
const hasRoads511Changes = JSON.stringify(roads511Config) !== roads511Original
|
||||
const hasWzdxChanges = JSON.stringify(wzdxConfig) !== wzdxOriginal
|
||||
|
|
@ -666,7 +637,7 @@ export default function Environment() {
|
|||
const hasAvalancheChanges = JSON.stringify(avalancheConfig) !== avalancheOriginal
|
||||
const hasSwpcChanges = JSON.stringify(swpcConfig) !== swpcOriginal
|
||||
const hasSatpassChanges = JSON.stringify(satpassConfig) !== satpassOriginal
|
||||
const hasChanges = hasEnvChanges || hasWfigsChanges || hasFiresChanges || hasTomtomChanges || hasRoads511Changes || hasWzdxChanges || hasNwsChanges || hasAvalancheChanges || hasSwpcChanges || hasSatpassChanges
|
||||
const hasChanges = hasEnvChanges || hasWfigsChanges || hasTomtomChanges || hasRoads511Changes || hasWzdxChanges || hasNwsChanges || hasAvalancheChanges || hasSwpcChanges || hasSatpassChanges
|
||||
|
||||
|
||||
const saveAdapterConfig = async (adapterName: string, key: string, value: unknown) => {
|
||||
|
|
@ -731,21 +702,6 @@ const save = async () => {
|
|||
setWfigsOriginal(JSON.stringify(wfigsConfig))
|
||||
}
|
||||
|
||||
// Save fires adapter config changes (digest)
|
||||
if (hasFiresChanges) {
|
||||
const orig = JSON.parse(firesOriginal) as FiresConfig
|
||||
if (firesConfig.digest_enabled !== orig.digest_enabled) {
|
||||
await saveAdapterConfig("fires", "digest_enabled", firesConfig.digest_enabled)
|
||||
}
|
||||
if (JSON.stringify(firesConfig.digest_schedule) !== JSON.stringify(orig.digest_schedule)) {
|
||||
await saveAdapterConfig("fires", "digest_schedule", firesConfig.digest_schedule)
|
||||
}
|
||||
if (firesConfig.digest_timezone !== orig.digest_timezone) {
|
||||
await saveAdapterConfig("fires", "digest_timezone", firesConfig.digest_timezone)
|
||||
}
|
||||
setFiresOriginal(JSON.stringify(firesConfig))
|
||||
}
|
||||
|
||||
// Save tomtom adapter config changes
|
||||
if (hasTomtomChanges) {
|
||||
const orig = JSON.parse(tomtomOriginal) as TomtomConfig
|
||||
|
|
@ -863,7 +819,6 @@ const save = async () => {
|
|||
const discard = () => {
|
||||
if (env) setEnv(JSON.parse(original))
|
||||
setWfigsConfig(JSON.parse(wfigsOriginal || JSON.stringify(wfigsConfig)))
|
||||
setFiresConfig(JSON.parse(firesOriginal || JSON.stringify(firesConfig)))
|
||||
setTomtomConfig(JSON.parse(tomtomOriginal || JSON.stringify(tomtomConfig)))
|
||||
setRoads511Config(JSON.parse(roads511Original || JSON.stringify(roads511Config)))
|
||||
setWzdxConfig(JSON.parse(wzdxOriginal || JSON.stringify(wzdxConfig)))
|
||||
|
|
@ -1136,32 +1091,6 @@ const save = async () => {
|
|||
<NumberInput label="Update Cooldown (hours)" value={Math.round(wfigsConfig.cooldown_seconds / 3600)} onChange={(v) => setWfigsConfig({ ...wfigsConfig, cooldown_seconds: v * 3600 })} min={0} helper="Minimum hours between updates for the same fire" />
|
||||
<NumberInput label="Freshness Window (hours)" value={Math.round(wfigsConfig.freshness_seconds / 3600)} onChange={(v) => setWfigsConfig({ ...wfigsConfig, freshness_seconds: v * 3600 })} min={0} helper="0 = always broadcast regardless of event age" />
|
||||
</div>
|
||||
<div className="border-t border-border pt-4 mt-2">
|
||||
<div className="text-[10px] font-sans font-medium uppercase tracking-widest text-[#666] mb-3">Fire Digest</div>
|
||||
<label className="flex items-center justify-between">
|
||||
<span className="text-sm font-sans text-[#e0e0e0]">Enable daily digest</span>
|
||||
<input type="checkbox" checked={firesConfig.digest_enabled}
|
||||
onChange={(e) => setFiresConfig({ ...firesConfig, digest_enabled: e.target.checked })}
|
||||
className="w-4 h-4 accent-[#f59e0b]" />
|
||||
</label>
|
||||
{firesConfig.digest_enabled && (
|
||||
<div className="mt-3 space-y-3">
|
||||
<ListInput label="Schedule (HH:MM)" value={firesConfig.digest_schedule}
|
||||
onChange={(v) => setFiresConfig({ ...firesConfig, digest_schedule: v })}
|
||||
helper="Digest times in HH:MM format, e.g. 06:00 and 18:00" />
|
||||
<SelectInput label="Timezone" value={firesConfig.digest_timezone}
|
||||
onChange={(v) => setFiresConfig({ ...firesConfig, digest_timezone: v })}
|
||||
options={[
|
||||
{ value: 'America/Boise', label: 'Mountain — America/Boise' },
|
||||
{ value: 'America/Los_Angeles', label: 'Pacific — America/Los_Angeles' },
|
||||
{ value: 'America/Denver', label: 'Mountain — America/Denver' },
|
||||
{ value: 'America/Chicago', label: 'Central — America/Chicago' },
|
||||
{ value: 'America/New_York', label: 'Eastern — America/New_York' },
|
||||
{ value: 'UTC', label: 'UTC' },
|
||||
]} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
case 'avalanche': return (
|
||||
|
|
|
|||
|
|
@ -367,7 +367,7 @@ export default function Reference() {
|
|||
</ul>
|
||||
</TopicSection>
|
||||
|
||||
{/* Fire Tracker (v0.7 fusion: FIRMS + WFIGS + LLM digest) */}
|
||||
{/* Fire Tracker (v0.7 fusion: FIRMS + WFIGS) */}
|
||||
<TopicSection id="fire-tracker" title="Fire Tracker (Fusion)">
|
||||
<p>
|
||||
FIRMS hotspots are fast but noisy; WFIGS incidents are accurate but slow.
|
||||
|
|
@ -402,16 +402,6 @@ export default function Reference() {
|
|||
]}
|
||||
/>
|
||||
|
||||
<SectionHeader>Daily LLM digest</SectionHeader>
|
||||
<p>
|
||||
Twice a day (default 06:00 and 18:00 Mountain Time) the bot runs an LLM
|
||||
summary across every active fire and the last 24 h of growth + spotting
|
||||
events, then broadcasts one terse line to the mesh. Shape:{' '}
|
||||
<span className="text-amber-300">"Fires today: Cache Peak 1,847 ac +200 NE; Twin Peaks 320 ac stable; possible new fire 15 mi from Cache Peak."</span>{' '}
|
||||
Configure the schedule and timezone under <Mono>fires.digest_*</Mono>{' '}
|
||||
keys on the Adapter Config page.
|
||||
</p>
|
||||
|
||||
<SectionHeader>How attribution works</SectionHeader>
|
||||
<p>
|
||||
When a FIRMS hotspot lands, the bot walks every active fire (those not
|
||||
|
|
@ -465,10 +455,6 @@ export default function Reference() {
|
|||
[<Mono>halt_minimum_seconds</Mono>, '43,200 (12 h)', 'Minimum elapsed seconds since the most recent attributed pixel before wildfire_halted can fire.'],
|
||||
[<Mono>spotting_distance_threshold_mi</Mono>, '1.5 mi', 'Distance from prior-pass perimeter that fires wildfire_spotting.'],
|
||||
[<Mono>spotting_cooldown_seconds</Mono>, '3,600 (1 h)', 'Minimum seconds between consecutive spotting broadcasts per fire.'],
|
||||
[<Mono>digest_enabled</Mono>, 'true', 'Master toggle for the twice-daily digest.'],
|
||||
[<Mono>digest_schedule</Mono>, '["06:00","18:00"]', 'Local-time slots for the digest.'],
|
||||
[<Mono>digest_timezone</Mono>, 'America/Boise', 'IANA tz for digest_schedule.'],
|
||||
[<Mono>digest_max_chars</Mono>, '200', 'Hard cap on the digest wire (the LLM is told to fit; the chunker enforces).'],
|
||||
]}
|
||||
/>
|
||||
</TopicSection>
|
||||
|
|
|
|||
|
|
@ -4,17 +4,10 @@ import { fetchConfig as apiFetchConfig, updateConfig as apiUpdateConfig } from '
|
|||
import { useDirty } from '@/context/DirtyContext'
|
||||
import { notifyRestartRequired } from '@/components/RestartBanner'
|
||||
import {
|
||||
Toggle, NumberInput, TimeInput, InfoButton,
|
||||
Toggle, NumberInput, TimeInput,
|
||||
type NotificationsConfig,
|
||||
} from '@/pages/Notifications'
|
||||
|
||||
// Fires adapter config shape (digest settings)
|
||||
interface FiresConfig {
|
||||
digest_enabled: boolean
|
||||
digest_schedule: string[]
|
||||
digest_timezone: string
|
||||
}
|
||||
|
||||
interface Props {
|
||||
family?: 'meshtastic' | 'meshcore'
|
||||
}
|
||||
|
|
@ -26,14 +19,6 @@ export default function ScheduledBroadcasts({ family = 'meshtastic' }: Props) {
|
|||
const [notifConfig, setNotifConfig] = useState<NotificationsConfig | null>(null)
|
||||
const [originalNotifConfig, setOriginalNotifConfig] = useState<NotificationsConfig | null>(null)
|
||||
|
||||
// Fires adapter config state
|
||||
const [firesConfig, setFiresConfig] = useState<FiresConfig>({
|
||||
digest_enabled: true,
|
||||
digest_schedule: ['06:00', '18:00'],
|
||||
digest_timezone: 'America/Boise',
|
||||
})
|
||||
const [originalFiresConfig, setOriginalFiresConfig] = useState<string>('')
|
||||
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
|
@ -49,24 +34,6 @@ export default function ScheduledBroadcasts({ family = 'meshtastic' }: Props) {
|
|||
setNotifConfig(notif)
|
||||
setOriginalNotifConfig(JSON.parse(JSON.stringify(notif)))
|
||||
|
||||
// Load fires adapter config
|
||||
try {
|
||||
const firesRes = await fetch('/api/adapter-config/fires')
|
||||
if (firesRes.ok) {
|
||||
const firesData = await firesRes.json()
|
||||
const fires: FiresConfig = {
|
||||
digest_enabled: firesData.digest_enabled?.value ?? true,
|
||||
digest_schedule: firesData.digest_schedule?.value ?? ['06:00', '18:00'],
|
||||
digest_timezone: firesData.digest_timezone?.value ?? 'America/Boise',
|
||||
}
|
||||
setFiresConfig(fires)
|
||||
setOriginalFiresConfig(JSON.stringify(fires))
|
||||
}
|
||||
} catch {
|
||||
// adapter-config optional — proceed with defaults
|
||||
setOriginalFiresConfig(JSON.stringify(firesConfig))
|
||||
}
|
||||
|
||||
setHasChanges(false)
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to load config')
|
||||
|
|
@ -83,28 +50,15 @@ export default function ScheduledBroadcasts({ family = 'meshtastic' }: Props) {
|
|||
useEffect(() => {
|
||||
if (notifConfig && originalNotifConfig) {
|
||||
const notifChanged = JSON.stringify(notifConfig) !== JSON.stringify(originalNotifConfig)
|
||||
const firesChanged = JSON.stringify(firesConfig) !== originalFiresConfig
|
||||
setHasChanges(notifChanged || firesChanged)
|
||||
setHasChanges(notifChanged)
|
||||
}
|
||||
}, [notifConfig, originalNotifConfig, firesConfig, originalFiresConfig])
|
||||
}, [notifConfig, originalNotifConfig])
|
||||
|
||||
useEffect(() => {
|
||||
setDirty(hasChanges)
|
||||
return () => setDirty(false)
|
||||
}, [hasChanges, setDirty])
|
||||
|
||||
const saveAdapterKey = async (adapter: string, key: string, value: unknown) => {
|
||||
const res = await fetch(`/api/adapter-config/${adapter}/${key}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ value }),
|
||||
})
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({}))
|
||||
throw new Error(err.detail || `Failed to save ${adapter}.${key}`)
|
||||
}
|
||||
}
|
||||
|
||||
const saveConfig = async () => {
|
||||
if (!notifConfig) return
|
||||
setSaving(true)
|
||||
|
|
@ -116,19 +70,6 @@ export default function ScheduledBroadcasts({ family = 'meshtastic' }: Props) {
|
|||
setOriginalNotifConfig(JSON.parse(JSON.stringify(notifConfig)))
|
||||
if (result.restart_required) notifyRestartRequired([])
|
||||
|
||||
// Save fires adapter config — only changed keys
|
||||
const origFires = originalFiresConfig ? (JSON.parse(originalFiresConfig) as FiresConfig) : null
|
||||
if (!origFires || firesConfig.digest_enabled !== origFires.digest_enabled) {
|
||||
await saveAdapterKey('fires', 'digest_enabled', firesConfig.digest_enabled)
|
||||
}
|
||||
if (!origFires || JSON.stringify(firesConfig.digest_schedule) !== JSON.stringify(origFires.digest_schedule)) {
|
||||
await saveAdapterKey('fires', 'digest_schedule', firesConfig.digest_schedule)
|
||||
}
|
||||
if (!origFires || firesConfig.digest_timezone !== origFires.digest_timezone) {
|
||||
await saveAdapterKey('fires', 'digest_timezone', firesConfig.digest_timezone)
|
||||
}
|
||||
setOriginalFiresConfig(JSON.stringify(firesConfig))
|
||||
|
||||
setHasChanges(false)
|
||||
setDirty(false)
|
||||
setSuccess('Scheduled broadcasts saved successfully')
|
||||
|
|
@ -142,7 +83,6 @@ export default function ScheduledBroadcasts({ family = 'meshtastic' }: Props) {
|
|||
|
||||
const discardChanges = () => {
|
||||
if (originalNotifConfig) setNotifConfig(JSON.parse(JSON.stringify(originalNotifConfig)))
|
||||
if (originalFiresConfig) setFiresConfig(JSON.parse(originalFiresConfig))
|
||||
setHasChanges(false)
|
||||
}
|
||||
|
||||
|
|
@ -235,7 +175,7 @@ export default function ScheduledBroadcasts({ family = 'meshtastic' }: Props) {
|
|||
label="Enable scheduled band-conditions broadcasts"
|
||||
checked={notifConfig.band_conditions_enabled ?? true}
|
||||
onChange={(v) => setNotifConfig({ ...notifConfig, band_conditions_enabled: v })}
|
||||
helper="3x/day HF propagation summary (Day/Night ratings per band group). The daily fire digest (twice-daily LLM summary of active fires + the last 24h of growth/spotting) is configured separately under Adapter Config -> fires.digest_*. See Reference -> Fire Tracker (Fusion) and Reference -> Broadcast Types for the New/Update/Active prefix system."
|
||||
helper="3x/day HF propagation summary (Day/Night ratings per band group). See Reference -> Fire Tracker (Fusion) and Reference -> Broadcast Types for the New/Update/Active prefix system."
|
||||
info="Source priority: (1) recent SWPC readings persisted locally; (2) HamQSL.com fallback; (3) silent skip if both fail. Persistence rows are written either way for an audit trail."
|
||||
/>
|
||||
{(notifConfig.band_conditions_enabled ?? true) && (
|
||||
|
|
@ -274,57 +214,6 @@ export default function ScheduledBroadcasts({ family = 'meshtastic' }: Props) {
|
|||
)}
|
||||
<p className="text-xs text-slate-600">All times are Mountain Time (America/Boise). DST handled automatically.</p>
|
||||
</div>
|
||||
|
||||
{/* Fire Digest */}
|
||||
<div className="bg-bg-card border border-border p-6 space-y-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<label className="text-xs text-slate-500 uppercase tracking-wide">
|
||||
Fire Digest
|
||||
<InfoButton info="Twice-daily LLM summary of active fires + last 24h of growth/spotting events. Configured per the fires adapter." />
|
||||
</label>
|
||||
</div>
|
||||
<Toggle
|
||||
label="Enable fire digest broadcasts"
|
||||
checked={firesConfig.digest_enabled}
|
||||
onChange={(v) => setFiresConfig({ ...firesConfig, digest_enabled: v })}
|
||||
helper="Send a twice-daily digest of active fire conditions to the mesh"
|
||||
/>
|
||||
{firesConfig.digest_enabled && (
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<TimeInput
|
||||
label="Digest Slot 1"
|
||||
value={(firesConfig.digest_schedule ?? ['06:00', '18:00'])[0] || '06:00'}
|
||||
onChange={(v) => {
|
||||
const s = [...(firesConfig.digest_schedule ?? ['06:00', '18:00'])]
|
||||
s[0] = v
|
||||
setFiresConfig({ ...firesConfig, digest_schedule: s })
|
||||
}}
|
||||
helper="Morning digest (default 06:00 MT)"
|
||||
/>
|
||||
<TimeInput
|
||||
label="Digest Slot 2"
|
||||
value={(firesConfig.digest_schedule ?? ['06:00', '18:00'])[1] || '18:00'}
|
||||
onChange={(v) => {
|
||||
const s = [...(firesConfig.digest_schedule ?? ['06:00', '18:00'])]
|
||||
s[1] = v
|
||||
setFiresConfig({ ...firesConfig, digest_schedule: s })
|
||||
}}
|
||||
helper="Evening digest (default 18:00 MT)"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="space-y-1">
|
||||
<label className="text-xs text-slate-500 uppercase tracking-wide">Timezone</label>
|
||||
<input
|
||||
type="text"
|
||||
value={firesConfig.digest_timezone}
|
||||
onChange={(e) => setFiresConfig({ ...firesConfig, digest_timezone: e.target.value })}
|
||||
placeholder="America/Boise"
|
||||
className="w-full px-3 py-2 bg-[#0a0e17] border border-[#1e2a3a] rounded text-sm text-slate-200 focus:outline-none focus:border-accent"
|
||||
/>
|
||||
<p className="text-xs text-slate-600">IANA timezone name (e.g. America/Boise). DST handled automatically.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -318,7 +318,7 @@ REGISTRY: dict[tuple[str, str], dict[str, Any]] = {
|
|||
},
|
||||
|
||||
# =================================================================
|
||||
# FIRES -- 10 settings (P1 radius + P2 growth/halt + P3 spotting + P4 digest)
|
||||
# FIRES -- 7 settings (P1 radius + P2 growth/halt + P3 spotting)
|
||||
# =================================================================
|
||||
# Per-fire spread radius override lives in fires.spread_radius_mi;
|
||||
# the value below is the fallback. v0.7-fire-1 shipped 5 mi based on
|
||||
|
|
@ -394,43 +394,6 @@ REGISTRY: dict[tuple[str, str], dict[str, Any]] = {
|
|||
"type": "int",
|
||||
"description": "Minimum seconds between consecutive wildfire_spotting broadcasts for the same fire; suppresses rapid-ember spam.",
|
||||
},
|
||||
# v0.7-fire-4 -- daily fire digest scheduled broadcaster.
|
||||
# digest_enabled: master switch. Off by default for prod safety;
|
||||
# flip via GUI once the digest wording is dialed in.
|
||||
("fires", "digest_enabled"): {
|
||||
"default": True,
|
||||
"type": "bool",
|
||||
"description": "Whether the fire-digest scheduler broadcasts at the configured slots. Off => no broadcasts even if all other config is valid.",
|
||||
},
|
||||
# digest_broadcast_enabled: independent kill-switch on the actual mesh
|
||||
# emission. Disabled by default so the scheduler keeps running (building /
|
||||
# recording digests) without putting the twice-daily digest on the mesh.
|
||||
# Per-fire wfigs alerts are unaffected.
|
||||
("fires", "digest_broadcast_enabled"): {
|
||||
"default": False,
|
||||
"type": "bool",
|
||||
"description": "Emit the twice-daily fire-digest broadcast. Disabled by default; per-fire wfigs alerts are unaffected.",
|
||||
},
|
||||
# digest_schedule: list of HH:MM strings, local-time per digest_timezone.
|
||||
# Mirrors band_conditions_schedule shape so operators can reason
|
||||
# about the two side-by-side.
|
||||
("fires", "digest_schedule"): {
|
||||
"default": ["06:00", "18:00"],
|
||||
"type": "json",
|
||||
"description": "Local-time HH:MM slots for the fire-digest broadcast (list of strings). Honor digest_timezone for wall-clock semantics.",
|
||||
},
|
||||
("fires", "digest_timezone"): {
|
||||
"default": "America/Boise",
|
||||
"type": "str",
|
||||
"description": "IANA tz used to interpret digest_schedule.",
|
||||
},
|
||||
# digest_max_chars: mesh wire cap. The LLM is told to fit under this.
|
||||
# Reuses the response.max_length chunking if the LLM ignores the cap.
|
||||
("fires", "digest_max_chars"): {
|
||||
"default": 140,
|
||||
"type": "int",
|
||||
"description": "Hard cap on the digest wire string length (chars). The LLM prompt asks to fit; the chunker enforces.",
|
||||
},
|
||||
|
||||
# =================================================================
|
||||
# FIRMS -- 7 settings (storage floors + dedup + 3 v0.7 cluster knobs)
|
||||
|
|
@ -509,7 +472,7 @@ REGISTRY: dict[tuple[str, str], dict[str, Any]] = {
|
|||
("reminders_wfigs", "enabled"): {
|
||||
"default": False,
|
||||
"type": "bool",
|
||||
"description": "Enable Active: reminder broadcasts for ongoing fires. Disabled by default — use the digest instead.",
|
||||
"description": "Enable Active: reminder broadcasts for ongoing fires. Disabled by default.",
|
||||
},
|
||||
("reminders_wfigs", "cadence_kind"): {
|
||||
"default": "interval",
|
||||
|
|
|
|||
74
work/meshai/env/store.py
vendored
74
work/meshai/env/store.py
vendored
|
|
@ -40,6 +40,7 @@ class EnvironmentalStore:
|
|||
event_bus: Optional["EventBus"] = None,
|
||||
coverage_bbox: list = None,
|
||||
coverage_excluded: list = None,
|
||||
coverage_areas: list = None,
|
||||
generic_sources: list = None,
|
||||
):
|
||||
# Config-driven REST/GeoJSON sources (top-level config.generic_sources)
|
||||
|
|
@ -56,6 +57,38 @@ class EnvironmentalStore:
|
|||
self._coverage_bbox = coverage_bbox or []
|
||||
self._coverage_excluded = set(coverage_excluded or [])
|
||||
|
||||
# ── Fire-ingest coverage-scope gate ──────────────────────────────
|
||||
# The exact per-area MonitoringArea union the dispatch-level
|
||||
# CoverageFilter gates on (areas_from_config(config.coverage) — the
|
||||
# SAME set-union Shapely membership test). Built ONCE here and reused
|
||||
# per-fire in _ingest_fires so an out-of-area fire is never STORED
|
||||
# (thus never tracked / alerted / reminded / re-ingested), keeping
|
||||
# ingest scope == dispatch scope. Fail-OPEN: an empty list (coverage
|
||||
# disabled or no areas configured) means the gate is a no-op and every
|
||||
# fire is stored, preserving current behaviour. `coverage_areas` is the
|
||||
# raw config.coverage.areas dict-list; areas_from_config also honours
|
||||
# the legacy single-`bbox` fallback via the `.bbox` attribute, so we
|
||||
# wrap the raw list in a tiny shim exposing both fields.
|
||||
self._fire_coverage_areas = []
|
||||
try:
|
||||
from meshai.coverage_area import areas_from_config
|
||||
|
||||
class _CoverageShim:
|
||||
def __init__(self, areas, bbox):
|
||||
self.areas = areas or []
|
||||
self.bbox = bbox or []
|
||||
|
||||
# Only build a real gate when this adapter is NOT on the coverage
|
||||
# opt-out list ("fires"), mirroring _coverage_for's escape hatch:
|
||||
# an excluded fires adapter falls back to no coverage gating.
|
||||
if "fires" not in self._coverage_excluded:
|
||||
self._fire_coverage_areas = areas_from_config(
|
||||
_CoverageShim(coverage_areas, self._coverage_bbox))
|
||||
except Exception:
|
||||
logger.exception(
|
||||
"fire coverage-scope gate init failed; failing OPEN (no gate)")
|
||||
self._fire_coverage_areas = []
|
||||
|
||||
# ── Received-delta gate (NATIVE-only) ────────────────────────────
|
||||
# The model the operator demanded: a native adapter broadcasts an item
|
||||
# ONLY when it was newly RECEIVED from the API this poll — never by
|
||||
|
|
@ -356,6 +389,31 @@ class EnvironmentalStore:
|
|||
# seeded — the leak-proof invariant behind the wzdx staging fix.
|
||||
self._seeded.update(touched)
|
||||
|
||||
def _fire_out_of_coverage(self, lat, lon) -> bool:
|
||||
"""True iff (lat, lon) lies OUTSIDE every configured coverage area.
|
||||
|
||||
Mirrors the dispatch-level CoverageFilter membership exactly: builds a
|
||||
GeoJSON Point from the fire's (lon, lat) and classifies it against
|
||||
``self._fire_coverage_areas`` with the SAME set-union Shapely test
|
||||
(classify_geom_areas). Only ``out-of-bounds`` returns True (drop);
|
||||
every other verdict — in-bounds, or an unlocatable / unparseable point
|
||||
(null-geom / invalid-geom) — returns False (fail-OPEN, keep), matching
|
||||
the fire path's fail-open semantics. Callers must already have checked
|
||||
that ``self._fire_coverage_areas`` is non-empty.
|
||||
"""
|
||||
try:
|
||||
if lat is None or lon is None:
|
||||
return False # unlocatable fire -> fail open (keep/store)
|
||||
from meshai.coverage_area import build_geom_json, classify_geom_areas
|
||||
geom_json = build_geom_json({"centroid": [lon, lat]})
|
||||
verdict = classify_geom_areas(geom_json, self._fire_coverage_areas)
|
||||
return verdict == "out-of-bounds"
|
||||
except Exception:
|
||||
logger.exception(
|
||||
"fire coverage-scope test failed for (%s,%s); failing OPEN",
|
||||
lat, lon)
|
||||
return False
|
||||
|
||||
def _ingest_fires(self, adapter) -> None:
|
||||
"""Native WFIGS fire ingest — Phase-3 growth-decider path.
|
||||
|
||||
|
|
@ -412,6 +470,22 @@ class EnvironmentalStore:
|
|||
contained = evt.get("contained_pct")
|
||||
declared = evt.get("declared_at_epoch")
|
||||
|
||||
# ── Coverage-scope drop ──────────────────────────────────
|
||||
# BEFORE any store write (cold-start seed AND live
|
||||
# INSERT/UPDATE), drop a fire whose (lat, lon) falls OUTSIDE
|
||||
# every configured coverage area. Uses the SAME per-area
|
||||
# set-union Shapely membership test as the dispatch-level
|
||||
# CoverageFilter (classify_geom_areas over the areas built in
|
||||
# __init__), so ingest scope == dispatch scope. Fail-OPEN: with
|
||||
# no coverage areas the list is empty and this never drops.
|
||||
if self._fire_coverage_areas and self._fire_out_of_coverage(
|
||||
evt.get("lat"), evt.get("lon")):
|
||||
logger.info(
|
||||
"coverage: skipped out-of-area fire %s (%s,%s)",
|
||||
evt.get("name") or irwin_id,
|
||||
evt.get("lat"), evt.get("lon"))
|
||||
continue
|
||||
|
||||
row = conn.execute(
|
||||
"SELECT last_broadcast_at FROM fires WHERE irwin_id=?",
|
||||
(irwin_id,)).fetchone()
|
||||
|
|
|
|||
|
|
@ -113,9 +113,9 @@ class MeshAI:
|
|||
# now that we are inside the running event loop.
|
||||
if self.event_bus is not None:
|
||||
from .notifications.pipeline import start_pipeline
|
||||
# v0.7-fire-tracker-4 llm_backend hook: surface the LLM into
|
||||
# the pipeline components dict BEFORE start_pipeline spawns the
|
||||
# scheduled broadcasters. FireDigestScheduler reads this.
|
||||
# Surface the LLM into the pipeline components dict BEFORE
|
||||
# start_pipeline spawns the scheduled broadcasters, so any
|
||||
# component that reads comps["llm_backend"] can find it.
|
||||
try:
|
||||
comps = getattr(self.event_bus, "_pipeline_components", {}) or {}
|
||||
comps["llm_backend"] = self.llm
|
||||
|
|
@ -679,10 +679,16 @@ class MeshAI:
|
|||
from meshai.coverage import enclosing_bbox
|
||||
cov = self.config.coverage
|
||||
coverage_bbox = (enclosing_bbox(cov.areas) or cov.bbox) if cov.enabled else []
|
||||
# Per-area list for the store's fire-ingest coverage gate (the SAME
|
||||
# areas the dispatch-level CoverageFilter uses, so ingest scope ==
|
||||
# dispatch scope). Only when coverage is enabled — a disabled
|
||||
# coverage leaves this empty so the ingest gate fails OPEN.
|
||||
coverage_areas = cov.areas if cov.enabled else []
|
||||
self.env_store = EnvironmentalStore(
|
||||
config=env_cfg, region_anchors=region_anchors,
|
||||
coverage_bbox=coverage_bbox, event_bus=self.event_bus,
|
||||
coverage_excluded=cov.excluded_adapters,
|
||||
coverage_areas=coverage_areas,
|
||||
generic_sources=self.config.generic_sources,
|
||||
)
|
||||
logger.info(f"Environmental feeds enabled ({len(self.env_store._adapters)} adapters)")
|
||||
|
|
|
|||
|
|
@ -28,7 +28,6 @@ from meshai.notifications.channels import create_channel
|
|||
from meshai.notifications.pipeline.bus import EventBus, get_bus
|
||||
from meshai.notifications.pipeline.dispatcher import Dispatcher
|
||||
try:
|
||||
from meshai.notifications.scheduled.fire_digest import FireDigestScheduler
|
||||
from meshai.notifications.scheduled.band_conditions import (
|
||||
BandConditionsScheduler,
|
||||
)
|
||||
|
|
@ -249,22 +248,6 @@ async def start_pipeline(bus: EventBus, config) -> DigestScheduler:
|
|||
_lg.getLogger("meshai.pipeline").exception(
|
||||
"band_conditions scheduler failed to start")
|
||||
|
||||
# v0.7-fire-tracker-4 FireDigestScheduler -- twice-daily fire digest.
|
||||
if FireDigestScheduler is not None:
|
||||
try:
|
||||
comps = getattr(bus, "_pipeline_components", {}) or {}
|
||||
disp = comps.get("dispatcher")
|
||||
llm_backend = comps.get("llm_backend")
|
||||
if disp is not None:
|
||||
fd_sched = FireDigestScheduler(disp)
|
||||
await fd_sched.start()
|
||||
comps["fire_digest_scheduler"] = fd_sched
|
||||
bus._pipeline_components = comps
|
||||
except Exception:
|
||||
import logging as _lg
|
||||
_lg.getLogger("meshai.pipeline").exception(
|
||||
"fire_digest scheduler failed to start")
|
||||
|
||||
# v0.6-phase3 ReminderScheduler -- runs alongside band_conditions.
|
||||
if ReminderScheduler is not None:
|
||||
try:
|
||||
|
|
|
|||
|
|
@ -1,306 +0,0 @@
|
|||
"""v0.7-fire-tracker-4 fire-digest scheduled broadcaster.
|
||||
|
||||
Twice-daily (default 06:00 + 18:00 Mountain) deterministic summary of
|
||||
active fires, broadcast via dispatcher.dispatch_scheduled_broadcast.
|
||||
|
||||
Modeled after band_conditions.py (cf. v0.5.11 scheduled broadcaster).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import time
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Any, Callable, Optional
|
||||
|
||||
try:
|
||||
from zoneinfo import ZoneInfo
|
||||
except ImportError:
|
||||
ZoneInfo = None # pragma: no cover
|
||||
|
||||
from meshai.adapter_config import adapter_config
|
||||
|
||||
logger = logging.getLogger("meshai.scheduled.fire_digest")
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# Slot epoch -- HH:MM local -> UNIX epoch (UTC)
|
||||
# ===========================================================================
|
||||
|
||||
|
||||
def _slot_epoch(now_dt: datetime, hh_mm: str, tz_name: str) -> int:
|
||||
"""Convert HH:MM in `tz_name` on now_dt's local date to UNIX epoch."""
|
||||
h, m = hh_mm.split(":")
|
||||
if ZoneInfo is None:
|
||||
# Fall back: treat as UTC.
|
||||
local = now_dt.replace(hour=int(h), minute=int(m),
|
||||
second=0, microsecond=0,
|
||||
tzinfo=timezone.utc)
|
||||
else:
|
||||
tz = ZoneInfo(tz_name)
|
||||
local = now_dt.astimezone(tz).replace(
|
||||
hour=int(h), minute=int(m), second=0, microsecond=0,
|
||||
)
|
||||
return int(local.astimezone(timezone.utc).timestamp())
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# Deterministic renderer
|
||||
# ===========================================================================
|
||||
|
||||
|
||||
def _get_anchor(lat, lon) -> str:
|
||||
"""Get location anchor for a fire using nearest_town from central_normalizer."""
|
||||
if not isinstance(lat, (int, float)) or not isinstance(lon, (int, float)):
|
||||
return ""
|
||||
try:
|
||||
from meshai.central_normalizer import nearest_town
|
||||
max_mi = float(adapter_config.wfigs.anchor_max_mi)
|
||||
nt = nearest_town(lat, lon, max_distance_mi=max_mi)
|
||||
except Exception:
|
||||
return ""
|
||||
if nt and nt.get("name"):
|
||||
town = nt["name"].title()
|
||||
d = nt.get("distance_mi")
|
||||
bearing = nt.get("bearing")
|
||||
if isinstance(d, (int, float)):
|
||||
if d < 1:
|
||||
return f"near {town}"
|
||||
return f"{int(round(d))} mi {bearing or ''} of {town}".strip()
|
||||
return f"near {town}"
|
||||
return ""
|
||||
|
||||
|
||||
async def render_digest(*, now: Optional[int] = None) -> tuple[str, str]:
|
||||
"""Build the digest wire string deterministically.
|
||||
|
||||
Returns (wire, source). source is 'deterministic' on success,
|
||||
'no_fires' if there are no active fires (wire is empty).
|
||||
"""
|
||||
from meshai.persistence import get_db
|
||||
|
||||
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 last_event_at >= ? "
|
||||
"AND tombstoned_at IS NULL "
|
||||
"AND (current_contained_pct IS NULL OR current_contained_pct < 100) "
|
||||
"ORDER BY last_event_at DESC",
|
||||
(cutoff,),
|
||||
).fetchall()
|
||||
|
||||
if not rows:
|
||||
return "", "no_fires"
|
||||
|
||||
total = len(rows)
|
||||
top = rows[:2]
|
||||
|
||||
header = f"\U0001f525 Fire Digest \u2014 {total} active wildfire(s)"
|
||||
|
||||
fire_lines: list[str] = []
|
||||
for row in top:
|
||||
name = row["incident_name"] or "(unnamed)"
|
||||
county = row["county"]
|
||||
state = row["state"]
|
||||
if county and state:
|
||||
fire_lines.append(f"{name} in {county} Co, {state}")
|
||||
elif state:
|
||||
fire_lines.append(f"{name} in {state}")
|
||||
else:
|
||||
fire_lines.append(name)
|
||||
|
||||
# Assemble within the universal mesh budget; trim fire lines, never the tail
|
||||
budget = int(adapter_config.fires.digest_max_chars)
|
||||
shown: list[str] = []
|
||||
for line in fire_lines:
|
||||
# Estimate tail for budget check
|
||||
est_remaining = total - len(shown) - 1
|
||||
if est_remaining == 1:
|
||||
est_tail = "There is 1 additional wildfire. DM me for the full list."
|
||||
elif est_remaining > 1:
|
||||
est_tail = f"There are {est_remaining} additional wildfires. DM me for the full list."
|
||||
else:
|
||||
est_tail = ""
|
||||
parts = [header] + shown + [line]
|
||||
if est_tail:
|
||||
parts.append(est_tail)
|
||||
candidate = "\n".join(parts)
|
||||
if len(candidate.encode("utf-8")) <= budget:
|
||||
shown.append(line)
|
||||
else:
|
||||
break
|
||||
|
||||
# Compute tail AFTER budget loop with actual shown count
|
||||
remaining = total - len(shown)
|
||||
if remaining == 1:
|
||||
tail = "There is 1 additional wildfire. DM me for the full list."
|
||||
elif remaining > 1:
|
||||
tail = f"There are {remaining} additional wildfires. DM me for the full list."
|
||||
else:
|
||||
tail = ""
|
||||
|
||||
parts = [header] + shown
|
||||
if tail:
|
||||
parts.append(tail)
|
||||
wire = "\n".join(parts)
|
||||
|
||||
return wire, "deterministic"
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# Broadcast
|
||||
# ===========================================================================
|
||||
|
||||
|
||||
def _record_slot_attempt(slot_epoch_s: int, *,
|
||||
sent_at: int,
|
||||
summary: Optional[str],
|
||||
source: str) -> Optional[int]:
|
||||
"""Insert into fire_digest_broadcasts. Returns rowid on insert, None
|
||||
if the slot was already broadcast (UNIQUE PK collision)."""
|
||||
try:
|
||||
from meshai.persistence import get_db
|
||||
conn = get_db()
|
||||
except Exception:
|
||||
return None
|
||||
cur = conn.execute(
|
||||
"INSERT OR IGNORE INTO fire_digest_broadcasts(slot_epoch, "
|
||||
"sent_at, summary, source) VALUES (?,?,?,?)",
|
||||
(slot_epoch_s, sent_at, summary, source),
|
||||
)
|
||||
return int(cur.lastrowid) if cur.rowcount > 0 else None
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# Scheduler
|
||||
# ===========================================================================
|
||||
|
||||
|
||||
class FireDigestScheduler:
|
||||
"""Fires fire-digest broadcasts at configured local times."""
|
||||
|
||||
def __init__(self, dispatcher, *,
|
||||
clock: Optional[Callable[[], float]] = None,
|
||||
sleep: Optional[Callable[[float], Any]] = None):
|
||||
self._dispatcher = dispatcher
|
||||
self._clock = clock or time.time
|
||||
self._sleep = sleep or asyncio.sleep
|
||||
self._task: Optional[asyncio.Task] = None
|
||||
self._stop_event: Optional[asyncio.Event] = None
|
||||
self._logger = logger
|
||||
|
||||
def _enabled(self) -> bool:
|
||||
try:
|
||||
return bool(adapter_config.fires.digest_enabled)
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
def _schedule(self) -> list[str]:
|
||||
try:
|
||||
sched = adapter_config.fires.digest_schedule
|
||||
except Exception:
|
||||
sched = ["06:00", "18:00"]
|
||||
if not isinstance(sched, list):
|
||||
sched = ["06:00", "18:00"]
|
||||
return [s for s in sched if isinstance(s, str) and ":" in s]
|
||||
|
||||
def _tz_name(self) -> str:
|
||||
try:
|
||||
return str(adapter_config.fires.digest_timezone)
|
||||
except Exception:
|
||||
return "America/Boise"
|
||||
|
||||
async def start(self) -> None:
|
||||
if self._task is not None and not self._task.done():
|
||||
raise RuntimeError("FireDigestScheduler already running")
|
||||
self._stop_event = asyncio.Event()
|
||||
self._task = asyncio.create_task(self._run(),
|
||||
name="fire-digest-scheduler")
|
||||
self._logger.info(
|
||||
"Fire digest scheduler started: enabled=%s schedule=%s tz=%s",
|
||||
self._enabled(), self._schedule(), self._tz_name())
|
||||
|
||||
async def stop(self) -> None:
|
||||
if self._stop_event:
|
||||
self._stop_event.set()
|
||||
if self._task:
|
||||
await self._task
|
||||
|
||||
async def _run(self) -> None:
|
||||
while not (self._stop_event and self._stop_event.is_set()):
|
||||
if not self._enabled():
|
||||
await self._sleep(60); continue
|
||||
now = self._clock()
|
||||
now_dt = datetime.fromtimestamp(now, tz=timezone.utc)
|
||||
target_epoch, target_hh_mm = self._next_slot(now_dt)
|
||||
wait_s = max(1, target_epoch - int(now))
|
||||
try:
|
||||
await self._sleep(min(wait_s, 3600))
|
||||
except asyncio.CancelledError:
|
||||
break
|
||||
now2 = int(self._clock())
|
||||
if now2 >= target_epoch:
|
||||
await self.fire_slot(target_epoch, target_hh_mm)
|
||||
|
||||
def _next_slot(self, now_dt: datetime) -> tuple[int, str]:
|
||||
schedule = sorted(set(self._schedule()))
|
||||
if not schedule:
|
||||
tomorrow = now_dt + timedelta(days=1)
|
||||
return _slot_epoch(tomorrow, "12:00", self._tz_name()), "12:00"
|
||||
today_now = int(now_dt.timestamp())
|
||||
for hh_mm in schedule:
|
||||
ep = _slot_epoch(now_dt, hh_mm, self._tz_name())
|
||||
if ep > today_now:
|
||||
return ep, hh_mm
|
||||
tomorrow = now_dt + timedelta(days=1)
|
||||
return _slot_epoch(tomorrow, schedule[0], self._tz_name()), schedule[0]
|
||||
|
||||
def _broadcast_enabled(self) -> bool:
|
||||
try:
|
||||
return bool(adapter_config.fires.digest_broadcast_enabled)
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
async def fire_slot(self, slot_epoch_s: int, hh_mm: str) -> bool:
|
||||
"""Build + broadcast for the given slot. Returns True on broadcast."""
|
||||
# Kill-switch on the actual mesh emission. Disabled by default: the
|
||||
# scheduler wiring stays intact (so flipping the flag to True cleanly
|
||||
# re-enables it) but nothing is dispatched. Per-fire wfigs alerts are a
|
||||
# separate path and are unaffected.
|
||||
if not self._broadcast_enabled():
|
||||
self._logger.info(
|
||||
"fire-digest: broadcast disabled "
|
||||
"(fires.digest_broadcast_enabled=False); skipping slot %s", hh_mm)
|
||||
return False
|
||||
wire, source = await render_digest(now=int(self._clock()))
|
||||
if source == "no_fires":
|
||||
self._logger.info(
|
||||
"fire-digest: silent skip for %s (no active fires)", hh_mm)
|
||||
_record_slot_attempt(slot_epoch_s,
|
||||
sent_at=int(self._clock()),
|
||||
summary=None,
|
||||
source="skipped_no_fires")
|
||||
return False
|
||||
bcast_id = _record_slot_attempt(slot_epoch_s,
|
||||
sent_at=int(self._clock()),
|
||||
summary=wire,
|
||||
source=source)
|
||||
if bcast_id is None:
|
||||
self._logger.info(
|
||||
"fire-digest: slot %s already broadcast; skipping dup",
|
||||
hh_mm)
|
||||
return False
|
||||
try:
|
||||
success = await self._dispatcher.dispatch_scheduled_broadcast(
|
||||
text=wire,
|
||||
source_event_table="fire_digest_broadcasts",
|
||||
source_event_pk=str(bcast_id),
|
||||
)
|
||||
except Exception:
|
||||
self._logger.exception(
|
||||
"fire-digest: dispatcher raised; row stays in table")
|
||||
success = False
|
||||
return bool(success)
|
||||
|
|
@ -129,20 +129,20 @@ def test_wfigs_broadcast_on_acres_bool_roundtrip(client):
|
|||
assert type(val) is bool
|
||||
|
||||
|
||||
def test_fires_digest_enabled_bool_roundtrip(client):
|
||||
"""Third adapter (fires.digest_enabled) -- additional proof of generic handling."""
|
||||
def test_reminders_wfigs_enabled_bool_roundtrip(client):
|
||||
"""Third adapter (reminders_wfigs.enabled) -- additional proof of generic handling."""
|
||||
# Read default
|
||||
default_val = adapter_config.fires.digest_enabled
|
||||
default_val = adapter_config.reminders_wfigs.enabled
|
||||
|
||||
# Flip
|
||||
new_val = not default_val
|
||||
r = client.put(
|
||||
"/api/adapter-config/fires/digest_enabled",
|
||||
"/api/adapter-config/reminders_wfigs/enabled",
|
||||
json={"value": new_val},
|
||||
)
|
||||
assert r.status_code == 200
|
||||
assert r.json()["value"] is new_val
|
||||
|
||||
# Accessor returns the correct bool
|
||||
assert adapter_config.fires.digest_enabled is new_val
|
||||
assert type(adapter_config.fires.digest_enabled) is bool
|
||||
assert adapter_config.reminders_wfigs.enabled is new_val
|
||||
assert type(adapter_config.reminders_wfigs.enabled) is bool
|
||||
|
|
|
|||
|
|
@ -1,73 +0,0 @@
|
|||
"""Tests for the fire-digest broadcast kill-switch (fires.digest_broadcast_enabled).
|
||||
|
||||
The scheduler wiring stays intact; only the mesh EMISSION is gated. Disabled by
|
||||
default -> fire_slot() dispatches nothing. Flipping the flag True re-enables it
|
||||
cleanly. Per-fire wfigs alerts are a separate path and are unaffected (covered in
|
||||
test_wfigs_handler.py).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import time
|
||||
|
||||
from meshai.persistence import get_db
|
||||
from meshai.notifications.scheduled.fire_digest import FireDigestScheduler
|
||||
|
||||
|
||||
class _RecordingDispatcher:
|
||||
def __init__(self):
|
||||
self.calls = []
|
||||
|
||||
async def dispatch_scheduled_broadcast(self, *, text, source_event_table,
|
||||
source_event_pk):
|
||||
self.calls.append(
|
||||
{"text": text, "table": source_event_table, "pk": source_event_pk})
|
||||
return True
|
||||
|
||||
|
||||
def _seed_active_fire(conn):
|
||||
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 (?,?,?,?,?,?,?,?,?,?,?,?)",
|
||||
("GATE-01", "Gatekeeper Fire", "WF", 900, None, 43.6, -116.2,
|
||||
"Ada", "ID", now, now - 600, None),
|
||||
)
|
||||
|
||||
|
||||
def test_fire_slot_disabled_by_default_dispatches_nothing():
|
||||
"""With defaults (digest_broadcast_enabled=False) fire_slot emits nothing."""
|
||||
conn = get_db()
|
||||
_seed_active_fire(conn) # a broadcast WOULD be produced if the gate were open
|
||||
now = int(time.time())
|
||||
dispatcher = _RecordingDispatcher()
|
||||
sched = FireDigestScheduler(dispatcher, clock=lambda: now)
|
||||
|
||||
result = asyncio.run(sched.fire_slot(now, "06:00"))
|
||||
|
||||
assert result is False
|
||||
assert dispatcher.calls == [], "digest broadcast must NOT be dispatched by default"
|
||||
|
||||
|
||||
def test_fire_slot_dispatches_when_flag_enabled():
|
||||
"""Flipping fires.digest_broadcast_enabled True cleanly re-enables emission."""
|
||||
from meshai.adapter_config import set_runtime_override
|
||||
conn = get_db()
|
||||
_seed_active_fire(conn)
|
||||
now = int(time.time())
|
||||
dispatcher = _RecordingDispatcher()
|
||||
sched = FireDigestScheduler(dispatcher, clock=lambda: now)
|
||||
|
||||
set_runtime_override("fires", "digest_broadcast_enabled", True)
|
||||
try:
|
||||
result = asyncio.run(sched.fire_slot(now, "06:00"))
|
||||
finally:
|
||||
# Reset so the override doesn't leak into other tests in this process.
|
||||
set_runtime_override("fires", "digest_broadcast_enabled", False)
|
||||
|
||||
assert result is True
|
||||
assert len(dispatcher.calls) == 1
|
||||
assert dispatcher.calls[0]["table"] == "fire_digest_broadcasts"
|
||||
assert "Gatekeeper Fire" in dispatcher.calls[0]["text"]
|
||||
|
|
@ -1,230 +0,0 @@
|
|||
"""Tests for the fire digest deterministic renderer.
|
||||
|
||||
Validates recency ordering, contained/tombstoned exclusion, the
|
||||
"Name in County Co, ST" line format, correct tail count after budget
|
||||
trimming, singular/plural grammar, and the 140-byte universal mesh budget.
|
||||
"""
|
||||
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
|
||||
|
||||
_seed_fire(conn, irwin_id="F-01", name="Alpha Fire",
|
||||
acres=500, contained=None,
|
||||
last_event_at=now - 3600,
|
||||
county="Ada", state="ID")
|
||||
_seed_fire(conn, irwin_id="F-02", name="Bravo Fire",
|
||||
acres=200, contained=25,
|
||||
last_event_at=now - 7200,
|
||||
county="Boise", state="ID")
|
||||
_seed_fire(conn, irwin_id="F-03", name="Charlie Fire",
|
||||
acres=1000, contained=None,
|
||||
last_event_at=now - 2 * day,
|
||||
county="Elmore", state="ID")
|
||||
|
||||
_seed_fire(conn, irwin_id="F-04", name="Contained Fire",
|
||||
acres=800, contained=100,
|
||||
last_event_at=now - 1800,
|
||||
county="Gem", state="ID")
|
||||
|
||||
_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 most recent active fire is listed first.
|
||||
|
||||
With the universal 140-byte budget, the header + 1 fire line + tail
|
||||
fits; the second fire line does not. Alpha (most recent) must appear;
|
||||
Bravo belongs to the tail 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))
|
||||
assert source == "deterministic"
|
||||
# Most recent fire must appear in the wire body.
|
||||
assert "Alpha Fire" in wire
|
||||
# Bravo does not fit within 140 bytes alongside the header + tail.
|
||||
assert "Bravo Fire" not in wire
|
||||
|
||||
def test_line_format_name_in_county_co_state(self):
|
||||
"""Fire lines render as 'Name in County Co, ST'.
|
||||
|
||||
Only the most recent fire fits within the 140-byte budget.
|
||||
"""
|
||||
conn = get_db()
|
||||
now = int(time.time())
|
||||
_seed_scenario(conn)
|
||||
from meshai.notifications.scheduled.fire_digest import render_digest
|
||||
wire, _ = asyncio.run(render_digest(now=now))
|
||||
assert "Alpha Fire in Ada Co, ID" in wire
|
||||
|
||||
def test_missing_county_renders_name_in_state(self):
|
||||
"""Fire with no county renders as 'Name in ST'."""
|
||||
conn = get_db()
|
||||
now = int(time.time())
|
||||
_seed_fire(conn, irwin_id="NC-01", name="No County Blaze",
|
||||
acres=100, contained=None,
|
||||
last_event_at=now - 3600,
|
||||
county=None, state="MT")
|
||||
_seed_fire(conn, irwin_id="NC-02", name="Second Fire",
|
||||
acres=50, contained=None,
|
||||
last_event_at=now - 7200,
|
||||
county="Ada", state="ID")
|
||||
from meshai.notifications.scheduled.fire_digest import render_digest
|
||||
wire, _ = asyncio.run(render_digest(now=now))
|
||||
assert "No County Blaze in MT" in wire
|
||||
|
||||
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, _ = 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, _ = asyncio.run(render_digest(now=now))
|
||||
assert "Tombstoned Fire" not in wire
|
||||
|
||||
def test_n1_grammar_singular(self):
|
||||
"""N == 1 renders 'There is 1 additional wildfire.'."""
|
||||
conn = get_db()
|
||||
now = int(time.time())
|
||||
# 3 fires; short names + no county → lines are short enough that
|
||||
# 2 fit within the 140-byte budget, leaving 1 in the tail.
|
||||
for irwin, name, offset in [
|
||||
("SG-01", "A", 3600),
|
||||
("SG-02", "B", 7200),
|
||||
("SG-03", "C", 10800),
|
||||
]:
|
||||
_seed_fire(conn, irwin_id=irwin, name=name, acres=100,
|
||||
contained=None, last_event_at=now - offset,
|
||||
county=None, state="ID")
|
||||
from meshai.notifications.scheduled.fire_digest import render_digest
|
||||
wire, _ = asyncio.run(render_digest(now=now))
|
||||
# 3 active total, 2 shown (budget), 1 remaining
|
||||
assert "There is 1 additional wildfire. DM me for the full list." in wire
|
||||
|
||||
def test_n_plural_grammar(self):
|
||||
"""N > 1 renders 'There are N additional wildfires.'."""
|
||||
conn = get_db()
|
||||
now = int(time.time())
|
||||
for i in range(4):
|
||||
_seed_fire(conn, irwin_id=f"PL-{i:02d}", name=f"Fire {i}",
|
||||
acres=100 + i, contained=None,
|
||||
last_event_at=now - 3600 * (i + 1),
|
||||
county="Ada", state="ID")
|
||||
from meshai.notifications.scheduled.fire_digest import render_digest
|
||||
wire, _ = asyncio.run(render_digest(now=now))
|
||||
# 4 active; "Fire N in Ada Co, ID" lines total ~142 bytes for 2 lines +
|
||||
# header + tail → only 1 line fits in the 140-byte budget → 3 remaining.
|
||||
assert "There are 3 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())
|
||||
_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_tail_count_correct_after_budget_trim(self):
|
||||
"""When budget trims a line, N reflects actual shown count."""
|
||||
conn = get_db()
|
||||
now = int(time.time())
|
||||
# Long names force the second line to be trimmed
|
||||
long_name_1 = "A" * 60
|
||||
long_name_2 = "B" * 60
|
||||
_seed_fire(conn, irwin_id="LN-01", name=long_name_1,
|
||||
acres=500, contained=None,
|
||||
last_event_at=now - 3600,
|
||||
county="Bonneville", state="ID")
|
||||
_seed_fire(conn, irwin_id="LN-02", name=long_name_2,
|
||||
acres=200, contained=None,
|
||||
last_event_at=now - 7200,
|
||||
county="Bannock", state="ID")
|
||||
_seed_fire(conn, irwin_id="LN-03", name="Short Fire",
|
||||
acres=100, contained=None,
|
||||
last_event_at=now - 3 * 86400,
|
||||
county="Ada", state="ID")
|
||||
from meshai.notifications.scheduled.fire_digest import render_digest
|
||||
wire, source = asyncio.run(render_digest(now=now))
|
||||
assert source == "deterministic"
|
||||
byte_len = len(wire.encode("utf-8"))
|
||||
assert byte_len <= 140, f"Wire is {byte_len} bytes, exceeds 140"
|
||||
# If both long lines fit, remaining = 1; if only one fits, remaining = 2
|
||||
# Either way the tail count must match (total - shown)
|
||||
lines = wire.split("\n")
|
||||
shown_fires = [l for l in lines if long_name_1 in l or long_name_2 in l]
|
||||
remaining = 3 - len(shown_fires)
|
||||
if remaining == 1:
|
||||
assert "There is 1 additional wildfire." in wire
|
||||
elif remaining > 1:
|
||||
assert f"There are {remaining} additional wildfires." 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, _ = asyncio.run(render_digest(now=now))
|
||||
byte_len = len(wire.encode("utf-8"))
|
||||
assert byte_len <= 140, f"Digest is {byte_len} bytes, exceeds 140-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"
|
||||
|
|
@ -110,16 +110,27 @@ def _raw_fire(*, name="MORA", irwin=_IRWIN, acres=2410, contained=10,
|
|||
}
|
||||
|
||||
|
||||
def _make_store():
|
||||
def _make_store(coverage_areas=None, coverage_excluded=None):
|
||||
bus = EventBus()
|
||||
captured: list = []
|
||||
bus.subscribe(lambda e: captured.append(e))
|
||||
store = EnvironmentalStore(EnvironmentalConfig(), event_bus=bus)
|
||||
store = EnvironmentalStore(
|
||||
EnvironmentalConfig(), event_bus=bus,
|
||||
coverage_areas=coverage_areas,
|
||||
coverage_excluded=coverage_excluded,
|
||||
)
|
||||
adapter = _FakeFires()
|
||||
store._adapters["nifc"] = adapter
|
||||
return store, adapter, captured
|
||||
|
||||
|
||||
# A coverage area covering SW Idaho (the default _raw_fire lat/lon 44.0,-115.0
|
||||
# sits inside this box; a fire near 0,0 or on the US east coast is outside).
|
||||
_SW_IDAHO_AREA = {
|
||||
"name": "sw-id", "west": -117.0, "south": 42.0, "east": -114.0, "north": 45.0,
|
||||
}
|
||||
|
||||
|
||||
def _seed_row(conn, *, acres, contained, last_bcast_at):
|
||||
"""Manually insert an already-broadcast fires row (skips cold-start)."""
|
||||
conn.execute(
|
||||
|
|
@ -309,3 +320,80 @@ def test_to_event_stamps_canonical_data(env):
|
|||
assert ev.data["contained_pct"] == 30
|
||||
assert ev.data["declared_at_epoch"] == _NOW
|
||||
assert ev.data["lat"] == 44.0 and ev.data["state"] == "US-ID"
|
||||
|
||||
|
||||
# ── 7. Coverage-scope ingest gate ────────────────────────────────────────────
|
||||
# Fires OUTSIDE every configured coverage area are NEVER stored (so they are
|
||||
# never tracked / alerted / reminded / re-ingested). The gate mirrors the
|
||||
# dispatch-level CoverageFilter's set-union membership and applies to BOTH the
|
||||
# cold-start seed and the live INSERT/UPDATE path. Fail-OPEN when coverage is
|
||||
# disabled or has no areas (unchanged current behaviour).
|
||||
|
||||
|
||||
def _fires_row(conn, irwin):
|
||||
return conn.execute(
|
||||
"SELECT irwin_id FROM fires WHERE irwin_id=?", (irwin,)).fetchone()
|
||||
|
||||
|
||||
def test_coverage_gate_inside_area_is_stored(env):
|
||||
"""A fire INSIDE a coverage box is stored (cold-start seed row present)."""
|
||||
conn, _clk = env
|
||||
store, adapter, captured = _make_store(coverage_areas=[_SW_IDAHO_AREA])
|
||||
# Default coords 44.0,-115.0 are inside _SW_IDAHO_AREA.
|
||||
adapter.set_batch([_raw_fire(irwin="IRWIN-IN-1", lat=44.0, lon=-115.0)])
|
||||
store._ingest("nifc", adapter)
|
||||
|
||||
assert captured == [], "cold-start seed must broadcast nothing"
|
||||
assert _fires_row(conn, "IRWIN-IN-1") is not None, \
|
||||
"in-area fire must be stored"
|
||||
|
||||
|
||||
def test_coverage_gate_outside_area_is_not_stored(env):
|
||||
"""A fire OUTSIDE all coverage boxes is NOT stored (no row) with coverage
|
||||
enabled + areas defined — neither the cold-start seed nor a live upsert."""
|
||||
conn, _clk = env
|
||||
store, adapter, captured = _make_store(coverage_areas=[_SW_IDAHO_AREA])
|
||||
# 0,0 (Gulf of Guinea) is far outside the SW-Idaho box.
|
||||
adapter.set_batch([_raw_fire(irwin="IRWIN-OUT-1", lat=0.0, lon=0.0)])
|
||||
store._ingest("nifc", adapter)
|
||||
|
||||
assert captured == [], "out-of-area fire must broadcast nothing"
|
||||
assert _fires_row(conn, "IRWIN-OUT-1") is None, \
|
||||
"out-of-area fire must NOT be stored (cold-start seed dropped)"
|
||||
|
||||
# A later (already-seeded) poll must ALSO refuse to INSERT the out-of-area
|
||||
# fire via the live path — it must never latch a row.
|
||||
store._fires_seeded = True
|
||||
store._ingest("nifc", adapter)
|
||||
assert _fires_row(conn, "IRWIN-OUT-1") is None, \
|
||||
"out-of-area fire must NOT be stored on the live upsert path either"
|
||||
|
||||
|
||||
def test_coverage_gate_fail_open_when_no_areas(env):
|
||||
"""Coverage DISABLED / no areas -> an out-of-box fire IS stored (fail-open,
|
||||
unchanged behaviour)."""
|
||||
conn, _clk = env
|
||||
store, adapter, captured = _make_store() # no coverage areas
|
||||
assert store._fire_coverage_areas == [], \
|
||||
"no coverage areas -> gate is a no-op list"
|
||||
adapter.set_batch([_raw_fire(irwin="IRWIN-OPEN-1", lat=0.0, lon=0.0)])
|
||||
store._ingest("nifc", adapter)
|
||||
|
||||
assert _fires_row(conn, "IRWIN-OPEN-1") is not None, \
|
||||
"with no coverage areas, every fire is stored (fail-open)"
|
||||
|
||||
|
||||
def test_coverage_gate_excluded_adapter_fails_open(env):
|
||||
"""When 'fires' is on the coverage opt-out list, the gate is disabled even
|
||||
with areas configured — mirrors the _coverage_for fetch-scope escape hatch;
|
||||
an out-of-box fire IS stored."""
|
||||
conn, _clk = env
|
||||
store, adapter, captured = _make_store(
|
||||
coverage_areas=[_SW_IDAHO_AREA], coverage_excluded=["fires"])
|
||||
assert store._fire_coverage_areas == [], \
|
||||
"excluded 'fires' adapter -> no gate built"
|
||||
adapter.set_batch([_raw_fire(irwin="IRWIN-EXCL-1", lat=0.0, lon=0.0)])
|
||||
store._ingest("nifc", adapter)
|
||||
|
||||
assert _fires_row(conn, "IRWIN-EXCL-1") is not None, \
|
||||
"excluded adapter falls back to no coverage gating (fire stored)"
|
||||
|
|
|
|||
|
|
@ -59,79 +59,6 @@ def test_router_scope_type_defined_before_env_check():
|
|||
or "scope_type:" in preceding
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# adapter_config seed + categories registration
|
||||
# ===========================================================================
|
||||
|
||||
|
||||
def test_adapter_config_seeds_digest_keys():
|
||||
from meshai.persistence import get_db
|
||||
rows = {
|
||||
(r["adapter"], r["key"]): r["default_json"]
|
||||
for r in get_db().execute(
|
||||
"SELECT adapter, key, default_json FROM adapter_config "
|
||||
"WHERE adapter='fires' AND key LIKE 'digest%'"
|
||||
)
|
||||
}
|
||||
assert rows[("fires", "digest_enabled")] == "true"
|
||||
assert rows[("fires", "digest_schedule")] == '["06:00", "18:00"]'
|
||||
assert rows[("fires", "digest_timezone")] == '"America/Boise"'
|
||||
assert rows[("fires", "digest_max_chars")] == "140"
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# Digest renderer
|
||||
# ===========================================================================
|
||||
|
||||
|
||||
def test_render_digest_returns_no_fires_when_table_empty():
|
||||
from meshai.notifications.scheduled.fire_digest import render_digest
|
||||
|
||||
async def _run():
|
||||
return await render_digest(now=None)
|
||||
wire, source = asyncio.run(_run())
|
||||
assert wire == ""
|
||||
assert source == "no_fires"
|
||||
|
||||
|
||||
def test_render_digest_terse_fallback_when_no_llm():
|
||||
_seed_fire(irwin_id="ID-A", name="Cache Peak",
|
||||
lat=42.0, lon=-114.0, acres=1847, contained=23)
|
||||
_seed_fire(irwin_id="ID-B", name="Twin Peaks",
|
||||
lat=43.0, lon=-115.0, acres=320, contained=5)
|
||||
from meshai.notifications.scheduled.fire_digest import render_digest
|
||||
|
||||
async def _run():
|
||||
return await render_digest(now=None)
|
||||
wire, source = asyncio.run(_run())
|
||||
assert source == "deterministic"
|
||||
assert wire
|
||||
assert "Cache Peak" in wire
|
||||
assert len(wire) <= 140
|
||||
|
||||
|
||||
def test_render_digest_uses_llm_when_available():
|
||||
"""When the LLM backend returns a string, that string IS the wire."""
|
||||
_seed_fire(irwin_id="ID-A", name="Cache Peak",
|
||||
lat=42.0, lon=-114.0, acres=1847)
|
||||
|
||||
class StubLLM:
|
||||
async def generate(self, *, messages, system_prompt, max_tokens):
|
||||
# The renderer must give us a single-line wire derived from
|
||||
# the LLM output, with markdown stripped + cap applied.
|
||||
return "Cache Peak 1847 ac stable; no spotting today."
|
||||
|
||||
from meshai.notifications.scheduled.fire_digest import render_digest
|
||||
|
||||
async def _run():
|
||||
return await render_digest(now=None)
|
||||
wire, source = asyncio.run(_run())
|
||||
assert source == "deterministic"
|
||||
# render_digest is now fully deterministic (no LLM backend).
|
||||
assert "Cache Peak" in wire
|
||||
assert "1,847 ac" in wire
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# Natural-language fire DMs route to the LLM (no ?status fallback)
|
||||
# ===========================================================================
|
||||
|
|
|
|||
|
|
@ -670,16 +670,11 @@ def test_wfigs_discovery_is_date_only():
|
|||
|
||||
|
||||
# ============================================================================
|
||||
# Fire-digest kill-switch does NOT touch the per-fire wfigs path: a new-fire
|
||||
# envelope still produces a broadcast even though digest broadcast is disabled.
|
||||
# A new-fire envelope produces a per-fire wfigs broadcast.
|
||||
# ============================================================================
|
||||
|
||||
|
||||
def test_per_fire_wfigs_broadcasts_while_digest_disabled(mem_db, no_photon):
|
||||
from meshai.adapter_config import adapter_config
|
||||
# Default posture: the twice-daily digest broadcast is OFF.
|
||||
assert bool(adapter_config.fires.digest_broadcast_enabled) is False
|
||||
# ... yet a new per-fire wfigs alert still broadcasts.
|
||||
def test_per_fire_wfigs_broadcasts_new_fire(mem_db, no_photon):
|
||||
env = _make_active_envelope(geocoder_city="Burley")
|
||||
data = {}
|
||||
wire = handle_wfigs(cn.normalize(env), env, env["subject"],
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue