Skip to content

Commit 4cd7927

Browse files
committed
Add --llm/--llm-reduce to eval: LLM map + reduce alongside WER
`assembly eval` now takes the same LLM-Gateway flags as `transcribe`: - `--llm` (repeatable) runs a prompt chain over each transcribed hypothesis and attaches it under the row's `llm` key. The WER score still uses the raw transcript — the chain output rides along as extra and feeds the reduce. - `--llm-reduce` (repeatable) runs one chain over every item's result (last `--llm` output, else the transcript text) and adds a top-level `reduce` block to the single JSON payload (rendered as a section in human mode). Skips the billable call when there's nothing to aggregate. - `--model` / `--max-tokens` select the gateway model and token budget. https://claude.ai/code/session_0133Txdj2E5So6ZU1293W4px
1 parent 0bdec6c commit 4cd7927

6 files changed

Lines changed: 527 additions & 4 deletions

File tree

README.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -170,6 +170,14 @@ assembly init voice-agent && assembly deploy --prod
170170
assembly eval librispeech --speech-model universal-3-pro --limit 50
171171
```
172172

173+
Add `--llm` to run an LLM-Gateway chain over each transcript (the WER score still
174+
uses the raw transcript), and `--llm-reduce` to run one prompt over every item's
175+
result and summarize the errors across the whole run:
176+
177+
```sh
178+
assembly eval tedlium --limit 50 --llm-reduce "Summarize the common error patterns"
179+
```
180+
173181
## 📦 Installation
174182

175183
Requires Python 3.12+ (Homebrew brings its own; for pipx/uv see the `--python` hint below).

REFERENCE.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,3 +91,9 @@ output printed to stdout (the progress table is routed to stderr so stdout stays
9191
clean for piping). `--llm-reduce` is repeatable, each prompt running on the
9292
previous one's output; for a single source it extends the `--llm` chain over
9393
that transcript.
94+
95+
`assembly eval` takes the same `--llm`/`--llm-reduce` flags but emits a single
96+
JSON object (not NDJSON): `--llm` runs a chain over each transcript and attaches
97+
`{"model","steps"}` under the row's `llm` key (the WER score still uses the raw
98+
transcript), and `--llm-reduce` runs one prompt over every item's result and
99+
adds a top-level `reduce` (`{"model","prompts","output"}`) to the object.

aai_cli/commands/evaluate/__init__.py

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
from aai_cli.app.context import run_with_options
1414
from aai_cli.commands.evaluate import _exec as evaluate_exec
1515
from aai_cli.commands.evaluate._exec import EvalSpeechModel
16+
from aai_cli.core import llm
1617
from aai_cli.ui.help_text import examples_epilog
1718

1819
app = typer.Typer()
@@ -45,6 +46,10 @@
4546
"Evaluate non-English audio",
4647
"assembly eval commonvoice --subset fr --language-code fr",
4748
),
49+
(
50+
"Summarize error patterns across the set",
51+
'assembly eval tedlium --llm-reduce "Summarize the common error patterns"',
52+
),
4853
]
4954
),
5055
)
@@ -79,6 +84,34 @@ def evaluate(
7984
min=1,
8085
help="How many items to transcribe at once (sequential by default)",
8186
),
87+
llm_prompt: list[str] | None = typer.Option(
88+
None,
89+
"--llm",
90+
help="Transform each transcript through LLM Gateway before reporting (the WER "
91+
"score still uses the raw transcript). Repeatable: each prompt runs on the "
92+
"previous one's response, the first on the transcript.",
93+
rich_help_panel=help_panels.OPT_LLM,
94+
),
95+
llm_reduce: list[str] | None = typer.Option(
96+
None,
97+
"--llm-reduce",
98+
help="Run one LLM-Gateway prompt over every item's result (a reduce). "
99+
"Repeatable: each runs on the previous one's output.",
100+
rich_help_panel=help_panels.OPT_LLM,
101+
),
102+
model: str = typer.Option(
103+
llm.DEFAULT_MODEL,
104+
"--model",
105+
help="LLM Gateway model",
106+
rich_help_panel=help_panels.OPT_LLM,
107+
autocompletion=llm.complete_model,
108+
),
109+
max_tokens: int = typer.Option(
110+
llm.DEFAULT_MAX_TOKENS,
111+
"--max-tokens",
112+
help="Max tokens",
113+
rich_help_panel=help_panels.OPT_LLM,
114+
),
82115
json_out: bool = options.json_option("Output the rows and summary as one JSON object"),
83116
) -> None:
84117
"""Transcribe a dataset and score WER against its reference texts
@@ -99,6 +132,10 @@ def evaluate(
99132
(English; --subset fr etc. for its 98 other locales), voxpopuli
100133
(parliament speech), switchboard (phone calls), expresso (expressive
101134
speech), loquacious, and callhome (phone calls).
135+
136+
--llm runs an LLM-Gateway chain over each transcript (the WER score still
137+
uses the raw transcript); --llm-reduce then runs one prompt over every
138+
item's result to summarize patterns across the run.
102139
"""
103140
opts = evaluate_exec.EvalOptions(
104141
dataset=dataset,
@@ -110,5 +147,9 @@ def evaluate(
110147
speech_model=speech_model,
111148
language_code=language_code,
112149
concurrency=concurrency,
150+
llm_prompt=llm_prompt,
151+
llm_reduce=llm_reduce,
152+
model=model,
153+
max_tokens=max_tokens,
113154
)
114155
run_with_options(ctx, evaluate_exec.run_evaluate, opts, json=json_out)

aai_cli/commands/evaluate/_exec.py

Lines changed: 159 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -20,10 +20,12 @@
2020

2121
import assemblyai as aai
2222
from rich.console import RenderableType
23+
from rich.markup import escape
2324

2425
from aai_cli.app.context import AppState
2526
from aai_cli.commands.evaluate import _data as eval_data
2627
from aai_cli.core import client, jsonshape, wer
28+
from aai_cli.core import llm as gateway
2729
from aai_cli.core.errors import CLIError, NotAuthenticated
2830
from aai_cli.ui import output
2931

@@ -50,6 +52,31 @@ class EvalOptions:
5052
speech_model: EvalSpeechModel | None
5153
language_code: str | None
5254
concurrency: int
55+
llm_prompt: list[str] | None
56+
llm_reduce: list[str] | None
57+
model: str
58+
max_tokens: int
59+
60+
def llm_options(self) -> _LlmOptions:
61+
"""The ``--llm`` / ``--llm-reduce`` chain settings as plain data."""
62+
return _LlmOptions(
63+
prompts=list(self.llm_prompt or []),
64+
reduce_prompts=list(self.llm_reduce or []),
65+
model=self.model,
66+
max_tokens=self.max_tokens,
67+
)
68+
69+
70+
@dataclass(frozen=True)
71+
class _LlmOptions:
72+
"""The post-transcription LLM-Gateway transform: the per-item ``--llm`` chain
73+
(a *map*) and the across-items ``--llm-reduce`` chain (a *reduce*), plus the
74+
gateway model + token budget both run under."""
75+
76+
prompts: list[str]
77+
reduce_prompts: list[str]
78+
model: str
79+
max_tokens: int
5380

5481

5582
def _pct(value: object) -> str:
@@ -75,11 +102,16 @@ def _percentile(values: list[float], q: float) -> float:
75102

76103
@dataclass(frozen=True)
77104
class _ItemResult:
78-
"""One scored row: the emitted dict plus the score and latency kept for pooling."""
105+
"""One scored row: the emitted dict plus the score and latency kept for pooling.
106+
107+
``hypothesis`` is the transcript text (``None`` for a failed row) — kept so the
108+
optional ``--llm`` map / ``--llm-reduce`` reduce can run over it after scoring.
109+
"""
79110

80111
row: dict[str, object]
81112
words: wer.Score | None
82113
latency: float
114+
hypothesis: str | None = None
83115

84116

85117
def _failed_result(item: eval_data.EvalItem, err: CLIError, latency: float) -> _ItemResult:
@@ -94,15 +126,16 @@ def _failed_result(item: eval_data.EvalItem, err: CLIError, latency: float) -> _
94126
def _score_item(
95127
item: eval_data.EvalItem, transcript: aai.Transcript, latency: float
96128
) -> _ItemResult:
97-
words = wer.score(item.reference, str(transcript.text or ""))
129+
hypothesis = str(transcript.text or "")
130+
words = wer.score(item.reference, hypothesis)
98131
row: dict[str, object] = {
99132
"item": item.item_id,
100133
"words": words.words,
101134
"errors": words.errors,
102135
"wer": words.wer,
103136
"latency": latency,
104137
}
105-
return _ItemResult(row=row, words=words, latency=latency)
138+
return _ItemResult(row=row, words=words, latency=latency, hypothesis=hypothesis)
106139

107140

108141
def _pooled_metrics(results: list[_ItemResult]) -> dict[str, object]:
@@ -204,6 +237,87 @@ def _transcripts(
204237
)
205238

206239

240+
def _run_llm_map(
241+
api_key: str,
242+
results: list[_ItemResult],
243+
llm_opts: _LlmOptions,
244+
*,
245+
json_mode: bool,
246+
quiet: bool,
247+
) -> None:
248+
"""Run the ``--llm`` chain over each transcribed row and attach it under ``llm``.
249+
250+
A *map*: the chain runs over the row's transcript text (inline, like
251+
``stream --llm``) and lands as ``{"model", "steps"}`` on the row — the WER score
252+
is untouched. Failed rows have no transcript, so they're skipped.
253+
"""
254+
scored = [result for result in results if result.hypothesis is not None]
255+
with output.status(
256+
f"Running --llm over {len(scored)} transcripts…", json_mode=json_mode, quiet=quiet
257+
):
258+
for result in scored:
259+
steps = gateway.run_chain_steps(
260+
api_key,
261+
llm_opts.prompts,
262+
transcript_text=result.hypothesis,
263+
model=llm_opts.model,
264+
max_tokens=llm_opts.max_tokens,
265+
)
266+
result.row["llm"] = {"model": llm_opts.model, "steps": steps}
267+
268+
269+
def _reduce_input(result: _ItemResult) -> str:
270+
"""A row's contribution to the reduce: its last ``--llm`` output, else its transcript."""
271+
llm_data = jsonshape.as_mapping(result.row.get("llm"))
272+
if llm_data is not None:
273+
steps = jsonshape.mapping_list(llm_data.get("steps"))
274+
if steps:
275+
return str(steps[-1].get("output", "") or "")
276+
return result.hypothesis or ""
277+
278+
279+
def _gather_reduce_inputs(results: list[_ItemResult]) -> str:
280+
"""Concatenate every transcribed row's reduce input under an item header."""
281+
blocks: list[str] = []
282+
for result in results:
283+
if result.hypothesis is None:
284+
continue
285+
text = _reduce_input(result)
286+
if text:
287+
blocks.append(f"### Item: {result.row.get('item')}\n{text}")
288+
return "\n\n".join(blocks)
289+
290+
291+
def _run_reduce(
292+
api_key: str,
293+
results: list[_ItemResult],
294+
llm_opts: _LlmOptions,
295+
*,
296+
json_mode: bool,
297+
quiet: bool,
298+
) -> dict[str, object] | None:
299+
"""Run the ``--llm-reduce`` chain once over every row's result; the payload entry.
300+
301+
``None`` when there's nothing to aggregate (every row failed or transcribed to
302+
empty text) so the caller skips the (billable) gateway call and the payload key.
303+
"""
304+
combined = _gather_reduce_inputs(results)
305+
if not combined:
306+
output.emit_warning(
307+
"Nothing to reduce: no transcript text across items.", json_mode=json_mode
308+
)
309+
return None
310+
with output.status("Running --llm-reduce over all items…", json_mode=json_mode, quiet=quiet):
311+
result = gateway.run_chain(
312+
api_key,
313+
llm_opts.reduce_prompts,
314+
transcript_text=combined,
315+
model=llm_opts.model,
316+
max_tokens=llm_opts.max_tokens,
317+
)
318+
return {"model": llm_opts.model, "prompts": llm_opts.reduce_prompts, "output": result}
319+
320+
207321
def _payload(
208322
label: str, speech_model: EvalSpeechModel | None, results: list[_ItemResult]
209323
) -> dict[str, object]:
@@ -249,6 +363,36 @@ def _secs_cell(row: dict[str, object], key: str) -> str:
249363
return _secs(row[key]) if key in row else ""
250364

251365

366+
def _final_llm_output(row: dict[str, object]) -> str | None:
367+
"""A row's last ``--llm`` step output, or ``None`` when no chain ran on it."""
368+
llm_data = jsonshape.as_mapping(row.get("llm"))
369+
if llm_data is None:
370+
return None
371+
steps = jsonshape.mapping_list(llm_data.get("steps"))
372+
return str(steps[-1].get("output", "") or "") if steps else ""
373+
374+
375+
def _llm_block(payload: dict[str, object]) -> str | None:
376+
"""The per-item ``--llm`` outputs as a heading + one ``item: output`` line each,
377+
or ``None`` when no ``--llm`` chain ran."""
378+
lines: list[str] = []
379+
for row in jsonshape.mapping_list(payload.get("rows")):
380+
final = _final_llm_output(row)
381+
if final is not None:
382+
lines.append(f"{escape(str(row.get('item')))}: {escape(final)}")
383+
if not lines:
384+
return None
385+
return "\n".join([output.heading("--llm"), *lines])
386+
387+
388+
def _reduce_block(payload: dict[str, object]) -> str | None:
389+
"""The ``--llm-reduce`` aggregate as a heading + the output, or ``None`` when unset."""
390+
reduce = jsonshape.as_mapping(payload.get("reduce"))
391+
if reduce is None:
392+
return None
393+
return f"{output.heading('--llm-reduce')}\n{escape(str(reduce.get('output', '')))}"
394+
395+
252396
def _render(payload: dict[str, object]) -> RenderableType:
253397
has_wer = "wer" in payload
254398
has_failed = "failed" in payload
@@ -271,7 +415,11 @@ def _render(payload: dict[str, object]) -> RenderableType:
271415
table.add_row(*cells)
272416
model = payload.get("speech_model") or "default model"
273417
return output.stack(
274-
output.muted(f"{payload.get('dataset')} · {model}"), table, _summary(payload)
418+
output.muted(f"{payload.get('dataset')} · {model}"),
419+
table,
420+
_summary(payload),
421+
_llm_block(payload),
422+
_reduce_block(payload),
275423
)
276424

277425

@@ -310,7 +458,14 @@ def run_evaluate(opts: EvalOptions, state: AppState, *, json_mode: bool) -> None
310458
strict=True, # pragma: no mutate (defensive invariant; _transcripts returns one outcome per item)
311459
)
312460
]
461+
llm_opts = opts.llm_options()
462+
if llm_opts.prompts:
463+
_run_llm_map(api_key, results, llm_opts, json_mode=json_mode, quiet=state.quiet)
313464
payload = _payload(data.label, opts.speech_model, results)
465+
if llm_opts.reduce_prompts:
466+
reduce = _run_reduce(api_key, results, llm_opts, json_mode=json_mode, quiet=state.quiet)
467+
if reduce is not None:
468+
payload["reduce"] = reduce
314469
output.emit(payload, _render, json_mode=json_mode)
315470
failed = jsonshape.as_int(payload.get("failed"))
316471
if failed:

tests/__snapshots__/test_snapshots_help_run.ambr

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -496,6 +496,10 @@
496496
(parliament speech), switchboard (phone calls), expresso (expressive
497497
speech), loquacious, and callhome (phone calls).
498498

499+
--llm runs an LLM-Gateway chain over each transcript (the WER score still
500+
uses the raw transcript); --llm-reduce then runs one prompt over every
501+
item's result to summarize patterns across the run.
502+
499503
╭─ Arguments ──────────────────────────────────────────────────────────────────╮
500504
│ * dataset TEXT Hugging Face dataset id, or a local .csv/.jsonl │
501505
│ manifest with audio + text columns │
@@ -527,6 +531,19 @@
527531
│ object │
528532
│ --help Show this message and │
529533
│ exit. │
534+
╰──────────────────────────────────────────────────────────────────────────────╯
535+
╭─ LLM Transform ──────────────────────────────────────────────────────────────╮
536+
│ --llm TEXT Transform each transcript through LLM Gateway │
537+
│ before reporting (the WER score still uses the │
538+
│ raw transcript). Repeatable: each prompt runs │
539+
│ on the previous one's response, the first on │
540+
│ the transcript. │
541+
│ --llm-reduce TEXT Run one LLM-Gateway prompt over every item's │
542+
│ result (a reduce). Repeatable: each runs on the │
543+
│ previous one's output. │
544+
│ --model TEXT LLM Gateway model │
545+
│ [default: claude-haiku-4-5-20251001] │
546+
│ --max-tokens INTEGER Max tokens [default: 1000] │
530547
╰──────────────────────────────────────────────────────────────────────────────╯
531548

532549
Examples
@@ -538,6 +555,8 @@
538555
$ assembly eval librispeech --limit 50 --concurrency 4
539556
Evaluate non-English audio
540557
$ assembly eval commonvoice --subset fr --language-code fr
558+
Summarize error patterns across the set
559+
$ assembly eval tedlium --llm-reduce "Summarize the common error patterns"
541560

542561

543562

0 commit comments

Comments
 (0)