Improve speaker mapping UX
This commit is contained in:
@@ -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:
|
||||
|
||||
@@ -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
@@ -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:
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user