diff --git a/CHANGELOG.md b/CHANGELOG.md index c981406..bc0c85f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 ### Fixed diff --git a/VERSION b/VERSION index 5771ee1..ebc03cf 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.12.91 +1.12.92 diff --git a/config/audiobook_prompt.backup.txt b/config/audiobook_prompt.backup.txt new file mode 100644 index 0000000..c9011c4 --- /dev/null +++ b/config/audiobook_prompt.backup.txt @@ -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. \ No newline at end of file diff --git a/routes/conversation.py b/routes/conversation.py index dff3627..a130425 100644 --- a/routes/conversation.py +++ b/routes/conversation.py @@ -961,6 +961,25 @@ async def attribute_dialogue(request: Request): if not isinstance(prompt_override, str): prompt_override = data.get("prompt") 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(): base_prompt = ( "You attribute dialogue in prose fiction for a multi-voice audiobook. " diff --git a/static/index.html b/static/index.html index 91af6c8..f218b7f 100644 --- a/static/index.html +++ b/static/index.html @@ -10,7 +10,7 @@ - + @@ -27,7 +27,7 @@ - + @@ -365,7 +365,7 @@ window.toggleNavTree = function(treeId, chevronId) { - + diff --git a/static/js/audiobook.js b/static/js/audiobook.js index 99dae51..3f894c5 100644 --- a/static/js/audiobook.js +++ b/static/js/audiobook.js @@ -1301,7 +1301,7 @@ STRIKTE FORMAT- UND TEXTREGELN: return [...out]; }; 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) => { if (!rec?.name) return; _hlVer++; @@ -1344,17 +1344,24 @@ STRIKTE FORMAT- UND TEXTREGELN: names.push(n); } 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 = { ver: _hlVer, rosterSize: roster.size, - list: names.map(name => ({ - name, - regex: new RegExp(`\\b(${name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')})\\b`, 'gi'), - })), + regex: pattern ? new RegExp(`\\b(${pattern})\\b`, 'gi') : null, + byLower: new Map(names.map(n => [n.toLowerCase(), n])), }; } - for (const { name, regex } of _hlCache.list) { - html = html.replace(regex, (match) => `${match}`); + if (_hlCache.regex) { + html = html.replace(_hlCache.regex, (match) => { + const canon = _hlCache.byLower.get(match.toLowerCase()) || match; + return `${match}`; + }); } return html; }; @@ -1393,6 +1400,33 @@ STRIKTE FORMAT- UND TEXTREGELN: `; _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 = ` + + `; + _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 _abNavIdx = 0; let _abDetailEl = null; // full profile panel (when Profil is open) @@ -1941,9 +1975,10 @@ STRIKTE FORMAT- UND TEXTREGELN: const _abSyncTopbar = () => { const hasSelection = !_abBar.hidden; const hasHistory = _abUndoStack.length > 0 || _abRedoStack.length > 0; - const hasPages = !_abPageNav.hidden; _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 = () => { 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'; 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 { isNarrator, speakerName } = _abRowSpeaker(s); const c = colorFor(speakerName); 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.innerHTML = ` diff --git a/static/js/characters-library.js b/static/js/characters-library.js index 14df05c..08f1241 100644 --- a/static/js/characters-library.js +++ b/static/js/characters-library.js @@ -80,6 +80,10 @@ function clNormalizeColor(color, name) { const CL_IDENTITY_FIELDS = ['name', 'aliases', 'first_name', 'last_name', 'full_name', 'title']; const CL_ALIAS_MAX_TOKENS = 12; 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 = {}) { 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) .map(x => x.trim()) .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 []; return parts.slice(0, opts.aliases ? CL_ALIAS_MAX_TOKENS : undefined); } diff --git a/static/style.css b/static/style.css index d7998fb..1e38953 100644 --- a/static/style.css +++ b/static/style.css @@ -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 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). */ +/* 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:hover { background: rgba(37,99,235,.14); text-decoration: underline; text-decoration-style: dotted; } .ab-cv-txt { cursor: pointer; }