Files
Claude 0f530b575a feat(analyse): Kontextnotiz zum Gespraech nach dem Stop
Ein Freitextfeld ("Kontext zum Gespraech") in der Detailansicht, nach dem
Stop aufgeklappt. Der Text geht als Hintergrundwissen in die LLM-Analyse:
Personen, Firmen, Anlass und Eigennamen muessen nicht mehr aus dem
fehlerbehafteten ASR-Text erraten werden.

Bewusst EIN Feld statt eines Formulars mit Teilnehmer-/Anlass-/Begriffs-
feldern (Entscheidung des Nutzers): die Box benutzen auch technisch nicht
versierte Menschen. Gefuehrt wird ueber den Platzhaltertext, die Struktur
zieht die Analyse selbst aus der Prosa.

- recordings.context_note/-_at, db.setContextNote/getContextNote
- PATCH /api/recordings/:id/context; Antwort `applies` sagt ehrlich, ob die
  Notiz die laufende Verarbeitung noch erreicht (auto/running/reanalyze)
- analyzeRecording liest die Notiz erst kurz vor dem LLM-Aufruf, damit
  waehrend der minutenlangen Vorstufen Getipptes noch zaehlt
- llm.contextBlock: eigener """-Block mit Verbot, daraus Aussagen, Ergebnisse
  oder Aufgaben abzuleiten (sonst schreibt das 4B-Modell die Notiz als
  Gesagtes zurueck); eingespeist in Single-, Whole- und Sektions-Pfad
- zwei Deckel: 1200 Zeichen fuer Prompts, die die Notiz einmal sehen,
  600 fuer die Chunk-Prompts (dort landet sie in jedem Chunk von 3200)
- GUI-Karte zeichnet sich selbst neu (kein openRecording -> Audio laeuft
  weiter), ungespeicherter Text ueberlebt den Neuaufbau nach der Analyse

Verifiziert an #97: Kontext greift (korrekte Namen, richtiges Thema), die
absichtlich eingelegten Fremdfakten der Testnotiz erscheinen nicht in der
Zusammenfassung. Alte Analyse danach zurueckgeschrieben.

Doku: Kapitel 05 (neuer Abschnitt + Flussdiagramm + Deckel-Tabelle),
Kapitel 03 (Sequenzdiagramm + API), Changelog 2026-08-17.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-17 16:10:38 +02:00

807 lines
51 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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';
import * as gate from './gate.js';
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));
// Ein write() in die stdin eines toten Kindes loest 'error' (EPIPE) auf
// diesem Socket aus. Ohne Listener behandelt node das als unhandled
// 'error' event und beendet den GANZEN Dienst — genau so starb callrec am
// 2026-08-06 nach #98. Der Fehler gehoert an die offene Anfrage, nicht an
// den Prozess.
proc.stdin.on('error', e => {
if (pending) { const p = pending; pending = null; p.reject(new Error('rkllm stdin: ' + e.message)); }
});
ready = new Promise((resolve, reject) => {
settleReject = reject;
// 180s: Qwen3-4B (5.3GB) needs ~96s to load on the capped clock — the old
// 60s killed it mid-load in an endless retry loop (Gemma was ~72s, barely fit).
const to = setTimeout(() => { if (!done) { done = true; reject(new Error('rkllm READY timeout')); } }, 180000);
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++) {
// Vor JEDEM Versuch die anderen großen Helfer beenden. rkllm braucht ~6,5 GB
// von 7,74 GB; blieb z. B. der warme CT2-Daemon der vorherigen Kurz-Aufnahme
// resident, wird rkllm OOM-getötet — und ein Retry ohne freien Speicher
// stirbt genauso (2026-08-07: 5/5 Versuche, "KI-Analyse fehlgeschlagen").
await gate.freeRam('rkllm');
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 () => {
// Haltepunkt zwischen den Chunks: startet mittendrin eine Aufnahme,
// pausiert die Analyse hier, statt neben dem offenen Mikrofon zu rechnen.
await gate.waitIdle('llm');
await startServer();
return await new Promise((resolve, reject) => {
// Zweites Netz: der Prozess kann zwischen startServer() und hier
// gestorben sein (exit-Event noch nicht verarbeitet).
if (!proc || !proc.stdin || proc.stdin.destroyed)
return reject(new Error('rkllm nicht bereit (Prozess weg)'));
pending = { resolve, reject };
try { proc.stdin.write(b64e(prompt) + '\n'); }
catch (e) { pending = null; reject(new Error('rkllm stdin: ' + e.message)); }
});
});
chain = run.catch(() => {}); // next request starts from a clean, resolved chain
return run;
}
// Raw prompt access for the /api/debug/ask prompt lab (serialized via `chain`
// like every other call; uses the resident server, no extra RAM).
export const askRaw = (prompt) => ask(prompt);
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);
// --- Kontextnotiz des Nutzers -------------------------------------------------
// Freitext, den der Nutzer nach dem Stop notiert ("Call mit Meier von Fa. Krämer
// wegen Angebot Halle 3"). Er ist Hintergrundwissen, KEIN Gesprächsinhalt: das
// 4B-Modell schreibt eine solche Notiz sonst als Gesagtes in die Zusammenfassung
// zurück (dieselbe Falle wie beim CORRECTION_HINT, siehe dessen STRENG-Satz).
// Deshalb steht die Notiz in eigenen Anführungsblöcken MIT Verbot, daraus Fakten
// abzuleiten. Zwei Deckel: der lange für Prompts, die den Text einmal sehen, der
// kurze für den Abschnitts-Pfad, wo die Notiz in JEDEN Chunk-Prompt geht
// (CHUNK_CHARS = 3200 — eine 1200-Zeichen-Notiz wäre dort ein Drittel Ballast).
const CONTEXT_MAX_CHARS = +(process.env.LLM_CONTEXT_CHARS || 1200);
const CONTEXT_CHUNK_CHARS = +(process.env.LLM_CONTEXT_CHUNK_CHARS || 600);
function contextBlock(note, max = CONTEXT_MAX_CHARS) {
const n = (note || '').toString().replace(/\r/g, '').replace(/\n{3,}/g, '\n\n').trim();
if (!n) return '';
const t = n.length > max ? n.slice(0, max).trim() + '…' : n;
return `
HINTERGRUNDWISSEN DES NUTZERS (von ihm nach dem Gespräch notiert, NICHT Teil des Gesprächs):
"""
${t}
"""
Nutze diese Notiz NUR, um Personen, Firmen, Rollen und Fachbegriffe richtig zu schreiben und zuzuordnen, den Anlass des Gesprächs zu verstehen und die Themen richtig zu gewichten. STRENG: Die Notiz ist KEIN Transkript. Übernimm daraus KEINE Aussagen, Ergebnisse, Entscheidungen oder Aufgaben — jede Angabe in deiner Antwort muss im Gespräch selbst gesprochen worden sein. Erwähne die Notiz nicht und zitiere sie nicht.`;
}
// 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, ctx = '') =>
`Du analysierst das Transkript eines deutschen Gesprächs.${ctx}
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, ctx = '') =>
`Du analysierst das VOLLSTÄNDIGE Transkript eines deutschen Gesprächs aus automatischer Spracherkennung. ${hint}${ctx}
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, ctx = '') =>
`Das folgende deutsche Transkript stammt aus automatischer Spracherkennung eines Gesprächs mit möglicherweise MEHREREN Sprechern (in einer Tonspur gemischt). ${hint}${ctx}
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, ctx = '') =>
`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}${ctx}
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.
// 4200, not 8000: at 8k chars the 4B still parses but the CONTENT degrades to
// generic name-free mush ("Optimierung des Teams…"); at ~4k it stays grounded
// and keeps proper nouns (Com4Tec/René/Tobi+Niklas verified on #70, 2026-07-16).
// 3200 since 2026-07-29: at 4200 a chunk regularly holds two unrelated topics
// (#79 chunk 1 = the Timo/Sabbatical opening AND the Instagram video review) and
// the 4B summarizes only the longer one — the opening agenda item vanished from
// the summary in one of three runs. Smaller chunks = fewer topics competing per
// call; the extra ~2 calls cost ~5 min on the NPU.
const CHUNK_CHARS = +(process.env.LLM_CHUNK_CHARS || 3200);
// Verbatim overlap handed to the next chunk as context-only grounding.
const TAIL_CHARS = +(process.env.LLM_TAIL_CHARS || 700);
const SINGLE_MAX_CHARS = 4800; // below this: single combined call (full cleaning)
// Whole-pass cutoff. Model-dependent, NOT just a context question:
// - Gemma-3-4B fell apart above ~6k input tokens (English chat, recency bias,
// JSON decay) → its limit was 20000 chars even at 12288 runtime ctx.
// - Qwen3-4B-Instruct-2507 (2026-07-16 A/B on #70) nailed the SAME 21.8k-char
// whole-pass in one call: correctly weighted German JSON, car smalltalk kept
// out of title/summary, concrete names — the failure mode is gone.
// Budget at 12288 ctx: 12288 − 1536 gen ≈ 10752 tok ≈ 35k chars; 32000 leaves
// margin. Beyond that the section-conglomerate path below takes over.
const WHOLE_MAX_CHARS = 32000;
// Round-robin meetings (many speakers, each reporting their own topics) defeat
// the whole-pass even below WHOLE_MAX: on #74 (33min standup, 13 diarized
// speakers, 31k chars) the one-call summary kept only the LAST speakers'
// topics — a 4B cannot weight 15+ equal topics from a 10k-token prompt.
// Labeled transcripts with >= this many distinct speakers go through the
// section-conglomerate path instead, which covers every part by construction.
const MANY_SPEAKERS = +(process.env.LLM_MANY_SPEAKERS || 3);
// …and speaker count is not the real driver either: #79 (23-min marketing jour
// fixe, only 3 diarized speakers, 21k chars, 6 distinct topics) went whole-pass
// and the summary kept ONLY the last topics — the first agenda item (Timo /
// Sabbatical, 2.5 minutes at the very start) was missing entirely, and the whole
// thing fit into 1082 chars where MS Teams produced 5 topic blocks. The failure
// mode is INPUT LENGTH, not head count: above ~3k tokens the 4B compresses the
// early part of the prompt away. Any transcript longer than this goes through
// the section-conglomerate path, which covers every part by construction.
const SECTION_MIN_CHARS = +(process.env.LLM_SECTION_MIN || 9000);
// Count distinct "Name:"-style turn labels in a labeled dialogue.
function countSpeakers(text) {
const s = new Set();
for (const m of text.matchAll(/^([^:\n]{1,40}):\s/gm)) s.add(m[1].trim());
return s.size;
}
// 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];
}
// NOTE: the LLM-REDUCE that used to merge the partial summaries is REMOVED
// (2026-07-16). Every variant tested in the #70 prompt lab — weighting rules,
// section labels, topic tables, name requirements — produced generic name-free
// prose, hallucinated roles or repetition loops: cross-section synthesis is
// beyond Gemma-3-4B. analyzeLong() now assembles the conglomerate in code.
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.
// Rolling context (1 sentence per prior section) lets the model recognize when
// the current section CHANGES an earlier decision — the only cross-section
// awareness a 4B can handle. eigennamen = pure extraction list (names survive
// even when the summary generalizes); its array sometimes degenerates, which
// salvageChunkFields() recovers.
// `tail` = the VERBATIM last few hundred characters of the previous chunk. The
// rolling one-sentence context is a summary and loses referents; a sentence that
// happens to land at the very start of a chunk then has no subject at all.
// Measured on #79: "Ich würde die lustigen Sachen nur bei Instagram lassen" sits
// 4 % into chunk 6, so the model read it cold and turned a statement about FUNNY
// POSTS into "the team publishes only on Instagram" — the one factual inversion
// that survived every prompt wording (wrong in 4 of 7 runs). The tail is marked
// as context-only so it is not summarized twice.
const PROMPT_CHUNK_ANALYZE = (transcript, context = '', hint = CORRECTION_HINT, tail = '', ctx = '') =>
`Abschnitt eines deutschen Gesprächs (aus Spracherkennung, evtl. Hörfehler — lies sinngemäß).${ctx}${context ? ` Bisheriger Gesprächsverlauf in Kurzform:
${context}` : ''}${tail ? `
Die letzten Sätze des VORIGEN Abschnitts, nur damit du weißt, worauf sich der Anfang bezieht — NICHT zusammenfassen:
"""
${tail}
"""` : ''}${(context || tail) ? `
Aktueller Abschnitt:` : ''}
"""
${transcript}
"""
Fasse NUR DEN AKTUELLEN Abschnitt zusammen. Nenne ALLE besprochenen Themen und Entscheidungen (nicht nur eines), mit konkreten Namen/Rollen. Enthält der Abschnitt mehrere verschiedene Themen, behandle JEDES in mindestens einem Satz — auch ein kurzes Thema ganz am Anfang. Bleibe dabei genau beim Gesagten und verallgemeinere NICHT: Eine Entscheidung gilt immer nur für das, worüber gerade konkret gesprochen wird. Schreibe also "Video X kommt nur auf Instagram" und NICHT "das Team veröffentlicht nur noch auf Instagram". Drehe keine Aussage um. Wenn mehrere Personen nacheinander berichten, fasse den BERUFLICHEN Beitrag JEDER Person zusammen — lasse NIEMANDEN weg; nutze den Namen der Person, wenn er aus dem Kontext hervorgeht (Vorstellung, Anrede), sonst das Sprecher-Label. Berufliches steht im Vordergrund: private Erlebnisse und Smalltalk (Essen, Familie, Auto, Urlaubserzählungen, Anekdoten) höchstens in einem halben Satz erwähnen oder ganz weglassen.${context ? ` Wenn der aktuelle Abschnitt eine frühere Aussage oder Entscheidung ÄNDERT, sage das ausdrücklich ("jetzt X, vorher Y").` : ''}
Antworte auf DEUTSCH und AUSSCHLIESSLICH mit einem JSON-Objekt, ohne Text davor oder danach, ohne Markdown. Beginne mit { und ende mit }.
{"thema": "kurze Überschrift dieses Abschnitts, max. 6 Wörter", "zusammenfassung": "3-6 deutsche Sätze über alle Themen dieses Abschnitts", "eigennamen": ["jeder im Abschnitt vorkommende Personen- oder Firmenname, exakt wie im Text"], "teilnehmer": ["Name oder Rolle"], "todos": ["nur beruflich relevante, konkret angewiesene oder fest zugesagte Aufgabe im Imperativ, dahinter in Klammern wer sie übernimmt (falls erkennbar); private Vorhaben, Freizeit und Grußformeln sind KEINE Aufgabe"]}`;
// Targeted fact-check for ONE section, run only when its draft claims something
// absolute ("nur noch", "ausschließlich", "statt auch"). Those are the claims the
// 4B gets wrong by over-scoping a sentence about a single post to the whole team
// (#79: "Achso, ja, dann lassen wir LinkedIn raus" was said about ONE ragebait
// video and became "the team publishes only on Instagram" in 5 of 8 runs — the
// verbatim tail fixed the same error at a chunk START, but not this one, which
// sits mid-chunk with its referent several sentences back). Verifying every
// section would double a 20-minute analysis; this fires on 1-2 sections.
// Two conditions, because "nur"/"statt" alone appear in perfectly fine sentences:
// a scope word AND a collective subject. The first version only looked for the
// literal "nur noch"/"statt auch" and therefore never fired on the actual
// offender, "Das Team entscheidet sich dafür, nur Instagram zu nutzen statt
// LinkedIn" (#79 run 9).
const ABSOLUTE_SCOPE = /(\bnur\b|ausschließlich|\bstatt\b|nicht mehr|dauerhaft|grundsätzlich|komplett|beendet|eingestellt)/i;
const COLLECTIVE_SUBJECT = /(das team|die gruppe|\bwir\b|\bman\b|künftig|zukünftig|in zukunft)/i;
const isAbsoluteClaim = (s) => ABSOLUTE_SCOPE.test(s) && COLLECTIVE_SUBJECT.test(s);
const PROMPT_VERIFY_SECTION = (transcript, draft) =>
`Gesprächsabschnitt:
"""
${transcript}
"""
Entwurf der Zusammenfassung:
"""
${draft}
"""
Prüfe NUR die Aussagen des Entwurfs, die etwas Allgemeines oder Dauerhaftes behaupten ("nur noch", "ausschließlich", "statt auch", "nicht mehr"). Steht das wirklich so im Abschnitt, oder bezieht sich die Aussage in Wahrheit nur auf EINEN einzelnen Beitrag, EIN Video oder EINEN Fall? Wenn sie zu weit gefasst ist, schreibe sie eng und korrekt. Ändere sonst NICHTS und lasse keinen Inhalt weg.
Antworte auf DEUTSCH und AUSSCHLIESSLICH mit {"zusammenfassung": "..."}`;
async function verifySection(chunk, draft) {
try {
const j = extractJson(await ask(PROMPT_VERIFY_SECTION(chunk, draft)));
const fixed = (j?.zusammenfassung || j?.summary || '').toString().trim();
// Guard against the checker "correcting" a section into a stub.
if (fixed.length >= draft.length * 0.6) {
if (fixed !== draft) console.log(`llm: Abschnitt nach Prüfung korrigiert (${draft.length}→${fixed.length} Zeichen)`);
return fixed;
}
console.log(`llm: Prüf-Antwort verworfen (${fixed.length} statt ${draft.length} Zeichen)`);
} catch (e) { console.log(`llm: Prüf-Aufruf fehlgeschlagen: ${e.message}`); }
return draft;
}
// Title micro-call over 2-sentence section Kurzfassungen (small input/output =
// reliable regime). The LLM-reduce that used to build the whole summary is
// gone: cross-section synthesis is beyond the 4B (see analyzeLong header).
const PROMPT_TITLE = (kurzfassungen, ctx = '') =>
`Kurzfassungen der Abschnitte eines deutschen Gesprächs (chronologisch):
${kurzfassungen}${ctx}
Gib einen prägnanten deutschen Titel (max. 6 Wörter) für das GESAMTE Gespräch. Der Titel benennt das größte Thema.
Antworte AUSSCHLIESSLICH mit einem JSON-Objekt: {"titel": "..."}`;
// Overview micro-call over the section Kurzfassungen — same small-input/small-
// output regime as the title call. Gives the conglomerate a Copilot-style
// intro paragraph; equal weighting is enforced by the input (every section
// contributes the same 1-2 sentences).
const PROMPT_OVERVIEW = (kurzfassungen, ctx = '') =>
`Kurzfassungen der Abschnitte eines deutschen Gesprächs (chronologisch):
${kurzfassungen}${ctx}
Gib einen kurzen Überblick über das GESAMTE Gespräch in 2-3 deutschen Sätzen: worum es ging und was die wichtigsten Ergebnisse waren. Gewichte ALLE Abschnitte gleich — nicht nur die letzten.
Antworte AUSSCHLIESSLICH mit einem JSON-Objekt: {"ueberblick": "..."}`;
// Analyze a transcript → { title, summary, participants[], todos[], transcriptClean }.
// `labeled` = the text already carries acoustic "Sprecher X:" turn labels.
// `context` = Freitext-Kontextnotiz des Nutzers (siehe contextBlock).
export async function analyze(transcript, { labeled = false, glossary = [], context = '' } = {}) {
const raw = (transcript || '').trim();
if (!raw) return { title: 'Leere Aufnahme', summary: '', participants: [], todos: [], transcriptClean: '' };
const hint = correctionHint(glossary);
const ctx = contextBlock(context);
const ctxShort = contextBlock(context, CONTEXT_CHUNK_CHARS);
if (ctx) console.log(`llm: Kontextnotiz des Nutzers wird mitgegeben (${(context || '').trim().length} Zeichen` +
`${(context || '').trim().length > CONTEXT_MAX_CHARS ? `, auf ${CONTEXT_MAX_CHARS} gekürzt` : ''})`);
const manySpeakers = labeled && countSpeakers(raw) >= MANY_SPEAKERS;
const longMeeting = raw.length > SECTION_MIN_CHARS;
if (raw.length > WHOLE_MAX_CHARS || longMeeting || (manySpeakers && raw.length > SINGLE_MAX_CHARS)) {
console.log(`llm: ${raw.length} chars / ${countSpeakers(raw)} speakers -> section path` +
(longMeeting ? ` (>${SECTION_MIN_CHARS} chars)` : ' (round-robin meeting)'));
return analyzeLong(raw, hint, ctx, ctxShort);
}
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, ctx); }
catch (e) { console.log(`llm: whole-analysis failed (${e.message}) -> chunked fallback`); return analyzeLong(raw, hint, ctx, ctxShort); }
}
return analyzeSingle(raw, labeled, hint, ctx);
}
// 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, ctx = '') {
const out = await ask(PROMPT_WHOLE_ANALYZE(raw, hint, ctx));
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, ctx = '') {
const cleanPrompt = labeled ? PROMPT_CLEAN_LABELED : PROMPT_CLEAN;
const out = await ask(cleanPrompt(raw, hint, ctx) + 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, ctx)));
}
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,
};
}
// Field-level salvage for a broken chunk JSON: in every observed failure the
// zusammenfassung STRING survives intact — only the eigennamen ARRAY
// degenerates (dot-loops, junk tails). Regex the fields out instead of losing
// the chunk (prose-salvage) or burning a 100s retry.
function salvageChunkFields(text) {
const t = text.replace(/[“”„«»]/g, '"');
const sum = (t.match(/"zusammenfassung"\s*:\s*"((?:[^"\\]|\\.)*)"/) || [])[1] || '';
if (!sum) return null;
const thema = (t.match(/"thema"\s*:\s*"((?:[^"\\]|\\.)*)"/) || [])[1] || '';
let names = [];
const nm = t.match(/"eigennamen"\s*:\s*\[([\s\S]*?)(\]|$)/);
if (nm) names = [...nm[1].matchAll(/"((?:[^"\\]|\\.){2,60})"/g)].map(m => m[1])
.filter(s => /[A-ZÄÖÜ]/.test(s) && !/^\.+$/.test(s));
let todos = [];
const tm = t.match(/"todos"\s*:\s*\[([\s\S]*?)(\]|$)/);
if (tm) todos = [...tm[1].matchAll(/"((?:[^"\\]|\\.){4,200})"/g)].map(m => m[1]);
return { thema, zusammenfassung: sum, eigennamen: names, todos };
}
// Chunked path for long transcripts (> WHOLE_MAX_CHARS). Design after the #70
// prompt-lab (2026-07-16): Gemma-3-4B is grounded and keeps proper nouns on
// ~4k-char inputs but degrades into generic, name-free mush on 8k — and it
// CANNOT synthesize across sections at all (every LLM-reduce variant tested
// produced weightless generic prose, hallucinated roles, or repetition loops).
// Therefore: the LLM only UNDERSTANDS one small section at a time (map, with a
// rolling 1-sentence context of prior sections so it can flag changed
// decisions), and the conglomerate is assembled DETERMINISTICALLY in code —
// section-labelled summary, weighting impossible to get wrong. Title comes
// from a micro-call over 2-sentence Kurzfassungen (last section excluded:
// end-of-call smalltalk heuristic). transcriptClean stays empty on purpose
// (detail view renders diarized turns from `words`).
// `note` = Kontextnotiz-Block für die kleinen Titel-/Überblick-Aufrufe,
// `noteShort` die gekürzte Fassung für die vielen Chunk-Prompts. NICHT `ctx`
// nennen: so heißt in der Chunk-Schleife schon der rollende Gesprächsverlauf.
async function analyzeLong(raw, hint = CORRECTION_HINT, note = '', noteShort = note) {
const chunks = chunkText(raw, CHUNK_CHARS);
console.log(`llm: long transcript (${raw.length} chars, ~${estTokens(raw)} tok) -> ${chunks.length} chunks (analyse-only, rolling context)`);
const sections = []; // { sum, names[] }
const ctxLines = []; // 1-sentence rolling context (keeps later prompts small)
let participants = [];
let todos = [];
let okChunks = 0;
let lastErr = null;
for (let i = 0; i < chunks.length; i++) {
let j = null, rawOut = '';
const context = ctxLines.join('\n');
try {
// Parse success is a sampling coin-flip → 1 retry, then field-salvage.
// A chunk that ECHOES the field description ("3-6 deutsche Sätze über
// alle Themen…") counts as failed too — valid JSON, zero content (lost
// the car section of #70 that way).
const echoedSum = (o) => /deutsche Sätze über alle Themen|Feldbeschreibung|Name oder Rolle/i
.test((o?.zusammenfassung || o?.summary || '').toString());
for (let tries = 0; tries < 3 && !j; tries++) {
// Some section contents make Gemma echo the field description on EVERY
// try with the same prompt (#70 chunk 7, twice in a row) — vary the
// prompt on later tries: drop the rolling context first, then hint too.
const ctx = tries === 0 ? context : '';
// The verbatim tail stays on every try — it is grounding, not context
// the model can get chatty about.
const tail = i > 0 ? chunks[i - 1].slice(-TAIL_CHARS) : '';
// Die Kontextnotiz fällt auf dem LETZTEN Versuch mit dem hint weg: wenn
// das Modell zweimal die Feldbeschreibung nachgeplappert hat, ist jeder
// Zusatztext vor dem Abschnitt verdächtig (siehe Kommentar oben).
rawOut = await ask(PROMPT_CHUNK_ANALYZE(chunks[i], ctx, tries === 2 ? '' : hint, tail, tries === 2 ? '' : noteShort));
j = extractJson(rawOut) || extractJson(`{${rawOut.replace(/^[\s,]+|[\s,]+$/g, '')}}`)
|| salvageChunkFields(rawOut);
if (j && echoedSum(j)) { console.log(`llm: chunk ${i + 1} echoed field description (try ${tries + 1})`); j = null; }
if (!j && tries < 2) console.log(`llm: chunk ${i + 1} unparseable -> retry ${tries + 2}`);
}
okChunks++;
console.log(`llm: chunk ${i + 1} parsed: ${j ? 'ok' : 'PARSE_FAIL(salvage prose)'}`);
} catch (e) { lastErr = e; console.log(`llm: chunk ${i + 1} failed: ${e.message}`); }
let sum = '';
if (j) {
sum = (j.zusammenfassung || j.summary || '').toString().trim();
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));
// Extraction list needs scrubbing: Gemma appends notes ("Stefan(nicht
// erwähnt)"), generic roles ("Sprecher 2", "Kandidatenen") and phrases.
const names = uniqStr((j.eigennamen || []).map(s => String(s).replace(/\s*\([^)]*\)\s*/g, ' ').trim()))
.filter(s => /^[A-ZÄÖÜ]/.test(s) && s.length >= 2 && s.length <= 25
&& !/^(Sprecher|Kandidat|Teilnehmer|Person|Firma|Mitarbeiter|Manager|jeder|alle|guten?)\b/i.test(s)
&& !/\b(mit|von|der|die|das|nicht)\b.*\b(mit|von|der|die|das|nicht)\b/i.test(s)
&& (s.match(/\s/g) || []).length <= 2);
const thema = String(j.thema || '').trim().replace(/[.:]+\s*$/, '').slice(0, 60);
if (sum && isAbsoluteClaim(sum)) { console.log(`llm: Abschnitt ${i + 1} enthält Grundsatz-Aussage → Prüf-Aufruf`); sum = await verifySection(chunks[i], sum); }
if (sum) sections.push({ sum, names, thema });
} else if (rawOut) {
// Last resort: keep the prose so the section isn't lost (strip fences,
// malformed {...} blobs and chatty English openers — the #69 leak).
const prose = rawOut.replace(/```[a-z]*|```/gi, '').replace(/\{[\s\S]*\}/g, ' ')
.replace(/^\s*(Okay|Sure|Here'?s|Certainly)[^.\n]*[.:\n]/i, '').trim();
// < 60 chars = leftover junk (a stray quote once became a whole "Teil"),
// better to drop the section than to render garbage.
if (prose.length >= 60) { sum = prose.slice(0, 1200); sections.push({ sum, names: [], thema: '' }); }
}
if (sum) ctxLines.push(`${sections.length}. ${(sum.split(/(?<=[.!?])\s/)[0] || '').slice(0, 220)}`);
console.log(`llm: chunk ${i + 1}/${chunks.length} done (sections so far: ${sections.length})`);
}
// 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');
// The last chunk of a call is usually nothing but goodbyes, and the model
// dutifully writes a whole "Gesprächsabschluss" block quoting "Danke euch" /
// "Ciao" (#79). Drop a TRAILING section that is mostly farewell phrases — never
// an inner one, and never the only one.
// Two detectors, because the model sometimes QUOTES the goodbyes ("Danke
// euch", "Ciao") and sometimes only PARAPHRASES them ("bedankt sich bei den
// Teilnehmern") — the latter matches no phrase at all, but it always labels
// such a block "Gesprächsabschluss"/"Abschluss des Gesprächs".
const FAREWELL = /\b(tsch(ü|ue)ss|ciao|dann macht'?s gut|macht'?s gut|danke euch|dankt|bedank|verabschied|sch(ö|oe)nen (tag|abend)|bis (dann|morgen|n(ä|ae)chste)|gute[nrs]? (zeit|tag|woche)|w(ü|ue)nscht)\b/gi;
const FAREWELL_THEMA = /(abschluss|abschied|verabschied|gespr(ä|ae)chsende|schlussworte|ende des gespr(ä|ae)chs)/i;
while (sections.length > 1) {
const last = sections[sections.length - 1];
const hits = (last.sum.match(FAREWELL) || []).length;
if ((hits >= 2 || FAREWELL_THEMA.test(last.thema || '')) && last.sum.length < 800) {
console.log(`llm: Schluss-Abschnitt "${last.thema || '?'}" verworfen (${hits} Verabschiedungsfloskeln)`);
sections.pop();
} else break;
}
participants = uniqStr(cleanList(participants));
todos = uniqStr(cleanList(todos));
// Title micro-call on 2-sentence Kurzfassungen, LAST section excluded when
// there are ≥3 (closing smalltalk must not name the call). Tiny input/output
// = the regime where Gemma is reliable; graceful fallback to first sentence.
// Kurzfassung per section (thema + first 2 sentences) — shared input for the
// title and overview micro-calls.
const brief = (s) => `${s.thema ? s.thema + ' — ' : ''}${s.sum.split('. ').slice(0, 2).join('. ').slice(0, 300)}`;
let title = '';
const forTitle = sections.length >= 3 ? sections.slice(0, -1) : sections;
try {
const out = await ask(PROMPT_TITLE(forTitle.map((s, k) => `${k + 1}. ${brief(s)}`).join('\n'), note));
const tj = extractJson(out);
title = (tj?.titel || tj?.title || '').toString().trim().slice(0, 120);
} catch (e) { console.log(`llm: title call failed: ${e.message}`); }
if (!title) title = (sections[0]?.sum || '').split(/[.!?\n]/)[0].trim().slice(0, 60) || 'Gespräch';
// Overview micro-call (Copilot-style intro). Graceful: conglomerate works
// without it when the call fails or echoes.
let overview = '';
try {
const out = await ask(PROMPT_OVERVIEW(sections.map((s, k) => `${k + 1}. ${brief(s)}`).join('\n'), note));
const oj = extractJson(out) || {};
// Key is model-spelled: "ueberblick" as prompted, but also "überblick"/…
// — accept any long string value rather than losing the overview to a key
// spelling mismatch.
overview = (oj.ueberblick || oj['überblick'] || oj.uberblick || oj.overview
|| Object.values(oj).find(v => typeof v === 'string' && v.trim().length > 40) || '')
.toString().trim().slice(0, 800);
// Echo check: only the literal instruction phrases, not legitimate wording.
if (/Gewichte ALLE Abschnitte|2-3 deutschen Sätzen/.test(overview)) overview = '';
console.log(`llm: overview ${overview ? `ok (${overview.length} chars)` : `EMPTY; raw tail: ${JSON.stringify(out.slice(-200))}`}`);
} catch (e) { console.log(`llm: overview call failed: ${e.message}`); }
// Deterministic conglomerate: chronological, one block per section with its
// LLM headline. No LLM re-weighting = no mis-weighting. GUI renders pre-line.
// NOTE: the extracted proper nouns (s.names) are NOT printed under each block
// any more — they are working data for the rolling context between chunks
// (keeps the model's spelling of people/companies stable), not something the
// reader wants under every paragraph.
const body = sections.map((s, k) =>
`${s.thema || `Teil ${k + 1}`}:\n${s.sum}`
).join('\n\n');
const allNames = uniqStr(sections.flatMap(s => s.names));
const summary = [overview, body, allNames.length ? `Genannte Namen: ${allNames.join(', ')}` : '']
.filter(Boolean).join('\n\n');
// 16000, not 6000: a 33-min 13-speaker standup produced ~7k chars of
// sections — the old cap cut the last third mid-sentence (#74). Column is
// TEXT (64KB), the GUI scrolls.
return { title, summary: summary.slice(0, 16000), participants, todos, transcriptClean: '' };
}