@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
|
@@ -0,0 +1,474 @@
|
|
|
1
|
+
// Copyright 2026 the AAI authors. MIT license.
|
|
2
|
+
// An EVAL for a WORKFLOW APP: does the run actually do the work? Run it with
|
|
3
|
+
// `aai eval`.
|
|
4
|
+
//
|
|
5
|
+
// `agent.test.ts` covers everything this desk DECIDES — every ffmpeg argv, both
|
|
6
|
+
// analysis parsers, and where `planSegments` cuts — as pure functions, because
|
|
7
|
+
// `workflows/media.ts` exists so that it can. What it cannot do is spawn, so
|
|
8
|
+
// the one thing it never sees is whether ffmpeg AGREES: whether the argv this
|
|
9
|
+
// desk builds really produces the pauses it plans against, and whether a plan
|
|
10
|
+
// derived from those pauses really addresses audio that exists.
|
|
11
|
+
//
|
|
12
|
+
// That is what this file is for. **Every case here runs a real ffmpeg** — five
|
|
13
|
+
// invocations per run, over audio built sample by sample so the pauses in it
|
|
14
|
+
// are known — and then checks where the desk cut. The twelve-byte planning bug
|
|
15
|
+
// `durationSeconds` documents was found exactly this way, and it is the class of
|
|
16
|
+
// bug that cannot be found any other way.
|
|
17
|
+
//
|
|
18
|
+
// It therefore needs `ffmpeg` and `ffprobe` on `PATH` (or `AAI_FFMPEG_PATH` /
|
|
19
|
+
// `AAI_FFPROBE_PATH`), which is what the template's own doc says a developer
|
|
20
|
+
// needs for anything that is not already a WAV. Measured against ffmpeg 7.1.5.
|
|
21
|
+
// A missing binary fails these cases with the SDK's installable message, which
|
|
22
|
+
// is the same thing `aai dev` does on a laptop without one.
|
|
23
|
+
//
|
|
24
|
+
// `describeWorkflowEval` picks the providers for you and says which it picked:
|
|
25
|
+
//
|
|
26
|
+
// * with `ASSEMBLYAI_API_KEY` — a LIVE run. The `{ live: true }` case below
|
|
27
|
+
// downloads a real four-and-a-half-minute recording, levels it with a real
|
|
28
|
+
// ffmpeg, fans three real requests out at its real pauses, and asks a real
|
|
29
|
+
// model to audit what came back. That spends money and about a minute.
|
|
30
|
+
// * without one — a SCRIPTED run: the same ffmpeg, the same plan, the same
|
|
31
|
+
// seams, with the transcription endpoint and the model answered in memory.
|
|
32
|
+
//
|
|
33
|
+
// Three of the four cases are SCRIPTED IN BOTH MODES, deliberately. Their claims
|
|
34
|
+
// are about WHERE THE DESK CUT and what it did at the seam, and a live provider
|
|
35
|
+
// can neither confirm nor deny either — it can only transcribe whatever it is
|
|
36
|
+
// sent. The live case is the one that answers "is the transcript really of the
|
|
37
|
+
// recording, and is the audit really of the transcript".
|
|
38
|
+
//
|
|
39
|
+
// TWO THINGS ARE FAKED IN EVERY MODE, and both are harness limits rather than
|
|
40
|
+
// choices:
|
|
41
|
+
//
|
|
42
|
+
// * the UPLOAD STORE, because it is this app's own storage (a row and a blob)
|
|
43
|
+
// rather than a provider, and an eval has neither. `{ writable: true }` is
|
|
44
|
+
// not optional here: this desk WRITES two files.
|
|
45
|
+
// * the VOICE — see `speakATone` below, which carries both reasons.
|
|
46
|
+
//
|
|
47
|
+
// WHAT NO EVAL HERE COVERS: durability. Imported through vitest with no bundler
|
|
48
|
+
// in the path, a `"use workflow"` body is an ordinary async function — no
|
|
49
|
+
// journal, no replay, and no per-step retry, so the resume-after-segment-27
|
|
50
|
+
// property is NOT exercised and a rate-limited live run fails where a deployed
|
|
51
|
+
// one would have ridden it out. `aai-cli`'s `dev-workflow.scenario.test.ts` is
|
|
52
|
+
// the tier that really resumes a run.
|
|
53
|
+
import { spawnSync } from "node:child_process";
|
|
54
|
+
import { encodeWav } from "@alexkroman1/aai/step";
|
|
55
|
+
import { installStubTranscribe, installStubUploads } from "@alexkroman1/aai/testing/vitest";
|
|
56
|
+
import { describeWorkflowEval } from "@alexkroman1/aai-runtime/eval/vitest";
|
|
57
|
+
import { describe, expect, test } from "vitest";
|
|
58
|
+
import agentDef, { audit } from "./agent.ts";
|
|
59
|
+
import { ANALYSIS_FORMAT, BYTES_PER_SECOND, MAX_SEGMENT_SECONDS } from "./workflows/media.ts";
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Both binaries, or neither — every case here decodes, and three of the four
|
|
63
|
+
* read `ffprobe` back to check what the desk did.
|
|
64
|
+
*/
|
|
65
|
+
const HAVE_FFMPEG = ["ffmpeg", "ffprobe"].every(
|
|
66
|
+
(bin) => spawnSync(bin, ["-version"], { stdio: "ignore" }).status === 0,
|
|
67
|
+
);
|
|
68
|
+
|
|
69
|
+
const HOW_TO =
|
|
70
|
+
"Install ffmpeg (`apt-get install ffmpeg`, `brew install ffmpeg`) or point\n" +
|
|
71
|
+
"AAI_FFMPEG_PATH / AAI_FFPROBE_PATH at binaries. A deployed guest always has them.";
|
|
72
|
+
|
|
73
|
+
// Biome's `noSkippedTests` flags the `describe.skip(…)` CALL form, so the gated
|
|
74
|
+
// suite references it instead — exactly as `aai/host/ffmpeg.scenario.test.ts`
|
|
75
|
+
// and `_pg-test-utils.ts` do.
|
|
76
|
+
const skipSuite = describe.skip;
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* `describeWorkflowEval`, gated on a real ffmpeg — and the skip ANNOUNCES itself.
|
|
80
|
+
*
|
|
81
|
+
* This file used to call `describeWorkflowEval` directly, on the reasoning (still
|
|
82
|
+
* in the header above) that "a missing binary fails these cases with the SDK's
|
|
83
|
+
* installable message, which is the same thing `aai dev` does on a laptop without
|
|
84
|
+
* one". That is right for a laptop and wrong for a RUNNER: `check.yml` installs
|
|
85
|
+
* ffmpeg on the Linux leg only — deliberately, because the scenario suite it was
|
|
86
|
+
* added for is OS-independent and brew costs minutes — so when `check:eval` joined
|
|
87
|
+
* that same job, the macOS leg went permanently red on a binary nobody had decided
|
|
88
|
+
* it should have.
|
|
89
|
+
*
|
|
90
|
+
* So this follows `describeWithFfmpeg` (`aai/host/ffmpeg.scenario.test.ts`), which
|
|
91
|
+
* follows `describeWithPg`: skip loudly, and let **`AAI_REQUIRE_FFMPEG`** — which
|
|
92
|
+
* the Linux leg's install step sets — turn the skip into a hard failure, so a
|
|
93
|
+
* broken install step cannot read as a green run. The coverage is unchanged on
|
|
94
|
+
* every machine that has the binary, which includes all of CI's Linux legs.
|
|
95
|
+
*/
|
|
96
|
+
const describeWorkflowEvalWithFfmpeg: typeof describeWorkflowEval = (agent, define, options) => {
|
|
97
|
+
if (HAVE_FFMPEG) {
|
|
98
|
+
describeWorkflowEval(agent, define, options);
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
if ((process.env.AAI_REQUIRE_FFMPEG ?? "") !== "") {
|
|
102
|
+
throw new Error(`AAI_REQUIRE_FFMPEG is set but no ffmpeg was found.\n${HOW_TO}`);
|
|
103
|
+
}
|
|
104
|
+
console.warn(`\n[skipped: no ffmpeg] ${agent.name} eval not run.\n${HOW_TO}\n`);
|
|
105
|
+
skipSuite(agent.name, () => {
|
|
106
|
+
// Named `test`, not aliased: Biome's `noMisplacedAssertion` matches the
|
|
107
|
+
// CALLEE IDENTIFIER, so an `expect` inside a `vitestTest(…)` is an error.
|
|
108
|
+
// Same trap `describeEval`'s own signature documents.
|
|
109
|
+
test("needs a real ffmpeg", () => {
|
|
110
|
+
expect(HAVE_FFMPEG).toBe(true);
|
|
111
|
+
});
|
|
112
|
+
});
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
/** The id every case uploads the recording under. */
|
|
116
|
+
const UPLOAD_ID = "upl_eval";
|
|
117
|
+
|
|
118
|
+
/** The public sample recording — four and a half minutes of real speech. */
|
|
119
|
+
const LIVE_RECORDING = "https://assembly.ai/wildfires.mp3";
|
|
120
|
+
|
|
121
|
+
/** Long enough to force a cut: the desk's cap is 110 seconds. */
|
|
122
|
+
const OVER_THE_CAP_SECONDS = 130;
|
|
123
|
+
|
|
124
|
+
/** What the model is scripted to answer with. */
|
|
125
|
+
const REPLY = {
|
|
126
|
+
headline: "Renewal call with Northwind",
|
|
127
|
+
risks: ["Nobody owns the migration date"],
|
|
128
|
+
actions: ["Ana to send revised pricing by Friday"],
|
|
129
|
+
spoken:
|
|
130
|
+
"The renewal is close to done, but the migration date still has nobody's name on it. " +
|
|
131
|
+
"Ana is sending revised pricing before the end of the week.",
|
|
132
|
+
};
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* One span of a synthetic recording: `loud` is speech, quiet is a pause.
|
|
136
|
+
*
|
|
137
|
+
* A 220 Hz sine at about 40% of full scale, which is ~27 dB above the desk's
|
|
138
|
+
* −35 dB silence floor, so `silencedetect` reads it as speech; a run of zero
|
|
139
|
+
* samples is what it reads as a pause. Built sample by sample rather than
|
|
140
|
+
* captured, because the POINT is that the pauses are at second 60 and second 62
|
|
141
|
+
* and nowhere else — which is the only way "did it cut in the pause" has a
|
|
142
|
+
* right answer.
|
|
143
|
+
*/
|
|
144
|
+
type Span = { seconds: number; loud: boolean };
|
|
145
|
+
|
|
146
|
+
/** A 16 kHz mono 16-bit WAV of the spans, ready to hand ffmpeg. */
|
|
147
|
+
function recording(spans: readonly Span[]): Uint8Array {
|
|
148
|
+
const samples = spans.reduce((total, span) => total + Math.round(span.seconds * 16_000), 0);
|
|
149
|
+
const pcm = new Uint8Array(samples * 2);
|
|
150
|
+
const view = new DataView(pcm.buffer);
|
|
151
|
+
let at = 0;
|
|
152
|
+
for (const span of spans) {
|
|
153
|
+
const count = Math.round(span.seconds * 16_000);
|
|
154
|
+
for (let i = 0; i < count; i += 1, at += 1) {
|
|
155
|
+
// `setInt16(…, true)` rather than an `Int16Array`: little-endian is what
|
|
156
|
+
// `s16le` means, and a typed array would be whatever the platform is.
|
|
157
|
+
const value = span.loud
|
|
158
|
+
? Math.round(13_000 * Math.sin((2 * Math.PI * 220 * at) / 16_000))
|
|
159
|
+
: 0;
|
|
160
|
+
view.setInt16(at * 2, value, true);
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
return encodeWav(pcm, ANALYSIS_FORMAT);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* The synthesizer every case here runs on, live included — and it answers with a
|
|
168
|
+
* TONE rather than silence.
|
|
169
|
+
*
|
|
170
|
+
* Two reasons, and the second is a measurement:
|
|
171
|
+
*
|
|
172
|
+
* * `stepSpeak` reads a PUBLISHED synthesizer, the eval engine publishes none
|
|
173
|
+
* by default, and `@alexkroman1/aai-runtime` exports no real one to pass —
|
|
174
|
+
* so there is no arrangement in which the voice is real. The live case
|
|
175
|
+
* therefore claims nothing about audible audio.
|
|
176
|
+
* * it may not answer with SILENCE, which is what `installStubSpeech` does.
|
|
177
|
+
* `libmp3lame` ABORTS on pure digital silence — `Assertion failed:
|
|
178
|
+
* (el >= 0), psymodel.c:576`, exit 134 on ffmpeg 7.1.5 — so the mastering
|
|
179
|
+
* pass this template exists to demonstrate would fail on the fixture rather
|
|
180
|
+
* than on the code, and the failure would read as a broken template.
|
|
181
|
+
*/
|
|
182
|
+
const speakATone = (request: { text: string; sampleRate: number }): Promise<Uint8Array> => {
|
|
183
|
+
// A second of tone per twenty characters, so a longer script really is a
|
|
184
|
+
// longer file and `audioDurationMs` is a number that means something.
|
|
185
|
+
const samples = Math.max(1, Math.round((request.text.length / 20) * request.sampleRate));
|
|
186
|
+
const pcm = new Uint8Array(samples * 2);
|
|
187
|
+
const view = new DataView(pcm.buffer);
|
|
188
|
+
for (let at = 0; at < samples; at += 1) {
|
|
189
|
+
view.setInt16(
|
|
190
|
+
at * 2,
|
|
191
|
+
Math.round(9000 * Math.sin((2 * Math.PI * 180 * at) / request.sampleRate)),
|
|
192
|
+
true,
|
|
193
|
+
);
|
|
194
|
+
}
|
|
195
|
+
return Promise.resolve(pcm);
|
|
196
|
+
};
|
|
197
|
+
|
|
198
|
+
/** Publish this app's own store, writable because this desk writes two files. */
|
|
199
|
+
function publish(bytes: Uint8Array, name: string, type: string) {
|
|
200
|
+
return installStubUploads({ [UPLOAD_ID]: { bytes, name, type } }, { writable: true });
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* Answer the sync transcription endpoint and the model in memory.
|
|
205
|
+
*
|
|
206
|
+
* ONE fake, because publishing a `stepFetch` REPLACES — a flow that transcribes
|
|
207
|
+
* AND calls a model cannot install two, which is what `otherwise` is for. The
|
|
208
|
+
* transcription half is the SDK's own fake rather than this file's hand-typed
|
|
209
|
+
* wire: it routes off the SDK's endpoint constants, so a case cannot pass
|
|
210
|
+
* because the fake and the step agree on a typo.
|
|
211
|
+
*/
|
|
212
|
+
function scriptProvider(text: readonly string[]) {
|
|
213
|
+
return installStubTranscribe({
|
|
214
|
+
text,
|
|
215
|
+
otherwise: (request) =>
|
|
216
|
+
request.url.includes("/chat/completions")
|
|
217
|
+
? { body: { choices: [{ message: { content: JSON.stringify(REPLY) } }] } }
|
|
218
|
+
: undefined,
|
|
219
|
+
});
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
describeWorkflowEvalWithFfmpeg(
|
|
223
|
+
agentDef,
|
|
224
|
+
(test) => {
|
|
225
|
+
test("cuts where ffmpeg heard a PAUSE, and reads the seam as a paragraph", async ({ app }) => {
|
|
226
|
+
// The case this template exists for, and the one that cannot be written
|
|
227
|
+
// without a real decoder: a 2-second pause at second 60 of a 130-second
|
|
228
|
+
// recording is the only place a cut may land, and where it landed decides
|
|
229
|
+
// both the byte ranges and how the transcript reads at the seam.
|
|
230
|
+
const uploads = publish(
|
|
231
|
+
recording([
|
|
232
|
+
{ seconds: 60, loud: true },
|
|
233
|
+
{ seconds: 2, loud: false },
|
|
234
|
+
{ seconds: 68, loud: true },
|
|
235
|
+
]),
|
|
236
|
+
"call.wav",
|
|
237
|
+
"audio/wav",
|
|
238
|
+
);
|
|
239
|
+
const provider = scriptProvider([
|
|
240
|
+
"so that is the renewal settled",
|
|
241
|
+
"on the migration date nobody has committed yet",
|
|
242
|
+
]);
|
|
243
|
+
|
|
244
|
+
const run = await app.run(audit, { recording: UPLOAD_ID });
|
|
245
|
+
|
|
246
|
+
// The error FIRST, so a failed run names its own reason instead of
|
|
247
|
+
// reporting "expected 'failed' to be 'completed'".
|
|
248
|
+
expect(run.error).toBeUndefined();
|
|
249
|
+
expect(run.status).toBe("completed");
|
|
250
|
+
const output = run.output;
|
|
251
|
+
if (output === undefined) expect.fail("a completed run must carry an output");
|
|
252
|
+
|
|
253
|
+
// ffmpeg really ran: this is ffprobe's reading of the file, and the
|
|
254
|
+
// duration is the normalized PCM's own byte count rather than anything a
|
|
255
|
+
// container claimed.
|
|
256
|
+
expect(output.codec).toBe("pcm_s16le");
|
|
257
|
+
expect(output.durationMs).toBe(OVER_THE_CAP_SECONDS * 1000);
|
|
258
|
+
|
|
259
|
+
// TWO segments, and NEITHER is a blind cut — which is the whole claim.
|
|
260
|
+
// The desk found the pause, cut in the middle of it, and therefore owes
|
|
261
|
+
// no overlap, no seam matching and no de-duplication.
|
|
262
|
+
expect(output.segments).toBe(2);
|
|
263
|
+
expect(output.blindCuts).toBe(0);
|
|
264
|
+
|
|
265
|
+
// And the cut is at the pause's MIDPOINT, second 61 — not at 110, which
|
|
266
|
+
// is where a desk with no decoder would have had to cut.
|
|
267
|
+
const sync = provider.calls.filter((call) => call.leg === "sync");
|
|
268
|
+
expect(sync).toHaveLength(2);
|
|
269
|
+
const first = sync[0]?.body?.length ?? 0;
|
|
270
|
+
const second = sync[1]?.body?.length ?? 0;
|
|
271
|
+
// 61 seconds of audio plus a WAV header inside a multipart envelope.
|
|
272
|
+
expect(first).toBeGreaterThan(61 * BYTES_PER_SECOND);
|
|
273
|
+
expect(first).toBeLessThan(61 * BYTES_PER_SECOND + 2000);
|
|
274
|
+
// 69 seconds — and together they are the whole recording, exactly once.
|
|
275
|
+
expect(second).toBeGreaterThan(69 * BYTES_PER_SECOND);
|
|
276
|
+
expect(second).toBeLessThan(69 * BYTES_PER_SECOND + 2000);
|
|
277
|
+
|
|
278
|
+
// A cut placed in a pause is a turn boundary, so the seam is a PARAGRAPH
|
|
279
|
+
// break. This is the one place `cutInSpeech` changes an output rather
|
|
280
|
+
// than a report, and the assertion that pairs with the next case.
|
|
281
|
+
expect(output.transcript).toBe(
|
|
282
|
+
"so that is the renewal settled\n\non the migration date nobody has committed yet",
|
|
283
|
+
);
|
|
284
|
+
|
|
285
|
+
// 2 seconds of pause in 130 is 98% speech, measured by ffmpeg rather
|
|
286
|
+
// than assumed, and the loudness is a real measurement of real audio.
|
|
287
|
+
expect(output.speechPercent).toBe(98);
|
|
288
|
+
expect(output.loudnessBefore).toBeLessThan(0);
|
|
289
|
+
expect(output.loudnessBefore).toBeGreaterThan(-70);
|
|
290
|
+
|
|
291
|
+
// The narration says what it found, which is what makes a long run
|
|
292
|
+
// legible — and names the ONE pause it cut in.
|
|
293
|
+
expect(run.reported[0]).toMatch(/^Reading call\.wav \(/);
|
|
294
|
+
expect(run.reported).toContain("Levelling 2:10 of pcm_s16le to 16 kHz mono.");
|
|
295
|
+
expect(run.reported).toContain(
|
|
296
|
+
`Levelled 2:10 from ${output.loudnessBefore} LUFS, 98% speech across 1 pause.`,
|
|
297
|
+
);
|
|
298
|
+
expect(run.reported).toContain("Transcribing 0:00–1:01.");
|
|
299
|
+
expect(run.reported).toContain("Transcribing 1:01–2:10.");
|
|
300
|
+
|
|
301
|
+
// Two files written: the levelled PCM everything downstream addresses,
|
|
302
|
+
// and the mastered audit. Nothing durable was asked for.
|
|
303
|
+
expect(uploads.writes.map((one) => one.name)).toEqual(["call.pcm", "audit.mp3"]);
|
|
304
|
+
expect(run.slept).toEqual([]);
|
|
305
|
+
});
|
|
306
|
+
|
|
307
|
+
test("an unbroken monologue gets a BLIND cut, and says so", async ({ app }) => {
|
|
308
|
+
// The case the desk cannot serve, driven rather than argued: 130 seconds
|
|
309
|
+
// of unbroken speech has no pause to cut in, so it gets the arithmetic
|
|
310
|
+
// cut `transcription-workflow` always makes — at exactly the cap. Hiding
|
|
311
|
+
// that would leave a reader looking at a mangled word with no
|
|
312
|
+
// explanation, which is why `cutInSpeech` is counted and rendered.
|
|
313
|
+
publish(
|
|
314
|
+
recording([{ seconds: OVER_THE_CAP_SECONDS, loud: true }]),
|
|
315
|
+
"monologue.wav",
|
|
316
|
+
"audio/wav",
|
|
317
|
+
);
|
|
318
|
+
const provider = scriptProvider(["and then the second thing we agreed", "was the pricing"]);
|
|
319
|
+
|
|
320
|
+
const run = await app.run(audit, { recording: UPLOAD_ID });
|
|
321
|
+
|
|
322
|
+
expect(run.error).toBeUndefined();
|
|
323
|
+
const output = run.output;
|
|
324
|
+
if (output === undefined) expect.fail("a completed run must carry an output");
|
|
325
|
+
|
|
326
|
+
expect(output.segments).toBe(2);
|
|
327
|
+
// ONE blind cut — the first segment's end. The recording's own end is
|
|
328
|
+
// not a cut this planner invented, so it is not counted.
|
|
329
|
+
expect(output.blindCuts).toBe(1);
|
|
330
|
+
// 100% speech, because ffmpeg found no pause at all.
|
|
331
|
+
expect(output.speechPercent).toBe(100);
|
|
332
|
+
expect(run.reported).toContain(
|
|
333
|
+
`Levelled 2:10 from ${output.loudnessBefore} LUFS, 100% speech across 0 pauses.`,
|
|
334
|
+
);
|
|
335
|
+
|
|
336
|
+
// The cut is at the cap exactly, which is what "no candidate in range"
|
|
337
|
+
// means — and the first request is therefore 110 seconds of audio.
|
|
338
|
+
expect(run.reported).toContain("Transcribing 0:00–1:50.");
|
|
339
|
+
const sync = provider.calls.filter((call) => call.leg === "sync");
|
|
340
|
+
const first = sync[0]?.body?.length ?? 0;
|
|
341
|
+
expect(first).toBeGreaterThan(MAX_SEGMENT_SECONDS * BYTES_PER_SECOND);
|
|
342
|
+
expect(first).toBeLessThan(MAX_SEGMENT_SECONDS * BYTES_PER_SECOND + 2000);
|
|
343
|
+
|
|
344
|
+
// A blind cut landed MID-SENTENCE, so the seam is a space rather than a
|
|
345
|
+
// paragraph break. The pair with the previous case is the point: the same
|
|
346
|
+
// recording length, the same two segments, a different reading.
|
|
347
|
+
expect(output.transcript).toBe("and then the second thing we agreed was the pricing");
|
|
348
|
+
});
|
|
349
|
+
|
|
350
|
+
test("the audit comes back as a real MP3 that the run's output NAMES", async ({ app }) => {
|
|
351
|
+
// The way OUT, which is the second thing having a decoder buys: a step
|
|
352
|
+
// speaks, ffmpeg masters what it said, and the run's output carries an
|
|
353
|
+
// upload ID rather than the bytes. A short recording, because what this
|
|
354
|
+
// case is about is the last invocation rather than the fan-out.
|
|
355
|
+
const uploads = publish(recording([{ seconds: 5, loud: true }]), "quick.wav", "audio/wav");
|
|
356
|
+
scriptProvider(["short and to the point"]);
|
|
357
|
+
|
|
358
|
+
const run = await app.run(audit, { recording: UPLOAD_ID });
|
|
359
|
+
|
|
360
|
+
expect(run.error).toBeUndefined();
|
|
361
|
+
const output = run.output;
|
|
362
|
+
if (output === undefined) expect.fail("a completed run must carry an output");
|
|
363
|
+
// Under the cap, so no cut at all.
|
|
364
|
+
expect(output.segments).toBe(1);
|
|
365
|
+
expect(output.blindCuts).toBe(0);
|
|
366
|
+
|
|
367
|
+
// TWO writes, in this order, and the output names the SECOND. A run that
|
|
368
|
+
// returned the levelled PCM's id would be handing a page a headerless
|
|
369
|
+
// file nothing can play.
|
|
370
|
+
expect(uploads.writes).toHaveLength(2);
|
|
371
|
+
const [pcm, mp3] = uploads.writes;
|
|
372
|
+
if (pcm === undefined || mp3 === undefined) expect.fail("both files must be written");
|
|
373
|
+
// Named after the recording, and typed HONESTLY: raw samples with no
|
|
374
|
+
// container, so not `audio/wav` and not `audio/L16` either.
|
|
375
|
+
expect(pcm.name).toBe("quick.pcm");
|
|
376
|
+
expect(pcm.type).toBe("application/octet-stream");
|
|
377
|
+
expect(pcm.bytes.byteLength).toBe(5 * BYTES_PER_SECOND);
|
|
378
|
+
expect(output.audio).toBe(mp3.id);
|
|
379
|
+
expect(mp3.name).toBe("audit.mp3");
|
|
380
|
+
// Typed for the browser: the byte route serves what it was given, and
|
|
381
|
+
// nothing plays inline a file handed over as octet-stream.
|
|
382
|
+
expect(mp3.type).toBe("audio/mpeg");
|
|
383
|
+
|
|
384
|
+
// And it is really an MP3, which is a claim about ffmpeg's output rather
|
|
385
|
+
// than about our naming: an MPEG audio frame begins with eleven set bits,
|
|
386
|
+
// or the file opens with an ID3 tag.
|
|
387
|
+
const head = mp3.bytes.subarray(0, 3);
|
|
388
|
+
const framed = head[0] === 0xff && ((head[1] ?? 0) & 0xe0) === 0xe0;
|
|
389
|
+
const tagged = String.fromCharCode(...head) === "ID3";
|
|
390
|
+
expect(framed || tagged).toBe(true);
|
|
391
|
+
expect(output.audioBytes).toBe(mp3.bytes.byteLength);
|
|
392
|
+
expect(output.audioBytes).toBeGreaterThan(0);
|
|
393
|
+
// The compression is the reason the pass exists: the WAV that went in is
|
|
394
|
+
// 2 bytes per sample at 24 kHz, and the MP3 is a fraction of it.
|
|
395
|
+
const wavBytes = 44 + (output.audioDurationMs / 1000) * 24_000 * 2;
|
|
396
|
+
expect(output.audioBytes).toBeLessThan(wavBytes / 4);
|
|
397
|
+
// The run says both numbers, which is what makes the trade visible.
|
|
398
|
+
expect(run.reported.at(-1)).toMatch(
|
|
399
|
+
/^Recorded a \d+s audit in \w+'s voice — .* of MP3, from .* of WAV\.$/,
|
|
400
|
+
);
|
|
401
|
+
|
|
402
|
+
// The model really read the transcript, and the audit really reached the
|
|
403
|
+
// output — including the empty-array case being an ANSWER.
|
|
404
|
+
expect(output.headline).toBe(REPLY.headline);
|
|
405
|
+
expect(output.risks).toEqual(REPLY.risks);
|
|
406
|
+
expect(output.spoken).toBe(REPLY.spoken);
|
|
407
|
+
// The RUN's own clock, from two journaled steps rather than a body-level
|
|
408
|
+
// `Date.now()` a replay would re-read.
|
|
409
|
+
expect(output.elapsedMs).toBeGreaterThanOrEqual(0);
|
|
410
|
+
});
|
|
411
|
+
|
|
412
|
+
test(
|
|
413
|
+
"really levels, cuts and audits a real call recording",
|
|
414
|
+
async ({ app }) => {
|
|
415
|
+
// LIVE ONLY, and the case that earns the template its name. Four things
|
|
416
|
+
// have to be real: ffmpeg has to decode an MP3 nobody here made, the
|
|
417
|
+
// pauses it finds have to cover the whole recording, every segment's
|
|
418
|
+
// request has to come back with the words that are in it, and the model
|
|
419
|
+
// has to audit what came back.
|
|
420
|
+
const response = await fetch(LIVE_RECORDING);
|
|
421
|
+
expect(response.ok).toBe(true);
|
|
422
|
+
const mp3 = new Uint8Array(await response.arrayBuffer());
|
|
423
|
+
const uploads = publish(mp3, "wildfires.mp3", "audio/mpeg");
|
|
424
|
+
|
|
425
|
+
const run = await app.run(audit, { recording: UPLOAD_ID });
|
|
426
|
+
|
|
427
|
+
expect(run.error).toBeUndefined();
|
|
428
|
+
expect(run.status).toBe("completed");
|
|
429
|
+
const output = run.output;
|
|
430
|
+
if (output === undefined) expect.fail("a completed run must carry an output");
|
|
431
|
+
|
|
432
|
+
// ffprobe read the container it really was, and the FILENAME a reader
|
|
433
|
+
// sees is the one they uploaded rather than the converted artifact's.
|
|
434
|
+
expect(output.codec).toBe("mp3");
|
|
435
|
+
expect(output.source).toBe("wildfires.mp3");
|
|
436
|
+
expect(output.durationMs).toBeGreaterThan(250_000);
|
|
437
|
+
|
|
438
|
+
// Four and a half minutes against a 110-second cap is at least three
|
|
439
|
+
// segments, and real speech has pauses — so the cuts should be in them.
|
|
440
|
+
expect(output.segments).toBeGreaterThanOrEqual(3);
|
|
441
|
+
expect(output.blindCuts).toBe(0);
|
|
442
|
+
// A real recording is mostly speech and not all of it.
|
|
443
|
+
expect(output.speechPercent).toBeGreaterThan(50);
|
|
444
|
+
expect(output.speechPercent).toBeLessThan(100);
|
|
445
|
+
expect(output.loudnessBefore).toBeLessThan(0);
|
|
446
|
+
|
|
447
|
+
// Every segment came back with words, which is how "the whole recording
|
|
448
|
+
// was covered" is checked rather than assumed: a plan that ran past the
|
|
449
|
+
// end would leave a silent tail segment and a short transcript.
|
|
450
|
+
expect(output.words).toBeGreaterThan(400);
|
|
451
|
+
expect(output.transcript).toMatch(/wildfire/i);
|
|
452
|
+
expect(output.transcript).toMatch(/canada/i);
|
|
453
|
+
expect(output.transcript).toMatch(/air quality/i);
|
|
454
|
+
|
|
455
|
+
// The audit is of THIS transcript, and the spoken half is a SCRIPT
|
|
456
|
+
// rather than a list — the template's central prompt decision,
|
|
457
|
+
// measured against a real model.
|
|
458
|
+
expect(output.headline.length).toBeGreaterThan(0);
|
|
459
|
+
expect(`${output.headline} ${output.spoken}`).toMatch(/smoke|wildfire|air|health/i);
|
|
460
|
+
expect(output.risks.length).toBeLessThanOrEqual(4);
|
|
461
|
+
expect(output.actions.length).toBeLessThanOrEqual(4);
|
|
462
|
+
expect(output.spoken).toMatch(/[.!?]/);
|
|
463
|
+
expect(output.spoken).not.toMatch(/^\s*[-*•]/m);
|
|
464
|
+
|
|
465
|
+
// And the mastering pass ran on what the voice produced.
|
|
466
|
+
expect(uploads.writes.map((one) => one.name)).toEqual(["wildfires.pcm", "audit.mp3"]);
|
|
467
|
+
expect(uploads.read(output.audio)?.type).toBe("audio/mpeg");
|
|
468
|
+
expect(output.audioBytes).toBeGreaterThan(0);
|
|
469
|
+
},
|
|
470
|
+
{ live: true },
|
|
471
|
+
);
|
|
472
|
+
},
|
|
473
|
+
{ speech: speakATone },
|
|
474
|
+
);
|