#70-Debug: Diar Weak-Fold (7->4 Sprecher), LLM-Robustheit + Whole-Pass-Modellgrenze, Thermal-Cap fuer analyzeRecording
- diar.js: Stufe-3 Weak-Fold (DIAR_MIN_TOTAL_SEC 20s) gegen Phantom-Sprecher - llm.js: End-Instruktion, JSON-Repair, Sanity+Fallback, Chunk-Retry, Quote-Sanitizer, gelabelter/gewichteter Reduce; WHOLE_MAX 20000 (Gemma-4B-Grenze) - rkllm_server.cpp: max_context_len 8192->12288 - server.js: analyzeRecording unter A76-Takt-Cap (95-97 Grad ungedeckelt) - Doku Kap. 03/04/05 + Changelog Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0113vDKdSNtD3Dwcw4QMu9yV
This commit is contained in:
co-authored by
Claude Fable 5
parent
f9e1eb82c4
commit
2f00ddeec6
+75
-16
@@ -112,7 +112,15 @@ function extractJson(text) {
|
||||
let t = text.replace(/```(json)?/gi, '').trim();
|
||||
const s = t.indexOf('{'), e = t.lastIndexOf('}');
|
||||
if (s === -1 || e === -1 || e < s) return null;
|
||||
try { return JSON.parse(t.slice(s, e + 1)); } catch { return null; }
|
||||
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
|
||||
@@ -207,14 +215,35 @@ const PROMPT_WHOLE_ANALYZE = (transcript, hint = CORRECTION_HINT) =>
|
||||
`Du analysierst das VOLLSTÄNDIGE Transkript eines deutschen Gesprächs aus automatischer Spracherkennung. ${hint}
|
||||
|
||||
Lies das GESAMTE Gespräch und gib AUSSCHLIESSLICH gültiges JSON zurück (kein Markdown, keine Erklärung) mit genau diesen Feldern:
|
||||
{"titel": "kurzer prägnanter Titel, max. 6 Wörter", "zusammenfassung": "kurzer Überblick in 2-4 Sätzen: worum es ging und was das Ergebnis war", "kernpunkte": ["wichtiger besprochener Punkt als kurzer Stichpunkt, je ein Punkt pro Thema"], "entscheidungen": ["im Gespräch getroffene Entscheidung als kurzer Satz"], "teilnehmer": ["Name oder Rolle je beteiligter Person"], "todos": ["kurze Handlungsanweisung im Imperativ"]}
|
||||
Antworte auf Deutsch. Fülle alle Felder mit ECHTEM Inhalt aus dem Gespräch; schreibe niemals Feldbeschreibungen ab. "kernpunkte" = 3-8 Stichpunkte über ALLE wichtigen Themen; "entscheidungen" = nur wirklich getroffene Entscheidungen, [] wenn keine. "teilnehmer" = im Gespräch genannte/erkennbare Personen (Namen wenn genannt, sonst Rolle); [] wenn unklar.
|
||||
{"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
|
||||
@@ -287,13 +316,16 @@ const estTokens = (s) => Math.ceil((s || '').length / 3.3); // ~German chars p
|
||||
// slow part, ~5-8 tok/s) stay ~120/chunk. Big chunks → fewer chunks → faster.
|
||||
const CHUNK_CHARS = 8000; // small chunks: Gemma emits valid JSON on short inputs, fails on big ones
|
||||
const SINGLE_MAX_CHARS = 4800; // below this: single combined call (full cleaning)
|
||||
// The 16K-converted Gemma model is RUN at an 8192 runtime context (rkllm_server
|
||||
// max_context_len) to cap KV-cache RAM on the 8GB board. The WHOLE conversation
|
||||
// still fits in ONE analysis-only call → coherent summary (vs the map-reduce
|
||||
// chunking below, which lost cross-chunk coherence → the "Unsinn" long summary).
|
||||
// Budget: 8192 ctx − 1536 gen ≈ 6656 tok prompt ≈ 21900 chars; 20000 leaves
|
||||
// margin. Longer recordings still fall back to chunking. Raise together with the
|
||||
// server's max_context_len if board RAM headroom allows.
|
||||
// The 16K-converted Gemma model is RUN at a 12288 runtime context (rkllm_server
|
||||
// max_context_len) — but 20000 chars stays the whole-pass cutoff: it is a MODEL
|
||||
// limit, not a context limit. Verified on #70 (22.6k chars, fits in 12288 ctx):
|
||||
// above ~6k input tokens Gemma-3-4B ignores the instruction (English chat),
|
||||
// fixates on the END of the call (recency bias: summarized the closing car
|
||||
// smalltalk), and its JSON decays (curly quotes, repeated keys, repetition
|
||||
// loops). At ≤20k chars whole-pass quality is validated good (#69 era). Longer
|
||||
// recordings use the chunked map-reduce below, whose REDUCE prompt weights
|
||||
// topics by share across sections. Do NOT raise without re-validating content
|
||||
// quality — a bigger window does not make the model read evenly.
|
||||
const WHOLE_MAX_CHARS = 20000;
|
||||
|
||||
// Split transcript into <=maxChars pieces on natural boundaries (labeled lines
|
||||
@@ -320,9 +352,9 @@ const PROMPT_REDUCE = (summaries) =>
|
||||
${summaries}
|
||||
"""
|
||||
|
||||
Erstelle daraus EINE zusammenhängende deutsche Gesamt-Zusammenfassung des GANZEN Gesprächs. Decke ALLE Themen, Entscheidungen und Ergebnisse aus ALLEN Abschnitten ab — lasse kein wichtiges Thema weg, konzentriere dich nicht auf ein einzelnes. Nenne konkrete Namen, Rollen und Entscheidungen.
|
||||
Erstelle daraus EINE zusammenhängende deutsche Gesamt-Zusammenfassung des GANZEN Gesprächs. Die Abschnitte sind chronologisch und gleich lang — das HAUPTTHEMA ist, was in mehreren Abschnitten vorkommt oder den meisten Raum einnimmt, NICHT was zuletzt kam: Titel und Überblick nur aus den größten Themen; Smalltalk und Randbemerkungen (z.B. Begrüßung, Privates am Schluss) dort weglassen. Ein Thema, das NUR im letzten Abschnitt vorkommt, ist meist Smalltalk am Gesprächsende — erwähne es höchstens als letzten Kernpunkt, nie in Titel oder Überblick. Decke in den Kernpunkten ALLE Themen, Entscheidungen und Ergebnisse aus ALLEN Abschnitten ab — lasse kein wichtiges Thema weg, konzentriere dich nicht auf ein einzelnes. Nenne KONKRETE Ergebnisse mit Namen, Firmen und Rollen: wer macht was, welche Kontakte wurden geknüpft, welche Zusagen/Bewerbungen/Termine gab es.
|
||||
Antworte auf DEUTSCH und AUSSCHLIESSLICH mit einem JSON-Objekt, ohne Text davor oder danach, ohne Markdown. Beginne mit { und ende mit }.
|
||||
{"titel": "prägnanter deutscher Titel, max. 6 Wörter, Hauptthema", "zusammenfassung": "kurzer deutscher Überblick über das GESAMTE Gespräch in 2-4 Sätzen", "kernpunkte": ["wichtiger besprochener Punkt als kurzer deutscher Stichpunkt, je ein Punkt pro Thema, ALLE Abschnitte abdecken"], "entscheidungen": ["im Gespräch getroffene Entscheidung als kurzer deutscher Satz; leere Liste wenn keine"]}`;
|
||||
{"titel": "prägnanter deutscher Titel, max. 6 Wörter, Hauptthema", "zusammenfassung": "kurzer deutscher Überblick über das GESAMTE Gespräch in 2-4 Sätzen", "kernpunkte": ["wichtiger besprochener Punkt als kurzer deutscher Stichpunkt, je ein Punkt pro Thema, ALLE Abschnitte abdecken, wichtigstes zuerst"], "entscheidungen": ["im Gespräch getroffene Entscheidung als kurzer deutscher Satz; leere Liste wenn keine"]}`;
|
||||
|
||||
const uniqStr = (arr) => [...new Set((arr || []).map(s => String(s).trim()).filter(Boolean))];
|
||||
|
||||
@@ -348,7 +380,12 @@ export async function analyze(transcript, { labeled = false, glossary = [] } = {
|
||||
if (!raw) return { title: 'Leere Aufnahme', summary: '', participants: [], todos: [], transcriptClean: '' };
|
||||
const hint = correctionHint(glossary);
|
||||
if (raw.length > WHOLE_MAX_CHARS) return analyzeLong(raw, hint);
|
||||
if (raw.length > SINGLE_MAX_CHARS) return analyzeWhole(raw, hint);
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -361,7 +398,20 @@ export async function analyze(transcript, { labeled = false, glossary = [] } = {
|
||||
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),
|
||||
@@ -422,8 +472,14 @@ async function analyzeLong(raw, hint = CORRECTION_HINT) {
|
||||
for (let i = 0; i < chunks.length; i++) {
|
||||
let j = null, rawOut = '';
|
||||
try {
|
||||
rawOut = await ask(PROMPT_CHUNK_ANALYZE(chunks[i], hint));
|
||||
j = extractJson(rawOut) || extractJson(`{${rawOut.replace(/^[\s,]+|[\s,]+$/g, '')}}`);
|
||||
// Whether a chunk parses is a sampling coin-flip (same prompt: 3/3 ok in
|
||||
// one run, 1/3 in the next) — one retry turns P(fail) into P(fail²) and
|
||||
// costs ~100s only when needed.
|
||||
for (let tries = 0; tries < 2 && !j; tries++) {
|
||||
rawOut = await ask(PROMPT_CHUNK_ANALYZE(chunks[i], hint));
|
||||
j = extractJson(rawOut) || extractJson(`{${rawOut.replace(/^[\s,]+|[\s,]+$/g, '')}}`);
|
||||
if (!j && tries === 0) console.log(`llm: chunk ${i + 1} unparseable -> retry`);
|
||||
}
|
||||
okChunks++;
|
||||
console.log(`llm: chunk ${i + 1} parsed: ${j ? 'JSON ok' : 'PARSE_FAIL(salvage prose)'}`);
|
||||
} catch (e) { lastErr = e; console.log(`llm: chunk ${i + 1} failed: ${e.message}`); }
|
||||
@@ -458,7 +514,10 @@ async function analyzeLong(raw, hint = CORRECTION_HINT) {
|
||||
// Gemma answers big inputs with prose instead of JSON (same failure as the
|
||||
// chunk step) → cap each partial summary and retry the reduce once; the
|
||||
// #69 run lost title+structure because the single reduce didn't parse.
|
||||
const reduceInput = summaries.map(s => s.slice(0, 800)).join('\n\n');
|
||||
// Label each partial so the reduce can APPLY its weighting rule ("main
|
||||
// topic = spans several sections") — unlabeled concatenation gave the last
|
||||
// section (closing smalltalk) equal weight in title/overview (#70).
|
||||
const reduceInput = summaries.map((s, i) => `Abschnitt ${i + 1} von ${summaries.length}:\n${s.slice(0, 800)}`).join('\n\n');
|
||||
for (let attempt = 1; attempt <= 2 && !red; attempt++) {
|
||||
try {
|
||||
const out = await ask(PROMPT_REDUCE(reduceInput));
|
||||
|
||||
Reference in New Issue
Block a user