LXC-Frontend (FastAPI + HTML/JS): - Audio-Upload (MP3/WAV/M4A/MP4/OGG/FLAC, max. 500 MB) - SQLite Job-Store, BackgroundTask-Pipeline - Job-Liste mit Live-Status, Downloads (DOCX + JSON) - Mac-Health-Indicator im UI Mac-Worker (FastAPI): - /api/transcribe (lightning-whisper-mlx | faster-whisper | mock) - /api/summarize + /api/protocol via Ollama (llama3.1:8b) - /api/export/docx via python-docx Deploy: - systemd-Service, Nginx Reverse-Proxy - deploy/install.sh: idempotentes LXC-Setup Doku: README.md, lxc-frontend/README.md, mac-worker/README.md
152 lines
4.5 KiB
Python
152 lines
4.5 KiB
Python
import json
|
|
import logging
|
|
import re
|
|
from typing import Any
|
|
|
|
import httpx
|
|
|
|
from .config import settings
|
|
|
|
log = logging.getLogger("mac-worker.ollama")
|
|
|
|
|
|
SUMMARY_PROMPT = """Du bist ein präziser Protokollassistent für deutsche Geschäftssitzungen.
|
|
Analysiere das folgende Sitzungstranskript und liefere ein JSON-Objekt mit:
|
|
|
|
- "topic": Hauptthema der Sitzung (kurz)
|
|
- "summary": Zusammenfassung in 3-6 Sätzen
|
|
- "decisions": Liste der Beschlüsse (jeweils ein Satz)
|
|
- "tasks": Liste der Aufgaben, jede mit "owner" (Name oder "n/a") und "task"
|
|
- "participants": Liste der erkennbaren Teilnehmer (Namen oder "Sprecher 1/2/...")
|
|
- "open_questions": Liste offener Fragen (kann leer sein)
|
|
|
|
Antworte AUSSCHLIESSLICH mit gültigem JSON, keine Erläuterungen, kein Markdown-Codeblock.
|
|
|
|
Sitzungstitel: {title}
|
|
|
|
Transkript:
|
|
\"\"\"
|
|
{transcript}
|
|
\"\"\"
|
|
"""
|
|
|
|
|
|
PROTOCOL_PROMPT = """Erstelle aus den folgenden Daten ein formelles deutsches Sitzungsprotokoll
|
|
als JSON-Objekt mit folgenden Feldern:
|
|
|
|
- "title": Protokoll-Titel
|
|
- "date": Datum (YYYY-MM-DD, falls aus Transkript ableitbar, sonst leer)
|
|
- "topic": Thema
|
|
- "participants": Liste der Teilnehmer
|
|
- "summary": Zusammenfassung
|
|
- "decisions": Liste der Beschlüsse
|
|
- "tasks": Liste der Aufgaben (jeweils mit "owner" und "task")
|
|
- "open_questions": Liste offener Fragen
|
|
- "next_meeting": Wenn erwähnt, sonst leer
|
|
- "transcript_excerpt": Liste von Objekten mit "time", "speaker", "text" (max. 20 Einträge, zeitlich verteilt aus dem Transkript ziehen)
|
|
|
|
Antworte AUSSCHLIESSLICH mit JSON, kein Markdown.
|
|
|
|
Titel: {title}
|
|
Bestehende Zusammenfassung: {summary_json}
|
|
|
|
Transkript:
|
|
\"\"\"
|
|
{transcript}
|
|
\"\"\"
|
|
"""
|
|
|
|
|
|
def _strip_codefence(s: str) -> str:
|
|
s = s.strip()
|
|
if s.startswith("```"):
|
|
s = re.sub(r"^```(?:json)?", "", s, count=1).strip()
|
|
if s.endswith("```"):
|
|
s = s[:-3].strip()
|
|
return s
|
|
|
|
|
|
def _parse_json(text: str) -> dict:
|
|
text = _strip_codefence(text)
|
|
try:
|
|
return json.loads(text)
|
|
except json.JSONDecodeError:
|
|
m = re.search(r"\{.*\}", text, re.S)
|
|
if m:
|
|
return json.loads(m.group(0))
|
|
raise
|
|
|
|
|
|
def _truncate(text: str, max_chars: int = 60000) -> str:
|
|
if len(text) <= max_chars:
|
|
return text
|
|
head = text[: max_chars // 2]
|
|
tail = text[-max_chars // 2 :]
|
|
return head + "\n\n[... gekürzt ...]\n\n" + tail
|
|
|
|
|
|
async def _chat(prompt: str) -> str:
|
|
url = f"{settings.ollama_url.rstrip('/')}/api/generate"
|
|
payload = {
|
|
"model": settings.ollama_model,
|
|
"prompt": prompt,
|
|
"stream": False,
|
|
"options": {"temperature": 0.2},
|
|
}
|
|
log.info("Ollama generate model=%s prompt_len=%d", settings.ollama_model, len(prompt))
|
|
async with httpx.AsyncClient(timeout=settings.ollama_timeout) as c:
|
|
r = await c.post(url, json=payload)
|
|
r.raise_for_status()
|
|
data = r.json()
|
|
return data.get("response", "")
|
|
|
|
|
|
async def summarize(transcript: str, title: str = "") -> dict[str, Any]:
|
|
prompt = SUMMARY_PROMPT.format(title=title or "(ohne Titel)", transcript=_truncate(transcript))
|
|
raw = await _chat(prompt)
|
|
try:
|
|
return _parse_json(raw)
|
|
except Exception: # noqa: BLE001
|
|
log.warning("Failed to parse summary JSON, falling back to text")
|
|
return {
|
|
"topic": title,
|
|
"summary": raw.strip(),
|
|
"decisions": [],
|
|
"tasks": [],
|
|
"participants": [],
|
|
"open_questions": [],
|
|
}
|
|
|
|
|
|
async def make_protocol(transcript: str, summary: dict, title: str = "") -> dict:
|
|
prompt = PROTOCOL_PROMPT.format(
|
|
title=title or "(ohne Titel)",
|
|
summary_json=json.dumps(summary, ensure_ascii=False),
|
|
transcript=_truncate(transcript),
|
|
)
|
|
raw = await _chat(prompt)
|
|
try:
|
|
return _parse_json(raw)
|
|
except Exception: # noqa: BLE001
|
|
log.warning("Failed to parse protocol JSON, falling back to structured summary")
|
|
return {
|
|
"title": title,
|
|
"date": "",
|
|
"topic": summary.get("topic", title),
|
|
"participants": summary.get("participants", []),
|
|
"summary": summary.get("summary", ""),
|
|
"decisions": summary.get("decisions", []),
|
|
"tasks": summary.get("tasks", []),
|
|
"open_questions": summary.get("open_questions", []),
|
|
"next_meeting": "",
|
|
"transcript_excerpt": [],
|
|
}
|
|
|
|
|
|
async def health() -> dict:
|
|
url = f"{settings.ollama_url.rstrip('/')}/api/tags"
|
|
async with httpx.AsyncClient(timeout=10) as c:
|
|
r = await c.get(url)
|
|
r.raise_for_status()
|
|
return r.json()
|