Add container diarization configuration
This commit is contained in:
@@ -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"]'
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
Reference in New Issue
Block a user