Add direct protocol MVP core

This commit is contained in:
2026-08-20 21:42:36 +02:00
parent 70007ea8f2
commit a9dab7c81a
5 changed files with 662 additions and 0 deletions
+141
View File
@@ -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())
+95
View File
@@ -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)
@@ -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 ## <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.
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"
)
@@ -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,
)
+296
View File
@@ -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()