feat(server): improve stability for long records

This commit is contained in:
DarkSky
2026-08-17 01:25:09 +08:00
parent 047db0fa3e
commit 81fb82761c
8 changed files with 506 additions and 155 deletions
Generated
+3 -3
View File
@@ -2963,7 +2963,7 @@ dependencies = [
"libc",
"log",
"rustversion",
"windows-link 0.1.3",
"windows-link 0.2.1",
"windows-result 0.4.1",
]
@@ -4756,9 +4756,9 @@ dependencies = [
[[package]]
name = "llm_adapter"
version = "0.2.19"
version = "0.2.20"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c17ef06aac72e31a4fae7937998029267c4d923a0671bf1a4062c96a1ab5fa70"
checksum = "5d13be366ea35d2a9966ad5770e3d070e495af4e1e6dd009638dee4b55ab45ed"
dependencies = [
"base64",
"jsonschema",
@@ -196,11 +196,7 @@ providerTest(
'managed transcript route executes the provider-neutral job port',
async t => {
const { models, runtime, transcript } = t.context;
await assertManagedRoute(
runtime,
'transcript.audio',
'Transcript audio structured'
);
await assertManagedRoute(runtime, 'transcript.audio', 'Transcript audio');
const user = await models.user.create({
email: `copilot-provider-transcript-${randomUUID()}@affine.pro`,
});
@@ -63,7 +63,7 @@ test('TranscriptPayloadSchema rejects empty payloads', t => {
function createTranscriptPromptService() {
return {
get: Sinon.stub().resolves({ name: 'Transcript audio structured' }),
get: Sinon.stub().callsFake(async name => ({ name })),
finish: Sinon.stub().callsFake((_prompt, params) => [
{
role: 'user',
@@ -73,57 +73,16 @@ function createTranscriptPromptService() {
};
}
async function buildNativeTranscriptResult(input: any, runId: string) {
await input.onRunCreated?.({ runId, attempt: 1 });
const nativeInput = { input: input.inputSnapshot };
return {
nativeInput,
result: {
sourceAudio: nativeInput.input.sourceAudio ?? null,
quality: nativeInput.input.quality ?? null,
infos: [{ url: 'about:invalid', mimeType: 'text/plain', index: 0 }],
sliceManifest: null,
normalizedSegments: [
{
speaker: 'A',
startSec: 5,
endSec: 9,
start: '00:00:05',
end: '00:00:09',
text: 'Kickoff',
},
],
normalizedTranscript: '00:00:05 A: Kickoff',
summaryJson: {
title: 'Weekly Sync',
durationMinutes: 1,
attendees: ['A'],
keyPoints: ['Kickoff'],
actionItems: [],
decisions: [],
openQuestions: [],
blockers: [],
},
version: 'transcript-result-v1',
},
};
}
function createSuccessfulTranscriptBridge(
runId: string,
bridgeInputs: unknown[]
) {
return {
runStream: (input: unknown) =>
runStream: (input: any, executor: (input: any) => Promise<any>) =>
(async function* () {
const { nativeInput, result } = await buildNativeTranscriptResult(
input,
runId
);
bridgeInputs.push({
...(input as Record<string, unknown>),
nativeInput,
});
await input.onRunCreated?.({ runId, attempt: 1 });
const { result } = await executor(input);
bridgeInputs.push(input);
yield {
type: 'action_done' as const,
actionId: 'transcript.audio',
@@ -334,7 +293,7 @@ test('retryTask reuses failed task and queues a new action attempt', async t =>
user: 'user-1',
workspace: 'workspace-1',
featureKind: 'transcript',
builtInRouteId: 'Transcript audio structured',
builtInRouteId: 'Transcript audio',
}
);
t.is(assertRoute.callCount, 2);
@@ -468,13 +427,13 @@ for (const status of ['ready', 'settled']) {
user: 'user-1',
workspace: 'workspace-1',
featureKind: 'transcript',
builtInRouteId: 'Transcript audio structured',
builtInRouteId: 'Transcript audio',
}
);
});
}
test('transcriptTask runs native transcript recipe through action bridge when available', async t => {
test('transcriptTask transcribes each audio slice and merges absolute timestamps', async t => {
const payload = TranscriptPayloadSchema.parse({
sourceAudio: { blobId: 'blob-1', mimeType: 'audio/opus' },
sliceManifest: [
@@ -485,6 +444,13 @@ test('transcriptTask runs native transcript recipe through action bridge when av
startSec: 12,
durationSec: 30,
},
{
index: 1,
fileName: 'audio-1.opus',
mimeType: 'audio/opus',
startSec: 42,
durationSec: 300,
},
],
infos: [
{
@@ -493,9 +459,62 @@ test('transcriptTask runs native transcript recipe through action bridge when av
mimeType: 'audio/opus',
index: 0,
},
{
key: 'blob-1-1',
url: 'https://affine.fail/api/copilot/blob/user-1/workspace-1/blob-1-1',
mimeType: 'audio/opus',
index: 1,
},
],
});
const bridgeInputs: unknown[] = [];
const clock = Sinon.useFakeTimers();
t.teardown(() => clock.restore());
const structuredCalls: {
messages: { content?: string; attachments?: unknown[] }[];
options: { builtInRouteId?: string };
slot?: string;
}[] = [];
let transientFailure = true;
const generateStructuredValue = Sinon.stub().callsFake(
async (
_conditions: unknown,
messages: { content?: string; attachments?: unknown[] }[],
options: { builtInRouteId?: string },
_contract: unknown,
_filter: unknown,
slot?: string
) => {
structuredCalls.push({ messages, options, slot });
if (options.builtInRouteId === 'Summarize the meeting structured') {
return {
value: {
title: 'Weekly Sync',
durationMinutes: 1,
attendees: ['A', 'B'],
keyPoints: ['Kickoff', 'Follow-up'],
actionItems: [],
decisions: [],
openQuestions: [],
blockers: [],
},
};
}
const attachment = messages
.flatMap(message => message.attachments ?? [])
.at(0) as { attachment: string };
if (attachment.attachment.includes('blob-1-1') && transientFailure) {
transientFailure = false;
throw new Error('upstream returned status 503: UNAVAILABLE');
}
return {
value: attachment.attachment.includes('blob-1-0')
? [{ a: 'A', s: 5, e: 9, t: 'Kickoff' }]
: [{ a: 'B', s: 100, e: 500, t: 'Follow-up' }],
};
}
);
const claimDispatch = Sinon.stub();
claimDispatch.onFirstCall().resolves(true);
claimDispatch.onSecondCall().resolves(false);
@@ -519,20 +538,24 @@ test('transcriptTask runs native transcript recipe through action bridge when av
} as never,
{} as never,
{
presignGet: Sinon.stub().resolves(
'https://canary.copilotcontent.affine.pro/blob-1-0?sig=test'
presignGet: Sinon.stub().callsFake(
async (_userId, _workspaceId, key) =>
`https://canary.copilotcontent.affine.pro/${key}?sig=test`
),
} as never,
{} as never,
createTranscriptPromptService() as never,
createSuccessfulTranscriptBridge('run-bridge', bridgeInputs) as never
createSuccessfulTranscriptBridge('run-bridge', bridgeInputs) as never,
{ generateStructuredValue } as never
);
await service.transcriptTask({
const run = service.transcriptTask({
taskId: 'task-1',
payload,
generation: 'generation-1',
});
await clock.tickAsync(5_000);
await run;
await service.transcriptTask({
taskId: 'task-1',
payload,
@@ -546,39 +569,63 @@ test('transcriptTask runs native transcript recipe through action bridge when av
});
t.like((bridgeInputs[0] as { step: Record<string, unknown> }).step, {
slot: 'transcript.audio',
builtInRouteId: 'Transcript audio structured',
builtInRouteId: 'Transcript audio',
});
t.deepEqual(
(
bridgeInputs[0] as {
nativeInput: { input: { infos: unknown[] } };
inputSnapshot: { infos: unknown[] };
}
).nativeInput.input.infos,
).inputSnapshot.infos,
[
{
url: 'https://canary.copilotcontent.affine.pro/blob-1-0?sig=test',
mimeType: 'audio/opus',
index: 0,
},
{
url: 'https://canary.copilotcontent.affine.pro/blob-1-1?sig=test',
mimeType: 'audio/opus',
index: 1,
},
]
);
const messages = (
bridgeInputs[0] as {
step: {
messages: { content?: string; attachments?: unknown[] }[];
};
}
).step.messages;
t.false(messages[0].content?.includes('data:image/png'));
t.like(JSON.parse(messages[0].content ?? '{}'), {
infos: [{ mimeType: 'audio/opus', index: 0 }],
});
t.deepEqual(messages.at(-1)?.attachments, [
{
attachment: 'https://canary.copilotcontent.affine.pro/blob-1-0?sig=test',
mimeType: 'audio/opus',
},
]);
t.is(structuredCalls.length, 4);
const transcriptCalls = structuredCalls.filter(
call => call.options.builtInRouteId === 'Transcript audio'
);
t.is(transcriptCalls.length, 3);
t.true(transcriptCalls.every(call => call.slot === 'transcript.audio'));
t.deepEqual(
transcriptCalls.map(call => call.messages.at(-1)?.attachments),
[
[
{
attachment:
'https://canary.copilotcontent.affine.pro/blob-1-0?sig=test',
mimeType: 'audio/opus',
},
],
[
{
attachment:
'https://canary.copilotcontent.affine.pro/blob-1-1?sig=test',
mimeType: 'audio/opus',
},
],
[
{
attachment:
'https://canary.copilotcontent.affine.pro/blob-1-1?sig=test',
mimeType: 'audio/opus',
},
],
]
);
t.is(
structuredCalls.at(-1)?.messages.at(-1)?.content,
'00:00:17 A: Kickoff\n00:01:42 B: Follow-up'
);
t.like(completeDispatch.firstCall.args[3], {
status: 'ready',
errorCode: null,
@@ -592,7 +639,16 @@ test('transcriptTask runs native transcript recipe through action bridge when av
);
t.is(
completeDispatch.firstCall.args[3].protectedResult.normalizedTranscript,
'00:00:05 A: Kickoff'
'00:00:17 A: Kickoff\n00:01:42 B: Follow-up'
);
t.like(
completeDispatch.firstCall.args[3].protectedResult.normalizedSegments[1],
{
startSec: 102,
endSec: 342,
start: '00:01:42',
end: '00:05:42',
}
);
t.deepEqual(
completeDispatch.firstCall.args[3].protectedResult.infos,
@@ -626,9 +682,9 @@ test('transcriptTask fails task when native action bridge reports an error event
{} as never,
createTranscriptPromptService() as never,
{
runStream: (input: unknown) =>
runStream: (input: any) =>
(async function* () {
await buildNativeTranscriptResult(input, 'run-bridge');
await input.onRunCreated?.({ runId: 'run-bridge', attempt: 1 });
yield {
type: 'error' as const,
actionId: 'transcript.audio',
@@ -49,6 +49,15 @@ export type ActionRuntimeBridgeEvent = NativeActionEvent & {
runId: string;
};
export type ActionRuntimeBridgeOutput = {
result: unknown;
attachments?: unknown[];
};
export type ActionRuntimeBridgeExecutor = (
input: ActionRuntimeBridgeInput
) => Promise<ActionRuntimeBridgeOutput>;
export type ActionRuntimeBridgeRunContext = {
runId: string;
attempt: number;
@@ -173,7 +182,8 @@ export class ActionRuntimeBridge {
}
async *runStream(
input: ActionRuntimeBridgeInput
input: ActionRuntimeBridgeInput,
executor?: ActionRuntimeBridgeExecutor
): AsyncIterableIterator<ActionRuntimeBridgeEvent> {
const attempt = await this.resolveAttempt(input);
const run = await this.models.copilotActionRun.create({
@@ -206,8 +216,10 @@ export class ActionRuntimeBridge {
status: 'running',
};
yield { ...actionStart, runId: run.id };
const output = await this.execute(inputWithBillingUnit);
for (const artifact of output.attachments) {
const output = executor
? await executor(inputWithBillingUnit)
: await this.execute(inputWithBillingUnit);
for (const artifact of output.attachments ?? []) {
const attachment = input.persistAttachment
? await input.persistAttachment(artifact)
: artifact;
@@ -1,3 +1,4 @@
export const TRANSCRIPT_ACTION_ID = 'transcript.audio';
export const TRANSCRIPT_PROMPT_REF = 'Transcript audio structured';
export const TRANSCRIPT_PROMPT_REF = 'Transcript audio';
export const TRANSCRIPT_SUMMARY_PROMPT_REF = 'Summarize the meeting structured';
export const TRANSCRIPT_ACTION_VERSION = 'v1';
@@ -6,6 +6,14 @@ import type {
TranscriptionPayloadV2,
} from './types';
export type RawTranscriptSegment = {
sliceIndex: number;
speaker: string;
startSec: number;
endSec: number;
text: string;
};
function formatSection(title: string, items: string[]) {
if (!items.length) {
return [];
@@ -26,6 +34,58 @@ export function formatTranscriptTime(time: number) {
.join(':');
}
export function normalizeTranscriptSegments(
rawSegments: RawTranscriptSegment[]
): NormalizedTranscriptSegment[] {
const normalized: NormalizedTranscriptSegment[] = [];
const dedupe = new Set<string>();
const sorted = [...rawSegments].sort((left, right) => {
return (
left.startSec - right.startSec ||
left.endSec - right.endSec ||
left.sliceIndex - right.sliceIndex
);
});
for (const segment of sorted) {
const text = segment.text.trim();
if (!text) continue;
const startSec = Math.max(
normalized.at(-1)?.endSec ?? 0,
segment.startSec,
0
);
const endSec = Math.max(segment.endSec, startSec);
if (endSec <= startSec) continue;
const speaker = segment.speaker.trim() || 'Speaker';
const key = `${speaker}|${startSec}|${endSec}|${text}`;
if (dedupe.has(key)) continue;
dedupe.add(key);
normalized.push({
speaker,
startSec,
endSec,
start: formatTranscriptTime(startSec),
end: formatTranscriptTime(endSec),
text,
});
}
return normalized;
}
export function buildNormalizedTranscript(
segments: NormalizedTranscriptSegment[]
) {
return segments
.map(segment => `${segment.start} ${segment.speaker}: ${segment.text}`)
.join('\n')
.trim();
}
export function toLegacyTranscriptionSegments(
segments: NormalizedTranscriptSegment[]
): LegacyTranscriptionSegment[] {
@@ -1,7 +1,9 @@
import { z } from 'zod';
import { llmGetContractSchema } from '../../../native';
import { buildStructuredResponseFromSchemaJson } from '../runtime/contracts';
import {
buildStructuredResponseContract,
type RequiredStructuredOutputContract,
} from '../runtime/contracts';
// Owner: DB/job/API legacy compatibility and transcript projection.
// Native owns transcript domain result schemas; this file accepts historical
@@ -61,6 +63,15 @@ export const MeetingSummaryV2Schema = z.object({
blockers: z.array(z.string()),
});
export const TranscriptionResponseSchema = z.array(
z.object({
a: z.string().describe("speaker's name, for example A, B, C"),
s: z.number().describe('start time in seconds'),
e: z.number().describe('end time in seconds'),
t: z.string().describe('transcription text'),
})
);
export const TranscriptionSourceAudioSchema = z.object({
blobId: z.string().nullable().optional(),
mimeType: z.string().nullable().optional(),
@@ -103,8 +114,8 @@ export const TranscriptionSubmitInputSchema = TranscriptionPayloadV2Schema.pick(
}
);
function buildRequiredStructuredContract(schema: Record<string, unknown>) {
const contract = buildStructuredResponseFromSchemaJson(schema);
function buildRequiredStructuredContract(schema: z.ZodType) {
const contract = buildStructuredResponseContract(schema);
if (!contract.responseSchemaJson || !contract.schemaHash) {
throw new Error('Structured transcript contract is required');
}
@@ -112,11 +123,15 @@ function buildRequiredStructuredContract(schema: Record<string, unknown>) {
return {
responseSchemaJson: contract.responseSchemaJson,
schemaHash: contract.schemaHash,
};
} satisfies RequiredStructuredOutputContract;
}
export const TranscriptActionResultContract = buildRequiredStructuredContract(
llmGetContractSchema('transcriptGeneratedResult')
export const TranscriptionResponseContract = buildRequiredStructuredContract(
TranscriptionResponseSchema
);
export const MeetingSummaryV2Contract = buildRequiredStructuredContract(
MeetingSummaryV2Schema
);
type CanonicalTranscriptPayload = z.infer<typeof TranscriptionPayloadV2Schema>;
@@ -1,4 +1,5 @@
import { randomUUID } from 'node:crypto';
import { setTimeout } from 'node:timers/promises';
import { BadRequestException, Injectable } from '@nestjs/common';
import { AiJobStatus } from '@prisma/client';
@@ -16,27 +17,48 @@ import {
} from '../../../core/realtime';
import { Models } from '../../../models';
import { PromptService } from '../prompt';
import { ActionRuntimeBridge } from '../runtime/action-runtime-bridge';
import type {
CopilotStructuredOptions,
PromptMessage,
} from '../providers/types';
import {
ActionRuntimeBridge,
type ActionRuntimeBridgeInput,
} from '../runtime/action-runtime-bridge';
import { CapabilityRuntime } from '../runtime/capability-runtime';
import type { RequiredStructuredOutputContract } from '../runtime/contracts';
import { CopilotStorage } from '../storage';
import {
TRANSCRIPT_ACTION_ID,
TRANSCRIPT_ACTION_VERSION,
TRANSCRIPT_PROMPT_REF,
TRANSCRIPT_SUMMARY_PROMPT_REF,
} from './constants';
import { taskToJob, type TranscriptionJob } from './job';
import {
buildNormalizedTranscript,
normalizeTranscriptSegments,
type RawTranscriptSegment,
} from './projection';
import { CopilotTranscriptionRetryService } from './retry';
import {
TranscriptActionResultContract,
MeetingSummaryV2Contract,
MeetingSummaryV2Schema,
TranscriptionResponseContract,
TranscriptionResponseSchema,
TranscriptPayloadSchema,
} from './schema';
import type {
AudioBlobInfo,
AudioBlobInfos,
TranscriptionPayloadV2,
TranscriptionSubmitInput,
} from './types';
import { readStream } from './utils';
const TRANSCRIPT_SLICE_CONCURRENCY = 2;
const TRANSCRIPT_RETRY_DELAYS = [5_000, 15_000];
@Injectable()
export class CopilotTranscriptionService {
constructor(
@@ -140,35 +162,223 @@ export class CopilotTranscriptionService {
} satisfies TranscriptionPayloadV2;
}
private async buildTranscriptActionMessages(payload: TranscriptionPayloadV2) {
private async buildTranscriptSliceMessages(info: AudioBlobInfo) {
const prompt = await this.prompts.get(TRANSCRIPT_PROMPT_REF);
if (!prompt) {
throw new Error('Transcript action prompt not found');
throw new Error('Transcript prompt not found');
}
const metadata = {
sourceAudio: payload.sourceAudio ?? null,
quality: payload.quality ?? null,
sliceManifest: payload.sliceManifest ?? null,
infos:
payload.infos?.map(info => ({
mimeType: info.mimeType,
index: info.index ?? null,
})) ?? null,
};
const attachments = (payload.infos ?? []).map(info => ({
role: 'user' as const,
content: `Audio attachment ${info.index ?? 0}`,
attachments: [{ attachment: info.url, mimeType: info.mimeType }],
params: { mimetype: info.mimeType },
}));
return [
...this.prompts.finish(prompt, {
content: JSON.stringify(metadata),
}),
...attachments,
...this.prompts.finish(prompt, {}),
{
role: 'user' as const,
content:
'Transcribe this audio slice. Return start and end timestamps as elapsed seconds relative to this slice; never encode MM:SS as a number.',
attachments: [{ attachment: info.url, mimeType: info.mimeType }],
params: { mimetype: info.mimeType },
},
];
}
private async buildMeetingSummaryMessages(normalizedTranscript: string) {
const prompt = await this.prompts.get(TRANSCRIPT_SUMMARY_PROMPT_REF);
if (!prompt) {
throw new Error('Transcript summary prompt not found');
}
return this.prompts.finish(prompt, { content: normalizedTranscript });
}
private rebaseManifestlessSlices(
infos: AudioBlobInfos,
slices: RawTranscriptSegment[][]
) {
let accumulatedOffset = 0;
return slices
.map((segments, fallbackIndex) => ({
fallbackIndex,
sliceIndex: infos[fallbackIndex]?.index ?? fallbackIndex,
segments,
}))
.sort(
(left, right) =>
left.sliceIndex - right.sliceIndex ||
left.fallbackIndex - right.fallbackIndex
)
.flatMap(({ segments }) => {
const rebased = segments.map(segment => ({
...segment,
startSec: segment.startSec + accumulatedOffset,
endSec: segment.endSec + accumulatedOffset,
}));
accumulatedOffset += Math.max(
0,
...segments.map(segment => segment.endSec)
);
return rebased;
});
}
private async transcribeSlice(
input: ActionRuntimeBridgeInput,
info: AudioBlobInfo,
fallbackIndex: number,
offset: number,
durationSec?: number
): Promise<RawTranscriptSegment[]> {
const messages = await this.buildTranscriptSliceMessages(info);
const output = await this.generateStructuredValue(
input,
messages,
TRANSCRIPT_PROMPT_REF,
TranscriptionResponseContract,
'transcript.audio'
);
const sliceIndex = info.index ?? fallbackIndex;
const response = TranscriptionResponseSchema.parse(output.value);
const timestamps = response.flatMap(segment => [segment.s, segment.e]);
const maxTimestamp = Math.max(0, ...timestamps);
const mmssTimestamps = timestamps.map(timestamp => {
const minutes = Math.floor(timestamp / 100);
const seconds = timestamp - minutes * 100;
return seconds < 60 ? minutes * 60 + seconds : null;
});
const usesMmss =
durationSec !== undefined &&
maxTimestamp > durationSec + 5 &&
mmssTimestamps.every(
timestamp => timestamp !== null && timestamp <= durationSec + 5
);
if (
durationSec !== undefined &&
maxTimestamp > durationSec + 5 &&
!usesMmss
) {
throw new Error('Transcript slice timestamps exceed audio duration');
}
return response.map((segment, index) => {
const startSec = usesMmss
? (mmssTimestamps[index * 2] ?? segment.s)
: segment.s;
const endSec = usesMmss
? (mmssTimestamps[index * 2 + 1] ?? segment.e)
: segment.e;
return {
sliceIndex,
speaker: segment.a,
startSec: Math.min(startSec, durationSec ?? startSec) + offset,
endSec: Math.min(endSec, durationSec ?? endSec) + offset,
text: segment.t,
};
});
}
private async generateStructuredValue(
input: ActionRuntimeBridgeInput,
messages: PromptMessage[],
builtInRouteId: string,
contract: RequiredStructuredOutputContract,
slot = 'prompt.structured'
) {
for (let attempt = 0; ; attempt++) {
try {
return await this.runtime.generateStructuredValue(
{
profileId: input.step.profileId,
modelId: input.step.modelId,
},
messages,
{
...(input.step.options as CopilotStructuredOptions | undefined),
builtInRouteId,
},
contract,
undefined,
slot
);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
const retryable =
/upstream returned status (?:429|5\d\d)|RESOURCE_EXHAUSTED|UNAVAILABLE|llm_timeout|timed? out|fetch failed/i.test(
message
);
const delay = TRANSCRIPT_RETRY_DELAYS[attempt];
if (!retryable || delay === undefined || input.signal?.aborted) {
throw error;
}
await setTimeout(delay, undefined, { signal: input.signal });
}
}
}
private async executeTranscriptAction(
input: ActionRuntimeBridgeInput,
payload: TranscriptionPayloadV2
) {
const infos = payload.infos ?? [];
const slices: RawTranscriptSegment[][] = [];
const manifestProvided = !!payload.sliceManifest?.length;
for (
let batchStart = 0;
batchStart < infos.length;
batchStart += TRANSCRIPT_SLICE_CONCURRENCY
) {
const batch = infos.slice(
batchStart,
batchStart + TRANSCRIPT_SLICE_CONCURRENCY
);
await Promise.all(
batch.map(async (info, batchIndex) => {
const index = batchStart + batchIndex;
const manifestItem = manifestProvided
? payload.sliceManifest?.find(
item => item.index === (info.index ?? index)
)
: undefined;
slices[index] = await this.transcribeSlice(
input,
info,
index,
manifestItem?.startSec ?? 0,
manifestItem?.durationSec
);
})
);
}
const rawSegments = manifestProvided
? slices.flat()
: this.rebaseManifestlessSlices(infos, slices);
const normalizedSegments = normalizeTranscriptSegments(rawSegments);
const normalizedTranscript = buildNormalizedTranscript(normalizedSegments);
let summaryJson = null;
if (normalizedTranscript) {
const messages =
await this.buildMeetingSummaryMessages(normalizedTranscript);
const output = await this.generateStructuredValue(
input,
messages,
TRANSCRIPT_SUMMARY_PROMPT_REF,
MeetingSummaryV2Contract
);
summaryJson = MeetingSummaryV2Schema.parse(output.value);
}
return {
result: {
sourceAudio: payload.sourceAudio,
quality: payload.quality,
sliceManifest: payload.sliceManifest,
normalizedSegments,
normalizedTranscript,
summaryJson,
version: 'transcript-result-v1',
} satisfies TranscriptionPayloadV2,
};
}
async submitTask(
userId: string,
workspaceId: string,
@@ -306,46 +516,47 @@ export class CopilotTranscriptionService {
task.workspaceId,
payload
);
const messages = await this.buildTranscriptActionMessages(runtimePayload);
for await (const event of this.actionBridge.runStream({
userId: task.userId,
workspaceId: task.workspaceId,
actionId: TRANSCRIPT_ACTION_ID,
actionVersion: TRANSCRIPT_ACTION_VERSION,
retryOf: retryOf ?? null,
inputSnapshot: runtimePayload,
onRunCreated: async ({ runId }) => {
const attached =
await this.models.copilotTranscriptTask.attachActionRun(
for await (const event of this.actionBridge.runStream(
{
userId: task.userId,
workspaceId: task.workspaceId,
actionId: TRANSCRIPT_ACTION_ID,
actionVersion: TRANSCRIPT_ACTION_VERSION,
retryOf: retryOf ?? null,
inputSnapshot: runtimePayload,
onRunCreated: async ({ runId }) => {
const attached =
await this.models.copilotTranscriptTask.attachActionRun(
taskId,
generation,
actionRunId,
runId
);
if (!attached) {
throw new Error('stale transcript dispatch generation');
}
actionRunId = runId;
this.publishTaskChanged(
task.workspaceId,
taskId,
generation,
actionRunId,
runId
AiJobStatus.running
);
if (!attached) {
throw new Error('stale transcript dispatch generation');
}
actionRunId = runId;
this.publishTaskChanged(
task.workspaceId,
taskId,
AiJobStatus.running
);
},
step: {
slot: 'transcript.audio',
builtInRouteId: TRANSCRIPT_PROMPT_REF,
messages,
options: {
user: task.userId,
workspace: task.workspaceId,
taskId,
billingUnitId: taskId,
featureKind: 'transcript',
},
responseContract: TranscriptActionResultContract,
step: {
slot: 'transcript.audio',
builtInRouteId: TRANSCRIPT_PROMPT_REF,
messages: [],
options: {
user: task.userId,
workspace: task.workspaceId,
taskId,
billingUnitId: taskId,
featureKind: 'transcript',
},
},
},
})) {
input => this.executeTranscriptAction(input, runtimePayload)
)) {
if (event.type === 'error' || event.status === 'failed') {
bridgeFailed = true;
bridgeError = event.errorMessage ?? event.errorCode ?? bridgeError;