diff --git a/app/db.js b/app/db.js index ca038ee..77179ab 100644 --- a/app/db.js +++ b/app/db.js @@ -191,6 +191,21 @@ export async function setTranscript(id, transcript, words) { await replaceWords(id, words || []); } +// Every in-flight job dies with the service: systemd SIGKILLs the whisper, +// spk_embed and rkllm children on stop, and nothing resumes them. The row +// however still reads 'pending' with a start stamp, so the GUI shows +// "KI-Analyse läuft…" and a timer counting up forever on work that no longer +// exists (observed: 2 h 34 min on a 29-minute recording after a deploy +// restart). Called once at boot — at that moment no job CAN legitimately be +// running — so the retry button appears instead of a phantom counter. +export async function failStaleJobs() { + const [a] = await pool.query( + "UPDATE recordings SET analysis_status='error' WHERE analysis_status='pending' AND analysis_started_at IS NOT NULL"); + const [t] = await pool.query( + "UPDATE recordings SET status='error' WHERE status='transcribing'"); + return { analyses: a.affectedRows, transcriptions: t.affectedRows }; +} + // Stamp the wall-clock start of a processing run (transcribe+diar+LLM). The // GUI shows a live counter from here; updateAnalysis('done') freezes the total // into analysis_ms. Called at every trigger (record-stop, retranscribe, reanalyze). diff --git a/app/server.js b/app/server.js index 9b813cf..c91de4e 100644 --- a/app/server.js +++ b/app/server.js @@ -493,7 +493,15 @@ app.post('/api/search/answer', wrap(async (req, res) => { const q = String((req.body && req.body.q) || '').trim(); if (!q) return res.status(400).json({ error: 'q fehlt' }); const { hits } = await search.search(q, { limit: 20 }); - res.json(await withNpuLock('search-answer', () => search.answer(q, hits))); + // stopServer() afterwards is NOT optional: rkllm stays resident with ~6GB + // once loaded, and the next speaker-embedding pass then OOM-dies next to it + // (analyzeRecording/rediarize free it for exactly that reason). A search + // answer left the model squatting 82% of RAM for hours. + try { + res.json(await withNpuLock('search-answer', () => search.answer(q, hits))); + } finally { + llm.stopServer(); + } })); app.get('/api/search/status', wrap(async (req, res) => { const [[{ n }]] = await db.pool.query('SELECT COUNT(*) AS n FROM passages'); @@ -718,6 +726,9 @@ app.get(/^(?!\/api|\/media).*/, (req, res) => res.sendFile(path.join(__dirname, const PORT = process.env.PORT || 3000; db.ensureSchema() .catch(e => console.error('ensureSchema:', e.message)) + .then(() => db.failStaleJobs()) + .then(r => { if (r && (r.analyses || r.transcriptions)) console.log(`boot: ${r.analyses} abgebrochene Analysen, ${r.transcriptions} abgebrochene Transkriptionen als Fehler markiert`); }) + .catch(e => console.error('failStaleJobs:', e.message)) .finally(() => app.listen(PORT, '0.0.0.0', () => { console.log(`callrec listening on :${PORT}`); // Retention sweep at boot + daily. unref() so it never holds the process open. diff --git a/docs/08-changelog.md b/docs/08-changelog.md index 2ac68a3..2fa7976 100644 --- a/docs/08-changelog.md +++ b/docs/08-changelog.md @@ -3,6 +3,36 @@ Chronik der Entwicklungsschritte. **Regel: jede zukünftige Änderung bekommt hier einen Eintrag** (Datum, Was, Warum, betroffene Dateien/Backups). +## 2026-07-31 (spät) — Zwei Fehler aus dem Suche-Feature behoben + +Beide beim Nachsehen aufgefallen, warum die GUI bei einer 29:32-Aufnahme **2 h 34 min Analysezeit** anzeigte. + +### 1. Geisterzähler nach Dienst-Neustart + +Ein `systemctl restart` SIGKILLt alle Kindprozesse (whisper, spk_embed, rkllm). Die Datenbankzeile blieb +aber auf `analysis_status='pending'` mit gesetztem `analysis_started_at` stehen — die GUI zeigte darauf +„KI-Analyse läuft… (NPU)" und einen Zähler, der ewig hochlief, für Arbeit die es nicht mehr gab. + +Ausgelöst hatte es ein Deploy-Neustart um 13:11 mitten in der laufenden Analyse von #87. + +**Fix `db.failStaleJobs()`, aufgerufen beim Start:** `pending` → `error`, `transcribing` → `error`. Zum +Startzeitpunkt kann kein Job legitim laufen, also ist das eindeutig. Der Nutzer sieht jetzt den +Wiederholen-Knopf statt eines Phantomzählers. + +### 2. `/api/search/answer` ließ rkllm resident + +Die Antwortroute lud rkllm über `llm.askRaw()` und gab es nie frei. Gemessen: **1 h 48 min lang 82,6 % RAM +belegt, nur 237 MB verfügbar.** + +Das ist genau der Zustand, den `analyzeRecording` und `rediarize` mit ihrem `llm.stopServer()` bewusst +vermeiden — der TitaNet-Helfer für die Sprecher-Embeddings stirbt neben dem residenten 5,9-GB-Modell an +OOM (auf #71 ergab das seinerzeit 48 Rohsprecher). Die nächste Transkription wäre also mit hoher +Wahrscheinlichkeit fehlgeschlagen. + +**Fix:** `llm.stopServer()` im `finally` der Route. Danach wieder 7,3 GB verfügbar. + +Dateien: `app/server.js`, `app/db.js`. Board-Backups: `.bak-stalejobs`. + ## 2026-07-31 (nachmittags) — Abschnittstitel in der Zusammenfassung fett Die Section-Zusammenfassung kommt als `Abschnittstitel:\nFließtext…`-Blöcke zurück (llm.js/analyzeLong),