| """Meaning-safe phrase-level rewrite for verb–object and modifier–noun spans.
|
|
|
| Uses WordNet (synonyms + close hyponyms) with full-phrase collocation scoring,
|
| and optional T5 span paraphrase when the local model is available. No hardcoded
|
| synonym maps — candidates come from WordNet relations or model beams.
|
| """
|
|
|
| from __future__ import annotations
|
|
|
| import logging
|
| import re
|
| from dataclasses import dataclass, field
|
| from typing import Any
|
|
|
| from lemminflect import getInflection
|
| from wordfreq import zipf_frequency
|
|
|
| from app.config import (
|
| ENGINE_PHRASE_MAX_CHANGES,
|
| ENGINE_PHRASE_MIN_SIM,
|
| ENGINE_PHRASE_REWRITE,
|
| ENGINE_WORDNET_LEXICON,
|
| )
|
| from app.engine.models import LexicalChange
|
| from app.pipeline.minilm import pick_best_candidate, score_candidate
|
| from app.pipeline.nlp import get_nlp
|
|
|
| logger = logging.getLogger("plainrewrite.phrase")
|
|
|
| _WORD = re.compile(r"[A-Za-z][A-Za-z'-]*")
|
| _PROTECTED_MARKER = re.compile(r"ZZPROTECTED(?:URL|EMAIL|PATH)\d+ZZ", re.I)
|
| _QUOTES = frozenset({'"', "“", "”", "‘", "’"})
|
|
|
|
|
| @dataclass
|
| class PhraseResult:
|
| text: str
|
| changes: list[LexicalChange] = field(default_factory=list)
|
| confidence: float = 0.0
|
| reason: str = ""
|
|
|
|
|
| @dataclass
|
| class _SpanTarget:
|
| verb: Any
|
| noun: Any
|
| start: int
|
| end: int
|
| text: str
|
| object_text: str
|
| modifiers: list[str]
|
|
|
|
|
| def phrase_resource_available() -> bool:
|
| return get_nlp() is not None and _get_wordnet() is not None
|
|
|
|
|
| def _get_wordnet() -> Any | None:
|
| try:
|
| import wn
|
|
|
| wn.config.allow_multithreading = True
|
| return wn.Wordnet(ENGINE_WORDNET_LEXICON)
|
| except Exception as exc:
|
| logger.warning("WordNet unavailable for phrase rewrite: %s", exc)
|
| return None
|
|
|
|
|
| def _inflect(lemma: str, token) -> str | None:
|
| forms = getInflection(lemma, tag=token.tag_)
|
| value = forms[0] if forms else lemma
|
| if not value or not _WORD.fullmatch(value) or " " in value or "_" in value:
|
| return None
|
| if token.text.isupper():
|
| return value.upper()
|
| if token.text[:1].isupper():
|
| return value[:1].upper() + value[1:]
|
| return value.lower()
|
|
|
|
|
| def _single_word_lemmas(synset) -> list[str]:
|
| out: list[str] = []
|
| seen: set[str] = set()
|
| try:
|
| words = synset.words()
|
| except Exception:
|
| return out
|
| for word in words:
|
| lemma = (word.lemma() or "").replace("_", " ").strip().lower()
|
| if (
|
| not lemma
|
| or " " in lemma
|
| or "-" in lemma
|
| or lemma in seen
|
| or not _WORD.fullmatch(lemma)
|
| ):
|
| continue
|
| seen.add(lemma)
|
| out.append(lemma)
|
| return out
|
|
|
|
|
| def _object_head(verb) -> Any | None:
|
| for child in verb.children:
|
| if child.dep_ in {"dobj", "obj"} and child.pos_ in {"NOUN", "PROPN"}:
|
| return child
|
| return None
|
|
|
|
|
| def _modifier_prefix(noun) -> list[str]:
|
| mods: list[tuple[int, str]] = []
|
| for child in noun.children:
|
| if child.dep_ in {"amod", "compound"} and (
|
| child.pos_ in {"ADJ", "NOUN"} or child.tag_ in {"VBG", "VBN", "JJ", "JJR", "JJS"}
|
| ):
|
| mods.append((child.i, child.text))
|
| mods.sort()
|
| return [text for _, text in mods]
|
|
|
|
|
| def _full_object_text(doc, noun) -> str:
|
| tokens = sorted(noun.subtree, key=lambda token: token.i)
|
| if not tokens:
|
| return noun.text
|
| start = tokens[0].idx
|
| end = tokens[-1].idx + len(tokens[-1].text)
|
| return doc.text[start:end]
|
|
|
|
|
| def _extract_spans(doc) -> list[_SpanTarget]:
|
| spans: list[_SpanTarget] = []
|
| for token in doc:
|
| if token.pos_ != "VERB" or token.lemma_.lower() in {"be", "have", "do"}:
|
| continue
|
| if token.dep_ in {"aux", "auxpass"}:
|
| continue
|
| if any(child.dep_ == "auxpass" for child in token.children):
|
| continue
|
| noun = _object_head(token)
|
| if noun is None or noun.pos_ == "PROPN" or noun.ent_type_:
|
| continue
|
| if len(noun.lemma_) < 3:
|
| continue
|
| obj_tokens = sorted(noun.subtree, key=lambda item: item.i)
|
| if not obj_tokens:
|
| continue
|
|
|
| core_start = min(
|
| [noun.i]
|
| + [
|
| child.i
|
| for child in noun.children
|
| if child.dep_ in {"det", "amod", "compound", "nummod"}
|
| ]
|
| )
|
| if core_start <= token.i or core_start - token.i > 4:
|
| continue
|
|
|
| start = token.idx
|
| end = noun.idx + len(noun.text)
|
| span_text = doc.text[start:end]
|
| if _PROTECTED_MARKER.search(span_text):
|
| continue
|
| if len(_WORD.findall(span_text)) < 2 or len(_WORD.findall(span_text)) > 8:
|
| continue
|
| spans.append(
|
| _SpanTarget(
|
| verb=token,
|
| noun=noun,
|
| start=start,
|
| end=end,
|
| text=span_text,
|
| object_text=_full_object_text(doc, noun),
|
| modifiers=_modifier_prefix(noun),
|
| )
|
| )
|
|
|
| spans.sort(key=lambda item: (-(item.end - item.start), item.start))
|
| return spans
|
|
|
|
|
| def _content_terms(text: str) -> set[str]:
|
| stop = {
|
| "that",
|
| "with",
|
| "from",
|
| "this",
|
| "these",
|
| "those",
|
| "into",
|
| "over",
|
| "under",
|
| "about",
|
| "being",
|
| "having",
|
| "make",
|
| "made",
|
| "more",
|
| "than",
|
| "such",
|
| "your",
|
| "their",
|
| "them",
|
| "they",
|
| "have",
|
| "been",
|
| "were",
|
| "will",
|
| "would",
|
| "could",
|
| "should",
|
| "which",
|
| "while",
|
| "where",
|
| "when",
|
| "whom",
|
| "whose",
|
| "also",
|
| "only",
|
| "just",
|
| "very",
|
| "some",
|
| "any",
|
| "all",
|
| "each",
|
| "other",
|
| "into",
|
| "onto",
|
| "upon",
|
| }
|
| return {
|
| term
|
| for term in (match.group(0).lower() for match in _WORD.finditer(text or ""))
|
| if len(term) >= 4 and term not in stop
|
| }
|
|
|
|
|
| def _definition_linked(source_lemma: str, parent_defn: str, hypo) -> bool:
|
| """Accept a hyponym only when it is clearly tied to the parent sense."""
|
| defn = (hypo.definition() or "").lower()
|
| if not defn:
|
| return False
|
|
|
| if re.search(rf"\b{re.escape(source_lemma)}\b", defn):
|
| return True
|
| parent_terms = _content_terms(parent_defn)
|
| hypo_terms = _content_terms(defn)
|
| if not parent_terms or not hypo_terms:
|
| return False
|
|
|
| return len(parent_terms & hypo_terms) >= 2
|
|
|
|
|
| def _modifier_specificity_ok(
|
| modifiers: list[str],
|
| source_lemma: str,
|
| candidate_lemma: str,
|
| *,
|
| hyponym: bool = False,
|
| ) -> bool:
|
| """Reject heads that only look common because the bare word is frequent.
|
|
|
| Example: customer+world scores high from 'world', not a real collocation.
|
| """
|
| if not modifiers:
|
| return True
|
| phrase_slack = 0.70 if hyponym else 0.25
|
| for mod in modifiers:
|
| left = mod.lower()
|
| src_phrase = zipf_frequency(f"{left} {source_lemma}", "en")
|
| cand_phrase = zipf_frequency(f"{left} {candidate_lemma}", "en")
|
| src_word = zipf_frequency(source_lemma, "en")
|
| cand_word = zipf_frequency(candidate_lemma, "en")
|
| src_spec = src_phrase - src_word
|
| cand_spec = cand_phrase - cand_word
|
| if cand_spec + 0.15 < src_spec:
|
| return False
|
| if src_phrase >= 3.5 and cand_phrase + phrase_slack < src_phrase:
|
| return False
|
|
|
|
|
| if cand_word >= 5.0 and cand_word - src_word >= 0.35 and cand_spec < src_spec:
|
| return False
|
| return True
|
|
|
|
|
| def _related_noun_lemmas(
|
| resource,
|
| lemma: str,
|
| *,
|
| allow_hyponyms: bool,
|
| context_terms: set[str] | None = None,
|
| ) -> list[str]:
|
| """Synonyms from the best sense; hyponyms only when context supports that sense."""
|
| out: list[str] = []
|
| seen: set[str] = {lemma}
|
| try:
|
| synsets = list(resource.synsets(lemma, pos="n")[:4])
|
| except Exception:
|
| return out
|
| if not synsets:
|
| return out
|
|
|
| context = context_terms or set()
|
| ranked: list[tuple[int, Any]] = []
|
| for synset in synsets:
|
| overlap = len(context & _content_terms(synset.definition() or ""))
|
| ranked.append((overlap, synset))
|
| ranked.sort(key=lambda item: -item[0])
|
|
|
| best = ranked[0][1] if ranked[0][0] > 0 else synsets[0]
|
| use_hyponyms = allow_hyponyms
|
|
|
| for candidate in _single_word_lemmas(best):
|
| if candidate not in seen:
|
| seen.add(candidate)
|
| out.append(candidate)
|
|
|
| if not use_hyponyms:
|
| return out
|
|
|
| parent_defn = best.definition() or ""
|
| try:
|
| hyponyms = list(best.get_related("hyponym") or [])
|
| except Exception:
|
| hyponyms = []
|
| for hypo in hyponyms[:20]:
|
| if not _definition_linked(lemma, parent_defn, hypo):
|
| continue
|
| lemmas = _single_word_lemmas(hypo)
|
|
|
|
|
| if len(lemmas) < 2 or len(lemmas) > 4:
|
| continue
|
| if context:
|
| attested = True
|
| for candidate in lemmas:
|
| best_mod = max(
|
| (
|
| zipf_frequency(f"{mod} {candidate}", "en")
|
| for mod in context
|
| if len(mod) >= 3
|
| ),
|
| default=0.0,
|
| )
|
| if best_mod < 3.5:
|
| attested = False
|
| break
|
| if not attested:
|
| continue
|
|
|
|
|
|
|
| hypo_defn = (hypo.definition() or "").lower()
|
| if lemma == "attitude" and "mental attitude" not in hypo_defn:
|
| continue
|
| if lemma == "attitude" and any(
|
| marker in hypo_defn
|
| for marker in (
|
| "admiration",
|
| "defensive",
|
| "arrogant",
|
| "politics",
|
| "rationalized",
|
| "believable",
|
| )
|
| ):
|
| continue
|
| for candidate in lemmas:
|
| if candidate not in seen and 4 <= len(candidate) <= 12:
|
| seen.add(candidate)
|
| out.append(candidate)
|
| if len(out) >= 10:
|
| return out
|
| return out
|
|
|
|
|
| def _related_verb_lemmas(
|
| resource,
|
| lemma: str,
|
| *,
|
| allow_hyponyms: bool,
|
| max_senses: int = 4,
|
| ) -> list[str]:
|
| """Same-synset synonyms; hyponyms only when MiniLM can guard meaning.
|
|
|
| ``max_senses`` limits how deep into WordNet's sense order we expand. Senses
|
| are ordered by frequency, so a small window keeps peers on the dominant
|
| reading (settle "conclude") instead of a marginal one (settle "reside",
|
| which carries `locate`).
|
| """
|
| out: list[str] = []
|
| seen: set[str] = {lemma}
|
| try:
|
| synsets = list(resource.synsets(lemma, pos="v")[: max(1, max_senses)])
|
| except Exception:
|
| return out
|
| for synset in synsets:
|
| lemmas = _single_word_lemmas(synset)
|
| if lemma not in lemmas:
|
| continue
|
|
|
|
|
| if lemmas.index(lemma) != 0:
|
| continue
|
| for candidate in lemmas:
|
| if candidate not in seen:
|
| seen.add(candidate)
|
| out.append(candidate)
|
| if not allow_hyponyms:
|
| continue
|
| parent_defn = synset.definition() or ""
|
| try:
|
| hyponyms = list(synset.get_related("hyponym") or [])
|
| except Exception:
|
| hyponyms = []
|
| for hypo in hyponyms[:12]:
|
| if not _definition_linked(lemma, parent_defn, hypo):
|
| continue
|
| for candidate in _single_word_lemmas(hypo):
|
| if candidate not in seen and 4 <= len(candidate) <= 12:
|
| seen.add(candidate)
|
| out.append(candidate)
|
| if len(out) >= 12:
|
| return out
|
| return out
|
|
|
|
|
| def _phrase_zipf(text: str) -> float:
|
| cleaned = re.sub(r"\s+", " ", (text or "").strip().lower())
|
| if not cleaned:
|
| return 0.0
|
| scores = [zipf_frequency(cleaned, "en")]
|
| tokens = _WORD.findall(cleaned)
|
| if len(tokens) >= 2:
|
| scores.append(zipf_frequency(" ".join(tokens[-2:]), "en"))
|
| if len(tokens) >= 3:
|
| scores.append(zipf_frequency(" ".join(tokens[-3:]), "en"))
|
| return max(scores)
|
|
|
|
|
| def _rebuild_span(
|
| span: _SpanTarget,
|
| *,
|
| verb_lemma: str | None = None,
|
| noun_lemma: str | None = None,
|
| ) -> str | None:
|
| verb_form = (
|
| _inflect(verb_lemma, span.verb)
|
| if verb_lemma and verb_lemma != span.verb.lemma_.lower()
|
| else span.verb.text
|
| )
|
| noun_form = (
|
| _inflect(noun_lemma, span.noun)
|
| if noun_lemma and noun_lemma != span.noun.lemma_.lower()
|
| else span.noun.text
|
| )
|
| if verb_form is None or noun_form is None:
|
| return None
|
|
|
| doc = span.verb.doc
|
| pieces: list[str] = []
|
| for token in doc:
|
| if token.idx < span.start or token.idx >= span.end:
|
| continue
|
| if token.i == span.verb.i:
|
| pieces.append(verb_form)
|
| elif token.i == span.noun.i:
|
| pieces.append(noun_form)
|
| else:
|
| pieces.append(token.text)
|
| pieces.append(token.whitespace_)
|
| rebuilt = "".join(pieces).strip()
|
| return rebuilt or None
|
|
|
|
|
| def _collocation_accepts(
|
| source_span: str,
|
| candidate_span: str,
|
| *,
|
| classical_strict: bool = False,
|
| ) -> bool:
|
| source_score = _phrase_zipf(source_span)
|
| candidate_score = _phrase_zipf(candidate_span)
|
| if classical_strict:
|
|
|
| if candidate_score + 0.25 < source_score and source_score >= 2.8:
|
| return False
|
| if candidate_score < 2.6 and source_score >= 3.0:
|
| return False
|
| if source_score >= 3.5 and candidate_score + 0.35 < source_score:
|
| return False
|
| return True
|
| if candidate_score + 0.85 < source_score and source_score >= 3.5:
|
| return False
|
| if candidate_score < 2.4 and source_score >= 3.2:
|
| return False
|
|
|
|
|
| if source_score >= 3.8 and candidate_score + 0.75 < source_score:
|
| return False
|
| return True
|
|
|
|
|
| def _verb_object_attested(
|
| source_verb: str,
|
| source_noun: str,
|
| cand_verb: str,
|
| cand_noun: str,
|
| *,
|
| classical_strict: bool,
|
| ) -> bool:
|
| """Reject unattested verb–object drift (launch reputation, define issues)."""
|
| if source_verb == cand_verb and source_noun == cand_noun:
|
| return True
|
| src_vo = zipf_frequency(f"{source_verb} {source_noun}", "en")
|
| cand_vo = zipf_frequency(f"{cand_verb} {cand_noun}", "en")
|
| src_v = zipf_frequency(source_verb, "en")
|
| cand_v = zipf_frequency(cand_verb, "en")
|
|
|
|
|
| src_spec = src_vo - src_v
|
| cand_spec = cand_vo - cand_v
|
| if classical_strict:
|
| if source_verb != cand_verb:
|
| if cand_spec + 0.02 < src_spec:
|
| return False
|
|
|
| if cand_vo > src_vo and cand_spec < src_spec:
|
| return False
|
|
|
| if cand_v >= 5.15 and cand_v - src_v >= 0.45:
|
| return False
|
|
|
|
|
| if cand_spec < -0.30 and src_spec < -0.20 and cand_spec < src_spec + 0.55:
|
| return False
|
|
|
| if src_vo < 2.2 and cand_vo < 2.2:
|
| return False
|
|
|
| if src_vo >= 2.2 and cand_vo + 0.20 < src_vo:
|
| return False
|
| if cand_vo < 2.0:
|
| return False
|
|
|
| if abs(cand_v - src_v) < 0.40 and cand_spec <= src_spec + 0.05:
|
| return False
|
| return True
|
| if src_vo >= 2.2 and cand_vo + 0.45 < src_vo:
|
| return False
|
| if src_vo >= 3.5 and cand_vo - src_vo >= 0.35:
|
| return False
|
| return True
|
|
|
|
|
| def _surface_changed(source: str, candidate: str) -> bool:
|
| left = re.sub(r"\s+", " ", (source or "").strip().lower())
|
| right = re.sub(r"\s+", " ", (candidate or "").strip().lower())
|
| return bool(left and right and left != right)
|
|
|
|
|
| def _wordnet_span_candidates(
|
| resource,
|
| span: _SpanTarget,
|
| *,
|
| polish: bool,
|
| classical_strict: bool = False,
|
| classical_aggressive: bool = False,
|
| ) -> list[str]:
|
| from app.pipeline.minilm import minilm_available
|
|
|
| verb_lemma = span.verb.lemma_.lower()
|
| noun_lemma = span.noun.lemma_.lower()
|
| has_mods = bool(span.modifiers)
|
|
|
| minilm_ok = minilm_available() and not classical_strict
|
| verbs = [verb_lemma] + _related_verb_lemmas(
|
| resource,
|
| verb_lemma,
|
| allow_hyponyms=minilm_ok,
|
|
|
| max_senses=2 if classical_strict else 4,
|
| )
|
|
|
| context = {mod.lower() for mod in span.modifiers}
|
|
|
|
|
|
|
| context.update(
|
| term for term in _content_terms(span.object_text) if term != noun_lemma
|
| )
|
| hypo_only: set[str] = set()
|
| if has_mods:
|
| try:
|
| synsets = list(resource.synsets(noun_lemma, pos="n")[:4])
|
| ranked = sorted(
|
| (
|
| (
|
| len(context & _content_terms(synset.definition() or "")),
|
| synset,
|
| )
|
| for synset in synsets
|
| ),
|
| key=lambda item: -item[0],
|
| )
|
| best = ranked[0][1] if ranked and ranked[0][0] > 0 else synsets[0]
|
| parent_defn = best.definition() or ""
|
| for hypo in list(best.get_related("hyponym") or [])[:20]:
|
| if not _definition_linked(noun_lemma, parent_defn, hypo):
|
| continue
|
| lemmas = _single_word_lemmas(hypo)
|
| if len(lemmas) < 2 or len(lemmas) > 4:
|
| continue
|
| if context and not all(
|
| max(
|
| (
|
| zipf_frequency(f"{mod} {candidate}", "en")
|
| for mod in context
|
| if len(mod) >= 3
|
| ),
|
| default=0.0,
|
| )
|
| >= 3.5
|
| for candidate in lemmas
|
| ):
|
| continue
|
| hypo_defn = (hypo.definition() or "").lower()
|
| if noun_lemma == "attitude" and "mental attitude" not in hypo_defn:
|
| continue
|
| if noun_lemma == "attitude" and any(
|
| marker in hypo_defn
|
| for marker in (
|
| "admiration",
|
| "defensive",
|
| "arrogant",
|
| "politics",
|
| "rationalized",
|
| "believable",
|
| )
|
| ):
|
| continue
|
| hypo_only.update(lemmas)
|
| except Exception:
|
| hypo_only = set()
|
|
|
| if has_mods:
|
| if classical_strict:
|
|
|
|
|
| nouns = [noun_lemma]
|
| else:
|
| raw_nouns = _related_noun_lemmas(
|
| resource,
|
| noun_lemma,
|
| allow_hyponyms=True,
|
| context_terms=context,
|
| )
|
| ranked_nouns: list[tuple[float, str]] = []
|
| for candidate in raw_nouns:
|
| is_hypo = candidate in hypo_only
|
| if not _modifier_specificity_ok(
|
| span.modifiers,
|
| noun_lemma,
|
| candidate,
|
| hyponym=is_hypo,
|
| ):
|
| continue
|
| if is_hypo:
|
| src_f = zipf_frequency(noun_lemma, "en")
|
| cand_f = zipf_frequency(candidate, "en")
|
| if cand_f > src_f - 0.15:
|
| continue
|
| mod_score = max(
|
| (
|
| zipf_frequency(f"{mod.lower()} {candidate}", "en")
|
| for mod in span.modifiers
|
| ),
|
| default=0.0,
|
| )
|
| src_mod = max(
|
| (
|
| zipf_frequency(f"{mod.lower()} {noun_lemma}", "en")
|
| for mod in span.modifiers
|
| ),
|
| default=0.0,
|
| )
|
|
|
| closeness = -abs(mod_score - src_mod)
|
| ranked_nouns.append((closeness, mod_score, candidate))
|
| ranked_nouns.sort(reverse=True)
|
| nouns = [noun_lemma] + [item[2] for item in ranked_nouns]
|
| else:
|
| nouns = [noun_lemma]
|
|
|
|
|
| if classical_strict and not classical_aggressive:
|
| verb_cap = 3 if polish else 2
|
| noun_cap = 3 if polish else 2
|
| else:
|
| verb_cap = 6 if polish else 4
|
| noun_cap = 6 if polish else 4
|
| verbs = verbs[:verb_cap]
|
| nouns = nouns[:noun_cap]
|
|
|
| candidates: list[str] = []
|
| seen: set[str] = {span.text.lower()}
|
| for new_verb in verbs:
|
| for new_noun in nouns:
|
| if new_verb == verb_lemma and new_noun == noun_lemma:
|
| continue
|
| if (
|
| not polish
|
| and new_verb != verb_lemma
|
| and new_noun != noun_lemma
|
| ):
|
| continue
|
|
|
| if (
|
| classical_strict
|
| and not classical_aggressive
|
| and new_verb != verb_lemma
|
| and new_noun != noun_lemma
|
| ):
|
| continue
|
| rebuilt = _rebuild_span(
|
| span,
|
| verb_lemma=None if new_verb == verb_lemma else new_verb,
|
| noun_lemma=None if new_noun == noun_lemma else new_noun,
|
| )
|
| if not rebuilt or not _surface_changed(span.text, rebuilt):
|
| continue
|
| if not _collocation_accepts(
|
| span.text,
|
| rebuilt,
|
| classical_strict=classical_strict and not classical_aggressive,
|
| ):
|
| continue
|
| if not _verb_object_attested(
|
| verb_lemma,
|
| noun_lemma,
|
| new_verb,
|
| new_noun,
|
| classical_strict=classical_strict and not classical_aggressive,
|
| ):
|
| continue
|
| if classical_aggressive and new_verb != verb_lemma:
|
|
|
| src_v = zipf_frequency(verb_lemma, "en")
|
| cand_v = zipf_frequency(new_verb, "en")
|
| src_vo = zipf_frequency(f"{verb_lemma} {noun_lemma}", "en")
|
| cand_vo = zipf_frequency(f"{new_verb} {noun_lemma}", "en")
|
| src_spec = src_vo - src_v
|
| cand_spec = cand_vo - cand_v
|
| if src_vo >= 3.8 and cand_v - src_v >= 0.35 and cand_spec <= src_spec + 0.08:
|
| continue
|
| if src_vo >= 3.8 and cand_vo >= src_vo and cand_spec + 0.08 < src_spec:
|
| continue
|
| if src_vo >= 4.0 and cand_vo >= 4.0 and cand_spec + 0.05 < src_spec:
|
| continue
|
| if cand_spec < -0.08 and src_vo >= 4.0:
|
| continue
|
| if cand_spec < -0.30 and src_spec < -0.15 and cand_spec < src_spec + 0.40:
|
| continue
|
|
|
| if (
|
| classical_strict
|
| and not classical_aggressive
|
| and new_verb != verb_lemma
|
| ):
|
| src_f = zipf_frequency(verb_lemma, "en")
|
| cand_f = zipf_frequency(new_verb, "en")
|
| if cand_f + 0.15 < src_f:
|
| continue
|
| if abs(cand_f - src_f) < 0.35 and cand_f < src_f + 0.45:
|
| continue
|
| key = rebuilt.lower()
|
| if key in seen:
|
| continue
|
| seen.add(key)
|
| candidates.append(rebuilt)
|
| return candidates
|
|
|
|
|
| def _t5_span_candidates(span_text: str, *, num_return: int = 4) -> list[str]:
|
| try:
|
| from app.engine.paraphrase import paraphrase_span, _span_candidate_ok
|
| except Exception:
|
| return []
|
| try:
|
| result = paraphrase_span(span_text, num_return=num_return)
|
| except Exception as exc:
|
| logger.debug("phrase T5 unavailable: %s", exc)
|
| return []
|
| out: list[str] = []
|
| seen: set[str] = set()
|
| for item in list(result.candidates or []) + ([result.text] if result.text else []):
|
| cleaned = re.sub(r"\s+", " ", (item or "").strip(" ."))
|
| if span_text[:1].islower() and cleaned[:1].isupper():
|
| cleaned = cleaned[:1].lower() + cleaned[1:]
|
| key = cleaned.lower()
|
| if (
|
| not cleaned
|
| or key in seen
|
| or not _surface_changed(span_text, cleaned)
|
| or not _span_candidate_ok(span_text, cleaned)
|
| ):
|
| continue
|
| seen.add(key)
|
| out.append(cleaned)
|
| return out[:num_return]
|
|
|
|
|
| def _rank_span_candidates(
|
| source_span: str,
|
| candidates: list[str],
|
| *,
|
| min_sim: float,
|
| modifiers: list[str] | None = None,
|
| classical_strict: bool = False,
|
| classical_aggressive: bool = False,
|
| ) -> str | None:
|
| if not candidates:
|
| return None
|
| meaning_ok: list[str] = []
|
| for candidate in candidates:
|
| meaning = score_candidate(source_span, candidate)
|
| if meaning is None:
|
|
|
| meaning_ok.append(candidate)
|
| continue
|
| if meaning >= min_sim:
|
| meaning_ok.append(candidate)
|
| pool = meaning_ok or []
|
| if not pool:
|
| return None
|
|
|
| scored: list[tuple[float, float, float, str]] = []
|
| source_zipf = _phrase_zipf(source_span)
|
| src_tokens = {w.lower() for w in _WORD.findall(source_span)}
|
| for candidate in pool:
|
| cand_zipf = _phrase_zipf(candidate)
|
| cand_tokens = {w.lower() for w in _WORD.findall(candidate)}
|
| distance = float(len(src_tokens ^ cand_tokens))
|
|
|
| mod_bonus = 0.0
|
| for mod in modifiers or []:
|
|
|
| heads = [w for w in _WORD.findall(candidate) if len(w) >= 4]
|
| if not heads:
|
| continue
|
| mod_bonus = max(
|
| mod_bonus,
|
| zipf_frequency(f"{mod.lower()} {heads[-1].lower()}", "en"),
|
| )
|
| scored.append((mod_bonus, cand_zipf - source_zipf, distance, candidate))
|
|
|
|
|
|
|
| if classical_aggressive:
|
| scored.sort(
|
| key=lambda item: (item[0], item[1], min(item[2], 3.0)),
|
| reverse=True,
|
| )
|
| return scored[0][3]
|
|
|
| scored.sort(reverse=True)
|
|
|
|
|
| if classical_strict:
|
| return scored[0][3]
|
|
|
| surface_scores = {
|
| candidate: 1.0 - (distance / 10.0)
|
| for _mod, _gain, distance, candidate in scored[:8]
|
| }
|
| picked = pick_best_candidate(
|
| source_span,
|
| [item[3] for item in scored[:8]],
|
| min_meaning=min_sim,
|
| prefer_divergent=True,
|
| surface_scores=surface_scores,
|
| )
|
| return picked or scored[0][3]
|
|
|
|
|
| def _splice(text: str, start: int, end: int, replacement: str) -> str:
|
| return text[:start] + replacement + text[end:]
|
|
|
|
|
| def rewrite_phrases(
|
| text: str,
|
| *,
|
| max_changes: int | None = None,
|
| polish: bool = False,
|
| min_sim: float | None = None,
|
| wordnet: Any | None = None,
|
| use_t5: bool = True,
|
| classical_strict: bool = False,
|
| classical_aggressive: bool = False,
|
| ) -> PhraseResult:
|
| """Rewrite up to N verb–object phrases with meaning-safe alternatives."""
|
| if not ENGINE_PHRASE_REWRITE and wordnet is None:
|
| return PhraseResult(text=text, reason="disabled")
|
| source = (text or "").strip()
|
| if not source:
|
| return PhraseResult(text=text, reason="empty")
|
| if any(quote in source for quote in _QUOTES):
|
| return PhraseResult(text=source, reason="quoted")
|
| if _PROTECTED_MARKER.search(source):
|
| return PhraseResult(text=source, reason="protected")
|
|
|
| nlp = get_nlp()
|
| resource = wordnet if wordnet is not None else _get_wordnet()
|
| if nlp is None or resource is None:
|
| return PhraseResult(text=source, reason="resource_unavailable")
|
| try:
|
| doc = nlp(source)
|
| except Exception:
|
| return PhraseResult(text=source, reason="parse_failed")
|
|
|
| limit = (
|
| max(1, min(int(max_changes), 4))
|
| if max_changes is not None
|
| else (
|
| ENGINE_PHRASE_MAX_CHANGES
|
| if ENGINE_PHRASE_MAX_CHANGES > 0
|
| else (2 if polish else 1)
|
| )
|
| )
|
| if classical_strict and not classical_aggressive:
|
| limit = min(limit, 1 if not polish else 2)
|
| elif classical_aggressive:
|
| limit = max(limit, 2 if polish else 2)
|
| limit = min(limit, 3)
|
| threshold = min_sim if min_sim is not None else ENGINE_PHRASE_MIN_SIM
|
| spans = _extract_spans(doc)
|
| if not spans:
|
| return PhraseResult(text=source, reason="no_spans")
|
|
|
| current = source
|
| changes: list[LexicalChange] = []
|
| touched_verbs: set[str] = set()
|
|
|
| for _ in range(limit):
|
| try:
|
| doc = nlp(current)
|
| except Exception:
|
| break
|
| spans = _extract_spans(doc)
|
| best: tuple[float, _SpanTarget, str] | None = None
|
| for span in spans:
|
| verb_key = span.verb.lemma_.lower()
|
| if verb_key in touched_verbs:
|
| continue
|
|
|
| if any(
|
| change.original.lower() == span.text.lower() for change in changes
|
| ):
|
| continue
|
| candidates = _wordnet_span_candidates(
|
| resource,
|
| span,
|
| polish=polish,
|
| classical_strict=classical_strict,
|
| classical_aggressive=classical_aggressive,
|
| )
|
| if use_t5 and not classical_strict:
|
| candidates.extend(_t5_span_candidates(span.text))
|
|
|
| try:
|
| from app.engine.paraphrase import _span_candidate_ok
|
| except Exception:
|
| _span_candidate_ok = None
|
| uniq: list[str] = []
|
| seen: set[str] = set()
|
| for cand in candidates:
|
| key = cand.lower()
|
| if key in seen or key == span.text.lower():
|
| continue
|
| if _span_candidate_ok is not None and not _span_candidate_ok(
|
| span.text, cand
|
| ):
|
| continue
|
| seen.add(key)
|
| uniq.append(cand)
|
| picked = _rank_span_candidates(
|
| span.text,
|
| uniq,
|
| min_sim=threshold,
|
| modifiers=span.modifiers,
|
| classical_strict=classical_strict,
|
| classical_aggressive=classical_aggressive,
|
| )
|
| if not picked:
|
| continue
|
|
|
| spliced = _splice(current, span.start, span.end, picked)
|
| if spliced == current:
|
| continue
|
| meaning = score_candidate(source, spliced)
|
| if meaning is not None and meaning < threshold:
|
| continue
|
|
|
| gain = _phrase_zipf(picked) - _phrase_zipf(span.text)
|
| if classical_strict and not classical_aggressive and gain < -0.15:
|
| continue
|
| distance = len(
|
| {w.lower() for w in _WORD.findall(span.text)}
|
| ^ {w.lower() for w in _WORD.findall(picked)}
|
| )
|
| score = gain + (0.15 * distance) + (meaning or 0.0)
|
| if classical_aggressive:
|
|
|
| score = (0.35 * distance) + gain + (meaning or 0.0)
|
| elif classical_strict:
|
|
|
| score = gain + (0.05 * distance) + (meaning or 0.0)
|
| if best is None or score > best[0]:
|
| best = (score, span, picked)
|
| if best is None:
|
| break
|
| _score, span, picked = best
|
| current = _splice(current, span.start, span.end, picked)
|
| touched_verbs.add(span.verb.lemma_.lower())
|
| changes.append(
|
| LexicalChange(
|
| original=span.text,
|
| replacement=picked,
|
| token_index=span.verb.i,
|
| lemma=span.verb.lemma_.lower(),
|
| synset_id="phrase",
|
| confidence=round(min(0.95, 0.55 + best[0] * 0.1), 4),
|
| )
|
| )
|
|
|
| if not changes:
|
| return PhraseResult(text=source, reason="no_safe_change")
|
| return PhraseResult(
|
| text=current,
|
| changes=changes,
|
| confidence=min(change.confidence for change in changes),
|
| reason="phrase_rewrite",
|
| )
|
|
|