diff --git a/.gitignore b/.gitignore index 383e6dc..2f56bf0 100644 --- a/.gitignore +++ b/.gitignore @@ -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/ dist/ build/ -.env -config/project.env -*.log -.DS_Store coverage/ .nyc_output/ + +# Sonstiges +*.log +.DS_Store *.pem *.key diff --git a/README.md b/README.md new file mode 100644 index 0000000..9fbf565 --- /dev/null +++ b/README.md @@ -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:///` ö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. diff --git a/deploy/install.sh b/deploy/install.sh new file mode 100755 index 0000000..cbd3302 --- /dev/null +++ b/deploy/install.sh @@ -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" diff --git a/deploy/nginx/voice-agent.conf b/deploy/nginx/voice-agent.conf new file mode 100644 index 0000000..9fd547d --- /dev/null +++ b/deploy/nginx/voice-agent.conf @@ -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; + } +} diff --git a/deploy/systemd/voice-agent.service b/deploy/systemd/voice-agent.service new file mode 100644 index 0000000..c632e5f --- /dev/null +++ b/deploy/systemd/voice-agent.service @@ -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 diff --git a/lxc-frontend/.env.example b/lxc-frontend/.env.example new file mode 100644 index 0000000..f114f2d --- /dev/null +++ b/lxc-frontend/.env.example @@ -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 diff --git a/lxc-frontend/README.md b/lxc-frontend/README.md new file mode 100644 index 0000000..c2484d9 --- /dev/null +++ b/lxc-frontend/README.md @@ -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}/ +``` diff --git a/lxc-frontend/app/__init__.py b/lxc-frontend/app/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/lxc-frontend/app/api/__init__.py b/lxc-frontend/app/api/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/lxc-frontend/app/api/jobs.py b/lxc-frontend/app/api/jobs.py new file mode 100644 index 0000000..fd14065 --- /dev/null +++ b/lxc-frontend/app/api/jobs.py @@ -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), + }, + } diff --git a/lxc-frontend/app/core/__init__.py b/lxc-frontend/app/core/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/lxc-frontend/app/core/config.py b/lxc-frontend/app/core/config.py new file mode 100644 index 0000000..f31014f --- /dev/null +++ b/lxc-frontend/app/core/config.py @@ -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) diff --git a/lxc-frontend/app/core/db.py b/lxc-frontend/app/core/db.py new file mode 100644 index 0000000..b9e6012 --- /dev/null +++ b/lxc-frontend/app/core/db.py @@ -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 diff --git a/lxc-frontend/app/core/mac_client.py b/lxc-frontend/app/core/mac_client.py new file mode 100644 index 0000000..96196da --- /dev/null +++ b/lxc-frontend/app/core/mac_client.py @@ -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 diff --git a/lxc-frontend/app/main.py b/lxc-frontend/app/main.py new file mode 100644 index 0000000..6f7f339 --- /dev/null +++ b/lxc-frontend/app/main.py @@ -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)}) diff --git a/lxc-frontend/app/models/__init__.py b/lxc-frontend/app/models/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/lxc-frontend/app/models/job.py b/lxc-frontend/app/models/job.py new file mode 100644 index 0000000..19647f4 --- /dev/null +++ b/lxc-frontend/app/models/job.py @@ -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)) diff --git a/lxc-frontend/app/services/__init__.py b/lxc-frontend/app/services/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/lxc-frontend/app/services/pipeline.py b/lxc-frontend/app/services/pipeline.py new file mode 100644 index 0000000..e29039a --- /dev/null +++ b/lxc-frontend/app/services/pipeline.py @@ -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}") diff --git a/lxc-frontend/app/static/app.js b/lxc-frontend/app/static/app.js new file mode 100644 index 0000000..3c79169 --- /dev/null +++ b/lxc-frontend/app/static/app.js @@ -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 = ` + #${job.id} + ${title}
${job.original_name} + ${job.status}${job.error ? " — " + job.error : ""} +
${job.progress}% + ${fmtDate(job.updated_at)} + `; + 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 = 'Noch keine Jobs.'; + 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); diff --git a/lxc-frontend/app/static/index.html b/lxc-frontend/app/static/index.html new file mode 100644 index 0000000..af1f8ee --- /dev/null +++ b/lxc-frontend/app/static/index.html @@ -0,0 +1,58 @@ + + + + + + Voice-Agent — Transkription + + + +
+

Voice-Agent

+

Sitzungsaufnahmen automatisch transkribieren und protokollieren

+
Mac-Backend: prüfe …
+
+ +
+
+

Neue Aufnahme hochladen

+
+ + + +
+ +

+
+ +
+

Jobs

+ + + + + + + + + +
#Titel / DateiStatusFortschrittAktualisiertDownloads
Noch keine Jobs.
+
+
+ + + + + + diff --git a/lxc-frontend/app/static/style.css b/lxc-frontend/app/static/style.css new file mode 100644 index 0000000..90d28d2 --- /dev/null +++ b/lxc-frontend/app/static/style.css @@ -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; } diff --git a/lxc-frontend/requirements.txt b/lxc-frontend/requirements.txt new file mode 100644 index 0000000..a609cb5 --- /dev/null +++ b/lxc-frontend/requirements.txt @@ -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 diff --git a/mac-worker/.env.example b/mac-worker/.env.example new file mode 100644 index 0000000..4dab5c5 --- /dev/null +++ b/mac-worker/.env.example @@ -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 diff --git a/mac-worker/README.md b/mac-worker/README.md new file mode 100644 index 0000000..d2a00c8 --- /dev/null +++ b/mac-worker/README.md @@ -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://: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. diff --git a/mac-worker/app/__init__.py b/mac-worker/app/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/mac-worker/app/api/__init__.py b/mac-worker/app/api/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/mac-worker/app/core/__init__.py b/mac-worker/app/core/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/mac-worker/app/core/config.py b/mac-worker/app/core/config.py new file mode 100644 index 0000000..70497b8 --- /dev/null +++ b/mac-worker/app/core/config.py @@ -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) diff --git a/mac-worker/app/core/docx_export.py b/mac-worker/app/core/docx_export.py new file mode 100644 index 0000000..4a8f591 --- /dev/null +++ b/mac-worker/app/core/docx_export.py @@ -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() diff --git a/mac-worker/app/core/ollama_client.py b/mac-worker/app/core/ollama_client.py new file mode 100644 index 0000000..3740124 --- /dev/null +++ b/mac-worker/app/core/ollama_client.py @@ -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() diff --git a/mac-worker/app/core/whisper_engine.py b/mac-worker/app/core/whisper_engine.py new file mode 100644 index 0000000..14244ca --- /dev/null +++ b/mac-worker/app/core/whisper_engine.py @@ -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() diff --git a/mac-worker/app/main.py b/mac-worker/app/main.py new file mode 100644 index 0000000..72c3605 --- /dev/null +++ b/mac-worker/app/main.py @@ -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"'}, + ) diff --git a/mac-worker/requirements.txt b/mac-worker/requirements.txt new file mode 100644 index 0000000..2ac4f2b --- /dev/null +++ b/mac-worker/requirements.txt @@ -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 diff --git a/need.md b/need.md new file mode 100644 index 0000000..c3e555d --- /dev/null +++ b/need.md @@ -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 + +---