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:
@@ -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
@@ -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
@@ -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'; }
|
||||
};
|
||||
|
||||
@@ -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
|
||||
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)
|
||||
|
||||
| Marotte | Gegenmaßnahme |
|
||||
|
||||
@@ -3,6 +3,28 @@
|
||||
Chronik der Entwicklungsschritte. **Regel: jede zukünftige Änderung bekommt hier einen Eintrag** (Datum,
|
||||
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
|
||||
|
||||
- **Problem:** #74 (33-min-Weekly, 22 Teilnehmer + ein Kind) automatisch neu analysiert → **44 Sprecher**,
|
||||
|
||||
Reference in New Issue
Block a user