Add container diarization configuration

This commit is contained in:
2026-08-24 15:59:18 +02:00
parent 2a4481f2f9
commit 303563abf1
8 changed files with 152 additions and 5 deletions
+7
View File
@@ -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"]'
+5
View File
@@ -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
+17
View File
@@ -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:
+31
View File
@@ -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)
+6 -1
View File
@@ -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(
+4 -1
View File
@@ -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:
+38
View File
@@ -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()
+44 -3
View File
@@ -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()