Upload 102 files
Browse files- README.md +2 -1
- app/__pycache__/config.cpython-311.pyc +0 -0
- app/config.py +19 -6
- app/data/content_swaps.json +2 -14
- app/data/elevate_swaps.json +0 -1
- app/data/protected_phrases.json +4 -0
- app/main.py +8 -1
- app/pipeline/__pycache__/candidate_ranker.cpython-311.pyc +0 -0
- app/pipeline/__pycache__/candidate_validator.cpython-311.pyc +0 -0
- app/pipeline/__pycache__/generative.cpython-311.pyc +0 -0
- app/pipeline/__pycache__/grammar_fix.cpython-311.pyc +0 -0
- app/pipeline/__pycache__/meaning_safety.cpython-311.pyc +0 -0
- app/pipeline/__pycache__/ml_polish.cpython-311.pyc +0 -0
- app/pipeline/__pycache__/orchestrator.cpython-311.pyc +0 -0
- app/pipeline/__pycache__/sentence_transform.cpython-311.pyc +0 -0
- app/pipeline/__pycache__/synonym.cpython-311.pyc +0 -0
- app/pipeline/candidate_ranker.py +99 -0
- app/pipeline/candidate_validator.py +165 -0
- app/pipeline/generative.py +150 -180
- app/pipeline/grammar_fix.py +25 -2
- app/pipeline/meaning_safety.py +101 -0
- app/pipeline/ml_polish.py +8 -3
- app/pipeline/orchestrator.py +171 -137
- app/pipeline/sentence_transform.py +3 -1
- app/pipeline/synonym.py +4 -0
- requirements.txt +2 -1
- scripts/test_meaning_fix.py +69 -0
- scripts/test_quality_pipeline.py +110 -0
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 |
-
-
|
|
|
|
| 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 |
-
#
|
|
|
|
| 67 |
GENERATIVE_MODEL = (
|
| 68 |
-
os.environ.get("GENERATIVE_MODEL") or "google/flan-t5-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
| 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": [
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
"""
|
| 2 |
|
| 3 |
-
|
| 4 |
-
1)
|
| 5 |
-
2)
|
| 6 |
-
3)
|
| 7 |
|
| 8 |
-
Lazy-loaded; missing torch/transformers → callers
|
| 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 |
-
|
| 21 |
GENERATIVE_MODEL,
|
| 22 |
GENERATIVE_POLISH_ENABLED,
|
| 23 |
ML_POLISH_AVAILABLE_DEFAULT,
|
| 24 |
)
|
| 25 |
-
from app.pipeline.
|
| 26 |
-
from app.pipeline.
|
| 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 |
-
"
|
| 41 |
-
"
|
| 42 |
-
"
|
| 43 |
-
"
|
| 44 |
-
"
|
|
|
|
| 45 |
),
|
| 46 |
"Formal": (
|
| 47 |
-
"
|
| 48 |
-
"
|
| 49 |
-
"
|
| 50 |
-
"
|
| 51 |
-
"
|
|
|
|
| 52 |
),
|
| 53 |
"Academic": (
|
| 54 |
-
"
|
| 55 |
-
"
|
| 56 |
-
"
|
| 57 |
-
"
|
| 58 |
-
"
|
|
|
|
| 59 |
),
|
| 60 |
"Neutral": (
|
| 61 |
-
"
|
| 62 |
-
"
|
| 63 |
-
"
|
| 64 |
-
"
|
|
|
|
|
|
|
|
|
|
|
|
|
| 65 |
),
|
| 66 |
}
|
| 67 |
|
| 68 |
_RETRY_PROMPT = (
|
| 69 |
-
"
|
| 70 |
-
"
|
| 71 |
-
"
|
| 72 |
-
"
|
| 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
|
| 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
|
| 128 |
return _model
|
| 129 |
except Exception as exc:
|
| 130 |
-
logger.warning("Generative
|
| 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 (
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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, *,
|
| 162 |
-
"""Sampling-heavy decode so paraphrases diverge from the source."""
|
| 163 |
strength = max(0, min(2, int(strength)))
|
| 164 |
-
|
| 165 |
-
|
| 166 |
-
|
| 167 |
-
|
| 168 |
-
|
| 169 |
-
|
| 170 |
-
"top_k": 50,
|
| 171 |
-
"max_new_tokens": 160,
|
| 172 |
-
"no_repeat_ngram_size": 3,
|
| 173 |
-
"num_return_sequences": 3,
|
| 174 |
-
}
|
| 175 |
return {
|
| 176 |
-
|
| 177 |
-
|
| 178 |
-
|
| 179 |
-
|
| 180 |
-
|
| 181 |
-
|
| 182 |
-
|
| 183 |
-
|
| 184 |
-
|
| 185 |
-
|
| 186 |
-
|
| 187 |
-
|
| 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=
|
| 255 |
)
|
| 256 |
-
kwargs = _gen_kwargs(strength,
|
| 257 |
with torch.no_grad():
|
| 258 |
out_ids = model.generate(**inputs, **kwargs)
|
| 259 |
-
|
| 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
|
| 269 |
-
sentence
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 270 |
tone: str,
|
| 271 |
strength: int,
|
| 272 |
*,
|
| 273 |
-
|
| 274 |
) -> str:
|
| 275 |
-
"""
|
| 276 |
model = _ensure_model()
|
| 277 |
if model is None or _tokenizer is None:
|
| 278 |
-
return
|
|
|
|
|
|
|
| 279 |
|
| 280 |
tone_l = normalize_tone(tone)
|
| 281 |
-
prompt = _TONE_PROMPTS.get(tone_l, _TONE_PROMPTS["Neutral"]).format(text=
|
| 282 |
-
|
| 283 |
-
|
| 284 |
-
|
| 285 |
-
#
|
| 286 |
-
|
| 287 |
-
|
| 288 |
-
|
| 289 |
-
|
| 290 |
-
|
| 291 |
-
|
| 292 |
-
|
| 293 |
-
if picked ==
|
| 294 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 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 |
-
|
| 322 |
-
budget = int(GENERATIVE_MAX_SENTENCES)
|
| 323 |
unlimited = budget <= 0
|
| 324 |
used = 0
|
| 325 |
changed_n = 0
|
| 326 |
|
| 327 |
for para in paras:
|
| 328 |
-
|
| 329 |
-
if not sents:
|
| 330 |
continue
|
| 331 |
-
|
| 332 |
-
|
|
|
|
| 333 |
if not unlimited and used >= budget:
|
| 334 |
-
|
| 335 |
-
continue
|
| 336 |
-
if len(sent.split()) < 3:
|
| 337 |
-
new_sents.append(sent)
|
| 338 |
continue
|
| 339 |
-
nxt =
|
|
|
|
|
|
|
| 340 |
used += 1
|
| 341 |
-
if nxt.strip() !=
|
| 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 |
-
|
| 348 |
-
|
| 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
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 292 |
-
|
| 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 |
-
#
|
| 11 |
-
_MIN_MEANING_KEEP = 0.
|
|
|
|
| 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 |
-
|
|
|
|
| 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
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 67 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 89 |
-
|
| 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 |
-
|
| 120 |
-
out =
|
| 121 |
-
|
| 122 |
-
|
| 123 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 124 |
|
| 125 |
|
| 126 |
def rewrite_text(
|
|
@@ -132,21 +181,18 @@ def rewrite_text(
|
|
| 132 |
ml_polish: bool = False,
|
| 133 |
) -> RewriteResult:
|
| 134 |
"""
|
| 135 |
-
|
| 136 |
|
| 137 |
-
When ml_polish=True and
|
| 138 |
-
|
| 139 |
-
|
| 140 |
-
|
| 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 |
-
#
|
| 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(
|
| 179 |
-
|
|
|
|
|
|
|
|
|
|
| 180 |
draft = generative_paraphrase(
|
| 181 |
source,
|
| 182 |
tone=tone,
|
| 183 |
strength=max(1, strength),
|
| 184 |
-
|
| 185 |
)
|
| 186 |
-
if draft and draft.strip():
|
| 187 |
-
working = tidy(draft)
|
| 188 |
mark_gen_used()
|
| 189 |
used_gen_main = True
|
| 190 |
-
|
| 191 |
-
|
| 192 |
-
|
| 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 |
-
|
| 206 |
)
|
| 207 |
-
if draft2 and draft2.strip():
|
| 208 |
-
rewritten =
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 209 |
else:
|
| 210 |
-
tip =
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 219 |
-
|
| 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 |
-
|
| 237 |
-
|
| 238 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 239 |
|
| 240 |
rewritten = enforce_length_budget(source, rewritten, preserve_length)
|
| 241 |
rewritten = tidy(rewritten)
|
| 242 |
|
| 243 |
-
|
| 244 |
-
if
|
| 245 |
-
logger.info("
|
| 246 |
-
rewritten =
|
| 247 |
-
|
| 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 |
-
|
| 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("
|
|
|
|
|
|
|
| 280 |
engine_bits.append("spacy" if spacy_available() else "regex-fallback")
|
| 281 |
if used_gen_main:
|
| 282 |
-
engine_bits.extend(["
|
| 283 |
else:
|
| 284 |
-
engine_bits.extend(["rules", "structure", "
|
| 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 |
-
"
|
| 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
|
| 295 |
notes = f"{notes} {tip}".strip() if notes else tip
|
| 296 |
elif ml_polish and want_gen and not want_minilm:
|
| 297 |
-
tip = "
|
| 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}
|
|
|
|
| 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 |
-
"
|
| 322 |
-
"
|
|
|
|
| 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
|
| 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
|
|
|
|
| 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")
|