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/
|
node_modules/
|
||||||
dist/
|
dist/
|
||||||
build/
|
build/
|
||||||
.env
|
|
||||||
config/project.env
|
|
||||||
*.log
|
|
||||||
.DS_Store
|
|
||||||
coverage/
|
coverage/
|
||||||
.nyc_output/
|
.nyc_output/
|
||||||
|
|
||||||
|
# Sonstiges
|
||||||
|
*.log
|
||||||
|
.DS_Store
|
||||||
*.pem
|
*.pem
|
||||||
*.key
|
*.key
|
||||||
|
|||||||
90
README.md
Normal file
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