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
+81 -2
View File
@@ -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();
+18 -1
View File
@@ -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">
+34
View File
@@ -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;
}