feat(audiobook): span-mode casting - deterministic quote spans, LLM only names speakers
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>
This commit is contained in:
parent
e3d6cb2cca
commit
e7cb92c572
26
CHANGELOG.md
26
CHANGELOG.md
@ -5,6 +5,32 @@ Follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) · versioned wi
|
||||
|
||||
---
|
||||
|
||||
## [1.32.0] — 2026-09-19
|
||||
|
||||
### Added
|
||||
- **Span-mode audiobook casting.** Structure now comes from the book's own quotation marks (`static/js/audiobook-spans.js`, deterministic, pure, unit-tested): narration and dialogue spans keep the exact source text, and the LLM is asked only *who speaks each dialogue span* via the new `POST /api/attribute-spans` (`core/attribution_spans.py`) — it can no longer fuse, drop or duplicate text, and answers with a few tokens per line instead of re-emitting the passage. `audiobookCast()` dispatches to it by default (`static/js/audiobook-cast-spans.js`); `localStorage['ab-span-mode']='0'` restores the classic engine, and a book with no recognisable quote style falls back to it automatically. Drafts now record which engine made them (`mode`) so a resume never mixes window indices.
|
||||
- Handles the extraction damage seen in real books: closing marks whose opener was lost, openers whose closer was lost, multi-paragraph speeches that are not re-opened per paragraph, and quoted terms inside sentences.
|
||||
- **`scripts/score_attribution.py`**: word-aligned, alias-tolerant scoring of a cast against a hand-corrected one (coverage, narration/dialogue agreement, speaker accuracy, confusions). Insensitive to how narration is chunked, unlike `scripts/compare_casts.py`'s exact-text matching that dominated the old "fully correct" figure.
|
||||
- `architecture/audiobook-attribution.md` documents the design, contract, measurements and rollback.
|
||||
- `npm run test:unit` (Node's built-in runner) and `tests/test_attribution_spans.py`.
|
||||
|
||||
### Changed
|
||||
- The attribution prompt asks for the label the text uses when a person is only known by role ("Der Fremde", "Baronin") instead of forcing another known character's name; role → name resolution happens book-wide in the existing alias passes. Thinking is disabled for this lookup (≈25× faster on the same windows, no measurable accuracy difference).
|
||||
|
||||
### Measured (same scorer, same hand-corrected references)
|
||||
| | classic Run 3 | span mode |
|
||||
|---|---|---|
|
||||
| Book 1: narration/dialogue agreement | 93.6 % | 97.4 % |
|
||||
| Book 1: dialogue words right speaker (tolerant) | 60.6 % | 75.4 % |
|
||||
| Book 1: every word right (tolerant) | 87.0 % | 92.2 % |
|
||||
| Book 1: wall time | ≈ 4 h | ≈ 11 min |
|
||||
| Book 2 (not tuned on) every word right | – | 90.8 % |
|
||||
| Book 3 (not tuned on) every word right | – | 89.9 % |
|
||||
|
||||
The references themselves contain label noise (inline narration folded into dialogue, inconsistent long monologues, some wrong speakers); differences of 1–2 points are not meaningful.
|
||||
|
||||
---
|
||||
|
||||
## [1.31.0] — 2026-09-16
|
||||
|
||||
### Fixed
|
||||
|
||||
46
Manual.md
46
Manual.md
@ -8,6 +8,52 @@ Living manual. Every feature updates this file in the same commit as the code.
|
||||
|
||||
---
|
||||
|
||||
## Audiobook casting (Read Aloud → Cast as Audiobook)
|
||||
|
||||
### Purpose
|
||||
|
||||
Turn a book into a script with one narrator and one voice per character, ready for
|
||||
the Rehearser and MP3 export.
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- A book open in Read Aloud (PDF or text) and an LLM configured in Settings.
|
||||
|
||||
### Step by step
|
||||
|
||||
1. Open the book, then **Cast as Audiobook**.
|
||||
2. The app finds the quotation marks in the text and splits it into narration and
|
||||
spoken lines by itself — this part never changes a word of the book. The LLM is
|
||||
then asked only *who speaks* each spoken line, in passages of a few thousand
|
||||
characters. A whole novel takes roughly ten minutes (the previous method took
|
||||
hours).
|
||||
3. Progress shows per passage; **Stop Casting** keeps what is done and **Continue
|
||||
casting** resumes.
|
||||
4. Review the cast as before (Recast unknown, Verify, Define characters, …).
|
||||
|
||||
**Expected result:** every word of the book appears exactly once, quoted speech is
|
||||
typed as dialogue and everything else as narration. When the text only refers to a
|
||||
person by role ("der Fremde", "die Baronin"), the line is labelled with that role;
|
||||
later passes merge it into the named character when the book itself ties the two
|
||||
together (e.g. "der Inquisitor Marcian"). Some role labels stay separate — use the
|
||||
character merge in the cast view for those.
|
||||
|
||||
### Errors, limits and privacy
|
||||
|
||||
- If the LLM cannot be reached for a passage, its lines are named from the speech
|
||||
tags around them where possible and otherwise marked Unknown; nothing is lost.
|
||||
- A book whose speech is not marked with quotation marks (»…«, „…“, “…”, «…») uses
|
||||
the previous whole-passage method automatically. To force the previous method in
|
||||
this browser: `localStorage['ab-span-mode'] = '0'`.
|
||||
- Book text goes to the LLM you configured, exactly as before; nothing else is sent
|
||||
anywhere. Nothing works offline without a reachable LLM.
|
||||
- Accuracy: measured against hand-corrected casts of three novels, roughly 90–92 % of
|
||||
all words end up with the right voice and about three quarters of spoken words with
|
||||
the right speaker. Similar-looking characters in the same scene are the usual
|
||||
mix-up; check those in review.
|
||||
|
||||
---
|
||||
|
||||
## Magazines
|
||||
|
||||
### Purpose
|
||||
|
||||
6
TODO.md
6
TODO.md
@ -343,10 +343,8 @@ codebase; benefit is relative user/reliability impact.
|
||||
- [x] **P1 / L–XL — Very high benefit — Rework segmentation from Run 3.** The initial-cast final normalization now
|
||||
merges narration only, preserving dialogue turn boundaries; Run 3 showed same-speaker dialogue joins were obscuring
|
||||
upstream split/fusion errors. Recast/verify retains its separately tested dialogue-only merge.
|
||||
- [ ] **P1 / M — High benefit — Re-run Book 1.** Re-measure the hand-corrected Book 1 reference after segmentation
|
||||
changes; do not treat a short or incomplete run as a valid result.
|
||||
- [ ] **P1 / L–XL — Very high benefit — Run Book 2 generalization.** Execute the backed-up Book 2 reference regression
|
||||
to measure whether segmentation and attribution rules generalize beyond Book 1.
|
||||
- [x] **P1 / M — High benefit — Re-run Book 1.** Done 2026-09-19 with the span-mode engine: overall 92.2 % (was 87.0 %), type agreement 97.4 % (was 93.6 %), ≈11 min instead of ≈4 h. See `architecture/audiobook-attribution.md`.
|
||||
- [x] **P1 / L–XL — Very high benefit — Run Book 2 generalization.** Done 2026-09-19: Book 2 90.8 % and Book 3 89.9 % overall (neither tuned on) vs 92.2 % on Book 1 — the approach generalises. Note the references carry label noise, so 1–2 points is not meaningful.
|
||||
- [x] **P1 / L — Very high benefit — Unify visual identity.** Portrait and concept-sheet generation now use one
|
||||
canonical identity description, stored prompt version, identity fingerprint, asset digests, and stale-asset review
|
||||
path; shared-anchor and metadata persistence are covered by automated tests.
|
||||
|
||||
99
architecture/audiobook-attribution.md
Normal file
99
architecture/audiobook-attribution.md
Normal file
@ -0,0 +1,99 @@
|
||||
# SOP — Audiobook speaker attribution (span mode)
|
||||
|
||||
Status: **implemented, default on.** Switch off with `localStorage['ab-span-mode'] = '0'`
|
||||
(the classic whole-passage engine then runs). A book with no recognisable quotation
|
||||
style automatically uses the classic engine.
|
||||
|
||||
## Why it changed
|
||||
|
||||
The classic engine sends ~3,000-character passages to an LLM and asks it to re-emit
|
||||
the whole passage as JSON segments (`/api/attribute-dialogue`). That has three costs:
|
||||
|
||||
* **Text corruption.** The model fuses, duplicates or drops text; a stack of
|
||||
post-processing passes (dedupe, recover-dropped-text, prefix-dup cut, orphan-quote
|
||||
repair) exists only to undo it. Measured on a hand-corrected book: narration/dialogue
|
||||
typing agreed on 93.6 % of words.
|
||||
* **Hours per book.** Output tokens ≈ input tokens (a 4 h run for one novel).
|
||||
* **Unmeasurable.** Exact-text matching counts any boundary difference as an error.
|
||||
|
||||
## Design
|
||||
|
||||
```
|
||||
book text ──► segmentByQuotes() deterministic (static/js/audiobook-spans.js)
|
||||
narration / dialogue spans with exact source offsets
|
||||
──► buildWindows() ~2.4–3.6k chars per LLM call, cut at paragraphs
|
||||
──► POST /api/attribute-spans LLM answers ONLY {id, speaker, emotion} per dialogue span
|
||||
──► assemble segments text is taken from the spans, never from the LLM
|
||||
──► existing finalisation phantom drop, role→name aliases, name consolidation,
|
||||
confidence, autosave, Rehearser save, auto-repair
|
||||
```
|
||||
|
||||
The LLM can no longer change, drop, duplicate or fuse a single word; it can only choose
|
||||
a speaker, so its output is a few tokens per dialogue line.
|
||||
|
||||
### Segmenter rules (`audiobook-spans.js`)
|
||||
|
||||
* One quote style per book, chosen by counting well-formed pairs (`»…«` German inward,
|
||||
`„…“`, `“…”`, `«…»` French). No style with ≥ 5 pairs → classic engine.
|
||||
* A closing mark with no opener (opener lost in PDF extraction): the speech starts at the
|
||||
earliest run of speech-like sentences directly before it (1st/2nd-person pronouns, `?`,
|
||||
short `!`).
|
||||
* An opener whose closer is lost: the speech ends after the leading run of speech-like
|
||||
sentences.
|
||||
* A speech continues across paragraph breaks while a closing mark still follows before the
|
||||
next opener (some typesetting re-opens every paragraph, some only closes the last).
|
||||
* A quote that is a term/title inside a sentence (no sentence-final punctuation, lower-case
|
||||
continuation) is narration.
|
||||
* Adjacent narration merges; each quote stays its own dialogue span.
|
||||
|
||||
### `POST /api/attribute-spans`
|
||||
|
||||
Request: `{spans:[{id, kind, text}], known_characters, recent, following, language, llm_url,
|
||||
model, timeout_seconds, want_reasoning}`. Ids are `N<n>` / `D<n>`; `kind` must match the id
|
||||
prefix; ≤ 400 spans, ≤ 20 000 chars per span, ≤ 60 000 total, at least one dialogue span
|
||||
(HTTP 400 otherwise).
|
||||
Response: `{speakers:{id:{speaker, emotion}}, missing:[ids], characters:[names]}`, or
|
||||
`{failed:true, error, missing:[…]}` (HTTP 200) when the LLM is unavailable — the client then
|
||||
falls back to speech-tag guessing. The text is never returned.
|
||||
|
||||
Behaviour: thinking is disabled by default (`chat_template_kwargs.enable_thinking=false`,
|
||||
retried without the key on HTTP 400/422). Measured: reasoning mode was ~25× slower with no
|
||||
accuracy gain on the same windows. An answer that parses to nothing is retried once with a
|
||||
larger token budget; lines the model skipped are retried once on their own. Calls are
|
||||
serialised with every other attribution call.
|
||||
|
||||
Naming rule: the model answers with the label the text uses ("Der Fremde", "Baronin",
|
||||
"Oberst") when a person is only referred to by role in the scene, instead of guessing another
|
||||
known character's name. Role → name resolution then happens book-wide in the existing
|
||||
`_audiobookResolveRoleAliases` / `_audiobookConsolidateSpeakerAliases` passes.
|
||||
|
||||
## Measuring
|
||||
|
||||
`scripts/score_attribution.py <manual.json> <auto.json> [--characters <manual chars>]` aligns
|
||||
the two casts word by word (insensitive to chunking) and reports coverage, narration/dialogue
|
||||
type agreement, dialogue speaker accuracy (strict and alias-tolerant) and the largest
|
||||
confusions. **Read it with care:** the human references contain label noise (inline narration
|
||||
folded into the neighbouring dialogue, inconsistent handling of very long monologues,
|
||||
occasional wrong speakers), so differences of ~1–2 points are noise.
|
||||
|
||||
Book 1 (*Der Sturm*), same reference, same scorer:
|
||||
|
||||
| | classic (Run 3) | span mode |
|
||||
|---|---|---|
|
||||
| type agreement | 93.6 % | 97.4 % |
|
||||
| dialogue words, right speaker (tolerant) | 60.6 % | 75.4 % |
|
||||
| overall every word right (tolerant) | 87.0 % | 92.2 % |
|
||||
| wall time | ≈ 4 h | ≈ 11 min |
|
||||
| Unknown-speaker words | 16 | 114 |
|
||||
|
||||
## Security / privacy
|
||||
|
||||
Book text is sent to the configured LLM exactly as before (same endpoint, same settings);
|
||||
the server never returns text. Inputs are validated at the boundary (see limits above). No
|
||||
new credentials, storage or network destinations.
|
||||
|
||||
## Rollback
|
||||
|
||||
`localStorage['ab-span-mode'] = '0'` restores the classic engine per browser; removing the
|
||||
dispatch at the top of `audiobookCast()` (`static/js/audiobook-pipeline.js`) removes it for
|
||||
everyone. Casts made by either engine are the same shape and remain readable by the other.
|
||||
169
core/attribution_spans.py
Normal file
169
core/attribution_spans.py
Normal file
@ -0,0 +1,169 @@
|
||||
"""Speaker attribution over pre-segmented spans.
|
||||
|
||||
The passage has already been split into narration and dialogue spans by
|
||||
deterministic quote analysis (static/js/audiobook-spans.js). The LLM is asked
|
||||
only WHO speaks each dialogue span and never re-emits any text, so it cannot
|
||||
drop, duplicate or fuse passages, and its output is a few tokens per line.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
MAX_SPANS = 400
|
||||
MAX_SPAN_CHARS = 20_000
|
||||
MAX_TOTAL_CHARS = 60_000
|
||||
_ID_RE = re.compile(r"^[ND]\d{1,6}$")
|
||||
_UNKNOWN = {"", "unknown", "unbekannt", "?", "n/a", "none", "null", "unklar"}
|
||||
_NARRATOR = {"narrator", "erzähler", "erzaehler", "narration"}
|
||||
|
||||
|
||||
class SpanRequestError(ValueError):
|
||||
"""The request body is not a valid span-attribution request."""
|
||||
|
||||
|
||||
def validate_spans(raw: Any) -> list[dict]:
|
||||
"""Validate and normalise the span list at the API boundary."""
|
||||
if not isinstance(raw, list) or not raw:
|
||||
raise SpanRequestError("spans must be a non-empty list")
|
||||
if len(raw) > MAX_SPANS:
|
||||
raise SpanRequestError(f"too many spans (max {MAX_SPANS})")
|
||||
spans, seen, total = [], set(), 0
|
||||
for item in raw:
|
||||
if not isinstance(item, dict):
|
||||
raise SpanRequestError("each span must be an object")
|
||||
sid, kind, text = item.get("id"), item.get("kind"), item.get("text")
|
||||
if not isinstance(sid, str) or not _ID_RE.match(sid):
|
||||
raise SpanRequestError("span ids must look like N12 or D7")
|
||||
if sid in seen:
|
||||
raise SpanRequestError(f"duplicate span id {sid}")
|
||||
if kind not in ("narration", "dialogue") or (kind == "dialogue") != sid.startswith("D"):
|
||||
raise SpanRequestError(f"span {sid} has an inconsistent kind")
|
||||
if not isinstance(text, str) or not text.strip():
|
||||
raise SpanRequestError(f"span {sid} has no text")
|
||||
if len(text) > MAX_SPAN_CHARS:
|
||||
raise SpanRequestError(f"span {sid} is too long")
|
||||
total += len(text)
|
||||
if total > MAX_TOTAL_CHARS:
|
||||
raise SpanRequestError("passage is too long")
|
||||
seen.add(sid)
|
||||
spans.append({"id": sid, "kind": kind, "text": text.strip()})
|
||||
if not any(s["kind"] == "dialogue" for s in spans):
|
||||
raise SpanRequestError("passage has no dialogue spans")
|
||||
return spans
|
||||
|
||||
|
||||
_RULES = """\
|
||||
You attribute speakers in prose fiction for a multi-voice audiobook.
|
||||
|
||||
The passage below is ALREADY divided into numbered lines. Lines starting with N are narration; lines starting with D are quoted speech. Do NOT rewrite, split, merge or quote any text. Your only job: for EVERY D line, decide who speaks it.
|
||||
|
||||
Answer with ONLY this JSON object (no markdown, no commentary):
|
||||
{"speakers":[{"id":"D1","speaker":"Name","emotion":"one or two words"}]}
|
||||
- Exactly one entry per D line, in order, with the exact ids given.
|
||||
- speaker: the character's name. Reuse the exact spelling from the known-characters list whenever it is the same person.
|
||||
- emotion: one or two words in the language of the text describing how the line is spoken, or "".
|
||||
|
||||
How to decide (apply in this order):
|
||||
1. Speech tags in neighbouring N lines name the speaker ("sagte Marcian", "fragte sie", "asked Peter"). Resolve pronouns (er/sie/he/she) to the most recently named character of matching gender.
|
||||
2. A narration line ending in ":" makes its subject the speaker of the next D line.
|
||||
3. The narration right AFTER a D line often names the speaker, also in impersonal wording ("»Was machst du da?« ertönte es über ihm. Karyla war herübergekommen." -> Karyla).
|
||||
4. ADDRESSEE vs SPEAKER: a person named next to a quote is very often the one being ADDRESSED, looked at or answered - not the one speaking. Check who actually performs the speech verb or the action framing the quote. "X wandte sich an Y" means X speaks and Y answers next.
|
||||
5. Turn-taking: in a two-person exchange the speakers alternate strictly, even across many untagged D lines. Continue the alternation from the "Recent dialogue" lines. A short tag between two D lines ("»Die Pause«, sagte Peter, »ist vorbei.«") means the SAME speaker on both sides.
|
||||
6. Keep the alternation consistent over a whole scene instead of deciding each line in isolation. Also watch for two characters' lines being swapped, especially the protagonist attracting lines that belong to the person talking WITH him or her.
|
||||
7. Voice announcements ("Marcians Stimme klang fest", "X setzte zum Sprechen an", "ihre ersten Worte waren") give the next D line to that person. A line that names its own speaker as an introduction ("Man nennt mich Andra") belongs to that person.
|
||||
8. Names: use the exact name the text or the known-characters list gives for that person. If in this scene the person is referred to ONLY by a role or epithet ("der Fremde", "die Baronin", "der junge Oberst", "der Inquisitor"), answer with that label in its plain form ("Der Fremde", "Baronin", "Oberst", "Inquisitor") - do NOT swap in the name of some other known character just because a proper name feels more specific. Use a known character's name for such an epithet only when the text itself ties them together (e.g. "der Inquisitor Marcian", "Marcian, der Fremde", or the same person addressed by name in this passage). Which epithet belongs to which named character is settled afterwards, from the whole book.
|
||||
9. Answer "Unknown" only when the sole evidence is that a character stands nearby or is important in the scene; a confidently wrong name silently gives one character another's voice, an Unknown is cheap to fix. Turn-taking and clear tags always come first.
|
||||
10. If a D line is clearly not spoken words at all (a quoted title or term), answer "Narrator".
|
||||
"""
|
||||
|
||||
|
||||
def build_messages(
|
||||
spans: list[dict],
|
||||
known_characters: list[str] | None = None,
|
||||
recent: str = "",
|
||||
following: str = "",
|
||||
language: str = "",
|
||||
want_reasoning: bool = False,
|
||||
only_ids: list[str] | None = None,
|
||||
) -> tuple[str, str]:
|
||||
"""Return (system, user) prompts for one window of spans."""
|
||||
system = _RULES
|
||||
if language:
|
||||
system += f"\nThe text language is {language}; keep names and emotion words in that language.\n"
|
||||
if want_reasoning:
|
||||
system += (
|
||||
"\nFirst, inside <think>...</think>, reason in at most 4 short sentences about who speaks the "
|
||||
"untagged or ambiguous D lines. Then output the JSON object and nothing else."
|
||||
)
|
||||
lines = []
|
||||
for sp in spans:
|
||||
if sp["kind"] == "dialogue":
|
||||
lines.append(f'[{sp["id"]}] "{sp["text"]}"')
|
||||
else:
|
||||
lines.append(f'[{sp["id"]}] {sp["text"]}')
|
||||
known = [str(n).strip() for n in (known_characters or []) if str(n).strip()][:60]
|
||||
user = ""
|
||||
if known:
|
||||
user += "Known characters so far: " + ", ".join(known) + "\n\n"
|
||||
if recent.strip():
|
||||
user += "Recent dialogue (already attributed - continue the same conversation and alternation):\n" + recent.strip() + "\n\n"
|
||||
user += "Passage:\n" + "\n".join(lines) + "\n\n"
|
||||
if following.strip():
|
||||
user += "What follows the passage (context only, do not attribute):\n" + following.strip() + "\n\n"
|
||||
ids = [s["id"] for s in spans if s["kind"] == "dialogue" and (not only_ids or s["id"] in only_ids)]
|
||||
user += f"Attribute these lines: {', '.join(ids)}\nReturn ONLY the JSON object."
|
||||
return system, user
|
||||
|
||||
|
||||
def max_tokens_for(spans: list[dict], want_reasoning: bool = False) -> int:
|
||||
n = sum(1 for s in spans if s["kind"] == "dialogue")
|
||||
return min(4096, 300 + 60 * n) + (350 if want_reasoning else 0)
|
||||
|
||||
|
||||
def _clean_speaker(value: Any) -> str:
|
||||
s = re.sub(r"\s+", " ", str(value or "")).strip().strip("\"'»«„“”‚‘’`").strip()
|
||||
s = re.sub(r"[.:;,]+$", "", s).strip()
|
||||
low = s.casefold()
|
||||
if low in _UNKNOWN:
|
||||
return "Unknown"
|
||||
if low in _NARRATOR:
|
||||
return "Narrator"
|
||||
return s[:80]
|
||||
|
||||
|
||||
_OBJ_RE = re.compile(r"\{[^{}]*\}")
|
||||
|
||||
|
||||
def parse_response(raw: str, spans: list[dict]) -> dict:
|
||||
"""Extract {id: {speaker, emotion}} from the model's answer.
|
||||
|
||||
Tolerates reasoning blocks, markdown fences and truncated JSON by reading
|
||||
each {"id": ...} object independently; unknown ids are ignored and missing
|
||||
dialogue ids are reported so the caller can retry or mark them Unknown.
|
||||
"""
|
||||
content = re.sub(r"<think>.*?</think>", "", raw or "", flags=re.DOTALL)
|
||||
if "<think>" in content and "</think>" not in content:
|
||||
content = content.split("<think>", 1)[0]
|
||||
wanted = {s["id"] for s in spans if s["kind"] == "dialogue"}
|
||||
speakers: dict[str, dict] = {}
|
||||
for m in _OBJ_RE.finditer(content):
|
||||
try:
|
||||
obj = json.loads(m.group(0))
|
||||
except Exception:
|
||||
continue
|
||||
sid = obj.get("id") if isinstance(obj, dict) else None
|
||||
if sid in wanted and sid not in speakers:
|
||||
speakers[sid] = {
|
||||
"speaker": _clean_speaker(obj.get("speaker")),
|
||||
"emotion": re.sub(r"\s+", " ", str(obj.get("emotion") or "")).strip()[:60],
|
||||
}
|
||||
order = [s["id"] for s in spans if s["kind"] == "dialogue"]
|
||||
missing = [i for i in order if i not in speakers]
|
||||
characters: list[str] = []
|
||||
for i in order:
|
||||
sp = speakers.get(i, {}).get("speaker")
|
||||
if sp and sp not in ("Unknown", "Narrator") and sp not in characters:
|
||||
characters.append(sp)
|
||||
return {"speakers": speakers, "missing": missing, "characters": characters}
|
||||
@ -9,7 +9,8 @@
|
||||
"test:ui": "playwright test --ui",
|
||||
"install:browsers": "playwright install chromium",
|
||||
"minify": "node scripts/minify.mjs",
|
||||
"check:frontend": "node scripts/check-frontend.mjs"
|
||||
"check:frontend": "node scripts/check-frontend.mjs",
|
||||
"test:unit": "node --test tests/*.test.mjs"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.48.0",
|
||||
|
||||
@ -35,6 +35,10 @@ from core.voice import (
|
||||
from core.voice_index import rebuild_voice_index
|
||||
from core.database import background_job_create, background_job_get, background_job_store_artifact, background_job_update
|
||||
from core.retry import request_with_retries
|
||||
from core.attribution_spans import (
|
||||
SpanRequestError, build_messages as _span_build_messages, max_tokens_for as _span_max_tokens,
|
||||
parse_response as _span_parse_response, validate_spans as _span_validate,
|
||||
)
|
||||
from core.tts_helpers import _preview_request_audio
|
||||
from routes.stt import _transcribe_audio, _clean_stt_backend
|
||||
|
||||
@ -4084,6 +4088,95 @@ def _attribution_parse(raw: str, text: str) -> dict:
|
||||
return {"segments": clean, "characters": chars}
|
||||
|
||||
|
||||
def _span_llm_answer(payload: dict, llm_url: str, model: str, api_key: str, timeout_seconds: float, fallback_model: str) -> str:
|
||||
"""One blocking chat-completion for span attribution; returns the raw text
|
||||
or raises RuntimeError. Serialised with every other attribution call."""
|
||||
if not _attribution_llm_lock.acquire(True, timeout_seconds):
|
||||
raise RuntimeError("Attribution engine busy")
|
||||
try:
|
||||
headers = {"Authorization": f"Bearer {api_key}"}
|
||||
resp = _post_llm_chat_completion(llm_url, payload, headers, timeout_seconds)
|
||||
if resp.status_code in (400, 422) and "chat_template_kwargs" in payload:
|
||||
# A backend that rejects the (Qwen-style) thinking switch: retry without it.
|
||||
payload = {k: v for k, v in payload.items() if k != "chat_template_kwargs"}
|
||||
resp = _post_llm_chat_completion(llm_url, payload, headers, timeout_seconds)
|
||||
if (
|
||||
resp.status_code >= 400 and _is_router_model_alias(model) and fallback_model
|
||||
and fallback_model != model and not _is_router_model_alias(fallback_model)
|
||||
):
|
||||
resp = _post_llm_chat_completion(llm_url, dict(payload, model=fallback_model), headers, timeout_seconds)
|
||||
finally:
|
||||
_attribution_llm_lock.release()
|
||||
if resp.status_code >= 400:
|
||||
raise RuntimeError(f"LLM HTTP {resp.status_code}: {_response_error_text(resp)[:200]}")
|
||||
msg = resp.json()["choices"][0]["message"]
|
||||
return (msg.get("content") or _reasoning_text(msg) or "").strip()
|
||||
|
||||
|
||||
@router.post("/api/attribute-spans")
|
||||
async def attribute_spans(request: Request):
|
||||
"""Attribute speakers for one window of pre-segmented spans.
|
||||
|
||||
Body: {spans:[{id, kind:"narration"|"dialogue", text}], known_characters, recent,
|
||||
following, language, llm_url, model, timeout_seconds, want_reasoning}
|
||||
Returns: {speakers:{id:{speaker, emotion}}, missing:[ids], characters:[names]}
|
||||
or {failed:true, error} when the LLM is unavailable (the client then falls back
|
||||
to tag-based guessing). The text itself is never sent back: the caller already
|
||||
holds it, so the LLM cannot alter it.
|
||||
"""
|
||||
data = await request.json()
|
||||
if not isinstance(data, dict):
|
||||
raise HTTPException(400, "Body must be an object")
|
||||
try:
|
||||
spans = _span_validate(data.get("spans"))
|
||||
except SpanRequestError as exc:
|
||||
raise HTTPException(400, str(exc))
|
||||
known = [str(n) for n in (data.get("known_characters") or []) if isinstance(n, (str, int))][:80]
|
||||
recent = str(data.get("recent") or "")[:4000]
|
||||
following = str(data.get("following") or "")[:1500]
|
||||
language = str(data.get("language") or "")[:40]
|
||||
want_reasoning = bool(data.get("want_reasoning"))
|
||||
settings = _load_settings()
|
||||
llm_url = (data.get("llm_url") or settings.get("llm_url") or "http://localhost:11434/v1").rstrip("/")
|
||||
model = (data.get("model") or settings.get("llm_model") or "").strip()
|
||||
timeout_seconds = _request_timeout_seconds(data.get("timeout_seconds"), 600.0)
|
||||
api_key = settings.get("llm_api_key") or "sk-dummy-key"
|
||||
fallback_model = (settings.get("llm_model") or "").strip()
|
||||
|
||||
def ask(only_ids=None, token_boost: int = 0) -> dict:
|
||||
system, user = _span_build_messages(spans, known, recent, following, language, want_reasoning, only_ids)
|
||||
payload: dict = {
|
||||
"messages": [{"role": "system", "content": system}, {"role": "user", "content": user}],
|
||||
"temperature": 0.1,
|
||||
"max_tokens": _span_max_tokens(spans, want_reasoning) + token_boost,
|
||||
}
|
||||
if not want_reasoning:
|
||||
# Who-speaks-this is a short lookup: skip the model's chain of thought
|
||||
# (roughly 8x faster). Reasoning stays available via want_reasoning.
|
||||
payload["chat_template_kwargs"] = {"enable_thinking": False}
|
||||
if model:
|
||||
payload["model"] = model
|
||||
raw = _span_llm_answer(payload, llm_url, model, api_key, timeout_seconds, fallback_model)
|
||||
return _span_parse_response(raw, spans)
|
||||
|
||||
try:
|
||||
result = await asyncio.to_thread(ask)
|
||||
if not result["speakers"]:
|
||||
# Nothing parsed at all: a backend that ignores the thinking switch spends the
|
||||
# whole budget on reasoning. Give it room to finish once.
|
||||
result = await asyncio.to_thread(ask, None, 3000)
|
||||
if result["missing"] and len(result["missing"]) < sum(1 for s in spans if s["kind"] == "dialogue"):
|
||||
# Partial answer (truncated or skipped lines): one focused retry for just those lines.
|
||||
retry = await asyncio.to_thread(ask, list(result["missing"]))
|
||||
result["speakers"].update({k: v for k, v in retry["speakers"].items() if k not in result["speakers"]})
|
||||
result["missing"] = [i for i in result["missing"] if i not in result["speakers"]]
|
||||
result["characters"] = retry["characters"] + [c for c in result["characters"] if c not in retry["characters"]]
|
||||
return result
|
||||
except Exception as exc:
|
||||
print(f"[attribute-spans] LLM failed: {exc}")
|
||||
return {"speakers": {}, "missing": [s["id"] for s in spans if s["kind"] == "dialogue"], "characters": [], "failed": True, "error": str(exc)[:200]}
|
||||
|
||||
|
||||
@router.post("/api/attribute-dialogue/stream")
|
||||
async def attribute_dialogue_stream(request: Request):
|
||||
"""Same job as /api/attribute-dialogue, but streams the LLM's live output
|
||||
|
||||
182
scripts/score_attribution.py
Normal file
182
scripts/score_attribution.py
Normal file
@ -0,0 +1,182 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Score an automated audiobook cast against a hand-corrected one, by word.
|
||||
|
||||
python3 scripts/score_attribution.py <manual_cast.json> <auto_cast.json>
|
||||
[--characters <manual_characters.json>] [--confusions N] [--json]
|
||||
|
||||
The older scripts/compare_casts.py matches whole segments by exact text, so any
|
||||
difference in where a segment starts or ends counts as an error even when every
|
||||
word is spoken by the right voice. This scorer aligns the two casts word by
|
||||
word instead, which makes it insensitive to how narration is chunked and lets
|
||||
runs with different segmentation be compared fairly.
|
||||
|
||||
Reported (all as % of the manual cast's words unless stated):
|
||||
coverage words of the manual cast found in the auto cast
|
||||
type narration/dialogue agreement over aligned words
|
||||
dialogue of the words the human labelled dialogue: correct speaker,
|
||||
strict and alias-tolerant (a variant such as "Baronin" for
|
||||
"Baronin Ira von Seewiesen", or a listed alias, counts as right)
|
||||
speaker (both) speaker accuracy over words BOTH casts label dialogue - the
|
||||
pure attribution quality, separate from segmentation quality
|
||||
overall every word right (type and, for dialogue, speaker), tolerant
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import collections
|
||||
import difflib
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
_QUOTES = re.compile(r"[»«„“”\"'`‚‘’›‹]")
|
||||
_ARTICLE = re.compile(r"^(?:der|die|das|den|dem|ein|eine)\s+")
|
||||
_GLUE = {"von", "der", "die", "und", "zu", "vom"}
|
||||
|
||||
|
||||
def _clean_name(name: str) -> str:
|
||||
return _ARTICLE.sub("", str(name or "").strip().lower())
|
||||
|
||||
|
||||
def _tokens(name: str) -> set[str]:
|
||||
return set(re.findall(r"\w+", _clean_name(name)))
|
||||
|
||||
|
||||
def load_segments(path: str | Path) -> list[dict]:
|
||||
data = json.loads(Path(path).read_text(encoding="utf-8"))
|
||||
return (data.get("segments") if isinstance(data, dict) else data) or []
|
||||
|
||||
|
||||
def load_aliases(path: str | Path | None) -> dict[str, set[str]]:
|
||||
"""{canonical name: {cleaned alias, ...}} from a characters export."""
|
||||
aliases: dict[str, set[str]] = collections.defaultdict(set)
|
||||
if not path:
|
||||
return aliases
|
||||
data = json.loads(Path(path).read_text(encoding="utf-8"))
|
||||
for c in data if isinstance(data, list) else data.get("characters", []):
|
||||
name = c.get("name")
|
||||
if not name:
|
||||
continue
|
||||
aliases[name].add(_clean_name(name))
|
||||
raw = (c.get("sheet") or {}).get("aliases") or ""
|
||||
for a in str(raw).split(","):
|
||||
if a.strip():
|
||||
aliases[name].add(_clean_name(a))
|
||||
return aliases
|
||||
|
||||
|
||||
def speaker_matches(human: str, auto: str, aliases: dict[str, set[str]]) -> bool:
|
||||
"""Strict equality, a listed alias, or a name that is a token subset of the other."""
|
||||
if human == auto:
|
||||
return True
|
||||
if _clean_name(auto) in aliases.get(human, ()):
|
||||
return True
|
||||
ta, th = _tokens(auto), _tokens(human)
|
||||
if ta and th and (ta <= th or th <= ta) and not ((ta & th) <= _GLUE):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _words(segments: list[dict]) -> tuple[list[str], list[tuple[bool, str]]]:
|
||||
words, labels = [], []
|
||||
for seg in segments:
|
||||
dialogue = seg.get("type") == "dialogue"
|
||||
speaker = str(seg.get("speaker") or "Narrator") if dialogue else "Narrator"
|
||||
for w in re.findall(r"\w+", _QUOTES.sub(" ", str(seg.get("text") or "")).lower()):
|
||||
words.append(w)
|
||||
labels.append((dialogue, speaker))
|
||||
return words, labels
|
||||
|
||||
|
||||
def score(manual: list[dict], auto: list[dict], aliases: dict[str, set[str]] | None = None, top: int = 25) -> dict:
|
||||
aliases = aliases or {}
|
||||
mw, ml = _words(manual)
|
||||
aw, al = _words(auto)
|
||||
blocks = difflib.SequenceMatcher(None, mw, aw, autojunk=False).get_matching_blocks()
|
||||
aligned = type_ok = 0
|
||||
d_total = d_strict = d_tol = 0 # human-dialogue words
|
||||
both = both_strict = both_tol = 0 # dialogue in both casts
|
||||
overall_tol = 0
|
||||
confusions: collections.Counter = collections.Counter()
|
||||
per_speaker: dict[str, list[int]] = collections.defaultdict(lambda: [0, 0])
|
||||
unknown_words = 0
|
||||
for b in blocks:
|
||||
for k in range(b.size):
|
||||
(hd, hs), (ad, as_) = ml[b.a + k], al[b.b + k]
|
||||
aligned += 1
|
||||
if hd == ad:
|
||||
type_ok += 1
|
||||
if ad and str(as_).lower().startswith(("unknown", "unbekannt")):
|
||||
unknown_words += 1
|
||||
tol = strict = False
|
||||
if hd and ad:
|
||||
strict, tol = hs == as_, speaker_matches(hs, as_, aliases)
|
||||
both += 1
|
||||
both_strict += strict
|
||||
both_tol += tol
|
||||
elif not hd and not ad:
|
||||
strict = tol = True
|
||||
if hd:
|
||||
d_total += 1
|
||||
d_strict += strict
|
||||
d_tol += tol
|
||||
per_speaker[hs][0] += 1
|
||||
per_speaker[hs][1] += tol
|
||||
overall_tol += tol
|
||||
if not tol:
|
||||
confusions[(hs, as_)] += 1
|
||||
n = len(mw) or 1
|
||||
pct = lambda x, d: round(100.0 * x / d, 2) if d else 0.0
|
||||
return {
|
||||
"manual_words": len(mw), "auto_words": len(aw),
|
||||
"coverage": pct(aligned, n),
|
||||
"type": pct(type_ok, aligned),
|
||||
"dialogue_strict": pct(d_strict, d_total), "dialogue_tolerant": pct(d_tol, d_total),
|
||||
"dialogue_words": d_total,
|
||||
"speaker_both_strict": pct(both_strict, both), "speaker_both_tolerant": pct(both_tol, both),
|
||||
"overall_tolerant": pct(overall_tol, n),
|
||||
"auto_unknown_words": unknown_words,
|
||||
"auto_speakers": len({s.get("speaker") for s in auto if s.get("type") == "dialogue"}),
|
||||
"manual_speakers": len({s.get("speaker") for s in manual if s.get("type") == "dialogue"}),
|
||||
"top_confusions": [
|
||||
{"human": h, "auto": a, "words": c} for (h, a), c in confusions.most_common(top)
|
||||
],
|
||||
"weakest_speakers": [
|
||||
{"speaker": s, "words": t[0], "accuracy": pct(t[1], t[0])}
|
||||
for s, t in sorted(per_speaker.items(), key=lambda kv: -kv[1][0])[:14]
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def render(res: dict) -> str:
|
||||
lines = [
|
||||
f"words: manual={res['manual_words']} auto={res['auto_words']} coverage={res['coverage']}%",
|
||||
f"narration/dialogue type agreement : {res['type']}%",
|
||||
f"dialogue words ({res['dialogue_words']}) right speaker : strict {res['dialogue_strict']}% tolerant {res['dialogue_tolerant']}%",
|
||||
f"speaker accuracy where both say dialogue : strict {res['speaker_both_strict']}% tolerant {res['speaker_both_tolerant']}%",
|
||||
f"overall every-word-right (tolerant) : {res['overall_tolerant']}%",
|
||||
f"speakers: manual={res['manual_speakers']} auto={res['auto_speakers']} auto Unknown words={res['auto_unknown_words']}",
|
||||
"", "top remaining confusions (human -> auto, words):",
|
||||
]
|
||||
lines += [f" {c['words']:6d} {c['human']:26s} -> {c['auto']}" for c in res["top_confusions"]]
|
||||
lines += ["", "biggest human speakers (tolerant accuracy):"]
|
||||
lines += [f" {s['speaker']:26s} {s['words']:6d} words {s['accuracy']:5.1f}%" for s in res["weakest_speakers"]]
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
||||
ap.add_argument("manual")
|
||||
ap.add_argument("auto")
|
||||
ap.add_argument("--characters", help="manual characters export, for alias tolerance")
|
||||
ap.add_argument("--confusions", type=int, default=25)
|
||||
ap.add_argument("--json", action="store_true", help="print machine-readable JSON")
|
||||
args = ap.parse_args(argv)
|
||||
res = score(load_segments(args.manual), load_segments(args.auto), load_aliases(args.characters), args.confusions)
|
||||
print(json.dumps(res, ensure_ascii=False, indent=1) if args.json else render(res))
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@ -10,7 +10,7 @@
|
||||
<meta name="format-detection" content="telephone=no">
|
||||
<meta name="color-scheme" content="light dark">
|
||||
<meta name="theme-color" content="#2563EB">
|
||||
<meta name="app-version" content="1.31.0">
|
||||
<meta name="app-version" content="1.32.0">
|
||||
<link rel="manifest" href="/manifest.webmanifest">
|
||||
<link rel="icon" href="/static/icon.svg" type="image/svg+xml">
|
||||
<link rel="apple-touch-icon" href="/static/icon.svg">
|
||||
@ -27,12 +27,12 @@
|
||||
|
||||
<!-- ── Core styles (local — no CDN dependency for first paint) ────────── -->
|
||||
<link rel="stylesheet" href="/static/vendor/mdi/materialdesignicons.min.css">
|
||||
<link rel="stylesheet" href="/static/style.css?v=1.31.0">
|
||||
<link rel="stylesheet" href="/static/css/resource-diagnostics.css?v=1.31.0">
|
||||
<link rel="stylesheet" href="/static/css/background-jobs.css?v=1.31.0">
|
||||
<link rel="stylesheet" href="/static/css/voice-design.css?v=1.31.0">
|
||||
<link rel="stylesheet" href="/static/css/rehearser.css?v=1.31.0">
|
||||
<link rel="stylesheet" href="/static/css/audiobook.css?v=1.31.0">
|
||||
<link rel="stylesheet" href="/static/style.css?v=1.32.0">
|
||||
<link rel="stylesheet" href="/static/css/resource-diagnostics.css?v=1.32.0">
|
||||
<link rel="stylesheet" href="/static/css/background-jobs.css?v=1.32.0">
|
||||
<link rel="stylesheet" href="/static/css/voice-design.css?v=1.32.0">
|
||||
<link rel="stylesheet" href="/static/css/rehearser.css?v=1.32.0">
|
||||
<link rel="stylesheet" href="/static/css/audiobook.css?v=1.32.0">
|
||||
|
||||
|
||||
<!-- ── Flag icons — non-blocking (loaded async, icons appear after JS) ── -->
|
||||
|
||||
177
static/js/audiobook-cast-spans.js
Normal file
177
static/js/audiobook-cast-spans.js
Normal file
@ -0,0 +1,177 @@
|
||||
// ── Audiobook casting, span mode ────────────────────────────────────────────
|
||||
// Structure (narration vs dialogue, and the exact text of each piece) comes
|
||||
// from the book's own quotation marks, deterministically. The LLM is only asked
|
||||
// who speaks each dialogue span (POST /api/attribute-spans), so it can never
|
||||
// drop, duplicate or fuse text, and a whole book takes minutes instead of hours.
|
||||
// Used by audiobookCast() unless switched off (localStorage 'ab-span-mode' = '0')
|
||||
// or the book has no recognisable quotation style, in which case the classic
|
||||
// whole-passage engine runs instead.
|
||||
(function (root) {
|
||||
'use strict';
|
||||
|
||||
function audiobookSpanModeEnabled() {
|
||||
try { return localStorage.getItem('ab-span-mode') !== '0'; } catch (_) { return true; }
|
||||
}
|
||||
|
||||
const RECENT_LINES = 6;
|
||||
const ROSTER_LIMIT = 40;
|
||||
|
||||
// Page (1-based) of a character offset, from the reader's page marks.
|
||||
function _abPageAt(marks, offset) {
|
||||
let page = 1;
|
||||
for (const m of marks) { if (m.offset <= offset) page = m.page + 1; else break; }
|
||||
return page;
|
||||
}
|
||||
|
||||
function _abWindowRequest(win, next, roster, recent, language, llm) {
|
||||
const following = (next ? next.spans.slice(0, 2) : []).map(s => s.llmText.slice(0, 300)).join(' ');
|
||||
return {
|
||||
spans: win.spans.map(s => ({ id: s.id, kind: s.kind, text: s.llmText })),
|
||||
known_characters: roster.slice(-ROSTER_LIMIT),
|
||||
recent, following, language,
|
||||
llm_url: llm.url, model: llm.model,
|
||||
timeout_seconds: llm.timeout,
|
||||
};
|
||||
}
|
||||
|
||||
async function audiobookCastSpans(overrideUrl, overrideModel, resume) {
|
||||
const text = audiobookScopeText();
|
||||
if (!text || !root.TTVC?.audiobook?.spans) return false;
|
||||
const S = root.TTVC.audiobook.spans;
|
||||
const spans = S.segmentByQuotes(text);
|
||||
if (!spans || !spans.some(s => s.kind === 'dialogue')) return false; // no quote style: classic engine
|
||||
const windows = S.buildWindows(spans, text);
|
||||
const byId = new Map();
|
||||
for (const w of windows) for (const sp of w.spans) byId.set(sp.id, sp);
|
||||
|
||||
_audiobook.running = true;
|
||||
_audiobook.castMode = 'spans';
|
||||
if (!_abBookId()) await _abEnsureLibraryBook();
|
||||
const startIndex = (resume && resume.startIndex > 0 && resume.startIndex < windows.length) ? resume.startIndex : 0;
|
||||
if (!startIndex) _abClearDraft();
|
||||
if (typeof window.setNavCastingBadge === 'function') window.setNavCastingBadge(true);
|
||||
const ac = new AbortController();
|
||||
_audiobook.cancel = false;
|
||||
_audiobook.abort = () => ac.abort();
|
||||
|
||||
if (overrideUrl && typeof overrideUrl !== 'string') overrideUrl = null;
|
||||
const llmUrl = overrideUrl || audiobookLlmUrl();
|
||||
const model = audiobookSafeLlmModel(overrideModel || audiobookLlmModel());
|
||||
const language = audiobookLang(text);
|
||||
const view = audiobookCastView(windows.length, llmUrl, model, false);
|
||||
if (startIndex && resume.segments && resume.segments.length) view.rebuild(resume.segments);
|
||||
const llm = {
|
||||
url: document.getElementById('ab-cv-llm-url')?.value.trim() || llmUrl,
|
||||
model: audiobookSafeLlmModel(document.getElementById('ab-cv-llm-select')?.value || model),
|
||||
timeout: audiobookTimeoutSeconds(AUDIOBOOK_ATTRIBUTION_TIMEOUT_MS),
|
||||
};
|
||||
|
||||
const allSegments = startIndex ? resume.segments.slice() : [];
|
||||
_audiobook.liveSegments = allSegments;
|
||||
const roster = startIndex ? (resume.roster || []).slice() : [];
|
||||
let completed = startIndex, degraded = startIndex ? (resume.degraded || 0) : 0, narrationOnly = startIndex ? (resume.narrationOnly || 0) : 0;
|
||||
const marks = (_audiobook.pageMarks || []).slice();
|
||||
_abStartDraftAutosave(() => { if (allSegments.length) _abSaveDraft(allSegments, roster, text, completed, windows.length); });
|
||||
|
||||
try {
|
||||
for (let i = startIndex; i < windows.length; i++) {
|
||||
if (_audiobook.cancel) break;
|
||||
const win = windows[i];
|
||||
view.update(i);
|
||||
const segs = [];
|
||||
let answer = { speakers: {} };
|
||||
if (win.hasDialogue) {
|
||||
const recent = allSegments.filter(s => s.type === 'dialogue' && s.speaker && !/^Unknown|Unbekannt/i.test(s.speaker))
|
||||
.slice(-RECENT_LINES).map(s => `${s.speaker}: ${(s.text || '').slice(0, 80)}`).join('\n');
|
||||
view.processing(win.spans.map(s => s.text).join(' ').slice(0, 900));
|
||||
try {
|
||||
const r = await audiobookFetchWithTimeout('/api/attribute-spans', {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' }, signal: ac.signal,
|
||||
body: JSON.stringify(_abWindowRequest(win, windows[i + 1], roster, recent, language, llm)),
|
||||
}, AUDIOBOOK_ATTRIBUTION_TIMEOUT_MS + 5000);
|
||||
if (!r.ok) throw new Error((await r.json().catch(() => ({}))).detail || ('HTTP ' + r.status));
|
||||
answer = await r.json();
|
||||
} catch (err) {
|
||||
if (err.name === 'AbortError') throw err;
|
||||
answer = { failed: true, speakers: {} };
|
||||
view.note(`⚠️ Passage ${i + 1}: speaker lookup failed (${err.message}) — falling back to speech tags`);
|
||||
}
|
||||
if (answer.failed) degraded++;
|
||||
} else narrationOnly++;
|
||||
|
||||
win.spans.forEach((sp, k) => {
|
||||
const page = _abPageAt(marks, sp.start);
|
||||
if (sp.kind === 'narration') { segs.push({ speaker: 'Narrator', type: 'narration', text: sp.text, emotion: '', page }); return; }
|
||||
const hit = answer.speakers && answer.speakers[sp.id];
|
||||
let speaker = hit && hit.speaker;
|
||||
if (!speaker || answer.failed) {
|
||||
const before = win.spans[k - 1]?.text || '', after = win.spans[k + 1]?.text || '';
|
||||
speaker = audiobookGuessSpeaker(after, before) || 'Unknown';
|
||||
}
|
||||
if (speaker === 'Narrator') { segs.push({ speaker: 'Narrator', type: 'narration', text: sp.text, emotion: '', page }); return; }
|
||||
segs.push({ speaker, type: 'dialogue', text: sp.text, emotion: (hit && hit.emotion) || '', page });
|
||||
});
|
||||
|
||||
audiobookResolveUnknowns(segs, allSegments.slice(-2), roster);
|
||||
segs.forEach(s => {
|
||||
if (s.type === 'dialogue' && s.speaker && !/^Unknown|Unbekannt/i.test(s.speaker) && !roster.includes(s.speaker)) roster.push(s.speaker);
|
||||
allSegments.push(s);
|
||||
});
|
||||
view.addSegments(segs);
|
||||
completed = i + 1;
|
||||
_abSaveDraft(allSegments, roster, text, completed, windows.length);
|
||||
}
|
||||
view.update(_audiobook.cancel ? completed : windows.length);
|
||||
} catch (err) {
|
||||
_audiobook.cancel = true;
|
||||
view.note(err.name === 'AbortError' ? 'Casting stopped. Completed passages were preserved.' : `❌ Casting stopped — unexpected error: ${err.message}`);
|
||||
} finally {
|
||||
_abStopDraftAutosave();
|
||||
_audiobook.running = false;
|
||||
_audiobook.abort = null;
|
||||
_abHideCastingControl();
|
||||
}
|
||||
|
||||
if (!allSegments.length) {
|
||||
view.done({ stopped: true, message: 'Casting stopped before any passage completed.' });
|
||||
return true;
|
||||
}
|
||||
try {
|
||||
{ const ph = _audiobookDropPhantomSpeakers(allSegments);
|
||||
if (ph.dropped) console.info('[cast] dropped', ph.dropped, 'phantom speaker line(s):', ph.names.join(', ')); }
|
||||
{ const role = _audiobookResolveRoleAliases(allSegments, text);
|
||||
if (role.moved) console.info('[cast] resolved role labels to names:', role.pairs.join(', '), `(${role.moved} lines)`); }
|
||||
{ const ident = _audiobookConsolidateSpeakerAliases(allSegments);
|
||||
if (ident.merged) console.info('[cast] consolidated', ident.merged, 'speaker label variant(s),', ident.moved, 'line(s)'); }
|
||||
_abStampConfidence(allSegments);
|
||||
_audiobook.segments = allSegments;
|
||||
_audiobook.lastText = text;
|
||||
_audiobook.roster = roster;
|
||||
_audiobook.narratedPassages = narrationOnly;
|
||||
_audiobook.degraded = degraded;
|
||||
_audiobook.recoveredGaps = 0;
|
||||
_audiobook.completedChunks = _audiobook.cancel ? completed : windows.length;
|
||||
_audiobook.completedTotal = windows.length;
|
||||
_abSaveDraft(allSegments, roster, text, _audiobook.completedChunks, windows.length);
|
||||
audiobookSaveAsRehearsal({ silent: true });
|
||||
const speakers = new Set(allSegments.filter(s => s.type === 'dialogue' && s.speaker).map(s => s.speaker));
|
||||
const stoppedEarly = _audiobook.cancel && completed < windows.length;
|
||||
view.complete(`${stoppedEarly ? 'Stopped early · ' : ''}${speakers.size} character${speakers.size !== 1 ? 's' : ''} · ${allSegments.length} segments`,
|
||||
audiobookShowPreview, audiobookCast, audiobookRecastUnknown);
|
||||
if (!stoppedEarly && !_audiobook.cancel && audiobookAutoRepairEnabled()) {
|
||||
const unknown = allSegments.filter(s => s?.type === 'dialogue' && (!s.speaker || /^Unknown|Unbekannt/i.test(s.speaker))).length;
|
||||
if (unknown > 10) setTimeout(() => { audiobookAutoRepairCast().catch(e => console.warn('[auto-repair]', e)); }, 800);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('[audiobookCastSpans] post-processing failed', e);
|
||||
view.done({ stopped: true, message: 'Finished casting, but saving/cleanup failed: ' + e.message });
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
root.audiobookSpanModeEnabled = audiobookSpanModeEnabled;
|
||||
root.audiobookCastSpans = audiobookCastSpans;
|
||||
root.TTVC = root.TTVC || {};
|
||||
root.TTVC.audiobook = root.TTVC.audiobook || {};
|
||||
root.TTVC.audiobook.castSpans = audiobookCastSpans;
|
||||
})(window);
|
||||
@ -469,6 +469,7 @@ async function audiobookOpenCastView() {
|
||||
_audiobook.lastText = text;
|
||||
_audiobook.pageMarks = draft.pageMarks || [];
|
||||
_audiobook.rehId = draft.rehId || null;
|
||||
_audiobook.castMode = draft.mode || 'classic'; // window indices only mean something to the engine that made them
|
||||
// Always keep the raw progress the original cast reached, even once it's
|
||||
// finished or a later edit re-saves the draft — later saves (manual speaker
|
||||
// fixes, recast-unknown) must not clobber this with a "looks 100%" sentinel,
|
||||
@ -605,6 +606,13 @@ function _abRecoverDroppedText(sourceText, segs) {
|
||||
|
||||
async function audiobookCast(overrideUrl, overrideModel, resume) {
|
||||
if (_abBusyGuard('Casting')) return;
|
||||
// Span mode (deterministic quote spans, LLM only names the speakers) is the
|
||||
// default; a resume must stay on the engine that produced the draft.
|
||||
if (typeof root.audiobookCastSpans === 'function' && root.audiobookSpanModeEnabled()) {
|
||||
const canResume = !resume || _audiobook.castMode === 'spans';
|
||||
if (canResume && await root.audiobookCastSpans(overrideUrl, overrideModel, resume)) return;
|
||||
}
|
||||
_audiobook.castMode = 'classic';
|
||||
const text = audiobookScopeText();
|
||||
if (!text) { toast('Import a document first', 'error'); return; }
|
||||
if (typeof parseScript !== 'function') { toast('Rehearser not loaded yet — try again in a moment', 'error'); return; }
|
||||
|
||||
195
static/js/audiobook-spans.js
Normal file
195
static/js/audiobook-spans.js
Normal file
@ -0,0 +1,195 @@
|
||||
// ── Audiobook span segmentation ─────────────────────────────────────────────
|
||||
// Deterministic split of a book's text into narration and dialogue spans from
|
||||
// its own quotation typography. The LLM is then only asked WHO speaks each
|
||||
// dialogue span, never to re-emit the text: that removes the fusion, dropped
|
||||
// and duplicated text an LLM introduces when it rewrites a whole passage.
|
||||
//
|
||||
// Pure functions, no DOM: loaded in the browser and importable from Node so
|
||||
// the segmentation can be scored against the hand-corrected references.
|
||||
(function (root) {
|
||||
'use strict';
|
||||
|
||||
const STYLES = [
|
||||
{ name: 'guillemets-inward', open: '»', close: '«' }, // German »…«
|
||||
{ name: 'low-high', open: '„', close: '“' }, // German „…“
|
||||
{ name: 'curly', open: '“', close: '”' }, // English “…”
|
||||
{ name: 'guillemets-outward', open: '«', close: '»' }, // French «…»
|
||||
];
|
||||
const MIN_PAIRS = 5;
|
||||
const MAX_SPEECH_CHARS = 12000; // a speech longer than this is treated as a lost closer
|
||||
|
||||
// Counts well-formed open…close pairs per style; the style with the most wins.
|
||||
// One style per document: nested/secondary marks (›‹, ‘’) are deliberately ignored.
|
||||
function detectStyle(text) {
|
||||
let best = null, bestPairs = 0;
|
||||
for (const st of STYLES) {
|
||||
let pairs = 0, inside = false;
|
||||
for (let i = 0; i < text.length; i++) {
|
||||
const ch = text[i];
|
||||
if (ch === st.open) inside = true;
|
||||
else if (ch === st.close && inside) { pairs++; inside = false; }
|
||||
}
|
||||
if (pairs > bestPairs) { best = st; bestPairs = pairs; }
|
||||
}
|
||||
return bestPairs >= MIN_PAIRS ? best : null;
|
||||
}
|
||||
|
||||
// Sentences of text[a,b) as [start,end) ranges, each ending at . ! ? … plus
|
||||
// any trailing quote/bracket marks.
|
||||
function sentenceRanges(text, a, b) {
|
||||
const out = [];
|
||||
const re = /[^.!?…]*[.!?…]+[»«“”"’)\]]*\s*|[^.!?…]+$/g;
|
||||
const slice = text.slice(a, b);
|
||||
let m;
|
||||
while ((m = re.exec(slice)) && m[0].length) out.push([a + m.index, a + m.index + m[0].length]);
|
||||
return out;
|
||||
}
|
||||
|
||||
// First/second-person pronouns and possessives are strong speech evidence in
|
||||
// prose; third-person narration almost never contains them. `ihr`/`sie` are
|
||||
// excluded (3rd-person possessive/pronoun) and polite `Ihr/Euch` only count
|
||||
// when not sentence-initial.
|
||||
const SPEECH_PRONOUN = /\b(?:ich|mich|mir|mein\w*|du|dich|dir|dein\w*|wir|uns|unser\w*|euch|euer|eure\w*|Euch|Euer|Eure\w*|I|me|my|you|your|we|us|our)\b/;
|
||||
function looksLikeSpeech(sentence) {
|
||||
const s = sentence.trim();
|
||||
if (!s) return false;
|
||||
if (SPEECH_PRONOUN.test(s)) return true;
|
||||
if (/\?["»«”’)]*$/.test(s)) return true;
|
||||
if (/!["»«”’)]*$/.test(s) && s.split(/\s+/).length <= 12) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
// A closing mark with no opener: the opener was lost in extraction. The
|
||||
// speech starts at the earliest run of speech-like sentences directly before
|
||||
// the mark, never before `floor` (the end of the previous span).
|
||||
function recoverStart(text, floor, closeIdx) {
|
||||
const sents = sentenceRanges(text, floor, closeIdx);
|
||||
if (!sents.length) return closeIdx;
|
||||
let k = sents.length - 1;
|
||||
while (k > 0 && looksLikeSpeech(text.slice(sents[k - 1][0], sents[k - 1][1])) &&
|
||||
!/\n\s*\n/.test(text.slice(sents[k - 1][1], sents[k][0]))) k--;
|
||||
return sents[k][0];
|
||||
}
|
||||
|
||||
// An opening mark whose closer is missing: the speech ends after the last of
|
||||
// the leading run of speech-like sentences.
|
||||
function recoverEnd(text, openEnd, limit) {
|
||||
const sents = sentenceRanges(text, openEnd, limit);
|
||||
if (!sents.length) return limit;
|
||||
let k = 0;
|
||||
while (k + 1 < sents.length && looksLikeSpeech(text.slice(sents[k + 1][0], sents[k + 1][1]))) k++;
|
||||
return sents[k][1];
|
||||
}
|
||||
|
||||
// A quote that is really a quoted term or title inside a narration sentence
|
||||
// (»den gewundenen Weg der Krieger« handelte…) is not speech.
|
||||
function isQuotedTerm(text, a, b) {
|
||||
const inner = text.slice(a, b).trim();
|
||||
if (!inner) return true;
|
||||
if (/[.!?…]["»«”’)]*$/.test(inner)) return false;
|
||||
const before = text.slice(0, a).trimEnd();
|
||||
const after = text.slice(b).replace(/^[»«“”"’)\s]+/, '');
|
||||
const beforeOpen = before && !/[.!?…:]["»«”’)]*$/.test(before);
|
||||
const afterCont = /^[a-zäöüß,;]/.test(after);
|
||||
return beforeOpen && afterCont;
|
||||
}
|
||||
|
||||
function segmentByQuotes(text, opts) {
|
||||
const style = (opts && opts.style) || detectStyle(text);
|
||||
if (!style) return null;
|
||||
const spans = [];
|
||||
const push = (kind, a, b) => { if (text.slice(a, b).trim()) spans.push({ kind, start: a, end: b }); };
|
||||
const paraBreakNext = (i) => /^\s*\n\s*\n/.test(text.slice(i, i + 6));
|
||||
let narrStart = 0, qStart = 0, inQuote = false;
|
||||
|
||||
for (let i = 0; i < text.length; i++) {
|
||||
const ch = text[i];
|
||||
if (!inQuote) {
|
||||
if (ch === style.open) { push('narration', narrStart, i); inQuote = true; qStart = i + 1; }
|
||||
else if (ch === style.close) {
|
||||
const s = recoverStart(text, Math.max(narrStart, 0), i);
|
||||
push('narration', narrStart, s); push('dialogue', s, i); narrStart = i + 1;
|
||||
}
|
||||
} else if (ch === style.close) {
|
||||
push('dialogue', qStart, i); inQuote = false; narrStart = i + 1;
|
||||
} else if (ch === style.open) {
|
||||
// A second opener with no closer between: the first quote lost its closer.
|
||||
const end = recoverEnd(text, qStart, i);
|
||||
push('dialogue', qStart, end); push('narration', end, i);
|
||||
qStart = i + 1; narrStart = i + 1;
|
||||
} else if (ch === '\n' && paraBreakNext(i)) {
|
||||
// Paragraph break inside a quote. A long speech simply continues across
|
||||
// paragraphs (some typesetting re-opens each one, some only closes the
|
||||
// last), so keep going while a closing mark still follows before the next
|
||||
// opener; only when it never does was the closer lost.
|
||||
const rest = text.slice(i).replace(/^\s+/, '');
|
||||
if (rest[0] !== style.open) {
|
||||
const nextClose = text.indexOf(style.close, i), nextOpen = text.indexOf(style.open, i);
|
||||
const closesFirst = nextClose !== -1 && (nextOpen === -1 || nextClose < nextOpen) && nextClose - i <= MAX_SPEECH_CHARS;
|
||||
if (!closesFirst) {
|
||||
const end = recoverEnd(text, qStart, i);
|
||||
push('dialogue', qStart, end); push('narration', end, i);
|
||||
inQuote = false; narrStart = i;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (inQuote) {
|
||||
const end = recoverEnd(text, qStart, text.length);
|
||||
push('dialogue', qStart, end); push('narration', end, text.length);
|
||||
} else push('narration', narrStart, text.length);
|
||||
|
||||
// Quoted terms/titles are narration; then merge neighbouring narration.
|
||||
for (const sp of spans) if (sp.kind === 'dialogue' && isQuotedTerm(text, sp.start, sp.end)) sp.kind = 'narration';
|
||||
const merged = [];
|
||||
for (const sp of spans) {
|
||||
const last = merged[merged.length - 1];
|
||||
if (last && last.kind === 'narration' && sp.kind === 'narration') last.end = sp.end;
|
||||
else merged.push(Object.assign({}, sp));
|
||||
}
|
||||
return merged.map((sp, index) => ({
|
||||
index, kind: sp.kind, start: sp.start, end: sp.end,
|
||||
text: text.slice(sp.start, sp.end).replace(/[»«]/g, '').replace(/[ \t]+/g, ' ').trim(),
|
||||
})).filter(sp => sp.text);
|
||||
}
|
||||
|
||||
// Groups consecutive spans into passages for one LLM call each. Cuts prefer a
|
||||
// paragraph boundary once a window is reasonably full; a window never exceeds
|
||||
// `hardChars` unless a single span alone does. Ids are global to the book
|
||||
// (N/D + running index) so a window can be re-sent or resumed unchanged.
|
||||
function buildWindows(spans, text, opts) {
|
||||
const target = (opts && opts.targetChars) || 2400;
|
||||
const hard = (opts && opts.hardChars) || 3600;
|
||||
const windows = [];
|
||||
let cur = [], chars = 0;
|
||||
const flush = () => { if (cur.length) windows.push(cur); cur = []; chars = 0; };
|
||||
for (let i = 0; i < spans.length; i++) {
|
||||
const sp = spans[i];
|
||||
if (cur.length && chars + sp.text.length > hard) flush();
|
||||
cur.push(sp); chars += sp.text.length;
|
||||
const next = spans[i + 1];
|
||||
if (next && chars >= target) {
|
||||
const gap = text.slice(sp.end, next.start);
|
||||
if (/\n\s*\n/.test(gap) || chars >= hard * 0.9) flush();
|
||||
}
|
||||
}
|
||||
flush();
|
||||
// Attribution only needs the edges of a long span (speech tags sit there), so
|
||||
// what is sent to the LLM is abbreviated; the caller keeps the full text.
|
||||
const forLlm = (t, max) => t.length <= max ? t : t.slice(0, Math.ceil(max * 0.6)).trimEnd() + ' [...] ' + t.slice(-Math.floor(max * 0.4)).trimStart();
|
||||
return windows.map((w, index) => ({
|
||||
index,
|
||||
spans: w.map(sp => ({
|
||||
id: (sp.kind === 'dialogue' ? 'D' : 'N') + sp.index, kind: sp.kind, text: sp.text,
|
||||
llmText: forLlm(sp.text, sp.kind === 'dialogue' ? 1500 : 900), start: sp.start, end: sp.end,
|
||||
})),
|
||||
hasDialogue: w.some(sp => sp.kind === 'dialogue'),
|
||||
}));
|
||||
}
|
||||
|
||||
const spansApi = { detectStyle, segmentByQuotes, buildWindows, looksLikeSpeech, recoverStart, recoverEnd, isQuotedTerm };
|
||||
root.TTVC = root.TTVC || {};
|
||||
root.TTVC.audiobook = root.TTVC.audiobook || {};
|
||||
root.TTVC.audiobook.spans = spansApi;
|
||||
if (typeof module !== 'undefined' && module.exports) module.exports = spansApi;
|
||||
})(typeof window !== 'undefined' ? window : globalThis);
|
||||
@ -329,6 +329,7 @@ function _abSaveDraft(segs, roster, text, done, total) {
|
||||
roster: roster,
|
||||
pageMarks: _audiobook.pageMarks || [],
|
||||
rehId: _audiobook.rehId || null,
|
||||
mode: _audiobook.castMode || 'classic',
|
||||
done: done,
|
||||
total: total,
|
||||
savedAt: Date.now()
|
||||
|
||||
@ -52,8 +52,10 @@
|
||||
"audiobook/network",
|
||||
"audiobook-parse",
|
||||
"audiobook-segmentation",
|
||||
"audiobook-spans",
|
||||
"audiobook-cast",
|
||||
"audiobook-pipeline",
|
||||
"audiobook-cast-spans",
|
||||
"audiobook-preview-export",
|
||||
"audiobook",
|
||||
"character-sheets"
|
||||
@ -76,8 +78,10 @@
|
||||
"audiobook/network",
|
||||
"audiobook-parse",
|
||||
"audiobook-segmentation",
|
||||
"audiobook-spans",
|
||||
"audiobook-cast",
|
||||
"audiobook-pipeline",
|
||||
"audiobook-cast-spans",
|
||||
"audiobook-preview-export",
|
||||
"audiobook",
|
||||
"character-sheets",
|
||||
@ -104,8 +108,10 @@
|
||||
"audiobook/network",
|
||||
"audiobook-parse",
|
||||
"audiobook-segmentation",
|
||||
"audiobook-spans",
|
||||
"audiobook-cast",
|
||||
"audiobook-pipeline",
|
||||
"audiobook-cast-spans",
|
||||
"audiobook-preview-export",
|
||||
"audiobook",
|
||||
"character-sheets",
|
||||
|
||||
33
tests/audiobook-spans.spec.js
Normal file
33
tests/audiobook-spans.spec.js
Normal file
@ -0,0 +1,33 @@
|
||||
// @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'));
|
||||
});
|
||||
68
tests/audiobook-spans.test.mjs
Normal file
68
tests/audiobook-spans.test.mjs
Normal file
@ -0,0 +1,68 @@
|
||||
// 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);
|
||||
});
|
||||
145
tests/test_attribution_spans.py
Normal file
145
tests/test_attribution_spans.py
Normal file
@ -0,0 +1,145 @@
|
||||
"""Span-based speaker attribution: request validation, prompt, parsing, route."""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import tempfile
|
||||
import unittest
|
||||
from unittest import mock
|
||||
|
||||
os.environ.setdefault("CONFIG_DIR", tempfile.mkdtemp(prefix="ttvc-spans-test-config-"))
|
||||
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from core.attribution_spans import (
|
||||
SpanRequestError, build_messages, max_tokens_for, parse_response, validate_spans,
|
||||
)
|
||||
import routes.conversation as conversation
|
||||
|
||||
SPANS = [
|
||||
{"id": "N0", "kind": "narration", "text": "Der Zwerg schneuzte sich."},
|
||||
{"id": "D1", "kind": "dialogue", "text": "Na los, steh hier nicht weiter rum."},
|
||||
{"id": "N2", "kind": "narration", "text": "sagte Linosch."},
|
||||
{"id": "D3", "kind": "dialogue", "text": "Vielen Dank!"},
|
||||
]
|
||||
|
||||
|
||||
class ValidateSpansTests(unittest.TestCase):
|
||||
def test_accepts_a_well_formed_window(self):
|
||||
self.assertEqual([s["id"] for s in validate_spans(SPANS)], ["N0", "D1", "N2", "D3"])
|
||||
|
||||
def test_rejects_bad_shapes(self):
|
||||
bad = [
|
||||
None, [], "x", [1],
|
||||
[{"id": "X1", "kind": "dialogue", "text": "a"}], # id pattern
|
||||
[{"id": "D1", "kind": "narration", "text": "a"}], # kind/id mismatch
|
||||
[{"id": "D1", "kind": "dialogue", "text": " "}], # empty text
|
||||
[{"id": "D1", "kind": "dialogue", "text": "a"}, {"id": "D1", "kind": "dialogue", "text": "b"}],
|
||||
[{"id": "N1", "kind": "narration", "text": "only narration"}], # nothing to attribute
|
||||
[{"id": "D%d" % i, "kind": "dialogue", "text": "a"} for i in range(401)],
|
||||
[{"id": "D1", "kind": "dialogue", "text": "x" * 20_001}],
|
||||
]
|
||||
for case in bad:
|
||||
with self.subTest(case=str(case)[:60]):
|
||||
with self.assertRaises(SpanRequestError):
|
||||
validate_spans(case)
|
||||
|
||||
|
||||
class PromptTests(unittest.TestCase):
|
||||
def test_prompt_lists_every_line_and_the_ids_to_attribute(self):
|
||||
system, user = build_messages(SPANS, ["Marcian"], "Marcian: Hallo", "Danach ging er.", "German")
|
||||
self.assertIn("Do NOT rewrite", system)
|
||||
self.assertIn("German", system)
|
||||
self.assertIn('[D1] "Na los, steh hier nicht weiter rum."', user)
|
||||
self.assertIn("[N2] sagte Linosch.", user)
|
||||
self.assertIn("Known characters so far: Marcian", user)
|
||||
self.assertIn("Recent dialogue", user)
|
||||
self.assertIn("Attribute these lines: D1, D3", user)
|
||||
|
||||
def test_only_ids_restricts_the_request_for_a_retry(self):
|
||||
_, user = build_messages(SPANS, only_ids=["D3"])
|
||||
self.assertIn("Attribute these lines: D3", user)
|
||||
self.assertNotIn("D1, D3", user)
|
||||
|
||||
def test_token_budget_grows_with_dialogue_and_is_capped(self):
|
||||
many = lambda n: [{"id": "D%d" % i, "kind": "dialogue", "text": "a"} for i in range(n)]
|
||||
self.assertLess(max_tokens_for(SPANS), max_tokens_for(many(30)))
|
||||
self.assertLessEqual(max_tokens_for(many(400)), 4096)
|
||||
|
||||
|
||||
class ParseTests(unittest.TestCase):
|
||||
def test_parses_clean_json_and_normalises_names(self):
|
||||
raw = '{"speakers":[{"id":"D1","speaker":" Linosch ","emotion":"gereizt"},{"id":"D3","speaker":"unbekannt","emotion":""}]}'
|
||||
out = parse_response(raw, validate_spans(SPANS))
|
||||
self.assertEqual(out["speakers"]["D1"], {"speaker": "Linosch", "emotion": "gereizt"})
|
||||
self.assertEqual(out["speakers"]["D3"]["speaker"], "Unknown")
|
||||
self.assertEqual(out["missing"], [])
|
||||
self.assertEqual(out["characters"], ["Linosch"])
|
||||
|
||||
def test_ignores_reasoning_fences_and_unknown_ids(self):
|
||||
raw = ('<think>D1 is Linosch, {"id":"D1","speaker":"Wrong"}</think>\n```json\n'
|
||||
'{"speakers":[{"id":"D1","speaker":"Linosch","emotion":""},{"id":"D9","speaker":"Ghost"}]}\n```')
|
||||
out = parse_response(raw, validate_spans(SPANS))
|
||||
self.assertEqual(out["speakers"]["D1"]["speaker"], "Linosch")
|
||||
self.assertNotIn("D9", out["speakers"])
|
||||
self.assertEqual(out["missing"], ["D3"])
|
||||
|
||||
def test_salvages_a_truncated_answer(self):
|
||||
raw = '{"speakers":[{"id":"D1","speaker":"Linosch","emotion":"x"},{"id":"D3","speak'
|
||||
out = parse_response(raw, validate_spans(SPANS))
|
||||
self.assertEqual(list(out["speakers"]), ["D1"])
|
||||
self.assertEqual(out["missing"], ["D3"])
|
||||
|
||||
def test_narrator_answer_for_a_non_speech_line_is_kept_out_of_the_cast(self):
|
||||
raw = '{"speakers":[{"id":"D1","speaker":"Erzähler"},{"id":"D3","speaker":"Marcian"}]}'
|
||||
out = parse_response(raw, validate_spans(SPANS))
|
||||
self.assertEqual(out["speakers"]["D1"]["speaker"], "Narrator")
|
||||
self.assertEqual(out["characters"], ["Marcian"])
|
||||
|
||||
|
||||
class RouteTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
app = FastAPI()
|
||||
app.include_router(conversation.router)
|
||||
self.client = TestClient(app)
|
||||
|
||||
def test_rejects_an_invalid_request_before_calling_the_llm(self):
|
||||
with mock.patch.object(conversation, "_span_llm_answer") as llm:
|
||||
r = self.client.post("/api/attribute-spans", json={"spans": [{"id": "N1", "kind": "narration", "text": "x"}]})
|
||||
self.assertEqual(r.status_code, 400)
|
||||
llm.assert_not_called()
|
||||
|
||||
def test_returns_speakers_and_never_the_text(self):
|
||||
answer = '{"speakers":[{"id":"D1","speaker":"Linosch","emotion":"gereizt"},{"id":"D3","speaker":"Alrik","emotion":""}]}'
|
||||
with mock.patch.object(conversation, "_span_llm_answer", return_value=answer) as llm:
|
||||
r = self.client.post("/api/attribute-spans", json={"spans": SPANS, "known_characters": ["Alrik"]})
|
||||
self.assertEqual(r.status_code, 200)
|
||||
body = r.json()
|
||||
self.assertEqual(body["speakers"]["D3"]["speaker"], "Alrik")
|
||||
self.assertEqual(body["missing"], [])
|
||||
self.assertNotIn("Zwerg", r.text)
|
||||
llm.assert_called_once()
|
||||
|
||||
def test_retries_only_the_lines_the_model_skipped(self):
|
||||
first = '{"speakers":[{"id":"D1","speaker":"Linosch","emotion":""}]}'
|
||||
second = '{"speakers":[{"id":"D3","speaker":"Alrik","emotion":""}]}'
|
||||
with mock.patch.object(conversation, "_span_llm_answer", side_effect=[first, second]) as llm:
|
||||
r = self.client.post("/api/attribute-spans", json={"spans": SPANS})
|
||||
body = r.json()
|
||||
self.assertEqual(llm.call_count, 2)
|
||||
self.assertEqual({k: v["speaker"] for k, v in body["speakers"].items()}, {"D1": "Linosch", "D3": "Alrik"})
|
||||
self.assertEqual(body["missing"], [])
|
||||
retry_prompt = llm.call_args_list[1].args[0]["messages"][1]["content"]
|
||||
self.assertIn("Attribute these lines: D3", retry_prompt)
|
||||
|
||||
def test_reports_failure_instead_of_erroring_when_the_llm_is_down(self):
|
||||
with mock.patch.object(conversation, "_span_llm_answer", side_effect=RuntimeError("LLM HTTP 503")):
|
||||
r = self.client.post("/api/attribute-spans", json={"spans": SPANS})
|
||||
self.assertEqual(r.status_code, 200)
|
||||
body = r.json()
|
||||
self.assertTrue(body["failed"])
|
||||
self.assertEqual(body["missing"], ["D1", "D3"])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Loading…
Reference in New Issue
Block a user