Files
UmbraAudio/scripts/report_speech_evidence.py
2026-10-18 19:01:04 -07:00

169 lines
6.4 KiB
Python

# Copyright 2026 Umbra Tools (umbratools.com)
# SPDX-License-Identifier: Apache-2.0
"""Report mechanical speech-evidence support over a project's dialogue lines.
Read-only. Prints the verdict mix, then every contradicted line with the narration the
reviewer needs to read to judge it. Chapter references always carry BOTH the database id
and the title, because ``chapter_id`` and ``chapter_index`` differ by one in every project
in this repo and a bare "ch8" sent the operator to the wrong chapter once already.
Usage::
python scripts/report_speech_evidence.py --project "Last Dwarf"
python scripts/report_speech_evidence.py --project "Last Dwarf" --chapter-id 6
python scripts/report_speech_evidence.py --project "Last Dwarf" --verdict contradicted
"""
from __future__ import annotations
import argparse
import json
import sqlite3
import sys
from collections import Counter
from pathlib import Path
from typing import Any
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
from umbraaudio.analysis.speech_evidence import ( # noqa: E402
nearest_tag,
support,
)
MAX_LISTED = 60
def load_cast(project_dir: Path) -> dict[str, dict[str, Any]]:
path = project_dir / "speakers.json"
if not path.exists():
raise SystemExit(f"no speakers.json in {project_dir}")
data = json.loads(path.read_text())
cast: dict[str, dict[str, Any]] = {}
for profile in data.get("speakers", []):
canonical = profile.get("primary_name") or ""
if not canonical:
continue
cast[canonical.casefold()] = profile
for alias in profile.get("aliases") or []:
cast.setdefault(str(alias).casefold(), profile)
return cast
def report(project_dir: Path, chapter_id: int | None, wanted: str | None) -> int:
cast = load_cast(project_dir)
db = project_dir / "project.db"
con = sqlite3.connect(f"file:{db.resolve()}?mode=ro", uri=True)
con.row_factory = sqlite3.Row
query = (
"SELECT id, chapter_index, title FROM chapters"
+ (" WHERE id = ?" if chapter_id else "")
+ " ORDER BY chapter_index"
)
chapters = con.execute(query, (chapter_id,) if chapter_id else ()).fetchall()
totals: Counter[str] = Counter()
flagged: list[dict[str, Any]] = []
for chapter in chapters:
rows = con.execute(
"SELECT utterance_index, type, speaker_name, text FROM utterances "
"WHERE chapter_id = ? ORDER BY utterance_index",
(chapter["id"],),
).fetchall()
utts = [dict(r) for r in rows]
scene = sorted(
{
(u.get("speaker_name") or "").strip()
for u in utts
if (u.get("type") or "").lower() == "dialogue"
and (u.get("speaker_name") or "").strip()
and (u.get("speaker_name") or "").strip().casefold() != "narrator"
}
)
for i, u in enumerate(utts):
if (u.get("type") or "").lower() != "dialogue":
continue
totals["dialogue"] += 1
speaker = (u.get("speaker_name") or "").strip()
ctx = nearest_tag(utts, i, cast)
verdict = support(
speaker, ctx.tag, cast, scene, text=u.get("text") or "", context=ctx
)
totals[verdict.verdict] += 1
if verdict.needs_review:
flagged.append(
{
"chapter_id": chapter["id"],
"chapter_index": chapter["chapter_index"],
"title": chapter["title"],
"utterance_index": u["utterance_index"],
"speaker": speaker,
"line": (u.get("text") or "").strip(),
"narration": ctx.narration or "",
"tag": ctx.tag.value if ctx.tag else None,
"tag_kind": ctx.tag.kind if ctx.tag else None,
"mid_conversation": ctx.mid_conversation,
"unparsed": ctx.unparsed,
"verdict": verdict.verdict,
"candidate_count": verdict.candidate_count,
"note": verdict.note,
}
)
checked = totals["dialogue"] or 1
print(f"project : {project_dir.parent.name}")
print(f"chapters : {len(chapters)}")
print(f"dialogue lines : {checked}")
print("verdicts :")
for key in ("supported", "unconstrained", "contradicted", "unsupported"):
n = totals[key]
share = 100 * n / checked
print(f" {key:14s} {n:5d} {share:5.2f}%")
print(f" review queue {len(flagged):5d} {100 * len(flagged) / checked:5.2f}%")
unparsed = [f for f in flagged if f.get("unparsed")]
print(f" of which unparsed narration (segmentation defects): {len(unparsed)}")
confident = [f for f in flagged if f["candidate_count"] == 1]
print(f" of which one-candidate (confident suggestion): {len(confident)}")
shown = [f for f in flagged if not wanted or f["verdict"] == wanted]
if not shown:
print("\nno lines match the filter")
con.close()
return 0
print(f"\n{'=' * 78}\n{len(shown)} line(s) to read\n{'=' * 78}")
for f in shown[:MAX_LISTED]:
print(f"\nchapter_id={f['chapter_id']} (index {f['chapter_index']}) '{f['title']}'")
print(f" utt{f['utterance_index']} {f['verdict'].upper()} speaker={f['speaker']!r}")
if f["narration"]:
print(f" narration : {f['narration'][:110]}")
print(f" line : {f['line'][:110]}")
print(f" why : {f['note']}")
if len(shown) > MAX_LISTED:
print(f"\n... and {len(shown) - MAX_LISTED} more (raise --verdict or use --chapter-id)")
con.close()
return 0
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--project", required=True)
parser.add_argument("--chapter-id", type=int, default=None)
parser.add_argument(
"--verdict", choices=["contradicted", "unsupported"], default=None
)
args = parser.parse_args()
root = Path.cwd()
if not (root / args.project).exists():
raise SystemExit(f"project directory not found: {root / args.project}")
return report(root / args.project / ".umbra", args.chapter_id, args.verdict)
if __name__ == "__main__":
raise SystemExit(main())