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
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user