fix(ipaws): route county-only alerts through evac-phase detection

Adds meshai.notifications.evac_phase.detect_phase, wired into the IPAWS
formatter/adapter, to classify READY/SET/GO evacuation phases from real
FEMA IPAWS alert headline/CMAMtext strings. Includes explicit
false-positive guards since a wrong GO detection would broadcast an
evacuation order that was never issued.
This commit is contained in:
Matt Johnson 2026-08-16 01:19:46 +00:00
commit c4706d3c92
5 changed files with 376 additions and 13 deletions

View file

@ -104,6 +104,13 @@ def _norm_fips(value) -> str:
class IPAWSAlertsAdapter: class IPAWSAlertsAdapter:
"""FEMA IPAWS-OPEN EAS civil alerts — two-stage CAP poller.""" """FEMA IPAWS-OPEN EAS civil alerts — two-stage CAP poller."""
# Stage-2 retry suppression. 401/403/404/410 are treated as durable (the
# alert body will not become fetchable), so we wait out the feed's rolling
# window; everything else (timeout, 5xx, 429, connection) is transient.
_STAGE2_FORBIDDEN_COOLDOWN = 21600 # 6h
_STAGE2_TRANSIENT_COOLDOWN = 300 # 5m
_STAGE2_FORBIDDEN_CODES = (401, 403, 404, 410)
def __init__(self, config: "IPAWSConfig", coverage: dict = None): def __init__(self, config: "IPAWSConfig", coverage: dict = None):
self._base_url = _cfg_str(config, "base_url", DEFAULT_BASE_URL).rstrip("/") self._base_url = _cfg_str(config, "base_url", DEFAULT_BASE_URL).rstrip("/")
self._user_agent = getattr(config, "user_agent", "") or "meshai-ipaws/1.0" self._user_agent = getattr(config, "user_agent", "") or "meshai-ipaws/1.0"
@ -133,6 +140,11 @@ class IPAWSAlertsAdapter:
self._last_error = None self._last_error = None
self._backoff_until = 0.0 self._backoff_until = 0.0
self._is_loaded = False self._is_loaded = False
# Negative cache: stage-2 CAP URL -> epoch after which a retry is
# allowed. Stops re-hammering FEMA for an alert listed in the feed whose
# detail endpoint keeps failing (e.g. a durable 403 on a COG-restricted
# alert). Pruned to the current feed each pass so it can't grow unbounded.
self._stage2_cooldown = {}
# ── Polling ────────────────────────────────────────────────────────────── # ── Polling ──────────────────────────────────────────────────────────────
@ -205,6 +217,8 @@ class IPAWSAlertsAdapter:
self._consecutive_errors += 1 self._consecutive_errors += 1
return False return False
now = time.time()
seen_urls = set()
new_events = [] new_events = []
for entry in feed.findall(f"{{{_ATOM_NS}}}entry"): for entry in feed.findall(f"{{{_ATOM_NS}}}entry"):
statefips = None statefips = None
@ -221,18 +235,43 @@ class IPAWSAlertsAdapter:
cap_url = self._stage2_url(href) cap_url = self._stage2_url(href)
if not cap_url: if not cap_url:
continue continue
seen_urls.add(cap_url)
# ── Negative cache: skip URLs still within their failure cooldown ─
retry_after = self._stage2_cooldown.get(cap_url)
if retry_after is not None and now < retry_after:
continue
# ── Stage 2: full CAP document ─────────────────────────────────── # ── Stage 2: full CAP document ───────────────────────────────────
try: try:
cap_raw = self._get(cap_url) cap_raw = self._get(cap_url)
except Exception as e: # noqa: BLE001 except HTTPError as e:
logger.debug("IPAWS stage-2 fetch failed for %s: %s", cap_url, e) cooldown = (self._STAGE2_FORBIDDEN_COOLDOWN
if e.code in self._STAGE2_FORBIDDEN_CODES
else self._STAGE2_TRANSIENT_COOLDOWN)
self._stage2_cooldown[cap_url] = now + cooldown
logger.debug("IPAWS stage-2 fetch failed for %s: HTTP %s "
"(cooldown %ds)", cap_url, e.code, cooldown)
continue continue
except Exception as e: # noqa: BLE001
self._stage2_cooldown[cap_url] = now + self._STAGE2_TRANSIENT_COOLDOWN
logger.debug("IPAWS stage-2 fetch failed for %s: %s (cooldown %ds)",
cap_url, e, self._STAGE2_TRANSIENT_COOLDOWN)
continue
# Success — clear any stale cooldown for this URL.
self._stage2_cooldown.pop(cap_url, None)
parsed = self._parse_cap(cap_raw, statefips) parsed = self._parse_cap(cap_raw, statefips)
if parsed: if parsed:
new_events.append(parsed) new_events.append(parsed)
# Prune the negative cache to URLs still present in the feed, so it
# tracks the rolling window and never grows without bound.
self._stage2_cooldown = {
u: t for u, t in self._stage2_cooldown.items() if u in seen_urls
}
# Change detection on the active identifier set. # Change detection on the active identifier set.
old_ids = {e["event_id"] for e in self._events} old_ids = {e["event_id"] for e in self._events}
new_ids = {e["event_id"] for e in new_events} new_ids = {e["event_id"] for e in new_events}
@ -297,6 +336,30 @@ class IPAWSAlertsAdapter:
certainty = _t(info, "certainty") certainty = _t(info, "certainty")
headline = _t(info, "headline") headline = _t(info, "headline")
description = _t(info, "description") description = _t(info, "description")
instruction = _t(info, "instruction")
# ── <parameter> blocks: valueName -> value ────────────────────────────
# Civil alerts carry their real public-facing text here (CMAMtext /
# CMAMlongtext are the agency's own purpose-written WEA copy) — this was
# previously discarded entirely. A repeated valueName (e.g. multiple
# BLOCKCHANNEL entries) becomes a list; defensive against malformed/
# missing valueName or value children (skipped, never raises).
parameters: dict = {}
for param in info.findall(f"{{{_CAP_NS}}}parameter"):
vn = param.find(f"{{{_CAP_NS}}}valueName")
vv = param.find(f"{{{_CAP_NS}}}value")
name = vn.text.strip() if vn is not None and vn.text else ""
if not name:
continue
value = vv.text.strip() if vv is not None and vv.text else ""
if name in parameters:
existing = parameters[name]
if isinstance(existing, list):
existing.append(value)
else:
parameters[name] = [existing, value]
else:
parameters[name] = value
# eventCode SAME value # eventCode SAME value
same_value = "" same_value = ""
@ -377,6 +440,8 @@ class IPAWSAlertsAdapter:
"msgType": msg_type, "msgType": msg_type,
"headline": headline, "headline": headline,
"description": description, "description": description,
"instruction": instruction,
"parameters": parameters,
"same_code": same_value, "same_code": same_value,
"area_desc": area_desc, "area_desc": area_desc,
"area_same_codes": area_same_codes, "area_same_codes": area_same_codes,
@ -491,7 +556,8 @@ class IPAWSAlertsAdapter:
"area_desc": area_desc, "area_desc": area_desc,
"geocoder": {"city": None, "county": area_desc, "state": None}, "geocoder": {"city": None, "county": area_desc, "state": None},
"description": raw.get("description", ""), "description": raw.get("description", ""),
"parameters": {}, "instruction": raw.get("instruction", ""),
"parameters": raw.get("parameters") or {},
"msgType": raw.get("msgType", "Alert"), "msgType": raw.get("msgType", "Alert"),
"references": [], "references": [],
"category": category, "category": category,

View file

@ -0,0 +1,95 @@
"""Idaho READY / SET / GO evacuation-phase detection from free-text CAP content.
Real-world FEMA IPAWS civil alerts do NOT carry a machine-readable phase
CAP ``<responseType>`` does not exist in the wild. The phase (READY / SET /
GO) instead shows up as free text inside ``headline``, ``description``, and
the ``CMAMtext``/``CMAMlongtext`` ``<parameter>`` values, phrased however the
issuing agency happened to write it ("Level 3 GO NOW", "Set to GO",
"Evacuation Warning", ...). This module scans that free text for the phrases
agencies actually use and returns the phase, or ``None`` when nothing
matches callers must never guess a phase, since a false GO would broadcast
an evacuation order that was never issued.
"""
from __future__ import annotations
import re
# ── Strong multi-word phrases (checked case-insensitively) ───────────────────
# ANY match sets that phase's hit flag. Order within a list is irrelevant —
# the final result is decided purely by GO > SET > READY precedence below,
# never by which phrase or text argument matched first.
_GO_PHRASES = [
r"\bGO\s+NOW\b",
r"\bLEVEL\s+3\s+GO\b",
r"\bLEVEL\s+3\b",
r"\bLEVEL\s+III\b",
r"\bGO\s+EVACUATION\b",
r"\bIMMEDIATE\s+EVACUATION\b",
r"\bEVACUATE\s+NOW\b",
r"\bEVACUATION\s+ORDER\b",
r"\bSET\s+TO\s+GO\b", # "Set to GO" — the standalone-GO idiom, spelled out
]
_SET_PHRASES = [
r"\bLEVEL\s+2\b",
r"\bLEVEL\s+II\b",
r"\bPREPARE\s+TO\s+EVACUATE\b",
r"\bEVACUATION\s+WARNING\b",
r"\bBE\s+READY\s+TO\s+LEAVE\b",
]
_READY_PHRASES = [
r"\bLEVEL\s+1\b",
r"\bLEVEL\s+I\b",
r"\bEVACUATION\s+ADVISORY\b",
]
_PRECEDENCE = ("GO", "SET", "READY")
_PHRASE_RE = {
"GO": re.compile("|".join(_GO_PHRASES), re.IGNORECASE),
"SET": re.compile("|".join(_SET_PHRASES), re.IGNORECASE),
"READY": re.compile("|".join(_READY_PHRASES), re.IGNORECASE),
}
# A bare level-word (GO/SET/READY) counts ONLY when it is:
# 1. standalone (word-boundaried) AND written in the exact uppercase form
# (narrative prose never shouts a whole word in caps: "go to the
# fairgrounds", "set to arrive", "via Go Creek Road" all fail this), AND
# 2. accompanied elsewhere in the same text by other alert/evacuation
# vocabulary, so a bare "GO"/"SET"/"READY" floating in unrelated text
# can't fire on its own.
# This is what lets "LEVEL I SET Alert" resolve as SET (word beats numeral —
# err upward) even though "LEVEL I" alone would read as READY.
_STANDALONE_TOKEN_RE = {
"GO": re.compile(r"\bGO\b"),
"SET": re.compile(r"\bSET\b"),
"READY": re.compile(r"\bREADY\b"),
}
_CONTEXT_CUE_RE = re.compile(
r"evacuat|level|alert|status|notice|prepar|leave|order|warning|advisory",
re.IGNORECASE,
)
def detect_phase(*texts: "str | None") -> "str | None":
"""Scan the given texts for Idaho READY/SET/GO evacuation-phase language.
All provided texts are combined and scanned together (case-insensitive
for the strong phrases). The HIGHEST phase found wins GO > SET > READY
never the first match. Returns None when nothing matches; never
guesses.
"""
combined = "\n".join(t for t in texts if t)
if not combined:
return None
has_cue = bool(_CONTEXT_CUE_RE.search(combined))
for phase in _PRECEDENCE:
if _PHRASE_RE[phase].search(combined):
return phase
if has_cue and _STANDALONE_TOKEN_RE[phase].search(combined):
return phase
return None

View file

@ -6,11 +6,19 @@ Message, AMBER, 911 outage, law enforcement, HazMat) from the canonical CAP
Civil alerts carry their signal in the CAP ``headline`` (a plain human Civil alerts carry their signal in the CAP ``headline`` (a plain human
sentence), so unlike the weather formatter, which parses structured sentence), so unlike the weather formatter, which parses structured
HAZARD.../IMPACT... blocks this formatter is headline-forward: HAZARD.../IMPACT... blocks this formatter is headline-forward. Real CAP
data has no machine-readable Idaho READY/SET/GO evacuation phase (there is
no ``<responseType>`` in the wild); ``evac_phase.detect_phase`` scans the
headline/CMAMtext/description free text for the phrases agencies actually
use ("Level 3 GO NOW", "Evacuation Warning", ...) so the wire message can
lead with the phase instead of raw CAP jargon:
Line 1: {emoji} {prefix}{event} e.g. "🚨 Evacuation Immediate" Line 1 (phase detected): {emoji} {prefix}{PHASE} {action}
e.g. "🚨 GO — Leave now"
Line 1 (no phase found unchanged fallback):
{emoji} {prefix}{event} e.g. "🚨 Evacuation Immediate"
Line 2: {area}[ · Until {t} {tz}] areaDesc (first area) + expiry Line 2: {area}[ · Until {t} {tz}] areaDesc (first area) + expiry
Line 3: {headline} the operator's message Line 3: {CMAMtext or headline} the agency's own public alert text
Reuses ``event.data`` (canonical) + ``_budget.fit_to_budget``; does NOT touch Reuses ``event.data`` (canonical) + ``_budget.fit_to_budget``; does NOT touch
the NWS formatter. ``now`` is a structural seam (not used expiry is absolute). the NWS formatter. ``now`` is a structural seam (not used expiry is absolute).
@ -21,6 +29,7 @@ import zoneinfo
from datetime import datetime from datetime import datetime
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
from meshai.notifications.evac_phase import detect_phase
from meshai.notifications.formatters._budget import fit_to_budget from meshai.notifications.formatters._budget import fit_to_budget
if TYPE_CHECKING: if TYPE_CHECKING:
@ -37,6 +46,13 @@ _CATEGORY_EMOJI = {
"emergency_civil": "⚠️", "emergency_civil": "⚠️",
} }
# Idaho READY/SET/GO — the one-line action tied to each detected phase.
_PHASE_ACTION = {
"READY": "Get prepared",
"SET": "Be ready to leave",
"GO": "Leave now",
}
def format(event: "Event", *, now: float, budget: int) -> str: def format(event: "Event", *, now: float, budget: int) -> str:
"""Render the IPAWS civil-alert wire string from canonical event.data. """Render the IPAWS civil-alert wire string from canonical event.data.
@ -54,6 +70,11 @@ def format(event: "Event", *, now: float, budget: int) -> str:
event_type = d.get("event") or "Emergency Alert" event_type = d.get("event") or "Emergency Alert"
area_desc = d.get("area_desc") or "" area_desc = d.get("area_desc") or ""
headline = (d.get("headline") or "").strip() headline = (d.get("headline") or "").strip()
description = d.get("description") or ""
parameters = d.get("parameters") or {}
cmam_text = parameters.get("CMAMtext") or ""
if isinstance(cmam_text, list): # defensive: a repeated valueName collapses to a list
cmam_text = cmam_text[0] if cmam_text else ""
expires_epoch = d.get("expires_at") expires_epoch = d.get("expires_at")
prefix = d.get("_ipaws_prefix") or "" prefix = d.get("_ipaws_prefix") or ""
category = d.get("category") or event.category or "emergency_civil" category = d.get("category") or event.category or "emergency_civil"
@ -61,8 +82,20 @@ def format(event: "Event", *, now: float, budget: int) -> str:
emoji = _CATEGORY_EMOJI.get(category, "⚠️") emoji = _CATEGORY_EMOJI.get(category, "⚠️")
prefix_seg = f"{prefix}: " if prefix else "" prefix_seg = f"{prefix}: " if prefix else ""
# Line 1: emoji + prefix + event type # Line 1: lead with the Idaho READY/SET/GO phase when the free text
line1 = f"{emoji} {prefix_seg}{event_type}" # actually names one; otherwise keep the raw CAP event string (safe
# fallback — never invent a phase that isn't there).
phase = detect_phase(headline, cmam_text, description)
if phase:
# A detected GO always gets the alarm emoji, regardless of CAP
# category — a confirmed "leave now" evacuation must never render
# with the softer category-based ⚠️ (e.g. emergency_civil).
if phase == "GO":
emoji = "🚨"
action = _PHASE_ACTION[phase]
line1 = f"{emoji} {prefix_seg}{phase}{action}"
else:
line1 = f"{emoji} {prefix_seg}{event_type}"
# Line 2: first area + optional expiry ("Until 4:54 PM MDT") # Line 2: first area + optional expiry ("Until 4:54 PM MDT")
area = (area_desc or "").split(";")[0].strip() area = (area_desc or "").split(";")[0].strip()
@ -79,8 +112,9 @@ def format(event: "Event", *, now: float, budget: int) -> str:
else: else:
line2 = area or time_seg line2 = area or time_seg
# Line 3: the headline (the actual civil message) # Line 3: the agency's own public alert text (CMAMtext — purpose-written
line3 = headline # for WEA, ~90 chars) when present; else fall back to the headline as before.
line3 = cmam_text.strip() or headline
msg = "\n".join(ln for ln in (line1, line2, line3) if ln) msg = "\n".join(ln for ln in (line1, line2, line3) if ln)
return fit_to_budget(msg, budget) return fit_to_budget(msg, budget)

View file

@ -349,6 +349,12 @@ def test_decider_uses_own_table_not_nws(_isolate_meshai_db):
# ============================================================ # ============================================================
def test_formatter_renders_civil_alert(): def test_formatter_renders_civil_alert():
"""Idaho CEM headline ("Wildfire Immediate Evacuation Alert") names the GO
phase explicitly ("Immediate Evacuation"), so line 1 leads with GO instead
of the raw CAP event string the whole point of this feature. Category
is emergency_civil (normally ), but a detected GO forces the alarm
emoji regardless of category a confirmed "leave now" must never render
with the softer warning icon."""
from meshai.notifications.formatters.ipaws import format as ipaws_format from meshai.notifications.formatters.ipaws import format as ipaws_format
a = IPAWSAlertsAdapter(_config()) a = IPAWSAlertsAdapter(_config())
raw = a._parse_cap(_fx("eas_idaho_cem.xml"), "16") raw = a._parse_cap(_fx("eas_idaho_cem.xml"), "16")
@ -357,20 +363,73 @@ def test_formatter_renders_civil_alert():
wire = ipaws_format(ev, now=1000.0, budget=200) wire = ipaws_format(ev, now=1000.0, budget=200)
assert len(wire) <= 200 assert len(wire) <= 200
lines = wire.split("\n") lines = wire.split("\n")
assert lines[0].startswith("⚠️") # civil -> warning emoji assert lines[0].startswith("🚨") # GO overrides civil's ⚠️ -> alarm emoji
assert "Civil Emergency Message" in lines[0] assert "GO" in lines[0] and "Leave now" in lines[0]
assert "Boundary County" in wire assert "Boundary County" in wire
assert "evacuation" in wire.lower() # headline carried the signal assert "evacuation" in wire.lower() # headline carried the signal
def test_formatter_evacuation_emoji(): def test_formatter_evacuation_emoji():
"""Oregon EVI has no phase language in its headline, but its CMAMtext
parameter ("Level 3 GO NOW evacuation notice...") does, and that text is
now parsed and preferred for line 3 so GO must win here too."""
from meshai.notifications.formatters.ipaws import format as ipaws_format from meshai.notifications.formatters.ipaws import format as ipaws_format
a = IPAWSAlertsAdapter(_config()) a = IPAWSAlertsAdapter(_config())
raw = a._parse_cap(_fx("eas_oregon_evi.xml"), "41") raw = a._parse_cap(_fx("eas_oregon_evi.xml"), "41")
ev = a.to_event(raw) ev = a.to_event(raw)
wire = ipaws_format(ev, now=1000.0, budget=200) wire = ipaws_format(ev, now=1000.0, budget=200)
assert wire.startswith("🚨") # evacuation -> alarm emoji assert wire.startswith("🚨") # evacuation -> alarm emoji
assert "Evacuation Immediate" in wire lines = wire.split("\n")
assert "GO" in lines[0] and "Leave now" in lines[0]
assert "Level 3 GO NOW" in wire # CMAMtext carried into line 3
def test_formatter_full_three_line_go_rendering():
"""Full 3-line render for a real GO alert (Oregon EVI): phase-led line 1,
area+expiry line 2, CMAMtext line 3 exact text, not just substrings."""
from meshai.notifications.formatters.ipaws import format as ipaws_format
a = IPAWSAlertsAdapter(_config())
raw = a._parse_cap(_fx("eas_oregon_evi.xml"), "41")
ev = a.to_event(raw)
ev.data["_ipaws_prefix"] = ""
wire = ipaws_format(ev, now=1000.0, budget=200)
lines = wire.split("\n")
assert lines[0] == "🚨 GO — Leave now"
assert lines[1] == "Jackson County · Until 8:50 AM MDT"
assert lines[2] == (
"Wildfire Alert- Level 3 GO NOW evacuation notice is UPGRADED for JAC-126"
)
def test_formatter_no_phase_fallback_unchanged():
"""When no READY/SET/GO language is present anywhere in headline/CMAMtext/
description, line 1 keeps the CURRENT raw-event-string behaviour exactly
the safe fallback this feature must never break."""
from meshai.notifications.formatters.ipaws import format as ipaws_format
from meshai.notifications.events import make_event
canonical = _canonical(
event="Civil Emergency Message",
headline="Boil water advisory issued for the district",
description="A water main break has contaminated the supply.",
parameters={},
expires_at=None,
)
ev = make_event(
source="ipaws",
category="emergency_civil",
severity="priority",
title=canonical["headline"],
summary=canonical["headline"],
body=canonical["description"],
data=canonical,
)
ev.data["_ipaws_prefix"] = ""
wire = ipaws_format(ev, now=1000.0, budget=200)
lines = wire.split("\n")
assert lines[0] == "⚠️ Civil Emergency Message" # unchanged fallback (no phase)
assert lines[1] == "Boundary County"
assert lines[2] == "Boil water advisory issued for the district"
# ============================================================ # ============================================================
@ -386,3 +445,36 @@ def test_coverage_state_fips_for_bbox():
resolved = resolve_adapter_coverage("ipaws", idaho_bbox, "native") resolved = resolve_adapter_coverage("ipaws", idaho_bbox, "native")
assert "16" in resolved["state_fips"] assert "16" in resolved["state_fips"]
assert resolved["bbox"] == [round(c, 6) for c in idaho_bbox] assert resolved["bbox"] == [round(c, 6) for c in idaho_bbox]
# ============================================================
# stage-2 failure negative cache (no re-hammering FEMA)
# ============================================================
def test_stage2_forbidden_is_negative_cached():
"""A 403 on a stage-2 detail URL is remembered so the next poll tick does
NOT re-fetch it, while a sibling URL that succeeds is fetched every pass."""
from urllib.error import HTTPError
counts: dict[str, int] = {}
def _urlopen(req, timeout=None):
url = req.full_url
counts[url] = counts.get(url, 0) + 1
if url.endswith("/feed"):
return _FakeResp(_fx("feed.xml"))
if url.endswith("/eas/300130859542"): # Idaho CEM -> forbidden
raise HTTPError(url, 403, "Forbidden", {}, None)
if url.endswith("/eas/300130856756"): # Oregon EVI -> ok
return _FakeResp(_fx("eas_oregon_evi.xml"))
raise AssertionError(f"unexpected fetch: {url}")
a = IPAWSAlertsAdapter(_config())
with patch("meshai.env.ipaws.urlopen", _urlopen):
a._fetch()
a._fetch()
cem = next(u for u in counts if u.endswith("/eas/300130859542"))
evi = next(u for u in counts if u.endswith("/eas/300130856756"))
assert counts[cem] == 1 # 403 -> negative-cached, not re-fetched
assert counts[evi] == 2 # success -> fetched on each pass

View file

@ -0,0 +1,76 @@
"""Tests for meshai.notifications.evac_phase.detect_phase.
Cases are drawn from REAL FEMA IPAWS alert headline/CMAMtext/description
strings (see tests/fixtures/ipaws/) plus explicit false-positive guards, since
a wrong GO detection would broadcast an evacuation order that was never
issued.
"""
from __future__ import annotations
import pytest
from meshai.notifications.evac_phase import detect_phase
# ============================================================
# real strings -> expected phase
# ============================================================
@pytest.mark.parametrize("text, expected", [
("LEVEL 1 READY - Evacuation Status", "READY"),
("Level 2 Set Alert", "SET"),
("LEVEL I SET Alert", "SET"), # word beats numeral; err upward
("Level 3 - Go Now", "GO"),
("Level 3- Go Now", "GO"),
("Ohio Gulch GO Evacuation Status", "GO"),
("Indian Creek Set to GO.", "GO"), # highest wins, NOT SET
("Immediate Evacuation", "GO"),
("Prepare to Evacuate", "SET"),
("BRUSH FIRE", None),
("Owyhee County Closure Area", None),
("Endangered Missing Person Alert", None),
(
"Jackson County Sheriff's Office- Level 3 GO NOW evacuation notice "
"UPGRADED for JAC-126",
"GO",
),
])
def test_detect_phase_real_strings(text, expected):
assert detect_phase(text) == expected
# ============================================================
# false-positive guards — bare lowercase / narrative usage must NOT match
# ============================================================
@pytest.mark.parametrize("text", [
"residents should go to the Blaine County Fairgrounds",
"crews are set to arrive by 0600",
"evacuate via Go Creek Road",
])
def test_detect_phase_false_positive_guards(text):
assert detect_phase(text) is None
# ============================================================
# precedence + multi-arg scanning
# ============================================================
def test_precedence_highest_always_wins_regardless_of_arg_order():
# SET language in the first text, GO language in the second — GO must win.
assert detect_phase("Prepare to Evacuate", "Level 3 - Go Now") == "GO"
# Same phrases, arguments reversed — still GO (order-independent).
assert detect_phase("Level 3 - Go Now", "Prepare to Evacuate") == "GO"
def test_detect_phase_scans_all_texts_together():
# No single text alone carries a phase; combined they do (SET here,
# since "LEVEL 2" is an explicit SET phrase and no GO phrase is present).
assert detect_phase("Jackson County Sheriff's Office", "Level 2 Set Alert") == "SET"
def test_detect_phase_none_texts_and_empty_input_are_safe():
assert detect_phase(None, None) is None
assert detect_phase() is None
assert detect_phase("") is None
assert detect_phase(None, "BRUSH FIRE", None) is None