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