Files
server-26/drb-c2-core/app/routers/systems.py
T
Logan CusanoandClaude Sonnet 5 fb0bb15c22
Build & Deploy / Build & push images (push) Successful in 4m10s
Build & Deploy / Deploy to VM (push) Failing after 3m25s
Build & Deploy / Report a failed deploy (push) Successful in 1s
correlator/intelligence: close the incident-clearance gap (dispatch-to-10-8 lifecycle)
Two independent fixes, found by tracing why incidents never actually close
(only 2/281 incidents in 5 correlation debug windows ever got a non-empty
units_cleared; 183 resolved via the 90-min idle sweep instead of a real clear):

1. Pattern B (re-dispatch accept, no explicit 10-8): reassignment=True already
   fired correctly and suppressed the unit from re-linking to its prior
   incident, but nothing ever released the unit FROM that incident — it just
   sat "active" until the idle sweep timed it out. _release_reassigned_units
   now scans other active incidents for unit overlap on a reassignment and
   clears the unit there, reusing the same units_active/units_cleared merge
   (factored out as _apply_unit_clearance) that explicit 10-8 extraction uses.

2. Pattern A (self-clear) extraction was inconsistent for two reasons: no
   per-system unit ID format awareness anywhere in the pipeline (formats vary
   by department with zero shared convention), and the cleared_units prompt
   rule only accepted a unit self-reporting, missing dispatch confirming a
   unit's status back to them. Added system.unit_format_hint (owner-authored
   free text, GET/PUT /systems/{id}/unit-format, no auto-induction yet) fed
   into the extraction prompt, and broadened the cleared_units rule while
   still requiring an identifiable unit ID (guards against bare "10-8"/"clear"
   noise, including Whisper hallucination runs already caught upstream by
   _is_garbage_transcript).

Verified: 401 pass, 0 fail (local Linux venv ~/venvs/drb-5c — see CLAUDE.md
testing-reality note).

server-26#pending — not yet filed, Gitea unreachable from this sandbox.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-20 14:25:01 -04:00

378 lines
15 KiB
Python

import uuid
from fastapi import APIRouter, HTTPException, Depends, Query
from pydantic import BaseModel
from typing import Dict, List, Optional
from app.models import AreaContextBody, SystemCreate, SystemRecord
from app.internal import firestore as fstore
from app.internal import area_context as area_ctx
from app.internal.auth import (
require_admin_token,
require_node_service_or_firebase_token,
resolve_caller_org_id,
bootstrap_limiter,
)
from app.internal.tenancy import FOUNDING_ORG_ID
router = APIRouter(prefix="/systems", tags=["systems"])
class VocabularyTermBody(BaseModel):
term: str
class TenCodesBody(BaseModel):
ten_codes: Dict[str, str]
class UnitFormatBody(BaseModel):
unit_format_hint: str
class PendingTermBody(BaseModel):
talkgroup_id: int
term: str
class AiFlagsBody(BaseModel):
stt_enabled: Optional[bool] = None
correlation_enabled: Optional[bool] = None
@router.get("")
async def list_systems(decoded: dict = Depends(require_node_service_or_firebase_token)):
org_id = await resolve_caller_org_id(decoded)
if org_id is None: # service key or platform admin — unrestricted, matches prior behaviour
return await fstore.collection_list("systems")
return await fstore.collection_list("systems", org_id=org_id)
@router.get("/{system_id}")
async def get_system(system_id: str, decoded: dict = Depends(require_node_service_or_firebase_token)):
system = await fstore.doc_get("systems", system_id)
if not system:
raise HTTPException(404, f"System '{system_id}' not found.")
org_id = await resolve_caller_org_id(decoded)
if org_id is not None and system.get("org_id") != org_id:
raise HTTPException(404, f"System '{system_id}' not found.")
return system
@router.post("", status_code=201)
async def create_system(
body: SystemCreate,
org_id: Optional[str] = Query(None, description="Platform-admin only — defaults to the founding org."),
_: dict = Depends(require_admin_token),
):
system_id = str(uuid.uuid4())
doc = SystemRecord(system_id=system_id, org_id=org_id or FOUNDING_ORG_ID, **body.model_dump())
await fstore.doc_set("systems", system_id, doc.model_dump(), merge=False)
return doc
@router.put("/{system_id}")
async def update_system(system_id: str, body: SystemCreate, _: dict = Depends(require_admin_token)):
existing = await fstore.doc_get("systems", system_id)
if not existing:
raise HTTPException(404, f"System '{system_id}' not found.")
# exclude_unset, or every field the caller omitted gets written as its
# default and silently erases what was there. The systems page PUTs only
# {name, type, config}, so a plain model_dump() wiped ten_codes on every
# save — they are edited through PUT /{id}/ten-codes and were never in this
# payload. area_context (server-26#36) would have been the second casualty.
patch = body.model_dump(exclude_unset=True)
# The form sends config.talkgroups[] in full, which would erase the resolved
# anchor and the pending-term queue the backend put there. Same class of bug
# as ten_codes above; the backend merges its own fields back rather than
# taking dictation from the client (server-26#36).
if "config" in patch:
patch["config"] = area_ctx.merge_config(patch["config"], existing.get("config"))
if "area_context" in patch:
patch["area_context"] = area_ctx.merge_server_fields(
area_ctx.normalize(patch["area_context"]), existing.get("area_context")
)
await fstore.doc_update("systems", system_id, patch)
# Geocoding the anchor is a write-time job — a place changes when someone
# edits a town name, not every five minutes — but the operator should not
# wait on Maps to see their save land.
area_ctx.schedule_refresh(system_id)
return {**existing, **patch}
@router.delete("/{system_id}", status_code=204)
async def delete_system(system_id: str, _: dict = Depends(require_admin_token)):
existing = await fstore.doc_get("systems", system_id)
if not existing:
raise HTTPException(404, f"System '{system_id}' not found.")
await fstore.doc_delete("systems", system_id)
# ── Per-system AI flag overrides ──────────────────────────────────────────────
@router.put("/{system_id}/ai-flags")
async def update_system_ai_flags(
system_id: str,
body: AiFlagsBody,
_: dict = Depends(require_admin_token),
):
"""
Set per-system AI flag overrides. Only fields included in the body are
written; omitted fields remain unchanged (or absent, meaning inherit global).
Pass null to clear an override and fall back to the global flag.
"""
existing = await fstore.doc_get("systems", system_id)
if not existing:
raise HTTPException(404, f"System '{system_id}' not found.")
current: dict = existing.get("ai_flags") or {}
for field, value in body.model_dump(exclude_unset=True).items():
if value is None:
current.pop(field, None) # clear override → inherit global
else:
current[field] = value
await fstore.doc_update("systems", system_id, {"ai_flags": current})
return {"ok": True, "ai_flags": current}
# ── Ten-codes endpoints ────────────────────────────────────────────────────────
@router.get("/{system_id}/ten-codes")
async def get_ten_codes(system_id: str):
"""Return the ten-code dictionary for a system."""
system = await fstore.doc_get("systems", system_id)
if not system:
raise HTTPException(404, f"System '{system_id}' not found.")
return {"ten_codes": system.get("ten_codes") or {}}
@router.put("/{system_id}/ten-codes")
async def update_ten_codes(
system_id: str,
body: TenCodesBody,
_: dict = Depends(require_admin_token),
):
"""Replace the ten-code dictionary for a system."""
existing = await fstore.doc_get("systems", system_id)
if not existing:
raise HTTPException(404, f"System '{system_id}' not found.")
await fstore.doc_update("systems", system_id, {"ten_codes": body.ten_codes})
return {"ok": True, "ten_codes": body.ten_codes}
# ── Unit ID format hint ─────────────────────────────────────────────────────────
@router.get("/{system_id}/unit-format")
async def get_unit_format(system_id: str):
"""Return the unit-ID format hint for a system."""
system = await fstore.doc_get("systems", system_id)
if not system:
raise HTTPException(404, f"System '{system_id}' not found.")
return {"unit_format_hint": system.get("unit_format_hint") or ""}
@router.put("/{system_id}/unit-format")
async def update_unit_format(
system_id: str,
body: UnitFormatBody,
_: dict = Depends(require_admin_token),
):
"""
Set the free-text unit-ID format hint fed into intelligence.py's
extraction prompt (server-26#<pending>). Departments have no shared unit
ID convention — e.g. "5-David"/bare "David" vs "SAM-1"/"airport-3" — and
the extraction prompt has no way to recognise a format it hasn't been
told about. Own route for the same reason ten-codes has one: not carried
by the systems form, so folding it into PUT /{id} would wipe it.
"""
existing = await fstore.doc_get("systems", system_id)
if not existing:
raise HTTPException(404, f"System '{system_id}' not found.")
await fstore.doc_update("systems", system_id, {"unit_format_hint": body.unit_format_hint})
return {"ok": True, "unit_format_hint": body.unit_format_hint}
# ── Area context ──────────────────────────────────────────────────────────────
@router.get("/{system_id}/area-context")
async def get_area_context(system_id: str, _: dict = Depends(require_admin_token)):
system = await fstore.doc_get("systems", system_id)
if not system:
raise HTTPException(404, f"System '{system_id}' not found.")
return {"area_context": system.get("area_context") or {}}
@router.put("/{system_id}/area-context")
async def update_area_context(
system_id: str,
body: AreaContextBody,
_: dict = Depends(require_admin_token),
):
"""
Replace the system-wide area context used by the corrector and the verifier.
Ground truth about where this system operates — municipality, county, state,
and the local names whose sound Whisper mangles. Per-talkgroup overrides live
inside config.talkgroups[] and rank ABOVE this (server-26#36), so a
multi-county system narrows per channel rather than replacing this wholesale.
Leaving it entirely empty is legitimate and meaningful: it says nothing here
is true of every talkgroup.
The derived anchor (`center`, `radius_km`, `resolved_from`, `resolved_at`) is
never taken from the body — it is carried forward and then recomputed here.
Its own route rather than a field on PUT /systems/{id} for the same reason
ten-codes has one: the systems form does not carry it, and folding it into
that payload is how ten_codes kept getting wiped.
"""
existing = await fstore.doc_get("systems", system_id)
if not existing:
raise HTTPException(404, f"System '{system_id}' not found.")
area = area_ctx.merge_server_fields(
area_ctx.normalize(body.model_dump(exclude_none=True)),
existing.get("area_context"),
)
await fstore.doc_update("systems", system_id, {"area_context": area})
# Awaited, not scheduled: this route exists to edit the place, so the caller
# should get back the anchor its edit produced. Talkgroups are refreshed with
# it because their anchor derives from the merged place, not their own.
patch = await area_ctx.refresh_anchors({**existing, "area_context": area})
if patch:
await fstore.doc_update("systems", system_id, patch)
area = patch.get("area_context", area)
return {"ok": True, "area_context": area}
# -- Talkgroup-level pending local knowledge (server-26#37) --------------------
@router.get("/{system_id}/talkgroup-pending")
async def list_talkgroup_pending(system_id: str, _: dict = Depends(require_admin_token)):
"""
Every pending local-knowledge proposal on this system, by talkgroup.
Proposals are made at talkgroup level and are never promoted to the system
automatically — a wrong term on one channel misleads one channel, the same
term system-wide misleads every channel on it.
"""
system = await fstore.doc_get("systems", system_id)
if not system:
raise HTTPException(404, f"System '{system_id}' not found.")
out = []
for tg in ((system.get("config") or {}).get("talkgroups") or []):
if not isinstance(tg, dict):
continue
pending = tg.get(area_ctx.PENDING_KEY) or []
if pending:
out.append({
"talkgroup_id": tg.get("id"),
"talkgroup_name": tg.get("name"),
"pending": pending,
})
return {"talkgroups": out}
@router.post("/{system_id}/talkgroup-pending/approve")
async def approve_talkgroup_pending(
system_id: str, body: PendingTermBody, _: dict = Depends(require_admin_token)
):
"""Move a pending term into that talkgroup's local_knowledge."""
if not await area_ctx.resolve_pending(system_id, body.talkgroup_id, body.term, approve=True):
raise HTTPException(404, "No such pending term on that talkgroup.")
return {"ok": True}
@router.post("/{system_id}/talkgroup-pending/dismiss")
async def dismiss_talkgroup_pending(
system_id: str, body: PendingTermBody, _: dict = Depends(require_admin_token)
):
"""Drop a pending term without adding it."""
if not await area_ctx.resolve_pending(system_id, body.talkgroup_id, body.term, approve=False):
raise HTTPException(404, "No such pending term on that talkgroup.")
return {"ok": True}
# ── Vocabulary endpoints ───────────────────────────────────────────────────────
@router.get("/{system_id}/vocabulary")
async def get_vocabulary(system_id: str):
"""Return approved vocabulary and pending induction suggestions."""
existing = await fstore.doc_get("systems", system_id)
if not existing:
raise HTTPException(404, f"System '{system_id}' not found.")
from app.internal.vocabulary_learner import get_vocabulary as _get
return await _get(system_id)
@router.post("/{system_id}/vocabulary/bootstrap", status_code=202)
async def bootstrap_vocabulary(
system_id: str,
decoded: dict = Depends(require_admin_token),
):
"""Trigger a one-shot GPT-4o bootstrap to seed the vocabulary from local knowledge."""
existing = await fstore.doc_get("systems", system_id)
if not existing:
raise HTTPException(404, f"System '{system_id}' not found.")
bootstrap_limiter.check(system_id)
from app.internal.vocabulary_learner import bootstrap_system_vocabulary
terms = await bootstrap_system_vocabulary(system_id)
return {"added": len(terms), "terms": terms}
@router.post("/{system_id}/vocabulary/terms")
async def add_vocabulary_term(
system_id: str,
body: VocabularyTermBody,
_: dict = Depends(require_admin_token),
):
"""Manually add a term to the approved vocabulary."""
existing = await fstore.doc_get("systems", system_id)
if not existing:
raise HTTPException(404, f"System '{system_id}' not found.")
from app.internal.vocabulary_learner import add_term
await add_term(system_id, body.term.strip())
return {"ok": True}
@router.delete("/{system_id}/vocabulary/terms")
async def remove_vocabulary_term(
system_id: str,
body: VocabularyTermBody,
_: dict = Depends(require_admin_token),
):
"""Remove a term from the approved vocabulary."""
existing = await fstore.doc_get("systems", system_id)
if not existing:
raise HTTPException(404, f"System '{system_id}' not found.")
from app.internal.vocabulary_learner import remove_term
await remove_term(system_id, body.term)
return {"ok": True}
@router.post("/{system_id}/vocabulary/pending/approve")
async def approve_pending(
system_id: str,
body: VocabularyTermBody,
_: dict = Depends(require_admin_token),
):
"""Move a pending induction suggestion into the approved vocabulary."""
existing = await fstore.doc_get("systems", system_id)
if not existing:
raise HTTPException(404, f"System '{system_id}' not found.")
from app.internal.vocabulary_learner import approve_pending_term
await approve_pending_term(system_id, body.term)
return {"ok": True}
@router.post("/{system_id}/vocabulary/pending/dismiss")
async def dismiss_pending(
system_id: str,
body: VocabularyTermBody,
_: dict = Depends(require_admin_token),
):
"""Dismiss a pending induction suggestion without adding it."""
existing = await fstore.doc_get("systems", system_id)
if not existing:
raise HTTPException(404, f"System '{system_id}' not found.")
from app.internal.vocabulary_learner import dismiss_pending_term
await dismiss_pending_term(system_id, body.term)
return {"ok": True}