From 98409536874201b913e015fdfc17a6693f3139bd Mon Sep 17 00:00:00 2001 From: Martin Date: Sat, 12 Sep 2026 11:22:09 +0200 Subject: [PATCH] Improve speaker mapping UX --- PROJECT_KNOWLEDGE.md | 6 ++ README.md | 5 ++ src/mka/ui/streamlit_app.py | 92 +++++++++++++++------ tests/test_speaker_mapping_ui.py | 132 +++++++++++++++++++++++++++++++ 4 files changed, 212 insertions(+), 23 deletions(-) create mode 100644 tests/test_speaker_mapping_ui.py diff --git a/PROJECT_KNOWLEDGE.md b/PROJECT_KNOWLEDGE.md index a5f28ce..63a7d7f 100644 --- a/PROJECT_KNOWLEDGE.md +++ b/PROJECT_KNOWLEDGE.md @@ -107,6 +107,12 @@ Speakers otherwise remain anonymous. Automatic speaker-name inference is not allowed. The GUI must create and edit Meeting Context; hand-written YAML is not a product requirement. +Speaker selectors filter participants using a snapshot of all current widget +selections, falling back to saved mappings before first interaction. Filtering +retains existing assignments; clearing a selection releases the participant. +Completeness counts and warnings cover detected speakers only and do not gate +protocol regeneration or change mapping persistence. + ## Progress Contract Meeting Lab emits stage-based progress events for: diff --git a/README.md b/README.md index c40717b..1a02aa5 100644 --- a/README.md +++ b/README.md @@ -49,6 +49,11 @@ compatibility path. Diarization is optional and produces anonymous speaker labels. A label identifies a participant only when the user explicitly confirms the mapping; automatic speaker-name inference is not allowed. +Speaker selectors hide participants already assigned to other speakers while keeping +the current assignment available. The UI shows detected, assigned and unassigned +counts, marks unassigned speakers, and warns when mappings remain incomplete. +Anonymous-speaker protocol generation remains available. + After a diarized run, the result view lists detected `SPEAKER_XX` labels with short transcript excerpts. Confirmed mappings regenerate only the protocol from the existing diarized transcript; audio preparation, Whisper and Pyannote diff --git a/src/mka/ui/streamlit_app.py b/src/mka/ui/streamlit_app.py index 6946ffb..5e4724f 100644 --- a/src/mka/ui/streamlit_app.py +++ b/src/mka/ui/streamlit_app.py @@ -21,6 +21,7 @@ from mka.application.meeting_service import ( MeetingProcessingService, ParticipantInput, ProcessingOptions, + SpeakerMappingReview, stable_id, ) from mka.application.people_yaml import ( @@ -340,6 +341,73 @@ def _progress_callback( return update +def _speaker_options( + participants: tuple[str, ...], mappings: dict[str, str | None], speaker_label: str +) -> list[str | None]: + """Reserve other speakers' participants while retaining this speaker's mapping.""" + current = mappings.get(speaker_label) + reserved = {value for label, value in mappings.items() if label != speaker_label} + options: list[str | None] = [None] + options.extend(person for person in participants if person == current or person not in reserved) + if current is not None and current not in options: + options.append(current) + return options + + +def _mapping_counts( + speaker_labels: tuple[str, ...], mappings: dict[str, str | None] +) -> tuple[int, int, int]: + """Count only detected speakers, including explicit cleared selections.""" + detected = len(speaker_labels) + assigned = sum(mappings.get(label) is not None for label in speaker_labels) + return detected, assigned, detected - assigned + + +def _render_speaker_mapping(review: SpeakerMappingReview, run_name: str) -> dict[str, str]: + st.subheader("Identify diarized speakers") + st.caption( + "Confirm identities explicitly. Unmapped speakers remain anonymous; " + "the diarized source transcript is not modified." + ) + participant_names = dict(review.participants) + # Read every widget before rendering so later speakers also reserve their person. + keys = { + speaker.speaker_label: f"speaker_mapping_{run_name}_{speaker.speaker_label}" + for speaker in review.speakers + } + mappings = { + label: st.session_state.get(key, review.current_mappings.get(label)) + for label, key in keys.items() + } + detected, assigned, unassigned = _mapping_counts(tuple(keys), mappings) + st.markdown(f"**{detected} speakers detected · {assigned} assigned · {unassigned} unassigned**") + if unassigned: + st.warning( + "Some detected speakers have no confirmed participant mapping. " + "Check whether a participant is missing or speaker assignment is incomplete. " + "You can still generate a protocol with anonymous speakers." + ) + selections: dict[str, str] = {} + for speaker in review.speakers: + label = speaker.speaker_label + current = mappings[label] + options = _speaker_options(tuple(participant_names), mappings, label) + st.session_state[keys[label]] = current + selected = st.selectbox( + f"{label} — Unassigned" if current is None else label, + options=options, + format_func=lambda value, names=participant_names: ( + "Unmapped / Unknown" if value is None else names.get(value, value) + ), + key=keys[label], + ) + if selected is not None: + selections[label] = selected + for excerpt in speaker.excerpts: + st.caption(f"“{excerpt}”") + return selections + + def _render_result() -> None: outcome = st.session_state.outcome if outcome is None: @@ -383,29 +451,7 @@ def _render_result() -> None: if review is None or not review.speakers: return - st.subheader("Identify diarized speakers") - st.caption( - "Confirm identities explicitly. Unmapped speakers remain anonymous; " - "the diarized source transcript is not modified." - ) - participant_names = dict(review.participants) - options = [None, *participant_names] - selections: dict[str, str] = {} - for speaker in review.speakers: - current = review.current_mappings.get(speaker.speaker_label) - selected = st.selectbox( - speaker.speaker_label, - options=options, - index=options.index(current) if current in options else 0, - format_func=lambda value, names=participant_names: ( - "Unmapped / Unknown" if value is None else names[value] - ), - key=f"speaker_mapping_{outcome.run_dir.name}_{speaker.speaker_label}", - ) - if selected is not None: - selections[speaker.speaker_label] = selected - for excerpt in speaker.excerpts: - st.caption(f"“{excerpt}”") + selections = _render_speaker_mapping(review, outcome.run_dir.name) duplicate_assignments = len(selections.values()) != len(set(selections.values())) if duplicate_assignments: diff --git a/tests/test_speaker_mapping_ui.py b/tests/test_speaker_mapping_ui.py new file mode 100644 index 0000000..74b52fc --- /dev/null +++ b/tests/test_speaker_mapping_ui.py @@ -0,0 +1,132 @@ +from pathlib import Path +from types import SimpleNamespace + +import pytest +from streamlit.testing.v1 import AppTest + +from mka.ui.streamlit_app import _mapping_counts, _speaker_options + + +@pytest.mark.parametrize("label", ["SPEAKER_00", "SPEAKER_01", "SPEAKER_02"]) +def test_unmapped_options_include_all_participants(label): + assert _speaker_options(("a", "b"), {}, label) == [None, "a", "b"] + + +def test_options_reserve_other_assignments_and_preserve_current(): + mappings = {"SPEAKER_00": "a"} + assert _speaker_options(("a", "b"), mappings, "SPEAKER_00") == [None, "a", "b"] + for label in ("SPEAKER_01", "SPEAKER_02"): + assert _speaker_options(("a", "b"), mappings, label) == [None, "b"] + mappings["SPEAKER_00"] = "b" + assert _speaker_options(("a", "b"), mappings, "SPEAKER_01") == [None, "a"] + mappings["SPEAKER_00"] = None + assert _speaker_options(("a", "b"), mappings, "SPEAKER_01") == [None, "a", "b"] + + +def test_existing_duplicate_or_missing_participant_is_not_removed(): + assert _speaker_options(("b",), {"s0": "a", "s1": "a"}, "s0") == [None, "b", "a"] + + +@pytest.mark.parametrize( + ("mappings", "expected"), + [ + ({}, (2, 0, 2)), + ({"s0": "a", "other": "b"}, (2, 1, 1)), + ({"s0": "a", "s1": "b"}, (2, 2, 0)), + ({"s0": None}, (2, 0, 2)), + ], +) +def test_counts_only_include_detected_speakers(mappings, expected): + assert _mapping_counts(("s0", "s1"), mappings) == expected + + +def mapping_app(): + from mka.application.meeting_service import SpeakerMappingReview, SpeakerReview + from mka.ui.streamlit_app import _render_speaker_mapping + + _render_speaker_mapping( + SpeakerMappingReview( + tuple(SpeakerReview(f"SPEAKER_0{i}", ()) for i in range(3)), + (("a", "Participant A"), ("b", "Participant B"), ("c", "Participant C")), + {"SPEAKER_00": "a"}, + ), + "test", + ) + + +def test_widget_reruns_filter_release_and_update_status(): + app = AppTest.from_function(mapping_app).run() + assert not app.exception + assert app.selectbox[0].value == "a" + assert "Participant A" not in app.selectbox[1].options + assert len(app.warning) == 1 + assert "3 speakers detected · 1 assigned · 2 unassigned" in app.markdown[0].value + assert "Unassigned" in app.selectbox[1].label + + app.selectbox[2].select("c").run() + assert app.selectbox[0].value == "a" + assert "Participant C" not in app.selectbox[0].options + app.selectbox[1].select("b").run() + assert not app.warning + assert "3 assigned · 0 unassigned" in app.markdown[0].value + app.selectbox[0].select(None).run() + assert app.warning + assert "Participant A" in app.selectbox[1].options + assert app.selectbox[1].value == "b" + app.selectbox[1].select("a").run() + assert "Participant B" in app.selectbox[0].options + assert "Participant A" not in app.selectbox[0].options + assert app.selectbox[0].value is None + assert not app.exception + + +def test_anonymous_regeneration_remains_allowed(monkeypatch): + from mka.application.meeting_service import SpeakerMappingReview, SpeakerReview + from mka.ui import streamlit_app as ui + + outcome = SimpleNamespace( + succeeded=True, + run_dir=Path("/tmp/mapping-test"), + speaker_attribution_available=True, + original_protocol="Anonymous protocol", + ) + review = SpeakerMappingReview((SpeakerReview("SPEAKER_00", ()),), (("a", "A"),), {}) + calls = [] + + class Service: + def __init__(self, *args): + pass + + def load_speaker_mapping_review(self, run_dir): + return review + + def regenerate_protocol(self, run_dir, selections, **kwargs): + calls.append(selections) + return outcome + + monkeypatch.setattr(ui, "MeetingProcessingService", Service) + monkeypatch.setattr(ui.AppSettings, "from_environment", lambda: None) + monkeypatch.setattr(ui, "MeetingLabGateway", lambda: None) + monkeypatch.setattr(ui, "_remember_regeneration_timing", lambda *args: None, raising=False) + monkeypatch.setattr( + ui, + "_run_with_live_progress", + lambda service, action, states, message: (action(None), states, message, None), + raising=False, + ) + + def result_app(outcome): + import streamlit as st + + from mka.ui.streamlit_app import _render_result + + st.session_state.outcome = outcome + _render_result() + + app = AppTest.from_function(result_app, args=(outcome,)).run() + regenerate = app.button[1] + assert not regenerate.disabled + assert app.warning + regenerate.click().run() + assert calls == [{}] + assert not app.exception