idnameraj commited on
Commit
d7b0955
·
verified ·
1 Parent(s): bb7b252

Upload 105 files

Browse files
README.md CHANGED
@@ -110,7 +110,7 @@ Add Supabase secrets, then add the Space URL to Supabase Auth redirect URLs.
110
 
111
  ## Notes
112
 
113
- - With **ML polish**: generative rewrite is primary (`flan-t5-base` by default); classical lexicon is fallback. See [docs/ML_POLISH.md](docs/ML_POLISH.md).
114
  - Without ML polish: classical NLP (spaCy + WordNet + rules + grammar).
115
  - With Supabase enabled: **guest preview** on the homepage → sign up for Free → Pro later.
116
  - Admin: manage `profiles` / `plans` in the Supabase Table Editor.
 
110
 
111
  ## Notes
112
 
113
+ - With **ML polish**: hybrid pipeline (classical → validate → generative patch). Default generative profile in `.env.example` is SmolLM2 causal; FLAN-T5 seq2seq still supported. See [docs/ML_POLISH.md](docs/ML_POLISH.md), [docs/HYBRID_REWRITE_DESIGN.md](docs/HYBRID_REWRITE_DESIGN.md), [docs/SMOLLM2_PIPELINE_DESIGN.md](docs/SMOLLM2_PIPELINE_DESIGN.md).
114
  - Without ML polish: classical NLP (spaCy + WordNet + rules + grammar).
115
  - With Supabase enabled: **guest preview** on the homepage → sign up for Free → Pro later.
116
  - Admin: manage `profiles` / `plans` in the Supabase Table Editor.
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/config.py CHANGED
@@ -63,11 +63,24 @@ LANGUAGE_TOOL_ENABLED = _lt_flag not in {"0", "false", "no", "off"} and bool(LAN
63
  MINILM_MODEL = (
64
  os.environ.get("MINILM_MODEL") or "sentence-transformers/all-MiniLM-L6-v2"
65
  ).strip()
66
- # Seq2seq rewrite model. Prefer base over small for quality; override via env.
67
- # Larger instruction models (7B+) need GPU — set GENERATIVE_MODEL explicitly.
68
  GENERATIVE_MODEL = (
69
  os.environ.get("GENERATIVE_MODEL") or "google/flan-t5-base"
70
  ).strip()
 
 
 
 
 
 
 
 
 
 
 
 
 
71
  # 0 = rewrite entire document (no paragraph cap). Positive = safety cap.
72
  _gen_max_raw = (
73
  os.environ.get("GENERATIVE_MAX_PARAGRAPHS")
 
63
  MINILM_MODEL = (
64
  os.environ.get("MINILM_MODEL") or "sentence-transformers/all-MiniLM-L6-v2"
65
  ).strip()
66
+ # Generative rewrite model. Seq2seq default: flan-t5-base.
67
+ # Causal (SmolLM2): GENERATIVE_MODEL=HuggingFaceTB/SmolLM2-360M-Instruct + GENERATIVE_BACKEND=causal
68
  GENERATIVE_MODEL = (
69
  os.environ.get("GENERATIVE_MODEL") or "google/flan-t5-base"
70
  ).strip()
71
+ _gen_backend_raw = (os.environ.get("GENERATIVE_BACKEND") or "seq2seq").strip().lower()
72
+ 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")
app/main.py CHANGED
@@ -29,14 +29,16 @@ from app.bootstrap import ensure_resources
29
  from app.config import (
30
  APP_TITLE,
31
  AUTH_ENABLED,
 
32
  GENERATIVE_MODEL,
33
  GRAMMAR_MAX_CHARS,
34
  LANGUAGE_TOOL_LANGUAGE,
35
  LANGUAGE_TOOL_URL,
36
  MAX_CHARS,
37
  ML_POLISH_AVAILABLE_DEFAULT,
 
38
  )
39
- from app.pipeline.generative import generative_package_present
40
  from app.pipeline.grammar import (
41
  check_grammar,
42
  languagetool_reachable,
@@ -106,7 +108,8 @@ def health():
106
  if ml_pkg:
107
  engines.append("minilm")
108
  if gen_pkg:
109
- engines.append("flan-t5")
 
110
  return {
111
  "status": "ok",
112
  "app": APP_TITLE,
@@ -124,14 +127,18 @@ def health():
124
  "available": bool(ml_pkg or gen_pkg),
125
  "minilm": ml_pkg,
126
  "generative": gen_pkg,
 
127
  "backend": {
128
  "minilm": "fastembed|sentence-transformers" if ml_pkg else None,
129
  "generative": GENERATIVE_MODEL if gen_pkg else None,
 
 
130
  },
131
  "pipeline": [
132
  "grammar",
133
- "generative paraphrase",
134
- "validate/rank",
 
135
  "rules-light",
136
  "minilm meaning guard",
137
  "lexicon-fallback",
@@ -208,6 +215,21 @@ def api_rewrite(
208
  "changed": result.changed,
209
  "notes": result.notes,
210
  "ml_polish": use_ml,
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
211
  },
212
  "account": account_payload(account),
213
  }
 
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,
37
  MAX_CHARS,
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,
 
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,
 
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
+ "classical baseline",
140
+ "align/validate",
141
+ "generative patch (seq2seq|causal)",
142
  "rules-light",
143
  "minilm meaning guard",
144
  "lexicon-fallback",
 
215
  "changed": result.changed,
216
  "notes": result.notes,
217
  "ml_polish": use_ml,
218
+ "pipeline_mode": result.pipeline_mode,
219
+ "generative_backend": GENERATIVE_BACKEND if use_ml else None,
220
+ "generative_model": GENERATIVE_MODEL if use_ml else None,
221
+ "hybrid": (
222
+ {
223
+ "units": result.hybrid.units,
224
+ "classical_kept": result.hybrid.classical_kept,
225
+ "regenerated": result.hybrid.regenerated,
226
+ "gen_accepted": result.hybrid.gen_accepted,
227
+ "reverted_source": result.hybrid.reverted_source,
228
+ "reasons": result.hybrid.reasons,
229
+ }
230
+ if result.hybrid
231
+ else None
232
+ ),
233
  },
234
  "account": account_payload(account),
235
  }
app/pipeline/__pycache__/alignment.cpython-311.pyc ADDED
Binary file (9.01 kB). View file
 
app/pipeline/__pycache__/generative.cpython-311.pyc CHANGED
Binary files a/app/pipeline/__pycache__/generative.cpython-311.pyc and b/app/pipeline/__pycache__/generative.cpython-311.pyc differ
 
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/alignment.py ADDED
@@ -0,0 +1,180 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Paragraph-preserving sentence alignment for hybrid rewrite."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from difflib import SequenceMatcher
7
+
8
+ from app.pipeline.nlp import get_nlp
9
+ from app.pipeline.normalize import split_paragraphs, split_sentences_regex
10
+
11
+
12
+ @dataclass
13
+ class AlignedUnit:
14
+ """One aligned source/candidate span within a paragraph."""
15
+
16
+ source: str
17
+ candidate: str
18
+ paragraph_index: int
19
+ confidence: float
20
+ kind: str = "1:1" # 1:1 | 2:1 | 1:2 | para
21
+
22
+
23
+ def _split_sentences(text: str) -> list[str]:
24
+ text = (text or "").strip()
25
+ if not text:
26
+ return []
27
+ nlp = get_nlp()
28
+ if nlp is not None:
29
+ return [s.text.strip() for s in nlp(text).sents if s.text.strip()]
30
+ return split_sentences_regex(text)
31
+
32
+
33
+ def _sim(a: str, b: str) -> float:
34
+ return SequenceMatcher(None, a.lower().strip(), b.lower().strip()).ratio()
35
+
36
+
37
+ def align_paragraph_sentences(
38
+ source_para: str,
39
+ candidate_para: str,
40
+ *,
41
+ paragraph_index: int = 0,
42
+ min_confidence: float = 0.35,
43
+ ) -> list[AlignedUnit]:
44
+ """Monotonic alignment of sentences within one paragraph."""
45
+ src = _split_sentences(source_para)
46
+ cand = _split_sentences(candidate_para)
47
+ if not src and not cand:
48
+ return []
49
+ if not src:
50
+ return [
51
+ AlignedUnit("", c, paragraph_index, 0.0, "1:1")
52
+ for c in cand
53
+ ]
54
+ if not cand:
55
+ return [
56
+ AlignedUnit(s, "", paragraph_index, 0.0, "1:1")
57
+ for s in src
58
+ ]
59
+
60
+ # Equal counts → order-based pairs (verify confidence)
61
+ if len(src) == len(cand):
62
+ units: list[AlignedUnit] = []
63
+ for s, c in zip(src, cand):
64
+ conf = _sim(s, c)
65
+ if conf < min_confidence:
66
+ # Fall back to whole-paragraph unit when pairing looks wrong
67
+ return [
68
+ AlignedUnit(
69
+ source_para.strip(),
70
+ candidate_para.strip(),
71
+ paragraph_index,
72
+ _sim(source_para, candidate_para),
73
+ "para",
74
+ )
75
+ ]
76
+ units.append(AlignedUnit(s, c, paragraph_index, conf, "1:1"))
77
+ return units
78
+
79
+ # Greedy monotonic merge for unequal counts
80
+ units = []
81
+ i = j = 0
82
+ while i < len(src) and j < len(cand):
83
+ one = _sim(src[i], cand[j])
84
+ merge_src = (
85
+ _sim(" ".join(src[i : i + 2]), cand[j]) if i + 1 < len(src) else -1.0
86
+ )
87
+ merge_cand = (
88
+ _sim(src[i], " ".join(cand[j : j + 2])) if j + 1 < len(cand) else -1.0
89
+ )
90
+ best = max(one, merge_src, merge_cand)
91
+ if best < min_confidence:
92
+ return [
93
+ AlignedUnit(
94
+ source_para.strip(),
95
+ candidate_para.strip(),
96
+ paragraph_index,
97
+ _sim(source_para, candidate_para),
98
+ "para",
99
+ )
100
+ ]
101
+ if best == merge_src and merge_src >= one and merge_src >= merge_cand:
102
+ units.append(
103
+ AlignedUnit(
104
+ " ".join(src[i : i + 2]),
105
+ cand[j],
106
+ paragraph_index,
107
+ merge_src,
108
+ "2:1",
109
+ )
110
+ )
111
+ i += 2
112
+ j += 1
113
+ elif best == merge_cand and merge_cand >= one:
114
+ units.append(
115
+ AlignedUnit(
116
+ src[i],
117
+ " ".join(cand[j : j + 2]),
118
+ paragraph_index,
119
+ merge_cand,
120
+ "1:2",
121
+ )
122
+ )
123
+ i += 1
124
+ j += 2
125
+ else:
126
+ units.append(AlignedUnit(src[i], cand[j], paragraph_index, one, "1:1"))
127
+ i += 1
128
+ j += 1
129
+
130
+ # Trailing leftovers → attach to last unit or emit unpaired
131
+ while i < len(src):
132
+ if units:
133
+ last = units[-1]
134
+ units[-1] = AlignedUnit(
135
+ (last.source + " " + src[i]).strip(),
136
+ last.candidate,
137
+ paragraph_index,
138
+ _sim(last.source + " " + src[i], last.candidate),
139
+ "2:1",
140
+ )
141
+ else:
142
+ units.append(AlignedUnit(src[i], "", paragraph_index, 0.0, "1:1"))
143
+ i += 1
144
+ while j < len(cand):
145
+ if units:
146
+ last = units[-1]
147
+ units[-1] = AlignedUnit(
148
+ last.source,
149
+ (last.candidate + " " + cand[j]).strip(),
150
+ paragraph_index,
151
+ _sim(last.source, last.candidate + " " + cand[j]),
152
+ "1:2",
153
+ )
154
+ else:
155
+ units.append(AlignedUnit("", cand[j], paragraph_index, 0.0, "1:1"))
156
+ j += 1
157
+ return units
158
+
159
+
160
+ def align_documents(source: str, candidate: str) -> list[AlignedUnit]:
161
+ """Align source and candidate paragraph-by-paragraph."""
162
+ s_paras = split_paragraphs(source)
163
+ c_paras = split_paragraphs(candidate)
164
+ # Pad shorter side so paragraphs stay in order
165
+ n = max(len(s_paras), len(c_paras))
166
+ while len(s_paras) < n:
167
+ s_paras.append("")
168
+ while len(c_paras) < n:
169
+ c_paras.append("")
170
+ out: list[AlignedUnit] = []
171
+ for idx, (sp, cp) in enumerate(zip(s_paras, c_paras)):
172
+ if not sp.strip() and not cp.strip():
173
+ continue
174
+ if not sp.strip() or not cp.strip():
175
+ out.append(
176
+ AlignedUnit(sp.strip(), cp.strip(), idx, 0.0, "para")
177
+ )
178
+ continue
179
+ out.extend(align_paragraph_sentences(sp, cp, paragraph_index=idx))
180
+ return out
app/pipeline/generative.py CHANGED
@@ -1,7 +1,7 @@
1
- """Paragraph-level generative rewrite (quality-first path).
2
 
3
  Pipeline when models load:
4
- 1) Draft several paraphrases per paragraph (seq2seq)
5
  2) Validate polarity / facts / meaning (reject near-copies)
6
  3) Rank survivors for fidelity + useful difference
7
 
@@ -17,6 +17,7 @@ from difflib import SequenceMatcher
17
  from typing import Any
18
 
19
  from app.config import (
 
20
  GENERATIVE_MAX_PARAGRAPHS,
21
  GENERATIVE_MODEL,
22
  GENERATIVE_POLISH_ENABLED,
@@ -33,6 +34,14 @@ _tokenizer: Any = None
33
  _model: Any = None
34
  _failed = False
35
  _backend: str | None = None
 
 
 
 
 
 
 
 
36
 
37
  _SHARED_RULES = (
38
  "Keep ALL sentences and claims from the paragraph — do not drop or merge away ideas.\n"
@@ -80,6 +89,20 @@ _RETRY_PROMPT = (
80
  "Paraphrase:"
81
  )
82
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
83
 
84
  def generative_package_present() -> bool:
85
  if not ML_POLISH_AVAILABLE_DEFAULT or not GENERATIVE_POLISH_ENABLED:
@@ -112,8 +135,13 @@ def backend_name() -> str | None:
112
  return _backend
113
 
114
 
 
 
 
 
 
115
  def _ensure_model():
116
- global _tokenizer, _model, _failed, _backend
117
  if _model is not None or _failed:
118
  return _model
119
  with _lock:
@@ -121,15 +149,31 @@ def _ensure_model():
121
  return _model
122
  try:
123
  import torch
124
- from transformers import AutoModelForSeq2SeqLM, AutoTokenizer
125
 
126
  name = GENERATIVE_MODEL
127
- logger.info("Loading generative rewrite model %s …", name)
 
 
 
128
  _tokenizer = AutoTokenizer.from_pretrained(name)
129
- _model = AutoModelForSeq2SeqLM.from_pretrained(name)
 
 
 
 
 
 
 
 
 
 
 
 
 
130
  _model.eval()
131
  _model.to("cpu")
132
- _backend = f"transformers:{name}"
133
  _ = torch.__version__
134
  logger.info("Generative rewrite ready (%s)", _backend)
135
  return _model
@@ -139,6 +183,7 @@ def _ensure_model():
139
  _tokenizer = None
140
  _model = None
141
  _backend = None
 
142
  return None
143
 
144
 
@@ -146,11 +191,18 @@ def _clean_gen_text(text: str) -> str:
146
  text = re.sub(r"\s+", " ", (text or "").strip())
147
  if not text:
148
  return ""
 
 
 
 
149
  low = text.lower()
150
  for prefix in (
151
  "rewrite:",
152
  "paraphrase:",
153
  "new paraphrase:",
 
 
 
154
  "input:",
155
  "paragraph:",
156
  "sentence:",
@@ -159,6 +211,9 @@ def _clean_gen_text(text: str) -> str:
159
  if low.startswith(prefix):
160
  text = text[len(prefix) :].strip()
161
  low = text.lower()
 
 
 
162
  return text
163
 
164
 
@@ -182,6 +237,9 @@ def _gen_kwargs(strength: int, *, n_return: int | None = None) -> dict[str, Any]
182
  2: {"temperature": 1.05, "top_p": 0.95, "top_k": 60, "max_new_tokens": 320, "n": 5},
183
  }[strength]
184
  n = n_return if n_return is not None else base["n"]
 
 
 
185
  return {
186
  "do_sample": True,
187
  "num_beams": 1,
@@ -190,27 +248,92 @@ def _gen_kwargs(strength: int, *, n_return: int | None = None) -> dict[str, Any]
190
  "top_k": base["top_k"],
191
  "max_new_tokens": base["max_new_tokens"],
192
  "no_repeat_ngram_size": 3,
193
- "num_return_sequences": max(3, min(int(n), 5)),
194
  }
195
 
196
 
197
- def _generate_once(prompt: str, strength: int, *, n_return: int | None = None) -> list[str]:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
198
  model = _ensure_model()
199
  if model is None or _tokenizer is None:
200
  return []
 
201
  try:
202
- import torch
203
-
204
- inputs = _tokenizer(
205
- prompt,
206
- return_tensors="pt",
207
- truncation=True,
208
- max_length=512,
209
- )
210
- kwargs = _gen_kwargs(strength, n_return=n_return)
211
- with torch.no_grad():
212
- out_ids = model.generate(**inputs, **kwargs)
213
- return [_tokenizer.decode(row, skip_special_tokens=True) for row in out_ids]
214
  except Exception as exc:
215
  logger.warning("Generative decode failed: %s", exc)
216
  return []
@@ -239,6 +362,29 @@ def _chunk_long_paragraph(para: str, max_words: int = 180) -> list[str]:
239
  return chunks or [para]
240
 
241
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
242
  def _paraphrase_paragraph(
243
  paragraph: str,
244
  tone: str,
@@ -247,7 +393,7 @@ def _paraphrase_paragraph(
247
  min_meaning: float = 0.72,
248
  max_sim: float = NEAR_COPY_SURFACE,
249
  ) -> str:
250
- """Generate candidates for one paragraph; return best validated rewrite or original."""
251
  model = _ensure_model()
252
  if model is None or _tokenizer is None:
253
  return paragraph
@@ -255,7 +401,12 @@ def _paraphrase_paragraph(
255
  return paragraph
256
 
257
  tone_l = normalize_tone(tone)
258
- prompt = _TONE_PROMPTS.get(tone_l, _TONE_PROMPTS["Neutral"]).format(text=paragraph.strip())
 
 
 
 
 
259
  raw = _generate_once(prompt, strength)
260
  cands = [_clean_gen_text(x) for x in raw]
261
 
@@ -268,10 +419,14 @@ def _paraphrase_paragraph(
268
  fallback=paragraph,
269
  )
270
 
271
- # Retry when only near-copies / identical survived
272
  if _is_near_copy(paragraph, picked, max_sim):
273
- retry = _RETRY_PROMPT.format(tone=tone_l.lower(), text=paragraph.strip())
274
- raw2 = _generate_once(retry, min(2, strength + 1), n_return=5)
 
 
 
 
 
275
  cands2 = [_clean_gen_text(x) for x in raw2]
276
  picked2 = pick_best_candidate(
277
  paragraph,
@@ -285,7 +440,7 @@ def _paraphrase_paragraph(
285
  picked = picked2
286
  else:
287
  logger.info(
288
- "Generative paragraph still near-copy (sim=%.3f); keeping source chunk",
289
  _surface_sim(paragraph, picked2),
290
  )
291
  return paragraph
@@ -348,15 +503,16 @@ def generative_paraphrase(
348
  return None
349
  result = "\n\n".join(p for p in out_paras if p)
350
  logger.info(
351
- "Generative paraphrase: %s/%s units changed (strength=%s, model=%s, budget=%s, max_sim=%.2f)",
 
352
  changed_n,
353
  used,
354
  gen_strength,
355
  GENERATIVE_MODEL,
 
356
  "all" if unlimited else budget,
357
  max_sim,
358
  )
359
- # Whole-doc still a near-copy → signal orchestrator to use classical fallback
360
  if used > 0 and changed_n == 0:
361
  logger.info("Generative produced no useful paraphrases; returning near-copy for fallback")
362
  return result
@@ -366,10 +522,10 @@ def warm_generative() -> bool:
366
  try:
367
  ok = generative_available()
368
  if ok:
369
- _paraphrase_paragraph(
370
  "The system works well for most users and saves time.",
371
- "Neutral",
372
- 1,
373
  )
374
  return ok
375
  except Exception as exc:
 
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
 
 
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,
 
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 English text. Keep EVERY claim and the same polarity. "
41
+ "Do not flip negatives or antonyms. Preserve names, numbers, dates, and quotations. "
42
+ "Use different wording and sentence structure — do not copy phrases almost verbatim. "
43
+ "Do not summarize, drop, or invent content. Return only the rewritten text."
44
+ )
45
 
46
  _SHARED_RULES = (
47
  "Keep ALL sentences and claims from the paragraph — do not drop or merge away ideas.\n"
 
89
  "Paraphrase:"
90
  )
91
 
92
+ _CAUSAL_USER = (
93
+ "Tone: {tone}.\n"
94
+ "Rewrite the following text. Keep every claim and polarity. "
95
+ "Do not drop or invent content.\n\n"
96
+ "{text}"
97
+ )
98
+
99
+ _CAUSAL_RETRY_USER = (
100
+ "Tone: {tone}.\n"
101
+ "Rewrite again with clearly different wording and sentence structure, "
102
+ "but the exact same meaning and polarity. Do not copy long phrases.\n\n"
103
+ "{text}"
104
+ )
105
+
106
 
107
  def generative_package_present() -> bool:
108
  if not ML_POLISH_AVAILABLE_DEFAULT or not GENERATIVE_POLISH_ENABLED:
 
135
  return _backend
136
 
137
 
138
+ def backend_kind() -> str:
139
+ """Configured backend kind: seq2seq or causal (even before model load)."""
140
+ return GENERATIVE_BACKEND if GENERATIVE_BACKEND in {"seq2seq", "causal"} else "seq2seq"
141
+
142
+
143
  def _ensure_model():
144
+ global _tokenizer, _model, _failed, _backend, _backend_kind
145
  if _model is not None or _failed:
146
  return _model
147
  with _lock:
 
149
  return _model
150
  try:
151
  import torch
152
+ from transformers import AutoTokenizer
153
 
154
  name = GENERATIVE_MODEL
155
+ kind = backend_kind()
156
+ logger.info(
157
+ "Loading generative rewrite model %s (backend=%s) …", name, kind
158
+ )
159
  _tokenizer = AutoTokenizer.from_pretrained(name)
160
+ if _tokenizer.pad_token is None and _tokenizer.eos_token is not None:
161
+ _tokenizer.pad_token = _tokenizer.eos_token
162
+
163
+ if kind == "causal":
164
+ from transformers import AutoModelForCausalLM
165
+
166
+ _model = AutoModelForCausalLM.from_pretrained(name)
167
+ _backend_kind = "causal"
168
+ else:
169
+ from transformers import AutoModelForSeq2SeqLM
170
+
171
+ _model = AutoModelForSeq2SeqLM.from_pretrained(name)
172
+ _backend_kind = "seq2seq"
173
+
174
  _model.eval()
175
  _model.to("cpu")
176
+ _backend = f"transformers:{kind}:{name}"
177
  _ = torch.__version__
178
  logger.info("Generative rewrite ready (%s)", _backend)
179
  return _model
 
183
  _tokenizer = None
184
  _model = None
185
  _backend = None
186
+ _backend_kind = None
187
  return None
188
 
189
 
 
191
  text = re.sub(r"\s+", " ", (text or "").strip())
192
  if not text:
193
  return ""
194
+ # Drop casual chat wrappers
195
+ text = re.sub(
196
+ r"^(?:assistant|system|user)\s*[:\-]\s*", "", text, flags=re.I
197
+ ).strip()
198
  low = text.lower()
199
  for prefix in (
200
  "rewrite:",
201
  "paraphrase:",
202
  "new paraphrase:",
203
+ "rewritten text:",
204
+ "here is the rewrite:",
205
+ "here is the rewritten text:",
206
  "input:",
207
  "paragraph:",
208
  "sentence:",
 
211
  if low.startswith(prefix):
212
  text = text[len(prefix) :].strip()
213
  low = text.lower()
214
+ # Strip surrounding quotes if the whole output is quoted
215
+ if len(text) >= 2 and text[0] in "\"'" and text[-1] == text[0]:
216
+ text = text[1:-1].strip()
217
  return text
218
 
219
 
 
237
  2: {"temperature": 1.05, "top_p": 0.95, "top_k": 60, "max_new_tokens": 320, "n": 5},
238
  }[strength]
239
  n = n_return if n_return is not None else base["n"]
240
+ # Causal models: fewer return sequences to limit CPU RAM
241
+ if _backend_kind == "causal":
242
+ n = min(int(n), 3)
243
  return {
244
  "do_sample": True,
245
  "num_beams": 1,
 
248
  "top_k": base["top_k"],
249
  "max_new_tokens": base["max_new_tokens"],
250
  "no_repeat_ngram_size": 3,
251
+ "num_return_sequences": max(2, min(int(n), 5)),
252
  }
253
 
254
 
255
+ def _build_causal_prompt(text: str, tone: str, *, retry: bool = False) -> str:
256
+ tone_l = normalize_tone(tone)
257
+ user = (_CAUSAL_RETRY_USER if retry else _CAUSAL_USER).format(
258
+ tone=tone_l, text=text.strip()
259
+ )
260
+ messages = [
261
+ {"role": "system", "content": _SYSTEM_REWRITE},
262
+ {"role": "user", "content": user},
263
+ ]
264
+ assert _tokenizer is not None
265
+ try:
266
+ return _tokenizer.apply_chat_template(
267
+ messages,
268
+ tokenize=False,
269
+ add_generation_prompt=True,
270
+ )
271
+ except Exception:
272
+ # Fallback if chat template missing
273
+ return (
274
+ f"System: {_SYSTEM_REWRITE}\n\n"
275
+ f"User: {user}\n\n"
276
+ f"Assistant:"
277
+ )
278
+
279
+
280
+ def _generate_seq2seq(prompt: str, strength: int, *, n_return: int | None = None) -> list[str]:
281
+ import torch
282
+
283
+ assert _model is not None and _tokenizer is not None
284
+ inputs = _tokenizer(
285
+ prompt,
286
+ return_tensors="pt",
287
+ truncation=True,
288
+ max_length=512,
289
+ )
290
+ kwargs = _gen_kwargs(strength, n_return=n_return)
291
+ with torch.no_grad():
292
+ out_ids = _model.generate(**inputs, **kwargs)
293
+ return [_tokenizer.decode(row, skip_special_tokens=True) for row in out_ids]
294
+
295
+
296
+ def _generate_causal(prompt: str, strength: int, *, n_return: int | None = None) -> list[str]:
297
+ import torch
298
+
299
+ assert _model is not None and _tokenizer is not None
300
+ inputs = _tokenizer(
301
+ prompt,
302
+ return_tensors="pt",
303
+ truncation=True,
304
+ max_length=1024,
305
+ )
306
+ input_len = int(inputs["input_ids"].shape[-1])
307
+ kwargs = _gen_kwargs(strength, n_return=n_return)
308
+ # Avoid pad/eos issues on small causal models
309
+ if _tokenizer.eos_token_id is not None:
310
+ kwargs["eos_token_id"] = _tokenizer.eos_token_id
311
+ if _tokenizer.pad_token_id is not None:
312
+ kwargs["pad_token_id"] = _tokenizer.pad_token_id
313
+ with torch.no_grad():
314
+ out_ids = _model.generate(**inputs, **kwargs)
315
+ decoded: list[str] = []
316
+ for row in out_ids:
317
+ new_tokens = row[input_len:]
318
+ decoded.append(_tokenizer.decode(new_tokens, skip_special_tokens=True))
319
+ return decoded
320
+
321
+
322
+ def _generate_once(
323
+ prompt: str,
324
+ strength: int,
325
+ *,
326
+ n_return: int | None = None,
327
+ causal: bool | None = None,
328
+ ) -> list[str]:
329
  model = _ensure_model()
330
  if model is None or _tokenizer is None:
331
  return []
332
+ use_causal = (_backend_kind == "causal") if causal is None else causal
333
  try:
334
+ if use_causal:
335
+ return _generate_causal(prompt, strength, n_return=n_return)
336
+ return _generate_seq2seq(prompt, strength, n_return=n_return)
 
 
 
 
 
 
 
 
 
337
  except Exception as exc:
338
  logger.warning("Generative decode failed: %s", exc)
339
  return []
 
362
  return chunks or [para]
363
 
364
 
365
+ def rewrite_unit(
366
+ text: str,
367
+ *,
368
+ tone: str = "Neutral",
369
+ strength: int = 1,
370
+ min_meaning: float = 0.72,
371
+ max_sim: float = NEAR_COPY_SURFACE,
372
+ ) -> str:
373
+ """
374
+ Public targeted-unit rewrite (sentence or short paragraph).
375
+
376
+ Backend-agnostic: uses seq2seq or causal depending on GENERATIVE_BACKEND.
377
+ Returns the best validated rewrite, or the original unit if generation fails.
378
+ """
379
+ return _paraphrase_paragraph(
380
+ text,
381
+ tone,
382
+ strength,
383
+ min_meaning=min_meaning,
384
+ max_sim=max_sim,
385
+ )
386
+
387
+
388
  def _paraphrase_paragraph(
389
  paragraph: str,
390
  tone: str,
 
393
  min_meaning: float = 0.72,
394
  max_sim: float = NEAR_COPY_SURFACE,
395
  ) -> str:
396
+ """Generate candidates for one unit; return best validated rewrite or original."""
397
  model = _ensure_model()
398
  if model is None or _tokenizer is None:
399
  return paragraph
 
401
  return paragraph
402
 
403
  tone_l = normalize_tone(tone)
404
+ if _backend_kind == "causal":
405
+ prompt = _build_causal_prompt(paragraph, tone_l, retry=False)
406
+ else:
407
+ prompt = _TONE_PROMPTS.get(tone_l, _TONE_PROMPTS["Neutral"]).format(
408
+ text=paragraph.strip()
409
+ )
410
  raw = _generate_once(prompt, strength)
411
  cands = [_clean_gen_text(x) for x in raw]
412
 
 
419
  fallback=paragraph,
420
  )
421
 
 
422
  if _is_near_copy(paragraph, picked, max_sim):
423
+ if _backend_kind == "causal":
424
+ retry_prompt = _build_causal_prompt(paragraph, tone_l, retry=True)
425
+ else:
426
+ retry_prompt = _RETRY_PROMPT.format(
427
+ tone=tone_l.lower(), text=paragraph.strip()
428
+ )
429
+ raw2 = _generate_once(retry_prompt, min(2, strength + 1), n_return=5)
430
  cands2 = [_clean_gen_text(x) for x in raw2]
431
  picked2 = pick_best_candidate(
432
  paragraph,
 
440
  picked = picked2
441
  else:
442
  logger.info(
443
+ "Generative unit still near-copy (sim=%.3f); keeping source chunk",
444
  _surface_sim(paragraph, picked2),
445
  )
446
  return paragraph
 
503
  return None
504
  result = "\n\n".join(p for p in out_paras if p)
505
  logger.info(
506
+ "Generative paraphrase: %s/%s units changed (strength=%s, model=%s, "
507
+ "backend=%s, budget=%s, max_sim=%.2f)",
508
  changed_n,
509
  used,
510
  gen_strength,
511
  GENERATIVE_MODEL,
512
+ _backend_kind or backend_kind(),
513
  "all" if unlimited else budget,
514
  max_sim,
515
  )
 
516
  if used > 0 and changed_n == 0:
517
  logger.info("Generative produced no useful paraphrases; returning near-copy for fallback")
518
  return result
 
522
  try:
523
  ok = generative_available()
524
  if ok:
525
+ rewrite_unit(
526
  "The system works well for most users and saves time.",
527
+ tone="Neutral",
528
+ strength=1,
529
  )
530
  return ok
531
  except Exception as exc:
app/pipeline/orchestrator.py CHANGED
@@ -1,7 +1,9 @@
1
- """Orchestrate quality-first rewrite: grammar generative validate grammar.
2
 
3
- Classical lexicon/WordNet is demoted to offline fallback when generative is
4
- unavailable or ML polish is off.
 
 
5
  """
6
 
7
  from __future__ import annotations
@@ -9,18 +11,27 @@ from __future__ import annotations
9
  import logging
10
  import random
11
  import time
12
- from dataclasses import dataclass
13
  from difflib import SequenceMatcher
14
 
15
  from app.config import (
 
16
  GENERATIVE_MODEL,
17
  GENERATIVE_PRIMARY,
18
  GRAMMAR_FIX_INPUT,
19
  GRAMMAR_FIX_OUTPUT,
20
  LEXICON_FALLBACK,
 
21
  )
 
 
22
  from app.pipeline.candidate_validator import validate_candidate
23
- from app.pipeline.generative import generative_available, generative_paraphrase
 
 
 
 
 
24
  from app.pipeline.grammar_fix import correct_text
25
  from app.pipeline.mechanics import enforce_length_budget, scrub_phrases, tidy
26
  from app.pipeline.ml_context import (
@@ -52,6 +63,20 @@ if not logger.handlers:
52
  logging.basicConfig(level=logging.INFO, format="%(levelname)s %(name)s: %(message)s")
53
 
54
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
55
  @dataclass
56
  class RewriteResult:
57
  text: str
@@ -63,6 +88,8 @@ class RewriteResult:
63
  tone: str
64
  changed: bool
65
  notes: str = ""
 
 
66
 
67
 
68
  def _rng_for(text: str) -> random.Random:
@@ -114,7 +141,6 @@ def _classical_rewrite(
114
  rewritten = apply_tone_style(rewritten, tone, strength, rng)
115
  rewritten = apply_tone_contractions(rewritten, tone)
116
  if force:
117
- # Second-pass lexicon force for stubborn near-copies
118
  bump = min(2, strength + 1)
119
  paras = split_paragraphs(rewritten)
120
  forced: list[str] = []
@@ -139,19 +165,33 @@ def _classical_rewrite(
139
  return rewritten
140
 
141
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
142
  def _accept_generative(source: str, draft: str) -> bool:
143
  """Whole-document gate: polarity/meaning must hold vs grammar-corrected source."""
144
  if not draft or not draft.strip():
145
  return False
146
 
147
- def _hard_fail(reasons: list[str]) -> bool:
148
- for r in reasons:
149
- if r in {"polarity", "numbers", "quotes", "length", "coverage"}:
150
- return True
151
- if r.startswith("meaning") or r.startswith("entity"):
152
- return True
153
- return False
154
-
155
  s_paras = split_paragraphs(source)
156
  d_paras = split_paragraphs(draft)
157
  if len(s_paras) == len(d_paras) and s_paras:
@@ -172,6 +212,98 @@ def _accept_generative(source: str, draft: str) -> bool:
172
  return True
173
 
174
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
175
  def rewrite_text(
176
  text: str,
177
  *,
@@ -183,10 +315,9 @@ def rewrite_text(
183
  """
184
  Quality-first rewrite.
185
 
186
- When ml_polish=True and generative model loads:
187
- grammar → paragraph generative (multi-candidate validate/rank) → light scrub → grammar
188
- Otherwise (or if gen fails validation):
189
- grammar → classical lexicon fallback (if LEXICON_FALLBACK) → grammar
190
  """
191
  started = time.perf_counter()
192
  original = normalize_whitespace(text or "")
@@ -203,23 +334,61 @@ def rewrite_text(
203
 
204
  strength = max(0, min(2, int(strength)))
205
  tone = normalize_tone(tone)
206
- want_gen = bool(ml_polish) and GENERATIVE_PRIMARY and generative_available()
 
 
 
 
 
 
 
 
 
207
  want_minilm = bool(ml_polish) and minilm_available()
208
- # Skip MiniLM synonym ranking on gen path (it prefers near-copies)
209
- set_ml_polish(want_minilm and not want_gen)
210
  rng = _rng_for(
211
- source + "|" + tone + "|" + str(strength) + ("|ml" if (want_gen or want_minilm) else "")
212
  )
213
 
214
  notes = ""
215
  used_gen_main = False
 
216
  rewritten = source
217
 
218
- if want_gen:
219
  logger.info(
220
- "Quality path: generative rewrite (%s, model=%s)…",
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
221
  tone,
222
  GENERATIVE_MODEL,
 
223
  )
224
  draft = generative_paraphrase(
225
  source,
@@ -230,7 +399,6 @@ def rewrite_text(
230
  )
231
  if draft and draft.strip() and _accept_generative(source, draft):
232
  sim0 = _similarity_ratio(source, draft)
233
- # Near-copy does not count as a successful generative rewrite
234
  if sim0 >= 0.92:
235
  logger.info(
236
  "Generative draft too similar (%.3f); trying stronger pass",
@@ -294,18 +462,16 @@ def rewrite_text(
294
  notes = tip
295
  used_gen_main = False
296
 
297
- if not used_gen_main:
298
  if want_minilm:
299
  set_ml_polish(True)
300
  if LEXICON_FALLBACK or not ml_polish:
301
  rewritten = _classical_rewrite(source, tone, strength, rng)
302
  else:
303
- # ML requested but gen failed and lexicon disabled → corrected source only
304
  rewritten = source
305
  tip = "No generative output and lexicon fallback disabled; returning grammar-corrected source."
306
  notes = f"{notes} {tip}".strip() if notes else tip
307
 
308
- # Meaning guard on classical path only
309
  if want_minilm:
310
  set_ml_polish(True)
311
  rewritten = apply_minilm_polish(source, rewritten, tone)
@@ -325,8 +491,7 @@ def rewrite_text(
325
  rewritten = enforce_length_budget(source, rewritten, preserve_length)
326
  rewritten = tidy(rewritten)
327
 
328
- # Final document-level validation: if gen output flipped meaning, revert to source
329
- if used_gen_main and not _accept_generative(source, rewritten):
330
  logger.info("Final generative output failed validation; reverting to corrected source")
331
  rewritten = source
332
  tip = "Generative output failed final meaning checks; returned grammar-corrected source."
@@ -344,41 +509,46 @@ def rewrite_text(
344
  engine_bits: list[str] = []
345
  if GRAMMAR_FIX_INPUT or GRAMMAR_FIX_OUTPUT:
346
  engine_bits.append("grammar")
347
- if gen_was_used():
348
- engine_bits.append("generative")
 
 
 
 
 
 
 
349
  engine_bits.append("validate")
350
  engine_bits.append("rank")
351
- engine_bits.append("spacy" if spacy_available() else "regex-fallback")
352
- if used_gen_main:
353
- engine_bits.extend(["scrub-light"])
354
  else:
 
355
  engine_bits.extend(["rules", "structure", "lexicon-fallback", "mechanics", "tone"])
356
  if want_minilm and ml_was_used():
357
  engine_bits.append("minilm")
358
 
359
- if ml_polish and not want_gen and not want_minilm:
360
  tip = (
361
  "ML polish requested but generative/MiniLM models are unavailable "
362
  "(install torch+transformers and/or fastembed; set GENERATIVE_MODEL)."
363
  )
364
  notes = f"{notes} {tip}".strip() if notes else tip
365
- elif ml_polish and want_minilm and not want_gen:
366
  tip = "ML polish: MiniLM guard only (generative unavailable — check GENERATIVE_MODEL)."
367
  notes = f"{notes} {tip}".strip() if notes else tip
368
- elif ml_polish and want_gen and not want_minilm:
369
- tip = "Quality path: generative + validation (MiniLM optional for scoring)."
370
- notes = f"{notes} {tip}".strip() if notes else tip
371
  if grammar_fixed_input:
372
  tip = "Corrected grammar in the source before rewriting."
373
  notes = f"{notes} {tip}".strip() if notes else tip
374
 
375
  engine = "+".join(engine_bits)
376
  msg = (
377
- f"rewrite engine={engine} strength={strength} tone={tone} "
378
  f"words={word_count(original)}->{word_count(rewritten)} "
379
  f"changed={changed} similarity={ratio:.3f} "
380
- f"ml_polish={bool(ml_polish)} gen={want_gen} gen_main={used_gen_main} "
381
- f"model={GENERATIVE_MODEL if want_gen else '-'}"
 
382
  )
383
  logger.info(msg)
384
  print(f"[plainrewrite] {msg}", flush=True)
@@ -391,7 +561,7 @@ def rewrite_text(
391
  )
392
  notes = f"{notes} {tip}".strip() if notes else tip
393
  elif used_gen_main and ratio > 0.90:
394
- tip = "Generative rewrite stayed close to the source (meaning-first ranking)."
395
  notes = f"{notes} {tip}".strip() if notes else tip
396
 
397
  return RewriteResult(
@@ -404,6 +574,8 @@ def rewrite_text(
404
  tone=tone,
405
  changed=changed,
406
  notes=notes,
 
 
407
  )
408
 
409
 
 
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
 
11
  import logging
12
  import random
13
  import time
14
+ from dataclasses import dataclass, field
15
  from difflib import SequenceMatcher
16
 
17
  from app.config import (
18
+ GENERATIVE_BACKEND,
19
  GENERATIVE_MODEL,
20
  GENERATIVE_PRIMARY,
21
  GRAMMAR_FIX_INPUT,
22
  GRAMMAR_FIX_OUTPUT,
23
  LEXICON_FALLBACK,
24
+ PIPELINE_MODE,
25
  )
26
+ from app.pipeline.alignment import align_documents
27
+ from app.pipeline.candidate_ranker import NEAR_COPY_SURFACE
28
  from app.pipeline.candidate_validator import validate_candidate
29
+ from app.pipeline.generative import (
30
+ backend_kind,
31
+ generative_available,
32
+ generative_paraphrase,
33
+ rewrite_unit,
34
+ )
35
  from app.pipeline.grammar_fix import correct_text
36
  from app.pipeline.mechanics import enforce_length_budget, scrub_phrases, tidy
37
  from app.pipeline.ml_context import (
 
63
  logging.basicConfig(level=logging.INFO, format="%(levelname)s %(name)s: %(message)s")
64
 
65
 
66
+ @dataclass
67
+ class HybridStats:
68
+ units: int = 0
69
+ classical_kept: int = 0
70
+ regenerated: int = 0
71
+ gen_accepted: int = 0
72
+ reverted_source: int = 0
73
+ reasons: dict[str, int] = field(default_factory=dict)
74
+
75
+ def bump_reason(self, reason: str) -> None:
76
+ key = reason.split(":")[0] if reason else "other"
77
+ self.reasons[key] = self.reasons.get(key, 0) + 1
78
+
79
+
80
  @dataclass
81
  class RewriteResult:
82
  text: str
 
88
  tone: str
89
  changed: bool
90
  notes: str = ""
91
+ pipeline_mode: str = ""
92
+ hybrid: HybridStats | None = None
93
 
94
 
95
  def _rng_for(text: str) -> random.Random:
 
141
  rewritten = apply_tone_style(rewritten, tone, strength, rng)
142
  rewritten = apply_tone_contractions(rewritten, tone)
143
  if force:
 
144
  bump = min(2, strength + 1)
145
  paras = split_paragraphs(rewritten)
146
  forced: list[str] = []
 
165
  return rewritten
166
 
167
 
168
+ def _hard_fail(reasons: list[str]) -> bool:
169
+ for r in reasons:
170
+ if r in {"polarity", "numbers", "quotes", "length", "coverage"}:
171
+ return True
172
+ if r.startswith("meaning") or r.startswith("entity"):
173
+ return True
174
+ return False
175
+
176
+
177
+ def _needs_patch(source_unit: str, candidate: str, *, max_sim: float = NEAR_COPY_SURFACE) -> tuple[bool, list[str]]:
178
+ """Return (needs_regen, reasons). Near-copy or validation failure → patch."""
179
+ if not candidate.strip():
180
+ return True, ["empty"]
181
+ if not source_unit.strip():
182
+ return False, []
183
+ v = validate_candidate(source_unit, candidate, min_meaning=0.72, max_surface=max_sim)
184
+ if v.ok:
185
+ return False, []
186
+ # identical / too_similar → patch; hard fails → patch; soft → still patch if not ok
187
+ return True, list(v.reasons)
188
+
189
+
190
  def _accept_generative(source: str, draft: str) -> bool:
191
  """Whole-document gate: polarity/meaning must hold vs grammar-corrected source."""
192
  if not draft or not draft.strip():
193
  return False
194
 
 
 
 
 
 
 
 
 
195
  s_paras = split_paragraphs(source)
196
  d_paras = split_paragraphs(draft)
197
  if len(s_paras) == len(d_paras) and s_paras:
 
212
  return True
213
 
214
 
215
+ def _hybrid_rewrite(
216
+ source: str,
217
+ tone: str,
218
+ strength: int,
219
+ rng: random.Random,
220
+ *,
221
+ can_generate: bool,
222
+ ) -> tuple[str, HybridStats, bool]:
223
+ """
224
+ Classical baseline → align → validate → generative patch failed units.
225
+
226
+ Returns (text, stats, used_any_gen).
227
+ """
228
+ stats = HybridStats()
229
+ baseline = _classical_rewrite(source, tone, strength, rng)
230
+ units = align_documents(source, baseline)
231
+ if not units:
232
+ return baseline, stats, False
233
+
234
+ # Group by paragraph to reassemble with blank lines
235
+ by_para: dict[int, list[str]] = {}
236
+ used_gen = False
237
+
238
+ for unit in units:
239
+ stats.units += 1
240
+ src_u = unit.source.strip()
241
+ cand_u = unit.candidate.strip()
242
+ if not src_u:
243
+ if cand_u:
244
+ by_para.setdefault(unit.paragraph_index, []).append(cand_u)
245
+ continue
246
+
247
+ needs, reasons = _needs_patch(src_u, cand_u)
248
+ if not needs:
249
+ stats.classical_kept += 1
250
+ by_para.setdefault(unit.paragraph_index, []).append(cand_u or src_u)
251
+ continue
252
+
253
+ for r in reasons:
254
+ stats.bump_reason(r)
255
+
256
+ if not can_generate:
257
+ # Prefer classical if soft failure; revert to source on hard fail / empty
258
+ if cand_u and not _hard_fail(reasons) and "too_similar" not in reasons and "identical" not in reasons:
259
+ stats.classical_kept += 1
260
+ by_para.setdefault(unit.paragraph_index, []).append(cand_u)
261
+ else:
262
+ # Near-copy with no gen: keep classical if present else source
263
+ if cand_u and ("too_similar" in reasons or "identical" in reasons):
264
+ stats.classical_kept += 1
265
+ by_para.setdefault(unit.paragraph_index, []).append(cand_u)
266
+ else:
267
+ stats.reverted_source += 1
268
+ by_para.setdefault(unit.paragraph_index, []).append(src_u)
269
+ continue
270
+
271
+ stats.regenerated += 1
272
+ mark_gen_used()
273
+ used_gen = True
274
+ gen_raw = rewrite_unit(
275
+ src_u,
276
+ tone=tone,
277
+ strength=max(1, strength),
278
+ min_meaning=0.72,
279
+ max_sim=0.92,
280
+ )
281
+ # Grammar-clean generated unit before revalidation
282
+ gen_clean = correct_text(gen_raw) if gen_raw else gen_raw
283
+ gen_clean = tidy(gen_clean or "")
284
+
285
+ vg = validate_candidate(src_u, gen_clean, min_meaning=0.70, max_surface=0.92)
286
+ near = _similarity_ratio(src_u, gen_clean) >= 0.92
287
+ if vg.ok and not near:
288
+ stats.gen_accepted += 1
289
+ by_para.setdefault(unit.paragraph_index, []).append(gen_clean)
290
+ else:
291
+ stats.reverted_source += 1
292
+ for r in vg.reasons:
293
+ stats.bump_reason(r)
294
+ logger.info(
295
+ "Hybrid gen unit rejected (%s); keeping corrected source",
296
+ vg.reasons or ["near_copy"],
297
+ )
298
+ by_para.setdefault(unit.paragraph_index, []).append(src_u)
299
+
300
+ para_idxs = sorted(by_para.keys())
301
+ paras_out = [" ".join(by_para[i]).strip() for i in para_idxs if by_para[i]]
302
+ text = tidy("\n\n".join(p for p in paras_out if p))
303
+ text = _light_post_gen(text, tone)
304
+ return text, stats, used_gen
305
+
306
+
307
  def rewrite_text(
308
  text: str,
309
  *,
 
315
  """
316
  Quality-first rewrite.
317
 
318
+ PIPELINE_MODE=hybrid (default with ML): classical → validate → generative patch
319
+ PIPELINE_MODE=generative: whole-document generative rewrite
320
+ PIPELINE_MODE=classical / ml_polish off: classical lexicon path
 
321
  """
322
  started = time.perf_counter()
323
  original = normalize_whitespace(text or "")
 
334
 
335
  strength = max(0, min(2, int(strength)))
336
  tone = normalize_tone(tone)
337
+ mode = PIPELINE_MODE if ml_polish else "classical"
338
+ if mode == "classical" and not ml_polish:
339
+ pass
340
+ elif not ml_polish:
341
+ mode = "classical"
342
+ elif mode == "hybrid" and not GENERATIVE_PRIMARY:
343
+ # hybrid without gen primary still runs classical+validate; patch only if model loads
344
+ pass
345
+
346
+ gen_ready = bool(ml_polish) and generative_available()
347
  want_minilm = bool(ml_polish) and minilm_available()
348
+ # Skip MiniLM synonym ranking on generative-heavy paths
349
+ set_ml_polish(want_minilm and mode == "classical")
350
  rng = _rng_for(
351
+ source + "|" + tone + "|" + str(strength) + ("|ml" if ml_polish else "")
352
  )
353
 
354
  notes = ""
355
  used_gen_main = False
356
+ hybrid_stats: HybridStats | None = None
357
  rewritten = source
358
 
359
+ if mode == "hybrid" and ml_polish:
360
  logger.info(
361
+ "Hybrid path: classical validate → patch (%s, model=%s, backend=%s)…",
362
+ tone,
363
+ GENERATIVE_MODEL if gen_ready else "-",
364
+ GENERATIVE_BACKEND if gen_ready else "-",
365
+ )
366
+ rewritten, hybrid_stats, used_gen_main = _hybrid_rewrite(
367
+ source,
368
+ tone,
369
+ strength,
370
+ rng,
371
+ can_generate=gen_ready,
372
+ )
373
+ if not gen_ready:
374
+ tip = (
375
+ "Hybrid: generative unavailable; classical baseline with safety fallbacks."
376
+ )
377
+ notes = tip
378
+ else:
379
+ tip = (
380
+ f"Hybrid: kept={hybrid_stats.classical_kept} "
381
+ f"patched={hybrid_stats.gen_accepted}/{hybrid_stats.regenerated} "
382
+ f"reverted={hybrid_stats.reverted_source}"
383
+ )
384
+ notes = tip
385
+
386
+ elif mode == "generative" and ml_polish and gen_ready:
387
+ logger.info(
388
+ "Generative path (%s, model=%s, backend=%s)…",
389
  tone,
390
  GENERATIVE_MODEL,
391
+ GENERATIVE_BACKEND,
392
  )
393
  draft = generative_paraphrase(
394
  source,
 
399
  )
400
  if draft and draft.strip() and _accept_generative(source, draft):
401
  sim0 = _similarity_ratio(source, draft)
 
402
  if sim0 >= 0.92:
403
  logger.info(
404
  "Generative draft too similar (%.3f); trying stronger pass",
 
462
  notes = tip
463
  used_gen_main = False
464
 
465
+ if mode != "hybrid" and not used_gen_main:
466
  if want_minilm:
467
  set_ml_polish(True)
468
  if LEXICON_FALLBACK or not ml_polish:
469
  rewritten = _classical_rewrite(source, tone, strength, rng)
470
  else:
 
471
  rewritten = source
472
  tip = "No generative output and lexicon fallback disabled; returning grammar-corrected source."
473
  notes = f"{notes} {tip}".strip() if notes else tip
474
 
 
475
  if want_minilm:
476
  set_ml_polish(True)
477
  rewritten = apply_minilm_polish(source, rewritten, tone)
 
491
  rewritten = enforce_length_budget(source, rewritten, preserve_length)
492
  rewritten = tidy(rewritten)
493
 
494
+ if used_gen_main and mode == "generative" and not _accept_generative(source, rewritten):
 
495
  logger.info("Final generative output failed validation; reverting to corrected source")
496
  rewritten = source
497
  tip = "Generative output failed final meaning checks; returned grammar-corrected source."
 
509
  engine_bits: list[str] = []
510
  if GRAMMAR_FIX_INPUT or GRAMMAR_FIX_OUTPUT:
511
  engine_bits.append("grammar")
512
+ if mode == "hybrid":
513
+ engine_bits.extend(["classical", "align", "validate"])
514
+ if used_gen_main or (hybrid_stats and hybrid_stats.regenerated):
515
+ engine_bits.append(f"generative-patch:{backend_kind()}")
516
+ engine_bits.append("rank")
517
+ engine_bits.append("spacy" if spacy_available() else "regex-fallback")
518
+ engine_bits.append("scrub-light")
519
+ elif gen_was_used():
520
+ engine_bits.append(f"generative:{backend_kind()}")
521
  engine_bits.append("validate")
522
  engine_bits.append("rank")
523
+ engine_bits.append("spacy" if spacy_available() else "regex-fallback")
524
+ engine_bits.append("scrub-light")
 
525
  else:
526
+ engine_bits.append("spacy" if spacy_available() else "regex-fallback")
527
  engine_bits.extend(["rules", "structure", "lexicon-fallback", "mechanics", "tone"])
528
  if want_minilm and ml_was_used():
529
  engine_bits.append("minilm")
530
 
531
+ if ml_polish and not gen_ready and not want_minilm:
532
  tip = (
533
  "ML polish requested but generative/MiniLM models are unavailable "
534
  "(install torch+transformers and/or fastembed; set GENERATIVE_MODEL)."
535
  )
536
  notes = f"{notes} {tip}".strip() if notes else tip
537
+ elif ml_polish and want_minilm and not gen_ready and mode != "hybrid":
538
  tip = "ML polish: MiniLM guard only (generative unavailable — check GENERATIVE_MODEL)."
539
  notes = f"{notes} {tip}".strip() if notes else tip
 
 
 
540
  if grammar_fixed_input:
541
  tip = "Corrected grammar in the source before rewriting."
542
  notes = f"{notes} {tip}".strip() if notes else tip
543
 
544
  engine = "+".join(engine_bits)
545
  msg = (
546
+ f"rewrite engine={engine} mode={mode} strength={strength} tone={tone} "
547
  f"words={word_count(original)}->{word_count(rewritten)} "
548
  f"changed={changed} similarity={ratio:.3f} "
549
+ f"ml_polish={bool(ml_polish)} gen={gen_ready} gen_main={used_gen_main} "
550
+ f"backend={GENERATIVE_BACKEND if gen_ready else '-'} "
551
+ f"model={GENERATIVE_MODEL if gen_ready else '-'}"
552
  )
553
  logger.info(msg)
554
  print(f"[plainrewrite] {msg}", flush=True)
 
561
  )
562
  notes = f"{notes} {tip}".strip() if notes else tip
563
  elif used_gen_main and ratio > 0.90:
564
+ tip = "Rewrite stayed close to the source (meaning-first ranking)."
565
  notes = f"{notes} {tip}".strip() if notes else tip
566
 
567
  return RewriteResult(
 
574
  tone=tone,
575
  changed=changed,
576
  notes=notes,
577
+ pipeline_mode=mode,
578
+ hybrid=hybrid_stats,
579
  )
580
 
581
 
requirements.txt CHANGED
@@ -9,9 +9,10 @@ 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 + seq2seq rewrite (lazy-loaded)
13
- # Default model is flan-t5-base (override GENERATIVE_MODEL for larger GPUs)
14
  fastembed>=0.4.2
15
  torch>=2.2.0
16
  transformers>=4.40.0
17
  sentencepiece>=0.2.0
 
 
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
scripts/test_smollm2_pipeline.py ADDED
@@ -0,0 +1,138 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Regression tests for SmolLM2 / hybrid pipeline (no model download required)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import sys
7
+ from pathlib import Path
8
+
9
+ ROOT = Path(__file__).resolve().parent.parent
10
+ sys.path.insert(0, str(ROOT))
11
+
12
+ from app.config import GENERATIVE_BACKEND, PIPELINE_MODE
13
+ from app.pipeline.alignment import align_documents, align_paragraph_sentences
14
+ from app.pipeline.candidate_validator import validate_candidate
15
+ from app.pipeline.generative import backend_kind, _clean_gen_text
16
+ from app.pipeline.grammar_fix import correct_text
17
+ from app.pipeline.orchestrator import rewrite_text
18
+
19
+
20
+ def test_backend_defaults() -> None:
21
+ # Defaults come from env at import time; kind helper mirrors config
22
+ assert backend_kind() in {"seq2seq", "causal"}
23
+ assert GENERATIVE_BACKEND in {"seq2seq", "causal"}
24
+ assert PIPELINE_MODE in {"hybrid", "generative", "classical"}
25
+ print("backend defaults OK:", GENERATIVE_BACKEND, PIPELINE_MODE)
26
+
27
+
28
+ def test_clean_causal_wrappers() -> None:
29
+ assert _clean_gen_text('Assistant: Hello there.') == "Hello there."
30
+ assert _clean_gen_text('"Rewritten sentence."') == "Rewritten sentence."
31
+ assert _clean_gen_text("Paraphrase: People live healthier lives.") == (
32
+ "People live healthier lives."
33
+ )
34
+ print("clean wrappers OK")
35
+
36
+
37
+ def test_alignment_1to1() -> None:
38
+ src = "Alpha is first. Beta is second."
39
+ cand = "Alpha comes first. Beta comes second."
40
+ units = align_paragraph_sentences(src, cand)
41
+ assert len(units) == 2
42
+ assert all(u.kind == "1:1" for u in units)
43
+ print("alignment 1:1 OK")
44
+
45
+
46
+ def test_alignment_preserves_paragraphs() -> None:
47
+ src = "One. Two.\n\nThree."
48
+ cand = "Uno. Dos.\n\nTres."
49
+ units = align_documents(src, cand)
50
+ idxs = {u.paragraph_index for u in units}
51
+ assert 0 in idxs and 1 in idxs
52
+ print("alignment paragraphs OK:", len(units), "units")
53
+
54
+
55
+ def test_truncated_rejected() -> None:
56
+ orig = (
57
+ "Nowadays many people are living an unhealthy life because they don't have enough time. "
58
+ "Eating fast foods is becoming very common and people don't realize how much it affects their health."
59
+ )
60
+ truncated = (
61
+ "Fast food is becoming very common because many people don't realize "
62
+ "how much it affects their health."
63
+ )
64
+ v = validate_candidate(orig, truncated, min_meaning=0.5)
65
+ assert not v.ok
66
+ assert "length" in v.reasons or "coverage" in v.reasons
67
+ print("truncated rejected OK")
68
+
69
+
70
+ def test_hybrid_classical_without_ml() -> None:
71
+ orig = (
72
+ "Nowadays many peoples are living unhealthy life because they don't have enough times. "
73
+ "Eating fast foods are becoming very common and peoples don't realizes how much it affect their health."
74
+ )
75
+ r = rewrite_text(
76
+ orig,
77
+ tone="Neutral",
78
+ strength=1,
79
+ preserve_length=True,
80
+ ml_polish=False,
81
+ )
82
+ out = r.text.lower()
83
+ assert "peoples" not in out
84
+ assert "an unhealthy life" in out
85
+ assert "affects" in out
86
+ assert "enough time" in out
87
+ # Both claims should survive classical path
88
+ assert "unhealthy" in out
89
+ assert "fast food" in out or "fast foods" in out
90
+ assert r.pipeline_mode == "classical"
91
+ print("hybrid classical-without-ml OK:", r.engine)
92
+
93
+
94
+ def test_hybrid_mode_with_ml_no_model() -> None:
95
+ """When generative fails to load, hybrid still returns safe classical output."""
96
+ orig = (
97
+ "Nowadays many peoples are living unhealthy life because they don't have enough times. "
98
+ "Eating fast foods are becoming very common and peoples don't realizes how much it affect their health."
99
+ )
100
+ # Force unavailable generative by not requiring it; hybrid runs classical+validate
101
+ r = rewrite_text(
102
+ orig,
103
+ tone="Neutral",
104
+ strength=1,
105
+ preserve_length=True,
106
+ ml_polish=True,
107
+ )
108
+ out = r.text.lower()
109
+ assert "unhealthy" in out
110
+ assert "health" in out
111
+ # Must not collapse to single truncated claim only
112
+ g = correct_text(orig).lower()
113
+ assert "enough time" in out or "enough time" in g
114
+ print("hybrid ml path OK mode=", r.pipeline_mode, "engine=", r.engine)
115
+ if r.hybrid:
116
+ print(
117
+ " units=",
118
+ r.hybrid.units,
119
+ "kept=",
120
+ r.hybrid.classical_kept,
121
+ "regen=",
122
+ r.hybrid.regenerated,
123
+ "accepted=",
124
+ r.hybrid.gen_accepted,
125
+ "reverted=",
126
+ r.hybrid.reverted_source,
127
+ )
128
+
129
+
130
+ if __name__ == "__main__":
131
+ test_backend_defaults()
132
+ test_clean_causal_wrappers()
133
+ test_alignment_1to1()
134
+ test_alignment_preserves_paragraphs()
135
+ test_truncated_rejected()
136
+ test_hybrid_classical_without_ml()
137
+ test_hybrid_mode_with_ml_no_model()
138
+ print("\nALL SMOLLM2 / HYBRID PIPELINE TESTS PASSED")