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>
118 lines
4.7 KiB
Python
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}
|