feat: add speaker mapping and workflow improvements
This commit is contained in:
+268
-8
@@ -9,6 +9,11 @@ from uuid import uuid4
|
||||
import streamlit as st
|
||||
|
||||
from mka.application.config import AppSettings, ConfigurationError
|
||||
from mka.application.glossary import (
|
||||
GLOSSARY_CATEGORIES,
|
||||
GlossaryConflictError,
|
||||
GlossaryRepository,
|
||||
)
|
||||
from mka.application.meeting_service import (
|
||||
STAGES,
|
||||
AppProgressEvent,
|
||||
@@ -23,6 +28,13 @@ from mka.application.people_yaml import (
|
||||
export_people_yaml,
|
||||
import_people_yaml,
|
||||
)
|
||||
from mka.application.run_inputs import (
|
||||
RunInputJsonError,
|
||||
RunInputState,
|
||||
export_run_inputs,
|
||||
import_run_inputs,
|
||||
run_input_filename,
|
||||
)
|
||||
from mka.integrations.meeting_lab import (
|
||||
MeetingLabGateway,
|
||||
MeetingLabUnavailableError,
|
||||
@@ -36,6 +48,87 @@ STAGE_LABELS = {
|
||||
}
|
||||
|
||||
|
||||
def _parse_aliases(value: str) -> tuple[str, ...]:
|
||||
"""Parse one alias per line while tolerating comma-separated input."""
|
||||
return tuple(
|
||||
alias.strip() for line in value.splitlines() for alias in line.split(",") if alias.strip()
|
||||
)
|
||||
|
||||
|
||||
def _render_glossary(repository: GlossaryRepository) -> None:
|
||||
"""Render simple global glossary CRUD controls."""
|
||||
with st.expander("Terminology glossary", expanded=False):
|
||||
st.caption(
|
||||
"Store canonical core terms and recognition aliases. Compound phrases are "
|
||||
"composed from meeting context during protocol generation."
|
||||
)
|
||||
with st.form("glossary_add"):
|
||||
columns = st.columns(2)
|
||||
canonical = columns[0].text_input("Canonical term")
|
||||
category = columns[1].selectbox("Category", GLOSSARY_CATEGORIES)
|
||||
aliases = st.text_area("Aliases", help="One per line or comma-separated.")
|
||||
description = st.text_area("Optional description")
|
||||
if st.form_submit_button("Add glossary entry"):
|
||||
try:
|
||||
repository.create(
|
||||
canonical,
|
||||
category,
|
||||
aliases=_parse_aliases(aliases),
|
||||
description=description,
|
||||
)
|
||||
except (GlossaryConflictError, ValueError) as exc:
|
||||
st.error(str(exc))
|
||||
else:
|
||||
st.success("Glossary entry added.")
|
||||
st.rerun()
|
||||
|
||||
search = st.text_input("Search glossary")
|
||||
active_only = st.checkbox("Show active entries only")
|
||||
entries = repository.list(search, active_only=active_only)
|
||||
st.caption(f"{len(entries)} glossary entries")
|
||||
for entry in entries:
|
||||
status = "active" if entry.is_active else "inactive"
|
||||
with (
|
||||
st.expander(f"{entry.canonical_term} · {entry.category} · {status}"),
|
||||
st.form(f"glossary_edit_{entry.id}"),
|
||||
):
|
||||
columns = st.columns(2)
|
||||
edited_canonical = columns[0].text_input("Canonical term", entry.canonical_term)
|
||||
edited_category = columns[1].selectbox(
|
||||
"Category",
|
||||
GLOSSARY_CATEGORIES,
|
||||
index=GLOSSARY_CATEGORIES.index(entry.category),
|
||||
)
|
||||
edited_aliases = st.text_area("Aliases", "\n".join(entry.aliases))
|
||||
edited_description = st.text_area("Optional description", entry.description or "")
|
||||
edited_active = st.checkbox("Active", value=entry.is_active)
|
||||
delete_confirmed = st.checkbox(
|
||||
"Permanently delete this entry",
|
||||
help="Prefer clearing Active for normal use.",
|
||||
)
|
||||
action_columns = st.columns(2)
|
||||
save = action_columns[0].form_submit_button("Save changes")
|
||||
delete = action_columns[1].form_submit_button(
|
||||
"Delete", disabled=not delete_confirmed
|
||||
)
|
||||
try:
|
||||
if save:
|
||||
repository.update(
|
||||
entry.id,
|
||||
edited_canonical,
|
||||
edited_category,
|
||||
aliases=_parse_aliases(edited_aliases),
|
||||
description=edited_description,
|
||||
is_active=edited_active,
|
||||
)
|
||||
st.rerun()
|
||||
if delete:
|
||||
repository.delete(entry.id)
|
||||
st.rerun()
|
||||
except (GlossaryConflictError, ValueError) as exc:
|
||||
st.error(str(exc))
|
||||
|
||||
|
||||
def _new_participant() -> dict[str, str]:
|
||||
return {
|
||||
"row_id": uuid4().hex,
|
||||
@@ -51,6 +144,51 @@ def _initialize_state() -> None:
|
||||
st.session_state.setdefault("participants", [_new_participant()])
|
||||
st.session_state.setdefault("outcome", None)
|
||||
st.session_state.setdefault("edited_protocol", "")
|
||||
st.session_state.setdefault("source_media_widget_generation", 0)
|
||||
_apply_pending_run_inputs()
|
||||
_apply_pending_edited_protocol()
|
||||
|
||||
|
||||
def _queue_run_inputs(value: RunInputState) -> None:
|
||||
"""Defer form restoration until the beginning of the next Streamlit run."""
|
||||
st.session_state["pending_run_inputs"] = value
|
||||
|
||||
|
||||
def _apply_pending_run_inputs() -> None:
|
||||
"""Restore imported values before any corresponding widget is instantiated."""
|
||||
value = st.session_state.pop("pending_run_inputs", None)
|
||||
if value is None:
|
||||
return
|
||||
st.session_state.update(
|
||||
{
|
||||
"meeting_title": value.title,
|
||||
"meeting_description": value.description,
|
||||
"meeting_language": value.language,
|
||||
"meeting_has_date": value.meeting_date is not None,
|
||||
"meeting_date": value.meeting_date or date.today(),
|
||||
"participants": _people_to_rows(list(value.participants)),
|
||||
"audio_normalization": value.audio_normalization,
|
||||
"diarization_enabled": value.diarization_enabled,
|
||||
"imported_source_file_name": value.source_file_name,
|
||||
"run_input_import_message": (
|
||||
"Meeting inputs imported. Select the source media before processing."
|
||||
),
|
||||
"source_media_widget_generation": (
|
||||
st.session_state.get("source_media_widget_generation", 0) + 1
|
||||
),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def _apply_pending_edited_protocol() -> None:
|
||||
"""Apply a deferred widget value before the widget is instantiated."""
|
||||
if "pending_edited_protocol" in st.session_state:
|
||||
st.session_state["edited_protocol"] = st.session_state.pop("pending_edited_protocol")
|
||||
|
||||
|
||||
def _queue_edited_protocol(value: str) -> None:
|
||||
"""Defer an edited-protocol widget update until the next Streamlit run."""
|
||||
st.session_state["pending_edited_protocol"] = value
|
||||
|
||||
|
||||
def _people_to_rows(people: list[ParticipantInput]) -> list[dict[str, str]]:
|
||||
@@ -68,6 +206,27 @@ def _people_to_rows(people: list[ParticipantInput]) -> list[dict[str, str]]:
|
||||
]
|
||||
|
||||
|
||||
def _render_run_input_import() -> None:
|
||||
"""Render import controls before the widgets whose state they restore."""
|
||||
st.subheader("Input configuration")
|
||||
imported_file = st.file_uploader(
|
||||
"Import inputs",
|
||||
type=["json"],
|
||||
key="run_input_import_file",
|
||||
help="Restore form values only; source media is never included.",
|
||||
)
|
||||
if st.button("Import input configuration", disabled=imported_file is None):
|
||||
try:
|
||||
imported = import_run_inputs(imported_file.getvalue())
|
||||
except RunInputJsonError as exc:
|
||||
st.error(str(exc))
|
||||
else:
|
||||
_queue_run_inputs(imported)
|
||||
st.rerun()
|
||||
if message := st.session_state.pop("run_input_import_message", None):
|
||||
st.success(message)
|
||||
|
||||
|
||||
def _render_participants() -> list[ParticipantInput]:
|
||||
st.subheader("People")
|
||||
st.caption("Record whether each relevant person attended or was only mentioned.")
|
||||
@@ -196,6 +355,13 @@ def _render_result() -> None:
|
||||
|
||||
st.success("Processing completed. Review the generated protocol before use.")
|
||||
st.caption(f"Run artifacts: {outcome.run_dir}")
|
||||
if outcome.speaker_attribution_available is False:
|
||||
st.warning(
|
||||
"Speaker attribution was unavailable for this protocol because the "
|
||||
"safe-budget fallback removed diarization labels from the prompt."
|
||||
)
|
||||
if message := st.session_state.pop("speaker_mapping_message", None):
|
||||
st.success(message)
|
||||
with st.expander("Original generated protocol", expanded=False):
|
||||
st.markdown(outcome.original_protocol or "")
|
||||
edited = st.text_area(
|
||||
@@ -208,6 +374,60 @@ def _render_result() -> None:
|
||||
path = MeetingProcessingService.save_edited_protocol(outcome.run_dir, edited)
|
||||
st.success(f"Saved edited protocol to {path}")
|
||||
|
||||
try:
|
||||
service = MeetingProcessingService(AppSettings.from_environment(), MeetingLabGateway())
|
||||
review = service.load_speaker_mapping_review(outcome.run_dir)
|
||||
except (MeetingLabUnavailableError, OSError, ValueError) as exc:
|
||||
st.warning(f"Speaker mapping is unavailable: {exc}")
|
||||
return
|
||||
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}”")
|
||||
|
||||
duplicate_assignments = len(selections.values()) != len(set(selections.values()))
|
||||
if duplicate_assignments:
|
||||
st.error("Each participant can be assigned to only one detected speaker.")
|
||||
if st.button(
|
||||
"Regenerate protocol with confirmed speakers",
|
||||
disabled=duplicate_assignments,
|
||||
type="primary",
|
||||
):
|
||||
try:
|
||||
with st.spinner("Regenerating protocol without rerunning audio processing..."):
|
||||
regenerated = service.regenerate_protocol(outcome.run_dir, selections)
|
||||
except (OSError, RuntimeError, ValueError) as exc:
|
||||
st.error(f"Protocol regeneration failed: {exc}")
|
||||
else:
|
||||
st.session_state.outcome = regenerated
|
||||
_queue_edited_protocol(regenerated.original_protocol or "")
|
||||
st.session_state.speaker_mapping_message = (
|
||||
"Speaker mappings saved and protocol regenerated."
|
||||
)
|
||||
st.rerun()
|
||||
|
||||
|
||||
def main() -> None:
|
||||
st.set_page_config(page_title="Meeting Assistant", layout="wide")
|
||||
@@ -215,15 +435,36 @@ def main() -> None:
|
||||
st.title("Meeting Assistant")
|
||||
st.caption("Create meeting context, run Meeting Lab, and review the protocol.")
|
||||
|
||||
settings = AppSettings.from_environment()
|
||||
glossary = GlossaryRepository(settings.glossary_database)
|
||||
glossary.initialize()
|
||||
_render_glossary(glossary)
|
||||
|
||||
_render_run_input_import()
|
||||
|
||||
st.header("Meeting and audio")
|
||||
audio = st.file_uploader("Audio recording", type=["wav", "flac", "m4a"])
|
||||
title = st.text_input("Meeting title")
|
||||
description = st.text_area("Description / context", height=100)
|
||||
audio = st.file_uploader(
|
||||
"Audio recording",
|
||||
type=["wav", "flac", "m4a"],
|
||||
key=f"source_media_{st.session_state.source_media_widget_generation}",
|
||||
)
|
||||
imported_source_name = st.session_state.get("imported_source_file_name")
|
||||
if audio is None and imported_source_name:
|
||||
st.caption(
|
||||
f"Previous source filename: {imported_source_name}. "
|
||||
"Select the media file again before processing."
|
||||
)
|
||||
title = st.text_input("Meeting title", key="meeting_title")
|
||||
description = st.text_area("Description / context", height=100, key="meeting_description")
|
||||
metadata_columns = st.columns(3)
|
||||
language = metadata_columns[0].selectbox("Meeting language", options=["de", "en"])
|
||||
has_date = metadata_columns[1].checkbox("Meeting date is known", value=True)
|
||||
language = metadata_columns[0].selectbox(
|
||||
"Meeting language", options=["de", "en"], key="meeting_language"
|
||||
)
|
||||
has_date = metadata_columns[1].checkbox(
|
||||
"Meeting date is known", value=True, key="meeting_has_date"
|
||||
)
|
||||
selected_date: date | None = (
|
||||
metadata_columns[2].date_input("Meeting date") if has_date else None
|
||||
metadata_columns[2].date_input("Meeting date", key="meeting_date") if has_date else None
|
||||
)
|
||||
|
||||
participants = _render_participants()
|
||||
@@ -232,6 +473,7 @@ def main() -> None:
|
||||
audio_normalization = st.checkbox(
|
||||
"Audio normalization",
|
||||
value=True,
|
||||
key="audio_normalization",
|
||||
help=(
|
||||
"Normalize speech loudness during preparation. WAV, FLAC, and M4A "
|
||||
"are always converted to the canonical Meeting Lab audio format, "
|
||||
@@ -240,9 +482,28 @@ def main() -> None:
|
||||
)
|
||||
diarization_enabled = st.checkbox(
|
||||
"Enable speaker diarization",
|
||||
key="diarization_enabled",
|
||||
help="Speaker labels remain anonymous; identities are never inferred.",
|
||||
)
|
||||
|
||||
current_inputs = RunInputState(
|
||||
title=title,
|
||||
description=description,
|
||||
language=language,
|
||||
meeting_date=selected_date,
|
||||
participants=tuple(participants),
|
||||
audio_normalization=audio_normalization,
|
||||
diarization_enabled=diarization_enabled,
|
||||
source_file_name=(audio.name if audio is not None else imported_source_name),
|
||||
)
|
||||
st.download_button(
|
||||
"Export inputs",
|
||||
data=export_run_inputs(current_inputs).encode("utf-8"),
|
||||
file_name=run_input_filename(title),
|
||||
mime="application/json",
|
||||
help="Download the current form configuration without source media or run results.",
|
||||
)
|
||||
|
||||
if st.button("Start processing", type="primary", disabled=audio is None):
|
||||
if not title.strip():
|
||||
st.error("Meeting title is required.")
|
||||
@@ -252,9 +513,8 @@ def main() -> None:
|
||||
):
|
||||
st.error("Every person row needs a name and participant ID.")
|
||||
else:
|
||||
settings = AppSettings.from_environment()
|
||||
try:
|
||||
service = MeetingProcessingService(settings, MeetingLabGateway())
|
||||
service = MeetingProcessingService(settings, MeetingLabGateway(), glossary)
|
||||
meeting = MeetingDetails(
|
||||
title=title,
|
||||
language=language,
|
||||
|
||||
Reference in New Issue
Block a user