tts-voice-creator-clone-and.../architecture/character-extraction.md
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

8.8 KiB

Character extraction, identity merging and record hygiene

Status: 2026-10-03. Companion to character-visual-identity.md (how a saved sheet becomes image prompts). This document covers how sheets are extracted from the book, how they are merged into library records, and the rules that keep one character's data out of another's.

Pipeline

  1. Cast roster (Identify Characters / audiobook casting) names the speakers.
  2. Extraction (static/js/character-sheets.js csGenerate): the book is cut into ~4000-character passages; each passage goes to POST /api/character-sheets (routes/conversation.py _charsheets_prepare / _charsheets_parse) together with the roster and a short summary of the sheets so far. One LLM call returns sheets for every character the passage reveals (batch per passage, not per character).
    • Cast-target mode (roster known, csForReader): only sheets whose name exactly equals a roster name are kept and merged by exact name (strictRoster).
    • Discovery mode (no roster, csForRehearser): sheets are merged by identity (csFindMergeKey), then csConsolidateAliasDuplicates folds alias-linked entries.
    • Field merge is "longer text wins" per field; sources (max 12) and inventory are unioned.
  3. Canonical names (csCanonicalizeRosterNames / csRosterCanonicalName): the roster name is the display name; other names found by the LLM become aliases.
  4. Save (static/js/characters-library.js clUpsert): a roster-authoritative sheet is written by its book+name key; other sheets look for an existing record first.
  5. Image prompts read the saved record (character-visual-identity.md); the story-moment panel uses one quote from sources (_character_scene_evidence).

Root causes found on the real library (270 records, 4 books)

Evidence from a read-only snapshot, the config/regression_snapshots/* exports of 2026-08-31 and the per-update backups in config/backups/characters/.

Symptom Cause (proven)
Arthag (book 02) has Kolon's species, relationships and motivation word for word; both records date from the 2026-07-17 batch and were already identical on 2026-08-31 The extraction code of that time filtered target-mode sheets by substring roster match and merged by any shared identity token (title still counted). Merging is "longer text wins", so Kolon's long fields overwrote Arthag's. Today's target mode uses exact names, but discovery mode (csFindMergeKey, csConsolidateAliasDuplicates) and the library upsert (clSameIdentity) still linked records over a single shared token such as "Zwerg".
16 records in book 02/03 (Zwerg, Kolon, Inquisitor, Soldat, Alter Soldat, Alte, Ritter, Navigator, Fremder, Alchimist, Kriegerin, Mädchen, ...) are complete copies, sources included, of another record (Arthag, Marcian, Veteran, Alrik, Mann, Promos, Nyrilla, Marina, ...) Born in two bulk saves on 2026-08-16 (06:41 and 07:13, ~20 ms apart, two books), the day after csRosterCanonicalName shipped. The alias lists of each pair are the same list minus each record's own name, i.e. one sheet was written under two names. csRosterCanonicalName returned the first roster entry found among all of a sheet's tokens, not the sheet's own name, so Arthag (aliases "der Zwerg, Zwerg, ..., Kolon") became "Zwerg"/"Kolon" and the save created those records from Arthag's sheet. (The exact caller of that batch is not recoverable; the rename path is the only code that turns one sheet into another name.)
Kolon (b02) aliases "Sharraz Garthai, ..., Sharraz, Zwerg, Kriegshäuptling", first/last name Sharraz Garthai The LLM, given a passage about both, wrote the other person's name into Kolon's sheet; its own relationships field says "dient ... Sharraz Garthai". Aliases were accepted verbatim; only first/last name had a roster check (client side). 87 records carry another record's name as alias or real name.
Mann/Fremder (b03), Fremde/Zerwas (KONTROLLE) share species text Same as row 2 (copied sheet), see duplicated_sheets in the audit.
Story-moment quote about another cast member _character_scene_evidence chose by line_hint and verb score only. Sources hold whatever the passage quoted, including other characters' lines (also inherited through the merges above). 16 picked quotes named only another cast member; 85 did not name the character at all.
"Warte: ...", "Nein, Kolon ist ...", "Der Text sagt ..." in species/backstory/notes (12 records) Reasoning-model deliberation written into field values; nothing validated them.
Marrad (a boy, "fast acht Sommer") rendered as an adult; Fremder b01 ("mindestens 15 Jahre älter") as a teen _character_age_category knew no boy/girl nouns ("Junge", "Mädchen", "boy", "girl"), no "Sommer/Winter" ages, and read "15 Jahre älter" as an age.

What could not be proven without live LLM runs: how often today's prompt still produces cross-character aliases or merged facts for a multi-character passage. To test: run Recast selected for Kolon and Arthag on book 02 with the local model and check that Kolon's aliases contain no roster name and Arthag's species is his own.

Rules (implemented)

Identity evidence (core/character_records.py, mirrored in static/js/character-identity.js, word lists kept equal by a unit test): a shared token proves "same person" only if it is a real name. Generic = only common nouns, roles, ranks, kinship, species, age/look adjectives or name particles ("Zwerg", "Mann", "Fremder", "Alter Soldat", "Ork-General", "Bürgerwehroffizier", "von"), or starting with an article ("der Zwerg", "der Tunneltreiber"). Used by csFindMergeKey, csConsolidateAliasDuplicates, csRosterCanonicalName and clSameIdentity.

Own name first: csFindMergeKey targets an entry with exactly the sheet's name; csRosterCanonicalName keeps the sheet's own name when it is a cast name; clUpsert writes to the record with exactly this book+name before any alias scan; csForReader never renames a saved sheet onto a cast name that has its own saved record.

Per-character isolation at parse time (_charsheets_parse(raw, roster), roster only in cast-target mode): aliases and first/last/full names that are another cast member's name, or a name-like alias containing one ("Sharraz Garthai", "Schlange Gamba"), are dropped; descriptions naming a relative ("Tochter des Promos") stay. Every text field is cut before reasoning markers (strip_thinking_leakage). Source quotes are kept: some name another character but are about this one.

Story moment: only a source quote that names the character (name, real name, genitive, non-generic alias) is eligible; otherwise the character's own mannerisms / capabilities text is used, never somebody else's quote.

Age: explicit age (digits or number words with Jahre/jährig/Sommer/Winter/years/ summers/winters; not "15 Jahre älter") from the age estimate, age-tagged source quotes, labels, physical; then the estimate's evidence clause ("im Text als Junge beschrieben" / "described in the text as a boy"); then child/teen words and boy/girl nouns in label fields. A hedged "Junges Mädchen / Junger Mann" with a minor signal is a minor. German "Junge Frau"/"Junger Mann" are adults. Works on German and translated English text.

Audit and cleanup (SOP)

  • python3 scripts/audit_character_records.py [DB] [--book X] [--details|--json]: read-only (mode=ro, query_only). Reports duplicated sheets, alias collisions, generic-only alias links, thinking leakage, foreign source quotes and scene quotes about another cast member. Run on a copy when the app is busy.
  • python3 scripts/clean_character_records.py [DB] --out DIR: dry run (default), writes report.json / report.md, database untouched. Plans: cut reasoning leakage; remove an alias / real name of another cast member only when the record's own relationships name that person as someone else; everything else is review only. --drop-foreign-sources additionally removes source quotes naming only another cast member (lossy, off by default). Duplicated sheets are reported, never changed.
  • --apply --out DIR (owner approval only) writes backup.json with every affected row first, then updates those rows in one transaction.
  • Rollback: for each entry in backup.json, write its sheet and updated back: UPDATE characters SET sheet=?, updated=? WHERE id=? (or restore the DB backup taken before applying). Stop the container or make sure no recast runs meanwhile.

Snapshot of 2026-10-03 (before): 33 duplicated-sheet pairs, 161 alias collisions in 87 records, 61 generic-only alias links, 12 records with leakage, 139 foreign source quotes in 89 records, 16 scene quotes about another cast member (0 with the fixed picker). Dry run: 12 records would change (12 leakage cuts, 6 evidenced identity removals), 70 identity collisions listed for review.