File size: 3,223 Bytes
67f284e
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
"""Explicit high-coverage rule fallback for declarative sentences."""

from __future__ import annotations

import re

from app.pipeline.nlp import get_nlp

_SUBJECT_DEPS = frozenset({"nsubj", "nsubjpass", "csubj", "csubjpass"})
_PROTECTED = re.compile(r"ZZPROTECTED[A-Z]+\d+ZZ", re.I)
_QUOTES = frozenset({'"', "“", "”", "‘", "’"})


def _lower_continuation(text: str, subject_head) -> str:
    value = text.strip()
    if not value:
        return value
    if (
        subject_head.pos_ == "PROPN"
        or subject_head.text == "I"
    ):
        return value
    return value[:1].lower() + value[1:]


def force_cleft_rewrite(text: str) -> str | None:
    """Create a subject cleft while retaining the original predicate."""
    raw = (text or "").strip()
    if (
        not raw
        or raw.endswith("?")
        or any(mark in raw for mark in _QUOTES)
        or _PROTECTED.search(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"), None)
    subjects = [token for token in doc if token.dep_ in _SUBJECT_DEPS]
    if root is None:
        return None

    # Gerund subjects with coordinated objects are often parsed as the root.
    # The first finite auxiliary provides a reliable dynamic boundary.
    finite_aux = next(
        (
            token
            for token in doc
            if token.pos_ == "AUX" and token.i > 0 and token.head.i > token.i
        ),
        None,
    )
    if doc[0].tag_ == "VBG" and finite_aux is not None:
        subject = doc[0]
        start = doc[0].idx
        end = finite_aux.idx
    else:
        if not subjects:
            return None
        subject = next(
            (
                token
                for token in subjects
                if token.head == root or root in tuple(token.ancestors)
            ),
            subjects[0],
        )
        subject_tokens = sorted(subject.subtree, key=lambda token: token.i)
        if not subject_tokens:
            return None
        start = subject_tokens[0].idx
        last = subject_tokens[-1]
        end = last.idx + len(last.text)
    prefix = raw[:start].strip()
    subject_text = raw[start:end].strip(" ,")
    predicate = raw[end:].strip()
    predicate = predicate.lstrip(" ,")
    terminal = "."
    if predicate.endswith(("!", ".")):
        terminal = predicate[-1]
        predicate = predicate[:-1].rstrip()
    if not subject_text or not predicate:
        return None
    if predicate.lower().startswith(("is it ", "was it ")):
        return None

    subject_text = _lower_continuation(subject_text, subject)
    core = f"it is {subject_text} that {predicate}{terminal}"
    if prefix:
        prefix = prefix.rstrip(" ,")
        candidate = f"{prefix}, {core}"
    else:
        candidate = core[:1].upper() + core[1:]
    candidate = re.sub(r"\s+", " ", candidate).strip()
    if candidate.lower().rstrip(".!?") == raw.lower().rstrip(".!?"):
        return None
    return candidate