@alexkroman1/aai-cli 6.11.0 → 8.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/README.md +1 -0
- package/dist/{_agent-DpH2pBJd.mjs → _agent-BTwZJu4P.mjs} +2 -2
- package/dist/{_bundler-C2BDwU7r.mjs → _bundler-CDuYl5Gb.mjs} +2 -2
- package/dist/_dev-env.d.ts +22 -2
- package/dist/{_dev-server-LLLWnFBs.mjs → _dev-server-Me06wgeF.mjs} +38 -47
- package/dist/_dev-server.d.ts +0 -17
- package/dist/{_init-C57bWAQ8.mjs → _init-BhEfQ6Yi.mjs} +3 -2
- package/dist/{_preflight-BtfaYtbE.mjs → _preflight-Co1G9Jww.mjs} +1 -1
- package/dist/_preflight.d.ts +1 -1
- package/dist/_resource-commands.d.ts +23 -0
- package/dist/{_slug-api-BxrsJXVI.mjs → _slug-api-z-USxPtL.mjs} +16 -12
- package/dist/_slug-api.d.ts +5 -2
- package/dist/{_studio-U3jhKlA3.mjs → _studio-DR5yzZ1T.mjs} +2 -1
- package/dist/{_templates-Dxb_P2Wz.mjs → _templates-4WcKOjS5.mjs} +5 -6
- package/dist/_templates.d.ts +3 -4
- package/dist/_workflow-scan.d.ts +108 -0
- package/dist/{build-Dfza2pRI.mjs → build-BBxf8vom.mjs} +6 -3
- package/dist/cli.mjs +346 -349
- 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-DRC8jWr8.mjs} +5 -5
- package/dist/{dev-Uq5ujP8a.mjs → dev-BvcctCn2.mjs} +1 -1
- package/dist/eval-DfQ8cm5p.mjs +56 -0
- package/dist/eval.d.ts +43 -0
- package/dist/{init-l6xfU4xX.mjs → init-CUo87gcI.mjs} +3 -3
- package/dist/{login-C71-qz8F.mjs → login-BBuM1sxH.mjs} +3 -4
- package/dist/{logs-BWVGSjLR.mjs → logs-CQ2T46dr.mjs} +2 -2
- package/dist/project-config.mjs +1 -1
- package/dist/scaffold/CLAUDE.md +442 -80
- package/dist/scaffold/package.json +6 -3
- package/dist/scaffold/server.mjs +11 -4
- package/dist/scaffold/tsconfig.json +0 -1
- package/dist/{secret-BuMuFR4B.mjs → secret-Ct34kdgU.mjs} +2 -2
- package/dist/{storage-DzRZ-eCw.mjs → storage-i_79ZwAR.mjs} +2 -2
- package/dist/{studio-BCNUpDgP.mjs → studio-CzTzPqe1.mjs} +4 -4
- package/dist/templates/briefing-desk/agent.test.ts +350 -0
- package/dist/templates/briefing-desk/agent.ts +30 -0
- package/dist/templates/briefing-desk/shared.ts +200 -0
- package/dist/templates/briefing-desk/system-prompt.md +39 -0
- package/dist/templates/briefing-desk/tools/briefing_so_far.ts +35 -0
- package/dist/templates/briefing-desk/tools/research_topic.ts +88 -0
- package/dist/templates/briefing-desk/tools/verify_claim.ts +77 -0
- package/dist/templates/call-audit/agent.eval.test.ts +474 -0
- package/dist/templates/call-audit/agent.test.ts +57 -204
- package/dist/templates/call-audit/agent.ts +32 -19
- package/dist/templates/call-audit/client.tsx +17 -54
- package/dist/templates/call-audit/workflows/audit.ts +11 -18
- package/dist/templates/call-audit/workflows/ingest.ts +106 -114
- package/dist/templates/call-audit/workflows/media.ts +2 -12
- package/dist/templates/call-audit/workflows/summarize.ts +47 -52
- package/dist/templates/call-audit/workflows/sync-api.ts +7 -7
- package/dist/templates/code-interpreter/agent.eval.test.ts +165 -0
- package/dist/templates/dispatch-center/agent.eval.test.ts +349 -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.eval.test.ts +138 -0
- package/dist/templates/embedded-assets/agent.test.ts +17 -14
- package/dist/templates/health-assistant/agent.eval.test.ts +167 -0
- package/dist/templates/health-assistant/agent.test.ts +22 -4
- package/dist/templates/infocom-adventure/agent.eval.test.ts +158 -0
- package/dist/templates/infocom-adventure/agent.test.ts +53 -24
- package/dist/templates/infocom-adventure/agent.ts +21 -0
- package/dist/templates/infocom-adventure/client.tsx +246 -164
- package/dist/templates/infocom-adventure/shared.ts +20 -9
- package/dist/templates/infocom-adventure/system-prompt.md +1 -1
- package/dist/templates/infocom-adventure/tools/game_state_move.ts +3 -2
- package/dist/templates/link-digest/agent.eval.test.ts +195 -0
- 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/math-buddy/agent.eval.test.ts +146 -0
- package/dist/templates/night-owl/agent.eval.test.ts +272 -0
- 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/personal-finance/agent.eval.test.ts +168 -0
- package/dist/templates/personal-finance/system-prompt.md +4 -0
- package/dist/templates/pipeline-simple/agent.eval.test.ts +78 -0
- package/dist/templates/pipeline-simple/agent.ts +2 -2
- package/dist/templates/pizza-ordering/agent.eval.test.ts +171 -0
- package/dist/templates/pizza-ordering/agent.test.ts +42 -27
- package/dist/templates/pizza-ordering/client.tsx +9 -26
- package/dist/templates/pizza-ordering/shared.ts +20 -1
- package/dist/templates/pizza-ordering/system-prompt.md +6 -0
- package/dist/templates/plan-and-execute/agent.eval.test.ts +209 -0
- 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.eval.test.ts +411 -0
- package/dist/templates/podcast-digest/agent.test.ts +717 -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 +444 -0
- package/dist/templates/podcast-digest/workflows/feeds.ts +507 -0
- package/dist/templates/podcast-digest/workflows/slack.ts +88 -0
- package/dist/templates/recap-workflow/agent.eval.test.ts +366 -0
- package/dist/templates/recap-workflow/agent.test.ts +97 -94
- 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 +36 -40
- package/dist/templates/redline/agent.eval.test.ts +209 -0
- 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.eval.test.ts +329 -0
- 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/address.ts +87 -1
- package/dist/templates/retail/agent.eval.test.ts +314 -0
- package/dist/templates/retail/agent.test.ts +195 -112
- package/dist/templates/retail/agent.ts +35 -1
- package/dist/templates/retail/cancel.ts +116 -0
- package/dist/templates/retail/client.tsx +265 -117
- package/dist/templates/retail/payment.ts +107 -0
- package/dist/templates/retail/pending.test.ts +196 -0
- package/dist/templates/retail/pending.ts +137 -0
- package/dist/templates/retail/registry.test.ts +150 -7
- package/dist/templates/retail/returns.ts +102 -0
- package/dist/templates/retail/shared.test.ts +42 -0
- package/dist/templates/retail/shared.ts +35 -0
- package/dist/templates/retail/store.test.ts +84 -15
- package/dist/templates/retail/store.ts +231 -48
- package/dist/templates/retail/swap.test.ts +4 -4
- package/dist/templates/retail/swap.ts +248 -15
- package/dist/templates/retail/system-prompt.md +69 -28
- package/dist/templates/retail/tools/cancel_change.ts +33 -0
- package/dist/templates/retail/tools/cancel_pending_order.ts +16 -60
- package/dist/templates/retail/tools/confirm_change.ts +42 -0
- package/dist/templates/retail/tools/exchange_delivered_order_items.ts +22 -67
- 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 +13 -25
- package/dist/templates/retail/tools/modify_pending_order_items.ts +23 -62
- package/dist/templates/retail/tools/modify_pending_order_payment.ts +16 -78
- package/dist/templates/retail/tools/modify_user_address.ts +13 -16
- package/dist/templates/retail/tools/return_delivered_order_items.ts +17 -72
- package/dist/templates/retail/tools/transfer_to_human_agents.ts +12 -6
- package/dist/templates/simple/agent.eval.test.ts +54 -0
- package/dist/templates/solo-rpg/agent.eval.test.ts +320 -0
- 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.eval.test.ts +342 -0
- package/dist/templates/spoken-summary/agent.test.ts +68 -97
- package/dist/templates/spoken-summary/agent.ts +30 -17
- package/dist/templates/spoken-summary/client.tsx +10 -50
- package/dist/templates/spoken-summary/workflows/summarize.ts +17 -22
- package/dist/templates/spoken-summary/workflows/transcribe.ts +19 -26
- package/dist/templates/support-line/agent.eval.test.ts +273 -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/system-prompt.md +2 -1
- package/dist/templates/support-line/tools/answer_question.ts +66 -4
- package/dist/templates/transcription-workflow/agent.eval.test.ts +234 -0
- package/dist/templates/transcription-workflow/agent.test.ts +94 -194
- package/dist/templates/transcription-workflow/agent.ts +1 -1
- package/dist/templates/transcription-workflow/client.tsx +17 -42
- package/dist/templates/transcription-workflow/workflows/batch.ts +19 -22
- package/dist/templates/transcription-workflow/workflows/normalize.ts +90 -186
- package/dist/templates/transcription-workflow/workflows/stitch.ts +0 -11
- package/dist/templates/transcription-workflow/workflows/stream.ts +4 -5
- package/dist/templates/transcription-workflow/workflows/sync-api.ts +6 -6
- package/dist/templates/transcription-workflow/workflows/transcribe.ts +32 -26
- package/dist/templates/transcription-workflow/workflows/wav.ts +0 -31
- package/dist/templates/travel-concierge/agent.eval.test.ts +323 -0
- package/dist/templates/travel-concierge/agent.test.ts +174 -43
- package/dist/templates/travel-concierge/agent.ts +18 -9
- 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 +180 -12
- package/dist/templates/travel-concierge/system-prompt.md +7 -1
- package/dist/templates/travel-concierge/tools/book_car_rental.ts +8 -5
- package/dist/templates/travel-concierge/tools/book_excursion.ts +8 -5
- package/dist/templates/travel-concierge/tools/book_hotel.ts +8 -5
- package/dist/templates/travel-concierge/tools/cancel_ticket.ts +8 -5
- package/dist/templates/travel-concierge/tools/search_car_rentals.ts +9 -5
- package/dist/templates/travel-concierge/tools/search_excursions.ts +9 -5
- package/dist/templates/travel-concierge/tools/search_flights.ts +9 -6
- package/dist/templates/travel-concierge/tools/search_hotels.ts +9 -5
- package/dist/templates/travel-concierge/tools/update_ticket.ts +8 -5
- package/dist/templates/web-researcher/agent.eval.test.ts +121 -0
- package/dist/templates/web-researcher/system-prompt.md +15 -4
- package/dist/{test-9kPKJI-w.mjs → test-BvyT6sAc.mjs} +24 -10
- package/dist/test.d.ts +29 -5
- package/dist/{worker-bundler-CGD4r8Kc.mjs → worker-bundler-CCVEDjm1.mjs} +241 -3
- package/dist/worker-bundler.mjs +1 -1
- package/dist/{workflow-CFpxOFfQ.mjs → workflow-BflATMmU.mjs} +47 -12
- package/dist/workflow-bundler.d.ts +6 -1
- package/dist/workflow.d.ts +1 -1
- package/package.json +5 -4
- package/dist/eject-31gjtaHF.mjs +0 -116
- package/dist/eject.d.ts +0 -60
- package/dist/templates/call-audit/workflows/temp-media.ts +0 -138
- package/dist/templates/infocom-adventure/tools/game_state_history.ts +0 -14
- 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
|
@@ -23,27 +23,24 @@
|
|
|
23
23
|
* that would have finished.
|
|
24
24
|
*/
|
|
25
25
|
|
|
26
|
-
import { readdir
|
|
26
|
+
import { readdir } from "node:fs/promises";
|
|
27
27
|
import { tmpdir } from "node:os";
|
|
28
|
-
import {
|
|
29
|
-
import {
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
28
|
+
import { stubSpeech } from "@alexkroman1/aai/testing";
|
|
29
|
+
import {
|
|
30
|
+
installStubGateway,
|
|
31
|
+
installStubReporter,
|
|
32
|
+
installStubSpeech,
|
|
33
|
+
installStubTranscribe,
|
|
34
|
+
installStubUploads,
|
|
35
|
+
} from "@alexkroman1/aai/testing/vitest";
|
|
36
|
+
import { beforeEach, describe, expect, test, vi } from "vitest";
|
|
33
37
|
import { FatalError, RetryableError } from "workflow";
|
|
34
38
|
import agentDef, { audit } from "./agent.ts";
|
|
35
|
-
import {
|
|
36
|
-
|
|
37
|
-
joinSegments,
|
|
38
|
-
now,
|
|
39
|
-
SEGMENT_CONCURRENCY,
|
|
40
|
-
transcribeSegment,
|
|
41
|
-
} from "./workflows/audit.ts";
|
|
42
|
-
import { analyse, classifyFfmpeg, ingestRecording } from "./workflows/ingest.ts";
|
|
39
|
+
import { joinSegments, now, SEGMENT_CONCURRENCY, transcribeSegment } from "./workflows/audit.ts";
|
|
40
|
+
import { analyse, ingestRecording } from "./workflows/ingest.ts";
|
|
43
41
|
import {
|
|
44
42
|
ANALYSIS_FORMAT,
|
|
45
43
|
BYTES_PER_SECOND,
|
|
46
|
-
clock,
|
|
47
44
|
durationSeconds,
|
|
48
45
|
type Loudness,
|
|
49
46
|
MAX_SEGMENT_SECONDS,
|
|
@@ -59,7 +56,6 @@ import {
|
|
|
59
56
|
speechFraction,
|
|
60
57
|
} from "./workflows/media.ts";
|
|
61
58
|
import { narrate, summarize } from "./workflows/summarize.ts";
|
|
62
|
-
import { fileChunks, materializeUpload, withTempDir } from "./workflows/temp-media.ts";
|
|
63
59
|
|
|
64
60
|
/** The id every spec below uploads under. */
|
|
65
61
|
const UPLOAD_ID = "upl_test";
|
|
@@ -163,12 +159,6 @@ function pauses(...starts: number[]): Silence[] {
|
|
|
163
159
|
return starts.map((startSec) => ({ startSec, endSec: startSec + MIN_SILENCE_SECONDS }));
|
|
164
160
|
}
|
|
165
161
|
|
|
166
|
-
/** Slots left published reach the next file, so every one is released here. */
|
|
167
|
-
const restores: (() => void)[] = [];
|
|
168
|
-
afterEach(() => {
|
|
169
|
-
while (restores.length > 0) restores.pop()?.();
|
|
170
|
-
});
|
|
171
|
-
|
|
172
162
|
beforeEach(() => {
|
|
173
163
|
// The step env, which is where `requireStepEnv`, `stepSpeak` and the gateway
|
|
174
164
|
// read the key. `vi.stubEnv` rather than an assignment: `unstubEnvs` undoes it
|
|
@@ -518,13 +508,6 @@ describe("what the page is told about the recording", () => {
|
|
|
518
508
|
test("a zero-length recording answers 0 rather than dividing by it", () => {
|
|
519
509
|
expect(speechFraction([], 0)).toBe(0);
|
|
520
510
|
});
|
|
521
|
-
|
|
522
|
-
test("the clock grows an hours field only when there is one", () => {
|
|
523
|
-
expect(clock(0)).toBe("0:00");
|
|
524
|
-
expect(clock(9000)).toBe("0:09");
|
|
525
|
-
expect(clock(249_000)).toBe("4:09");
|
|
526
|
-
expect(clock(3_849_000)).toBe("1:04:09");
|
|
527
|
-
});
|
|
528
511
|
});
|
|
529
512
|
|
|
530
513
|
describe("joining the segments", () => {
|
|
@@ -577,39 +560,34 @@ describe("joining the segments", () => {
|
|
|
577
560
|
]),
|
|
578
561
|
).toBe("words");
|
|
579
562
|
});
|
|
580
|
-
|
|
581
|
-
test("counts words, or none for an empty transcript", () => {
|
|
582
|
-
expect(countWords(" two words ")).toBe(2);
|
|
583
|
-
expect(countWords(" ")).toBe(0);
|
|
584
|
-
});
|
|
585
563
|
});
|
|
586
564
|
|
|
587
565
|
describe("transcribing one segment", () => {
|
|
588
566
|
/**
|
|
589
567
|
* One second of stored PCM, and a sync endpoint that answers.
|
|
590
568
|
*
|
|
591
|
-
* `
|
|
592
|
-
* which reaches a published slot rather than the global. Stubbing
|
|
593
|
-
* still passes, because an unpublished slot falls back to it, and
|
|
594
|
-
* asserting against a path production does not take.
|
|
569
|
+
* A published `stepFetch`, not `vi.stubGlobal("fetch", …)`: the step calls
|
|
570
|
+
* `stepFetch`, which reaches a published slot rather than the global. Stubbing
|
|
571
|
+
* the global still passes, because an unpublished slot falls back to it, and
|
|
572
|
+
* would be asserting against a path production does not take.
|
|
595
573
|
*/
|
|
596
|
-
function stubProvider(
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
574
|
+
function stubProvider(failure?: { status: number; message: string }) {
|
|
575
|
+
installStubUploads({
|
|
576
|
+
[UPLOAD_ID]: {
|
|
577
|
+
bytes: new Uint8Array(BYTES_PER_SECOND),
|
|
578
|
+
name: "call.pcm",
|
|
579
|
+
type: "application/octet-stream",
|
|
580
|
+
},
|
|
581
|
+
});
|
|
582
|
+
installStubReporter();
|
|
583
|
+
// `installStubTranscribe` answers AssemblyAI's own endpoints off the SDK's
|
|
584
|
+
// endpoint constants, so this spec no longer re-types the wire — and a refusal
|
|
585
|
+
// is staged as a STATUS, which is what makes the classification below a test of
|
|
586
|
+
// the SDK's reading of it rather than of a `TranscribeError` a fake minted.
|
|
587
|
+
return installStubTranscribe({
|
|
588
|
+
text: "hello there",
|
|
589
|
+
failure: failure === undefined ? undefined : { leg: "sync", ...failure },
|
|
590
|
+
}).calls;
|
|
613
591
|
}
|
|
614
592
|
|
|
615
593
|
const SEGMENT = {
|
|
@@ -651,13 +629,13 @@ describe("transcribing one segment", () => {
|
|
|
651
629
|
test("a rate limit is RETRYABLE, so one busy minute does not fail the run", async () => {
|
|
652
630
|
// The expected failure of a 32-wide fan-out, and the reason this step's
|
|
653
631
|
// `maxRetries` is above the default.
|
|
654
|
-
stubProvider({ status: 429,
|
|
632
|
+
stubProvider({ status: 429, message: "slow down" });
|
|
655
633
|
await expect(transcribeSegment(UPLOAD_ID, SEGMENT)).rejects.toBeInstanceOf(RetryableError);
|
|
656
634
|
expect(transcribeSegment.maxRetries).toBe(5);
|
|
657
635
|
});
|
|
658
636
|
|
|
659
637
|
test("a rejected request is FATAL, so it is not asked five more times", async () => {
|
|
660
|
-
stubProvider({ status: 400,
|
|
638
|
+
stubProvider({ status: 400, message: "that is not audio" });
|
|
661
639
|
await expect(transcribeSegment(UPLOAD_ID, SEGMENT)).rejects.toBeInstanceOf(FatalError);
|
|
662
640
|
});
|
|
663
641
|
|
|
@@ -675,7 +653,7 @@ describe("auditing the transcript", () => {
|
|
|
675
653
|
test("asks for a script as well as lists, and keeps both", async () => {
|
|
676
654
|
// The two-summaries decision: a voice reading a bullet list says "one. two.
|
|
677
655
|
// three." with no connective tissue, so the schema demands sentences too.
|
|
678
|
-
|
|
656
|
+
installStubReporter();
|
|
679
657
|
// The stub answers with TEXT, because that is what a gateway returns — and
|
|
680
658
|
// `stepGenerateJson` parsing it is part of what this exercises.
|
|
681
659
|
installStubGateway(
|
|
@@ -696,7 +674,7 @@ describe("auditing the transcript", () => {
|
|
|
696
674
|
test("an empty risk list is an ANSWER, not a retry", async () => {
|
|
697
675
|
// A schema that demanded a risk would get an invented one, which is worse than
|
|
698
676
|
// silence on a call that really had none.
|
|
699
|
-
|
|
677
|
+
installStubReporter();
|
|
700
678
|
installStubGateway(
|
|
701
679
|
JSON.stringify({
|
|
702
680
|
headline: "Weekly standup",
|
|
@@ -711,49 +689,22 @@ describe("auditing the transcript", () => {
|
|
|
711
689
|
test("a reply with no spoken script is a RETRY rather than silence", async () => {
|
|
712
690
|
// `spoken` is required rather than defaulted precisely so this fails: a default
|
|
713
691
|
// would turn a missing field into half a second of audio nobody notices.
|
|
714
|
-
|
|
692
|
+
installStubReporter();
|
|
715
693
|
installStubGateway(JSON.stringify({ headline: "A call", risks: [], actions: [] }));
|
|
716
694
|
await expect(summarize("…", "call.m4a", 60_000)).rejects.toThrow();
|
|
717
695
|
});
|
|
718
696
|
});
|
|
719
697
|
|
|
720
698
|
describe("classifying a failure", () => {
|
|
721
|
-
/**
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
test("a file ffmpeg REFUSED is fatal, so it is not attempted five times", () => {
|
|
732
|
-
// `exit` means ffmpeg read the file and would read it the same way again.
|
|
733
|
-
expect(() => classifyFfmpeg(failed("exit"))).toThrow(FatalError);
|
|
734
|
-
});
|
|
735
|
-
|
|
736
|
-
test("no ffmpeg at all is fatal — a retry cannot install one", () => {
|
|
737
|
-
// The `aai dev` case, whose message already carries the install instructions.
|
|
738
|
-
expect(() => classifyFfmpeg(failed("missing-binary"))).toThrow(FatalError);
|
|
739
|
-
});
|
|
740
|
-
|
|
741
|
-
test("a run that timed out keeps its retries, and its argv", () => {
|
|
742
|
-
// Rethrown UNCHANGED rather than wrapped, which is what `toStepError` does with
|
|
743
|
-
// an error carrying no verdict — and the DevKit's default for anything that is
|
|
744
|
-
// not a `FatalError` is to retry. Asserting the class survives is asserting the
|
|
745
|
-
// diagnosis does: `argv` is the command you paste into a shell.
|
|
746
|
-
for (const kind of ["timeout", "aborted"] as const) {
|
|
747
|
-
const err = failed(kind);
|
|
748
|
-
expect(() => classifyFfmpeg(err)).toThrow(err);
|
|
749
|
-
expect(() => classifyFfmpeg(err)).not.toThrow(FatalError);
|
|
750
|
-
}
|
|
751
|
-
});
|
|
752
|
-
|
|
753
|
-
test("something that is not an ffmpeg failure at all is fatal", () => {
|
|
754
|
-
expect(() => classifyFfmpeg(new Error("no space left on device"))).toThrow(FatalError);
|
|
755
|
-
});
|
|
756
|
-
|
|
699
|
+
/**
|
|
700
|
+
* The FFMPEG verdict is no longer tested here, and its absence is the change
|
|
701
|
+
* rather than a gap: `throwFfmpegStepError` on `@alexkroman1/aai/step-errors` owns
|
|
702
|
+
* it now, with both arms pinned in `sdk/step-errors.test.ts` — including the case
|
|
703
|
+
* this template contributed, a cause that is not an ffmpeg failure at all.
|
|
704
|
+
*
|
|
705
|
+
* What stays is what is still THIS desk's: `analyse`, which decides that an
|
|
706
|
+
* analysis `media.ts` cannot read is terminal.
|
|
707
|
+
*/
|
|
757
708
|
test("an analysis this desk cannot read is fatal, because a retry reads it again", () => {
|
|
758
709
|
// ffmpeg SUCCEEDED and printed something unrecognized — a renamed key, a lost
|
|
759
710
|
// `-loglevel info`. Every retry runs the same binary and prints the same thing.
|
|
@@ -802,104 +753,6 @@ describe("the mastered narration", () => {
|
|
|
802
753
|
});
|
|
803
754
|
});
|
|
804
755
|
|
|
805
|
-
describe("moving bytes between the store and a local file", () => {
|
|
806
|
-
/**
|
|
807
|
-
* These DO touch the filesystem, which the unit tier otherwise avoids — and the
|
|
808
|
-
* exception is deliberate rather than a slip. A temp directory is hermetic, costs
|
|
809
|
-
* milliseconds, and is the only way to exercise the one bug this module is written
|
|
810
|
-
* to prevent: `fileChunks` reuses one buffer across reads, so yielding a view of
|
|
811
|
-
* it rather than a copy hands the consumer memory the next read overwrites. That
|
|
812
|
-
* failure produces a stored file made of the LAST chunk repeated, and it does not
|
|
813
|
-
* reproduce whenever the consumer happens to copy before the next iteration — so
|
|
814
|
-
* a mock consumer would not see it and only real bytes will.
|
|
815
|
-
*
|
|
816
|
-
* `aai-cli`'s unit specs take the same exception through their own `withTempDir`.
|
|
817
|
-
*/
|
|
818
|
-
test("withTempDir gives the work a private directory and removes it after", async () => {
|
|
819
|
-
let seen: string | undefined;
|
|
820
|
-
await withTempDir(async (dir) => {
|
|
821
|
-
seen = dir;
|
|
822
|
-
await writeFile(join(dir, "probe"), "x");
|
|
823
|
-
await expect(stat(join(dir, "probe"))).resolves.toBeTruthy();
|
|
824
|
-
});
|
|
825
|
-
expect(seen).toBeTruthy();
|
|
826
|
-
// Gone. A guest's disk is small, and a step that left a copy of every recording
|
|
827
|
-
// it touched would fill it.
|
|
828
|
-
await expect(stat(seen ?? "")).rejects.toThrow();
|
|
829
|
-
});
|
|
830
|
-
|
|
831
|
-
test("the directory is removed even when the work THROWS", async () => {
|
|
832
|
-
let seen: string | undefined;
|
|
833
|
-
const boom = new Error("the conversion failed");
|
|
834
|
-
await expect(
|
|
835
|
-
withTempDir((dir) => {
|
|
836
|
-
seen = dir;
|
|
837
|
-
throw boom;
|
|
838
|
-
}),
|
|
839
|
-
).rejects.toThrow(boom);
|
|
840
|
-
await expect(stat(seen ?? "")).rejects.toThrow();
|
|
841
|
-
});
|
|
842
|
-
|
|
843
|
-
test("materializeUpload writes the stored bytes to a path, in order", async () => {
|
|
844
|
-
// The upload store is the seam that makes this testable at all: `readUpload`
|
|
845
|
-
// reads a process-wide slot rather than dialling anything.
|
|
846
|
-
const bytes = new Uint8Array(4096).map((_, at) => at % 251);
|
|
847
|
-
restores.push(stubUploads({ [UPLOAD_ID]: { bytes, name: "call.m4a" } }));
|
|
848
|
-
|
|
849
|
-
await withTempDir(async (dir) => {
|
|
850
|
-
const path = join(dir, "source");
|
|
851
|
-
// A small window, so the loop really runs four times — at the real 8 MiB one
|
|
852
|
-
// a 4 KB upload would exercise a single pass and prove nothing about it.
|
|
853
|
-
await materializeUpload(UPLOAD_ID, bytes.byteLength, path, 1024);
|
|
854
|
-
expect(new Uint8Array(await readFile(path))).toEqual(bytes);
|
|
855
|
-
});
|
|
856
|
-
});
|
|
857
|
-
|
|
858
|
-
test("an empty upload materializes an empty file rather than failing", async () => {
|
|
859
|
-
restores.push(stubUploads({ [UPLOAD_ID]: { bytes: new Uint8Array(0) } }));
|
|
860
|
-
await withTempDir(async (dir) => {
|
|
861
|
-
const path = join(dir, "source");
|
|
862
|
-
await materializeUpload(UPLOAD_ID, 0, path);
|
|
863
|
-
expect((await stat(path)).size).toBe(0);
|
|
864
|
-
});
|
|
865
|
-
});
|
|
866
|
-
|
|
867
|
-
test("fileChunks yields the file's real bytes, not a reused buffer", async () => {
|
|
868
|
-
// The aliasing bug, and the reason these specs touch a disk. Verified to CATCH
|
|
869
|
-
// it: deleting the `.slice()` in `fileChunks` fails this test.
|
|
870
|
-
await withTempDir(async (dir) => {
|
|
871
|
-
const path = join(dir, "audio.pcm");
|
|
872
|
-
const bytes = new Uint8Array(5000).map((_, at) => (at * 7) % 251);
|
|
873
|
-
await writeFile(path, bytes);
|
|
874
|
-
|
|
875
|
-
// A 1 KB window over 5 KB of bytes, so there are five reads through ONE
|
|
876
|
-
// buffer. That is what makes the aliasing reachable; collecting the chunks and
|
|
877
|
-
// concatenating afterwards is what makes it visible, since every earlier chunk
|
|
878
|
-
// would then read as the last one.
|
|
879
|
-
const collected: Uint8Array[] = [];
|
|
880
|
-
for await (const chunk of fileChunks(path, 1024)) collected.push(chunk);
|
|
881
|
-
|
|
882
|
-
const rejoined = new Uint8Array(collected.reduce((n, c) => n + c.byteLength, 0));
|
|
883
|
-
let at = 0;
|
|
884
|
-
for (const chunk of collected) {
|
|
885
|
-
rejoined.set(chunk, at);
|
|
886
|
-
at += chunk.byteLength;
|
|
887
|
-
}
|
|
888
|
-
expect(rejoined).toEqual(bytes);
|
|
889
|
-
});
|
|
890
|
-
});
|
|
891
|
-
|
|
892
|
-
test("fileChunks over an empty file yields nothing rather than one empty chunk", async () => {
|
|
893
|
-
await withTempDir(async (dir) => {
|
|
894
|
-
const path = join(dir, "empty");
|
|
895
|
-
await writeFile(path, new Uint8Array(0));
|
|
896
|
-
const collected: Uint8Array[] = [];
|
|
897
|
-
for await (const chunk of fileChunks(path)) collected.push(chunk);
|
|
898
|
-
expect(collected).toEqual([]);
|
|
899
|
-
});
|
|
900
|
-
});
|
|
901
|
-
});
|
|
902
|
-
|
|
903
756
|
describe("the ffmpeg steps, up to the spawn", () => {
|
|
904
757
|
/**
|
|
905
758
|
* These reach the point where ffmpeg would run and stop there, DETERMINISTICALLY
|
|
@@ -924,12 +777,10 @@ describe("the ffmpeg steps, up to the spawn", () => {
|
|
|
924
777
|
});
|
|
925
778
|
|
|
926
779
|
test("ingestRecording materializes the upload, then fails fatally with no ffprobe", async () => {
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
stubReporter().restore,
|
|
932
|
-
);
|
|
780
|
+
installStubUploads({
|
|
781
|
+
[UPLOAD_ID]: { bytes: new Uint8Array(2048), name: "call.m4a", type: "audio/mp4" },
|
|
782
|
+
});
|
|
783
|
+
installStubReporter();
|
|
933
784
|
// Fatal, not retryable: four more attempts find the same missing binary, and the
|
|
934
785
|
// message already carries the install instructions.
|
|
935
786
|
await expect(ingestRecording(UPLOAD_ID)).rejects.toBeInstanceOf(FatalError);
|
|
@@ -941,8 +792,9 @@ describe("the ffmpeg steps, up to the spawn", () => {
|
|
|
941
792
|
// `stepSpeak` runs — the synthesis is not what is broken here — so this also
|
|
942
793
|
// pins the ORDER: a step that mastered before speaking would fail without ever
|
|
943
794
|
// calling the voice service.
|
|
944
|
-
const speech =
|
|
945
|
-
|
|
795
|
+
const speech = installStubSpeech();
|
|
796
|
+
installStubReporter();
|
|
797
|
+
installStubUploads({}, { writable: true });
|
|
946
798
|
|
|
947
799
|
await expect(narrate("Read this back.", "jane")).rejects.toBeInstanceOf(FatalError);
|
|
948
800
|
expect(speech.calls.length).toBe(1);
|
|
@@ -953,8 +805,9 @@ describe("the ffmpeg steps, up to the spawn", () => {
|
|
|
953
805
|
// The `finally` in `withTempDir`, on the path that matters: a guest's disk is
|
|
954
806
|
// small, and a step that leaked a directory per failed run would fill it.
|
|
955
807
|
const before = await readdir(tmpdir());
|
|
956
|
-
|
|
957
|
-
|
|
808
|
+
installStubSpeech();
|
|
809
|
+
installStubReporter();
|
|
810
|
+
installStubUploads({}, { writable: true });
|
|
958
811
|
|
|
959
812
|
await expect(narrate("Read this back.")).rejects.toBeInstanceOf(FatalError);
|
|
960
813
|
const after = await readdir(tmpdir());
|
|
@@ -86,8 +86,9 @@
|
|
|
86
86
|
|
|
87
87
|
import { workflow, workflowApp } from "@alexkroman1/aai";
|
|
88
88
|
import { ASSEMBLYAI_TTS_DEFAULT_VOICE, ASSEMBLYAI_TTS_VOICES } from "@alexkroman1/aai/tts";
|
|
89
|
+
import type { WorkflowDef } from "@alexkroman1/aai/workflow-api";
|
|
89
90
|
import { z } from "zod";
|
|
90
|
-
import { auditFlow } from "./workflows/audit.ts";
|
|
91
|
+
import { auditFlow, type CallAudit } from "./workflows/audit.ts";
|
|
91
92
|
|
|
92
93
|
/**
|
|
93
94
|
* The voices the form offers.
|
|
@@ -112,31 +113,43 @@ const VOICES = Object.entries(ASSEMBLYAI_TTS_VOICES)
|
|
|
112
113
|
*/
|
|
113
114
|
const [FIRST_VOICE = ASSEMBLYAI_TTS_DEFAULT_VOICE, ...OTHER_VOICES] = VOICES;
|
|
114
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
|
+
|
|
115
142
|
/**
|
|
116
143
|
* The declaration: schema, description, and the directive body.
|
|
117
144
|
*
|
|
118
145
|
* Exported so `WorkflowOutputOf<typeof audit>` names the output type in one place —
|
|
119
146
|
* including from `client.tsx`, where `import type` is erased and so bundles nothing
|
|
120
|
-
* server-side.
|
|
147
|
+
* server-side — and so `workflows/audit.ts` can name `WorkflowInputOf<typeof audit>`
|
|
148
|
+
* for the body's parameter.
|
|
121
149
|
*/
|
|
122
|
-
export const audit = workflow({
|
|
150
|
+
export const audit: WorkflowDef<typeof auditInput, CallAudit> = workflow({
|
|
123
151
|
description: "Level a call recording, transcribe it at its pauses, and audit what was said",
|
|
124
|
-
input:
|
|
125
|
-
// A plain string, because an upload id is what the run really receives. What
|
|
126
|
-
// makes it a file picker rather than a text box is the `uploads` line below.
|
|
127
|
-
//
|
|
128
|
-
// "Anything" is not marketing: the first step hands the file to ffmpeg, so the
|
|
129
|
-
// accepted set is ffmpeg's rather than this template's — a video's audio track
|
|
130
|
-
// included, since the conversion drops the video.
|
|
131
|
-
recording: z.string().describe("Any recording — WAV, MP3, M4A, or a video's audio track"),
|
|
132
|
-
// An enum, so the form renders a SELECT rather than a text box — which is the
|
|
133
|
-
// whole reason the list is derived above rather than left free-form. Optional,
|
|
134
|
-
// so the SDK's own default voice applies when nobody chooses.
|
|
135
|
-
voice: z
|
|
136
|
-
.enum([FIRST_VOICE, ...OTHER_VOICES])
|
|
137
|
-
.optional()
|
|
138
|
-
.describe("Voice to read the audit in"),
|
|
139
|
-
}),
|
|
152
|
+
input: auditInput,
|
|
140
153
|
// The one line that makes the form take a file: `<WorkflowFields>` renders a
|
|
141
154
|
// picker for this property, `useWorkflowSubmit` stores the chosen file, and the
|
|
142
155
|
// ingest step reads it back with `readUpload`.
|
|
@@ -13,30 +13,34 @@
|
|
|
13
13
|
* cut it in the pauses or fell back to cutting by arithmetic, and that difference
|
|
14
14
|
* is exactly what explains a mangled word at a seam. So `blindCuts` is rendered
|
|
15
15
|
* rather than hidden, alongside what the recording measured before levelling.
|
|
16
|
-
* - **`
|
|
16
|
+
* - **`useDownloadUrl`, not a URL.** The run's output carries an upload id. The
|
|
17
17
|
* obvious `<audio src={`/workflows/uploads/${id}`}>` is wrong in a way that only
|
|
18
18
|
* shows up after a deploy: the byte route takes the same `Authorization` header
|
|
19
19
|
* every other route does, and neither `<audio src>` nor `<a href>` can send one.
|
|
20
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.
|
|
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.
|
|
22
25
|
*/
|
|
23
26
|
|
|
24
27
|
import "@alexkroman1/aai-ui/styles.css";
|
|
25
28
|
// ERASED at build time, so naming the agent's own type costs the browser bundle
|
|
26
29
|
// nothing — and it is what stops this file restating a shape `workflows/audit.ts`
|
|
27
30
|
// already declares.
|
|
28
|
-
import
|
|
31
|
+
import { formatBytes, formatDuration } from "@alexkroman1/aai/utils";
|
|
32
|
+
import type { WorkflowOutputOf } from "@alexkroman1/aai/workflow-api";
|
|
29
33
|
import {
|
|
30
34
|
createWorkflowApi,
|
|
31
35
|
Form,
|
|
32
36
|
page,
|
|
33
37
|
SubmitButton,
|
|
34
38
|
UploadProgressBar,
|
|
39
|
+
useDownloadUrl,
|
|
35
40
|
useWorkflowSubmit,
|
|
36
41
|
WorkflowFields,
|
|
37
42
|
WorkflowProgress,
|
|
38
43
|
} from "@alexkroman1/aai-ui";
|
|
39
|
-
import { useEffect, useState } from "react";
|
|
40
44
|
import type { audit } from "./agent.ts";
|
|
41
45
|
|
|
42
46
|
/** What a completed run reports, derived from the workflow rather than restated. */
|
|
@@ -59,51 +63,6 @@ const WORKFLOW = "audit";
|
|
|
59
63
|
*/
|
|
60
64
|
const api = createWorkflowApi();
|
|
61
65
|
|
|
62
|
-
/** `4:09`, from the milliseconds a run reports. */
|
|
63
|
-
function duration(ms: number): string {
|
|
64
|
-
const total = Math.round(ms / 1000);
|
|
65
|
-
const minutes = Math.floor(total / 60);
|
|
66
|
-
return `${minutes}:${String(total % 60).padStart(2, "0")}`;
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
/**
|
|
70
|
-
* The finished run's audio, as something the browser will play.
|
|
71
|
-
*
|
|
72
|
-
* A hook rather than four lines in the component because the CLEANUP is the part
|
|
73
|
-
* worth keeping in one place: an object URL pins its blob for the life of the
|
|
74
|
-
* document, so it is revoked when the id changes and when the page goes away. The
|
|
75
|
-
* `cancelled` flag covers the other half — a second run settling while the first
|
|
76
|
-
* download is still in flight would otherwise set state from the stale one.
|
|
77
|
-
*/
|
|
78
|
-
function useAudioUrl(uploadId: string | undefined): { url?: string; error?: string } {
|
|
79
|
-
const [state, setState] = useState<{ url?: string; error?: string }>({});
|
|
80
|
-
|
|
81
|
-
useEffect(() => {
|
|
82
|
-
if (uploadId === undefined) {
|
|
83
|
-
setState({});
|
|
84
|
-
return;
|
|
85
|
-
}
|
|
86
|
-
let cancelled = false;
|
|
87
|
-
let objectUrl: string | undefined;
|
|
88
|
-
api
|
|
89
|
-
.download(uploadId)
|
|
90
|
-
.then((blob) => {
|
|
91
|
-
if (cancelled) return;
|
|
92
|
-
objectUrl = URL.createObjectURL(blob);
|
|
93
|
-
setState({ url: objectUrl });
|
|
94
|
-
})
|
|
95
|
-
.catch((err: unknown) => {
|
|
96
|
-
if (!cancelled) setState({ error: err instanceof Error ? err.message : String(err) });
|
|
97
|
-
});
|
|
98
|
-
return () => {
|
|
99
|
-
cancelled = true;
|
|
100
|
-
if (objectUrl !== undefined) URL.revokeObjectURL(objectUrl);
|
|
101
|
-
};
|
|
102
|
-
}, [uploadId]);
|
|
103
|
-
|
|
104
|
-
return state;
|
|
105
|
-
}
|
|
106
|
-
|
|
107
66
|
/** One labelled number in the pipeline panel. */
|
|
108
67
|
function Stat({ label, value }: { label: string; value: string }) {
|
|
109
68
|
return (
|
|
@@ -135,7 +94,10 @@ export function App() {
|
|
|
135
94
|
const { submit, run, pending, upload, pauseUpload, resumeUpload, error } =
|
|
136
95
|
useWorkflowSubmit<Audit>(WORKFLOW, { api });
|
|
137
96
|
const output = run?.status === "completed" ? run.output : undefined;
|
|
138
|
-
|
|
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 });
|
|
139
101
|
|
|
140
102
|
return (
|
|
141
103
|
<main className="mx-auto flex max-w-3xl flex-col gap-6 p-8">
|
|
@@ -172,7 +134,7 @@ export function App() {
|
|
|
172
134
|
<div className="flex flex-col gap-1">
|
|
173
135
|
<h2 className="text-xl">{output.headline}</h2>
|
|
174
136
|
<p className="text-sm opacity-70">
|
|
175
|
-
{output.source} · {
|
|
137
|
+
{output.source} · {formatDuration(output.durationMs)} · {output.words} words
|
|
176
138
|
</p>
|
|
177
139
|
</div>
|
|
178
140
|
|
|
@@ -188,7 +150,7 @@ export function App() {
|
|
|
188
150
|
label="Cut in speech"
|
|
189
151
|
value={output.blindCuts === 0 ? "none" : String(output.blindCuts)}
|
|
190
152
|
/>
|
|
191
|
-
<Stat label="Run time" value={
|
|
153
|
+
<Stat label="Run time" value={formatDuration(output.elapsedMs)} />
|
|
192
154
|
</dl>
|
|
193
155
|
|
|
194
156
|
<Findings title="Risks" items={output.risks} />
|
|
@@ -196,9 +158,10 @@ export function App() {
|
|
|
196
158
|
|
|
197
159
|
<section className="flex flex-col gap-2">
|
|
198
160
|
<h3 className="text-sm font-medium opacity-70">
|
|
199
|
-
Read aloud · {
|
|
200
|
-
{
|
|
161
|
+
Read aloud · {formatDuration(output.audioDurationMs)} ·{" "}
|
|
162
|
+
{formatBytes(output.audioBytes)}
|
|
201
163
|
</h3>
|
|
164
|
+
{audio.pending && <p className="text-sm opacity-70">Fetching the audio…</p>}
|
|
202
165
|
{audio.error !== undefined && (
|
|
203
166
|
<p className="text-red-600">Could not load the audio: {audio.error}</p>
|
|
204
167
|
)}
|
|
@@ -45,11 +45,16 @@
|
|
|
45
45
|
* ingest result, once as the plan) and buy nothing.
|
|
46
46
|
*/
|
|
47
47
|
|
|
48
|
-
import { encodeWav, mapConcurrent, readUpload, report } from "@alexkroman1/aai/
|
|
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";
|
|
49
55
|
import { ingestRecording } from "./ingest.ts";
|
|
50
56
|
import {
|
|
51
57
|
ANALYSIS_FORMAT,
|
|
52
|
-
clock,
|
|
53
58
|
durationSeconds,
|
|
54
59
|
planSegments,
|
|
55
60
|
type Segment,
|
|
@@ -136,13 +141,7 @@ export type CallAudit = {
|
|
|
136
141
|
* The input is what `POST /workflows/runs` carries — see `agent.ts` for the schema
|
|
137
142
|
* it is validated against before a run exists.
|
|
138
143
|
*/
|
|
139
|
-
export async function auditFlow(input: {
|
|
140
|
-
recording: string;
|
|
141
|
-
// `| undefined` explicitly, not merely optional: `exactOptionalPropertyTypes` is
|
|
142
|
-
// on repo-wide, and what a zod `.optional()` infers is a property that may be
|
|
143
|
-
// PRESENT and undefined.
|
|
144
|
-
voice?: string | undefined;
|
|
145
|
-
}): Promise<CallAudit> {
|
|
144
|
+
export async function auditFlow(input: WorkflowInputOf<typeof audit>): Promise<CallAudit> {
|
|
146
145
|
"use workflow";
|
|
147
146
|
|
|
148
147
|
// Both at once: neither needs the other, and issued together they are one round
|
|
@@ -207,7 +206,7 @@ export async function auditFlow(input: {
|
|
|
207
206
|
* **`encodeWav` is what makes a byte range decodable.** The stored audio is
|
|
208
207
|
* headerless PCM, and the endpoint decodes each request independently, so a slice
|
|
209
208
|
* of it is meaningless bytes until a header says what they are. That header is the
|
|
210
|
-
* SDK's (`@alexkroman1/aai/
|
|
209
|
+
* SDK's (`@alexkroman1/aai/step`) rather than this template's: the equivalent
|
|
211
210
|
* function in `transcription-workflow` is 25 lines of `DataView` writes with a
|
|
212
211
|
* comment about which of the two declared lengths a decoder trusts, and there is
|
|
213
212
|
* no reason for a second copy of it to exist.
|
|
@@ -222,7 +221,7 @@ export async function transcribeSegment(audioId: string, segment: Segment): Prom
|
|
|
222
221
|
// ORDER is not guaranteed here and does not need to be — the calls go out
|
|
223
222
|
// together, so their lines interleave by completion, and `segment.index` is what
|
|
224
223
|
// puts the TRANSCRIPT back in order.
|
|
225
|
-
await report(`Transcribing ${
|
|
224
|
+
await report(`Transcribing ${formatDuration(segment.startMs)}–${formatDuration(segment.endMs)}.`);
|
|
226
225
|
|
|
227
226
|
// `[start, end)`, the same half-open pair `planSegments` produced — the store
|
|
228
227
|
// owns the conversion to HTTP's inclusive range, so there is no `- 1` here to get
|
|
@@ -231,7 +230,7 @@ export async function transcribeSegment(audioId: string, segment: Segment): Prom
|
|
|
231
230
|
const text = await transcribeSpan(
|
|
232
231
|
encodeWav(audio.bytes, ANALYSIS_FORMAT),
|
|
233
232
|
`segment-${segment.index}.wav`,
|
|
234
|
-
`Segment ${segment.index} (${
|
|
233
|
+
`Segment ${segment.index} (${formatDuration(segment.startMs)})`,
|
|
235
234
|
);
|
|
236
235
|
|
|
237
236
|
return { index: segment.index, text };
|
|
@@ -297,9 +296,3 @@ export function joinSegments(segments: readonly Segment[], parts: readonly Segme
|
|
|
297
296
|
}
|
|
298
297
|
return joined;
|
|
299
298
|
}
|
|
300
|
-
|
|
301
|
-
/** Words in a transcript, for the counts a page shows. */
|
|
302
|
-
export function countWords(text: string): number {
|
|
303
|
-
const trimmed = text.trim();
|
|
304
|
-
return trimmed.length === 0 ? 0 : trimmed.split(/\s+/).length;
|
|
305
|
-
}
|