feat(server): support sliced audio (#11562)

fix AF-2479
This commit is contained in:
darkskygit
2025-04-09 08:21:19 +00:00
parent 06497773a7
commit 15abb78a6b
15 changed files with 239 additions and 75 deletions

View File

@@ -88,3 +88,47 @@ Generated by [AVA](https://avajs.dev).
], ],
}, },
] ]
> should submit audio transcription job
[
{
status: 'running',
},
]
> should claim audio transcription job
[
{
status: 'claimed',
summary: '[{"a":"A","s":30,"e":45,"t":"Hello, everyone."},{"a":"B","s":46,"e":70,"t":"Hi, thank you for joining the meeting today."}]',
title: '[{"a":"A","s":30,"e":45,"t":"Hello, everyone."},{"a":"B","s":46,"e":70,"t":"Hi, thank you for joining the meeting today."}]',
transcription: [
{
end: '00:00:45',
speaker: 'A',
start: '00:00:30',
transcription: 'Hello, everyone.',
},
{
end: '00:01:10',
speaker: 'B',
start: '00:00:46',
transcription: 'Hi, thank you for joining the meeting today.',
},
{
end: '00:10:45',
speaker: 'A',
start: '00:10:30',
transcription: 'Hello, everyone.',
},
{
end: '00:11:10',
speaker: 'B',
start: '00:10:46',
transcription: 'Hi, thank you for joining the meeting today.',
},
],
},
]

View File

@@ -883,13 +883,10 @@ test('should be able to transcript', async t => {
'[{"a":"A","s":30,"e":45,"t":"Hello, everyone."},{"a":"B","s":46,"e":70,"t":"Hi, thank you for joining the meeting today."}]' '[{"a":"A","s":30,"e":45,"t":"Hello, everyone."},{"a":"B","s":46,"e":70,"t":"Hi, thank you for joining the meeting today."}]'
); );
const job = await submitAudioTranscription( {
app, const job = await submitAudioTranscription(app, workspaceId, '1', '1.mp3', [
workspaceId, Buffer.from([1, 1]),
'blobId', ]);
'test.mp3',
Buffer.from([1, 1])
);
t.snapshot( t.snapshot(
cleanObject([job], ['id']), cleanObject([job], ['id']),
'should submit audio transcription job' 'should submit audio transcription job'
@@ -898,11 +895,13 @@ test('should be able to transcript', async t => {
// wait for processing // wait for processing
{ {
let { status } = (await audioTranscription(app, workspaceId, job.id)) || {}; let { status } =
(await audioTranscription(app, workspaceId, job.id)) || {};
while (status !== 'finished') { while (status !== 'finished') {
await new Promise(resolve => setTimeout(resolve, 1000)); await new Promise(resolve => setTimeout(resolve, 1000));
({ status } = (await audioTranscription(app, workspaceId, job.id)) || {}); ({ status } =
(await audioTranscription(app, workspaceId, job.id)) || {});
} }
} }
@@ -913,4 +912,38 @@ test('should be able to transcript', async t => {
'should claim audio transcription job' 'should claim audio transcription job'
); );
} }
}
{
// sliced audio
const job = await submitAudioTranscription(app, workspaceId, '2', '2.mp3', [
Buffer.from([1, 1]),
Buffer.from([1, 2]),
]);
t.snapshot(
cleanObject([job], ['id']),
'should submit audio transcription job'
);
t.truthy(job.id, 'should have job id');
// wait for processing
{
let { status } =
(await audioTranscription(app, workspaceId, job.id)) || {};
while (status !== 'finished') {
await new Promise(resolve => setTimeout(resolve, 1000));
({ status } =
(await audioTranscription(app, workspaceId, job.id)) || {});
}
}
{
const result = await claimAudioTranscription(app, job.id);
t.snapshot(
cleanObject([result], ['id']),
'should claim audio transcription job'
);
}
}
}); });

View File

@@ -335,17 +335,17 @@ export async function submitAudioTranscription(
workspaceId: string, workspaceId: string,
blobId: string, blobId: string,
fileName: string, fileName: string,
content: Buffer content: Buffer[]
): Promise<{ id: string; status: string }> { ): Promise<{ id: string; status: string }> {
const res = await app let resp = app
.POST('/graphql') .POST('/graphql')
.set({ 'x-request-id': 'test', 'x-operation-name': 'test' }) .set({ 'x-request-id': 'test', 'x-operation-name': 'test' })
.field( .field(
'operations', 'operations',
JSON.stringify({ JSON.stringify({
query: ` query: `
mutation submitAudioTranscription($blob: Upload!, $blobId: String!, $workspaceId: String!) { mutation submitAudioTranscription($blob: Upload, $blobs: [Upload!], $blobId: String!, $workspaceId: String!) {
submitAudioTranscription(blob: $blob, blobId: $blobId, workspaceId: $workspaceId) { submitAudioTranscription(blob: $blob, blobs: $blobs, blobId: $blobId, workspaceId: $workspaceId) {
id id
status status
} }
@@ -353,17 +353,29 @@ export async function submitAudioTranscription(
`, `,
variables: { variables: {
blob: null, blob: null,
blobs: [],
blobId, blobId,
workspaceId, workspaceId,
}, },
}) })
) )
.field('map', JSON.stringify({ '0': ['variables.blob'] })) .field(
.attach('0', content, { 'map',
JSON.stringify(
Array.from<any>({ length: content.length }).reduce((acc, _, idx) => {
acc[idx.toString()] = [`variables.blobs.${idx}`];
return acc;
}, {})
)
);
for (const [idx, buffer] of content.entries()) {
resp = resp.attach(idx.toString(), buffer, {
filename: fileName, filename: fileName,
contentType: 'application/octet-stream', contentType: 'application/octet-stream',
}) });
.expect(200); }
const res = await resp.expect(200);
return res.body.data.submitAudioTranscription; return res.body.data.submitAudioTranscription;
} }

View File

@@ -709,6 +709,10 @@ export const USER_FRIENDLY_ERRORS = {
type: 'bad_request', type: 'bad_request',
message: () => `Transcription job not found.`, message: () => `Transcription job not found.`,
}, },
copilot_transcription_audio_not_provided: {
type: 'bad_request',
message: () => `Audio not provided.`,
},
// Quota & Limit errors // Quota & Limit errors
blob_quota_exceeded: { blob_quota_exceeded: {

View File

@@ -771,6 +771,12 @@ export class CopilotTranscriptionJobNotFound extends UserFriendlyError {
} }
} }
export class CopilotTranscriptionAudioNotProvided extends UserFriendlyError {
constructor(message?: string) {
super('bad_request', 'copilot_transcription_audio_not_provided', message);
}
}
export class BlobQuotaExceeded extends UserFriendlyError { export class BlobQuotaExceeded extends UserFriendlyError {
constructor(message?: string) { constructor(message?: string) {
super('quota_exceeded', 'blob_quota_exceeded', message); super('quota_exceeded', 'blob_quota_exceeded', message);
@@ -1027,6 +1033,7 @@ export enum ErrorNames {
COPILOT_EMBEDDING_UNAVAILABLE, COPILOT_EMBEDDING_UNAVAILABLE,
COPILOT_TRANSCRIPTION_JOB_EXISTS, COPILOT_TRANSCRIPTION_JOB_EXISTS,
COPILOT_TRANSCRIPTION_JOB_NOT_FOUND, COPILOT_TRANSCRIPTION_JOB_NOT_FOUND,
COPILOT_TRANSCRIPTION_AUDIO_NOT_PROVIDED,
BLOB_QUOTA_EXCEEDED, BLOB_QUOTA_EXCEEDED,
STORAGE_QUOTA_EXCEEDED, STORAGE_QUOTA_EXCEEDED,
MEMBER_QUOTA_EXCEEDED, MEMBER_QUOTA_EXCEEDED,

View File

@@ -14,6 +14,7 @@ import { AiJobStatus } from '@prisma/client';
import GraphQLUpload from 'graphql-upload/GraphQLUpload.mjs'; import GraphQLUpload from 'graphql-upload/GraphQLUpload.mjs';
import { import {
CopilotTranscriptionAudioNotProvided,
CopilotTranscriptionJobNotFound, CopilotTranscriptionJobNotFound,
type FileUpload, type FileUpload,
} from '../../../base'; } from '../../../base';
@@ -100,20 +101,27 @@ export class CopilotTranscriptionResolver {
@CurrentUser() user: CurrentUser, @CurrentUser() user: CurrentUser,
@Args('workspaceId') workspaceId: string, @Args('workspaceId') workspaceId: string,
@Args('blobId') blobId: string, @Args('blobId') blobId: string,
@Args({ name: 'blob', type: () => GraphQLUpload }) @Args({ name: 'blob', type: () => GraphQLUpload, nullable: true })
blob: FileUpload blob: FileUpload | null,
@Args({ name: 'blobs', type: () => [GraphQLUpload], nullable: true })
blobs: FileUpload[] | null
): Promise<TranscriptionResultType | null> { ): Promise<TranscriptionResultType | null> {
await this.ac await this.ac
.user(user.id) .user(user.id)
.workspace(workspaceId) .workspace(workspaceId)
.allowLocal() .allowLocal()
.assert('Workspace.Copilot'); .assert('Workspace.Copilot');
// merge blobs
const allBlobs = blob ? [blob, ...(blobs || [])].filter(v => !!v) : blobs;
if (!allBlobs || allBlobs.length === 0) {
throw new CopilotTranscriptionAudioNotProvided();
}
const jobResult = await this.service.submitTranscriptionJob( const jobResult = await this.service.submitTranscriptionJob(
user.id, user.id,
workspaceId, workspaceId,
blobId, blobId,
blob await Promise.all(allBlobs)
); );
return this.handleJobResult(jobResult); return this.handleJobResult(jobResult);
@@ -136,14 +144,13 @@ export class CopilotTranscriptionResolver {
workspaceId, workspaceId,
jobId jobId
); );
if (!job || !job.url || !job.mimeType) { if (!job || !job.infos) {
throw new CopilotTranscriptionJobNotFound(); throw new CopilotTranscriptionJobNotFound();
} }
const jobResult = await this.service.executeTranscriptionJob( const jobResult = await this.service.executeTranscriptionJob(
job.id, job.id,
job.url, job.infos
job.mimeType
); );
return this.handleJobResult(jobResult); return this.handleJobResult(jobResult);

View File

@@ -23,6 +23,7 @@ import {
} from '../providers'; } from '../providers';
import { CopilotStorage } from '../storage'; import { CopilotStorage } from '../storage';
import { import {
AudioBlobInfos,
TranscriptionPayload, TranscriptionPayload,
TranscriptionResponseSchema, TranscriptionResponseSchema,
TranscriptPayloadSchema, TranscriptPayloadSchema,
@@ -32,8 +33,7 @@ import { readStream } from './utils';
export type TranscriptionJob = { export type TranscriptionJob = {
id: string; id: string;
status: AiJobStatus; status: AiJobStatus;
url?: string; infos?: AudioBlobInfos;
mimeType?: string;
transcription?: TranscriptionPayload; transcription?: TranscriptionPayload;
}; };
@@ -52,7 +52,7 @@ export class CopilotTranscriptionService {
userId: string, userId: string,
workspaceId: string, workspaceId: string,
blobId: string, blobId: string,
blob: FileUpload blobs: FileUpload[]
): Promise<TranscriptionJob> { ): Promise<TranscriptionJob> {
if (await this.models.copilotJob.has(userId, workspaceId, blobId)) { if (await this.models.copilotJob.has(userId, workspaceId, blobId)) {
throw new CopilotTranscriptionJobExists(); throw new CopilotTranscriptionJobExists();
@@ -65,21 +65,24 @@ export class CopilotTranscriptionService {
type: AiJobType.transcription, type: AiJobType.transcription,
}); });
const infos: AudioBlobInfos = [];
for (const blob of blobs) {
const buffer = await readStream(blob.createReadStream()); const buffer = await readStream(blob.createReadStream());
const url = await this.storage.put(userId, workspaceId, blobId, buffer); const url = await this.storage.put(userId, workspaceId, blobId, buffer);
infos.push({ url, mimeType: blob.mimetype });
}
return await this.executeTranscriptionJob(jobId, url, blob.mimetype); return await this.executeTranscriptionJob(jobId, infos);
} }
async executeTranscriptionJob( async executeTranscriptionJob(
jobId: string, jobId: string,
url: string, infos: AudioBlobInfos
mimeType: string
): Promise<TranscriptionJob> { ): Promise<TranscriptionJob> {
const status = AiJobStatus.running; const status = AiJobStatus.running;
const success = await this.models.copilotJob.update(jobId, { const success = await this.models.copilotJob.update(jobId, {
status, status,
payload: { url, mimeType }, payload: { infos },
}); });
if (!success) { if (!success) {
@@ -88,8 +91,7 @@ export class CopilotTranscriptionService {
await this.job.add('copilot.transcript.submit', { await this.job.add('copilot.transcript.submit', {
jobId, jobId,
url, infos,
mimeType,
}); });
return { id: jobId, status }; return { id: jobId, status };
@@ -132,8 +134,13 @@ export class CopilotTranscriptionService {
const payload = TranscriptPayloadSchema.safeParse(job.payload); const payload = TranscriptPayloadSchema.safeParse(job.payload);
if (payload.success) { if (payload.success) {
ret.url = payload.data.url || undefined; let { url, mimeType, infos } = payload.data;
ret.mimeType = payload.data.mimeType || undefined; infos = infos || [];
if (url && mimeType) {
infos.push({ url, mimeType });
}
ret.infos = this.mergeInfos(infos, url, mimeType);
if (job.status === AiJobStatus.claimed) { if (job.status === AiJobStatus.claimed) {
ret.transcription = payload.data; ret.transcription = payload.data;
} }
@@ -173,7 +180,24 @@ export class CopilotTranscriptionService {
); );
} }
private convertTime(time: number) { // TODO(@darkskygit): remove after old server down
private mergeInfos(
infos?: AudioBlobInfos | null,
url?: string | null,
mimeType?: string | null
) {
if (url && mimeType) {
if (infos) {
infos.push({ url, mimeType });
} else {
infos = [{ url, mimeType }];
}
}
return infos || [];
}
private convertTime(time: number, offset = 0) {
time = time + offset;
const minutes = Math.floor(time / 60); const minutes = Math.floor(time / 60);
const seconds = Math.floor(time % 60); const seconds = Math.floor(time % 60);
const hours = Math.floor(minutes / 60); const hours = Math.floor(minutes / 60);
@@ -186,10 +210,15 @@ export class CopilotTranscriptionService {
@OnJob('copilot.transcript.submit') @OnJob('copilot.transcript.submit')
async transcriptAudio({ async transcriptAudio({
jobId, jobId,
infos,
// @deprecated
url, url,
mimeType, mimeType,
}: Jobs['copilot.transcript.submit']) { }: Jobs['copilot.transcript.submit']) {
try { try {
const blobInfos = this.mergeInfos(infos, url, mimeType);
const transcriptions = [];
for (const [idx, { url, mimeType }] of blobInfos.entries()) {
const result = await this.chatWithPrompt( const result = await this.chatWithPrompt(
'Transcript audio', 'Transcript audio',
{ {
@@ -199,16 +228,20 @@ export class CopilotTranscriptionService {
TranscriptionResponseSchema TranscriptionResponseSchema
); );
const offset = idx * 10 * 60;
const transcription = TranscriptionResponseSchema.parse( const transcription = TranscriptionResponseSchema.parse(
JSON.parse(result) JSON.parse(result)
).map(t => ({ ).map(t => ({
speaker: t.a, speaker: t.a,
start: this.convertTime(t.s), start: this.convertTime(t.s, offset),
end: this.convertTime(t.e), end: this.convertTime(t.e, offset),
transcription: t.t, transcription: t.t,
})); }));
transcriptions.push(transcription);
}
await this.models.copilotJob.update(jobId, { await this.models.copilotJob.update(jobId, {
payload: { transcription }, payload: { transcription: transcriptions.flat() },
}); });
await this.job.add('copilot.transcript.summary.submit', { await this.job.add('copilot.transcript.summary.submit', {

View File

@@ -20,9 +20,17 @@ const TranscriptionItemSchema = z.object({
export const TranscriptionSchema = z.array(TranscriptionItemSchema); export const TranscriptionSchema = z.array(TranscriptionItemSchema);
export const AudioBlobInfosSchema = z
.object({
url: z.string(),
mimeType: z.string(),
})
.array();
export const TranscriptPayloadSchema = z.object({ export const TranscriptPayloadSchema = z.object({
url: z.string().nullable().optional(), url: z.string().nullable().optional(),
mimeType: z.string().nullable().optional(), mimeType: z.string().nullable().optional(),
infos: AudioBlobInfosSchema.nullable().optional(),
title: z.string().nullable().optional(), title: z.string().nullable().optional(),
summary: z.string().nullable().optional(), summary: z.string().nullable().optional(),
transcription: TranscriptionSchema.nullable().optional(), transcription: TranscriptionSchema.nullable().optional(),
@@ -32,6 +40,8 @@ export type TranscriptionItem = z.infer<typeof TranscriptionItemSchema>;
export type Transcription = z.infer<typeof TranscriptionSchema>; export type Transcription = z.infer<typeof TranscriptionSchema>;
export type TranscriptionPayload = z.infer<typeof TranscriptPayloadSchema>; export type TranscriptionPayload = z.infer<typeof TranscriptPayloadSchema>;
export type AudioBlobInfos = z.infer<typeof AudioBlobInfosSchema>;
declare global { declare global {
interface Events { interface Events {
'workspace.file.transcript.finished': { 'workspace.file.transcript.finished': {
@@ -44,8 +54,11 @@ declare global {
interface Jobs { interface Jobs {
'copilot.transcript.submit': { 'copilot.transcript.submit': {
jobId: string; jobId: string;
url: string; infos?: AudioBlobInfos;
mimeType: string; /// @deprecated use `infos` instead
url?: string;
/// @deprecated use `infos` instead
mimeType?: string;
}; };
'copilot.transcript.summary.submit': { 'copilot.transcript.summary.submit': {
jobId: string; jobId: string;

View File

@@ -435,6 +435,7 @@ enum ErrorNames {
COPILOT_QUOTA_EXCEEDED COPILOT_QUOTA_EXCEEDED
COPILOT_SESSION_DELETED COPILOT_SESSION_DELETED
COPILOT_SESSION_NOT_FOUND COPILOT_SESSION_NOT_FOUND
COPILOT_TRANSCRIPTION_AUDIO_NOT_PROVIDED
COPILOT_TRANSCRIPTION_JOB_EXISTS COPILOT_TRANSCRIPTION_JOB_EXISTS
COPILOT_TRANSCRIPTION_JOB_NOT_FOUND COPILOT_TRANSCRIPTION_JOB_NOT_FOUND
CUSTOMER_PORTAL_CREATE_FAILED CUSTOMER_PORTAL_CREATE_FAILED
@@ -1004,7 +1005,7 @@ type Mutation {
sendVerifyChangeEmail(callbackUrl: String!, email: String!, token: String!): Boolean! sendVerifyChangeEmail(callbackUrl: String!, email: String!, token: String!): Boolean!
sendVerifyEmail(callbackUrl: String!): Boolean! sendVerifyEmail(callbackUrl: String!): Boolean!
setBlob(blob: Upload!, workspaceId: String!): String! setBlob(blob: Upload!, workspaceId: String!): String!
submitAudioTranscription(blob: Upload!, blobId: String!, workspaceId: String!): TranscriptionResultType submitAudioTranscription(blob: Upload, blobId: String!, blobs: [Upload!], workspaceId: String!): TranscriptionResultType
"""update app configuration""" """update app configuration"""
updateAppConfig(updates: [UpdateAppConfigInput!]!): JSONObject! updateAppConfig(updates: [UpdateAppConfigInput!]!): JSONObject!

View File

@@ -1,5 +1,5 @@
mutation submitAudioTranscription($workspaceId: String!, $blobId: String!, $blob: Upload!) { mutation submitAudioTranscription($workspaceId: String!, $blobId: String!, $blob: Upload, $blobs: [Upload!]) {
submitAudioTranscription(blob: $blob, blobId: $blobId, workspaceId: $workspaceId) { submitAudioTranscription(blob: $blob, blobs: $blobs, blobId: $blobId, workspaceId: $workspaceId) {
id id
status status
} }

View File

@@ -601,9 +601,10 @@ export const getCopilotHistoriesQuery = {
export const submitAudioTranscriptionMutation = { export const submitAudioTranscriptionMutation = {
id: 'submitAudioTranscriptionMutation' as const, id: 'submitAudioTranscriptionMutation' as const,
op: 'submitAudioTranscription', op: 'submitAudioTranscription',
query: `mutation submitAudioTranscription($workspaceId: String!, $blobId: String!, $blob: Upload!) { query: `mutation submitAudioTranscription($workspaceId: String!, $blobId: String!, $blob: Upload, $blobs: [Upload!]) {
submitAudioTranscription( submitAudioTranscription(
blob: $blob blob: $blob
blobs: $blobs
blobId: $blobId blobId: $blobId
workspaceId: $workspaceId workspaceId: $workspaceId
) { ) {
@@ -611,7 +612,6 @@ export const submitAudioTranscriptionMutation = {
status status
} }
}`, }`,
file: true,
}; };
export const claimAudioTranscriptionMutation = { export const claimAudioTranscriptionMutation = {

View File

@@ -580,6 +580,7 @@ export enum ErrorNames {
COPILOT_QUOTA_EXCEEDED = 'COPILOT_QUOTA_EXCEEDED', COPILOT_QUOTA_EXCEEDED = 'COPILOT_QUOTA_EXCEEDED',
COPILOT_SESSION_DELETED = 'COPILOT_SESSION_DELETED', COPILOT_SESSION_DELETED = 'COPILOT_SESSION_DELETED',
COPILOT_SESSION_NOT_FOUND = 'COPILOT_SESSION_NOT_FOUND', COPILOT_SESSION_NOT_FOUND = 'COPILOT_SESSION_NOT_FOUND',
COPILOT_TRANSCRIPTION_AUDIO_NOT_PROVIDED = 'COPILOT_TRANSCRIPTION_AUDIO_NOT_PROVIDED',
COPILOT_TRANSCRIPTION_JOB_EXISTS = 'COPILOT_TRANSCRIPTION_JOB_EXISTS', COPILOT_TRANSCRIPTION_JOB_EXISTS = 'COPILOT_TRANSCRIPTION_JOB_EXISTS',
COPILOT_TRANSCRIPTION_JOB_NOT_FOUND = 'COPILOT_TRANSCRIPTION_JOB_NOT_FOUND', COPILOT_TRANSCRIPTION_JOB_NOT_FOUND = 'COPILOT_TRANSCRIPTION_JOB_NOT_FOUND',
CUSTOMER_PORTAL_CREATE_FAILED = 'CUSTOMER_PORTAL_CREATE_FAILED', CUSTOMER_PORTAL_CREATE_FAILED = 'CUSTOMER_PORTAL_CREATE_FAILED',
@@ -1432,8 +1433,9 @@ export interface MutationSetBlobArgs {
} }
export interface MutationSubmitAudioTranscriptionArgs { export interface MutationSubmitAudioTranscriptionArgs {
blob: Scalars['Upload']['input']; blob?: InputMaybe<Scalars['Upload']['input']>;
blobId: Scalars['String']['input']; blobId: Scalars['String']['input'];
blobs?: InputMaybe<Array<Scalars['Upload']['input']>>;
workspaceId: Scalars['String']['input']; workspaceId: Scalars['String']['input'];
} }
@@ -2991,7 +2993,10 @@ export type GetCopilotHistoriesQuery = {
export type SubmitAudioTranscriptionMutationVariables = Exact<{ export type SubmitAudioTranscriptionMutationVariables = Exact<{
workspaceId: Scalars['String']['input']; workspaceId: Scalars['String']['input'];
blobId: Scalars['String']['input']; blobId: Scalars['String']['input'];
blob: Scalars['Upload']['input']; blob?: InputMaybe<Scalars['Upload']['input']>;
blobs?: InputMaybe<
Array<Scalars['Upload']['input']> | Scalars['Upload']['input']
>;
}>; }>;
export type SubmitAudioTranscriptionMutation = { export type SubmitAudioTranscriptionMutation = {

View File

@@ -8127,6 +8127,10 @@ export function useAFFiNEI18N(): {
* `Transcription job not found.` * `Transcription job not found.`
*/ */
["error.COPILOT_TRANSCRIPTION_JOB_NOT_FOUND"](): string; ["error.COPILOT_TRANSCRIPTION_JOB_NOT_FOUND"](): string;
/**
* `Audio not provided.`
*/
["error.COPILOT_TRANSCRIPTION_AUDIO_NOT_PROVIDED"](): string;
/** /**
* `You have exceeded your blob size quota.` * `You have exceeded your blob size quota.`
*/ */

View File

@@ -2010,6 +2010,7 @@
"error.COPILOT_EMBEDDING_UNAVAILABLE": "Embedding feature not available, you may need to install pgvector extension to your database", "error.COPILOT_EMBEDDING_UNAVAILABLE": "Embedding feature not available, you may need to install pgvector extension to your database",
"error.COPILOT_TRANSCRIPTION_JOB_EXISTS": "Transcription job already exists", "error.COPILOT_TRANSCRIPTION_JOB_EXISTS": "Transcription job already exists",
"error.COPILOT_TRANSCRIPTION_JOB_NOT_FOUND": "Transcription job not found.", "error.COPILOT_TRANSCRIPTION_JOB_NOT_FOUND": "Transcription job not found.",
"error.COPILOT_TRANSCRIPTION_AUDIO_NOT_PROVIDED": "Audio not provided.",
"error.BLOB_QUOTA_EXCEEDED": "You have exceeded your blob size quota.", "error.BLOB_QUOTA_EXCEEDED": "You have exceeded your blob size quota.",
"error.STORAGE_QUOTA_EXCEEDED": "You have exceeded your storage quota.", "error.STORAGE_QUOTA_EXCEEDED": "You have exceeded your storage quota.",
"error.MEMBER_QUOTA_EXCEEDED": "You have exceeded your workspace member quota.", "error.MEMBER_QUOTA_EXCEEDED": "You have exceeded your workspace member quota.",