Add diarization and reusable MVP meeting pipeline
This commit is contained in:
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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",
|
||||||
|
]
|
||||||
@@ -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
|
||||||
@@ -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())
|
||||||
@@ -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:"])
|
||||||
|
|||||||
@@ -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",
|
||||||
|
]
|
||||||
@@ -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)
|
||||||
@@ -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"]
|
||||||
@@ -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()
|
||||||
@@ -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"
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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
@@ -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"),
|
||||||
):
|
):
|
||||||
|
|||||||
Reference in New Issue
Block a user