Start to learn vocab from talkgroups to improve accuracy of STT
This commit is contained in:
@@ -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>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -93,4 +93,20 @@ export const c2api = {
|
||||
// Node key management
|
||||
reissueNodeKey: (nodeId: string) =>
|
||||
request(`/nodes/${nodeId}/reissue-key`, { method: "POST" }),
|
||||
|
||||
// Vocabulary
|
||||
getVocabulary: (systemId: string) =>
|
||||
request<{ vocabulary: string[]; vocabulary_pending: { term: string; source: string; added_at: string }[]; vocabulary_bootstrapped: boolean }>(
|
||||
`/systems/${systemId}/vocabulary`
|
||||
),
|
||||
bootstrapVocabulary: (systemId: string) =>
|
||||
request<{ added: number; terms: string[] }>(`/systems/${systemId}/vocabulary/bootstrap`, { method: "POST" }),
|
||||
addVocabularyTerm: (systemId: string, term: string) =>
|
||||
request(`/systems/${systemId}/vocabulary/terms`, { method: "POST", body: JSON.stringify({ term }) }),
|
||||
removeVocabularyTerm: (systemId: string, term: string) =>
|
||||
request(`/systems/${systemId}/vocabulary/terms`, { method: "DELETE", body: JSON.stringify({ term }) }),
|
||||
approvePendingTerm: (systemId: string, term: string) =>
|
||||
request(`/systems/${systemId}/vocabulary/pending/approve`, { method: "POST", body: JSON.stringify({ term }) }),
|
||||
dismissPendingTerm: (systemId: string, term: string) =>
|
||||
request(`/systems/${systemId}/vocabulary/pending/dismiss`, { method: "POST", body: JSON.stringify({ term }) }),
|
||||
};
|
||||
|
||||
@@ -13,11 +13,20 @@ export interface NodeRecord {
|
||||
approval_status: ApprovalStatus | null;
|
||||
}
|
||||
|
||||
export interface VocabularyPendingTerm {
|
||||
term: string;
|
||||
source: "induction" | "correction";
|
||||
added_at: string;
|
||||
}
|
||||
|
||||
export interface SystemRecord {
|
||||
system_id: string;
|
||||
name: string;
|
||||
type: string; // P25 | DMR | NBFM
|
||||
config: Record<string, unknown>;
|
||||
vocabulary?: string[];
|
||||
vocabulary_pending?: VocabularyPendingTerm[];
|
||||
vocabulary_bootstrapped?: boolean;
|
||||
}
|
||||
|
||||
export interface TranscriptSegment {
|
||||
|
||||
Reference in New Issue
Block a user