diff --git a/.env.example b/.env.example index b01eaca..a6d25af 100644 --- a/.env.example +++ b/.env.example @@ -8,3 +8,10 @@ MKA_WHISPER_THREADS=auto MKA_DIARIZATION_MODE=auto MKA_DIARIZATION_RUNTIME=native # MKA_DIARIZATION_CONTAINER_IMAGE= +# MKA_DIARIZATION_CONTAINER_ARGS=[] + +# Example AMD ROCm container configuration for a compatible workstation: +# MKA_DIARIZATION_MODE=gpu +# MKA_DIARIZATION_RUNTIME=container +# MKA_DIARIZATION_CONTAINER_IMAGE=rocm/pytorch:rocm7.2.1_ubuntu24.04_py3.12_pytorch_release_2.9.1 +# MKA_DIARIZATION_CONTAINER_ARGS='["--device=/dev/kfd","--device=/dev/dri","--group-add","video"]' diff --git a/PROJECT_KNOWLEDGE.md b/PROJECT_KNOWLEDGE.md index 6582cf7..a1dda6f 100644 --- a/PROJECT_KNOWLEDGE.md +++ b/PROJECT_KNOWLEDGE.md @@ -44,6 +44,11 @@ Meeting Assistant owns user interaction and product workflow: Processing logic must not be duplicated in the application. +Diarization runtime, container image and ordered container arguments are +machine configuration. `MKA_DIARIZATION_CONTAINER_ARGS` is a JSON array of +strings forwarded unchanged to Meeting Lab; these details are not normal UI +controls. + People-list YAML is a Meeting Assistant application concern and contains only stable IDs, display names, roles, organizations and attendance states. It does not contain meeting metadata or processing settings. Named team or meeting diff --git a/README.md b/README.md index f595bcc..82d3211 100644 --- a/README.md +++ b/README.md @@ -106,6 +106,23 @@ Optional machine-specific settings include: - `MKA_DIARIZATION_MODE` (`auto`, `cpu`, or `gpu`; default: `auto`) - `MKA_DIARIZATION_RUNTIME` (`native` or `container`; default: `native`) - `MKA_DIARIZATION_CONTAINER_IMAGE` (required for container diarization) +- `MKA_DIARIZATION_CONTAINER_ARGS` (default: no extra arguments), encoded as a + JSON array of strings so ordering and leading dashes are preserved exactly + +For example, a compatible AMD ROCm workstation can configure validated +container access without adding controls to the Streamlit UI: + +```bash +export MKA_DIARIZATION_MODE=gpu +export MKA_DIARIZATION_RUNTIME=container +export MKA_DIARIZATION_CONTAINER_IMAGE=rocm/pytorch:rocm7.2.1_ubuntu24.04_py3.12_pytorch_release_2.9.1 +export MKA_DIARIZATION_CONTAINER_ARGS='["--device=/dev/kfd","--device=/dev/dri","--group-add","video"]' +``` + +The JSON elements are forwarded as four separate, ordered Meeting Lab +container arguments. Runtime and hardware details remain machine-specific +environment configuration; the UI continues to expose only the diarization +on/off choice. From the Meeting Assistant repository, start the UI with: diff --git a/src/mka/application/config.py b/src/mka/application/config.py index ce4bd9a..479733f 100644 --- a/src/mka/application/config.py +++ b/src/mka/application/config.py @@ -2,6 +2,7 @@ from __future__ import annotations +import json import os from dataclasses import dataclass from pathlib import Path @@ -26,6 +27,7 @@ class AppSettings: diarization_mode: str = "auto" diarization_runtime: str = "native" diarization_container_image: str | None = None + diarization_container_args: tuple[str, ...] = () @classmethod def from_environment(cls) -> AppSettings: @@ -45,6 +47,9 @@ class AppSettings: diarization_mode=os.getenv("MKA_DIARIZATION_MODE", "auto"), diarization_runtime=os.getenv("MKA_DIARIZATION_RUNTIME", "native"), diarization_container_image=os.getenv("MKA_DIARIZATION_CONTAINER_IMAGE"), + diarization_container_args=_parse_container_args( + os.getenv("MKA_DIARIZATION_CONTAINER_ARGS") + ), ) def validate_for_processing(self) -> None: @@ -67,3 +72,29 @@ class AppSettings: raise ConfigurationError( "MKA_DIARIZATION_CONTAINER_IMAGE is required for container runtime." ) + if any( + not isinstance(argument, str) or not argument + for argument in self.diarization_container_args + ): + raise ConfigurationError( + "MKA_DIARIZATION_CONTAINER_ARGS must contain only non-empty strings." + ) + + +def _parse_container_args(value: str | None) -> tuple[str, ...]: + """Parse an ordered JSON array of opaque container command arguments.""" + if value is None or not value.strip(): + return () + try: + parsed = json.loads(value) + except json.JSONDecodeError as exc: + raise ConfigurationError( + "MKA_DIARIZATION_CONTAINER_ARGS must be a JSON array of strings." + ) from exc + if not isinstance(parsed, list) or any( + not isinstance(argument, str) or not argument for argument in parsed + ): + raise ConfigurationError( + "MKA_DIARIZATION_CONTAINER_ARGS must be a JSON array of non-empty strings." + ) + return tuple(parsed) diff --git a/src/mka/application/meeting_service.py b/src/mka/application/meeting_service.py index 4309807..3fd3d2d 100644 --- a/src/mka/application/meeting_service.py +++ b/src/mka/application/meeting_service.py @@ -200,6 +200,7 @@ class MeetingProcessingService: ), "diarization_runtime": self.settings.diarization_runtime, "diarization_container_image": (self.settings.diarization_container_image), + "diarization_container_args": self.settings.diarization_container_args, } ) current_stage: str | None = None @@ -230,13 +231,17 @@ class MeetingProcessingService: "transcript_validation": "transcription", "protocol": "protocol_generation", }.get(backend_stage, backend_stage) + failure_message = failure.get("message") or "Meeting Lab processing failed." + failure_type = failure.get("type") + if failure_type and not failure_message.startswith(f"{failure_type}:"): + failure_message = f"{failure_type}: {failure_message}" return ProcessingOutcome( succeeded=False, run_dir=result.run_dir, original_protocol=None, protocol_path=None, failed_stage=failed_stage or current_stage or "preparing", - error_message=failure.get("message") or "Meeting Lab processing failed.", + error_message=failure_message, ) protocol_path = Path(result.protocol_path) return ProcessingOutcome( diff --git a/src/mka/ui/streamlit_app.py b/src/mka/ui/streamlit_app.py index 4cb18e1..c89bcb0 100644 --- a/src/mka/ui/streamlit_app.py +++ b/src/mka/ui/streamlit_app.py @@ -286,7 +286,10 @@ def main() -> None: if outcome.succeeded: status_box.success("Processing completed.") else: - status_box.error("Processing failed. Artifacts were preserved.") + status_box.error( + f"Processing failed during {outcome.failed_stage}: " + f"{outcome.error_message} Artifacts were preserved." + ) except (ConfigurationError, MeetingLabUnavailableError, ValueError) as exc: st.error(str(exc)) except Exception as exc: diff --git a/tests/test_application_config.py b/tests/test_application_config.py index bc41b9f..b44c0a0 100644 --- a/tests/test_application_config.py +++ b/tests/test_application_config.py @@ -28,3 +28,41 @@ def test_settings_accept_valid_native_configuration(tmp_path: Path) -> None: settings = AppSettings(data_root=tmp_path / "runs", whisper_model=model) settings.validate_for_processing() + + assert settings.diarization_runtime == "native" + assert settings.diarization_container_args == () + + +def test_environment_defaults_to_no_diarization_container_args(monkeypatch) -> None: + monkeypatch.delenv("MKA_DIARIZATION_CONTAINER_ARGS", raising=False) + + settings = AppSettings.from_environment() + + assert settings.diarization_container_args == () + + +def test_environment_parses_multiple_ordered_container_args(monkeypatch) -> None: + monkeypatch.setenv( + "MKA_DIARIZATION_CONTAINER_ARGS", + '["--device=/dev/kfd", "--device=/dev/dri", "--group-add", "video"]', + ) + + settings = AppSettings.from_environment() + + assert settings.diarization_container_args == ( + "--device=/dev/kfd", + "--device=/dev/dri", + "--group-add", + "video", + ) + + +@pytest.mark.parametrize( + "value", + ["not-json", '"--flag"', '["valid", ""]', '["valid", 1]'], +) +def test_environment_rejects_invalid_container_args(monkeypatch, value: str) -> None: + monkeypatch.setenv("MKA_DIARIZATION_CONTAINER_ARGS", value) + + with pytest.raises(ConfigurationError, match="JSON array"): + AppSettings.from_environment() diff --git a/tests/test_meeting_service.py b/tests/test_meeting_service.py index 17eac59..905d835 100644 --- a/tests/test_meeting_service.py +++ b/tests/test_meeting_service.py @@ -1,5 +1,5 @@ import json -from dataclasses import dataclass +from dataclasses import dataclass, replace from datetime import date from pathlib import Path from types import SimpleNamespace @@ -74,7 +74,15 @@ class FakeMeetingLab: ) if self.fail: (self.run_dir / "run_metadata.json").write_text( - json.dumps({"failure": {"stage": "whisper", "message": "model failed"}}), + json.dumps( + { + "failure": { + "stage": "whisper", + "type": "TranscriptionError", + "message": "model failed", + } + } + ), encoding="utf-8", ) progress_sink( @@ -211,6 +219,8 @@ def test_process_translates_configuration_and_disables_diarization( 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" ) @@ -260,6 +270,37 @@ def test_process_propagates_enabled_diarization_and_progress(tmp_path: Path) -> 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" @@ -283,7 +324,7 @@ def test_failure_reports_stage_and_preserves_run_dir(tmp_path: Path) -> None: assert not outcome.succeeded assert outcome.failed_stage == "transcription" - assert outcome.error_message == "model failed" + assert outcome.error_message == "TranscriptionError: model failed" assert outcome.run_dir == gateway.run_dir assert (gateway.run_dir / "run_metadata.json").is_file()