Files
UmbraAudio/scripts/speaker_review_audit.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

375 lines
14 KiB
Python

"""Speaker-review audit: is the attribution data fit to hand to the next stage?
Answers the two questions asked after a speaker review completes:
1. Are there utterances with no speaker?
2. Now that speakers have been defined, renamed, merged or deleted, are
there utterances that should change?
Everything here is mechanical and read-only. It never edits the database -
these are candidate lists for a human to accept or dismiss, and the report
records what was *measured*, not what is assumed.
A null speaker on a NARRATION row is not a defect: narration is not spoken by
a character. Only dialogue without a speaker counts as missing, and a
narration row carrying a character's name is reported separately because it
usually means an inner monologue that may want the character's voice.
Usage:
python scripts/speaker_review_audit.py --project DealHunterShip_4.0e_whole-to-audio
python scripts/speaker_review_audit.py --project Last Dwarf --json
"""
from __future__ import annotations
import argparse
import csv
import json
import sqlite3
import sys
from collections import defaultdict
from dataclasses import asdict, dataclass, field
from pathlib import Path
from typing import Any
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
from umbraaudio.analysis.attribution_qa import ( # noqa: E402
build_alias_map,
check_attribution,
)
from umbraaudio.config import load_config # noqa: E402
# Issues that are safe to act on mechanically: the narration immediately
# before the line names a different speaker.
HIGH_CONFIDENCE_ISSUES = {"speech_tag_mismatch"}
# Advisory only - pronoun coreference and role guessing are judgement calls.
#
# narrator_dialogue is advisory rather than an action item on purpose. In a
# text like this most of these rows are quoted labels - a console reading
# "operations", a legend saying "Reactor." - which are typed as dialogue but
# are correctly read by the narrator. Treating them as defects would bury
# the one row that is a real segmentation error under nine false positives.
ADVISORY_ISSUES = {
"role_tag_guessed",
"address_mismatch",
"narration_speaker_mismatch",
"long_monologue",
}
PLACEHOLDER_SPEAKERS = {"", "__unassigned__", "unassigned", "unknown", "none", "null"}
@dataclass
class Finding:
"""One reviewable row. ``chapter``/``utterance`` are what a human needs."""
check: str
severity: str # "action" | "advisory" | "info"
chapter: int
utterance: int
utterance_id: int
speaker: str
expected: str
detail: str
text: str
@dataclass
class Audit:
project: str
generated: str
findings: list[Finding] = field(default_factory=list)
stats: dict[str, Any] = field(default_factory=dict)
def add(self, **kw: Any) -> None:
self.findings.append(Finding(**kw))
def by_check(self) -> dict[str, list[Finding]]:
out: dict[str, list[Finding]] = defaultdict(list)
for f in self.findings:
out[f.check].append(f)
return out
def _chapters(conn: sqlite3.Connection) -> list[int]:
return [
r[0]
for r in conn.execute("SELECT DISTINCT chapter_id FROM utterances ORDER BY chapter_id")
]
def _chapter_utterances(conn: sqlite3.Connection, chapter_id: int) -> list[dict[str, Any]]:
rows = conn.execute(
"SELECT id, utterance_index, speaker_name, type, text FROM utterances "
"WHERE chapter_id = ? ORDER BY utterance_index",
(chapter_id,),
).fetchall()
return [
{
"id": r[0],
"utterance_index": r[1],
"speaker_name": r[2],
"type": r[3],
"text": r[4] or "",
}
for r in rows
]
def run_audit(project: str | None, as_json: bool) -> Audit:
cfg = load_config(project) if project else load_config()
data_dir = cfg.data_dir
conn = sqlite3.connect(f"file:{data_dir / 'project.db'}?mode=ro", uri=True)
conn.row_factory = sqlite3.Row
from datetime import UTC, datetime
audit = Audit(
project=cfg.project_dir.name,
generated=datetime.now(UTC).strftime("%Y-%m-%d %H:%M:%SZ"),
)
speakers_path = data_dir / "speakers.json"
speakers = []
if speakers_path.exists():
speakers = json.loads(speakers_path.read_text()).get("speakers", [])
alias_map = build_alias_map(speakers_path)
known_lower = set(alias_map.keys())
# ---------------------------------------------------------- 1. no speaker
missing = conn.execute(
"SELECT id, chapter_id, utterance_index, type, speaker_name, text FROM utterances "
"WHERE type = 'dialogue' AND ("
" speaker_name IS NULL OR TRIM(speaker_name) = '' "
" OR LOWER(TRIM(speaker_name)) IN (" + ",".join("?" * len(PLACEHOLDER_SPEAKERS)) + "))",
tuple(PLACEHOLDER_SPEAKERS),
).fetchall()
for r in missing:
audit.add(
check="dialogue_missing_speaker",
severity="action",
chapter=r[1],
utterance=r[2],
utterance_id=r[0],
speaker=r[4] or "",
expected="",
detail="dialogue with no speaker",
text=(r[5] or "")[:110],
)
# Narration with no speaker is normal; count it so the report is honest
# about what was excluded rather than quietly dropping it.
audit.stats["narration_without_speaker"] = conn.execute(
"SELECT COUNT(*) FROM utterances WHERE type IN ('narration','scene_break') "
"AND (speaker_name IS NULL OR TRIM(speaker_name) = '')"
).fetchone()[0]
# ------------------------------------------------- 2. unresolvable speaker
used = {
r[0]
for r in conn.execute(
"SELECT DISTINCT speaker_name FROM utterances "
"WHERE speaker_name IS NOT NULL AND TRIM(speaker_name) <> ''"
)
}
for name in sorted(used):
if name.casefold() in known_lower:
continue
rows = conn.execute(
"SELECT id, chapter_id, utterance_index, text FROM utterances WHERE speaker_name = ?",
(name,),
).fetchall()
for r in rows:
audit.add(
check="speaker_not_resolvable",
severity="action",
chapter=r[1],
utterance=r[2],
utterance_id=r[0],
speaker=name,
expected="",
detail="attributed to a speaker that no longer exists",
text=(r[3] or "")[:110],
)
# ------------------------------------- 3. identity / case fragmentation
# The database stores whatever string was written at attribution time, so
# one speaker can appear under several spellings ("narrator" and "Narrator",
# for instance). Both resolve through the alias map so the audio is correct
# today, but anything that groups by the raw column will split them - which
# is exactly what a translation export would do. Reported here so it is a
# decision rather than a surprise downstream.
canonical: dict[str, set[str]] = defaultdict(set)
for name in used:
target = alias_map.get(name.casefold())
if target:
canonical[target].add(name)
for target, variants in canonical.items():
if len(variants) < 2:
continue
for variant in sorted(variants):
count = conn.execute(
"SELECT COUNT(*) FROM utterances WHERE speaker_name = ?", (variant,)
).fetchone()[0]
if not count:
continue
audit.add(
check="speaker_name_variants",
severity="action",
chapter=0,
utterance=0,
utterance_id=0,
speaker=variant,
expected=target,
detail=(
f"{count} utterances stored under this spelling; the same "
"speaker also appears as "
f"{sorted(variants - {variant})}. They resolve to one voice, "
"but a per-speaker export would split them."
),
text="",
)
# ------------------------------------- 4. new speaker vs stale attribution
# This is the "a speaker has since been defined" question: the narration
# right before the line names speaker X, and the line is not attributed
# to X.
for chapter_id in _chapters(conn):
utts = _chapter_utterances(conn, chapter_id)
result = check_attribution(utts, chapter_id, alias_map)
for issue in result.issues:
idx = issue.utterance_index
row = next((u for u in utts if u["utterance_index"] == idx), None)
if row is None:
continue
severity = (
"action"
if issue.issue_type in HIGH_CONFIDENCE_ISSUES
else "advisory"
)
audit.add(
check=issue.issue_type,
severity=severity,
chapter=chapter_id,
utterance=idx,
utterance_id=row["id"],
speaker=row["speaker_name"] or "",
expected=issue.expected or "",
detail=issue.evidence or "",
text=(row["text"] or "")[:110],
)
audit.stats.update(
utterances=conn.execute("SELECT COUNT(*) FROM utterances").fetchone()[0],
dialogue=conn.execute(
"SELECT COUNT(*) FROM utterances WHERE type='dialogue'"
).fetchone()[0],
speakers=len(speakers),
names_and_aliases=len(known_lower),
)
return audit
def write_reports(audit: Audit, out_dir: Path) -> tuple[Path, Path]:
out_dir.mkdir(parents=True, exist_ok=True)
md = out_dir / "speaker_review_audit.md"
csv_path = out_dir / "speaker_review_audit.csv"
grouped = audit.by_check()
order = [
("dialogue_missing_speaker", "Dialogue with no speaker"),
("speaker_not_resolvable", "Attributed to a speaker that no longer exists"),
("narrator_dialogue", "Dialogue still voiced by the narrator"),
("speech_tag_mismatch", "Narration names a different speaker"),
("speaker_name_variants", "One speaker, several spellings in the database"),
("role_tag_guessed", "Role tag guessed as a named character"),
("narration_speaker_mismatch", "Narration speaker disagrees (pronoun)"),
("address_mismatch", "Addressed to the speaker themselves"),
("long_monologue", "Long monologue"),
]
action = [f for f in audit.findings if f.severity == "action"]
lines = [
f"# Speaker review audit - {audit.project}",
"",
f"Generated {audit.generated}. Read-only: nothing was modified.",
"",
"## Summary",
"",
f"- Utterances: **{audit.stats.get('utterances')}** "
f"({audit.stats.get('dialogue')} dialogue)",
f"- Speakers: **{audit.stats.get('speakers')}** "
f"({audit.stats.get('names_and_aliases')} distinct names + aliases)",
f"- Narration rows with no speaker: "
f"**{audit.stats.get('narration_without_speaker')}** - expected, narration is "
"not spoken by a character",
f"- Findings needing a decision: **{len(action)}**",
"",
]
if not audit.findings:
lines += ["No findings. The attribution data is clean.", ""]
for check, title in order:
rows = grouped.get(check)
if not rows:
continue
lines += [f"## {title} ({len(rows)})", ""]
lines += ["| Chapter | Utt | Severity | Speaker | Expected | Detail |",
"|---|---|---|---|---|---|"]
for f in sorted(rows, key=lambda r: (r.chapter, r.utterance)):
detail = (f.detail or "").replace("|", "\\|")[:70]
# Book-level findings have no single utterance; "-" beats a
# misleading chapter 0 / utterance 0.
where = f"{f.chapter} | {f.utterance}" if f.chapter else "- | -"
lines.append(
f"| {where} | {f.severity} | {f.speaker} | "
f"{f.expected} | {detail} |"
)
lines.append("")
md.write_text("\n".join(lines))
with open(csv_path, "w", newline="") as fh:
w = csv.DictWriter(fh, fieldnames=list(asdict(audit.findings[0]).keys()))
w.writeheader()
for f in audit.findings:
w.writerow(asdict(f))
return md, csv_path
def main() -> int:
ap = argparse.ArgumentParser(description=__doc__)
ap.add_argument("--project")
ap.add_argument("--json", action="store_true", help="print JSON to stdout")
ap.add_argument("--out", help="output directory (default: <project>/.umbra/reports)")
args = ap.parse_args()
audit = run_audit(args.project, args.json)
cfg = load_config(args.project) if args.project else load_config()
out_dir = Path(args.out) if args.out else cfg.data_dir / "reports"
md, csv_path = write_reports(audit, out_dir)
if args.json:
print(json.dumps({
"project": audit.project,
"generated": audit.generated,
"stats": audit.stats,
"findings": [asdict(f) for f in audit.findings],
}, indent=2))
else:
action = sum(1 for f in audit.findings if f.severity == "action")
print(f" {audit.project}: {len(audit.findings)} findings "
f"({action} need a decision)")
for f in audit.findings:
if f.severity != "action":
continue
print(f" [{f.check}] ch{f.chapter} utt{f.utterance} "
f"speaker={f.speaker!r} expected={f.expected!r}")
print(f" written: {md}")
print(f" written: {csv_path}")
return 0
if __name__ == "__main__":
raise SystemExit(main())