Upload 146 files
Browse files- app/engine/classify/__init__.py +47 -21
- app/engine/classify/__pycache__/__init__.cpython-311.pyc +0 -0
- app/engine/consistency/__init__.py +29 -46
- app/engine/consistency/__pycache__/__init__.cpython-311.pyc +0 -0
- app/engine/grammar/__init__.py +21 -14
- app/engine/grammar/__pycache__/__init__.cpython-311.pyc +0 -0
- app/engine/parse/__init__.py +220 -173
- app/engine/parse/__pycache__/__init__.cpython-311.pyc +0 -0
- app/engine/rewrite/__init__.py +2 -1
- app/engine/rewrite/__pycache__/__init__.cpython-311.pyc +0 -0
- app/engine/templates/__init__.py +128 -66
- app/engine/templates/__pycache__/__init__.cpython-311.pyc +0 -0
- tests/__pycache__/test_esl_habit_regression.cpython-311-pytest-9.1.1.pyc +0 -0
- tests/test_esl_habit_regression.py +18 -1
app/engine/classify/__init__.py
CHANGED
|
@@ -1,15 +1,10 @@
|
|
| 1 |
-
"""Sentence type classification —
|
| 2 |
|
| 3 |
from __future__ import annotations
|
| 4 |
|
| 5 |
import re
|
| 6 |
|
| 7 |
-
|
| 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 |
-
|
| 38 |
-
if
|
| 39 |
-
return
|
|
|
|
|
|
|
|
|
|
| 40 |
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 46 |
return "complex"
|
| 47 |
-
if
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 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 |
-
|
| 25 |
-
|
| 26 |
-
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
|
| 30 |
-
|
| 31 |
-
|
| 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
|
| 39 |
canon: dict[str, str] = {}
|
| 40 |
for rec in records:
|
| 41 |
-
for name in
|
| 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 |
-
|
| 79 |
-
|
| 80 |
-
|
| 81 |
-
|
| 82 |
-
|
| 83 |
-
|
| 84 |
-
|
| 85 |
-
|
| 86 |
-
|
| 87 |
-
|
|
|
|
|
|
|
| 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 |
-
|
| 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
|
| 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
|
|
|
|
| 23 |
|
| 24 |
def repl(m: re.Match[str]) -> str:
|
| 25 |
word = m.group(1)
|
| 26 |
-
if
|
| 27 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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-
|
| 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
|
| 47 |
-
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 51 |
|
| 52 |
|
| 53 |
-
def
|
| 54 |
-
"""
|
| 55 |
-
|
| 56 |
-
if
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
|
|
|
|
| 60 |
return True
|
| 61 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 62 |
return True
|
| 63 |
-
|
|
|
|
| 64 |
return True
|
|
|
|
|
|
|
|
|
|
|
|
|
| 65 |
return False
|
| 66 |
|
| 67 |
|
| 68 |
def _dedupe_spans(spans: list[str]) -> list[str]:
|
| 69 |
-
|
| 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
|
| 89 |
-
"""
|
| 90 |
-
|
| 91 |
-
|
| 92 |
-
|
| 93 |
-
|
| 94 |
-
|
| 95 |
-
|
| 96 |
-
|
| 97 |
-
|
| 98 |
-
|
| 99 |
-
|
| 100 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
| 133 |
for child in root.children:
|
| 134 |
-
if child.dep_ in {"nsubj", "nsubjpass"}:
|
| 135 |
-
|
| 136 |
break
|
| 137 |
-
slots.subject = _span_text(subj_toks)
|
| 138 |
|
| 139 |
-
|
|
|
|
| 140 |
for t in doc:
|
| 141 |
-
if
|
| 142 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 166 |
-
if
|
| 167 |
-
|
| 168 |
-
|
| 169 |
-
|
| 170 |
-
|
| 171 |
-
|
| 172 |
-
|
| 173 |
-
|
| 174 |
-
|
| 175 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 184 |
continue
|
| 185 |
-
|
| 186 |
-
|
| 187 |
-
|
| 188 |
-
|
| 189 |
-
|
| 190 |
-
|
| 191 |
-
|
| 192 |
-
|
| 193 |
-
|
| 194 |
-
break
|
| 195 |
-
if under_for:
|
| 196 |
continue
|
| 197 |
time_parts.append(ent.text)
|
| 198 |
|
| 199 |
for t in doc:
|
| 200 |
-
|
| 201 |
-
|
| 202 |
-
|
| 203 |
-
|
| 204 |
-
|
| 205 |
-
|
| 206 |
-
|
| 207 |
-
|
| 208 |
-
|
| 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
|
|
|
|
|
|
|
|
|
|
|
|
|
| 229 |
]
|
| 230 |
-
# Prefer a single best time span
|
| 231 |
slots.time = time_parts[0] if time_parts else ""
|
| 232 |
-
|
| 233 |
-
|
| 234 |
-
|
| 235 |
-
|
| 236 |
-
|
| 237 |
-
|
| 238 |
-
|
| 239 |
-
|
| 240 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
"""
|
| 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 |
-
#
|
| 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 |
-
|
| 277 |
-
|
| 278 |
-
|
| 279 |
-
|
| 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>(?
|
| 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 |
-
|
| 300 |
-
|
| 301 |
-
|
| 302 |
-
|
| 303 |
-
|
| 304 |
-
|
| 305 |
-
|
| 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.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 313 |
|
| 314 |
slots.time = time
|
| 315 |
slots.manner = manner
|
| 316 |
-
if
|
| 317 |
-
|
| 318 |
-
|
| 319 |
-
score = 0.
|
| 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.
|
| 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.
|
| 36 |
-
out.append(TemplateCandidate("subj_manner_verb_rest", 0.
|
| 37 |
if slots.place:
|
| 38 |
-
out.append(TemplateCandidate("subj_verb_place", 0.
|
| 39 |
if slots.object:
|
| 40 |
-
out.append(TemplateCandidate("subj_verb_object", 0.
|
| 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
|
| 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[
|
| 83 |
-
|
| 84 |
-
|
| 85 |
-
|
| 86 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 (
|
| 111 |
-
parts = (verb_phrase or "").
|
| 112 |
if not parts:
|
| 113 |
return True
|
| 114 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 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 |
-
|
| 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 |
-
|
| 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,
|
| 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 |
-
|
| 171 |
s.place,
|
| 172 |
s.time,
|
| 173 |
)
|
| 174 |
return body + end
|
| 175 |
|
| 176 |
if tid == "subj_manner_verb_place":
|
| 177 |
-
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 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 |
-
|
| 219 |
-
|
|
|
|
|
|
|
| 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,
|
| 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
|
| 263 |
raw = (text or "").strip()
|
| 264 |
-
if not raw
|
| 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")
|