Fix phrase span leaks, safer length budget, disable phrase T5 by default.
Browse filesReject object-front and duplicate-verb span candidates, keep length budget from dropping source content, and bake ENGINE_PHRASE_USE_T5=false into Space defaults.
Co-authored-by: Cursor <cursoragent@cursor.com>
- .env.example +23 -5
- Dockerfile +5 -1
- app/config.py +3 -0
- app/engine/mechanics.py +11 -3
- app/engine/orchestrator.py +2 -0
- app/engine/paraphrase/__init__.py +21 -5
.env.example
CHANGED
|
@@ -27,13 +27,29 @@ ENGINE_SAFETY_MIN=0.80
|
|
| 27 |
ENGINE_USE_MINILM_SAFETY=true
|
| 28 |
MINILM_MODEL=sentence-transformers/all-MiniLM-L6-v2
|
| 29 |
|
| 30 |
-
# Local CPU paraphraser for
|
| 31 |
ENGINE_PARAPHRASE=true
|
|
|
|
| 32 |
ENGINE_PARAPHRASE_MODEL=Vamsi/T5_Paraphrase_Paws
|
| 33 |
ENGINE_PARAPHRASE_MIN_SIM=0.72
|
| 34 |
-
ENGINE_PARAPHRASE_NUM_RETURN=
|
| 35 |
-
ENGINE_PARAPHRASE_MAX_NEW_TOKENS=
|
| 36 |
-
ENGINE_PARAPHRASE_MAX_SURFACE=0.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 37 |
|
| 38 |
# Legacy fixed cleft fallback (off by default)
|
| 39 |
ENGINE_FORCE_REWRITE=false
|
|
@@ -43,7 +59,9 @@ ENGINE_REQUIRE_WORDING_CHANGE=true
|
|
| 43 |
|
| 44 |
# Low-impact vocabulary refinement after structure/paraphrase
|
| 45 |
ENGINE_LEXICAL_REFINEMENT=true
|
| 46 |
-
|
|
|
|
|
|
|
| 47 |
ENGINE_LEXICAL_MIN_WSD=0.14
|
| 48 |
ENGINE_LEXICAL_MIN_ZIPF=4.0
|
| 49 |
ENGINE_LEXICAL_MAX_FREQUENCY_GAP=0.60
|
|
|
|
| 27 |
ENGINE_USE_MINILM_SAFETY=true
|
| 28 |
MINILM_MODEL=sentence-transformers/all-MiniLM-L6-v2
|
| 29 |
|
| 30 |
+
# Local CPU paraphraser for meaning-safe but newer-looking rewrites
|
| 31 |
ENGINE_PARAPHRASE=true
|
| 32 |
+
ENGINE_PARAPHRASE_PRIMARY=true
|
| 33 |
ENGINE_PARAPHRASE_MODEL=Vamsi/T5_Paraphrase_Paws
|
| 34 |
ENGINE_PARAPHRASE_MIN_SIM=0.72
|
| 35 |
+
ENGINE_PARAPHRASE_NUM_RETURN=5
|
| 36 |
+
ENGINE_PARAPHRASE_MAX_NEW_TOKENS=72
|
| 37 |
+
ENGINE_PARAPHRASE_MAX_SURFACE=0.88
|
| 38 |
+
ENGINE_PARAPHRASE_MIN_DIVERGENCE=0.14
|
| 39 |
+
|
| 40 |
+
# Phrase-level rewrite (verb–object spans via WordNet hyponyms + optional T5)
|
| 41 |
+
ENGINE_PHRASE_REWRITE=true
|
| 42 |
+
# 0 = dynamic (1 normally, 2 with polish). Set 1–4 to hard-cap.
|
| 43 |
+
ENGINE_PHRASE_MAX_CHANGES=0
|
| 44 |
+
ENGINE_PHRASE_MIN_SIM=0.74
|
| 45 |
+
# Keep T5 span paraphrase off by default (WordNet phrase path is safer).
|
| 46 |
+
ENGINE_PHRASE_USE_T5=false
|
| 47 |
+
|
| 48 |
+
# Keep rewrite length near the source; trim only when clearly bloated.
|
| 49 |
+
ENGINE_PRESERVE_LENGTH=true
|
| 50 |
+
# Split long sentences on clause joins during structural fallback.
|
| 51 |
+
ENGINE_SPLIT_LONG=true
|
| 52 |
+
ENGINE_SPLIT_MIN_WORDS=16
|
| 53 |
|
| 54 |
# Legacy fixed cleft fallback (off by default)
|
| 55 |
ENGINE_FORCE_REWRITE=false
|
|
|
|
| 59 |
|
| 60 |
# Low-impact vocabulary refinement after structure/paraphrase
|
| 61 |
ENGINE_LEXICAL_REFINEMENT=true
|
| 62 |
+
# 0 = fully dynamic synonym budget from sentence length (recommended).
|
| 63 |
+
# Set >0 only to hard-cap the dynamic budget (max 15).
|
| 64 |
+
ENGINE_LEXICAL_MAX_CHANGES=0
|
| 65 |
ENGINE_LEXICAL_MIN_WSD=0.14
|
| 66 |
ENGINE_LEXICAL_MIN_ZIPF=4.0
|
| 67 |
ENGINE_LEXICAL_MAX_FREQUENCY_GAP=0.60
|
Dockerfile
CHANGED
|
@@ -20,7 +20,11 @@ ENV PYTHONDONTWRITEBYTECODE=1 \
|
|
| 20 |
ENGINE_PARAPHRASE=true \
|
| 21 |
ENGINE_FORCE_REWRITE=false \
|
| 22 |
ENGINE_USE_MINILM_SAFETY=true \
|
| 23 |
-
ENGINE_LEXICAL_REFINEMENT=true \
|
|
|
|
|
|
|
|
|
|
|
|
|
| 24 |
HF_HOME=/opt/hf_cache \
|
| 25 |
TRANSFORMERS_CACHE=/opt/hf_cache \
|
| 26 |
LANGUAGETOOL_HOME=/opt/languagetool \
|
|
|
|
| 20 |
ENGINE_PARAPHRASE=true \
|
| 21 |
ENGINE_FORCE_REWRITE=false \
|
| 22 |
ENGINE_USE_MINILM_SAFETY=true \
|
| 23 |
+
ENGINE_LEXICAL_REFINEMENT=true \
|
| 24 |
+
ENGINE_PHRASE_REWRITE=true \
|
| 25 |
+
ENGINE_PHRASE_USE_T5=false \
|
| 26 |
+
ENGINE_PRESERVE_LENGTH=true \
|
| 27 |
+
ENGINE_SPLIT_LONG=true \\
|
| 28 |
HF_HOME=/opt/hf_cache \
|
| 29 |
TRANSFORMERS_CACHE=/opt/hf_cache \
|
| 30 |
LANGUAGETOOL_HOME=/opt/languagetool \
|
app/config.py
CHANGED
|
@@ -209,6 +209,9 @@ ENGINE_PHRASE_MIN_SIM = max(
|
|
| 209 |
0.0,
|
| 210 |
min(float(os.environ.get("ENGINE_PHRASE_MIN_SIM", "0.74") or "0.74"), 1.0),
|
| 211 |
)
|
|
|
|
|
|
|
|
|
|
| 212 |
|
| 213 |
# Keep rewritten length near the source (trims only when clearly bloated).
|
| 214 |
_epl = (os.environ.get("ENGINE_PRESERVE_LENGTH") or "true").strip().lower()
|
|
|
|
| 209 |
0.0,
|
| 210 |
min(float(os.environ.get("ENGINE_PHRASE_MIN_SIM", "0.74") or "0.74"), 1.0),
|
| 211 |
)
|
| 212 |
+
# T5 span paraphrase is optional; WordNet phrase swaps are the default wording path.
|
| 213 |
+
_ephr_t5 = (os.environ.get("ENGINE_PHRASE_USE_T5") or "false").strip().lower()
|
| 214 |
+
ENGINE_PHRASE_USE_T5 = _ephr_t5 in {"1", "true", "yes", "on"}
|
| 215 |
|
| 216 |
# Keep rewritten length near the source (trims only when clearly bloated).
|
| 217 |
_epl = (os.environ.get("ENGINE_PRESERVE_LENGTH") or "true").strip().lower()
|
app/engine/mechanics.py
CHANGED
|
@@ -22,7 +22,8 @@ def enforce_length_budget(
|
|
| 22 |
) -> str:
|
| 23 |
"""Trim only when the rewrite clearly ballooned past the original.
|
| 24 |
|
| 25 |
-
Preserves paragraph breaks (\\n\\n)
|
|
|
|
| 26 |
"""
|
| 27 |
source = original or ""
|
| 28 |
output = rewritten or ""
|
|
@@ -31,8 +32,11 @@ def enforce_length_budget(
|
|
| 31 |
if source_words == 0:
|
| 32 |
return output
|
| 33 |
|
| 34 |
-
|
|
|
|
| 35 |
max_words = max(1, int(source_words * max_ratio))
|
|
|
|
|
|
|
| 36 |
if output_words <= max_words:
|
| 37 |
return output
|
| 38 |
|
|
@@ -59,4 +63,8 @@ def enforce_length_budget(
|
|
| 59 |
kept_paragraphs.append(" ".join(kept_sentences))
|
| 60 |
if count >= max_words:
|
| 61 |
break
|
| 62 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 22 |
) -> str:
|
| 23 |
"""Trim only when the rewrite clearly ballooned past the original.
|
| 24 |
|
| 25 |
+
Preserves paragraph breaks (\\n\\n). Never trims so hard that most of the
|
| 26 |
+
source content disappears.
|
| 27 |
"""
|
| 28 |
source = original or ""
|
| 29 |
output = rewritten or ""
|
|
|
|
| 32 |
if source_words == 0:
|
| 33 |
return output
|
| 34 |
|
| 35 |
+
# Slightly looser ceiling so normal phrase/lexical growth is kept.
|
| 36 |
+
max_ratio = 1.20 if preserve_length else 1.6
|
| 37 |
max_words = max(1, int(source_words * max_ratio))
|
| 38 |
+
# Floor: do not destroy content just to hit the ceiling.
|
| 39 |
+
min_words = max(1, int(source_words * 0.90))
|
| 40 |
if output_words <= max_words:
|
| 41 |
return output
|
| 42 |
|
|
|
|
| 63 |
kept_paragraphs.append(" ".join(kept_sentences))
|
| 64 |
if count >= max_words:
|
| 65 |
break
|
| 66 |
+
trimmed = "\n\n".join(kept_paragraphs).strip()
|
| 67 |
+
if not trimmed or len(trimmed.split()) < min_words:
|
| 68 |
+
# Trimming would drop too much meaning — keep the full rewrite.
|
| 69 |
+
return output
|
| 70 |
+
return trimmed
|
app/engine/orchestrator.py
CHANGED
|
@@ -18,6 +18,7 @@ from app.config import (
|
|
| 18 |
ENGINE_PHRASE_MAX_CHANGES,
|
| 19 |
ENGINE_PHRASE_MIN_SIM,
|
| 20 |
ENGINE_PHRASE_REWRITE,
|
|
|
|
| 21 |
ENGINE_PRESERVE_LENGTH,
|
| 22 |
ENGINE_REQUIRE_WORDING_CHANGE,
|
| 23 |
ENGINE_SAFETY_MIN,
|
|
@@ -211,6 +212,7 @@ def _apply_phrase_rewrite(
|
|
| 211 |
max_changes=max_changes,
|
| 212 |
polish=polish,
|
| 213 |
min_sim=min(ENGINE_PHRASE_MIN_SIM, safety_min),
|
|
|
|
| 214 |
)
|
| 215 |
if not refined.changes or refined.text == before:
|
| 216 |
return record
|
|
|
|
| 18 |
ENGINE_PHRASE_MAX_CHANGES,
|
| 19 |
ENGINE_PHRASE_MIN_SIM,
|
| 20 |
ENGINE_PHRASE_REWRITE,
|
| 21 |
+
ENGINE_PHRASE_USE_T5,
|
| 22 |
ENGINE_PRESERVE_LENGTH,
|
| 23 |
ENGINE_REQUIRE_WORDING_CHANGE,
|
| 24 |
ENGINE_SAFETY_MIN,
|
|
|
|
| 212 |
max_changes=max_changes,
|
| 213 |
polish=polish,
|
| 214 |
min_sim=min(ENGINE_PHRASE_MIN_SIM, safety_min),
|
| 215 |
+
use_t5=ENGINE_PHRASE_USE_T5,
|
| 216 |
)
|
| 217 |
if not refined.changes or refined.text == before:
|
| 218 |
return record
|
app/engine/paraphrase/__init__.py
CHANGED
|
@@ -414,19 +414,35 @@ def _span_candidate_ok(source: str, candidate: str) -> bool:
|
|
| 414 |
return False
|
| 415 |
if ":" in cand or ";" in cand:
|
| 416 |
return False
|
| 417 |
-
src_tokens = _WORD.findall(src)
|
| 418 |
-
cand_tokens = _WORD.findall(cand)
|
| 419 |
if not src_tokens or not cand_tokens:
|
| 420 |
return False
|
| 421 |
# Keep span length close so splicing stays grammatical.
|
| 422 |
-
if len(cand_tokens) > len(src_tokens) +
|
| 423 |
return False
|
| 424 |
-
if len(cand) > max(12, int(len(src) * 1.
|
| 425 |
return False
|
| 426 |
# Mid-sentence VO spans usually start with a verb; reject full-clause flips
|
| 427 |
-
# like "Customer loyalty strengthens".
|
| 428 |
if src[0].islower() and cand[0].isupper():
|
| 429 |
return False
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 430 |
return True
|
| 431 |
|
| 432 |
|
|
|
|
| 414 |
return False
|
| 415 |
if ":" in cand or ";" in cand:
|
| 416 |
return False
|
| 417 |
+
src_tokens = [token.lower() for token in _WORD.findall(src)]
|
| 418 |
+
cand_tokens = [token.lower() for token in _WORD.findall(cand)]
|
| 419 |
if not src_tokens or not cand_tokens:
|
| 420 |
return False
|
| 421 |
# Keep span length close so splicing stays grammatical.
|
| 422 |
+
if len(cand_tokens) > len(src_tokens) + 1 or len(cand_tokens) + 2 < len(src_tokens):
|
| 423 |
return False
|
| 424 |
+
if len(cand) > max(12, int(len(src) * 1.45)):
|
| 425 |
return False
|
| 426 |
# Mid-sentence VO spans usually start with a verb; reject full-clause flips
|
| 427 |
+
# like "Customer loyalty strengthens" / "customer loyalty strengthens".
|
| 428 |
if src[0].islower() and cand[0].isupper():
|
| 429 |
return False
|
| 430 |
+
src_tail = set(src_tokens[1:])
|
| 431 |
+
if cand_tokens[0] in src_tail:
|
| 432 |
+
return False
|
| 433 |
+
# Reject duplicated content words ("…experience to create").
|
| 434 |
+
from collections import Counter
|
| 435 |
+
|
| 436 |
+
src_counts = Counter(src_tokens)
|
| 437 |
+
cand_counts = Counter(cand_tokens)
|
| 438 |
+
for token, count in cand_counts.items():
|
| 439 |
+
if len(token) < 4:
|
| 440 |
+
continue
|
| 441 |
+
if count > max(1, src_counts.get(token, 0)):
|
| 442 |
+
return False
|
| 443 |
+
# Reject dangling infinitive tails invented by T5.
|
| 444 |
+
if re.search(r"\bto\s+[a-z]{3,}$", low) and " to " not in src.lower():
|
| 445 |
+
return False
|
| 446 |
return True
|
| 447 |
|
| 448 |
|