Extraktion bleibt als Arbeitsdatum fuer den Rolling Context zwischen den Chunks. Bestehende Summaries bereinigt (#74 7503 -> 7203 Zeichen).
653 lines
41 KiB
JavaScript
653 lines
41 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;
|
||
// 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++) {
|
||
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;
|
||
}
|
||
|
||
// 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);
|
||
|
||
// 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.
|
||
// 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).
|
||
const CHUNK_CHARS = 4200;
|
||
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 = 5;
|
||
|
||
// 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.
|
||
const PROMPT_CHUNK_ANALYZE = (transcript, context = '', hint = CORRECTION_HINT) =>
|
||
`Abschnitt eines deutschen Gesprächs (aus Spracherkennung, evtl. Hörfehler — lies sinngemäß).${context ? ` Bisheriger Gesprächsverlauf in Kurzform:
|
||
${context}
|
||
|
||
Aktueller Abschnitt:` : ''}
|
||
"""
|
||
${transcript}
|
||
"""
|
||
|
||
Fasse NUR DEN AKTUELLEN Abschnitt zusammen. Nenne ALLE besprochenen Themen und Entscheidungen (nicht nur eines), mit konkreten Namen/Rollen. 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"]}`;
|
||
|
||
// 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) =>
|
||
`Kurzfassungen der Abschnitte eines deutschen Gesprächs (chronologisch):
|
||
${kurzfassungen}
|
||
|
||
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) =>
|
||
`Kurzfassungen der Abschnitte eines deutschen Gesprächs (chronologisch):
|
||
${kurzfassungen}
|
||
|
||
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.
|
||
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);
|
||
const manySpeakers = labeled && countSpeakers(raw) >= MANY_SPEAKERS;
|
||
if (raw.length > WHOLE_MAX_CHARS || (manySpeakers && raw.length > SINGLE_MAX_CHARS)) {
|
||
if (manySpeakers) console.log(`llm: ${countSpeakers(raw)} speakers -> section path (round-robin meeting)`);
|
||
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,
|
||
};
|
||
}
|
||
|
||
// 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`).
|
||
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, 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 : '';
|
||
rawOut = await ask(PROMPT_CHUNK_ANALYZE(chunks[i], ctx, tries === 2 ? '' : hint));
|
||
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) 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');
|
||
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')));
|
||
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')));
|
||
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: '' };
|
||
}
|