From 8cfdcf5d5688e06700a1028aa989e1e620f66a2c Mon Sep 17 00:00:00 2001 From: schakal00783 Date: Thu, 16 Jul 2026 17:47:14 +0200 Subject: [PATCH] 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 Claude-Session: https://claude.ai/code/session_0113vDKdSNtD3Dwcw4QMu9yV --- app/llm.js | 194 ++++++++++++++++++++++++++--------------- docs/05-llm-analyse.md | 30 +++++-- docs/08-changelog.md | 21 +++++ 3 files changed, 168 insertions(+), 77 deletions(-) diff --git a/app/llm.js b/app/llm.js index a9e58af..df0ad74 100644 --- a/app/llm.js +++ b/app/llm.js @@ -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++) { - 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: '', - }; + + // 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_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: '' }; } diff --git a/docs/05-llm-analyse.md b/docs/05-llm-analyse.md index 4a3588e..846c348 100644 --- a/docs/05-llm-analyse.md +++ b/docs/05-llm-analyse.md @@ -26,17 +26,27 @@ flowchart TB REPAIR --> WCHECK WCHECK -- "weiterhin leer/Junk" --> LONG WCHECK -- ja --> OUT - B -- nein --> LONG["analyzeLong (Map-Reduce):
Chunks à 8 000 Zeichen"] - LONG --> MAP["MAP: pro Chunk Mini-JSON
(zusammenfassung, teilnehmer, todos)
bewusst minimaler Prompt — lange Regelwände
kippen Gemma in englische Prosa
1 RETRY bei Parse-Fehler (Sampling-Münzwurf)"] - MAP --> SALV{"JSON parsebar?"} - SALV -- ja --> COLLECT["einsammeln"] - SALV -- nein --> PROSE["Prosa retten:
Code-Fences UND rohe {...}-Blobs entfernen
(JSON-Leak-Fix), dann als Teil-Summary nutzen"] - PROSE --> COLLECT - COLLECT --> REDUCE["REDUCE: Teil-Summaries (je ≤ 800 Zeichen,
gelabelt 'Abschnitt i von n')
→ EIN JSON: titel, zusammenfassung,
kernpunkte[], entscheidungen[]
Gewichtung nach Abschnitts-Anteil,
Schluss-Smalltalk nie in Titel/Überblick
2 Parse-Versuche, Tail wird geloggt"] - REDUCE --> OUT["Ergebnis"] + B -- nein --> LONG["analyzeLong (Sektions-Konglomerat):
Chunks à 4 200 Zeichen
(bei 8k wird der Inhalt generisch/namenlos,
bei 4k bleibt Gemma geerdet)"] + LONG --> MAP["MAP pro Chunk, sequenziell mit ROLLING CONTEXT
(1-Satz-Kurzfassungen der vorherigen Abschnitte →
Änderungen erkennbar: 'jetzt X, vorher Y'):
Mini-JSON zusammenfassung + eigennamen[]
(reine Namens-EXTRAKTION) + teilnehmer + todos
bewusst minimaler Prompt"] + MAP --> SALV{"JSON parsebar +
kein Feldbeschreibungs-Echo?"} + SALV -- nein --> RETRY["bis 3 Versuche, Prompt VARIIERT
(2.: ohne Kontext, 3.: ohne Hint —
manche Inhalte echoen sonst JEDES Mal)
dann Feld-Salvage per Regex
(zusammenfassung-String überlebt fast immer),
zuletzt Prosa-Rettung (≥ 60 Zeichen, sonst verwerfen)"] + RETRY --> COLLECT + SALV -- ja --> COLLECT["Sektionen einsammeln"] + COLLECT --> TITLE["TITEL-Mikro-Call: 2-Satz-Kurzfassungen
OHNE letzten Abschnitt (Schluss-Smalltalk-Filter)
→ nur {titel} — kleines Input/Output = zuverlässig"] + TITLE --> ASSEMBLE["KONGLOMERAT baut CODE (kein LLM-Reduce mehr):
'Gesprächsverlauf in n Teilen — genannte Namen: …'
+ 'Teil i/n: Summary (Namen: …)' chronologisch
→ 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“) diff --git a/docs/08-changelog.md b/docs/08-changelog.md index 6d091c6..c138cc8 100644 --- a/docs/08-changelog.md +++ b/docs/08-changelog.md @@ -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