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:
@@ -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))
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user