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:
@@ -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
|
||||
@@ -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,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)
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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"'},
|
||||
)
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user