@alexkroman1/aai-cli 6.10.1 → 7.0.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/{_agent-DpH2pBJd.mjs → _agent-BTwZJu4P.mjs} +2 -2
- package/dist/{_bundler-C2BDwU7r.mjs → _bundler-B4RqNF5Z.mjs} +2 -2
- package/dist/_dev-env.d.ts +22 -2
- package/dist/{_dev-server-LLLWnFBs.mjs → _dev-server-CiKFH9qw.mjs} +38 -47
- package/dist/_dev-server.d.ts +0 -17
- package/dist/{_init-C57bWAQ8.mjs → _init-CE4HKdgV.mjs} +3 -2
- package/dist/{_preflight-BtfaYtbE.mjs → _preflight-Co1G9Jww.mjs} +1 -1
- package/dist/_preflight.d.ts +1 -1
- package/dist/{_slug-api-BxrsJXVI.mjs → _slug-api-nRFaBEPJ.mjs} +2 -2
- package/dist/{_studio-U3jhKlA3.mjs → _studio-DR5yzZ1T.mjs} +2 -1
- package/dist/{_templates-Dxb_P2Wz.mjs → _templates-FI3xPEMj.mjs} +1 -1
- package/dist/_workflow-scan.d.ts +108 -0
- package/dist/{build-Dfza2pRI.mjs → build-6jfaakgc.mjs} +5 -2
- package/dist/cli.mjs +30 -25
- package/dist/{client-bundler-BEIqgOtd.mjs → client-bundler-CWnG42cU.mjs} +2 -1
- package/dist/client-bundler.mjs +1 -1
- package/dist/{delete-vbpjGzqs.mjs → delete-Dl_LSfvI.mjs} +3 -3
- package/dist/{deploy-B8lubiRT.mjs → deploy-DvYeD8ia.mjs} +5 -5
- package/dist/{dev-Uq5ujP8a.mjs → dev-B59JIfWO.mjs} +1 -1
- package/dist/{eject-31gjtaHF.mjs → eject-C9WJyyr2.mjs} +1 -1
- package/dist/{init-l6xfU4xX.mjs → init-CpJVjEg0.mjs} +3 -3
- package/dist/{login-C71-qz8F.mjs → login-BBuM1sxH.mjs} +3 -4
- package/dist/{logs-BWVGSjLR.mjs → logs-9q-psa4q.mjs} +2 -2
- package/dist/project-config.mjs +1 -1
- package/dist/scaffold/CLAUDE.md +93 -14
- package/dist/scaffold/package.json +5 -3
- package/dist/scaffold/server.mjs +13 -4
- package/dist/scaffold/vite.config.ts +1 -1
- package/dist/{secret-BuMuFR4B.mjs → secret-CVvSLIDV.mjs} +2 -2
- package/dist/{storage-DzRZ-eCw.mjs → storage-BvUrnvM3.mjs} +2 -2
- package/dist/{studio-BCNUpDgP.mjs → studio--MUV0cid.mjs} +4 -4
- package/dist/templates/call-audit/agent.test.ts +818 -0
- package/dist/templates/call-audit/agent.ts +171 -0
- package/dist/templates/call-audit/client.tsx +198 -0
- package/dist/templates/call-audit/workflows/audit.ts +298 -0
- package/dist/templates/call-audit/workflows/ingest.ts +251 -0
- package/dist/templates/call-audit/workflows/media.ts +637 -0
- package/dist/templates/call-audit/workflows/summarize.ts +201 -0
- package/dist/templates/call-audit/workflows/sync-api.ts +44 -0
- package/dist/templates/dispatch-center/agent.test.ts +149 -25
- package/dist/templates/dispatch-center/client.tsx +239 -129
- package/dist/templates/dispatch-center/shared.ts +99 -1
- package/dist/templates/dispatch-center/system-prompt.md +3 -1
- package/dist/templates/dispatch-center/tools/incident_add_note.ts +16 -13
- package/dist/templates/dispatch-center/tools/incident_create.ts +19 -1
- package/dist/templates/dispatch-center/tools/incident_escalate.ts +68 -59
- package/dist/templates/dispatch-center/tools/incident_triage.ts +57 -43
- package/dist/templates/dispatch-center/tools/incident_update_status.ts +49 -40
- package/dist/templates/dispatch-center/tools/ops_run_scenario.ts +8 -2
- package/dist/templates/dispatch-center/tools/resources_dispatch.ts +69 -52
- package/dist/templates/dispatch-center/tools/resources_update_status.ts +45 -41
- package/dist/templates/embedded-assets/agent.test.ts +17 -14
- package/dist/templates/health-assistant/agent.test.ts +22 -4
- package/dist/templates/infocom-adventure/agent.test.ts +4 -6
- package/dist/templates/infocom-adventure/client.tsx +246 -164
- package/dist/templates/link-digest/agent.test.ts +24 -19
- package/dist/templates/link-digest/client.tsx +47 -61
- package/dist/templates/link-digest/workflows/digest.ts +19 -31
- package/dist/templates/night-owl/agent.test.ts +70 -19
- package/dist/templates/night-owl/agent.ts +5 -0
- package/dist/templates/night-owl/client.tsx +56 -56
- package/dist/templates/night-owl/shared.ts +24 -0
- package/dist/templates/night-owl/tools/recommend.ts +18 -6
- package/dist/templates/pizza-ordering/agent.test.ts +27 -27
- package/dist/templates/pizza-ordering/client.tsx +9 -26
- package/dist/templates/plan-and-execute/agent.test.ts +99 -58
- package/dist/templates/plan-and-execute/agent.ts +1 -1
- package/dist/templates/plan-and-execute/client.tsx +12 -15
- package/dist/templates/plan-and-execute/shared.ts +71 -2
- package/dist/templates/plan-and-execute/tools/plan_status.ts +18 -6
- package/dist/templates/plan-and-execute/tools/revise_plan.ts +18 -10
- package/dist/templates/plan-and-execute/tools/start_plan.ts +13 -2
- package/dist/templates/plan-and-execute/tools/work_next_step.ts +70 -22
- package/dist/templates/podcast-digest/agent.test.ts +746 -0
- package/dist/templates/podcast-digest/agent.ts +139 -0
- package/dist/templates/podcast-digest/client.tsx +154 -0
- package/dist/templates/podcast-digest/workflows/digest.ts +411 -0
- package/dist/templates/podcast-digest/workflows/feeds.ts +507 -0
- package/dist/templates/podcast-digest/workflows/slack.ts +209 -0
- package/dist/templates/recap-workflow/agent.test.ts +107 -96
- package/dist/templates/recap-workflow/tools/recap_progress.ts +9 -12
- package/dist/templates/recap-workflow/tools/recap_status.ts +8 -9
- package/dist/templates/recap-workflow/tools/request_recap.ts +2 -1
- package/dist/templates/recap-workflow/workflows/recap.ts +48 -41
- package/dist/templates/redline/agent.test.ts +17 -15
- package/dist/templates/redline/client.tsx +12 -12
- package/dist/templates/redline/workflows/redline.ts +19 -31
- package/dist/templates/research-workflow/agent.test.ts +60 -59
- package/dist/templates/research-workflow/tools/research_progress.ts +9 -12
- package/dist/templates/research-workflow/tools/research_status.ts +9 -11
- package/dist/templates/research-workflow/workflows/research.ts +44 -61
- package/dist/templates/retail/agent.test.ts +26 -23
- package/dist/templates/retail/client.tsx +226 -117
- package/dist/templates/retail/registry.test.ts +38 -6
- package/dist/templates/retail/store.test.ts +82 -15
- package/dist/templates/retail/store.ts +174 -47
- package/dist/templates/retail/system-prompt.md +11 -2
- package/dist/templates/retail/tools/cancel_pending_order.ts +2 -2
- package/dist/templates/retail/tools/exchange_delivered_order_items.ts +2 -2
- package/dist/templates/retail/tools/find_user_id_by_email.ts +8 -5
- package/dist/templates/retail/tools/find_user_id_by_name_zip.ts +4 -5
- package/dist/templates/retail/tools/get_item_details.ts +3 -4
- package/dist/templates/retail/tools/get_order_details.ts +3 -4
- package/dist/templates/retail/tools/get_product_details.ts +3 -4
- package/dist/templates/retail/tools/get_user_details.ts +2 -2
- package/dist/templates/retail/tools/list_all_product_types.ts +5 -5
- package/dist/templates/retail/tools/modify_pending_order_address.ts +2 -2
- package/dist/templates/retail/tools/modify_pending_order_items.ts +2 -3
- package/dist/templates/retail/tools/modify_pending_order_payment.ts +2 -2
- package/dist/templates/retail/tools/modify_user_address.ts +2 -2
- package/dist/templates/retail/tools/return_delivered_order_items.ts +2 -2
- package/dist/templates/retail/tools/transfer_to_human_agents.ts +8 -4
- package/dist/templates/solo-rpg/agent.test.ts +227 -48
- package/dist/templates/solo-rpg/client.tsx +38 -37
- package/dist/templates/solo-rpg/shared.ts +145 -19
- package/dist/templates/solo-rpg/system-prompt.md +3 -2
- package/dist/templates/solo-rpg/tools/action_roll.ts +82 -63
- package/dist/templates/solo-rpg/tools/burn_momentum.ts +85 -50
- package/dist/templates/solo-rpg/tools/check_state.ts +24 -7
- package/dist/templates/solo-rpg/tools/load_game.ts +13 -1
- package/dist/templates/solo-rpg/tools/save_game.ts +16 -7
- package/dist/templates/solo-rpg/tools/setup_character.ts +22 -2
- package/dist/templates/solo-rpg/tools/update_state.ts +113 -100
- package/dist/templates/spoken-summary/agent.test.ts +314 -0
- package/dist/templates/spoken-summary/agent.ts +155 -0
- package/dist/templates/spoken-summary/client.tsx +185 -0
- package/dist/templates/spoken-summary/workflows/summarize.ts +237 -0
- package/dist/templates/spoken-summary/workflows/transcribe.ts +138 -0
- package/dist/templates/support-line/agent.test.ts +11 -16
- package/dist/templates/support-line/agent.ts +1 -1
- package/dist/templates/support-line/client.tsx +9 -9
- package/dist/templates/support-line/nodes.ts +100 -0
- package/dist/templates/support-line/procedure.ts +407 -0
- package/dist/templates/support-line/tools/answer_question.ts +17 -4
- package/dist/templates/transcription-workflow/agent.test.ts +246 -123
- package/dist/templates/transcription-workflow/agent.ts +21 -7
- package/dist/templates/transcription-workflow/client.tsx +17 -42
- package/dist/templates/transcription-workflow/workflows/batch.ts +79 -180
- package/dist/templates/transcription-workflow/workflows/normalize.ts +247 -0
- package/dist/templates/transcription-workflow/workflows/stitch.ts +0 -11
- package/dist/templates/transcription-workflow/workflows/stream.ts +10 -9
- package/dist/templates/transcription-workflow/workflows/sync-api.ts +26 -94
- package/dist/templates/transcription-workflow/workflows/transcribe.ts +55 -40
- package/dist/templates/transcription-workflow/workflows/wav.ts +31 -31
- package/dist/templates/travel-concierge/agent.test.ts +64 -33
- package/dist/templates/travel-concierge/client.tsx +11 -23
- package/dist/templates/travel-concierge/routing.ts +34 -15
- package/dist/templates/travel-concierge/shared.ts +70 -3
- package/dist/templates/travel-concierge/tools/book_car_rental.ts +2 -2
- package/dist/templates/travel-concierge/tools/book_excursion.ts +2 -2
- package/dist/templates/travel-concierge/tools/book_hotel.ts +2 -2
- package/dist/templates/travel-concierge/tools/cancel_ticket.ts +2 -2
- package/dist/templates/travel-concierge/tools/update_ticket.ts +2 -2
- package/dist/{worker-bundler-CGD4r8Kc.mjs → worker-bundler-COxnqstQ.mjs} +221 -3
- package/dist/worker-bundler.mjs +1 -1
- package/dist/{workflow-CFpxOFfQ.mjs → workflow-D2AQf2Pl.mjs} +28 -1
- package/dist/workflow-bundler.d.ts +6 -1
- package/dist/workflow.d.ts +1 -1
- package/package.json +5 -4
- package/dist/templates/support-line/graph.ts +0 -224
- package/dist/{_config-D_s09e7g.mjs → _config-CmJOFsAP.mjs} +1 -1
- /package/dist/templates/plan-and-execute/{graph.ts → procedure.ts} +0 -0
|
@@ -0,0 +1,201 @@
|
|
|
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 { report, stepSpeak } from "@alexkroman1/aai/step";
|
|
39
|
+
import { stepGenerateJsonClassified, throwFfmpegStepError } from "@alexkroman1/aai/step-errors";
|
|
40
|
+
import { withTempDir, writeUploadFromFile } from "@alexkroman1/aai/step-files";
|
|
41
|
+
import { formatBytes, formatDuration, omitUndefined, plural } from "@alexkroman1/aai/utils";
|
|
42
|
+
import { z } from "zod";
|
|
43
|
+
import { masterArgs } from "./media.ts";
|
|
44
|
+
|
|
45
|
+
/** Risks the summary is reduced to. Enough to be useful, few enough to act on. */
|
|
46
|
+
const MAX_RISKS = 4;
|
|
47
|
+
|
|
48
|
+
/** Actions the summary is reduced to. */
|
|
49
|
+
const MAX_ACTIONS = 4;
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Characters of transcript handed to the model.
|
|
53
|
+
*
|
|
54
|
+
* The pass-an-id-not-a-payload rule meeting a case where the payload IS the work:
|
|
55
|
+
* the text has to cross the queue between two steps, so it is bounded rather than
|
|
56
|
+
* trusted. 40k characters is roughly four hours of speech — past where another
|
|
57
|
+
* paragraph changes a four-point summary.
|
|
58
|
+
*/
|
|
59
|
+
const MAX_TRANSCRIPT_CHARS = 40_000;
|
|
60
|
+
|
|
61
|
+
/** How long the mastering pass may run. Seconds of work; the bound is for a pathological input. */
|
|
62
|
+
const MASTER_TIMEOUT_MS = 5 * 60_000;
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* The shape the model must answer in, as something that CHECKS.
|
|
66
|
+
*
|
|
67
|
+
* `stepGenerateJson` validates against this and throws plainly when the reply
|
|
68
|
+
* misses, which is what a retry is for: a model that answered with prose may well
|
|
69
|
+
* obey on the next attempt.
|
|
70
|
+
*/
|
|
71
|
+
const AuditReply = z.object({
|
|
72
|
+
headline: z.string().trim().min(1),
|
|
73
|
+
// Allowed to be EMPTY, unlike `spoken` below, and the asymmetry is deliberate: a
|
|
74
|
+
// call with nothing worrying in it is a real call, and a schema that demanded a
|
|
75
|
+
// risk would get an invented one. An empty array is an answer.
|
|
76
|
+
risks: z.array(z.string().trim().min(1)),
|
|
77
|
+
actions: z.array(z.string().trim().min(1)),
|
|
78
|
+
// NOT `.default("")` — the whole second half of this workflow has nothing to say
|
|
79
|
+
// without it, and a default would turn a missing field into a silent half-second
|
|
80
|
+
// of audio rather than a retry.
|
|
81
|
+
spoken: z.string().trim().min(1),
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
/** What the model made of the call. */
|
|
85
|
+
export type CallSummary = {
|
|
86
|
+
headline: string;
|
|
87
|
+
risks: string[];
|
|
88
|
+
actions: string[];
|
|
89
|
+
/** The same summary, written to be READ ALOUD — see {@link summarize}. */
|
|
90
|
+
spoken: string;
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Reduce the transcript to a headline, the risks, the actions, and a script.
|
|
95
|
+
*
|
|
96
|
+
* **The model is asked for TWO summaries, and the difference is the point.**
|
|
97
|
+
* `risks`/`actions` are for reading and `spoken` is for hearing; a template that
|
|
98
|
+
* synthesized its own bullet list produces a voice reading "one. two. three." with
|
|
99
|
+
* no connective tissue. So the schema asks for a script as well, in sentences, and
|
|
100
|
+
* that is what {@link narrate} is handed. It is a decision a prompt alone does not
|
|
101
|
+
* hold, which is why the field is required rather than defaulted.
|
|
102
|
+
*/
|
|
103
|
+
export async function summarize(
|
|
104
|
+
transcript: string,
|
|
105
|
+
source: string,
|
|
106
|
+
durationMs: number,
|
|
107
|
+
): Promise<CallSummary> {
|
|
108
|
+
"use step";
|
|
109
|
+
|
|
110
|
+
await report("Reading the transcript.");
|
|
111
|
+
const reply = await stepGenerateJsonClassified(
|
|
112
|
+
`Audit this transcript of a recorded call (${source}, ${formatDuration(durationMs)}).\n\n` +
|
|
113
|
+
"Answer with JSON only, in this shape:\n" +
|
|
114
|
+
`{"headline": "...", "risks": ["..."], "actions": ["..."], "spoken": "..."}\n\n` +
|
|
115
|
+
"- headline: one line naming what the call was about.\n" +
|
|
116
|
+
`- risks: at most ${MAX_RISKS} things a reader should worry about — a ` +
|
|
117
|
+
"commitment nobody owns, a number that was guessed at, a disagreement left " +
|
|
118
|
+
"unresolved. Quote or name the specifics. An EMPTY array if the call really " +
|
|
119
|
+
"had none; never invent one.\n" +
|
|
120
|
+
`- actions: at most ${MAX_ACTIONS} things somebody has to do next, each ` +
|
|
121
|
+
"naming who if the call named them.\n" +
|
|
122
|
+
"- spoken: the same audit written to be READ ALOUD. Full sentences that " +
|
|
123
|
+
"flow, under 150 words, no bullet markers, no headings, no markdown. " +
|
|
124
|
+
"Someone will hear this without seeing the lists.\n\n" +
|
|
125
|
+
`Transcript:\n${transcript.slice(0, MAX_TRANSCRIPT_CHARS)}`,
|
|
126
|
+
{
|
|
127
|
+
system: "You audit recorded calls. You answer with JSON and nothing else.",
|
|
128
|
+
schema: AuditReply,
|
|
129
|
+
},
|
|
130
|
+
// The `Classified` caller is `stepGenerateJson` plus `throwStepError`, which is
|
|
131
|
+
// what reads the gateway's own status: a 429 is worth another attempt and a 400
|
|
132
|
+
// is not, and that is what tells the DevKit which.
|
|
133
|
+
);
|
|
134
|
+
|
|
135
|
+
await report(
|
|
136
|
+
`Found ${reply.risks.length} ${plural(reply.risks.length, "risk")} and ` +
|
|
137
|
+
`${reply.actions.length} ${plural(reply.actions.length, "action")}.`,
|
|
138
|
+
);
|
|
139
|
+
return {
|
|
140
|
+
headline: reply.headline,
|
|
141
|
+
risks: reply.risks.slice(0, MAX_RISKS),
|
|
142
|
+
actions: reply.actions.slice(0, MAX_ACTIONS),
|
|
143
|
+
spoken: reply.spoken,
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Read the audit aloud, master it, store it, and answer with its id.
|
|
149
|
+
*
|
|
150
|
+
* **All four halves belong in ONE step**, and the reason is what a journal
|
|
151
|
+
* records: a step is replayed by its RETURN VALUE, so an id is replayed and bytes
|
|
152
|
+
* are not. Split in two, the audio would have to cross the queue between them —
|
|
153
|
+
* megabytes of it, on every resume — and the temp file the mastering pass needs
|
|
154
|
+
* cannot cross a step boundary at all (see `@alexkroman1/aai/step-files`). Together, a resumed
|
|
155
|
+
* run replays the id and re-reads a file that is already there.
|
|
156
|
+
*
|
|
157
|
+
* The cost is that a retried step writes a second upload and abandons the first.
|
|
158
|
+
* Cheap next to a step that cannot retry.
|
|
159
|
+
*/
|
|
160
|
+
export async function narrate(
|
|
161
|
+
script: string,
|
|
162
|
+
voice?: string,
|
|
163
|
+
): Promise<{ audio: string; durationMs: number; bytes: number }> {
|
|
164
|
+
"use step";
|
|
165
|
+
|
|
166
|
+
const spoken = await stepSpeak(script, omitUndefined({ voice }));
|
|
167
|
+
|
|
168
|
+
return await withTempDir(
|
|
169
|
+
async (dir) => {
|
|
170
|
+
const wav = join(dir, "spoken.wav");
|
|
171
|
+
const mp3 = join(dir, "summary.mp3");
|
|
172
|
+
|
|
173
|
+
// `writeFile` rather than a stream, and this is the one place in the template
|
|
174
|
+
// where holding the whole thing in memory is right: `stepSpeak` already
|
|
175
|
+
// returned it as a single `Uint8Array`, so streaming it to disk would be
|
|
176
|
+
// copying from the heap to the heap on the way. It is bounded by the script,
|
|
177
|
+
// which the schema keeps under 150 words.
|
|
178
|
+
await writeFile(wav, spoken.audio);
|
|
179
|
+
|
|
180
|
+
await runFfmpeg(masterArgs(wav, mp3), { timeoutMs: MASTER_TIMEOUT_MS }).catch(
|
|
181
|
+
throwFfmpegStepError,
|
|
182
|
+
);
|
|
183
|
+
const bytes = (await stat(mp3)).size;
|
|
184
|
+
|
|
185
|
+
const stored = await writeUploadFromFile(mp3, {
|
|
186
|
+
// Named, because this is what a person sees on the download link rather than
|
|
187
|
+
// an opaque id — and typed, because the byte route serves the type it was
|
|
188
|
+
// given and a browser will not play a file it was handed as bytes.
|
|
189
|
+
name: "audit.mp3",
|
|
190
|
+
type: "audio/mpeg",
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
await report(
|
|
194
|
+
`Recorded a ${Math.round(spoken.durationMs / 1000)}s audit in ${spoken.voice}'s voice — ` +
|
|
195
|
+
`${formatBytes(bytes)} of MP3, from ${formatBytes(spoken.audio.byteLength)} of WAV.`,
|
|
196
|
+
);
|
|
197
|
+
return { audio: stored.id, durationMs: spoken.durationMs, bytes };
|
|
198
|
+
},
|
|
199
|
+
{ prefix: "aai-call-audit-" },
|
|
200
|
+
);
|
|
201
|
+
}
|
|
@@ -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 classification that hands the verdict to
|
|
11
|
+
* the DevKit, and that belongs somewhere a spec can reach it.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { stepTranscribeSyncClassified } from "@alexkroman1/aai/step-errors";
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Transcribe one complete WAV.
|
|
18
|
+
*
|
|
19
|
+
* `bytes` must be a whole file, header included — the endpoint decodes each
|
|
20
|
+
* request independently, so a headerless span is bytes it will refuse. This desk
|
|
21
|
+
* stores headerless PCM on purpose (see `media.ts`) and puts a header back with
|
|
22
|
+
* `encodeWav` for exactly this call.
|
|
23
|
+
*
|
|
24
|
+
* `stepTranscribeSyncClassified` — the SDK's own `stepTranscribeSync` plus
|
|
25
|
+
* `throwStepError`, and nothing else — is the whole of what this adds, and it is
|
|
26
|
+
* where the 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 stepTranscribeSyncClassified(bytes, { filename, label });
|
|
43
|
+
return text;
|
|
44
|
+
}
|
|
@@ -1,11 +1,20 @@
|
|
|
1
|
-
import type {
|
|
2
|
-
|
|
1
|
+
import type {
|
|
2
|
+
DialogPosition,
|
|
3
|
+
InferToolOutput,
|
|
4
|
+
ToolContext,
|
|
5
|
+
ToolDef,
|
|
6
|
+
ToolInputSchema,
|
|
7
|
+
} from "@alexkroman1/aai";
|
|
8
|
+
import { isToolFailure } from "@alexkroman1/aai";
|
|
9
|
+
import { createToolContext, ok } from "@alexkroman1/aai/testing";
|
|
3
10
|
import { describe, expect, test } from "vitest";
|
|
4
|
-
import { dispatchSlot } from "./shared.ts";
|
|
11
|
+
import { callFlow, dispatchSlot } from "./shared.ts";
|
|
12
|
+
import incidentAddNote from "./tools/incident_add_note.ts";
|
|
5
13
|
import incidentCreate from "./tools/incident_create.ts";
|
|
6
14
|
import incidentEscalate from "./tools/incident_escalate.ts";
|
|
7
15
|
import incidentTriage from "./tools/incident_triage.ts";
|
|
8
16
|
import incidentUpdateStatus from "./tools/incident_update_status.ts";
|
|
17
|
+
import opsRunScenario from "./tools/ops_run_scenario.ts";
|
|
9
18
|
import resourcesDispatch from "./tools/resources_dispatch.ts";
|
|
10
19
|
import resourcesUpdateStatus from "./tools/resources_update_status.ts";
|
|
11
20
|
|
|
@@ -14,14 +23,31 @@ import resourcesUpdateStatus from "./tools/resources_update_status.ts";
|
|
|
14
23
|
* construction. */
|
|
15
24
|
const makeCtx = (): ToolContext => createToolContext();
|
|
16
25
|
|
|
26
|
+
/**
|
|
27
|
+
* What a gated tool's own `execute` returned, read off the tool itself.
|
|
28
|
+
*
|
|
29
|
+
* `callFlow.tool` threads its result type out now, so `InferToolOutput` answers
|
|
30
|
+
* `DialogToolResult<R> | ToolFailure` — this is that minus the envelope and the
|
|
31
|
+
* refusal arm. It replaces the inline `{ dispatched: { callsign: string }[] }`
|
|
32
|
+
* shapes the assertions below used to restate, which were a second copy of each
|
|
33
|
+
* tool's return type that could not go stale loudly.
|
|
34
|
+
*
|
|
35
|
+
* The unwrap itself is `ok` from `@alexkroman1/aai/testing`; the hand-rolled
|
|
36
|
+
* copy that used to sit here was byte-identical to three other templates'.
|
|
37
|
+
*/
|
|
38
|
+
type Result<T extends ToolDef<ToolInputSchema>> = Extract<
|
|
39
|
+
InferToolOutput<T>,
|
|
40
|
+
{ result: unknown }
|
|
41
|
+
>["result"];
|
|
42
|
+
|
|
43
|
+
/** Where the call is, without going through a tool. */
|
|
44
|
+
const at = (ctx: ToolContext): DialogPosition => callFlow.position(ctx);
|
|
45
|
+
|
|
17
46
|
async function createIncidentFor(
|
|
18
47
|
ctx: ToolContext,
|
|
19
48
|
description = "structure fire with heavy smoke",
|
|
20
49
|
): Promise<string> {
|
|
21
|
-
const result =
|
|
22
|
-
{ location: "400 Oak Street", description },
|
|
23
|
-
ctx,
|
|
24
|
-
)) as { incidentId: string };
|
|
50
|
+
const result = await incidentCreate.execute({ location: "400 Oak Street", description }, ctx);
|
|
25
51
|
return result.incidentId;
|
|
26
52
|
}
|
|
27
53
|
|
|
@@ -52,10 +78,9 @@ describe("dispatch-center template", () => {
|
|
|
52
78
|
const ctx = makeCtx();
|
|
53
79
|
const incidentId = await createIncidentFor(ctx, "cardiac arrest, patient not breathing");
|
|
54
80
|
|
|
55
|
-
const result =
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
};
|
|
81
|
+
const result = ok<Result<typeof resourcesDispatch>>(
|
|
82
|
+
await resourcesDispatch.execute({ incidentId, callsigns: ["auto"] }, ctx),
|
|
83
|
+
);
|
|
59
84
|
|
|
60
85
|
expect(result.failed).toBeUndefined();
|
|
61
86
|
expect(result.dispatched.length).toBeGreaterThan(0);
|
|
@@ -68,10 +93,10 @@ describe("dispatch-center template", () => {
|
|
|
68
93
|
// mutex in updateState makes each one run against the previous one's
|
|
69
94
|
// finished state, so neither incident's changes are half-applied when
|
|
70
95
|
// the other's mutator runs.
|
|
71
|
-
const [a, b] =
|
|
96
|
+
const [a, b] = await Promise.all([
|
|
72
97
|
incidentCreate.execute({ location: "1 First St", description: "gas leak" }, ctx),
|
|
73
98
|
incidentCreate.execute({ location: "2 Second St", description: "vehicle crash" }, ctx),
|
|
74
|
-
])
|
|
99
|
+
]);
|
|
75
100
|
|
|
76
101
|
const state = dispatchSlot.get(ctx);
|
|
77
102
|
expect(a?.incidentId).not.toBe(b?.incidentId);
|
|
@@ -124,21 +149,120 @@ describe("dispatch-center template", () => {
|
|
|
124
149
|
const incidentId = await createIncidentFor(ctx);
|
|
125
150
|
await incidentUpdateStatus.execute({ incidentId, status: "resolved" }, ctx);
|
|
126
151
|
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
}
|
|
130
|
-
|
|
152
|
+
// Each is a refusal the BODY answered, so it arrives unwrapped as a
|
|
153
|
+
// `ToolFailure` rather than under the position envelope — which is what the
|
|
154
|
+
// narrowing says, where the old cast to `{ error?: string }` said nothing
|
|
155
|
+
// and would have read `undefined` off a success just as quietly.
|
|
156
|
+
const escalated = await incidentEscalate.execute({ incidentId, reason: "flare-up" }, ctx);
|
|
157
|
+
expect(isToolFailure(escalated) && escalated.error).toMatch(/resolved/);
|
|
131
158
|
|
|
132
|
-
const reResolved =
|
|
133
|
-
|
|
134
|
-
ctx,
|
|
135
|
-
)) as { error?: string };
|
|
136
|
-
expect(reResolved.error).toMatch(/resolved/);
|
|
159
|
+
const reResolved = await incidentUpdateStatus.execute({ incidentId, status: "resolved" }, ctx);
|
|
160
|
+
expect(isToolFailure(reResolved) && reResolved.error).toMatch(/resolved/);
|
|
137
161
|
|
|
138
|
-
const dispatchedTo =
|
|
162
|
+
const dispatchedTo = await resourcesDispatch.execute(
|
|
139
163
|
{ incidentId, callsigns: ["Medic-1"] },
|
|
140
164
|
ctx,
|
|
141
|
-
)
|
|
142
|
-
expect(dispatchedTo.error).toMatch(/resolved/);
|
|
165
|
+
);
|
|
166
|
+
expect(isToolFailure(dispatchedTo) && dispatchedTo.error).toMatch(/resolved/);
|
|
167
|
+
});
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
describe("the call flow", () => {
|
|
171
|
+
test("a fresh shift is in standby, and every incident tool refuses there", async () => {
|
|
172
|
+
const ctx = makeCtx();
|
|
173
|
+
expect(at(ctx).state).toBe("standby");
|
|
174
|
+
|
|
175
|
+
// Each of these used to run and answer `Incident INC-0001 not found` — a
|
|
176
|
+
// data answer to a positional question. The refusal now names where the
|
|
177
|
+
// shift is and quotes what to do about it.
|
|
178
|
+
for (const call of [
|
|
179
|
+
incidentTriage.execute({ incidentId: "INC-0001" }, ctx),
|
|
180
|
+
resourcesDispatch.execute({ incidentId: "INC-0001", callsigns: ["Medic-1"] }, ctx),
|
|
181
|
+
incidentUpdateStatus.execute({ incidentId: "INC-0001", status: "on_scene" }, ctx),
|
|
182
|
+
incidentEscalate.execute({ incidentId: "INC-0001", reason: "spreading" }, ctx),
|
|
183
|
+
incidentAddNote.execute({ incidentId: "INC-0001", note: "caller hung up" }, ctx),
|
|
184
|
+
resourcesUpdateStatus.execute({ callsign: "Medic-1", status: "en_route" }, ctx),
|
|
185
|
+
]) {
|
|
186
|
+
const refusal = await call;
|
|
187
|
+
expect(isToolFailure(refusal)).toBe(true);
|
|
188
|
+
expect(isToolFailure(refusal) && refusal.error).toMatch(/standby/);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// And nothing ran: a refusal must not have touched the board.
|
|
192
|
+
expect(dispatchSlot.get(ctx).incidentCounter).toBe(0);
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
test("logging, triaging and dispatching walk the call through its three steps", async () => {
|
|
196
|
+
const ctx = makeCtx();
|
|
197
|
+
|
|
198
|
+
const created = await incidentCreate.execute(
|
|
199
|
+
{ location: "400 Oak Street", description: "structure fire with heavy smoke" },
|
|
200
|
+
ctx,
|
|
201
|
+
);
|
|
202
|
+
// `state`/`instruction`, not `at`/`next`: this ungated tool spreads the
|
|
203
|
+
// `DialogPosition` verbatim now, so it reports its position under the same
|
|
204
|
+
// keys every gated tool's result carries.
|
|
205
|
+
expect(created.state).toBe("working.triaging");
|
|
206
|
+
expect(created.instruction).toMatch(/incident_triage/);
|
|
207
|
+
|
|
208
|
+
const triaged = ok<Result<typeof incidentTriage>>(
|
|
209
|
+
await incidentTriage.execute({ incidentId: created.incidentId, severity: "critical" }, ctx),
|
|
210
|
+
);
|
|
211
|
+
expect(triaged.triageScore).toBeGreaterThan(0);
|
|
212
|
+
expect(at(ctx).state).toBe("working.dispatching");
|
|
213
|
+
|
|
214
|
+
ok(
|
|
215
|
+
await resourcesDispatch.execute({ incidentId: created.incidentId, autoDispatch: true }, ctx),
|
|
216
|
+
);
|
|
217
|
+
expect(at(ctx).state).toBe("working.monitoring");
|
|
218
|
+
expect(at(ctx).instruction).toMatch(/radio in/);
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
test("a dispatch that rolled nothing leaves the call where it was", async () => {
|
|
222
|
+
const ctx = makeCtx();
|
|
223
|
+
const incidentId = await createIncidentFor(ctx);
|
|
224
|
+
await incidentTriage.execute({ incidentId }, ctx);
|
|
225
|
+
expect(at(ctx).state).toBe("working.dispatching");
|
|
226
|
+
|
|
227
|
+
// Every requested callsign is unknown, so no unit moved — and the call has
|
|
228
|
+
// not moved on either.
|
|
229
|
+
const result = ok<Result<typeof resourcesDispatch>>(
|
|
230
|
+
await resourcesDispatch.execute({ incidentId, callsigns: ["Ghost-1"] }, ctx),
|
|
231
|
+
);
|
|
232
|
+
expect(result.dispatched).toHaveLength(0);
|
|
233
|
+
expect(at(ctx).state).toBe("working.dispatching");
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
test("a new call is legal mid-incident and puts the flow back on triage", async () => {
|
|
237
|
+
const ctx = makeCtx();
|
|
238
|
+
const first = await createIncidentFor(ctx);
|
|
239
|
+
await incidentTriage.execute({ incidentId: first }, ctx);
|
|
240
|
+
await resourcesDispatch.execute({ incidentId: first, autoDispatch: true }, ctx);
|
|
241
|
+
expect(at(ctx).state).toBe("working.monitoring");
|
|
242
|
+
|
|
243
|
+
await createIncidentFor(ctx, "chemical spill spreading toward a storm drain");
|
|
244
|
+
expect(at(ctx).state).toBe("working.triaging");
|
|
245
|
+
|
|
246
|
+
// The first incident is still workable — the position tracks the call in
|
|
247
|
+
// hand, and the tools are addressed by id.
|
|
248
|
+
ok(await incidentAddNote.execute({ incidentId: first, note: "crews on scene" }, ctx));
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
test("a failed tool does not advance the flow", async () => {
|
|
252
|
+
const ctx = makeCtx();
|
|
253
|
+
const incidentId = await createIncidentFor(ctx);
|
|
254
|
+
await incidentUpdateStatus.execute({ incidentId, status: "resolved" }, ctx);
|
|
255
|
+
const before = at(ctx).state;
|
|
256
|
+
|
|
257
|
+
const refused = await incidentTriage.execute({ incidentId, severity: "critical" }, ctx);
|
|
258
|
+
expect(isToolFailure(refused)).toBe(true);
|
|
259
|
+
expect(at(ctx).state).toBe(before);
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
test("a training scenario logs incidents like a real call does", async () => {
|
|
263
|
+
const ctx = makeCtx();
|
|
264
|
+
const result = await opsRunScenario.execute({ scenario: "mass_casualty" }, ctx);
|
|
265
|
+
expect(result.incidentsCreated.length).toBeGreaterThan(1);
|
|
266
|
+
expect(result.state).toBe("working.triaging");
|
|
143
267
|
});
|
|
144
268
|
});
|