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:
schakal00783
2026-07-16 17:47:14 +02:00
co-authored by Claude Fable 5
parent 2f00ddeec6
commit 8cfdcf5d56
3 changed files with 168 additions and 77 deletions
+124 -68
View File
@@ -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
View File
@@ -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“)
+21
View File
@@ -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