import json import re import secrets from datetime import datetime, timezone from pathlib import Path import aiofiles from fastapi import APIRouter, BackgroundTasks, Body, 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(""), model: 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) # Priorität: Form-Eingabe → User-Default → leer (= Worker-Default) chosen_model = model.strip() or (user.default_model or "").strip() job = Job( owner_id=user.id, profile=(profile.strip() or user.default_profile or "meeting"), model=chosen_model, 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, payload: dict = Body(default_factory=dict), session: Session = Depends(get_session), user: User = Depends(current_user), ): """Erlaubt einen erneuten LLM-Lauf auf einem bereits abgeschlossenen Job. Optionaler Body: `{"model": "qwen2.5:14b"}` — überschreibt das LLM nur für diesen Lauf. Leer/fehlend → bisheriges job.model bleibt, leer → Mac-Default. Der bisherige Lauf (Summary/Protokoll/DOCX/PDF) wandert in runs/-/ als Snapshot. Transkript bleibt im Hauptordner. """ 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") new_model_raw = (payload.get("model") or "").strip() if isinstance(payload, dict) else "" # Für den Snapshot zählt das Modell, das den *bisherigen* Output produziert hat — # also das vorherige job.model, oder fallback der aktuelle Worker-Default. snapshot_model = (job.model or "").strip() or await _current_mac_model() ts = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ") run_id = f"{ts}-{_slug(snapshot_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": snapshot_model, "profile": job.profile, "snapshot_at": ts, "previous_status": job.status, "files": moved, }, ensure_ascii=False, indent=2, ), encoding="utf-8", ) # Neues Modell für den kommenden Lauf — nur wenn explizit mitgegeben. if new_model_raw: job.model = new_model_raw 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, "snapshot_model": snapshot_model, "next_model": job.model or "(worker default)", } @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", # Leer = Worker-Default; UI zeigt das als „Standard". "model": j.model or "", "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