Files
meeting-lab/tests/test_mvp_api.py
T

250 lines
10 KiB
Python

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(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()