@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,206 @@
|
|
|
1
|
+
// Copyright 2026 the AAI authors. MIT license.
|
|
2
|
+
/**
|
|
3
|
+
* The two steps after the transcript: a model reads the call, and a voice reads
|
|
4
|
+
* the model back — through ffmpeg on the way out.
|
|
5
|
+
*
|
|
6
|
+
* ```text
|
|
7
|
+
* summarize one step, LLM Gateway → headline, risks, actions, script
|
|
8
|
+
* narrate one step, TTS + ffmpeg → an MP3, stored, and its id
|
|
9
|
+
* ```
|
|
10
|
+
*
|
|
11
|
+
* `spoken-summary` owns the audio ROUND TRIP and is the template to read for it:
|
|
12
|
+
* why `stepSpeak` exists at all (a `TtsSession` is an event stream wired into a
|
|
13
|
+
* live pipeline's playback, and a step has no turn to be part of and has to return
|
|
14
|
+
* a VALUE), why `writeUpload` is its other half, and why speaking and storing must
|
|
15
|
+
* be one step. None of that is restated here.
|
|
16
|
+
*
|
|
17
|
+
* **What this file adds is the pass AFTER the synthesis**, and it is the second
|
|
18
|
+
* half of what having a decoder in the pipeline buys. `stepSpeak` answers with a
|
|
19
|
+
* 24 kHz WAV, which is correct and is not a deliverable:
|
|
20
|
+
*
|
|
21
|
+
* - **It is uncompressed.** A 90-second summary is 4.3 MB, and the page downloads
|
|
22
|
+
* the whole thing through `api.download` before it can play a note of it. The
|
|
23
|
+
* same summary as VBR MP3 is ~110 KB, which is a fortieth.
|
|
24
|
+
* - **Its level is whatever the voice service chose.** Played straight after a
|
|
25
|
+
* recording this desk levelled to −16 LUFS, a summary at −24 sounds broken. The
|
|
26
|
+
* mastering pass puts both on the same scale, which is the whole reason to have
|
|
27
|
+
* one number for the desk rather than one per stage.
|
|
28
|
+
*
|
|
29
|
+
* So the audio the page plays has been through ffmpeg twice — once on the way in
|
|
30
|
+
* to make it analysable, once on the way out to make it shippable. `media.ts`'s
|
|
31
|
+
* `masterArgs` is the second, and it explains why that one is a SINGLE `loudnorm`
|
|
32
|
+
* pass where the ingest is two.
|
|
33
|
+
*/
|
|
34
|
+
|
|
35
|
+
import { stat, writeFile } from "node:fs/promises";
|
|
36
|
+
import { join } from "node:path";
|
|
37
|
+
import { runFfmpeg } from "@alexkroman1/aai/ffmpeg";
|
|
38
|
+
import { throwStepError } from "@alexkroman1/aai/step-errors";
|
|
39
|
+
import {
|
|
40
|
+
omitUndefined,
|
|
41
|
+
report,
|
|
42
|
+
stepGenerateJson,
|
|
43
|
+
stepSpeak,
|
|
44
|
+
writeUpload,
|
|
45
|
+
} from "@alexkroman1/aai/utils";
|
|
46
|
+
import { z } from "zod";
|
|
47
|
+
import { classifyFfmpeg } from "./ingest.ts";
|
|
48
|
+
import { clock, masterArgs } from "./media.ts";
|
|
49
|
+
import { fileChunks, withTempDir } from "./temp-media.ts";
|
|
50
|
+
|
|
51
|
+
/** Risks the summary is reduced to. Enough to be useful, few enough to act on. */
|
|
52
|
+
const MAX_RISKS = 4;
|
|
53
|
+
|
|
54
|
+
/** Actions the summary is reduced to. */
|
|
55
|
+
const MAX_ACTIONS = 4;
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Characters of transcript handed to the model.
|
|
59
|
+
*
|
|
60
|
+
* The pass-an-id-not-a-payload rule meeting a case where the payload IS the work:
|
|
61
|
+
* the text has to cross the queue between two steps, so it is bounded rather than
|
|
62
|
+
* trusted. 40k characters is roughly four hours of speech — past where another
|
|
63
|
+
* paragraph changes a four-point summary.
|
|
64
|
+
*/
|
|
65
|
+
const MAX_TRANSCRIPT_CHARS = 40_000;
|
|
66
|
+
|
|
67
|
+
/** How long the mastering pass may run. Seconds of work; the bound is for a pathological input. */
|
|
68
|
+
const MASTER_TIMEOUT_MS = 5 * 60_000;
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* The shape the model must answer in, as something that CHECKS.
|
|
72
|
+
*
|
|
73
|
+
* `stepGenerateJson` validates against this and throws plainly when the reply
|
|
74
|
+
* misses, which is what a retry is for: a model that answered with prose may well
|
|
75
|
+
* obey on the next attempt.
|
|
76
|
+
*/
|
|
77
|
+
const AuditReply = z.object({
|
|
78
|
+
headline: z.string().trim().min(1),
|
|
79
|
+
// Allowed to be EMPTY, unlike `spoken` below, and the asymmetry is deliberate: a
|
|
80
|
+
// call with nothing worrying in it is a real call, and a schema that demanded a
|
|
81
|
+
// risk would get an invented one. An empty array is an answer.
|
|
82
|
+
risks: z.array(z.string().trim().min(1)),
|
|
83
|
+
actions: z.array(z.string().trim().min(1)),
|
|
84
|
+
// NOT `.default("")` — the whole second half of this workflow has nothing to say
|
|
85
|
+
// without it, and a default would turn a missing field into a silent half-second
|
|
86
|
+
// of audio rather than a retry.
|
|
87
|
+
spoken: z.string().trim().min(1),
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
/** What the model made of the call. */
|
|
91
|
+
export type CallSummary = {
|
|
92
|
+
headline: string;
|
|
93
|
+
risks: string[];
|
|
94
|
+
actions: string[];
|
|
95
|
+
/** The same summary, written to be READ ALOUD — see {@link summarize}. */
|
|
96
|
+
spoken: string;
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Reduce the transcript to a headline, the risks, the actions, and a script.
|
|
101
|
+
*
|
|
102
|
+
* **The model is asked for TWO summaries, and the difference is the point.**
|
|
103
|
+
* `risks`/`actions` are for reading and `spoken` is for hearing; a template that
|
|
104
|
+
* synthesized its own bullet list produces a voice reading "one. two. three." with
|
|
105
|
+
* no connective tissue. So the schema asks for a script as well, in sentences, and
|
|
106
|
+
* that is what {@link narrate} is handed. It is a decision a prompt alone does not
|
|
107
|
+
* hold, which is why the field is required rather than defaulted.
|
|
108
|
+
*/
|
|
109
|
+
export async function summarize(
|
|
110
|
+
transcript: string,
|
|
111
|
+
source: string,
|
|
112
|
+
durationMs: number,
|
|
113
|
+
): Promise<CallSummary> {
|
|
114
|
+
"use step";
|
|
115
|
+
|
|
116
|
+
await report("Reading the transcript.");
|
|
117
|
+
const reply = await stepGenerateJson(
|
|
118
|
+
`Audit this transcript of a recorded call (${source}, ${clock(durationMs)}).\n\n` +
|
|
119
|
+
"Answer with JSON only, in this shape:\n" +
|
|
120
|
+
`{"headline": "...", "risks": ["..."], "actions": ["..."], "spoken": "..."}\n\n` +
|
|
121
|
+
"- headline: one line naming what the call was about.\n" +
|
|
122
|
+
`- risks: at most ${MAX_RISKS} things a reader should worry about — a ` +
|
|
123
|
+
"commitment nobody owns, a number that was guessed at, a disagreement left " +
|
|
124
|
+
"unresolved. Quote or name the specifics. An EMPTY array if the call really " +
|
|
125
|
+
"had none; never invent one.\n" +
|
|
126
|
+
`- actions: at most ${MAX_ACTIONS} things somebody has to do next, each ` +
|
|
127
|
+
"naming who if the call named them.\n" +
|
|
128
|
+
"- spoken: the same audit written to be READ ALOUD. Full sentences that " +
|
|
129
|
+
"flow, under 150 words, no bullet markers, no headings, no markdown. " +
|
|
130
|
+
"Someone will hear this without seeing the lists.\n\n" +
|
|
131
|
+
`Transcript:\n${transcript.slice(0, MAX_TRANSCRIPT_CHARS)}`,
|
|
132
|
+
{
|
|
133
|
+
system: "You audit recorded calls. You answer with JSON and nothing else.",
|
|
134
|
+
schema: AuditReply,
|
|
135
|
+
},
|
|
136
|
+
// Classified off the gateway's own status: a 429 is worth another attempt and a
|
|
137
|
+
// 400 is not, and `throwStepError` is what tells the DevKit which.
|
|
138
|
+
).catch(throwStepError);
|
|
139
|
+
|
|
140
|
+
await report(
|
|
141
|
+
`Found ${reply.risks.length} risk${reply.risks.length === 1 ? "" : "s"} and ` +
|
|
142
|
+
`${reply.actions.length} action${reply.actions.length === 1 ? "" : "s"}.`,
|
|
143
|
+
);
|
|
144
|
+
return {
|
|
145
|
+
headline: reply.headline,
|
|
146
|
+
risks: reply.risks.slice(0, MAX_RISKS),
|
|
147
|
+
actions: reply.actions.slice(0, MAX_ACTIONS),
|
|
148
|
+
spoken: reply.spoken,
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Read the audit aloud, master it, store it, and answer with its id.
|
|
154
|
+
*
|
|
155
|
+
* **All four halves belong in ONE step**, and the reason is what a journal
|
|
156
|
+
* records: a step is replayed by its RETURN VALUE, so an id is replayed and bytes
|
|
157
|
+
* are not. Split in two, the audio would have to cross the queue between them —
|
|
158
|
+
* megabytes of it, on every resume — and the temp file the mastering pass needs
|
|
159
|
+
* cannot cross a step boundary at all (see `temp-media.ts`). Together, a resumed
|
|
160
|
+
* run replays the id and re-reads a file that is already there.
|
|
161
|
+
*
|
|
162
|
+
* The cost is that a retried step writes a second upload and abandons the first.
|
|
163
|
+
* Cheap next to a step that cannot retry.
|
|
164
|
+
*/
|
|
165
|
+
export async function narrate(
|
|
166
|
+
script: string,
|
|
167
|
+
voice?: string,
|
|
168
|
+
): Promise<{ audio: string; durationMs: number; bytes: number }> {
|
|
169
|
+
"use step";
|
|
170
|
+
|
|
171
|
+
const spoken = await stepSpeak(script, omitUndefined({ voice }));
|
|
172
|
+
|
|
173
|
+
return await withTempDir(async (dir) => {
|
|
174
|
+
const wav = join(dir, "spoken.wav");
|
|
175
|
+
const mp3 = join(dir, "summary.mp3");
|
|
176
|
+
|
|
177
|
+
// `writeFile` rather than a stream, and this is the one place in the template
|
|
178
|
+
// where holding the whole thing in memory is right: `stepSpeak` already
|
|
179
|
+
// returned it as a single `Uint8Array`, so streaming it to disk would be
|
|
180
|
+
// copying from the heap to the heap on the way. It is bounded by the script,
|
|
181
|
+
// which the schema keeps under 150 words.
|
|
182
|
+
await writeFile(wav, spoken.audio);
|
|
183
|
+
|
|
184
|
+
await runFfmpeg(masterArgs(wav, mp3), { timeoutMs: MASTER_TIMEOUT_MS }).catch(classifyFfmpeg);
|
|
185
|
+
const bytes = (await stat(mp3)).size;
|
|
186
|
+
|
|
187
|
+
const stored = await writeUpload(fileChunks(mp3), {
|
|
188
|
+
// Named, because this is what a person sees on the download link rather than
|
|
189
|
+
// an opaque id — and typed, because the byte route serves the type it was
|
|
190
|
+
// given and a browser will not play a file it was handed as bytes.
|
|
191
|
+
name: "audit.mp3",
|
|
192
|
+
type: "audio/mpeg",
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
await report(
|
|
196
|
+
`Recorded a ${Math.round(spoken.durationMs / 1000)}s audit in ${spoken.voice}'s voice — ` +
|
|
197
|
+
`${kb(bytes)} of MP3, from ${kb(spoken.audio.byteLength)} of WAV.`,
|
|
198
|
+
);
|
|
199
|
+
return { audio: stored.id, durationMs: spoken.durationMs, bytes };
|
|
200
|
+
});
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/** A size a person can read, in the unit this file's output actually lands in. */
|
|
204
|
+
function kb(bytes: number): string {
|
|
205
|
+
return `${Math.round(bytes / 1024)} KB`;
|
|
206
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
// Copyright 2026 the AAI authors. MIT license.
|
|
2
|
+
/**
|
|
3
|
+
* The one transcription request, and its classification.
|
|
4
|
+
*
|
|
5
|
+
* No directive, so it sits under `workflows/` untransformed and is called FROM a
|
|
6
|
+
* step, inheriting its environment. It is its own module for the same reason
|
|
7
|
+
* `transcription-workflow` has one: `stepTranscribeSync` is the SDK's — the URL,
|
|
8
|
+
* the raw-key auth (no `Bearer`, which is a 401 that reads like a wrong key), the
|
|
9
|
+
* multipart shape, the deadline and the three-way failure verdict all live there —
|
|
10
|
+
* so what is left at the call site is the `.catch` that hands the verdict to the
|
|
11
|
+
* DevKit, and that belongs somewhere a spec can reach it.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { throwStepError } from "@alexkroman1/aai/step-errors";
|
|
15
|
+
import { stepTranscribeSync } from "@alexkroman1/aai/utils";
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Transcribe one complete WAV.
|
|
19
|
+
*
|
|
20
|
+
* `bytes` must be a whole file, header included — the endpoint decodes each
|
|
21
|
+
* request independently, so a headerless span is bytes it will refuse. This desk
|
|
22
|
+
* stores headerless PCM on purpose (see `media.ts`) and puts a header back with
|
|
23
|
+
* `encodeWav` for exactly this call.
|
|
24
|
+
*
|
|
25
|
+
* `.catch(throwStepError)` is the whole of what this adds, and it is where the
|
|
26
|
+
* three-way call is made: a `FatalError` stops the DevKit retrying something that
|
|
27
|
+
* will answer the same way, a bare `RetryableError` retries in ONE SECOND (that
|
|
28
|
+
* class's own default), and a `RetryableError` carrying `retryAfter` waits exactly
|
|
29
|
+
* as long as the far side asked. The last matters here because a whole fan-out
|
|
30
|
+
* hits a rate limit together — a second later all of them ask again, where on the
|
|
31
|
+
* server's number they drain.
|
|
32
|
+
*
|
|
33
|
+
* @param label - How this piece is named in a failure. The CALLER's vocabulary (a
|
|
34
|
+
* segment's timestamp), because it is what a reader of the log has in front of
|
|
35
|
+
* them.
|
|
36
|
+
*/
|
|
37
|
+
export async function transcribeSpan(
|
|
38
|
+
bytes: Uint8Array,
|
|
39
|
+
filename: string,
|
|
40
|
+
label: string,
|
|
41
|
+
): Promise<string> {
|
|
42
|
+
const { text } = await stepTranscribeSync(bytes, { filename, label }).catch(throwStepError);
|
|
43
|
+
return text;
|
|
44
|
+
}
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
// Copyright 2026 the AAI authors. MIT license.
|
|
2
|
+
/**
|
|
3
|
+
* Moving bytes between the upload store and a local file, which is what an
|
|
4
|
+
* ffmpeg step spends most of its lines on.
|
|
5
|
+
*
|
|
6
|
+
* No directive, so it sits under `workflows/` untransformed and is called FROM
|
|
7
|
+
* steps, inheriting their environment. It exists because both ffmpeg steps in
|
|
8
|
+
* this template need the same three things and the third one is the one that is
|
|
9
|
+
* easy to get wrong.
|
|
10
|
+
*
|
|
11
|
+
* ## Why a temp file at all
|
|
12
|
+
*
|
|
13
|
+
* `@alexkroman1/aai/ffmpeg` takes bytes as happily as a path, and for a short
|
|
14
|
+
* clip bytes are the better call. This desk uses paths, for two reasons that are
|
|
15
|
+
* both properties of real recordings rather than preferences:
|
|
16
|
+
*
|
|
17
|
+
* - **A pipe cannot seek.** An `.m4a` off a phone usually carries its `moov`
|
|
18
|
+
* index at the END of the file, so ffmpeg reading it from `pipe:0` fails with
|
|
19
|
+
* `moov atom not found`. That is the flagship input.
|
|
20
|
+
* - **Piped output is capped**, at `DEFAULT_MAX_FFMPEG_OUTPUT_BYTES` (64 MiB),
|
|
21
|
+
* which is about half an hour of this desk's 16 kHz mono PCM. The desk exists
|
|
22
|
+
* for the two-hour call.
|
|
23
|
+
*
|
|
24
|
+
* ## A temp file may not outlive its step
|
|
25
|
+
*
|
|
26
|
+
* A step is journaled by its RETURN VALUE and may be dispatched into a different
|
|
27
|
+
* process than its neighbours, so a path in a return value is a path that is
|
|
28
|
+
* replayed after the file behind it is gone — and the failure mode is a resumed
|
|
29
|
+
* run reading a directory that another run is using. {@link withTempDir} makes
|
|
30
|
+
* the lifetime a lexical scope: the directory is created on entry, removed on
|
|
31
|
+
* exit, and what crosses the step boundary is an upload id.
|
|
32
|
+
*/
|
|
33
|
+
|
|
34
|
+
import { mkdtemp, open, rm } from "node:fs/promises";
|
|
35
|
+
import { tmpdir } from "node:os";
|
|
36
|
+
import { join } from "node:path";
|
|
37
|
+
import { readUpload } from "@alexkroman1/aai/utils";
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Bytes moved per `readUpload`, and per write.
|
|
41
|
+
*
|
|
42
|
+
* Large enough that a two-hour recording is a few hundred round trips rather
|
|
43
|
+
* than tens of thousands, and small enough that a step's resident set is a
|
|
44
|
+
* constant rather than a function of the recording. The number this must NOT be
|
|
45
|
+
* is "the whole file", which is the shape every first draft has.
|
|
46
|
+
*/
|
|
47
|
+
export const WINDOW_BYTES = 8 * 1024 * 1024;
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Run `work` with a private temp directory, and remove it afterwards.
|
|
51
|
+
*
|
|
52
|
+
* `join(tmpdir(), …)` rather than a `/tmp` literal, which is this repo's rule
|
|
53
|
+
* (`guard-invariants` rule 11) and not merely portability theatre: on Windows a
|
|
54
|
+
* literal `/tmp/x` is DRIVE-RELATIVE, so it resolves somewhere that does not
|
|
55
|
+
* exist and every write fails with ENOENT. A step runs in a Linux guest when it
|
|
56
|
+
* is deployed and on the developer's own machine under `aai dev`, which is the
|
|
57
|
+
* half that makes it matter.
|
|
58
|
+
*
|
|
59
|
+
* The removal is in a `finally`, so it also runs on the failure paths — a guest's
|
|
60
|
+
* disk is small, and a step that leaves a copy of every recording it touched
|
|
61
|
+
* fills it. `force` so a run that never created its output does not fail HERE and
|
|
62
|
+
* replace the real error with this one.
|
|
63
|
+
*/
|
|
64
|
+
export async function withTempDir<T>(work: (dir: string) => Promise<T>): Promise<T> {
|
|
65
|
+
const dir = await mkdtemp(join(tmpdir(), "aai-call-audit-"));
|
|
66
|
+
try {
|
|
67
|
+
return await work(dir);
|
|
68
|
+
} finally {
|
|
69
|
+
await rm(dir, { recursive: true, force: true });
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Write an upload to a local path, a window at a time.
|
|
75
|
+
*
|
|
76
|
+
* A `for` loop rather than a fan-out deliberately: the bytes land in one file at
|
|
77
|
+
* one offset each, so concurrency buys nothing and costs exactly the memory the
|
|
78
|
+
* windows are here to bound.
|
|
79
|
+
*
|
|
80
|
+
* `windowBytes` defaults to {@link WINDOW_BYTES}; see {@link fileChunks} for why
|
|
81
|
+
* it is a parameter at all.
|
|
82
|
+
*/
|
|
83
|
+
export async function materializeUpload(
|
|
84
|
+
uploadId: string,
|
|
85
|
+
size: number,
|
|
86
|
+
path: string,
|
|
87
|
+
windowBytes: number = WINDOW_BYTES,
|
|
88
|
+
): Promise<void> {
|
|
89
|
+
const handle = await open(path, "w");
|
|
90
|
+
try {
|
|
91
|
+
for (let at = 0; at < size; at += windowBytes) {
|
|
92
|
+
const slice = await readUpload(uploadId, {
|
|
93
|
+
start: at,
|
|
94
|
+
end: Math.min(at + windowBytes, size),
|
|
95
|
+
});
|
|
96
|
+
await handle.write(slice.bytes);
|
|
97
|
+
}
|
|
98
|
+
} finally {
|
|
99
|
+
await handle.close();
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* A local file as the stream `writeUpload` takes.
|
|
105
|
+
*
|
|
106
|
+
* A generator rather than `readFile`, for the same reason the windows above
|
|
107
|
+
* exist: the normalized PCM is the largest thing this desk touches, and handing
|
|
108
|
+
* the store an `AsyncIterable` is what keeps it off the heap.
|
|
109
|
+
*
|
|
110
|
+
* **The `.slice()` is load-bearing.** One buffer is reused across reads, so
|
|
111
|
+
* yielding a view of it hands the consumer memory the next read overwrites — a
|
|
112
|
+
* bug whose symptom is a stored file made of the LAST chunk repeated, and which
|
|
113
|
+
* does not reproduce whenever the consumer happens to copy before the next
|
|
114
|
+
* iteration.
|
|
115
|
+
*
|
|
116
|
+
* `windowBytes` is a parameter with a default for exactly that reason, and it is
|
|
117
|
+
* the one testability seam in this template. The aliasing bug above only manifests
|
|
118
|
+
* across MULTIPLE reads, so at the real 8 MiB window a spec would have to write a
|
|
119
|
+
* 16 MB file to reach it — and a first draft of that spec used 200 KB, passed with
|
|
120
|
+
* the `.slice()` deleted, and would have shipped a test proving nothing. A small
|
|
121
|
+
* window makes the multi-chunk path a few kilobytes instead.
|
|
122
|
+
*/
|
|
123
|
+
export async function* fileChunks(
|
|
124
|
+
path: string,
|
|
125
|
+
windowBytes: number = WINDOW_BYTES,
|
|
126
|
+
): AsyncIterable<Uint8Array> {
|
|
127
|
+
const handle = await open(path, "r");
|
|
128
|
+
try {
|
|
129
|
+
const buffer = new Uint8Array(windowBytes);
|
|
130
|
+
for (;;) {
|
|
131
|
+
const { bytesRead } = await handle.read(buffer, 0, buffer.length, null);
|
|
132
|
+
if (bytesRead === 0) return;
|
|
133
|
+
yield buffer.subarray(0, bytesRead).slice();
|
|
134
|
+
}
|
|
135
|
+
} finally {
|
|
136
|
+
await handle.close();
|
|
137
|
+
}
|
|
138
|
+
}
|
|
@@ -408,13 +408,21 @@ describe("submitRecording", () => {
|
|
|
408
408
|
|
|
409
409
|
const call = calls[0];
|
|
410
410
|
expect(call?.init.method).toBe("POST");
|
|
411
|
+
// `speaker_labels` is this desk's own request, carried through the SDK's
|
|
412
|
+
// `params` passthrough; the model field is the SDK's and is PLURAL.
|
|
411
413
|
expect(JSON.parse(String(call?.init.body))).toMatchObject({
|
|
412
414
|
audio_url: "https://example.com/a.mp3",
|
|
415
|
+
speaker_labels: true,
|
|
413
416
|
});
|
|
414
|
-
// AssemblyAI
|
|
415
|
-
//
|
|
417
|
+
// AssemblyAI takes the key RAW — no `Bearer` prefix, unlike the
|
|
418
|
+
// OpenAI-compatible LLM gateway `summarize` calls. The SDK spells the
|
|
419
|
+
// header `Authorization`; HTTP header names are case-insensitive, so the
|
|
420
|
+
// lookup is too rather than pinning one casing.
|
|
416
421
|
const headers = call?.init.headers as Record<string, string> | undefined;
|
|
417
|
-
|
|
422
|
+
const auth = Object.entries(headers ?? {}).find(
|
|
423
|
+
([name]) => name.toLowerCase() === "authorization",
|
|
424
|
+
);
|
|
425
|
+
expect(auth?.[1]).toBe("sk-test");
|
|
418
426
|
});
|
|
419
427
|
|
|
420
428
|
test("fails FATALLY on a bad key and plainly on a rate limit", async () => {
|
|
@@ -76,6 +76,7 @@ import {
|
|
|
76
76
|
requireStepEnv,
|
|
77
77
|
stepFetch,
|
|
78
78
|
stepGenerateJson,
|
|
79
|
+
stepTranscribeSubmit,
|
|
79
80
|
} from "@alexkroman1/aai/utils";
|
|
80
81
|
import { createHook, FatalError, sleep } from "workflow";
|
|
81
82
|
import { z } from "zod";
|
|
@@ -358,14 +359,13 @@ export async function submitRecording(url: string): Promise<{ id: string }> {
|
|
|
358
359
|
|
|
359
360
|
await report(`Submitting ${new URL(url).hostname} for transcription…`);
|
|
360
361
|
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
return { id };
|
|
362
|
+
// `stepTranscribeSubmit` owns the endpoint, the raw-key auth, the PLURAL
|
|
363
|
+
// `speech_models` field and the failure classification. `speaker_labels` is
|
|
364
|
+
// this desk's own request, which is what `params` is for — the async API's
|
|
365
|
+
// surface is large and the SDK deliberately does not mirror it.
|
|
366
|
+
return await stepTranscribeSubmit(url, { params: { speaker_labels: true } }).catch(
|
|
367
|
+
throwStepError,
|
|
368
|
+
);
|
|
369
369
|
}
|
|
370
370
|
|
|
371
371
|
/**
|
|
@@ -374,6 +374,17 @@ export async function submitRecording(url: string): Promise<{ id: string }> {
|
|
|
374
374
|
* One poll is one step, so each attempt is journaled on its own: a run that dies
|
|
375
375
|
* mid-wait resumes knowing what the last answer was instead of starting the
|
|
376
376
|
* recording over.
|
|
377
|
+
*
|
|
378
|
+
* **Deliberately NOT `stepTranscribePoll`, though its sibling above did move to
|
|
379
|
+
* the SDK.** That helper answers `done` and THROWS on a job the provider gave
|
|
380
|
+
* up on, which is the right shape for a flow whose only question is "is the
|
|
381
|
+
* text ready". This desk's question is different: `status` is a VALUE here,
|
|
382
|
+
* read by the Query port (`recap_status`) while the run is still going, and an
|
|
383
|
+
* `error` status is the branch that unwinds the saga's compensation stack
|
|
384
|
+
* rather than a failure to propagate. Converting this would trade a documented
|
|
385
|
+
* state machine — the thing this template is actually about — for a throw.
|
|
386
|
+
* The provider's status union is the template's subject, so it stays in the
|
|
387
|
+
* template.
|
|
377
388
|
*/
|
|
378
389
|
export async function checkTranscript(id: string): Promise<TranscriptState> {
|
|
379
390
|
"use step";
|