From 75e452fdfcde51bb634b0c4f1b1a7b931a68fc13 Mon Sep 17 00:00:00 2001 From: mARTin-B78 Date: Sat, 19 Sep 2026 20:55:39 +0200 Subject: [PATCH] fix(audiobook): route the Verify menu action to the advisory second pass Co-Authored-By: Claude Sonnet 5 --- CHANGELOG.md | 3 +++ static/js/audiobook-cast.js | 4 ++-- static/js/audiobook-pipeline.js | 2 +- tests/audiobook-spans.spec.js | 14 ++++++++++++++ 4 files changed, 20 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4a8504d..0737e39 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,9 @@ Follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) · versioned wi ### 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`. +### 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 - 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. diff --git a/static/js/audiobook-cast.js b/static/js/audiobook-cast.js index ce2df6d..c077270 100644 --- a/static/js/audiobook-cast.js +++ b/static/js/audiobook-cast.js @@ -2989,7 +2989,7 @@ ABSOLUTE REGELN: // measured against the hand-corrected book it still catches 37 of the // 51 real "dialogue swallowed into narration" cases. A pass that // 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 @@ -3324,7 +3324,7 @@ ABSOLUTE REGELN: if (!Number.isFinite(threshold) || threshold < 0) { toast('Enter a whole number of 0 or more', 'error'); return; } 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 }, // 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 diff --git a/static/js/audiobook-pipeline.js b/static/js/audiobook-pipeline.js index 2a94c30..377b40a 100644 --- a/static/js/audiobook-pipeline.js +++ b/static/js/audiobook-pipeline.js @@ -22,7 +22,7 @@ async function audiobookRecastUnknown(overrideUrl, overrideModel, options = {}) if (_abBusyGuard('Recast / verify')) return; // 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() - && await root.audiobookVerifySpans(overrideUrl, overrideModel, { unknownOnly: !options.includeNarrator })) return; + && await root.audiobookVerifySpans(overrideUrl, overrideModel, { unknownOnly: !(options.includeNarrator || options.verify) })) return; const segs = _audiobook.segments; if (!segs || !segs.length) return; const originalSegments = segs.map(s => ({...s})); diff --git a/tests/audiobook-spans.spec.js b/tests/audiobook-spans.spec.js index 00b6fd4..4a48dff 100644 --- a/tests/audiobook-spans.spec.js +++ b/tests/audiobook-spans.spec.js @@ -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.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'); +});