| """Dependency-driven voice transformations between active and passive."""
|
|
|
| from __future__ import annotations
|
|
|
| from functools import lru_cache
|
|
|
| from lemminflect import getInflection
|
|
|
| from app.pipeline.nlp import get_nlp
|
|
|
|
|
| def _span_indices(token) -> set[int]:
|
| return {part.i for part in token.subtree}
|
|
|
|
|
| def _ranges_text(doc, indices: set[int]) -> str:
|
| if not indices:
|
| return ""
|
| parts: list[str] = []
|
| ordered = sorted(indices)
|
| start = previous = ordered[0]
|
| for index in ordered[1:]:
|
| if index != previous + 1:
|
| parts.append(doc[start : previous + 1].text.strip())
|
| start = index
|
| previous = index
|
| parts.append(doc[start : previous + 1].text.strip())
|
| return " ".join(part for part in parts if part)
|
|
|
|
|
| def _is_structural_punct(token, doc) -> bool:
|
| """Punctuation that separates phrases, as opposed to joining a compound.
|
|
|
| The hyphens in "high-quality" and "word-of-mouth" are ``punct`` too;
|
| dropping them would rebuild the sentence as "high quality".
|
| """
|
| if token.dep_ != "punct":
|
| return False
|
| return bool(token.whitespace_) or token.i == len(doc) - 1
|
|
|
|
|
| def _continue_case(text: str, head) -> str:
|
| value = text.strip()
|
| if not value or head.pos_ == "PROPN":
|
| return value
|
| return value[0].lower() + value[1:]
|
|
|
|
|
| def _start_case(text: str) -> str:
|
| value = text.strip()
|
| return value[:1].upper() + value[1:] if value else value
|
|
|
|
|
| def _passive_auxiliary(root, auxiliaries: list, plural: bool) -> str | None:
|
| aux_text = [token.text.lower() for token in auxiliaries]
|
| has_modal = any(token.tag_ == "MD" for token in auxiliaries)
|
| has_perfect = any(
|
| token.lemma_.lower() == "have" and token.tag_ != "VBG"
|
| for token in auxiliaries
|
| )
|
| has_progressive = root.tag_ == "VBG" and any(
|
| token.lemma_.lower() == "be" for token in auxiliaries
|
| )
|
|
|
| if has_perfect and has_progressive:
|
| return None
|
| if has_perfect:
|
| perfect_aux: list[str] = []
|
| for token in auxiliaries:
|
| if token.lemma_.lower() == "have" and token.tag_ in {"VBP", "VBZ"}:
|
| target_tag = "VBP" if plural else "VBZ"
|
| forms = getInflection("have", tag=target_tag)
|
| perfect_aux.append(forms[0] if forms else token.text.lower())
|
| else:
|
| perfect_aux.append(token.text.lower())
|
| return " ".join([*perfect_aux, "been"])
|
| if has_modal:
|
| return " ".join([*aux_text, "be"])
|
| if has_progressive:
|
| return " ".join([*aux_text, "being"])
|
| if auxiliaries:
|
|
|
| return None
|
| if root.tag_ == "VBD":
|
| return "were" if plural else "was"
|
| if root.tag_ in {"VBP", "VBZ"}:
|
| return "are" if plural else "is"
|
| return None
|
|
|
|
|
| @lru_cache(maxsize=4096)
|
| def active_to_passive(text: str) -> str | None:
|
| """Convert an eligible active transitive clause without lexical paraphrasing."""
|
| raw = (text or "").strip()
|
| if not raw or "?" in raw:
|
| return None
|
| nlp = get_nlp()
|
| if nlp is None:
|
| return None
|
| try:
|
| doc = nlp(raw)
|
| except Exception:
|
| return None
|
|
|
| root = next(
|
| (token for token in doc if token.dep_ == "ROOT" and token.pos_ == "VERB"),
|
| None,
|
| )
|
| if root is None or any(
|
| token.dep_ in {"auxpass", "nsubjpass"} for token in doc
|
| ):
|
| return None
|
|
|
|
|
|
|
|
|
|
|
| verb_conjs = [
|
| child
|
| for child in root.children
|
| if child.dep_ == "conj" and child.pos_ == "VERB"
|
| ]
|
| if len(verb_conjs) > 1:
|
| return None
|
| if len(verb_conjs) == 1:
|
| between = doc[root.i + 1 : verb_conjs[0].i]
|
| if any(token.text == "," for token in between):
|
| return None
|
|
|
|
|
|
|
|
|
|
|
|
|
| stray_verb_phrase = any(
|
| child.dep_ == "advcl"
|
| and child.pos_ == "VERB"
|
| and child.tag_ in {"VB", "VBP", "VBZ", "VBD"}
|
| and child.i > root.i
|
| and not any(
|
| grand.dep_ in {"mark", "nsubj", "nsubjpass", "aux"}
|
| or grand.pos_ == "SCONJ"
|
| for grand in child.children
|
| )
|
| for child in root.children
|
| )
|
| if stray_verb_phrase:
|
| return None
|
|
|
| subject = next((child for child in root.children if child.dep_ == "nsubj"), None)
|
| if subject is None or subject.pos_ == "PRON":
|
| return None
|
|
|
| object_head = next(
|
| (child for child in root.children if child.dep_ in {"dobj", "obj"}),
|
| None,
|
| )
|
| complement_indices: set[int] = set()
|
| carrier = None
|
| if object_head is None:
|
| carrier = next(
|
| (
|
| child
|
| for child in root.children
|
| if child.dep_ in {"ccomp", "xcomp"}
|
| and child.pos_ in {"ADJ", "VERB"}
|
| ),
|
| None,
|
| )
|
| if carrier is None:
|
| return None
|
| object_head = next(
|
| (
|
| child
|
| for child in carrier.children
|
| if child.dep_ in {"nsubj", "nsubjpass"}
|
| ),
|
| None,
|
| )
|
| if object_head is None or object_head.pos_ == "PRON":
|
| return None
|
| complement_indices = _span_indices(carrier) - _span_indices(object_head)
|
|
|
| subject_indices = _span_indices(subject)
|
| object_indices = _span_indices(object_head)
|
|
|
|
|
|
|
|
|
| if any(doc[index].dep_ == "relcl" for index in object_indices):
|
| trailing_conj = next(
|
| (
|
| child
|
| for child in root.children
|
| if child.dep_ == "conj"
|
| and child.pos_ == "VERB"
|
| and child.i > max(object_indices)
|
| and any(grand.dep_ == "nsubj" for grand in child.children)
|
| ),
|
| None,
|
| )
|
| if trailing_conj is not None:
|
| end = max(_span_indices(trailing_conj))
|
| between = doc[min(object_indices) : end + 1]
|
| if not any(token.text == "," for token in between):
|
| object_indices.update(range(min(object_indices), end + 1))
|
|
|
|
|
|
|
|
|
|
|
|
|
| while True:
|
| following = next(
|
| (
|
| child
|
| for child in root.children
|
| if child.dep_ == "prep"
|
| and child.lower_ in {"of", "for"}
|
| and min(part.i for part in child.subtree) == max(object_indices) + 1
|
| ),
|
| None,
|
| )
|
| if following is None:
|
| break
|
| object_indices |= _span_indices(following)
|
|
|
| auxiliaries = sorted(
|
| [child for child in root.children if child.dep_ == "aux"],
|
| key=lambda token: token.i,
|
| )
|
| negations = sorted(
|
| [child for child in root.children if child.dep_ == "neg"],
|
| key=lambda token: token.i,
|
| )
|
| plural = "Plur" in object_head.morph.get("Number")
|
| passive_aux = _passive_auxiliary(root, auxiliaries, plural)
|
| participles = getInflection(root.lemma_, tag="VBN")
|
| if not passive_aux or not participles:
|
| return None
|
|
|
| if negations:
|
| aux_parts = passive_aux.split()
|
| passive_aux = " ".join(
|
| [aux_parts[0], *(token.text for token in negations), *aux_parts[1:]]
|
| )
|
|
|
| consumed = (
|
| subject_indices
|
| | object_indices
|
| | complement_indices
|
| | {root.i}
|
| | {token.i for token in auxiliaries}
|
| | {token.i for token in negations}
|
| | {token.i for token in doc if _is_structural_punct(token, doc)}
|
| )
|
| extras = {
|
| token.i
|
| for token in doc
|
| if token.i not in consumed
|
| }
|
|
|
|
|
|
|
|
|
|
|
| if extras:
|
| subject_start = min(subject_indices)
|
| if any(index < subject_start for index in extras):
|
| return None
|
| if any(
|
| doc[index].pos_ in {"ADV", "PART"} and index < root.i for index in extras
|
| ):
|
| return None
|
|
|
| new_subject = _start_case(_ranges_text(doc, object_indices))
|
| agent = _continue_case(_ranges_text(doc, subject_indices), subject)
|
| complement = _ranges_text(doc, complement_indices)
|
| remainder = _ranges_text(doc, extras)
|
| pieces = [
|
| new_subject,
|
| passive_aux,
|
| participles[0],
|
| complement,
|
| f"by {agent}",
|
| remainder,
|
| ]
|
| sentence = " ".join(piece for piece in pieces if piece).strip()
|
| terminal = raw[-1] if raw.endswith(("!", "?")) else "."
|
| return sentence.rstrip(".!?") + terminal
|
|
|
|
|
| def can_convert_active_to_passive(text: str) -> bool:
|
| return active_to_passive(text) is not None
|
|
|
|
|
| def _active_auxiliary(root, auxiliaries: list, plural: bool) -> tuple[str, str] | None:
|
| """Return (aux text, verb tag) for the active clause, or None if unsupported.
|
|
|
| The passive chain is ``aux* be VBN``. Dropping the ``be`` leaves the tense on
|
| whatever precedes it; when nothing does, the copula itself carried the tense.
|
| """
|
| modal = [token for token in auxiliaries if token.tag_ == "MD"]
|
| perfect = [
|
| token for token in auxiliaries if token.lemma_.lower() == "have"
|
| ]
|
| copulas = [token for token in auxiliaries if token.lemma_.lower() == "be"]
|
| if not copulas:
|
| return None
|
| if any(token.tag_ == "VBG" for token in copulas) and not modal and not perfect:
|
|
|
| return None
|
|
|
| if modal:
|
|
|
| return " ".join(token.text.lower() for token in modal), "VB"
|
| if perfect:
|
|
|
| return " ".join(token.text.lower() for token in perfect), "VBN"
|
|
|
| copula = copulas[0]
|
| tag = copula.tag_
|
| if tag == "VBD":
|
| return "", "VBD"
|
| if tag in {"VBP", "VBZ"}:
|
| return "", "VBP" if plural else "VBZ"
|
| return None
|
|
|
|
|
| @lru_cache(maxsize=4096)
|
| def passive_to_active(text: str) -> str | None:
|
| """Convert ``X is/can be VERBed by Y`` into ``Y verbs X`` without paraphrasing.
|
|
|
| Requires an explicit ``by`` agent; agentless passives have no recoverable
|
| subject and must stay as they are.
|
| """
|
| raw = (text or "").strip()
|
| if not raw or "?" in raw:
|
| return None
|
| nlp = get_nlp()
|
| if nlp is None:
|
| return None
|
| try:
|
| doc = nlp(raw)
|
| except Exception:
|
| return None
|
|
|
| root = next(
|
| (token for token in doc if token.dep_ == "ROOT" and token.pos_ == "VERB"),
|
| None,
|
| )
|
| if root is None or root.tag_ != "VBN":
|
| return None
|
|
|
| subject = next(
|
| (child for child in root.children if child.dep_ == "nsubjpass"), None
|
| )
|
| if subject is None:
|
| return None
|
|
|
| auxiliaries = sorted(
|
| [child for child in root.children if child.dep_ in {"aux", "auxpass"}],
|
| key=lambda token: token.i,
|
| )
|
| if not any(token.dep_ == "auxpass" for token in auxiliaries):
|
| return None
|
|
|
|
|
| if any(
|
| child.dep_ == "conj" and child.pos_ == "VERB" for child in root.children
|
| ):
|
| return None
|
|
|
| agent_prep = next(
|
| (
|
| child
|
| for child in root.children
|
| if child.dep_ == "agent" and child.lower_ == "by"
|
| ),
|
| None,
|
| )
|
| if agent_prep is None:
|
| return None
|
| agent_head = next(
|
| (child for child in agent_prep.children if child.dep_ == "pobj"), None
|
| )
|
| if agent_head is None:
|
| return None
|
|
|
| negations = sorted(
|
| [child for child in root.children if child.dep_ == "neg"],
|
| key=lambda token: token.i,
|
| )
|
| agent_indices = _span_indices(agent_head)
|
| subject_indices = _span_indices(subject)
|
| plural = "Plur" in agent_head.morph.get("Number") or agent_head.tag_ == "NNS"
|
|
|
| active = _active_auxiliary(root, auxiliaries, plural)
|
| if active is None:
|
| return None
|
| aux_text, verb_tag = active
|
| if negations and not aux_text:
|
|
|
|
|
| return None
|
| forms = getInflection(root.lemma_, tag=verb_tag)
|
| if not forms:
|
| return None
|
|
|
| consumed = (
|
| subject_indices
|
| | agent_indices
|
| | {agent_prep.i, root.i}
|
| | {token.i for token in auxiliaries}
|
| | {token.i for token in negations}
|
| | {token.i for token in doc if token.dep_ == "punct"}
|
| )
|
| extras = {token.i for token in doc if token.i not in consumed}
|
|
|
| new_subject = _start_case(_ranges_text(doc, agent_indices))
|
| new_object = _continue_case(_ranges_text(doc, subject_indices), subject)
|
| negation = " ".join(token.text for token in negations)
|
| pieces = [
|
| new_subject,
|
| aux_text,
|
| negation,
|
| forms[0],
|
| new_object,
|
| _ranges_text(doc, extras),
|
| ]
|
| sentence = " ".join(piece for piece in pieces if piece).strip()
|
| terminal = raw[-1] if raw.endswith(("!", "?")) else "."
|
| return sentence.rstrip(".!?") + terminal
|
|
|
|
|
| def can_convert_passive_to_active(text: str) -> bool:
|
| return passive_to_active(text) is not None
|
|
|