From 42a45c43b6fe924a215fa295560e8baaa916561b Mon Sep 17 00:00:00 2001 From: mARTin-B78 Date: Sat, 4 Jul 2026 22:40:14 +0200 Subject: [PATCH] Resolve Unknown speakers deterministically after each passage (v1.12.93) The LLM left ~44% of dialogue Unknown even with all deduction rules in its prompt, so the mechanical ones now run in code per passage: colon rule (with a non-agent-noun stoplist), post-quote inquit, and the "who had spoken" pattern. Fills only Unknowns, never overrides the LLM. Tested against the exact reported failure cases. Co-Authored-By: Claude Fable 5 --- CHANGELOG.md | 7 ++++++ VERSION | 2 +- static/index.html | 6 ++--- static/js/audiobook.js | 55 ++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 66 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index bc0c85f..916203a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,13 @@ Follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) · versioned wi --- +## [1.12.93] — 2026-07-04 + +### Added +- **Deterministic Unknown-resolution pass during casting** — measured on a full book, the LLM left ~44% of dialogue "Unknown" even with all deduction rules in its prompt, so the two most mechanical rules are now applied in code after each passage, where they can't be ignored: the colon rule (narration ending in ":" names the next quote's speaker — with a non-agent-noun stoplist so "rief in die Runde:" resolves to the Ork, not "Runde"), post-quote inquits ("fragte Uriens leise." after a quote), and the "Der Krieger, der gesprochen hatte" pattern. Only fills segments the LLM left Unknown; never overrides an actual attribution. Verified against the exact failure cases from the reported screenshots. + +--- + ## [1.12.92] — 2026-07-04 ### Fixed diff --git a/VERSION b/VERSION index ebc03cf..0fec8f2 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.12.92 +1.12.93 diff --git a/static/index.html b/static/index.html index f218b7f..28dd8ca 100644 --- a/static/index.html +++ b/static/index.html @@ -10,7 +10,7 @@ - + @@ -27,7 +27,7 @@ - + @@ -365,7 +365,7 @@ window.toggleNavTree = function(treeId, chevronId) { - + diff --git a/static/js/audiobook.js b/static/js/audiobook.js index 3f894c5..638fd07 100644 --- a/static/js/audiobook.js +++ b/static/js/audiobook.js @@ -564,6 +564,57 @@ const AB_NOTNAME = new Set([ 'Gelächter', 'Wieder', 'Gleich', 'Sogleich', 'Langsam', 'Leise', 'Laut', 'Kaum', 'Vielleicht', 'Natürlich', 'Wirklich', 'Ja', 'Nein', 'Komm', 'Warte', 'Halt', 'Geh', 'Hier', 'Dort', 'Oben', 'Unten', 'Schon', 'Noch', 'Auch', 'Nur', 'Immer', 'Nie']); const _AB_NAME = "([A-ZÄÖÜ][A-Za-zäöüß'\\-]+)"; + +// Deterministic Unknown-resolution — the LLM keeps missing two mechanical +// patterns no matter how explicitly the prompt spells them out (measured: +// ~44% Unknown on a full book WITH the rules in the prompt), so apply them +// in code where they cannot be ignored: +// A. Colon rule: the narration right before a quote ends with ":" → its +// last-mentioned character/role speaks ("… und rief in die Runde:"). +// B. Post-quote inquit: the narration right after a quote starts with a +// speech verb + Name (or "Der X, der gesprochen hatte") → X spoke. +// Only fills segments still Unknown; never overrides an LLM attribution. +function audiobookResolveUnknowns(segs, prevTail, roster) { + const isUnknown = s => s?.type === 'dialogue' && (!s.speaker || /^Unknown|Unbekannt/i.test(s.speaker)); + const names = (roster || []).filter(n => n && !/^(Narrator|Unknown|Unbekannt)/i.test(n)).sort((a, b) => b.length - a.length); + const lastNameIn = (text) => { + let best = null, bestAt = -1; + for (const n of names) { + const at = text.lastIndexOf(n); + if (at > bestAt) { bestAt = at; best = n; } + } + if (best) return best; + // fallback: last "der/die " role noun — skipping non-agent + // nouns that commonly sit right before the colon ("rief in die Runde:"). + const NON_AGENT = /^(Runde|Stimme|Stimmen|Menge|Ferne|Höhe|Größe|Richtung|Seite|Stadt|Tür|Luft|Hand|Hände|Augen|Worte|Wort|Frage|Antwort|Dunkelheit|Stille|Nacht|Morgen|Abend|Erde|Himmel|Boden|Wand|Mauer)$/; + const m = [...text.matchAll(/\b(?:[Dd]er|[Dd]ie|[Dd]en|[Dd]em|[Ee]in|[Ee]ine)\s+([A-ZÄÖÜ][a-zäöüß]{2,})\b/g)] + .map(x => x[1]).filter(n => !NON_AGENT.test(n)); + return m.length ? m[m.length - 1] : null; + }; + const all = [...(prevTail || []), ...segs]; + const offset = (prevTail || []).length; + const resolved = []; + for (let i = offset; i < all.length; i++) { + const s = all[i]; + if (!isUnknown(s)) continue; + const prev = all[i - 1], next = all[i + 1]; + let who = null; + if (prev?.type === 'narration' && /:\s*$/.test(String(prev.text || '').trim())) { + who = lastNameIn(String(prev.text || '')); + } + if (!who && next?.type === 'narration') { + const nt = String(next.text || '').trim(); + let m = nt.match(new RegExp('^\\W{0,3}(?:' + AB_SPEECH_VERBS + ')\\s+(?:der|die)?\\s*' + _AB_NAME)); + if (!m) m = nt.match(new RegExp('^(?:Der|Die)\\s+' + _AB_NAME + ',\\s+(?:der|die)\\s+gesprochen hatte')); + if (m) who = m[1]; + } + if (who && !/^(Narrator|Unknown|Unbekannt)$/i.test(who)) { + s.speaker = who; + resolved.push(who); + } + } + return resolved; +} const _AB_TAG_NAME = "[A-ZÄÖÜ][A-Za-zäöüß'\\-]+"; const AB_SPEECH_TAG_ONLY_RE = new RegExp( '^\\s*[,.;:!?–-]*\\s*(?:' + @@ -3552,6 +3603,10 @@ async function audiobookCast(overrideUrl, overrideModel, resume) { segs = audiobookSplitByQuotes(chunks[i]); view.note(`❌ Passage ${i + 1} — unexpected error (${chunkErr.message}) — auto-detected dialogue instead`); } + // Deterministic pass over what the LLM left Unknown — colon rule and + // post-quote inquit are mechanical enough to apply in code (with the + // previous chunk's tail as lookback context across the boundary). + audiobookResolveUnknowns(segs, allSegments.slice(-2), roster); // harvest speaker names (from LLM or tag heuristic) into the running roster segs.forEach(s => { const speakerName = (s.type !== 'dialogue' || !s.speaker || s.speaker.toLowerCase() === 'narrator') ? 'Narrator' : s.speaker; if (!/^Unknown|Unbekannt/i.test(speakerName) && !roster.includes(speakerName)) roster.push(speakerName); }); segs.forEach(s => { s.page = _curPageNum; allSegments.push(s); });