@alexkroman1/aai-cli 5.14.0 → 6.1.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-C8Jk-ZcP.mjs → _agent-vGW5SBnX.mjs} +23 -5
- package/dist/_agent.d.ts +18 -0
- package/dist/{_api-client-BX8Qv51n.mjs → _api-client-LgLksMhN.mjs} +45 -8
- package/dist/_api-client.d.ts +39 -0
- package/dist/{_bundler-BuIyDdvs.mjs → _bundler-BapCUUJN.mjs} +42 -14
- package/dist/_bundler.d.ts +43 -5
- package/dist/_cli-common.d.ts +67 -3
- package/dist/{_config-CzwAWOWr.mjs → _config-D_s09e7g.mjs} +36 -18
- package/dist/_deploy.d.ts +2 -5
- package/dist/_dev-restart.d.ts +6 -1
- package/dist/{_dev-server-Ch7K8e8i.mjs → _dev-server-DQ1t9sbw.mjs} +94 -38
- package/dist/_dev-server.d.ts +30 -2
- package/dist/_e2e-test-utils.d.ts +80 -0
- package/dist/_fault-mode.d.ts +175 -0
- package/dist/{_init-CbkrE2wq.mjs → _init-CpISqoR8.mjs} +3 -3
- package/dist/{_output-CC300DzW.mjs → _output-CKkmWs7i.mjs} +8 -1
- package/dist/_output.d.ts +9 -2
- package/dist/{_server-common-61fL__39.mjs → _server-common-6e0QI4mq.mjs} +1 -1
- package/dist/{_slug-api-D30zPmsn.mjs → _slug-api-D2heILuC.mjs} +2 -2
- package/dist/{_studio-D2ciIhbi.mjs → _studio-zAJycZ8j.mjs} +3 -3
- package/dist/{_templates-B1kt3BGt.mjs → _templates-iDGjGiPf.mjs} +4 -6
- package/dist/{_typecheck-gate-4v9UBZHh.mjs → _typecheck-gate-BXvUNnfs.mjs} +1 -1
- package/dist/{_utils-0DHbcqUb.mjs → _utils-B8QmtFhK.mjs} +21 -7
- package/dist/_utils.d.ts +18 -2
- package/dist/{_vite-env-Dg_QlVv0.mjs → _vite-env-BNveawd1.mjs} +31 -1
- package/dist/_vite-env.d.ts +30 -0
- package/dist/build-BO1Ni6oJ.mjs +62 -0
- package/dist/build.d.ts +19 -0
- package/dist/cli-workflow.d.ts +10 -0
- package/dist/cli.d.ts +1 -2
- package/dist/cli.mjs +406 -224
- package/dist/{client-bundler-C9qXL8A2.mjs → client-bundler-B8Q9F8gJ.mjs} +3 -22
- package/dist/client-bundler.mjs +1 -1
- package/dist/{delete-DCRX3d96.mjs → delete-4I0uNR4a.mjs} +6 -7
- package/dist/delete.d.ts +2 -8
- package/dist/{deploy-DjsEV-40.mjs → deploy-DJ3HRcia.mjs} +13 -14
- package/dist/{dev-CvPH5dbF.mjs → dev-C6SWaAZP.mjs} +3 -3
- package/dist/{eject-LgBRfnbH.mjs → eject-Zrzk5KV3.mjs} +41 -15
- package/dist/eject.d.ts +30 -3
- package/dist/{init-BppxMR8r.mjs → init-BTRGiPQr.mjs} +61 -26
- package/dist/init.d.ts +11 -0
- package/dist/{login-BQQaxJhL.mjs → login-k0Z3VQu9.mjs} +5 -5
- package/dist/project-config.mjs +1 -1
- package/dist/scaffold/CLAUDE.md +498 -91
- package/dist/scaffold/package.json +11 -8
- package/dist/scaffold/pnpm-workspace.yaml +8 -0
- package/dist/scaffold/server.mjs +53 -57
- package/dist/{secret-Ds9dvqAj.mjs → secret-DzSQx1Ds.mjs} +6 -3
- package/dist/{storage-DbrQSHFk.mjs → storage-C-Do0mIA.mjs} +18 -6
- package/dist/{studio-GSFd4s5O.mjs → studio-BGVmCA2G.mjs} +10 -10
- package/dist/templates/code-interpreter/agent.ts +0 -2
- package/dist/templates/dispatch-center/agent.test.ts +17 -19
- package/dist/templates/dispatch-center/agent.ts +2 -29
- package/dist/templates/dispatch-center/client.tsx +33 -16
- package/dist/templates/dispatch-center/shared.ts +53 -19
- package/dist/templates/dispatch-center/tools/incident_add_note.ts +12 -14
- package/dist/templates/dispatch-center/tools/incident_create.ts +41 -44
- package/dist/templates/dispatch-center/tools/incident_escalate.ts +59 -61
- package/dist/templates/dispatch-center/tools/incident_get.ts +3 -4
- package/dist/templates/dispatch-center/tools/incident_triage.ts +43 -45
- package/dist/templates/dispatch-center/tools/incident_update_status.ts +39 -41
- package/dist/templates/dispatch-center/tools/ops_dashboard.ts +2 -5
- package/dist/templates/dispatch-center/tools/ops_protocols.ts +1 -1
- package/dist/templates/dispatch-center/tools/ops_run_scenario.ts +20 -23
- package/dist/templates/dispatch-center/tools/resources_dispatch.ts +52 -54
- package/dist/templates/dispatch-center/tools/resources_get_available.ts +2 -4
- package/dist/templates/dispatch-center/tools/resources_update_status.ts +39 -42
- package/dist/templates/embedded-assets/agent.test.ts +83 -0
- package/dist/templates/embedded-assets/agent.ts +1 -48
- package/dist/templates/embedded-assets/shared.ts +17 -0
- package/dist/templates/embedded-assets/system-prompt.md +13 -0
- package/dist/templates/embedded-assets/tools/list_topics.ts +9 -0
- package/dist/templates/embedded-assets/tools/search_knowledge.ts +26 -0
- package/dist/templates/health-assistant/agent.test.ts +169 -0
- package/dist/templates/health-assistant/agent.ts +1 -166
- package/dist/templates/health-assistant/fda.ts +100 -0
- package/dist/templates/health-assistant/tools/check_drug_interaction.ts +59 -0
- package/dist/templates/health-assistant/tools/medication_lookup.ts +31 -0
- package/dist/templates/infocom-adventure/agent.test.ts +205 -0
- package/dist/templates/infocom-adventure/agent.ts +3 -106
- package/dist/templates/infocom-adventure/client.tsx +28 -12
- package/dist/templates/infocom-adventure/shared.ts +28 -11
- package/dist/templates/infocom-adventure/tools/game_state_drop.ts +13 -0
- package/dist/templates/infocom-adventure/tools/game_state_flag.ts +13 -0
- package/dist/templates/infocom-adventure/tools/game_state_get.ts +16 -0
- package/dist/templates/infocom-adventure/tools/game_state_history.ts +14 -0
- package/dist/templates/infocom-adventure/tools/game_state_move.ts +14 -0
- package/dist/templates/infocom-adventure/tools/game_state_restart.ts +13 -0
- package/dist/templates/infocom-adventure/tools/game_state_score.ts +13 -0
- package/dist/templates/infocom-adventure/tools/game_state_take.ts +13 -0
- package/dist/templates/link-digest/agent.test.ts +223 -0
- package/dist/templates/link-digest/agent.ts +79 -0
- package/dist/templates/link-digest/client.tsx +146 -0
- package/dist/templates/link-digest/workflows/digest.ts +248 -0
- package/dist/templates/math-buddy/agent.ts +0 -2
- package/dist/templates/night-owl/agent.test.ts +80 -0
- package/dist/templates/night-owl/agent.ts +1 -68
- package/dist/templates/night-owl/system-prompt.md +10 -0
- package/dist/templates/night-owl/tools/recommend.ts +64 -0
- package/dist/templates/personal-finance/agent.ts +0 -2
- package/dist/templates/pizza-ordering/agent.test.ts +52 -47
- package/dist/templates/pizza-ordering/agent.ts +9 -158
- package/dist/templates/pizza-ordering/shared.ts +35 -12
- package/dist/templates/pizza-ordering/tools/add_pizza.ts +31 -0
- package/dist/templates/pizza-ordering/tools/place_order.ts +22 -0
- package/dist/templates/pizza-ordering/tools/remove_pizza.ts +22 -0
- package/dist/templates/pizza-ordering/tools/set_customer_name.ts +13 -0
- package/dist/templates/pizza-ordering/tools/update_pizza.ts +28 -0
- package/dist/templates/pizza-ordering/tools/view_order.ts +21 -0
- package/dist/templates/plan-and-execute/agent.test.ts +411 -0
- package/dist/templates/plan-and-execute/agent.ts +23 -0
- package/dist/templates/plan-and-execute/client.tsx +115 -0
- package/dist/templates/plan-and-execute/graph.ts +178 -0
- package/dist/templates/plan-and-execute/prompts.ts +110 -0
- package/dist/templates/plan-and-execute/shared.ts +161 -0
- package/dist/templates/plan-and-execute/system-prompt.md +18 -0
- package/dist/templates/plan-and-execute/tools/plan_status.ts +20 -0
- package/dist/templates/plan-and-execute/tools/revise_plan.ts +63 -0
- package/dist/templates/plan-and-execute/tools/start_plan.ts +51 -0
- package/dist/templates/plan-and-execute/tools/work_next_step.ts +101 -0
- package/dist/templates/recap-workflow/agent.test.ts +762 -0
- package/dist/templates/recap-workflow/agent.ts +95 -0
- package/dist/templates/recap-workflow/shared.ts +43 -0
- package/dist/templates/recap-workflow/tools/cancel_recap.ts +23 -0
- package/dist/templates/recap-workflow/tools/keep_transcript.ts +31 -0
- package/dist/templates/recap-workflow/tools/recap_progress.ts +27 -0
- package/dist/templates/recap-workflow/tools/recap_status.ts +46 -0
- package/dist/templates/recap-workflow/tools/request_recap.ts +43 -0
- package/dist/templates/recap-workflow/workflows/recap.ts +544 -0
- package/dist/templates/recap-workflow/workflows/tokens.ts +26 -0
- package/dist/templates/redline/agent.test.ts +222 -0
- package/dist/templates/redline/agent.ts +89 -0
- package/dist/templates/redline/client.tsx +184 -0
- package/dist/templates/redline/workflows/prompts.ts +68 -0
- package/dist/templates/redline/workflows/redline.ts +258 -0
- package/dist/templates/research-workflow/agent.test.ts +525 -0
- package/dist/templates/research-workflow/agent.ts +94 -0
- package/dist/templates/research-workflow/shared.ts +35 -0
- package/dist/templates/research-workflow/tools/file_it_now.ts +22 -0
- package/dist/templates/research-workflow/tools/request_research.ts +33 -0
- package/dist/templates/research-workflow/tools/research_progress.ts +26 -0
- package/dist/templates/research-workflow/tools/research_status.ts +40 -0
- package/dist/templates/research-workflow/workflows/prompts.ts +143 -0
- package/dist/templates/research-workflow/workflows/research.ts +558 -0
- package/dist/templates/retail/agent.test.ts +236 -172
- package/dist/templates/retail/agent.ts +5 -42
- package/dist/templates/retail/client.tsx +33 -11
- package/dist/templates/retail/registry.test.ts +31 -16
- package/dist/templates/retail/resolve.ts +28 -64
- package/dist/templates/retail/shared.test.ts +4 -3
- package/dist/templates/retail/shared.ts +24 -7
- package/dist/templates/retail/store.test.ts +12 -10
- package/dist/templates/retail/store.ts +69 -30
- package/dist/templates/retail/tools/cancel_pending_order.ts +4 -9
- package/dist/templates/retail/tools/exchange_delivered_order_items.ts +22 -11
- package/dist/templates/retail/tools/find_user_id_by_email.ts +5 -9
- package/dist/templates/retail/tools/find_user_id_by_name_zip.ts +5 -7
- package/dist/templates/retail/tools/get_item_details.ts +5 -7
- package/dist/templates/retail/tools/get_order_details.ts +5 -7
- package/dist/templates/retail/tools/get_product_details.ts +4 -7
- package/dist/templates/retail/tools/get_user_details.ts +4 -7
- package/dist/templates/retail/tools/list_all_product_types.ts +5 -7
- package/dist/templates/retail/tools/modify_pending_order_address.ts +4 -9
- package/dist/templates/retail/tools/modify_pending_order_items.ts +4 -16
- package/dist/templates/retail/tools/modify_pending_order_payment.ts +3 -9
- package/dist/templates/retail/tools/modify_user_address.ts +4 -9
- package/dist/templates/retail/tools/return_delivered_order_items.ts +3 -9
- package/dist/templates/retail/tools/transfer_to_human_agents.ts +1 -1
- package/dist/templates/solo-rpg/agent.test.ts +152 -29
- package/dist/templates/solo-rpg/agent.ts +3 -21
- package/dist/templates/solo-rpg/client.tsx +19 -6
- package/dist/templates/solo-rpg/shared.ts +19 -11
- package/dist/templates/solo-rpg/tools/action_roll.ts +2 -4
- package/dist/templates/solo-rpg/tools/burn_momentum.ts +2 -4
- package/dist/templates/solo-rpg/tools/check_state.ts +6 -6
- package/dist/templates/solo-rpg/tools/load_game.ts +1 -1
- package/dist/templates/solo-rpg/tools/oracle.ts +18 -11
- package/dist/templates/solo-rpg/tools/save_game.ts +10 -7
- package/dist/templates/solo-rpg/tools/setup_character.ts +1 -1
- package/dist/templates/solo-rpg/tools/update_state.ts +3 -4
- package/dist/templates/support-line/agent.test.ts +343 -0
- package/dist/templates/support-line/agent.ts +27 -0
- package/dist/templates/support-line/client.tsx +122 -0
- package/dist/templates/support-line/graph.ts +224 -0
- package/dist/templates/support-line/knowledge.json +65 -0
- package/dist/templates/support-line/prompts.ts +95 -0
- package/dist/templates/support-line/shared.ts +240 -0
- package/dist/templates/support-line/system-prompt.md +17 -0
- package/dist/templates/support-line/tools/answer_question.ts +72 -0
- package/dist/templates/support-line/tools/list_topics.ts +22 -0
- package/dist/templates/support-line/tools/log_ticket.ts +30 -0
- package/dist/templates/transcription-workflow/agent.test.ts +563 -0
- package/dist/templates/transcription-workflow/agent.ts +92 -0
- package/dist/templates/transcription-workflow/client.tsx +234 -0
- package/dist/templates/transcription-workflow/workflows/transcribe.ts +416 -0
- package/dist/templates/transcription-workflow/workflows/wav.ts +314 -0
- package/dist/templates/travel-concierge/agent.test.ts +338 -0
- package/dist/templates/travel-concierge/agent.ts +29 -0
- package/dist/templates/travel-concierge/client.tsx +161 -0
- package/dist/templates/travel-concierge/routing.ts +138 -0
- package/dist/templates/travel-concierge/shared.ts +547 -0
- package/dist/templates/travel-concierge/system-prompt.md +24 -0
- package/dist/templates/travel-concierge/tools/book_car_rental.ts +20 -0
- package/dist/templates/travel-concierge/tools/book_excursion.ts +18 -0
- package/dist/templates/travel-concierge/tools/book_hotel.ts +20 -0
- package/dist/templates/travel-concierge/tools/cancel_action.ts +7 -0
- package/dist/templates/travel-concierge/tools/cancel_ticket.ts +16 -0
- package/dist/templates/travel-concierge/tools/complete_or_escalate.ts +7 -0
- package/dist/templates/travel-concierge/tools/confirm_action.ts +7 -0
- package/dist/templates/travel-concierge/tools/lookup_booking.ts +35 -0
- package/dist/templates/travel-concierge/tools/search_car_rentals.ts +33 -0
- package/dist/templates/travel-concierge/tools/search_excursions.ts +37 -0
- package/dist/templates/travel-concierge/tools/search_flights.ts +43 -0
- package/dist/templates/travel-concierge/tools/search_hotels.ts +36 -0
- package/dist/templates/travel-concierge/tools/to_car_rental_assistant.ts +11 -0
- package/dist/templates/travel-concierge/tools/to_excursion_assistant.ts +11 -0
- package/dist/templates/travel-concierge/tools/to_flight_assistant.ts +11 -0
- package/dist/templates/travel-concierge/tools/to_hotel_assistant.ts +11 -0
- package/dist/templates/travel-concierge/tools/update_ticket.ts +22 -0
- package/dist/templates/web-researcher/agent.ts +0 -2
- package/dist/templates/web-researcher/system-prompt.md +10 -0
- package/dist/{test-2LWBcXvY.mjs → test-B6ZBxSpk.mjs} +2 -2
- package/dist/typecheck.mjs +17 -15
- package/dist/worker-bundler-CIdDhZ0c.mjs +398 -0
- package/dist/worker-bundler.d.ts +14 -0
- package/dist/worker-bundler.mjs +1 -108
- package/dist/workflow-B-O97AqI.mjs +125 -0
- package/dist/workflow-bundler.d.ts +133 -0
- package/dist/workflow.d.ts +67 -0
- package/package.json +13 -9
- package/dist/build-DQJ1ZSm9.mjs +0 -39
|
@@ -0,0 +1,416 @@
|
|
|
1
|
+
// Copyright 2026 the AAI authors. MIT license.
|
|
2
|
+
/**
|
|
3
|
+
* The durable half of the transcription desk: split a recording, transcribe
|
|
4
|
+
* every piece, stitch the pieces back together.
|
|
5
|
+
*
|
|
6
|
+
* Read `research-workflow/workflows/research.ts` first. It states the two rules
|
|
7
|
+
* every directive body obeys — replayed from the top, so no live handles and no
|
|
8
|
+
* undurable decisions; step arguments and return values are serialized, so pass
|
|
9
|
+
* an id and not a payload — and both hold here unchanged. What this template
|
|
10
|
+
* adds is the shape a real provider limit forces on a workflow, and it is three
|
|
11
|
+
* steps in a straight line:
|
|
12
|
+
*
|
|
13
|
+
* ```text
|
|
14
|
+
* splitRecording one step → the format + a byte range per segment
|
|
15
|
+
* transcribeSegment N steps → one sync API request each, bounded
|
|
16
|
+
* mergeTranscript one step → the stitched transcript
|
|
17
|
+
* ```
|
|
18
|
+
*
|
|
19
|
+
* ## Why the SYNC endpoint, and why that forces a fan-out
|
|
20
|
+
*
|
|
21
|
+
* AssemblyAI has two pre-recorded APIs. The BATCH one takes a job and a webhook
|
|
22
|
+
* and calls back minutes later, which is the classic durable-workflow shape and
|
|
23
|
+
* is what this template used to demonstrate against a stub. The SYNC one
|
|
24
|
+
* (`https://sync.assemblyai.com/transcribe`) answers in the request — and pays
|
|
25
|
+
* for it with a hard 120-second, 40 MB cap. So a real recording is not one call,
|
|
26
|
+
* it is N; the desk owns the splitting, the retrying and the reassembly that the
|
|
27
|
+
* batch API would have owned for it.
|
|
28
|
+
*
|
|
29
|
+
* That is the more interesting workflow, not the lesser one. A fan-out of N
|
|
30
|
+
* network calls is exactly the work a journal earns its keep on: a run that dies
|
|
31
|
+
* on segment 27 of 60 resumes having replayed 1-26 from the journal — not
|
|
32
|
+
* re-downloaded, not re-transcribed, not re-billed — and issues only what is
|
|
33
|
+
* missing. Nothing about `Promise.all` in a tool body survives the same crash.
|
|
34
|
+
*
|
|
35
|
+
* ## Three properties this leans on
|
|
36
|
+
*
|
|
37
|
+
* - **A step can read the agent's env now.** `stepEnv`/`requireStepEnv`
|
|
38
|
+
* (`@alexkroman1/aai/utils`) is what makes any of this real: a step is
|
|
39
|
+
* dispatched separately from the agent bundle and is handed no `ToolContext`,
|
|
40
|
+
* so before that seam existed no step anywhere could authenticate an outbound
|
|
41
|
+
* call, and every workflow template's I/O was a fixture saying so.
|
|
42
|
+
* - **The audio is addressed by BYTE RANGE, never carried.** A workflow's input
|
|
43
|
+
* is journaled and replayed on every resume, so the recording lives in the
|
|
44
|
+
* app's own upload store and the run carries only its id; each step reads
|
|
45
|
+
* exactly its own window with `readUpload`. Sixty steps therefore move the
|
|
46
|
+
* recording once between them, not sixty times.
|
|
47
|
+
* - **The fan-out is bounded by `mapInBatches`, and the bound is not a detail.**
|
|
48
|
+
* The DevKit correlates a journal entry to a step call by the ORDER the call
|
|
49
|
+
* was issued in, so a work-stealing pool — which issues its next call only
|
|
50
|
+
* when a previous one settles — puts the calls in a different order on a
|
|
51
|
+
* replay than it did on the first execution. That primitive is sequential
|
|
52
|
+
* batches of `Promise.all` for exactly that reason; its module doc carries the
|
|
53
|
+
* argument.
|
|
54
|
+
*/
|
|
55
|
+
|
|
56
|
+
import { throwFatalStepError, toStepError } from "@alexkroman1/aai/step-errors";
|
|
57
|
+
import {
|
|
58
|
+
mapInBatches,
|
|
59
|
+
multipartBody,
|
|
60
|
+
readUpload,
|
|
61
|
+
report,
|
|
62
|
+
requireStepEnv,
|
|
63
|
+
stepFetch,
|
|
64
|
+
uploadInfo,
|
|
65
|
+
} from "@alexkroman1/aai/utils";
|
|
66
|
+
import {
|
|
67
|
+
parseWav,
|
|
68
|
+
planSegments,
|
|
69
|
+
type Segment,
|
|
70
|
+
UnsupportedRecordingError,
|
|
71
|
+
type WavFormat,
|
|
72
|
+
wavWithHeader,
|
|
73
|
+
} from "./wav.ts";
|
|
74
|
+
|
|
75
|
+
/** The synchronous transcription endpoint. Global — it routes to the nearest region. */
|
|
76
|
+
const SYNC_ENDPOINT = "https://sync.assemblyai.com/transcribe";
|
|
77
|
+
|
|
78
|
+
/** Required on every sync request; the endpoint routes on it. */
|
|
79
|
+
const SYNC_MODEL = "universal-3-5-pro";
|
|
80
|
+
|
|
81
|
+
/** The key a step reads out of the agent env. Declared in `agent.ts`'s `requiredEnv`. */
|
|
82
|
+
const API_KEY_ENV = "ASSEMBLYAI_API_KEY";
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Segments in flight at once.
|
|
86
|
+
*
|
|
87
|
+
* Bounded because the far side has a capacity limit, and it is MEASURED now —
|
|
88
|
+
* 65 segments (1h37m of 48 kHz stereo, 17.66 MB each) through this workflow,
|
|
89
|
+
* one concurrency per run, from one laptop and one account:
|
|
90
|
+
*
|
|
91
|
+
* | in flight | wall | vs realtime | `503`s |
|
|
92
|
+
* | --- | --- | --- | --- |
|
|
93
|
+
* | 8 | 43.3s | 134x | 0 |
|
|
94
|
+
* | 32 | 27.5s | 211x | 0 |
|
|
95
|
+
* | 48 | 26.1-28.5s | 204-223x | 0-4 |
|
|
96
|
+
* | 64 | 31.9s | 182x | 20 |
|
|
97
|
+
*
|
|
98
|
+
* Two readings, and the second is why this is 8 and not 32. Throughput
|
|
99
|
+
* PLATEAUS around 32: past it the uplink is the bottleneck, every request just
|
|
100
|
+
* gets a thinner share of it (p50 4.2s at 8, 10.0s at 32, 12.5s at 64), and at
|
|
101
|
+
* 64 the far side starts answering `503 Capacity Exceeded` — so the extra
|
|
102
|
+
* concurrency buys retries rather than speed. And the number the plateau sits
|
|
103
|
+
* at belongs to the machine, not to this code: a deployed guest reserves one
|
|
104
|
+
* CPU and has neither this uplink nor its ~47 MB/s, so a default measured here
|
|
105
|
+
* would be an overcommit there.
|
|
106
|
+
*
|
|
107
|
+
* So 8 is a floor with headroom, and the ceiling is known: raise it toward 32
|
|
108
|
+
* against your own account and watch `503`s in the log. Overshooting is no
|
|
109
|
+
* longer expensive — a `503` carries `retry-after` and `toStepError` below
|
|
110
|
+
* honours it, so the run completes having paid one extra request per limited
|
|
111
|
+
* segment (measured: 20 `503`s at 64, each retried exactly once, run
|
|
112
|
+
* completed). That is only true over HTTP/1.1, which is what `stepFetch` pins.
|
|
113
|
+
*/
|
|
114
|
+
const SEGMENT_CONCURRENCY = 8;
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Bytes probed for the WAV header.
|
|
118
|
+
*
|
|
119
|
+
* The canonical header is 44 bytes; a recorder that writes a `LIST` or `bext`
|
|
120
|
+
* chunk in front of the samples pushes the `data` chunk further out, and 64 KB
|
|
121
|
+
* covers every such file anyone has produced by accident.
|
|
122
|
+
*/
|
|
123
|
+
const HEADER_PROBE_BYTES = 64 * 1024;
|
|
124
|
+
|
|
125
|
+
/** The endpoint's own per-request deadline, plus room to upload. */
|
|
126
|
+
const SYNC_TIMEOUT_MS = 60_000;
|
|
127
|
+
|
|
128
|
+
/** Most words `stitchTranscript` will look back over to find a repeated seam. */
|
|
129
|
+
const MAX_SEAM_WORDS = 40;
|
|
130
|
+
|
|
131
|
+
/** What one segment's request came back with. */
|
|
132
|
+
export type SegmentTranscript = {
|
|
133
|
+
index: number;
|
|
134
|
+
text: string;
|
|
135
|
+
};
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Transcribe a recording and return one transcript.
|
|
139
|
+
*
|
|
140
|
+
* The input is what `POST /workflows/runs` carries — see `agent.ts` for the
|
|
141
|
+
* schema it is validated against before a run exists.
|
|
142
|
+
*/
|
|
143
|
+
export async function transcribeFlow(input: { recording: string }) {
|
|
144
|
+
"use workflow";
|
|
145
|
+
|
|
146
|
+
const plan = await splitRecording(input.recording);
|
|
147
|
+
|
|
148
|
+
// One step per segment, bounded, in an order a replay reproduces exactly.
|
|
149
|
+
// A failed segment fails the RUN, deliberately: every sibling that finished is
|
|
150
|
+
// already journaled, so the resume replays those for free and re-issues only
|
|
151
|
+
// what is missing, where catching here to salvage a partial transcript would
|
|
152
|
+
// return a recording with a silent hole in it and report success.
|
|
153
|
+
const parts = await mapInBatches(plan.segments, SEGMENT_CONCURRENCY, (segment) =>
|
|
154
|
+
transcribeSegment(input.recording, plan.format, segment),
|
|
155
|
+
);
|
|
156
|
+
|
|
157
|
+
// Whatever this returns is what a caller reads as `output` on a completed run
|
|
158
|
+
// — so it is what the page renders, typed through `WorkflowOutputOf`.
|
|
159
|
+
return await mergeTranscript(input.recording, plan.durationMs, parts);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Read the recording's header and decide where to cut it.
|
|
164
|
+
*
|
|
165
|
+
* A step rather than body code for two reasons that both matter. It does I/O,
|
|
166
|
+
* which a body may not; and its RESULT is what the fan-out's width is derived
|
|
167
|
+
* from, so journaling it is what makes that width stable across a resume — the
|
|
168
|
+
* body re-derives the same segment list from the same journaled format rather
|
|
169
|
+
* than re-probing a URL whose content may have changed underneath it.
|
|
170
|
+
*/
|
|
171
|
+
export async function splitRecording(uploadId: string): Promise<{
|
|
172
|
+
format: WavFormat;
|
|
173
|
+
segments: Segment[];
|
|
174
|
+
durationMs: number;
|
|
175
|
+
}> {
|
|
176
|
+
"use step";
|
|
177
|
+
|
|
178
|
+
const head = await readUpload(uploadId, { end: HEADER_PROBE_BYTES });
|
|
179
|
+
const format = fatalOnUnsupported(() => parseWav(head.bytes, head.info.size));
|
|
180
|
+
const segments = fatalOnUnsupported(() => planSegments(format));
|
|
181
|
+
const durationMs = segments.at(-1)?.endMs ?? 0;
|
|
182
|
+
|
|
183
|
+
await report(
|
|
184
|
+
`Split ${clock(durationMs)} of audio into ${segments.length} segment${segments.length === 1 ? "" : "s"}.`,
|
|
185
|
+
);
|
|
186
|
+
return { format, segments, durationMs };
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* Transcribe one segment through the sync API.
|
|
191
|
+
*
|
|
192
|
+
* One step each, so a run that dies part-way resumes having replayed the
|
|
193
|
+
* finished ones from the journal — no re-downloading, no re-billing — and issues
|
|
194
|
+
* exactly the calls that are missing.
|
|
195
|
+
*/
|
|
196
|
+
export async function transcribeSegment(
|
|
197
|
+
uploadId: string,
|
|
198
|
+
format: WavFormat,
|
|
199
|
+
segment: Segment,
|
|
200
|
+
): Promise<SegmentTranscript> {
|
|
201
|
+
"use step";
|
|
202
|
+
|
|
203
|
+
// One line per segment, which is what makes the fan-out legible to a page: the
|
|
204
|
+
// status is `running` for the whole thing, so without this a sixty-segment
|
|
205
|
+
// recording and a one-segment recording look identical while they run.
|
|
206
|
+
//
|
|
207
|
+
// ORDER is not guaranteed here and does not need to be. A batch issues its
|
|
208
|
+
// calls together, so their lines interleave by completion — the page renders a
|
|
209
|
+
// log, not a sequence, and `segment.index` is what puts the TRANSCRIPT back in
|
|
210
|
+
// order.
|
|
211
|
+
await report(`Transcribing ${clock(segment.startMs)}–${clock(segment.endMs)}.`);
|
|
212
|
+
|
|
213
|
+
const apiKey = apiKeyOrFatal();
|
|
214
|
+
// `[start, end)`, the same half-open pair `planSegments` produced — the store
|
|
215
|
+
// owns the conversion to HTTP's inclusive range, so there is no `- 1` here to
|
|
216
|
+
// get wrong.
|
|
217
|
+
const audio = await readUpload(uploadId, { start: segment.start, end: segment.end });
|
|
218
|
+
|
|
219
|
+
// The audio and nothing else. A `config` part carrying `language_code` used
|
|
220
|
+
// to ride along, and it is gone with the picker that fed it: the model detects
|
|
221
|
+
// the language, so the field was a question asked of a person that the service
|
|
222
|
+
// answers better — and getting it wrong is a whole transcript in the wrong
|
|
223
|
+
// language. Add one back only for a desk that really knows.
|
|
224
|
+
const part = multipartBody({
|
|
225
|
+
name: "audio",
|
|
226
|
+
filename: `segment-${segment.index}.wav`,
|
|
227
|
+
type: "audio/wav",
|
|
228
|
+
bytes: wavWithHeader(format, audio.bytes),
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
// `stepFetch`, not `fetch`, and here it is load-bearing rather than tidy:
|
|
232
|
+
// `fetch` speaks HTTP/2 wherever the far side offers it, which puts a whole
|
|
233
|
+
// batch of segments on ONE connection — and a capacity limit then arrives as a
|
|
234
|
+
// stream reset carrying no HTTP status for `toStepError` below to read. The
|
|
235
|
+
// fan-out is exactly the shape that breaks on. `sdk/step-fetch.ts` holds the
|
|
236
|
+
// measurements; a `StepTransportError` out of here is already retryable and
|
|
237
|
+
// already names its cause.
|
|
238
|
+
const response = await stepFetch(SYNC_ENDPOINT, {
|
|
239
|
+
method: "POST",
|
|
240
|
+
headers: {
|
|
241
|
+
// The raw key — this endpoint takes it unprefixed, and a `Bearer ` in
|
|
242
|
+
// front of it is a 401 that reads like a wrong key.
|
|
243
|
+
Authorization: apiKey,
|
|
244
|
+
"X-AAI-Model": SYNC_MODEL,
|
|
245
|
+
...part.headers,
|
|
246
|
+
},
|
|
247
|
+
body: part.body,
|
|
248
|
+
// Nothing here has a deadline of its own, and a hung upload inside a step is
|
|
249
|
+
// a run that never finishes rather than one that retries.
|
|
250
|
+
signal: AbortSignal.timeout(SYNC_TIMEOUT_MS),
|
|
251
|
+
});
|
|
252
|
+
if (!response.ok) throw await syncFailure(response, segment);
|
|
253
|
+
|
|
254
|
+
const body = (await response.json()) as { text?: string };
|
|
255
|
+
return { index: segment.index, text: (body.text ?? "").trim() };
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/**
|
|
259
|
+
* Retries beyond the default 3, because a rate limit is the expected failure and
|
|
260
|
+
* a segment that 429s is not a segment that is wrong.
|
|
261
|
+
*/
|
|
262
|
+
transcribeSegment.maxRetries = 5;
|
|
263
|
+
|
|
264
|
+
/**
|
|
265
|
+
* Stitch the segments into one transcript.
|
|
266
|
+
*
|
|
267
|
+
* A step rather than a pure call in the body, and the reason is the narration:
|
|
268
|
+
* the body replays from the top on every resume, so a `report()` written there
|
|
269
|
+
* is re-emitted on each one. Journaling the finished transcript also means a
|
|
270
|
+
* caller re-reading a completed run gets the same bytes rather than a value
|
|
271
|
+
* recomputed from parts.
|
|
272
|
+
*/
|
|
273
|
+
export async function mergeTranscript(
|
|
274
|
+
uploadId: string,
|
|
275
|
+
durationMs: number,
|
|
276
|
+
parts: readonly SegmentTranscript[],
|
|
277
|
+
): Promise<{
|
|
278
|
+
source: string;
|
|
279
|
+
segments: number;
|
|
280
|
+
durationMs: number;
|
|
281
|
+
words: number;
|
|
282
|
+
transcript: string;
|
|
283
|
+
}> {
|
|
284
|
+
"use step";
|
|
285
|
+
|
|
286
|
+
await report(`Stitching ${parts.length} segment${parts.length === 1 ? "" : "s"} together.`);
|
|
287
|
+
|
|
288
|
+
// `mapInBatches` resolves in ITEM order however the calls settled, so this is
|
|
289
|
+
// already ordered — sorted anyway, because the merge is where an ordering
|
|
290
|
+
// mistake would be invisible rather than loud.
|
|
291
|
+
const ordered = [...parts].sort((a, b) => a.index - b.index);
|
|
292
|
+
const transcript = stitchTranscript(ordered.map((part) => part.text));
|
|
293
|
+
|
|
294
|
+
// The FILENAME, not the id: the page prints this, and `upl_9f3…` tells a
|
|
295
|
+
// reader nothing about which recording they are looking at.
|
|
296
|
+
const source = (await uploadInfo(uploadId)).name || uploadId;
|
|
297
|
+
return {
|
|
298
|
+
source,
|
|
299
|
+
segments: parts.length,
|
|
300
|
+
durationMs,
|
|
301
|
+
words: countWords(transcript),
|
|
302
|
+
transcript,
|
|
303
|
+
};
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
// ---- Pure helpers -----------------------------------------------------------
|
|
307
|
+
|
|
308
|
+
/** A word, stripped of the punctuation the decoder added, for seam comparison. */
|
|
309
|
+
function seamKey(word: string): string {
|
|
310
|
+
return word.toLowerCase().replace(/[^\p{L}\p{N}']/gu, "");
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
/**
|
|
314
|
+
* Join segment transcripts, dropping the words the overlap made duplicates.
|
|
315
|
+
*
|
|
316
|
+
* Segments overlap by `SEGMENT_OVERLAP_SECONDS` (see `wav.ts` for why), so the
|
|
317
|
+
* last few words of one segment are the first few of the next — verbatim when
|
|
318
|
+
* the decoder heard them the same way, which is the common case because it heard
|
|
319
|
+
* the same audio. This finds the longest such run and removes one copy.
|
|
320
|
+
*
|
|
321
|
+
* Comparison is on `seamKey`, not the raw words: the two passes punctuate
|
|
322
|
+
* differently at their own edges (one ends a sentence where the other is
|
|
323
|
+
* mid-clause), so `"today."` and `"today"` are the same word and a raw compare
|
|
324
|
+
* finds no seam at all. The text KEPT is the raw text — only the match is
|
|
325
|
+
* normalized.
|
|
326
|
+
*
|
|
327
|
+
* A missed seam repeats a few words, which a reader can see and forgive. A
|
|
328
|
+
* false one would delete speech, so the search is bounded at
|
|
329
|
+
* `MAX_SEAM_WORDS` and always prefers the LONGEST match: a single repeated
|
|
330
|
+
* "the" is not evidence of anything, and requiring the longest run is what stops
|
|
331
|
+
* it counting as one when a longer match is available.
|
|
332
|
+
*/
|
|
333
|
+
export function stitchTranscript(parts: readonly string[]): string {
|
|
334
|
+
const merged: string[] = [];
|
|
335
|
+
for (const part of parts) {
|
|
336
|
+
const next = part.split(/\s+/).filter(Boolean);
|
|
337
|
+
if (next.length === 0) continue;
|
|
338
|
+
if (merged.length === 0) {
|
|
339
|
+
merged.push(...next);
|
|
340
|
+
continue;
|
|
341
|
+
}
|
|
342
|
+
merged.push(...next.slice(seamLength(merged, next)));
|
|
343
|
+
}
|
|
344
|
+
return merged.join(" ");
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
/** How many leading words of `next` repeat the tail of `merged`. */
|
|
348
|
+
function seamLength(merged: readonly string[], next: readonly string[]): number {
|
|
349
|
+
const limit = Math.min(MAX_SEAM_WORDS, merged.length, next.length);
|
|
350
|
+
// Longest first, so a short accidental match never wins over a real seam.
|
|
351
|
+
for (let length = limit; length > 0; length--) {
|
|
352
|
+
const tail = merged.slice(merged.length - length);
|
|
353
|
+
if (tail.every((word, at) => seamKey(word) === seamKey(next[at] ?? ""))) return length;
|
|
354
|
+
}
|
|
355
|
+
return 0;
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
/** Words in a string. */
|
|
359
|
+
function countWords(text: string): number {
|
|
360
|
+
return text.split(/\s+/).filter(Boolean).length;
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
/** `m:ss` for the progress log — a byte offset means nothing to a reader. */
|
|
364
|
+
export function clock(ms: number): string {
|
|
365
|
+
const seconds = Math.max(0, Math.round(ms / 1000));
|
|
366
|
+
return `${Math.floor(seconds / 60)}:${String(seconds % 60).padStart(2, "0")}`;
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
// ---- I/O helpers ------------------------------------------------------------
|
|
370
|
+
|
|
371
|
+
/** The API key, or a terminal failure — three more attempts find the same gap. */
|
|
372
|
+
function apiKeyOrFatal(): string {
|
|
373
|
+
try {
|
|
374
|
+
return requireStepEnv(API_KEY_ENV);
|
|
375
|
+
} catch (err: unknown) {
|
|
376
|
+
// `throwFatalStepError` rather than `throw new FatalError(…)`: that class
|
|
377
|
+
// takes only a message — no `cause` — so constructing one inside a `catch`
|
|
378
|
+
// loses the original where the linter (rightly) expects it preserved. Here
|
|
379
|
+
// the original is the ARGUMENT, and nothing is swallowed.
|
|
380
|
+
return throwFatalStepError(err);
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
/** Run a `wav.ts` helper, turning its "cannot cut this" into a terminal failure. */
|
|
385
|
+
function fatalOnUnsupported<T>(read: () => T): T {
|
|
386
|
+
try {
|
|
387
|
+
return read();
|
|
388
|
+
} catch (err: unknown) {
|
|
389
|
+
if (err instanceof UnsupportedRecordingError) return throwFatalStepError(err);
|
|
390
|
+
throw err;
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
/**
|
|
395
|
+
* The sync endpoint's failure, with whatever it said about it.
|
|
396
|
+
*
|
|
397
|
+
* `toStepError` makes the three-way call: a `FatalError` stops the DevKit
|
|
398
|
+
* retrying something that will answer the same way, a bare `RetryableError`
|
|
399
|
+
* retries in ONE SECOND (that class's own default), and a `RetryableError`
|
|
400
|
+
* carrying `retryAfter` waits exactly as long as the far side asked. The last
|
|
401
|
+
* matters here because `SEGMENT_CONCURRENCY` segments hit the rate limit
|
|
402
|
+
* together — a second later all four ask again, where on the server's number
|
|
403
|
+
* they drain.
|
|
404
|
+
*/
|
|
405
|
+
async function syncFailure(response: Response, segment: Segment): Promise<Error> {
|
|
406
|
+
// Two shapes, documented: `{ error_code, message }` for a request problem and
|
|
407
|
+
// `{ detail }` for auth and rate limits.
|
|
408
|
+
const body = (await response.json().catch(() => ({}))) as { message?: string; detail?: string };
|
|
409
|
+
const detail = body.message ?? body.detail;
|
|
410
|
+
return toStepError(
|
|
411
|
+
response,
|
|
412
|
+
`Segment ${segment.index} (${clock(segment.startMs)}) failed: HTTP ${response.status}${
|
|
413
|
+
detail ? ` — ${detail}` : ""
|
|
414
|
+
}`,
|
|
415
|
+
);
|
|
416
|
+
}
|