Add diarization and reusable MVP meeting pipeline

This commit is contained in:
2026-08-23 19:29:47 +02:00
parent f2d21c1faf
commit 8dab928763
18 changed files with 1697 additions and 180 deletions
+85 -165
View File
@@ -1,34 +1,29 @@
#!/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
import argparse
import json
import re
import shutil
import sys
import time
from datetime import datetime
from pathlib import Path
from typing import Any, Callable
from typing import Any
REPO_ROOT = Path(__file__).resolve().parents[1]
if str(REPO_ROOT) not in sys.path:
sys.path.insert(0, str(REPO_ROOT))
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.protocol.generate_direct_protocol import ( # noqa: E402
from src.meeting_lab.models.meeting_context import MeetingContext # noqa: E402
from src.meeting_lab.orchestration.mvp import ( # noqa: E402
DEFAULT_DIARIZATION_MODEL,
DEFAULT_MODEL,
DirectProtocolResult,
generate_direct_protocol,
load_compact_transcript,
DEFAULT_OUTPUT_ROOT,
MvpMeetingConfig,
create_unique_run_dir,
run_mvp_meeting,
)
from src.meeting_lab.transcription.whisper import transcribe_audio # noqa: E402
DEFAULT_OUTPUT_ROOT = Path("meeting_data/runs")
from src.meeting_lab.progress import ProgressSink # noqa: E402
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("--output-root", type=Path, default=DEFAULT_OUTPUT_ROOT)
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("--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)
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 config_from_args(args: argparse.Namespace) -> MvpMeetingConfig:
return MvpMeetingConfig(
audio_file=args.audio_file,
whisper_model=args.whisper_model,
whisper_executable=args.whisper_executable,
context_file=args.context,
output_root=args.output_root,
language=args.language,
threads=args.threads,
model=args.model,
ollama_endpoint=args.ollama_endpoint,
diarization=args.diarization,
diarization_runtime=args.diarization_runtime,
diarization_container_image=args.diarization_container_image,
diarization_container_args=tuple(args.diarization_container_arg),
)
def write_json(path: Path, value: Any) -> None:
path.write_text(json.dumps(value, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
def validate_inputs(args: argparse.Namespace) -> None:
if not args.audio_file.is_file():
raise FileNotFoundError(f"Audio file does not exist: {args.audio_file}")
if not args.whisper_model.is_file():
raise FileNotFoundError(f"Whisper model does not exist: {args.whisper_model}")
if args.context is not None:
if not args.context.is_file():
raise FileNotFoundError(f"Meeting Context file does not exist: {args.context}")
load_meeting_context(args.context)
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 run(
args: argparse.Namespace,
*,
context_override: MeetingContext | dict[str, Any] | None = None,
progress_sink: ProgressSink | None = None,
) -> tuple[int, Path | None, Path | None]:
"""Compatibility wrapper for existing Python callers of the CLI module."""
result = run_mvp_meeting(
config_from_args(args),
meeting_context=context_override,
progress_sink=progress_sink,
)
return result.exit_code, result.run_dir, result.protocol_path
def main(argv: list[str] | None = None) -> int:
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:
print(protocol_path)
return code