idnameraj commited on
Commit
8f6d79d
·
verified ·
1 Parent(s): 6394b33

Upload 142 files

Browse files
Files changed (49) hide show
  1. app/__pycache__/config.cpython-311.pyc +0 -0
  2. app/bootstrap.py +4 -20
  3. app/config.py +23 -4
  4. app/engine/__init__.py +6 -0
  5. app/engine/__pycache__/__init__.cpython-311.pyc +0 -0
  6. app/engine/__pycache__/models.cpython-311.pyc +0 -0
  7. app/engine/__pycache__/orchestrator.cpython-311.pyc +0 -0
  8. app/engine/classify/__init__.py +62 -0
  9. app/engine/classify/__pycache__/__init__.cpython-311.pyc +0 -0
  10. app/engine/consistency/__init__.py +94 -0
  11. app/engine/consistency/__pycache__/__init__.cpython-311.pyc +0 -0
  12. app/engine/grammar/__init__.py +31 -0
  13. app/engine/grammar/__pycache__/__init__.cpython-311.pyc +0 -0
  14. app/engine/ingest/__init__.py +17 -0
  15. app/engine/ingest/__pycache__/__init__.cpython-311.pyc +0 -0
  16. app/engine/models.py +104 -0
  17. app/engine/normalize/__init__.py +258 -0
  18. app/engine/normalize/__pycache__/__init__.cpython-311.pyc +0 -0
  19. app/engine/orchestrator.py +354 -0
  20. app/engine/parse/__init__.py +217 -0
  21. app/engine/parse/__pycache__/__init__.cpython-311.pyc +0 -0
  22. app/engine/plan/__init__.py +97 -0
  23. app/engine/plan/__pycache__/__init__.cpython-311.pyc +0 -0
  24. app/engine/rewrite/__init__.py +85 -0
  25. app/engine/rewrite/__pycache__/__init__.cpython-311.pyc +0 -0
  26. app/engine/safety/__init__.py +145 -0
  27. app/engine/safety/__pycache__/__init__.cpython-311.pyc +0 -0
  28. app/engine/segment/__init__.py +58 -0
  29. app/engine/segment/__pycache__/__init__.cpython-311.pyc +0 -0
  30. app/engine/stitch/__init__.py +35 -0
  31. app/engine/stitch/__pycache__/__init__.cpython-311.pyc +0 -0
  32. app/engine/templates/__init__.py +182 -0
  33. app/engine/templates/__pycache__/__init__.cpython-311.pyc +0 -0
  34. app/main.py +33 -20
  35. app/pipeline/__pycache__/orchestrator.cpython-311.pyc +0 -0
  36. app/pipeline/__pycache__/synonym.cpython-311.pyc +0 -0
  37. app/pipeline/generative.py +11 -523
  38. app/pipeline/orchestrator.py +75 -1526
  39. app/pipeline/restructure.py +62 -399
  40. app/pipeline/synonym.py +29 -570
  41. requirements.txt +11 -8
  42. scripts/_probe_reorder_wording.py +28 -0
  43. scripts/test_phase_a_guards.py +59 -0
  44. tests/__pycache__/conftest.cpython-311-pytest-9.1.1.pyc +0 -0
  45. tests/__pycache__/test_engine_long.cpython-311-pytest-9.1.1.pyc +0 -0
  46. tests/__pycache__/test_engine_short.cpython-311-pytest-9.1.1.pyc +0 -0
  47. tests/conftest.py +10 -0
  48. tests/test_engine_long.py +88 -0
  49. 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 + NLTK WordNet data (used by Docker entrypoint and local first run)."""
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 nltk
27
- from nltk.corpus import wordnet as wn
28
 
29
- try:
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] ML polish warm: {exc}")
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 "hybrid").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
- else:
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 "true").strip().lower()
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 = ["rules", "wordnet", "mechanics", "grammar"]
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 or gen_pkg),
128
  "minilm": ml_pkg,
129
- "generative": gen_pkg,
130
  "pipeline_mode": PIPELINE_MODE,
131
  "backend": {
132
  "minilm": "fastembed|sentence-transformers" if ml_pkg else None,
133
- "generative": GENERATIVE_MODEL if gen_pkg else None,
134
- "generative_backend": GENERATIVE_BACKEND if gen_pkg else None,
135
- "kind": backend_kind() if gen_pkg else None,
136
  },
137
  "pipeline": [
 
138
  "grammar",
139
- "lm-unit rewrite (seq2seq|causal)",
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": GENERATIVE_BACKEND if use_ml else None,
219
- "generative_model": GENERATIVE_MODEL if use_ml else None,
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
- """Generative rewrite: seq2seq (FLAN-T5) or causal chat (SmolLM2).
2
 
3
- Pipeline when models load:
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
- if not ML_POLISH_AVAILABLE_DEFAULT or not GENERATIVE_POLISH_ENABLED:
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
- if not generative_package_present():
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
- _ensure_model()
150
- return _backend
151
 
152
 
153
  def backend_kind() -> str:
154
- """Configured backend kind: seq2seq or causal (even before model load)."""
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
- paras = split_paragraphs(text)
489
- out_paras: list[str] = []
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
- if not out_paras:
518
- return None
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
- try:
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: classical / generative / hybrid (SmolLM2 or FLAN patch).
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.config import (
19
- GENERATIVE_BACKEND,
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
- def _rng_for(text: str) -> random.Random:
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
- Quality-first rewrite.
1288
 
1289
- PIPELINE_MODE=hybrid (default with ML): grammar Small LM per sentence → validate
1290
- (classical lexicon only if generative unavailable)
1291
- PIPELINE_MODE=generative: whole-document generative rewrite
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
- mode = PIPELINE_MODE if ml_polish else "classical"
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
- if ml_polish and not gen_ready and not want_minilm:
1540
- tip = (
1541
- "ML polish requested but generative/MiniLM models are unavailable "
1542
- "(install torch+transformers and/or fastembed; set GENERATIVE_MODEL)."
1543
- )
1544
- notes = f"{notes} {tip}".strip() if notes else tip
1545
- elif ml_polish and want_minilm and not gen_ready and mode != "hybrid":
1546
- tip = "ML polish: MiniLM guard only (generative unavailable — check GENERATIVE_MODEL)."
1547
- notes = f"{notes} {tip}".strip() if notes else tip
1548
- if grammar_fixed_input:
1549
- tip = "Corrected grammar in the source before rewriting."
1550
- notes = f"{notes} {tip}".strip() if notes else tip
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1551
 
1552
- engine = "+".join(engine_bits)
1553
- msg = (
1554
- f"rewrite engine={engine} mode={mode} strength={strength} tone={tone} "
1555
- f"words={word_count(original)}->{word_count(rewritten)} "
1556
- f"changed={changed} similarity={ratio:.3f} "
1557
- f"ml_polish={bool(ml_polish)} gen={gen_ready} gen_main={used_gen_main} "
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=rewritten,
1580
- input_words=word_count(original),
1581
- output_words=word_count(rewritten),
1582
- seconds=round(time.perf_counter() - started, 3),
1583
- engine=engine,
1584
  strength=strength,
1585
  tone=tone,
1586
- changed=changed,
1587
- notes=notes,
1588
- pipeline_mode=mode,
1589
- hybrid=hybrid_stats,
1590
- similarity=round(user_sim, 3),
 
 
 
1591
  )
1592
 
1593
 
1594
  def similarity_check(rewrite: str, reference: str) -> SimilarityResult:
1595
- return compare_similarity(rewrite or "", reference or "")
 
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 (reorder-first, no synonym thrash).
2
 
3
- Pipeline per sentence:
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 re
11
- from dataclasses import dataclass, field
12
-
13
- from app.pipeline.nlp import get_nlp
14
-
15
-
16
- _TIME_WORDS = frozenset(
17
- """
18
- yesterday today tomorrow earlier later recently now tonight
19
- monday tuesday wednesday thursday friday saturday sunday
20
- morning afternoon evening night
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
- text: str
65
- template_id: str
66
- confidence: float
67
- plan: ReorderPlan | None = None
68
-
69
-
70
- def classify_sentence(text: str) -> str:
71
- """Tag sentence type; only safe types are restructured."""
72
- t = (text or "").strip()
73
- if not t:
74
- return "empty"
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 extract_slots(text: str) -> SentenceSlots:
103
- """Parse subject/verb/object/time/place/manner with spaCy when available."""
104
- raw = (text or "").strip()
105
- slots = SentenceSlots(text=raw, sentence_type=classify_sentence(raw))
106
- if slots.sentence_type not in {"simple_declarative", "compound"}:
107
- slots.reasons.append(f"skip:{slots.sentence_type}")
108
- return slots
109
-
110
- nlp = get_nlp()
111
- if nlp is None:
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 _join_slots(*parts: str) -> str:
304
- bits = [p.strip() for p in parts if p and p.strip()]
305
- s = " ".join(bits)
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
- if not slots.subject or not slots.verb_phrase:
 
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
- """Run classify → slots → plan template fill. Returns None if unsafe/low-confidence."""
406
- raw = (text or "").strip()
407
- if not raw:
408
  return None
409
- slots = extract_slots(raw)
410
- if slots.confidence < min_confidence or slots.reasons:
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.strip(),
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
- """Word-level synonym / register rewrite — dynamic per token (no sentence templates)."""
 
 
 
 
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.exists():
 
 
 
 
 
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.exists():
128
  return ()
129
  try:
130
- raw = json.loads(path.read_text(encoding="utf-8"))
 
 
 
 
 
 
 
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
- force_all_lexicon: bool = False,
 
397
  ) -> str:
398
- """
399
- Dynamic per-token rewrite for any input:
400
- lexicon (content + tone register) then optional WordNet.
401
- """
402
- lexicon = _lexicon_for(tone, strength)
403
- tone_l = normalize_tone(tone)
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
- # ML polish: MiniLM meaning scoring + generative rewrite (lazy-loaded)
13
- # Backends: seq2seq (FLAN-T5) or causal chat (SmolLM2) via GENERATIVE_BACKEND
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
 
 
 
 
 
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()