Diarisierung: Marker-Zwangsbedingungen absichern, Voiceprints als cannot-link; LLM: Sektions-Pfad ab 9k Zeichen
#79 verlor einen von vier Sprechern und lieferte eine 1082-Zeichen-Zusammenfassung. Diarisierung: ein zweiter, falsch verankerter Marker verschmolz Saschas Cluster in Merles (Kosinus -0,727). must-link prueft jetzt die akustische Aehnlichkeit, Anker werden nach Trefferqualitaet gewichtet, und enrolled Stimmen wirken als cannot-link -- damit ist der Box-Besitzer immer ein eigener Sprecher. LLM: der Whole-Pass komprimierte das erste Thema weg. Sektions-Pfad jetzt ab 9000 Zeichen, kleinere Chunks, woertlicher Kontext aus dem Vorgaenger-Chunk und ein gezielter Pruef-Aufruf gegen ueberdehnte Einzelaussagen. #79: 4 Sprecher, Zusammenfassung ~5300 Zeichen, 7 Themenbloecke. Regression: #70 -> 4, #68 -> 2, #74 -> 20 Sprecher unveraendert.
This commit is contained in:
+105
-8
@@ -323,7 +323,14 @@ const estTokens = (s) => Math.ceil((s || '').length / 3.3); // ~German chars p
|
||||
// 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;
|
||||
// 3200 since 2026-07-29: at 4200 a chunk regularly holds two unrelated topics
|
||||
// (#79 chunk 1 = the Timo/Sabbatical opening AND the Instagram video review) and
|
||||
// the 4B summarizes only the longer one — the opening agenda item vanished from
|
||||
// the summary in one of three runs. Smaller chunks = fewer topics competing per
|
||||
// call; the extra ~2 calls cost ~5 min on the NPU.
|
||||
const CHUNK_CHARS = +(process.env.LLM_CHUNK_CHARS || 3200);
|
||||
// Verbatim overlap handed to the next chunk as context-only grounding.
|
||||
const TAIL_CHARS = +(process.env.LLM_TAIL_CHARS || 700);
|
||||
const SINGLE_MAX_CHARS = 4800; // below this: single combined call (full cleaning)
|
||||
// Whole-pass cutoff. Model-dependent, NOT just a context question:
|
||||
// - Gemma-3-4B fell apart above ~6k input tokens (English chat, recency bias,
|
||||
@@ -340,7 +347,16 @@ const WHOLE_MAX_CHARS = 32000;
|
||||
// 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;
|
||||
const MANY_SPEAKERS = +(process.env.LLM_MANY_SPEAKERS || 3);
|
||||
// …and speaker count is not the real driver either: #79 (23-min marketing jour
|
||||
// fixe, only 3 diarized speakers, 21k chars, 6 distinct topics) went whole-pass
|
||||
// and the summary kept ONLY the last topics — the first agenda item (Timo /
|
||||
// Sabbatical, 2.5 minutes at the very start) was missing entirely, and the whole
|
||||
// thing fit into 1082 chars where MS Teams produced 5 topic blocks. The failure
|
||||
// mode is INPUT LENGTH, not head count: above ~3k tokens the 4B compresses the
|
||||
// early part of the prompt away. Any transcript longer than this goes through
|
||||
// the section-conglomerate path, which covers every part by construction.
|
||||
const SECTION_MIN_CHARS = +(process.env.LLM_SECTION_MIN || 9000);
|
||||
|
||||
// Count distinct "Name:"-style turn labels in a labeled dialogue.
|
||||
function countSpeakers(text) {
|
||||
@@ -383,19 +399,76 @@ const uniqStr = (arr) => [...new Set((arr || []).map(s => String(s).trim()).filt
|
||||
// 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) =>
|
||||
// `tail` = the VERBATIM last few hundred characters of the previous chunk. The
|
||||
// rolling one-sentence context is a summary and loses referents; a sentence that
|
||||
// happens to land at the very start of a chunk then has no subject at all.
|
||||
// Measured on #79: "Ich würde die lustigen Sachen nur bei Instagram lassen" sits
|
||||
// 4 % into chunk 6, so the model read it cold and turned a statement about FUNNY
|
||||
// POSTS into "the team publishes only on Instagram" — the one factual inversion
|
||||
// that survived every prompt wording (wrong in 4 of 7 runs). The tail is marked
|
||||
// as context-only so it is not summarized twice.
|
||||
const PROMPT_CHUNK_ANALYZE = (transcript, context = '', hint = CORRECTION_HINT, tail = '') =>
|
||||
`Abschnitt eines deutschen Gesprächs (aus Spracherkennung, evtl. Hörfehler — lies sinngemäß).${context ? ` Bisheriger Gesprächsverlauf in Kurzform:
|
||||
${context}
|
||||
${context}` : ''}${tail ? `
|
||||
|
||||
Die letzten Sätze des VORIGEN Abschnitts, nur damit du weißt, worauf sich der Anfang bezieht — NICHT zusammenfassen:
|
||||
"""
|
||||
${tail}
|
||||
"""` : ''}${(context || tail) ? `
|
||||
|
||||
Aktueller Abschnitt:` : ''}
|
||||
"""
|
||||
${transcript}
|
||||
"""
|
||||
|
||||
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").` : ''}
|
||||
Fasse NUR DEN AKTUELLEN Abschnitt zusammen. Nenne ALLE besprochenen Themen und Entscheidungen (nicht nur eines), mit konkreten Namen/Rollen. Enthält der Abschnitt mehrere verschiedene Themen, behandle JEDES in mindestens einem Satz — auch ein kurzes Thema ganz am Anfang. Bleibe dabei genau beim Gesagten und verallgemeinere NICHT: Eine Entscheidung gilt immer nur für das, worüber gerade konkret gesprochen wird. Schreibe also "Video X kommt nur auf Instagram" und NICHT "das Team veröffentlicht nur noch auf Instagram". Drehe keine Aussage um. 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 }.
|
||||
{"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"]}`;
|
||||
|
||||
// Targeted fact-check for ONE section, run only when its draft claims something
|
||||
// absolute ("nur noch", "ausschließlich", "statt auch"). Those are the claims the
|
||||
// 4B gets wrong by over-scoping a sentence about a single post to the whole team
|
||||
// (#79: "Achso, ja, dann lassen wir LinkedIn raus" was said about ONE ragebait
|
||||
// video and became "the team publishes only on Instagram" in 5 of 8 runs — the
|
||||
// verbatim tail fixed the same error at a chunk START, but not this one, which
|
||||
// sits mid-chunk with its referent several sentences back). Verifying every
|
||||
// section would double a 20-minute analysis; this fires on 1-2 sections.
|
||||
// Two conditions, because "nur"/"statt" alone appear in perfectly fine sentences:
|
||||
// a scope word AND a collective subject. The first version only looked for the
|
||||
// literal "nur noch"/"statt auch" and therefore never fired on the actual
|
||||
// offender, "Das Team entscheidet sich dafür, nur Instagram zu nutzen statt
|
||||
// LinkedIn" (#79 run 9).
|
||||
const ABSOLUTE_SCOPE = /(\bnur\b|ausschließlich|\bstatt\b|nicht mehr|dauerhaft|grundsätzlich|komplett|beendet|eingestellt)/i;
|
||||
const COLLECTIVE_SUBJECT = /(das team|die gruppe|\bwir\b|\bman\b|künftig|zukünftig|in zukunft)/i;
|
||||
const isAbsoluteClaim = (s) => ABSOLUTE_SCOPE.test(s) && COLLECTIVE_SUBJECT.test(s);
|
||||
const PROMPT_VERIFY_SECTION = (transcript, draft) =>
|
||||
`Gesprächsabschnitt:
|
||||
"""
|
||||
${transcript}
|
||||
"""
|
||||
|
||||
Entwurf der Zusammenfassung:
|
||||
"""
|
||||
${draft}
|
||||
"""
|
||||
|
||||
Prüfe NUR die Aussagen des Entwurfs, die etwas Allgemeines oder Dauerhaftes behaupten ("nur noch", "ausschließlich", "statt auch", "nicht mehr"). Steht das wirklich so im Abschnitt, oder bezieht sich die Aussage in Wahrheit nur auf EINEN einzelnen Beitrag, EIN Video oder EINEN Fall? Wenn sie zu weit gefasst ist, schreibe sie eng und korrekt. Ändere sonst NICHTS und lasse keinen Inhalt weg.
|
||||
Antworte auf DEUTSCH und AUSSCHLIESSLICH mit {"zusammenfassung": "..."}`;
|
||||
|
||||
async function verifySection(chunk, draft) {
|
||||
try {
|
||||
const j = extractJson(await ask(PROMPT_VERIFY_SECTION(chunk, draft)));
|
||||
const fixed = (j?.zusammenfassung || j?.summary || '').toString().trim();
|
||||
// Guard against the checker "correcting" a section into a stub.
|
||||
if (fixed.length >= draft.length * 0.6) {
|
||||
if (fixed !== draft) console.log(`llm: Abschnitt nach Prüfung korrigiert (${draft.length}→${fixed.length} Zeichen)`);
|
||||
return fixed;
|
||||
}
|
||||
console.log(`llm: Prüf-Antwort verworfen (${fixed.length} statt ${draft.length} Zeichen)`);
|
||||
} catch (e) { console.log(`llm: Prüf-Aufruf fehlgeschlagen: ${e.message}`); }
|
||||
return draft;
|
||||
}
|
||||
|
||||
// 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).
|
||||
@@ -424,8 +497,10 @@ export async function analyze(transcript, { labeled = false, glossary = [] } = {
|
||||
if (!raw) return { title: 'Leere Aufnahme', summary: '', participants: [], todos: [], transcriptClean: '' };
|
||||
const hint = correctionHint(glossary);
|
||||
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)`);
|
||||
const longMeeting = raw.length > SECTION_MIN_CHARS;
|
||||
if (raw.length > WHOLE_MAX_CHARS || longMeeting || (manySpeakers && raw.length > SINGLE_MAX_CHARS)) {
|
||||
console.log(`llm: ${raw.length} chars / ${countSpeakers(raw)} speakers -> section path` +
|
||||
(longMeeting ? ` (>${SECTION_MIN_CHARS} chars)` : ' (round-robin meeting)'));
|
||||
return analyzeLong(raw, hint);
|
||||
}
|
||||
if (raw.length > SINGLE_MAX_CHARS) {
|
||||
@@ -557,7 +632,10 @@ async function analyzeLong(raw, hint = CORRECTION_HINT) {
|
||||
// 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));
|
||||
// The verbatim tail stays on every try — it is grounding, not context
|
||||
// the model can get chatty about.
|
||||
const tail = i > 0 ? chunks[i - 1].slice(-TAIL_CHARS) : '';
|
||||
rawOut = await ask(PROMPT_CHUNK_ANALYZE(chunks[i], ctx, tries === 2 ? '' : hint, tail));
|
||||
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; }
|
||||
@@ -580,6 +658,7 @@ async function analyzeLong(raw, hint = CORRECTION_HINT) {
|
||||
&& !/\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);
|
||||
const thema = String(j.thema || '').trim().replace(/[.:]+\s*$/, '').slice(0, 60);
|
||||
if (sum && isAbsoluteClaim(sum)) { console.log(`llm: Abschnitt ${i + 1} enthält Grundsatz-Aussage → Prüf-Aufruf`); sum = await verifySection(chunks[i], sum); }
|
||||
if (sum) sections.push({ sum, names, thema });
|
||||
} else if (rawOut) {
|
||||
// Last resort: keep the prose so the section isn't lost (strip fences,
|
||||
@@ -597,6 +676,24 @@ async function analyzeLong(raw, hint = CORRECTION_HINT) {
|
||||
// "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');
|
||||
// The last chunk of a call is usually nothing but goodbyes, and the model
|
||||
// dutifully writes a whole "Gesprächsabschluss" block quoting "Danke euch" /
|
||||
// "Ciao" (#79). Drop a TRAILING section that is mostly farewell phrases — never
|
||||
// an inner one, and never the only one.
|
||||
// Two detectors, because the model sometimes QUOTES the goodbyes ("Danke
|
||||
// euch", "Ciao") and sometimes only PARAPHRASES them ("bedankt sich bei den
|
||||
// Teilnehmern") — the latter matches no phrase at all, but it always labels
|
||||
// such a block "Gesprächsabschluss"/"Abschluss des Gesprächs".
|
||||
const FAREWELL = /\b(tsch(ü|ue)ss|ciao|dann macht'?s gut|macht'?s gut|danke euch|dankt|bedank|verabschied|sch(ö|oe)nen (tag|abend)|bis (dann|morgen|n(ä|ae)chste)|gute[nrs]? (zeit|tag|woche)|w(ü|ue)nscht)\b/gi;
|
||||
const FAREWELL_THEMA = /(abschluss|abschied|verabschied|gespr(ä|ae)chsende|schlussworte|ende des gespr(ä|ae)chs)/i;
|
||||
while (sections.length > 1) {
|
||||
const last = sections[sections.length - 1];
|
||||
const hits = (last.sum.match(FAREWELL) || []).length;
|
||||
if ((hits >= 2 || FAREWELL_THEMA.test(last.thema || '')) && last.sum.length < 800) {
|
||||
console.log(`llm: Schluss-Abschnitt "${last.thema || '?'}" verworfen (${hits} Verabschiedungsfloskeln)`);
|
||||
sections.pop();
|
||||
} else break;
|
||||
}
|
||||
participants = uniqStr(cleanList(participants));
|
||||
todos = uniqStr(cleanList(todos));
|
||||
|
||||
|
||||
Reference in New Issue
Block a user