import json import shutil import subprocess import tempfile import unittest from dataclasses import replace from pathlib import Path from unittest.mock import patch from scripts import run_mvp_meeting as cli from src.meeting_lab.audio import PreparedAudio from src.meeting_lab.models.meeting_context import load_meeting_context from src.meeting_lab.orchestration import mvp as mvp_api from src.meeting_lab.orchestration.mvp import MvpMeetingConfig, MvpRunResult from src.meeting_lab.protocol.generate_direct_protocol import DirectProtocolResult from src.meeting_lab.transcription.whisper import TranscriptionError, TranscriptionResult def context_data(): return { "schema_version": "1", "meeting": { "meeting_id": "programmatic-test", "title": "Programmatic Test", "language": "de", "date": None, "objective": "API prüfen", "notes": "", }, "participants": [ { "participant_id": "person-1", "display_name": "Test Person", "aliases": [], "role": "Projektleitung", "department": None, "attendance_status": "present", "notes": None, } ], "speaker_mappings": {"SPEAKER_00": "person-1"}, "mentioned_people": [], "organization": {"name": "Example", "departments": []}, "known_entities": {}, "context_rules": {"do_not_infer_responsibilities": True}, } def fake_transcribe(audio, model, output, language, **kwargs): output.mkdir(parents=True, exist_ok=True) raw = output / "whisper_raw.json" transcript = output / "transcript.json" text = output / "transcript.txt" metadata = output / "runtime_metadata.json" raw.write_text('{"transcription": []}\n', encoding="utf-8") transcript.write_text( json.dumps( { "text": "Ein kurzer Besprechungstext.", "segments": [ { "id": 0, "start": 0.0, "end": 1.0, "text": "Ein kurzer Besprechungstext.", } ], } ), encoding="utf-8", ) text.write_text("Ein kurzer Besprechungstext.\n", encoding="utf-8") metadata.write_text('{"runtime_seconds": 0.1}\n', encoding="utf-8") return TranscriptionResult(output, raw, transcript, text, metadata, 0.1) def fake_prepare(source, destination, **kwargs): destination.parent.mkdir(parents=True, exist_ok=True) shutil.copyfile(source, destination) return PreparedAudio(source, source.suffix.removeprefix("."), destination, "ffmpeg", "ffmpeg") def fake_protocol(transcript, context, **kwargs): rendered_context = load_meeting_context(context) assert rendered_context.meeting_id == "programmatic-test" return DirectProtocolResult( protocol_text="# Meeting Protocol\n", exact_prompt="prompt", model_metadata={"model": kwargs["model"]}, runtime_metadata={"request_count": 1}, raw_response={"response": "# Meeting Protocol\n"}, ) class MvpApiTests(unittest.TestCase): def config(self, root: Path): audio = root / "meeting.wav" model = root / "model.bin" audio.write_bytes(b"audio") model.write_bytes(b"model") return MvpMeetingConfig( audio_file=audio, whisper_model=model, output_root=root / "runs", model="test:model", ) def test_programmatic_context_is_persisted_without_source_yaml(self): with tempfile.TemporaryDirectory() as directory: root = Path(directory) config = self.config(root) events = [] with ( patch.object(mvp_api, "transcribe_audio", side_effect=fake_transcribe), patch.object(mvp_api, "prepare_audio", side_effect=fake_prepare), patch.object( mvp_api, "generate_direct_protocol", side_effect=fake_protocol ), patch.object(subprocess, "run") as subprocess_run, ): result = mvp_api.run_mvp_meeting( config, meeting_context=context_data(), progress_sink=events.append ) self.assertEqual(result.exit_code, 0) context_path = result.run_dir / "context/meeting_context.yaml" self.assertTrue(context_path.is_file()) persisted = load_meeting_context(context_path) self.assertEqual(persisted.meeting_id, "programmatic-test") self.assertEqual(persisted.speaker_mappings, {"SPEAKER_00": "person-1"}) subprocess_run.assert_not_called() self.assertEqual( [(event.stage, event.status) for event in events], [ ("preparing", "started"), ("preparing", "completed"), ("transcription", "started"), ("transcription", "completed"), ("protocol_generation", "started"), ("protocol_generation", "completed"), ("completed", "completed"), ], ) self.assertTrue(all(event.progress is None for event in events)) def test_failure_emits_terminal_failure_event(self): with tempfile.TemporaryDirectory() as directory: root = Path(directory) config = self.config(root) events = [] with patch.object( mvp_api, "transcribe_audio", side_effect=TranscriptionError("stopped"), ), patch.object(mvp_api, "prepare_audio", side_effect=fake_prepare): result = mvp_api.run_mvp_meeting(config, progress_sink=events.append) self.assertEqual(result.exit_code, 2) self.assertEqual(events[-1].stage, "failed") self.assertEqual(events[-1].status, "failed") self.assertIn("transcription", events[-1].message) metadata = json.loads((result.run_dir / "run_metadata.json").read_text()) self.assertEqual(metadata["status"], "failed") def test_cli_defaults_and_wrapper_delegate_without_subprocess(self): with tempfile.TemporaryDirectory() as directory: root = Path(directory) config = self.config(root) args = cli.parse_args( [str(config.audio_file), "--whisper-model", str(config.whisper_model)] ) expected = MvpRunResult(0, root / "run", root / "run/protocol.md") with patch.object(cli, "run_mvp_meeting", return_value=expected) as api: actual = cli.run(args, context_override=context_data()) self.assertEqual(actual, (0, expected.run_dir, expected.protocol_path)) delegated = api.call_args.args[0] self.assertEqual(delegated.diarization, "off") self.assertEqual(delegated.language, "de") self.assertEqual(delegated.whisper_executable, "whisper-cli") self.assertEqual(delegated.ffmpeg_executable, "ffmpeg") self.assertTrue(delegated.audio_normalization) self.assertEqual(delegated.protocol_safe_input_token_budget, 16_200) self.assertEqual(api.call_args.kwargs["meeting_context"], context_data()) def test_cli_explicit_audio_normalization_values_are_propagated(self): with tempfile.TemporaryDirectory() as directory: root = Path(directory) config = self.config(root) enabled = cli.config_from_args( cli.parse_args( [ str(config.audio_file), "--whisper-model", str(config.whisper_model), "--audio-normalization", ] ) ) disabled = cli.config_from_args( cli.parse_args( [ str(config.audio_file), "--whisper-model", str(config.whisper_model), "--no-audio-normalization", ] ) ) self.assertTrue(enabled.audio_normalization) self.assertFalse(disabled.audio_normalization) def test_transcription_receives_prepared_wav_for_encoded_inputs(self): for suffix in (".flac", ".m4a"): with self.subTest(suffix=suffix), tempfile.TemporaryDirectory() as directory: root = Path(directory) config = self.config(root) encoded = config.audio_file.with_suffix(suffix) config.audio_file.rename(encoded) config = replace(config, audio_file=encoded) received = [] def capture_transcribe(audio, *args, received_paths=received, **kwargs): received_paths.append(audio) return fake_transcribe(audio, *args, **kwargs) with ( patch.object(mvp_api, "prepare_audio", side_effect=fake_prepare), patch.object(mvp_api, "transcribe_audio", side_effect=capture_transcribe), patch.object(mvp_api, "generate_direct_protocol", side_effect=fake_protocol), ): result = mvp_api.run_mvp_meeting( config, meeting_context=context_data() ) self.assertEqual(result.exit_code, 0) self.assertEqual(received, [result.run_dir / "audio" / "prepared.wav"]) manifest = json.loads( (result.run_dir / "audio" / "input_manifest.json").read_text() ) self.assertEqual(manifest["format"], suffix.removeprefix(".")) self.assertEqual( manifest["prepared_audio"]["prepared_audio_path"], str((result.run_dir / "audio" / "prepared.wav").resolve()), ) if __name__ == "__main__": unittest.main()