# 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())