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