fix(audiobook): route the Verify menu action to the advisory second pass

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
mARTin-B78 2026-09-19 20:55:39 +02:00
parent ded70c3853
commit 75e452fdfc
4 changed files with 20 additions and 3 deletions

View File

@ -10,6 +10,9 @@ Follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) · versioned wi
### Added ### Added
- **Verify / recast for span-mode casts** (`audiobookVerifySpans`): the app's Recast unknown, 2nd Quality Run and auto-repair entry points now run span-natively for a span cast instead of the classic passes that re-emit text. **Verify is advisory**: one extra differently-windowed pass (~10 min per novel) that never changes a speaker — agreeing lines are marked `agreed`, disagreeing lines keep the first answer, are marked `disputed` and record the other pass's answer as `altSpeaker`; the confidence score reflects both. **Recast unknown** re-asks only the Unknown lines with reasoning and fuller context (`attribute_only` on `/api/attribute-spans`) and leaves any line the model cannot place Unknown. Segments now carry a `spanId`. - **Verify / recast for span-mode casts** (`audiobookVerifySpans`): the app's Recast unknown, 2nd Quality Run and auto-repair entry points now run span-natively for a span cast instead of the classic passes that re-emit text. **Verify is advisory**: one extra differently-windowed pass (~10 min per novel) that never changes a speaker — agreeing lines are marked `agreed`, disagreeing lines keep the first answer, are marked `disputed` and record the other pass's answer as `altSpeaker`; the confidence score reflects both. **Recast unknown** re-asks only the Unknown lines with reasoning and fuller context (`attribute_only` on `/api/attribute-spans`) and leaves any line the model cannot place Unknown. Segments now carry a `spanId`.
### Fixed
- The cast view's **2.4 Verify all characters** now reaches the advisory second pass for span casts (it previously passed only `repairNarration`, which would have run the Unknown-only recast); its tooltip describes what it does for a span cast.
### Changed ### Changed
- Auto-repair no longer runs after a span-mode cast: Unknown lines stay visible for review instead of being guessed at. - Auto-repair no longer runs after a span-mode cast: Unknown lines stay visible for review instead of being guessed at.
- **Span-cast confidence is calibrated on agreement between the two passes** instead of the old heuristics: agreed 82 % (Likely), disputed 48 % (Doubtful), Unknown 5 % (Guess), not yet verified 76 % (Probably) — pooled over three novels (~54 000 spoken words, 2 485 lines with a second opinion). The old bands claimed 16 % for lines that were 62 % right and the tag/role-label signals did not discriminate in span casts. Classic casts keep the old model. Running Verify therefore shrinks the "below 80 %" review queue to the disputed and Unknown lines. - **Span-cast confidence is calibrated on agreement between the two passes** instead of the old heuristics: agreed 82 % (Likely), disputed 48 % (Doubtful), Unknown 5 % (Guess), not yet verified 76 % (Probably) — pooled over three novels (~54 000 spoken words, 2 485 lines with a second opinion). The old bands claimed 16 % for lines that were 62 % right and the tag/role-label signals did not discriminate in span casts. Classic casts keep the old model. Running Verify therefore shrinks the "below 80 %" review queue to the disputed and Unknown lines.

View File

@ -2989,7 +2989,7 @@ ABSOLUTE REGELN:
// measured against the hand-corrected book it still catches 37 of the // measured against the hand-corrected book it still catches 37 of the
// 51 real "dialogue swallowed into narration" cases. A pass that // 51 real "dialogue swallowed into narration" cases. A pass that
// finishes and catches 73% beats one that never finishes at all. // finishes and catches 73% beats one that never finishes at all.
audiobookRecastUnknown(savedChoice.url, savedChoice.model, { prompt: verificationPrompt, repairNarration: true }); audiobookRecastUnknown(savedChoice.url, savedChoice.model, { prompt: verificationPrompt, repairNarration: true, verify: true });
}; };
// 3rd quality pass — deliberately NOT chunk-by-chunk like the two // 3rd quality pass — deliberately NOT chunk-by-chunk like the two
@ -3324,7 +3324,7 @@ ABSOLUTE REGELN:
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: '2.4 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: (_audiobook.castMode === 'spans' ? 'Reads every spoken line a second time with different passage boundaries and flags the lines where the two readings disagree. Never changes a speaker.' : 'Second-pass plausibility check that keeps the existing cast and only corrects uncertain matches'), onClick: runVerificationPass },
{ 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 }, { 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 },
// Scores are normally stamped at the end of casting/recasting, so a cast // Scores are normally stamped at the end of casting/recasting, so a cast
// made before this feature existed carries none. This re-scores in place // made before this feature existed carries none. This re-scores in place

View File

@ -22,7 +22,7 @@ async function audiobookRecastUnknown(overrideUrl, overrideModel, options = {})
if (_abBusyGuard('Recast / verify')) return; if (_abBusyGuard('Recast / verify')) return;
// A span-mode cast keeps its text exactly; every repair/verify entry point re-asks only who speaks. // A span-mode cast keeps its text exactly; every repair/verify entry point re-asks only who speaks.
if (typeof root.audiobookVerifySpans === 'function' && root.audiobookSpanModeEnabled() if (typeof root.audiobookVerifySpans === 'function' && root.audiobookSpanModeEnabled()
&& await root.audiobookVerifySpans(overrideUrl, overrideModel, { unknownOnly: !options.includeNarrator })) return; && await root.audiobookVerifySpans(overrideUrl, overrideModel, { unknownOnly: !(options.includeNarrator || options.verify) })) return;
const segs = _audiobook.segments; const segs = _audiobook.segments;
if (!segs || !segs.length) return; if (!segs || !segs.length) return;
const originalSegments = segs.map(s => ({...s})); const originalSegments = segs.map(s => ({...s}));

View File

@ -147,3 +147,17 @@ test('an unverified span cast is labelled as such, and a classic cast keeps the
expect(out.span).toEqual([76, 'Probably', 'not verified yet — run Verify to check this line']); expect(out.span).toEqual([76, 'Probably', 'not verified yet — run Verify to check this line']);
expect(out.classic[0]).toBe('number'); expect(out.classic[0]).toBe('number');
}); });
test('the "Verify all characters" menu action runs the advisory second pass, not the Unknown-only recast', async ({ page }) => {
await openReader(page);
await seedCast(page, { 'Na los!': 'Linosch' });
const calls = await mockSpanEndpoint(page, { second: { 'Na los!': 'Linosch' }, reasoning: {} });
// the menu handler passes { repairNarration: true, verify: true } (static/js/audiobook-cast.js runVerificationPass)
await page.evaluate(() => window.audiobookRecastUnknown(undefined, undefined, { repairNarration: true, verify: true }));
await expect.poll(async () => page.evaluate(() => window._audiobook.segments.find(s => s.text === 'Na los!').verified)).toBe('agreed');
expect(calls.length).toBeGreaterThan(0);
expect(calls.every(c => !c.reasoning && !c.only)).toBe(true);
// the plain repair entry (auto-repair / Identify unknown) still means Unknown-only
const src = await page.evaluate(() => fetch('/static/js/audiobook-cast.js').then(r => r.text()));
expect(src).toContain('repairNarration: true, verify: true');
});