Add diarization and reusable MVP meeting pipeline

This commit is contained in:
2026-08-23 19:29:47 +02:00
parent f2d21c1faf
commit 8dab928763
18 changed files with 1697 additions and 180 deletions
+20
View File
@@ -0,0 +1,20 @@
"""Optional speaker diarization and transcript alignment."""
from src.meeting_lab.diarization.alignment import align_transcript, write_diarized_transcript
from src.meeting_lab.diarization.backend import (
DEFAULT_MODEL,
DiarizationError,
DiarizationResult,
diarize_audio,
select_device,
)
__all__ = [
"DEFAULT_MODEL",
"DiarizationError",
"DiarizationResult",
"align_transcript",
"diarize_audio",
"select_device",
"write_diarized_transcript",
]
+129
View File
@@ -0,0 +1,129 @@
"""Deterministic Whisper-segment alignment to anonymous diarization turns."""
from __future__ import annotations
import json
from pathlib import Path
from typing import Any
class AlignmentError(ValueError):
"""Raised when transcript or diarization inputs are malformed."""
def _number(value: Any, description: str) -> float:
if not isinstance(value, (int, float)) or isinstance(value, bool):
raise AlignmentError(f"{description} must be a number.")
return float(value)
def _validated_turns(turns: list[dict[str, Any]]) -> list[dict[str, Any]]:
validated = []
for index, turn in enumerate(turns):
if not isinstance(turn, dict):
raise AlignmentError(f"Diarization turn {index} must be an object.")
start = _number(turn.get("start"), f"Diarization turn {index} start")
end = _number(turn.get("end"), f"Diarization turn {index} end")
speaker = turn.get("speaker_id", turn.get("speaker"))
if end < start:
raise AlignmentError(f"Diarization turn {index} ends before it starts.")
if not isinstance(speaker, str) or not speaker.startswith("SPEAKER_"):
raise AlignmentError(
f"Diarization turn {index} must have an anonymous SPEAKER_ label."
)
validated.append({"start": start, "end": end, "speaker_id": speaker})
return validated
def align_transcript(
transcript: dict[str, Any], exclusive_turns: list[dict[str, Any]]
) -> dict[str, Any]:
"""Return a derived transcript using maximum exclusive-turn overlap per segment."""
if not isinstance(transcript, dict) or not isinstance(transcript.get("segments"), list):
raise AlignmentError("Whisper transcript must contain a 'segments' list.")
turns = _validated_turns(exclusive_turns)
aligned_segments: list[dict[str, Any]] = []
for index, source in enumerate(transcript["segments"]):
if not isinstance(source, dict):
raise AlignmentError(f"Transcript segment {index} must be an object.")
start = _number(source.get("start"), f"Transcript segment {index} start")
end = _number(source.get("end"), f"Transcript segment {index} end")
if end < start:
raise AlignmentError(f"Transcript segment {index} ends before it starts.")
overlap_by_speaker: dict[str, float] = {}
for turn in turns:
overlap = max(0.0, min(end, turn["end"]) - max(start, turn["start"]))
if overlap:
speaker = turn["speaker_id"]
overlap_by_speaker[speaker] = overlap_by_speaker.get(speaker, 0.0) + overlap
speaker_id = None
overlap_seconds = 0.0
if overlap_by_speaker:
speaker_id, overlap_seconds = min(
overlap_by_speaker.items(), key=lambda item: (-item[1], item[0])
)
duration = end - start
aligned = dict(source)
aligned.update(
{
"speaker_id": speaker_id,
"speaker_overlap_seconds": round(overlap_seconds, 6),
"speaker_overlap_ratio": round(
overlap_seconds / duration if duration > 0 else 0.0, 6
),
}
)
aligned_segments.append(aligned)
return {
"text": diarized_transcript_text(aligned_segments, include_end=True),
"segments": aligned_segments,
"speaker_labels_anonymous": True,
"alignment_source": "exclusive_diarization",
}
def _timestamp(seconds: float) -> str:
milliseconds = int(round(seconds * 1000))
hours, remainder = divmod(milliseconds, 3_600_000)
minutes, remainder = divmod(remainder, 60_000)
secs, millis = divmod(remainder, 1000)
return f"{hours:02d}:{minutes:02d}:{secs:02d}.{millis:03d}"
def diarized_transcript_text(
segments: list[dict[str, Any]], *, include_end: bool = True
) -> str:
lines = []
for segment in segments:
start = _timestamp(float(segment["start"]))
end = _timestamp(float(segment["end"]))
speaker = segment.get("speaker_id") or "SPEAKER_UNASSIGNED"
timestamp = f"[{start} - {end}]" if include_end else f"[{start}]"
lines.append(f"{timestamp} {speaker}: {str(segment.get('text', '')).strip()}")
return "\n".join(lines) + ("\n" if lines else "")
def write_diarized_transcript(
transcript_path: Path,
exclusive_turns_path: Path,
output_dir: Path,
) -> tuple[Path, Path]:
"""Read source artifacts and write a separate speaker-aware transcript pair."""
transcript = json.loads(Path(transcript_path).read_text(encoding="utf-8-sig"))
turns = json.loads(Path(exclusive_turns_path).read_text(encoding="utf-8"))
if not isinstance(turns, list):
raise AlignmentError("Exclusive diarization turns must contain a JSON list.")
derived = align_transcript(transcript, turns)
output_dir = Path(output_dir)
output_dir.mkdir(parents=True, exist_ok=True)
json_path = output_dir / "transcript_diarized.json"
text_path = output_dir / "transcript_diarized.txt"
json_path.write_text(
json.dumps(derived, ensure_ascii=False, indent=2) + "\n", encoding="utf-8"
)
text_path.write_text(
diarized_transcript_text(derived["segments"], include_end=True), encoding="utf-8"
)
return json_path, text_path
+314
View File
@@ -0,0 +1,314 @@
"""pyannote Community-1 backend with native and isolated-container runtimes."""
from __future__ import annotations
import importlib.metadata
import json
import os
import subprocess
import time
import wave
from dataclasses import dataclass
from pathlib import Path
from typing import Any, Callable, Literal, Sequence
DEFAULT_MODEL = "pyannote/speaker-diarization-community-1"
PYANNOTE_VERSION = "4.0.7"
DeviceMode = Literal["auto", "gpu", "cpu"]
RuntimeMode = Literal["native", "container"]
class DiarizationError(RuntimeError):
"""Raised when diarization configuration or execution fails."""
@dataclass(frozen=True)
class DiarizationResult:
output_dir: Path
metadata_path: Path
ordinary_rttm: Path
exclusive_rttm: Path
turns_json: Path
exclusive_turns_json: Path
metadata: dict[str, Any]
def _host_uid() -> int:
getter = getattr(os, "getuid", None)
if getter is None:
raise DiarizationError("Container diarization requires host UID discovery.")
return int(getter())
def _host_gid() -> int:
getter = getattr(os, "getgid", None)
if getter is None:
raise DiarizationError("Container diarization requires host GID discovery.")
return int(getter())
def select_device(mode: DeviceMode, torch_module: Any) -> tuple[Any, str | None]:
"""Resolve CPU/GPU without depending on the GPU vendor."""
if mode == "cpu":
return torch_module.device("cpu"), None
if mode not in ("auto", "gpu"):
raise DiarizationError(f"Unsupported diarization device mode: {mode}")
try:
available = bool(torch_module.cuda.is_available())
if available:
name = str(torch_module.cuda.get_device_name(0))
probe = torch_module.zeros(1, device="cuda")
del probe
return torch_module.device("cuda"), name
except Exception as exc:
if mode == "gpu":
raise DiarizationError(f"Requested PyTorch GPU is not usable: {exc}") from exc
if mode == "gpu":
raise DiarizationError("Requested PyTorch GPU is unavailable.")
return torch_module.device("cpu"), None
def _load_pcm_wave(audio_path: Path, torch_module: Any) -> tuple[Any, int, float, dict[str, Any]]:
try:
with wave.open(str(audio_path), "rb") as source:
channels = source.getnchannels()
sample_rate = source.getframerate()
sample_width = source.getsampwidth()
frame_count = source.getnframes()
pcm = bytearray(source.readframes(frame_count))
except (OSError, wave.Error) as exc:
raise DiarizationError(f"Cannot read PCM WAV input {audio_path}: {exc}") from exc
if channels != 1 or sample_rate != 16000 or sample_width != 2:
raise DiarizationError(
"Diarization currently requires mono 16 kHz signed 16-bit PCM WAV; "
f"got channels={channels}, sample_rate={sample_rate}, sample_width={sample_width}."
)
waveform = torch_module.frombuffer(pcm, dtype=torch_module.int16).to(
torch_module.float32
)
waveform = (waveform / 32768.0).reshape(channels, frame_count)
duration = frame_count / sample_rate
validation = {
"waveform_dtype": str(waveform.dtype),
"waveform_shape": list(waveform.shape),
"sample_rate": sample_rate,
"sample_count": frame_count,
"duration_seconds": duration,
"min_sample_value": waveform.min().item(),
"max_sample_value": waveform.max().item(),
"audio_loading": "python_wave_pcm16",
}
return waveform, sample_rate, duration, validation
def _turns(annotation: Any) -> list[dict[str, Any]]:
return [
{
"start": segment.start,
"end": segment.end,
"speaker_id": speaker,
}
for segment, _track, speaker in annotation.itertracks(yield_label=True)
]
def _write_json(path: Path, value: Any) -> None:
path.write_text(json.dumps(value, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
def _result_from_output(output_dir: Path) -> DiarizationResult:
metadata_path = output_dir / "metadata.json"
try:
metadata = json.loads(metadata_path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError) as exc:
raise DiarizationError(f"Cannot read diarization metadata: {exc}") from exc
return DiarizationResult(
output_dir=output_dir,
metadata_path=metadata_path,
ordinary_rttm=output_dir / "diarization.rttm",
exclusive_rttm=output_dir / "exclusive_diarization.rttm",
turns_json=output_dir / "turns.json",
exclusive_turns_json=output_dir / "exclusive_turns.json",
metadata=metadata,
)
def _require_writable_output(output_dir: Path) -> None:
unwritable = [
path
for path in (output_dir, *output_dir.rglob("*"))
if not os.access(path, os.W_OK)
]
if unwritable:
rendered = ", ".join(str(path) for path in unwritable[:3])
if len(unwritable) > 3:
rendered += f", and {len(unwritable) - 3} more"
raise DiarizationError(
f"Container diarization artifacts are not writable by the host user: {rendered}"
)
def run_native_pyannote(
audio_path: Path,
output_dir: Path,
device_mode: DeviceMode,
*,
model: str = DEFAULT_MODEL,
) -> DiarizationResult:
"""Run one local pyannote inference using an in-memory waveform mapping."""
try:
import torch
from pyannote.audio import Pipeline
except ImportError as exc:
raise DiarizationError(
f"Native diarization requires pyannote.audio=={PYANNOTE_VERSION} and PyTorch."
) from exc
token = os.environ.get("HF_TOKEN")
if not token:
raise DiarizationError("HF_TOKEN is required for the pyannote model.")
output_dir.mkdir(parents=True, exist_ok=True)
waveform, sample_rate, duration, audio_metadata = _load_pcm_wave(audio_path, torch)
device, device_name = select_device(device_mode, torch)
try:
pipeline = Pipeline.from_pretrained(model, token=token)
pipeline.to(device)
started = time.perf_counter()
output = pipeline(
{
"waveform": waveform,
"sample_rate": sample_rate,
"uri": audio_path.stem,
}
)
runtime = time.perf_counter() - started
except Exception as exc:
raise DiarizationError(f"pyannote diarization failed: {type(exc).__name__}: {exc}") from exc
ordinary = getattr(output, "speaker_diarization", output)
exclusive = getattr(output, "exclusive_speaker_diarization", None)
if exclusive is None:
raise DiarizationError("Community-1 did not return exclusive diarization.")
ordinary_turns = _turns(ordinary)
exclusive_turns = _turns(exclusive)
with (output_dir / "diarization.rttm").open("w", encoding="utf-8") as handle:
ordinary.write_rttm(handle)
with (output_dir / "exclusive_diarization.rttm").open(
"w", encoding="utf-8"
) as handle:
exclusive.write_rttm(handle)
_write_json(output_dir / "turns.json", ordinary_turns)
_write_json(output_dir / "exclusive_turns.json", exclusive_turns)
speakers = sorted({turn["speaker_id"] for turn in ordinary_turns})
actual_device = str(device)
metadata = {
"backend": "pyannote.audio",
"model": model,
"pyannote_version": importlib.metadata.version("pyannote.audio"),
"torch_version": torch.__version__,
"hip_version": getattr(torch.version, "hip", None),
"cuda_version": getattr(torch.version, "cuda", None),
"runtime_adapter": "native",
"requested_device_mode": device_mode,
"actual_device": actual_device,
"device_name": device_name if actual_device == "cuda" else None,
"audio_duration_seconds": duration,
"runtime_seconds": runtime,
"rtf": runtime / duration,
"speaker_count": len(speakers),
"speaker_labels": speakers,
"turn_count": len(ordinary_turns),
"exclusive_turn_count": len(exclusive_turns),
"audio": audio_metadata,
"credentials_persisted": False,
"output_files": {
"ordinary_rttm": "diarization.rttm",
"exclusive_rttm": "exclusive_diarization.rttm",
"turns": "turns.json",
"exclusive_turns": "exclusive_turns.json",
},
}
_write_json(output_dir / "metadata.json", metadata)
return _result_from_output(output_dir)
def run_container_pyannote(
audio_path: Path,
output_dir: Path,
device_mode: DeviceMode,
*,
image: str,
container_args: Sequence[str] = (),
runner: Callable[..., subprocess.CompletedProcess[str]] = subprocess.run,
uid_getter: Callable[[], int] = _host_uid,
gid_getter: Callable[[], int] = _host_gid,
) -> DiarizationResult:
"""Run the same backend in an explicitly configured disposable container."""
if not image.strip():
raise DiarizationError("A diarization container image is required.")
output_dir.mkdir(parents=True, exist_ok=True)
host_uid = uid_getter()
host_gid = gid_getter()
if host_uid < 0 or host_gid < 0:
raise DiarizationError("Host UID and GID must be non-negative integers.")
command = [
"docker", "run", "--rm", "--ipc=host", "--shm-size=8g", "-e", "HF_TOKEN",
*container_args,
"-v", f"{Path(audio_path).resolve()}:/input/audio.wav:ro",
"-v", f"{output_dir.resolve()}:/output:rw",
"-v", f"{Path(__file__).resolve().parents[3]}:/work/meeting-lab:ro",
"-w", "/work/meeting-lab",
image,
"/bin/bash", "-lc",
(
"inference_status=0; "
f"python -m pip install --disable-pip-version-check pyannote.audio=={PYANNOTE_VERSION} "
"> /output/pip-install.log 2>&1 && "
"python -m src.meeting_lab.diarization.container_entry "
f"/input/audio.wav /output --device {device_mode} || inference_status=$?; "
f"chown -R {host_uid}:{host_gid} /output || exit $?; "
"chmod -R u+rwX /output || exit $?; "
'exit "$inference_status"'
),
]
try:
completed = runner(command, check=False, capture_output=True, text=True)
except OSError as exc:
raise DiarizationError(f"Could not start diarization container: {exc}") from exc
(output_dir / "container_stdout.log").write_text(completed.stdout, encoding="utf-8")
(output_dir / "container_stderr.log").write_text(completed.stderr, encoding="utf-8")
if completed.returncode != 0:
detail = completed.stderr.strip() or completed.stdout.strip() or "no diagnostic output"
raise DiarizationError(
f"Diarization container failed with exit code {completed.returncode}: {detail}"
)
_require_writable_output(output_dir)
result = _result_from_output(output_dir)
metadata = dict(result.metadata)
metadata["runtime_adapter"] = "container"
_write_json(result.metadata_path, metadata)
return _result_from_output(output_dir)
def diarize_audio(
audio_path: Path,
output_dir: Path,
device_mode: DeviceMode,
*,
runtime: RuntimeMode = "native",
container_image: str | None = None,
container_args: Sequence[str] = (),
) -> DiarizationResult:
if runtime == "native":
return run_native_pyannote(audio_path, output_dir, device_mode)
if runtime == "container":
return run_container_pyannote(
audio_path,
output_dir,
device_mode,
image=container_image or "",
container_args=container_args,
)
raise DiarizationError(f"Unsupported diarization runtime: {runtime}")
@@ -0,0 +1,22 @@
"""Internal entry point for the isolated pyannote container adapter."""
from __future__ import annotations
import argparse
from pathlib import Path
from src.meeting_lab.diarization.backend import run_native_pyannote
def main() -> int:
parser = argparse.ArgumentParser()
parser.add_argument("audio", type=Path)
parser.add_argument("output", type=Path)
parser.add_argument("--device", choices=("auto", "gpu", "cpu"), required=True)
args = parser.parse_args()
run_native_pyannote(args.audio, args.output, args.device)
return 0
if __name__ == "__main__":
raise SystemExit(main())
+89
View File
@@ -3,6 +3,8 @@
from __future__ import annotations
import ast
import copy
import re
from dataclasses import dataclass
from pathlib import Path
from typing import Any
@@ -29,6 +31,21 @@ class MeetingContext:
def meeting_id(self) -> str:
return str(self.data["meeting"]["meeting_id"])
@property
def speaker_mappings(self) -> dict[str, str]:
mappings = self.data.get("speaker_mappings")
return dict(mappings) if isinstance(mappings, dict) else {}
def participant_for_speaker(self, speaker_label: str) -> dict[str, Any] | None:
"""Resolve only an explicit authoritative mapping; never infer identity."""
participant_id = self.speaker_mappings.get(speaker_label)
if participant_id is None:
return None
for participant in self.data.get("participants", []):
if participant.get("participant_id") == participant_id:
return participant
return None
def provenance(self) -> dict[str, str]:
return {
"meeting_id": self.meeting_id,
@@ -46,6 +63,40 @@ def load_meeting_context(path: Path) -> MeetingContext:
return MeetingContext(data=loaded, source_file=path)
def create_meeting_context(
data: dict[str, Any], *, source_file: Path = Path("<generated>")
) -> MeetingContext:
"""Validate structured data and return an immutable context boundary."""
validated = copy.deepcopy(data)
validate_meeting_context(validated)
return MeetingContext(data=validated, source_file=source_file)
def serialize_meeting_context_yaml(context: MeetingContext) -> str:
"""Serialize validated Meeting Context data deterministically as YAML."""
validate_meeting_context(context.data)
try:
import yaml # type: ignore[import-not-found]
except ModuleNotFoundError as exc:
raise MeetingContextValidationError(
"PyYAML is required to write Meeting Context YAML."
) from exc
return yaml.safe_dump(
context.data,
allow_unicode=True,
sort_keys=False,
default_flow_style=False,
)
def write_meeting_context(context: MeetingContext, path: Path) -> Path:
"""Persist a validated context without changing its schema or semantics."""
path = Path(path)
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(serialize_meeting_context_yaml(context), encoding="utf-8")
return path
def validate_meeting_context(data: dict[str, Any]) -> None:
schema_version = str(data.get("schema_version", "")).strip()
if schema_version not in SUPPORTED_SCHEMA_VERSIONS:
@@ -74,6 +125,22 @@ def validate_meeting_context(data: dict[str, Any]) -> None:
+ ", ".join(collisions)
)
speaker_mappings = _optional_mapping(
data.get("speaker_mappings"), "speaker_mappings"
)
for speaker_label, participant_id in speaker_mappings.items():
if not isinstance(speaker_label, str) or re.fullmatch(
r"SPEAKER_\d+", speaker_label
) is None:
raise MeetingContextValidationError(
f"Invalid diarization speaker label: {speaker_label!r}."
)
if not isinstance(participant_id, str) or participant_id not in participant_ids:
raise MeetingContextValidationError(
f"speaker_mappings.{speaker_label} references unknown participant: "
f"{participant_id!r}."
)
for index, participant in enumerate(participants):
item_path = f"participants[{index}]"
_validate_attendance(participant, item_path)
@@ -131,6 +198,28 @@ def render_meeting_context_for_prompt(context: MeetingContext) -> str:
for participant in participants:
lines.append(_render_person_line(participant, "participant_id", departments_by_id))
speaker_mappings = context.speaker_mappings
if speaker_mappings:
participants_by_id = {
participant["participant_id"]: participant
for participant in participants
if isinstance(participant, dict) and participant.get("participant_id")
}
lines.extend(
[
"",
"Confirmed diarization speaker mappings (authoritative):",
"- Use only these explicit mappings. Never infer identities for other speaker labels.",
"- Unmapped SPEAKER_XX labels must remain anonymous.",
]
)
for speaker_label, participant_id in sorted(speaker_mappings.items()):
participant = participants_by_id[participant_id]
lines.append(
f"- {speaker_label}: {_text(participant.get('display_name'))} "
f"(participant_id: {participant_id})"
)
mentioned_people = _optional_list(data.get("mentioned_people"), "mentioned_people")
if mentioned_people:
lines.extend(["", "Mentioned but absent people:"])
+17
View File
@@ -0,0 +1,17 @@
"""Reusable orchestration APIs for Meeting Lab applications and CLIs."""
from src.meeting_lab.orchestration.mvp import (
DEFAULT_OUTPUT_ROOT,
MvpMeetingConfig,
MvpRunResult,
create_unique_run_dir,
run_mvp_meeting,
)
__all__ = [
"DEFAULT_OUTPUT_ROOT",
"MvpMeetingConfig",
"MvpRunResult",
"create_unique_run_dir",
"run_mvp_meeting",
]
+360
View File
@@ -0,0 +1,360 @@
"""Reusable audio-to-direct-protocol MVP orchestration."""
from __future__ import annotations
import json
import re
import shutil
import sys
import time
from dataclasses import dataclass
from datetime import datetime
from pathlib import Path
from typing import Any, Callable, Mapping, Sequence
from src.meeting_lab.diarization import (
DEFAULT_MODEL as DEFAULT_DIARIZATION_MODEL,
diarize_audio,
write_diarized_transcript,
)
from src.meeting_lab.llm.ollama import DEFAULT_ENDPOINT
from src.meeting_lab.models.meeting_context import (
MeetingContext,
create_meeting_context,
load_meeting_context,
validate_meeting_context,
write_meeting_context,
)
from src.meeting_lab.progress import ProgressEvent, ProgressSink, ProgressStatus
from src.meeting_lab.protocol.generate_direct_protocol import (
DEFAULT_MODEL,
DirectProtocolResult,
generate_direct_protocol,
load_compact_transcript,
)
from src.meeting_lab.transcription.whisper import transcribe_audio
DEFAULT_OUTPUT_ROOT = Path("meeting_data/runs")
ContextInput = MeetingContext | Mapping[str, Any]
@dataclass(frozen=True)
class MvpMeetingConfig:
audio_file: Path
whisper_model: Path
whisper_executable: str = "whisper-cli"
context_file: Path | None = None
output_root: Path = DEFAULT_OUTPUT_ROOT
language: str = "de"
threads: str | int = "auto"
model: str = DEFAULT_MODEL
ollama_endpoint: str = DEFAULT_ENDPOINT
diarization: str = "off"
diarization_runtime: str = "native"
diarization_container_image: str | None = None
diarization_container_args: Sequence[str] = ()
@dataclass(frozen=True)
class MvpRunResult:
exit_code: int
run_dir: Path | None
protocol_path: Path | None
def create_unique_run_dir(
output_root: Path,
meeting_name: str,
now: Callable[[], datetime] = datetime.now,
) -> Path:
safe_name = re.sub(r"[^A-Za-z0-9_.-]+", "_", meeting_name).strip("._-") or "meeting"
base = output_root / f"{safe_name}_{now().strftime('%Y%m%d_%H%M%S')}"
candidate = base
suffix = 1
while candidate.exists():
candidate = output_root / f"{base.name}_{suffix:02d}"
suffix += 1
candidate.mkdir(parents=True)
return candidate
def _write_json(path: Path, value: Any) -> None:
path.write_text(json.dumps(value, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
def _effective_context(value: ContextInput | None) -> MeetingContext | None:
if value is None:
return None
if isinstance(value, MeetingContext):
validate_meeting_context(value.data)
return value
if isinstance(value, Mapping):
return create_meeting_context(dict(value), source_file=Path("<programmatic>"))
raise TypeError("meeting_context must be MeetingContext, mapping, or None.")
def _validate_inputs(
config: MvpMeetingConfig, meeting_context: MeetingContext | None
) -> None:
if not config.audio_file.is_file():
raise FileNotFoundError(f"Audio file does not exist: {config.audio_file}")
if not config.whisper_model.is_file():
raise FileNotFoundError(f"Whisper model does not exist: {config.whisper_model}")
if meeting_context is not None and config.context_file is not None:
raise ValueError("Use either a context file or a programmatic Meeting Context, not both.")
if meeting_context is not None:
validate_meeting_context(meeting_context.data)
elif config.context_file is not None:
if not config.context_file.is_file():
raise FileNotFoundError(
f"Meeting Context file does not exist: {config.context_file}"
)
load_meeting_context(config.context_file)
if config.diarization not in ("off", "auto", "gpu", "cpu"):
raise ValueError(f"Unsupported diarization mode: {config.diarization}")
if config.diarization_runtime not in ("native", "container"):
raise ValueError(
f"Unsupported diarization runtime: {config.diarization_runtime}"
)
if (
config.diarization != "off"
and config.diarization_runtime == "container"
and not config.diarization_container_image
):
raise ValueError("A diarization container image is required.")
def _emit(
sink: ProgressSink | None,
stage: str,
status: ProgressStatus,
overall_started: float,
*,
message: str | None = None,
) -> None:
if sink is not None:
sink(
ProgressEvent(
stage=stage,
status=status,
elapsed_seconds=time.perf_counter() - overall_started,
message=message,
)
)
def _persist_protocol(run_dir: Path, result: DirectProtocolResult) -> Path:
protocol_dir = run_dir / "protocol"
protocol_dir.mkdir(exist_ok=True)
(protocol_dir / "exact_prompt.txt").write_text(result.exact_prompt, encoding="utf-8")
_write_json(protocol_dir / "raw_response.json", result.raw_response)
_write_json(protocol_dir / "runtime_metadata.json", result.runtime_metadata)
protocol_path = run_dir / "protocol.md"
protocol_path.write_text(result.protocol_text, encoding="utf-8")
return protocol_path
def run_mvp_meeting(
config: MvpMeetingConfig,
*,
meeting_context: ContextInput | None = None,
progress_sink: ProgressSink | None = None,
) -> MvpRunResult:
"""Run the existing MVP directly, without subprocess or GUI dependencies."""
overall_started = time.perf_counter()
validation_started = time.perf_counter()
_emit(progress_sink, "preparing", "started", overall_started)
try:
effective_context = _effective_context(meeting_context)
_validate_inputs(config, effective_context)
except Exception as exc:
_emit(
progress_sink,
"failed",
"failed",
overall_started,
message=f"preparing: {type(exc).__name__}: {exc}",
)
print(f"Error: {type(exc).__name__}: {exc}", file=sys.stderr)
return MvpRunResult(2, None, None)
validation_runtime = time.perf_counter() - validation_started
run_dir = create_unique_run_dir(config.output_root, config.audio_file.stem)
timestamp = datetime.now().astimezone().isoformat(timespec="seconds")
transcript_path = run_dir / "transcript" / "transcript.json"
protocol_path = run_dir / "protocol.md"
stage_runtimes: dict[str, float | None] = {
"validation": round(validation_runtime, 3),
"setup": None,
"whisper": None,
"transcript_validation": None,
"protocol": None,
}
if config.diarization != "off":
stage_runtimes["diarization"] = None
stage_runtimes["diarization_alignment"] = None
metadata: dict[str, Any] = {
"run_id": run_dir.name,
"timestamp": timestamp,
"input_audio": str(config.audio_file.resolve()),
"transcript_output": str(transcript_path.resolve()),
"protocol_output": str(protocol_path.resolve()),
"whisper_model": str(config.whisper_model.resolve()),
"model": config.model,
"ollama_endpoint": config.ollama_endpoint,
"status": "running",
"stage_runtimes_seconds": stage_runtimes,
"total_runtime_seconds": None,
"failure": None,
"diarization": {
"enabled": config.diarization != "off",
"backend": "pyannote.audio" if config.diarization != "off" else None,
"model": DEFAULT_DIARIZATION_MODEL if config.diarization != "off" else None,
"requested_device_mode": config.diarization,
"runtime": config.diarization_runtime if config.diarization != "off" else None,
"metadata_path": None,
"transcript_diarized": None,
},
}
current_stage = "preparing"
stage_started = time.perf_counter()
try:
audio_dir = run_dir / "audio"
transcript_dir = run_dir / "transcript"
context_dir = run_dir / "context"
protocol_dir = run_dir / "protocol"
audio_dir.mkdir()
transcript_dir.mkdir()
context_dir.mkdir()
protocol_dir.mkdir()
_write_json(
audio_dir / "input_manifest.json",
{
"source_file": str(config.audio_file.resolve()),
"filename": config.audio_file.name,
"size_bytes": config.audio_file.stat().st_size,
},
)
preserved_context: Path | None = None
if effective_context is not None:
preserved_context = context_dir / "meeting_context.yaml"
write_meeting_context(effective_context, preserved_context)
elif config.context_file is not None:
preserved_context = context_dir / "meeting_context.yaml"
shutil.copy2(config.context_file, preserved_context)
stage_runtimes["setup"] = round(time.perf_counter() - stage_started, 3)
_emit(progress_sink, "preparing", "completed", overall_started)
current_stage = "transcription"
stage_started = time.perf_counter()
_emit(progress_sink, "transcription", "started", overall_started)
transcription = transcribe_audio(
config.audio_file,
config.whisper_model,
transcript_dir,
config.language,
executable=config.whisper_executable,
threads=config.threads,
)
stage_runtimes["whisper"] = round(time.perf_counter() - stage_started, 3)
_emit(progress_sink, "transcription", "completed", overall_started)
stage_started = time.perf_counter()
load_compact_transcript(transcription.transcript_json)
stage_runtimes["transcript_validation"] = round(
time.perf_counter() - stage_started, 3
)
protocol_transcript = transcription.transcript_json
if config.diarization != "off":
current_stage = "diarization"
stage_started = time.perf_counter()
_emit(progress_sink, "diarization", "started", overall_started)
diarization_dir = run_dir / "diarization"
diarization = diarize_audio(
config.audio_file,
diarization_dir,
config.diarization,
runtime=config.diarization_runtime,
container_image=config.diarization_container_image,
container_args=config.diarization_container_args,
)
stage_runtimes["diarization"] = round(
time.perf_counter() - stage_started, 3
)
metadata["diarization"].update(
{
"actual_device": diarization.metadata.get("actual_device"),
"device_name": diarization.metadata.get("device_name"),
"runtime_seconds": diarization.metadata.get("runtime_seconds"),
"speaker_count": diarization.metadata.get("speaker_count"),
"metadata_path": str(diarization.metadata_path.resolve()),
}
)
stage_started = time.perf_counter()
protocol_transcript, diarized_text = write_diarized_transcript(
transcription.transcript_json,
diarization.exclusive_turns_json,
diarization_dir,
)
load_compact_transcript(protocol_transcript)
stage_runtimes["diarization_alignment"] = round(
time.perf_counter() - stage_started, 3
)
metadata["diarization"].update(
{
"transcript_diarized": str(protocol_transcript.resolve()),
"transcript_diarized_text": str(diarized_text.resolve()),
}
)
_emit(progress_sink, "diarization", "completed", overall_started)
current_stage = "protocol_generation"
stage_started = time.perf_counter()
_emit(progress_sink, "protocol_generation", "started", overall_started)
result = generate_direct_protocol(
protocol_transcript,
preserved_context,
model=config.model,
endpoint=config.ollama_endpoint,
)
stage_runtimes["protocol"] = round(time.perf_counter() - stage_started, 3)
protocol_path = _persist_protocol(run_dir, result)
_emit(progress_sink, "protocol_generation", "completed", overall_started)
metadata["status"] = "completed"
_emit(progress_sink, "completed", "completed", overall_started)
except Exception as exc:
metadata_stage = {
"preparing": "setup",
"transcription": "whisper",
"diarization": "diarization",
"protocol_generation": "protocol",
}.get(current_stage, current_stage)
runtime_key = metadata_stage
if runtime_key in stage_runtimes and stage_runtimes[runtime_key] is None:
stage_runtimes[runtime_key] = round(time.perf_counter() - stage_started, 3)
metadata["status"] = "failed"
metadata["failure"] = {
"stage": metadata_stage,
"type": type(exc).__name__,
"message": str(exc),
}
protocol_path = None
_emit(
progress_sink,
"failed",
"failed",
overall_started,
message=f"{current_stage}: {type(exc).__name__}: {exc}",
)
print(f"Error: {type(exc).__name__}: {exc}", file=sys.stderr)
finally:
metadata["total_runtime_seconds"] = round(time.perf_counter() - overall_started, 3)
_write_json(run_dir / "run_metadata.json", metadata)
exit_code = 0 if metadata["status"] == "completed" else 2
return MvpRunResult(exit_code, run_dir, protocol_path)
+21
View File
@@ -0,0 +1,21 @@
"""Small observer boundary for long-running Meeting Lab operations."""
from __future__ import annotations
from dataclasses import dataclass
from typing import Callable, Literal
ProgressStatus = Literal["started", "completed", "failed"]
@dataclass(frozen=True)
class ProgressEvent:
stage: str
status: ProgressStatus
elapsed_seconds: float
progress: float | None = None
message: str | None = None
ProgressSink = Callable[[ProgressEvent], None]
@@ -3,11 +3,11 @@
from __future__ import annotations
DIRECT_PROTOCOL_INSTRUCTION = """Erstelle aus dem vollständigen Transkript und dem Meeting-Kontext ein prägnantes, professionelles internes Besprechungsprotokoll in deutscher Sprache.
DIRECT_PROTOCOL_INSTRUCTION = """Erstelle aus dem vollständigen Transkript und dem Meeting-Kontext ein vollständiges, strukturiertes und professionelles internes Besprechungsprotokoll in deutscher Sprache.
Das Protokoll muss themenorientiert sein, nicht chronologisch und nicht nach technischen Kategorien gegliedert. Beginne mit # Meeting Protocol. Verwende für jedes kohärente Thema eine Überschrift ## <Thema> und darunter eine knappe Synthese der Diskussion. Nenne Entscheidungen oder abgestimmte Positionen nur, wenn sie tatsächlich belegt sind. Führe Maßnahmen nur auf, wenn eine konkrete zukünftige Handlung gestützt ist; nenne verantwortliche Personen und Fristen ausschließlich bei expliziter Zuweisung, Annahme oder Bestätigung im Transkript. Vorschläge, Einwände, Möglichkeiten und vorläufige Ideen sind keine Entscheidungen oder Verpflichtungen. Bewahre relevante Einschränkungen und ungelöste Meinungsverschiedenheiten. Nenne offene Punkte nur, wenn sie wirklich offen bleiben. Nicht jedes Thema benötigt Entscheidungen, Maßnahmen oder offene Punkte.
Das Protokoll muss themenorientiert sein, nicht chronologisch und nicht nach technischen Kategorien gegliedert. Beginne mit # Meeting Protocol. Verwende für jedes kohärente Thema eine Überschrift ## <Thema> und darunter eine strukturierte Synthese der Diskussion. Bewahre relevante Diskussionsverläufe, unterschiedliche Positionen, offene Punkte und Entscheidungsgrundlagen. Dokumentiere die wesentlichen Inhalte nachvollziehbar und fasse Themenblöcke so zusammen, dass auch Personen, die nicht am Meeting teilgenommen haben, den Kontext und die Entwicklung der Diskussion verstehen können. Nenne Entscheidungen oder abgestimmte Positionen nur, wenn sie tatsächlich belegt sind. Führe Maßnahmen nur auf, wenn eine konkrete zukünftige Handlung gestützt ist; nenne verantwortliche Personen und Fristen ausschließlich bei expliziter Zuweisung, Annahme oder Bestätigung im Transkript. Vorschläge, Einwände, Möglichkeiten und vorläufige Ideen sind keine Entscheidungen oder Verpflichtungen. Bewahre relevante Einschränkungen und ungelöste Meinungsverschiedenheiten. Nenne offene Punkte nur, wenn sie wirklich offen bleiben. Nicht jedes Thema benötigt Entscheidungen, Maßnahmen oder offene Punkte.
Synthetisiere zusammengehörige Aussagen, entferne Füllwörter, Wiederholungen und Gesprächsrauschen und erfinde keine Fakten, Verantwortlichen oder Fristen. Gib kein JSON, keine internen Labels und keine Analyse oder Denkprotokolle aus. Das Ergebnis soll als Markdown-Protokoll nach geringfügiger menschlicher Redaktion intern versendbar sein. Eine kompakte themenübergreifende Maßnahmenliste am Ende ist optional, wenn sie nützlich und vollständig belegt ist."""
Erzeuge keine reine Wiedergabe des Transkripts und verlängere das Protokoll nicht unnötig durch Wiederholungen. Synthetisiere zusammengehörige Aussagen, entferne Füllwörter und Gesprächsrauschen und erfinde keine Fakten, Entscheidungen, Zustimmungen, Verantwortlichen oder Fristen. Gib kein JSON, keine internen Labels und keine Analyse oder Denkprotokolle aus. Das Ergebnis soll als Markdown-Protokoll nach geringfügiger menschlicher Redaktion intern versendbar sein. Eine kompakte themenübergreifende Maßnahmenliste am Ende ist optional, wenn sie nützlich und vollständig belegt ist."""
def build_direct_protocol_prompt(transcript: str, meeting_context: str | None = None) -> str:
@@ -0,0 +1,5 @@
"""Audio transcription support for the direct-protocol MVP."""
from .whisper import TranscriptionError, TranscriptionResult, transcribe_audio
__all__ = ["TranscriptionError", "TranscriptionResult", "transcribe_audio"]