"""Streamlit presentation layer for the first Meeting Assistant MVP.""" from __future__ import annotations from datetime import date 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.meeting_service import ( STAGES, AppProgressEvent, MeetingDetails, MeetingProcessingService, ParticipantInput, ProcessingOptions, stable_id, ) from mka.application.people_yaml import ( PeopleYamlError, 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, ) 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." ) 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 _progress_callback( status_box: Any, stage_table: Any, progress_slot: Any, states: dict[str, str], ) -> Any: progress_bar = None def update(event: AppProgressEvent) -> None: nonlocal progress_bar 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" elapsed = f"{event.elapsed_seconds:.1f} s" message = event.message or STAGE_LABELS.get(event.stage, event.stage) status_box.info(f"{message} — elapsed {elapsed}") stage_table.table( [{"Stage": STAGE_LABELS[stage], "Status": states[stage]} for stage in STAGES] ) 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)}: {event.progress:.0%}", ) return update def _render_result() -> None: outcome = st.session_state.outcome if outcome is None: return st.divider() st.header("Protocol result") 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 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( "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 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") _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" ) 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") 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.header("Processing status") status_box = st.empty() stage_table = st.empty() progress_slot = st.empty() states = {stage: "pending" for stage in STAGES} if not diarization_enabled: states["diarization"] = "skipped" callback = _progress_callback(status_box, stage_table, progress_slot, states) outcome = service.process( audio_path, meeting, participants, ProcessingOptions( diarization_enabled=diarization_enabled, audio_normalization=audio_normalization, ), progress_sink=callback, ) st.session_state.outcome = outcome st.session_state.edited_protocol = outcome.original_protocol or "" if outcome.succeeded: status_box.success("Processing completed.") else: status_box.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()