| """Deterministic template families for structural reorder."""
|
|
|
| from __future__ import annotations
|
|
|
| import re
|
| from functools import lru_cache
|
|
|
| from app.engine.models import SentenceSlots, TemplateCandidate
|
| from app.pipeline.nlp import get_nlp
|
|
|
|
|
|
|
|
|
|
|
| _DIRECTIONAL_PLACE = re.compile(
|
| r"^(to|into|onto|towards?|from|out of)\b", re.IGNORECASE
|
| )
|
|
|
|
|
| def _place_fronts_cleanly(slots: SentenceSlots) -> bool:
|
| return bool(slots.place) and not _DIRECTIONAL_PLACE.match(slots.place.strip())
|
|
|
|
|
| def rank_templates(slots: SentenceSlots) -> list[TemplateCandidate]:
|
| """Return confidence-ranked template candidates (never random)."""
|
| if not slots.subject or not slots.verb_phrase:
|
| return []
|
| if slots.confidence < 0.45 or "low_confidence" in slots.reasons:
|
| return []
|
|
|
| out: list[TemplateCandidate] = []
|
|
|
| if slots.time and slots.manner and (slots.place or slots.object):
|
| out.append(TemplateCandidate("time_subj_manner_verb_place", 0.9))
|
| if slots.time and slots.place:
|
| out.append(TemplateCandidate("time_subj_verb_place", 0.8))
|
| if slots.time and slots.object:
|
| out.append(TemplateCandidate("time_subj_verb_object", 0.78))
|
| if slots.time:
|
| out.append(TemplateCandidate("time_subj_verb_object_place", 0.72))
|
| out.append(TemplateCandidate("subj_verb_object_time", 0.68))
|
| out.append(TemplateCandidate("time_front", 0.64))
|
| if slots.place and slots.subject and slots.verb_phrase:
|
| if _place_fronts_cleanly(slots):
|
| out.append(TemplateCandidate("place_front", 0.74))
|
| if slots.manner and slots.place:
|
| out.append(TemplateCandidate("subj_manner_verb_place", 0.7))
|
| if slots.manner and slots.object:
|
| out.append(TemplateCandidate("subj_manner_verb_rest", 0.66))
|
| if slots.manner:
|
| out.append(TemplateCandidate("adv_subj_verb_object", 0.72))
|
| out.append(TemplateCandidate("subj_manner_verb_rest", 0.65))
|
| if slots.place:
|
| out.append(TemplateCandidate("subj_verb_place", 0.6))
|
| if slots.object:
|
| out.append(TemplateCandidate("subj_verb_object", 0.58))
|
|
|
| seen: set[str] = set()
|
| ranked: list[TemplateCandidate] = []
|
| for c in sorted(out, key=lambda x: -x.confidence):
|
| if c.template_id in seen:
|
| continue
|
| seen.add(c.template_id)
|
| ranked.append(c)
|
| return ranked
|
|
|
|
|
| def _cap(text: str) -> str:
|
| t = (text or "").strip()
|
| if not t:
|
| return t
|
| return t[0].upper() + t[1:]
|
|
|
|
|
| def _cont(phrase: str, slots: SentenceSlots) -> str:
|
| """Lowercase a moved subject unless its parsed head is a proper noun."""
|
| p = (phrase or "").strip()
|
| if not p:
|
| return p
|
| first = p.split()[0].strip("\"'")
|
| for ent in slots.entities:
|
| ent_first = ent.split()[0]
|
| if first == ent_first and ent_first[:1].isupper():
|
| return p
|
| if p == slots.subject and not slots.subject_is_proper and first:
|
| return first.lower() + p[len(first) :]
|
| return p
|
|
|
|
|
| def _join_slots(*parts: str) -> str:
|
| bits = [p.strip() for p in parts if p and p.strip()]
|
| s = " ".join(bits)
|
| s = re.sub(r"\s+", " ", s).strip()
|
| s = re.sub(r"\s+([,.;:!?])", r"\1", s)
|
| return s
|
|
|
|
|
| def _terminal(text: str) -> str:
|
| if text.rstrip().endswith(("!", "?")):
|
| return text.rstrip()[-1]
|
| return "."
|
|
|
|
|
| @lru_cache(maxsize=2048)
|
| def _initial_subordinate_parts(text: str) -> tuple[str, str, str, bool] | None:
|
| """Return an initial parsed adverbial clause and its main clause."""
|
| raw = (text or "").strip()
|
| if "," not in raw:
|
| return None
|
| nlp = get_nlp()
|
| if nlp is None:
|
| return None
|
| try:
|
| doc = nlp(raw)
|
| except Exception:
|
| return None
|
| comma = next((token for token in doc if token.text == ","), None)
|
| root = next((token for token in doc if token.dep_ == "ROOT"), None)
|
| if comma is None or root is None or root.i <= comma.i:
|
| return None
|
| clause = next(
|
| (
|
| token
|
| for token in doc
|
| if token.dep_ == "advcl"
|
| and min(part.i for part in token.subtree) == 0
|
| and max(part.i for part in token.subtree) < comma.i
|
| and any(
|
| part.dep_ == "mark" or part.pos_ == "SCONJ"
|
| for part in token.subtree
|
| )
|
| ),
|
| None,
|
| )
|
| if clause is None:
|
| return None
|
|
|
| end = _terminal(raw)
|
| core = raw[:-1].rstrip() if raw.endswith((".", "!", "?")) else raw
|
| subordinate, main = (part.strip() for part in core.split(",", 1))
|
| if len(subordinate.split()) < 3 or len(main.split()) < 3:
|
| return None
|
| return subordinate, main, end, doc[0].dep_ == "mark"
|
|
|
|
|
| def can_swap_initial_subordinate(text: str) -> bool:
|
| return _initial_subordinate_parts(text) is not None
|
|
|
|
|
| def try_complex_clause_swap(text: str) -> str | None:
|
| """Move a parsed initial adverbial clause behind the main clause."""
|
| parts = _initial_subordinate_parts(text)
|
| if parts is None:
|
| return None
|
| subordinate, main, end, needs_comma = parts
|
| continuation = subordinate[0].lower() + subordinate[1:]
|
| separator = ", " if needs_comma else " "
|
| return f"{_cap(main)}{separator}{continuation}{end}"
|
|
|
|
|
|
|
|
|
| _DISCOURSE_OPENERS = frozenset(
|
| {
|
| "however", "moreover", "therefore", "thus", "also", "instead",
|
| "nevertheless", "nonetheless", "meanwhile", "furthermore", "besides",
|
| "otherwise", "consequently", "similarly", "conversely", "finally",
|
| "first", "firstly", "second", "secondly", "third", "thirdly", "next",
|
| "then", "overall", "generally", "typically", "unfortunately",
|
| "fortunately", "importantly", "specifically", "notably",
|
| }
|
| )
|
|
|
|
|
| @lru_cache(maxsize=2048)
|
| def try_unfront_opener(text: str) -> str | None:
|
| """Move a sentence-initial modifier phrase to the end.
|
|
|
| The inverse of the ``*_front`` family. Fronted openers are otherwise a dead
|
| end: the fronting templates regenerate the same sentence, so a text that
|
| already opens with "For the success of any business, ..." has no remaining
|
| structural move.
|
| """
|
| raw = (text or "").strip()
|
| if "," not in raw:
|
| return None
|
| nlp = get_nlp()
|
| if nlp is None:
|
| return None
|
| try:
|
| doc = nlp(raw)
|
| except Exception:
|
| return None
|
|
|
| comma = next((token for token in doc if token.text == ","), None)
|
| root = next((token for token in doc if token.dep_ == "ROOT"), None)
|
| if comma is None or root is None or root.i <= comma.i:
|
| return None
|
|
|
|
|
| if any(token.text == "," for token in doc[comma.i + 1 :]):
|
| return None
|
|
|
| opener_head = next(
|
| (
|
| token
|
| for token in doc
|
| if token.head.i == root.i
|
| and token.dep_ in {"prep", "advmod", "npadvmod", "nmod"}
|
| and min(part.i for part in token.subtree) == 0
|
| and max(part.i for part in token.subtree) == comma.i - 1
|
| ),
|
| None,
|
| )
|
| if opener_head is None:
|
| return None
|
| if doc[0].lower_ in _DISCOURSE_OPENERS:
|
| return None
|
|
|
|
|
|
|
| subject = next(
|
| (
|
| child
|
| for child in root.children
|
| if child.dep_ in {"nsubj", "nsubjpass", "csubj"}
|
| ),
|
| None,
|
| )
|
| if subject is None or subject.i < comma.i:
|
| return None
|
|
|
| end = _terminal(raw)
|
| core = raw[:-1].rstrip() if raw.endswith((".", "!", "?")) else raw
|
| opener, main = (part.strip() for part in core.split(",", 1))
|
| if len(opener.split()) < 2 or len(main.split()) < 4:
|
| return None
|
| continuation = opener[0].lower() + opener[1:] if not doc[0].pos_ == "PROPN" else opener
|
| return f"{_cap(main)} {continuation}{end}"
|
|
|
|
|
| def _manner_before_verb(slots: SentenceSlots) -> bool:
|
| return not slots.verb_starts_with_aux
|
|
|
|
|
| def _object_without_place(slots: SentenceSlots) -> str:
|
| obj = (slots.object or "").strip()
|
| place = (slots.place or "").strip()
|
| if place and obj and place in obj:
|
| obj = obj.replace(place, "").strip(" ,")
|
| obj = re.sub(r"\s+", " ", obj).strip()
|
| return obj
|
|
|
|
|
| def fill_template(template_id: str, slots: SentenceSlots) -> str | None:
|
| """Build sentence from slots using the selected template (reorder only)."""
|
| s = slots
|
| end = _terminal(s.text)
|
| tid = template_id
|
| obj = _object_without_place(s)
|
|
|
| if tid == "place_front":
|
| if not s.place:
|
| return None
|
|
|
| if _manner_before_verb(s):
|
| body = _join_slots(
|
| _cap(s.place) + ",",
|
| _cont(s.subject, s),
|
| s.manner,
|
| s.verb_phrase,
|
| obj,
|
| s.time,
|
| )
|
| else:
|
| body = _join_slots(
|
| _cap(s.place) + ",",
|
| _cont(s.subject, s),
|
| s.verb_phrase,
|
| s.manner,
|
| obj,
|
| s.time,
|
| )
|
| out = body + end
|
| if out.lower().rstrip(".!?") == s.text.lower().rstrip(".!?"):
|
| return None
|
| return out
|
|
|
| if tid == "time_subj_manner_verb_place":
|
| place_or_obj = s.place or obj
|
| if _manner_before_verb(s):
|
| body = _join_slots(
|
| _cap(s.time) + ",", _cont(s.subject, s), s.manner, s.verb_phrase, place_or_obj
|
| )
|
| else:
|
| body = _join_slots(
|
| _cap(s.time) + ",", _cont(s.subject, s), s.verb_phrase, s.manner, place_or_obj
|
| )
|
| return body + end
|
|
|
| if tid == "time_subj_verb_place":
|
| body = _join_slots(_cap(s.time) + ",", _cont(s.subject, s), s.verb_phrase, s.place)
|
| return body + end
|
|
|
| if tid == "time_subj_verb_object":
|
| body = _join_slots(
|
| _cap(s.time) + ",",
|
| _cont(s.subject, s),
|
| s.verb_phrase,
|
| obj,
|
| s.manner,
|
| s.place if s.place not in (obj or "") else "",
|
| )
|
| return body + end
|
|
|
| if tid == "time_subj_verb_object_place":
|
| body = _join_slots(
|
| _cap(s.time) + ",",
|
| _cont(s.subject, s),
|
| s.verb_phrase,
|
| obj,
|
| s.place,
|
| s.manner,
|
| )
|
| return body + end
|
|
|
| if tid == "subj_verb_object_time":
|
| body = _join_slots(_cap(s.subject), s.verb_phrase, obj, s.place, s.manner, s.time)
|
| return body + end
|
|
|
| if tid == "adv_subj_verb_object":
|
| body = _join_slots(
|
| _cap(s.manner) + ",",
|
| _cont(s.subject, s),
|
| s.verb_phrase,
|
| obj,
|
| s.place,
|
| s.time,
|
| )
|
| return body + end
|
|
|
| if tid == "subj_manner_verb_place":
|
| if _manner_before_verb(s):
|
| body = _join_slots(_cap(s.subject), s.manner, s.verb_phrase, s.place)
|
| else:
|
| body = _join_slots(_cap(s.subject), s.verb_phrase, s.manner, s.place)
|
| return body + end
|
|
|
| if tid == "time_front":
|
| if s.time and re.search(
|
| rf"\b(for|over|within|during)\s+{re.escape(s.time)}\b",
|
| s.text,
|
| flags=re.I,
|
| ):
|
| return None
|
| if s.subject and s.verb_phrase:
|
| body = _join_slots(
|
| _cap(s.time) + ",",
|
| _cont(s.subject, s),
|
| s.verb_phrase,
|
| obj,
|
| s.place,
|
| s.manner,
|
| )
|
| return body.rstrip(".!?") + end
|
| rest = s.text
|
| if s.time:
|
| rest = re.sub(rf"\b{re.escape(s.time)}\b", "", rest, count=1, flags=re.I)
|
| rest = re.sub(r"\s+", " ", rest).strip(" ,.")
|
| if not rest:
|
| return None
|
| if re.search(r"\b(for|over|within|during)\s+(every|each|\.|$)", rest, flags=re.I):
|
| return None
|
| body = _join_slots(_cap(s.time) + ",", _cont(rest, s))
|
| return body.rstrip(".!?") + end
|
|
|
| if tid == "subj_manner_verb_rest":
|
| if _manner_before_verb(s):
|
| body = _join_slots(_cap(s.subject), s.manner, s.verb_phrase, obj, s.place, s.time)
|
| else:
|
| body = _join_slots(_cap(s.subject), s.verb_phrase, s.manner, obj, s.place, s.time)
|
| return body + end
|
|
|
| if tid == "subj_verb_place":
|
| body = _join_slots(_cap(s.subject), s.verb_phrase, s.place, s.time, s.manner)
|
| return body + end
|
|
|
| if tid == "subj_verb_object":
|
| body = _join_slots(_cap(s.subject), s.verb_phrase, obj, s.place, s.time, s.manner)
|
| return body + end
|
|
|
| return None
|
|
|
|
|
| def try_because_front(text: str) -> str | None:
|
| """Main … because Sub → Because Sub, main …"""
|
| raw = (text or "").strip()
|
| if not raw:
|
| return None
|
| end = "."
|
| m_end = re.search(r"[.!?]+$", raw)
|
| if m_end:
|
| end = m_end.group(0)
|
| raw = raw[: m_end.start()].strip()
|
| m = re.match(r"^(?P<main>.+?)\s+because\s+(?P<sub>.+)$", raw, flags=re.I)
|
| if not m:
|
| return None
|
| main = m.group("main").strip(" ,")
|
| sub = m.group("sub").strip(" ,")
|
| if len(main.split()) < 3 or len(sub.split()) < 2:
|
| return None
|
| if re.search(r"\bbecause\b", main, flags=re.I) or re.search(
|
| r"\bbecause\b", sub, flags=re.I
|
| ):
|
| return None
|
| body = (
|
| f"Because {sub[0].lower() + sub[1:]}, "
|
| f"{main[0].lower() + main[1:]}"
|
| )
|
| return re.sub(r"\s+", " ", body).strip() + end
|
|
|
|
|
| def _sentence_end(text: str) -> tuple[str, str]:
|
| raw = (text or "").strip()
|
| end = "."
|
| m_end = re.search(r"[.!?]+$", raw)
|
| if m_end:
|
| end = m_end.group(0)
|
| raw = raw[: m_end.start()].strip()
|
| return raw, end
|
|
|
|
|
| def try_for_purpose_front(text: str) -> str | None:
|
| """X is essential for Y → For Y, x is essential."""
|
| raw, end = _sentence_end(text)
|
| match = re.match(
|
| r"^(?P<head>.+?)\s+(?P<be>is|are|was|were)\s+"
|
| r"(?P<adj>essential|important|critical|necessary|vital|crucial)\s+"
|
| r"for\s+(?P<purpose>.+)$",
|
| raw,
|
| flags=re.I,
|
| )
|
| if not match:
|
| return None
|
| head = match.group("head").strip(" ,")
|
| purpose = match.group("purpose").strip(" ,")
|
| if len(head.split()) < 3 or len(purpose.split()) < 2:
|
| return None
|
| if re.search(r"\bfor\b", head, flags=re.I):
|
| return None
|
| lowered = head[0].lower() + head[1:] if head[:1].isupper() else head
|
| body = (
|
| f"For {purpose}, {lowered} {match.group('be')} {match.group('adj')}"
|
| )
|
| return re.sub(r"\s+", " ", body).strip() + end
|
|
|
|
|
| def try_in_pp_front(text: str) -> str | None:
|
| """… play/take … in Y → In Y, …"""
|
| raw, end = _sentence_end(text)
|
| match = re.match(
|
| r"^(?P<head>.+?)\s+"
|
| r"(?P<verb>play|plays|take|takes|have|has|hold|holds)\s+"
|
| r"(?P<object>(?:an?\s+)?(?:important\s+|key\s+|major\s+)?(?:role|roles|part|parts|place))\s+"
|
| r"in\s+(?P<place>.+)$",
|
| raw,
|
| flags=re.I,
|
| )
|
| if not match:
|
| return None
|
| head = match.group("head").strip(" ,")
|
| place = match.group("place").strip(" ,")
|
| if len(head.split()) < 2 or len(place.split()) < 2:
|
| return None
|
| lowered = head[0].lower() + head[1:] if head[:1].isupper() else head
|
| body = (
|
| f"In {place}, {lowered} {match.group('verb')} {match.group('object')}"
|
| )
|
| return re.sub(r"\s+", " ", body).strip() + end
|
|
|
|
|
| def try_copula_np_invert(text: str) -> str | None:
|
| """Subject + is/are a/an/the NP → NP is/are subject."""
|
| raw, end = _sentence_end(text)
|
| match = re.match(
|
| r"^(?P<subj>[A-Z][^,]{1,100}?)\s+"
|
| r"(?P<be>is|are|was|were)\s+"
|
| r"(?P<pred>(?:a|an|the)\s+[^,;:]+)$",
|
| raw,
|
| flags=0,
|
| )
|
| if not match:
|
| return None
|
| subject = match.group("subj").strip(" ,")
|
| predicate = match.group("pred").strip(" ,")
|
| be = match.group("be")
|
| if len(subject.split()) < 1 or len(predicate.split()) < 2:
|
| return None
|
|
|
| if re.search(
|
| r"\b(that|which|who|when|where|because|while|although)\b",
|
| subject,
|
| flags=re.I,
|
| ):
|
| return None
|
| front = predicate[0].upper() + predicate[1:]
|
| lowered = subject[0].lower() + subject[1:] if subject[:1].isupper() else subject
|
| if re.match(r"^(a|an)\b", predicate, flags=re.I) and be in {"are", "were"}:
|
| be = "is" if be == "are" else "was"
|
| if re.match(r"^the\b", predicate, flags=re.I) and be in {"is", "was"}:
|
|
|
| if subject.lower().endswith("s") and not subject.lower().endswith(
|
| ("ss", "ness", "ics")
|
| ):
|
| be = "are" if be == "is" else "were"
|
| body = f"{front} {be} {lowered}"
|
| return re.sub(r"\s+", " ", body).strip() + end
|
|
|
|
|
| def try_when_clause_front(text: str) -> str | None:
|
| """Main … when Sub → When Sub, main …"""
|
| raw, end = _sentence_end(text)
|
| match = re.match(
|
| r"^(?P<main>.+?)\s+when\s+(?P<sub>.+)$",
|
| raw,
|
| flags=re.I,
|
| )
|
| if not match:
|
| return None
|
| main = match.group("main").strip(" ,")
|
| sub = match.group("sub").strip(" ,")
|
| if len(main.split()) < 4 or len(sub.split()) < 3:
|
| return None
|
| if re.search(r"\bwhen\b", main, flags=re.I) or re.search(
|
| r"\bwhen\b", sub, flags=re.I
|
| ):
|
| return None
|
| body = (
|
| f"When {sub[0].lower() + sub[1:]}, "
|
| f"{main[0].lower() + main[1:]}"
|
| )
|
| return re.sub(r"\s+", " ", body).strip() + end
|
|
|
|
|
| def try_by_agent_front(text: str) -> str | None:
|
| """… are/is VERBen by AGENT → By AGENT, … are/is VERBen."""
|
| raw, end = _sentence_end(text)
|
| match = re.match(
|
| r"^(?P<head>.+?)\s+"
|
| r"(?P<be>is|are|was|were)\s+"
|
| r"(?P<verb>\w+ed|\w+en)\s+"
|
| r"by\s+(?P<agent>.+)$",
|
| raw,
|
| flags=re.I,
|
| )
|
| if not match:
|
| return None
|
| head = match.group("head").strip(" ,")
|
| agent = match.group("agent").strip(" ,")
|
| if len(head.split()) < 3 or len(agent.split()) < 2:
|
| return None
|
| if re.search(r"\bby\b", head, flags=re.I):
|
| return None
|
| lowered = head[0].lower() + head[1:] if head[:1].isupper() else head
|
| body = (
|
| f"By {agent}, {lowered} {match.group('be')} {match.group('verb')}"
|
| )
|
| return re.sub(r"\s+", " ", body).strip() + end
|
|
|
|
|
| def try_in_both_front(text: str) -> str | None:
|
| """X is Y in both A and B → In both A and B, x is Y."""
|
| raw, end = _sentence_end(text)
|
| match = re.match(
|
| r"^(?P<head>.+?)\s+"
|
| r"(?P<be>is|are|was|were)\s+"
|
| r"(?P<pred>.+?)\s+"
|
| r"in both\s+(?P<scope>.+)$",
|
| raw,
|
| flags=re.I,
|
| )
|
| if not match:
|
| return None
|
| head = match.group("head").strip(" ,")
|
| pred = match.group("pred").strip(" ,")
|
| scope = match.group("scope").strip(" ,")
|
| if len(head.split()) < 1 or len(pred.split()) < 2 or len(scope.split()) < 3:
|
| return None
|
| lowered = head[0].lower() + head[1:] if head[:1].isupper() else head
|
| body = (
|
| f"In both {scope}, {lowered} {match.group('be')} {pred}"
|
| )
|
| return re.sub(r"\s+", " ", body).strip() + end
|
|
|
|
|
| def try_such_as_front(text: str) -> str | None:
|
| """Label such as EXAMPLES can TAIL → EXAMPLES can TAIL as label."""
|
| raw, end = _sentence_end(text)
|
| match = re.match(
|
| r"^(?P<label>(?:[A-Za-z]+(?:\s+[A-Za-z]+){0,3}))\s+"
|
| r"such as\s+(?P<examples>.+?)\s+"
|
| r"(?P<tail>(?:can|could|may|might|will|would)\s+.+)$",
|
| raw,
|
| flags=re.I,
|
| )
|
| if not match:
|
| return None
|
| label = match.group("label").strip()
|
| examples = match.group("examples").strip(" ,")
|
| tail = match.group("tail").strip(" ,")
|
| if len(examples.split()) < 3 or len(tail.split()) < 3:
|
| return None
|
| front = examples[0].upper() + examples[1:]
|
| body = f"{front} {tail} as {label[0].lower() + label[1:]}"
|
| return re.sub(r"\s+", " ", body).strip() + end
|
|
|
|
|