@alexkroman1/aai-cli 6.10.0 → 6.11.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.
Files changed (29) hide show
  1. package/dist/scaffold/CLAUDE.md +58 -0
  2. package/dist/scaffold/package.json +3 -3
  3. package/dist/scaffold/server.mjs +12 -3
  4. package/dist/scaffold/vite.config.ts +1 -1
  5. package/dist/templates/call-audit/agent.test.ts +965 -0
  6. package/dist/templates/call-audit/agent.ts +158 -0
  7. package/dist/templates/call-audit/client.tsx +235 -0
  8. package/dist/templates/call-audit/workflows/audit.ts +305 -0
  9. package/dist/templates/call-audit/workflows/ingest.ts +259 -0
  10. package/dist/templates/call-audit/workflows/media.ts +647 -0
  11. package/dist/templates/call-audit/workflows/summarize.ts +206 -0
  12. package/dist/templates/call-audit/workflows/sync-api.ts +44 -0
  13. package/dist/templates/call-audit/workflows/temp-media.ts +138 -0
  14. package/dist/templates/recap-workflow/agent.test.ts +11 -3
  15. package/dist/templates/recap-workflow/workflows/recap.ts +19 -8
  16. package/dist/templates/spoken-summary/agent.test.ts +343 -0
  17. package/dist/templates/spoken-summary/agent.ts +142 -0
  18. package/dist/templates/spoken-summary/client.tsx +225 -0
  19. package/dist/templates/spoken-summary/workflows/summarize.ts +242 -0
  20. package/dist/templates/spoken-summary/workflows/transcribe.ts +145 -0
  21. package/dist/templates/transcription-workflow/agent.test.ts +241 -18
  22. package/dist/templates/transcription-workflow/agent.ts +20 -6
  23. package/dist/templates/transcription-workflow/workflows/batch.ts +75 -173
  24. package/dist/templates/transcription-workflow/workflows/normalize.ts +343 -0
  25. package/dist/templates/transcription-workflow/workflows/stream.ts +6 -4
  26. package/dist/templates/transcription-workflow/workflows/sync-api.ts +26 -94
  27. package/dist/templates/transcription-workflow/workflows/transcribe.ts +23 -14
  28. package/dist/templates/transcription-workflow/workflows/wav.ts +31 -0
  29. package/package.json +3 -3
@@ -0,0 +1,225 @@
1
+ // Copyright 2026 the AAI authors. MIT license.
2
+ /**
3
+ * The page: a form, a progress log, a summary, and a player.
4
+ *
5
+ * `link-digest` shows these primitives raw and `transcription-workflow` shows
6
+ * the form layer in full; neither is restated here. What this page adds is the
7
+ * one thing a workflow app could not do before — **playing a file the RUN
8
+ * produced.**
9
+ *
10
+ * ## An upload id is not a URL, and `api.download` is why
11
+ *
12
+ * The run's output carries `audio`, which is an upload id in the agent's own
13
+ * store. The obvious next line is `<audio src={`/workflows/uploads/${id}`}>`,
14
+ * and it is wrong in a way that only shows up after a deploy: the byte route
15
+ * takes the same `Authorization` header every other route does, and neither
16
+ * `<audio src>` nor `<a href>` can send one. So a page built on a URL works
17
+ * against `aai dev`, where there is no token, and 401s the moment the agent has
18
+ * one.
19
+ *
20
+ * `api.download(id)` reads it with the header and answers a `Blob`;
21
+ * `URL.createObjectURL` turns that into something both elements take. The
22
+ * object URL is REVOKED when the run changes, which is not tidiness — an object
23
+ * URL pins its blob for the life of the document, so a page that summarized
24
+ * five recordings would be holding five files it can no longer reach.
25
+ *
26
+ * ## The form is DECLARED, not written
27
+ *
28
+ * There is no field markup here at all. `<WorkflowFields>` renders a control
29
+ * per property of the workflow's own input schema, read from `GET /workflows` —
30
+ * so the file picker exists because `agent.ts` declares `recording` in
31
+ * `uploads`, and the voice SELECT exists because it declares `voice` as an
32
+ * enum. Adding a field there adds a control here with no edit.
33
+ */
34
+
35
+ import "@alexkroman1/aai-ui/styles.css";
36
+ // ERASED at build time, so naming the agent's own type costs the browser bundle
37
+ // nothing — and it is what stops this file restating a shape
38
+ // `workflows/summarize.ts` already declares.
39
+ import type { WorkflowOutputOf } from "@alexkroman1/aai";
40
+ import {
41
+ createWorkflowApi,
42
+ Form,
43
+ page,
44
+ SubmitButton,
45
+ UploadProgressBar,
46
+ useWorkflowSubmit,
47
+ WorkflowFields,
48
+ WorkflowProgress,
49
+ } from "@alexkroman1/aai-ui";
50
+ import { useEffect, useState } from "react";
51
+ import type { spokenSummary } from "./agent.ts";
52
+
53
+ /** What a completed run reports, derived from the workflow rather than restated. */
54
+ type Summary = WorkflowOutputOf<typeof spokenSummary>;
55
+
56
+ /**
57
+ * The workflow's name, as a page starts a run by one.
58
+ *
59
+ * A rename in `agent.ts` is a runtime 400 rather than a compile error, which is
60
+ * why `agent.test.ts` pins this string.
61
+ */
62
+ const WORKFLOW = "spokenSummary";
63
+
64
+ /**
65
+ * Hoisted out of the component deliberately.
66
+ *
67
+ * The hooks hold the client in a ref precisely so a fresh object per render
68
+ * cannot restart their watch, but building one in render is still a new `fetch`
69
+ * closure every time and reads as though it were free.
70
+ */
71
+ const api = createWorkflowApi();
72
+
73
+ /**
74
+ * The spoken text as a one-cue WebVTT track, inline.
75
+ *
76
+ * A data URL rather than another stored file: the words are already on the page
77
+ * and the whole track is a few hundred bytes, so a second upload — and a second
78
+ * `download` round trip to read it — would buy nothing.
79
+ */
80
+ function captionsUrl(text: string, durationMs: number): string {
81
+ // `hh:mm:ss.mmm`, which is the only timestamp shape WebVTT accepts.
82
+ const end = new Date(durationMs).toISOString().slice(11, 23);
83
+ const vtt = `WEBVTT\n\n00:00:00.000 --> ${end}\n${text}\n`;
84
+ return `data:text/vtt;charset=utf-8,${encodeURIComponent(vtt)}`;
85
+ }
86
+
87
+ /** Seconds a person can read, from the milliseconds a run reports. */
88
+ function duration(ms: number): string {
89
+ const total = Math.round(ms / 1000);
90
+ return `${Math.floor(total / 60)}:${String(total % 60).padStart(2, "0")}`;
91
+ }
92
+
93
+ /**
94
+ * The finished run's audio, as something the browser will play.
95
+ *
96
+ * A hook rather than four lines in the component because the CLEANUP is the
97
+ * part worth keeping in one place: an object URL pins its blob for the life of
98
+ * the document, so it is revoked when the id changes and when the page goes
99
+ * away. The `cancelled` flag covers the other half — a second run settling
100
+ * while the first download is still in flight would otherwise set state from
101
+ * the stale one.
102
+ */
103
+ function useAudioUrl(uploadId: string | undefined): { url?: string; error?: string } {
104
+ const [state, setState] = useState<{ url?: string; error?: string }>({});
105
+
106
+ useEffect(() => {
107
+ if (uploadId === undefined) {
108
+ setState({});
109
+ return;
110
+ }
111
+ let cancelled = false;
112
+ let objectUrl: string | undefined;
113
+ api
114
+ .download(uploadId)
115
+ .then((blob) => {
116
+ if (cancelled) return;
117
+ objectUrl = URL.createObjectURL(blob);
118
+ setState({ url: objectUrl });
119
+ })
120
+ .catch((err: unknown) => {
121
+ if (!cancelled) setState({ error: err instanceof Error ? err.message : String(err) });
122
+ });
123
+ return () => {
124
+ cancelled = true;
125
+ if (objectUrl !== undefined) URL.revokeObjectURL(objectUrl);
126
+ };
127
+ }, [uploadId]);
128
+
129
+ return state;
130
+ }
131
+
132
+ export function App() {
133
+ // The generic is what makes `run.status === "completed"` narrow to a TYPED
134
+ // `run.output` instead of `unknown`.
135
+ const { submit, run, pending, upload, pauseUpload, resumeUpload, error } =
136
+ useWorkflowSubmit<Summary>(WORKFLOW, { api });
137
+ const output = run?.status === "completed" ? run.output : undefined;
138
+ const audio = useAudioUrl(output?.audio);
139
+
140
+ return (
141
+ <main className="mx-auto flex max-w-2xl flex-col gap-6 p-8">
142
+ <header className="flex flex-col gap-1">
143
+ <h1 className="text-2xl font-medium">Spoken Summary</h1>
144
+ <p className="text-sm opacity-70">
145
+ Upload a recording. It comes back summarized — in writing, and read aloud.
146
+ </p>
147
+ </header>
148
+
149
+ <Form onSubmit={submit} error={error} className="flex flex-col gap-4">
150
+ {/* Every control, from the workflow's own input schema. See the module doc. */}
151
+ <WorkflowFields workflow={WORKFLOW} />
152
+ <SubmitButton pending={pending} pendingLabel="Working…">
153
+ Summarize
154
+ </SubmitButton>
155
+ </Form>
156
+
157
+ {/* The upload is its own wait, and the one nothing else can describe: the
158
+ run does not EXIST until the bytes are in, so there is no run id and
159
+ nothing for `<WorkflowProgress>` to read. */}
160
+ <UploadProgressBar upload={upload} onPause={pauseUpload} onResume={resumeUpload} />
161
+
162
+ {/* What the run itself says, from `report()` in the workflow's steps. */}
163
+ <WorkflowProgress runId={run?.runId} api={api} />
164
+
165
+ {run?.status === "failed" && <p className="text-red-600">That one failed: {run.error}</p>}
166
+
167
+ {output !== undefined && (
168
+ <article className="flex flex-col gap-5">
169
+ <div className="flex flex-col gap-1">
170
+ <h2 className="text-xl">{output.headline}</h2>
171
+ <p className="text-sm opacity-70">
172
+ {output.source} · {duration(output.durationMs)} · {output.words} words
173
+ </p>
174
+ </div>
175
+
176
+ <ul className="flex list-disc flex-col gap-1 pl-5">
177
+ {output.points.map((point) => (
178
+ <li key={point}>{point}</li>
179
+ ))}
180
+ </ul>
181
+
182
+ <section className="flex flex-col gap-2">
183
+ <h3 className="text-sm font-medium opacity-70">
184
+ Read aloud · {duration(output.audioDurationMs)}
185
+ </h3>
186
+ {audio.error !== undefined && (
187
+ <p className="text-red-600">Could not load the audio: {audio.error}</p>
188
+ )}
189
+ {audio.url !== undefined && (
190
+ <>
191
+ <audio controls src={audio.url} className="w-full">
192
+ {/* A real caption track, not a suppression: the summary was
193
+ written before it was spoken, so the words are already
194
+ here and one cue spanning the clip is an honest
195
+ transcript of it. */}
196
+ <track
197
+ kind="captions"
198
+ srcLang="en"
199
+ label="Summary"
200
+ default
201
+ src={captionsUrl(output.spoken, output.audioDurationMs)}
202
+ />
203
+ </audio>
204
+ {/* `download` works on an object URL because the bytes are
205
+ already in the tab; it is the href that could not carry the
206
+ agent's bearer, not the attribute. */}
207
+ <a href={audio.url} download="summary.wav" className="text-sm underline">
208
+ Download summary.wav
209
+ </a>
210
+ </>
211
+ )}
212
+ <p className="text-sm opacity-70">{output.spoken}</p>
213
+ </section>
214
+
215
+ <details className="text-sm">
216
+ <summary className="cursor-pointer opacity-70">Transcript</summary>
217
+ <p className="mt-2 whitespace-pre-wrap">{output.transcript}</p>
218
+ </details>
219
+ </article>
220
+ )}
221
+ </main>
222
+ );
223
+ }
224
+
225
+ page({ name: "Spoken Summary", component: App });
@@ -0,0 +1,242 @@
1
+ // Copyright 2026 the AAI authors. MIT license.
2
+ /**
3
+ * The workflow body, and the two legs after the transcript: a model reads it,
4
+ * and a voice reads the model back.
5
+ *
6
+ * ```text
7
+ * transcribe (workflows/transcribe.ts) → the words
8
+ * summarize one step, LLM Gateway → a headline, points, and a script
9
+ * speak one step, streaming TTS → a WAV, stored, and its id
10
+ * ```
11
+ *
12
+ * ## Audio in, audio out — and the second half is the part that needed the SDK
13
+ *
14
+ * Reading a file and transcribing it is what `transcription-workflow` already
15
+ * shows. What this template is for is the return trip, which until recently a
16
+ * workflow could not make at all:
17
+ *
18
+ * - **`stepSpeak`** synthesizes from inside a step. The session TTS surface
19
+ * cannot: a `TtsSession` is an event stream wired into a live pipeline's
20
+ * playback, and a step has no turn to be part of and has to return a VALUE.
21
+ * - **`writeUpload`** puts that value somewhere. A run's OUTPUT is read back as
22
+ * JSON, so audio cannot travel in one — the same rule that keeps a
23
+ * recording's bytes out of a run's INPUT, arriving at the other end of the
24
+ * run. The bytes go to the store, the output carries the id, and the page
25
+ * turns it back into something to play with `api.download(id)`.
26
+ *
27
+ * Both are on `@alexkroman1/aai/utils`, imported from THERE rather than the
28
+ * root: a `workflows/*.ts` module is bundled separately by the WDK builder, so
29
+ * the root barrel's module graph would ride into the step bundle.
30
+ *
31
+ * ## The model is asked for TWO things, and the difference is the point
32
+ *
33
+ * `points` is for reading and `spoken` is for hearing, and a template that
34
+ * synthesized the bullet list would produce something nobody wants to listen
35
+ * to — a voice reading "one. two. three." with no connective tissue. So the
36
+ * schema asks for a script as well, in sentences, and that is what
37
+ * {@link speak} is handed. It is the same decision `recap-workflow` makes for
38
+ * the sentence it reads down a phone, and it is one prompts get wrong when the
39
+ * shape does not force it.
40
+ *
41
+ * ## Why each leg is its own step
42
+ *
43
+ * They fail differently and cost differently. The transcription is minutes of a
44
+ * provider's queue; the model call is seconds and rate-limited; the synthesis
45
+ * is a socket. Splitting them means a rate-limited model call replays the
46
+ * transcript from the journal instead of transcribing the recording again, and
47
+ * a synthesis that failed does not re-run the model — which is the ordinary
48
+ * reason to split steps, made sharp here because the first leg is the
49
+ * expensive one.
50
+ */
51
+
52
+ import { throwStepError } from "@alexkroman1/aai/step-errors";
53
+ import {
54
+ omitUndefined,
55
+ report,
56
+ stepGenerateJson,
57
+ stepSpeak,
58
+ TRANSCRIBE_API,
59
+ writeUpload,
60
+ } from "@alexkroman1/aai/utils";
61
+ import { sleep } from "workflow";
62
+ import { z } from "zod";
63
+ import {
64
+ countWords,
65
+ createJob,
66
+ MAX_POLLS,
67
+ POLL_INTERVAL,
68
+ pollTranscript,
69
+ type Transcript,
70
+ uploadToProvider,
71
+ } from "./transcribe.ts";
72
+
73
+ /** Points the summary is reduced to. Enough to be a summary, few enough to scan. */
74
+ const POINTS = 4;
75
+
76
+ /**
77
+ * Characters of transcript handed to the model.
78
+ *
79
+ * The pass-an-id-not-a-payload rule meeting a case where the payload IS the
80
+ * work: the text has to cross the queue between two steps, so it is bounded
81
+ * rather than trusted. 40k characters is roughly four hours of speech — past
82
+ * where another paragraph changes a four-point summary.
83
+ */
84
+ const MAX_TRANSCRIPT_CHARS = 40_000;
85
+
86
+ /**
87
+ * The shape the model must answer in, as something that CHECKS.
88
+ *
89
+ * `stepGenerateJson` validates against this and throws plainly when the reply
90
+ * misses, which is what a retry is for: a model that answered with prose may
91
+ * well obey on the next attempt.
92
+ */
93
+ const SummaryReply = z.object({
94
+ headline: z.string().trim().min(1),
95
+ points: z.array(z.string().trim().min(1)).min(1),
96
+ // NOT `.default("")` — the whole second half of this workflow has nothing to
97
+ // say without it, and a default would turn a missing field into a silent
98
+ // half-second of audio rather than a retry.
99
+ spoken: z.string().trim().min(1),
100
+ });
101
+
102
+ /** What a finished run reports. Small and JSON-shaped, like every step result. */
103
+ export type SpokenSummary = {
104
+ /** The uploaded file's own name. */
105
+ source: string;
106
+ /** The recording's length, as the provider measured it. */
107
+ durationMs: number;
108
+ /** Words in the transcript. */
109
+ words: number;
110
+ /** One line naming what the recording was about. */
111
+ headline: string;
112
+ /** The summary, for reading. */
113
+ points: string[];
114
+ /** The summary, for hearing — what {@link speak} was handed. */
115
+ spoken: string;
116
+ /** The whole transcript, so the page can show its work. */
117
+ transcript: string;
118
+ /**
119
+ * The upload id of the spoken summary — a WAV, in this app's own store.
120
+ *
121
+ * An ID rather than the bytes, and that is the rule rather than a
122
+ * preference: a run's output is read back as JSON. `api.download(id)` is the
123
+ * browser half.
124
+ */
125
+ audio: string;
126
+ /** How long the spoken summary lasts. */
127
+ audioDurationMs: number;
128
+ };
129
+
130
+ /** Transcribe a recording, summarize it, and read the summary back. */
131
+ export async function spokenSummaryFlow(input: {
132
+ recording: string;
133
+ // `| undefined` explicitly, not merely optional: `exactOptionalPropertyTypes`
134
+ // is on repo-wide, and what a zod `.optional()` infers is a property that may
135
+ // be PRESENT and undefined.
136
+ voice?: string | undefined;
137
+ }): Promise<SpokenSummary> {
138
+ "use workflow";
139
+
140
+ const transcript = await transcribe(input.recording);
141
+ const summary = await summarize(transcript.text);
142
+ const spoken = await speak(summary.spoken, input.voice);
143
+
144
+ return {
145
+ source: transcript.source,
146
+ durationMs: transcript.durationMs,
147
+ words: countWords(transcript.text),
148
+ headline: summary.headline,
149
+ points: summary.points,
150
+ spoken: summary.spoken,
151
+ transcript: transcript.text,
152
+ audio: spoken.audio,
153
+ audioDurationMs: spoken.durationMs,
154
+ };
155
+ }
156
+
157
+ /**
158
+ * The whole first leg, factored out of the body.
159
+ *
160
+ * A plain async function rather than a step, and NOT because it is small: it
161
+ * calls steps and it `sleep`s durably between polls, neither of which a step
162
+ * may do. So it runs as part of the BODY and is replayed with it — which is
163
+ * legal here for the ordinary reason, that everything it does is either a step
164
+ * call or a `sleep`, so a replay re-derives exactly the same sequence.
165
+ */
166
+ async function transcribe(recording: string): Promise<Transcript> {
167
+ const { audioUrl } = await uploadToProvider(recording);
168
+ const job = await createJob(audioUrl);
169
+
170
+ for (let poll = 0; poll < MAX_POLLS; poll += 1) {
171
+ const progress = await pollTranscript(recording, job.id);
172
+ if (progress.done) return progress.transcript;
173
+ await sleep(POLL_INTERVAL);
174
+ }
175
+ // A plain throw: this is the BODY, where the fatal/retryable distinction has
176
+ // nothing to apply to. The transcript is not lost, so the message says where
177
+ // it is rather than only that the wait ran out.
178
+ throw new Error(
179
+ `Transcript ${job.id} was still unfinished after ${MAX_POLLS} polls. It is not lost — ` +
180
+ `read it directly with GET ${TRANSCRIBE_API}/v2/transcript/${job.id}.`,
181
+ );
182
+ }
183
+
184
+ /** Reduce the transcript to a headline, {@link POINTS} points, and a script. */
185
+ export async function summarize(
186
+ text: string,
187
+ ): Promise<{ headline: string; points: string[]; spoken: string }> {
188
+ "use step";
189
+
190
+ await report("Summarizing the transcript.");
191
+ const reply = await stepGenerateJson(
192
+ "Summarize this transcript of a recording.\n\n" +
193
+ "Answer with JSON only, in this shape:\n" +
194
+ `{"headline": "...", "points": ["..."], "spoken": "..."}\n\n` +
195
+ "- headline: one line naming what the recording was about.\n" +
196
+ `- points: at most ${POINTS} short points, each a complete thought. Concrete ` +
197
+ `specifics — decisions, numbers, names, what happens next — never "the ` +
198
+ `speaker discussed several topics".\n` +
199
+ "- spoken: the same summary written to be READ ALOUD. Full sentences that " +
200
+ "flow, under 120 words, no bullet markers, no headings, no markdown. " +
201
+ "Someone will hear this without seeing the points.\n\n" +
202
+ `Transcript:\n${text.slice(0, MAX_TRANSCRIPT_CHARS)}`,
203
+ {
204
+ system: "You summarize recordings. You answer with JSON and nothing else.",
205
+ schema: SummaryReply,
206
+ },
207
+ // Classified off the gateway's own status: a 429 is worth another attempt
208
+ // and a 400 is not, and `throwStepError` is what tells the DevKit which.
209
+ ).catch(throwStepError);
210
+
211
+ return { headline: reply.headline, points: reply.points.slice(0, POINTS), spoken: reply.spoken };
212
+ }
213
+
214
+ /**
215
+ * Read the summary aloud, store the WAV, and answer with its id.
216
+ *
217
+ * **Both halves belong in ONE step**, and the reason is what a journal records:
218
+ * a step is replayed by its RETURN VALUE, so an id is replayed and bytes are
219
+ * not. Split in two, the audio would have to cross the queue between them —
220
+ * megabytes of it, on every resume. Together, a resumed run replays the id and
221
+ * re-reads a file that is already there.
222
+ */
223
+ export async function speak(
224
+ script: string,
225
+ voice?: string,
226
+ ): Promise<{ audio: string; durationMs: number }> {
227
+ "use step";
228
+
229
+ const spoken = await stepSpeak(script, omitUndefined({ voice }));
230
+ const stored = await writeUpload(spoken.audio, {
231
+ // Named, because this is what a person sees on the download link rather
232
+ // than an opaque id — and typed, because the byte route serves the type it
233
+ // was given and a browser will not play a file it was handed as bytes.
234
+ name: "summary.wav",
235
+ type: "audio/wav",
236
+ });
237
+
238
+ await report(
239
+ `Recorded a ${Math.round(spoken.durationMs / 1000)}s summary in ${spoken.voice}'s voice.`,
240
+ );
241
+ return { audio: stored.id, durationMs: spoken.durationMs };
242
+ }
@@ -0,0 +1,145 @@
1
+ // Copyright 2026 the AAI authors. MIT license.
2
+ /**
3
+ * The first leg: the uploaded recording becomes text.
4
+ *
5
+ * Three steps and a durable wait, over AssemblyAI's ASYNC transcription API:
6
+ *
7
+ * ```text
8
+ * uploadToProvider one step → the file, streamed, and the URL it answered
9
+ * createJob one step → the transcript id
10
+ * pollTranscript one step + a durable sleep, until the text comes back
11
+ * ```
12
+ *
13
+ * **Every one of them is four lines, because the SDK owns the endpoint.**
14
+ * `stepTranscribeUpload` / `stepTranscribeSubmit` / `stepTranscribePoll` on
15
+ * `@alexkroman1/aai/utils` carry the URL, the raw-key auth, the windowed
16
+ * streaming upload, the PLURAL `speech_models` field and the failure
17
+ * classification — all of which this file used to spell out, and all of which
18
+ * `transcription-workflow` used to spell out again, differently worded and
19
+ * identical in behaviour. What is left here is what is genuinely this app's:
20
+ * which steps to cut the job into, how long to wait, and what to report.
21
+ *
22
+ * ## The steps are still OURS, and they have to be
23
+ *
24
+ * The SDK cannot ship a `"use step"`: the Workflow DevKit's builder transforms
25
+ * exactly the files under this `workflows/` directory, so a directive inside a
26
+ * dependency would be transformed by nothing and would run inline with no
27
+ * journal and no retry, silently. The SDK owns what happens INSIDE a step; the
28
+ * boundaries — which is to say, what gets journaled and what a retry repeats —
29
+ * are the app's.
30
+ *
31
+ * **The async API rather than the sync one, and the choice is about the FORM.**
32
+ * The sync endpoint (`stepTranscribeSync`) answers inside the request and pays
33
+ * for it with a hard 120-second, 40 MB cap, so a longer recording has to be cut
34
+ * into segments and fanned out — which is a whole subject, and it has a
35
+ * template (`transcription-workflow`, which shows that cut three ways and
36
+ * measures them). This app's subject is the ROUND TRIP, so the transcription is
37
+ * the one leg that should be as boring as possible.
38
+ */
39
+
40
+ import { throwStepError } from "@alexkroman1/aai/step-errors";
41
+ import {
42
+ report,
43
+ stepTranscribePoll,
44
+ stepTranscribeSubmit,
45
+ stepTranscribeUpload,
46
+ uploadInfo,
47
+ } from "@alexkroman1/aai/utils";
48
+
49
+ /** How long between polls of a submitted job. */
50
+ export const POLL_INTERVAL = "10s";
51
+
52
+ /**
53
+ * Polls before the run gives up on a job.
54
+ *
55
+ * At {@link POLL_INTERVAL} this is an hour, well past what the async API takes
56
+ * for any recording it accepts. Bounded rather than endless because a job that
57
+ * never leaves `queued` is a run that would otherwise be replayed forever.
58
+ */
59
+ export const MAX_POLLS = 360;
60
+
61
+ /** What the first leg hands the second. */
62
+ export type Transcript = {
63
+ /** The FILENAME the uploader gave, not the opaque id — this reaches the page. */
64
+ source: string;
65
+ /** The provider's own measurement of the recording, in milliseconds. */
66
+ durationMs: number;
67
+ /** What was said. */
68
+ text: string;
69
+ };
70
+
71
+ /**
72
+ * Upload the recording to the provider and answer with the URL it gave.
73
+ *
74
+ * Its OWN step, and that is a measurement rather than a preference: folded into
75
+ * the submit, a fault in the JSON body — a deprecated field, a bad model name —
76
+ * makes the DevKit retry the whole step and re-upload the recording on every
77
+ * attempt. A retry that repeats the expensive half to fix the cheap half is not
78
+ * a retry. The `upload_url` is short-lived, so the risk being taken is that it
79
+ * expires before the next step runs; that costs one fresh upload, once, instead
80
+ * of five.
81
+ *
82
+ * `.catch(throwStepError)` is what turns the SDK's `TranscribeError` into the
83
+ * DevKit's verdict — a missing key and a 400 stop, a 429 waits as long as the
84
+ * service asked. Every step here ends the same way for the same reason.
85
+ */
86
+ export async function uploadToProvider(uploadId: string): Promise<{ audioUrl: string }> {
87
+ "use step";
88
+
89
+ const stored = await uploadInfo(uploadId);
90
+ await report(`Uploading ${stored.name || uploadId} (${mb(stored.size)}) for transcription.`);
91
+ return await stepTranscribeUpload(uploadId).catch(throwStepError);
92
+ }
93
+
94
+ /** Retries beyond the default 3: an upload is the one call here worth another attempt. */
95
+ uploadToProvider.maxRetries = 5;
96
+
97
+ /** Create the transcription job, and answer with the id that outlives this run. */
98
+ export async function createJob(audioUrl: string): Promise<{ id: string }> {
99
+ "use step";
100
+
101
+ const job = await stepTranscribeSubmit(audioUrl).catch(throwStepError);
102
+ await report(`Transcribing — job ${job.id}.`);
103
+ return job;
104
+ }
105
+
106
+ /**
107
+ * Ask once whether the job has finished, and read it when it has.
108
+ *
109
+ * One request answers both, which is the SDK's doing and worth knowing: this
110
+ * used to poll for a status and then fetch the identical URL a second time for
111
+ * the text. The body still branches on `done` rather than on a status string —
112
+ * a provider's vocabulary must not be interpreted in a body, where a new status
113
+ * would read as "not finished yet" forever.
114
+ */
115
+ export async function pollTranscript(
116
+ uploadId: string,
117
+ id: string,
118
+ ): Promise<{ done: false } | { done: true; transcript: Transcript }> {
119
+ "use step";
120
+
121
+ const progress = await stepTranscribePoll(id).catch(throwStepError);
122
+ if (!progress.done) return { done: false };
123
+
124
+ const stored = await uploadInfo(uploadId);
125
+ await report(`Transcribed ${countWords(progress.transcript.text)} words.`);
126
+ return {
127
+ done: true,
128
+ transcript: {
129
+ source: stored.name || uploadId,
130
+ durationMs: progress.transcript.durationMs,
131
+ text: progress.transcript.text,
132
+ },
133
+ };
134
+ }
135
+
136
+ /** Words in a transcript, for the counts a page shows. */
137
+ export function countWords(text: string): number {
138
+ const trimmed = text.trim();
139
+ return trimmed.length === 0 ? 0 : trimmed.split(/\s+/).length;
140
+ }
141
+
142
+ /** A size a person can read, because the number that matters is the scale. */
143
+ function mb(bytes: number): string {
144
+ return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
145
+ }