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 ? `
${escapeHtml(job.error)}
` : ""} @@ -208,7 +265,7 @@ function row(job) { tr.innerHTML = ` #${job.id} - ${escapeHtml(title)}
${escapeHtml(job.original_name)}${ownerLine} + ${escapeHtml(title)} ${profileTag}
${escapeHtml(job.original_name)}${ownerLine} ${statusCell}
${job.progress}% ${fmtDate(job.updated_at)} @@ -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(); diff --git a/lxc-frontend/app/static/index.html b/lxc-frontend/app/static/index.html index 67ae445..bf4fe5c 100644 --- a/lxc-frontend/app/static/index.html +++ b/lxc-frontend/app/static/index.html @@ -50,9 +50,14 @@

Neue Aufnahme hochladen

+