Add Streamlit meeting assistant MVP
This commit is contained in:
@@ -0,0 +1,299 @@
|
||||
import json
|
||||
from dataclasses import dataclass
|
||||
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", "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["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_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 == "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"
|
||||
Reference in New Issue
Block a user