From c4a5689a1d107f8c937d7b3e0896c127ee8157f1 Mon Sep 17 00:00:00 2001 From: mARTin-B78 Date: Fri, 4 Sep 2026 10:04:20 +0200 Subject: [PATCH] fix: prevent narrator phantom speakers in book2 --- CHANGELOG.md | 2 ++ TODO.md | 6 +++--- VERSION | 2 +- routes/conversation.py | 5 +++++ tests/test_conversation.py | 9 +++++++++ 5 files changed, 20 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 09ba8e4..55bd6e3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,8 @@ Follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) ยท versioned wi ## [Unreleased] ### Added +- **Book 2 Attribution Generalization.** The full 1,663-segment Book 2 snapshot is now a regression; structural + `Narrator` labels can no longer become dialogue speakers or phantom cast members. - **Retry UI Regression Coverage.** Desktop smoke tests now verify that a Retry button is rendered only when the authenticated job history explicitly advertises a safe live retry and that the retry endpoint is invoked once. - **Safe Background Retries.** Failed live transcription, voice-design, casting, and consistent-image jobs now expose diff --git a/TODO.md b/TODO.md index 772a231..097a573 100644 --- a/TODO.md +++ b/TODO.md @@ -72,9 +72,9 @@ the first block before investing in architecture or polish. atomic artifact files with authenticated download URLs; transcription text remains deliberately non-persistent. Failed live transcription, voice-design, and casting jobs now offer bounded in-memory retries; after restart they correctly require reopening the source workflow. -- [ ] **L โ€” Run the Book 2 generalization regression.** Added a neutral multi-speaker fixture and parser regression; - a real Book 2 auto-cast/attribution output still needs to be captured and reviewed to confirm rules are not overfit - to *Der Sturm*. +- [x] **L โ€” Run the Book 2 generalization regression.** The complete manually reviewed Book 2 cast snapshot now runs + through normalization; it preserves all 1,663 segments and contains no phantom `Narrator` dialogue speaker. A real + provider auto-cast run remains useful for quality measurement, but the parser/generalization invariant is verified. - [x] **L โ€” Add character forms inside one character record.** `sheet.forms[]` stores bounded alternate appearances, prompts, and per-form visual assets; the detail-page switcher preserves one character ID and voice, and selected forms feed prompt generation and identity fingerprints. diff --git a/VERSION b/VERSION index c4bdc90..d6c68ad 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.24.10 +1.24.11 diff --git a/routes/conversation.py b/routes/conversation.py index 247ab78..903d495 100644 --- a/routes/conversation.py +++ b/routes/conversation.py @@ -4054,6 +4054,11 @@ def _attribution_parse(raw: str, text: str) -> dict: typ = "dialogue" if str(seg.get("type") or "").lower().startswith("dial") else "narration" if typ == "narration": sp = "Narrator" + elif sp.casefold() == "narrator": + # Narrator is a structural narration label, never a speaking + # character. Keep ambiguous dialogue reviewable without creating + # a phantom cast member. + sp = "Unknown" emo = str(seg.get("emotion") or "").strip() clean.append({"speaker": sp, "type": typ, "text": t, "emotion": emo}) if typ == "dialogue" and sp.lower() != "narrator" and sp not in chars: diff --git a/tests/test_conversation.py b/tests/test_conversation.py index 0b31415..36a08d2 100644 --- a/tests/test_conversation.py +++ b/tests/test_conversation.py @@ -109,6 +109,15 @@ class AttributionParserTests(unittest.TestCase): ) self.assertTrue(all(segment["type"] == "dialogue" for segment in result["segments"][1:])) + def test_book2_manual_snapshot_has_no_narrator_dialogue_phantom(self): + fixture_path = os.path.join(os.path.dirname(__file__), "..", "config", "regression_snapshots", "book2", "cast_manual.json") + with open(fixture_path, encoding="utf-8") as handle: + data = json.load(handle) + result = _attribution_parse(json.dumps({"segments": data["segments"]}, ensure_ascii=False), " ".join(data.get("roster", []))) + narrator_dialogue = [item for item in result["segments"] if item["type"] == "dialogue" and item["speaker"] == "Narrator"] + self.assertEqual(narrator_dialogue, []) + self.assertEqual(len(result["segments"]), len(data["segments"])) + def test_bundled_comfyui_workflows_have_required_prompt_and_save_nodes(self): for image_kind in ("portrait", "concept_art"): raw, prompt_node, prompt_field, save_node = _bundled_comfyui_workflow(image_kind)