Fix broken name-token assign buttons + scanner missed an unnamed speaker

Two real bugs found by the user clicking Assign on the Name Tokens tab:

1. The action buttons never fired. {{ token|tojson }} emits double quotes
   inside onclick="...", so the HTML attribute terminated early
   (onclick="ntAssign(8, " and the rest became stray attributes. The browser
   threw a JS syntax error, no fetch was ever sent, and the token stayed
   unresolved. Switched to data-token/data-idx attributes read in JS, which is
   immune to spaces and quotes. Also added real error surfacing (.catch +
   server error message) and made the result message explain the 0-utterance
   case: a name-tag token like 'Tobias Teppo' comes from narration, not an
   assigned speaker, so assigning it records an alias and re-points nothing.

2. The scanner missed a real unnamed speaker. _is_known('Witch') returned True
   because single-word containment matched the profile 'The Witch Pair', so the
   2 utterances attributed to an unnamed Witch never reached the queue. Expanded
   _ROLE_NOUNS with identity/trade/supernatural/machine nouns and made the
   single-generic-word guard use it, so generic identity words are never
   auto-absorbed into a longer profile. Last Dwarf now reports 10 tokens
   (was 9) with 'Witch' (ch40) included.

Verified: real assign round-trips (alias + evidence + utterance re-point),
error paths return 400, tokens with spaces route correctly. Added a regression
test for the Witch containment case. Full suite 425 passed (8 pre-existing /
parallel-session failures unchanged).
This commit is contained in:
B.D. Murphy
2026-09-25 17:53:34 -07:00
parent 79e1cabe1e
commit abb99757bf
4 changed files with 87 additions and 30 deletions

View File

@@ -88,6 +88,14 @@ class TestIsKnown:
assert _is_known("guard", ALIAS_MAP) is False
assert _is_known("voice", ALIAS_MAP) is False
def test_single_identity_word_not_absorbed_by_longer_profile(self) -> None:
# Regression guard: an unnamed "Witch" must NOT be auto-resolved into a
# distinct profile named "The Witch Pair" via word containment.
amap = {"the witch pair": "The Witch Pair"}
assert _is_known("Witch", amap) is False
# ...but the distinctive multi-word name still resolves.
assert _is_known("The Witch Pair", amap) is True
def test_empty_always_known(self) -> None:
assert _is_known("", ALIAS_MAP) is True

View File

@@ -75,23 +75,44 @@ _NON_NAME_WORDS = frozenset(
}
)
# Role nouns: a token whose head word is one of these is an unnamed *role*, not
# a personal name. Roles get create/assign/confirm treatment, and we suppress
# personal-name fuzzy suggestions for them (so "The Kid" does not suggest
# "The King" on a spurious soundex match).
# Role nouns: a token whose head word is one of these is an unnamed *role* or
# *identity*, not a personal name. Two jobs:
# 1. roles get create/assign/confirm treatment and no personal-name fuzzy
# suggestion (so "The Kid" does not suggest "The King" on a soundex match);
# 2. a single generic word like "Witch" is NOT auto-resolved into a longer
# profile via word containment ("The Witch Pair"), so a genuinely unnamed
# speaker still reaches the review queue.
_ROLE_NOUNS = frozenset(
{
# ranks / military / civic
"guard", "guards", "officer", "officers", "captain", "captains",
"advisor", "adviser", "soldier", "soldiers", "man", "men", "woman",
"women", "girl", "girls", "boy", "boys", "kid", "kids", "king",
"queen", "doctor", "doctors", "master", "commander", "leader",
"pilot", "crew", "sergeant", "lieutenant", "general", "nurse",
"teacher", "student", "engineer", "scientist", "merchant", "trader",
"hunter", "pirate", "pirates", "dwarf", "dwarves", "robot", "robots",
"computer", "computers", "voice", "voices", "friend", "stranger",
"child", "children", "baby", "elder", "gentleman", "lady", "youth",
"hunter", "pirate", "pirates", "clerk", "driver", "priest",
"bystander", "passerby", "figure", "one", "servant", "attendant",
"clerk", "driver", "priest",
# trades / occupations
"scavenger", "scavengers", "miner", "miners", "farmer", "farmers",
"smuggler", "smugglers", "mercenary", "mercenaries", "raider",
"raiders", "blacksmith", "baker", "tailor", "carpenter", "sailor",
"sailors", "spy", "spies", "assassin", "thief", "thieves",
"criminal", "prisoner", "slave", "serf", "peasant",
# supernatural / fantasy identities
"witch", "witches", "wizard", "wizards", "sorcerer", "sorcerers",
"mage", "mages", "magician", "shaman", "druid", "ghost", "ghosts",
"spirit", "spirits", "demon", "demons", "angel", "angels", "saint",
"dragon", "dragons", "beast", "beasts", "monster", "monsters",
"creature", "creatures", "giant", "giants", "troll", "trolls",
"orc", "orcs", "elf", "elves", "dwarf", "dwarves",
# machines / non-human
"robot", "robots", "android", "cyborg", "drone", "drones",
"computer", "computers", "terminal", "console", "voice", "voices",
"speaker", "speakers", "narrator", "ai",
# generic people
"friend", "stranger", "child", "children", "baby", "elder", "elders",
"gentleman", "lady", "youth", "teenager", "adult", "adults", "group",
}
)

View File

@@ -61,12 +61,15 @@
<option value="{{ sp.primary_name }}" {% if sp.primary_name == t.suggestion %}selected{% endif %}>{{ sp.primary_name }}</option>
{% endfor %}
</select>
<button class="btn btn-approve" style="font-size:0.72rem; padding:0.2rem 0.5rem"
onclick="ntAssign({{ loop.index }}, {{ t.token|tojson }})">Assign</button>
<button class="btn btn-primary" style="font-size:0.72rem; padding:0.2rem 0.5rem"
onclick="ntCreate({{ loop.index }}, {{ t.token|tojson }})">New speaker</button>
<button class="btn" style="font-size:0.72rem; padding:0.2rem 0.5rem; background:#475569; color:#e0e0e0"
onclick="ntDismiss({{ loop.index }}, {{ t.token|tojson }})">Dismiss</button>
<button class="btn btn-approve" data-token="{{ t.token }}" data-idx="{{ loop.index }}"
style="font-size:0.72rem; padding:0.2rem 0.5rem"
onclick="ntAssign(this)">Assign</button>
<button class="btn btn-primary" data-token="{{ t.token }}" data-idx="{{ loop.index }}"
style="font-size:0.72rem; padding:0.2rem 0.5rem"
onclick="ntCreate(this)">New speaker</button>
<button class="btn" data-token="{{ t.token }}" data-idx="{{ loop.index }}"
style="font-size:0.72rem; padding:0.2rem 0.5rem; background:#475569; color:#e0e0e0"
onclick="ntDismiss(this)">Dismiss</button>
</div>
{% else %}
<span style="color:#64748b; font-size:0.78rem">

View File

@@ -41,9 +41,9 @@ function ntRefresh() {
}
function ntSummarize() {
var unresolved = document.querySelectorAll('#nt-table tbody tr').length;
var rows = document.querySelectorAll('#nt-table tbody tr').length;
var el = document.getElementById('nt-summary');
if (el) el.textContent = unresolved ? (unresolved + ' token(s) listed') : 'No tokens listed.';
if (el) el.textContent = rows ? (rows + ' token(s) listed') : 'No tokens listed.';
}
function ntPost(url, payload) {
@@ -54,35 +54,60 @@ function ntPost(url, payload) {
}).then(r => r.json().then(d => ({ok: r.ok, data: d})));
}
function ntAssign(idx, token) {
var sel = document.getElementById('nt-assign-' + idx);
function ntFail(res) {
alert('Failed: ' + ntTokenize(res.data && (res.data.error || res.data.detail)));
ntRefresh();
}
// Reads the token + row index off the clicked button. Using data-* attributes
// avoids quoting problems with names that contain spaces or quotes.
function ntToken(btn) { return btn.getAttribute('data-token') || ''; }
function ntIdx(btn) { return btn.getAttribute('data-idx') || ''; }
function ntAssign(btn) {
var token = ntToken(btn);
var sel = document.getElementById('nt-assign-' + ntIdx(btn));
var speaker = sel ? sel.value : '';
if (!speaker) { alert('Pick a speaker to assign “' + token + '” to.'); return; }
if (!speaker) { alert('Pick a speaker to assign "' + token + '" to.'); return; }
ntPost('/api/name-tokens/' + encodeURIComponent(token) + '/assign', {speaker: speaker})
.then(function (res) {
if (!res.ok) { alert('Failed: ' + ntTokenize(res.data.error)); return; }
alert('Assigned “' + token + '” → ' + res.data.target_speaker + ' (' + res.data.utterances_changed + ' utterance(s) re-pointed)');
if (!res.ok) { ntFail(res); return; }
var n = res.data.utterances_changed || 0;
var msg = 'Added "' + token + '" as an alias of ' + res.data.target_speaker + '.';
if (n > 0) {
msg += '\n\n' + n + ' utterance(s) re-pointed to ' + res.data.target_speaker +
'. Their audio is now stale and will regenerate.';
} else {
msg += '\n\nNo utterance was attributed to "' + token + '" — this name came from a ' +
'narration speech tag, not an assigned speaker, so only the alias was recorded. ' +
'Future runs will read "' + token + '" as ' + res.data.target_speaker + '.';
}
alert(msg);
ntRefresh();
});
}).catch(e => alert('Network error: ' + e));
}
function ntCreate(idx, token) {
if (!confirm('Create a new speaker named “' + token + '”?\n\nIt will enter the normal voice-design flow on the Speakers tab.')) return;
function ntCreate(btn) {
var token = ntToken(btn);
if (!confirm('Create a new speaker named "' + token + '"?\n\nIt will be added to speakers.json and appear on the ' +
'Speakers tab, where you can generate and approve its voice.')) return;
ntPost('/api/name-tokens/' + encodeURIComponent(token) + '/create', {})
.then(function (res) {
if (!res.ok) { alert('Failed: ' + ntTokenize(res.data.error)); return; }
if (!res.ok) { ntFail(res); return; }
alert('Created speaker "' + res.data.target_speaker + '".\n\nNext: open the Speakers tab to generate its voice reference, then approve it.');
ntRefresh();
});
}).catch(e => alert('Network error: ' + e));
}
function ntDismiss(idx, token) {
var reason = prompt('Dismiss “' + token + '” — why is it not a character?\n(e.g. a place, a sound, a one-off mention)', '');
function ntDismiss(btn) {
var token = ntToken(btn);
var reason = prompt('Dismiss "' + token + '" — why is it not a character?\n(e.g. a place, a sound, a one-off mention)', '');
if (reason === null) return;
ntPost('/api/name-tokens/' + encodeURIComponent(token) + '/dismiss', {reason: reason})
.then(function (res) {
if (!res.ok) { alert('Failed: ' + ntTokenize(res.data.error)); return; }
if (!res.ok) { ntFail(res); return; }
ntRefresh();
});
}).catch(e => alert('Network error: ' + e));
}
function ntScan() {