File size: 7,205 Bytes
d5f43d4
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
"""
Rule-based sentence RESTRUCTURING (no AI model): changes the shape of a sentence, not just its words, so that
Parrot's paraphrase of it ends up further from the original. Used for the sentences an uploaded report flagged.

Transforms (each one keeps every word of content; the caller checks terms, numbers and citations with
textparse.guard against the ORIGINAL sentence):
  * drop stock openers        "Furthermore, X."               -> "X."
  * fronted clause to the end  "By combining A, the method B."  -> "The method B by combining A."
  * end clause to the front    "X improves Y, although Z."      -> "Although Z, X improves Y."
  * split long sentences       "A; B."  /  "A, whereas B."      -> "A. In contrast, B."  /  "A, and the B" -> "A. The B"
"""
import re

_OPENERS = re.compile(
    r"^(Furthermore|Moreover|Additionally|In addition|Also|Overall|Therefore|Thus|Hence|As a result|"
    r"Consequently|In conclusion|Finally|Importantly|Notably|In particular|Specifically|Accordingly),\s+", re.I)
_FRONT = re.compile(
    r"^(?P<lead>(?:Although|While|Because|Since|When|If|As|By|Through|Using|With|For|To|After|Before|During|"
    r"Despite|Unlike|Based on|In order to|In response to|In this|In the|Within|Across|Among)\b[^,;]{6,140}),\s+"
    r"(?P<main>(?:the|this|these|those|it|its|they|their|we|our|such|a|an|each|both|all|many|most|[A-Z])[^;]*?)"
    r"(?P<end>[.!?])$")
_BACK = re.compile(
    r"^(?P<main>[^,;]{12,}?),\s+(?P<conj>although|though|because|since|while|whereas|as|unless)\s+"
    r"(?P<clause>[^,;]{8,})(?P<end>[.!?])$", re.I)
_SPLITS = [
    (re.compile(r";\s+(?=[a-z])"), lambda m: ". ", None),
    (re.compile(r",\s+whereas\s+"), lambda m: ". In contrast, ", None),
    (re.compile(r",\s+while\s+(?=(?:the|this|these|it|they|other|another|[a-z]+s\b))"), lambda m: ". Meanwhile, ", None),
    (re.compile(r",\s+and\s+(?=(?:the|this|these|it|they|such|their|its)\b)"), lambda m: ". ", None),
    (re.compile(r",\s+which\s+(?=(?:is|are|was|were|has|have|can|could|may|might|will|would|helps|makes|allows|leads|"
                r"provides|enables|improves|reduces|results)\b)"), lambda m: ". This ", None),
]
_OPEN_NOCOMMA = re.compile(r"^(Also|Overall|Therefore|Thus|Hence|Furthermore|Moreover|Additionally)\s+"
                           r"(?=(?:the|this|these|it|they|we|such|our|its|their)\b)", re.I)
_VERB_AFTER_SUBJ = re.compile(r"^((?:it|they|we)\b|(?:the|this|these|such|our|its|their)\b(?:\s+\S+){1,4}?)\s+"
                              r"(is|are|was|were|can|could|may|might|will|would|has|have|had|"
                              r"[a-z]+(?:es|s|ed))\b", re.I)
_TAIL_PP = re.compile(r"\s(in|within|across|for|through|during|among|throughout|under)\s"
                      r"(?P<np>(?:the\s|a\s|an\s|this\s|these\s|such\s|many\s|both\s|all\s)?[a-z][\w\-]*"
                      r"(?:\s[\w\-]+){1,6})(?P<end>[.!?])$")
_VERBISH = re.compile(r"\b(is|are|was|were|be|been|being|can|could|may|might|will|would|has|have|had|do|does|"
                      r"did|which|that|who|whom|whose|when|where|while|and|or)\b")
_KEEP_CAPS = re.compile(r"^(?:[A-Z]{2,}|[A-Z][a-z]*[A-Z]|I\b)")   # acronyms / CamelCase / "I" stay capitalised


def _lower_first(s):
    return s if _KEEP_CAPS.match(s) else s[:1].lower() + s[1:]


def _upper_first(s):
    return s[:1].upper() + s[1:]


def _drop_opener(s):
    m = _OPENERS.match(s)
    if m and len(s) - m.end() > 25:
        return _upper_first(s[m.end():])
    return None


def _drop_opener_nocomma(s):
    """'Also the thesis focuses on X.' -> 'The thesis also focuses on X.'"""
    m = _OPEN_NOCOMMA.match(s)
    if not m:
        return None
    word, rest = m.group(1).lower(), s[m.end():]
    v = _VERB_AFTER_SUBJ.match(rest)
    if v and word in ("also", "therefore", "thus", "hence"):
        verb = v.group(2)
        if verb.lower() in ("is", "are", "was", "were", "can", "could", "may", "might", "will", "would",
                            "has", "have", "had"):           # "It is also ...", "They can also ..."
            return _upper_first(f"{v.group(1)} {verb} {word}{rest[v.end(2):]}")
        return _upper_first(f"{v.group(1)} {word} {rest[v.start(2):]}")
    return _upper_first(rest) if len(rest.split()) >= 6 else None


def _tail_to_front(s):
    """'X becomes Y in educational data mining.' -> 'In educational data mining, X becomes Y.'"""
    if "," in s[-80:]:
        return None
    m = _TAIL_PP.search(s)
    if not m or _VERBISH.search(m.group("np")):
        return None
    main = s[:m.start()]
    if len(main.split()) < 7 or re.search(r"\b(that|which|who|whom|whose|where|such that|so that)\b", main):
        return None                                   # the phrase may belong to a sub-clause: moving it changes meaning
    return f"{_upper_first(m.group(1))} {m.group('np')}, {_lower_first(main)}{m.group('end')}"


def _front_to_back(s):
    m = _FRONT.match(s)
    if not m:
        return None
    lead, main = m.group("lead"), m.group("main")
    if len(main.split()) < 5:
        return None
    return f"{_upper_first(main)} {_lower_first(lead)}{m.group('end')}"


def _back_to_front(s):
    m = _BACK.match(s)
    if not m or len(m.group("main").split()) < 4:
        return None
    conj = m.group("conj").lower()
    if conj == "whereas":
        return None                                   # handled by a split instead
    return f"{_upper_first(conj)} {m.group('clause')}, {_lower_first(m.group('main'))}{m.group('end')}"


def _split(s, min_words=24):
    if len(s.split()) < min_words:
        return None
    for pat, rep, _ in _SPLITS:
        m = pat.search(s)
        if not m:
            continue
        left, right = s[:m.start()], s[m.end():]
        if len(left.split()) < 7 or len(right.split()) < 6:
            continue
        joiner = rep(m)
        right = _upper_first(right) if joiner.endswith(". ") else right
        left = left.rstrip(" ,;")
        return f"{left}{joiner}{right}"
    return None


def variants(sentence):
    """Restructured versions of `sentence` (may be empty). Combinations are tried as well."""
    s = sentence.strip()
    out = []
    first = [f(s) for f in (_drop_opener, _drop_opener_nocomma, _front_to_back, _back_to_front, _split,
                            _tail_to_front)]
    for v in first:
        if v and v != s and v not in out:
            out.append(v)
    for v in list(out):                               # one more step on top (e.g. opener dropped, then split)
        for f in (_front_to_back, _back_to_front, _split, _tail_to_front):
            w = f(v)
            if w and w != v and w != s and w not in out:
                out.append(w)
    return out


def restructure(sentence, guard=None, overlap=None):
    """The restructured version that changes the sentence's shape the most and still passes `guard`
    (callable(original, new) -> str|None). Returns the original sentence when nothing applies."""
    best, best_ov = sentence, 101.0
    for v in variants(sentence):
        ok = guard(sentence, v) if guard else v
        if not ok:
            continue
        ov = overlap(sentence, ok) if overlap else 0.0
        if ov < best_ov:
            best, best_ov = ok, ov
    return best