""" BreakPilot Meeting Minutes Generator Generiert KI-basierte Meeting-Protokolle aus Transkriptionen. Nutzt das LLM Gateway (Ollama/vLLM/Anthropic) fuer lokale Verarbeitung. Lizenz: MIT (kommerziell nutzbar) """ import os import json import logging import httpx from datetime import datetime from typing import Optional, List from dataclasses import dataclass, asdict from pydantic import BaseModel, Field logger = logging.getLogger(__name__) # ========================================== # CONFIGURATION # ========================================== LLM_GATEWAY_URL = os.getenv("LLM_GATEWAY_URL", "http://localhost:8002") LLM_MODEL = os.getenv("MEETING_MINUTES_MODEL", "breakpilot-teacher-8b") LLM_TIMEOUT = int(os.getenv("LLM_TIMEOUT", "120")) # ========================================== # PYDANTIC MODELS # ========================================== class ActionItem(BaseModel): """Ein Aktionspunkt aus dem Meeting.""" task: str = Field(..., description="Die zu erledigende Aufgabe") assignee: Optional[str] = Field(None, description="Verantwortliche Person (SPEAKER_XX oder Name)") deadline: Optional[str] = Field(None, description="Faelligkeit, falls erwaehnt") priority: str = Field(default="normal", description="Prioritaet: high, normal, low") class Decision(BaseModel): """Eine getroffene Entscheidung.""" topic: str = Field(..., description="Thema der Entscheidung") decision: str = Field(..., description="Die getroffene Entscheidung") rationale: Optional[str] = Field(None, description="Begruendung, falls erwaehnt") class TopicSummary(BaseModel): """Zusammenfassung eines besprochenen Themas.""" title: str = Field(..., description="Titel des Themas") summary: str = Field(..., description="Kurze Zusammenfassung") participants: List[str] = Field(default_factory=list, description="Beteiligte Sprecher") duration_estimate: Optional[str] = Field(None, description="Geschaetzte Dauer") class MeetingMinutes(BaseModel): """Vollstaendiges Meeting-Protokoll.""" id: str recording_id: str transcription_id: str # Metadaten title: str = Field(..., description="Titel des Meetings") date: str = Field(..., description="Datum des Meetings") duration_minutes: Optional[int] = Field(None, description="Dauer in Minuten") participant_count: int = Field(default=0, description="Anzahl Teilnehmer") language: str = Field(default="de", description="Sprache") # Inhalt summary: str = Field(..., description="Zusammenfassung in 3-5 Saetzen") topics: List[TopicSummary] = Field(default_factory=list, description="Besprochene Themen") decisions: List[Decision] = Field(default_factory=list, description="Getroffene Entscheidungen") action_items: List[ActionItem] = Field(default_factory=list, description="Aktionspunkte/TODOs") open_questions: List[str] = Field(default_factory=list, description="Offene Fragen") # KI-Metadaten model_used: str = Field(..., description="Verwendetes LLM") generated_at: datetime = Field(default_factory=datetime.utcnow) generation_time_seconds: Optional[float] = Field(None, description="Generierungszeit") # Status status: str = Field(default="completed", description="Status: pending, processing, completed, failed") error_message: Optional[str] = Field(None, description="Fehlermeldung bei Status=failed") class MinutesGenerationRequest(BaseModel): """Anfrage zur Protokoll-Generierung.""" title: Optional[str] = Field(None, description="Meeting-Titel (optional, wird generiert)") model: str = Field(default=LLM_MODEL, description="LLM Modell") include_action_items: bool = Field(default=True, description="Action Items extrahieren") include_decisions: bool = Field(default=True, description="Entscheidungen extrahieren") max_topics: int = Field(default=10, description="Maximale Anzahl Themen") # ========================================== # PROMPTS (German, Education Context) # ========================================== SYSTEM_PROMPT = """Du bist ein Assistent für die Erstellung von Meeting-Protokollen in deutschen Bildungseinrichtungen (Schulen, Universitäten). Deine Aufgabe ist es, aus einer Transkription ein strukturiertes Protokoll zu erstellen. WICHTIG: - Schreibe professionell und sachlich auf Deutsch - Verwende die formelle Anrede (Sie) - Halte dich an die Fakten der Transkription - Erfinde KEINE Informationen, die nicht in der Transkription stehen - Sprecher werden als SPEAKER_00, SPEAKER_01 etc. bezeichnet - behalte diese Bezeichnungen bei - Wenn du dir bei etwas unsicher bist, schreibe "Unklar:" davor Format für die Ausgabe (JSON): { "summary": "3-5 Sätze Zusammenfassung", "topics": [ {"title": "Thema", "summary": "Kurzbeschreibung", "participants": ["SPEAKER_00"]} ], "decisions": [ {"topic": "Thema", "decision": "Was wurde entschieden", "rationale": "Begründung oder null"} ], "action_items": [ {"task": "Aufgabe", "assignee": "SPEAKER_XX oder null", "deadline": "Datum oder null", "priority": "high/normal/low"} ], "open_questions": ["Frage 1", "Frage 2"] }""" EXTRACTION_PROMPT = """Analysiere folgende Meeting-Transkription und erstelle ein strukturiertes Protokoll. Meeting-Titel: {title} Datum: {date} Dauer: {duration} Minuten Teilnehmer: {participant_count} --- TRANSKRIPTION --- {transcript} --- ENDE TRANSKRIPTION --- Erstelle ein JSON-Protokoll mit: 1. summary: Zusammenfassung in 3-5 Sätzen 2. topics: Liste der besprochenen Themen (maximal {max_topics}) 3. decisions: Alle getroffenen Entscheidungen 4. action_items: Alle Aufgaben/TODOs mit Verantwortlichen (falls genannt) 5. open_questions: Offene Fragen, die nicht beantwortet wurden Antworte NUR mit dem JSON-Objekt, ohne zusätzlichen Text.""" # ========================================== # MEETING MINUTES GENERATOR # ========================================== class MeetingMinutesGenerator: """Generator fuer Meeting-Protokolle aus Transkriptionen.""" def __init__(self, llm_gateway_url: str = LLM_GATEWAY_URL): self.llm_gateway_url = llm_gateway_url self._client: Optional[httpx.AsyncClient] = None async def get_client(self) -> httpx.AsyncClient: """Lazy initialization des HTTP Clients.""" if self._client is None: self._client = httpx.AsyncClient(timeout=LLM_TIMEOUT) return self._client async def close(self): """Schliesst den HTTP Client.""" if self._client: await self._client.aclose() self._client = None async def _call_llm( self, messages: List[dict], model: str = LLM_MODEL, temperature: float = 0.3, max_tokens: int = 4096 ) -> str: """Ruft das LLM Gateway auf.""" client = await self.get_client() payload = { "model": model, "messages": messages, "temperature": temperature, "max_tokens": max_tokens, "stream": False } try: response = await client.post( f"{self.llm_gateway_url}/v1/chat/completions", json=payload, timeout=LLM_TIMEOUT ) response.raise_for_status() data = response.json() content = data.get("choices", [{}])[0].get("message", {}).get("content", "") return content except httpx.TimeoutException: logger.error("LLM Gateway timeout") raise RuntimeError("LLM Gateway antwortet nicht (Timeout)") except httpx.HTTPStatusError as e: logger.error(f"LLM Gateway error: {e.response.status_code}") raise RuntimeError(f"LLM Gateway Fehler: {e.response.status_code}") except Exception as e: logger.error(f"LLM call failed: {e}") raise RuntimeError(f"LLM Aufruf fehlgeschlagen: {str(e)}") def _parse_llm_response(self, response: str) -> dict: """Parst die LLM-Antwort als JSON.""" # Versuche JSON aus der Antwort zu extrahieren response = response.strip() # Entferne eventuelle Markdown Code-Bloecke if response.startswith("```json"): response = response[7:] if response.startswith("```"): response = response[3:] if response.endswith("```"): response = response[:-3] response = response.strip() try: return json.loads(response) except json.JSONDecodeError as e: logger.warning(f"JSON parse error: {e}. Response: {response[:200]}...") # Fallback: Leeres Protokoll return { "summary": "Protokoll konnte nicht automatisch erstellt werden.", "topics": [], "decisions": [], "action_items": [], "open_questions": [] } async def generate( self, transcript: str, recording_id: str, transcription_id: str, title: Optional[str] = None, date: Optional[str] = None, duration_minutes: Optional[int] = None, participant_count: int = 0, model: str = LLM_MODEL, max_topics: int = 10, include_action_items: bool = True, include_decisions: bool = True ) -> MeetingMinutes: """ Generiert Meeting Minutes aus einer Transkription. Args: transcript: Die vollstaendige Transkription recording_id: ID der Aufzeichnung transcription_id: ID der Transkription title: Meeting-Titel (wird generiert falls nicht angegeben) date: Datum des Meetings duration_minutes: Dauer in Minuten participant_count: Anzahl Teilnehmer model: LLM Modell max_topics: Maximale Anzahl Themen include_action_items: Action Items extrahieren include_decisions: Entscheidungen extrahieren Returns: MeetingMinutes: Das generierte Protokoll """ import uuid import time start_time = time.time() minutes_id = str(uuid.uuid4()) # Defaults if not title: title = f"Meeting vom {date or datetime.utcnow().strftime('%d.%m.%Y')}" if not date: date = datetime.utcnow().strftime("%d.%m.%Y") # Transkription kuerzen falls zu lang (max ~8000 Tokens ~ 32000 chars) max_chars = 32000 if len(transcript) > max_chars: logger.warning(f"Transcript too long ({len(transcript)} chars), truncating...") transcript = transcript[:max_chars] + "\n\n[... Transkription gekürzt ...]" # Prompt erstellen user_prompt = EXTRACTION_PROMPT.format( title=title, date=date, duration=duration_minutes or "unbekannt", participant_count=participant_count, transcript=transcript, max_topics=max_topics ) messages = [ {"role": "system", "content": SYSTEM_PROMPT}, {"role": "user", "content": user_prompt} ] try: # LLM aufrufen logger.info(f"Generating minutes for recording {recording_id} using {model}") response = await self._call_llm(messages, model=model) # Antwort parsen parsed = self._parse_llm_response(response) generation_time = time.time() - start_time # MeetingMinutes erstellen minutes = MeetingMinutes( id=minutes_id, recording_id=recording_id, transcription_id=transcription_id, title=title, date=date, duration_minutes=duration_minutes, participant_count=participant_count, language="de", summary=parsed.get("summary", "Zusammenfassung nicht verfügbar."), topics=[ TopicSummary(**t) for t in parsed.get("topics", []) ] if parsed.get("topics") else [], decisions=[ Decision(**d) for d in parsed.get("decisions", []) ] if include_decisions and parsed.get("decisions") else [], action_items=[ ActionItem(**a) for a in parsed.get("action_items", []) ] if include_action_items and parsed.get("action_items") else [], open_questions=parsed.get("open_questions", []), model_used=model, generated_at=datetime.utcnow(), generation_time_seconds=round(generation_time, 2), status="completed" ) logger.info(f"Minutes generated in {generation_time:.2f}s: {len(minutes.topics)} topics, {len(minutes.action_items)} action items") return minutes except Exception as e: logger.error(f"Minutes generation failed: {e}") return MeetingMinutes( id=minutes_id, recording_id=recording_id, transcription_id=transcription_id, title=title, date=date, duration_minutes=duration_minutes, participant_count=participant_count, language="de", summary="", model_used=model, status="failed", error_message=str(e) ) # ========================================== # EXPORT FUNCTIONS # ========================================== def minutes_to_markdown(minutes: MeetingMinutes) -> str: """Exportiert Meeting Minutes als Markdown.""" md = f"""# {minutes.title} **Datum:** {minutes.date} **Dauer:** {minutes.duration_minutes or 'unbekannt'} Minuten **Teilnehmer:** {minutes.participant_count} --- ## Zusammenfassung {minutes.summary} --- ## Besprochene Themen """ for i, topic in enumerate(minutes.topics, 1): md += f"### {i}. {topic.title}\n\n" md += f"{topic.summary}\n\n" if topic.participants: md += f"*Beteiligte: {', '.join(topic.participants)}*\n\n" if minutes.decisions: md += "---\n\n## Entscheidungen\n\n" for decision in minutes.decisions: md += f"- **{decision.topic}:** {decision.decision}" if decision.rationale: md += f" *(Begründung: {decision.rationale})*" md += "\n" md += "\n" if minutes.action_items: md += "---\n\n## Action Items\n\n" md += "| Aufgabe | Verantwortlich | Fällig | Priorität |\n" md += "|---------|----------------|--------|----------|\n" for item in minutes.action_items: md += f"| {item.task} | {item.assignee or '-'} | {item.deadline or '-'} | {item.priority} |\n" md += "\n" if minutes.open_questions: md += "---\n\n## Offene Fragen\n\n" for q in minutes.open_questions: md += f"- {q}\n" md += "\n" md += f"""--- *Generiert am {minutes.generated_at.strftime('%d.%m.%Y um %H:%M Uhr')} mit {minutes.model_used}* *Generierungszeit: {minutes.generation_time_seconds or 0:.1f} Sekunden* """ return md def minutes_to_html(minutes: MeetingMinutes) -> str: """Exportiert Meeting Minutes als HTML (fuer PDF-Konvertierung).""" html = f"""
{minutes.summary}
{topic.summary}
""" if topic.participants: html += f"Beteiligte: {', '.join(topic.participants)}
\n" if minutes.decisions: html += "| Aufgabe | Verantwortlich | Fällig | Priorität |
|---|---|---|---|
| {item.task} | {item.assignee or '-'} | {item.deadline or '-'} | {item.priority} |