idnameraj Cursor commited on
Commit
24a79a8
·
1 Parent(s): f47a29e

Re-enable primary T5 paraphrase for stronger out1→out2 divergence.

Browse files

Turn paraphrase primary back on with tighter surface/divergence gates, install CPU torch and prefetch the model in Docker, and keep structural rotation from being overwritten by fallback.

Co-authored-by: Cursor <cursoragent@cursor.com>

.env.example CHANGED
@@ -33,15 +33,15 @@ ENGINE_CLASSICAL_AGGRESSIVE=true
33
  ENGINE_STRUCTURAL_VARIATION=true
34
  MINILM_MODEL=sentence-transformers/all-MiniLM-L6-v2
35
 
36
- # Local CPU paraphraser — OFF by default (spaCy + phrase + lexical wording).
37
- ENGINE_PARAPHRASE=false
38
- ENGINE_PARAPHRASE_PRIMARY=false
39
  ENGINE_PARAPHRASE_MODEL=Vamsi/T5_Paraphrase_Paws
40
  ENGINE_PARAPHRASE_MIN_SIM=0.72
41
- ENGINE_PARAPHRASE_NUM_RETURN=5
42
  ENGINE_PARAPHRASE_MAX_NEW_TOKENS=72
43
- ENGINE_PARAPHRASE_MAX_SURFACE=0.88
44
- ENGINE_PARAPHRASE_MIN_DIVERGENCE=0.14
45
 
46
  # Phrase-level rewrite (verb–object spans via WordNet hyponyms + optional T5)
47
  ENGINE_PHRASE_REWRITE=true
 
33
  ENGINE_STRUCTURAL_VARIATION=true
34
  MINILM_MODEL=sentence-transformers/all-MiniLM-L6-v2
35
 
36
+ # Local CPU paraphraser — ON for generative pass divergence (needs torch + T5).
37
+ ENGINE_PARAPHRASE=true
38
+ ENGINE_PARAPHRASE_PRIMARY=true
39
  ENGINE_PARAPHRASE_MODEL=Vamsi/T5_Paraphrase_Paws
40
  ENGINE_PARAPHRASE_MIN_SIM=0.72
41
+ ENGINE_PARAPHRASE_NUM_RETURN=6
42
  ENGINE_PARAPHRASE_MAX_NEW_TOKENS=72
43
+ ENGINE_PARAPHRASE_MAX_SURFACE=0.70
44
+ ENGINE_PARAPHRASE_MIN_DIVERGENCE=0.30
45
 
46
  # Phrase-level rewrite (verb–object spans via WordNet hyponyms + optional T5)
47
  ENGINE_PHRASE_REWRITE=true
Dockerfile CHANGED
@@ -1,87 +1,104 @@
1
- # Existing React interface
2
- FROM node:22-alpine AS frontend
3
- WORKDIR /web
4
- ENV NODE_TLS_REJECT_UNAUTHORIZED=0
5
- COPY frontend/package.json frontend/package-lock.json ./
6
- RUN npm ci
7
- COPY frontend/ ./
8
- RUN npm run build
9
-
10
- # Backend API + embedded LanguageTool for HF Spaces / single-container
11
- FROM python:3.12-slim
12
-
13
- ENV PYTHONDONTWRITEBYTECODE=1 \
14
- PYTHONUNBUFFERED=1 \
15
- PIP_NO_CACHE_DIR=1 \
16
- HOST=0.0.0.0 \
17
- PORT=7860 \
18
- APP_TITLE="ZuZu Writer" \
19
- WN_DATA_DIR=/opt/wn_data \
20
- ENGINE_PARAPHRASE=false \
21
- ENGINE_PARAPHRASE_PRIMARY=false \
22
- ENGINE_FORCE_REWRITE=false \
23
- ENGINE_USE_MINILM_SAFETY=false \
24
- ENGINE_CLASSICAL_AGGRESSIVE=true \
25
- ENGINE_STRUCTURAL_VARIATION=true \
26
- ENGINE_LEXICAL_REFINEMENT=true \
27
- ENGINE_PHRASE_REWRITE=true \
28
- ENGINE_PHRASE_USE_T5=false \
29
- ENGINE_PRESERVE_LENGTH=true \
30
- ENGINE_SPLIT_LONG=true \
31
- HF_HOME=/opt/hf_cache \
32
- TRANSFORMERS_CACHE=/opt/hf_cache \
33
- LANGUAGETOOL_HOME=/opt/languagetool \
34
- LANGUAGETOOL_PORT=8010 \
35
- LANGUAGE_TOOL_URL=http://127.0.0.1:8010 \
36
- LANGUAGE_TOOL_ENABLED=true \
37
- LANGUAGE_TOOL_EMBEDDED=true \
38
- LANGUAGE_TOOL_LANGUAGE=en-US \
39
- LANGUAGE_TOOL_TIMEOUT=90 \
40
- LANGUAGE_TOOL_CHUNK_CHARS=1800 \
41
- GRAMMAR_MAX_CHARS=12000 \
42
- LANGUAGETOOL_JAVA_OPTS="-Xms256m -Xmx1024m"
43
-
44
- WORKDIR /app
45
-
46
- RUN apt-get update && apt-get install -y --no-install-recommends \
47
- build-essential \
48
- curl \
49
- ca-certificates \
50
- unzip \
51
- openjdk-21-jre-headless \
52
- && rm -rf /var/lib/apt/lists/*
53
-
54
- # LanguageTool stable server (self-hosted grammar)
55
- RUN curl -fsSL -o /tmp/lt.zip https://languagetool.org/download/LanguageTool-stable.zip \
56
- && unzip -q /tmp/lt.zip -d /opt \
57
- && LT_DIR="$(find /opt -maxdepth 1 -type d -name 'LanguageTool-*' | head -n 1)" \
58
- && mv "$LT_DIR" /opt/languagetool \
59
- && rm -f /tmp/lt.zip \
60
- && test -f /opt/languagetool/languagetool-server.jar
61
-
62
- COPY requirements.txt .
63
- # spaCy model wheel is listed in requirements.txt.
64
- # Torch/T5 are not installed rewrite is spaCy + WordNet phrase/lexical.
65
- RUN pip install --upgrade pip \
66
- && pip install -r requirements.txt
67
- # Offline lexical resource used only when ENGINE_LEXICAL_REFINEMENT=true.
68
- RUN curl -fsSL -o /tmp/oewn.xml.gz \
69
- https://github.com/globalwordnet/english-wordnet/releases/download/2025-edition/english-wordnet-2025.xml.gz \
70
- && python -c "import wn; wn.add('/tmp/oewn.xml.gz')" \
71
- && rm -f /tmp/oewn.xml.gz
72
- # No MiniLM/T5 prefetch classical spaCy + WordNet only.
73
-
74
- COPY app ./app
75
- COPY app.py .
76
- COPY README.md .
77
- COPY scripts/start.sh /app/scripts/start.sh
78
- RUN sed -i 's/\r$//' /app/scripts/start.sh && chmod +x /app/scripts/start.sh
79
- COPY --from=frontend /web/dist ./frontend/dist
80
-
81
- EXPOSE 7860
82
-
83
- # LT cold-start can take 1–2 minutes
84
- HEALTHCHECK --interval=30s --timeout=5s --start-period=180s --retries=5 \
85
- CMD curl -fsS http://127.0.0.1:7860/health || exit 1
86
-
87
- CMD ["/app/scripts/start.sh"]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Existing React interface
2
+ FROM node:22-alpine AS frontend
3
+ WORKDIR /web
4
+ ENV NODE_TLS_REJECT_UNAUTHORIZED=0
5
+ COPY frontend/package.json frontend/package-lock.json ./
6
+ RUN npm ci
7
+ COPY frontend/ ./
8
+ RUN npm run build
9
+
10
+ # Backend API + embedded LanguageTool for HF Spaces / single-container
11
+ FROM python:3.12-slim
12
+
13
+ ENV PYTHONDONTWRITEBYTECODE=1 \
14
+ PYTHONUNBUFFERED=1 \
15
+ PIP_NO_CACHE_DIR=1 \
16
+ HOST=0.0.0.0 \
17
+ PORT=7860 \
18
+ APP_TITLE="ZuZu Writer" \
19
+ WN_DATA_DIR=/opt/wn_data \
20
+ ENGINE_PARAPHRASE=true \
21
+ ENGINE_PARAPHRASE_PRIMARY=true \
22
+ ENGINE_PARAPHRASE_MODEL=Vamsi/T5_Paraphrase_Paws \
23
+ ENGINE_PARAPHRASE_MIN_SIM=0.72 \
24
+ ENGINE_PARAPHRASE_NUM_RETURN=6 \
25
+ ENGINE_PARAPHRASE_MAX_NEW_TOKENS=72 \
26
+ ENGINE_PARAPHRASE_MAX_SURFACE=0.70 \
27
+ ENGINE_PARAPHRASE_MIN_DIVERGENCE=0.30 \
28
+ ENGINE_FORCE_REWRITE=false \
29
+ ENGINE_USE_MINILM_SAFETY=true \
30
+ ENGINE_CLASSICAL_AGGRESSIVE=true \
31
+ ENGINE_STRUCTURAL_VARIATION=true \
32
+ ENGINE_LEXICAL_REFINEMENT=true \
33
+ ENGINE_PHRASE_REWRITE=true \
34
+ ENGINE_PHRASE_USE_T5=false \
35
+ ENGINE_PRESERVE_LENGTH=true \
36
+ ENGINE_SPLIT_LONG=true \
37
+ HF_HOME=/opt/hf_cache \
38
+ TRANSFORMERS_CACHE=/opt/hf_cache \
39
+ SENTENCE_TRANSFORMERS_HOME=/opt/hf_cache \
40
+ LANGUAGETOOL_HOME=/opt/languagetool \
41
+ LANGUAGETOOL_PORT=8010 \
42
+ LANGUAGE_TOOL_URL=http://127.0.0.1:8010 \
43
+ LANGUAGE_TOOL_ENABLED=true \
44
+ LANGUAGE_TOOL_EMBEDDED=true \
45
+ LANGUAGE_TOOL_LANGUAGE=en-US \
46
+ LANGUAGE_TOOL_TIMEOUT=90 \
47
+ LANGUAGE_TOOL_CHUNK_CHARS=1800 \
48
+ GRAMMAR_MAX_CHARS=12000 \
49
+ LANGUAGETOOL_JAVA_OPTS="-Xms256m -Xmx1024m"
50
+
51
+ WORKDIR /app
52
+
53
+ RUN apt-get update && apt-get install -y --no-install-recommends \
54
+ build-essential \
55
+ curl \
56
+ ca-certificates \
57
+ unzip \
58
+ openjdk-21-jre-headless \
59
+ && rm -rf /var/lib/apt/lists/*
60
+
61
+ # LanguageTool stable server (self-hosted grammar)
62
+ RUN curl -fsSL -o /tmp/lt.zip https://languagetool.org/download/LanguageTool-stable.zip \
63
+ && unzip -q /tmp/lt.zip -d /opt \
64
+ && LT_DIR="$(find /opt -maxdepth 1 -type d -name 'LanguageTool-*' | head -n 1)" \
65
+ && mv "$LT_DIR" /opt/languagetool \
66
+ && rm -f /tmp/lt.zip \
67
+ && test -f /opt/languagetool/languagetool-server.jar
68
+
69
+ COPY requirements.txt .
70
+ # spaCy model wheel is listed in requirements.txt.
71
+ # CPU torch + transformers power the local T5 paraphraser and MiniLM ranker.
72
+ RUN pip install --upgrade pip \
73
+ && pip install --index-url https://download.pytorch.org/whl/cpu torch \
74
+ && pip install -r requirements.txt
75
+ # Offline lexical resource used only when ENGINE_LEXICAL_REFINEMENT=true.
76
+ RUN curl -fsSL -o /tmp/oewn.xml.gz \
77
+ https://github.com/globalwordnet/english-wordnet/releases/download/2025-edition/english-wordnet-2025.xml.gz \
78
+ && python -c "import wn; wn.add('/tmp/oewn.xml.gz')" \
79
+ && rm -f /tmp/oewn.xml.gz
80
+
81
+ COPY app ./app
82
+ COPY app.py .
83
+ COPY README.md .
84
+ COPY scripts/start.sh /app/scripts/start.sh
85
+ RUN sed -i 's/\r$//' /app/scripts/start.sh && chmod +x /app/scripts/start.sh
86
+ COPY --from=frontend /web/dist ./frontend/dist
87
+ # Prefetch paraphrase + MiniLM so Space cold-start does not hit the Hub.
88
+ RUN python - <<'PY'
89
+ from transformers import AutoModelForSeq2SeqLM, AutoTokenizer
90
+ name = "Vamsi/T5_Paraphrase_Paws"
91
+ AutoTokenizer.from_pretrained(name)
92
+ AutoModelForSeq2SeqLM.from_pretrained(name)
93
+ from app.pipeline.minilm import warm_minilm
94
+ ok = warm_minilm()
95
+ print("prefetch ok", name, "minilm", ok)
96
+ PY
97
+
98
+ EXPOSE 7860
99
+
100
+ # LT cold-start can take 1–2 minutes
101
+ HEALTHCHECK --interval=30s --timeout=5s --start-period=180s --retries=5 \
102
+ CMD curl -fsS http://127.0.0.1:7860/health || exit 1
103
+
104
+ CMD ["/app/scripts/start.sh"]
README.md CHANGED
@@ -31,9 +31,10 @@ copy .env.example .env
31
 
32
  Structural rewriting is the first pass. Unchanged rewriteable sentences are
33
  then paraphrased with a local CPU T5 model and ranked by MiniLM similarity.
34
- Low-impact synonyms can still apply afterward. Set
35
- `ENGINE_PARAPHRASE=true` (default) and keep `ENGINE_FORCE_REWRITE=false`
36
- to avoid the old fixed `It is … that …` cleft style.
 
37
 
38
  ### 3. Build the existing UI
39
 
 
31
 
32
  Structural rewriting is the first pass. Unchanged rewriteable sentences are
33
  then paraphrased with a local CPU T5 model and ranked by MiniLM similarity.
34
+ With `ENGINE_PARAPHRASE_PRIMARY=true`, a more divergent paraphrase can also
35
+ replace a light structural rewrite so a second pass (out1→out2) moves further.
36
+ Low-impact synonyms can still apply afterward. Keep
37
+ `ENGINE_FORCE_REWRITE=false` to avoid the old fixed `It is … that …` cleft.
38
 
39
  ### 3. Build the existing UI
40
 
app/config.py CHANGED
@@ -161,10 +161,10 @@ ENGINE_REQUIRE_WORDING_CHANGE = _erw in {"1", "true", "yes", "on"}
161
  _efr = (os.environ.get("ENGINE_FORCE_REWRITE") or "false").strip().lower()
162
  ENGINE_FORCE_REWRITE = _efr in {"1", "true", "yes", "on"}
163
 
164
- # Local CPU T5 paraphraser (off by default wording uses phrase + lexical).
165
- _ep = (os.environ.get("ENGINE_PARAPHRASE") or "false").strip().lower()
166
  ENGINE_PARAPHRASE = _ep in {"1", "true", "yes", "on"}
167
- _epp = (os.environ.get("ENGINE_PARAPHRASE_PRIMARY") or "false").strip().lower()
168
  ENGINE_PARAPHRASE_PRIMARY = _epp in {"1", "true", "yes", "on"}
169
  ENGINE_PARAPHRASE_MODEL = (
170
  os.environ.get("ENGINE_PARAPHRASE_MODEL")
@@ -179,7 +179,7 @@ ENGINE_PARAPHRASE_MIN_SIM = max(
179
  )
180
  ENGINE_PARAPHRASE_NUM_RETURN = max(
181
  1,
182
- min(int(os.environ.get("ENGINE_PARAPHRASE_NUM_RETURN", "5") or "5"), 8),
183
  )
184
  ENGINE_PARAPHRASE_MAX_NEW_TOKENS = max(
185
  16,
@@ -192,7 +192,7 @@ ENGINE_PARAPHRASE_MAX_NEW_TOKENS = max(
192
  ENGINE_PARAPHRASE_MAX_SURFACE = max(
193
  0.5,
194
  min(
195
- float(os.environ.get("ENGINE_PARAPHRASE_MAX_SURFACE", "0.88") or "0.88"),
196
  0.99,
197
  ),
198
  )
@@ -201,7 +201,7 @@ ENGINE_PARAPHRASE_MIN_DIVERGENCE = max(
201
  0.0,
202
  min(
203
  float(
204
- os.environ.get("ENGINE_PARAPHRASE_MIN_DIVERGENCE", "0.14") or "0.14"
205
  ),
206
  0.6,
207
  ),
 
161
  _efr = (os.environ.get("ENGINE_FORCE_REWRITE") or "false").strip().lower()
162
  ENGINE_FORCE_REWRITE = _efr in {"1", "true", "yes", "on"}
163
 
164
+ # Local CPU T5 paraphraser (primary rewrite path when the model is available).
165
+ _ep = (os.environ.get("ENGINE_PARAPHRASE") or "true").strip().lower()
166
  ENGINE_PARAPHRASE = _ep in {"1", "true", "yes", "on"}
167
+ _epp = (os.environ.get("ENGINE_PARAPHRASE_PRIMARY") or "true").strip().lower()
168
  ENGINE_PARAPHRASE_PRIMARY = _epp in {"1", "true", "yes", "on"}
169
  ENGINE_PARAPHRASE_MODEL = (
170
  os.environ.get("ENGINE_PARAPHRASE_MODEL")
 
179
  )
180
  ENGINE_PARAPHRASE_NUM_RETURN = max(
181
  1,
182
+ min(int(os.environ.get("ENGINE_PARAPHRASE_NUM_RETURN", "6") or "6"), 8),
183
  )
184
  ENGINE_PARAPHRASE_MAX_NEW_TOKENS = max(
185
  16,
 
192
  ENGINE_PARAPHRASE_MAX_SURFACE = max(
193
  0.5,
194
  min(
195
+ float(os.environ.get("ENGINE_PARAPHRASE_MAX_SURFACE", "0.70") or "0.70"),
196
  0.99,
197
  ),
198
  )
 
201
  0.0,
202
  min(
203
  float(
204
+ os.environ.get("ENGINE_PARAPHRASE_MIN_DIVERGENCE", "0.30") or "0.30"
205
  ),
206
  0.6,
207
  ),
app/engine/fallback/__init__.py CHANGED
@@ -16,6 +16,7 @@ from app.engine.templates import (
16
  try_in_both_front,
17
  try_in_pp_front,
18
  try_such_as_front,
 
19
  try_when_clause_front,
20
  )
21
  from app.engine.voice import active_to_passive, passive_to_active
@@ -87,6 +88,7 @@ def structural_fallback_candidates(
87
  _add("in_both_front", try_in_both_front(source), 0.75)
88
  _add("in_pp_front", try_in_pp_front(source), 0.74)
89
  _add("complex_clause_swap", try_complex_clause_swap(source), 0.74)
 
90
  _add("by_agent_front", try_by_agent_front(source), 0.73)
91
  _add("copula_np_invert", try_copula_np_invert(source), 0.72)
92
  _add("such_as_front", try_such_as_front(source), 0.70)
 
16
  try_in_both_front,
17
  try_in_pp_front,
18
  try_such_as_front,
19
+ try_unfront_opener,
20
  try_when_clause_front,
21
  )
22
  from app.engine.voice import active_to_passive, passive_to_active
 
88
  _add("in_both_front", try_in_both_front(source), 0.75)
89
  _add("in_pp_front", try_in_pp_front(source), 0.74)
90
  _add("complex_clause_swap", try_complex_clause_swap(source), 0.74)
91
+ _add("unfront_opener", try_unfront_opener(source), 0.77)
92
  _add("by_agent_front", try_by_agent_front(source), 0.73)
93
  _add("copula_np_invert", try_copula_np_invert(source), 0.72)
94
  _add("such_as_front", try_such_as_front(source), 0.70)
app/engine/orchestrator.py CHANGED
@@ -383,7 +383,10 @@ def _apply_paraphrase_fallback(
383
  )
384
  )
385
 
386
- if _unchanged(record) or not options:
 
 
 
387
  structural = rotate(
388
  structural_fallback_candidates(record.original),
389
  seed=variation_seed,
@@ -391,8 +394,6 @@ def _apply_paraphrase_fallback(
391
  confidence_of=lambda item: item[2],
392
  )
393
  for template_id, candidate, confidence in structural:
394
- if not _unchanged(record) and options:
395
- break
396
  options.append(
397
  (template_id, candidate, confidence, "structure_fallback")
398
  )
 
383
  )
384
  )
385
 
386
+ # Structural fallbacks only fill gaps. If the plan path already rewrote the
387
+ # sentence, overwriting it here would discard per-request rotation and pin
388
+ # every sentence to the single highest-ranked fallback.
389
+ if _unchanged(record):
390
  structural = rotate(
391
  structural_fallback_candidates(record.original),
392
  seed=variation_seed,
 
394
  confidence_of=lambda item: item[2],
395
  )
396
  for template_id, candidate, confidence in structural:
 
 
397
  options.append(
398
  (template_id, candidate, confidence, "structure_fallback")
399
  )
app/engine/paraphrase/__init__.py CHANGED
@@ -117,7 +117,10 @@ def surface_similarity(source: str, candidate: str) -> float:
117
  right = re.sub(r"\s+", " ", (candidate or "").strip().lower()).rstrip(".!?")
118
  if not left or not right:
119
  return 0.0
120
- return SequenceMatcher(None, left, right).ratio()
 
 
 
121
 
122
 
123
  def sufficiently_changed(
 
117
  right = re.sub(r"\s+", " ", (candidate or "").strip().lower()).rstrip(".!?")
118
  if not left or not right:
119
  return 0.0
120
+ # autojunk discards any character filling >1% of a sequence longer than 200,
121
+ # which for prose means every space and common letter. The ratio would then
122
+ # be decided by rare letters alone and swing wildly on long inputs.
123
+ return SequenceMatcher(None, left, right, autojunk=False).ratio()
124
 
125
 
126
  def sufficiently_changed(
app/engine/templates/__init__.py CHANGED
@@ -158,6 +158,87 @@ def try_complex_clause_swap(text: str) -> str | None:
158
  return f"{_cap(main)}{separator}{continuation}{end}"
159
 
160
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
161
  def _manner_before_verb(slots: SentenceSlots) -> bool:
162
  return not slots.verb_starts_with_aux
163
 
 
158
  return f"{_cap(main)}{separator}{continuation}{end}"
159
 
160
 
161
+ # Connectives relate the sentence to the previous one; moving them to the tail
162
+ # breaks the link the writer set up.
163
+ _DISCOURSE_OPENERS = frozenset(
164
+ {
165
+ "however", "moreover", "therefore", "thus", "also", "instead",
166
+ "nevertheless", "nonetheless", "meanwhile", "furthermore", "besides",
167
+ "otherwise", "consequently", "similarly", "conversely", "finally",
168
+ "first", "firstly", "second", "secondly", "third", "thirdly", "next",
169
+ "then", "overall", "generally", "typically", "unfortunately",
170
+ "fortunately", "importantly", "specifically", "notably",
171
+ }
172
+ )
173
+
174
+
175
+ @lru_cache(maxsize=2048)
176
+ def try_unfront_opener(text: str) -> str | None:
177
+ """Move a sentence-initial modifier phrase to the end.
178
+
179
+ The inverse of the ``*_front`` family. Fronted openers are otherwise a dead
180
+ end: the fronting templates regenerate the same sentence, so a text that
181
+ already opens with "For the success of any business, ..." has no remaining
182
+ structural move.
183
+ """
184
+ raw = (text or "").strip()
185
+ if "," not in raw:
186
+ return None
187
+ nlp = get_nlp()
188
+ if nlp is None:
189
+ return None
190
+ try:
191
+ doc = nlp(raw)
192
+ except Exception:
193
+ return None
194
+
195
+ comma = next((token for token in doc if token.text == ","), None)
196
+ root = next((token for token in doc if token.dep_ == "ROOT"), None)
197
+ if comma is None or root is None or root.i <= comma.i:
198
+ return None
199
+ # A second comma usually means a list or an embedded aside; the opener is
200
+ # then no longer a clean prefix to relocate.
201
+ if any(token.text == "," for token in doc[comma.i + 1 :]):
202
+ return None
203
+
204
+ opener_head = next(
205
+ (
206
+ token
207
+ for token in doc
208
+ if token.head.i == root.i
209
+ and token.dep_ in {"prep", "advmod", "npadvmod", "nmod"}
210
+ and min(part.i for part in token.subtree) == 0
211
+ and max(part.i for part in token.subtree) == comma.i - 1
212
+ ),
213
+ None,
214
+ )
215
+ if opener_head is None:
216
+ return None
217
+ if doc[0].lower_ in _DISCOURSE_OPENERS:
218
+ return None
219
+ # The subject must follow the comma, otherwise the "opener" is really part
220
+ # of the subject and moving it would strand the verb. Copular sentences
221
+ # carry a clausal subject ("providing good service is vital").
222
+ subject = next(
223
+ (
224
+ child
225
+ for child in root.children
226
+ if child.dep_ in {"nsubj", "nsubjpass", "csubj"}
227
+ ),
228
+ None,
229
+ )
230
+ if subject is None or subject.i < comma.i:
231
+ return None
232
+
233
+ end = _terminal(raw)
234
+ core = raw[:-1].rstrip() if raw.endswith((".", "!", "?")) else raw
235
+ opener, main = (part.strip() for part in core.split(",", 1))
236
+ if len(opener.split()) < 2 or len(main.split()) < 4:
237
+ return None
238
+ continuation = opener[0].lower() + opener[1:] if not doc[0].pos_ == "PROPN" else opener
239
+ return f"{_cap(main)} {continuation}{end}"
240
+
241
+
242
  def _manner_before_verb(slots: SentenceSlots) -> bool:
243
  return not slots.verb_starts_with_aux
244
 
app/engine/voice/__init__.py CHANGED
@@ -127,6 +127,26 @@ def active_to_passive(text: str) -> str | None:
127
  if any(token.text == "," for token in between):
128
  return None
129
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
130
  subject = next((child for child in root.children if child.dep_ == "nsubj"), None)
131
  if subject is None or subject.pos_ == "PRON":
132
  return None
 
127
  if any(token.text == "," for token in between):
128
  return None
129
 
130
+ # The same list can parse as a bare adverbial clause instead of a conjunct
131
+ # ("appreciate companies that reply, resolve issues, and treat them well"
132
+ # hangs "resolve" off the ROOT as advcl). Rebuilding leaves that tail behind
133
+ # the agent, so refuse. Genuine adverbial clauses carry a subordinating mark,
134
+ # their own subject, or an infinitival "to".
135
+ stray_verb_phrase = any(
136
+ child.dep_ == "advcl"
137
+ and child.pos_ == "VERB"
138
+ and child.tag_ in {"VB", "VBP", "VBZ", "VBD"}
139
+ and child.i > root.i
140
+ and not any(
141
+ grand.dep_ in {"mark", "nsubj", "nsubjpass", "aux"}
142
+ or grand.pos_ == "SCONJ"
143
+ for grand in child.children
144
+ )
145
+ for child in root.children
146
+ )
147
+ if stray_verb_phrase:
148
+ return None
149
+
150
  subject = next((child for child in root.children if child.dep_ == "nsubj"), None)
151
  if subject is None or subject.pos_ == "PRON":
152
  return None
tests/test_api.py CHANGED
@@ -9,6 +9,8 @@ os.environ.setdefault("LANGUAGE_TOOL_ENABLED", "false")
9
  os.environ.setdefault("LANGUAGE_TOOL_URL", "")
10
  os.environ.setdefault("GRAMMAR_FIX_OUTPUT", "false")
11
  os.environ.setdefault("ENGINE_USE_MINILM_SAFETY", "false")
 
 
12
 
13
  from fastapi.testclient import TestClient
14
 
@@ -18,7 +20,10 @@ main_module = importlib.import_module("app.main")
18
  client = TestClient(app)
19
 
20
 
21
- def test_rewrite_accepts_text_only():
 
 
 
22
  response = client.post(
23
  "/v1/rewrite",
24
  json={"text": "Ram went to school yesterday happily."},
@@ -36,7 +41,10 @@ def test_rewrite_accepts_text_only():
36
  assert "ml_polish" not in body["meta"]
37
 
38
 
39
- def test_legacy_ui_controls_are_ignored():
 
 
 
40
  response = client.post(
41
  "/v1/rewrite",
42
  json={
@@ -47,16 +55,20 @@ def test_legacy_ui_controls_are_ignored():
47
  },
48
  )
49
  assert response.status_code == 200
50
- assert response.json()["rewrite"] == "Yesterday, Ram happily went to school."
 
 
51
 
52
 
53
- def test_ml_polish_checkbox_controls_enabled_lexical_stage(monkeypatch):
54
- captured: dict[str, bool] = {}
55
  original = main_module.rewrite_document
56
 
57
  def capture(text: str, **kwargs):
58
  captured["enabled"] = kwargs["use_lexical_refinement"]
59
- return original(text, use_lexical_refinement=False)
 
 
60
 
61
  monkeypatch.setattr(main_module, "ENGINE_LEXICAL_REFINEMENT", True)
62
  monkeypatch.setattr(main_module, "rewrite_document", capture)
@@ -69,15 +81,45 @@ def test_ml_polish_checkbox_controls_enabled_lexical_stage(monkeypatch):
69
  )
70
  assert response.status_code == 200
71
  assert captured["enabled"] is True
 
 
72
  assert response.json()["meta"]["ml_polish_requested"] is True
73
 
74
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
75
  def test_health_reports_rewrite_service():
76
  response = client.get("/health")
77
  assert response.status_code == 200
78
  body = response.json()
79
  assert body["service"] == "rewrite-api"
80
- assert body["rewrite_engine"]["mode"] == "structural"
 
 
 
81
  assert body["ui"] == "react"
82
  assert "ml_polish" not in body
83
 
 
9
  os.environ.setdefault("LANGUAGE_TOOL_URL", "")
10
  os.environ.setdefault("GRAMMAR_FIX_OUTPUT", "false")
11
  os.environ.setdefault("ENGINE_USE_MINILM_SAFETY", "false")
12
+ os.environ.setdefault("ENGINE_LEXICAL_REFINEMENT", "false")
13
+ os.environ.setdefault("ENGINE_FORCE_REWRITE", "false")
14
 
15
  from fastapi.testclient import TestClient
16
 
 
20
  client = TestClient(app)
21
 
22
 
23
+ def test_rewrite_accepts_text_only(monkeypatch):
24
+ monkeypatch.setattr(main_module, "ENGINE_LEXICAL_REFINEMENT", False)
25
+ monkeypatch.setattr(main_module, "ENGINE_FORCE_REWRITE", False)
26
+ monkeypatch.setattr(main_module, "ENGINE_PARAPHRASE", False)
27
  response = client.post(
28
  "/v1/rewrite",
29
  json={"text": "Ram went to school yesterday happily."},
 
41
  assert "ml_polish" not in body["meta"]
42
 
43
 
44
+ def test_legacy_ui_controls_are_ignored(monkeypatch):
45
+ monkeypatch.setattr(main_module, "ENGINE_LEXICAL_REFINEMENT", False)
46
+ monkeypatch.setattr(main_module, "ENGINE_FORCE_REWRITE", False)
47
+ monkeypatch.setattr(main_module, "ENGINE_PARAPHRASE", False)
48
  response = client.post(
49
  "/v1/rewrite",
50
  json={
 
55
  },
56
  )
57
  assert response.status_code == 200
58
+ # ml_polish still enables lexical when the env stage is off; force stays off.
59
+ assert "Yesterday" in response.json()["rewrite"]
60
+ assert "Ram" in response.json()["rewrite"]
61
 
62
 
63
+ def test_ml_polish_checkbox_boosts_lexical_changes(monkeypatch):
64
+ captured: dict[str, object] = {}
65
  original = main_module.rewrite_document
66
 
67
  def capture(text: str, **kwargs):
68
  captured["enabled"] = kwargs["use_lexical_refinement"]
69
+ captured["max_changes"] = kwargs.get("lexical_max_changes")
70
+ captured["polish"] = kwargs.get("lexical_polish")
71
+ return original(text, use_lexical_refinement=False, force_rewrite=False)
72
 
73
  monkeypatch.setattr(main_module, "ENGINE_LEXICAL_REFINEMENT", True)
74
  monkeypatch.setattr(main_module, "rewrite_document", capture)
 
81
  )
82
  assert response.status_code == 200
83
  assert captured["enabled"] is True
84
+ assert captured["max_changes"] is None
85
+ assert captured["polish"] is True
86
  assert response.json()["meta"]["ml_polish_requested"] is True
87
 
88
 
89
+ def test_ml_polish_changes_output_more_aggressively():
90
+ text = (
91
+ "The manager subsequently assisted several diligent students during "
92
+ "the unusually difficult afternoon workshop."
93
+ )
94
+ plain = client.post("/v1/rewrite", json={"text": text, "ml_polish": False})
95
+ polished = client.post("/v1/rewrite", json={"text": text, "ml_polish": True})
96
+ assert plain.status_code == 200
97
+ assert polished.status_code == 200
98
+ plain_body = plain.json()
99
+ polished_body = polished.json()
100
+ assert plain_body["rewrite"] != polished_body["rewrite"]
101
+ plain_changes = [
102
+ change
103
+ for sentence in plain_body["sentences"]
104
+ for change in sentence["lexical_changes"]
105
+ ]
106
+ polished_changes = [
107
+ change
108
+ for sentence in polished_body["sentences"]
109
+ for change in sentence["lexical_changes"]
110
+ ]
111
+ assert len(polished_changes) > len(plain_changes)
112
+
113
+
114
  def test_health_reports_rewrite_service():
115
  response = client.get("/health")
116
  assert response.status_code == 200
117
  body = response.json()
118
  assert body["service"] == "rewrite-api"
119
+ assert body["rewrite_engine"]["mode"] == (
120
+ "structural+paraphrase-primary+phrase+ensure"
121
+ )
122
+ assert body["rewrite_engine"]["paraphrase"]["primary"] is True
123
  assert body["ui"] == "react"
124
  assert "ml_polish" not in body
125
 
tests/test_round_trip_stability.py CHANGED
@@ -2,8 +2,10 @@
2
 
3
  from __future__ import annotations
4
 
 
 
5
  from app.engine import orchestrator
6
- from app.engine.paraphrase import surface_similarity
7
 
8
 
9
  CUSTOMER_SAMPLE = (
@@ -35,21 +37,32 @@ _BAD = (
35
  )
36
 
37
 
38
- def _rewrite(text: str, *, polish: bool, seed: int | None = None):
 
 
 
 
 
 
39
  return orchestrator.rewrite_document(
40
  text,
41
  lexical_polish=polish,
42
  use_lexical_refinement=True,
43
- use_paraphrase=False,
44
- use_minilm_safety=False,
45
  require_wording_change=True,
46
  variation_seed=seed,
47
  )
48
 
49
 
50
- def _round_trip(text: str, *, polish: bool) -> tuple[str, str, float, float]:
51
- first = _rewrite(text, polish=polish)
52
- second = _rewrite(first.text, polish=polish)
 
 
 
 
 
53
  hop1 = surface_similarity(text, first.text)
54
  hop2 = surface_similarity(first.text, second.text)
55
  return first.text, second.text, hop1, hop2
@@ -65,10 +78,9 @@ def test_round_trip_polish_true_keeps_meaning_on_both_hops():
65
  out1, out2, hop1, hop2 = _round_trip(CUSTOMER_SAMPLE, polish=True)
66
  print(f"polish=true hop1(input->out1)={hop1:.4f} hop2(out1->out2)={hop2:.4f}")
67
  print(f"identical={out1 == out2}")
68
- # Wording is a fixed point after pass 1; any second-hop movement comes from
69
- # structure (a voice flip), never from marginal WordNet senses.
70
  assert out1 != CUSTOMER_SAMPLE
71
- assert hop1 < 0.90, hop1
 
72
  for text in (out1, out2):
73
  _assert_no_bad(text)
74
  for marker in ("vital", "employee", "reputation"):
@@ -87,8 +99,30 @@ def test_round_trip_reports_similarity_for_inspection():
87
  out1, out2, hop1, hop2 = _round_trip(CUSTOMER_SAMPLE, polish=True)
88
  print(f"round_trip hop1={hop1:.4f} hop2={hop2:.4f}")
89
  print(f"out1_words={len(out1.split())} out2_words={len(out2.split())}")
90
- assert hop1 < 0.90
91
- assert hop2 > 0.85
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
92
 
93
 
94
  def test_repeated_requests_pick_different_structures(structural_variation):
 
2
 
3
  from __future__ import annotations
4
 
5
+ import pytest
6
+
7
  from app.engine import orchestrator
8
+ from app.engine.paraphrase import paraphrase_resource_available, surface_similarity
9
 
10
 
11
  CUSTOMER_SAMPLE = (
 
37
  )
38
 
39
 
40
+ def _rewrite(
41
+ text: str,
42
+ *,
43
+ polish: bool,
44
+ seed: int | None = None,
45
+ use_paraphrase: bool = False,
46
+ ):
47
  return orchestrator.rewrite_document(
48
  text,
49
  lexical_polish=polish,
50
  use_lexical_refinement=True,
51
+ use_paraphrase=use_paraphrase,
52
+ use_minilm_safety=use_paraphrase,
53
  require_wording_change=True,
54
  variation_seed=seed,
55
  )
56
 
57
 
58
+ def _round_trip(
59
+ text: str,
60
+ *,
61
+ polish: bool,
62
+ use_paraphrase: bool = False,
63
+ ) -> tuple[str, str, float, float]:
64
+ first = _rewrite(text, polish=polish, use_paraphrase=use_paraphrase)
65
+ second = _rewrite(first.text, polish=polish, use_paraphrase=use_paraphrase)
66
  hop1 = surface_similarity(text, first.text)
67
  hop2 = surface_similarity(first.text, second.text)
68
  return first.text, second.text, hop1, hop2
 
78
  out1, out2, hop1, hop2 = _round_trip(CUSTOMER_SAMPLE, polish=True)
79
  print(f"polish=true hop1(input->out1)={hop1:.4f} hop2(out1->out2)={hop2:.4f}")
80
  print(f"identical={out1 == out2}")
 
 
81
  assert out1 != CUSTOMER_SAMPLE
82
+ # Corrected SequenceMatcher (autojunk=False) scores long prose higher.
83
+ assert hop1 < 0.95, hop1
84
  for text in (out1, out2):
85
  _assert_no_bad(text)
86
  for marker in ("vital", "employee", "reputation"):
 
99
  out1, out2, hop1, hop2 = _round_trip(CUSTOMER_SAMPLE, polish=True)
100
  print(f"round_trip hop1={hop1:.4f} hop2={hop2:.4f}")
101
  print(f"out1_words={len(out1.split())} out2_words={len(out2.split())}")
102
+ assert hop1 < 0.95
103
+ # Classical-only path: second hop may only flip structure lightly.
104
+ assert hop2 > 0.70
105
+
106
+
107
+ @pytest.mark.skipif(
108
+ not paraphrase_resource_available(),
109
+ reason="T5 paraphraser unavailable in this environment",
110
+ )
111
+ def test_generative_round_trip_diverges_below_half():
112
+ """Primary T5 paraphrase should drive out1→out2 well below classical levels."""
113
+ out1, out2, hop1, hop2 = _round_trip(
114
+ CUSTOMER_SAMPLE, polish=True, use_paraphrase=True
115
+ )
116
+ print(f"generative hop1={hop1:.4f} hop2={hop2:.4f}")
117
+ print(f"out1={out1[:180]}...")
118
+ print(f"out2={out2[:180]}...")
119
+ assert out1 != CUSTOMER_SAMPLE
120
+ assert out1 != out2
121
+ assert hop2 < 0.40, hop2
122
+ for text in (out1, out2):
123
+ _assert_no_bad(text)
124
+ for marker in ("vital", "employee", "reputation"):
125
+ assert marker in text.lower()
126
 
127
 
128
  def test_repeated_requests_pick_different_structures(structural_variation):
tests/test_structural_variation.py CHANGED
@@ -65,6 +65,38 @@ def test_active_and_passive_are_not_mutually_reachable():
65
  assert passive_to_active(passive) is not None
66
 
67
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
68
  def test_coordinated_split_repeats_subject_instead_of_stranding_a_verb():
69
  text = (
70
  "By consistently delivering high-quality service, organizations can "
 
65
  assert passive_to_active(passive) is not None
66
 
67
 
68
+ def test_active_to_passive_refuses_misattached_coordinate_tails():
69
+ """spaCy may hang list verbs as advcl; rebuilding would strand them."""
70
+ text = (
71
+ "Customers appreciate companies that respond quickly to their questions, "
72
+ "resolve issues efficiently, and treat them with respect."
73
+ )
74
+ assert active_to_passive(text) is None
75
+
76
+
77
+ def test_unfront_opener_moves_prepositional_prefix():
78
+ from app.engine.templates import try_unfront_opener
79
+
80
+ assert try_unfront_opener(
81
+ "For the success of any business, supplying excellent customer service "
82
+ "is vital."
83
+ ) == (
84
+ "Supplying excellent customer service is vital for the success of any "
85
+ "business."
86
+ )
87
+ assert try_unfront_opener(
88
+ "By consistently delivering high-quality service, organizations can "
89
+ "establish a strong reputation."
90
+ ) == (
91
+ "Organizations can establish a strong reputation by consistently "
92
+ "delivering high-quality service."
93
+ )
94
+ # Discourse connectives must stay fronted.
95
+ assert try_unfront_opener(
96
+ "However, the committee approved the budget today."
97
+ ) is None
98
+
99
+
100
  def test_coordinated_split_repeats_subject_instead_of_stranding_a_verb():
101
  text = (
102
  "By consistently delivering high-quality service, organizations can "