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 <noreply@anthropic.com>
This commit is contained in:
mARTin-B78 2026-07-04 22:40:14 +02:00
parent 3c3c209d3a
commit 42a45c43b6
4 changed files with 66 additions and 4 deletions

View File

@ -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 ## [1.12.92] — 2026-07-04
### Fixed ### Fixed

View File

@ -1 +1 @@
1.12.92 1.12.93

View File

@ -10,7 +10,7 @@
<meta name="format-detection" content="telephone=no"> <meta name="format-detection" content="telephone=no">
<meta name="color-scheme" content="light dark"> <meta name="color-scheme" content="light dark">
<meta name="theme-color" content="#2563EB"> <meta name="theme-color" content="#2563EB">
<meta name="app-version" content="1.12.92"> <meta name="app-version" content="1.12.93">
<link rel="manifest" href="/manifest.webmanifest"> <link rel="manifest" href="/manifest.webmanifest">
<link rel="icon" href="/static/icon.svg" type="image/svg+xml"> <link rel="icon" href="/static/icon.svg" type="image/svg+xml">
<link rel="apple-touch-icon" href="/static/icon.svg"> <link rel="apple-touch-icon" href="/static/icon.svg">
@ -27,7 +27,7 @@
<!-- ── Core styles (local — no CDN dependency for first paint) ────────── --> <!-- ── Core styles (local — no CDN dependency for first paint) ────────── -->
<link rel="stylesheet" href="/static/vendor/mdi/materialdesignicons.min.css"> <link rel="stylesheet" href="/static/vendor/mdi/materialdesignicons.min.css">
<link rel="stylesheet" href="/static/style.css?v=1.12.92"> <link rel="stylesheet" href="/static/style.css?v=1.12.93">
<!-- ── Flag icons — non-blocking (loaded async, icons appear after JS) ── --> <!-- ── Flag icons — non-blocking (loaded async, icons appear after JS) ── -->
@ -365,7 +365,7 @@ window.toggleNavTree = function(treeId, chevronId) {
</script> </script>
<!-- loader.js: fetches sections → loads JS modules → removes skeleton --> <!-- loader.js: fetches sections → loads JS modules → removes skeleton -->
<script src="/static/loader.js?v=1.12.92"></script> <script src="/static/loader.js?v=1.12.93"></script>
</body> </body>
</html> </html>

View File

@ -564,6 +564,57 @@ const AB_NOTNAME = new Set([
'Gelächter', 'Wieder', 'Gleich', 'Sogleich', 'Langsam', 'Leise', 'Laut', 'Kaum', 'Vielleicht', 'Natürlich', 'Wirklich', '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']); 'Ja', 'Nein', 'Komm', 'Warte', 'Halt', 'Geh', 'Hier', 'Dort', 'Oben', 'Unten', 'Schon', 'Noch', 'Auch', 'Nur', 'Immer', 'Nie']);
const _AB_NAME = "([A-ZÄÖÜ][A-Za-zäöüß'\\-]+)"; 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 <Capitalized>" 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_TAG_NAME = "[A-ZÄÖÜ][A-Za-zäöüß'\\-]+";
const AB_SPEECH_TAG_ONLY_RE = new RegExp( const AB_SPEECH_TAG_ONLY_RE = new RegExp(
'^\\s*[,.;:!?-]*\\s*(?:' + '^\\s*[,.;:!?-]*\\s*(?:' +
@ -3552,6 +3603,10 @@ async function audiobookCast(overrideUrl, overrideModel, resume) {
segs = audiobookSplitByQuotes(chunks[i]); segs = audiobookSplitByQuotes(chunks[i]);
view.note(`❌ Passage ${i + 1} — unexpected error (${chunkErr.message}) — auto-detected dialogue instead`); 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 // 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 => { 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); }); segs.forEach(s => { s.page = _curPageNum; allSegments.push(s); });