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:
co-authored by
Claude Fable 5
parent
b33b555fa7
commit
432c4b14dd
+67
-12
@@ -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: '' };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user