feat: Add filters for entity and people summaries with minimum mention options

This commit is contained in:
JB
2025-10-12 10:01:43 -07:00
parent b4c9a1ced8
commit 17534d7890
5 changed files with 561 additions and 111 deletions
+37 -1
View File
@@ -60,12 +60,36 @@ _STOP_LABELS = {
"nor", "nor",
"so", "so",
"yet", "yet",
"dr",
"mr",
"mrs",
"ms",
"miss",
"sir",
"madam",
"lady",
"lord",
}
_EXCLUDED_NER_LABELS = {
"CARDINAL",
"DATE",
"ORDINAL",
"PERCENT",
"TIME",
"LAW",
"MONEY",
"QUANTITY",
} }
_TITLE_PATTERN = re.compile(r"^(?:" + "|".join(re.escape(prefix) for prefix in _TITLE_PREFIXES) + r")\.?\s+", re.IGNORECASE) _TITLE_PATTERN = re.compile(r"^(?:" + "|".join(re.escape(prefix) for prefix in _TITLE_PREFIXES) + r")\.?\s+", re.IGNORECASE)
_POSSESSIVE_PATTERN = re.compile(r"(?:'s|s|\u2019s)$", re.IGNORECASE) _POSSESSIVE_PATTERN = re.compile(r"(?:'s|s|\u2019s)$", re.IGNORECASE)
_NON_WORD_PATTERN = re.compile(r"[^\w\s'-]+") _NON_WORD_PATTERN = re.compile(r"[^\w\s'-]+")
_MULTI_SPACE_PATTERN = re.compile(r"\s+") _MULTI_SPACE_PATTERN = re.compile(r"\s+")
_SUFFIX_PATTERN = re.compile(
r",?\s+(?:jr|sr|ii|iii|iv|v|vi|md|phd|esq|esquire|dds|dvm)\.?$",
re.IGNORECASE,
)
@dataclass(slots=True) @dataclass(slots=True)
@@ -165,6 +189,7 @@ def _normalize_label(text: str) -> str:
if not stripped: if not stripped:
return "" return ""
stripped = _TITLE_PATTERN.sub("", stripped) stripped = _TITLE_PATTERN.sub("", stripped)
stripped = _SUFFIX_PATTERN.sub("", stripped)
stripped = _POSSESSIVE_PATTERN.sub("", stripped) stripped = _POSSESSIVE_PATTERN.sub("", stripped)
stripped = _NON_WORD_PATTERN.sub(" ", stripped) stripped = _NON_WORD_PATTERN.sub(" ", stripped)
stripped = _MULTI_SPACE_PATTERN.sub(" ", stripped) stripped = _MULTI_SPACE_PATTERN.sub(" ", stripped)
@@ -252,9 +277,16 @@ def extract_entities(
for idx, chapter in enumerate(chapters): for idx, chapter in enumerate(chapters):
text = chapter.get("text") if isinstance(chapter, Mapping) else None text = chapter.get("text") if isinstance(chapter, Mapping) else None
text_value = str(text or "") text_value = str(text or "")
chapter_texts.append((idx, text_value)) original_index = idx
if isinstance(chapter, Mapping):
try:
original_index = int(chapter.get("index", idx))
except (TypeError, ValueError):
original_index = idx
chapter_texts.append((original_index, text_value))
if text_value: if text_value:
combined_hasher.update(text_value.encode("utf-8", "ignore")) combined_hasher.update(text_value.encode("utf-8", "ignore"))
combined_hasher.update(str(original_index).encode("utf-8", "ignore"))
cache_key = combined_hasher.hexdigest() cache_key = combined_hasher.hexdigest()
if not chapter_texts: if not chapter_texts:
@@ -279,6 +311,8 @@ def extract_entities(
def _register_span(span: Any, category_hint: Optional[str] = None) -> None: def _register_span(span: Any, category_hint: Optional[str] = None) -> None:
nonlocal processed_tokens nonlocal processed_tokens
if category_hint is None and span.label_ in _EXCLUDED_NER_LABELS:
return
cleaned = _normalize_label(span.text) cleaned = _normalize_label(span.text)
if not cleaned: if not cleaned:
return return
@@ -346,6 +380,8 @@ def extract_entities(
"tokens": processed_tokens, "tokens": processed_tokens,
"chapters": len(chapter_texts), "chapters": len(chapter_texts),
"processed": True, "processed": True,
"people": len(people_payload),
"entities": len(entity_payload),
}, },
"model": { "model": {
"name": getattr(nlp, "meta", {}).get("name", "unknown"), "name": getattr(nlp, "meta", {}).get("name", "unknown"),
+10 -1
View File
@@ -1115,7 +1115,16 @@ def _refresh_entity_summary(pending: PendingJob, chapters: Iterable[Mapping[str,
return return
language = pending.language or "en" language = pending.language or "en"
result = extract_entities(chapters, language=language) chapter_list: List[Mapping[str, Any]] = [chapter for chapter in chapters if isinstance(chapter, Mapping)]
if not chapter_list:
pending.entity_summary = {}
pending.entity_cache_key = ""
pending.pronunciation_overrides = pending.pronunciation_overrides or []
return
enabled_only = [chapter for chapter in chapter_list if chapter.get("enabled")]
target_chapters = enabled_only or chapter_list
result = extract_entities(target_chapters, language=language)
summary = dict(result.summary) summary = dict(result.summary)
tokens: List[str] = [] tokens: List[str] = []
for group in ("people", "entities"): for group in ("people", "entities"):
+458 -106
View File
@@ -73,6 +73,9 @@ const initPrepare = (root = document) => {
const voiceCatalogMap = new Map(voiceCatalog.map((voice) => [voice.id, voice])); const voiceCatalogMap = new Map(voiceCatalog.map((voice) => [voice.id, voice]));
const sampleIndexState = new WeakMap(); const sampleIndexState = new WeakMap();
const speakerHints = new Map();
const canonicalizeEntityKey = (value) => (value || "").toLowerCase().replace(/\s+/g, " ").trim();
const readSpeakerSamples = (speakerItem) => { const readSpeakerSamples = (speakerItem) => {
if (!speakerItem) return []; if (!speakerItem) return [];
@@ -111,6 +114,47 @@ const initPrepare = (root = document) => {
return normalised; return normalised;
}; };
const registerSpeakerHintFromNode = (node) => {
if (!node) return;
const nameNode = node.querySelector(".speaker-list__name");
const label = nameNode?.textContent || "";
const key = canonicalizeEntityKey(label);
if (!key) return;
const genderInput = node.querySelector('[data-role="gender-input"]');
const voiceSelect = node.querySelector('[data-role="speaker-voice"]');
const formulaInput = node.querySelector('[data-role="speaker-formula"]');
let resolvedVoice = "";
if (voiceSelect) {
const selectedValue = voiceSelect.value || voiceSelect.dataset.prevManual || "";
if (selectedValue && selectedValue !== "__custom_mix") {
resolvedVoice = selectedValue;
} else if (voiceSelect.dataset.defaultVoice) {
resolvedVoice = voiceSelect.dataset.defaultVoice;
} else if (form.dataset.baseVoice) {
resolvedVoice = form.dataset.baseVoice;
}
} else if (form.dataset.baseVoice) {
resolvedVoice = form.dataset.baseVoice;
}
if (!resolvedVoice && formulaInput?.value?.trim()) {
const formula = formulaInput.value.trim();
const firstTerm = formula.split("+")[0] || "";
const [voiceId] = firstTerm.split("*");
if (voiceId) {
resolvedVoice = voiceId.trim();
}
}
speakerHints.set(key, {
gender: (genderInput?.value || "unknown").toLowerCase(),
voice: resolvedVoice,
});
};
const rebuildSpeakerHints = () => {
speakerHints.clear();
form.querySelectorAll(".speaker-list__item").forEach((item) => registerSpeakerHintFromNode(item));
};
const getPronunciationText = (container) => { const getPronunciationText = (container) => {
if (!container) return ""; if (!container) return "";
const input = container.querySelector('[data-role="speaker-pronunciation"]'); const input = container.querySelector('[data-role="speaker-pronunciation"]');
@@ -411,6 +455,7 @@ const initPrepare = (root = document) => {
target.value = previous; target.value = previous;
} }
updatePreviewVoice(target); updatePreviewVoice(target);
registerSpeakerHintFromNode(container);
return; return;
} }
@@ -430,6 +475,9 @@ const initPrepare = (root = document) => {
target.dataset.prevManual = target.value || ""; target.dataset.prevManual = target.value || "";
updatePreviewVoice(target); updatePreviewVoice(target);
delete target.dataset.suppressFormulaClear; delete target.dataset.suppressFormulaClear;
if (container) {
registerSpeakerHintFromNode(container);
}
}); });
updatePreviewVoice(select); updatePreviewVoice(select);
}); });
@@ -443,7 +491,9 @@ const initPrepare = (root = document) => {
pronunciationInput.addEventListener("input", sync); pronunciationInput.addEventListener("input", sync);
pronunciationInput.addEventListener("change", sync); pronunciationInput.addEventListener("change", sync);
} }
registerSpeakerHintFromNode(item);
}); });
rebuildSpeakerHints();
const activeStepInput = form.querySelector('[data-role="active-step-input"]'); const activeStepInput = form.querySelector('[data-role="active-step-input"]');
const analysisButtons = Array.from(form.querySelectorAll('[data-role="submit-speaker-analysis"]')); const analysisButtons = Array.from(form.querySelectorAll('[data-role="submit-speaker-analysis"]'));
@@ -588,6 +638,7 @@ const initPrepare = (root = document) => {
updatePreviewVoice(select); updatePreviewVoice(select);
delete select.dataset.suppressFormulaClear; delete select.dataset.suppressFormulaClear;
registerSpeakerHintFromNode(speakerItem);
}; };
const hideGenderMenus = () => { const hideGenderMenus = () => {
@@ -620,6 +671,8 @@ const initPrepare = (root = document) => {
option.removeAttribute("data-state"); option.removeAttribute("data-state");
} }
}); });
const speakerItem = genderContainer.closest(".speaker-list__item");
registerSpeakerHintFromNode(speakerItem);
}; };
Array.from(form.querySelectorAll('[data-role="speaker-gender"]')).forEach((container) => { Array.from(form.querySelectorAll('[data-role="speaker-gender"]')).forEach((container) => {
@@ -1071,10 +1124,18 @@ const initPrepare = (root = document) => {
cacheKey: typeof entityCacheKeyData === "string" ? entityCacheKeyData : "", cacheKey: typeof entityCacheKeyData === "string" ? entityCacheKeyData : "",
manualOverrides: Array.isArray(manualOverridesSeed) ? [...manualOverridesSeed] : [], manualOverrides: Array.isArray(manualOverridesSeed) ? [...manualOverridesSeed] : [],
pronunciationOverrides: Array.isArray(pronunciationOverridesSeed) ? [...pronunciationOverridesSeed] : [], pronunciationOverrides: Array.isArray(pronunciationOverridesSeed) ? [...pronunciationOverridesSeed] : [],
filters: {
people: 0,
entities: 0,
},
}; };
let highlightedOverrideId = ""; let highlightedOverrideId = "";
const pendingOverrideSaves = new Map(); const dirtyOverrideIds = new Set();
let activeEntityPanel = "";
let overrideFlushPromise = null;
let markOverrideDirty = () => {};
let flushManualOverrides = () => null;
if (entityTabs) { if (entityTabs) {
const tabButtons = Array.from(entityTabs.querySelectorAll('[data-role="entity-tab"]')); const tabButtons = Array.from(entityTabs.querySelectorAll('[data-role="entity-tab"]'));
@@ -1082,11 +1143,17 @@ const initPrepare = (root = document) => {
Array.from(entityTabs.querySelectorAll('[data-role="entity-panel"]')).map((panel) => [panel.dataset.panel || "", panel]) Array.from(entityTabs.querySelectorAll('[data-role="entity-panel"]')).map((panel) => [panel.dataset.panel || "", panel])
); );
const entitySummaryContainer = entityTabs.querySelector('[data-role="entity-summary"]'); const peopleSummaryContainer = entityTabs.querySelector('[data-role="people-summary"]');
const entityStatsNode = entitySummaryContainer?.querySelector('[data-role="entity-stats"]'); const peopleStatsNode = peopleSummaryContainer?.querySelector('[data-role="people-stats"]');
const entityListNode = entitySummaryContainer?.querySelector('[data-role="entity-list"]'); const peopleListNode = peopleSummaryContainer?.querySelector('[data-role="entity-list-people"]');
const entityRowTemplate = entitySummaryContainer?.querySelector('template[data-role="entity-row-template"]'); const peopleFilterNode = peopleSummaryContainer?.querySelector('[data-role="entity-filter-people"]');
const entitiesRefreshButton = entitySummaryContainer?.querySelector('[data-role="entities-refresh"]');
const entitySummaryContainer = entityTabs.querySelector('[data-role="entities-summary"]');
const entityStatsNode = entitySummaryContainer?.querySelector('[data-role="entity-stats"]');
const entityListNode = entitySummaryContainer?.querySelector('[data-role="entity-list-entities"]');
const entitiesFilterNode = entitySummaryContainer?.querySelector('[data-role="entity-filter-entities"]');
const entityRowTemplate = entityTabs.querySelector('template[data-role="entity-row-template"]');
const entitiesRefreshButton = entitySummaryContainer?.querySelector('[data-role="entities-refresh"]');
const manualOverridesRoot = entityTabs.querySelector('[data-role="manual-overrides"]'); const manualOverridesRoot = entityTabs.querySelector('[data-role="manual-overrides"]');
const manualOverrideList = manualOverridesRoot?.querySelector('[data-role="manual-override-list"]'); const manualOverrideList = manualOverridesRoot?.querySelector('[data-role="manual-override-list"]');
@@ -1102,6 +1169,25 @@ const initPrepare = (root = document) => {
entitiesRefreshButton.setAttribute("aria-disabled", entitiesEnabled ? "false" : "true"); entitiesRefreshButton.setAttribute("aria-disabled", entitiesEnabled ? "false" : "true");
} }
const parseThreshold = (value) => {
const parsed = Number.parseInt(value, 10);
return Number.isFinite(parsed) && parsed >= 0 ? parsed : 0;
};
if (peopleFilterNode) {
peopleFilterNode.addEventListener("change", () => {
entityState.filters.people = parseThreshold(peopleFilterNode.value);
renderEntitySummary();
});
}
if (entitiesFilterNode) {
entitiesFilterNode.addEventListener("change", () => {
entityState.filters.entities = parseThreshold(entitiesFilterNode.value);
renderEntitySummary();
});
}
const cloneTemplate = (template) => { const cloneTemplate = (template) => {
if (!template) return null; if (!template) return null;
if (template.content && template.content.firstElementChild) { if (template.content && template.content.firstElementChild) {
@@ -1116,6 +1202,9 @@ const initPrepare = (root = document) => {
}; };
function activateEntityTab(panelKey) { function activateEntityTab(panelKey) {
if (activeEntityPanel === "manual" && panelKey !== activeEntityPanel) {
void flushManualOverrides();
}
tabButtons.forEach((button) => { tabButtons.forEach((button) => {
const isActive = button.dataset.panel === panelKey; const isActive = button.dataset.panel === panelKey;
button.classList.toggle("is-active", isActive); button.classList.toggle("is-active", isActive);
@@ -1128,6 +1217,7 @@ const initPrepare = (root = document) => {
panel.hidden = !isActive; panel.hidden = !isActive;
panel.setAttribute("aria-hidden", isActive ? "false" : "true"); panel.setAttribute("aria-hidden", isActive ? "false" : "true");
}); });
activeEntityPanel = panelKey;
} }
function populateVoiceOptions(select, selectedVoice) { function populateVoiceOptions(select, selectedVoice) {
@@ -1163,22 +1253,60 @@ const initPrepare = (root = document) => {
} }
function renderEntitySummary() { function renderEntitySummary() {
if (!entityListNode) return;
if (!entitiesEnabled) {
if (entityStatsNode) {
entityStatsNode.textContent = "Entity recognition is turned off in Settings.";
}
entityListNode.innerHTML = "";
const emptyItem = document.createElement("li");
emptyItem.className = "entity-summary__empty";
emptyItem.textContent = "Enable entity recognition to populate detected entities.";
entityListNode.appendChild(emptyItem);
return;
}
const summary = entityState.summary || {}; const summary = entityState.summary || {};
const stats = summary.stats || {}; const stats = summary.stats || {};
const errors = Array.isArray(summary.errors) ? summary.errors : []; const errors = Array.isArray(summary.errors) ? summary.errors : [];
const peopleEntries = Array.isArray(summary.people) ? summary.people : [];
const entityEntries = Array.isArray(summary.entities) ? summary.entities : [];
const peopleThreshold = Number.parseInt(entityState.filters?.people, 10) || 0;
const entityThreshold = Number.parseInt(entityState.filters?.entities, 10) || 0;
const ensureSelectValue = (node, value) => {
if (!node) return;
const target = String(value);
if (node.value !== target) {
node.value = target;
}
};
ensureSelectValue(peopleFilterNode, peopleThreshold);
ensureSelectValue(entitiesFilterNode, entityThreshold);
const renderDisabled = (listNode, message) => {
if (!listNode) return;
listNode.innerHTML = "";
const emptyItem = document.createElement("li");
emptyItem.className = "entity-summary__empty";
emptyItem.textContent = message;
listNode.appendChild(emptyItem);
};
if (!entitiesEnabled) {
const disabledMessage = "Entity recognition is turned off in Settings.";
if (entityStatsNode) {
entityStatsNode.textContent = disabledMessage;
}
if (peopleStatsNode) {
peopleStatsNode.textContent = disabledMessage;
}
if (peopleFilterNode) {
peopleFilterNode.disabled = true;
}
if (entitiesFilterNode) {
entitiesFilterNode.disabled = true;
}
renderDisabled(peopleListNode, disabledMessage);
renderDisabled(entityListNode, "Enable entity recognition to populate detected entities.");
return;
}
if (peopleFilterNode) {
peopleFilterNode.disabled = false;
}
if (entitiesFilterNode) {
entitiesFilterNode.disabled = false;
}
if (entityStatsNode) { if (entityStatsNode) {
if (errors.length) { if (errors.length) {
entityStatsNode.textContent = errors.join(" · "); entityStatsNode.textContent = errors.join(" · ");
@@ -1190,90 +1318,162 @@ const initPrepare = (root = document) => {
if (typeof stats.tokens === "number") { if (typeof stats.tokens === "number") {
parts.push(`${stats.tokens.toLocaleString()} tokens processed`); parts.push(`${stats.tokens.toLocaleString()} tokens processed`);
} }
if (typeof stats.people === "number") {
parts.push(`${stats.people} character${stats.people === 1 ? "" : "s"}`);
}
if (typeof stats.entities === "number") {
parts.push(`${stats.entities} ${stats.entities === 1 ? "entity" : "entities"}`);
}
entityStatsNode.textContent = parts.length ? parts.join(" · ") : "Entity analysis up to date."; entityStatsNode.textContent = parts.length ? parts.join(" · ") : "Entity analysis up to date.";
} else { } else {
entityStatsNode.textContent = "Entity analysis will populate once you continue from chapters."; entityStatsNode.textContent = "Entity analysis will populate once you continue from chapters.";
} }
} }
entityListNode.innerHTML = ""; const renderGroup = (listNode, entries, threshold, options) => {
const entries = Array.isArray(summary.entities) ? summary.entities : []; if (!listNode) {
if (!entries.length) { return { visible: 0, total: entries.length };
const emptyItem = document.createElement("li"); }
emptyItem.className = "entity-summary__empty"; listNode.innerHTML = "";
emptyItem.textContent = "No entities detected yet."; const filtered = entries.filter((entry) => Number(entry?.count || 0) >= threshold);
entityListNode.appendChild(emptyItem); if (!filtered.length) {
return; const emptyItem = document.createElement("li");
emptyItem.className = "entity-summary__empty";
emptyItem.textContent = entries.length ? options.filteredEmptyText : options.emptyText;
listNode.appendChild(emptyItem);
return { visible: 0, total: entries.length };
}
filtered.forEach((entity) => {
const item = cloneTemplate(entityRowTemplate);
if (!item) return;
const normalized = entity.normalized || entity.label || entity.token || "";
const tokenLabel = entity.label || entity.token || normalized || "Untitled entity";
item.dataset.entityId = entity.id || normalized || tokenLabel;
item.dataset.entityCategory = options.groupKey;
const labelEl = item.querySelector('[data-role="entity-label"]');
if (labelEl) {
labelEl.textContent = tokenLabel;
}
const kindEl = item.querySelector('[data-role="entity-kind"]');
if (kindEl) {
const kind = entity.kind || entity.category || "";
if (options.hideKind || !kind) {
kindEl.textContent = "";
kindEl.hidden = true;
} else {
kindEl.hidden = false;
kindEl.textContent = kind;
}
}
const countEl = item.querySelector('[data-role="entity-count"]');
if (countEl) {
countEl.textContent = formatMentions(entity.count);
}
const samplesContainer = item.querySelector('[data-role="entity-samples"]');
if (samplesContainer) {
samplesContainer.innerHTML = "";
const samples = Array.isArray(entity.samples) ? entity.samples : [];
if (!samples.length) {
const hint = document.createElement("p");
hint.className = "hint";
hint.textContent = "No sample sentences captured yet.";
samplesContainer.appendChild(hint);
} else {
const list = document.createElement("ul");
list.className = "entity-summary__samples-list";
samples.slice(0, 3).forEach((sample) => {
const text = typeof sample === "string" ? sample : sample?.excerpt;
if (!text) return;
const entry = document.createElement("li");
entry.textContent = text;
list.appendChild(entry);
});
samplesContainer.appendChild(list);
}
}
const button = item.querySelector('[data-role="entity-add-override"]');
if (button) {
const hasOverride = entityState.manualOverrides.some((entry) => {
const candidate = entry?.normalized || entry?.token || "";
return candidate && candidate.toLowerCase() === normalized.toLowerCase();
});
button.dataset.entityToken = entity.label || entity.token || "";
button.dataset.entityNormalized = normalized;
button.dataset.entityCategory = options.groupKey;
button.dataset.entityCount = String(entity.count || 0);
const sampleContext = Array.isArray(entity.samples) && entity.samples.length
? typeof entity.samples[0] === "string"
? entity.samples[0]
: entity.samples[0]?.excerpt || ""
: "";
if (sampleContext) {
button.dataset.entityContext = sampleContext;
}
if (entity.kind) {
button.dataset.entityKind = entity.kind;
}
button.textContent = hasOverride ? "Edit manual override" : "Add manual override";
}
listNode.appendChild(item);
});
return { visible: filtered.length, total: entries.length };
};
const peopleRender = renderGroup(peopleListNode, peopleEntries, peopleThreshold, {
groupKey: "people",
hideKind: true,
emptyText: "No characters detected yet.",
filteredEmptyText: "No characters match the selected mention filter.",
});
if (peopleFilterNode && !peopleEntries.length) {
peopleFilterNode.value = "0";
} }
entries.forEach((entity) => { if (peopleStatsNode) {
const item = cloneTemplate(entityRowTemplate); if (!peopleEntries.length) {
if (!item) return; peopleStatsNode.textContent = "No characters detected yet.";
const normalized = entity.normalized || entity.label || entity.token || ""; } else if (!peopleRender.visible) {
const tokenLabel = entity.label || entity.token || normalized || "Untitled entity"; peopleStatsNode.textContent = "Adjust the mention filter to see additional characters.";
item.dataset.entityId = entity.id || normalized || tokenLabel; } else {
let label = "all mentions";
const labelEl = item.querySelector('[data-role="entity-label"]'); if (peopleThreshold > 1) {
if (labelEl) { label = `${peopleThreshold}+ mentions`;
labelEl.textContent = tokenLabel; } else if (peopleThreshold === 1) {
} label = "1+ mention";
const kindEl = item.querySelector('[data-role="entity-kind"]');
if (kindEl) {
const kind = entity.kind || entity.category || "";
kindEl.textContent = kind;
kindEl.hidden = !kind;
}
const countEl = item.querySelector('[data-role="entity-count"]');
if (countEl) {
countEl.textContent = formatMentions(entity.count);
}
const samplesContainer = item.querySelector('[data-role="entity-samples"]');
if (samplesContainer) {
samplesContainer.innerHTML = "";
const samples = Array.isArray(entity.samples) ? entity.samples : [];
if (!samples.length) {
const hint = document.createElement("p");
hint.className = "hint";
hint.textContent = "No sample sentences captured yet.";
samplesContainer.appendChild(hint);
} else {
const list = document.createElement("ul");
list.className = "entity-summary__samples-list";
samples.slice(0, 3).forEach((sample) => {
const text = typeof sample === "string" ? sample : sample?.excerpt;
if (!text) return;
const entry = document.createElement("li");
entry.textContent = text;
list.appendChild(entry);
});
samplesContainer.appendChild(list);
} }
peopleStatsNode.textContent = `Showing ${peopleRender.visible} of ${peopleRender.total} characters (${label}).`;
} }
}
const button = item.querySelector('[data-role="entity-add-override"]'); const entitiesRender = renderGroup(entityListNode, entityEntries, entityThreshold, {
if (button) { groupKey: "entities",
const hasOverride = entityState.manualOverrides.some((entry) => { hideKind: false,
const candidate = entry?.normalized || entry?.token || ""; emptyText: "No entities detected yet.",
return candidate && candidate.toLowerCase() === normalized.toLowerCase(); filteredEmptyText: "No entities match the selected mention filter.",
});
button.dataset.entityToken = entity.label || entity.token || "";
button.dataset.entityNormalized = normalized;
const sampleContext = Array.isArray(entity.samples) && entity.samples.length
? typeof entity.samples[0] === "string"
? entity.samples[0]
: entity.samples[0]?.excerpt || ""
: "";
if (sampleContext) {
button.dataset.entityContext = sampleContext;
}
button.textContent = hasOverride ? "Edit manual override" : "Add manual override";
}
entityListNode.appendChild(item);
}); });
if (entitiesFilterNode && !entityEntries.length) {
entitiesFilterNode.value = "0";
}
if (
entityStatsNode &&
!errors.length &&
stats.processed &&
typeof stats.entities === "number" &&
entityThreshold > 0 &&
stats.entities > entitiesRender.visible
) {
const filterLabel = entityThreshold > 1 ? `${entityThreshold}+ mentions` : "1+ mention";
entityStatsNode.textContent += ` · Filter hiding ${stats.entities - entitiesRender.visible} entries (${filterLabel})`;
}
} }
function renderManualOverrides() { function renderManualOverrides() {
@@ -1400,6 +1600,87 @@ const initPrepare = (root = document) => {
renderManualOverrides(); renderManualOverrides();
} }
const filterVoicesByGender = (voices, genderHint) => {
const normalized = (genderHint || "unknown").toLowerCase();
if (normalized === "female") {
return voices.filter((voice) => (voice.gender_code || "").toLowerCase() === "f");
}
if (normalized === "male") {
return voices.filter((voice) => (voice.gender_code || "").toLowerCase() === "m");
}
if (normalized === "either") {
return voices.filter((voice) => {
const code = (voice.gender_code || "").toLowerCase();
return code === "f" || code === "m";
});
}
return voices.slice();
};
const pickRandomVoiceForOverride = (genderHint) => {
if (!Array.isArray(voiceCatalog) || !voiceCatalog.length) {
return baseVoice || "";
}
const normalizedLanguage = (languageCode || "").trim().toLowerCase();
const languageCandidates = () => {
const keys = [];
if (normalizedLanguage) keys.push(normalizedLanguage);
if (languageCode && languageCode !== normalizedLanguage) {
keys.push(languageCode);
}
for (const key of keys) {
const mapped = languageMap?.[key];
if (Array.isArray(mapped) && mapped.length) {
const lookup = new Set(mapped);
const matches = voiceCatalog.filter((voice) => lookup.has(voice.id));
if (matches.length) {
return matches;
}
}
}
const direct = voiceCatalog.filter((voice) => (voice.language || "").toLowerCase() === normalizedLanguage);
return direct;
};
const preferred = languageCandidates();
let pool = filterVoicesByGender(preferred.length ? preferred : voiceCatalog, genderHint);
if (!pool.length) {
pool = voiceCatalog.slice();
}
if (!pool.length) {
return baseVoice || "";
}
const selected = pool[Math.floor(Math.random() * pool.length)];
return selected?.id || baseVoice || "";
};
const resolveOverrideVoice = (data) => {
if (data.voice) {
return data.voice;
}
const normalizedKey = canonicalizeEntityKey(data.normalized || data.token || "");
let genderHint = (data.gender || "").toLowerCase();
if (normalizedKey) {
const speakerHint = speakerHints.get(normalizedKey);
if (speakerHint) {
if (speakerHint.voice) {
return speakerHint.voice;
}
if (!genderHint || genderHint === "unknown") {
genderHint = speakerHint.gender || genderHint;
}
}
}
if (!genderHint || genderHint === "unknown") {
if (data.category === "people" || data.kind === "PERSON") {
genderHint = "either";
} else {
genderHint = "";
}
}
return pickRandomVoiceForOverride(genderHint);
};
function collectOverridePayload(overrideId) { function collectOverridePayload(overrideId) {
if (!overrideId || !manualOverrideList) return null; if (!overrideId || !manualOverrideList) return null;
const selectorId = typeof CSS !== "undefined" && CSS.escape ? CSS.escape(overrideId) : overrideId.replace(/["\\]/g, "\\$&"); const selectorId = typeof CSS !== "undefined" && CSS.escape ? CSS.escape(overrideId) : overrideId.replace(/["\\]/g, "\\$&");
@@ -1420,7 +1701,7 @@ const initPrepare = (root = document) => {
async function saveManualOverride(overrideId) { async function saveManualOverride(overrideId) {
const payload = collectOverridePayload(overrideId); const payload = collectOverridePayload(overrideId);
if (!payload || !manualUpsertUrl) return; if (!payload || !manualUpsertUrl) return false;
try { try {
const response = await fetch(manualUpsertUrl, { const response = await fetch(manualUpsertUrl, {
method: "POST", method: "POST",
@@ -1432,25 +1713,52 @@ const initPrepare = (root = document) => {
} }
const data = await response.json(); const data = await response.json();
applyEntityPayload(data, { highlightId: data.override?.id || payload.id }); applyEntityPayload(data, { highlightId: data.override?.id || payload.id });
return true;
} catch (error) { } catch (error) {
console.error("Failed to save manual override", error); console.error("Failed to save manual override", error);
return false;
} }
} }
function scheduleManualOverrideSave(overrideId) { markOverrideDirty = (overrideId) => {
if (!overrideId) return; if (!overrideId) return;
if (pendingOverrideSaves.has(overrideId)) { dirtyOverrideIds.add(overrideId);
clearTimeout(pendingOverrideSaves.get(overrideId)); };
}
const timer = setTimeout(() => { flushManualOverrides = (options = {}) => {
pendingOverrideSaves.delete(overrideId); const { overrideId } = options || {};
saveManualOverride(overrideId); const pendingIds = Array.isArray(overrideId) ? overrideId : overrideId ? [overrideId] : Array.from(dirtyOverrideIds);
}, 400); const targetIds = pendingIds.filter((id) => dirtyOverrideIds.has(id));
pendingOverrideSaves.set(overrideId, timer); if (!targetIds.length) return null;
} targetIds.forEach((id) => dirtyOverrideIds.delete(id));
const runFlush = async () => {
if (overrideFlushPromise) {
try {
await overrideFlushPromise;
} catch (error) {
console.warn("Previous override flush failed", error);
}
}
const results = await Promise.all(targetIds.map((id) => saveManualOverride(id)));
results.forEach((ok, index) => {
if (!ok) {
dirtyOverrideIds.add(targetIds[index]);
}
});
};
overrideFlushPromise = runFlush()
.catch((error) => {
console.error("Failed flushing manual overrides", error);
})
.finally(() => {
overrideFlushPromise = null;
});
return overrideFlushPromise;
};
async function deleteManualOverride(overrideId) { async function deleteManualOverride(overrideId) {
if (!overrideId || !manualDeleteUrlTemplate) return; if (!overrideId || !manualDeleteUrlTemplate) return;
dirtyOverrideIds.delete(overrideId);
const targetUrl = manualDeleteUrlTemplate.replace("__OVERRIDE_ID__", encodeURIComponent(overrideId)); const targetUrl = manualDeleteUrlTemplate.replace("__OVERRIDE_ID__", encodeURIComponent(overrideId));
try { try {
const response = await fetch(targetUrl, { method: "DELETE" }); const response = await fetch(targetUrl, { method: "DELETE" });
@@ -1543,12 +1851,14 @@ const initPrepare = (root = document) => {
if (!data || !manualUpsertUrl) return; if (!data || !manualUpsertUrl) return;
const token = (data.token || "").trim(); const token = (data.token || "").trim();
if (!token) return; if (!token) return;
const normalized = (data.normalized || "").trim();
const voiceChoice = resolveOverrideVoice({ ...data, token, normalized });
const payload = { const payload = {
token, token,
normalized: (data.normalized || "").trim(), normalized,
context: data.context || "", context: data.context || "",
pronunciation: data.pronunciation || "", pronunciation: data.pronunciation || "",
voice: data.voice || "", voice: voiceChoice || "",
source: data.source || "manual", source: data.source || "manual",
}; };
try { try {
@@ -1580,6 +1890,7 @@ const initPrepare = (root = document) => {
if (entitiesRefreshButton) { if (entitiesRefreshButton) {
entitiesRefreshButton.addEventListener("click", () => { entitiesRefreshButton.addEventListener("click", () => {
void flushManualOverrides();
performEntitiesRefresh(true); performEntitiesRefresh(true);
}); });
} }
@@ -1593,6 +1904,8 @@ const initPrepare = (root = document) => {
token: trigger.dataset.entityToken || "", token: trigger.dataset.entityToken || "",
normalized: trigger.dataset.entityNormalized || "", normalized: trigger.dataset.entityNormalized || "",
context: trigger.dataset.entityContext || "", context: trigger.dataset.entityContext || "",
category: trigger.dataset.entityCategory || "",
kind: trigger.dataset.entityKind || "",
source: "entity", source: "entity",
}); });
}); });
@@ -1649,7 +1962,7 @@ const initPrepare = (root = document) => {
previewButton.dataset.previewText = input.value.trim() || input.placeholder || ""; previewButton.dataset.previewText = input.value.trim() || input.placeholder || "";
} }
if (overrideId) { if (overrideId) {
scheduleManualOverrideSave(overrideId); markOverrideDirty(overrideId);
} }
}); });
@@ -1663,11 +1976,15 @@ const initPrepare = (root = document) => {
previewButton.dataset.voice = select.value || baseVoice || select.dataset.defaultVoice || ""; previewButton.dataset.voice = select.value || baseVoice || select.dataset.defaultVoice || "";
} }
if (overrideId) { if (overrideId) {
scheduleManualOverrideSave(overrideId); markOverrideDirty(overrideId);
} }
}); });
manualOverrideList.addEventListener("click", (event) => { manualOverrideList.addEventListener("click", (event) => {
const previewButton = event.target.closest('[data-role="speaker-preview"]');
if (previewButton?.dataset.overrideId) {
void flushManualOverrides({ overrideId: previewButton.dataset.overrideId });
}
const deleteButton = event.target.closest('[data-role="manual-override-delete"]'); const deleteButton = event.target.closest('[data-role="manual-override-delete"]');
if (!deleteButton) return; if (!deleteButton) return;
event.preventDefault(); event.preventDefault();
@@ -1683,7 +2000,42 @@ const initPrepare = (root = document) => {
renderManualOverrides(); renderManualOverrides();
} }
const handleDeferredSubmit = (event) => {
const hasDirty = dirtyOverrideIds.size > 0;
const hasPendingFlush = Boolean(overrideFlushPromise);
if (!hasDirty && !hasPendingFlush) return;
if (form.dataset.skipDirtyFlush === "true") {
delete form.dataset.skipDirtyFlush;
return;
}
event.preventDefault();
const submitter = event.submitter || null;
const resumeSubmission = () => {
form.dataset.skipDirtyFlush = "true";
if (typeof form.requestSubmit === "function") {
form.requestSubmit(submitter || undefined);
} else if (submitter && typeof submitter.click === "function") {
submitter.click();
} else if (typeof form.submit === "function") {
form.submit();
}
};
const flushPromise = overrideFlushPromise || flushManualOverrides();
if (!flushPromise) {
resumeSubmission();
return;
}
flushPromise.finally(resumeSubmission);
};
form.addEventListener("submit", handleDeferredSubmit);
form.addEventListener("click", (event) => { form.addEventListener("click", (event) => {
const previewFromOverride = event.target.closest('[data-role="speaker-preview"]');
if (previewFromOverride?.dataset.overrideId) {
void flushManualOverrides({ overrideId: previewFromOverride.dataset.overrideId });
}
const genderMenu = event.target.closest('[data-role="gender-menu"]'); const genderMenu = event.target.closest('[data-role="gender-menu"]');
const genderPill = event.target.closest('[data-role="gender-pill"]'); const genderPill = event.target.closest('[data-role="gender-pill"]');
if (!genderMenu && !genderPill) { if (!genderMenu && !genderPill) {
+18
View File
@@ -1719,6 +1719,24 @@ button.step-indicator__item:focus-visible {
gap: 1rem; gap: 1rem;
} }
.entity-summary__filters {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.75rem;
}
.entity-summary__filters .field,
.entity-summary__filters .button {
margin: 0;
}
.field--inline {
display: inline-flex;
flex-direction: column;
gap: 0.35rem;
}
.entity-summary__titles h2 { .entity-summary__titles h2 {
margin: 0; margin: 0;
} }
@@ -332,6 +332,29 @@
{% else %} {% else %}
<p class="hint">No additional speakers detected yet. The narrator voice will be used for all dialogue.</p> <p class="hint">No additional speakers detected yet. The narrator voice will be used for all dialogue.</p>
{% endif %} {% endif %}
<section class="entity-summary entity-summary--people" data-role="people-summary">
<header class="entity-summary__header">
<div class="entity-summary__titles">
<h2>Detected people</h2>
<p class="hint">Characters surfaced by entity recognition. Filter by mention count to focus on recurring names.</p>
</div>
<div class="entity-summary__filters">
<label class="field field--inline" for="people-mention-filter">
<span>Minimum mentions</span>
<select id="people-mention-filter" data-role="entity-filter-people">
<option value="0">All</option>
<option value="1">1+</option>
<option value="2">2+</option>
<option value="5">5+</option>
<option value="10">10+</option>
</select>
</label>
</div>
</header>
<dl class="entity-summary__stats" data-role="people-stats"></dl>
<ol class="entity-summary__list" data-role="entity-list-people"></ol>
</section>
</section> </section>
<section class="entity-tabs__panel" <section class="entity-tabs__panel"
@@ -341,16 +364,28 @@
role="tabpanel" role="tabpanel"
aria-labelledby="entity-tab-entities" aria-labelledby="entity-tab-entities"
hidden> hidden>
<div class="entity-summary" data-role="entity-summary"> <div class="entity-summary" data-role="entities-summary">
<header class="entity-summary__header"> <header class="entity-summary__header">
<div class="entity-summary__titles"> <div class="entity-summary__titles">
<h2>Detected entities</h2> <h2>Detected entities</h2>
<p class="hint">Assign pronunciations and voices for recurring names and terminology across the manuscript.</p> <p class="hint">Assign pronunciations and voices for recurring names and terminology across the manuscript.</p>
</div> </div>
<button type="button" class="button button--ghost button--small" data-role="entities-refresh" {% if not recognition_enabled %}disabled aria-disabled="true"{% endif %}>Refresh</button> <div class="entity-summary__filters">
<label class="field field--inline" for="entities-mention-filter">
<span>Minimum mentions</span>
<select id="entities-mention-filter" data-role="entity-filter-entities">
<option value="0">All</option>
<option value="1">1+</option>
<option value="2">2+</option>
<option value="5">5+</option>
<option value="10">10+</option>
</select>
</label>
<button type="button" class="button button--ghost button--small" data-role="entities-refresh" {% if not recognition_enabled %}disabled aria-disabled="true"{% endif %}>Refresh</button>
</div>
</header> </header>
<dl class="entity-summary__stats" data-role="entity-stats"></dl> <dl class="entity-summary__stats" data-role="entity-stats"></dl>
<ol class="entity-summary__list" data-role="entity-list"></ol> <ol class="entity-summary__list" data-role="entity-list-entities"></ol>
<template data-role="entity-row-template"> <template data-role="entity-row-template">
<li class="entity-summary__item"> <li class="entity-summary__item">
<header class="entity-summary__item-header"> <header class="entity-summary__item-header">