Files
abogen/abogen/heteronym_overrides.py
T
2026-01-09 01:36:14 +03:00

305 lines
9.3 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
from __future__ import annotations
import hashlib
import re
from dataclasses import dataclass
from typing import Any, Dict, Iterable, List, Mapping, Optional, Sequence, Tuple
try: # pragma: no cover - optional dependency
import spacy # type: ignore
except Exception: # pragma: no cover - spaCy may be unavailable in minimal environments
spacy = None
@dataclass(frozen=True)
class HeteronymVariant:
key: str
label: str
replacement_token: str
example_sentence: str
@dataclass(frozen=True)
class HeteronymSpec:
token: str
variants: Tuple[HeteronymVariant, HeteronymVariant]
def default_choice_for_token(self, spacy_token: Any) -> str:
"""Return the most likely variant key for this token."""
pos = (getattr(spacy_token, "pos_", "") or "").upper()
tag = (getattr(spacy_token, "tag_", "") or "").upper()
token_lower = self.token.casefold()
if token_lower == "wind":
# VERB => /waɪnd/, NOUN => /wɪnd/
return "verb" if pos == "VERB" else "noun"
if token_lower == "read":
# VBD/VBN => /rɛd/
return "past" if tag in {"VBD", "VBN"} else "present"
if token_lower == "tear":
return "verb" if pos == "VERB" else "noun"
if token_lower == "close":
return "verb" if pos == "VERB" else "adj"
if token_lower == "lead":
# Default to verb unless POS suggests noun.
return "metal" if pos == "NOUN" else "verb"
return self.variants[0].key
# Minimal, high-confidence starter set.
# NOTE: These replacements intentionally prioritize speech output.
# Some replacements may not be appropriate for subtitles/text exports.
_HETERONYM_SPECS: Dict[str, HeteronymSpec] = {
"wind": HeteronymSpec(
token="wind",
variants=(
HeteronymVariant(
key="noun",
label="Noun (the wind)",
replacement_token="wind",
example_sentence="Listen to the wind.",
),
HeteronymVariant(
key="verb",
label="Verb (to wind)",
replacement_token="wynd",
example_sentence="I need to wind the watch.",
),
),
),
"read": HeteronymSpec(
token="read",
variants=(
HeteronymVariant(
key="present",
label="Present (I read every day)",
replacement_token="read",
example_sentence="I read every day.",
),
HeteronymVariant(
key="past",
label="Past (I read it yesterday)",
replacement_token="red",
example_sentence="I read it yesterday.",
),
),
),
"tear": HeteronymSpec(
token="tear",
variants=(
HeteronymVariant(
key="noun",
label="Noun (a tear /crying/)",
replacement_token="tier",
example_sentence="A tear rolled down her cheek.",
),
HeteronymVariant(
key="verb",
label="Verb (to tear /rip/)",
replacement_token="tear",
example_sentence="Please don't tear the page.",
),
),
),
"close": HeteronymSpec(
token="close",
variants=(
HeteronymVariant(
key="adj",
label="Adjective (close /near/)",
replacement_token="close",
example_sentence="We are close to the station.",
),
HeteronymVariant(
key="verb",
label="Verb (close /klohz/)",
replacement_token="cloze",
example_sentence="Please close the door.",
),
),
),
"lead": HeteronymSpec(
token="lead",
variants=(
HeteronymVariant(
key="verb",
label="Verb (to lead)",
replacement_token="lead",
example_sentence="They will lead the way.",
),
HeteronymVariant(
key="metal",
label="Noun (lead /metal/)",
replacement_token="led",
example_sentence="The pipe was made of lead.",
),
),
),
}
def _hash_id(*parts: str) -> str:
digest = hashlib.sha1("\n".join(parts).encode("utf-8")).hexdigest()
return digest[:12]
_WORD_BOUNDARY_CACHE: Dict[str, re.Pattern[str]] = {}
def _word_boundary_pattern(token: str) -> re.Pattern[str]:
key = token.casefold()
cached = _WORD_BOUNDARY_CACHE.get(key)
if cached is not None:
return cached
escaped = re.escape(token)
pattern = re.compile(
rf"(?i)(?<!\w){escaped}(?P<possessive>'s|\u2019s|\u2019)?(?!\w)"
)
_WORD_BOUNDARY_CACHE[key] = pattern
return pattern
def _preserve_case(replacement: str, original: str) -> str:
if not replacement:
return replacement
if original.isupper():
return replacement.upper()
if original[:1].isupper():
return replacement[:1].upper() + replacement[1:]
return replacement
def _build_replacement_sentence(
sentence: str, token: str, replacement_token: str
) -> str:
pattern = _word_boundary_pattern(token)
def _repl(match: re.Match[str]) -> str:
matched = match.group(0) or ""
suffix = match.group("possessive") or ""
base = matched[: len(matched) - len(suffix)] if suffix else matched
return _preserve_case(replacement_token, base) + suffix
return pattern.sub(_repl, sentence)
def _load_spacy(language: str) -> Any:
if spacy is None:
return None
# English only for now.
# Use installed small model; keep it simple.
lang = (language or "en").lower()
if lang.startswith("en"):
try:
return spacy.load("en_core_web_sm")
except Exception:
return spacy.blank("en")
return spacy.blank("xx")
def extract_heteronym_overrides(
chapters: Sequence[Mapping[str, Any]],
*,
language: str,
existing: Optional[Iterable[Mapping[str, Any]]] = None,
) -> List[Dict[str, Any]]:
"""Extract distinct heteronym-containing sentences from chapters.
Returns entries shaped for persistence + UI.
Each entry contains:
- id
- token
- sentence
- options: [{key,label,replacement_token,replacement_sentence,example_sentence}]
- default_choice
- choice
"""
lang = (language or "en").lower()
if not lang.startswith("en"):
return []
if spacy is None:
return []
nlp = _load_spacy(lang)
if nlp is None:
return []
previous_choices: Dict[str, str] = {}
if existing:
for item in existing:
if not isinstance(item, Mapping):
continue
entry_id = str(item.get("id") or "").strip()
choice = str(item.get("choice") or "").strip()
if entry_id and choice:
previous_choices[entry_id] = choice
results: List[Dict[str, Any]] = []
seen: set[tuple[str, str]] = set()
for chapter in chapters:
if not isinstance(chapter, Mapping):
continue
text = str(chapter.get("text") or "")
if not text.strip():
continue
doc = nlp(text)
for sent in getattr(doc, "sents", []):
sentence = str(getattr(sent, "text", "") or "").strip()
if not sentence:
continue
for token in sent:
token_text = str(getattr(token, "text", "") or "")
if not token_text:
continue
token_key = token_text.casefold()
spec = _HETERONYM_SPECS.get(token_key)
if not spec:
continue
dedupe_key = (token_key, sentence)
if dedupe_key in seen:
continue
seen.add(dedupe_key)
entry_id = _hash_id(token_key, sentence)
default_choice = spec.default_choice_for_token(token)
choice = previous_choices.get(entry_id, default_choice)
options: List[Dict[str, Any]] = []
for variant in spec.variants:
replacement_sentence = _build_replacement_sentence(
sentence,
token=spec.token,
replacement_token=variant.replacement_token,
)
options.append(
{
"key": variant.key,
"label": variant.label,
"replacement_token": variant.replacement_token,
"replacement_sentence": replacement_sentence,
"example_sentence": variant.example_sentence,
}
)
results.append(
{
"id": entry_id,
"token": token_text,
"token_lower": token_key,
"sentence": sentence,
"options": options,
"default_choice": default_choice,
"choice": choice,
}
)
return results