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
+260 -40
View File
@@ -5,7 +5,13 @@ import { useRouter } from "next/navigation";
import { useSystems } from "@/lib/useSystems";
import { c2api } from "@/lib/c2api";
import { useAuth } from "@/components/AuthProvider";
import type { AreaContext, SystemRecord, VocabularyPendingTerm } from "@/lib/types";
import type {
AreaContext,
LocalKnowledgeEntry,
SystemRecord,
TalkgroupPending,
VocabularyPendingTerm,
} from "@/lib/types";
// ── P25 structured config types ───────────────────────────────────────────────
@@ -25,17 +31,77 @@ const listToText = (v?: string[]) => (v ?? []).join(", ");
const textToList = (v: string) =>
v.split(",").map((x) => x.trim()).filter(Boolean);
/** Drop an area_context whose every field is blank, so we don't store noise. */
/**
* Local knowledge is one entry per line, `term — meaning`.
*
* A bare term is valid — half the information is still worth having, and
* forcing a meaning would make people invent one. An em dash, a spaced hyphen
* or an equals sign all separate; whichever comes first wins.
*/
const knowledgeToText = (v?: LocalKnowledgeEntry[]) =>
(v ?? []).map((e) => (e.meaning ? `${e.term} — ${e.meaning}` : e.term)).join("\n");
function textToKnowledge(v: string): LocalKnowledgeEntry[] {
const out: LocalKnowledgeEntry[] = [];
for (const line of v.split("\n")) {
const match = line.match(/^(.*?)\s*(?:—|–|\s-\s|=)\s*(.*)$/);
const term = (match ? match[1] : line).trim();
const meaning = match ? match[2].trim() : "";
if (term) out.push(meaning ? { term, meaning } : { term });
}
return out;
}
/**
* Fold the pre-#36 shape forward on load.
*
* `roads[]` and `landmarks[]` were the original fields and real systems still
* have them stored. Showing them as local-knowledge lines means an operator
* sees what they already entered instead of an empty box, and the next save
* writes them in the new shape.
*/
function migrateArea(a?: AreaContext & { roads?: string[]; landmarks?: string[] }): AreaContext {
if (!a) return {};
const legacy = [...(a.roads ?? []), ...(a.landmarks ?? [])].map((term) => ({ term }));
if (!legacy.length) return a;
const seen = new Set((a.local_knowledge ?? []).map((e) => e.term.toLowerCase()));
const { roads: _roads, landmarks: _landmarks, ...rest } = a;
return {
...rest,
local_knowledge: [
...(a.local_knowledge ?? []),
...legacy.filter((e) => !seen.has(e.term.toLowerCase())),
],
};
}
/**
* Drop an area_context whose every field is blank, so we don't store noise.
*
* Also strips the derived anchor: `center`/`radius_km`/`resolved_*` belong to
* the backend, which geocodes them from the place and merges them back. Sending
* them up would be the frontend deciding what is in a system document.
*/
function cleanArea(a?: AreaContext): AreaContext | undefined {
if (!a) return undefined;
const out: AreaContext = {};
if (a.municipality?.trim()) out.municipality = a.municipality.trim();
if (a.county?.trim()) out.county = a.county.trim();
if (a.roads?.length) out.roads = a.roads;
if (a.landmarks?.length) out.landmarks = a.landmarks;
if (a.state?.trim()) out.state = a.state.trim();
if (a.local_knowledge?.length) out.local_knowledge = a.local_knowledge;
return Object.keys(out).length ? out : undefined;
}
/** What the backend resolved this area to, in one line. */
function anchorLabel(a?: AreaContext): string {
if (!a) return "";
const place = [a.municipality, a.county, a.state].filter(Boolean).join(", ");
if (!place) return "no area set — location checking is off for this scope";
if (a.center && a.radius_km) return `anchored to ${place}, ${Math.round(a.radius_km)} km radius`;
if (a.resolved_from) return `${place} — too wide to check locations against, so that check is skipped`;
return `${place} — not yet resolved`;
}
interface P25Config {
nac: string;
system_id: string;
@@ -76,7 +142,7 @@ function recordToP25Config(c: Record<string, unknown>): P25Config {
name: tg.name,
tag: tg.tag ?? "other",
vocabulary: tg.vocabulary ?? [],
area_context: tg.area_context ?? {},
area_context: migrateArea(tg.area_context),
}))
: [],
};
@@ -359,27 +425,41 @@ function hasLocalKnowledge(tg: TalkgroupEntry): boolean {
return Boolean((tg.vocabulary ?? []).length || cleanArea(tg.area_context));
}
/** One labelled text input in the local-knowledge grid. */
/** One labelled field in the local-knowledge grid. */
function LocalKnowledgeField({
label,
value,
onChange,
placeholder,
multiline,
}: {
label: string;
value: string;
onChange: (v: string) => void;
placeholder?: string;
multiline?: boolean;
}) {
const cls =
"w-full mt-0.5 bg-gray-900 border border-gray-700 rounded px-2 py-1 text-white text-xs focus:outline-none focus:border-indigo-500";
return (
<label className="block">
<span className="text-xs text-gray-500 font-sans">{label}</span>
<input
value={value}
onChange={(e) => onChange(e.target.value)}
placeholder={placeholder}
className="w-full mt-0.5 bg-gray-900 border border-gray-700 rounded px-2 py-1 text-white text-xs focus:outline-none focus:border-indigo-500"
/>
{multiline ? (
<textarea
value={value}
onChange={(e) => onChange(e.target.value)}
placeholder={placeholder}
rows={4}
className={`${cls} font-mono resize-y`}
/>
) : (
<input
value={value}
onChange={(e) => onChange(e.target.value)}
placeholder={placeholder}
className={cls}
/>
)}
</label>
);
}
@@ -404,17 +484,17 @@ function TalkgroupEditor({
onChange([...talkgroups, { id: "", name: "", tag: "other" }]);
}
function updateArea(i: number, field: "municipality" | "county", value: string) {
function updateArea(i: number, field: "municipality" | "county" | "state", value: string) {
const updated = [...talkgroups];
updated[i] = { ...updated[i], area_context: { ...updated[i].area_context, [field]: value } };
onChange(updated);
}
function updateAreaList(i: number, field: "roads" | "landmarks", value: string) {
function updateAreaKnowledge(i: number, value: string) {
const updated = [...talkgroups];
updated[i] = {
...updated[i],
area_context: { ...updated[i].area_context, [field]: textToList(value) },
area_context: { ...updated[i].area_context, local_knowledge: textToKnowledge(value) },
};
onChange(updated);
}
@@ -642,17 +722,22 @@ function TalkgroupEditor({
placeholder="Westchester"
/>
<LocalKnowledgeField
label="Roads"
value={listToText(tg.area_context?.roads)}
onChange={(v) => updateAreaList(i, "roads", v)}
placeholder="Route 9, Croton Ave"
/>
<LocalKnowledgeField
label="Landmarks & businesses"
value={listToText(tg.area_context?.landmarks)}
onChange={(v) => updateAreaList(i, "landmarks", v)}
placeholder="Sing Sing, Phelps Hospital"
label="State"
value={tg.area_context?.state ?? ""}
onChange={(v) => updateArea(i, "state", v)}
placeholder="New York"
/>
<div className="md:col-span-2">
<LocalKnowledgeField
label="Local knowledge — one per line, term — what it is"
multiline
value={knowledgeToText(tg.area_context?.local_knowledge)}
onChange={(v) => updateAreaKnowledge(i, v)}
placeholder={
"Snowden Avenue — residential street\nSing Sing — state prison\n11-X-ray — MTA PD patrol unit"
}
/>
</div>
<div className="md:col-span-2">
<LocalKnowledgeField
label="Unit call signs & local terms"
@@ -1169,7 +1254,7 @@ function AreaContextPanel({ systemId }: { systemId: string }) {
setLoading(true);
try {
const data = await c2api.getAreaContext(systemId);
setArea(data.area_context ?? {});
setArea(migrateArea(data.area_context));
} catch (e) {
setError(String(e));
} finally {
@@ -1182,7 +1267,11 @@ function AreaContextPanel({ systemId }: { systemId: string }) {
setSaving(true);
setError(null);
try {
await c2api.updateAreaContext(systemId, area);
// The response carries the anchor this edit produced, so the readout
// below reflects what the backend actually resolved rather than what was
// typed.
const saved = await c2api.updateAreaContext(systemId, area);
setArea(saved.area_context ?? area);
} catch (e) {
setError(String(e));
} finally {
@@ -1191,7 +1280,7 @@ function AreaContextPanel({ systemId }: { systemId: string }) {
}
const filled = area
? [area.municipality, area.county, area.roads?.length, area.landmarks?.length].filter(Boolean).length
? [area.municipality, area.county, area.state, area.local_knowledge?.length].filter(Boolean).length
: 0;
return (
@@ -1219,9 +1308,15 @@ function AreaContextPanel({ systemId }: { systemId: string }) {
<p className="text-gray-500">
Given to the transcript corrector for every talkgroup on this system.
Whisper mishears local names constantly — naming them here is what lets
them be put back. Individual talkgroups can narrow this in the edit form.
them be put back.
</p>
<div className="grid grid-cols-1 md:grid-cols-2 gap-2">
<p className="text-gray-500">
Fill this in <span className="text-gray-300">only if it is true of every
talkgroup</span> on the system. One town, one department — fill it once here.
A system spanning several counties — leave it blank and describe each
talkgroup in the edit form instead.
</p>
<div className="grid grid-cols-1 md:grid-cols-3 gap-2">
<LocalKnowledgeField
label="Municipality"
value={area.municipality ?? ""}
@@ -1235,18 +1330,25 @@ function AreaContextPanel({ systemId }: { systemId: string }) {
placeholder="Westchester"
/>
<LocalKnowledgeField
label="Roads"
value={listToText(area.roads)}
onChange={(v) => setArea({ ...area, roads: textToList(v) })}
placeholder="Route 9, Saw Mill Parkway"
/>
<LocalKnowledgeField
label="Landmarks & businesses"
value={listToText(area.landmarks)}
onChange={(v) => setArea({ ...area, landmarks: textToList(v) })}
placeholder="Phelps Hospital, Metro-North station"
label="State"
value={area.state ?? ""}
onChange={(v) => setArea({ ...area, state: v })}
placeholder="New York"
/>
</div>
<LocalKnowledgeField
label="Local knowledge — one per line, term — what it is"
multiline
value={knowledgeToText(area.local_knowledge)}
onChange={(v) => setArea({ ...area, local_knowledge: textToKnowledge(v) })}
placeholder={
"Route 9 — main north-south highway\nPhelps — Phelps Hospital, Sleepy Hollow\nthe flats — low-lying area by the river"
}
/>
{/* The anchor is the backend's answer to what was typed above, and
it decides whether location checking runs at all — so it is
worth showing rather than leaving as invisible state. */}
<p className="text-gray-600 font-mono">{anchorLabel(area)}</p>
<button
type="button"
onClick={save}
@@ -1266,6 +1368,123 @@ function AreaContextPanel({ systemId }: { systemId: string }) {
// ── Vocabulary panel ──────────────────────────────────────────────────────────
/**
* Terms the place verifier and the induction loop proposed, per talkgroup.
*
* Approval is always a person's decision and always lands on the talkgroup that
* proposed it — nothing here promotes a term to the system (server-26#37). A
* wrong term on one channel misleads one channel; the same term system-wide
* misleads every channel on it, including one on the far side of a statewide
* system.
*/
function TalkgroupPendingPanel({ systemId }: { systemId: string }) {
const [open, setOpen] = useState(false);
const [rows, setRows] = useState<TalkgroupPending[] | null>(null);
const [busy, setBusy] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
async function load() {
try {
const data = await c2api.getTalkgroupPending(systemId);
setRows(data.talkgroups ?? []);
} catch (e) {
setError(String(e));
}
}
async function toggle() {
const next = !open;
setOpen(next);
if (next && rows === null) await load();
}
async function act(talkgroupId: number, term: string, approve: boolean) {
setBusy(`${talkgroupId}:${term}`);
setError(null);
try {
if (approve) await c2api.approveTalkgroupTerm(systemId, talkgroupId, term);
else await c2api.dismissTalkgroupTerm(systemId, talkgroupId, term);
await load();
} catch (e) {
setError(String(e));
} finally {
setBusy(null);
}
}
const total = (rows ?? []).reduce((n, r) => n + r.pending.length, 0);
return (
<div className="mt-3 border-t border-gray-800 pt-3">
<button
onClick={toggle}
className="text-xs text-gray-500 hover:text-gray-300 font-mono transition-colors flex items-center gap-1"
>
<span>{open ? "▲" : "▼"}</span>
<span>
Proposed Terms
{rows !== null && (
<span className={total > 0 ? "text-indigo-400 ml-1" : "text-gray-600 ml-1"}>
({total > 0 ? `${total} awaiting review` : "none"})
</span>
)}
</span>
</button>
{open && (
<div className="mt-3 space-y-3 text-xs">
{rows === null && <p className="text-gray-600 italic font-mono">Loading…</p>}
{rows !== null && total === 0 && (
<p className="text-gray-600 italic">
Nothing proposed yet. Terms appear here when a corrected place name
turns out to be real nearby, or when the induction loop spots one in
recent traffic.
</p>
)}
{(rows ?? []).map((row) => (
<div key={row.talkgroup_id}>
<p className="text-gray-400 font-mono mb-1">
{row.talkgroup_name || `TGID ${row.talkgroup_id}`}
</p>
<ul className="space-y-1">
{row.pending.map((p) => (
<li
key={p.term}
className="flex items-start gap-2 bg-gray-900/60 border border-gray-800 rounded px-2 py-1"
>
<span className="flex-1">
<span className="text-white font-mono">{p.term}</span>
{p.meaning && <span className="text-gray-500"> — {p.meaning}</span>}
<span className="text-gray-700 ml-2">{p.source}</span>
</span>
<button
type="button"
disabled={busy === `${row.talkgroup_id}:${p.term}`}
onClick={() => act(row.talkgroup_id, p.term, true)}
className="text-emerald-400 hover:text-emerald-300 disabled:opacity-40 font-semibold"
>
Add
</button>
<button
type="button"
disabled={busy === `${row.talkgroup_id}:${p.term}`}
onClick={() => act(row.talkgroup_id, p.term, false)}
className="text-gray-600 hover:text-red-400 disabled:opacity-40 font-semibold"
>
Drop
</button>
</li>
))}
</ul>
</div>
))}
{error && <p className="text-red-400 font-mono">{error}</p>}
</div>
)}
</div>
);
}
function VocabularyPanel({ systemId }: { systemId: string }) {
const [vocab, setVocab] = useState<string[] | null>(null);
const [pending, setPending] = useState<VocabularyPendingTerm[]>([]);
@@ -1560,6 +1779,7 @@ export default function SystemsPage() {
<PreferredTokenPanel systemId={s.system_id} initialTokenId={s.preferred_token_id} />
<AiFlagsPanel systemId={s.system_id} initial={(s as unknown as { ai_flags?: SystemAiFlags }).ai_flags ?? {}} />
<AreaContextPanel systemId={s.system_id} />
<TalkgroupPendingPanel systemId={s.system_id} />
<VocabularyPanel systemId={s.system_id} />
</div>
);
+28 -2
View File
@@ -1,5 +1,5 @@
import { auth } from "@/lib/firebase";
import type { AreaContext } from "@/lib/types";
import type { AreaContext, TalkgroupPending } from "@/lib/types";
const BASE = process.env.NEXT_PUBLIC_C2_URL ?? "http://localhost:8000";
@@ -151,12 +151,38 @@ export const c2api = {
// {name, type, config} and would otherwise wipe them on every save.
getAreaContext: (systemId: string) =>
request<{ area_context: AreaContext }>(`/systems/${systemId}/area-context`),
// Only the operator-set fields go up. center/radius_km/resolved_* are the
// backend's — it geocodes them from the place and merges them back, and the
// response carries the anchor its edit produced.
updateAreaContext: (systemId: string, area: AreaContext) =>
request<{ ok: boolean; area_context: AreaContext }>(
`/systems/${systemId}/area-context`,
{ method: "PUT", body: JSON.stringify(area) },
{
method: "PUT",
body: JSON.stringify({
municipality: area.municipality ?? null,
county: area.county ?? null,
state: area.state ?? null,
local_knowledge: area.local_knowledge ?? [],
}),
},
),
// Talkgroup-level pending local knowledge (server-26#37). Proposals land on
// the talkgroup and are never promoted to the system automatically.
getTalkgroupPending: (systemId: string) =>
request<{ talkgroups: TalkgroupPending[] }>(`/systems/${systemId}/talkgroup-pending`),
approveTalkgroupTerm: (systemId: string, talkgroupId: number, term: string) =>
request(`/systems/${systemId}/talkgroup-pending/approve`, {
method: "POST",
body: JSON.stringify({ talkgroup_id: talkgroupId, term }),
}),
dismissTalkgroupTerm: (systemId: string, talkgroupId: number, term: string) =>
request(`/systems/${systemId}/talkgroup-pending/dismiss`, {
method: "POST",
body: JSON.stringify({ talkgroup_id: talkgroupId, term }),
}),
// Vocabulary
getVocabulary: (systemId: string) =>
request<{ vocabulary: string[]; vocabulary_pending: { term: string; source: "induction" | "correction"; added_at: string }[]; vocabulary_bootstrapped: boolean }>(
+33 -2
View File
@@ -237,9 +237,40 @@ export interface AlertEvent {
* a multi-county system can be specific per channel without its wider list
* burying the detail.
*/
export interface LocalKnowledgeEntry {
term: string;
meaning?: string | null;
}
export interface AreaContext {
// Set by an operator. Every field nullable on purpose: which SCOPE gets
// filled is the declaration of how homogeneous the system is. A one-town
// system is described once here and inherited by every talkgroup; a
// statewide one is left empty here and described per talkgroup.
municipality?: string;
county?: string;
roads?: string[];
landmarks?: string[];
state?: string;
local_knowledge?: LocalKnowledgeEntry[];
// Written by the backend, read-only here. Absent means the place is unset or
// too wide to discriminate, and location verification skips entirely
// (server-26#37) — never send these back.
center?: { lat: number; lng: number };
radius_km?: number;
resolved_from?: string;
resolved_at?: string;
}
/** A term the verifier or the induction loop proposed for one talkgroup. */
export interface PendingLocalTerm {
term: string;
meaning?: string | null;
source: string;
added_at: string;
source_call_ids?: string[];
}
export interface TalkgroupPending {
talkgroup_id: number;
talkgroup_name?: string;
pending: PendingLocalTerm[];
}