diff --git a/lxc-frontend/app/api/jobs.py b/lxc-frontend/app/api/jobs.py index db527a2..4a431d9 100644 --- a/lxc-frontend/app/api/jobs.py +++ b/lxc-frontend/app/api/jobs.py @@ -120,6 +120,8 @@ async def retry_job( 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) @@ -128,6 +130,21 @@ async def retry_job( 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) @@ -158,6 +175,8 @@ def _job_dict(j: Job, user: User, owner_username: str | None = None) -> dict: "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), diff --git a/lxc-frontend/app/core/mac_client.py b/lxc-frontend/app/core/mac_client.py index 74ec779..c5a8c1d 100644 --- a/lxc-frontend/app/core/mac_client.py +++ b/lxc-frontend/app/core/mac_client.py @@ -1,13 +1,62 @@ +import asyncio +import logging from pathlib import Path + import httpx + from .config import settings +log = logging.getLogger("voice-agent.mac_client") + +# Transient-Fehler, bei denen ein einmaliger Retry sinnvoll ist +# (typisch bei Ollama-Reload oder kurzem Mac-Worker-Restart). +_RETRY_EXCEPTIONS = ( + httpx.RemoteProtocolError, + httpx.ConnectError, + httpx.ReadError, +) + class MacClient: def __init__(self, base_url: str | None = None, timeout: int | None = None): self.base_url = (base_url or settings.mac_api_url).rstrip("/") self.timeout = timeout or settings.mac_api_timeout + @staticmethod + def _hdrs(job_id: int | None) -> dict[str, str] | None: + if job_id is None: + return None + return {"X-Job-Id": str(job_id)} + + async def _post_json_retry(self, url: str, payload: dict, job_id: int | None) -> dict: + """JSON-POST mit einmaligem Retry bei transienten Verbindungsabbrüchen.""" + for attempt in (0, 1): + try: + async with httpx.AsyncClient(timeout=self.timeout) as c: + r = await c.post(url, json=payload, headers=self._hdrs(job_id)) + r.raise_for_status() + return r.json() + except _RETRY_EXCEPTIONS as e: + if attempt == 0: + log.warning("[job %s] %s bei %s — Retry in 3s", job_id, type(e).__name__, url) + await asyncio.sleep(3.0) + continue + raise + + async def _post_bytes_retry(self, url: str, payload: dict, job_id: int | None) -> bytes: + for attempt in (0, 1): + try: + async with httpx.AsyncClient(timeout=self.timeout) as c: + r = await c.post(url, json=payload, headers=self._hdrs(job_id)) + r.raise_for_status() + return r.content + except _RETRY_EXCEPTIONS as e: + if attempt == 0: + log.warning("[job %s] %s bei %s — Retry in 3s", job_id, type(e).__name__, url) + await asyncio.sleep(3.0) + continue + raise + async def health(self) -> dict: async with httpx.AsyncClient(timeout=10) as c: r = await c.get(f"{self.base_url}/health") @@ -20,47 +69,67 @@ class MacClient: r.raise_for_status() return r.json().get("profiles", []) - async def transcribe(self, audio_path: Path, language: str = "de") -> dict: + async def fetch_log(self, job_id: int, last: int = 200) -> list[str]: + """Holt die letzten Mac-Worker-Logzeilen für einen Job (für die Diagnose-Anzeige).""" + try: + async with httpx.AsyncClient(timeout=8) as c: + r = await c.get(f"{self.base_url}/api/jobs/{job_id}/log", params={"last": last}) + r.raise_for_status() + return r.json().get("lines", []) + except Exception as e: # noqa: BLE001 + log.warning("fetch_log job=%s failed: %s", job_id, e) + return [] + + async def preload(self, profile: str | None = None, job_id: int | None = None) -> dict: + """Vor-Heizen: Ollama soll das Modell laden, bevor der echte Call läuft.""" + return await self._post_json_retry( + f"{self.base_url}/api/preload", + {"profile": profile}, + job_id, + ) + + async def transcribe(self, audio_path: Path, language: str = "de", job_id: int | None = None) -> dict: + # Kein Retry: Datei-Upload ist nicht idempotent, und die Transkription + # ist der teuerste Schritt — Fehler hier sollen sichtbar bleiben. async with httpx.AsyncClient(timeout=self.timeout) as c: with audio_path.open("rb") as f: files = {"audio": (audio_path.name, f, "application/octet-stream")} data = {"language": language} - r = await c.post(f"{self.base_url}/api/transcribe", files=files, data=data) + r = await c.post( + f"{self.base_url}/api/transcribe", + files=files, + data=data, + headers=self._hdrs(job_id), + ) r.raise_for_status() return r.json() - async def summarize(self, transcript: str, title: str = "", profile: str | None = None) -> dict: - async with httpx.AsyncClient(timeout=self.timeout) as c: - r = await c.post( - f"{self.base_url}/api/summarize", - json={"transcript": transcript, "title": title, "profile": profile}, - ) - r.raise_for_status() - return r.json() + async def summarize(self, transcript: str, title: str = "", profile: str | None = None, + job_id: int | None = None) -> dict: + return await self._post_json_retry( + f"{self.base_url}/api/summarize", + {"transcript": transcript, "title": title, "profile": profile}, + job_id, + ) - async def protocol(self, transcript: str, summary: dict, title: str = "", profile: str | None = None) -> dict: - async with httpx.AsyncClient(timeout=self.timeout) as c: - r = await c.post( - f"{self.base_url}/api/protocol", - json={"transcript": transcript, "summary": summary, "title": title, "profile": profile}, - ) - r.raise_for_status() - return r.json() + async def protocol(self, transcript: str, summary: dict, title: str = "", profile: str | None = None, + job_id: int | None = None) -> dict: + return await self._post_json_retry( + f"{self.base_url}/api/protocol", + {"transcript": transcript, "summary": summary, "title": title, "profile": profile}, + job_id, + ) - async def export_docx(self, data: dict, profile: str | None = None) -> bytes: - async with httpx.AsyncClient(timeout=self.timeout) as c: - r = await c.post( - f"{self.base_url}/api/export/docx", - json={"data": data, "profile": profile}, - ) - r.raise_for_status() - return r.content + async def export_docx(self, data: dict, profile: str | None = None, job_id: int | None = None) -> bytes: + return await self._post_bytes_retry( + f"{self.base_url}/api/export/docx", + {"data": data, "profile": profile}, + job_id, + ) - async def export_pdf(self, data: dict, profile: str | None = None) -> bytes: - async with httpx.AsyncClient(timeout=self.timeout) as c: - r = await c.post( - f"{self.base_url}/api/export/pdf", - json={"data": data, "profile": profile}, - ) - r.raise_for_status() - return r.content + async def export_pdf(self, data: dict, profile: str | None = None, job_id: int | None = None) -> bytes: + return await self._post_bytes_retry( + f"{self.base_url}/api/export/pdf", + {"data": data, "profile": profile}, + job_id, + ) diff --git a/lxc-frontend/app/core/migrate.py b/lxc-frontend/app/core/migrate.py index fc64811..62f41b4 100644 --- a/lxc-frontend/app/core/migrate.py +++ b/lxc-frontend/app/core/migrate.py @@ -46,6 +46,15 @@ def run_migrations() -> None: log.info("Migration: füge job.pdf_path hinzu") conn.execute(text("ALTER TABLE job ADD COLUMN pdf_path VARCHAR DEFAULT ''")) + # 5) Diagnose-Felder: step_started_at, last_heartbeat_at + cols = _existing_columns(conn, "job") + if "step_started_at" not in cols: + log.info("Migration: füge job.step_started_at hinzu") + conn.execute(text("ALTER TABLE job ADD COLUMN step_started_at DATETIME")) + if "last_heartbeat_at" not in cols: + log.info("Migration: füge job.last_heartbeat_at hinzu") + conn.execute(text("ALTER TABLE job ADD COLUMN last_heartbeat_at DATETIME")) + def assign_orphan_jobs_to(user_id: int) -> int: """Weist alle Jobs ohne owner_id dem angegebenen User zu. Gibt Anzahl zurück.""" diff --git a/lxc-frontend/app/models/job.py b/lxc-frontend/app/models/job.py index a3df7f1..6b4eb20 100644 --- a/lxc-frontend/app/models/job.py +++ b/lxc-frontend/app/models/job.py @@ -28,5 +28,7 @@ class Job(SQLModel, table=True): protocol_path: str = "" docx_path: str = "" pdf_path: str = "" + step_started_at: Optional[datetime] = Field(default=None) + last_heartbeat_at: Optional[datetime] = Field(default=None) created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc)) updated_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc)) diff --git a/lxc-frontend/app/services/pipeline.py b/lxc-frontend/app/services/pipeline.py index 38e7891..11b900d 100644 --- a/lxc-frontend/app/services/pipeline.py +++ b/lxc-frontend/app/services/pipeline.py @@ -1,7 +1,9 @@ +import asyncio import json import logging from datetime import datetime, timezone from pathlib import Path +from typing import Awaitable, TypeVar from sqlmodel import Session @@ -12,6 +14,10 @@ from app.models.job import Job, JobStatus log = logging.getLogger("voice-agent.pipeline") +T = TypeVar("T") + +HEARTBEAT_INTERVAL = 3.0 # Sekunden + def _update(job_id: int, **fields) -> None: with Session(engine) as s: @@ -32,8 +38,51 @@ def _existing(path_str: str) -> Path | None: return p if p.exists() and p.stat().st_size > 0 else None +def _start_step(job_id: int, status: str, progress: int) -> None: + """Setzt status/progress + step_started_at + initialen Heartbeat.""" + now = datetime.now(timezone.utc) + _update( + job_id, + status=status, + progress=progress, + step_started_at=now, + last_heartbeat_at=now, + error="", + ) + + +async def _with_heartbeat(job_id: int, coro: Awaitable[T]) -> T: + """Führt `coro` aus und pingt parallel alle paar Sekunden last_heartbeat_at. + + Damit sieht die UI live, ob ein Schritt noch lebt — auch wenn der Mac + minutenlang an einer Ollama-Generierung arbeitet. + """ + stop = asyncio.Event() + + async def beat() -> None: + while not stop.is_set(): + try: + _update(job_id, last_heartbeat_at=datetime.now(timezone.utc)) + except Exception: # noqa: BLE001 + pass # Heartbeat darf den Job nie killen + try: + await asyncio.wait_for(stop.wait(), timeout=HEARTBEAT_INTERVAL) + except asyncio.TimeoutError: + pass + + beat_task = asyncio.create_task(beat()) + try: + return await coro + finally: + stop.set() + try: + await beat_task + except Exception: # noqa: BLE001 + pass + + async def run_pipeline(job_id: int) -> None: - """Pipeline: transcribe → summarize → protocol → DOCX. + """Pipeline: transcribe → preload → summarize → protocol → DOCX → PDF. Idempotent: bereits vorhandene Zwischenergebnisse werden weiterverwendet, damit ein 'Erneut versuchen' nicht die teure Transkription wiederholt. @@ -64,9 +113,9 @@ async def run_pipeline(job_id: int) -> None: tx = json.loads(existing_tx_path.read_text(encoding="utf-8")) _update(job_id, status=JobStatus.TRANSCRIBING, progress=45) else: - _update(job_id, status=JobStatus.TRANSCRIBING, progress=10, error="") + _start_step(job_id, JobStatus.TRANSCRIBING, 10) log.info("[job %s] transcribe (profile=%s)", job_id, profile) - tx = await client.transcribe(audio_path, language="de") + tx = await _with_heartbeat(job_id, client.transcribe(audio_path, language="de", job_id=job_id)) transcript_path = job_dir / "transcript.json" transcript_path.write_text(json.dumps(tx, ensure_ascii=False, indent=2), encoding="utf-8") _update(job_id, transcript_path=str(transcript_path), progress=45) @@ -75,15 +124,24 @@ async def run_pipeline(job_id: int) -> None: s.get("text", "") for s in tx.get("segments", []) ) - # 2) SUMMARY + # 2) SUMMARY — vorher Preload, damit Ollama nicht mitten drin lädt if existing_sum_path: log.info("[job %s] reuse summary %s", job_id, existing_sum_path.name) summary = json.loads(existing_sum_path.read_text(encoding="utf-8")) _update(job_id, status=JobStatus.SUMMARIZING, progress=70) else: - _update(job_id, status=JobStatus.SUMMARIZING, progress=55) + _start_step(job_id, JobStatus.SUMMARIZING, 50) + log.info("[job %s] preload model", job_id) + try: + await _with_heartbeat(job_id, client.preload(profile=profile, job_id=job_id)) + except Exception as e: # noqa: BLE001 + log.warning("[job %s] preload failed (non-fatal): %s", job_id, e) + _update(job_id, progress=55) log.info("[job %s] summarize", job_id) - summary = await client.summarize(transcript_text, title=title, profile=profile) + summary = await _with_heartbeat( + job_id, + client.summarize(transcript_text, title=title, profile=profile, job_id=job_id), + ) summary_path = job_dir / "summary.json" summary_path.write_text(json.dumps(summary, ensure_ascii=False, indent=2), encoding="utf-8") _update(job_id, summary_path=str(summary_path), progress=70) @@ -94,9 +152,12 @@ async def run_pipeline(job_id: int) -> None: protocol = json.loads(existing_pro_path.read_text(encoding="utf-8")) _update(job_id, status=JobStatus.PROTOCOLLING, progress=85) else: - _update(job_id, status=JobStatus.PROTOCOLLING, progress=75) + _start_step(job_id, JobStatus.PROTOCOLLING, 75) log.info("[job %s] protocol", job_id) - protocol = await client.protocol(transcript_text, summary, title=title, profile=profile) + protocol = await _with_heartbeat( + job_id, + client.protocol(transcript_text, summary, title=title, profile=profile, job_id=job_id), + ) protocol_path = job_dir / "protocol.json" protocol_path.write_text(json.dumps(protocol, ensure_ascii=False, indent=2), encoding="utf-8") _update(job_id, protocol_path=str(protocol_path), progress=85) @@ -105,9 +166,12 @@ async def run_pipeline(job_id: int) -> None: if existing_dx_path: log.info("[job %s] reuse docx %s", job_id, existing_dx_path.name) else: - _update(job_id, status=JobStatus.EXPORTING, progress=90) + _start_step(job_id, JobStatus.EXPORTING, 90) log.info("[job %s] export docx", job_id) - docx_bytes = await client.export_docx(protocol, profile=profile) + docx_bytes = await _with_heartbeat( + job_id, + client.export_docx(protocol, profile=profile, job_id=job_id), + ) docx_path = job_dir / "protocol.docx" docx_path.write_bytes(docx_bytes) _update(job_id, docx_path=str(docx_path), progress=95) @@ -119,7 +183,10 @@ async def run_pipeline(job_id: int) -> None: else: _update(job_id, status=JobStatus.EXPORTING, progress=96) log.info("[job %s] export pdf", job_id) - pdf_bytes = await client.export_pdf(protocol, profile=profile) + pdf_bytes = await _with_heartbeat( + job_id, + client.export_pdf(protocol, profile=profile, job_id=job_id), + ) pdf_path = job_dir / "protocol.pdf" pdf_path.write_bytes(pdf_bytes) _update(job_id, pdf_path=str(pdf_path), status=JobStatus.DONE, progress=100, error="") diff --git a/lxc-frontend/app/static/app.js b/lxc-frontend/app/static/app.js index 56174b3..40198f1 100644 --- a/lxc-frontend/app/static/app.js +++ b/lxc-frontend/app/static/app.js @@ -76,6 +76,38 @@ function fmtDate(s) { return d.toLocaleString("de-DE", { dateStyle: "short", timeStyle: "medium" }); } +function fmtSecs(sec) { + if (!isFinite(sec) || sec < 0) return "?"; + if (sec < 60) return `${Math.floor(sec)}s`; + const m = Math.floor(sec / 60), s = Math.floor(sec % 60); + return `${m}m ${s}s`; +} + +const RUNNING_STATES = new Set(["queued", "transcribing", "summarizing", "protocolling", "exporting"]); +const DIAG_STATES = new Set([...RUNNING_STATES, "failed"]); + +// Welche Jobs haben aktuell ein offenes Diag-Panel? Über Re-Renders hinweg merken. +const openDiagIds = new Set(); + +function liveTimingHtml(job) { + if (!RUNNING_STATES.has(job.status) || !job.step_started_at) return ""; + const now = Date.now(); + const stepSec = (now - new Date(job.step_started_at).getTime()) / 1000; + let parts = [`läuft seit ${fmtSecs(stepSec)}`]; + if (job.last_heartbeat_at) { + const beatSec = (now - new Date(job.last_heartbeat_at).getTime()) / 1000; + const stale = beatSec > 10; + parts.push(`letzter Ping vor ${fmtSecs(beatSec)}${stale ? " ⚠" : ""}`); + } + return `${parts.join(" · ")}`; +} + +function diagButtonHtml(job) { + if (!DIAG_STATES.has(job.status)) return ""; + const open = openDiagIds.has(String(job.id)); + return ``; +} + async function api(path, opts = {}) { const r = await fetch(path, { credentials: "same-origin", @@ -294,10 +326,13 @@ function row(job) { const retryBtn = job.status !== "done" ? `` : ""; + const timing = liveTimingHtml(job); const statusCell = ` ${escapeHtml(job.status)} + ${timing ? `
${timing}
` : ""} ${job.error ? `
${escapeHtml(job.error)}
` : ""} ${retryBtn} + ${diagButtonHtml(job)} `; tr.innerHTML = ` @@ -311,6 +346,15 @@ function row(job) { return tr; } +// Zweite Tabellenzeile direkt unter `tr`, in die das Diagnose-Panel rutscht. +function diagRow(job) { + const tr = document.createElement("tr"); + tr.className = "diag-row hidden"; + tr.dataset.diagId = String(job.id); + tr.innerHTML = `
Lade Diagnose …
`; + return tr; +} + function card(job) { const div = document.createElement("div"); div.className = "jc"; @@ -328,6 +372,7 @@ function card(job) { return `${label}`; }).join(""); + const timing = liveTimingHtml(job); div.innerHTML = `
@@ -341,8 +386,15 @@ function card(job) {
${job.progress}%
+ ${timing ? `
${timing}
` : ""} ${job.error ? `
${escapeHtml(job.error)}
` : ""} - ${job.status !== "done" ? `` : ""} +
+ ${job.status !== "done" ? `` : ""} + ${diagButtonHtml(job)} +
+
aktualisiert: ${escapeHtml(fmtDate(job.updated_at))}
${dlButtons}
`; @@ -364,13 +416,83 @@ async function loadJobs() { } jobs.forEach((j) => { jobsBody.appendChild(row(j)); + jobsBody.appendChild(diagRow(j)); jobsCards.appendChild(card(j)); }); + // Vorher offene Diagnosen wieder aufklappen + frisch nachladen. + openDiagIds.forEach((id) => { + document.querySelectorAll(`[data-diag-id="${id}"]`).forEach((el) => el.classList.remove("hidden")); + refreshDiag(id); + }); } catch (e) { console.error(e); } } +function renderDiag(diag) { + const job = diag.job; + const lines = (diag.mac_log || []); + const stepInfo = diag.step_started_at + ? `Stage ${escapeHtml(job.status)} seit ${fmtSecs((Date.now() - new Date(diag.step_started_at).getTime()) / 1000)}` + : "kein Stage-Start gespeichert"; + const beatInfo = diag.last_heartbeat_at + ? `Letzter Heartbeat vor ${fmtSecs((Date.now() - new Date(diag.last_heartbeat_at).getTime()) / 1000)}` + : "noch kein Heartbeat"; + const errBlock = job.error + ? `
Fehler: ${escapeHtml(job.error)}
` + : ""; + const logBlock = lines.length + ? `
${escapeHtml(lines.join("\n"))}
` + : `
Keine Mac-Worker-Logzeilen für diesen Job (Buffer leer oder Mac nicht erreichbar).
`; + return ` +
+
${stepInfo}
+
${beatInfo}
+
Profil: ${escapeHtml(job.profile)}
+
+ ${errBlock} +
Mac-Worker-Log (letzte ${lines.length} Zeilen)
+ ${logBlock} + `; +} + +async function refreshDiag(jobId) { + let data; + try { + const r = await api(`/api/jobs/${jobId}/diag`); + if (!r.ok) throw new Error(`HTTP ${r.status}`); + data = await r.json(); + } catch (e) { + document.querySelectorAll(`[data-diag-id="${jobId}"] .diag-content`).forEach((el) => { + el.innerHTML = `
Diagnose konnte nicht geladen werden: ${escapeHtml(e.message)}
`; + }); + return; + } + const html = renderDiag(data); + document.querySelectorAll(`[data-diag-id="${jobId}"] .diag-content`).forEach((el) => { + el.innerHTML = html; + }); +} + +// Diagnose-Toggle (Tabelle und Karten gemeinsam, Event-Delegation) +document.addEventListener("click", (ev) => { + const btn = ev.target.closest(".btn-diag"); + if (!btn) return; + ev.preventDefault(); + const id = btn.dataset.id; + const wasOpen = openDiagIds.has(id); + if (wasOpen) { + openDiagIds.delete(id); + document.querySelectorAll(`[data-diag-id="${id}"]`).forEach((el) => el.classList.add("hidden")); + btn.textContent = "Diagnose ▾"; + } else { + openDiagIds.add(id); + document.querySelectorAll(`[data-diag-id="${id}"]`).forEach((el) => el.classList.remove("hidden")); + btn.textContent = "Diagnose ▴"; + refreshDiag(id); + } +}); + form.addEventListener("submit", (ev) => { ev.preventDefault(); // Quelle: Aufnahme bevorzugt, sonst File-Input diff --git a/lxc-frontend/app/static/style.css b/lxc-frontend/app/static/style.css index 00e66d7..31ba8c7 100644 --- a/lxc-frontend/app/static/style.css +++ b/lxc-frontend/app/static/style.css @@ -560,3 +560,93 @@ select { text-transform: lowercase; vertical-align: middle; } + +/* Live-Timing direkt unter dem Status-Pill (läuft seit · letzter Ping vor) */ +.status-timing, .jc-timing { + margin-top: 4px; + font-size: 11px; + line-height: 1.3; +} +.live-timing { color: var(--muted); } + +/* Diagnose-Button – wie btn-retry, aber neutraler Ton */ +.btn-diag { + display: inline-block; + margin-top: 6px; + margin-left: 4px; + padding: 4px 10px; + font-size: 12px; + background: rgba(120,120,120,.10); + border: 1px solid rgba(160,160,160,.35); + color: var(--muted); + border-radius: 5px; + text-decoration: none; + cursor: pointer; +} +.btn-diag:hover { background: rgba(160,160,160,.20); color: inherit; } + +/* Aktions-Reihe in der mobilen Karte */ +.jc-actions { + display: flex; + flex-wrap: wrap; + gap: 6px; + margin-top: 4px; +} + +/* Diagnose-Panel: Tabelle (zweite Zeile, full-width) und Karte */ +.diag-row > td { + background: rgba(255,255,255,.03); + border-top: 0; + padding: 10px 12px; +} +.diag-panel { + margin-top: 10px; + padding: 10px; + background: rgba(255,255,255,.03); + border: 1px solid rgba(255,255,255,.08); + border-radius: 6px; +} +.diag-meta { + display: flex; + gap: 16px; + flex-wrap: wrap; + font-size: 12px; + color: var(--muted); + margin-bottom: 6px; +} +.diag-meta code { + background: rgba(255,255,255,.08); + padding: 1px 4px; + border-radius: 3px; +} +.diag-err { + margin: 6px 0; + padding: 6px 8px; + background: rgba(231,76,60,.10); + border: 1px solid rgba(231,76,60,.35); + border-radius: 4px; + color: var(--err); + font-size: 12px; + word-break: break-word; +} +.diag-loghead { + margin-top: 8px; + font-size: 11px; + text-transform: uppercase; + letter-spacing: .05em; + color: var(--muted); +} +.diag-log { + margin: 4px 0 0 0; + padding: 8px; + background: rgba(0,0,0,.30); + border: 1px solid rgba(255,255,255,.06); + border-radius: 4px; + max-height: 320px; + overflow: auto; + font-family: ui-monospace, "SF Mono", Menlo, Consolas, monospace; + font-size: 11.5px; + line-height: 1.45; + white-space: pre-wrap; + word-break: break-word; +} diff --git a/mac-worker/app/core/joblog.py b/mac-worker/app/core/joblog.py new file mode 100644 index 0000000..ab62032 --- /dev/null +++ b/mac-worker/app/core/joblog.py @@ -0,0 +1,69 @@ +"""Per-Job Log-Ringbuffer. + +Während ein HTTP-Request läuft, setzt eine Middleware die ContextVar `current_job` +auf die `X-Job-Id` aus dem Header. Ein Logging-Handler greift diesen Wert ab und +schreibt jede Logzeile zusätzlich in einen Ringbuffer für genau diesen Job. + +Die LXC-Frontend kann den Buffer per `GET /api/jobs/{job_id}/log` abrufen und in +der Diagnose-Anzeige rendern — der User sieht so ohne SSH-Zugang zum Mac, was +während eines hängenden Calls passiert. +""" +from __future__ import annotations + +import logging +import time +from collections import deque +from contextvars import ContextVar +from threading import Lock + +# Per-Coroutine: welcher Job-ID gehört dem aktuellen Async-Kontext. +current_job: ContextVar[int | None] = ContextVar("current_job", default=None) + +_RING_MAX = 200 # Zeilen pro Job +_TTL_SECONDS = 3600 # alte Buffers nach 1h löschen +_buffers: dict[int, dict] = {} +_lock = Lock() + + +def append(job_id: int, line: str) -> None: + now = time.time() + with _lock: + buf = _buffers.get(job_id) + if buf is None: + buf = _buffers[job_id] = {"lines": deque(maxlen=_RING_MAX), "last": now} + ts = time.strftime("%H:%M:%S", time.localtime(now)) + buf["lines"].append(f"{ts} {line}") + buf["last"] = now + # Lazy GC: alte Buffer rauswerfen. + for jid in list(_buffers): + if now - _buffers[jid]["last"] > _TTL_SECONDS: + _buffers.pop(jid, None) + + +def get(job_id: int, last_n: int = 200) -> list[str]: + with _lock: + buf = _buffers.get(job_id) + if buf is None: + return [] + return list(buf["lines"])[-last_n:] + + +class JobLogHandler(logging.Handler): + """Spiegelt jede Log-Zeile in den Ringbuffer des aktuellen Jobs.""" + + def emit(self, record: logging.LogRecord) -> None: + jid = current_job.get() + if jid is None: + return + try: + msg = self.format(record) + except Exception: # noqa: BLE001 + msg = record.getMessage() + append(jid, msg) + + +def install() -> None: + """An Root-Logger anhängen, damit alle Module abgegriffen werden.""" + h = JobLogHandler() + h.setFormatter(logging.Formatter("[%(levelname)s] %(name)s: %(message)s")) + logging.getLogger().addHandler(h) diff --git a/mac-worker/app/main.py b/mac-worker/app/main.py index 73aadac..a13d8e0 100644 --- a/mac-worker/app/main.py +++ b/mac-worker/app/main.py @@ -2,11 +2,12 @@ import logging import secrets from pathlib import Path -from fastapi import FastAPI, File, Form, HTTPException, UploadFile +import httpx +from fastapi import FastAPI, File, Form, HTTPException, Request, UploadFile from fastapi.responses import Response from pydantic import BaseModel -from app.core import ollama_client, profiles +from app.core import joblog, 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 @@ -15,9 +16,30 @@ 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") +# Per-Job Log-Spiegel installieren — fängt alle Logs während eines Requests +# mit X-Job-Id Header und legt sie im Ringbuffer ab. +joblog.install() + app = FastAPI(title="Voice-Agent Mac-Worker") +@app.middleware("http") +async def attach_job_id(request: Request, call_next): + """Bindet X-Job-Id-Header an die ContextVar, damit Logs zugeordnet werden.""" + raw = request.headers.get("x-job-id") + token = None + if raw: + try: + token = joblog.current_job.set(int(raw)) + except ValueError: + token = None + try: + return await call_next(request) + finally: + if token is not None: + joblog.current_job.reset(token) + + class SummarizeIn(BaseModel): transcript: str title: str = "" @@ -41,6 +63,10 @@ class PdfIn(BaseModel): profile: str | None = None +class PreloadIn(BaseModel): + profile: str | None = None + + @app.get("/health") async def health(): info = { @@ -115,3 +141,40 @@ async def export_pdf(payload: PdfIn): media_type="application/pdf", headers={"Content-Disposition": 'attachment; filename="protocol.pdf"'}, ) + + +@app.post("/api/preload") +async def preload(payload: PreloadIn): + """Forciert Ollama-Modell-Load (keep_alive 30 m) mit Mini-Prompt. + + Wird vom LXC-Frontend vor summarize aufgerufen, damit Ollama den + Modell-Reload nicht *während* der eigentlichen Generierung macht + (häufige Ursache von 'Server disconnected without sending a response'). + """ + profile_obj = profiles.get(payload.profile) + log.info("preload model=%s profile=%s", settings.ollama_model, profile_obj.name) + url = f"{settings.ollama_url.rstrip('/')}/api/generate" + body = { + "model": settings.ollama_model, + "prompt": "ping", + "stream": False, + "keep_alive": "30m", + "options": {"num_predict": 1, "temperature": 0}, + } + timeout = httpx.Timeout(connect=15.0, read=300.0, write=15.0, pool=10.0) + try: + async with httpx.AsyncClient(timeout=timeout) as c: + r = await c.post(url, json=body) + r.raise_for_status() + data = r.json() + except Exception as e: # noqa: BLE001 + log.warning("preload failed: %s", e) + raise HTTPException(503, f"Ollama preload fehlgeschlagen: {e}") from e + log.info("preload ok load_duration=%dms", int(data.get("load_duration", 0) / 1_000_000)) + return {"ok": True, "model": settings.ollama_model, "load_duration_ns": data.get("load_duration", 0)} + + +@app.get("/api/jobs/{job_id}/log") +async def job_log(job_id: int, last: int = 200): + """Liefert die letzten Log-Zeilen, die beim Bearbeiten dieses Jobs entstanden sind.""" + return {"job_id": job_id, "lines": joblog.get(job_id, last)}