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
@@ -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,
)