idnameraj commited on
Commit
3b361f1
·
verified ·
1 Parent(s): 6718023

Upload 81 files

Browse files
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 check_grammar, languagetool_reachable, normalize_language, rules_fallback_result
 
 
 
 
 
 
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
- rng = _rng_for(original + "|" + tone + "|" + str(strength))
 
 
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 sometimes keeps end
326
- if is_casual(tone) and rng.random() < 0.35:
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.1,
393
- "Neutral": 0.05,
394
- "Formal": 0.12,
395
- "Academic": 0.18,
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
- - Neutral/Casual: preferred_swaps; Neutral also mild elevate fill-ins
 
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(cands: list[str], tone: str, rng: random.Random) -> str:
 
 
 
 
 
 
 
267
  if not cands:
268
  return ""
269
- if is_elevated(tone):
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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. Neutral/Casual: adj/adv WordNet allowed.
 
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(raw, _pick(cands, tone, rng))
 
 
 
 
 
 
 
 
 
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(tok, cands[0])
 
 
 
 
 
 
 
 
 
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