root f408a555f8 feat: kontinuierliche LXC↔Mac-Diagnose + Auto-Retry
Diagnose-Sichtbarkeit (Plan A):
- Job.step_started_at + last_heartbeat_at, Heartbeat-Task pingt alle 3 s
  während laufender Mac-Calls
- Mac-Worker hält per X-Job-Id Header einen Log-Ringbuffer pro Job
  (200 Zeilen, 1 h TTL); GET /api/jobs/{id}/log liefert ihn aus
- LXC-Endpoint GET /api/jobs/{id}/diag bündelt Job-Status, Stage-Sekunden,
  Heartbeat-Alter und Mac-Worker-Log
- Frontend: Live-Timing im Status-Pill ("läuft seit Xs · letzter Ping vor Ys"),
  klappbares Diagnose-Panel mit Mac-Log für FAILED + langlaufende Jobs

Robustheit (Plan B):
- MacClient: einmaliger Retry mit 3 s Backoff bei RemoteProtocolError /
  ConnectError / ReadError für summarize/protocol/export
- Mac-Worker /api/preload heizt Ollama vor (keep_alive 30 m, num_predict 1);
  Pipeline ruft Preload vor summarize, damit Modell-Reload nicht mitten
  in der Generierung passiert (Hauptursache "Server disconnected")

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 09:12:27 +00:00

192 lines
7.1 KiB
Python

import secrets
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.auth import current_user
from app.core.config import settings
from app.core.db import get_session
from app.models.job import Job, JobStatus # noqa: F401
from app.models.user import User
from app.services.pipeline import run_pipeline
router = APIRouter(prefix="/api/jobs", tags=["jobs"])
MAX_BYTES = lambda: settings.max_upload_mb * 1024 * 1024
def _require_access(job: Job | None, user: User) -> Job:
"""404 wenn nicht gefunden oder nicht zugänglich (kein Hinweis darauf, dass es existiert)."""
if not job or (job.owner_id != user.id and not user.is_admin):
raise HTTPException(404, "Job nicht gefunden")
return job
@router.post("")
async def create_job(
background: BackgroundTasks,
audio: UploadFile = File(...),
title: str = Form(""),
profile: str = Form(""),
session: Session = Depends(get_session),
user: User = Depends(current_user),
):
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(
owner_id=user.id,
profile=(profile.strip() or user.default_profile or "meeting"),
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, user)
@router.get("")
def list_jobs(session: Session = Depends(get_session), user: User = Depends(current_user)):
stmt = select(Job).order_by(Job.created_at.desc())
if not user.is_admin:
stmt = stmt.where(Job.owner_id == user.id)
jobs = session.exec(stmt).all()
# Admin sieht zusätzlich Owner-Username — kleine Map für effiziente Anzeige.
owner_names: dict[int, str] = {}
if user.is_admin:
owner_ids = {j.owner_id for j in jobs if j.owner_id is not None}
if owner_ids:
owners = session.exec(select(User).where(User.id.in_(owner_ids))).all() # type: ignore[attr-defined]
owner_names = {u.id: u.username for u in owners}
return [_job_dict(j, user, owner_names.get(j.owner_id) if user.is_admin else None) for j in jobs]
@router.get("/{job_id}")
def get_job(job_id: int, session: Session = Depends(get_session), user: User = Depends(current_user)):
job = _require_access(session.get(Job, job_id), user)
return _job_dict(job, user)
@router.post("/{job_id}/retry")
async def retry_job(
job_id: int,
background: BackgroundTasks,
session: Session = Depends(get_session),
user: User = Depends(current_user),
):
"""Verarbeitung neu anstoßen.
Bereits vorhandenes Transkript wird beibehalten (teuerster Schritt),
Summary/Protokoll/DOCX werden gelöscht und neu erzeugt. Sinnvoll wenn
der Job bei summarize/protocol/docx hängt oder failed ist.
"""
job = _require_access(session.get(Job, job_id), user)
if job.status == JobStatus.DONE:
raise HTTPException(400, "Job ist bereits abgeschlossen")
# Post-Transkriptions-Artefakte löschen, damit sie neu erzeugt werden.
for attr in ("summary_path", "protocol_path", "docx_path", "pdf_path"):
p = getattr(job, attr)
if p:
try:
Path(p).unlink(missing_ok=True)
except OSError:
pass
setattr(job, attr, "")
job.status = JobStatus.QUEUED
job.progress = 0
job.error = ""
job.step_started_at = None
job.last_heartbeat_at = None
session.add(job)
session.commit()
session.refresh(job)
background.add_task(run_pipeline, job.id)
return _job_dict(job, user)
@router.get("/{job_id}/diag")
async def diag(job_id: int, session: Session = Depends(get_session), user: User = Depends(current_user)):
"""Diagnose-Bündel: Job-Status + letzte Mac-Worker-Logzeilen für genau diesen Job."""
from app.core.mac_client import MacClient
job = _require_access(session.get(Job, job_id), user)
client = MacClient()
mac_log = await client.fetch_log(job_id, last=200)
return {
"job": _job_dict(job, user),
"step_started_at": job.step_started_at.isoformat() if job.step_started_at else None,
"last_heartbeat_at": job.last_heartbeat_at.isoformat() if job.last_heartbeat_at else None,
"mac_log": mac_log,
}
@router.get("/{job_id}/download/{kind}")
def download(job_id: int, kind: str, session: Session = Depends(get_session), user: User = Depends(current_user)):
job = _require_access(session.get(Job, job_id), user)
mapping = {
"docx": (job.docx_path, "application/vnd.openxmlformats-officedocument.wordprocessingml.document", "protocol.docx"),
"pdf": (job.pdf_path, "application/pdf", "protocol.pdf"),
"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, user: User, owner_username: str | None = None) -> dict:
d = {
"id": j.id,
"title": j.title,
"original_name": j.original_name,
"status": j.status,
"progress": j.progress,
"error": j.error,
"profile": j.profile or "meeting",
"created_at": j.created_at.isoformat(),
"updated_at": j.updated_at.isoformat(),
"step_started_at": j.step_started_at.isoformat() if j.step_started_at else None,
"last_heartbeat_at": j.last_heartbeat_at.isoformat() if j.last_heartbeat_at else None,
"has": {
"transcript": bool(j.transcript_path),
"summary": bool(j.summary_path),
"protocol": bool(j.protocol_path),
"docx": bool(j.docx_path),
"pdf": bool(j.pdf_path),
},
}
if user.is_admin:
d["owner_id"] = j.owner_id
d["owner_username"] = owner_username or ""
return d