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