786 lines
31 KiB
Python
786 lines
31 KiB
Python
"""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()
|