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:
2026-07-29 16:09:30 +02:00
parent c76f26abaf
commit 810cee1dba
9 changed files with 438 additions and 38 deletions
+96 -14
View File
@@ -132,7 +132,7 @@ export async function diarize(wavPath, numSpeakers, opts = {}) {
if (MODE === 'v2') {
const turns = await sherpaTurns(wavPath, n, true);
if (turns.length) {
try { return await reclusterTurns(wavPath, turns, n, opts.markers || []); }
try { return await reclusterTurns(wavPath, turns, n, opts.markers || [], opts.voiceprints || []); }
catch (e) { console.log('diar: v2 re-clustering failed, falling back to legacy:', e.message); }
}
}
@@ -297,9 +297,9 @@ function ahcAverage(X, threshold, kStop = 0, initGroups = null, names = null) {
}
// v2 entry: embed the windows, then cluster them.
async function reclusterTurns(wavPath, turns, numSpeakers = 0, markers = []) {
async function reclusterTurns(wavPath, turns, numSpeakers = 0, markers = [], voiceprints = []) {
const ok = await embedWindows(wavPath, turns);
return clusterWindows(turns, ok, numSpeakers, markers);
return clusterWindows(turns, ok, numSpeakers, markers, voiceprints);
}
// Map each live marker to the turn the named person was speaking in: the turn
@@ -309,6 +309,11 @@ async function reclusterTurns(wavPath, turns, numSpeakers = 0, markers = []) {
const ANCHOR_BACK_SEC = +(process.env.MARK_PRE_MS || 25000) / 1000;
const ANCHOR_FWD_SEC = +(process.env.MARK_POST_MS || 8000) / 1000;
const ANCHOR_MIN_SEC = 1.0; // too short to identify a voice from
// Each anchor carries how it was found: `d` = 0 when the marker fell INSIDE the
// turn (certain), else the seconds of typing lag (the further back, the more
// likely the name belongs to somebody else who spoke in between). The vote
// weight below is derived from it — a covering anchor must outweigh a 20-second
// guess, otherwise one mistyped marker decides a whole cluster (#79).
function markerAnchors(turns, markers) {
const byName = new Map();
const usable = turns.map((t, i) => ({ i, ...t })).filter(t => t.end - t.start >= ANCHOR_MIN_SEC);
@@ -325,11 +330,35 @@ function markerAnchors(turns, markers) {
if (d < bd) { bd = d; pick = c.i; }
}
if (pick < 0) continue;
const key = String(mk.name).trim().toLowerCase();
(byName.get(key) || byName.set(key, []).get(key)).push(pick);
const key = normName(mk.name);
(byName.get(key) || byName.set(key, []).get(key)).push({ ti: pick, d: bd });
}
return byName;
}
// Constraint key for a person. First name only, lowercased: the same human is
// enrolled/typed as both "Sascha" and "Sascha Wolf" (two voiceprint rows exist),
// and treating those as two people would forbid merging their clusters.
const normName = (s) => String(s || '').trim().toLowerCase().split(/\s+/)[0];
// Anchor vote weight: 1.0 for a marker inside the turn, decaying to 0.2 at the
// far end of the look-back window.
const anchorWeight = (d) => d <= 0 ? 1 : Math.max(0.2, 1 - 0.8 * (d / ANCHOR_BACK_SEC));
// A name typed twice may legitimately point at two stage-A clusters of the SAME
// voice (that is the must-link the markers exist for) — but only if the two
// clusters actually sound alike. Below this fraction of the per-recording merge
// threshold the second anchor is treated as mistyped/mis-anchored instead of
// gluing two different people together (#79: "Merle" typed once while Sascha
// was talking merged Sascha into Merle → 3 speakers instead of 4).
const MARK_LINK_REL = +(process.env.DIAR_MARK_LINK_REL || 0.6);
// Enrolled voiceprints as a SECOND cannot-link source. Voice identification used
// to run only AFTER the clustering (identifySpeakers), so it could name what came
// out but never prevent two known people from being merged into one cluster —
// and the box owner, who is enrolled and speaks in every recording, silently
// disappeared into somebody else's cluster (#79: Sascha inside "Merle"). Matching
// happens in RAW embedding space (voiceprints are stored un-centred), so it needs
// a strict score AND a margin over the runner-up before it is allowed to
// constrain anything.
const VP_MIN = +(process.env.DIAR_VP_MIN || 0.55);
const VP_MARGIN = +(process.env.DIAR_VP_MARGIN || 0.05);
// Slice + embed every window of every turn (the only part that touches audio).
export async function embedWindows(wavPath, turns) {
@@ -368,7 +397,7 @@ export async function embedWindows(wavPath, turns) {
// The heart of v2: window embeddings → clusters → per-turn speaker labels.
// Split from the audio/embedding part so the clustering can be replayed offline
// on cached embeddings (lab/lab-eval.mjs) — the tuning ran against THIS code.
export function clusterWindows(turns, ok, numSpeakers = 0, markers = []) {
export function clusterWindows(turns, ok, numSpeakers = 0, markers = [], voiceprints = []) {
// Channel compensation: L2 → subtract the recording mean → L2 again.
let X = ok.map(w => l2(w.vec));
const mu = meanVec(X);
@@ -432,34 +461,87 @@ export function clusterWindows(turns, ok, numSpeakers = 0, markers = []) {
let tags = null;
if (anchors.size) {
const { lab } = labelTurns(groups);
const nameOfGroup = new Map(); // groupIdx -> name (first/most-voted wins)
const nameOfGroup = new Map(); // groupIdx -> name (strongest vote wins)
const votes = new Map();
for (const [name, tis] of anchors) for (const ti of tis) {
for (const [name, hits] of anchors) for (const { ti, d } of hits) {
const g = lab[ti];
if (g < 0) continue;
const key = g + '\0' + name;
votes.set(key, (votes.get(key) || 0) + 1);
votes.set(key, (votes.get(key) || 0) + anchorWeight(d));
}
const bestPerGroup = new Map();
for (const [key, v] of votes) {
const [g, name] = key.split('\0');
const cur = bestPerGroup.get(g);
if (!cur || v > cur.v) bestPerGroup.set(g, { name, v });
else cur.rival = true;
}
for (const [g, { name }] of bestPerGroup) nameOfGroup.set(+g, name);
// must-link: all groups tagged with the same name become ONE group
for (const [g, { name, rival }] of bestPerGroup) {
nameOfGroup.set(+g, name);
// Two different names anchored in ONE cluster means stage A already glued
// two people together — the cannot-link can no longer separate them, so at
// least say so instead of silently keeping the louder vote.
if (rival) console.log(`diar v2: WARNUNG Cluster ${g} trägt Marker mehrerer Namen → "${name}" gewinnt (Sprecher evtl. verschmolzen)`);
}
// must-link: groups carrying the SAME name become one — but only as far as
// they sound alike (see MARK_LINK_REL). The strongest-vote group is the seed;
// an acoustically distant namesake stays separate and UNTAGGED, so stage B
// may still merge it on its own evidence.
const linkMin = cosFinal * MARK_LINK_REL;
const byName = new Map();
for (const [g, name] of nameOfGroup) (byName.get(name) || byName.set(name, []).get(name)).push(g);
const used = new Set();
const merged = [], mtags = [];
let anchored = 0, rejected = 0;
for (const [name, gs] of byName) {
merged.push(gs.flatMap(g => groups[g]));
const ranked = [...gs].sort((a, b) => (bestPerGroup.get(String(b))?.v || 0) - (bestPerGroup.get(String(a))?.v || 0));
const seed = ranked[0];
const seedCent = centroidOf(groups[seed]);
const take = [seed];
for (const g of ranked.slice(1)) {
const c = dot(seedCent, centroidOf(groups[g]));
if (c >= linkMin) { take.push(g); continue; }
rejected++;
console.log(`diar v2: Marker "${name}" auf Cluster ${g} verworfen (cos ${c.toFixed(3)} < ${linkMin.toFixed(3)}) — vermutlich falsch getippter Zeitpunkt`);
}
merged.push(take.flatMap(g => groups[g]));
mtags.push(name);
gs.forEach(g => used.add(g));
take.forEach(g => used.add(g));
anchored += take.length;
}
groups.forEach((g, i) => { if (!used.has(i)) { merged.push(g); mtags.push(null); } });
groups = merged; tags = mtags;
console.log(`diar v2: ${anchors.size} Marker-Name(n) als Zwangsbedingung (${[...byName.values()].reduce((a, g) => a + g.length, 0)} Cluster verankert)`);
console.log(`diar v2: ${anchors.size} Marker-Name(n) als Zwangsbedingung (${anchored} Cluster verankert, ${rejected} verworfen)`);
}
// Known voices → cannot-link for every cluster the markers did NOT already
// claim. Typed markers stay authoritative: a name that is already a marker tag
// is skipped here, and one enrolled person can claim at most one cluster (best
// score wins), so a shaky second match cannot split a speaker in two.
if ((voiceprints || []).length) {
if (!tags) tags = groups.map(() => null);
const rawCent = g => l2(meanVec(g.map(i => ok[i].vec)));
const taken = new Set(tags.filter(Boolean));
const cand = [];
groups.forEach((g, gi) => {
if (tags[gi]) return;
const c = rawCent(g);
let best = null, s1 = -Infinity, s2 = -Infinity;
for (const vp of voiceprints) {
const s = spk.cosine(c, vp.vec);
if (s > s1) { s2 = s1; s1 = s; best = normName(vp.name); }
else if (s > s2) s2 = s;
}
if (best && s1 >= VP_MIN && (s1 - s2) >= VP_MARGIN && !taken.has(best)) cand.push({ gi, name: best, s: s1 });
});
cand.sort((a, b) => b.s - a.s);
let vpTags = 0;
for (const c of cand) {
if (taken.has(c.name) || tags[c.gi]) continue;
tags[c.gi] = c.name; taken.add(c.name); vpTags++;
console.log(`diar v2: Cluster ${c.gi} = bekannte Stimme "${c.name}" (cos ${c.s.toFixed(3)}) → Trennungs-Zwangsbedingung`);
}
if (!vpTags) console.log(`diar v2: keine Stimme sicher genug erkannt (${voiceprints.length} Abdrücke, Schwelle ${VP_MIN})`);
}
// Stage B: merge the remaining strong clusters up to the final threshold — or
+19
View File
@@ -0,0 +1,19 @@
// Like lab-eval.mjs, but also feeds enrolled voiceprints into clusterWindows.
// Usage: node lab-eval2.mjs <emb.json> [expect] [numSpeakers] [markers.json] [vp.json]
import fs from 'node:fs';
import { clusterWindows } from './diar-v2.js';
const [file, expect, numSp, markerFile, vpFile] = process.argv.slice(2);
const d = JSON.parse(fs.readFileSync(file, 'utf8'));
const ok = d.wins.filter(w => w.v).map(w => ({ ti: w.ti, start: w.s, end: w.e, vec: w.v }));
const markers = markerFile && markerFile !== '-' ? JSON.parse(fs.readFileSync(markerFile, 'utf8')) : [];
const vps = vpFile ? JSON.parse(fs.readFileSync(vpFile, 'utf8')) : [];
const segs = clusterWindows(d.turns, ok, +(numSp || 0), markers, vps);
const talk = new Map(), first = new Map(), last = new Map();
for (const s of segs) {
talk.set(s.speaker, (talk.get(s.speaker) || 0) + (s.end - s.start));
if (!first.has(s.speaker)) first.set(s.speaker, s.start);
last.set(s.speaker, s.end);
}
const order = [...talk.entries()].sort((a, b) => b[1] - a[1]);
console.log(`RESULT speakers=${order.length}${expect ? ` (expect ${expect})` : ''} segs=${segs.length}`);
console.log(' ' + order.map(([sp, t]) => `${sp.replace('speaker_','')}:${t.toFixed(0)}s[${(first.get(sp)/60).toFixed(1)}-${(last.get(sp)/60).toFixed(1)}]`).join(' '));
+7
View File
@@ -0,0 +1,7 @@
import * as db from '/opt/callrec/db.js';
import * as spk from '/opt/callrec/spk.js';
import fs from 'node:fs';
const vps = await db.listVoiceprints(spk.modelId());
fs.writeFileSync('vp.json', JSON.stringify(vps.map(v => ({ name: v.name, vec: v.vec }))));
console.log('voiceprints:', vps.length, vps.map(v => v.name).join(', '));
process.exit(0);
+105 -8
View File
@@ -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));
+1 -1
View File
@@ -413,7 +413,7 @@ export async function transcribeWav(wavFile, numSpeakers, opts = {}) {
// Acoustic diarization under the A76 clock cap (both paths). Non-fatal.
await setA76Max(A76_CAP);
try {
const segs = await diar.diarize(wavPath, numSpeakers, { markers: opts.markers || [] });
const segs = await diar.diarize(wavPath, numSpeakers, { markers: opts.markers || [], voiceprints: opts.voiceprints || [] });
diar.assignSpeakers(words, segs);
} catch (e) { console.error('diarize failed:', e.message); }
try { fs.unlinkSync(`${outBase}.json`); } catch {}
+58 -5
View File
@@ -44,7 +44,16 @@ function buildBasis(rec, nameMap = {}) {
if (!hasSpk) return { text: rec.transcript || '', labeled: false, labels: {} };
const turns = diar.toTurns(words);
const labels = {}; let n = 0;
const label = sp => labels[sp] || (labels[sp] = nameMap[sp] || `Sprecher ${++n}`);
// Labels MUST be unique. On #79 a mis-anchored marker named Sascha's cluster
// "Markus" while the voiceprint named Markus' own cluster "Markus" too — the
// LLM then read two different people under one name and blended them into one
// person, and relabelAnalysis refuses to patch an ambiguous label afterwards.
const used = new Set();
const uniq = (name) => {
if (!used.has(name)) { used.add(name); return name; }
for (let k = 2; ; k++) if (!used.has(`${name} ${k}`)) { used.add(`${name} ${k}`); return `${name} ${k}`; }
};
const label = sp => labels[sp] || (labels[sp] = uniq(nameMap[sp] || `Sprecher ${++n}`));
const text = turns.map(t => `${label(t.speaker)}: ${t.text}`).join('\n');
return { text, labeled: true, labels };
}
@@ -182,12 +191,21 @@ async function applyMarkers(id) {
const resolved = diar.resolveMarkers(rec.words, rec.markers);
for (const m of resolved) await db.setMarkerSpeaker(m.id, m.speaker, m.conf).catch(() => {});
// name -> clusters (only confident hits drive the merge)
// name -> clusters. NAMING may follow a 'mid' hit, but the cluster MERGE below
// is destructive and unguarded (no acoustic check at this level), so only a
// 'high' hit — marker inside the turn, that turn clearly dominant — is allowed
// to fold two clusters together. #79 lost a speaker exactly this way one layer
// deeper, in the diarizer's must-link.
const byName = {};
for (const m of resolved) {
if (!m.speaker || m.conf === 'low' || m.conf === null) continue;
if (!m.speaker || m.conf !== 'high') continue;
(byName[m.name] = byName[m.name] || new Set()).add(m.speaker);
}
const nameOnly = {};
for (const m of resolved) {
if (!m.speaker || m.conf === 'low' || m.conf === null) continue;
(nameOnly[m.name] = nameOnly[m.name] || new Set()).add(m.speaker);
}
const talk = {};
for (const w of rec.words) if (w.speaker) talk[w.speaker] = (talk[w.speaker] || 0) + (w.t_end_ms - w.t_start_ms);
@@ -199,6 +217,13 @@ async function applyMarkers(id) {
nameOf[primary] = name;
for (const c of cls.slice(1)) fold[c] = primary;
}
// Naming without merging: a 'mid' hit labels the cluster it landed on, as long
// as that cluster is not already claimed by somebody else.
for (const [name, set] of Object.entries(nameOnly)) {
if (byName[name]) continue;
const cls = [...set].sort((a, b) => (talk[b] || 0) - (talk[a] || 0));
if (cls[0] && !nameOf[cls[0]]) nameOf[cls[0]] = name;
}
let merged = 0;
if (Object.keys(fold).length) {
for (const w of rec.words) if (fold[w.speaker]) w.speaker = fold[w.speaker];
@@ -236,7 +261,23 @@ async function analyzeRecording(id) {
// Seed the per-recording speaker names from acoustic identification (fills
// blanks only; manual edits stay). Manual names win for the LLM labelling.
await db.mergeSpeakerNames(id, nameMap).catch(() => {});
// Stored names normally win over acoustic identification (they are typed by
// hand). Exception: a name that came from a NON-'high' marker is only a
// guess about WHICH cluster was speaking — resolveMarkers looks back up to
// 25 s, so in a fast four-way discussion it lands on the wrong voice (#79
// put "Markus" on Sascha's cluster). If an enrolled voiceprint disagrees
// with such a weak marker name, the voiceprint wins.
const markers = await db.listMarkers(id).catch(() => []);
const weakName = new Map();
for (const m of markers) if (m.speaker && m.conf !== 'high') weakName.set(m.speaker, m.name);
const names = { ...nameMap, ...(rec.speaker_names || {}) };
for (const [sp, vname] of Object.entries(nameMap)) {
if (names[sp] !== vname && weakName.get(sp) === names[sp]) {
console.log(`identify #${id}: Stimmabdruck "${vname}" schlägt unsicheren Marker "${names[sp]}" auf ${sp}`);
names[sp] = vname;
await db.setSpeakerName(id, sp, vname).catch(() => {});
}
}
const glossary = (await db.listGlossary().catch(() => [])).filter(g => g.enabled);
// Deterministic soundalike replacement: the 3B model is unreliable at obeying
// a "map Amy->Aime" instruction (it treats "Amy" as a valid name), so entries
@@ -269,6 +310,11 @@ async function analyzeRecording(id) {
a.participants = (a.participants || []).map(p => applyGlossaryText(p, gmap));
a.todos = (a.todos || []).map(t => applyGlossaryText(t, gmap));
await db.updateAnalysis(id, { ...a, status: 'done' });
// A speaker can be named WHILE the analysis runs (it takes ~15 min, and the
// marker/voiceprint passes themselves rename clusters). The finished text
// still carries the label from analysis start ("Sprecher 1"), so patch it
// once at the end — same mechanism as renaming a speaker by hand.
await relabelAnalysis(id).catch(() => {});
console.log(`analysis done for #${id}: ${a.title}`);
} catch (e) {
console.error(`analysis failed for #${id}:`, e.message);
@@ -344,7 +390,10 @@ async function transcribeAndAnalyze(id, wavFile, numSpeakers) {
// Live markers constrain the diarization itself (same name = one speaker,
// different names = never merged), so they are loaded before transcribing.
const markers = await db.listMarkers(id).catch(() => []);
const { transcript, words } = await rec.transcribeWav(wavFile, numSpeakers, { markers });
// Enrolled voices do the same for people who were never typed — the box
// owner above all (see diar.clusterWindows).
const voiceprints = await db.listVoiceprints(spk.modelId()).catch(() => []);
const { transcript, words } = await rec.transcribeWav(wavFile, numSpeakers, { markers, voiceprints });
await db.setTranscript(id, transcript, words);
console.log(`transcription done for #${id}: ${words.length} segments`);
// Names typed live → cluster names (+ user-supervised cluster merge) BEFORE
@@ -439,7 +488,11 @@ async function rediarize(id, numSpeakers) {
// is the other entry point and must do it too, or a long re-diar overheats).
await pexec('sudo', ['-n', '/opt/callrec/aime-cpucap.sh', '1608000']).catch(() => {});
try {
const segs = await diar.diarize(wav, numSpeakers, { markers: r.markers || [] });
// Enrolled voices go IN, not just out: they keep two known people (above all
// the box owner, who is enrolled and talks in every recording) from ending up
// in one cluster. See the cannot-link block in diar.clusterWindows.
const voiceprints = await db.listVoiceprints(spk.modelId()).catch(() => []);
const segs = await diar.diarize(wav, numSpeakers, { markers: r.markers || [], voiceprints });
diar.assignSpeakers(words, segs);
await db.replaceWords(id, words);
// Re-diarization RENUMBERS the clusters, so every previously stored name