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>
378 lines
15 KiB
Python
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}
|