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

136 lines
4.7 KiB
Python

#!/usr/bin/env python3
# Copyright 2026 Umbra Tools (umbratools.com)
# SPDX-License-Identifier: Apache-2.0
"""Merge mechanically-detected speaker designations into speakers.json.
Discovery is LLM-driven and will always miss *some* characters — on Last Dwarf
ch5 the model found "The big one" but dropped "The oldest" and "the tallest"
from the same crowd. This script runs the deterministic designation scanner and
ADDS any designation the cast does not already cover, marked
``unidentified: true`` so the Speakers page flags them for review.
It never removes or rewrites existing speakers, and never overwrites curated
fields. Safe to re-run.
Usage:
python scripts/merge_designations.py --project "Last Dwarf"
python scripts/merge_designations.py --project "Last Dwarf" --dry-run
"""
from __future__ import annotations
import argparse
import json
import logging
import sqlite3
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).parent.parent))
from umbraaudio.analysis.designation_scan import scan_book_designations
from umbraaudio.config import load_config
logger = logging.getLogger(__name__)
def _known_names(speakers: list[dict]) -> set[str]:
names: set[str] = set()
for s in speakers:
p = (s.get("primary_name") or "").strip()
if p:
names.add(p.lower())
for a in s.get("aliases") or []:
if a:
names.add(a.strip().lower())
return names
def main() -> int:
ap = argparse.ArgumentParser(description=__doc__)
ap.add_argument("--project", default=".")
ap.add_argument("--dry-run", action="store_true")
ap.add_argument(
"--chapters", default=None, help="Comma-separated chapter IDs to scan"
)
args = ap.parse_args()
cfg = load_config(args.project)
db_path = cfg.db_path
speakers_path = cfg.data_dir / "speakers.json"
if not db_path.exists() or not speakers_path.exists():
print(f"Missing DB or speakers.json under {cfg.data_dir}")
return 1
db = sqlite3.connect(str(db_path))
db.row_factory = sqlite3.Row
if args.chapters:
ids = [int(x.strip()) for x in args.chapters.split(",")]
ph = ",".join("?" * len(ids))
rows = db.execute(
f"SELECT id, source_text FROM chapters WHERE id IN ({ph})", ids
).fetchall()
else:
rows = db.execute("SELECT id, source_text FROM chapters").fetchall()
db.close()
chapters = [{"id": r["id"], "source_text": r["source_text"] or ""} for r in rows]
with open(speakers_path) as f:
doc = json.load(f)
speakers = doc.get("speakers", [])
hits = scan_book_designations(chapters)
known = _known_names(speakers)
missing = {k: v for k, v in hits.items() if k not in known}
print(f"scanned {len(chapters)} chapter(s): {len(hits)} designations found")
print(f"already covered by speakers.json: {len(hits) - len(missing)}")
print(f"MISSING (would be added): {len(missing)}\n")
for v in sorted(missing.values(), key=lambda x: x.designation.lower()):
print(f" {v.designation!r:36s} ch{v.chapter_id:<3d} {v.evidence[:70]!r}")
if not missing:
print("\nNothing to add.")
return 0
if args.dry_run:
print("\n(dry run — speakers.json unchanged)")
return 0
for v in sorted(missing.values(), key=lambda x: x.designation.lower()):
speakers.append(
{
"primary_name": v.designation,
"aliases": [],
"demographics": {"type": "Human", "gender": "Unknown"},
"vocal_traits": [],
"communication_style": {
"primary_register": "Casual",
"style_shifts_or_notes": "",
},
"is_narrator": False,
"is_group": False,
"description": "",
"sample_dialogue": "",
"chapters_appeared": [v.chapter_id],
"unidentified": True,
"unidentified_reason": (
"Spoke in the manuscript but was missed by character discovery; "
"found by the deterministic designation scanner. Confirm the "
"designation is right, then give this speaker a voice."
),
"alias_evidence": [f"Designation found by speech-tag scan: {v.evidence[:180]}"],
}
)
tmp = speakers_path.with_suffix(".json.tmp")
with open(tmp, "w") as f:
json.dump({**doc, "speakers": speakers}, f, indent=2)
tmp.replace(speakers_path)
print(f"\nAdded {len(missing)} speaker(s). Total now {len(speakers)}.")
return 0
if __name__ == "__main__":
logging.basicConfig(level=logging.WARNING)
raise SystemExit(main())