tts-voice-creator-clone-and.../scripts/clean_character_records.py
mARTin-B78 1f812d2faf fix(characters): merge profiles only on real names, keep other cast names, reasoning and foreign quotes out of a character
Real-library audit (270 records) traced the data damage to:
- identity matching on ANY shared token ("Zwerg", "der Zwerg", "Mann") with
  "longer text wins" merging (Arthag b02 = Kolon's species/relationships), and
- csRosterCanonicalName renaming a sheet to the first cast name among its
  aliases, which saved 16 whole-sheet copies (Zwerg/Kolon = Arthag b03,
  Fremder = Mann, Alchimist = Promos ...).

Fixes:
- static/js/character-identity.js + core/character_records.py: generic labels
  are never identity evidence; csFindMergeKey, csConsolidateAliasDuplicates,
  csRosterCanonicalName, clSameIdentity use it; own name always wins; clUpsert
  writes to the exact-name record before any alias scan; no seed rename onto a
  name that has its own record.
- _charsheets_parse: in cast-target mode drop other cast members' names from
  aliases / first / last name (Kolon: "Sharraz Garthai"), strip reasoning
  leakage ("Warte:", "Der Text sagt ...") from every text field.
- _character_scene_evidence: only a quote naming the character is used,
  otherwise its own mannerisms (16 panels showed another character's scene).
- _character_age_category: Junge/Mädchen/boy/girl, number words, Sommer/
  Winter ages, evidence clause, age-tagged quotes; "15 Jahre älter" is no age.
- scripts/audit_character_records.py (read-only) and
  scripts/clean_character_records.py (dry run by default, --apply backs up).

Tests: tests/test_character_record_hygiene.py, tests/character-identity.test.mjs.
Docs: architecture/character-extraction.md, Manual, CHANGELOG.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 14:09:53 +02:00

198 lines
10 KiB
Python

#!/usr/bin/env python3
"""Dry-run-first cleanup of extraction damage in saved character records.
Usage:
python3 scripts/clean_character_records.py [DB_PATH] [--book SUBSTRING] [--out DIR]
python3 scripts/clean_character_records.py [DB_PATH] --apply --out DIR # owner approval only
Without ``--apply`` nothing is written to the database: the database is opened
read-only and the planned changes are written as a report (report.json +
report.md) into ``--out`` (default: a new temp directory).
With ``--apply`` the script first writes ``backup.json`` (every affected row,
unchanged) and the report into ``--out``, then updates only those rows in one
transaction. Restore = write the backed-up ``sheet`` values back (see
architecture/character-extraction.md, "Cleanup and rollback").
Deterministic, evidence-based fixes only:
1. reasoning leakage ("Warte: ...", "Der Text sagt ...") cut from text fields
2. aliases / first / last / full names that are ANOTHER cast member's record
name in the same book removed
3. only with --drop-foreign-sources: source quotes that name another cast
member but not this character removed (lossy, off by default)
Duplicated sheets (one sheet copied into two records) are only REPORTED: which
record is genuine needs the owner's judgement.
"""
from __future__ import annotations
import argparse
import copy
import datetime as _dt
import json
import sqlite3
import sys
import tempfile
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(ROOT))
from core import character_records as cr # noqa: E402
from scripts.audit_character_records import audit, load_records # noqa: E402
def plan_changes(records: list[dict], drop_foreign_sources: bool = False) -> list[dict]:
"""Return one entry per record that would change: {id, book, name, changes, before, after}."""
by_book: dict[str, list[dict]] = {}
for rec in records:
by_book.setdefault(rec["book"], []).append(rec)
# Records that are copies of one another (same sources / long fields) are
# probably one person cast twice: their names in each other's aliases are
# not "foreign". That duplication is reported for the owner instead.
copies: dict[tuple[str, str], set[str]] = {}
for dup in audit(records, with_scene=False)["issues"]["duplicated_sheets"]:
copies.setdefault((dup["book"], dup["a"]), set()).add(dup["b"])
copies.setdefault((dup["book"], dup["b"]), set()).add(dup["a"])
plans = []
for book, recs in by_book.items():
cast = [(r["name"], r["sheet"]) for r in recs]
for rec in recs:
twins = copies.get((book, rec["name"]), set())
cast_names = [r["name"] for r in recs if r["name"] not in twins]
before = rec["sheet"]
after = copy.deepcopy(before)
changes: list[dict] = []
for field, (old, new) in cr.sanitize_sheet_text_fields(after).items():
changes.append({"kind": "thinking_leakage", "field": field, "old": old, "new": new})
# Only where the record contradicts itself: its own relationships
# name that person as somebody else (Kolon "dient ... Sharraz
# Garthai" yet alias "Sharraz"). Other collisions may be one person
# cast twice (Promos / Alchimist): listed for review, never applied.
probe = copy.deepcopy(after)
relationships = str(after.get("relationships") or "")
real_name = f"{after.get('first_name') or ''} {after.get('last_name') or ''}".strip()
for removed in cr.drop_foreign_identity(rec["name"], probe, cast_names):
field, _, value = removed.partition("=")
probe_text = real_name if field in ("first_name", "last_name") else value
related = [n for n in cast_names if cr.normalize_identity_token(n) != cr.normalize_identity_token(rec["name"])
and not cr.is_generic_identity_token(n) and cr.text_mentions_token(probe_text, n)
and cr.text_mentions_token(relationships, n)]
kind = "foreign_identity" if related else "review_identity"
changes.append({"kind": kind, "field": field, "removed": value,
**({"evidence": f"relationships names {', '.join(related)}"} if related else {})})
contradicted = {c["removed"] for c in changes if c["kind"] == "foreign_identity" and c["field"] == "aliases"}
if contradicted:
after["aliases"] = ", ".join(a for a in cr.split_identity_tokens(after.get("aliases")) if a not in contradicted)
for c in changes:
if c["kind"] == "foreign_identity" and c["field"] in ("first_name", "last_name", "full_name"):
after[c["field"]] = ""
sources = after.get("sources") if isinstance(after.get("sources"), list) else []
kept = []
for src in sources:
quote = str(src.get("quote") or "") if isinstance(src, dict) else ""
if drop_foreign_sources and quote and not cr.quote_mentions_identity(quote, rec["name"], after):
others = cr.quote_mentions_other_cast(quote, rec["name"], after, cast)
if others:
changes.append({"kind": "foreign_source", "field": "sources", "removed": quote[:240],
"names": others})
continue
kept.append(src)
if len(kept) != len(sources):
after["sources"] = kept
if changes:
plans.append({"id": rec["id"], "book": book, "name": rec["name"], "changes": changes,
"before": before, "after": after})
return plans
def write_report(out: Path, plans: list[dict], audit_result: dict, applied: bool) -> None:
out.mkdir(parents=True, exist_ok=True)
slim = [{k: v for k, v in p.items() if k not in ("before", "after")} for p in plans]
(out / "report.json").write_text(json.dumps({
"applied": applied, "records_changed": len(plans), "plans": slim,
"duplicated_sheets_report_only": audit_result["issues"]["duplicated_sheets"],
"audit_summary": audit_result["summary"],
}, ensure_ascii=False, indent=2), encoding="utf-8")
counts: dict[str, int] = {}
for p in plans:
for c in p["changes"]:
counts[c["kind"]] = counts.get(c["kind"], 0) + 1
lines = [f"# Character record cleanup ({'APPLIED' if applied else 'dry run, nothing written'})", "",
f"Records that would change: {sum(1 for p in plans if p['after'] != p['before'])}", ""]
lines += [f"- {kind}: {n}" for kind, n in sorted(counts.items())]
lines += ["", f"Duplicated sheets (report only, owner decides): {len(audit_result['issues']['duplicated_sheets'])}", ""]
for p in plans:
lines.append(f"## {p['book']} | {p['name']}")
for c in p["changes"]:
if c["kind"] == "thinking_leakage":
lines.append(f"- {c['field']}: cut to {c['new']!r} (was {len(c['old'])} chars)")
elif c["kind"] == "foreign_identity":
lines.append(f"- {c['field']}: remove {c['removed']!r} ({c['evidence']})")
elif c["kind"] == "review_identity":
lines.append(f"- REVIEW ONLY (not changed): {c['field']} {c['removed']!r} is another record's name")
else:
lines.append(f"- source quote naming {', '.join(c['names'])}: remove {c['removed'][:100]!r}")
lines.append("")
(out / "report.md").write_text("\n".join(lines), encoding="utf-8")
def apply_plans(db_path: str, plans: list[dict], out: Path) -> None:
out.mkdir(parents=True, exist_ok=True)
plans = [p for p in plans if p["after"] != p["before"]] # review-only entries are never written
con = sqlite3.connect(db_path)
try:
ids = [p["id"] for p in plans]
rows = []
for rid in ids:
row = con.execute("SELECT id, book, name, sheet, updated FROM characters WHERE id = ?", (rid,)).fetchone()
if row:
rows.append(dict(zip(("id", "book", "name", "sheet", "updated"), row)))
backup = out / "backup.json"
backup.write_text(json.dumps(rows, ensure_ascii=False, indent=1), encoding="utf-8")
if len(rows) != len(ids):
raise SystemExit("records changed since planning; nothing written")
now = _dt.datetime.now(_dt.timezone.utc).isoformat().replace("+00:00", "Z")
with con:
for p in plans:
con.execute("UPDATE characters SET sheet = ?, updated = ? WHERE id = ?",
(json.dumps(p["after"], ensure_ascii=False), now, p["id"]))
finally:
con.close()
def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(description=__doc__.split("\n\n")[0])
parser.add_argument("db", nargs="?", default=str(ROOT / "config" / "tts_creator.db"))
parser.add_argument("--book", default="")
parser.add_argument("--out", default="", help="report/backup directory (default: new temp dir)")
parser.add_argument("--drop-foreign-sources", action="store_true",
help="also remove source quotes that name another cast member but not this one "
"(lossy: some are about this character by pronoun/role; image prompts already skip them)")
parser.add_argument("--apply", action="store_true",
help="write the changes (backup first). Only with the owner's approval.")
args = parser.parse_args(argv)
if not Path(args.db).is_file():
print(f"database not found: {args.db}", file=sys.stderr)
return 2
out = Path(args.out) if args.out else Path(tempfile.mkdtemp(prefix="ttvc-character-cleanup-"))
records = load_records(args.db, args.book) # read-only connection
plans = plan_changes(records, drop_foreign_sources=args.drop_foreign_sources)
audit_result = audit(records, with_scene=False)
if args.apply:
if not args.out:
print("--apply needs an explicit --out directory for the backup", file=sys.stderr)
return 2
apply_plans(args.db, plans, out)
write_report(out, plans, audit_result, applied=args.apply)
kinds: dict[str, int] = {}
for p in plans:
for c in p["changes"]:
kinds[c["kind"]] = kinds.get(c["kind"], 0) + 1
changed = sum(1 for p in plans if p["after"] != p["before"])
print(f"{'applied' if args.apply else 'dry run'}: {changed} records would change {kinds}; report: {out}")
return 0
if __name__ == "__main__":
raise SystemExit(main())