@opengeni/api-router 0.21.11 → 0.22.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/app.d.ts +1 -0
- package/dist/app.js +1 -1
- package/dist/{chunk-J4JS2F7L.js → chunk-HWXJW5C7.js} +1210 -173
- package/dist/chunk-HWXJW5C7.js.map +1 -0
- package/dist/index.js +1 -1
- package/dist/integrations/slack-interactions.d.ts +1 -1
- package/dist/routes/transcription-recordings.d.ts +3 -0
- package/dist/transcription/segmenter.d.ts +10 -0
- package/dist/transcription/service.d.ts +5 -0
- package/package.json +12 -12
- package/src/app.ts +36 -2
- package/src/mcp/server.ts +5 -80
- package/src/mcp/toolspace.ts +91 -69
- package/src/routes/codex.ts +138 -8
- package/src/routes/transcription-recordings.ts +722 -0
- package/src/routes/transcriptions.ts +2 -0
- package/src/transcription/providers/azure-openai.ts +4 -3
- package/src/transcription/providers/codex-subscription.ts +4 -1
- package/src/transcription/providers/openai.ts +7 -2
- package/src/transcription/segmenter.ts +260 -0
- package/src/transcription/service.ts +111 -10
- package/dist/chunk-J4JS2F7L.js.map +0 -1
|
@@ -0,0 +1,722 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import {
|
|
3
|
+
CreateTranscriptionRecordingRequest,
|
|
4
|
+
FinalizeTranscriptionRecordingRequest,
|
|
5
|
+
resolveWorkspaceVoiceInputEnabled,
|
|
6
|
+
TRANSCRIPTION_RECORDING_PROVIDER_SEGMENT_SECONDS,
|
|
7
|
+
TRANSCRIPTION_RECORDING_RECOVERY_RETRY_AFTER_MILLISECONDS,
|
|
8
|
+
type TranscriptionRecordingErrorCode,
|
|
9
|
+
type TranscriptionRecordingResponse,
|
|
10
|
+
type UploadTranscriptionRecordingChunkResponse,
|
|
11
|
+
} from "@opengeni/contracts";
|
|
12
|
+
import {
|
|
13
|
+
isAcceptedMimeType,
|
|
14
|
+
normalizeMimeType,
|
|
15
|
+
requireAccessGrant,
|
|
16
|
+
TRANSCRIPTION_PROVIDER_REQUEST_TIMEOUT_MILLISECONDS,
|
|
17
|
+
TranscriptionServiceError,
|
|
18
|
+
type ApiRouteDeps,
|
|
19
|
+
} from "@opengeni/core";
|
|
20
|
+
import {
|
|
21
|
+
claimNextTranscriptionRecordingSegment,
|
|
22
|
+
claimTranscriptionRecordingAssembly,
|
|
23
|
+
completeTranscriptionRecordingAssembly,
|
|
24
|
+
completeTranscriptionRecordingChunk,
|
|
25
|
+
completeTranscriptionRecordingSegment,
|
|
26
|
+
completeTranscriptionRecordingSegmentPreparation,
|
|
27
|
+
createTranscriptionRecording,
|
|
28
|
+
discardTranscriptionRecording,
|
|
29
|
+
failTranscriptionRecordingAssembly,
|
|
30
|
+
failTranscriptionRecordingSegment,
|
|
31
|
+
getTranscriptionRecording,
|
|
32
|
+
listTranscriptionRecordings,
|
|
33
|
+
listTranscriptionRecordingChunks,
|
|
34
|
+
markTranscriptionRecordingObjectCleaned,
|
|
35
|
+
markTranscriptionRecordingObjectsCleaned,
|
|
36
|
+
reserveTranscriptionRecordingChunk,
|
|
37
|
+
reserveTranscriptionRecordingSegment,
|
|
38
|
+
startTranscriptionRecordingSegmentProviderCall,
|
|
39
|
+
transcriptionRecordingObjectKeys,
|
|
40
|
+
TranscriptionRecordingConflictError,
|
|
41
|
+
TranscriptionRecordingNotFoundError,
|
|
42
|
+
TranscriptionRecordingStateError,
|
|
43
|
+
} from "@opengeni/db";
|
|
44
|
+
import { getWorkspace } from "@opengeni/db";
|
|
45
|
+
import type { Context, Hono } from "hono";
|
|
46
|
+
import { TranscriptionSegmenterError } from "../transcription/segmenter";
|
|
47
|
+
|
|
48
|
+
const CHUNK_SHA256_HEADER = "x-opengeni-chunk-sha256";
|
|
49
|
+
const CHUNK_START_HEADER = "x-opengeni-chunk-start-milliseconds";
|
|
50
|
+
const CHUNK_DURATION_HEADER = "x-opengeni-chunk-duration-milliseconds";
|
|
51
|
+
const PROCESSING_LEASE_MILLISECONDS = 15 * 60 * 1_000;
|
|
52
|
+
|
|
53
|
+
type RecordingAuthority = {
|
|
54
|
+
accountId: string;
|
|
55
|
+
workspaceId: string;
|
|
56
|
+
subjectId: string;
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
class RecordingProcessingError extends Error {
|
|
60
|
+
readonly name = "RecordingProcessingError";
|
|
61
|
+
|
|
62
|
+
constructor(
|
|
63
|
+
message: string,
|
|
64
|
+
readonly code: TranscriptionRecordingErrorCode,
|
|
65
|
+
readonly retryable: boolean,
|
|
66
|
+
) {
|
|
67
|
+
super(message);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function registerResumableTranscriptionRoutes(app: Hono, deps: ApiRouteDeps): void {
|
|
72
|
+
app.get("/v1/workspaces/:workspaceId/transcription-recordings", async (c) => {
|
|
73
|
+
try {
|
|
74
|
+
const authority = await requireRecordingAuthority(c, deps, false);
|
|
75
|
+
return c.json({
|
|
76
|
+
recordings: await listTranscriptionRecordings(deps.db, {
|
|
77
|
+
workspaceId: authority.workspaceId,
|
|
78
|
+
subjectId: authority.subjectId,
|
|
79
|
+
}),
|
|
80
|
+
});
|
|
81
|
+
} catch (error) {
|
|
82
|
+
return routeError(c, error);
|
|
83
|
+
}
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
app.post("/v1/workspaces/:workspaceId/transcription-recordings", async (c) => {
|
|
87
|
+
try {
|
|
88
|
+
const authority = await requireRecordingAuthority(c, deps, true);
|
|
89
|
+
if (!(await resumableAvailable(deps, authority.workspaceId))) {
|
|
90
|
+
return c.json({ code: "unavailable" }, 503);
|
|
91
|
+
}
|
|
92
|
+
const parsed = CreateTranscriptionRecordingRequest.safeParse(await jsonBody(c));
|
|
93
|
+
if (!parsed.success) return c.json({ code: "invalid_request" }, 400);
|
|
94
|
+
const mimeType = normalizeMimeType(parsed.data.mimeType);
|
|
95
|
+
if (!isAcceptedMimeType(mimeType, deps.transcription!.limits().acceptedMimeTypes)) {
|
|
96
|
+
return c.json({ code: "not_supported" }, 415);
|
|
97
|
+
}
|
|
98
|
+
const recording = await createTranscriptionRecording(deps.db, {
|
|
99
|
+
...authority,
|
|
100
|
+
recordingId: parsed.data.recordingId,
|
|
101
|
+
mimeType,
|
|
102
|
+
expiresAt: new Date(Date.now() + deps.settings.voiceInputResumableRetentionSeconds * 1_000),
|
|
103
|
+
});
|
|
104
|
+
return c.json(recording, 201);
|
|
105
|
+
} catch (error) {
|
|
106
|
+
return routeError(c, error);
|
|
107
|
+
}
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
app.get("/v1/workspaces/:workspaceId/transcription-recordings/:recordingId", async (c) => {
|
|
111
|
+
try {
|
|
112
|
+
const authority = await requireRecordingAuthority(c, deps, false);
|
|
113
|
+
const response = await getTranscriptionRecording(deps.db, {
|
|
114
|
+
...authority,
|
|
115
|
+
recordingId: uuidParam(c, "recordingId"),
|
|
116
|
+
});
|
|
117
|
+
return c.json(withRecoveryRetryHint(await cleanupTerminalObjects(deps, authority, response)));
|
|
118
|
+
} catch (error) {
|
|
119
|
+
return routeError(c, error);
|
|
120
|
+
}
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
app.put(
|
|
124
|
+
"/v1/workspaces/:workspaceId/transcription-recordings/:recordingId/chunks/:chunkNumber",
|
|
125
|
+
async (c) => {
|
|
126
|
+
try {
|
|
127
|
+
const authority = await requireRecordingAuthority(c, deps, true);
|
|
128
|
+
if (!deps.objectStorage || !deps.settings.voiceInputResumableEnabled) {
|
|
129
|
+
return c.json({ code: "unavailable" }, 503);
|
|
130
|
+
}
|
|
131
|
+
const chunkNumber = nonnegativeInteger(c.req.param("chunkNumber"));
|
|
132
|
+
const startMilliseconds = headerInteger(c, CHUNK_START_HEADER, true);
|
|
133
|
+
const durationMilliseconds = headerInteger(c, CHUNK_DURATION_HEADER, true);
|
|
134
|
+
const declaredSha256 = c.req.header(CHUNK_SHA256_HEADER)?.trim().toLowerCase() ?? "";
|
|
135
|
+
if (!/^[0-9a-f]{64}$/.test(declaredSha256)) {
|
|
136
|
+
return c.json({ code: "invalid_request" }, 400);
|
|
137
|
+
}
|
|
138
|
+
const body = await readBoundedBody(
|
|
139
|
+
c.req.raw,
|
|
140
|
+
deps.settings.voiceInputResumableMaxChunkSizeBytes,
|
|
141
|
+
);
|
|
142
|
+
const sha256 = sha256Hex(body);
|
|
143
|
+
if (sha256 !== declaredSha256) {
|
|
144
|
+
return c.json({ code: "conflict" }, 409);
|
|
145
|
+
}
|
|
146
|
+
const existing = await getTranscriptionRecording(deps.db, {
|
|
147
|
+
...authority,
|
|
148
|
+
recordingId: uuidParam(c, "recordingId"),
|
|
149
|
+
});
|
|
150
|
+
if (
|
|
151
|
+
normalizeMimeType(c.req.header("content-type") ?? "") !==
|
|
152
|
+
normalizeMimeType(existing.recording.mimeType)
|
|
153
|
+
) {
|
|
154
|
+
return c.json({ code: "not_supported" }, 415);
|
|
155
|
+
}
|
|
156
|
+
const reservation = await reserveTranscriptionRecordingChunk(deps.db, {
|
|
157
|
+
...authority,
|
|
158
|
+
recordingId: existing.recording.id,
|
|
159
|
+
chunkNumber,
|
|
160
|
+
byteLength: body.byteLength,
|
|
161
|
+
sha256,
|
|
162
|
+
startMilliseconds,
|
|
163
|
+
durationMilliseconds,
|
|
164
|
+
maxTotalBytes: deps.settings.voiceInputResumableMaxSizeBytes,
|
|
165
|
+
maxDurationMilliseconds: deps.settings.voiceInputResumableMaxDurationSeconds * 1_000,
|
|
166
|
+
});
|
|
167
|
+
if (!reservation.deduplicated) {
|
|
168
|
+
try {
|
|
169
|
+
// A concurrent same-hash retry may still observe the row while it is
|
|
170
|
+
// uploading and repeat this PUT. The object key is hash-derived and
|
|
171
|
+
// the storage contract permits identical verified writes; the DB
|
|
172
|
+
// completion fence prevents duplicate chunk accounting.
|
|
173
|
+
await deps.objectStorage.putObject({
|
|
174
|
+
key: reservation.chunk.objectKey,
|
|
175
|
+
contentType: existing.recording.mimeType,
|
|
176
|
+
body,
|
|
177
|
+
sha256,
|
|
178
|
+
});
|
|
179
|
+
} catch {
|
|
180
|
+
throw new RecordingProcessingError("Chunk upload failed", "network", true);
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
const completed = await completeTranscriptionRecordingChunk(deps.db, {
|
|
184
|
+
workspaceId: authority.workspaceId,
|
|
185
|
+
subjectId: authority.subjectId,
|
|
186
|
+
recordingId: existing.recording.id,
|
|
187
|
+
chunkNumber,
|
|
188
|
+
});
|
|
189
|
+
const response: UploadTranscriptionRecordingChunkResponse = {
|
|
190
|
+
recording: completed.recording.recording,
|
|
191
|
+
chunk: {
|
|
192
|
+
chunkNumber: completed.chunk.chunkNumber,
|
|
193
|
+
byteLength: completed.chunk.byteLength,
|
|
194
|
+
sha256: completed.chunk.sha256,
|
|
195
|
+
startMilliseconds: completed.chunk.startMilliseconds,
|
|
196
|
+
durationMilliseconds: completed.chunk.durationMilliseconds,
|
|
197
|
+
deduplicated: reservation.deduplicated || completed.deduplicated,
|
|
198
|
+
},
|
|
199
|
+
};
|
|
200
|
+
return c.json(response);
|
|
201
|
+
} catch (error) {
|
|
202
|
+
return routeError(c, error);
|
|
203
|
+
}
|
|
204
|
+
},
|
|
205
|
+
);
|
|
206
|
+
|
|
207
|
+
app.post(
|
|
208
|
+
"/v1/workspaces/:workspaceId/transcription-recordings/:recordingId/finalize",
|
|
209
|
+
async (c) => {
|
|
210
|
+
const owner = correlationId(c);
|
|
211
|
+
let authority: RecordingAuthority | null = null;
|
|
212
|
+
let generation = 0;
|
|
213
|
+
try {
|
|
214
|
+
authority = await requireRecordingAuthority(c, deps, true);
|
|
215
|
+
if (!(await resumableAvailable(deps, authority.workspaceId))) {
|
|
216
|
+
return c.json({ code: "unavailable" }, 503);
|
|
217
|
+
}
|
|
218
|
+
const parsed = FinalizeTranscriptionRecordingRequest.safeParse(await jsonBody(c));
|
|
219
|
+
if (!parsed.success) return c.json({ code: "invalid_request" }, 400);
|
|
220
|
+
const recordingId = uuidParam(c, "recordingId");
|
|
221
|
+
const claim = await claimTranscriptionRecordingAssembly(deps.db, {
|
|
222
|
+
workspaceId: authority.workspaceId,
|
|
223
|
+
subjectId: authority.subjectId,
|
|
224
|
+
recordingId,
|
|
225
|
+
owner,
|
|
226
|
+
...parsed.data,
|
|
227
|
+
staleBefore: new Date(Date.now() - PROCESSING_LEASE_MILLISECONDS),
|
|
228
|
+
});
|
|
229
|
+
generation = claim.generation;
|
|
230
|
+
if (!claim.claimed) {
|
|
231
|
+
const response = withRecoveryRetryHint(claim.recording);
|
|
232
|
+
return c.json(response, claim.recording.recording.state === "segmenting" ? 202 : 200);
|
|
233
|
+
}
|
|
234
|
+
for (const key of claim.staleObjectKeys) {
|
|
235
|
+
try {
|
|
236
|
+
await deps.objectStorage!.deleteObject(key);
|
|
237
|
+
await markTranscriptionRecordingObjectCleaned(deps.db, {
|
|
238
|
+
workspaceId: authority.workspaceId,
|
|
239
|
+
subjectId: authority.subjectId,
|
|
240
|
+
recordingId,
|
|
241
|
+
objectKey: key,
|
|
242
|
+
});
|
|
243
|
+
} catch {
|
|
244
|
+
// The durable object ledger keeps the key eligible for the global reaper.
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
const chunks = await listTranscriptionRecordingChunks(deps.db, {
|
|
248
|
+
workspaceId: authority.workspaceId,
|
|
249
|
+
subjectId: authority.subjectId,
|
|
250
|
+
recordingId,
|
|
251
|
+
});
|
|
252
|
+
const providerMaxSegmentSeconds = deps.transcription!.limits().maxDurationSeconds;
|
|
253
|
+
const minimumBoundedSegmentSeconds = Math.ceil(
|
|
254
|
+
claim.recording.recording.totalDurationMilliseconds / 1_000 / 1_000,
|
|
255
|
+
);
|
|
256
|
+
if (providerMaxSegmentSeconds < minimumBoundedSegmentSeconds) {
|
|
257
|
+
throw new RecordingProcessingError(
|
|
258
|
+
"Recording cannot fit the bounded provider segment projection",
|
|
259
|
+
"too_large",
|
|
260
|
+
false,
|
|
261
|
+
);
|
|
262
|
+
}
|
|
263
|
+
const providerSegmentSeconds = Math.min(
|
|
264
|
+
TRANSCRIPTION_RECORDING_PROVIDER_SEGMENT_SECONDS,
|
|
265
|
+
providerMaxSegmentSeconds,
|
|
266
|
+
);
|
|
267
|
+
for await (const segment of deps.transcriptionSegmenter!.segment({
|
|
268
|
+
sourceMimeType: claim.recording.recording.mimeType,
|
|
269
|
+
totalDurationMilliseconds: claim.recording.recording.totalDurationMilliseconds,
|
|
270
|
+
providerSegmentSeconds,
|
|
271
|
+
chunks: verifiedChunkBytes(deps, chunks, c.req.raw.signal),
|
|
272
|
+
signal: c.req.raw.signal,
|
|
273
|
+
})) {
|
|
274
|
+
const sha256 = sha256Hex(segment.bytes);
|
|
275
|
+
const reservation = await reserveTranscriptionRecordingSegment(deps.db, {
|
|
276
|
+
...authority,
|
|
277
|
+
recordingId,
|
|
278
|
+
owner,
|
|
279
|
+
generation,
|
|
280
|
+
segmentNumber: segment.segmentNumber,
|
|
281
|
+
byteLength: segment.bytes.byteLength,
|
|
282
|
+
sha256,
|
|
283
|
+
startMilliseconds: segment.startMilliseconds,
|
|
284
|
+
durationMilliseconds: segment.durationMilliseconds,
|
|
285
|
+
});
|
|
286
|
+
try {
|
|
287
|
+
await deps.objectStorage!.putObject({
|
|
288
|
+
key: reservation.objectKey,
|
|
289
|
+
contentType: segment.mimeType,
|
|
290
|
+
body: segment.bytes,
|
|
291
|
+
sha256,
|
|
292
|
+
});
|
|
293
|
+
} catch {
|
|
294
|
+
throw new RecordingProcessingError("Segment upload failed", "network", true);
|
|
295
|
+
}
|
|
296
|
+
await completeTranscriptionRecordingSegmentPreparation(deps.db, {
|
|
297
|
+
workspaceId: authority.workspaceId,
|
|
298
|
+
subjectId: authority.subjectId,
|
|
299
|
+
recordingId,
|
|
300
|
+
owner,
|
|
301
|
+
generation,
|
|
302
|
+
segmentNumber: segment.segmentNumber,
|
|
303
|
+
});
|
|
304
|
+
}
|
|
305
|
+
return c.json(
|
|
306
|
+
await completeTranscriptionRecordingAssembly(deps.db, {
|
|
307
|
+
workspaceId: authority.workspaceId,
|
|
308
|
+
subjectId: authority.subjectId,
|
|
309
|
+
recordingId,
|
|
310
|
+
owner,
|
|
311
|
+
generation,
|
|
312
|
+
}),
|
|
313
|
+
);
|
|
314
|
+
} catch (error) {
|
|
315
|
+
if (authority && generation > 0) {
|
|
316
|
+
const failure = processingFailure(error);
|
|
317
|
+
const persisted = await failTranscriptionRecordingAssembly(deps.db, {
|
|
318
|
+
workspaceId: authority.workspaceId,
|
|
319
|
+
subjectId: authority.subjectId,
|
|
320
|
+
recordingId: uuidParam(c, "recordingId"),
|
|
321
|
+
owner,
|
|
322
|
+
generation,
|
|
323
|
+
errorCode: failure.code,
|
|
324
|
+
retryable: failure.retryable,
|
|
325
|
+
}).catch(() => null);
|
|
326
|
+
if (persisted) return c.json(persisted);
|
|
327
|
+
}
|
|
328
|
+
return routeError(c, error);
|
|
329
|
+
}
|
|
330
|
+
},
|
|
331
|
+
);
|
|
332
|
+
|
|
333
|
+
app.post(
|
|
334
|
+
"/v1/workspaces/:workspaceId/transcription-recordings/:recordingId/process-next",
|
|
335
|
+
async (c) => {
|
|
336
|
+
let authority: RecordingAuthority | null = null;
|
|
337
|
+
let attemptId: string | null = null;
|
|
338
|
+
let segmentNumber: number | null = null;
|
|
339
|
+
try {
|
|
340
|
+
authority = await requireRecordingAuthority(c, deps, true);
|
|
341
|
+
const service = deps.transcription;
|
|
342
|
+
if (
|
|
343
|
+
!deps.objectStorage ||
|
|
344
|
+
!service ||
|
|
345
|
+
!(await service.available({ workspaceId: authority.workspaceId }))
|
|
346
|
+
) {
|
|
347
|
+
return c.json({ code: "unavailable" }, 503);
|
|
348
|
+
}
|
|
349
|
+
const selectedProvider = service.selectProvider
|
|
350
|
+
? await service.selectProvider({ workspaceId: authority.workspaceId })
|
|
351
|
+
: "host";
|
|
352
|
+
if (!selectedProvider) return c.json({ code: "unavailable" }, 503);
|
|
353
|
+
attemptId = correlationId(c);
|
|
354
|
+
const claim = await claimNextTranscriptionRecordingSegment(deps.db, {
|
|
355
|
+
workspaceId: authority.workspaceId,
|
|
356
|
+
subjectId: authority.subjectId,
|
|
357
|
+
recordingId: uuidParam(c, "recordingId"),
|
|
358
|
+
attemptId,
|
|
359
|
+
providerId: selectedProvider,
|
|
360
|
+
staleBefore: new Date(Date.now() - PROCESSING_LEASE_MILLISECONDS),
|
|
361
|
+
providerDeadlineAt: new Date(Date.now() + PROCESSING_LEASE_MILLISECONDS),
|
|
362
|
+
});
|
|
363
|
+
if (!claim.claimed || !claim.segment) {
|
|
364
|
+
const cleaned = await cleanupTerminalObjects(deps, authority, claim.recording);
|
|
365
|
+
return c.json(
|
|
366
|
+
withRecoveryRetryHint(cleaned),
|
|
367
|
+
cleaned.recording.state === "transcribing" ? 202 : 200,
|
|
368
|
+
);
|
|
369
|
+
}
|
|
370
|
+
segmentNumber = claim.segment.segmentNumber;
|
|
371
|
+
const stored = await deps.objectStorage?.getObjectBytes(claim.segment.objectKey);
|
|
372
|
+
if (!stored) {
|
|
373
|
+
throw new RecordingProcessingError("Provider segment is missing", "invalid_audio", false);
|
|
374
|
+
}
|
|
375
|
+
if (
|
|
376
|
+
stored.bytes.byteLength !== claim.segment.byteLength ||
|
|
377
|
+
sha256Hex(stored.bytes) !== claim.segment.sha256
|
|
378
|
+
) {
|
|
379
|
+
throw new RecordingProcessingError(
|
|
380
|
+
"Provider segment failed integrity verification",
|
|
381
|
+
"invalid_audio",
|
|
382
|
+
false,
|
|
383
|
+
);
|
|
384
|
+
}
|
|
385
|
+
const providerStartedAt = new Date();
|
|
386
|
+
const providerDeadlineAt = new Date(
|
|
387
|
+
providerStartedAt.getTime() + TRANSCRIPTION_PROVIDER_REQUEST_TIMEOUT_MILLISECONDS,
|
|
388
|
+
);
|
|
389
|
+
await startTranscriptionRecordingSegmentProviderCall(deps.db, {
|
|
390
|
+
workspaceId: authority.workspaceId,
|
|
391
|
+
subjectId: authority.subjectId,
|
|
392
|
+
recordingId: uuidParam(c, "recordingId"),
|
|
393
|
+
segmentNumber,
|
|
394
|
+
attemptId,
|
|
395
|
+
providerStartedAt,
|
|
396
|
+
providerDeadlineAt,
|
|
397
|
+
});
|
|
398
|
+
const result = await service.transcribe({
|
|
399
|
+
workspaceId: authority.workspaceId,
|
|
400
|
+
accountId: authority.accountId,
|
|
401
|
+
audio: stored.bytes,
|
|
402
|
+
mimeType: "audio/wav",
|
|
403
|
+
durationSeconds: claim.segment.durationMilliseconds / 1_000,
|
|
404
|
+
requestId: attemptId,
|
|
405
|
+
providerDeadlineAt,
|
|
406
|
+
...(claim.segment.providerId && claim.segment.providerId !== "host"
|
|
407
|
+
? { providerId: claim.segment.providerId }
|
|
408
|
+
: {}),
|
|
409
|
+
});
|
|
410
|
+
const completed = await completeTranscriptionRecordingSegment(deps.db, {
|
|
411
|
+
workspaceId: authority.workspaceId,
|
|
412
|
+
subjectId: authority.subjectId,
|
|
413
|
+
recordingId: uuidParam(c, "recordingId"),
|
|
414
|
+
segmentNumber,
|
|
415
|
+
attemptId,
|
|
416
|
+
text: result.text,
|
|
417
|
+
languages: result.languages,
|
|
418
|
+
providerId: claim.segment.providerId ?? result.providerId,
|
|
419
|
+
});
|
|
420
|
+
return c.json(await cleanupTerminalObjects(deps, authority, completed));
|
|
421
|
+
} catch (error) {
|
|
422
|
+
if (authority && attemptId && segmentNumber !== null) {
|
|
423
|
+
const failure = processingFailure(error);
|
|
424
|
+
const persisted = await failTranscriptionRecordingSegment(deps.db, {
|
|
425
|
+
workspaceId: authority.workspaceId,
|
|
426
|
+
subjectId: authority.subjectId,
|
|
427
|
+
recordingId: uuidParam(c, "recordingId"),
|
|
428
|
+
segmentNumber,
|
|
429
|
+
attemptId,
|
|
430
|
+
errorCode: failure.code,
|
|
431
|
+
retryable: failure.retryable,
|
|
432
|
+
}).catch(() => null);
|
|
433
|
+
if (persisted) return c.json(persisted);
|
|
434
|
+
}
|
|
435
|
+
return routeError(c, error);
|
|
436
|
+
}
|
|
437
|
+
},
|
|
438
|
+
);
|
|
439
|
+
|
|
440
|
+
app.delete("/v1/workspaces/:workspaceId/transcription-recordings/:recordingId", async (c) => {
|
|
441
|
+
try {
|
|
442
|
+
const authority = await requireRecordingAuthority(c, deps, false);
|
|
443
|
+
const discarded = await discardTranscriptionRecording(deps.db, {
|
|
444
|
+
workspaceId: authority.workspaceId,
|
|
445
|
+
subjectId: authority.subjectId,
|
|
446
|
+
recordingId: uuidParam(c, "recordingId"),
|
|
447
|
+
});
|
|
448
|
+
return c.json(await cleanupTerminalObjects(deps, authority, discarded));
|
|
449
|
+
} catch (error) {
|
|
450
|
+
return routeError(c, error);
|
|
451
|
+
}
|
|
452
|
+
});
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
function withRecoveryRetryHint(
|
|
456
|
+
response: TranscriptionRecordingResponse,
|
|
457
|
+
): TranscriptionRecordingResponse {
|
|
458
|
+
if (response.recording.state === "segmenting" || response.recording.state === "transcribing") {
|
|
459
|
+
return {
|
|
460
|
+
...response,
|
|
461
|
+
retryAfterMilliseconds: TRANSCRIPTION_RECORDING_RECOVERY_RETRY_AFTER_MILLISECONDS,
|
|
462
|
+
};
|
|
463
|
+
}
|
|
464
|
+
return response;
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
async function requireRecordingAuthority(
|
|
468
|
+
c: Context,
|
|
469
|
+
deps: ApiRouteDeps,
|
|
470
|
+
requirePolicy: boolean,
|
|
471
|
+
): Promise<RecordingAuthority> {
|
|
472
|
+
const workspaceId = c.req.param("workspaceId");
|
|
473
|
+
if (!workspaceId) {
|
|
474
|
+
throw new TranscriptionRecordingNotFoundError("Workspace not found");
|
|
475
|
+
}
|
|
476
|
+
const grant = await requireAccessGrant(c, deps, workspaceId, "sessions:create");
|
|
477
|
+
if (requirePolicy) {
|
|
478
|
+
const workspace = await getWorkspace(deps.db, workspaceId);
|
|
479
|
+
if (!workspace) throw new TranscriptionRecordingNotFoundError("Workspace not found");
|
|
480
|
+
if (resolveWorkspaceVoiceInputEnabled(workspace.settings) === false) {
|
|
481
|
+
throw new RecordingProcessingError("Voice input is disabled", "policy_blocked", false);
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
return {
|
|
485
|
+
accountId: grant.accountId,
|
|
486
|
+
workspaceId,
|
|
487
|
+
subjectId: grant.subjectId,
|
|
488
|
+
};
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
async function resumableAvailable(deps: ApiRouteDeps, workspaceId: string): Promise<boolean> {
|
|
492
|
+
return Boolean(
|
|
493
|
+
deps.settings.voiceInputResumableEnabled &&
|
|
494
|
+
deps.objectStorage &&
|
|
495
|
+
deps.transcription &&
|
|
496
|
+
deps.transcriptionSegmenter &&
|
|
497
|
+
(await deps.transcription.available({ workspaceId })) &&
|
|
498
|
+
(await deps.transcriptionSegmenter.available()),
|
|
499
|
+
);
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
async function cleanupTerminalObjects(
|
|
503
|
+
deps: ApiRouteDeps,
|
|
504
|
+
authority: RecordingAuthority,
|
|
505
|
+
response: TranscriptionRecordingResponse,
|
|
506
|
+
): Promise<TranscriptionRecordingResponse> {
|
|
507
|
+
if (
|
|
508
|
+
!deps.objectStorage ||
|
|
509
|
+
response.recording.objectsCleaned ||
|
|
510
|
+
(response.recording.state !== "complete" &&
|
|
511
|
+
response.recording.state !== "discarded" &&
|
|
512
|
+
!(response.recording.state === "failed" && !response.recording.retryable))
|
|
513
|
+
) {
|
|
514
|
+
return response;
|
|
515
|
+
}
|
|
516
|
+
try {
|
|
517
|
+
const keys = await transcriptionRecordingObjectKeys(deps.db, {
|
|
518
|
+
workspaceId: authority.workspaceId,
|
|
519
|
+
subjectId: authority.subjectId,
|
|
520
|
+
recordingId: response.recording.id,
|
|
521
|
+
});
|
|
522
|
+
let current = response;
|
|
523
|
+
for (const key of keys) {
|
|
524
|
+
await deps.objectStorage.deleteObject(key);
|
|
525
|
+
current = await markTranscriptionRecordingObjectCleaned(deps.db, {
|
|
526
|
+
workspaceId: authority.workspaceId,
|
|
527
|
+
subjectId: authority.subjectId,
|
|
528
|
+
recordingId: response.recording.id,
|
|
529
|
+
objectKey: key,
|
|
530
|
+
});
|
|
531
|
+
}
|
|
532
|
+
if (current.recording.objectsCleaned) return current;
|
|
533
|
+
return await markTranscriptionRecordingObjectsCleaned(deps.db, {
|
|
534
|
+
workspaceId: authority.workspaceId,
|
|
535
|
+
subjectId: authority.subjectId,
|
|
536
|
+
recordingId: response.recording.id,
|
|
537
|
+
});
|
|
538
|
+
} catch {
|
|
539
|
+
return response;
|
|
540
|
+
}
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
async function* verifiedChunkBytes(
|
|
544
|
+
deps: ApiRouteDeps,
|
|
545
|
+
chunks: Awaited<ReturnType<typeof listTranscriptionRecordingChunks>>,
|
|
546
|
+
signal: AbortSignal,
|
|
547
|
+
): AsyncIterable<Uint8Array> {
|
|
548
|
+
for (const chunk of chunks) {
|
|
549
|
+
if (signal.aborted) {
|
|
550
|
+
throw new RecordingProcessingError("Audio assembly was cancelled", "cancelled", true);
|
|
551
|
+
}
|
|
552
|
+
let stored: Awaited<ReturnType<NonNullable<ApiRouteDeps["objectStorage"]>["getObjectBytes"]>>;
|
|
553
|
+
try {
|
|
554
|
+
stored = await deps.objectStorage!.getObjectBytes(chunk.objectKey);
|
|
555
|
+
} catch {
|
|
556
|
+
throw new RecordingProcessingError("Chunk download failed", "network", true);
|
|
557
|
+
}
|
|
558
|
+
if (!stored) {
|
|
559
|
+
throw new RecordingProcessingError("Chunk is missing", "invalid_audio", false);
|
|
560
|
+
}
|
|
561
|
+
if (stored.bytes.byteLength !== chunk.byteLength || sha256Hex(stored.bytes) !== chunk.sha256) {
|
|
562
|
+
throw new RecordingProcessingError(
|
|
563
|
+
"Chunk integrity verification failed",
|
|
564
|
+
"invalid_audio",
|
|
565
|
+
false,
|
|
566
|
+
);
|
|
567
|
+
}
|
|
568
|
+
yield stored.bytes;
|
|
569
|
+
}
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
function processingFailure(error: unknown): {
|
|
573
|
+
code: TranscriptionRecordingErrorCode;
|
|
574
|
+
retryable: boolean;
|
|
575
|
+
} {
|
|
576
|
+
if (error instanceof RecordingProcessingError) {
|
|
577
|
+
return { code: error.code, retryable: error.retryable };
|
|
578
|
+
}
|
|
579
|
+
if (error instanceof TranscriptionSegmenterError) {
|
|
580
|
+
return { code: error.code, retryable: error.retryable };
|
|
581
|
+
}
|
|
582
|
+
if (error instanceof TranscriptionServiceError) {
|
|
583
|
+
return {
|
|
584
|
+
code: error.code,
|
|
585
|
+
retryable:
|
|
586
|
+
error.retryable ||
|
|
587
|
+
error.code === "cancelled" ||
|
|
588
|
+
error.code === "network" ||
|
|
589
|
+
error.code === "timeout" ||
|
|
590
|
+
error.code === "unavailable" ||
|
|
591
|
+
error.code === "provider",
|
|
592
|
+
};
|
|
593
|
+
}
|
|
594
|
+
if (error instanceof DOMException && error.name === "AbortError") {
|
|
595
|
+
return { code: "cancelled", retryable: true };
|
|
596
|
+
}
|
|
597
|
+
return { code: "unknown", retryable: true };
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
function routeError(c: Context, error: unknown): Response | Promise<Response> {
|
|
601
|
+
if (error instanceof TranscriptionRecordingNotFoundError) {
|
|
602
|
+
return c.json({ code: "not_found" }, 404);
|
|
603
|
+
}
|
|
604
|
+
if (error instanceof TranscriptionRecordingConflictError) {
|
|
605
|
+
return c.json({ code: "conflict" }, 409);
|
|
606
|
+
}
|
|
607
|
+
if (error instanceof TranscriptionRecordingStateError) {
|
|
608
|
+
return c.json({ code: "invalid_state" }, 409);
|
|
609
|
+
}
|
|
610
|
+
if (error instanceof RecordingProcessingError) {
|
|
611
|
+
const status =
|
|
612
|
+
error.code === "policy_blocked"
|
|
613
|
+
? 403
|
|
614
|
+
: error.code === "not_supported"
|
|
615
|
+
? 415
|
|
616
|
+
: error.code === "too_large"
|
|
617
|
+
? 413
|
|
618
|
+
: error.code === "invalid_audio"
|
|
619
|
+
? 400
|
|
620
|
+
: error.code === "unavailable"
|
|
621
|
+
? 503
|
|
622
|
+
: 502;
|
|
623
|
+
return c.json({ code: error.code }, status as never);
|
|
624
|
+
}
|
|
625
|
+
return c.json({ code: "unknown" }, 500);
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
async function jsonBody(c: Context): Promise<unknown> {
|
|
629
|
+
try {
|
|
630
|
+
return await c.req.json();
|
|
631
|
+
} catch {
|
|
632
|
+
return null;
|
|
633
|
+
}
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
function correlationId(c: Context): string {
|
|
637
|
+
const value = c.req.header("x-opengeni-correlation-id")?.trim();
|
|
638
|
+
return value && /^[A-Za-z0-9._:-]{1,128}$/.test(value) ? valueAsUuid(value) : crypto.randomUUID();
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
function valueAsUuid(value: string): string {
|
|
642
|
+
if (/^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(value)) {
|
|
643
|
+
return value;
|
|
644
|
+
}
|
|
645
|
+
const hex = createHash("sha256").update(value).digest("hex").slice(0, 32);
|
|
646
|
+
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-4${hex.slice(13, 16)}-a${hex.slice(17, 20)}-${hex.slice(20, 32)}`;
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
function nonnegativeInteger(value: string): number {
|
|
650
|
+
if (!/^(0|[1-9][0-9]*)$/.test(value)) {
|
|
651
|
+
throw new RecordingProcessingError("Invalid integer", "invalid_audio", false);
|
|
652
|
+
}
|
|
653
|
+
const parsed = Number(value);
|
|
654
|
+
if (!Number.isSafeInteger(parsed)) {
|
|
655
|
+
throw new RecordingProcessingError("Invalid integer", "invalid_audio", false);
|
|
656
|
+
}
|
|
657
|
+
return parsed;
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
function uuidParam(c: Context, name: string): string {
|
|
661
|
+
const value = c.req.param(name);
|
|
662
|
+
if (
|
|
663
|
+
!value ||
|
|
664
|
+
!/^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(value)
|
|
665
|
+
) {
|
|
666
|
+
throw new TranscriptionRecordingNotFoundError("Recording not found");
|
|
667
|
+
}
|
|
668
|
+
return value;
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
function headerInteger(c: Context, name: string, allowZero: boolean): number {
|
|
672
|
+
const raw = c.req.header(name) ?? "";
|
|
673
|
+
const value = nonnegativeInteger(raw);
|
|
674
|
+
if (!allowZero && value === 0) {
|
|
675
|
+
throw new RecordingProcessingError("Invalid integer", "invalid_audio", false);
|
|
676
|
+
}
|
|
677
|
+
return value;
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
async function readBoundedBody(request: Request, maxBytes: number): Promise<Uint8Array> {
|
|
681
|
+
const contentLength = Number(request.headers.get("content-length"));
|
|
682
|
+
if (Number.isFinite(contentLength) && contentLength > maxBytes) {
|
|
683
|
+
throw new RecordingProcessingError("Chunk is too large", "too_large", false);
|
|
684
|
+
}
|
|
685
|
+
if (!request.body) {
|
|
686
|
+
throw new RecordingProcessingError("Chunk is required", "invalid_audio", false);
|
|
687
|
+
}
|
|
688
|
+
const reader = request.body.getReader();
|
|
689
|
+
const chunks: Uint8Array[] = [];
|
|
690
|
+
let total = 0;
|
|
691
|
+
try {
|
|
692
|
+
for (;;) {
|
|
693
|
+
if (request.signal.aborted) {
|
|
694
|
+
throw new RecordingProcessingError("Chunk upload was cancelled", "cancelled", true);
|
|
695
|
+
}
|
|
696
|
+
const next = await reader.read();
|
|
697
|
+
if (next.done) break;
|
|
698
|
+
total += next.value.byteLength;
|
|
699
|
+
if (total > maxBytes) {
|
|
700
|
+
await reader.cancel();
|
|
701
|
+
throw new RecordingProcessingError("Chunk is too large", "too_large", false);
|
|
702
|
+
}
|
|
703
|
+
chunks.push(next.value);
|
|
704
|
+
}
|
|
705
|
+
} finally {
|
|
706
|
+
reader.releaseLock();
|
|
707
|
+
}
|
|
708
|
+
if (total === 0) {
|
|
709
|
+
throw new RecordingProcessingError("Chunk is required", "invalid_audio", false);
|
|
710
|
+
}
|
|
711
|
+
const body = new Uint8Array(total);
|
|
712
|
+
let offset = 0;
|
|
713
|
+
for (const chunk of chunks) {
|
|
714
|
+
body.set(chunk, offset);
|
|
715
|
+
offset += chunk.byteLength;
|
|
716
|
+
}
|
|
717
|
+
return body;
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
function sha256Hex(bytes: Uint8Array): string {
|
|
721
|
+
return createHash("sha256").update(bytes).digest("hex");
|
|
722
|
+
}
|