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