Alerts: confidence gate before any push, held alerts, release on corroboration (server-26#300)

No confidence field exists anywhere in scene/incident data, so the alerter
derives one: match weight (talkgroup 1.0, extracted tag 0.8, raw transcript
0.6) scaled down only when stt_confidence is under ALERT_STT_REFERENCE (0.3),
zero for not_speech. Below ALERT_MIN_CONFIDENCE, or high severity (term list,
or a major incident) without ALERT_CORROBORATION_MIN_CALLS calls or
ALERT_HIGH_SEVERITY_MIN_CONFIDENCE, the match is written to alert_holds
(no Firestore rule, admin-only GET /alerts/held) and logged instead of pushed.
A needs_corroboration hold is released when a later call brings its incident
to the call minimum; a rule pushed by that call supersedes its hold.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Logan Cusano
2026-10-27 20:29:01 -04:00
co-authored by Claude Opus 5.5
parent 240bbee5a4
commit ff0a89e9f1
4 changed files with 421 additions and 1 deletions
+24
View File
@@ -163,6 +163,30 @@ class Settings(BaseSettings):
# else's PII. Both gates must be open before any snippet leaves. # else's PII. Both gates must be open before any snippet leaves.
alert_transcript_snippet_enabled: bool = False alert_transcript_snippet_enabled: bool = False
place_verify_max_per_call: int = 3 place_verify_max_per_call: int = 3
# ── Alert confidence gate (server-26#300) ─────────────────────────────────
# Nothing in the pipeline emits a confidence, so the alerter derives one:
# how the rule matched (talkgroup 1.0 / extracted tag 0.8 / raw transcript
# text only 0.5 -- the CrimeRadar "fire alarm" -> "firearm" failure was a
# transcript match) scaled down when Whisper's own per-token probability
# (calls.stt_confidence) is below alert_stt_reference. Below the floor ->
# held, never pushed. High-severity matches also need the incident to
# have alert_corroboration_min_calls calls, or a confidence at or above
# alert_high_severity_min_confidence; held ones are released when a
# later call corroborates the incident. Starting values, not measured.
alert_min_confidence: float = 0.5
alert_high_severity_min_confidence: float = 0.85
alert_corroboration_min_calls: int = 2
# Low on purpose: exp(avg_logprob) on vocoded radio audio is often well
# under 0.5 for speech that is right, and Groq/OpenAI logprobs are not on
# a shared scale. Only clearly garbled audio should pull the score down.
alert_stt_reference: float = 0.3
alert_high_severity_terms: list[str] = [
"shots-fired", "shots fired", "shooting", "shot", "gun", "firearm", "weapon",
"stabbing", "stabbed", "officer-down", "officer down", "10-13",
"officer-needs-assistance", "active-shooter", "hostage", "barricade",
"explosion", "structure-fire", "structure fire", "working-fire",
"entrapment", "cardiac-arrest", "vehicle-pursuit", "pursuit",
]
# How close a candidate has to sound before it may rewrite a transcript. # How close a candidate has to sound before it may rewrite a transcript.
# Below this, Places Text Search will confidently hand back the nearest # Below this, Places Text Search will confidently hand back the nearest
# business for any garbage string. # business for any garbage string.
+208 -1
View File
@@ -2,9 +2,19 @@
Alert dispatch engine. Alert dispatch engine.
Loads enabled alert rules from Firestore and checks each one against the call's Loads enabled alert rules from Firestore and checks each one against the call's
talkgroup ID, tags, and transcript. On a match: talkgroup ID, tags, and transcript. On a match that passes the confidence
gate (server-26#300, `_assess`):
1. Creates an AlertEvent document in Firestore. 1. Creates an AlertEvent document in Firestore.
2. Optionally POSTs a Discord webhook message if the rule has one configured. 2. Optionally POSTs a Discord webhook message if the rule has one configured.
A match that fails the gate is written to `alert_holds` instead -- never
pushed, readable by admins (GET /alerts/held) -- and a hold that was waiting
only on corroboration is released when a later call brings its incident to
alert_corroboration_min_calls calls.
WHY A GATE: CrimeRadar pushed "shots fired at an elementary school" after STT
heard "fire alarm" as "firearm". A keyword hit in raw transcript text is the
weakest evidence this pipeline has, and a push alert is the one surface that
cannot be taken back.
Raw transcript text is withheld from both by default -- see _snippet_allowed Raw transcript text is withheld from both by default -- see _snippet_allowed
and server-26#85. and server-26#85.
@@ -54,6 +64,8 @@ async def check_and_dispatch(
# Loop-invariant: every rule here belongs to the same org, so the opt-in is # Loop-invariant: every rule here belongs to the same org, so the opt-in is
# resolved once rather than per match. # resolved once rather than per match.
snippet_allowed = await _snippet_allowed(org_id) snippet_allowed = await _snippet_allowed(org_id)
evidence = await _evidence(call_doc or {})
pushed_rules: set[str] = set()
for rule in rules: for rule in rules:
matched_keywords = _match_rule(rule, talkgroup_id, tags, transcript) matched_keywords = _match_rule(rule, talkgroup_id, tags, transcript)
@@ -63,6 +75,12 @@ async def check_and_dispatch(
alert_id = str(uuid.uuid4()) alert_id = str(uuid.uuid4())
snippet = _snippet(transcript) if snippet_allowed else None snippet = _snippet(transcript) if snippet_allowed else None
now = datetime.now(timezone.utc).isoformat() now = datetime.now(timezone.utc).isoformat()
verdict = _assess(matched_keywords, tags, evidence)
if not verdict["push"]:
await _hold(alert_id, rule, org_id, call_id, node_id, talkgroup_id,
talkgroup_name, matched_keywords, snippet, now, verdict, evidence)
continue
pushed_rules.add(rule.get("rule_id", ""))
event = { event = {
"alert_id": alert_id, "alert_id": alert_id,
"org_id": org_id, "org_id": org_id,
@@ -76,6 +94,9 @@ async def check_and_dispatch(
"transcript_snippet": snippet, "transcript_snippet": snippet,
"triggered_at": now, "triggered_at": now,
"acknowledged": False, "acknowledged": False,
"confidence": verdict["confidence"],
"high_severity": verdict["high_severity"],
"corroborating_calls": evidence["max_calls"],
} }
try: try:
@@ -92,6 +113,192 @@ async def check_and_dispatch(
if webhook_url: if webhook_url:
await _post_webhook(webhook_url, rule.get("name", ""), talkgroup_name, matched_keywords, snippet) await _post_webhook(webhook_url, rule.get("name", ""), talkgroup_name, matched_keywords, snippet)
await _release_corroborated(rules, evidence, call_id, pushed_rules)
# ── Confidence gate (server-26#300) ─────────────────────────────────────────
# How a rule matched, as evidence. Talkgroup activity is metadata, not
# recognised speech, so it cannot be misheard.
_W_TALKGROUP = 1.0
_W_TAG = 0.8 # the extraction model put it in tags
_W_TRANSCRIPT = 0.6 # substring of raw STT text only (above the 0.5 default floor)
def _norm_term(term: str) -> str:
return (term or "").strip().lower().replace("-", " ")
def _is_high_severity_term(term: str) -> bool:
wanted = {_norm_term(t) for t in settings.alert_high_severity_terms}
return _norm_term(term) in wanted
async def _evidence(call_doc: dict) -> dict:
"""What the call doc and its incidents say about how much to trust it."""
incident_ids = list(call_doc.get("incident_ids") or [])
max_calls = 0
max_severity = call_doc.get("severity")
severity_rank = {"routine": 0, "minor": 1, "moderate": 2, "major": 3}
for iid in incident_ids:
try:
inc = await fstore.doc_get("incidents", iid) or {}
except Exception as e:
logger.warning(f"Alerter: could not read incident {iid}: {e}")
continue
max_calls = max(max_calls, len(inc.get("call_ids") or []))
if severity_rank.get(inc.get("severity"), -1) > severity_rank.get(max_severity, -1):
max_severity = inc.get("severity")
stt = call_doc.get("stt_confidence")
return {
"incident_ids": incident_ids,
"max_calls": max_calls,
"severity": max_severity,
"stt_confidence": stt if isinstance(stt, (int, float)) else None,
"not_speech": bool(call_doc.get("transcript_not_speech")),
}
def _assess(matched: list[str], tags: list[str], evidence: dict) -> dict:
"""
Push or hold, and why. Pure, so the gate is testable without Firestore.
confidence = match weight x STT factor. The STT factor is 1.0 unless
Whisper's own mean token probability is under alert_stt_reference, and
1.0 when there is none (a json-only model) rather than guessing.
"""
content = [m for m in matched if not m.startswith("talkgroup:")]
if not content:
weight = _W_TALKGROUP
elif any(m in (tags or []) for m in content):
weight = _W_TAG
else:
weight = _W_TRANSCRIPT
stt = evidence.get("stt_confidence")
stt_factor = 1.0
if content and stt is not None and settings.alert_stt_reference > 0:
stt_factor = min(1.0, max(0.0, stt / settings.alert_stt_reference))
if content and evidence.get("not_speech"):
stt_factor = 0.0 # the corrector called this recogniser noise
confidence = round(weight * stt_factor, 3)
high = bool(content) and (
any(_is_high_severity_term(m) for m in content)
or any(_is_high_severity_term(t) for t in (tags or []))
or evidence.get("severity") == "major"
)
corroborated = evidence.get("max_calls", 0) >= settings.alert_corroboration_min_calls
if confidence < settings.alert_min_confidence:
return {"push": False, "confidence": confidence, "high_severity": high,
"hold_reason": "low_confidence"}
if high and not corroborated and confidence < settings.alert_high_severity_min_confidence:
return {"push": False, "confidence": confidence, "high_severity": high,
"hold_reason": "needs_corroboration"}
return {"push": True, "confidence": confidence, "high_severity": high, "hold_reason": None}
async def _hold(alert_id, rule, org_id, call_id, node_id, talkgroup_id, talkgroup_name,
matched_keywords, snippet, now, verdict, evidence) -> None:
"""Record a gated match where admins can see it, and push nothing."""
hold = {
"alert_id": alert_id,
"org_id": org_id,
"rule_id": rule.get("rule_id", ""),
"rule_name": rule.get("name", ""),
"call_id": call_id,
"node_id": node_id,
"talkgroup_id": talkgroup_id,
"talkgroup_name": talkgroup_name or "",
"matched_keywords": matched_keywords,
"transcript_snippet": snippet,
"triggered_at": now,
"status": "held",
"hold_reason": verdict["hold_reason"],
"confidence": verdict["confidence"],
"high_severity": verdict["high_severity"],
# One incident to wait on: the first the call linked to. A call that
# linked to none can never be corroborated, so it stays held.
"incident_id": (evidence["incident_ids"] or [None])[0],
"corroborating_calls": evidence["max_calls"],
}
try:
await fstore.doc_set("alert_holds", alert_id, hold, merge=False)
except Exception as e:
logger.warning(f"Alerter: could not save held alert: {e}")
return
logger.info(
f"Alert HELD ({verdict['hold_reason']}, confidence={verdict['confidence']}, "
f"high_severity={verdict['high_severity']}, calls={evidence['max_calls']}): "
f"rule='{rule.get('name')}' call={call_id} keywords={matched_keywords}"
)
async def _release_corroborated(rules: list[dict], evidence: dict, call_id: str,
pushed_rules: set[str]) -> None:
"""
Push holds that were waiting only on corroboration once their incident
has enough calls. A low-confidence hold is never released this way: a
second call on the incident says the event is real, not that the first
call was heard right. A rule this call already pushed for supersedes its
hold instead of alerting twice.
"""
if evidence["max_calls"] < settings.alert_corroboration_min_calls:
return
by_id = {r.get("rule_id", ""): r for r in rules}
for iid in evidence["incident_ids"]:
try:
holds = await fstore.collection_list("alert_holds", incident_id=iid, status="held")
except Exception as e:
logger.warning(f"Alerter: could not read holds for incident {iid}: {e}")
continue
for hold in holds:
if hold.get("hold_reason") != "needs_corroboration":
continue
rule = by_id.get(hold.get("rule_id", ""))
now = datetime.now(timezone.utc).isoformat()
if rule is None or hold.get("rule_id", "") in pushed_rules:
status = "superseded" if rule is not None else "rule_gone"
await _set_hold_status(hold, status, call_id, now)
continue
event = {k: hold.get(k) for k in (
"alert_id", "org_id", "rule_id", "rule_name", "call_id", "node_id",
"talkgroup_id", "talkgroup_name", "matched_keywords",
"transcript_snippet", "confidence", "high_severity",
)}
event.update({
"triggered_at": now,
"acknowledged": False,
"released_from_hold": True,
"corroborated_by_call_id": call_id,
"corroborating_calls": evidence["max_calls"],
})
try:
await fstore.doc_set("alert_events", hold["alert_id"], event, merge=False)
except Exception as e:
logger.warning(f"Alerter: could not release held alert {hold.get('alert_id')}: {e}")
continue
await _set_hold_status(hold, "released", call_id, now)
pushed_rules.add(hold.get("rule_id", ""))
logger.info(
f"Alert released from hold: rule='{hold.get('rule_name')}' incident={iid} "
f"corroborated by call {call_id} ({evidence['max_calls']} calls)"
)
webhook_url = rule.get("discord_webhook")
if webhook_url:
await _post_webhook(webhook_url, rule.get("name", ""), hold.get("talkgroup_name"),
hold.get("matched_keywords") or [], hold.get("transcript_snippet"))
async def _set_hold_status(hold: dict, status: str, call_id: str, now: str) -> None:
try:
await fstore.doc_update("alert_holds", hold["alert_id"], {
"status": status, "resolved_at": now, "resolved_by_call_id": call_id,
})
except Exception as e:
logger.warning(f"Alerter: could not update hold {hold.get('alert_id')}: {e}")
async def _snippet_allowed(org_id: Optional[str]) -> bool: async def _snippet_allowed(org_id: Optional[str]) -> bool:
""" """
+21
View File
@@ -28,6 +28,27 @@ async def list_alerts(
return await fstore.collection_list("alert_events", **filters) return await fstore.collection_list("alert_events", **filters)
@router.get("/alerts/held")
async def list_held_alerts(
status: Optional[str] = Query("held", description="held | released | superseded | rule_gone; empty = all"),
org_id: Optional[str] = Query(None, description="Platform-admin only — omit for every org."),
_: dict = Depends(require_admin_token),
):
"""
Rule matches the confidence gate kept from being pushed (server-26#300).
Admin-only: a held match is by definition one we do not trust enough to
show, so it does not belong on any customer surface (and alert_holds has
no Firestore rule, so the browser cannot read it directly either).
"""
filters = {}
if status:
filters["status"] = status
if org_id:
filters["org_id"] = org_id
holds = await fstore.collection_list("alert_holds", **filters)
return sorted(holds, key=lambda h: h.get("triggered_at") or "", reverse=True)
@router.post("/alerts/{alert_id}/acknowledge") @router.post("/alerts/{alert_id}/acknowledge")
async def acknowledge_alert(alert_id: str, decoded: dict = Depends(require_service_or_firebase_token)): async def acknowledge_alert(alert_id: str, decoded: dict = Depends(require_service_or_firebase_token)):
doc = await fstore.doc_get("alert_events", alert_id) doc = await fstore.doc_get("alert_events", alert_id)
+168
View File
@@ -0,0 +1,168 @@
"""
server-26#300 — confidence gate before any push alert.
CrimeRadar pushed "shots fired at an elementary school" after STT heard "fire
alarm" as "firearm". A single uncorroborated transcript hit on a
high-severity term must be held, not pushed; a corroborated incident releases
it; low-confidence matches never push.
"""
import pytest
from unittest.mock import AsyncMock, patch
from app.internal import alerter
from tests.fake_firestore import FakeFirestore
def _ev(**over):
base = {"incident_ids": [], "max_calls": 0, "severity": None,
"stt_confidence": None, "not_speech": False}
base.update(over)
return base
# ── the pure decision ───────────────────────────────────────────────────────
def test_talkgroup_only_rule_always_pushes():
v = alerter._assess(["talkgroup:9048"], [], _ev(stt_confidence=0.1))
assert v["push"] and v["confidence"] == 1.0
def test_transcript_only_firearm_single_call_is_held():
v = alerter._assess(["firearm"], [], _ev())
assert not v["push"] and v["hold_reason"] == "needs_corroboration"
assert v["high_severity"] is True
def test_tagged_shots_fired_still_needs_corroboration():
v = alerter._assess(["shots-fired"], ["shots-fired"], _ev(max_calls=1))
assert not v["push"] and v["confidence"] == 0.8
def test_corroborated_high_severity_pushes():
v = alerter._assess(["shots-fired"], ["shots-fired"], _ev(max_calls=2))
assert v["push"]
def test_major_incident_makes_any_content_match_high_severity():
v = alerter._assess(["school"], [], _ev(severity="major"))
assert v["high_severity"] and not v["push"]
def test_ordinary_tag_match_pushes():
v = alerter._assess(["mva"], ["mva"], _ev(max_calls=1))
assert v["push"] and not v["high_severity"]
def test_low_stt_confidence_holds_even_ordinary_matches():
v = alerter._assess(["mva"], ["mva"], _ev(stt_confidence=0.1))
assert not v["push"] and v["hold_reason"] == "low_confidence"
assert v["confidence"] == round(0.8 * 0.1 / 0.3, 3)
def test_typical_radio_logprob_does_not_hold_a_transcript_match():
"""exp(avg_logprob) of ~0.4 is normal on vocoded audio (review of #300)."""
v = alerter._assess(["mva"], [], _ev(stt_confidence=0.4))
assert v["push"]
def test_not_speech_never_pushes_content():
v = alerter._assess(["mva"], ["mva"], _ev(not_speech=True))
assert not v["push"]
# ── end to end through Firestore ────────────────────────────────────────────
RULE = {"rule_id": "r1", "name": "Weapons", "enabled": True, "org_id": "org-1",
"keywords": ["firearm", "shots-fired"], "discord_webhook": "https://discord.example/wh"}
@pytest.fixture
def fs(monkeypatch):
fake = FakeFirestore.install(monkeypatch)
fake.put("alert_rules", "r1", dict(RULE))
return fake
def _posts():
posted = []
class _Client:
async def __aenter__(self): return self
async def __aexit__(self, *a): return False
async def post(self, url, json=None, **k):
posted.append(json)
return posted, patch("httpx.AsyncClient", lambda *a, **k: _Client())
async def _dispatch(call_id, tags, transcript):
await alerter.check_and_dispatch(call_id=call_id, node_id="n1", talkgroup_id=1,
talkgroup_name="PD Dispatch", tags=tags, transcript=transcript)
@pytest.mark.asyncio
async def test_crimeradar_case_is_held_then_released_by_corroboration(fs):
fs.put("calls", "c1", {"org_id": "org-1", "incident_ids": ["inc-1"]})
fs.put("incidents", "inc-1", {"call_ids": ["c1"], "severity": "moderate"})
posted, http = _posts()
with http:
await _dispatch("c1", [], "report of a firearm at the elementary school")
assert posted == [], "a single uncorroborated firearm hit must not push"
assert fs.data.get("alert_events", {}) == {}
(hold,) = fs.data["alert_holds"].values()
assert hold["status"] == "held" and hold["hold_reason"] == "needs_corroboration"
assert hold["incident_id"] == "inc-1"
# A second call joins the incident; it does not itself mention a firearm.
fs.put("calls", "c2", {"org_id": "org-1", "incident_ids": ["inc-1"]})
fs.put("incidents", "inc-1", {"call_ids": ["c1", "c2"], "severity": "moderate"})
with http:
await _dispatch("c2", [], "units on scene, stand by")
assert len(posted) == 1 and "Weapons" in posted[0]["content"]
(event,) = fs.data["alert_events"].values()
assert event["released_from_hold"] is True and event["corroborated_by_call_id"] == "c2"
assert fs.get("alert_holds", hold["alert_id"])["status"] == "released"
@pytest.mark.asyncio
async def test_low_confidence_hold_is_not_released_by_corroboration(fs):
fs.put("calls", "c1", {"org_id": "org-1", "incident_ids": ["inc-1"], "stt_confidence": 0.1})
fs.put("incidents", "inc-1", {"call_ids": ["c1"]})
posted, http = _posts()
with http:
await _dispatch("c1", [], "firearm")
(hold,) = fs.data["alert_holds"].values()
assert hold["hold_reason"] == "low_confidence"
fs.put("calls", "c2", {"org_id": "org-1", "incident_ids": ["inc-1"]})
fs.put("incidents", "inc-1", {"call_ids": ["c1", "c2"]})
with http:
await _dispatch("c2", [], "units on scene")
assert posted == []
assert fs.get("alert_holds", hold["alert_id"])["status"] == "held"
@pytest.mark.asyncio
async def test_rule_pushed_now_supersedes_its_hold_instead_of_alerting_twice(fs):
fs.put("calls", "c1", {"org_id": "org-1", "incident_ids": ["inc-1"]})
fs.put("incidents", "inc-1", {"call_ids": ["c1"]})
posted, http = _posts()
with http:
await _dispatch("c1", ["shots-fired"], "shots fired")
fs.put("calls", "c2", {"org_id": "org-1", "incident_ids": ["inc-1"]})
fs.put("incidents", "inc-1", {"call_ids": ["c1", "c2"]})
with http:
await _dispatch("c2", ["shots-fired"], "more shots fired")
assert len(posted) == 1
(hold,) = fs.data["alert_holds"].values()
assert hold["status"] == "superseded"
@pytest.mark.asyncio
async def test_held_alerts_are_listed_for_admins(fs):
from app.routers import alerts as alerts_router
fs.put("alert_holds", "h1", {"alert_id": "h1", "status": "held", "triggered_at": "2026-10-27T01:00:00"})
fs.put("alert_holds", "h2", {"alert_id": "h2", "status": "released", "triggered_at": "2026-10-27T02:00:00"})
out = await alerts_router.list_held_alerts(status="held", org_id=None, _={})
assert [h["alert_id"] for h in out] == ["h1"]