Namen nachtraeglich in fertige Zusammenfassung uebernehmen

speaker_labels speichert das Label jedes Clusters im LLM-Text; relabelAnalysis()
ersetzt beim Umbenennen wortgenau in summary/todos/transcript_clean und baut die
Teilnehmerliste neu. #74: keine 'Sprecher N' mehr.
This commit is contained in:
Claude
2026-07-27 23:52:28 +02:00
parent 61531fc50a
commit 448e2b0726
5 changed files with 136 additions and 8 deletions
+12
View File
@@ -15,6 +15,7 @@ export async function ensureSchema() {
ADD COLUMN IF NOT EXISTS transcript_clean TEXT NULL,
ADD COLUMN IF NOT EXISTS wav_file VARCHAR(255) NULL,
ADD COLUMN IF NOT EXISTS speaker_names TEXT NULL,
ADD COLUMN IF NOT EXISTS speaker_labels TEXT NULL,
ADD COLUMN IF NOT EXISTS analysis_status VARCHAR(16) NOT NULL DEFAULT 'pending',
ADD COLUMN IF NOT EXISTS analysis_started_at BIGINT NULL,
ADD COLUMN IF NOT EXISTS analysis_ms INT NULL`);
@@ -187,6 +188,17 @@ export async function setSpeakerName(id, speaker, name) {
if (name) m[speaker] = name; else delete m[speaker]; // empty name clears back to "Sprecher N"
await pool.execute('UPDATE recordings SET speaker_names=? WHERE id=?', [JSON.stringify(m), id]);
}
// The label EVERY speaker carried in the text the LLM produced ({speaker_07:
// "Sprecher 5"}). Stored with the analysis so a later rename can swap the name
// into the finished summary/todos instead of re-running the 20-minute analysis.
export async function setSpeakerLabels(id, labels) {
await pool.execute('UPDATE recordings SET speaker_labels=? WHERE id=?', [JSON.stringify(labels || {}), id]);
}
export async function getSpeakerLabels(id) {
const [rows] = await pool.query('SELECT speaker_labels FROM recordings WHERE id=?', [id]);
try { return JSON.parse((rows[0] && rows[0].speaker_labels) || '{}') || {}; } catch { return {}; }
}
// Seed names from acoustic identification WITHOUT clobbering existing (manual) ones.
export async function mergeSpeakerNames(id, map) {
if (!map || !Object.keys(map).length) return;
+70 -7
View File
@@ -35,15 +35,69 @@ function applyGlossaryText(text, m) {
// Build the LLM input from stored words. If acoustic diarization tagged speakers,
// produce a labelled dialogue ("Sprecher 1: …") so the LLM keeps real speakers
// instead of guessing; otherwise the plain transcript.
// Also returns `labels` = the exact label every speaker carries in the text the
// LLM sees ({speaker_07: "Sprecher 5"}); it is stored with the analysis so a
// rename afterwards can be patched into the finished summary (relabelAnalysis).
function buildBasis(rec, nameMap = {}) {
const words = rec.words || [];
const hasSpk = words.some(w => w.speaker);
if (!hasSpk) return { text: rec.transcript || '', labeled: false };
if (!hasSpk) return { text: rec.transcript || '', labeled: false, labels: {} };
const turns = diar.toTurns(words);
const map = {}; let n = 0;
const label = sp => nameMap[sp] || (map[sp] || (map[sp] = `Sprecher ${++n}`));
const labels = {}; let n = 0;
const label = sp => labels[sp] || (labels[sp] = nameMap[sp] || `Sprecher ${++n}`);
const text = turns.map(t => `${label(t.speaker)}: ${t.text}`).join('\n');
return { text, labeled: true };
return { text, labeled: true, labels };
}
// Swap speaker labels for real names in the ALREADY FINISHED analysis text.
// Renaming a speaker after the analysis used to leave "Sprecher 5" all over the
// summary/todos; re-running the LLM costs ~20 min on this board, while the label
// each speaker had is known exactly (recordings.speaker_labels). Whole-word
// replacement in summary, todos, transcript_clean and participants; the stored
// label is then updated to the new name so a second rename works too.
async function relabelAnalysis(id) {
const rec = await db.getRecording(id);
if (!rec) return { changed: 0 };
const names = rec.speaker_names || {};
const labels = await db.getSpeakerLabels(id);
if (!Object.keys(names).length || !Object.keys(labels).length) return { changed: 0 };
// A label that TWO speakers carried (the voice-ID once matched "Fabio" onto two
// clusters) is ambiguous in the text — leave those alone instead of renaming
// both occurrences to one person.
const labelCount = {};
for (const l of Object.values(labels)) labelCount[l] = (labelCount[l] || 0) + 1;
const pairs = Object.entries(names)
.filter(([sp, name]) => name && labels[sp] && labels[sp] !== name && labelCount[labels[sp]] === 1)
.map(([sp, name]) => ({ sp, from: labels[sp], to: name }))
// longest label first: "Sprecher 1" must not eat "Sprecher 12"
.sort((a, b) => b.from.length - a.from.length);
const esc = s => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const swap = (text) => {
if (!text) return text;
let out = String(text);
for (const p of pairs) out = out.replace(new RegExp(`(?<![\\p{L}\\p{N}])${esc(p.from)}(?![\\p{L}\\p{N}])`, 'gu'), p.to);
return out;
};
const todos = Array.isArray(rec.todos) ? rec.todos.map(t => swap(String(t))) : rec.todos;
// Participants are REBUILT (not string-swapped) from the diarized speakers in
// order of first appearance — a swap would leave the entries of speakers that
// were never mentioned in the text untouched and can duplicate names.
const spkOrder = [];
for (const w of (rec.words || [])) if (w.speaker && !spkOrder.includes(w.speaker)) spkOrder.push(w.speaker);
const participants = spkOrder.length
? spkOrder.map((sp, i) => names[sp] || labels[sp] || `Sprecher ${i + 1}`)
: (Array.isArray(rec.participants) ? rec.participants.map(p => swap(String(p))) : rec.participants);
const partChanged = JSON.stringify(participants || null) !== JSON.stringify(rec.participants || null);
if (!pairs.length && !partChanged) return { changed: 0 };
await db.pool.execute(
'UPDATE recordings SET summary=?, todos=?, transcript_clean=?, participants=? WHERE id=?',
[swap(rec.summary), todos ? JSON.stringify(todos) : null, swap(rec.transcript_clean),
participants ? JSON.stringify(participants) : null, id]);
const next = { ...labels };
for (const p of pairs) next[p.sp] = p.to;
await db.setSpeakerLabels(id, next);
console.log(`relabel #${id}: ${pairs.length ? pairs.map(p => `${p.from}→${p.to}`).join(', ') : 'nur Teilnehmerliste'}`);
return { changed: pairs.length || (partChanged ? 1 : 0) };
}
const REC_DIR2 = path.join(path.dirname(fileURLToPath(import.meta.url)), 'recordings');
@@ -158,6 +212,7 @@ async function applyMarkers(id) {
let learned = 0;
for (const [sp, name] of Object.entries(nameOf)) if (await enrollSpeakerVoice(rec2, sp, name)) learned++;
const named = Object.keys(nameOf).length;
await relabelAnalysis(id).catch(() => {});
console.log(`markers on #${id}: ${resolved.length} marker(s) → ${named} named speaker(s), ${merged} merged, ${learned} voice(s) learned`);
return { resolved, named, merged, learned };
}
@@ -194,7 +249,10 @@ async function analyzeRecording(id) {
for (const w of rec.words) { const nt = applyGlossaryText(w.word, gmap); if (nt !== w.word) { w.word = nt; changed = true; } }
if (changed) { await db.replaceWords(id, rec.words); console.log(`glossary: applied soundalike swaps to #${id} words`); }
}
const { text, labeled } = buildBasis(rec, names);
const { text, labeled, labels } = buildBasis(rec, names);
// Remember which label each speaker had in the LLM input, so renaming a
// speaker later can patch the finished text (relabelAnalysis).
await db.setSpeakerLabels(id, labels).catch(() => {});
const a = await llm.analyze(text, { labeled, glossary });
// Participants = the ACOUSTICALLY diarized speakers (named where known),
// not the LLM's list — the model returned every name MENTIONED in the
@@ -202,7 +260,9 @@ async function analyzeRecording(id) {
// LLM participants stay only as fallback when there is no diarization.
const spkOrder = [];
for (const w of (rec.words || [])) if (w.speaker && !spkOrder.includes(w.speaker)) spkOrder.push(w.speaker);
if (spkOrder.length) a.participants = spkOrder.map((sp, i) => names[sp] || `Sprecher ${i + 1}`);
// Same labels as in the dialogue the LLM read — otherwise the participant
// list numbers the speakers differently than the summary talks about them.
if (spkOrder.length) a.participants = spkOrder.map((sp, i) => names[sp] || labels[sp] || `Sprecher ${i + 1}`);
// Belt-and-suspenders: also swap in the LLM outputs (cheap, catches any the
// model reintroduced).
for (const k of ['title', 'summary', 'transcriptClean']) if (a[k]) a[k] = applyGlossaryText(a[k], gmap);
@@ -486,6 +546,8 @@ app.post('/api/recordings/:id/speaker', wrap(async (req, res) => {
const r = await db.getRecording(id);
if (!r) return res.status(404).json({ error: 'not found' });
await db.setSpeakerName(id, speaker, name);
// Patch the name into the already generated summary/todos/participants.
const relabel = await relabelAnalysis(id).catch(e => { console.error('relabel:', e.message); return { changed: 0 }; });
let enrolled = false, learnMsg = null;
if (enroll && name && r.wav_file) {
@@ -508,7 +570,8 @@ app.post('/api/recordings/:id/speaker', wrap(async (req, res) => {
}
}
const r2 = await db.getRecording(id);
res.json({ ok: true, speaker_names: r2.speaker_names || {}, enrolled, learnMsg });
res.json({ ok: true, speaker_names: r2.speaker_names || {}, enrolled, learnMsg,
relabeled: relabel.changed });
}));
app.get(/^(?!\/api|\/media).*/, (req, res) => res.sendFile(path.join(__dirname, 'web/dist/index.html')));
+2 -1
View File
@@ -810,7 +810,8 @@ async function openRecording(id) {
btn.disabled = true; btn.textContent = '…';
try {
const res = await api.post('/api/recordings/' + id + '/speaker', { speaker: popSp, name, enroll });
toast(res.enrolled ? `„${name}" gespeichert + Stimme gelernt` : (res.learnMsg ? 'Gespeichert (' + res.learnMsg + ')' : 'Gespeichert'));
const base = res.enrolled ? `„${name}" gespeichert + Stimme gelernt` : (res.learnMsg ? 'Gespeichert (' + res.learnMsg + ')' : 'Gespeichert');
toast(res.relabeled ? base + ' · Name in Zusammenfassung übernommen' : base);
openRecording(id);
} catch (e) { toast(e.message, true); btn.disabled = false; btn.textContent = 'Speichern'; }
};