From 432c4b14dd7157a3ca411978b96c10253ddce75f Mon Sep 17 00:00:00 2001 From: schakal00783 Date: Fri, 17 Jul 2026 16:03:18 +0200 Subject: [PATCH] LLM: Sektions-Pfad ab 5 Sprechern gegen Recency-Bias in Meetings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Whole-Pass versagte auf #74 (13-Sprecher-Weekly, 31k Zeichen): Summary enthielt fast nur die letzten Sprecher. analyze() routet labeled Transkripte mit >=5 Sprechern jetzt in analyzeLong; Sektions-Pfad liefert Themen-Überschriften, jede Person, Smalltalk-Dämpfung, Todos mit Verantwortlichem, Überblick-Mikro-Call, Cap 16000. Kap. 05 + Changelog aktualisiert. Co-Authored-By: Claude Fable 5 --- app/llm.js | 79 +++++++++++++++++++++++++++++++++++------- docs/05-llm-analyse.md | 38 ++++++++++++++++---- docs/08-changelog.md | 17 +++++++++ 3 files changed, 116 insertions(+), 18 deletions(-) diff --git a/app/llm.js b/app/llm.js index 57ec984..6aec753 100644 --- a/app/llm.js +++ b/app/llm.js @@ -334,6 +334,20 @@ const SINGLE_MAX_CHARS = 4800; // below this: single combined call (full clean // Budget at 12288 ctx: 12288 − 1536 gen ≈ 10752 tok ≈ 35k chars; 32000 leaves // margin. Beyond that the section-conglomerate path below takes over. const WHOLE_MAX_CHARS = 32000; +// Round-robin meetings (many speakers, each reporting their own topics) defeat +// the whole-pass even below WHOLE_MAX: on #74 (33min standup, 13 diarized +// speakers, 31k chars) the one-call summary kept only the LAST speakers' +// topics — a 4B cannot weight 15+ equal topics from a 10k-token prompt. +// Labeled transcripts with >= this many distinct speakers go through the +// section-conglomerate path instead, which covers every part by construction. +const MANY_SPEAKERS = 5; + +// Count distinct "Name:"-style turn labels in a labeled dialogue. +function countSpeakers(text) { + const s = new Set(); + for (const m of text.matchAll(/^([^:\n]{1,40}):\s/gm)) s.add(m[1].trim()); + return s.size; +} // Split transcript into <=maxChars pieces on natural boundaries (labeled lines // first, else sentences, else hard char cut) so no chunk splits mid-word. @@ -378,9 +392,9 @@ Aktueller Abschnitt:` : ''} ${transcript} """ -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").` : ''} +Fasse NUR DEN AKTUELLEN Abschnitt zusammen. Nenne ALLE besprochenen Themen und Entscheidungen (nicht nur eines), mit konkreten Namen/Rollen. Wenn mehrere Personen nacheinander berichten, fasse den BERUFLICHEN Beitrag JEDER Person zusammen — lasse NIEMANDEN weg; nutze den Namen der Person, wenn er aus dem Kontext hervorgeht (Vorstellung, Anrede), sonst das Sprecher-Label. Berufliches steht im Vordergrund: private Erlebnisse und Smalltalk (Essen, Familie, Auto, Urlaubserzählungen, Anekdoten) höchstens in einem halben Satz erwähnen oder ganz weglassen.${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", "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"]}`; +{"thema": "kurze Überschrift dieses Abschnitts, max. 6 Wörter", "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": ["nur beruflich relevante, konkret angewiesene oder fest zugesagte Aufgabe im Imperativ, dahinter in Klammern wer sie übernimmt (falls erkennbar); private Vorhaben, Freizeit und 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 @@ -392,13 +406,28 @@ ${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": "..."}`; +// Overview micro-call over the section Kurzfassungen — same small-input/small- +// output regime as the title call. Gives the conglomerate a Copilot-style +// intro paragraph; equal weighting is enforced by the input (every section +// contributes the same 1-2 sentences). +const PROMPT_OVERVIEW = (kurzfassungen) => +`Kurzfassungen der Abschnitte eines deutschen Gesprächs (chronologisch): +${kurzfassungen} + +Gib einen kurzen Überblick über das GESAMTE Gespräch in 2-3 deutschen Sätzen: worum es ging und was die wichtigsten Ergebnisse waren. Gewichte ALLE Abschnitte gleich — nicht nur die letzten. +Antworte AUSSCHLIESSLICH mit einem JSON-Objekt: {"ueberblick": "..."}`; + // Analyze a transcript → { title, summary, participants[], todos[], transcriptClean }. // `labeled` = the text already carries acoustic "Sprecher X:" turn labels. export async function analyze(transcript, { labeled = false, glossary = [] } = {}) { const raw = (transcript || '').trim(); if (!raw) return { title: 'Leere Aufnahme', summary: '', participants: [], todos: [], transcriptClean: '' }; const hint = correctionHint(glossary); - if (raw.length > WHOLE_MAX_CHARS) return analyzeLong(raw, hint); + const manySpeakers = labeled && countSpeakers(raw) >= MANY_SPEAKERS; + if (raw.length > WHOLE_MAX_CHARS || (manySpeakers && raw.length > SINGLE_MAX_CHARS)) { + if (manySpeakers) console.log(`llm: ${countSpeakers(raw)} speakers -> section path (round-robin meeting)`); + return analyzeLong(raw, hint); + } if (raw.length > SINGLE_MAX_CHARS) { // Whole-pass gives the coherent summary; if Gemma's JSON breaks even after // the repair call, degrade to chunked map-reduce instead of failing hard. @@ -481,6 +510,7 @@ function salvageChunkFields(text) { const t = text.replace(/[“”„«»]/g, '"'); const sum = (t.match(/"zusammenfassung"\s*:\s*"((?:[^"\\]|\\.)*)"/) || [])[1] || ''; if (!sum) return null; + const thema = (t.match(/"thema"\s*:\s*"((?:[^"\\]|\\.)*)"/) || [])[1] || ''; let names = []; const nm = t.match(/"eigennamen"\s*:\s*\[([\s\S]*?)(\]|$)/); if (nm) names = [...nm[1].matchAll(/"((?:[^"\\]|\\.){2,60})"/g)].map(m => m[1]) @@ -488,7 +518,7 @@ function salvageChunkFields(text) { 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 }; + return { thema, zusammenfassung: sum, eigennamen: names, todos }; } // Chunked path for long transcripts (> WHOLE_MAX_CHARS). Design after the #70 @@ -549,7 +579,8 @@ async function analyzeLong(raw, hint = CORRECTION_HINT) { && !/^(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 }); + const thema = String(j.thema || '').trim().replace(/[.:]+\s*$/, '').slice(0, 60); + if (sum) sections.push({ sum, names, thema }); } else if (rawOut) { // Last resort: keep the prose so the section isn't lost (strip fences, // malformed {...} blobs and chatty English openers — the #69 leak). @@ -557,7 +588,7 @@ async function analyzeLong(raw, hint = CORRECTION_HINT) { .replace(/^\s*(Okay|Sure|Here'?s|Certainly)[^.\n]*[.:\n]/i, '').trim(); // < 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: [] }); } + if (prose.length >= 60) { sum = prose.slice(0, 1200); sections.push({ sum, names: [], thema: '' }); } } 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})`); @@ -572,22 +603,46 @@ async function analyzeLong(raw, hint = CORRECTION_HINT) { // 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. + // Kurzfassung per section (thema + first 2 sentences) — shared input for the + // title and overview micro-calls. + const brief = (s) => `${s.thema ? s.thema + ' — ' : ''}${s.sum.split('. ').slice(0, 2).join('. ').slice(0, 300)}`; 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 out = await ask(PROMPT_TITLE(forTitle.map((s, k) => `${k + 1}. ${brief(s)}`).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. + // Overview micro-call (Copilot-style intro). Graceful: conglomerate works + // without it when the call fails or echoes. + let overview = ''; + try { + const out = await ask(PROMPT_OVERVIEW(sections.map((s, k) => `${k + 1}. ${brief(s)}`).join('\n'))); + const oj = extractJson(out) || {}; + // Key is model-spelled: "ueberblick" as prompted, but also "überblick"/… + // — accept any long string value rather than losing the overview to a key + // spelling mismatch. + overview = (oj.ueberblick || oj['überblick'] || oj.uberblick || oj.overview + || Object.values(oj).find(v => typeof v === 'string' && v.trim().length > 40) || '') + .toString().trim().slice(0, 800); + // Echo check: only the literal instruction phrases, not legitimate wording. + if (/Gewichte ALLE Abschnitte|2-3 deutschen Sätzen/.test(overview)) overview = ''; + console.log(`llm: overview ${overview ? `ok (${overview.length} chars)` : `EMPTY; raw tail: ${JSON.stringify(out.slice(-200))}`}`); + } catch (e) { console.log(`llm: overview call failed: ${e.message}`); } + + // Deterministic conglomerate: chronological, one block per section with its + // LLM headline. 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(', ')})` : ''}` + `${s.thema || `Teil ${k + 1}`}:\n${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}`; + const summary = [overview, body, allNames.length ? `Genannte Namen: ${allNames.join(', ')}` : ''] + .filter(Boolean).join('\n\n'); - return { title, summary: summary.slice(0, 6000), participants, todos, transcriptClean: '' }; + // 16000, not 6000: a 33-min 13-speaker standup produced ~7k chars of + // sections — the old cap cut the last third mid-sentence (#74). Column is + // TEXT (64KB), the GUI scrolls. + return { title, summary: summary.slice(0, 16000), participants, todos, transcriptClean: '' }; } diff --git a/docs/05-llm-analyse.md b/docs/05-llm-analyse.md index 8e2b844..df2bf7d 100644 --- a/docs/05-llm-analyse.md +++ b/docs/05-llm-analyse.md @@ -19,25 +19,51 @@ flowchart TB T["Transkript"] --> A{"≤ 4 800 Zeichen?"} A -- ja --> SINGLE["analyzeSingle:
EIN Call = bereinigter Dialog + JSON-Analyse
(Fallback: separater JSON-Call bei Echo/Parse-Fehler)"] - A -- nein --> B{"≤ 32 000 Zeichen?"} + A -- nein --> SPK{"labeled + ≥ 5 Sprecher?
(MANY_SPEAKERS, countSpeakers)"} + SPK -- "ja (Round-Robin-Meeting)" --> LONG + SPK -- nein --> B{"≤ 32 000 Zeichen?"} B -- ja --> WHOLE["analyzeWhole:
GANZES Gespräch in einem Analyse-Call
(kohärente Summary, kleine JSON-Ausgabe)
Instruktion VOR und NACH dem Transkript"] WHOLE --> WCHECK{"JSON parsebar +
Zusammenfassung ≥ 20 Zeichen?"} WCHECK -- "JSON kaputt" --> REPAIR["Repair-Call: NUR der kaputte
Output (klein) → gültiges JSON"] REPAIR --> WCHECK WCHECK -- "weiterhin leer/Junk" --> LONG WCHECK -- ja --> OUT - 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"] + B -- nein --> LONG["analyzeLong (Sektions-Konglomerat):
Chunks à 4 200 Zeichen
(bei 8k wird der Inhalt generisch/namenlos,
bei 4k bleibt das Modell geerdet);
chunkText splittet an Zeilen = Turn-Grenzen"] + LONG --> MAP["MAP pro Chunk, sequenziell mit ROLLING CONTEXT
(1-Satz-Kurzfassungen der vorherigen Abschnitte →
Änderungen erkennbar: 'jetzt X, vorher Y'):
Mini-JSON thema (Überschrift ≤ 6 Wörter) +
zusammenfassung (JEDE berichtende Person, beruflich;
Smalltalk max. Halbsatz) + eigennamen[] (EXTRAKTION)
+ teilnehmer + todos (nur beruflich, Verantwortlicher
in Klammern) — 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)"] + 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/thema-Strings überleben 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"] + COLLECT --> TITLE["TITEL-Mikro-Call: Thema + 2-Satz-Kurzfassungen
OHNE letzten Abschnitt (Schluss-Smalltalk-Filter)
→ nur {titel} — kleines Input/Output = zuverlässig"] + TITLE --> OVERVIEW["ÜBERBLICK-Mikro-Call über dieselben Kurzfassungen
→ {ueberblick} 2-3 Sätze, alle Abschnitte gleich gewichtet
(Key-tolerant parsen: auch 'überblick' oder
irgendein langer String-Value; leer = weglassen)"] + OVERVIEW --> ASSEMBLE["KONGLOMERAT baut CODE (kein LLM-Reduce):
Überblick-Absatz + pro Sektion 'Thema:\nSummary (Namen: …)'
chronologisch + 'Genannte Namen: …' am Ende
→ deterministisch, keine Fehlgewichtung möglich; Cap 16 000"] ASSEMBLE --> OUT["Ergebnis"] SINGLE --> OUT ``` +### Round-Robin-Meetings: Sprecherzahl schlägt Länge (2026-07-17) + +Aufnahme #74 (33-min-Weekly, 13 diarisierte Sprecher, 31k Zeichen) lag knapp UNTER `WHOLE_MAX_CHARS` +und lief als Whole-Pass — Ergebnis: Summary fast nur aus den LETZTEN Sprechern (Gasblender, Sitas, +Zeiterfassung), alle frühen Wochenberichte fehlten. Ein 4B kann 15+ gleichgewichtige Themen aus einem +~10k-Token-Prompt nicht ausbalancieren — der Whole-Pass funktioniert nur für narrative Gespräche mit +wenigen Sprechern (dort validiert, #70/#71). Deshalb routet `analyze()` **labeled Transkripte mit +≥ 5 Sprechern (`MANY_SPEAKERS`) immer in den Sektions-Pfad**, der jede Gesprächsphase per Konstruktion +abdeckt. Der Sektions-Pfad wurde dabei auf Copilot-Niveau ausgebaut (3 Iterationen auf #74): + +1. **Themen-Überschrift pro Chunk** (`thema`-Feld) → Konglomerat liest sich wie Besprechungsnotizen + („Performanceproblem bei SAP-System:" statt „Teil 2/10:"). +2. **Jede berichtende Person MUSS vorkommen** (Prompt-Regel) — vorher fielen einzelne Sprecher weg. +3. **Smalltalk-Dämpfung**: Privates (Essen, Familie, Auto, Urlaubserzählungen) max. ein Halbsatz; + Todos nur beruflich, mit Verantwortlichem in Klammern (37 → 30 saubere Todos auf #74). +4. **Überblick-Mikro-Call** (`PROMPT_OVERVIEW`) liefert den Intro-Absatz; gleiche kleine + Input/Output-Klasse wie der Titel-Call. GOTCHA: Qwen antwortet teils mit Key `"überblick"` + (ü!) statt `"ueberblick"` — Parser akzeptiert jeden langen String-Value. +5. Summary-Cap 6 000 → **16 000** Zeichen: 10 Sektionen ≈ 8k, der alte Cap schnitt mitten im Satz ab. + +Validiert auf #74: alle 13 Sprecher + alle Themen (DWP-Bank, Diakonie, SAP-GUI, Sitas, Zeiterfassung, +Wellpass, LinkedIn, Polavis, Gasblender) abgedeckt, kein Recency-Bias mehr. Backup `llm.js.bak-standup`. + ### 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 diff --git a/docs/08-changelog.md b/docs/08-changelog.md index 0cdc2b9..aa799dd 100644 --- a/docs/08-changelog.md +++ b/docs/08-changelog.md @@ -3,6 +3,23 @@ Chronik der Entwicklungsschritte. **Regel: jede zukünftige Änderung bekommt hier einen Eintrag** (Datum, Was, Warum, betroffene Dateien/Backups). +## 2026-07-17 — Round-Robin-Meetings: Sektions-Pfad statt Whole-Pass ab 5 Sprechern (Recency-Bias-Fix) + +- **Problem:** #74 (33-min-Weekly, 13 Sprecher, 31k Zeichen) lief knapp unter `WHOLE_MAX_CHARS` als + Whole-Pass → Summary enthielt fast nur die letzten Sprecher. 4B kann 15+ gleichwertige Themen in + einem ~10k-Token-Prompt nicht gewichten; Whole-Pass taugt nur für narrative Gespräche mit wenigen + Sprechern. +- **Fix (llm.js, `.bak-standup`):** `analyze()` routet labeled Transkripte mit ≥ 5 Sprechern + (`MANY_SPEAKERS`, `countSpeakers()`) immer in `analyzeLong` (Sektions-Konglomerat). Sektions-Pfad + auf Besprechungsnotizen-Niveau ausgebaut: `thema`-Überschrift pro Chunk, Pflicht zur Erwähnung + JEDER berichtenden Person, Smalltalk-Dämpfung (Privates max. Halbsatz), Todos nur beruflich mit + Verantwortlichem in Klammern, Überblick-Mikro-Call (`PROMPT_OVERVIEW`, Key-tolerant: Qwen schreibt + teils `"überblick"`), Summary-Cap 6 000 → 16 000 (alter Cap schnitt 10 Sektionen mitten im Satz ab). +- **Validierung:** 3 Iterationen per `reanalyze-llm` auf #74 — final alle 13 Sprecher + alle Themen + abgedeckt (Intro-Absatz, 10 Themen-Sektionen, 30 saubere Todos mit Verantwortlichen), kein + Recency-Bias. Temporärer `/api/debug/ask` fürs Overview-Prompt-Tuning wieder entfernt + (server.js unverändert). + ## 2026-07-17 — GUI: Aufnahme-Tab gestrichen, Record-Button in der Kopfzeile + Stille-MP3-Fix - Der Reiter "Aufnahme" ist weg: Quelle (CallRecMix), HFP-Codec (auto) und Sprecherzahl (auto) waren