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:
+96
-14
@@ -132,7 +132,7 @@ export async function diarize(wavPath, numSpeakers, opts = {}) {
|
|||||||
if (MODE === 'v2') {
|
if (MODE === 'v2') {
|
||||||
const turns = await sherpaTurns(wavPath, n, true);
|
const turns = await sherpaTurns(wavPath, n, true);
|
||||||
if (turns.length) {
|
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); }
|
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.
|
// 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);
|
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
|
// 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_BACK_SEC = +(process.env.MARK_PRE_MS || 25000) / 1000;
|
||||||
const ANCHOR_FWD_SEC = +(process.env.MARK_POST_MS || 8000) / 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
|
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) {
|
function markerAnchors(turns, markers) {
|
||||||
const byName = new Map();
|
const byName = new Map();
|
||||||
const usable = turns.map((t, i) => ({ i, ...t })).filter(t => t.end - t.start >= ANCHOR_MIN_SEC);
|
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 (d < bd) { bd = d; pick = c.i; }
|
||||||
}
|
}
|
||||||
if (pick < 0) continue;
|
if (pick < 0) continue;
|
||||||
const key = String(mk.name).trim().toLowerCase();
|
const key = normName(mk.name);
|
||||||
(byName.get(key) || byName.set(key, []).get(key)).push(pick);
|
(byName.get(key) || byName.set(key, []).get(key)).push({ ti: pick, d: bd });
|
||||||
}
|
}
|
||||||
return byName;
|
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).
|
// Slice + embed every window of every turn (the only part that touches audio).
|
||||||
export async function embedWindows(wavPath, turns) {
|
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.
|
// The heart of v2: window embeddings → clusters → per-turn speaker labels.
|
||||||
// Split from the audio/embedding part so the clustering can be replayed offline
|
// 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.
|
// 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.
|
// Channel compensation: L2 → subtract the recording mean → L2 again.
|
||||||
let X = ok.map(w => l2(w.vec));
|
let X = ok.map(w => l2(w.vec));
|
||||||
const mu = meanVec(X);
|
const mu = meanVec(X);
|
||||||
@@ -432,34 +461,87 @@ export function clusterWindows(turns, ok, numSpeakers = 0, markers = []) {
|
|||||||
let tags = null;
|
let tags = null;
|
||||||
if (anchors.size) {
|
if (anchors.size) {
|
||||||
const { lab } = labelTurns(groups);
|
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();
|
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];
|
const g = lab[ti];
|
||||||
if (g < 0) continue;
|
if (g < 0) continue;
|
||||||
const key = g + '\0' + name;
|
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();
|
const bestPerGroup = new Map();
|
||||||
for (const [key, v] of votes) {
|
for (const [key, v] of votes) {
|
||||||
const [g, name] = key.split('\0');
|
const [g, name] = key.split('\0');
|
||||||
const cur = bestPerGroup.get(g);
|
const cur = bestPerGroup.get(g);
|
||||||
if (!cur || v > cur.v) bestPerGroup.set(g, { name, v });
|
if (!cur || v > cur.v) bestPerGroup.set(g, { name, v });
|
||||||
|
else cur.rival = true;
|
||||||
}
|
}
|
||||||
for (const [g, { name }] of bestPerGroup) nameOfGroup.set(+g, name);
|
for (const [g, { name, rival }] of bestPerGroup) {
|
||||||
// must-link: all groups tagged with the same name become ONE group
|
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();
|
const byName = new Map();
|
||||||
for (const [g, name] of nameOfGroup) (byName.get(name) || byName.set(name, []).get(name)).push(g);
|
for (const [g, name] of nameOfGroup) (byName.get(name) || byName.set(name, []).get(name)).push(g);
|
||||||
const used = new Set();
|
const used = new Set();
|
||||||
const merged = [], mtags = [];
|
const merged = [], mtags = [];
|
||||||
|
let anchored = 0, rejected = 0;
|
||||||
for (const [name, gs] of byName) {
|
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);
|
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.forEach((g, i) => { if (!used.has(i)) { merged.push(g); mtags.push(null); } });
|
||||||
groups = merged; tags = mtags;
|
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
|
// Stage B: merge the remaining strong clusters up to the final threshold — or
|
||||||
|
|||||||
@@ -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(' '));
|
||||||
@@ -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
@@ -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
|
// 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
|
// 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).
|
// 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)
|
const SINGLE_MAX_CHARS = 4800; // below this: single combined call (full cleaning)
|
||||||
// Whole-pass cutoff. Model-dependent, NOT just a context question:
|
// Whole-pass cutoff. Model-dependent, NOT just a context question:
|
||||||
// - Gemma-3-4B fell apart above ~6k input tokens (English chat, recency bias,
|
// - 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.
|
// topics — a 4B cannot weight 15+ equal topics from a 10k-token prompt.
|
||||||
// Labeled transcripts with >= this many distinct speakers go through the
|
// Labeled transcripts with >= this many distinct speakers go through the
|
||||||
// section-conglomerate path instead, which covers every part by construction.
|
// 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.
|
// Count distinct "Name:"-style turn labels in a labeled dialogue.
|
||||||
function countSpeakers(text) {
|
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
|
// awareness a 4B can handle. eigennamen = pure extraction list (names survive
|
||||||
// even when the summary generalizes); its array sometimes degenerates, which
|
// even when the summary generalizes); its array sometimes degenerates, which
|
||||||
// salvageChunkFields() recovers.
|
// 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:
|
`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:` : ''}
|
Aktueller Abschnitt:` : ''}
|
||||||
"""
|
"""
|
||||||
${transcript}
|
${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 }.
|
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"]}`;
|
{"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 =
|
// Title micro-call over 2-sentence section Kurzfassungen (small input/output =
|
||||||
// reliable regime). The LLM-reduce that used to build the whole summary is
|
// reliable regime). The LLM-reduce that used to build the whole summary is
|
||||||
// gone: cross-section synthesis is beyond the 4B (see analyzeLong header).
|
// 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: '' };
|
if (!raw) return { title: 'Leere Aufnahme', summary: '', participants: [], todos: [], transcriptClean: '' };
|
||||||
const hint = correctionHint(glossary);
|
const hint = correctionHint(glossary);
|
||||||
const manySpeakers = labeled && countSpeakers(raw) >= MANY_SPEAKERS;
|
const manySpeakers = labeled && countSpeakers(raw) >= MANY_SPEAKERS;
|
||||||
if (raw.length > WHOLE_MAX_CHARS || (manySpeakers && raw.length > SINGLE_MAX_CHARS)) {
|
const longMeeting = raw.length > SECTION_MIN_CHARS;
|
||||||
if (manySpeakers) console.log(`llm: ${countSpeakers(raw)} speakers -> section path (round-robin meeting)`);
|
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);
|
return analyzeLong(raw, hint);
|
||||||
}
|
}
|
||||||
if (raw.length > SINGLE_MAX_CHARS) {
|
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
|
// 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.
|
// prompt on later tries: drop the rolling context first, then hint too.
|
||||||
const ctx = tries === 0 ? context : '';
|
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, '')}}`)
|
j = extractJson(rawOut) || extractJson(`{${rawOut.replace(/^[\s,]+|[\s,]+$/g, '')}}`)
|
||||||
|| salvageChunkFields(rawOut);
|
|| salvageChunkFields(rawOut);
|
||||||
if (j && echoedSum(j)) { console.log(`llm: chunk ${i + 1} echoed field description (try ${tries + 1})`); j = null; }
|
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)
|
&& !/\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);
|
&& (s.match(/\s/g) || []).length <= 2);
|
||||||
const thema = String(j.thema || '').trim().replace(/[.:]+\s*$/, '').slice(0, 60);
|
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 });
|
if (sum) sections.push({ sum, names, thema });
|
||||||
} else if (rawOut) {
|
} else if (rawOut) {
|
||||||
// Last resort: keep the prose so the section isn't lost (strip fences,
|
// 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
|
// "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).
|
// 'error' and the UI shows a retry (this was the silent-failure bug).
|
||||||
if (okChunks === 0) throw lastErr || new Error('LLM: alle Chunks fehlgeschlagen');
|
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));
|
participants = uniqStr(cleanList(participants));
|
||||||
todos = uniqStr(cleanList(todos));
|
todos = uniqStr(cleanList(todos));
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -413,7 +413,7 @@ export async function transcribeWav(wavFile, numSpeakers, opts = {}) {
|
|||||||
// Acoustic diarization under the A76 clock cap (both paths). Non-fatal.
|
// Acoustic diarization under the A76 clock cap (both paths). Non-fatal.
|
||||||
await setA76Max(A76_CAP);
|
await setA76Max(A76_CAP);
|
||||||
try {
|
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);
|
diar.assignSpeakers(words, segs);
|
||||||
} catch (e) { console.error('diarize failed:', e.message); }
|
} catch (e) { console.error('diarize failed:', e.message); }
|
||||||
try { fs.unlinkSync(`${outBase}.json`); } catch {}
|
try { fs.unlinkSync(`${outBase}.json`); } catch {}
|
||||||
|
|||||||
+58
-5
@@ -44,7 +44,16 @@ function buildBasis(rec, nameMap = {}) {
|
|||||||
if (!hasSpk) return { text: rec.transcript || '', labeled: false, labels: {} };
|
if (!hasSpk) return { text: rec.transcript || '', labeled: false, labels: {} };
|
||||||
const turns = diar.toTurns(words);
|
const turns = diar.toTurns(words);
|
||||||
const labels = {}; let n = 0;
|
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');
|
const text = turns.map(t => `${label(t.speaker)}: ${t.text}`).join('\n');
|
||||||
return { text, labeled: true, labels };
|
return { text, labeled: true, labels };
|
||||||
}
|
}
|
||||||
@@ -182,12 +191,21 @@ async function applyMarkers(id) {
|
|||||||
const resolved = diar.resolveMarkers(rec.words, rec.markers);
|
const resolved = diar.resolveMarkers(rec.words, rec.markers);
|
||||||
for (const m of resolved) await db.setMarkerSpeaker(m.id, m.speaker, m.conf).catch(() => {});
|
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 = {};
|
const byName = {};
|
||||||
for (const m of resolved) {
|
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);
|
(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 = {};
|
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);
|
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;
|
nameOf[primary] = name;
|
||||||
for (const c of cls.slice(1)) fold[c] = primary;
|
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;
|
let merged = 0;
|
||||||
if (Object.keys(fold).length) {
|
if (Object.keys(fold).length) {
|
||||||
for (const w of rec.words) if (fold[w.speaker]) w.speaker = fold[w.speaker];
|
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
|
// Seed the per-recording speaker names from acoustic identification (fills
|
||||||
// blanks only; manual edits stay). Manual names win for the LLM labelling.
|
// blanks only; manual edits stay). Manual names win for the LLM labelling.
|
||||||
await db.mergeSpeakerNames(id, nameMap).catch(() => {});
|
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 || {}) };
|
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);
|
const glossary = (await db.listGlossary().catch(() => [])).filter(g => g.enabled);
|
||||||
// Deterministic soundalike replacement: the 3B model is unreliable at obeying
|
// Deterministic soundalike replacement: the 3B model is unreliable at obeying
|
||||||
// a "map Amy->Aime" instruction (it treats "Amy" as a valid name), so entries
|
// 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.participants = (a.participants || []).map(p => applyGlossaryText(p, gmap));
|
||||||
a.todos = (a.todos || []).map(t => applyGlossaryText(t, gmap));
|
a.todos = (a.todos || []).map(t => applyGlossaryText(t, gmap));
|
||||||
await db.updateAnalysis(id, { ...a, status: 'done' });
|
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}`);
|
console.log(`analysis done for #${id}: ${a.title}`);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(`analysis failed for #${id}:`, e.message);
|
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,
|
// Live markers constrain the diarization itself (same name = one speaker,
|
||||||
// different names = never merged), so they are loaded before transcribing.
|
// different names = never merged), so they are loaded before transcribing.
|
||||||
const markers = await db.listMarkers(id).catch(() => []);
|
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);
|
await db.setTranscript(id, transcript, words);
|
||||||
console.log(`transcription done for #${id}: ${words.length} segments`);
|
console.log(`transcription done for #${id}: ${words.length} segments`);
|
||||||
// Names typed live → cluster names (+ user-supervised cluster merge) BEFORE
|
// 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).
|
// 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(() => {});
|
await pexec('sudo', ['-n', '/opt/callrec/aime-cpucap.sh', '1608000']).catch(() => {});
|
||||||
try {
|
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);
|
diar.assignSpeakers(words, segs);
|
||||||
await db.replaceWords(id, words);
|
await db.replaceWords(id, words);
|
||||||
// Re-diarization RENUMBERS the clusters, so every previously stored name
|
// Re-diarization RENUMBERS the clusters, so every previously stored name
|
||||||
|
|||||||
+50
-4
@@ -22,9 +22,12 @@ flowchart TB
|
|||||||
FOLD -- nein --> STRONG["starker Cluster"]
|
FOLD -- nein --> STRONG["starker Cluster"]
|
||||||
WEAK --> MK
|
WEAK --> MK
|
||||||
STRONG --> MK{"Live-Namensmarker vorhanden?"}
|
STRONG --> MK{"Live-Namensmarker vorhanden?"}
|
||||||
MK -- ja --> CON["must-link: gleicher Name = 1 Cluster<br/>cannot-link: verschiedene Namen<br/>dürfen NIE verschmelzen"]
|
MK -- ja --> CON["must-link: gleicher Name = 1 Cluster,<br/>ABER nur wenn die Cluster akustisch passen<br/>(cos ≥ 0,6 × gelernte Schwelle) —<br/>sonst Marker verwerfen (falsch getippter Zeitpunkt)<br/>cannot-link: verschiedene Namen verschmelzen NIE"]
|
||||||
MK -- nein --> B
|
MK -- nein --> VP
|
||||||
CON --> B["Stufe B: weiter mergen bis Schwelle<br/>(GMM-Wert) ODER bis gepinnte Anzahl"]
|
CON --> VP{"Bekannte Stimmabdrücke?"}
|
||||||
|
VP -- ja --> VPC["Cluster ohne Marker gegen Voiceprints prüfen<br/>(RAW-Kosinus ≥ 0,55 + Abstand 0,05 zum Zweitbesten,<br/>ein Name höchstens 1 Cluster)<br/>→ zusätzliches cannot-link<br/>sichert vor allem den Box-Besitzer"]
|
||||||
|
VP -- nein --> B
|
||||||
|
VPC --> B["Stufe B: weiter mergen bis Schwelle<br/>(GMM-Wert) ODER bis gepinnte Anzahl"]
|
||||||
GMM --> B
|
GMM --> B
|
||||||
B --> LAB["Turn-Label = dauergewichtete<br/>Mehrheit seiner Fenster"]
|
B --> LAB["Turn-Label = dauergewichtete<br/>Mehrheit seiner Fenster"]
|
||||||
LAB --> OUT["finale Sprecher<br/>(#74: 44 → 20)"]
|
LAB --> OUT["finale Sprecher<br/>(#74: 44 → 20)"]
|
||||||
@@ -93,7 +96,50 @@ sequenceDiagram
|
|||||||
`applyMarkers()` führt sie zusammen (nutzergestützter Fix für Rest-Übersegmentierung).
|
`applyMarkers()` führt sie zusammen (nutzergestützter Fix für Rest-Übersegmentierung).
|
||||||
- **Nachtragen:** im Detail einer Aufnahme kann an der aktuellen Abspielposition ein Marker gesetzt werden
|
- **Nachtragen:** im Detail einer Aufnahme kann an der aktuellen Abspielposition ein Marker gesetzt werden
|
||||||
(`POST /api/recordings/:id/markers`), „↻ Zuordnen" rechnet die Zuordnung neu (`apply-markers`, ohne sherpa).
|
(`POST /api/recordings/:id/markers`), „↻ Zuordnen" rechnet die Zuordnung neu (`apply-markers`, ohne sherpa).
|
||||||
- Marker-Namen sind manuell = autoritativ; die akustische Auto-Identifikation füllt nur Lücken.
|
- Marker-Namen sind manuell = autoritativ; die akustische Auto-Identifikation füllt nur Lücken —
|
||||||
|
**mit einer Ausnahme, siehe unten** (unsicher verankerter Marker verliert gegen einen Stimmabdruck).
|
||||||
|
|
||||||
|
## Ein falsch getippter Marker kostete einen Sprecher (2026-07-29)
|
||||||
|
|
||||||
|
**Symptom (Aufnahme #79, 23-min-Jour-fixe, 4 Personen):** nur 3 Sprecher, Sascha fehlte komplett.
|
||||||
|
|
||||||
|
**Ursache, gemessen im Labor:** Stufe A + Faltung lieferten korrekt **4 starke Cluster**. Es gab 4 Marker
|
||||||
|
mit 3 Namen — „Merle" war zweimal getippt worden, der zweite davon landete auf **Saschas** Cluster. Das
|
||||||
|
alte must-link verschmolz beide „Merle"-Cluster bedingungslos, also Sascha in Merle hinein. Ihre
|
||||||
|
Kosinus-Ähnlichkeit war **−0,727**, d. h. das Signal war eindeutig da — es wurde nur nicht ausgewertet.
|
||||||
|
|
||||||
|
**Drei Absicherungen:**
|
||||||
|
|
||||||
|
| Ebene | vorher | jetzt |
|
||||||
|
|---|---|---|
|
||||||
|
| Anker-Gewicht (`markerAnchors`) | jeder Marker zählt 1 | Marker IM Turn zählt 1,0, bei 25 s Tippverzögerung 0,2 |
|
||||||
|
| must-link (`clusterWindows`) | verschmilzt immer | nur bei `cos ≥ 0,6 × gelernter Schwelle`, sonst Marker verwerfen + loggen |
|
||||||
|
| Cluster-Fold (`applyMarkers`, server.js) | ab `conf ≠ low` | **nur `conf = high`**; `mid` darf noch benennen, aber nicht mehr verschmelzen |
|
||||||
|
|
||||||
|
Zusätzlich: trägt EIN Cluster Marker mehrerer Namen, war er schon vor Stufe B verschmolzen — das wird
|
||||||
|
jetzt als Warnung geloggt statt still den lauteren Vote zu nehmen.
|
||||||
|
|
||||||
|
## Stimmabdrücke als Trennkriterium — der Besitzer zählt immer (2026-07-29)
|
||||||
|
|
||||||
|
Die Stimm-Identifikation lief bisher **nach** dem Clustering (`identifySpeakers`): sie konnte benennen,
|
||||||
|
was herauskam, aber nie verhindern, dass zwei bekannte Personen in einem Cluster landen. Gerade der
|
||||||
|
Besitzer der Box ist enrolled und spricht in JEDER Aufnahme — genau er verschwand in #79.
|
||||||
|
|
||||||
|
Jetzt gehen die Voiceprints **in** die Diarisierung (`diarize(wav, n, {markers, voiceprints})`, gefüllt in
|
||||||
|
`rediarize()` und `transcribeAndAnalyze()`). Jedes noch namenlose Cluster wird gegen die Abdrücke geprüft;
|
||||||
|
sitzt einer sicher darauf, wirkt der Name wie ein Marker-Tag = cannot-link.
|
||||||
|
|
||||||
|
- Vergleich im **RAW**-Raum (Voiceprints sind unzentriert gespeichert), deshalb streng: `DIAR_VP_MIN` 0,55
|
||||||
|
**und** 0,05 Abstand zum Zweitbesten, ein Name beansprucht höchstens ein Cluster (bester Score gewinnt).
|
||||||
|
- Namensschlüssel = **Vorname, kleingeschrieben**: derselbe Mensch ist als „Sascha" und „Sascha Wolf"
|
||||||
|
enrolled (ebenso Kim/Kim Nebendahl, Fabienne/Fabienne Nachtsheim) — zwei Einträge dürfen nicht zu einem
|
||||||
|
cannot-link zwischen seinen eigenen Clustern führen.
|
||||||
|
- Gegenprobe ohne Regression: #79 → 4 (vorher 3), #70 → 4, #68 → 2, #74 → 20 (unverändert mit und ohne
|
||||||
|
Voiceprints). Auf #79 wird Sascha mit cos 0,861 erkannt, auf #74 fünfzehn Personen (Kai 0,656 … Niklas 0,966).
|
||||||
|
|
||||||
|
**Nachwirkung im server:** ein Name, der aus einem Marker mit `conf ≠ high` stammt, verliert gegen einen
|
||||||
|
widersprechenden Stimmabdruck (`identify #id: Stimmabdruck "X" schlägt unsicheren Marker "Y"`). In #79
|
||||||
|
hatte der Marker bei 6,6 s Saschas Cluster „Markus" getauft.
|
||||||
|
|
||||||
## Harte Grenzen / Sackgassen
|
## Harte Grenzen / Sackgassen
|
||||||
|
|
||||||
|
|||||||
+60
-6
@@ -19,7 +19,9 @@
|
|||||||
flowchart TB
|
flowchart TB
|
||||||
T["Transkript"] --> A{"≤ 4 800 Zeichen?"}
|
T["Transkript"] --> A{"≤ 4 800 Zeichen?"}
|
||||||
A -- ja --> SINGLE["analyzeSingle:<br/>EIN Call = bereinigter Dialog + JSON-Analyse<br/>(Fallback: separater JSON-Call bei Echo/Parse-Fehler)"]
|
A -- ja --> SINGLE["analyzeSingle:<br/>EIN Call = bereinigter Dialog + JSON-Analyse<br/>(Fallback: separater JSON-Call bei Echo/Parse-Fehler)"]
|
||||||
A -- nein --> SPK{"labeled + ≥ 5 Sprecher?<br/>(MANY_SPEAKERS, countSpeakers)"}
|
A -- nein --> LEN{"> 9 000 Zeichen?<br/>(SECTION_MIN_CHARS, seit 2026-07-29)"}
|
||||||
|
LEN -- "ja (Meeting-Länge)" --> LONG
|
||||||
|
LEN -- nein --> SPK{"labeled + ≥ 3 Sprecher?<br/>(MANY_SPEAKERS, countSpeakers)"}
|
||||||
SPK -- "ja (Round-Robin-Meeting)" --> LONG
|
SPK -- "ja (Round-Robin-Meeting)" --> LONG
|
||||||
SPK -- nein --> B{"≤ 32 000 Zeichen?"}
|
SPK -- nein --> B{"≤ 32 000 Zeichen?"}
|
||||||
B -- ja --> WHOLE["analyzeWhole:<br/>GANZES Gespräch in einem Analyse-Call<br/>(kohärente Summary, kleine JSON-Ausgabe)<br/>Instruktion VOR und NACH dem Transkript"]
|
B -- ja --> WHOLE["analyzeWhole:<br/>GANZES Gespräch in einem Analyse-Call<br/>(kohärente Summary, kleine JSON-Ausgabe)<br/>Instruktion VOR und NACH dem Transkript"]
|
||||||
@@ -28,19 +30,71 @@ flowchart TB
|
|||||||
REPAIR --> WCHECK
|
REPAIR --> WCHECK
|
||||||
WCHECK -- "weiterhin leer/Junk" --> LONG
|
WCHECK -- "weiterhin leer/Junk" --> LONG
|
||||||
WCHECK -- ja --> OUT
|
WCHECK -- ja --> OUT
|
||||||
B -- nein --> LONG["analyzeLong (Sektions-Konglomerat):<br/>Chunks à 4 200 Zeichen<br/>(bei 8k wird der Inhalt generisch/namenlos,<br/>bei 4k bleibt das Modell geerdet);<br/>chunkText splittet an Zeilen = Turn-Grenzen"]
|
B -- nein --> LONG["analyzeLong (Sektions-Konglomerat):<br/>Chunks à 3 200 Zeichen (seit 2026-07-29;<br/>bei 8k wird der Inhalt generisch/namenlos,<br/>bei 4,2k konkurrieren zwei Themen pro Chunk);<br/>chunkText splittet an Zeilen = Turn-Grenzen"]
|
||||||
LONG --> MAP["MAP pro Chunk, sequenziell mit ROLLING CONTEXT<br/>(1-Satz-Kurzfassungen der vorherigen Abschnitte →<br/>Änderungen erkennbar: 'jetzt X, vorher Y'):<br/>Mini-JSON thema (Überschrift ≤ 6 Wörter) +<br/>zusammenfassung (JEDE berichtende Person, beruflich;<br/>Smalltalk max. Halbsatz) + eigennamen[] (EXTRAKTION)<br/>+ teilnehmer + todos (nur beruflich, Verantwortlicher<br/>in Klammern) — bewusst minimaler Prompt"]
|
LONG --> MAP["MAP pro Chunk, sequenziell mit ROLLING CONTEXT<br/>(1-Satz-Kurzfassungen der vorherigen Abschnitte →<br/>Änderungen erkennbar: 'jetzt X, vorher Y')<br/>+ TAIL: die letzten 700 Zeichen des VORIGEN Chunks<br/>WÖRTLICH, ausdrücklich 'nicht zusammenfassen'<br/>(Bezugswörter für Sätze am Chunk-Anfang):<br/>Mini-JSON thema (Überschrift ≤ 6 Wörter) +<br/>zusammenfassung (JEDE berichtende Person, beruflich;<br/>JEDES Thema mind. 1 Satz; nicht verallgemeinern;<br/>Smalltalk max. Halbsatz) + eigennamen[] (EXTRAKTION)<br/>+ teilnehmer + todos (nur beruflich, Verantwortlicher<br/>in Klammern)"]
|
||||||
MAP --> SALV{"JSON parsebar +<br/>kein Feldbeschreibungs-Echo?"}
|
MAP --> SALV{"JSON parsebar +<br/>kein Feldbeschreibungs-Echo?"}
|
||||||
SALV -- nein --> RETRY["bis 3 Versuche, Prompt VARIIERT<br/>(2.: ohne Kontext, 3.: ohne Hint —<br/>manche Inhalte echoen sonst JEDES Mal)<br/>dann Feld-Salvage per Regex<br/>(zusammenfassung/thema-Strings überleben fast immer),<br/>zuletzt Prosa-Rettung (≥ 60 Zeichen, sonst verwerfen)"]
|
SALV -- nein --> RETRY["bis 3 Versuche, Prompt VARIIERT<br/>(2.: ohne Kontext, 3.: ohne Hint —<br/>manche Inhalte echoen sonst JEDES Mal)<br/>dann Feld-Salvage per Regex<br/>(zusammenfassung/thema-Strings überleben fast immer),<br/>zuletzt Prosa-Rettung (≥ 60 Zeichen, sonst verwerfen)"]
|
||||||
RETRY --> COLLECT
|
RETRY --> ABS
|
||||||
SALV -- ja --> COLLECT["Sektionen einsammeln"]
|
SALV -- ja --> ABS{"Entwurf behauptet etwas Grundsätzliches?<br/>Skopus-Wort (nur/ausschließlich/statt/…)<br/>UND kollektives Subjekt (das Team/wir/künftig/…)"}
|
||||||
COLLECT --> TITLE["TITEL-Mikro-Call: Thema + 2-Satz-Kurzfassungen<br/>OHNE letzten Abschnitt (Schluss-Smalltalk-Filter)<br/>→ nur {titel} — kleines Input/Output = zuverlässig"]
|
ABS -- ja --> VERIFY["PRÜF-Aufruf NUR für diesen Abschnitt:<br/>Abschnitt wörtlich + Entwurf →<br/>'gilt das allgemein oder nur für EINEN Beitrag?'<br/>Antwort < 60 % der Entwurfslänge wird verworfen"]
|
||||||
|
VERIFY --> COLLECT
|
||||||
|
ABS -- nein --> COLLECT["Sektionen einsammeln"]
|
||||||
|
COLLECT --> DROP{"letzter Abschnitt =<br/>nur Verabschiedung?<br/>(2 Floskeln ODER Überschrift 'Abschluss…')"}
|
||||||
|
DROP -- ja --> DROPPED["Abschnitt verwerfen<br/>(nie ein innerer, nie der einzige)"]
|
||||||
|
DROPPED --> TITLE
|
||||||
|
DROP -- nein --> TITLE["TITEL-Mikro-Call: Thema + 2-Satz-Kurzfassungen<br/>OHNE letzten Abschnitt (Schluss-Smalltalk-Filter)<br/>→ nur {titel} — kleines Input/Output = zuverlässig"]
|
||||||
TITLE --> OVERVIEW["ÜBERBLICK-Mikro-Call über dieselben Kurzfassungen<br/>→ {ueberblick} 2-3 Sätze, alle Abschnitte gleich gewichtet<br/>(Key-tolerant parsen: auch 'überblick' oder<br/>irgendein langer String-Value; leer = weglassen)"]
|
TITLE --> OVERVIEW["ÜBERBLICK-Mikro-Call über dieselben Kurzfassungen<br/>→ {ueberblick} 2-3 Sätze, alle Abschnitte gleich gewichtet<br/>(Key-tolerant parsen: auch 'überblick' oder<br/>irgendein langer String-Value; leer = weglassen)"]
|
||||||
OVERVIEW --> ASSEMBLE["KONGLOMERAT baut CODE (kein LLM-Reduce):<br/>Überblick-Absatz + pro Sektion 'Thema:\nSummary (Namen: …)'<br/>chronologisch + 'Genannte Namen: …' am Ende<br/>→ deterministisch, keine Fehlgewichtung möglich; Cap 16 000"]
|
OVERVIEW --> ASSEMBLE["KONGLOMERAT baut CODE (kein LLM-Reduce):<br/>Überblick-Absatz + pro Sektion 'Thema:\nSummary (Namen: …)'<br/>chronologisch + 'Genannte Namen: …' am Ende<br/>→ deterministisch, keine Fehlgewichtung möglich; Cap 16 000"]
|
||||||
ASSEMBLE --> OUT["Ergebnis"]
|
ASSEMBLE --> OUT["Ergebnis"]
|
||||||
SINGLE --> OUT
|
SINGLE --> OUT
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Länge schlägt beides: Sektions-Pfad ab 9 000 Zeichen (2026-07-29)
|
||||||
|
|
||||||
|
Aufnahme #79 (23-min-Marketing-Jour-fixe, 3 diarisierte Sprecher, 21k Zeichen im gelabelten Transkript)
|
||||||
|
erfüllte **keine** der beiden bisherigen Bedingungen — unter `WHOLE_MAX_CHARS` 32 000 und unter
|
||||||
|
`MANY_SPEAKERS` 5 — und lief deshalb als Whole-Pass. Ergebnis: **1 082 Zeichen** Zusammenfassung, in der
|
||||||
|
das erste Tagesordnungsthema (Timo/Sabbatical, die ersten 2,5 Minuten) **komplett fehlte**; MS Teams
|
||||||
|
lieferte für dasselbe Meeting 5 Themenblöcke. Die Sprecherzahl war also nie der eigentliche Treiber:
|
||||||
|
oberhalb von ~3k Token komprimiert das 4B den ANFANG des Prompts weg, egal wie viele Leute reden.
|
||||||
|
|
||||||
|
- Neu `SECTION_MIN_CHARS = 9 000` (`LLM_SECTION_MIN`): alles darüber geht in `analyzeLong`, das jeden
|
||||||
|
Abschnitt bauartbedingt abdeckt. `MANY_SPEAKERS` zusätzlich 5 → 3.
|
||||||
|
- Wirkung auf #79 (gleiches Transkript, gleiche Diarisierung): **1 082 → ~5 300 Zeichen**, 7 Themenblöcke,
|
||||||
|
alle Teams-Themen enthalten, 15–18 ToDos mit Verantwortlichen.
|
||||||
|
- Preis: der Sektions-Pfad braucht auf diesem Board ~20 statt ~8 Minuten.
|
||||||
|
|
||||||
|
### Zwei Fehlerbilder des Sektions-Pfads — und was wirklich half (2026-07-29)
|
||||||
|
|
||||||
|
Beide wurden über **zehn** Läufe derselben Aufnahme gemessen, nicht geraten.
|
||||||
|
|
||||||
|
**1. Verlorenes Thema am Chunk-Anfang.** Bei 4 200 Zeichen enthielt Chunk 1 von #79 zwei zusammenhanglose
|
||||||
|
Themen (Timo-Gespräch, dann Video-Review) — das Modell fasste nur das längere zusammen, das Timo-Thema
|
||||||
|
verschwand in 1 von 3 Läufen. Fix: `CHUNK_CHARS` 4 200 → **3 200** plus die Prompt-Zeile „Enthält der
|
||||||
|
Abschnitt mehrere verschiedene Themen, behandle JEDES in mindestens einem Satz — auch ein kurzes Thema
|
||||||
|
ganz am Anfang." Ein früherer Versuch mit dem Zusatz „…und lasse weg, was unklar bleibt" machte es
|
||||||
|
**schlimmer** (das war die Lizenz, kurze Themen zu streichen) und wurde zurückgenommen.
|
||||||
|
|
||||||
|
**2. Überdehnte Einzelaussagen.** „Achso, ja, dann lassen wir LinkedIn raus, dann kommen wir es nur auf
|
||||||
|
Instagram" — gesagt über EIN Ragebait-Video — wurde zu „das Team veröffentlicht nur noch auf Instagram",
|
||||||
|
also der Umkehrung der echten Entscheidung (LinkedIn soll fachlicher werden). In 5 von 8 Läufen falsch,
|
||||||
|
und **keine** Prompt-Formulierung hat das zuverlässig behoben (abstrakte Regel, dann Gegensatzpaar mit
|
||||||
|
Beispiel — beide blieben drin, beide reichten nicht). Was gemessen wurde und half:
|
||||||
|
|
||||||
|
- Ein Skript, das `buildBasis` + `chunkText` nachbaut, lokalisierte die Auslöser: „lustigen Sachen nur bei
|
||||||
|
Instagram" liegt **4 % in Chunk 6**, also ohne Bezugswort. → **`TAIL_CHARS` = 700**: jeder Chunk bekommt
|
||||||
|
die letzten 700 Zeichen des vorigen wörtlich als ausdrücklich gekennzeichneten Kontextblock. Das behob
|
||||||
|
genau diesen Abschnitt.
|
||||||
|
- Der zweite Auslöser sitzt mitten in Chunk 5 (51 %), hat also Kontext und wird trotzdem überdehnt. →
|
||||||
|
**gezielter Prüf-Aufruf**, wenn der Entwurf ein Skopus-Wort UND ein kollektives Subjekt enthält.
|
||||||
|
Erste Fassung des Triggers suchte wörtlich nach „nur noch"/„statt auch" und feuerte deshalb NIE auf den
|
||||||
|
echten Fehlsatz „Das Team entscheidet sich dafür, **nur** Instagram zu nutzen **statt** LinkedIn".
|
||||||
|
|
||||||
|
**Stand:** die harte Umkehrung („LinkedIn wird beendet") ist weg, der Rest-Effekt ist eine abgeschwächte
|
||||||
|
Formulierung („verstärkter Fokus auf Instagram") in einem von sieben Abschnitten, während Überblick und
|
||||||
|
LinkedIn-Abschnitt die Strategie korrekt wiedergeben. Ein vollständiger Verifikationsdurchlauf über ALLE
|
||||||
|
Abschnitte würde die Analyse verdoppeln — dafür ist der Rest-Fehler zu klein.
|
||||||
|
|
||||||
### Round-Robin-Meetings: Sprecherzahl schlägt Länge (2026-07-17)
|
### Round-Robin-Meetings: Sprecherzahl schlägt Länge (2026-07-17)
|
||||||
|
|
||||||
Aufnahme #74 (33-min-Weekly, 13 diarisierte Sprecher, 31k Zeichen) lag knapp UNTER `WHOLE_MAX_CHARS`
|
Aufnahme #74 (33-min-Weekly, 13 diarisierte Sprecher, 31k Zeichen) lag knapp UNTER `WHOLE_MAX_CHARS`
|
||||||
|
|||||||
@@ -3,6 +3,48 @@
|
|||||||
Chronik der Entwicklungsschritte. **Regel: jede zukünftige Änderung bekommt hier einen Eintrag** (Datum,
|
Chronik der Entwicklungsschritte. **Regel: jede zukünftige Änderung bekommt hier einen Eintrag** (Datum,
|
||||||
Was, Warum, betroffene Dateien/Backups).
|
Was, Warum, betroffene Dateien/Backups).
|
||||||
|
|
||||||
|
## 2026-07-29 — #79: verlorener Sprecher + magere Zusammenfassung (Vergleich gegen MS Teams)
|
||||||
|
|
||||||
|
**Ausgangslage (Nutzer):** Aufnahme #79 (23-min-Marketing-Jour-fixe) wurde trotz live getippter Namen
|
||||||
|
falsch analysiert — 3 statt 4 Sprecher, Zusammenfassung 1 082 Zeichen ohne Inhalt. Als Messlatte lagen die
|
||||||
|
Teams-Notizen desselben Meetings vor (5 Themenblöcke + 3 Folgeaufgaben).
|
||||||
|
|
||||||
|
**Zwei unabhängige Ursachen, beide gemessen:**
|
||||||
|
|
||||||
|
1. *Diarisierung:* Stufe A lieferte korrekt 4 starke Cluster. Von 4 Markern mit 3 Namen landete der zweite
|
||||||
|
„Merle" auf **Saschas** Cluster; das bedingungslose must-link verschmolz beide (Kosinus **−0,727**).
|
||||||
|
2. *LLM:* 21k Zeichen < `WHOLE_MAX_CHARS` 32 000 und 3 < `MANY_SPEAKERS` 5 → Whole-Pass in einem Call →
|
||||||
|
das erste Thema (Timo/Sabbatical) fiel der Prompt-Kompression zum Opfer.
|
||||||
|
|
||||||
|
**Fixes `diar.js` (`.bak-marklink`):** Anker-Gewicht nach Trefferqualität (im Turn 1,0 → 25 s Tippverzögerung
|
||||||
|
0,2); must-link nur bei `cos ≥ 0,6 × gelernter Schwelle` (`DIAR_MARK_LINK_REL`), sonst Marker verwerfen +
|
||||||
|
loggen; Warnung bei mehreren Namen auf einem Cluster; **Voiceprints als cannot-link** (`DIAR_VP_MIN` 0,55,
|
||||||
|
Abstand 0,05, Namensschlüssel = Vorname) — damit ist der Box-Besitzer immer ein eigener Sprecher.
|
||||||
|
|
||||||
|
**Fixes `server.js` (`.bak-vpconstraint`, `.bak-uniqlabel`) / `recorder.js`:** Voiceprints werden in
|
||||||
|
`rediarize()` und `transcribeAndAnalyze()` in die Diarisierung durchgereicht; `applyMarkers()` verschmilzt
|
||||||
|
nur noch bei `conf = high` (`mid` benennt weiterhin); `buildBasis()` vergibt **eindeutige** Labels (in #79
|
||||||
|
trugen zwei Cluster „Markus", der LLM verschmolz daraufhin zwei Personen im Text); Stimmabdruck schlägt
|
||||||
|
einen unsicher verankerten Marker; `relabelAnalysis()` läuft am Ende JEDER Analyse (Namen, die während der
|
||||||
|
~20 min Laufzeit entstehen, landen sonst nie im fertigen Text — „Sprecher 1" blieb stehen).
|
||||||
|
|
||||||
|
**Fixes `llm.js` (`.bak-sectionmin`, `.bak-grounding`):** `SECTION_MIN_CHARS` = 9 000 (alles darüber in den
|
||||||
|
Sektions-Pfad), `MANY_SPEAKERS` 5 → 3, `CHUNK_CHARS` 4 200 → 3 200, `TAIL_CHARS` = 700 (wörtlicher Kontext
|
||||||
|
aus dem Vorgänger-Chunk), Prompt-Regeln „jedes Thema mind. ein Satz" + „nicht verallgemeinern", gezielter
|
||||||
|
Prüf-Aufruf bei Grundsatz-Aussagen, Verwerfen einer reinen Verabschiedungs-Sektion am Ende.
|
||||||
|
|
||||||
|
**Ergebnis #79:** 4 Sprecher (Sascha/Markus/Lisa/Merle, alle automatisch benannt), Zusammenfassung
|
||||||
|
**1 082 → ~5 300 Zeichen**, 7 Themenblöcke, 15–18 ToDos mit Verantwortlichen; alle Teams-Themen abgedeckt.
|
||||||
|
Regression geprüft: #70 → 4, #68 → 2, #74 → 20 Sprecher (unverändert).
|
||||||
|
|
||||||
|
**Neu im Labor:** `app/lab/lab-eval2.mjs` (Replay des echten `clusterWindows()` MIT Voiceprints),
|
||||||
|
`app/lab/vp-dump.mjs` (Voiceprints als JSON). Damit kostet eine Clustering-Iteration Sekunden statt eines
|
||||||
|
sherpa-Laufs.
|
||||||
|
|
||||||
|
**Betriebs-GOTCHA:** der A76-Takt wurde für einen sherpa-Lab-Lauf auf 2,256 GHz gesetzt — 88–89 °C, kurz
|
||||||
|
darauf hing das Board (kein Ping auf LAN und WLAN, Hard-Reset nötig). Lüfterlos gilt weiter: schwere Jobs
|
||||||
|
seriell und Cap 1,6 GHz. Nach dem Neustart hat der R1 per DHCP **192.168.1.48** bekommen (vorher .200).
|
||||||
|
|
||||||
## 2026-07-28 — GUI: alle Tabs nutzen die volle Fensterbreite
|
## 2026-07-28 — GUI: alle Tabs nutzen die volle Fensterbreite
|
||||||
|
|
||||||
- **Problem:** nur „Aufnahmen" bekam `#app.wide` (max-width: none), Stimmen/Glossar/Bluetooth blieben auf
|
- **Problem:** nur „Aufnahmen" bekam `#app.wide` (max-width: none), Stimmen/Glossar/Bluetooth blieben auf
|
||||||
|
|||||||
Reference in New Issue
Block a user