169 lines
6.4 KiB
Python
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())
|