Use physical CPU cores for Whisper runtime defaults
This commit is contained in:
@@ -0,0 +1,248 @@
|
||||
import json
|
||||
import tempfile
|
||||
import unittest
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
from scripts import run_mvp_meeting
|
||||
from src.meeting_lab.protocol.generate_direct_protocol import DirectProtocolResult
|
||||
from src.meeting_lab.transcription.whisper import TranscriptionError, TranscriptionResult
|
||||
|
||||
|
||||
VALID_CONTEXT = """schema_version: "1"
|
||||
meeting:
|
||||
meeting_id: "mvp-test"
|
||||
title: "MVP Test"
|
||||
language: "de"
|
||||
participants: []
|
||||
mentioned_people: []
|
||||
organization:
|
||||
departments: []
|
||||
known_entities: {}
|
||||
"""
|
||||
|
||||
|
||||
def protocol_result(model: str = "chosen:model") -> DirectProtocolResult:
|
||||
text = "# Protokoll\n\nUnverändert. \n"
|
||||
return DirectProtocolResult(
|
||||
protocol_text=text,
|
||||
exact_prompt="exact prompt\n",
|
||||
model_metadata={"model": model},
|
||||
runtime_metadata={"model": model, "request_count": 1, "client_wall_time_seconds": 0.5},
|
||||
raw_response={"response": text, "done": True},
|
||||
)
|
||||
|
||||
|
||||
def fake_transcribe(
|
||||
audio_path: Path,
|
||||
model_path: Path,
|
||||
output_dir: Path,
|
||||
language: str,
|
||||
*,
|
||||
executable: str,
|
||||
threads: str | int,
|
||||
) -> TranscriptionResult:
|
||||
output_dir.mkdir(parents=True, exist_ok=True)
|
||||
raw = output_dir / "whisper_raw.json"
|
||||
transcript = output_dir / "transcript.json"
|
||||
text = output_dir / "transcript.txt"
|
||||
metadata = output_dir / "runtime_metadata.json"
|
||||
raw.write_text('{"transcription": []}\n', encoding="utf-8")
|
||||
transcript.write_text(
|
||||
json.dumps({"text": "Besprechungstext.", "segments": []}) + "\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
text.write_text("Besprechungstext.\n", encoding="utf-8")
|
||||
metadata.write_text('{"runtime_seconds": 1.25}\n', encoding="utf-8")
|
||||
return TranscriptionResult(output_dir, raw, transcript, text, metadata, 1.25)
|
||||
|
||||
|
||||
class MvpOrchestratorTests(unittest.TestCase):
|
||||
def create_inputs(self, root: Path) -> tuple[Path, Path, Path]:
|
||||
audio = root / "team meeting.wav"
|
||||
whisper_model = root / "ggml-model.bin"
|
||||
context = root / "source-context.yaml"
|
||||
audio.write_bytes(b"audio")
|
||||
whisper_model.write_bytes(b"model")
|
||||
context.write_text(VALID_CONTEXT, encoding="utf-8")
|
||||
return audio, whisper_model, context
|
||||
|
||||
def args(self, root: Path, extra: list[str] | None = None):
|
||||
audio, whisper_model, context = self.create_inputs(root)
|
||||
values = [
|
||||
str(audio),
|
||||
"--whisper-model", str(whisper_model),
|
||||
"--context", str(context),
|
||||
"--output-root", str(root / "runs"),
|
||||
"--language", "de",
|
||||
"--model", "chosen:model",
|
||||
"--ollama-endpoint", "http://ollama.test:11434",
|
||||
]
|
||||
if extra:
|
||||
values.extend(extra)
|
||||
return run_mvp_meeting.parse_args(values)
|
||||
|
||||
def test_successful_full_orchestration_and_artifact_layout(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
root = Path(directory)
|
||||
args = self.args(root)
|
||||
with (
|
||||
patch.object(run_mvp_meeting, "transcribe_audio", side_effect=fake_transcribe) as whisper,
|
||||
patch.object(
|
||||
run_mvp_meeting,
|
||||
"generate_direct_protocol",
|
||||
return_value=protocol_result(),
|
||||
) as protocol,
|
||||
):
|
||||
code, run_dir, protocol_path = run_mvp_meeting.run(args)
|
||||
|
||||
self.assertEqual(code, 0)
|
||||
self.assertIsNotNone(run_dir)
|
||||
self.assertEqual(protocol_path, run_dir / "protocol.md")
|
||||
expected = {
|
||||
"run_metadata.json",
|
||||
"audio/input_manifest.json",
|
||||
"transcript/whisper_raw.json",
|
||||
"transcript/transcript.json",
|
||||
"transcript/transcript.txt",
|
||||
"transcript/runtime_metadata.json",
|
||||
"context/meeting_context.yaml",
|
||||
"protocol/exact_prompt.txt",
|
||||
"protocol/raw_response.json",
|
||||
"protocol/runtime_metadata.json",
|
||||
"protocol.md",
|
||||
}
|
||||
self.assertTrue(all((run_dir / item).is_file() for item in expected))
|
||||
metadata = json.loads((run_dir / "run_metadata.json").read_text())
|
||||
self.assertEqual(metadata["status"], "completed")
|
||||
self.assertEqual(metadata["model"], "chosen:model")
|
||||
self.assertIsNone(metadata["failure"])
|
||||
self.assertEqual(whisper.call_count, 1)
|
||||
self.assertEqual(protocol.call_count, 1)
|
||||
|
||||
def test_context_model_endpoint_and_whisper_options_are_forwarded(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
root = Path(directory)
|
||||
args = self.args(
|
||||
root,
|
||||
["--whisper-executable", "/tools/whisper-cli", "--threads", "4"],
|
||||
)
|
||||
with (
|
||||
patch.object(run_mvp_meeting, "transcribe_audio", side_effect=fake_transcribe) as whisper,
|
||||
patch.object(
|
||||
run_mvp_meeting,
|
||||
"generate_direct_protocol",
|
||||
return_value=protocol_result(),
|
||||
) as protocol,
|
||||
):
|
||||
code, run_dir, _ = run_mvp_meeting.run(args)
|
||||
|
||||
self.assertEqual(code, 0)
|
||||
self.assertEqual(whisper.call_args.args[3], "de")
|
||||
self.assertEqual(whisper.call_args.kwargs["executable"], "/tools/whisper-cli")
|
||||
self.assertEqual(whisper.call_args.kwargs["threads"], "4")
|
||||
self.assertEqual(protocol.call_args.args[1], run_dir / "context/meeting_context.yaml")
|
||||
self.assertEqual(protocol.call_args.kwargs["model"], "chosen:model")
|
||||
self.assertEqual(
|
||||
protocol.call_args.kwargs["endpoint"], "http://ollama.test:11434"
|
||||
)
|
||||
|
||||
def test_whisper_failure_is_recorded_and_protocol_is_not_called(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
root = Path(directory)
|
||||
args = self.args(root)
|
||||
with (
|
||||
patch.object(
|
||||
run_mvp_meeting,
|
||||
"transcribe_audio",
|
||||
side_effect=TranscriptionError("whisper stopped"),
|
||||
),
|
||||
patch.object(run_mvp_meeting, "generate_direct_protocol") as protocol,
|
||||
):
|
||||
code, run_dir, protocol_path = run_mvp_meeting.run(args)
|
||||
|
||||
metadata = json.loads((run_dir / "run_metadata.json").read_text())
|
||||
self.assertEqual(code, 2)
|
||||
self.assertIsNone(protocol_path)
|
||||
self.assertEqual(metadata["status"], "failed")
|
||||
self.assertEqual(metadata["failure"]["stage"], "whisper")
|
||||
self.assertIn("whisper stopped", metadata["failure"]["message"])
|
||||
protocol.assert_not_called()
|
||||
self.assertTrue((run_dir / "audio/input_manifest.json").is_file())
|
||||
|
||||
def test_protocol_failure_preserves_transcript_and_failure_metadata(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
root = Path(directory)
|
||||
args = self.args(root)
|
||||
with (
|
||||
patch.object(run_mvp_meeting, "transcribe_audio", side_effect=fake_transcribe),
|
||||
patch.object(
|
||||
run_mvp_meeting,
|
||||
"generate_direct_protocol",
|
||||
side_effect=ValueError("generation stopped"),
|
||||
),
|
||||
):
|
||||
code, run_dir, protocol_path = run_mvp_meeting.run(args)
|
||||
|
||||
metadata = json.loads((run_dir / "run_metadata.json").read_text())
|
||||
self.assertEqual(code, 2)
|
||||
self.assertIsNone(protocol_path)
|
||||
self.assertEqual(metadata["failure"]["stage"], "protocol")
|
||||
self.assertTrue((run_dir / "transcript/transcript.json").is_file())
|
||||
self.assertFalse((run_dir / "protocol.md").exists())
|
||||
|
||||
def test_unique_run_directories_do_not_overwrite(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
root = Path(directory)
|
||||
fixed = datetime(2026, 8, 21, 9, 15, 30)
|
||||
first = run_mvp_meeting.create_unique_run_dir(root, "team meeting", lambda: fixed)
|
||||
marker = first / "keep.txt"
|
||||
marker.write_text("keep", encoding="utf-8")
|
||||
second = run_mvp_meeting.create_unique_run_dir(root, "team meeting", lambda: fixed)
|
||||
self.assertEqual(first.name, "team_meeting_20260821_091530")
|
||||
self.assertEqual(second.name, "team_meeting_20260821_091530_01")
|
||||
self.assertEqual(marker.read_text(), "keep")
|
||||
|
||||
def test_semantic_pipeline_functions_are_never_invoked(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
root = Path(directory)
|
||||
args = self.args(root)
|
||||
with (
|
||||
patch.object(run_mvp_meeting, "transcribe_audio", side_effect=fake_transcribe),
|
||||
patch.object(
|
||||
run_mvp_meeting,
|
||||
"generate_direct_protocol",
|
||||
return_value=protocol_result(),
|
||||
),
|
||||
patch("src.meeting_lab.chunking.chunk_transcript.build_chunks") as chunking,
|
||||
patch("src.meeting_lab.extraction.extract_chunks.extract_input") as extraction,
|
||||
patch("src.meeting_lab.consolidation.consolidate_facts.call_ollama") as consolidation,
|
||||
):
|
||||
code, _, _ = run_mvp_meeting.run(args)
|
||||
|
||||
self.assertEqual(code, 0)
|
||||
chunking.assert_not_called()
|
||||
extraction.assert_not_called()
|
||||
consolidation.assert_not_called()
|
||||
|
||||
def test_main_returns_nonzero_for_whisper_failure(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
root = Path(directory)
|
||||
args = self.args(root)
|
||||
argv = [
|
||||
str(args.audio_file),
|
||||
"--whisper-model", str(args.whisper_model),
|
||||
"--context", str(args.context),
|
||||
"--output-root", str(args.output_root),
|
||||
]
|
||||
with patch.object(
|
||||
run_mvp_meeting,
|
||||
"transcribe_audio",
|
||||
side_effect=TranscriptionError("failed"),
|
||||
):
|
||||
self.assertEqual(run_mvp_meeting.main(argv), 2)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,221 @@
|
||||
import json
|
||||
import subprocess
|
||||
import tempfile
|
||||
import unittest
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
from scripts import transcribe_meeting
|
||||
from src.meeting_lab.transcription.whisper import (
|
||||
TranscriptionError,
|
||||
compact_transcript,
|
||||
transcribe_audio,
|
||||
)
|
||||
|
||||
|
||||
RAW_RESULT = {
|
||||
"params": {"language": "de"},
|
||||
"transcription": [
|
||||
{
|
||||
"timestamps": {"from": "00:00:00,000", "to": "00:00:01,200"},
|
||||
"offsets": {"from": 0, "to": 1200},
|
||||
"text": " Hallo ",
|
||||
},
|
||||
{
|
||||
"timestamps": {"from": "00:00:01,200", "to": "00:00:02,500"},
|
||||
"offsets": {"from": 1200, "to": 2500},
|
||||
"text": "Welt",
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
class FakeRunner:
|
||||
def __init__(self, *, returncode: int = 0, write_raw: bool = True) -> None:
|
||||
self.returncode = returncode
|
||||
self.write_raw = write_raw
|
||||
self.commands: list[list[str]] = []
|
||||
|
||||
def __call__(self, command, **kwargs):
|
||||
command = list(command)
|
||||
self.commands.append(command)
|
||||
if self.write_raw:
|
||||
prefix = Path(command[command.index("-of") + 1])
|
||||
prefix.with_suffix(".json").write_text(
|
||||
json.dumps(RAW_RESULT, ensure_ascii=False), encoding="utf-8"
|
||||
)
|
||||
return subprocess.CompletedProcess(
|
||||
command, self.returncode, stdout="", stderr="backend failure"
|
||||
)
|
||||
|
||||
|
||||
class TranscriptionTests(unittest.TestCase):
|
||||
def create_inputs(self, root: Path) -> tuple[Path, Path]:
|
||||
audio = root / "meeting.wav"
|
||||
model = root / "ggml-model.bin"
|
||||
audio.write_bytes(b"audio")
|
||||
model.write_bytes(b"model")
|
||||
return audio, model
|
||||
|
||||
def test_missing_audio_file_is_rejected_before_execution(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
root = Path(directory)
|
||||
model = root / "model.bin"
|
||||
model.write_bytes(b"model")
|
||||
runner = FakeRunner()
|
||||
with self.assertRaisesRegex(TranscriptionError, "Audio file does not exist"):
|
||||
transcribe_audio(root / "missing.wav", model, root / "out", runner=runner)
|
||||
self.assertEqual(runner.commands, [])
|
||||
|
||||
def test_invalid_parameters_are_rejected(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
root = Path(directory)
|
||||
audio, model = self.create_inputs(root)
|
||||
with self.assertRaisesRegex(TranscriptionError, "model does not exist"):
|
||||
transcribe_audio(audio, root / "missing.bin", root / "out")
|
||||
with self.assertRaisesRegex(TranscriptionError, "Language must"):
|
||||
transcribe_audio(audio, model, root / "out", " ")
|
||||
with self.assertRaisesRegex(TranscriptionError, "executable must"):
|
||||
transcribe_audio(audio, model, root / "out", executable="")
|
||||
with self.assertRaisesRegex(TranscriptionError, "Threads must"):
|
||||
transcribe_audio(audio, model, root / "out", threads="zero")
|
||||
|
||||
def test_auto_threads_use_detected_physical_core_count_and_flash_attention(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
root = Path(directory)
|
||||
audio, model = self.create_inputs(root)
|
||||
runner = FakeRunner()
|
||||
result = transcribe_audio(
|
||||
audio,
|
||||
model,
|
||||
root / "output",
|
||||
runner=runner,
|
||||
thread_detector=lambda: 6,
|
||||
)
|
||||
|
||||
command = runner.commands[0]
|
||||
self.assertEqual(command[command.index("-t") + 1], "6")
|
||||
self.assertIn("-fa", command)
|
||||
metadata = json.loads(result.runtime_metadata.read_text())
|
||||
self.assertEqual(metadata["threads"], 6)
|
||||
self.assertEqual(metadata["threads_option"], "auto")
|
||||
self.assertTrue(metadata["flash_attention"])
|
||||
self.assertEqual(metadata["whisper_executable"], "whisper-cli")
|
||||
|
||||
def test_explicit_thread_count_is_forwarded_unchanged(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
root = Path(directory)
|
||||
audio, model = self.create_inputs(root)
|
||||
runner = FakeRunner()
|
||||
result = transcribe_audio(
|
||||
audio, model, root / "output", threads=12, runner=runner
|
||||
)
|
||||
|
||||
command = runner.commands[0]
|
||||
self.assertEqual(command[command.index("-t") + 1], "12")
|
||||
metadata = json.loads(result.runtime_metadata.read_text())
|
||||
self.assertEqual(metadata["threads"], 12)
|
||||
self.assertEqual(metadata["threads_option"], "12")
|
||||
|
||||
def test_cli_forwards_explicit_thread_count_to_wrapper(self) -> None:
|
||||
fake_result = Mock(
|
||||
transcript_json=Path("output/transcript.json"), runtime_seconds=1.0
|
||||
)
|
||||
with patch.object(
|
||||
transcribe_meeting, "transcribe_audio", return_value=fake_result
|
||||
) as transcribe:
|
||||
code = transcribe_meeting.main(
|
||||
[
|
||||
"meeting.wav",
|
||||
"--model", "model.bin",
|
||||
"--output-dir", "output",
|
||||
"--threads", "6",
|
||||
]
|
||||
)
|
||||
|
||||
self.assertEqual(code, 0)
|
||||
self.assertEqual(transcribe.call_args.kwargs["threads"], "6")
|
||||
|
||||
def test_compact_transcript_has_established_schema(self) -> None:
|
||||
self.assertEqual(
|
||||
compact_transcript(RAW_RESULT),
|
||||
{
|
||||
"text": "Hallo Welt",
|
||||
"segments": [
|
||||
{"id": 0, "start": 0.0, "end": 1.2, "text": "Hallo"},
|
||||
{"id": 1, "start": 1.2, "end": 2.5, "text": "Welt"},
|
||||
],
|
||||
},
|
||||
)
|
||||
|
||||
def test_success_preserves_raw_output_and_creates_metadata(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
root = Path(directory)
|
||||
audio, model = self.create_inputs(root)
|
||||
runner = FakeRunner()
|
||||
times = iter([10.0, 12.25])
|
||||
result = transcribe_audio(
|
||||
audio,
|
||||
model,
|
||||
root / "output",
|
||||
"de",
|
||||
runner=runner,
|
||||
monotonic=lambda: next(times),
|
||||
now=lambda: datetime(2026, 8, 21, 8, 30, tzinfo=timezone.utc),
|
||||
)
|
||||
|
||||
self.assertEqual(json.loads(result.raw_output.read_text()), RAW_RESULT)
|
||||
self.assertEqual(json.loads(result.transcript_json.read_text())["text"], "Hallo Welt")
|
||||
self.assertEqual(
|
||||
result.transcript_text.read_text(),
|
||||
"[00:00:00.000 - 00:00:01.200] Hallo\n"
|
||||
"[00:00:01.200 - 00:00:02.500] Welt\n",
|
||||
)
|
||||
metadata = json.loads(result.runtime_metadata.read_text())
|
||||
self.assertEqual(metadata["backend"], "whisper.cpp")
|
||||
self.assertEqual(metadata["language"], "de")
|
||||
self.assertEqual(metadata["duration_seconds"], 2.5)
|
||||
self.assertEqual(metadata["runtime_seconds"], 2.25)
|
||||
self.assertEqual(metadata["timestamp"], "2026-08-21T08:30:00+00:00")
|
||||
self.assertEqual(metadata["input_file"], str(audio.resolve()))
|
||||
self.assertEqual(metadata["model"], str(model.resolve()))
|
||||
|
||||
def test_output_paths_are_deterministic(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
root = Path(directory)
|
||||
audio, model = self.create_inputs(root)
|
||||
output = root / "chosen-output"
|
||||
result = transcribe_audio(audio, model, output, runner=FakeRunner())
|
||||
self.assertEqual(result.raw_output, output / "whisper_raw.json")
|
||||
self.assertEqual(result.transcript_json, output / "transcript.json")
|
||||
self.assertEqual(result.transcript_text, output / "transcript.txt")
|
||||
self.assertEqual(result.runtime_metadata, output / "runtime_metadata.json")
|
||||
self.assertEqual(
|
||||
set(json.loads(result.runtime_metadata.read_text())["output_files"].values()),
|
||||
{"whisper_raw.json", "transcript.json", "transcript.txt", "runtime_metadata.json"},
|
||||
)
|
||||
|
||||
def test_backend_failure_raises_and_preserves_available_raw_output(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
root = Path(directory)
|
||||
audio, model = self.create_inputs(root)
|
||||
output = root / "output"
|
||||
with self.assertRaisesRegex(TranscriptionError, "exit code 7.*backend failure"):
|
||||
transcribe_audio(audio, model, output, runner=FakeRunner(returncode=7))
|
||||
self.assertEqual(json.loads((output / "whisper_raw.json").read_text()), RAW_RESULT)
|
||||
self.assertFalse((output / "transcript.json").exists())
|
||||
self.assertFalse((output / "runtime_metadata.json").exists())
|
||||
|
||||
def test_missing_backend_output_is_reported(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
root = Path(directory)
|
||||
audio, model = self.create_inputs(root)
|
||||
with self.assertRaisesRegex(TranscriptionError, "without producing JSON"):
|
||||
transcribe_audio(
|
||||
audio, model, root / "output", runner=FakeRunner(write_raw=False)
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user