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)
118 lines
3.4 KiB
Python
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"'},
|
|
)
|