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>
192 lines
7.1 KiB
Python
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
|