diff --git a/scripts/run_direct_protocol.py b/scripts/run_direct_protocol.py new file mode 100644 index 0000000..9c21878 --- /dev/null +++ b/scripts/run_direct_protocol.py @@ -0,0 +1,141 @@ +#!/usr/bin/env python3 +"""Run the one-call direct protocol MVP from compact Whisper JSON.""" + +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 + +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.protocol.generate_direct_protocol import ( # noqa: E402 + DEFAULT_MODEL, + DirectProtocolResult, + generate_direct_protocol, +) + + +DEFAULT_OUTPUT_ROOT = Path("meeting_data/runs") + + +def parse_args(argv: list[str] | None = None) -> argparse.Namespace: + parser = argparse.ArgumentParser(description="Generate one direct protocol from compact Whisper JSON.") + parser.add_argument("transcript", type=Path) + parser.add_argument("--context", type=Path) + parser.add_argument("--output-root", type=Path, default=DEFAULT_OUTPUT_ROOT) + parser.add_argument("--model", default=DEFAULT_MODEL) + parser.add_argument("--ollama-endpoint", default=DEFAULT_ENDPOINT) + return parser.parse_args(argv) + + +def create_unique_run_dir( + output_root: Path, + transcript_stem: str, + now: Callable[[], datetime] = datetime.now, +) -> Path: + safe_stem = re.sub(r"[^A-Za-z0-9_.-]+", "_", transcript_stem).strip("._-") or "meeting" + base = output_root / f"{safe_stem}_{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, data: Any) -> None: + path.write_text(json.dumps(data, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") + + +def persist_result(run_dir: Path, result: DirectProtocolResult) -> Path: + protocol_dir = run_dir / "protocol" + protocol_dir.mkdir() + (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, Path | None]: + run_dir = create_unique_run_dir(args.output_root, args.transcript.stem) + timestamp = datetime.now().astimezone().isoformat(timespec="seconds") + started = time.perf_counter() + protocol_path: Path | None = None + metadata: dict[str, Any] = { + "run_id": run_dir.name, + "timestamp": timestamp, + "transcript_path": str(args.transcript.resolve()), + "context_path": str(args.context.resolve()) if args.context else None, + "model": args.model, + "ollama_endpoint": args.ollama_endpoint, + "status": "running", + "total_runtime_seconds": None, + "final_protocol_path": None, + } + try: + transcript_dir = run_dir / "transcript" + transcript_dir.mkdir() + if not args.transcript.is_file(): + raise FileNotFoundError(f"Transcript file does not exist: {args.transcript}") + preserved_transcript = transcript_dir / "transcript.json" + shutil.copy2(args.transcript, preserved_transcript) + + preserved_context: Path | None = None + if args.context is not None: + if not args.context.is_file(): + raise FileNotFoundError(f"Meeting Context file does not exist: {args.context}") + context_dir = run_dir / "context" + context_dir.mkdir() + preserved_context = context_dir / "meeting_context.yaml" + shutil.copy2(args.context, preserved_context) + + write_json( + run_dir / "input_manifest.json", + { + "transcript_source": str(args.transcript.resolve()), + "transcript_copy": str(preserved_transcript.resolve()), + "context_source": str(args.context.resolve()) if args.context else None, + "context_copy": str(preserved_context.resolve()) if preserved_context else None, + }, + ) + result = generate_direct_protocol( + preserved_transcript, + preserved_context, + model=args.model, + endpoint=args.ollama_endpoint, + ) + protocol_path = persist_result(run_dir, result) + metadata["status"] = "completed" + metadata["final_protocol_path"] = str(protocol_path.resolve()) + except Exception as exc: + metadata["status"] = "failed" + metadata["failure"] = f"{type(exc).__name__}: {exc}" + print(f"Error: {metadata['failure']}", file=sys.stderr) + finally: + metadata["total_runtime_seconds"] = round(time.perf_counter() - 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: + code, _run_dir, protocol_path = run(parse_args(argv)) + if protocol_path is not None: + print(protocol_path) + return code + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/src/meeting_lab/llm/ollama.py b/src/meeting_lab/llm/ollama.py index e69de29..6c860d4 100644 --- a/src/meeting_lab/llm/ollama.py +++ b/src/meeting_lab/llm/ollama.py @@ -0,0 +1,95 @@ +"""Minimal Ollama client behavior used by the direct protocol MVP.""" + +from __future__ import annotations + +import time +from dataclasses import dataclass +from typing import Any + +import requests + + +DEFAULT_ENDPOINT = "http://127.0.0.1:11434" + + +class OllamaError(RuntimeError): + """Raised when Ollama cannot safely complete the requested operation.""" + + +@dataclass(frozen=True) +class OllamaGeneration: + raw_response: dict[str, Any] + text: str + client_wall_time_seconds: float + + +def ollama_base_url(endpoint: str) -> str: + endpoint = endpoint.rstrip("/") + return endpoint.rsplit("/api/", 1)[0] if "/api/" in endpoint else endpoint + + +def generate_url(endpoint: str) -> str: + return f"{ollama_base_url(endpoint)}/api/generate" + + +def require_model(endpoint: str, model: str, timeout: int = 10) -> dict[str, Any]: + base_url = ollama_base_url(endpoint) + try: + response = requests.get(f"{base_url}/api/tags", timeout=timeout) + response.raise_for_status() + data = response.json() + except (requests.RequestException, ValueError) as exc: + raise OllamaError(f"Ollama endpoint is not reachable at {base_url}: {exc}") from exc + + models = data.get("models") if isinstance(data, dict) else None + if not isinstance(models, list): + raise OllamaError("Ollama /api/tags returned a malformed response.") + installed = { + item.get("name") + for item in models + if isinstance(item, dict) and isinstance(item.get("name"), str) + } + if model not in installed: + raise OllamaError(f"Requested model is not installed in Ollama: {model}") + return {"base_url": base_url, "model": model, "installed": True} + + +def generate_once( + endpoint: str, + model: str, + prompt: str, + *, + timeout: int, + num_ctx: int, + num_predict: int, +) -> OllamaGeneration: + payload = { + "model": model, + "prompt": prompt, + "think": False, + "stream": False, + "options": { + "temperature": 0.0, + "num_ctx": num_ctx, + "num_predict": num_predict, + }, + } + started = time.perf_counter() + try: + response = requests.post(generate_url(endpoint), json=payload, timeout=timeout) + response.raise_for_status() + data = response.json() + except requests.RequestException as exc: + raise OllamaError(f"Ollama generation request failed: {exc}") from exc + except ValueError as exc: + raise OllamaError("Ollama generation response is not valid JSON.") from exc + wall_time = time.perf_counter() - started + + if not isinstance(data, dict): + raise OllamaError("Ollama generation response must be a JSON object.") + text = data.get("response") + if not isinstance(text, str): + raise OllamaError("Ollama generation response has no string 'response' field.") + if not text.strip(): + raise OllamaError("Ollama returned an empty protocol.") + return OllamaGeneration(data, text, wall_time) diff --git a/src/meeting_lab/protocol/direct_protocol_prompt.py b/src/meeting_lab/protocol/direct_protocol_prompt.py new file mode 100644 index 0000000..6dd5d39 --- /dev/null +++ b/src/meeting_lab/protocol/direct_protocol_prompt.py @@ -0,0 +1,19 @@ +"""Prompt construction for the direct transcript-to-protocol MVP.""" + +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. + +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 ## 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. + +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.""" + + +def build_direct_protocol_prompt(transcript: str, meeting_context: str | None = None) -> str: + context = meeting_context.strip() if meeting_context else "Kein Meeting-Kontext bereitgestellt." + return ( + f"{DIRECT_PROTOCOL_INSTRUCTION}\n\n" + f"MEETING-KONTEXT:\n{context}\n\n" + f"VOLLSTAENDIGES TRANSKRIPT:\n{transcript.strip()}\n" + ) diff --git a/src/meeting_lab/protocol/generate_direct_protocol.py b/src/meeting_lab/protocol/generate_direct_protocol.py new file mode 100644 index 0000000..434a59d --- /dev/null +++ b/src/meeting_lab/protocol/generate_direct_protocol.py @@ -0,0 +1,111 @@ +"""One-call direct protocol generation from a compact Whisper transcript.""" + +from __future__ import annotations + +import json +from dataclasses import dataclass +from pathlib import Path +from typing import Any, Callable + +from src.meeting_lab.llm.ollama import ( + DEFAULT_ENDPOINT, + OllamaGeneration, + generate_once, + require_model, +) +from src.meeting_lab.models.meeting_context import ( + MeetingContext, + load_meeting_context, + render_meeting_context_for_prompt, +) +from src.meeting_lab.protocol.direct_protocol_prompt import build_direct_protocol_prompt + + +DEFAULT_MODEL = "qwen3.6:35B-A3B" +DEFAULT_NUM_CTX = 32768 +DEFAULT_NUM_PREDICT = 8192 +DEFAULT_TIMEOUT = 1800 + + +class DirectProtocolError(ValueError): + """Raised for invalid direct-protocol inputs or model output.""" + + +@dataclass(frozen=True) +class DirectProtocolResult: + protocol_text: str + exact_prompt: str + model_metadata: dict[str, Any] + runtime_metadata: dict[str, Any] + raw_response: dict[str, Any] + + +def load_compact_transcript(path: Path) -> str: + if not path.is_file(): + raise DirectProtocolError(f"Transcript file does not exist: {path}") + try: + data = json.loads(path.read_text(encoding="utf-8-sig")) + except json.JSONDecodeError as exc: + raise DirectProtocolError(f"Transcript is not valid JSON: {path}: {exc}") from exc + if not isinstance(data, dict): + raise DirectProtocolError("Transcript JSON must contain a top-level object.") + if "text" not in data: + raise DirectProtocolError("Transcript JSON must contain top-level 'text'.") + text = data["text"] + if not isinstance(text, str) or not text.strip(): + raise DirectProtocolError("Transcript top-level 'text' must be a non-empty string.") + return text + + +def generate_direct_protocol( + transcript_path: Path, + context_path: Path | None = None, + *, + model: str = DEFAULT_MODEL, + endpoint: str = DEFAULT_ENDPOINT, + timeout: int = DEFAULT_TIMEOUT, + num_ctx: int = DEFAULT_NUM_CTX, + num_predict: int = DEFAULT_NUM_PREDICT, + model_check: Callable[[str, str, int], dict[str, Any]] = require_model, + generation_call: Callable[..., OllamaGeneration] = generate_once, +) -> DirectProtocolResult: + transcript = load_compact_transcript(transcript_path) + context: MeetingContext | None = ( + load_meeting_context(context_path) if context_path is not None else None + ) + rendered_context = render_meeting_context_for_prompt(context) if context else None + prompt = build_direct_protocol_prompt(transcript, rendered_context) + + model_metadata = model_check(endpoint, model, 10) + generation = generation_call( + endpoint, + model, + prompt, + timeout=timeout, + num_ctx=num_ctx, + num_predict=num_predict, + ) + data = generation.raw_response + runtime_metadata = { + "model": model, + "prompt_token_count": data.get("prompt_eval_count"), + "output_token_count": data.get("eval_count"), + "prompt_evaluation_duration_ns": data.get("prompt_eval_duration"), + "generation_duration_ns": data.get("eval_duration"), + "total_ollama_duration_ns": data.get("total_duration"), + "client_wall_time_seconds": generation.client_wall_time_seconds, + "completion_reason": data.get("done_reason"), + "done": data.get("done"), + "request_count": 1, + "temperature": 0.0, + "think": False, + "num_ctx": num_ctx, + "num_predict": num_predict, + } + return DirectProtocolResult( + protocol_text=generation.text, + exact_prompt=prompt, + model_metadata=model_metadata, + runtime_metadata=runtime_metadata, + raw_response=data, + ) diff --git a/tests/test_direct_protocol.py b/tests/test_direct_protocol.py new file mode 100644 index 0000000..97baee3 --- /dev/null +++ b/tests/test_direct_protocol.py @@ -0,0 +1,296 @@ +import json +import tempfile +import unittest +from datetime import datetime +from pathlib import Path +from unittest.mock import Mock, patch + +import requests + +from scripts import run_direct_protocol +from src.meeting_lab.llm import ollama +from src.meeting_lab.llm.ollama import OllamaError, OllamaGeneration +from src.meeting_lab.protocol.generate_direct_protocol import ( + DirectProtocolError, + generate_direct_protocol, + load_compact_transcript, +) + + +VALID_CONTEXT = """schema_version: "1" +meeting: + meeting_id: "test-meeting" + title: "Test Meeting" + language: "de" +participants: [] +mentioned_people: [] +organization: + departments: [] +known_entities: {} +""" + + +def write_transcript(path: Path, text: str = "Wir besprechen den Projektstatus.") -> None: + path.write_text(json.dumps({"text": text, "segments": []}), encoding="utf-8") + + +def generation(text: str = "# Meeting Protocol\n\n## Status\nUnveraendert.") -> OllamaGeneration: + return OllamaGeneration( + raw_response={ + "response": text, + "done": True, + "done_reason": "stop", + "prompt_eval_count": 123, + "eval_count": 17, + "prompt_eval_duration": 1000, + "eval_duration": 2000, + "total_duration": 4000, + }, + text=text, + client_wall_time_seconds=0.25, + ) + + +class TranscriptLoadingTests(unittest.TestCase): + def test_valid_transcript_is_accepted(self) -> None: + with tempfile.TemporaryDirectory() as directory: + path = Path(directory) / "transcript.json" + write_transcript(path) + self.assertEqual(load_compact_transcript(path), "Wir besprechen den Projektstatus.") + + def test_missing_top_level_text_is_rejected(self) -> None: + with tempfile.TemporaryDirectory() as directory: + path = Path(directory) / "transcript.json" + path.write_text('{"segments": []}', encoding="utf-8") + with self.assertRaisesRegex(DirectProtocolError, "top-level 'text'"): + load_compact_transcript(path) + + def test_empty_transcript_is_rejected(self) -> None: + with tempfile.TemporaryDirectory() as directory: + path = Path(directory) / "transcript.json" + write_transcript(path, " \n") + with self.assertRaisesRegex(DirectProtocolError, "non-empty string"): + load_compact_transcript(path) + + def test_malformed_json_is_rejected(self) -> None: + with tempfile.TemporaryDirectory() as directory: + path = Path(directory) / "transcript.json" + path.write_text("{", encoding="utf-8") + with self.assertRaisesRegex(DirectProtocolError, "not valid JSON"): + load_compact_transcript(path) + + +class GeneratorTests(unittest.TestCase): + def test_optional_context_absent_and_generation_called_once(self) -> None: + with tempfile.TemporaryDirectory() as directory: + transcript = Path(directory) / "transcript.json" + write_transcript(transcript) + check = Mock(return_value={"model": "qwen3.6:35B-A3B"}) + call = Mock(return_value=generation()) + + result = generate_direct_protocol( + transcript, + model_check=check, + generation_call=call, + ) + + self.assertIn("Kein Meeting-Kontext", result.exact_prompt) + self.assertEqual(check.call_count, 1) + self.assertEqual(call.call_count, 1) + self.assertEqual(call.call_args.args[1], "qwen3.6:35B-A3B") + self.assertEqual(result.runtime_metadata["request_count"], 1) + self.assertEqual(result.runtime_metadata["prompt_token_count"], 123) + self.assertFalse(result.runtime_metadata["think"]) + self.assertEqual(result.runtime_metadata["temperature"], 0.0) + + def test_valid_context_is_loaded_and_rendered(self) -> None: + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + transcript = root / "transcript.json" + context = root / "context.yaml" + write_transcript(transcript) + context.write_text(VALID_CONTEXT, encoding="utf-8") + result = generate_direct_protocol( + transcript, + context, + model_check=Mock(return_value={}), + generation_call=Mock(return_value=generation()), + ) + + self.assertIn("MEETING CONTEXT V1", result.exact_prompt) + self.assertIn("Test Meeting", result.exact_prompt) + + def test_invalid_context_is_rejected_before_network_calls(self) -> None: + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + transcript = root / "transcript.json" + context = root / "context.yaml" + write_transcript(transcript) + context.write_text("schema_version: wrong", encoding="utf-8") + check = Mock() + call = Mock() + with self.assertRaisesRegex(ValueError, "schema_version"): + generate_direct_protocol( + transcript, + context, + model_check=check, + generation_call=call, + ) + + check.assert_not_called() + call.assert_not_called() + + +class OllamaTests(unittest.TestCase): + def test_unavailable_endpoint_failure(self) -> None: + with patch.object(ollama.requests, "get", side_effect=requests.ConnectionError("down")): + with self.assertRaisesRegex(OllamaError, "not reachable"): + ollama.require_model("http://127.0.0.1:11434", "model") + + def test_missing_model_failure(self) -> None: + response = Mock() + response.raise_for_status.return_value = None + response.json.return_value = {"models": [{"name": "other:model"}]} + with patch.object(ollama.requests, "get", return_value=response): + with self.assertRaisesRegex(OllamaError, "not installed"): + ollama.require_model("http://127.0.0.1:11434", "model") + + def test_request_settings_and_raw_response(self) -> None: + raw = {"response": "# Meeting Protocol", "done": True} + response = Mock() + response.raise_for_status.return_value = None + response.json.return_value = raw + with patch.object(ollama.requests, "post", return_value=response) as post: + result = ollama.generate_once( + "http://localhost:11434", + "chosen:model", + "prompt", + timeout=30, + num_ctx=32768, + num_predict=8192, + ) + + self.assertEqual(post.call_count, 1) + payload = post.call_args.kwargs["json"] + self.assertEqual(payload["model"], "chosen:model") + self.assertEqual(payload["options"]["temperature"], 0.0) + self.assertFalse(payload["think"]) + self.assertFalse(payload["stream"]) + self.assertEqual(result.raw_response, raw) + + def test_malformed_response_failure_without_retry(self) -> None: + response = Mock() + response.raise_for_status.return_value = None + response.json.return_value = {"message": "missing response"} + with patch.object(ollama.requests, "post", return_value=response) as post: + with self.assertRaisesRegex(OllamaError, "no string 'response'"): + ollama.generate_once("url", "model", "prompt", timeout=1, num_ctx=1, num_predict=1) + self.assertEqual(post.call_count, 1) + + def test_empty_response_failure(self) -> None: + response = Mock() + response.raise_for_status.return_value = None + response.json.return_value = {"response": " "} + with patch.object(ollama.requests, "post", return_value=response): + with self.assertRaisesRegex(OllamaError, "empty protocol"): + ollama.generate_once("url", "model", "prompt", timeout=1, num_ctx=1, num_predict=1) + + +class DirectProtocolCliTests(unittest.TestCase): + def test_artifacts_are_preserved_and_protocol_is_untouched(self) -> None: + protocol_text = "# Meeting Protocol\n\nExact output. \n" + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + transcript = root / "source.json" + context = root / "source.yaml" + write_transcript(transcript) + context.write_text(VALID_CONTEXT, encoding="utf-8") + args = run_direct_protocol.parse_args( + [str(transcript), "--context", str(context), "--output-root", str(root / "runs")] + ) + with patch.object( + run_direct_protocol, + "generate_direct_protocol", + return_value=type("Result", (), { + "protocol_text": protocol_text, + "exact_prompt": "exact prompt\n", + "raw_response": {"response": protocol_text}, + "runtime_metadata": {"request_count": 1}, + })(), + ) as generator: + code, run_dir, protocol_path = run_direct_protocol.run(args) + + self.assertEqual(code, 0) + self.assertEqual(generator.call_count, 1) + self.assertEqual(protocol_path.read_text(encoding="utf-8"), protocol_text) + self.assertEqual( + (run_dir / "protocol/exact_prompt.txt").read_text(encoding="utf-8"), + "exact prompt\n", + ) + self.assertEqual( + json.loads((run_dir / "protocol/raw_response.json").read_text())["response"], + protocol_text, + ) + self.assertEqual( + json.loads((run_dir / "protocol/runtime_metadata.json").read_text())["request_count"], + 1, + ) + self.assertTrue((run_dir / "transcript/transcript.json").is_file()) + self.assertTrue((run_dir / "context/meeting_context.yaml").is_file()) + self.assertTrue((run_dir / "input_manifest.json").is_file()) + self.assertEqual(json.loads((run_dir / "run_metadata.json").read_text())["status"], "completed") + + def test_unique_run_directories_do_not_overwrite(self) -> None: + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + fixed = datetime(2026, 8, 20, 12, 0, 0) + first = run_direct_protocol.create_unique_run_dir(root, "meeting", lambda: fixed) + marker = first / "keep.txt" + marker.write_text("keep", encoding="utf-8") + second = run_direct_protocol.create_unique_run_dir(root, "meeting", lambda: fixed) + self.assertEqual(first.name, "meeting_20260820_120000") + self.assertEqual(second.name, "meeting_20260820_120000_01") + self.assertEqual(marker.read_text(encoding="utf-8"), "keep") + + def test_failure_after_directory_creation_preserves_metadata(self) -> None: + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + args = run_direct_protocol.parse_args( + [str(root / "missing.json"), "--output-root", str(root / "runs")] + ) + code, run_dir, protocol_path = run_direct_protocol.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.assertIn("does not exist", metadata["failure"]) + + def test_semantic_pipeline_functions_are_never_invoked(self) -> None: + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + transcript = root / "source.json" + write_transcript(transcript) + args = run_direct_protocol.parse_args( + [str(transcript), "--output-root", str(root / "runs")] + ) + fake_result = type("Result", (), { + "protocol_text": "# Meeting Protocol", + "exact_prompt": "prompt", + "raw_response": {"response": "# Meeting Protocol"}, + "runtime_metadata": {}, + })() + with ( + patch("src.meeting_lab.extraction.extract_chunks.extract_input") as extraction, + patch("src.meeting_lab.consolidation.consolidate_facts.call_ollama") as consolidation, + patch.object(run_direct_protocol, "generate_direct_protocol", return_value=fake_result), + ): + code, _run_dir, _protocol_path = run_direct_protocol.run(args) + + self.assertEqual(code, 0) + extraction.assert_not_called() + consolidation.assert_not_called() + + +if __name__ == "__main__": + unittest.main()