diff --git a/aai_cli/commands/evaluate/_exec.py b/aai_cli/commands/evaluate/_exec.py index 2984f6d4..d597efd9 100644 --- a/aai_cli/commands/evaluate/_exec.py +++ b/aai_cli/commands/evaluate/_exec.py @@ -19,11 +19,10 @@ from enum import StrEnum import assemblyai as aai -from rich.console import RenderableType -from rich.markup import escape from aai_cli.app.context import AppState from aai_cli.commands.evaluate import _data as eval_data +from aai_cli.commands.evaluate import _render from aai_cli.core import client, jsonshape, wer from aai_cli.core import llm as gateway from aai_cli.core.errors import CLIError, NotAuthenticated @@ -79,15 +78,6 @@ class _LlmOptions: max_tokens: int -def _pct(value: object) -> str: - return f"{jsonshape.as_float(value):.2%}" - - -def _secs(value: object) -> str: - """A latency in seconds, formatted for display.""" - return f"{jsonshape.as_float(value):.2f}s" - - def _percentile(values: list[float], q: float) -> float: """The q-quantile (q in [0, 1]) of ``values``, linearly interpolated between the two closest ranks (numpy's default method). ``values`` must be non-empty.""" @@ -334,95 +324,6 @@ def _payload( return payload -def _summary(payload: dict[str, object]) -> str: - parts: list[str] = [] - if "wer" in payload: - errors = jsonshape.as_int(payload.get("errors")) - noun = "error" if errors == 1 else "errors" - parts.append( - f"WER {_pct(payload.get('wer'))} ({errors} {noun} / {payload.get('words')} words)" - ) - if "latency_p50" in payload: - parts.append( - f"latency p50 {_secs(payload.get('latency_p50'))}" - f" · p90 {_secs(payload.get('latency_p90'))}" - ) - return output.heading(" ".join(parts)) - - -def _cell(row: dict[str, object], key: str) -> str: - """The row's value as table text — blank when absent (e.g. a failed row's scores).""" - return str(row[key]) if key in row else "" - - -def _pct_cell(row: dict[str, object], key: str) -> str: - return _pct(row[key]) if key in row else "" - - -def _secs_cell(row: dict[str, object], key: str) -> str: - return _secs(row[key]) if key in row else "" - - -def _final_llm_output(row: dict[str, object]) -> str | None: - """A row's last ``--llm`` step output, or ``None`` when no chain ran on it.""" - llm_data = jsonshape.as_mapping(row.get("llm")) - if llm_data is None: - return None - steps = jsonshape.mapping_list(llm_data.get("steps")) - return str(steps[-1].get("output", "") or "") if steps else "" - - -def _llm_block(payload: dict[str, object]) -> str | None: - """The per-item ``--llm`` outputs as a heading + one ``item: output`` line each, - or ``None`` when no ``--llm`` chain ran.""" - lines: list[str] = [] - for row in jsonshape.mapping_list(payload.get("rows")): - final = _final_llm_output(row) - if final is not None: - lines.append(f"{escape(str(row.get('item')))}: {escape(final)}") - if not lines: - return None - return "\n".join([output.heading("--llm"), *lines]) - - -def _reduce_block(payload: dict[str, object]) -> str | None: - """The ``--llm-reduce`` aggregate as a heading + the output, or ``None`` when unset.""" - reduce = jsonshape.as_mapping(payload.get("reduce")) - if reduce is None: - return None - return f"{output.heading('--llm-reduce')}\n{escape(str(reduce.get('output', '')))}" - - -def _render(payload: dict[str, object]) -> RenderableType: - has_wer = "wer" in payload - has_failed = "failed" in payload - has_latency = "latency_p50" in payload - columns = [ - "ITEM", - *(["WORDS", "ERRORS", "WER"] if has_wer else []), - *(["LATENCY"] if has_latency else []), - *(["ERROR"] if has_failed else []), - ] - table = output.data_table(*columns) - for row in jsonshape.mapping_list(payload.get("rows")): - cells = [str(row.get("item"))] - if has_wer: - cells += [_cell(row, "words"), _cell(row, "errors"), _pct_cell(row, "wer")] - if has_latency: - cells.append(_secs_cell(row, "latency")) - if has_failed: - cells.append(_cell(row, "error")) - table.add_row(*cells) - model = payload.get("speech_model") or "default model" - return output.stack( - output.muted(f"{payload.get('dataset')} · {model}"), - table, - _summary(payload), - _llm_block(payload), - _reduce_block(payload), - ) - - def _evaluate_one( dataset: str, api_key: str, opts: EvalOptions, state: AppState, *, json_mode: bool ) -> dict[str, object]: @@ -477,7 +378,7 @@ def run_evaluate(opts: EvalOptions, state: AppState, *, json_mode: bool) -> None total = 0 for dataset in opts.datasets: payload = _evaluate_one(dataset, api_key, opts, state, json_mode=json_mode) - output.emit(payload, _render, json_mode=json_mode) + output.emit(payload, _render.render, json_mode=json_mode) failed += jsonshape.as_int(payload.get("failed")) total += jsonshape.as_int(payload.get("items")) if failed: diff --git a/aai_cli/commands/evaluate/_render.py b/aai_cli/commands/evaluate/_render.py new file mode 100644 index 00000000..7f783c9c --- /dev/null +++ b/aai_cli/commands/evaluate/_render.py @@ -0,0 +1,113 @@ +"""Human-mode rendering for `assembly eval`: turn an emitted payload into a table. + +Split out of ``_exec`` so the scoring/orchestration path and the Rich rendering +stay in separate files. Every function here reads only the already-emitted payload +dict (plus its rows), so it shares no state with the run path — the `--json` +output is the payload verbatim, and this module is what `-o`/human mode renders. +""" + +from __future__ import annotations + +from rich.console import RenderableType +from rich.markup import escape + +from aai_cli.core import jsonshape +from aai_cli.ui import output + + +def _pct(value: object) -> str: + return f"{jsonshape.as_float(value):.2%}" + + +def _secs(value: object) -> str: + """A latency in seconds, formatted for display.""" + return f"{jsonshape.as_float(value):.2f}s" + + +def _summary(payload: dict[str, object]) -> str: + parts: list[str] = [] + if "wer" in payload: + errors = jsonshape.as_int(payload.get("errors")) + noun = "error" if errors == 1 else "errors" + parts.append( + f"WER {_pct(payload.get('wer'))} ({errors} {noun} / {payload.get('words')} words)" + ) + if "latency_p50" in payload: + parts.append( + f"latency p50 {_secs(payload.get('latency_p50'))}" + f" · p90 {_secs(payload.get('latency_p90'))}" + ) + return output.heading(" ".join(parts)) + + +def _cell(row: dict[str, object], key: str) -> str: + """The row's value as table text — blank when absent (e.g. a failed row's scores).""" + return str(row[key]) if key in row else "" + + +def _pct_cell(row: dict[str, object], key: str) -> str: + return _pct(row[key]) if key in row else "" + + +def _secs_cell(row: dict[str, object], key: str) -> str: + return _secs(row[key]) if key in row else "" + + +def _final_llm_output(row: dict[str, object]) -> str | None: + """A row's last ``--llm`` step output, or ``None`` when no chain ran on it.""" + llm_data = jsonshape.as_mapping(row.get("llm")) + if llm_data is None: + return None + steps = jsonshape.mapping_list(llm_data.get("steps")) + return str(steps[-1].get("output", "") or "") if steps else "" + + +def _llm_block(payload: dict[str, object]) -> str | None: + """The per-item ``--llm`` outputs as a heading + one ``item: output`` line each, + or ``None`` when no ``--llm`` chain ran.""" + lines: list[str] = [] + for row in jsonshape.mapping_list(payload.get("rows")): + final = _final_llm_output(row) + if final is not None: + lines.append(f"{escape(str(row.get('item')))}: {escape(final)}") + if not lines: + return None + return "\n".join([output.heading("--llm"), *lines]) + + +def _reduce_block(payload: dict[str, object]) -> str | None: + """The ``--llm-reduce`` aggregate as a heading + the output, or ``None`` when unset.""" + reduce = jsonshape.as_mapping(payload.get("reduce")) + if reduce is None: + return None + return f"{output.heading('--llm-reduce')}\n{escape(str(reduce.get('output', '')))}" + + +def render(payload: dict[str, object]) -> RenderableType: + has_wer = "wer" in payload + has_failed = "failed" in payload + has_latency = "latency_p50" in payload + columns = [ + "ITEM", + *(["WORDS", "ERRORS", "WER"] if has_wer else []), + *(["LATENCY"] if has_latency else []), + *(["ERROR"] if has_failed else []), + ] + table = output.data_table(*columns) + for row in jsonshape.mapping_list(payload.get("rows")): + cells = [str(row.get("item"))] + if has_wer: + cells += [_cell(row, "words"), _cell(row, "errors"), _pct_cell(row, "wer")] + if has_latency: + cells.append(_secs_cell(row, "latency")) + if has_failed: + cells.append(_cell(row, "error")) + table.add_row(*cells) + model = payload.get("speech_model") or "default model" + return output.stack( + output.muted(f"{payload.get('dataset')} · {model}"), + table, + _summary(payload), + _llm_block(payload), + _reduce_block(payload), + ) diff --git a/aai_cli/commands/stream/_exec.py b/aai_cli/commands/stream/_exec.py index 9327574b..e3d0ed28 100644 --- a/aai_cli/commands/stream/_exec.py +++ b/aai_cli/commands/stream/_exec.py @@ -12,7 +12,6 @@ import tempfile from collections.abc import Iterable from dataclasses import dataclass -from datetime import UTC, datetime from pathlib import Path from assemblyai import PIISubstitutionPolicy @@ -20,10 +19,11 @@ from aai_cli import code_gen from aai_cli.app.context import AppState +from aai_cli.commands.stream import _save from aai_cli.core import choices, client, config_builder, signals, stdio, youtube from aai_cli.core.errors import UsageError, mutually_exclusive from aai_cli.core.microphone import MicrophoneSource -from aai_cli.streaming import naming, record, savedir, transcript, turn_presets +from aai_cli.streaming import turn_presets from aai_cli.streaming.batch import stream_batch_sources from aai_cli.streaming.macos import MacSystemAudioSource from aai_cli.streaming.render import StreamRenderer @@ -196,118 +196,6 @@ def _reject_save_with_show_code(opts: StreamOptions) -> None: ) -@dataclass(frozen=True) -class SaveTargets: - """Resolved save destinations for one streaming run. - - ``audio`` tees a single source to one WAV; ``audio_by_label`` instead maps each - parallel ``--system-audio`` channel ("you", "system") to its own WAV when the two - streams can't share a file. At most one of the two is set; ``transcript`` is the - single shared transcript either way. ``plan`` is set only under ``--save-dir`` and - carries the post-stream finalization (auto-name rename, ``--llm`` note, sidecar). - """ - - transcript: Path | None = None - audio: Path | None = None - audio_by_label: dict[str, Path] | None = None - plan: savedir.SaveDirPlan | None = None - - -def _save_dir_targets(opts: StreamOptions, sources: SourceOptions, save_dir: Path) -> SaveTargets: - """Resolve ``--save-dir`` into auto-named targets plus the finalization plan. - - ``--save-dir`` owns filename assembly, so it rejects the explicit - ``--save-audio``/``--save-transcript`` paths and the conflicting ``--name``/ - ``--auto-name`` title pair. Two parallel ``--system-audio`` streams can't tee to one - WAV, so each channel gets its own ``-{you,system}.wav`` (one shared transcript); - ``--no-save-audio`` drops the WAV(s) entirely. - """ - mutually_exclusive( - ("--save-dir", True), - ("--save-audio", opts.save_audio is not None), - ("--save-transcript", opts.save_transcript is not None), - suggestion="--save-dir names the files for you; drop the explicit path.", - ) - mutually_exclusive( - ("--name", opts.name is not None), - ("--auto-name", opts.auto_name), - suggestion="Both set the title — pass --name for an explicit one or " - "--auto-name to derive it from the transcript.", - ) - # Local wall-clock time (what a meeting filename wants); the explicit utc-then- - # astimezone keeps the now() call timezone-aware for the linter. - now = datetime.now(UTC).astimezone() - plan = savedir.SaveDirPlan( - save_dir=save_dir, - now=now, - name=opts.name, - auto_name=opts.auto_name, - write_note=bool(opts.llm_prompt), - ) - paths = plan.paths - naming.ensure_dir(paths.directory) - if opts.no_save_audio: - # Transcript + sidecar (+ note) only; no WAV teed for any source. - return SaveTargets(transcript=paths.transcript, plan=plan) - if sources.system_audio: - # Parallel mic + system: one WAV per channel beside the shared transcript. - return SaveTargets( - transcript=paths.transcript, - audio_by_label={ - "you": naming.channel_audio(paths.audio, "you"), - "system": naming.channel_audio(paths.audio, "system"), - }, - plan=plan, - ) - if sources.system_audio_only: - # A lone system-audio stream; label its single WAV so it reads like the pair. - return SaveTargets( - transcript=paths.transcript, - audio=naming.channel_audio(paths.audio, "system"), - plan=plan, - ) - return SaveTargets(transcript=paths.transcript, audio=paths.audio, plan=plan) - - -def _resolve_save_targets(opts: StreamOptions, sources: SourceOptions) -> SaveTargets: - """Resolve the save flags into the destinations the session writes. - - ``--save-dir`` owns filename assembly (see ``_save_dir_targets``); the explicit - ``--save-audio``/``--save-transcript`` paths are the fallback, with the save-dir-only - ``--name``/``--auto-name``/``--no-save-audio`` flags rejected outside it. - """ - if opts.save_dir is not None: - return _save_dir_targets(opts, sources, opts.save_dir) - if opts.name is not None: - raise UsageError( - "--name applies only with --save-dir.", - suggestion="Pass --save-dir DIR to auto-name the files, " - "or --save-transcript PATH for an explicit path.", - ) - if opts.auto_name: - raise UsageError( - "--auto-name applies only with --save-dir.", - suggestion="Pass --save-dir DIR so there's an auto-named file to title.", - ) - if opts.no_save_audio: - raise UsageError( - "--no-save-audio applies only with --save-dir.", - suggestion="Omit --save-audio to skip the WAV, or pass --save-dir DIR.", - ) - if opts.save_audio is not None: - if sources.system_audio: - raise UsageError( - "--save-audio cannot be combined with --system-audio; the mic and system " - "streams can't share one file.", - suggestion="Pass --save-dir DIR to save one WAV per channel, " - "or record a single source.", - ) - record.validate_target(opts.save_audio) - if opts.save_transcript is not None: - transcript.validate_target(opts.save_transcript) - return SaveTargets(transcript=opts.save_transcript, audio=opts.save_audio) - - def _dispatch(session: StreamSession, opts: SourceOptions) -> None: """Open the right audio source(s) for the flags and stream them.""" if opts.from_system_audio: @@ -455,7 +343,7 @@ def run_stream(opts: StreamOptions, state: AppState, *, json_mode: bool) -> None # Validate the requested sources (including that a local file exists) before # credentials, so a typo'd path reads as "file not found" — not as a login. validate_sources(sources, has_llm=bool(opts.llm_prompt), text_mode=text_mode) - targets = _resolve_save_targets(opts, sources) + targets = _save.resolve_save_targets(opts, sources) if sources.from_file and not sources.from_stdin: client.resolve_audio_source(sources.source, sample=sources.sample) api_key = state.resolve_api_key() diff --git a/aai_cli/commands/stream/_save.py b/aai_cli/commands/stream/_save.py new file mode 100644 index 00000000..3690fd5d --- /dev/null +++ b/aai_cli/commands/stream/_save.py @@ -0,0 +1,134 @@ +"""Save-destination resolution for `assembly stream`. + +Split out of ``_exec`` so the save-flag matrix (`--save-dir` auto-naming vs the +explicit `--save-audio`/`--save-transcript` paths, the `--system-audio` per-channel +WAV split, and the flags that apply only under `--save-dir`) lives in one file. The +run path calls :func:`resolve_save_targets` and writes to the returned +``SaveTargets``; everything here is a pure function of the parsed options. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from datetime import UTC, datetime +from pathlib import Path +from typing import TYPE_CHECKING + +from aai_cli.core.errors import UsageError, mutually_exclusive +from aai_cli.streaming import naming, record, savedir, transcript +from aai_cli.streaming.validate import SourceOptions + +if TYPE_CHECKING: + from aai_cli.commands.stream._exec import StreamOptions + + +@dataclass(frozen=True) +class SaveTargets: + """Resolved save destinations for one streaming run. + + ``audio`` tees a single source to one WAV; ``audio_by_label`` instead maps each + parallel ``--system-audio`` channel ("you", "system") to its own WAV when the two + streams can't share a file. At most one of the two is set; ``transcript`` is the + single shared transcript either way. ``plan`` is set only under ``--save-dir`` and + carries the post-stream finalization (auto-name rename, ``--llm`` note, sidecar). + """ + + transcript: Path | None = None + audio: Path | None = None + audio_by_label: dict[str, Path] | None = None + plan: savedir.SaveDirPlan | None = None + + +def _save_dir_targets(opts: StreamOptions, sources: SourceOptions, save_dir: Path) -> SaveTargets: + """Resolve ``--save-dir`` into auto-named targets plus the finalization plan. + + ``--save-dir`` owns filename assembly, so it rejects the explicit + ``--save-audio``/``--save-transcript`` paths and the conflicting ``--name``/ + ``--auto-name`` title pair. Two parallel ``--system-audio`` streams can't tee to one + WAV, so each channel gets its own ``-{you,system}.wav`` (one shared transcript); + ``--no-save-audio`` drops the WAV(s) entirely. + """ + mutually_exclusive( + ("--save-dir", True), + ("--save-audio", opts.save_audio is not None), + ("--save-transcript", opts.save_transcript is not None), + suggestion="--save-dir names the files for you; drop the explicit path.", + ) + mutually_exclusive( + ("--name", opts.name is not None), + ("--auto-name", opts.auto_name), + suggestion="Both set the title — pass --name for an explicit one or " + "--auto-name to derive it from the transcript.", + ) + # Local wall-clock time (what a meeting filename wants); the explicit utc-then- + # astimezone keeps the now() call timezone-aware for the linter. + now = datetime.now(UTC).astimezone() + plan = savedir.SaveDirPlan( + save_dir=save_dir, + now=now, + name=opts.name, + auto_name=opts.auto_name, + write_note=bool(opts.llm_prompt), + ) + paths = plan.paths + naming.ensure_dir(paths.directory) + if opts.no_save_audio: + # Transcript + sidecar (+ note) only; no WAV teed for any source. + return SaveTargets(transcript=paths.transcript, plan=plan) + if sources.system_audio: + # Parallel mic + system: one WAV per channel beside the shared transcript. + return SaveTargets( + transcript=paths.transcript, + audio_by_label={ + "you": naming.channel_audio(paths.audio, "you"), + "system": naming.channel_audio(paths.audio, "system"), + }, + plan=plan, + ) + if sources.system_audio_only: + # A lone system-audio stream; label its single WAV so it reads like the pair. + return SaveTargets( + transcript=paths.transcript, + audio=naming.channel_audio(paths.audio, "system"), + plan=plan, + ) + return SaveTargets(transcript=paths.transcript, audio=paths.audio, plan=plan) + + +def resolve_save_targets(opts: StreamOptions, sources: SourceOptions) -> SaveTargets: + """Resolve the save flags into the destinations the session writes. + + ``--save-dir`` owns filename assembly (see ``_save_dir_targets``); the explicit + ``--save-audio``/``--save-transcript`` paths are the fallback, with the save-dir-only + ``--name``/``--auto-name``/``--no-save-audio`` flags rejected outside it. + """ + if opts.save_dir is not None: + return _save_dir_targets(opts, sources, opts.save_dir) + if opts.name is not None: + raise UsageError( + "--name applies only with --save-dir.", + suggestion="Pass --save-dir DIR to auto-name the files, " + "or --save-transcript PATH for an explicit path.", + ) + if opts.auto_name: + raise UsageError( + "--auto-name applies only with --save-dir.", + suggestion="Pass --save-dir DIR so there's an auto-named file to title.", + ) + if opts.no_save_audio: + raise UsageError( + "--no-save-audio applies only with --save-dir.", + suggestion="Omit --save-audio to skip the WAV, or pass --save-dir DIR.", + ) + if opts.save_audio is not None: + if sources.system_audio: + raise UsageError( + "--save-audio cannot be combined with --system-audio; the mic and system " + "streams can't share one file.", + suggestion="Pass --save-dir DIR to save one WAV per channel, " + "or record a single source.", + ) + record.validate_target(opts.save_audio) + if opts.save_transcript is not None: + transcript.validate_target(opts.save_transcript) + return SaveTargets(transcript=opts.save_transcript, audio=opts.save_audio) diff --git a/tests/test_eval_llm.py b/tests/test_eval_llm.py index d451125f..d7c82060 100644 --- a/tests/test_eval_llm.py +++ b/tests/test_eval_llm.py @@ -12,6 +12,7 @@ from typer.testing import CliRunner from aai_cli.commands.evaluate import _exec as evaluate_exec +from aai_cli.commands.evaluate import _render as evaluate_render from aai_cli.core import config from aai_cli.main import app @@ -272,14 +273,14 @@ def test_gather_reduce_inputs_headers_and_skips_failures_and_blanks(): def test_final_llm_output_distinguishes_absent_from_empty(): - assert evaluate_exec._final_llm_output({"item": "a"}) is None - assert evaluate_exec._final_llm_output({"llm": {"steps": []}}) == "" - assert evaluate_exec._final_llm_output({"llm": {"steps": [{"output": "x"}]}}) == "x" + assert evaluate_render._final_llm_output({"item": "a"}) is None + assert evaluate_render._final_llm_output({"llm": {"steps": []}}) == "" + assert evaluate_render._final_llm_output({"llm": {"steps": [{"output": "x"}]}}) == "x" def test_render_blocks_drop_out_when_absent(): - assert evaluate_exec._llm_block({"rows": [{"item": "a"}]}) is None - assert evaluate_exec._reduce_block({}) is None + assert evaluate_render._llm_block({"rows": [{"item": "a"}]}) is None + assert evaluate_render._reduce_block({}) is None def _assign(obj, attribute, value): diff --git a/tests/test_stream_exec.py b/tests/test_stream_exec.py index db8e1773..44ecf692 100644 --- a/tests/test_stream_exec.py +++ b/tests/test_stream_exec.py @@ -17,6 +17,7 @@ from aai_cli.app.context import AppState from aai_cli.commands.stream import _exec as stream_exec +from aai_cli.commands.stream import _save as stream_save from aai_cli.core import config from aai_cli.core.errors import CLIError, UsageError from aai_cli.streaming.turn_presets import TurnDetectionPreset @@ -130,7 +131,7 @@ def test_save_targets_are_immutable(): # later step can't quietly retarget a file mid-run. field_name = "audio" with pytest.raises(dataclasses.FrozenInstanceError): - setattr(stream_exec.SaveTargets(), field_name, Path("x.wav")) + setattr(stream_save.SaveTargets(), field_name, Path("x.wav")) # --- batch streaming (--from-stdin) validation ----------------------------- diff --git a/tests/test_stream_save_dir.py b/tests/test_stream_save_dir.py index cb5c4f6e..98438d8e 100644 --- a/tests/test_stream_save_dir.py +++ b/tests/test_stream_save_dir.py @@ -16,6 +16,7 @@ from aai_cli.app.context import AppState from aai_cli.commands.stream import _exec as stream_exec +from aai_cli.commands.stream import _save as stream_save from aai_cli.core import config from aai_cli.core.errors import UsageError from tests._stream_helpers import DEFAULTS, FakeTurn, FixedDatetime, RecordingMic, emit_turns @@ -25,7 +26,7 @@ def test_save_dir_auto_names_transcript_and_matching_wav(monkeypatch, tmp_path): # --save-dir buckets by date and shares one timestamp+slug stem across the .txt and # the .wav, so both land together under DIR/YYYY-MM-DD/. config.set_api_key("default", "sk_live") - monkeypatch.setattr(stream_exec, "datetime", FixedDatetime) + monkeypatch.setattr(stream_save, "datetime", FixedDatetime) monkeypatch.setattr(stream_exec.client, "stream_audio", emit_turns(FakeTurn("hi there"))) monkeypatch.setattr(stream_exec, "MicrophoneSource", RecordingMic) @@ -82,7 +83,7 @@ def test_save_flags_reject_show_code(overrides): def test_no_save_audio_writes_transcript_and_sidecar_but_no_wav(monkeypatch, tmp_path): # --save-dir --no-save-audio keeps the auto-named transcript + sidecar but writes no WAV. config.set_api_key("default", "sk_live") - monkeypatch.setattr(stream_exec, "datetime", FixedDatetime) + monkeypatch.setattr(stream_save, "datetime", FixedDatetime) monkeypatch.setattr(stream_exec.client, "stream_audio", emit_turns(FakeTurn("hi there"))) monkeypatch.setattr(stream_exec, "MicrophoneSource", RecordingMic) @@ -103,7 +104,7 @@ def test_save_dir_auto_name_and_note_end_to_end(monkeypatch, tmp_path): # --save-dir --auto-name --llm: the files are renamed from the LLM-derived title, the # final answer lands as a .md note, and the sidecar records the title. config.set_api_key("default", "sk_live") - monkeypatch.setattr(stream_exec, "datetime", FixedDatetime) + monkeypatch.setattr(stream_save, "datetime", FixedDatetime) monkeypatch.setattr(stream_exec.client, "stream_audio", emit_turns(FakeTurn("hi there"))) monkeypatch.setattr(stream_exec, "MicrophoneSource", RecordingMic) diff --git a/tests/test_stream_system_audio.py b/tests/test_stream_system_audio.py index 5cc54b3e..ed48bea9 100644 --- a/tests/test_stream_system_audio.py +++ b/tests/test_stream_system_audio.py @@ -420,7 +420,7 @@ def test_stream_system_audio_save_dir_writes_one_wav_per_channel(monkeypatch, tm # --save-dir + --system-audio can't tee two streams into one WAV, so each channel # gets its own -{you,system}.wav beside the single shared transcript. config.set_api_key("default", "sk_live") - monkeypatch.setattr("aai_cli.commands.stream._exec.datetime", _FixedDatetime) + monkeypatch.setattr("aai_cli.commands.stream._save.datetime", _FixedDatetime) class FakeSystemAudio: def __init__(self, *, on_open=None): @@ -463,7 +463,7 @@ def test_stream_system_audio_only_save_dir_writes_one_labeled_wav(monkeypatch, t # A lone --system-audio-only stream saves to a single channel-labeled WAV (never the # bare .wav a mic recording uses) and still never opens the microphone. config.set_api_key("default", "sk_live") - monkeypatch.setattr("aai_cli.commands.stream._exec.datetime", _FixedDatetime) + monkeypatch.setattr("aai_cli.commands.stream._save.datetime", _FixedDatetime) class FakeSystemAudio: def __init__(self, *, on_open=None):