Re-enable primary T5 paraphrase for stronger out1→out2 divergence.
Browse filesTurn 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 +6 -6
- Dockerfile +104 -87
- README.md +4 -3
- app/config.py +6 -6
- app/engine/fallback/__init__.py +2 -0
- app/engine/orchestrator.py +4 -3
- app/engine/paraphrase/__init__.py +4 -1
- app/engine/templates/__init__.py +81 -0
- app/engine/voice/__init__.py +20 -0
- tests/test_api.py +49 -7
- tests/test_round_trip_stability.py +46 -12
- tests/test_structural_variation.py +32 -0
.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 —
|
| 37 |
-
ENGINE_PARAPHRASE=
|
| 38 |
-
ENGINE_PARAPHRASE_PRIMARY=
|
| 39 |
ENGINE_PARAPHRASE_MODEL=Vamsi/T5_Paraphrase_Paws
|
| 40 |
ENGINE_PARAPHRASE_MIN_SIM=0.72
|
| 41 |
-
ENGINE_PARAPHRASE_NUM_RETURN=
|
| 42 |
ENGINE_PARAPHRASE_MAX_NEW_TOKENS=72
|
| 43 |
-
ENGINE_PARAPHRASE_MAX_SURFACE=0.
|
| 44 |
-
ENGINE_PARAPHRASE_MIN_DIVERGENCE=0.
|
| 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=
|
| 21 |
-
ENGINE_PARAPHRASE_PRIMARY=
|
| 22 |
-
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
|
| 30 |
-
|
| 31 |
-
|
| 32 |
-
|
| 33 |
-
|
| 34 |
-
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
&& rm -
|
| 60 |
-
|
| 61 |
-
|
| 62 |
-
|
| 63 |
-
|
| 64 |
-
|
| 65 |
-
|
| 66 |
-
&&
|
| 67 |
-
|
| 68 |
-
|
| 69 |
-
|
| 70 |
-
|
| 71 |
-
|
| 72 |
-
|
| 73 |
-
|
| 74 |
-
|
| 75 |
-
|
| 76 |
-
|
| 77 |
-
|
| 78 |
-
|
| 79 |
-
|
| 80 |
-
|
| 81 |
-
|
| 82 |
-
|
| 83 |
-
|
| 84 |
-
|
| 85 |
-
|
| 86 |
-
|
| 87 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 35 |
-
|
| 36 |
-
|
|
|
|
| 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 (
|
| 165 |
-
_ep = (os.environ.get("ENGINE_PARAPHRASE") or "
|
| 166 |
ENGINE_PARAPHRASE = _ep in {"1", "true", "yes", "on"}
|
| 167 |
-
_epp = (os.environ.get("ENGINE_PARAPHRASE_PRIMARY") or "
|
| 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", "
|
| 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.
|
| 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.
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
| 51 |
|
| 52 |
|
| 53 |
-
def
|
| 54 |
-
captured: dict[str,
|
| 55 |
original = main_module.rewrite_document
|
| 56 |
|
| 57 |
def capture(text: str, **kwargs):
|
| 58 |
captured["enabled"] = kwargs["use_lexical_refinement"]
|
| 59 |
-
|
|
|
|
|
|
|
| 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"] ==
|
|
|
|
|
|
|
|
|
|
| 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(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 39 |
return orchestrator.rewrite_document(
|
| 40 |
text,
|
| 41 |
lexical_polish=polish,
|
| 42 |
use_lexical_refinement=True,
|
| 43 |
-
use_paraphrase=
|
| 44 |
-
use_minilm_safety=
|
| 45 |
require_wording_change=True,
|
| 46 |
variation_seed=seed,
|
| 47 |
)
|
| 48 |
|
| 49 |
|
| 50 |
-
def _round_trip(
|
| 51 |
-
|
| 52 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
| 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.
|
| 91 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 "
|