Upload 81 files
Browse files- app/__pycache__/bootstrap.cpython-311.pyc +0 -0
- app/__pycache__/config.cpython-311.pyc +0 -0
- app/__pycache__/main.cpython-311.pyc +0 -0
- app/bootstrap.py +11 -0
- app/config.py +10 -0
- app/data/casual_swaps.json +102 -0
- app/main.py +34 -2
- app/pipeline/__pycache__/minilm.cpython-311.pyc +0 -0
- app/pipeline/__pycache__/ml_context.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/__pycache__/syntax_rewrite.cpython-311.pyc +0 -0
- app/pipeline/__pycache__/tones.cpython-311.pyc +0 -0
- app/pipeline/minilm.py +239 -0
- app/pipeline/ml_context.py +25 -0
- app/pipeline/ml_polish.py +68 -0
- app/pipeline/orchestrator.py +23 -2
- app/pipeline/sentence_transform.py +13 -6
- app/pipeline/synonym.py +86 -7
- app/pipeline/syntax_rewrite.py +5 -0
- app/pipeline/tones.py +3 -3
- frontend/dist/assets/index.css +15 -0
- frontend/dist/assets/index.js +33 -0
- frontend/src/App.tsx +41 -5
- frontend/src/api.ts +1 -0
- frontend/src/index.css +15 -0
- requirements.txt +2 -0
app/__pycache__/bootstrap.cpython-311.pyc
CHANGED
|
Binary files a/app/__pycache__/bootstrap.cpython-311.pyc and b/app/__pycache__/bootstrap.cpython-311.pyc differ
|
|
|
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/__pycache__/main.cpython-311.pyc
CHANGED
|
Binary files a/app/__pycache__/main.cpython-311.pyc and b/app/__pycache__/main.cpython-311.pyc differ
|
|
|
app/bootstrap.py
CHANGED
|
@@ -34,6 +34,17 @@ def ensure_resources() -> None:
|
|
| 34 |
except Exception as exc: # noqa: BLE001
|
| 35 |
print(f"[warn] NLTK WordNet setup: {exc}")
|
| 36 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 37 |
|
| 38 |
if __name__ == "__main__":
|
| 39 |
ensure_resources()
|
|
|
|
| 34 |
except Exception as exc: # noqa: BLE001
|
| 35 |
print(f"[warn] NLTK WordNet setup: {exc}")
|
| 36 |
|
| 37 |
+
try:
|
| 38 |
+
from app.config import ML_POLISH_WARM
|
| 39 |
+
|
| 40 |
+
if ML_POLISH_WARM:
|
| 41 |
+
from app.pipeline.minilm import warm_minilm
|
| 42 |
+
|
| 43 |
+
ok = warm_minilm()
|
| 44 |
+
print(f"[info] MiniLM warm: {'ok' if ok else 'skipped/unavailable'}")
|
| 45 |
+
except Exception as exc: # noqa: BLE001
|
| 46 |
+
print(f"[warn] MiniLM warm: {exc}")
|
| 47 |
+
|
| 48 |
|
| 49 |
if __name__ == "__main__":
|
| 50 |
ensure_resources()
|
app/config.py
CHANGED
|
@@ -57,3 +57,13 @@ GRAMMAR_MAX_CHARS = max(
|
|
| 57 |
)
|
| 58 |
_lt_flag = (os.environ.get("LANGUAGE_TOOL_ENABLED") or "true").strip().lower()
|
| 59 |
LANGUAGE_TOOL_ENABLED = _lt_flag not in {"0", "false", "no", "off"} and bool(LANGUAGE_TOOL_URL)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 57 |
)
|
| 58 |
_lt_flag = (os.environ.get("LANGUAGE_TOOL_ENABLED") or "true").strip().lower()
|
| 59 |
LANGUAGE_TOOL_ENABLED = _lt_flag not in {"0", "false", "no", "off"} and bool(LANGUAGE_TOOL_URL)
|
| 60 |
+
|
| 61 |
+
# MiniLM ML polish (optional Pro feature — synonym scoring / tone affinity)
|
| 62 |
+
# Model id used by fastembed (or mapped for sentence-transformers fallback)
|
| 63 |
+
MINILM_MODEL = (
|
| 64 |
+
os.environ.get("MINILM_MODEL") or "sentence-transformers/all-MiniLM-L6-v2"
|
| 65 |
+
).strip()
|
| 66 |
+
_ml_flag = (os.environ.get("ML_POLISH_ENABLED") or "true").strip().lower()
|
| 67 |
+
ML_POLISH_AVAILABLE_DEFAULT = _ml_flag not in {"0", "false", "no", "off"}
|
| 68 |
+
_ml_warm = (os.environ.get("ML_POLISH_WARM") or "false").strip().lower()
|
| 69 |
+
ML_POLISH_WARM = _ml_warm in {"1", "true", "yes", "on"}
|
app/data/casual_swaps.json
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"furthermore": "also",
|
| 3 |
+
"moreover": "also",
|
| 4 |
+
"nevertheless": "still",
|
| 5 |
+
"nonetheless": "still",
|
| 6 |
+
"therefore": "so",
|
| 7 |
+
"consequently": "so",
|
| 8 |
+
"thus": "so",
|
| 9 |
+
"hence": "so",
|
| 10 |
+
"additionally": "also",
|
| 11 |
+
"subsequently": "later",
|
| 12 |
+
"substantial": "big",
|
| 13 |
+
"substantially": "really",
|
| 14 |
+
"considerable": "big",
|
| 15 |
+
"considerably": "really",
|
| 16 |
+
"significant": "big",
|
| 17 |
+
"significantly": "really",
|
| 18 |
+
"numerous": "many",
|
| 19 |
+
"approximately": "about",
|
| 20 |
+
"facilitate": "help",
|
| 21 |
+
"facilitates": "helps",
|
| 22 |
+
"facilitated": "helped",
|
| 23 |
+
"commence": "start",
|
| 24 |
+
"commences": "starts",
|
| 25 |
+
"commenced": "started",
|
| 26 |
+
"utilize": "use",
|
| 27 |
+
"utilizes": "uses",
|
| 28 |
+
"utilized": "used",
|
| 29 |
+
"utilizing": "using",
|
| 30 |
+
"obtain": "get",
|
| 31 |
+
"obtains": "gets",
|
| 32 |
+
"obtained": "got",
|
| 33 |
+
"demonstrate": "show",
|
| 34 |
+
"demonstrates": "shows",
|
| 35 |
+
"demonstrated": "showed",
|
| 36 |
+
"indicate": "show",
|
| 37 |
+
"indicates": "shows",
|
| 38 |
+
"indicated": "showed",
|
| 39 |
+
"individuals": "people",
|
| 40 |
+
"individual": "person",
|
| 41 |
+
"regarding": "about",
|
| 42 |
+
"concerning": "about",
|
| 43 |
+
"essential": "needed",
|
| 44 |
+
"pivotal": "key",
|
| 45 |
+
"instrumental": "helpful",
|
| 46 |
+
"endeavor": "try",
|
| 47 |
+
"endeavors": "tries",
|
| 48 |
+
"attempt": "try",
|
| 49 |
+
"attempts": "tries",
|
| 50 |
+
"require": "need",
|
| 51 |
+
"requires": "needs",
|
| 52 |
+
"required": "needed",
|
| 53 |
+
"purchase": "buy",
|
| 54 |
+
"purchases": "buys",
|
| 55 |
+
"purchased": "bought",
|
| 56 |
+
"assistance": "help",
|
| 57 |
+
"methodology": "method",
|
| 58 |
+
"comprehensive": "full",
|
| 59 |
+
"robust": "solid",
|
| 60 |
+
"seamless": "smooth",
|
| 61 |
+
"optimize": "improve",
|
| 62 |
+
"optimizes": "improves",
|
| 63 |
+
"enhance": "improve",
|
| 64 |
+
"enhances": "improves",
|
| 65 |
+
"implement": "put in place",
|
| 66 |
+
"implements": "puts in place",
|
| 67 |
+
"implementation": "rollout",
|
| 68 |
+
"collaborate": "work together",
|
| 69 |
+
"collaboration": "teamwork",
|
| 70 |
+
"provide": "give",
|
| 71 |
+
"provides": "gives",
|
| 72 |
+
"provided": "gave",
|
| 73 |
+
"remain": "stay",
|
| 74 |
+
"remains": "stays",
|
| 75 |
+
"remaining": "left",
|
| 76 |
+
"currently": "now",
|
| 77 |
+
"initially": "at first",
|
| 78 |
+
"previously": "earlier",
|
| 79 |
+
"frequently": "often",
|
| 80 |
+
"entirely": "fully",
|
| 81 |
+
"completely": "fully",
|
| 82 |
+
"objective": "goal",
|
| 83 |
+
"objectives": "goals",
|
| 84 |
+
"determine": "figure out",
|
| 85 |
+
"determines": "figures out",
|
| 86 |
+
"determined": "figured out",
|
| 87 |
+
"sufficient": "enough",
|
| 88 |
+
"additional": "more",
|
| 89 |
+
"prior": "earlier",
|
| 90 |
+
"subsequent": "later",
|
| 91 |
+
"beneficial": "helpful",
|
| 92 |
+
"favorable": "good",
|
| 93 |
+
"unfavorable": "bad",
|
| 94 |
+
"however": "but",
|
| 95 |
+
"whilst": "while",
|
| 96 |
+
"upon": "on",
|
| 97 |
+
"amongst": "among",
|
| 98 |
+
"towards": "toward",
|
| 99 |
+
"aforementioned": "above",
|
| 100 |
+
"hereinafter": "below",
|
| 101 |
+
"notwithstanding": "even so"
|
| 102 |
+
}
|
app/main.py
CHANGED
|
@@ -33,8 +33,15 @@ from app.config import (
|
|
| 33 |
LANGUAGE_TOOL_LANGUAGE,
|
| 34 |
LANGUAGE_TOOL_URL,
|
| 35 |
MAX_CHARS,
|
|
|
|
| 36 |
)
|
| 37 |
-
from app.pipeline.grammar import
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 38 |
from app.pipeline.nlp import spacy_available
|
| 39 |
from app.pipeline.orchestrator import rewrite_text, similarity_check
|
| 40 |
from app.pipeline.tones import normalize_tone
|
|
@@ -63,6 +70,7 @@ class RewriteRequest(BaseModel):
|
|
| 63 |
tone: str = "Neutral"
|
| 64 |
strength: int = Field(1, ge=0, le=2)
|
| 65 |
preserve_length: bool = True
|
|
|
|
| 66 |
|
| 67 |
|
| 68 |
class SimilarityRequest(BaseModel):
|
|
@@ -91,6 +99,9 @@ def health():
|
|
| 91 |
lt_ok = languagetool_reachable()
|
| 92 |
if lt_ok:
|
| 93 |
engines.append("languagetool")
|
|
|
|
|
|
|
|
|
|
| 94 |
return {
|
| 95 |
"status": "ok",
|
| 96 |
"app": APP_TITLE,
|
|
@@ -103,6 +114,12 @@ def health():
|
|
| 103 |
"reachable": lt_ok,
|
| 104 |
"default_language": LANGUAGE_TOOL_LANGUAGE or "en-US",
|
| 105 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 106 |
}
|
| 107 |
|
| 108 |
|
|
@@ -144,7 +161,20 @@ def api_rewrite(
|
|
| 144 |
|
| 145 |
ip = _client_ip(request)
|
| 146 |
input_words = len(text.split())
|
| 147 |
-
assert_can_rewrite(user, input_words, client_ip=ip)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 148 |
|
| 149 |
try:
|
| 150 |
result = rewrite_text(
|
|
@@ -152,6 +182,7 @@ def api_rewrite(
|
|
| 152 |
tone=normalize_tone(body.tone),
|
| 153 |
strength=body.strength,
|
| 154 |
preserve_length=body.preserve_length,
|
|
|
|
| 155 |
)
|
| 156 |
except ValueError as exc:
|
| 157 |
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
|
@@ -169,6 +200,7 @@ def api_rewrite(
|
|
| 169 |
"strength": result.strength,
|
| 170 |
"changed": result.changed,
|
| 171 |
"notes": result.notes,
|
|
|
|
| 172 |
},
|
| 173 |
"account": account_payload(account),
|
| 174 |
}
|
|
|
|
| 33 |
LANGUAGE_TOOL_LANGUAGE,
|
| 34 |
LANGUAGE_TOOL_URL,
|
| 35 |
MAX_CHARS,
|
| 36 |
+
ML_POLISH_AVAILABLE_DEFAULT,
|
| 37 |
)
|
| 38 |
+
from app.pipeline.grammar import (
|
| 39 |
+
check_grammar,
|
| 40 |
+
languagetool_reachable,
|
| 41 |
+
normalize_language,
|
| 42 |
+
rules_fallback_result,
|
| 43 |
+
)
|
| 44 |
+
from app.pipeline.minilm import minilm_package_present
|
| 45 |
from app.pipeline.nlp import spacy_available
|
| 46 |
from app.pipeline.orchestrator import rewrite_text, similarity_check
|
| 47 |
from app.pipeline.tones import normalize_tone
|
|
|
|
| 70 |
tone: str = "Neutral"
|
| 71 |
strength: int = Field(1, ge=0, le=2)
|
| 72 |
preserve_length: bool = True
|
| 73 |
+
ml_polish: bool = False
|
| 74 |
|
| 75 |
|
| 76 |
class SimilarityRequest(BaseModel):
|
|
|
|
| 99 |
lt_ok = languagetool_reachable()
|
| 100 |
if lt_ok:
|
| 101 |
engines.append("languagetool")
|
| 102 |
+
ml_pkg = minilm_package_present()
|
| 103 |
+
if ml_pkg:
|
| 104 |
+
engines.append("minilm")
|
| 105 |
return {
|
| 106 |
"status": "ok",
|
| 107 |
"app": APP_TITLE,
|
|
|
|
| 114 |
"reachable": lt_ok,
|
| 115 |
"default_language": LANGUAGE_TOOL_LANGUAGE or "en-US",
|
| 116 |
},
|
| 117 |
+
"ml_polish": {
|
| 118 |
+
"enabled": bool(ML_POLISH_AVAILABLE_DEFAULT),
|
| 119 |
+
"available": ml_pkg,
|
| 120 |
+
"backend": "fastembed|sentence-transformers" if ml_pkg else None,
|
| 121 |
+
"plans": ["pro", "plus"],
|
| 122 |
+
},
|
| 123 |
}
|
| 124 |
|
| 125 |
|
|
|
|
| 161 |
|
| 162 |
ip = _client_ip(request)
|
| 163 |
input_words = len(text.split())
|
| 164 |
+
account_pre = assert_can_rewrite(user, input_words, client_ip=ip)
|
| 165 |
+
|
| 166 |
+
use_ml = bool(body.ml_polish)
|
| 167 |
+
if use_ml and AUTH_ENABLED:
|
| 168 |
+
plan_id = "guest"
|
| 169 |
+
if account_pre is not None:
|
| 170 |
+
plan_id = account_pre.plan.plan_id
|
| 171 |
+
elif user is not None:
|
| 172 |
+
plan_id = get_account_state(user).plan.plan_id
|
| 173 |
+
if plan_id not in {"pro", "plus"}:
|
| 174 |
+
raise HTTPException(
|
| 175 |
+
status_code=403,
|
| 176 |
+
detail="ML polish (beta) requires a Pro or Plus plan.",
|
| 177 |
+
)
|
| 178 |
|
| 179 |
try:
|
| 180 |
result = rewrite_text(
|
|
|
|
| 182 |
tone=normalize_tone(body.tone),
|
| 183 |
strength=body.strength,
|
| 184 |
preserve_length=body.preserve_length,
|
| 185 |
+
ml_polish=use_ml,
|
| 186 |
)
|
| 187 |
except ValueError as exc:
|
| 188 |
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
|
|
|
| 200 |
"strength": result.strength,
|
| 201 |
"changed": result.changed,
|
| 202 |
"notes": result.notes,
|
| 203 |
+
"ml_polish": use_ml,
|
| 204 |
},
|
| 205 |
"account": account_payload(account),
|
| 206 |
}
|
app/pipeline/__pycache__/minilm.cpython-311.pyc
ADDED
|
Binary file (11.6 kB). View file
|
|
|
app/pipeline/__pycache__/ml_context.cpython-311.pyc
ADDED
|
Binary file (1.69 kB). View file
|
|
|
app/pipeline/__pycache__/ml_polish.cpython-311.pyc
ADDED
|
Binary file (4.12 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__/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/__pycache__/syntax_rewrite.cpython-311.pyc
CHANGED
|
Binary files a/app/pipeline/__pycache__/syntax_rewrite.cpython-311.pyc and b/app/pipeline/__pycache__/syntax_rewrite.cpython-311.pyc differ
|
|
|
app/pipeline/__pycache__/tones.cpython-311.pyc
CHANGED
|
Binary files a/app/pipeline/__pycache__/tones.cpython-311.pyc and b/app/pipeline/__pycache__/tones.cpython-311.pyc differ
|
|
|
app/pipeline/minilm.py
ADDED
|
@@ -0,0 +1,239 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""MiniLM semantic scoring for synonym / rewrite candidates (optional ML polish).
|
| 2 |
+
|
| 3 |
+
Uses fastembed (ONNX MiniLM) when installed; otherwise sentence-transformers.
|
| 4 |
+
If neither is available, scoring is a no-op and callers fall back to rules.
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
from __future__ import annotations
|
| 8 |
+
|
| 9 |
+
import logging
|
| 10 |
+
import threading
|
| 11 |
+
from functools import lru_cache
|
| 12 |
+
from typing import Sequence
|
| 13 |
+
|
| 14 |
+
from app.config import MINILM_MODEL, ML_POLISH_AVAILABLE_DEFAULT
|
| 15 |
+
|
| 16 |
+
logger = logging.getLogger("plainrewrite.minilm")
|
| 17 |
+
|
| 18 |
+
_lock = threading.Lock()
|
| 19 |
+
_model = None
|
| 20 |
+
_backend: str | None = None
|
| 21 |
+
_failed = False
|
| 22 |
+
|
| 23 |
+
# Short register exemplars — used to nudge synonym picks toward the selected tone.
|
| 24 |
+
_TONE_EXEMPLARS: dict[str, tuple[str, ...]] = {
|
| 25 |
+
"Casual": (
|
| 26 |
+
"This really helps a lot and feels easy to read.",
|
| 27 |
+
"We can't wait to try it out with the team.",
|
| 28 |
+
"So the change makes a big difference for people.",
|
| 29 |
+
),
|
| 30 |
+
"Formal": (
|
| 31 |
+
"This approach substantially improves professional outcomes.",
|
| 32 |
+
"We cannot proceed without the required approval.",
|
| 33 |
+
"The results demonstrate a clear operational benefit.",
|
| 34 |
+
),
|
| 35 |
+
"Academic": (
|
| 36 |
+
"This approach constitutes a significant scholarly contribution.",
|
| 37 |
+
"Further investigation remains warranted given these findings.",
|
| 38 |
+
"The results carry considerable implications for the field.",
|
| 39 |
+
),
|
| 40 |
+
"Neutral": (
|
| 41 |
+
"This approach improves outcomes for most readers.",
|
| 42 |
+
"We cannot proceed without approval.",
|
| 43 |
+
"The results show a clear benefit.",
|
| 44 |
+
),
|
| 45 |
+
}
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
def minilm_package_present() -> bool:
|
| 49 |
+
"""True if a MiniLM backend package is installed (does not load weights)."""
|
| 50 |
+
if not ML_POLISH_AVAILABLE_DEFAULT:
|
| 51 |
+
return False
|
| 52 |
+
try:
|
| 53 |
+
import fastembed # noqa: F401
|
| 54 |
+
|
| 55 |
+
return True
|
| 56 |
+
except Exception:
|
| 57 |
+
pass
|
| 58 |
+
try:
|
| 59 |
+
import sentence_transformers # noqa: F401
|
| 60 |
+
|
| 61 |
+
return True
|
| 62 |
+
except Exception:
|
| 63 |
+
return False
|
| 64 |
+
|
| 65 |
+
|
| 66 |
+
def minilm_available() -> bool:
|
| 67 |
+
"""True if a MiniLM backend can be loaded (or already is)."""
|
| 68 |
+
if not ML_POLISH_AVAILABLE_DEFAULT:
|
| 69 |
+
return False
|
| 70 |
+
if _failed:
|
| 71 |
+
return False
|
| 72 |
+
if _model is not None:
|
| 73 |
+
return True
|
| 74 |
+
if not minilm_package_present():
|
| 75 |
+
return False
|
| 76 |
+
try:
|
| 77 |
+
_ensure_model()
|
| 78 |
+
return _model is not None
|
| 79 |
+
except Exception:
|
| 80 |
+
return False
|
| 81 |
+
|
| 82 |
+
|
| 83 |
+
def _ensure_model():
|
| 84 |
+
global _model, _backend, _failed
|
| 85 |
+
if _model is not None or _failed:
|
| 86 |
+
return _model
|
| 87 |
+
with _lock:
|
| 88 |
+
if _model is not None or _failed:
|
| 89 |
+
return _model
|
| 90 |
+
# Prefer fastembed (ONNX, lighter than full PyTorch)
|
| 91 |
+
try:
|
| 92 |
+
from fastembed import TextEmbedding
|
| 93 |
+
|
| 94 |
+
_model = TextEmbedding(model_name=MINILM_MODEL)
|
| 95 |
+
_backend = "fastembed"
|
| 96 |
+
logger.info("MiniLM loaded via fastembed (%s)", MINILM_MODEL)
|
| 97 |
+
return _model
|
| 98 |
+
except Exception as exc:
|
| 99 |
+
logger.warning("fastembed MiniLM unavailable: %s", exc)
|
| 100 |
+
|
| 101 |
+
try:
|
| 102 |
+
from sentence_transformers import SentenceTransformer
|
| 103 |
+
|
| 104 |
+
# Common HF id when fastembed model name differs
|
| 105 |
+
st_name = MINILM_MODEL
|
| 106 |
+
if "/" not in st_name:
|
| 107 |
+
st_name = "sentence-transformers/all-MiniLM-L6-v2"
|
| 108 |
+
_model = SentenceTransformer(st_name)
|
| 109 |
+
_backend = "sentence-transformers"
|
| 110 |
+
logger.info("MiniLM loaded via sentence-transformers (%s)", st_name)
|
| 111 |
+
return _model
|
| 112 |
+
except Exception as exc:
|
| 113 |
+
logger.warning("sentence-transformers MiniLM unavailable: %s", exc)
|
| 114 |
+
_failed = True
|
| 115 |
+
_model = None
|
| 116 |
+
_backend = None
|
| 117 |
+
return None
|
| 118 |
+
|
| 119 |
+
|
| 120 |
+
def backend_name() -> str | None:
|
| 121 |
+
_ensure_model()
|
| 122 |
+
return _backend
|
| 123 |
+
|
| 124 |
+
|
| 125 |
+
def _embed_texts(texts: Sequence[str]) -> list[list[float]]:
|
| 126 |
+
model = _ensure_model()
|
| 127 |
+
if model is None:
|
| 128 |
+
return []
|
| 129 |
+
cleaned = [t.strip() or " " for t in texts]
|
| 130 |
+
if _backend == "fastembed":
|
| 131 |
+
# fastembed returns a generator of numpy arrays
|
| 132 |
+
return [list(map(float, vec)) for vec in model.embed(cleaned)]
|
| 133 |
+
# sentence-transformers
|
| 134 |
+
vectors = model.encode(cleaned, normalize_embeddings=True)
|
| 135 |
+
return [list(map(float, v)) for v in vectors]
|
| 136 |
+
|
| 137 |
+
|
| 138 |
+
def _cosine(a: Sequence[float], b: Sequence[float]) -> float:
|
| 139 |
+
if not a or not b or len(a) != len(b):
|
| 140 |
+
return 0.0
|
| 141 |
+
dot = sum(x * y for x, y in zip(a, b))
|
| 142 |
+
# Vectors from ST are normalized; fastembed MiniLM is typically L2-normalized too.
|
| 143 |
+
# Still guard with norms for safety.
|
| 144 |
+
na = sum(x * x for x in a) ** 0.5
|
| 145 |
+
nb = sum(y * y for y in b) ** 0.5
|
| 146 |
+
if na <= 1e-9 or nb <= 1e-9:
|
| 147 |
+
return 0.0
|
| 148 |
+
return float(dot / (na * nb))
|
| 149 |
+
|
| 150 |
+
|
| 151 |
+
@lru_cache(maxsize=8)
|
| 152 |
+
def _tone_centroid(tone: str) -> tuple[float, ...] | None:
|
| 153 |
+
exemplars = _TONE_EXEMPLARS.get(tone) or _TONE_EXEMPLARS["Neutral"]
|
| 154 |
+
vectors = _embed_texts(exemplars)
|
| 155 |
+
if not vectors:
|
| 156 |
+
return None
|
| 157 |
+
dim = len(vectors[0])
|
| 158 |
+
acc = [0.0] * dim
|
| 159 |
+
for v in vectors:
|
| 160 |
+
for i, x in enumerate(v):
|
| 161 |
+
acc[i] += x
|
| 162 |
+
n = float(len(vectors))
|
| 163 |
+
return tuple(x / n for x in acc)
|
| 164 |
+
|
| 165 |
+
|
| 166 |
+
def score_candidate(
|
| 167 |
+
original: str,
|
| 168 |
+
candidate: str,
|
| 169 |
+
*,
|
| 170 |
+
tone: str = "Neutral",
|
| 171 |
+
) -> float:
|
| 172 |
+
"""Higher is better. Mixes meaning retention with tone affinity."""
|
| 173 |
+
if not original.strip() or not candidate.strip():
|
| 174 |
+
return 0.0
|
| 175 |
+
vectors = _embed_texts([original, candidate])
|
| 176 |
+
if len(vectors) < 2:
|
| 177 |
+
return 0.0
|
| 178 |
+
meaning = _cosine(vectors[0], vectors[1])
|
| 179 |
+
# Reject meaning drift
|
| 180 |
+
if meaning < 0.62:
|
| 181 |
+
return meaning * 0.25
|
| 182 |
+
centroid = _tone_centroid(tone)
|
| 183 |
+
tone_sim = _cosine(vectors[1], centroid) if centroid else 0.0
|
| 184 |
+
return 0.72 * meaning + 0.28 * tone_sim
|
| 185 |
+
|
| 186 |
+
|
| 187 |
+
def pick_best_candidate(
|
| 188 |
+
original_sentence: str,
|
| 189 |
+
candidates: Sequence[str],
|
| 190 |
+
*,
|
| 191 |
+
tone: str = "Neutral",
|
| 192 |
+
min_meaning: float = 0.68,
|
| 193 |
+
) -> str | None:
|
| 194 |
+
"""Pick the candidate sentence with best MiniLM score, or None if scoring fails."""
|
| 195 |
+
uniq = []
|
| 196 |
+
seen: set[str] = set()
|
| 197 |
+
for c in candidates:
|
| 198 |
+
key = (c or "").strip()
|
| 199 |
+
if not key or key in seen:
|
| 200 |
+
continue
|
| 201 |
+
seen.add(key)
|
| 202 |
+
uniq.append(key)
|
| 203 |
+
if not uniq:
|
| 204 |
+
return None
|
| 205 |
+
if len(uniq) == 1:
|
| 206 |
+
return uniq[0]
|
| 207 |
+
if _ensure_model() is None:
|
| 208 |
+
return None
|
| 209 |
+
|
| 210 |
+
best: str | None = None
|
| 211 |
+
best_score = -1.0
|
| 212 |
+
orig_vec = _embed_texts([original_sentence])
|
| 213 |
+
if not orig_vec:
|
| 214 |
+
return None
|
| 215 |
+
o = orig_vec[0]
|
| 216 |
+
cand_vecs = _embed_texts(uniq)
|
| 217 |
+
centroid = _tone_centroid(tone)
|
| 218 |
+
for text, vec in zip(uniq, cand_vecs):
|
| 219 |
+
meaning = _cosine(o, vec)
|
| 220 |
+
if meaning < min_meaning:
|
| 221 |
+
continue
|
| 222 |
+
tone_sim = _cosine(vec, centroid) if centroid else 0.0
|
| 223 |
+
score = 0.72 * meaning + 0.28 * tone_sim
|
| 224 |
+
if score > best_score:
|
| 225 |
+
best_score = score
|
| 226 |
+
best = text
|
| 227 |
+
return best
|
| 228 |
+
|
| 229 |
+
|
| 230 |
+
def warm_minilm() -> bool:
|
| 231 |
+
"""Optional startup warm — returns True if model is ready."""
|
| 232 |
+
try:
|
| 233 |
+
ok = minilm_available()
|
| 234 |
+
if ok:
|
| 235 |
+
_embed_texts(["warm up"])
|
| 236 |
+
return ok
|
| 237 |
+
except Exception as exc:
|
| 238 |
+
logger.warning("MiniLM warm failed: %s", exc)
|
| 239 |
+
return False
|
app/pipeline/ml_context.py
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Per-request ML polish flag (ContextVar — safe across async workers)."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
from contextvars import ContextVar
|
| 6 |
+
|
| 7 |
+
_ml_polish: ContextVar[bool] = ContextVar("ml_polish", default=False)
|
| 8 |
+
_ml_used: ContextVar[bool] = ContextVar("ml_used", default=False)
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
def set_ml_polish(enabled: bool) -> None:
|
| 12 |
+
_ml_polish.set(bool(enabled))
|
| 13 |
+
_ml_used.set(False)
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
def ml_polish_enabled() -> bool:
|
| 17 |
+
return bool(_ml_polish.get())
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
def mark_ml_used() -> None:
|
| 21 |
+
_ml_used.set(True)
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
def ml_was_used() -> bool:
|
| 25 |
+
return bool(_ml_used.get())
|
app/pipeline/ml_polish.py
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Sentence-level MiniLM polish — meaning guard + tone affinity."""
|
| 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 pick_best_candidate, score_candidate
|
| 7 |
+
from app.pipeline.normalize import split_paragraphs, split_sentences_regex
|
| 8 |
+
from app.pipeline.nlp import get_nlp
|
| 9 |
+
from app.pipeline.tones import normalize_tone
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
def _split_sents(text: str) -> list[str]:
|
| 13 |
+
nlp = get_nlp()
|
| 14 |
+
if nlp is not None:
|
| 15 |
+
return [s.text.strip() for s in nlp(text).sents if s.text.strip()]
|
| 16 |
+
return [s for s in split_sentences_regex(text) if s.strip()]
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
def apply_minilm_polish(original: str, rewritten: str, tone: str) -> str:
|
| 20 |
+
"""
|
| 21 |
+
When ML polish is on, compare sentence pairs and keep the better MiniLM score.
|
| 22 |
+
If sentence counts diverge, score the whole paragraph blocks instead.
|
| 23 |
+
"""
|
| 24 |
+
if not ml_polish_enabled():
|
| 25 |
+
return rewritten
|
| 26 |
+
tone_l = normalize_tone(tone)
|
| 27 |
+
orig_paras = split_paragraphs(original)
|
| 28 |
+
rew_paras = split_paragraphs(rewritten)
|
| 29 |
+
if len(orig_paras) != len(rew_paras):
|
| 30 |
+
# Whole-doc fallback
|
| 31 |
+
best = pick_best_candidate(original, [rewritten, original], tone=tone_l)
|
| 32 |
+
if best:
|
| 33 |
+
mark_ml_used()
|
| 34 |
+
return best
|
| 35 |
+
return rewritten
|
| 36 |
+
|
| 37 |
+
out_paras: list[str] = []
|
| 38 |
+
used = False
|
| 39 |
+
for op, rp in zip(orig_paras, rew_paras):
|
| 40 |
+
o_sents = _split_sents(op)
|
| 41 |
+
r_sents = _split_sents(rp)
|
| 42 |
+
if len(o_sents) == len(r_sents) and o_sents:
|
| 43 |
+
merged: list[str] = []
|
| 44 |
+
for o, r in zip(o_sents, r_sents):
|
| 45 |
+
if o == r:
|
| 46 |
+
merged.append(r)
|
| 47 |
+
continue
|
| 48 |
+
# Prefer rewrite if it keeps meaning and leans toward tone
|
| 49 |
+
score_r = score_candidate(o, r, tone=tone_l)
|
| 50 |
+
score_o = score_candidate(o, o, tone=tone_l)
|
| 51 |
+
if score_r + 0.02 >= score_o * 0.92 and score_r >= 0.55:
|
| 52 |
+
merged.append(r)
|
| 53 |
+
used = True
|
| 54 |
+
else:
|
| 55 |
+
# Meaning drift — keep original sentence
|
| 56 |
+
merged.append(o)
|
| 57 |
+
used = True
|
| 58 |
+
out_paras.append(" ".join(merged))
|
| 59 |
+
else:
|
| 60 |
+
best = pick_best_candidate(op, [rp, op], tone=tone_l)
|
| 61 |
+
if best:
|
| 62 |
+
out_paras.append(best)
|
| 63 |
+
used = True
|
| 64 |
+
else:
|
| 65 |
+
out_paras.append(rp)
|
| 66 |
+
if used:
|
| 67 |
+
mark_ml_used()
|
| 68 |
+
return "\n\n".join(p for p in out_paras if p)
|
app/pipeline/orchestrator.py
CHANGED
|
@@ -9,6 +9,9 @@ from dataclasses import dataclass
|
|
| 9 |
from difflib import SequenceMatcher
|
| 10 |
|
| 11 |
from app.pipeline.mechanics import enforce_length_budget, scrub_phrases, tidy
|
|
|
|
|
|
|
|
|
|
| 12 |
from app.pipeline.nlp import get_nlp, spacy_available
|
| 13 |
from app.pipeline.normalize import (
|
| 14 |
normalize_whitespace,
|
|
@@ -105,6 +108,7 @@ def rewrite_text(
|
|
| 105 |
tone: str = "Neutral",
|
| 106 |
strength: int = 1,
|
| 107 |
preserve_length: bool = True,
|
|
|
|
| 108 |
) -> RewriteResult:
|
| 109 |
started = time.perf_counter()
|
| 110 |
original = normalize_whitespace(text or "")
|
|
@@ -113,13 +117,21 @@ def rewrite_text(
|
|
| 113 |
|
| 114 |
strength = max(0, min(2, int(strength)))
|
| 115 |
tone = normalize_tone(tone)
|
| 116 |
-
|
|
|
|
|
|
|
| 117 |
|
| 118 |
paragraphs = split_paragraphs(original)
|
| 119 |
out_paras = [_rewrite_paragraph(p, tone, strength, rng) for p in paragraphs]
|
| 120 |
|
| 121 |
rewritten = tidy("\n\n".join(out_paras))
|
| 122 |
rewritten = scrub_phrases(rewritten)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 123 |
rewritten = enforce_length_budget(original, rewritten, preserve_length)
|
| 124 |
rewritten = tidy(rewritten)
|
| 125 |
|
|
@@ -128,6 +140,10 @@ def rewrite_text(
|
|
| 128 |
if ratio > 0.85:
|
| 129 |
logger.info("Low change detected (ratio=%.3f); running stronger second pass", ratio)
|
| 130 |
rewritten = _force_more_changes(rewritten, tone, strength, rng)
|
|
|
|
|
|
|
|
|
|
|
|
|
| 131 |
rewritten = enforce_length_budget(original, rewritten, preserve_length)
|
| 132 |
rewritten = tidy(rewritten)
|
| 133 |
notes = "Applied second pass (input was too similar after first rewrite)."
|
|
@@ -135,6 +151,11 @@ def rewrite_text(
|
|
| 135 |
|
| 136 |
changed = rewritten.strip() != original.strip()
|
| 137 |
engine_bits = ["rules", "structure", "wordnet", "mechanics", "tone"]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 138 |
if spacy_available():
|
| 139 |
engine_bits.insert(0, "spacy")
|
| 140 |
else:
|
|
@@ -144,7 +165,7 @@ def rewrite_text(
|
|
| 144 |
msg = (
|
| 145 |
f"rewrite engine={engine} strength={strength} tone={tone} "
|
| 146 |
f"words={word_count(original)}->{word_count(rewritten)} "
|
| 147 |
-
f"changed={changed} similarity={ratio:.3f}"
|
| 148 |
)
|
| 149 |
logger.info(msg)
|
| 150 |
# HF Spaces often hide logger INFO; print shows in Space Logs
|
|
|
|
| 9 |
from difflib import SequenceMatcher
|
| 10 |
|
| 11 |
from app.pipeline.mechanics import enforce_length_budget, scrub_phrases, tidy
|
| 12 |
+
from app.pipeline.ml_context import ml_was_used, set_ml_polish
|
| 13 |
+
from app.pipeline.ml_polish import apply_minilm_polish
|
| 14 |
+
from app.pipeline.minilm import minilm_available
|
| 15 |
from app.pipeline.nlp import get_nlp, spacy_available
|
| 16 |
from app.pipeline.normalize import (
|
| 17 |
normalize_whitespace,
|
|
|
|
| 108 |
tone: str = "Neutral",
|
| 109 |
strength: int = 1,
|
| 110 |
preserve_length: bool = True,
|
| 111 |
+
ml_polish: bool = False,
|
| 112 |
) -> RewriteResult:
|
| 113 |
started = time.perf_counter()
|
| 114 |
original = normalize_whitespace(text or "")
|
|
|
|
| 117 |
|
| 118 |
strength = max(0, min(2, int(strength)))
|
| 119 |
tone = normalize_tone(tone)
|
| 120 |
+
want_ml = bool(ml_polish) and minilm_available()
|
| 121 |
+
set_ml_polish(want_ml)
|
| 122 |
+
rng = _rng_for(original + "|" + tone + "|" + str(strength) + ("|ml" if want_ml else ""))
|
| 123 |
|
| 124 |
paragraphs = split_paragraphs(original)
|
| 125 |
out_paras = [_rewrite_paragraph(p, tone, strength, rng) for p in paragraphs]
|
| 126 |
|
| 127 |
rewritten = tidy("\n\n".join(out_paras))
|
| 128 |
rewritten = scrub_phrases(rewritten)
|
| 129 |
+
if want_ml:
|
| 130 |
+
rewritten = apply_minilm_polish(original, rewritten, tone)
|
| 131 |
+
rewritten = tidy(rewritten)
|
| 132 |
+
# Re-apply register after MiniLM sentence swaps
|
| 133 |
+
rewritten = apply_tone_style(rewritten, tone, strength, rng)
|
| 134 |
+
rewritten = apply_tone_contractions(rewritten, tone)
|
| 135 |
rewritten = enforce_length_budget(original, rewritten, preserve_length)
|
| 136 |
rewritten = tidy(rewritten)
|
| 137 |
|
|
|
|
| 140 |
if ratio > 0.85:
|
| 141 |
logger.info("Low change detected (ratio=%.3f); running stronger second pass", ratio)
|
| 142 |
rewritten = _force_more_changes(rewritten, tone, strength, rng)
|
| 143 |
+
if want_ml:
|
| 144 |
+
rewritten = apply_minilm_polish(original, rewritten, tone)
|
| 145 |
+
rewritten = apply_tone_style(rewritten, tone, strength, rng)
|
| 146 |
+
rewritten = apply_tone_contractions(rewritten, tone)
|
| 147 |
rewritten = enforce_length_budget(original, rewritten, preserve_length)
|
| 148 |
rewritten = tidy(rewritten)
|
| 149 |
notes = "Applied second pass (input was too similar after first rewrite)."
|
|
|
|
| 151 |
|
| 152 |
changed = rewritten.strip() != original.strip()
|
| 153 |
engine_bits = ["rules", "structure", "wordnet", "mechanics", "tone"]
|
| 154 |
+
if want_ml and ml_was_used():
|
| 155 |
+
engine_bits.append("minilm")
|
| 156 |
+
elif ml_polish and not minilm_available():
|
| 157 |
+
tip = "ML polish requested but MiniLM is unavailable on this server."
|
| 158 |
+
notes = f"{notes} {tip}".strip() if notes else tip
|
| 159 |
if spacy_available():
|
| 160 |
engine_bits.insert(0, "spacy")
|
| 161 |
else:
|
|
|
|
| 165 |
msg = (
|
| 166 |
f"rewrite engine={engine} strength={strength} tone={tone} "
|
| 167 |
f"words={word_count(original)}->{word_count(rewritten)} "
|
| 168 |
+
f"changed={changed} similarity={ratio:.3f} ml_polish={want_ml}"
|
| 169 |
)
|
| 170 |
logger.info(msg)
|
| 171 |
# HF Spaces often hide logger INFO; print shows in Space Logs
|
app/pipeline/sentence_transform.py
CHANGED
|
@@ -322,8 +322,8 @@ 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
|
| 326 |
-
if is_casual(tone) and rng.random() < 0.
|
| 327 |
return None
|
| 328 |
link = linker
|
| 329 |
if is_academic(tone) and linker.lower() == "while":
|
|
@@ -388,12 +388,19 @@ def transform_sentence(
|
|
| 388 |
strength = max(0, min(2, int(strength)))
|
| 389 |
max_n = max_transforms if max_transforms is not None else {0: 1, 1: 2, 2: 3}.get(strength, 2)
|
| 390 |
tone_l = normalize_tone(tone)
|
|
|
|
| 391 |
tone_boost = {
|
| 392 |
-
"Casual": 0.
|
| 393 |
-
"Neutral": 0.
|
| 394 |
-
"Formal": 0.
|
| 395 |
-
"Academic": 0.
|
| 396 |
}.get(tone_l, 0.05)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 397 |
# At Normal+, almost always attempt high-value unpackers first
|
| 398 |
base_chance = {0: 0.6, 1: 0.92, 2: 1.0}.get(strength, 0.92) + tone_boost
|
| 399 |
|
|
|
|
| 322 |
if main.lower().startswith(linker.lower()):
|
| 323 |
return None
|
| 324 |
body = main[0].lower() + main[1:] if main and main[0].isupper() else main
|
| 325 |
+
# Academic always prefers fronting; Formal usually; Casual often keeps clause at end
|
| 326 |
+
if is_casual(tone) and rng.random() < 0.55:
|
| 327 |
return None
|
| 328 |
link = linker
|
| 329 |
if is_academic(tone) and linker.lower() == "while":
|
|
|
|
| 388 |
strength = max(0, min(2, int(strength)))
|
| 389 |
max_n = max_transforms if max_transforms is not None else {0: 1, 1: 2, 2: 3}.get(strength, 2)
|
| 390 |
tone_l = normalize_tone(tone)
|
| 391 |
+
# Academic / Formal lean into unpackers; Casual prefers punchy conversational ones
|
| 392 |
tone_boost = {
|
| 393 |
+
"Casual": 0.18,
|
| 394 |
+
"Neutral": 0.04,
|
| 395 |
+
"Formal": 0.14,
|
| 396 |
+
"Academic": 0.22,
|
| 397 |
}.get(tone_l, 0.05)
|
| 398 |
+
# Casual: slightly fewer stacked transforms so text stays snappy
|
| 399 |
+
if max_transforms is None and is_casual(tone_l):
|
| 400 |
+
max_n = {0: 1, 1: 2, 2: 2}.get(strength, 2)
|
| 401 |
+
# Academic: allow one extra at Heavy
|
| 402 |
+
if max_transforms is None and is_academic(tone_l) and strength >= 2:
|
| 403 |
+
max_n = 4
|
| 404 |
# At Normal+, almost always attempt high-value unpackers first
|
| 405 |
base_chance = {0: 0.6, 1: 0.92, 2: 1.0}.get(strength, 0.92) + tone_boost
|
| 406 |
|
app/pipeline/synonym.py
CHANGED
|
@@ -9,8 +9,10 @@ from difflib import SequenceMatcher
|
|
| 9 |
from functools import lru_cache
|
| 10 |
|
| 11 |
from app.config import DATA_DIR
|
|
|
|
|
|
|
| 12 |
from app.pipeline.nlp import get_nlp
|
| 13 |
-
from app.pipeline.tones import is_academic, is_elevated, normalize_tone
|
| 14 |
|
| 15 |
_WORDNET_READY = False
|
| 16 |
|
|
@@ -33,6 +35,18 @@ _STOP_SWAP = {
|
|
| 33 |
# Dynamic safety: POS gating (adj/adv WordNet only) + structural filters below.
|
| 34 |
|
| 35 |
_OPENERS = {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 36 |
# Business / professional — restrained connectors
|
| 37 |
"Formal": {
|
| 38 |
"but": "Yet",
|
|
@@ -40,6 +54,8 @@ _OPENERS = {
|
|
| 40 |
"and": "Also",
|
| 41 |
"yet": "Still",
|
| 42 |
"also": "Additionally",
|
|
|
|
|
|
|
| 43 |
},
|
| 44 |
# Scholarly — denser discourse markers
|
| 45 |
"Academic": {
|
|
@@ -49,6 +65,8 @@ _OPENERS = {
|
|
| 49 |
"yet": "Nevertheless",
|
| 50 |
"still": "Nevertheless",
|
| 51 |
"also": "Moreover",
|
|
|
|
|
|
|
| 52 |
},
|
| 53 |
}
|
| 54 |
|
|
@@ -110,13 +128,19 @@ def load_academic_swaps() -> dict[str, str]:
|
|
| 110 |
return _load_json_map("academic_swaps.json")
|
| 111 |
|
| 112 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 113 |
def _lexicon_for(tone: str, strength: int) -> dict[str, str]:
|
| 114 |
"""
|
| 115 |
Merge lexicons by canonical tone:
|
| 116 |
- content_swaps for every tone
|
| 117 |
- Formal: elevate_swaps on top
|
| 118 |
- Academic: elevate + academic_swaps (scholarly overlay)
|
| 119 |
-
-
|
|
|
|
| 120 |
"""
|
| 121 |
content = load_content_swaps()
|
| 122 |
tone_l = normalize_tone(tone)
|
|
@@ -124,6 +148,9 @@ def _lexicon_for(tone: str, strength: int) -> dict[str, str]:
|
|
| 124 |
return {**content, **load_elevate_swaps(), **load_academic_swaps()}
|
| 125 |
if is_elevated(tone_l): # Formal
|
| 126 |
return {**content, **load_elevate_swaps()}
|
|
|
|
|
|
|
|
|
|
| 127 |
merged = {**load_preferred_swaps(), **content}
|
| 128 |
if tone_l == "Neutral" and strength >= 1:
|
| 129 |
for k, v in load_elevate_swaps().items():
|
|
@@ -263,11 +290,41 @@ def _join_tokens(tokens: list[str]) -> str:
|
|
| 263 |
return re.sub(r"\s+([,.;:!?])", r"\1", text)
|
| 264 |
|
| 265 |
|
| 266 |
-
def _pick(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 267 |
if not cands:
|
| 268 |
return ""
|
| 269 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 270 |
return max(cands, key=len)
|
|
|
|
|
|
|
|
|
|
| 271 |
return rng.choice(cands)
|
| 272 |
|
| 273 |
|
|
@@ -296,10 +353,14 @@ def rewrite_sentence_synonyms(
|
|
| 296 |
lexicon = _lexicon_for(tone, strength)
|
| 297 |
tone_l = normalize_tone(tone)
|
| 298 |
openers = _OPENERS.get(tone_l, {})
|
| 299 |
-
# Formal: lexicon-only register.
|
|
|
|
| 300 |
if is_elevated(tone_l):
|
| 301 |
max_wn = 0
|
| 302 |
wn_rate = 0.0
|
|
|
|
|
|
|
|
|
|
| 303 |
else:
|
| 304 |
max_wn = {0: 1, 1: 3, 2: 5}.get(strength, 3)
|
| 305 |
wn_rate = {0: 0.4, 1: 0.7, 2: 0.9}.get(strength, 0.7)
|
|
@@ -336,7 +397,16 @@ def rewrite_sentence_synonyms(
|
|
| 336 |
if not cands:
|
| 337 |
return raw
|
| 338 |
wn_budget[0] -= 1
|
| 339 |
-
return _match_case(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 340 |
|
| 341 |
def _force_min_changes(tokens: list[str], out: list[str], min_changes: int) -> list[str]:
|
| 342 |
changed = sum(1 for a, b in zip(tokens, out) if a.isalpha() and a != b)
|
|
@@ -367,7 +437,16 @@ def rewrite_sentence_synonyms(
|
|
| 367 |
cands = list(_wordnet_synonyms(low, "r"))
|
| 368 |
if not cands:
|
| 369 |
continue
|
| 370 |
-
out[i] = _match_case(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 371 |
changed += 1
|
| 372 |
if changed >= min_changes:
|
| 373 |
break
|
|
|
|
| 9 |
from functools import lru_cache
|
| 10 |
|
| 11 |
from app.config import DATA_DIR
|
| 12 |
+
from app.pipeline.ml_context import mark_ml_used, ml_polish_enabled
|
| 13 |
+
from app.pipeline.minilm import pick_best_candidate
|
| 14 |
from app.pipeline.nlp import get_nlp
|
| 15 |
+
from app.pipeline.tones import is_academic, is_casual, is_elevated, normalize_tone
|
| 16 |
|
| 17 |
_WORDNET_READY = False
|
| 18 |
|
|
|
|
| 35 |
# Dynamic safety: POS gating (adj/adv WordNet only) + structural filters below.
|
| 36 |
|
| 37 |
_OPENERS = {
|
| 38 |
+
# Conversational — short spoken connectors
|
| 39 |
+
"Casual": {
|
| 40 |
+
"but": "Still",
|
| 41 |
+
"so": "So",
|
| 42 |
+
"and": "Plus",
|
| 43 |
+
"yet": "Still",
|
| 44 |
+
"however": "Still",
|
| 45 |
+
"therefore": "So",
|
| 46 |
+
"additionally": "Also",
|
| 47 |
+
"furthermore": "Also",
|
| 48 |
+
"moreover": "Also",
|
| 49 |
+
},
|
| 50 |
# Business / professional — restrained connectors
|
| 51 |
"Formal": {
|
| 52 |
"but": "Yet",
|
|
|
|
| 54 |
"and": "Also",
|
| 55 |
"yet": "Still",
|
| 56 |
"also": "Additionally",
|
| 57 |
+
"therefore": "Accordingly",
|
| 58 |
+
"however": "Nevertheless",
|
| 59 |
},
|
| 60 |
# Scholarly — denser discourse markers
|
| 61 |
"Academic": {
|
|
|
|
| 65 |
"yet": "Nevertheless",
|
| 66 |
"still": "Nevertheless",
|
| 67 |
"also": "Moreover",
|
| 68 |
+
"therefore": "Accordingly",
|
| 69 |
+
"thus": "Hence",
|
| 70 |
},
|
| 71 |
}
|
| 72 |
|
|
|
|
| 128 |
return _load_json_map("academic_swaps.json")
|
| 129 |
|
| 130 |
|
| 131 |
+
@lru_cache(maxsize=1)
|
| 132 |
+
def load_casual_swaps() -> dict[str, str]:
|
| 133 |
+
return _load_json_map("casual_swaps.json")
|
| 134 |
+
|
| 135 |
+
|
| 136 |
def _lexicon_for(tone: str, strength: int) -> dict[str, str]:
|
| 137 |
"""
|
| 138 |
Merge lexicons by canonical tone:
|
| 139 |
- content_swaps for every tone
|
| 140 |
- Formal: elevate_swaps on top
|
| 141 |
- Academic: elevate + academic_swaps (scholarly overlay)
|
| 142 |
+
- Casual: preferred + casual downshift overlay (wins over content)
|
| 143 |
+
- Neutral: preferred_swaps; mild elevate fill-ins at Normal+
|
| 144 |
"""
|
| 145 |
content = load_content_swaps()
|
| 146 |
tone_l = normalize_tone(tone)
|
|
|
|
| 148 |
return {**content, **load_elevate_swaps(), **load_academic_swaps()}
|
| 149 |
if is_elevated(tone_l): # Formal
|
| 150 |
return {**content, **load_elevate_swaps()}
|
| 151 |
+
if is_casual(tone_l):
|
| 152 |
+
# preferred + content, then casual wins on conflicts (stronger downshift)
|
| 153 |
+
return {**content, **load_preferred_swaps(), **load_casual_swaps()}
|
| 154 |
merged = {**load_preferred_swaps(), **content}
|
| 155 |
if tone_l == "Neutral" and strength >= 1:
|
| 156 |
for k, v in load_elevate_swaps().items():
|
|
|
|
| 290 |
return re.sub(r"\s+([,.;:!?])", r"\1", text)
|
| 291 |
|
| 292 |
|
| 293 |
+
def _pick(
|
| 294 |
+
cands: list[str],
|
| 295 |
+
tone: str,
|
| 296 |
+
rng: random.Random,
|
| 297 |
+
*,
|
| 298 |
+
original_sentence: str = "",
|
| 299 |
+
original_word: str = "",
|
| 300 |
+
) -> str:
|
| 301 |
if not cands:
|
| 302 |
return ""
|
| 303 |
+
tone_l = normalize_tone(tone)
|
| 304 |
+
|
| 305 |
+
# MiniLM polish: score full-sentence variants when enabled
|
| 306 |
+
if ml_polish_enabled() and original_sentence and original_word:
|
| 307 |
+
variants: list[str] = []
|
| 308 |
+
low = original_word.lower()
|
| 309 |
+
for c in cands[:5]:
|
| 310 |
+
# Replace first case-insensitive whole-word hit
|
| 311 |
+
pat = re.compile(rf"\b{re.escape(original_word)}\b", re.I)
|
| 312 |
+
if not pat.search(original_sentence):
|
| 313 |
+
pat = re.compile(rf"\b{re.escape(low)}\b", re.I)
|
| 314 |
+
nxt = pat.sub(_match_case(original_word, c), original_sentence, count=1)
|
| 315 |
+
variants.append(nxt)
|
| 316 |
+
best_sent = pick_best_candidate(original_sentence, variants, tone=tone_l)
|
| 317 |
+
if best_sent:
|
| 318 |
+
for c, variant in zip(cands[:5], variants):
|
| 319 |
+
if variant == best_sent:
|
| 320 |
+
mark_ml_used()
|
| 321 |
+
return c
|
| 322 |
+
|
| 323 |
+
if is_elevated(tone_l):
|
| 324 |
return max(cands, key=len)
|
| 325 |
+
if is_casual(tone_l):
|
| 326 |
+
# Prefer shorter, punchier synonyms
|
| 327 |
+
return min(cands, key=lambda w: (len(w), w))
|
| 328 |
return rng.choice(cands)
|
| 329 |
|
| 330 |
|
|
|
|
| 353 |
lexicon = _lexicon_for(tone, strength)
|
| 354 |
tone_l = normalize_tone(tone)
|
| 355 |
openers = _OPENERS.get(tone_l, {})
|
| 356 |
+
# Formal/Academic: lexicon-only register. Casual: mostly lexicon (safer downshift).
|
| 357 |
+
# Neutral: adj/adv WordNet allowed more freely.
|
| 358 |
if is_elevated(tone_l):
|
| 359 |
max_wn = 0
|
| 360 |
wn_rate = 0.0
|
| 361 |
+
elif is_casual(tone_l):
|
| 362 |
+
max_wn = {0: 0, 1: 1, 2: 2}.get(strength, 1)
|
| 363 |
+
wn_rate = {0: 0.0, 1: 0.35, 2: 0.55}.get(strength, 0.35)
|
| 364 |
else:
|
| 365 |
max_wn = {0: 1, 1: 3, 2: 5}.get(strength, 3)
|
| 366 |
wn_rate = {0: 0.4, 1: 0.7, 2: 0.9}.get(strength, 0.7)
|
|
|
|
| 397 |
if not cands:
|
| 398 |
return raw
|
| 399 |
wn_budget[0] -= 1
|
| 400 |
+
return _match_case(
|
| 401 |
+
raw,
|
| 402 |
+
_pick(
|
| 403 |
+
cands,
|
| 404 |
+
tone,
|
| 405 |
+
rng,
|
| 406 |
+
original_sentence=sentence,
|
| 407 |
+
original_word=raw,
|
| 408 |
+
),
|
| 409 |
+
)
|
| 410 |
|
| 411 |
def _force_min_changes(tokens: list[str], out: list[str], min_changes: int) -> list[str]:
|
| 412 |
changed = sum(1 for a, b in zip(tokens, out) if a.isalpha() and a != b)
|
|
|
|
| 437 |
cands = list(_wordnet_synonyms(low, "r"))
|
| 438 |
if not cands:
|
| 439 |
continue
|
| 440 |
+
out[i] = _match_case(
|
| 441 |
+
tok,
|
| 442 |
+
_pick(
|
| 443 |
+
cands,
|
| 444 |
+
tone,
|
| 445 |
+
rng,
|
| 446 |
+
original_sentence=sentence,
|
| 447 |
+
original_word=tok,
|
| 448 |
+
),
|
| 449 |
+
)
|
| 450 |
changed += 1
|
| 451 |
if changed >= min_changes:
|
| 452 |
break
|
app/pipeline/syntax_rewrite.py
CHANGED
|
@@ -139,7 +139,12 @@ def apply_tone_contractions(text: str, tone: str) -> str:
|
|
| 139 |
(r"\byou are\b", "you're"),
|
| 140 |
(r"\bthey are\b", "they're"),
|
| 141 |
(r"\bI am\b", "I'm"),
|
|
|
|
|
|
|
|
|
|
|
|
|
| 142 |
]
|
|
|
|
| 143 |
use = pairs if is_casual(tone_l) else pairs[:12]
|
| 144 |
out = text
|
| 145 |
for pat, repl in use:
|
|
|
|
| 139 |
(r"\byou are\b", "you're"),
|
| 140 |
(r"\bthey are\b", "they're"),
|
| 141 |
(r"\bI am\b", "I'm"),
|
| 142 |
+
(r"\bI have\b", "I've"),
|
| 143 |
+
(r"\blet us\b", "let's"),
|
| 144 |
+
(r"\bwho is\b", "who's"),
|
| 145 |
+
(r"\bwhat is\b", "what's"),
|
| 146 |
]
|
| 147 |
+
# Casual: full contraction set. Neutral: core negatives only (subtler).
|
| 148 |
use = pairs if is_casual(tone_l) else pairs[:12]
|
| 149 |
out = text
|
| 150 |
for pat, repl in use:
|
app/pipeline/tones.py
CHANGED
|
@@ -1,8 +1,8 @@
|
|
| 1 |
"""Tone options — four meaningfully distinct registers.
|
| 2 |
|
| 3 |
-
- Neutral — balanced default
|
| 4 |
-
- Casual — conversational
|
| 5 |
-
- Formal — professional / business elevated
|
| 6 |
- Academic — scholarly register (denser lexicon, discourse markers, frames)
|
| 7 |
"""
|
| 8 |
|
|
|
|
| 1 |
"""Tone options — four meaningfully distinct registers.
|
| 2 |
|
| 3 |
+
- Neutral — balanced default (mild elevate fill-ins at Normal+)
|
| 4 |
+
- Casual — conversational (downshift lexicon, full contractions, punchy transforms)
|
| 5 |
+
- Formal — professional / business elevated (no contractions)
|
| 6 |
- Academic — scholarly register (denser lexicon, discourse markers, frames)
|
| 7 |
"""
|
| 8 |
|
frontend/dist/assets/index.css
CHANGED
|
@@ -643,6 +643,21 @@ textarea {
|
|
| 643 |
accent-color: var(--accent);
|
| 644 |
}
|
| 645 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 646 |
.toolbar-actions {
|
| 647 |
display: flex;
|
| 648 |
align-items: center;
|
|
|
|
| 643 |
accent-color: var(--accent);
|
| 644 |
}
|
| 645 |
|
| 646 |
+
.check-locked {
|
| 647 |
+
opacity: 0.78;
|
| 648 |
+
}
|
| 649 |
+
|
| 650 |
+
.check-badge {
|
| 651 |
+
font-size: 0.68rem;
|
| 652 |
+
font-weight: 700;
|
| 653 |
+
letter-spacing: 0.04em;
|
| 654 |
+
text-transform: uppercase;
|
| 655 |
+
color: var(--accent);
|
| 656 |
+
border: 1px solid color-mix(in srgb, var(--accent) 35%, transparent);
|
| 657 |
+
border-radius: 0.35rem;
|
| 658 |
+
padding: 0.08rem 0.35rem;
|
| 659 |
+
}
|
| 660 |
+
|
| 661 |
.toolbar-actions {
|
| 662 |
display: flex;
|
| 663 |
align-items: center;
|
frontend/dist/assets/index.js
CHANGED
|
@@ -632,6 +632,7 @@ function App() {
|
|
| 632 |
const [tone, setTone] = useState("Neutral");
|
| 633 |
const [strength, setStrength] = useState("Normal");
|
| 634 |
const [preserveLength, setPreserveLength] = useState(true);
|
|
|
|
| 635 |
const [loading, setLoading] = useState(false);
|
| 636 |
const [error, setError] = useState("");
|
| 637 |
const [meta, setMeta] = useState("");
|
|
@@ -654,6 +655,8 @@ function App() {
|
|
| 654 |
const activeProduct = PRODUCTS.find((p) => p.id === product) || PRODUCTS[0];
|
| 655 |
const isGuest = !!(authEnabled && !session);
|
| 656 |
const maxWords = (account && account.plan && account.plan.max_words_per_request) || (isGuest ? guestMaxWords : 50000);
|
|
|
|
|
|
|
| 657 |
|
| 658 |
useEffect(() => {
|
| 659 |
if (!copied) return undefined;
|
|
@@ -761,12 +764,21 @@ function App() {
|
|
| 761 |
setError("");
|
| 762 |
setMeta("Rewriting…");
|
| 763 |
setFreshOut(false);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 764 |
try {
|
| 765 |
const result = await rewriteText({
|
| 766 |
text,
|
| 767 |
tone,
|
| 768 |
strength: STRENGTH_MAP[strength],
|
| 769 |
preserve_length: preserveLength,
|
|
|
|
| 770 |
}, session && session.access_token);
|
| 771 |
setOutput(result.rewrite);
|
| 772 |
setFreshOut(true);
|
|
@@ -968,6 +980,27 @@ function App() {
|
|
| 968 |
h("input", { type: "checkbox", checked: preserveLength, onChange: (e) => setPreserveLength(e.target.checked) }),
|
| 969 |
" Match length",
|
| 970 |
),
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 971 |
h("div", { className: "toolbar-actions" },
|
| 972 |
h("button", {
|
| 973 |
type: "button",
|
|
|
|
| 632 |
const [tone, setTone] = useState("Neutral");
|
| 633 |
const [strength, setStrength] = useState("Normal");
|
| 634 |
const [preserveLength, setPreserveLength] = useState(true);
|
| 635 |
+
const [mlPolish, setMlPolish] = useState(false);
|
| 636 |
const [loading, setLoading] = useState(false);
|
| 637 |
const [error, setError] = useState("");
|
| 638 |
const [meta, setMeta] = useState("");
|
|
|
|
| 655 |
const activeProduct = PRODUCTS.find((p) => p.id === product) || PRODUCTS[0];
|
| 656 |
const isGuest = !!(authEnabled && !session);
|
| 657 |
const maxWords = (account && account.plan && account.plan.max_words_per_request) || (isGuest ? guestMaxWords : 50000);
|
| 658 |
+
const planId = (account && account.plan && account.plan.id) || (authEnabled ? "guest" : "dev");
|
| 659 |
+
const canMlPolish = !authEnabled || planId === "pro" || planId === "plus";
|
| 660 |
|
| 661 |
useEffect(() => {
|
| 662 |
if (!copied) return undefined;
|
|
|
|
| 764 |
setError("");
|
| 765 |
setMeta("Rewriting…");
|
| 766 |
setFreshOut(false);
|
| 767 |
+
const wantMl = mlPolish && canMlPolish;
|
| 768 |
+
if (mlPolish && !canMlPolish) {
|
| 769 |
+
setShowUpgrade(true);
|
| 770 |
+
setError("ML polish (beta) is available on Pro and Plus.");
|
| 771 |
+
setMeta("");
|
| 772 |
+
setLoading(false);
|
| 773 |
+
return;
|
| 774 |
+
}
|
| 775 |
try {
|
| 776 |
const result = await rewriteText({
|
| 777 |
text,
|
| 778 |
tone,
|
| 779 |
strength: STRENGTH_MAP[strength],
|
| 780 |
preserve_length: preserveLength,
|
| 781 |
+
ml_polish: wantMl,
|
| 782 |
}, session && session.access_token);
|
| 783 |
setOutput(result.rewrite);
|
| 784 |
setFreshOut(true);
|
|
|
|
| 980 |
h("input", { type: "checkbox", checked: preserveLength, onChange: (e) => setPreserveLength(e.target.checked) }),
|
| 981 |
" Match length",
|
| 982 |
),
|
| 983 |
+
h("label", {
|
| 984 |
+
className: canMlPolish ? "check" : "check check-locked",
|
| 985 |
+
title: canMlPolish
|
| 986 |
+
? "MiniLM scores synonym picks for meaning + tone (Pro beta)"
|
| 987 |
+
: "ML polish (beta) requires Pro or Plus",
|
| 988 |
+
},
|
| 989 |
+
h("input", {
|
| 990 |
+
type: "checkbox",
|
| 991 |
+
checked: mlPolish && canMlPolish,
|
| 992 |
+
onChange: (e) => {
|
| 993 |
+
if (!canMlPolish) {
|
| 994 |
+
setShowUpgrade(true);
|
| 995 |
+
setMlPolish(false);
|
| 996 |
+
return;
|
| 997 |
+
}
|
| 998 |
+
setMlPolish(e.target.checked);
|
| 999 |
+
},
|
| 1000 |
+
}),
|
| 1001 |
+
" ML polish (beta)",
|
| 1002 |
+
canMlPolish ? null : h("span", { className: "check-badge" }, "Pro"),
|
| 1003 |
+
),
|
| 1004 |
h("div", { className: "toolbar-actions" },
|
| 1005 |
h("button", {
|
| 1006 |
type: "button",
|
frontend/src/App.tsx
CHANGED
|
@@ -103,10 +103,10 @@ const SAMPLE_TEXT =
|
|
| 103 |
"Artificial intelligence has significantly transformed the way individuals create written content in recent years. Many professionals now rely on advanced language models to generate initial drafts quickly and efficiently. However, the resulting text can sometimes appear repetitive, overly formal, or lacking a natural human voice. Therefore, it is essential to carefully refine AI-generated material so that it communicates clearly, remains original in wording, and feels authentic to the intended audience.";
|
| 104 |
|
| 105 |
const TONE_HINT: Record<Tone, string> = {
|
| 106 |
-
Neutral: "Clear and balanced",
|
| 107 |
-
Casual: "Conversational",
|
| 108 |
-
Formal: "Professional",
|
| 109 |
-
Academic: "Scholarly",
|
| 110 |
};
|
| 111 |
|
| 112 |
function wordCount(text: string): number {
|
|
@@ -631,6 +631,7 @@ export default function App() {
|
|
| 631 |
const [tone, setTone] = useState<Tone>("Neutral");
|
| 632 |
const [strength, setStrength] = useState<StrengthLabel>("Normal");
|
| 633 |
const [preserveLength, setPreserveLength] = useState(true);
|
|
|
|
| 634 |
const [loading, setLoading] = useState(false);
|
| 635 |
const [error, setError] = useState("");
|
| 636 |
const [meta, setMeta] = useState("");
|
|
@@ -656,6 +657,8 @@ export default function App() {
|
|
| 656 |
const maxWords = isGuest
|
| 657 |
? guestMaxWords
|
| 658 |
: account?.plan.max_words_per_request ?? 50000;
|
|
|
|
|
|
|
| 659 |
|
| 660 |
useEffect(() => {
|
| 661 |
if (!copied) return;
|
|
@@ -685,7 +688,7 @@ export default function App() {
|
|
| 685 |
window.addEventListener("keydown", onKey);
|
| 686 |
return () => window.removeEventListener("keydown", onKey);
|
| 687 |
// eslint-disable-next-line react-hooks/exhaustive-deps
|
| 688 |
-
}, [input, tone, strength, preserveLength, loading, session, product, grammarText, grammarLoading, grammarLanguage]);
|
| 689 |
|
| 690 |
function openAuth(mode: "signin" | "signup", title?: string) {
|
| 691 |
setAuthMode(mode);
|
|
@@ -795,6 +798,14 @@ export default function App() {
|
|
| 795 |
setError("");
|
| 796 |
setMeta("Rewriting…");
|
| 797 |
setFreshOut(false);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 798 |
try {
|
| 799 |
const result = await rewriteText(
|
| 800 |
{
|
|
@@ -802,6 +813,7 @@ export default function App() {
|
|
| 802 |
tone,
|
| 803 |
strength: STRENGTH_MAP[strength],
|
| 804 |
preserve_length: preserveLength,
|
|
|
|
| 805 |
},
|
| 806 |
session?.access_token,
|
| 807 |
);
|
|
@@ -1169,6 +1181,30 @@ export default function App() {
|
|
| 1169 |
/>
|
| 1170 |
Match length
|
| 1171 |
</label>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1172 |
</div>
|
| 1173 |
|
| 1174 |
<div className="toolbar-actions">
|
|
|
|
| 103 |
"Artificial intelligence has significantly transformed the way individuals create written content in recent years. Many professionals now rely on advanced language models to generate initial drafts quickly and efficiently. However, the resulting text can sometimes appear repetitive, overly formal, or lacking a natural human voice. Therefore, it is essential to carefully refine AI-generated material so that it communicates clearly, remains original in wording, and feels authentic to the intended audience.";
|
| 104 |
|
| 105 |
const TONE_HINT: Record<Tone, string> = {
|
| 106 |
+
Neutral: "Clear and balanced — light elevate only",
|
| 107 |
+
Casual: "Conversational — contractions + downshift lexicon",
|
| 108 |
+
Formal: "Professional — elevated wording, no contractions",
|
| 109 |
+
Academic: "Scholarly — denser lexicon + discourse markers",
|
| 110 |
};
|
| 111 |
|
| 112 |
function wordCount(text: string): number {
|
|
|
|
| 631 |
const [tone, setTone] = useState<Tone>("Neutral");
|
| 632 |
const [strength, setStrength] = useState<StrengthLabel>("Normal");
|
| 633 |
const [preserveLength, setPreserveLength] = useState(true);
|
| 634 |
+
const [mlPolish, setMlPolish] = useState(false);
|
| 635 |
const [loading, setLoading] = useState(false);
|
| 636 |
const [error, setError] = useState("");
|
| 637 |
const [meta, setMeta] = useState("");
|
|
|
|
| 657 |
const maxWords = isGuest
|
| 658 |
? guestMaxWords
|
| 659 |
: account?.plan.max_words_per_request ?? 50000;
|
| 660 |
+
const planId = account?.plan.id ?? (authEnabled ? "guest" : "dev");
|
| 661 |
+
const canMlPolish = !authEnabled || planId === "pro" || planId === "plus";
|
| 662 |
|
| 663 |
useEffect(() => {
|
| 664 |
if (!copied) return;
|
|
|
|
| 688 |
window.addEventListener("keydown", onKey);
|
| 689 |
return () => window.removeEventListener("keydown", onKey);
|
| 690 |
// eslint-disable-next-line react-hooks/exhaustive-deps
|
| 691 |
+
}, [input, tone, strength, preserveLength, mlPolish, loading, session, product, grammarText, grammarLoading, grammarLanguage]);
|
| 692 |
|
| 693 |
function openAuth(mode: "signin" | "signup", title?: string) {
|
| 694 |
setAuthMode(mode);
|
|
|
|
| 798 |
setError("");
|
| 799 |
setMeta("Rewriting…");
|
| 800 |
setFreshOut(false);
|
| 801 |
+
const wantMl = mlPolish && canMlPolish;
|
| 802 |
+
if (mlPolish && !canMlPolish) {
|
| 803 |
+
setShowUpgrade(true);
|
| 804 |
+
setError("ML polish (beta) is available on Pro and Plus.");
|
| 805 |
+
setMeta("");
|
| 806 |
+
setLoading(false);
|
| 807 |
+
return;
|
| 808 |
+
}
|
| 809 |
try {
|
| 810 |
const result = await rewriteText(
|
| 811 |
{
|
|
|
|
| 813 |
tone,
|
| 814 |
strength: STRENGTH_MAP[strength],
|
| 815 |
preserve_length: preserveLength,
|
| 816 |
+
ml_polish: wantMl,
|
| 817 |
},
|
| 818 |
session?.access_token,
|
| 819 |
);
|
|
|
|
| 1181 |
/>
|
| 1182 |
Match length
|
| 1183 |
</label>
|
| 1184 |
+
|
| 1185 |
+
<label
|
| 1186 |
+
className={`check${canMlPolish ? "" : " check-locked"}`}
|
| 1187 |
+
title={
|
| 1188 |
+
canMlPolish
|
| 1189 |
+
? "MiniLM scores synonym picks for meaning + tone (Pro beta)"
|
| 1190 |
+
: "ML polish (beta) requires Pro or Plus"
|
| 1191 |
+
}
|
| 1192 |
+
>
|
| 1193 |
+
<input
|
| 1194 |
+
type="checkbox"
|
| 1195 |
+
checked={mlPolish && canMlPolish}
|
| 1196 |
+
onChange={(e) => {
|
| 1197 |
+
if (!canMlPolish) {
|
| 1198 |
+
setShowUpgrade(true);
|
| 1199 |
+
setMlPolish(false);
|
| 1200 |
+
return;
|
| 1201 |
+
}
|
| 1202 |
+
setMlPolish(e.target.checked);
|
| 1203 |
+
}}
|
| 1204 |
+
/>
|
| 1205 |
+
ML polish (beta)
|
| 1206 |
+
{!canMlPolish ? <span className="check-badge">Pro</span> : null}
|
| 1207 |
+
</label>
|
| 1208 |
</div>
|
| 1209 |
|
| 1210 |
<div className="toolbar-actions">
|
frontend/src/api.ts
CHANGED
|
@@ -63,6 +63,7 @@ export async function rewriteText(
|
|
| 63 |
tone: Tone;
|
| 64 |
strength: number;
|
| 65 |
preserve_length: boolean;
|
|
|
|
| 66 |
},
|
| 67 |
accessToken?: string | null,
|
| 68 |
): Promise<RewriteResponse> {
|
|
|
|
| 63 |
tone: Tone;
|
| 64 |
strength: number;
|
| 65 |
preserve_length: boolean;
|
| 66 |
+
ml_polish?: boolean;
|
| 67 |
},
|
| 68 |
accessToken?: string | null,
|
| 69 |
): Promise<RewriteResponse> {
|
frontend/src/index.css
CHANGED
|
@@ -643,6 +643,21 @@ textarea {
|
|
| 643 |
accent-color: var(--accent);
|
| 644 |
}
|
| 645 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 646 |
.toolbar-actions {
|
| 647 |
display: flex;
|
| 648 |
align-items: center;
|
|
|
|
| 643 |
accent-color: var(--accent);
|
| 644 |
}
|
| 645 |
|
| 646 |
+
.check-locked {
|
| 647 |
+
opacity: 0.78;
|
| 648 |
+
}
|
| 649 |
+
|
| 650 |
+
.check-badge {
|
| 651 |
+
font-size: 0.68rem;
|
| 652 |
+
font-weight: 700;
|
| 653 |
+
letter-spacing: 0.04em;
|
| 654 |
+
text-transform: uppercase;
|
| 655 |
+
color: var(--accent);
|
| 656 |
+
border: 1px solid color-mix(in srgb, var(--accent) 35%, transparent);
|
| 657 |
+
border-radius: 0.35rem;
|
| 658 |
+
padding: 0.08rem 0.35rem;
|
| 659 |
+
}
|
| 660 |
+
|
| 661 |
.toolbar-actions {
|
| 662 |
display: flex;
|
| 663 |
align-items: center;
|
requirements.txt
CHANGED
|
@@ -9,3 +9,5 @@ python-multipart>=0.0.9
|
|
| 9 |
httpx>=0.27.0
|
| 10 |
PyJWT[crypto]>=2.8.0
|
| 11 |
python-dotenv>=1.0.0
|
|
|
|
|
|
|
|
|
| 9 |
httpx>=0.27.0
|
| 10 |
PyJWT[crypto]>=2.8.0
|
| 11 |
python-dotenv>=1.0.0
|
| 12 |
+
# Optional MiniLM scoring for Pro "ML polish" (ONNX via fastembed; graceful no-op if missing)
|
| 13 |
+
fastembed>=0.4.2
|