@alexkroman1/aai-cli 6.4.0 → 6.5.0
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/scaffold/package.json +3 -3
- package/dist/templates/transcription-workflow/agent.test.ts +73 -1
- 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 +30 -8
- package/dist/templates/transcription-workflow/workflows/stitch.ts +133 -0
- package/dist/templates/transcription-workflow/workflows/stream.ts +19 -10
- package/dist/templates/transcription-workflow/workflows/transcribe.ts +64 -85
- package/package.json +3 -3
|
@@ -13,8 +13,8 @@
|
|
|
13
13
|
"publish:agent": "aai publish"
|
|
14
14
|
},
|
|
15
15
|
"dependencies": {
|
|
16
|
-
"@alexkroman1/aai": "^6.
|
|
17
|
-
"@alexkroman1/aai-ui": "^6.
|
|
16
|
+
"@alexkroman1/aai": "^6.5.0",
|
|
17
|
+
"@alexkroman1/aai-ui": "^6.5.0",
|
|
18
18
|
"@workflow/world-postgres": "4.3.3",
|
|
19
19
|
"react": "^19.2.8",
|
|
20
20
|
"react-dom": "^19.2.8",
|
|
@@ -23,7 +23,7 @@
|
|
|
23
23
|
"zod": "^4.4.3"
|
|
24
24
|
},
|
|
25
25
|
"devDependencies": {
|
|
26
|
-
"@alexkroman1/aai-cli": "^6.
|
|
26
|
+
"@alexkroman1/aai-cli": "^6.5.0",
|
|
27
27
|
"@tailwindcss/vite": "^4.3.3",
|
|
28
28
|
"@types/node": "^26.2.0",
|
|
29
29
|
"@types/react": "^19.2.18",
|
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
* the decoder happily transcribes into confident nonsense.
|
|
18
18
|
*/
|
|
19
19
|
|
|
20
|
-
import { stubStepFetch, stubUploads } from "@alexkroman1/aai/testing";
|
|
20
|
+
import { stubReporter, stubStepFetch, stubUploads } from "@alexkroman1/aai/testing";
|
|
21
21
|
import { readUpload } from "@alexkroman1/aai/utils";
|
|
22
22
|
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
|
23
23
|
import { FatalError, RetryableError } from "workflow";
|
|
@@ -30,7 +30,9 @@ import {
|
|
|
30
30
|
clock,
|
|
31
31
|
mergeTranscript,
|
|
32
32
|
splitRecording,
|
|
33
|
+
stitchChunks,
|
|
33
34
|
stitchTranscript,
|
|
35
|
+
TRANSCRIPT_STREAM,
|
|
34
36
|
transcribeSegment,
|
|
35
37
|
} from "./workflows/transcribe.ts";
|
|
36
38
|
import {
|
|
@@ -421,6 +423,52 @@ describe("stitchTranscript", () => {
|
|
|
421
423
|
});
|
|
422
424
|
});
|
|
423
425
|
|
|
426
|
+
describe("stitchChunks — what the PAGE renders while a run is going", () => {
|
|
427
|
+
const chunk = (index: number, text: string) => ({
|
|
428
|
+
index,
|
|
429
|
+
startMs: index * 1000,
|
|
430
|
+
endMs: (index + 1) * 1000,
|
|
431
|
+
text,
|
|
432
|
+
});
|
|
433
|
+
|
|
434
|
+
test("orders by index, because chunks arrive as the segments settle", () => {
|
|
435
|
+
// The one thing a live reader definitely does not have is arrival order: the
|
|
436
|
+
// segments are transcribed concurrently, so chunk 2 routinely precedes 1.
|
|
437
|
+
expect(stitchChunks([chunk(2, "gamma"), chunk(0, "alpha"), chunk(1, "beta")])).toBe(
|
|
438
|
+
"alpha beta gamma",
|
|
439
|
+
);
|
|
440
|
+
});
|
|
441
|
+
|
|
442
|
+
test("stitches the seams the same way the finished run does", () => {
|
|
443
|
+
// The whole reason the page imports the run's own function: a live
|
|
444
|
+
// transcript that de-duplicated differently would read as the model having
|
|
445
|
+
// changed its mind between the last poll and the result.
|
|
446
|
+
const parts = ["we should ship it on Friday", "ship it on Friday if the tests pass"];
|
|
447
|
+
expect(stitchChunks(parts.map((text, index) => chunk(index, text)))).toBe(
|
|
448
|
+
stitchTranscript(parts),
|
|
449
|
+
);
|
|
450
|
+
});
|
|
451
|
+
|
|
452
|
+
test("keeps both pieces around a HOLE rather than seaming across it", () => {
|
|
453
|
+
// A partial list is the ordinary case here, and two pieces that were never
|
|
454
|
+
// adjacent share no overlap — so the live text reads with a jump in it until
|
|
455
|
+
// the missing segment lands, instead of quietly losing a sentence.
|
|
456
|
+
expect(stitchChunks([chunk(0, "alpha beta"), chunk(2, "epsilon zeta")])).toBe(
|
|
457
|
+
"alpha beta epsilon zeta",
|
|
458
|
+
);
|
|
459
|
+
});
|
|
460
|
+
|
|
461
|
+
test("does not mutate the caller's list, which is React state", () => {
|
|
462
|
+
const chunks = [chunk(1, "beta"), chunk(0, "alpha")];
|
|
463
|
+
stitchChunks(chunks);
|
|
464
|
+
expect(chunks.map((one) => one.index)).toEqual([1, 0]);
|
|
465
|
+
});
|
|
466
|
+
|
|
467
|
+
test("renders nothing out of nothing", () => {
|
|
468
|
+
expect(stitchChunks([])).toBe("");
|
|
469
|
+
});
|
|
470
|
+
});
|
|
471
|
+
|
|
424
472
|
describe("clock", () => {
|
|
425
473
|
test("renders a position a reader can find in the recording", () => {
|
|
426
474
|
expect(clock(0)).toBe("0:00");
|
|
@@ -525,6 +573,30 @@ describe("transcribeSegment", () => {
|
|
|
525
573
|
expect(decoded).toContain("RIFF");
|
|
526
574
|
});
|
|
527
575
|
|
|
576
|
+
test("EMITS the segment's words as it lands, into the transcript stream", async () => {
|
|
577
|
+
// What makes the run's answer streamable rather than only its narration: the
|
|
578
|
+
// page stitches whatever has arrived, so the transcript renders growing
|
|
579
|
+
// instead of appearing when the last segment does. The reporter is the SDK's
|
|
580
|
+
// published slot, which is the same seam `report()` goes through.
|
|
581
|
+
const reported = stubReporter();
|
|
582
|
+
stubs.push(reported.restore);
|
|
583
|
+
stubProvider();
|
|
584
|
+
|
|
585
|
+
await transcribeSegment(UPLOAD_ID, FORMAT, SEGMENT);
|
|
586
|
+
|
|
587
|
+
// The timestamps ride along for the READER — a partial transcript has holes
|
|
588
|
+
// in it, and "0:00–0:01" is what explains a jump.
|
|
589
|
+
expect(reported.emitted).toEqual([
|
|
590
|
+
{
|
|
591
|
+
namespace: TRANSCRIPT_STREAM,
|
|
592
|
+
chunk: { index: 0, startMs: 0, endMs: 1000, text: "hello there" },
|
|
593
|
+
},
|
|
594
|
+
]);
|
|
595
|
+
// And the narration is still its own stream: lines a page prints verbatim
|
|
596
|
+
// cannot share a channel with objects.
|
|
597
|
+
expect(reported.lines.some((line) => line.startsWith("Transcribing"))).toBe(true);
|
|
598
|
+
});
|
|
599
|
+
|
|
528
600
|
test("fails FATALLY with no API key rather than retrying five times", async () => {
|
|
529
601
|
vi.stubEnv("ASSEMBLYAI_API_KEY", "");
|
|
530
602
|
stubProvider();
|
|
@@ -136,6 +136,14 @@ const ROUTES: readonly { route: string; does: string }[] = [
|
|
|
136
136
|
route: "PUT /workflows/uploads/:id",
|
|
137
137
|
does: "store a file under YOUR id, readable as it arrives",
|
|
138
138
|
},
|
|
139
|
+
{
|
|
140
|
+
route: "POST /workflows/uploads/:id/parts",
|
|
141
|
+
does: "declare an upload its parts fill in · ?total=<bytes>",
|
|
142
|
+
},
|
|
143
|
+
{
|
|
144
|
+
route: "PUT /workflows/uploads/:id/parts",
|
|
145
|
+
does: "one window of it · ?offset=<byte>, sent concurrently",
|
|
146
|
+
},
|
|
139
147
|
{ route: "GET /workflows/uploads/:id", does: "read the bytes back · Range honoured" },
|
|
140
148
|
{ route: "GET /workflows/uploads/:id/info", does: "name, bytes stored so far, and complete" },
|
|
141
149
|
];
|
|
@@ -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,7 +40,8 @@
|
|
|
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";
|
|
@@ -100,8 +101,13 @@ const MAX_POLLS = 360;
|
|
|
100
101
|
export async function transcribeBatchFlow(input: { recording: string }): Promise<Transcript> {
|
|
101
102
|
"use workflow";
|
|
102
103
|
|
|
103
|
-
|
|
104
|
-
|
|
104
|
+
// Both at once: the clock does not depend on the upload, and issuing them
|
|
105
|
+
// together costs one round trip instead of two before a byte moves. Their issue
|
|
106
|
+
// order is still decided by this line rather than by which lands first.
|
|
107
|
+
const [startedAt, { audioUrl }] = await Promise.all([
|
|
108
|
+
startClock(),
|
|
109
|
+
uploadToProvider(input.recording),
|
|
110
|
+
]);
|
|
105
111
|
const job = await createJob(audioUrl);
|
|
106
112
|
|
|
107
113
|
for (let poll = 0; poll < MAX_POLLS; poll += 1) {
|
|
@@ -241,17 +247,33 @@ export async function readTranscript(
|
|
|
241
247
|
}
|
|
242
248
|
|
|
243
249
|
/**
|
|
244
|
-
* The stored upload as a sequence of windows.
|
|
250
|
+
* The stored upload as a sequence of windows, with the next one already in flight.
|
|
245
251
|
*
|
|
246
252
|
* A generator rather than one `readUpload`, because the whole point is that the file
|
|
247
253
|
* is never held: each window is read, sent, and dropped. `readUpload` clamps to what
|
|
248
254
|
* is stored, so the loop ends on the real end of the file even if `size` moved.
|
|
255
|
+
*
|
|
256
|
+
* **One window of READ-AHEAD**, which is the whole concurrency available here: the
|
|
257
|
+
* consumer is a socket and the producer is the app's own store, and read-then-send
|
|
258
|
+
* makes them strictly alternate — the store idles while bytes go out, and the socket
|
|
259
|
+
* idles while the next window is fetched. Starting the next read before yielding the
|
|
260
|
+
* current window overlaps them, so a gigabyte upload pays the larger of the two
|
|
261
|
+
* rather than their sum. Exactly one, not a queue: a deeper buffer holds more of a
|
|
262
|
+
* file this generator exists to avoid holding, and there is nothing to gain past
|
|
263
|
+
* keeping both ends busy.
|
|
249
264
|
*/
|
|
250
265
|
async function* windows(uploadId: string, size: number): AsyncGenerator<Uint8Array> {
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
266
|
+
const read = (at: number): Promise<Uint8Array> =>
|
|
267
|
+
readUpload(uploadId, { start: at, end: at + UPLOAD_WINDOW_BYTES }).then((slice) => slice.bytes);
|
|
268
|
+
let at = 0;
|
|
269
|
+
let next = at < size ? read(at) : undefined;
|
|
270
|
+
while (next !== undefined) {
|
|
271
|
+
const bytes = await next;
|
|
272
|
+
if (bytes.length === 0) return;
|
|
273
|
+
at += UPLOAD_WINDOW_BYTES;
|
|
274
|
+
// Issued BEFORE the yield, so the store is fetching while the socket sends.
|
|
275
|
+
next = at < size ? read(at) : undefined;
|
|
276
|
+
yield bytes;
|
|
255
277
|
}
|
|
256
278
|
}
|
|
257
279
|
|
|
@@ -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,17 +101,25 @@
|
|
|
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,
|
|
@@ -209,10 +217,11 @@ export async function transcribeStreamFlow(input: { recording: string }) {
|
|
|
209
217
|
lastSize = at.size;
|
|
210
218
|
for (const segment of ready) done.add(segment.index);
|
|
211
219
|
// One step per segment, bounded, in an order a replay reproduces exactly —
|
|
212
|
-
// `ready` is derived from a journaled poll, and `
|
|
213
|
-
// calls in
|
|
220
|
+
// `ready` is derived from a journaled poll, and `mapConcurrent` issues its
|
|
221
|
+
// calls in list order. THE SAME STEP the classic flow uses, so a segment
|
|
222
|
+
// transcribed here reaches the page's live transcript identically.
|
|
214
223
|
parts.push(
|
|
215
|
-
...(await
|
|
224
|
+
...(await mapConcurrent(
|
|
216
225
|
ready,
|
|
217
226
|
segmentConcurrency((plan as StreamPlan).format),
|
|
218
227
|
(segment) => transcribeSegment(input.recording, (plan as StreamPlan).format, segment),
|
|
@@ -44,17 +44,28 @@
|
|
|
44
44
|
* app's own upload store and the run carries only its id; each step reads
|
|
45
45
|
* exactly its own window with `readUpload`. Sixty steps therefore move the
|
|
46
46
|
* recording once between them, not sixty times.
|
|
47
|
-
* - **The fan-out is bounded by `
|
|
47
|
+
* - **The fan-out is bounded by `mapConcurrent`, and the bound is not a detail.**
|
|
48
48
|
* The DevKit correlates a journal entry to a step call by the ORDER the call
|
|
49
|
-
* was issued in, so
|
|
50
|
-
*
|
|
51
|
-
*
|
|
52
|
-
*
|
|
53
|
-
*
|
|
49
|
+
* was issued in, so the primitive keeps a WINDOW over a cursor that only ever
|
|
50
|
+
* hands out the next index — the Nth call issued is segment N-1 however the
|
|
51
|
+
* calls settle. Its module doc carries the argument; what matters here is that
|
|
52
|
+
* there is no barrier, so a slow segment costs only itself.
|
|
53
|
+
* - **The transcript STREAMS as it is produced.** Each segment is emitted the
|
|
54
|
+
* moment it lands (`emit(TRANSCRIPT_STREAM, …)`), so the page renders the
|
|
55
|
+
* answer growing rather than a status line and then everything at once. That is
|
|
56
|
+
* the difference a fan-out can make to a reader and a run output cannot: an
|
|
57
|
+
* `output` exists only when the last segment does.
|
|
54
58
|
*/
|
|
55
59
|
|
|
56
60
|
import { throwFatalStepError } from "@alexkroman1/aai/step-errors";
|
|
57
|
-
import {
|
|
61
|
+
import { emit, mapConcurrent, readUpload, report, uploadInfo } from "@alexkroman1/aai/utils";
|
|
62
|
+
import {
|
|
63
|
+
clock,
|
|
64
|
+
countWords,
|
|
65
|
+
stitchTranscript,
|
|
66
|
+
TRANSCRIPT_STREAM,
|
|
67
|
+
type TranscriptChunk,
|
|
68
|
+
} from "./stitch.ts";
|
|
58
69
|
import { elapsed, timed, transcribeWav } from "./sync-api.ts";
|
|
59
70
|
import {
|
|
60
71
|
bytesPerSecond,
|
|
@@ -117,14 +128,16 @@ export const BYTES_IN_FLIGHT = 640 * 1024 * 1024;
|
|
|
117
128
|
* The widest fan-out, however small the segments are.
|
|
118
129
|
*
|
|
119
130
|
* Because {@link BYTES_IN_FLIGHT} stops being the binding constraint once segments
|
|
120
|
-
* are small — 16 kHz mono would divide out to 173 — and
|
|
121
|
-
* before that helps
|
|
122
|
-
*
|
|
123
|
-
*
|
|
124
|
-
*
|
|
125
|
-
*
|
|
126
|
-
*
|
|
127
|
-
*
|
|
131
|
+
* are small — 16 kHz mono would divide out to 173 — and the endpoint's own tail
|
|
132
|
+
* takes over before that helps: p95/p50 measured 1.1x at 20 concurrent against
|
|
133
|
+
* 1.5x at 320, with max/p50 reaching 6.7x (5.2s against 35.0s). A `503` carrying
|
|
134
|
+
* `retry-after: 1` is exactly such a straggler.
|
|
135
|
+
*
|
|
136
|
+
* **That tail used to be paid once per ROUND, and is not any more.** `mapConcurrent`
|
|
137
|
+
* was `mapInBatches` — sequential batches of `Promise.all` — so a batch's wall time
|
|
138
|
+
* was its slowest member and a run's was the sum of those. It is a window over a
|
|
139
|
+
* cursor now: a straggler holds up nothing but itself, and the numbers below were
|
|
140
|
+
* measured under the old barrier, so they are if anything pessimistic.
|
|
128
141
|
*
|
|
129
142
|
* 32 is the measured knee over 65 segments (1h37m of 48 kHz stereo), one concurrency
|
|
130
143
|
* per run, through this workflow:
|
|
@@ -142,6 +155,10 @@ export const BYTES_IN_FLIGHT = 640 * 1024 * 1024;
|
|
|
142
155
|
* also inert below a threshold — at 90-second segments, 32 only binds past 48
|
|
143
156
|
* minutes of audio — so on a typical recording the whole fan-out is in flight
|
|
144
157
|
* either way and this number changes nothing.
|
|
158
|
+
*
|
|
159
|
+
* The table above was measured under the old per-round barrier. Re-measuring it is
|
|
160
|
+
* worth doing before this number moves again: the window makes a wide fan-out
|
|
161
|
+
* cheaper at the tail, which if anything argues for a HIGHER knee.
|
|
145
162
|
*/
|
|
146
163
|
export const MAX_SEGMENT_CONCURRENCY = 32;
|
|
147
164
|
|
|
@@ -155,7 +172,7 @@ export const MAX_SEGMENT_CONCURRENCY = 32;
|
|
|
155
172
|
*
|
|
156
173
|
* Safe to call from a workflow BODY: `format` arrives from a journaled step result,
|
|
157
174
|
* so a replay derives the same width from the same bytes — which is what keeps
|
|
158
|
-
* `
|
|
175
|
+
* `mapConcurrent` issuing its calls in the order the journal recorded them.
|
|
159
176
|
*
|
|
160
177
|
* Overshooting stays recoverable whatever this returns: a `503` carries
|
|
161
178
|
* `retry-after` and `toStepError` below honours it, so the run completes having paid
|
|
@@ -178,9 +195,6 @@ export function segmentConcurrency(format: WavFormat): number {
|
|
|
178
195
|
*/
|
|
179
196
|
const HEADER_PROBE_BYTES = 64 * 1024;
|
|
180
197
|
|
|
181
|
-
/** Most words `stitchTranscript` will look back over to find a repeated seam. */
|
|
182
|
-
const MAX_SEAM_WORDS = 40;
|
|
183
|
-
|
|
184
198
|
/**
|
|
185
199
|
* What a finished run reports, whichever flow produced it.
|
|
186
200
|
*
|
|
@@ -201,7 +215,7 @@ export type Transcript = {
|
|
|
201
215
|
transcript: string;
|
|
202
216
|
};
|
|
203
217
|
|
|
204
|
-
/** What one segment's request came back with. */
|
|
218
|
+
/** What one segment's request came back with — the STEP's result, journaled. */
|
|
205
219
|
export type SegmentTranscript = {
|
|
206
220
|
index: number;
|
|
207
221
|
text: string;
|
|
@@ -216,15 +230,18 @@ export type SegmentTranscript = {
|
|
|
216
230
|
export async function transcribeFlow(input: { recording: string }) {
|
|
217
231
|
"use workflow";
|
|
218
232
|
|
|
219
|
-
|
|
220
|
-
|
|
233
|
+
// Both at once: neither needs the other, and issued together they are one
|
|
234
|
+
// round trip instead of two before any audio is read. The ORDER is still a
|
|
235
|
+
// pure function of this line — the two calls go out synchronously, left to
|
|
236
|
+
// right — which is what a replay reproduces.
|
|
237
|
+
const [startedAt, plan] = await Promise.all([startClock(), splitRecording(input.recording)]);
|
|
221
238
|
|
|
222
239
|
// One step per segment, bounded, in an order a replay reproduces exactly.
|
|
223
240
|
// A failed segment fails the RUN, deliberately: every sibling that finished is
|
|
224
241
|
// already journaled, so the resume replays those for free and re-issues only
|
|
225
242
|
// what is missing, where catching here to salvage a partial transcript would
|
|
226
243
|
// return a recording with a silent hole in it and report success.
|
|
227
|
-
const parts = await
|
|
244
|
+
const parts = await mapConcurrent(plan.segments, segmentConcurrency(plan.format), (segment) =>
|
|
228
245
|
transcribeSegment(input.recording, plan.format, segment),
|
|
229
246
|
);
|
|
230
247
|
|
|
@@ -309,6 +326,17 @@ export async function transcribeSegment(
|
|
|
309
326
|
// The LATENCY, which is what says whether the concurrency bound or the endpoint
|
|
310
327
|
// is the thing limiting the run — see `timed`'s doc.
|
|
311
328
|
await report(`Transcribed ${clock(segment.startMs)}–${clock(segment.endMs)} in ${elapsed(ms)}.`);
|
|
329
|
+
// And the WORDS, into their own stream, which is what makes this run's answer
|
|
330
|
+
// streamable rather than only its narration: the page stitches whatever has
|
|
331
|
+
// arrived and renders the transcript growing, minutes before `output` exists.
|
|
332
|
+
// Its own namespace because `report`'s stream carries sentences a page prints
|
|
333
|
+
// verbatim — see `emit`'s doc.
|
|
334
|
+
await emit(TRANSCRIPT_STREAM, {
|
|
335
|
+
index: segment.index,
|
|
336
|
+
startMs: segment.startMs,
|
|
337
|
+
endMs: segment.endMs,
|
|
338
|
+
text,
|
|
339
|
+
} satisfies TranscriptChunk);
|
|
312
340
|
return { index: segment.index, text };
|
|
313
341
|
}
|
|
314
342
|
|
|
@@ -337,7 +365,7 @@ export async function mergeTranscript(
|
|
|
337
365
|
|
|
338
366
|
await report(`Stitching ${parts.length} segment${parts.length === 1 ? "" : "s"} together.`);
|
|
339
367
|
|
|
340
|
-
// `
|
|
368
|
+
// `mapConcurrent` resolves in ITEM order however the calls settled, so this is
|
|
341
369
|
// already ordered — sorted anyway, because the merge is where an ordering
|
|
342
370
|
// mistake would be invisible rather than loud.
|
|
343
371
|
const ordered = [...parts].sort((a, b) => a.index - b.index);
|
|
@@ -358,7 +386,7 @@ export async function mergeTranscript(
|
|
|
358
386
|
};
|
|
359
387
|
}
|
|
360
388
|
|
|
361
|
-
// ----
|
|
389
|
+
// ---- The run's own clock ----------------------------------------------------
|
|
362
390
|
|
|
363
391
|
/**
|
|
364
392
|
* When the run started, as epoch ms.
|
|
@@ -380,66 +408,17 @@ export async function startClock(): Promise<number> {
|
|
|
380
408
|
return Date.now();
|
|
381
409
|
}
|
|
382
410
|
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
* the same audio. This finds the longest such run and removes one copy.
|
|
395
|
-
*
|
|
396
|
-
* Comparison is on `seamKey`, not the raw words: the two passes punctuate
|
|
397
|
-
* differently at their own edges (one ends a sentence where the other is
|
|
398
|
-
* mid-clause), so `"today."` and `"today"` are the same word and a raw compare
|
|
399
|
-
* finds no seam at all. The text KEPT is the raw text — only the match is
|
|
400
|
-
* normalized.
|
|
401
|
-
*
|
|
402
|
-
* A missed seam repeats a few words, which a reader can see and forgive. A
|
|
403
|
-
* false one would delete speech, so the search is bounded at
|
|
404
|
-
* `MAX_SEAM_WORDS` and always prefers the LONGEST match: a single repeated
|
|
405
|
-
* "the" is not evidence of anything, and requiring the longest run is what stops
|
|
406
|
-
* it counting as one when a longer match is available.
|
|
407
|
-
*/
|
|
408
|
-
export function stitchTranscript(parts: readonly string[]): string {
|
|
409
|
-
const merged: string[] = [];
|
|
410
|
-
for (const part of parts) {
|
|
411
|
-
const next = part.split(/\s+/).filter(Boolean);
|
|
412
|
-
if (next.length === 0) continue;
|
|
413
|
-
if (merged.length === 0) {
|
|
414
|
-
merged.push(...next);
|
|
415
|
-
continue;
|
|
416
|
-
}
|
|
417
|
-
merged.push(...next.slice(seamLength(merged, next)));
|
|
418
|
-
}
|
|
419
|
-
return merged.join(" ");
|
|
420
|
-
}
|
|
421
|
-
|
|
422
|
-
/** How many leading words of `next` repeat the tail of `merged`. */
|
|
423
|
-
function seamLength(merged: readonly string[], next: readonly string[]): number {
|
|
424
|
-
const limit = Math.min(MAX_SEAM_WORDS, merged.length, next.length);
|
|
425
|
-
// Longest first, so a short accidental match never wins over a real seam.
|
|
426
|
-
for (let length = limit; length > 0; length--) {
|
|
427
|
-
const tail = merged.slice(merged.length - length);
|
|
428
|
-
if (tail.every((word, at) => seamKey(word) === seamKey(next[at] ?? ""))) return length;
|
|
429
|
-
}
|
|
430
|
-
return 0;
|
|
431
|
-
}
|
|
432
|
-
|
|
433
|
-
/** Words in a string. Exported so the streaming flow reports the same number. */
|
|
434
|
-
export function countWords(text: string): number {
|
|
435
|
-
return text.split(/\s+/).filter(Boolean).length;
|
|
436
|
-
}
|
|
437
|
-
|
|
438
|
-
/** `m:ss` for the progress log — a byte offset means nothing to a reader. */
|
|
439
|
-
export function clock(ms: number): string {
|
|
440
|
-
const seconds = Math.max(0, Math.round(ms / 1000));
|
|
441
|
-
return `${Math.floor(seconds / 60)}:${String(seconds % 60).padStart(2, "0")}`;
|
|
442
|
-
}
|
|
411
|
+
// `clock` and `countWords` are re-exported rather than re-declared: `stream.ts`
|
|
412
|
+
// and `batch.ts` already import them from this module, and the split that let the
|
|
413
|
+
// PAGE stitch a partial transcript should not ripple through every flow.
|
|
414
|
+
export {
|
|
415
|
+
clock,
|
|
416
|
+
countWords,
|
|
417
|
+
stitchChunks,
|
|
418
|
+
stitchTranscript,
|
|
419
|
+
TRANSCRIPT_STREAM,
|
|
420
|
+
type TranscriptChunk,
|
|
421
|
+
} from "./stitch.ts";
|
|
443
422
|
|
|
444
423
|
// ---- I/O helpers ------------------------------------------------------------
|
|
445
424
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@alexkroman1/aai-cli",
|
|
3
|
-
"version": "6.
|
|
3
|
+
"version": "6.5.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"bin": {
|
|
6
6
|
"aai": "bin.mjs"
|
|
@@ -44,8 +44,8 @@
|
|
|
44
44
|
"p-timeout": "^7.0.1",
|
|
45
45
|
"vite": "^8.2.1",
|
|
46
46
|
"zod": "^4.4.3",
|
|
47
|
-
"@alexkroman1/aai": "6.
|
|
48
|
-
"@alexkroman1/aai-ui": "6.
|
|
47
|
+
"@alexkroman1/aai": "6.5.0",
|
|
48
|
+
"@alexkroman1/aai-ui": "6.5.0"
|
|
49
49
|
},
|
|
50
50
|
"devDependencies": {
|
|
51
51
|
"playwright": "^1.62.1",
|