diff --git a/lxc-frontend/app/api/auth.py b/lxc-frontend/app/api/auth.py
index fe016d0..4f88050 100644
--- a/lxc-frontend/app/api/auth.py
+++ b/lxc-frontend/app/api/auth.py
@@ -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)."""
diff --git a/lxc-frontend/app/api/jobs.py b/lxc-frontend/app/api/jobs.py
index 26ff050..8dd3354 100644
--- a/lxc-frontend/app/api/jobs.py
+++ b/lxc-frontend/app/api/jobs.py
@@ -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": {
diff --git a/lxc-frontend/app/api/profiles.py b/lxc-frontend/app/api/profiles.py
new file mode 100644
index 0000000..ce3ce47
--- /dev/null
+++ b/lxc-frontend/app/api/profiles.py
@@ -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}
diff --git a/lxc-frontend/app/core/mac_client.py b/lxc-frontend/app/core/mac_client.py
index 96196da..e0336fc 100644
--- a/lxc-frontend/app/core/mac_client.py
+++ b/lxc-frontend/app/core/mac_client.py
@@ -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
diff --git a/lxc-frontend/app/core/migrate.py b/lxc-frontend/app/core/migrate.py
index 1119145..cd9f357 100644
--- a/lxc-frontend/app/core/migrate.py
+++ b/lxc-frontend/app/core/migrate.py
@@ -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."""
diff --git a/lxc-frontend/app/main.py b/lxc-frontend/app/main.py
index 6a4001e..c0b19b1 100644
--- a/lxc-frontend/app/main.py
+++ b/lxc-frontend/app/main.py
@@ -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"
diff --git a/lxc-frontend/app/models/job.py b/lxc-frontend/app/models/job.py
index 3249b6f..2e79eec 100644
--- a/lxc-frontend/app/models/job.py
+++ b/lxc-frontend/app/models/job.py
@@ -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 = ""
diff --git a/lxc-frontend/app/models/user.py b/lxc-frontend/app/models/user.py
index e305c93..0539d02 100644
--- a/lxc-frontend/app/models/user.py
+++ b/lxc-frontend/app/models/user.py
@@ -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))
diff --git a/lxc-frontend/app/services/pipeline.py b/lxc-frontend/app/services/pipeline.py
index e29039a..bc523f6 100644
--- a/lxc-frontend/app/services/pipeline.py
+++ b/lxc-frontend/app/services/pipeline.py
@@ -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)
diff --git a/lxc-frontend/app/static/app.js b/lxc-frontend/app/static/app.js
index d17da72..e7696c3 100644
--- a/lxc-frontend/app/static/app.js
+++ b/lxc-frontend/app/static/app.js
@@ -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) {
? `
von ${escapeHtml(job.owner_username)}`
: "";
+ // 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
+ ? `${escapeHtml(profileLabelOf(job.profile || "meeting"))}`
+ : "";
+
const statusCell = `
${escapeHtml(job.status)}
${job.error ? `