Add diarization and reusable MVP meeting pipeline
This commit is contained in:
+85
-165
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user