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