feat(server): retry transcript job (#11414)

This commit is contained in:
darkskygit
2025-04-02 12:02:07 +00:00
parent 3b13affa58
commit 501b5f6a97
11 changed files with 125 additions and 10 deletions

View File

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

View File

@@ -765,6 +765,12 @@ export class CopilotTranscriptionJobExists extends UserFriendlyError {
}
}
export class CopilotTranscriptionJobNotFound extends UserFriendlyError {
constructor(message?: string) {
super('bad_request', 'copilot_transcription_job_not_found', message);
}
}
export class BlobQuotaExceeded extends UserFriendlyError {
constructor(message?: string) {
super('quota_exceeded', 'blob_quota_exceeded', message);
@@ -1020,6 +1026,7 @@ export enum ErrorNames {
COPILOT_FAILED_TO_MATCH_CONTEXT,
COPILOT_EMBEDDING_UNAVAILABLE,
COPILOT_TRANSCRIPTION_JOB_EXISTS,
COPILOT_TRANSCRIPTION_JOB_NOT_FOUND,
BLOB_QUOTA_EXCEEDED,
STORAGE_QUOTA_EXCEEDED,
MEMBER_QUOTA_EXCEEDED,

View File

@@ -13,7 +13,10 @@ import {
import { AiJobStatus } from '@prisma/client';
import GraphQLUpload from 'graphql-upload/GraphQLUpload.mjs';
import type { FileUpload } from '../../../base';
import {
CopilotTranscriptionJobNotFound,
type FileUpload,
} from '../../../base';
import { CurrentUser } from '../../../core/auth';
import { AccessController } from '../../../core/permission';
import { CopilotType } from '../resolver';
@@ -106,14 +109,44 @@ export class CopilotTranscriptionResolver {
.allowLocal()
.assert('Workspace.Copilot');
const job = await this.service.submitTranscriptionJob(
const jobResult = await this.service.submitTranscriptionJob(
user.id,
workspaceId,
blobId,
blob
);
return this.handleJobResult(job);
return this.handleJobResult(jobResult);
}
@Mutation(() => TranscriptionResultType, { nullable: true })
async retryAudioTranscription(
@CurrentUser() user: CurrentUser,
@Args('workspaceId') workspaceId: string,
@Args('jobId') jobId: string
): Promise<TranscriptionResultType | null> {
await this.ac
.user(user.id)
.workspace(workspaceId)
.allowLocal()
.assert('Workspace.Copilot');
const job = await this.service.queryTranscriptionJob(
user.id,
workspaceId,
jobId
);
if (!job || !job.url || !job.mimeType) {
throw new CopilotTranscriptionJobNotFound();
}
const jobResult = await this.service.executeTranscriptionJob(
job.id,
job.url,
job.mimeType
);
return this.handleJobResult(jobResult);
}
@Mutation(() => TranscriptionResultType, { nullable: true })

View File

@@ -5,6 +5,7 @@ import { ZodType } from 'zod';
import {
CopilotPromptNotFound,
CopilotTranscriptionJobExists,
CopilotTranscriptionJobNotFound,
EventBus,
type FileUpload,
JobQueue,
@@ -31,6 +32,8 @@ import { readStream } from './utils';
export type TranscriptionJob = {
id: string;
status: AiJobStatus;
url?: string;
mimeType?: string;
transcription?: TranscriptionPayload;
};
@@ -55,7 +58,7 @@ export class CopilotTranscriptionService {
throw new CopilotTranscriptionJobExists();
}
const { id: jobId, status } = await this.models.copilotJob.create({
const { id: jobId } = await this.models.copilotJob.create({
workspaceId,
blobId,
createdBy: userId,
@@ -65,14 +68,28 @@ export class CopilotTranscriptionService {
const buffer = await readStream(blob.createReadStream());
const url = await this.storage.put(userId, workspaceId, blobId, buffer);
await this.models.copilotJob.update(jobId, {
status: AiJobStatus.running,
return await this.executeTranscriptionJob(jobId, url, blob.mimetype);
}
async executeTranscriptionJob(
jobId: string,
url: string,
mimeType: string
): Promise<TranscriptionJob> {
const status = AiJobStatus.running;
const success = await this.models.copilotJob.update(jobId, {
status,
payload: { url, mimeType },
});
if (!success) {
throw new CopilotTranscriptionJobNotFound();
}
await this.job.add('copilot.transcript.submit', {
jobId,
url,
mimeType: blob.mimetype,
mimeType,
});
return { id: jobId, status };
@@ -113,9 +130,11 @@ export class CopilotTranscriptionService {
const ret: TranscriptionJob = { id: job.id, status: job.status };
if (job.status === AiJobStatus.claimed) {
const payload = TranscriptPayloadSchema.safeParse(job.payload);
if (payload.success) {
const payload = TranscriptPayloadSchema.safeParse(job.payload);
if (payload.success) {
ret.url = payload.data.url || undefined;
ret.mimeType = payload.data.mimeType || undefined;
if (job.status === AiJobStatus.claimed) {
ret.transcription = payload.data;
}
}

View File

@@ -21,6 +21,8 @@ const TranscriptionItemSchema = z.object({
export const TranscriptionSchema = z.array(TranscriptionItemSchema);
export const TranscriptPayloadSchema = z.object({
url: z.string().nullable().optional(),
mimeType: z.string().nullable().optional(),
title: z.string().nullable().optional(),
summary: z.string().nullable().optional(),
transcription: TranscriptionSchema.nullable().optional(),

View File

@@ -436,6 +436,7 @@ enum ErrorNames {
COPILOT_SESSION_DELETED
COPILOT_SESSION_NOT_FOUND
COPILOT_TRANSCRIPTION_JOB_EXISTS
COPILOT_TRANSCRIPTION_JOB_NOT_FOUND
CUSTOMER_PORTAL_CREATE_FAILED
DOC_ACTION_DENIED
DOC_DEFAULT_ROLE_CAN_NOT_BE_OWNER
@@ -990,6 +991,7 @@ type Mutation {
removeContextFile(options: RemoveContextFileInput!): Boolean!
removeWorkspaceFeature(feature: FeatureType!, workspaceId: String!): Boolean!
resumeSubscription(idempotencyKey: String @deprecated(reason: "use header `Idempotency-Key`"), plan: SubscriptionPlan = Pro, workspaceId: String): SubscriptionType!
retryAudioTranscription(jobId: String!, workspaceId: String!): TranscriptionResultType
revoke(userId: String!, workspaceId: String!): Boolean!
revokeDocUserRoles(input: RevokeDocUserRoleInput!): Boolean!
revokeInviteLink(workspaceId: String!): Boolean!

View File

@@ -0,0 +1,6 @@
mutation retryAudioTranscription($workspaceId: String!, $jobId: String!) {
retryAudioTranscription(workspaceId: $workspaceId, jobId: $jobId) {
id
status
}
}

View File

@@ -656,6 +656,17 @@ export const getAudioTranscriptionQuery = {
}`,
};
export const retryAudioTranscriptionMutation = {
id: 'retryAudioTranscriptionMutation' as const,
op: 'retryAudioTranscription',
query: `mutation retryAudioTranscription($workspaceId: String!, $jobId: String!) {
retryAudioTranscription(workspaceId: $workspaceId, jobId: $jobId) {
id
status
}
}`,
};
export const createCopilotMessageMutation = {
id: 'createCopilotMessageMutation' as const,
op: 'createCopilotMessage',

View File

@@ -581,6 +581,7 @@ export enum ErrorNames {
COPILOT_SESSION_DELETED = 'COPILOT_SESSION_DELETED',
COPILOT_SESSION_NOT_FOUND = 'COPILOT_SESSION_NOT_FOUND',
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',
DOC_ACTION_DENIED = 'DOC_ACTION_DENIED',
DOC_DEFAULT_ROLE_CAN_NOT_BE_OWNER = 'DOC_DEFAULT_ROLE_CAN_NOT_BE_OWNER',
@@ -1102,6 +1103,7 @@ export interface Mutation {
removeContextFile: Scalars['Boolean']['output'];
removeWorkspaceFeature: Scalars['Boolean']['output'];
resumeSubscription: SubscriptionType;
retryAudioTranscription: Maybe<TranscriptionResultType>;
revoke: Scalars['Boolean']['output'];
revokeDocUserRoles: Scalars['Boolean']['output'];
revokeInviteLink: Scalars['Boolean']['output'];
@@ -1367,6 +1369,11 @@ export interface MutationResumeSubscriptionArgs {
workspaceId?: InputMaybe<Scalars['String']['input']>;
}
export interface MutationRetryAudioTranscriptionArgs {
jobId: Scalars['String']['input'];
workspaceId: Scalars['String']['input'];
}
export interface MutationRevokeArgs {
userId: Scalars['String']['input'];
workspaceId: Scalars['String']['input'];
@@ -3048,6 +3055,20 @@ export type GetAudioTranscriptionQuery = {
} | null;
};
export type RetryAudioTranscriptionMutationVariables = Exact<{
workspaceId: Scalars['String']['input'];
jobId: Scalars['String']['input'];
}>;
export type RetryAudioTranscriptionMutation = {
__typename?: 'Mutation';
retryAudioTranscription: {
__typename?: 'TranscriptionResultType';
id: string;
status: AiJobStatus;
} | null;
};
export type CreateCopilotMessageMutationVariables = Exact<{
options: CreateChatMessageInput;
}>;
@@ -4706,6 +4727,11 @@ export type Mutations =
variables: ClaimAudioTranscriptionMutationVariables;
response: ClaimAudioTranscriptionMutation;
}
| {
name: 'retryAudioTranscriptionMutation';
variables: RetryAudioTranscriptionMutationVariables;
response: RetryAudioTranscriptionMutation;
}
| {
name: 'createCopilotMessageMutation';
variables: CreateCopilotMessageMutationVariables;

View File

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

View File

@@ -1999,6 +1999,7 @@
"error.COPILOT_FAILED_TO_MATCH_CONTEXT": "Failed to match context {{contextId}} with \"%7B%7Bcontent%7D%7D\": {{message}}",
"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_NOT_FOUND": "Transcription job not found.",
"error.BLOB_QUOTA_EXCEEDED": "You have exceeded your blob size quota.",
"error.STORAGE_QUOTA_EXCEEDED": "You have exceeded your storage quota.",
"error.MEMBER_QUOTA_EXCEEDED": "You have exceeded your workspace member quota.",