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
+38
View File
@@ -0,0 +1,38 @@
# Optional Speaker Diarization
The direct-protocol MVP keeps speaker diarization disabled by default. Enable
anonymous Community-1 speaker labels with `--diarization auto`, `gpu`, or
`cpu`:
```bash
python3 scripts/run_mvp_meeting.py meeting.wav \
--whisper-model /path/to/ggml-model.bin \
--diarization auto
```
Native mode (the default runtime) requires a compatible local PyTorch and
`pyannote.audio==4.0.7`. For isolated ROCm/CUDA environments, select the
container runtime and provide its image and hardware arguments explicitly:
```bash
python3 scripts/run_mvp_meeting.py meeting.wav \
--whisper-model /path/to/ggml-model.bin \
--diarization gpu \
--diarization-runtime container \
--diarization-container-image IMAGE \
--diarization-container-arg=--device=/dev/kfd \
--diarization-container-arg=--device=/dev/dri \
--diarization-container-arg=--group-add \
--diarization-container-arg=video
```
The container receives `HF_TOKEN` by environment-variable name only. It mounts
the source audio and repository read-only and writes diarization artifacts into
the current run directory. Meeting Lab loads mono 16 kHz PCM16 WAV with
Python's `wave` module and sends an in-memory tensor to pyannote, avoiding its
torchcodec file decoder.
Anonymous `SPEAKER_XX` labels are aligned to Whisper segments by maximum
temporal overlap with Community-1 exclusive diarization. The original Whisper
transcript is preserved; the derived transcript under `diarization/` is used as
the unchanged direct-protocol generator's input.
@@ -4,6 +4,8 @@
schema_version: "1" schema_version: "1"
meeting: meeting:
# Stable identifier used for provenance across corrections and later runs.
meeting_id: ""
# Human-readable title for the meeting. # Human-readable title for the meeting.
title: "" title: ""
# Dominant meeting language, for example "de" or "en". # Dominant meeting language, for example "de" or "en".
@@ -28,6 +30,12 @@ participants:
attendance_status: "present" attendance_status: "present"
notes: null notes: null
# Optional authoritative mapping from diarization labels to actual participants.
# Add entries only after a human or trusted external process confirms identity.
# Never infer mappings from conversational context. Unmapped labels stay anonymous.
speaker_mappings: {}
# SPEAKER_00: "participant-id"
mentioned_people: mentioned_people:
# People discussed or referenced but not present in the meeting. # People discussed or referenced but not present in the meeting.
# Mentioned people are not speakers and must not become responsible persons # Mentioned people are not speakers and must not become responsible persons
+85 -165
View File
@@ -1,34 +1,29 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
"""Run the minimal Meeting Lab MVP: audio -> Whisper -> direct protocol.""" """CLI adapter for the reusable Meeting Lab MVP orchestration API."""
from __future__ import annotations from __future__ import annotations
import argparse import argparse
import json import json
import re
import shutil
import sys import sys
import time
from datetime import datetime
from pathlib import Path from pathlib import Path
from typing import Any, Callable from typing import Any
REPO_ROOT = Path(__file__).resolve().parents[1] REPO_ROOT = Path(__file__).resolve().parents[1]
if str(REPO_ROOT) not in sys.path: if str(REPO_ROOT) not in sys.path:
sys.path.insert(0, str(REPO_ROOT)) sys.path.insert(0, str(REPO_ROOT))
from src.meeting_lab.llm.ollama import DEFAULT_ENDPOINT # noqa: E402 from src.meeting_lab.llm.ollama import DEFAULT_ENDPOINT # noqa: E402
from src.meeting_lab.models.meeting_context import load_meeting_context # noqa: E402 from src.meeting_lab.models.meeting_context import MeetingContext # noqa: E402
from src.meeting_lab.protocol.generate_direct_protocol import ( # noqa: E402 from src.meeting_lab.orchestration.mvp import ( # noqa: E402
DEFAULT_DIARIZATION_MODEL,
DEFAULT_MODEL, DEFAULT_MODEL,
DirectProtocolResult, DEFAULT_OUTPUT_ROOT,
generate_direct_protocol, MvpMeetingConfig,
load_compact_transcript, create_unique_run_dir,
run_mvp_meeting,
) )
from src.meeting_lab.transcription.whisper import transcribe_audio # noqa: E402 from src.meeting_lab.progress import ProgressSink # noqa: E402
DEFAULT_OUTPUT_ROOT = Path("meeting_data/runs")
def parse_args(argv: list[str] | None = None) -> argparse.Namespace: def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
@@ -41,167 +36,92 @@ def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
parser.add_argument("--context", type=Path) parser.add_argument("--context", type=Path)
parser.add_argument("--output-root", type=Path, default=DEFAULT_OUTPUT_ROOT) parser.add_argument("--output-root", type=Path, default=DEFAULT_OUTPUT_ROOT)
parser.add_argument("--language", default="de") parser.add_argument("--language", default="de")
parser.add_argument("--threads", default="auto", help="Thread count or 'auto' for physical CPU cores (default: auto).") parser.add_argument(
"--threads",
default="auto",
help="Thread count or 'auto' for physical CPU cores (default: auto).",
)
parser.add_argument("--model", default=DEFAULT_MODEL) parser.add_argument("--model", default=DEFAULT_MODEL)
parser.add_argument("--ollama-endpoint", default=DEFAULT_ENDPOINT) parser.add_argument("--ollama-endpoint", default=DEFAULT_ENDPOINT)
parser.add_argument(
"--diarization",
choices=("auto", "gpu", "cpu", "off"),
default="off",
help="Optional Community-1 diarization device mode (default: off).",
)
parser.add_argument(
"--diarization-runtime",
choices=("native", "container"),
default="native",
help="Run pyannote in this Python environment or an explicit container.",
)
parser.add_argument(
"--diarization-container-image",
help="Container image required with --diarization-runtime container.",
)
parser.add_argument(
"--diarization-container-arg",
action="append",
default=[],
help="Additional docker argument; repeat and use = for values beginning with --.",
)
return parser.parse_args(argv) return parser.parse_args(argv)
def create_unique_run_dir( def config_from_args(args: argparse.Namespace) -> MvpMeetingConfig:
output_root: Path, return MvpMeetingConfig(
meeting_name: str, audio_file=args.audio_file,
now: Callable[[], datetime] = datetime.now, whisper_model=args.whisper_model,
) -> Path: whisper_executable=args.whisper_executable,
safe_name = re.sub(r"[^A-Za-z0-9_.-]+", "_", meeting_name).strip("._-") or "meeting" context_file=args.context,
base = output_root / f"{safe_name}_{now().strftime('%Y%m%d_%H%M%S')}" output_root=args.output_root,
candidate = base language=args.language,
suffix = 1 threads=args.threads,
while candidate.exists(): model=args.model,
candidate = output_root / f"{base.name}_{suffix:02d}" ollama_endpoint=args.ollama_endpoint,
suffix += 1 diarization=args.diarization,
candidate.mkdir(parents=True) diarization_runtime=args.diarization_runtime,
return candidate diarization_container_image=args.diarization_container_image,
diarization_container_args=tuple(args.diarization_container_arg),
)
def write_json(path: Path, value: Any) -> None: def run(
path.write_text(json.dumps(value, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") args: argparse.Namespace,
*,
context_override: MeetingContext | dict[str, Any] | None = None,
def validate_inputs(args: argparse.Namespace) -> None: progress_sink: ProgressSink | None = None,
if not args.audio_file.is_file(): ) -> tuple[int, Path | None, Path | None]:
raise FileNotFoundError(f"Audio file does not exist: {args.audio_file}") """Compatibility wrapper for existing Python callers of the CLI module."""
if not args.whisper_model.is_file(): result = run_mvp_meeting(
raise FileNotFoundError(f"Whisper model does not exist: {args.whisper_model}") config_from_args(args),
if args.context is not None: meeting_context=context_override,
if not args.context.is_file(): progress_sink=progress_sink,
raise FileNotFoundError(f"Meeting Context file does not exist: {args.context}") )
load_meeting_context(args.context) return result.exit_code, result.run_dir, result.protocol_path
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(args: argparse.Namespace) -> tuple[int, Path | None, Path | None]:
overall_started = time.perf_counter()
validation_started = time.perf_counter()
try:
validate_inputs(args)
except Exception as exc:
print(f"Error: {type(exc).__name__}: {exc}", file=sys.stderr)
return 2, None, None
validation_runtime = time.perf_counter() - validation_started
run_dir = create_unique_run_dir(args.output_root, args.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,
}
metadata: dict[str, Any] = {
"run_id": run_dir.name,
"timestamp": timestamp,
"input_audio": str(args.audio_file.resolve()),
"transcript_output": str(transcript_path.resolve()),
"protocol_output": str(protocol_path.resolve()),
"whisper_model": str(args.whisper_model.resolve()),
"model": args.model,
"ollama_endpoint": args.ollama_endpoint,
"status": "running",
"stage_runtimes_seconds": stage_runtimes,
"total_runtime_seconds": None,
"failure": None,
}
current_stage = "setup"
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(args.audio_file.resolve()),
"filename": args.audio_file.name,
"size_bytes": args.audio_file.stat().st_size,
},
)
preserved_context: Path | None = None
if args.context is not None:
preserved_context = context_dir / "meeting_context.yaml"
shutil.copy2(args.context, preserved_context)
stage_runtimes["setup"] = round(time.perf_counter() - stage_started, 3)
current_stage = "whisper"
stage_started = time.perf_counter()
transcription = transcribe_audio(
args.audio_file,
args.whisper_model,
transcript_dir,
args.language,
executable=args.whisper_executable,
threads=args.threads,
)
stage_runtimes["whisper"] = round(time.perf_counter() - stage_started, 3)
current_stage = "transcript_validation"
stage_started = time.perf_counter()
load_compact_transcript(transcription.transcript_json)
stage_runtimes["transcript_validation"] = round(
time.perf_counter() - stage_started, 3
)
current_stage = "protocol"
stage_started = time.perf_counter()
result = generate_direct_protocol(
transcription.transcript_json,
preserved_context,
model=args.model,
endpoint=args.ollama_endpoint,
)
stage_runtimes["protocol"] = round(time.perf_counter() - stage_started, 3)
protocol_path = persist_protocol(run_dir, result)
metadata["status"] = "completed"
except Exception as exc:
if current_stage in stage_runtimes and stage_runtimes[current_stage] is None:
stage_runtimes[current_stage] = round(time.perf_counter() - stage_started, 3)
metadata["status"] = "failed"
metadata["failure"] = {
"stage": current_stage,
"type": type(exc).__name__,
"message": str(exc),
}
protocol_path = None
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)
return (0 if metadata["status"] == "completed" else 2), run_dir, protocol_path
def main(argv: list[str] | None = None) -> int: def main(argv: list[str] | None = None) -> int:
args = parse_args(argv) args = parse_args(argv)
code, _run_dir, protocol_path = run(args) if args.diarization == "off":
print("Diarization: disabled")
else:
print(
f"Diarization: enabled; backend=pyannote.audio; "
f"model={DEFAULT_DIARIZATION_MODEL}; requested_device={args.diarization}; "
f"runtime={args.diarization_runtime}"
)
code, run_dir, protocol_path = run(args)
if run_dir is not None and args.diarization != "off":
metadata_path = run_dir / "diarization" / "metadata.json"
if metadata_path.is_file():
details = json.loads(metadata_path.read_text(encoding="utf-8"))
print(
f"Diarization result: device={details.get('actual_device')}; "
f"device_name={details.get('device_name') or 'n/a'}; "
f"runtime={details.get('runtime_seconds'):.3f}s; "
f"speakers={details.get('speaker_count')}; artifacts={metadata_path.parent}"
)
if protocol_path is not None: if protocol_path is not None:
print(protocol_path) print(protocol_path)
return code return code
+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 from __future__ import annotations
import ast import ast
import copy
import re
from dataclasses import dataclass from dataclasses import dataclass
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any
@@ -29,6 +31,21 @@ class MeetingContext:
def meeting_id(self) -> str: def meeting_id(self) -> str:
return str(self.data["meeting"]["meeting_id"]) 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]: def provenance(self) -> dict[str, str]:
return { return {
"meeting_id": self.meeting_id, "meeting_id": self.meeting_id,
@@ -46,6 +63,40 @@ def load_meeting_context(path: Path) -> MeetingContext:
return MeetingContext(data=loaded, source_file=path) 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: def validate_meeting_context(data: dict[str, Any]) -> None:
schema_version = str(data.get("schema_version", "")).strip() schema_version = str(data.get("schema_version", "")).strip()
if schema_version not in SUPPORTED_SCHEMA_VERSIONS: if schema_version not in SUPPORTED_SCHEMA_VERSIONS:
@@ -74,6 +125,22 @@ def validate_meeting_context(data: dict[str, Any]) -> None:
+ ", ".join(collisions) + ", ".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): for index, participant in enumerate(participants):
item_path = f"participants[{index}]" item_path = f"participants[{index}]"
_validate_attendance(participant, item_path) _validate_attendance(participant, item_path)
@@ -131,6 +198,28 @@ def render_meeting_context_for_prompt(context: MeetingContext) -> str:
for participant in participants: for participant in participants:
lines.append(_render_person_line(participant, "participant_id", departments_by_id)) 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") mentioned_people = _optional_list(data.get("mentioned_people"), "mentioned_people")
if mentioned_people: if mentioned_people:
lines.extend(["", "Mentioned but absent 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 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: 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"]
+222
View File
@@ -0,0 +1,222 @@
import json
import os
import tempfile
import unittest
from pathlib import Path
from types import SimpleNamespace
from unittest.mock import patch
from src.meeting_lab.diarization.alignment import align_transcript, write_diarized_transcript
from src.meeting_lab.diarization.backend import (
DiarizationError,
diarize_audio,
run_container_pyannote,
select_device,
)
class FakeCuda:
def __init__(self, available: bool, *, name: str = "Test GPU", failure=None):
self.available = available
self.name = name
self.failure = failure
def is_available(self):
return self.available
def get_device_name(self, index):
if self.failure:
raise self.failure
return self.name
class FakeTorch:
def __init__(self, available: bool, *, failure=None):
self.cuda = FakeCuda(available, failure=failure)
self.probes = []
def device(self, name):
return name
def zeros(self, size, *, device):
self.probes.append(device)
if self.cuda.failure:
raise self.cuda.failure
return [0]
class DeviceSelectionTests(unittest.TestCase):
def test_auto_selects_usable_gpu(self):
torch = FakeTorch(True)
self.assertEqual(select_device("auto", torch), ("cuda", "Test GPU"))
self.assertEqual(torch.probes, ["cuda"])
def test_auto_falls_back_to_cpu(self):
self.assertEqual(select_device("auto", FakeTorch(False)), ("cpu", None))
self.assertEqual(
select_device("auto", FakeTorch(True, failure=RuntimeError("probe"))),
("cpu", None),
)
def test_explicit_cpu_does_not_probe_gpu(self):
torch = FakeTorch(True)
self.assertEqual(select_device("cpu", torch), ("cpu", None))
self.assertEqual(torch.probes, [])
def test_explicit_gpu_fails_when_unavailable(self):
with self.assertRaisesRegex(DiarizationError, "GPU is unavailable"):
select_device("gpu", FakeTorch(False))
class AlignmentTests(unittest.TestCase):
def test_exclusive_overlap_assigns_anonymous_speakers(self):
transcript = {
"text": "Original unchanged text.",
"segments": [
{"id": 0, "start": 0.0, "end": 4.0, "text": "Hallo"},
{"id": 1, "start": 4.0, "end": 6.0, "text": "Antwort"},
],
}
original = json.loads(json.dumps(transcript))
turns = [
{"start": 0.0, "end": 3.0, "speaker_id": "SPEAKER_00"},
{"start": 3.0, "end": 6.0, "speaker_id": "SPEAKER_01"},
]
derived = align_transcript(transcript, turns)
self.assertEqual(transcript, original)
self.assertEqual(derived["segments"][0]["speaker_id"], "SPEAKER_00")
self.assertEqual(derived["segments"][0]["speaker_overlap_seconds"], 3.0)
self.assertEqual(derived["segments"][1]["speaker_id"], "SPEAKER_01")
self.assertIn("SPEAKER_00: Hallo", derived["text"])
self.assertTrue(derived["speaker_labels_anonymous"])
self.assertEqual(derived["alignment_source"], "exclusive_diarization")
def test_speaker_aware_transcript_is_a_separate_artifact(self):
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
source = root / "transcript.json"
turns = root / "exclusive_turns.json"
source_text = json.dumps(
{"text": "Original", "segments": [{"start": 0, "end": 1, "text": "Hi"}]}
)
source.write_text(source_text, encoding="utf-8")
turns.write_text(
json.dumps([{"start": 0, "end": 1, "speaker_id": "SPEAKER_07"}]),
encoding="utf-8",
)
json_path, text_path = write_diarized_transcript(source, turns, root / "derived")
self.assertEqual(source.read_text(encoding="utf-8"), source_text)
self.assertNotEqual(json_path, source)
self.assertIn("SPEAKER_07", json_path.read_text(encoding="utf-8"))
self.assertIn("SPEAKER_07", text_path.read_text(encoding="utf-8"))
class ContainerAdapterTests(unittest.TestCase):
def test_container_configuration_and_metadata_do_not_persist_token(self):
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
audio = root / "audio.wav"
output = root / "output"
audio.write_bytes(b"audio")
observed = {}
def fake_runner(command, **kwargs):
observed["command"] = command
output.mkdir(exist_ok=True)
metadata = {
"backend": "pyannote.audio",
"model": "pyannote/speaker-diarization-community-1",
"requested_device_mode": "gpu",
"actual_device": "cuda",
"credentials_persisted": False,
}
(output / "metadata.json").write_text(json.dumps(metadata))
return SimpleNamespace(returncode=0, stdout="ok", stderr="")
with patch.dict("os.environ", {"HF_TOKEN": "secret-token"}):
result = run_container_pyannote(
audio,
output,
"gpu",
image="test/image",
container_args=("--device=/dev/test",),
runner=fake_runner,
uid_getter=lambda: 2345,
gid_getter=lambda: 3456,
)
command = observed["command"]
shell_command = command[-1]
persisted = "".join(
path.read_text(encoding="utf-8")
for path in output.iterdir()
if path.is_file()
)
self.assertNotIn("secret-token", persisted)
self.assertNotIn("secret-token", command)
self.assertIn("HF_TOKEN", command)
self.assertIn("chown -R 2345:3456 /output", shell_command)
self.assertIn("chmod -R u+rwX /output", shell_command)
self.assertNotIn("1000:1000", shell_command)
device_index = command.index("--device=/dev/test")
self.assertLess(device_index, command.index("test/image"))
self.assertFalse(result.metadata["credentials_persisted"])
self.assertEqual(result.metadata["runtime_adapter"], "container")
self.assertTrue(
all(os.access(path, os.W_OK) for path in (output, *output.rglob("*")))
)
def test_unwritable_container_artifact_is_rejected_before_metadata_update(self):
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
audio = root / "audio.wav"
output = root / "output"
audio.write_bytes(b"audio")
def fake_runner(command, **kwargs):
output.mkdir(exist_ok=True)
metadata = output / "metadata.json"
metadata.write_text("{}", encoding="utf-8")
metadata.chmod(0o444)
return SimpleNamespace(returncode=0, stdout="", stderr="")
with patch(
"src.meeting_lab.diarization.backend.os.access",
side_effect=lambda path, mode: Path(path).name != "metadata.json",
):
with self.assertRaisesRegex(DiarizationError, "not writable"):
run_container_pyannote(
audio,
output,
"cpu",
image="test/image",
runner=fake_runner,
)
def test_orchestrator_dispatches_runtime(self):
with patch(
"src.meeting_lab.diarization.backend.run_container_pyannote"
) as container:
diarize_audio(
Path("audio.wav"),
Path("out"),
"cpu",
runtime="container",
container_image="image",
container_args=("--arg",),
)
container.assert_called_once_with(
Path("audio.wav"),
Path("out"),
"cpu",
image="image",
container_args=("--arg",),
)
if __name__ == "__main__":
unittest.main()
+18
View File
@@ -81,6 +81,24 @@ class TranscriptLoadingTests(unittest.TestCase):
class GeneratorTests(unittest.TestCase): class GeneratorTests(unittest.TestCase):
def test_prompt_requires_contextual_discussion_density_without_transcript_replay(self) -> None:
with tempfile.TemporaryDirectory() as directory:
transcript = Path(directory) / "transcript.json"
write_transcript(transcript)
result = generate_direct_protocol(
transcript,
model_check=Mock(return_value={}),
generation_call=Mock(return_value=generation()),
)
self.assertIn("vollständiges, strukturiertes", result.exact_prompt)
self.assertIn("relevante Diskussionsverläufe", result.exact_prompt)
self.assertIn("unterschiedliche Positionen", result.exact_prompt)
self.assertIn("Entscheidungsgrundlagen", result.exact_prompt)
self.assertIn("nicht am Meeting teilgenommen haben", result.exact_prompt)
self.assertIn("keine reine Wiedergabe des Transkripts", result.exact_prompt)
self.assertIn("nicht unnötig durch Wiederholungen", result.exact_prompt)
def test_optional_context_absent_and_generation_called_once(self) -> None: def test_optional_context_absent_and_generation_called_once(self) -> None:
with tempfile.TemporaryDirectory() as directory: with tempfile.TemporaryDirectory() as directory:
transcript = Path(directory) / "transcript.json" transcript = Path(directory) / "transcript.json"
+47
View File
@@ -11,8 +11,10 @@ from src.meeting_lab.extraction.extract_chunks import (
) )
from src.meeting_lab.models.meeting_context import ( from src.meeting_lab.models.meeting_context import (
MeetingContextValidationError, MeetingContextValidationError,
create_meeting_context,
load_meeting_context, load_meeting_context,
render_meeting_context_for_prompt, render_meeting_context_for_prompt,
serialize_meeting_context_yaml,
validate_meeting_context, validate_meeting_context,
) )
@@ -192,6 +194,51 @@ class MeetingContextTests(unittest.TestCase):
self.assertNotIn("responsible: Björn", prompt_context) self.assertNotIn("responsible: Björn", prompt_context)
self.assertNotIn("responsible: Jovana", prompt_context) self.assertNotIn("responsible: Jovana", prompt_context)
def test_existing_context_without_speaker_mappings_remains_valid(self) -> None:
self.assertEqual(self.context.speaker_mappings, {})
self.assertIsNone(self.context.participant_for_speaker("SPEAKER_00"))
def test_explicit_speaker_mapping_is_authoritative(self) -> None:
data = copy.deepcopy(self.context.data)
participant = data["participants"][0]
data["speaker_mappings"] = {"SPEAKER_03": participant["participant_id"]}
context = create_meeting_context(data)
rendered = render_meeting_context_for_prompt(context)
self.assertEqual(
context.participant_for_speaker("SPEAKER_03")["participant_id"],
participant["participant_id"],
)
self.assertIsNone(context.participant_for_speaker("SPEAKER_04"))
self.assertIn("Confirmed diarization speaker mappings (authoritative)", rendered)
self.assertIn("Unmapped SPEAKER_XX labels must remain anonymous", rendered)
def test_speaker_mapping_must_reference_existing_participant(self) -> None:
data = copy.deepcopy(self.context.data)
data["speaker_mappings"] = {"SPEAKER_00": "unknown-person"}
with self.assertRaisesRegex(MeetingContextValidationError, "unknown participant"):
validate_meeting_context(data)
def test_speaker_mapping_label_must_use_pyannote_shape(self) -> None:
data = copy.deepcopy(self.context.data)
data["speaker_mappings"] = {
"Martin": data["participants"][0]["participant_id"]
}
with self.assertRaisesRegex(MeetingContextValidationError, "speaker label"):
validate_meeting_context(data)
def test_generated_context_yaml_is_deterministic_and_round_trips(self) -> None:
first = serialize_meeting_context_yaml(self.context)
second = serialize_meeting_context_yaml(self.context)
self.assertEqual(first, second)
SCRATCH_DIR.mkdir(exist_ok=True)
path = SCRATCH_DIR / "generated_context.yaml"
path.write_text(first, encoding="utf-8")
loaded = load_meeting_context(path)
self.assertEqual(loaded.data, self.context.data)
if __name__ == "__main__": if __name__ == "__main__":
unittest.main() unittest.main()
+175
View File
@@ -0,0 +1,175 @@
import json
import subprocess
import tempfile
import unittest
from pathlib import Path
from unittest.mock import patch
from scripts import run_mvp_meeting as cli
from src.meeting_lab.models.meeting_context import load_meeting_context
from src.meeting_lab.orchestration import mvp as mvp_api
from src.meeting_lab.orchestration.mvp import MvpMeetingConfig, MvpRunResult
from src.meeting_lab.protocol.generate_direct_protocol import DirectProtocolResult
from src.meeting_lab.transcription.whisper import TranscriptionError, TranscriptionResult
def context_data():
return {
"schema_version": "1",
"meeting": {
"meeting_id": "programmatic-test",
"title": "Programmatic Test",
"language": "de",
"date": None,
"objective": "API prüfen",
"notes": "",
},
"participants": [
{
"participant_id": "person-1",
"display_name": "Test Person",
"aliases": [],
"role": "Projektleitung",
"department": None,
"attendance_status": "present",
"notes": None,
}
],
"speaker_mappings": {"SPEAKER_00": "person-1"},
"mentioned_people": [],
"organization": {"name": "Example", "departments": []},
"known_entities": {},
"context_rules": {"do_not_infer_responsibilities": True},
}
def fake_transcribe(audio, model, output, language, **kwargs):
output.mkdir(parents=True, exist_ok=True)
raw = output / "whisper_raw.json"
transcript = output / "transcript.json"
text = output / "transcript.txt"
metadata = output / "runtime_metadata.json"
raw.write_text('{"transcription": []}\n', encoding="utf-8")
transcript.write_text(
json.dumps(
{
"text": "Ein kurzer Besprechungstext.",
"segments": [
{
"id": 0,
"start": 0.0,
"end": 1.0,
"text": "Ein kurzer Besprechungstext.",
}
],
}
),
encoding="utf-8",
)
text.write_text("Ein kurzer Besprechungstext.\n", encoding="utf-8")
metadata.write_text('{"runtime_seconds": 0.1}\n', encoding="utf-8")
return TranscriptionResult(output, raw, transcript, text, metadata, 0.1)
def fake_protocol(transcript, context, **kwargs):
rendered_context = load_meeting_context(context)
assert rendered_context.meeting_id == "programmatic-test"
return DirectProtocolResult(
protocol_text="# Meeting Protocol\n",
exact_prompt="prompt",
model_metadata={"model": kwargs["model"]},
runtime_metadata={"request_count": 1},
raw_response={"response": "# Meeting Protocol\n"},
)
class MvpApiTests(unittest.TestCase):
def config(self, root: Path):
audio = root / "meeting.wav"
model = root / "model.bin"
audio.write_bytes(b"audio")
model.write_bytes(b"model")
return MvpMeetingConfig(
audio_file=audio,
whisper_model=model,
output_root=root / "runs",
model="test:model",
)
def test_programmatic_context_is_persisted_without_source_yaml(self):
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
config = self.config(root)
events = []
with (
patch.object(mvp_api, "transcribe_audio", side_effect=fake_transcribe),
patch.object(
mvp_api, "generate_direct_protocol", side_effect=fake_protocol
),
patch.object(subprocess, "run") as subprocess_run,
):
result = mvp_api.run_mvp_meeting(
config, meeting_context=context_data(), progress_sink=events.append
)
self.assertEqual(result.exit_code, 0)
context_path = result.run_dir / "context/meeting_context.yaml"
self.assertTrue(context_path.is_file())
persisted = load_meeting_context(context_path)
self.assertEqual(persisted.meeting_id, "programmatic-test")
self.assertEqual(persisted.speaker_mappings, {"SPEAKER_00": "person-1"})
subprocess_run.assert_not_called()
self.assertEqual(
[(event.stage, event.status) for event in events],
[
("preparing", "started"),
("preparing", "completed"),
("transcription", "started"),
("transcription", "completed"),
("protocol_generation", "started"),
("protocol_generation", "completed"),
("completed", "completed"),
],
)
self.assertTrue(all(event.progress is None for event in events))
def test_failure_emits_terminal_failure_event(self):
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
config = self.config(root)
events = []
with patch.object(
mvp_api,
"transcribe_audio",
side_effect=TranscriptionError("stopped"),
):
result = mvp_api.run_mvp_meeting(config, progress_sink=events.append)
self.assertEqual(result.exit_code, 2)
self.assertEqual(events[-1].stage, "failed")
self.assertEqual(events[-1].status, "failed")
self.assertIn("transcription", events[-1].message)
metadata = json.loads((result.run_dir / "run_metadata.json").read_text())
self.assertEqual(metadata["status"], "failed")
def test_cli_defaults_and_wrapper_delegate_without_subprocess(self):
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
config = self.config(root)
args = cli.parse_args(
[str(config.audio_file), "--whisper-model", str(config.whisper_model)]
)
expected = MvpRunResult(0, root / "run", root / "run/protocol.md")
with patch.object(cli, "run_mvp_meeting", return_value=expected) as api:
actual = cli.run(args, context_override=context_data())
self.assertEqual(actual, (0, expected.run_dir, expected.protocol_path))
delegated = api.call_args.args[0]
self.assertEqual(delegated.diarization, "off")
self.assertEqual(delegated.language, "de")
self.assertEqual(delegated.whisper_executable, "whisper-cli")
self.assertEqual(api.call_args.kwargs["meeting_context"], context_data())
if __name__ == "__main__":
unittest.main()
+124 -12
View File
@@ -6,7 +6,9 @@ from pathlib import Path
from unittest.mock import Mock, patch from unittest.mock import Mock, patch
from scripts import run_mvp_meeting from scripts import run_mvp_meeting
from src.meeting_lab.orchestration import mvp as mvp_api
from src.meeting_lab.protocol.generate_direct_protocol import DirectProtocolResult from src.meeting_lab.protocol.generate_direct_protocol import DirectProtocolResult
from src.meeting_lab.diarization.backend import DiarizationResult
from src.meeting_lab.transcription.whisper import TranscriptionError, TranscriptionResult from src.meeting_lab.transcription.whisper import TranscriptionError, TranscriptionResult
@@ -50,7 +52,15 @@ def fake_transcribe(
metadata = output_dir / "runtime_metadata.json" metadata = output_dir / "runtime_metadata.json"
raw.write_text('{"transcription": []}\n', encoding="utf-8") raw.write_text('{"transcription": []}\n', encoding="utf-8")
transcript.write_text( transcript.write_text(
json.dumps({"text": "Besprechungstext.", "segments": []}) + "\n", json.dumps(
{
"text": "Besprechungstext.",
"segments": [
{"id": 0, "start": 0.0, "end": 1.0, "text": "Besprechungstext."}
],
}
)
+ "\n",
encoding="utf-8", encoding="utf-8",
) )
text.write_text("Besprechungstext.\n", encoding="utf-8") text.write_text("Besprechungstext.\n", encoding="utf-8")
@@ -58,6 +68,44 @@ def fake_transcribe(
return TranscriptionResult(output_dir, raw, transcript, text, metadata, 1.25) return TranscriptionResult(output_dir, raw, transcript, text, metadata, 1.25)
def fake_diarize(audio_path, output_dir, device_mode, **kwargs):
output_dir.mkdir(parents=True, exist_ok=True)
metadata = {
"backend": "pyannote.audio",
"model": "pyannote/speaker-diarization-community-1",
"requested_device_mode": device_mode,
"actual_device": "cuda",
"device_name": "Fake GPU",
"runtime_seconds": 2.5,
"speaker_count": 1,
"credentials_persisted": False,
}
paths = {
"metadata": output_dir / "metadata.json",
"ordinary": output_dir / "diarization.rttm",
"exclusive": output_dir / "exclusive_diarization.rttm",
"turns": output_dir / "turns.json",
"exclusive_turns": output_dir / "exclusive_turns.json",
}
paths["metadata"].write_text(json.dumps(metadata), encoding="utf-8")
paths["ordinary"].write_text("", encoding="utf-8")
paths["exclusive"].write_text("", encoding="utf-8")
paths["turns"].write_text("[]", encoding="utf-8")
paths["exclusive_turns"].write_text(
json.dumps([{"start": 0, "end": 10, "speaker_id": "SPEAKER_00"}]),
encoding="utf-8",
)
return DiarizationResult(
output_dir,
paths["metadata"],
paths["ordinary"],
paths["exclusive"],
paths["turns"],
paths["exclusive_turns"],
metadata,
)
class MvpOrchestratorTests(unittest.TestCase): class MvpOrchestratorTests(unittest.TestCase):
def create_inputs(self, root: Path) -> tuple[Path, Path, Path]: def create_inputs(self, root: Path) -> tuple[Path, Path, Path]:
audio = root / "team meeting.wav" audio = root / "team meeting.wav"
@@ -88,9 +136,9 @@ class MvpOrchestratorTests(unittest.TestCase):
root = Path(directory) root = Path(directory)
args = self.args(root) args = self.args(root)
with ( with (
patch.object(run_mvp_meeting, "transcribe_audio", side_effect=fake_transcribe) as whisper, patch.object(mvp_api, "transcribe_audio", side_effect=fake_transcribe) as whisper,
patch.object( patch.object(
run_mvp_meeting, mvp_api,
"generate_direct_protocol", "generate_direct_protocol",
return_value=protocol_result(), return_value=protocol_result(),
) as protocol, ) as protocol,
@@ -129,9 +177,9 @@ class MvpOrchestratorTests(unittest.TestCase):
["--whisper-executable", "/tools/whisper-cli", "--threads", "4"], ["--whisper-executable", "/tools/whisper-cli", "--threads", "4"],
) )
with ( with (
patch.object(run_mvp_meeting, "transcribe_audio", side_effect=fake_transcribe) as whisper, patch.object(mvp_api, "transcribe_audio", side_effect=fake_transcribe) as whisper,
patch.object( patch.object(
run_mvp_meeting, mvp_api,
"generate_direct_protocol", "generate_direct_protocol",
return_value=protocol_result(), return_value=protocol_result(),
) as protocol, ) as protocol,
@@ -148,17 +196,81 @@ class MvpOrchestratorTests(unittest.TestCase):
protocol.call_args.kwargs["endpoint"], "http://ollama.test:11434" protocol.call_args.kwargs["endpoint"], "http://ollama.test:11434"
) )
def test_diarization_is_off_by_default_and_preserves_protocol_input(self) -> None:
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
args = self.args(root)
with (
patch.object(mvp_api, "transcribe_audio", side_effect=fake_transcribe),
patch.object(mvp_api, "diarize_audio") as diarization,
patch.object(
mvp_api,
"generate_direct_protocol",
return_value=protocol_result(),
) as protocol,
):
code, run_dir, _ = run_mvp_meeting.run(args)
self.assertEqual(code, 0)
diarization.assert_not_called()
self.assertEqual(protocol.call_args.args[0], run_dir / "transcript/transcript.json")
self.assertFalse((run_dir / "diarization").exists())
def test_diarization_cli_propagates_and_uses_derived_protocol_input(self) -> None:
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
args = self.args(
root,
[
"--diarization", "gpu",
"--diarization-runtime", "container",
"--diarization-container-image", "rocm/test",
"--diarization-container-arg=--device=/dev/kfd",
],
)
with (
patch.object(
mvp_api, "transcribe_audio", side_effect=fake_transcribe
),
patch.object(
mvp_api, "diarize_audio", side_effect=fake_diarize
) as diarization,
patch.object(
mvp_api,
"generate_direct_protocol",
return_value=protocol_result(),
) as protocol,
):
code, run_dir, _ = run_mvp_meeting.run(args)
self.assertEqual(code, 0)
self.assertEqual(diarization.call_args.args[2], "gpu")
self.assertEqual(diarization.call_args.kwargs["runtime"], "container")
self.assertEqual(
diarization.call_args.kwargs["container_args"], ("--device=/dev/kfd",)
)
derived = run_dir / "diarization/transcript_diarized.json"
self.assertEqual(protocol.call_args.args[0], derived)
self.assertIn("SPEAKER_00", derived.read_text(encoding="utf-8"))
self.assertEqual(
json.loads((run_dir / "transcript/transcript.json").read_text())["text"],
"Besprechungstext.",
)
run_metadata = json.loads((run_dir / "run_metadata.json").read_text())
self.assertTrue(run_metadata["diarization"]["enabled"])
self.assertNotIn("HF_TOKEN", json.dumps(run_metadata))
def test_whisper_failure_is_recorded_and_protocol_is_not_called(self) -> None: def test_whisper_failure_is_recorded_and_protocol_is_not_called(self) -> None:
with tempfile.TemporaryDirectory() as directory: with tempfile.TemporaryDirectory() as directory:
root = Path(directory) root = Path(directory)
args = self.args(root) args = self.args(root)
with ( with (
patch.object( patch.object(
run_mvp_meeting, mvp_api,
"transcribe_audio", "transcribe_audio",
side_effect=TranscriptionError("whisper stopped"), side_effect=TranscriptionError("whisper stopped"),
), ),
patch.object(run_mvp_meeting, "generate_direct_protocol") as protocol, patch.object(mvp_api, "generate_direct_protocol") as protocol,
): ):
code, run_dir, protocol_path = run_mvp_meeting.run(args) code, run_dir, protocol_path = run_mvp_meeting.run(args)
@@ -176,9 +288,9 @@ class MvpOrchestratorTests(unittest.TestCase):
root = Path(directory) root = Path(directory)
args = self.args(root) args = self.args(root)
with ( with (
patch.object(run_mvp_meeting, "transcribe_audio", side_effect=fake_transcribe), patch.object(mvp_api, "transcribe_audio", side_effect=fake_transcribe),
patch.object( patch.object(
run_mvp_meeting, mvp_api,
"generate_direct_protocol", "generate_direct_protocol",
side_effect=ValueError("generation stopped"), side_effect=ValueError("generation stopped"),
), ),
@@ -209,9 +321,9 @@ class MvpOrchestratorTests(unittest.TestCase):
root = Path(directory) root = Path(directory)
args = self.args(root) args = self.args(root)
with ( with (
patch.object(run_mvp_meeting, "transcribe_audio", side_effect=fake_transcribe), patch.object(mvp_api, "transcribe_audio", side_effect=fake_transcribe),
patch.object( patch.object(
run_mvp_meeting, mvp_api,
"generate_direct_protocol", "generate_direct_protocol",
return_value=protocol_result(), return_value=protocol_result(),
), ),
@@ -237,7 +349,7 @@ class MvpOrchestratorTests(unittest.TestCase):
"--output-root", str(args.output_root), "--output-root", str(args.output_root),
] ]
with patch.object( with patch.object(
run_mvp_meeting, mvp_api,
"transcribe_audio", "transcribe_audio",
side_effect=TranscriptionError("failed"), side_effect=TranscriptionError("failed"),
): ):