Add diarization and reusable MVP meeting pipeline
This commit is contained in:
@@ -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
|
||||
|
||||
import ast
|
||||
import copy
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
@@ -29,6 +31,21 @@ class MeetingContext:
|
||||
def meeting_id(self) -> str:
|
||||
return str(self.data["meeting"]["meeting_id"])
|
||||
|
||||
@property
|
||||
def speaker_mappings(self) -> dict[str, str]:
|
||||
mappings = self.data.get("speaker_mappings")
|
||||
return dict(mappings) if isinstance(mappings, dict) else {}
|
||||
|
||||
def participant_for_speaker(self, speaker_label: str) -> dict[str, Any] | None:
|
||||
"""Resolve only an explicit authoritative mapping; never infer identity."""
|
||||
participant_id = self.speaker_mappings.get(speaker_label)
|
||||
if participant_id is None:
|
||||
return None
|
||||
for participant in self.data.get("participants", []):
|
||||
if participant.get("participant_id") == participant_id:
|
||||
return participant
|
||||
return None
|
||||
|
||||
def provenance(self) -> dict[str, str]:
|
||||
return {
|
||||
"meeting_id": self.meeting_id,
|
||||
@@ -46,6 +63,40 @@ def load_meeting_context(path: Path) -> MeetingContext:
|
||||
return MeetingContext(data=loaded, source_file=path)
|
||||
|
||||
|
||||
def create_meeting_context(
|
||||
data: dict[str, Any], *, source_file: Path = Path("<generated>")
|
||||
) -> MeetingContext:
|
||||
"""Validate structured data and return an immutable context boundary."""
|
||||
validated = copy.deepcopy(data)
|
||||
validate_meeting_context(validated)
|
||||
return MeetingContext(data=validated, source_file=source_file)
|
||||
|
||||
|
||||
def serialize_meeting_context_yaml(context: MeetingContext) -> str:
|
||||
"""Serialize validated Meeting Context data deterministically as YAML."""
|
||||
validate_meeting_context(context.data)
|
||||
try:
|
||||
import yaml # type: ignore[import-not-found]
|
||||
except ModuleNotFoundError as exc:
|
||||
raise MeetingContextValidationError(
|
||||
"PyYAML is required to write Meeting Context YAML."
|
||||
) from exc
|
||||
return yaml.safe_dump(
|
||||
context.data,
|
||||
allow_unicode=True,
|
||||
sort_keys=False,
|
||||
default_flow_style=False,
|
||||
)
|
||||
|
||||
|
||||
def write_meeting_context(context: MeetingContext, path: Path) -> Path:
|
||||
"""Persist a validated context without changing its schema or semantics."""
|
||||
path = Path(path)
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(serialize_meeting_context_yaml(context), encoding="utf-8")
|
||||
return path
|
||||
|
||||
|
||||
def validate_meeting_context(data: dict[str, Any]) -> None:
|
||||
schema_version = str(data.get("schema_version", "")).strip()
|
||||
if schema_version not in SUPPORTED_SCHEMA_VERSIONS:
|
||||
@@ -74,6 +125,22 @@ def validate_meeting_context(data: dict[str, Any]) -> None:
|
||||
+ ", ".join(collisions)
|
||||
)
|
||||
|
||||
speaker_mappings = _optional_mapping(
|
||||
data.get("speaker_mappings"), "speaker_mappings"
|
||||
)
|
||||
for speaker_label, participant_id in speaker_mappings.items():
|
||||
if not isinstance(speaker_label, str) or re.fullmatch(
|
||||
r"SPEAKER_\d+", speaker_label
|
||||
) is None:
|
||||
raise MeetingContextValidationError(
|
||||
f"Invalid diarization speaker label: {speaker_label!r}."
|
||||
)
|
||||
if not isinstance(participant_id, str) or participant_id not in participant_ids:
|
||||
raise MeetingContextValidationError(
|
||||
f"speaker_mappings.{speaker_label} references unknown participant: "
|
||||
f"{participant_id!r}."
|
||||
)
|
||||
|
||||
for index, participant in enumerate(participants):
|
||||
item_path = f"participants[{index}]"
|
||||
_validate_attendance(participant, item_path)
|
||||
@@ -131,6 +198,28 @@ def render_meeting_context_for_prompt(context: MeetingContext) -> str:
|
||||
for participant in participants:
|
||||
lines.append(_render_person_line(participant, "participant_id", departments_by_id))
|
||||
|
||||
speaker_mappings = context.speaker_mappings
|
||||
if speaker_mappings:
|
||||
participants_by_id = {
|
||||
participant["participant_id"]: participant
|
||||
for participant in participants
|
||||
if isinstance(participant, dict) and participant.get("participant_id")
|
||||
}
|
||||
lines.extend(
|
||||
[
|
||||
"",
|
||||
"Confirmed diarization speaker mappings (authoritative):",
|
||||
"- Use only these explicit mappings. Never infer identities for other speaker labels.",
|
||||
"- Unmapped SPEAKER_XX labels must remain anonymous.",
|
||||
]
|
||||
)
|
||||
for speaker_label, participant_id in sorted(speaker_mappings.items()):
|
||||
participant = participants_by_id[participant_id]
|
||||
lines.append(
|
||||
f"- {speaker_label}: {_text(participant.get('display_name'))} "
|
||||
f"(participant_id: {participant_id})"
|
||||
)
|
||||
|
||||
mentioned_people = _optional_list(data.get("mentioned_people"), "mentioned_people")
|
||||
if mentioned_people:
|
||||
lines.extend(["", "Mentioned but absent people:"])
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
DIRECT_PROTOCOL_INSTRUCTION = """Erstelle aus dem vollständigen Transkript und dem Meeting-Kontext ein prägnantes, professionelles internes Besprechungsprotokoll in deutscher Sprache.
|
||||
DIRECT_PROTOCOL_INSTRUCTION = """Erstelle aus dem vollständigen Transkript und dem Meeting-Kontext ein vollständiges, strukturiertes und professionelles internes Besprechungsprotokoll in deutscher Sprache.
|
||||
|
||||
Das Protokoll muss themenorientiert sein, nicht chronologisch und nicht nach technischen Kategorien gegliedert. Beginne mit # Meeting Protocol. Verwende für jedes kohärente Thema eine Überschrift ## <Thema> und darunter eine knappe Synthese der Diskussion. Nenne Entscheidungen oder abgestimmte Positionen nur, wenn sie tatsächlich belegt sind. Führe Maßnahmen nur auf, wenn eine konkrete zukünftige Handlung gestützt ist; nenne verantwortliche Personen und Fristen ausschließlich bei expliziter Zuweisung, Annahme oder Bestätigung im Transkript. Vorschläge, Einwände, Möglichkeiten und vorläufige Ideen sind keine Entscheidungen oder Verpflichtungen. Bewahre relevante Einschränkungen und ungelöste Meinungsverschiedenheiten. Nenne offene Punkte nur, wenn sie wirklich offen bleiben. Nicht jedes Thema benötigt Entscheidungen, Maßnahmen oder offene Punkte.
|
||||
Das Protokoll muss themenorientiert sein, nicht chronologisch und nicht nach technischen Kategorien gegliedert. Beginne mit # Meeting Protocol. Verwende für jedes kohärente Thema eine Überschrift ## <Thema> und darunter eine strukturierte Synthese der Diskussion. Bewahre relevante Diskussionsverläufe, unterschiedliche Positionen, offene Punkte und Entscheidungsgrundlagen. Dokumentiere die wesentlichen Inhalte nachvollziehbar und fasse Themenblöcke so zusammen, dass auch Personen, die nicht am Meeting teilgenommen haben, den Kontext und die Entwicklung der Diskussion verstehen können. Nenne Entscheidungen oder abgestimmte Positionen nur, wenn sie tatsächlich belegt sind. Führe Maßnahmen nur auf, wenn eine konkrete zukünftige Handlung gestützt ist; nenne verantwortliche Personen und Fristen ausschließlich bei expliziter Zuweisung, Annahme oder Bestätigung im Transkript. Vorschläge, Einwände, Möglichkeiten und vorläufige Ideen sind keine Entscheidungen oder Verpflichtungen. Bewahre relevante Einschränkungen und ungelöste Meinungsverschiedenheiten. Nenne offene Punkte nur, wenn sie wirklich offen bleiben. Nicht jedes Thema benötigt Entscheidungen, Maßnahmen oder offene Punkte.
|
||||
|
||||
Synthetisiere zusammengehörige Aussagen, entferne Füllwörter, Wiederholungen und Gesprächsrauschen und erfinde keine Fakten, Verantwortlichen oder Fristen. Gib kein JSON, keine internen Labels und keine Analyse oder Denkprotokolle aus. Das Ergebnis soll als Markdown-Protokoll nach geringfügiger menschlicher Redaktion intern versendbar sein. Eine kompakte themenübergreifende Maßnahmenliste am Ende ist optional, wenn sie nützlich und vollständig belegt ist."""
|
||||
Erzeuge keine reine Wiedergabe des Transkripts und verlängere das Protokoll nicht unnötig durch Wiederholungen. Synthetisiere zusammengehörige Aussagen, entferne Füllwörter und Gesprächsrauschen und erfinde keine Fakten, Entscheidungen, Zustimmungen, Verantwortlichen oder Fristen. Gib kein JSON, keine internen Labels und keine Analyse oder Denkprotokolle aus. Das Ergebnis soll als Markdown-Protokoll nach geringfügiger menschlicher Redaktion intern versendbar sein. Eine kompakte themenübergreifende Maßnahmenliste am Ende ist optional, wenn sie nützlich und vollständig belegt ist."""
|
||||
|
||||
|
||||
def build_direct_protocol_prompt(transcript: str, meeting_context: str | None = None) -> str:
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
"""Audio transcription support for the direct-protocol MVP."""
|
||||
|
||||
from .whisper import TranscriptionError, TranscriptionResult, transcribe_audio
|
||||
|
||||
__all__ = ["TranscriptionError", "TranscriptionResult", "transcribe_audio"]
|
||||
Reference in New Issue
Block a user