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:
parent
32ab12014f
commit
5321c8602e
28
.gitignore
vendored
28
.gitignore
vendored
@ -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
|
||||
|
||||
90
README.md
Normal file
90
README.md
Normal 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
61
deploy/install.sh
Executable 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"
|
||||
20
deploy/nginx/voice-agent.conf
Normal file
20
deploy/nginx/voice-agent.conf
Normal 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;
|
||||
}
|
||||
}
|
||||
19
deploy/systemd/voice-agent.service
Normal file
19
deploy/systemd/voice-agent.service
Normal 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
17
lxc-frontend/.env.example
Normal 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
44
lxc-frontend/README.md
Normal 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}/
|
||||
```
|
||||
0
lxc-frontend/app/__init__.py
Normal file
0
lxc-frontend/app/__init__.py
Normal file
0
lxc-frontend/app/api/__init__.py
Normal file
0
lxc-frontend/app/api/__init__.py
Normal file
105
lxc-frontend/app/api/jobs.py
Normal file
105
lxc-frontend/app/api/jobs.py
Normal 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),
|
||||
},
|
||||
}
|
||||
0
lxc-frontend/app/core/__init__.py
Normal file
0
lxc-frontend/app/core/__init__.py
Normal file
29
lxc-frontend/app/core/config.py
Normal file
29
lxc-frontend/app/core/config.py
Normal 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)
|
||||
14
lxc-frontend/app/core/db.py
Normal file
14
lxc-frontend/app/core/db.py
Normal 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
|
||||
48
lxc-frontend/app/core/mac_client.py
Normal file
48
lxc-frontend/app/core/mac_client.py
Normal 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
51
lxc-frontend/app/main.py
Normal 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)})
|
||||
0
lxc-frontend/app/models/__init__.py
Normal file
0
lxc-frontend/app/models/__init__.py
Normal file
29
lxc-frontend/app/models/job.py
Normal file
29
lxc-frontend/app/models/job.py
Normal 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))
|
||||
0
lxc-frontend/app/services/__init__.py
Normal file
0
lxc-frontend/app/services/__init__.py
Normal file
76
lxc-frontend/app/services/pipeline.py
Normal file
76
lxc-frontend/app/services/pipeline.py
Normal 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}")
|
||||
125
lxc-frontend/app/static/app.js
Normal file
125
lxc-frontend/app/static/app.js
Normal 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);
|
||||
58
lxc-frontend/app/static/index.html
Normal file
58
lxc-frontend/app/static/index.html
Normal 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 & 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>
|
||||
117
lxc-frontend/app/static/style.css
Normal file
117
lxc-frontend/app/static/style.css
Normal 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; }
|
||||
8
lxc-frontend/requirements.txt
Normal file
8
lxc-frontend/requirements.txt
Normal 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
17
mac-worker/.env.example
Normal 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
67
mac-worker/README.md
Normal 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.
|
||||
0
mac-worker/app/__init__.py
Normal file
0
mac-worker/app/__init__.py
Normal file
0
mac-worker/app/api/__init__.py
Normal file
0
mac-worker/app/api/__init__.py
Normal file
0
mac-worker/app/core/__init__.py
Normal file
0
mac-worker/app/core/__init__.py
Normal file
24
mac-worker/app/core/config.py
Normal file
24
mac-worker/app/core/config.py
Normal 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)
|
||||
80
mac-worker/app/core/docx_export.py
Normal file
80
mac-worker/app/core/docx_export.py
Normal 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()
|
||||
151
mac-worker/app/core/ollama_client.py
Normal file
151
mac-worker/app/core/ollama_client.py
Normal 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()
|
||||
83
mac-worker/app/core/whisper_engine.py
Normal file
83
mac-worker/app/core/whisper_engine.py
Normal 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
86
mac-worker/app/main.py
Normal 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"'},
|
||||
)
|
||||
11
mac-worker/requirements.txt
Normal file
11
mac-worker/requirements.txt
Normal 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
366
need.md
Normal 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
|
||||
|
||||
---
|
||||
Loading…
x
Reference in New Issue
Block a user