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**,