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:
@@ -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
|
||||
|
||||
|
||||
@@ -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",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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() {
|
||||
|
||||
Reference in New Issue
Block a user