From b09e3244319ec6c91d11a6f5843cbc4234323f02 Mon Sep 17 00:00:00 2001
From: "B.D. Murphy" <161863146+bdmurphy73@users.noreply.github.com>
Date: Tue, 22 Sep 2026 19:12:50 -0700
Subject: [PATCH] Multi-pass attribution + web voice-maintenance banner + qwen
demo branding
Attribution (from prior session, now committed):
- llm_attributor: mechanical narration evidence, unnamed-role rules,
wider context (12->20 lines, 8->40 utterances), uncertain flag,
verify pass with 20-item batches and invented-name gate
- reattribute_all: --min-id/--max-id, --dry-run, --no-verify,
--verify-rounds, qa_accepted skip guard, per-chapter elapsed times
- test_multipass_attribution: 19 tests
Web banner:
- voice_maintenance module: pid-liveness-checked marker file so a
crashed run self-heals the banner away
- routes/base.html: amber banner on every page while the LLM
re-attribution run has Qwen3-TTS offline
- reattribute_all: sets banner before switching models, clears in
finally (also on failure)
- test_voice_maintenance: 8 tests
qwen3-tts submodule: UmbraAudio Voice Studio branding in web demo
(icon data-uri + favicon logo).
---
Qwen3-TTS | 2 +-
scripts/reattribute_all.py | 307 ++++++++++++------
tests/test_multipass_attribution.py | 329 ++++++++++++++++++++
tests/test_voice_maintenance.py | 75 +++++
umbraaudio/analysis/llm_attributor.py | 428 +++++++++++++++++++++++++-
umbraaudio/voice_maintenance.py | 58 ++++
umbraaudio/web/routes.py | 11 +
umbraaudio/web/templates/base.html | 7 +
8 files changed, 1126 insertions(+), 91 deletions(-)
create mode 100644 tests/test_multipass_attribution.py
create mode 100644 tests/test_voice_maintenance.py
create mode 100644 umbraaudio/voice_maintenance.py
diff --git a/Qwen3-TTS b/Qwen3-TTS
index 022e286..012e452 160000
--- a/Qwen3-TTS
+++ b/Qwen3-TTS
@@ -1 +1 @@
-Subproject commit 022e286b98fbec7e1e916cb940cdf532cd9f488e
+Subproject commit 012e45298d44e8fc6ec748ba8d194bc7d5cc6c94
diff --git a/scripts/reattribute_all.py b/scripts/reattribute_all.py
index 662fa40..d51454b 100644
--- a/scripts/reattribute_all.py
+++ b/scripts/reattribute_all.py
@@ -5,6 +5,7 @@ Preserves utterance IDs and mechanical segmentation; only updates speaker_name.
"""
from __future__ import annotations
+import json
import sqlite3
import sys
import time
@@ -12,11 +13,63 @@ from pathlib import Path
sys.path.insert(0, str(Path(__file__).parent.parent))
+from umbraaudio.analysis.attribution_qa import (
+ check_attribution,
+ verify_conversation_flow,
+)
from umbraaudio.analysis.llm_attributor import LLMAttributor
-from umbraaudio.config import AppConfig, LLMConfig
+from umbraaudio.config import load_config
+from umbraaudio.voice_maintenance import (
+ clear_voice_maintenance,
+ set_voice_maintenance,
+)
from umbraaudio.voices.model_coordinator import ModelCoordinator, PipelineStage
+def _build_alias_map(speakers_path: Path) -> dict[str, str]:
+ if not speakers_path.exists():
+ return {}
+ with open(speakers_path) as f:
+ data = json.load(f)
+ alias_map: dict[str, str] = {}
+ for s in data.get("speakers", []):
+ primary = s.get("primary_name", "")
+ if not primary:
+ continue
+ alias_map[primary.lower()] = primary
+ for alias in s.get("aliases", []):
+ if alias:
+ alias_map[alias.lower()] = primary
+ return alias_map
+
+
+def _qa_issue_count(utt_list: list[dict], chapter_id: int, alias_map: dict) -> int:
+ qa = check_attribution(utt_list, chapter_id, alias_map)
+ flow = verify_conversation_flow(utt_list, chapter_id, alias_map)
+ return len(qa.issues) + len(flow.issues)
+
+
+def _report_dry_run(attributed: list[dict], original: list[dict]) -> None:
+ """Print speaker changes the run would make (no DB writes)."""
+ orig_by_id = {u["id"]: u for u in original}
+ changed = 0
+ shown = 0
+ for u in attributed:
+ o = orig_by_id.get(u["id"])
+ if o is None:
+ continue
+ before = o.get("speaker_name") or "narrator"
+ after = u.get("speaker_name") or "narrator"
+ if (before or "").lower() == (after or "").lower():
+ continue
+ changed += 1
+ if shown < 5:
+ text = (u.get("text") or "")[:70]
+ print(f" utt {u.get('utterance_index')}: {before} -> {after} {text!r}")
+ shown += 1
+ print(f" dry-run: {changed} speaker change(s) (sample above)")
+
+
def main():
import argparse
@@ -33,6 +86,18 @@ def main():
default=None,
help="Comma-separated chapter IDs (e.g. '5,6,7')",
)
+ parser.add_argument(
+ "--min-id",
+ type=int,
+ default=None,
+ help="Only chapters with id >= this value (e.g. 11 = 'chapters 11 to the end')",
+ )
+ parser.add_argument(
+ "--max-id",
+ type=int,
+ default=None,
+ help="Only chapters with id <= this value",
+ )
parser.add_argument(
"--model",
type=str,
@@ -45,10 +110,24 @@ def main():
default=1.0,
help="Seconds to sleep between LLM batches",
)
+ parser.add_argument(
+ "--no-verify",
+ action="store_true",
+ help="Skip the multi-pass verification round (pass 3)",
+ )
+ parser.add_argument(
+ "--verify-rounds",
+ type=int,
+ default=2,
+ help="Max verification rounds for flagged lines (default 2)",
+ )
+ parser.add_argument(
+ "--dry-run",
+ action="store_true",
+ help="Attribute in memory and report vs DB, but write NOTHING",
+ )
args = parser.parse_args()
- from umbraaudio.config import load_config
-
cfg = load_config(args.project)
config = cfg.llm
@@ -59,92 +138,144 @@ def main():
print("Failed to switch models to CHAPTER_ANALYSIS stage")
sys.exit(1)
- db_path = cfg.db_path
- if not db_path.exists():
- print(f"Database not found: {db_path}")
- return
-
- db = sqlite3.connect(str(db_path))
- db.row_factory = sqlite3.Row
-
- if args.chapters:
- chapter_ids = [int(x.strip()) for x in args.chapters.split(",")]
- placeholders = ",".join("?" * len(chapter_ids))
- chapters = db.execute(
- f"SELECT id, title, chapter_index FROM chapters "
- f"WHERE id IN ({placeholders}) ORDER BY chapter_index",
- chapter_ids,
- ).fetchall()
- else:
- chapters = db.execute(
- "SELECT id, title, chapter_index FROM chapters ORDER BY chapter_index"
- ).fetchall()
-
- print(f"Found {len(chapters)} chapters to attribute")
- print(f"Model: {args.model}")
-
- attributor = LLMAttributor(config, model=args.model, data_dir=cfg.data_dir)
- total_dialogue = 0
- total_narrator_left = 0
-
- for ch in chapters:
- ch_id = ch["id"]
- title = ch["title"] or f"Chapter {ch['chapter_index']}"
-
- utts = db.execute(
- "SELECT id, utterance_index, text, type, speaker_name "
- "FROM utterances WHERE chapter_id=? ORDER BY utterance_index",
- (ch_id,),
- ).fetchall()
- if not utts:
- print(f" {title}: no utterances, skipping")
- continue
-
- utt_list = [dict(u) for u in utts]
- dialogue_before = sum(1 for u in utt_list if u.get("type") == "dialogue")
- narrator_before = sum(
- 1
- for u in utt_list
- if u.get("type") == "dialogue"
- and (u.get("speaker_name") or "narrator").lower() == "narrator"
- )
-
- attributed = attributor.attribute_speakers(utt_list, title, ch_id)
- time.sleep(0.5)
-
- for u in attributed:
- db.execute(
- "UPDATE utterances SET speaker_name=?, attribution_confidence=?, attribution_reasoning=? WHERE id=?",
- (
- u.get("speaker_name", "Narrator"),
- u.get("attribution_confidence", u.get("confidence", 0.5)),
- u.get("attribution_reasoning", "") or "",
- u["id"],
- ),
- )
- db.commit()
-
- narrator_after = sum(
- 1
- for u in attributed
- if u.get("type") == "dialogue"
- and (u.get("speaker_name") or "narrator").lower() == "narrator"
- )
- total_dialogue += dialogue_before
- total_narrator_left += narrator_after
- print(
- f" {title} (id={ch_id}): dialogue {dialogue_before}, "
- f"narrator-left {narrator_before} -> {narrator_after}"
- )
-
- db.close()
-
- coordinator.switch_to(PipelineStage.IDLE)
-
- print(
- f"\nTotal: {total_dialogue} dialogue utterances, "
- f"{total_narrator_left} still attributed to narrator"
+ set_voice_maintenance(
+ "LLM speaker re-attribution is running — Qwen3-TTS is offline "
+ "for the analysis. Voice re-generation is paused until the run finishes."
)
+
+ try:
+ db_path = cfg.db_path
+ if not db_path.exists():
+ print(f"Database not found: {db_path}")
+ return
+
+ db = sqlite3.connect(str(db_path))
+ db.row_factory = sqlite3.Row
+
+ alias_map = _build_alias_map(cfg.data_dir / "speakers.json")
+
+ if args.chapters:
+ chapter_ids = [int(x.strip()) for x in args.chapters.split(",")]
+ placeholders = ",".join("?" * len(chapter_ids))
+ chapters = db.execute(
+ f"SELECT id, title, chapter_index FROM chapters "
+ f"WHERE id IN ({placeholders}) ORDER BY chapter_index",
+ chapter_ids,
+ ).fetchall()
+ else:
+ chapters = db.execute(
+ "SELECT id, title, chapter_index FROM chapters ORDER BY chapter_index"
+ ).fetchall()
+
+ if args.min_id is not None or args.max_id is not None:
+ chapters = [
+ ch
+ for ch in chapters
+ if (args.min_id is None or ch["id"] >= args.min_id)
+ and (args.max_id is None or ch["id"] <= args.max_id)
+ ]
+
+ print(f"Found {len(chapters)} chapters to attribute")
+ print(f"Model: {args.model}")
+ print(f"Verify pass: {'ON' if not args.no_verify else 'OFF'} "
+ f"(up to {args.verify_rounds} round(s))")
+ if args.dry_run:
+ print("DRY RUN: no changes will be written")
+
+ attributor = LLMAttributor(config, model=args.model, data_dir=cfg.data_dir)
+ total_dialogue = 0
+ total_narrator_left = 0
+ total_qa_before = 0
+ total_qa_after = 0
+
+ for ch in chapters:
+ ch_id = ch["id"]
+ title = ch["title"] or f"Chapter {ch['chapter_index']}"
+ ch_start = time.time()
+
+ utts = db.execute(
+ "SELECT id, utterance_index, text, type, speaker_name, "
+ "qa_accepted, qa_accepted_sig "
+ "FROM utterances WHERE chapter_id=? ORDER BY utterance_index",
+ (ch_id,),
+ ).fetchall()
+ if not utts:
+ print(f" {title}: no utterances, skipping")
+ continue
+
+ utt_list = [dict(u) for u in utts]
+ dialogue_before = sum(1 for u in utt_list if u.get("type") == "dialogue")
+ narrator_before = sum(
+ 1
+ for u in utt_list
+ if u.get("type") == "dialogue"
+ and (u.get("speaker_name") or "narrator").lower() == "narrator"
+ )
+ qa_before = _qa_issue_count(utt_list, ch_id, alias_map)
+
+ attributed = attributor.attribute_speakers(
+ utt_list,
+ title,
+ ch_id,
+ verify=not args.no_verify,
+ verify_rounds=args.verify_rounds,
+ )
+ time.sleep(0.5)
+ ch_elapsed = time.time() - ch_start
+
+ accepted_skipped = 0
+ for u in attributed:
+ if u.get("qa_accepted"):
+ accepted_skipped += 1
+ continue
+ if args.dry_run:
+ continue
+ db.execute(
+ "UPDATE utterances SET speaker_name=?, attribution_confidence=?, "
+ "attribution_reasoning=? WHERE id=?",
+ (
+ u.get("speaker_name", "Narrator"),
+ u.get("attribution_confidence", u.get("confidence", 0.5)),
+ u.get("attribution_reasoning", "") or "",
+ u["id"],
+ ),
+ )
+ if not args.dry_run:
+ db.commit()
+
+ if args.dry_run:
+ _report_dry_run(attributed, utt_list)
+
+ narrator_after = sum(
+ 1
+ for u in attributed
+ if u.get("type") == "dialogue"
+ and (u.get("speaker_name") or "narrator").lower() == "narrator"
+ )
+ qa_after = _qa_issue_count(attributed, ch_id, alias_map)
+ total_dialogue += dialogue_before
+ total_narrator_left += narrator_after
+ total_qa_before += qa_before
+ total_qa_after += qa_after
+ print(
+ f" {title} (id={ch_id}): dialogue {dialogue_before}, "
+ f"narrator-left {narrator_before} -> {narrator_after}, "
+ f"QA issues {qa_before} -> {qa_after}"
+ f" ({ch_elapsed:.0f}s)"
+ + (f", accepted-skipped {accepted_skipped}" if accepted_skipped else "")
+ )
+
+ db.close()
+
+ print(
+ f"\nTotal: {total_dialogue} dialogue utterances, "
+ f"{total_narrator_left} still attributed to narrator, "
+ f"QA issues {total_qa_before} -> {total_qa_after}"
+ )
+ finally:
+ clear_voice_maintenance()
+ coordinator.switch_to(PipelineStage.IDLE)
+
print("Done!")
diff --git a/tests/test_multipass_attribution.py b/tests/test_multipass_attribution.py
new file mode 100644
index 0000000..4381b19
--- /dev/null
+++ b/tests/test_multipass_attribution.py
@@ -0,0 +1,329 @@
+"""Tests for the multi-pass speaker-attribution pipeline.
+
+Covers the mechanical narration-evidence builder, speaker normalization for
+parenthetical qualifiers, target collection for the verification pass, and the
+deterministic-fix + LLM verification loop itself (with a stubbed LLM).
+"""
+from __future__ import annotations
+
+from umbraaudio.analysis.llm_attributor import LLMAttributor
+
+
+def _attributor_with_speakers(speakers: list[dict]) -> LLMAttributor:
+ attr = LLMAttributor.__new__(LLMAttributor)
+ attr._load_speakers = lambda: speakers # type: ignore[method-assign]
+ return attr
+
+
+_SPEAKERS = [
+ {
+ "primary_name": "Kainda Walters Foster Murphy",
+ "aliases": ["Princess Kainda", "Princess", "Kainda"],
+ },
+ {
+ "primary_name": "Narrator",
+ "aliases": ["narrator", "the narrator"],
+ },
+ {
+ "primary_name": "Chief of the Guard",
+ "aliases": ["Lead Guard"],
+ },
+ {
+ "primary_name": "Nanny",
+ "aliases": ["Niñera"],
+ },
+ {
+ "primary_name": "Deal Hunter",
+ "aliases": ["The AI", "Ship AI"],
+ },
+ {
+ "primary_name": "Xylia",
+ "aliases": [],
+ },
+]
+
+_NARRATION = "narration"
+_DIALOGUE = "dialogue"
+
+
+def _utt(idx: int, text: str, utt_type: str = _NARRATION, speaker: str | None = None) -> dict:
+ u: dict = {
+ "utterance_index": idx,
+ "text": text,
+ "type": utt_type,
+ }
+ if speaker is not None:
+ u["speaker_name"] = speaker
+ return u
+
+
+class TestMechanicalEvidence:
+ def test_name_tag_from_preceding_narration(self) -> None:
+ attr = _attributor_with_speakers(_SPEAKERS)
+ alias_map = attr._build_alias_map()
+ scene = [
+ _utt(1, "Kainda asked quietly."),
+ _utt(2, "Have you seen the vault?", _DIALOGUE),
+ ]
+ evidence = attr._build_mechanical_evidence(scene, [scene[1]], alias_map)
+ entry = evidence[2]
+ assert entry["tag"] == "Kainda Walters Foster Murphy"
+ assert entry["tag_kind"] == "name"
+ assert "smiled" in entry["narr_before"] or "asked" in entry["narr_before"]
+
+ def test_role_tag(self) -> None:
+ attr = _attributor_with_speakers(_SPEAKERS)
+ alias_map = attr._build_alias_map()
+ scene = [
+ _utt(1, "The lead guard replied."),
+ _utt(2, "Nobody enters tonight.", _DIALOGUE),
+ ]
+ evidence = attr._build_mechanical_evidence(scene, [scene[1]], alias_map)
+ entry = evidence[2]
+ assert entry["tag_kind"] == "role"
+ assert entry["tag"].lower() == "the lead guard"
+
+ def test_pronoun_tag(self) -> None:
+ attr = _attributor_with_speakers(_SPEAKERS)
+ alias_map = attr._build_alias_map()
+ scene = [
+ _utt(1, "She asks, looking nervous."),
+ _utt(2, "Where is Father?", _DIALOGUE),
+ ]
+ evidence = attr._build_mechanical_evidence(scene, [scene[1]], alias_map)
+ entry = evidence[2]
+ assert entry["tag_kind"] == "pronoun"
+ assert entry["tag"] == "she"
+
+ def test_addressee_detected(self) -> None:
+ attr = _attributor_with_speakers(_SPEAKERS)
+ alias_map = attr._build_alias_map()
+ scene = [
+ _utt(1, "Nanny stepped closer."),
+ _utt(2, "Princess, you must run.", _DIALOGUE),
+ ]
+ evidence = attr._build_mechanical_evidence(scene, [scene[1]], alias_map)
+ entry = evidence[2]
+ assert entry["addressees"] == ["Kainda Walters Foster Murphy"]
+
+ def test_no_evidence_when_prev_is_dialogue(self) -> None:
+ attr = _attributor_with_speakers(_SPEAKERS)
+ alias_map = attr._build_alias_map()
+ scene = [
+ _utt(1, "First line.", _DIALOGUE, "Kainda Walters Foster Murphy"),
+ _utt(2, "Second line.", _DIALOGUE),
+ ]
+ evidence = attr._build_mechanical_evidence(scene, [scene[1]], alias_map)
+ entry = evidence[2]
+ assert entry["tag"] is None
+ assert entry["narr_before"] == ""
+
+
+class TestNormalizeSpeakerParenthetical:
+ def test_strips_parenthetical_qualifier_to_known_name(self) -> None:
+ attr = _attributor_with_speakers(_SPEAKERS)
+ alias_map = attr._build_alias_map()
+ assert (
+ attr._normalize_speaker("Xylia (Voice (Dragon))", alias_map)
+ == "Xylia"
+ )
+
+ def test_strips_scene_qualifier_to_known_name(self) -> None:
+ attr = _attributor_with_speakers(_SPEAKERS)
+ alias_map = attr._build_alias_map()
+ assert (
+ attr._normalize_speaker("Nanny (Scene 14)", alias_map) == "Nanny"
+ )
+
+ def test_keeps_unknown_name_with_parens_unchanged(self) -> None:
+ attr = _attributor_with_speakers(_SPEAKERS)
+ alias_map = attr._build_alias_map()
+ assert attr._normalize_speaker("Somebody (Guard)", alias_map) == "Somebody (Guard)"
+
+ def test_strips_quotes(self) -> None:
+ attr = _attributor_with_speakers(_SPEAKERS)
+ alias_map = attr._build_alias_map()
+ assert attr._normalize_speaker('"Kainda"', alias_map) == "Kainda Walters Foster Murphy"
+
+
+class TestCollectVerifyTargets:
+ def test_flags_narrator_left_dialogue(self) -> None:
+ attr = _attributor_with_speakers(_SPEAKERS)
+ alias_map = attr._build_alias_map()
+ utterances = [
+ _utt(1, "Nanny speaks."),
+ _utt(2, "Line one.", _DIALOGUE, "narrator"),
+ ]
+ targets = attr._collect_verify_targets(utterances, 6, alias_map)
+ assert 2 in targets
+
+ def test_flags_tag_mismatch(self) -> None:
+ attr = _attributor_with_speakers(_SPEAKERS)
+ alias_map = attr._build_alias_map()
+ utterances = [
+ _utt(1, "Nanny asked"),
+ _utt(2, "Line one.", _DIALOGUE, "Kainda Walters Foster Murphy"),
+ ]
+ targets = attr._collect_verify_targets(utterances, 6, alias_map)
+ assert 2 in targets
+ assert "speech_tag_mismatch" in targets[2]
+
+ def test_flags_low_confidence(self) -> None:
+ attr = _attributor_with_speakers(_SPEAKERS)
+ alias_map = attr._build_alias_map()
+ utterances = [
+ _utt(1, "Some narration."),
+ _utt(2, "Confident line.", _DIALOGUE, "Nanny"),
+ _utt(3, "Low confidence.", _DIALOGUE, "Nanny"),
+ ]
+ utterances[1]["attribution_confidence"] = 0.75
+ utterances[2]["attribution_confidence"] = 0.55
+ targets = attr._collect_verify_targets(utterances, 6, alias_map)
+ assert 3 in targets # 0.55 < 0.6 → flagged
+ assert 2 not in targets # 0.75 ≥ 0.6 → clean
+
+ def test_flags_uncertain_flag(self) -> None:
+ attr = _attributor_with_speakers(_SPEAKERS)
+ alias_map = attr._build_alias_map()
+ utterances = [
+ _utt(1, "Some narration."),
+ _utt(2, "Line one.", _DIALOGUE, "Nanny"),
+ ]
+ utterances[1]["attribution_confidence"] = 0.8
+ utterances[1]["attribution_uncertain"] = True
+ targets = attr._collect_verify_targets(utterances, 6, alias_map)
+ assert 2 in targets
+ assert "uncertain" in targets[2]
+
+
+class TestVerifyAttributions:
+ def test_applies_llm_correction(self) -> None:
+ attr = _attributor_with_speakers(_SPEAKERS)
+ alias_map = attr._build_alias_map()
+
+ def fake_llm(prompt: str) -> list[dict]:
+ return [
+ {
+ "index": 4,
+ "speaker": "Nanny",
+ "confidence": 0.95,
+ "reasoning": "Nanny is the nurse",
+ }
+ ]
+
+ attr._call_llm = fake_llm # type: ignore[method-assign]
+
+ utterances = [
+ _utt(1, "Nanny entered the room."),
+ _utt(2, "Who are you?", _DIALOGUE, "Kainda Walters Foster Murphy"),
+ _utt(3, "The nurse."),
+ _utt(4, "I am here to help you.", _DIALOGUE, "Kainda Walters Foster Murphy"),
+ ]
+ profiles = "- Kainda Walters Foster Murphy\n- Nanny"
+ result = attr._verify_attributions(
+ utterances, 6, "Chapter Six", profiles, alias_map, max_rounds=1
+ )
+ assert result[3]["speaker_name"] == "Nanny"
+
+ def test_keeps_correct_attribution_unchanged(self) -> None:
+ attr = _attributor_with_speakers(_SPEAKERS)
+ alias_map = attr._build_alias_map()
+
+ def fake_llm(prompt: str) -> list[dict]:
+ return [{"index": 2, "speaker": "Nanny", "confidence": 0.9, "reasoning": ""}]
+
+ attr._call_llm = fake_llm # type: ignore[method-assign]
+
+ utterances = [
+ _utt(1, "Nanny spoke."),
+ _utt(2, "Stay still.", _DIALOGUE, "Nanny"),
+ ]
+ profiles = "- Nanny"
+ result = attr._verify_attributions(
+ utterances, 6, "Chapter Six", profiles, alias_map, max_rounds=1
+ )
+ assert result[1]["speaker_name"] == "Nanny"
+
+ def test_rejects_narrator_correction(self) -> None:
+ attr = _attributor_with_speakers(_SPEAKERS)
+ alias_map = attr._build_alias_map()
+
+ def fake_llm(prompt: str) -> list[dict]:
+ return [{"index": 2, "speaker": "Narrator", "confidence": 0.9, "reasoning": ""}]
+
+ attr._call_llm = fake_llm # type: ignore[method-assign]
+
+ utterances = [
+ _utt(1, "Nanny spoke."),
+ _utt(2, "Stay still.", _DIALOGUE, "Nanny"),
+ ]
+ profiles = "- Nanny"
+ result = attr._verify_attributions(
+ utterances, 6, "Chapter Six", profiles, alias_map, max_rounds=1
+ )
+ assert result[1]["speaker_name"] == "Nanny"
+
+ def test_deterministic_fix_applied_before_llm(self) -> None:
+ attr = _attributor_with_speakers(_SPEAKERS)
+ alias_map = attr._build_alias_map()
+
+ def fake_llm(prompt: str) -> list[dict]:
+ return []
+
+ attr._call_llm = fake_llm # type: ignore[method-assign]
+
+ # "Nanny asked" is directly before the line → fix_high_confidence corrects.
+ utterances = [
+ _utt(1, "Nanny asked"),
+ _utt(2, "Who is there?", _DIALOGUE, "Kainda Walters Foster Murphy"),
+ ]
+ profiles = "- Nanny\n- Kainda Walters Foster Murphy"
+ result = attr._verify_attributions(
+ utterances, 6, "Chapter Six", profiles, alias_map, max_rounds=1
+ )
+ assert result[1]["speaker_name"] == "Nanny"
+
+ def test_rejects_invented_name(self) -> None:
+ attr = _attributor_with_speakers(_SPEAKERS)
+ alias_map = attr._build_alias_map()
+
+ def fake_llm(prompt: str) -> list[dict]:
+ # Digits make it fail both the alias map and the role-name heuristic.
+ return [{"index": 2, "speaker": "Guard 5", "confidence": 0.9, "reasoning": ""}]
+
+ attr._call_llm = fake_llm # type: ignore[method-assign]
+
+ utterances = [
+ _utt(1, "Nanny spoke."),
+ _utt(2, "Stay still.", _DIALOGUE, "Nanny"),
+ ]
+ utterances[1]["attribution_confidence"] = 0.5
+ profiles = "- Nanny"
+ result = attr._verify_attributions(
+ utterances, 6, "Chapter Six", profiles, alias_map, max_rounds=1
+ )
+ assert result[1]["speaker_name"] == "Nanny"
+
+ def test_verify_targets_batched_at_twenty(self) -> None:
+ attr = _attributor_with_speakers(_SPEAKERS)
+ alias_map = attr._build_alias_map()
+ calls: list[str] = []
+
+ def fake_llm(prompt: str) -> list[dict]:
+ calls.append(prompt)
+ return []
+
+ attr._call_llm = fake_llm # type: ignore[method-assign]
+
+ # 25 narrator-left dialogue lines → 25 targets → 20 + 5 in two calls.
+ utterances = [_utt(1, "Some narration.")]
+ for i in range(2, 27):
+ utterances.append(_utt(i, f"Line {i}.", _DIALOGUE, "narrator"))
+ profiles = "- Nanny"
+ attr._verify_attributions(
+ utterances, 6, "Chapter Six", profiles, alias_map, max_rounds=1
+ )
+ assert len(calls) == 2
+ assert '"index": 2' in calls[0]
+ assert '"index": 22' not in calls[0]
+ assert '"index": 22' in calls[1]
diff --git a/tests/test_voice_maintenance.py b/tests/test_voice_maintenance.py
new file mode 100644
index 0000000..43e3c31
--- /dev/null
+++ b/tests/test_voice_maintenance.py
@@ -0,0 +1,75 @@
+"""Tests for the web-UI voice-maintenance banner marker.
+
+The marker is a small JSON file whose ``pid`` field gates the banner: a
+run that died without clearing the file must not leave the banner up
+forever, so ``active_voice_maintenance`` checks the owner is still alive.
+"""
+from __future__ import annotations
+
+import json
+import os
+from typing import TYPE_CHECKING
+
+import pytest
+
+if TYPE_CHECKING:
+ from pathlib import Path
+
+from umbraaudio import voice_maintenance as vm
+
+
+@pytest.fixture
+def marker(tmp_path, monkeypatch: pytest.MonkeyPatch) -> Path:
+ path = tmp_path / "voice_maintenance.json"
+ monkeypatch.setattr(vm, "MAINTENANCE_FILE", path)
+ return path
+
+
+def test_set_writes_marker_with_live_pid(marker: Path) -> None:
+ vm.set_voice_maintenance("analysis in progress")
+ assert marker.exists()
+ data = json.loads(marker.read_text())
+ assert data["pid"] == os.getpid()
+ assert "analysis" in data["reason"]
+
+
+def test_active_returns_marker_while_owner_alive(marker: Path) -> None:
+ vm.set_voice_maintenance("analysis in progress")
+ active = vm.active_voice_maintenance()
+ assert active is not None
+ assert active["pid"] == os.getpid()
+
+
+def test_active_none_when_no_marker(marker: Path) -> None:
+ assert vm.active_voice_maintenance() is None
+
+
+def test_clear_removes_marker(marker: Path) -> None:
+ vm.set_voice_maintenance("analysis in progress")
+ vm.clear_voice_maintenance()
+ assert not marker.exists()
+ assert vm.active_voice_maintenance() is None
+
+
+def test_clear_missing_marker_is_idempotent(marker: Path) -> None:
+ vm.clear_voice_maintenance()
+ vm.clear_voice_maintenance()
+
+
+def test_active_none_when_owner_dead(marker: Path) -> None:
+ marker.write_text(
+ json.dumps({"pid": 2**24, "started_at": 1, "reason": "dead owner"})
+ )
+ assert vm.active_voice_maintenance() is None
+
+
+def test_active_none_on_corrupt_marker(marker: Path) -> None:
+ marker.write_text("{not json")
+ assert vm.active_voice_maintenance() is None
+
+
+def test_active_none_on_bad_pid_type(marker: Path) -> None:
+ marker.write_text(
+ json.dumps({"pid": "not-an-int", "started_at": 1, "reason": "bad"})
+ )
+ assert vm.active_voice_maintenance() is None
diff --git a/umbraaudio/analysis/llm_attributor.py b/umbraaudio/analysis/llm_attributor.py
index 1c8227f..1c506fc 100644
--- a/umbraaudio/analysis/llm_attributor.py
+++ b/umbraaudio/analysis/llm_attributor.py
@@ -13,6 +13,14 @@ from openai import OpenAI
from umbraaudio.config import LLMConfig
from umbraaudio.analysis.context_analyzer import ContextAnalyzer, AttributionConfidence
from umbraaudio.analysis.tone_analyzer import ToneAnalyzer, SceneEmotionalProfile
+from umbraaudio.analysis.attribution_qa import (
+ SPEECH_VERBS as _QA_SPEECH_VERBS,
+ _detect_addressee as _qa_detect_addressee,
+ _match_speech_tag as _qa_match_speech_tag,
+ check_attribution as _qa_check_attribution,
+ fix_high_confidence as _qa_fix_high_confidence,
+ verify_conversation_flow as _qa_verify_flow,
+)
logger = logging.getLogger(__name__)
@@ -23,6 +31,36 @@ _PRONOUNS = frozenset(
}
)
+# Matches a TAG narration line whose subject is a pronoun: "He said,", "She asks."
+_PRONOUN_TAG = re.compile(
+ r"^\s*(H[ei]|She|They)\s+"
+ r"(says|said|asks|asked|replies|replied|whispers|whispered|shouts|shouted|"
+ r"mutters|muttered|calls|called|answers|answered)\b",
+ re.IGNORECASE,
+)
+
+# Words allowed inside a Title Case ROLE name (rule 3g / 15).
+_ROLE_WORDS = frozenset(
+ {"the", "a", "an", "of", "in", "to", "for", "and", "or", "on", "at", "de"}
+)
+
+
+def _looks_like_role(name: str) -> bool:
+ """True when a name plausibly is a Title Case ROLE name, not a personal name."""
+ if not name or len(name) > 40:
+ return False
+ if re.search(r"[()\d]", name):
+ return False
+ words = [w for w in re.split(r"[\s-]+", name.strip()) if w]
+ if not 1 <= len(words) <= 6:
+ return False
+ for w in words:
+ if w.lower() in _ROLE_WORDS:
+ continue
+ if not w[:1].isupper():
+ return False
+ return True
+
# Enhanced prompt with context awareness and confidence scoring
PROMPT_TEMPLATE = """You are a dialogue attribution expert for audiobook production.
@@ -49,6 +87,18 @@ You are attributing dialogue for: {scene_label}
## Recent Conversation Flow (context before this batch)
{position_context}
+## Mechanical Narration Evidence
+For each utterance below, these machine-extracted fields came straight from the
+manuscript (the story's own words, not guesses):
+- "narr_before": the narration text immediately before this dialogue line, verbatim
+- "tag": the speaker named or tagged by that narration ("Kainda", "the lead guard",
+ "She"), or null when the narration tags no speaker
+- "tag_kind": "name" (exact character name), "role" (role/title), "pronoun"
+ (He/She/They), or null
+- "addressees": names or titles the dialogue itself addresses at its start, if any
+
+These fields are the STRONGEST signal available. Prefer them over dialogue content.
+
## Utterances to Attribute
{utterances_json}
@@ -76,6 +126,17 @@ You are attributing dialogue for: {scene_label}
11. CRITICAL — every returned speaker must be the ACTUAL character speaking. Assigning the wrong speaker ruins the audiobook. When genuinely uncertain, prefer an unnamed role speaker over a random named character.
12. If the Character Profiles include an AI/computer character (e.g. a ship AI), attribute dialogue to it only when the text indicates an AI or computer is speaking (e.g., "ear device", "ship AI", "computer voice"). Do NOT default undetermined utterances to an AI character if another character is more likely.
13. KEYWORD NON-MATCHING: When a character's name appears in the TEXT of a dialogue line (e.g., "The ship is the Kestrel", "I know Captain Smith", "We are near the Kestrel"), it does NOT mean that character is speaking. The speaker is determined by the preceding narration tag, NOT by character names mentioned inside the dialogue. A character can be TALKED ABOUT without being the speaker. Only attribute to a character when a narration tag or dialogue tag explicitly identifies them as the speaker.
+14. EVIDENCE PRIORITY (order of preference):
+ (a) a "name" tag in narr_before → that character speaks
+ (b) a "role" tag → the role speaker (rule 3g)
+ (c) a "pronoun" tag → resolve by rule 3f
+ (d) an addressee listed in "addressees" → the addressee is NOT the speaker (rules 3j/3k)
+ (e) scene flow and dialogue content as a last resort
+ Never override an explicit tag with a guess from dialogue content (rule 13).
+15. ROLE-NAME CONSISTENCY: once you pick a speaker for a recurring role ("the guard",
+ "a pirate"), keep using the SAME speaker name for that role throughout the chapter.
+ Do NOT emit scene-qualified variants like "Guard (Scene 2)" or invent a new role
+ name per line. Reuse the existing role speaker from the profiles.
## Scene Attribution Rules (CRITICAL)
- Chapters can contain multiple scenes separated by scene breaks (<<<>>>)
@@ -93,6 +154,59 @@ Return ONLY a JSON array of objects, one per utterance, each with:
- "speaker": the correct speaker name (use primary_name from profiles, MUST be a character name, NOT "Narrator")
- "confidence": your confidence in this attribution (0.0 - 1.0)
- "reasoning": brief explanation of key evidence (max 50 chars)
+- "uncertain": true ONLY when no narration evidence exists and you had to guess
+ from content; this flags the line for a later verification pass
+
+Return ONLY the JSON array, no other text."""
+
+
+# Second-pass prompt: verify and correct attributions a first pass already made.
+# The LLM is shown only the lines a mechanical QA pass flagged (or low-confidence
+# lines), each with its mechanical narration evidence, and asked to confirm or fix.
+VERIFY_TEMPLATE = """You are a dialogue attribution verifier for audiobook production.
+
+A first pass already attributed speakers to the lines below, but a mechanical check
+flagged some as possibly wrong. For EACH line decide whether the current attribution
+is correct; if not, give the correct speaker.
+
+## Character Profiles
+{character_profiles}
+
+## Chapter Title
+{chapter_title}
+
+## Lines to Verify
+{items_json}
+
+## Rules
+1. The narration IMMEDIATELY BEFORE a line (the "narr_before" field,
+ VERBATIM from the manuscript) is the STRONGEST signal. A "name" tag
+ there decides the speaker.
+2. A "role" tag ("the lead guard", "a pirate") → use the matching role
+ speaker's EXACT primary_name from the Character Profiles, else a
+ descriptive Title Case role name. NEVER switch to an unrelated named
+ character.
+3. A "pronoun" tag ("He says", "She says") → resolve to the most recently
+ mentioned character of that gender in the recent context, within the
+ SAME scene.
+4. The addressee of a line is NOT its speaker ("Yes, Captain" is spoken TO
+ the Captain). A character never addresses themself by name.
+5. Repeated lines ("The voice calls again, ...") keep the first
+ occurrence's speaker.
+6. NEVER use "Narrator" for dialogue when any character evidence exists.
+7. Use EXACT primary_names from the profiles, never invent names. Keep
+ role names consistent (no "Guard (Scene 2)" variants).
+8. The "issue" field explains why the line was flagged — treat it as a
+ hint, not a verdict. Confirm the current speaker when it is right.
+9. Keep the EXACT "index" values from the input. Return one object per
+ line.
+
+## Output Format
+Return ONLY a JSON array of objects, one per input line, in the same order, each with:
+- "index": the line's exact "index" value (number)
+- "speaker": the CORRECT speaker primary_name (the same as "current" if it is already right)
+- "confidence": your confidence in this attribution (0.0 - 1.0)
+- "reasoning": brief explanation (max 60 chars)
Return ONLY the JSON array, no other text."""
@@ -173,14 +287,27 @@ class LLMAttributor:
def _normalize_speaker(self, name: str, alias_map: dict[str, str]) -> str:
if not name:
return name
- lower = name.strip().lower()
+ raw = name.strip().strip("\"'“”")
+ lower = raw.lower()
if lower in ("narrator", "the narrator"):
return "narrator"
if lower in _PRONOUNS:
return "narrator"
if lower in alias_map:
return alias_map[lower]
- return name.strip()
+ # Strip a trailing parenthetical qualifier ("Xylia (Voice (Dragon))" → "Xylia")
+ # and re-check the base name. "Guard (Scene 2)" stays as-is when it IS a
+ # primary, but LLM reinventions like "Guard (Scene 3)" collapse to "Guard".
+ match = re.match(r"^(?P