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
+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