"""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