#### PR Dependency Tree * **PR #14729** 👈 This tree was auto-generated by [Charcoal](https://github.com/danerwilliams/charcoal) <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * Transcriptions now produce structured meeting summaries (strict JSON) and a normalized, speaker‑tagged, non‑overlapping transcript with legacy projection support. * **API** * Submission accepts richer transcription input; results return source‑audio metadata, slice manifest, quality indicators, normalized segments/transcript, and structured summary JSON. * **Frontend** * Recording flow stores transcription metadata and uploads preprocessed audio slices with slice/quality info; UI-side result normalization applied. * **Tests** * Expanded unit, contract, and e2e coverage for normalization, payload parsing, persistence/retry, and end‑to‑end transcription flows. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
587 lines
14 KiB
TypeScript
587 lines
14 KiB
TypeScript
import { AiJobStatus } from '@prisma/client';
|
|
import test from 'ava';
|
|
import Sinon from 'sinon';
|
|
|
|
import {
|
|
buildLegacyProjection,
|
|
buildNormalizedTranscript,
|
|
normalizeTranscriptSegments,
|
|
} from '../../plugins/copilot/transcript/projection';
|
|
import { TranscriptPayloadSchema } from '../../plugins/copilot/transcript/schema';
|
|
import { CopilotTranscriptionService } from '../../plugins/copilot/transcript/service';
|
|
|
|
test('normalizeTranscriptSegments trims, sorts and clips overlaps', t => {
|
|
const normalized = normalizeTranscriptSegments([
|
|
{
|
|
source: 'asr',
|
|
sliceIndex: 1,
|
|
speaker: ' B ',
|
|
startSec: 12,
|
|
endSec: 16,
|
|
text: ' second ',
|
|
},
|
|
{
|
|
source: 'asr',
|
|
sliceIndex: 0,
|
|
speaker: 'A',
|
|
startSec: 10,
|
|
endSec: 13,
|
|
text: ' first ',
|
|
},
|
|
{
|
|
source: 'asr',
|
|
sliceIndex: 1,
|
|
speaker: 'B',
|
|
startSec: 12,
|
|
endSec: 16,
|
|
text: 'second',
|
|
},
|
|
{
|
|
source: 'asr',
|
|
sliceIndex: 2,
|
|
speaker: '',
|
|
startSec: 16,
|
|
endSec: 18,
|
|
text: ' ',
|
|
},
|
|
{
|
|
source: 'asr',
|
|
sliceIndex: 2,
|
|
speaker: 'C',
|
|
startSec: 15,
|
|
endSec: 20,
|
|
text: 'third',
|
|
},
|
|
]);
|
|
|
|
t.deepEqual(normalized, [
|
|
{
|
|
speaker: 'A',
|
|
startSec: 10,
|
|
endSec: 13,
|
|
start: '00:00:10',
|
|
end: '00:00:13',
|
|
text: 'first',
|
|
},
|
|
{
|
|
speaker: 'B',
|
|
startSec: 13,
|
|
endSec: 16,
|
|
start: '00:00:13',
|
|
end: '00:00:16',
|
|
text: 'second',
|
|
},
|
|
{
|
|
speaker: 'C',
|
|
startSec: 16,
|
|
endSec: 20,
|
|
start: '00:00:16',
|
|
end: '00:00:20',
|
|
text: 'third',
|
|
},
|
|
]);
|
|
|
|
t.is(
|
|
buildNormalizedTranscript(normalized),
|
|
['00:00:10 A: first', '00:00:13 B: second', '00:00:16 C: third'].join('\n')
|
|
);
|
|
});
|
|
|
|
test('buildLegacyProjection backfills summary, actions and transcription', t => {
|
|
const legacy = buildLegacyProjection({
|
|
normalizedSegments: [
|
|
{
|
|
speaker: 'A',
|
|
startSec: 10,
|
|
endSec: 12,
|
|
start: '00:00:10',
|
|
end: '00:00:12',
|
|
text: 'Kickoff',
|
|
},
|
|
],
|
|
summaryJson: {
|
|
title: 'Weekly Sync',
|
|
durationMinutes: 30,
|
|
attendees: ['A', 'B'],
|
|
keyPoints: ['Reviewed launch status'],
|
|
actionItems: [
|
|
{
|
|
description: 'Send recap',
|
|
owner: 'A',
|
|
deadline: 'Friday',
|
|
},
|
|
],
|
|
decisions: ['Ship on Monday'],
|
|
openQuestions: ['Need final QA sign-off'],
|
|
blockers: ['Missing analytics dashboard'],
|
|
},
|
|
});
|
|
|
|
t.is(legacy.title, 'Weekly Sync');
|
|
t.true(legacy.summary?.includes('Reviewed launch status') ?? false);
|
|
t.true(legacy.summary?.includes('## Decisions') ?? false);
|
|
t.is(legacy.actions, '- [ ] Send recap (A · Friday)');
|
|
t.deepEqual(legacy.transcription, [
|
|
{
|
|
speaker: 'A',
|
|
start: '00:00:10',
|
|
end: '00:00:12',
|
|
transcription: 'Kickoff',
|
|
},
|
|
]);
|
|
});
|
|
|
|
test('TranscriptPayloadSchema keeps legacy payload readable as v2', t => {
|
|
const parsed = TranscriptPayloadSchema.parse({
|
|
url: 'https://example.com/audio.opus',
|
|
mimeType: 'audio/opus',
|
|
title: 'Legacy title',
|
|
summary: '- summary',
|
|
actions: '- [ ] task',
|
|
transcription: [
|
|
{
|
|
speaker: 'A',
|
|
start: '00:00:01',
|
|
end: '00:00:03',
|
|
transcription: 'legacy line',
|
|
},
|
|
],
|
|
});
|
|
|
|
t.deepEqual(parsed.infos, [
|
|
{
|
|
url: 'https://example.com/audio.opus',
|
|
mimeType: 'audio/opus',
|
|
index: 0,
|
|
},
|
|
]);
|
|
t.deepEqual(parsed.legacy, {
|
|
title: 'Legacy title',
|
|
summary: '- summary',
|
|
actions: '- [ ] task',
|
|
transcription: [
|
|
{
|
|
speaker: 'A',
|
|
start: '00:00:01',
|
|
end: '00:00:03',
|
|
transcription: 'legacy line',
|
|
},
|
|
],
|
|
});
|
|
});
|
|
|
|
test('TranscriptPayloadSchema rejects empty legacy payloads', t => {
|
|
const emptyError = t.throws(() => TranscriptPayloadSchema.parse({}));
|
|
t.truthy(emptyError);
|
|
|
|
const unknownOnlyError = t.throws(() =>
|
|
TranscriptPayloadSchema.parse({ foo: 'bar' })
|
|
);
|
|
t.truthy(unknownOnlyError);
|
|
});
|
|
|
|
test('transcriptAudio persists transcript payload before summary failure', async t => {
|
|
const event = { emit: Sinon.spy() };
|
|
const persistedPayloads: any[] = [];
|
|
let currentPayload: any = {};
|
|
const service = new CopilotTranscriptionService(
|
|
event as never,
|
|
{} as never,
|
|
{} as never,
|
|
{} as never,
|
|
{} as never,
|
|
{} as never
|
|
);
|
|
|
|
Sinon.stub(service as any, 'callTranscript').resolves([
|
|
{
|
|
source: 'asr',
|
|
sliceIndex: 0,
|
|
speaker: 'A',
|
|
startSec: 5,
|
|
endSec: 9,
|
|
text: 'Kickoff',
|
|
},
|
|
{
|
|
source: 'asr',
|
|
sliceIndex: 0,
|
|
speaker: 'B',
|
|
startSec: 10,
|
|
endSec: 14,
|
|
text: 'Status update',
|
|
},
|
|
]);
|
|
Sinon.stub(service as any, 'summarizeMeeting').rejects(
|
|
new Error('summary provider unavailable')
|
|
);
|
|
Sinon.stub(service as any, 'updatePayload').callsFake(
|
|
async (...args: any[]) => {
|
|
const updater = args[1] as (payload: any) => any;
|
|
currentPayload = await updater(currentPayload);
|
|
persistedPayloads.push(currentPayload);
|
|
return currentPayload;
|
|
}
|
|
);
|
|
|
|
await t.throwsAsync(() =>
|
|
service.transcriptAudio({
|
|
jobId: 'job-1',
|
|
modelId: 'model-1',
|
|
payload: {
|
|
infos: [
|
|
{
|
|
url: 'https://example.com/audio-0.m4a',
|
|
mimeType: 'audio/m4a',
|
|
index: 0,
|
|
},
|
|
],
|
|
sliceManifest: [
|
|
{
|
|
index: 0,
|
|
fileName: 'audio-0.m4a',
|
|
mimeType: 'audio/m4a',
|
|
startSec: 0,
|
|
durationSec: 30,
|
|
},
|
|
],
|
|
},
|
|
} as Jobs['copilot.transcript.submit'])
|
|
);
|
|
|
|
t.is(persistedPayloads.length, 2);
|
|
t.deepEqual(
|
|
persistedPayloads[0].rawSegments?.map((segment: any) => segment.text),
|
|
['Kickoff', 'Status update']
|
|
);
|
|
t.deepEqual(
|
|
persistedPayloads[0].normalizedSegments?.map(
|
|
(segment: any) => segment.start
|
|
),
|
|
['00:00:05', '00:00:10']
|
|
);
|
|
t.is(
|
|
persistedPayloads[0].normalizedTranscript,
|
|
['00:00:05 A: Kickoff', '00:00:10 B: Status update'].join('\n')
|
|
);
|
|
t.is(persistedPayloads[0].summaryJson, null);
|
|
t.deepEqual(persistedPayloads[1].retryMeta, { skipAsrOnRetry: true });
|
|
Sinon.assert.calledWith(event.emit, 'workspace.file.transcript.failed', {
|
|
jobId: 'job-1',
|
|
});
|
|
});
|
|
|
|
test('transcriptAudio reuses persisted transcript once after summary failure', async t => {
|
|
const event = { emit: Sinon.spy() };
|
|
let currentPayload: any = {
|
|
infos: [
|
|
{
|
|
url: 'https://example.com/audio-0.m4a',
|
|
mimeType: 'audio/m4a',
|
|
index: 0,
|
|
},
|
|
],
|
|
rawSegments: [
|
|
{
|
|
source: 'asr',
|
|
sliceIndex: 0,
|
|
speaker: 'A',
|
|
startSec: 5,
|
|
endSec: 9,
|
|
text: 'Kickoff',
|
|
},
|
|
],
|
|
normalizedSegments: [
|
|
{
|
|
speaker: 'A',
|
|
startSec: 5,
|
|
endSec: 9,
|
|
start: '00:00:05',
|
|
end: '00:00:09',
|
|
text: 'Kickoff',
|
|
},
|
|
],
|
|
normalizedTranscript: '00:00:05 A: Kickoff',
|
|
summaryJson: null,
|
|
retryMeta: { skipAsrOnRetry: true },
|
|
};
|
|
const service = new CopilotTranscriptionService(
|
|
event as never,
|
|
{} as never,
|
|
{} as never,
|
|
{} as never,
|
|
{} as never,
|
|
{} as never
|
|
);
|
|
|
|
const callTranscript = Sinon.stub(service as any, 'callTranscript');
|
|
Sinon.stub(service as any, 'summarizeMeeting').resolves({
|
|
title: 'Weekly Sync',
|
|
durationMinutes: 12,
|
|
attendees: ['A'],
|
|
keyPoints: ['Kickoff'],
|
|
actionItems: [],
|
|
decisions: [],
|
|
openQuestions: [],
|
|
blockers: [],
|
|
});
|
|
Sinon.stub(service as any, 'updatePayload').callsFake(
|
|
async (...args: any[]) => {
|
|
const updater = args[1] as (payload: any) => any;
|
|
currentPayload = await updater(currentPayload);
|
|
return currentPayload;
|
|
}
|
|
);
|
|
|
|
await service.transcriptAudio({
|
|
jobId: 'job-2',
|
|
modelId: 'model-1',
|
|
payload: currentPayload,
|
|
} as Jobs['copilot.transcript.submit']);
|
|
|
|
Sinon.assert.notCalled(callTranscript);
|
|
t.is(currentPayload.summaryJson?.title, 'Weekly Sync');
|
|
t.is(currentPayload.retryMeta, undefined);
|
|
Sinon.assert.calledWith(event.emit, 'workspace.file.transcript.finished', {
|
|
jobId: 'job-2',
|
|
});
|
|
});
|
|
|
|
test('transcriptAudio clears reuse flag after repeated summary failure', async t => {
|
|
const event = { emit: Sinon.spy() };
|
|
let currentPayload: any = {
|
|
infos: [
|
|
{
|
|
url: 'https://example.com/audio-0.m4a',
|
|
mimeType: 'audio/m4a',
|
|
index: 0,
|
|
},
|
|
],
|
|
rawSegments: [
|
|
{
|
|
source: 'asr',
|
|
sliceIndex: 0,
|
|
speaker: 'A',
|
|
startSec: 5,
|
|
endSec: 9,
|
|
text: 'Kickoff',
|
|
},
|
|
],
|
|
normalizedSegments: [
|
|
{
|
|
speaker: 'A',
|
|
startSec: 5,
|
|
endSec: 9,
|
|
start: '00:00:05',
|
|
end: '00:00:09',
|
|
text: 'Kickoff',
|
|
},
|
|
],
|
|
normalizedTranscript: '00:00:05 A: Kickoff',
|
|
summaryJson: null,
|
|
retryMeta: { skipAsrOnRetry: true },
|
|
};
|
|
const service = new CopilotTranscriptionService(
|
|
event as never,
|
|
{} as never,
|
|
{} as never,
|
|
{} as never,
|
|
{} as never,
|
|
{} as never
|
|
);
|
|
|
|
const callTranscript = Sinon.stub(service as any, 'callTranscript');
|
|
Sinon.stub(service as any, 'summarizeMeeting').rejects(
|
|
new Error('summary still unavailable')
|
|
);
|
|
Sinon.stub(service as any, 'updatePayload').callsFake(
|
|
async (...args: any[]) => {
|
|
const updater = args[1] as (payload: any) => any;
|
|
currentPayload = await updater(currentPayload);
|
|
return currentPayload;
|
|
}
|
|
);
|
|
|
|
await t.throwsAsync(() =>
|
|
service.transcriptAudio({
|
|
jobId: 'job-3',
|
|
modelId: 'model-1',
|
|
payload: currentPayload,
|
|
} as Jobs['copilot.transcript.submit'])
|
|
);
|
|
|
|
Sinon.assert.notCalled(callTranscript);
|
|
t.is(currentPayload.retryMeta, undefined);
|
|
t.is(currentPayload.normalizedTranscript, '00:00:05 A: Kickoff');
|
|
Sinon.assert.calledWith(event.emit, 'workspace.file.transcript.failed', {
|
|
jobId: 'job-3',
|
|
});
|
|
});
|
|
|
|
test('queryJob returns transcript payload for finished jobs', async t => {
|
|
const payload = TranscriptPayloadSchema.parse({
|
|
infos: [
|
|
{
|
|
url: 'https://example.com/audio-0.m4a',
|
|
mimeType: 'audio/m4a',
|
|
index: 0,
|
|
},
|
|
],
|
|
normalizedSegments: [
|
|
{
|
|
speaker: 'A',
|
|
startSec: 5,
|
|
endSec: 9,
|
|
start: '00:00:05',
|
|
end: '00:00:09',
|
|
text: 'Kickoff',
|
|
},
|
|
],
|
|
normalizedTranscript: '00:00:05 A: Kickoff',
|
|
});
|
|
const service = new CopilotTranscriptionService(
|
|
{} as never,
|
|
{
|
|
copilotJob: {
|
|
getWithUser: Sinon.stub().resolves({
|
|
id: 'job-4',
|
|
status: AiJobStatus.finished,
|
|
payload,
|
|
}),
|
|
},
|
|
} as never,
|
|
{} as never,
|
|
{} as never,
|
|
{} as never,
|
|
{} as never
|
|
);
|
|
|
|
const result = await service.queryJob('user-1', 'workspace-1', 'job-4');
|
|
|
|
t.is(result?.status, AiJobStatus.finished);
|
|
t.deepEqual(result?.infos, payload.infos);
|
|
t.is(result?.transcription?.normalizedTranscript, '00:00:05 A: Kickoff');
|
|
});
|
|
|
|
test('createCanonicalPayload keeps sliceManifest undefined when input omits it', async t => {
|
|
const service = new CopilotTranscriptionService(
|
|
{} as never,
|
|
{} as never,
|
|
{} as never,
|
|
{} as never,
|
|
{} as never,
|
|
{} as never
|
|
);
|
|
|
|
const payload = await (service as any).createCanonicalPayload('blob-1', [
|
|
{
|
|
url: 'https://example.com/audio-0.m4a',
|
|
mimeType: 'audio/m4a',
|
|
index: 0,
|
|
},
|
|
{
|
|
url: 'https://example.com/audio-1.m4a',
|
|
mimeType: 'audio/m4a',
|
|
index: 1,
|
|
},
|
|
]);
|
|
|
|
t.is(payload.sliceManifest, undefined);
|
|
});
|
|
|
|
test('transcriptAudio derives manifest-less slice offsets from observed durations', async t => {
|
|
const event = { emit: Sinon.spy() };
|
|
let currentPayload: any = {};
|
|
const service = new CopilotTranscriptionService(
|
|
event as never,
|
|
{} as never,
|
|
{} as never,
|
|
{} as never,
|
|
{} as never,
|
|
{} as never
|
|
);
|
|
|
|
const callTranscript = Sinon.stub(service as any, 'callTranscript');
|
|
callTranscript.onCall(0).resolves([
|
|
{
|
|
source: 'asr',
|
|
sliceIndex: 0,
|
|
speaker: 'A',
|
|
startSec: 30,
|
|
endSec: 45,
|
|
text: 'Hello, everyone.',
|
|
},
|
|
{
|
|
source: 'asr',
|
|
sliceIndex: 0,
|
|
speaker: 'B',
|
|
startSec: 46,
|
|
endSec: 70,
|
|
text: 'Hi, thank you for joining the meeting today.',
|
|
},
|
|
]);
|
|
callTranscript.onCall(1).resolves([
|
|
{
|
|
source: 'asr',
|
|
sliceIndex: 1,
|
|
speaker: 'A',
|
|
startSec: 30,
|
|
endSec: 45,
|
|
text: 'Second slice hello.',
|
|
},
|
|
{
|
|
source: 'asr',
|
|
sliceIndex: 1,
|
|
speaker: 'B',
|
|
startSec: 46,
|
|
endSec: 70,
|
|
text: 'Second slice response.',
|
|
},
|
|
]);
|
|
Sinon.stub(service as any, 'summarizeMeeting').resolves({
|
|
title: 'Weekly Sync',
|
|
durationMinutes: 12,
|
|
attendees: ['A', 'B'],
|
|
keyPoints: ['Reviewed launch status'],
|
|
actionItems: [],
|
|
decisions: [],
|
|
openQuestions: [],
|
|
blockers: [],
|
|
});
|
|
Sinon.stub(service as any, 'updatePayload').callsFake(
|
|
async (...args: any[]) => {
|
|
const updater = args[1] as (payload: any) => any;
|
|
currentPayload = await updater(currentPayload);
|
|
return currentPayload;
|
|
}
|
|
);
|
|
|
|
await service.transcriptAudio({
|
|
jobId: 'job-5',
|
|
modelId: 'model-1',
|
|
payload: {
|
|
infos: [
|
|
{
|
|
url: 'https://example.com/audio-0.m4a',
|
|
mimeType: 'audio/m4a',
|
|
index: 0,
|
|
},
|
|
{
|
|
url: 'https://example.com/audio-1.m4a',
|
|
mimeType: 'audio/m4a',
|
|
index: 1,
|
|
},
|
|
],
|
|
},
|
|
} as Jobs['copilot.transcript.submit']);
|
|
|
|
t.deepEqual(
|
|
currentPayload.normalizedSegments?.map((segment: any) => segment.start),
|
|
['00:00:30', '00:00:46', '00:01:40', '00:01:56']
|
|
);
|
|
t.is(
|
|
currentPayload.normalizedTranscript?.split('\n')[2],
|
|
'00:01:40 A: Second slice hello.'
|
|
);
|
|
t.is(currentPayload.sliceManifest, undefined);
|
|
});
|