Pooled over three hand-corrected novels (~54k spoken words): agreed 82% right, disputed 48%, Unknown ~0%, unverified ~76%. The old heuristic bands claimed 16% for lines that were 62% right and tag/role signals did not discriminate in span casts; classic casts keep the old model. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
150 lines
9.6 KiB
JavaScript
150 lines
9.6 KiB
JavaScript
// @ts-check
|
|
// Browser-level checks for span-mode casting: the modules load with the audiobook
|
|
// section, the pure segmenter works in the real page, and the engine switch honours
|
|
// localStorage. The LLM-driven flow itself is covered by the unit tests and the
|
|
// scored regression runs (architecture/audiobook-attribution.md).
|
|
const { test, expect } = require('@playwright/test');
|
|
|
|
async function openReader(page) {
|
|
await page.goto('/', { waitUntil: 'domcontentloaded' });
|
|
await page.waitForSelector('#app-skeleton', { state: 'detached', timeout: 15_000 });
|
|
await page.waitForFunction(() => typeof window.navTo === 'function', null, { timeout: 15_000 });
|
|
await page.evaluate(() => window.navTo('s-reader'));
|
|
await page.waitForFunction(() => typeof window.audiobookCastSpans === 'function', null, { timeout: 15_000 });
|
|
}
|
|
|
|
test('span-mode modules load and segment quoted speech in the page', async ({ page }) => {
|
|
await openReader(page);
|
|
const out = await page.evaluate(() => {
|
|
const filler = Array.from({ length: 6 }, (_, i) => `»Zeile ${i} ist ein Satz.« sagte er.`).join(' ');
|
|
const spans = window.TTVC.audiobook.spans.segmentByQuotes(`${filler} Der Zwerg schneuzte sich. »Na los!« Er ging.`);
|
|
return spans.slice(-3).map((s) => `${s.kind}:${s.text}`);
|
|
});
|
|
expect(out.some((s) => s === 'dialogue:Na los!')).toBe(true);
|
|
expect(out[out.length - 1].startsWith('narration:')).toBe(true);
|
|
});
|
|
|
|
test('span mode is on by default and can be switched off per browser', async ({ page }) => {
|
|
await openReader(page);
|
|
expect(await page.evaluate(() => window.audiobookSpanModeEnabled())).toBe(true);
|
|
await page.evaluate(() => localStorage.setItem('ab-span-mode', '0'));
|
|
expect(await page.evaluate(() => window.audiobookSpanModeEnabled())).toBe(false);
|
|
await page.evaluate(() => localStorage.removeItem('ab-span-mode'));
|
|
});
|
|
|
|
// ── Verify / recast for span casts ───────────────────────────────────────────
|
|
// A cast made in span mode is verified by a second pass plus candidate-restricted adjudication; these
|
|
// tests drive audiobookVerifySpans against a mocked /api/attribute-spans.
|
|
const FILLER = Array.from({ length: 6 }, (_, i) => `»Zeile ${i} ist ein Satz.« sagte er.`).join(' ');
|
|
const BOOK = `${FILLER} Der Zwerg schneuzte sich. »Na los!« sagte Linosch. »Vielen Dank!« Er ging. »Wirklich?« fragte jemand. »Ist das so?« Stille.`;
|
|
|
|
async function seedCast(page, firstPass, book = BOOK) {
|
|
await page.evaluate(([book, first]) => {
|
|
const spans = window.TTVC.audiobook.spans.segmentByQuotes(book);
|
|
window._audiobook.lastText = book;
|
|
window._audiobook.roster = ['Marcian', 'Linosch', 'Alrik'];
|
|
window._audiobook.segments = spans.map((sp) => {
|
|
const id = (sp.kind === 'dialogue' ? 'D' : 'N') + sp.index;
|
|
return sp.kind === 'dialogue'
|
|
? { speaker: first[sp.text] || 'Marcian', type: 'dialogue', text: sp.text, emotion: '', spanId: id }
|
|
: { speaker: 'Narrator', type: 'narration', text: sp.text, emotion: '', spanId: id };
|
|
});
|
|
}, [book, firstPass]);
|
|
}
|
|
|
|
async function mockSpanEndpoint(page, plan) {
|
|
const calls = [];
|
|
await page.route('**/api/attribute-spans', route => {
|
|
const body = route.request().postDataJSON();
|
|
calls.push({ reasoning: !!body.want_reasoning, only: body.attribute_only || null, ids: body.spans.filter(s => s.kind === 'dialogue').map(s => s.id) });
|
|
const table = body.want_reasoning ? plan.reasoning : plan.second;
|
|
const speakers = {};
|
|
for (const s of body.spans) {
|
|
if (s.kind !== 'dialogue') continue;
|
|
if (body.attribute_only && !body.attribute_only.includes(s.id)) continue;
|
|
const name = table[s.text] ?? (body.want_reasoning ? undefined : 'Marcian'); // the second pass answers every line
|
|
if (name !== undefined) speakers[s.id] = { speaker: name, emotion: '' };
|
|
}
|
|
return route.fulfill({ json: { speakers, missing: [], characters: [] } });
|
|
});
|
|
return calls;
|
|
}
|
|
|
|
test('verify is advisory: it marks agreement and disputes but never changes a speaker', async ({ page }) => {
|
|
await openReader(page);
|
|
await seedCast(page, { 'Na los!': 'Linosch', 'Vielen Dank!': 'Alrik', 'Wirklich?': 'Unknown', 'Ist das so?': 'Alrik' });
|
|
const calls = await mockSpanEndpoint(page, { second: { 'Na los!': 'Linosch', 'Vielen Dank!': 'Marcian', 'Wirklich?': 'Alrik', 'Ist das so?': 'Linosch' }, reasoning: {} });
|
|
const handled = await page.evaluate(() => window.audiobookVerifySpans(undefined, undefined, {}));
|
|
expect(handled).toBe(true);
|
|
const rows = await page.evaluate(() => window._audiobook.segments.filter(s => s.type === 'dialogue')
|
|
.map(s => [s.text, s.speaker, s.verified, s.altSpeaker || null, s.confWhy]));
|
|
const by = Object.fromEntries(rows.map(([t, sp, v, alt, why]) => [t, { sp, v, alt, why }]));
|
|
expect(by['Na los!']).toMatchObject({ sp: 'Linosch', v: 'agreed' });
|
|
expect(by['Vielen Dank!']).toMatchObject({ sp: 'Alrik', v: 'disputed', alt: 'Marcian' }); // first answer kept
|
|
expect(by['Ist das so?']).toMatchObject({ sp: 'Alrik', v: 'disputed', alt: 'Linosch' });
|
|
expect(by['Wirklich?']).toMatchObject({ sp: 'Unknown', v: 'unresolved', alt: 'Alrik' }); // never filled in silently
|
|
expect(by['Zeile 0 ist ein Satz.'].v).toBe('agreed');
|
|
expect(by['Vielen Dank!'].why).toContain('two passes disagree (other pass: Marcian)');
|
|
expect(by['Na los!'].why).toContain('two passes agree');
|
|
// span-cast confidence is calibrated on agreement (pooled measurement), not on the old heuristics
|
|
const conf = await page.evaluate(() => Object.fromEntries(window._audiobook.segments.filter(s => s.type === 'dialogue')
|
|
.map(s => [s.text, [s.conf, s.confLabel]])));
|
|
expect(conf['Na los!']).toEqual([82, 'Likely']);
|
|
expect(conf['Vielen Dank!']).toEqual([48, 'Doubtful']);
|
|
expect(conf['Wirklich?']).toEqual([5, 'Guess']);
|
|
// a single no-thinking pass: no reasoning calls, no per-line restriction
|
|
expect(calls.length).toBeGreaterThan(0);
|
|
expect(calls.every(c => !c.reasoning && !c.only)).toBe(true);
|
|
// the text is untouched, every span still present once
|
|
const text = await page.evaluate(() => window._audiobook.segments.map(s => s.text).join(' '));
|
|
expect(text.replace(/[»«]/g, '').replace(/\s+/g, ' ')).toBe(BOOK.replace(/[»«]/g, '').replace(/\s+/g, ' '));
|
|
});
|
|
|
|
test('recast unknown on a span cast only re-asks the Unknown lines, with reasoning', async ({ page }) => {
|
|
await openReader(page);
|
|
// Untagged lines before any speaker is known: the grammar pre-pass cannot settle them, so the model is asked.
|
|
const book = `»Wer ist da?« Wind. »Niemand.« Nacht. ${FILLER}`;
|
|
await seedCast(page, { 'Wer ist da?': 'Unknown', 'Niemand.': 'Unknown' }, book);
|
|
const calls = await mockSpanEndpoint(page, { second: {}, reasoning: { 'Wer ist da?': 'Alrik', 'Niemand.': 'Unknown' } });
|
|
await page.evaluate(() => window.audiobookRecastUnknown(undefined, undefined, {}));
|
|
await expect.poll(async () => page.evaluate(() => window._audiobook.segments.find(s => s.text === 'Wer ist da?').speaker)).toBe('Alrik');
|
|
const speakers = await page.evaluate(() => Object.fromEntries(window._audiobook.segments.filter(s => s.type === 'dialogue').map(s => [s.text, s.speaker])));
|
|
expect(speakers['Niemand.']).toBe('Unknown'); // still unresolved: left alone, never invented
|
|
expect(speakers['Zeile 0 ist ein Satz.']).toBe('Marcian'); // known lines are not re-litigated
|
|
expect(calls.length).toBeGreaterThan(0);
|
|
expect(calls.every(c => c.reasoning)).toBe(true);
|
|
// the two Unknown lines were the only ones ever asked about
|
|
const asked = new Set(calls.flatMap(c => c.only || []));
|
|
const unknownIds = await page.evaluate(() => window._audiobook.segments.filter(s => s.text === 'Wer ist da?' || s.text === 'Niemand.').map(s => s.spanId));
|
|
expect(asked.size).toBeGreaterThan(0);
|
|
expect([...asked].every(id => unknownIds.includes(id))).toBe(true);
|
|
});
|
|
|
|
test('verify leaves the cast untouched when the endpoint fails, and declines a cast it cannot map to the text', async ({ page }) => {
|
|
await openReader(page);
|
|
await seedCast(page, { 'Na los!': 'Linosch', 'Vielen Dank!': 'Alrik' });
|
|
const before = await page.evaluate(() => JSON.stringify(window._audiobook.segments.map(s => [s.spanId, s.speaker])));
|
|
await page.route('**/api/attribute-spans', route => route.fulfill({ status: 503, json: { detail: 'down' } }));
|
|
await page.evaluate(() => window.audiobookVerifySpans(undefined, undefined, {}));
|
|
const after = await page.evaluate(() => JSON.stringify(window._audiobook.segments.map(s => [s.spanId, s.speaker])));
|
|
expect(after).toBe(before);
|
|
// a cast from a different text (or a classic cast without span ids) is declined so the classic path can run
|
|
const declined = await page.evaluate(() => {
|
|
window._audiobook.segments = [{ speaker: 'X', type: 'dialogue', text: 'Hallo', emotion: '' }];
|
|
return window.audiobookVerifySpans(undefined, undefined, {});
|
|
});
|
|
expect(declined).toBe(false);
|
|
});
|
|
|
|
test('an unverified span cast is labelled as such, and a classic cast keeps the old confidence model', async ({ page }) => {
|
|
await openReader(page);
|
|
const out = await page.evaluate(() => {
|
|
const span = [{ speaker: 'Marcian', type: 'dialogue', text: '»Ja.«', emotion: '', spanId: 'D1' }];
|
|
const classic = [{ speaker: 'Marcian', type: 'dialogue', text: '»Ja.«', emotion: '' }];
|
|
window._abStampConfidence(span); window._abStampConfidence(classic);
|
|
return { span: [span[0].conf, span[0].confLabel, span[0].confWhy], classic: [typeof classic[0].conf, classic[0].confWhy.includes('speech tag')] };
|
|
});
|
|
expect(out.span).toEqual([76, 'Probably', 'not verified yet — run Verify to check this line']);
|
|
expect(out.classic[0]).toBe('number');
|
|
});
|