app/ = /opt/callrec (Node-Backend, Vite-Frontend, Python-Inferenz, Audio-Skripte, rkllm_server.cpp), system/ = systemd-Units, WirePlumber/PipeWire-Drop-ins, sudoers. Modelle, Binaries, Aufnahmen und node_modules bleiben draussen (.gitignore). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
479 lines
29 KiB
JavaScript
479 lines
29 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;
|
||
try { return JSON.parse(t.slice(s, e + 1)); } 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 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. "kernpunkte" = 3-8 Stichpunkte über ALLE wichtigen Themen; "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}
|
||
"""`;
|
||
|
||
// 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 an 8192 runtime context (rkllm_server
|
||
// max_context_len) to cap KV-cache RAM on the 8GB board. The WHOLE conversation
|
||
// still fits in ONE analysis-only call → coherent summary (vs the map-reduce
|
||
// chunking below, which lost cross-chunk coherence → the "Unsinn" long summary).
|
||
// Budget: 8192 ctx − 1536 gen ≈ 6656 tok prompt ≈ 21900 chars; 20000 leaves
|
||
// margin. Longer recordings still fall back to chunking. Raise together with the
|
||
// server's max_context_len if board RAM headroom allows.
|
||
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. Decke ALLE Themen, Entscheidungen und Ergebnisse aus ALLEN Abschnitten ab — lasse kein wichtiges Thema weg, konzentriere dich nicht auf ein einzelnes. Nenne konkrete Namen, Rollen und Entscheidungen.
|
||
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"], "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) return analyzeWhole(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) throw new Error('LLM lieferte kein gültiges JSON (Gesamt-Analyse)');
|
||
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 {
|
||
rawOut = await ask(PROMPT_CHUNK_ANALYZE(chunks[i], hint));
|
||
j = extractJson(rawOut) || extractJson(`{${rawOut.replace(/^[\s,]+|[\s,]+$/g, '')}}`);
|
||
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.
|
||
const reduceInput = summaries.map(s => 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: '',
|
||
};
|
||
}
|