Sektions-Konglomerat statt LLM-Reduce fuer Lang-Transkripte
- llm.js analyzeLong neu: 4.2k-Chunks (8k = namenloser Brei), Rolling Context, eigennamen-Extraktion, Echo-Guard mit Prompt-Variation (3 Versuche), Feld-Salvage, Titel-Mikro-Call ohne letzten Abschnitt, Konglomerat in Code - LLM-Reduce entfernt: Cross-Section-Synthese nachweislich jenseits Gemma-3-4B - server.js: Prompt-Lab-Endpoint nach Abschluss wieder entfernt - Doku Kap. 05 (Mermaid, Marotten) + 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
2f00ddeec6
commit
8cfdcf5d56
+124
-68
@@ -86,6 +86,10 @@ function ask(prompt) {
|
||||
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 };
|
||||
}
|
||||
@@ -314,7 +318,10 @@ const estTokens = (s) => Math.ceil((s || '').length / 3.3); // ~German chars p
|
||||
// Long path is ANALYSIS-ONLY per chunk (tiny JSON output), so the prompt can be
|
||||
// large — only the prefill grows, which is cheap on the NPU. Output tokens (the
|
||||
// slow part, ~5-8 tok/s) stay ~120/chunk. Big chunks → fewer chunks → faster.
|
||||
const CHUNK_CHARS = 8000; // small chunks: Gemma emits valid JSON on short inputs, fails on big ones
|
||||
// 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)
|
||||
// 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
|
||||
@@ -344,17 +351,11 @@ function chunkText(text, maxChars) {
|
||||
return chunks.length ? chunks : [text];
|
||||
}
|
||||
|
||||
// Reduce the per-chunk partial summaries into ONE final title + summary. Input
|
||||
// is small (a few 2-4 sentence summaries) so this call always fits the context.
|
||||
const PROMPT_REDUCE = (summaries) =>
|
||||
`Das sind Teil-Zusammenfassungen aufeinanderfolgender Abschnitte EINES langen deutschen Gesprächs (in Reihenfolge; manche evtl. auf Englisch):
|
||||
"""
|
||||
${summaries}
|
||||
"""
|
||||
|
||||
Erstelle daraus EINE zusammenhängende deutsche Gesamt-Zusammenfassung des GANZEN Gesprächs. Die Abschnitte sind chronologisch und gleich lang — das HAUPTTHEMA ist, was in mehreren Abschnitten vorkommt oder den meisten Raum einnimmt, NICHT was zuletzt kam: Titel und Überblick nur aus den größten Themen; Smalltalk und Randbemerkungen (z.B. Begrüßung, Privates am Schluss) dort weglassen. Ein Thema, das NUR im letzten Abschnitt vorkommt, ist meist Smalltalk am Gesprächsende — erwähne es höchstens als letzten Kernpunkt, nie in Titel oder Überblick. Decke in den Kernpunkten ALLE Themen, Entscheidungen und Ergebnisse aus ALLEN Abschnitten ab — lasse kein wichtiges Thema weg, konzentriere dich nicht auf ein einzelnes. Nenne KONKRETE Ergebnisse mit Namen, Firmen und Rollen: wer macht was, welche Kontakte wurden geknüpft, welche Zusagen/Bewerbungen/Termine gab es.
|
||||
Antworte auf DEUTSCH und AUSSCHLIESSLICH mit einem JSON-Objekt, ohne Text davor oder danach, ohne Markdown. Beginne mit { und ende mit }.
|
||||
{"titel": "prägnanter deutscher Titel, max. 6 Wörter, Hauptthema", "zusammenfassung": "kurzer deutscher Überblick über das GESAMTE Gespräch in 2-4 Sätzen", "kernpunkte": ["wichtiger besprochener Punkt als kurzer deutscher Stichpunkt, je ein Punkt pro Thema, ALLE Abschnitte abdecken, wichtigstes zuerst"], "entscheidungen": ["im Gespräch getroffene Entscheidung als kurzer deutscher Satz; leere Liste wenn keine"]}`;
|
||||
// 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))];
|
||||
|
||||
@@ -363,15 +364,33 @@ const uniqStr = (arr) => [...new Set((arr || []).map(s => String(s).trim()).filt
|
||||
// prose instead of JSON (4/5 chunks PARSE_FAIL'd → narrow summary). A short,
|
||||
// JSON-forcing, German-forcing prompt with the format LAST (recency) makes it
|
||||
// obey. No CORRECTION_HINT/TODO_RULE/NO_INVENT here — they triggered the chatter.
|
||||
const PROMPT_CHUNK_ANALYZE = (transcript, hint = CORRECTION_HINT) =>
|
||||
`Abschnitt eines deutschen Gesprächs (aus Spracherkennung, evtl. Hörfehler — lies sinngemäß):
|
||||
// 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 DIESEN Abschnitt zusammen. Nenne ALLE besprochenen Themen und Entscheidungen (nicht nur eines), mit konkreten Namen/Rollen.
|
||||
Fasse NUR DEN AKTUELLEN Abschnitt zusammen. Nenne ALLE besprochenen Themen und Entscheidungen (nicht nur eines), mit konkreten Namen/Rollen.${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 }.
|
||||
{"zusammenfassung": "3-6 deutsche Sätze über alle Themen dieses Abschnitts", "teilnehmer": ["Name oder Rolle"], "todos": ["konkret zugesagte Aufgabe im Imperativ; Grußformeln sind KEINE Aufgabe"]}`;
|
||||
{"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": ["konkret zugesagte Aufgabe im Imperativ; 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": "..."}`;
|
||||
|
||||
// Analyze a transcript → { title, summary, participants[], todos[], transcriptClean }.
|
||||
// `labeled` = the text already carries acoustic "Sprecher X:" turn labels.
|
||||
@@ -454,84 +473,121 @@ async function analyzeSingle(raw, labeled, hint = CORRECTION_HINT) {
|
||||
};
|
||||
}
|
||||
|
||||
// Chunked map-reduce path (long transcripts). MAP: analyse each chunk into a
|
||||
// small JSON (summary/participants/todos) — NO full-dialogue rewrite, so NPU
|
||||
// decode stays short (~120 tok/chunk vs ~1300 for cleaning → minutes not tens of
|
||||
// minutes). REDUCE: merge participants/todos and derive one final title+summary.
|
||||
// transcriptClean is left empty on purpose: the detail view renders the
|
||||
// diarized turns from `words`, and re-emitting the whole transcript through the
|
||||
// NPU is exactly the slow step we avoid here. No fixed length limit.
|
||||
// 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;
|
||||
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 { 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)`);
|
||||
const summaries = [];
|
||||
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 {
|
||||
// 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`);
|
||||
// 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 ? 'JSON ok' : 'PARSE_FAIL(salvage prose)'}`);
|
||||
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) {
|
||||
const s = (j.zusammenfassung || j.summary || '').toString().trim();
|
||||
if (s) summaries.push(s);
|
||||
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);
|
||||
if (sum) sections.push({ sum, names });
|
||||
} else if (rawOut) {
|
||||
// JSON parse failed (Gemma answered in prose) → salvage the prose so this
|
||||
// chunk's content still reaches the (German-forcing) reduce, instead of
|
||||
// being dropped (which collapsed the summary to a single lucky chunk).
|
||||
// Strip any malformed {...} blob first — raw JSON in a salvage leaked
|
||||
// verbatim into the final summary once (#69).
|
||||
// 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();
|
||||
if (prose) summaries.push(prose.slice(0, 1200));
|
||||
// < 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: [] }); }
|
||||
}
|
||||
console.log(`llm: chunk ${i + 1}/${chunks.length} done (summaries so far: ${summaries.length})`);
|
||||
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})`);
|
||||
}
|
||||
console.log(`llm: REDUCE input (${summaries.length} summaries): ${JSON.stringify(summaries.join(' || ').slice(0, 500))}`);
|
||||
// Every chunk failed (e.g. rkllm never came READY) → do NOT persist a fake
|
||||
// "done" with a generic "Gespräch" title; propagate so the row is marked
|
||||
// 'error' and the UI shows a retry (this was the silent-failure bug).
|
||||
if (okChunks === 0) throw lastErr || new Error('LLM: alle Chunks fehlgeschlagen');
|
||||
participants = uniqStr(cleanList(participants));
|
||||
todos = uniqStr(cleanList(todos));
|
||||
// Final title + summary from the (small) partial summaries.
|
||||
let red = null;
|
||||
if (summaries.length) {
|
||||
// Gemma answers big inputs with prose instead of JSON (same failure as the
|
||||
// chunk step) → cap each partial summary and retry the reduce once; the
|
||||
// #69 run lost title+structure because the single reduce didn't parse.
|
||||
// Label each partial so the reduce can APPLY its weighting rule ("main
|
||||
// topic = spans several sections") — unlabeled concatenation gave the last
|
||||
// section (closing smalltalk) equal weight in title/overview (#70).
|
||||
const reduceInput = summaries.map((s, i) => `Abschnitt ${i + 1} von ${summaries.length}:\n${s.slice(0, 800)}`).join('\n\n');
|
||||
for (let attempt = 1; attempt <= 2 && !red; attempt++) {
|
||||
|
||||
// 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.
|
||||
let title = '';
|
||||
const forTitle = sections.length >= 3 ? sections.slice(0, -1) : sections;
|
||||
try {
|
||||
const out = await ask(PROMPT_REDUCE(reduceInput));
|
||||
red = extractJson(out) || extractJson(`{${out.replace(/^[\s,]+|[\s,]+$/g, '')}}`);
|
||||
if (!red) console.log(`llm: reduce attempt ${attempt} unparseable; tail: ${JSON.stringify(out.slice(-200))}`);
|
||||
} catch (e) { console.log(`llm: reduce attempt ${attempt} failed:`, e.message); }
|
||||
}
|
||||
}
|
||||
const fallbackTitle = (summaries[0] || '').split(/[.!?\n]/)[0].trim().slice(0, 60);
|
||||
return {
|
||||
title: (red?.titel || red?.title || '').toString().trim().slice(0, 120) || fallbackTitle || 'Gespräch',
|
||||
summary: (red ? composeSummary(red) : '') || summaries.join('\n\n').slice(0, 2500),
|
||||
participants,
|
||||
todos,
|
||||
transcriptClean: '',
|
||||
};
|
||||
const out = await ask(PROMPT_TITLE(forTitle.map((s, k) => `${k + 1}. ${s.sum.split('. ').slice(0, 2).join('. ').slice(0, 300)}`).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';
|
||||
|
||||
// Deterministic conglomerate: chronological, section-labelled, names listed.
|
||||
// No LLM re-weighting = no mis-weighting. GUI renders pre-line.
|
||||
const body = sections.map((s, k) =>
|
||||
`Teil ${k + 1}/${sections.length}: ${s.sum}${s.names.length ? `\n(Namen: ${s.names.join(', ')})` : ''}`
|
||||
).join('\n\n');
|
||||
const allNames = uniqStr(sections.flatMap(s => s.names));
|
||||
const summary = `Gesprächsverlauf in ${sections.length} Teilen${allNames.length ? ` — genannte Namen: ${allNames.join(', ')}` : ''}\n\n${body}`;
|
||||
|
||||
return { title, summary: summary.slice(0, 6000), participants, todos, transcriptClean: '' };
|
||||
}
|
||||
|
||||
+22
-8
@@ -26,17 +26,27 @@ flowchart TB
|
||||
REPAIR --> WCHECK
|
||||
WCHECK -- "weiterhin leer/Junk" --> LONG
|
||||
WCHECK -- ja --> OUT
|
||||
B -- nein --> LONG["analyzeLong (Map-Reduce):<br/>Chunks à 8 000 Zeichen"]
|
||||
LONG --> MAP["MAP: pro Chunk Mini-JSON<br/>(zusammenfassung, teilnehmer, todos)<br/>bewusst minimaler Prompt — lange Regelwände<br/>kippen Gemma in englische Prosa<br/>1 RETRY bei Parse-Fehler (Sampling-Münzwurf)"]
|
||||
MAP --> SALV{"JSON parsebar?"}
|
||||
SALV -- ja --> COLLECT["einsammeln"]
|
||||
SALV -- nein --> PROSE["Prosa retten:<br/>Code-Fences UND rohe {...}-Blobs entfernen<br/>(JSON-Leak-Fix), dann als Teil-Summary nutzen"]
|
||||
PROSE --> COLLECT
|
||||
COLLECT --> REDUCE["REDUCE: Teil-Summaries (je ≤ 800 Zeichen,<br/>gelabelt 'Abschnitt i von n')<br/>→ EIN JSON: titel, zusammenfassung,<br/>kernpunkte[], entscheidungen[]<br/>Gewichtung nach Abschnitts-Anteil,<br/>Schluss-Smalltalk nie in Titel/Überblick<br/>2 Parse-Versuche, Tail wird geloggt"]
|
||||
REDUCE --> OUT["Ergebnis"]
|
||||
B -- nein --> LONG["analyzeLong (Sektions-Konglomerat):<br/>Chunks à 4 200 Zeichen<br/>(bei 8k wird der Inhalt generisch/namenlos,<br/>bei 4k bleibt Gemma geerdet)"]
|
||||
LONG --> MAP["MAP pro Chunk, sequenziell mit ROLLING CONTEXT<br/>(1-Satz-Kurzfassungen der vorherigen Abschnitte →<br/>Änderungen erkennbar: 'jetzt X, vorher Y'):<br/>Mini-JSON zusammenfassung + eigennamen[]<br/>(reine Namens-EXTRAKTION) + teilnehmer + todos<br/>bewusst minimaler Prompt"]
|
||||
MAP --> SALV{"JSON parsebar +<br/>kein Feldbeschreibungs-Echo?"}
|
||||
SALV -- nein --> RETRY["bis 3 Versuche, Prompt VARIIERT<br/>(2.: ohne Kontext, 3.: ohne Hint —<br/>manche Inhalte echoen sonst JEDES Mal)<br/>dann Feld-Salvage per Regex<br/>(zusammenfassung-String überlebt fast immer),<br/>zuletzt Prosa-Rettung (≥ 60 Zeichen, sonst verwerfen)"]
|
||||
RETRY --> COLLECT
|
||||
SALV -- ja --> COLLECT["Sektionen einsammeln"]
|
||||
COLLECT --> TITLE["TITEL-Mikro-Call: 2-Satz-Kurzfassungen<br/>OHNE letzten Abschnitt (Schluss-Smalltalk-Filter)<br/>→ nur {titel} — kleines Input/Output = zuverlässig"]
|
||||
TITLE --> ASSEMBLE["KONGLOMERAT baut CODE (kein LLM-Reduce mehr):<br/>'Gesprächsverlauf in n Teilen — genannte Namen: …'<br/>+ 'Teil i/n: Summary (Namen: …)' chronologisch<br/>→ deterministisch, keine Fehlgewichtung möglich"]
|
||||
ASSEMBLE --> OUT["Ergebnis"]
|
||||
SINGLE --> OUT
|
||||
```
|
||||
|
||||
### Warum kein LLM-Reduce mehr (Prompt-Lab 2026-07-16)
|
||||
|
||||
Der alte Reduce (Teil-Summaries → LLM baut EINE Gesamt-Summary) war die Fehlgewichtungs-Quelle. Im
|
||||
Prompt-Lab auf #70 (temporärer `/api/debug/ask`-Endpoint, ~15 Varianten) scheiterte JEDE Synthese-Form
|
||||
reproduzierbar: Gewichtungsregeln ignoriert (Auto-Smalltalk blieb im Titel), Namen durch Rollen ersetzt
|
||||
(„Personalwesen"), Themen-Tabellen mit 4× demselben Eintrag, Repetitionsschleifen. **Abschnittsübergreifende
|
||||
Synthese liegt jenseits von Gemma-3-4B** — Verstehen einzelner 4k-Abschnitte dagegen funktioniert gut
|
||||
(Com4Tec, René, Tobi+Niklas, 20 Bewerbungen alle korrekt extrahiert). Also: LLM versteht, Code führt zusammen.
|
||||
|
||||
### Warum die Whole-Pass-Grenze bei 20 000 Zeichen bleibt (Modellgrenze, 2026-07-16)
|
||||
|
||||
Aufnahme #70 (26 min, 22,6k Zeichen ≈ 6 900 Token) lag knapp über der Grenze. Versuch, sie per
|
||||
@@ -88,6 +98,10 @@ hatte alle im Gespräch *erwähnten* Namen als Teilnehmer gelistet (11 statt 3 b
|
||||
| Typografische Anführungszeichen (“”„) als JSON-Delimiter, trailing commas | `extractJson()`-Sanitizer: erst strikt parsen, bei Fehler Quotes/Kommas reparieren und nochmal |
|
||||
| Whole-Pass > ~6k Token: englisches Geplauber, Recency-Bias, JSON-Zerfall | `WHOLE_MAX_CHARS` 20 000 hart; End-Instruktion; Repair-Call; Sanity-Check (Zusammenfassung ≥ 20 Zeichen) → sonst Chunk-Fallback |
|
||||
| Kopiert `...`-Platzhalter aus Schema-Beispielen wörtlich | End-Instruktion nennt nur Feldnamen, nie ein Skelett mit Platzhaltern |
|
||||
| 8k-Chunk: Inhalt wird generisches namenloses Blabla (JSON trotzdem valide!) | Chunks à 4 200 Zeichen — dort bleibt der Inhalt geerdet mit echten Eigennamen |
|
||||
| Echot Feldbeschreibung INHALTS-abhängig bei JEDEM Versuch (Auto-Abschnitt #70, 2× identisch) | Echo-Guard + bis 3 Versuche mit VARIIERTEM Prompt (ohne Kontext / ohne Hint) |
|
||||
| `eigennamen`-Array degeneriert (Punkte-Loops, Junk-Tails), String-Felder intakt | Feld-Salvage per Regex holt zusammenfassung + Namen einzeln raus |
|
||||
| Cross-Section-Synthese: Regeln ignoriert, Namen→Rollen, Themen-Dubletten | LLM-Reduce ABGESCHAFFT — Konglomerat baut deterministischer Code |
|
||||
|
||||
## Eigennamen-Glossar (Whisper hört z. B. „Aime“ als „Amy“)
|
||||
|
||||
|
||||
@@ -3,6 +3,27 @@
|
||||
Chronik der Entwicklungsschritte. **Regel: jede zukünftige Änderung bekommt hier einen Eintrag** (Datum,
|
||||
Was, Warum, betroffene Dateien/Backups).
|
||||
|
||||
## 2026-07-16 — Sektions-Konglomerat statt LLM-Reduce (Abend, `.bak-sectionsum`)
|
||||
|
||||
Auftrag: Lang-Transkript-Summary (> 20k Zeichen) inhaltlich retten. Prompt-Lab auf #70 mit temporärem
|
||||
`/api/debug/ask`-Endpoint (nach Abschluss wieder entfernt), ~15 Reduce-/Chunk-Varianten in je ~2 min
|
||||
statt 9-min-Vollanalysen.
|
||||
|
||||
- **Befund 1: Chunk-Größe ist der Fidelity-Hebel.** 8k-Chunks → generisches namenloses Blabla; 4k-Chunks →
|
||||
geerdete Summaries mit echten Eigennamen (Com4Tec, René, Tobi+Niklas, 20 Bewerbungen). `CHUNK_CHARS` 8000 → **4200**.
|
||||
- **Befund 2: Cross-Section-Synthese unmöglich auf Gemma-3-4B.** Jede LLM-Reduce-Variante (Gewichtungsregeln,
|
||||
Abschnitts-Labels, Themen-Tabellen) → Regeln ignoriert, Namen durch Rollen ersetzt, Dubletten, Loops.
|
||||
**LLM-Reduce abgeschafft.**
|
||||
- **Neue `analyzeLong`-Pipeline** (`llm.js`): 4,2k-Chunks sequenziell mit **Rolling Context** (1-Satz-
|
||||
Kurzfassungen der Vorgänger → erkennt geänderte Entscheidungen: „jetzt X, vorher Y") + **eigennamen-
|
||||
Extraktionsfeld**; Echo-Guard (Feldbeschreibungs-Echo ist INHALTS-abhängig reproduzierbar → bis 3 Versuche
|
||||
mit variiertem Prompt: ohne Kontext, ohne Hint) + Feld-Salvage per Regex + Prosa-Rettung (≥ 60 Zeichen);
|
||||
**Titel-Mikro-Call** ohne letzten Abschnitt (Smalltalk-Filter); **Konglomerat baut Code**: „Gesprächsverlauf
|
||||
in n Teilen — genannte Namen: …" + „Teil i/n: …" chronologisch. Kap. 05 (Mermaid + Marotten aktualisiert).
|
||||
- **E2E #70 (3 Läufe):** Titel ohne Auto-Smalltalk ✅, alle Abschnitte mit echten Namen ✅, Auto nur noch in
|
||||
Teil 7 ✅, 12-14 min für 26-min-Aufnahme. Restgrenze: Abschnitts-Summaries variieren pro Lauf in Schärfe
|
||||
(Sampling), keine thematische Verdichtung ÜBER Abschnitte (4B-Decke). ≤ 20k Zeichen weiter Whole-Pass.
|
||||
|
||||
## 2026-07-16 — #70-Debug: Phantom-Sprecher, Summary-Fehlgewichtung, Thermal-Leck (Nachmittag)
|
||||
|
||||
Auslöser: Aufnahme #70 (26 min, 4 Personen) — 7 Sprecher erkannt, Titel/Summary aus Randthemen
|
||||
|
||||
Reference in New Issue
Block a user