Upload 142 files
Browse files- app/__pycache__/config.cpython-311.pyc +0 -0
- app/bootstrap.py +4 -20
- app/config.py +23 -4
- app/engine/__init__.py +6 -0
- app/engine/__pycache__/__init__.cpython-311.pyc +0 -0
- app/engine/__pycache__/models.cpython-311.pyc +0 -0
- app/engine/__pycache__/orchestrator.cpython-311.pyc +0 -0
- app/engine/classify/__init__.py +62 -0
- app/engine/classify/__pycache__/__init__.cpython-311.pyc +0 -0
- app/engine/consistency/__init__.py +94 -0
- app/engine/consistency/__pycache__/__init__.cpython-311.pyc +0 -0
- app/engine/grammar/__init__.py +31 -0
- app/engine/grammar/__pycache__/__init__.cpython-311.pyc +0 -0
- app/engine/ingest/__init__.py +17 -0
- app/engine/ingest/__pycache__/__init__.cpython-311.pyc +0 -0
- app/engine/models.py +104 -0
- app/engine/normalize/__init__.py +258 -0
- app/engine/normalize/__pycache__/__init__.cpython-311.pyc +0 -0
- app/engine/orchestrator.py +354 -0
- app/engine/parse/__init__.py +217 -0
- app/engine/parse/__pycache__/__init__.cpython-311.pyc +0 -0
- app/engine/plan/__init__.py +97 -0
- app/engine/plan/__pycache__/__init__.cpython-311.pyc +0 -0
- app/engine/rewrite/__init__.py +85 -0
- app/engine/rewrite/__pycache__/__init__.cpython-311.pyc +0 -0
- app/engine/safety/__init__.py +145 -0
- app/engine/safety/__pycache__/__init__.cpython-311.pyc +0 -0
- app/engine/segment/__init__.py +58 -0
- app/engine/segment/__pycache__/__init__.cpython-311.pyc +0 -0
- app/engine/stitch/__init__.py +35 -0
- app/engine/stitch/__pycache__/__init__.cpython-311.pyc +0 -0
- app/engine/templates/__init__.py +182 -0
- app/engine/templates/__pycache__/__init__.cpython-311.pyc +0 -0
- app/main.py +33 -20
- app/pipeline/__pycache__/orchestrator.cpython-311.pyc +0 -0
- app/pipeline/__pycache__/synonym.cpython-311.pyc +0 -0
- app/pipeline/generative.py +11 -523
- app/pipeline/orchestrator.py +75 -1526
- app/pipeline/restructure.py +62 -399
- app/pipeline/synonym.py +29 -570
- requirements.txt +11 -8
- scripts/_probe_reorder_wording.py +28 -0
- scripts/test_phase_a_guards.py +59 -0
- tests/__pycache__/conftest.cpython-311-pytest-9.1.1.pyc +0 -0
- tests/__pycache__/test_engine_long.cpython-311-pytest-9.1.1.pyc +0 -0
- tests/__pycache__/test_engine_short.cpython-311-pytest-9.1.1.pyc +0 -0
- tests/conftest.py +10 -0
- tests/test_engine_long.py +88 -0
- tests/test_engine_short.py +120 -0
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/bootstrap.py
CHANGED
|
@@ -1,4 +1,4 @@
|
|
| 1 |
-
"""Bootstrap spaCy
|
| 2 |
|
| 3 |
from __future__ import annotations
|
| 4 |
|
|
@@ -12,7 +12,6 @@ def ensure_resources() -> None:
|
|
| 12 |
try:
|
| 13 |
spacy.load(SPACY_MODEL)
|
| 14 |
except OSError:
|
| 15 |
-
# Prefer wheel install in Docker; CLI download can fail on SSL.
|
| 16 |
print(
|
| 17 |
f"[warn] spaCy model '{SPACY_MODEL}' not installed. "
|
| 18 |
"Using regex fallback. Install with: "
|
|
@@ -23,30 +22,15 @@ def ensure_resources() -> None:
|
|
| 23 |
print(f"[warn] spaCy setup: {exc}")
|
| 24 |
|
| 25 |
try:
|
| 26 |
-
import
|
| 27 |
-
from nltk.corpus import wordnet as wn
|
| 28 |
|
| 29 |
-
|
| 30 |
-
wn.synsets("test")
|
| 31 |
-
except LookupError:
|
| 32 |
-
nltk.download("wordnet", quiet=True)
|
| 33 |
-
nltk.download("omw-1.4", quiet=True)
|
| 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.generative import warm_generative
|
| 42 |
from app.pipeline.minilm import warm_minilm
|
| 43 |
|
| 44 |
ok_m = warm_minilm()
|
| 45 |
print(f"[info] MiniLM warm: {'ok' if ok_m else 'skipped/unavailable'}")
|
| 46 |
-
ok_g = warm_generative()
|
| 47 |
-
print(f"[info] Generative warm: {'ok' if ok_g else 'skipped/unavailable'}")
|
| 48 |
except Exception as exc: # noqa: BLE001
|
| 49 |
-
print(f"[warn]
|
| 50 |
|
| 51 |
|
| 52 |
if __name__ == "__main__":
|
|
|
|
| 1 |
+
"""Bootstrap spaCy (used by Docker entrypoint and local first run)."""
|
| 2 |
|
| 3 |
from __future__ import annotations
|
| 4 |
|
|
|
|
| 12 |
try:
|
| 13 |
spacy.load(SPACY_MODEL)
|
| 14 |
except OSError:
|
|
|
|
| 15 |
print(
|
| 16 |
f"[warn] spaCy model '{SPACY_MODEL}' not installed. "
|
| 17 |
"Using regex fallback. Install with: "
|
|
|
|
| 22 |
print(f"[warn] spaCy setup: {exc}")
|
| 23 |
|
| 24 |
try:
|
| 25 |
+
from app.config import ENGINE_USE_MINILM_SAFETY, ML_POLISH_WARM
|
|
|
|
| 26 |
|
| 27 |
+
if ML_POLISH_WARM or ENGINE_USE_MINILM_SAFETY:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 28 |
from app.pipeline.minilm import warm_minilm
|
| 29 |
|
| 30 |
ok_m = warm_minilm()
|
| 31 |
print(f"[info] MiniLM warm: {'ok' if ok_m else 'skipped/unavailable'}")
|
|
|
|
|
|
|
| 32 |
except Exception as exc: # noqa: BLE001
|
| 33 |
+
print(f"[warn] MiniLM warm: {exc}")
|
| 34 |
|
| 35 |
|
| 36 |
if __name__ == "__main__":
|
app/config.py
CHANGED
|
@@ -73,14 +73,16 @@ if _gen_backend_raw in {"causal", "chat", "instruct", "decoder"}:
|
|
| 73 |
GENERATIVE_BACKEND = "causal"
|
| 74 |
else:
|
| 75 |
GENERATIVE_BACKEND = "seq2seq"
|
| 76 |
-
# Pipeline: hybrid | generative | classical
|
| 77 |
-
_pipe_raw = (os.environ.get("PIPELINE_MODE") or "
|
| 78 |
if _pipe_raw in {"generative", "gen", "llm"}:
|
| 79 |
PIPELINE_MODE = "generative"
|
| 80 |
elif _pipe_raw in {"classical", "rules", "lexicon"}:
|
| 81 |
PIPELINE_MODE = "classical"
|
| 82 |
-
|
| 83 |
PIPELINE_MODE = "hybrid"
|
|
|
|
|
|
|
| 84 |
# 0 = rewrite entire document (no paragraph cap). Positive = safety cap.
|
| 85 |
_gen_max_raw = (
|
| 86 |
os.environ.get("GENERATIVE_MAX_PARAGRAPHS")
|
|
@@ -112,5 +114,22 @@ _gfo = (os.environ.get("GRAMMAR_FIX_OUTPUT") or "true").strip().lower()
|
|
| 112 |
GRAMMAR_FIX_OUTPUT = _gfo not in {"0", "false", "no", "off"}
|
| 113 |
|
| 114 |
# Classical lexicon path: only used when generative is unavailable / ML polish off
|
| 115 |
-
_lex = (os.environ.get("LEXICON_FALLBACK") or "
|
| 116 |
LEXICON_FALLBACK = _lex not in {"0", "false", "no", "off"}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 73 |
GENERATIVE_BACKEND = "causal"
|
| 74 |
else:
|
| 75 |
GENERATIVE_BACKEND = "seq2seq"
|
| 76 |
+
# Pipeline: structural (default) | hybrid | generative | classical (legacy)
|
| 77 |
+
_pipe_raw = (os.environ.get("PIPELINE_MODE") or "structural").strip().lower()
|
| 78 |
if _pipe_raw in {"generative", "gen", "llm"}:
|
| 79 |
PIPELINE_MODE = "generative"
|
| 80 |
elif _pipe_raw in {"classical", "rules", "lexicon"}:
|
| 81 |
PIPELINE_MODE = "classical"
|
| 82 |
+
elif _pipe_raw in {"hybrid"}:
|
| 83 |
PIPELINE_MODE = "hybrid"
|
| 84 |
+
else:
|
| 85 |
+
PIPELINE_MODE = "structural"
|
| 86 |
# 0 = rewrite entire document (no paragraph cap). Positive = safety cap.
|
| 87 |
_gen_max_raw = (
|
| 88 |
os.environ.get("GENERATIVE_MAX_PARAGRAPHS")
|
|
|
|
| 114 |
GRAMMAR_FIX_OUTPUT = _gfo not in {"0", "false", "no", "off"}
|
| 115 |
|
| 116 |
# Classical lexicon path: only used when generative is unavailable / ML polish off
|
| 117 |
+
_lex = (os.environ.get("LEXICON_FALLBACK") or "false").strip().lower()
|
| 118 |
LEXICON_FALLBACK = _lex not in {"0", "false", "no", "off"}
|
| 119 |
+
|
| 120 |
+
# ---------------------------------------------------------------------------
|
| 121 |
+
# Structural rewrite engine (primary path)
|
| 122 |
+
# ---------------------------------------------------------------------------
|
| 123 |
+
ENGINE_BATCH_PARAS = max(
|
| 124 |
+
1, min(int(os.environ.get("ENGINE_BATCH_PARAS", "20") or "20"), 100)
|
| 125 |
+
)
|
| 126 |
+
ENGINE_MIN_CONFIDENCE = max(
|
| 127 |
+
0.0, min(float(os.environ.get("ENGINE_MIN_CONFIDENCE", "0.55") or "0.55"), 1.0)
|
| 128 |
+
)
|
| 129 |
+
ENGINE_SAFETY_MIN = max(
|
| 130 |
+
0.0, min(float(os.environ.get("ENGINE_SAFETY_MIN", "0.80") or "0.80"), 1.0)
|
| 131 |
+
)
|
| 132 |
+
_syn = (os.environ.get("ALLOW_SYNONYM_REPLACEMENT") or "false").strip().lower()
|
| 133 |
+
ALLOW_SYNONYM_REPLACEMENT = _syn in {"1", "true", "yes", "on"}
|
| 134 |
+
_ems = (os.environ.get("ENGINE_USE_MINILM_SAFETY") or "false").strip().lower()
|
| 135 |
+
ENGINE_USE_MINILM_SAFETY = _ems in {"1", "true", "yes", "on"}
|
app/engine/__init__.py
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""CPU-friendly rule-based document rewrite engine (structural reorder)."""
|
| 2 |
+
|
| 3 |
+
from app.engine.models import EngineResult, SentenceRecord
|
| 4 |
+
from app.engine.orchestrator import rewrite_document
|
| 5 |
+
|
| 6 |
+
__all__ = ["rewrite_document", "EngineResult", "SentenceRecord"]
|
app/engine/__pycache__/__init__.cpython-311.pyc
ADDED
|
Binary file (469 Bytes). View file
|
|
|
app/engine/__pycache__/models.cpython-311.pyc
ADDED
|
Binary file (6.07 kB). View file
|
|
|
app/engine/__pycache__/orchestrator.cpython-311.pyc
ADDED
|
Binary file (13.3 kB). View file
|
|
|
app/engine/classify/__init__.py
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Sentence type classification — only safe types enter the rewrite path."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
import re
|
| 6 |
+
|
| 7 |
+
_SUBORDINATORS = frozenset(
|
| 8 |
+
"""
|
| 9 |
+
although though while whilst whereas unless until since
|
| 10 |
+
if when whenever wherever whether before after
|
| 11 |
+
""".split()
|
| 12 |
+
)
|
| 13 |
+
|
| 14 |
+
_REWRITEABLE = frozenset({"simple_declarative", "compound", "because_clause"})
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
def classify_sentence(text: str) -> str:
|
| 18 |
+
"""Tag sentence type for rewrite eligibility."""
|
| 19 |
+
t = (text or "").strip()
|
| 20 |
+
if not t:
|
| 21 |
+
return "empty"
|
| 22 |
+
if re.match(r"^#{1,6}\s", t) or (len(t.split()) <= 6 and t.isupper()):
|
| 23 |
+
return "heading"
|
| 24 |
+
if re.match(r"^(\d+[\.\)]\s+|[-*•]\s+)", t):
|
| 25 |
+
return "list_item"
|
| 26 |
+
if t.startswith(('"', "'", "\u201c", "\u2018")) and (
|
| 27 |
+
t.count('"') >= 2 or t.count("\u201c") or t.count("'") >= 2
|
| 28 |
+
):
|
| 29 |
+
return "quoted"
|
| 30 |
+
if "?" in t or t.endswith("?"):
|
| 31 |
+
return "question"
|
| 32 |
+
if len(t.split()) < 3:
|
| 33 |
+
return "too_short"
|
| 34 |
+
if len(t.split()) > 45:
|
| 35 |
+
return "too_long"
|
| 36 |
+
|
| 37 |
+
low = t.lower()
|
| 38 |
+
if re.search(r"\bbecause\b", low):
|
| 39 |
+
return "because_clause"
|
| 40 |
+
|
| 41 |
+
# Coordinating compound (safe if short enough)
|
| 42 |
+
if re.search(r"\b(and|but|or|so|yet)\b", low) and "," in t:
|
| 43 |
+
# Still complex if subordinators present
|
| 44 |
+
for sub in _SUBORDINATORS:
|
| 45 |
+
if re.search(rf"\b{re.escape(sub)}\b", low):
|
| 46 |
+
return "complex"
|
| 47 |
+
if len(t.split()) <= 28:
|
| 48 |
+
return "compound"
|
| 49 |
+
return "complex"
|
| 50 |
+
|
| 51 |
+
for sub in _SUBORDINATORS:
|
| 52 |
+
if re.search(rf"\b{re.escape(sub)}\b", low):
|
| 53 |
+
return "complex"
|
| 54 |
+
|
| 55 |
+
if re.search(r"\b(who|whom|whose|which|that)\b", low) and len(t.split()) > 12:
|
| 56 |
+
return "complex"
|
| 57 |
+
|
| 58 |
+
return "simple_declarative"
|
| 59 |
+
|
| 60 |
+
|
| 61 |
+
def is_rewriteable_type(sentence_type: str) -> bool:
|
| 62 |
+
return sentence_type in _REWRITEABLE
|
app/engine/classify/__pycache__/__init__.cpython-311.pyc
ADDED
|
Binary file (3.33 kB). View file
|
|
|
app/engine/consistency/__init__.py
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Document-level consistency pass after sentence rewrite."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
import re
|
| 6 |
+
from collections import defaultdict
|
| 7 |
+
|
| 8 |
+
from app.engine.models import SentenceRecord
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
def _proper_nouns(text: str) -> list[str]:
|
| 12 |
+
return re.findall(r"\b[A-Z][a-zA-Z0-9'-]+\b", text or "")
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
def canonicalize_entities(text: str, records: list[SentenceRecord]) -> str:
|
| 16 |
+
"""Normalize entity spelling to the first-seen form across the document."""
|
| 17 |
+
# Build map: lowercase → canonical first spelling
|
| 18 |
+
canon: dict[str, str] = {}
|
| 19 |
+
for rec in records:
|
| 20 |
+
for name in _proper_nouns(rec.original):
|
| 21 |
+
key = name.lower()
|
| 22 |
+
if key not in canon:
|
| 23 |
+
canon[key] = name
|
| 24 |
+
|
| 25 |
+
if not canon:
|
| 26 |
+
return text
|
| 27 |
+
|
| 28 |
+
def repl(m: re.Match[str]) -> str:
|
| 29 |
+
word = m.group(0)
|
| 30 |
+
key = word.lower()
|
| 31 |
+
if key in canon and word != canon[key]:
|
| 32 |
+
# Only fix case variants of same spelling length
|
| 33 |
+
if word.lower() == canon[key].lower():
|
| 34 |
+
return canon[key]
|
| 35 |
+
return word
|
| 36 |
+
|
| 37 |
+
return re.sub(r"\b[A-Za-z][A-Za-z0-9'-]*\b", repl, text)
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
def suppress_template_streaks(records: list[SentenceRecord]) -> list[SentenceRecord]:
|
| 41 |
+
"""If two consecutive rewrites used the same time-front template, revert the second."""
|
| 42 |
+
time_fronts = {
|
| 43 |
+
"time_subj_manner_verb_place",
|
| 44 |
+
"time_subj_verb_place",
|
| 45 |
+
"time_subj_verb_object",
|
| 46 |
+
"time_subj_verb_object_place",
|
| 47 |
+
"time_front",
|
| 48 |
+
}
|
| 49 |
+
out: list[SentenceRecord] = []
|
| 50 |
+
prev_tid = ""
|
| 51 |
+
for rec in records:
|
| 52 |
+
if (
|
| 53 |
+
rec.status == "rewritten"
|
| 54 |
+
and rec.template_id in time_fronts
|
| 55 |
+
and prev_tid == rec.template_id
|
| 56 |
+
):
|
| 57 |
+
# Revert to original to avoid mechanical repetition
|
| 58 |
+
reverted = SentenceRecord(
|
| 59 |
+
index=rec.index,
|
| 60 |
+
original=rec.original,
|
| 61 |
+
rewritten=rec.original,
|
| 62 |
+
confidence=rec.confidence,
|
| 63 |
+
status="reverted",
|
| 64 |
+
template_id="",
|
| 65 |
+
sentence_type=rec.sentence_type,
|
| 66 |
+
reasons=rec.reasons + ["template_streak"],
|
| 67 |
+
block_index=rec.block_index,
|
| 68 |
+
)
|
| 69 |
+
out.append(reverted)
|
| 70 |
+
prev_tid = ""
|
| 71 |
+
continue
|
| 72 |
+
out.append(rec)
|
| 73 |
+
prev_tid = rec.template_id if rec.status == "rewritten" else ""
|
| 74 |
+
return out
|
| 75 |
+
|
| 76 |
+
|
| 77 |
+
def apply_consistency(
|
| 78 |
+
text: str,
|
| 79 |
+
records: list[SentenceRecord],
|
| 80 |
+
) -> tuple[str, list[SentenceRecord]]:
|
| 81 |
+
"""Run light document-level consistency fixes."""
|
| 82 |
+
adjusted = suppress_template_streaks(records)
|
| 83 |
+
# Rebuild is caller's job when records change; here we only fix entity spelling on text
|
| 84 |
+
# if no streak reverts. When streaks revert, orchestrator should restitch.
|
| 85 |
+
fixed_text = canonicalize_entities(text, adjusted)
|
| 86 |
+
return fixed_text, adjusted
|
| 87 |
+
|
| 88 |
+
|
| 89 |
+
def entity_frequency(records: list[SentenceRecord]) -> dict[str, int]:
|
| 90 |
+
freq: dict[str, int] = defaultdict(int)
|
| 91 |
+
for rec in records:
|
| 92 |
+
for name in _proper_nouns(rec.original):
|
| 93 |
+
freq[name] += 1
|
| 94 |
+
return dict(freq)
|
app/engine/consistency/__pycache__/__init__.cpython-311.pyc
ADDED
|
Binary file (3.99 kB). View file
|
|
|
app/engine/grammar/__init__.py
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Sentence-scoped grammar repair after structural rewrite."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
import re
|
| 6 |
+
|
| 7 |
+
from app.pipeline.grammar_fix import correct_text
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
def repair_sentence(text: str) -> str:
|
| 11 |
+
"""Fix punctuation, capitalization, agreement, spacing on one sentence."""
|
| 12 |
+
raw = (text or "").strip()
|
| 13 |
+
if not raw:
|
| 14 |
+
return raw
|
| 15 |
+
# Lightweight local cleanup before LanguageTool/spaCy agreement
|
| 16 |
+
s = re.sub(r"\s+", " ", raw).strip()
|
| 17 |
+
s = re.sub(r"\s+([,.;:!?])", r"\1", s)
|
| 18 |
+
s = re.sub(r",\s*,+", ",", s)
|
| 19 |
+
s = re.sub(r"\s+,", ",", s)
|
| 20 |
+
if s and s[0].islower():
|
| 21 |
+
s = s[0].upper() + s[1:]
|
| 22 |
+
try:
|
| 23 |
+
fixed = correct_text(s)
|
| 24 |
+
if fixed and fixed.strip():
|
| 25 |
+
# Prefer sentence-sized result; correct_text may expand oddly
|
| 26 |
+
out = fixed.strip()
|
| 27 |
+
if abs(len(out.split()) - len(s.split())) <= max(3, len(s.split()) // 2):
|
| 28 |
+
return out
|
| 29 |
+
except Exception:
|
| 30 |
+
pass
|
| 31 |
+
return s
|
app/engine/grammar/__pycache__/__init__.cpython-311.pyc
ADDED
|
Binary file (1.99 kB). View file
|
|
|
app/engine/ingest/__init__.py
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Document ingest — raw text / bytes → UTF-8 string."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
|
| 6 |
+
def ingest_text(source: str | bytes | None, *, encoding: str = "utf-8") -> str:
|
| 7 |
+
"""Normalize input into a UTF-8 Unicode string."""
|
| 8 |
+
if source is None:
|
| 9 |
+
return ""
|
| 10 |
+
if isinstance(source, bytes):
|
| 11 |
+
for enc in (encoding, "utf-8", "utf-8-sig", "latin-1"):
|
| 12 |
+
try:
|
| 13 |
+
return source.decode(enc)
|
| 14 |
+
except (UnicodeDecodeError, LookupError):
|
| 15 |
+
continue
|
| 16 |
+
return source.decode("utf-8", errors="replace")
|
| 17 |
+
return str(source)
|
app/engine/ingest/__pycache__/__init__.cpython-311.pyc
ADDED
|
Binary file (1.12 kB). View file
|
|
|
app/engine/models.py
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Shared data models for the structural rewrite engine."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
from dataclasses import dataclass, field
|
| 6 |
+
from typing import Any
|
| 7 |
+
|
| 8 |
+
|
| 9 |
+
@dataclass
|
| 10 |
+
class DocumentBlock:
|
| 11 |
+
"""A contiguous document region with rewrite policy."""
|
| 12 |
+
|
| 13 |
+
text: str
|
| 14 |
+
kind: str = "paragraph" # paragraph|heading|list|table|code|formula|bibliography|blank
|
| 15 |
+
rewriteable: bool = True
|
| 16 |
+
index: int = 0
|
| 17 |
+
meta: dict[str, Any] = field(default_factory=dict)
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
@dataclass
|
| 21 |
+
class SentenceSlots:
|
| 22 |
+
"""Constituent spans extracted for template fill."""
|
| 23 |
+
|
| 24 |
+
text: str
|
| 25 |
+
subject: str = ""
|
| 26 |
+
verb: str = ""
|
| 27 |
+
verb_phrase: str = ""
|
| 28 |
+
object: str = ""
|
| 29 |
+
place: str = ""
|
| 30 |
+
time: str = ""
|
| 31 |
+
manner: str = ""
|
| 32 |
+
negation: str = ""
|
| 33 |
+
leftover: str = ""
|
| 34 |
+
entities: list[str] = field(default_factory=list)
|
| 35 |
+
confidence: float = 0.0
|
| 36 |
+
sentence_type: str = "unsupported"
|
| 37 |
+
reasons: list[str] = field(default_factory=list)
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
@dataclass
|
| 41 |
+
class TemplateCandidate:
|
| 42 |
+
template_id: str
|
| 43 |
+
confidence: float
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
@dataclass
|
| 47 |
+
class RewritePlan:
|
| 48 |
+
"""Decision object before generation."""
|
| 49 |
+
|
| 50 |
+
safe: bool
|
| 51 |
+
slots: SentenceSlots | None = None
|
| 52 |
+
template_id: str = ""
|
| 53 |
+
candidates: list[TemplateCandidate] = field(default_factory=list)
|
| 54 |
+
fixed_spans: list[str] = field(default_factory=list)
|
| 55 |
+
movable: list[str] = field(default_factory=list)
|
| 56 |
+
skip_reason: str = ""
|
| 57 |
+
confidence: float = 0.0
|
| 58 |
+
|
| 59 |
+
|
| 60 |
+
@dataclass
|
| 61 |
+
class SentenceRecord:
|
| 62 |
+
"""Per-sentence rewrite report."""
|
| 63 |
+
|
| 64 |
+
index: int
|
| 65 |
+
original: str
|
| 66 |
+
rewritten: str
|
| 67 |
+
confidence: float
|
| 68 |
+
status: str # rewritten|skipped|reverted|passthrough
|
| 69 |
+
template_id: str = ""
|
| 70 |
+
sentence_type: str = ""
|
| 71 |
+
reasons: list[str] = field(default_factory=list)
|
| 72 |
+
block_index: int = 0
|
| 73 |
+
|
| 74 |
+
|
| 75 |
+
@dataclass
|
| 76 |
+
class EngineStats:
|
| 77 |
+
batches: int = 0
|
| 78 |
+
blocks: int = 0
|
| 79 |
+
sentences: int = 0
|
| 80 |
+
rewritten: int = 0
|
| 81 |
+
skipped: int = 0
|
| 82 |
+
reverted: int = 0
|
| 83 |
+
passthrough: int = 0
|
| 84 |
+
seconds: float = 0.0
|
| 85 |
+
reasons: dict[str, int] = field(default_factory=dict)
|
| 86 |
+
|
| 87 |
+
def bump(self, reason: str) -> None:
|
| 88 |
+
key = (reason or "other").split(":")[0]
|
| 89 |
+
self.reasons[key] = self.reasons.get(key, 0) + 1
|
| 90 |
+
|
| 91 |
+
|
| 92 |
+
@dataclass
|
| 93 |
+
class EngineResult:
|
| 94 |
+
text: str
|
| 95 |
+
sentences: list[SentenceRecord] = field(default_factory=list)
|
| 96 |
+
skipped: list[SentenceRecord] = field(default_factory=list)
|
| 97 |
+
mapping: list[tuple[str, str]] = field(default_factory=list)
|
| 98 |
+
stats: EngineStats = field(default_factory=EngineStats)
|
| 99 |
+
notes: str = ""
|
| 100 |
+
engine: str = "structural-reorder"
|
| 101 |
+
input_words: int = 0
|
| 102 |
+
output_words: int = 0
|
| 103 |
+
changed: bool = False
|
| 104 |
+
similarity: float = 1.0
|
app/engine/normalize/__init__.py
ADDED
|
@@ -0,0 +1,258 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Cleaning, punctuation normalization, and block tagging."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
import re
|
| 6 |
+
|
| 7 |
+
from app.engine.models import DocumentBlock
|
| 8 |
+
|
| 9 |
+
_BIBLIO_HEADINGS = re.compile(
|
| 10 |
+
r"^(#{1,6}\s*)?(references|bibliography|works\s+cited|citations)\s*:?\s*$",
|
| 11 |
+
re.I | re.M,
|
| 12 |
+
)
|
| 13 |
+
_HEADING = re.compile(r"^(#{1,6}\s+\S.*|[A-Z][A-Z0-9 ,.'-]{2,60})$")
|
| 14 |
+
_LIST_ITEM = re.compile(r"^(\d+[\.\)]\s+|[-*•]\s+)\S")
|
| 15 |
+
_CODE_FENCE = re.compile(r"^```")
|
| 16 |
+
_TABLE_LINE = re.compile(r"^\s*\|.+\|\s*$")
|
| 17 |
+
_HTML_TABLE = re.compile(r"</?table\b", re.I)
|
| 18 |
+
_FORMULA = re.compile(r"(\$\$.+?\$\$|\\\[[\s\S]+?\\\]|\\begin\{(?:equation|align|math)\})")
|
| 19 |
+
_URL = re.compile(r"https?://[^\s<>\"']+|www\.[^\s<>\"']+", re.I)
|
| 20 |
+
_EMAIL = re.compile(r"\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}\b")
|
| 21 |
+
_SOFT_WRAP = re.compile(r"(?<=\w)\n(?=\w)")
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
def normalize_text(text: str) -> str:
|
| 25 |
+
"""UTF-8 hygiene, quotes/dashes, soft wraps, spacing."""
|
| 26 |
+
t = (text or "").replace("\r\n", "\n").replace("\r", "\n")
|
| 27 |
+
# Smart quotes / dashes → ASCII equivalents
|
| 28 |
+
t = (
|
| 29 |
+
t.replace("\u201c", '"')
|
| 30 |
+
.replace("\u201d", '"')
|
| 31 |
+
.replace("\u2018", "'")
|
| 32 |
+
.replace("\u2019", "'")
|
| 33 |
+
.replace("\u2013", "-")
|
| 34 |
+
.replace("\u2014", "—")
|
| 35 |
+
.replace("\u00a0", " ")
|
| 36 |
+
)
|
| 37 |
+
# Fix broken line wraps inside paragraphs (keep blank-line paragraph breaks)
|
| 38 |
+
parts = re.split(r"(\n\s*\n)", t)
|
| 39 |
+
fixed: list[str] = []
|
| 40 |
+
for part in parts:
|
| 41 |
+
if re.match(r"\n\s*\n", part):
|
| 42 |
+
fixed.append("\n\n")
|
| 43 |
+
continue
|
| 44 |
+
# Don't join wraps inside code fences
|
| 45 |
+
if "```" in part:
|
| 46 |
+
fixed.append(part)
|
| 47 |
+
continue
|
| 48 |
+
lines = part.split("\n")
|
| 49 |
+
rebuilt: list[str] = []
|
| 50 |
+
buf = ""
|
| 51 |
+
for line in lines:
|
| 52 |
+
stripped = line.strip()
|
| 53 |
+
if not stripped:
|
| 54 |
+
if buf:
|
| 55 |
+
rebuilt.append(buf)
|
| 56 |
+
buf = ""
|
| 57 |
+
rebuilt.append("")
|
| 58 |
+
continue
|
| 59 |
+
if _LIST_ITEM.match(stripped) or _HEADING.match(stripped) or _TABLE_LINE.match(stripped):
|
| 60 |
+
if buf:
|
| 61 |
+
rebuilt.append(buf)
|
| 62 |
+
buf = ""
|
| 63 |
+
rebuilt.append(stripped)
|
| 64 |
+
continue
|
| 65 |
+
if buf and not buf.endswith(("-", "—")):
|
| 66 |
+
# Soft wrap: join with space
|
| 67 |
+
buf = f"{buf} {stripped}"
|
| 68 |
+
elif buf and buf.endswith(("-", "—")):
|
| 69 |
+
buf = buf.rstrip("-—") + stripped
|
| 70 |
+
else:
|
| 71 |
+
buf = stripped
|
| 72 |
+
if buf:
|
| 73 |
+
rebuilt.append(buf)
|
| 74 |
+
fixed.append("\n".join(rebuilt))
|
| 75 |
+
t = "".join(fixed)
|
| 76 |
+
t = re.sub(r"[ \t]+", " ", t)
|
| 77 |
+
t = re.sub(r" *\n *", "\n", t)
|
| 78 |
+
t = re.sub(r"\n{3,}", "\n\n", t)
|
| 79 |
+
# Punctuation spacing
|
| 80 |
+
t = re.sub(r"\s+([,.;:!?])", r"\1", t)
|
| 81 |
+
t = re.sub(r"([(\[{])\s+", r"\1", t)
|
| 82 |
+
t = re.sub(r"\s+([)\]}])", r"\1", t)
|
| 83 |
+
return t.strip()
|
| 84 |
+
|
| 85 |
+
|
| 86 |
+
def _is_bibliography_heading(line: str) -> bool:
|
| 87 |
+
return bool(_BIBLIO_HEADINGS.match(line.strip()))
|
| 88 |
+
|
| 89 |
+
|
| 90 |
+
def detect_blocks(text: str) -> list[DocumentBlock]:
|
| 91 |
+
"""Split normalized text into typed blocks with rewrite flags."""
|
| 92 |
+
raw = text or ""
|
| 93 |
+
if not raw.strip():
|
| 94 |
+
return []
|
| 95 |
+
|
| 96 |
+
blocks: list[DocumentBlock] = []
|
| 97 |
+
chunks = re.split(r"(\n\s*\n)", raw)
|
| 98 |
+
in_code = False
|
| 99 |
+
in_biblio = False
|
| 100 |
+
buf_lines: list[str] = []
|
| 101 |
+
buf_kind = "paragraph"
|
| 102 |
+
index = 0
|
| 103 |
+
|
| 104 |
+
def flush() -> None:
|
| 105 |
+
nonlocal index, buf_lines, buf_kind
|
| 106 |
+
if not buf_lines:
|
| 107 |
+
return
|
| 108 |
+
body = "\n".join(buf_lines).strip("\n")
|
| 109 |
+
if body.strip() == "" and body:
|
| 110 |
+
blocks.append(
|
| 111 |
+
DocumentBlock(text=body, kind="blank", rewriteable=False, index=index)
|
| 112 |
+
)
|
| 113 |
+
elif body.strip():
|
| 114 |
+
rewriteable = buf_kind == "paragraph" and not in_biblio
|
| 115 |
+
blocks.append(
|
| 116 |
+
DocumentBlock(
|
| 117 |
+
text=body.strip(),
|
| 118 |
+
kind=buf_kind if not in_biblio or buf_kind != "paragraph" else "bibliography",
|
| 119 |
+
rewriteable=rewriteable and buf_kind == "paragraph",
|
| 120 |
+
index=index,
|
| 121 |
+
)
|
| 122 |
+
)
|
| 123 |
+
index += 1
|
| 124 |
+
buf_lines = []
|
| 125 |
+
buf_kind = "paragraph"
|
| 126 |
+
|
| 127 |
+
for chunk in chunks:
|
| 128 |
+
if re.match(r"\n\s*\n", chunk or ""):
|
| 129 |
+
flush()
|
| 130 |
+
blocks.append(
|
| 131 |
+
DocumentBlock(text="", kind="blank", rewriteable=False, index=index)
|
| 132 |
+
)
|
| 133 |
+
index += 1
|
| 134 |
+
continue
|
| 135 |
+
|
| 136 |
+
for line in (chunk or "").split("\n"):
|
| 137 |
+
stripped = line.strip()
|
| 138 |
+
|
| 139 |
+
if _CODE_FENCE.match(stripped):
|
| 140 |
+
if in_code:
|
| 141 |
+
buf_lines.append(line)
|
| 142 |
+
buf_kind = "code"
|
| 143 |
+
flush()
|
| 144 |
+
in_code = False
|
| 145 |
+
else:
|
| 146 |
+
flush()
|
| 147 |
+
in_code = True
|
| 148 |
+
buf_kind = "code"
|
| 149 |
+
buf_lines = [line]
|
| 150 |
+
continue
|
| 151 |
+
|
| 152 |
+
if in_code:
|
| 153 |
+
buf_lines.append(line)
|
| 154 |
+
buf_kind = "code"
|
| 155 |
+
continue
|
| 156 |
+
|
| 157 |
+
if _is_bibliography_heading(stripped):
|
| 158 |
+
flush()
|
| 159 |
+
in_biblio = True
|
| 160 |
+
buf_kind = "bibliography"
|
| 161 |
+
buf_lines = [stripped]
|
| 162 |
+
flush()
|
| 163 |
+
continue
|
| 164 |
+
|
| 165 |
+
if _HTML_TABLE.search(stripped) or _TABLE_LINE.match(stripped):
|
| 166 |
+
flush()
|
| 167 |
+
blocks.append(
|
| 168 |
+
DocumentBlock(
|
| 169 |
+
text=stripped, kind="table", rewriteable=False, index=index
|
| 170 |
+
)
|
| 171 |
+
)
|
| 172 |
+
index += 1
|
| 173 |
+
continue
|
| 174 |
+
|
| 175 |
+
if _FORMULA.search(stripped) and len(stripped.split()) < 40:
|
| 176 |
+
flush()
|
| 177 |
+
blocks.append(
|
| 178 |
+
DocumentBlock(
|
| 179 |
+
text=stripped, kind="formula", rewriteable=False, index=index
|
| 180 |
+
)
|
| 181 |
+
)
|
| 182 |
+
index += 1
|
| 183 |
+
continue
|
| 184 |
+
|
| 185 |
+
if _LIST_ITEM.match(stripped):
|
| 186 |
+
flush()
|
| 187 |
+
blocks.append(
|
| 188 |
+
DocumentBlock(
|
| 189 |
+
text=stripped, kind="list", rewriteable=False, index=index
|
| 190 |
+
)
|
| 191 |
+
)
|
| 192 |
+
index += 1
|
| 193 |
+
continue
|
| 194 |
+
|
| 195 |
+
if _HEADING.match(stripped) and len(stripped.split()) <= 12:
|
| 196 |
+
flush()
|
| 197 |
+
blocks.append(
|
| 198 |
+
DocumentBlock(
|
| 199 |
+
text=stripped, kind="heading", rewriteable=False, index=index
|
| 200 |
+
)
|
| 201 |
+
)
|
| 202 |
+
index += 1
|
| 203 |
+
continue
|
| 204 |
+
|
| 205 |
+
if in_biblio:
|
| 206 |
+
buf_kind = "bibliography"
|
| 207 |
+
else:
|
| 208 |
+
buf_kind = "paragraph"
|
| 209 |
+
buf_lines.append(stripped if stripped else line)
|
| 210 |
+
|
| 211 |
+
flush()
|
| 212 |
+
|
| 213 |
+
# Protect paragraphs that are mostly URL/email-only
|
| 214 |
+
for b in blocks:
|
| 215 |
+
if not b.rewriteable:
|
| 216 |
+
continue
|
| 217 |
+
plain = _URL.sub("", b.text)
|
| 218 |
+
plain = _EMAIL.sub("", plain).strip()
|
| 219 |
+
if len(plain.split()) < 3 and (_URL.search(b.text) or _EMAIL.search(b.text)):
|
| 220 |
+
b.rewriteable = False
|
| 221 |
+
b.kind = "special"
|
| 222 |
+
|
| 223 |
+
return blocks
|
| 224 |
+
|
| 225 |
+
|
| 226 |
+
def mask_protected_spans(text: str) -> tuple[str, dict[str, str]]:
|
| 227 |
+
"""Replace URLs/emails/paths with placeholders for rewrite safety."""
|
| 228 |
+
mapping: dict[str, str] = {}
|
| 229 |
+
counter = {"n": 0}
|
| 230 |
+
|
| 231 |
+
def _sub(pattern: re.Pattern[str], label: str, s: str) -> str:
|
| 232 |
+
def repl(m: re.Match[str]) -> str:
|
| 233 |
+
key = f"__PROT_{label}_{counter['n']}__"
|
| 234 |
+
mapping[key] = m.group(0)
|
| 235 |
+
counter["n"] += 1
|
| 236 |
+
return key
|
| 237 |
+
|
| 238 |
+
return pattern.sub(repl, s)
|
| 239 |
+
|
| 240 |
+
out = text
|
| 241 |
+
out = _sub(_URL, "URL", out)
|
| 242 |
+
out = _sub(_EMAIL, "EMAIL", out)
|
| 243 |
+
|
| 244 |
+
def path_repl(m: re.Match[str]) -> str:
|
| 245 |
+
key = f"__PROT_PATH_{counter['n']}__"
|
| 246 |
+
mapping[key] = m.group(0)
|
| 247 |
+
counter["n"] += 1
|
| 248 |
+
return key
|
| 249 |
+
|
| 250 |
+
out = re.sub(r"(?:[A-Za-z]:\\|/)[^\s<>\"']+", path_repl, out)
|
| 251 |
+
return out, mapping
|
| 252 |
+
|
| 253 |
+
|
| 254 |
+
def unmask_protected_spans(text: str, mapping: dict[str, str]) -> str:
|
| 255 |
+
out = text
|
| 256 |
+
for key, val in mapping.items():
|
| 257 |
+
out = out.replace(key, val)
|
| 258 |
+
return out
|
app/engine/normalize/__pycache__/__init__.cpython-311.pyc
ADDED
|
Binary file (11.8 kB). View file
|
|
|
app/engine/orchestrator.py
ADDED
|
@@ -0,0 +1,354 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Batch/stream orchestrator for the structural rewrite engine."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
import logging
|
| 6 |
+
import time
|
| 7 |
+
from difflib import SequenceMatcher
|
| 8 |
+
|
| 9 |
+
from app.config import (
|
| 10 |
+
ALLOW_SYNONYM_REPLACEMENT,
|
| 11 |
+
ENGINE_BATCH_PARAS,
|
| 12 |
+
ENGINE_MIN_CONFIDENCE,
|
| 13 |
+
ENGINE_SAFETY_MIN,
|
| 14 |
+
ENGINE_USE_MINILM_SAFETY,
|
| 15 |
+
GRAMMAR_FIX_INPUT,
|
| 16 |
+
GRAMMAR_FIX_OUTPUT,
|
| 17 |
+
)
|
| 18 |
+
from app.engine.classify import classify_sentence
|
| 19 |
+
from app.engine.consistency import apply_consistency
|
| 20 |
+
from app.engine.grammar import repair_sentence
|
| 21 |
+
from app.engine.ingest import ingest_text
|
| 22 |
+
from app.engine.models import (
|
| 23 |
+
DocumentBlock,
|
| 24 |
+
EngineResult,
|
| 25 |
+
EngineStats,
|
| 26 |
+
SentenceRecord,
|
| 27 |
+
)
|
| 28 |
+
from app.engine.normalize import (
|
| 29 |
+
detect_blocks,
|
| 30 |
+
mask_protected_spans,
|
| 31 |
+
normalize_text,
|
| 32 |
+
unmask_protected_spans,
|
| 33 |
+
)
|
| 34 |
+
from app.engine.plan import build_plan
|
| 35 |
+
from app.engine.rewrite import generate_from_plan
|
| 36 |
+
from app.engine.safety import check_safety
|
| 37 |
+
from app.engine.segment import iter_paragraph_batches, split_sentences, word_count
|
| 38 |
+
from app.engine.stitch import join_sentences, stitch_blocks
|
| 39 |
+
|
| 40 |
+
logger = logging.getLogger("plainrewrite.engine")
|
| 41 |
+
|
| 42 |
+
# Synonym path is intentionally unused unless explicitly enabled (not implemented on hot path).
|
| 43 |
+
_ = ALLOW_SYNONYM_REPLACEMENT
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
def _similarity(a: str, b: str) -> float:
|
| 47 |
+
return SequenceMatcher(None, (a or "").lower(), (b or "").lower()).ratio()
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
def _process_sentence(
|
| 51 |
+
text: str,
|
| 52 |
+
*,
|
| 53 |
+
index: int,
|
| 54 |
+
block_index: int,
|
| 55 |
+
min_confidence: float,
|
| 56 |
+
safety_min: float,
|
| 57 |
+
use_minilm: bool,
|
| 58 |
+
last_template: str,
|
| 59 |
+
stats: EngineStats,
|
| 60 |
+
) -> tuple[SentenceRecord, str]:
|
| 61 |
+
"""Rewrite one sentence with plan → generate → grammar → safety → fallback."""
|
| 62 |
+
original = (text or "").strip()
|
| 63 |
+
kind = classify_sentence(original)
|
| 64 |
+
|
| 65 |
+
if not original:
|
| 66 |
+
rec = SentenceRecord(
|
| 67 |
+
index=index,
|
| 68 |
+
original=original,
|
| 69 |
+
rewritten=original,
|
| 70 |
+
confidence=1.0,
|
| 71 |
+
status="passthrough",
|
| 72 |
+
sentence_type=kind,
|
| 73 |
+
block_index=block_index,
|
| 74 |
+
)
|
| 75 |
+
stats.passthrough += 1
|
| 76 |
+
return rec, ""
|
| 77 |
+
|
| 78 |
+
plan = build_plan(original, min_confidence=min_confidence)
|
| 79 |
+
if not plan.safe:
|
| 80 |
+
reason = plan.skip_reason or kind
|
| 81 |
+
stats.skipped += 1
|
| 82 |
+
stats.bump(reason)
|
| 83 |
+
return (
|
| 84 |
+
SentenceRecord(
|
| 85 |
+
index=index,
|
| 86 |
+
original=original,
|
| 87 |
+
rewritten=original,
|
| 88 |
+
confidence=plan.confidence,
|
| 89 |
+
status="skipped",
|
| 90 |
+
sentence_type=kind,
|
| 91 |
+
reasons=[reason],
|
| 92 |
+
block_index=block_index,
|
| 93 |
+
),
|
| 94 |
+
"",
|
| 95 |
+
)
|
| 96 |
+
|
| 97 |
+
# Prefer alternate template if last sentence used the same time-front family
|
| 98 |
+
time_fronts = {
|
| 99 |
+
"time_subj_manner_verb_place",
|
| 100 |
+
"time_subj_verb_place",
|
| 101 |
+
"time_subj_verb_object",
|
| 102 |
+
"time_subj_verb_object_place",
|
| 103 |
+
"time_front",
|
| 104 |
+
}
|
| 105 |
+
candidates = list(plan.candidates)
|
| 106 |
+
if last_template in time_fronts and candidates:
|
| 107 |
+
reordered = [c for c in candidates if c.template_id != last_template]
|
| 108 |
+
reordered += [c for c in candidates if c.template_id == last_template]
|
| 109 |
+
candidates = reordered
|
| 110 |
+
|
| 111 |
+
best: SentenceRecord | None = None
|
| 112 |
+
for cand in candidates:
|
| 113 |
+
generated = generate_from_plan(plan, template_id=cand.template_id)
|
| 114 |
+
if not generated:
|
| 115 |
+
continue
|
| 116 |
+
repaired = repair_sentence(generated) if GRAMMAR_FIX_OUTPUT else generated
|
| 117 |
+
safety = check_safety(
|
| 118 |
+
original,
|
| 119 |
+
repaired,
|
| 120 |
+
min_meaning=safety_min,
|
| 121 |
+
min_confidence=min_confidence,
|
| 122 |
+
use_minilm=use_minilm,
|
| 123 |
+
)
|
| 124 |
+
if not safety.ok:
|
| 125 |
+
stats.bump(safety.reasons[0] if safety.reasons else "safety")
|
| 126 |
+
continue
|
| 127 |
+
conf = min(cand.confidence, safety.confidence + 0.2)
|
| 128 |
+
best = SentenceRecord(
|
| 129 |
+
index=index,
|
| 130 |
+
original=original,
|
| 131 |
+
rewritten=repaired,
|
| 132 |
+
confidence=conf,
|
| 133 |
+
status="rewritten",
|
| 134 |
+
template_id=cand.template_id,
|
| 135 |
+
sentence_type=kind,
|
| 136 |
+
reasons=[],
|
| 137 |
+
block_index=block_index,
|
| 138 |
+
)
|
| 139 |
+
break
|
| 140 |
+
|
| 141 |
+
if best is None:
|
| 142 |
+
stats.reverted += 1
|
| 143 |
+
stats.bump("safety_fallback")
|
| 144 |
+
return (
|
| 145 |
+
SentenceRecord(
|
| 146 |
+
index=index,
|
| 147 |
+
original=original,
|
| 148 |
+
rewritten=original,
|
| 149 |
+
confidence=0.0,
|
| 150 |
+
status="reverted",
|
| 151 |
+
sentence_type=kind,
|
| 152 |
+
reasons=["safety_fallback"],
|
| 153 |
+
block_index=block_index,
|
| 154 |
+
),
|
| 155 |
+
"",
|
| 156 |
+
)
|
| 157 |
+
|
| 158 |
+
stats.rewritten += 1
|
| 159 |
+
return best, best.template_id
|
| 160 |
+
|
| 161 |
+
|
| 162 |
+
def _process_block(
|
| 163 |
+
block: DocumentBlock,
|
| 164 |
+
*,
|
| 165 |
+
sent_offset: int,
|
| 166 |
+
min_confidence: float,
|
| 167 |
+
safety_min: float,
|
| 168 |
+
use_minilm: bool,
|
| 169 |
+
last_template: str,
|
| 170 |
+
stats: EngineStats,
|
| 171 |
+
) -> tuple[DocumentBlock, list[SentenceRecord], str]:
|
| 172 |
+
if not block.rewriteable or block.kind != "paragraph":
|
| 173 |
+
stats.passthrough += 1
|
| 174 |
+
rec = SentenceRecord(
|
| 175 |
+
index=sent_offset,
|
| 176 |
+
original=block.text,
|
| 177 |
+
rewritten=block.text,
|
| 178 |
+
confidence=1.0,
|
| 179 |
+
status="passthrough",
|
| 180 |
+
sentence_type=block.kind,
|
| 181 |
+
block_index=block.index,
|
| 182 |
+
reasons=[f"block:{block.kind}"],
|
| 183 |
+
)
|
| 184 |
+
return block, [rec], last_template
|
| 185 |
+
|
| 186 |
+
masked, prot = mask_protected_spans(block.text)
|
| 187 |
+
sentences = split_sentences(masked)
|
| 188 |
+
if not sentences:
|
| 189 |
+
return block, [], last_template
|
| 190 |
+
|
| 191 |
+
records: list[SentenceRecord] = []
|
| 192 |
+
out_sents: list[str] = []
|
| 193 |
+
tid = last_template
|
| 194 |
+
for i, sent in enumerate(sentences):
|
| 195 |
+
rec, tid = _process_sentence(
|
| 196 |
+
sent,
|
| 197 |
+
index=sent_offset + i,
|
| 198 |
+
block_index=block.index,
|
| 199 |
+
min_confidence=min_confidence,
|
| 200 |
+
safety_min=safety_min,
|
| 201 |
+
use_minilm=use_minilm,
|
| 202 |
+
last_template=tid,
|
| 203 |
+
stats=stats,
|
| 204 |
+
)
|
| 205 |
+
# Unmask protected spans in both sides of the record
|
| 206 |
+
rec.original = unmask_protected_spans(rec.original, prot)
|
| 207 |
+
rec.rewritten = unmask_protected_spans(rec.rewritten, prot)
|
| 208 |
+
records.append(rec)
|
| 209 |
+
out_sents.append(rec.rewritten)
|
| 210 |
+
|
| 211 |
+
new_text = unmask_protected_spans(join_sentences(out_sents), prot)
|
| 212 |
+
new_block = DocumentBlock(
|
| 213 |
+
text=new_text,
|
| 214 |
+
kind=block.kind,
|
| 215 |
+
rewriteable=block.rewriteable,
|
| 216 |
+
index=block.index,
|
| 217 |
+
meta=dict(block.meta),
|
| 218 |
+
)
|
| 219 |
+
return new_block, records, tid
|
| 220 |
+
|
| 221 |
+
|
| 222 |
+
def rewrite_document(
|
| 223 |
+
source: str | bytes | None,
|
| 224 |
+
*,
|
| 225 |
+
batch_paras: int | None = None,
|
| 226 |
+
min_confidence: float | None = None,
|
| 227 |
+
safety_min: float | None = None,
|
| 228 |
+
use_minilm_safety: bool | None = None,
|
| 229 |
+
) -> EngineResult:
|
| 230 |
+
"""
|
| 231 |
+
Structural document rewrite: reorder safe sentences, skip the rest.
|
| 232 |
+
|
| 233 |
+
Processes large documents in paragraph batches. Tone/synonym replacement
|
| 234 |
+
is not used on this path.
|
| 235 |
+
"""
|
| 236 |
+
started = time.perf_counter()
|
| 237 |
+
batch_paras = batch_paras if batch_paras is not None else ENGINE_BATCH_PARAS
|
| 238 |
+
min_confidence = (
|
| 239 |
+
min_confidence if min_confidence is not None else ENGINE_MIN_CONFIDENCE
|
| 240 |
+
)
|
| 241 |
+
safety_min = safety_min if safety_min is not None else ENGINE_SAFETY_MIN
|
| 242 |
+
use_minilm = (
|
| 243 |
+
use_minilm_safety
|
| 244 |
+
if use_minilm_safety is not None
|
| 245 |
+
else ENGINE_USE_MINILM_SAFETY
|
| 246 |
+
)
|
| 247 |
+
|
| 248 |
+
raw = ingest_text(source)
|
| 249 |
+
if not raw.strip():
|
| 250 |
+
raise ValueError("Paste some text first.")
|
| 251 |
+
|
| 252 |
+
normalized = normalize_text(raw)
|
| 253 |
+
if GRAMMAR_FIX_INPUT:
|
| 254 |
+
try:
|
| 255 |
+
from app.pipeline.grammar_fix import correct_text
|
| 256 |
+
|
| 257 |
+
cleaned = correct_text(normalized)
|
| 258 |
+
if cleaned and cleaned.strip():
|
| 259 |
+
normalized = normalize_text(cleaned)
|
| 260 |
+
except Exception:
|
| 261 |
+
logger.debug("input grammar fix skipped", exc_info=True)
|
| 262 |
+
|
| 263 |
+
blocks = detect_blocks(normalized)
|
| 264 |
+
stats = EngineStats(blocks=len(blocks))
|
| 265 |
+
all_records: list[SentenceRecord] = []
|
| 266 |
+
out_blocks: list[DocumentBlock] = []
|
| 267 |
+
sent_offset = 0
|
| 268 |
+
last_template = ""
|
| 269 |
+
|
| 270 |
+
for batch in iter_paragraph_batches(blocks, batch_paras=batch_paras):
|
| 271 |
+
stats.batches += 1
|
| 272 |
+
for block in batch:
|
| 273 |
+
new_block, records, last_template = _process_block(
|
| 274 |
+
block,
|
| 275 |
+
sent_offset=sent_offset,
|
| 276 |
+
min_confidence=min_confidence,
|
| 277 |
+
safety_min=safety_min,
|
| 278 |
+
use_minilm=use_minilm,
|
| 279 |
+
last_template=last_template,
|
| 280 |
+
stats=stats,
|
| 281 |
+
)
|
| 282 |
+
out_blocks.append(new_block)
|
| 283 |
+
all_records.extend(records)
|
| 284 |
+
# Advance offset by sentence count for rewriteable paras
|
| 285 |
+
if block.rewriteable and block.kind == "paragraph":
|
| 286 |
+
sent_offset += max(1, len(records))
|
| 287 |
+
else:
|
| 288 |
+
sent_offset += 1
|
| 289 |
+
|
| 290 |
+
stats.sentences = len(all_records)
|
| 291 |
+
stitched = stitch_blocks(out_blocks)
|
| 292 |
+
|
| 293 |
+
# Consistency: suppress streaks then rebuild from records if needed
|
| 294 |
+
stitched, all_records = apply_consistency(stitched, all_records)
|
| 295 |
+
streak_reverts = [r for r in all_records if "template_streak" in r.reasons]
|
| 296 |
+
if streak_reverts:
|
| 297 |
+
# Rebuild rewriteable paragraph texts from records
|
| 298 |
+
by_block: dict[int, list[SentenceRecord]] = {}
|
| 299 |
+
for rec in all_records:
|
| 300 |
+
by_block.setdefault(rec.block_index, []).append(rec)
|
| 301 |
+
rebuilt: list[DocumentBlock] = []
|
| 302 |
+
for block in out_blocks:
|
| 303 |
+
recs = by_block.get(block.index, [])
|
| 304 |
+
if block.rewriteable and block.kind == "paragraph" and recs:
|
| 305 |
+
text = join_sentences([r.rewritten for r in recs])
|
| 306 |
+
rebuilt.append(
|
| 307 |
+
DocumentBlock(
|
| 308 |
+
text=text,
|
| 309 |
+
kind=block.kind,
|
| 310 |
+
rewriteable=True,
|
| 311 |
+
index=block.index,
|
| 312 |
+
)
|
| 313 |
+
)
|
| 314 |
+
else:
|
| 315 |
+
rebuilt.append(block)
|
| 316 |
+
stitched = stitch_blocks(rebuilt)
|
| 317 |
+
stitched, all_records = apply_consistency(stitched, all_records)
|
| 318 |
+
|
| 319 |
+
stats.seconds = round(time.perf_counter() - started, 3)
|
| 320 |
+
mapping = [(r.original, r.rewritten) for r in all_records]
|
| 321 |
+
skipped = [r for r in all_records if r.status in {"skipped", "reverted"}]
|
| 322 |
+
in_words = word_count(normalized)
|
| 323 |
+
out_words = word_count(stitched)
|
| 324 |
+
changed = stitched.strip() != normalized.strip()
|
| 325 |
+
sim = _similarity(normalized, stitched)
|
| 326 |
+
|
| 327 |
+
notes = (
|
| 328 |
+
f"structural: rewritten={stats.rewritten} skipped={stats.skipped} "
|
| 329 |
+
f"reverted={stats.reverted} batches={stats.batches}"
|
| 330 |
+
)
|
| 331 |
+
logger.info(
|
| 332 |
+
"engine done words=%s→%s rewritten=%s skipped=%s reverted=%s batches=%s t=%.2fs",
|
| 333 |
+
in_words,
|
| 334 |
+
out_words,
|
| 335 |
+
stats.rewritten,
|
| 336 |
+
stats.skipped,
|
| 337 |
+
stats.reverted,
|
| 338 |
+
stats.batches,
|
| 339 |
+
stats.seconds,
|
| 340 |
+
)
|
| 341 |
+
|
| 342 |
+
return EngineResult(
|
| 343 |
+
text=stitched,
|
| 344 |
+
sentences=all_records,
|
| 345 |
+
skipped=skipped,
|
| 346 |
+
mapping=mapping,
|
| 347 |
+
stats=stats,
|
| 348 |
+
notes=notes,
|
| 349 |
+
engine="structural-reorder",
|
| 350 |
+
input_words=in_words,
|
| 351 |
+
output_words=out_words,
|
| 352 |
+
changed=changed,
|
| 353 |
+
similarity=round(sim, 4),
|
| 354 |
+
)
|
app/engine/parse/__init__.py
ADDED
|
@@ -0,0 +1,217 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Dependency parsing — spaCy-first slot extraction (no synonym tables)."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
import re
|
| 6 |
+
|
| 7 |
+
from app.engine.classify import classify_sentence
|
| 8 |
+
from app.engine.models import SentenceSlots
|
| 9 |
+
from app.pipeline.nlp import get_nlp
|
| 10 |
+
|
| 11 |
+
# Closed-class helpers only (spaCy NER/deps preferred for open-class time/place)
|
| 12 |
+
_TIME_WORDS = frozenset(
|
| 13 |
+
"""
|
| 14 |
+
yesterday today tomorrow earlier later recently now tonight
|
| 15 |
+
monday tuesday wednesday thursday friday saturday sunday
|
| 16 |
+
morning afternoon evening night
|
| 17 |
+
""".split()
|
| 18 |
+
)
|
| 19 |
+
_DISCOURSE_TIME = frozenset({"nowadays"})
|
| 20 |
+
_DEGREE_ADVS = frozenset(
|
| 21 |
+
"""
|
| 22 |
+
more most less least much many very really quite just also still even
|
| 23 |
+
only rather pretty fairly so too enough almost nearly already ever never
|
| 24 |
+
not n't however nevertheless furthermore moreover
|
| 25 |
+
""".split()
|
| 26 |
+
)
|
| 27 |
+
_PLACE_PREPS = frozenset("to at in on into onto from toward towards".split())
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
def _span_text(tokens: list) -> str:
|
| 31 |
+
if not tokens:
|
| 32 |
+
return ""
|
| 33 |
+
return "".join(t.text_with_ws for t in tokens).strip()
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
def _subtree_tokens(token) -> list:
|
| 37 |
+
return sorted(token.subtree, key=lambda t: t.i)
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
def _is_manner_adv(word: str) -> bool:
|
| 41 |
+
low = (word or "").lower().strip()
|
| 42 |
+
if not low or low in _DEGREE_ADVS or low in _DISCOURSE_TIME:
|
| 43 |
+
return False
|
| 44 |
+
return low.endswith("ly") and len(low) >= 4
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
def extract_slots(text: str) -> SentenceSlots:
|
| 48 |
+
"""Parse subject/verb/object/time/place/manner/negation/entities."""
|
| 49 |
+
raw = (text or "").strip()
|
| 50 |
+
slots = SentenceSlots(text=raw, sentence_type=classify_sentence(raw))
|
| 51 |
+
if slots.sentence_type not in {"simple_declarative", "compound"}:
|
| 52 |
+
slots.reasons.append(f"skip:{slots.sentence_type}")
|
| 53 |
+
return slots
|
| 54 |
+
|
| 55 |
+
nlp = get_nlp()
|
| 56 |
+
if nlp is None:
|
| 57 |
+
return _extract_slots_regex(raw)
|
| 58 |
+
|
| 59 |
+
doc = nlp(raw)
|
| 60 |
+
slots.entities = [ent.text for ent in doc.ents if ent.label_ in {
|
| 61 |
+
"PERSON", "ORG", "GPE", "LOC", "DATE", "TIME", "MONEY", "PERCENT", "CARDINAL",
|
| 62 |
+
}]
|
| 63 |
+
|
| 64 |
+
root = next((t for t in doc if t.dep_ == "ROOT" and t.pos_ in {"VERB", "AUX"}), None)
|
| 65 |
+
if root is None:
|
| 66 |
+
root = next((t for t in doc if t.pos_ == "VERB"), None)
|
| 67 |
+
if root is None:
|
| 68 |
+
slots.reasons.append("no_verb")
|
| 69 |
+
return slots
|
| 70 |
+
|
| 71 |
+
subj_toks: list = []
|
| 72 |
+
for child in root.children:
|
| 73 |
+
if child.dep_ in {"nsubj", "nsubjpass"}:
|
| 74 |
+
subj_toks = _subtree_tokens(child)
|
| 75 |
+
break
|
| 76 |
+
slots.subject = _span_text(subj_toks)
|
| 77 |
+
|
| 78 |
+
neg_parts: list[str] = []
|
| 79 |
+
for t in doc:
|
| 80 |
+
if t.dep_ == "neg" and (t.head == root or t.head.head == root):
|
| 81 |
+
neg_parts.append(t.text)
|
| 82 |
+
slots.negation = " ".join(neg_parts)
|
| 83 |
+
|
| 84 |
+
verb_toks = [t for t in root.lefts if t.dep_ in {"aux", "auxpass", "neg"}] + [root]
|
| 85 |
+
slots.verb = root.text
|
| 86 |
+
slots.verb_phrase = _span_text(sorted(verb_toks, key=lambda t: t.i))
|
| 87 |
+
|
| 88 |
+
obj_toks: list = []
|
| 89 |
+
for child in root.children:
|
| 90 |
+
if child.dep_ in {"dobj", "obj", "attr"}:
|
| 91 |
+
obj_toks = _subtree_tokens(child)
|
| 92 |
+
break
|
| 93 |
+
if child.dep_ in {"ccomp", "xcomp"} and child.pos_ != "VERB":
|
| 94 |
+
obj_toks = _subtree_tokens(child)
|
| 95 |
+
break
|
| 96 |
+
slots.object = _span_text(obj_toks)
|
| 97 |
+
|
| 98 |
+
place_parts: list[str] = []
|
| 99 |
+
for child in root.children:
|
| 100 |
+
if child.dep_ == "prep" and child.text.lower() in _PLACE_PREPS:
|
| 101 |
+
# Prefer place over time-marked preps
|
| 102 |
+
span = _span_text(_subtree_tokens(child))
|
| 103 |
+
if not any(w.lower() in _TIME_WORDS for w in span.split()):
|
| 104 |
+
place_parts.append(span)
|
| 105 |
+
if not place_parts:
|
| 106 |
+
for t in doc:
|
| 107 |
+
if t.dep_ == "prep" and t.head == root and t.text.lower() in _PLACE_PREPS:
|
| 108 |
+
place_parts.append(_span_text(_subtree_tokens(t)))
|
| 109 |
+
slots.place = " ".join(p for p in place_parts if p).strip()
|
| 110 |
+
|
| 111 |
+
time_parts: list[str] = []
|
| 112 |
+
manner_parts: list[str] = []
|
| 113 |
+
for ent in doc.ents:
|
| 114 |
+
if ent.label_ in {"DATE", "TIME"}:
|
| 115 |
+
time_parts.append(ent.text)
|
| 116 |
+
for t in doc:
|
| 117 |
+
low = t.text.lower()
|
| 118 |
+
if t.dep_ in {"advmod", "npadvmod"} and (t.head == root or t.head.head == root):
|
| 119 |
+
if low in _TIME_WORDS or t.ent_type_ in {"DATE", "TIME"}:
|
| 120 |
+
if low not in _DISCOURSE_TIME:
|
| 121 |
+
time_parts.append(_span_text(_subtree_tokens(t)))
|
| 122 |
+
elif _is_manner_adv(low):
|
| 123 |
+
manner_parts.append(t.text)
|
| 124 |
+
if t.dep_ == "npadvmod" and low in _TIME_WORDS and low not in _DISCOURSE_TIME:
|
| 125 |
+
time_parts.append(t.text)
|
| 126 |
+
|
| 127 |
+
if not time_parts:
|
| 128 |
+
for t in doc:
|
| 129 |
+
low = t.text.lower()
|
| 130 |
+
if low in _TIME_WORDS and low not in _DISCOURSE_TIME:
|
| 131 |
+
time_parts.append(t.text)
|
| 132 |
+
if not manner_parts:
|
| 133 |
+
for t in doc:
|
| 134 |
+
if _is_manner_adv(t.text.lower()) and t.pos_ == "ADV":
|
| 135 |
+
manner_parts.append(t.text)
|
| 136 |
+
|
| 137 |
+
slots.time = " ".join(dict.fromkeys(time_parts)).strip()
|
| 138 |
+
slots.manner = " ".join(dict.fromkeys(manner_parts)).strip()
|
| 139 |
+
|
| 140 |
+
for piece in (slots.place, slots.time, slots.manner):
|
| 141 |
+
if piece and slots.object.endswith(piece):
|
| 142 |
+
slots.object = slots.object[: -len(piece)].strip(" ,")
|
| 143 |
+
|
| 144 |
+
score = 0.2
|
| 145 |
+
if slots.subject:
|
| 146 |
+
score += 0.25
|
| 147 |
+
if slots.verb_phrase:
|
| 148 |
+
score += 0.2
|
| 149 |
+
if slots.place or slots.object:
|
| 150 |
+
score += 0.15
|
| 151 |
+
if slots.time:
|
| 152 |
+
score += 0.1
|
| 153 |
+
if slots.manner:
|
| 154 |
+
score += 0.1
|
| 155 |
+
slots.confidence = min(1.0, score)
|
| 156 |
+
if slots.confidence < 0.45 or not slots.subject or not slots.verb_phrase:
|
| 157 |
+
slots.reasons.append("low_confidence")
|
| 158 |
+
return slots
|
| 159 |
+
|
| 160 |
+
|
| 161 |
+
def _extract_slots_regex(text: str) -> SentenceSlots:
|
| 162 |
+
"""Regex fallback when spaCy is unavailable."""
|
| 163 |
+
slots = SentenceSlots(text=text, sentence_type=classify_sentence(text))
|
| 164 |
+
if slots.sentence_type != "simple_declarative":
|
| 165 |
+
return slots
|
| 166 |
+
core = text
|
| 167 |
+
m_end = re.search(r"[.!?]+$", text)
|
| 168 |
+
if m_end:
|
| 169 |
+
core = text[: m_end.start()]
|
| 170 |
+
|
| 171 |
+
m = re.match(
|
| 172 |
+
r"^(?P<subj>[A-Za-z][\w'-]*)\s+"
|
| 173 |
+
r"(?P<verb>\w+)\s+"
|
| 174 |
+
r"(?:to\s+(?P<place>.+?)\s+)?"
|
| 175 |
+
r"(?P<time>yesterday|today|tomorrow|earlier|later|recently)?\s*"
|
| 176 |
+
r"(?P<manner>\w+ly)?$",
|
| 177 |
+
core,
|
| 178 |
+
flags=re.I,
|
| 179 |
+
)
|
| 180 |
+
if m:
|
| 181 |
+
slots.subject = m.group("subj")
|
| 182 |
+
slots.verb = m.group("verb")
|
| 183 |
+
slots.verb_phrase = m.group("verb")
|
| 184 |
+
if m.group("place"):
|
| 185 |
+
slots.place = "to " + m.group("place").strip()
|
| 186 |
+
slots.time = (m.group("time") or "").strip()
|
| 187 |
+
slots.manner = (m.group("manner") or "").strip()
|
| 188 |
+
slots.confidence = 0.7
|
| 189 |
+
return slots
|
| 190 |
+
|
| 191 |
+
m2 = re.match(r"^(?P<subj>[A-Za-z][\w'-]*)\s+(?P<rest>.+)$", core, flags=re.I)
|
| 192 |
+
if not m2:
|
| 193 |
+
slots.reasons.append("regex_no_match")
|
| 194 |
+
return slots
|
| 195 |
+
slots.subject = m2.group("subj")
|
| 196 |
+
rest = m2.group("rest")
|
| 197 |
+
mm = re.search(r"\b(\w+ly)$", rest, flags=re.I)
|
| 198 |
+
if mm:
|
| 199 |
+
slots.manner = mm.group(1)
|
| 200 |
+
rest = rest[: mm.start()].strip()
|
| 201 |
+
mt = re.search(
|
| 202 |
+
r"\b(yesterday|today|tomorrow|earlier|later|recently)$",
|
| 203 |
+
rest,
|
| 204 |
+
flags=re.I,
|
| 205 |
+
)
|
| 206 |
+
if mt:
|
| 207 |
+
slots.time = mt.group(1)
|
| 208 |
+
rest = rest[: mt.start()].strip()
|
| 209 |
+
mv = re.match(r"^(\w+)(?:\s+to\s+(.+))?$", rest, flags=re.I)
|
| 210 |
+
if mv:
|
| 211 |
+
slots.verb_phrase = mv.group(1)
|
| 212 |
+
slots.verb = mv.group(1)
|
| 213 |
+
slots.place = f"to {mv.group(2)}" if mv.group(2) else ""
|
| 214 |
+
slots.confidence = 0.55 if slots.subject and slots.verb_phrase else 0.2
|
| 215 |
+
if slots.confidence < 0.45:
|
| 216 |
+
slots.reasons.append("low_confidence")
|
| 217 |
+
return slots
|
app/engine/parse/__pycache__/__init__.cpython-311.pyc
ADDED
|
Binary file (13.8 kB). View file
|
|
|
app/engine/plan/__init__.py
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Rewrite planning — decide safety and ranked template candidates."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
from app.engine.classify import classify_sentence, is_rewriteable_type
|
| 6 |
+
from app.engine.models import RewritePlan, SentenceSlots, TemplateCandidate
|
| 7 |
+
from app.engine.parse import extract_slots
|
| 8 |
+
from app.engine.templates import rank_templates
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
def build_plan(text: str, *, min_confidence: float = 0.55) -> RewritePlan:
|
| 12 |
+
"""Create a rewrite plan without generating text yet."""
|
| 13 |
+
raw = (text or "").strip()
|
| 14 |
+
if not raw:
|
| 15 |
+
return RewritePlan(safe=False, skip_reason="empty")
|
| 16 |
+
|
| 17 |
+
kind = classify_sentence(raw)
|
| 18 |
+
if kind == "because_clause":
|
| 19 |
+
return RewritePlan(
|
| 20 |
+
safe=True,
|
| 21 |
+
template_id="because_front",
|
| 22 |
+
candidates=[TemplateCandidate("because_front", 0.85)],
|
| 23 |
+
confidence=0.85,
|
| 24 |
+
slots=SentenceSlots(text=raw, sentence_type=kind, confidence=0.85),
|
| 25 |
+
movable=["because_clause"],
|
| 26 |
+
fixed_spans=[],
|
| 27 |
+
)
|
| 28 |
+
|
| 29 |
+
if not is_rewriteable_type(kind):
|
| 30 |
+
return RewritePlan(
|
| 31 |
+
safe=False,
|
| 32 |
+
skip_reason=kind,
|
| 33 |
+
confidence=0.0,
|
| 34 |
+
slots=SentenceSlots(text=raw, sentence_type=kind),
|
| 35 |
+
)
|
| 36 |
+
|
| 37 |
+
# Discourse front as dedicated candidate when applicable
|
| 38 |
+
low = raw.lower()
|
| 39 |
+
if "nowadays" in low and not low.startswith("nowadays"):
|
| 40 |
+
return RewritePlan(
|
| 41 |
+
safe=True,
|
| 42 |
+
template_id="discourse_front",
|
| 43 |
+
candidates=[TemplateCandidate("discourse_front", 0.7)],
|
| 44 |
+
confidence=0.7,
|
| 45 |
+
slots=SentenceSlots(text=raw, sentence_type=kind, confidence=0.7),
|
| 46 |
+
movable=["discourse"],
|
| 47 |
+
)
|
| 48 |
+
|
| 49 |
+
slots = extract_slots(raw)
|
| 50 |
+
if "low_confidence" in slots.reasons or slots.confidence < min_confidence:
|
| 51 |
+
return RewritePlan(
|
| 52 |
+
safe=False,
|
| 53 |
+
skip_reason="low_confidence",
|
| 54 |
+
confidence=slots.confidence,
|
| 55 |
+
slots=slots,
|
| 56 |
+
)
|
| 57 |
+
if any(r.startswith("skip:") for r in slots.reasons):
|
| 58 |
+
return RewritePlan(
|
| 59 |
+
safe=False,
|
| 60 |
+
skip_reason=slots.reasons[0],
|
| 61 |
+
confidence=slots.confidence,
|
| 62 |
+
slots=slots,
|
| 63 |
+
)
|
| 64 |
+
|
| 65 |
+
candidates = rank_templates(slots)
|
| 66 |
+
if not candidates:
|
| 67 |
+
return RewritePlan(
|
| 68 |
+
safe=False,
|
| 69 |
+
skip_reason="no_template",
|
| 70 |
+
confidence=slots.confidence,
|
| 71 |
+
slots=slots,
|
| 72 |
+
)
|
| 73 |
+
|
| 74 |
+
# Filter by min confidence
|
| 75 |
+
candidates = [c for c in candidates if c.confidence >= min_confidence]
|
| 76 |
+
if not candidates:
|
| 77 |
+
return RewritePlan(
|
| 78 |
+
safe=False,
|
| 79 |
+
skip_reason="low_template_confidence",
|
| 80 |
+
confidence=slots.confidence,
|
| 81 |
+
slots=slots,
|
| 82 |
+
)
|
| 83 |
+
|
| 84 |
+
fixed = list(slots.entities)
|
| 85 |
+
if slots.negation:
|
| 86 |
+
fixed.append(slots.negation)
|
| 87 |
+
movable = [x for x in ("time", "manner", "place") if getattr(slots, x, "")]
|
| 88 |
+
|
| 89 |
+
return RewritePlan(
|
| 90 |
+
safe=True,
|
| 91 |
+
slots=slots,
|
| 92 |
+
template_id=candidates[0].template_id,
|
| 93 |
+
candidates=candidates,
|
| 94 |
+
fixed_spans=fixed,
|
| 95 |
+
movable=movable,
|
| 96 |
+
confidence=candidates[0].confidence,
|
| 97 |
+
)
|
app/engine/plan/__pycache__/__init__.cpython-311.pyc
ADDED
|
Binary file (4.15 kB). View file
|
|
|
app/engine/rewrite/__init__.py
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Sentence rewrite generation from plans/templates."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
import re
|
| 6 |
+
|
| 7 |
+
from app.engine.models import RewritePlan
|
| 8 |
+
from app.engine.templates import fill_template, try_because_front, try_discourse_front
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
def _content_tokens(text: str) -> list[str]:
|
| 12 |
+
return [
|
| 13 |
+
w
|
| 14 |
+
for w in re.findall(r"[a-zA-Z']+", (text or "").lower())
|
| 15 |
+
if len(w) >= 3
|
| 16 |
+
]
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
def reorder_quality_ok(source: str, candidate: str) -> bool:
|
| 20 |
+
"""Reject junk reorders that drop clauses or shuffle degree adverbs."""
|
| 21 |
+
src = (source or "").strip()
|
| 22 |
+
cand = (candidate or "").strip()
|
| 23 |
+
if not src or not cand:
|
| 24 |
+
return False
|
| 25 |
+
if cand.lower().rstrip(".!?") == src.lower().rstrip(".!?"):
|
| 26 |
+
return False
|
| 27 |
+
if re.search(
|
| 28 |
+
r"\b(more|most|less|least|very|really|quite)\s+"
|
| 29 |
+
r"(makes?|make|is|are|was|were|has|have|had|does|do|did)\b",
|
| 30 |
+
cand,
|
| 31 |
+
flags=re.I,
|
| 32 |
+
):
|
| 33 |
+
return False
|
| 34 |
+
src_toks = _content_tokens(src)
|
| 35 |
+
cand_toks = _content_tokens(cand)
|
| 36 |
+
if not src_toks:
|
| 37 |
+
return False
|
| 38 |
+
keep = len(set(src_toks) & set(cand_toks)) / max(1, len(set(src_toks)))
|
| 39 |
+
if keep < 0.85:
|
| 40 |
+
return False
|
| 41 |
+
if len(cand_toks) < int(len(src_toks) * 0.75) or len(cand_toks) > int(
|
| 42 |
+
len(src_toks) * 1.35
|
| 43 |
+
):
|
| 44 |
+
return False
|
| 45 |
+
return True
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
def generate_from_plan(
|
| 49 |
+
plan: RewritePlan,
|
| 50 |
+
*,
|
| 51 |
+
template_id: str | None = None,
|
| 52 |
+
) -> str | None:
|
| 53 |
+
"""Generate a rewritten sentence from a plan (slot reorder only)."""
|
| 54 |
+
if not plan.safe:
|
| 55 |
+
return None
|
| 56 |
+
tid = template_id or plan.template_id
|
| 57 |
+
raw = plan.slots.text if plan.slots else ""
|
| 58 |
+
|
| 59 |
+
if tid == "because_front":
|
| 60 |
+
return try_because_front(raw)
|
| 61 |
+
if tid == "discourse_front":
|
| 62 |
+
return try_discourse_front(raw)
|
| 63 |
+
|
| 64 |
+
if plan.slots is None:
|
| 65 |
+
return None
|
| 66 |
+
filled = fill_template(tid, plan.slots)
|
| 67 |
+
if not filled:
|
| 68 |
+
return None
|
| 69 |
+
if filled.strip().lower().rstrip(".!?") == raw.lower().rstrip(".!?"):
|
| 70 |
+
return None
|
| 71 |
+
if not reorder_quality_ok(raw, filled):
|
| 72 |
+
return None
|
| 73 |
+
return filled.strip()
|
| 74 |
+
|
| 75 |
+
|
| 76 |
+
def generate_candidates(plan: RewritePlan) -> list[tuple[str, str, float]]:
|
| 77 |
+
"""Try each ranked template; return (template_id, text, confidence)."""
|
| 78 |
+
results: list[tuple[str, str, float]] = []
|
| 79 |
+
if not plan.safe:
|
| 80 |
+
return results
|
| 81 |
+
for cand in plan.candidates:
|
| 82 |
+
text = generate_from_plan(plan, template_id=cand.template_id)
|
| 83 |
+
if text:
|
| 84 |
+
results.append((cand.template_id, text, cand.confidence))
|
| 85 |
+
return results
|
app/engine/rewrite/__pycache__/__init__.cpython-311.pyc
ADDED
|
Binary file (4.57 kB). View file
|
|
|
app/engine/safety/__init__.py
ADDED
|
@@ -0,0 +1,145 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Semantic safety checks and fallback decisions."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
import re
|
| 6 |
+
from dataclasses import dataclass
|
| 7 |
+
|
| 8 |
+
from app.pipeline.candidate_validator import validate_candidate
|
| 9 |
+
from app.pipeline.meaning_safety import polarity_safe
|
| 10 |
+
|
| 11 |
+
_URL = re.compile(r"https?://[^\s<>\"']+|www\.[^\s<>\"']+", re.I)
|
| 12 |
+
_EMAIL = re.compile(r"\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}\b")
|
| 13 |
+
_NUMBER = re.compile(r"\b\d[\d,]*(?:\.\d+)?%?\b")
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
@dataclass
|
| 17 |
+
class SafetyResult:
|
| 18 |
+
ok: bool
|
| 19 |
+
confidence: float
|
| 20 |
+
reasons: list[str]
|
| 21 |
+
surface_sim: float = 0.0
|
| 22 |
+
meaning: float = 0.0
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
def _entity_tokens(text: str) -> set[str]:
|
| 26 |
+
"""Heuristic proper-noun / protected token set."""
|
| 27 |
+
toks = set()
|
| 28 |
+
for m in re.finditer(r"\b[A-Z][a-zA-Z0-9'-]+\b", text or ""):
|
| 29 |
+
# Skip sentence-initial common words later; keep all for subset check
|
| 30 |
+
toks.add(m.group(0))
|
| 31 |
+
for m in _URL.finditer(text or ""):
|
| 32 |
+
toks.add(m.group(0))
|
| 33 |
+
for m in _EMAIL.finditer(text or ""):
|
| 34 |
+
toks.add(m.group(0))
|
| 35 |
+
return toks
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
def _numbers(text: str) -> set[str]:
|
| 39 |
+
return {m.group(0).replace(",", "") for m in _NUMBER.finditer(text or "")}
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
def _tense_aux_ok(original: str, candidate: str) -> bool:
|
| 43 |
+
"""Reject if auxiliary/tense markers disappear."""
|
| 44 |
+
aux = {"did", "does", "do", "was", "were", "is", "are", "am", "has", "have", "had", "will", "would", "could", "should"}
|
| 45 |
+
o = set(re.findall(r"[a-zA-Z']+", (original or "").lower()))
|
| 46 |
+
c = set(re.findall(r"[a-zA-Z']+", (candidate or "").lower()))
|
| 47 |
+
o_aux = o & aux
|
| 48 |
+
if not o_aux:
|
| 49 |
+
return True
|
| 50 |
+
return o_aux.issubset(c)
|
| 51 |
+
|
| 52 |
+
|
| 53 |
+
def check_safety(
|
| 54 |
+
original: str,
|
| 55 |
+
candidate: str,
|
| 56 |
+
*,
|
| 57 |
+
min_meaning: float = 0.80,
|
| 58 |
+
min_confidence: float = 0.55,
|
| 59 |
+
use_minilm: bool = False,
|
| 60 |
+
) -> SafetyResult:
|
| 61 |
+
"""Lightweight similarity/safety gate between original and rewrite."""
|
| 62 |
+
reasons: list[str] = []
|
| 63 |
+
o = (original or "").strip()
|
| 64 |
+
c = (candidate or "").strip()
|
| 65 |
+
if not o or not c:
|
| 66 |
+
return SafetyResult(False, 0.0, ["empty"])
|
| 67 |
+
|
| 68 |
+
if not polarity_safe(o, c):
|
| 69 |
+
reasons.append("negation")
|
| 70 |
+
|
| 71 |
+
o_ents = _entity_tokens(o)
|
| 72 |
+
# Drop first token of original if capitalized (sentence start)
|
| 73 |
+
first = (o.split() or [""])[0].strip(".,;:!?\"'")
|
| 74 |
+
o_ents.discard(first)
|
| 75 |
+
for ent in o_ents:
|
| 76 |
+
if ent not in c and ent.lower() not in c.lower():
|
| 77 |
+
reasons.append(f"entity:{ent}")
|
| 78 |
+
break
|
| 79 |
+
|
| 80 |
+
o_nums, c_nums = _numbers(o), _numbers(c)
|
| 81 |
+
if o_nums and not o_nums.issubset(c_nums):
|
| 82 |
+
reasons.append("numbers")
|
| 83 |
+
|
| 84 |
+
if not _tense_aux_ok(o, c):
|
| 85 |
+
reasons.append("tense")
|
| 86 |
+
|
| 87 |
+
# Structural reorder may be near-copy in surface ratio; relax max_surface
|
| 88 |
+
vr = validate_candidate(
|
| 89 |
+
o,
|
| 90 |
+
c,
|
| 91 |
+
min_meaning=min_meaning if use_minilm else 0.0,
|
| 92 |
+
max_surface=0.995,
|
| 93 |
+
min_surface=0.20,
|
| 94 |
+
)
|
| 95 |
+
# Filter validator reasons that fight structural reorder
|
| 96 |
+
ignore = {"too_similar", "identical"}
|
| 97 |
+
for r in vr.reasons:
|
| 98 |
+
if r in ignore:
|
| 99 |
+
continue
|
| 100 |
+
if r.startswith("meaning:") and not use_minilm:
|
| 101 |
+
continue
|
| 102 |
+
if r not in reasons:
|
| 103 |
+
reasons.append(r)
|
| 104 |
+
|
| 105 |
+
# Optional MiniLM meaning score when enabled
|
| 106 |
+
meaning = vr.meaning
|
| 107 |
+
if use_minilm:
|
| 108 |
+
try:
|
| 109 |
+
from app.pipeline.minilm import score_candidate
|
| 110 |
+
|
| 111 |
+
scored = score_candidate(o, c)
|
| 112 |
+
if scored is not None:
|
| 113 |
+
meaning = float(scored)
|
| 114 |
+
if meaning < min_meaning:
|
| 115 |
+
reasons.append(f"meaning:{meaning:.2f}")
|
| 116 |
+
except Exception:
|
| 117 |
+
pass
|
| 118 |
+
|
| 119 |
+
confidence = max(0.0, min(1.0, (meaning + (1.0 - abs(vr.surface_sim - 0.7))) / 2))
|
| 120 |
+
if reasons:
|
| 121 |
+
confidence = min(confidence, 0.4)
|
| 122 |
+
|
| 123 |
+
ok = not reasons and confidence >= min_confidence * 0.5
|
| 124 |
+
# If only soft issues, still allow when polarity+entities ok
|
| 125 |
+
hard = {
|
| 126 |
+
r
|
| 127 |
+
for r in reasons
|
| 128 |
+
if r in {"negation", "numbers", "tense", "polarity", "entity_inject", "invention", "broken"}
|
| 129 |
+
or r.startswith("entity:")
|
| 130 |
+
or r.startswith("meaning:")
|
| 131 |
+
}
|
| 132 |
+
if hard:
|
| 133 |
+
ok = False
|
| 134 |
+
elif reasons and vr.surface_sim >= 0.35:
|
| 135 |
+
# Soft validator noise on reorders — accept if content preserved
|
| 136 |
+
ok = True
|
| 137 |
+
confidence = max(confidence, 0.6)
|
| 138 |
+
|
| 139 |
+
return SafetyResult(
|
| 140 |
+
ok=ok,
|
| 141 |
+
confidence=confidence,
|
| 142 |
+
reasons=reasons,
|
| 143 |
+
surface_sim=vr.surface_sim,
|
| 144 |
+
meaning=float(meaning or 0.0),
|
| 145 |
+
)
|
app/engine/safety/__pycache__/__init__.cpython-311.pyc
ADDED
|
Binary file (7.36 kB). View file
|
|
|
app/engine/segment/__init__.py
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Paragraph / sentence segmentation with batch streaming."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
from collections.abc import Iterator
|
| 6 |
+
|
| 7 |
+
from app.engine.models import DocumentBlock
|
| 8 |
+
from app.pipeline.nlp import get_nlp
|
| 9 |
+
from app.pipeline.normalize import split_sentences_regex
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
def split_sentences(text: str) -> list[str]:
|
| 13 |
+
"""Split a paragraph into sentences (spaCy preferred)."""
|
| 14 |
+
raw = (text or "").strip()
|
| 15 |
+
if not raw:
|
| 16 |
+
return []
|
| 17 |
+
nlp = get_nlp()
|
| 18 |
+
if nlp is not None:
|
| 19 |
+
try:
|
| 20 |
+
doc = nlp(raw)
|
| 21 |
+
sents = [s.text.strip() for s in doc.sents if s.text.strip()]
|
| 22 |
+
if sents:
|
| 23 |
+
return sents
|
| 24 |
+
except Exception:
|
| 25 |
+
pass
|
| 26 |
+
return split_sentences_regex(raw)
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
def iter_paragraph_batches(
|
| 30 |
+
blocks: list[DocumentBlock],
|
| 31 |
+
*,
|
| 32 |
+
batch_paras: int = 20,
|
| 33 |
+
) -> Iterator[list[DocumentBlock]]:
|
| 34 |
+
"""Yield batches of rewriteable paragraph blocks interleaved with passthrough.
|
| 35 |
+
|
| 36 |
+
Each yielded list preserves document order for that slice of blocks.
|
| 37 |
+
Non-rewriteable blocks are included so stitch can keep structure.
|
| 38 |
+
Batching is driven by count of rewriteable paragraphs.
|
| 39 |
+
"""
|
| 40 |
+
batch_paras = max(1, min(int(batch_paras), 100))
|
| 41 |
+
batch: list[DocumentBlock] = []
|
| 42 |
+
rewriteable_count = 0
|
| 43 |
+
|
| 44 |
+
for block in blocks:
|
| 45 |
+
batch.append(block)
|
| 46 |
+
if block.rewriteable and block.kind == "paragraph":
|
| 47 |
+
rewriteable_count += 1
|
| 48 |
+
if rewriteable_count >= batch_paras:
|
| 49 |
+
yield batch
|
| 50 |
+
batch = []
|
| 51 |
+
rewriteable_count = 0
|
| 52 |
+
|
| 53 |
+
if batch:
|
| 54 |
+
yield batch
|
| 55 |
+
|
| 56 |
+
|
| 57 |
+
def word_count(text: str) -> int:
|
| 58 |
+
return len((text or "").split()) if (text or "").strip() else 0
|
app/engine/segment/__pycache__/__init__.cpython-311.pyc
ADDED
|
Binary file (2.99 kB). View file
|
|
|
app/engine/stitch/__init__.py
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Rebuild document text from processed blocks."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
from app.engine.models import DocumentBlock
|
| 6 |
+
|
| 7 |
+
|
| 8 |
+
def stitch_blocks(blocks: list[DocumentBlock]) -> str:
|
| 9 |
+
"""Join blocks preserving blank lines and non-rewriteable sections."""
|
| 10 |
+
if not blocks:
|
| 11 |
+
return ""
|
| 12 |
+
parts: list[str] = []
|
| 13 |
+
for i, block in enumerate(blocks):
|
| 14 |
+
if block.kind == "blank":
|
| 15 |
+
# Represented as paragraph separator; avoid stacking too many
|
| 16 |
+
if parts and not parts[-1].endswith("\n\n"):
|
| 17 |
+
if parts[-1] and not parts[-1].endswith("\n"):
|
| 18 |
+
parts.append("\n\n")
|
| 19 |
+
elif parts[-1].endswith("\n") and not parts[-1].endswith("\n\n"):
|
| 20 |
+
parts.append("\n")
|
| 21 |
+
continue
|
| 22 |
+
text = block.text or ""
|
| 23 |
+
if i > 0 and parts and not parts[-1].endswith("\n"):
|
| 24 |
+
# Separate consecutive non-blank blocks with blank line if both are paras/headings
|
| 25 |
+
prev = blocks[i - 1]
|
| 26 |
+
if prev.kind != "blank":
|
| 27 |
+
parts.append("\n\n")
|
| 28 |
+
parts.append(text)
|
| 29 |
+
out = "".join(parts)
|
| 30 |
+
out = out.replace("\n\n\n\n", "\n\n").strip()
|
| 31 |
+
return out
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
def join_sentences(sentences: list[str]) -> str:
|
| 35 |
+
return " ".join(s.strip() for s in sentences if s and s.strip())
|
app/engine/stitch/__pycache__/__init__.cpython-311.pyc
ADDED
|
Binary file (2.44 kB). View file
|
|
|
app/engine/templates/__init__.py
ADDED
|
@@ -0,0 +1,182 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Deterministic template families for structural reorder."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
import re
|
| 6 |
+
|
| 7 |
+
from app.engine.models import SentenceSlots, TemplateCandidate
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
def rank_templates(slots: SentenceSlots) -> list[TemplateCandidate]:
|
| 11 |
+
"""Return confidence-ranked template candidates (never random)."""
|
| 12 |
+
if not slots.subject or not slots.verb_phrase:
|
| 13 |
+
return []
|
| 14 |
+
if slots.confidence < 0.45 or "low_confidence" in slots.reasons:
|
| 15 |
+
return []
|
| 16 |
+
|
| 17 |
+
out: list[TemplateCandidate] = []
|
| 18 |
+
|
| 19 |
+
if slots.time and slots.manner and (slots.place or slots.object):
|
| 20 |
+
out.append(TemplateCandidate("time_subj_manner_verb_place", 0.9))
|
| 21 |
+
if slots.time and slots.place:
|
| 22 |
+
out.append(TemplateCandidate("time_subj_verb_place", 0.8))
|
| 23 |
+
if slots.time and slots.object:
|
| 24 |
+
out.append(TemplateCandidate("time_subj_verb_object", 0.75))
|
| 25 |
+
if slots.time:
|
| 26 |
+
out.append(TemplateCandidate("time_subj_verb_object_place", 0.72))
|
| 27 |
+
out.append(TemplateCandidate("subj_verb_object_time", 0.68))
|
| 28 |
+
out.append(TemplateCandidate("time_front", 0.65))
|
| 29 |
+
if slots.manner and slots.place:
|
| 30 |
+
out.append(TemplateCandidate("subj_manner_verb_place", 0.7))
|
| 31 |
+
if slots.manner:
|
| 32 |
+
out.append(TemplateCandidate("adv_subj_verb_object", 0.62))
|
| 33 |
+
out.append(TemplateCandidate("subj_manner_verb_rest", 0.6))
|
| 34 |
+
if slots.place:
|
| 35 |
+
out.append(TemplateCandidate("subj_verb_place", 0.55))
|
| 36 |
+
if slots.object:
|
| 37 |
+
out.append(TemplateCandidate("subj_verb_object", 0.5))
|
| 38 |
+
|
| 39 |
+
# Dedupe by template_id keeping highest confidence order
|
| 40 |
+
seen: set[str] = set()
|
| 41 |
+
ranked: list[TemplateCandidate] = []
|
| 42 |
+
for c in sorted(out, key=lambda x: -x.confidence):
|
| 43 |
+
if c.template_id in seen:
|
| 44 |
+
continue
|
| 45 |
+
seen.add(c.template_id)
|
| 46 |
+
ranked.append(c)
|
| 47 |
+
return ranked
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
def _cap(text: str) -> str:
|
| 51 |
+
t = (text or "").strip()
|
| 52 |
+
if not t:
|
| 53 |
+
return t
|
| 54 |
+
return t[0].upper() + t[1:]
|
| 55 |
+
|
| 56 |
+
|
| 57 |
+
def _join_slots(*parts: str) -> str:
|
| 58 |
+
bits = [p.strip() for p in parts if p and p.strip()]
|
| 59 |
+
s = " ".join(bits)
|
| 60 |
+
s = re.sub(r"\s+", " ", s).strip()
|
| 61 |
+
s = re.sub(r"\s+([,.;:!?])", r"\1", s)
|
| 62 |
+
return s
|
| 63 |
+
|
| 64 |
+
|
| 65 |
+
def _terminal(text: str) -> str:
|
| 66 |
+
if text.rstrip().endswith(("!", "?")):
|
| 67 |
+
return text.rstrip()[-1]
|
| 68 |
+
return "."
|
| 69 |
+
|
| 70 |
+
|
| 71 |
+
def fill_template(template_id: str, slots: SentenceSlots) -> str | None:
|
| 72 |
+
"""Build sentence from slots using the selected template (reorder only)."""
|
| 73 |
+
s = slots
|
| 74 |
+
end = _terminal(s.text)
|
| 75 |
+
tid = template_id
|
| 76 |
+
|
| 77 |
+
if tid == "time_subj_manner_verb_place":
|
| 78 |
+
place_or_obj = s.place or s.object
|
| 79 |
+
body = _join_slots(_cap(s.time) + ",", s.subject, s.manner, s.verb_phrase, place_or_obj)
|
| 80 |
+
return body + end
|
| 81 |
+
|
| 82 |
+
if tid == "time_subj_verb_place":
|
| 83 |
+
body = _join_slots(_cap(s.time) + ",", s.subject, s.verb_phrase, s.place)
|
| 84 |
+
return body + end
|
| 85 |
+
|
| 86 |
+
if tid == "time_subj_verb_object":
|
| 87 |
+
body = _join_slots(_cap(s.time) + ",", s.subject, s.verb_phrase, s.object)
|
| 88 |
+
return body + end
|
| 89 |
+
|
| 90 |
+
if tid == "time_subj_verb_object_place":
|
| 91 |
+
body = _join_slots(
|
| 92 |
+
_cap(s.time) + ",", s.subject, s.verb_phrase, s.object, s.place, s.manner
|
| 93 |
+
)
|
| 94 |
+
return body + end
|
| 95 |
+
|
| 96 |
+
if tid == "subj_verb_object_time":
|
| 97 |
+
body = _join_slots(_cap(s.subject), s.verb_phrase, s.object, s.place, s.manner, s.time)
|
| 98 |
+
return body + end
|
| 99 |
+
|
| 100 |
+
if tid == "adv_subj_verb_object":
|
| 101 |
+
body = _join_slots(_cap(s.manner) + ",", s.subject, s.verb_phrase, s.object, s.place, s.time)
|
| 102 |
+
return body + end
|
| 103 |
+
|
| 104 |
+
if tid == "subj_manner_verb_place":
|
| 105 |
+
body = _join_slots(_cap(s.subject), s.manner, s.verb_phrase, s.place)
|
| 106 |
+
return body + end
|
| 107 |
+
|
| 108 |
+
if tid == "time_front":
|
| 109 |
+
rest = s.text
|
| 110 |
+
if s.time:
|
| 111 |
+
rest = re.sub(rf"\b{re.escape(s.time)}\b", "", rest, count=1, flags=re.I)
|
| 112 |
+
rest = re.sub(r"\s+", " ", rest).strip(" ,.")
|
| 113 |
+
if not rest:
|
| 114 |
+
return None
|
| 115 |
+
body = _join_slots(_cap(s.time) + ",", rest[0].lower() + rest[1:])
|
| 116 |
+
return body.rstrip(".!?") + end
|
| 117 |
+
|
| 118 |
+
if tid == "subj_manner_verb_rest":
|
| 119 |
+
rest_bits = [s.verb_phrase, s.object, s.place, s.time]
|
| 120 |
+
body = _join_slots(_cap(s.subject), s.manner, *rest_bits)
|
| 121 |
+
return body + end
|
| 122 |
+
|
| 123 |
+
if tid == "subj_verb_place":
|
| 124 |
+
body = _join_slots(_cap(s.subject), s.verb_phrase, s.place, s.time, s.manner)
|
| 125 |
+
return body + end
|
| 126 |
+
|
| 127 |
+
if tid == "subj_verb_object":
|
| 128 |
+
body = _join_slots(_cap(s.subject), s.verb_phrase, s.object, s.place, s.time, s.manner)
|
| 129 |
+
return body + end
|
| 130 |
+
|
| 131 |
+
return None
|
| 132 |
+
|
| 133 |
+
|
| 134 |
+
def try_because_front(text: str) -> str | None:
|
| 135 |
+
"""Main … because Sub → Because Sub, main …"""
|
| 136 |
+
raw = (text or "").strip()
|
| 137 |
+
if not raw:
|
| 138 |
+
return None
|
| 139 |
+
end = "."
|
| 140 |
+
m_end = re.search(r"[.!?]+$", raw)
|
| 141 |
+
if m_end:
|
| 142 |
+
end = m_end.group(0)
|
| 143 |
+
raw = raw[: m_end.start()].strip()
|
| 144 |
+
m = re.match(r"^(?P<main>.+?)\s+because\s+(?P<sub>.+)$", raw, flags=re.I)
|
| 145 |
+
if not m:
|
| 146 |
+
return None
|
| 147 |
+
main = m.group("main").strip(" ,")
|
| 148 |
+
sub = m.group("sub").strip(" ,")
|
| 149 |
+
if len(main.split()) < 3 or len(sub.split()) < 2:
|
| 150 |
+
return None
|
| 151 |
+
if re.search(r"\bbecause\b", main, flags=re.I) or re.search(
|
| 152 |
+
r"\bbecause\b", sub, flags=re.I
|
| 153 |
+
):
|
| 154 |
+
return None
|
| 155 |
+
body = (
|
| 156 |
+
f"Because {sub[0].lower() + sub[1:]}, "
|
| 157 |
+
f"{main[0].lower() + main[1:]}"
|
| 158 |
+
)
|
| 159 |
+
return re.sub(r"\s+", " ", body).strip() + end
|
| 160 |
+
|
| 161 |
+
|
| 162 |
+
def try_discourse_front(text: str) -> str | None:
|
| 163 |
+
"""Move mid-sentence 'nowadays' to the front."""
|
| 164 |
+
raw = (text or "").strip()
|
| 165 |
+
if not raw or re.match(r"^nowadays\b", raw, flags=re.I):
|
| 166 |
+
return None
|
| 167 |
+
if re.search(r"\b(because|although|while|unless)\b", raw, flags=re.I):
|
| 168 |
+
return None
|
| 169 |
+
end = "."
|
| 170 |
+
m_end = re.search(r"[.!?]+$", raw)
|
| 171 |
+
core = raw
|
| 172 |
+
if m_end:
|
| 173 |
+
end = m_end.group(0)
|
| 174 |
+
core = raw[: m_end.start()].strip()
|
| 175 |
+
m = re.search(r"\bnowadays\b", core, flags=re.I)
|
| 176 |
+
if not m:
|
| 177 |
+
return None
|
| 178 |
+
rest = (core[: m.start()] + core[m.end() :]).strip(" ,")
|
| 179 |
+
if len(rest.split()) < 4:
|
| 180 |
+
return None
|
| 181 |
+
out = f"Nowadays, {rest[0].lower() + rest[1:]}{end}"
|
| 182 |
+
return re.sub(r"\s+", " ", out).strip()
|
app/engine/templates/__pycache__/__init__.cpython-311.pyc
ADDED
|
Binary file (11.5 kB). View file
|
|
|
app/main.py
CHANGED
|
@@ -29,8 +29,6 @@ from app.bootstrap import ensure_resources
|
|
| 29 |
from app.config import (
|
| 30 |
APP_TITLE,
|
| 31 |
AUTH_ENABLED,
|
| 32 |
-
GENERATIVE_BACKEND,
|
| 33 |
-
GENERATIVE_MODEL,
|
| 34 |
GRAMMAR_MAX_CHARS,
|
| 35 |
LANGUAGE_TOOL_LANGUAGE,
|
| 36 |
LANGUAGE_TOOL_URL,
|
|
@@ -38,7 +36,6 @@ from app.config import (
|
|
| 38 |
ML_POLISH_AVAILABLE_DEFAULT,
|
| 39 |
PIPELINE_MODE,
|
| 40 |
)
|
| 41 |
-
from app.pipeline.generative import backend_kind, generative_package_present
|
| 42 |
from app.pipeline.grammar import (
|
| 43 |
check_grammar,
|
| 44 |
languagetool_reachable,
|
|
@@ -98,18 +95,14 @@ def _client_ip(request: Request) -> str:
|
|
| 98 |
|
| 99 |
@app.get("/health")
|
| 100 |
def health():
|
| 101 |
-
engines = ["
|
| 102 |
engines.insert(0, "spacy" if spacy_available() else "regex-fallback")
|
| 103 |
lt_ok = languagetool_reachable()
|
| 104 |
if lt_ok:
|
| 105 |
engines.append("languagetool")
|
| 106 |
ml_pkg = minilm_package_present()
|
| 107 |
-
gen_pkg = generative_package_present()
|
| 108 |
if ml_pkg:
|
| 109 |
-
engines.append("minilm")
|
| 110 |
-
if gen_pkg:
|
| 111 |
-
engines.append("generative")
|
| 112 |
-
engines.append(GENERATIVE_BACKEND)
|
| 113 |
return {
|
| 114 |
"status": "ok",
|
| 115 |
"app": APP_TITLE,
|
|
@@ -122,24 +115,41 @@ def health():
|
|
| 122 |
"reachable": lt_ok,
|
| 123 |
"default_language": LANGUAGE_TOOL_LANGUAGE or "en-US",
|
| 124 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 125 |
"ml_polish": {
|
| 126 |
"enabled": bool(ML_POLISH_AVAILABLE_DEFAULT),
|
| 127 |
-
"available": bool(ml_pkg
|
| 128 |
"minilm": ml_pkg,
|
| 129 |
-
"generative":
|
| 130 |
"pipeline_mode": PIPELINE_MODE,
|
| 131 |
"backend": {
|
| 132 |
"minilm": "fastembed|sentence-transformers" if ml_pkg else None,
|
| 133 |
-
"generative":
|
| 134 |
-
"generative_backend":
|
| 135 |
-
"kind":
|
| 136 |
},
|
| 137 |
"pipeline": [
|
|
|
|
| 138 |
"grammar",
|
| 139 |
-
"
|
| 140 |
-
"validate/minilm",
|
| 141 |
-
"rules-light",
|
| 142 |
-
"lexicon-fallback-if-no-lm",
|
| 143 |
],
|
| 144 |
"plans": "all",
|
| 145 |
},
|
|
@@ -203,6 +213,9 @@ def api_rewrite(
|
|
| 203 |
|
| 204 |
return {
|
| 205 |
"rewrite": result.text,
|
|
|
|
|
|
|
|
|
|
| 206 |
"meta": {
|
| 207 |
"engine": result.engine,
|
| 208 |
"input_words": result.input_words,
|
|
@@ -215,8 +228,8 @@ def api_rewrite(
|
|
| 215 |
"notes": result.notes,
|
| 216 |
"ml_polish": use_ml,
|
| 217 |
"pipeline_mode": result.pipeline_mode,
|
| 218 |
-
"generative_backend":
|
| 219 |
-
"generative_model":
|
| 220 |
"hybrid": (
|
| 221 |
{
|
| 222 |
"units": result.hybrid.units,
|
|
|
|
| 29 |
from app.config import (
|
| 30 |
APP_TITLE,
|
| 31 |
AUTH_ENABLED,
|
|
|
|
|
|
|
| 32 |
GRAMMAR_MAX_CHARS,
|
| 33 |
LANGUAGE_TOOL_LANGUAGE,
|
| 34 |
LANGUAGE_TOOL_URL,
|
|
|
|
| 36 |
ML_POLISH_AVAILABLE_DEFAULT,
|
| 37 |
PIPELINE_MODE,
|
| 38 |
)
|
|
|
|
| 39 |
from app.pipeline.grammar import (
|
| 40 |
check_grammar,
|
| 41 |
languagetool_reachable,
|
|
|
|
| 95 |
|
| 96 |
@app.get("/health")
|
| 97 |
def health():
|
| 98 |
+
engines = ["structural-reorder", "grammar"]
|
| 99 |
engines.insert(0, "spacy" if spacy_available() else "regex-fallback")
|
| 100 |
lt_ok = languagetool_reachable()
|
| 101 |
if lt_ok:
|
| 102 |
engines.append("languagetool")
|
| 103 |
ml_pkg = minilm_package_present()
|
|
|
|
| 104 |
if ml_pkg:
|
| 105 |
+
engines.append("minilm-safety")
|
|
|
|
|
|
|
|
|
|
| 106 |
return {
|
| 107 |
"status": "ok",
|
| 108 |
"app": APP_TITLE,
|
|
|
|
| 115 |
"reachable": lt_ok,
|
| 116 |
"default_language": LANGUAGE_TOOL_LANGUAGE or "en-US",
|
| 117 |
},
|
| 118 |
+
"rewrite_engine": {
|
| 119 |
+
"mode": "structural",
|
| 120 |
+
"pipeline_mode": PIPELINE_MODE,
|
| 121 |
+
"pipeline": [
|
| 122 |
+
"ingest",
|
| 123 |
+
"normalize",
|
| 124 |
+
"segment (batched)",
|
| 125 |
+
"classify",
|
| 126 |
+
"parse (spaCy)",
|
| 127 |
+
"plan",
|
| 128 |
+
"templates",
|
| 129 |
+
"rewrite (reorder)",
|
| 130 |
+
"grammar",
|
| 131 |
+
"safety",
|
| 132 |
+
"stitch",
|
| 133 |
+
"consistency",
|
| 134 |
+
],
|
| 135 |
+
"minilm_safety": ml_pkg,
|
| 136 |
+
},
|
| 137 |
"ml_polish": {
|
| 138 |
"enabled": bool(ML_POLISH_AVAILABLE_DEFAULT),
|
| 139 |
+
"available": bool(ml_pkg),
|
| 140 |
"minilm": ml_pkg,
|
| 141 |
+
"generative": False,
|
| 142 |
"pipeline_mode": PIPELINE_MODE,
|
| 143 |
"backend": {
|
| 144 |
"minilm": "fastembed|sentence-transformers" if ml_pkg else None,
|
| 145 |
+
"generative": None,
|
| 146 |
+
"generative_backend": None,
|
| 147 |
+
"kind": None,
|
| 148 |
},
|
| 149 |
"pipeline": [
|
| 150 |
+
"structural-reorder",
|
| 151 |
"grammar",
|
| 152 |
+
"safety/minilm-optional",
|
|
|
|
|
|
|
|
|
|
| 153 |
],
|
| 154 |
"plans": "all",
|
| 155 |
},
|
|
|
|
| 213 |
|
| 214 |
return {
|
| 215 |
"rewrite": result.text,
|
| 216 |
+
"sentences": result.sentences,
|
| 217 |
+
"mapping": result.mapping,
|
| 218 |
+
"stats": result.stats,
|
| 219 |
"meta": {
|
| 220 |
"engine": result.engine,
|
| 221 |
"input_words": result.input_words,
|
|
|
|
| 228 |
"notes": result.notes,
|
| 229 |
"ml_polish": use_ml,
|
| 230 |
"pipeline_mode": result.pipeline_mode,
|
| 231 |
+
"generative_backend": None,
|
| 232 |
+
"generative_model": None,
|
| 233 |
"hybrid": (
|
| 234 |
{
|
| 235 |
"units": result.hybrid.units,
|
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__/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/generative.py
CHANGED
|
@@ -1,548 +1,36 @@
|
|
| 1 |
-
"""
|
| 2 |
|
| 3 |
-
|
| 4 |
-
1) Draft several paraphrases per unit
|
| 5 |
-
2) Validate polarity / facts / meaning (reject near-copies)
|
| 6 |
-
3) Rank survivors for fidelity + useful difference
|
| 7 |
-
|
| 8 |
-
Lazy-loaded; missing torch/transformers → callers fall back to classical rules.
|
| 9 |
"""
|
| 10 |
|
| 11 |
from __future__ import annotations
|
| 12 |
|
| 13 |
-
import logging
|
| 14 |
-
import re
|
| 15 |
-
import threading
|
| 16 |
-
from difflib import SequenceMatcher
|
| 17 |
from typing import Any
|
| 18 |
|
| 19 |
-
from app.config import (
|
| 20 |
-
GENERATIVE_BACKEND,
|
| 21 |
-
GENERATIVE_MAX_PARAGRAPHS,
|
| 22 |
-
GENERATIVE_MODEL,
|
| 23 |
-
GENERATIVE_POLISH_ENABLED,
|
| 24 |
-
ML_POLISH_AVAILABLE_DEFAULT,
|
| 25 |
-
)
|
| 26 |
-
from app.pipeline.candidate_ranker import NEAR_COPY_SURFACE, pick_best_candidate
|
| 27 |
-
from app.pipeline.normalize import split_paragraphs
|
| 28 |
-
from app.pipeline.tones import normalize_tone
|
| 29 |
-
|
| 30 |
-
logger = logging.getLogger("plainrewrite.generative")
|
| 31 |
-
|
| 32 |
-
_lock = threading.Lock()
|
| 33 |
-
_tokenizer: Any = None
|
| 34 |
-
_model: Any = None
|
| 35 |
-
_failed = False
|
| 36 |
-
_backend: str | None = None
|
| 37 |
-
_backend_kind: str | None = None # seq2seq | causal
|
| 38 |
-
|
| 39 |
-
_SYSTEM_REWRITE = (
|
| 40 |
-
"You rewrite ONE English sentence with clearly DIFFERENT WORDS. "
|
| 41 |
-
"Output exactly one sentence — no second sentence, no preamble. "
|
| 42 |
-
"Keep EVERY claim and the same polarity. "
|
| 43 |
-
"Do not flip negatives or antonyms. "
|
| 44 |
-
"Preserve names, numbers, dates, quotations, and fixed phrases "
|
| 45 |
-
"(e.g. keep 'fast food' as 'fast food', not 'food' alone). "
|
| 46 |
-
"Do not add reasons, examples, education, awareness, routines, "
|
| 47 |
-
"workouts, diets, or new facts. "
|
| 48 |
-
"Do not summarize or drop content. "
|
| 49 |
-
"Do NOT rearrange the same words. Do NOT copy almost verbatim. "
|
| 50 |
-
"Replace verbs, adverbs, and phrases with natural synonyms. "
|
| 51 |
-
"Return only the rewritten sentence."
|
| 52 |
-
)
|
| 53 |
-
|
| 54 |
-
_SHARED_RULES = (
|
| 55 |
-
"Output exactly one sentence if the input is one sentence.\n"
|
| 56 |
-
"Keep ALL claims — do not drop, merge away, or invent ideas.\n"
|
| 57 |
-
"Do not add reasons, education, awareness, routines, workouts, diets, "
|
| 58 |
-
"or extra clauses.\n"
|
| 59 |
-
"Keep multiword terms intact (e.g. fast food, junk food, physical activity).\n"
|
| 60 |
-
"MUST use different wording — never rearrange the same words only.\n"
|
| 61 |
-
"Keep polarity. Preserve names, numbers, dates, and quotations. Correct grammar.\n"
|
| 62 |
-
"Return only the rewritten text."
|
| 63 |
-
)
|
| 64 |
-
|
| 65 |
-
_TONE_PROMPTS: dict[str, str] = {
|
| 66 |
-
"Casual": (
|
| 67 |
-
"Paraphrase this text in clear casual English.\n"
|
| 68 |
-
f"{_SHARED_RULES}\n\n"
|
| 69 |
-
"Text:\n{text}\n\n"
|
| 70 |
-
"Paraphrase:"
|
| 71 |
-
),
|
| 72 |
-
"Formal": (
|
| 73 |
-
"Paraphrase this text in clear formal professional English.\n"
|
| 74 |
-
f"{_SHARED_RULES}\n\n"
|
| 75 |
-
"Text:\n{text}\n\n"
|
| 76 |
-
"Paraphrase:"
|
| 77 |
-
),
|
| 78 |
-
"Academic": (
|
| 79 |
-
"Paraphrase this text in clear academic English.\n"
|
| 80 |
-
f"{_SHARED_RULES}\n\n"
|
| 81 |
-
"Text:\n{text}\n\n"
|
| 82 |
-
"Paraphrase:"
|
| 83 |
-
),
|
| 84 |
-
"Neutral": (
|
| 85 |
-
"Paraphrase this text in clear natural English using different words.\n"
|
| 86 |
-
f"{_SHARED_RULES}\n"
|
| 87 |
-
"Keep polarity (e.g. unhealthy stays unhealthy; "
|
| 88 |
-
"don't realize stays a lack of awareness — without adding 'education').\n"
|
| 89 |
-
"Do not return a grammar-only edit or a shuffle of the same words.\n\n"
|
| 90 |
-
"Text:\n{text}\n\n"
|
| 91 |
-
"Paraphrase:"
|
| 92 |
-
),
|
| 93 |
-
}
|
| 94 |
-
|
| 95 |
-
_RETRY_PROMPT = (
|
| 96 |
-
"Rewrite this as ONE sentence using DIFFERENT WORDS (not a word shuffle) "
|
| 97 |
-
"but the exact same meaning and polarity. Do not add or remove facts. "
|
| 98 |
-
"Keep fixed phrases like 'fast food'. "
|
| 99 |
-
"Do not invent workouts, diets, or routines. Do not add a second sentence. "
|
| 100 |
-
"Do not return a near-copy, grammar-only edit, or rearranged same words. "
|
| 101 |
-
"Tone: {tone}.\n\n"
|
| 102 |
-
"Text:\n{text}\n\n"
|
| 103 |
-
"Rewrite:"
|
| 104 |
-
)
|
| 105 |
-
|
| 106 |
-
_CAUSAL_USER = (
|
| 107 |
-
"Tone: {tone}.\n"
|
| 108 |
-
"Rewrite the following as exactly ONE sentence. "
|
| 109 |
-
"Keep every claim and polarity. Keep fixed phrases (e.g. fast food). "
|
| 110 |
-
"Do not drop, invent, or add education/routines/workouts.\n\n"
|
| 111 |
-
"{text}"
|
| 112 |
-
)
|
| 113 |
-
|
| 114 |
-
_CAUSAL_RETRY_USER = (
|
| 115 |
-
"Tone: {tone}.\n"
|
| 116 |
-
"Try again: exactly ONE sentence, different wording, same meaning and polarity. "
|
| 117 |
-
"No extra claims. No second sentence.\n\n"
|
| 118 |
-
"{text}"
|
| 119 |
-
)
|
| 120 |
-
|
| 121 |
|
| 122 |
def generative_package_present() -> bool:
|
| 123 |
-
|
| 124 |
-
return False
|
| 125 |
-
try:
|
| 126 |
-
import transformers # noqa: F401
|
| 127 |
-
import torch # noqa: F401
|
| 128 |
-
|
| 129 |
-
return True
|
| 130 |
-
except Exception:
|
| 131 |
-
return False
|
| 132 |
|
| 133 |
|
| 134 |
def generative_available() -> bool:
|
| 135 |
-
|
| 136 |
-
return False
|
| 137 |
-
if _failed:
|
| 138 |
-
return False
|
| 139 |
-
if _model is not None:
|
| 140 |
-
return True
|
| 141 |
-
try:
|
| 142 |
-
_ensure_model()
|
| 143 |
-
return _model is not None
|
| 144 |
-
except Exception:
|
| 145 |
-
return False
|
| 146 |
|
| 147 |
|
| 148 |
def backend_name() -> str | None:
|
| 149 |
-
|
| 150 |
-
return _backend
|
| 151 |
|
| 152 |
|
| 153 |
def backend_kind() -> str:
|
| 154 |
-
|
| 155 |
-
return GENERATIVE_BACKEND if GENERATIVE_BACKEND in {"seq2seq", "causal"} else "seq2seq"
|
| 156 |
-
|
| 157 |
-
|
| 158 |
-
def _ensure_model():
|
| 159 |
-
global _tokenizer, _model, _failed, _backend, _backend_kind
|
| 160 |
-
if _model is not None or _failed:
|
| 161 |
-
return _model
|
| 162 |
-
with _lock:
|
| 163 |
-
if _model is not None or _failed:
|
| 164 |
-
return _model
|
| 165 |
-
try:
|
| 166 |
-
import torch
|
| 167 |
-
from transformers import AutoTokenizer
|
| 168 |
-
|
| 169 |
-
name = GENERATIVE_MODEL
|
| 170 |
-
kind = backend_kind()
|
| 171 |
-
logger.info(
|
| 172 |
-
"Loading generative rewrite model %s (backend=%s) …", name, kind
|
| 173 |
-
)
|
| 174 |
-
_tokenizer = AutoTokenizer.from_pretrained(name)
|
| 175 |
-
if _tokenizer.pad_token is None and _tokenizer.eos_token is not None:
|
| 176 |
-
_tokenizer.pad_token = _tokenizer.eos_token
|
| 177 |
-
|
| 178 |
-
if kind == "causal":
|
| 179 |
-
from transformers import AutoModelForCausalLM
|
| 180 |
-
|
| 181 |
-
_model = AutoModelForCausalLM.from_pretrained(name)
|
| 182 |
-
_backend_kind = "causal"
|
| 183 |
-
else:
|
| 184 |
-
from transformers import AutoModelForSeq2SeqLM
|
| 185 |
-
|
| 186 |
-
_model = AutoModelForSeq2SeqLM.from_pretrained(name)
|
| 187 |
-
_backend_kind = "seq2seq"
|
| 188 |
-
|
| 189 |
-
_model.eval()
|
| 190 |
-
_model.to("cpu")
|
| 191 |
-
_backend = f"transformers:{kind}:{name}"
|
| 192 |
-
_ = torch.__version__
|
| 193 |
-
logger.info("Generative rewrite ready (%s)", _backend)
|
| 194 |
-
return _model
|
| 195 |
-
except Exception as exc:
|
| 196 |
-
logger.warning("Generative rewrite unavailable: %s", exc)
|
| 197 |
-
_failed = True
|
| 198 |
-
_tokenizer = None
|
| 199 |
-
_model = None
|
| 200 |
-
_backend = None
|
| 201 |
-
_backend_kind = None
|
| 202 |
-
return None
|
| 203 |
-
|
| 204 |
-
|
| 205 |
-
def _clean_gen_text(text: str) -> str:
|
| 206 |
-
text = re.sub(r"\s+", " ", (text or "").strip())
|
| 207 |
-
if not text:
|
| 208 |
-
return ""
|
| 209 |
-
# Drop casual chat wrappers
|
| 210 |
-
text = re.sub(
|
| 211 |
-
r"^(?:assistant|system|user)\s*[:\-]\s*", "", text, flags=re.I
|
| 212 |
-
).strip()
|
| 213 |
-
low = text.lower()
|
| 214 |
-
for prefix in (
|
| 215 |
-
"rewrite:",
|
| 216 |
-
"paraphrase:",
|
| 217 |
-
"new paraphrase:",
|
| 218 |
-
"rewritten text:",
|
| 219 |
-
"here is the rewrite:",
|
| 220 |
-
"here is the rewritten text:",
|
| 221 |
-
"input:",
|
| 222 |
-
"paragraph:",
|
| 223 |
-
"sentence:",
|
| 224 |
-
"output:",
|
| 225 |
-
):
|
| 226 |
-
if low.startswith(prefix):
|
| 227 |
-
text = text[len(prefix) :].strip()
|
| 228 |
-
low = text.lower()
|
| 229 |
-
# Strip surrounding quotes if the whole output is quoted
|
| 230 |
-
if len(text) >= 2 and text[0] in "\"'" and text[-1] == text[0]:
|
| 231 |
-
text = text[1:-1].strip()
|
| 232 |
-
return text
|
| 233 |
-
|
| 234 |
-
|
| 235 |
-
def _surface_sim(a: str, b: str) -> float:
|
| 236 |
-
return SequenceMatcher(None, a.lower().strip(), b.lower().strip()).ratio()
|
| 237 |
-
|
| 238 |
-
|
| 239 |
-
def _is_near_copy(original: str, candidate: str, max_sim: float) -> bool:
|
| 240 |
-
if not candidate or not candidate.strip():
|
| 241 |
-
return True
|
| 242 |
-
if candidate.strip().lower() == original.strip().lower():
|
| 243 |
-
return True
|
| 244 |
-
return _surface_sim(original, candidate) >= max_sim
|
| 245 |
-
|
| 246 |
-
|
| 247 |
-
def _gen_kwargs(strength: int, *, n_return: int | None = None) -> dict[str, Any]:
|
| 248 |
-
strength = max(0, min(2, int(strength)))
|
| 249 |
-
base = {
|
| 250 |
-
0: {"temperature": 0.85, "top_p": 0.92, "top_k": 50, "max_new_tokens": 192, "n": 4},
|
| 251 |
-
1: {"temperature": 0.95, "top_p": 0.93, "top_k": 55, "max_new_tokens": 256, "n": 5},
|
| 252 |
-
2: {"temperature": 1.05, "top_p": 0.95, "top_k": 60, "max_new_tokens": 320, "n": 5},
|
| 253 |
-
}[strength]
|
| 254 |
-
n = n_return if n_return is not None else base["n"]
|
| 255 |
-
# Causal models: fewer return sequences to limit CPU RAM
|
| 256 |
-
if _backend_kind == "causal":
|
| 257 |
-
n = min(int(n), 3)
|
| 258 |
-
return {
|
| 259 |
-
"do_sample": True,
|
| 260 |
-
"num_beams": 1,
|
| 261 |
-
"temperature": base["temperature"],
|
| 262 |
-
"top_p": base["top_p"],
|
| 263 |
-
"top_k": base["top_k"],
|
| 264 |
-
"max_new_tokens": base["max_new_tokens"],
|
| 265 |
-
"no_repeat_ngram_size": 3,
|
| 266 |
-
"num_return_sequences": max(2, min(int(n), 5)),
|
| 267 |
-
}
|
| 268 |
-
|
| 269 |
-
|
| 270 |
-
def _build_causal_prompt(text: str, tone: str, *, retry: bool = False) -> str:
|
| 271 |
-
tone_l = normalize_tone(tone)
|
| 272 |
-
user = (_CAUSAL_RETRY_USER if retry else _CAUSAL_USER).format(
|
| 273 |
-
tone=tone_l, text=text.strip()
|
| 274 |
-
)
|
| 275 |
-
messages = [
|
| 276 |
-
{"role": "system", "content": _SYSTEM_REWRITE},
|
| 277 |
-
{"role": "user", "content": user},
|
| 278 |
-
]
|
| 279 |
-
assert _tokenizer is not None
|
| 280 |
-
try:
|
| 281 |
-
return _tokenizer.apply_chat_template(
|
| 282 |
-
messages,
|
| 283 |
-
tokenize=False,
|
| 284 |
-
add_generation_prompt=True,
|
| 285 |
-
)
|
| 286 |
-
except Exception:
|
| 287 |
-
# Fallback if chat template missing
|
| 288 |
-
return (
|
| 289 |
-
f"System: {_SYSTEM_REWRITE}\n\n"
|
| 290 |
-
f"User: {user}\n\n"
|
| 291 |
-
f"Assistant:"
|
| 292 |
-
)
|
| 293 |
-
|
| 294 |
-
|
| 295 |
-
def _generate_seq2seq(prompt: str, strength: int, *, n_return: int | None = None) -> list[str]:
|
| 296 |
-
import torch
|
| 297 |
-
|
| 298 |
-
assert _model is not None and _tokenizer is not None
|
| 299 |
-
inputs = _tokenizer(
|
| 300 |
-
prompt,
|
| 301 |
-
return_tensors="pt",
|
| 302 |
-
truncation=True,
|
| 303 |
-
max_length=512,
|
| 304 |
-
)
|
| 305 |
-
kwargs = _gen_kwargs(strength, n_return=n_return)
|
| 306 |
-
with torch.no_grad():
|
| 307 |
-
out_ids = _model.generate(**inputs, **kwargs)
|
| 308 |
-
return [_tokenizer.decode(row, skip_special_tokens=True) for row in out_ids]
|
| 309 |
-
|
| 310 |
-
|
| 311 |
-
def _generate_causal(prompt: str, strength: int, *, n_return: int | None = None) -> list[str]:
|
| 312 |
-
import torch
|
| 313 |
-
|
| 314 |
-
assert _model is not None and _tokenizer is not None
|
| 315 |
-
inputs = _tokenizer(
|
| 316 |
-
prompt,
|
| 317 |
-
return_tensors="pt",
|
| 318 |
-
truncation=True,
|
| 319 |
-
max_length=1024,
|
| 320 |
-
)
|
| 321 |
-
input_len = int(inputs["input_ids"].shape[-1])
|
| 322 |
-
kwargs = _gen_kwargs(strength, n_return=n_return)
|
| 323 |
-
# Avoid pad/eos issues on small causal models
|
| 324 |
-
if _tokenizer.eos_token_id is not None:
|
| 325 |
-
kwargs["eos_token_id"] = _tokenizer.eos_token_id
|
| 326 |
-
if _tokenizer.pad_token_id is not None:
|
| 327 |
-
kwargs["pad_token_id"] = _tokenizer.pad_token_id
|
| 328 |
-
with torch.no_grad():
|
| 329 |
-
out_ids = _model.generate(**inputs, **kwargs)
|
| 330 |
-
decoded: list[str] = []
|
| 331 |
-
for row in out_ids:
|
| 332 |
-
new_tokens = row[input_len:]
|
| 333 |
-
decoded.append(_tokenizer.decode(new_tokens, skip_special_tokens=True))
|
| 334 |
-
return decoded
|
| 335 |
-
|
| 336 |
-
|
| 337 |
-
def _generate_once(
|
| 338 |
-
prompt: str,
|
| 339 |
-
strength: int,
|
| 340 |
-
*,
|
| 341 |
-
n_return: int | None = None,
|
| 342 |
-
causal: bool | None = None,
|
| 343 |
-
) -> list[str]:
|
| 344 |
-
model = _ensure_model()
|
| 345 |
-
if model is None or _tokenizer is None:
|
| 346 |
-
return []
|
| 347 |
-
use_causal = (_backend_kind == "causal") if causal is None else causal
|
| 348 |
-
try:
|
| 349 |
-
if use_causal:
|
| 350 |
-
return _generate_causal(prompt, strength, n_return=n_return)
|
| 351 |
-
return _generate_seq2seq(prompt, strength, n_return=n_return)
|
| 352 |
-
except Exception as exc:
|
| 353 |
-
logger.warning("Generative decode failed: %s", exc)
|
| 354 |
-
return []
|
| 355 |
-
|
| 356 |
-
|
| 357 |
-
def _chunk_long_paragraph(para: str, max_words: int = 180) -> list[str]:
|
| 358 |
-
"""Split oversized paragraphs into sentence groups for model context limits."""
|
| 359 |
-
words = para.split()
|
| 360 |
-
if len(words) <= max_words:
|
| 361 |
-
return [para]
|
| 362 |
-
parts = re.split(r"(?<=[.!?])\s+", para.strip())
|
| 363 |
-
chunks: list[str] = []
|
| 364 |
-
buf: list[str] = []
|
| 365 |
-
count = 0
|
| 366 |
-
for sent in parts:
|
| 367 |
-
w = len(sent.split())
|
| 368 |
-
if buf and count + w > max_words:
|
| 369 |
-
chunks.append(" ".join(buf))
|
| 370 |
-
buf = [sent]
|
| 371 |
-
count = w
|
| 372 |
-
else:
|
| 373 |
-
buf.append(sent)
|
| 374 |
-
count += w
|
| 375 |
-
if buf:
|
| 376 |
-
chunks.append(" ".join(buf))
|
| 377 |
-
return chunks or [para]
|
| 378 |
-
|
| 379 |
-
|
| 380 |
-
def rewrite_unit(
|
| 381 |
-
text: str,
|
| 382 |
-
*,
|
| 383 |
-
tone: str = "Neutral",
|
| 384 |
-
strength: int = 1,
|
| 385 |
-
min_meaning: float = 0.80,
|
| 386 |
-
max_sim: float = NEAR_COPY_SURFACE,
|
| 387 |
-
) -> str:
|
| 388 |
-
"""
|
| 389 |
-
Public targeted-unit rewrite (sentence or short paragraph).
|
| 390 |
-
|
| 391 |
-
Backend-agnostic: uses seq2seq or causal depending on GENERATIVE_BACKEND.
|
| 392 |
-
Returns the best validated rewrite, or the original unit if generation fails.
|
| 393 |
-
"""
|
| 394 |
-
return _paraphrase_paragraph(
|
| 395 |
-
text,
|
| 396 |
-
tone,
|
| 397 |
-
strength,
|
| 398 |
-
min_meaning=min_meaning,
|
| 399 |
-
max_sim=max_sim,
|
| 400 |
-
)
|
| 401 |
-
|
| 402 |
-
|
| 403 |
-
def _paraphrase_paragraph(
|
| 404 |
-
paragraph: str,
|
| 405 |
-
tone: str,
|
| 406 |
-
strength: int,
|
| 407 |
-
*,
|
| 408 |
-
min_meaning: float = 0.80,
|
| 409 |
-
max_sim: float = NEAR_COPY_SURFACE,
|
| 410 |
-
) -> str:
|
| 411 |
-
"""Generate candidates for one unit; return best validated rewrite or original."""
|
| 412 |
-
model = _ensure_model()
|
| 413 |
-
if model is None or _tokenizer is None:
|
| 414 |
-
return paragraph
|
| 415 |
-
if len(paragraph.split()) < 3:
|
| 416 |
-
return paragraph
|
| 417 |
-
|
| 418 |
-
tone_l = normalize_tone(tone)
|
| 419 |
-
if _backend_kind == "causal":
|
| 420 |
-
prompt = _build_causal_prompt(paragraph, tone_l, retry=False)
|
| 421 |
-
else:
|
| 422 |
-
prompt = _TONE_PROMPTS.get(tone_l, _TONE_PROMPTS["Neutral"]).format(
|
| 423 |
-
text=paragraph.strip()
|
| 424 |
-
)
|
| 425 |
-
raw = _generate_once(prompt, strength)
|
| 426 |
-
cands = [_clean_gen_text(x) for x in raw]
|
| 427 |
-
|
| 428 |
-
picked = pick_best_candidate(
|
| 429 |
-
paragraph,
|
| 430 |
-
cands,
|
| 431 |
-
tone=tone_l,
|
| 432 |
-
min_meaning=min_meaning,
|
| 433 |
-
max_surface=max_sim,
|
| 434 |
-
fallback=paragraph,
|
| 435 |
-
)
|
| 436 |
-
|
| 437 |
-
if _is_near_copy(paragraph, picked, max_sim):
|
| 438 |
-
if _backend_kind == "causal":
|
| 439 |
-
retry_prompt = _build_causal_prompt(paragraph, tone_l, retry=True)
|
| 440 |
-
else:
|
| 441 |
-
retry_prompt = _RETRY_PROMPT.format(
|
| 442 |
-
tone=tone_l.lower(), text=paragraph.strip()
|
| 443 |
-
)
|
| 444 |
-
raw2 = _generate_once(retry_prompt, min(2, strength + 1), n_return=5)
|
| 445 |
-
cands2 = [_clean_gen_text(x) for x in raw2]
|
| 446 |
-
picked2 = pick_best_candidate(
|
| 447 |
-
paragraph,
|
| 448 |
-
cands + cands2,
|
| 449 |
-
tone=tone_l,
|
| 450 |
-
min_meaning=max(0.76, min_meaning - 0.02),
|
| 451 |
-
max_surface=max_sim,
|
| 452 |
-
fallback=paragraph,
|
| 453 |
-
)
|
| 454 |
-
if not _is_near_copy(paragraph, picked2, max_sim):
|
| 455 |
-
picked = picked2
|
| 456 |
-
else:
|
| 457 |
-
logger.info(
|
| 458 |
-
"Generative unit still near-copy (sim=%.3f); keeping source chunk",
|
| 459 |
-
_surface_sim(paragraph, picked2),
|
| 460 |
-
)
|
| 461 |
-
return paragraph
|
| 462 |
-
|
| 463 |
-
return picked
|
| 464 |
-
|
| 465 |
-
|
| 466 |
-
def generative_paraphrase(
|
| 467 |
-
text: str,
|
| 468 |
-
*,
|
| 469 |
-
tone: str = "Neutral",
|
| 470 |
-
strength: int = 1,
|
| 471 |
-
max_sim: float = NEAR_COPY_SURFACE,
|
| 472 |
-
min_meaning: float = 0.80,
|
| 473 |
-
) -> str | None:
|
| 474 |
-
"""
|
| 475 |
-
Quality-first paragraph paraphrase.
|
| 476 |
-
Returns None if the model is unavailable.
|
| 477 |
-
Near-copy paragraphs are left unchanged so the orchestrator can fall back.
|
| 478 |
-
"""
|
| 479 |
-
if not text.strip():
|
| 480 |
-
return None
|
| 481 |
-
if _ensure_model() is None:
|
| 482 |
-
return None
|
| 483 |
|
| 484 |
-
tone = normalize_tone(tone)
|
| 485 |
-
strength = max(0, min(2, int(strength)))
|
| 486 |
-
gen_strength = max(1, strength)
|
| 487 |
|
| 488 |
-
|
| 489 |
-
|
| 490 |
-
budget = int(GENERATIVE_MAX_PARAGRAPHS)
|
| 491 |
-
unlimited = budget <= 0
|
| 492 |
-
used = 0
|
| 493 |
-
changed_n = 0
|
| 494 |
|
| 495 |
-
for para in paras:
|
| 496 |
-
if not para.strip():
|
| 497 |
-
continue
|
| 498 |
-
chunks = _chunk_long_paragraph(para)
|
| 499 |
-
new_chunks: list[str] = []
|
| 500 |
-
for chunk in chunks:
|
| 501 |
-
if not unlimited and used >= budget:
|
| 502 |
-
new_chunks.append(chunk)
|
| 503 |
-
continue
|
| 504 |
-
nxt = _paraphrase_paragraph(
|
| 505 |
-
chunk,
|
| 506 |
-
tone,
|
| 507 |
-
gen_strength,
|
| 508 |
-
min_meaning=min_meaning,
|
| 509 |
-
max_sim=max_sim,
|
| 510 |
-
)
|
| 511 |
-
used += 1
|
| 512 |
-
if not _is_near_copy(chunk, nxt, max_sim):
|
| 513 |
-
changed_n += 1
|
| 514 |
-
new_chunks.append(nxt)
|
| 515 |
-
out_paras.append(" ".join(new_chunks))
|
| 516 |
|
| 517 |
-
|
| 518 |
-
|
| 519 |
-
result = "\n\n".join(p for p in out_paras if p)
|
| 520 |
-
logger.info(
|
| 521 |
-
"Generative paraphrase: %s/%s units changed (strength=%s, model=%s, "
|
| 522 |
-
"backend=%s, budget=%s, max_sim=%.2f)",
|
| 523 |
-
changed_n,
|
| 524 |
-
used,
|
| 525 |
-
gen_strength,
|
| 526 |
-
GENERATIVE_MODEL,
|
| 527 |
-
_backend_kind or backend_kind(),
|
| 528 |
-
"all" if unlimited else budget,
|
| 529 |
-
max_sim,
|
| 530 |
-
)
|
| 531 |
-
if used > 0 and changed_n == 0:
|
| 532 |
-
logger.info("Generative produced no useful paraphrases; returning near-copy for fallback")
|
| 533 |
-
return result
|
| 534 |
|
| 535 |
|
| 536 |
def warm_generative() -> bool:
|
| 537 |
-
|
| 538 |
-
ok = generative_available()
|
| 539 |
-
if ok:
|
| 540 |
-
rewrite_unit(
|
| 541 |
-
"The system works well for most users and saves time.",
|
| 542 |
-
tone="Neutral",
|
| 543 |
-
strength=1,
|
| 544 |
-
)
|
| 545 |
-
return ok
|
| 546 |
-
except Exception as exc:
|
| 547 |
-
logger.warning("Generative warm failed: %s", exc)
|
| 548 |
-
return False
|
|
|
|
| 1 |
+
"""Legacy generative LM rewrite — NOT used by the structural engine.
|
| 2 |
|
| 3 |
+
Functions remain as stubs so optional imports and old scripts do not crash.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 4 |
"""
|
| 5 |
|
| 6 |
from __future__ import annotations
|
| 7 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 8 |
from typing import Any
|
| 9 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 10 |
|
| 11 |
def generative_package_present() -> bool:
|
| 12 |
+
return False
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 13 |
|
| 14 |
|
| 15 |
def generative_available() -> bool:
|
| 16 |
+
return False
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 17 |
|
| 18 |
|
| 19 |
def backend_name() -> str | None:
|
| 20 |
+
return None
|
|
|
|
| 21 |
|
| 22 |
|
| 23 |
def backend_kind() -> str:
|
| 24 |
+
return "none"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 25 |
|
|
|
|
|
|
|
|
|
|
| 26 |
|
| 27 |
+
def rewrite_unit(*_args: Any, **_kwargs: Any) -> str | None:
|
| 28 |
+
return None
|
|
|
|
|
|
|
|
|
|
|
|
|
| 29 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 30 |
|
| 31 |
+
def generative_paraphrase(*_args: Any, **_kwargs: Any) -> str | None:
|
| 32 |
+
return None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 33 |
|
| 34 |
|
| 35 |
def warm_generative() -> bool:
|
| 36 |
+
return False
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
app/pipeline/orchestrator.py
CHANGED
|
@@ -1,64 +1,14 @@
|
|
| 1 |
-
"""Orchestrate rewrite
|
| 2 |
-
|
| 3 |
-
Modes (PIPELINE_MODE):
|
| 4 |
-
classical — rules + lexicon only
|
| 5 |
-
generative — whole-document generative rewrite
|
| 6 |
-
hybrid — classical baseline → validate units → generative patch failed units
|
| 7 |
-
"""
|
| 8 |
|
| 9 |
from __future__ import annotations
|
| 10 |
|
| 11 |
import logging
|
| 12 |
-
import random
|
| 13 |
-
import re
|
| 14 |
import time
|
| 15 |
from dataclasses import dataclass, field
|
| 16 |
-
from difflib import SequenceMatcher
|
| 17 |
|
| 18 |
-
from app.
|
| 19 |
-
|
| 20 |
-
GENERATIVE_MAX_PARAGRAPHS,
|
| 21 |
-
GENERATIVE_MODEL,
|
| 22 |
-
GENERATIVE_PRIMARY,
|
| 23 |
-
GRAMMAR_FIX_INPUT,
|
| 24 |
-
GRAMMAR_FIX_OUTPUT,
|
| 25 |
-
LEXICON_FALLBACK,
|
| 26 |
-
PIPELINE_MODE,
|
| 27 |
-
)
|
| 28 |
-
from app.pipeline.alignment import align_documents, iter_source_units
|
| 29 |
-
from app.pipeline.candidate_ranker import NEAR_COPY_SURFACE
|
| 30 |
-
from app.pipeline.candidate_validator import validate_candidate
|
| 31 |
-
from app.pipeline.generative import (
|
| 32 |
-
backend_kind,
|
| 33 |
-
generative_available,
|
| 34 |
-
generative_paraphrase,
|
| 35 |
-
rewrite_unit,
|
| 36 |
-
)
|
| 37 |
-
from app.pipeline.grammar_fix import correct_text
|
| 38 |
-
from app.pipeline.mechanics import enforce_length_budget, scrub_phrases, tidy
|
| 39 |
-
from app.pipeline.ml_context import (
|
| 40 |
-
gen_was_used,
|
| 41 |
-
mark_gen_used,
|
| 42 |
-
ml_was_used,
|
| 43 |
-
set_ml_polish,
|
| 44 |
-
)
|
| 45 |
-
from app.pipeline.ml_polish import apply_minilm_polish
|
| 46 |
-
from app.pipeline.minilm import minilm_available
|
| 47 |
-
from app.pipeline.nlp import get_nlp, spacy_available
|
| 48 |
-
from app.pipeline.normalize import (
|
| 49 |
-
normalize_whitespace,
|
| 50 |
-
split_paragraphs,
|
| 51 |
-
split_sentences_regex,
|
| 52 |
-
word_count,
|
| 53 |
-
)
|
| 54 |
-
from app.pipeline.restructure import restructure_sentence
|
| 55 |
from app.pipeline.similarity import SimilarityResult, compare_similarity
|
| 56 |
-
from app.pipeline.synonym import rewrite_sentence_synonyms
|
| 57 |
-
from app.pipeline.syntax_rewrite import (
|
| 58 |
-
apply_tone_contractions,
|
| 59 |
-
rewrite_paragraph_structure,
|
| 60 |
-
)
|
| 61 |
-
from app.pipeline.tone_style import apply_tone_style
|
| 62 |
from app.pipeline.tones import normalize_tone
|
| 63 |
|
| 64 |
logger = logging.getLogger("plainrewrite")
|
|
@@ -68,6 +18,8 @@ if not logger.handlers:
|
|
| 68 |
|
| 69 |
@dataclass
|
| 70 |
class HybridStats:
|
|
|
|
|
|
|
| 71 |
units: int = 0
|
| 72 |
classical_kept: int = 0
|
| 73 |
regenerated: int = 0
|
|
@@ -92,1187 +44,12 @@ class RewriteResult:
|
|
| 92 |
tone: str
|
| 93 |
changed: bool
|
| 94 |
notes: str = ""
|
| 95 |
-
pipeline_mode: str = ""
|
| 96 |
hybrid: HybridStats | None = None
|
| 97 |
similarity: float = 1.0
|
| 98 |
-
|
| 99 |
-
|
| 100 |
-
|
| 101 |
-
return random.Random(sum(ord(c) for c in text) * 2654435761 & 0xFFFFFFFF)
|
| 102 |
-
|
| 103 |
-
|
| 104 |
-
def _similarity_ratio(a: str, b: str) -> float:
|
| 105 |
-
return SequenceMatcher(None, a.lower(), b.lower()).ratio()
|
| 106 |
-
|
| 107 |
-
|
| 108 |
-
def _light_post_gen(text: str, tone: str) -> str:
|
| 109 |
-
"""After generative rewrite: scrub fillers + contractions only — no synonym thrash."""
|
| 110 |
-
out = scrub_phrases(text)
|
| 111 |
-
out = apply_tone_contractions(out, tone)
|
| 112 |
-
return tidy(out)
|
| 113 |
-
|
| 114 |
-
|
| 115 |
-
def _ensure_unit_terminal(text: str) -> str:
|
| 116 |
-
"""Guarantee a sentence terminator so units stay countable after join."""
|
| 117 |
-
s = (text or "").strip()
|
| 118 |
-
if not s:
|
| 119 |
-
return s
|
| 120 |
-
if s[-1] not in ".!?":
|
| 121 |
-
s += "."
|
| 122 |
-
return s
|
| 123 |
-
|
| 124 |
-
|
| 125 |
-
def _unit_stems(text: str) -> set[str]:
|
| 126 |
-
"""Content stems shared with the validator (for lexical-novelty checks)."""
|
| 127 |
-
from app.pipeline.candidate_validator import _content_tokens
|
| 128 |
-
|
| 129 |
-
return _content_tokens(text or "")
|
| 130 |
-
|
| 131 |
-
|
| 132 |
-
def _stem_keep_ratio(source: str, candidate: str) -> float:
|
| 133 |
-
src = _unit_stems(source)
|
| 134 |
-
if not src:
|
| 135 |
-
return 1.0
|
| 136 |
-
return len(src & _unit_stems(candidate)) / len(src)
|
| 137 |
-
|
| 138 |
-
|
| 139 |
-
def _stem_jaccard(source: str, candidate: str) -> float:
|
| 140 |
-
a, b = _unit_stems(source), _unit_stems(candidate)
|
| 141 |
-
if not a and not b:
|
| 142 |
-
return 1.0
|
| 143 |
-
return len(a & b) / max(1, len(a | b))
|
| 144 |
-
|
| 145 |
-
|
| 146 |
-
def _paraphrase_hard_ok(source_unit: str, trial: str) -> bool:
|
| 147 |
-
"""Meaning safety for forced rewrites.
|
| 148 |
-
|
| 149 |
-
Allows real synonym/phrase changes (relaxed key_content/collocation) but still
|
| 150 |
-
blocks polarity flips, invention, topic swaps, etc.
|
| 151 |
-
"""
|
| 152 |
-
t = (trial or "").strip()
|
| 153 |
-
if not t:
|
| 154 |
-
return False
|
| 155 |
-
t = _clamp_to_source_shape(source_unit, t) or t
|
| 156 |
-
v = validate_candidate(source_unit, t, min_meaning=0.62, max_surface=0.995)
|
| 157 |
-
# key_content/coverage/collocation/drift are too strict for synonym paraphrases;
|
| 158 |
-
# enforce a softer stem floor instead.
|
| 159 |
-
soft = {
|
| 160 |
-
"too_similar",
|
| 161 |
-
"identical",
|
| 162 |
-
"near_copy",
|
| 163 |
-
"too_divergent",
|
| 164 |
-
"key_content",
|
| 165 |
-
"coverage",
|
| 166 |
-
"drift",
|
| 167 |
-
"grammar_worse",
|
| 168 |
-
"collocation",
|
| 169 |
-
}
|
| 170 |
-
hard_keys = [r.split(":")[0] for r in v.reasons if r.split(":")[0] not in soft]
|
| 171 |
-
if _hard_fail(hard_keys):
|
| 172 |
-
return False
|
| 173 |
-
if _stem_keep_ratio(source_unit, t) < 0.30:
|
| 174 |
-
return False
|
| 175 |
-
return True
|
| 176 |
-
|
| 177 |
-
|
| 178 |
-
def _content_word_bag(text: str) -> frozenset[str]:
|
| 179 |
-
"""Lowercased content words (len≥4)."""
|
| 180 |
-
return frozenset(
|
| 181 |
-
w.strip("'")
|
| 182 |
-
for w in re.findall(r"[a-zA-Z']+", (text or "").lower())
|
| 183 |
-
if len(w.strip("'")) >= 4
|
| 184 |
-
)
|
| 185 |
-
|
| 186 |
-
|
| 187 |
-
def _token_seq(text: str) -> tuple[str, ...]:
|
| 188 |
-
return tuple(re.findall(r"[a-zA-Z']+", (text or "").lower()))
|
| 189 |
-
|
| 190 |
-
|
| 191 |
-
def _is_structural_reorder(source: str, candidate: str) -> bool:
|
| 192 |
-
"""Same content words, different order (user-expected sentence reorder)."""
|
| 193 |
-
src_bag = _content_word_bag(source)
|
| 194 |
-
cand_bag = _content_word_bag(candidate)
|
| 195 |
-
if not src_bag or src_bag != cand_bag:
|
| 196 |
-
return False
|
| 197 |
-
return _token_seq(source) != _token_seq(candidate)
|
| 198 |
-
|
| 199 |
-
|
| 200 |
-
def _is_true_rewrite(source_unit: str, candidate: str) -> bool:
|
| 201 |
-
"""Accept structural reorder OR lexical wording change — not identity."""
|
| 202 |
-
src = (source_unit or "").strip()
|
| 203 |
-
cand = (candidate or "").strip()
|
| 204 |
-
if not src or not cand:
|
| 205 |
-
return False
|
| 206 |
-
if cand.lower().rstrip(".!?") == src.lower().rstrip(".!?"):
|
| 207 |
-
return False
|
| 208 |
-
if not _paraphrase_hard_ok(src, cand):
|
| 209 |
-
return False
|
| 210 |
-
sim = _similarity_ratio(src, cand)
|
| 211 |
-
# Preferred path: reorder same words into a new structure
|
| 212 |
-
if _is_structural_reorder(src, cand):
|
| 213 |
-
return sim < 0.995
|
| 214 |
-
# Lexical path: different content words
|
| 215 |
-
jacc = _stem_jaccard(src, cand)
|
| 216 |
-
if jacc >= 0.92:
|
| 217 |
-
return False
|
| 218 |
-
if sim > 0.90:
|
| 219 |
-
return False
|
| 220 |
-
return True
|
| 221 |
-
|
| 222 |
-
|
| 223 |
-
def _safe_paraphrase_trial(source_unit: str, trial: str) -> tuple[bool, float]:
|
| 224 |
-
"""Return (is_true_rewrite, surface_sim)."""
|
| 225 |
-
t = (trial or "").strip()
|
| 226 |
-
if not t:
|
| 227 |
-
return False, 1.0
|
| 228 |
-
t = _ensure_unit_terminal(_clamp_to_source_shape(source_unit, t) or t)
|
| 229 |
-
if not _is_true_rewrite(source_unit, t):
|
| 230 |
-
return False, 1.0
|
| 231 |
-
return True, _similarity_ratio(source_unit, t)
|
| 232 |
-
|
| 233 |
-
|
| 234 |
-
def _rewrite_score(source_unit: str, candidate: str) -> float:
|
| 235 |
-
"""Prefer structural reorder; otherwise reward lexical difference."""
|
| 236 |
-
sim = _similarity_ratio(source_unit, candidate)
|
| 237 |
-
if _is_structural_reorder(source_unit, candidate):
|
| 238 |
-
# Strong preference for clear reorders (user expectation)
|
| 239 |
-
return 1.25 + (1.0 - sim)
|
| 240 |
-
jacc = _stem_jaccard(source_unit, candidate)
|
| 241 |
-
return (1.0 - sim) + 0.85 * (1.0 - jacc)
|
| 242 |
-
|
| 243 |
-
|
| 244 |
-
# Phrase-level meaning-preserving rewrites (change wording, not just order).
|
| 245 |
-
# Prefer swaps that keep enough claim stems to stay meaning-safe.
|
| 246 |
-
_PHRASE_REWRITES: tuple[tuple[re.Pattern[str], str], ...] = (
|
| 247 |
-
(re.compile(r"\bget(?:s)?\s+diseases\b", re.I), "get sick"),
|
| 248 |
-
(re.compile(r"\bgetting\s+diseases\b", re.I), "getting sick"),
|
| 249 |
-
(
|
| 250 |
-
re.compile(r"\bless chance to gets?\s+diseases\b", re.I),
|
| 251 |
-
"less chance to get sick",
|
| 252 |
-
),
|
| 253 |
-
(
|
| 254 |
-
re.compile(r"\bless chance of getting\s+diseases\b", re.I),
|
| 255 |
-
"less chance of getting sick",
|
| 256 |
-
),
|
| 257 |
-
(re.compile(r"\bwatching television\b", re.I), "watching TV"),
|
| 258 |
-
(re.compile(r"\bdoing physical activities\b", re.I), "working out"),
|
| 259 |
-
(re.compile(r"\bphysical activities\b", re.I), "real exercise"),
|
| 260 |
-
(re.compile(r"\bexercise regularly\b", re.I), "work out often"),
|
| 261 |
-
(re.compile(r"\bexercises regularly\b", re.I), "works out often"),
|
| 262 |
-
(re.compile(r"\ba person exercise\b", re.I), "a person works out"),
|
| 263 |
-
(re.compile(r"\ba person exercises\b", re.I), "a person works out"),
|
| 264 |
-
(re.compile(r"\bprefers watching\b", re.I), "would rather watch"),
|
| 265 |
-
(re.compile(r"\bprefer watching\b", re.I), "would rather watch"),
|
| 266 |
-
(re.compile(r"\bmany peoples\b", re.I), "a lot of people"),
|
| 267 |
-
(re.compile(r"\bmany people\b", re.I), "a lot of people"),
|
| 268 |
-
(re.compile(r"\bUnfortunately,\s*", re.I), "Sadly, "),
|
| 269 |
-
(
|
| 270 |
-
re.compile(
|
| 271 |
-
r"\bis one of the most important\s+\w+\s+that keeps our body fit\b",
|
| 272 |
-
re.I,
|
| 273 |
-
),
|
| 274 |
-
"is a key way to keep the body fit",
|
| 275 |
-
),
|
| 276 |
-
(
|
| 277 |
-
re.compile(r"\bis one of the most important\b", re.I),
|
| 278 |
-
"is a key",
|
| 279 |
-
),
|
| 280 |
-
(re.compile(r"\bkeeps our body fit\b", re.I), "helps the body stay fit"),
|
| 281 |
-
(re.compile(r"\bfind it harder to\b", re.I), "struggle more to"),
|
| 282 |
-
(re.compile(r"\binstead of\b", re.I), "rather than"),
|
| 283 |
-
)
|
| 284 |
-
|
| 285 |
-
|
| 286 |
-
def _sentence_reorder_variants(text: str) -> list[str]:
|
| 287 |
-
"""Structural reorders that keep the same words (user-expected style).
|
| 288 |
-
|
| 289 |
-
Example:
|
| 290 |
-
Ram went to school yesterday happily.
|
| 291 |
-
→ Yesterday, Ram happily went to school.
|
| 292 |
-
"""
|
| 293 |
-
out: list[str] = []
|
| 294 |
-
base = (text or "").strip()
|
| 295 |
-
if not base:
|
| 296 |
-
return out
|
| 297 |
-
end_m = re.search(r"[.!?]+$", base)
|
| 298 |
-
end = end_m.group(0) if end_m else "."
|
| 299 |
-
core = base[: end_m.start()] if end_m else base
|
| 300 |
-
|
| 301 |
-
# Subj + motion-verb + to + place + time + manner-ly
|
| 302 |
-
m = re.match(
|
| 303 |
-
r"^(?P<subj>[A-Za-z][\w'-]*)\s+"
|
| 304 |
-
r"(?P<verb>went|go|goes|walked|ran|drove|came|moved)\s+to\s+"
|
| 305 |
-
r"(?P<place>.+?)\s+"
|
| 306 |
-
r"(?P<time>yesterday|today|tomorrow|earlier|later|recently)\s+"
|
| 307 |
-
r"(?P<manner>\w+ly)$",
|
| 308 |
-
core,
|
| 309 |
-
flags=re.I,
|
| 310 |
-
)
|
| 311 |
-
if m:
|
| 312 |
-
subj = m.group("subj")
|
| 313 |
-
verb = m.group("verb")
|
| 314 |
-
place = m.group("place").strip()
|
| 315 |
-
time = m.group("time")
|
| 316 |
-
manner = m.group("manner")
|
| 317 |
-
# Preferred: Yesterday, Ram happily went to school.
|
| 318 |
-
out.append(
|
| 319 |
-
f"{_cap_first(time)}, {subj} {manner} {verb.lower()} to {place}{end}"
|
| 320 |
-
)
|
| 321 |
-
out.append(
|
| 322 |
-
f"{_cap_first(time)}, {subj} {verb.lower()} to {place} {manner}{end}"
|
| 323 |
-
)
|
| 324 |
-
out.append(f"{subj} {manner} {verb.lower()} to {place} {time}{end}")
|
| 325 |
-
|
| 326 |
-
# Trailing time → front: "... yesterday."
|
| 327 |
-
m = re.match(
|
| 328 |
-
r"^(?P<main>.+?)\s+"
|
| 329 |
-
r"(?P<time>yesterday|today|tomorrow|earlier|later|recently|now)"
|
| 330 |
-
r"$",
|
| 331 |
-
core,
|
| 332 |
-
flags=re.I,
|
| 333 |
-
)
|
| 334 |
-
if m and len(m.group("main").split()) >= 3:
|
| 335 |
-
main = m.group("main").rstrip(" ,")
|
| 336 |
-
time = m.group("time")
|
| 337 |
-
out.append(f"{_cap_first(time)}, {_lower_first(main)}{end}")
|
| 338 |
-
|
| 339 |
-
# Trailing manner -ly → before main verb (simple Subj ... ly)
|
| 340 |
-
m = re.match(
|
| 341 |
-
r"^(?P<subj>[A-Za-z][\w'-]*)\s+(?P<mid>.+?)\s+(?P<manner>\w+ly)$",
|
| 342 |
-
core,
|
| 343 |
-
flags=re.I,
|
| 344 |
-
)
|
| 345 |
-
if m and " to " in f" {m.group('mid')} ":
|
| 346 |
-
subj, mid, manner = m.group("subj"), m.group("mid").strip(), m.group("manner")
|
| 347 |
-
# Insert manner after subject
|
| 348 |
-
out.append(f"{subj} {manner} {mid}{end}")
|
| 349 |
-
|
| 350 |
-
# "Y instead of X" → "Instead of X, Y"
|
| 351 |
-
m = re.match(
|
| 352 |
-
r"^(?P<main>.+?)\s+instead of\s+(?P<alt>.+)$",
|
| 353 |
-
core,
|
| 354 |
-
flags=re.I,
|
| 355 |
-
)
|
| 356 |
-
if m:
|
| 357 |
-
main = m.group("main").rstrip(" ,")
|
| 358 |
-
alt = m.group("alt").rstrip(" .!?")
|
| 359 |
-
out.append(f"Instead of {alt}, {_lower_first(main)}{end}")
|
| 360 |
-
|
| 361 |
-
# "X because Y" → "Because Y, X"
|
| 362 |
-
m = re.match(
|
| 363 |
-
r"^(?P<main>.+?)\s+because\s+(?P<reason>.+)$",
|
| 364 |
-
core,
|
| 365 |
-
flags=re.I,
|
| 366 |
-
)
|
| 367 |
-
if m and len(m.group("main").split()) >= 3:
|
| 368 |
-
main = m.group("main").rstrip(" ,")
|
| 369 |
-
reason = m.group("reason").rstrip(" .!?")
|
| 370 |
-
out.append(f"Because {reason}, {_lower_first(main)}{end}")
|
| 371 |
-
|
| 372 |
-
# Dedup
|
| 373 |
-
seen: set[str] = set()
|
| 374 |
-
uniq: list[str] = []
|
| 375 |
-
for t in out:
|
| 376 |
-
key = t.strip().lower()
|
| 377 |
-
if not key or key in seen:
|
| 378 |
-
continue
|
| 379 |
-
seen.add(key)
|
| 380 |
-
uniq.append(t.strip())
|
| 381 |
-
return uniq
|
| 382 |
-
|
| 383 |
-
|
| 384 |
-
def _apply_phrase_rewrites(text: str, *, max_swaps: int = 4) -> list[str]:
|
| 385 |
-
"""Build increasingly rewritten phrase variants."""
|
| 386 |
-
out: list[str] = []
|
| 387 |
-
base = (text or "").strip()
|
| 388 |
-
if not base:
|
| 389 |
-
return out
|
| 390 |
-
# Single-swap variants
|
| 391 |
-
for pat, repl in _PHRASE_REWRITES:
|
| 392 |
-
if not pat.search(base):
|
| 393 |
-
continue
|
| 394 |
-
cand = pat.sub(repl, base, count=1)
|
| 395 |
-
if cand.strip() and cand.strip() != base:
|
| 396 |
-
out.append(cand.strip())
|
| 397 |
-
# Stacked multi-swap
|
| 398 |
-
stacked = base
|
| 399 |
-
n = 0
|
| 400 |
-
for pat, repl in _PHRASE_REWRITES:
|
| 401 |
-
if n >= max_swaps:
|
| 402 |
-
break
|
| 403 |
-
if pat.search(stacked):
|
| 404 |
-
nxt = pat.sub(repl, stacked, count=1)
|
| 405 |
-
if nxt != stacked:
|
| 406 |
-
stacked = nxt
|
| 407 |
-
n += 1
|
| 408 |
-
out.append(stacked.strip())
|
| 409 |
-
return out
|
| 410 |
-
|
| 411 |
-
|
| 412 |
-
def _esl_structural_variants(text: str) -> list[str]:
|
| 413 |
-
"""Lexical ESL rewrites (must change wording — not mere reorder)."""
|
| 414 |
-
out: list[str] = []
|
| 415 |
-
base = (text or "").strip()
|
| 416 |
-
if not base:
|
| 417 |
-
return out
|
| 418 |
-
end_m = re.search(r"[.!?]+$", base)
|
| 419 |
-
end = end_m.group(0) if end_m else "."
|
| 420 |
-
core = base[: end_m.start()] if end_m else base
|
| 421 |
-
|
| 422 |
-
m = re.match(
|
| 423 |
-
r"^(?:Another|One|An)\s+important\s+thing\s+(?:is|are)\s+(.+)$",
|
| 424 |
-
core,
|
| 425 |
-
flags=re.I,
|
| 426 |
-
)
|
| 427 |
-
if m:
|
| 428 |
-
topic = m.group(1).strip().rstrip(".!?")
|
| 429 |
-
if topic:
|
| 430 |
-
# Keep topic head "thing" so topic_anchor passes; change other wording
|
| 431 |
-
out.append(f"{_cap_first(topic)} remains an important thing{end}")
|
| 432 |
-
out.append(f"{_cap_first(topic)} is still an important thing{end}")
|
| 433 |
-
out.append(f"A further important thing is {_lower_first(topic)}{end}")
|
| 434 |
-
|
| 435 |
-
m = re.match(r"^Without enough\s+(\w+),\s*(.+)$", core, flags=re.I)
|
| 436 |
-
if m:
|
| 437 |
-
noun, rest = m.group(1), m.group(2).strip()
|
| 438 |
-
if noun and rest:
|
| 439 |
-
out.append(f"When {noun} runs short, {_lower_first(rest)}{end}")
|
| 440 |
-
out.append(f"If {noun} is lacking, {_lower_first(rest)}{end}")
|
| 441 |
-
out.append(f"Too little {noun} leaves {_lower_first(rest)}{end}")
|
| 442 |
-
|
| 443 |
-
m = re.match(
|
| 444 |
-
r"^(.+?)\s+is one of the most important\s+(.+)$",
|
| 445 |
-
core,
|
| 446 |
-
flags=re.I,
|
| 447 |
-
)
|
| 448 |
-
if m:
|
| 449 |
-
subj, rest = m.group(1).strip(), m.group(2).strip()
|
| 450 |
-
if subj and rest:
|
| 451 |
-
out.append(f"{_cap_first(subj)} is a key part of {rest}{end}")
|
| 452 |
-
out.append(f"{_cap_first(subj)} matters a lot for {rest}{end}")
|
| 453 |
-
|
| 454 |
-
m = re.match(r"^It is important (?:that|to)\s+(.+)$", core, flags=re.I)
|
| 455 |
-
if m:
|
| 456 |
-
rest = m.group(1).strip()
|
| 457 |
-
if rest:
|
| 458 |
-
out.append(f"People need to {_lower_first(rest)}{end}")
|
| 459 |
-
out.append(f"{_cap_first(rest)} really matters{end}")
|
| 460 |
-
|
| 461 |
-
# If a person X, they will Y → People who X will Y
|
| 462 |
-
m = re.match(
|
| 463 |
-
r"^(?:If\s+)?a person\s+(.+?),\s*they will\s+(.+)$",
|
| 464 |
-
core,
|
| 465 |
-
flags=re.I,
|
| 466 |
-
)
|
| 467 |
-
if m:
|
| 468 |
-
act, result = m.group(1).strip(), m.group(2).strip()
|
| 469 |
-
out.append(f"People who {act} will {result}{end}")
|
| 470 |
-
out.append(f"Anyone who {act} will {result}{end}")
|
| 471 |
-
|
| 472 |
-
return out
|
| 473 |
-
|
| 474 |
-
|
| 475 |
-
def _cap_first(text: str) -> str:
|
| 476 |
-
t = (text or "").strip()
|
| 477 |
-
if not t:
|
| 478 |
-
return t
|
| 479 |
-
return t[0].upper() + t[1:]
|
| 480 |
-
|
| 481 |
-
|
| 482 |
-
def _lower_first(text: str) -> str:
|
| 483 |
-
t = (text or "").strip()
|
| 484 |
-
if not t:
|
| 485 |
-
return t
|
| 486 |
-
return t[0].lower() + t[1:]
|
| 487 |
-
|
| 488 |
-
|
| 489 |
-
_SAFE_SURFACE_SWAPS: tuple[tuple[re.Pattern[str], str], ...] = (
|
| 490 |
-
(re.compile(r"\bIn addition,\b", re.I), "Also,"),
|
| 491 |
-
(re.compile(r"\bAdditionally,\b", re.I), "Also,"),
|
| 492 |
-
(re.compile(r"\bHowever,\b", re.I), "Still,"),
|
| 493 |
-
(re.compile(r"\bTherefore,\b", re.I), "So,"),
|
| 494 |
-
(re.compile(r"\bDue to the fact that\b", re.I), "Because"),
|
| 495 |
-
(re.compile(r"\bin order to\b", re.I), "to"),
|
| 496 |
-
(re.compile(r"\ba large number of\b", re.I), "many"),
|
| 497 |
-
(re.compile(r"\bMany\b"), "A lot of"),
|
| 498 |
-
(re.compile(r"\bis able to\b", re.I), "can"),
|
| 499 |
-
(re.compile(r"\bare able to\b", re.I), "can"),
|
| 500 |
-
(re.compile(r"\bPeople should\b"), "People need to"),
|
| 501 |
-
(re.compile(r"\bpeople should\b"), "people need to"),
|
| 502 |
-
(re.compile(r"\bbecause of\b", re.I), "due to"),
|
| 503 |
-
(re.compile(r"\bfor example\b", re.I), "for instance"),
|
| 504 |
-
(re.compile(r"\bthink that\b", re.I), "think"),
|
| 505 |
-
(re.compile(r"\bbelieve that\b", re.I), "believe"),
|
| 506 |
-
(re.compile(r"\bby giving\b", re.I), "by offering"),
|
| 507 |
-
(re.compile(r"\bhelp communities\b", re.I), "support communities"),
|
| 508 |
-
)
|
| 509 |
-
|
| 510 |
-
_VISIBLE_TARGET_SIM = 0.82
|
| 511 |
-
_LM_SHIP_MAX_SIM = 0.85
|
| 512 |
-
|
| 513 |
-
|
| 514 |
-
def _surface_humanize_variants(text: str, rng: random.Random) -> list[str]:
|
| 515 |
-
"""Reorder + phrase + light surface variants."""
|
| 516 |
-
out: list[str] = []
|
| 517 |
-
base = (text or "").strip()
|
| 518 |
-
if not base:
|
| 519 |
-
return out
|
| 520 |
-
|
| 521 |
-
out.extend(_sentence_reorder_variants(base))
|
| 522 |
-
out.extend(_apply_phrase_rewrites(base))
|
| 523 |
-
out.extend(_esl_structural_variants(base))
|
| 524 |
-
|
| 525 |
-
# Because-clause fronting only when combined with a phrase rewrite later
|
| 526 |
-
m = re.match(
|
| 527 |
-
r"^(?P<main>.+?)\s+because\s+(?P<reason>.+?)(?P<end>[.!?])?$",
|
| 528 |
-
base,
|
| 529 |
-
flags=re.I,
|
| 530 |
-
)
|
| 531 |
-
if m and len(m.group("main").split()) >= 3:
|
| 532 |
-
main = m.group("main").rstrip(" ,")
|
| 533 |
-
reason = m.group("reason").rstrip(" .!?")
|
| 534 |
-
end = m.group("end") or "."
|
| 535 |
-
out.append(f"Because {reason}, {_lower_first(main)}{end}")
|
| 536 |
-
|
| 537 |
-
swaps = list(_SAFE_SURFACE_SWAPS)
|
| 538 |
-
rng.shuffle(swaps)
|
| 539 |
-
for pat, repl in swaps:
|
| 540 |
-
if not pat.search(base):
|
| 541 |
-
continue
|
| 542 |
-
cand = pat.sub(repl, base, count=1)
|
| 543 |
-
if cand.strip() and cand.strip() != base:
|
| 544 |
-
out.append(cand.strip())
|
| 545 |
-
# Stack phrase rewrites on surface swaps
|
| 546 |
-
for seed in list(out[:8]):
|
| 547 |
-
out.extend(_apply_phrase_rewrites(seed))
|
| 548 |
-
out.extend(_esl_structural_variants(seed))
|
| 549 |
-
return out
|
| 550 |
-
|
| 551 |
-
|
| 552 |
-
def _visible_unit_paraphrase(
|
| 553 |
-
source_unit: str,
|
| 554 |
-
tone: str,
|
| 555 |
-
rng: random.Random,
|
| 556 |
-
*,
|
| 557 |
-
strength: int = 2,
|
| 558 |
-
target_sim: float = _VISIBLE_TARGET_SIM,
|
| 559 |
-
) -> str:
|
| 560 |
-
"""Prefer dependency/template reorder; fall back to classical paraphrase."""
|
| 561 |
-
base = scrub_phrases(source_unit)
|
| 562 |
-
base = apply_tone_contractions(base, tone)
|
| 563 |
-
base = tidy(correct_text(base) if base else "")
|
| 564 |
-
if not base.strip():
|
| 565 |
-
base = source_unit
|
| 566 |
-
|
| 567 |
-
# --- Primary: dep+template restructure (no synonym thrash) ---
|
| 568 |
-
try:
|
| 569 |
-
rr = restructure_sentence(base, min_confidence=0.55)
|
| 570 |
-
if rr and rr.text.strip():
|
| 571 |
-
cand = tidy(correct_text(rr.text) if rr.text else rr.text)
|
| 572 |
-
cand = _ensure_unit_terminal(
|
| 573 |
-
_clamp_to_source_shape(source_unit, cand) or cand
|
| 574 |
-
)
|
| 575 |
-
if _is_true_rewrite(source_unit, cand) or _is_structural_reorder(
|
| 576 |
-
source_unit, cand
|
| 577 |
-
):
|
| 578 |
-
if _paraphrase_hard_ok(source_unit, cand):
|
| 579 |
-
return cand
|
| 580 |
-
except Exception:
|
| 581 |
-
pass
|
| 582 |
-
|
| 583 |
-
structured = base
|
| 584 |
-
try:
|
| 585 |
-
structured = rewrite_paragraph_structure(base, strength, rng, tone=tone)
|
| 586 |
-
structured = tidy(structured)
|
| 587 |
-
clamped = _clamp_to_source_shape(source_unit, structured)
|
| 588 |
-
if clamped:
|
| 589 |
-
structured = clamped
|
| 590 |
-
elif _sentence_count_simple(structured) != _sentence_count_simple(source_unit):
|
| 591 |
-
structured = base
|
| 592 |
-
except Exception:
|
| 593 |
-
structured = base
|
| 594 |
-
|
| 595 |
-
trials: list[str] = []
|
| 596 |
-
# Prefer structural reorder first (user expectation)
|
| 597 |
-
for seed in (base, structured):
|
| 598 |
-
trials.extend(_sentence_reorder_variants(seed))
|
| 599 |
-
for seed in (base, structured):
|
| 600 |
-
trials.extend(_apply_phrase_rewrites(seed))
|
| 601 |
-
trials.extend(_esl_structural_variants(seed))
|
| 602 |
-
trials.extend(_surface_humanize_variants(seed, rng))
|
| 603 |
-
if structured.strip() and structured.strip() != base.strip():
|
| 604 |
-
trials.append(structured)
|
| 605 |
-
|
| 606 |
-
# Synonym passes — allow stronger lexicon; safety filter keeps meaning
|
| 607 |
-
for syn_src, syn_str, force in (
|
| 608 |
-
(structured, 2, True),
|
| 609 |
-
(base, 2, True),
|
| 610 |
-
(structured, 1, True),
|
| 611 |
-
(base, 1, False),
|
| 612 |
-
):
|
| 613 |
-
try:
|
| 614 |
-
cand = rewrite_sentence_synonyms(
|
| 615 |
-
syn_src,
|
| 616 |
-
syn_str,
|
| 617 |
-
rng,
|
| 618 |
-
tone=tone,
|
| 619 |
-
force_all_lexicon=force,
|
| 620 |
-
)
|
| 621 |
-
cand = apply_tone_contractions(cand, tone)
|
| 622 |
-
cand = tidy(correct_text(cand) if cand else "")
|
| 623 |
-
if cand.strip():
|
| 624 |
-
trials.append(cand)
|
| 625 |
-
trials.extend(_apply_phrase_rewrites(cand))
|
| 626 |
-
trials.extend(_esl_structural_variants(cand))
|
| 627 |
-
except Exception:
|
| 628 |
-
continue
|
| 629 |
-
|
| 630 |
-
seen: set[str] = set()
|
| 631 |
-
uniq: list[str] = []
|
| 632 |
-
for t in trials:
|
| 633 |
-
key = t.strip().lower()
|
| 634 |
-
if not key or key in seen:
|
| 635 |
-
continue
|
| 636 |
-
seen.add(key)
|
| 637 |
-
uniq.append(t.strip())
|
| 638 |
-
|
| 639 |
-
best = _ensure_unit_terminal(source_unit)
|
| 640 |
-
best_score = -1.0
|
| 641 |
-
for trial in uniq:
|
| 642 |
-
ok, sim = _safe_paraphrase_trial(source_unit, trial)
|
| 643 |
-
if not ok:
|
| 644 |
-
continue
|
| 645 |
-
t = _ensure_unit_terminal(
|
| 646 |
-
_clamp_to_source_shape(source_unit, trial.strip()) or trial.strip()
|
| 647 |
-
)
|
| 648 |
-
score = _rewrite_score(source_unit, t)
|
| 649 |
-
if score > best_score or (
|
| 650 |
-
abs(score - best_score) < 1e-9 and sim < _similarity_ratio(source_unit, best)
|
| 651 |
-
):
|
| 652 |
-
best = t
|
| 653 |
-
best_score = score
|
| 654 |
-
|
| 655 |
-
# Phrase polish only for lexical winners — never undo a structural reorder
|
| 656 |
-
if best_score >= 0 and not _is_structural_reorder(source_unit, best):
|
| 657 |
-
for polished in _apply_phrase_rewrites(best, max_swaps=4):
|
| 658 |
-
if not _is_true_rewrite(source_unit, polished):
|
| 659 |
-
continue
|
| 660 |
-
if _is_structural_reorder(source_unit, polished):
|
| 661 |
-
continue
|
| 662 |
-
t = _ensure_unit_terminal(
|
| 663 |
-
_clamp_to_source_shape(source_unit, polished.strip())
|
| 664 |
-
or polished.strip()
|
| 665 |
-
)
|
| 666 |
-
score = _rewrite_score(source_unit, t)
|
| 667 |
-
if score > best_score:
|
| 668 |
-
best = t
|
| 669 |
-
best_score = score
|
| 670 |
-
return best
|
| 671 |
-
|
| 672 |
-
|
| 673 |
-
def _light_revert_polish(
|
| 674 |
-
source_unit: str,
|
| 675 |
-
tone: str,
|
| 676 |
-
rng: random.Random,
|
| 677 |
-
*,
|
| 678 |
-
strength: int = 0,
|
| 679 |
-
) -> str:
|
| 680 |
-
"""Backward-compatible name — now runs true lexical paraphrase."""
|
| 681 |
-
return _visible_unit_paraphrase(
|
| 682 |
-
source_unit,
|
| 683 |
-
tone,
|
| 684 |
-
rng,
|
| 685 |
-
strength=max(2, strength + 1),
|
| 686 |
-
target_sim=_VISIBLE_TARGET_SIM,
|
| 687 |
-
)
|
| 688 |
-
|
| 689 |
-
|
| 690 |
-
def _try_restructure_unit(source_unit: str) -> str | None:
|
| 691 |
-
"""Return a safe dep+template reorder, or None if unavailable."""
|
| 692 |
-
src = (source_unit or "").strip()
|
| 693 |
-
if not src or len(src.split()) < 3:
|
| 694 |
-
return None
|
| 695 |
-
try:
|
| 696 |
-
base = tidy(correct_text(src) if src else "") or src
|
| 697 |
-
rr = restructure_sentence(base, min_confidence=0.55)
|
| 698 |
-
if not rr or not rr.text.strip():
|
| 699 |
-
return None
|
| 700 |
-
cand = tidy(correct_text(rr.text) if rr.text else rr.text)
|
| 701 |
-
cand = _ensure_unit_terminal(
|
| 702 |
-
_clamp_to_source_shape(src, cand) or cand
|
| 703 |
-
)
|
| 704 |
-
if not (_is_structural_reorder(src, cand) or _is_true_rewrite(src, cand)):
|
| 705 |
-
return None
|
| 706 |
-
if not _paraphrase_hard_ok(src, cand):
|
| 707 |
-
return None
|
| 708 |
-
return cand
|
| 709 |
-
except Exception:
|
| 710 |
-
return None
|
| 711 |
-
|
| 712 |
-
|
| 713 |
-
def _require_visible_rewrite(
|
| 714 |
-
source_unit: str,
|
| 715 |
-
candidate: str,
|
| 716 |
-
tone: str,
|
| 717 |
-
rng: random.Random,
|
| 718 |
-
*,
|
| 719 |
-
target_sim: float = _VISIBLE_TARGET_SIM,
|
| 720 |
-
min_words: int = 4,
|
| 721 |
-
) -> str:
|
| 722 |
-
"""Prefer structural reorder; otherwise force a visible true rewrite."""
|
| 723 |
-
src = (source_unit or "").strip()
|
| 724 |
-
cand = (candidate or src).strip() or src
|
| 725 |
-
if len(src.split()) < min_words:
|
| 726 |
-
return _ensure_unit_terminal(cand)
|
| 727 |
-
|
| 728 |
-
# Structural reorder is the preferred finished product — do not synonym-polish it
|
| 729 |
-
if _is_structural_reorder(src, cand) and _paraphrase_hard_ok(src, cand):
|
| 730 |
-
return _ensure_unit_terminal(cand)
|
| 731 |
-
|
| 732 |
-
if _is_true_rewrite(src, cand) and _similarity_ratio(src, cand) <= target_sim:
|
| 733 |
-
return _ensure_unit_terminal(cand)
|
| 734 |
-
|
| 735 |
-
# Prefer dep+template restructure before classical/LM thrash
|
| 736 |
-
restructured = _try_restructure_unit(src)
|
| 737 |
-
if restructured:
|
| 738 |
-
return restructured
|
| 739 |
-
|
| 740 |
-
# Classical true rewrite from source (ignore weak LM near-copies)
|
| 741 |
-
trials = [
|
| 742 |
-
_visible_unit_paraphrase(src, tone, rng, strength=2, target_sim=target_sim),
|
| 743 |
-
_visible_unit_paraphrase(src, tone, rng, strength=2, target_sim=0.78),
|
| 744 |
-
]
|
| 745 |
-
best = _ensure_unit_terminal(cand)
|
| 746 |
-
best_score = -1.0
|
| 747 |
-
if _is_true_rewrite(src, best):
|
| 748 |
-
best_score = _rewrite_score(src, best)
|
| 749 |
-
for trial in trials:
|
| 750 |
-
if not _is_true_rewrite(src, trial):
|
| 751 |
-
continue
|
| 752 |
-
t = _ensure_unit_terminal(
|
| 753 |
-
_clamp_to_source_shape(src, trial.strip()) or trial.strip()
|
| 754 |
-
)
|
| 755 |
-
score = _rewrite_score(src, t)
|
| 756 |
-
if score > best_score:
|
| 757 |
-
best = t
|
| 758 |
-
best_score = score
|
| 759 |
-
return best
|
| 760 |
-
|
| 761 |
-
|
| 762 |
-
def _boost_if_near_copy(
|
| 763 |
-
source_unit: str,
|
| 764 |
-
candidate: str,
|
| 765 |
-
tone: str,
|
| 766 |
-
rng: random.Random,
|
| 767 |
-
*,
|
| 768 |
-
threshold: float = _VISIBLE_TARGET_SIM,
|
| 769 |
-
) -> str:
|
| 770 |
-
"""If a kept unit is still nearly identical, force a true rewrite."""
|
| 771 |
-
return _require_visible_rewrite(
|
| 772 |
-
source_unit,
|
| 773 |
-
candidate,
|
| 774 |
-
tone,
|
| 775 |
-
rng,
|
| 776 |
-
target_sim=min(threshold, _VISIBLE_TARGET_SIM),
|
| 777 |
-
)
|
| 778 |
-
|
| 779 |
-
|
| 780 |
-
def _assemble_hybrid_units(
|
| 781 |
-
source_units: list[tuple[int, str]],
|
| 782 |
-
outputs: list[str],
|
| 783 |
-
) -> str:
|
| 784 |
-
"""Join one output per source unit; never drop a unit."""
|
| 785 |
-
if len(outputs) != len(source_units):
|
| 786 |
-
# Repair: pad with source text
|
| 787 |
-
fixed = list(outputs)
|
| 788 |
-
while len(fixed) < len(source_units):
|
| 789 |
-
fixed.append(source_units[len(fixed)][1])
|
| 790 |
-
outputs = fixed[: len(source_units)]
|
| 791 |
-
|
| 792 |
-
by_para: dict[int, list[str]] = {}
|
| 793 |
-
for (para_idx, src_u), out in zip(source_units, outputs):
|
| 794 |
-
piece = _ensure_unit_terminal((out or src_u).strip() or src_u)
|
| 795 |
-
by_para.setdefault(para_idx, []).append(piece)
|
| 796 |
-
paras = [" ".join(by_para[i]).strip() for i in sorted(by_para.keys()) if by_para[i]]
|
| 797 |
-
return tidy("\n\n".join(p for p in paras if p))
|
| 798 |
-
|
| 799 |
-
|
| 800 |
-
def _sentence_count_simple(text: str) -> int:
|
| 801 |
-
parts = re.split(r"[.!?]+", (text or "").strip())
|
| 802 |
-
return len([p for p in parts if p.strip()])
|
| 803 |
-
|
| 804 |
-
|
| 805 |
-
def _clamp_to_source_shape(source_unit: str, candidate: str) -> str:
|
| 806 |
-
"""Keep LM output to the same sentence count as the source unit (1→1)."""
|
| 807 |
-
c = (candidate or "").strip()
|
| 808 |
-
if not c:
|
| 809 |
-
return ""
|
| 810 |
-
o_n = _sentence_count_simple(source_unit)
|
| 811 |
-
c_n = _sentence_count_simple(c)
|
| 812 |
-
if o_n == 1 and c_n != 1:
|
| 813 |
-
# Take first sentence only; if still multi or empty, reject
|
| 814 |
-
first = re.split(r"(?<=[.!?])\s+", c, maxsplit=1)[0].strip()
|
| 815 |
-
if _sentence_count_simple(first) == 1 and first:
|
| 816 |
-
return first
|
| 817 |
-
return ""
|
| 818 |
-
if o_n >= 1 and c_n < o_n:
|
| 819 |
-
return ""
|
| 820 |
-
return c
|
| 821 |
-
|
| 822 |
-
|
| 823 |
-
def _classical_paragraph(paragraph: str, tone: str, strength: int, rng: random.Random) -> str:
|
| 824 |
-
"""Offline fallback: structure + curated lexicon (not the primary quality path)."""
|
| 825 |
-
styled = apply_tone_style(paragraph, tone, strength, rng)
|
| 826 |
-
structured = rewrite_paragraph_structure(styled, strength, rng, tone=tone)
|
| 827 |
-
nlp = get_nlp()
|
| 828 |
-
if nlp is not None:
|
| 829 |
-
sents = [s.text.strip() for s in nlp(structured).sents if s.text.strip()]
|
| 830 |
-
else:
|
| 831 |
-
sents = split_sentences_regex(structured)
|
| 832 |
-
rewritten_sents = [
|
| 833 |
-
rewrite_sentence_synonyms(s, strength, rng, tone=tone) for s in sents
|
| 834 |
-
]
|
| 835 |
-
joined = " ".join(rewritten_sents)
|
| 836 |
-
joined = scrub_phrases(joined)
|
| 837 |
-
joined = apply_tone_style(joined, tone, strength, rng)
|
| 838 |
-
joined = apply_tone_contractions(joined, tone)
|
| 839 |
-
return joined.strip()
|
| 840 |
-
|
| 841 |
-
|
| 842 |
-
def _classical_rewrite(
|
| 843 |
-
source: str,
|
| 844 |
-
tone: str,
|
| 845 |
-
strength: int,
|
| 846 |
-
rng: random.Random,
|
| 847 |
-
*,
|
| 848 |
-
force: bool = False,
|
| 849 |
-
) -> str:
|
| 850 |
-
paragraphs = split_paragraphs(source)
|
| 851 |
-
out = [_classical_paragraph(p, tone, strength, rng) for p in paragraphs]
|
| 852 |
-
rewritten = tidy("\n\n".join(out))
|
| 853 |
-
rewritten = scrub_phrases(rewritten)
|
| 854 |
-
rewritten = apply_tone_style(rewritten, tone, strength, rng)
|
| 855 |
-
rewritten = apply_tone_contractions(rewritten, tone)
|
| 856 |
-
if force:
|
| 857 |
-
bump = min(2, strength + 1)
|
| 858 |
-
paras = split_paragraphs(rewritten)
|
| 859 |
-
forced: list[str] = []
|
| 860 |
-
for para in paras:
|
| 861 |
-
bumped = apply_tone_style(para, tone, bump, rng)
|
| 862 |
-
bumped = rewrite_paragraph_structure(bumped, bump, rng, tone=tone)
|
| 863 |
-
nlp = get_nlp()
|
| 864 |
-
if nlp is not None:
|
| 865 |
-
sents = [s.text.strip() for s in nlp(bumped).sents if s.text.strip()]
|
| 866 |
-
else:
|
| 867 |
-
sents = split_sentences_regex(bumped)
|
| 868 |
-
rewritten_sents = [
|
| 869 |
-
rewrite_sentence_synonyms(
|
| 870 |
-
s, bump, rng, tone=tone, force_all_lexicon=True
|
| 871 |
-
)
|
| 872 |
-
for s in sents
|
| 873 |
-
]
|
| 874 |
-
joined = scrub_phrases(" ".join(rewritten_sents))
|
| 875 |
-
joined = apply_tone_contractions(joined, tone)
|
| 876 |
-
forced.append(joined.strip())
|
| 877 |
-
rewritten = tidy("\n\n".join(p for p in forced if p))
|
| 878 |
-
return rewritten
|
| 879 |
-
|
| 880 |
-
|
| 881 |
-
def _hard_fail(reasons: list[str]) -> bool:
|
| 882 |
-
for r in reasons:
|
| 883 |
-
if r in {
|
| 884 |
-
"polarity",
|
| 885 |
-
"numbers",
|
| 886 |
-
"quotes",
|
| 887 |
-
"length",
|
| 888 |
-
"coverage",
|
| 889 |
-
"key_content",
|
| 890 |
-
"topic_anchor",
|
| 891 |
-
"entity_inject",
|
| 892 |
-
"drift",
|
| 893 |
-
"broken",
|
| 894 |
-
"grammar_worse",
|
| 895 |
-
"invention",
|
| 896 |
-
"collocation",
|
| 897 |
-
"shape",
|
| 898 |
-
}:
|
| 899 |
-
return True
|
| 900 |
-
if r.startswith("meaning") or r.startswith("entity"):
|
| 901 |
-
return True
|
| 902 |
-
return False
|
| 903 |
-
|
| 904 |
-
|
| 905 |
-
def _needs_patch(
|
| 906 |
-
source_unit: str,
|
| 907 |
-
candidate: str,
|
| 908 |
-
*,
|
| 909 |
-
max_sim: float = 0.88,
|
| 910 |
-
) -> tuple[bool, list[str]]:
|
| 911 |
-
"""Return (needs_regen, reasons). Near-copy, weak rewrite, or validation failure → patch."""
|
| 912 |
-
if not candidate.strip():
|
| 913 |
-
return True, ["empty"]
|
| 914 |
-
if not source_unit.strip():
|
| 915 |
-
return False, []
|
| 916 |
-
# Stricter near-copy bar for hybrid so more units get generative paraphrase
|
| 917 |
-
v = validate_candidate(source_unit, candidate, min_meaning=0.80, max_surface=max_sim)
|
| 918 |
-
surf = v.surface_sim
|
| 919 |
-
reasons = list(v.reasons)
|
| 920 |
-
if v.ok and surf >= 0.88:
|
| 921 |
-
return True, ["weak_rewrite"]
|
| 922 |
-
if v.ok:
|
| 923 |
-
return False, []
|
| 924 |
-
return True, reasons
|
| 925 |
-
|
| 926 |
-
|
| 927 |
-
def _safe_classical_unit(source_unit: str, candidate: str) -> str:
|
| 928 |
-
"""Grammar-clean classical unit; revert to source on hard meaning failure."""
|
| 929 |
-
cleaned = tidy(correct_text(candidate) if candidate else "")
|
| 930 |
-
if not cleaned.strip():
|
| 931 |
-
return source_unit
|
| 932 |
-
v = validate_candidate(source_unit, cleaned, min_meaning=0.68, max_surface=0.99)
|
| 933 |
-
if _hard_fail(v.reasons):
|
| 934 |
-
return source_unit
|
| 935 |
-
return cleaned
|
| 936 |
-
|
| 937 |
-
|
| 938 |
-
def _accept_generative(source: str, draft: str) -> bool:
|
| 939 |
-
"""Whole-document gate: polarity/meaning must hold vs grammar-corrected source."""
|
| 940 |
-
if not draft or not draft.strip():
|
| 941 |
-
return False
|
| 942 |
-
|
| 943 |
-
s_paras = split_paragraphs(source)
|
| 944 |
-
d_paras = split_paragraphs(draft)
|
| 945 |
-
if len(s_paras) == len(d_paras) and s_paras:
|
| 946 |
-
for sp, dp in zip(s_paras, d_paras):
|
| 947 |
-
if sp.strip() == dp.strip():
|
| 948 |
-
continue
|
| 949 |
-
v = validate_candidate(sp, dp, min_meaning=0.78)
|
| 950 |
-
if not v.ok and _hard_fail(v.reasons):
|
| 951 |
-
logger.info("Generative para rejected: %s", v.reasons)
|
| 952 |
-
return False
|
| 953 |
-
return True
|
| 954 |
-
v = validate_candidate(source, draft, min_meaning=0.78)
|
| 955 |
-
if v.ok or "identical" in v.reasons:
|
| 956 |
-
return True
|
| 957 |
-
if _hard_fail(v.reasons):
|
| 958 |
-
logger.info("Generative doc rejected: %s", v.reasons)
|
| 959 |
-
return False
|
| 960 |
-
return True
|
| 961 |
-
|
| 962 |
-
|
| 963 |
-
def _looks_needs_lm(text: str, strength: int) -> bool:
|
| 964 |
-
"""At Normal+, send almost all claim units to LM (short phrases still skipped)."""
|
| 965 |
-
words = text.split()
|
| 966 |
-
if len(words) < 4:
|
| 967 |
-
return False
|
| 968 |
-
if strength <= 0:
|
| 969 |
-
return False
|
| 970 |
-
# Residual ESL / awkward patterns after grammar — always send to LM
|
| 971 |
-
low = text.lower()
|
| 972 |
-
residual = (
|
| 973 |
-
"peoples",
|
| 974 |
-
"enough times",
|
| 975 |
-
"don't realizes",
|
| 976 |
-
"does realizes",
|
| 977 |
-
"it affect ",
|
| 978 |
-
"to gets ",
|
| 979 |
-
"to obtains ",
|
| 980 |
-
"a person exercise ",
|
| 981 |
-
"foods are becoming",
|
| 982 |
-
"prefers watching",
|
| 983 |
-
)
|
| 984 |
-
if any(m in low for m in residual):
|
| 985 |
-
return True
|
| 986 |
-
# Normal+: rewrite short claims too (was ≥8 — caused grammar-only sleep example)
|
| 987 |
-
if strength >= 1 and len(words) >= 4:
|
| 988 |
-
return True
|
| 989 |
-
return strength >= 2
|
| 990 |
-
|
| 991 |
-
|
| 992 |
-
def _accept_lm_unit(
|
| 993 |
-
source_unit: str, candidate: str, *, max_sim: float = _LM_SHIP_MAX_SIM
|
| 994 |
-
) -> tuple[bool, list[str]]:
|
| 995 |
-
"""Strict gate for a single LM-rewritten sentence — must be a true rewrite."""
|
| 996 |
-
if not candidate or not candidate.strip():
|
| 997 |
-
return False, ["empty"]
|
| 998 |
-
o_n = _sentence_count_simple(source_unit)
|
| 999 |
-
c_n = _sentence_count_simple(candidate)
|
| 1000 |
-
if o_n == 1 and c_n != 1:
|
| 1001 |
-
return False, ["shape"]
|
| 1002 |
-
if o_n >= 1 and c_n < o_n:
|
| 1003 |
-
return False, ["shape"]
|
| 1004 |
-
sim = _similarity_ratio(source_unit, candidate)
|
| 1005 |
-
if sim >= max_sim or sim >= _LM_SHIP_MAX_SIM:
|
| 1006 |
-
return False, ["near_copy"]
|
| 1007 |
-
# Must be a true lexical rewrite (not grammar/reorder)
|
| 1008 |
-
if not _is_true_rewrite(source_unit, candidate):
|
| 1009 |
-
return False, ["near_copy"]
|
| 1010 |
-
v = validate_candidate(
|
| 1011 |
-
source_unit, candidate, min_meaning=0.72, max_surface=max_sim
|
| 1012 |
-
)
|
| 1013 |
-
soft = {
|
| 1014 |
-
"too_similar",
|
| 1015 |
-
"identical",
|
| 1016 |
-
"near_copy",
|
| 1017 |
-
"too_divergent",
|
| 1018 |
-
"key_content",
|
| 1019 |
-
"coverage",
|
| 1020 |
-
"drift",
|
| 1021 |
-
"grammar_worse",
|
| 1022 |
-
"collocation",
|
| 1023 |
-
}
|
| 1024 |
-
hard_keys = [r.split(":")[0] for r in v.reasons if r.split(":")[0] not in soft]
|
| 1025 |
-
if _hard_fail(hard_keys):
|
| 1026 |
-
return False, list(v.reasons) or ["reject"]
|
| 1027 |
-
return True, []
|
| 1028 |
-
|
| 1029 |
-
|
| 1030 |
-
def _hybrid_rewrite(
|
| 1031 |
-
source: str,
|
| 1032 |
-
tone: str,
|
| 1033 |
-
strength: int,
|
| 1034 |
-
rng: random.Random,
|
| 1035 |
-
*,
|
| 1036 |
-
can_generate: bool,
|
| 1037 |
-
) -> tuple[str, HybridStats, bool]:
|
| 1038 |
-
"""
|
| 1039 |
-
Reorder-first hybrid (dynamic documents):
|
| 1040 |
-
|
| 1041 |
-
grammar-corrected source
|
| 1042 |
-
→ dep+template restructure when confident (skip LM)
|
| 1043 |
-
→ skip clean/short units (latency)
|
| 1044 |
-
→ per-sentence Small LM rewrite for leftovers
|
| 1045 |
-
→ strict validate (shape/invention/coverage)
|
| 1046 |
-
→ accept | retry | classical visible rewrite (1:1 unit invariant)
|
| 1047 |
-
|
| 1048 |
-
Classical lexicon is used when generative is unavailable
|
| 1049 |
-
(and LEXICON_FALLBACK is on), still preferring restructure.
|
| 1050 |
-
|
| 1051 |
-
Returns (text, stats, used_any_gen).
|
| 1052 |
-
"""
|
| 1053 |
-
stats = HybridStats()
|
| 1054 |
-
used_gen = False
|
| 1055 |
-
|
| 1056 |
-
# --- Offline path: generative unavailable → classical lexicon fallback ---
|
| 1057 |
-
if not can_generate:
|
| 1058 |
-
if not LEXICON_FALLBACK:
|
| 1059 |
-
stats.units = len(iter_source_units(source)) or 1
|
| 1060 |
-
stats.reverted_source = stats.units
|
| 1061 |
-
return source, stats, False
|
| 1062 |
-
baseline = tidy(correct_text(_classical_rewrite(source, tone, strength, rng)))
|
| 1063 |
-
units = align_documents(source, baseline)
|
| 1064 |
-
by_para: dict[int, list[str]] = {}
|
| 1065 |
-
for unit in units:
|
| 1066 |
-
stats.units += 1
|
| 1067 |
-
src_u = unit.source.strip()
|
| 1068 |
-
cand_u = unit.candidate.strip()
|
| 1069 |
-
if not src_u:
|
| 1070 |
-
if cand_u:
|
| 1071 |
-
by_para.setdefault(unit.paragraph_index, []).append(cand_u)
|
| 1072 |
-
continue
|
| 1073 |
-
safe = _safe_classical_unit(src_u, cand_u)
|
| 1074 |
-
if strength >= 1 and len(src_u.split()) >= 4:
|
| 1075 |
-
safe = _require_visible_rewrite(
|
| 1076 |
-
src_u, safe, tone, rng, target_sim=_VISIBLE_TARGET_SIM
|
| 1077 |
-
)
|
| 1078 |
-
if _similarity_ratio(src_u, safe) > 0.92:
|
| 1079 |
-
stats.reverted_source += 1
|
| 1080 |
-
else:
|
| 1081 |
-
stats.classical_kept += 1
|
| 1082 |
-
by_para.setdefault(unit.paragraph_index, []).append(safe)
|
| 1083 |
-
# Assembly invariant: every aligned source unit produced an output
|
| 1084 |
-
paras_out = [
|
| 1085 |
-
" ".join(by_para[i]).strip()
|
| 1086 |
-
for i in sorted(by_para.keys())
|
| 1087 |
-
if by_para[i]
|
| 1088 |
-
]
|
| 1089 |
-
text = tidy(correct_text(_light_post_gen("\n\n".join(p for p in paras_out if p), tone)))
|
| 1090 |
-
return text, stats, False
|
| 1091 |
-
|
| 1092 |
-
# --- Primary path: restructure first, then Small LM → validate ---
|
| 1093 |
-
source_units = iter_source_units(source)
|
| 1094 |
-
if not source_units:
|
| 1095 |
-
return source, stats, False
|
| 1096 |
-
|
| 1097 |
-
lm_budget = int(GENERATIVE_MAX_PARAGRAPHS)
|
| 1098 |
-
unlimited = lm_budget <= 0
|
| 1099 |
-
lm_used = 0
|
| 1100 |
-
|
| 1101 |
-
outputs: list[str] = []
|
| 1102 |
-
for para_idx, src_u in source_units:
|
| 1103 |
-
stats.units += 1
|
| 1104 |
-
# ALWAYS emit exactly one string for this source unit (no drops)
|
| 1105 |
-
chosen = src_u
|
| 1106 |
-
|
| 1107 |
-
# Reorder-first: skip LM when dep+template restructure succeeds
|
| 1108 |
-
if strength >= 1 and len(src_u.split()) >= 4:
|
| 1109 |
-
restructured = _try_restructure_unit(src_u)
|
| 1110 |
-
if restructured:
|
| 1111 |
-
stats.bump_reason("restructure")
|
| 1112 |
-
stats.classical_kept += 1
|
| 1113 |
-
outputs.append(_ensure_unit_terminal(restructured))
|
| 1114 |
-
continue
|
| 1115 |
-
|
| 1116 |
-
if not _looks_needs_lm(src_u, strength):
|
| 1117 |
-
stats.skipped += 1
|
| 1118 |
-
# Tiny leftovers: still force visible rewrite at Normal+ when possible
|
| 1119 |
-
if strength >= 1 and len(src_u.split()) >= 4:
|
| 1120 |
-
chosen = _require_visible_rewrite(
|
| 1121 |
-
src_u, src_u, tone, rng, target_sim=_VISIBLE_TARGET_SIM
|
| 1122 |
-
)
|
| 1123 |
-
outputs.append(_ensure_unit_terminal(chosen))
|
| 1124 |
-
continue
|
| 1125 |
-
|
| 1126 |
-
if not unlimited and lm_used >= lm_budget:
|
| 1127 |
-
stats.skipped += 1
|
| 1128 |
-
outputs.append(
|
| 1129 |
-
_ensure_unit_terminal(
|
| 1130 |
-
_require_visible_rewrite(
|
| 1131 |
-
src_u,
|
| 1132 |
-
_light_revert_polish(src_u, tone, rng),
|
| 1133 |
-
tone,
|
| 1134 |
-
rng,
|
| 1135 |
-
target_sim=_VISIBLE_TARGET_SIM,
|
| 1136 |
-
)
|
| 1137 |
-
)
|
| 1138 |
-
)
|
| 1139 |
-
continue
|
| 1140 |
-
|
| 1141 |
-
stats.regenerated += 1
|
| 1142 |
-
lm_used += 1
|
| 1143 |
-
mark_gen_used()
|
| 1144 |
-
used_gen = True
|
| 1145 |
-
|
| 1146 |
-
gen_raw = rewrite_unit(
|
| 1147 |
-
src_u,
|
| 1148 |
-
tone=tone,
|
| 1149 |
-
strength=max(1, strength),
|
| 1150 |
-
min_meaning=0.80,
|
| 1151 |
-
max_sim=_LM_SHIP_MAX_SIM,
|
| 1152 |
-
)
|
| 1153 |
-
gen_clean = tidy(correct_text(gen_raw) if gen_raw else "")
|
| 1154 |
-
gen_clean = _clamp_to_source_shape(src_u, gen_clean)
|
| 1155 |
-
ok, reasons = _accept_lm_unit(src_u, gen_clean, max_sim=_LM_SHIP_MAX_SIM)
|
| 1156 |
-
|
| 1157 |
-
if ok:
|
| 1158 |
-
stats.gen_accepted += 1
|
| 1159 |
-
chosen = gen_clean
|
| 1160 |
-
else:
|
| 1161 |
-
soft = not _hard_fail([r.split(":")[0] for r in reasons])
|
| 1162 |
-
if soft or "near_copy" in reasons or "too_similar" in reasons:
|
| 1163 |
-
gen_raw2 = rewrite_unit(
|
| 1164 |
-
src_u,
|
| 1165 |
-
tone=tone,
|
| 1166 |
-
strength=2,
|
| 1167 |
-
min_meaning=0.78,
|
| 1168 |
-
max_sim=0.85,
|
| 1169 |
-
)
|
| 1170 |
-
gen_clean2 = tidy(correct_text(gen_raw2) if gen_raw2 else "")
|
| 1171 |
-
gen_clean2 = _clamp_to_source_shape(src_u, gen_clean2)
|
| 1172 |
-
ok2, reasons2 = _accept_lm_unit(src_u, gen_clean2, max_sim=0.85)
|
| 1173 |
-
if ok2:
|
| 1174 |
-
stats.gen_accepted += 1
|
| 1175 |
-
chosen = gen_clean2
|
| 1176 |
-
else:
|
| 1177 |
-
for r in reasons2:
|
| 1178 |
-
stats.bump_reason(r)
|
| 1179 |
-
stats.reverted_source += 1
|
| 1180 |
-
logger.info(
|
| 1181 |
-
"LM unit rejected (%s); visible paraphrase on source unit",
|
| 1182 |
-
reasons2 or reasons or ["near_copy"],
|
| 1183 |
-
)
|
| 1184 |
-
chosen = _light_revert_polish(src_u, tone, rng, strength=1)
|
| 1185 |
-
else:
|
| 1186 |
-
for r in reasons:
|
| 1187 |
-
stats.bump_reason(r)
|
| 1188 |
-
stats.reverted_source += 1
|
| 1189 |
-
logger.info(
|
| 1190 |
-
"LM unit hard-rejected (%s); visible paraphrase on source unit",
|
| 1191 |
-
reasons,
|
| 1192 |
-
)
|
| 1193 |
-
chosen = _light_revert_polish(src_u, tone, rng, strength=1)
|
| 1194 |
-
|
| 1195 |
-
if not (chosen or "").strip():
|
| 1196 |
-
chosen = src_u
|
| 1197 |
-
chosen = _clamp_to_source_shape(src_u, chosen) or src_u
|
| 1198 |
-
# Never let an accepted unit expand into multiple sentences
|
| 1199 |
-
if _sentence_count_simple(chosen) != _sentence_count_simple(src_u):
|
| 1200 |
-
if _sentence_count_simple(src_u) == 1:
|
| 1201 |
-
chosen = _light_revert_polish(src_u, tone, rng, strength=1)
|
| 1202 |
-
else:
|
| 1203 |
-
chosen = src_u
|
| 1204 |
-
# Must not ship grammar-only / near-copy — force visible rewrite
|
| 1205 |
-
chosen = _require_visible_rewrite(
|
| 1206 |
-
src_u, chosen, tone, rng, target_sim=_VISIBLE_TARGET_SIM
|
| 1207 |
-
)
|
| 1208 |
-
outputs.append(_ensure_unit_terminal(chosen))
|
| 1209 |
-
|
| 1210 |
-
if len(outputs) != len(source_units):
|
| 1211 |
-
logger.warning(
|
| 1212 |
-
"Assembly invariant broken (%s out vs %s in); repairing from source",
|
| 1213 |
-
len(outputs),
|
| 1214 |
-
len(source_units),
|
| 1215 |
-
)
|
| 1216 |
-
while len(outputs) < len(source_units):
|
| 1217 |
-
outputs.append(_ensure_unit_terminal(source_units[len(outputs)][1]))
|
| 1218 |
-
outputs = outputs[: len(source_units)]
|
| 1219 |
-
|
| 1220 |
-
# Per-slot sanity: empty/too-short slots → source unit then forced rewrite
|
| 1221 |
-
fixed_outs: list[str] = []
|
| 1222 |
-
for (_pi, src_u), out in zip(source_units, outputs):
|
| 1223 |
-
piece = (out or "").strip()
|
| 1224 |
-
if (
|
| 1225 |
-
not piece
|
| 1226 |
-
or _sentence_count_simple(piece) < _sentence_count_simple(src_u)
|
| 1227 |
-
or len(piece.split()) < max(3, int(len(src_u.split()) * 0.55))
|
| 1228 |
-
):
|
| 1229 |
-
piece = _require_visible_rewrite(
|
| 1230 |
-
src_u, src_u, tone, rng, target_sim=_VISIBLE_TARGET_SIM
|
| 1231 |
-
)
|
| 1232 |
-
stats.bump_reason("unit_restore")
|
| 1233 |
-
fixed_outs.append(_ensure_unit_terminal(piece))
|
| 1234 |
-
outputs = fixed_outs
|
| 1235 |
-
|
| 1236 |
-
# Doc-level: any remaining near-copies get forced paraphrase
|
| 1237 |
-
boosted_outs: list[str] = []
|
| 1238 |
-
for (_pi, src_u), out in zip(source_units, outputs):
|
| 1239 |
-
if (
|
| 1240 |
-
strength >= 1
|
| 1241 |
-
and len(src_u.split()) >= 4
|
| 1242 |
-
and _similarity_ratio(src_u, out) > _VISIBLE_TARGET_SIM
|
| 1243 |
-
):
|
| 1244 |
-
boosted_outs.append(
|
| 1245 |
-
_require_visible_rewrite(
|
| 1246 |
-
src_u, out, tone, rng, target_sim=_VISIBLE_TARGET_SIM
|
| 1247 |
-
)
|
| 1248 |
-
)
|
| 1249 |
-
stats.bump_reason("near_copy_boost")
|
| 1250 |
-
else:
|
| 1251 |
-
boosted_outs.append(out)
|
| 1252 |
-
outputs = boosted_outs
|
| 1253 |
-
|
| 1254 |
-
# Assemble ONLY from per-unit strings — no document-level grammar here
|
| 1255 |
-
text = _assemble_hybrid_units(source_units, outputs)
|
| 1256 |
-
scrubbed = tidy(_light_post_gen(text, tone))
|
| 1257 |
-
if len(iter_source_units(scrubbed)) < len(source_units):
|
| 1258 |
-
logger.warning(
|
| 1259 |
-
"Post-scrub dropped units (%s→%s); keeping assembled text",
|
| 1260 |
-
len(source_units),
|
| 1261 |
-
len(iter_source_units(scrubbed)),
|
| 1262 |
-
)
|
| 1263 |
-
text = _assemble_hybrid_units(source_units, outputs)
|
| 1264 |
-
else:
|
| 1265 |
-
text = scrubbed
|
| 1266 |
-
# Final hard guarantee — never ship fewer units than source
|
| 1267 |
-
if len(iter_source_units(text)) < len(source_units):
|
| 1268 |
-
logger.warning("Final hybrid text lost units; restoring assembled outputs")
|
| 1269 |
-
text = _assemble_hybrid_units(source_units, outputs)
|
| 1270 |
-
if len(iter_source_units(text)) < len(source_units):
|
| 1271 |
-
text = _assemble_hybrid_units(
|
| 1272 |
-
source_units,
|
| 1273 |
-
[_ensure_unit_terminal(u) for _i, u in source_units],
|
| 1274 |
-
)
|
| 1275 |
-
return text, stats, used_gen
|
| 1276 |
|
| 1277 |
|
| 1278 |
def rewrite_text(
|
|
@@ -1284,312 +61,84 @@ def rewrite_text(
|
|
| 1284 |
ml_polish: bool = False,
|
| 1285 |
) -> RewriteResult:
|
| 1286 |
"""
|
| 1287 |
-
|
| 1288 |
|
| 1289 |
-
|
| 1290 |
-
|
| 1291 |
-
|
| 1292 |
-
PIPELINE_MODE=classical / ml_polish off: classical lexicon path
|
| 1293 |
"""
|
|
|
|
| 1294 |
started = time.perf_counter()
|
| 1295 |
-
original = normalize_whitespace(text or "")
|
| 1296 |
-
if not original:
|
| 1297 |
-
raise ValueError("Paste some text first.")
|
| 1298 |
-
|
| 1299 |
-
source = original
|
| 1300 |
-
grammar_fixed_input = False
|
| 1301 |
-
if GRAMMAR_FIX_INPUT:
|
| 1302 |
-
cleaned = correct_text(original)
|
| 1303 |
-
if cleaned and cleaned.strip():
|
| 1304 |
-
grammar_fixed_input = cleaned.strip() != original.strip()
|
| 1305 |
-
source = normalize_whitespace(cleaned)
|
| 1306 |
-
|
| 1307 |
-
strength = max(0, min(2, int(strength)))
|
| 1308 |
tone = normalize_tone(tone)
|
| 1309 |
-
|
| 1310 |
-
if mode == "classical" and not ml_polish:
|
| 1311 |
-
pass
|
| 1312 |
-
elif not ml_polish:
|
| 1313 |
-
mode = "classical"
|
| 1314 |
-
elif mode == "hybrid" and not GENERATIVE_PRIMARY:
|
| 1315 |
-
# hybrid without gen primary still runs classical+validate; patch only if model loads
|
| 1316 |
-
pass
|
| 1317 |
-
|
| 1318 |
-
gen_ready = bool(ml_polish) and generative_available()
|
| 1319 |
-
want_minilm = bool(ml_polish) and minilm_available()
|
| 1320 |
-
# Skip MiniLM synonym ranking on generative-heavy paths
|
| 1321 |
-
set_ml_polish(want_minilm and mode == "classical")
|
| 1322 |
-
rng = _rng_for(
|
| 1323 |
-
source + "|" + tone + "|" + str(strength) + ("|ml" if ml_polish else "")
|
| 1324 |
-
)
|
| 1325 |
-
|
| 1326 |
-
notes = ""
|
| 1327 |
-
used_gen_main = False
|
| 1328 |
-
hybrid_stats: HybridStats | None = None
|
| 1329 |
-
rewritten = source
|
| 1330 |
-
|
| 1331 |
-
if mode == "hybrid" and ml_polish:
|
| 1332 |
-
logger.info(
|
| 1333 |
-
"Hybrid path: LM-per-sentence → validate (%s, model=%s, backend=%s)…",
|
| 1334 |
-
tone,
|
| 1335 |
-
GENERATIVE_MODEL if gen_ready else "-",
|
| 1336 |
-
GENERATIVE_BACKEND if gen_ready else "-",
|
| 1337 |
-
)
|
| 1338 |
-
rewritten, hybrid_stats, used_gen_main = _hybrid_rewrite(
|
| 1339 |
-
source,
|
| 1340 |
-
tone,
|
| 1341 |
-
strength,
|
| 1342 |
-
rng,
|
| 1343 |
-
can_generate=gen_ready,
|
| 1344 |
-
)
|
| 1345 |
-
if not gen_ready:
|
| 1346 |
-
tip = (
|
| 1347 |
-
"Hybrid: generative unavailable; classical lexicon fallback only."
|
| 1348 |
-
)
|
| 1349 |
-
notes = tip
|
| 1350 |
-
else:
|
| 1351 |
-
tip = (
|
| 1352 |
-
f"Hybrid LM: accepted={hybrid_stats.gen_accepted}/"
|
| 1353 |
-
f"{hybrid_stats.regenerated} "
|
| 1354 |
-
f"skipped={hybrid_stats.skipped} "
|
| 1355 |
-
f"reverted={hybrid_stats.reverted_source} "
|
| 1356 |
-
f"classical_fb={hybrid_stats.classical_kept}"
|
| 1357 |
-
)
|
| 1358 |
-
notes = tip
|
| 1359 |
-
|
| 1360 |
-
elif mode == "generative" and ml_polish and gen_ready:
|
| 1361 |
-
logger.info(
|
| 1362 |
-
"Generative path (%s, model=%s, backend=%s)…",
|
| 1363 |
-
tone,
|
| 1364 |
-
GENERATIVE_MODEL,
|
| 1365 |
-
GENERATIVE_BACKEND,
|
| 1366 |
-
)
|
| 1367 |
-
draft = generative_paraphrase(
|
| 1368 |
-
source,
|
| 1369 |
-
tone=tone,
|
| 1370 |
-
strength=max(1, strength),
|
| 1371 |
-
min_meaning=0.80,
|
| 1372 |
-
max_sim=0.92,
|
| 1373 |
-
)
|
| 1374 |
-
if draft and draft.strip() and _accept_generative(source, draft):
|
| 1375 |
-
sim0 = _similarity_ratio(source, draft)
|
| 1376 |
-
if sim0 >= 0.92:
|
| 1377 |
-
logger.info(
|
| 1378 |
-
"Generative draft too similar (%.3f); trying stronger pass",
|
| 1379 |
-
sim0,
|
| 1380 |
-
)
|
| 1381 |
-
draft2 = generative_paraphrase(
|
| 1382 |
-
source,
|
| 1383 |
-
tone=tone,
|
| 1384 |
-
strength=2,
|
| 1385 |
-
min_meaning=0.78,
|
| 1386 |
-
max_sim=0.90,
|
| 1387 |
-
)
|
| 1388 |
-
if (
|
| 1389 |
-
draft2
|
| 1390 |
-
and draft2.strip()
|
| 1391 |
-
and _accept_generative(source, draft2)
|
| 1392 |
-
and _similarity_ratio(source, draft2) < 0.92
|
| 1393 |
-
):
|
| 1394 |
-
mark_gen_used()
|
| 1395 |
-
used_gen_main = True
|
| 1396 |
-
rewritten = _light_post_gen(tidy(draft2), tone)
|
| 1397 |
-
else:
|
| 1398 |
-
used_gen_main = False
|
| 1399 |
-
tip = (
|
| 1400 |
-
"Generative rewrite stayed a near-copy; "
|
| 1401 |
-
"using classical fallback for visible change."
|
| 1402 |
-
)
|
| 1403 |
-
notes = tip
|
| 1404 |
-
else:
|
| 1405 |
-
mark_gen_used()
|
| 1406 |
-
used_gen_main = True
|
| 1407 |
-
rewritten = _light_post_gen(tidy(draft), tone)
|
| 1408 |
-
if _similarity_ratio(source, rewritten) > 0.90:
|
| 1409 |
-
draft2 = generative_paraphrase(
|
| 1410 |
-
source,
|
| 1411 |
-
tone=tone,
|
| 1412 |
-
strength=2,
|
| 1413 |
-
min_meaning=0.78,
|
| 1414 |
-
max_sim=0.90,
|
| 1415 |
-
)
|
| 1416 |
-
if (
|
| 1417 |
-
draft2
|
| 1418 |
-
and draft2.strip()
|
| 1419 |
-
and _accept_generative(source, draft2)
|
| 1420 |
-
and _similarity_ratio(source, draft2) < 0.92
|
| 1421 |
-
):
|
| 1422 |
-
rewritten = _light_post_gen(tidy(draft2), tone)
|
| 1423 |
-
logger.info(
|
| 1424 |
-
"Generative main path words %s→%s sim=%.3f",
|
| 1425 |
-
word_count(source),
|
| 1426 |
-
word_count(rewritten),
|
| 1427 |
-
_similarity_ratio(source, rewritten),
|
| 1428 |
-
)
|
| 1429 |
-
else:
|
| 1430 |
-
tip = (
|
| 1431 |
-
"Generative rewrite unavailable or failed validation; "
|
| 1432 |
-
"using classical fallback."
|
| 1433 |
-
if draft
|
| 1434 |
-
else "Generative model produced no draft; using classical fallback."
|
| 1435 |
-
)
|
| 1436 |
-
notes = tip
|
| 1437 |
-
used_gen_main = False
|
| 1438 |
-
|
| 1439 |
-
if mode != "hybrid" and not used_gen_main:
|
| 1440 |
-
if want_minilm:
|
| 1441 |
-
set_ml_polish(True)
|
| 1442 |
-
if LEXICON_FALLBACK or not ml_polish:
|
| 1443 |
-
rewritten = _classical_rewrite(source, tone, strength, rng)
|
| 1444 |
-
else:
|
| 1445 |
-
rewritten = source
|
| 1446 |
-
tip = "No generative output and lexicon fallback disabled; returning grammar-corrected source."
|
| 1447 |
-
notes = f"{notes} {tip}".strip() if notes else tip
|
| 1448 |
-
|
| 1449 |
-
if want_minilm:
|
| 1450 |
-
set_ml_polish(True)
|
| 1451 |
-
rewritten = apply_minilm_polish(source, rewritten, tone)
|
| 1452 |
-
rewritten = tidy(rewritten)
|
| 1453 |
-
rewritten = apply_tone_contractions(rewritten, tone)
|
| 1454 |
-
|
| 1455 |
-
ratio_tmp = _similarity_ratio(source, rewritten)
|
| 1456 |
-
if ratio_tmp > 0.88 and LEXICON_FALLBACK:
|
| 1457 |
-
logger.info("Classical near-copy (%.3f); stronger lexicon pass", ratio_tmp)
|
| 1458 |
-
rewritten = _classical_rewrite(source, tone, strength, rng, force=True)
|
| 1459 |
-
if want_minilm:
|
| 1460 |
-
rewritten = apply_minilm_polish(source, rewritten, tone)
|
| 1461 |
-
rewritten = apply_tone_contractions(rewritten, tone)
|
| 1462 |
-
tip = "Applied stronger classical pass (first pass too similar)."
|
| 1463 |
-
notes = f"{notes} {tip}".strip() if notes else tip
|
| 1464 |
-
|
| 1465 |
-
rewritten = enforce_length_budget(source, rewritten, preserve_length)
|
| 1466 |
-
rewritten = tidy(rewritten)
|
| 1467 |
-
|
| 1468 |
-
if used_gen_main and mode == "generative" and not _accept_generative(source, rewritten):
|
| 1469 |
-
logger.info("Final generative output failed validation; reverting to corrected source")
|
| 1470 |
-
rewritten = source
|
| 1471 |
-
tip = "Generative output failed final meaning checks; returned grammar-corrected source."
|
| 1472 |
-
notes = f"{notes} {tip}".strip() if notes else tip
|
| 1473 |
-
used_gen_main = False
|
| 1474 |
-
|
| 1475 |
-
if GRAMMAR_FIX_OUTPUT:
|
| 1476 |
-
cleaned_out = correct_text(rewritten)
|
| 1477 |
-
if cleaned_out and cleaned_out.strip():
|
| 1478 |
-
# Hybrid: never let document-level LT drop sentence units
|
| 1479 |
-
if mode == "hybrid" and hybrid_stats and hybrid_stats.units:
|
| 1480 |
-
n_src = hybrid_stats.units
|
| 1481 |
-
if len(iter_source_units(cleaned_out)) < n_src:
|
| 1482 |
-
logger.warning(
|
| 1483 |
-
"Output grammar dropped units (%s→%s); keeping pre-grammar text",
|
| 1484 |
-
n_src,
|
| 1485 |
-
len(iter_source_units(cleaned_out)),
|
| 1486 |
-
)
|
| 1487 |
-
else:
|
| 1488 |
-
rewritten = tidy(cleaned_out)
|
| 1489 |
-
else:
|
| 1490 |
-
rewritten = tidy(cleaned_out)
|
| 1491 |
-
|
| 1492 |
-
# Nuclear hybrid safety: never return fewer sentence units than the source
|
| 1493 |
-
if mode == "hybrid" and hybrid_stats and hybrid_stats.units:
|
| 1494 |
-
n_src = hybrid_stats.units
|
| 1495 |
-
if len(iter_source_units(rewritten)) < n_src:
|
| 1496 |
-
logger.warning(
|
| 1497 |
-
"Post-pipeline unit loss (%s→%s); restoring from grammar source units",
|
| 1498 |
-
n_src,
|
| 1499 |
-
len(iter_source_units(rewritten)),
|
| 1500 |
-
)
|
| 1501 |
-
src_units = iter_source_units(source)
|
| 1502 |
-
rewritten = _assemble_hybrid_units(
|
| 1503 |
-
src_units,
|
| 1504 |
-
[
|
| 1505 |
-
_ensure_unit_terminal(
|
| 1506 |
-
_light_revert_polish(u, tone, rng, strength=1)
|
| 1507 |
-
)
|
| 1508 |
-
for _i, u in src_units
|
| 1509 |
-
],
|
| 1510 |
-
)
|
| 1511 |
-
|
| 1512 |
-
ratio = _similarity_ratio(source, rewritten)
|
| 1513 |
-
user_sim = _similarity_ratio(original, rewritten)
|
| 1514 |
-
changed = rewritten.strip() != original.strip()
|
| 1515 |
-
|
| 1516 |
-
engine_bits: list[str] = []
|
| 1517 |
-
if GRAMMAR_FIX_INPUT or GRAMMAR_FIX_OUTPUT:
|
| 1518 |
-
engine_bits.append("grammar")
|
| 1519 |
-
if mode == "hybrid":
|
| 1520 |
-
if used_gen_main or (hybrid_stats and hybrid_stats.regenerated):
|
| 1521 |
-
engine_bits.append(f"lm-unit:{backend_kind()}")
|
| 1522 |
-
engine_bits.extend(["validate", "rank"])
|
| 1523 |
-
elif hybrid_stats and hybrid_stats.classical_kept:
|
| 1524 |
-
engine_bits.extend(["rules", "structure", "lexicon-fallback"])
|
| 1525 |
-
engine_bits.append("spacy" if spacy_available() else "regex-fallback")
|
| 1526 |
-
engine_bits.append("scrub-light")
|
| 1527 |
-
elif gen_was_used():
|
| 1528 |
-
engine_bits.append(f"generative:{backend_kind()}")
|
| 1529 |
-
engine_bits.append("validate")
|
| 1530 |
-
engine_bits.append("rank")
|
| 1531 |
-
engine_bits.append("spacy" if spacy_available() else "regex-fallback")
|
| 1532 |
-
engine_bits.append("scrub-light")
|
| 1533 |
-
else:
|
| 1534 |
-
engine_bits.append("spacy" if spacy_available() else "regex-fallback")
|
| 1535 |
-
engine_bits.extend(["rules", "structure", "lexicon-fallback", "mechanics", "tone"])
|
| 1536 |
-
if want_minilm and ml_was_used():
|
| 1537 |
-
engine_bits.append("minilm")
|
| 1538 |
|
| 1539 |
-
|
| 1540 |
-
|
| 1541 |
-
|
| 1542 |
-
|
| 1543 |
-
|
| 1544 |
-
|
| 1545 |
-
|
| 1546 |
-
|
| 1547 |
-
|
| 1548 |
-
|
| 1549 |
-
|
| 1550 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1551 |
|
| 1552 |
-
|
| 1553 |
-
|
| 1554 |
-
|
| 1555 |
-
|
| 1556 |
-
|
| 1557 |
-
|
| 1558 |
-
f"backend={GENERATIVE_BACKEND if gen_ready else '-'} "
|
| 1559 |
-
f"model={GENERATIVE_MODEL if gen_ready else '-'}"
|
| 1560 |
)
|
| 1561 |
-
logger.info(msg)
|
| 1562 |
-
print(f"[plainrewrite] {msg}", flush=True)
|
| 1563 |
-
|
| 1564 |
-
if not changed:
|
| 1565 |
-
tip = (
|
| 1566 |
-
"Output matched input after validation. "
|
| 1567 |
-
"Try Heavy strength, enable ML polish with a loaded generative model, "
|
| 1568 |
-
"or paste text with more rewritable content."
|
| 1569 |
-
)
|
| 1570 |
-
notes = f"{notes} {tip}".strip() if notes else tip
|
| 1571 |
-
elif used_gen_main and ratio > 0.90:
|
| 1572 |
-
tip = "Rewrite stayed close to the source (meaning-first ranking)."
|
| 1573 |
-
notes = f"{notes} {tip}".strip() if notes else tip
|
| 1574 |
-
if user_sim >= 0.92 and changed:
|
| 1575 |
-
tip = f"Surface similarity to input is high ({user_sim:.0%})."
|
| 1576 |
-
notes = f"{notes} {tip}".strip() if notes else tip
|
| 1577 |
|
| 1578 |
return RewriteResult(
|
| 1579 |
-
text=
|
| 1580 |
-
input_words=word_count(
|
| 1581 |
-
output_words=word_count(
|
| 1582 |
-
seconds=
|
| 1583 |
-
engine=engine,
|
| 1584 |
strength=strength,
|
| 1585 |
tone=tone,
|
| 1586 |
-
changed=changed,
|
| 1587 |
-
notes=notes,
|
| 1588 |
-
pipeline_mode=
|
| 1589 |
-
hybrid=
|
| 1590 |
-
similarity=
|
|
|
|
|
|
|
|
|
|
| 1591 |
)
|
| 1592 |
|
| 1593 |
|
| 1594 |
def similarity_check(rewrite: str, reference: str) -> SimilarityResult:
|
| 1595 |
-
return compare_similarity(rewrite
|
|
|
|
| 1 |
+
"""Orchestrate rewrite via the structural engine (compatibility shim)."""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2 |
|
| 3 |
from __future__ import annotations
|
| 4 |
|
| 5 |
import logging
|
|
|
|
|
|
|
| 6 |
import time
|
| 7 |
from dataclasses import dataclass, field
|
|
|
|
| 8 |
|
| 9 |
+
from app.engine.orchestrator import rewrite_document
|
| 10 |
+
from app.pipeline.normalize import word_count
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 11 |
from app.pipeline.similarity import SimilarityResult, compare_similarity
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 12 |
from app.pipeline.tones import normalize_tone
|
| 13 |
|
| 14 |
logger = logging.getLogger("plainrewrite")
|
|
|
|
| 18 |
|
| 19 |
@dataclass
|
| 20 |
class HybridStats:
|
| 21 |
+
"""Legacy stats shape kept for API compatibility."""
|
| 22 |
+
|
| 23 |
units: int = 0
|
| 24 |
classical_kept: int = 0
|
| 25 |
regenerated: int = 0
|
|
|
|
| 44 |
tone: str
|
| 45 |
changed: bool
|
| 46 |
notes: str = ""
|
| 47 |
+
pipeline_mode: str = "structural"
|
| 48 |
hybrid: HybridStats | None = None
|
| 49 |
similarity: float = 1.0
|
| 50 |
+
sentences: list | None = None
|
| 51 |
+
mapping: list | None = None
|
| 52 |
+
stats: dict | None = None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 53 |
|
| 54 |
|
| 55 |
def rewrite_text(
|
|
|
|
| 61 |
ml_polish: bool = False,
|
| 62 |
) -> RewriteResult:
|
| 63 |
"""
|
| 64 |
+
Structural document rewrite (rule-based sentence reorder).
|
| 65 |
|
| 66 |
+
``tone``, ``strength``, ``preserve_length``, and ``ml_polish`` are accepted
|
| 67 |
+
for API compatibility but do not drive synonym or generative rewrite.
|
| 68 |
+
MiniLM is used only when ENGINE_USE_MINILM_SAFETY is enabled.
|
|
|
|
| 69 |
"""
|
| 70 |
+
_ = preserve_length
|
| 71 |
started = time.perf_counter()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 72 |
tone = normalize_tone(tone)
|
| 73 |
+
strength = max(0, min(2, int(strength)))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 74 |
|
| 75 |
+
result = rewrite_document(
|
| 76 |
+
text,
|
| 77 |
+
use_minilm_safety=bool(ml_polish) or None,
|
| 78 |
+
)
|
| 79 |
+
|
| 80 |
+
hybrid = HybridStats(
|
| 81 |
+
units=result.stats.sentences,
|
| 82 |
+
classical_kept=result.stats.rewritten,
|
| 83 |
+
regenerated=0,
|
| 84 |
+
gen_accepted=0,
|
| 85 |
+
reverted_source=result.stats.reverted,
|
| 86 |
+
skipped=result.stats.skipped,
|
| 87 |
+
reasons=dict(result.stats.reasons),
|
| 88 |
+
)
|
| 89 |
+
|
| 90 |
+
seconds = result.stats.seconds or round(time.perf_counter() - started, 3)
|
| 91 |
+
sentence_payload = [
|
| 92 |
+
{
|
| 93 |
+
"index": s.index,
|
| 94 |
+
"original": s.original,
|
| 95 |
+
"rewritten": s.rewritten,
|
| 96 |
+
"confidence": s.confidence,
|
| 97 |
+
"status": s.status,
|
| 98 |
+
"template_id": s.template_id,
|
| 99 |
+
"sentence_type": s.sentence_type,
|
| 100 |
+
"reasons": s.reasons,
|
| 101 |
+
}
|
| 102 |
+
for s in result.sentences
|
| 103 |
+
]
|
| 104 |
+
stats_payload = {
|
| 105 |
+
"batches": result.stats.batches,
|
| 106 |
+
"blocks": result.stats.blocks,
|
| 107 |
+
"sentences": result.stats.sentences,
|
| 108 |
+
"rewritten": result.stats.rewritten,
|
| 109 |
+
"skipped": result.stats.skipped,
|
| 110 |
+
"reverted": result.stats.reverted,
|
| 111 |
+
"passthrough": result.stats.passthrough,
|
| 112 |
+
"seconds": result.stats.seconds,
|
| 113 |
+
"reasons": result.stats.reasons,
|
| 114 |
+
}
|
| 115 |
|
| 116 |
+
logger.info(
|
| 117 |
+
"rewrite_text structural words=%s→%s changed=%s t=%.2fs",
|
| 118 |
+
result.input_words,
|
| 119 |
+
result.output_words,
|
| 120 |
+
result.changed,
|
| 121 |
+
seconds,
|
|
|
|
|
|
|
| 122 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 123 |
|
| 124 |
return RewriteResult(
|
| 125 |
+
text=result.text,
|
| 126 |
+
input_words=result.input_words or word_count(text or ""),
|
| 127 |
+
output_words=result.output_words or word_count(result.text),
|
| 128 |
+
seconds=seconds,
|
| 129 |
+
engine=result.engine,
|
| 130 |
strength=strength,
|
| 131 |
tone=tone,
|
| 132 |
+
changed=result.changed,
|
| 133 |
+
notes=result.notes,
|
| 134 |
+
pipeline_mode="structural",
|
| 135 |
+
hybrid=hybrid,
|
| 136 |
+
similarity=result.similarity,
|
| 137 |
+
sentences=sentence_payload,
|
| 138 |
+
mapping=result.mapping,
|
| 139 |
+
stats=stats_payload,
|
| 140 |
)
|
| 141 |
|
| 142 |
|
| 143 |
def similarity_check(rewrite: str, reference: str) -> SimilarityResult:
|
| 144 |
+
return compare_similarity(rewrite, reference)
|
app/pipeline/restructure.py
CHANGED
|
@@ -1,425 +1,88 @@
|
|
| 1 |
-
"""Dependency + template sentence restructure
|
| 2 |
|
| 3 |
-
|
| 4 |
-
classify → parse slots → plan → template → fill → return candidate
|
| 5 |
-
(caller runs grammar repair + semantic safety)
|
| 6 |
"""
|
| 7 |
|
| 8 |
from __future__ import annotations
|
| 9 |
|
| 10 |
-
import
|
| 11 |
-
from
|
| 12 |
-
|
| 13 |
-
from app.
|
| 14 |
-
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
|
| 19 |
-
|
| 20 |
-
|
| 21 |
-
""".split()
|
| 22 |
-
)
|
| 23 |
-
|
| 24 |
-
_PLACE_PREPS = frozenset("to at in on into onto from toward towards".split())
|
| 25 |
-
_MOTION_VERBS = frozenset(
|
| 26 |
-
"""
|
| 27 |
-
went go goes going gone walk walks walked walking
|
| 28 |
-
run runs ran running drive drives drove driving
|
| 29 |
-
come comes came coming move moves moved moving
|
| 30 |
-
fly flies flew flying ride rides rode riding
|
| 31 |
-
travel travels traveled travelled travel
|
| 32 |
-
head heads headed heading return returns returned
|
| 33 |
-
""".split()
|
| 34 |
)
|
| 35 |
|
| 36 |
|
| 37 |
-
@dataclass
|
| 38 |
-
class SentenceSlots:
|
| 39 |
-
"""Constituent spans extracted for template fill."""
|
| 40 |
-
|
| 41 |
-
text: str
|
| 42 |
-
subject: str = ""
|
| 43 |
-
verb: str = ""
|
| 44 |
-
verb_phrase: str = "" # auxiliaries + main verb
|
| 45 |
-
object: str = ""
|
| 46 |
-
place: str = ""
|
| 47 |
-
time: str = ""
|
| 48 |
-
manner: str = ""
|
| 49 |
-
leftover: str = ""
|
| 50 |
-
confidence: float = 0.0
|
| 51 |
-
sentence_type: str = "unsupported"
|
| 52 |
-
reasons: list[str] = field(default_factory=list)
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
@dataclass
|
| 56 |
-
class ReorderPlan:
|
| 57 |
-
template_id: str
|
| 58 |
-
slots: SentenceSlots
|
| 59 |
-
confidence: float
|
| 60 |
-
|
| 61 |
-
|
| 62 |
-
@dataclass
|
| 63 |
class RestructureResult:
|
| 64 |
-
|
| 65 |
-
|
| 66 |
-
|
| 67 |
-
|
| 68 |
-
|
| 69 |
-
|
| 70 |
-
|
| 71 |
-
|
| 72 |
-
|
| 73 |
-
|
| 74 |
-
|
| 75 |
-
if re.match(r"^#{1,6}\s", t) or (len(t.split()) <= 6 and t.isupper()):
|
| 76 |
-
return "title"
|
| 77 |
-
if re.match(r"^(\d+[\.\)]\s+|[-*•]\s+)", t):
|
| 78 |
-
return "list_item"
|
| 79 |
-
if t.count('"') >= 2 or t.count("“") or t.count("'") >= 2:
|
| 80 |
-
# Allow short quotes inside; skip heavily quoted lines
|
| 81 |
-
if t.startswith(("\"", "'", "“", "‘")):
|
| 82 |
-
return "quoted"
|
| 83 |
-
if "?" in t or t.endswith("?"):
|
| 84 |
-
return "question"
|
| 85 |
-
if len(t.split()) < 3:
|
| 86 |
-
return "too_short"
|
| 87 |
-
if len(t.split()) > 45:
|
| 88 |
-
return "too_long"
|
| 89 |
-
return "simple_declarative"
|
| 90 |
-
|
| 91 |
-
|
| 92 |
-
def _span_text(tokens: list) -> str:
|
| 93 |
-
if not tokens:
|
| 94 |
-
return ""
|
| 95 |
-
return "".join(t.text_with_ws for t in tokens).strip()
|
| 96 |
-
|
| 97 |
-
|
| 98 |
-
def _subtree_tokens(token) -> list:
|
| 99 |
-
return sorted(token.subtree, key=lambda t: t.i)
|
| 100 |
|
| 101 |
|
| 102 |
-
def
|
| 103 |
-
|
| 104 |
-
|
| 105 |
-
|
| 106 |
-
|
| 107 |
-
|
| 108 |
-
|
| 109 |
-
|
| 110 |
-
|
| 111 |
-
|
| 112 |
-
return _extract_slots_regex(raw)
|
| 113 |
-
|
| 114 |
-
doc = nlp(raw)
|
| 115 |
-
# Prefer ROOT verb
|
| 116 |
-
root = next((t for t in doc if t.dep_ == "ROOT" and t.pos_ in {"VERB", "AUX"}), None)
|
| 117 |
-
if root is None:
|
| 118 |
-
root = next((t for t in doc if t.pos_ == "VERB"), None)
|
| 119 |
-
if root is None:
|
| 120 |
-
slots.reasons.append("no_verb")
|
| 121 |
-
return slots
|
| 122 |
-
|
| 123 |
-
# Subject
|
| 124 |
-
subj_toks: list = []
|
| 125 |
-
for child in root.children:
|
| 126 |
-
if child.dep_ in {"nsubj", "nsubjpass"}:
|
| 127 |
-
subj_toks = _subtree_tokens(child)
|
| 128 |
-
break
|
| 129 |
-
slots.subject = _span_text(subj_toks)
|
| 130 |
-
|
| 131 |
-
# Verb phrase: aux + root (exclude negation particles for separate handling later)
|
| 132 |
-
verb_toks = [t for t in root.lefts if t.dep_ in {"aux", "auxpass"}] + [root]
|
| 133 |
-
slots.verb = root.text
|
| 134 |
-
slots.verb_phrase = _span_text(sorted(verb_toks, key=lambda t: t.i))
|
| 135 |
-
|
| 136 |
-
# Object / attr / dobj
|
| 137 |
-
obj_toks: list = []
|
| 138 |
-
for child in root.children:
|
| 139 |
-
if child.dep_ in {"dobj", "obj", "attr", "xcomp", "ccomp"}:
|
| 140 |
-
# Prefer noun-ish objects over full clause for simple templates
|
| 141 |
-
if child.dep_ in {"ccomp", "xcomp"} and child.pos_ == "VERB":
|
| 142 |
-
continue
|
| 143 |
-
obj_toks = _subtree_tokens(child)
|
| 144 |
-
break
|
| 145 |
-
# Strip trailing advmod/time from object subtree if present
|
| 146 |
-
slots.object = _span_text(obj_toks)
|
| 147 |
-
|
| 148 |
-
# Place: prep "to/at/in..." with pobj
|
| 149 |
-
place_parts: list[str] = []
|
| 150 |
-
for child in root.children:
|
| 151 |
-
if child.dep_ == "prep" and child.text.lower() in _PLACE_PREPS:
|
| 152 |
-
place_parts.append(_span_text(_subtree_tokens(child)))
|
| 153 |
-
elif child.dep_ == "prt":
|
| 154 |
-
pass
|
| 155 |
-
# Also "went to school" — prep attached to verb
|
| 156 |
-
if not place_parts:
|
| 157 |
-
for t in doc:
|
| 158 |
-
if t.dep_ == "prep" and t.head == root and t.text.lower() in _PLACE_PREPS:
|
| 159 |
-
place_parts.append(_span_text(_subtree_tokens(t)))
|
| 160 |
-
# Motion verb + "to X" misparsed as infinitive xcomp (e.g. drove to work)
|
| 161 |
-
if not place_parts and root.text.lower() in _MOTION_VERBS:
|
| 162 |
-
for child in root.children:
|
| 163 |
-
if child.dep_ == "xcomp":
|
| 164 |
-
aux_to = [
|
| 165 |
-
t
|
| 166 |
-
for t in child.lefts
|
| 167 |
-
if t.dep_ == "aux" and t.text.lower() == "to"
|
| 168 |
-
]
|
| 169 |
-
if aux_to or child.text.lower() not in {"be", "have", "do"}:
|
| 170 |
-
place_parts.append("to " + child.text)
|
| 171 |
-
break
|
| 172 |
-
slots.place = " ".join(p for p in place_parts if p).strip()
|
| 173 |
-
|
| 174 |
-
# Time + manner from advmod / npadvmod / prep
|
| 175 |
-
time_parts: list[str] = []
|
| 176 |
-
manner_parts: list[str] = []
|
| 177 |
-
for t in doc:
|
| 178 |
-
low = t.text.lower()
|
| 179 |
-
if t.dep_ in {"advmod", "npadvmod"} and (t.head == root or t.head.head == root):
|
| 180 |
-
if low in _TIME_WORDS or t.ent_type_ == "DATE" or t.ent_type_ == "TIME":
|
| 181 |
-
time_parts.append(_span_text(_subtree_tokens(t)))
|
| 182 |
-
elif low.endswith("ly") or t.pos_ == "ADV":
|
| 183 |
-
# avoid degree modifiers alone
|
| 184 |
-
if low not in {"very", "really", "quite", "just", "also", "still", "not", "n't"}:
|
| 185 |
-
manner_parts.append(t.text)
|
| 186 |
-
if t.dep_ == "npadvmod" and low in _TIME_WORDS:
|
| 187 |
-
time_parts.append(t.text)
|
| 188 |
-
if t.ent_type_ in {"DATE", "TIME"} and t.head == root:
|
| 189 |
-
time_parts.append(t.text)
|
| 190 |
-
|
| 191 |
-
# Fallback scan for known time words anywhere
|
| 192 |
-
if not time_parts:
|
| 193 |
-
for t in doc:
|
| 194 |
-
if t.text.lower() in _TIME_WORDS:
|
| 195 |
-
time_parts.append(t.text)
|
| 196 |
-
if not manner_parts:
|
| 197 |
-
for t in doc:
|
| 198 |
-
if t.pos_ == "ADV" and t.text.lower().endswith("ly"):
|
| 199 |
-
manner_parts.append(t.text)
|
| 200 |
-
|
| 201 |
-
slots.time = " ".join(dict.fromkeys(time_parts)).strip()
|
| 202 |
-
slots.manner = " ".join(dict.fromkeys(manner_parts)).strip()
|
| 203 |
-
|
| 204 |
-
# Clean object if it absorbed place/time/manner
|
| 205 |
-
for piece in (slots.place, slots.time, slots.manner):
|
| 206 |
-
if piece and slots.object.endswith(piece):
|
| 207 |
-
slots.object = slots.object[: -len(piece)].strip(" ,")
|
| 208 |
-
|
| 209 |
-
# Confidence
|
| 210 |
-
score = 0.2
|
| 211 |
-
if slots.subject:
|
| 212 |
-
score += 0.25
|
| 213 |
-
if slots.verb_phrase:
|
| 214 |
-
score += 0.2
|
| 215 |
-
if slots.place or slots.object:
|
| 216 |
-
score += 0.15
|
| 217 |
-
if slots.time:
|
| 218 |
-
score += 0.1
|
| 219 |
-
if slots.manner:
|
| 220 |
-
score += 0.1
|
| 221 |
-
slots.confidence = min(1.0, score)
|
| 222 |
-
slots.sentence_type = "simple_declarative"
|
| 223 |
-
if slots.confidence < 0.45 or not slots.subject or not slots.verb_phrase:
|
| 224 |
-
slots.reasons.append("low_confidence")
|
| 225 |
-
return slots
|
| 226 |
-
|
| 227 |
-
|
| 228 |
-
def _extract_slots_regex(text: str) -> SentenceSlots:
|
| 229 |
-
"""Regex fallback when spaCy is unavailable (covers Ram-style sentences)."""
|
| 230 |
-
slots = SentenceSlots(text=text, sentence_type=classify_sentence(text))
|
| 231 |
-
if slots.sentence_type != "simple_declarative":
|
| 232 |
-
return slots
|
| 233 |
-
end = ""
|
| 234 |
-
core = text
|
| 235 |
-
m_end = re.search(r"[.!?]+$", text)
|
| 236 |
-
if m_end:
|
| 237 |
-
end = m_end.group(0)
|
| 238 |
-
core = text[: m_end.start()]
|
| 239 |
-
|
| 240 |
-
m = re.match(
|
| 241 |
-
r"^(?P<subj>[A-Za-z][\w'-]*)\s+"
|
| 242 |
-
r"(?P<verb>went|go|goes|walked|ran|drove|came|moved|is|are|was|were|has|have|had)\s+"
|
| 243 |
-
r"(?:to\s+(?P<place>.+?)\s+)?"
|
| 244 |
-
r"(?P<time>yesterday|today|tomorrow|earlier|later|recently)?\s*"
|
| 245 |
-
r"(?P<manner>\w+ly)?$",
|
| 246 |
-
core,
|
| 247 |
-
flags=re.I,
|
| 248 |
)
|
| 249 |
-
if not m:
|
| 250 |
-
# Broader: Subj verb ... time manner
|
| 251 |
-
m2 = re.match(
|
| 252 |
-
r"^(?P<subj>[A-Za-z][\w'-]*)\s+(?P<rest>.+)$",
|
| 253 |
-
core,
|
| 254 |
-
flags=re.I,
|
| 255 |
-
)
|
| 256 |
-
if not m2:
|
| 257 |
-
slots.reasons.append("regex_no_match")
|
| 258 |
-
return slots
|
| 259 |
-
slots.subject = m2.group("subj")
|
| 260 |
-
rest = m2.group("rest")
|
| 261 |
-
# peel manner / time from end
|
| 262 |
-
mm = re.search(r"\b(\w+ly)$", rest, flags=re.I)
|
| 263 |
-
if mm:
|
| 264 |
-
slots.manner = mm.group(1)
|
| 265 |
-
rest = rest[: mm.start()].strip()
|
| 266 |
-
mt = re.search(
|
| 267 |
-
r"\b(yesterday|today|tomorrow|earlier|later|recently)$",
|
| 268 |
-
rest,
|
| 269 |
-
flags=re.I,
|
| 270 |
-
)
|
| 271 |
-
if mt:
|
| 272 |
-
slots.time = mt.group(1)
|
| 273 |
-
rest = rest[: mt.start()].strip()
|
| 274 |
-
mv = re.match(r"^(\w+)(?:\s+to\s+(.+))?$", rest, flags=re.I)
|
| 275 |
-
if mv:
|
| 276 |
-
slots.verb_phrase = mv.group(1)
|
| 277 |
-
slots.verb = mv.group(1)
|
| 278 |
-
slots.place = f"to {mv.group(2)}" if mv.group(2) else ""
|
| 279 |
-
if slots.place and not slots.place.lower().startswith("to "):
|
| 280 |
-
slots.place = "to " + slots.place
|
| 281 |
-
slots.confidence = 0.55 if slots.subject and slots.verb_phrase else 0.2
|
| 282 |
-
return slots
|
| 283 |
-
|
| 284 |
-
slots.subject = m.group("subj")
|
| 285 |
-
slots.verb = m.group("verb")
|
| 286 |
-
slots.verb_phrase = m.group("verb")
|
| 287 |
-
if m.group("place"):
|
| 288 |
-
slots.place = "to " + m.group("place").strip()
|
| 289 |
-
slots.time = (m.group("time") or "").strip()
|
| 290 |
-
slots.manner = (m.group("manner") or "").strip()
|
| 291 |
-
slots.confidence = 0.7
|
| 292 |
-
_ = end
|
| 293 |
-
return slots
|
| 294 |
-
|
| 295 |
-
|
| 296 |
-
def _cap(text: str) -> str:
|
| 297 |
-
t = (text or "").strip()
|
| 298 |
-
if not t:
|
| 299 |
-
return t
|
| 300 |
-
return t[0].upper() + t[1:]
|
| 301 |
|
| 302 |
|
| 303 |
-
def
|
| 304 |
-
|
| 305 |
-
|
| 306 |
-
s = re.sub(r"\s+", " ", s).strip()
|
| 307 |
-
s = re.sub(r"\s+([,.;:!?])", r"\1", s)
|
| 308 |
-
return s
|
| 309 |
-
|
| 310 |
-
|
| 311 |
-
def plan_reorder(slots: SentenceSlots) -> ReorderPlan | None:
|
| 312 |
-
"""Pick a deterministic template from available slots."""
|
| 313 |
-
if slots.confidence < 0.45 or "low_confidence" in slots.reasons:
|
| 314 |
return None
|
| 315 |
-
|
|
|
|
| 316 |
return None
|
| 317 |
-
|
| 318 |
-
# Family A: has time + manner + place → Time, Subject Manner Verb Place
|
| 319 |
-
if slots.time and slots.manner and slots.place:
|
| 320 |
-
return ReorderPlan("time_subj_manner_verb_place", slots, 0.9)
|
| 321 |
-
|
| 322 |
-
# Family B: time + place
|
| 323 |
-
if slots.time and slots.place:
|
| 324 |
-
return ReorderPlan("time_subj_verb_place", slots, 0.8)
|
| 325 |
-
|
| 326 |
-
# Family C: time + object
|
| 327 |
-
if slots.time and slots.object:
|
| 328 |
-
return ReorderPlan("time_subj_verb_object", slots, 0.75)
|
| 329 |
-
|
| 330 |
-
# Family D: manner + place
|
| 331 |
-
if slots.manner and slots.place:
|
| 332 |
-
return ReorderPlan("subj_manner_verb_place", slots, 0.7)
|
| 333 |
-
|
| 334 |
-
# Family E: time only → front time
|
| 335 |
-
if slots.time:
|
| 336 |
-
return ReorderPlan("time_front", slots, 0.65)
|
| 337 |
-
|
| 338 |
-
# Family F: manner only → manner before verb
|
| 339 |
-
if slots.manner:
|
| 340 |
-
return ReorderPlan("subj_manner_verb_rest", slots, 0.6)
|
| 341 |
-
|
| 342 |
-
# Family G: place present → keep SVP but ensure clean order
|
| 343 |
-
if slots.place:
|
| 344 |
-
return ReorderPlan("subj_verb_place", slots, 0.55)
|
| 345 |
-
|
| 346 |
-
if slots.object:
|
| 347 |
-
return ReorderPlan("subj_verb_object", slots, 0.5)
|
| 348 |
-
|
| 349 |
-
return None
|
| 350 |
-
|
| 351 |
-
|
| 352 |
-
def fill_template(plan: ReorderPlan) -> str | None:
|
| 353 |
-
"""Build sentence from plan without synonym replacement."""
|
| 354 |
-
s = plan.slots
|
| 355 |
-
end = "."
|
| 356 |
-
if s.text.rstrip().endswith(("!", "?")):
|
| 357 |
-
end = s.text.rstrip()[-1]
|
| 358 |
-
|
| 359 |
-
tid = plan.template_id
|
| 360 |
-
if tid == "time_subj_manner_verb_place":
|
| 361 |
-
# Yesterday, Ram happily went to school.
|
| 362 |
-
place = s.place
|
| 363 |
-
body = _join_slots(_cap(s.time) + ",", s.subject, s.manner, s.verb_phrase, place)
|
| 364 |
-
return body + end
|
| 365 |
-
|
| 366 |
-
if tid == "time_subj_verb_place":
|
| 367 |
-
body = _join_slots(_cap(s.time) + ",", s.subject, s.verb_phrase, s.place)
|
| 368 |
-
return body + end
|
| 369 |
-
|
| 370 |
-
if tid == "time_subj_verb_object":
|
| 371 |
-
body = _join_slots(_cap(s.time) + ",", s.subject, s.verb_phrase, s.object)
|
| 372 |
-
return body + end
|
| 373 |
-
|
| 374 |
-
if tid == "subj_manner_verb_place":
|
| 375 |
-
body = _join_slots(_cap(s.subject), s.manner, s.verb_phrase, s.place)
|
| 376 |
-
return body + end
|
| 377 |
-
|
| 378 |
-
if tid == "time_front":
|
| 379 |
-
# Move time to front; rebuild remainder by stripping time from original lightly
|
| 380 |
-
rest = s.text
|
| 381 |
-
if s.time:
|
| 382 |
-
rest = re.sub(rf"\b{re.escape(s.time)}\b", "", rest, count=1, flags=re.I)
|
| 383 |
-
rest = re.sub(r"\s+", " ", rest).strip(" ,.")
|
| 384 |
-
body = _join_slots(_cap(s.time) + ",", rest[0].lower() + rest[1:] if rest else "")
|
| 385 |
-
return (body.rstrip(".!?") + end) if body else None
|
| 386 |
-
|
| 387 |
-
if tid == "subj_manner_verb_rest":
|
| 388 |
-
# Subject manner verb ... (strip trailing manner from original rebuild)
|
| 389 |
-
rest_bits = [s.verb_phrase, s.object, s.place, s.time]
|
| 390 |
-
body = _join_slots(_cap(s.subject), s.manner, *rest_bits)
|
| 391 |
-
return body + end
|
| 392 |
-
|
| 393 |
-
if tid == "subj_verb_place":
|
| 394 |
-
body = _join_slots(_cap(s.subject), s.verb_phrase, s.place, s.time, s.manner)
|
| 395 |
-
return body + end
|
| 396 |
-
|
| 397 |
-
if tid == "subj_verb_object":
|
| 398 |
-
body = _join_slots(_cap(s.subject), s.verb_phrase, s.object, s.place, s.time, s.manner)
|
| 399 |
-
return body + end
|
| 400 |
-
|
| 401 |
-
return None
|
| 402 |
|
| 403 |
|
| 404 |
def restructure_sentence(text: str, *, min_confidence: float = 0.55) -> RestructureResult | None:
|
| 405 |
-
|
| 406 |
-
|
| 407 |
-
if not raw:
|
| 408 |
return None
|
| 409 |
-
|
| 410 |
-
if
|
| 411 |
-
# Allow regex path without explicit low_confidence reason if score ok
|
| 412 |
-
if "low_confidence" in slots.reasons or slots.confidence < min_confidence:
|
| 413 |
-
return None
|
| 414 |
-
plan = plan_reorder(slots)
|
| 415 |
-
if plan is None or plan.confidence < min_confidence:
|
| 416 |
-
return None
|
| 417 |
-
filled = fill_template(plan)
|
| 418 |
-
if not filled or filled.strip().lower().rstrip(".!?") == raw.lower().rstrip(".!?"):
|
| 419 |
return None
|
| 420 |
return RestructureResult(
|
| 421 |
-
text=filled
|
| 422 |
template_id=plan.template_id,
|
| 423 |
confidence=plan.confidence,
|
| 424 |
plan=plan,
|
| 425 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Dependency + template sentence restructure — thin re-export of engine stages.
|
| 2 |
|
| 3 |
+
Prefer ``app.engine`` for new code. Kept for scripts that import restructure APIs.
|
|
|
|
|
|
|
| 4 |
"""
|
| 5 |
|
| 6 |
from __future__ import annotations
|
| 7 |
|
| 8 |
+
from app.engine.classify import classify_sentence
|
| 9 |
+
from app.engine.models import RewritePlan as ReorderPlan
|
| 10 |
+
from app.engine.models import SentenceSlots
|
| 11 |
+
from app.engine.parse import extract_slots
|
| 12 |
+
from app.engine.plan import build_plan
|
| 13 |
+
from app.engine.rewrite import generate_from_plan, reorder_quality_ok
|
| 14 |
+
from app.engine.templates import (
|
| 15 |
+
fill_template as _fill_template,
|
| 16 |
+
rank_templates,
|
| 17 |
+
try_because_front,
|
| 18 |
+
try_discourse_front,
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 19 |
)
|
| 20 |
|
| 21 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 22 |
class RestructureResult:
|
| 23 |
+
def __init__(
|
| 24 |
+
self,
|
| 25 |
+
text: str,
|
| 26 |
+
template_id: str,
|
| 27 |
+
confidence: float,
|
| 28 |
+
plan: ReorderPlan | None = None,
|
| 29 |
+
) -> None:
|
| 30 |
+
self.text = text
|
| 31 |
+
self.template_id = template_id
|
| 32 |
+
self.confidence = confidence
|
| 33 |
+
self.plan = plan
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 34 |
|
| 35 |
|
| 36 |
+
def plan_reorder(slots: SentenceSlots) -> ReorderPlan | None:
|
| 37 |
+
cands = rank_templates(slots)
|
| 38 |
+
if not cands:
|
| 39 |
+
return None
|
| 40 |
+
return ReorderPlan(
|
| 41 |
+
safe=True,
|
| 42 |
+
slots=slots,
|
| 43 |
+
template_id=cands[0].template_id,
|
| 44 |
+
candidates=cands,
|
| 45 |
+
confidence=cands[0].confidence,
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 46 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 47 |
|
| 48 |
|
| 49 |
+
def fill_template(plan: ReorderPlan):
|
| 50 |
+
"""Back-compat: accept ReorderPlan like the old API."""
|
| 51 |
+
if plan is None or not plan.slots:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 52 |
return None
|
| 53 |
+
tid = plan.template_id or (plan.candidates[0].template_id if plan.candidates else "")
|
| 54 |
+
if not tid:
|
| 55 |
return None
|
| 56 |
+
return _fill_template(tid, plan.slots)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 57 |
|
| 58 |
|
| 59 |
def restructure_sentence(text: str, *, min_confidence: float = 0.55) -> RestructureResult | None:
|
| 60 |
+
plan = build_plan(text, min_confidence=min_confidence)
|
| 61 |
+
if not plan.safe:
|
|
|
|
| 62 |
return None
|
| 63 |
+
filled = generate_from_plan(plan)
|
| 64 |
+
if not filled:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 65 |
return None
|
| 66 |
return RestructureResult(
|
| 67 |
+
text=filled,
|
| 68 |
template_id=plan.template_id,
|
| 69 |
confidence=plan.confidence,
|
| 70 |
plan=plan,
|
| 71 |
)
|
| 72 |
+
|
| 73 |
+
|
| 74 |
+
_reorder_quality_ok = reorder_quality_ok
|
| 75 |
+
|
| 76 |
+
__all__ = [
|
| 77 |
+
"SentenceSlots",
|
| 78 |
+
"ReorderPlan",
|
| 79 |
+
"RestructureResult",
|
| 80 |
+
"classify_sentence",
|
| 81 |
+
"extract_slots",
|
| 82 |
+
"plan_reorder",
|
| 83 |
+
"fill_template",
|
| 84 |
+
"restructure_sentence",
|
| 85 |
+
"try_because_front",
|
| 86 |
+
"try_discourse_front",
|
| 87 |
+
"_reorder_quality_ok",
|
| 88 |
+
]
|
app/pipeline/synonym.py
CHANGED
|
@@ -1,616 +1,75 @@
|
|
| 1 |
-
"""
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2 |
|
| 3 |
from __future__ import annotations
|
| 4 |
|
| 5 |
import json
|
| 6 |
-
import random
|
| 7 |
-
import re
|
| 8 |
-
from difflib import SequenceMatcher
|
| 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 |
-
|
| 19 |
-
_STOP_SWAP = {
|
| 20 |
-
"be", "is", "are", "was", "were", "been", "being", "have", "has", "had",
|
| 21 |
-
"do", "does", "did", "will", "would", "could", "should", "may", "might",
|
| 22 |
-
"must", "shall", "can", "to", "of", "in", "for", "on", "with", "at", "by",
|
| 23 |
-
"from", "as", "into", "the", "a", "an", "and", "or", "but", "if",
|
| 24 |
-
"this", "that", "these", "those", "it", "its", "they", "them", "he", "she",
|
| 25 |
-
"we", "you", "i", "not", "no", "yes", "my", "our", "your", "their", "his",
|
| 26 |
-
"her", "who", "what", "when", "where", "why", "how", "which", "than", "then",
|
| 27 |
-
"nor", "yet", "both", "each", "other", "some", "such", "own", "same", "too",
|
| 28 |
-
# Prepositions / conjunctions — WordNet turns these into adverbs and breaks grammar
|
| 29 |
-
"after", "before", "during", "until", "since", "while", "among", "between",
|
| 30 |
-
"under", "over", "about", "against", "upon", "onto", "into", "through",
|
| 31 |
-
"across", "around", "without", "within", "beside", "behind", "above", "below",
|
| 32 |
-
# Degree adverbs — WordNet turns very→rattling and wrecks collocations
|
| 33 |
-
"very", "really", "quite", "rather", "pretty", "fairly", "highly", "extremely",
|
| 34 |
-
# Temporal discourse — WordNet nowadays→today is pointless churn
|
| 35 |
-
"nowadays", "today", "tonight", "tomorrow", "yesterday", "now",
|
| 36 |
-
# Collocation anchors — WordNet adj sense of "chance"→casual/nonchalant wrecks ESL text
|
| 37 |
-
"chance", "chances", "instead", "prefer", "prefers", "preferred", "less", "more",
|
| 38 |
-
"most", "least", "body", "fit", "health", "healthy", "unhealthy",
|
| 39 |
-
"unfortunately", "fortunately", "however", "therefore",
|
| 40 |
-
}
|
| 41 |
-
|
| 42 |
-
# Closed-class grammar only — no domain word lists.
|
| 43 |
-
# Dynamic safety: POS gating (adj/adv WordNet only) + structural filters below.
|
| 44 |
-
|
| 45 |
-
_OPENERS = {
|
| 46 |
-
# Conversational — short spoken connectors
|
| 47 |
-
"Casual": {
|
| 48 |
-
"but": "Still",
|
| 49 |
-
"so": "So",
|
| 50 |
-
"and": "Plus",
|
| 51 |
-
"yet": "Still",
|
| 52 |
-
"however": "Still",
|
| 53 |
-
"therefore": "So",
|
| 54 |
-
"additionally": "Also",
|
| 55 |
-
"furthermore": "Also",
|
| 56 |
-
"moreover": "Also",
|
| 57 |
-
},
|
| 58 |
-
# Business / professional — restrained connectors
|
| 59 |
-
"Formal": {
|
| 60 |
-
"but": "Yet",
|
| 61 |
-
"so": "Thus",
|
| 62 |
-
"and": "Also",
|
| 63 |
-
"yet": "Still",
|
| 64 |
-
"also": "Additionally",
|
| 65 |
-
"therefore": "Accordingly",
|
| 66 |
-
"however": "Nevertheless",
|
| 67 |
-
},
|
| 68 |
-
# Scholarly — denser discourse markers
|
| 69 |
-
"Academic": {
|
| 70 |
-
"but": "However",
|
| 71 |
-
"so": "Consequently",
|
| 72 |
-
"and": "Furthermore",
|
| 73 |
-
"yet": "Nevertheless",
|
| 74 |
-
"still": "Nevertheless",
|
| 75 |
-
"also": "Moreover",
|
| 76 |
-
"therefore": "Accordingly",
|
| 77 |
-
"thus": "Hence",
|
| 78 |
-
},
|
| 79 |
-
}
|
| 80 |
-
|
| 81 |
-
_IRREGULAR_PAST = {
|
| 82 |
-
"heard", "buried", "wished", "decided", "stretched", "pushed", "stopped",
|
| 83 |
-
"tried", "kept", "made", "said", "went", "came", "took", "gave", "found",
|
| 84 |
-
"thought", "told", "knew", "began", "grew", "saw", "sat", "stood", "ran",
|
| 85 |
-
"felt", "laid", "lain", "sung", "sang", "chosen", "chose", "done", "been",
|
| 86 |
-
"had", "did", "was", "were", "got", "put", "let", "set", "cut", "hit",
|
| 87 |
-
"hurt", "read", "led", "met", "paid", "spent", "built", "meant", "left",
|
| 88 |
-
"brought", "bought", "caught", "taught", "fought", "sought",
|
| 89 |
-
}
|
| 90 |
-
|
| 91 |
-
|
| 92 |
-
def _ensure_wordnet() -> bool:
|
| 93 |
-
global _WORDNET_READY
|
| 94 |
-
if _WORDNET_READY:
|
| 95 |
-
return True
|
| 96 |
-
try:
|
| 97 |
-
from nltk.corpus import wordnet as wn
|
| 98 |
-
|
| 99 |
-
try:
|
| 100 |
-
wn.synsets("test")
|
| 101 |
-
except LookupError:
|
| 102 |
-
import nltk
|
| 103 |
-
|
| 104 |
-
nltk.download("wordnet", quiet=True)
|
| 105 |
-
nltk.download("omw-1.4", quiet=True)
|
| 106 |
-
_WORDNET_READY = True
|
| 107 |
-
return True
|
| 108 |
-
except Exception:
|
| 109 |
-
return False
|
| 110 |
|
| 111 |
|
| 112 |
def _load_json_map(name: str) -> dict[str, str]:
|
| 113 |
path = DATA_DIR / name
|
| 114 |
-
if not path.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 115 |
return {}
|
| 116 |
-
return {k.lower(): v for k, v in json.loads(path.read_text(encoding="utf-8")).items() if k != v}
|
| 117 |
|
| 118 |
|
| 119 |
@lru_cache(maxsize=1)
|
| 120 |
def load_protected_phrases() -> tuple[str, ...]:
|
| 121 |
-
"""Multiword collocations whose component words must not be swapped.
|
| 122 |
-
|
| 123 |
-
Data-driven (app/data/protected_phrases.json) so it stays domain-agnostic —
|
| 124 |
-
e.g. 'fast food' must never become 'rapid food'.
|
| 125 |
-
"""
|
| 126 |
path = DATA_DIR / "protected_phrases.json"
|
| 127 |
-
if not path.
|
| 128 |
return ()
|
| 129 |
try:
|
| 130 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 131 |
except Exception:
|
| 132 |
return ()
|
| 133 |
-
phrases = {str(p).strip().lower() for p in raw if str(p).strip()}
|
| 134 |
-
# Longest first so multi-word matches win.
|
| 135 |
-
return tuple(sorted(phrases, key=len, reverse=True))
|
| 136 |
|
| 137 |
|
| 138 |
-
def _protected_spans(sentence: str) -> list[tuple[int, int]]:
|
| 139 |
-
"""Character spans in `sentence` covered by a protected collocation."""
|
| 140 |
-
phrases = load_protected_phrases()
|
| 141 |
-
if not phrases:
|
| 142 |
-
return []
|
| 143 |
-
spans: list[tuple[int, int]] = []
|
| 144 |
-
low = sentence.lower()
|
| 145 |
-
for phrase in phrases:
|
| 146 |
-
for m in re.finditer(rf"\b{re.escape(phrase)}\b", low):
|
| 147 |
-
spans.append((m.start(), m.end()))
|
| 148 |
-
return spans
|
| 149 |
-
|
| 150 |
-
|
| 151 |
-
def _in_protected_span(start: int, end: int, spans: list[tuple[int, int]]) -> bool:
|
| 152 |
-
for s, e in spans:
|
| 153 |
-
if start < e and end > s: # any overlap
|
| 154 |
-
return True
|
| 155 |
-
return False
|
| 156 |
-
|
| 157 |
-
|
| 158 |
-
@lru_cache(maxsize=1)
|
| 159 |
def load_preferred_swaps() -> dict[str, str]:
|
| 160 |
return _load_json_map("preferred_swaps.json")
|
| 161 |
|
| 162 |
|
| 163 |
-
@lru_cache(maxsize=1)
|
| 164 |
def load_elevate_swaps() -> dict[str, str]:
|
| 165 |
return _load_json_map("elevate_swaps.json")
|
| 166 |
|
| 167 |
|
| 168 |
-
@lru_cache(maxsize=1)
|
| 169 |
def load_content_swaps() -> dict[str, str]:
|
| 170 |
return _load_json_map("content_swaps.json")
|
| 171 |
|
| 172 |
|
| 173 |
-
@lru_cache(maxsize=1)
|
| 174 |
def load_academic_swaps() -> dict[str, str]:
|
| 175 |
return _load_json_map("academic_swaps.json")
|
| 176 |
|
| 177 |
|
| 178 |
-
@lru_cache(maxsize=1)
|
| 179 |
def load_casual_swaps() -> dict[str, str]:
|
| 180 |
return _load_json_map("casual_swaps.json")
|
| 181 |
|
| 182 |
|
| 183 |
-
def _lexicon_for(tone: str, strength: int) -> dict[str, str]:
|
| 184 |
-
"""
|
| 185 |
-
Merge lexicons by canonical tone (classical fallback only).
|
| 186 |
-
|
| 187 |
-
- content_swaps for every tone
|
| 188 |
-
- Formal: elevate_swaps on top
|
| 189 |
-
- Academic: elevate + academic_swaps
|
| 190 |
-
- Casual: preferred + casual downshift (wins over content)
|
| 191 |
-
- Neutral: preferred wins over content (see preferred_swaps.json); elevate only at Heavy
|
| 192 |
-
"""
|
| 193 |
-
content = load_content_swaps()
|
| 194 |
-
tone_l = normalize_tone(tone)
|
| 195 |
-
if is_academic(tone_l):
|
| 196 |
-
return {**content, **load_elevate_swaps(), **load_academic_swaps()}
|
| 197 |
-
if is_elevated(tone_l): # Formal
|
| 198 |
-
return {**content, **load_elevate_swaps()}
|
| 199 |
-
if is_casual(tone_l):
|
| 200 |
-
return {**content, **load_preferred_swaps(), **load_casual_swaps()}
|
| 201 |
-
# Neutral: preferred overlays content so plain wording wins
|
| 202 |
-
merged = {**content, **load_preferred_swaps()}
|
| 203 |
-
if tone_l == "Neutral" and strength >= 2:
|
| 204 |
-
for k, v in load_elevate_swaps().items():
|
| 205 |
-
merged.setdefault(k, v)
|
| 206 |
-
return merged
|
| 207 |
-
|
| 208 |
-
|
| 209 |
-
def _wn_pos(tag: str) -> str | None:
|
| 210 |
-
if tag.startswith("J"):
|
| 211 |
-
return "a"
|
| 212 |
-
if tag.startswith("V"):
|
| 213 |
-
return "v"
|
| 214 |
-
if tag.startswith("N"):
|
| 215 |
-
return "n"
|
| 216 |
-
if tag.startswith("R"):
|
| 217 |
-
return "r"
|
| 218 |
-
return None
|
| 219 |
-
|
| 220 |
-
|
| 221 |
-
def _looks_inflected_verb(word: str) -> bool:
|
| 222 |
-
w = word.lower()
|
| 223 |
-
return w in _IRREGULAR_PAST or w.endswith(("ing", "ed"))
|
| 224 |
-
|
| 225 |
-
|
| 226 |
-
def _is_morph_cousin(original: str, candidate: str) -> bool:
|
| 227 |
-
"""Reject truncated / lookalike forms (climate→clime) that are not real paraphrases."""
|
| 228 |
-
o, c = original.lower(), candidate.lower()
|
| 229 |
-
if o.startswith(c) or c.startswith(o):
|
| 230 |
-
if abs(len(o) - len(c)) <= 3 and min(len(o), len(c)) >= 3:
|
| 231 |
-
return True
|
| 232 |
-
# Very high string overlap usually means same stem, not a paraphrase
|
| 233 |
-
if SequenceMatcher(None, o, c).ratio() >= 0.82:
|
| 234 |
-
return True
|
| 235 |
-
return False
|
| 236 |
-
|
| 237 |
-
|
| 238 |
-
def _looks_safe_synonym(original: str, candidate: str) -> bool:
|
| 239 |
-
"""Structural synonym safety — works for any word, no domain blocklists.
|
| 240 |
-
|
| 241 |
-
Rejects stopwords, lookalike truncations, and shape mismatches.
|
| 242 |
-
Does NOT require high character similarity (that blocks happy→glad).
|
| 243 |
-
"""
|
| 244 |
-
o, c = original.lower(), candidate.lower()
|
| 245 |
-
if c in _STOP_SWAP or c == o:
|
| 246 |
-
return False
|
| 247 |
-
if not c.isalpha() or " " in c:
|
| 248 |
-
return False
|
| 249 |
-
if len(o) < 4 or len(c) < 4:
|
| 250 |
-
return False
|
| 251 |
-
if abs(len(c) - len(o)) > 8:
|
| 252 |
-
return False
|
| 253 |
-
if c.endswith(("ish", "ness", "ment")) and not o.endswith(("ish", "ness", "ment")):
|
| 254 |
-
return False
|
| 255 |
-
if o in {"first", "second", "third", "fourth", "fifth", "last", "next"}:
|
| 256 |
-
return False
|
| 257 |
-
if _is_morph_cousin(o, c):
|
| 258 |
-
return False
|
| 259 |
-
return True
|
| 260 |
-
|
| 261 |
-
|
| 262 |
-
@lru_cache(maxsize=4096)
|
| 263 |
-
def _wordnet_synonyms(word: str, pos: str | None = None) -> tuple[str, ...]:
|
| 264 |
-
"""WordNet for adjectives/adverbs only — POS-gated, domain-agnostic.
|
| 265 |
-
|
| 266 |
-
Nouns/verbs are never queried: WordNet sense inventory is too ambiguous
|
| 267 |
-
for arbitrary technical/domain text (software→package, etc.).
|
| 268 |
-
Lexicon JSON covers noun/verb paraphrases deliberately.
|
| 269 |
-
"""
|
| 270 |
-
if not _ensure_wordnet():
|
| 271 |
-
return ()
|
| 272 |
-
if word.lower() in _STOP_SWAP:
|
| 273 |
-
return ()
|
| 274 |
-
if _looks_inflected_verb(word):
|
| 275 |
-
return ()
|
| 276 |
-
# Nouns & verbs: lexicon only (dynamic for any domain)
|
| 277 |
-
if pos in {"n", "v"}:
|
| 278 |
-
return ()
|
| 279 |
-
from nltk.corpus import wordnet as wn
|
| 280 |
-
|
| 281 |
-
# Unknown POS (regex fallback): still adj/adv only — never fall through to nouns
|
| 282 |
-
order = ["a", "r"]
|
| 283 |
-
if pos == "r":
|
| 284 |
-
order = ["r", "a"]
|
| 285 |
-
elif pos == "a":
|
| 286 |
-
order = ["a", "r"]
|
| 287 |
-
|
| 288 |
-
out: list[str] = []
|
| 289 |
-
for p in order:
|
| 290 |
-
synsets = wn.synsets(word, pos=p)
|
| 291 |
-
if not synsets:
|
| 292 |
-
continue
|
| 293 |
-
for lemma in synsets[0].lemmas():
|
| 294 |
-
name = lemma.name().replace("_", " ").lower()
|
| 295 |
-
if " " in name:
|
| 296 |
-
continue
|
| 297 |
-
if _looks_safe_synonym(word, name) and name not in out:
|
| 298 |
-
out.append(name)
|
| 299 |
-
if out:
|
| 300 |
-
break
|
| 301 |
-
return tuple(out[:5])
|
| 302 |
-
|
| 303 |
-
|
| 304 |
-
@lru_cache(maxsize=4096)
|
| 305 |
-
def _wordnet_synonyms_lemma(lemma: str, pos: str | None = None) -> tuple[str, ...]:
|
| 306 |
-
"""Synonyms for a lemma (used for verbs after spaCy lemmatization)."""
|
| 307 |
-
if not lemma or len(lemma) < 3:
|
| 308 |
-
return ()
|
| 309 |
-
return _wordnet_synonyms(lemma, pos)
|
| 310 |
-
|
| 311 |
-
|
| 312 |
-
def _match_case(original: str, replacement: str) -> str:
|
| 313 |
-
if " " in replacement:
|
| 314 |
-
parts = replacement.split()
|
| 315 |
-
if original[:1].isupper():
|
| 316 |
-
parts[0] = parts[0][:1].upper() + parts[0][1:]
|
| 317 |
-
return " ".join(parts)
|
| 318 |
-
if original.isupper():
|
| 319 |
-
return replacement.upper()
|
| 320 |
-
if original[:1].isupper():
|
| 321 |
-
return replacement[:1].upper() + replacement[1:]
|
| 322 |
-
return replacement
|
| 323 |
-
|
| 324 |
-
|
| 325 |
-
def _join_tokens(tokens: list[str]) -> str:
|
| 326 |
-
text = ""
|
| 327 |
-
for i, t in enumerate(tokens):
|
| 328 |
-
if i == 0:
|
| 329 |
-
text = t
|
| 330 |
-
continue
|
| 331 |
-
prev = tokens[i - 1]
|
| 332 |
-
# Keep contractions/possessives glued: don ' t → don't, cat ' s → cat's
|
| 333 |
-
if prev.endswith(("'", "’")) or t in {"'", "’"}:
|
| 334 |
-
text += t
|
| 335 |
-
elif re.match(r"\w", t):
|
| 336 |
-
text += " " + t
|
| 337 |
-
else:
|
| 338 |
-
text += t
|
| 339 |
-
return re.sub(r"\s+([,.;:!?])", r"\1", text)
|
| 340 |
-
|
| 341 |
-
|
| 342 |
-
def _pick(
|
| 343 |
-
cands: list[str],
|
| 344 |
-
tone: str,
|
| 345 |
-
rng: random.Random,
|
| 346 |
-
*,
|
| 347 |
-
original_sentence: str = "",
|
| 348 |
-
original_word: str = "",
|
| 349 |
-
) -> str:
|
| 350 |
-
if not cands:
|
| 351 |
-
return ""
|
| 352 |
-
tone_l = normalize_tone(tone)
|
| 353 |
-
|
| 354 |
-
# MiniLM polish: score full-sentence variants when enabled
|
| 355 |
-
if ml_polish_enabled() and original_sentence and original_word:
|
| 356 |
-
variants: list[str] = []
|
| 357 |
-
low = original_word.lower()
|
| 358 |
-
for c in cands[:5]:
|
| 359 |
-
# Replace first case-insensitive whole-word hit
|
| 360 |
-
pat = re.compile(rf"\b{re.escape(original_word)}\b", re.I)
|
| 361 |
-
if not pat.search(original_sentence):
|
| 362 |
-
pat = re.compile(rf"\b{re.escape(low)}\b", re.I)
|
| 363 |
-
nxt = pat.sub(_match_case(original_word, c), original_sentence, count=1)
|
| 364 |
-
variants.append(nxt)
|
| 365 |
-
best_sent = pick_best_candidate(original_sentence, variants, tone=tone_l)
|
| 366 |
-
if best_sent:
|
| 367 |
-
for c, variant in zip(cands[:5], variants):
|
| 368 |
-
if variant == best_sent:
|
| 369 |
-
mark_ml_used()
|
| 370 |
-
return c
|
| 371 |
-
|
| 372 |
-
if is_elevated(tone_l):
|
| 373 |
-
return max(cands, key=len)
|
| 374 |
-
if is_casual(tone_l):
|
| 375 |
-
# Prefer shorter, punchier synonyms
|
| 376 |
-
return min(cands, key=lambda w: (len(w), w))
|
| 377 |
-
return rng.choice(cands)
|
| 378 |
-
|
| 379 |
-
|
| 380 |
-
def _is_sentence_start(tokens: list[str], index: int) -> bool:
|
| 381 |
-
if index == 0:
|
| 382 |
-
return True
|
| 383 |
-
for j in range(index - 1, -1, -1):
|
| 384 |
-
t = tokens[j]
|
| 385 |
-
if t.strip():
|
| 386 |
-
return t in {".", "!", "?", "…"} or t.endswith((".", "!", "?"))
|
| 387 |
-
return True
|
| 388 |
-
|
| 389 |
-
|
| 390 |
def rewrite_sentence_synonyms(
|
| 391 |
sentence: str,
|
| 392 |
-
strength: int,
|
| 393 |
-
rng: random.Random,
|
| 394 |
-
*,
|
| 395 |
tone: str = "Neutral",
|
| 396 |
-
|
|
|
|
| 397 |
) -> str:
|
| 398 |
-
"""
|
| 399 |
-
|
| 400 |
-
|
| 401 |
-
|
| 402 |
-
|
| 403 |
-
|
| 404 |
-
openers = _OPENERS.get(tone_l, {})
|
| 405 |
-
# Formal/Academic: lexicon-only register. Casual: mostly lexicon (safer downshift).
|
| 406 |
-
# Neutral: adj/adv WordNet allowed more freely.
|
| 407 |
-
if is_elevated(tone_l):
|
| 408 |
-
max_wn = 0
|
| 409 |
-
wn_rate = 0.0
|
| 410 |
-
elif is_casual(tone_l):
|
| 411 |
-
max_wn = {0: 0, 1: 1, 2: 2}.get(strength, 1)
|
| 412 |
-
wn_rate = {0: 0.0, 1: 0.35, 2: 0.55}.get(strength, 0.35)
|
| 413 |
-
else:
|
| 414 |
-
max_wn = {0: 1, 1: 3, 2: 5}.get(strength, 3)
|
| 415 |
-
wn_rate = {0: 0.4, 1: 0.7, 2: 0.9}.get(strength, 0.7)
|
| 416 |
-
# Lexicon hits always apply at Normal+; Light usually applies; second pass forces all
|
| 417 |
-
lex_rate = 1.0 if force_all_lexicon else {0: 0.85, 1: 1.0, 2: 1.0}.get(strength, 1.0)
|
| 418 |
-
|
| 419 |
-
def transform(
|
| 420 |
-
raw: str,
|
| 421 |
-
pos: str | None,
|
| 422 |
-
wn_budget: list[int],
|
| 423 |
-
*,
|
| 424 |
-
sentence_start: bool,
|
| 425 |
-
lemma: str | None = None,
|
| 426 |
-
) -> str:
|
| 427 |
-
low = raw.lower()
|
| 428 |
-
if not raw.isalpha():
|
| 429 |
-
return raw
|
| 430 |
-
if sentence_start and low in openers:
|
| 431 |
-
return openers[low]
|
| 432 |
-
if low in _STOP_SWAP or len(low) < 4:
|
| 433 |
-
return raw
|
| 434 |
-
if low in lexicon and rng.random() < lex_rate:
|
| 435 |
-
return _match_case(raw, lexicon[low])
|
| 436 |
-
# WordNet only for adjectives/adverbs (pos already gated inside helper)
|
| 437 |
-
if pos in {"n", "v"}:
|
| 438 |
-
return raw
|
| 439 |
-
if wn_budget[0] <= 0 or rng.random() > wn_rate:
|
| 440 |
-
return raw
|
| 441 |
-
if lemma and _looks_inflected_verb(low) and pos == "v":
|
| 442 |
-
return raw
|
| 443 |
-
cands = list(_wordnet_synonyms(low, pos))
|
| 444 |
-
if not cands and lemma and lemma.lower() == low and pos in {None, "a", "r"}:
|
| 445 |
-
cands = list(_wordnet_synonyms_lemma(lemma, pos))
|
| 446 |
-
if not cands:
|
| 447 |
-
return raw
|
| 448 |
-
wn_budget[0] -= 1
|
| 449 |
-
return _match_case(
|
| 450 |
-
raw,
|
| 451 |
-
_pick(
|
| 452 |
-
cands,
|
| 453 |
-
tone,
|
| 454 |
-
rng,
|
| 455 |
-
original_sentence=sentence,
|
| 456 |
-
original_word=raw,
|
| 457 |
-
),
|
| 458 |
-
)
|
| 459 |
-
|
| 460 |
-
def _force_min_changes(
|
| 461 |
-
tokens: list[str],
|
| 462 |
-
out: list[str],
|
| 463 |
-
min_changes: int,
|
| 464 |
-
protected_idx: set[int] | None = None,
|
| 465 |
-
) -> list[str]:
|
| 466 |
-
protected_idx = protected_idx or set()
|
| 467 |
-
changed = sum(1 for a, b in zip(tokens, out) if a.isalpha() and a != b)
|
| 468 |
-
if changed >= min_changes:
|
| 469 |
-
return out
|
| 470 |
-
for i, tok in enumerate(tokens):
|
| 471 |
-
if i in protected_idx or not tok.isalpha():
|
| 472 |
-
continue
|
| 473 |
-
if out[i] != tok:
|
| 474 |
-
continue
|
| 475 |
-
low = tok.lower()
|
| 476 |
-
if low in _STOP_SWAP or len(low) < 4 or low not in lexicon:
|
| 477 |
-
continue
|
| 478 |
-
out[i] = _match_case(tok, lexicon[low])
|
| 479 |
-
changed += 1
|
| 480 |
-
if changed >= min_changes:
|
| 481 |
-
break
|
| 482 |
-
# Adj/adv WordNet fill only — never nouns/verbs (no domain lists needed)
|
| 483 |
-
if changed < min_changes and max_wn > 0:
|
| 484 |
-
for i, tok in enumerate(tokens):
|
| 485 |
-
if i in protected_idx or not tok.isalpha() or out[i] != tok:
|
| 486 |
-
continue
|
| 487 |
-
low = tok.lower()
|
| 488 |
-
if low in _STOP_SWAP or len(low) < 4 or _looks_inflected_verb(low):
|
| 489 |
-
continue
|
| 490 |
-
cands = list(_wordnet_synonyms(low, "a"))
|
| 491 |
-
if not cands:
|
| 492 |
-
cands = list(_wordnet_synonyms(low, "r"))
|
| 493 |
-
if not cands:
|
| 494 |
-
continue
|
| 495 |
-
out[i] = _match_case(
|
| 496 |
-
tok,
|
| 497 |
-
_pick(
|
| 498 |
-
cands,
|
| 499 |
-
tone,
|
| 500 |
-
rng,
|
| 501 |
-
original_sentence=sentence,
|
| 502 |
-
original_word=tok,
|
| 503 |
-
),
|
| 504 |
-
)
|
| 505 |
-
changed += 1
|
| 506 |
-
if changed >= min_changes:
|
| 507 |
-
break
|
| 508 |
-
return out
|
| 509 |
-
|
| 510 |
-
nlp = get_nlp()
|
| 511 |
-
spans = _protected_spans(sentence)
|
| 512 |
-
|
| 513 |
-
if nlp is None:
|
| 514 |
-
matches = list(re.finditer(r"\w+|[^\w\s]", sentence, flags=re.UNICODE))
|
| 515 |
-
tokens = [m.group(0) for m in matches]
|
| 516 |
-
protected_idx = {
|
| 517 |
-
i
|
| 518 |
-
for i, m in enumerate(matches)
|
| 519 |
-
if _in_protected_span(m.start(), m.end(), spans)
|
| 520 |
-
}
|
| 521 |
-
content_n = sum(
|
| 522 |
-
1
|
| 523 |
-
for t in tokens
|
| 524 |
-
if t.isalpha() and t.lower() not in _STOP_SWAP and len(t) >= 4
|
| 525 |
-
)
|
| 526 |
-
min_ch = {0: 1, 1: max(2, content_n // 4), 2: max(3, content_n // 3)}.get(
|
| 527 |
-
strength, 2
|
| 528 |
-
)
|
| 529 |
-
if force_all_lexicon:
|
| 530 |
-
min_ch = max(min_ch, max(2, content_n // 3))
|
| 531 |
-
budget = [max_wn]
|
| 532 |
-
out = [
|
| 533 |
-
tok
|
| 534 |
-
if i in protected_idx
|
| 535 |
-
else transform(tok, None, budget, sentence_start=_is_sentence_start(tokens, i))
|
| 536 |
-
for i, tok in enumerate(tokens)
|
| 537 |
-
]
|
| 538 |
-
out = _force_min_changes(tokens, out, min_ch, protected_idx)
|
| 539 |
-
return _join_tokens(out)
|
| 540 |
-
|
| 541 |
-
doc = nlp(sentence)
|
| 542 |
-
budget = [max_wn]
|
| 543 |
-
pieces: list[str] = []
|
| 544 |
-
toks = list(doc)
|
| 545 |
-
protected_idx = {
|
| 546 |
-
i
|
| 547 |
-
for i, token in enumerate(toks)
|
| 548 |
-
if _in_protected_span(token.idx, token.idx + len(token.text), spans)
|
| 549 |
-
}
|
| 550 |
-
for i, token in enumerate(toks):
|
| 551 |
-
raw = token.text
|
| 552 |
-
if i in protected_idx or not token.is_alpha:
|
| 553 |
-
pieces.append(raw)
|
| 554 |
-
continue
|
| 555 |
-
prev_text = toks[i - 1].text if i else ""
|
| 556 |
-
start = i == 0 or prev_text in {".", "!", "?", "…"}
|
| 557 |
-
if token.ent_type_ or token.pos_ in {"PROPN", "PRON", "DET", "ADP", "PART"}:
|
| 558 |
-
low = raw.lower()
|
| 559 |
-
# Never rewrite closed-class / stop words — even if a lexicon has an entry
|
| 560 |
-
if low in _STOP_SWAP or token.pos_ in {"PROPN", "PRON", "DET"}:
|
| 561 |
-
pieces.append(openers[low] if start and low in openers else raw)
|
| 562 |
-
elif low in lexicon and (force_all_lexicon or rng.random() < lex_rate):
|
| 563 |
-
pieces.append(_match_case(raw, lexicon[low]))
|
| 564 |
-
else:
|
| 565 |
-
pieces.append(openers[low] if start and low in openers else raw)
|
| 566 |
-
continue
|
| 567 |
-
if token.pos_ in {"CCONJ", "SCONJ"}:
|
| 568 |
-
low = raw.lower()
|
| 569 |
-
if start and low in openers:
|
| 570 |
-
pieces.append(openers[low])
|
| 571 |
-
elif low in _STOP_SWAP:
|
| 572 |
-
pieces.append(raw)
|
| 573 |
-
elif low in lexicon and low not in {"and", "or", "if"}:
|
| 574 |
-
pieces.append(_match_case(raw, lexicon[low]))
|
| 575 |
-
else:
|
| 576 |
-
pieces.append(raw)
|
| 577 |
-
continue
|
| 578 |
-
if token.pos_ in {"VERB", "AUX"}:
|
| 579 |
-
low = raw.lower()
|
| 580 |
-
if low in lexicon and (force_all_lexicon or rng.random() < lex_rate):
|
| 581 |
-
pieces.append(_match_case(raw, lexicon[low]))
|
| 582 |
-
elif not _looks_inflected_verb(low):
|
| 583 |
-
pieces.append(
|
| 584 |
-
transform(
|
| 585 |
-
raw,
|
| 586 |
-
"v",
|
| 587 |
-
budget,
|
| 588 |
-
sentence_start=start,
|
| 589 |
-
lemma=token.lemma_,
|
| 590 |
-
)
|
| 591 |
-
)
|
| 592 |
-
else:
|
| 593 |
-
pieces.append(raw)
|
| 594 |
-
continue
|
| 595 |
-
pieces.append(
|
| 596 |
-
transform(
|
| 597 |
-
raw,
|
| 598 |
-
_wn_pos(token.tag_),
|
| 599 |
-
budget,
|
| 600 |
-
sentence_start=start,
|
| 601 |
-
lemma=token.lemma_,
|
| 602 |
-
)
|
| 603 |
-
)
|
| 604 |
-
|
| 605 |
-
# Force lexicon fills if barely changed
|
| 606 |
-
raws = [t.text for t in toks]
|
| 607 |
-
content_n = sum(
|
| 608 |
-
1
|
| 609 |
-
for t in toks
|
| 610 |
-
if t.is_alpha and t.text.lower() not in _STOP_SWAP and len(t.text) >= 4
|
| 611 |
-
)
|
| 612 |
-
min_ch = {0: 1, 1: max(2, content_n // 4), 2: max(3, content_n // 3)}.get(strength, 2)
|
| 613 |
-
if force_all_lexicon:
|
| 614 |
-
min_ch = max(min_ch, max(2, content_n // 3))
|
| 615 |
-
pieces = _force_min_changes(raws, pieces, min_ch, protected_idx)
|
| 616 |
-
return "".join(p + t.whitespace_ for t, p in zip(doc, pieces)).strip()
|
|
|
|
| 1 |
+
"""Legacy synonym / register rewrite — NOT used by the structural engine.
|
| 2 |
+
|
| 3 |
+
Kept only for ``load_protected_phrases`` (safety validator) and optional
|
| 4 |
+
ALLOW_SYNONYM_REPLACEMENT experiments. Swap JSON maps have been removed.
|
| 5 |
+
"""
|
| 6 |
|
| 7 |
from __future__ import annotations
|
| 8 |
|
| 9 |
import json
|
|
|
|
|
|
|
|
|
|
| 10 |
from functools import lru_cache
|
| 11 |
|
| 12 |
+
from app.config import ALLOW_SYNONYM_REPLACEMENT, DATA_DIR
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 13 |
|
| 14 |
|
| 15 |
def _load_json_map(name: str) -> dict[str, str]:
|
| 16 |
path = DATA_DIR / name
|
| 17 |
+
if not path.is_file():
|
| 18 |
+
return {}
|
| 19 |
+
try:
|
| 20 |
+
data = json.loads(path.read_text(encoding="utf-8"))
|
| 21 |
+
return {str(k): str(v) for k, v in data.items()} if isinstance(data, dict) else {}
|
| 22 |
+
except Exception:
|
| 23 |
return {}
|
|
|
|
| 24 |
|
| 25 |
|
| 26 |
@lru_cache(maxsize=1)
|
| 27 |
def load_protected_phrases() -> tuple[str, ...]:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 28 |
path = DATA_DIR / "protected_phrases.json"
|
| 29 |
+
if not path.is_file():
|
| 30 |
return ()
|
| 31 |
try:
|
| 32 |
+
data = json.loads(path.read_text(encoding="utf-8"))
|
| 33 |
+
if isinstance(data, list):
|
| 34 |
+
phrases = {str(p).strip().lower() for p in data if str(p).strip()}
|
| 35 |
+
elif isinstance(data, dict):
|
| 36 |
+
phrases = {str(x).strip().lower() for x in data.keys() if str(x).strip()}
|
| 37 |
+
else:
|
| 38 |
+
return ()
|
| 39 |
+
return tuple(sorted(phrases, key=len, reverse=True))
|
| 40 |
except Exception:
|
| 41 |
return ()
|
|
|
|
|
|
|
|
|
|
| 42 |
|
| 43 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 44 |
def load_preferred_swaps() -> dict[str, str]:
|
| 45 |
return _load_json_map("preferred_swaps.json")
|
| 46 |
|
| 47 |
|
|
|
|
| 48 |
def load_elevate_swaps() -> dict[str, str]:
|
| 49 |
return _load_json_map("elevate_swaps.json")
|
| 50 |
|
| 51 |
|
|
|
|
| 52 |
def load_content_swaps() -> dict[str, str]:
|
| 53 |
return _load_json_map("content_swaps.json")
|
| 54 |
|
| 55 |
|
|
|
|
| 56 |
def load_academic_swaps() -> dict[str, str]:
|
| 57 |
return _load_json_map("academic_swaps.json")
|
| 58 |
|
| 59 |
|
|
|
|
| 60 |
def load_casual_swaps() -> dict[str, str]:
|
| 61 |
return _load_json_map("casual_swaps.json")
|
| 62 |
|
| 63 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 64 |
def rewrite_sentence_synonyms(
|
| 65 |
sentence: str,
|
|
|
|
|
|
|
|
|
|
| 66 |
tone: str = "Neutral",
|
| 67 |
+
strength: int = 1,
|
| 68 |
+
rng=None,
|
| 69 |
) -> str:
|
| 70 |
+
"""No-op unless ALLOW_SYNONYM_REPLACEMENT is explicitly enabled."""
|
| 71 |
+
_ = (tone, strength, rng)
|
| 72 |
+
if not ALLOW_SYNONYM_REPLACEMENT:
|
| 73 |
+
return sentence
|
| 74 |
+
# Synonym maps removed — structural engine is the primary path.
|
| 75 |
+
return sentence
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
requirements.txt
CHANGED
|
@@ -3,16 +3,19 @@ uvicorn[standard]>=0.30.0
|
|
| 3 |
spacy>=3.7.0,<3.9.0
|
| 4 |
# Wheel avoids `python -m spacy download` SSL failures on Spaces / Windows
|
| 5 |
https://github.com/explosion/spacy-models/releases/download/en_core_web_sm-3.8.0/en_core_web_sm-3.8.0-py3-none-any.whl
|
| 6 |
-
nltk>=3.9.0
|
| 7 |
pydantic>=2.0.0
|
| 8 |
python-multipart>=0.0.9
|
| 9 |
httpx>=0.27.0
|
| 10 |
PyJWT[crypto]>=2.8.0
|
| 11 |
python-dotenv>=1.0.0
|
| 12 |
-
|
| 13 |
-
|
| 14 |
-
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3 |
spacy>=3.7.0,<3.9.0
|
| 4 |
# Wheel avoids `python -m spacy download` SSL failures on Spaces / Windows
|
| 5 |
https://github.com/explosion/spacy-models/releases/download/en_core_web_sm-3.8.0/en_core_web_sm-3.8.0-py3-none-any.whl
|
|
|
|
| 6 |
pydantic>=2.0.0
|
| 7 |
python-multipart>=0.0.9
|
| 8 |
httpx>=0.27.0
|
| 9 |
PyJWT[crypto]>=2.8.0
|
| 10 |
python-dotenv>=1.0.0
|
| 11 |
+
pytest>=8.0.0
|
| 12 |
+
|
| 13 |
+
# Optional: MiniLM safety scoring only (ENGINE_USE_MINILM_SAFETY=true)
|
| 14 |
+
# fastembed>=0.4.2
|
| 15 |
+
# torch>=2.2.0
|
| 16 |
+
# transformers>=4.40.0
|
| 17 |
+
# sentencepiece>=0.2.0
|
| 18 |
+
# accelerate>=0.30.0
|
| 19 |
+
|
| 20 |
+
# Optional legacy: WordNet synonym path (ALLOW_SYNONYM_REPLACEMENT — unused by structural engine)
|
| 21 |
+
# nltk>=3.9.0
|
scripts/_probe_reorder_wording.py
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import random
|
| 4 |
+
import sys
|
| 5 |
+
from pathlib import Path
|
| 6 |
+
|
| 7 |
+
ROOT = Path(__file__).resolve().parent.parent
|
| 8 |
+
sys.path.insert(0, str(ROOT))
|
| 9 |
+
|
| 10 |
+
from app.pipeline.orchestrator import _require_visible_rewrite, _try_restructure_unit
|
| 11 |
+
from app.pipeline.restructure import restructure_sentence
|
| 12 |
+
|
| 13 |
+
samples = [
|
| 14 |
+
"Ram went to school yesterday happily.",
|
| 15 |
+
"Nowadays many people are living an unhealthy life because they don't have enough time.",
|
| 16 |
+
"Online learning makes education more accessible for students in remote areas.",
|
| 17 |
+
"Sleeping is still an important thing.",
|
| 18 |
+
"Climate change is causing more extreme weather events around the world.",
|
| 19 |
+
"Without enough sleep, the brain cannot work properly.",
|
| 20 |
+
]
|
| 21 |
+
|
| 22 |
+
for s in samples:
|
| 23 |
+
rr = restructure_sentence(s)
|
| 24 |
+
print("SRC:", s)
|
| 25 |
+
print(" rr:", None if not rr else (rr.template_id, rr.text))
|
| 26 |
+
print(" try:", _try_restructure_unit(s))
|
| 27 |
+
print(" vis:", _require_visible_rewrite(s, s, "Neutral", random.Random(1)))
|
| 28 |
+
print()
|
scripts/test_phase_a_guards.py
CHANGED
|
@@ -259,6 +259,62 @@ def test_require_visible_keeps_structural_reorder() -> None:
|
|
| 259 |
print("keep structural reorder OK:", out)
|
| 260 |
|
| 261 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 262 |
def test_rejects_hollow_ons_bills() -> None:
|
| 263 |
src = (
|
| 264 |
"People should save money for emergencies instead of spending every paycheck."
|
|
@@ -574,6 +630,9 @@ if __name__ == "__main__":
|
|
| 574 |
test_complete_wording_rewrite_not_shuffle()
|
| 575 |
test_restructure_time_only_and_manner()
|
| 576 |
test_require_visible_keeps_structural_reorder()
|
|
|
|
|
|
|
|
|
|
| 577 |
test_rejects_hollow_ons_bills()
|
| 578 |
test_assembly_preserves_unit_count()
|
| 579 |
test_live_bug_patterns_rejected()
|
|
|
|
| 259 |
print("keep structural reorder OK:", out)
|
| 260 |
|
| 261 |
|
| 262 |
+
def test_rejects_degree_adv_junk_reorder() -> None:
|
| 263 |
+
"""Do not ship 'more makes' style degree-adverb reorders."""
|
| 264 |
+
from app.pipeline.orchestrator import _try_restructure_unit
|
| 265 |
+
from app.pipeline.restructure import restructure_sentence
|
| 266 |
+
|
| 267 |
+
src = "Online learning makes education more accessible for students in remote areas."
|
| 268 |
+
rr = restructure_sentence(src)
|
| 269 |
+
if rr is not None:
|
| 270 |
+
assert "more makes" not in rr.text.lower(), rr.text
|
| 271 |
+
try_out = _try_restructure_unit(src)
|
| 272 |
+
if try_out is not None:
|
| 273 |
+
assert "more makes" not in try_out.lower(), try_out
|
| 274 |
+
print("degree-adv junk reorder rejected OK:", rr, try_out)
|
| 275 |
+
|
| 276 |
+
|
| 277 |
+
def test_because_front_preserves_clauses() -> None:
|
| 278 |
+
from app.pipeline.restructure import restructure_sentence
|
| 279 |
+
|
| 280 |
+
src = (
|
| 281 |
+
"Nowadays many people are living an unhealthy life "
|
| 282 |
+
"because they don't have enough time."
|
| 283 |
+
)
|
| 284 |
+
rr = restructure_sentence(src)
|
| 285 |
+
assert rr is not None, "because_front should apply"
|
| 286 |
+
assert rr.template_id == "because_front"
|
| 287 |
+
low = rr.text.lower()
|
| 288 |
+
assert low.startswith("because")
|
| 289 |
+
assert "unhealthy" in low and "enough time" in low
|
| 290 |
+
assert "nowadays" in low
|
| 291 |
+
print("because_front OK:", rr.text)
|
| 292 |
+
|
| 293 |
+
|
| 294 |
+
def test_esl_wording_avoids_synonym_thrash() -> None:
|
| 295 |
+
import random
|
| 296 |
+
|
| 297 |
+
from app.pipeline.orchestrator import _require_visible_rewrite
|
| 298 |
+
|
| 299 |
+
sleep = "Sleeping is still an important thing."
|
| 300 |
+
out_s = _require_visible_rewrite(
|
| 301 |
+
sleep, sleep, "Neutral", random.Random(3), target_sim=0.95
|
| 302 |
+
)
|
| 303 |
+
low_s = out_s.lower()
|
| 304 |
+
assert "endeavor" not in low_s and "nevertheless" not in low_s
|
| 305 |
+
assert "sleep" in low_s or "sleeping" in low_s
|
| 306 |
+
print("sleep wording OK:", out_s)
|
| 307 |
+
|
| 308 |
+
climate = "Climate change is causing more extreme weather events around the world."
|
| 309 |
+
out_c = _require_visible_rewrite(
|
| 310 |
+
climate, climate, "Neutral", random.Random(7), target_sim=0.95
|
| 311 |
+
)
|
| 312 |
+
low_c = out_c.lower()
|
| 313 |
+
assert "uttermost" not in low_c and "upwind" not in low_c
|
| 314 |
+
assert "climate" in low_c and "weather" in low_c
|
| 315 |
+
print("climate wording OK:", out_c)
|
| 316 |
+
|
| 317 |
+
|
| 318 |
def test_rejects_hollow_ons_bills() -> None:
|
| 319 |
src = (
|
| 320 |
"People should save money for emergencies instead of spending every paycheck."
|
|
|
|
| 630 |
test_complete_wording_rewrite_not_shuffle()
|
| 631 |
test_restructure_time_only_and_manner()
|
| 632 |
test_require_visible_keeps_structural_reorder()
|
| 633 |
+
test_rejects_degree_adv_junk_reorder()
|
| 634 |
+
test_because_front_preserves_clauses()
|
| 635 |
+
test_esl_wording_avoids_synonym_thrash()
|
| 636 |
test_rejects_hollow_ons_bills()
|
| 637 |
test_assembly_preserves_unit_count()
|
| 638 |
test_live_bug_patterns_rejected()
|
tests/__pycache__/conftest.cpython-311-pytest-9.1.1.pyc
ADDED
|
Binary file (795 Bytes). View file
|
|
|
tests/__pycache__/test_engine_long.cpython-311-pytest-9.1.1.pyc
ADDED
|
Binary file (18.7 kB). View file
|
|
|
tests/__pycache__/test_engine_short.cpython-311-pytest-9.1.1.pyc
ADDED
|
Binary file (30.7 kB). View file
|
|
|
tests/conftest.py
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Pytest configuration."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
import sys
|
| 6 |
+
from pathlib import Path
|
| 7 |
+
|
| 8 |
+
ROOT = Path(__file__).resolve().parent.parent
|
| 9 |
+
if str(ROOT) not in sys.path:
|
| 10 |
+
sys.path.insert(0, str(ROOT))
|
tests/test_engine_long.py
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Long-document batching and safety tests."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
import os
|
| 6 |
+
|
| 7 |
+
os.environ.setdefault("LANGUAGE_TOOL_ENABLED", "false")
|
| 8 |
+
os.environ.setdefault("LANGUAGE_TOOL_URL", "")
|
| 9 |
+
os.environ.setdefault("GRAMMAR_FIX_INPUT", "false")
|
| 10 |
+
os.environ.setdefault("GRAMMAR_FIX_OUTPUT", "false")
|
| 11 |
+
os.environ.setdefault("ENGINE_USE_MINILM_SAFETY", "false")
|
| 12 |
+
|
| 13 |
+
from app.engine.normalize import detect_blocks, normalize_text
|
| 14 |
+
from app.engine.orchestrator import rewrite_document
|
| 15 |
+
from app.engine.safety import check_safety
|
| 16 |
+
from app.engine.segment import iter_paragraph_batches, word_count
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
def _make_long_doc(target_words: int = 10500) -> str:
|
| 20 |
+
templates = [
|
| 21 |
+
"Ram went to school yesterday happily.",
|
| 22 |
+
"The committee approved the budget today carefully.",
|
| 23 |
+
"Workers cleaned the factory floor this morning quickly.",
|
| 24 |
+
"Students completed the assignment last night diligently.",
|
| 25 |
+
"Although rain fell heavily, traffic moved slowly through town.",
|
| 26 |
+
"# Section Heading\n\n",
|
| 27 |
+
"- Keep this list item unchanged forever.\n",
|
| 28 |
+
"She sent the report to https://files.example.org/a.pdf yesterday.\n\n",
|
| 29 |
+
]
|
| 30 |
+
parts: list[str] = []
|
| 31 |
+
while word_count("\n\n".join(parts)) < target_words:
|
| 32 |
+
for t in templates:
|
| 33 |
+
parts.append(t.strip())
|
| 34 |
+
if word_count("\n\n".join(parts)) >= target_words:
|
| 35 |
+
break
|
| 36 |
+
return "\n\n".join(parts)
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
def test_long_document_batching():
|
| 40 |
+
doc = _make_long_doc(10500)
|
| 41 |
+
assert word_count(doc) >= 10000
|
| 42 |
+
result = rewrite_document(doc, batch_paras=15)
|
| 43 |
+
assert result.stats.batches >= 2
|
| 44 |
+
assert result.input_words >= 10000
|
| 45 |
+
assert result.stats.sentences > 50
|
| 46 |
+
# Paragraph-ish structure preserved (blank-line separated blocks)
|
| 47 |
+
assert result.text.count("\n\n") >= 10
|
| 48 |
+
assert result.stats.seconds < 180 # CPU smoke budget
|
| 49 |
+
|
| 50 |
+
|
| 51 |
+
def test_bibliography_passthrough():
|
| 52 |
+
src = (
|
| 53 |
+
"Ram went to school yesterday happily.\n\n"
|
| 54 |
+
"References\n\n"
|
| 55 |
+
"Smith, J. (2020). A paper about schools. Journal of Education.\n"
|
| 56 |
+
"Doe, A. (2019). Another citation here with many words about learning."
|
| 57 |
+
)
|
| 58 |
+
result = rewrite_document(src)
|
| 59 |
+
assert "Smith, J." in result.text
|
| 60 |
+
assert "References" in result.text
|
| 61 |
+
|
| 62 |
+
|
| 63 |
+
def test_safety_rejects_entity_loss():
|
| 64 |
+
original = "Alice met Bob yesterday at the park."
|
| 65 |
+
bad = "Someone met a friend yesterday at the park."
|
| 66 |
+
safety = check_safety(original, bad, use_minilm=False)
|
| 67 |
+
assert not safety.ok
|
| 68 |
+
assert any(r.startswith("entity:") or r == "negation" for r in safety.reasons) or (
|
| 69 |
+
"entity" in " ".join(safety.reasons)
|
| 70 |
+
)
|
| 71 |
+
|
| 72 |
+
|
| 73 |
+
def test_safety_rejects_negation_flip():
|
| 74 |
+
original = "Ram did not go to school yesterday."
|
| 75 |
+
bad = "Ram did go to school yesterday."
|
| 76 |
+
safety = check_safety(original, bad, use_minilm=False)
|
| 77 |
+
assert not safety.ok
|
| 78 |
+
assert "negation" in safety.reasons or "polarity" in safety.reasons
|
| 79 |
+
|
| 80 |
+
|
| 81 |
+
def test_normalize_and_blocks():
|
| 82 |
+
raw = "Hello world.\n\n# Title\n\n- item one\n\n```\ncode\n```"
|
| 83 |
+
norm = normalize_text(raw)
|
| 84 |
+
blocks = detect_blocks(norm)
|
| 85 |
+
kinds = {b.kind for b in blocks}
|
| 86 |
+
assert "heading" in kinds or "paragraph" in kinds
|
| 87 |
+
batches = list(iter_paragraph_batches(blocks, batch_paras=2))
|
| 88 |
+
assert batches
|
tests/test_engine_short.py
ADDED
|
@@ -0,0 +1,120 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Short-document structural rewrite tests."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
import os
|
| 6 |
+
|
| 7 |
+
# Keep tests offline and fast — no LanguageTool / MiniLM
|
| 8 |
+
os.environ.setdefault("LANGUAGE_TOOL_ENABLED", "false")
|
| 9 |
+
os.environ.setdefault("LANGUAGE_TOOL_URL", "")
|
| 10 |
+
os.environ.setdefault("GRAMMAR_FIX_INPUT", "false")
|
| 11 |
+
os.environ.setdefault("GRAMMAR_FIX_OUTPUT", "false")
|
| 12 |
+
os.environ.setdefault("ENGINE_USE_MINILM_SAFETY", "false")
|
| 13 |
+
os.environ.setdefault("ALLOW_SYNONYM_REPLACEMENT", "false")
|
| 14 |
+
|
| 15 |
+
from app.engine.classify import classify_sentence
|
| 16 |
+
from app.engine.orchestrator import rewrite_document
|
| 17 |
+
from app.engine.plan import build_plan
|
| 18 |
+
from app.engine.rewrite import generate_from_plan
|
| 19 |
+
from app.pipeline.orchestrator import rewrite_text
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
def test_ram_school_reorder():
|
| 23 |
+
src = "Ram went to school yesterday happily."
|
| 24 |
+
result = rewrite_document(src)
|
| 25 |
+
out = result.text.strip().rstrip(".")
|
| 26 |
+
# Accept either full template or time-front
|
| 27 |
+
assert "Yesterday" in result.text
|
| 28 |
+
assert "Ram" in result.text
|
| 29 |
+
assert "school" in result.text.lower()
|
| 30 |
+
assert "happily" in result.text.lower()
|
| 31 |
+
assert "went" in result.text.lower()
|
| 32 |
+
# Should not be identical
|
| 33 |
+
assert result.text.strip().lower() != src.strip().lower()
|
| 34 |
+
rewritten = [s for s in result.sentences if s.status == "rewritten"]
|
| 35 |
+
assert rewritten, f"expected rewrite, got {result.sentences}"
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
def test_negation_preserved():
|
| 39 |
+
src = "Ram did not go to school yesterday."
|
| 40 |
+
result = rewrite_document(src)
|
| 41 |
+
low = result.text.lower()
|
| 42 |
+
assert "not" in low or "n't" in low
|
| 43 |
+
assert "ram" in low
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
def test_entity_preserved():
|
| 47 |
+
src = "Alice visited Paris yesterday happily."
|
| 48 |
+
result = rewrite_document(src)
|
| 49 |
+
assert "Alice" in result.text
|
| 50 |
+
assert "Paris" in result.text
|
| 51 |
+
|
| 52 |
+
|
| 53 |
+
def test_url_and_email_passthrough():
|
| 54 |
+
src = (
|
| 55 |
+
"Please read https://example.com/docs carefully today.\n\n"
|
| 56 |
+
"Contact support at help@example.com for details."
|
| 57 |
+
)
|
| 58 |
+
result = rewrite_document(src)
|
| 59 |
+
assert "https://example.com/docs" in result.text
|
| 60 |
+
assert "help@example.com" in result.text
|
| 61 |
+
|
| 62 |
+
|
| 63 |
+
def test_heading_and_list_untouched():
|
| 64 |
+
src = (
|
| 65 |
+
"# Introduction\n\n"
|
| 66 |
+
"Ram went to school yesterday happily.\n\n"
|
| 67 |
+
"- First bullet item here\n"
|
| 68 |
+
"- Second bullet item here"
|
| 69 |
+
)
|
| 70 |
+
result = rewrite_document(src)
|
| 71 |
+
assert "# Introduction" in result.text or "Introduction" in result.text
|
| 72 |
+
assert "- First bullet item here" in result.text
|
| 73 |
+
assert "- Second bullet item here" in result.text
|
| 74 |
+
|
| 75 |
+
|
| 76 |
+
def test_code_block_untouched():
|
| 77 |
+
src = (
|
| 78 |
+
"Ram went to school yesterday happily.\n\n"
|
| 79 |
+
"```python\nprint('hello')\n```\n\n"
|
| 80 |
+
"Bob walked home today slowly."
|
| 81 |
+
)
|
| 82 |
+
result = rewrite_document(src)
|
| 83 |
+
assert "```python" in result.text
|
| 84 |
+
assert "print('hello')" in result.text
|
| 85 |
+
|
| 86 |
+
|
| 87 |
+
def test_complex_sentence_skipped():
|
| 88 |
+
src = "Although the weather was bad, Ram went to school yesterday."
|
| 89 |
+
result = rewrite_document(src)
|
| 90 |
+
assert classify_sentence(src) == "complex"
|
| 91 |
+
# Should remain unchanged (skipped)
|
| 92 |
+
assert any(s.status == "skipped" for s in result.sentences)
|
| 93 |
+
assert "Although" in result.text
|
| 94 |
+
|
| 95 |
+
|
| 96 |
+
def test_structured_output_fields():
|
| 97 |
+
src = "Ram went to school yesterday happily."
|
| 98 |
+
result = rewrite_document(src)
|
| 99 |
+
assert result.sentences
|
| 100 |
+
assert result.mapping
|
| 101 |
+
assert result.stats.sentences >= 1
|
| 102 |
+
assert result.engine == "structural-reorder"
|
| 103 |
+
|
| 104 |
+
|
| 105 |
+
def test_rewrite_text_shim():
|
| 106 |
+
src = "Ram went to school yesterday happily."
|
| 107 |
+
result = rewrite_text(src, tone="Neutral", strength=1)
|
| 108 |
+
assert result.pipeline_mode == "structural"
|
| 109 |
+
assert result.engine == "structural-reorder"
|
| 110 |
+
assert result.sentences is not None
|
| 111 |
+
assert result.stats is not None
|
| 112 |
+
|
| 113 |
+
|
| 114 |
+
def test_plan_then_generate():
|
| 115 |
+
src = "Ram went to school yesterday happily."
|
| 116 |
+
plan = build_plan(src)
|
| 117 |
+
assert plan.safe
|
| 118 |
+
text = generate_from_plan(plan)
|
| 119 |
+
assert text
|
| 120 |
+
assert "Yesterday" in text or "happily" in text.lower()
|