@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,171 @@
|
|
|
1
|
+
// Copyright 2026 the AAI authors. MIT license.
|
|
2
|
+
/**
|
|
3
|
+
* A WORKFLOW APP that audits a recorded call, with **ffmpeg in the pipeline on
|
|
4
|
+
* both sides of the model.**
|
|
5
|
+
*
|
|
6
|
+
* ```text
|
|
7
|
+
* any recording → levelled PCM → cut at the pauses → transcript
|
|
8
|
+
* ffmpeg ffmpeg sync STT
|
|
9
|
+
*
|
|
10
|
+
* → headline, risks, actions → a script → an MP3
|
|
11
|
+
* LLM Gateway schema TTS + ffmpeg
|
|
12
|
+
* ```
|
|
13
|
+
*
|
|
14
|
+
* ## Which template to read first
|
|
15
|
+
*
|
|
16
|
+
* This one is the deepest of the workflow apps, so it is the wrong place to start.
|
|
17
|
+
*
|
|
18
|
+
* - `link-digest` owns the SHAPE — `workflowApp()`, no session, no tools, a form
|
|
19
|
+
* that starts a run and a page that watches it.
|
|
20
|
+
* - `transcription-workflow` owns the FAN-OUT — why the sync transcription
|
|
21
|
+
* endpoint's 120-second cap forces one, and how a run survives dying on segment
|
|
22
|
+
* 27 of 60.
|
|
23
|
+
* - `spoken-summary` owns the audio ROUND TRIP — `stepSpeak`, `writeUpload`, and
|
|
24
|
+
* why a page needs `api.download` rather than a URL.
|
|
25
|
+
*
|
|
26
|
+
* **What this template adds is a decoder**, and everything downstream changes
|
|
27
|
+
* because of it. `workflows/audit.ts` carries the table comparing it against
|
|
28
|
+
* `transcription-workflow` row by row; the summary is that normalizing FIRST lets
|
|
29
|
+
* the desk cut a recording *in its pauses* instead of every 90 seconds, which
|
|
30
|
+
* deletes the segment overlap, the seam-matching stitcher, the WAV header parser
|
|
31
|
+
* and one of the two provider caps it had to plan against.
|
|
32
|
+
*
|
|
33
|
+
* ## Five ffmpeg jobs, and each one is a decision
|
|
34
|
+
*
|
|
35
|
+
* `workflows/media.ts` is where they are built, and it is the file to read: every
|
|
36
|
+
* argv is a pure function, so the argv is a value a spec asserts on rather than a
|
|
37
|
+
* string embedded in a step that spawns.
|
|
38
|
+
*
|
|
39
|
+
* | | what it does | why it is not obvious |
|
|
40
|
+
* | --- | --- | --- |
|
|
41
|
+
* | `ffprobe` | what the file WAS | on a temp file, because a pipe cannot seek an m4a's trailing index |
|
|
42
|
+
* | `loudnorm` pass 1 | measure five numbers | `-f null -`: decode everything, write nothing |
|
|
43
|
+
* | `loudnorm` pass 2 | apply them | one linear gain, so speech does not pump |
|
|
44
|
+
* | `silencedetect` | find every pause | same pass as above — a filter chain, not a second decode |
|
|
45
|
+
* | `libmp3lame` | master the summary | 4.3 MB of WAV becomes ~110 KB |
|
|
46
|
+
*
|
|
47
|
+
* The two analyses read their answers back by **different routes**, and that is the
|
|
48
|
+
* single most surprising thing in the template: loudness arrives on stderr (one
|
|
49
|
+
* block, printed last, so the SDK's capped stderr TAIL holds it) and the pauses
|
|
50
|
+
* arrive in a FILE (one event per pause, so a tail would silently drop the
|
|
51
|
+
* earliest ones and the desk would mis-cut only long recordings). `media.ts`'s
|
|
52
|
+
* module doc carries it.
|
|
53
|
+
*
|
|
54
|
+
* ## What it needs
|
|
55
|
+
*
|
|
56
|
+
* - **`ASSEMBLYAI_API_KEY` in the agent env** — `.env` under `aai dev`,
|
|
57
|
+
* `aai secret put ASSEMBLYAI_API_KEY` once deployed. `requiredEnv` below is what
|
|
58
|
+
* makes a deploy check for it rather than letting the first run find out. One key
|
|
59
|
+
* covers transcription, the model and the voice alike.
|
|
60
|
+
* - **Storage** (`aai storage enable`, Settings → Database in the studio, or
|
|
61
|
+
* `DATABASE_URL` under `aai dev`). REQUIRED, unlike most workflow apps: an
|
|
62
|
+
* upload's record is a row, and this desk both reads an upload and writes two.
|
|
63
|
+
* - **ffmpeg** — every deployed guest's image installs it (and `ffprobe` with it).
|
|
64
|
+
* Under `aai dev` it is whatever is on `PATH`, or what `AAI_FFMPEG_PATH` /
|
|
65
|
+
* `AAI_FFPROBE_PATH` name. That is the one place dev/prod parity is partial, so
|
|
66
|
+
* a missing binary is reported as an instruction rather than as
|
|
67
|
+
* `spawn ffmpeg ENOENT`.
|
|
68
|
+
*
|
|
69
|
+
* ## It is scriptable, which is the other half of having an API
|
|
70
|
+
*
|
|
71
|
+
* The page is one caller. Two requests do the same thing from a shell — upload,
|
|
72
|
+
* then start a run naming the id, with `wait` holding the request open:
|
|
73
|
+
*
|
|
74
|
+
* ```sh
|
|
75
|
+
* ID=$(curl -s -X POST "https://<your-agent>/workflows/uploads?name=call.m4a" \
|
|
76
|
+
* -H 'content-type: audio/mp4' --data-binary @call.m4a | jq -r .id)
|
|
77
|
+
*
|
|
78
|
+
* curl -X POST https://<your-agent>/workflows/runs \
|
|
79
|
+
* -H 'content-type: application/json' \
|
|
80
|
+
* -d "{\"workflow\":\"audit\",\"wait\":60000,\"input\":{\"recording\":\"$ID\"}}"
|
|
81
|
+
* ```
|
|
82
|
+
*
|
|
83
|
+
* The spoken audit comes back as an upload id in the run's output; the byte route
|
|
84
|
+
* (`GET /workflows/uploads/<id>`) takes the same bearer every other route does.
|
|
85
|
+
*/
|
|
86
|
+
|
|
87
|
+
import { workflow, workflowApp } from "@alexkroman1/aai";
|
|
88
|
+
import { ASSEMBLYAI_TTS_DEFAULT_VOICE, ASSEMBLYAI_TTS_VOICES } from "@alexkroman1/aai/tts";
|
|
89
|
+
import type { WorkflowDef } from "@alexkroman1/aai/workflow-api";
|
|
90
|
+
import { z } from "zod";
|
|
91
|
+
import { auditFlow, type CallAudit } from "./workflows/audit.ts";
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* The voices the form offers.
|
|
95
|
+
*
|
|
96
|
+
* READ from the SDK's catalog rather than listed, because a wrong voice id is a
|
|
97
|
+
* SILENT failure — it is a free-form string the service rejects in band after the
|
|
98
|
+
* socket is open, so the synthesis simply produces nothing. Narrowed to the English
|
|
99
|
+
* ones because the audit is written in the transcript's language and the prompt does
|
|
100
|
+
* not translate; every voice in the catalog speaks exactly one.
|
|
101
|
+
*/
|
|
102
|
+
const VOICES = Object.entries(ASSEMBLYAI_TTS_VOICES)
|
|
103
|
+
.filter(([, spec]) => spec.language === "en")
|
|
104
|
+
.map(([id]) => id);
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* The same list as a TUPLE, which is what `z.enum` takes.
|
|
108
|
+
*
|
|
109
|
+
* Destructured rather than cast: a `.map` produces an array, and
|
|
110
|
+
* `as [string, ...string[]]` would be a template teaching a cast. The default covers
|
|
111
|
+
* the empty case honestly — a catalog with no English voice falls back to the SDK's
|
|
112
|
+
* own default rather than rendering a picker with no options.
|
|
113
|
+
*/
|
|
114
|
+
const [FIRST_VOICE = ASSEMBLYAI_TTS_DEFAULT_VOICE, ...OTHER_VOICES] = VOICES;
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* The run input, as its own const.
|
|
118
|
+
*
|
|
119
|
+
* Named rather than inline because {@link audit} carries an explicit type, and
|
|
120
|
+
* that annotation is what lets `workflows/audit.ts` name
|
|
121
|
+
* `WorkflowInputOf<typeof audit>` for its body's parameter: the body's own
|
|
122
|
+
* signature would otherwise be part of what infers this declaration's type, and
|
|
123
|
+
* TypeScript refuses the cycle (`TS7022`).
|
|
124
|
+
*/
|
|
125
|
+
const auditInput = z.object({
|
|
126
|
+
// A plain string, because an upload id is what the run really receives. What
|
|
127
|
+
// makes it a file picker rather than a text box is the `uploads` line below.
|
|
128
|
+
//
|
|
129
|
+
// "Anything" is not marketing: the first step hands the file to ffmpeg, so the
|
|
130
|
+
// accepted set is ffmpeg's rather than this template's — a video's audio track
|
|
131
|
+
// included, since the conversion drops the video.
|
|
132
|
+
recording: z.string().describe("Any recording — WAV, MP3, M4A, or a video's audio track"),
|
|
133
|
+
// An enum, so the form renders a SELECT rather than a text box — which is the
|
|
134
|
+
// whole reason the list is derived above rather than left free-form. Optional,
|
|
135
|
+
// so the SDK's own default voice applies when nobody chooses.
|
|
136
|
+
voice: z
|
|
137
|
+
.enum([FIRST_VOICE, ...OTHER_VOICES])
|
|
138
|
+
.optional()
|
|
139
|
+
.describe("Voice to read the audit in"),
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* The declaration: schema, description, and the directive body.
|
|
144
|
+
*
|
|
145
|
+
* Exported so `WorkflowOutputOf<typeof audit>` names the output type in one place —
|
|
146
|
+
* including from `client.tsx`, where `import type` is erased and so bundles nothing
|
|
147
|
+
* server-side — and so `workflows/audit.ts` can name `WorkflowInputOf<typeof audit>`
|
|
148
|
+
* for the body's parameter.
|
|
149
|
+
*/
|
|
150
|
+
export const audit: WorkflowDef<typeof auditInput, CallAudit> = workflow({
|
|
151
|
+
description: "Level a call recording, transcribe it at its pauses, and audit what was said",
|
|
152
|
+
input: auditInput,
|
|
153
|
+
// The one line that makes the form take a file: `<WorkflowFields>` renders a
|
|
154
|
+
// picker for this property, `useWorkflowSubmit` stores the chosen file, and the
|
|
155
|
+
// ingest step reads it back with `readUpload`.
|
|
156
|
+
uploads: ["recording"],
|
|
157
|
+
run: auditFlow,
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
export default workflowApp({
|
|
161
|
+
name: "Call Audit",
|
|
162
|
+
workflows: { audit },
|
|
163
|
+
// Checked at deploy time, so a missing key is a warning naming it rather than a run
|
|
164
|
+
// that fails on its third step. A workflow app declares no providers, so this is the
|
|
165
|
+
// only thing that can name the credential its steps read.
|
|
166
|
+
//
|
|
167
|
+
// ffmpeg is NOT here and cannot be: `requiredEnv` checks the agent's environment,
|
|
168
|
+
// and a binary on `PATH` is not an environment variable. The deployed guest always
|
|
169
|
+
// has one; under `aai dev` the failure names its own remedy.
|
|
170
|
+
requiredEnv: ["ASSEMBLYAI_API_KEY"],
|
|
171
|
+
});
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
// Copyright 2026 the AAI authors. MIT license.
|
|
2
|
+
/**
|
|
3
|
+
* The page: a form, a progress log, the audit, and a player.
|
|
4
|
+
*
|
|
5
|
+
* `link-digest` shows these primitives raw, `transcription-workflow` shows the form
|
|
6
|
+
* layer in full, and `spoken-summary` shows how a page plays a file the RUN
|
|
7
|
+
* produced. None of that is restated here — this page is deliberately the least
|
|
8
|
+
* novel file in the template, because the subject is the pipeline.
|
|
9
|
+
*
|
|
10
|
+
* Two things it does add, and both are about being honest about the pipeline:
|
|
11
|
+
*
|
|
12
|
+
* - **The PIPELINE panel.** A reader cannot tell from a transcript whether the desk
|
|
13
|
+
* cut it in the pauses or fell back to cutting by arithmetic, and that difference
|
|
14
|
+
* is exactly what explains a mangled word at a seam. So `blindCuts` is rendered
|
|
15
|
+
* rather than hidden, alongside what the recording measured before levelling.
|
|
16
|
+
* - **`useDownloadUrl`, not a URL.** The run's output carries an upload id. The
|
|
17
|
+
* obvious `<audio src={`/workflows/uploads/${id}`}>` is wrong in a way that only
|
|
18
|
+
* shows up after a deploy: the byte route takes the same `Authorization` header
|
|
19
|
+
* every other route does, and neither `<audio src>` nor `<a href>` can send one.
|
|
20
|
+
* So a page built on a URL works against `aai dev`, where there is no token, and
|
|
21
|
+
* 401s the moment the agent has one. The hook is `aai-ui`'s rather than four
|
|
22
|
+
* lines here, because the two lines that matter are the ones around them: the
|
|
23
|
+
* `URL.revokeObjectURL` on cleanup, and the guard that stops a slow first
|
|
24
|
+
* download landing under a second run's output.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
import "@alexkroman1/aai-ui/styles.css";
|
|
28
|
+
// ERASED at build time, so naming the agent's own type costs the browser bundle
|
|
29
|
+
// nothing — and it is what stops this file restating a shape `workflows/audit.ts`
|
|
30
|
+
// already declares.
|
|
31
|
+
import { formatBytes, formatDuration } from "@alexkroman1/aai/utils";
|
|
32
|
+
import type { WorkflowOutputOf } from "@alexkroman1/aai/workflow-api";
|
|
33
|
+
import {
|
|
34
|
+
createWorkflowApi,
|
|
35
|
+
Form,
|
|
36
|
+
page,
|
|
37
|
+
SubmitButton,
|
|
38
|
+
UploadProgressBar,
|
|
39
|
+
useDownloadUrl,
|
|
40
|
+
useWorkflowSubmit,
|
|
41
|
+
WorkflowFields,
|
|
42
|
+
WorkflowProgress,
|
|
43
|
+
} from "@alexkroman1/aai-ui";
|
|
44
|
+
import type { audit } from "./agent.ts";
|
|
45
|
+
|
|
46
|
+
/** What a completed run reports, derived from the workflow rather than restated. */
|
|
47
|
+
type Audit = WorkflowOutputOf<typeof audit>;
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* The workflow's name, as a page starts a run by one.
|
|
51
|
+
*
|
|
52
|
+
* A rename in `agent.ts` is a runtime 400 rather than a compile error, which is why
|
|
53
|
+
* `agent.test.ts` pins this string.
|
|
54
|
+
*/
|
|
55
|
+
const WORKFLOW = "audit";
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Hoisted out of the component deliberately.
|
|
59
|
+
*
|
|
60
|
+
* The hooks hold the client in a ref precisely so a fresh object per render cannot
|
|
61
|
+
* restart their watch, but building one in render is still a new `fetch` closure
|
|
62
|
+
* every time and reads as though it were free.
|
|
63
|
+
*/
|
|
64
|
+
const api = createWorkflowApi();
|
|
65
|
+
|
|
66
|
+
/** One labelled number in the pipeline panel. */
|
|
67
|
+
function Stat({ label, value }: { label: string; value: string }) {
|
|
68
|
+
return (
|
|
69
|
+
<div className="flex flex-col">
|
|
70
|
+
<dt className="text-xs uppercase tracking-wide opacity-60">{label}</dt>
|
|
71
|
+
<dd className="text-sm">{value}</dd>
|
|
72
|
+
</div>
|
|
73
|
+
);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** A list that renders nothing rather than an empty box — see `risks` in the schema. */
|
|
77
|
+
function Findings({ title, items }: { title: string; items: string[] }) {
|
|
78
|
+
if (items.length === 0) return null;
|
|
79
|
+
return (
|
|
80
|
+
<section className="flex flex-col gap-1">
|
|
81
|
+
<h3 className="text-sm font-medium opacity-70">{title}</h3>
|
|
82
|
+
<ul className="flex list-disc flex-col gap-1 pl-5">
|
|
83
|
+
{items.map((item) => (
|
|
84
|
+
<li key={item}>{item}</li>
|
|
85
|
+
))}
|
|
86
|
+
</ul>
|
|
87
|
+
</section>
|
|
88
|
+
);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export function App() {
|
|
92
|
+
// The generic is what makes `run.status === "completed"` narrow to a TYPED
|
|
93
|
+
// `run.output` instead of `unknown`.
|
|
94
|
+
const { submit, run, pending, upload, pauseUpload, resumeUpload, error } =
|
|
95
|
+
useWorkflowSubmit<Audit>(WORKFLOW, { api });
|
|
96
|
+
const output = run?.status === "completed" ? run.output : undefined;
|
|
97
|
+
// `useDownloadUrl` is the SDK's: the byte route takes the agent's bearer, so the
|
|
98
|
+
// bytes have to be FETCHED and handed to the element as an object URL — and the
|
|
99
|
+
// object URL has to be revoked, which is the half a page written by hand forgets.
|
|
100
|
+
const audio = useDownloadUrl(output?.audio, { api });
|
|
101
|
+
|
|
102
|
+
return (
|
|
103
|
+
<main className="mx-auto flex max-w-3xl flex-col gap-6 p-8">
|
|
104
|
+
<header className="flex flex-col gap-1">
|
|
105
|
+
<h1 className="text-2xl font-medium">Call Audit</h1>
|
|
106
|
+
<p className="text-sm opacity-70">
|
|
107
|
+
Upload a call recording — any format. It comes back levelled, transcribed at its pauses,
|
|
108
|
+
and audited for risks and actions.
|
|
109
|
+
</p>
|
|
110
|
+
</header>
|
|
111
|
+
|
|
112
|
+
<Form onSubmit={submit} error={error} className="flex flex-col gap-4">
|
|
113
|
+
{/* Every control, from the workflow's own input schema. See the module doc. */}
|
|
114
|
+
<WorkflowFields workflow={WORKFLOW} />
|
|
115
|
+
<SubmitButton pending={pending} pendingLabel="Auditing…">
|
|
116
|
+
Audit the call
|
|
117
|
+
</SubmitButton>
|
|
118
|
+
</Form>
|
|
119
|
+
|
|
120
|
+
{/* The upload is its own wait, and the one nothing else can describe: the run
|
|
121
|
+
does not EXIST until the bytes are in, so there is no run id and nothing
|
|
122
|
+
for `<WorkflowProgress>` to read. */}
|
|
123
|
+
<UploadProgressBar upload={upload} onPause={pauseUpload} onResume={resumeUpload} />
|
|
124
|
+
|
|
125
|
+
{/* What the run itself says, from `report()` in the workflow's steps — which
|
|
126
|
+
for this template is the ffmpeg narration: what the file was, what it
|
|
127
|
+
measured, how many pauses were found. */}
|
|
128
|
+
<WorkflowProgress runId={run?.runId} api={api} />
|
|
129
|
+
|
|
130
|
+
{run?.status === "failed" && <p className="text-red-600">That one failed: {run.error}</p>}
|
|
131
|
+
|
|
132
|
+
{output !== undefined && (
|
|
133
|
+
<article className="flex flex-col gap-6">
|
|
134
|
+
<div className="flex flex-col gap-1">
|
|
135
|
+
<h2 className="text-xl">{output.headline}</h2>
|
|
136
|
+
<p className="text-sm opacity-70">
|
|
137
|
+
{output.source} · {formatDuration(output.durationMs)} · {output.words} words
|
|
138
|
+
</p>
|
|
139
|
+
</div>
|
|
140
|
+
|
|
141
|
+
{/* What the pipeline did, which is this template's subject. Rendered rather
|
|
142
|
+
than logged because `blindCuts` is the one number that explains a bad
|
|
143
|
+
seam, and a reader has no other way to know. */}
|
|
144
|
+
<dl className="grid grid-cols-2 gap-3 rounded border border-current/10 p-4 sm:grid-cols-3">
|
|
145
|
+
<Stat label="Source codec" value={output.codec} />
|
|
146
|
+
<Stat label="Loudness in" value={`${output.loudnessBefore} LUFS`} />
|
|
147
|
+
<Stat label="Speech" value={`${output.speechPercent}%`} />
|
|
148
|
+
<Stat label="Segments" value={String(output.segments)} />
|
|
149
|
+
<Stat
|
|
150
|
+
label="Cut in speech"
|
|
151
|
+
value={output.blindCuts === 0 ? "none" : String(output.blindCuts)}
|
|
152
|
+
/>
|
|
153
|
+
<Stat label="Run time" value={formatDuration(output.elapsedMs)} />
|
|
154
|
+
</dl>
|
|
155
|
+
|
|
156
|
+
<Findings title="Risks" items={output.risks} />
|
|
157
|
+
<Findings title="Actions" items={output.actions} />
|
|
158
|
+
|
|
159
|
+
<section className="flex flex-col gap-2">
|
|
160
|
+
<h3 className="text-sm font-medium opacity-70">
|
|
161
|
+
Read aloud · {formatDuration(output.audioDurationMs)} ·{" "}
|
|
162
|
+
{formatBytes(output.audioBytes)}
|
|
163
|
+
</h3>
|
|
164
|
+
{audio.pending && <p className="text-sm opacity-70">Fetching the audio…</p>}
|
|
165
|
+
{audio.error !== undefined && (
|
|
166
|
+
<p className="text-red-600">Could not load the audio: {audio.error}</p>
|
|
167
|
+
)}
|
|
168
|
+
{audio.url !== undefined && (
|
|
169
|
+
<>
|
|
170
|
+
{/* No `<track>`, and that is a judgement rather than an
|
|
171
|
+
oversight: the spoken text is rendered in full immediately
|
|
172
|
+
below this player, which is the same information a caption
|
|
173
|
+
track would carry. `spoken-summary` serves a one-cue WebVTT
|
|
174
|
+
data URL instead — worth reading for how, if a real track is
|
|
175
|
+
what a page needs. */}
|
|
176
|
+
<audio controls src={audio.url} className="w-full" />
|
|
177
|
+
{/* `download` works on an object URL because the bytes are already in
|
|
178
|
+
the tab; it is the href that could not carry the agent's bearer,
|
|
179
|
+
not the attribute. */}
|
|
180
|
+
<a href={audio.url} download="audit.mp3" className="text-sm underline">
|
|
181
|
+
Download audit.mp3
|
|
182
|
+
</a>
|
|
183
|
+
</>
|
|
184
|
+
)}
|
|
185
|
+
<p className="text-sm opacity-70">{output.spoken}</p>
|
|
186
|
+
</section>
|
|
187
|
+
|
|
188
|
+
<details className="text-sm">
|
|
189
|
+
<summary className="cursor-pointer opacity-70">Transcript</summary>
|
|
190
|
+
<p className="mt-2 whitespace-pre-wrap">{output.transcript}</p>
|
|
191
|
+
</details>
|
|
192
|
+
</article>
|
|
193
|
+
)}
|
|
194
|
+
</main>
|
|
195
|
+
);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
page({ name: "Call Audit", component: App });
|
|
@@ -0,0 +1,298 @@
|
|
|
1
|
+
// Copyright 2026 the AAI authors. MIT license.
|
|
2
|
+
/**
|
|
3
|
+
* The workflow body, and the fan-out it plans.
|
|
4
|
+
*
|
|
5
|
+
* ```text
|
|
6
|
+
* now one step → when the run began
|
|
7
|
+
* ingestRecording one step → levelled PCM + every pause (ingest.ts)
|
|
8
|
+
* planSegments the BODY → where to cut (media.ts, pure)
|
|
9
|
+
* transcribeSegment N steps → one sync API request each, bounded
|
|
10
|
+
* summarize one step → headline, risks, actions (summarize.ts)
|
|
11
|
+
* narrate one step → an MP3 of the summary (summarize.ts)
|
|
12
|
+
* now one step → when it finished
|
|
13
|
+
* ```
|
|
14
|
+
*
|
|
15
|
+
* Read `transcription-workflow` first: it owns the fan-out — why the sync
|
|
16
|
+
* endpoint forces one, how `mapConcurrent` keeps a replay's call order stable,
|
|
17
|
+
* why a segment is addressed by byte range and never carried — and none of that
|
|
18
|
+
* is restated here. **What this template adds is what changes when a DECODER is
|
|
19
|
+
* in the pipeline**, and it is worth reading the two side by side, because the
|
|
20
|
+
* difference is subtraction:
|
|
21
|
+
*
|
|
22
|
+
* | | `transcription-workflow` | here |
|
|
23
|
+
* | --- | --- | --- |
|
|
24
|
+
* | accepts | any audio, converts to WAV | any audio, converts to raw PCM |
|
|
25
|
+
* | header | parsed (`parseWav`, ~180 lines) | **none — byte 0 is second 0** |
|
|
26
|
+
* | cut at | every 90s, wherever that lands | **the middle of a pause** |
|
|
27
|
+
* | overlap | 2s per segment, transcribed twice | **none** |
|
|
28
|
+
* | stitching | seam matching, drops repeated words | **ordered concatenation** |
|
|
29
|
+
* | caps to plan against | 120s AND 40 MB, whichever binds | **120s** |
|
|
30
|
+
* | levelling | none | `loudnorm`, two-pass |
|
|
31
|
+
*
|
|
32
|
+
* Every row on the right is a consequence of one decision: normalize FIRST, to a
|
|
33
|
+
* format this desk chose. `media.ts` carries the argument for each.
|
|
34
|
+
*
|
|
35
|
+
* ## The plan is made in the BODY, and that is legal
|
|
36
|
+
*
|
|
37
|
+
* `planSegments` runs in the directive body rather than in a step, which looks
|
|
38
|
+
* like a rule violation and is not: it is a pure function of `ingested.silences`
|
|
39
|
+
* and `ingested.durationMs`, both of which came out of a journaled step result.
|
|
40
|
+
* So a replay re-derives the identical list in the identical order, which is
|
|
41
|
+
* exactly what `mapConcurrent` needs — the DevKit correlates a journal entry to a
|
|
42
|
+
* step call by the ORDER the call was issued in.
|
|
43
|
+
*
|
|
44
|
+
* Putting it in a step would journal the same list twice (once as part of the
|
|
45
|
+
* ingest result, once as the plan) and buy nothing.
|
|
46
|
+
*/
|
|
47
|
+
|
|
48
|
+
import { encodeWav, mapConcurrent, readUpload, report } from "@alexkroman1/aai/step";
|
|
49
|
+
import { countWords, formatDuration } from "@alexkroman1/aai/utils";
|
|
50
|
+
// ERASED at build time, so the body can name the schema's own output type without
|
|
51
|
+
// a runtime cycle back through `agent.ts` — the same mechanism `client.tsx` uses
|
|
52
|
+
// for `WorkflowOutputOf`.
|
|
53
|
+
import type { WorkflowInputOf } from "@alexkroman1/aai/workflow-api";
|
|
54
|
+
import type { audit } from "../agent.ts";
|
|
55
|
+
import { ingestRecording } from "./ingest.ts";
|
|
56
|
+
import {
|
|
57
|
+
ANALYSIS_FORMAT,
|
|
58
|
+
durationSeconds,
|
|
59
|
+
planSegments,
|
|
60
|
+
type Segment,
|
|
61
|
+
speechFraction,
|
|
62
|
+
} from "./media.ts";
|
|
63
|
+
import { narrate, summarize } from "./summarize.ts";
|
|
64
|
+
import { transcribeSpan } from "./sync-api.ts";
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* How many segments to keep in flight.
|
|
68
|
+
*
|
|
69
|
+
* A CONSTANT here, where `transcription-workflow` derives one per recording — and
|
|
70
|
+
* the difference is the payoff of normalizing. That template cuts whatever format
|
|
71
|
+
* it was handed, so the byte cost of a segment is a property of the file: the same
|
|
72
|
+
* 32 segments are 94 MB of 16 kHz mono or 1.28 GB of a format at the endpoint's
|
|
73
|
+
* ceiling, and only one of those is safe to have in flight. It has to divide a
|
|
74
|
+
* measured byte budget to find a width.
|
|
75
|
+
*
|
|
76
|
+
* Here the format is {@link ANALYSIS_FORMAT} for every recording, so a segment is
|
|
77
|
+
* at most 3.5 MB and 32 of them are 113 MB — comfortably inside the ~640 MB that
|
|
78
|
+
* template measured as the point where the endpoint starts returning `503`s. So
|
|
79
|
+
* the byte bound never binds and what is left is the endpoint's own knee, which it
|
|
80
|
+
* measured at 32. Its `BYTES_IN_FLIGHT` and `MAX_SEGMENT_CONCURRENCY` docs carry
|
|
81
|
+
* both measurements; this is the one number that survives them.
|
|
82
|
+
*/
|
|
83
|
+
export const SEGMENT_CONCURRENCY = 32;
|
|
84
|
+
|
|
85
|
+
/** What one segment's request came back with — the STEP's result, journaled. */
|
|
86
|
+
export type SegmentText = {
|
|
87
|
+
index: number;
|
|
88
|
+
text: string;
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
/** What a finished run reports. Small and JSON-shaped, like every step result. */
|
|
92
|
+
export type CallAudit = {
|
|
93
|
+
/** The uploaded file's own name. */
|
|
94
|
+
source: string;
|
|
95
|
+
/** What ffprobe made of it before the conversion — `aac`, `mp3`, `pcm_s16le`. */
|
|
96
|
+
codec: string;
|
|
97
|
+
/** Length of the audio. */
|
|
98
|
+
durationMs: number;
|
|
99
|
+
/** How long the RUN took, wall clock. */
|
|
100
|
+
elapsedMs: number;
|
|
101
|
+
/** How many requests the transcript was assembled from. */
|
|
102
|
+
segments: number;
|
|
103
|
+
/**
|
|
104
|
+
* Segments whose end landed in speech because no pause was in range.
|
|
105
|
+
*
|
|
106
|
+
* `0` on an ordinary recording. Surfaced because it is the one thing that can
|
|
107
|
+
* make this desk's transcript as seam-damaged as a blind cut's, and a reader
|
|
108
|
+
* looking at a mangled word deserves to know which case they are in.
|
|
109
|
+
*/
|
|
110
|
+
blindCuts: number;
|
|
111
|
+
/** Share of the recording that is speech rather than pause, 0-100. */
|
|
112
|
+
speechPercent: number;
|
|
113
|
+
/** Integrated loudness BEFORE levelling, LUFS — what the recording arrived at. */
|
|
114
|
+
loudnessBefore: number;
|
|
115
|
+
words: number;
|
|
116
|
+
transcript: string;
|
|
117
|
+
/** One line naming what the call was about. */
|
|
118
|
+
headline: string;
|
|
119
|
+
/** What a reader should worry about. */
|
|
120
|
+
risks: string[];
|
|
121
|
+
/** What somebody has to do next. */
|
|
122
|
+
actions: string[];
|
|
123
|
+
/** The summary, written to be heard. */
|
|
124
|
+
spoken: string;
|
|
125
|
+
/**
|
|
126
|
+
* Upload id of the spoken summary — an MP3, in this app's own store.
|
|
127
|
+
*
|
|
128
|
+
* An ID rather than the bytes, and that is the rule rather than a preference: a
|
|
129
|
+
* run's output is read back as JSON. `api.download(id)` is the browser half.
|
|
130
|
+
*/
|
|
131
|
+
audio: string;
|
|
132
|
+
/** How long the spoken summary lasts. */
|
|
133
|
+
audioDurationMs: number;
|
|
134
|
+
/** Size of the MP3, which is the number that makes the mastering pass worth it. */
|
|
135
|
+
audioBytes: number;
|
|
136
|
+
};
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Audit a call recording: level it, transcribe it, summarize it, read it back.
|
|
140
|
+
*
|
|
141
|
+
* The input is what `POST /workflows/runs` carries — see `agent.ts` for the schema
|
|
142
|
+
* it is validated against before a run exists.
|
|
143
|
+
*/
|
|
144
|
+
export async function auditFlow(input: WorkflowInputOf<typeof audit>): Promise<CallAudit> {
|
|
145
|
+
"use workflow";
|
|
146
|
+
|
|
147
|
+
// Both at once: neither needs the other, and issued together they are one round
|
|
148
|
+
// trip instead of two before any audio moves. The ORDER is still a pure function
|
|
149
|
+
// of this expression — the two calls go out synchronously, left to right — which
|
|
150
|
+
// is what a replay reproduces.
|
|
151
|
+
const [startedAt, ingested] = await Promise.all([now(), ingestRecording(input.recording)]);
|
|
152
|
+
|
|
153
|
+
// Pure, in the body, from journaled values. See the module doc. Planned against
|
|
154
|
+
// the stored BYTE COUNT rather than the reported duration — `durationSeconds`
|
|
155
|
+
// carries why those are not interchangeable.
|
|
156
|
+
const segments = planSegments(ingested.silences, ingested.bytes);
|
|
157
|
+
|
|
158
|
+
// One step per segment, bounded, in an order a replay reproduces exactly. A
|
|
159
|
+
// failed segment fails the RUN deliberately: every sibling that finished is
|
|
160
|
+
// already journaled, so a resume replays those for free and re-issues only what
|
|
161
|
+
// is missing — where catching here to salvage a partial transcript would return
|
|
162
|
+
// a recording with a silent hole in it and report success.
|
|
163
|
+
const parts = await mapConcurrent(segments, SEGMENT_CONCURRENCY, (segment) =>
|
|
164
|
+
transcribeSegment(ingested.audio, segment),
|
|
165
|
+
);
|
|
166
|
+
|
|
167
|
+
const transcript = joinSegments(segments, parts);
|
|
168
|
+
const summary = await summarize(transcript, ingested.source, ingested.durationMs);
|
|
169
|
+
const spoken = await narrate(summary.spoken, input.voice);
|
|
170
|
+
const finishedAt = await now();
|
|
171
|
+
|
|
172
|
+
// Whatever this returns is what a caller reads as `output` on a completed run —
|
|
173
|
+
// so it is what the page renders, typed through `WorkflowOutputOf`. Assembled in
|
|
174
|
+
// the body rather than in a step because every field is already journaled: a
|
|
175
|
+
// step here would re-record values it was handed.
|
|
176
|
+
return {
|
|
177
|
+
source: ingested.source,
|
|
178
|
+
codec: ingested.codec,
|
|
179
|
+
durationMs: ingested.durationMs,
|
|
180
|
+
elapsedMs: finishedAt - startedAt,
|
|
181
|
+
segments: segments.length,
|
|
182
|
+
blindCuts: segments.filter((segment) => segment.cutInSpeech).length,
|
|
183
|
+
speechPercent: Math.round(
|
|
184
|
+
speechFraction(ingested.silences, durationSeconds(ingested.bytes)) * 100,
|
|
185
|
+
),
|
|
186
|
+
loudnessBefore: ingested.loudness.inputLufs,
|
|
187
|
+
words: countWords(transcript),
|
|
188
|
+
transcript,
|
|
189
|
+
headline: summary.headline,
|
|
190
|
+
risks: summary.risks,
|
|
191
|
+
actions: summary.actions,
|
|
192
|
+
spoken: summary.spoken,
|
|
193
|
+
audio: spoken.audio,
|
|
194
|
+
audioDurationMs: spoken.durationMs,
|
|
195
|
+
audioBytes: spoken.bytes,
|
|
196
|
+
};
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* Transcribe one segment through the sync API.
|
|
201
|
+
*
|
|
202
|
+
* One step each, so a run that dies part-way resumes having replayed the finished
|
|
203
|
+
* ones from the journal — no re-reading, no re-billing — and issues exactly the
|
|
204
|
+
* calls that are missing.
|
|
205
|
+
*
|
|
206
|
+
* **`encodeWav` is what makes a byte range decodable.** The stored audio is
|
|
207
|
+
* headerless PCM, and the endpoint decodes each request independently, so a slice
|
|
208
|
+
* of it is meaningless bytes until a header says what they are. That header is the
|
|
209
|
+
* SDK's (`@alexkroman1/aai/step`) rather than this template's: the equivalent
|
|
210
|
+
* function in `transcription-workflow` is 25 lines of `DataView` writes with a
|
|
211
|
+
* comment about which of the two declared lengths a decoder trusts, and there is
|
|
212
|
+
* no reason for a second copy of it to exist.
|
|
213
|
+
*/
|
|
214
|
+
export async function transcribeSegment(audioId: string, segment: Segment): Promise<SegmentText> {
|
|
215
|
+
"use step";
|
|
216
|
+
|
|
217
|
+
// One line per segment, which is what makes the fan-out legible to a page: the
|
|
218
|
+
// status is `running` for the whole thing, so without this a sixty-segment
|
|
219
|
+
// recording and a one-segment recording look identical while they run.
|
|
220
|
+
//
|
|
221
|
+
// ORDER is not guaranteed here and does not need to be — the calls go out
|
|
222
|
+
// together, so their lines interleave by completion, and `segment.index` is what
|
|
223
|
+
// puts the TRANSCRIPT back in order.
|
|
224
|
+
await report(`Transcribing ${formatDuration(segment.startMs)}–${formatDuration(segment.endMs)}.`);
|
|
225
|
+
|
|
226
|
+
// `[start, end)`, the same half-open pair `planSegments` produced — the store
|
|
227
|
+
// owns the conversion to HTTP's inclusive range, so there is no `- 1` here to get
|
|
228
|
+
// wrong.
|
|
229
|
+
const audio = await readUpload(audioId, { start: segment.startByte, end: segment.endByte });
|
|
230
|
+
const text = await transcribeSpan(
|
|
231
|
+
encodeWav(audio.bytes, ANALYSIS_FORMAT),
|
|
232
|
+
`segment-${segment.index}.wav`,
|
|
233
|
+
`Segment ${segment.index} (${formatDuration(segment.startMs)})`,
|
|
234
|
+
);
|
|
235
|
+
|
|
236
|
+
return { index: segment.index, text };
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* Retries beyond the default 3, because a rate limit is the expected failure and a
|
|
241
|
+
* segment that 429s is not a segment that is wrong.
|
|
242
|
+
*/
|
|
243
|
+
transcribeSegment.maxRetries = 5;
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* When it is now, as epoch ms.
|
|
247
|
+
*
|
|
248
|
+
* A STEP, and that is the whole reason it exists rather than a `Date.now()` in the
|
|
249
|
+
* body: a body replays from the top on every resume, so a clock read there returns
|
|
250
|
+
* a different value each time and every duration derived from it would be a
|
|
251
|
+
* different duration. A step's result is journaled, so this is the moment the run
|
|
252
|
+
* really reached this line however many times it is replayed.
|
|
253
|
+
*
|
|
254
|
+
* Called twice — once at each end — rather than a `startClock`/`elapsed` pair,
|
|
255
|
+
* because the alternative is a step taking every field of the output so it can
|
|
256
|
+
* subtract inside itself. Two journal entries and a subtraction in the body is the
|
|
257
|
+
* smaller thing.
|
|
258
|
+
*/
|
|
259
|
+
export async function now(): Promise<number> {
|
|
260
|
+
"use step";
|
|
261
|
+
|
|
262
|
+
return Date.now();
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/**
|
|
266
|
+
* Join the segment transcripts into one.
|
|
267
|
+
*
|
|
268
|
+
* Ordered concatenation, and the absence of anything cleverer is the point:
|
|
269
|
+
* segments do not overlap, so there is nothing to de-duplicate. The equivalent in
|
|
270
|
+
* `transcription-workflow` is a seam matcher that looks back up to 40 words for a
|
|
271
|
+
* repeated run and drops it — necessary there, because a blind cut forces a
|
|
272
|
+
* two-second overlap to avoid splitting a word, and heuristic by nature.
|
|
273
|
+
*
|
|
274
|
+
* The one judgement left is the SEPARATOR, and the plan already knows the answer.
|
|
275
|
+
* A cut placed in a pause is a turn or sentence boundary, so a paragraph break
|
|
276
|
+
* reads correctly; a blind cut lands mid-sentence, so it gets a space. That is the
|
|
277
|
+
* one place `cutInSpeech` changes an output rather than a report.
|
|
278
|
+
*/
|
|
279
|
+
export function joinSegments(segments: readonly Segment[], parts: readonly SegmentText[]): string {
|
|
280
|
+
// `mapConcurrent` resolves in ITEM order however the calls settled, so this is
|
|
281
|
+
// already ordered — sorted anyway, because a merge is where an ordering mistake
|
|
282
|
+
// would be invisible rather than loud.
|
|
283
|
+
const byIndex = new Map(parts.map((part) => [part.index, part.text]));
|
|
284
|
+
let joined = "";
|
|
285
|
+
for (const segment of segments) {
|
|
286
|
+
const text = (byIndex.get(segment.index) ?? "").trim();
|
|
287
|
+
if (text === "") continue;
|
|
288
|
+
if (joined === "") {
|
|
289
|
+
joined = text;
|
|
290
|
+
continue;
|
|
291
|
+
}
|
|
292
|
+
// The separator belongs to the boundary BEFORE this segment, which is the
|
|
293
|
+
// previous segment's end — so the flag read here is the earlier one's.
|
|
294
|
+
const previous = segments[segment.index - 1];
|
|
295
|
+
joined += previous?.cutInSpeech === true ? ` ${text}` : `\n\n${text}`;
|
|
296
|
+
}
|
|
297
|
+
return joined;
|
|
298
|
+
}
|