Skip to content

Commit aa52abe

Browse files
Caller transcript pre-recording (#717)
Co-authored-by: Kent C. Dodds <me+github@kentcdodds.com> Co-authored-by: Cursor Agent <cursoragent@cursor.com>
1 parent 3e39c33 commit aa52abe

8 files changed

Lines changed: 603 additions & 7 deletions

File tree

app/routes/calls_.admin/$callId.tsx

Lines changed: 122 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,9 @@ export async function loader({ request, params }: Route.LoaderArgs) {
5050
title: true,
5151
id: true,
5252
isAnonymous: true,
53+
callerTranscript: true,
54+
callerTranscriptStatus: true,
55+
callerTranscriptErrorMessage: true,
5356
episodeDraft: {
5457
select: {
5558
id: true,
@@ -102,6 +105,12 @@ function CallListing({ call }: { call: SerializeFrom<typeof loader>['call'] }) {
102105
const [audioEl, setAudioEl] = React.useState<HTMLAudioElement | null>(null)
103106
const [playbackRate, setPlaybackRate] = React.useState(2)
104107
const dc = useDoubleCheck()
108+
const callerTranscriptStatus = call.callerTranscriptStatus
109+
const callerTranscript = call.callerTranscript?.trim() ?? ''
110+
const [callerTranscriptValue, setCallerTranscriptValue] =
111+
React.useState(callerTranscript)
112+
const callerTranscriptLocked = Boolean(call.episodeDraft)
113+
const callerTranscriptError = call.callerTranscriptErrorMessage
105114
const mailtoHref = `mailto:${call.user.email}?${new URLSearchParams({
106115
subject: `Re: Call Kent - ${call.title}`,
107116
body: `I just wanted to talk about your call on the Call Kent podcast`,
@@ -112,6 +121,10 @@ function CallListing({ call }: { call: SerializeFrom<typeof loader>['call'] }) {
112121
audioEl.playbackRate = playbackRate
113122
}, [audioEl, playbackRate])
114123

124+
React.useEffect(() => {
125+
setCallerTranscriptValue(callerTranscript)
126+
}, [callerTranscript])
127+
115128
return (
116129
<section
117130
className={`set-color-team-current-${call.user.team.toLowerCase()} rounded-lg bg-gray-100 p-6 lg:p-8 dark:bg-gray-800`}
@@ -169,6 +182,104 @@ function CallListing({ call }: { call: SerializeFrom<typeof loader>['call'] }) {
169182
</Paragraph>
170183
</div>
171184

185+
{/* Caller Transcript */}
186+
<div className="mb-6 rounded-lg bg-gray-200 p-4 dark:bg-gray-700">
187+
<div className="mb-3 flex flex-wrap items-center justify-between gap-3">
188+
<H6 as="h3" id="caller-transcript">
189+
Caller transcript
190+
</H6>
191+
<Form method="post" action={recordingFormActionPath}>
192+
<input type="hidden" name="intent" value="generate-caller-transcript" />
193+
<input type="hidden" name="callId" value={call.id} />
194+
<Button
195+
type="submit"
196+
variant="secondary"
197+
size="small"
198+
disabled={
199+
callerTranscriptStatus === 'PROCESSING' || callerTranscriptLocked
200+
}
201+
>
202+
{callerTranscriptStatus === 'READY'
203+
? 'Regenerate transcript'
204+
: callerTranscriptStatus === 'PROCESSING'
205+
? 'Generating...'
206+
: 'Generate transcript'}
207+
</Button>
208+
</Form>
209+
</div>
210+
211+
{callerTranscriptStatus === 'PROCESSING' ? (
212+
<div className="flex items-center gap-2 text-sm text-gray-600 dark:text-slate-300">
213+
<Spinner showSpinner={true} size={16} />
214+
<span>Generating caller transcript…</span>
215+
</div>
216+
) : null}
217+
218+
{callerTranscriptStatus === 'ERROR' ? (
219+
<Paragraph className="whitespace-pre-wrap text-red-700 dark:text-red-300">
220+
{callerTranscriptError || 'Unable to generate caller transcript.'}
221+
</Paragraph>
222+
) : null}
223+
224+
{callerTranscriptLocked ? (
225+
<div className="mt-3 flex flex-col gap-3">
226+
<Paragraph className="text-xs text-gray-500 dark:text-slate-400">
227+
Caller transcript edits are disabled after an episode draft exists.
228+
Update the{' '}
229+
<a href="#draft-transcript" className="underline">
230+
episode draft transcript
231+
</a>{' '}
232+
below instead.
233+
</Paragraph>
234+
<div className="rounded-lg bg-white px-4 py-3 text-sm whitespace-pre-wrap text-gray-800 dark:bg-gray-800 dark:text-white">
235+
{callerTranscriptValue || 'No caller transcript available.'}
236+
</div>
237+
</div>
238+
) : (
239+
<Form
240+
method="post"
241+
action={recordingFormActionPath}
242+
className="mt-3 flex flex-col gap-3"
243+
>
244+
<input type="hidden" name="intent" value="update-caller-transcript" />
245+
<input type="hidden" name="callId" value={call.id} />
246+
<textarea
247+
name="callerTranscript"
248+
value={callerTranscriptValue}
249+
onChange={(event) => {
250+
setCallerTranscriptValue(event.currentTarget.value)
251+
}}
252+
placeholder="Caller transcript"
253+
rows={6}
254+
aria-labelledby="caller-transcript"
255+
className="focus-ring w-full rounded-lg bg-white px-4 py-3 text-sm text-gray-800 dark:bg-gray-800 dark:text-white"
256+
disabled={callerTranscriptStatus === 'PROCESSING'}
257+
/>
258+
<div className="flex items-center justify-between gap-3">
259+
<Paragraph className="text-xs text-gray-500 dark:text-slate-400">
260+
Edit this transcript before recording; this version is used for
261+
the full episode transcript.
262+
</Paragraph>
263+
<Button
264+
type="submit"
265+
variant="secondary"
266+
size="small"
267+
disabled={callerTranscriptStatus === 'PROCESSING'}
268+
>
269+
Save caller transcript
270+
</Button>
271+
</div>
272+
</Form>
273+
)}
274+
275+
{callerTranscriptStatus === 'NOT_STARTED' && !callerTranscriptLocked ? (
276+
<Paragraph className="text-gray-500 dark:text-slate-400">
277+
Generate this now to review the caller's transcript before recording
278+
your response.
279+
</Paragraph>
280+
) : null}
281+
</div>
282+
172283
{/* Audio Player */}
173284
<div className="rounded-lg bg-gray-200 p-4 dark:bg-gray-700">
174285
<H6 as="h3" className="mb-3">
@@ -599,6 +710,16 @@ function RecordingDetailScreen({
599710
setPolledStatus(null)
600711
}, [draft?.id])
601712

713+
// Caller transcript generation runs before episode-draft creation, so poll the
714+
// full loader while that lightweight status is processing.
715+
useInterval(
716+
async () => {
717+
if (revalidator.state !== 'idle') return
718+
await revalidator.revalidate()
719+
},
720+
data.call.callerTranscriptStatus === 'PROCESSING' ? 1500 : 0,
721+
)
722+
602723
// Use lightweight status-only endpoint when polling to avoid re-fetching
603724
// transcript, title, description, keywords on every 1.5s poll.
604725
useInterval(
@@ -640,7 +761,7 @@ function RecordingDetailScreen({
640761
Episode draft
641762
</H6>
642763
<Paragraph className="mb-6 text-gray-500 dark:text-slate-400">
643-
{`Record your response, then the app will generate the full episode audio, transcript, and AI metadata before you publish.`}
764+
{`You can review the caller transcript first, then record your response. The app will generate full episode audio and metadata before publish.`}
644765
</Paragraph>
645766

646767
{draft ? (

app/routes/resources/calls/save.tsx

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import {
77
getAudioBuffer,
88
putCallAudioFromDataUrl,
99
} from '#app/utils/call-kent-audio-storage.server.ts'
10+
import { startCallKentCallerTranscriptProcessing } from '#app/utils/call-kent-caller-transcript.server.ts'
1011
import { startCallKentEpisodeDraftProcessing } from '#app/utils/call-kent-episode-draft.server.ts'
1112
import { getPublishedCallKentEpisodeEmail } from '#app/utils/call-kent-published-email.ts'
1213
import {
@@ -115,6 +116,8 @@ async function createCall({
115116
throw error
116117
}
117118

119+
void startCallKentCallerTranscriptProcessing(createdCall.id)
120+
118121
try {
119122
const env = getEnv()
120123
const channelId = env.DISCORD_PRIVATE_BOT_CHANNEL
@@ -456,6 +459,80 @@ async function createEpisodeDraft({
456459
return redirect(`/calls/admin/${callId}`)
457460
}
458461

462+
async function generateCallerTranscript({
463+
request,
464+
formData,
465+
}: {
466+
request: Request
467+
formData: FormData
468+
}) {
469+
const callId = getStringFormValue(formData, 'callId')
470+
if (!callId) return redirectCallNotFound()
471+
472+
await requireAdminUser(request)
473+
474+
const call = await prisma.call.findFirst({
475+
where: { id: callId },
476+
select: { id: true },
477+
})
478+
if (!call) return redirectCallNotFound()
479+
480+
await prisma.call.update({
481+
where: { id: callId },
482+
data: {
483+
callerTranscriptStatus: 'PROCESSING',
484+
callerTranscriptErrorMessage: null,
485+
},
486+
})
487+
488+
void startCallKentCallerTranscriptProcessing(callId, { force: true })
489+
return redirect(`/calls/admin/${callId}`)
490+
}
491+
492+
async function updateCallerTranscript({
493+
request,
494+
formData,
495+
}: {
496+
request: Request
497+
formData: FormData
498+
}) {
499+
const callId = getStringFormValue(formData, 'callId')
500+
if (!callId) return redirectCallNotFound()
501+
502+
await requireAdminUser(request)
503+
504+
const call = await prisma.call.findFirst({
505+
where: { id: callId },
506+
select: { id: true },
507+
})
508+
if (!call) return redirectCallNotFound()
509+
510+
const transcriptText = getStringFormValue(formData, 'callerTranscript')
511+
const nextTranscript = transcriptText?.trim() ?? ''
512+
513+
if (!nextTranscript) {
514+
await prisma.call.update({
515+
where: { id: callId },
516+
data: {
517+
callerTranscript: null,
518+
callerTranscriptStatus: 'NOT_STARTED',
519+
callerTranscriptErrorMessage: null,
520+
},
521+
})
522+
} else {
523+
await prisma.call.update({
524+
where: { id: callId },
525+
data: {
526+
callerTranscript: nextTranscript,
527+
callerTranscriptStatus: 'READY',
528+
callerTranscriptErrorMessage: null,
529+
},
530+
})
531+
}
532+
533+
return redirect(`/calls/admin/${callId}`)
534+
}
535+
459536
async function undoEpisodeDraft({
460537
request,
461538
formData,
@@ -606,6 +683,10 @@ export async function action({ request }: Route.ActionArgs) {
606683
}
607684
if (intent === 'create-episode-draft')
608685
return createEpisodeDraft({ request, formData })
686+
if (intent === 'generate-caller-transcript')
687+
return generateCallerTranscript({ request, formData })
688+
if (intent === 'update-caller-transcript')
689+
return updateCallerTranscript({ request, formData })
609690
if (intent === 'undo-episode-draft')
610691
return undoEpisodeDraft({ request, formData })
611692
if (intent === 'update-episode-draft')
Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
1+
import { expect, test, vi } from 'vitest'
2+
3+
vi.mock('#app/utils/call-kent-audio-storage.server.ts', () => ({
4+
getAudioBuffer: vi.fn(),
5+
}))
6+
7+
vi.mock('#app/utils/cloudflare-ai-transcription.server.ts', () => ({
8+
transcribeMp3WithWorkersAi: vi.fn(),
9+
}))
10+
11+
vi.mock('#app/utils/cloudflare-ai-call-kent-transcript-format.server.ts', () => ({
12+
formatCallKentTranscriptWithWorkersAi: vi.fn(),
13+
}))
14+
15+
vi.mock('#app/utils/prisma.server.ts', () => ({
16+
prisma: {
17+
call: {
18+
updateMany: vi.fn(),
19+
findUnique: vi.fn(),
20+
},
21+
},
22+
}))
23+
24+
import { getAudioBuffer } from '#app/utils/call-kent-audio-storage.server.ts'
25+
import { formatCallKentTranscriptWithWorkersAi } from '#app/utils/cloudflare-ai-call-kent-transcript-format.server.ts'
26+
import { transcribeMp3WithWorkersAi } from '#app/utils/cloudflare-ai-transcription.server.ts'
27+
import { prisma } from '#app/utils/prisma.server.ts'
28+
import {
29+
normalizeCallerTranscriptForEpisode,
30+
startCallKentCallerTranscriptProcessing,
31+
} from '../call-kent-caller-transcript.server.ts'
32+
33+
test('startCallKentCallerTranscriptProcessing saves ready transcript', async () => {
34+
vi.clearAllMocks()
35+
36+
vi.mocked(prisma.call.updateMany)
37+
.mockResolvedValueOnce({ count: 1 })
38+
.mockResolvedValueOnce({ count: 1 })
39+
vi.mocked(prisma.call.findUnique).mockResolvedValue({
40+
audioKey: 'calls/123.mp3',
41+
title: 'How do I get better at testing?',
42+
notes: 'I am stuck with flaky tests.',
43+
isAnonymous: false,
44+
user: { firstName: 'Riley' },
45+
} as any)
46+
vi.mocked(getAudioBuffer).mockResolvedValue(Buffer.from('audio-data'))
47+
vi.mocked(transcribeMp3WithWorkersAi).mockResolvedValue(
48+
'I have this testing question',
49+
)
50+
vi.mocked(formatCallKentTranscriptWithWorkersAi).mockResolvedValue(
51+
'Riley: I have this testing question',
52+
)
53+
54+
await startCallKentCallerTranscriptProcessing('call-123')
55+
56+
expect(transcribeMp3WithWorkersAi).toHaveBeenCalledWith({
57+
mp3: Buffer.from('audio-data'),
58+
callerName: 'Riley',
59+
callTitle: 'How do I get better at testing?',
60+
callerNotes: 'I am stuck with flaky tests.',
61+
})
62+
expect(formatCallKentTranscriptWithWorkersAi).toHaveBeenCalledWith({
63+
transcript: 'Riley: I have this testing question',
64+
callTitle: 'How do I get better at testing?',
65+
callerNotes: 'I am stuck with flaky tests.',
66+
callerName: 'Riley',
67+
})
68+
expect(prisma.call.updateMany).toHaveBeenLastCalledWith({
69+
where: { id: 'call-123', callerTranscriptStatus: 'PROCESSING' },
70+
data: {
71+
callerTranscript: 'Riley: I have this testing question',
72+
callerTranscriptStatus: 'READY',
73+
callerTranscriptErrorMessage: null,
74+
},
75+
})
76+
})
77+
78+
test('startCallKentCallerTranscriptProcessing records error state', async () => {
79+
vi.clearAllMocks()
80+
81+
vi.mocked(prisma.call.updateMany)
82+
.mockResolvedValueOnce({ count: 1 })
83+
.mockResolvedValueOnce({ count: 1 })
84+
vi.mocked(prisma.call.findUnique).mockResolvedValue({
85+
audioKey: null,
86+
title: 'Question without audio',
87+
notes: null,
88+
isAnonymous: true,
89+
user: { firstName: 'Taylor' },
90+
} as any)
91+
92+
await startCallKentCallerTranscriptProcessing('call-456')
93+
94+
expect(prisma.call.updateMany).toHaveBeenLastCalledWith({
95+
where: { id: 'call-456', callerTranscriptStatus: 'PROCESSING' },
96+
data: {
97+
callerTranscriptStatus: 'ERROR',
98+
callerTranscriptErrorMessage: 'Caller audio is missing (audioKey is null).',
99+
},
100+
})
101+
})
102+
103+
test('normalizeCallerTranscriptForEpisode strips caller label prefix', () => {
104+
expect(
105+
normalizeCallerTranscriptForEpisode({
106+
callerTranscript: 'Riley: How should I test this flow?',
107+
callerName: 'Riley',
108+
}),
109+
).toBe('How should I test this flow?')
110+
expect(
111+
normalizeCallerTranscriptForEpisode({
112+
callerTranscript: 'Caller: Is this better now?',
113+
callerName: 'Riley',
114+
}),
115+
).toBe('Is this better now?')
116+
})
117+
118+
test('normalizeCallerTranscriptForEpisode preserves plain transcript text', () => {
119+
expect(
120+
normalizeCallerTranscriptForEpisode({
121+
callerTranscript: 'How should I test this flow?',
122+
callerName: 'Riley',
123+
}),
124+
).toBe('How should I test this flow?')
125+
})

0 commit comments

Comments
 (0)