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