Files
Aime/app/llm.js
T
schakal00783andClaude Fable 5 2f00ddeec6 #70-Debug: Diar Weak-Fold (7->4 Sprecher), LLM-Robustheit + Whole-Pass-Modellgrenze, Thermal-Cap fuer analyzeRecording
- diar.js: Stufe-3 Weak-Fold (DIAR_MIN_TOTAL_SEC 20s) gegen Phantom-Sprecher
- llm.js: End-Instruktion, JSON-Repair, Sanity+Fallback, Chunk-Retry, Quote-Sanitizer, gelabelter/gewichteter Reduce; WHOLE_MAX 20000 (Gemma-4B-Grenze)
- rkllm_server.cpp: max_context_len 8192->12288
- server.js: analyzeRecording unter A76-Takt-Cap (95-97 Grad ungedeckelt)
- Doku Kap. 03/04/05 + Changelog

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0113vDKdSNtD3Dwcw4QMu9yV
2026-07-16 14:37:07 +02:00

538 lines
34 KiB
JavaScript

// NPU LLM integration. Spawns the persistent rkllm_server (Qwen2.5-3B on the
// RK3588 NPU) ONCE and talks to it over the base64 line protocol. Inference is
// single-flight (the NPU handles one request at a time) via a promise chain.
import { spawn } from 'node:child_process';
import fs from 'node:fs';
import readline from 'node:readline';
const SERVER = '/opt/callrec/rkllm_server';
const MODEL = process.env.RKLLM_MODEL || '/opt/rkllm-models/qwen2.5-3b.rkllm';
let proc = null;
let ready = null; // Promise<void> resolved when the model printed READY
let rl = null;
let pending = null; // { resolve, reject } for the in-flight request
let chain = Promise.resolve(); // serializes requests
function b64e(s) { return Buffer.from(s, 'utf8').toString('base64'); }
function b64d(s) { return Buffer.from(s, 'base64').toString('utf8'); }
// Spawn one rkllm_server and return a promise that resolves when it prints
// READY. CRUCIAL: if the process dies before READY (e.g. rkllm_init fails with
// the transient NPU handle-alloc error "errno 14 Bad address" that happens when
// the whisper-turbo encoder only just released the NPU), the `ready` promise is
// REJECTED immediately — the old code only nulled the variable, so the awaiter
// hung the full 120s timeout, and a stale rejected `ready` then cascaded to
// every queued request (all 7 chunks "failed" in the same second → silent empty
// analysis). startServer() below retries this a few times.
function spawnServer() {
let settleReject = () => {};
let done = false;
proc = spawn('taskset', ['-c', '4-7', SERVER, MODEL], { stdio: ['pipe', 'pipe', 'pipe'] });
proc.stderr.on('data', d => process.stderr.write('[rkllm] ' + d));
ready = new Promise((resolve, reject) => {
settleReject = reject;
const to = setTimeout(() => { if (!done) { done = true; reject(new Error('rkllm READY timeout')); } }, 60000);
rl = readline.createInterface({ input: proc.stdout });
rl.on('line', line => {
if (line === 'READY') { if (!done) { done = true; clearTimeout(to); resolve(); } return; }
if (!pending) return;
const p = pending; pending = null;
if (line.startsWith('OK ')) p.resolve(b64d(line.slice(3)));
else p.reject(new Error(line.replace(/^ERR\s*/, '') || 'rkllm error'));
});
});
const cleanup = () => {
proc = null; ready = null; rl = null;
if (!done) { done = true; settleReject(new Error('rkllm beendet vor READY (init fehlgeschlagen)')); }
if (pending) { pending.reject(new Error('rkllm beendet')); pending = null; }
};
proc.on('exit', cleanup);
proc.on('error', () => cleanup());
}
async function startServer() {
if (proc && ready) { await ready; return; } // already up (or coming up)
if (!fs.existsSync(MODEL)) throw new Error('LLM-Modell fehlt: ' + MODEL);
// rkllm_init can transiently fail right after the NPU-whisper encoder ran
// (NPU handle not released yet). A short wait + respawn recovers it — later
// loads succeed. Up to 5 attempts, ~4s apart.
let lastErr;
for (let attempt = 1; attempt <= 5; attempt++) {
try { spawnServer(); await ready; return; }
catch (e) {
lastErr = e;
try { if (proc) proc.kill('SIGKILL'); } catch {}
proc = null; ready = null; rl = null;
console.warn(`[rkllm] start ${attempt}/5 failed: ${e.message}${attempt < 5 ? ' — retry in 4s' : ''}`);
if (attempt < 5) await new Promise(r => setTimeout(r, 4000));
}
}
throw lastErr || new Error('rkllm start fehlgeschlagen');
}
// Send one prompt, resolve with the raw completion string. Each request is
// serialized on `chain`, but a FAILED request must NOT poison the chain for the
// next one (otherwise one bad chunk fails all following chunks instantly).
function ask(prompt) {
const run = chain.then(async () => {
await startServer();
return await new Promise((resolve, reject) => {
pending = { resolve, reject };
proc.stdin.write(b64e(prompt) + '\n');
});
});
chain = run.catch(() => {}); // next request starts from a clean, resolved chain
return run;
}
export function status() {
return { model: MODEL, modelPresent: fs.existsSync(MODEL), running: !!proc };
}
// Free the ~3.1GB rkllm_server (NPU LLM) from RAM. Called before the NPU whisper
// transcriber runs: turbo rknn (1.35GB) + CT2 decoder alongside a resident rkllm
// OOM-killed the 7.7GB board. rkllm auto-respawns on the next analyze()
// (startServer), and analysis always follows transcription, so this just reloads
// the model a few seconds later. No-op if not running.
export function stopServer() {
if (!proc) return Promise.resolve();
const p = proc;
return new Promise(resolve => {
let settled = false;
const done = () => { if (!settled) { settled = true; resolve(); } };
p.once('exit', done);
try { p.kill('SIGTERM'); } catch { done(); return; }
setTimeout(() => { try { p.kill('SIGKILL'); } catch {} setTimeout(done, 500); }, 5000);
});
}
// Robustly pull the first {...} JSON object out of a model completion.
function extractJson(text) {
let t = text.replace(/```(json)?/gi, '').trim();
const s = t.indexOf('{'), e = t.lastIndexOf('}');
if (s === -1 || e === -1 || e < s) return null;
const cut = t.slice(s, e + 1);
try { return JSON.parse(cut); } catch {}
// Gemma degrades JSON on longer generations: typographic quotes (“”„) as
// string delimiters and trailing commas (#70 round-5 head). Only reached
// when the strict parse failed, so a lossy fix-up is a net win.
try {
const fixed = cut.replace(/[“”„«»]/g, '"').replace(/[‘’]/g, "'").replace(/,\s*([}\]])/g, '$1');
return JSON.parse(fixed);
} catch { return null; }
}
// Normalize an LLM string array: trim, drop empties and the literal "null" the
// model sometimes emits as a string inside todo/participant lists.
function cleanList(arr) {
return (arr || []).map(x => {
// Gemma sometimes emits objects ({"aufgabe": "..."}) instead of strings —
// String() would persist "[object Object]"; use the string values instead.
if (x && typeof x === 'object') x = Object.values(x).filter(v => typeof v === 'string').join(' — ');
return String(x ?? '').trim().replace(/^[?¿\s]+|[?\s]+$/g, '');
}).filter(s => s && !/^(null|none|keine|-|\[object Object\])$/i.test(s));
}
// Compose the persisted summary text from the structured analysis JSON:
// short overview paragraph + bullet lists for key points and decisions.
// Rendered with white-space:pre-line in the UI, so \n and • survive.
function composeSummary(j) {
const parts = [];
const s = (j.zusammenfassung || j.summary || '').toString().trim();
if (s) parts.push(s);
const kp = cleanList(j.kernpunkte || j.punkte).map(x => '• ' + x).join('\n');
if (kp) parts.push('Kernpunkte:\n' + kp);
const en = cleanList(j.entscheidungen).map(x => '• ' + x).join('\n');
if (en) parts.push('Entscheidungen:\n' + en);
return parts.join('\n\n');
}
// Structured analysis. The transcript is ASR output and may contain recognition
// errors — the model must infer the intended words from the WHOLE context and use
// the correct terms everywhere (e.g. "Gekopressor" → "Kompressor").
// NOTE: contains NO concrete real-word examples on purpose. The 3B model echoes
// any concrete noun from this instruction into the title/summary (once produced a
// phantom "Kompressor/Reifendruck" summary that was just an old prompt example).
// Keep the correction PATTERN abstract; the deterministic glossary handles the
// important real-word fixes (e.g. Aime).
const CORRECTION_HINT =
`WICHTIG: Die Spracherkennung erzeugt LAUTÄHNLICHE Verwechslungen. Jedes Wort, das kein gebräuchliches deutsches Wort ist oder als erfundenes Kompositum wirkt, ist ein FEHLER — ersetze es durch das lautähnliche, im Kontext sinnvolle ECHTE Wort; nutze dazu den GESAMTEN Gesprächskontext und wähle immer ein reales, gebräuchliches deutsches Wort. Rekonstruiere auch verstümmelte Aufforderungen: endet ein Satz auf ein loses trennbares Präfix (auf/ab/ein/aus/zu) zusammen mit "bitte" oder "mal", ergänze das gemeinte Verb. STRENG: Diese Anleitung ist nur Regelwerk — übernimm KEINE Wörter, Themen oder Beispiele aus dieser Anleitung in Titel, Zusammenfassung, Aufgaben oder Transkript. Schreibe ausschließlich über das, was im Transkript unten tatsächlich gesprochen wird.`;
// Build an extra correction hint from the user's domain glossary (known proper
// nouns / terms the ASR mis-hears, e.g. "Aime" heard as "Amy"). Applied in the
// LLM correction step — the SAFE place (feeding a term list to whisper as an
// initial-prompt makes it echo-loop; verified). `glossary` = [{term, soundalikes}].
function glossaryHint(glossary) {
const g = (glossary || []).filter(x => x && x.term && String(x.term).trim());
if (!g.length) return '';
const terms = g.map(x => String(x.term).trim());
const maps = g.filter(x => x.soundalikes && String(x.soundalikes).trim())
.map(x => `${String(x.soundalikes).split(',').map(s => `"${s.trim()}"`).filter(s => s !== '""').join('/')} → "${String(x.term).trim()}"`);
let s = `\nBEKANNTE EIGENNAMEN/FACHBEGRIFFE (feste, korrekte Schreibweise — im Text IMMER exakt so schreiben): ${terms.join(', ')}. Wenn ein lautähnliches Wort im Transkript einem dieser Begriffe entspricht, ersetze es durch die korrekte Schreibweise. Erfinde diese Begriffe aber NICHT hinzu, wo sie nicht gemeint sind.`;
if (maps.length) s += ` Bekannte Verwechslungen: ${maps.join('; ')}.`;
return s;
}
// Combine the base correction rule with the per-recording glossary.
const correctionHint = (glossary) => CORRECTION_HINT + glossaryHint(glossary);
// Shared todo-extraction rule (used verbatim in the combined suffix AND the
// JSON-only fallback so the two never drift). Tuned for RECALL: the 3B model
// was dropping clear work assignments under an over-cautious "im Zweifel leer"
// rule, so this frames todos positively (imperative/assignment/commitment with a
// concrete action+object) while still excluding smalltalk and child play.
const TODO_RULE =
`"todos" = nur konkret angewiesene oder fest zugesagte Aufgaben, die WÖRTLICH IM TRANSKRIPT VORKOMMEN, jeweils als kurzer Imperativ. Eine Aufgabe liegt vor, sobald im Transkript eine konkrete Handlung mit klarem Objekt angewiesen ("du machst/sortierst/schneidest X"; "X muss gemacht werden") oder fest zugesagt wird — auch beiläufig oder höflich ("räum mir bitte mal X auf"). Nimm jeden im Transkript klar genannten Auftrag auf; lasse ihn NICHT weg. KEINE Aufgaben sind: Smalltalk, Fragen, Begrüßung/Verabschiedung, allgemeine Aussagen ohne Handlung, kindliches Spiel und leere Meta-Aussagen. WICHTIG: Wenn im Transkript keine konkrete Aufgabe angewiesen oder zugesagt wird, gib "todos": [] zurück. Erfinde NIEMALS Aufgaben und übernimm KEINE Wörter oder Beispiele aus dieser Anleitung — jede Aufgabe muss durch eine konkrete Stelle im Transkript belegt sein. Niemals Sprecher-Namen oder "Sprecher X:" in eine Aufgabe schreiben.`;
// Anti-hallucination rule for the FREE-TEXT fields (title/summary). The 3B model
// greedily coins fake compound words when compressing (e.g. "Audio-Dualeintragung").
// Append to every summary-producing prompt.
const NO_INVENT =
`WICHTIG (keine Halluzination): Verwende in Titel und Zusammenfassung AUSSCHLIESSLICH reale, gebräuchliche deutsche Wörter und NUR Informationen, die im Transkript tatsächlich vorkommen. Erfinde NICHTS — keine Wörter, keine zusammengesetzten Fantasiebegriffe (z.B. NIEMALS Erfindungen wie "Audio-Dualeintragung"), keine Fakten, keine Namen, keine Zahlen. Bist du dir bei einem Wort unsicher, formuliere einfacher mit gebräuchlichen Wörtern statt zu raten. Halte die Zusammenfassung schlicht und nah am Gesagten.`;
// Runs on the ALREADY-CORRECTED transcript, so every field is consistent.
const PROMPT_JSON = (transcript) =>
`Du analysierst das Transkript eines deutschen Gesprächs.
Transkript:
"""
${transcript}
"""
Gib AUSSCHLIESSLICH gültiges JSON zurück (kein Markdown, keine Erklärung) mit genau diesen Feldern:
{"titel": "kurzer prägnanter Titel, max. 6 Wörter", "zusammenfassung": "2-4 Sätze, worum es ging", "teilnehmer": ["Name oder Rolle je beteiligter Person"], "todos": ["kurze Handlungsanweisung im Imperativ"]}
Regeln: Antworte auf Deutsch. "teilnehmer" = im Gespräch genannte/erkennbare Personen (Namen wenn genannt, sonst Rolle wie "Sprecher"); [] wenn unklar.
${TODO_RULE}
${NO_INVENT}`;
// Whole-conversation analysis prompt (16K-context path). The model sees the FULL
// transcript, reads through ASR errors via `hint`, and returns one rich JSON
// analysis. Richer summary than the short/chunk prompts (the whole arc is
// visible) but still analysis-only → small output.
const PROMPT_WHOLE_ANALYZE = (transcript, hint = CORRECTION_HINT) =>
`Du analysierst das VOLLSTÄNDIGE Transkript eines deutschen Gesprächs aus automatischer Spracherkennung. ${hint}
Lies das GESAMTE Gespräch und gib AUSSCHLIESSLICH gültiges JSON zurück (kein Markdown, keine Erklärung) mit genau diesen Feldern:
{"titel": "kurzer prägnanter Titel, max. 6 Wörter", "zusammenfassung": "kurzer Überblick in 2-4 Sätzen: worum es ging und was das konkrete Ergebnis war", "kernpunkte": ["wichtiger besprochener Punkt als kurzer Stichpunkt, je ein Punkt pro Thema"], "entscheidungen": ["im Gespräch getroffene Entscheidung als kurzer Satz"], "teilnehmer": ["Name oder Rolle je beteiligter Person"], "todos": ["kurze Handlungsanweisung im Imperativ"]}
Antworte auf Deutsch. Fülle alle Felder mit ECHTEM Inhalt aus dem Gespräch; schreibe niemals Feldbeschreibungen ab. WICHTIG — Gewichtung nach GESPRÄCHSANTEIL über das GANZE Gespräch (Anfang, Mitte UND Ende zählen gleich): Titel und Zusammenfassung müssen die Themen abbilden, über die am LÄNGSTEN gesprochen wurde; Smalltalk und kurz erwähnte Randthemen gehören NICHT in Titel oder Überblick. Nenne konkrete ERGEBNISSE statt abstrakter Oberbegriffe: wer macht was, welche Kontakte wurden geknüpft, welche Zusagen/Bewerbungen/Termine gab es. "kernpunkte" = 3-8 Stichpunkte über ALLE wichtigen Themen, nach Gesprächsanteil geordnet (wichtigstes zuerst); "entscheidungen" = nur wirklich getroffene Entscheidungen, [] wenn keine. "teilnehmer" = im Gespräch genannte/erkennbare Personen (Namen wenn genannt, sonst Rolle); [] wenn unklar.
${TODO_RULE}
${NO_INVENT}
Transkript:
"""
${transcript}
"""
Gib jetzt AUSSCHLIESSLICH das JSON-Objekt mit den Feldern titel, zusammenfassung, kernpunkte, entscheidungen, teilnehmer und todos zurück — jedes Feld gefüllt mit ECHTEM Inhalt aus dem Gespräch oben. Gewichte nach Gesprächsanteil über das GANZE Gespräch; nicht den Schluss-Smalltalk zusammenfassen. Kein anderer Text, keine Vor- oder Nachbemerkung, kein Markdown, NUR das JSON. Antworte auf Deutsch.`;
// ^ Instruction REPEATED after the transcript: with ~7k-token inputs Gemma-3-4B
// "loses" a top-only instruction and drifts into English chat about the
// conversation instead of analysing it (seen on #70 at 22.7k chars). Field
// names only, NO {"titel": ...} skeleton here — Gemma copied the "..."
// placeholders literally when the repeat included one. And NO "themen"
// inventory field: asking for a topic list first sent Gemma into a repetition
// loop of broken JSON (curly quotes, repeated keys) on the same input.
// Reformat a broken analysis into valid JSON. Sees ONLY the LLM's own broken
// output — small prompt, fast prefill, and no transcript to get distracted by.
const PROMPT_JSON_REPAIR = (broken) =>
`Die folgende Gesprächsanalyse sollte gültiges JSON sein, ist aber fehlerhaft formatiert. Gib NUR das korrigierte, gültige JSON zurück (kein Markdown, keine Erklärung) mit genau diesen Feldern:
{"titel": "...", "zusammenfassung": "...", "kernpunkte": ["..."], "entscheidungen": ["..."], "teilnehmer": ["..."], "todos": ["..."]}
Übernimm die Inhalte unverändert; fehlende Felder als leere Liste [].
Analyse:
"""
${broken}
"""`;
// Separate plain-text call for the cleaned transcript — avoids escaping a huge
// string inside JSON (fragile). Returns just the corrected text.
// Corrects ASR errors AND splits the single mixed mono transcript into a
// speaker-labelled dialogue by CONTEXT (this is text inference, not acoustic
// voice recognition). Output is plain "Name: Text" lines — robust to parse.
const PROMPT_CLEAN = (transcript, hint = CORRECTION_HINT) =>
`Das folgende deutsche Transkript stammt aus automatischer Spracherkennung eines Gesprächs mit möglicherweise MEHREREN Sprechern (in einer Tonspur gemischt). ${hint}
Aufgabe: Gib das korrigierte Gespräch als DIALOG zurück. Erkenne aus dem Kontext (Anrede, Fragen und Antworten, genannte Namen, Themen- und Perspektivwechsel) die Sprecherwechsel und ordne jede Aussage einem Sprecher zu. Verwende genannte Namen (z.B. "Michael"); wenn ein Name unbekannt ist, "Sprecher 1", "Sprecher 2". Wenn nur eine Person spricht, nutze durchgehend ein Label.
Format: pro Redebeitrag GENAU EINE Zeile im Format "Name: Text". Keine Einleitung, keine Erklärung, kein Markdown, keine Leerzeilen.
Transkript:
${transcript}`;
// When acoustic diarization already labelled the turns ("Sprecher 1: …" lines),
// the LLM must NOT re-guess speakers — only fix ASR errors and keep the labels.
const PROMPT_CLEAN_LABELED = (transcript, hint = CORRECTION_HINT) =>
`Das folgende deutsche Gespräch ist bereits nach Sprechern gegliedert (Zeilen im Format "Sprecher X: Text"), die Zuordnung stammt aus Stimm-Erkennung und ist korrekt. ${hint}
Aufgabe: Gib den Text korrigiert zurück und BEHALTE die Sprecher-Labels und die Zeilenstruktur EXAKT bei (dieselben Labels, dieselbe Reihenfolge). Korrigiere nur die Wörter, ändere keine Sprecher-Zuordnung. Keine Einleitung, kein Markdown.
Gespräch:
${transcript}`;
// Appended to the clean prompts so correction + analysis happen in ONE NPU call
// (saves a full prompt prefill and one round trip vs. the old two-call flow).
// The corrected dialogue comes FIRST so the JSON fields are grounded in it.
const COMBINED_SUFFIX =
`
DANACH, nach dem korrigierten Gespräch, gib eine Zeile aus, die exakt so lautet:
===ANALYSE===
und direkt darunter AUSSCHLIESSLICH ein gültiges JSON-Objekt (beginnend mit { und endend mit }, kein Markdown, keine Erklärung) in genau dieser Form:
{"titel": "...", "zusammenfassung": "...", "teilnehmer": ["..."], "todos": ["..."]}
Bedeutung: "titel" = max. 6 Wörter; "zusammenfassung" = 2-4 Sätze über den Gesprächsinhalt; "teilnehmer" = Name oder Rolle je beteiligter Person; "todos" = kurze Handlungsanweisungen im Imperativ.
Fülle alle Felder mit ECHTEM INHALT aus dem Gespräch — schreibe niemals Feldbeschreibungen oder "..." ab.
Regeln: Antworte auf Deutsch. "teilnehmer" = im Gespräch genannte/erkennbare Personen (Namen wenn genannt, sonst Rolle wie "Sprecher"); [] wenn unklar.
${TODO_RULE}
${NO_INVENT}`;
// Split a combined completion into the corrected dialogue and the JSON tail.
// Falls back to locating the JSON by its "titel" key when the marker is missing.
function splitCombined(out) {
const m = out.search(/^\s*=+\s*ANALYSE\s*=+\s*$/im);
if (m !== -1) {
const rest = out.slice(m);
return { clean: out.slice(0, m), json: rest.slice(rest.indexOf('\n') + 1) };
}
const ti = out.indexOf('"titel"');
if (ti !== -1) {
const bi = out.lastIndexOf('{', ti);
if (bi !== -1) return { clean: out.slice(0, bi), json: out.slice(bi) };
}
return { clean: '', json: out };
}
// ---- Long-transcript support ---------------------------------------------
// The NPU model has a FIXED 4096-token context (set at model-conversion time,
// not raisable at runtime). The combined clean+analyze call also GENERATES
// ~as many tokens as the cleaned dialogue, so one call is only safe for short
// transcripts. Longer ones are processed in chunks (map: clean + partial
// analysis per chunk) and then reduced (final title/summary from the partial
// summaries; todos/participants merged). Works for ANY length.
const estTokens = (s) => Math.ceil((s || '').length / 3.3); // ~German chars per token
// Long path is ANALYSIS-ONLY per chunk (tiny JSON output), so the prompt can be
// large — only the prefill grows, which is cheap on the NPU. Output tokens (the
// slow part, ~5-8 tok/s) stay ~120/chunk. Big chunks → fewer chunks → faster.
const CHUNK_CHARS = 8000; // small chunks: Gemma emits valid JSON on short inputs, fails on big ones
const SINGLE_MAX_CHARS = 4800; // below this: single combined call (full cleaning)
// The 16K-converted Gemma model is RUN at a 12288 runtime context (rkllm_server
// max_context_len) — but 20000 chars stays the whole-pass cutoff: it is a MODEL
// limit, not a context limit. Verified on #70 (22.6k chars, fits in 12288 ctx):
// above ~6k input tokens Gemma-3-4B ignores the instruction (English chat),
// fixates on the END of the call (recency bias: summarized the closing car
// smalltalk), and its JSON decays (curly quotes, repeated keys, repetition
// loops). At ≤20k chars whole-pass quality is validated good (#69 era). Longer
// recordings use the chunked map-reduce below, whose REDUCE prompt weights
// topics by share across sections. Do NOT raise without re-validating content
// quality — a bigger window does not make the model read evenly.
const WHOLE_MAX_CHARS = 20000;
// Split transcript into <=maxChars pieces on natural boundaries (labeled lines
// first, else sentences, else hard char cut) so no chunk splits mid-word.
function chunkText(text, maxChars) {
const units = text.includes('\n') ? text.split(/\n/) : text.split(/(?<=[.!?…])\s+/);
const chunks = [];
let cur = '';
const flush = () => { if (cur.trim()) chunks.push(cur.trim()); cur = ''; };
for (let u of units) {
while (u.length > maxChars) { flush(); chunks.push(u.slice(0, maxChars)); u = u.slice(maxChars); }
if (cur && (cur.length + 1 + u.length) > maxChars) flush();
cur += (cur ? '\n' : '') + u;
}
flush();
return chunks.length ? chunks : [text];
}
// Reduce the per-chunk partial summaries into ONE final title + summary. Input
// is small (a few 2-4 sentence summaries) so this call always fits the context.
const PROMPT_REDUCE = (summaries) =>
`Das sind Teil-Zusammenfassungen aufeinanderfolgender Abschnitte EINES langen deutschen Gesprächs (in Reihenfolge; manche evtl. auf Englisch):
"""
${summaries}
"""
Erstelle daraus EINE zusammenhängende deutsche Gesamt-Zusammenfassung des GANZEN Gesprächs. Die Abschnitte sind chronologisch und gleich lang — das HAUPTTHEMA ist, was in mehreren Abschnitten vorkommt oder den meisten Raum einnimmt, NICHT was zuletzt kam: Titel und Überblick nur aus den größten Themen; Smalltalk und Randbemerkungen (z.B. Begrüßung, Privates am Schluss) dort weglassen. Ein Thema, das NUR im letzten Abschnitt vorkommt, ist meist Smalltalk am Gesprächsende — erwähne es höchstens als letzten Kernpunkt, nie in Titel oder Überblick. Decke in den Kernpunkten ALLE Themen, Entscheidungen und Ergebnisse aus ALLEN Abschnitten ab — lasse kein wichtiges Thema weg, konzentriere dich nicht auf ein einzelnes. Nenne KONKRETE Ergebnisse mit Namen, Firmen und Rollen: wer macht was, welche Kontakte wurden geknüpft, welche Zusagen/Bewerbungen/Termine gab es.
Antworte auf DEUTSCH und AUSSCHLIESSLICH mit einem JSON-Objekt, ohne Text davor oder danach, ohne Markdown. Beginne mit { und ende mit }.
{"titel": "prägnanter deutscher Titel, max. 6 Wörter, Hauptthema", "zusammenfassung": "kurzer deutscher Überblick über das GESAMTE Gespräch in 2-4 Sätzen", "kernpunkte": ["wichtiger besprochener Punkt als kurzer deutscher Stichpunkt, je ein Punkt pro Thema, ALLE Abschnitte abdecken, wichtigstes zuerst"], "entscheidungen": ["im Gespräch getroffene Entscheidung als kurzer deutscher Satz; leere Liste wenn keine"]}`;
const uniqStr = (arr) => [...new Set((arr || []).map(s => String(s).trim()).filter(Boolean))];
// Per-chunk analysis prompt for the long path. MINIMAL on purpose: Gemma-3-4B
// ignores long walls of rules on big inputs and answers with chatty English
// prose instead of JSON (4/5 chunks PARSE_FAIL'd → narrow summary). A short,
// JSON-forcing, German-forcing prompt with the format LAST (recency) makes it
// obey. No CORRECTION_HINT/TODO_RULE/NO_INVENT here — they triggered the chatter.
const PROMPT_CHUNK_ANALYZE = (transcript, hint = CORRECTION_HINT) =>
`Abschnitt eines deutschen Gesprächs (aus Spracherkennung, evtl. Hörfehler — lies sinngemäß):
"""
${transcript}
"""
Fasse NUR DIESEN Abschnitt zusammen. Nenne ALLE besprochenen Themen und Entscheidungen (nicht nur eines), mit konkreten Namen/Rollen.
Antworte auf DEUTSCH und AUSSCHLIESSLICH mit einem JSON-Objekt, ohne Text davor oder danach, ohne Markdown. Beginne mit { und ende mit }.
{"zusammenfassung": "3-6 deutsche Sätze über alle Themen dieses Abschnitts", "teilnehmer": ["Name oder Rolle"], "todos": ["konkret zugesagte Aufgabe im Imperativ; Grußformeln sind KEINE Aufgabe"]}`;
// Analyze a transcript → { title, summary, participants[], todos[], transcriptClean }.
// `labeled` = the text already carries acoustic "Sprecher X:" turn labels.
export async function analyze(transcript, { labeled = false, glossary = [] } = {}) {
const raw = (transcript || '').trim();
if (!raw) return { title: 'Leere Aufnahme', summary: '', participants: [], todos: [], transcriptClean: '' };
const hint = correctionHint(glossary);
if (raw.length > WHOLE_MAX_CHARS) return analyzeLong(raw, hint);
if (raw.length > SINGLE_MAX_CHARS) {
// Whole-pass gives the coherent summary; if Gemma's JSON breaks even after
// the repair call, degrade to chunked map-reduce instead of failing hard.
try { return await analyzeWhole(raw, hint); }
catch (e) { console.log(`llm: whole-analysis failed (${e.message}) -> chunked fallback`); return analyzeLong(raw, hint); }
}
return analyzeSingle(raw, labeled, hint);
}
// Whole-transcript analysis in ONE call (16K-context model). Reads the ENTIRE
// conversation at once → summary/todos/participants are grounded in the full
// context (unlike analyzeLong's per-chunk map-reduce, which could not see across
// chunk boundaries → incoherent long summaries). Analysis-ONLY (small JSON out),
// no full-dialogue rewrite → decode stays short. transcriptClean left empty on
// purpose: the detail view renders diarized turns from `words`.
async function analyzeWhole(raw, hint = CORRECTION_HINT) {
const out = await ask(PROMPT_WHOLE_ANALYZE(raw, hint));
let j = extractJson(out) || extractJson(`{${out.replace(/^[\s,]+|[\s,]+$/g, '')}}`);
if (!j) {
// Gemma occasionally breaks the JSON on very long inputs (leaked prose,
// unescaped quotes) while the analysis CONTENT is fine. A tiny repair call
// that sees only the broken output (not the 20k+ transcript) reformats it
// far more reliably (and faster) than re-running the whole analysis.
console.log(`llm: whole-analysis JSON unparseable -> repair call; head: ${JSON.stringify(out.slice(0, 200))} tail: ${JSON.stringify(out.slice(-250))}`);
j = extractJson(await ask(PROMPT_JSON_REPAIR(out.slice(0, 6000))));
}
if (!j) throw new Error('LLM lieferte kein gültiges JSON (Gesamt-Analyse)');
// Content sanity: when Gemma drifts off-task (English chat about the call),
// the repair call can still yield formally valid but empty/junk JSON. Throw
// so analyze() degrades to the chunked path instead of storing nonsense.
const zsm = (j.zusammenfassung || j.summary || '').toString().trim();
if (!zsm || zsm.length < 20) throw new Error('Gesamt-Analyse ohne brauchbare Zusammenfassung');
return {
title: (j.titel || j.title || '').toString().trim().slice(0, 120) || null,
summary: composeSummary(j),
participants: cleanList(j.teilnehmer || j.participants),
todos: cleanList(j.todos),
transcriptClean: '',
};
}
// Single-call path (short transcripts): one combined NPU call = corrected
// dialogue + JSON analysis. PROMPT_JSON alone is the fallback if it doesn't parse.
async function analyzeSingle(raw, labeled, hint = CORRECTION_HINT) {
const cleanPrompt = labeled ? PROMPT_CLEAN_LABELED : PROMPT_CLEAN;
const out = await ask(cleanPrompt(raw, hint) + COMBINED_SUFFIX);
const parts = splitCombined(out);
let transcriptClean = parts.clean
.replace(/^```.*$/gm, '')
.replace(/^\s*(Gespräch|Dialog|Transkript|Hier[^\n:]*):\s*$/gim, '') // strip echoed prompt headers
.trim();
// Labeled input is already a usable dialogue — better than showing nothing
// when the model skipped straight to the JSON part.
if (!transcriptClean && labeled) transcriptClean = raw;
// Guard against the model echoing the field descriptions instead of filling
// them (the 3B model does this occasionally in the combined call) — then a
// dedicated JSON-only call is worth the extra ~10s.
const echoed = (o) => !o || /prägnanter Titel|worum es ging|Sätze,|^\.{3}$/.test(`${o.titel || o.title || ''} ${o.zusammenfassung || o.summary || ''}`);
// The model sometimes emits the fields without the enclosing braces — wrap and retry.
let j = extractJson(parts.json) || extractJson(`{${parts.json.replace(/^[\s,]+|[\s,]+$/g, '')}}`);
if (echoed(j)) {
console.log(`llm: combined JSON ${j ? 'echoed field descriptions' : 'unparseable'} -> separate JSON call; tail: ${JSON.stringify(out.slice(-250))}`);
j = extractJson(await ask(PROMPT_JSON(transcriptClean || raw)));
}
if (!j) throw new Error('LLM lieferte kein gültiges JSON');
return {
title: (j.titel || j.title || '').toString().trim().slice(0, 120) || null,
summary: composeSummary(j),
participants: cleanList(j.teilnehmer || j.participants),
todos: cleanList(j.todos),
transcriptClean,
};
}
// Chunked map-reduce path (long transcripts). MAP: analyse each chunk into a
// small JSON (summary/participants/todos) — NO full-dialogue rewrite, so NPU
// decode stays short (~120 tok/chunk vs ~1300 for cleaning → minutes not tens of
// minutes). REDUCE: merge participants/todos and derive one final title+summary.
// transcriptClean is left empty on purpose: the detail view renders the
// diarized turns from `words`, and re-emitting the whole transcript through the
// NPU is exactly the slow step we avoid here. No fixed length limit.
async function analyzeLong(raw, hint = CORRECTION_HINT) {
const chunks = chunkText(raw, CHUNK_CHARS);
console.log(`llm: long transcript (${raw.length} chars, ~${estTokens(raw)} tok) -> ${chunks.length} chunks (analyse-only)`);
const summaries = [];
let participants = [];
let todos = [];
let okChunks = 0;
let lastErr = null;
for (let i = 0; i < chunks.length; i++) {
let j = null, rawOut = '';
try {
// Whether a chunk parses is a sampling coin-flip (same prompt: 3/3 ok in
// one run, 1/3 in the next) — one retry turns P(fail) into P(fail²) and
// costs ~100s only when needed.
for (let tries = 0; tries < 2 && !j; tries++) {
rawOut = await ask(PROMPT_CHUNK_ANALYZE(chunks[i], hint));
j = extractJson(rawOut) || extractJson(`{${rawOut.replace(/^[\s,]+|[\s,]+$/g, '')}}`);
if (!j && tries === 0) console.log(`llm: chunk ${i + 1} unparseable -> retry`);
}
okChunks++;
console.log(`llm: chunk ${i + 1} parsed: ${j ? 'JSON ok' : 'PARSE_FAIL(salvage prose)'}`);
} catch (e) { lastErr = e; console.log(`llm: chunk ${i + 1} failed: ${e.message}`); }
if (j) {
const s = (j.zusammenfassung || j.summary || '').toString().trim();
if (s) summaries.push(s);
const pp = j.teilnehmer || j.participants;
if (Array.isArray(pp)) participants.push(...pp.map(String));
if (Array.isArray(j.todos)) todos.push(...j.todos.map(String));
} else if (rawOut) {
// JSON parse failed (Gemma answered in prose) → salvage the prose so this
// chunk's content still reaches the (German-forcing) reduce, instead of
// being dropped (which collapsed the summary to a single lucky chunk).
// Strip any malformed {...} blob first — raw JSON in a salvage leaked
// verbatim into the final summary once (#69).
const prose = rawOut.replace(/```[a-z]*|```/gi, '').replace(/\{[\s\S]*\}/g, ' ')
.replace(/^\s*(Okay|Sure|Here'?s|Certainly)[^.\n]*[.:\n]/i, '').trim();
if (prose) summaries.push(prose.slice(0, 1200));
}
console.log(`llm: chunk ${i + 1}/${chunks.length} done (summaries so far: ${summaries.length})`);
}
console.log(`llm: REDUCE input (${summaries.length} summaries): ${JSON.stringify(summaries.join(' || ').slice(0, 500))}`);
// Every chunk failed (e.g. rkllm never came READY) → do NOT persist a fake
// "done" with a generic "Gespräch" title; propagate so the row is marked
// 'error' and the UI shows a retry (this was the silent-failure bug).
if (okChunks === 0) throw lastErr || new Error('LLM: alle Chunks fehlgeschlagen');
participants = uniqStr(cleanList(participants));
todos = uniqStr(cleanList(todos));
// Final title + summary from the (small) partial summaries.
let red = null;
if (summaries.length) {
// Gemma answers big inputs with prose instead of JSON (same failure as the
// chunk step) → cap each partial summary and retry the reduce once; the
// #69 run lost title+structure because the single reduce didn't parse.
// Label each partial so the reduce can APPLY its weighting rule ("main
// topic = spans several sections") — unlabeled concatenation gave the last
// section (closing smalltalk) equal weight in title/overview (#70).
const reduceInput = summaries.map((s, i) => `Abschnitt ${i + 1} von ${summaries.length}:\n${s.slice(0, 800)}`).join('\n\n');
for (let attempt = 1; attempt <= 2 && !red; attempt++) {
try {
const out = await ask(PROMPT_REDUCE(reduceInput));
red = extractJson(out) || extractJson(`{${out.replace(/^[\s,]+|[\s,]+$/g, '')}}`);
if (!red) console.log(`llm: reduce attempt ${attempt} unparseable; tail: ${JSON.stringify(out.slice(-200))}`);
} catch (e) { console.log(`llm: reduce attempt ${attempt} failed:`, e.message); }
}
}
const fallbackTitle = (summaries[0] || '').split(/[.!?\n]/)[0].trim().slice(0, 60);
return {
title: (red?.titel || red?.title || '').toString().trim().slice(0, 120) || fallbackTitle || 'Gespräch',
summary: (red ? composeSummary(red) : '') || summaries.join('\n\n').slice(0, 2500),
participants,
todos,
transcriptClean: '',
};
}