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