mirror of
https://github.com/denizsafak/abogen.git
synced 2026-07-18 05:40:26 +02:00
266 lines
8.0 KiB
Python
266 lines
8.0 KiB
Python
from __future__ import annotations
|
|
|
|
import os
|
|
import logging
|
|
from dataclasses import dataclass
|
|
from functools import lru_cache
|
|
from typing import Any, Dict, Optional, Tuple
|
|
|
|
try: # pragma: no cover - optional dependency
|
|
import spacy
|
|
except Exception: # pragma: no cover - spaCy unavailable at runtime
|
|
spacy = None
|
|
|
|
# Lazy spaCy type hints to avoid a hard dependency at import time.
|
|
Language = Any # type: ignore[assignment]
|
|
Token = Any # type: ignore[assignment]
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class ContractionResolution:
|
|
start: int
|
|
end: int
|
|
surface: str
|
|
expansion: str
|
|
category: str
|
|
lemma: str
|
|
|
|
@property
|
|
def span(self) -> Tuple[int, int]:
|
|
return self.start, self.end
|
|
|
|
|
|
_DEFAULT_MODEL = os.environ.get("ABOGEN_SPACY_MODEL", "en_core_web_sm")
|
|
|
|
|
|
@lru_cache(maxsize=1)
|
|
def _load_spacy_model(model: str = _DEFAULT_MODEL) -> Optional[Language]:
|
|
if spacy is None:
|
|
logger.debug("spaCy is not installed; skipping contraction disambiguation")
|
|
return None
|
|
|
|
try:
|
|
nlp = spacy.load(model)
|
|
except Exception as exc: # pragma: no cover - depends on environment
|
|
logger.warning("Failed to load spaCy model '%s': %s", model, exc)
|
|
return None
|
|
return nlp
|
|
|
|
|
|
def resolve_ambiguous_contractions(
|
|
text: str, *, model: Optional[str] = None
|
|
) -> Dict[Tuple[int, int], ContractionResolution]:
|
|
"""Use spaCy to disambiguate ambiguous contractions in *text*.
|
|
|
|
Returns a mapping from (start, end) spans to their resolved expansion.
|
|
Only ambiguous `'s` and `'d` contractions are considered.
|
|
"""
|
|
if not text:
|
|
return {}
|
|
|
|
nlp = _load_spacy_model(model or _DEFAULT_MODEL)
|
|
if nlp is None:
|
|
return {}
|
|
|
|
doc = nlp(text)
|
|
resolutions: Dict[Tuple[int, int], ContractionResolution] = {}
|
|
for token in doc:
|
|
if token.text == "'s":
|
|
resolution = _resolve_apostrophe_s(token)
|
|
elif token.text == "'d":
|
|
resolution = _resolve_apostrophe_d(token)
|
|
else:
|
|
resolution = None
|
|
|
|
if resolution is None:
|
|
continue
|
|
|
|
if resolution.span not in resolutions:
|
|
resolutions[resolution.span] = resolution
|
|
return resolutions
|
|
|
|
|
|
def _resolution(
|
|
prev: Token, token: Token, expansion_word: str, category: str, lemma_hint: str
|
|
) -> Optional[ContractionResolution]:
|
|
if token is None or prev is None:
|
|
return None
|
|
|
|
if prev.idx + len(prev.text) != token.idx:
|
|
# Not a contiguous contraction (whitespace or punctuation in between)
|
|
return None
|
|
|
|
surface_start = prev.idx
|
|
surface_end = token.idx + len(token.text)
|
|
surface_text = token.doc.text[surface_start:surface_end]
|
|
|
|
expansion = _assemble_expansion(prev.text, surface_text, expansion_word)
|
|
return ContractionResolution(
|
|
start=surface_start,
|
|
end=surface_end,
|
|
surface=surface_text,
|
|
expansion=expansion,
|
|
category=category,
|
|
lemma=lemma_hint,
|
|
)
|
|
|
|
|
|
def _assemble_expansion(base_text: str, surface_text: str, expansion_word: str) -> str:
|
|
"""Combine *base_text* with *expansion_word*, preserving coarse casing."""
|
|
if not expansion_word:
|
|
return base_text
|
|
|
|
if surface_text.isupper() and expansion_word.isalpha():
|
|
adjusted = expansion_word.upper()
|
|
elif len(surface_text) > 2 and surface_text[:-2].istitle() and expansion_word:
|
|
# Surface like "It's" -> keep appended word lowercase
|
|
adjusted = expansion_word.lower()
|
|
else:
|
|
adjusted = expansion_word
|
|
|
|
return f"{base_text} {adjusted}".strip()
|
|
|
|
|
|
def _resolve_apostrophe_s(token: Token) -> Optional[ContractionResolution]:
|
|
prev = token.nbor(-1) if token.i > 0 else None
|
|
if prev is None:
|
|
return None
|
|
|
|
# Possessive marker e.g., dog's
|
|
if token.tag_ == "POS" or token.lemma_ == "'s":
|
|
return None
|
|
|
|
prev_lower = prev.lemma_.lower()
|
|
surface = token.doc.text[prev.idx : token.idx + len(token.text)]
|
|
|
|
if prev_lower == "let":
|
|
return _resolution(prev, token, "us", "contraction_let_us", "us")
|
|
|
|
# Special check for 's been -> has been, overriding lemma
|
|
next_content = _next_content_token(token)
|
|
if next_content and next_content.text.lower() == "been":
|
|
return _resolution(prev, token, "has", "contraction_aux_have", "have")
|
|
|
|
lemma = token.lemma_.lower()
|
|
if not lemma:
|
|
lemma = "be" if _favors_be(token) else "have" if _favors_have(token) else "be"
|
|
|
|
if lemma == "be":
|
|
return _resolution(prev, token, "is", "contraction_aux_be", "be")
|
|
if lemma == "have":
|
|
return _resolution(prev, token, "has", "contraction_aux_have", "have")
|
|
|
|
if _favors_have(token):
|
|
return _resolution(prev, token, "has", "contraction_aux_have", "have")
|
|
|
|
if _favors_be(token):
|
|
return _resolution(prev, token, "is", "contraction_aux_be", "be")
|
|
|
|
# Default to copula expansion.
|
|
return _resolution(prev, token, "is", "contraction_aux_be", lemma or "be")
|
|
|
|
|
|
def _resolve_apostrophe_d(token: Token) -> Optional[ContractionResolution]:
|
|
prev = token.nbor(-1) if token.i > 0 else None
|
|
if prev is None:
|
|
return None
|
|
|
|
if token.morph.get("VerbForm") == ["Part"]:
|
|
# spaCy sometimes tags possessives oddly; guard anyway
|
|
return None
|
|
|
|
lemma = token.lemma_.lower()
|
|
tense = set(token.morph.get("Tense"))
|
|
next_content = _next_content_token(token)
|
|
prefers_had = _context_prefers_had(token)
|
|
|
|
if prefers_had:
|
|
return _resolution(prev, token, "had", "contraction_aux_have", "have")
|
|
|
|
if "Past" in tense and lemma in {"have", "had"}:
|
|
return _resolution(prev, token, "had", "contraction_aux_have", "have")
|
|
|
|
if next_content is not None:
|
|
next_tag = next_content.tag_
|
|
next_lemma = next_content.lemma_.lower()
|
|
else:
|
|
next_tag = ""
|
|
next_lemma = ""
|
|
|
|
if next_tag == "VB":
|
|
return _resolution(
|
|
prev, token, "would", "contraction_modal_would", lemma or "will"
|
|
)
|
|
|
|
if token.tag_ == "MD" or lemma in {"will", "would", "shall"}:
|
|
return _resolution(
|
|
prev, token, "would", "contraction_modal_would", lemma or "will"
|
|
)
|
|
|
|
if next_lemma in {"been", "gone", "had", "better"} or next_tag in {"VBN", "VBD"}:
|
|
return _resolution(prev, token, "had", "contraction_aux_have", "have")
|
|
|
|
if lemma in {"have", "had"}:
|
|
return _resolution(prev, token, "had", "contraction_aux_have", lemma)
|
|
|
|
return _resolution(prev, token, "would", "contraction_modal_would", lemma or "will")
|
|
|
|
|
|
def _next_content_token(token: Token) -> Optional[Token]:
|
|
doc = token.doc
|
|
for candidate in doc[token.i + 1 :]:
|
|
if candidate.is_space:
|
|
continue
|
|
if candidate.is_punct and candidate.text not in {"-"}:
|
|
break
|
|
if candidate.text in {"'", ""}:
|
|
continue
|
|
return candidate
|
|
return None
|
|
|
|
|
|
def _favors_have(token: Token) -> bool:
|
|
next_content = _next_content_token(token)
|
|
if next_content is None:
|
|
return False
|
|
if next_content.tag_ in {"VBN"}:
|
|
return True
|
|
if next_content.lemma_.lower() in {"been", "gone", "had"}:
|
|
return True
|
|
return False
|
|
|
|
|
|
def _favors_be(token: Token) -> bool:
|
|
next_content = _next_content_token(token)
|
|
if next_content is None:
|
|
return True
|
|
if next_content.tag_ in {"VBG", "JJ", "RB", "DT", "IN"}:
|
|
return True
|
|
return False
|
|
|
|
|
|
def _context_prefers_had(token: Token) -> bool:
|
|
head = token.head if token.head is not None else None
|
|
if head is not None and head.i > token.i:
|
|
head_tag = head.tag_
|
|
head_lemma = head.lemma_.lower()
|
|
if head_tag in {"VBN", "VBD"} or head_lemma in {"gone", "been", "had"}:
|
|
return True
|
|
if head_lemma == "better":
|
|
return True
|
|
|
|
next_content = _next_content_token(token)
|
|
if next_content is None:
|
|
return False
|
|
next_tag = next_content.tag_
|
|
next_lemma = next_content.lemma_.lower()
|
|
if next_tag in {"VBN", "VBD"}:
|
|
return True
|
|
if next_lemma in {"been", "gone", "had"}:
|
|
return True
|
|
if next_lemma == "better":
|
|
return True
|
|
return False
|