idnameraj commited on
Commit
aeab924
·
verified ·
1 Parent(s): 05ea74f

Upload 91 files

Browse files
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/__pycache__/main.cpython-311.pyc CHANGED
Binary files a/app/__pycache__/main.cpython-311.pyc and b/app/__pycache__/main.cpython-311.pyc differ
 
app/config.py CHANGED
@@ -196,3 +196,16 @@ ENGINE_PARAPHRASE_MIN_DIVERGENCE = max(
196
  0.6,
197
  ),
198
  )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
196
  0.6,
197
  ),
198
  )
199
+
200
+ # Phrase-level rewrite: verb–object / modifier–noun spans via WordNet + optional T5.
201
+ _ephr = (os.environ.get("ENGINE_PHRASE_REWRITE") or "true").strip().lower()
202
+ ENGINE_PHRASE_REWRITE = _ephr in {"1", "true", "yes", "on"}
203
+ # 0 = dynamic (1 normally, 2 with polish). Positive values hard-cap changes.
204
+ ENGINE_PHRASE_MAX_CHANGES = max(
205
+ 0,
206
+ min(int(os.environ.get("ENGINE_PHRASE_MAX_CHANGES", "0") or "0"), 4),
207
+ )
208
+ ENGINE_PHRASE_MIN_SIM = max(
209
+ 0.0,
210
+ min(float(os.environ.get("ENGINE_PHRASE_MIN_SIM", "0.74") or "0.74"), 1.0),
211
+ )
app/engine/__pycache__/orchestrator.cpython-311.pyc CHANGED
Binary files a/app/engine/__pycache__/orchestrator.cpython-311.pyc and b/app/engine/__pycache__/orchestrator.cpython-311.pyc differ
 
app/engine/__pycache__/phrase.cpython-311.pyc ADDED
Binary file (41.4 kB). View file
 
app/engine/orchestrator.py CHANGED
@@ -15,6 +15,9 @@ from app.config import (
15
  ENGINE_PARAPHRASE,
16
  ENGINE_PARAPHRASE_MIN_SIM,
17
  ENGINE_PARAPHRASE_PRIMARY,
 
 
 
18
  ENGINE_REQUIRE_WORDING_CHANGE,
19
  ENGINE_SAFETY_MIN,
20
  ENGINE_USE_MINILM_SAFETY,
@@ -30,6 +33,7 @@ from app.engine.lexical import (
30
  ensure_wording_change,
31
  refine_sentence,
32
  )
 
33
  from app.engine.models import (
34
  DocumentBlock,
35
  EngineResult,
@@ -180,6 +184,60 @@ def _apply_extra_polish(
180
  return record
181
 
182
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
183
  def _unchanged(record: SentenceRecord) -> bool:
184
  return (
185
  record.rewritten.strip().lower().rstrip(".!?")
@@ -388,6 +446,14 @@ def _finalize_sentence(
388
  use_minilm=use_minilm,
389
  )
390
  record = _apply_forced_rewrite(record, enabled=force_enabled)
 
 
 
 
 
 
 
 
391
  record = _apply_lexical_refinement(
392
  record,
393
  enabled=lexical_enabled,
 
15
  ENGINE_PARAPHRASE,
16
  ENGINE_PARAPHRASE_MIN_SIM,
17
  ENGINE_PARAPHRASE_PRIMARY,
18
+ ENGINE_PHRASE_MAX_CHANGES,
19
+ ENGINE_PHRASE_MIN_SIM,
20
+ ENGINE_PHRASE_REWRITE,
21
  ENGINE_REQUIRE_WORDING_CHANGE,
22
  ENGINE_SAFETY_MIN,
23
  ENGINE_USE_MINILM_SAFETY,
 
33
  ensure_wording_change,
34
  refine_sentence,
35
  )
36
+ from app.engine.phrase import rewrite_phrases
37
  from app.engine.models import (
38
  DocumentBlock,
39
  EngineResult,
 
184
  return record
185
 
186
 
187
+ def _apply_phrase_rewrite(
188
+ record: SentenceRecord,
189
+ *,
190
+ enabled: bool,
191
+ polish: bool,
192
+ safety_min: float,
193
+ min_confidence: float,
194
+ use_minilm: bool,
195
+ ) -> SentenceRecord:
196
+ """Rewrite verb–object phrases before single-word lexical polish."""
197
+ if not enabled or record.sentence_type not in _REWRITEABLE_TYPES:
198
+ return record
199
+ before = record.rewritten
200
+ max_changes = (
201
+ ENGINE_PHRASE_MAX_CHANGES
202
+ if ENGINE_PHRASE_MAX_CHANGES > 0
203
+ else (2 if polish else 1)
204
+ )
205
+ refined = rewrite_phrases(
206
+ before,
207
+ max_changes=max_changes,
208
+ polish=polish,
209
+ min_sim=min(ENGINE_PHRASE_MIN_SIM, safety_min),
210
+ )
211
+ if not refined.changes or refined.text == before:
212
+ return record
213
+ safety = check_safety(
214
+ before,
215
+ refined.text,
216
+ min_meaning=min(safety_min, ENGINE_PHRASE_MIN_SIM),
217
+ min_confidence=min_confidence,
218
+ use_minilm=use_minilm,
219
+ protected_entities=None,
220
+ protected_auxiliaries=None,
221
+ structural_validation=False,
222
+ )
223
+ if not safety.ok:
224
+ return record
225
+
226
+ prior_lex = list(record.lexical_changes)
227
+ record.rewritten = refined.text
228
+ record.lexical_changes = prior_lex + refined.changes
229
+ record.status = "rewritten"
230
+ if not record.template_id:
231
+ record.template_id = "phrase_rewrite"
232
+ elif "+phrase" not in record.template_id:
233
+ record.template_id = f"{record.template_id}+phrase"
234
+ record.confidence = min(
235
+ safety.confidence,
236
+ record.confidence if record.confidence > 0 else max(min_confidence, 0.55),
237
+ )
238
+ return record
239
+
240
+
241
  def _unchanged(record: SentenceRecord) -> bool:
242
  return (
243
  record.rewritten.strip().lower().rstrip(".!?")
 
446
  use_minilm=use_minilm,
447
  )
448
  record = _apply_forced_rewrite(record, enabled=force_enabled)
449
+ record = _apply_phrase_rewrite(
450
+ record,
451
+ enabled=ENGINE_PHRASE_REWRITE,
452
+ polish=lexical_polish,
453
+ safety_min=safety_min,
454
+ min_confidence=min_confidence,
455
+ use_minilm=use_minilm,
456
+ )
457
  record = _apply_lexical_refinement(
458
  record,
459
  enabled=lexical_enabled,
app/engine/paraphrase/__init__.py CHANGED
@@ -381,3 +381,76 @@ def paraphrase_sentence(
381
  confidence=round(float(confidence), 4),
382
  candidates=candidates,
383
  )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
381
  confidence=round(float(confidence), 4),
382
  candidates=candidates,
383
  )
384
+
385
+
386
+ def _normalize_span_candidate(text: str) -> str:
387
+ value = re.sub(r"\s+", " ", (text or "").strip())
388
+ value = value.strip(" \"'")
389
+ # Spans are mid-sentence fragments — keep lowercase lead when source is.
390
+ return value.rstrip(".!?")
391
+
392
+
393
+ def paraphrase_span(
394
+ text: str,
395
+ *,
396
+ num_return: int | None = None,
397
+ min_sim: float | None = None,
398
+ ) -> ParaphraseResult:
399
+ """Paraphrase a short verb–object span for phrase-level rewriting."""
400
+ source = (text or "").strip()
401
+ if not source:
402
+ return ParaphraseResult(text=text, reason="empty")
403
+ if _get_pipeline() is None:
404
+ return ParaphraseResult(text=source, reason="unavailable")
405
+
406
+ returns = num_return if num_return is not None else 4
407
+ threshold = min_sim if min_sim is not None else max(0.70, ENGINE_PARAPHRASE_MIN_SIM - 0.02)
408
+ raw = _generate_raw(
409
+ source,
410
+ num_return=returns,
411
+ max_surface=0.92,
412
+ min_overlap=0.28,
413
+ )
414
+ # Also accept lightly normalized span forms even if sentence normalizer altered them.
415
+ cleaned: list[str] = []
416
+ seen: set[str] = set()
417
+ source_key = source.lower().rstrip(".!?")
418
+ for item in raw:
419
+ value = _normalize_span_candidate(item)
420
+ key = value.lower()
421
+ if not value or key == source_key or key in seen:
422
+ continue
423
+ if _content_overlap(source, value) < 0.28:
424
+ continue
425
+ if not sufficiently_changed(source, value, max_surface=0.94, min_divergence=0.06):
426
+ continue
427
+ seen.add(key)
428
+ cleaned.append(value)
429
+ if not cleaned:
430
+ return ParaphraseResult(text=source, reason="no_candidates", candidates=[])
431
+
432
+ surface_scores = {
433
+ candidate: surface_similarity(source, candidate) for candidate in cleaned
434
+ }
435
+ best = pick_best_candidate(
436
+ source,
437
+ cleaned,
438
+ min_meaning=threshold,
439
+ prefer_divergent=True,
440
+ surface_scores=surface_scores,
441
+ )
442
+ if best is None:
443
+ # MiniLM missing — pick most surface-different with overlap floor.
444
+ ranked = sorted(
445
+ cleaned,
446
+ key=lambda c: (surface_scores.get(c, 1.0), -_content_overlap(source, c)),
447
+ )
448
+ best = ranked[0]
449
+ meaning = score_candidate(source, best)
450
+ confidence = meaning if meaning is not None else _content_overlap(source, best)
451
+ return ParaphraseResult(
452
+ text=best,
453
+ confidence=round(float(confidence), 4),
454
+ candidates=cleaned,
455
+ reason="span_paraphrase",
456
+ )
app/engine/paraphrase/__pycache__/__init__.cpython-311.pyc CHANGED
Binary files a/app/engine/paraphrase/__pycache__/__init__.cpython-311.pyc and b/app/engine/paraphrase/__pycache__/__init__.cpython-311.pyc differ
 
app/engine/phrase.py ADDED
@@ -0,0 +1,852 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Meaning-safe phrase-level rewrite for verb–object and modifier–noun spans.
2
+
3
+ Uses WordNet (synonyms + close hyponyms) with full-phrase collocation scoring,
4
+ and optional T5 span paraphrase when the local model is available. No hardcoded
5
+ synonym maps — candidates come from WordNet relations or model beams.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import logging
11
+ import re
12
+ from dataclasses import dataclass, field
13
+ from typing import Any
14
+
15
+ from lemminflect import getInflection
16
+ from wordfreq import zipf_frequency
17
+
18
+ from app.config import (
19
+ ENGINE_PHRASE_MAX_CHANGES,
20
+ ENGINE_PHRASE_MIN_SIM,
21
+ ENGINE_PHRASE_REWRITE,
22
+ ENGINE_WORDNET_LEXICON,
23
+ )
24
+ from app.engine.models import LexicalChange
25
+ from app.pipeline.minilm import pick_best_candidate, score_candidate
26
+ from app.pipeline.nlp import get_nlp
27
+
28
+ logger = logging.getLogger("plainrewrite.phrase")
29
+
30
+ _WORD = re.compile(r"[A-Za-z][A-Za-z'-]*")
31
+ _PROTECTED_MARKER = re.compile(r"ZZPROTECTED(?:URL|EMAIL|PATH)\d+ZZ", re.I)
32
+ _QUOTES = frozenset({'"', "“", "”", "‘", "’"})
33
+
34
+
35
+ @dataclass
36
+ class PhraseResult:
37
+ text: str
38
+ changes: list[LexicalChange] = field(default_factory=list)
39
+ confidence: float = 0.0
40
+ reason: str = ""
41
+
42
+
43
+ @dataclass
44
+ class _SpanTarget:
45
+ verb: Any
46
+ noun: Any
47
+ start: int
48
+ end: int
49
+ text: str
50
+ object_text: str
51
+ modifiers: list[str]
52
+
53
+
54
+ def phrase_resource_available() -> bool:
55
+ return get_nlp() is not None and _get_wordnet() is not None
56
+
57
+
58
+ def _get_wordnet() -> Any | None:
59
+ try:
60
+ import wn
61
+
62
+ wn.config.allow_multithreading = True
63
+ return wn.Wordnet(ENGINE_WORDNET_LEXICON)
64
+ except Exception as exc:
65
+ logger.warning("WordNet unavailable for phrase rewrite: %s", exc)
66
+ return None
67
+
68
+
69
+ def _inflect(lemma: str, token) -> str | None:
70
+ forms = getInflection(lemma, tag=token.tag_)
71
+ value = forms[0] if forms else lemma
72
+ if not value or not _WORD.fullmatch(value) or " " in value or "_" in value:
73
+ return None
74
+ if token.text.isupper():
75
+ return value.upper()
76
+ if token.text[:1].isupper():
77
+ return value[:1].upper() + value[1:]
78
+ return value.lower()
79
+
80
+
81
+ def _single_word_lemmas(synset) -> list[str]:
82
+ out: list[str] = []
83
+ seen: set[str] = set()
84
+ try:
85
+ words = synset.words()
86
+ except Exception:
87
+ return out
88
+ for word in words:
89
+ lemma = (word.lemma() or "").replace("_", " ").strip().lower()
90
+ if (
91
+ not lemma
92
+ or " " in lemma
93
+ or "-" in lemma
94
+ or lemma in seen
95
+ or not _WORD.fullmatch(lemma)
96
+ ):
97
+ continue
98
+ seen.add(lemma)
99
+ out.append(lemma)
100
+ return out
101
+
102
+
103
+ def _object_head(verb) -> Any | None:
104
+ for child in verb.children:
105
+ if child.dep_ in {"dobj", "obj"} and child.pos_ in {"NOUN", "PROPN"}:
106
+ return child
107
+ return None
108
+
109
+
110
+ def _modifier_prefix(noun) -> list[str]:
111
+ mods: list[tuple[int, str]] = []
112
+ for child in noun.children:
113
+ if child.dep_ in {"amod", "compound"} and (
114
+ child.pos_ in {"ADJ", "NOUN"} or child.tag_ in {"VBG", "VBN", "JJ", "JJR", "JJS"}
115
+ ):
116
+ mods.append((child.i, child.text))
117
+ mods.sort()
118
+ return [text for _, text in mods]
119
+
120
+
121
+ def _full_object_text(doc, noun) -> str:
122
+ tokens = sorted(noun.subtree, key=lambda token: token.i)
123
+ if not tokens:
124
+ return noun.text
125
+ start = tokens[0].idx
126
+ end = tokens[-1].idx + len(tokens[-1].text)
127
+ return doc.text[start:end]
128
+
129
+
130
+ def _extract_spans(doc) -> list[_SpanTarget]:
131
+ spans: list[_SpanTarget] = []
132
+ for token in doc:
133
+ if token.pos_ != "VERB" or token.lemma_.lower() in {"be", "have", "do"}:
134
+ continue
135
+ if token.dep_ in {"aux", "auxpass"}:
136
+ continue
137
+ if any(child.dep_ == "auxpass" for child in token.children):
138
+ continue
139
+ noun = _object_head(token)
140
+ if noun is None or noun.pos_ == "PROPN" or noun.ent_type_:
141
+ continue
142
+ if len(noun.lemma_) < 3:
143
+ continue
144
+ obj_tokens = sorted(noun.subtree, key=lambda item: item.i)
145
+ if not obj_tokens:
146
+ continue
147
+ # Verb must precede its object and stay near it (skip long gaps).
148
+ core_start = min(
149
+ [noun.i]
150
+ + [
151
+ child.i
152
+ for child in noun.children
153
+ if child.dep_ in {"det", "amod", "compound", "nummod"}
154
+ ]
155
+ )
156
+ if core_start <= token.i or core_start - token.i > 4:
157
+ continue
158
+ # Keep the object core only (det/amod/compound + head), not PP adjuncts.
159
+ start = token.idx
160
+ end = noun.idx + len(noun.text)
161
+ span_text = doc.text[start:end]
162
+ if _PROTECTED_MARKER.search(span_text):
163
+ continue
164
+ if len(_WORD.findall(span_text)) < 2 or len(_WORD.findall(span_text)) > 8:
165
+ continue
166
+ spans.append(
167
+ _SpanTarget(
168
+ verb=token,
169
+ noun=noun,
170
+ start=start,
171
+ end=end,
172
+ text=span_text,
173
+ object_text=_full_object_text(doc, noun),
174
+ modifiers=_modifier_prefix(noun),
175
+ )
176
+ )
177
+ # Prefer longer, more distinctive spans first.
178
+ spans.sort(key=lambda item: (-(item.end - item.start), item.start))
179
+ return spans
180
+
181
+
182
+ def _content_terms(text: str) -> set[str]:
183
+ stop = {
184
+ "that",
185
+ "with",
186
+ "from",
187
+ "this",
188
+ "these",
189
+ "those",
190
+ "into",
191
+ "over",
192
+ "under",
193
+ "about",
194
+ "being",
195
+ "having",
196
+ "make",
197
+ "made",
198
+ "more",
199
+ "than",
200
+ "such",
201
+ "your",
202
+ "their",
203
+ "them",
204
+ "they",
205
+ "have",
206
+ "been",
207
+ "were",
208
+ "will",
209
+ "would",
210
+ "could",
211
+ "should",
212
+ "which",
213
+ "while",
214
+ "where",
215
+ "when",
216
+ "whom",
217
+ "whose",
218
+ "also",
219
+ "only",
220
+ "just",
221
+ "very",
222
+ "some",
223
+ "any",
224
+ "all",
225
+ "each",
226
+ "other",
227
+ "into",
228
+ "onto",
229
+ "upon",
230
+ }
231
+ return {
232
+ term
233
+ for term in (match.group(0).lower() for match in _WORD.finditer(text or ""))
234
+ if len(term) >= 4 and term not in stop
235
+ }
236
+
237
+
238
+ def _definition_linked(source_lemma: str, parent_defn: str, hypo) -> bool:
239
+ """Accept a hyponym only when it is clearly tied to the parent sense."""
240
+ defn = (hypo.definition() or "").lower()
241
+ if not defn:
242
+ return False
243
+ # Strong link: parent lemma named in the hyponym gloss.
244
+ if re.search(rf"\b{re.escape(source_lemma)}\b", defn):
245
+ return True
246
+ parent_terms = _content_terms(parent_defn)
247
+ hypo_terms = _content_terms(defn)
248
+ if not parent_terms or not hypo_terms:
249
+ return False
250
+ # Require real gloss overlap beyond a single generic word.
251
+ return len(parent_terms & hypo_terms) >= 2
252
+
253
+
254
+ def _modifier_specificity_ok(
255
+ modifiers: list[str],
256
+ source_lemma: str,
257
+ candidate_lemma: str,
258
+ *,
259
+ hyponym: bool = False,
260
+ ) -> bool:
261
+ """Reject heads that only look common because the bare word is frequent.
262
+
263
+ Example: customer+world scores high from 'world', not a real collocation.
264
+ """
265
+ if not modifiers:
266
+ return True
267
+ phrase_slack = 0.70 if hyponym else 0.25
268
+ for mod in modifiers:
269
+ left = mod.lower()
270
+ src_phrase = zipf_frequency(f"{left} {source_lemma}", "en")
271
+ cand_phrase = zipf_frequency(f"{left} {candidate_lemma}", "en")
272
+ src_word = zipf_frequency(source_lemma, "en")
273
+ cand_word = zipf_frequency(candidate_lemma, "en")
274
+ src_spec = src_phrase - src_word
275
+ cand_spec = cand_phrase - cand_word
276
+ if cand_spec + 0.15 < src_spec:
277
+ return False
278
+ if src_phrase >= 3.5 and cand_phrase + phrase_slack < src_phrase:
279
+ return False
280
+ # Ultra-common heads that absorb modifiers ("positive culture") usually
281
+ # have weaker specificity than the source even when phrase zipf looks high.
282
+ if cand_word >= 5.0 and cand_word - src_word >= 0.35 and cand_spec < src_spec:
283
+ return False
284
+ return True
285
+
286
+
287
+ def _related_noun_lemmas(
288
+ resource,
289
+ lemma: str,
290
+ *,
291
+ allow_hyponyms: bool,
292
+ context_terms: set[str] | None = None,
293
+ ) -> list[str]:
294
+ """Synonyms from the best sense; hyponyms only when context supports that sense."""
295
+ out: list[str] = []
296
+ seen: set[str] = {lemma}
297
+ try:
298
+ synsets = list(resource.synsets(lemma, pos="n")[:4])
299
+ except Exception:
300
+ return out
301
+ if not synsets:
302
+ return out
303
+
304
+ context = context_terms or set()
305
+ ranked: list[tuple[int, Any]] = []
306
+ for synset in synsets:
307
+ overlap = len(context & _content_terms(synset.definition() or ""))
308
+ ranked.append((overlap, synset))
309
+ ranked.sort(key=lambda item: -item[0])
310
+ # Prefer a context-supported sense; otherwise stay on the primary sense only.
311
+ best = ranked[0][1] if ranked[0][0] > 0 else synsets[0]
312
+ use_hyponyms = allow_hyponyms
313
+
314
+ for candidate in _single_word_lemmas(best):
315
+ if candidate not in seen:
316
+ seen.add(candidate)
317
+ out.append(candidate)
318
+
319
+ if not use_hyponyms:
320
+ return out
321
+
322
+ parent_defn = best.definition() or ""
323
+ try:
324
+ hyponyms = list(best.get_related("hyponym") or [])
325
+ except Exception:
326
+ hyponyms = []
327
+ for hypo in hyponyms[:20]:
328
+ if not _definition_linked(lemma, parent_defn, hypo):
329
+ continue
330
+ lemmas = _single_word_lemmas(hypo)
331
+ # Prefer compact near-synonym clusters (mindset/outlook), not singleton
332
+ # specialized hyponyms (defensive) or mismatched pairs (credence/acceptance).
333
+ if len(lemmas) < 2 or len(lemmas) > 4:
334
+ continue
335
+ if context:
336
+ attested = True
337
+ for candidate in lemmas:
338
+ best_mod = max(
339
+ (
340
+ zipf_frequency(f"{mod} {candidate}", "en")
341
+ for mod in context
342
+ if len(mod) >= 3
343
+ ),
344
+ default=0.0,
345
+ )
346
+ if best_mod < 3.5:
347
+ attested = False
348
+ break
349
+ if not attested:
350
+ continue
351
+ # Prefer genus-style hyponyms that restate the parent as
352
+ # "mental attitude" / "characteristic …" rather than specialized
353
+ # attitudes (admiration, defensiveness, politics).
354
+ hypo_defn = (hypo.definition() or "").lower()
355
+ if lemma == "attitude" and "mental attitude" not in hypo_defn:
356
+ continue
357
+ if lemma == "attitude" and any(
358
+ marker in hypo_defn
359
+ for marker in (
360
+ "admiration",
361
+ "defensive",
362
+ "arrogant",
363
+ "politics",
364
+ "rationalized",
365
+ "believable",
366
+ )
367
+ ):
368
+ continue
369
+ for candidate in lemmas:
370
+ if candidate not in seen and 4 <= len(candidate) <= 12:
371
+ seen.add(candidate)
372
+ out.append(candidate)
373
+ if len(out) >= 10:
374
+ return out
375
+ return out
376
+
377
+
378
+ def _related_verb_lemmas(
379
+ resource,
380
+ lemma: str,
381
+ *,
382
+ allow_hyponyms: bool,
383
+ ) -> list[str]:
384
+ """Same-synset synonyms; hyponyms only when MiniLM can guard meaning."""
385
+ out: list[str] = []
386
+ seen: set[str] = {lemma}
387
+ try:
388
+ synsets = list(resource.synsets(lemma, pos="v")[:4])
389
+ except Exception:
390
+ return out
391
+ for synset in synsets:
392
+ lemmas = _single_word_lemmas(synset)
393
+ if lemma not in lemmas:
394
+ continue
395
+ # Only expand senses where the source is the canonical headword.
396
+ # Avoid peripheral members (tone/strengthen → "tone").
397
+ if lemmas.index(lemma) != 0:
398
+ continue
399
+ for candidate in lemmas:
400
+ if candidate not in seen:
401
+ seen.add(candidate)
402
+ out.append(candidate)
403
+ if not allow_hyponyms:
404
+ continue
405
+ parent_defn = synset.definition() or ""
406
+ try:
407
+ hyponyms = list(synset.get_related("hyponym") or [])
408
+ except Exception:
409
+ hyponyms = []
410
+ for hypo in hyponyms[:12]:
411
+ if not _definition_linked(lemma, parent_defn, hypo):
412
+ continue
413
+ for candidate in _single_word_lemmas(hypo):
414
+ if candidate not in seen and 4 <= len(candidate) <= 12:
415
+ seen.add(candidate)
416
+ out.append(candidate)
417
+ if len(out) >= 12:
418
+ return out
419
+ return out
420
+
421
+
422
+ def _phrase_zipf(text: str) -> float:
423
+ cleaned = re.sub(r"\s+", " ", (text or "").strip().lower())
424
+ if not cleaned:
425
+ return 0.0
426
+ scores = [zipf_frequency(cleaned, "en")]
427
+ tokens = _WORD.findall(cleaned)
428
+ if len(tokens) >= 2:
429
+ scores.append(zipf_frequency(" ".join(tokens[-2:]), "en"))
430
+ if len(tokens) >= 3:
431
+ scores.append(zipf_frequency(" ".join(tokens[-3:]), "en"))
432
+ return max(scores)
433
+
434
+
435
+ def _rebuild_span(
436
+ span: _SpanTarget,
437
+ *,
438
+ verb_lemma: str | None = None,
439
+ noun_lemma: str | None = None,
440
+ ) -> str | None:
441
+ verb_form = (
442
+ _inflect(verb_lemma, span.verb)
443
+ if verb_lemma and verb_lemma != span.verb.lemma_.lower()
444
+ else span.verb.text
445
+ )
446
+ noun_form = (
447
+ _inflect(noun_lemma, span.noun)
448
+ if noun_lemma and noun_lemma != span.noun.lemma_.lower()
449
+ else span.noun.text
450
+ )
451
+ if verb_form is None or noun_form is None:
452
+ return None
453
+ # Rebuild from original span tokens, swapping only verb/noun heads.
454
+ doc = span.verb.doc
455
+ pieces: list[str] = []
456
+ for token in doc:
457
+ if token.idx < span.start or token.idx >= span.end:
458
+ continue
459
+ if token.i == span.verb.i:
460
+ pieces.append(verb_form)
461
+ elif token.i == span.noun.i:
462
+ pieces.append(noun_form)
463
+ else:
464
+ pieces.append(token.text)
465
+ pieces.append(token.whitespace_)
466
+ rebuilt = "".join(pieces).strip()
467
+ return rebuilt or None
468
+
469
+
470
+ def _collocation_accepts(source_span: str, candidate_span: str) -> bool:
471
+ source_score = _phrase_zipf(source_span)
472
+ candidate_score = _phrase_zipf(candidate_span)
473
+ if candidate_score + 0.85 < source_score and source_score >= 3.5:
474
+ return False
475
+ if candidate_score < 2.4 and source_score >= 3.2:
476
+ return False
477
+ # Prefer attested or near-parity collocations. Allow modest drops so
478
+ # hyponyms like "positive attitude" → "positive mindset" can pass.
479
+ if source_score >= 3.8 and candidate_score + 0.75 < source_score:
480
+ return False
481
+ return True
482
+
483
+
484
+ def _surface_changed(source: str, candidate: str) -> bool:
485
+ left = re.sub(r"\s+", " ", (source or "").strip().lower())
486
+ right = re.sub(r"\s+", " ", (candidate or "").strip().lower())
487
+ return bool(left and right and left != right)
488
+
489
+
490
+ def _wordnet_span_candidates(
491
+ resource,
492
+ span: _SpanTarget,
493
+ *,
494
+ polish: bool,
495
+ ) -> list[str]:
496
+ from app.pipeline.minilm import minilm_available
497
+
498
+ verb_lemma = span.verb.lemma_.lower()
499
+ noun_lemma = span.noun.lemma_.lower()
500
+ has_mods = bool(span.modifiers)
501
+ minilm_ok = minilm_available()
502
+ verbs = [verb_lemma] + _related_verb_lemmas(
503
+ resource,
504
+ verb_lemma,
505
+ allow_hyponyms=minilm_ok,
506
+ )
507
+
508
+ context = {mod.lower() for mod in span.modifiers}
509
+ context.update(_content_terms(span.object_text))
510
+ hypo_only: set[str] = set()
511
+ if has_mods:
512
+ try:
513
+ synsets = list(resource.synsets(noun_lemma, pos="n")[:4])
514
+ ranked = sorted(
515
+ (
516
+ (
517
+ len(context & _content_terms(synset.definition() or "")),
518
+ synset,
519
+ )
520
+ for synset in synsets
521
+ ),
522
+ key=lambda item: -item[0],
523
+ )
524
+ best = ranked[0][1] if ranked and ranked[0][0] > 0 else synsets[0]
525
+ parent_defn = best.definition() or ""
526
+ for hypo in list(best.get_related("hyponym") or [])[:20]:
527
+ if not _definition_linked(noun_lemma, parent_defn, hypo):
528
+ continue
529
+ lemmas = _single_word_lemmas(hypo)
530
+ if len(lemmas) < 2 or len(lemmas) > 4:
531
+ continue
532
+ if context and not all(
533
+ max(
534
+ (
535
+ zipf_frequency(f"{mod} {candidate}", "en")
536
+ for mod in context
537
+ if len(mod) >= 3
538
+ ),
539
+ default=0.0,
540
+ )
541
+ >= 3.5
542
+ for candidate in lemmas
543
+ ):
544
+ continue
545
+ hypo_defn = (hypo.definition() or "").lower()
546
+ if noun_lemma == "attitude" and "mental attitude" not in hypo_defn:
547
+ continue
548
+ if noun_lemma == "attitude" and any(
549
+ marker in hypo_defn
550
+ for marker in (
551
+ "admiration",
552
+ "defensive",
553
+ "arrogant",
554
+ "politics",
555
+ "rationalized",
556
+ "believable",
557
+ )
558
+ ):
559
+ continue
560
+ hypo_only.update(lemmas)
561
+ except Exception:
562
+ hypo_only = set()
563
+
564
+ if has_mods:
565
+ raw_nouns = _related_noun_lemmas(
566
+ resource,
567
+ noun_lemma,
568
+ allow_hyponyms=True,
569
+ context_terms=context,
570
+ )
571
+ ranked_nouns: list[tuple[float, str]] = []
572
+ for candidate in raw_nouns:
573
+ is_hypo = candidate in hypo_only
574
+ if not _modifier_specificity_ok(
575
+ span.modifiers,
576
+ noun_lemma,
577
+ candidate,
578
+ hyponym=is_hypo,
579
+ ):
580
+ continue
581
+ if is_hypo:
582
+ src_f = zipf_frequency(noun_lemma, "en")
583
+ cand_f = zipf_frequency(candidate, "en")
584
+ if cand_f > src_f - 0.15:
585
+ continue
586
+ mod_score = max(
587
+ (
588
+ zipf_frequency(f"{mod.lower()} {candidate}", "en")
589
+ for mod in span.modifiers
590
+ ),
591
+ default=0.0,
592
+ )
593
+ src_mod = max(
594
+ (
595
+ zipf_frequency(f"{mod.lower()} {noun_lemma}", "en")
596
+ for mod in span.modifiers
597
+ ),
598
+ default=0.0,
599
+ )
600
+ # Prefer heads that keep modifier collocation closest to the source.
601
+ closeness = -abs(mod_score - src_mod)
602
+ ranked_nouns.append((closeness, mod_score, candidate))
603
+ ranked_nouns.sort(reverse=True)
604
+ nouns = [noun_lemma] + [item[2] for item in ranked_nouns]
605
+ else:
606
+ nouns = [noun_lemma]
607
+
608
+ verb_cap = 6 if polish else 4
609
+ noun_cap = 6 if polish else 4
610
+ verbs = verbs[:verb_cap]
611
+ nouns = nouns[:noun_cap]
612
+
613
+ candidates: list[str] = []
614
+ seen: set[str] = {span.text.lower()}
615
+ for new_verb in verbs:
616
+ for new_noun in nouns:
617
+ if new_verb == verb_lemma and new_noun == noun_lemma:
618
+ continue
619
+ if (
620
+ not polish
621
+ and new_verb != verb_lemma
622
+ and new_noun != noun_lemma
623
+ ):
624
+ continue
625
+ rebuilt = _rebuild_span(
626
+ span,
627
+ verb_lemma=None if new_verb == verb_lemma else new_verb,
628
+ noun_lemma=None if new_noun == noun_lemma else new_noun,
629
+ )
630
+ if not rebuilt or not _surface_changed(span.text, rebuilt):
631
+ continue
632
+ if not _collocation_accepts(span.text, rebuilt):
633
+ continue
634
+ if new_verb != verb_lemma:
635
+ src_vo = zipf_frequency(f"{verb_lemma} {noun_lemma}", "en")
636
+ cand_vo = zipf_frequency(f"{new_verb} {new_noun}", "en")
637
+ if src_vo >= 2.2 and cand_vo + 0.45 < src_vo:
638
+ continue
639
+ if src_vo >= 3.5 and cand_vo - src_vo >= 0.35:
640
+ continue
641
+ key = rebuilt.lower()
642
+ if key in seen:
643
+ continue
644
+ seen.add(key)
645
+ candidates.append(rebuilt)
646
+ return candidates
647
+
648
+
649
+ def _t5_span_candidates(span_text: str, *, num_return: int = 4) -> list[str]:
650
+ try:
651
+ from app.engine.paraphrase import paraphrase_span
652
+ except Exception:
653
+ return []
654
+ try:
655
+ result = paraphrase_span(span_text, num_return=num_return)
656
+ except Exception as exc:
657
+ logger.debug("phrase T5 unavailable: %s", exc)
658
+ return []
659
+ out: list[str] = []
660
+ for item in result.candidates or []:
661
+ cleaned = re.sub(r"\s+", " ", (item or "").strip(" ."))
662
+ if cleaned and _surface_changed(span_text, cleaned):
663
+ out.append(cleaned)
664
+ if result.text and _surface_changed(span_text, result.text):
665
+ cleaned = re.sub(r"\s+", " ", result.text.strip(" ."))
666
+ if cleaned and cleaned.lower() not in {x.lower() for x in out}:
667
+ out.insert(0, cleaned)
668
+ return out[:num_return]
669
+
670
+
671
+ def _rank_span_candidates(
672
+ source_span: str,
673
+ candidates: list[str],
674
+ *,
675
+ min_sim: float,
676
+ modifiers: list[str] | None = None,
677
+ ) -> str | None:
678
+ if not candidates:
679
+ return None
680
+ meaning_ok: list[str] = []
681
+ for candidate in candidates:
682
+ meaning = score_candidate(source_span, candidate)
683
+ if meaning is None:
684
+ meaning_ok.append(candidate)
685
+ continue
686
+ if meaning >= min_sim:
687
+ meaning_ok.append(candidate)
688
+ pool = meaning_ok or []
689
+ if not pool:
690
+ return None
691
+
692
+ scored: list[tuple[float, float, float, str]] = []
693
+ source_zipf = _phrase_zipf(source_span)
694
+ src_tokens = {w.lower() for w in _WORD.findall(source_span)}
695
+ for candidate in pool:
696
+ cand_zipf = _phrase_zipf(candidate)
697
+ cand_tokens = {w.lower() for w in _WORD.findall(candidate)}
698
+ distance = float(len(src_tokens ^ cand_tokens))
699
+ # Prefer stable modifier collocations when present.
700
+ mod_bonus = 0.0
701
+ for mod in modifiers or []:
702
+ # Find noun-ish last content token as head proxy.
703
+ heads = [w for w in _WORD.findall(candidate) if len(w) >= 4]
704
+ if not heads:
705
+ continue
706
+ mod_bonus = max(
707
+ mod_bonus,
708
+ zipf_frequency(f"{mod.lower()} {heads[-1].lower()}", "en"),
709
+ )
710
+ scored.append((mod_bonus, cand_zipf - source_zipf, distance, candidate))
711
+ scored.sort(reverse=True)
712
+ surface_scores = {
713
+ candidate: 1.0 - (distance / 10.0)
714
+ for _mod, _gain, distance, candidate in scored[:8]
715
+ }
716
+ picked = pick_best_candidate(
717
+ source_span,
718
+ [item[3] for item in scored[:8]],
719
+ min_meaning=min_sim,
720
+ prefer_divergent=True,
721
+ surface_scores=surface_scores,
722
+ )
723
+ return picked or scored[0][3]
724
+
725
+
726
+ def _splice(text: str, start: int, end: int, replacement: str) -> str:
727
+ return text[:start] + replacement + text[end:]
728
+
729
+
730
+ def rewrite_phrases(
731
+ text: str,
732
+ *,
733
+ max_changes: int | None = None,
734
+ polish: bool = False,
735
+ min_sim: float | None = None,
736
+ wordnet: Any | None = None,
737
+ use_t5: bool = True,
738
+ ) -> PhraseResult:
739
+ """Rewrite up to N verb–object phrases with meaning-safe alternatives."""
740
+ if not ENGINE_PHRASE_REWRITE and wordnet is None:
741
+ return PhraseResult(text=text, reason="disabled")
742
+ source = (text or "").strip()
743
+ if not source:
744
+ return PhraseResult(text=text, reason="empty")
745
+ if any(quote in source for quote in _QUOTES):
746
+ return PhraseResult(text=source, reason="quoted")
747
+ if _PROTECTED_MARKER.search(source):
748
+ return PhraseResult(text=source, reason="protected")
749
+
750
+ nlp = get_nlp()
751
+ resource = wordnet if wordnet is not None else _get_wordnet()
752
+ if nlp is None or resource is None:
753
+ return PhraseResult(text=source, reason="resource_unavailable")
754
+ try:
755
+ doc = nlp(source)
756
+ except Exception:
757
+ return PhraseResult(text=source, reason="parse_failed")
758
+
759
+ limit = (
760
+ max(1, min(int(max_changes), 4))
761
+ if max_changes is not None
762
+ else (
763
+ ENGINE_PHRASE_MAX_CHANGES
764
+ if ENGINE_PHRASE_MAX_CHANGES > 0
765
+ else (2 if polish else 1)
766
+ )
767
+ )
768
+ threshold = min_sim if min_sim is not None else ENGINE_PHRASE_MIN_SIM
769
+ spans = _extract_spans(doc)
770
+ if not spans:
771
+ return PhraseResult(text=source, reason="no_spans")
772
+
773
+ current = source
774
+ changes: list[LexicalChange] = []
775
+ touched_verbs: set[str] = set()
776
+ # Re-parse after each accepted splice so offsets stay valid.
777
+ for _ in range(limit):
778
+ try:
779
+ doc = nlp(current)
780
+ except Exception:
781
+ break
782
+ spans = _extract_spans(doc)
783
+ best: tuple[float, _SpanTarget, str] | None = None
784
+ for span in spans:
785
+ verb_key = span.verb.lemma_.lower()
786
+ if verb_key in touched_verbs:
787
+ continue
788
+ # Skip spans already touched.
789
+ if any(
790
+ change.original.lower() == span.text.lower() for change in changes
791
+ ):
792
+ continue
793
+ candidates = _wordnet_span_candidates(resource, span, polish=polish)
794
+ if use_t5:
795
+ candidates.extend(_t5_span_candidates(span.text))
796
+ # Deduplicate
797
+ uniq: list[str] = []
798
+ seen: set[str] = set()
799
+ for cand in candidates:
800
+ key = cand.lower()
801
+ if key in seen or key == span.text.lower():
802
+ continue
803
+ seen.add(key)
804
+ uniq.append(cand)
805
+ picked = _rank_span_candidates(
806
+ span.text,
807
+ uniq,
808
+ min_sim=threshold,
809
+ modifiers=span.modifiers,
810
+ )
811
+ if not picked:
812
+ continue
813
+ # Score full-sentence splice.
814
+ spliced = _splice(current, span.start, span.end, picked)
815
+ if spliced == current:
816
+ continue
817
+ meaning = score_candidate(source, spliced)
818
+ if meaning is not None and meaning < threshold:
819
+ continue
820
+ # Collocation-weighted rank key.
821
+ gain = _phrase_zipf(picked) - _phrase_zipf(span.text)
822
+ distance = len(
823
+ {w.lower() for w in _WORD.findall(span.text)}
824
+ ^ {w.lower() for w in _WORD.findall(picked)}
825
+ )
826
+ score = gain + (0.15 * distance) + (meaning or 0.0)
827
+ if best is None or score > best[0]:
828
+ best = (score, span, picked)
829
+ if best is None:
830
+ break
831
+ _score, span, picked = best
832
+ current = _splice(current, span.start, span.end, picked)
833
+ touched_verbs.add(span.verb.lemma_.lower())
834
+ changes.append(
835
+ LexicalChange(
836
+ original=span.text,
837
+ replacement=picked,
838
+ token_index=span.verb.i,
839
+ lemma=span.verb.lemma_.lower(),
840
+ synset_id="phrase",
841
+ confidence=round(min(0.95, 0.55 + best[0] * 0.1), 4),
842
+ )
843
+ )
844
+
845
+ if not changes:
846
+ return PhraseResult(text=source, reason="no_safe_change")
847
+ return PhraseResult(
848
+ text=current,
849
+ changes=changes,
850
+ confidence=min(change.confidence for change in changes),
851
+ reason="phrase_rewrite",
852
+ )
app/main.py CHANGED
@@ -34,6 +34,7 @@ from app.config import (
34
  ENGINE_LEXICAL_REFINEMENT,
35
  ENGINE_PARAPHRASE,
36
  ENGINE_PARAPHRASE_PRIMARY,
 
37
  ENGINE_REQUIRE_WORDING_CHANGE,
38
  ENGINE_USE_MINILM_SAFETY,
39
  GRAMMAR_MAX_CHARS,
@@ -43,6 +44,7 @@ from app.config import (
43
  )
44
  from app.engine.lexical import lexical_resource_available
45
  from app.engine.paraphrase import paraphrase_resource_available
 
46
  from app.engine.orchestrator import rewrite_document
47
  from app.pipeline.minilm import minilm_available
48
  from app.pipeline.grammar import (
@@ -117,7 +119,7 @@ def health():
117
  "default_language": LANGUAGE_TOOL_LANGUAGE or "en-US",
118
  },
119
  "rewrite_engine": {
120
- "mode": "structural+paraphrase-primary+ensure",
121
  "force_rewrite": ENGINE_FORCE_REWRITE,
122
  "require_wording_change": ENGINE_REQUIRE_WORDING_CHANGE,
123
  "paraphrase": {
@@ -129,6 +131,14 @@ def health():
129
  else None
130
  ),
131
  },
 
 
 
 
 
 
 
 
132
  "minilm_safety": {
133
  "enabled": ENGINE_USE_MINILM_SAFETY,
134
  "resource_available": (
@@ -158,6 +168,7 @@ def health():
158
  "safety",
159
  "paraphrase (optional)",
160
  "forced-cleft (optional legacy)",
 
161
  "lexical-refinement (optional)",
162
  "stitch",
163
  "consistency",
 
34
  ENGINE_LEXICAL_REFINEMENT,
35
  ENGINE_PARAPHRASE,
36
  ENGINE_PARAPHRASE_PRIMARY,
37
+ ENGINE_PHRASE_REWRITE,
38
  ENGINE_REQUIRE_WORDING_CHANGE,
39
  ENGINE_USE_MINILM_SAFETY,
40
  GRAMMAR_MAX_CHARS,
 
44
  )
45
  from app.engine.lexical import lexical_resource_available
46
  from app.engine.paraphrase import paraphrase_resource_available
47
+ from app.engine.phrase import phrase_resource_available
48
  from app.engine.orchestrator import rewrite_document
49
  from app.pipeline.minilm import minilm_available
50
  from app.pipeline.grammar import (
 
119
  "default_language": LANGUAGE_TOOL_LANGUAGE or "en-US",
120
  },
121
  "rewrite_engine": {
122
+ "mode": "structural+paraphrase-primary+phrase+ensure",
123
  "force_rewrite": ENGINE_FORCE_REWRITE,
124
  "require_wording_change": ENGINE_REQUIRE_WORDING_CHANGE,
125
  "paraphrase": {
 
131
  else None
132
  ),
133
  },
134
+ "phrase_rewrite": {
135
+ "enabled": ENGINE_PHRASE_REWRITE,
136
+ "resource_available": (
137
+ phrase_resource_available()
138
+ if ENGINE_PHRASE_REWRITE
139
+ else None
140
+ ),
141
+ },
142
  "minilm_safety": {
143
  "enabled": ENGINE_USE_MINILM_SAFETY,
144
  "resource_available": (
 
168
  "safety",
169
  "paraphrase (optional)",
170
  "forced-cleft (optional legacy)",
171
+ "phrase-rewrite (optional)",
172
  "lexical-refinement (optional)",
173
  "stitch",
174
  "consistency",