File size: 6,465 Bytes
8f6d79d
 
 
 
 
c212805
8f6d79d
 
7ea9869
 
 
 
 
7b67b50
67f284e
8f6d79d
 
 
 
 
 
 
 
 
 
67f284e
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
8f6d79d
 
 
 
 
 
 
 
c212805
 
 
 
 
 
 
67f284e
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
8f6d79d
 
 
 
 
 
 
67f284e
 
 
 
 
 
 
 
 
 
4726a76
 
 
 
 
 
 
 
 
 
 
 
 
8f6d79d
 
 
 
c212805
 
 
 
67f284e
 
11c7d9f
8f6d79d
11c7d9f
8f6d79d
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
7ea9869
 
7b67b50
 
8f6d79d
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
"""Sentence rewrite generation from plans/templates."""

from __future__ import annotations

import re
from collections import Counter

from app.engine.models import RewritePlan
from app.engine.templates import (
    fill_template,
    try_because_front,
    try_complex_clause_swap,
)
from app.engine.voice import active_to_passive
from app.pipeline.nlp import get_nlp


def _content_tokens(text: str) -> list[str]:
    return [
        w
        for w in re.findall(r"[a-zA-Z']+", (text or "").lower())
        if len(w) >= 3
    ]


def _attributive_phrases_preserved(source: str, candidate: str) -> bool:
    """Keep adjective/noun compounds attached (for example 'daily tasks')."""
    nlp = get_nlp()
    if nlp is None:
        return True
    try:
        doc = nlp(source)
    except Exception:
        return True
    cand = (candidate or "").lower()
    for token in doc:
        if token.dep_ not in {"amod", "compound"}:
            continue
        if token.head.pos_ not in {"NOUN", "PROPN"}:
            continue
        if token.pos_ not in {"ADJ", "NOUN", "PROPN"}:
            continue
        left = f"{token.text} {token.head.text}".lower()
        right = f"{token.head.text} {token.text}".lower()
        if left not in cand and right not in cand:
            return False
    return True


def reorder_quality_ok(source: str, candidate: str) -> bool:
    """Reject junk reorders that drop clauses or shuffle degree adverbs."""
    src = (source or "").strip()
    cand = (candidate or "").strip()
    if not src or not cand:
        return False
    if cand.lower().rstrip(".!?") == src.lower().rstrip(".!?"):
        return False
    # Preserve short comma-delimited sentence openers (for example discourse
    # markers) unless a dedicated clause template handles them.
    opener = re.match(r"^\s*([^,]{1,24}),", src)
    if opener and len(opener.group(1).split()) <= 2:
        expected = opener.group(1).strip().lower() + ","
        if not cand.lower().startswith(expected):
            return False
    # Reject fronting an internal attributive word such as "daily" from
    # "planning daily tasks" into "Daily, planning tasks ...".
    fronted = re.match(r"^\s*([A-Za-z]+),\s+", cand)
    if fronted:
        word = fronted.group(1)
        if not re.match(rf"^{re.escape(word)}\b", src, flags=re.I):
            nlp = get_nlp()
            if nlp is not None:
                try:
                    source_doc = nlp(src)
                    token = next(
                        (
                            item
                            for item in source_doc
                            if item.text.lower() == word.lower()
                        ),
                        None,
                    )
                    if (
                        token is not None
                        and token.dep_ in {"amod", "compound"}
                        and token.head.pos_ in {"NOUN", "PROPN"}
                    ):
                        return False
                except Exception:
                    pass
    if re.search(
        r"\b(more|most|less|least|very|really|quite)\s+"
        r"(makes?|make|is|are|was|were|has|have|had|does|do|did)\b",
        cand,
        flags=re.I,
    ):
        return False
    # Reject reorders that split degree+adverb pairs such as "more efficiently".
    for match in re.finditer(
        r"\b(more|most|less|least)\s+(efficiently|effectively|carefully|"
        r"quickly|slowly|easily|clearly|accurately)\b",
        src,
        flags=re.I,
    ):
        phrase = match.group(0).lower()
        if phrase not in cand.lower():
            return False
    # Duration fronting that strands "for/over": "At least thirty minutes, … for every day"
    if re.search(
        r"^(at\s+least\s+)?\d*\s*(thirty|forty|fifty|\d+)\s+"
        r"(minutes?|hours?|seconds?)\s*,",
        cand,
        flags=re.I,
    ):
        return False
    if re.search(r"\b(for|over|within|during)\s+(every|each)\b", cand, flags=re.I):
        if re.search(r"\b(for|over|within|during)\s+\S+\s+(minutes?|hours?)\b", src, flags=re.I):
            return False
    if re.search(r",\s+(for|over|within|during)\s+\w+\s*$", cand, flags=re.I):
        return False
    src_toks = _content_tokens(src)
    cand_toks = _content_tokens(cand)
    if not src_toks:
        return False
    # Slot templates reorder existing words; additions, omissions, or
    # duplicate degree words indicate a bad parse.
    if Counter(src_toks) != Counter(cand_toks):
        return False
    if not _attributive_phrases_preserved(src, cand):
        return False
    # Soften keep ratio slightly for structural adverb fronts that drop discourse ADVs (also)
    keep = len(set(src_toks) & set(cand_toks)) / max(1, len(set(src_toks)))
    if keep < 0.80:
        return False
    if len(cand_toks) < int(len(src_toks) * 0.75) or len(cand_toks) > int(
        len(src_toks) * 1.35
    ):
        return False
    return True


def generate_from_plan(

    plan: RewritePlan,

    *,

    template_id: str | None = None,

) -> str | None:
    """Generate a rewritten sentence from a plan (slot reorder only)."""
    if not plan.safe:
        return None
    tid = template_id or plan.template_id
    raw = plan.slots.text if plan.slots else ""

    if tid == "because_front":
        return try_because_front(raw)
    if tid == "complex_clause_swap":
        return try_complex_clause_swap(raw)
    if tid == "active_to_passive":
        return active_to_passive(raw)

    if plan.slots is None:
        return None
    filled = fill_template(tid, plan.slots)
    if not filled:
        return None
    if filled.strip().lower().rstrip(".!?") == raw.lower().rstrip(".!?"):
        return None
    if not reorder_quality_ok(raw, filled):
        return None
    return filled.strip()


def generate_candidates(plan: RewritePlan) -> list[tuple[str, str, float]]:
    """Try each ranked template; return (template_id, text, confidence)."""
    results: list[tuple[str, str, float]] = []
    if not plan.safe:
        return results
    for cand in plan.candidates:
        text = generate_from_plan(plan, template_id=cand.template_id)
        if text:
            results.append((cand.template_id, text, cand.confidence))
    return results