136 lines
4.7 KiB
Python
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())
|