Split the book into narration/dialogue spans from its own quotation marks and ask the LLM only who speaks each dialogue span (POST /api/attribute-spans), so text can no longer be fused, dropped or duplicated and a novel takes ~10 min instead of ~4 h. Book 1 vs the hand-corrected reference: every-word-right 87.0% -> 92.3%, type agreement 93.6% -> 97.4%; Books 2/3 (not tuned on) 90.8% / 89.9%. - static/js/audiobook-spans.js (pure, unit-tested) + audiobook-cast-spans.js - core/attribution_spans.py, route, tests; scripts/score_attribution.py - default on; localStorage ab-span-mode=0 restores the classic engine - architecture/audiobook-attribution.md, Manual, CHANGELOG 1.32.0, TODO Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
69 lines
3.5 KiB
JavaScript
69 lines
3.5 KiB
JavaScript
// Unit tests for the deterministic quote-span segmenter. Run: npm run test:unit
|
|
import test from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import { createRequire } from 'node:module';
|
|
const spans = createRequire(import.meta.url)('../static/js/audiobook-spans.js');
|
|
|
|
const filler = Array.from({ length: 6 }, (_, i) => `»Zeile ${i} ist ein Satz.« sagte er.`).join(' ');
|
|
const kinds = (out) => out.map((s) => (s.kind === 'dialogue' ? 'D' : 'N')).join('');
|
|
const strip = (t) => t.replace(/[»«]/g, '').replace(/\s+/g, ' ').trim();
|
|
|
|
test('detects German inward guillemets and ignores text without a quote style', () => {
|
|
assert.equal(spans.detectStyle(filler).name, 'guillemets-inward');
|
|
assert.equal(spans.detectStyle('Kein wörtliche Rede hier. '.repeat(20)), null);
|
|
assert.equal(spans.segmentByQuotes('Nur Erzählung.'), null);
|
|
});
|
|
|
|
test('splits narration and dialogue and keeps every word', () => {
|
|
const text = `${filler} Der Zwerg schneuzte sich. »Na los!« Er ging.`;
|
|
const out = spans.segmentByQuotes(text);
|
|
assert.equal(strip(out.map((s) => s.text).join(' ')), strip(text));
|
|
assert.match(kinds(out), /DN$/);
|
|
assert.equal(out.find((s) => s.text === 'Na los!').kind, 'dialogue');
|
|
assert.equal(out.find((s) => s.text.includes('Der Zwerg')).kind, 'narration');
|
|
});
|
|
|
|
test('a closing mark without its opener still yields the speech before it', () => {
|
|
const text = `${filler} Misira griff nach seiner Hand. Ich liebe dich und will ohne dich nicht leben.« Sie stockte.`;
|
|
const out = spans.segmentByQuotes(text);
|
|
const d = out.find((s) => s.text.startsWith('Ich liebe dich'));
|
|
assert.equal(d.kind, 'dialogue');
|
|
assert.equal(out.find((s) => s.text.includes('Misira')).kind, 'narration');
|
|
});
|
|
|
|
test('a speech continues across paragraphs that are not re-opened', () => {
|
|
const text = `${filler} Er begann: »Erster Absatz der Rede ist hier.\n\nZweiter Absatz ohne Anführungszeichen.\n\nDritter Absatz endet.« Dann schwieg er.`;
|
|
const out = spans.segmentByQuotes(text);
|
|
const d = out.find((s) => s.text.includes('Zweiter Absatz'));
|
|
assert.equal(d.kind, 'dialogue');
|
|
assert.ok(d.text.includes('Dritter Absatz endet.'));
|
|
assert.equal(out.find((s) => s.text.includes('Dann schwieg')).kind, 'narration');
|
|
});
|
|
|
|
test('a quoted term inside a sentence is not speech', () => {
|
|
const text = `${filler} Himgi war überzeugt, daß es der »gewundene Weg der Krieger« sei, von dem die Schriften sprachen.`;
|
|
const out = spans.segmentByQuotes(text);
|
|
assert.ok(!out.some((s) => s.kind === 'dialogue' && s.text.includes('gewundene')));
|
|
});
|
|
|
|
test('windows keep spans whole, stay near the target size and split on paragraph gaps', () => {
|
|
const para = `${filler}\n\n`;
|
|
const text = para.repeat(12);
|
|
const out = spans.segmentByQuotes(text);
|
|
const wins = spans.buildWindows(out, text, { targetChars: 300, hardChars: 500 });
|
|
assert.equal(wins.flatMap((w) => w.spans).length, out.length);
|
|
assert.ok(wins.length > 3);
|
|
assert.ok(wins.every((w) => w.spans.reduce((a, s) => a + s.text.length, 0) <= 500 + 200));
|
|
assert.ok(wins.every((w) => w.hasDialogue === w.spans.some((s) => s.kind === 'dialogue')));
|
|
});
|
|
|
|
test('very long spans are abbreviated for the LLM but kept whole in text', () => {
|
|
const long = 'Wort '.repeat(600);
|
|
const text = `${filler} ${long}\n\n»Kurz.« sagte er.`;
|
|
const out = spans.segmentByQuotes(text);
|
|
const wins = spans.buildWindows(out, text);
|
|
const sp = wins.flatMap((w) => w.spans).find((s) => s.text.length > 2000);
|
|
assert.ok(sp.llmText.length < 1000 && sp.llmText.includes('[...]'));
|
|
assert.equal(sp.text.length > 2000, true);
|
|
});
|