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:
parent
98cf9eb47e
commit
3a9881d36f
@ -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)."""
|
||||||
|
|||||||
@ -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": {
|
||||||
|
|||||||
38
lxc-frontend/app/api/profiles.py
Normal file
38
lxc-frontend/app/api/profiles.py
Normal 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}
|
||||||
@ -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
|
||||||
|
|||||||
@ -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."""
|
||||||
|
|||||||
@ -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"
|
||||||
|
|||||||
@ -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 = ""
|
||||||
|
|||||||
@ -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))
|
||||||
|
|||||||
@ -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)
|
||||||
|
|||||||
@ -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();
|
||||||
|
|||||||
@ -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">
|
||||||
|
|||||||
@ -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;
|
||||||
|
}
|
||||||
|
|||||||
@ -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()
|
|
||||||
|
|||||||
@ -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:
|
||||||
|
|||||||
84
mac-worker/app/core/profiles.py
Normal file
84
mac-worker/app/core/profiles.py
Normal 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()
|
||||||
@ -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",
|
||||||
|
|||||||
63
mac-worker/profiles/meeting.yaml
Normal file
63
mac-worker/profiles/meeting.yaml
Normal 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 }
|
||||||
@ -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"
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user