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.
375 lines
14 KiB
Python
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())
|