File size: 6,465 Bytes
8f6d79d c212805 8f6d79d 7ea9869 7b67b50 67f284e 8f6d79d 67f284e 8f6d79d c212805 67f284e 8f6d79d 67f284e 4726a76 8f6d79d c212805 67f284e 11c7d9f 8f6d79d 11c7d9f 8f6d79d 7ea9869 7b67b50 8f6d79d | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 | """Sentence rewrite generation from plans/templates."""
from __future__ import annotations
import re
from collections import Counter
from app.engine.models import RewritePlan
from app.engine.templates import (
fill_template,
try_because_front,
try_complex_clause_swap,
)
from app.engine.voice import active_to_passive
from app.pipeline.nlp import get_nlp
def _content_tokens(text: str) -> list[str]:
return [
w
for w in re.findall(r"[a-zA-Z']+", (text or "").lower())
if len(w) >= 3
]
def _attributive_phrases_preserved(source: str, candidate: str) -> bool:
"""Keep adjective/noun compounds attached (for example 'daily tasks')."""
nlp = get_nlp()
if nlp is None:
return True
try:
doc = nlp(source)
except Exception:
return True
cand = (candidate or "").lower()
for token in doc:
if token.dep_ not in {"amod", "compound"}:
continue
if token.head.pos_ not in {"NOUN", "PROPN"}:
continue
if token.pos_ not in {"ADJ", "NOUN", "PROPN"}:
continue
left = f"{token.text} {token.head.text}".lower()
right = f"{token.head.text} {token.text}".lower()
if left not in cand and right not in cand:
return False
return True
def reorder_quality_ok(source: str, candidate: str) -> bool:
"""Reject junk reorders that drop clauses or shuffle degree adverbs."""
src = (source or "").strip()
cand = (candidate or "").strip()
if not src or not cand:
return False
if cand.lower().rstrip(".!?") == src.lower().rstrip(".!?"):
return False
# Preserve short comma-delimited sentence openers (for example discourse
# markers) unless a dedicated clause template handles them.
opener = re.match(r"^\s*([^,]{1,24}),", src)
if opener and len(opener.group(1).split()) <= 2:
expected = opener.group(1).strip().lower() + ","
if not cand.lower().startswith(expected):
return False
# Reject fronting an internal attributive word such as "daily" from
# "planning daily tasks" into "Daily, planning tasks ...".
fronted = re.match(r"^\s*([A-Za-z]+),\s+", cand)
if fronted:
word = fronted.group(1)
if not re.match(rf"^{re.escape(word)}\b", src, flags=re.I):
nlp = get_nlp()
if nlp is not None:
try:
source_doc = nlp(src)
token = next(
(
item
for item in source_doc
if item.text.lower() == word.lower()
),
None,
)
if (
token is not None
and token.dep_ in {"amod", "compound"}
and token.head.pos_ in {"NOUN", "PROPN"}
):
return False
except Exception:
pass
if re.search(
r"\b(more|most|less|least|very|really|quite)\s+"
r"(makes?|make|is|are|was|were|has|have|had|does|do|did)\b",
cand,
flags=re.I,
):
return False
# Reject reorders that split degree+adverb pairs such as "more efficiently".
for match in re.finditer(
r"\b(more|most|less|least)\s+(efficiently|effectively|carefully|"
r"quickly|slowly|easily|clearly|accurately)\b",
src,
flags=re.I,
):
phrase = match.group(0).lower()
if phrase not in cand.lower():
return False
# Duration fronting that strands "for/over": "At least thirty minutes, … for every day"
if re.search(
r"^(at\s+least\s+)?\d*\s*(thirty|forty|fifty|\d+)\s+"
r"(minutes?|hours?|seconds?)\s*,",
cand,
flags=re.I,
):
return False
if re.search(r"\b(for|over|within|during)\s+(every|each)\b", cand, flags=re.I):
if re.search(r"\b(for|over|within|during)\s+\S+\s+(minutes?|hours?)\b", src, flags=re.I):
return False
if re.search(r",\s+(for|over|within|during)\s+\w+\s*$", cand, flags=re.I):
return False
src_toks = _content_tokens(src)
cand_toks = _content_tokens(cand)
if not src_toks:
return False
# Slot templates reorder existing words; additions, omissions, or
# duplicate degree words indicate a bad parse.
if Counter(src_toks) != Counter(cand_toks):
return False
if not _attributive_phrases_preserved(src, cand):
return False
# Soften keep ratio slightly for structural adverb fronts that drop discourse ADVs (also)
keep = len(set(src_toks) & set(cand_toks)) / max(1, len(set(src_toks)))
if keep < 0.80:
return False
if len(cand_toks) < int(len(src_toks) * 0.75) or len(cand_toks) > int(
len(src_toks) * 1.35
):
return False
return True
def generate_from_plan(
plan: RewritePlan,
*,
template_id: str | None = None,
) -> str | None:
"""Generate a rewritten sentence from a plan (slot reorder only)."""
if not plan.safe:
return None
tid = template_id or plan.template_id
raw = plan.slots.text if plan.slots else ""
if tid == "because_front":
return try_because_front(raw)
if tid == "complex_clause_swap":
return try_complex_clause_swap(raw)
if tid == "active_to_passive":
return active_to_passive(raw)
if plan.slots is None:
return None
filled = fill_template(tid, plan.slots)
if not filled:
return None
if filled.strip().lower().rstrip(".!?") == raw.lower().rstrip(".!?"):
return None
if not reorder_quality_ok(raw, filled):
return None
return filled.strip()
def generate_candidates(plan: RewritePlan) -> list[tuple[str, str, float]]:
"""Try each ranked template; return (template_id, text, confidence)."""
results: list[tuple[str, str, float]] = []
if not plan.safe:
return results
for cand in plan.candidates:
text = generate_from_plan(plan, template_id=cand.template_id)
if text:
results.append((cand.template_id, text, cand.confidence))
return results
|