Guarantee attribution rules server-side, fix highlight corruption (v1.12.92)

The saved casting prompt was the 2nd-quality verification prompt, so
first-pass attribution ran with the wrong job description and none of
the deduction rules — and the client-side prompt migration had no
anchor to upgrade. The server now appends the rules to any prompt
lacking them, and the saved prompt was reset to the default (backed up
to config/audiobook_prompt.backup.txt). Also: single-pass combined name
regex (a shorter alias could match inside a longer name's data-name
attribute and leak raw style="..." into the feed), stopword filter so a
comma-split alias like "Die, die den Vampir verließ" can't underline
every article, heading-like narration renders bold/centered, and A-/A+
font controls in the casting toolbar sharing the Rehearser Stage scale.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
mARTin-B78 2026-07-04 18:19:01 +02:00
parent c15a91d229
commit 3c3c209d3a
8 changed files with 135 additions and 15 deletions

View File

@ -9,6 +9,19 @@ Follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) · versioned wi
--- ---
## [1.12.92] — 2026-07-04
### Fixed
- **Deduction rules never reached the LLM when a custom prompt was saved** — the saved casting prompt turned out to be the 2nd-Quality-Run *verification* prompt (pasted into the Prompt panel at some point), which tells the model it's reviewing an existing segmentation instead of doing first-pass attribution, and contains none of the speaker-deduction rules; the client-side prompt auto-upgrade found no anchor text in it and silently skipped. Two fixes: the server now appends the deduction rules to any prompt that lacks them (attribution quality no longer depends on prompt history), and the misapplied saved prompt was reset to the shipped default (backed up to `config/audiobook_prompt.backup.txt`).
- **Every "die"/"der" underlined as a character name** — a descriptive alias like "Die, die den Vampir verließ" gets comma-split into tokens, and the bare article "Die" became an alias that matched every article in the book. Bare articles/pronouns are now filtered out of alias token lists.
- **Raw `style="…"` HTML leaking into casting text** — name-highlighting replaced name-by-name over HTML that already contained the injected highlight spans, so a shorter alias could match *inside* a longer name's `data-name` attribute and corrupt the markup. All names are now matched in a single combined pass that never re-scans its own output.
### Added
- **Chapter headings render as headings in the casting feed** — short standalone narration that reads like a title ("Prolog", "1. Kapitel", an OCR'd headline band) is now bold, larger, and centered on the paper page.
- **Font-size controls in the casting toolbar** — A/A+ buttons scale the paper pages' text; the size is shared with the Script Rehearser Stage (same persisted scale), so one reading size applies across both screens.
---
## [1.12.91] — 2026-07-04 ## [1.12.91] — 2026-07-04
### Fixed ### Fixed

View File

@ -1 +1 @@
1.12.91 1.12.92

View File

@ -0,0 +1,28 @@
Du bist ein Qualitätsprüfer für die Analyse eines deutschen Hörbuchs. Eine erste KI hat den Textauszug bereits in Segmente unterteilt und jedem Segment einen Sprecher zugewiesen. Deine Aufgabe ist es JEDEN Sprecher-Zuweisung kritisch zu überprüfen und zu korrigieren.
AUFGABE (2. Qualitätslauf — Verifizierung):
1. ÜBERPRÜFE jede Sprecher-Zuweisung: Ist der angegebene Sprecher wirklich derjenige, der spricht? Nutze den Kontext (Inquit-Formeln wie "sagte X", "fragte sie", "rief er", Handlungsbeschreibungen, das Ping-Pong-Prinzip zwischen Sprechern, und den Gesamtkontext der Szene).
2. KORRIGIERE falsch zugewiesene Sprecher mit dem korrekten Namen direkt aus dem Text.
3. LÖSE alle 'Unknown'-Segmente auf: Nutze umgebenden Kontext, die Reihenfolge der Sprecher und textliche Hinweise. 'Unknown' ist NUR dann erlaubt, wenn der Sprecher absolut nicht bestimmbar ist.
4. BEHALTE alle korrekten Zuweisungen exakt unverändert bei.
DEFINITION VON DIALOG (KRITISCH):
Text ist NUR 'dialogue', wenn er in Anführungszeichen steht (»...«, „...", "...", «...», <<...>>) oder mit einem Gedankenstrich (—) beginnt. ALLES ANDERE ist 'narration' (speaker: 'Narrator').
GRAMMATIK-CHECK FÜR NARRATION:
- Inquit-Formeln / Sprecher-Tags sind narration, niemals dialogue: finite Sprechverben wie sagte, fragte, rief, entgegnete, murmelte, flüsterte, schrie, antwortete + Subjekt/Pronomen/Name.
- Beispiele: "murmelte er mit erstickter Stimme.", ", entgegnete Marcian kalt.", "fragte Uriens leise." sind Narrator/narration.
- Action Beats sind narration: blickte, ging, schwieg, lachte, hob die Hand, wandte sich ab, usw.
- Nur die tatsächlich gesprochenen Wörter innerhalb der Anführungszeichen bleiben dialogue; alle grammatischen Rahmen- und Berichtssätze sind narration.
FÜR JEDES SEGMENT AUSGABE:
- speaker: 'Narrator' für Narration, oder EXAKT der Name des Charakters.
- type: 'narration' oder 'dialogue'
- text: EXAKT der WORTWÖRTLICHE Originaltext — KEINE Änderungen, KEINE Auslassungen, KEINE Ergänzungen.
- emotion: Bei Dialogen 1-2 deutsche Wörter für den Tonfall. Bei Narration leer ('').
ABSOLUTE REGELN:
- Alle Segmente zusammen MÜSSEN den Originaltext exakt, lückenlos und wortgetreu rekonstruieren.
- PDF-/OCR-SCHUTZ: Ein fehlendes » oder « darf niemals bewirken, dass die restliche Passage als Dialog markiert wird. Bei einem offenen »-Zitat vor einer Inquit-Formel oder Erzählerhandlung endet der Dialog am ersten plausiblen Satzende (? ! .). Bei einem einzelnen schließenden « nach einem kurzen Satz ist dieser Satz davor der Dialog.
- Erfinde NIEMALS Text. Lasse NIEMALS Wörter weg. Füge NIEMALS etwas hinzu.
- Mische NIEMALS Narration und Dialog in einem Segment.

View File

@ -961,6 +961,25 @@ async def attribute_dialogue(request: Request):
if not isinstance(prompt_override, str): if not isinstance(prompt_override, str):
prompt_override = data.get("prompt") prompt_override = data.get("prompt")
base_prompt = (prompt_override if isinstance(prompt_override, str) else "") or _settings.get("audiobook_prompt") or "" base_prompt = (prompt_override if isinstance(prompt_override, str) else "") or _settings.get("audiobook_prompt") or ""
# A user-saved custom prompt can predate (or have replaced) the deduction
# rules that cut down false Unknown/Narrator attributions — the client-side
# prompt migration only upgrades prompts still containing the original
# anchor text. Guarantee the rules ride along regardless of what prompt is
# in play, so attribution quality never silently depends on prompt history.
if base_prompt.strip() and "Doppelpunkt-Regel" not in base_prompt:
base_prompt += (
"\n\nANALYSE-REGELN FÜR DIE ZUORDNUNG DES SPRECHERS (zusätzlich, immer anwenden):\n"
"1. Pronomen (er/sie) IMMER zum zuletzt genannten Charakter passenden Geschlechts auflösen.\n"
"2. Doppelpunkt-Regel: Endet ein Erzählersatz mit \":\", spricht dessen Subjekt das folgende Zitat "
"(z.B. \"Dann richtete er sich auf und rief in die Runde:\" → der zuvor genannte Charakter spricht).\n"
"3. Nachgestellte Zuordnung: Der Erzählersatz NACH einem Zitat verrät oft den Sprecher — auch bei "
"unpersönlicher Formel (\"»Was machst du da?« ertönte es über ihm. Karyla war herübergekommen.\" → Karyla sprach).\n"
"4. Adressaten-Regel: \"X wandte sich an Y\" / \"X sah Y an\" → X spricht das nächste Zitat, Y antwortet.\n"
"5. Ping-Pong: Zwei Personen im Gespräch wechseln sich strikt ab, auch über viele Zitate ohne Tags. "
"In einer Zwei-Personen-Szene ist 'Unknown' fast immer falsch.\n"
"6. Rollenbezeichnungen sind gültige Sprecher ('Ork', 'Der Fremde', 'Nachbar', 'Wächter') — nutze sie statt 'Unknown'.\n"
"7. 'Unknown' NUR, wenn eine Zuordnung trotz aller Regeln absolut unmöglich ist."
)
if not base_prompt.strip(): if not base_prompt.strip():
base_prompt = ( base_prompt = (
"You attribute dialogue in prose fiction for a multi-voice audiobook. " "You attribute dialogue in prose fiction for a multi-voice audiobook. "

View File

@ -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.12.91"> <meta name="app-version" content="1.12.92">
<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.12.91"> <link rel="stylesheet" href="/static/style.css?v=1.12.92">
<!-- ── Flag icons — non-blocking (loaded async, icons appear after JS) ── --> <!-- ── Flag icons — non-blocking (loaded async, icons appear after JS) ── -->
@ -365,7 +365,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.12.91"></script> <script src="/static/loader.js?v=1.12.92"></script>
</body> </body>
</html> </html>

View File

@ -1301,7 +1301,7 @@ STRIKTE FORMAT- UND TEXTREGELN:
return [...out]; return [...out];
}; };
let _hlVer = 0; // bumped whenever character records change let _hlVer = 0; // bumped whenever character records change
let _hlCache = { ver: -1, rosterSize: -1, list: [] }; let _hlCache = { ver: -1, rosterSize: -1, regex: null, byLower: new Map() };
const registerCharacterRecord = (rec) => { const registerCharacterRecord = (rec) => {
if (!rec?.name) return; if (!rec?.name) return;
_hlVer++; _hlVer++;
@ -1344,17 +1344,24 @@ STRIKTE FORMAT- UND TEXTREGELN:
names.push(n); names.push(n);
} }
names.sort((a, b) => b.length - a.length); names.sort((a, b) => b.length - a.length);
// ONE combined alternation (longest-first) applied in a single pass —
// replacing name-by-name re-scanned HTML that already contained the
// injected spans, so a shorter alias ("Larissa") could match inside a
// longer name's data-name attribute ("Schwester Larissa") and corrupt
// the markup, leaking raw style="..." text into the visible feed.
const pattern = names.map(n => n.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')).join('|');
_hlCache = { _hlCache = {
ver: _hlVer, ver: _hlVer,
rosterSize: roster.size, rosterSize: roster.size,
list: names.map(name => ({ regex: pattern ? new RegExp(`\\b(${pattern})\\b`, 'gi') : null,
name, byLower: new Map(names.map(n => [n.toLowerCase(), n])),
regex: new RegExp(`\\b(${name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')})\\b`, 'gi'),
})),
}; };
} }
for (const { name, regex } of _hlCache.list) { if (_hlCache.regex) {
html = html.replace(regex, (match) => `<span class="ab-name-hit" data-name="${escHtml(name)}" style="border-bottom: 2px solid ${colorFor(name)}; font-weight: 600;">${match}</span>`); html = html.replace(_hlCache.regex, (match) => {
const canon = _hlCache.byLower.get(match.toLowerCase()) || match;
return `<span class="ab-name-hit" data-name="${escHtml(canon)}" style="border-bottom: 2px solid ${colorFor(canon)}; font-weight: 600;">${match}</span>`;
});
} }
return html; return html;
}; };
@ -1393,6 +1400,33 @@ STRIKTE FORMAT- UND TEXTREGELN:
<button class="ab-page-nav-btn ab-page-source" type="button" title="Back to this source page in Reader"><span class="mdi mdi-book-open-page-variant"></span></button>`; <button class="ab-page-nav-btn ab-page-source" type="button" title="Back to this source page in Reader"><span class="mdi mdi-book-open-page-variant"></span></button>`;
_abTopbar.appendChild(_abPageNav); _abTopbar.appendChild(_abPageNav);
// Font-size controls for the feed's paper pages. Shares the Rehearser
// Stage's persisted scale (ttsvc_reh_stage_scale) because .ab-cv-page's
// font-size is already calc(12pt * var(--reh-stage-scale)) from the shared
// paper spec — one reading size across both screens.
const _abFontCtl = document.createElement('div');
_abFontCtl.className = 'ab-font-ctl';
_abFontCtl.innerHTML = `
<button class="ab-page-nav-btn ab-font-dec" type="button" title="Decrease text size">A</button>
<button class="ab-page-nav-btn ab-font-inc" type="button" title="Increase text size">A+</button>`;
_abTopbar.appendChild(_abFontCtl);
const _abApplyFont = () => {
let v = 1;
try { v = parseFloat(localStorage.getItem('ttsvc_reh_stage_scale')) || 1; } catch (_) {}
feed.style.setProperty('--reh-stage-scale', String(v));
};
const _abFontStep = (d) => {
let v = 1;
try { v = parseFloat(localStorage.getItem('ttsvc_reh_stage_scale')) || 1; } catch (_) {}
v = Math.round(Math.min(2, Math.max(0.7, v + d)) * 100) / 100;
try { localStorage.setItem('ttsvc_reh_stage_scale', String(v)); } catch (_) {}
_abApplyFont();
if (typeof rehApplyStageFont === 'function') rehApplyStageFont(); // keep the Rehearser Stage in sync too
};
_abFontCtl.querySelector('.ab-font-dec').addEventListener('click', () => _abFontStep(-0.1));
_abFontCtl.querySelector('.ab-font-inc').addEventListener('click', () => _abFontStep(0.1));
_abApplyFont();
let _abCharRows = []; // rows in feed for selected character let _abCharRows = []; // rows in feed for selected character
let _abNavIdx = 0; let _abNavIdx = 0;
let _abDetailEl = null; // full profile panel (when Profil is open) let _abDetailEl = null; // full profile panel (when Profil is open)
@ -1941,9 +1975,10 @@ STRIKTE FORMAT- UND TEXTREGELN:
const _abSyncTopbar = () => { const _abSyncTopbar = () => {
const hasSelection = !_abBar.hidden; const hasSelection = !_abBar.hidden;
const hasHistory = _abUndoStack.length > 0 || _abRedoStack.length > 0; const hasHistory = _abUndoStack.length > 0 || _abRedoStack.length > 0;
const hasPages = !_abPageNav.hidden;
_abEditToolbar.hidden = !hasHistory; _abEditToolbar.hidden = !hasHistory;
_abTopbar.hidden = !(hasSelection || hasHistory || hasPages); // Always visible — it hosts the font-size controls now, not just the
// page nav / selection bar / undo history.
_abTopbar.hidden = false;
}; };
const _abActiveSegments = () => { const _abActiveSegments = () => {
if (_audiobook.running && Array.isArray(_audiobook.liveSegments)) return { key: 'liveSegments', arr: _audiobook.liveSegments }; if (_audiobook.running && Array.isArray(_audiobook.liveSegments)) return { key: 'liveSegments', arr: _audiobook.liveSegments };
@ -1990,11 +2025,23 @@ STRIKTE FORMAT- UND TEXTREGELN:
const isNarrator = s.type !== 'dialogue' || !s.speaker || String(s.speaker).toLowerCase() === 'narrator'; const isNarrator = s.type !== 'dialogue' || !s.speaker || String(s.speaker).toLowerCase() === 'narrator';
return { isNarrator, speakerName: isNarrator ? 'Narrator' : s.speaker }; return { isNarrator, speakerName: isNarrator ? 'Narrator' : s.speaker };
}; };
// A narration segment that reads like a heading: short, standalone (a
// paragraph of its own from OCR/paragraph detection), either a chapter-word
// pattern or a few words with no sentence punctuation. Rendered bold,
// larger, and centered so recovered chapter titles look like titles.
const _abIsHeadingSeg = (s) => {
if (s?.type === 'dialogue') return false;
const t = String(s?.text || '').trim();
if (!t || t.length > 60) return false;
if (/^(prolog(ue)?|epilog(ue)?|kapitel|chapter|teil|buch|part|book|akt|szene|scene)\b/i.test(t)) return true;
if (/^\d+[\.\)]?\s*(kapitel|chapter)?$/i.test(t)) return true;
return t.split(/\s+/).length <= 7 && !/[.!?;:,«»"]/.test(t);
};
const _abRowFromSegment = (s, extraClass = '') => { const _abRowFromSegment = (s, extraClass = '') => {
const { isNarrator, speakerName } = _abRowSpeaker(s); const { isNarrator, speakerName } = _abRowSpeaker(s);
const c = colorFor(speakerName); const c = colorFor(speakerName);
const row = document.createElement('div'); const row = document.createElement('div');
row.className = `ab-cv-row${extraClass ? ' ' + extraClass : ''}${isNarrator ? ' is-narr' : ''}`; row.className = `ab-cv-row${extraClass ? ' ' + extraClass : ''}${isNarrator ? ' is-narr' : ''}${_abIsHeadingSeg(s) ? ' is-heading' : ''}`;
row.__seg = s; row.__seg = s;
row.innerHTML = `<span class="ab-cv-row-tools"> row.innerHTML = `<span class="ab-cv-row-tools">
<button class="ab-cv-row-tool ab-cv-edit-text" type="button" title="Edit text"><span class="mdi mdi-pencil-outline"></span></button> <button class="ab-cv-row-tool ab-cv-edit-text" type="button" title="Edit text"><span class="mdi mdi-pencil-outline"></span></button>

View File

@ -80,6 +80,10 @@ function clNormalizeColor(color, name) {
const CL_IDENTITY_FIELDS = ['name', 'aliases', 'first_name', 'last_name', 'full_name', 'title']; const CL_IDENTITY_FIELDS = ['name', 'aliases', 'first_name', 'last_name', 'full_name', 'title'];
const CL_ALIAS_MAX_TOKENS = 12; const CL_ALIAS_MAX_TOKENS = 12;
const CL_ALIAS_MAX_CHARS = 500; const CL_ALIAS_MAX_CHARS = 500;
// Bare articles/pronouns can end up as "aliases" when a descriptive alias like
// "Die, die den Vampir verließ" gets comma-split — a lone "Die" token then
// matches (and underlines) every article in the whole book.
const CL_ALIAS_STOPWORDS = new Set(['die','der','das','den','dem','des','ein','eine','einer','er','sie','es','ich','du','wir','ihr','the','a','an','he','she','it','they','who']);
function clSplitIdentityTokens(v, opts = {}) { function clSplitIdentityTokens(v, opts = {}) {
const raw = _clStr(v); const raw = _clStr(v);
@ -87,7 +91,8 @@ function clSplitIdentityTokens(v, opts = {}) {
.split(/[,;/|]|\baka\b|\baka\.\b|\balias(?:es)?\b|\bgenannt\b|\bnamens\b|\bcalled\b|\bknown as\b/i) .split(/[,;/|]|\baka\b|\baka\.\b|\balias(?:es)?\b|\bgenannt\b|\bnamens\b|\bcalled\b|\bknown as\b/i)
.map(x => x.trim()) .map(x => x.trim())
.filter(Boolean) .filter(Boolean)
.filter(x => x.length <= 80 && !/^needs?:/i.test(x) && !/^complete$/i.test(x)); .filter(x => x.length <= 80 && !/^needs?:/i.test(x) && !/^complete$/i.test(x))
.filter(x => !CL_ALIAS_STOPWORDS.has(x.toLowerCase()));
if (opts.aliases && (raw.length > CL_ALIAS_MAX_CHARS || parts.length > CL_ALIAS_MAX_TOKENS)) return []; if (opts.aliases && (raw.length > CL_ALIAS_MAX_CHARS || parts.length > CL_ALIAS_MAX_TOKENS)) return [];
return parts.slice(0, opts.aliases ? CL_ALIAS_MAX_TOKENS : undefined); return parts.slice(0, opts.aliases ? CL_ALIAS_MAX_TOKENS : undefined);
} }

View File

@ -5331,6 +5331,14 @@ code { background: var(--panel); border-radius: 4px; padding: 1px 5px; font-fami
/* Every word in the narration/dialogue text is individually clickable the /* Every word in the narration/dialogue text is individually clickable the
basis for "click a name in the text to assign", including names the app basis for "click a name in the text to assign", including names the app
doesn't already know (drag across a multi-word one to select it all). */ doesn't already know (drag across a multi-word one to select it all). */
/* Heading-like narration (recovered chapter titles, OCR headline bands):
bold, larger, centered so titles read as titles inside the paper page. */
.ab-cv-row.is-heading .ab-cv-txt {
display: block; text-align: center; font-weight: 700;
font-size: 1.25em; letter-spacing: .02em; padding: 6px 0;
}
.ab-font-ctl { display: flex; align-items: center; gap: 4px; flex: 0 0 auto; }
.ab-font-ctl .ab-page-nav-btn { width: auto; padding: 0 8px; font-size: 12px; font-weight: 700; }
.ab-name-hit { cursor: pointer; border-radius: 2px; } .ab-name-hit { cursor: pointer; border-radius: 2px; }
.ab-name-hit:hover { background: rgba(37,99,235,.14); text-decoration: underline; text-decoration-style: dotted; } .ab-name-hit:hover { background: rgba(37,99,235,.14); text-decoration: underline; text-decoration-style: dotted; }
.ab-cv-txt { cursor: pointer; } .ab-cv-txt { cursor: pointer; }