341 lines
11 KiB
Python
341 lines
11 KiB
Python
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["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"
|