area_context v2 + Maps place verification (server-26#36, #37)
Build & Deploy / Build & push images (push) Successful in 4m9s
Build & Deploy / Deploy to VM (push) Successful in 1m54s
Build & Deploy / Report a failed deploy (push) Skipped

#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>
This commit is contained in:
Logan Cusano
2026-08-23 16:43:59 -04:00
co-authored by Claude Opus 5
parent 58efdbd6eb
commit 964343c819
14 changed files with 2052 additions and 167 deletions
+87 -12
View File
@@ -2,8 +2,9 @@ import uuid
from fastapi import APIRouter, HTTPException, Depends, Query
from pydantic import BaseModel
from typing import Dict, List, Optional
from app.models import SystemCreate, SystemRecord
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,
@@ -23,12 +24,9 @@ class TenCodesBody(BaseModel):
ten_codes: Dict[str, str]
class AreaContextBody(BaseModel):
"""Ground truth about the area a system covers — see PUT /{id}/area-context."""
municipality: Optional[str] = None
county: Optional[str] = None
roads: List[str] = []
landmarks: List[str] = []
class PendingTermBody(BaseModel):
talkgroup_id: int
term: str
class AiFlagsBody(BaseModel):
@@ -78,7 +76,21 @@ async def update_system(system_id: str, body: SystemCreate, _: dict = Depends(re
# 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}
@@ -160,12 +172,17 @@ async def update_area_context(
_: dict = Depends(require_admin_token),
):
"""
Replace the system-wide area context used by the transcript corrector.
Replace the system-wide area context used by the corrector and the verifier.
Ground truth about where this system operates — municipality, county, the
roads and landmarks whose names Whisper mangles. Per-talkgroup overrides
live inside config.talkgroups[] and rank ABOVE this (server-26#36), so a
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
@@ -174,11 +191,69 @@ async def update_area_context(
existing = await fstore.doc_get("systems", system_id)
if not existing:
raise HTTPException(404, f"System '{system_id}' not found.")
area = body.model_dump(exclude_none=True)
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")