idnameraj commited on
Commit
4e06845
·
verified ·
1 Parent(s): b289da4

Upload 90 files

Browse files
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/fallback/__init__.py CHANGED
@@ -7,14 +7,29 @@ from app.engine.plan import build_plan
7
  from app.engine.rewrite import generate_candidates, reorder_quality_ok
8
  from app.engine.templates import (
9
  try_because_front,
 
10
  try_complex_clause_swap,
11
  try_copula_np_invert,
12
  try_for_purpose_front,
 
13
  try_in_pp_front,
14
  try_such_as_front,
 
15
  )
16
  from app.engine.voice import active_to_passive
17
 
 
 
 
 
 
 
 
 
 
 
 
 
18
 
19
  def structural_fallback_candidates(
20
  text: str,
@@ -43,25 +58,21 @@ def structural_fallback_candidates(
43
  return
44
  if not sufficiently_changed(source, cleaned):
45
  return
46
- # Dedicated fallbacks already preserve meaning; skip the generic
47
- # opener guard that rejects valid PP/predicate fronts.
48
- if template_id not in {
49
- "for_purpose_front",
50
- "in_pp_front",
51
- "copula_np_invert",
52
- "such_as_front",
53
- } and not reorder_quality_ok(source, cleaned):
54
  return
55
  seen.add(key)
56
  results.append((template_id, cleaned, confidence))
57
 
 
 
58
  _add("for_purpose_front", try_for_purpose_front(source), 0.76)
 
59
  _add("in_pp_front", try_in_pp_front(source), 0.74)
 
 
60
  _add("copula_np_invert", try_copula_np_invert(source), 0.72)
61
  _add("such_as_front", try_such_as_front(source), 0.70)
62
  _add("active_to_passive", active_to_passive(source), 0.68)
63
- _add("because_front", try_because_front(source), 0.78)
64
- _add("complex_clause_swap", try_complex_clause_swap(source), 0.74)
65
 
66
  plan = build_plan(source, min_confidence=min_confidence)
67
  if plan.safe:
 
7
  from app.engine.rewrite import generate_candidates, reorder_quality_ok
8
  from app.engine.templates import (
9
  try_because_front,
10
+ try_by_agent_front,
11
  try_complex_clause_swap,
12
  try_copula_np_invert,
13
  try_for_purpose_front,
14
+ try_in_both_front,
15
  try_in_pp_front,
16
  try_such_as_front,
17
+ try_when_clause_front,
18
  )
19
  from app.engine.voice import active_to_passive
20
 
21
+ _DEDICATED = frozenset(
22
+ {
23
+ "for_purpose_front",
24
+ "in_pp_front",
25
+ "copula_np_invert",
26
+ "such_as_front",
27
+ "when_clause_front",
28
+ "by_agent_front",
29
+ "in_both_front",
30
+ }
31
+ )
32
+
33
 
34
  def structural_fallback_candidates(
35
  text: str,
 
58
  return
59
  if not sufficiently_changed(source, cleaned):
60
  return
61
+ if template_id not in _DEDICATED and not reorder_quality_ok(source, cleaned):
 
 
 
 
 
 
 
62
  return
63
  seen.add(key)
64
  results.append((template_id, cleaned, confidence))
65
 
66
+ _add("when_clause_front", try_when_clause_front(source), 0.80)
67
+ _add("because_front", try_because_front(source), 0.78)
68
  _add("for_purpose_front", try_for_purpose_front(source), 0.76)
69
+ _add("in_both_front", try_in_both_front(source), 0.75)
70
  _add("in_pp_front", try_in_pp_front(source), 0.74)
71
+ _add("complex_clause_swap", try_complex_clause_swap(source), 0.74)
72
+ _add("by_agent_front", try_by_agent_front(source), 0.73)
73
  _add("copula_np_invert", try_copula_np_invert(source), 0.72)
74
  _add("such_as_front", try_such_as_front(source), 0.70)
75
  _add("active_to_passive", active_to_passive(source), 0.68)
 
 
76
 
77
  plan = build_plan(source, min_confidence=min_confidence)
78
  if plan.safe:
app/engine/fallback/__pycache__/__init__.cpython-311.pyc CHANGED
Binary files a/app/engine/fallback/__pycache__/__init__.cpython-311.pyc and b/app/engine/fallback/__pycache__/__init__.cpython-311.pyc differ
 
app/engine/lexical/__init__.py CHANGED
@@ -1,4 +1,4 @@
1
- """Conservative, context-aware vocabulary refinement."""
2
 
3
  from __future__ import annotations
4
 
@@ -20,6 +20,7 @@ from app.config import (
20
  ENGINE_WORDNET_LEXICON,
21
  )
22
  from app.engine.models import LexicalChange
 
23
  from app.pipeline.nlp import get_nlp
24
 
25
  _POS_MAP = {"NOUN": "n", "VERB": "v", "ADJ": "a", "ADV": "r"}
@@ -30,57 +31,6 @@ _CITATION = re.compile(
30
  )
31
  _QUOTES = frozenset({'"', "“", "”", "‘", "’"})
32
  _CLEFT_PREFIX = re.compile(r"^it is\b", re.I)
33
- # Collocation-sensitive light verbs: do not swap when the object is idiomatic.
34
- _LIGHT_VERB_OBJECTS: dict[str, frozenset[str]] = {
35
- "play": frozenset({"role", "roles", "part", "parts", "host", "hosts"}),
36
- "take": frozenset({"place", "part", "care", "advantage", "effect"}),
37
- "make": frozenset({"sense", "sure", "way", "difference"}),
38
- "do": frozenset({"homework", "favor", "favours", "justice"}),
39
- }
40
- _BLOCKED_LEMMA_SWAPS = frozenset(
41
- {
42
- ("play", "act"),
43
- ("act", "play"),
44
- ("play", "perform"),
45
- ("perform", "play"),
46
- # Archaic WordNet sense: "science" ≈ problem-solving skill.
47
- ("skill", "science"),
48
- ("science", "skill"),
49
- # Wrong everyday sense / awkward wording.
50
- ("equip", "fit"),
51
- ("fit", "equip"),
52
- ("teammate", "mate"),
53
- ("mate", "teammate"),
54
- ("improve", "better"),
55
- ("better", "improve"),
56
- ("overcome", "master"),
57
- ("master", "overcome"),
58
- }
59
- )
60
- # Closed compounds must not be opened ("teamwork" → "team work").
61
- _CLOSED_COMPOUNDS = frozenset(
62
- {
63
- "teamwork",
64
- "teammate",
65
- "teammates",
66
- "workplace",
67
- "lifestyle",
68
- "classmate",
69
- "classroom",
70
- "homework",
71
- "feedback",
72
- "deadline",
73
- "workflow",
74
- "workforce",
75
- "online",
76
- "database",
77
- }
78
- )
79
- # Plural "individuals" should become everyday "people", not "persons".
80
- _CURATED_SURFACE = {
81
- ("individual", "NNS"): "people",
82
- ("individual", "NNPS"): "People",
83
- }
84
  logger = logging.getLogger("plainrewrite.lexical")
85
 
86
 
@@ -97,8 +47,6 @@ def _get_wordnet() -> Any | None:
97
  try:
98
  import wn
99
 
100
- # FastAPI executes synchronous endpoints in worker threads. Wn must
101
- # permit its shared SQLite pool to be used across those workers.
102
  wn.config.allow_multithreading = True
103
  return wn.Wordnet(ENGINE_WORDNET_LEXICON)
104
  except Exception as exc:
@@ -124,17 +72,11 @@ def _context_terms(doc, target) -> set[str]:
124
 
125
 
126
  def _terms_from_text(text: str, stop_words: set[str]) -> set[str]:
127
- terms = {
128
- match.group(0).lower()
129
- for match in _WORD.finditer(text or "")
130
- }
131
  return {term for term in terms if len(term) >= 3 and term not in stop_words}
132
 
133
 
134
- def _gloss_terms(
135
- synset,
136
- stop_words: set[str],
137
- ) -> tuple[set[str], set[str]]:
138
  definition = _terms_from_text(synset.definition(), stop_words)
139
  examples: set[str] = set()
140
  try:
@@ -160,46 +102,40 @@ def _sense_score(synset, context: set[str], stop_words: set[str]) -> float:
160
  def _eligible(token, doc) -> bool:
161
  if token.pos_ not in _POS_MAP:
162
  return False
 
163
  if (
164
  token.pos_ == "PROPN"
165
  or token.ent_type_
166
  or token.is_stop
167
  or not token.is_alpha
168
- or len(token.lemma_) < 3
169
  or token.dep_ in {"aux", "auxpass", "neg", "mark"}
170
  or _PROTECTED_MARKER.search(token.text)
171
  ):
172
  return False
173
- # Keep gerund subjects/cleft foci stable ("Planning ..." / "It is planning ...").
174
  if token.tag_ == "VBG" and token.dep_ in {"ROOT", "csubj", "nsubj", "attr"}:
175
  return False
176
  if token.tag_ == "VBG" and token.i <= 2 and _CLEFT_PREFIX.search(doc.text):
177
  return False
178
- # Prefer content heads; skip light copulas already handled above.
179
- if token.lemma_.lower() in {"be", "have", "do"}:
180
  return False
181
- # In clefts, refine the predicate rather than the dummy subject.
182
  if _CLEFT_PREFIX.search(doc.text) and token.text.lower() in {"it"}:
183
  return False
184
- # Freeze ability constructions: "better equipped to …", "prepared to …".
185
- if token.tag_ in {"VBN", "VBD"} and token.i + 1 < len(doc):
186
- if doc[token.i + 1].lower_ == "to":
187
- return False
188
- # Keep closed compounds intact.
189
- if token.text.lower() in _CLOSED_COMPOUNDS or token.lemma_.lower() in {
190
- "teamwork",
191
- "teammate",
192
- }:
193
  return False
194
  return True
195
 
196
 
197
  def _impact_rank(token) -> int:
198
- """Lower score = safer / lower-impact wording change."""
199
  if token.pos_ == "ADV":
200
  return 0
201
  if token.pos_ == "ADJ":
202
  return 1
 
 
 
 
203
  if token.pos_ == "NOUN" and token.dep_ in {
204
  "attr",
205
  "acomp",
@@ -208,23 +144,15 @@ def _impact_rank(token) -> int:
208
  "dobj",
209
  "appos",
210
  }:
211
- return 2
212
- if token.pos_ == "VERB" and token.dep_ != "ROOT":
213
- return 3
214
- if token.pos_ == "NOUN":
215
  return 4
216
- if token.pos_ == "VERB":
217
  return 5
218
  return 6
219
 
220
 
221
  def _inflect(lemma: str, token) -> str | None:
222
- curated = _CURATED_SURFACE.get((token.lemma_.lower(), token.tag_))
223
- if curated and lemma in {"person", "individual", "someone", "somebody"}:
224
- value = curated
225
- else:
226
- forms = getInflection(lemma, tag=token.tag_)
227
- value = forms[0] if forms else lemma
228
  if not value or not _WORD.fullmatch(value) or " " in value or "_" in value:
229
  return None
230
  if token.text.isupper():
@@ -264,7 +192,7 @@ def _phrase_zipf(word: str, left: str, right: str) -> float:
264
  return max(scores)
265
 
266
 
267
- def _verb_collocation_ok(
268
  source_lemma: str,
269
  candidate_lemma: str,
270
  token,
@@ -272,47 +200,47 @@ def _verb_collocation_ok(
272
  left: str,
273
  right: str,
274
  ) -> bool:
275
- """Reject swaps that break light-verb / neighbor collocations."""
276
- if (source_lemma, candidate_lemma) in _BLOCKED_LEMMA_SWAPS:
277
- return False
278
  obj = _object_lemma(token)
279
- protected = _LIGHT_VERB_OBJECTS.get(source_lemma)
280
- if protected and obj in protected:
281
- # Freeze idiomatic light verbs ("play a role", "take place").
282
- return False
283
  if obj:
284
- source_vo = zipf_frequency(f"{source_lemma} {obj}", "en")
285
- candidate_vo = zipf_frequency(f"{candidate_lemma} {obj}", "en")
286
- # Only reject a sharp collocation collapse, not ordinary near-synonyms.
287
- if source_vo >= 3.5 and candidate_vo + 0.85 < source_vo:
288
- return False
289
- if source_vo >= 4.0 and candidate_vo < 2.0:
290
- return False
291
  if right:
292
- source_right = zipf_frequency(f"{source_lemma} {right}", "en")
293
- candidate_right = zipf_frequency(f"{candidate_lemma} {right}", "en")
294
- if source_right >= 4.0 and candidate_right + 0.85 < source_right:
295
- return False
 
 
296
  if left:
297
- source_left = zipf_frequency(f"{left} {source_lemma}", "en")
298
- candidate_left = zipf_frequency(f"{left} {candidate_lemma}", "en")
299
- if source_left >= 4.0 and candidate_left + 0.85 < source_left:
 
 
 
 
 
 
 
300
  return False
301
  return True
302
 
303
 
304
  def _frequency_ok(source_frequency: float, candidate_frequency: float) -> bool:
305
- """Accept common everyday replacements; reject rarer/advanced wording."""
306
  if candidate_frequency < ENGINE_LEXICAL_MIN_ZIPF:
307
  return False
308
  if ENGINE_LEXICAL_PREFER_SIMPLER:
309
- # Allow a larger move toward more common words.
310
  if candidate_frequency > source_frequency:
311
  return (
312
  candidate_frequency - source_frequency
313
  <= ENGINE_LEXICAL_MAX_SIMPLER_GAP
314
  )
315
- # Only a tiny step toward less common / more advanced words.
316
  return source_frequency - candidate_frequency <= ENGINE_LEXICAL_MAX_HARDER_GAP
317
  return (
318
  abs(candidate_frequency - source_frequency)
@@ -321,11 +249,8 @@ def _frequency_ok(source_frequency: float, candidate_frequency: float) -> bool:
321
 
322
 
323
  def _candidate_for_synset(synset, token, doc) -> tuple[str, float] | None:
324
- """Return (replacement, simplicity_gain) for the best everyday synonym."""
325
  source = token.lemma_.lower()
326
  source_surface = token.text.lower()
327
- # Use surface frequency too so inflected forms like "equipped" are not
328
- # treated as rare just because the lemma "equip" is uncommon.
329
  source_frequency = max(
330
  zipf_frequency(source, "en"),
331
  zipf_frequency(source_surface, "en"),
@@ -337,7 +262,6 @@ def _candidate_for_synset(synset, token, doc) -> tuple[str, float] | None:
337
  except Exception:
338
  return None
339
 
340
- # Rank: higher everyday frequency, stronger collocation, shorter word.
341
  ranked: list[tuple[float, float, float, str]] = []
342
  for word in words:
343
  lemma = (word.lemma() or "").replace("_", " ").strip().lower()
@@ -348,9 +272,7 @@ def _candidate_for_synset(synset, token, doc) -> tuple[str, float] | None:
348
  or not _WORD.fullmatch(lemma)
349
  ):
350
  continue
351
- if (source, lemma) in _BLOCKED_LEMMA_SWAPS:
352
- continue
353
- if token.pos_ == "VERB" and not _verb_collocation_ok(
354
  source,
355
  lemma,
356
  token,
@@ -358,11 +280,10 @@ def _candidate_for_synset(synset, token, doc) -> tuple[str, float] | None:
358
  right=right,
359
  ):
360
  continue
361
- candidate_frequency = zipf_frequency(lemma, "en")
362
- # Curated everyday plurals (individuals→people) use the surface zipf.
363
  replacement = _inflect(lemma, token)
364
  if not replacement or replacement.lower() == token.text.lower():
365
  continue
 
366
  rank_frequency = zipf_frequency(replacement.lower(), "en")
367
  if not _frequency_ok(
368
  source_frequency,
@@ -370,9 +291,6 @@ def _candidate_for_synset(synset, token, doc) -> tuple[str, float] | None:
370
  ):
371
  continue
372
  phrase = _phrase_zipf(replacement.lower(), left, right)
373
- # Reject rare collocations. For adjective+noun pairs, also reject a
374
- # jump into a more common but wrong sense ("daily tasks" ->
375
- # "daily projects").
376
  if phrase + 0.55 < source_phrase:
377
  continue
378
  if left and token.pos_ == "NOUN":
@@ -380,7 +298,6 @@ def _candidate_for_synset(synset, token, doc) -> tuple[str, float] | None:
380
  candidate_bigram = zipf_frequency(
381
  f"{left} {replacement.lower()}", "en"
382
  )
383
- # Keep noun compounds stable ("teamwork skills" not "teamwork sciences").
384
  if source_bigram >= 3.0 and candidate_bigram + 0.45 < source_bigram:
385
  continue
386
  if (
@@ -391,9 +308,15 @@ def _candidate_for_synset(synset, token, doc) -> tuple[str, float] | None:
391
  and phrase > source_phrase + 0.25
392
  ):
393
  continue
394
- brevity = -float(len(replacement))
 
395
  ranked.append(
396
- (max(candidate_frequency, rank_frequency), phrase, brevity, replacement)
 
 
 
 
 
397
  )
398
 
399
  if not ranked:
@@ -436,11 +359,11 @@ def refine_sentence(
436
  return LexicalResult(text=source, reason="parse_failed")
437
 
438
  stop_words = set(nlp.Defaults.stop_words)
439
- # (wsd, impact, -simplicity_gain, offset, token_i, replacement, synset)
440
  proposals: list[tuple[float, int, float, int, int, str, Any]] = []
441
  for token in doc:
442
  if not _eligible(token, doc):
443
  continue
 
444
  context = _context_terms(doc, token)
445
  try:
446
  synsets = list(
@@ -451,43 +374,51 @@ def refine_sentence(
451
  )
452
  except Exception:
453
  continue
454
- best: tuple[float, Any] | None = None
455
- for synset in synsets:
456
- score = _sense_score(synset, context, stop_words)
457
- if best is None or score > best[0]:
458
- best = (score, synset)
459
- if best is not None and best[0] == 0.0 and len(synsets) == 1:
460
- # A single available sense is unambiguous even when its short
461
- # gloss shares no literal words with the sentence context.
462
- best = (min_wsd, best[1])
463
- if (
464
- synsets
465
- and (best is None or best[0] < min_wsd)
466
- and ENGINE_LEXICAL_PREFER_SIMPLER
467
- ):
468
- # Soft path: only the primary WordNet sense. Secondary senses cause
469
- # meaning errors like skill→science ("problem-domain ability").
470
- primary = synsets[0]
471
- trial = _candidate_for_synset(primary, token, doc)
472
- if trial is not None and trial[1] >= 0.45:
473
- best = (min_wsd, primary)
474
- if best is None or best[0] < min_wsd:
475
  continue
476
- picked = _candidate_for_synset(best[1], token, doc)
477
- if picked:
478
- replacement, simplicity_gain = picked
479
- # Prefer low-impact slots, then larger gains toward simpler words.
480
- proposals.append(
481
- (
482
- best[0],
483
- _impact_rank(token),
484
- -simplicity_gain,
485
- token.idx,
486
- token.i,
487
- replacement,
488
- best[1],
489
- )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
490
  )
 
491
 
492
  if not proposals:
493
  return LexicalResult(text=source, reason="no_confident_candidate")
@@ -504,7 +435,6 @@ def refine_sentence(
504
  ):
505
  token = doc[token_index]
506
  output = output[:offset] + replacement + output[offset + len(token.text) :]
507
- # Keep a/an agreement when an adjective or noun changes.
508
  if token_index > 0 and doc[token_index - 1].lower_ in {"a", "an"}:
509
  prev = doc[token_index - 1]
510
  needed = _article_for(replacement)
@@ -521,7 +451,7 @@ def refine_sentence(
521
  replacement=replacement,
522
  token_index=token_index,
523
  lemma=token.lemma_,
524
- synset_id=str(synset.id),
525
  confidence=round(confidence, 4),
526
  )
527
  )
 
1
+ """Conservative, context-aware vocabulary refinement (no hard-coded word lists)."""
2
 
3
  from __future__ import annotations
4
 
 
20
  ENGINE_WORDNET_LEXICON,
21
  )
22
  from app.engine.models import LexicalChange
23
+ from app.engine.quality import substitution_pos_stable
24
  from app.pipeline.nlp import get_nlp
25
 
26
  _POS_MAP = {"NOUN": "n", "VERB": "v", "ADJ": "a", "ADV": "r"}
 
31
  )
32
  _QUOTES = frozenset({'"', "“", "”", "‘", "’"})
33
  _CLEFT_PREFIX = re.compile(r"^it is\b", re.I)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
34
  logger = logging.getLogger("plainrewrite.lexical")
35
 
36
 
 
47
  try:
48
  import wn
49
 
 
 
50
  wn.config.allow_multithreading = True
51
  return wn.Wordnet(ENGINE_WORDNET_LEXICON)
52
  except Exception as exc:
 
72
 
73
 
74
  def _terms_from_text(text: str, stop_words: set[str]) -> set[str]:
75
+ terms = {match.group(0).lower() for match in _WORD.finditer(text or "")}
 
 
 
76
  return {term for term in terms if len(term) >= 3 and term not in stop_words}
77
 
78
 
79
+ def _gloss_terms(synset, stop_words: set[str]) -> tuple[set[str], set[str]]:
 
 
 
80
  definition = _terms_from_text(synset.definition(), stop_words)
81
  examples: set[str] = set()
82
  try:
 
102
  def _eligible(token, doc) -> bool:
103
  if token.pos_ not in _POS_MAP:
104
  return False
105
+ lemma = token.lemma_.lower()
106
  if (
107
  token.pos_ == "PROPN"
108
  or token.ent_type_
109
  or token.is_stop
110
  or not token.is_alpha
111
+ or len(lemma) < 3
112
  or token.dep_ in {"aux", "auxpass", "neg", "mark"}
113
  or _PROTECTED_MARKER.search(token.text)
114
  ):
115
  return False
 
116
  if token.tag_ == "VBG" and token.dep_ in {"ROOT", "csubj", "nsubj", "attr"}:
117
  return False
118
  if token.tag_ == "VBG" and token.i <= 2 and _CLEFT_PREFIX.search(doc.text):
119
  return False
120
+ if lemma in {"be", "have", "do"}:
 
121
  return False
 
122
  if _CLEFT_PREFIX.search(doc.text) and token.text.lower() in {"it"}:
123
  return False
124
+ # Freeze "* to " constructions (equipped to, bring to, get to,).
125
+ if token.i + 1 < len(doc) and doc[token.i + 1].lower_ == "to":
 
 
 
 
 
 
 
126
  return False
127
  return True
128
 
129
 
130
  def _impact_rank(token) -> int:
 
131
  if token.pos_ == "ADV":
132
  return 0
133
  if token.pos_ == "ADJ":
134
  return 1
135
+ if token.pos_ == "VERB" and token.dep_ != "ROOT":
136
+ return 2
137
+ if token.pos_ == "VERB":
138
+ return 3
139
  if token.pos_ == "NOUN" and token.dep_ in {
140
  "attr",
141
  "acomp",
 
144
  "dobj",
145
  "appos",
146
  }:
 
 
 
 
147
  return 4
148
+ if token.pos_ == "NOUN":
149
  return 5
150
  return 6
151
 
152
 
153
  def _inflect(lemma: str, token) -> str | None:
154
+ forms = getInflection(lemma, tag=token.tag_)
155
+ value = forms[0] if forms else lemma
 
 
 
 
156
  if not value or not _WORD.fullmatch(value) or " " in value or "_" in value:
157
  return None
158
  if token.text.isupper():
 
192
  return max(scores)
193
 
194
 
195
+ def _collocation_ok(
196
  source_lemma: str,
197
  candidate_lemma: str,
198
  token,
 
200
  left: str,
201
  right: str,
202
  ) -> bool:
203
+ """Reject swaps that collapse local collocations (algorithmic, no denylist)."""
 
 
204
  obj = _object_lemma(token)
205
+ checks: list[tuple[float, float]] = []
 
 
 
206
  if obj:
207
+ checks.append(
208
+ (
209
+ zipf_frequency(f"{source_lemma} {obj}", "en"),
210
+ zipf_frequency(f"{candidate_lemma} {obj}", "en"),
211
+ )
212
+ )
 
213
  if right:
214
+ checks.append(
215
+ (
216
+ zipf_frequency(f"{source_lemma} {right}", "en"),
217
+ zipf_frequency(f"{candidate_lemma} {right}", "en"),
218
+ )
219
+ )
220
  if left:
221
+ checks.append(
222
+ (
223
+ zipf_frequency(f"{left} {source_lemma}", "en"),
224
+ zipf_frequency(f"{left} {candidate_lemma}", "en"),
225
+ )
226
+ )
227
+ for source_score, candidate_score in checks:
228
+ if source_score >= 3.5 and candidate_score + 0.85 < source_score:
229
+ return False
230
+ if source_score >= 4.0 and candidate_score < 2.0:
231
  return False
232
  return True
233
 
234
 
235
  def _frequency_ok(source_frequency: float, candidate_frequency: float) -> bool:
 
236
  if candidate_frequency < ENGINE_LEXICAL_MIN_ZIPF:
237
  return False
238
  if ENGINE_LEXICAL_PREFER_SIMPLER:
 
239
  if candidate_frequency > source_frequency:
240
  return (
241
  candidate_frequency - source_frequency
242
  <= ENGINE_LEXICAL_MAX_SIMPLER_GAP
243
  )
 
244
  return source_frequency - candidate_frequency <= ENGINE_LEXICAL_MAX_HARDER_GAP
245
  return (
246
  abs(candidate_frequency - source_frequency)
 
249
 
250
 
251
  def _candidate_for_synset(synset, token, doc) -> tuple[str, float] | None:
 
252
  source = token.lemma_.lower()
253
  source_surface = token.text.lower()
 
 
254
  source_frequency = max(
255
  zipf_frequency(source, "en"),
256
  zipf_frequency(source_surface, "en"),
 
262
  except Exception:
263
  return None
264
 
 
265
  ranked: list[tuple[float, float, float, str]] = []
266
  for word in words:
267
  lemma = (word.lemma() or "").replace("_", " ").strip().lower()
 
272
  or not _WORD.fullmatch(lemma)
273
  ):
274
  continue
275
+ if not _collocation_ok(
 
 
276
  source,
277
  lemma,
278
  token,
 
280
  right=right,
281
  ):
282
  continue
 
 
283
  replacement = _inflect(lemma, token)
284
  if not replacement or replacement.lower() == token.text.lower():
285
  continue
286
+ candidate_frequency = zipf_frequency(lemma, "en")
287
  rank_frequency = zipf_frequency(replacement.lower(), "en")
288
  if not _frequency_ok(
289
  source_frequency,
 
291
  ):
292
  continue
293
  phrase = _phrase_zipf(replacement.lower(), left, right)
 
 
 
294
  if phrase + 0.55 < source_phrase:
295
  continue
296
  if left and token.pos_ == "NOUN":
 
298
  candidate_bigram = zipf_frequency(
299
  f"{left} {replacement.lower()}", "en"
300
  )
 
301
  if source_bigram >= 3.0 and candidate_bigram + 0.45 < source_bigram:
302
  continue
303
  if (
 
308
  and phrase > source_phrase + 0.25
309
  ):
310
  continue
311
+ if not substitution_pos_stable(doc.text, token.i, replacement):
312
+ continue
313
  ranked.append(
314
+ (
315
+ max(candidate_frequency, rank_frequency),
316
+ phrase,
317
+ -float(len(replacement)),
318
+ replacement,
319
+ )
320
  )
321
 
322
  if not ranked:
 
359
  return LexicalResult(text=source, reason="parse_failed")
360
 
361
  stop_words = set(nlp.Defaults.stop_words)
 
362
  proposals: list[tuple[float, int, float, int, int, str, Any]] = []
363
  for token in doc:
364
  if not _eligible(token, doc):
365
  continue
366
+ # Prefer low-impact slots: modifiers first; nouns/verbs need stronger sense support.
367
  context = _context_terms(doc, token)
368
  try:
369
  synsets = list(
 
374
  )
375
  except Exception:
376
  continue
377
+ if not synsets:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
378
  continue
379
+
380
+ # Nouns/verbs stay on the primary WordNet sense only. Secondary senses
381
+ # create meaning errors (skill→science) even with partial gloss overlap.
382
+ if token.pos_ in {"NOUN", "VERB"}:
383
+ chosen = synsets[0]
384
+ best_score = _sense_score(chosen, context, stop_words)
385
+ if best_score < min_wsd:
386
+ continue
387
+ else:
388
+ scored: list[tuple[float, Any]] = []
389
+ for synset in synsets:
390
+ scored.append((_sense_score(synset, context, stop_words), synset))
391
+ scored.sort(key=lambda item: item[0], reverse=True)
392
+ best_score, best_synset = scored[0]
393
+ chosen = None
394
+ if best_score >= min_wsd:
395
+ chosen = best_synset
396
+ elif len(synsets) == 1:
397
+ chosen = best_synset
398
+ best_score = min_wsd
399
+ elif ENGINE_LEXICAL_PREFER_SIMPLER and best_score == 0.0:
400
+ trial = _candidate_for_synset(synsets[0], token, doc)
401
+ if trial is not None and trial[1] >= 0.45:
402
+ chosen = synsets[0]
403
+ best_score = min_wsd
404
+ if chosen is None:
405
+ continue
406
+
407
+ picked = _candidate_for_synset(chosen, token, doc)
408
+ if not picked:
409
+ continue
410
+ replacement, simplicity_gain = picked
411
+ proposals.append(
412
+ (
413
+ best_score,
414
+ _impact_rank(token),
415
+ -simplicity_gain,
416
+ token.idx,
417
+ token.i,
418
+ replacement,
419
+ chosen,
420
  )
421
+ )
422
 
423
  if not proposals:
424
  return LexicalResult(text=source, reason="no_confident_candidate")
 
435
  ):
436
  token = doc[token_index]
437
  output = output[:offset] + replacement + output[offset + len(token.text) :]
 
438
  if token_index > 0 and doc[token_index - 1].lower_ in {"a", "an"}:
439
  prev = doc[token_index - 1]
440
  needed = _article_for(replacement)
 
451
  replacement=replacement,
452
  token_index=token_index,
453
  lemma=token.lemma_,
454
+ synset_id=str(getattr(synset, "id", synset)),
455
  confidence=round(confidence, 4),
456
  )
457
  )
app/engine/lexical/__pycache__/__init__.cpython-311.pyc CHANGED
Binary files a/app/engine/lexical/__pycache__/__init__.cpython-311.pyc and b/app/engine/lexical/__pycache__/__init__.cpython-311.pyc differ
 
app/engine/lexical/__pycache__/policy.cpython-311.pyc ADDED
Binary file (5.82 kB). View file
 
app/engine/orchestrator.py CHANGED
@@ -185,27 +185,28 @@ def _apply_paraphrase_fallback(
185
  min_confidence: float,
186
  use_minilm: bool,
187
  ) -> SentenceRecord:
188
- """Dynamic paraphrase, then varied structural reorders never fixed clefts."""
189
- if not enabled or record.sentence_type not in _REWRITEABLE_TYPES:
190
  return record
191
  if not _unchanged(record):
192
  return record
193
 
194
  options: list[tuple[str, str, float, str]] = []
195
- result = paraphrase_sentence(record.original)
196
- if (
197
- result.text
198
- and result.text != record.original
199
- and sufficiently_changed(record.original, result.text)
200
- ):
201
- options.append(
202
- (
203
- "paraphrase",
204
- result.text,
205
- max(0.55, result.confidence),
206
- "paraphrase_fallback",
 
 
207
  )
208
- )
209
 
210
  for template_id, candidate, confidence in structural_fallback_candidates(
211
  record.original
 
185
  min_confidence: float,
186
  use_minilm: bool,
187
  ) -> SentenceRecord:
188
+ """Paraphrase (optional) then structural reorders for unchanged sentences."""
189
+ if record.sentence_type not in _REWRITEABLE_TYPES:
190
  return record
191
  if not _unchanged(record):
192
  return record
193
 
194
  options: list[tuple[str, str, float, str]] = []
195
+ if enabled:
196
+ result = paraphrase_sentence(record.original)
197
+ if (
198
+ result.text
199
+ and result.text != record.original
200
+ and sufficiently_changed(record.original, result.text)
201
+ ):
202
+ options.append(
203
+ (
204
+ "paraphrase",
205
+ result.text,
206
+ max(0.55, result.confidence),
207
+ "paraphrase_fallback",
208
+ )
209
  )
 
210
 
211
  for template_id, candidate, confidence in structural_fallback_candidates(
212
  record.original
app/engine/quality/__init__.py ADDED
@@ -0,0 +1,200 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Algorithmic rewrite quality gates (no word/phrase denylists)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ from collections import Counter
7
+
8
+ from app.pipeline.nlp import get_nlp
9
+
10
+ _WORD = re.compile(r"[A-Za-z][A-Za-z']*")
11
+ _MODAL = frozenset(
12
+ {"can", "could", "will", "would", "may", "might", "should", "must", "shall"}
13
+ )
14
+
15
+
16
+ def _alpha_tokens(text: str) -> list[str]:
17
+ return [m.group(0).lower() for m in _WORD.finditer(text or "")]
18
+
19
+
20
+ def closed_compounds_not_split(original: str, candidate: str) -> bool:
21
+ """Reject when a solid source token is opened into two words (teamwork→team work)."""
22
+ source_tokens = _alpha_tokens(original)
23
+ cand = re.sub(r"\s+", " ", (candidate or "").lower())
24
+ cand_token_set = set(_alpha_tokens(candidate))
25
+ for token in source_tokens:
26
+ if len(token) < 6:
27
+ continue
28
+ if token in cand_token_set:
29
+ continue
30
+ for index in range(3, len(token) - 2):
31
+ left, right = token[:index], token[index:]
32
+ if f"{left} {right}" in cand:
33
+ return False
34
+ return True
35
+
36
+
37
+ def modal_head_pos_preserved(original: str, candidate: str) -> bool:
38
+ """Keep modal/aux clause heads stable in POS and lexical relatedness."""
39
+ nlp = get_nlp()
40
+ if nlp is None:
41
+ return True
42
+ try:
43
+ source_doc = nlp(original or "")
44
+ cand_doc = nlp(candidate or "")
45
+ except Exception:
46
+ return True
47
+
48
+ def _modal_heads(doc) -> list[tuple[str, str, str]]:
49
+ heads: list[tuple[str, str, str]] = []
50
+ for token in doc:
51
+ lemma = token.lemma_.lower()
52
+ is_modal = lemma in _MODAL or (
53
+ token.pos_ == "AUX" and token.dep_ in {"aux", "auxpass"}
54
+ )
55
+ if not is_modal:
56
+ continue
57
+ head = token.head
58
+ if head.i == token.i:
59
+ continue
60
+ heads.append((lemma, head.pos_, head.lemma_.lower()))
61
+ return heads
62
+
63
+ def _verb_related(source_lemma: str, candidate_lemma: str) -> bool:
64
+ if source_lemma == candidate_lemma:
65
+ return True
66
+ try:
67
+ import wn
68
+ from app.config import ENGINE_WORDNET_LEXICON
69
+
70
+ wn.config.allow_multithreading = True
71
+ resource = wn.Wordnet(ENGINE_WORDNET_LEXICON)
72
+ synsets = list(resource.synsets(source_lemma, pos="v"))
73
+ except Exception:
74
+ # If lexicon is unavailable, keep POS-only behavior.
75
+ return True
76
+ for synset in synsets:
77
+ try:
78
+ words = {
79
+ (word.lemma() or "").replace("_", " ").strip().lower()
80
+ for word in synset.words()
81
+ }
82
+ except Exception:
83
+ continue
84
+ if candidate_lemma in words:
85
+ return True
86
+ return False
87
+
88
+ source_heads = _modal_heads(source_doc)
89
+ if not source_heads:
90
+ return True
91
+ cand_heads = _modal_heads(cand_doc)
92
+ cand_by_modal: dict[str, list[tuple[str, str]]] = {}
93
+ for modal, pos, lemma in cand_heads:
94
+ cand_by_modal.setdefault(modal, []).append((pos, lemma))
95
+ for modal, pos, lemma in source_heads:
96
+ options = cand_by_modal.get(modal)
97
+ if not options:
98
+ continue
99
+ matched = False
100
+ for cand_pos, cand_lemma in options:
101
+ if cand_pos != pos:
102
+ continue
103
+ if _verb_related(lemma, cand_lemma):
104
+ matched = True
105
+ break
106
+ if not matched:
107
+ return False
108
+ return True
109
+
110
+
111
+ def content_pos_balance_ok(original: str, candidate: str) -> bool:
112
+ """Reject large shifts in content POS counts (noun/verb/adj)."""
113
+ nlp = get_nlp()
114
+ if nlp is None:
115
+ return True
116
+ try:
117
+ source_doc = nlp(original or "")
118
+ cand_doc = nlp(candidate or "")
119
+ except Exception:
120
+ return True
121
+
122
+ def _counts(doc) -> Counter:
123
+ return Counter(
124
+ token.pos_
125
+ for token in doc
126
+ if token.is_alpha and not token.is_stop and token.pos_ in {"NOUN", "VERB", "ADJ"}
127
+ )
128
+
129
+ source = _counts(source_doc)
130
+ cand = _counts(cand_doc)
131
+ for pos in ("NOUN", "VERB", "ADJ"):
132
+ if abs(source[pos] - cand[pos]) > 2:
133
+ return False
134
+ return True
135
+
136
+
137
+ def substitution_pos_stable(
138
+ sentence: str,
139
+ token_index: int,
140
+ replacement: str,
141
+ ) -> bool:
142
+ """Require the replaced span to keep the same coarse POS after re-parse."""
143
+ nlp = get_nlp()
144
+ if nlp is None:
145
+ return True
146
+ try:
147
+ doc = nlp(sentence)
148
+ except Exception:
149
+ return True
150
+ if token_index < 0 or token_index >= len(doc):
151
+ return False
152
+ token = doc[token_index]
153
+ updated = sentence[: token.idx] + replacement + sentence[token.idx + len(token.text) :]
154
+ try:
155
+ new_doc = nlp(updated)
156
+ except Exception:
157
+ return True
158
+ # Locate replacement by character offset.
159
+ target = None
160
+ for item in new_doc:
161
+ if item.idx == token.idx:
162
+ target = item
163
+ break
164
+ if target is None:
165
+ # Fallback: first overlapping alpha token near the old index.
166
+ for item in new_doc:
167
+ if item.is_alpha and abs(item.idx - token.idx) <= max(1, len(replacement)):
168
+ target = item
169
+ break
170
+ if target is None:
171
+ return False
172
+ return target.pos_ == token.pos_
173
+
174
+
175
+ def meaning_ok(original: str, candidate: str, *, min_sim: float = 0.72) -> bool | None:
176
+ """MiniLM meaning check when available; None means backend unavailable."""
177
+ try:
178
+ from app.pipeline.minilm import score_candidate
179
+
180
+ scored = score_candidate(original, candidate)
181
+ except Exception:
182
+ return None
183
+ if scored is None:
184
+ return None
185
+ return float(scored) >= min_sim
186
+
187
+
188
+ def naturalness_reasons(original: str, candidate: str) -> list[str]:
189
+ """Algorithmic naturalness failures (no phrase denylist)."""
190
+ reasons: list[str] = []
191
+ if not closed_compounds_not_split(original, candidate):
192
+ reasons.append("compound_split")
193
+ if not modal_head_pos_preserved(original, candidate):
194
+ reasons.append("modal_pos_shift")
195
+ if not content_pos_balance_ok(original, candidate):
196
+ reasons.append("pos_balance")
197
+ meaning = meaning_ok(original, candidate)
198
+ if meaning is False:
199
+ reasons.append("meaning_drop")
200
+ return reasons
app/engine/quality/__pycache__/__init__.cpython-311.pyc ADDED
Binary file (9.93 kB). View file
 
app/engine/safety/__init__.py CHANGED
@@ -7,6 +7,7 @@ from collections.abc import Iterable
7
  from dataclasses import dataclass
8
  from difflib import SequenceMatcher
9
 
 
10
  from app.pipeline.candidate_validator import validate_candidate
11
  from app.pipeline.meaning_safety import polarity_safe
12
  from app.pipeline.nlp import get_nlp
@@ -14,20 +15,6 @@ from app.pipeline.nlp import get_nlp
14
  _URL = re.compile(r"https?://[^\s<>\"']+|www\.[^\s<>\"']+", re.I)
15
  _EMAIL = re.compile(r"\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}\b")
16
  _NUMBER = re.compile(r"\b\d[\d,]*(?:\.\d+)?%?\b")
17
- _CLOSED_COMPOUNDS = (
18
- "teamwork",
19
- "teammate",
20
- "teammates",
21
- "workplace",
22
- "lifestyle",
23
- "classmate",
24
- "classroom",
25
- "homework",
26
- "feedback",
27
- "deadline",
28
- "workflow",
29
- "workforce",
30
- )
31
 
32
 
33
  @dataclass
@@ -65,26 +52,6 @@ def _numbers(text: str) -> set[str]:
65
  return {m.group(0).replace(",", "") for m in _NUMBER.finditer(text or "")}
66
 
67
 
68
- def _closed_compounds_preserved(original: str, candidate: str) -> bool:
69
- """Reject opening closed compounds (teamwork → team work)."""
70
- source = original or ""
71
- cand = candidate or ""
72
- for word in _CLOSED_COMPOUNDS:
73
- if not re.search(rf"\b{re.escape(word)}\b", source, flags=re.I):
74
- continue
75
- if re.search(rf"\b{re.escape(word)}\b", cand, flags=re.I):
76
- continue
77
- for index in range(3, len(word) - 2):
78
- left, right = word[:index], word[index:]
79
- if re.search(
80
- rf"\b{re.escape(left)}\s+{re.escape(right)}\b",
81
- cand,
82
- flags=re.I,
83
- ):
84
- return False
85
- return True
86
-
87
-
88
  def _tense_aux_ok(
89
  original: str,
90
  candidate: str,
@@ -139,8 +106,7 @@ def check_safety(
139
  if not polarity_safe(o, c):
140
  reasons.append("negation")
141
 
142
- if not _closed_compounds_preserved(o, c):
143
- reasons.append("closed_compound")
144
 
145
  if hard_invariants_only:
146
  # Forced rule fallbacks may add cleft auxiliaries; keep only hard facts.
@@ -152,8 +118,6 @@ def check_safety(
152
  o_nums, c_nums = _numbers(o), _numbers(c)
153
  if o_nums and not o_nums.issubset(c_nums):
154
  reasons.append("numbers")
155
- if not _closed_compounds_preserved(o, c) and "closed_compound" not in reasons:
156
- reasons.append("closed_compound")
157
  surface_sim = SequenceMatcher(None, o.lower(), c.lower()).ratio()
158
  return SafetyResult(
159
  ok=not reasons,
@@ -250,6 +214,10 @@ def check_safety(
250
  "broken",
251
  "duration_front",
252
  "stranded_prep",
 
 
 
 
253
  }
254
  or r.startswith("entity:")
255
  or r.startswith("meaning:")
 
7
  from dataclasses import dataclass
8
  from difflib import SequenceMatcher
9
 
10
+ from app.engine.quality import naturalness_reasons
11
  from app.pipeline.candidate_validator import validate_candidate
12
  from app.pipeline.meaning_safety import polarity_safe
13
  from app.pipeline.nlp import get_nlp
 
15
  _URL = re.compile(r"https?://[^\s<>\"']+|www\.[^\s<>\"']+", re.I)
16
  _EMAIL = re.compile(r"\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}\b")
17
  _NUMBER = re.compile(r"\b\d[\d,]*(?:\.\d+)?%?\b")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
18
 
19
 
20
  @dataclass
 
52
  return {m.group(0).replace(",", "") for m in _NUMBER.finditer(text or "")}
53
 
54
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
55
  def _tense_aux_ok(
56
  original: str,
57
  candidate: str,
 
106
  if not polarity_safe(o, c):
107
  reasons.append("negation")
108
 
109
+ reasons.extend(naturalness_reasons(o, c))
 
110
 
111
  if hard_invariants_only:
112
  # Forced rule fallbacks may add cleft auxiliaries; keep only hard facts.
 
118
  o_nums, c_nums = _numbers(o), _numbers(c)
119
  if o_nums and not o_nums.issubset(c_nums):
120
  reasons.append("numbers")
 
 
121
  surface_sim = SequenceMatcher(None, o.lower(), c.lower()).ratio()
122
  return SafetyResult(
123
  ok=not reasons,
 
214
  "broken",
215
  "duration_front",
216
  "stranded_prep",
217
+ "compound_split",
218
+ "modal_pos_shift",
219
+ "pos_balance",
220
+ "meaning_drop",
221
  }
222
  or r.startswith("entity:")
223
  or r.startswith("meaning:")
app/engine/safety/__pycache__/__init__.cpython-311.pyc CHANGED
Binary files a/app/engine/safety/__pycache__/__init__.cpython-311.pyc and b/app/engine/safety/__pycache__/__init__.cpython-311.pyc differ
 
app/engine/templates/__init__.py CHANGED
@@ -386,10 +386,10 @@ def try_in_pp_front(text: str) -> str | None:
386
 
387
 
388
  def try_copula_np_invert(text: str) -> str | None:
389
- """Gerund/clause subject + is a/an NP → A/an NP is subject."""
390
  raw, end = _sentence_end(text)
391
  match = re.match(
392
- r"^(?P<subj>(?:[A-Z][a-z]+ing|[A-Z][a-z]+ing\b.+?))\s+"
393
  r"(?P<be>is|are|was|were)\s+"
394
  r"(?P<pred>(?:a|an|the)\s+[^,;:]+)$",
395
  raw,
@@ -400,18 +400,105 @@ def try_copula_np_invert(text: str) -> str | None:
400
  subject = match.group("subj").strip(" ,")
401
  predicate = match.group("pred").strip(" ,")
402
  be = match.group("be")
403
- if len(subject.split()) < 2 or len(predicate.split()) < 2:
 
 
 
 
 
 
 
404
  return None
405
- # Keep article capitalization on the fronted NP.
406
  front = predicate[0].upper() + predicate[1:]
407
- lowered = subject[0].lower() + subject[1:]
408
- # Number agreement: "a/an ..." prefers singular "is".
409
  if re.match(r"^(a|an)\b", predicate, flags=re.I) and be in {"are", "were"}:
410
  be = "is" if be == "are" else "was"
 
 
 
 
 
 
411
  body = f"{front} {be} {lowered}"
412
  return re.sub(r"\s+", " ", body).strip() + end
413
 
414
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
415
  def try_such_as_front(text: str) -> str | None:
416
  """Label such as EXAMPLES can TAIL → EXAMPLES can TAIL as label."""
417
  raw, end = _sentence_end(text)
 
386
 
387
 
388
  def try_copula_np_invert(text: str) -> str | None:
389
+ """Subject + is/are a/an/the NP → NP is/are subject."""
390
  raw, end = _sentence_end(text)
391
  match = re.match(
392
+ r"^(?P<subj>[A-Z][^,]{1,100}?)\s+"
393
  r"(?P<be>is|are|was|were)\s+"
394
  r"(?P<pred>(?:a|an|the)\s+[^,;:]+)$",
395
  raw,
 
400
  subject = match.group("subj").strip(" ,")
401
  predicate = match.group("pred").strip(" ,")
402
  be = match.group("be")
403
+ if len(subject.split()) < 1 or len(predicate.split()) < 2:
404
+ return None
405
+ # Avoid inverting long clausal subjects with internal finite verbs.
406
+ if re.search(
407
+ r"\b(that|which|who|when|where|because|while|although)\b",
408
+ subject,
409
+ flags=re.I,
410
+ ):
411
  return None
 
412
  front = predicate[0].upper() + predicate[1:]
413
+ lowered = subject[0].lower() + subject[1:] if subject[:1].isupper() else subject
 
414
  if re.match(r"^(a|an)\b", predicate, flags=re.I) and be in {"are", "were"}:
415
  be = "is" if be == "are" else "was"
416
+ if re.match(r"^the\b", predicate, flags=re.I) and be in {"is", "was"}:
417
+ # Keep agreement for pluralish subjects ending with s when obvious.
418
+ if subject.lower().endswith("s") and not subject.lower().endswith(
419
+ ("ss", "ness", "ics")
420
+ ):
421
+ be = "are" if be == "is" else "were"
422
  body = f"{front} {be} {lowered}"
423
  return re.sub(r"\s+", " ", body).strip() + end
424
 
425
 
426
+ def try_when_clause_front(text: str) -> str | None:
427
+ """Main … when Sub → When Sub, main …"""
428
+ raw, end = _sentence_end(text)
429
+ match = re.match(
430
+ r"^(?P<main>.+?)\s+when\s+(?P<sub>.+)$",
431
+ raw,
432
+ flags=re.I,
433
+ )
434
+ if not match:
435
+ return None
436
+ main = match.group("main").strip(" ,")
437
+ sub = match.group("sub").strip(" ,")
438
+ if len(main.split()) < 4 or len(sub.split()) < 3:
439
+ return None
440
+ if re.search(r"\bwhen\b", main, flags=re.I) or re.search(
441
+ r"\bwhen\b", sub, flags=re.I
442
+ ):
443
+ return None
444
+ body = (
445
+ f"When {sub[0].lower() + sub[1:]}, "
446
+ f"{main[0].lower() + main[1:]}"
447
+ )
448
+ return re.sub(r"\s+", " ", body).strip() + end
449
+
450
+
451
+ def try_by_agent_front(text: str) -> str | None:
452
+ """… are/is VERBen by AGENT → By AGENT, … are/is VERBen."""
453
+ raw, end = _sentence_end(text)
454
+ match = re.match(
455
+ r"^(?P<head>.+?)\s+"
456
+ r"(?P<be>is|are|was|were)\s+"
457
+ r"(?P<verb>\w+ed|\w+en)\s+"
458
+ r"by\s+(?P<agent>.+)$",
459
+ raw,
460
+ flags=re.I,
461
+ )
462
+ if not match:
463
+ return None
464
+ head = match.group("head").strip(" ,")
465
+ agent = match.group("agent").strip(" ,")
466
+ if len(head.split()) < 3 or len(agent.split()) < 2:
467
+ return None
468
+ if re.search(r"\bby\b", head, flags=re.I):
469
+ return None
470
+ lowered = head[0].lower() + head[1:] if head[:1].isupper() else head
471
+ body = (
472
+ f"By {agent}, {lowered} {match.group('be')} {match.group('verb')}"
473
+ )
474
+ return re.sub(r"\s+", " ", body).strip() + end
475
+
476
+
477
+ def try_in_both_front(text: str) -> str | None:
478
+ """X is Y in both A and B → In both A and B, x is Y."""
479
+ raw, end = _sentence_end(text)
480
+ match = re.match(
481
+ r"^(?P<head>.+?)\s+"
482
+ r"(?P<be>is|are|was|were)\s+"
483
+ r"(?P<pred>.+?)\s+"
484
+ r"in both\s+(?P<scope>.+)$",
485
+ raw,
486
+ flags=re.I,
487
+ )
488
+ if not match:
489
+ return None
490
+ head = match.group("head").strip(" ,")
491
+ pred = match.group("pred").strip(" ,")
492
+ scope = match.group("scope").strip(" ,")
493
+ if len(head.split()) < 1 or len(pred.split()) < 2 or len(scope.split()) < 3:
494
+ return None
495
+ lowered = head[0].lower() + head[1:] if head[:1].isupper() else head
496
+ body = (
497
+ f"In both {scope}, {lowered} {match.group('be')} {pred}"
498
+ )
499
+ return re.sub(r"\s+", " ", body).strip() + end
500
+
501
+
502
  def try_such_as_front(text: str) -> str | None:
503
  """Label such as EXAMPLES can TAIL → EXAMPLES can TAIL as label."""
504
  raw, end = _sentence_end(text)
app/engine/templates/__pycache__/__init__.cpython-311.pyc CHANGED
Binary files a/app/engine/templates/__pycache__/__init__.cpython-311.pyc and b/app/engine/templates/__pycache__/__init__.cpython-311.pyc differ