"""Streamlit presentation layer for the first Meeting Assistant MVP.""" from __future__ import annotations import time from collections.abc import Callable from concurrent.futures import ThreadPoolExecutor from datetime import date from queue import Empty, Queue from typing import Any 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.glossary_yaml import ( GlossaryYamlError, export_glossary_yaml, import_glossary_yaml, ) from mka.application.meeting_service import ( STAGES, AppProgressEvent, MeetingDetails, MeetingProcessingService, ParticipantInput, ProcessingOptions, SpeakerMappingReview, stable_id, ) from mka.application.people_yaml import ( PeopleYamlError, export_people_yaml, import_people_yaml, ) from mka.application.performance import DEFAULT_PERFORMANCE_PROFILE, PERFORMANCE_PROFILES from mka.application.progress_timing import TimingSnapshot 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, ) STAGE_LABELS = { "preparing": "Preparation", "transcription": "Transcription", "diarization": "Diarization", "protocol_generation": "Protocol generation", } 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." ) import_file = st.file_uploader( "Import glossary", type=["yaml", "yml"], key="glossary_import_file", help="Validate and atomically replace the SQLite glossary from versioned YAML.", ) action_columns = st.columns(2) if action_columns[0].button("Import glossary", disabled=import_file is None): try: imported = import_glossary_yaml(import_file.getvalue()) repository.replace_all(imported) except (GlossaryYamlError, GlossaryConflictError, ValueError) as exc: st.error(str(exc)) else: st.session_state.glossary_import_message = ( f"Imported {len(imported)} glossary entries." ) st.rerun() action_columns[1].download_button( "Export glossary", data=export_glossary_yaml(repository.list()).encode("utf-8"), file_name="terminology-glossary.yaml", mime="application/yaml", ) if message := st.session_state.pop("glossary_import_message", None): st.success(message) 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, "participant_id": "", "display_name": "", "role": "", "organization": "", "attendance_status": "present", } 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]]: """Create fresh widget rows while preserving reusable person IDs.""" return [ { "row_id": uuid4().hex, "participant_id": person.participant_id, "display_name": person.display_name, "role": person.role, "organization": person.organization, "attendance_status": person.attendance_status, } for person in people ] 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.") imported_file = st.file_uploader( "Import people", type=["yaml", "yml"], help="Replace the current People list from a versioned YAML export.", ) if st.button("Import people list", disabled=imported_file is None): try: imported_people = import_people_yaml(imported_file.getvalue()) except PeopleYamlError as exc: st.error(str(exc)) else: st.session_state.participants = _people_to_rows(imported_people) st.session_state.people_import_message = f"Imported {len(imported_people)} people." st.rerun() if message := st.session_state.pop("people_import_message", None): st.success(message) rows = st.session_state.participants remove_index: int | None = None for index, row in enumerate(rows): row_id = row["row_id"] columns = st.columns([2, 2, 2, 2, 2, 0.6]) row["display_name"] = columns[0].text_input( "Name", value=row["display_name"], key=f"name_{row_id}" ) suggested_id = row["participant_id"] or stable_id(row["display_name"]) row["participant_id"] = columns[1].text_input( "Participant ID", value=suggested_id, key=f"id_{row_id}" ) row["role"] = columns[2].text_input("Role", value=row["role"], key=f"role_{row_id}") row["organization"] = columns[3].text_input( "Organization / department", value=row["organization"], key=f"organization_{row_id}", ) row["attendance_status"] = columns[4].selectbox( "Attendance", options=["present", "mentioned_only"], format_func=lambda value: { "present": "Present / participated", "mentioned_only": "Mentioned, but not present", }[value], index=0 if row["attendance_status"] == "present" else 1, key=f"attendance_{row_id}", ) if columns[5].button("Remove", key=f"remove_{row_id}"): remove_index = index if remove_index is not None: rows.pop(remove_index) st.rerun() action_columns = st.columns(2) if action_columns[0].button("Add person"): rows.append(_new_participant()) st.rerun() people = [ ParticipantInput( participant_id=row["participant_id"], display_name=row["display_name"], role=row["role"], organization=row["organization"], attendance_status=row["attendance_status"], ) for row in rows ] exported_yaml = export_people_yaml(people).encode("utf-8") action_columns[1].download_button( "Export people", data=exported_yaml, file_name="people.yaml", mime="application/yaml", ) return people def _format_duration(seconds: float) -> str: """Format numeric seconds as MM:SS or H:MM:SS.""" whole_seconds = max(0, int(seconds)) hours, remainder = divmod(whole_seconds, 3600) minutes, seconds = divmod(remainder, 60) return f"{hours}:{minutes:02d}:{seconds:02d}" if hours else f"{minutes:02d}:{seconds:02d}" def _render_progress_status( status_box: Any, stage_table: Any, states: dict[str, str], timing: TimingSnapshot, message: str, ) -> None: suffix = " …" if timing.running else "" status_box.info(f"{message} — total runtime {_format_duration(timing.total_duration)}{suffix}") stage_table.table( [ { "Stage": STAGE_LABELS[stage], "Status": states[stage], "Runtime": ( _format_duration(timing.stage_durations[stage]) + (" …" if timing.active_stage == stage else "") if stage in timing.stage_durations else "" ), } for stage in STAGES ] + [ { "Stage": "Total runtime", "Status": "", "Runtime": _format_duration(timing.total_duration) + suffix, } ] ) def _apply_progress_event(event: AppProgressEvent, states: dict[str, str]) -> None: if event.stage in states: states[event.stage] = "running" if event.status == "started" else event.status if event.stage == "failed": running = next((stage for stage, status in states.items() if status == "running"), None) if running: states[running] = "failed" def _run_with_live_progress( service: MeetingProcessingService, work: Callable[[Callable[[AppProgressEvent], None]], Any], states: dict[str, str], initial_message: str, ) -> tuple[Any, dict[str, str], str, TimingSnapshot]: """Run pipeline work while rendering the shared timer and progress events.""" status_box = st.empty() stage_table = st.empty() progress_slot = st.empty() event_queue: Queue[AppProgressEvent] = Queue() latest_message = initial_message progress_bar = None with ThreadPoolExecutor(max_workers=1) as executor: future = executor.submit(work, event_queue.put) while not future.done(): try: while True: event = event_queue.get_nowait() _apply_progress_event(event, states) latest_message = event.message or STAGE_LABELS.get(event.stage, event.stage) if event.progress is not None: if progress_bar is None: progress_bar = progress_slot.progress(0.0) progress_bar.progress( min(max(event.progress, 0.0), 1.0), text=( f"{STAGE_LABELS.get(event.stage, event.stage)}: " f"{event.progress:.0%}" ), ) except Empty: pass _render_progress_status( status_box, stage_table, states, service.timer.snapshot(), latest_message, ) time.sleep(0.2) try: result = future.result() except Exception: while not event_queue.empty(): event = event_queue.get_nowait() _apply_progress_event(event, states) latest_message = event.message or STAGE_LABELS.get(event.stage, event.stage) service.timer.finish_run() running_stage = next( (stage for stage, status in states.items() if status == "running"), None, ) if running_stage is not None: states[running_stage] = "failed" snapshot = service.timer.snapshot() _render_progress_status(status_box, stage_table, states, snapshot, latest_message) st.session_state["processing_progress"] = (states.copy(), latest_message, snapshot) raise while not event_queue.empty(): event = event_queue.get_nowait() _apply_progress_event(event, states) latest_message = event.message or STAGE_LABELS.get(event.stage, event.stage) running_stage = next((stage for stage, status in states.items() if status == "running"), None) if running_stage is not None: states[running_stage] = "completed" if getattr(result, "succeeded", True) else "failed" snapshot = service.timer.snapshot() _render_progress_status(status_box, stage_table, states, snapshot, latest_message) st.session_state["processing_progress"] = (states.copy(), latest_message, snapshot) return result, states, latest_message, snapshot def _remember_regeneration_timing( states: dict[str, str], message: str, timing: TimingSnapshot ) -> None: st.session_state["regeneration_progress"] = (states.copy(), message, timing) 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: return st.divider() st.header("Protocol result") if retained_progress := st.session_state.get("processing_progress"): states, message, timing = retained_progress _render_progress_status(st.empty(), st.empty(), states, timing, message) if not outcome.succeeded: st.error(f"Processing failed during {outcome.failed_stage}: {outcome.error_message}") if outcome.run_dir: st.code(str(outcome.run_dir)) st.caption("Intermediate artifacts and run metadata were preserved here.") return awaiting_review = getattr(outcome, "awaiting_speaker_review", False) if awaiting_review: count = getattr(outcome, "detected_speaker_count", None) detected = f"{count} speakers detected" if count is not None else "speakers detected" st.info( f"Diarization complete — {detected}. Assign speakers if desired, then generate the protocol." ) else: 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) if not awaiting_review: with st.expander("Original generated protocol", expanded=False): st.markdown(outcome.original_protocol or "") edited = st.text_area( "Editable protocol", key="edited_protocol", height=500, help="The original protocol.md remains unchanged.", ) if st.button("Save edited protocol", type="primary"): 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 selections = _render_speaker_mapping(review, outcome.run_dir.name) 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( "Generate protocol" if awaiting_review else "Regenerate protocol with confirmed speakers", disabled=duplicate_assignments, type="primary", ): st.session_state.pop("regeneration_progress", None) run_dir = outcome.run_dir mapping_items = tuple(selections.items()) selected_profile = st.session_state.get("performance_profile", DEFAULT_PERFORMANCE_PROFILE) states = {stage: "skipped" for stage in STAGES} states["protocol_generation"] = "pending" try: regenerated, states, message, timing = _run_with_live_progress( service, lambda progress_sink: service.regenerate_protocol( run_dir, dict(mapping_items), progress_sink=progress_sink, performance_profile=selected_profile, ), states, "Starting protocol generation" if awaiting_review else "Starting protocol regeneration", ) except (OSError, RuntimeError, ValueError) as exc: _remember_regeneration_timing( states, "Protocol regeneration failed", service.timer.snapshot(), ) st.error(f"Protocol generation failed: {exc}") else: _remember_regeneration_timing(states, message, timing) st.session_state.outcome = regenerated _queue_edited_protocol(regenerated.original_protocol or "") st.session_state.speaker_mapping_message = ( "Speaker mappings saved and protocol generated." ) st.rerun() def main() -> None: st.set_page_config(page_title="Meeting Assistant", layout="wide") _initialize_state() 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"], 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"], key="meeting_language", help="Language for both transcription and the generated protocol.", ) 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", key="meeting_date") if has_date else None ) participants = _render_participants() st.header("Processing options") performance_profile = st.selectbox( "Performance profile", options=PERFORMANCE_PROFILES, format_func=str.title, key="performance_profile", help="Controls protocol-generation performance using an abstract runtime profile.", ) 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, " "regardless of this setting." ), ) 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.") elif any( not item.display_name.strip() or not item.participant_id.strip() for item in participants ): st.error("Every person row needs a name and participant ID.") else: try: service = MeetingProcessingService(settings, MeetingLabGateway(), glossary) meeting = MeetingDetails( title=title, language=language, meeting_date=selected_date, description=description, ) meeting_id = stable_id(title) audio_path = service.preserve_upload(meeting_id, audio.name, audio) st.session_state.pop("regeneration_progress", None) st.session_state.pop("processing_progress", None) st.header("Processing status") states = {stage: "pending" for stage in STAGES} if not diarization_enabled: states["diarization"] = "skipped" outcome, _, _, _ = _run_with_live_progress( service, lambda progress_sink: service.process( audio_path, meeting, participants, ProcessingOptions( diarization_enabled=diarization_enabled, audio_normalization=audio_normalization, performance_profile=performance_profile, ), progress_sink=progress_sink, ), states, "Starting processing", ) st.session_state.outcome = outcome st.session_state.edited_protocol = outcome.original_protocol or "" if outcome.succeeded: st.success("Processing completed.") else: st.error( f"Processing failed during {outcome.failed_stage}: " f"{outcome.error_message} Artifacts were preserved." ) except (ConfigurationError, MeetingLabUnavailableError, ValueError) as exc: st.error(str(exc)) except Exception as exc: st.error(f"Unable to process meeting: {exc}") _render_result() if __name__ == "__main__": main()