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
|
allowed. The GUI must create and edit Meeting Context; hand-written YAML is not
|
||||||
a product requirement.
|
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
|
## Progress Contract
|
||||||
|
|
||||||
Meeting Lab emits stage-based progress events for:
|
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
|
labels. A label identifies a participant only when the user explicitly
|
||||||
confirms the mapping; automatic speaker-name inference is not allowed.
|
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
|
After a diarized run, the result view lists detected `SPEAKER_XX` labels with
|
||||||
short transcript excerpts. Confirmed mappings regenerate only the protocol
|
short transcript excerpts. Confirmed mappings regenerate only the protocol
|
||||||
from the existing diarized transcript; audio preparation, Whisper and Pyannote
|
from the existing diarized transcript; audio preparation, Whisper and Pyannote
|
||||||
|
|||||||
+69
-23
@@ -21,6 +21,7 @@ from mka.application.meeting_service import (
|
|||||||
MeetingProcessingService,
|
MeetingProcessingService,
|
||||||
ParticipantInput,
|
ParticipantInput,
|
||||||
ProcessingOptions,
|
ProcessingOptions,
|
||||||
|
SpeakerMappingReview,
|
||||||
stable_id,
|
stable_id,
|
||||||
)
|
)
|
||||||
from mka.application.people_yaml import (
|
from mka.application.people_yaml import (
|
||||||
@@ -340,6 +341,73 @@ def _progress_callback(
|
|||||||
return update
|
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:
|
def _render_result() -> None:
|
||||||
outcome = st.session_state.outcome
|
outcome = st.session_state.outcome
|
||||||
if outcome is None:
|
if outcome is None:
|
||||||
@@ -383,29 +451,7 @@ def _render_result() -> None:
|
|||||||
if review is None or not review.speakers:
|
if review is None or not review.speakers:
|
||||||
return
|
return
|
||||||
|
|
||||||
st.subheader("Identify diarized speakers")
|
selections = _render_speaker_mapping(review, outcome.run_dir.name)
|
||||||
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}”")
|
|
||||||
|
|
||||||
duplicate_assignments = len(selections.values()) != len(set(selections.values()))
|
duplicate_assignments = len(selections.values()) != len(set(selections.values()))
|
||||||
if duplicate_assignments:
|
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