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

106 lines
8.8 KiB
Markdown

# 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.