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 transcript_clean TEXT NULL,
ADD COLUMN IF NOT EXISTS wav_file VARCHAR(255) 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_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_status VARCHAR(16) NOT NULL DEFAULT 'pending',
ADD COLUMN IF NOT EXISTS analysis_started_at BIGINT NULL, ADD COLUMN IF NOT EXISTS analysis_started_at BIGINT NULL,
ADD COLUMN IF NOT EXISTS analysis_ms INT 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" 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]); 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. // Seed names from acoustic identification WITHOUT clobbering existing (manual) ones.
export async function mergeSpeakerNames(id, map) { export async function mergeSpeakerNames(id, map) {
if (!map || !Object.keys(map).length) return; 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, // Build the LLM input from stored words. If acoustic diarization tagged speakers,
// produce a labelled dialogue ("Sprecher 1: …") so the LLM keeps real speakers // produce a labelled dialogue ("Sprecher 1: …") so the LLM keeps real speakers
// instead of guessing; otherwise the plain transcript. // 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 = {}) { function buildBasis(rec, nameMap = {}) {
const words = rec.words || []; const words = rec.words || [];
const hasSpk = words.some(w => w.speaker); 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 turns = diar.toTurns(words);
const map = {}; let n = 0; const labels = {}; let n = 0;
const label = sp => nameMap[sp] || (map[sp] || (map[sp] = `Sprecher ${++n}`)); const label = sp => labels[sp] || (labels[sp] = 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 }; 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'); const REC_DIR2 = path.join(path.dirname(fileURLToPath(import.meta.url)), 'recordings');
@@ -158,6 +212,7 @@ async function applyMarkers(id) {
let learned = 0; let learned = 0;
for (const [sp, name] of Object.entries(nameOf)) if (await enrollSpeakerVoice(rec2, sp, name)) learned++; for (const [sp, name] of Object.entries(nameOf)) if (await enrollSpeakerVoice(rec2, sp, name)) learned++;
const named = Object.keys(nameOf).length; 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`); console.log(`markers on #${id}: ${resolved.length} marker(s) → ${named} named speaker(s), ${merged} merged, ${learned} voice(s) learned`);
return { resolved, named, merged, 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; } } 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`); } 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 }); const a = await llm.analyze(text, { labeled, glossary });
// Participants = the ACOUSTICALLY diarized speakers (named where known), // Participants = the ACOUSTICALLY diarized speakers (named where known),
// not the LLM's list — the model returned every name MENTIONED in the // 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. // LLM participants stay only as fallback when there is no diarization.
const spkOrder = []; const spkOrder = [];
for (const w of (rec.words || [])) if (w.speaker && !spkOrder.includes(w.speaker)) spkOrder.push(w.speaker); 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 // Belt-and-suspenders: also swap in the LLM outputs (cheap, catches any the
// model reintroduced). // model reintroduced).
for (const k of ['title', 'summary', 'transcriptClean']) if (a[k]) a[k] = applyGlossaryText(a[k], gmap); 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); const r = await db.getRecording(id);
if (!r) return res.status(404).json({ error: 'not found' }); if (!r) return res.status(404).json({ error: 'not found' });
await db.setSpeakerName(id, speaker, name); 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; let enrolled = false, learnMsg = null;
if (enroll && name && r.wav_file) { 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); 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'))); 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 = '…'; btn.disabled = true; btn.textContent = '…';
try { try {
const res = await api.post('/api/recordings/' + id + '/speaker', { speaker: popSp, name, enroll }); 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); openRecording(id);
} catch (e) { toast(e.message, true); btn.disabled = false; btn.textContent = 'Speichern'; } } catch (e) { toast(e.message, true); btn.disabled = false; btn.textContent = 'Speichern'; }
}; };
+30
View File
@@ -111,6 +111,36 @@ GUI rendert mit `white-space: pre-line` (style.css) → Bullets und Absätze sic
Feld/eigene Liste. **Teilnehmer kommen NICHT vom LLM**, sondern aus der Diarization (Kap. 04) — das LLM Feld/eigene Liste. **Teilnehmer kommen NICHT vom LLM**, sondern aus der Diarization (Kap. 04) — das LLM
hatte alle im Gespräch *erwähnten* Namen als Teilnehmer gelistet (11 statt 3 bei Aufnahme #69). hatte alle im Gespräch *erwähnten* Namen als Teilnehmer gelistet (11 statt 3 bei Aufnahme #69).
## Namen nachträglich in eine fertige Analyse übernehmen (seit 2026-07-27)
Die Zusammenfassung entsteht aus einem Dialog, in dem jeder Sprecher ein **Label** trägt: seinen Namen,
falls beim Analysezeitpunkt bekannt, sonst „Sprecher N". Wer erst DANACH benannt wird (Sprecher-Popover,
Namensmarker), stand vorher weiter als „Sprecher 5" im fertigen Text — und eine Neu-Analyse kostet auf
diesem Board ~20–30 min.
```mermaid
flowchart LR
A["analyzeRecording()"] --> B["buildBasis() vergibt Labels<br/>{speaker_09: 'Sprecher 5'}"]
B --> C[("recordings.speaker_labels")]
B --> D["LLM: Zusammenfassung/Todos<br/>mit genau diesen Labels"]
E["Sprecher umbenennen<br/>(Popover / Marker)"] --> F["relabelAnalysis()"]
C --> F
F --> G["Whole-Word-Ersetzung in<br/>summary · todos · transcript_clean"]
F --> H["Teilnehmerliste NEU gebaut<br/>(Reihenfolge des ersten Auftretens)"]
F --> C
```
- `speaker_labels` (JSON, pro Aufnahme) hält genau das Label, das jeder Cluster im LLM-Text hatte —
deshalb ist die Ersetzung eindeutig und braucht keine erneute Analyse (Sekunden statt Minuten).
- Nach der Ersetzung wird das gespeicherte Label auf den Namen gesetzt, damit ein zweites Umbenennen
wieder funktioniert.
- **Teilnehmerliste** wird neu aufgebaut statt ersetzt (String-Swap ließ Sprecher stehen, die im Text nie
vorkamen, und konnte Namen doppeln).
- **Mehrdeutige Labels werden übersprungen:** trug dasselbe Label zwei Cluster (die Stimm-Erkennung hatte
„Fabio" einmal auf zwei Cluster gelegt), lässt sich im Text nicht entscheiden, wer gemeint war — dann
bleibt der Text unangetastet. Sauber wird das erst durch „Analyse neu (nur KI)" mit korrekten Namen.
- GUI: Umbenennen → Toast „Name in Zusammenfassung übernommen", Detailansicht lädt neu.
## Robustheit gegen Gemma-Marotten (alle real aufgetreten) ## Robustheit gegen Gemma-Marotten (alle real aufgetreten)
| Marotte | Gegenmaßnahme | | Marotte | Gegenmaßnahme |
+22
View File
@@ -3,6 +3,28 @@
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-27 (2) — Namen nachträglich in die fertige Zusammenfassung übernehmen
- **Problem (Nutzer):** alle 20 Sprecher von #74 benannt, in der Zusammenfassung stand trotzdem weiter
„Sprecher 5" statt Katharina. Grund: der Text entsteht mit den Labels, die beim Analysezeitpunkt galten;
spätere Namen ändern nur `speaker_names`. Zusätzlich zählte `buildBasis()` „Sprecher N" nur über die
UNBENANNTEN Sprecher, die Teilnehmerliste und die Spur-Beschriftung dagegen über alle → dieselbe Nummer
meinte in Text und GUI verschiedene Personen.
- **Fix:** neue Spalte `recordings.speaker_labels` (JSON) speichert das Label jedes Clusters im LLM-Text;
`relabelAnalysis()` (server.js) ersetzt beim Umbenennen wortgenau in `summary`, `todos` und
`transcript_clean`, baut die Teilnehmerliste aus der Diarization neu auf und schreibt das Label auf den
neuen Namen fort. Aufgerufen aus `POST /recordings/:id/speaker` und aus `applyMarkers()`.
Mehrdeutige Labels (ein Label auf zwei Clustern) werden bewusst NICHT ersetzt. Teilnehmerliste der
Analyse nutzt jetzt dieselben Labels wie der Dialogtext.
- **#74 nachgezogen:** Label-Karte des alten Laufs rekonstruiert (Namen zum Analysezeitpunkt:
Kai/Fabio/Fabio) und angewandt → **0 „Sprecher N" mehr in Summary und Todos**, Teilnehmerliste = die 20
echten Namen in Reihenfolge des ersten Auftretens. Rest: „Fabio" steht an 10 Stellen, davon einige
eigentlich Nico (die Stimm-ID hatte damals zwei Cluster „Fabio" genannt) — sauber nur per
„Analyse neu (nur KI)".
- **Dateien:** `server.js` (buildBasis liefert Labels, relabelAnalysis), `db.js`
(`speaker_labels`, `setSpeakerLabels`/`getSpeakerLabels`), `web/src/main.js` (Toast).
Bundle `index-TOCXxq47.js`.
## 2026-07-27 — Diarization v2 (Fenster-Clustering, gelernte Schwelle) + Live-Namensmarker ## 2026-07-27 — Diarization v2 (Fenster-Clustering, gelernte Schwelle) + Live-Namensmarker
- **Problem:** #74 (33-min-Weekly, 22 Teilnehmer + ein Kind) automatisch neu analysiert → **44 Sprecher**, - **Problem:** #74 (33-min-Weekly, 22 Teilnehmer + ein Kind) automatisch neu analysiert → **44 Sprecher**,