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

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

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

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

Doku: README.md, lxc-frontend/README.md, mac-worker/README.md
This commit is contained in:
root
2026-05-13 15:33:53 +00:00
parent 32ab12014f
commit 5321c8602e
35 changed files with 1820 additions and 4 deletions
View File
+24
View File
@@ -0,0 +1,24 @@
from pathlib import Path
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8", extra="ignore")
app_host: str = "0.0.0.0"
app_port: int = 8080
whisper_engine: str = "mlx" # mlx | faster | mock
whisper_model: str = "large-v3"
whisper_batch_size: int = 12
whisper_language: str = "de"
ollama_url: str = "http://127.0.0.1:11434"
ollama_model: str = "llama3.1:8b"
ollama_timeout: int = 600
work_dir: Path = Path("/tmp/voice-agent-mac")
settings = Settings()
settings.work_dir.mkdir(parents=True, exist_ok=True)
+80
View File
@@ -0,0 +1,80 @@
from io import BytesIO
from typing import Any
from docx import Document
from docx.shared import Pt
def build_docx(protocol: dict[str, Any]) -> bytes:
doc = Document()
title = protocol.get("title") or "Sitzungsprotokoll"
doc.add_heading(title, level=0)
meta_lines = []
if protocol.get("date"):
meta_lines.append(f"Datum: {protocol['date']}")
if protocol.get("topic"):
meta_lines.append(f"Thema: {protocol['topic']}")
if meta_lines:
for line in meta_lines:
doc.add_paragraph(line)
participants = protocol.get("participants") or []
if participants:
doc.add_heading("Teilnehmer", level=1)
for p in participants:
doc.add_paragraph(str(p), style="List Bullet")
summary = protocol.get("summary") or ""
if summary:
doc.add_heading("Zusammenfassung", level=1)
doc.add_paragraph(summary)
decisions = protocol.get("decisions") or []
if decisions:
doc.add_heading("Beschlüsse", level=1)
for d in decisions:
doc.add_paragraph(str(d), style="List Bullet")
tasks = protocol.get("tasks") or []
if tasks:
doc.add_heading("Aufgaben", level=1)
for t in tasks:
if isinstance(t, dict):
owner = t.get("owner") or "n/a"
task = t.get("task") or t.get("description") or ""
doc.add_paragraph(f"{owner}: {task}", style="List Bullet")
else:
doc.add_paragraph(str(t), style="List Bullet")
questions = protocol.get("open_questions") or []
if questions:
doc.add_heading("Offene Fragen", level=1)
for q in questions:
doc.add_paragraph(str(q), style="List Bullet")
next_meeting = protocol.get("next_meeting")
if next_meeting:
doc.add_heading("Nächster Termin", level=1)
doc.add_paragraph(str(next_meeting))
excerpt = protocol.get("transcript_excerpt") or []
if excerpt:
doc.add_heading("Transkript-Auszug", level=1)
for item in excerpt:
if isinstance(item, dict):
time = item.get("time", "")
speaker = item.get("speaker", "")
text = item.get("text", "")
p = doc.add_paragraph()
run = p.add_run(f"[{time}] {speaker}: ")
run.bold = True
run.font.size = Pt(10)
p.add_run(text).font.size = Pt(10)
else:
doc.add_paragraph(str(item))
buf = BytesIO()
doc.save(buf)
return buf.getvalue()
+151
View File
@@ -0,0 +1,151 @@
import json
import logging
import re
from typing import Any
import httpx
from .config import settings
log = logging.getLogger("mac-worker.ollama")
SUMMARY_PROMPT = """Du bist ein präziser Protokollassistent für deutsche Geschäftssitzungen.
Analysiere das folgende Sitzungstranskript und liefere ein JSON-Objekt mit:
- "topic": Hauptthema der Sitzung (kurz)
- "summary": Zusammenfassung in 3-6 Sätzen
- "decisions": Liste der Beschlüsse (jeweils ein Satz)
- "tasks": Liste der Aufgaben, jede mit "owner" (Name oder "n/a") und "task"
- "participants": Liste der erkennbaren Teilnehmer (Namen oder "Sprecher 1/2/...")
- "open_questions": Liste offener Fragen (kann leer sein)
Antworte AUSSCHLIESSLICH mit gültigem JSON, keine Erläuterungen, kein Markdown-Codeblock.
Sitzungstitel: {title}
Transkript:
\"\"\"
{transcript}
\"\"\"
"""
PROTOCOL_PROMPT = """Erstelle aus den folgenden Daten ein formelles deutsches Sitzungsprotokoll
als JSON-Objekt mit folgenden Feldern:
- "title": Protokoll-Titel
- "date": Datum (YYYY-MM-DD, falls aus Transkript ableitbar, sonst leer)
- "topic": Thema
- "participants": Liste der Teilnehmer
- "summary": Zusammenfassung
- "decisions": Liste der Beschlüsse
- "tasks": Liste der Aufgaben (jeweils mit "owner" und "task")
- "open_questions": Liste offener Fragen
- "next_meeting": Wenn erwähnt, sonst leer
- "transcript_excerpt": Liste von Objekten mit "time", "speaker", "text" (max. 20 Einträge, zeitlich verteilt aus dem Transkript ziehen)
Antworte AUSSCHLIESSLICH mit JSON, kein Markdown.
Titel: {title}
Bestehende Zusammenfassung: {summary_json}
Transkript:
\"\"\"
{transcript}
\"\"\"
"""
def _strip_codefence(s: str) -> str:
s = s.strip()
if s.startswith("```"):
s = re.sub(r"^```(?:json)?", "", s, count=1).strip()
if s.endswith("```"):
s = s[:-3].strip()
return s
def _parse_json(text: str) -> dict:
text = _strip_codefence(text)
try:
return json.loads(text)
except json.JSONDecodeError:
m = re.search(r"\{.*\}", text, re.S)
if m:
return json.loads(m.group(0))
raise
def _truncate(text: str, max_chars: int = 60000) -> str:
if len(text) <= max_chars:
return text
head = text[: max_chars // 2]
tail = text[-max_chars // 2 :]
return head + "\n\n[... gekürzt ...]\n\n" + tail
async def _chat(prompt: str) -> str:
url = f"{settings.ollama_url.rstrip('/')}/api/generate"
payload = {
"model": settings.ollama_model,
"prompt": prompt,
"stream": False,
"options": {"temperature": 0.2},
}
log.info("Ollama generate model=%s prompt_len=%d", settings.ollama_model, len(prompt))
async with httpx.AsyncClient(timeout=settings.ollama_timeout) as c:
r = await c.post(url, json=payload)
r.raise_for_status()
data = r.json()
return data.get("response", "")
async def summarize(transcript: str, title: str = "") -> dict[str, Any]:
prompt = SUMMARY_PROMPT.format(title=title or "(ohne Titel)", transcript=_truncate(transcript))
raw = await _chat(prompt)
try:
return _parse_json(raw)
except Exception: # noqa: BLE001
log.warning("Failed to parse summary JSON, falling back to text")
return {
"topic": title,
"summary": raw.strip(),
"decisions": [],
"tasks": [],
"participants": [],
"open_questions": [],
}
async def make_protocol(transcript: str, summary: dict, title: str = "") -> dict:
prompt = PROTOCOL_PROMPT.format(
title=title or "(ohne Titel)",
summary_json=json.dumps(summary, ensure_ascii=False),
transcript=_truncate(transcript),
)
raw = await _chat(prompt)
try:
return _parse_json(raw)
except Exception: # noqa: BLE001
log.warning("Failed to parse protocol JSON, falling back to structured summary")
return {
"title": title,
"date": "",
"topic": summary.get("topic", title),
"participants": summary.get("participants", []),
"summary": summary.get("summary", ""),
"decisions": summary.get("decisions", []),
"tasks": summary.get("tasks", []),
"open_questions": summary.get("open_questions", []),
"next_meeting": "",
"transcript_excerpt": [],
}
async def health() -> dict:
url = f"{settings.ollama_url.rstrip('/')}/api/tags"
async with httpx.AsyncClient(timeout=10) as c:
r = await c.get(url)
r.raise_for_status()
return r.json()
+83
View File
@@ -0,0 +1,83 @@
"""Abstraktion über mehrere Whisper-Implementierungen."""
from __future__ import annotations
import logging
from pathlib import Path
from typing import Any
from .config import settings
log = logging.getLogger("mac-worker.whisper")
class WhisperEngine:
def __init__(self) -> None:
self.engine = settings.whisper_engine.lower()
self._impl: Any = None
def _ensure_loaded(self) -> None:
if self._impl is not None:
return
if self.engine == "mlx":
from lightning_whisper_mlx import LightningWhisperMLX # type: ignore
log.info("Loading lightning-whisper-mlx model=%s batch=%d", settings.whisper_model, settings.whisper_batch_size)
self._impl = LightningWhisperMLX(
model=settings.whisper_model,
batch_size=settings.whisper_batch_size,
quant=None,
)
elif self.engine == "faster":
from faster_whisper import WhisperModel # type: ignore
log.info("Loading faster-whisper model=%s", settings.whisper_model)
self._impl = WhisperModel(settings.whisper_model, device="auto", compute_type="auto")
elif self.engine == "mock":
self._impl = "mock"
else:
raise ValueError(f"Unknown WHISPER_ENGINE: {self.engine}")
def transcribe(self, audio_path: Path, language: str | None = None) -> dict:
self._ensure_loaded()
lang = language or settings.whisper_language
if self.engine == "mock":
return {
"text": "[MOCK] Dies ist eine Beispiel-Transkription für Tests ohne Whisper-Modell.",
"language": lang,
"segments": [
{"start": 0.0, "end": 3.0, "text": "[MOCK] Erste Aussage."},
{"start": 3.0, "end": 6.0, "text": "[MOCK] Zweite Aussage."},
],
}
if self.engine == "mlx":
out = self._impl.transcribe(audio_path=str(audio_path), language=lang)
# MLX-Output: {"text": "...", "segments": [...]} oder String. Robust mappen.
if isinstance(out, str):
return {"text": out, "language": lang, "segments": []}
segments = out.get("segments") or []
normalized = [
{
"start": float(s.get("start", 0.0)),
"end": float(s.get("end", 0.0)),
"text": (s.get("text") or "").strip(),
}
for s in segments
]
return {"text": out.get("text", "").strip(), "language": lang, "segments": normalized}
if self.engine == "faster":
segments_iter, info = self._impl.transcribe(str(audio_path), language=lang, vad_filter=True)
segments = []
full = []
for s in segments_iter:
t = s.text.strip()
full.append(t)
segments.append({"start": float(s.start), "end": float(s.end), "text": t})
return {"text": " ".join(full), "language": info.language or lang, "segments": segments}
raise RuntimeError(f"Engine {self.engine} not implemented")
engine = WhisperEngine()