idnameraj commited on
Commit
f82f941
·
verified ·
1 Parent(s): 158d7d6

Upload 92 files

Browse files
app/__pycache__/config.cpython-311.pyc CHANGED
Binary files a/app/__pycache__/config.cpython-311.pyc and b/app/__pycache__/config.cpython-311.pyc differ
 
app/config.py CHANGED
@@ -81,3 +81,10 @@ _ml_flag = (os.environ.get("ML_POLISH_ENABLED") or "true").strip().lower()
81
  ML_POLISH_AVAILABLE_DEFAULT = _ml_flag not in {"0", "false", "no", "off"}
82
  _ml_warm = (os.environ.get("ML_POLISH_WARM") or "false").strip().lower()
83
  ML_POLISH_WARM = _ml_warm in {"1", "true", "yes", "on"}
 
 
 
 
 
 
 
 
81
  ML_POLISH_AVAILABLE_DEFAULT = _ml_flag not in {"0", "false", "no", "off"}
82
  _ml_warm = (os.environ.get("ML_POLISH_WARM") or "false").strip().lower()
83
  ML_POLISH_WARM = _ml_warm in {"1", "true", "yes", "on"}
84
+
85
+ # Grammar cleanup around rewrite (fixes ESL errors so they don't propagate/re-appear).
86
+ # INPUT: clean the source before rewriting. OUTPUT: clean the rewrite before returning.
87
+ _gfi = (os.environ.get("GRAMMAR_FIX_INPUT") or "true").strip().lower()
88
+ GRAMMAR_FIX_INPUT = _gfi not in {"0", "false", "no", "off"}
89
+ _gfo = (os.environ.get("GRAMMAR_FIX_OUTPUT") or "true").strip().lower()
90
+ GRAMMAR_FIX_OUTPUT = _gfo not in {"0", "false", "no", "off"}
app/data/protected_phrases.json ADDED
@@ -0,0 +1,100 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ [
2
+ "how much",
3
+ "how many",
4
+ "so much",
5
+ "so many",
6
+ "too much",
7
+ "too many",
8
+ "as much",
9
+ "as many",
10
+ "how far",
11
+ "how long",
12
+ "fast food",
13
+ "fast foods",
14
+ "junk food",
15
+ "junk foods",
16
+ "street food",
17
+ "sea food",
18
+ "seafood",
19
+ "whole food",
20
+ "whole foods",
21
+ "comfort food",
22
+ "real estate",
23
+ "social media",
24
+ "climate change",
25
+ "artificial intelligence",
26
+ "machine learning",
27
+ "deep learning",
28
+ "customer service",
29
+ "public transport",
30
+ "public transportation",
31
+ "high school",
32
+ "middle school",
33
+ "primary school",
34
+ "mental health",
35
+ "public health",
36
+ "health care",
37
+ "healthcare",
38
+ "human rights",
39
+ "credit card",
40
+ "search engine",
41
+ "operating system",
42
+ "hard drive",
43
+ "smart phone",
44
+ "smartphone",
45
+ "video game",
46
+ "video games",
47
+ "power plant",
48
+ "supply chain",
49
+ "role model",
50
+ "common sense",
51
+ "point of view",
52
+ "quality of life",
53
+ "standard of living",
54
+ "cost of living",
55
+ "side effect",
56
+ "side effects",
57
+ "greenhouse gas",
58
+ "greenhouse gases",
59
+ "living room",
60
+ "dining room",
61
+ "waiting room",
62
+ "parking lot",
63
+ "bus stop",
64
+ "traffic jam",
65
+ "rush hour",
66
+ "full time",
67
+ "part time",
68
+ "long term",
69
+ "short term",
70
+ "day care",
71
+ "work out",
72
+ "grocery store",
73
+ "department store",
74
+ "small business",
75
+ "big data",
76
+ "world war",
77
+ "civil war",
78
+ "united states",
79
+ "middle east",
80
+ "solar system",
81
+ "black hole",
82
+ "blood pressure",
83
+ "heart attack",
84
+ "immune system",
85
+ "nervous system",
86
+ "birth rate",
87
+ "death rate",
88
+ "life expectancy",
89
+ "natural resources",
90
+ "renewable energy",
91
+ "fossil fuel",
92
+ "fossil fuels",
93
+ "global warming",
94
+ "air pollution",
95
+ "water pollution",
96
+ "drinking water",
97
+ "food chain",
98
+ "human being",
99
+ "human beings"
100
+ ]
app/pipeline/__pycache__/generative.cpython-311.pyc CHANGED
Binary files a/app/pipeline/__pycache__/generative.cpython-311.pyc and b/app/pipeline/__pycache__/generative.cpython-311.pyc differ
 
app/pipeline/__pycache__/grammar_fix.cpython-311.pyc ADDED
Binary file (15 kB). View file
 
app/pipeline/__pycache__/orchestrator.cpython-311.pyc CHANGED
Binary files a/app/pipeline/__pycache__/orchestrator.cpython-311.pyc and b/app/pipeline/__pycache__/orchestrator.cpython-311.pyc differ
 
app/pipeline/__pycache__/synonym.cpython-311.pyc CHANGED
Binary files a/app/pipeline/__pycache__/synonym.cpython-311.pyc and b/app/pipeline/__pycache__/synonym.cpython-311.pyc differ
 
app/pipeline/grammar_fix.py ADDED
@@ -0,0 +1,296 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Deterministic grammar cleanup applied around the rewrite.
2
+
3
+ Two tiers, both safe to run offline:
4
+ 1. High-precision lexical fixes (mass-noun plurals, do-support agreement) via regex.
5
+ 2. spaCy-based subject/verb agreement fixes (pronoun/plural subject vs. verb form).
6
+ Optionally applies self-hosted LanguageTool suggestions first when configured.
7
+
8
+ The goal is narrow: fix the common ESL errors that the rewriter cannot fix on its
9
+ own and that the MiniLM guard would otherwise re-inject when it reverts a sentence.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import logging
15
+ import re
16
+
17
+ from app.pipeline.nlp import get_nlp
18
+
19
+ logger = logging.getLogger("plainrewrite.grammar_fix")
20
+
21
+ # Uncountable / irregular nouns that get wrongly pluralized.
22
+ _MASS_NOUN_PLURALS: dict[str, str] = {
23
+ "peoples": "people",
24
+ "informations": "information",
25
+ "advices": "advice",
26
+ "equipments": "equipment",
27
+ "softwares": "software",
28
+ "hardwares": "hardware",
29
+ "knowledges": "knowledge",
30
+ "researches": "research",
31
+ "staffs": "staff",
32
+ "furnitures": "furniture",
33
+ "luggages": "luggage",
34
+ "moneys": "money",
35
+ "homeworks": "homework",
36
+ "houseworks": "housework",
37
+ "feedbacks": "feedback",
38
+ "traffics": "traffic",
39
+ "progresses": "progress",
40
+ "evidences": "evidence",
41
+ "musics": "music",
42
+ "slangs": "slang",
43
+ "childs": "children",
44
+ "mans": "men",
45
+ "womans": "women",
46
+ "foots": "feet",
47
+ "tooths": "teeth",
48
+ }
49
+
50
+ # Words ending in -s after do-support that are nouns, not verbs (skip stripping).
51
+ _DO_NOUN_EXCEPTIONS = {"dishes", "chores", "sports", "maths", "backups"}
52
+
53
+ # Modals / auxiliaries: if a verb is governed by one of these it keeps its base form.
54
+ _MODALS_AUX = {
55
+ "will", "would", "shall", "should", "can", "could", "may", "might",
56
+ "must", "do", "does", "did", "to",
57
+ }
58
+
59
+ # Words that must never be treated as a base present-tense verb after a pronoun
60
+ # in the no-spaCy regex fallback (copulas, auxiliaries, adverbs, invariant/past verbs).
61
+ _AGREEMENT_SKIP = {
62
+ "is", "are", "was", "were", "be", "been", "being", "am",
63
+ "has", "have", "had", "will", "would", "shall", "should",
64
+ "can", "could", "may", "might", "must", "do", "does", "did",
65
+ "not", "never", "also", "just", "only", "always", "often",
66
+ "sometimes", "soon", "then", "here", "there", "really", "still",
67
+ "already", "even", "too", "so", "very", "quite", "almost",
68
+ # invariant or past-equal-to-base verbs (ambiguous → leave alone)
69
+ "read", "put", "cut", "set", "hit", "let", "hurt", "cost", "shut",
70
+ "split", "spread", "bet", "quit", "fit", "beat", "cast", "burst",
71
+ # common irregular past forms (valid with singular subject)
72
+ "went", "saw", "came", "took", "gave", "found", "made", "said",
73
+ "told", "knew", "grew", "ran", "felt", "kept", "left", "meant",
74
+ "brought", "bought", "caught", "taught", "thought", "sought", "got",
75
+ "sat", "stood", "won", "lost", "held", "led", "met", "paid", "spent",
76
+ }
77
+
78
+
79
+ def _match_case(sample: str, replacement: str) -> str:
80
+ if sample.isupper():
81
+ return replacement.upper()
82
+ if sample[:1].isupper():
83
+ return replacement[:1].upper() + replacement[1:]
84
+ return replacement
85
+
86
+
87
+ def _fix_mass_nouns(text: str) -> str:
88
+ def repl(m: re.Match[str]) -> str:
89
+ return _match_case(m.group(0), _MASS_NOUN_PLURALS[m.group(0).lower()])
90
+
91
+ pattern = re.compile(
92
+ r"\b(" + "|".join(re.escape(k) for k in _MASS_NOUN_PLURALS) + r")\b",
93
+ re.IGNORECASE,
94
+ )
95
+ return pattern.sub(repl, text)
96
+
97
+
98
+ def _fix_do_support(text: str) -> str:
99
+ """`don't realizes` → `don't realize`; `does works` → `does work`."""
100
+ aux = r"(do|does|did|don't|doesn't|didn't|do not|does not|did not)"
101
+ pattern = re.compile(rf"\b{aux}\s+([A-Za-z]+?)s\b", re.IGNORECASE)
102
+
103
+ def repl(m: re.Match[str]) -> str:
104
+ verb_s = m.group(2) + "s"
105
+ if verb_s.lower() in _DO_NOUN_EXCEPTIONS:
106
+ return m.group(0)
107
+ base = m.group(2)
108
+ # Keep words like "address" (ends in -ss) — those aren't "base+s".
109
+ if base.lower().endswith("s"):
110
+ return m.group(0)
111
+ return f"{m.group(1)} {base}"
112
+
113
+ return pattern.sub(repl, text)
114
+
115
+
116
+ def _to_third_person(verb: str) -> str:
117
+ v = verb.lower()
118
+ irregular = {"be": "is", "have": "has", "do": "does", "go": "goes"}
119
+ if v in irregular:
120
+ return _match_case(verb, irregular[v])
121
+ if v.endswith(("s", "sh", "ch", "x", "z", "o")):
122
+ out = v + "es"
123
+ elif v.endswith("y") and len(v) > 1 and v[-2] not in "aeiou":
124
+ out = v[:-1] + "ies"
125
+ else:
126
+ out = v + "s"
127
+ return _match_case(verb, out)
128
+
129
+
130
+ def _singular_to_base(verb_s: str) -> str:
131
+ """Invert a 3rd-person-singular verb to its base form (goes→go, tries→try)."""
132
+ v = verb_s.lower()
133
+ irregular = {"goes": "go", "does": "do", "has": "have"}
134
+ if v in irregular:
135
+ return _match_case(verb_s, irregular[v])
136
+ special_ies = {"dies": "die", "ties": "tie", "lies": "lie"}
137
+ if v in special_ies:
138
+ return _match_case(verb_s, special_ies[v])
139
+ if v.endswith("ies") and len(v) > 4:
140
+ out = v[:-3] + "y"
141
+ elif v.endswith(("ses", "zes", "xes", "ches", "shes", "oes")):
142
+ out = v[:-2]
143
+ elif v.endswith("s"):
144
+ out = v[:-1]
145
+ else:
146
+ return verb_s
147
+ return _match_case(verb_s, out)
148
+
149
+
150
+ def _has_modal_or_aux(token) -> bool:
151
+ for child in token.lefts:
152
+ if child.dep_ in {"aux", "auxpass"} or child.lower_ in _MODALS_AUX:
153
+ return True
154
+ # Verb itself directly preceded by "to" (infinitive) — leave base form.
155
+ if token.i > 0:
156
+ prev = token.doc[token.i - 1]
157
+ if prev.lower_ in _MODALS_AUX:
158
+ return True
159
+ return False
160
+
161
+
162
+ def _fix_agreement_spacy(text: str) -> str:
163
+ """Fix subject/verb agreement using the dependency parse (high precision)."""
164
+ nlp = get_nlp()
165
+ if nlp is None:
166
+ return text
167
+ doc = nlp(text)
168
+ # (start_char, end_char, replacement)
169
+ edits: list[tuple[int, int, str]] = []
170
+
171
+ for token in doc:
172
+ if token.pos_ not in {"VERB"}:
173
+ continue
174
+ subj = None
175
+ for child in token.lefts:
176
+ if child.dep_ in {"nsubj", "nsubjpass"}:
177
+ subj = child
178
+ break
179
+ if subj is None:
180
+ continue
181
+ if _has_modal_or_aux(token):
182
+ continue
183
+
184
+ subj_low = subj.lower_
185
+ # Singular 3rd-person subject needs a -s verb: "it affect" -> "it affects"
186
+ singular_3p = subj_low in {"it", "he", "she"} or (
187
+ subj.pos_ in {"NOUN", "PROPN"} and subj.tag_ in {"NN", "NNP"}
188
+ )
189
+ plural_or_first = subj_low in {"they", "we", "you", "i"} or (
190
+ subj.pos_ == "NOUN" and subj.tag_ == "NNS"
191
+ )
192
+
193
+ if singular_3p and token.tag_ == "VB":
194
+ new = _to_third_person(token.text)
195
+ if new != token.text:
196
+ edits.append((token.idx, token.idx + len(token.text), new))
197
+ elif plural_or_first and token.tag_ == "VBZ":
198
+ base = token.lemma_
199
+ if base and base != token.text.lower():
200
+ edits.append(
201
+ (token.idx, token.idx + len(token.text), _match_case(token.text, base))
202
+ )
203
+
204
+ if not edits:
205
+ return text
206
+ edits.sort(key=lambda e: e[0], reverse=True)
207
+ out = text
208
+ for start, end, repl in edits:
209
+ out = out[:start] + repl + out[end:]
210
+ return out
211
+
212
+
213
+ def _fix_agreement_regex(text: str) -> str:
214
+ """Conservative subject/verb agreement fix for the no-spaCy path.
215
+
216
+ Only touches clear pronoun-subject patterns; skips copulas, auxiliaries,
217
+ adverbs, and ambiguous invariant/past verbs to stay high-precision.
218
+ """
219
+
220
+ def third(m: re.Match[str]) -> str:
221
+ subj, verb = m.group(1), m.group(2)
222
+ vl = verb.lower()
223
+ if vl in _AGREEMENT_SKIP or vl.endswith(("s", "ed", "ing")) or len(vl) < 2:
224
+ return m.group(0)
225
+ return f"{subj} {_to_third_person(verb)}"
226
+
227
+ # "it/he/she + <base verb>" → third person (it affect → it affects)
228
+ text = re.sub(r"\b(it|he|she|It|He|She)\s+([A-Za-z]+)\b", third, text)
229
+
230
+ def base(m: re.Match[str]) -> str:
231
+ subj, verb_s = m.group(1), m.group(2)
232
+ vl = verb_s.lower()
233
+ if vl in _DO_NOUN_EXCEPTIONS or vl in _AGREEMENT_SKIP:
234
+ return m.group(0)
235
+ stem = _singular_to_base(verb_s)
236
+ if stem == verb_s or len(stem) < 2:
237
+ return m.group(0)
238
+ return f"{subj} {stem}"
239
+
240
+ # "they/we/i + <verb>s" → base (they runs → they run). "you" excluded (noisy).
241
+ text = re.sub(r"\b(they|we|i|They|We|I)\s+([A-Za-z]+s)\b", base, text)
242
+ return text
243
+
244
+
245
+ def _apply_languagetool(text: str, language: str | None) -> str:
246
+ """Apply safe LanguageTool suggestions (spelling/grammar) when the server is up."""
247
+ try:
248
+ from app.config import LANGUAGE_TOOL_ENABLED
249
+ from app.pipeline.grammar import check_languagetool
250
+ except Exception: # noqa: BLE001
251
+ return text
252
+ if not LANGUAGE_TOOL_ENABLED:
253
+ return text
254
+ try:
255
+ issues, _warning = check_languagetool(text, language)
256
+ except Exception as exc: # noqa: BLE001
257
+ logger.warning("LanguageTool correction skipped: %s", exc)
258
+ return text
259
+ if not issues:
260
+ return text
261
+
262
+ # Apply only unambiguous spelling/grammar fixes, non-overlapping, right-to-left.
263
+ usable = [
264
+ i
265
+ for i in issues
266
+ if i.suggestion
267
+ and i.category in {"spelling", "grammar"}
268
+ and 0 <= i.start < i.end <= len(text)
269
+ ]
270
+ usable.sort(key=lambda i: i.start, reverse=True)
271
+ out = text
272
+ last_start = len(text) + 1
273
+ for issue in usable:
274
+ if issue.end > last_start:
275
+ continue # overlaps a later edit
276
+ out = out[: issue.start] + issue.suggestion + out[issue.end :]
277
+ last_start = issue.start
278
+ return out
279
+
280
+
281
+ def correct_text(text: str, language: str | None = None) -> str:
282
+ """Best-effort grammar cleanup. Never raises; returns input on failure."""
283
+ if not text or not text.strip():
284
+ return text
285
+ try:
286
+ out = _apply_languagetool(text, language)
287
+ out = _fix_mass_nouns(out)
288
+ out = _fix_do_support(out)
289
+ if get_nlp() is not None:
290
+ out = _fix_agreement_spacy(out)
291
+ else:
292
+ out = _fix_agreement_regex(out)
293
+ return out
294
+ except Exception as exc: # noqa: BLE001
295
+ logger.warning("correct_text failed, returning original: %s", exc)
296
+ return text
app/pipeline/orchestrator.py CHANGED
@@ -8,7 +8,9 @@ import time
8
  from dataclasses import dataclass
9
  from difflib import SequenceMatcher
10
 
 
11
  from app.pipeline.generative import generative_available, generative_paraphrase
 
12
  from app.pipeline.mechanics import enforce_length_budget, scrub_phrases, tidy
13
  from app.pipeline.ml_context import (
14
  gen_was_used,
@@ -143,6 +145,16 @@ def rewrite_text(
143
  if not original:
144
  raise ValueError("Paste some text first.")
145
 
 
 
 
 
 
 
 
 
 
 
146
  strength = max(0, min(2, int(strength)))
147
  tone = normalize_tone(tone)
148
  want_gen = bool(ml_polish) and generative_available()
@@ -151,7 +163,7 @@ def rewrite_text(
151
  # Still enable context for meaning guard helpers that check the flag.
152
  set_ml_polish(want_minilm and not want_gen)
153
  rng = _rng_for(
154
- original
155
  + "|"
156
  + tone
157
  + "|"
@@ -166,7 +178,7 @@ def rewrite_text(
166
  logger.info("ML polish: aggressive generative rewrite (%s)…", tone)
167
  # Force at least Normal generative aggression
168
  draft = generative_paraphrase(
169
- original,
170
  tone=tone,
171
  strength=max(1, strength),
172
  max_sim=0.86,
@@ -179,15 +191,15 @@ def rewrite_text(
179
  rewritten = _light_polish_after_gen(working, tone, strength, rng)
180
  logger.info(
181
  "ML polish: generative main path words %s→%s sim=%.3f",
182
- word_count(original),
183
  word_count(rewritten),
184
- _similarity_ratio(original, rewritten),
185
  )
186
  # If still too similar, one more generative pass at Heavy
187
- if _similarity_ratio(original, rewritten) > 0.88:
188
  logger.info("ML polish: generative second pass (still too similar)")
189
  draft2 = generative_paraphrase(
190
- original,
191
  tone=tone,
192
  strength=2,
193
  max_sim=0.82,
@@ -203,56 +215,66 @@ def rewrite_text(
203
  # Full classical path (also when ML polish off, or gen missing)
204
  if want_minilm:
205
  set_ml_polish(True)
206
- paragraphs = split_paragraphs(original)
207
  out_paras = [_rewrite_paragraph(p, tone, strength, rng) for p in paragraphs]
208
  rewritten = tidy("\n\n".join(out_paras))
209
  rewritten = scrub_phrases(rewritten)
210
  rewritten = apply_tone_style(rewritten, tone, strength, rng)
211
  rewritten = apply_tone_contractions(rewritten, tone)
212
 
213
- # Meaning guard: looser when generative wrote the draft
 
 
214
  if want_minilm:
215
  set_ml_polish(True) # enable guard
216
  if used_gen_main:
217
  rewritten = apply_minilm_polish(
218
- original, rewritten, tone, min_meaning=0.58
219
  )
220
  else:
221
- rewritten = apply_minilm_polish(original, rewritten, tone)
222
  rewritten = tidy(rewritten)
223
  rewritten = apply_tone_style(rewritten, tone, strength, rng)
224
  rewritten = apply_tone_contractions(rewritten, tone)
225
 
226
- rewritten = enforce_length_budget(original, rewritten, preserve_length)
227
  rewritten = tidy(rewritten)
228
 
229
- ratio = _similarity_ratio(original, rewritten)
230
  if ratio > 0.85 and not used_gen_main:
231
  logger.info("Low change detected (ratio=%.3f); running stronger second pass", ratio)
232
  rewritten = _force_more_changes(rewritten, tone, strength, rng)
233
  if want_minilm:
234
- rewritten = apply_minilm_polish(original, rewritten, tone)
235
  rewritten = apply_tone_style(rewritten, tone, strength, rng)
236
  rewritten = apply_tone_contractions(rewritten, tone)
237
- rewritten = enforce_length_budget(original, rewritten, preserve_length)
238
  rewritten = tidy(rewritten)
239
  tip = "Applied second pass (input was too similar after first rewrite)."
240
  notes = f"{notes} {tip}".strip() if notes else tip
241
- ratio = _similarity_ratio(original, rewritten)
242
  elif ratio > 0.90 and used_gen_main and want_gen:
243
  # Last resort: rules force-pass on top of weak generative output
244
  logger.info("Generative output still similar (%.3f); blending rules force pass", ratio)
245
  blended = _force_more_changes(rewritten, tone, min(2, strength + 1), rng)
246
  if want_minilm:
247
- blended = apply_minilm_polish(original, blended, tone, min_meaning=0.58)
248
- rewritten = enforce_length_budget(original, tidy(blended), preserve_length)
249
  rewritten = tidy(rewritten)
250
  tip = "Blended rules force-pass after generative stayed too similar."
251
  notes = f"{notes} {tip}".strip() if notes else tip
252
- ratio = _similarity_ratio(original, rewritten)
 
 
 
 
 
 
253
 
254
  changed = rewritten.strip() != original.strip()
255
  engine_bits: list[str] = []
 
 
256
  if gen_was_used():
257
  engine_bits.append("flan-t5")
258
  engine_bits.append("spacy" if spacy_available() else "regex-fallback")
@@ -274,6 +296,9 @@ def rewrite_text(
274
  elif ml_polish and want_gen and not want_minilm:
275
  tip = "ML polish: generative main path (MiniLM unavailable for meaning guard)."
276
  notes = f"{notes} {tip}".strip() if notes else tip
 
 
 
277
 
278
  engine = "+".join(engine_bits)
279
  msg = (
 
8
  from dataclasses import dataclass
9
  from difflib import SequenceMatcher
10
 
11
+ from app.config import GRAMMAR_FIX_INPUT, GRAMMAR_FIX_OUTPUT
12
  from app.pipeline.generative import generative_available, generative_paraphrase
13
+ from app.pipeline.grammar_fix import correct_text
14
  from app.pipeline.mechanics import enforce_length_budget, scrub_phrases, tidy
15
  from app.pipeline.ml_context import (
16
  gen_was_used,
 
145
  if not original:
146
  raise ValueError("Paste some text first.")
147
 
148
+ # Clean the source first so ESL errors don't propagate through the rewrite
149
+ # (and so the MiniLM meaning guard can't re-inject them when it reverts).
150
+ source = original
151
+ grammar_fixed_input = False
152
+ if GRAMMAR_FIX_INPUT:
153
+ cleaned = correct_text(original)
154
+ if cleaned and cleaned.strip():
155
+ grammar_fixed_input = cleaned.strip() != original.strip()
156
+ source = normalize_whitespace(cleaned)
157
+
158
  strength = max(0, min(2, int(strength)))
159
  tone = normalize_tone(tone)
160
  want_gen = bool(ml_polish) and generative_available()
 
163
  # Still enable context for meaning guard helpers that check the flag.
164
  set_ml_polish(want_minilm and not want_gen)
165
  rng = _rng_for(
166
+ source
167
  + "|"
168
  + tone
169
  + "|"
 
178
  logger.info("ML polish: aggressive generative rewrite (%s)…", tone)
179
  # Force at least Normal generative aggression
180
  draft = generative_paraphrase(
181
+ source,
182
  tone=tone,
183
  strength=max(1, strength),
184
  max_sim=0.86,
 
191
  rewritten = _light_polish_after_gen(working, tone, strength, rng)
192
  logger.info(
193
  "ML polish: generative main path words %s→%s sim=%.3f",
194
+ word_count(source),
195
  word_count(rewritten),
196
+ _similarity_ratio(source, rewritten),
197
  )
198
  # If still too similar, one more generative pass at Heavy
199
+ if _similarity_ratio(source, rewritten) > 0.88:
200
  logger.info("ML polish: generative second pass (still too similar)")
201
  draft2 = generative_paraphrase(
202
+ source,
203
  tone=tone,
204
  strength=2,
205
  max_sim=0.82,
 
215
  # Full classical path (also when ML polish off, or gen missing)
216
  if want_minilm:
217
  set_ml_polish(True)
218
+ paragraphs = split_paragraphs(source)
219
  out_paras = [_rewrite_paragraph(p, tone, strength, rng) for p in paragraphs]
220
  rewritten = tidy("\n\n".join(out_paras))
221
  rewritten = scrub_phrases(rewritten)
222
  rewritten = apply_tone_style(rewritten, tone, strength, rng)
223
  rewritten = apply_tone_contractions(rewritten, tone)
224
 
225
+ # Meaning guard: looser when generative wrote the draft.
226
+ # NOTE: guard against `source` (grammar-corrected) — so any revert restores
227
+ # the corrected sentence, never the original ESL error.
228
  if want_minilm:
229
  set_ml_polish(True) # enable guard
230
  if used_gen_main:
231
  rewritten = apply_minilm_polish(
232
+ source, rewritten, tone, min_meaning=0.58
233
  )
234
  else:
235
+ rewritten = apply_minilm_polish(source, rewritten, tone)
236
  rewritten = tidy(rewritten)
237
  rewritten = apply_tone_style(rewritten, tone, strength, rng)
238
  rewritten = apply_tone_contractions(rewritten, tone)
239
 
240
+ rewritten = enforce_length_budget(source, rewritten, preserve_length)
241
  rewritten = tidy(rewritten)
242
 
243
+ ratio = _similarity_ratio(source, rewritten)
244
  if ratio > 0.85 and not used_gen_main:
245
  logger.info("Low change detected (ratio=%.3f); running stronger second pass", ratio)
246
  rewritten = _force_more_changes(rewritten, tone, strength, rng)
247
  if want_minilm:
248
+ rewritten = apply_minilm_polish(source, rewritten, tone)
249
  rewritten = apply_tone_style(rewritten, tone, strength, rng)
250
  rewritten = apply_tone_contractions(rewritten, tone)
251
+ rewritten = enforce_length_budget(source, rewritten, preserve_length)
252
  rewritten = tidy(rewritten)
253
  tip = "Applied second pass (input was too similar after first rewrite)."
254
  notes = f"{notes} {tip}".strip() if notes else tip
255
+ ratio = _similarity_ratio(source, rewritten)
256
  elif ratio > 0.90 and used_gen_main and want_gen:
257
  # Last resort: rules force-pass on top of weak generative output
258
  logger.info("Generative output still similar (%.3f); blending rules force pass", ratio)
259
  blended = _force_more_changes(rewritten, tone, min(2, strength + 1), rng)
260
  if want_minilm:
261
+ blended = apply_minilm_polish(source, blended, tone, min_meaning=0.58)
262
+ rewritten = enforce_length_budget(source, tidy(blended), preserve_length)
263
  rewritten = tidy(rewritten)
264
  tip = "Blended rules force-pass after generative stayed too similar."
265
  notes = f"{notes} {tip}".strip() if notes else tip
266
+ ratio = _similarity_ratio(source, rewritten)
267
+
268
+ # Final grammar cleanup on the rewrite itself (catches residual agreement slips).
269
+ if GRAMMAR_FIX_OUTPUT:
270
+ cleaned_out = correct_text(rewritten)
271
+ if cleaned_out and cleaned_out.strip():
272
+ rewritten = tidy(cleaned_out)
273
 
274
  changed = rewritten.strip() != original.strip()
275
  engine_bits: list[str] = []
276
+ if GRAMMAR_FIX_INPUT or GRAMMAR_FIX_OUTPUT:
277
+ engine_bits.append("grammar")
278
  if gen_was_used():
279
  engine_bits.append("flan-t5")
280
  engine_bits.append("spacy" if spacy_available() else "regex-fallback")
 
296
  elif ml_polish and want_gen and not want_minilm:
297
  tip = "ML polish: generative main path (MiniLM unavailable for meaning guard)."
298
  notes = f"{notes} {tip}".strip() if notes else tip
299
+ if grammar_fixed_input:
300
+ tip = "Corrected grammar in the source before rewriting."
301
+ notes = f"{notes} {tip}".strip() if notes else tip
302
 
303
  engine = "+".join(engine_bits)
304
  msg = (
app/pipeline/synonym.py CHANGED
@@ -108,6 +108,45 @@ def _load_json_map(name: str) -> dict[str, str]:
108
  return {k.lower(): v for k, v in json.loads(path.read_text(encoding="utf-8")).items() if k != v}
109
 
110
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
111
  @lru_cache(maxsize=1)
112
  def load_preferred_swaps() -> dict[str, str]:
113
  return _load_json_map("preferred_swaps.json")
@@ -281,8 +320,9 @@ def _join_tokens(tokens: list[str]) -> str:
281
  text = t
282
  continue
283
  prev = tokens[i - 1]
284
- if re.match(r"\w", t) and (not prev or re.match(r"\w", prev[-1:])):
285
- text += " " + t
 
286
  elif re.match(r"\w", t):
287
  text += " " + t
288
  else:
@@ -408,12 +448,18 @@ def rewrite_sentence_synonyms(
408
  ),
409
  )
410
 
411
- def _force_min_changes(tokens: list[str], out: list[str], min_changes: int) -> list[str]:
 
 
 
 
 
 
412
  changed = sum(1 for a, b in zip(tokens, out) if a.isalpha() and a != b)
413
  if changed >= min_changes:
414
  return out
415
  for i, tok in enumerate(tokens):
416
- if not tok.isalpha():
417
  continue
418
  if out[i] != tok:
419
  continue
@@ -427,7 +473,7 @@ def rewrite_sentence_synonyms(
427
  # Adj/adv WordNet fill only — never nouns/verbs (no domain lists needed)
428
  if changed < min_changes and max_wn > 0:
429
  for i, tok in enumerate(tokens):
430
- if not tok.isalpha() or out[i] != tok:
431
  continue
432
  low = tok.lower()
433
  if low in _STOP_SWAP or len(low) < 4 or _looks_inflected_verb(low):
@@ -453,9 +499,16 @@ def rewrite_sentence_synonyms(
453
  return out
454
 
455
  nlp = get_nlp()
 
456
 
457
  if nlp is None:
458
- tokens = re.findall(r"\w+|[^\w\s]", sentence, flags=re.UNICODE)
 
 
 
 
 
 
459
  content_n = sum(
460
  1
461
  for t in tokens
@@ -468,19 +521,26 @@ def rewrite_sentence_synonyms(
468
  min_ch = max(min_ch, max(2, content_n // 3))
469
  budget = [max_wn]
470
  out = [
471
- transform(tok, None, budget, sentence_start=_is_sentence_start(tokens, i))
 
 
472
  for i, tok in enumerate(tokens)
473
  ]
474
- out = _force_min_changes(tokens, out, min_ch)
475
  return _join_tokens(out)
476
 
477
  doc = nlp(sentence)
478
  budget = [max_wn]
479
  pieces: list[str] = []
480
  toks = list(doc)
 
 
 
 
 
481
  for i, token in enumerate(toks):
482
  raw = token.text
483
- if not token.is_alpha:
484
  pieces.append(raw)
485
  continue
486
  prev_text = toks[i - 1].text if i else ""
@@ -543,5 +603,5 @@ def rewrite_sentence_synonyms(
543
  min_ch = {0: 1, 1: max(2, content_n // 4), 2: max(3, content_n // 3)}.get(strength, 2)
544
  if force_all_lexicon:
545
  min_ch = max(min_ch, max(2, content_n // 3))
546
- pieces = _force_min_changes(raws, pieces, min_ch)
547
  return "".join(p + t.whitespace_ for t, p in zip(doc, pieces)).strip()
 
108
  return {k.lower(): v for k, v in json.loads(path.read_text(encoding="utf-8")).items() if k != v}
109
 
110
 
111
+ @lru_cache(maxsize=1)
112
+ def load_protected_phrases() -> tuple[str, ...]:
113
+ """Multiword collocations whose component words must not be swapped.
114
+
115
+ Data-driven (app/data/protected_phrases.json) so it stays domain-agnostic —
116
+ e.g. 'fast food' must never become 'rapid food'.
117
+ """
118
+ path = DATA_DIR / "protected_phrases.json"
119
+ if not path.exists():
120
+ return ()
121
+ try:
122
+ raw = json.loads(path.read_text(encoding="utf-8"))
123
+ except Exception:
124
+ return ()
125
+ phrases = {str(p).strip().lower() for p in raw if str(p).strip()}
126
+ # Longest first so multi-word matches win.
127
+ return tuple(sorted(phrases, key=len, reverse=True))
128
+
129
+
130
+ def _protected_spans(sentence: str) -> list[tuple[int, int]]:
131
+ """Character spans in `sentence` covered by a protected collocation."""
132
+ phrases = load_protected_phrases()
133
+ if not phrases:
134
+ return []
135
+ spans: list[tuple[int, int]] = []
136
+ low = sentence.lower()
137
+ for phrase in phrases:
138
+ for m in re.finditer(rf"\b{re.escape(phrase)}\b", low):
139
+ spans.append((m.start(), m.end()))
140
+ return spans
141
+
142
+
143
+ def _in_protected_span(start: int, end: int, spans: list[tuple[int, int]]) -> bool:
144
+ for s, e in spans:
145
+ if start < e and end > s: # any overlap
146
+ return True
147
+ return False
148
+
149
+
150
  @lru_cache(maxsize=1)
151
  def load_preferred_swaps() -> dict[str, str]:
152
  return _load_json_map("preferred_swaps.json")
 
320
  text = t
321
  continue
322
  prev = tokens[i - 1]
323
+ # Keep contractions/possessives glued: don ' t → don't, cat ' s → cat's
324
+ if prev.endswith(("'", "’")) or t in {"'", "’"}:
325
+ text += t
326
  elif re.match(r"\w", t):
327
  text += " " + t
328
  else:
 
448
  ),
449
  )
450
 
451
+ def _force_min_changes(
452
+ tokens: list[str],
453
+ out: list[str],
454
+ min_changes: int,
455
+ protected_idx: set[int] | None = None,
456
+ ) -> list[str]:
457
+ protected_idx = protected_idx or set()
458
  changed = sum(1 for a, b in zip(tokens, out) if a.isalpha() and a != b)
459
  if changed >= min_changes:
460
  return out
461
  for i, tok in enumerate(tokens):
462
+ if i in protected_idx or not tok.isalpha():
463
  continue
464
  if out[i] != tok:
465
  continue
 
473
  # Adj/adv WordNet fill only — never nouns/verbs (no domain lists needed)
474
  if changed < min_changes and max_wn > 0:
475
  for i, tok in enumerate(tokens):
476
+ if i in protected_idx or not tok.isalpha() or out[i] != tok:
477
  continue
478
  low = tok.lower()
479
  if low in _STOP_SWAP or len(low) < 4 or _looks_inflected_verb(low):
 
499
  return out
500
 
501
  nlp = get_nlp()
502
+ spans = _protected_spans(sentence)
503
 
504
  if nlp is None:
505
+ matches = list(re.finditer(r"\w+|[^\w\s]", sentence, flags=re.UNICODE))
506
+ tokens = [m.group(0) for m in matches]
507
+ protected_idx = {
508
+ i
509
+ for i, m in enumerate(matches)
510
+ if _in_protected_span(m.start(), m.end(), spans)
511
+ }
512
  content_n = sum(
513
  1
514
  for t in tokens
 
521
  min_ch = max(min_ch, max(2, content_n // 3))
522
  budget = [max_wn]
523
  out = [
524
+ tok
525
+ if i in protected_idx
526
+ else transform(tok, None, budget, sentence_start=_is_sentence_start(tokens, i))
527
  for i, tok in enumerate(tokens)
528
  ]
529
+ out = _force_min_changes(tokens, out, min_ch, protected_idx)
530
  return _join_tokens(out)
531
 
532
  doc = nlp(sentence)
533
  budget = [max_wn]
534
  pieces: list[str] = []
535
  toks = list(doc)
536
+ protected_idx = {
537
+ i
538
+ for i, token in enumerate(toks)
539
+ if _in_protected_span(token.idx, token.idx + len(token.text), spans)
540
+ }
541
  for i, token in enumerate(toks):
542
  raw = token.text
543
+ if i in protected_idx or not token.is_alpha:
544
  pieces.append(raw)
545
  continue
546
  prev_text = toks[i - 1].text if i else ""
 
603
  min_ch = {0: 1, 1: max(2, content_n // 4), 2: max(3, content_n // 3)}.get(strength, 2)
604
  if force_all_lexicon:
605
  min_ch = max(min_ch, max(2, content_n // 3))
606
+ pieces = _force_min_changes(raws, pieces, min_ch, protected_idx)
607
  return "".join(p + t.whitespace_ for t, p in zip(doc, pieces)).strip()
frontend/dist/assets/index.js CHANGED
@@ -972,7 +972,7 @@ function App() {
972
  ),
973
  h("label", {
974
  className: "check",
975
- title: "FLAN-T5 paraphrase → rules → MiniLM meaning check (beta)",
976
  },
977
  h("input", {
978
  type: "checkbox",
 
972
  ),
973
  h("label", {
974
  className: "check",
975
+ title: "Aggressive FLAN-T5 rewritelight rules → MiniLM meaning check (beta)",
976
  },
977
  h("input", {
978
  type: "checkbox",
frontend/src/App.tsx CHANGED
@@ -1174,7 +1174,7 @@ export default function App() {
1174
 
1175
  <label
1176
  className="check"
1177
- title="FLAN-T5 paraphrase → rules → MiniLM meaning check (beta)"
1178
  >
1179
  <input
1180
  type="checkbox"
 
1174
 
1175
  <label
1176
  className="check"
1177
+ title="Aggressive FLAN-T5 rewritelight rules → MiniLM meaning check (beta)"
1178
  >
1179
  <input
1180
  type="checkbox"
scripts/test_grammar_fix.py ADDED
@@ -0,0 +1,33 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import sys
4
+ from pathlib import Path
5
+
6
+ ROOT = Path(__file__).resolve().parent.parent
7
+ sys.path.insert(0, str(ROOT))
8
+
9
+ from app.pipeline.orchestrator import rewrite_text
10
+
11
+ orig = (
12
+ "Nowadays many peoples are living unhealthy life because they don't have "
13
+ "enough times. Eating fast foods are becoming very common and peoples don't "
14
+ "realizes how much it affect their health."
15
+ )
16
+
17
+ for tone, st in [("Neutral", 1), ("Academic", 2), ("Casual", 2)]:
18
+ r = rewrite_text(orig, tone=tone, strength=st, preserve_length=True, ml_polish=False)
19
+ print("===", tone, "strength", st, "| changed=", r.changed, "| engine=", r.engine)
20
+ print(r.text)
21
+ print("notes:", r.notes)
22
+ print()
23
+
24
+ # Collocation protection: 'fast food' must never become 'rapid food' etc.
25
+ assert "rapid food" not in " ".join(
26
+ rewrite_text(orig, tone=t, strength=2, preserve_length=True).text.lower()
27
+ for t in ("Neutral", "Academic", "Casual")
28
+ )
29
+ # Grammar propagation: these ESL errors must be gone from Neutral output.
30
+ neutral = rewrite_text(orig, tone="Neutral", strength=1, preserve_length=True).text.lower()
31
+ for bad in ("peoples", "don't realizes", "it affect "):
32
+ assert bad not in neutral + " ", f"still present: {bad!r}"
33
+ print("ALL ASSERTIONS PASSED")