feat: voice-agent MVP — LXC web frontend + Mac AI worker

LXC-Frontend (FastAPI + HTML/JS):
- Audio-Upload (MP3/WAV/M4A/MP4/OGG/FLAC, max. 500 MB)
- SQLite Job-Store, BackgroundTask-Pipeline
- Job-Liste mit Live-Status, Downloads (DOCX + JSON)
- Mac-Health-Indicator im UI

Mac-Worker (FastAPI):
- /api/transcribe (lightning-whisper-mlx | faster-whisper | mock)
- /api/summarize + /api/protocol via Ollama (llama3.1:8b)
- /api/export/docx via python-docx

Deploy:
- systemd-Service, Nginx Reverse-Proxy
- deploy/install.sh: idempotentes LXC-Setup

Doku: README.md, lxc-frontend/README.md, mac-worker/README.md
This commit is contained in:
root 2026-05-13 15:33:53 +00:00
parent 32ab12014f
commit 5321c8602e
35 changed files with 1820 additions and 4 deletions

28
.gitignore vendored
View File

@ -1,11 +1,31 @@
# Toolkit / Projekt-Konfig
config/project.env
# Python
__pycache__/
*.py[cod]
*.egg-info/
.venv/
venv/
.pytest_cache/
.mypy_cache/
.ruff_cache/
# App-Daten
.env
*.sqlite
*.sqlite3
*.db
# Node (Toolkit)
node_modules/ node_modules/
dist/ dist/
build/ build/
.env
config/project.env
*.log
.DS_Store
coverage/ coverage/
.nyc_output/ .nyc_output/
# Sonstiges
*.log
.DS_Store
*.pem *.pem
*.key *.key

90
README.md Normal file
View File

@ -0,0 +1,90 @@
# Voice-Agent
Self-hosted Transkriptions- und Protokollsystem für Sitzungsaufzeichnungen — vollständig lokal, DSGVO-konform.
> Vollständiges Lastenheft: siehe [`need.md`](need.md)
## Architektur
```
Benutzer
▼ HTTP (Upload)
┌─────────────────────────────┐ ┌──────────────────────────────┐
│ LXC-Container │ HTTP │ Mac (Apple Silicon) │
│ - FastAPI + HTML/JS UI │ ──────► │ - FastAPI Worker │
│ - SQLite Job-Store │ │ - lightning-whisper-mlx │
│ - Result-Verzeichnis │ ◄────── │ - Ollama (llama3.1:8b) │
│ - Nginx Reverse Proxy │ │ - python-docx │
└─────────────────────────────┘ └──────────────────────────────┘
```
- **LXC**: keine AI-Verarbeitung — nur Uploads, Job-Tracking, Auslieferung.
- **Mac**: Stateless API. Erhält Audio/Text, gibt Transkripte / Zusammenfassungen / Protokolle / DOCX zurück.
## MVP-Funktionsumfang (v0.1)
- [x] Audio-Upload (MP3, WAV, M4A, MP4, OGG, FLAC, max. 500 MB)
- [x] Whisper-Transkription auf dem Mac
- [x] Ollama-Zusammenfassung (Beschlüsse, Aufgaben, Teilnehmer)
- [x] Strukturiertes Sitzungsprotokoll
- [x] DOCX-Export
- [ ] Speaker-Diarization (Prio 2)
- [ ] Auth / Benutzerverwaltung (Prio 2)
- [ ] PDF-Export (Prio 2)
## Schnellstart
### 1. Mac einrichten
Siehe [`mac-worker/README.md`](mac-worker/README.md).
### 2. LXC provisionieren
Mit dem Toolkit-Skill `/proxmox-lxc` (oder manuell). Anschließend auf dem LXC:
```bash
sudo bash <(curl -sSL https://git.cynfo.net/christian/voice-agent/raw/branch/main/deploy/install.sh)
```
oder nach `git clone`:
```bash
cd /var/www/voice-agent
sudo ./deploy/install.sh
```
### 3. Konfigurieren
`/var/www/voice-agent/lxc-frontend/.env` öffnen und `MAC_API_URL` auf die LAN-IP des Macs setzen, dann:
```bash
sudo systemctl restart voice-agent
```
### 4. Verwenden
Browser auf `http://<LXC-IP>/` öffnen, Audio hochladen, fertig.
## Verzeichnisstruktur
```
voice-agent/
├── lxc-frontend/ # FastAPI Web-App (läuft im LXC)
│ ├── app/
│ ├── requirements.txt
│ └── .env.example
├── mac-worker/ # FastAPI AI-Worker (läuft auf dem Mac)
│ ├── app/
│ ├── requirements.txt
│ └── .env.example
├── deploy/ # systemd / nginx / install.sh
├── config/ # Toolkit-Konfiguration (project.env)
├── .claude/ # KI-Agenten Skills
├── need.md # Lastenheft
└── README.md
```
## Sicherheit
- Vollständig lokaler Betrieb. Keine Cloud-Calls.
- HTTPS via Reverse-Proxy ist Aufgabe der Infrastruktur (Let's Encrypt o. Ä.).
- Mac-Worker hat **keine** Auth — Betrieb nur im internen Netz.
- Uploads / Ergebnisse unter `/var/lib/voice-agent/` — bei Bedarf Backup einplanen.

61
deploy/install.sh Executable file
View File

@ -0,0 +1,61 @@
#!/usr/bin/env bash
# Wird im LXC als deploy/root ausgeführt um die App einzurichten.
# Idempotent: kann wiederholt laufen.
set -euo pipefail
REPO_URL="${REPO_URL:-https://git.cynfo.net/christian/voice-agent.git}"
APP_DIR="/var/www/voice-agent"
APP_USER="deploy"
echo "[1/7] System-Pakete installieren"
apt-get update -y
apt-get install -y --no-install-recommends \
git python3 python3-venv python3-pip nginx ca-certificates curl ffmpeg
echo "[2/7] Deploy-User sicherstellen"
id "$APP_USER" >/dev/null 2>&1 || useradd -m -s /bin/bash "$APP_USER"
mkdir -p "$APP_DIR"
chown -R "$APP_USER:$APP_USER" "$APP_DIR"
echo "[3/7] Repo klonen/aktualisieren"
if [ -d "$APP_DIR/.git" ]; then
sudo -u "$APP_USER" git -C "$APP_DIR" fetch --all --prune
sudo -u "$APP_USER" git -C "$APP_DIR" reset --hard origin/main
else
sudo -u "$APP_USER" git clone "$REPO_URL" "$APP_DIR"
fi
echo "[4/7] Python-venv + Dependencies"
cd "$APP_DIR/lxc-frontend"
sudo -u "$APP_USER" python3 -m venv .venv
sudo -u "$APP_USER" .venv/bin/pip install --upgrade pip
sudo -u "$APP_USER" .venv/bin/pip install -r requirements.txt
echo "[5/7] .env anlegen falls fehlt"
if [ ! -f "$APP_DIR/lxc-frontend/.env" ]; then
cp "$APP_DIR/lxc-frontend/.env.example" "$APP_DIR/lxc-frontend/.env"
chown "$APP_USER:$APP_USER" "$APP_DIR/lxc-frontend/.env"
fi
mkdir -p /var/lib/voice-agent/uploads /var/lib/voice-agent/results
chown -R "$APP_USER:$APP_USER" /var/lib/voice-agent
echo "[6/7] systemd-Service installieren"
cp "$APP_DIR/deploy/systemd/voice-agent.service" /etc/systemd/system/voice-agent.service
systemctl daemon-reload
systemctl enable voice-agent
systemctl restart voice-agent
echo "[7/7] Nginx-Reverse-Proxy"
cp "$APP_DIR/deploy/nginx/voice-agent.conf" /etc/nginx/sites-available/voice-agent
ln -sf /etc/nginx/sites-available/voice-agent /etc/nginx/sites-enabled/voice-agent
rm -f /etc/nginx/sites-enabled/default
nginx -t
systemctl reload nginx
echo
echo "Fertig. Health-Check:"
echo " curl -s http://localhost/health"
echo
echo "Nach erstem Start unbedingt MAC_API_URL in $APP_DIR/lxc-frontend/.env setzen und Service neu starten:"
echo " sudo systemctl restart voice-agent"

View File

@ -0,0 +1,20 @@
server {
listen 80 default_server;
server_name _;
# Upload-Limit muss zu MAX_UPLOAD_MB in .env passen
client_max_body_size 600M;
# Lange Verarbeitung — Timeouts hoch
proxy_read_timeout 1800;
proxy_send_timeout 1800;
location / {
proxy_pass http://127.0.0.1:8000;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}

View File

@ -0,0 +1,19 @@
[Unit]
Description=Voice-Agent FastAPI (LXC-Frontend)
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
User=deploy
Group=deploy
WorkingDirectory=/var/www/voice-agent/lxc-frontend
EnvironmentFile=/var/www/voice-agent/lxc-frontend/.env
ExecStart=/var/www/voice-agent/lxc-frontend/.venv/bin/uvicorn app.main:app --host 0.0.0.0 --port 8000 --workers 2
Restart=on-failure
RestartSec=3
StandardOutput=journal
StandardError=journal
[Install]
WantedBy=multi-user.target

17
lxc-frontend/.env.example Normal file
View File

@ -0,0 +1,17 @@
# LXC-Frontend Konfiguration
APP_NAME="Voice-Agent"
APP_HOST=0.0.0.0
APP_PORT=8000
# Verzeichnisse (werden bei Start angelegt)
UPLOAD_DIR=/var/lib/voice-agent/uploads
RESULT_DIR=/var/lib/voice-agent/results
DB_URL=sqlite:////var/lib/voice-agent/voice-agent.db
# Mac-Worker API (im LAN erreichbar)
MAC_API_URL=http://192.168.85.10:8080
MAC_API_TIMEOUT=1800
# Limits
MAX_UPLOAD_MB=500
ALLOWED_EXTS=mp3,wav,m4a,mp4,ogg,flac

44
lxc-frontend/README.md Normal file
View File

@ -0,0 +1,44 @@
# Voice-Agent — LXC-Frontend
FastAPI + statisches HTML/JS. Läuft im LXC-Container, stellt die Weboberfläche, verwaltet Jobs (SQLite) und ruft den Mac-Worker per HTTP an. Macht selbst **keine** AI-Verarbeitung.
## Lokal starten (Entwicklung)
```bash
cd lxc-frontend
python3 -m venv .venv
source .venv/bin/activate
pip install -r requirements.txt
cp .env.example .env
# Für lokalen Test ohne Mac kann der Mac-Worker mit WHISPER_ENGINE=mock laufen
uvicorn app.main:app --reload --port 8000
```
Öffnen: http://localhost:8000
## Auf LXC deployen
`deploy/install.sh` macht alles: Repo klonen, venv anlegen, systemd-Service und Nginx einrichten. Siehe Haupt-`README.md`.
## API
| Methode | Pfad | Zweck |
|---|---|---|
| GET | `/` | Web-UI |
| GET | `/health` | LXC-Health |
| GET | `/api/mac/health` | Mac-Worker-Erreichbarkeit |
| POST | `/api/jobs` | multipart: `audio`, `title` → Job anlegen |
| GET | `/api/jobs` | Liste aller Jobs |
| GET | `/api/jobs/{id}` | Job-Detail |
| GET | `/api/jobs/{id}/download/{kind}` | `kind``docx`, `transcript`, `summary`, `protocol` |
## Datenfluss
```
Upload → SQLite-Job → BackgroundTask
→ Mac: /api/transcribe (multipart)
→ Mac: /api/summarize (JSON)
→ Mac: /api/protocol (JSON)
→ Mac: /api/export/docx (JSON) → DOCX
→ Ergebnisse landen in /var/lib/voice-agent/results/{job_id}/
```

View File

View File

View File

@ -0,0 +1,105 @@
import secrets
from datetime import datetime, timezone
from pathlib import Path
import aiofiles
from fastapi import APIRouter, BackgroundTasks, Depends, File, Form, HTTPException, UploadFile
from fastapi.responses import FileResponse
from sqlmodel import Session, select
from app.core.config import settings
from app.core.db import get_session
from app.models.job import Job, JobStatus
from app.services.pipeline import run_pipeline
router = APIRouter(prefix="/api/jobs", tags=["jobs"])
MAX_BYTES = lambda: settings.max_upload_mb * 1024 * 1024
@router.post("")
async def create_job(
background: BackgroundTasks,
audio: UploadFile = File(...),
title: str = Form(""),
session: Session = Depends(get_session),
):
if not audio.filename:
raise HTTPException(400, "Dateiname fehlt")
ext = Path(audio.filename).suffix.lower().lstrip(".")
if ext not in settings.allowed_ext_set:
raise HTTPException(400, f"Format '{ext}' nicht erlaubt. Erlaubt: {sorted(settings.allowed_ext_set)}")
safe = secrets.token_hex(8) + "." + ext
dest = settings.upload_dir / safe
size = 0
limit = MAX_BYTES()
async with aiofiles.open(dest, "wb") as f:
while chunk := await audio.read(1024 * 1024):
size += len(chunk)
if size > limit:
await f.close()
dest.unlink(missing_ok=True)
raise HTTPException(413, f"Datei zu groß (max. {settings.max_upload_mb} MB)")
await f.write(chunk)
job = Job(filename=safe, original_name=audio.filename, title=title.strip())
session.add(job)
session.commit()
session.refresh(job)
background.add_task(run_pipeline, job.id)
return _job_dict(job)
@router.get("")
def list_jobs(session: Session = Depends(get_session)):
jobs = session.exec(select(Job).order_by(Job.created_at.desc())).all()
return [_job_dict(j) for j in jobs]
@router.get("/{job_id}")
def get_job(job_id: int, session: Session = Depends(get_session)):
job = session.get(Job, job_id)
if not job:
raise HTTPException(404, "Job nicht gefunden")
return _job_dict(job)
@router.get("/{job_id}/download/{kind}")
def download(job_id: int, kind: str, session: Session = Depends(get_session)):
job = session.get(Job, job_id)
if not job:
raise HTTPException(404, "Job nicht gefunden")
mapping = {
"docx": (job.docx_path, "application/vnd.openxmlformats-officedocument.wordprocessingml.document", "protocol.docx"),
"transcript": (job.transcript_path, "application/json", "transcript.json"),
"summary": (job.summary_path, "application/json", "summary.json"),
"protocol": (job.protocol_path, "application/json", "protocol.json"),
}
if kind not in mapping:
raise HTTPException(400, "Unbekannter Download-Typ")
path, media, name = mapping[kind]
if not path or not Path(path).exists():
raise HTTPException(404, "Datei noch nicht verfügbar")
base = (job.title or Path(job.original_name).stem).replace("/", "_")
return FileResponse(path, media_type=media, filename=f"{base}-{name}")
def _job_dict(j: Job) -> dict:
return {
"id": j.id,
"title": j.title,
"original_name": j.original_name,
"status": j.status,
"progress": j.progress,
"error": j.error,
"created_at": j.created_at.isoformat(),
"updated_at": j.updated_at.isoformat(),
"has": {
"transcript": bool(j.transcript_path),
"summary": bool(j.summary_path),
"protocol": bool(j.protocol_path),
"docx": bool(j.docx_path),
},
}

View File

View File

@ -0,0 +1,29 @@
from pathlib import Path
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8", extra="ignore")
app_name: str = "Voice-Agent"
app_host: str = "0.0.0.0"
app_port: int = 8000
upload_dir: Path = Path("/var/lib/voice-agent/uploads")
result_dir: Path = Path("/var/lib/voice-agent/results")
db_url: str = "sqlite:////var/lib/voice-agent/voice-agent.db"
mac_api_url: str = "http://192.168.85.10:8080"
mac_api_timeout: int = 1800
max_upload_mb: int = 500
allowed_exts: str = "mp3,wav,m4a,mp4,ogg,flac"
@property
def allowed_ext_set(self) -> set[str]:
return {e.strip().lower().lstrip(".") for e in self.allowed_exts.split(",") if e.strip()}
settings = Settings()
settings.upload_dir.mkdir(parents=True, exist_ok=True)
settings.result_dir.mkdir(parents=True, exist_ok=True)

View File

@ -0,0 +1,14 @@
from sqlmodel import SQLModel, create_engine, Session
from .config import settings
engine = create_engine(settings.db_url, connect_args={"check_same_thread": False})
def init_db() -> None:
from app.models.job import Job # noqa: F401
SQLModel.metadata.create_all(engine)
def get_session():
with Session(engine) as session:
yield session

View File

@ -0,0 +1,48 @@
from pathlib import Path
import httpx
from .config import settings
class MacClient:
def __init__(self, base_url: str | None = None, timeout: int | None = None):
self.base_url = (base_url or settings.mac_api_url).rstrip("/")
self.timeout = timeout or settings.mac_api_timeout
async def health(self) -> dict:
async with httpx.AsyncClient(timeout=10) as c:
r = await c.get(f"{self.base_url}/health")
r.raise_for_status()
return r.json()
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:
files = {"audio": (audio_path.name, f, "application/octet-stream")}
data = {"language": language}
r = await c.post(f"{self.base_url}/api/transcribe", files=files, data=data)
r.raise_for_status()
return r.json()
async def summarize(self, transcript: str, title: str = "") -> 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},
)
r.raise_for_status()
return r.json()
async def protocol(self, transcript: str, summary: dict, title: str = "") -> 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},
)
r.raise_for_status()
return r.json()
async def export_docx(self, protocol: dict) -> bytes:
async with httpx.AsyncClient(timeout=self.timeout) as c:
r = await c.post(f"{self.base_url}/api/export/docx", json=protocol)
r.raise_for_status()
return r.content

51
lxc-frontend/app/main.py Normal file
View File

@ -0,0 +1,51 @@
import logging
from contextlib import asynccontextmanager
from pathlib import Path
from fastapi import FastAPI
from fastapi.responses import FileResponse, JSONResponse
from fastapi.staticfiles import StaticFiles
from app.api.jobs import router as jobs_router
from app.core.config import settings
from app.core.db import init_db
from app.core.mac_client import MacClient
logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(name)s: %(message)s")
log = logging.getLogger("voice-agent")
@asynccontextmanager
async def lifespan(app: FastAPI):
init_db()
log.info("DB initialized at %s", settings.db_url)
log.info("Upload dir: %s", settings.upload_dir)
log.info("Result dir: %s", settings.result_dir)
log.info("Mac API: %s", settings.mac_api_url)
yield
app = FastAPI(title=settings.app_name, lifespan=lifespan)
app.include_router(jobs_router)
STATIC_DIR = Path(__file__).resolve().parent / "static"
app.mount("/static", StaticFiles(directory=STATIC_DIR), name="static")
@app.get("/")
def index():
return FileResponse(STATIC_DIR / "index.html")
@app.get("/health")
def health():
return {"status": "ok", "service": settings.app_name}
@app.get("/api/mac/health")
async def mac_health():
try:
data = await MacClient().health()
return {"reachable": True, "mac": data}
except Exception as e: # noqa: BLE001
return JSONResponse(status_code=502, content={"reachable": False, "error": str(e)})

View File

View File

@ -0,0 +1,29 @@
from datetime import datetime, timezone
from typing import Optional
from sqlmodel import SQLModel, Field
class JobStatus:
QUEUED = "queued"
TRANSCRIBING = "transcribing"
SUMMARIZING = "summarizing"
PROTOCOLLING = "protocolling"
EXPORTING = "exporting"
DONE = "done"
FAILED = "failed"
class Job(SQLModel, table=True):
id: Optional[int] = Field(default=None, primary_key=True)
filename: str
original_name: str
title: str = ""
status: str = JobStatus.QUEUED
progress: int = 0
error: str = ""
transcript_path: str = ""
summary_path: str = ""
protocol_path: str = ""
docx_path: str = ""
created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
updated_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))

View File

View File

@ -0,0 +1,76 @@
import json
import logging
from datetime import datetime, timezone
from pathlib import Path
from sqlmodel import Session
from app.core.config import settings
from app.core.db import engine
from app.core.mac_client import MacClient
from app.models.job import Job, JobStatus
log = logging.getLogger("voice-agent.pipeline")
def _update(job_id: int, **fields) -> None:
with Session(engine) as s:
job = s.get(Job, job_id)
if not job:
return
for k, v in fields.items():
setattr(job, k, v)
job.updated_at = datetime.now(timezone.utc)
s.add(job)
s.commit()
async def run_pipeline(job_id: int) -> None:
"""Run the full pipeline: transcribe → summarize → protocol → DOCX."""
client = MacClient()
with Session(engine) as s:
job = s.get(Job, job_id)
if not job:
log.error("Job %s not found", job_id)
return
audio_path = settings.upload_dir / job.filename
title = job.title or audio_path.stem
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)
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")
_update(job_id, transcript_path=str(transcript_path), progress=45)
transcript_text = tx.get("text") or "\n".join(s.get("text", "") for s in tx.get("segments", []))
_update(job_id, status=JobStatus.SUMMARIZING, progress=55)
log.info("[job %s] summarize", job_id)
summary = await client.summarize(transcript_text, title=title)
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_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_path = job_dir / "protocol.docx"
docx_path.write_bytes(docx_bytes)
_update(job_id, docx_path=str(docx_path), status=JobStatus.DONE, progress=100)
log.info("[job %s] done", job_id)
except Exception as e: # noqa: BLE001
log.exception("[job %s] failed", job_id)
_update(job_id, status=JobStatus.FAILED, error=f"{type(e).__name__}: {e}")

View File

@ -0,0 +1,125 @@
const form = document.getElementById("upload-form");
const titleInput = document.getElementById("title");
const audioInput = document.getElementById("audio");
const submitBtn = document.getElementById("submit-btn");
const uploadProgress = document.getElementById("upload-progress");
const uploadBar = uploadProgress.querySelector(".bar-fill");
const uploadMsg = document.getElementById("upload-msg");
const jobsBody = document.getElementById("jobs-body");
const macStatus = document.getElementById("mac-status");
async function checkMac() {
try {
const r = await fetch("/api/mac/health");
const j = await r.json();
if (r.ok && j.reachable) {
macStatus.textContent = "Mac-Backend: online";
macStatus.classList.add("ok");
macStatus.classList.remove("err");
} else {
macStatus.textContent = "Mac-Backend: nicht erreichbar";
macStatus.classList.add("err");
macStatus.classList.remove("ok");
}
} catch {
macStatus.textContent = "Mac-Backend: Fehler";
macStatus.classList.add("err");
}
}
function fmtDate(s) {
const d = new Date(s);
return d.toLocaleString("de-DE", { dateStyle: "short", timeStyle: "medium" });
}
function row(job) {
const tr = document.createElement("tr");
const title = job.title || job.original_name;
const dl = (kind, label) => {
const a = document.createElement("a");
a.textContent = label;
a.href = `/api/jobs/${job.id}/download/${kind}`;
if (!job.has[kind]) a.classList.add("disabled");
return a;
};
const dlCell = document.createElement("td");
dlCell.className = "dl";
["docx", "protocol", "summary", "transcript"].forEach((k) =>
dlCell.appendChild(dl(k, k === "docx" ? "DOCX" : k.charAt(0).toUpperCase() + k.slice(1) + " (JSON)"))
);
tr.innerHTML = `
<td>#${job.id}</td>
<td>${title}<br><small class="muted">${job.original_name}</small></td>
<td><span class="status-pill status-${job.status}">${job.status}${job.error ? " — " + job.error : ""}</span></td>
<td><div class="bar"><div class="bar-fill" style="width:${job.progress}%"></div></div><small class="muted">${job.progress}%</small></td>
<td><small class="muted">${fmtDate(job.updated_at)}</small></td>
`;
tr.appendChild(dlCell);
return tr;
}
async function loadJobs() {
try {
const r = await fetch("/api/jobs");
const jobs = await r.json();
jobsBody.innerHTML = "";
if (!jobs.length) {
jobsBody.innerHTML = '<tr><td colspan="6" class="muted">Noch keine Jobs.</td></tr>';
return;
}
jobs.forEach((j) => jobsBody.appendChild(row(j)));
} catch (e) {
console.error(e);
}
}
form.addEventListener("submit", (ev) => {
ev.preventDefault();
if (!audioInput.files.length) return;
const fd = new FormData();
fd.append("audio", audioInput.files[0]);
fd.append("title", titleInput.value);
submitBtn.disabled = true;
uploadProgress.classList.remove("hidden");
uploadBar.style.width = "0%";
uploadMsg.textContent = "";
uploadMsg.className = "msg";
const xhr = new XMLHttpRequest();
xhr.open("POST", "/api/jobs");
xhr.upload.onprogress = (e) => {
if (e.lengthComputable) {
const p = Math.round((e.loaded / e.total) * 100);
uploadBar.style.width = p + "%";
}
};
xhr.onload = () => {
submitBtn.disabled = false;
uploadProgress.classList.add("hidden");
if (xhr.status >= 200 && xhr.status < 300) {
const j = JSON.parse(xhr.responseText);
uploadMsg.textContent = `Job #${j.id} erstellt. Verarbeitung läuft …`;
uploadMsg.className = "msg ok";
form.reset();
loadJobs();
} else {
let err = "Upload fehlgeschlagen";
try { err = JSON.parse(xhr.responseText).detail || err; } catch {}
uploadMsg.textContent = err;
uploadMsg.className = "msg err";
}
};
xhr.onerror = () => {
submitBtn.disabled = false;
uploadProgress.classList.add("hidden");
uploadMsg.textContent = "Netzwerkfehler beim Upload";
uploadMsg.className = "msg err";
};
xhr.send(fd);
});
checkMac();
loadJobs();
setInterval(loadJobs, 3000);
setInterval(checkMac, 15000);

View File

@ -0,0 +1,58 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Voice-Agent — Transkription</title>
<link rel="stylesheet" href="/static/style.css" />
</head>
<body>
<header>
<h1>Voice-Agent</h1>
<p class="sub">Sitzungsaufnahmen automatisch transkribieren und protokollieren</p>
<div id="mac-status" class="badge">Mac-Backend: prüfe …</div>
</header>
<main>
<section class="card">
<h2>Neue Aufnahme hochladen</h2>
<form id="upload-form">
<label>
Sitzungstitel (optional)
<input type="text" name="title" id="title" placeholder="z. B. Monatsbesprechung 13.05.2026" />
</label>
<label>
Audiodatei (MP3, WAV, M4A, MP4, OGG, FLAC)
<input type="file" name="audio" id="audio" accept=".mp3,.wav,.m4a,.mp4,.ogg,.flac" required />
</label>
<button type="submit" id="submit-btn">Hochladen &amp; verarbeiten</button>
</form>
<div id="upload-progress" class="hidden">
<div class="bar"><div class="bar-fill"></div></div>
<small class="muted">Upload läuft …</small>
</div>
<p id="upload-msg" class="msg"></p>
</section>
<section class="card">
<h2>Jobs</h2>
<table id="jobs-table">
<thead>
<tr>
<th>#</th><th>Titel / Datei</th><th>Status</th><th>Fortschritt</th><th>Aktualisiert</th><th>Downloads</th>
</tr>
</thead>
<tbody id="jobs-body">
<tr><td colspan="6" class="muted">Noch keine Jobs.</td></tr>
</tbody>
</table>
</section>
</main>
<footer>
<small class="muted">DSGVO-konform, alles lokal verarbeitet.</small>
</footer>
<script src="/static/app.js"></script>
</body>
</html>

View File

@ -0,0 +1,117 @@
:root {
--bg: #0f1115;
--card: #181c24;
--border: #262b36;
--text: #e6e8eb;
--muted: #8b93a3;
--accent: #4f8cff;
--accent-hover: #3a78f0;
--ok: #2ecc71;
--warn: #f1c40f;
--err: #e74c3c;
}
* { box-sizing: border-box; }
body {
margin: 0;
background: var(--bg);
color: var(--text);
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
line-height: 1.5;
}
header {
padding: 32px 24px 16px;
text-align: center;
border-bottom: 1px solid var(--border);
}
header h1 { margin: 0 0 4px; font-size: 28px; letter-spacing: .3px; }
.sub { color: var(--muted); margin: 0 0 12px; }
.badge {
display: inline-block;
padding: 4px 10px;
border-radius: 999px;
background: var(--card);
border: 1px solid var(--border);
font-size: 12px;
color: var(--muted);
}
.badge.ok { color: var(--ok); border-color: rgba(46,204,113,.4); }
.badge.err { color: var(--err); border-color: rgba(231,76,60,.4); }
main {
max-width: 1000px;
margin: 24px auto;
padding: 0 16px;
display: grid;
gap: 20px;
}
.card {
background: var(--card);
border: 1px solid var(--border);
border-radius: 12px;
padding: 20px 22px;
}
.card h2 { margin-top: 0; font-size: 18px; }
form { display: grid; gap: 12px; }
label { display: grid; gap: 6px; font-size: 14px; color: var(--muted); }
input[type=text], input[type=file] {
background: #0f1219;
border: 1px solid var(--border);
color: var(--text);
padding: 10px 12px;
border-radius: 8px;
font-size: 14px;
}
button {
background: var(--accent);
color: white;
border: 0;
padding: 10px 16px;
border-radius: 8px;
font-size: 14px;
cursor: pointer;
justify-self: start;
}
button:hover { background: var(--accent-hover); }
button:disabled { opacity: .6; cursor: not-allowed; }
.hidden { display: none; }
.msg { min-height: 1em; margin: 8px 0 0; font-size: 13px; }
.msg.ok { color: var(--ok); }
.msg.err { color: var(--err); }
.muted { color: var(--muted); }
.bar {
width: 100%;
height: 8px;
background: #0f1219;
border-radius: 4px;
overflow: hidden;
margin-top: 6px;
}
.bar-fill {
height: 100%;
width: 0%;
background: var(--accent);
transition: width .25s ease;
}
table { width: 100%; border-collapse: collapse; font-size: 14px; }
th, td { padding: 10px 8px; text-align: left; border-bottom: 1px solid var(--border); }
th { color: var(--muted); font-weight: 600; font-size: 12px; text-transform: uppercase; letter-spacing: .5px; }
.status-pill {
display: inline-block;
padding: 2px 10px;
border-radius: 999px;
font-size: 12px;
border: 1px solid var(--border);
}
.status-queued { color: var(--muted); }
.status-transcribing, .status-summarizing, .status-protocolling, .status-exporting { color: var(--warn); border-color: rgba(241,196,15,.4); }
.status-done { color: var(--ok); border-color: rgba(46,204,113,.4); }
.status-failed { color: var(--err); border-color: rgba(231,76,60,.4); }
.dl a {
color: var(--accent);
text-decoration: none;
margin-right: 10px;
font-size: 13px;
}
.dl a:hover { text-decoration: underline; }
.dl .disabled { color: var(--muted); pointer-events: none; }
footer { text-align: center; padding: 24px 16px 32px; }

View File

@ -0,0 +1,8 @@
fastapi==0.115.5
uvicorn[standard]==0.32.1
python-multipart==0.0.18
sqlmodel==0.0.22
httpx==0.28.1
pydantic-settings==2.7.0
jinja2==3.1.4
aiofiles==24.1.0

17
mac-worker/.env.example Normal file
View File

@ -0,0 +1,17 @@
# Mac-Worker Konfiguration
APP_HOST=0.0.0.0
APP_PORT=8080
# Whisper-Engine: "mlx" (Apple Silicon), "faster" (CPU/CUDA), oder "mock" (Testbetrieb)
WHISPER_ENGINE=mlx
WHISPER_MODEL=large-v3
WHISPER_BATCH_SIZE=12
WHISPER_LANGUAGE=de
# Ollama (lokal auf dem Mac)
OLLAMA_URL=http://127.0.0.1:11434
OLLAMA_MODEL=llama3.1:8b
OLLAMA_TIMEOUT=600
# Arbeitsverzeichnis für temporäre Dateien
WORK_DIR=/tmp/voice-agent-mac

67
mac-worker/README.md Normal file
View File

@ -0,0 +1,67 @@
# Voice-Agent Mac-Worker
Stateless FastAPI-Service auf dem Mac. Macht die schwere Arbeit: Whisper-Transkription und Ollama-Zusammenfassung. Wird vom LXC-Frontend per HTTP angesprochen.
## Setup (Apple Silicon)
```bash
cd mac-worker
python3.11 -m venv .venv
source .venv/bin/activate
pip install --upgrade pip
pip install -r requirements.txt
cp .env.example .env
# Bei Bedarf .env anpassen
```
## Ollama vorbereiten
```bash
# Ollama installieren, falls noch nicht geschehen
brew install ollama
# Modell ziehen
ollama pull llama3.1:8b
# Ollama netzwerkweit erreichbar machen (LaunchAgent)
launchctl setenv OLLAMA_HOST "0.0.0.0:11434"
brew services restart ollama
```
## Whisper-Engine wählen
| `WHISPER_ENGINE` | Wann nutzen |
|---|---|
| `mlx` | Apple Silicon — schnellste Option |
| `faster` | Intel-Mac / Linux / CUDA |
| `mock` | Testbetrieb ohne echtes Modell |
## Worker starten
```bash
uvicorn app.main:app --host 0.0.0.0 --port 8080
```
Health-Check vom LXC:
```bash
curl http://<MAC-IP>:8080/health
```
## API-Endpunkte
| Methode | Pfad | Zweck |
|---|---|---|
| GET | `/health` | Status + Ollama-Reachability |
| POST | `/api/transcribe` | multipart: `audio`, `language` → Transkript-JSON |
| POST | `/api/summarize` | JSON: `transcript`, `title` → Summary-JSON |
| POST | `/api/protocol` | JSON: `transcript`, `summary`, `title` → Protokoll-JSON |
| POST | `/api/export/docx` | JSON: Protokoll → DOCX-Binary |
## Firewall am Mac
```bash
# Eingehende Verbindungen auf Port 8080 erlauben (System-Settings > Network > Firewall)
# oder via pf — siehe Apple-Doku.
```
Der Worker hat **keine Authentifizierung** — Betrieb nur im internen Netz.

View File

View File

View File

View File

@ -0,0 +1,24 @@
from pathlib import Path
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8", extra="ignore")
app_host: str = "0.0.0.0"
app_port: int = 8080
whisper_engine: str = "mlx" # mlx | faster | mock
whisper_model: str = "large-v3"
whisper_batch_size: int = 12
whisper_language: str = "de"
ollama_url: str = "http://127.0.0.1:11434"
ollama_model: str = "llama3.1:8b"
ollama_timeout: int = 600
work_dir: Path = Path("/tmp/voice-agent-mac")
settings = Settings()
settings.work_dir.mkdir(parents=True, exist_ok=True)

View File

@ -0,0 +1,80 @@
from io import BytesIO
from typing import Any
from docx import Document
from docx.shared import Pt
def build_docx(protocol: dict[str, Any]) -> bytes:
doc = Document()
title = protocol.get("title") or "Sitzungsprotokoll"
doc.add_heading(title, level=0)
meta_lines = []
if protocol.get("date"):
meta_lines.append(f"Datum: {protocol['date']}")
if protocol.get("topic"):
meta_lines.append(f"Thema: {protocol['topic']}")
if meta_lines:
for line in meta_lines:
doc.add_paragraph(line)
participants = protocol.get("participants") or []
if participants:
doc.add_heading("Teilnehmer", level=1)
for p in participants:
doc.add_paragraph(str(p), style="List Bullet")
summary = protocol.get("summary") or ""
if summary:
doc.add_heading("Zusammenfassung", level=1)
doc.add_paragraph(summary)
decisions = protocol.get("decisions") or []
if decisions:
doc.add_heading("Beschlüsse", level=1)
for d in decisions:
doc.add_paragraph(str(d), style="List Bullet")
tasks = protocol.get("tasks") or []
if tasks:
doc.add_heading("Aufgaben", level=1)
for t in tasks:
if isinstance(t, dict):
owner = t.get("owner") or "n/a"
task = t.get("task") or t.get("description") or ""
doc.add_paragraph(f"{owner}: {task}", style="List Bullet")
else:
doc.add_paragraph(str(t), style="List Bullet")
questions = protocol.get("open_questions") or []
if questions:
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):
time = item.get("time", "")
speaker = item.get("speaker", "")
text = item.get("text", "")
p = doc.add_paragraph()
run = p.add_run(f"[{time}] {speaker}: ")
run.bold = True
run.font.size = Pt(10)
p.add_run(text).font.size = Pt(10)
else:
doc.add_paragraph(str(item))
buf = BytesIO()
doc.save(buf)
return buf.getvalue()

View File

@ -0,0 +1,151 @@
import json
import logging
import re
from typing import Any
import httpx
from .config import settings
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:
s = s.strip()
if s.startswith("```"):
s = re.sub(r"^```(?:json)?", "", s, count=1).strip()
if s.endswith("```"):
s = s[:-3].strip()
return s
def _parse_json(text: str) -> dict:
text = _strip_codefence(text)
try:
return json.loads(text)
except json.JSONDecodeError:
m = re.search(r"\{.*\}", text, re.S)
if m:
return json.loads(m.group(0))
raise
def _truncate(text: str, max_chars: int = 60000) -> str:
if len(text) <= max_chars:
return text
head = text[: max_chars // 2]
tail = text[-max_chars // 2 :]
return head + "\n\n[... gekürzt ...]\n\n" + tail
async def _chat(prompt: str) -> str:
url = f"{settings.ollama_url.rstrip('/')}/api/generate"
payload = {
"model": settings.ollama_model,
"prompt": prompt,
"stream": False,
"options": {"temperature": 0.2},
}
log.info("Ollama generate model=%s prompt_len=%d", settings.ollama_model, len(prompt))
async with httpx.AsyncClient(timeout=settings.ollama_timeout) as c:
r = await c.post(url, json=payload)
r.raise_for_status()
data = r.json()
return data.get("response", "")
async def summarize(transcript: str, title: str = "") -> dict[str, Any]:
prompt = SUMMARY_PROMPT.format(title=title or "(ohne Titel)", transcript=_truncate(transcript))
raw = await _chat(prompt)
try:
return _parse_json(raw)
except Exception: # noqa: BLE001
log.warning("Failed to parse summary JSON, falling back to text")
return {
"topic": title,
"summary": raw.strip(),
"decisions": [],
"tasks": [],
"participants": [],
"open_questions": [],
}
async def make_protocol(transcript: str, summary: dict, title: str = "") -> dict:
prompt = PROTOCOL_PROMPT.format(
title=title or "(ohne Titel)",
summary_json=json.dumps(summary, ensure_ascii=False),
transcript=_truncate(transcript),
)
raw = await _chat(prompt)
try:
return _parse_json(raw)
except Exception: # noqa: BLE001
log.warning("Failed to parse protocol JSON, falling back to structured summary")
return {
"title": title,
"date": "",
"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:
url = f"{settings.ollama_url.rstrip('/')}/api/tags"
async with httpx.AsyncClient(timeout=10) as c:
r = await c.get(url)
r.raise_for_status()
return r.json()

View File

@ -0,0 +1,83 @@
"""Abstraktion über mehrere Whisper-Implementierungen."""
from __future__ import annotations
import logging
from pathlib import Path
from typing import Any
from .config import settings
log = logging.getLogger("mac-worker.whisper")
class WhisperEngine:
def __init__(self) -> None:
self.engine = settings.whisper_engine.lower()
self._impl: Any = None
def _ensure_loaded(self) -> None:
if self._impl is not None:
return
if self.engine == "mlx":
from lightning_whisper_mlx import LightningWhisperMLX # type: ignore
log.info("Loading lightning-whisper-mlx model=%s batch=%d", settings.whisper_model, settings.whisper_batch_size)
self._impl = LightningWhisperMLX(
model=settings.whisper_model,
batch_size=settings.whisper_batch_size,
quant=None,
)
elif self.engine == "faster":
from faster_whisper import WhisperModel # type: ignore
log.info("Loading faster-whisper model=%s", settings.whisper_model)
self._impl = WhisperModel(settings.whisper_model, device="auto", compute_type="auto")
elif self.engine == "mock":
self._impl = "mock"
else:
raise ValueError(f"Unknown WHISPER_ENGINE: {self.engine}")
def transcribe(self, audio_path: Path, language: str | None = None) -> dict:
self._ensure_loaded()
lang = language or settings.whisper_language
if self.engine == "mock":
return {
"text": "[MOCK] Dies ist eine Beispiel-Transkription für Tests ohne Whisper-Modell.",
"language": lang,
"segments": [
{"start": 0.0, "end": 3.0, "text": "[MOCK] Erste Aussage."},
{"start": 3.0, "end": 6.0, "text": "[MOCK] Zweite Aussage."},
],
}
if self.engine == "mlx":
out = self._impl.transcribe(audio_path=str(audio_path), language=lang)
# MLX-Output: {"text": "...", "segments": [...]} oder String. Robust mappen.
if isinstance(out, str):
return {"text": out, "language": lang, "segments": []}
segments = out.get("segments") or []
normalized = [
{
"start": float(s.get("start", 0.0)),
"end": float(s.get("end", 0.0)),
"text": (s.get("text") or "").strip(),
}
for s in segments
]
return {"text": out.get("text", "").strip(), "language": lang, "segments": normalized}
if self.engine == "faster":
segments_iter, info = self._impl.transcribe(str(audio_path), language=lang, vad_filter=True)
segments = []
full = []
for s in segments_iter:
t = s.text.strip()
full.append(t)
segments.append({"start": float(s.start), "end": float(s.end), "text": t})
return {"text": " ".join(full), "language": info.language or lang, "segments": segments}
raise RuntimeError(f"Engine {self.engine} not implemented")
engine = WhisperEngine()

86
mac-worker/app/main.py Normal file
View File

@ -0,0 +1,86 @@
import logging
import secrets
from pathlib import Path
from fastapi import FastAPI, File, Form, HTTPException, UploadFile
from fastapi.responses import Response
from pydantic import BaseModel
from app.core import ollama_client
from app.core.config import settings
from app.core.docx_export import build_docx
from app.core.whisper_engine import engine as whisper
logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(name)s: %(message)s")
log = logging.getLogger("mac-worker")
app = FastAPI(title="Voice-Agent Mac-Worker")
class SummarizeIn(BaseModel):
transcript: str
title: str = ""
class ProtocolIn(BaseModel):
transcript: str
summary: dict
title: str = ""
@app.get("/health")
async def health():
info = {
"status": "ok",
"whisper_engine": settings.whisper_engine,
"whisper_model": settings.whisper_model,
"ollama_model": settings.ollama_model,
"ollama_reachable": False,
}
try:
await ollama_client.health()
info["ollama_reachable"] = True
except Exception as e: # noqa: BLE001
info["ollama_error"] = str(e)
return info
@app.post("/api/transcribe")
async def transcribe(audio: UploadFile = File(...), language: str = Form("de")):
if not audio.filename:
raise HTTPException(400, "Dateiname fehlt")
suffix = Path(audio.filename).suffix or ".bin"
tmp = settings.work_dir / (secrets.token_hex(8) + suffix)
try:
with tmp.open("wb") as f:
while chunk := await audio.read(1024 * 1024):
f.write(chunk)
log.info("Transcribing %s (%.1f MB) lang=%s", audio.filename, tmp.stat().st_size / 1024 / 1024, language)
result = whisper.transcribe(tmp, language=language)
return result
finally:
tmp.unlink(missing_ok=True)
@app.post("/api/summarize")
async def summarize(payload: SummarizeIn):
if not payload.transcript.strip():
raise HTTPException(400, "Leerer Transkript")
return await ollama_client.summarize(payload.transcript, title=payload.title)
@app.post("/api/protocol")
async def protocol(payload: ProtocolIn):
if not payload.transcript.strip():
raise HTTPException(400, "Leerer Transkript")
return await ollama_client.make_protocol(payload.transcript, payload.summary, title=payload.title)
@app.post("/api/export/docx")
async def export_docx(protocol: dict):
data = build_docx(protocol)
return Response(
content=data,
media_type="application/vnd.openxmlformats-officedocument.wordprocessingml.document",
headers={"Content-Disposition": 'attachment; filename="protocol.docx"'},
)

View File

@ -0,0 +1,11 @@
fastapi==0.115.5
uvicorn[standard]==0.32.1
python-multipart==0.0.18
pydantic-settings==2.7.0
httpx==0.28.1
python-docx==1.1.2
# Whisper-Engines (eine wählen — siehe README):
# Apple Silicon (empfohlen):
lightning-whisper-mlx==0.0.10 ; sys_platform == "darwin" and platform_machine == "arm64"
# Portable Alternative:
faster-whisper==1.0.3

366
need.md Normal file
View File

@ -0,0 +1,366 @@
# Lastenheft / Umsetzungskonzept
# Self-hosted AI-Transkriptionssystem für Sitzungsaufzeichnungen
## Ziel
Eine Firma lädt regelmäßig Audioaufzeichnungen von Sitzungen hoch.
Das System transkribiert die Aufnahme automatisch, erkennt mehrere Sprecher, erstellt Zusammenfassungen und exportiert fertige Protokolle.
---
# 1. Architektur
```text
Benutzer
Webfrontend im LXC-Container
REST/API-Aufruf
Mac-Rechner als AI-Backend
├─ Whisper / MLX-Whisper / whisper.cpp
├─ Ollama
├─ optionale Speaker-Diarization
└─ Export DOCX/PDF/TXT
```
---
# 2. Systemaufteilung
## 2.1 LXC-Container (ohne GPU)
### Aufgaben
- Weboberfläche
- Datei-Upload
- Benutzerverwaltung
- Jobverwaltung
- Statusanzeige
- Downloadbereich
- Speicherung der Ergebnisse
### Der LXC führt keine AI-Verarbeitung durch.
---
## 2.2 Mac-Rechner (mit Ollama)
### Aufgaben
- Speech-to-Text
- Audioanalyse
- Sprechererkennung
- Zusammenfassung
- Protokollgenerierung
- Exporterstellung
---
# 3. Komponenten
## 3.1 Webfrontend im LXC
### Empfohlene Technologien
- FastAPI oder Flask
- HTML/CSS/JavaScript
- SQLite oder PostgreSQL
- Nginx Reverse Proxy
- Optional Docker
### Funktionen
- Upload von MP3/WAV/M4A
- Anzeige laufender Jobs
- Download fertiger Ergebnisse
- Benutzerverwaltung optional
---
## 3.2 AI-Backend auf dem Mac
### Empfohlene Technologien
- Ollama
- whisper.cpp
- lightning-whisper-mlx
- optional WhisperX
- optional pyannote.audio
- FastAPI
### Empfehlung für Apple Silicon
```text
lightning-whisper-mlx
```
### Alternative
```text
whisper.cpp
```
---
# 4. Datenfluss
```text
1. Benutzer lädt Audio im Webfrontend hoch
2. LXC speichert Datei temporär
3. LXC sendet Datei oder Dateipfad an Mac-API
4. Mac transkribiert Audio
5. Mac erkennt optional Sprecher
6. Mac sendet Transkript an Ollama
7. Ollama erzeugt:
- Zusammenfassung
- Beschlüsse
- Aufgaben
- offizielles Protokoll
8. Ergebnis wird gespeichert
9. Benutzer lädt Ergebnis herunter
```
---
# 5. API-Design
## Endpunkte auf dem Mac
### Transkription starten
```http
POST /api/transcribe
```
### Zusammenfassung erzeugen
```http
POST /api/summarize
```
### Protokoll generieren
```http
POST /api/protocol
```
### Jobstatus abrufen
```http
GET /api/job/{job_id}
```
### Ergebnis abrufen
```http
GET /api/result/{job_id}
```
---
# 6. Ausgabeformate
## Das System erzeugt
- Rohtranskript
- Zeitgestempeltes Transkript
- Sprecherzuordnung
- Zusammenfassung
- Beschlussliste
- Aufgabenliste
- Sitzungsprotokoll
---
## Exportformate
- TXT
- DOCX
- PDF
---
# 7. Beispielausgabe
```text
Sitzungsprotokoll
Datum: 13.05.2026
Thema: Monatsbesprechung
Teilnehmer:
- Sprecher 1
- Sprecher 2
- Sprecher 3
Zusammenfassung:
In der Sitzung wurden Budget, Personalplanung und IT-Migration besprochen.
Beschlüsse:
- Budget für Q3 wird freigegeben.
- Servererneuerung wird vorbereitet.
Aufgaben:
- Herr Müller holt Angebote ein.
- Frau Schneider koordiniert den nächsten Termin.
Transkript:
[00:00:01] Sprecher 1: Guten Morgen zusammen.
[00:00:07] Sprecher 2: Ich beginne mit dem Budgetbericht.
```
---
# 8. Ollama-Konfiguration
## Ollama im Netzwerk erreichbar machen
```bash
launchctl setenv OLLAMA_HOST "0.0.0.0:11434"
```
### Test vom LXC
```bash
curl http://MAC-IP:11434/api/tags
```
---
# 9. Empfohlener Firmenworkflow
```text
1. Sitzung wird aufgenommen
2. Datei wird im Webportal hochgeladen
3. System verarbeitet die Datei automatisch
4. Sekretariat prüft Ergebnis
5. Protokoll wird final gespeichert oder verteilt
```
---
# 10. Prioritäten
## Priorität 1 (MVP)
- Upload-Webfrontend
- Mac-API
- Audio-Transkription
- Ollama-Zusammenfassung
- DOCX-Export
---
## Priorität 2
- Sprechererkennung
- PDF-Export
- Benutzerverwaltung
- Protokollvorlagen
---
## Priorität 3
- Active Directory / LDAP
- Nextcloud-/SharePoint-Integration
- automatische E-Mail-Verteilung
- Übersetzungen
---
# 11. Technische Empfehlung
## MVP-Architektur
### LXC
```text
- FastAPI Webfrontend
- SQLite
- Upload-Verzeichnis
- Ergebnis-Verzeichnis
```
### Mac
```text
- FastAPI Worker
- lightning-whisper-mlx
- Ollama
- python-docx
```
---
# 12. Ziel der ersten Version
Die erste Version soll können:
- Audiodatei hochladen
- Datei an den Mac senden
- Audio automatisch transkribieren
- Zusammenfassung mit Ollama erzeugen
- Sitzungsprotokoll als DOCX exportieren
- Ergebnis im Webfrontend herunterladen
---
# 13. Erweiterungsmöglichkeiten
## Später mögliche Funktionen
- Live-Transkription
- automatische Sprechererkennung mit Namen
- Meeting-Kalenderintegration
- Teams-/Zoom-Import
- automatische E-Mail-Protokolle
- Mehrsprachigkeit
- Übersetzung
- Suchfunktion
- Archivierung
- Rechte- und Rollensystem
---
# 14. Sicherheitsanforderungen
- vollständiger lokaler Betrieb
- keine Cloud-Anbindung
- DSGVO-konforme Verarbeitung
- Zugriffsschutz
- HTTPS
- Benutzerrechte
- Audit-Logging optional
---
# 15. Empfohlene Open-Source-Komponenten
## Speech-to-Text
- Whisper
- whisper.cpp
- lightning-whisper-mlx
## Speaker-Diarization
- pyannote.audio
- WhisperX
## LLM / Zusammenfassung
- Ollama
## Dokumentenerstellung
- python-docx
- reportlab
## Webfrontend
- FastAPI
- Flask
- Nginx
---