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