Files
UmbraAudio/umbraaudio/analysis/attribution_qa.py
B.D. Murphy 9bda553311 Add a speaker-review audit: is the attribution data fit for translation?
Runs after a reviewer finishes a book and answers the two questions that
gate the next stage:

  1. Are there utterances with no speaker?
  2. Now that speakers have been defined, renamed, merged or deleted, are
     there utterances that should change?

Read-only - it never edits the database, and a test asserts the files are
byte-identical afterwards. Output is a markdown table and a CSV, one row per
finding with chapter, utterance number and a text preview, so each item is
locatable rather than a count.

Deal Hunter, after the completed review (56 speakers, 0 still needing
review, 4319 utterances):

  - Dialogue with no speaker: 0. The 1803 speakerless rows are narration
    and scene breaks, which is correct and is reported as a number rather
    than silently dropped.
  - Utterances attributed to a deleted speaker: 0.
  - One speaker stored under two spellings: 6 rows across 3 speakers
    (narrator/Narrator, Kainda/Princess Kainda, Willamina Wake/Captain).
    All resolve to one voice so the audio is correct, but a per-speaker
    export would split them into six - caught before the translation stage,
    not after.
  - 6 speech-tag mismatches where the narration immediately before the line
    names a different speaker.
  - 10 dialogue rows voiced by the narrator: 9 are quoted labels ("Reactor.",
    "operations") that are correctly read by the narrator, 1 (ch28 utt46) is
    a real segmentation error. Classified advisory so the one real defect is
    not buried under nine false positives.

build_alias_map moved from scripts/qa_attribution.py into attribution_qa.py
so the CLI and the audit cannot disagree about what a speech tag resolves to;
qa_attribution output verified byte-identical.

13 new tests. Suite: 721 passed, 8 pre-existing.
2026-10-11 21:57:58 -07:00

567 lines
19 KiB
Python

"""Mechanical cross-check of speaker attribution against speech tags.
Runs after LLM attribution as a cheap, deterministic verification pass. It does
not attempt full coreference resolution; it only flags high-signal cases so a
human (or the LLM re-run) can fix them:
- ``speech_tag_mismatch``: narration immediately before a dialogue names a
speaker ("X says,") and the dialogue is attributed to someone else.
- ``narrator_dialogue``: a dialogue line is still attributed to the narrator.
- ``role_tag_guessed``: narration tags the speaker with an unnamed role ("the
lead guard", "a pirate") but the line is attributed to a named character with
no lexical connection to that role.
- ``address_mismatch``: dialogue addresses someone by name/title ("Captain,"
"Hunter,") and the attributed speaker IS that person — a character never
addresses themselves.
- ``narration_speaker_mismatch``: narration within 2 preceding lines names a
speaker via speech verb, but the dialogue is attributed to someone else.
``--fix`` only auto-applies ``speech_tag_mismatch`` (the high-confidence case).
"""
from __future__ import annotations
import re
from dataclasses import dataclass, field
from typing import Any
SPEECH_VERBS = re.compile(
r"\b(says|said|asks|asked|replies|replied|whispers|whispered|shouts|shouted|"
r"mutters|muttered|calls|called|answers|answered|tells|told|explains|explained|"
r"saying|telling|responding)\b",
)
_SUBJECT_PROPER = re.compile(
r"\b([A-Z][a-zA-Z]+(?:\s+[A-Z][a-zA-Z]+)*)\s+"
r"(says|said|asks|asked|replies|replied|whispers|whispered|shouts|shouted|"
r"mutters|muttered|calls|called|answers|answered|tells|told|explains|explained)\b",
)
_SUBJECT_LOWER = re.compile(
r"\b([Tt]he|[Aa]|[Aa]n|[Tt]his|[Tt]that)\s+([a-z][a-z-]+(?:\s+[a-z][a-z-]+){0,1})\s+"
r"(says|said|asks|asked|replies|replied|whispers|whispered|shouts|shouted|"
r"mutters|muttered|calls|called|answers|answered|tells|told|explains|explained)\b",
)
ROLE_KEYWORDS = frozenset(
{
"guard",
"guards",
"doctor",
"pilot",
"nurse",
"medic",
"robot",
"robots",
"officer",
"officers",
"crew",
"security",
"voice",
"fugitive",
"pirate",
"pirates",
"dealer",
"servant",
"informant",
"interrogator",
"questioner",
"nanny",
"warbot",
"warbots",
"messenger",
"courier",
"driver",
"attendant",
"staff",
"engineer",
"mechanic",
"sailor",
"soldier",
"patrol",
"suit",
"figure",
"person",
"stranger",
"woman",
"man",
"girl",
"boy",
"child",
"speaker",
"intruder",
"enemy",
"friend",
"employer",
"client",
"customer",
"steward",
"valet",
}
)
PRONOUN_SUBJECTS = frozenset({"he", "she", "they", "it", "we", "you", "his", "her", "their", "i"})
_CONJUNCTIONS = frozenset(
{
"and",
"or",
"but",
"then",
"while",
"when",
"so",
"that",
"who",
"with",
"which",
"from",
"before",
"after",
}
)
@dataclass
class QAIssue:
"""One flagged speaker-attribution problem for review."""
chapter_id: int
utterance_index: int
issue_type: str
speaker: str
expected: str | None = None
confidence: float = 0.5
evidence: str = ""
text: str = ""
@dataclass
class QAResult:
"""Outcome of running the cross-check over a chapter."""
chapter_id: int
issues: list[QAIssue] = field(default_factory=list)
checked: int = 0
def has(self, issue_type: str) -> bool:
return any(i.issue_type == issue_type for i in self.issues)
def by_type(self, issue_type: str) -> list[QAIssue]:
return [i for i in self.issues if i.issue_type == issue_type]
def _canonicalize(name: str, alias_map: dict[str, str]) -> str:
lower = name.strip().lower()
if lower in ("narrator", "the narrator"):
return "narrator"
return alias_map.get(lower, name.strip())
def _subject_words(subject: str) -> set[str]:
words = set(re.findall(r"[a-z]+", subject.lower()))
words.discard("the")
words.discard("a")
words.discard("an")
words.discard("this")
words.discard("that")
return words
def _match_speech_tag(text: str, alias_map: dict[str, str]) -> tuple[str | None, str | None, bool]:
"""Return ``(kind, value, resolved)`` for the speaker named by a tag line.
``kind`` is ``"name"`` (value = canonical speaker name) or ``"role"``
(value = "the lead guard"-style role phrase). ``resolved`` is True only for
name tags whose raw name is present in ``alias_map`` (safe to auto-fix).
Returns ``(None, None, False)`` when the line carries no resolvable tag.
Pronoun subjects ("He says,") are left to the LLM's coreference pass.
"""
proper = _SUBJECT_PROPER.search(text)
if proper:
raw = proper.group(1)
if raw.strip().lower() not in PRONOUN_SUBJECTS:
expected = _canonicalize(raw, alias_map)
if expected and expected != "narrator":
return "name", expected, raw.strip().lower() in alias_map
return None, None, False
lower = _SUBJECT_LOWER.search(text)
if lower:
words = lower.group(2).strip()
last = words.split()[-1].lower()
if last not in _CONJUNCTIONS:
role = f"{lower.group(1)} {words}"
if any(kw in _subject_words(role) for kw in ROLE_KEYWORDS):
return "role", role, False
return None, None, False
return None, None, False
def _names_match(tag: str, speaker: str, alias_map: dict[str, str]) -> tuple[bool, bool]:
"""Return ``(matches, resolved)`` for a name tag vs the assigned speaker.
``resolved`` is True when the tag name is a known alias/primary, making an
exact canonical comparison safe to auto-fix. Unresolved titles ("Wakam",
"Chief Felucca") fall back to word overlap against both names.
"""
tag = tag.strip()
speaker = speaker.strip()
canonical_tag = alias_map.get(tag.lower(), tag)
canonical_speaker = alias_map.get(speaker.lower(), speaker)
if canonical_tag.lower() == canonical_speaker.lower():
return True, True
resolved = tag.lower() in alias_map
overlap = bool(_subject_words(canonical_tag) & _subject_words(canonical_speaker))
return overlap, resolved
def _detect_addressee(text: str, alias_map: dict[str, str]) -> str | None:
"""Detect if dialogue addresses someone by name or title at the start.
Patterns: "Hunter, kill the connection" / "Captain, we need to jump" /
"Kainda, please." / "\"Yes, Captain."
Returns the canonical speaker name if an addressee is detected.
"""
stripped = text.strip().strip("\u201c\u201d\"'")
address_re = re.compile(
r"^(?:Yes,\s*|No,\s*|Well,\s*|So,\s*|Okay,\s*|Alright,\s*)?"
r"([A-Z][a-zA-Z]+(?:\s+[A-Z][a-zA-Z]+){0,2})\b",
)
m = address_re.match(stripped)
if m:
raw = m.group(1)
canonical = alias_map.get(raw.lower(), raw)
if canonical and canonical.lower() not in ("narrator", "the narrator"):
return canonical
title_re = re.compile(
r"^(?:Yes,\s*|No,\s*|Well,\s*|So,\s*|Okay,\s*|Alright,\s*)?"
r"(Captain|Princess|Hunter|Doctor|Sir|Ma'am|Admiral|Chief)\b",
)
m2 = title_re.match(stripped)
if m2:
title = m2.group(1)
canonical = alias_map.get(title.lower(), title)
if canonical and canonical.lower() not in ("narrator",):
return canonical
return None
def _find_narrated_speaker(
utterances: list[dict[str, Any]], dialogue_index: int, alias_map: dict[str, str]
) -> str | None:
"""Look back from dialogue_index for a narrated speaker via speech verb.
Only checks the immediately preceding utterance — a narration tag at i-2
was already consumed by the dialogue at i-1.
Returns the canonical speaker name if narration explicitly names who is
speaking before this dialogue line.
"""
j = dialogue_index - 1
if j < 0:
return None
prev = utterances[j]
prev_text = (prev.get("text") or "").strip()
if prev_text == "<<<>>>":
return None
if (prev.get("type") or "").lower() != "narration":
return None
if not SPEECH_VERBS.search(prev_text):
return None
kind, value, resolved = _match_speech_tag(prev_text, alias_map)
if kind == "name" and value:
return value
complex_narr_re = re.compile(
r"\b([A-Z][a-zA-Z]+(?:\s+[A-Z][a-zA-Z]+){0,2})\s+"
r"(?:nods|pauses|looks|checks|hits|turns|looks|smiles|frowns|shakes|holds|grabs|pushes|leans|stands|sits|walks|continues|starts|stops)\b",
)
cm = complex_narr_re.search(prev_text)
if cm:
raw = cm.group(1)
canonical = alias_map.get(raw.lower(), raw)
if canonical and canonical.lower() not in ("narrator", "the narrator"):
return canonical
return None
def build_alias_map(speakers_path) -> dict[str, str]:
"""Lowercase name/alias -> canonical ``primary_name``, plus first-word keys.
First-word entries let a speech tag like "Wakam" resolve to "Trader
Wakam". Lives here rather than in the CLI so every caller agrees on what a
tag can resolve to; scripts/qa_attribution.py imports this.
"""
import json
from pathlib import Path as _Path
path = _Path(speakers_path)
if not path.exists():
return {}
try:
data = json.loads(path.read_text())
except (OSError, ValueError):
return {}
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
first = primary.split()[0].lower() if primary.split() else ""
if first and first not in alias_map:
alias_map[first] = primary
return alias_map
def check_attribution(
utterances: list[dict[str, Any]],
chapter_id: int,
alias_map: dict[str, str] | None = None,
) -> QAResult:
"""Flag attribution problems in a chapter's utterance list.
``utterances`` must be ordered by ``utterance_index`` and contain ``text``,
``type``, and ``speaker_name``. ``alias_map`` maps lowercase names/aliases to
canonical ``primary_name`` values.
"""
alias_map = alias_map or {}
result = QAResult(chapter_id=chapter_id)
for i, u in enumerate(utterances):
if (u.get("type") or "").lower() != "dialogue":
continue
result.checked += 1
text = (u.get("text") or "").strip()
speaker = (u.get("speaker_name") or "").strip() or "narrator"
idx = int(u.get("utterance_index") or 0)
if speaker.lower() == "narrator":
result.issues.append(
QAIssue(
chapter_id=chapter_id,
utterance_index=idx,
issue_type="narrator_dialogue",
speaker=speaker,
confidence=0.95,
evidence="Dialogue line still attributed to the narrator.",
text=text[:150],
)
)
continue
# Apply a speech tag only from the immediately preceding narration
# line. A tag never spans a dialogue line (shared-tag dialogue blocks
# like "Kainda asks," + reply belong to whoever follows next).
if i == 0:
continue
prev = utterances[i - 1]
prev_text = (prev.get("text") or "").strip()
if prev_text == "<<<>>>":
continue
if (prev.get("type") or "").lower() != "narration":
continue
if not SPEECH_VERBS.search(prev_text):
continue
kind, value, resolved = _match_speech_tag(prev_text, alias_map)
if kind == "name":
assert value is not None
matches, tag_resolved = _names_match(value, speaker, alias_map)
if not matches:
result.issues.append(
QAIssue(
chapter_id=chapter_id,
utterance_index=idx,
issue_type="speech_tag_mismatch",
speaker=speaker,
expected=value,
confidence=0.9 if tag_resolved else 0.7,
evidence=f"Nearest tag: '{prev_text[:80]}'",
text=text[:150],
)
)
elif kind == "role":
assert value is not None
role_words = _subject_words(value)
speaker_words = _subject_words(speaker)
if not (role_words & speaker_words):
result.issues.append(
QAIssue(
chapter_id=chapter_id,
utterance_index=idx,
issue_type="role_tag_guessed",
speaker=speaker,
expected=value.title(),
confidence=0.5,
evidence=f"Nearest tag: '{prev_text[:80]}'",
text=text[:150],
)
)
for i, u in enumerate(utterances):
if (u.get("type") or "").lower() != "dialogue":
continue
text = (u.get("text") or "").strip()
speaker = (u.get("speaker_name") or "").strip() or "narrator"
idx = int(u.get("utterance_index") or 0)
if speaker.lower() == "narrator":
continue
canonical_speaker = _canonicalize(speaker, alias_map)
addressee = _detect_addressee(text, alias_map)
if addressee and canonical_speaker == addressee:
result.issues.append(
QAIssue(
chapter_id=chapter_id,
utterance_index=idx,
issue_type="address_mismatch",
speaker=speaker,
expected=None,
confidence=0.85,
evidence=f"Dialogue addresses '{addressee}' but speaker is also '{addressee}'",
text=text[:150],
)
)
narrated_speaker = _find_narrated_speaker(utterances, i, alias_map)
if narrated_speaker:
matches, _ = _names_match(narrated_speaker, speaker, alias_map)
if not matches:
already_flagged = any(
iss.issue_type == "speech_tag_mismatch"
and iss.utterance_index == idx
for iss in result.issues
)
if not already_flagged:
result.issues.append(
QAIssue(
chapter_id=chapter_id,
utterance_index=idx,
issue_type="narration_speaker_mismatch",
speaker=speaker,
expected=narrated_speaker,
confidence=0.80,
evidence=f"Narration names '{narrated_speaker}' as speaker",
text=text[:150],
)
)
return result
def fix_high_confidence(
utterances: list[dict[str, Any]], alias_map: dict[str, str]
) -> list[dict[str, Any]]:
"""Auto-fix only exact speech-tag mismatches (speaker == tagged name)."""
result = [dict(u) for u in utterances]
for i, u in enumerate(result):
if (u.get("type") or "").lower() != "dialogue" or i == 0:
continue
speaker = (u.get("speaker_name") or "").strip()
prev = result[i - 1]
prev_text = (prev.get("text") or "").strip()
if prev_text == "<<<>>>":
continue
if (prev.get("type") or "").lower() != "narration":
continue
if not SPEECH_VERBS.search(prev_text):
continue
kind, value, resolved = _match_speech_tag(prev_text, alias_map)
if kind == "name" and resolved:
assert value is not None
matches, _ = _names_match(value, speaker, alias_map)
if not matches:
result[i]["speaker_name"] = value
return result
def verify_conversation_flow(
utterances: list[dict[str, Any]],
chapter_id: int,
alias_map: dict[str, str] | None = None,
) -> QAResult:
"""Post-attribution check for conversation flow anomalies.
Flags:
- ``long_monologue``: 4+ consecutive dialogue lines from the same speaker
without narration breaks (likely misattribution in a dialogue scene).
- ``broken_alternation``: in a rapid A-B-A-B dialogue, a third speaker
appears without narration indicating the switch.
"""
alias_map = alias_map or {}
result = QAResult(chapter_id=chapter_id)
dialogue_runs: list[tuple[str, int, int]] = []
current_speaker = None
run_start = None
for i, u in enumerate(utterances):
utt_type = (u.get("type") or "").lower()
text = (u.get("text") or "").strip()
if text == "<<<>>>" or utt_type == "narration":
if current_speaker is not None and run_start is not None:
dialogue_runs.append((current_speaker, run_start, i - 1))
current_speaker = None
run_start = None
continue
if utt_type != "dialogue":
continue
speaker = (u.get("speaker_name") or "").strip() or "narrator"
canonical = _canonicalize(speaker, alias_map)
if canonical != current_speaker:
if current_speaker is not None and run_start is not None:
dialogue_runs.append((current_speaker, run_start, i - 1))
current_speaker = canonical
run_start = i
if current_speaker is not None and run_start is not None:
dialogue_runs.append((current_speaker, run_start, len(utterances) - 1))
for speaker, start_idx, end_idx in dialogue_runs:
run_length = end_idx - start_idx + 1
if run_length >= 4:
first_u = utterances[start_idx]
idx = int(first_u.get("utterance_index") or 0)
has_narration_between = False
for k in range(start_idx, end_idx + 1):
if k < len(utterances):
txt = (utterances[k].get("text") or "").strip()
if txt == "<<<>>>":
has_narration_between = True
break
if not has_narration_between:
texts = []
for k in range(start_idx, min(start_idx + 3, end_idx + 1)):
if k < len(utterances):
texts.append(utterances[k].get("text", "")[:60])
result.issues.append(
QAIssue(
chapter_id=chapter_id,
utterance_index=idx,
issue_type="long_monologue",
speaker=speaker,
expected=None,
confidence=0.5,
evidence=f"{run_length} consecutive lines by same speaker",
text=" | ".join(texts)[:150],
)
)
return result