area_context v2 + Maps place verification (server-26#36, #37)
#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:
co-authored by
Claude Opus 5
parent
58efdbd6eb
commit
964343c819
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user