diff --git a/app/db.js b/app/db.js index de9ba5b..4c1ba49 100644 --- a/app/db.js +++ b/app/db.js @@ -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; diff --git a/app/server.js b/app/server.js index 35f9095..d2ad44a 100644 --- a/app/server.js +++ b/app/server.js @@ -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(`(? 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'))); diff --git a/app/web/src/main.js b/app/web/src/main.js index f94d1d0..7872de8 100644 --- a/app/web/src/main.js +++ b/app/web/src/main.js @@ -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'; } }; diff --git a/docs/05-llm-analyse.md b/docs/05-llm-analyse.md index df2bf7d..6ca0109 100644 --- a/docs/05-llm-analyse.md +++ b/docs/05-llm-analyse.md @@ -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
{speaker_09: 'Sprecher 5'}"] + B --> C[("recordings.speaker_labels")] + B --> D["LLM: Zusammenfassung/Todos
mit genau diesen Labels"] + E["Sprecher umbenennen
(Popover / Marker)"] --> F["relabelAnalysis()"] + C --> F + F --> G["Whole-Word-Ersetzung in
summary · todos · transcript_clean"] + F --> H["Teilnehmerliste NEU gebaut
(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 | diff --git a/docs/08-changelog.md b/docs/08-changelog.md index aa7d322..bf73e06 100644 --- a/docs/08-changelog.md +++ b/docs/08-changelog.md @@ -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**,