#36 — the correction pass shipped in 58efdbd was right, its reference-data
shape was not. One shape now, at both scopes, every field nullable:
area_context: { municipality?, county?, state?,
center?, radius_km?, resolved_from?, resolved_at?,
local_knowledge?: [{term, meaning}] }
`state` closes the ambiguity that made "Ossining" a national guess.
`local_knowledge` replaces roads[]/landmarks[], which could not hold
intersections, schools or nicknames and carried no meanings — `11-X-ray` is
useless alone, `11-X-ray — MTA PD patrol unit` is what a corrector can act on.
Pre-#36 roads[]/landmarks[] are read forward as bare terms so nothing an
operator already entered is lost.
Nullability is the mechanism: which scope gets filled is the operator's
declaration of how homogeneous the system is. One town — fill it once at system
level. Statewide — leave it blank and fill each talkgroup.
The backend owns the derived anchor. PUT /systems/{id} merges config.talkgroups[]
against what is stored instead of writing the client's blob verbatim, which
would have erased the anchor and the pending queue — the same defect as the
ten_codes wipe.
#37 — Maps as a verifier, not as prompt stuffing. The corrector emits its
location nouns; each is geocoded against the talkgroup's anchor, and on a miss
we look for a sound-alike that does resolve there, correct to it, and propose
{term, meaning} to that talkgroup. Cost scales with location nouns, not calls.
No anchor means SKIP. An area too wide to discriminate stores no anchor at all,
because a statewide radius would confirm anything inside it — verification that
passes everything is worse than none, since it reads as a check in the data.
Also re-anchors _geocode_location, which rejected results >40km from the NODE
(server-26#6). An antenna is not a jurisdiction; distance-from-node was always
a stand-in for the anchor and is now only the fallback.
The induction loop proposes at talkgroup level and never promotes. Blast
radius: a wrong term on a channel misleads that channel, the same term
system-wide misleads one 400km away on a statewide system.
38 new tests; 240 pass. Frontend typechecks clean.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
342 lines
14 KiB
Python
342 lines
14 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 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}
|
|
|
|
|
|
# ── 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}
|