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

133 lines
4.4 KiB
Python

#!/usr/bin/env python3
"""Mechanical QA cross-check of speaker attribution against speech tags.
Usage:
python scripts/qa_attribution.py # check all chapters
python scripts/qa_attribution.py --chapters 6 # check chapter id 6
python scripts/qa_attribution.py --fix # auto-fix exact name-tag mismatches
python scripts/qa_attribution.py --type role_tag_guessed # only one issue type
"""
from __future__ import annotations
import argparse
import sqlite3
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).parent.parent))
from umbraaudio.analysis.attribution_qa import (
QAIssue,
build_alias_map,
check_attribution,
fix_high_confidence,
verify_conversation_flow,
)
from umbraaudio.config import load_config
def main() -> None:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument(
"--project", type=str, default=None, help="Project name or path"
)
parser.add_argument("--chapters", type=str, default=None, help="Comma-separated chapter IDs")
parser.add_argument("--fix", action="store_true", help="Auto-fix exact speech-tag mismatches")
parser.add_argument(
"--type",
dest="issue_type",
type=str,
default=None,
help=(
"Only report one issue type "
"(speech_tag_mismatch, narrator_dialogue, role_tag_guessed, "
"address_mismatch, narration_speaker_mismatch, long_monologue)"
),
)
args = parser.parse_args()
cfg = load_config(args.project)
db_path = cfg.db_path
if not db_path.exists():
print(f"Database not found: {db_path}")
return
alias_map = build_alias_map(cfg.data_dir / "speakers.json")
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()
totals = {
"speech_tag_mismatch": 0,
"narrator_dialogue": 0,
"role_tag_guessed": 0,
"address_mismatch": 0,
"narration_speaker_mismatch": 0,
"long_monologue": 0,
}
all_issues: list[QAIssue] = []
for ch in chapters:
ch_id = ch["id"]
rows = db.execute(
"SELECT id, utterance_index, text, type, speaker_name "
"FROM utterances WHERE chapter_id=? ORDER BY utterance_index",
(ch_id,),
).fetchall()
utts = [dict(r) for r in rows]
result = check_attribution(utts, ch_id, alias_map)
flow_result = verify_conversation_flow(utts, ch_id, alias_map)
result.issues.extend(flow_result.issues)
if args.fix:
fixed = fix_high_confidence(utts, alias_map)
for orig, new in zip(utts, fixed):
if orig.get("speaker_name") != new.get("speaker_name"):
db.execute(
"UPDATE utterances SET speaker_name=? WHERE id=?",
(new["speaker_name"], orig["id"]),
)
print(
f" FIX ch{ch_id} utt{new.get('utterance_index')}: "
f"{orig.get('speaker_name')} -> {new['speaker_name']}"
)
db.commit()
for issue in result.issues:
if args.issue_type and issue.issue_type != args.issue_type:
continue
totals[issue.issue_type] += 1
all_issues.append(issue)
print(
f"[{issue.issue_type}] ch{issue.chapter_id} utt{issue.utterance_index} "
f"speaker={issue.speaker!r} expected={issue.expected!r} conf={issue.confidence}"
)
if issue.evidence:
print(f" evidence: {issue.evidence}")
print(f" text: {issue.text}")
db.close()
print(f"\nTotals: {totals}")
if all_issues:
print(f"Total issues: {len(all_issues)} (review or fix before accepting voices)")
if __name__ == "__main__":
main()