feat: add speaker mapping and workflow improvements
This commit is contained in:
@@ -0,0 +1,218 @@
|
||||
"""Versioned JSON import/export for the user-facing run input form."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from collections.abc import Sequence
|
||||
from dataclasses import dataclass
|
||||
from datetime import date
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from mka.application.meeting_service import ParticipantInput, stable_id
|
||||
from mka.application.people_yaml import ATTENDANCE_VALUES, PARTICIPANT_ID_PATTERN
|
||||
|
||||
RUN_INPUT_SCHEMA_VERSION = 1
|
||||
SUPPORTED_LANGUAGES = frozenset({"de", "en"})
|
||||
|
||||
|
||||
class RunInputJsonError(ValueError):
|
||||
"""Raised when a run-input JSON document is malformed or unsupported."""
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class RunInputState:
|
||||
"""All user-configurable values needed before starting a processing run."""
|
||||
|
||||
title: str
|
||||
description: str
|
||||
language: str
|
||||
meeting_date: date | None
|
||||
participants: tuple[ParticipantInput, ...]
|
||||
audio_normalization: bool
|
||||
diarization_enabled: bool
|
||||
source_file_name: str | None = None
|
||||
|
||||
@classmethod
|
||||
def defaults(cls) -> RunInputState:
|
||||
return cls(
|
||||
title="",
|
||||
description="",
|
||||
language="de",
|
||||
meeting_date=date.today(),
|
||||
participants=(ParticipantInput(participant_id="", display_name=""),),
|
||||
audio_normalization=True,
|
||||
diarization_enabled=False,
|
||||
)
|
||||
|
||||
|
||||
def export_run_inputs(state: RunInputState) -> str:
|
||||
"""Serialize form state as deterministic, human-readable JSON."""
|
||||
source_file_name = _source_file_name(state.source_file_name)
|
||||
document = {
|
||||
"schema_version": RUN_INPUT_SCHEMA_VERSION,
|
||||
"meeting": {
|
||||
"title": state.title,
|
||||
"description": state.description,
|
||||
"language": state.language,
|
||||
"date": state.meeting_date.isoformat() if state.meeting_date else None,
|
||||
"participants": [
|
||||
{
|
||||
"participant_id": person.participant_id,
|
||||
"display_name": person.display_name,
|
||||
"role": person.role,
|
||||
"organization": person.organization,
|
||||
"attendance_status": person.attendance_status,
|
||||
}
|
||||
for person in state.participants
|
||||
],
|
||||
},
|
||||
"processing": {
|
||||
"audio_normalization": state.audio_normalization,
|
||||
"diarization_enabled": state.diarization_enabled,
|
||||
},
|
||||
"source_file_name": source_file_name,
|
||||
}
|
||||
return json.dumps(document, ensure_ascii=False, indent=2) + "\n"
|
||||
|
||||
|
||||
def import_run_inputs(content: str | bytes) -> RunInputState:
|
||||
"""Parse supported fields, applying current defaults to omitted optional fields."""
|
||||
try:
|
||||
if isinstance(content, bytes):
|
||||
content = content.decode("utf-8")
|
||||
document = json.loads(content)
|
||||
except (UnicodeDecodeError, json.JSONDecodeError) as exc:
|
||||
raise RunInputJsonError(f"Malformed input JSON: {exc}") from exc
|
||||
if not isinstance(document, dict):
|
||||
raise RunInputJsonError("Input JSON must contain a top-level object.")
|
||||
version = document.get("schema_version")
|
||||
if type(version) is not int or version != RUN_INPUT_SCHEMA_VERSION:
|
||||
raise RunInputJsonError(
|
||||
f"Unsupported input schema_version {version!r}; expected {RUN_INPUT_SCHEMA_VERSION}."
|
||||
)
|
||||
|
||||
defaults = RunInputState.defaults()
|
||||
meeting = _optional_mapping(document, "meeting")
|
||||
processing = _optional_mapping(document, "processing")
|
||||
title = _optional_text(meeting, "title", defaults.title)
|
||||
description = _optional_text(meeting, "description", defaults.description)
|
||||
language = _optional_text(meeting, "language", defaults.language)
|
||||
if language not in SUPPORTED_LANGUAGES:
|
||||
raise RunInputJsonError(f"Unsupported meeting language: {language!r}.")
|
||||
meeting_date = _meeting_date(meeting, defaults.meeting_date)
|
||||
participants = _participants(meeting, defaults.participants)
|
||||
audio_normalization = _optional_bool(
|
||||
processing, "audio_normalization", defaults.audio_normalization
|
||||
)
|
||||
diarization_enabled = _optional_bool(
|
||||
processing, "diarization_enabled", defaults.diarization_enabled
|
||||
)
|
||||
source_file_name = _source_file_name(document.get("source_file_name"))
|
||||
return RunInputState(
|
||||
title=title,
|
||||
description=description,
|
||||
language=language,
|
||||
meeting_date=meeting_date,
|
||||
participants=participants,
|
||||
audio_normalization=audio_normalization,
|
||||
diarization_enabled=diarization_enabled,
|
||||
source_file_name=source_file_name,
|
||||
)
|
||||
|
||||
|
||||
def run_input_filename(title: str) -> str:
|
||||
"""Build a stable download filename without filesystem-specific characters."""
|
||||
suffix = stable_id(title) if title.strip() else "untitled"
|
||||
return f"meeting-inputs-{suffix}.json"
|
||||
|
||||
|
||||
def _optional_mapping(document: dict[str, Any], field: str) -> dict[str, Any]:
|
||||
value = document.get(field, {})
|
||||
if not isinstance(value, dict):
|
||||
raise RunInputJsonError(f"Field {field!r} must be an object.")
|
||||
return value
|
||||
|
||||
|
||||
def _optional_text(document: dict[str, Any], field: str, default: str) -> str:
|
||||
value = document.get(field, default)
|
||||
if not isinstance(value, str):
|
||||
raise RunInputJsonError(f"Field {field!r} must be text.")
|
||||
return value
|
||||
|
||||
|
||||
def _optional_bool(document: dict[str, Any], field: str, default: bool) -> bool:
|
||||
value = document.get(field, default)
|
||||
if type(value) is not bool:
|
||||
raise RunInputJsonError(f"Field {field!r} must be true or false.")
|
||||
return value
|
||||
|
||||
|
||||
def _meeting_date(document: dict[str, Any], default: date | None) -> date | None:
|
||||
if "date" not in document:
|
||||
return default
|
||||
value = document["date"]
|
||||
if value is None:
|
||||
return None
|
||||
if not isinstance(value, str):
|
||||
raise RunInputJsonError("Field 'date' must be an ISO date or null.")
|
||||
try:
|
||||
return date.fromisoformat(value)
|
||||
except ValueError as exc:
|
||||
raise RunInputJsonError("Field 'date' must be a valid ISO date or null.") from exc
|
||||
|
||||
|
||||
def _participants(
|
||||
document: dict[str, Any], default: Sequence[ParticipantInput]
|
||||
) -> tuple[ParticipantInput, ...]:
|
||||
if "participants" not in document:
|
||||
return tuple(default)
|
||||
entries = document["participants"]
|
||||
if not isinstance(entries, list):
|
||||
raise RunInputJsonError("Field 'participants' must be a list.")
|
||||
people: list[ParticipantInput] = []
|
||||
seen_ids: set[str] = set()
|
||||
for index, entry in enumerate(entries, start=1):
|
||||
if not isinstance(entry, dict):
|
||||
raise RunInputJsonError(f"Participant {index} must be an object.")
|
||||
participant_id = _optional_text(entry, "participant_id", "")
|
||||
display_name = _optional_text(entry, "display_name", "")
|
||||
role = _optional_text(entry, "role", "")
|
||||
organization = _optional_text(entry, "organization", "")
|
||||
attendance = _optional_text(entry, "attendance_status", "present")
|
||||
if bool(participant_id) != bool(display_name):
|
||||
raise RunInputJsonError(
|
||||
f"Participant {index} must provide both participant_id and display_name."
|
||||
)
|
||||
if participant_id and PARTICIPANT_ID_PATTERN.fullmatch(participant_id) is None:
|
||||
raise RunInputJsonError(
|
||||
f"Participant {index} has invalid participant_id {participant_id!r}."
|
||||
)
|
||||
if participant_id in seen_ids:
|
||||
raise RunInputJsonError(f"Duplicate participant_id: {participant_id!r}.")
|
||||
if participant_id:
|
||||
seen_ids.add(participant_id)
|
||||
if attendance not in ATTENDANCE_VALUES:
|
||||
raise RunInputJsonError(
|
||||
f"Participant {index} has invalid attendance_status {attendance!r}."
|
||||
)
|
||||
people.append(
|
||||
ParticipantInput(
|
||||
participant_id=participant_id,
|
||||
display_name=display_name,
|
||||
role=role,
|
||||
organization=organization,
|
||||
attendance_status=attendance,
|
||||
)
|
||||
)
|
||||
return tuple(people)
|
||||
|
||||
|
||||
def _source_file_name(value: Any) -> str | None:
|
||||
if value is None:
|
||||
return None
|
||||
if not isinstance(value, str) or not value.strip():
|
||||
raise RunInputJsonError("Field 'source_file_name' must be non-empty text or null.")
|
||||
if Path(value).name != value:
|
||||
raise RunInputJsonError("Field 'source_file_name' must be a filename, not a path.")
|
||||
return value
|
||||
Reference in New Issue
Block a user