idnameraj commited on
Commit
11c7d9f
·
verified ·
1 Parent(s): 8ade4ce

Upload 146 files

Browse files
app/engine/classify/__init__.py CHANGED
@@ -1,15 +1,10 @@
1
- """Sentence type classification — only safe types enter the rewrite path."""
2
 
3
  from __future__ import annotations
4
 
5
  import re
6
 
7
- _SUBORDINATORS = frozenset(
8
- """
9
- although though while whilst whereas unless until since
10
- if when whenever wherever whether before after
11
- """.split()
12
- )
13
 
14
  _REWRITEABLE = frozenset({"simple_declarative", "compound", "because_clause"})
15
 
@@ -34,27 +29,58 @@ def classify_sentence(text: str) -> str:
34
  if len(t.split()) > 45:
35
  return "too_long"
36
 
37
- low = t.lower()
38
- if re.search(r"\bbecause\b", low):
39
- return "because_clause"
 
 
 
40
 
41
- # Coordinating compound (safe if short enough)
42
- if re.search(r"\b(and|but|or|so|yet)\b", low) and "," in t:
43
- # Still complex if subordinators present
44
- for sub in _SUBORDINATORS:
45
- if re.search(rf"\b{re.escape(sub)}\b", low):
 
 
 
 
 
46
  return "complex"
47
- if len(t.split()) <= 28:
 
 
 
 
 
 
 
 
48
  return "compound"
49
  return "complex"
 
50
 
51
- for sub in _SUBORDINATORS:
52
- if re.search(rf"\b{re.escape(sub)}\b", low):
53
- return "complex"
54
 
55
- if re.search(r"\b(who|whom|whose|which|that)\b", low) and len(t.split()) > 12:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
56
  return "complex"
57
-
58
  return "simple_declarative"
59
 
60
 
 
1
+ """Sentence type classification — spaCy-driven when available."""
2
 
3
  from __future__ import annotations
4
 
5
  import re
6
 
7
+ from app.pipeline.nlp import get_nlp
 
 
 
 
 
8
 
9
  _REWRITEABLE = frozenset({"simple_declarative", "compound", "because_clause"})
10
 
 
29
  if len(t.split()) > 45:
30
  return "too_long"
31
 
32
+ nlp = get_nlp()
33
+ if nlp is not None:
34
+ return _classify_spacy(t, nlp)
35
+
36
+ return _classify_regex(t)
37
+
38
 
39
+ def _classify_spacy(text: str, nlp) -> str:
40
+ doc = nlp(text)
41
+ # because as mark / SCONJ
42
+ for t in doc:
43
+ if t.lemma_.lower() == "because" and t.pos_ in {"SCONJ", "ADP"}:
44
+ return "because_clause"
45
+ # Subordinate clauses via mark / advcl / SCONJ
46
+ for t in doc:
47
+ if t.dep_ == "mark" and t.head.dep_ in {"advcl", "acl"}:
48
+ if t.lemma_.lower() != "because":
49
  return "complex"
50
+ if t.pos_ == "SCONJ" and t.lemma_.lower() != "because":
51
+ if t.head.dep_ in {"advcl", "acl", "ROOT"} or t.dep_ == "mark":
52
+ return "complex"
53
+ # Relative clauses on longer sentences are unsafe to slot-rebuild
54
+ if any(t.dep_ == "relcl" for t in doc) and len(text.split()) >= 10:
55
+ return "complex"
56
+ # Coordinating compound with comma
57
+ if "," in text and any(t.dep_ == "cc" and t.head.dep_ in {"conj", "ROOT"} for t in doc):
58
+ if len(text.split()) <= 28:
59
  return "compound"
60
  return "complex"
61
+ return "simple_declarative"
62
 
 
 
 
63
 
64
+ def _classify_regex(text: str) -> str:
65
+ low = text.lower()
66
+ if re.search(r"\bbecause\b", low):
67
+ return "because_clause"
68
+ if re.search(r"\b(and|but|or|so|yet)\b", low) and "," in text:
69
+ if re.search(
70
+ r"\b(although|though|while|whilst|whereas|unless|until|since|if|when|"
71
+ r"whenever|wherever|whether|before|after)\b",
72
+ low,
73
+ ):
74
+ return "complex"
75
+ return "compound" if len(text.split()) <= 28 else "complex"
76
+ if re.search(
77
+ r"\b(although|though|while|whilst|whereas|unless|until|since|if|when|"
78
+ r"whenever|wherever|whether|before|after)\b",
79
+ low,
80
+ ):
81
+ return "complex"
82
+ if re.search(r"\b(who|whom|whose|which|that)\b", low) and len(text.split()) > 12:
83
  return "complex"
 
84
  return "simple_declarative"
85
 
86
 
app/engine/classify/__pycache__/__init__.cpython-311.pyc CHANGED
Binary files a/app/engine/classify/__pycache__/__init__.cpython-311.pyc and b/app/engine/classify/__pycache__/__init__.cpython-311.pyc differ
 
app/engine/consistency/__init__.py CHANGED
@@ -1,4 +1,4 @@
1
- """Document-level consistency pass after sentence rewrite."""
2
 
3
  from __future__ import annotations
4
 
@@ -6,45 +6,28 @@ import re
6
  from collections import defaultdict
7
 
8
  from app.engine.models import SentenceRecord
 
9
 
10
- # Words that are often sentence-initial — never treat as entity canon targets
11
- _COMMON_STARTS = frozenset(
12
- """
13
- the a an and or but if when as at by for in on to of with from
14
- this that these those it its they them he she we you i my our your
15
- many most some any all each every other such own same too not
16
- reading writing sleeping walking running making taking getting
17
- students experts people workers researchers managers teachers
18
- yesterday today tomorrow earlier later recently now
19
- because although however therefore moreover furthermore
20
- """.split()
21
- )
22
 
23
-
24
- def _mid_sentence_propers(text: str) -> list[str]:
25
- """Capitalized tokens that appear mid-sentence (likely real names)."""
26
- raw = text or ""
27
- out: list[str] = []
28
- parts = re.split(r"(?<=[.!?])\s+", raw)
29
- for part in parts:
30
- tokens = re.findall(r"\b[A-Za-z][A-Za-z0-9'-]*\b", part)
31
- for tok in tokens[1:]:
32
- if tok[0].isupper() and tok.lower() not in _COMMON_STARTS:
33
- out.append(tok)
34
- return out
35
 
36
 
37
  def canonicalize_entities(text: str, records: list[SentenceRecord]) -> str:
38
- """Normalize true proper-noun spelling; ignore sentence-initial capitals."""
39
  canon: dict[str, str] = {}
40
  for rec in records:
41
- for name in _mid_sentence_propers(rec.original):
42
  key = name.lower()
43
- if key in _COMMON_STARTS:
44
- continue
45
  if key not in canon:
46
  canon[key] = name
47
-
48
  if not canon:
49
  return text
50
 
@@ -59,13 +42,14 @@ def canonicalize_entities(text: str, records: list[SentenceRecord]) -> str:
59
 
60
 
61
  def suppress_template_streaks(records: list[SentenceRecord]) -> list[SentenceRecord]:
62
- """If two consecutive rewrites used the same time-front template, revert the second."""
63
  time_fronts = {
64
  "time_subj_manner_verb_place",
65
  "time_subj_verb_place",
66
  "time_subj_verb_object",
67
  "time_subj_verb_object_place",
68
  "time_front",
 
 
69
  }
70
  out: list[SentenceRecord] = []
71
  prev_tid = ""
@@ -75,18 +59,19 @@ def suppress_template_streaks(records: list[SentenceRecord]) -> list[SentenceRec
75
  and rec.template_id in time_fronts
76
  and prev_tid == rec.template_id
77
  ):
78
- reverted = SentenceRecord(
79
- index=rec.index,
80
- original=rec.original,
81
- rewritten=rec.original,
82
- confidence=rec.confidence,
83
- status="reverted",
84
- template_id="",
85
- sentence_type=rec.sentence_type,
86
- reasons=rec.reasons + ["template_streak"],
87
- block_index=rec.block_index,
 
 
88
  )
89
- out.append(reverted)
90
  prev_tid = ""
91
  continue
92
  out.append(rec)
@@ -98,15 +83,13 @@ def apply_consistency(
98
  text: str,
99
  records: list[SentenceRecord],
100
  ) -> tuple[str, list[SentenceRecord]]:
101
- """Run light document-level consistency fixes."""
102
  adjusted = suppress_template_streaks(records)
103
- fixed_text = canonicalize_entities(text, adjusted)
104
- return fixed_text, adjusted
105
 
106
 
107
  def entity_frequency(records: list[SentenceRecord]) -> dict[str, int]:
108
  freq: dict[str, int] = defaultdict(int)
109
  for rec in records:
110
- for name in _mid_sentence_propers(rec.original):
111
  freq[name] += 1
112
  return dict(freq)
 
1
+ """Document-level consistency spaCy PROPN only, no word lists."""
2
 
3
  from __future__ import annotations
4
 
 
6
  from collections import defaultdict
7
 
8
  from app.engine.models import SentenceRecord
9
+ from app.pipeline.nlp import get_nlp
10
 
 
 
 
 
 
 
 
 
 
 
 
 
11
 
12
+ def _proper_from_text(text: str) -> list[str]:
13
+ nlp = get_nlp()
14
+ if nlp is None:
15
+ return []
16
+ try:
17
+ doc = nlp(text or "")
18
+ return [t.text for t in doc if t.pos_ == "PROPN"]
19
+ except Exception:
20
+ return []
 
 
 
21
 
22
 
23
  def canonicalize_entities(text: str, records: list[SentenceRecord]) -> str:
24
+ """Normalize PROPN spelling to first-seen form."""
25
  canon: dict[str, str] = {}
26
  for rec in records:
27
+ for name in _proper_from_text(rec.original):
28
  key = name.lower()
 
 
29
  if key not in canon:
30
  canon[key] = name
 
31
  if not canon:
32
  return text
33
 
 
42
 
43
 
44
  def suppress_template_streaks(records: list[SentenceRecord]) -> list[SentenceRecord]:
 
45
  time_fronts = {
46
  "time_subj_manner_verb_place",
47
  "time_subj_verb_place",
48
  "time_subj_verb_object",
49
  "time_subj_verb_object_place",
50
  "time_front",
51
+ "place_front",
52
+ "adv_subj_verb_object",
53
  }
54
  out: list[SentenceRecord] = []
55
  prev_tid = ""
 
59
  and rec.template_id in time_fronts
60
  and prev_tid == rec.template_id
61
  ):
62
+ out.append(
63
+ SentenceRecord(
64
+ index=rec.index,
65
+ original=rec.original,
66
+ rewritten=rec.original,
67
+ confidence=rec.confidence,
68
+ status="reverted",
69
+ template_id="",
70
+ sentence_type=rec.sentence_type,
71
+ reasons=rec.reasons + ["template_streak"],
72
+ block_index=rec.block_index,
73
+ )
74
  )
 
75
  prev_tid = ""
76
  continue
77
  out.append(rec)
 
83
  text: str,
84
  records: list[SentenceRecord],
85
  ) -> tuple[str, list[SentenceRecord]]:
 
86
  adjusted = suppress_template_streaks(records)
87
+ return canonicalize_entities(text, adjusted), adjusted
 
88
 
89
 
90
  def entity_frequency(records: list[SentenceRecord]) -> dict[str, int]:
91
  freq: dict[str, int] = defaultdict(int)
92
  for rec in records:
93
+ for name in _proper_from_text(rec.original):
94
  freq[name] += 1
95
  return dict(freq)
app/engine/consistency/__pycache__/__init__.cpython-311.pyc CHANGED
Binary files a/app/engine/consistency/__pycache__/__init__.cpython-311.pyc and b/app/engine/consistency/__pycache__/__init__.cpython-311.pyc differ
 
app/engine/grammar/__init__.py CHANGED
@@ -5,26 +5,33 @@ from __future__ import annotations
5
  import re
6
 
7
  from app.pipeline.grammar_fix import correct_text
8
-
9
-
10
- _COMMON_AFTER_COMMA = frozenset(
11
- """
12
- the a an and or but if when as at by for in on to of with from
13
- this that these those it its they them he she we you i my our your
14
- many most some any all each every other such reading writing sleeping
15
- students experts people workers researchers managers teachers
16
- she he they we it
17
- """.split()
18
- )
19
 
20
 
21
  def _fix_intro_comma_caps(text: str) -> str:
22
- """Lowercase common words right after an introductory comma; keep names."""
 
23
 
24
  def repl(m: re.Match[str]) -> str:
25
  word = m.group(1)
26
- if word.lower() in _COMMON_AFTER_COMMA:
27
- return ", " + word[0].lower() + word[1:]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
28
  return m.group(0)
29
 
30
  return re.sub(r",\s+([A-Z][a-zA-Z']*)", repl, text)
 
5
  import re
6
 
7
  from app.pipeline.grammar_fix import correct_text
8
+ from app.pipeline.nlp import get_nlp
 
 
 
 
 
 
 
 
 
 
9
 
10
 
11
  def _fix_intro_comma_caps(text: str) -> str:
12
+ """Lowercase non-PROPN tokens right after an introductory comma (spaCy POS)."""
13
+ nlp = get_nlp()
14
 
15
  def repl(m: re.Match[str]) -> str:
16
  word = m.group(1)
17
+ if nlp is not None:
18
+ try:
19
+ tok = nlp(word)[0]
20
+ if tok.pos_ == "PROPN":
21
+ return m.group(0)
22
+ if tok.pos_ in {
23
+ "DET",
24
+ "PRON",
25
+ "ADP",
26
+ "SCONJ",
27
+ "CCONJ",
28
+ "ADV",
29
+ "NOUN",
30
+ "ADJ",
31
+ }:
32
+ return ", " + word[0].lower() + word[1:]
33
+ except Exception:
34
+ pass
35
  return m.group(0)
36
 
37
  return re.sub(r",\s+([A-Z][a-zA-Z']*)", repl, text)
app/engine/grammar/__pycache__/__init__.cpython-311.pyc CHANGED
Binary files a/app/engine/grammar/__pycache__/__init__.cpython-311.pyc and b/app/engine/grammar/__pycache__/__init__.cpython-311.pyc differ
 
app/engine/parse/__init__.py CHANGED
@@ -1,4 +1,4 @@
1
- """Dependency parsing — spaCy-first slot extraction (no synonym tables)."""
2
 
3
  from __future__ import annotations
4
 
@@ -8,30 +8,6 @@ from app.engine.classify import classify_sentence
8
  from app.engine.models import SentenceSlots
9
  from app.pipeline.nlp import get_nlp
10
 
11
- # Closed-class helpers only (spaCy NER/deps preferred for open-class time/place)
12
- _TIME_WORDS = frozenset(
13
- """
14
- yesterday today tomorrow earlier later recently now tonight
15
- monday tuesday wednesday thursday friday saturday sunday
16
- morning afternoon evening night
17
- """.split()
18
- )
19
- _FREQ_TIME = frozenset(
20
- """
21
- everyday daily nightly weekly monthly yearly annually
22
- """.split()
23
- )
24
- _DISCOURSE_TIME = frozenset({"nowadays"})
25
- _DEGREE_ADVS = frozenset(
26
- """
27
- more most less least much many very really quite just also still even
28
- only rather pretty fairly so too enough almost nearly already ever never
29
- not n't however nevertheless furthermore moreover
30
- """.split()
31
- )
32
- _PLACE_PREPS = frozenset("to at in on into onto from toward towards".split())
33
- _DURATION_PREPS = frozenset({"for", "over", "within", "during"})
34
-
35
 
36
  def _span_text(tokens: list) -> str:
37
  if not tokens:
@@ -43,36 +19,71 @@ def _subtree_tokens(token) -> list:
43
  return sorted(token.subtree, key=lambda t: t.i)
44
 
45
 
46
- def _is_manner_adv(word: str) -> bool:
47
- low = (word or "").lower().strip()
48
- if not low or low in _DEGREE_ADVS or low in _DISCOURSE_TIME:
49
- return False
50
- return low.endswith("ly") and len(low) >= 4
 
 
 
 
51
 
52
 
53
- def _is_duration_span(span: str, full_text: str) -> bool:
54
- """Durations inside for/over/in are not frontable sentence time."""
55
- s = (span or "").strip()
56
- if not s:
57
- return False
58
- low = s.lower()
59
- if re.search(r"\b(minute|minutes|hour|hours|second|seconds)\b", low):
 
60
  return True
61
- if re.match(r"at\s+least\b", low):
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
62
  return True
63
- if re.search(rf"\b(for|over|within|during)\s+{re.escape(s)}\b", full_text, flags=re.I):
 
64
  return True
 
 
 
 
65
  return False
66
 
67
 
68
  def _dedupe_spans(spans: list[str]) -> list[str]:
69
- """Prefer longer spans; drop ones contained in another."""
70
- cleaned = []
71
- for s in spans:
72
- s = (s or "").strip()
73
- if not s:
74
- continue
75
- cleaned.append(s)
76
  cleaned.sort(key=len, reverse=True)
77
  kept: list[str] = []
78
  for s in cleaned:
@@ -85,24 +96,28 @@ def _dedupe_spans(spans: list[str]) -> list[str]:
85
  return kept
86
 
87
 
88
- def _extract_freq_time(text: str) -> str:
89
- """Frontable frequency adverbials (every day / everyday / each morning)."""
90
- raw = text or ""
91
- m = re.search(
92
- r"\b(every|each)\s+(day|night|morning|evening|week|month|year)\b",
93
- raw,
94
- flags=re.I,
95
- )
96
- if m:
97
- return m.group(0)
98
- m2 = re.search(r"\b(everyday|daily|nightly|weekly|monthly|annually)\b", raw, flags=re.I)
99
- if m2:
100
- return m2.group(1)
 
 
 
 
101
  return ""
102
 
103
 
104
  def extract_slots(text: str) -> SentenceSlots:
105
- """Parse subject/verb/object/time/place/manner/negation/entities."""
106
  raw = (text or "").strip()
107
  slots = SentenceSlots(text=raw, sentence_type=classify_sentence(raw))
108
  if slots.sentence_type not in {"simple_declarative", "compound"}:
@@ -129,50 +144,81 @@ def extract_slots(text: str) -> SentenceSlots:
129
  slots.reasons.append("no_verb")
130
  return slots
131
 
132
- subj_toks: list = []
 
133
  for child in root.children:
134
- if child.dep_ in {"nsubj", "nsubjpass"}:
135
- subj_toks = _subtree_tokens(child)
136
  break
137
- slots.subject = _span_text(subj_toks)
138
 
139
- neg_parts: list[str] = []
 
140
  for t in doc:
141
- if t.dep_ == "neg" and (t.head == root or t.head.head == root):
142
- neg_parts.append(t.text)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
143
  slots.negation = " ".join(neg_parts)
144
 
145
  verb_toks = [t for t in root.lefts if t.dep_ in {"aux", "auxpass", "neg"}] + [root]
146
  slots.verb = root.text
147
  slots.verb_phrase = _span_text(sorted(verb_toks, key=lambda t: t.i))
148
 
149
- # Object: noun objects, or non-finite complements (reading … / to go …)
150
  obj_toks: list = []
151
  for child in root.children:
152
  if child.dep_ in {"dobj", "obj", "attr"}:
153
  obj_toks = _subtree_tokens(child)
154
  break
 
 
 
 
 
 
 
 
 
155
  if not obj_toks:
156
  for child in root.children:
157
  if child.dep_ in {"xcomp", "ccomp"}:
158
- # Keep gerund/infinitive complements as object material
159
  obj_toks = _subtree_tokens(child)
160
- # Include leading "to" aux if present for infinitives
161
  break
162
  slots.object = _span_text(obj_toks)
163
 
 
164
  place_parts: list[str] = []
165
- for child in root.children:
166
- if child.dep_ == "prep" and child.text.lower() in _PLACE_PREPS:
167
- span = _span_text(_subtree_tokens(child))
168
- if not any(w.lower() in _TIME_WORDS for w in span.split()):
169
- if not _is_duration_span(span.lstrip("to at in on "), raw):
170
- place_parts.append(span)
171
- if not place_parts:
172
- for t in doc:
173
- if t.dep_ == "prep" and t.head == root and t.text.lower() in _PLACE_PREPS:
174
- place_parts.append(_span_text(_subtree_tokens(t)))
175
- slots.place = " ".join(p for p in place_parts if p).strip()
 
 
 
 
 
 
 
 
176
 
177
  time_parts: list[str] = []
178
  manner_parts: list[str] = []
@@ -180,64 +226,81 @@ def extract_slots(text: str) -> SentenceSlots:
180
  for ent in doc.ents:
181
  if ent.label_ not in {"DATE", "TIME"}:
182
  continue
183
- if _is_duration_span(ent.text, raw):
184
  continue
185
- under_for = False
186
- for t in ent:
187
- cur = t
188
- while cur is not None and cur.head != cur:
189
- if cur.dep_ == "prep" and cur.text.lower() in _DURATION_PREPS:
190
- under_for = True
191
- break
192
- cur = cur.head
193
- if under_for:
194
- break
195
- if under_for:
196
  continue
197
  time_parts.append(ent.text)
198
 
199
  for t in doc:
200
- low = t.text.lower()
201
- if t.dep_ in {"advmod", "npadvmod"} and (t.head == root or t.head.head == root):
202
- if low in _TIME_WORDS or low in _FREQ_TIME or t.ent_type_ in {"DATE", "TIME"}:
203
- if low not in _DISCOURSE_TIME:
204
- span = _span_text(_subtree_tokens(t))
205
- if not _is_duration_span(span, raw):
206
- time_parts.append(span)
207
- elif _is_manner_adv(low):
208
- manner_parts.append(t.text)
209
- if t.dep_ == "npadvmod" and low in _TIME_WORDS and low not in _DISCOURSE_TIME:
210
- time_parts.append(t.text)
211
-
212
- # Frequency phrases (every day / everyday) — safe to front, keep durations intact
213
- freq = _extract_freq_time(raw)
214
- if freq:
215
- time_parts.append(freq)
216
-
217
- if not time_parts:
218
- for t in doc:
219
- low = t.text.lower()
220
- if low in _TIME_WORDS and low not in _DISCOURSE_TIME:
221
- time_parts.append(t.text)
222
- if not manner_parts:
223
- for t in doc:
224
- if _is_manner_adv(t.text.lower()) and t.pos_ == "ADV":
225
  manner_parts.append(t.text)
226
 
 
 
 
 
 
 
 
 
 
 
 
 
 
227
  time_parts = [
228
- p for p in _dedupe_spans(list(dict.fromkeys(time_parts))) if not _is_duration_span(p, raw)
 
 
 
 
229
  ]
230
- # Prefer a single best time span
231
  slots.time = time_parts[0] if time_parts else ""
232
- slots.manner = " ".join(dict.fromkeys(manner_parts)).strip()
233
-
234
- # Strip time/manner/freq from object if absorbed
235
- for piece in (slots.place, slots.time, slots.manner, freq):
236
- if piece and slots.object:
237
- slots.object = re.sub(
238
- rf"\b{re.escape(piece)}\b", "", slots.object, count=1, flags=re.I
239
- ).strip(" ,")
240
- slots.object = re.sub(r"\s+", " ", slots.object).strip()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
241
 
242
  score = 0.2
243
  if slots.subject:
@@ -257,7 +320,7 @@ def extract_slots(text: str) -> SentenceSlots:
257
 
258
 
259
  def _extract_slots_regex(text: str) -> SentenceSlots:
260
- """Regex fallback when spaCy is unavailable multi-word subjects supported."""
261
  slots = SentenceSlots(text=text, sentence_type=classify_sentence(text))
262
  if slots.sentence_type != "simple_declarative":
263
  return slots
@@ -266,67 +329,51 @@ def _extract_slots_regex(text: str) -> SentenceSlots:
266
  if m_end:
267
  core = text[: m_end.start()]
268
 
269
- # Peel frequency / calendar time / manner from the end
270
  manner = ""
271
- time = ""
272
  mm = re.search(r"\b(\w+ly)$", core, flags=re.I)
273
  if mm:
274
  manner = mm.group(1)
275
  core = core[: mm.start()].strip()
276
- mf = re.search(
277
- r"\b((?:every|each)\s+(?:day|night|morning|evening|week|month|year)|"
278
- r"everyday|daily|yesterday|today|tomorrow|earlier|later|recently|"
279
- r"this\s+(?:morning|afternoon|evening|week|month|year)|"
280
- r"last\s+(?:night|week|month|year))\s*$",
281
- core,
282
- flags=re.I,
283
- )
284
  if mf:
285
  time = mf.group(1)
286
  core = core[: mf.start()].strip()
287
 
288
- # Subject = determiners/adjectives + noun(s); verb = next token
289
  m = re.match(
290
- r"^(?P<subj>(?:(?:The|A|An|My|Your|Their|Our|His|Her|Many|Most|Some|Several|These|Those|This|That)\s+)?"
291
- r"(?:[A-Za-z][\w'-]*\s+){0,3}[A-Za-z][\w'-]*)\s+"
292
- r"(?P<verb>(?:(?:did|does|do|will|would|could|should|can|may|might|must|has|have|had|is|are|was|were|been)\s+)?"
293
- r"(?:not\s+)?[A-Za-z][\w'-]*)\s*"
294
- r"(?P<rest>.*)$",
295
  core,
296
  flags=re.I,
297
  )
298
  if not m:
299
- slots.reasons.append("regex_no_match")
300
- return slots
301
-
302
- slots.subject = m.group("subj").strip()
303
- slots.verb_phrase = m.group("verb").strip()
304
- slots.verb = slots.verb_phrase.split()[-1]
305
- rest = (m.group("rest") or "").strip()
306
-
307
- # Place: to X
308
- mp = re.match(r"^to\s+(.+)$", rest, flags=re.I)
309
- if mp and " for " not in rest.lower():
310
- slots.place = "to " + mp.group(1).strip()
311
  else:
312
- slots.object = rest
 
 
 
 
 
 
 
 
313
 
314
  slots.time = time
315
  slots.manner = manner
316
- if not slots.time:
317
- slots.time = _extract_freq_time(text)
318
-
319
- score = 0.2
320
- if slots.subject:
321
- score += 0.25
322
- if slots.verb_phrase:
323
- score += 0.2
324
- if slots.object or slots.place:
325
- score += 0.15
326
- if slots.time:
327
- score += 0.1
328
- if slots.manner:
329
- score += 0.1
330
  slots.confidence = min(1.0, score)
331
  if slots.confidence < 0.45 or not slots.subject or not slots.verb_phrase:
332
  slots.reasons.append("low_confidence")
 
1
+ """Dependency parsing — spaCy-driven slots (no synonym or domain word lists)."""
2
 
3
  from __future__ import annotations
4
 
 
8
  from app.engine.models import SentenceSlots
9
  from app.pipeline.nlp import get_nlp
10
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
11
 
12
  def _span_text(tokens: list) -> str:
13
  if not tokens:
 
19
  return sorted(token.subtree, key=lambda t: t.i)
20
 
21
 
22
+ def _under_duration_prep(token) -> bool:
23
+ """True if token sits under a duration-like preposition (for/over/within/during)."""
24
+ cur = token
25
+ while cur is not None and cur.head != cur:
26
+ if cur.dep_ == "prep" and cur.lemma_.lower() in {"for", "over", "within", "during"}:
27
+ # Duration if pobj/subtree looks numeric or has TIME unit via ent/morph
28
+ return True
29
+ cur = cur.head
30
+ return False
31
 
32
 
33
+ def _is_duration_ent(ent, doc_text: str) -> bool:
34
+ """DATE/TIME under for/over, or minute/hour-style spans not sentence adjuncts."""
35
+ text = ent.text.strip()
36
+ if re.search(
37
+ rf"\b(for|over|within|during)\s+{re.escape(text)}\b",
38
+ doc_text,
39
+ flags=re.I,
40
+ ):
41
  return True
42
+ for t in ent:
43
+ if _under_duration_prep(t):
44
+ # Allow calendar adjuncts like "for Monday" rarely; block if CARDINAL/TIME unitish
45
+ if ent.label_ in {"TIME", "DATE"} and re.search(
46
+ r"\b(minute|minutes|hour|hours|second|seconds)\b",
47
+ text,
48
+ flags=re.I,
49
+ ):
50
+ return True
51
+ if re.match(r"at\s+least\b", text, flags=re.I):
52
+ return True
53
+ if ent.label_ == "TIME" and _under_duration_prep(t):
54
+ return True
55
+ return False
56
+
57
+
58
+ def _is_frontable_adv(tok, root) -> bool:
59
+ """ADV modifiers that can move — derived from POS/DEP, not word lists."""
60
+ if tok.pos_ != "ADV":
61
+ return False
62
+ if tok.dep_ not in {"advmod", "npadvmod"}:
63
+ return False
64
+ if tok.lemma_.lower() in {"not", "n't"} or tok.dep_ == "neg":
65
+ return False
66
+ # Degree on adjectives/adverbs: "very important", "really useful"
67
+ if tok.head.pos_ in {"ADJ", "ADV"} and tok.head.i != root.i:
68
+ return False
69
+ return True
70
+
71
+
72
+ def _is_temporal_tok(tok) -> bool:
73
+ if tok.ent_type_ in {"DATE", "TIME"}:
74
  return True
75
+ # spaCy morph sometimes marks Tense; prefer ent + npadvmod of DATE-like
76
+ if tok.dep_ in {"npadvmod", "advmod"} and tok.ent_type_ in {"DATE", "TIME"}:
77
  return True
78
+ if tok.dep_ == "npadvmod" and tok.pos_ in {"NOUN", "PROPN"}:
79
+ # "yesterday"/"today" often NOUN+npadvmod without ent in sm model
80
+ if tok.head.pos_ in {"VERB", "AUX"}:
81
+ return True
82
  return False
83
 
84
 
85
  def _dedupe_spans(spans: list[str]) -> list[str]:
86
+ cleaned = [s.strip() for s in spans if s and s.strip()]
 
 
 
 
 
 
87
  cleaned.sort(key=len, reverse=True)
88
  kept: list[str] = []
89
  for s in cleaned:
 
96
  return kept
97
 
98
 
99
+ def _freq_from_doc(doc) -> str:
100
+ """every/each + noun via dependency structure (not a word list of weekdays)."""
101
+ for tok in doc:
102
+ if tok.pos_ == "DET" and tok.lemma_.lower() in {"every", "each"}:
103
+ head = tok.head
104
+ if head.pos_ in {"NOUN", "PROPN"}:
105
+ return _span_text([tok] + _subtree_tokens(head))
106
+ # single-token ADV frequency sometimes tagged ADV (everyday)
107
+ if tok.pos_ == "ADV" and tok.dep_ in {"advmod", "npadvmod"}:
108
+ # compounded everyday-like: has "day" lemma shape via morph — use text if det-less
109
+ if "day" in tok.lemma_.lower() or tok.text.lower().endswith("day"):
110
+ if tok.ent_type_ in {"DATE", "TIME", ""} and len(tok.text) > 3:
111
+ # only if not under duration prep
112
+ if not _under_duration_prep(tok):
113
+ # Avoid treating random -day words; require no object attachment
114
+ if tok.head.pos_ in {"VERB", "AUX"}:
115
+ return tok.text
116
  return ""
117
 
118
 
119
  def extract_slots(text: str) -> SentenceSlots:
120
+ """Parse subject/verb/object/time/place/manner/negation/entities via spaCy."""
121
  raw = (text or "").strip()
122
  slots = SentenceSlots(text=raw, sentence_type=classify_sentence(raw))
123
  if slots.sentence_type not in {"simple_declarative", "compound"}:
 
144
  slots.reasons.append("no_verb")
145
  return slots
146
 
147
+ # Subject: nominal or clausal (Visiting a library…)
148
+ subj_child = None
149
  for child in root.children:
150
+ if child.dep_ in {"nsubj", "nsubjpass", "csubj", "csubjpass"}:
151
+ subj_child = child
152
  break
 
153
 
154
+ # Collect frontable adverbs first so we can exclude them from subject span
155
+ early_manner: list = []
156
  for t in doc:
157
+ if _is_frontable_adv(t, root):
158
+ if not (_is_temporal_tok(t) or t.ent_type_ in {"DATE", "TIME"}):
159
+ if t.dep_ != "npadvmod" or t.pos_ not in {"NOUN", "PROPN"}:
160
+ early_manner.append(t)
161
+
162
+ if subj_child is not None:
163
+ exclude = {t.i for t in early_manner}
164
+ subj_toks = [t for t in _subtree_tokens(subj_child) if t.i not in exclude]
165
+ slots.subject = _span_text(subj_toks)
166
+ else:
167
+ slots.subject = ""
168
+
169
+ neg_parts = [
170
+ t.text
171
+ for t in doc
172
+ if t.dep_ == "neg" and (t.head == root or t.head.head == root)
173
+ ]
174
  slots.negation = " ".join(neg_parts)
175
 
176
  verb_toks = [t for t in root.lefts if t.dep_ in {"aux", "auxpass", "neg"}] + [root]
177
  slots.verb = root.text
178
  slots.verb_phrase = _span_text(sorted(verb_toks, key=lambda t: t.i))
179
 
 
180
  obj_toks: list = []
181
  for child in root.children:
182
  if child.dep_ in {"dobj", "obj", "attr"}:
183
  obj_toks = _subtree_tokens(child)
184
  break
185
+ # Append non-finite complements (to enjoy learning) so they are not dropped on rebuild
186
+ for child in root.children:
187
+ if child.dep_ in {"xcomp", "ccomp"}:
188
+ extra = _subtree_tokens(child)
189
+ # include leading "to" already in subtree for PART aux
190
+ for t in extra:
191
+ if t not in obj_toks:
192
+ obj_toks.append(t)
193
+ obj_toks = sorted(obj_toks, key=lambda t: t.i)
194
  if not obj_toks:
195
  for child in root.children:
196
  if child.dep_ in {"xcomp", "ccomp"}:
 
197
  obj_toks = _subtree_tokens(child)
 
198
  break
199
  slots.object = _span_text(obj_toks)
200
 
201
+ # Place / beneficiary PPs: prep+pobj under root or under object head
202
  place_parts: list[str] = []
203
+ for t in doc:
204
+ if t.dep_ != "prep" or t.pos_ != "ADP":
205
+ continue
206
+ # NP-internal complements (thousands of books) are not frontable place adjuncts
207
+ if t.head.pos_ in {"NOUN", "PROPN", "NUM", "PRON"} and t.lemma_.lower() == "of":
208
+ continue
209
+ if t.head != root and t.head.dep_ not in {"attr", "dobj", "obj"}:
210
+ continue
211
+ span = _span_text(_subtree_tokens(t))
212
+ pobj = next((c for c in t.children if c.dep_ == "pobj"), None)
213
+ if (
214
+ pobj is not None
215
+ and t.lemma_.lower() in {"for", "over", "within", "during"}
216
+ and any(c.like_num or c.ent_type_ in {"TIME", "CARDINAL"} for c in pobj.subtree)
217
+ ):
218
+ continue
219
+ if t.head == root or t.head.dep_ in {"attr", "dobj", "obj"}:
220
+ place_parts.append(span)
221
+ slots.place = _dedupe_spans(place_parts)[0] if place_parts else ""
222
 
223
  time_parts: list[str] = []
224
  manner_parts: list[str] = []
 
226
  for ent in doc.ents:
227
  if ent.label_ not in {"DATE", "TIME"}:
228
  continue
229
+ if _is_duration_ent(ent, raw):
230
  continue
231
+ under = any(_under_duration_prep(t) for t in ent)
232
+ if under and re.search(
233
+ r"\b(minute|minutes|hour|hours|second|seconds)\b", ent.text, flags=re.I
234
+ ):
235
+ continue
236
+ if under and re.match(r"at\s+least\b", ent.text, flags=re.I):
237
+ continue
238
+ # Duration under for — skip; bare adjunct DATE/TIME — keep
239
+ if under and ent.label_ == "TIME":
 
 
240
  continue
241
  time_parts.append(ent.text)
242
 
243
  for t in doc:
244
+ if _is_frontable_adv(t, root):
245
+ if _is_temporal_tok(t) or t.ent_type_ in {"DATE", "TIME"}:
246
+ if not _under_duration_prep(t):
247
+ time_parts.append(_span_text(_subtree_tokens(t)))
248
+ elif t.dep_ == "npadvmod" and t.pos_ in {"NOUN", "PROPN"} and t.head == root:
249
+ if not _under_duration_prep(t):
250
+ time_parts.append(_span_text(_subtree_tokens(t)))
251
+ else:
252
+ # manner / discourse adverb
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
253
  manner_parts.append(t.text)
254
 
255
+ freq = _freq_from_doc(doc)
256
+ if freq and not re.search(r"\b(minute|minutes|hour|hours|second|seconds)\b", freq, flags=re.I):
257
+ if not re.search(rf"\b(for|over|within|during)\s+{re.escape(freq)}\b", raw, flags=re.I):
258
+ time_parts.append(freq)
259
+
260
+ # npadvmod temporal nouns on root (yesterday/today) without NER
261
+ for t in doc:
262
+ if t.dep_ == "npadvmod" and t.head == root and t.pos_ in {"NOUN", "PROPN"}:
263
+ if not _under_duration_prep(t):
264
+ time_parts.append(_span_text(_subtree_tokens(t)))
265
+
266
+ time_parts = _dedupe_spans(list(dict.fromkeys(time_parts)))
267
+ # Drop duration-looking leftovers
268
  time_parts = [
269
+ p
270
+ for p in time_parts
271
+ if not re.search(r"\b(minute|minutes|hour|hours|second|seconds)\b", p, flags=re.I)
272
+ and not re.match(r"at\s+least\b", p, flags=re.I)
273
+ and not re.search(rf"\b(for|over|within|during)\s+{re.escape(p)}\b", raw, flags=re.I)
274
  ]
 
275
  slots.time = time_parts[0] if time_parts else ""
276
+ # Prefer a single -ly manner adverb when several ADVs fire (also/still/regularly)
277
+ ly = [m for m in dict.fromkeys(manner_parts) if m.lower().endswith("ly")]
278
+ if ly:
279
+ slots.manner = ly[0]
280
+ else:
281
+ slots.manner = next(iter(dict.fromkeys(manner_parts)), "")
282
+ # Never treat the same span as both time and manner
283
+ if slots.manner and slots.time and slots.manner.lower() == slots.time.lower():
284
+ slots.manner = ""
285
+ if slots.manner and slots.time and slots.manner.lower() in slots.time.lower():
286
+ slots.manner = ""
287
+
288
+ # If place was absorbed into object, keep object but allow place move
289
+ if slots.place and slots.object and slots.place in slots.object:
290
+ # OK — templates can omit place from object when fronting
291
+ pass
292
+
293
+ for piece in (slots.time, slots.manner, freq):
294
+ if not piece:
295
+ continue
296
+ for attr in ("object", "subject", "place", "verb_phrase"):
297
+ val = getattr(slots, attr) or ""
298
+ if piece.lower() in val.lower():
299
+ cleaned = re.sub(
300
+ rf"\b{re.escape(piece)}\b", "", val, count=1, flags=re.I
301
+ ).strip(" ,")
302
+ cleaned = re.sub(r"\s+", " ", cleaned).strip()
303
+ setattr(slots, attr, cleaned)
304
 
305
  score = 0.2
306
  if slots.subject:
 
320
 
321
 
322
  def _extract_slots_regex(text: str) -> SentenceSlots:
323
+ """Minimal regex fallback structure only, no domain lexicons."""
324
  slots = SentenceSlots(text=text, sentence_type=classify_sentence(text))
325
  if slots.sentence_type != "simple_declarative":
326
  return slots
 
329
  if m_end:
330
  core = text[: m_end.start()]
331
 
332
+ # Trailing -ly adverb
333
  manner = ""
 
334
  mm = re.search(r"\b(\w+ly)$", core, flags=re.I)
335
  if mm:
336
  manner = mm.group(1)
337
  core = core[: mm.start()].strip()
338
+
339
+ # Trailing every/each + noun (DET pattern)
340
+ time = ""
341
+ mf = re.search(r"\b((?:every|each)\s+\w+)$", core, flags=re.I)
 
 
 
 
342
  if mf:
343
  time = mf.group(1)
344
  core = core[: mf.start()].strip()
345
 
 
346
  m = re.match(
347
+ r"^(?P<subj>.+?)\s+(?P<verb>\w+(?:\s+\w+)?)\s+(?P<rest>.+)$",
 
 
 
 
348
  core,
349
  flags=re.I,
350
  )
351
  if not m:
352
+ # Subj verb only
353
+ m2 = re.match(r"^(?P<subj>.+?)\s+(?P<verb>\w+)$", core, flags=re.I)
354
+ if not m2:
355
+ slots.reasons.append("regex_no_match")
356
+ return slots
357
+ slots.subject = m2.group("subj").strip()
358
+ slots.verb_phrase = m2.group("verb").strip()
359
+ slots.verb = slots.verb_phrase.split()[-1]
 
 
 
 
360
  else:
361
+ slots.subject = m.group("subj").strip()
362
+ slots.verb_phrase = m.group("verb").strip()
363
+ slots.verb = slots.verb_phrase.split()[-1]
364
+ rest = m.group("rest").strip()
365
+ if re.match(r"^to\s+\w+", rest, flags=re.I):
366
+ slots.place = rest if " for " not in rest.lower() else ""
367
+ slots.object = rest if not slots.place else ""
368
+ else:
369
+ slots.object = rest
370
 
371
  slots.time = time
372
  slots.manner = manner
373
+ score = 0.2 + (0.25 if slots.subject else 0) + (0.2 if slots.verb_phrase else 0)
374
+ score += 0.15 if (slots.object or slots.place) else 0
375
+ score += 0.1 if slots.time else 0
376
+ score += 0.1 if slots.manner else 0
 
 
 
 
 
 
 
 
 
 
377
  slots.confidence = min(1.0, score)
378
  if slots.confidence < 0.45 or not slots.subject or not slots.verb_phrase:
379
  slots.reasons.append("low_confidence")
app/engine/parse/__pycache__/__init__.cpython-311.pyc CHANGED
Binary files a/app/engine/parse/__pycache__/__init__.cpython-311.pyc and b/app/engine/parse/__pycache__/__init__.cpython-311.pyc differ
 
app/engine/rewrite/__init__.py CHANGED
@@ -48,8 +48,9 @@ def reorder_quality_ok(source: str, candidate: str) -> bool:
48
  cand_toks = _content_tokens(cand)
49
  if not src_toks:
50
  return False
 
51
  keep = len(set(src_toks) & set(cand_toks)) / max(1, len(set(src_toks)))
52
- if keep < 0.85:
53
  return False
54
  if len(cand_toks) < int(len(src_toks) * 0.75) or len(cand_toks) > int(
55
  len(src_toks) * 1.35
 
48
  cand_toks = _content_tokens(cand)
49
  if not src_toks:
50
  return False
51
+ # Soften keep ratio slightly for structural adverb fronts that drop discourse ADVs (also)
52
  keep = len(set(src_toks) & set(cand_toks)) / max(1, len(set(src_toks)))
53
+ if keep < 0.80:
54
  return False
55
  if len(cand_toks) < int(len(src_toks) * 0.75) or len(cand_toks) > int(
56
  len(src_toks) * 1.35
app/engine/rewrite/__pycache__/__init__.cpython-311.pyc CHANGED
Binary files a/app/engine/rewrite/__pycache__/__init__.cpython-311.pyc and b/app/engine/rewrite/__pycache__/__init__.cpython-311.pyc differ
 
app/engine/templates/__init__.py CHANGED
@@ -5,6 +5,7 @@ from __future__ import annotations
5
  import re
6
 
7
  from app.engine.models import SentenceSlots, TemplateCandidate
 
8
 
9
 
10
  def rank_templates(slots: SentenceSlots) -> list[TemplateCandidate]:
@@ -25,19 +26,20 @@ def rank_templates(slots: SentenceSlots) -> list[TemplateCandidate]:
25
  if slots.time:
26
  out.append(TemplateCandidate("time_subj_verb_object_place", 0.72))
27
  out.append(TemplateCandidate("subj_verb_object_time", 0.68))
28
- # Prefer slot rebuild over strip-based front when object/place exist
29
  out.append(TemplateCandidate("time_front", 0.64))
 
 
30
  if slots.manner and slots.place:
31
  out.append(TemplateCandidate("subj_manner_verb_place", 0.7))
32
  if slots.manner and slots.object:
33
  out.append(TemplateCandidate("subj_manner_verb_rest", 0.66))
34
  if slots.manner:
35
- out.append(TemplateCandidate("adv_subj_verb_object", 0.62))
36
- out.append(TemplateCandidate("subj_manner_verb_rest", 0.6))
37
  if slots.place:
38
- out.append(TemplateCandidate("subj_verb_place", 0.55))
39
  if slots.object:
40
- out.append(TemplateCandidate("subj_verb_object", 0.5))
41
 
42
  seen: set[str] = set()
43
  ranked: list[TemplateCandidate] = []
@@ -49,20 +51,6 @@ def rank_templates(slots: SentenceSlots) -> list[TemplateCandidate]:
49
  return ranked
50
 
51
 
52
- _COMMON_CONTINUATIONS = frozenset(
53
- """
54
- the a an and or but if when as at by for in on to of with from
55
- this that these those it its they them he she we you i my our your
56
- many most some any all each every other such own same too not
57
- reading writing sleeping walking running making taking getting
58
- students experts people workers researchers managers teachers
59
- yesterday today tomorrow earlier later recently now
60
- because although however therefore moreover furthermore
61
- she he they we it one someone everyone nobody
62
- """.split()
63
- )
64
-
65
-
66
  def _cap(text: str) -> str:
67
  t = (text or "").strip()
68
  if not t:
@@ -71,19 +59,36 @@ def _cap(text: str) -> str:
71
 
72
 
73
  def _cont(phrase: str, slots: SentenceSlots) -> str:
74
- """Lowercase continuation after an intro phrase only for common words."""
75
  p = (phrase or "").strip()
76
  if not p:
77
  return p
78
  first = p.split()[0].strip("\"'")
79
- # Always keep known NER entities
80
  for ent in slots.entities:
81
  ent_first = ent.split()[0]
82
- if first.lower() == ent_first.lower() and ent_first[0].isupper():
83
- if first.lower() not in _COMMON_CONTINUATIONS:
84
- return ent_first + p[len(first) :]
85
- if first and first[0].isupper() and first.lower() in _COMMON_CONTINUATIONS:
86
- return first.lower() + p[len(first) :]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
87
  return p
88
 
89
 
@@ -101,17 +106,32 @@ def _terminal(text: str) -> str:
101
  return "."
102
 
103
 
104
- _AUX_START = frozenset(
105
- "did does do will would could should can may might must has have had is are was were been being".split()
106
- )
107
-
108
-
109
  def _manner_before_verb(verb_phrase: str) -> bool:
110
- """False when verb phrase starts with an auxiliary (avoid 'regularly should drink')."""
111
- parts = (verb_phrase or "").split()
112
  if not parts:
113
  return True
114
- return parts[0].lower() not in _AUX_START
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
115
 
116
 
117
  def fill_template(template_id: str, slots: SentenceSlots) -> str | None:
@@ -119,9 +139,37 @@ def fill_template(template_id: str, slots: SentenceSlots) -> str | None:
119
  s = slots
120
  end = _terminal(s.text)
121
  tid = template_id
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
122
 
123
  if tid == "time_subj_manner_verb_place":
124
- place_or_obj = s.place or s.object
125
  if _manner_before_verb(s.verb_phrase):
126
  body = _join_slots(
127
  _cap(s.time) + ",", _cont(s.subject, s), s.manner, s.verb_phrase, place_or_obj
@@ -141,9 +189,9 @@ def fill_template(template_id: str, slots: SentenceSlots) -> str | None:
141
  _cap(s.time) + ",",
142
  _cont(s.subject, s),
143
  s.verb_phrase,
144
- s.object,
145
  s.manner,
146
- s.place,
147
  )
148
  return body + end
149
 
@@ -152,14 +200,14 @@ def fill_template(template_id: str, slots: SentenceSlots) -> str | None:
152
  _cap(s.time) + ",",
153
  _cont(s.subject, s),
154
  s.verb_phrase,
155
- s.object,
156
  s.place,
157
  s.manner,
158
  )
159
  return body + end
160
 
161
  if tid == "subj_verb_object_time":
162
- body = _join_slots(_cap(s.subject), s.verb_phrase, s.object, s.place, s.manner, s.time)
163
  return body + end
164
 
165
  if tid == "adv_subj_verb_object":
@@ -167,56 +215,52 @@ def fill_template(template_id: str, slots: SentenceSlots) -> str | None:
167
  _cap(s.manner) + ",",
168
  _cont(s.subject, s),
169
  s.verb_phrase,
170
- s.object,
171
  s.place,
172
  s.time,
173
  )
174
  return body + end
175
 
176
  if tid == "subj_manner_verb_place":
177
- body = _join_slots(_cap(s.subject), s.manner, s.verb_phrase, s.place)
 
 
 
178
  return body + end
179
 
180
  if tid == "time_front":
181
- # Refuse to front durations that were complements of "for/over/…"
182
  if s.time and re.search(
183
  rf"\b(for|over|within|during)\s+{re.escape(s.time)}\b",
184
  s.text,
185
  flags=re.I,
186
  ):
187
  return None
188
- if s.time and re.search(
189
- r"\b(minute|minutes|hour|hours|second|seconds)\b", s.time, flags=re.I
190
- ):
191
- return None
192
- rest = s.text
193
- if s.time:
194
- rest = re.sub(rf"\b{re.escape(s.time)}\b", "", rest, count=1, flags=re.I)
195
- rest = re.sub(r"\s+", " ", rest).strip(" ,.")
196
- if not rest:
197
- return None
198
- # Reject stranded prepositions: "… for every day" / "… for."
199
- if re.search(r"\b(for|over|within|during)\s+(every|each|\.|$)", rest, flags=re.I):
200
- return None
201
- if re.search(r"\b(for|over|within|during)\s*$", rest, flags=re.I):
202
- return None
203
- # Prefer rebuilding from slots when we have subject+verb (cleaner than strip)
204
  if s.subject and s.verb_phrase:
205
  body = _join_slots(
206
  _cap(s.time) + ",",
207
  _cont(s.subject, s),
208
  s.verb_phrase,
209
- s.object,
210
  s.place,
211
  s.manner,
212
  )
213
  return body.rstrip(".!?") + end
 
 
 
 
 
 
 
 
214
  body = _join_slots(_cap(s.time) + ",", _cont(rest, s))
215
  return body.rstrip(".!?") + end
216
 
217
  if tid == "subj_manner_verb_rest":
218
- rest_bits = [s.verb_phrase, s.object, s.place, s.time]
219
- body = _join_slots(_cap(s.subject), s.manner, *rest_bits)
 
 
220
  return body + end
221
 
222
  if tid == "subj_verb_place":
@@ -224,7 +268,7 @@ def fill_template(template_id: str, slots: SentenceSlots) -> str | None:
224
  return body + end
225
 
226
  if tid == "subj_verb_object":
227
- body = _join_slots(_cap(s.subject), s.verb_phrase, s.object, s.place, s.time, s.manner)
228
  return body + end
229
 
230
  return None
@@ -259,12 +303,30 @@ def try_because_front(text: str) -> str | None:
259
 
260
 
261
  def try_discourse_front(text: str) -> str | None:
262
- """Move mid-sentence 'nowadays' to the front."""
263
  raw = (text or "").strip()
264
- if not raw or re.match(r"^nowadays\b", raw, flags=re.I):
265
- return None
266
- if re.search(r"\b(because|although|while|unless)\b", raw, flags=re.I):
267
  return None
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
268
  end = "."
269
  m_end = re.search(r"[.!?]+$", raw)
270
  core = raw
 
5
  import re
6
 
7
  from app.engine.models import SentenceSlots, TemplateCandidate
8
+ from app.pipeline.nlp import get_nlp
9
 
10
 
11
  def rank_templates(slots: SentenceSlots) -> list[TemplateCandidate]:
 
26
  if slots.time:
27
  out.append(TemplateCandidate("time_subj_verb_object_place", 0.72))
28
  out.append(TemplateCandidate("subj_verb_object_time", 0.68))
 
29
  out.append(TemplateCandidate("time_front", 0.64))
30
+ if slots.place and slots.subject and slots.verb_phrase:
31
+ out.append(TemplateCandidate("place_front", 0.74))
32
  if slots.manner and slots.place:
33
  out.append(TemplateCandidate("subj_manner_verb_place", 0.7))
34
  if slots.manner and slots.object:
35
  out.append(TemplateCandidate("subj_manner_verb_rest", 0.66))
36
  if slots.manner:
37
+ out.append(TemplateCandidate("adv_subj_verb_object", 0.72))
38
+ out.append(TemplateCandidate("subj_manner_verb_rest", 0.65))
39
  if slots.place:
40
+ out.append(TemplateCandidate("subj_verb_place", 0.6))
41
  if slots.object:
42
+ out.append(TemplateCandidate("subj_verb_object", 0.58))
43
 
44
  seen: set[str] = set()
45
  ranked: list[TemplateCandidate] = []
 
51
  return ranked
52
 
53
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
54
  def _cap(text: str) -> str:
55
  t = (text or "").strip()
56
  if not t:
 
59
 
60
 
61
  def _cont(phrase: str, slots: SentenceSlots) -> str:
62
+ """Lowercase continuation after intro using spaCy POS (keep PROPN / entities)."""
63
  p = (phrase or "").strip()
64
  if not p:
65
  return p
66
  first = p.split()[0].strip("\"'")
 
67
  for ent in slots.entities:
68
  ent_first = ent.split()[0]
69
+ if first.lower() == ent_first.lower() and ent_first[:1].isupper():
70
+ # PERSON/ORG/GPE-style entities only (skip CARDINAL etc. via original casing mid-sent)
71
+ return p
72
+
73
+ nlp = get_nlp()
74
+ if nlp is not None:
75
+ try:
76
+ tok = nlp(first)[0]
77
+ if tok.pos_ == "PROPN":
78
+ return p
79
+ if tok.pos_ in {"DET", "PRON", "ADP", "SCONJ", "CCONJ", "ADV", "PART"}:
80
+ return first.lower() + p[len(first) :]
81
+ # Common noun/gerund subject after intro comma → lowercase
82
+ if tok.pos_ in {"NOUN", "VERB"}:
83
+ return first.lower() + p[len(first) :]
84
+ except Exception:
85
+ pass
86
+ if first and first[0].isupper() and len(first) > 1 and first.isupper() is False:
87
+ # Fallback without model: lowercase non-PROPN-looking tokens (mixed case names kept)
88
+ if first[0].isupper() and first[1:].islower() and first.lower() != first:
89
+ # Ambiguous — keep if looks like name (short Title case single token often name)
90
+ # Prefer lowercasing articles/pronouns already handled; leave as-is for names
91
+ return p
92
  return p
93
 
94
 
 
106
  return "."
107
 
108
 
 
 
 
 
 
109
  def _manner_before_verb(verb_phrase: str) -> bool:
110
+ """False when verb phrase starts with an auxiliary/copula (spaCy POS)."""
111
+ parts = (verb_phrase or "").strip()
112
  if not parts:
113
  return True
114
+ nlp = get_nlp()
115
+ if nlp is not None:
116
+ try:
117
+ tok = nlp(parts)[0]
118
+ # AUX or copular BE as root verb phrase start
119
+ if tok.pos_ == "AUX":
120
+ return False
121
+ if tok.lemma_.lower() == "be":
122
+ return False
123
+ except Exception:
124
+ pass
125
+ return True
126
+
127
+
128
+ def _object_without_place(slots: SentenceSlots) -> str:
129
+ obj = (slots.object or "").strip()
130
+ place = (slots.place or "").strip()
131
+ if place and obj and place in obj:
132
+ obj = obj.replace(place, "").strip(" ,")
133
+ obj = re.sub(r"\s+", " ", obj).strip()
134
+ return obj
135
 
136
 
137
  def fill_template(template_id: str, slots: SentenceSlots) -> str | None:
 
139
  s = slots
140
  end = _terminal(s.text)
141
  tid = template_id
142
+ obj = _object_without_place(s)
143
+
144
+ if tid == "place_front":
145
+ if not s.place:
146
+ return None
147
+ # Copula/AUX: Subject + Verb + Manner + Object (not Subject + Manner + Verb)
148
+ if _manner_before_verb(s.verb_phrase):
149
+ body = _join_slots(
150
+ _cap(s.place) + ",",
151
+ _cont(s.subject, s),
152
+ s.manner,
153
+ s.verb_phrase,
154
+ obj,
155
+ s.time,
156
+ )
157
+ else:
158
+ body = _join_slots(
159
+ _cap(s.place) + ",",
160
+ _cont(s.subject, s),
161
+ s.verb_phrase,
162
+ s.manner,
163
+ obj,
164
+ s.time,
165
+ )
166
+ out = body + end
167
+ if out.lower().rstrip(".!?") == s.text.lower().rstrip(".!?"):
168
+ return None
169
+ return out
170
 
171
  if tid == "time_subj_manner_verb_place":
172
+ place_or_obj = s.place or obj
173
  if _manner_before_verb(s.verb_phrase):
174
  body = _join_slots(
175
  _cap(s.time) + ",", _cont(s.subject, s), s.manner, s.verb_phrase, place_or_obj
 
189
  _cap(s.time) + ",",
190
  _cont(s.subject, s),
191
  s.verb_phrase,
192
+ obj,
193
  s.manner,
194
+ s.place if s.place not in (obj or "") else "",
195
  )
196
  return body + end
197
 
 
200
  _cap(s.time) + ",",
201
  _cont(s.subject, s),
202
  s.verb_phrase,
203
+ obj,
204
  s.place,
205
  s.manner,
206
  )
207
  return body + end
208
 
209
  if tid == "subj_verb_object_time":
210
+ body = _join_slots(_cap(s.subject), s.verb_phrase, obj, s.place, s.manner, s.time)
211
  return body + end
212
 
213
  if tid == "adv_subj_verb_object":
 
215
  _cap(s.manner) + ",",
216
  _cont(s.subject, s),
217
  s.verb_phrase,
218
+ obj,
219
  s.place,
220
  s.time,
221
  )
222
  return body + end
223
 
224
  if tid == "subj_manner_verb_place":
225
+ if _manner_before_verb(s.verb_phrase):
226
+ body = _join_slots(_cap(s.subject), s.manner, s.verb_phrase, s.place)
227
+ else:
228
+ body = _join_slots(_cap(s.subject), s.verb_phrase, s.manner, s.place)
229
  return body + end
230
 
231
  if tid == "time_front":
 
232
  if s.time and re.search(
233
  rf"\b(for|over|within|during)\s+{re.escape(s.time)}\b",
234
  s.text,
235
  flags=re.I,
236
  ):
237
  return None
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
238
  if s.subject and s.verb_phrase:
239
  body = _join_slots(
240
  _cap(s.time) + ",",
241
  _cont(s.subject, s),
242
  s.verb_phrase,
243
+ obj,
244
  s.place,
245
  s.manner,
246
  )
247
  return body.rstrip(".!?") + end
248
+ rest = s.text
249
+ if s.time:
250
+ rest = re.sub(rf"\b{re.escape(s.time)}\b", "", rest, count=1, flags=re.I)
251
+ rest = re.sub(r"\s+", " ", rest).strip(" ,.")
252
+ if not rest:
253
+ return None
254
+ if re.search(r"\b(for|over|within|during)\s+(every|each|\.|$)", rest, flags=re.I):
255
+ return None
256
  body = _join_slots(_cap(s.time) + ",", _cont(rest, s))
257
  return body.rstrip(".!?") + end
258
 
259
  if tid == "subj_manner_verb_rest":
260
+ if _manner_before_verb(s.verb_phrase):
261
+ body = _join_slots(_cap(s.subject), s.manner, s.verb_phrase, obj, s.place, s.time)
262
+ else:
263
+ body = _join_slots(_cap(s.subject), s.verb_phrase, s.manner, obj, s.place, s.time)
264
  return body + end
265
 
266
  if tid == "subj_verb_place":
 
268
  return body + end
269
 
270
  if tid == "subj_verb_object":
271
+ body = _join_slots(_cap(s.subject), s.verb_phrase, obj, s.place, s.time, s.manner)
272
  return body + end
273
 
274
  return None
 
303
 
304
 
305
  def try_discourse_front(text: str) -> str | None:
306
+ """Move mid-sentence discourse ADV (nowadays) detected via spaCy when possible."""
307
  raw = (text or "").strip()
308
+ if not raw:
 
 
309
  return None
310
+ nlp = get_nlp()
311
+ token = None
312
+ if nlp is not None:
313
+ doc = nlp(raw)
314
+ for t in doc:
315
+ if (
316
+ t.pos_ == "ADV"
317
+ and t.dep_ in {"advmod", "npadvmod"}
318
+ and t.i > 0
319
+ and t.head.dep_ == "ROOT"
320
+ and t.text.lower() == "nowadays"
321
+ ):
322
+ token = t
323
+ break
324
+ if token is None:
325
+ if re.match(r"^nowadays\b", raw, flags=re.I):
326
+ return None
327
+ m = re.search(r"\bnowadays\b", raw, flags=re.I)
328
+ if not m:
329
+ return None
330
  end = "."
331
  m_end = re.search(r"[.!?]+$", raw)
332
  core = raw
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
 
tests/__pycache__/test_esl_habit_regression.cpython-311-pytest-9.1.1.pyc CHANGED
Binary files a/tests/__pycache__/test_esl_habit_regression.cpython-311-pytest-9.1.1.pyc and b/tests/__pycache__/test_esl_habit_regression.cpython-311-pytest-9.1.1.pyc differ
 
tests/test_esl_habit_regression.py CHANGED
@@ -34,6 +34,23 @@ def test_esl_habit_no_duration_front():
34
 
35
  def test_esl_habit_no_false_entity_caps():
36
  result = rewrite_document(SAMPLE)
37
- # Consistency must not force "Reading" / "Many" mid-sentence caps after commas
38
  assert ", Reading " not in result.text
39
  assert ", Many " not in result.text
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
34
 
35
  def test_esl_habit_no_false_entity_caps():
36
  result = rewrite_document(SAMPLE)
 
37
  assert ", Reading " not in result.text
38
  assert ", Many " not in result.text
39
+
40
+
41
+ def test_library_sample_reorders():
42
+ sample = (
43
+ "Libraries is still an important place for students and researchers. "
44
+ "They contains thousands of books that provide useful informations on different subjects. "
45
+ "Visiting a library regularly can also encourage childrens to enjoy learning."
46
+ )
47
+ result = rewrite_document(sample)
48
+ # Must do structural reorder, not grammar-only passthrough on sentence 1
49
+ assert result.sentences[0].status == "rewritten"
50
+ assert result.sentences[0].rewritten.lower().startswith("for students")
51
+ # Relative-clause sentence is safely skipped (may still get grammar cleanup)
52
+ assert result.sentences[1].status in {"skipped", "reverted", "passthrough"}
53
+ # Manner front on gerund subject
54
+ assert result.sentences[2].status == "rewritten"
55
+ assert "regularly" in result.sentences[2].rewritten.lower()
56
+ assert result.sentences[2].rewritten.lower().startswith("regularly")