@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.
- package/dist/scaffold/CLAUDE.md +58 -0
- package/dist/scaffold/package.json +3 -3
- package/dist/scaffold/server.mjs +12 -3
- package/dist/scaffold/vite.config.ts +1 -1
- package/dist/templates/call-audit/agent.test.ts +965 -0
- package/dist/templates/call-audit/agent.ts +158 -0
- package/dist/templates/call-audit/client.tsx +235 -0
- package/dist/templates/call-audit/workflows/audit.ts +305 -0
- package/dist/templates/call-audit/workflows/ingest.ts +259 -0
- package/dist/templates/call-audit/workflows/media.ts +647 -0
- package/dist/templates/call-audit/workflows/summarize.ts +206 -0
- package/dist/templates/call-audit/workflows/sync-api.ts +44 -0
- package/dist/templates/call-audit/workflows/temp-media.ts +138 -0
- package/dist/templates/recap-workflow/agent.test.ts +11 -3
- package/dist/templates/recap-workflow/workflows/recap.ts +19 -8
- package/dist/templates/spoken-summary/agent.test.ts +343 -0
- package/dist/templates/spoken-summary/agent.ts +142 -0
- package/dist/templates/spoken-summary/client.tsx +225 -0
- package/dist/templates/spoken-summary/workflows/summarize.ts +242 -0
- package/dist/templates/spoken-summary/workflows/transcribe.ts +145 -0
- package/dist/templates/transcription-workflow/agent.test.ts +241 -18
- package/dist/templates/transcription-workflow/agent.ts +20 -6
- package/dist/templates/transcription-workflow/workflows/batch.ts +75 -173
- package/dist/templates/transcription-workflow/workflows/normalize.ts +343 -0
- package/dist/templates/transcription-workflow/workflows/stream.ts +6 -4
- package/dist/templates/transcription-workflow/workflows/sync-api.ts +26 -94
- package/dist/templates/transcription-workflow/workflows/transcribe.ts +23 -14
- package/dist/templates/transcription-workflow/workflows/wav.ts +31 -0
- 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
|
+
}
|