Resolve pronoun-inquit split quotes, use verification prompt for repair pass (v1.20.21)
Two gaps found by validating the repair chain against a real German novel: Split quotes whose inquit uses a pronoun rather than a name (»Ja, ungewöhnlich« + ", antwortete er knapp.") stayed Unknown, because every existing rule requires a literal name after the speech verb. The antecedent is unambiguous, so it now resolves the same way the colon rule does — to the last character named in the narration immediately before the quote. Verified on the book: 2 further lines resolved with no LLM call, including the reported case. The repairNarration pass also only received the verification prompt when launched from the menu button; called from auto-repair or programmatically it fell back to the general casting prompt, which is not written for the "is this narration actually speech?" question. It now defaults to the verification prompt in every entry path. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
parent
30bac15b6e
commit
63ec69ad89
10
static/dist/main.min.js
vendored
10
static/dist/main.min.js
vendored
File diff suppressed because one or more lines are too long
@ -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.20.18">
|
<meta name="app-version" content="1.20.21">
|
||||||
<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.20.18">
|
<link rel="stylesheet" href="/static/style.css?v=1.20.21">
|
||||||
|
|
||||||
|
|
||||||
<!-- ── Flag icons — non-blocking (loaded async, icons appear after JS) ── -->
|
<!-- ── Flag icons — non-blocking (loaded async, icons appear after JS) ── -->
|
||||||
@ -378,7 +378,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.20.18"></script>
|
<script src="/static/loader.js?v=1.20.21"></script>
|
||||||
|
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@ -801,7 +801,18 @@ function audiobookResolveUnknowns(segs, prevTail, roster) {
|
|||||||
}
|
}
|
||||||
if (!who && next?.type === 'narration') {
|
if (!who && next?.type === 'narration') {
|
||||||
const nt = String(next.text || '').trim();
|
const nt = String(next.text || '').trim();
|
||||||
let m = nt.match(new RegExp('^\\W{0,3}(?:' + AB_SPEECH_VERBS + ')\\s+(?:der|die)?\\s*' + _AB_NAME));
|
// Split quote whose inquit uses a PRONOUN rather than a name:
|
||||||
|
// "»Ja, ungewöhnlich«" + ", antwortete er knapp." The rules below all
|
||||||
|
// require a literal name after the speech verb, so this whole class stayed
|
||||||
|
// Unknown even though the antecedent is unambiguous — resolve it the same
|
||||||
|
// way the colon rule does, to the last character named in the narration
|
||||||
|
// immediately before the quote.
|
||||||
|
if (new RegExp('^\\W{0,3}(?:' + AB_SPEECH_VERBS + ')\\s+(?:er|sie|es)\\b', 'i').test(nt)
|
||||||
|
&& prev?.type === 'narration') {
|
||||||
|
const byPronoun = lastNameIn(String(prev.text || ''));
|
||||||
|
if (byPronoun) who = byPronoun;
|
||||||
|
}
|
||||||
|
let m = who ? null : 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) m = nt.match(new RegExp('^(?:Der|Die)\\s+' + _AB_NAME + ',\\s+(?:der|die)\\s+gesprochen hatte'));
|
||||||
// "ertönte es plötzlich über ihm. Karyla hatte …" — impersonal formula,
|
// "ertönte es plötzlich über ihm. Karyla hatte …" — impersonal formula,
|
||||||
// the next sentence's subject is the speaker.
|
// the next sentence's subject is the speaker.
|
||||||
@ -1006,6 +1017,41 @@ function _audiobookMergeAdjacentSameSpeaker(segments) {
|
|||||||
// Normalizes away stray leading/trailing quote marks before comparing, since the
|
// Normalizes away stray leading/trailing quote marks before comparing, since the
|
||||||
// duplicate copy often carries one of those artifacts and an exact string
|
// duplicate copy often carries one of those artifacts and an exact string
|
||||||
// comparison would miss it.
|
// comparison would miss it.
|
||||||
|
// German uses » « for quoted TERMS and titles as well as for speech — "Himgi war
|
||||||
|
// der Überzeugung, daß es sich dabei um »den gewundenen Weg der Krieger und
|
||||||
|
// Häuptlinge« handelte, von dem die Inschriften berichteten." The casting model
|
||||||
|
// reads those marks as a dialogue boundary and cuts the sentence into three
|
||||||
|
// pieces: a narration fragment ending mid-clause, the quoted phrase alone, and a
|
||||||
|
// fragment resuming in lower case. Nothing there is speech, so no attribution
|
||||||
|
// pass can repair it — the sentence simply has to be put back together. Only
|
||||||
|
// merges NARRATION neighbours, and only when the break is grammatically
|
||||||
|
// impossible (no sentence-ending punctuation before, lower-case continuation
|
||||||
|
// after), so a genuine narration→dialogue→narration sequence is never touched.
|
||||||
|
function _audiobookMergeSplitSentences(segments) {
|
||||||
|
const out = [];
|
||||||
|
let merged = 0;
|
||||||
|
for (const s of segments) {
|
||||||
|
const prev = out[out.length - 1];
|
||||||
|
const cur = String(s?.text || '').trim();
|
||||||
|
if (prev && prev.type === 'narration' && s?.type === 'narration' && cur) {
|
||||||
|
const prevText = String(prev.text || '').trimEnd();
|
||||||
|
const endsOpen = prevText && !/[.!?:;…»"'\)\]]$/.test(prevText);
|
||||||
|
const startsLower = /^[a-zäöüß]/.test(cur);
|
||||||
|
// Never merge across an inquit (", flüsterte der Geist,") — that is a
|
||||||
|
// SPLIT QUOTE whose second half is really dialogue; fusing it into
|
||||||
|
// narration would bury a line the attribution passes could still fix.
|
||||||
|
const prevIsInquit = new RegExp(_AB_INQUIT_VERBS + "[^.!?]{0,40},\\s*$", 'i').test(prevText);
|
||||||
|
if (endsOpen && startsLower && !prevIsInquit) {
|
||||||
|
prev.text = prevText + ' ' + cur;
|
||||||
|
merged++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out.push(s);
|
||||||
|
}
|
||||||
|
return { segments: out, merged };
|
||||||
|
}
|
||||||
|
|
||||||
function _audiobookDedupNearbyDuplicates(segments) {
|
function _audiobookDedupNearbyDuplicates(segments) {
|
||||||
const QUOTE_CHARS = '»«„"‘’›‹';
|
const QUOTE_CHARS = '»«„"‘’›‹';
|
||||||
const stripRe = new RegExp(`(^[${QUOTE_CHARS}\\s]+)|([${QUOTE_CHARS}\\s]+$)`, 'g');
|
const stripRe = new RegExp(`(^[${QUOTE_CHARS}\\s]+)|([${QUOTE_CHARS}\\s]+$)`, 'g');
|
||||||
@ -3868,6 +3914,7 @@ ABSOLUTE REGELN:
|
|||||||
- Erfinde NIEMALS Text. Lasse NIEMALS Wörter weg. Füge NIEMALS etwas hinzu.
|
- Erfinde NIEMALS Text. Lasse NIEMALS Wörter weg. Füge NIEMALS etwas hinzu.
|
||||||
- Mische NIEMALS Narration und Dialog in einem Segment.
|
- Mische NIEMALS Narration und Dialog in einem Segment.
|
||||||
- Sei konservativ: ändere eine Zeile nur, wenn du nach dieser Prüfung wirklich zu einem ANDEREN Ergebnis kommst als naheliegend wäre — nicht jede Zeile muss sich ändern.`;
|
- Sei konservativ: ändere eine Zeile nur, wenn du nach dieser Prüfung wirklich zu einem ANDEREN Ergebnis kommst als naheliegend wäre — nicht jede Zeile muss sich ändern.`;
|
||||||
|
window._abVerificationPrompt = verificationPrompt;
|
||||||
const runVerificationPass = () => {
|
const runVerificationPass = () => {
|
||||||
// Deliberately NOT a copy of the first-pass attribution prompt. Pass 1
|
// Deliberately NOT a copy of the first-pass attribution prompt. Pass 1
|
||||||
// classifies from scratch; this pass receives a segment that ALREADY
|
// classifies from scratch; this pass receives a segment that ALREADY
|
||||||
@ -4180,20 +4227,21 @@ ABSOLUTE REGELN:
|
|||||||
const castMenu = foot.querySelector('#ab-cv-menu-cast');
|
const castMenu = foot.querySelector('#ab-cv-menu-cast');
|
||||||
const viewCastMenu = foot.querySelector('#ab-cv-menu-viewcast');
|
const viewCastMenu = foot.querySelector('#ab-cv-menu-viewcast');
|
||||||
identifyMenu?.addEventListener('click', () => _abToggleFootMenu(identifyMenu, [
|
identifyMenu?.addEventListener('click', () => _abToggleFootMenu(identifyMenu, [
|
||||||
{ icon: 'mdi-auto-fix', label: 'Auto-repair cast', title: 'Runs the repair sequence automatically: fix mislabeled dialogue, resolve unknown speakers, repeat while it helps, then a full verification only if still needed', onClick: () => audiobookAutoRepairCast() },
|
{ icon: 'mdi-refresh', label: '1. Identify all characters', title: 'Scan the text and build the cast list from scratch', onClick: runIdentifyAll, danger: true },
|
||||||
{ icon: 'mdi-refresh', label: 'Identify all characters', title: 'Scan the text and build the cast list from scratch', onClick: runIdentifyAll, danger: true },
|
{ icon: 'mdi-auto-fix', label: '2. Auto-repair cast (2.1–2.5)', title: 'Runs the repair sequence automatically: fix mislabeled dialogue, resolve unknown speakers, repeat while it helps, then a full verification only if still needed', onClick: () => audiobookAutoRepairCast() },
|
||||||
{ icon: 'mdi-comment-alert-outline', label: 'Repair mislabeled dialogue', title: 'Finds spoken lines that were typed as narration (usually because the » « quote marks were lost in extraction) and re-checks only those — far cheaper than verifying every narration line', onClick: runRepairNarration },
|
{ icon: 'mdi-comment-alert-outline', label: '2.1 Repair mislabeled dialogue', title: 'Finds spoken lines that were typed as narration (usually because the » « quote marks were lost in extraction) and re-checks only those — far cheaper than verifying every narration line', onClick: runRepairNarration },
|
||||||
{ icon: 'mdi-account-question-outline', label: 'Identify unknown characters', title: 'Re-scan only the unknown segments with the current prompt', onClick: runIdentifyUnknown },
|
{ icon: 'mdi-account-question-outline', label: '2.2 Identify unknown characters', title: 'Re-scan only the unknown segments with the current prompt', onClick: runIdentifyUnknown },
|
||||||
{ icon: 'mdi-repeat', label: 'Run until < N unknown…', title: 'Repeats recast-unknown + narrator-verify passes automatically until the Unknown-speaker count drops below a target, or progress stalls', onClick: () => {
|
{ icon: 'mdi-repeat', label: '2.3 Run until < N unknown…', title: 'Repeats recast-unknown + narrator-verify passes automatically until the Unknown-speaker count drops below a target, or progress stalls', onClick: () => {
|
||||||
const input = window.prompt('Stop once fewer than this many Unknown speakers remain:', '10');
|
const input = window.prompt('Stop once fewer than this many Unknown speakers remain:', '10');
|
||||||
if (input == null) return;
|
if (input == null) return;
|
||||||
const threshold = parseInt(input, 10);
|
const threshold = parseInt(input, 10);
|
||||||
if (!Number.isFinite(threshold) || threshold < 0) { toast('Enter a whole number of 0 or more', 'error'); return; }
|
if (!Number.isFinite(threshold) || threshold < 0) { toast('Enter a whole number of 0 or more', 'error'); return; }
|
||||||
audiobookRecastUntilThreshold(threshold);
|
audiobookRecastUntilThreshold(threshold);
|
||||||
} },
|
} },
|
||||||
{ icon: 'mdi-shield-check-outline', label: 'Verify all characters', title: 'Second-pass plausibility check that keeps the existing cast and only corrects uncertain matches', onClick: runVerificationPass },
|
{ icon: 'mdi-shield-check-outline', label: '2.4 Verify all characters', title: 'Second-pass plausibility check that keeps the existing cast and only corrects uncertain matches', onClick: runVerificationPass },
|
||||||
{ icon: 'mdi-account-search-outline', label: 'Check voice consistency', title: 'Third-pass check: gathers every line already credited to each character across the whole book and flags any that don’t match their established voice', onClick: runConsistencyPass },
|
{ icon: 'mdi-account-search-outline', label: '2.5 Check voice consistency', title: 'Third-pass check: gathers every line already credited to each character across the whole book and flags any that don’t match their established voice', onClick: runConsistencyPass },
|
||||||
]));
|
]));
|
||||||
|
window._abRunConsistencyPass = runConsistencyPass;
|
||||||
castMenu?.addEventListener('click', () => _abToggleFootMenu(castMenu, [
|
castMenu?.addEventListener('click', () => _abToggleFootMenu(castMenu, [
|
||||||
{ icon: 'mdi-account-multiple-plus-outline', label: 'Cast all character roles', title: 'Generate / refresh the character sheets for every cast character', onClick: runCastAll },
|
{ icon: 'mdi-account-multiple-plus-outline', label: 'Cast all character roles', title: 'Generate / refresh the character sheets for every cast character', onClick: runCastAll },
|
||||||
{ icon: 'mdi-account-arrow-right-outline', label: 'Continue uncasted characters', title: 'Only generate profiles for characters with no detail yet — skips anyone already fully cast', onClick: runCastContinueUncasted },
|
{ icon: 'mdi-account-arrow-right-outline', label: 'Continue uncasted characters', title: 'Only generate profiles for characters with no detail yet — skips anyone already fully cast', onClick: runCastContinueUncasted },
|
||||||
@ -4367,7 +4415,14 @@ async function audiobookRecastUnknown(overrideUrl, overrideModel, options = {})
|
|||||||
if (overrideUrl && typeof overrideUrl !== 'string') overrideUrl = null;
|
if (overrideUrl && typeof overrideUrl !== 'string') overrideUrl = null;
|
||||||
const llm_url = overrideUrl || audiobookLlmUrl(), language = audiobookLang();
|
const llm_url = overrideUrl || audiobookLlmUrl(), language = audiobookLang();
|
||||||
let model = audiobookSafeLlmModel(overrideModel || audiobookLlmModel());
|
let model = audiobookSafeLlmModel(overrideModel || audiobookLlmModel());
|
||||||
const promptOverride = typeof options.prompt === 'string' ? options.prompt : null;
|
// repairNarration exists specifically to re-judge narration that may really be
|
||||||
|
// speech — the verification prompt is the one written for that question, so it
|
||||||
|
// is the right default here. Previously only the menu button passed it, so the
|
||||||
|
// same pass behaved differently when called from auto-repair or the console.
|
||||||
|
let promptOverride = typeof options.prompt === 'string' ? options.prompt : null;
|
||||||
|
if (!promptOverride && options.repairNarration && typeof window._abVerificationPrompt === 'string') {
|
||||||
|
promptOverride = window._abVerificationPrompt;
|
||||||
|
}
|
||||||
|
|
||||||
const groups = audiobookRecastGroups(unknownIdxs, segs);
|
const groups = audiobookRecastGroups(unknownIdxs, segs);
|
||||||
const view = audiobookCastView(unknownIdxs.length, llm_url, model);
|
const view = audiobookCastView(unknownIdxs.length, llm_url, model);
|
||||||
@ -4649,9 +4704,17 @@ async function audiobookAutoRepairCast(threshold = 10, maxRounds = 4) {
|
|||||||
if (now >= prev) break; // no further progress — stop burning GPU time
|
if (now >= prev) break; // no further progress — stop burning GPU time
|
||||||
prev = now;
|
prev = now;
|
||||||
}
|
}
|
||||||
// Still above target: fall back to the exhaustive sweep, once.
|
// 2.4 — still above target: fall back to the exhaustive sweep, once.
|
||||||
toast(`Still ${prev} unknown — running full verification pass…`, 'info');
|
toast(`2.4 Still ${prev} unknown — running full verification pass…`, 'info');
|
||||||
await audiobookRecastUnknown(null, null, { includeNarrator: true });
|
await audiobookRecastUnknown(null, null, { includeNarrator: true });
|
||||||
|
const afterVerify = countUnknown();
|
||||||
|
// 2.5 — consistency is a different kind of check (it re-judges lines already
|
||||||
|
// attributed), so it runs last, once attributions have settled, and only if
|
||||||
|
// the passes above didn't leave the cast in a worse state than they found it.
|
||||||
|
if (typeof window._abRunConsistencyPass === 'function' && !_audiobook.cancel) {
|
||||||
|
toast('2.5 Checking voice consistency…', 'info');
|
||||||
|
try { await window._abRunConsistencyPass(); } catch (e) { console.warn('[auto-repair 2.5]', e); }
|
||||||
|
}
|
||||||
const final = countUnknown();
|
const final = countUnknown();
|
||||||
toast(final <= threshold
|
toast(final <= threshold
|
||||||
? `Auto-repair done: ${final} unknown left.`
|
? `Auto-repair done: ${final} unknown left.`
|
||||||
@ -5133,7 +5196,9 @@ async function audiobookCast(overrideUrl, overrideModel, resume) {
|
|||||||
// so a narration row that's about to be merged away doesn't carry a
|
// so a narration row that's about to be merged away doesn't carry a
|
||||||
// misplaced guillemet into its neighbour first.
|
// misplaced guillemet into its neighbour first.
|
||||||
const _quoteFixedSegs = _audiobookFixOrphanedQuoteMarks(allSegments);
|
const _quoteFixedSegs = _audiobookFixOrphanedQuoteMarks(allSegments);
|
||||||
const _mergedSegs = _audiobookMergeAdjacentSameSpeaker(_quoteFixedSegs);
|
const { segments: _rejoined, merged: _mergedCount } = _audiobookMergeSplitSentences(_quoteFixedSegs);
|
||||||
|
if (_mergedCount) console.info('[cast] rejoined', _mergedCount, 'sentence(s) split by quoted terms');
|
||||||
|
const _mergedSegs = _audiobookMergeAdjacentSameSpeaker(_rejoined);
|
||||||
allSegments.length = 0;
|
allSegments.length = 0;
|
||||||
allSegments.push(..._mergedSegs);
|
allSegments.push(..._mergedSegs);
|
||||||
_audiobook.segments = allSegments;
|
_audiobook.segments = allSegments;
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user