Start to learn vocab from talkgroups to improve accuracy of STT

This commit is contained in:
Logan
2026-04-21 22:17:30 -04:00
parent 6612e4b683
commit 338b946ba3
11 changed files with 759 additions and 8 deletions
+185 -1
View File
@@ -3,7 +3,7 @@
import { useState } from "react";
import { useSystems } from "@/lib/useSystems";
import { c2api } from "@/lib/c2api";
import type { SystemRecord } from "@/lib/types";
import type { SystemRecord, VocabularyPendingTerm } from "@/lib/types";
// ── P25 structured config types ──────────────────────────────────────────────
@@ -433,6 +433,189 @@ function SystemForm({
);
}
// ── Vocabulary panel ──────────────────────────────────────────────────────────
function VocabularyPanel({ systemId }: { systemId: string }) {
const [vocab, setVocab] = useState<string[] | null>(null);
const [pending, setPending] = useState<VocabularyPendingTerm[]>([]);
const [bootstrapped, setBootstrapped] = useState(false);
const [loading, setLoading] = useState(false);
const [bootstrapping, setBootstrapping] = useState(false);
const [newTerm, setNewTerm] = useState("");
const [adding, setAdding] = useState(false);
const [open, setOpen] = useState(false);
async function load() {
if (vocab !== null) return; // already loaded
setLoading(true);
try {
const data = await c2api.getVocabulary(systemId);
setVocab(data.vocabulary);
setPending(data.vocabulary_pending);
setBootstrapped(data.vocabulary_bootstrapped);
} finally {
setLoading(false);
}
}
function toggle() {
if (!open) load();
setOpen((v) => !v);
}
async function handleBootstrap() {
setBootstrapping(true);
try {
const result = await c2api.bootstrapVocabulary(systemId);
const data = await c2api.getVocabulary(systemId);
setVocab(data.vocabulary);
setPending(data.vocabulary_pending);
setBootstrapped(true);
alert(`Bootstrap added ${result.added} term(s).`);
} finally {
setBootstrapping(false);
}
}
async function handleAdd(e: React.FormEvent) {
e.preventDefault();
const term = newTerm.trim();
if (!term) return;
setAdding(true);
try {
await c2api.addVocabularyTerm(systemId, term);
setVocab((v) => (v ? [...v, term] : [term]));
setNewTerm("");
} finally {
setAdding(false);
}
}
async function handleRemove(term: string) {
await c2api.removeVocabularyTerm(systemId, term);
setVocab((v) => (v ?? []).filter((t) => t !== term));
}
async function handleApprove(term: string) {
await c2api.approvePendingTerm(systemId, term);
setVocab((v) => (v ? [...v, term] : [term]));
setPending((p) => p.filter((t) => t.term !== term));
}
async function handleDismiss(term: string) {
await c2api.dismissPendingTerm(systemId, term);
setPending((p) => p.filter((t) => t.term !== term));
}
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>
Vocabulary
{vocab !== null && <span className="text-gray-600 ml-1">({vocab.length} terms{pending.length > 0 ? `, ${pending.length} pending` : ""})</span>}
</span>
</button>
{open && (
<div className="mt-3 space-y-3 font-mono text-xs">
{loading && <p className="text-gray-600 italic">Loading</p>}
{!loading && vocab !== null && (
<>
{/* Bootstrap button */}
<div className="flex items-center gap-3">
<button
onClick={handleBootstrap}
disabled={bootstrapping}
className="bg-indigo-800 hover:bg-indigo-700 disabled:opacity-50 text-indigo-200 px-3 py-1.5 rounded-lg text-xs transition-colors"
>
{bootstrapping ? "Bootstrapping…" : bootstrapped ? "Re-bootstrap with AI" : "Bootstrap with AI"}
</button>
<span className="text-gray-600">GPT-4o generates local knowledge (agencies, units, streets)</span>
</div>
{/* Approved vocabulary chips */}
<div>
<p className="text-gray-500 uppercase tracking-wider mb-1.5">Approved ({vocab.length})</p>
{vocab.length > 0 ? (
<div className="flex flex-wrap gap-1.5">
{vocab.map((term) => (
<span
key={term}
className="inline-flex items-center gap-1 bg-gray-800 text-gray-300 px-2 py-0.5 rounded-full"
>
{term}
<button
onClick={() => handleRemove(term)}
className="text-gray-600 hover:text-red-400 transition-colors leading-none"
>
×
</button>
</span>
))}
</div>
) : (
<p className="text-gray-600 italic">No terms yet bootstrap or add manually.</p>
)}
</div>
{/* Add term */}
<form onSubmit={handleAdd} className="flex gap-2">
<input
value={newTerm}
onChange={(e) => setNewTerm(e.target.value)}
placeholder="Add term (e.g. 5-baker, YVAC)"
className="flex-1 bg-gray-800 border border-gray-700 rounded-lg px-3 py-1.5 text-white text-xs font-mono focus:outline-none focus:border-indigo-500"
/>
<button
type="submit"
disabled={adding || !newTerm.trim()}
className="bg-gray-700 hover:bg-gray-600 disabled:opacity-50 text-gray-200 px-3 py-1.5 rounded-lg transition-colors"
>
Add
</button>
</form>
{/* Pending induction suggestions */}
{pending.length > 0 && (
<div>
<p className="text-gray-500 uppercase tracking-wider mb-1.5">
Induction suggestions ({pending.length})
</p>
<div className="space-y-1">
{pending.map((p) => (
<div key={p.term} className="flex items-center gap-2">
<span className="text-gray-300 flex-1">{p.term}</span>
<span className="text-gray-600">{p.source}</span>
<button
onClick={() => handleApprove(p.term)}
className="text-green-500 hover:text-green-400 transition-colors px-1"
>
</button>
<button
onClick={() => handleDismiss(p.term)}
className="text-gray-600 hover:text-red-400 transition-colors px-1"
>
</button>
</div>
))}
</div>
</div>
)}
</>
)}
</div>
)}
</div>
);
}
// ── Systems list page ─────────────────────────────────────────────────────────
export default function SystemsPage() {
@@ -509,6 +692,7 @@ export default function SystemsPage() {
Delete
</button>
</div>
<VocabularyPanel systemId={s.system_id} />
</div>
);
})}