@alexkroman1/aai-cli 6.3.1 → 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 +324 -11
- package/dist/templates/transcription-workflow/agent.ts +76 -1
- package/dist/templates/transcription-workflow/api-help.tsx +214 -0
- package/dist/templates/transcription-workflow/client.tsx +292 -8
- package/dist/templates/transcription-workflow/workflows/batch.ts +301 -0
- package/dist/templates/transcription-workflow/workflows/stitch.ts +133 -0
- package/dist/templates/transcription-workflow/workflows/stream.ts +350 -0
- package/dist/templates/transcription-workflow/workflows/sync-api.ts +139 -0
- package/dist/templates/transcription-workflow/workflows/transcribe.ts +200 -183
- 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,16 +17,22 @@
|
|
|
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
|
+
import { readUpload } from "@alexkroman1/aai/utils";
|
|
21
22
|
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
|
22
|
-
import { RetryableError } from "workflow";
|
|
23
|
+
import { FatalError, RetryableError } from "workflow";
|
|
23
24
|
import { z } from "zod";
|
|
24
|
-
import agentDef, { transcribe } from "./agent.ts";
|
|
25
|
+
import agentDef, { transcribe, transcribeBatch, transcribeStream } from "./agent.ts";
|
|
26
|
+
import { createJob, pollTranscript, readTranscript, uploadToProvider } from "./workflows/batch.ts";
|
|
27
|
+
import { planStreamed, probeUpload } from "./workflows/stream.ts";
|
|
28
|
+
|
|
25
29
|
import {
|
|
26
30
|
clock,
|
|
27
31
|
mergeTranscript,
|
|
28
32
|
splitRecording,
|
|
33
|
+
stitchChunks,
|
|
29
34
|
stitchTranscript,
|
|
35
|
+
TRANSCRIPT_STREAM,
|
|
30
36
|
transcribeSegment,
|
|
31
37
|
} from "./workflows/transcribe.ts";
|
|
32
38
|
import {
|
|
@@ -49,6 +55,16 @@ const SYNC_ORIGIN = "https://sync.assemblyai.com";
|
|
|
49
55
|
/** The id every spec below uploads under. */
|
|
50
56
|
const UPLOAD_ID = "upl_test";
|
|
51
57
|
|
|
58
|
+
/**
|
|
59
|
+
* A fixed run-start epoch, so `elapsedMs` is assertable at all.
|
|
60
|
+
*
|
|
61
|
+
* `startClock` is a step in production; a spec supplies the value directly, which is
|
|
62
|
+
* the point of threading it as an argument rather than reading a clock inside the
|
|
63
|
+
* merge — the duration is then a function of journaled values and not of how long the
|
|
64
|
+
* test took.
|
|
65
|
+
*/
|
|
66
|
+
const STARTED_AT = 1_000_000;
|
|
67
|
+
|
|
52
68
|
/**
|
|
53
69
|
* Publish one in-memory upload, the way `createServer` publishes a real store.
|
|
54
70
|
*
|
|
@@ -109,10 +125,28 @@ function wavFile(
|
|
|
109
125
|
return head;
|
|
110
126
|
}
|
|
111
127
|
|
|
112
|
-
describe("the agent declares its
|
|
113
|
-
test("under the
|
|
114
|
-
|
|
128
|
+
describe("the agent declares its three workflows and nothing else", () => {
|
|
129
|
+
test("under the names the REST route resolves them by", () => {
|
|
130
|
+
// The page starts a run by these strings, so a rename is a runtime 400 rather
|
|
131
|
+
// than a compile error — which is what makes pinning them worth a test.
|
|
132
|
+
expect(Object.keys(agentDef.workflows ?? {})).toEqual([
|
|
133
|
+
"transcribe",
|
|
134
|
+
"transcribeStream",
|
|
135
|
+
"transcribeBatch",
|
|
136
|
+
]);
|
|
115
137
|
expect(agentDef.workflows?.transcribe).toBe(transcribe);
|
|
138
|
+
expect(agentDef.workflows?.transcribeStream).toBe(transcribeStream);
|
|
139
|
+
expect(agentDef.workflows?.transcribeBatch).toBe(transcribeBatch);
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
test("all three take `recording` as an UPLOAD, which is what makes one picker serve them", () => {
|
|
143
|
+
// There is no second kind of declaration: `recording` carries an upload id in
|
|
144
|
+
// every flow, and the streaming one differs only in that the CLIENT chose the id
|
|
145
|
+
// and PUT the file to it. A divergence here would mean the form had to ask a
|
|
146
|
+
// person how the bytes should travel.
|
|
147
|
+
for (const flow of [transcribe, transcribeStream, transcribeBatch]) {
|
|
148
|
+
expect(flow.uploads).toEqual(["recording"]);
|
|
149
|
+
}
|
|
116
150
|
});
|
|
117
151
|
|
|
118
152
|
test("with no tools, because the interface is the page and the API", () => {
|
|
@@ -389,6 +423,52 @@ describe("stitchTranscript", () => {
|
|
|
389
423
|
});
|
|
390
424
|
});
|
|
391
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
|
+
|
|
392
472
|
describe("clock", () => {
|
|
393
473
|
test("renders a position a reader can find in the recording", () => {
|
|
394
474
|
expect(clock(0)).toBe("0:00");
|
|
@@ -493,6 +573,30 @@ describe("transcribeSegment", () => {
|
|
|
493
573
|
expect(decoded).toContain("RIFF");
|
|
494
574
|
});
|
|
495
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
|
+
|
|
496
600
|
test("fails FATALLY with no API key rather than retrying five times", async () => {
|
|
497
601
|
vi.stubEnv("ASSEMBLYAI_API_KEY", "");
|
|
498
602
|
stubProvider();
|
|
@@ -538,10 +642,15 @@ describe("transcribeSegment", () => {
|
|
|
538
642
|
describe("mergeTranscript", () => {
|
|
539
643
|
test("stitches the segments in index order, whatever order they arrive in", async () => {
|
|
540
644
|
publishRecording(new Uint8Array(1));
|
|
541
|
-
const merged = await mergeTranscript(
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
645
|
+
const merged = await mergeTranscript(
|
|
646
|
+
UPLOAD_ID,
|
|
647
|
+
12_000,
|
|
648
|
+
[
|
|
649
|
+
{ index: 1, text: "on Friday if the tests pass" },
|
|
650
|
+
{ index: 0, text: "we ship on Friday" },
|
|
651
|
+
],
|
|
652
|
+
STARTED_AT,
|
|
653
|
+
);
|
|
545
654
|
expect(merged.transcript).toBe("we ship on Friday if the tests pass");
|
|
546
655
|
expect(merged.words).toBe(8);
|
|
547
656
|
expect(merged).toMatchObject({ segments: 2, durationMs: 12_000 });
|
|
@@ -549,7 +658,7 @@ describe("mergeTranscript", () => {
|
|
|
549
658
|
|
|
550
659
|
test("names the FILE it transcribed, not the id the run carried", async () => {
|
|
551
660
|
publishRecording(new Uint8Array(1), "standup.wav");
|
|
552
|
-
const merged = await mergeTranscript(UPLOAD_ID, 1000, [{ index: 0, text: "hi" }]);
|
|
661
|
+
const merged = await mergeTranscript(UPLOAD_ID, 1000, [{ index: 0, text: "hi" }], STARTED_AT);
|
|
553
662
|
expect(merged.source).toBe("standup.wav");
|
|
554
663
|
});
|
|
555
664
|
});
|
|
@@ -561,3 +670,207 @@ function concat(a: Uint8Array, b: Uint8Array): Uint8Array {
|
|
|
561
670
|
out.set(b, a.length);
|
|
562
671
|
return out;
|
|
563
672
|
}
|
|
673
|
+
|
|
674
|
+
/**
|
|
675
|
+
* The STREAMING flow's own steps.
|
|
676
|
+
*
|
|
677
|
+
* Same honest line as the classic half above: the steps are driven directly and the
|
|
678
|
+
* body is not, because imported through vitest a `"use step"` function is an ordinary
|
|
679
|
+
* async function. Almost nothing here is new — the transcribing and the merging are
|
|
680
|
+
* `transcribe.ts`'s own steps, called unchanged — so what is worth asserting is the
|
|
681
|
+
* two things this flow adds: reading how far the upload has got, and planning from a
|
|
682
|
+
* header while most of the file is still missing.
|
|
683
|
+
*/
|
|
684
|
+
describe("the streaming flow", () => {
|
|
685
|
+
const FORMAT: WavFormat = { ...MONO_16K, dataStart: 44, dataEnd: 44 + 320_000 };
|
|
686
|
+
|
|
687
|
+
/** Publish a partially-arrived upload: `stored` bytes of a `declared`-byte file. */
|
|
688
|
+
function publishPartial(stored: number, declared: number, complete = false) {
|
|
689
|
+
const bytes = new Uint8Array(44 + stored);
|
|
690
|
+
bytes.set(wavFile(MONO_16K, declared), 0);
|
|
691
|
+
restore = stubUploads({
|
|
692
|
+
[UPLOAD_ID]: { bytes, name: "standup.wav", type: "audio/wav", complete },
|
|
693
|
+
});
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
test("probeUpload reports what has ARRIVED and whether that is all", async () => {
|
|
697
|
+
publishPartial(1000, 320_000);
|
|
698
|
+
// The poll the body runs. `complete` is separate from `size` because a size that
|
|
699
|
+
// stopped growing is not a claim that the file is finished.
|
|
700
|
+
await expect(probeUpload(UPLOAD_ID)).resolves.toEqual({ size: 44 + 1000, complete: false });
|
|
701
|
+
});
|
|
702
|
+
|
|
703
|
+
test("probeUpload reports complete once it is", async () => {
|
|
704
|
+
publishPartial(320_000, 320_000, true);
|
|
705
|
+
await expect(probeUpload(UPLOAD_ID)).resolves.toMatchObject({ complete: true });
|
|
706
|
+
});
|
|
707
|
+
|
|
708
|
+
test("planStreamed plans the WHOLE recording from a header that arrived alone", async () => {
|
|
709
|
+
// The one real difference from `splitRecording`: only 1000 bytes of audio are
|
|
710
|
+
// stored, and the plan still covers the 320,000 the header declares. Planning
|
|
711
|
+
// from what has arrived would fan out over a fraction of the recording and report
|
|
712
|
+
// success — which is the failure this argument exists to prevent.
|
|
713
|
+
publishPartial(1000, 320_000);
|
|
714
|
+
const plan = await planStreamed(UPLOAD_ID);
|
|
715
|
+
expect(plan.format.dataEnd).toBe(44 + 320_000);
|
|
716
|
+
expect(plan.segments.at(-1)?.end).toBe(44 + 320_000);
|
|
717
|
+
expect(plan.segments.length).toBe(planSegments(FORMAT).length);
|
|
718
|
+
});
|
|
719
|
+
|
|
720
|
+
test("planStreamed refuses a WAV that declares no length, naming the other flow", async () => {
|
|
721
|
+
// `0` means "unknown", and there is nothing to compute a segment list from until
|
|
722
|
+
// the file has finished — which is exactly what `transcribe` is for.
|
|
723
|
+
const bytes = new Uint8Array(44 + 100);
|
|
724
|
+
bytes.set(wavFile(MONO_16K, 100, { declaredDataSize: 0 }), 0);
|
|
725
|
+
restore = stubUploads({ [UPLOAD_ID]: { bytes, complete: false } });
|
|
726
|
+
await expect(planStreamed(UPLOAD_ID)).rejects.toThrow(/declares no data length/);
|
|
727
|
+
});
|
|
728
|
+
|
|
729
|
+
test("planStreamed refuses a file that is not a WAV, terminally", async () => {
|
|
730
|
+
restore = stubUploads({ [UPLOAD_ID]: { bytes: new Uint8Array(2000), complete: false } });
|
|
731
|
+
// Fatal, not retryable: three more attempts read the same bytes.
|
|
732
|
+
await expect(planStreamed(UPLOAD_ID)).rejects.toBeInstanceOf(FatalError);
|
|
733
|
+
});
|
|
734
|
+
|
|
735
|
+
test("a segment reads SHORT rather than failing when its bytes have not landed", async () => {
|
|
736
|
+
// The property the whole flow rests on, and it predates streaming: `readUpload`
|
|
737
|
+
// clamps its window to what is stored. So a body that asks slightly early gets
|
|
738
|
+
// what exists — which is why the body checks `end <= size` and can trust the
|
|
739
|
+
// clamp for the final segment of a file that came up short.
|
|
740
|
+
publishPartial(1000, 320_000);
|
|
741
|
+
const slice = await readUpload(UPLOAD_ID, { start: 44, end: 44 + 320_000 });
|
|
742
|
+
expect(slice.bytes.length).toBe(1000);
|
|
743
|
+
expect(slice.end).toBe(44 + 1000);
|
|
744
|
+
});
|
|
745
|
+
});
|
|
746
|
+
|
|
747
|
+
/**
|
|
748
|
+
* The ASYNC flow's steps.
|
|
749
|
+
*
|
|
750
|
+
* Driven against a stubbed `stepFetch`, like the sync flow's — and note what that
|
|
751
|
+
* makes assertable: the three calls this flow makes are the whole of it, so the
|
|
752
|
+
* assertions are about the CONTRACT with the provider (an id survives, a failed job
|
|
753
|
+
* is terminal, the file is streamed rather than buffered) rather than about
|
|
754
|
+
* arithmetic this flow does not do.
|
|
755
|
+
*/
|
|
756
|
+
describe("the async flow", () => {
|
|
757
|
+
beforeEach(() => {
|
|
758
|
+
vi.stubEnv("ASSEMBLYAI_API_KEY", "sk-test");
|
|
759
|
+
});
|
|
760
|
+
|
|
761
|
+
const batchStubs: (() => void)[] = [];
|
|
762
|
+
afterEach(() => {
|
|
763
|
+
for (const undo of batchStubs.splice(0)) undo();
|
|
764
|
+
});
|
|
765
|
+
|
|
766
|
+
/** Answer the async API, recording what was sent. */
|
|
767
|
+
function stubBatch(answer: (url: string) => { status?: number; body?: unknown }) {
|
|
768
|
+
const stub = stubStepFetch((req) => answer(req.url));
|
|
769
|
+
batchStubs.push(stub.restore);
|
|
770
|
+
return stub.calls;
|
|
771
|
+
}
|
|
772
|
+
|
|
773
|
+
test("uploadToProvider streams the file and answers with the provider's URL", async () => {
|
|
774
|
+
publishRecording(new Uint8Array(5000), "standup.wav");
|
|
775
|
+
const calls = stubBatch(() => ({ body: { upload_url: "https://cdn.example/abc" } }));
|
|
776
|
+
await expect(uploadToProvider(UPLOAD_ID)).resolves.toEqual({
|
|
777
|
+
audioUrl: "https://cdn.example/abc",
|
|
778
|
+
});
|
|
779
|
+
expect(calls.map((one) => one.url)).toEqual(["https://api.assemblyai.com/v2/upload"]);
|
|
780
|
+
});
|
|
781
|
+
|
|
782
|
+
test("the file is STREAMED, so a step never holds a whole recording", async () => {
|
|
783
|
+
publishRecording(new Uint8Array(5000), "standup.wav");
|
|
784
|
+
const calls = stubBatch(() => ({ body: { upload_url: "https://cdn.example/abc" } }));
|
|
785
|
+
await uploadToProvider(UPLOAD_ID);
|
|
786
|
+
// `stubStepFetch` drains a streaming body into bytes, so what this asserts is that
|
|
787
|
+
// every byte went out — the streaming is what keeps a gigabyte off the heap, and
|
|
788
|
+
// the bytes arriving intact is what says the windowing is right.
|
|
789
|
+
const sent = calls[0]?.body;
|
|
790
|
+
expect(sent).toBeInstanceOf(Uint8Array);
|
|
791
|
+
expect(sent instanceof Uint8Array ? sent.length : -1).toBe(5000);
|
|
792
|
+
});
|
|
793
|
+
|
|
794
|
+
test("the upload is a SEPARATE step, so a failed submit does not re-send the file", async () => {
|
|
795
|
+
// Found by running it: as one step, a 400 on the create call retried the whole
|
|
796
|
+
// thing five times and re-uploaded 24 MB on each attempt. The split is what makes
|
|
797
|
+
// a retry of the cheap half cost the cheap half.
|
|
798
|
+
publishRecording(new Uint8Array(5000));
|
|
799
|
+
const calls = stubBatch(() => ({ status: 400, body: { error: "bad field" } }));
|
|
800
|
+
await expect(createJob("https://cdn.example/abc")).rejects.toBeInstanceOf(FatalError);
|
|
801
|
+
// One call, and it is not the upload.
|
|
802
|
+
expect(calls).toHaveLength(1);
|
|
803
|
+
expect(calls[0]?.url).toBe("https://api.assemblyai.com/v2/transcript");
|
|
804
|
+
});
|
|
805
|
+
|
|
806
|
+
test("createJob asks for `speech_models`, plural — the singular field is a 400", async () => {
|
|
807
|
+
publishRecording(new Uint8Array(10));
|
|
808
|
+
const calls = stubBatch(() => ({ body: { id: "tr_1" } }));
|
|
809
|
+
await expect(createJob("https://cdn.example/abc")).resolves.toEqual({ id: "tr_1" });
|
|
810
|
+
const sent = JSON.parse(String(calls[0]?.body)) as Record<string, unknown>;
|
|
811
|
+
// The async API deprecated `speech_model` and answers 400 for any current model
|
|
812
|
+
// name passed to it — which is how the first live run of this flow failed. The
|
|
813
|
+
// STREAMING API still uses the singular field, so neither is "the" spelling.
|
|
814
|
+
expect(sent).toMatchObject({ speech_models: ["universal-3-5-pro"] });
|
|
815
|
+
expect(sent.speech_model).toBeUndefined();
|
|
816
|
+
});
|
|
817
|
+
|
|
818
|
+
test("a job the provider gave up on is TERMINAL, not polled forever", async () => {
|
|
819
|
+
publishRecording(new Uint8Array(10));
|
|
820
|
+
stubBatch(() => ({ body: { status: "error", error: "audio too quiet" } }));
|
|
821
|
+
// The provider has decided; no number of polls changes it, so this must not come
|
|
822
|
+
// back as "not done yet".
|
|
823
|
+
await expect(pollTranscript("tr_1")).rejects.toBeInstanceOf(FatalError);
|
|
824
|
+
});
|
|
825
|
+
|
|
826
|
+
test("pollTranscript answers `done` on completed and not before", async () => {
|
|
827
|
+
publishRecording(new Uint8Array(10));
|
|
828
|
+
stubBatch(() => ({ body: { status: "processing" } }));
|
|
829
|
+
await expect(pollTranscript("tr_1")).resolves.toEqual({ done: false, status: "processing" });
|
|
830
|
+
});
|
|
831
|
+
|
|
832
|
+
test("an unknown status is NOT done, so a new one cannot end a run early", async () => {
|
|
833
|
+
publishRecording(new Uint8Array(10));
|
|
834
|
+
stubBatch(() => ({ body: {} }));
|
|
835
|
+
await expect(pollTranscript("tr_1")).resolves.toMatchObject({ done: false });
|
|
836
|
+
});
|
|
837
|
+
|
|
838
|
+
test("readTranscript reports the provider's own duration and ONE segment", async () => {
|
|
839
|
+
publishRecording(new Uint8Array(10), "standup.wav");
|
|
840
|
+
stubBatch(() => ({ body: { text: " hello there ", audio_duration: 12.5 } }));
|
|
841
|
+
await expect(readTranscript(UPLOAD_ID, "tr_1", STARTED_AT)).resolves.toMatchObject({
|
|
842
|
+
source: "standup.wav",
|
|
843
|
+
// Not a fudge: the async API transcribed the recording in one piece, which is
|
|
844
|
+
// the difference this flow is here to show.
|
|
845
|
+
segments: 1,
|
|
846
|
+
durationMs: 12_500,
|
|
847
|
+
words: 2,
|
|
848
|
+
transcript: "hello there",
|
|
849
|
+
});
|
|
850
|
+
});
|
|
851
|
+
|
|
852
|
+
test("a rate limit is RETRYABLE, so a busy minute does not fail the run", async () => {
|
|
853
|
+
publishRecording(new Uint8Array(10));
|
|
854
|
+
stubBatch(() => ({ status: 429, body: { error: "slow down" } }));
|
|
855
|
+
await expect(pollTranscript("tr_1")).rejects.toBeInstanceOf(RetryableError);
|
|
856
|
+
});
|
|
857
|
+
|
|
858
|
+
test("all three flows report the same SHAPE, which is what lets one page render any", async () => {
|
|
859
|
+
publishRecording(new Uint8Array(10), "standup.wav");
|
|
860
|
+
stubBatch(() => ({ body: { text: "hi", audio_duration: 1 } }));
|
|
861
|
+
const batched = await readTranscript(UPLOAD_ID, "tr_1", STARTED_AT);
|
|
862
|
+
// One key set, so the page's summary line renders every flow's output. A field on
|
|
863
|
+
// one flow and not the others is a panel that shows it for some runs and not
|
|
864
|
+
// others, with nothing saying why.
|
|
865
|
+
expect(Object.keys(batched).sort()).toEqual([
|
|
866
|
+
"durationMs",
|
|
867
|
+
"elapsedMs",
|
|
868
|
+
"segments",
|
|
869
|
+
"source",
|
|
870
|
+
"transcript",
|
|
871
|
+
"words",
|
|
872
|
+
]);
|
|
873
|
+
// And the wall clock really is measured from what it was handed.
|
|
874
|
+
expect(batched.elapsedMs).toBeGreaterThan(0);
|
|
875
|
+
});
|
|
876
|
+
});
|
|
@@ -38,6 +38,25 @@
|
|
|
38
38
|
* None of that is this template's code. Uploads are the SDK's, for the reason
|
|
39
39
|
* every workflow app hits this wall on its first form.
|
|
40
40
|
*
|
|
41
|
+
* ## Three flows over one job, so they can be compared
|
|
42
|
+
*
|
|
43
|
+
* | | `transcribe` | `transcribeStream` | `transcribeBatch` |
|
|
44
|
+
* | --- | --- | --- | --- |
|
|
45
|
+
* | provider API | sync | sync | **async** |
|
|
46
|
+
* | run starts | after the upload | **before** it | after the upload |
|
|
47
|
+
* | client sends | `POST /uploads` | `PUT /uploads/<id>` | `POST /uploads` |
|
|
48
|
+
* | shape | plan, fan out, merge | poll, fan out, merge | submit, poll, read |
|
|
49
|
+
* | client hook | `useWorkflowSubmit` | `useWorkflowStream` | `useWorkflowSubmit` |
|
|
50
|
+
* | accepts | linear-PCM WAV | linear-PCM WAV | **any audio** |
|
|
51
|
+
* | segments | 7 for 10 minutes | 7 | **1** |
|
|
52
|
+
*
|
|
53
|
+
* The first two are the same fan-out arranged two ways, and the difference between
|
|
54
|
+
* them is measured rather than claimed — see `workflows/stream.ts`, which records
|
|
55
|
+
* what overlapping the upload actually saves (bounded by the transcription, not
|
|
56
|
+
* proportional to the file). The third does none of that work and is what a real
|
|
57
|
+
* product would probably ship; it is here because a template that only showed the
|
|
58
|
+
* clever option would be hiding the simple one.
|
|
59
|
+
*
|
|
41
60
|
* ## It is scriptable, which is the other half of having an API
|
|
42
61
|
*
|
|
43
62
|
* The page is one caller. Two requests do the same thing from a shell — upload,
|
|
@@ -52,10 +71,17 @@
|
|
|
52
71
|
* -d "{\"workflow\":\"transcribe\",\"wait\":30000,\"input\":{
|
|
53
72
|
* \"recording\":\"$ID\"}}"
|
|
54
73
|
* ```
|
|
74
|
+
*
|
|
75
|
+
* The streaming flow is the same three verbs in a different order — start, then
|
|
76
|
+
* upload parts, then seal — and the page renders the whole recipe under "Use the
|
|
77
|
+
* API without this page". `AGENT.md` in this template is the copy a script author
|
|
78
|
+
* reads.
|
|
55
79
|
*/
|
|
56
80
|
|
|
57
81
|
import { workflow, workflowApp } from "@alexkroman1/aai";
|
|
58
82
|
import { z } from "zod";
|
|
83
|
+
import { transcribeBatchFlow } from "./workflows/batch.ts";
|
|
84
|
+
import { transcribeStreamFlow } from "./workflows/stream.ts";
|
|
59
85
|
import { transcribeFlow } from "./workflows/transcribe.ts";
|
|
60
86
|
|
|
61
87
|
/**
|
|
@@ -83,9 +109,58 @@ export const transcribe = workflow({
|
|
|
83
109
|
run: transcribeFlow,
|
|
84
110
|
});
|
|
85
111
|
|
|
112
|
+
/**
|
|
113
|
+
* The same work, started BEFORE the recording has finished uploading.
|
|
114
|
+
*
|
|
115
|
+
* Declared beside `transcribe` rather than replacing it, because the two are worth
|
|
116
|
+
* reading against each other: this one shows the transcript growing while the bytes
|
|
117
|
+
* are still moving, and the other is the shape to understand first — three steps in a
|
|
118
|
+
* straight line, with no polling.
|
|
119
|
+
*
|
|
120
|
+
* **The declaration is IDENTICAL, including `uploads`.** That is the point of the
|
|
121
|
+
* mechanism: `recording` carries an upload id either way, and what differs is only
|
|
122
|
+
* that the client CHOSE the id and `PUT` the file to it, so the id was valid before
|
|
123
|
+
* the bytes were. `useWorkflowStream` is the client half; `workflows/stream.ts` is
|
|
124
|
+
* the body, and it reuses this file's own `transcribeSegment` unchanged.
|
|
125
|
+
*/
|
|
126
|
+
export const transcribeStream = workflow({
|
|
127
|
+
description: "Transcribe a recording while it is still uploading",
|
|
128
|
+
input: z.object({
|
|
129
|
+
recording: z.string().describe("A linear-PCM WAV recording (16-bit or 8-bit, any rate)"),
|
|
130
|
+
}),
|
|
131
|
+
uploads: ["recording"],
|
|
132
|
+
run: transcribeStreamFlow,
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* The same work again, handed to AssemblyAI's ASYNC API instead of cut up.
|
|
137
|
+
*
|
|
138
|
+
* The third desk, and the one a real product would probably ship. Both flows above
|
|
139
|
+
* exist because the SYNC endpoint answers inside the request and pays for it with a
|
|
140
|
+
* 120-second, 40 MB cap — so a long recording has to be planned, fanned out and
|
|
141
|
+
* stitched. The async API has no cap: submit a job, poll, read the text.
|
|
142
|
+
*
|
|
143
|
+
* It is declared here so the page can run all three over the same file and show what
|
|
144
|
+
* each costs. What this one gives up is the inside of the work — the latency is the
|
|
145
|
+
* provider's queue rather than a fan-out you can tune — and what it gains is
|
|
146
|
+
* everything the other two spend code on, plus formats they refuse: the async API
|
|
147
|
+
* takes compressed audio, so an m4a off a phone works where a WAV-only cut does not.
|
|
148
|
+
*
|
|
149
|
+
* `workflows/batch.ts` carries the rest, including why the wait is what makes this a
|
|
150
|
+
* workflow rather than a request.
|
|
151
|
+
*/
|
|
152
|
+
export const transcribeBatch = workflow({
|
|
153
|
+
description: "Transcribe a recording through the async API, polling until it is done",
|
|
154
|
+
input: z.object({
|
|
155
|
+
recording: z.string().describe("Any recording the async API accepts — WAV, MP3, M4A"),
|
|
156
|
+
}),
|
|
157
|
+
uploads: ["recording"],
|
|
158
|
+
run: transcribeBatchFlow,
|
|
159
|
+
});
|
|
160
|
+
|
|
86
161
|
export default workflowApp({
|
|
87
162
|
name: "Transcription Desk",
|
|
88
|
-
workflows: { transcribe },
|
|
163
|
+
workflows: { transcribe, transcribeStream, transcribeBatch },
|
|
89
164
|
// Checked at deploy time, so a missing key is a warning naming it rather than
|
|
90
165
|
// a run that fails on its second step.
|
|
91
166
|
requiredEnv: ["ASSEMBLYAI_API_KEY"],
|