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
+118 -57
View File
@@ -20,8 +20,9 @@ import json
import random
import re
from datetime import datetime, timezone, timedelta
from typing import Optional
from typing import Any, Optional
from app.internal.logger import logger
from app.internal import area_context
from app.internal import firestore as fstore
from app.config import settings
@@ -57,26 +58,32 @@ Do NOT include common English words. Max 80 terms. Only include what you are con
accurate for this specific area; return fewer terms rather than guessing."""
_INDUCTION_PROMPT = """\
You are analyzing P25 emergency radio transcripts to find vocabulary terms that should be \
added to improve future speech-to-text accuracy for this system.
You are analyzing P25 emergency radio transcripts from ONE talkgroup (a single radio channel) \
to find local terms that should be added to improve future speech-to-text accuracy for that \
channel.
System: {system_name}
Existing approved vocabulary (do not re-propose these): {existing_vocab}
Channel: {talkgroup_name}
Area: {area_hint}
Terms this channel already knows (do not re-propose these): {existing_vocab}
Sampled transcripts:
{transcript_block}
Find terms that are LIKELY STT errors or local terms missing from the vocabulary:
Find terms that are LIKELY STT errors or local terms missing from the list:
- Unit IDs that appear garbled (e.g. "5 acre" → "5-baker")
- Agency acronyms spelled out phonetically (e.g. "why vac" → "YVAC")
- Street names or locations that look misspelled or oddly transcribed
- Callsigns or local codes not yet in the vocabulary
- Callsigns or local codes not yet known
Return ONLY a JSON object:
{{"new_terms": ["term1", "term2", ...]}}
{{"new_terms": [{{"term": "YVAC", "meaning": "Yorktown Volunteer Ambulance Corps"}}, ...]}}
Only include high-confidence additions not already in existing vocabulary.
Return {{"new_terms": []}} if nothing new is found."""
`meaning` is what the term refers to — an agency, a road, a unit type. Omit it or use null \
when you genuinely do not know; a term with no meaning is still worth proposing.
Only propose what is specific to THIS channel and this area. Do not propose a term just \
because it appears often. Return {{"new_terms": []}} if nothing new is found."""
# ─────────────────────────────────────────────────────────────────────────────
@@ -97,10 +104,17 @@ async def bootstrap_system_vocabulary(system_id: str) -> list[str]:
system_name = system_doc.get("name", "Unknown")
system_type = system_doc.get("type", "P25")
# Build area hint from configured talkgroup names
talkgroups = system_doc.get("config", {}).get("talkgroups", [])
tg_names = [tg.get("name", "") for tg in talkgroups if tg.get("name")][:8]
area_hint = f"Talkgroups include: {', '.join(tg_names)}" if tg_names else "Unknown area"
# Prefer the place an operator actually set. Guessing the area from talkgroup
# names is thin for a single-municipality system and close to useless for a
# multi-county one (server-26#36), so it is only the fallback now.
area = system_doc.get("area_context") or {}
place = ", ".join(str(area[f]) for f in area_context.PLACE_FIELDS if area.get(f))
if place:
area_hint = place
else:
talkgroups = system_doc.get("config", {}).get("talkgroups", [])
tg_names = [tg.get("name", "") for tg in talkgroups if tg.get("name")][:8]
area_hint = f"Talkgroups include: {', '.join(tg_names)}" if tg_names else "Unknown area"
terms = await asyncio.to_thread(_sync_bootstrap, system_name, system_type, area_hint)
if not terms:
@@ -279,9 +293,20 @@ async def _run_induction_pass() -> None:
async def _induct_system(system_id: str, system_doc: dict) -> None:
"""Sample random transcripts for a system and propose new vocabulary."""
system_name = system_doc.get("name", "Unknown")
existing_vocab: list[str] = system_doc.get("vocabulary") or []
"""
Sample recent transcripts per TALKGROUP and propose local knowledge there.
Proposals used to land at system level, which is the wrong blast radius
(server-26#37). A wrong term on a talkgroup misleads one channel; the same
term at system level misleads every channel on that system — including one
400km away on a statewide system, which is exactly the context poisoning the
scope rule exists to prevent. If a term really does apply system-wide,
carrying it on several talkgroups costs almost nothing, while auto-promoting
a wrong one is expensive to notice. So: talkgroup-level pending terms only,
and nothing here ever promotes upward or approves itself.
"""
system_name = system_doc.get("name", "Unknown")
system_area = system_doc.get("area_context") or {}
# Fetch calls from the last 7 days only — avoids scanning the entire history.
# Active calls have ended_at=None and are excluded by the range filter automatically.
@@ -294,57 +319,87 @@ async def _induct_system(system_id: str, system_doc: dict) -> None:
if not all_calls:
return
# Random sample up to the token budget (4 chars ≈ 1 token)
random.shuffle(all_calls)
char_budget = settings.vocabulary_induction_sample_tokens * 4
by_tg: dict[Any, list[dict]] = {}
for call in all_calls:
tgid = call.get("talkgroup_id")
if tgid is None:
continue
by_tg.setdefault(tgid, []).append(call)
# The sample budget is per system, split across the talkgroups that have
# traffic — a channel with 400 calls should not starve one with 12.
char_budget = max(
(settings.vocabulary_induction_sample_tokens * 4) // max(len(by_tg), 1), 800
)
for talkgroup_id, calls in by_tg.items():
try:
await _induct_talkgroup(
system_id, system_doc, system_name, system_area,
talkgroup_id, calls, char_budget,
)
except Exception as e:
logger.warning(
f"Induction failed for talkgroup {talkgroup_id} on system {system_id}: {e}"
)
async def _induct_talkgroup(
system_id: str,
system_doc: dict,
system_name: str,
system_area: dict,
talkgroup_id: Any,
calls: list[dict],
char_budget: int,
) -> None:
tg_entry = area_context.talkgroup_entry(system_doc, talkgroup_id)
tg_area = tg_entry.get("area_context") or {}
area = area_context.effective(system_area, tg_area)
talkgroup_name = (
tg_entry.get("name")
or calls[0].get("talkgroup_name")
or f"TGID {talkgroup_id}"
)
known = area_context._known_terms(tg_entry, system_doc)
random.shuffle(calls)
transcript_block = ""
sampled_call_docs: list[dict] = []
sampled = 0
for call in all_calls:
for call in calls:
text = call.get("transcript_corrected") or call.get("transcript") or ""
if not text:
continue
if len(transcript_block) + len(text) > char_budget:
break
tg = call.get("talkgroup_name") or f"TGID {call.get('talkgroup_id', '?')}"
transcript_block += f"[{tg}] {text}\n"
transcript_block += f"{text}\n"
sampled_call_docs.append(call)
sampled += 1
if sampled < 3:
return # not enough data to learn from yet
if len(sampled_call_docs) < 3:
return # not enough data on this channel to learn from yet
new_terms = await asyncio.to_thread(
_sync_induct, system_name, existing_vocab, transcript_block
place = ", ".join(str(area[f]) for f in area_context.PLACE_FIELDS if area.get(f))
proposed = await asyncio.to_thread(
_sync_induct,
system_name, talkgroup_name, place or "not set",
sorted(known)[:80], transcript_block,
)
if not new_terms:
if not proposed:
return
now = datetime.now(timezone.utc).isoformat()
existing_pending: list[dict] = system_doc.get("vocabulary_pending") or []
pending_lower = {p["term"].lower() for p in existing_pending}
vocab_lower = {t.lower() for t in existing_vocab}
to_queue = []
for t in new_terms:
if t.lower() in vocab_lower or t.lower() in pending_lower:
continue
to_queue.append({
"term": t,
entries = [
{
"term": p["term"],
"meaning": p.get("meaning"),
"source": "induction",
"added_at": now,
"source_call_ids": _find_source_calls(t, sampled_call_docs),
})
if not to_queue:
return
await fstore.doc_set("systems", system_id, {
"vocabulary_pending": existing_pending + to_queue,
})
logger.info(
f"Vocabulary induction: {len(to_queue)} new term(s) proposed for "
f"system {system_id} ({system_name}): {[p['term'] for p in to_queue]}"
)
"source_call_ids": _find_source_calls(p["term"], sampled_call_docs),
}
for p in proposed
if p.get("term") and p["term"].lower() not in known
]
if entries:
await area_context.add_pending(system_id, talkgroup_id, entries)
# ─────────────────────────────────────────────────────────────────────────────
@@ -441,8 +496,13 @@ def _sync_bootstrap(system_name: str, system_type: str, area_hint: str) -> list[
def _sync_induct(
system_name: str, existing_vocab: list[str], transcript_block: str
) -> list[str]:
system_name: str,
talkgroup_name: str,
area_hint: str,
existing_vocab: list[str],
transcript_block: str,
) -> list[dict]:
"""Returns [{term, meaning}] — a bare string is still accepted from the model."""
from app.config import settings as cfg
from openai import OpenAI
@@ -452,6 +512,8 @@ def _sync_induct(
vocab_str = ", ".join(existing_vocab[:80]) if existing_vocab else "(none yet)"
prompt = _INDUCTION_PROMPT.format(
system_name=system_name,
talkgroup_name=talkgroup_name,
area_hint=area_hint,
existing_vocab=vocab_str,
transcript_block=transcript_block[:8000],
)
@@ -463,8 +525,7 @@ def _sync_induct(
response_format={"type": "json_object"},
)
data = json.loads(response.choices[0].message.content)
terms = data.get("new_terms") or []
return [str(t).strip() for t in terms if str(t).strip()]
return area_context.normalize_local_knowledge(data.get("new_terms") or [])
except Exception as e:
logger.warning(f"Vocabulary induction GPT call failed: {e}")
return []