Improve speaker mapping UX

This commit is contained in:
2026-09-12 11:22:09 +02:00
parent e55dd74c19
commit 9840953687
4 changed files with 212 additions and 23 deletions
+6
View File
@@ -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:
+5
View File
@@ -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
+69 -23
View File
@@ -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:
+132
View File
@@ -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