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.
179 lines
6.7 KiB
Python
179 lines
6.7 KiB
Python
"""Tests for the speaker-review audit.
|
|
|
|
The audit is the gate between "the reviewer finished" and "the data goes to
|
|
translation". Two of its rules encode decisions that are easy to get wrong and
|
|
expensive to get wrong quietly:
|
|
|
|
- narration with no speaker is NOT a missing speaker; only dialogue counts;
|
|
- a speaker stored under two spellings is fine for audio (both resolve via the
|
|
alias map) but splits per-speaker exports, so it must still be reported.
|
|
|
|
The rest guards that the audit is read-only and does not invent findings.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import sqlite3
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
|
|
|
from scripts.speaker_review_audit import write_reports # noqa: E402
|
|
from umbraaudio.web.routes import SCHEMA_SQL # noqa: E402
|
|
|
|
SPEAKERS = [
|
|
{"primary_name": "Narrator", "aliases": ["narrator", "the narrator"]},
|
|
{"primary_name": "Kainda", "aliases": ["Princess Kainda"]},
|
|
]
|
|
|
|
|
|
def _project(tmp_path: Path, utterances: list[tuple]) -> Path:
|
|
"""Build a throwaway project. ``utterances`` = (idx, type, speaker, text)."""
|
|
d = tmp_path / "proj" / ".umbra"
|
|
d.mkdir(parents=True)
|
|
(d / "speakers.json").write_text(json.dumps({"speakers": SPEAKERS}))
|
|
conn = sqlite3.connect(str(d / "project.db"))
|
|
conn.executescript(SCHEMA_SQL)
|
|
for i, (idx, utype, speaker, text) in enumerate(utterances):
|
|
conn.execute(
|
|
"INSERT INTO utterances (id, chapter_id, utterance_index, type, "
|
|
"speaker_name, text) VALUES (?, 1, ?, ?, ?, ?)",
|
|
(i + 1, idx, utype, speaker, text),
|
|
)
|
|
conn.commit()
|
|
conn.close()
|
|
return d
|
|
|
|
|
|
def _audit(tmp_path: Path, utterances, monkeypatch):
|
|
d = _project(tmp_path, utterances)
|
|
from umbraaudio.config import AppConfig
|
|
|
|
cfg = AppConfig()
|
|
cfg.project_dir = d.parent
|
|
cfg.data_dir = d
|
|
# Patch the name where it is USED. The script does
|
|
# "from umbraaudio.config import load_config", which binds at import time,
|
|
# so patching the config module would be ignored and these tests would
|
|
# quietly audit the real project instead of the fixture.
|
|
import scripts.speaker_review_audit as audit_mod
|
|
|
|
monkeypatch.setattr(audit_mod, "load_config", lambda _s=None: cfg)
|
|
return audit_mod.run_audit(None, False), d
|
|
|
|
|
|
def test_narration_without_a_speaker_is_not_a_defect(tmp_path, monkeypatch) -> None:
|
|
"""Narration is not spoken by a character; flagging it would bury real bugs."""
|
|
audit, _ = _audit(
|
|
tmp_path,
|
|
[(0, "narration", None, "The ship drifted.")],
|
|
monkeypatch,
|
|
)
|
|
assert [f for f in audit.findings if f.check == "dialogue_missing_speaker"] == []
|
|
assert audit.stats["narration_without_speaker"] == 1
|
|
|
|
|
|
def test_dialogue_without_a_speaker_is_reported(tmp_path, monkeypatch) -> None:
|
|
audit, _ = _audit(
|
|
tmp_path,
|
|
[(0, "dialogue", None, "Who goes there?"), (1, "narration", None, "Silence.")],
|
|
monkeypatch,
|
|
)
|
|
missing = [f for f in audit.findings if f.check == "dialogue_missing_speaker"]
|
|
assert len(missing) == 1
|
|
assert missing[0].chapter == 1
|
|
assert missing[0].severity == "action"
|
|
|
|
|
|
@pytest.mark.parametrize("placeholder", ["", " ", "__unassigned__", "unknown", "NONE"])
|
|
def test_placeholder_speaker_names_count_as_missing(
|
|
tmp_path, monkeypatch, placeholder
|
|
) -> None:
|
|
audit, _ = _audit(tmp_path, [(0, "dialogue", placeholder, "Hi.")], monkeypatch)
|
|
assert [f for f in audit.findings if f.check == "dialogue_missing_speaker"]
|
|
|
|
|
|
def test_two_spellings_of_one_speaker_are_reported(tmp_path, monkeypatch) -> None:
|
|
"""Both resolve to one voice, but a per-speaker export would split them."""
|
|
audit, _ = _audit(
|
|
tmp_path,
|
|
[(0, "dialogue", "Narrator", "One."), (1, "dialogue", "narrator", "Two.")],
|
|
monkeypatch,
|
|
)
|
|
variants = [f for f in audit.findings if f.check == "speaker_name_variants"]
|
|
assert {v.speaker for v in variants} == {"Narrator", "narrator"}
|
|
assert all(v.expected == "Narrator" for v in variants)
|
|
|
|
|
|
def test_a_single_spelling_is_not_reported(tmp_path, monkeypatch) -> None:
|
|
audit, _ = _audit(
|
|
tmp_path, [(0, "dialogue", "Kainda", "Hi."), (1, "dialogue", "Kainda", "Bye.")],
|
|
monkeypatch,
|
|
)
|
|
assert [f for f in audit.findings if f.check == "speaker_name_variants"] == []
|
|
|
|
|
|
def test_alias_spelling_is_reported_against_its_canonical(tmp_path, monkeypatch) -> None:
|
|
audit, _ = _audit(
|
|
tmp_path,
|
|
[(0, "dialogue", "Kainda", "A."), (1, "dialogue", "Princess Kainda", "B.")],
|
|
monkeypatch,
|
|
)
|
|
variants = [f for f in audit.findings if f.check == "speaker_name_variants"]
|
|
assert any(v.speaker == "Princess Kainda" and v.expected == "Kainda" for v in variants)
|
|
|
|
|
|
def test_deleted_speaker_is_reported_with_its_rows(tmp_path, monkeypatch) -> None:
|
|
audit, _ = _audit(
|
|
tmp_path,
|
|
[(0, "dialogue", "Gone", "A."), (1, "dialogue", "Kainda", "B.")],
|
|
monkeypatch,
|
|
)
|
|
orphans = [f for f in audit.findings if f.check == "speaker_not_resolvable"]
|
|
assert len(orphans) == 1
|
|
assert orphans[0].speaker == "Gone"
|
|
assert orphans[0].utterance == 0
|
|
|
|
|
|
def test_audit_does_not_modify_the_database(tmp_path, monkeypatch) -> None:
|
|
"""It is a review aid: it must never change the data it is describing."""
|
|
d = _project(tmp_path, [(0, "dialogue", None, "Who?"), (1, "dialogue", "Kainda", "Hi.")])
|
|
before = d.joinpath("project.db").read_bytes()
|
|
speakers_before = d.joinpath("speakers.json").read_bytes()
|
|
|
|
from umbraaudio.config import AppConfig
|
|
|
|
cfg = AppConfig()
|
|
cfg.project_dir = d.parent
|
|
cfg.data_dir = d
|
|
import scripts.speaker_review_audit as audit_mod
|
|
|
|
monkeypatch.setattr(audit_mod, "load_config", lambda _s=None: cfg)
|
|
audit_mod.run_audit(None, False)
|
|
assert d.joinpath("project.db").read_bytes() == before
|
|
assert d.joinpath("speakers.json").read_bytes() == speakers_before
|
|
|
|
|
|
def test_reports_are_written_with_chapter_and_utterance(tmp_path, monkeypatch) -> None:
|
|
audit, d = _audit(
|
|
tmp_path,
|
|
[
|
|
(0, "dialogue", "Kainda", '"I have to tell you." said the captain.'),
|
|
(1, "dialogue", None, "Who is there?"),
|
|
],
|
|
monkeypatch,
|
|
)
|
|
out = tmp_path / "out"
|
|
md, csv_path = write_reports(audit, out)
|
|
text = md.read_text()
|
|
assert "# Speaker review audit" in text
|
|
# Every row-level finding must be locatable by a human.
|
|
missing = [f for f in audit.findings if f.check == "dialogue_missing_speaker"][0]
|
|
assert f"| {missing.chapter} | {missing.utterance} |" in text
|
|
assert csv_path.exists()
|
|
header = csv_path.read_text().splitlines()[0]
|
|
assert "chapter" in header and "utterance" in header
|