@lincyaw/workspace 1.0.0-rc.0 → 1.0.0-rc.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/README.md +5 -5
- package/package.json +10 -5
- package/src/domain/{revisions.ts → commit-message.ts} +7 -7
- package/src/domain/locks.ts +5 -0
- package/src/domain/paths.ts +2 -2
- package/src/index.ts +7 -2
- package/src/node/authoring-clients.ts +206 -0
- package/src/node/build.ts +4 -4
- package/src/node/compile-session.ts +187 -0
- package/src/node/config.ts +36 -21
- package/src/node/document-assets.ts +30 -0
- package/src/node/document-audio.ts +473 -0
- package/src/node/document-authoring.ts +436 -0
- package/src/node/document-editor.ts +157 -0
- package/src/node/document-history.ts +190 -0
- package/src/node/document-images.ts +241 -0
- package/src/node/document-operations.ts +191 -0
- package/src/node/document-transcription-adoption.ts +96 -0
- package/src/node/document-transcription.ts +471 -0
- package/src/node/document-voice.ts +115 -0
- package/src/node/errors.ts +23 -0
- package/src/node/git.ts +15 -15
- package/src/node/jobs.ts +361 -0
- package/src/node/locks.ts +10 -5
- package/src/node/page-delete.ts +106 -0
- package/src/node/project-files.ts +163 -0
- package/src/node/projects.ts +192 -0
- package/src/node/review/agent-session-reviews.ts +374 -0
- package/src/node/review/git-guard.ts +9 -8
- package/src/node/review/git-review.ts +102 -90
- package/src/node/review/record.ts +77 -0
- package/src/node/review/routes.ts +36 -35
- package/src/node/review/task-reviews.ts +50 -0
- package/src/node/revisions.ts +321 -68
- package/src/node/routes.ts +236 -146
- package/src/node/serve.ts +319 -118
- package/src/node/site.ts +34 -23
- package/src/node/source-editor.ts +332 -0
- package/src/node/speech-settings.ts +21 -0
- package/src/node/templates.ts +27 -0
- package/src/node/workbench-api.ts +176 -0
- package/src/node/writer.ts +389 -0
- package/templates/document/default/lecture.json +75 -0
- package/src/domain/api.ts +0 -102
- package/src/domain/index.ts +0 -6
- package/src/node/documents.ts +0 -185
- package/src/node/files.ts +0 -252
- package/src/node/review/review-record.ts +0 -73
- package/src/node/review/reviews.ts +0 -422
- package/src/node/service.ts +0 -237
- package/src/node/session.ts +0 -123
- package/src/node/source.ts +0 -148
- package/templates/document/default/lecture.md +0 -18
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto'
|
|
2
|
+
import { jsonResponse, prepareTranscriptionAdoption, ProductionAudioRefusal } from '@lincyaw/kernel'
|
|
3
|
+
import { validateTranscriptionAdoptionApply, validateTranscriptionAdoptionDraft } from '@lincyaw/kernel/node'
|
|
4
|
+
import type { CanonicalSource, DocumentChange, DocumentTranscriptionAdoption, DocumentTranscriptions, Member, Route, TranscriptionAdoptionAnswer, TranscriptionAdoptionApply, TranscriptionAdoptionDraft, TranscriptionAdoptionPlan } from '@lincyaw/kernel'
|
|
5
|
+
import type { DocumentEditor } from './document-authoring.ts'
|
|
6
|
+
import { catchRequestErrors, RequestError } from './errors.ts'
|
|
7
|
+
|
|
8
|
+
interface Receipt { document: string, member: string, key: string, draft: Pick<TranscriptionAdoptionDraft, 'source' | 'candidate' | 'draftRevision'>, change: DocumentChange, expires: number, bytes: number, applied: boolean }
|
|
9
|
+
const sameSource = (a: CanonicalSource, b: CanonicalSource) => a.documentId === b.documentId && a.version === b.version && a.fingerprint === b.fingerprint
|
|
10
|
+
const sameCandidate = (a: { id: string, revision: string }, b: { id: string, revision: string }) => a.id === b.id && a.revision === b.revision
|
|
11
|
+
const stable = (value: unknown): string => JSON.stringify(value, (_, child) => child && typeof child === 'object' && !Array.isArray(child) ? Object.fromEntries(Object.entries(child).sort(([a], [b]) => a.localeCompare(b))) : child)
|
|
12
|
+
|
|
13
|
+
/** One preview and the existing document transaction, with separate trusted-editor and agent write paths. */
|
|
14
|
+
export function createTranscriptionAdoption({ documentEditor, transcriptions }: { documentEditor: DocumentEditor, transcriptions: DocumentTranscriptions }): { service: DocumentTranscriptionAdoption, routes: Record<string, Route>, close(): void } {
|
|
15
|
+
const receipts = new Map<string, Receipt>(), latest = new Map<string, { revision: number, digest: string }>()
|
|
16
|
+
let bytes = 0, closed = false
|
|
17
|
+
const available = () => { if (closed) throw new RequestError(503, 'adoption-closed') }
|
|
18
|
+
const assertLatest = (receipt: Receipt) => {
|
|
19
|
+
available()
|
|
20
|
+
if (latest.get(receipt.key)?.revision !== receipt.draft.draftRevision) throw new RequestError(409, 'adoption-draft-changed')
|
|
21
|
+
}
|
|
22
|
+
const preflight = async (document: string, raw: TranscriptionAdoptionApply, member: Member) => {
|
|
23
|
+
available()
|
|
24
|
+
if (!validateTranscriptionAdoptionApply(raw)) throw new RequestError(400, 'adoption-request-invalid')
|
|
25
|
+
const receipt = receipts.get(raw.planId)
|
|
26
|
+
if (!receipt || receipt.document !== document || receipt.member !== member.id) throw new RequestError(409, 'adoption-plan-missing')
|
|
27
|
+
if (raw.draftRevision !== receipt.draft.draftRevision || !sameSource(raw.source, receipt.draft.source) || !sameCandidate(raw.candidate, receipt.draft.candidate)) throw new RequestError(409, 'adoption-plan-changed')
|
|
28
|
+
assertLatest(receipt)
|
|
29
|
+
if (!receipt.applied) {
|
|
30
|
+
if (Date.now() > receipt.expires) throw new RequestError(409, 'adoption-plan-expired')
|
|
31
|
+
await transcriptions.verifyResult(document, raw.candidate.id, raw.candidate.revision, raw.source, member)
|
|
32
|
+
assertLatest(receipt)
|
|
33
|
+
}
|
|
34
|
+
return receipt
|
|
35
|
+
}
|
|
36
|
+
const apply = async (document: string, raw: TranscriptionAdoptionApply, member: Member, origin: 'editor' | 'agent'): Promise<TranscriptionAdoptionAnswer> => {
|
|
37
|
+
const receipt = await preflight(document, raw, member)
|
|
38
|
+
// Origin is selected by the host closure. No request field can choose the editor path.
|
|
39
|
+
const answer = origin === 'agent'
|
|
40
|
+
? await documentEditor.apply(document, { planId: raw.planId, change: receipt.change }, member)
|
|
41
|
+
: await documentEditor.change(document, receipt.change, member)
|
|
42
|
+
receipt.applied = true
|
|
43
|
+
return { ...answer, adoption: { planId: raw.planId, candidate: structuredClone(raw.candidate), draftRevision: raw.draftRevision } }
|
|
44
|
+
}
|
|
45
|
+
const service: DocumentTranscriptionAdoption = {
|
|
46
|
+
async plan(document, raw, member) {
|
|
47
|
+
available()
|
|
48
|
+
if (!validateTranscriptionAdoptionDraft(raw)) throw new RequestError(400, 'adoption-draft-invalid')
|
|
49
|
+
if (Buffer.byteLength(JSON.stringify(raw)) > 2 << 20) throw new RequestError(413, 'adoption-draft-size')
|
|
50
|
+
const draft = structuredClone(raw), key = JSON.stringify([document, member.id, draft.id]), digest = createHash('sha256').update(stable(draft)).digest('hex')
|
|
51
|
+
const previous = latest.get(key)
|
|
52
|
+
if (previous && (previous.revision > draft.draftRevision || previous.revision === draft.draftRevision && previous.digest !== digest)) throw new RequestError(409, 'adoption-draft-changed')
|
|
53
|
+
latest.set(key, { revision: draft.draftRevision, digest })
|
|
54
|
+
while (latest.size > 128) latest.delete(latest.keys().next().value!)
|
|
55
|
+
const current = await documentEditor.read(document, member)
|
|
56
|
+
if (!sameSource({ documentId: current.content.id, version: current.version, fingerprint: current.fingerprint }, draft.source)) throw new RequestError(409, 'document-version-changed')
|
|
57
|
+
const candidate = await transcriptions.verifyResult(document, draft.candidate.id, draft.candidate.revision, draft.source, member)
|
|
58
|
+
const prepared = prepareTranscriptionAdoption(current.content, candidate, draft)
|
|
59
|
+
const response: TranscriptionAdoptionPlan = { document, changeId: draft.id, source: draft.source, candidate: draft.candidate, draftRevision: draft.draftRevision, ...prepared }
|
|
60
|
+
if (prepared.conflicts.length || !prepared.content) return response
|
|
61
|
+
if (latest.get(key)?.revision !== draft.draftRevision) throw new RequestError(409, 'adoption-draft-changed')
|
|
62
|
+
const change: DocumentChange = { id: draft.id, baseDocumentVersion: draft.source.version, replacement: prepared.content }
|
|
63
|
+
const plan = await documentEditor.plan(document, change, member)
|
|
64
|
+
available()
|
|
65
|
+
if (latest.get(key)?.revision !== draft.draftRevision) throw new RequestError(409, 'adoption-draft-changed')
|
|
66
|
+
const size = Buffer.byteLength(JSON.stringify(change))
|
|
67
|
+
receipts.set(plan.planId, { document, member: member.id, key, draft: { source: draft.source, candidate: draft.candidate, draftRevision: draft.draftRevision }, change, expires: Date.parse(plan.expiresAt), bytes: size, applied: false })
|
|
68
|
+
bytes += size
|
|
69
|
+
while (receipts.size > 64 || bytes > 16 << 20) { const id = receipts.keys().next().value!; bytes -= receipts.get(id)!.bytes; receipts.delete(id) }
|
|
70
|
+
return { ...response, planId: plan.planId, expiresAt: plan.expiresAt, content: plan.content }
|
|
71
|
+
},
|
|
72
|
+
async check(document, request, member) {
|
|
73
|
+
const receipt = await preflight(document, request, member)
|
|
74
|
+
await documentEditor.checkApply(document, { planId: request.planId, change: receipt.change }, member)
|
|
75
|
+
},
|
|
76
|
+
apply: (document, request, member) => apply(document, request, member, 'agent'),
|
|
77
|
+
}
|
|
78
|
+
const guarded = (run: () => Promise<unknown>) => catchRequestErrors(async () => {
|
|
79
|
+
try { return jsonResponse(await run()) }
|
|
80
|
+
catch (error) { if (error instanceof ProductionAudioRefusal) throw new RequestError(error.status, error.code); throw error }
|
|
81
|
+
})
|
|
82
|
+
return {
|
|
83
|
+
service,
|
|
84
|
+
routes: {
|
|
85
|
+
'document/transcription/adoption/plan': { role: 'editor', maxBody: 2 << 20, handle: (request, ctx) => guarded(async () => {
|
|
86
|
+
if (request.method !== 'POST') throw new RequestError(405, 'POST')
|
|
87
|
+
return service.plan(ctx.document, await request.json().catch(() => undefined), ctx.member)
|
|
88
|
+
}) },
|
|
89
|
+
'document/transcription/adoption/apply': { role: 'editor', maxBody: 8 << 10, handle: (request, ctx) => guarded(async () => {
|
|
90
|
+
if (request.method !== 'POST') throw new RequestError(405, 'POST')
|
|
91
|
+
return apply(ctx.document, await request.json().catch(() => undefined), ctx.member, 'editor')
|
|
92
|
+
}) },
|
|
93
|
+
},
|
|
94
|
+
close() { closed = true; receipts.clear(); latest.clear(); bytes = 0 },
|
|
95
|
+
}
|
|
96
|
+
}
|
|
@@ -0,0 +1,471 @@
|
|
|
1
|
+
import { createHash, randomUUID } from 'node:crypto'
|
|
2
|
+
import { constants } from 'node:fs'
|
|
3
|
+
import { lstat, mkdir, open, opendir, realpath, rename, unlink } from 'node:fs/promises'
|
|
4
|
+
import { dirname, join, relative } from 'node:path'
|
|
5
|
+
import { setImmediate } from 'node:timers/promises'
|
|
6
|
+
import { DOCUMENT_ENTRY, fnv1a64, hasRole, isRecord, jsonResponse, ProductionAudioRefusal, TRANSCRIPTION_REQUEST_MAX_BYTES } from '@lincyaw/kernel'
|
|
7
|
+
import { productionAssetFile, validateDocumentTranscriptionRequest } from '@lincyaw/kernel/node'
|
|
8
|
+
import type { CanonicalSource, DocumentTranscriptions, Finding, Member, ProductionAudioAuthoring, RequestContext, Route, SpeechEngineInfo, SpeechService, Transcript, DocumentTranscriptionRequest, TranscriptionListAnswer, TranscriptionRevision, TranscriptionTask } from '@lincyaw/kernel'
|
|
9
|
+
import { readAuthoringDocument } from './document-editor.ts'
|
|
10
|
+
import { catchRequestErrors, RequestError } from './errors.ts'
|
|
11
|
+
import type { SourceEditor } from './source-editor.ts'
|
|
12
|
+
|
|
13
|
+
export interface TranscriptionServices { audio?: ProductionAudioAuthoring, speech?: SpeechService, findings?: Finding[] }
|
|
14
|
+
interface Options extends TranscriptionServices {
|
|
15
|
+
root: string
|
|
16
|
+
editor: SourceEditor
|
|
17
|
+
/** Assemble Node services for this exact source without compiling scenes or executing passes. */
|
|
18
|
+
services?(document: string, source: CanonicalSource): Promise<TranscriptionServices>
|
|
19
|
+
}
|
|
20
|
+
interface RecordEntry { format: 1, document: string, documentId: string, member: string, intent: string, payload?: string, cancelled?: true, task?: TranscriptionTask }
|
|
21
|
+
interface Active { record: RecordEntry, directory: string, controller: AbortController, saving: Promise<void>, finished?: Promise<void> }
|
|
22
|
+
const MAX_AUDIO = 16 << 20, MAX_RESULT = 2 << 20, MAX_TASKS = 1024
|
|
23
|
+
const hash = (value: string | Uint8Array) => createHash('sha256').update(value).digest('hex')
|
|
24
|
+
const idValid = (value: unknown): value is string => typeof value === 'string' && /^[a-f\d]{64}$/.test(value)
|
|
25
|
+
const intentValid = (value: unknown): value is string => typeof value === 'string' && /^[A-Za-z\d_-]{16,128}$/.test(value)
|
|
26
|
+
const sameSource = (a: CanonicalSource, b: CanonicalSource) => a.documentId === b.documentId && a.version === b.version && a.fingerprint === b.fingerprint
|
|
27
|
+
const now = () => new Date().toISOString()
|
|
28
|
+
const ctxOf = (document: string, member: Member): RequestContext => ({ document, member, rest: '' })
|
|
29
|
+
|
|
30
|
+
async function readBounded(file: string, limit: number): Promise<Uint8Array> {
|
|
31
|
+
const handle = await open(file, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK).catch(error => {
|
|
32
|
+
if (error.code === 'ELOOP') throw new RequestError(403, 'transcription-storage-path')
|
|
33
|
+
throw error
|
|
34
|
+
})
|
|
35
|
+
try {
|
|
36
|
+
const stat = await handle.stat()
|
|
37
|
+
if (!stat.isFile()) throw new RequestError(403, 'transcription-storage-path')
|
|
38
|
+
if (stat.size > limit) throw new RequestError(413, 'transcription-file-size')
|
|
39
|
+
const bytes = new Uint8Array(stat.size + 1)
|
|
40
|
+
let length = 0
|
|
41
|
+
while (length < bytes.length) {
|
|
42
|
+
const read = await handle.read(bytes, length, bytes.length - length, length)
|
|
43
|
+
if (!read.bytesRead) break
|
|
44
|
+
length += read.bytesRead
|
|
45
|
+
}
|
|
46
|
+
if (length > stat.size) throw new RequestError(409, 'transcription-file-changed')
|
|
47
|
+
return bytes.slice(0, length)
|
|
48
|
+
} finally { await handle.close() }
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
async function directory(path: string, create: boolean): Promise<void> {
|
|
52
|
+
if (create) await mkdir(path).catch(error => { if (error.code !== 'EEXIST') throw error })
|
|
53
|
+
const stat = await lstat(path)
|
|
54
|
+
if (!stat.isDirectory() || stat.isSymbolicLink() || await realpath(path) !== path) throw new RequestError(403, 'transcription-storage-path')
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
async function taskNames(path: string, limit: number): Promise<string[]> {
|
|
58
|
+
const names: string[] = []
|
|
59
|
+
for await (const entry of await opendir(path)) { names.push(entry.name); if (names.length >= limit) break }
|
|
60
|
+
return names
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
async function writeJson(path: string, value: unknown, exclusive = false): Promise<void> {
|
|
64
|
+
const text = JSON.stringify(value)
|
|
65
|
+
if (Buffer.byteLength(text) > MAX_RESULT) throw new RequestError(413, 'transcription-result-size')
|
|
66
|
+
const target = exclusive ? path : `${path}.${randomUUID()}.tmp`
|
|
67
|
+
const file = await open(target, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW, 0o600)
|
|
68
|
+
try { await file.writeFile(text); await file.sync() }
|
|
69
|
+
catch (error) { await unlink(target).catch(() => undefined); throw error }
|
|
70
|
+
finally { await file.close() }
|
|
71
|
+
if (!exclusive) {
|
|
72
|
+
try { await rename(target, path) }
|
|
73
|
+
catch (error) { await unlink(target).catch(() => undefined); throw error }
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Recognition is an immutable candidate. Only a later, separate authoring action may adopt it. */
|
|
78
|
+
export function createDocumentTranscriptions({ root, editor, audio, speech, services }: Options): DocumentTranscriptions & { close(): Promise<void> } {
|
|
79
|
+
const active = new Map<string, Active>()
|
|
80
|
+
const admitting = new Map<string, { document: string, documentId: string, member: string, intent: string, controller: AbortController, finished: Promise<void> }>()
|
|
81
|
+
let closed = false
|
|
82
|
+
const authorize = (document: string, member: Member) => {
|
|
83
|
+
if (!hasRole(member, 'editor')) throw new RequestError(403, 'transcription-permission-denied')
|
|
84
|
+
if (closed) throw new RequestError(503, 'transcription-closed')
|
|
85
|
+
// Cancelling already-admitted work needs file permission, not valid editable JSON.
|
|
86
|
+
return editor.readableFile(editor.scope(document), document ? `${document}/${DOCUMENT_ENTRY}` : DOCUMENT_ENTRY, member)
|
|
87
|
+
}
|
|
88
|
+
const opened = (document: string, member: Member) => {
|
|
89
|
+
authorize(document, member)
|
|
90
|
+
const current = readAuthoringDocument(editor, ctxOf(document, member))
|
|
91
|
+
return { ...current, binding: { documentId: current.content.id, version: current.source.version, fingerprint: fnv1a64(current.source.content) } }
|
|
92
|
+
}
|
|
93
|
+
const servicesOf = (document: string, source: CanonicalSource) => services ? services(document, source) : Promise.resolve({ audio, speech })
|
|
94
|
+
const capabilityOf = async (source: CanonicalSource, resolved: TranscriptionServices) => {
|
|
95
|
+
const engines = resolved.speech ? (await resolved.speech.engines()).filter(engine => engine.stt) : []
|
|
96
|
+
const available = Boolean(resolved.audio && engines.length)
|
|
97
|
+
const unknown = !available && resolved.findings?.some(finding => finding.severity === 'error')
|
|
98
|
+
return { source, available, status: available ? 'available' as const : unknown ? 'unknown' as const : 'unavailable' as const, ...unknown ? { reason: 'services-unavailable' as const } : !resolved.audio ? { reason: 'audio-unavailable' as const } : !resolved.speech ? { reason: 'speech-unavailable' as const } : !engines.length ? { reason: 'engine-unavailable' as const } : {}, verification: 'configuration-only' as const, ...resolved.findings?.length ? { findings: resolved.findings } : {}, engines, range: 'whole-asset' as const, limits: { audioBytes: MAX_AUDIO, durationMs: 600_000, tasks: MAX_TASKS, requestBytes: TRANSCRIPTION_REQUEST_MAX_BYTES } }
|
|
99
|
+
}
|
|
100
|
+
const taskId = (document: string, documentId: string, member: Member, intent: string) => hash(JSON.stringify([document, documentId, member.id, intent]))
|
|
101
|
+
const storage = async (document: string, member: Member, id: string, create = false) => {
|
|
102
|
+
if (!idValid(id)) throw new RequestError(400, 'transcription-id-invalid')
|
|
103
|
+
const path = join(dirname(authorize(document, member)), 'transcriptions')
|
|
104
|
+
await directory(path, create)
|
|
105
|
+
const target = join(path, id)
|
|
106
|
+
if (create && !(await lstat(target).catch(() => undefined)) && (await taskNames(path, MAX_TASKS)).length >= MAX_TASKS) throw new RequestError(503, 'transcription-intent-capacity')
|
|
107
|
+
await directory(target, create)
|
|
108
|
+
return target
|
|
109
|
+
}
|
|
110
|
+
const load = async (document: string, member: Member, id: string): Promise<{ record: RecordEntry, directory: string } | undefined> => {
|
|
111
|
+
try {
|
|
112
|
+
const target = await storage(document, member, id)
|
|
113
|
+
const text = new TextDecoder().decode(await readBounded(join(target, 'task.json'), MAX_RESULT))
|
|
114
|
+
let record: RecordEntry
|
|
115
|
+
try { record = JSON.parse(text) } catch { throw new RequestError(409, 'transcription-record-invalid') }
|
|
116
|
+
if (!isRecord(record) || record.format !== 1 || record.document !== document || typeof record.documentId !== 'string' || typeof record.member !== 'string' || !intentValid(record.intent)
|
|
117
|
+
|| hash(JSON.stringify([record.document, record.documentId, record.member, record.intent])) !== id
|
|
118
|
+
|| record.task && (!isRecord(record.task) || record.task.kind !== 'transcription' || record.task.id !== id || record.task.document !== document || record.task.intent !== record.intent || typeof record.task.createdAt !== 'string'
|
|
119
|
+
|| !isRecord(record.task.source) || record.task.source.documentId !== record.documentId || typeof record.task.source.version !== 'string' || !/^[a-f\d]{20}$/.test(record.task.source.version) || typeof record.task.source.fingerprint !== 'string' || !/^[a-f\d]{16}$/.test(record.task.source.fingerprint)
|
|
120
|
+
|| !isRecord(record.task.audio) || typeof record.task.audio.assetId !== 'string' || !idValid(record.task.audio.sha256) || !Number.isSafeInteger(record.task.sequence) || record.task.sequence < 0
|
|
121
|
+
|| !['queued', 'running', 'cancelling', 'cancelled', 'done', 'failed'].includes(record.task.state) || !isRecord(record.task.progress) || !['queued', 'recognizing', 'saving', 'finished'].includes(record.task.progress.phase)
|
|
122
|
+
|| record.task.state === 'done' && !isRecord(record.task.result)
|
|
123
|
+
|| record.task.result !== undefined && (!isRecord(record.task.result) || record.task.result.transcriptionId !== id || !idValid(record.task.result.revision)))) throw new RequestError(409, 'transcription-record-invalid')
|
|
124
|
+
return { record, directory: target }
|
|
125
|
+
} catch (error) { if ((error as NodeJS.ErrnoException).code === 'ENOENT') return undefined; throw error }
|
|
126
|
+
}
|
|
127
|
+
const persist = (entry: Active) => {
|
|
128
|
+
const snapshot = structuredClone(entry.record)
|
|
129
|
+
entry.saving = entry.saving.catch(() => undefined).then(() => writeJson(join(entry.directory, 'task.json'), snapshot))
|
|
130
|
+
return entry.saving
|
|
131
|
+
}
|
|
132
|
+
const change = (entry: Active) => { entry.record.task!.sequence++; return persist(entry) }
|
|
133
|
+
type Observation = { current?: ReturnType<typeof opened>, audio: Map<string, Promise<boolean>> }
|
|
134
|
+
const observe = (document: string, member: Member): Observation => {
|
|
135
|
+
try { return { current: opened(document, member), audio: new Map() } }
|
|
136
|
+
catch (error) { if (error instanceof RequestError && error.status === 422) return { audio: new Map() }; throw error }
|
|
137
|
+
}
|
|
138
|
+
const present = async (document: string, member: Member, task: TranscriptionTask, observation?: Observation): Promise<TranscriptionTask> => {
|
|
139
|
+
authorize(document, member)
|
|
140
|
+
const shown = structuredClone(task)
|
|
141
|
+
// A journal from an interrupted owner is not permission to repeat a spending request.
|
|
142
|
+
if (!active.has(task.id) && ['queued', 'running', 'cancelling'].includes(shown.state)) { shown.state = 'failed'; shown.error = 'transcription-interrupted' }
|
|
143
|
+
try {
|
|
144
|
+
const current = observation ? observation.current : opened(document, member)
|
|
145
|
+
if (!current) { shown.current = false; shown.staleReason = 'document-unavailable'; return shown }
|
|
146
|
+
shown.current = sameSource(current.binding, shown.source)
|
|
147
|
+
if (!shown.current) shown.staleReason = 'document-changed'
|
|
148
|
+
else {
|
|
149
|
+
const key = JSON.stringify([shown.audio.assetId, shown.audio.sha256])
|
|
150
|
+
let checked = observation?.audio.get(key)
|
|
151
|
+
if (!checked) {
|
|
152
|
+
checked = (async () => {
|
|
153
|
+
const asset = current.content.assets[shown.audio.assetId]
|
|
154
|
+
const path = asset?.kind === 'audio' && productionAssetFile(join(root, document), asset.location)
|
|
155
|
+
const file = path && editor.readableFile(editor.scope(document), relative(root, path).split('\\').join('/'), member)
|
|
156
|
+
return Boolean(file && hash(await readBounded(file, MAX_AUDIO)) === shown.audio.sha256)
|
|
157
|
+
})().catch(() => false)
|
|
158
|
+
observation?.audio.set(key, checked)
|
|
159
|
+
}
|
|
160
|
+
if (!await checked) { shown.current = false; shown.staleReason = 'audio-changed' }
|
|
161
|
+
}
|
|
162
|
+
if (!observation && !sameSource(opened(document, member).binding, shown.source)) { shown.current = false; shown.staleReason = 'document-changed' }
|
|
163
|
+
} catch (error) {
|
|
164
|
+
if (!(error instanceof RequestError) || error.status !== 422) throw error
|
|
165
|
+
shown.current = false; shown.staleReason = 'document-unavailable'
|
|
166
|
+
}
|
|
167
|
+
return shown
|
|
168
|
+
}
|
|
169
|
+
const resolveId = (document: string, member: Member, query: { id: string } | { intent: string }) => {
|
|
170
|
+
authorize(document, member)
|
|
171
|
+
if ('id' in query) { if (!idValid(query.id)) throw new RequestError(400, 'transcription-id-invalid'); return query.id }
|
|
172
|
+
if (!intentValid(query.intent)) throw new RequestError(400, 'transcription-intent-invalid')
|
|
173
|
+
const running = [...active.values()].find(entry => entry.record.document === document && entry.record.member === member.id && entry.record.intent === query.intent)
|
|
174
|
+
if (running) return running.record.task!.id
|
|
175
|
+
const pending = [...admitting.entries()].find(([, entry]) => entry.document === document && entry.member === member.id && entry.intent === query.intent)
|
|
176
|
+
if (pending) return pending[0]
|
|
177
|
+
try { return taskId(document, opened(document, member).content.id, member, query.intent) }
|
|
178
|
+
catch (error) { if (error instanceof RequestError && error.status === 422) throw new RequestError(409, 'transcription-task-id-required'); throw error }
|
|
179
|
+
}
|
|
180
|
+
const lookup = async (document: string, member: Member, query: { id: string } | { intent: string }) => {
|
|
181
|
+
const id = resolveId(document, member, query)
|
|
182
|
+
const entry = active.get(id) ?? await load(document, member, id)
|
|
183
|
+
if (!entry) throw new RequestError(404, 'transcription-task-missing')
|
|
184
|
+
if (entry.record.document !== document) throw new RequestError(409, 'transcription-document-changed')
|
|
185
|
+
return { id, entry }
|
|
186
|
+
}
|
|
187
|
+
const inspect = async (document: string, member: Member, request: DocumentTranscriptionRequest, signal?: AbortSignal) => {
|
|
188
|
+
const current = opened(document, member)
|
|
189
|
+
if (!sameSource(current.binding, request.source)) throw new RequestError(409, 'document-version-changed')
|
|
190
|
+
const asset = current.content.assets[request.assetId]
|
|
191
|
+
if (!asset || asset.kind !== 'audio') throw new RequestError(422, 'transcription-audio-required')
|
|
192
|
+
const path = productionAssetFile(join(root, document), asset.location)
|
|
193
|
+
if (!path) throw new RequestError(422, 'production.asset-path')
|
|
194
|
+
const file = editor.readableFile(editor.scope(document), relative(root, path).split('\\').join('/'), member)
|
|
195
|
+
const bytes = await readBounded(file, MAX_AUDIO)
|
|
196
|
+
if (!bytes.length || hash(bytes) !== request.audioSha256 || asset.fingerprint !== undefined && asset.fingerprint !== `sha256:${request.audioSha256}`) throw new RequestError(409, 'transcription-audio-changed')
|
|
197
|
+
const resolved = await servicesOf(document, current.binding)
|
|
198
|
+
if (!resolved.audio) throw new RequestError(503, 'transcription-audio-unavailable')
|
|
199
|
+
const { inspection } = await resolved.audio.inspectBytes(asset.id, bytes, signal)
|
|
200
|
+
signal?.throwIfAborted()
|
|
201
|
+
if (inspection.sha256 !== request.audioSha256) throw new RequestError(409, 'transcription-audio-changed')
|
|
202
|
+
if (!Number.isFinite(inspection.durationMs) || inspection.durationMs <= 0 || inspection.durationMs > 600_000) throw new RequestError(422, 'production.asset-duration')
|
|
203
|
+
if (!sameSource(opened(document, member).binding, request.source)) throw new RequestError(409, 'document-version-changed')
|
|
204
|
+
return { bytes, inspection, services: resolved }
|
|
205
|
+
}
|
|
206
|
+
const capabilities: DocumentTranscriptions['capabilities'] = async (document, member) => {
|
|
207
|
+
const current = opened(document, member)
|
|
208
|
+
const capability = await capabilityOf(current.binding, await servicesOf(document, current.binding))
|
|
209
|
+
if (!sameSource(opened(document, member).binding, current.binding)) throw new RequestError(409, 'document-version-changed')
|
|
210
|
+
return capability
|
|
211
|
+
}
|
|
212
|
+
/** Everything start checks before it stores a task; a replay of the same intent returns its recorded task. */
|
|
213
|
+
const admission = async (document: string, request: DocumentTranscriptionRequest, member: Member, signal?: AbortSignal) => {
|
|
214
|
+
if (!validateDocumentTranscriptionRequest(request)) throw new RequestError(400, 'transcription-request-invalid')
|
|
215
|
+
if (Buffer.byteLength(JSON.stringify(request)) > TRANSCRIPTION_REQUEST_MAX_BYTES) throw new RequestError(413, 'transcription-request-size')
|
|
216
|
+
const current = opened(document, member), id = taskId(document, current.content.id, member, request.intent), payload = hash(JSON.stringify([request.source.documentId, request.source.version, request.source.fingerprint, request.assetId, request.audioSha256, request.engine, request.lang ?? null, request.hints ?? []]))
|
|
217
|
+
const previous = active.get(id) ?? await load(document, member, id)
|
|
218
|
+
if (previous) {
|
|
219
|
+
if (previous.record.cancelled) throw new RequestError(409, 'transcription-intent-cancelled')
|
|
220
|
+
if (previous.record.payload !== payload) throw new RequestError(409, 'transcription-intent-reused')
|
|
221
|
+
const task = previous.record.task
|
|
222
|
+
if (!task) throw new RequestError(409, 'transcription-record-invalid')
|
|
223
|
+
return { current, id, payload, previous: task }
|
|
224
|
+
}
|
|
225
|
+
const input = await inspect(document, member, request, signal)
|
|
226
|
+
const capability = await capabilityOf(request.source, input.services)
|
|
227
|
+
const engine = capability.engines.find(engine => engine.id === request.engine)
|
|
228
|
+
if (!engine) {
|
|
229
|
+
// Asking for an engine that is configured but does not recognize speech, or is not configured, is a bad request; only unresolved speech services may come back.
|
|
230
|
+
const speech = capability.status === 'unknown' ? undefined : input.services.speech
|
|
231
|
+
if (!speech) throw new RequestError(503, 'transcription-engine-unavailable')
|
|
232
|
+
const named = (await speech.engines()).some(own => own.id === request.engine)
|
|
233
|
+
throw new RequestError(422, named ? 'transcription-engine-unsupported' : 'transcription-engine-unknown', { engine: request.engine, engines: capability.engines.map(own => own.id) })
|
|
234
|
+
}
|
|
235
|
+
signal?.throwIfAborted()
|
|
236
|
+
if (!sameSource(opened(document, member).binding, request.source)) throw new RequestError(409, 'document-version-changed')
|
|
237
|
+
if ([...active.values()].some(entry => entry.record.document === document) || [...admitting.values()].some(entry => entry.document === document)) throw new RequestError(409, 'transcription-busy')
|
|
238
|
+
return { current, id, payload, input, engine }
|
|
239
|
+
}
|
|
240
|
+
const run = async (entry: Active, request: DocumentTranscriptionRequest, engine: SpeechEngineInfo, input: Awaited<ReturnType<typeof inspect>>) => {
|
|
241
|
+
const task = entry.record.task!, signal = entry.controller.signal
|
|
242
|
+
try {
|
|
243
|
+
await setImmediate()
|
|
244
|
+
signal.throwIfAborted()
|
|
245
|
+
task.state = 'running'; task.startedAt = now(); task.progress.phase = 'recognizing'; await change(entry)
|
|
246
|
+
signal.throwIfAborted()
|
|
247
|
+
const transcript = await input.services.speech!.transcribe({ engine: engine.id, audio: new Blob([new Uint8Array(input.bytes)], { type: input.inspection.mediaType }), lang: request.lang, hints: request.hints }, signal)
|
|
248
|
+
signal.throwIfAborted()
|
|
249
|
+
const result = normalizeTranscript(transcript, input.inspection.durationMs, task.id)
|
|
250
|
+
task.progress.phase = 'saving'; await change(entry)
|
|
251
|
+
signal.throwIfAborted()
|
|
252
|
+
const candidate = { transcriptionId: task.id, createdAt: now(), source: request.source, audio: { assetId: request.assetId, sha256: request.audioSha256, sourceRange: { startMs: 0, endMs: input.inspection.durationMs } }, request: { engine: engine.id, provider: engine.provider, local: engine.local, ...request.lang ? { lang: request.lang } : {}, hints: request.hints ?? [], fingerprint: entry.record.payload! }, ...result }
|
|
253
|
+
const revision = hash(JSON.stringify(candidate))
|
|
254
|
+
await writeJson(join(entry.directory, `${revision}.json`), { ...candidate, revision }, true)
|
|
255
|
+
task.result = { transcriptionId: task.id, revision }
|
|
256
|
+
signal.throwIfAborted()
|
|
257
|
+
task.state = 'done'
|
|
258
|
+
} catch (error) {
|
|
259
|
+
task.state = signal.aborted ? 'cancelled' : 'failed'
|
|
260
|
+
if (!signal.aborted) task.error = error instanceof RequestError ? error.message : 'transcription-provider-failed'
|
|
261
|
+
} finally {
|
|
262
|
+
task.finishedAt = now(); task.progress.phase = 'finished'
|
|
263
|
+
await change(entry).catch(() => { task.state = 'failed'; task.error = 'transcription-storage-failed' })
|
|
264
|
+
// Retain the task record, not its audio bytes or AbortController, after completion.
|
|
265
|
+
active.delete(task.id)
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
return {
|
|
269
|
+
capabilities,
|
|
270
|
+
async list(document, member): Promise<TranscriptionListAnswer> {
|
|
271
|
+
const path = join(dirname(authorize(document, member)), 'transcriptions'), observation = observe(document, member)
|
|
272
|
+
let stored: string[] = []
|
|
273
|
+
try { await directory(path, false); stored = await taskNames(path, MAX_TASKS + 1) }
|
|
274
|
+
catch (error) { if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error }
|
|
275
|
+
const all = [...new Set([...active.values()].filter(entry => entry.record.document === document).map(entry => entry.record.task!.id).concat(stored.sort()))]
|
|
276
|
+
const ids = all.slice(0, MAX_TASKS), answer: TranscriptionListAnswer = { tasks: [], issues: [], ...all.length > MAX_TASKS ? { truncated: true } : {} }
|
|
277
|
+
let index = 0, bytes = 0
|
|
278
|
+
await Promise.all(Array.from({ length: Math.min(4, ids.length) }, async () => {
|
|
279
|
+
while (index < ids.length) {
|
|
280
|
+
const id = ids[index++]!
|
|
281
|
+
if (!idValid(id)) { answer.issues.push({ id, code: 'transcription-storage-entry-invalid' }); continue }
|
|
282
|
+
try {
|
|
283
|
+
const entry = active.get(id) ?? await load(document, member, id)
|
|
284
|
+
if (!entry) { if (admitting.get(id)?.document !== document) answer.issues.push({ id, code: 'transcription-record-missing' }); continue }
|
|
285
|
+
if (!entry.record.task) { if (!entry.record.cancelled) answer.issues.push({ id, code: 'transcription-record-invalid' }); continue }
|
|
286
|
+
const task = await present(document, member, entry.record.task, observation), size = Buffer.byteLength(JSON.stringify(task))
|
|
287
|
+
if (bytes + size > MAX_RESULT) { answer.truncated = true; answer.issues.push({ id, code: 'transcription-list-size' }); continue }
|
|
288
|
+
bytes += size; answer.tasks.push(task)
|
|
289
|
+
} catch (error) {
|
|
290
|
+
if (!(error instanceof RequestError)) throw error
|
|
291
|
+
answer.issues.push({ id, code: error.message })
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
}))
|
|
295
|
+
const latest = observe(document, member)
|
|
296
|
+
if (!latest.current || !observation.current || !sameSource(latest.current.binding, observation.current.binding)) for (const task of answer.tasks) {
|
|
297
|
+
task.current = false; task.staleReason = latest.current ? 'document-changed' : 'document-unavailable'
|
|
298
|
+
}
|
|
299
|
+
answer.tasks.sort((a, b) => b.createdAt.localeCompare(a.createdAt) || a.id.localeCompare(b.id))
|
|
300
|
+
answer.issues.sort((a, b) => a.id.localeCompare(b.id))
|
|
301
|
+
// Account for issues and JSON framing too, without repeatedly serializing the whole list.
|
|
302
|
+
let size = Buffer.byteLength(JSON.stringify(answer))
|
|
303
|
+
if (size > MAX_RESULT) {
|
|
304
|
+
answer.truncated = true; size = Buffer.byteLength(JSON.stringify(answer))
|
|
305
|
+
while (size > MAX_RESULT && (answer.tasks.length || answer.issues.length)) {
|
|
306
|
+
const items = answer.tasks.length ? answer.tasks : answer.issues
|
|
307
|
+
const removed = items.pop()!
|
|
308
|
+
size -= Buffer.byteLength(JSON.stringify(removed)) + (items.length ? 1 : 0)
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
return answer
|
|
312
|
+
},
|
|
313
|
+
async checkStart(document, request, member, signal) { await admission(document, request, member, signal) },
|
|
314
|
+
async checkCancel(document, query, member) {
|
|
315
|
+
const id = resolveId(document, member, query)
|
|
316
|
+
if (admitting.has(id)) return undefined
|
|
317
|
+
const running = active.get(id), entry = running ?? await load(document, member, id)
|
|
318
|
+
if (!entry) { if ('intent' in query) return undefined; throw new RequestError(404, 'transcription-task-missing') }
|
|
319
|
+
const task = entry.record.task
|
|
320
|
+
// A journal left queued or running by an interrupted owner is still corrected by cancel.
|
|
321
|
+
if (task && (running ? task.state === 'queued' || task.state === 'running' : ['queued', 'running', 'cancelling'].includes(task.state))) return undefined
|
|
322
|
+
return { intent: entry.record.intent, state: task?.state ?? 'cancelled', ...task ? { task: await present(document, member, task) } : {} }
|
|
323
|
+
},
|
|
324
|
+
async start(document, request, member, signal) {
|
|
325
|
+
const admitted = await admission(document, request, member, signal)
|
|
326
|
+
if (admitted.previous) return present(document, member, admitted.previous)
|
|
327
|
+
const { current, id, payload, input, engine } = admitted
|
|
328
|
+
const controller = new AbortController()
|
|
329
|
+
let finished!: () => void
|
|
330
|
+
admitting.set(id, { document, documentId: current.content.id, member: member.id, intent: request.intent, controller, finished: new Promise<void>(resolve => { finished = resolve }) })
|
|
331
|
+
try {
|
|
332
|
+
const target = await storage(document, member, id, true)
|
|
333
|
+
if (!sameSource(opened(document, member).binding, request.source)) throw new RequestError(409, 'document-version-changed')
|
|
334
|
+
const task: TranscriptionTask = { kind: 'transcription', id, intent: request.intent, document, source: request.source, audio: { assetId: request.assetId, sha256: request.audioSha256 }, sequence: 0, state: 'queued', current: true, progress: { phase: 'queued' }, createdAt: now() }
|
|
335
|
+
const entry: Active = { record: { format: 1, document, documentId: current.content.id, member: member.id, intent: request.intent, payload, task }, directory: target, controller, saving: Promise.resolve() }
|
|
336
|
+
try { await writeJson(join(target, 'task.json'), entry.record, true) }
|
|
337
|
+
catch (error) {
|
|
338
|
+
if ((error as NodeJS.ErrnoException).code === 'EEXIST') {
|
|
339
|
+
const existing = await load(document, member, id)
|
|
340
|
+
if (existing?.record.cancelled) throw new RequestError(409, 'transcription-intent-cancelled')
|
|
341
|
+
if (existing?.record.payload !== payload || !existing.record.task) throw new RequestError(409, 'transcription-intent-reused')
|
|
342
|
+
return present(document, member, existing.record.task)
|
|
343
|
+
}
|
|
344
|
+
throw error
|
|
345
|
+
}
|
|
346
|
+
if (closed || signal?.aborted || controller.signal.aborted) { controller.abort(); task.state = 'cancelled'; task.finishedAt = now(); task.progress.phase = 'finished'; await change(entry); return structuredClone(task) }
|
|
347
|
+
active.set(id, entry)
|
|
348
|
+
const accepted = structuredClone(task)
|
|
349
|
+
entry.finished = run(entry, structuredClone(request), engine, input)
|
|
350
|
+
void entry.finished.catch(() => undefined)
|
|
351
|
+
return accepted
|
|
352
|
+
} finally { admitting.delete(id); finished() }
|
|
353
|
+
},
|
|
354
|
+
async get(document, query, member) {
|
|
355
|
+
const { entry } = await lookup(document, member, query)
|
|
356
|
+
if (!entry.record.task) throw new RequestError(409, 'transcription-intent-cancelled')
|
|
357
|
+
return present(document, member, entry.record.task)
|
|
358
|
+
},
|
|
359
|
+
async readResult(document, id, revision, member) {
|
|
360
|
+
if (!idValid(revision)) throw new RequestError(400, 'transcription-revision-invalid')
|
|
361
|
+
const { entry } = await lookup(document, member, { id })
|
|
362
|
+
if (entry.record.task?.result?.revision !== revision) throw new RequestError(404, 'transcription-result-missing')
|
|
363
|
+
const candidate = JSON.parse(new TextDecoder().decode(await readBounded(join(entry.directory, `${revision}.json`), MAX_RESULT))) as TranscriptionRevision
|
|
364
|
+
const { revision: recorded, ...content } = candidate
|
|
365
|
+
if (candidate.transcriptionId !== id || recorded !== revision || hash(JSON.stringify(content)) !== revision) throw new RequestError(409, 'transcription-result-changed')
|
|
366
|
+
return candidate
|
|
367
|
+
},
|
|
368
|
+
async verifyResult(document, id, revision, source, member) {
|
|
369
|
+
const candidate = await this.readResult(document, id, revision, member)
|
|
370
|
+
if (!isRecord(candidate.source) || candidate.source.documentId !== source.documentId || !isRecord(candidate.audio) || typeof candidate.audio.assetId !== 'string' || !idValid(candidate.audio.sha256)
|
|
371
|
+
|| !isRecord(candidate.audio.sourceRange) || !isRecord(candidate.result) || typeof candidate.result.text !== 'string' || !Array.isArray(candidate.result.segments)
|
|
372
|
+
|| !candidate.result.segments.length || candidate.result.segments.length > 10_000
|
|
373
|
+
|| candidate.result.segments.some(segment => !isRecord(segment) || typeof segment.id !== 'string' || !segment.id || segment.id.length > 200 || typeof segment.text !== 'string')
|
|
374
|
+
|| new Set(candidate.result.segments.map(segment => segment.id)).size !== candidate.result.segments.length) throw new RequestError(422, 'transcription-result-invalid')
|
|
375
|
+
const { inspection } = await inspect(document, member, { intent: 'result-verification', source, assetId: candidate.audio.assetId, audioSha256: candidate.audio.sha256, engine: '', confirmed: true })
|
|
376
|
+
if (candidate.audio.sourceRange.startMs !== 0 || candidate.audio.sourceRange.endMs !== inspection.durationMs) throw new RequestError(409, 'transcription-audio-changed')
|
|
377
|
+
return candidate
|
|
378
|
+
},
|
|
379
|
+
async cancel(document, query, member) {
|
|
380
|
+
const id = resolveId(document, member, query)
|
|
381
|
+
const pending = admitting.get(id)
|
|
382
|
+
pending?.controller.abort()
|
|
383
|
+
let entry = active.get(id) ?? await load(document, member, id)
|
|
384
|
+
if (!entry) {
|
|
385
|
+
if (!('intent' in query)) throw new RequestError(404, 'transcription-task-missing')
|
|
386
|
+
const current = opened(document, member)
|
|
387
|
+
const target = await storage(document, member, id, true)
|
|
388
|
+
const record: RecordEntry = { format: 1, document, documentId: current.content.id, member: member.id, intent: query.intent, cancelled: true }
|
|
389
|
+
try { await writeJson(join(target, 'task.json'), record, true) }
|
|
390
|
+
catch (error) { if ((error as NodeJS.ErrnoException).code === 'EEXIST') return this.cancel(document, query, member); throw error }
|
|
391
|
+
return { intent: query.intent, state: 'cancelled' }
|
|
392
|
+
}
|
|
393
|
+
const task = entry.record.task
|
|
394
|
+
if (task && ['queued', 'running', 'cancelling'].includes(task.state)) {
|
|
395
|
+
const running = active.get(id)
|
|
396
|
+
task.state = running ? task.state !== 'queued' ? 'cancelling' : 'cancelled' : pending ? 'cancelled' : 'failed'
|
|
397
|
+
running?.controller.abort()
|
|
398
|
+
if (running) await change(running)
|
|
399
|
+
else { if (!pending) task.error = 'transcription-interrupted'; task.finishedAt = now(); task.sequence++; await writeJson(join(entry.directory, 'task.json'), entry.record) }
|
|
400
|
+
}
|
|
401
|
+
return { intent: entry.record.intent, state: task?.state ?? 'cancelled', ...task ? { task: await present(document, member, task) } : {} }
|
|
402
|
+
},
|
|
403
|
+
async close() {
|
|
404
|
+
closed = true
|
|
405
|
+
const entries = [...active.values()]
|
|
406
|
+
for (const entry of entries) entry.controller.abort()
|
|
407
|
+
const admissions = [...admitting.values()]
|
|
408
|
+
for (const entry of admissions) entry.controller.abort()
|
|
409
|
+
await Promise.allSettled([...entries.map(entry => entry.finished), ...admissions.map(entry => entry.finished)])
|
|
410
|
+
active.clear()
|
|
411
|
+
},
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
export function normalizeTranscript(transcript: Transcript, durationMs: number, id: string): Pick<TranscriptionRevision, 'result' | 'timing' | 'warnings'> {
|
|
416
|
+
if (!isRecord(transcript) || typeof transcript.text !== 'string' || typeof transcript.lang !== 'string' || !Array.isArray(transcript.segments) || transcript.segments.length > 10_000 || Buffer.byteLength(JSON.stringify(transcript)) > MAX_RESULT) throw new RequestError(422, 'transcription-result-invalid')
|
|
417
|
+
if (!transcript.segments.length && transcript.text.length) return { result: { lang: transcript.lang, text: transcript.text, segments: [{ id: `${id}:1`, text: transcript.text }] }, timing: 'none', warnings: ['transcription-timing-unavailable'] }
|
|
418
|
+
const warnings = new Set<string>()
|
|
419
|
+
const range = (start: unknown, end: unknown) => typeof start === 'number' && typeof end === 'number' && Number.isFinite(start) && Number.isFinite(end) && start >= 0 && Math.round(end * 1000) > Math.round(start * 1000) && Math.round(end * 1000) <= durationMs ? { startMs: Math.round(start * 1000), endMs: Math.round(end * 1000) } : undefined
|
|
420
|
+
let previousStart = 0
|
|
421
|
+
const segments = transcript.segments.map((segment, index) => {
|
|
422
|
+
if (!isRecord(segment) || typeof segment.text !== 'string' || !Array.isArray(segment.words) || segment.words.length > 100_000) throw new RequestError(422, 'transcription-result-invalid')
|
|
423
|
+
let timing = range(segment.start, segment.end)
|
|
424
|
+
if (timing && timing.startMs < previousStart) timing = undefined
|
|
425
|
+
if (timing) previousStart = timing.startMs
|
|
426
|
+
if (!timing) warnings.add('transcription-timing-unavailable')
|
|
427
|
+
let previousWordEnd = timing?.startMs ?? 0
|
|
428
|
+
const words = segment.words.flatMap(word => {
|
|
429
|
+
const at = isRecord(word) && range(word.start, word.end)
|
|
430
|
+
if (!at || typeof word.text !== 'string' || !word.text.trim() || !timing || at.startMs < previousWordEnd || at.endMs > timing.endMs) { warnings.add('transcription-timing-unavailable'); return [] }
|
|
431
|
+
previousWordEnd = at.endMs
|
|
432
|
+
return [{ text: word.text, ...at }]
|
|
433
|
+
})
|
|
434
|
+
return { id: `${id}:${index + 1}`, text: segment.text, ...timing, ...words.length ? { words } : {} }
|
|
435
|
+
})
|
|
436
|
+
return { result: { lang: transcript.lang, text: transcript.text, segments }, timing: segments.some(segment => segment.words?.length) ? 'words' : segments.some(segment => segment.startMs !== undefined) ? 'segments' : 'none', warnings: [...warnings] }
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
/** Thin HTTP adapter; source/permission/idempotency rules live in the shared service. */
|
|
440
|
+
export function documentTranscriptionRoutes(service: DocumentTranscriptions): Record<string, Route> {
|
|
441
|
+
const query = (value: unknown): { id: string } | { intent: string } => {
|
|
442
|
+
if (!isRecord(value) || Object.keys(value).length !== 1) throw new RequestError(400, 'transcription-query-invalid')
|
|
443
|
+
if (typeof value.id === 'string' && idValid(value.id)) return { id: value.id }
|
|
444
|
+
if (typeof value.intent === 'string' && intentValid(value.intent)) return { intent: value.intent }
|
|
445
|
+
throw new RequestError(400, 'transcription-query-invalid')
|
|
446
|
+
}
|
|
447
|
+
const guarded = (work: () => Promise<unknown>) => catchRequestErrors(async () => {
|
|
448
|
+
try { return jsonResponse(await work()) }
|
|
449
|
+
catch (error) { if (error instanceof ProductionAudioRefusal) throw new RequestError(error.status, error.code); throw error }
|
|
450
|
+
})
|
|
451
|
+
return {
|
|
452
|
+
'document/transcription/capabilities': { role: 'editor', handle: (request, ctx) => guarded(async () => {
|
|
453
|
+
if (request.method !== 'GET') throw new RequestError(405, 'GET')
|
|
454
|
+
return service.capabilities(ctx.document, ctx.member)
|
|
455
|
+
}) },
|
|
456
|
+
'document/transcription': { role: 'editor', maxBody: TRANSCRIPTION_REQUEST_MAX_BYTES, handle: (request, ctx) => guarded(async () => {
|
|
457
|
+
if (request.method === 'GET') { const params = new URL(request.url).searchParams; params.delete('document'); return params.size ? service.get(ctx.document, query(Object.fromEntries(params)), ctx.member) : service.list(ctx.document, ctx.member) }
|
|
458
|
+
if (request.method !== 'POST') throw new RequestError(405, 'GET or POST')
|
|
459
|
+
return service.start(ctx.document, await request.json().catch(() => undefined), ctx.member, request.signal)
|
|
460
|
+
}) },
|
|
461
|
+
'document/transcription/result': { role: 'editor', handle: (request, ctx) => guarded(async () => {
|
|
462
|
+
if (request.method !== 'GET') throw new RequestError(405, 'GET')
|
|
463
|
+
const params = new URL(request.url).searchParams
|
|
464
|
+
return service.readResult(ctx.document, params.get('id') ?? '', params.get('revision') ?? '', ctx.member)
|
|
465
|
+
}) },
|
|
466
|
+
'document/transcription/cancel': { role: 'editor', maxBody: 4 << 10, handle: (request, ctx) => guarded(async () => {
|
|
467
|
+
if (request.method !== 'POST') throw new RequestError(405, 'POST')
|
|
468
|
+
return service.cancel(ctx.document, query(await request.json().catch(() => undefined)), ctx.member)
|
|
469
|
+
}) },
|
|
470
|
+
}
|
|
471
|
+
}
|