@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.
@@ -13,8 +13,8 @@
13
13
  "publish:agent": "aai publish"
14
14
  },
15
15
  "dependencies": {
16
- "@alexkroman1/aai": "^6.3.1",
17
- "@alexkroman1/aai-ui": "^6.3.1",
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.3.1",
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 workflow and nothing else", () => {
113
- test("under the name the REST route resolves it by", () => {
114
- expect(Object.keys(agentDef.workflows ?? {})).toEqual(["transcribe"]);
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(UPLOAD_ID, 12_000, [
542
- { index: 1, text: "on Friday if the tests pass" },
543
- { index: 0, text: "we ship on Friday" },
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"],