feat: Implement heteronym handling with extraction, UI integration, and processing logic

This commit is contained in:
JB
2025-12-15 06:41:13 -08:00
parent ef2b045b69
commit daf4d78766
9 changed files with 615 additions and 6 deletions
+79
View File
@@ -906,6 +906,75 @@ def _compile_pronunciation_rules(
return compiled
def _compile_heteronym_sentence_rules(
overrides: Optional[Iterable[Mapping[str, Any]]],
) -> List[Dict[str, Any]]:
"""Compile sentence-level replacements for heteronym disambiguation.
These are intentionally scoped to a specific sentence string rather than a token,
so we can apply different pronunciations for the same word in different contexts.
Expected override entry shape (from pending/job):
- sentence: original sentence text
- choice: selected option key
- options: [{key, replacement_sentence, ...}]
"""
if not overrides:
return []
compiled: List[Dict[str, Any]] = []
seen: set[str] = set()
for entry in overrides:
if not isinstance(entry, Mapping):
continue
sentence = str(entry.get("sentence") or "").strip()
if not sentence:
continue
choice = str(entry.get("choice") or "").strip()
if not choice:
continue
replacement_sentence = ""
options = entry.get("options")
if isinstance(options, list):
for opt in options:
if not isinstance(opt, Mapping):
continue
if str(opt.get("key") or "").strip() == choice:
replacement_sentence = str(opt.get("replacement_sentence") or "").strip()
break
if not replacement_sentence:
continue
rule_key = f"{sentence}\n{choice}".casefold()
if rule_key in seen:
continue
seen.add(rule_key)
parts = [p for p in re.split(r"\s+", sentence) if p]
if not parts:
continue
pattern_text = r"\s+".join(re.escape(p) for p in parts)
pattern = re.compile(pattern_text)
compiled.append({"pattern": pattern, "replacement": replacement_sentence})
# Replace longer sentences first to avoid partial matches.
compiled.sort(key=lambda item: len(item["pattern"].pattern), reverse=True)
return compiled
def _apply_heteronym_sentence_rules(text: str, rules: List[Dict[str, Any]]) -> str:
if not text or not rules:
return text
result = text
for rule in rules:
pattern = rule["pattern"]
replacement = rule["replacement"]
result = pattern.sub(replacement, result)
return result
def _apply_pronunciation_rules(
text: str,
rules: List[Dict[str, Any]],
@@ -1306,6 +1375,14 @@ def run_conversion_job(job: Job) -> None:
extraction = extract_from_path(job.stored_path)
file_type = _infer_file_type(job.stored_path)
pronunciation_rules = _compile_pronunciation_rules(job.pronunciation_overrides)
heteronym_sentence_rules = _compile_heteronym_sentence_rules(
getattr(job, "heteronym_overrides", None)
)
if heteronym_sentence_rules:
job.add_log(
f"Applying {len(heteronym_sentence_rules)} heteronym override{'s' if len(heteronym_sentence_rules) != 1 else ''} during conversion.",
level="debug",
)
if pronunciation_rules:
count = len(pronunciation_rules)
job.add_log(
@@ -1447,6 +1524,8 @@ def run_conversion_job(job: Job) -> None:
) -> int:
nonlocal processed_chars, subtitle_index, current_time
source_text = str(text or "")
if heteronym_sentence_rules:
source_text = _apply_heteronym_sentence_rules(source_text, heteronym_sentence_rules)
if pronunciation_rules:
source_text = _apply_pronunciation_rules(
source_text,
+1
View File
@@ -64,6 +64,7 @@ def list_manual_overrides(pending_id: str) -> ResponseReturnValue:
return jsonify({
"overrides": pending.manual_overrides or [],
"pronunciation_overrides": pending.pronunciation_overrides or [],
"heteronym_overrides": getattr(pending, "heteronym_overrides", None) or [],
"language": pending.language or "en",
})
+20 -6
View File
@@ -17,6 +17,7 @@ from abogen.pronunciation_store import (
search_overrides as search_pronunciation_overrides,
)
from abogen.web.routes.utils.settings import load_settings
from abogen.heteronym_overrides import extract_heteronym_overrides
def collect_pronunciation_overrides(pending: PendingJob) -> List[Dict[str, Any]]:
language = pending.language or "en"
@@ -140,22 +141,34 @@ def sync_pronunciation_overrides(pending: PendingJob) -> None:
def refresh_entity_summary(pending: PendingJob, chapters: Iterable[Mapping[str, Any]]) -> None:
settings = load_settings()
if not bool(settings.get("enable_entity_recognition", True)):
pending.entity_summary = {}
pending.entity_cache_key = ""
pending.pronunciation_overrides = pending.pronunciation_overrides or []
return
language = pending.language or "en"
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 []
pending.heteronym_overrides = pending.heteronym_overrides or []
return
enabled_only = [chapter for chapter in chapter_list if chapter.get("enabled")]
target_chapters = enabled_only or chapter_list
# Always compute heteronym overrides (English only). Preserve any prior selections.
try:
pending.heteronym_overrides = extract_heteronym_overrides(
target_chapters,
language=language,
existing=getattr(pending, "heteronym_overrides", None),
)
except Exception:
pending.heteronym_overrides = getattr(pending, "heteronym_overrides", []) or []
if not bool(settings.get("enable_entity_recognition", True)):
pending.entity_summary = {}
pending.entity_cache_key = ""
pending.pronunciation_overrides = pending.pronunciation_overrides or []
return
result = extract_entities(target_chapters, language=language)
summary = dict(result.summary)
tokens: List[str] = []
@@ -343,6 +356,7 @@ def pending_entities_payload(pending: PendingJob) -> Dict[str, Any]:
"summary": pending.entity_summary or {},
"manual_overrides": pending.manual_overrides or [],
"pronunciation_overrides": pending.pronunciation_overrides or [],
"heteronym_overrides": getattr(pending, "heteronym_overrides", None) or [],
"cache_key": pending.entity_cache_key,
"language": pending.language or "en",
"recognition_enabled": recognition_enabled,
+25
View File
@@ -396,6 +396,31 @@ def apply_prepare_form(
enabled_overrides = [entry for entry in overrides if entry.get("enabled")]
heteronym_entries = getattr(pending, "heteronym_overrides", None)
if isinstance(heteronym_entries, list) and heteronym_entries:
for entry in heteronym_entries:
if not isinstance(entry, dict):
continue
entry_id = str(entry.get("entry_id") or entry.get("id") or "").strip()
if not entry_id:
continue
raw_choice = form.get(f"heteronym-{entry_id}-choice")
if raw_choice is None:
continue
choice = str(raw_choice).strip()
if not choice:
continue
options = entry.get("options")
if isinstance(options, list) and options:
allowed = {
str(opt.get("key")).strip()
for opt in options
if isinstance(opt, dict) and str(opt.get("key") or "").strip()
}
if allowed and choice not in allowed:
continue
entry["choice"] = choice
sync_pronunciation_overrides(pending)
return (
+1
View File
@@ -59,6 +59,7 @@ def submit_job(pending: PendingJob) -> str:
entity_summary=getattr(pending, "entity_summary", None),
manual_overrides=getattr(pending, "manual_overrides", None),
pronunciation_overrides=getattr(pending, "pronunciation_overrides", None),
heteronym_overrides=getattr(pending, "heteronym_overrides", None),
normalization_overrides=pending.normalization_overrides,
)
return job.id
+9
View File
@@ -152,6 +152,7 @@ class Job:
entity_summary: Dict[str, Any] = field(default_factory=dict)
manual_overrides: List[Dict[str, Any]] = field(default_factory=list)
pronunciation_overrides: List[Dict[str, Any]] = field(default_factory=list)
heteronym_overrides: List[Dict[str, Any]] = field(default_factory=list)
normalization_overrides: Dict[str, Any] = field(default_factory=dict)
speaker_voice_languages: List[str] = field(default_factory=list)
applied_speaker_config: Optional[str] = None
@@ -242,6 +243,7 @@ class Job:
"entity_summary": dict(self.entity_summary),
"manual_overrides": [dict(entry) for entry in self.manual_overrides],
"pronunciation_overrides": [dict(entry) for entry in self.pronunciation_overrides],
"heteronym_overrides": [dict(entry) for entry in self.heteronym_overrides],
"normalization_overrides": dict(self.normalization_overrides),
}
@@ -565,6 +567,7 @@ class PendingJob:
entity_summary: Dict[str, Any] = field(default_factory=dict)
manual_overrides: List[Dict[str, Any]] = field(default_factory=list)
pronunciation_overrides: List[Dict[str, Any]] = field(default_factory=list)
heteronym_overrides: List[Dict[str, Any]] = field(default_factory=list)
entity_cache_key: Optional[str] = None
wizard_max_step_index: int = 0
@@ -646,6 +649,7 @@ class ConversionService:
entity_summary: Optional[Mapping[str, Any]] = None,
manual_overrides: Optional[Iterable[Mapping[str, Any]]] = None,
pronunciation_overrides: Optional[Iterable[Mapping[str, Any]]] = None,
heteronym_overrides: Optional[Iterable[Mapping[str, Any]]] = None,
normalization_overrides: Optional[Mapping[str, Any]] = None,
) -> Job:
job_id = uuid.uuid4().hex
@@ -697,6 +701,7 @@ class ConversionService:
entity_summary=dict(entity_summary or {}),
manual_overrides=[dict(entry) for entry in manual_overrides] if manual_overrides else [],
pronunciation_overrides=[dict(entry) for entry in pronunciation_overrides] if pronunciation_overrides else [],
heteronym_overrides=[dict(entry) for entry in heteronym_overrides] if heteronym_overrides else [],
normalization_overrides=dict(normalization_overrides or {}),
)
with self._lock:
@@ -1184,6 +1189,7 @@ class ConversionService:
"entity_summary": dict(job.entity_summary),
"manual_overrides": [dict(entry) for entry in job.manual_overrides],
"pronunciation_overrides": [dict(entry) for entry in job.pronunciation_overrides],
"heteronym_overrides": [dict(entry) for entry in job.heteronym_overrides],
"normalization_overrides": dict(job.normalization_overrides),
}
@@ -1310,6 +1316,9 @@ class ConversionService:
job.pronunciation_overrides = [
dict(entry) for entry in payload.get("pronunciation_overrides", []) if isinstance(entry, Mapping)
]
job.heteronym_overrides = [
dict(entry) for entry in payload.get("heteronym_overrides", []) if isinstance(entry, Mapping)
]
job.normalization_overrides = dict(payload.get("normalization_overrides", {}) or {})
job.pause_event.set()
return job
+170
View File
@@ -1107,6 +1107,7 @@ const initPrepare = (root = document) => {
const entityCacheKeyData = parseJSONScript("entity-cache-key");
const manualOverridesSeed = parseJSONScript("manual-overrides-data") || [];
const pronunciationOverridesSeed = parseJSONScript("pronunciation-overrides-data") || [];
const heteronymOverridesSeed = parseJSONScript("heteronym-overrides-data") || [];
const entityTabs = form.querySelector('[data-role="entity-tabs"]');
const entitiesUrl = form.dataset.entitiesUrl || "";
@@ -1124,6 +1125,7 @@ const initPrepare = (root = document) => {
cacheKey: typeof entityCacheKeyData === "string" ? entityCacheKeyData : "",
manualOverrides: Array.isArray(manualOverridesSeed) ? [...manualOverridesSeed] : [],
pronunciationOverrides: Array.isArray(pronunciationOverridesSeed) ? [...pronunciationOverridesSeed] : [],
heteronymOverrides: Array.isArray(heteronymOverridesSeed) ? [...heteronymOverridesSeed] : [],
filters: {
people: 0,
entities: 0,
@@ -1171,6 +1173,10 @@ const initPrepare = (root = document) => {
const manualOverridesEmpty = manualOverridesRoot?.querySelector('[data-role="manual-overrides-empty"]');
const manualOverrideSaveButton = manualOverridesRoot?.querySelector('[data-role="manual-override-save-all"]');
const manualOverrideStatusNode = manualOverridesRoot?.querySelector('[data-role="manual-override-status"]');
const heteronymOverridesRoot = manualOverridesRoot?.querySelector('[data-role="heteronym-overrides"]');
const heteronymOverrideList = heteronymOverridesRoot?.querySelector('[data-role="heteronym-override-list"]');
const heteronymOverrideTemplate = heteronymOverridesRoot?.querySelector('template[data-role="heteronym-override-template"]');
const heteronymOverridesEmpty = heteronymOverridesRoot?.querySelector('[data-role="heteronym-overrides-empty"]');
let manualOverrideStatusTimer = null;
let manualOverrideStatusNonce = 0;
@@ -1879,6 +1885,165 @@ const initPrepare = (root = document) => {
});
}
const escapeRegExp = (value) => String(value || "").replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
const resolveHeteronymTooltip = (entry) => {
if (!entry || typeof entry !== "object") return "";
const options = Array.isArray(entry.options) ? entry.options : [];
if (options.length < 2) return "";
const parts = [];
options.slice(0, 2).forEach((opt) => {
const label = String(opt?.label || "").trim();
const example = String(opt?.example_sentence || "").trim();
if (label && example) {
parts.push(`${label}: ${example}`);
} else if (example) {
parts.push(example);
}
});
return parts.join("\n");
};
const fillHighlightedSentence = (container, sentence, token, tooltip) => {
if (!container) return;
container.textContent = "";
const rawSentence = String(sentence || "");
const rawToken = String(token || "");
if (!rawSentence) {
return;
}
if (!rawToken) {
container.textContent = rawSentence;
return;
}
const pattern = new RegExp(`\\b${escapeRegExp(rawToken)}\\b`, "i");
const match = pattern.exec(rawSentence);
if (!match) {
container.textContent = rawSentence;
return;
}
const before = rawSentence.slice(0, match.index);
const hit = rawSentence.slice(match.index, match.index + match[0].length);
const after = rawSentence.slice(match.index + match[0].length);
if (before) {
container.appendChild(document.createTextNode(before));
}
const chip = document.createElement("span");
chip.className = "chip";
chip.textContent = hit;
if (tooltip) {
chip.title = tooltip;
}
container.appendChild(chip);
if (after) {
container.appendChild(document.createTextNode(after));
}
};
function renderHeteronymOverrides() {
if (!heteronymOverrideList) return;
heteronymOverrideList.innerHTML = "";
const entries = Array.isArray(entityState.heteronymOverrides) ? entityState.heteronymOverrides : [];
if (!entries.length) {
if (heteronymOverridesEmpty) {
heteronymOverridesEmpty.hidden = false;
}
return;
}
if (heteronymOverridesEmpty) {
heteronymOverridesEmpty.hidden = true;
}
entries.forEach((entry) => {
if (!entry || typeof entry !== "object") return;
const entryId = String(entry.entry_id || entry.id || "").trim();
if (!entryId) return;
const sentence = String(entry.sentence || "").trim();
if (!sentence) return;
const token = String(entry.token || "").trim();
const node = cloneTemplate(heteronymOverrideTemplate);
if (!node) return;
node.dataset.entryId = entryId;
const sentenceEl = node.querySelector('[data-role="heteronym-sentence"]');
if (sentenceEl) {
const tooltip = resolveHeteronymTooltip(entry);
fillHighlightedSentence(sentenceEl, sentence, token, tooltip);
}
const notesEl = node.querySelector('[data-role="heteronym-notes"]');
if (notesEl) {
const suggested = String(entry.default_choice || "").trim();
if (suggested) {
const options = Array.isArray(entry.options) ? entry.options : [];
const match = options.find((opt) => String(opt?.key || "").trim() === suggested);
const label = String(match?.label || "").trim();
notesEl.textContent = label ? `Suggested: ${label}` : "";
notesEl.hidden = !notesEl.textContent;
} else {
notesEl.hidden = true;
}
}
const optionsContainer = node.querySelector('[data-role="heteronym-options"]');
if (optionsContainer) {
optionsContainer.innerHTML = "";
const options = Array.isArray(entry.options) ? entry.options : [];
const selectedKey = String(entry.choice || entry.default_choice || "").trim();
options.slice(0, 2).forEach((opt, index) => {
if (!opt || typeof opt !== "object") return;
const key = String(opt.key || "").trim();
const label = String(opt.label || "Option").trim();
const previewSentence = String(opt.replacement_sentence || sentence).trim();
if (!key) return;
const wrapper = document.createElement("div");
wrapper.className = "entity-inline-override__actions";
const choiceLabel = document.createElement("label");
choiceLabel.className = "toggle-pill";
const input = document.createElement("input");
input.type = "radio";
input.name = `heteronym-${entryId}-choice`;
input.value = key;
if (selectedKey) {
input.checked = selectedKey === key;
} else {
input.checked = index === 0;
}
const span = document.createElement("span");
span.textContent = label;
choiceLabel.appendChild(input);
choiceLabel.appendChild(span);
const previewButton = document.createElement("button");
previewButton.type = "button";
previewButton.className = "button button--ghost button--small";
previewButton.dataset.role = "speaker-preview";
previewButton.dataset.previewText = previewSentence;
previewButton.dataset.voice = baseVoice || "";
previewButton.dataset.language = languageCode;
previewButton.dataset.speed = defaultSpeed;
previewButton.dataset.useGpu = useGpuDefault;
previewButton.textContent = "Preview";
previewButton.disabled = !previewButton.dataset.voice;
previewButton.setAttribute("aria-disabled", previewButton.disabled ? "true" : "false");
wrapper.appendChild(choiceLabel);
wrapper.appendChild(previewButton);
optionsContainer.appendChild(wrapper);
});
}
heteronymOverrideList.appendChild(node);
});
}
function applyEntityPayload(payload, options = {}) {
if (payload && typeof payload === "object") {
if (payload.summary) {
@@ -1890,6 +2055,9 @@ const initPrepare = (root = document) => {
if (Array.isArray(payload.pronunciation_overrides)) {
entityState.pronunciationOverrides = payload.pronunciation_overrides;
}
if (Array.isArray(payload.heteronym_overrides)) {
entityState.heteronymOverrides = payload.heteronym_overrides;
}
if (typeof payload.cache_key === "string") {
entityState.cacheKey = payload.cache_key;
}
@@ -1907,6 +2075,7 @@ const initPrepare = (root = document) => {
}
renderEntitySummary();
renderManualOverrides();
renderHeteronymOverrides();
}
const filterVoicesByGender = (voices, genderHint) => {
@@ -2517,6 +2686,7 @@ const initPrepare = (root = document) => {
activateEntityTab(initialTab?.dataset.panel || "people");
renderEntitySummary();
renderManualOverrides();
renderHeteronymOverrides();
triggerEntitiesRefresh();
}
@@ -526,6 +526,28 @@
</article>
</template>
<p class="manual-overrides__empty" data-role="manual-overrides-empty" hidden>No overrides yet. Use search or add a custom entry to begin.</p>
<section class="manual-overrides__section" data-role="heteronym-overrides">
<header class="manual-overrides__header">
<div class="manual-overrides__copy">
<h2>Heteronyms</h2>
<p class="hint">Review sentences that contain a word with multiple pronunciations. The suggested option is selected by default. Hover the highlighted word for examples and use Preview to audition each pronunciation.</p>
</div>
</header>
<div class="manual-overrides__list" data-role="heteronym-override-list"></div>
<template data-role="heteronym-override-template">
<article class="manual-override" data-entry-id="">
<header class="manual-override__header">
<div>
<h3 class="manual-override__label" data-role="heteronym-sentence"></h3>
<p class="manual-override__notes" data-role="heteronym-notes"></p>
</div>
</header>
<div class="manual-override__body" data-role="heteronym-options"></div>
</article>
</template>
<p class="manual-overrides__empty" data-role="heteronym-overrides-empty" hidden>No heteronyms found in the current selection.</p>
</section>
</div>
</section>
</div>
@@ -567,6 +589,7 @@
<script id="entity-cache-key" type="application/json">{{ pending.entity_cache_key or '' | tojson }}</script>
<script id="manual-overrides-data" type="application/json">{{ pending.manual_overrides or [] | tojson }}</script>
<script id="pronunciation-overrides-data" type="application/json">{{ pending.pronunciation_overrides or [] | tojson }}</script>
<script id="heteronym-overrides-data" type="application/json">{{ pending.heteronym_overrides or [] | tojson }}</script>
<script id="voice-catalog-data" type="application/json">{{ options.voice_catalog | tojson }}</script>
<script id="voice-language-map" type="application/json">{{ options.languages | tojson }}</script>
{% if embed_scripts %}