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

View File

@ -33,7 +33,16 @@ class SetupIn(BaseModel):
def _user_dict(u: User) -> dict: def _user_dict(u: User) -> dict:
return {"id": u.id, "username": u.username, "is_admin": u.is_admin} return {
"id": u.id,
"username": u.username,
"is_admin": u.is_admin,
"default_profile": u.default_profile or "meeting",
}
class UpdateMeIn(BaseModel):
default_profile: str | None = Field(default=None, max_length=64)
@router.get("/me") @router.get("/me")
@ -41,6 +50,16 @@ def me(user: User = Depends(current_user)):
return _user_dict(user) return _user_dict(user)
@router.patch("/me")
def update_me(payload: UpdateMeIn, user: User = Depends(current_user), session: Session = Depends(get_session)):
if payload.default_profile is not None:
user.default_profile = payload.default_profile.strip() or "meeting"
session.add(user)
session.commit()
session.refresh(user)
return _user_dict(user)
@router.get("/needs-setup") @router.get("/needs-setup")
def needs_setup(session: Session = Depends(get_session)): def needs_setup(session: Session = Depends(get_session)):
"""Vor Login prüfen ob noch kein User existiert (erstes Deployment).""" """Vor Login prüfen ob noch kein User existiert (erstes Deployment)."""

View File

@ -30,6 +30,7 @@ async def create_job(
background: BackgroundTasks, background: BackgroundTasks,
audio: UploadFile = File(...), audio: UploadFile = File(...),
title: str = Form(""), title: str = Form(""),
profile: str = Form(""),
session: Session = Depends(get_session), session: Session = Depends(get_session),
user: User = Depends(current_user), user: User = Depends(current_user),
): ):
@ -54,6 +55,7 @@ async def create_job(
job = Job( job = Job(
owner_id=user.id, owner_id=user.id,
profile=(profile.strip() or user.default_profile or "meeting"),
filename=safe, filename=safe,
original_name=audio.filename, original_name=audio.filename,
title=title.strip(), title=title.strip(),
@ -114,6 +116,7 @@ def _job_dict(j: Job, user: User, owner_username: str | None = None) -> dict:
"status": j.status, "status": j.status,
"progress": j.progress, "progress": j.progress,
"error": j.error, "error": j.error,
"profile": j.profile or "meeting",
"created_at": j.created_at.isoformat(), "created_at": j.created_at.isoformat(),
"updated_at": j.updated_at.isoformat(), "updated_at": j.updated_at.isoformat(),
"has": { "has": {

View File

@ -0,0 +1,38 @@
"""Liefert die im Mac-Worker verfügbaren Verarbeitungs-Profile aus.
Cached die Liste 60 s, damit der Mac nicht bei jedem Reload getroffen wird.
"""
from __future__ import annotations
import logging
import time
from fastapi import APIRouter, Depends
from app.core.auth import current_user
from app.core.mac_client import MacClient
from app.models.user import User
router = APIRouter(prefix="/api", tags=["profiles"])
log = logging.getLogger("voice-agent.profiles")
_CACHE: dict = {"profiles": None, "ts": 0.0}
_TTL = 60.0
_FALLBACK = [{"name": "meeting", "display_name": "Sitzungsprotokoll", "language": "de"}]
@router.get("/profiles")
async def list_profiles(_: User = Depends(current_user)):
now = time.time()
if _CACHE["profiles"] is not None and now - _CACHE["ts"] < _TTL:
return {"profiles": _CACHE["profiles"]}
try:
profiles = await MacClient().list_profiles()
if not profiles:
profiles = _FALLBACK
except Exception as e: # noqa: BLE001
log.warning("Konnte Profile vom Mac nicht laden: %s — Fallback wird genutzt", e)
profiles = _FALLBACK
_CACHE["profiles"] = profiles
_CACHE["ts"] = now
return {"profiles": profiles}

View File

@ -14,6 +14,12 @@ class MacClient:
r.raise_for_status() r.raise_for_status()
return r.json() return r.json()
async def list_profiles(self) -> list[dict]:
async with httpx.AsyncClient(timeout=10) as c:
r = await c.get(f"{self.base_url}/api/profiles")
r.raise_for_status()
return r.json().get("profiles", [])
async def transcribe(self, audio_path: Path, language: str = "de") -> dict: async def transcribe(self, audio_path: Path, language: str = "de") -> dict:
async with httpx.AsyncClient(timeout=self.timeout) as c: async with httpx.AsyncClient(timeout=self.timeout) as c:
with audio_path.open("rb") as f: with audio_path.open("rb") as f:
@ -23,26 +29,29 @@ class MacClient:
r.raise_for_status() r.raise_for_status()
return r.json() return r.json()
async def summarize(self, transcript: str, title: str = "") -> dict: async def summarize(self, transcript: str, title: str = "", profile: str | None = None) -> dict:
async with httpx.AsyncClient(timeout=self.timeout) as c: async with httpx.AsyncClient(timeout=self.timeout) as c:
r = await c.post( r = await c.post(
f"{self.base_url}/api/summarize", f"{self.base_url}/api/summarize",
json={"transcript": transcript, "title": title}, json={"transcript": transcript, "title": title, "profile": profile},
) )
r.raise_for_status() r.raise_for_status()
return r.json() return r.json()
async def protocol(self, transcript: str, summary: dict, title: str = "") -> dict: async def protocol(self, transcript: str, summary: dict, title: str = "", profile: str | None = None) -> dict:
async with httpx.AsyncClient(timeout=self.timeout) as c: async with httpx.AsyncClient(timeout=self.timeout) as c:
r = await c.post( r = await c.post(
f"{self.base_url}/api/protocol", f"{self.base_url}/api/protocol",
json={"transcript": transcript, "summary": summary, "title": title}, json={"transcript": transcript, "summary": summary, "title": title, "profile": profile},
) )
r.raise_for_status() r.raise_for_status()
return r.json() return r.json()
async def export_docx(self, protocol: dict) -> bytes: async def export_docx(self, data: dict, profile: str | None = None) -> bytes:
async with httpx.AsyncClient(timeout=self.timeout) as c: async with httpx.AsyncClient(timeout=self.timeout) as c:
r = await c.post(f"{self.base_url}/api/export/docx", json=protocol) r = await c.post(
f"{self.base_url}/api/export/docx",
json={"data": data, "profile": profile},
)
r.raise_for_status() r.raise_for_status()
return r.content return r.content

View File

@ -28,6 +28,18 @@ def run_migrations() -> None:
conn.execute(text("ALTER TABLE job ADD COLUMN owner_id INTEGER")) conn.execute(text("ALTER TABLE job ADD COLUMN owner_id INTEGER"))
conn.execute(text("CREATE INDEX IF NOT EXISTS ix_job_owner_id ON job(owner_id)")) conn.execute(text("CREATE INDEX IF NOT EXISTS ix_job_owner_id ON job(owner_id)"))
# 2) Job.profile Spalte nachrüsten (Profil-System).
cols = _existing_columns(conn, "job")
if "profile" not in cols:
log.info("Migration: füge job.profile hinzu (default 'meeting')")
conn.execute(text("ALTER TABLE job ADD COLUMN profile VARCHAR DEFAULT 'meeting'"))
# 3) User.default_profile nachrüsten.
user_cols = _existing_columns(conn, "user")
if user_cols and "default_profile" not in user_cols:
log.info("Migration: füge user.default_profile hinzu (default 'meeting')")
conn.execute(text("ALTER TABLE user ADD COLUMN default_profile VARCHAR DEFAULT 'meeting'"))
def assign_orphan_jobs_to(user_id: int) -> int: def assign_orphan_jobs_to(user_id: int) -> int:
"""Weist alle Jobs ohne owner_id dem angegebenen User zu. Gibt Anzahl zurück.""" """Weist alle Jobs ohne owner_id dem angegebenen User zu. Gibt Anzahl zurück."""

View File

@ -11,6 +11,7 @@ from starlette.types import Scope
from app.api.admin import router as admin_router from app.api.admin import router as admin_router
from app.api.auth import router as auth_router from app.api.auth import router as auth_router
from app.api.jobs import router as jobs_router from app.api.jobs import router as jobs_router
from app.api.profiles import router as profiles_router
from app.core.config import settings from app.core.config import settings
from app.core.db import init_db from app.core.db import init_db
from app.core.mac_client import MacClient from app.core.mac_client import MacClient
@ -56,6 +57,7 @@ app.add_middleware(
app.include_router(auth_router) app.include_router(auth_router)
app.include_router(admin_router) app.include_router(admin_router)
app.include_router(profiles_router)
app.include_router(jobs_router) app.include_router(jobs_router)
STATIC_DIR = Path(__file__).resolve().parent / "static" STATIC_DIR = Path(__file__).resolve().parent / "static"

View File

@ -16,6 +16,7 @@ class JobStatus:
class Job(SQLModel, table=True): class Job(SQLModel, table=True):
id: Optional[int] = Field(default=None, primary_key=True) id: Optional[int] = Field(default=None, primary_key=True)
owner_id: Optional[int] = Field(default=None, foreign_key="user.id", index=True) owner_id: Optional[int] = Field(default=None, foreign_key="user.id", index=True)
profile: str = Field(default="meeting")
filename: str filename: str
original_name: str original_name: str
title: str = "" title: str = ""

View File

@ -9,4 +9,5 @@ class User(SQLModel, table=True):
username: str = Field(index=True, unique=True) username: str = Field(index=True, unique=True)
password_hash: str password_hash: str
is_admin: bool = Field(default=False) is_admin: bool = Field(default=False)
default_profile: str = Field(default="meeting")
created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc)) created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))

View File

@ -36,13 +36,14 @@ async def run_pipeline(job_id: int) -> None:
return return
audio_path = settings.upload_dir / job.filename audio_path = settings.upload_dir / job.filename
title = job.title or audio_path.stem title = job.title or audio_path.stem
profile = job.profile or "meeting"
job_dir = settings.result_dir / str(job_id) job_dir = settings.result_dir / str(job_id)
job_dir.mkdir(parents=True, exist_ok=True) job_dir.mkdir(parents=True, exist_ok=True)
try: try:
_update(job_id, status=JobStatus.TRANSCRIBING, progress=10) _update(job_id, status=JobStatus.TRANSCRIBING, progress=10)
log.info("[job %s] transcribe", job_id) log.info("[job %s] transcribe (profile=%s)", job_id, profile)
tx = await client.transcribe(audio_path, language="de") tx = await client.transcribe(audio_path, language="de")
transcript_path = job_dir / "transcript.json" transcript_path = job_dir / "transcript.json"
transcript_path.write_text(json.dumps(tx, ensure_ascii=False, indent=2), encoding="utf-8") transcript_path.write_text(json.dumps(tx, ensure_ascii=False, indent=2), encoding="utf-8")
@ -52,21 +53,21 @@ async def run_pipeline(job_id: int) -> None:
_update(job_id, status=JobStatus.SUMMARIZING, progress=55) _update(job_id, status=JobStatus.SUMMARIZING, progress=55)
log.info("[job %s] summarize", job_id) log.info("[job %s] summarize", job_id)
summary = await client.summarize(transcript_text, title=title) summary = await client.summarize(transcript_text, title=title, profile=profile)
summary_path = job_dir / "summary.json" summary_path = job_dir / "summary.json"
summary_path.write_text(json.dumps(summary, ensure_ascii=False, indent=2), encoding="utf-8") summary_path.write_text(json.dumps(summary, ensure_ascii=False, indent=2), encoding="utf-8")
_update(job_id, summary_path=str(summary_path), progress=70) _update(job_id, summary_path=str(summary_path), progress=70)
_update(job_id, status=JobStatus.PROTOCOLLING, progress=75) _update(job_id, status=JobStatus.PROTOCOLLING, progress=75)
log.info("[job %s] protocol", job_id) log.info("[job %s] protocol", job_id)
protocol = await client.protocol(transcript_text, summary, title=title) protocol = await client.protocol(transcript_text, summary, title=title, profile=profile)
protocol_path = job_dir / "protocol.json" protocol_path = job_dir / "protocol.json"
protocol_path.write_text(json.dumps(protocol, ensure_ascii=False, indent=2), encoding="utf-8") protocol_path.write_text(json.dumps(protocol, ensure_ascii=False, indent=2), encoding="utf-8")
_update(job_id, protocol_path=str(protocol_path), progress=85) _update(job_id, protocol_path=str(protocol_path), progress=85)
_update(job_id, status=JobStatus.EXPORTING, progress=90) _update(job_id, status=JobStatus.EXPORTING, progress=90)
log.info("[job %s] export docx", job_id) log.info("[job %s] export docx", job_id)
docx_bytes = await client.export_docx(protocol) docx_bytes = await client.export_docx(protocol, profile=profile)
docx_path = job_dir / "protocol.docx" docx_path = job_dir / "protocol.docx"
docx_path.write_bytes(docx_bytes) docx_path.write_bytes(docx_bytes)
_update(job_id, docx_path=str(docx_path), status=JobStatus.DONE, progress=100) _update(job_id, docx_path=str(docx_path), status=JobStatus.DONE, progress=100)

View File

@ -31,8 +31,17 @@ const newIsAdmin = document.getElementById("new-is-admin");
const userMsg = document.getElementById("user-msg"); const userMsg = document.getElementById("user-msg");
const usersBody = document.getElementById("users-body"); const usersBody = document.getElementById("users-body");
let currentUser = null; // { id, username, is_admin } const profileLabel = document.getElementById("profile-label");
const profileSelect = document.getElementById("profile-select");
const defaultProfileName = document.getElementById("default-profile-name");
const settingsCard = document.getElementById("settings-card");
const settingsForm = document.getElementById("settings-form");
const settingsProfile = document.getElementById("settings-profile");
const settingsMsg = document.getElementById("settings-msg");
let currentUser = null; // { id, username, is_admin, default_profile }
let mode = "login"; // "login" | "setup" let mode = "login"; // "login" | "setup"
let availableProfiles = []; // [{ name, display_name, language }, ...]
const DOWNLOADS = [ const DOWNLOADS = [
{ kind: "docx", label: "DOCX", title: "Sitzungsprotokoll als Word-Dokument" }, { kind: "docx", label: "DOCX", title: "Sitzungsprotokoll als Word-Dokument" },
@ -114,10 +123,52 @@ function enterApp() {
userName.textContent = currentUser.username; userName.textContent = currentUser.username;
userAdminBadge.classList.toggle("hidden", !currentUser.is_admin); userAdminBadge.classList.toggle("hidden", !currentUser.is_admin);
adminCard.classList.toggle("hidden", !currentUser.is_admin); adminCard.classList.toggle("hidden", !currentUser.is_admin);
loadProfiles().then(applyProfileUI);
loadJobs(); loadJobs();
if (currentUser.is_admin) loadUsers(); if (currentUser.is_admin) loadUsers();
} }
async function loadProfiles() {
try {
const r = await api("/api/profiles");
if (!r.ok) return;
const data = await r.json();
availableProfiles = data.profiles || [];
} catch (e) {
console.error("Profile laden fehlgeschlagen", e);
availableProfiles = [];
}
}
function profileLabelOf(name) {
const p = availableProfiles.find((x) => x.name === name);
return p ? p.display_name : name;
}
function applyProfileUI() {
// Upload-Dropdown nur sichtbar wenn ≥2 Profile zur Auswahl stehen.
const showDropdown = availableProfiles.length > 1;
profileLabel.classList.toggle("hidden", !showDropdown);
// Beide Selects befüllen
for (const sel of [profileSelect, settingsProfile]) {
sel.innerHTML = "";
availableProfiles.forEach((p) => {
const o = document.createElement("option");
o.value = p.name;
o.textContent = p.display_name;
sel.appendChild(o);
});
}
const def = currentUser.default_profile || "meeting";
profileSelect.value = def;
settingsProfile.value = def;
defaultProfileName.textContent = profileLabelOf(def);
// Settings-Karte nur, wenn es überhaupt was auszuwählen gibt.
settingsCard.classList.toggle("hidden", !showDropdown);
}
authForm.addEventListener("submit", async (ev) => { authForm.addEventListener("submit", async (ev) => {
ev.preventDefault(); ev.preventDefault();
authSubmit.disabled = true; authSubmit.disabled = true;
@ -201,6 +252,12 @@ function row(job) {
? `<br><small class="muted">von ${escapeHtml(job.owner_username)}</small>` ? `<br><small class="muted">von ${escapeHtml(job.owner_username)}</small>`
: ""; : "";
// Profile-Tag nur sichtbar wenn ≥2 Profile in der Auswahl ODER es ein anderes als "meeting" ist.
const showProfileTag = availableProfiles.length > 1 || (job.profile && job.profile !== "meeting");
const profileTag = showProfileTag
? `<span class="tag-profile" title="Verarbeitungs-Profil">${escapeHtml(profileLabelOf(job.profile || "meeting"))}</span>`
: "";
const statusCell = ` const statusCell = `
<span class="status-pill status-${job.status}">${escapeHtml(job.status)}</span> <span class="status-pill status-${job.status}">${escapeHtml(job.status)}</span>
${job.error ? `<div class="status-error" title="${escapeHtml(job.error)}">${escapeHtml(job.error)}</div>` : ""} ${job.error ? `<div class="status-error" title="${escapeHtml(job.error)}">${escapeHtml(job.error)}</div>` : ""}
@ -208,7 +265,7 @@ function row(job) {
tr.innerHTML = ` tr.innerHTML = `
<td class="col-id">#${job.id}</td> <td class="col-id">#${job.id}</td>
<td class="col-title">${escapeHtml(title)}<br><small class="muted">${escapeHtml(job.original_name)}</small>${ownerLine}</td> <td class="col-title">${escapeHtml(title)} ${profileTag}<br><small class="muted">${escapeHtml(job.original_name)}</small>${ownerLine}</td>
<td class="col-status">${statusCell}</td> <td class="col-status">${statusCell}</td>
<td class="col-progress"><div class="bar"><div class="bar-fill" style="width:${job.progress}%"></div></div><small class="muted">${job.progress}%</small></td> <td class="col-progress"><div class="bar"><div class="bar-fill" style="width:${job.progress}%"></div></div><small class="muted">${job.progress}%</small></td>
<td class="col-updated"><small class="muted">${fmtDate(job.updated_at)}</small></td> <td class="col-updated"><small class="muted">${fmtDate(job.updated_at)}</small></td>
@ -240,6 +297,8 @@ form.addEventListener("submit", (ev) => {
const fd = new FormData(); const fd = new FormData();
fd.append("audio", audioInput.files[0]); fd.append("audio", audioInput.files[0]);
fd.append("title", titleInput.value); fd.append("title", titleInput.value);
// Profile mitgeben — leer = Server nimmt user.default_profile
fd.append("profile", profileSelect.value || "");
submitBtn.disabled = true; submitBtn.disabled = true;
uploadProgress.classList.remove("hidden"); uploadProgress.classList.remove("hidden");
@ -358,6 +417,26 @@ usersBody.addEventListener("click", async (ev) => {
} }
}); });
settingsForm.addEventListener("submit", async (ev) => {
ev.preventDefault();
settingsMsg.textContent = "";
settingsMsg.className = "msg";
const r = await api("/api/auth/me", {
method: "PATCH",
body: JSON.stringify({ default_profile: settingsProfile.value }),
});
const data = await r.json();
if (!r.ok) {
settingsMsg.textContent = data.detail || "Speichern fehlgeschlagen";
settingsMsg.className = "msg err";
return;
}
currentUser = data;
applyProfileUI();
settingsMsg.textContent = "Gespeichert.";
settingsMsg.className = "msg ok";
});
// ─── Init ────────────────────────────────────────────────────────────────── // ─── Init ──────────────────────────────────────────────────────────────────
bootstrap(); bootstrap();
checkMac(); checkMac();

View File

@ -50,9 +50,14 @@
<h2>Neue Aufnahme hochladen</h2> <h2>Neue Aufnahme hochladen</h2>
<form id="upload-form"> <form id="upload-form">
<label> <label>
Sitzungstitel (optional) Titel (optional)
<input type="text" name="title" id="title" placeholder="z. B. Monatsbesprechung" /> <input type="text" name="title" id="title" placeholder="z. B. Monatsbesprechung" />
</label> </label>
<label id="profile-label" class="hidden">
Verarbeitungs-Profil
<select id="profile-select"></select>
<small class="muted">Standard: <span id="default-profile-name"></span> (in Einstellungen änderbar)</small>
</label>
<label> <label>
Audiodatei (MP3, WAV, M4A, MP4, OGG, FLAC) Audiodatei (MP3, WAV, M4A, MP4, OGG, FLAC)
<input type="file" name="audio" id="audio" accept=".mp3,.wav,.m4a,.mp4,.ogg,.flac" required /> <input type="file" name="audio" id="audio" accept=".mp3,.wav,.m4a,.mp4,.ogg,.flac" required />
@ -66,6 +71,18 @@
<p id="upload-msg" class="msg"></p> <p id="upload-msg" class="msg"></p>
</section> </section>
<section class="card" id="settings-card">
<h2>Meine Einstellungen</h2>
<form id="settings-form" class="settings-form">
<label>
Standard-Profil
<select id="settings-profile"></select>
</label>
<button type="submit">Speichern</button>
</form>
<p id="settings-msg" class="msg"></p>
</section>
<section class="card"> <section class="card">
<h2>Jobs</h2> <h2>Jobs</h2>
<table id="jobs-table"> <table id="jobs-table">

View File

@ -252,3 +252,37 @@ footer { text-align: center; padding: 24px 16px 32px; }
@media (max-width: 700px) { @media (max-width: 700px) {
.user-form { grid-template-columns: 1fr; } .user-form { grid-template-columns: 1fr; }
} }
/* Settings-Form */
.settings-form {
display: grid;
grid-template-columns: 1fr auto;
gap: 12px;
align-items: end;
max-width: 480px;
}
/* Select-Felder im selben Stil wie Inputs */
select {
background: #0f1219;
border: 1px solid var(--border);
color: var(--text);
padding: 10px 12px;
border-radius: 8px;
font-size: 14px;
font-family: inherit;
}
/* Profile-Tag in der Job-Zeile */
.tag-profile {
display: inline-block;
padding: 1px 8px;
margin-left: 6px;
border-radius: 4px;
background: rgba(46,204,113,.12);
border: 1px solid rgba(46,204,113,.4);
color: var(--ok);
font-size: 11px;
text-transform: lowercase;
vertical-align: middle;
}

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 io import BytesIO
from typing import Any from typing import Any
from docx import Document from docx import Document
from docx.shared import Pt 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() 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) doc.add_heading(title, level=0)
meta_lines = [] for meta in cfg.get("meta", []) or []:
if protocol.get("date"): val = data.get(meta["key"])
meta_lines.append(f"Datum: {protocol['date']}") if val:
if protocol.get("topic"): doc.add_paragraph(f"{meta.get('label', meta['key'])}: {val}")
meta_lines.append(f"Thema: {protocol['topic']}")
if meta_lines:
for line in meta_lines:
doc.add_paragraph(line)
participants = protocol.get("participants") or [] for section in cfg.get("sections", []) or []:
if participants: _render_section(doc, data, section)
doc.add_heading("Teilnehmer", level=1)
for p in participants:
doc.add_paragraph(str(p), style="List Bullet")
summary = protocol.get("summary") or "" if data.get("_parse_error") and data.get("_raw"):
if summary: doc.add_heading("Roh-Antwort des Modells", level=1)
doc.add_heading("Zusammenfassung", level=1) doc.add_paragraph(data["_raw"])
doc.add_paragraph(summary)
decisions = protocol.get("decisions") or [] buf = BytesIO()
if decisions: doc.save(buf)
doc.add_heading("Beschlüsse", level=1) return buf.getvalue()
for d in decisions:
doc.add_paragraph(str(d), style="List Bullet")
tasks = protocol.get("tasks") or []
if tasks: def _render_section(doc, data: dict, section: dict) -> None:
doc.add_heading("Aufgaben", level=1) val = data.get(section["key"])
for t in tasks: 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): if isinstance(t, dict):
owner = t.get("owner") or "n/a" owner = t.get("owner") or "n/a"
task = t.get("task") or t.get("description") or "" task = t.get("task") or t.get("description") or ""
doc.add_paragraph(f"{owner}: {task}", style="List Bullet") doc.add_paragraph(f"{owner}: {task}", style="List Bullet")
else: else:
doc.add_paragraph(str(t), style="List Bullet") doc.add_paragraph(str(t), style="List Bullet")
elif sec_type == "transcript":
questions = protocol.get("open_questions") or [] items = val if isinstance(val, list) else [val]
if questions: for item in items:
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:
if isinstance(item, dict): if isinstance(item, dict):
time = item.get("time", "") t = item.get("time", "")
speaker = item.get("speaker", "") spk = item.get("speaker", "")
text = item.get("text", "") txt = item.get("text", "")
p = doc.add_paragraph() p = doc.add_paragraph()
run = p.add_run(f"[{time}] {speaker}: ") run = p.add_run(f"[{t}] {spk}: ")
run.bold = True run.bold = True
run.font.size = Pt(10) run.font.size = Pt(10)
p.add_run(text).font.size = Pt(10) p.add_run(txt).font.size = Pt(10)
else: else:
doc.add_paragraph(str(item)) doc.add_paragraph(str(item))
else:
buf = BytesIO() doc.add_paragraph(str(val))
doc.save(buf)
return buf.getvalue()

View File

@ -5,58 +5,12 @@ from typing import Any
import httpx import httpx
from . import profiles
from .config import settings from .config import settings
log = logging.getLogger("mac-worker.ollama") 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: def _strip_codefence(s: str) -> str:
s = s.strip() s = s.strip()
if s.startswith("```"): if s.startswith("```"):
@ -101,46 +55,39 @@ async def _chat(prompt: str) -> str:
return data.get("response", "") return data.get("response", "")
async def summarize(transcript: str, title: str = "") -> dict[str, Any]: async def summarize(transcript: str, title: str = "", profile_name: str | None = None) -> dict[str, Any]:
prompt = SUMMARY_PROMPT.format(title=title or "(ohne Titel)", transcript=_truncate(transcript)) 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) raw = await _chat(prompt)
try: try:
return _parse_json(raw) return _parse_json(raw)
except Exception: # noqa: BLE001 except Exception: # noqa: BLE001
log.warning("Failed to parse summary JSON, falling back to text") log.warning("Failed to parse summary JSON — gebe Roh-Antwort als _raw zurück")
return { return {"_raw": raw.strip(), "_parse_error": True}
"topic": title,
"summary": raw.strip(),
"decisions": [],
"tasks": [],
"participants": [],
"open_questions": [],
}
async def make_protocol(transcript: str, summary: dict, title: str = "") -> dict: async def make_protocol(
prompt = PROTOCOL_PROMPT.format( 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)", title=title or "(ohne Titel)",
summary_json=json.dumps(summary, ensure_ascii=False), summary_json=json.dumps(summary, ensure_ascii=False),
transcript=_truncate(transcript), transcript=_truncate(transcript),
) )
log.info("Protocol profile=%s", profile.name)
raw = await _chat(prompt) raw = await _chat(prompt)
try: try:
return _parse_json(raw) return _parse_json(raw)
except Exception: # noqa: BLE001 except Exception: # noqa: BLE001
log.warning("Failed to parse protocol JSON, falling back to structured summary") log.warning("Failed to parse protocol JSON — Fallback auf Summary")
return { fallback = dict(summary) if isinstance(summary, dict) else {}
"title": title, fallback.setdefault("title", title)
"date": "", return fallback
"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: async def health() -> dict:

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()

View File

@ -6,7 +6,7 @@ from fastapi import FastAPI, File, Form, HTTPException, UploadFile
from fastapi.responses import Response from fastapi.responses import Response
from pydantic import BaseModel 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.config import settings
from app.core.docx_export import build_docx from app.core.docx_export import build_docx
from app.core.whisper_engine import engine as whisper from app.core.whisper_engine import engine as whisper
@ -20,12 +20,19 @@ app = FastAPI(title="Voice-Agent Mac-Worker")
class SummarizeIn(BaseModel): class SummarizeIn(BaseModel):
transcript: str transcript: str
title: str = "" title: str = ""
profile: str | None = None
class ProtocolIn(BaseModel): class ProtocolIn(BaseModel):
transcript: str transcript: str
summary: dict summary: dict
title: str = "" title: str = ""
profile: str | None = None
class DocxIn(BaseModel):
data: dict
profile: str | None = None
@app.get("/health") @app.get("/health")
@ -36,6 +43,7 @@ async def health():
"whisper_model": settings.whisper_model, "whisper_model": settings.whisper_model,
"ollama_model": settings.ollama_model, "ollama_model": settings.ollama_model,
"ollama_reachable": False, "ollama_reachable": False,
"profiles": [p["name"] for p in profiles.list_all()],
} }
try: try:
await ollama_client.health() await ollama_client.health()
@ -45,6 +53,11 @@ async def health():
return info return info
@app.get("/api/profiles")
def list_profiles():
return {"profiles": profiles.list_all()}
@app.post("/api/transcribe") @app.post("/api/transcribe")
async def transcribe(audio: UploadFile = File(...), language: str = Form("de")): async def transcribe(audio: UploadFile = File(...), language: str = Form("de")):
if not audio.filename: if not audio.filename:
@ -66,19 +79,21 @@ async def transcribe(audio: UploadFile = File(...), language: str = Form("de")):
async def summarize(payload: SummarizeIn): async def summarize(payload: SummarizeIn):
if not payload.transcript.strip(): if not payload.transcript.strip():
raise HTTPException(400, "Leerer Transkript") 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") @app.post("/api/protocol")
async def protocol(payload: ProtocolIn): async def protocol(payload: ProtocolIn):
if not payload.transcript.strip(): if not payload.transcript.strip():
raise HTTPException(400, "Leerer Transkript") 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") @app.post("/api/export/docx")
async def export_docx(protocol: dict): async def export_docx(payload: DocxIn):
data = build_docx(protocol) data = build_docx(payload.data, profile_name=payload.profile)
return Response( return Response(
content=data, content=data,
media_type="application/vnd.openxmlformats-officedocument.wordprocessingml.document", media_type="application/vnd.openxmlformats-officedocument.wordprocessingml.document",

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 }

View File

@ -4,6 +4,7 @@ python-multipart==0.0.18
pydantic-settings==2.7.0 pydantic-settings==2.7.0
httpx==0.28.1 httpx==0.28.1
python-docx==1.1.2 python-docx==1.1.2
pyyaml==6.0.2
# Whisper-Engines (eine wählen — siehe README): # Whisper-Engines (eine wählen — siehe README):
# Apple Silicon (empfohlen): # Apple Silicon (empfohlen):
lightning-whisper-mlx==0.0.10 ; sys_platform == "darwin" and platform_machine == "arm64" lightning-whisper-mlx==0.0.10 ; sys_platform == "darwin" and platform_machine == "arm64"