root dbf7bca6c7 feat(jobs): "Mit anderem Modell verarbeiten" + Verlauf pro Job
Neuer Endpoint POST /api/jobs/{id}/reprocess: erlaubt einen LLM-Lauf
auch auf done/failed-Jobs, sichert Summary/Protokoll/DOCX/PDF in
results/<id>/runs/<ts>-<modell>/ als Snapshot, leert die DB-Pfade,
startet die Pipeline mit dem aktuell auf dem Mac konfigurierten
Ollama-Modell. Transkript bleibt erhalten.

GET /api/jobs/{id}/runs liefert die Snapshot-Liste,
GET /api/jobs/{id}/runs/{run_id}/{file} den Einzeldownload.

UI: zwei neue Buttons auf done/failed-Jobs — "↻ Mit anderem Modell"
und "Verlauf ▾". Reprocess-Dialog zeigt das aktuell konfigurierte
Mac-Modell an, bevor er startet, damit kein Versehen passiert.
Verlauf-Panel listet pro Lauf Modellname + Zeitstempel + Download-
Links für die Snapshot-Dateien.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 07:58:23 +00:00

346 lines
12 KiB
Python

import json
import re
import secrets
from datetime import datetime, timezone
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
# Status, in denen der Job aktiv läuft — Reprocess wird dann verweigert,
# damit kein Schreibwettlauf mit der laufenden Pipeline entsteht.
_ACTIVE_STATUSES = {
JobStatus.QUEUED,
JobStatus.TRANSCRIBING,
JobStatus.SUMMARIZING,
JobStatus.PROTOCOLLING,
JobStatus.EXPORTING,
}
def _slug(s: str) -> str:
"""Dateisystem-sichere Variante eines Modellnamens (z. B. 'gpt-oss:latest''gpt-oss_latest')."""
return re.sub(r"[^A-Za-z0-9._-]+", "_", s).strip("_") or "unknown"
async def _current_mac_model() -> str:
"""Aktuelles Ollama-Modell vom Mac-Worker — fällt auf 'unknown' zurück, wenn unerreichbar."""
try:
from app.core.mac_client import MacClient
h = await MacClient().health()
return h.get("ollama_model") or "unknown"
except Exception: # noqa: BLE001
return "unknown"
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.post("/{job_id}/reprocess")
async def reprocess_job(
job_id: int,
background: BackgroundTasks,
session: Session = Depends(get_session),
user: User = Depends(current_user),
):
"""Erlaubt einen erneuten LLM-Lauf auf einem bereits abgeschlossenen Job.
Anders als /retry blockiert dieser Endpoint nicht bei status=done.
Sichert Summary/Protokoll/DOCX/PDF in runs/<ts>-<modell>/ als Snapshot
(Transkript bleibt im Hauptordner), startet die Pipeline neu — die
Mac-aktuell konfigurierte Ollama-Variante wird dabei verwendet.
"""
job = _require_access(session.get(Job, job_id), user)
if job.status in _ACTIVE_STATUSES:
raise HTTPException(409, "Job läuft gerade — bitte warten oder Diagnose prüfen")
model = await _current_mac_model()
ts = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ")
run_id = f"{ts}-{_slug(model)}"
job_dir = settings.result_dir / str(job.id)
runs_dir = job_dir / "runs" / run_id
runs_dir.mkdir(parents=True, exist_ok=True)
moved: list[str] = []
for attr in ("summary_path", "protocol_path", "docx_path", "pdf_path"):
path_str = getattr(job, attr)
if path_str:
src = Path(path_str)
if src.exists():
try:
src.replace(runs_dir / src.name)
moved.append(src.name)
except OSError:
pass
setattr(job, attr, "")
(runs_dir / "meta.json").write_text(
json.dumps(
{
"run_id": run_id,
"model": model,
"profile": job.profile,
"snapshot_at": ts,
"previous_status": job.status,
"files": moved,
},
ensure_ascii=False,
indent=2,
),
encoding="utf-8",
)
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": _job_dict(job, user), "snapshot": run_id, "model_for_new_run": model}
@router.get("/{job_id}/runs")
def list_runs(
job_id: int,
session: Session = Depends(get_session),
user: User = Depends(current_user),
):
"""Listet vergangene LLM-Läufe (Snapshots) eines Jobs."""
job = _require_access(session.get(Job, job_id), user)
runs_dir = settings.result_dir / str(job.id) / "runs"
out: list[dict] = []
if runs_dir.exists():
for d in sorted(runs_dir.iterdir(), reverse=True):
if not d.is_dir():
continue
meta: dict = {}
try:
meta = json.loads((d / "meta.json").read_text(encoding="utf-8"))
except Exception: # noqa: BLE001
pass
out.append({
"run_id": d.name,
"model": meta.get("model", "unknown"),
"snapshot_at": meta.get("snapshot_at", ""),
"profile": meta.get("profile", job.profile),
"files": [
{"name": f.name, "size": f.stat().st_size}
for f in sorted(d.iterdir())
if f.is_file() and f.name != "meta.json"
],
})
return {"runs": out}
@router.get("/{job_id}/runs/{run_id}/{filename}")
def download_run_artifact(
job_id: int,
run_id: str,
filename: str,
session: Session = Depends(get_session),
user: User = Depends(current_user),
):
"""Liefert eine einzelne Datei aus einem Snapshot-Lauf zum Download."""
job = _require_access(session.get(Job, job_id), user)
if "/" in run_id or ".." in run_id or "/" in filename or ".." in filename:
raise HTTPException(400, "Ungültige Pfad-Komponenten")
target = settings.result_dir / str(job.id) / "runs" / run_id / filename
if not target.exists():
raise HTTPException(404, "Snapshot-Datei nicht gefunden")
media_map = {
".json": "application/json",
".docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
".pdf": "application/pdf",
}
media = media_map.get(target.suffix.lower(), "application/octet-stream")
base = (job.title or Path(job.original_name).stem).replace("/", "_")
return FileResponse(target, media_type=media, filename=f"{base}-{run_id}-{filename}")
@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