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_MODE=auto
MKA_DIARIZATION_RUNTIME=native MKA_DIARIZATION_RUNTIME=native
# MKA_DIARIZATION_CONTAINER_IMAGE= # 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. 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 People-list YAML is a Meeting Assistant application concern and contains only
stable IDs, display names, roles, organizations and attendance states. It does stable IDs, display names, roles, organizations and attendance states. It does
not contain meeting metadata or processing settings. Named team or meeting 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_MODE` (`auto`, `cpu`, or `gpu`; default: `auto`)
- `MKA_DIARIZATION_RUNTIME` (`native` or `container`; default: `native`) - `MKA_DIARIZATION_RUNTIME` (`native` or `container`; default: `native`)
- `MKA_DIARIZATION_CONTAINER_IMAGE` (required for container diarization) - `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: From the Meeting Assistant repository, start the UI with:
+31
View File
@@ -2,6 +2,7 @@
from __future__ import annotations from __future__ import annotations
import json
import os import os
from dataclasses import dataclass from dataclasses import dataclass
from pathlib import Path from pathlib import Path
@@ -26,6 +27,7 @@ class AppSettings:
diarization_mode: str = "auto" diarization_mode: str = "auto"
diarization_runtime: str = "native" diarization_runtime: str = "native"
diarization_container_image: str | None = None diarization_container_image: str | None = None
diarization_container_args: tuple[str, ...] = ()
@classmethod @classmethod
def from_environment(cls) -> AppSettings: def from_environment(cls) -> AppSettings:
@@ -45,6 +47,9 @@ class AppSettings:
diarization_mode=os.getenv("MKA_DIARIZATION_MODE", "auto"), diarization_mode=os.getenv("MKA_DIARIZATION_MODE", "auto"),
diarization_runtime=os.getenv("MKA_DIARIZATION_RUNTIME", "native"), diarization_runtime=os.getenv("MKA_DIARIZATION_RUNTIME", "native"),
diarization_container_image=os.getenv("MKA_DIARIZATION_CONTAINER_IMAGE"), 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: def validate_for_processing(self) -> None:
@@ -67,3 +72,29 @@ class AppSettings:
raise ConfigurationError( raise ConfigurationError(
"MKA_DIARIZATION_CONTAINER_IMAGE is required for container runtime." "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_runtime": self.settings.diarization_runtime,
"diarization_container_image": (self.settings.diarization_container_image), "diarization_container_image": (self.settings.diarization_container_image),
"diarization_container_args": self.settings.diarization_container_args,
} }
) )
current_stage: str | None = None current_stage: str | None = None
@@ -230,13 +231,17 @@ class MeetingProcessingService:
"transcript_validation": "transcription", "transcript_validation": "transcription",
"protocol": "protocol_generation", "protocol": "protocol_generation",
}.get(backend_stage, backend_stage) }.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( return ProcessingOutcome(
succeeded=False, succeeded=False,
run_dir=result.run_dir, run_dir=result.run_dir,
original_protocol=None, original_protocol=None,
protocol_path=None, protocol_path=None,
failed_stage=failed_stage or current_stage or "preparing", 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) protocol_path = Path(result.protocol_path)
return ProcessingOutcome( return ProcessingOutcome(
+4 -1
View File
@@ -286,7 +286,10 @@ def main() -> None:
if outcome.succeeded: if outcome.succeeded:
status_box.success("Processing completed.") status_box.success("Processing completed.")
else: 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: except (ConfigurationError, MeetingLabUnavailableError, ValueError) as exc:
st.error(str(exc)) st.error(str(exc))
except Exception as 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 = AppSettings(data_root=tmp_path / "runs", whisper_model=model)
settings.validate_for_processing() 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 import json
from dataclasses import dataclass from dataclasses import dataclass, replace
from datetime import date from datetime import date
from pathlib import Path from pathlib import Path
from types import SimpleNamespace from types import SimpleNamespace
@@ -74,7 +74,15 @@ class FakeMeetingLab:
) )
if self.fail: if self.fail:
(self.run_dir / "run_metadata.json").write_text( (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", encoding="utf-8",
) )
progress_sink( 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["whisper_executable"] == "/opt/whisper-cli"
assert gateway.config_values["ffmpeg_executable"] == "ffmpeg" assert gateway.config_values["ffmpeg_executable"] == "ffmpeg"
assert gateway.config_values["audio_normalization"] is True 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"] == ( assert gateway.config_values["output_root"] == (
tmp_path / "meetings" / "architecture-review" / "runs" 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 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: def test_result_and_user_edit_are_preserved_separately(tmp_path: Path) -> None:
service, _ = make_service(tmp_path) service, _ = make_service(tmp_path)
audio = tmp_path / "meeting.wav" 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 not outcome.succeeded
assert outcome.failed_stage == "transcription" 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 outcome.run_dir == gateway.run_dir
assert (gateway.run_dir / "run_metadata.json").is_file() assert (gateway.run_dir / "run_metadata.json").is_file()