Add post-diarization speaker review workflow

This commit is contained in:
2026-09-17 19:16:57 +02:00
parent bd48aec5aa
commit 2215414282
8 changed files with 240 additions and 24 deletions
+6 -2
View File
@@ -81,7 +81,8 @@ Imported audio
(optionally normalize loudness; default on) (optionally normalize loudness; default on)
-> transcribe with whisper.cpp and large-v3-turbo -> transcribe with whisper.cpp and large-v3-turbo
-> optionally diarize with pyannote.audio Community-1 -> optionally diarize with pyannote.audio Community-1
-> generate a protocol directly from the full transcript -> when diarized, wait for optional speaker review before explicit protocol generation
-> otherwise generate a protocol directly from the full transcript
-> review and edit by a human -> review and edit by a human
``` ```
@@ -121,7 +122,10 @@ Speaker selectors filter participants using a snapshot of all current widget
selections, falling back to saved mappings before first interaction. Filtering selections, falling back to saved mappings before first interaction. Filtering
retains existing assignments; clearing a selection releases the participant. retains existing assignments; clearing a selection releases the participant.
Completeness counts and warnings cover detected speakers only and do not gate Completeness counts and warnings cover detected speakers only and do not gate
protocol regeneration or change mapping persistence. protocol generation or change mapping persistence. A diarized run persists the
`awaiting_speaker_review` checkpoint after diarization; an empty, partial or
complete mapping can then explicitly generate a protocol from the existing
artifacts. Failed generation preserves that checkpoint and saved mappings for retry.
## Progress Contract ## Progress Contract
+18 -1
View File
@@ -35,7 +35,8 @@ Audio file
-> FFmpeg preparation (mono, 16 kHz PCM WAV; normalization optional) -> FFmpeg preparation (mono, 16 kHz PCM WAV; normalization optional)
-> whisper.cpp transcription (large-v3-turbo) -> whisper.cpp transcription (large-v3-turbo)
-> optional pyannote.audio Community-1 diarization -> optional pyannote.audio Community-1 diarization
-> direct full-transcript protocol generation -> optional speaker review and explicit protocol generation (when diarized)
or direct full-transcript protocol generation (without diarization)
-> human review and export -> human review and export
``` ```
@@ -212,6 +213,22 @@ From the Meeting Assistant repository, start the UI with:
PYTHONPATH=src:../meeting-lab .venv/bin/streamlit run src/mka/ui/streamlit_app.py PYTHONPATH=src:../meeting-lab .venv/bin/streamlit run src/mka/ui/streamlit_app.py
``` ```
### North launcher
In the post-diarization Assistant worktree on North, use the checked-in
launcher instead of creating another virtual environment:
```bash
./run_north.sh
```
It reuses North's validated Assistant virtual environment and external
Whisper/Ollama/Docker-ROCm runtime while loading Meeting Assistant from this
worktree and Meeting Lab from
`/opt/git-projekts/meeting-lab`. `HF_TOKEN`, when present, is
passed through to the disposable diarization container without being stored by
the launcher.
Streamlit opens `http://localhost:8501` by default. Installing the sibling Streamlit opens `http://localhost:8501` by default. Installing the sibling
Meeting Lab project supplies its runtime requirements such as PyYAML and Meeting Lab project supplies its runtime requirements such as PyYAML and
Requests. Optional diarization dependencies are needed only when diarization Requests. Optional diarization dependencies are needed only when diarization
Executable
+57
View File
@@ -0,0 +1,57 @@
#!/usr/bin/env bash
set -euo pipefail
assistant_root="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd -P)"
assistant_src="$assistant_root/src"
lab_root=/opt/git-projekts/meeting-lab
north_venv=/opt/git-projekts/meeting-assistant/.venv
whisper_executable=/home/martin/whisper.cpp/whisper.cpp/build/bin/whisper-cli
whisper_model=/home/martin/whisper.cpp/whisper.cpp/models/ggml-large-v3-turbo.bin
require_directory() {
if [[ ! -d "$1" ]]; then
printf 'Required directory is missing: %s\n' "$1" >&2
exit 1
fi
}
require_file() {
if [[ ! -f "$1" ]]; then
printf 'Required file is missing: %s\n' "$1" >&2
exit 1
fi
}
require_executable() {
if [[ ! -x "$1" ]]; then
printf 'Required executable is missing or not executable: %s\n' "$1" >&2
exit 1
fi
}
require_directory "$assistant_src"
require_file "$assistant_src/mka/ui/streamlit_app.py"
require_directory "$lab_root"
require_file "$lab_root/src/meeting_lab/__init__.py"
require_directory "$north_venv"
require_executable "$north_venv/bin/streamlit"
require_executable "$whisper_executable"
require_file "$whisper_model"
if ! command -v docker >/dev/null 2>&1; then
printf 'Required executable is not on PATH: docker\n' >&2
exit 1
fi
export MKA_WHISPER_EXECUTABLE="$whisper_executable"
export MKA_WHISPER_MODEL="$whisper_model"
export MKA_PROTOCOL_MODEL=qwen3.8:27b
export MKA_OLLAMA_ENDPOINT=http://127.0.0.1:11434
export MKA_DIARIZATION_MODE=auto
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"]'
# Keep feature sources ahead of the reused venv's editable installations.
export PYTHONPATH="$assistant_src:$lab_root"
exec "$north_venv/bin/streamlit" run "$assistant_src/mka/ui/streamlit_app.py"
+32
View File
@@ -92,6 +92,8 @@ class ProcessingOutcome:
failed_stage: str | None = None failed_stage: str | None = None
error_message: str | None = None error_message: str | None = None
speaker_attribution_available: bool | None = None speaker_attribution_available: bool | None = None
awaiting_speaker_review: bool = False
detected_speaker_count: int | None = None
@dataclass(frozen=True) @dataclass(frozen=True)
@@ -249,6 +251,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, "diarization_container_args": self.settings.diarization_container_args,
"stop_after_diarization": options.diarization_enabled,
} }
) )
current_stage: str | None = None current_stage: str | None = None
@@ -299,6 +302,15 @@ class MeetingProcessingService:
failed_stage=failed_stage or current_stage or "preparing", failed_stage=failed_stage or current_stage or "preparing",
error_message=failure_message, error_message=failure_message,
) )
if result.protocol_path is None:
return ProcessingOutcome(
succeeded=True,
run_dir=result.run_dir,
original_protocol=None,
protocol_path=None,
awaiting_speaker_review=True,
detected_speaker_count=self._detected_speaker_count(result.run_dir),
)
protocol_path = Path(result.protocol_path) protocol_path = Path(result.protocol_path)
return ProcessingOutcome( return ProcessingOutcome(
succeeded=True, succeeded=True,
@@ -401,6 +413,13 @@ class MeetingProcessingService:
context_data["speaker_mappings"] = dict(sorted(speaker_mappings.items())) context_data["speaker_mappings"] = dict(sorted(speaker_mappings.items()))
self._apply_glossary(context_data) self._apply_glossary(context_data)
context = self.meeting_lab.create_context(context_data) context = self.meeting_lab.create_context(context_data)
# A checkpoint mapping is user input, not a derived protocol artifact. Save it
# before inference so a failed generation remains retryable with the same review.
if context_path.is_symlink():
context_path.unlink()
context_path.write_text(
yaml.safe_dump(context.data, allow_unicode=True, sort_keys=False), encoding="utf-8"
)
def relay(event: Any) -> None: def relay(event: Any) -> None:
self._update_timer(event) self._update_timer(event)
@@ -432,6 +451,7 @@ class MeetingProcessingService:
self.timer.finish_run() self.timer.finish_run()
raise raise
self.timer.finish_run() self.timer.finish_run()
self._persist_timing(Path(result.run_dir))
protocol_path = Path(result.protocol_path) protocol_path = Path(result.protocol_path)
return ProcessingOutcome( return ProcessingOutcome(
succeeded=True, succeeded=True,
@@ -473,6 +493,18 @@ class MeetingProcessingService:
value = metadata.get("speaker_attribution_available") value = metadata.get("speaker_attribution_available")
return value if isinstance(value, bool) else None return value if isinstance(value, bool) else None
@staticmethod
def _detected_speaker_count(run_dir: Path | None) -> int | None:
if run_dir is None:
return None
metadata_path = Path(run_dir) / "run_metadata.json"
try:
metadata = json.loads(metadata_path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError):
return None
value = metadata.get("diarization", {}).get("speaker_count")
return value if isinstance(value, int) else None
@staticmethod @staticmethod
def _read_failure(run_dir: Path | None) -> dict[str, str]: def _read_failure(run_dir: Path | None) -> dict[str, str]:
if run_dir is None: if run_dir is None:
+25 -16
View File
@@ -554,7 +554,15 @@ def _render_result() -> None:
st.caption("Intermediate artifacts and run metadata were preserved here.") st.caption("Intermediate artifacts and run metadata were preserved here.")
return return
st.success("Processing completed. Review the generated protocol before use.") awaiting_review = getattr(outcome, "awaiting_speaker_review", False)
if awaiting_review:
count = getattr(outcome, "detected_speaker_count", None)
detected = f"{count} speakers detected" if count is not None else "speakers detected"
st.info(
f"Diarization complete — {detected}. Assign speakers if desired, then generate the protocol."
)
else:
st.success("Processing completed. Review the generated protocol before use.")
st.caption(f"Run artifacts: {outcome.run_dir}") st.caption(f"Run artifacts: {outcome.run_dir}")
if outcome.speaker_attribution_available is False: if outcome.speaker_attribution_available is False:
st.warning( st.warning(
@@ -563,17 +571,18 @@ def _render_result() -> None:
) )
if message := st.session_state.pop("speaker_mapping_message", None): if message := st.session_state.pop("speaker_mapping_message", None):
st.success(message) st.success(message)
with st.expander("Original generated protocol", expanded=False): if not awaiting_review:
st.markdown(outcome.original_protocol or "") with st.expander("Original generated protocol", expanded=False):
edited = st.text_area( st.markdown(outcome.original_protocol or "")
"Editable protocol", edited = st.text_area(
key="edited_protocol", "Editable protocol",
height=500, key="edited_protocol",
help="The original protocol.md remains unchanged.", height=500,
) help="The original protocol.md remains unchanged.",
if st.button("Save edited protocol", type="primary"): )
path = MeetingProcessingService.save_edited_protocol(outcome.run_dir, edited) if st.button("Save edited protocol", type="primary"):
st.success(f"Saved edited protocol to {path}") path = MeetingProcessingService.save_edited_protocol(outcome.run_dir, edited)
st.success(f"Saved edited protocol to {path}")
try: try:
service = MeetingProcessingService(AppSettings.from_environment(), MeetingLabGateway()) service = MeetingProcessingService(AppSettings.from_environment(), MeetingLabGateway())
@@ -590,7 +599,7 @@ def _render_result() -> None:
if duplicate_assignments: if duplicate_assignments:
st.error("Each participant can be assigned to only one detected speaker.") st.error("Each participant can be assigned to only one detected speaker.")
if st.button( if st.button(
"Regenerate protocol with confirmed speakers", "Generate protocol" if awaiting_review else "Regenerate protocol with confirmed speakers",
disabled=duplicate_assignments, disabled=duplicate_assignments,
type="primary", type="primary",
): ):
@@ -610,7 +619,7 @@ def _render_result() -> None:
performance_profile=selected_profile, performance_profile=selected_profile,
), ),
states, states,
"Starting protocol regeneration", "Starting protocol generation" if awaiting_review else "Starting protocol regeneration",
) )
except (OSError, RuntimeError, ValueError) as exc: except (OSError, RuntimeError, ValueError) as exc:
_remember_regeneration_timing( _remember_regeneration_timing(
@@ -618,13 +627,13 @@ def _render_result() -> None:
"Protocol regeneration failed", "Protocol regeneration failed",
service.timer.snapshot(), service.timer.snapshot(),
) )
st.error(f"Protocol regeneration failed: {exc}") st.error(f"Protocol generation failed: {exc}")
else: else:
_remember_regeneration_timing(states, message, timing) _remember_regeneration_timing(states, message, timing)
st.session_state.outcome = regenerated st.session_state.outcome = regenerated
_queue_edited_protocol(regenerated.original_protocol or "") _queue_edited_protocol(regenerated.original_protocol or "")
st.session_state.speaker_mapping_message = ( st.session_state.speaker_mapping_message = (
"Speaker mappings saved and protocol regenerated." "Speaker mappings saved and protocol generated."
) )
st.rerun() st.rerun()
+3
View File
@@ -104,9 +104,12 @@ def test_paired_anonymous_generation_and_mapped_regeneration(
ProcessingOptions(diarization_enabled=True, performance_profile=profile), ProcessingOptions(diarization_enabled=True, performance_profile=profile),
) )
assert outcome.succeeded assert outcome.succeeded
assert outcome.awaiting_speaker_review
assert prepare.call_args.kwargs["normalization_enabled"] is True assert prepare.call_args.kwargs["normalization_enabled"] is True
assert transcribe.call_args.args[3] == language assert transcribe.call_args.args[3] == language
root = outcome.run_dir root = outcome.run_dir
assert calls == []
service.regenerate_protocol(root, {}, performance_profile=profile)
first = root / "protocol/generations/001" first = root / "protocol/generations/001"
before = {p.name: p.read_bytes() for p in first.iterdir()} before = {p.name: p.read_bytes() for p in first.iterdir()}
original = (root / "diarization/transcript_diarized.json").read_bytes() original = (root / "diarization/transcript_diarized.json").read_bytes()
+93 -1
View File
@@ -6,6 +6,7 @@ from types import SimpleNamespace
from typing import Any from typing import Any
import pytest import pytest
import yaml
from mka.application.config import AppSettings from mka.application.config import AppSettings
from mka.application.meeting_service import ( from mka.application.meeting_service import (
@@ -99,6 +100,24 @@ class FakeMeetingLab:
) )
) )
return SimpleNamespace(exit_code=2, run_dir=self.run_dir, protocol_path=None) return SimpleNamespace(exit_code=2, run_dir=self.run_dir, protocol_path=None)
if config["stop_after_diarization"]:
write_speaker_review_artifacts(self.run_dir)
(self.run_dir / "run_metadata.json").write_text(
json.dumps(
{
"status": "awaiting_speaker_review",
"diarization": {"speaker_count": 2},
}
),
encoding="utf-8",
)
progress_sink(
SimpleNamespace(
stage="diarization", status="completed", elapsed_seconds=0.3,
progress=None, message=None,
)
)
return SimpleNamespace(exit_code=0, run_dir=self.run_dir, protocol_path=None)
protocol = self.run_dir / "protocol.md" protocol = self.run_dir / "protocol.md"
protocol.write_text("# Generated protocol\n", encoding="utf-8") protocol.write_text("# Generated protocol\n", encoding="utf-8")
return SimpleNamespace(exit_code=0, run_dir=self.run_dir, protocol_path=protocol) return SimpleNamespace(exit_code=0, run_dir=self.run_dir, protocol_path=protocol)
@@ -387,7 +406,7 @@ def test_process_propagates_enabled_diarization_and_progress(tmp_path: Path) ->
audio.write_bytes(b"audio") audio.write_bytes(b"audio")
events = [] events = []
service.process( outcome = service.process(
audio, audio,
meeting(), meeting(),
participants(), participants(),
@@ -397,6 +416,9 @@ def test_process_propagates_enabled_diarization_and_progress(tmp_path: Path) ->
assert gateway.config_values is not None assert gateway.config_values is not None
assert gateway.config_values["diarization"] == "gpu" assert gateway.config_values["diarization"] == "gpu"
assert gateway.config_values["stop_after_diarization"] is True
assert outcome.awaiting_speaker_review is True
assert outcome.detected_speaker_count == 2
assert [(event.stage, event.status) for event in events[:2]] == [ assert [(event.stage, event.status) for event in events[:2]] == [
("preparing", "started"), ("preparing", "started"),
("preparing", "completed"), ("preparing", "completed"),
@@ -404,6 +426,33 @@ 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_diarization_checkpoint_defers_protocol_but_disabled_run_does_not(tmp_path: Path) -> None:
service, gateway = make_service(tmp_path)
audio = tmp_path / "meeting.wav"
audio.write_bytes(b"audio")
checkpoint = service.process(
audio, meeting(), participants(), ProcessingOptions(diarization_enabled=True)
)
assert checkpoint.succeeded
assert checkpoint.awaiting_speaker_review
assert checkpoint.original_protocol is None
assert checkpoint.protocol_path is None
normal_root = tmp_path / "without-diarization"
normal_root.mkdir()
normal_service, normal_gateway = make_service(normal_root)
normal_audio = normal_root / "meeting.wav"
normal_audio.write_bytes(b"audio")
normal = normal_service.process(
normal_audio, meeting(), participants(), ProcessingOptions(diarization_enabled=False)
)
assert normal.succeeded
assert not normal.awaiting_speaker_review
assert normal.protocol_path is not None
assert normal_gateway.config_values["stop_after_diarization"] is False
def test_process_propagates_ordered_diarization_container_args(tmp_path: Path) -> None: def test_process_propagates_ordered_diarization_container_args(tmp_path: Path) -> None:
service, gateway = make_service(tmp_path) service, gateway = make_service(tmp_path)
service.settings = replace( service.settings = replace(
@@ -537,6 +586,49 @@ def test_protocol_regeneration_allows_unmapped_and_rejects_duplicate_participant
) )
@pytest.mark.parametrize(
"mappings",
[
{"SPEAKER_00": "martin"},
{"SPEAKER_00": "martin", "SPEAKER_01": "anna"},
],
)
def test_protocol_generation_uses_partial_and_complete_confirmed_mappings(
tmp_path: Path, mappings: dict[str, str]
) -> None:
service, gateway = make_service(tmp_path)
write_speaker_review_artifacts(gateway.run_dir)
outcome = service.regenerate_protocol(gateway.run_dir, mappings)
assert outcome.succeeded
assert gateway.regeneration is not None
assert gateway.regeneration["meeting_context"].data["speaker_mappings"] == mappings
def test_failed_protocol_generation_keeps_checkpoint_mapping_for_retry(tmp_path: Path) -> None:
service, gateway = make_service(tmp_path)
source = write_speaker_review_artifacts(gateway.run_dir)
original_source = source.read_bytes()
original_regeneration = gateway.regenerate_protocol
def fail_regeneration(*args, **kwargs):
raise RuntimeError("model unavailable")
gateway.regenerate_protocol = fail_regeneration
with pytest.raises(RuntimeError, match="model unavailable"):
service.regenerate_protocol(gateway.run_dir, {"SPEAKER_00": "martin"})
persisted = yaml.safe_load(
(gateway.run_dir / "context" / "meeting_context.yaml").read_text(encoding="utf-8")
)
assert persisted["speaker_mappings"] == {"SPEAKER_00": "martin"}
gateway.regenerate_protocol = original_regeneration
retry = service.regenerate_protocol(gateway.run_dir, {"SPEAKER_00": "martin"})
assert retry.succeeded
assert source.read_bytes() == original_source
def test_fallback_attribution_loss_is_read_from_runtime_metadata(tmp_path: Path) -> None: def test_fallback_attribution_loss_is_read_from_runtime_metadata(tmp_path: Path) -> None:
run_dir = tmp_path / "run" run_dir = tmp_path / "run"
protocol_dir = run_dir / "protocol" protocol_dir = run_dir / "protocol"
+6 -4
View File
@@ -80,7 +80,7 @@ def test_widget_reruns_filter_release_and_update_status():
assert not app.exception assert not app.exception
def test_anonymous_regeneration_remains_allowed(monkeypatch): def test_checkpoint_allows_anonymous_protocol_generation(monkeypatch):
from mka.application.meeting_service import SpeakerMappingReview, SpeakerReview from mka.application.meeting_service import SpeakerMappingReview, SpeakerReview
from mka.ui import streamlit_app as ui from mka.ui import streamlit_app as ui
@@ -89,6 +89,8 @@ def test_anonymous_regeneration_remains_allowed(monkeypatch):
run_dir=Path("/tmp/mapping-test"), run_dir=Path("/tmp/mapping-test"),
speaker_attribution_available=True, speaker_attribution_available=True,
original_protocol="Anonymous protocol", original_protocol="Anonymous protocol",
awaiting_speaker_review=True,
detected_speaker_count=1,
) )
review = SpeakerMappingReview((SpeakerReview("SPEAKER_00", ()),), (("a", "A"),), {}) review = SpeakerMappingReview((SpeakerReview("SPEAKER_00", ()),), (("a", "A"),), {})
calls = [] calls = []
@@ -124,9 +126,9 @@ def test_anonymous_regeneration_remains_allowed(monkeypatch):
_render_result() _render_result()
app = AppTest.from_function(result_app, args=(outcome,)).run() app = AppTest.from_function(result_app, args=(outcome,)).run()
regenerate = app.button[1] generate = next(button for button in app.button if button.label == "Generate protocol")
assert not regenerate.disabled assert not generate.disabled
assert app.warning assert app.warning
regenerate.click().run() generate.click().run()
assert calls == [{}] assert calls == [{}]
assert not app.exception assert not app.exception