tts-voice-creator-clone-and.../scripts/compare_characters.py
2026-09-03 16:12:02 +02:00

126 lines
4.3 KiB
Python

#!/usr/bin/env python3
"""Compare an automated character-library snapshot against a manually-corrected one.
python3 scripts/compare_characters.py manual.json auto.json
Snapshots come from backup_characters.py. Characters are matched by name OR by
any shared alias (an LLM may canonicalise a character under a different name
each run), then their sheets are diffed field by field. This is the input for
tightening the character-extraction / alias-detection prompt in
static/js/character-sheets.js — keep any rule change general (it must not
reference names specific to one book) and re-check it against a different
book afterwards.
"""
import json
import re
import sys
FIELDS = [
"aliases", "first_name", "last_name", "title", "profession",
"age_estimate", "race_species", "languages", "nationality_background",
"social_class", "archetype", "gender", "physical", "clothing", "alignment",
]
def norm(t: str) -> str:
return re.sub(r"\s+", " ", str(t or "")).strip().lower()
def identity_tokens(rec: dict) -> set[str]:
sheet = rec.get("sheet") or {}
toks = {norm(rec.get("name"))}
for part in re.split(r"[,;|]", str(sheet.get("aliases") or "")):
p = norm(part)
if p:
toks.add(p)
toks.discard("")
return toks
def load(path: str) -> list[dict]:
with open(path, encoding="utf-8") as f:
return json.load(f)
def match(manual: list[dict], auto: list[dict]):
"""Greedy match by best token overlap; returns (pairs, only_manual, only_auto)."""
auto_left = list(range(len(auto)))
pairs = []
for mi, m in enumerate(manual):
mt = identity_tokens(m)
best, best_score = None, 0
for ai in auto_left:
score = len(mt & identity_tokens(auto[ai]))
if score > best_score:
best, best_score = ai, score
if best is not None and best_score > 0:
pairs.append((m, auto[best]))
auto_left.remove(best)
matched_manual = {id(p[0]) for p in pairs}
only_manual = [m for m in manual if id(m) not in matched_manual]
only_auto = [auto[ai] for ai in auto_left]
return pairs, only_manual, only_auto
def field_val(rec: dict, key: str) -> str:
v = (rec.get("sheet") or {}).get(key)
if isinstance(v, list):
return ", ".join(str(x) for x in v if x)
return str(v or "").strip()
def main(man_path: str, auto_path: str) -> None:
manual, auto = load(man_path), load(auto_path)
print("=" * 78)
print(f"MANUAL characters: {len(manual)} AUTO characters: {len(auto)}")
print("=" * 78)
pairs, only_manual, only_auto = match(manual, auto)
print(f"\nmatched by name/alias: {len(pairs)}")
print(f"\n--- characters AUTO missed entirely ({len(only_manual)}) ---")
for m in only_manual:
print(f" {m.get('name')!r} (aliases: {field_val(m, 'aliases') or '-'})")
print(f"\n--- characters AUTO invented / split out (not in manual) ({len(only_auto)}) ---")
for a in only_auto:
print(f" {a.get('name')!r} (aliases: {field_val(a, 'aliases') or '-'})")
field_diff_count = {k: 0 for k in FIELDS}
detailed = []
for m, a in pairs:
diffs = []
for k in FIELDS:
mv, av = field_val(m, k), field_val(a, k)
if norm(mv) != norm(av):
field_diff_count[k] += 1
diffs.append((k, mv, av))
if diffs:
detailed.append((m.get("name"), a.get("name"), diffs))
print(f"\n--- field mismatch counts across {len(pairs)} matched characters ---")
for k, c in sorted(field_diff_count.items(), key=lambda x: -x[1]):
if c:
print(f" {k:22s} {c:3d}/{len(pairs)}")
print("\n--- per-character diffs ---")
for mname, aname, diffs in detailed:
label = mname if mname == aname else f"{mname!r} (auto named {aname!r})"
print(f"\n {label}")
for k, mv, av in diffs:
print(f" {k:22s} manual={mv!r:40s} auto={av!r}")
tot = len(pairs)
exact_matches = tot - len(detailed)
print("\n" + "=" * 78)
print(f"summary: {len(manual)} manual / {len(auto)} auto | "
f"missed={len(only_manual)} invented={len(only_auto)} | "
f"matched={tot} exact={exact_matches} field-diffs={len(detailed)}")
if __name__ == "__main__":
if len(sys.argv) != 3:
print(__doc__)
sys.exit(1)
main(sys.argv[1], sys.argv[2])