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