126 lines
4.3 KiB
Python
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])
|