import json from dataclasses import dataclass, replace from datetime import date from pathlib import Path from types import SimpleNamespace from typing import Any from mka.application.config import AppSettings from mka.application.meeting_service import ( MeetingDetails, MeetingProcessingService, ParticipantInput, ProcessingOptions, ) @dataclass class FakeContext: data: dict[str, Any] @property def meeting_id(self) -> str: return self.data["meeting"]["meeting_id"] class FakeMeetingLab: def __init__(self, run_dir: Path) -> None: self.run_dir = run_dir self.context_data: dict[str, Any] | None = None self.config_values: dict[str, Any] | None = None self.fail = False def create_context(self, data: dict[str, Any]) -> FakeContext: self.context_data = data if not data["meeting"]["title"]: raise ValueError("meeting.title must be present and non-empty") participant_ids = [item["participant_id"] for item in data["participants"]] if len(participant_ids) != len(set(participant_ids)): raise ValueError("Duplicate participant_id") return FakeContext(data) def create_config(self, values: dict[str, Any]) -> dict[str, Any]: self.config_values = values return values def run(self, config: Any, meeting_context: Any, progress_sink: Any) -> Any: self.run_dir.mkdir(parents=True, exist_ok=True) progress_sink( SimpleNamespace( stage="preparing", status="started", elapsed_seconds=0.1, progress=None, message=None, ) ) progress_sink( SimpleNamespace( stage="preparing", status="completed", elapsed_seconds=0.2, progress=None, message=None, ) ) progress_sink( SimpleNamespace( stage="transcription", status="started", elapsed_seconds=0.2, progress=0.25, message="transcribing", ) ) if self.fail: (self.run_dir / "run_metadata.json").write_text( json.dumps( { "failure": { "stage": "whisper", "type": "TranscriptionError", "message": "model failed", } } ), encoding="utf-8", ) progress_sink( SimpleNamespace( stage="failed", status="failed", elapsed_seconds=0.3, progress=None, message="transcription: model failed", ) ) return SimpleNamespace(exit_code=2, run_dir=self.run_dir, protocol_path=None) protocol = self.run_dir / "protocol.md" protocol.write_text("# Generated protocol\n", encoding="utf-8") return SimpleNamespace(exit_code=0, run_dir=self.run_dir, protocol_path=protocol) def make_service(tmp_path: Path) -> tuple[MeetingProcessingService, FakeMeetingLab]: model = tmp_path / "model.bin" model.write_bytes(b"model") gateway = FakeMeetingLab(tmp_path / "backend-run") settings = AppSettings( data_root=tmp_path / "meetings", whisper_model=model, whisper_executable="/opt/whisper-cli", protocol_model="test:model", diarization_mode="gpu", ) return MeetingProcessingService(settings, gateway), gateway def meeting() -> MeetingDetails: return MeetingDetails( title="Architecture Review", language="de", meeting_date=date(2026, 8, 23), description="Review the MVP.", ) def participants() -> list[ParticipantInput]: return [ ParticipantInput( participant_id="martin", display_name="Martin", role="Project lead", organization="Engineering", ), ParticipantInput( participant_id="alex", display_name="Alex", organization="Engineering", ), ] def test_build_context_uses_actual_v1_shape(tmp_path: Path) -> None: service, gateway = make_service(tmp_path) context = service.build_context(meeting(), participants()) assert context.meeting_id == "architecture-review" assert gateway.context_data is not None assert gateway.context_data["meeting"]["date"] == "2026-08-23" assert gateway.context_data["meeting"]["notes"] == "Review the MVP." assert gateway.context_data["participants"][0] == { "participant_id": "martin", "display_name": "Martin", "aliases": [], "role": "Project lead", "department": "engineering", "attendance_status": "present", "notes": None, } assert gateway.context_data["organization"]["departments"] == [ {"id": "engineering", "name": "Engineering", "aliases": []} ] def test_build_context_preserves_explicit_speaker_mapping(tmp_path: Path) -> None: service, gateway = make_service(tmp_path) service.build_context(meeting(), participants(), {"SPEAKER_00": "martin"}) assert gateway.context_data is not None assert gateway.context_data["speaker_mappings"] == {"SPEAKER_00": "martin"} def test_build_context_translates_mentioned_only_person(tmp_path: Path) -> None: service, gateway = make_service(tmp_path) people = participants() + [ ParticipantInput( participant_id="sam", display_name="Sam", attendance_status="mentioned_only", ) ] service.build_context(meeting(), people) assert gateway.context_data is not None assert [item["participant_id"] for item in gateway.context_data["participants"]] == [ "martin", "alex", ] assert gateway.context_data["mentioned_people"] == [ { "person_id": "sam", "display_name": "Sam", "aliases": [], "role": None, "department": None, "attendance_status": "mentioned_only", "notes": None, } ] def test_process_translates_configuration_and_disables_diarization( tmp_path: Path, ) -> None: service, gateway = make_service(tmp_path) audio = tmp_path / "meeting.wav" audio.write_bytes(b"audio") outcome = service.process( audio, meeting(), participants(), ProcessingOptions(diarization_enabled=False) ) assert outcome.succeeded assert gateway.config_values is not None assert gateway.config_values["diarization"] == "off" assert gateway.config_values["model"] == "test:model" assert gateway.config_values["protocol_num_ctx"] == 32_768 assert gateway.config_values["protocol_safe_input_token_budget"] == 29_000 assert gateway.config_values["whisper_executable"] == "/opt/whisper-cli" assert gateway.config_values["ffmpeg_executable"] == "ffmpeg" assert gateway.config_values["audio_normalization"] is True assert gateway.config_values["diarization_runtime"] == "native" assert gateway.config_values["diarization_container_args"] == () assert gateway.config_values["output_root"] == ( tmp_path / "meetings" / "architecture-review" / "runs" ) def test_process_propagates_disabled_audio_normalization(tmp_path: Path) -> None: service, gateway = make_service(tmp_path) audio = tmp_path / "meeting.m4a" audio.write_bytes(b"audio") outcome = service.process( audio, meeting(), participants(), ProcessingOptions(audio_normalization=False), ) assert outcome.succeeded assert gateway.config_values is not None assert gateway.config_values["audio_normalization"] is False def test_processing_options_default_to_audio_normalization_on() -> None: assert ProcessingOptions().audio_normalization is True def test_process_propagates_enabled_diarization_and_progress(tmp_path: Path) -> None: service, gateway = make_service(tmp_path) audio = tmp_path / "meeting.flac" audio.write_bytes(b"audio") events = [] service.process( audio, meeting(), participants(), ProcessingOptions(diarization_enabled=True), progress_sink=events.append, ) assert gateway.config_values is not None assert gateway.config_values["diarization"] == "gpu" assert [(event.stage, event.status) for event in events[:2]] == [ ("preparing", "started"), ("preparing", "completed"), ] assert events[2].progress == 0.25 def test_process_propagates_ordered_diarization_container_args(tmp_path: Path) -> None: service, gateway = make_service(tmp_path) service.settings = replace( service.settings, diarization_runtime="container", diarization_container_image="runtime/image:tag", diarization_container_args=( "--network=host", "--label", "meeting-test", ), ) audio = tmp_path / "meeting.wav" audio.write_bytes(b"audio") outcome = service.process( audio, meeting(), participants(), ProcessingOptions(diarization_enabled=True), ) assert outcome.succeeded assert gateway.config_values is not None assert gateway.config_values["diarization_container_args"] == ( "--network=host", "--label", "meeting-test", ) def test_result_and_user_edit_are_preserved_separately(tmp_path: Path) -> None: service, _ = make_service(tmp_path) audio = tmp_path / "meeting.wav" audio.write_bytes(b"audio") outcome = service.process(audio, meeting(), participants(), ProcessingOptions()) edited_path = service.save_edited_protocol(outcome.run_dir, "# Reviewed\n") assert outcome.original_protocol == "# Generated protocol\n" assert outcome.protocol_path.read_text(encoding="utf-8") == "# Generated protocol\n" assert edited_path.read_text(encoding="utf-8") == "# Reviewed\n" def test_failure_reports_stage_and_preserves_run_dir(tmp_path: Path) -> None: service, gateway = make_service(tmp_path) gateway.fail = True audio = tmp_path / "meeting.wav" audio.write_bytes(b"audio") outcome = service.process(audio, meeting(), participants(), ProcessingOptions()) assert not outcome.succeeded assert outcome.failed_stage == "transcription" assert outcome.error_message == "TranscriptionError: model failed" assert outcome.run_dir == gateway.run_dir assert (gateway.run_dir / "run_metadata.json").is_file() def test_uploaded_source_is_preserved_in_meeting_directory(tmp_path: Path) -> None: service, _ = make_service(tmp_path) source = SimpleNamespace(getbuffer=lambda: b"source audio") destination = service.preserve_upload("meeting-1", "../unsafe.wav", source) assert destination.parent == tmp_path / "meetings" / "meeting-1" / "uploads" assert destination.name.endswith("_unsafe.wav") assert destination.read_bytes() == b"source audio"