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>
82 lines
4.8 KiB
JavaScript
82 lines
4.8 KiB
JavaScript
// ── Character identity rules ──────────────────────────────────────────────────
|
||
// Which alias/name tokens are evidence that two character sheets describe the
|
||
// SAME person. A token made only of common nouns, roles, ranks, species or an
|
||
// article-led description ("Zwerg", "der Zwerg", "Mann", "Fremder",
|
||
// "Alter Soldat", "Ork-General", "der Tunneltreiber") labels many people and
|
||
// is never such evidence. On the real library a shared "Zwerg"/"Mann" token
|
||
// merged or copied unrelated sheets (Arthag/Kolon, Mann/Fremder in book 03).
|
||
//
|
||
// Pure functions, no DOM: loaded in the browser before characters-library.js
|
||
// and character-sheets.js, and importable from Node for unit tests. The word
|
||
// list mirrors core/character_records.py (a Python test keeps them in sync).
|
||
(function (root) {
|
||
'use strict';
|
||
|
||
const ARTICLES = new Set(('a an das dem den der des die ein eine einem einen einer eines the').split(' '));
|
||
const GENERIC_WORDS = new Set((
|
||
'adept adepten admiral alte alten alter altes amazone anführer anführerin auelfe baron ' +
|
||
'baronin bauer befehlshaber begleiter begleiterin blacksmith blonde blonder bote botin boy brother ' +
|
||
'bruder bub bäckerstochter bäuerin bürger bürgerin bürgersohn captain chief child colonel commander ' +
|
||
'companion creature crowd dame daughter de del demon diener dienerin dritte dritter ' +
|
||
'druid druide dunkle dunkler dwarf dämon elf elfe elfen erste erster erzähler ' +
|
||
'executioner father feldherr figure fischer frau frauen fremde fremden fremder führer gast ' +
|
||
'gefährte gefährtin general gestalt geweihter girl graf greis greisin groß großadmiral große ' +
|
||
'großer gräfin guard hauptfrau hauptmann heerführer henker herr herrin herzog herzogin hexe ' +
|
||
'human händler häuptling inquisitor irre jung junge jungen junger junges junker jäger ' +
|
||
'kaiser kaiserin kapitän kaufmann kid kind king kleine kleiner knabe knecht knight ' +
|
||
'kommandant kommandantin kreatur krieger kriegerin kriegshäuptling kutscher könig königin lady leader lehrer ' +
|
||
'leute lord magd mage magier magierin man mann marschall marshal master meister ' +
|
||
'menge mensch menschen merchant messenger militärführer monster mother mutter mädchen männer mönch ' +
|
||
'nachbar nachbarin narrator navigator nonne oberst obrist officer offizier offizierin old onkel ' +
|
||
'orc ork orkin orks person priest priester priesterin prince princess prinz prinzessin ' +
|
||
'queen reiter ritter ritterin rote roter schamane schmied schreiber schwarzhaarige schwester schüler ' +
|
||
'scribe servant shaman sir sister sklave sklaven sklavin smith sohn soldat soldaten ' +
|
||
'soldatin soldier son stimme stimmen stranger tante tochter torwächter tote toter unbekannt ' +
|
||
'unbekannte unbekannter unknown unsicher vampir vampire vampirin van vater vermummt vermummte vermummten ' +
|
||
'vermummter veteran voice vom von wache wachen warrior weber wesen wirt wirtin ' +
|
||
'witch witwe witwer wizard woman wächter young zauberer zauberin zu zum zur ' +
|
||
'zweite zweiter zwerg zwerge zwergin'
|
||
).split(' '));
|
||
const GENERIC_SUFFIXES = [...GENERIC_WORDS].filter(w => w.length >= 5).sort((a, b) => b.length - a.length);
|
||
|
||
function normalizeToken(token) {
|
||
return String(token == null ? '' : token)
|
||
.replace(/\([^)]*\)/g, ' ')
|
||
.replace(/\s+/g, ' ')
|
||
.trim()
|
||
.replace(/^[\s.,;:!?"'»«„“”‚‘’]+|[\s.,;:!?"'»«„“”‚‘’]+$/g, '')
|
||
.toLowerCase();
|
||
}
|
||
|
||
function isGenericIdentityToken(token) {
|
||
const norm = normalizeToken(token);
|
||
if (!norm) return true;
|
||
const words = norm.split(/[\s-]+/).filter(Boolean);
|
||
if (!words.length) return true;
|
||
if (ARTICLES.has(words[0])) return true; // "der Henker": a description
|
||
return words.every(word => {
|
||
const bare = word.replace(/^['’.]+|['’.]+$/g, '');
|
||
if (bare.length <= 1 || ARTICLES.has(bare) || GENERIC_WORDS.has(bare)) return true;
|
||
return GENERIC_SUFFIXES.some(sfx => bare.length > sfx.length && bare.endsWith(sfx));
|
||
});
|
||
}
|
||
|
||
// Shared tokens of two lower-cased token collections that prove identity.
|
||
function sharedIdentityEvidence(a, b) {
|
||
const left = new Set([...(a || [])].map(normalizeToken));
|
||
const out = [];
|
||
for (const t of (b || [])) {
|
||
const n = normalizeToken(t);
|
||
if (n && left.has(n) && !isGenericIdentityToken(n) && !out.includes(n)) out.push(n);
|
||
}
|
||
return out;
|
||
}
|
||
|
||
const characterIdentityApi = { isGenericIdentityToken, sharedIdentityEvidence, normalizeToken, GENERIC_WORDS, ARTICLES };
|
||
root.TTVC = root.TTVC || {};
|
||
root.TTVC.characterIdentity = characterIdentityApi;
|
||
root.ttvcIsGenericIdentityToken = isGenericIdentityToken;
|
||
root.ttvcSharedIdentityEvidence = sharedIdentityEvidence;
|
||
if (typeof module !== 'undefined' && module.exports) module.exports = characterIdentityApi;
|
||
})(typeof window !== 'undefined' ? window : globalThis);
|