feat: Profile-System für unterschiedliche Use-Cases (Sitzungsprotokoll, später Arzt-Diktat etc.)

Mac-Worker:
- profiles/meeting.yaml — extrahiert die bisherigen Prompts + DOCX-Layout
- core/profiles.py — YAML-Loader mit Cache und Fallback
- ollama_client.summarize/make_protocol nehmen profile_name
- docx_export: generischer Renderer aus profile.docx (meta + sections mit
  Typ text/list/tasks/transcript)
- /api/profiles listet verfügbare Profile
- pyyaml als Dependency

LXC:
- Job.profile + User.default_profile (Migration: ALTER TABLE)
- /api/profiles proxy mit 60s-Cache und Fallback
- Upload-Form akzeptiert profile (Server-Default: user.default_profile)
- Pipeline gibt Profile bei summarize/protocol/docx an Mac weiter
- PATCH /api/auth/me — User kann Standard-Profil ändern

Frontend:
- Profile-Dropdown im Upload (nur sichtbar wenn ≥2 Profile)
- Settings-Karte "Mein Standard-Profil" (nur wenn ≥2 Profile)
- Job-Zeile zeigt grünes Profile-Tag (nur wenn relevant)

Neue Profile = neue YAML-Datei in mac-worker/profiles/ — kein Code-Deploy
nötig, der Mac-Worker liest sie beim Start ein.
This commit is contained in:
root
2026-05-14 03:11:30 +00:00
parent 98cf9eb47e
commit 3a9881d36f
18 changed files with 479 additions and 145 deletions
+61 -53
View File
@@ -1,80 +1,88 @@
"""Profil-getriebenes DOCX-Rendering.
Das Profil definiert in `docx`:
title_field: welches Feld ergibt den Dokumenttitel
default_title: Fallback wenn das Feld leer ist
meta: [{key, label}, ...] — kommen direkt unter den Titel als "Label: Wert"
sections: [{key, label, type}, ...] — jede Sektion bekommt eine H1 + gerenderten Inhalt
Sektion-Typen: text | list | tasks | transcript
"""
from __future__ import annotations
from io import BytesIO
from typing import Any
from docx import Document
from docx.shared import Pt
from . import profiles
def build_docx(data: dict[str, Any], profile_name: str | None = None) -> bytes:
profile = profiles.get(profile_name)
cfg = profile.docx or {}
def build_docx(protocol: dict[str, Any]) -> bytes:
doc = Document()
title = protocol.get("title") or "Sitzungsprotokoll"
title_field = cfg.get("title_field", "title")
title = (data.get(title_field) or cfg.get("default_title") or profile.display_name).strip()
doc.add_heading(title, level=0)
meta_lines = []
if protocol.get("date"):
meta_lines.append(f"Datum: {protocol['date']}")
if protocol.get("topic"):
meta_lines.append(f"Thema: {protocol['topic']}")
if meta_lines:
for line in meta_lines:
doc.add_paragraph(line)
for meta in cfg.get("meta", []) or []:
val = data.get(meta["key"])
if val:
doc.add_paragraph(f"{meta.get('label', meta['key'])}: {val}")
participants = protocol.get("participants") or []
if participants:
doc.add_heading("Teilnehmer", level=1)
for p in participants:
doc.add_paragraph(str(p), style="List Bullet")
for section in cfg.get("sections", []) or []:
_render_section(doc, data, section)
summary = protocol.get("summary") or ""
if summary:
doc.add_heading("Zusammenfassung", level=1)
doc.add_paragraph(summary)
if data.get("_parse_error") and data.get("_raw"):
doc.add_heading("Roh-Antwort des Modells", level=1)
doc.add_paragraph(data["_raw"])
decisions = protocol.get("decisions") or []
if decisions:
doc.add_heading("Beschlüsse", level=1)
for d in decisions:
doc.add_paragraph(str(d), style="List Bullet")
buf = BytesIO()
doc.save(buf)
return buf.getvalue()
tasks = protocol.get("tasks") or []
if tasks:
doc.add_heading("Aufgaben", level=1)
for t in tasks:
def _render_section(doc, data: dict, section: dict) -> None:
val = data.get(section["key"])
if val in (None, "", [], {}):
return
label = section.get("label", section["key"])
sec_type = section.get("type", "text")
doc.add_heading(label, level=1)
if sec_type == "text":
doc.add_paragraph(str(val))
elif sec_type == "list":
items = val if isinstance(val, list) else [val]
for item in items:
doc.add_paragraph(str(item), style="List Bullet")
elif sec_type == "tasks":
items = val if isinstance(val, list) else [val]
for t in items:
if isinstance(t, dict):
owner = t.get("owner") or "n/a"
task = t.get("task") or t.get("description") or ""
doc.add_paragraph(f"{owner}: {task}", style="List Bullet")
else:
doc.add_paragraph(str(t), style="List Bullet")
questions = protocol.get("open_questions") or []
if questions:
doc.add_heading("Offene Fragen", level=1)
for q in questions:
doc.add_paragraph(str(q), style="List Bullet")
next_meeting = protocol.get("next_meeting")
if next_meeting:
doc.add_heading("Nächster Termin", level=1)
doc.add_paragraph(str(next_meeting))
excerpt = protocol.get("transcript_excerpt") or []
if excerpt:
doc.add_heading("Transkript-Auszug", level=1)
for item in excerpt:
elif sec_type == "transcript":
items = val if isinstance(val, list) else [val]
for item in items:
if isinstance(item, dict):
time = item.get("time", "")
speaker = item.get("speaker", "")
text = item.get("text", "")
t = item.get("time", "")
spk = item.get("speaker", "")
txt = item.get("text", "")
p = doc.add_paragraph()
run = p.add_run(f"[{time}] {speaker}: ")
run = p.add_run(f"[{t}] {spk}: ")
run.bold = True
run.font.size = Pt(10)
p.add_run(text).font.size = Pt(10)
p.add_run(txt).font.size = Pt(10)
else:
doc.add_paragraph(str(item))
buf = BytesIO()
doc.save(buf)
return buf.getvalue()
else:
doc.add_paragraph(str(val))
+20 -73
View File
@@ -5,58 +5,12 @@ from typing import Any
import httpx
from . import profiles
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("```"):
@@ -101,46 +55,39 @@ async def _chat(prompt: str) -> str:
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))
async def summarize(transcript: str, title: str = "", profile_name: str | None = None) -> dict[str, Any]:
profile = profiles.get(profile_name)
prompt = profile.summary_prompt.format(
title=title or "(ohne Titel)",
transcript=_truncate(transcript),
)
log.info("Summarize profile=%s", profile.name)
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": [],
}
log.warning("Failed to parse summary JSON — gebe Roh-Antwort als _raw zurück")
return {"_raw": raw.strip(), "_parse_error": True}
async def make_protocol(transcript: str, summary: dict, title: str = "") -> dict:
prompt = PROTOCOL_PROMPT.format(
async def make_protocol(
transcript: str, summary: dict, title: str = "", profile_name: str | None = None
) -> dict:
profile = profiles.get(profile_name)
prompt = profile.protocol_prompt.format(
title=title or "(ohne Titel)",
summary_json=json.dumps(summary, ensure_ascii=False),
transcript=_truncate(transcript),
)
log.info("Protocol profile=%s", profile.name)
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": [],
}
log.warning("Failed to parse protocol JSON — Fallback auf Summary")
fallback = dict(summary) if isinstance(summary, dict) else {}
fallback.setdefault("title", title)
return fallback
async def health() -> dict:
+84
View File
@@ -0,0 +1,84 @@
"""Lädt Verarbeitungs-Profile aus mac-worker/profiles/*.yaml.
Ein Profil definiert:
- Prompts für Summary und Protocol
- DOCX-Layout (Meta-Zeilen + Sektionen mit Typ)
Sind keine Profile vorhanden, wirft get() FileNotFoundError beim ersten Zugriff.
"""
from __future__ import annotations
import logging
from dataclasses import dataclass, field
from pathlib import Path
import yaml
log = logging.getLogger("mac-worker.profiles")
PROFILES_DIR = Path(__file__).resolve().parent.parent.parent / "profiles"
DEFAULT_PROFILE_NAME = "meeting"
@dataclass
class Profile:
name: str
display_name: str
language: str = "de"
summary_prompt: str = ""
protocol_prompt: str = ""
docx: dict = field(default_factory=dict)
_cache: dict[str, Profile] = {}
def _load() -> dict[str, Profile]:
if _cache:
return _cache
if not PROFILES_DIR.exists():
log.warning("Profile-Verzeichnis %s existiert nicht", PROFILES_DIR)
return _cache
for f in sorted(PROFILES_DIR.glob("*.yaml")):
try:
data = yaml.safe_load(f.read_text(encoding="utf-8")) or {}
p = Profile(
name=data["name"],
display_name=data.get("display_name", data["name"]),
language=data.get("language", "de"),
summary_prompt=data.get("summary_prompt", ""),
protocol_prompt=data.get("protocol_prompt", ""),
docx=data.get("docx") or {},
)
_cache[p.name] = p
log.info("Profil geladen: %s (%s)", p.name, p.display_name)
except Exception as e: # noqa: BLE001
log.error("Profil %s konnte nicht geladen werden: %s", f.name, e)
return _cache
def list_all() -> list[dict]:
"""Zur Auslieferung über /api/profiles."""
return [
{"name": p.name, "display_name": p.display_name, "language": p.language}
for p in _load().values()
]
def get(name: str | None) -> Profile:
profiles = _load()
if not profiles:
raise FileNotFoundError(
f"Keine Profile gefunden in {PROFILES_DIR} — bitte mind. eine YAML-Datei anlegen."
)
if name and name in profiles:
return profiles[name]
if DEFAULT_PROFILE_NAME in profiles:
return profiles[DEFAULT_PROFILE_NAME]
return next(iter(profiles.values()))
def reload() -> None:
"""Cache leeren (z. B. für Tests oder Hot-Reload via SIGHUP)."""
_cache.clear()
+20 -5
View File
@@ -6,7 +6,7 @@ from fastapi import FastAPI, File, Form, HTTPException, UploadFile
from fastapi.responses import Response
from pydantic import BaseModel
from app.core import ollama_client
from app.core import ollama_client, profiles
from app.core.config import settings
from app.core.docx_export import build_docx
from app.core.whisper_engine import engine as whisper
@@ -20,12 +20,19 @@ app = FastAPI(title="Voice-Agent Mac-Worker")
class SummarizeIn(BaseModel):
transcript: str
title: str = ""
profile: str | None = None
class ProtocolIn(BaseModel):
transcript: str
summary: dict
title: str = ""
profile: str | None = None
class DocxIn(BaseModel):
data: dict
profile: str | None = None
@app.get("/health")
@@ -36,6 +43,7 @@ async def health():
"whisper_model": settings.whisper_model,
"ollama_model": settings.ollama_model,
"ollama_reachable": False,
"profiles": [p["name"] for p in profiles.list_all()],
}
try:
await ollama_client.health()
@@ -45,6 +53,11 @@ async def health():
return info
@app.get("/api/profiles")
def list_profiles():
return {"profiles": profiles.list_all()}
@app.post("/api/transcribe")
async def transcribe(audio: UploadFile = File(...), language: str = Form("de")):
if not audio.filename:
@@ -66,19 +79,21 @@ async def transcribe(audio: UploadFile = File(...), language: str = Form("de")):
async def summarize(payload: SummarizeIn):
if not payload.transcript.strip():
raise HTTPException(400, "Leerer Transkript")
return await ollama_client.summarize(payload.transcript, title=payload.title)
return await ollama_client.summarize(payload.transcript, title=payload.title, profile_name=payload.profile)
@app.post("/api/protocol")
async def protocol(payload: ProtocolIn):
if not payload.transcript.strip():
raise HTTPException(400, "Leerer Transkript")
return await ollama_client.make_protocol(payload.transcript, payload.summary, title=payload.title)
return await ollama_client.make_protocol(
payload.transcript, payload.summary, title=payload.title, profile_name=payload.profile
)
@app.post("/api/export/docx")
async def export_docx(protocol: dict):
data = build_docx(protocol)
async def export_docx(payload: DocxIn):
data = build_docx(payload.data, profile_name=payload.profile)
return Response(
content=data,
media_type="application/vnd.openxmlformats-officedocument.wordprocessingml.document",
+63
View File
@@ -0,0 +1,63 @@
name: meeting
display_name: Sitzungsprotokoll
language: de
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}
"""
docx:
title_field: title
default_title: Sitzungsprotokoll
meta:
- { key: date, label: "Datum" }
- { key: topic, label: "Thema" }
sections:
- { key: participants, label: "Teilnehmer", type: list }
- { key: summary, label: "Zusammenfassung", type: text }
- { key: decisions, label: "Beschlüsse", type: list }
- { key: tasks, label: "Aufgaben", type: tasks }
- { key: open_questions, label: "Offene Fragen", type: list }
- { key: next_meeting, label: "Nächster Termin", type: text }
- { key: transcript_excerpt, label: "Transkript-Auszug", type: transcript }
+1
View File
@@ -4,6 +4,7 @@ python-multipart==0.0.18
pydantic-settings==2.7.0
httpx==0.28.1
python-docx==1.1.2
pyyaml==6.0.2
# Whisper-Engines (eine wählen — siehe README):
# Apple Silicon (empfohlen):
lightning-whisper-mlx==0.0.10 ; sys_platform == "darwin" and platform_machine == "arm64"