Add reusable people list import and export

This commit is contained in:
2026-08-24 14:26:30 +02:00
parent 13d06b8a60
commit 2a4481f2f9
6 changed files with 297 additions and 2 deletions
+6
View File
@@ -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
+7
View File
@@ -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
+1
View File
@@ -13,6 +13,7 @@ authors = [
]
dependencies = [
"pydantic>=2,<3",
"PyYAML>=6,<7",
"streamlit>=1.40,<2",
]
+108
View File
@@ -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
+48 -2
View File
@@ -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(
+127
View File
@@ -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