root 354e4c57eb feat: PDF-Export zusätzlich zu DOCX
Mac-Worker:
- pdf_export.py mit reportlab (pure Python, kein externes Tool)
- Nutzt dieselbe profile.docx-Konfig wie der DOCX-Renderer →
  strukturell identisch, nur andere Optik
- POST /api/export/pdf
- requirements: reportlab==4.2.5

LXC:
- Job.pdf_path neu (Migration: ALTER TABLE job ADD COLUMN)
- Pipeline-Step nach DOCX: PDF wird ebenfalls erzeugt
  (Progress: 90 DOCX → 95 PDF → 100 done)
- /api/jobs/{id}/download/pdf
- /api/jobs/{id}/retry löscht PDF mit, damit es neu erzeugt wird
- Frontend: zusätzlicher Download-Button "PDF" (vorne im Array)
- Tabelle: Download-Spalte 320px, 3-spaltiges Grid (5 Buttons → 3+2)
- Cards (Mobile) bleiben 2-spaltig (5 Buttons → 3 Zeilen)
2026-05-14 04:48:47 +00:00

118 lines
3.4 KiB
Python

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, profiles
from app.core.config import settings
from app.core.docx_export import build_docx
from app.core.pdf_export import build_pdf
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 = ""
profile: str | None = None
class ProtocolIn(BaseModel):
transcript: str
summary: dict
title: str = ""
profile: str | None = None
class DocxIn(BaseModel):
data: dict
profile: str | None = None
class PdfIn(BaseModel):
data: dict
profile: str | None = None
@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,
"profiles": [p["name"] for p in profiles.list_all()],
}
try:
await ollama_client.health()
info["ollama_reachable"] = True
except Exception as e: # noqa: BLE001
info["ollama_error"] = str(e)
return info
@app.get("/api/profiles")
def list_profiles():
return {"profiles": profiles.list_all()}
@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, profile_name=payload.profile)
@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, profile_name=payload.profile
)
@app.post("/api/export/docx")
async def export_docx(payload: DocxIn):
data = build_docx(payload.data, profile_name=payload.profile)
return Response(
content=data,
media_type="application/vnd.openxmlformats-officedocument.wordprocessingml.document",
headers={"Content-Disposition": 'attachment; filename="protocol.docx"'},
)
@app.post("/api/export/pdf")
async def export_pdf(payload: PdfIn):
data = build_pdf(payload.data, profile_name=payload.profile)
return Response(
content=data,
media_type="application/pdf",
headers={"Content-Disposition": 'attachment; filename="protocol.pdf"'},
)