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:
parent
3c3c209d3a
commit
42a45c43b6
@ -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
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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); });
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user