250 lines
10 KiB
Python
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()
|