diff --git a/PROJECT_KNOWLEDGE.md b/PROJECT_KNOWLEDGE.md index dacecdb..6582cf7 100644 --- a/PROJECT_KNOWLEDGE.md +++ b/PROJECT_KNOWLEDGE.md @@ -36,6 +36,7 @@ Meeting Assistant owns user interaction and product workflow: - audio-file selection - structured Meeting Context editing - participant management +- versioned YAML import/export for reusable People lists, using replace semantics - optional explicit speaker mapping - pipeline launch and progress display - protocol review and editing @@ -43,6 +44,11 @@ Meeting Assistant owns user interaction and product workflow: Processing logic must not be duplicated in the application. +People-list YAML is a Meeting Assistant application concern and contains only +stable IDs, display names, roles, organizations and attendance states. It does +not contain meeting metadata or processing settings. Named team or meeting +templates may build on this later, but are not part of the current mechanism. + ## Validated MVP Pipeline ```text diff --git a/README.md b/README.md index 965b6dc..f595bcc 100644 --- a/README.md +++ b/README.md @@ -11,6 +11,7 @@ The first product milestone is a desktop GUI that lets a user: - select an existing WAV, FLAC, or M4A recording - create and edit structured meeting metadata and relevant people, including whether they were present or only mentioned +- import and export the reusable People list as versioned YAML - optionally map anonymous `SPEAKER_XX` labels to known participants - start the Meeting Lab processing pipeline - follow stage-based progress @@ -63,6 +64,12 @@ Audio normalization is enabled by default and can be disabled in the processing options. This controls loudness normalization only: Meeting Lab still prepares every WAV, FLAC or M4A source as canonical audio before transcription. +The People section can export its current entries to a UTF-8 `people.yaml` file +and replace them from a previous `.yaml` or `.yml` export. Stable person IDs, +names, roles, organizations and attendance states are retained. This is a small +reuse mechanism, not a server-side participant library or named meeting-template +system. + ## Run the Streamlit MVP The development setup expects `meeting-assistant` and `meeting-lab` to be diff --git a/pyproject.toml b/pyproject.toml index d3f68bb..703b2e8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -13,6 +13,7 @@ authors = [ ] dependencies = [ "pydantic>=2,<3", + "PyYAML>=6,<7", "streamlit>=1.40,<2", ] diff --git a/src/mka/application/people_yaml.py b/src/mka/application/people_yaml.py new file mode 100644 index 0000000..394f4fe --- /dev/null +++ b/src/mka/application/people_yaml.py @@ -0,0 +1,108 @@ +"""Versioned YAML import and export for reusable People lists.""" + +from __future__ import annotations + +import re +from collections.abc import Sequence + +import yaml + +from mka.application.meeting_service import ParticipantInput + +PEOPLE_YAML_VERSION = 1 +ATTENDANCE_VALUES = frozenset({"present", "mentioned_only"}) +PARTICIPANT_ID_PATTERN = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_.:-]*$") + + +class PeopleYamlError(ValueError): + """Raised when a reusable People-list document is invalid.""" + + +def export_people_yaml(people: Sequence[ParticipantInput]) -> str: + """Serialize people deterministically without meeting-specific data.""" + document = { + "version": PEOPLE_YAML_VERSION, + "people": [ + { + "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 + ], + } + return yaml.safe_dump( + document, + allow_unicode=True, + sort_keys=False, + default_flow_style=False, + ) + + +def import_people_yaml(content: str | bytes) -> list[ParticipantInput]: + """Parse and validate a complete replacement People list.""" + try: + if isinstance(content, bytes): + content = content.decode("utf-8") + document = yaml.safe_load(content) + except (yaml.YAMLError, UnicodeDecodeError) as exc: + raise PeopleYamlError(f"Malformed People YAML: {exc}") from exc + + if not isinstance(document, dict): + raise PeopleYamlError("People YAML must contain a top-level mapping.") + version = document.get("version") + if type(version) is not int or version != PEOPLE_YAML_VERSION: + raise PeopleYamlError( + f"Unsupported People YAML version {version!r}; expected {PEOPLE_YAML_VERSION}." + ) + entries = document.get("people") + if not isinstance(entries, list): + raise PeopleYamlError("People YAML must contain a top-level 'people' list.") + + people: list[ParticipantInput] = [] + seen_ids: set[str] = set() + for index, entry in enumerate(entries, start=1): + if not isinstance(entry, dict): + raise PeopleYamlError(f"Person {index} must be a mapping.") + participant_id = _required_text(entry, "participant_id", index) + if PARTICIPANT_ID_PATTERN.fullmatch(participant_id) is None: + raise PeopleYamlError(f"Person {index} has invalid participant_id {participant_id!r}.") + if participant_id in seen_ids: + raise PeopleYamlError(f"Duplicate participant_id: {participant_id!r}.") + seen_ids.add(participant_id) + + display_name = _required_text(entry, "display_name", index) + role = _optional_text(entry, "role", index) + organization = _optional_text(entry, "organization", index) + attendance_status = entry.get("attendance_status") + if attendance_status not in ATTENDANCE_VALUES: + allowed = ", ".join(sorted(ATTENDANCE_VALUES)) + raise PeopleYamlError( + f"Person {index} has invalid attendance_status; expected one of: {allowed}." + ) + people.append( + ParticipantInput( + participant_id=participant_id, + display_name=display_name, + role=role, + organization=organization, + attendance_status=attendance_status, + ) + ) + return people + + +def _required_text(entry: dict[object, object], field: str, index: int) -> str: + value = entry.get(field) + if not isinstance(value, str) or not value.strip(): + raise PeopleYamlError(f"Person {index} requires a non-empty {field}.") + return value + + +def _optional_text(entry: dict[object, object], field: str, index: int) -> str: + value = entry.get(field, "") + if not isinstance(value, str): + raise PeopleYamlError(f"Person {index} field {field} must be text.") + return value diff --git a/src/mka/ui/streamlit_app.py b/src/mka/ui/streamlit_app.py index 3d21830..4cb18e1 100644 --- a/src/mka/ui/streamlit_app.py +++ b/src/mka/ui/streamlit_app.py @@ -18,6 +18,11 @@ from mka.application.meeting_service import ( ProcessingOptions, stable_id, ) +from mka.application.people_yaml import ( + PeopleYamlError, + export_people_yaml, + import_people_yaml, +) from mka.integrations.meeting_lab import ( MeetingLabGateway, MeetingLabUnavailableError, @@ -48,9 +53,41 @@ def _initialize_state() -> None: st.session_state.setdefault("edited_protocol", "") +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_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): @@ -84,10 +121,11 @@ def _render_participants() -> list[ParticipantInput]: if remove_index is not None: rows.pop(remove_index) st.rerun() - if st.button("Add person"): + action_columns = st.columns(2) + if action_columns[0].button("Add person"): rows.append(_new_participant()) st.rerun() - return [ + people = [ ParticipantInput( participant_id=row["participant_id"], display_name=row["display_name"], @@ -97,6 +135,14 @@ def _render_participants() -> list[ParticipantInput]: ) 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( diff --git a/tests/test_people_yaml.py b/tests/test_people_yaml.py new file mode 100644 index 0000000..f15713a --- /dev/null +++ b/tests/test_people_yaml.py @@ -0,0 +1,127 @@ +import pytest + +from mka.application.meeting_service import ParticipantInput +from mka.application.people_yaml import ( + PeopleYamlError, + export_people_yaml, + import_people_yaml, +) + + +def sample_people() -> list[ParticipantInput]: + return [ + ParticipantInput( + participant_id="participant-35c1528c", + display_name="Martin Tazl", + role="Beirat", + organization="Verwaltungsbeirat", + attendance_status="present", + ), + ParticipantInput( + participant_id="participant-56aae811", + display_name="Norbert Hebbelmann", + role="Gast", + organization="Eigentümergemeinschaft", + attendance_status="mentioned_only", + ), + ] + + +def test_export_import_round_trip_preserves_all_people_fields() -> None: + people = sample_people() + + exported = export_people_yaml(people) + + assert exported.startswith("version: 1\npeople:\n") + assert import_people_yaml(exported) == people + assert export_people_yaml(import_people_yaml(exported)) == exported + + +def test_import_preserves_stable_ids_roles_organizations_and_attendance() -> None: + imported = import_people_yaml(export_people_yaml(sample_people())) + + assert [person.participant_id for person in imported] == [ + "participant-35c1528c", + "participant-56aae811", + ] + assert imported[0].role == "Beirat" + assert imported[1].organization == "Eigentümergemeinschaft" + assert [person.attendance_status for person in imported] == [ + "present", + "mentioned_only", + ] + + +@pytest.mark.parametrize("content", ["people: [", b"\xff\xfe"]) +def test_malformed_yaml_is_rejected(content: str | bytes) -> None: + with pytest.raises(PeopleYamlError, match="Malformed People YAML"): + import_people_yaml(content) + + +def test_unsupported_version_is_rejected() -> None: + with pytest.raises(PeopleYamlError, match="Unsupported People YAML version"): + import_people_yaml("version: 2\npeople: []\n") + + +def test_missing_id_is_rejected() -> None: + content = """\ +version: 1 +people: + - display_name: Martin Tazl + attendance_status: present +""" + + with pytest.raises(PeopleYamlError, match="participant_id"): + import_people_yaml(content) + + +def test_invalid_id_is_rejected_without_generating_a_replacement() -> None: + content = """\ +version: 1 +people: + - participant_id: invalid id + display_name: Martin + attendance_status: present +""" + + with pytest.raises(PeopleYamlError, match="invalid participant_id"): + import_people_yaml(content) + + +def test_duplicate_id_is_rejected() -> None: + content = """\ +version: 1 +people: + - participant_id: martin + display_name: Martin + attendance_status: present + - participant_id: martin + display_name: Martin Duplicate + attendance_status: mentioned_only +""" + + with pytest.raises(PeopleYamlError, match="Duplicate participant_id"): + import_people_yaml(content) + + +def test_invalid_attendance_is_rejected() -> None: + content = """\ +version: 1 +people: + - participant_id: martin + display_name: Martin + attendance_status: absent +""" + + with pytest.raises(PeopleYamlError, match="invalid attendance_status"): + import_people_yaml(content) + + +def test_failed_import_does_not_mutate_existing_people() -> None: + existing = sample_people() + snapshot = list(existing) + + with pytest.raises(PeopleYamlError): + import_people_yaml("version: 1\npeople: not-a-list\n") + + assert existing == snapshot