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
@@ -35,7 +35,9 @@ import json
from typing import Any, Optional
from app.config import settings
from app.internal import area_context
from app.internal import firestore as fstore
from app.internal import place_verifier
from app.internal.logger import logger
# A transcript this short has no proper nouns to get wrong — "10-4.", "6-2,
@@ -65,6 +67,10 @@ Return JSON:
are numbered.
not_speech: true if this is recogniser noise rather than a transmission
changed: list of ["heard" -> "corrected"] pairs you applied, for audit
locations: every place name in your corrected output, exactly as it appears
there — streets, intersections, businesses, schools, towns,
landmarks. Include ones you are unsure of; that is the point.
A unit call sign or a person's name is NOT a location.
{transcript}"""
@@ -107,35 +113,45 @@ def _talkgroup_entry(system_doc: dict, talkgroup_id: Optional[int]) -> dict:
return {}
def _area_lines(area: dict, label: str) -> list[str]:
"""Render an area_context dict as prompt lines. Empty when nothing is set."""
def _area_lines(area: dict) -> list[str]:
"""
Render a merged area_context as prompt lines. Empty when nothing is set.
One block, not one per scope: by the time this runs the two scopes have
already been merged with talkgroup ahead of system, and showing the model
two competing lists invites it to pick from the wrong one.
"""
if not area:
return []
parts: list[str] = []
for key, heading in (
("municipality", "Municipality"),
("county", "County"),
("roads", "Roads and highways"),
("landmarks", "Landmarks, businesses and facilities"),
):
value = area.get(key)
if not value:
continue
if isinstance(value, list):
value = ", ".join(str(v) for v in value if v)
if value:
parts.append(f" {heading}: {value}")
return [f"{label}:"] + parts if parts else []
lines: list[str] = []
place = ", ".join(
str(area[f]) for f in area_context.PLACE_FIELDS if area.get(f)
)
if place:
lines.append(f"Area covered by this channel: {place}")
knowledge = area.get("local_knowledge") or []
if knowledge:
lines.append("Local names heard on this channel:")
lines.extend(
f" {e['term']} — {e['meaning']}" if e.get("meaning") else f" {e['term']}"
for e in knowledge
)
return lines
async def resolve_context(system_id: Optional[str], talkgroup_id: Optional[int]) -> dict:
"""
Merge the reference data a corrector needs, talkgroup ahead of system.
Returns {"vocabulary": [...], "ten_codes": {...}, "area_lines": [...]}.
Empty everywhere is legitimate — a system nobody has configured yet.
Returns {"vocabulary", "ten_codes", "area_lines", "area", "system_area",
"tg_area"}. Empty everywhere is legitimate — a system nobody has configured
yet. The two raw scopes come back alongside the merge because the place
verifier needs them to pick an anchor (server-26#37).
"""
empty: dict[str, Any] = {"vocabulary": [], "ten_codes": {}, "area_lines": []}
empty: dict[str, Any] = {
"vocabulary": [], "ten_codes": {}, "area_lines": [],
"area": {}, "system_area": {}, "tg_area": {},
}
if not system_id:
return empty
@@ -155,12 +171,18 @@ async def resolve_context(system_id: Optional[str], talkgroup_id: Optional[int])
ten_codes = dict(system_doc.get("ten_codes") or {})
ten_codes.update(tg.get("ten_codes") or {})
area_lines = (
_area_lines(tg.get("area_context") or {}, "Area covered by this talkgroup")
+ _area_lines(system_doc.get("area_context") or {}, "Area covered by this system")
)
system_area = system_doc.get("area_context") or {}
tg_area = tg.get("area_context") or {}
area = area_context.effective(system_area, tg_area)
return {"vocabulary": vocabulary, "ten_codes": ten_codes, "area_lines": area_lines}
return {
"vocabulary": vocabulary,
"ten_codes": ten_codes,
"area_lines": _area_lines(area),
"area": area,
"system_area": system_area,
"tg_area": tg_area,
}
def build_context_block(context: dict, talkgroup_name: Optional[str]) -> str:
@@ -267,6 +289,34 @@ async def correct(
f"segment(s) against {len(segments)} — discarding segment corrections"
)
# Maps has the last word on place names (server-26#37). The corrector can
# only match against the list it was handed, so a plausible-sounding invention
# — "Cool Parts, Illinois" — reads exactly like a real street to it. The
# verifier geocodes each location noun against the talkgroup's anchor and,
# on a miss, looks for a sound-alike that does resolve there. It runs on the
# corrected copy so it judges the text everything downstream will actually
# read, and it skips entirely when there is no discriminating anchor.
if not not_speech:
locations = [x for x in (raw.get("locations") or []) if isinstance(x, str)]
try:
verified_text, verified_segments = await place_verifier.verify(
call_id,
corrected or text,
corrected_segments or segments,
locations,
context.get("system_area"),
context.get("tg_area"),
system_id=system_id,
talkgroup_id=talkgroup_id,
)
except Exception as e:
logger.warning(f"Place verification failed for call {call_id}: {e}")
verified_text, verified_segments = None, None
if verified_text:
corrected = verified_text
if verified_segments:
corrected_segments = verified_segments
if corrected or corrected_segments or not_speech:
changed = raw.get("changed") or []
logger.info(