LLM: Sektions-Pfad ab 5 Sprechern gegen Recency-Bias in Meetings

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 <noreply@anthropic.com>
This commit is contained in:
schakal00783
2026-07-17 16:03:18 +02:00
co-authored by Claude Fable 5
parent b33b555fa7
commit 432c4b14dd
3 changed files with 116 additions and 18 deletions
+67 -12
View File
@@ -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: '' };
}