@alexkroman1/aai-cli 6.4.0 → 6.5.1
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/{_agent-vGW5SBnX.mjs → _agent-DpH2pBJd.mjs} +1 -5
- package/dist/{_bundler-BapCUUJN.mjs → _bundler-C2BDwU7r.mjs} +5 -7
- package/dist/{_dev-server-DQ1t9sbw.mjs → _dev-server-LLLWnFBs.mjs} +3 -3
- package/dist/_e2e-test-utils.d.ts +17 -17
- package/dist/{_init-CpISqoR8.mjs → _init-C57bWAQ8.mjs} +2 -2
- package/dist/{_slug-api-D2heILuC.mjs → _slug-api-BxrsJXVI.mjs} +3 -3
- package/dist/_slug-api.d.ts +1 -1
- package/dist/{_api-client-LgLksMhN.mjs → _studio-U3jhKlA3.mjs} +105 -2
- package/dist/_studio.d.ts +11 -0
- package/dist/{_templates-iDGjGiPf.mjs → _templates-Dxb_P2Wz.mjs} +1 -1
- package/dist/bin.d.mts +2 -0
- package/dist/{build-BO1Ni6oJ.mjs → build-Dfza2pRI.mjs} +2 -2
- package/dist/cli.mjs +43 -42
- package/dist/{client-bundler-B8Q9F8gJ.mjs → client-bundler-BEIqgOtd.mjs} +2 -2
- package/dist/client-bundler.mjs +1 -1
- package/dist/{delete-4I0uNR4a.mjs → delete-vbpjGzqs.mjs} +3 -3
- package/dist/{deploy-DJ3HRcia.mjs → deploy-B8lubiRT.mjs} +3 -4
- package/dist/{dev-C6SWaAZP.mjs → dev-Uq5ujP8a.mjs} +1 -1
- package/dist/{eject-Zrzk5KV3.mjs → eject-31gjtaHF.mjs} +2 -2
- package/dist/{init-BTRGiPQr.mjs → init-l6xfU4xX.mjs} +3 -3
- package/dist/{login-k0Z3VQu9.mjs → login-C71-qz8F.mjs} +1 -1
- package/dist/scaffold/package.json +3 -3
- package/dist/{secret-DzSQx1Ds.mjs → secret-BuMuFR4B.mjs} +2 -2
- package/dist/{storage-C-Do0mIA.mjs → storage-DzRZ-eCw.mjs} +2 -2
- package/dist/{studio-BGVmCA2G.mjs → studio-BCNUpDgP.mjs} +4 -5
- package/dist/templates/retail/resolve.ts +16 -0
- package/dist/templates/retail/store.ts +20 -0
- package/dist/templates/retail/tools/cancel_pending_order.ts +2 -5
- package/dist/templates/retail/tools/exchange_delivered_order_items.ts +2 -5
- package/dist/templates/retail/tools/get_order_details.ts +4 -6
- package/dist/templates/retail/tools/get_user_details.ts +2 -7
- package/dist/templates/retail/tools/modify_pending_order_address.ts +2 -5
- package/dist/templates/retail/tools/modify_pending_order_items.ts +2 -5
- package/dist/templates/retail/tools/modify_pending_order_payment.ts +2 -5
- package/dist/templates/retail/tools/modify_user_address.ts +2 -7
- package/dist/templates/retail/tools/return_delivered_order_items.ts +2 -5
- package/dist/templates/transcription-workflow/agent.test.ts +75 -4
- package/dist/templates/transcription-workflow/api-help.tsx +8 -0
- package/dist/templates/transcription-workflow/client.tsx +149 -11
- package/dist/templates/transcription-workflow/workflows/batch.ts +32 -21
- package/dist/templates/transcription-workflow/workflows/stitch.ts +133 -0
- package/dist/templates/transcription-workflow/workflows/stream.ts +20 -20
- package/dist/templates/transcription-workflow/workflows/sync-api.ts +8 -2
- package/dist/templates/transcription-workflow/workflows/transcribe.ts +71 -86
- package/dist/{test-B6ZBxSpk.mjs → test-9kPKJI-w.mjs} +1 -3
- package/dist/typecheck.mjs +4 -5
- package/dist/{worker-bundler-CIdDhZ0c.mjs → worker-bundler-CGD4r8Kc.mjs} +25 -11
- package/dist/worker-bundler.mjs +1 -1
- package/dist/{workflow-B-O97AqI.mjs → workflow-CFpxOFfQ.mjs} +1 -1
- package/package.json +4 -4
- package/dist/_studio-zAJycZ8j.mjs +0 -94
|
@@ -42,15 +42,52 @@
|
|
|
42
42
|
* because they take the same input and return the same shape, and the only thing
|
|
43
43
|
* the page chooses is which HOOK submits it.
|
|
44
44
|
*
|
|
45
|
-
* `useWorkflowStream` is the streaming half: it
|
|
46
|
-
*
|
|
47
|
-
*
|
|
48
|
-
* `useWorkflowSubmit` is the classic half and is unchanged.
|
|
45
|
+
* `useWorkflowStream` is the streaming half: it mints the upload id, starts the run
|
|
46
|
+
* on it, sends the file, and wakes the run when the bytes land. `useWorkflowSubmit`
|
|
47
|
+
* is the classic half and is unchanged.
|
|
49
48
|
*
|
|
50
49
|
* Streaming is the DEFAULT because it is faster on any real recording. The classic
|
|
51
|
-
* path stays selectable because it is the shape to read first
|
|
52
|
-
*
|
|
53
|
-
*
|
|
50
|
+
* path stays selectable because it is the shape to read first.
|
|
51
|
+
*
|
|
52
|
+
* ## The third control is about the UPLOAD, not the flow
|
|
53
|
+
*
|
|
54
|
+
* "Split the file across connections" (`parallel`) is orthogonal to the three modes
|
|
55
|
+
* and applies to all of them, which is why it is a checkbox beside the radios
|
|
56
|
+
* rather than a fourth option. A single request moves a file at one connection's
|
|
57
|
+
* throughput, which over any distance is a fraction of the link — so the SDK cuts
|
|
58
|
+
* the file into megabyte-aligned parts and sends four at once. Nothing about the
|
|
59
|
+
* workflow changes: the agent reassembles them, `readUpload` reads the same
|
|
60
|
+
* windows, and the streaming flow still watches the file grow (what it polls is the
|
|
61
|
+
* CONTIGUOUS prefix, which is honest whether one connection or four are filling
|
|
62
|
+
* it).
|
|
63
|
+
*
|
|
64
|
+
* It is selectable rather than always-on for the reason the modes are: this is the
|
|
65
|
+
* template where a reader runs both over the same recording and sees what each
|
|
66
|
+
* costs. It also degrades on its own — a small file, or an agent deployed before
|
|
67
|
+
* the `/parts` routes existed, sends the single request instead — so leaving it on
|
|
68
|
+
* is safe.
|
|
69
|
+
*
|
|
70
|
+
* ## The transcript ARRIVES, rather than appearing at the end
|
|
71
|
+
*
|
|
72
|
+
* A run's `output` exists only when its last segment does, so a page with only
|
|
73
|
+
* that shows a status line for the whole fan-out and then everything at once — on
|
|
74
|
+
* a 97-minute recording, minutes of it. Each segment is emitted the moment it
|
|
75
|
+
* lands (`emit(TRANSCRIPT_STREAM, …)` in `workflows/transcribe.ts`) and
|
|
76
|
+
* `useWorkflowProgress` reads that stream, so the panel renders the transcript
|
|
77
|
+
* growing.
|
|
78
|
+
*
|
|
79
|
+
* Three things make it honest rather than decorative:
|
|
80
|
+
*
|
|
81
|
+
* - **The page stitches with the RUN's own function.** `stitchChunks` is
|
|
82
|
+
* `workflows/stitch.ts`, imported by both, so the live text and the stored one
|
|
83
|
+
* cannot drift into two different transcripts of one recording.
|
|
84
|
+
* - **It is a SEPARATE stream from the progress log.** `report()`'s lines go to
|
|
85
|
+
* the default one, which `<WorkflowProgress>` renders verbatim; objects in
|
|
86
|
+
* there would come out as `[object Object]` between the sentences.
|
|
87
|
+
* - **The finished run wins.** Once `output` exists the panel renders that
|
|
88
|
+
* instead — it is the authoritative text, counted and measured, and a live
|
|
89
|
+
* transcript that stayed on screen beside it would be a second answer with no
|
|
90
|
+
* way to tell which was current.
|
|
54
91
|
*
|
|
55
92
|
* ## Two waits, two bars
|
|
56
93
|
*
|
|
@@ -77,6 +114,7 @@ import {
|
|
|
77
114
|
page,
|
|
78
115
|
SubmitButton,
|
|
79
116
|
UploadProgressBar,
|
|
117
|
+
useWorkflowProgress,
|
|
80
118
|
useWorkflowRuns,
|
|
81
119
|
useWorkflowStream,
|
|
82
120
|
useWorkflowSubmit,
|
|
@@ -84,9 +122,16 @@ import {
|
|
|
84
122
|
WorkflowProgress,
|
|
85
123
|
type WorkflowRun,
|
|
86
124
|
} from "@alexkroman1/aai-ui";
|
|
87
|
-
import { useEffect, useState } from "react";
|
|
125
|
+
import { useEffect, useMemo, useState } from "react";
|
|
88
126
|
import type { transcribe } from "./agent.ts";
|
|
89
127
|
import { ApiHelp } from "./api-help.tsx";
|
|
128
|
+
import {
|
|
129
|
+
clock,
|
|
130
|
+
countWords,
|
|
131
|
+
stitchChunks,
|
|
132
|
+
TRANSCRIPT_STREAM,
|
|
133
|
+
type TranscriptChunk,
|
|
134
|
+
} from "./workflows/stitch.ts";
|
|
90
135
|
|
|
91
136
|
/**
|
|
92
137
|
* What a finished run reports.
|
|
@@ -142,12 +187,16 @@ const HISTORY_LIMIT = 10;
|
|
|
142
187
|
|
|
143
188
|
function TranscriptionDesk() {
|
|
144
189
|
const [mode, setMode] = useState<Mode>("streaming");
|
|
190
|
+
// Whether the browser cuts the recording up and sends the pieces at once. One
|
|
191
|
+
// piece of state for all three hooks, because it describes the UPLOAD and every
|
|
192
|
+
// mode has one — see the module doc.
|
|
193
|
+
const [parallel, setParallel] = useState(true);
|
|
145
194
|
// ALL THREE hooks are called every render, because a hook may not be conditional —
|
|
146
195
|
// and that costs nothing here: none of them does anything until its `submit` is
|
|
147
196
|
// called, and `useWorkflowRun` underneath them holds no id until then either.
|
|
148
|
-
const streamed = useWorkflowStream<Transcript>(WORKFLOWS.streaming);
|
|
149
|
-
const stored = useWorkflowSubmit<Transcript>(WORKFLOWS.classic);
|
|
150
|
-
const batched = useWorkflowSubmit<Transcript>(WORKFLOWS.batch);
|
|
197
|
+
const streamed = useWorkflowStream<Transcript>(WORKFLOWS.streaming, { parallel });
|
|
198
|
+
const stored = useWorkflowSubmit<Transcript>(WORKFLOWS.classic, { parallel });
|
|
199
|
+
const batched = useWorkflowSubmit<Transcript>(WORKFLOWS.batch, { parallel });
|
|
151
200
|
// The batch flow uploads the same way the classic one does — the id comes from the
|
|
152
201
|
// store — so it is the SAME hook against a different workflow. Only the streaming
|
|
153
202
|
// mode needs the other one, because only it needs the id before the bytes.
|
|
@@ -184,6 +233,8 @@ function TranscriptionDesk() {
|
|
|
184
233
|
|
|
185
234
|
<ModePicker mode={mode} onPick={setMode} disabled={pending} />
|
|
186
235
|
|
|
236
|
+
<UploadPicker parallel={parallel} onPick={setParallel} disabled={pending} />
|
|
237
|
+
|
|
187
238
|
{/* No mapping: the collected values already match the input schema. All three
|
|
188
239
|
workflows declare `recording` as an upload, so the same picker serves every
|
|
189
240
|
mode — how the bytes travel is not a question to ask a person. */}
|
|
@@ -256,6 +307,49 @@ function ModePicker({
|
|
|
256
307
|
);
|
|
257
308
|
}
|
|
258
309
|
|
|
310
|
+
/**
|
|
311
|
+
* How the recording travels, as one checkbox.
|
|
312
|
+
*
|
|
313
|
+
* Beside the mode radios rather than among them because it answers a different
|
|
314
|
+
* question — those pick the WORKFLOW, this picks how its input gets there — and
|
|
315
|
+
* every mode is uploading a file either way.
|
|
316
|
+
*
|
|
317
|
+
* Disabled mid-submission for the same reason the radios are: the bytes are
|
|
318
|
+
* already moving, and a control that looks live while changing nothing is worse
|
|
319
|
+
* than one that is plainly unavailable.
|
|
320
|
+
*/
|
|
321
|
+
function UploadPicker({
|
|
322
|
+
parallel,
|
|
323
|
+
onPick,
|
|
324
|
+
disabled,
|
|
325
|
+
}: {
|
|
326
|
+
parallel: boolean;
|
|
327
|
+
onPick: (next: boolean) => void;
|
|
328
|
+
disabled: boolean;
|
|
329
|
+
}) {
|
|
330
|
+
return (
|
|
331
|
+
<fieldset className="flex flex-col gap-3" disabled={disabled}>
|
|
332
|
+
<legend className="text-sm font-medium uppercase tracking-[1.2px]">Upload</legend>
|
|
333
|
+
<label className="flex items-start gap-3 text-sm">
|
|
334
|
+
<input
|
|
335
|
+
type="checkbox"
|
|
336
|
+
className="mt-1"
|
|
337
|
+
name="parallel"
|
|
338
|
+
checked={parallel}
|
|
339
|
+
onChange={(event) => onPick(event.target.checked)}
|
|
340
|
+
/>
|
|
341
|
+
<span className="flex flex-col gap-0.5">
|
|
342
|
+
<span>Split the file across connections</span>
|
|
343
|
+
<span className="text-xs opacity-70">
|
|
344
|
+
Sends the recording as several parts at once instead of in one request, which is most of
|
|
345
|
+
the wait on a long file. Falls back to the single request on a small one.
|
|
346
|
+
</span>
|
|
347
|
+
</span>
|
|
348
|
+
</label>
|
|
349
|
+
</fieldset>
|
|
350
|
+
);
|
|
351
|
+
}
|
|
352
|
+
|
|
259
353
|
/**
|
|
260
354
|
* Every recent run, newest first, with its transcript one click away.
|
|
261
355
|
*
|
|
@@ -338,6 +432,11 @@ function RunPanel({ run, onClear }: { run: WorkflowRun<Transcript>; onClear?: ()
|
|
|
338
432
|
finished run up in the panel below shows how it got there. */}
|
|
339
433
|
<WorkflowProgress runId={run.runId} />
|
|
340
434
|
|
|
435
|
+
{/* While it runs, the transcript so far. Unguarded on the run's status
|
|
436
|
+
beyond this: the component renders nothing until a segment has landed,
|
|
437
|
+
and stops the moment there is an `output` to render instead. */}
|
|
438
|
+
{!isTerminal(run) && <LiveTranscript runId={run.runId} />}
|
|
439
|
+
|
|
341
440
|
{/* Discriminated on `status`, so `output` and `error` are reachable
|
|
342
441
|
without a cast — the reason a snapshot is a union rather than a flat
|
|
343
442
|
object with optional fields. */}
|
|
@@ -356,6 +455,45 @@ function RunPanel({ run, onClear }: { run: WorkflowRun<Transcript>; onClear?: ()
|
|
|
356
455
|
);
|
|
357
456
|
}
|
|
358
457
|
|
|
458
|
+
/**
|
|
459
|
+
* The transcript as it arrives, stitched from the segments that have landed.
|
|
460
|
+
*
|
|
461
|
+
* The other half of `<WorkflowProgress>` above it: that one renders what the run
|
|
462
|
+
* SAYS about itself, this one renders what it has produced. Both are the same
|
|
463
|
+
* mechanism — a run's output stream — separated by the namespace, which is what
|
|
464
|
+
* lets this one be typed.
|
|
465
|
+
*
|
|
466
|
+
* It renders NOTHING until a segment lands, so a page can mount it unguarded:
|
|
467
|
+
* before the first chunk there is nothing to say that the progress log is not
|
|
468
|
+
* already saying better.
|
|
469
|
+
*
|
|
470
|
+
* The count is derived from the stitched text rather than summed per chunk,
|
|
471
|
+
* because the seams overlap — adding up the segments would over-count every one
|
|
472
|
+
* of them by a couple of seconds' worth of words.
|
|
473
|
+
*/
|
|
474
|
+
function LiveTranscript({ runId }: { runId: string }) {
|
|
475
|
+
const { progress } = useWorkflowProgress<TranscriptChunk>(runId, {
|
|
476
|
+
namespace: TRANSCRIPT_STREAM,
|
|
477
|
+
});
|
|
478
|
+
// Memoized on the ARRAY, which the hook appends to per read: stitching is a
|
|
479
|
+
// seam search per segment, and a fan-out re-renders this panel on every
|
|
480
|
+
// progress poll whether or not anything arrived.
|
|
481
|
+
const transcript = useMemo(() => stitchChunks(progress), [progress]);
|
|
482
|
+
if (progress.length === 0) return null;
|
|
483
|
+
|
|
484
|
+
// The furthest point reached, not the count: segments land out of order, so
|
|
485
|
+
// "6 segments" says nothing about how much of the recording is covered.
|
|
486
|
+
const covered = Math.max(...progress.map((chunk) => chunk.endMs));
|
|
487
|
+
return (
|
|
488
|
+
<div className="flex flex-col gap-2">
|
|
489
|
+
<p className="text-xs opacity-60">
|
|
490
|
+
{countWords(transcript)} words so far · through {clock(covered)}
|
|
491
|
+
</p>
|
|
492
|
+
<pre className="whitespace-pre-wrap text-sm leading-relaxed opacity-80">{transcript}</pre>
|
|
493
|
+
</div>
|
|
494
|
+
);
|
|
495
|
+
}
|
|
496
|
+
|
|
359
497
|
/**
|
|
360
498
|
* A duration a person can read.
|
|
361
499
|
*
|
|
@@ -40,12 +40,14 @@
|
|
|
40
40
|
*
|
|
41
41
|
* That is also why the step that does it is the step the DevKit retries: a streaming
|
|
42
42
|
* body is consumed once, so a retry has to re-read the upload from the start, which
|
|
43
|
-
* it does.
|
|
43
|
+
* it does. One window of READ-AHEAD keeps the store and the socket busy at the same
|
|
44
|
+
* time; `windows` carries the argument.
|
|
44
45
|
*/
|
|
45
46
|
|
|
46
47
|
import { throwFatalStepError, toStepError } from "@alexkroman1/aai/step-errors";
|
|
47
|
-
import { readUpload, report,
|
|
48
|
+
import { readUpload, report, stepFetch, uploadInfo } from "@alexkroman1/aai/utils";
|
|
48
49
|
import { sleep } from "workflow";
|
|
50
|
+
import { apiKeyOrFatal } from "./sync-api.ts";
|
|
49
51
|
import { countWords, startClock, type Transcript } from "./transcribe.ts";
|
|
50
52
|
|
|
51
53
|
/** The async API's base. */
|
|
@@ -65,9 +67,6 @@ const API = "https://api.assemblyai.com";
|
|
|
65
67
|
*/
|
|
66
68
|
const MODELS = ["universal-3-5-pro"];
|
|
67
69
|
|
|
68
|
-
/** The key a step reads out of the agent env. Declared in `agent.ts`'s `requiredEnv`. */
|
|
69
|
-
const API_KEY_ENV = "ASSEMBLYAI_API_KEY";
|
|
70
|
-
|
|
71
70
|
/** How much of our stored upload one outbound window carries. */
|
|
72
71
|
const UPLOAD_WINDOW_BYTES = 4 * 1024 * 1024;
|
|
73
72
|
|
|
@@ -100,8 +99,13 @@ const MAX_POLLS = 360;
|
|
|
100
99
|
export async function transcribeBatchFlow(input: { recording: string }): Promise<Transcript> {
|
|
101
100
|
"use workflow";
|
|
102
101
|
|
|
103
|
-
|
|
104
|
-
|
|
102
|
+
// Both at once: the clock does not depend on the upload, and issuing them
|
|
103
|
+
// together costs one round trip instead of two before a byte moves. Their issue
|
|
104
|
+
// order is still decided by this line rather than by which lands first.
|
|
105
|
+
const [startedAt, { audioUrl }] = await Promise.all([
|
|
106
|
+
startClock(),
|
|
107
|
+
uploadToProvider(input.recording),
|
|
108
|
+
]);
|
|
105
109
|
const job = await createJob(audioUrl);
|
|
106
110
|
|
|
107
111
|
for (let poll = 0; poll < MAX_POLLS; poll += 1) {
|
|
@@ -241,26 +245,33 @@ export async function readTranscript(
|
|
|
241
245
|
}
|
|
242
246
|
|
|
243
247
|
/**
|
|
244
|
-
* The stored upload as a sequence of windows.
|
|
248
|
+
* The stored upload as a sequence of windows, with the next one already in flight.
|
|
245
249
|
*
|
|
246
250
|
* A generator rather than one `readUpload`, because the whole point is that the file
|
|
247
251
|
* is never held: each window is read, sent, and dropped. `readUpload` clamps to what
|
|
248
252
|
* is stored, so the loop ends on the real end of the file even if `size` moved.
|
|
253
|
+
*
|
|
254
|
+
* **One window of READ-AHEAD**, which is the whole concurrency available here: the
|
|
255
|
+
* consumer is a socket and the producer is the app's own store, and read-then-send
|
|
256
|
+
* makes them strictly alternate — the store idles while bytes go out, and the socket
|
|
257
|
+
* idles while the next window is fetched. Starting the next read before yielding the
|
|
258
|
+
* current window overlaps them, so a gigabyte upload pays the larger of the two
|
|
259
|
+
* rather than their sum. Exactly one, not a queue: a deeper buffer holds more of a
|
|
260
|
+
* file this generator exists to avoid holding, and there is nothing to gain past
|
|
261
|
+
* keeping both ends busy.
|
|
249
262
|
*/
|
|
250
263
|
async function* windows(uploadId: string, size: number): AsyncGenerator<Uint8Array> {
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
} catch (err: unknown) {
|
|
263
|
-
return throwFatalStepError(err);
|
|
264
|
+
const read = (at: number): Promise<Uint8Array> =>
|
|
265
|
+
readUpload(uploadId, { start: at, end: at + UPLOAD_WINDOW_BYTES }).then((slice) => slice.bytes);
|
|
266
|
+
let at = 0;
|
|
267
|
+
let next = at < size ? read(at) : undefined;
|
|
268
|
+
while (next !== undefined) {
|
|
269
|
+
const bytes = await next;
|
|
270
|
+
if (bytes.length === 0) return;
|
|
271
|
+
at += UPLOAD_WINDOW_BYTES;
|
|
272
|
+
// Issued BEFORE the yield, so the store is fetching while the socket sends.
|
|
273
|
+
next = at < size ? read(at) : undefined;
|
|
274
|
+
yield bytes;
|
|
264
275
|
}
|
|
265
276
|
}
|
|
266
277
|
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
// Copyright 2026 the AAI authors. MIT license.
|
|
2
|
+
/**
|
|
3
|
+
* Joining segment transcripts back into one — and the only module here the PAGE
|
|
4
|
+
* imports.
|
|
5
|
+
*
|
|
6
|
+
* That is the whole reason it exists as its own file. Segments are transcribed
|
|
7
|
+
* one per step and each one is emitted the moment it lands (`emit("transcript",
|
|
8
|
+
* …)` in `transcribe.ts`), so the page can render the answer growing rather than
|
|
9
|
+
* a spinner — and to render it, the page has to do exactly what `mergeTranscript`
|
|
10
|
+
* does at the end: put the pieces in order and drop the words the overlap made
|
|
11
|
+
* duplicates.
|
|
12
|
+
*
|
|
13
|
+
* Two copies of that would drift, and they would drift INVISIBLY: a live
|
|
14
|
+
* transcript that stitches differently from the stored one reads as the model
|
|
15
|
+
* having changed its mind. So the seam logic is here, imported by the run and by
|
|
16
|
+
* the browser, with nothing else in the module — no directive, no I/O, no SDK
|
|
17
|
+
* import — so pulling it into the client bundle costs a few hundred bytes.
|
|
18
|
+
*
|
|
19
|
+
* `wav.ts` and `sync-api.ts` sit under `workflows/` on the same terms: the WDK
|
|
20
|
+
* builder scans this directory and transforms only what carries a directive.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* The stream a run publishes its segments into as they land.
|
|
25
|
+
*
|
|
26
|
+
* Declared HERE because it is the one string the run and the page have to agree
|
|
27
|
+
* on: a step emits into it and `client.tsx` subscribes by it, and a typo is a
|
|
28
|
+
* panel that renders nothing with nothing saying why. Both sync flows write it;
|
|
29
|
+
* the async flow has one segment and nothing to stream.
|
|
30
|
+
*/
|
|
31
|
+
export const TRANSCRIPT_STREAM = "transcript";
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* One segment as it goes over {@link TRANSCRIPT_STREAM}.
|
|
35
|
+
*
|
|
36
|
+
* Its own type rather than the step's journaled result widened, because the two
|
|
37
|
+
* have different readers: `mergeTranscript` needs an index and words, while
|
|
38
|
+
* somebody watching a partial transcript needs to know WHICH part of the
|
|
39
|
+
* recording each piece is — the list has holes in it until the run finishes, and
|
|
40
|
+
* "0:00–1:30" beside a paragraph is what explains a jump.
|
|
41
|
+
*/
|
|
42
|
+
export type TranscriptChunk = {
|
|
43
|
+
index: number;
|
|
44
|
+
/** Where this piece starts in the recording. */
|
|
45
|
+
startMs: number;
|
|
46
|
+
/** Where it ends. */
|
|
47
|
+
endMs: number;
|
|
48
|
+
text: string;
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
/** Most words {@link stitchTranscript} will look back over to find a repeated seam. */
|
|
52
|
+
const MAX_SEAM_WORDS = 40;
|
|
53
|
+
|
|
54
|
+
/** A word, stripped of the punctuation the decoder added, for seam comparison. */
|
|
55
|
+
function seamKey(word: string): string {
|
|
56
|
+
return word.toLowerCase().replace(/[^\p{L}\p{N}']/gu, "");
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Join segment transcripts, dropping the words the overlap made duplicates.
|
|
61
|
+
*
|
|
62
|
+
* Segments overlap by `SEGMENT_OVERLAP_SECONDS` (see `wav.ts` for why), so the
|
|
63
|
+
* last few words of one segment are the first few of the next — verbatim when
|
|
64
|
+
* the decoder heard them the same way, which is the common case because it heard
|
|
65
|
+
* the same audio. This finds the longest such run and removes one copy.
|
|
66
|
+
*
|
|
67
|
+
* Comparison is on `seamKey`, not the raw words: the two passes punctuate
|
|
68
|
+
* differently at their own edges (one ends a sentence where the other is
|
|
69
|
+
* mid-clause), so `"today."` and `"today"` are the same word and a raw compare
|
|
70
|
+
* finds no seam at all. The text KEPT is the raw text — only the match is
|
|
71
|
+
* normalized.
|
|
72
|
+
*
|
|
73
|
+
* A missed seam repeats a few words, which a reader can see and forgive. A
|
|
74
|
+
* false one would delete speech, so the search is bounded at
|
|
75
|
+
* {@link MAX_SEAM_WORDS} and always prefers the LONGEST match: a single repeated
|
|
76
|
+
* "the" is not evidence of anything, and requiring the longest run is what stops
|
|
77
|
+
* it counting as one when a longer match is available.
|
|
78
|
+
*
|
|
79
|
+
* **A gap is not a seam, which is what makes this safe to run on a PARTIAL
|
|
80
|
+
* list.** The page stitches whatever segments have arrived, and while a run is in
|
|
81
|
+
* flight that list has holes in it — segment 4 may land before segment 3. Two
|
|
82
|
+
* pieces that were never adjacent share no overlap, so no seam is found and both
|
|
83
|
+
* are kept whole: the live text reads with a jump in it until the missing piece
|
|
84
|
+
* arrives, rather than quietly losing a sentence.
|
|
85
|
+
*/
|
|
86
|
+
export function stitchTranscript(parts: readonly string[]): string {
|
|
87
|
+
const merged: string[] = [];
|
|
88
|
+
for (const part of parts) {
|
|
89
|
+
const next = part.split(/\s+/).filter(Boolean);
|
|
90
|
+
if (next.length === 0) continue;
|
|
91
|
+
if (merged.length === 0) {
|
|
92
|
+
merged.push(...next);
|
|
93
|
+
continue;
|
|
94
|
+
}
|
|
95
|
+
merged.push(...next.slice(seamLength(merged, next)));
|
|
96
|
+
}
|
|
97
|
+
return merged.join(" ");
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** How many leading words of `next` repeat the tail of `merged`. */
|
|
101
|
+
function seamLength(merged: readonly string[], next: readonly string[]): number {
|
|
102
|
+
const limit = Math.min(MAX_SEAM_WORDS, merged.length, next.length);
|
|
103
|
+
// Longest first, so a short accidental match never wins over a real seam.
|
|
104
|
+
for (let length = limit; length > 0; length--) {
|
|
105
|
+
const tail = merged.slice(merged.length - length);
|
|
106
|
+
if (tail.every((word, at) => seamKey(word) === seamKey(next[at] ?? ""))) return length;
|
|
107
|
+
}
|
|
108
|
+
return 0;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Order the chunks that have arrived and stitch them — what the PAGE renders.
|
|
113
|
+
*
|
|
114
|
+
* Sorted here rather than by the caller because arrival order is the one thing a
|
|
115
|
+
* live reader definitely does not have: segments are transcribed concurrently and
|
|
116
|
+
* each is emitted the moment it lands, so chunk 4 routinely precedes chunk 3.
|
|
117
|
+
*
|
|
118
|
+
* A COPY, because the caller's list is React state.
|
|
119
|
+
*/
|
|
120
|
+
export function stitchChunks(chunks: readonly TranscriptChunk[]): string {
|
|
121
|
+
return stitchTranscript([...chunks].sort((a, b) => a.index - b.index).map((chunk) => chunk.text));
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** Words in a string. The run and the page count them the same way. */
|
|
125
|
+
export function countWords(text: string): number {
|
|
126
|
+
return text.split(/\s+/).filter(Boolean).length;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** `m:ss` for the progress log — a byte offset means nothing to a reader. */
|
|
130
|
+
export function clock(ms: number): string {
|
|
131
|
+
const seconds = Math.max(0, Math.round(ms / 1000));
|
|
132
|
+
return `${Math.floor(seconds / 60)}:${String(seconds % 60).padStart(2, "0")}`;
|
|
133
|
+
}
|
|
@@ -101,20 +101,29 @@
|
|
|
101
101
|
* shape and is never slower, which is why the page offers both rather than replacing
|
|
102
102
|
* one with the other.
|
|
103
103
|
*
|
|
104
|
-
* ##
|
|
104
|
+
* ## A ROUND has to finish before the next poll
|
|
105
105
|
*
|
|
106
|
-
*
|
|
107
|
-
*
|
|
108
|
-
* a
|
|
109
|
-
*
|
|
110
|
-
*
|
|
106
|
+
* Everything the body decides comes from a journaled poll, so the set of segments it
|
|
107
|
+
* fans out over is fixed for the length of that fan-out: one that becomes readable
|
|
108
|
+
* while a round is in flight waits for the round. That is a smaller wait than it was
|
|
109
|
+
* — `mapConcurrent` is a window over a cursor rather than sequential batches, so a
|
|
110
|
+
* round now ends when its LAST segment lands rather than at the sum of each batch's
|
|
111
|
+
* slowest — but it is not zero, and it is why the two flows converge on a fast
|
|
112
|
+
* uplink rather than the streaming one winning. On a slow uplink it costs nothing:
|
|
113
|
+
* segments arrive slower than they transcribe.
|
|
114
|
+
*
|
|
115
|
+
* Feeding new segments into a running fan-out would remove it and is deliberately
|
|
116
|
+
* not done: which items are in flight would then depend on when bytes arrived, and
|
|
117
|
+
* the DevKit correlates a journal entry to a step call by ISSUE ORDER. A round is
|
|
118
|
+
* what keeps that order a pure function of journaled values.
|
|
111
119
|
*/
|
|
112
120
|
|
|
113
121
|
import { throwFatalStepError } from "@alexkroman1/aai/step-errors";
|
|
114
|
-
import {
|
|
122
|
+
import { mapConcurrent, readUpload, report, uploadInfo } from "@alexkroman1/aai/utils";
|
|
115
123
|
import { sleep } from "workflow";
|
|
116
124
|
import {
|
|
117
125
|
clock,
|
|
126
|
+
fatalOnUnsupported,
|
|
118
127
|
mergeTranscript,
|
|
119
128
|
type SegmentTranscript,
|
|
120
129
|
segmentConcurrency,
|
|
@@ -209,10 +218,11 @@ export async function transcribeStreamFlow(input: { recording: string }) {
|
|
|
209
218
|
lastSize = at.size;
|
|
210
219
|
for (const segment of ready) done.add(segment.index);
|
|
211
220
|
// One step per segment, bounded, in an order a replay reproduces exactly —
|
|
212
|
-
// `ready` is derived from a journaled poll, and `
|
|
213
|
-
// calls in
|
|
221
|
+
// `ready` is derived from a journaled poll, and `mapConcurrent` issues its
|
|
222
|
+
// calls in list order. THE SAME STEP the classic flow uses, so a segment
|
|
223
|
+
// transcribed here reaches the page's live transcript identically.
|
|
214
224
|
parts.push(
|
|
215
|
-
...(await
|
|
225
|
+
...(await mapConcurrent(
|
|
216
226
|
ready,
|
|
217
227
|
segmentConcurrency((plan as StreamPlan).format),
|
|
218
228
|
(segment) => transcribeSegment(input.recording, (plan as StreamPlan).format, segment),
|
|
@@ -314,16 +324,6 @@ function expectedSegments(plan: StreamPlan, size: number): number {
|
|
|
314
324
|
return plan.segments.filter((segment) => segment.start < size).length;
|
|
315
325
|
}
|
|
316
326
|
|
|
317
|
-
/** Run a `wav.ts` helper, turning its "cannot cut this" into a terminal failure. */
|
|
318
|
-
function fatalOnUnsupported<T>(read: () => T): T {
|
|
319
|
-
try {
|
|
320
|
-
return read();
|
|
321
|
-
} catch (err: unknown) {
|
|
322
|
-
if (err instanceof UnsupportedRecordingError) return throwFatalStepError(err);
|
|
323
|
-
throw err;
|
|
324
|
-
}
|
|
325
|
-
}
|
|
326
|
-
|
|
327
327
|
/**
|
|
328
328
|
* Give up on an upload that stopped arriving.
|
|
329
329
|
*
|
|
@@ -104,8 +104,14 @@ export async function transcribeWav(
|
|
|
104
104
|
return (body.text ?? "").trim();
|
|
105
105
|
}
|
|
106
106
|
|
|
107
|
-
/**
|
|
108
|
-
|
|
107
|
+
/**
|
|
108
|
+
* The API key, or a terminal failure — three more attempts find the same gap.
|
|
109
|
+
*
|
|
110
|
+
* Exported because `batch.ts` calls the same provider on the same key and had
|
|
111
|
+
* written this, and its own `API_KEY_ENV`, again. The key belongs to the
|
|
112
|
+
* PROVIDER; only the endpoint and its failure shapes belong to this module.
|
|
113
|
+
*/
|
|
114
|
+
export function apiKeyOrFatal(): string {
|
|
109
115
|
try {
|
|
110
116
|
return requireStepEnv(API_KEY_ENV);
|
|
111
117
|
} catch (err: unknown) {
|