any2human / app /engine /voice /__init__.py
idnameraj's picture
Re-enable primary T5 paraphrase for stronger out1→out2 divergence.
24a79a8
Raw
History Blame Contribute Delete
15.1 kB
"""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:
# Unsupported do-support or ambiguous auxiliary chain.
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
# Coordinated verb lists only rewrite the ROOT verb and drop the rest
# ("demonstrate A, participate B, and achieve C" → broken passive).
# A single trailing verb conj without a comma can be a misattached relative
# clause conjunct ("transformed the way students learn and teachers deliver").
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
# The same list can parse as a bare adverbial clause instead of a conjunct
# ("appreciate companies that reply, resolve issues, and treat them well"
# hangs "resolve" off the ROOT as advcl). Rebuilding leaves that tail behind
# the agent, so refuse. Genuine adverbial clauses carry a subordinating mark,
# their own subject, or an infinitival "to".
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)
# Parsers can attach the second half of a coordinated relative clause to
# the matrix verb. Extend a contiguous object phrase only when its object
# already contains a relative clause and there is no comma boundary.
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))
# A noun-complement phrase often parses onto the verb even though it
# belongs to the object ("identify areas | for improvement"). Promoting the
# passive subject without it strands the phrase at the far end. Only "of"
# and "for" qualify: "to"/"on" and friends are usually verb arguments
# ("emailed the invoice to X on Monday") and must stay with the verb.
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
}
# Leftovers are appended after the agent, which only reads correctly for
# trailing modifiers. Anything that opened the sentence ("By delivering
# good service, organizations can …") or sat before the verb ("also")
# would land in the wrong place and change what it modifies.
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:
# "is being reviewed" — progressive active needs a rebuilt "is reviewing".
return None
if modal:
# can be identified -> can identify
return " ".join(token.text.lower() for token in modal), "VB"
if perfect:
# has been reviewed -> has reviewed
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
# Only a single clause: coordinated verbs would silently lose conjuncts.
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:
# "were not corrected by X" needs do-support ("X did not correct"),
# which this surface rebuild cannot produce.
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