Files
server-26/drb-c2-core/app/routers/alerts.py
T
Logan CusanoandClaude Opus 5.5 ff0a89e9f1 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>
2026-10-27 20:29:01 -04:00

118 lines
4.7 KiB
Python

import uuid
from datetime import datetime, timezone
from typing import Optional
from fastapi import APIRouter, HTTPException, Depends, Query
from app.models import AlertRule, AlertRuleUpdate
from app.internal import firestore as fstore
from app.internal.auth import require_admin_token, require_service_or_firebase_token, resolve_caller_org_id
from app.internal.tenancy import FOUNDING_ORG_ID
router = APIRouter(tags=["alerts"])
# ---------------------------------------------------------------------------
# Alert events (triggered alerts)
# ---------------------------------------------------------------------------
@router.get("/alerts")
async def list_alerts(
acknowledged: Optional[bool] = None,
decoded: dict = Depends(require_service_or_firebase_token),
):
filters = {}
if acknowledged is not None:
filters["acknowledged"] = acknowledged
org_id = await resolve_caller_org_id(decoded)
if org_id is not None:
filters["org_id"] = org_id
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")
async def acknowledge_alert(alert_id: str, decoded: dict = Depends(require_service_or_firebase_token)):
doc = await fstore.doc_get("alert_events", alert_id)
if not doc:
raise HTTPException(404, f"Alert '{alert_id}' not found.")
# SAAS_PLAN.md B2c: previously any authenticated viewer could acknowledge
# any org's alerts. org_id may be absent on a pre-tenancy alert_event
# that hasn't been through scripts/backfill_org_id.py yet — allow those
# through rather than making them permanently unacknowledgeable.
org_id = await resolve_caller_org_id(decoded)
if org_id is not None and doc.get("org_id") and doc.get("org_id") != org_id:
raise HTTPException(404, f"Alert '{alert_id}' not found.")
await fstore.doc_update("alert_events", alert_id, {"acknowledged": True})
return {"ok": True}
# ---------------------------------------------------------------------------
# Alert rules
# ---------------------------------------------------------------------------
@router.get("/alert-rules")
async def list_alert_rules(decoded: dict = Depends(require_service_or_firebase_token)):
org_id = await resolve_caller_org_id(decoded)
if org_id is not None:
return await fstore.collection_list("alert_rules", org_id=org_id)
return await fstore.collection_list("alert_rules")
@router.post("/alert-rules")
async def create_alert_rule(
body: AlertRule,
org_id: Optional[str] = Query(None, description="Platform-admin only — defaults to the founding org."),
_: dict = Depends(require_admin_token),
):
rule_id = str(uuid.uuid4())
doc = {
"rule_id": rule_id,
"org_id": org_id or FOUNDING_ORG_ID,
"name": body.name,
"keywords": body.keywords,
"talkgroup_ids": body.talkgroup_ids,
"enabled": body.enabled,
"discord_webhook": body.discord_webhook,
"created_at": datetime.now(timezone.utc).isoformat(),
}
await fstore.doc_set("alert_rules", rule_id, doc, merge=False)
return doc
@router.put("/alert-rules/{rule_id}")
async def update_alert_rule(rule_id: str, body: AlertRuleUpdate, _: dict = Depends(require_admin_token)):
doc = await fstore.doc_get("alert_rules", rule_id)
if not doc:
raise HTTPException(404, f"Alert rule '{rule_id}' not found.")
updates = body.model_dump(exclude_none=True)
await fstore.doc_update("alert_rules", rule_id, updates)
return {**doc, **updates}
@router.delete("/alert-rules/{rule_id}")
async def delete_alert_rule(rule_id: str, _: dict = Depends(require_admin_token)):
doc = await fstore.doc_get("alert_rules", rule_id)
if not doc:
raise HTTPException(404, f"Alert rule '{rule_id}' not found.")
await fstore.doc_delete("alert_rules", rule_id)
return {"ok": True}