Add source call audio playback to vocabulary suggestions

When the induction loop proposes a new vocabulary term, it now records
which sampled call(s) most likely produced the suggestion. Admins see
a collapsible "▶ source" player under each pending term showing the
audio clip and transcript, so they can hear what was actually said
before approving or dismissing.

- vocabulary_learner: track sampled call docs, attach source_call_ids
  to each pending term via word-overlap search with fallback
- types: VocabularyPendingTerm.source_call_ids?: string[]
- c2api: add getCall(id) using existing GET /calls/{call_id} endpoint
- VocabularyPanel: SourceCallPlayer component — lazy-loads call on
  first expand, shows audio controls + transcript snippet
This commit is contained in:
Logan
2026-06-01 01:45:03 -04:00
parent 032eef311f
commit 913fe0cbee
4 changed files with 102 additions and 11 deletions
+63 -6
View File
@@ -829,6 +829,54 @@ function AiFlagsPanel({ systemId, initial }: { systemId: string; initial: System
);
}
// ── Source call audio player ──────────────────────────────────────────────────
function SourceCallPlayer({ callId }: { callId: string }) {
const [call, setCall] = useState<{ audio_url?: string | null; transcript?: string | null; transcript_corrected?: string | null } | null>(null);
const [loading, setLoading] = useState(false);
const [open, setOpen] = useState(false);
async function toggle() {
if (!open && !call) {
setLoading(true);
try {
const c = await c2api.getCall(callId);
setCall(c as typeof call);
} finally {
setLoading(false);
}
}
setOpen((v) => !v);
}
const transcript = call?.transcript_corrected || call?.transcript;
return (
<div className="text-xs">
<button
onClick={toggle}
disabled={loading}
className="text-indigo-500 hover:text-indigo-400 transition-colors disabled:opacity-50"
title={callId}
>
{loading ? "loading…" : open ? "▲ source" : "▶ source"}
</button>
{open && call && (
<div className="mt-1.5 space-y-1 pl-2 border-l border-gray-700">
{call.audio_url ? (
<audio src={call.audio_url} controls className="w-full" style={{ height: "1.75rem" }} />
) : (
<p className="text-gray-600 italic">No audio</p>
)}
{transcript && (
<p className="text-gray-500 italic line-clamp-2">{transcript}</p>
)}
</div>
)}
</div>
);
}
// ── Vocabulary panel ──────────────────────────────────────────────────────────
function VocabularyPanel({ systemId }: { systemId: string }) {
@@ -979,13 +1027,22 @@ function VocabularyPanel({ systemId }: { systemId: string }) {
<p className="text-gray-500 uppercase tracking-wider mb-1.5">
Induction suggestions ({pending.length})
</p>
<div className="space-y-1">
<div className="space-y-2">
{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 key={p.term} className="space-y-1">
<div 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>
{p.source_call_ids && p.source_call_ids.length > 0 && (
<div className="pl-1 space-y-1">
{p.source_call_ids.map((id) => (
<SourceCallPlayer key={id} callId={id} />
))}
</div>
)}
</div>
))}
</div>