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