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:
@@ -33,7 +33,16 @@ class SetupIn(BaseModel):
|
||||
|
||||
|
||||
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")
|
||||
@@ -41,6 +50,16 @@ def me(user: User = Depends(current_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")
|
||||
def needs_setup(session: Session = Depends(get_session)):
|
||||
"""Vor Login prüfen ob noch kein User existiert (erstes Deployment)."""
|
||||
|
||||
@@ -30,6 +30,7 @@ async def create_job(
|
||||
background: BackgroundTasks,
|
||||
audio: UploadFile = File(...),
|
||||
title: str = Form(""),
|
||||
profile: str = Form(""),
|
||||
session: Session = Depends(get_session),
|
||||
user: User = Depends(current_user),
|
||||
):
|
||||
@@ -54,6 +55,7 @@ async def create_job(
|
||||
|
||||
job = Job(
|
||||
owner_id=user.id,
|
||||
profile=(profile.strip() or user.default_profile or "meeting"),
|
||||
filename=safe,
|
||||
original_name=audio.filename,
|
||||
title=title.strip(),
|
||||
@@ -114,6 +116,7 @@ def _job_dict(j: Job, user: User, owner_username: str | None = None) -> dict:
|
||||
"status": j.status,
|
||||
"progress": j.progress,
|
||||
"error": j.error,
|
||||
"profile": j.profile or "meeting",
|
||||
"created_at": j.created_at.isoformat(),
|
||||
"updated_at": j.updated_at.isoformat(),
|
||||
"has": {
|
||||
|
||||
@@ -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}
|
||||
@@ -14,6 +14,12 @@ class MacClient:
|
||||
r.raise_for_status()
|
||||
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 with httpx.AsyncClient(timeout=self.timeout) as c:
|
||||
with audio_path.open("rb") as f:
|
||||
@@ -23,26 +29,29 @@ class MacClient:
|
||||
r.raise_for_status()
|
||||
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:
|
||||
r = await c.post(
|
||||
f"{self.base_url}/api/summarize",
|
||||
json={"transcript": transcript, "title": title},
|
||||
json={"transcript": transcript, "title": title, "profile": profile},
|
||||
)
|
||||
r.raise_for_status()
|
||||
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:
|
||||
r = await c.post(
|
||||
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()
|
||||
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:
|
||||
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()
|
||||
return r.content
|
||||
|
||||
@@ -28,6 +28,18 @@ def run_migrations() -> None:
|
||||
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)"))
|
||||
|
||||
# 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:
|
||||
"""Weist alle Jobs ohne owner_id dem angegebenen User zu. Gibt Anzahl zurück."""
|
||||
|
||||
@@ -11,6 +11,7 @@ from starlette.types import Scope
|
||||
from app.api.admin import router as admin_router
|
||||
from app.api.auth import router as auth_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.db import init_db
|
||||
from app.core.mac_client import MacClient
|
||||
@@ -56,6 +57,7 @@ app.add_middleware(
|
||||
|
||||
app.include_router(auth_router)
|
||||
app.include_router(admin_router)
|
||||
app.include_router(profiles_router)
|
||||
app.include_router(jobs_router)
|
||||
|
||||
STATIC_DIR = Path(__file__).resolve().parent / "static"
|
||||
|
||||
@@ -16,6 +16,7 @@ class JobStatus:
|
||||
class Job(SQLModel, table=True):
|
||||
id: Optional[int] = Field(default=None, primary_key=True)
|
||||
owner_id: Optional[int] = Field(default=None, foreign_key="user.id", index=True)
|
||||
profile: str = Field(default="meeting")
|
||||
filename: str
|
||||
original_name: str
|
||||
title: str = ""
|
||||
|
||||
@@ -9,4 +9,5 @@ class User(SQLModel, table=True):
|
||||
username: str = Field(index=True, unique=True)
|
||||
password_hash: str
|
||||
is_admin: bool = Field(default=False)
|
||||
default_profile: str = Field(default="meeting")
|
||||
created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
|
||||
|
||||
@@ -36,13 +36,14 @@ async def run_pipeline(job_id: int) -> None:
|
||||
return
|
||||
audio_path = settings.upload_dir / job.filename
|
||||
title = job.title or audio_path.stem
|
||||
profile = job.profile or "meeting"
|
||||
|
||||
job_dir = settings.result_dir / str(job_id)
|
||||
job_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
try:
|
||||
_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")
|
||||
transcript_path = job_dir / "transcript.json"
|
||||
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)
|
||||
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.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, status=JobStatus.PROTOCOLLING, progress=75)
|
||||
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.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, status=JobStatus.EXPORTING, progress=90)
|
||||
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.write_bytes(docx_bytes)
|
||||
_update(job_id, docx_path=str(docx_path), status=JobStatus.DONE, progress=100)
|
||||
|
||||
@@ -31,8 +31,17 @@ const newIsAdmin = document.getElementById("new-is-admin");
|
||||
const userMsg = document.getElementById("user-msg");
|
||||
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 availableProfiles = []; // [{ name, display_name, language }, ...]
|
||||
|
||||
const DOWNLOADS = [
|
||||
{ kind: "docx", label: "DOCX", title: "Sitzungsprotokoll als Word-Dokument" },
|
||||
@@ -114,10 +123,52 @@ function enterApp() {
|
||||
userName.textContent = currentUser.username;
|
||||
userAdminBadge.classList.toggle("hidden", !currentUser.is_admin);
|
||||
adminCard.classList.toggle("hidden", !currentUser.is_admin);
|
||||
loadProfiles().then(applyProfileUI);
|
||||
loadJobs();
|
||||
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) => {
|
||||
ev.preventDefault();
|
||||
authSubmit.disabled = true;
|
||||
@@ -201,6 +252,12 @@ function row(job) {
|
||||
? `<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 = `
|
||||
<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>` : ""}
|
||||
@@ -208,7 +265,7 @@ function row(job) {
|
||||
|
||||
tr.innerHTML = `
|
||||
<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-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>
|
||||
@@ -240,6 +297,8 @@ form.addEventListener("submit", (ev) => {
|
||||
const fd = new FormData();
|
||||
fd.append("audio", audioInput.files[0]);
|
||||
fd.append("title", titleInput.value);
|
||||
// Profile mitgeben — leer = Server nimmt user.default_profile
|
||||
fd.append("profile", profileSelect.value || "");
|
||||
|
||||
submitBtn.disabled = true;
|
||||
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 ──────────────────────────────────────────────────────────────────
|
||||
bootstrap();
|
||||
checkMac();
|
||||
|
||||
@@ -50,9 +50,14 @@
|
||||
<h2>Neue Aufnahme hochladen</h2>
|
||||
<form id="upload-form">
|
||||
<label>
|
||||
Sitzungstitel (optional)
|
||||
Titel (optional)
|
||||
<input type="text" name="title" id="title" placeholder="z. B. Monatsbesprechung" />
|
||||
</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>
|
||||
Audiodatei (MP3, WAV, M4A, MP4, OGG, FLAC)
|
||||
<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>
|
||||
</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">
|
||||
<h2>Jobs</h2>
|
||||
<table id="jobs-table">
|
||||
|
||||
@@ -252,3 +252,37 @@ footer { text-align: center; padding: 24px 16px 32px; }
|
||||
@media (max-width: 700px) {
|
||||
.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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user