idnameraj commited on
Commit
2f3072b
·
verified ·
1 Parent(s): f82f941

Upload 102 files

Browse files
README.md CHANGED
@@ -110,7 +110,8 @@ Add Supabase secrets, then add the Space URL to Supabase Auth redirect URLs.
110
 
111
  ## Notes
112
 
113
- - Core rewrite engine is classical NLP (spaCy + WordNet + rules).
 
114
  - With Supabase enabled: **guest preview** on the homepage → sign up for Free → Pro later.
115
  - Admin: manage `profiles` / `plans` in the Supabase Table Editor.
116
  - Stripe can set `plan_id` later; see setup doc.
 
110
 
111
  ## Notes
112
 
113
+ - With **ML polish**: generative rewrite is primary (`flan-t5-base` by default); classical lexicon is fallback. See [docs/ML_POLISH.md](docs/ML_POLISH.md).
114
+ - Without ML polish: classical NLP (spaCy + WordNet + rules + grammar).
115
  - With Supabase enabled: **guest preview** on the homepage → sign up for Free → Pro later.
116
  - Admin: manage `profiles` / `plans` in the Supabase Table Editor.
117
  - Stripe can set `plan_id` later; see setup doc.
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
@@ -63,20 +63,29 @@ LANGUAGE_TOOL_ENABLED = _lt_flag not in {"0", "false", "no", "off"} and bool(LAN
63
  MINILM_MODEL = (
64
  os.environ.get("MINILM_MODEL") or "sentence-transformers/all-MiniLM-L6-v2"
65
  ).strip()
66
- # Tiny seq2seq for ML polish draft (CPU). Override to try other FLAN/T5 checkpoints.
 
67
  GENERATIVE_MODEL = (
68
- os.environ.get("GENERATIVE_MODEL") or "google/flan-t5-small"
 
 
 
 
 
 
69
  ).strip()
70
- # 0 = paraphrase the entire uploaded document (no sentence cap).
71
- # Positive value = optional safety cap only (e.g. 200). Hard-clamped to 5000.
72
- _gen_max_raw = (os.environ.get("GENERATIVE_MAX_SENTENCES") or "0").strip()
73
  try:
74
  _gen_max_n = int(_gen_max_raw)
75
  except ValueError:
76
  _gen_max_n = 0
77
- GENERATIVE_MAX_SENTENCES = max(0, min(_gen_max_n, 5000))
 
 
78
  _gen_flag = (os.environ.get("GENERATIVE_POLISH_ENABLED") or "true").strip().lower()
79
  GENERATIVE_POLISH_ENABLED = _gen_flag not in {"0", "false", "no", "off"}
 
 
 
80
  _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()
@@ -88,3 +97,7 @@ _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"}
 
 
 
 
 
63
  MINILM_MODEL = (
64
  os.environ.get("MINILM_MODEL") or "sentence-transformers/all-MiniLM-L6-v2"
65
  ).strip()
66
+ # Seq2seq rewrite model. Prefer base over small for quality; override via env.
67
+ # Larger instruction models (7B+) need GPU — set GENERATIVE_MODEL explicitly.
68
  GENERATIVE_MODEL = (
69
+ os.environ.get("GENERATIVE_MODEL") or "google/flan-t5-base"
70
+ ).strip()
71
+ # 0 = rewrite entire document (no paragraph cap). Positive = safety cap.
72
+ _gen_max_raw = (
73
+ os.environ.get("GENERATIVE_MAX_PARAGRAPHS")
74
+ or os.environ.get("GENERATIVE_MAX_SENTENCES")
75
+ or "0"
76
  ).strip()
 
 
 
77
  try:
78
  _gen_max_n = int(_gen_max_raw)
79
  except ValueError:
80
  _gen_max_n = 0
81
+ GENERATIVE_MAX_PARAGRAPHS = max(0, min(_gen_max_n, 5000))
82
+ # Back-compat alias
83
+ GENERATIVE_MAX_SENTENCES = GENERATIVE_MAX_PARAGRAPHS
84
  _gen_flag = (os.environ.get("GENERATIVE_POLISH_ENABLED") or "true").strip().lower()
85
  GENERATIVE_POLISH_ENABLED = _gen_flag not in {"0", "false", "no", "off"}
86
+ # When true and generative model loads, prefer gen path whenever ml_polish is requested
87
+ _gen_primary = (os.environ.get("GENERATIVE_PRIMARY") or "true").strip().lower()
88
+ GENERATIVE_PRIMARY = _gen_primary not in {"0", "false", "no", "off"}
89
  _ml_flag = (os.environ.get("ML_POLISH_ENABLED") or "true").strip().lower()
90
  ML_POLISH_AVAILABLE_DEFAULT = _ml_flag not in {"0", "false", "no", "off"}
91
  _ml_warm = (os.environ.get("ML_POLISH_WARM") or "false").strip().lower()
 
97
  GRAMMAR_FIX_INPUT = _gfi not in {"0", "false", "no", "off"}
98
  _gfo = (os.environ.get("GRAMMAR_FIX_OUTPUT") or "true").strip().lower()
99
  GRAMMAR_FIX_OUTPUT = _gfo not in {"0", "false", "no", "off"}
100
+
101
+ # Classical lexicon path: only used when generative is unavailable / ML polish off
102
+ _lex = (os.environ.get("LEXICON_FALLBACK") or "true").strip().lower()
103
+ LEXICON_FALLBACK = _lex not in {"0", "false", "no", "off"}
app/data/content_swaps.json CHANGED
@@ -19,8 +19,6 @@
19
  "shoots": "sprouts",
20
  "rays": "beams",
21
  "ray": "beam",
22
- "time": "moment",
23
- "times": "moments",
24
  "happy": "glad",
25
  "quickly": "swiftly",
26
  "slowly": "gradually",
@@ -165,7 +163,6 @@
165
  "different": "distinct",
166
  "whole": "entire",
167
  "half": "partial",
168
- "enough": "adequate",
169
  "several": "various",
170
  "certain": "particular",
171
  "possible": "feasible",
@@ -193,7 +190,6 @@
193
  "first": "initial",
194
  "second": "next",
195
  "third": "another",
196
-
197
  "change": "shift",
198
  "changes": "shifts",
199
  "changed": "shifted",
@@ -228,7 +224,6 @@
228
  "protect": "safeguard",
229
  "protects": "safeguards",
230
  "protected": "safeguarded",
231
- "people": "communities",
232
  "environment": "ecosystem",
233
  "agriculture": "farming",
234
  "farmers": "growers",
@@ -324,15 +319,12 @@
324
  "factors": "elements",
325
  "example": "instance",
326
  "examples": "instances",
327
- "today": "nowadays",
328
- "nowadays": "today",
329
  "often": "frequently",
330
  "rarely": "seldom",
331
  "always": "consistently",
332
  "clearly": "plainly",
333
  "simply": "merely",
334
  "really": "truly",
335
- "very": "highly",
336
  "quite": "fairly",
337
  "rather": "somewhat",
338
  "especially": "particularly",
@@ -374,7 +366,6 @@
374
  "connected": "linked",
375
  "similar": "alike",
376
  "various": "assorted",
377
- "common": "usual",
378
  "unique": "distinct",
379
  "complex": "intricate",
380
  "simple": "basic",
@@ -391,11 +382,7 @@
391
  "serious": "severe",
392
  "severe": "serious",
393
  "strong": "sturdy",
394
-
395
  "reducing": "lowering",
396
- "reduce": "lower",
397
- "reduces": "lowers",
398
- "reduced": "lowered",
399
  "improving": "enhancing",
400
  "improve": "enhance",
401
  "improves": "enhances",
@@ -403,5 +390,6 @@
403
  "costs": "expenses",
404
  "cost": "expense",
405
  "bugs": "defects",
406
- "bug": "defect"
 
407
  }
 
19
  "shoots": "sprouts",
20
  "rays": "beams",
21
  "ray": "beam",
 
 
22
  "happy": "glad",
23
  "quickly": "swiftly",
24
  "slowly": "gradually",
 
163
  "different": "distinct",
164
  "whole": "entire",
165
  "half": "partial",
 
166
  "several": "various",
167
  "certain": "particular",
168
  "possible": "feasible",
 
190
  "first": "initial",
191
  "second": "next",
192
  "third": "another",
 
193
  "change": "shift",
194
  "changes": "shifts",
195
  "changed": "shifted",
 
224
  "protect": "safeguard",
225
  "protects": "safeguards",
226
  "protected": "safeguarded",
 
227
  "environment": "ecosystem",
228
  "agriculture": "farming",
229
  "farmers": "growers",
 
319
  "factors": "elements",
320
  "example": "instance",
321
  "examples": "instances",
 
 
322
  "often": "frequently",
323
  "rarely": "seldom",
324
  "always": "consistently",
325
  "clearly": "plainly",
326
  "simply": "merely",
327
  "really": "truly",
 
328
  "quite": "fairly",
329
  "rather": "somewhat",
330
  "especially": "particularly",
 
366
  "connected": "linked",
367
  "similar": "alike",
368
  "various": "assorted",
 
369
  "unique": "distinct",
370
  "complex": "intricate",
371
  "simple": "basic",
 
382
  "serious": "severe",
383
  "severe": "serious",
384
  "strong": "sturdy",
 
385
  "reducing": "lowering",
 
 
 
386
  "improving": "enhancing",
387
  "improve": "enhance",
388
  "improves": "enhances",
 
390
  "costs": "expenses",
391
  "cost": "expense",
392
  "bugs": "defects",
393
+ "bug": "defect",
394
+ "common": "widespread"
395
  }
app/data/elevate_swaps.json CHANGED
@@ -69,7 +69,6 @@
69
  "about": "regarding",
70
  "so": "therefore",
71
  "also": "additionally",
72
- "people": "individuals",
73
  "person": "individual",
74
  "change": "modification",
75
  "changes": "modifications",
 
69
  "about": "regarding",
70
  "so": "therefore",
71
  "also": "additionally",
 
72
  "person": "individual",
73
  "change": "modification",
74
  "changes": "modifications",
app/data/protected_phrases.json CHANGED
@@ -9,6 +9,10 @@
9
  "as many",
10
  "how far",
11
  "how long",
 
 
 
 
12
  "fast food",
13
  "fast foods",
14
  "junk food",
 
9
  "as many",
10
  "how far",
11
  "how long",
12
+ "enough time",
13
+ "enough times",
14
+ "very common",
15
+ "more common",
16
  "fast food",
17
  "fast foods",
18
  "junk food",
app/main.py CHANGED
@@ -128,7 +128,14 @@ def health():
128
  "minilm": "fastembed|sentence-transformers" if ml_pkg else None,
129
  "generative": GENERATIVE_MODEL if gen_pkg else None,
130
  },
131
- "pipeline": ["flan-t5 paraphrase", "rules", "minilm meaning guard"],
 
 
 
 
 
 
 
132
  "plans": "all",
133
  },
134
  }
 
128
  "minilm": "fastembed|sentence-transformers" if ml_pkg else None,
129
  "generative": GENERATIVE_MODEL if gen_pkg else None,
130
  },
131
+ "pipeline": [
132
+ "grammar",
133
+ "generative paraphrase",
134
+ "validate/rank",
135
+ "rules-light",
136
+ "minilm meaning guard",
137
+ "lexicon-fallback",
138
+ ],
139
  "plans": "all",
140
  },
141
  }
app/pipeline/__pycache__/candidate_ranker.cpython-311.pyc ADDED
Binary file (4.95 kB). View file
 
app/pipeline/__pycache__/candidate_validator.cpython-311.pyc ADDED
Binary file (9.39 kB). View file
 
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 CHANGED
Binary files a/app/pipeline/__pycache__/grammar_fix.cpython-311.pyc and b/app/pipeline/__pycache__/grammar_fix.cpython-311.pyc differ
 
app/pipeline/__pycache__/meaning_safety.cpython-311.pyc ADDED
Binary file (4.9 kB). View file
 
app/pipeline/__pycache__/ml_polish.cpython-311.pyc CHANGED
Binary files a/app/pipeline/__pycache__/ml_polish.cpython-311.pyc and b/app/pipeline/__pycache__/ml_polish.cpython-311.pyc differ
 
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__/sentence_transform.cpython-311.pyc CHANGED
Binary files a/app/pipeline/__pycache__/sentence_transform.cpython-311.pyc and b/app/pipeline/__pycache__/sentence_transform.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/candidate_ranker.py ADDED
@@ -0,0 +1,99 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Rank validated rewrite candidates for fidelity + natural difference."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from difflib import SequenceMatcher
6
+
7
+ from app.pipeline.candidate_validator import ValidationResult, filter_valid_candidates
8
+ from app.pipeline.tones import normalize_tone
9
+
10
+
11
+ def _surface_sim(a: str, b: str) -> float:
12
+ return SequenceMatcher(None, a.lower().strip(), b.lower().strip()).ratio()
13
+
14
+
15
+ def _tone_bonus(candidate: str, tone: str) -> float:
16
+ """Light register heuristics — secondary to meaning."""
17
+ tone_l = normalize_tone(tone)
18
+ low = candidate.lower()
19
+ casual_marks = ("don't", "can't", "won't", "it's", "that's", "gonna", "kinda")
20
+ formal_marks = ("therefore", "however", "moreover", "furthermore", "consequently")
21
+ academic_marks = ("thus", "hence", "whereby", "notwithstanding", "aforementioned")
22
+ if tone_l == "Casual":
23
+ return 0.08 if any(m in low for m in casual_marks) else 0.0
24
+ if tone_l == "Formal":
25
+ return 0.08 if any(m in low for m in formal_marks) else 0.0
26
+ if tone_l == "Academic":
27
+ return 0.08 if any(m in low for m in academic_marks + formal_marks) else 0.0
28
+ return 0.0
29
+
30
+
31
+ def score_candidate(
32
+ original: str,
33
+ candidate: str,
34
+ validation: ValidationResult,
35
+ *,
36
+ tone: str = "Neutral",
37
+ ) -> float:
38
+ """
39
+ Higher is better.
40
+
41
+ Weights favor meaning preservation over "being different".
42
+ """
43
+ meaning = validation.meaning
44
+ surf = validation.surface_sim if validation.surface_sim else _surface_sim(original, candidate)
45
+ # Useful difference: reward leaving the near-copy zone, not extreme divergence
46
+ if surf >= 0.95:
47
+ diff = 0.0
48
+ elif surf >= 0.80:
49
+ diff = 0.35
50
+ elif surf >= 0.55:
51
+ diff = 1.0
52
+ elif surf >= 0.35:
53
+ diff = 0.7
54
+ else:
55
+ diff = 0.25
56
+
57
+ ow = max(1, len(original.split()))
58
+ cw = len(candidate.split())
59
+ length_ratio = cw / ow
60
+ if 0.75 <= length_ratio <= 1.35:
61
+ length_score = 1.0
62
+ elif 0.55 <= length_ratio <= 1.7:
63
+ length_score = 0.6
64
+ else:
65
+ length_score = 0.2
66
+
67
+ tone_b = _tone_bonus(candidate, tone)
68
+
69
+ return (
70
+ 0.45 * meaning
71
+ + 0.25 * diff
72
+ + 0.15 * length_score
73
+ + 0.10 * (1.0 - abs(surf - 0.65)) # soft preference for moderate rewrite
74
+ + 0.05
75
+ + tone_b
76
+ )
77
+
78
+
79
+ def pick_best_candidate(
80
+ original: str,
81
+ candidates: list[str],
82
+ *,
83
+ tone: str = "Neutral",
84
+ min_meaning: float = 0.72,
85
+ fallback: str | None = None,
86
+ ) -> str:
87
+ """Validate + rank; return best candidate or fallback (default: original)."""
88
+ valid = filter_valid_candidates(original, candidates, min_meaning=min_meaning)
89
+ if not valid:
90
+ return fallback if fallback is not None else original
91
+
92
+ best_text = valid[0][0]
93
+ best_score = -1.0
94
+ for text, result in valid:
95
+ s = score_candidate(original, text, result, tone=tone)
96
+ if s > best_score:
97
+ best_score = s
98
+ best_text = text
99
+ return best_text
app/pipeline/candidate_validator.py ADDED
@@ -0,0 +1,165 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Validate rewrite candidates for meaning, polarity, and fact preservation."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ from dataclasses import dataclass
7
+
8
+ from app.pipeline.meaning_safety import polarity_safe
9
+
10
+ # Tokens that look like proper nouns / identifiers to preserve.
11
+ _PROPER = re.compile(r"\b([A-Z][a-z]+(?:\s+[A-Z][a-z]+)*)\b")
12
+ _NUMBER = re.compile(
13
+ r"\b(?:\d{1,3}(?:,\d{3})+(?:\.\d+)?|\d+\.\d+%?|\d+%?|\d{4})\b"
14
+ )
15
+ _QUOTED = re.compile(r"[\"“”']([^\"“”']{2,120})[\"“”']")
16
+ _YEAR = re.compile(r"\b(?:19|20)\d{2}\b")
17
+
18
+
19
+ @dataclass
20
+ class ValidationResult:
21
+ ok: bool
22
+ reasons: list[str]
23
+ meaning: float = 1.0
24
+ surface_sim: float = 1.0
25
+
26
+
27
+ def _surface_sim(a: str, b: str) -> float:
28
+ from difflib import SequenceMatcher
29
+
30
+ return SequenceMatcher(None, a.lower().strip(), b.lower().strip()).ratio()
31
+
32
+
33
+ def _extract_numbers(text: str) -> set[str]:
34
+ return {m.group(0) for m in _NUMBER.finditer(text)} | {
35
+ m.group(0) for m in _YEAR.finditer(text)
36
+ }
37
+
38
+
39
+ def _extract_quotes(text: str) -> set[str]:
40
+ return {m.group(1).strip().lower() for m in _QUOTED.finditer(text)}
41
+
42
+
43
+ def _extract_propers(text: str) -> set[str]:
44
+ """Capitalized multi-word / mid-sentence names (skip sentence starts)."""
45
+ found: set[str] = set()
46
+ for m in _PROPER.finditer(text):
47
+ start = m.start()
48
+ # Skip if at start or after sentence boundary (likely sentence capitalization)
49
+ if start == 0:
50
+ continue
51
+ prev = text[start - 1]
52
+ if prev in ".!?\n":
53
+ continue
54
+ found.add(m.group(1))
55
+ return found
56
+
57
+
58
+ def _length_ok(original: str, candidate: str) -> bool:
59
+ ow = max(1, len(original.split()))
60
+ cw = len(candidate.split())
61
+ if cw < max(3, ow // 3):
62
+ return False
63
+ if cw > int(ow * 2.2) + 8:
64
+ return False
65
+ return True
66
+
67
+
68
+ def _meaning_score(original: str, candidate: str) -> float | None:
69
+ """MiniLM cosine if available; None if model missing."""
70
+ try:
71
+ from app.pipeline.minilm import _cosine, _embed_texts, _ensure_model
72
+
73
+ if _ensure_model() is None:
74
+ return None
75
+ vecs = _embed_texts([original, candidate])
76
+ if len(vecs) < 2:
77
+ return None
78
+ return float(_cosine(vecs[0], vecs[1]))
79
+ except Exception:
80
+ return None
81
+
82
+
83
+ def validate_candidate(
84
+ original: str,
85
+ candidate: str,
86
+ *,
87
+ min_meaning: float = 0.72,
88
+ max_surface: float = 0.97,
89
+ min_surface: float = 0.25,
90
+ ) -> ValidationResult:
91
+ """Return whether a candidate is safe to keep as a rewrite of `original`."""
92
+ reasons: list[str] = []
93
+ o = (original or "").strip()
94
+ c = (candidate or "").strip()
95
+ if not o or not c:
96
+ return ValidationResult(False, ["empty"], 0.0, 0.0)
97
+
98
+ if c == o:
99
+ return ValidationResult(False, ["identical"], 1.0, 1.0)
100
+
101
+ if not polarity_safe(o, c):
102
+ reasons.append("polarity")
103
+
104
+ if not _length_ok(o, c):
105
+ reasons.append("length")
106
+
107
+ surf = _surface_sim(o, c)
108
+ if surf >= max_surface:
109
+ reasons.append("too_similar")
110
+ if surf < min_surface and len(o.split()) >= 8:
111
+ reasons.append("too_divergent")
112
+
113
+ o_nums, c_nums = _extract_numbers(o), _extract_numbers(c)
114
+ if o_nums and not o_nums.issubset(c_nums):
115
+ # Allow reformatting like 1,000 vs 1000 by normalizing commas
116
+ o_norm = {n.replace(",", "") for n in o_nums}
117
+ c_norm = {n.replace(",", "") for n in c_nums}
118
+ if not o_norm.issubset(c_norm):
119
+ reasons.append("numbers")
120
+
121
+ o_quotes = _extract_quotes(o)
122
+ c_quotes = _extract_quotes(c)
123
+ if o_quotes and not o_quotes.issubset(c_quotes):
124
+ reasons.append("quotes")
125
+
126
+ for name in _extract_propers(o):
127
+ if name not in c and name.lower() not in c.lower():
128
+ reasons.append(f"entity:{name}")
129
+ break
130
+
131
+ meaning = _meaning_score(o, c)
132
+ if meaning is None:
133
+ meaning = 1.0 if not reasons else 0.5
134
+ elif meaning < min_meaning:
135
+ reasons.append(f"meaning:{meaning:.2f}")
136
+
137
+ return ValidationResult(
138
+ ok=not reasons,
139
+ reasons=reasons,
140
+ meaning=float(meaning),
141
+ surface_sim=surf,
142
+ )
143
+
144
+
145
+ def filter_valid_candidates(
146
+ original: str,
147
+ candidates: list[str],
148
+ *,
149
+ min_meaning: float = 0.72,
150
+ ) -> list[tuple[str, ValidationResult]]:
151
+ """Deduplicate and keep only candidates that pass validation."""
152
+ seen: set[str] = set()
153
+ out: list[tuple[str, ValidationResult]] = []
154
+ for raw in candidates:
155
+ text = re.sub(r"\s+", " ", (raw or "").strip())
156
+ if not text:
157
+ continue
158
+ key = text.lower()
159
+ if key in seen:
160
+ continue
161
+ seen.add(key)
162
+ result = validate_candidate(original, text, min_meaning=min_meaning)
163
+ if result.ok:
164
+ out.append((text, result))
165
+ return out
app/pipeline/generative.py CHANGED
@@ -1,11 +1,11 @@
1
- """Aggressive FLAN-T5 paraphrase for ML polish (main rewrite path).
2
 
3
- When ML polish is on and the model loads:
4
- 1) This module drafts a clearly reworded, tone-aware paraphrase
5
- 2) Classical rules lightly polish (scrub / register)
6
- 3) MiniLM only blocks severe meaning drift
7
 
8
- Lazy-loaded; missing torch/transformers → callers skip to rules-only.
9
  """
10
 
11
  from __future__ import annotations
@@ -13,17 +13,16 @@ from __future__ import annotations
13
  import logging
14
  import re
15
  import threading
16
- from difflib import SequenceMatcher
17
  from typing import Any
18
 
19
  from app.config import (
20
- GENERATIVE_MAX_SENTENCES,
21
  GENERATIVE_MODEL,
22
  GENERATIVE_POLISH_ENABLED,
23
  ML_POLISH_AVAILABLE_DEFAULT,
24
  )
25
- from app.pipeline.normalize import split_paragraphs, split_sentences_regex
26
- from app.pipeline.nlp import get_nlp
27
  from app.pipeline.tones import normalize_tone
28
 
29
  logger = logging.getLogger("plainrewrite.generative")
@@ -34,43 +33,48 @@ _model: Any = None
34
  _failed = False
35
  _backend: str | None = None
36
 
37
- # Demand visible rewording — FLAN-T5 often copies unless pushed hard.
38
  _TONE_PROMPTS: dict[str, str] = {
39
  "Casual": (
40
- "Paraphrase the sentence using different words and a casual spoken style. "
41
- "Change sentence structure if helpful. Keep the same facts. "
42
- "Do not copy phrases verbatim.\n"
43
- "Input: {text}\n"
44
- "Paraphrase:"
 
45
  ),
46
  "Formal": (
47
- "Paraphrase the sentence using different words and a formal professional style. "
48
- "Change sentence structure if helpful. Keep the same facts. "
49
- "Do not copy phrases verbatim.\n"
50
- "Input: {text}\n"
51
- "Paraphrase:"
 
52
  ),
53
  "Academic": (
54
- "Paraphrase the sentence using different words and a clear academic style. "
55
- "Change sentence structure if helpful. Keep the same facts. "
56
- "Do not copy phrases verbatim.\n"
57
- "Input: {text}\n"
58
- "Paraphrase:"
 
59
  ),
60
  "Neutral": (
61
- "Paraphrase the sentence using different words while keeping the same meaning. "
62
- "Change wording and structure clearly. Do not copy phrases verbatim.\n"
63
- "Input: {text}\n"
64
- "Paraphrase:"
 
 
 
 
65
  ),
66
  }
67
 
68
  _RETRY_PROMPT = (
69
- "Write a clearly different paraphrase of this sentence. "
70
- "Replace most content words with synonyms and reorder clauses. "
71
- "Keep the meaning. Tone: {tone}.\n"
72
- "Input: {text}\n"
73
- "New paraphrase:"
74
  )
75
 
76
 
@@ -117,17 +121,17 @@ def _ensure_model():
117
  from transformers import AutoModelForSeq2SeqLM, AutoTokenizer
118
 
119
  name = GENERATIVE_MODEL
120
- logger.info("Loading generative polish model %s …", name)
121
  _tokenizer = AutoTokenizer.from_pretrained(name)
122
  _model = AutoModelForSeq2SeqLM.from_pretrained(name)
123
  _model.eval()
124
  _model.to("cpu")
125
  _backend = f"transformers:{name}"
126
  _ = torch.__version__
127
- logger.info("Generative polish ready (%s)", _backend)
128
  return _model
129
  except Exception as exc:
130
- logger.warning("Generative polish unavailable: %s", exc)
131
  _failed = True
132
  _tokenizer = None
133
  _model = None
@@ -135,112 +139,47 @@ def _ensure_model():
135
  return None
136
 
137
 
138
- def _split_sents(text: str) -> list[str]:
139
- nlp = get_nlp()
140
- if nlp is not None:
141
- return [s.text.strip() for s in nlp(text).sents if s.text.strip()]
142
- return [s for s in split_sentences_regex(text) if s.strip()]
143
-
144
-
145
- def _sim(a: str, b: str) -> float:
146
- return SequenceMatcher(None, a.lower().strip(), b.lower().strip()).ratio()
147
-
148
-
149
  def _clean_gen_text(text: str) -> str:
150
  text = re.sub(r"\s+", " ", (text or "").strip())
151
  if not text:
152
  return ""
153
  low = text.lower()
154
- for prefix in ("paraphrase:", "rewrite:", "new paraphrase:", "input:", "sentence:", "output:"):
 
 
 
 
 
 
 
 
155
  if low.startswith(prefix):
156
  text = text[len(prefix) :].strip()
157
  low = text.lower()
158
  return text
159
 
160
 
161
- def _gen_kwargs(strength: int, *, aggressive: bool = False) -> dict[str, Any]:
162
- """Sampling-heavy decode so paraphrases diverge from the source."""
163
  strength = max(0, min(2, int(strength)))
164
- if aggressive:
165
- return {
166
- "do_sample": True,
167
- "num_beams": 1,
168
- "temperature": 1.15,
169
- "top_p": 0.92,
170
- "top_k": 50,
171
- "max_new_tokens": 160,
172
- "no_repeat_ngram_size": 3,
173
- "num_return_sequences": 3,
174
- }
175
  return {
176
- 0: {
177
- "do_sample": True,
178
- "num_beams": 1,
179
- "temperature": 0.85,
180
- "top_p": 0.9,
181
- "top_k": 40,
182
- "max_new_tokens": 96,
183
- "no_repeat_ngram_size": 3,
184
- "num_return_sequences": 2,
185
- },
186
- 1: {
187
- "do_sample": True,
188
- "num_beams": 1,
189
- "temperature": 1.0,
190
- "top_p": 0.92,
191
- "top_k": 50,
192
- "max_new_tokens": 128,
193
- "no_repeat_ngram_size": 3,
194
- "num_return_sequences": 3,
195
- },
196
- 2: {
197
- "do_sample": True,
198
- "num_beams": 1,
199
- "temperature": 1.15,
200
- "top_p": 0.95,
201
- "top_k": 60,
202
- "max_new_tokens": 160,
203
- "no_repeat_ngram_size": 3,
204
- "num_return_sequences": 4,
205
- },
206
- }.get(
207
- strength,
208
- {
209
- "do_sample": True,
210
- "num_beams": 1,
211
- "temperature": 1.0,
212
- "top_p": 0.92,
213
- "max_new_tokens": 128,
214
- "num_return_sequences": 3,
215
- },
216
- )
217
-
218
-
219
- def _pick_most_different(original: str, candidates: list[str], *, max_sim: float) -> str:
220
- """Prefer the candidate farthest from the original that still looks like a sentence."""
221
- best: str | None = None
222
- best_score = 2.0
223
- for raw in candidates:
224
- c = _clean_gen_text(raw)
225
- if len(c) < 3:
226
- continue
227
- if len(c.split()) < max(3, len(original.split()) // 4):
228
- continue
229
- score = _sim(original, c)
230
- if score < best_score:
231
- best_score = score
232
- best = c
233
- if best is None:
234
- return original
235
- # Accept best even if above max_sim when it's clearly not identical
236
- if best_score >= 0.97:
237
- return original
238
- if best_score > max_sim and best_score > 0.92:
239
- return original
240
- return best
241
-
242
-
243
- def _generate_once(prompt: str, strength: int, *, aggressive: bool) -> list[str]:
244
  model = _ensure_model()
245
  if model is None or _tokenizer is None:
246
  return []
@@ -251,47 +190,79 @@ def _generate_once(prompt: str, strength: int, *, aggressive: bool) -> list[str]
251
  prompt,
252
  return_tensors="pt",
253
  truncation=True,
254
- max_length=384,
255
  )
256
- kwargs = _gen_kwargs(strength, aggressive=aggressive)
257
  with torch.no_grad():
258
  out_ids = model.generate(**inputs, **kwargs)
259
- texts: list[str] = []
260
- for row in out_ids:
261
- texts.append(_tokenizer.decode(row, skip_special_tokens=True))
262
- return texts
263
  except Exception as exc:
264
  logger.warning("Generative decode failed: %s", exc)
265
  return []
266
 
267
 
268
- def _paraphrase_sentence(
269
- sentence: str,
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
270
  tone: str,
271
  strength: int,
272
  *,
273
- max_sim: float = 0.88,
274
  ) -> str:
275
- """Return a paraphrase that differs enough from the source, or the source."""
276
  model = _ensure_model()
277
  if model is None or _tokenizer is None:
278
- return sentence
 
 
279
 
280
  tone_l = normalize_tone(tone)
281
- prompt = _TONE_PROMPTS.get(tone_l, _TONE_PROMPTS["Neutral"]).format(text=sentence.strip())
282
- cands = _generate_once(prompt, strength, aggressive=False)
283
- picked = _pick_most_different(sentence, cands, max_sim=max_sim)
284
-
285
- # Retry harder if still too similar
286
- if picked == sentence or _sim(sentence, picked) > max_sim:
287
- retry = _RETRY_PROMPT.format(tone=tone_l.lower(), text=sentence.strip())
288
- cands2 = _generate_once(retry, min(2, strength + 1), aggressive=True)
289
- picked2 = _pick_most_different(sentence, cands2, max_sim=max_sim)
290
- if picked2 != sentence and _sim(sentence, picked2) < _sim(sentence, picked):
291
- picked = picked2
292
-
293
- if picked == sentence or _sim(sentence, picked) > 0.97:
294
- return sentence
 
 
 
 
 
 
 
 
 
295
  return picked
296
 
297
 
@@ -301,11 +272,14 @@ def generative_paraphrase(
301
  tone: str = "Neutral",
302
  strength: int = 1,
303
  max_sim: float = 0.88,
 
304
  ) -> str | None:
305
  """
306
- Aggressive sentence-wise paraphrase with FLAN-T5.
307
  Returns None if the model is unavailable.
 
308
  """
 
309
  if not text.strip():
310
  return None
311
  if _ensure_model() is None:
@@ -313,50 +287,42 @@ def generative_paraphrase(
313
 
314
  tone = normalize_tone(tone)
315
  strength = max(0, min(2, int(strength)))
316
- # ML polish defaults to at least Normal generative force
317
  gen_strength = max(1, strength)
318
 
319
  paras = split_paragraphs(text)
320
  out_paras: list[str] = []
321
- # 0 / unset = entire uploaded document; positive = optional safety cap
322
- budget = int(GENERATIVE_MAX_SENTENCES)
323
  unlimited = budget <= 0
324
  used = 0
325
  changed_n = 0
326
 
327
  for para in paras:
328
- sents = _split_sents(para)
329
- if not sents:
330
  continue
331
- new_sents: list[str] = []
332
- for sent in sents:
 
333
  if not unlimited and used >= budget:
334
- new_sents.append(sent)
335
- continue
336
- if len(sent.split()) < 3:
337
- new_sents.append(sent)
338
  continue
339
- nxt = _paraphrase_sentence(sent, tone, gen_strength, max_sim=max_sim)
 
 
340
  used += 1
341
- if nxt.strip() != sent.strip() and _sim(sent, nxt) <= max_sim:
342
- changed_n += 1
343
- new_sents.append(nxt)
344
- elif nxt.strip() != sent.strip():
345
- # Accept mild change over none
346
  changed_n += 1
347
- new_sents.append(nxt)
348
- else:
349
- new_sents.append(sent)
350
- out_paras.append(" ".join(new_sents))
351
 
352
  if not out_paras:
353
  return None
354
  result = "\n\n".join(p for p in out_paras if p)
355
  logger.info(
356
- "Generative paraphrase: %s/%s sentences changed (strength=%s, budget=%s)",
357
  changed_n,
358
  used,
359
  gen_strength,
 
360
  "all" if unlimited else budget,
361
  )
362
  return result
@@ -366,7 +332,11 @@ def warm_generative() -> bool:
366
  try:
367
  ok = generative_available()
368
  if ok:
369
- _paraphrase_sentence("The system works well for most users.", "Neutral", 1)
 
 
 
 
370
  return ok
371
  except Exception as exc:
372
  logger.warning("Generative warm failed: %s", exc)
 
1
+ """Paragraph-level generative rewrite (quality-first path).
2
 
3
+ Pipeline when models load:
4
+ 1) Draft several paraphrases per paragraph (seq2seq)
5
+ 2) Validate polarity / facts / meaning
6
+ 3) Rank survivors never pick "most different" alone
7
 
8
+ Lazy-loaded; missing torch/transformers → callers fall back to classical rules.
9
  """
10
 
11
  from __future__ import annotations
 
13
  import logging
14
  import re
15
  import threading
 
16
  from typing import Any
17
 
18
  from app.config import (
19
+ GENERATIVE_MAX_PARAGRAPHS,
20
  GENERATIVE_MODEL,
21
  GENERATIVE_POLISH_ENABLED,
22
  ML_POLISH_AVAILABLE_DEFAULT,
23
  )
24
+ from app.pipeline.candidate_ranker import pick_best_candidate
25
+ from app.pipeline.normalize import split_paragraphs
26
  from app.pipeline.tones import normalize_tone
27
 
28
  logger = logging.getLogger("plainrewrite.generative")
 
33
  _failed = False
34
  _backend: str | None = None
35
 
 
36
  _TONE_PROMPTS: dict[str, str] = {
37
  "Casual": (
38
+ "Rewrite the paragraph in clear casual English.\n"
39
+ "Keep every claim and the same polarity. Do not flip negatives or antonyms.\n"
40
+ "Preserve names, numbers, dates, and quotations. Correct grammar.\n"
41
+ "Improve flow; do not invent facts. Return only the rewritten paragraph.\n\n"
42
+ "Paragraph:\n{text}\n\n"
43
+ "Rewrite:"
44
  ),
45
  "Formal": (
46
+ "Rewrite the paragraph in clear formal professional English.\n"
47
+ "Keep every claim and the same polarity. Do not flip negatives or antonyms.\n"
48
+ "Preserve names, numbers, dates, and quotations. Correct grammar.\n"
49
+ "Improve flow; do not invent facts. Return only the rewritten paragraph.\n\n"
50
+ "Paragraph:\n{text}\n\n"
51
+ "Rewrite:"
52
  ),
53
  "Academic": (
54
+ "Rewrite the paragraph in clear academic English.\n"
55
+ "Keep every claim and the same polarity. Do not flip negatives or antonyms.\n"
56
+ "Preserve names, numbers, dates, and quotations. Correct grammar.\n"
57
+ "Improve flow; do not invent facts. Return only the rewritten paragraph.\n\n"
58
+ "Paragraph:\n{text}\n\n"
59
+ "Rewrite:"
60
  ),
61
  "Neutral": (
62
+ "Rewrite the paragraph in clear natural English.\n"
63
+ "Keep every claim and the same polarity (e.g. unhealthy stays unhealthy; "
64
+ "don't realize stays a lack of awareness).\n"
65
+ "Preserve names, numbers, dates, and quotations. Correct grammar.\n"
66
+ "Improve sentence structure and flow. Do not invent facts.\n"
67
+ "Return only the rewritten paragraph.\n\n"
68
+ "Paragraph:\n{text}\n\n"
69
+ "Rewrite:"
70
  ),
71
  }
72
 
73
  _RETRY_PROMPT = (
74
+ "Rewrite this paragraph with different wording but the exact same meaning "
75
+ "and polarity. Do not add or remove facts. Tone: {tone}.\n\n"
76
+ "Paragraph:\n{text}\n\n"
77
+ "Rewrite:"
 
78
  )
79
 
80
 
 
121
  from transformers import AutoModelForSeq2SeqLM, AutoTokenizer
122
 
123
  name = GENERATIVE_MODEL
124
+ logger.info("Loading generative rewrite model %s …", name)
125
  _tokenizer = AutoTokenizer.from_pretrained(name)
126
  _model = AutoModelForSeq2SeqLM.from_pretrained(name)
127
  _model.eval()
128
  _model.to("cpu")
129
  _backend = f"transformers:{name}"
130
  _ = torch.__version__
131
+ logger.info("Generative rewrite ready (%s)", _backend)
132
  return _model
133
  except Exception as exc:
134
+ logger.warning("Generative rewrite unavailable: %s", exc)
135
  _failed = True
136
  _tokenizer = None
137
  _model = None
 
139
  return None
140
 
141
 
 
 
 
 
 
 
 
 
 
 
 
142
  def _clean_gen_text(text: str) -> str:
143
  text = re.sub(r"\s+", " ", (text or "").strip())
144
  if not text:
145
  return ""
146
  low = text.lower()
147
+ for prefix in (
148
+ "rewrite:",
149
+ "paraphrase:",
150
+ "new paraphrase:",
151
+ "input:",
152
+ "paragraph:",
153
+ "sentence:",
154
+ "output:",
155
+ ):
156
  if low.startswith(prefix):
157
  text = text[len(prefix) :].strip()
158
  low = text.lower()
159
  return text
160
 
161
 
162
+ def _gen_kwargs(strength: int, *, n_return: int | None = None) -> dict[str, Any]:
 
163
  strength = max(0, min(2, int(strength)))
164
+ base = {
165
+ 0: {"temperature": 0.75, "top_p": 0.9, "top_k": 40, "max_new_tokens": 192, "n": 3},
166
+ 1: {"temperature": 0.9, "top_p": 0.92, "top_k": 50, "max_new_tokens": 256, "n": 4},
167
+ 2: {"temperature": 1.0, "top_p": 0.94, "top_k": 55, "max_new_tokens": 320, "n": 5},
168
+ }[strength]
169
+ n = n_return if n_return is not None else base["n"]
 
 
 
 
 
170
  return {
171
+ "do_sample": True,
172
+ "num_beams": 1,
173
+ "temperature": base["temperature"],
174
+ "top_p": base["top_p"],
175
+ "top_k": base["top_k"],
176
+ "max_new_tokens": base["max_new_tokens"],
177
+ "no_repeat_ngram_size": 3,
178
+ "num_return_sequences": max(2, min(int(n), 5)),
179
+ }
180
+
181
+
182
+ def _generate_once(prompt: str, strength: int, *, n_return: int | None = None) -> list[str]:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
183
  model = _ensure_model()
184
  if model is None or _tokenizer is None:
185
  return []
 
190
  prompt,
191
  return_tensors="pt",
192
  truncation=True,
193
+ max_length=512,
194
  )
195
+ kwargs = _gen_kwargs(strength, n_return=n_return)
196
  with torch.no_grad():
197
  out_ids = model.generate(**inputs, **kwargs)
198
+ return [_tokenizer.decode(row, skip_special_tokens=True) for row in out_ids]
 
 
 
199
  except Exception as exc:
200
  logger.warning("Generative decode failed: %s", exc)
201
  return []
202
 
203
 
204
+ def _chunk_long_paragraph(para: str, max_words: int = 180) -> list[str]:
205
+ """Split oversized paragraphs into sentence groups for model context limits."""
206
+ words = para.split()
207
+ if len(words) <= max_words:
208
+ return [para]
209
+ # Prefer sentence boundaries
210
+ parts = re.split(r"(?<=[.!?])\s+", para.strip())
211
+ chunks: list[str] = []
212
+ buf: list[str] = []
213
+ count = 0
214
+ for sent in parts:
215
+ w = len(sent.split())
216
+ if buf and count + w > max_words:
217
+ chunks.append(" ".join(buf))
218
+ buf = [sent]
219
+ count = w
220
+ else:
221
+ buf.append(sent)
222
+ count += w
223
+ if buf:
224
+ chunks.append(" ".join(buf))
225
+ return chunks or [para]
226
+
227
+
228
+ def _paraphrase_paragraph(
229
+ paragraph: str,
230
  tone: str,
231
  strength: int,
232
  *,
233
+ min_meaning: float = 0.72,
234
  ) -> str:
235
+ """Generate candidates for one paragraph; return best validated rewrite or original."""
236
  model = _ensure_model()
237
  if model is None or _tokenizer is None:
238
+ return paragraph
239
+ if len(paragraph.split()) < 3:
240
+ return paragraph
241
 
242
  tone_l = normalize_tone(tone)
243
+ prompt = _TONE_PROMPTS.get(tone_l, _TONE_PROMPTS["Neutral"]).format(text=paragraph.strip())
244
+ raw = _generate_once(prompt, strength)
245
+ cands = [_clean_gen_text(x) for x in raw]
246
+
247
+ # Second pass if nothing useful
248
+ picked = pick_best_candidate(
249
+ paragraph,
250
+ cands,
251
+ tone=tone_l,
252
+ min_meaning=min_meaning,
253
+ fallback=paragraph,
254
+ )
255
+ if picked == paragraph:
256
+ retry = _RETRY_PROMPT.format(tone=tone_l.lower(), text=paragraph.strip())
257
+ raw2 = _generate_once(retry, min(2, strength + 1), n_return=5)
258
+ cands2 = [_clean_gen_text(x) for x in raw2]
259
+ picked = pick_best_candidate(
260
+ paragraph,
261
+ cands + cands2,
262
+ tone=tone_l,
263
+ min_meaning=min_meaning,
264
+ fallback=paragraph,
265
+ )
266
  return picked
267
 
268
 
 
272
  tone: str = "Neutral",
273
  strength: int = 1,
274
  max_sim: float = 0.88,
275
+ min_meaning: float = 0.72,
276
  ) -> str | None:
277
  """
278
+ Quality-first paragraph paraphrase.
279
  Returns None if the model is unavailable.
280
+ `max_sim` kept for API compatibility (ranking handles similarity).
281
  """
282
+ del max_sim # ranking/validation supersede surface-only max_sim
283
  if not text.strip():
284
  return None
285
  if _ensure_model() is None:
 
287
 
288
  tone = normalize_tone(tone)
289
  strength = max(0, min(2, int(strength)))
 
290
  gen_strength = max(1, strength)
291
 
292
  paras = split_paragraphs(text)
293
  out_paras: list[str] = []
294
+ budget = int(GENERATIVE_MAX_PARAGRAPHS)
 
295
  unlimited = budget <= 0
296
  used = 0
297
  changed_n = 0
298
 
299
  for para in paras:
300
+ if not para.strip():
 
301
  continue
302
+ chunks = _chunk_long_paragraph(para)
303
+ new_chunks: list[str] = []
304
+ for chunk in chunks:
305
  if not unlimited and used >= budget:
306
+ new_chunks.append(chunk)
 
 
 
307
  continue
308
+ nxt = _paraphrase_paragraph(
309
+ chunk, tone, gen_strength, min_meaning=min_meaning
310
+ )
311
  used += 1
312
+ if nxt.strip() != chunk.strip():
 
 
 
 
313
  changed_n += 1
314
+ new_chunks.append(nxt)
315
+ out_paras.append(" ".join(new_chunks))
 
 
316
 
317
  if not out_paras:
318
  return None
319
  result = "\n\n".join(p for p in out_paras if p)
320
  logger.info(
321
+ "Generative paraphrase: %s/%s units changed (strength=%s, model=%s, budget=%s)",
322
  changed_n,
323
  used,
324
  gen_strength,
325
+ GENERATIVE_MODEL,
326
  "all" if unlimited else budget,
327
  )
328
  return result
 
332
  try:
333
  ok = generative_available()
334
  if ok:
335
+ _paraphrase_paragraph(
336
+ "The system works well for most users and saves time.",
337
+ "Neutral",
338
+ 1,
339
+ )
340
  return ok
341
  except Exception as exc:
342
  logger.warning("Generative warm failed: %s", exc)
app/pipeline/grammar_fix.py CHANGED
@@ -95,6 +95,28 @@ def _fix_mass_nouns(text: str) -> str:
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)"
@@ -285,11 +307,12 @@ def correct_text(text: str, language: str | None = None) -> str:
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)
 
95
  return pattern.sub(repl, text)
96
 
97
 
98
+ def _fix_count_noun_slips(text: str) -> str:
99
+ """Fix common ESL count/mass slips that aren't full plurals."""
100
+ # "enough times" (duration) → "enough time"
101
+ text = re.sub(r"\benough\s+times\b", "enough time", text, flags=re.I)
102
+ # "Eating X are" / "X foods are becoming" → singular verb with plural subject of gerund
103
+ text = re.sub(
104
+ r"\b(Eating\s+(?:fast\s+)?foods)\s+are\b",
105
+ r"\1 is",
106
+ text,
107
+ flags=re.I,
108
+ )
109
+ # "living unhealthy life" → "living an unhealthy life"
110
+ text = re.sub(
111
+ r"\b(living|live|lived|leads?|led)\s+(unhealthy|healthy|better|good|bad)\s+life\b",
112
+ lambda m: f"{m.group(1)} {'an' if m.group(2).lower()[0] in 'aeiou' else 'a'} "
113
+ f"{m.group(2)} life",
114
+ text,
115
+ flags=re.I,
116
+ )
117
+ return text
118
+
119
+
120
  def _fix_do_support(text: str) -> str:
121
  """`don't realizes` → `don't realize`; `does works` → `does work`."""
122
  aux = r"(do|does|did|don't|doesn't|didn't|do not|does not|did not)"
 
307
  try:
308
  out = _apply_languagetool(text, language)
309
  out = _fix_mass_nouns(out)
310
+ out = _fix_count_noun_slips(out)
311
  out = _fix_do_support(out)
312
  if get_nlp() is not None:
313
  out = _fix_agreement_spacy(out)
314
+ # Regex pronoun patterns catch cases spaCy misses (e.g. "how much it affect").
315
+ out = _fix_agreement_regex(out)
316
  return out
317
  except Exception as exc: # noqa: BLE001
318
  logger.warning("correct_text failed, returning original: %s", exc)
app/pipeline/meaning_safety.py ADDED
@@ -0,0 +1,101 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Lightweight meaning-safety checks for rewrite candidates.
2
+
3
+ Used by FLAN-T5 candidate picking and MiniLM keep/revert so antonym flips
4
+ and negation reversals (unhealthy→healthy, don't realize→realize) are rejected.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import re
10
+
11
+ # Prefixed adjective flips that reverse sentiment / claim polarity.
12
+ _PREFIX_FLIPS: tuple[tuple[str, str], ...] = (
13
+ ("unhealthy", "healthy"),
14
+ ("unhappy", "happy"),
15
+ ("unsafe", "safe"),
16
+ ("unclear", "clear"),
17
+ ("unusual", "usual"),
18
+ ("unimportant", "important"),
19
+ ("unlikely", "likely"),
20
+ ("unable", "able"),
21
+ ("incorrect", "correct"),
22
+ ("incomplete", "complete"),
23
+ ("indirect", "direct"),
24
+ ("illegal", "legal"),
25
+ ("irresponsible", "responsible"),
26
+ ("dishonest", "honest"),
27
+ ("disloyal", "loyal"),
28
+ ("disagree", "agree"),
29
+ ("decrease", "increase"),
30
+ ("worsen", "improve"),
31
+ ("worse", "better"),
32
+ ("bad", "good"),
33
+ ("negative", "positive"),
34
+ ("harmful", "helpful"),
35
+ ("dangerous", "safe"),
36
+ )
37
+
38
+ # Content verbs whose negation must not flip.
39
+ _NEGATABLE_VERBS = (
40
+ "realize", "realise", "know", "understand", "notice", "see", "feel",
41
+ "want", "need", "like", "agree", "believe", "think", "have", "help",
42
+ "cause", "affect", "support", "allow", "permit", "prevent",
43
+ )
44
+
45
+ _NEG_BEFORE = re.compile(
46
+ r"\b(?:do|does|did|can|could|will|would|should|must|shall|may|might)\s+not\b|"
47
+ r"\b(?:don't|doesn't|didn't|can't|couldn't|won't|wouldn't|shouldn't|mustn't|"
48
+ r"isn't|aren't|wasn't|weren't|hasn't|haven't|hadn't|never|no)\b",
49
+ re.I,
50
+ )
51
+
52
+
53
+ def _contains_word(text: str, word: str) -> bool:
54
+ return bool(re.search(rf"\b{re.escape(word)}\b", text, flags=re.I))
55
+
56
+
57
+ def _negation_before_verb(text: str, verb: str) -> bool:
58
+ """True if a negation marker appears shortly before the verb lemma."""
59
+ # Match don't/does not/never + optional words + verb forms
60
+ pat = re.compile(
61
+ rf"(?:don't|doesn't|didn't|do\s+not|does\s+not|did\s+not|never|not)\s+"
62
+ rf"(?:\w+\s+){{0,2}}{re.escape(verb)}\w*",
63
+ re.I,
64
+ )
65
+ return bool(pat.search(text))
66
+
67
+
68
+ def polarity_safe(original: str, candidate: str) -> bool:
69
+ """Reject candidates that flip antonyms or strip/add negation on key verbs."""
70
+ if not original.strip() or not candidate.strip():
71
+ return False
72
+ o, c = original.lower(), candidate.lower()
73
+ if o == c:
74
+ return True
75
+
76
+ for a, b in _PREFIX_FLIPS:
77
+ # orig has A, cand has B without A → flipped
78
+ if _contains_word(o, a) and _contains_word(c, b) and not _contains_word(c, a):
79
+ return False
80
+ if _contains_word(o, b) and _contains_word(c, a) and not _contains_word(c, b):
81
+ return False
82
+
83
+ for verb in _NEGATABLE_VERBS:
84
+ o_neg = _negation_before_verb(o, verb)
85
+ c_neg = _negation_before_verb(c, verb)
86
+ o_has = bool(re.search(rf"\b{re.escape(verb)}\w*\b", o))
87
+ c_has = bool(re.search(rf"\b{re.escape(verb)}\w*\b", c))
88
+ if o_has and c_has and o_neg != c_neg:
89
+ return False
90
+
91
+ # Overall negation density shouldn't invert sharply on short sentences
92
+ o_negs = len(_NEG_BEFORE.findall(o))
93
+ c_negs = len(_NEG_BEFORE.findall(c))
94
+ if o_negs >= 1 and c_negs == 0 and len(o.split()) <= 40:
95
+ # Candidate dropped all negations — likely a flip
96
+ # Allow if original's only negation is in a protected idiom we can't check;
97
+ # still reject when a known flip verb was negated.
98
+ if any(_negation_before_verb(o, v) for v in _NEGATABLE_VERBS):
99
+ return False
100
+
101
+ return True
app/pipeline/ml_polish.py CHANGED
@@ -2,13 +2,15 @@
2
 
3
  from __future__ import annotations
4
 
 
5
  from app.pipeline.ml_context import mark_ml_used, ml_polish_enabled
6
  from app.pipeline.minilm import _cosine, _embed_texts, _ensure_model
7
  from app.pipeline.normalize import split_paragraphs, split_sentences_regex
8
  from app.pipeline.nlp import get_nlp
9
 
10
- # Default floor for rules-only path. Generative path uses a looser floor.
11
- _MIN_MEANING_KEEP = 0.68
 
12
 
13
 
14
  def _split_sents(text: str) -> list[str]:
@@ -29,8 +31,11 @@ def _meaning_ok(
29
  return False
30
  if original.strip() == candidate.strip():
31
  return True
 
 
32
  if _ensure_model() is None:
33
- return True # no model trust the rewrite
 
34
  vecs = _embed_texts([original, candidate])
35
  if len(vecs) < 2:
36
  return True
 
2
 
3
  from __future__ import annotations
4
 
5
+ from app.pipeline.meaning_safety import polarity_safe
6
  from app.pipeline.ml_context import mark_ml_used, ml_polish_enabled
7
  from app.pipeline.minilm import _cosine, _embed_texts, _ensure_model
8
  from app.pipeline.normalize import split_paragraphs, split_sentences_regex
9
  from app.pipeline.nlp import get_nlp
10
 
11
+ # Rules path. Generative path uses a slightly lower floor but still meaning-strict.
12
+ _MIN_MEANING_KEEP = 0.72
13
+ _MIN_MEANING_GENERATIVE = 0.70
14
 
15
 
16
  def _split_sents(text: str) -> list[str]:
 
31
  return False
32
  if original.strip() == candidate.strip():
33
  return True
34
+ if not polarity_safe(original, candidate):
35
+ return False
36
  if _ensure_model() is None:
37
+ # No MiniLM still enforce polarity; accept otherwise
38
+ return True
39
  vecs = _embed_texts([original, candidate])
40
  if len(vecs) < 2:
41
  return True
app/pipeline/orchestrator.py CHANGED
@@ -1,4 +1,8 @@
1
- """Orchestrate non-LLM rewrite pipeline (+ optional ML polish)."""
 
 
 
 
2
 
3
  from __future__ import annotations
4
 
@@ -8,7 +12,14 @@ import time
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
@@ -28,13 +39,13 @@ from app.pipeline.normalize import (
28
  word_count,
29
  )
30
  from app.pipeline.similarity import SimilarityResult, compare_similarity
31
- from app.pipeline.tones import normalize_tone
32
  from app.pipeline.synonym import rewrite_sentence_synonyms
33
  from app.pipeline.syntax_rewrite import (
34
  apply_tone_contractions,
35
  rewrite_paragraph_structure,
36
  )
37
  from app.pipeline.tone_style import apply_tone_style
 
38
 
39
  logger = logging.getLogger("plainrewrite")
40
  if not logger.handlers:
@@ -55,7 +66,6 @@ class RewriteResult:
55
 
56
 
57
  def _rng_for(text: str) -> random.Random:
58
- # Stable across processes (avoid PYTHONHASHSEED unpredictability)
59
  return random.Random(sum(ord(c) for c in text) * 2654435761 & 0xFFFFFFFF)
60
 
61
 
@@ -63,64 +73,103 @@ def _similarity_ratio(a: str, b: str) -> float:
63
  return SequenceMatcher(None, a.lower(), b.lower()).ratio()
64
 
65
 
66
- def _rewrite_paragraph(paragraph: str, tone: str, strength: int, rng: random.Random) -> str:
67
- # Tone/phrases first so structure operates on elevated text for Academic/Heavy
 
 
 
 
 
 
 
68
  styled = apply_tone_style(paragraph, tone, strength, rng)
69
  structured = rewrite_paragraph_structure(styled, strength, rng, tone=tone)
70
-
71
  nlp = get_nlp()
72
  if nlp is not None:
73
  sents = [s.text.strip() for s in nlp(structured).sents if s.text.strip()]
74
  else:
75
  sents = split_sentences_regex(structured)
76
-
77
  rewritten_sents = [
78
  rewrite_sentence_synonyms(s, strength, rng, tone=tone) for s in sents
79
  ]
80
  joined = " ".join(rewritten_sents)
81
  joined = scrub_phrases(joined)
82
- # Re-apply tone after scrub/synonym so register sticks
83
  joined = apply_tone_style(joined, tone, strength, rng)
84
  joined = apply_tone_contractions(joined, tone)
85
  return joined.strip()
86
 
87
 
88
- def _force_more_changes(text: str, tone: str, strength: int, rng: random.Random) -> str:
89
- """Stronger per-paragraph pass — preserves \\n\\n breaks."""
90
- bump = min(2, strength + 1)
91
- paras = split_paragraphs(text)
92
- out_paras: list[str] = []
93
- for para in paras:
94
- bumped = apply_tone_style(para, tone, bump, rng)
95
- # Re-run structure transforms at higher strength for more sentence shape change
96
- bumped = rewrite_paragraph_structure(bumped, bump, rng, tone=tone)
97
- nlp = get_nlp()
98
- if nlp is not None:
99
- sents = [s.text.strip() for s in nlp(bumped).sents if s.text.strip()]
100
- else:
101
- sents = split_sentences_regex(bumped)
102
- rewritten = [
103
- rewrite_sentence_synonyms(s, bump, rng, tone=tone, force_all_lexicon=True)
104
- for s in sents
105
- ]
106
- joined = scrub_phrases(" ".join(rewritten))
107
- joined = apply_tone_style(joined, tone, bump, rng)
108
- joined = apply_tone_contractions(joined, tone)
109
- out_paras.append(joined.strip())
110
- return "\n\n".join(p for p in out_paras if p)
111
-
112
-
113
- def _light_polish_after_gen(
114
- text: str,
115
  tone: str,
116
  strength: int,
117
  rng: random.Random,
 
 
118
  ) -> str:
119
- """After FLAN-T5, only scrub + register — avoid heavy synonym thrash that undoes the draft."""
120
- out = scrub_phrases(text)
121
- out = apply_tone_style(out, tone, strength, rng)
122
- out = apply_tone_contractions(out, tone)
123
- return tidy(out)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
124
 
125
 
126
  def rewrite_text(
@@ -132,21 +181,18 @@ def rewrite_text(
132
  ml_polish: bool = False,
133
  ) -> RewriteResult:
134
  """
135
- Classical rewrite by default.
136
 
137
- When ml_polish=True and models load:
138
- 1) Aggressive FLAN-T5 paraphrase (main rewrite)
139
- 2) Light rules polish (scrub / contractions / register)
140
- 3) MiniLM meaning guard vs original (loose floor keep T5 unless severe drift)
141
- If generative is unavailable → full classical rules + MiniLM ranking/guard.
142
  """
143
  started = time.perf_counter()
144
  original = normalize_whitespace(text or "")
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:
@@ -157,144 +203,133 @@ def rewrite_text(
157
 
158
  strength = max(0, min(2, int(strength)))
159
  tone = normalize_tone(tone)
160
- want_gen = bool(ml_polish) and generative_available()
161
  want_minilm = bool(ml_polish) and minilm_available()
162
- # When generative is the main path, skip MiniLM synonym ranking (it prefers near-copies).
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
- + "|"
170
- + str(strength)
171
- + ("|ml" if (want_gen or want_minilm) else "")
172
  )
173
 
174
  notes = ""
175
  used_gen_main = False
 
176
 
177
  if want_gen:
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,
185
  )
186
- if draft and draft.strip():
187
- working = tidy(draft)
188
  mark_gen_used()
189
  used_gen_main = True
190
- # Light register polish only — do not run full synonym/structure stack
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,
206
  )
207
- if draft2 and draft2.strip():
208
- rewritten = _light_polish_after_gen(tidy(draft2), tone, strength, rng)
 
 
 
 
 
 
209
  else:
210
- tip = "Generative polish produced no draft; falling back to full rules."
 
 
 
 
 
211
  notes = tip
212
  used_gen_main = False
213
 
214
  if not used_gen_main:
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")
281
  if used_gen_main:
282
- engine_bits.extend(["rules-light", "mechanics", "tone"])
283
  else:
284
- engine_bits.extend(["rules", "structure", "wordnet", "mechanics", "tone"])
285
  if want_minilm and ml_was_used():
286
  engine_bits.append("minilm")
 
287
  if ml_polish and not want_gen and not want_minilm:
288
  tip = (
289
  "ML polish requested but generative/MiniLM models are unavailable "
290
- "on this server (install torch+transformers and/or fastembed)."
291
  )
292
  notes = f"{notes} {tip}".strip() if notes else tip
293
  elif ml_polish and want_minilm and not want_gen:
294
- tip = "ML polish: MiniLM only (generative model unavailable — deploy torch+transformers)."
295
  notes = f"{notes} {tip}".strip() if notes else tip
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."
@@ -305,23 +340,22 @@ def rewrite_text(
305
  f"rewrite engine={engine} strength={strength} tone={tone} "
306
  f"words={word_count(original)}->{word_count(rewritten)} "
307
  f"changed={changed} similarity={ratio:.3f} "
308
- f"ml_polish={bool(ml_polish)} gen={want_gen} gen_main={used_gen_main} minilm={want_minilm}"
 
309
  )
310
  logger.info(msg)
311
  print(f"[plainrewrite] {msg}", flush=True)
312
 
313
- if ratio > 0.82:
314
- tip = (
315
- "Only small lexical shifts were available for this wording. "
316
- "Try Heavy strength, another tone, or ensure FLAN-T5 is loaded on the server."
317
- )
318
- notes = f"{notes} {tip}".strip() if notes else tip
319
  if not changed:
320
  tip = (
321
- "No rewrite produced. "
322
- "Enable ML polish with generative model, or try Heavy / another tone."
 
323
  )
324
  notes = f"{notes} {tip}".strip() if notes else tip
 
 
 
325
 
326
  return RewriteResult(
327
  text=rewritten,
 
1
+ """Orchestrate quality-first rewrite: grammar generative validate → grammar.
2
+
3
+ Classical lexicon/WordNet is demoted to offline fallback when generative is
4
+ unavailable or ML polish is off.
5
+ """
6
 
7
  from __future__ import annotations
8
 
 
12
  from dataclasses import dataclass
13
  from difflib import SequenceMatcher
14
 
15
+ from app.config import (
16
+ GENERATIVE_MODEL,
17
+ GENERATIVE_PRIMARY,
18
+ GRAMMAR_FIX_INPUT,
19
+ GRAMMAR_FIX_OUTPUT,
20
+ LEXICON_FALLBACK,
21
+ )
22
+ from app.pipeline.candidate_validator import validate_candidate
23
  from app.pipeline.generative import generative_available, generative_paraphrase
24
  from app.pipeline.grammar_fix import correct_text
25
  from app.pipeline.mechanics import enforce_length_budget, scrub_phrases, tidy
 
39
  word_count,
40
  )
41
  from app.pipeline.similarity import SimilarityResult, compare_similarity
 
42
  from app.pipeline.synonym import rewrite_sentence_synonyms
43
  from app.pipeline.syntax_rewrite import (
44
  apply_tone_contractions,
45
  rewrite_paragraph_structure,
46
  )
47
  from app.pipeline.tone_style import apply_tone_style
48
+ from app.pipeline.tones import normalize_tone
49
 
50
  logger = logging.getLogger("plainrewrite")
51
  if not logger.handlers:
 
66
 
67
 
68
  def _rng_for(text: str) -> random.Random:
 
69
  return random.Random(sum(ord(c) for c in text) * 2654435761 & 0xFFFFFFFF)
70
 
71
 
 
73
  return SequenceMatcher(None, a.lower(), b.lower()).ratio()
74
 
75
 
76
+ def _light_post_gen(text: str, tone: str) -> str:
77
+ """After generative rewrite: scrub fillers + contractions only no synonym thrash."""
78
+ out = scrub_phrases(text)
79
+ out = apply_tone_contractions(out, tone)
80
+ return tidy(out)
81
+
82
+
83
+ def _classical_paragraph(paragraph: str, tone: str, strength: int, rng: random.Random) -> str:
84
+ """Offline fallback: structure + curated lexicon (not the primary quality path)."""
85
  styled = apply_tone_style(paragraph, tone, strength, rng)
86
  structured = rewrite_paragraph_structure(styled, strength, rng, tone=tone)
 
87
  nlp = get_nlp()
88
  if nlp is not None:
89
  sents = [s.text.strip() for s in nlp(structured).sents if s.text.strip()]
90
  else:
91
  sents = split_sentences_regex(structured)
 
92
  rewritten_sents = [
93
  rewrite_sentence_synonyms(s, strength, rng, tone=tone) for s in sents
94
  ]
95
  joined = " ".join(rewritten_sents)
96
  joined = scrub_phrases(joined)
 
97
  joined = apply_tone_style(joined, tone, strength, rng)
98
  joined = apply_tone_contractions(joined, tone)
99
  return joined.strip()
100
 
101
 
102
+ def _classical_rewrite(
103
+ source: str,
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
104
  tone: str,
105
  strength: int,
106
  rng: random.Random,
107
+ *,
108
+ force: bool = False,
109
  ) -> str:
110
+ paragraphs = split_paragraphs(source)
111
+ out = [_classical_paragraph(p, tone, strength, rng) for p in paragraphs]
112
+ rewritten = tidy("\n\n".join(out))
113
+ rewritten = scrub_phrases(rewritten)
114
+ rewritten = apply_tone_style(rewritten, tone, strength, rng)
115
+ rewritten = apply_tone_contractions(rewritten, tone)
116
+ if force:
117
+ # Second-pass lexicon force for stubborn near-copies
118
+ bump = min(2, strength + 1)
119
+ paras = split_paragraphs(rewritten)
120
+ forced: list[str] = []
121
+ for para in paras:
122
+ bumped = apply_tone_style(para, tone, bump, rng)
123
+ bumped = rewrite_paragraph_structure(bumped, bump, rng, tone=tone)
124
+ nlp = get_nlp()
125
+ if nlp is not None:
126
+ sents = [s.text.strip() for s in nlp(bumped).sents if s.text.strip()]
127
+ else:
128
+ sents = split_sentences_regex(bumped)
129
+ rewritten_sents = [
130
+ rewrite_sentence_synonyms(
131
+ s, bump, rng, tone=tone, force_all_lexicon=True
132
+ )
133
+ for s in sents
134
+ ]
135
+ joined = scrub_phrases(" ".join(rewritten_sents))
136
+ joined = apply_tone_contractions(joined, tone)
137
+ forced.append(joined.strip())
138
+ rewritten = tidy("\n\n".join(p for p in forced if p))
139
+ return rewritten
140
+
141
+
142
+ def _accept_generative(source: str, draft: str) -> bool:
143
+ """Whole-document gate: polarity/meaning must hold vs grammar-corrected source."""
144
+ if not draft or not draft.strip():
145
+ return False
146
+
147
+ def _hard_fail(reasons: list[str]) -> bool:
148
+ for r in reasons:
149
+ if r == "polarity" or r in {"numbers", "quotes"}:
150
+ return True
151
+ if r.startswith("meaning") or r.startswith("entity"):
152
+ return True
153
+ return False
154
+
155
+ s_paras = split_paragraphs(source)
156
+ d_paras = split_paragraphs(draft)
157
+ if len(s_paras) == len(d_paras) and s_paras:
158
+ for sp, dp in zip(s_paras, d_paras):
159
+ if sp.strip() == dp.strip():
160
+ continue
161
+ v = validate_candidate(sp, dp, min_meaning=0.70)
162
+ if not v.ok and _hard_fail(v.reasons):
163
+ logger.info("Generative para rejected: %s", v.reasons)
164
+ return False
165
+ return True
166
+ v = validate_candidate(source, draft, min_meaning=0.70)
167
+ if v.ok or "identical" in v.reasons:
168
+ return True
169
+ if _hard_fail(v.reasons):
170
+ logger.info("Generative doc rejected: %s", v.reasons)
171
+ return False
172
+ return True
173
 
174
 
175
  def rewrite_text(
 
181
  ml_polish: bool = False,
182
  ) -> RewriteResult:
183
  """
184
+ Quality-first rewrite.
185
 
186
+ When ml_polish=True and generative model loads:
187
+ grammar paragraph generative (multi-candidate validate/rank) → light scrub → grammar
188
+ Otherwise (or if gen fails validation):
189
+ grammar classical lexicon fallback (if LEXICON_FALLBACK) grammar
 
190
  """
191
  started = time.perf_counter()
192
  original = normalize_whitespace(text or "")
193
  if not original:
194
  raise ValueError("Paste some text first.")
195
 
 
 
196
  source = original
197
  grammar_fixed_input = False
198
  if GRAMMAR_FIX_INPUT:
 
203
 
204
  strength = max(0, min(2, int(strength)))
205
  tone = normalize_tone(tone)
206
+ want_gen = bool(ml_polish) and GENERATIVE_PRIMARY and generative_available()
207
  want_minilm = bool(ml_polish) and minilm_available()
208
+ # Skip MiniLM synonym ranking on gen path (it prefers near-copies)
 
209
  set_ml_polish(want_minilm and not want_gen)
210
  rng = _rng_for(
211
+ source + "|" + tone + "|" + str(strength) + ("|ml" if (want_gen or want_minilm) else "")
 
 
 
 
 
212
  )
213
 
214
  notes = ""
215
  used_gen_main = False
216
+ rewritten = source
217
 
218
  if want_gen:
219
+ logger.info(
220
+ "Quality path: generative rewrite (%s, model=%s)…",
221
+ tone,
222
+ GENERATIVE_MODEL,
223
+ )
224
  draft = generative_paraphrase(
225
  source,
226
  tone=tone,
227
  strength=max(1, strength),
228
+ min_meaning=0.72,
229
  )
230
+ if draft and draft.strip() and _accept_generative(source, draft):
 
231
  mark_gen_used()
232
  used_gen_main = True
233
+ rewritten = _light_post_gen(tidy(draft), tone)
234
+ # If still nearly identical, one heavier generative pass
235
+ if _similarity_ratio(source, rewritten) > 0.92:
 
 
 
 
 
 
 
 
236
  draft2 = generative_paraphrase(
237
  source,
238
  tone=tone,
239
  strength=2,
240
+ min_meaning=0.70,
241
  )
242
+ if draft2 and draft2.strip() and _accept_generative(source, draft2):
243
+ rewritten = _light_post_gen(tidy(draft2), tone)
244
+ logger.info(
245
+ "Generative main path words %s→%s sim=%.3f",
246
+ word_count(source),
247
+ word_count(rewritten),
248
+ _similarity_ratio(source, rewritten),
249
+ )
250
  else:
251
+ tip = (
252
+ "Generative rewrite unavailable or failed validation; "
253
+ "using classical fallback."
254
+ if draft
255
+ else "Generative model produced no draft; using classical fallback."
256
+ )
257
  notes = tip
258
  used_gen_main = False
259
 
260
  if not used_gen_main:
 
261
  if want_minilm:
262
  set_ml_polish(True)
263
+ if LEXICON_FALLBACK or not ml_polish:
264
+ rewritten = _classical_rewrite(source, tone, strength, rng)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
265
  else:
266
+ # ML requested but gen failed and lexicon disabled → corrected source only
267
+ rewritten = source
268
+ tip = "No generative output and lexicon fallback disabled; returning grammar-corrected source."
269
+ notes = f"{notes} {tip}".strip() if notes else tip
270
+
271
+ # Meaning guard on classical path only
272
+ if want_minilm:
273
+ set_ml_polish(True)
274
  rewritten = apply_minilm_polish(source, rewritten, tone)
275
+ rewritten = tidy(rewritten)
276
+ rewritten = apply_tone_contractions(rewritten, tone)
277
+
278
+ ratio_tmp = _similarity_ratio(source, rewritten)
279
+ if ratio_tmp > 0.88 and LEXICON_FALLBACK:
280
+ logger.info("Classical near-copy (%.3f); stronger lexicon pass", ratio_tmp)
281
+ rewritten = _classical_rewrite(source, tone, strength, rng, force=True)
282
+ if want_minilm:
283
+ rewritten = apply_minilm_polish(source, rewritten, tone)
284
+ rewritten = apply_tone_contractions(rewritten, tone)
285
+ tip = "Applied stronger classical pass (first pass too similar)."
286
+ notes = f"{notes} {tip}".strip() if notes else tip
287
 
288
  rewritten = enforce_length_budget(source, rewritten, preserve_length)
289
  rewritten = tidy(rewritten)
290
 
291
+ # Final document-level validation: if gen output flipped meaning, revert to source
292
+ if used_gen_main and not _accept_generative(source, rewritten):
293
+ logger.info("Final generative output failed validation; reverting to corrected source")
294
+ rewritten = source
295
+ tip = "Generative output failed final meaning checks; returned grammar-corrected source."
 
 
 
 
 
 
296
  notes = f"{notes} {tip}".strip() if notes else tip
297
+ used_gen_main = False
 
 
 
 
 
 
 
 
 
 
 
298
 
 
299
  if GRAMMAR_FIX_OUTPUT:
300
  cleaned_out = correct_text(rewritten)
301
  if cleaned_out and cleaned_out.strip():
302
  rewritten = tidy(cleaned_out)
303
 
304
+ ratio = _similarity_ratio(source, rewritten)
305
  changed = rewritten.strip() != original.strip()
306
+
307
  engine_bits: list[str] = []
308
  if GRAMMAR_FIX_INPUT or GRAMMAR_FIX_OUTPUT:
309
  engine_bits.append("grammar")
310
  if gen_was_used():
311
+ engine_bits.append("generative")
312
+ engine_bits.append("validate")
313
+ engine_bits.append("rank")
314
  engine_bits.append("spacy" if spacy_available() else "regex-fallback")
315
  if used_gen_main:
316
+ engine_bits.extend(["scrub-light"])
317
  else:
318
+ engine_bits.extend(["rules", "structure", "lexicon-fallback", "mechanics", "tone"])
319
  if want_minilm and ml_was_used():
320
  engine_bits.append("minilm")
321
+
322
  if ml_polish and not want_gen and not want_minilm:
323
  tip = (
324
  "ML polish requested but generative/MiniLM models are unavailable "
325
+ "(install torch+transformers and/or fastembed; set GENERATIVE_MODEL)."
326
  )
327
  notes = f"{notes} {tip}".strip() if notes else tip
328
  elif ml_polish and want_minilm and not want_gen:
329
+ tip = "ML polish: MiniLM guard only (generative unavailable — check GENERATIVE_MODEL)."
330
  notes = f"{notes} {tip}".strip() if notes else tip
331
  elif ml_polish and want_gen and not want_minilm:
332
+ tip = "Quality path: generative + validation (MiniLM optional for scoring)."
333
  notes = f"{notes} {tip}".strip() if notes else tip
334
  if grammar_fixed_input:
335
  tip = "Corrected grammar in the source before rewriting."
 
340
  f"rewrite engine={engine} strength={strength} tone={tone} "
341
  f"words={word_count(original)}->{word_count(rewritten)} "
342
  f"changed={changed} similarity={ratio:.3f} "
343
+ f"ml_polish={bool(ml_polish)} gen={want_gen} gen_main={used_gen_main} "
344
+ f"model={GENERATIVE_MODEL if want_gen else '-'}"
345
  )
346
  logger.info(msg)
347
  print(f"[plainrewrite] {msg}", flush=True)
348
 
 
 
 
 
 
 
349
  if not changed:
350
  tip = (
351
+ "Output matched input after validation. "
352
+ "Try Heavy strength, enable ML polish with a loaded generative model, "
353
+ "or paste text with more rewritable content."
354
  )
355
  notes = f"{notes} {tip}".strip() if notes else tip
356
+ elif used_gen_main and ratio > 0.90:
357
+ tip = "Generative rewrite stayed close to the source (meaning-first ranking)."
358
+ notes = f"{notes} {tip}".strip() if notes else tip
359
 
360
  return RewriteResult(
361
  text=rewritten,
app/pipeline/sentence_transform.py CHANGED
@@ -322,9 +322,11 @@ def front_subordinate(
322
  if main.lower().startswith(linker.lower()):
323
  return None
324
  body = main[0].lower() + main[1:] if main and main[0].isupper() else main
325
- # Academic always prefers fronting; Formal usually; Casual often keeps clause at end
326
  if is_casual(tone) and rng.random() < 0.55:
327
  return None
 
 
328
  link = linker
329
  if is_academic(tone) and linker.lower() == "while":
330
  link = "Whilst"
 
322
  if main.lower().startswith(linker.lower()):
323
  return None
324
  body = main[0].lower() + main[1:] if main and main[0].isupper() else main
325
+ # Academic always prefers fronting; Formal usually; Casual/Neutral often keep clause at end
326
  if is_casual(tone) and rng.random() < 0.55:
327
  return None
328
+ if normalize_tone(tone) == "Neutral" and rng.random() < 0.70:
329
+ return None
330
  link = linker
331
  if is_academic(tone) and linker.lower() == "while":
332
  link = "Whilst"
app/pipeline/synonym.py CHANGED
@@ -29,6 +29,10 @@ _STOP_SWAP = {
29
  "after", "before", "during", "until", "since", "while", "among", "between",
30
  "under", "over", "about", "against", "upon", "onto", "into", "through",
31
  "across", "around", "without", "within", "beside", "behind", "above", "below",
 
 
 
 
32
  }
33
 
34
  # Closed-class grammar only — no domain word lists.
 
29
  "after", "before", "during", "until", "since", "while", "among", "between",
30
  "under", "over", "about", "against", "upon", "onto", "into", "through",
31
  "across", "around", "without", "within", "beside", "behind", "above", "below",
32
+ # Degree adverbs — WordNet turns very→rattling and wrecks collocations
33
+ "very", "really", "quite", "rather", "pretty", "fairly", "highly", "extremely",
34
+ # Temporal discourse — WordNet nowadays→today is pointless churn
35
+ "nowadays", "today", "tonight", "tomorrow", "yesterday", "now",
36
  }
37
 
38
  # Closed-class grammar only — no domain word lists.
requirements.txt CHANGED
@@ -9,7 +9,8 @@ python-multipart>=0.0.9
9
  httpx>=0.27.0
10
  PyJWT[crypto]>=2.8.0
11
  python-dotenv>=1.0.0
12
- # ML polish: MiniLM meaning guard (ONNX) + FLAN-T5-small generative draft (lazy-loaded)
 
13
  fastembed>=0.4.2
14
  torch>=2.2.0
15
  transformers>=4.40.0
 
9
  httpx>=0.27.0
10
  PyJWT[crypto]>=2.8.0
11
  python-dotenv>=1.0.0
12
+ # ML polish: MiniLM meaning scoring + seq2seq rewrite (lazy-loaded)
13
+ # Default model is flan-t5-base (override GENERATIVE_MODEL for larger GPUs)
14
  fastembed>=0.4.2
15
  torch>=2.2.0
16
  transformers>=4.40.0
scripts/test_meaning_fix.py ADDED
@@ -0,0 +1,69 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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.grammar_fix import correct_text
10
+ from app.pipeline.meaning_safety import polarity_safe
11
+ from app.pipeline.orchestrator import rewrite_text
12
+
13
+ # --- polarity ---
14
+ assert polarity_safe(
15
+ "people live an unhealthy life",
16
+ "people live a poor lifestyle",
17
+ )
18
+ assert not polarity_safe(
19
+ "people live an unhealthy life",
20
+ "people live a healthy life",
21
+ )
22
+ assert not polarity_safe(
23
+ "people don't realize how much it affects health",
24
+ "people realize how much it affects health",
25
+ )
26
+ assert not polarity_safe(
27
+ "people don't realize how much it affects health",
28
+ "causes people to realize how much it affects their health",
29
+ )
30
+ print("polarity OK")
31
+
32
+ # --- grammar ---
33
+ g = correct_text(
34
+ "Nowadays many peoples are living unhealthy life because they don't have enough times. "
35
+ "Eating fast foods are becoming very common and peoples don't realizes how much it affect their health."
36
+ )
37
+ gl = g.lower()
38
+ assert "peoples" not in gl
39
+ assert "enough times" not in gl
40
+ assert "enough time" in gl
41
+ assert "don't realize" in gl or "do not realize" in gl
42
+ assert "it affects" in gl
43
+ assert "fast foods is" in gl or "fast food" in gl
44
+ assert "an unhealthy life" in gl
45
+ print("grammar OK:")
46
+ print(g)
47
+
48
+ # --- classical rewrite ---
49
+ orig = (
50
+ "Nowadays many peoples are living unhealthy life because they don't have enough times. "
51
+ "Eating fast foods are becoming very common and peoples don't realizes how much it affect their health."
52
+ )
53
+ r = rewrite_text(orig, tone="Neutral", strength=1, preserve_length=True, ml_polish=False)
54
+ out = r.text.lower()
55
+ print("\nNeutral Normal:")
56
+ print(r.text)
57
+ assert "peoples" not in out
58
+ assert "adequate moments" not in out
59
+ assert "communities" not in out or "people" in out # communities banned as people swap
60
+ assert "rapid food" not in out
61
+ assert "highly usual" not in out
62
+ assert "don't realize" in out or "do not realize" in out
63
+ assert "affects" in out
64
+ assert "nowadays" in out # don't churn to today
65
+ assert "individuals" not in out # keep people
66
+ assert "enough time" in out
67
+ assert "an unhealthy life" in out
68
+ assert "very common" in out or "widespread" in out
69
+ print("\nALL ASSERTIONS PASSED")
scripts/test_quality_pipeline.py ADDED
@@ -0,0 +1,110 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Regression tests for quality-first rewrite pipeline (no GPU required)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import sys
6
+ from pathlib import Path
7
+
8
+ ROOT = Path(__file__).resolve().parent.parent
9
+ sys.path.insert(0, str(ROOT))
10
+
11
+ from app.pipeline.candidate_ranker import pick_best_candidate, score_candidate
12
+ from app.pipeline.candidate_validator import ValidationResult, validate_candidate
13
+ from app.pipeline.grammar_fix import correct_text
14
+ from app.pipeline.meaning_safety import polarity_safe
15
+ from app.pipeline.orchestrator import rewrite_text
16
+
17
+
18
+ def test_polarity() -> None:
19
+ assert polarity_safe(
20
+ "people live an unhealthy life",
21
+ "people live a poor lifestyle",
22
+ )
23
+ assert not polarity_safe(
24
+ "people live an unhealthy life",
25
+ "people live a healthy life",
26
+ )
27
+ assert not polarity_safe(
28
+ "people don't realize how much it affects health",
29
+ "causes people to realize how much it affects their health",
30
+ )
31
+ print("polarity OK")
32
+
33
+
34
+ def test_validator_rejects_flips() -> None:
35
+ orig = (
36
+ "Nowadays many people are living an unhealthy life because they don't have enough time. "
37
+ "Eating fast foods is becoming very common and people don't realize how much it affects their health."
38
+ )
39
+ bad = (
40
+ "Nowadays many people live a healthy life because they don't have time to live. "
41
+ "Eating fast food causes people to realize how much it affects their health."
42
+ )
43
+ v = validate_candidate(orig, bad, min_meaning=0.5)
44
+ assert not v.ok
45
+ assert "polarity" in v.reasons
46
+ print("validator rejects flips OK")
47
+
48
+
49
+ def test_ranker_prefers_faithful() -> None:
50
+ orig = "Students should complete the assignment before the deadline."
51
+ good = "Students ought to finish the assignment ahead of the deadline."
52
+ bad = "Students should ignore the assignment after the deadline."
53
+ # Force validation results
54
+ picked = pick_best_candidate(
55
+ orig,
56
+ [bad, good, orig],
57
+ tone="Neutral",
58
+ min_meaning=0.5,
59
+ fallback=orig,
60
+ )
61
+ # bad may fail polarity/meaning; good should win or fallback
62
+ assert "ignore" not in picked.lower()
63
+ print("ranker OK:", picked)
64
+
65
+
66
+ def test_grammar() -> None:
67
+ g = correct_text(
68
+ "Nowadays many peoples are living unhealthy life because they don't have enough times. "
69
+ "Eating fast foods are becoming very common and peoples don't realizes how much it affect their health."
70
+ )
71
+ gl = g.lower()
72
+ assert "peoples" not in gl
73
+ assert "enough time" in gl
74
+ assert "an unhealthy life" in gl
75
+ assert "don't realize" in gl
76
+ assert "it affects" in gl
77
+ print("grammar OK")
78
+
79
+
80
+ def test_classical_fallback() -> None:
81
+ orig = (
82
+ "Nowadays many peoples are living unhealthy life because they don't have enough times. "
83
+ "Eating fast foods are becoming very common and peoples don't realizes how much it affect their health."
84
+ )
85
+ r = rewrite_text(
86
+ orig,
87
+ tone="Neutral",
88
+ strength=1,
89
+ preserve_length=True,
90
+ ml_polish=False,
91
+ )
92
+ out = r.text.lower()
93
+ assert "peoples" not in out
94
+ assert "adequate moments" not in out
95
+ assert "rapid food" not in out
96
+ assert "don't realize" in out or "do not realize" in out
97
+ assert "affects" in out
98
+ assert "an unhealthy life" in out
99
+ print("classical fallback OK:")
100
+ print(r.text)
101
+ print("engine:", r.engine)
102
+
103
+
104
+ if __name__ == "__main__":
105
+ test_polarity()
106
+ test_validator_rejects_flips()
107
+ test_ranker_prefers_faithful()
108
+ test_grammar()
109
+ test_classical_fallback()
110
+ print("\nALL QUALITY PIPELINE TESTS PASSED")