Add reusable people list import and export
This commit is contained in:
@@ -36,6 +36,7 @@ Meeting Assistant owns user interaction and product workflow:
|
|||||||
- audio-file selection
|
- audio-file selection
|
||||||
- structured Meeting Context editing
|
- structured Meeting Context editing
|
||||||
- participant management
|
- participant management
|
||||||
|
- versioned YAML import/export for reusable People lists, using replace semantics
|
||||||
- optional explicit speaker mapping
|
- optional explicit speaker mapping
|
||||||
- pipeline launch and progress display
|
- pipeline launch and progress display
|
||||||
- protocol review and editing
|
- 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.
|
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
|
## Validated MVP Pipeline
|
||||||
|
|
||||||
```text
|
```text
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ The first product milestone is a desktop GUI that lets a user:
|
|||||||
- select an existing WAV, FLAC, or M4A recording
|
- select an existing WAV, FLAC, or M4A recording
|
||||||
- create and edit structured meeting metadata and relevant people, including whether
|
- create and edit structured meeting metadata and relevant people, including whether
|
||||||
they were present or only mentioned
|
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
|
- optionally map anonymous `SPEAKER_XX` labels to known participants
|
||||||
- start the Meeting Lab processing pipeline
|
- start the Meeting Lab processing pipeline
|
||||||
- follow stage-based progress
|
- 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
|
options. This controls loudness normalization only: Meeting Lab still prepares
|
||||||
every WAV, FLAC or M4A source as canonical audio before transcription.
|
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
|
## Run the Streamlit MVP
|
||||||
|
|
||||||
The development setup expects `meeting-assistant` and `meeting-lab` to be
|
The development setup expects `meeting-assistant` and `meeting-lab` to be
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ authors = [
|
|||||||
]
|
]
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"pydantic>=2,<3",
|
"pydantic>=2,<3",
|
||||||
|
"PyYAML>=6,<7",
|
||||||
"streamlit>=1.40,<2",
|
"streamlit>=1.40,<2",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -18,6 +18,11 @@ from mka.application.meeting_service import (
|
|||||||
ProcessingOptions,
|
ProcessingOptions,
|
||||||
stable_id,
|
stable_id,
|
||||||
)
|
)
|
||||||
|
from mka.application.people_yaml import (
|
||||||
|
PeopleYamlError,
|
||||||
|
export_people_yaml,
|
||||||
|
import_people_yaml,
|
||||||
|
)
|
||||||
from mka.integrations.meeting_lab import (
|
from mka.integrations.meeting_lab import (
|
||||||
MeetingLabGateway,
|
MeetingLabGateway,
|
||||||
MeetingLabUnavailableError,
|
MeetingLabUnavailableError,
|
||||||
@@ -48,9 +53,41 @@ def _initialize_state() -> None:
|
|||||||
st.session_state.setdefault("edited_protocol", "")
|
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]:
|
def _render_participants() -> list[ParticipantInput]:
|
||||||
st.subheader("People")
|
st.subheader("People")
|
||||||
st.caption("Record whether each relevant person attended or was only mentioned.")
|
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
|
rows = st.session_state.participants
|
||||||
remove_index: int | None = None
|
remove_index: int | None = None
|
||||||
for index, row in enumerate(rows):
|
for index, row in enumerate(rows):
|
||||||
@@ -84,10 +121,11 @@ def _render_participants() -> list[ParticipantInput]:
|
|||||||
if remove_index is not None:
|
if remove_index is not None:
|
||||||
rows.pop(remove_index)
|
rows.pop(remove_index)
|
||||||
st.rerun()
|
st.rerun()
|
||||||
if st.button("Add person"):
|
action_columns = st.columns(2)
|
||||||
|
if action_columns[0].button("Add person"):
|
||||||
rows.append(_new_participant())
|
rows.append(_new_participant())
|
||||||
st.rerun()
|
st.rerun()
|
||||||
return [
|
people = [
|
||||||
ParticipantInput(
|
ParticipantInput(
|
||||||
participant_id=row["participant_id"],
|
participant_id=row["participant_id"],
|
||||||
display_name=row["display_name"],
|
display_name=row["display_name"],
|
||||||
@@ -97,6 +135,14 @@ def _render_participants() -> list[ParticipantInput]:
|
|||||||
)
|
)
|
||||||
for row in rows
|
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(
|
def _progress_callback(
|
||||||
|
|||||||
@@ -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
|
||||||
Reference in New Issue
Block a user