Correct the transcript before anything reads it
Correction existed, but as a line in intelligence.py's EXTRACTION_PROMPT --
which put it in the wrong place twice over. The same model call that extracted
units, location and severity emitted the correction afterwards, so extraction
reasoned over text already known to be wrong; and it sat behind
correlation_enabled, so during a cost-controlled STT-only window nothing was
ever corrected at all. That is the normal state during development.
internal/transcript_correction.py is now its own pass, between the degenerate
filter and the Firestore write. It receives an already-produced transcript plus
a reference list, so unlike a Whisper prompt it has no series to extend -- the
distinction that keeps vocabulary out of the recogniser's prompt, where an
enumerated ten-code list once made it hallucinate ten-code runs.
Reference data is merged from the talkgroup and the system, TALKGROUP FIRST. A
system spanning several counties can have a talkgroup covering one
municipality, and that municipality's streets must not be buried under a
county-wide list. A single-municipality system is the degenerate case: populate
the system level and every talkgroup inherits it. Area context is now SET --
municipality, county, roads, landmarks, on both scopes -- rather than guessed
from talkgroup names, which is what vocabulary_learner did and which is close
to useless across multiple counties.
Segments are corrected too, not just the joined text. extract_scenes builds its
prompt from numbered segments whenever there is more than one, so a correction
that only fixed the transcript would have been discarded on exactly the
multi-transmission calls carrying the most content. Alignment is enforced: an
array of the wrong length or type is dropped whole, because scenes map back to
transmissions by index and a shifted array would misattribute audio silently.
Whisper is also retried once on degenerate output. Call e49ea32c produced a
56-word ten-code counting run on one attempt and ordinary speech on the next --
same clip, same temperature=0 -- so a hallucination is a coin-flip, and
discarding on the first bad roll threw away a recoverable transcript.
Two things found on the way:
PUT /systems/{id} wiped ten_codes on every save. The systems form sends only
{name, type, config}, and model_dump() wrote every omitted field as its default
over the top. Now exclude_unset. area_context would have been the next victim,
which is why it gets its own route alongside ten-codes rather than a field on
that payload.
Closes server-26#36.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
1bfa856d1b
commit
58efdbd6eb
@@ -5,7 +5,7 @@ import { useRouter } from "next/navigation";
|
||||
import { useSystems } from "@/lib/useSystems";
|
||||
import { c2api } from "@/lib/c2api";
|
||||
import { useAuth } from "@/components/AuthProvider";
|
||||
import type { SystemRecord, VocabularyPendingTerm } from "@/lib/types";
|
||||
import type { AreaContext, SystemRecord, VocabularyPendingTerm } from "@/lib/types";
|
||||
|
||||
// ── P25 structured config types ───────────────────────────────────────────────
|
||||
|
||||
@@ -13,6 +13,27 @@ interface TalkgroupEntry {
|
||||
id: string;
|
||||
name: string;
|
||||
tag: string;
|
||||
// Local knowledge for the transcript corrector (server-26#36). Optional on
|
||||
// every talkgroup: unset means "inherit the system's", which is the whole
|
||||
// point of the scope rule — talkgroup narrows, it does not replace.
|
||||
vocabulary?: string[];
|
||||
area_context?: AreaContext;
|
||||
}
|
||||
|
||||
/** Comma-separated text field <-> string[], the shape the API stores. */
|
||||
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. */
|
||||
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;
|
||||
return Object.keys(out).length ? out : undefined;
|
||||
}
|
||||
|
||||
interface P25Config {
|
||||
@@ -47,10 +68,15 @@ function recordToP25Config(c: Record<string, unknown>): P25Config {
|
||||
? (c.voice_channels as number[]).join(", ")
|
||||
: "",
|
||||
talkgroups: Array.isArray(c.talkgroups)
|
||||
? (c.talkgroups as Array<{ id: number; name: string; tag: string }>).map((tg) => ({
|
||||
? (c.talkgroups as Array<{
|
||||
id: number; name: string; tag: string;
|
||||
vocabulary?: string[]; area_context?: AreaContext;
|
||||
}>).map((tg) => ({
|
||||
id: String(tg.id),
|
||||
name: tg.name,
|
||||
tag: tg.tag ?? "other",
|
||||
vocabulary: tg.vocabulary ?? [],
|
||||
area_context: tg.area_context ?? {},
|
||||
}))
|
||||
: [],
|
||||
};
|
||||
@@ -70,7 +96,19 @@ function p25ConfigToRecord(p: P25Config): Record<string, unknown> {
|
||||
voice_channels: parseFreqs(p.voice_channels),
|
||||
talkgroups: p.talkgroups
|
||||
.filter((tg) => tg.id && tg.name)
|
||||
.map((tg) => ({ id: parseInt(tg.id, 10), name: tg.name, tag: tg.tag })),
|
||||
.map((tg) => {
|
||||
const area = cleanArea(tg.area_context);
|
||||
const vocab = (tg.vocabulary ?? []).filter(Boolean);
|
||||
return {
|
||||
id: parseInt(tg.id, 10),
|
||||
name: tg.name,
|
||||
tag: tg.tag,
|
||||
// Omitted rather than written empty: an absent key is what
|
||||
// resolve_context() reads as "inherit from the system".
|
||||
...(vocab.length ? { vocabulary: vocab } : {}),
|
||||
...(area ? { area_context: area } : {}),
|
||||
};
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -316,6 +354,36 @@ function RRImportModal({
|
||||
);
|
||||
}
|
||||
|
||||
/** True when this talkgroup overrides anything, so the row can say so. */
|
||||
function hasLocalKnowledge(tg: TalkgroupEntry): boolean {
|
||||
return Boolean((tg.vocabulary ?? []).length || cleanArea(tg.area_context));
|
||||
}
|
||||
|
||||
/** One labelled text input in the local-knowledge grid. */
|
||||
function LocalKnowledgeField({
|
||||
label,
|
||||
value,
|
||||
onChange,
|
||||
placeholder,
|
||||
}: {
|
||||
label: string;
|
||||
value: string;
|
||||
onChange: (v: string) => void;
|
||||
placeholder?: string;
|
||||
}) {
|
||||
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"
|
||||
/>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Talkgroup table editor ────────────────────────────────────────────────────
|
||||
|
||||
function TalkgroupEditor({
|
||||
@@ -329,12 +397,34 @@ function TalkgroupEditor({
|
||||
const [pasteText, setPasteText] = useState("");
|
||||
const [rrSystem, setRrSystem] = useState<RRSystem | null>(null);
|
||||
const [rrError, setRrError] = useState<string | null>(null);
|
||||
const [expanded, setExpanded] = useState<number | null>(null);
|
||||
const rrInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
function addRow() {
|
||||
onChange([...talkgroups, { id: "", name: "", tag: "other" }]);
|
||||
}
|
||||
|
||||
function updateArea(i: number, field: "municipality" | "county", 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) {
|
||||
const updated = [...talkgroups];
|
||||
updated[i] = {
|
||||
...updated[i],
|
||||
area_context: { ...updated[i].area_context, [field]: textToList(value) },
|
||||
};
|
||||
onChange(updated);
|
||||
}
|
||||
|
||||
function updateRowList(i: number, field: "vocabulary", value: string) {
|
||||
const updated = [...talkgroups];
|
||||
updated[i] = { ...updated[i], [field]: textToList(value) };
|
||||
onChange(updated);
|
||||
}
|
||||
|
||||
function removeRow(i: number) {
|
||||
onChange(talkgroups.filter((_, idx) => idx !== i));
|
||||
}
|
||||
@@ -478,7 +568,8 @@ function TalkgroupEditor({
|
||||
</thead>
|
||||
<tbody>
|
||||
{talkgroups.map((tg, i) => (
|
||||
<tr key={i} className="border-t border-gray-800 hover:bg-gray-800/30">
|
||||
<Fragment key={i}>
|
||||
<tr className="border-t border-gray-800 hover:bg-gray-800/30">
|
||||
<td className="px-2 py-1">
|
||||
<input
|
||||
value={tg.id}
|
||||
@@ -507,6 +598,16 @@ function TalkgroupEditor({
|
||||
</select>
|
||||
</td>
|
||||
<td className="px-2 py-1 text-center">
|
||||
<button
|
||||
type="button"
|
||||
title="Local knowledge for the transcript corrector"
|
||||
onClick={() => setExpanded(expanded === i ? null : i)}
|
||||
className={`transition-colors font-bold mr-2 ${
|
||||
hasLocalKnowledge(tg) ? "text-indigo-400" : "text-gray-600 hover:text-gray-300"
|
||||
}`}
|
||||
>
|
||||
{expanded === i ? "▾" : "▸"}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeRow(i)}
|
||||
@@ -516,6 +617,55 @@ function TalkgroupEditor({
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
{/* Local knowledge, collapsed by default: a system can carry 125
|
||||
talkgroups and most inherit the system's context rather than
|
||||
override it. */}
|
||||
{expanded === i && (
|
||||
<tr className="border-t border-gray-800 bg-gray-900/60">
|
||||
<td colSpan={4} className="px-3 py-3">
|
||||
<p className="text-xs text-gray-500 mb-2 font-sans">
|
||||
Given to the transcript corrector for this talkgroup only, ranked
|
||||
<span className="text-gray-300"> above</span> the system's own list.
|
||||
Leave blank to inherit the system's.
|
||||
</p>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-2">
|
||||
<LocalKnowledgeField
|
||||
label="Municipality"
|
||||
value={tg.area_context?.municipality ?? ""}
|
||||
onChange={(v) => updateArea(i, "municipality", v)}
|
||||
placeholder="Ossining"
|
||||
/>
|
||||
<LocalKnowledgeField
|
||||
label="County"
|
||||
value={tg.area_context?.county ?? ""}
|
||||
onChange={(v) => updateArea(i, "county", v)}
|
||||
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"
|
||||
/>
|
||||
<div className="md:col-span-2">
|
||||
<LocalKnowledgeField
|
||||
label="Unit call signs & local terms"
|
||||
value={listToText(tg.vocabulary)}
|
||||
onChange={(v) => updateRowList(i, "vocabulary", v)}
|
||||
placeholder="Post 4, 11-X-ray, Car 7"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</Fragment>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
@@ -996,6 +1146,124 @@ function SourceCallPlayer({ callId }: { callId: string }) {
|
||||
);
|
||||
}
|
||||
|
||||
// ── Area context panel ────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* System-wide ground truth for the transcript corrector (server-26#36).
|
||||
*
|
||||
* This is the fallback every talkgroup inherits. A talkgroup that covers one
|
||||
* municipality inside a multi-county system overrides it from the talkgroup
|
||||
* table in the edit form, and its entries rank above these.
|
||||
*/
|
||||
function AreaContextPanel({ systemId }: { systemId: string }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [area, setArea] = useState<AreaContext | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
async function toggle() {
|
||||
const next = !open;
|
||||
setOpen(next);
|
||||
if (!next || area !== null) return;
|
||||
setLoading(true);
|
||||
try {
|
||||
const data = await c2api.getAreaContext(systemId);
|
||||
setArea(data.area_context ?? {});
|
||||
} catch (e) {
|
||||
setError(String(e));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function save() {
|
||||
if (!area) return;
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
try {
|
||||
await c2api.updateAreaContext(systemId, area);
|
||||
} catch (e) {
|
||||
setError(String(e));
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
const filled = area
|
||||
? [area.municipality, area.county, area.roads?.length, area.landmarks?.length].filter(Boolean).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>
|
||||
Local Area
|
||||
{area !== null && (
|
||||
<span className="text-gray-600 ml-1">
|
||||
({filled > 0 ? `${filled} field${filled === 1 ? "" : "s"} set` : "not set"})
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<div className="mt-3 space-y-3 text-xs">
|
||||
{loading && <p className="text-gray-600 italic font-mono">Loading…</p>}
|
||||
{area && (
|
||||
<>
|
||||
<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.
|
||||
</p>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-2">
|
||||
<LocalKnowledgeField
|
||||
label="Municipality"
|
||||
value={area.municipality ?? ""}
|
||||
onChange={(v) => setArea({ ...area, municipality: v })}
|
||||
placeholder="Ossining"
|
||||
/>
|
||||
<LocalKnowledgeField
|
||||
label="County"
|
||||
value={area.county ?? ""}
|
||||
onChange={(v) => setArea({ ...area, county: v })}
|
||||
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"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={save}
|
||||
disabled={saving}
|
||||
className="bg-indigo-700 hover:bg-indigo-600 disabled:opacity-50 text-white px-3 py-1.5 rounded text-xs font-semibold transition-colors"
|
||||
>
|
||||
{saving ? "Saving…" : "Save area"}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
{error && <p className="text-red-400 font-mono">{error}</p>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Vocabulary panel ──────────────────────────────────────────────────────────
|
||||
|
||||
function VocabularyPanel({ systemId }: { systemId: string }) {
|
||||
@@ -1291,6 +1559,7 @@ export default function SystemsPage() {
|
||||
</div>
|
||||
<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} />
|
||||
<VocabularyPanel systemId={s.system_id} />
|
||||
</div>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user