| """Validate rewrite candidates for meaning, polarity, and fact preservation."""
|
|
|
| from __future__ import annotations
|
|
|
| import re
|
| from dataclasses import dataclass
|
|
|
| from app.pipeline.meaning_safety import polarity_safe
|
|
|
|
|
| _PROPER = re.compile(r"\b([A-Z][a-z]+(?:\s+[A-Z][a-z]+)*)\b")
|
| _NUMBER = re.compile(
|
| r"\b(?:\d{1,3}(?:,\d{3})+(?:\.\d+)?|\d+\.\d+%?|\d+%?|\d{4})\b"
|
| )
|
|
|
| _QUOTED = re.compile(r"[\"“”]([^\"“”]{2,120})[\"“”]")
|
| _YEAR = re.compile(r"\b(?:19|20)\d{2}\b")
|
| _SENT_START = re.compile(r"(?:^|[.!?]\s+)([A-Z])")
|
|
|
|
|
| @dataclass
|
| class ValidationResult:
|
| ok: bool
|
| reasons: list[str]
|
| meaning: float = 1.0
|
| surface_sim: float = 1.0
|
|
|
|
|
| def _surface_sim(a: str, b: str) -> float:
|
| from difflib import SequenceMatcher
|
|
|
| return SequenceMatcher(None, a.lower().strip(), b.lower().strip()).ratio()
|
|
|
|
|
| def _extract_numbers(text: str) -> set[str]:
|
| return {m.group(0) for m in _NUMBER.finditer(text)} | {
|
| m.group(0) for m in _YEAR.finditer(text)
|
| }
|
|
|
|
|
| def _extract_quotes(text: str) -> set[str]:
|
| return {m.group(1).strip().lower() for m in _QUOTED.finditer(text)}
|
|
|
|
|
| def _extract_propers(text: str) -> set[str]:
|
| """Capitalized multi-word / mid-sentence names (skip sentence starts)."""
|
| found: set[str] = set()
|
|
|
| sentence_starts = {m.start(1) for m in _SENT_START.finditer(text)}
|
| for m in _PROPER.finditer(text):
|
| start = m.start()
|
| if start in sentence_starts:
|
| continue
|
| if start == 0:
|
| continue
|
|
|
| i = start - 1
|
| while i >= 0 and text[i].isspace():
|
| i -= 1
|
| if i < 0 or text[i] in ".!?":
|
| continue
|
| found.add(m.group(1))
|
| return found
|
|
|
|
|
| _STOP = frozenset(
|
| """
|
| a an the and or but if in on at to for of as by with from into over after
|
| before about than then so too very more most such that this these those
|
| it its they them their we our you your he she his her is are was were be
|
| been being have has had do does did will would can could should may might
|
| not no nor also just only own same other another each every both few many
|
| much some any all because while when where how what which who whom
|
| """.split()
|
| )
|
|
|
|
|
| def _light_stem(token: str) -> str:
|
| """Shared light stem so topic/coverage checks agree on the same forms."""
|
| t = (token or "").strip("'").lower()
|
| if t.endswith("ing") and len(t) > 5:
|
| return t[:-3]
|
| if t.endswith("ed") and len(t) > 4:
|
| return t[:-2]
|
| if t.endswith("ies") and len(t) > 4:
|
| return t[:-3] + "y"
|
| if t.endswith("es") and len(t) > 4:
|
| return t[:-2]
|
| if t.endswith("s") and len(t) > 4:
|
| return t[:-1]
|
| return t
|
|
|
|
|
| def _content_tokens(text: str) -> set[str]:
|
| """Lowercased content words (length ≥4) used for coverage checks."""
|
| toks = re.findall(r"[a-zA-Z']+", text.lower())
|
| out: set[str] = set()
|
| for t in toks:
|
| t = t.strip("'")
|
| if len(t) < 4 or t in _STOP:
|
| continue
|
| out.add(_light_stem(t))
|
| return out
|
|
|
|
|
| def _sentence_count(text: str) -> int:
|
| parts = re.split(r"[.!?]+", (text or "").strip())
|
| return len([p for p in parts if p.strip()])
|
|
|
|
|
| def _unit_shape_ok(original: str, candidate: str) -> bool:
|
| """Single-sentence units must stay one sentence; never shrink claims away."""
|
| o_sents = _sentence_count(original)
|
| c_sents = _sentence_count(candidate)
|
|
|
| if o_sents == 1 and c_sents != 1:
|
| return False
|
| if o_sents >= 2 and c_sents < o_sents:
|
| return False
|
| ow = max(1, len(original.split()))
|
| cw = len(candidate.split())
|
|
|
| if ow <= 40 and cw < max(4, int(ow * 0.78)):
|
| return False
|
| return True
|
|
|
|
|
| def _length_ok(original: str, candidate: str) -> bool:
|
| ow = max(1, len(original.split()))
|
| cw = len(candidate.split())
|
|
|
| if ow <= 80:
|
| min_words = max(3, int(ow * 0.78))
|
| else:
|
| min_words = max(3, int(ow * 0.55))
|
| if cw < min_words:
|
| return False
|
| if cw > int(ow * 2.0) + 6:
|
| return False
|
| return True
|
|
|
|
|
| def _coverage_ok(original: str, candidate: str) -> bool:
|
| """Reject dropped sentences / severe content loss; allow normal paraphrases."""
|
| o_sents = _sentence_count(original)
|
| c_sents = _sentence_count(candidate)
|
|
|
| if 2 <= o_sents <= 5 and c_sents < o_sents:
|
| return False
|
|
|
| o_toks = _content_tokens(original)
|
| if len(o_toks) >= 5:
|
| c_toks = _content_tokens(candidate)
|
| overlap = len(o_toks & c_toks) / max(1, len(o_toks))
|
|
|
| if overlap < 0.48:
|
| return False
|
| return True
|
|
|
|
|
| def _key_stems(text: str) -> set[str]:
|
| """Claim-anchor stems: all content stems with len≥4 (book, city, save, …)."""
|
| return {t for t in _content_tokens(text) if len(t) >= 4}
|
|
|
|
|
| def _key_content_ok(original: str, candidate: str) -> bool:
|
| """Require most distinctive source stems to survive the rewrite.
|
|
|
| Catches 'Online learning' → 'Learning', 'Reading books' → 'Reading',
|
| and '…in big cities' drops.
|
| """
|
| keys = _key_stems(original)
|
|
|
| if len(original.split()) <= 28 or len(keys) < 3:
|
| keys = _content_tokens(original) or keys
|
| if len(keys) < 2:
|
| return True
|
| c_toks = _content_tokens(candidate)
|
| kept = len(keys & c_toks) / len(keys)
|
| ow = len(original.split())
|
| need = 0.82 if ow <= 28 else 0.72
|
| return kept >= need
|
|
|
|
|
| def _ordered_content_stems(text: str) -> list[str]:
|
| """Content stems in left-to-right order (for topic-head checks)."""
|
| toks = re.findall(r"[a-zA-Z']+", (text or "").lower())
|
| out: list[str] = []
|
| for t in toks:
|
| t = t.strip("'")
|
| if len(t) < 4 or t in _STOP:
|
| continue
|
| out.append(_light_stem(t))
|
| return out
|
|
|
|
|
| _WEAK_TOPIC_HEADS = frozenset(
|
| """
|
| public private many some more most other another every often usual common
|
| local young loud hard free quiet small large great important different
|
| second first major main recent modern social early late daily
|
| without within enough several various certain further additional
|
| unfortunately sadly however therefore moreover furthermore actually
|
| """.split()
|
| )
|
|
|
|
|
| def _topic_anchor_ok(original: str, candidate: str) -> bool:
|
| """Keep the first distinctive topic stem (libraries, electric, tourism, …)."""
|
| ordered = _ordered_content_stems(original)
|
| head = next(
|
| (s for s in ordered if len(s) >= 5 and s not in _WEAK_TOPIC_HEADS),
|
| None,
|
| )
|
| if not head:
|
| return True
|
| c_toks = _content_tokens(candidate)
|
| return head in c_toks
|
|
|
|
|
| def _entity_injection_ok(original: str, candidate: str) -> bool:
|
| """Reject pronoun→new-noun inventions (fund them → fund their schools)."""
|
| o = original or ""
|
| c = candidate or ""
|
| o_toks = _content_tokens(o)
|
| for m in re.finditer(r"\b([A-Za-z]+)\s+(them|it|these|those)\b", o, flags=re.I):
|
| verb = m.group(1)
|
| m2 = re.search(
|
| rf"\b{re.escape(verb)}\s+(?:their|the|a|an|our|its)\s+([A-Za-z]+)\b",
|
| c,
|
| flags=re.I,
|
| )
|
| if not m2:
|
| continue
|
| noun = m2.group(1).lower()
|
| stem = noun
|
| if noun.endswith("ies") and len(noun) > 4:
|
| stem = noun[:-3] + "y"
|
| elif noun.endswith("es") and len(noun) > 4:
|
| stem = noun[:-1]
|
| elif noun.endswith("s") and len(noun) > 3:
|
| stem = noun[:-1]
|
| if stem not in o_toks and noun not in o_toks:
|
| return False
|
| return True
|
|
|
|
|
| def _drift_ok(original: str, candidate: str) -> bool:
|
| """Reject rewrites that mostly introduce new content stems."""
|
| o_toks = _content_tokens(original)
|
| c_toks = _content_tokens(candidate)
|
| if len(c_toks) < 3:
|
| return True
|
| novel = c_toks - o_toks
|
| if len(novel) / len(c_toks) > 0.42:
|
| return False
|
| return True
|
|
|
|
|
| _KNOWN_CONTRACTIONS = frozenset(
|
| {
|
| "don't",
|
| "doesn't",
|
| "didn't",
|
| "can't",
|
| "couldn't",
|
| "won't",
|
| "wouldn't",
|
| "shouldn't",
|
| "mustn't",
|
| "isn't",
|
| "aren't",
|
| "wasn't",
|
| "weren't",
|
| "hasn't",
|
| "haven't",
|
| "hadn't",
|
| "i'm",
|
| "i've",
|
| "i'd",
|
| "i'll",
|
| "you're",
|
| "you've",
|
| "you'd",
|
| "you'll",
|
| "we're",
|
| "we've",
|
| "we'd",
|
| "we'll",
|
| "they're",
|
| "they've",
|
| "they'd",
|
| "they'll",
|
| "it's",
|
| "that's",
|
| "what's",
|
| "who's",
|
| "where's",
|
| "there's",
|
| "here's",
|
| "let's",
|
| }
|
| )
|
|
|
|
|
| def _garbage_tokens(text: str) -> list[str]:
|
| """Flag glued contractions / nonsense tokens (e.g. don'tstet)."""
|
| bad: list[str] = []
|
| for raw in re.findall(r"[A-Za-z']+", text or ""):
|
| tok = raw.lower().strip("'")
|
| if not tok:
|
| continue
|
|
|
| if re.match(
|
| r"(?:don|doesn|didn|won|can|isn|aren|wasn|weren|wouldn|shouldn|"
|
| r"mustn|hasn|haven|hadn)t[a-z]{2,}$",
|
| tok.replace("'", ""),
|
| ):
|
| bad.append(raw)
|
| continue
|
| if "'" in raw.lower() and raw.lower() not in _KNOWN_CONTRACTIONS:
|
|
|
| if len(tok) >= 8:
|
| bad.append(raw)
|
| continue
|
| if re.search(r"(.)\1{3,}", tok):
|
| bad.append(raw)
|
| return bad
|
|
|
|
|
| def _broken_rewrite_ok(original: str, candidate: str) -> bool:
|
| """Reject common FLAN tautologies / repeated n-grams / garbage tokens."""
|
| from collections import Counter
|
|
|
| c_low = (candidate or "").lower()
|
| o_low = (original or "").lower()
|
| for pat in (
|
| r"\bthan it would\b",
|
| r"\bthan they would\b",
|
| r"\bthan he would\b",
|
| r"\bthan she would\b",
|
| ):
|
| if re.search(pat, c_low) and not re.search(pat, o_low):
|
| return False
|
| if _garbage_tokens(candidate):
|
| return False
|
|
|
| if re.search(r"\b([a-z']{3,})\s+or\s+\1\b", c_low):
|
| return False
|
| o_words = {w.lower() for w in re.findall(r"[A-Za-z']+", original or "")}
|
| _ok_caps = {
|
| "USA",
|
| "UK",
|
| "EU",
|
| "UN",
|
| "AI",
|
| "ID",
|
| "PC",
|
| "TV",
|
| "DNA",
|
| "CPU",
|
| "GPU",
|
| "API",
|
| "PDF",
|
| "HTML",
|
| "HTTP",
|
| "HTTPS",
|
| "SQL",
|
| }
|
| for raw in re.findall(r"[A-Za-z']+", candidate or ""):
|
| if (
|
| raw.isupper()
|
| and 2 <= len(raw) <= 4
|
| and raw not in _ok_caps
|
| and raw.lower() not in o_words
|
| ):
|
| return False
|
| words = re.findall(r"[a-z']+", c_low)
|
| if len(words) >= 6:
|
| grams = [" ".join(words[i : i + 3]) for i in range(len(words) - 2)]
|
| for g, n in Counter(grams).items():
|
| if n >= 2 and len(g) >= 10:
|
| return False
|
| return True
|
|
|
|
|
| def _grammar_not_worse(original: str, candidate: str) -> bool:
|
| """Reject if local grammar-issue count rises sharply vs the source unit."""
|
| try:
|
| from app.pipeline.grammar import check_rules
|
| except Exception:
|
| return True
|
| try:
|
| o_n = len(check_rules(original or ""))
|
| c_n = len(check_rules(candidate or ""))
|
| except Exception:
|
| return True
|
|
|
| return c_n <= o_n + 1
|
|
|
|
|
| def _invention_ok(original: str, candidate: str) -> bool:
|
| """Reject candidates that invent content / topics not present in the source."""
|
|
|
| _PAD = frozenset(
|
| {
|
| "unfulfill",
|
| "unfulfilled",
|
| "miserable",
|
| "wonderful",
|
| "amazing",
|
| "fantastic",
|
| "terrible",
|
| "horrible",
|
| "beautiful",
|
| "awful",
|
| "delightful",
|
| "tragic",
|
| "glorious",
|
| "joyous",
|
| "blissful",
|
| "depress",
|
| "exciting",
|
| "thrilling",
|
| "fulfilling",
|
| "unsatisfying",
|
| "satisfying",
|
| "education",
|
| "awareness",
|
| "routine",
|
| "routines",
|
| "curriculum",
|
| "training",
|
| "workshop",
|
| "knowledge",
|
| "additionally",
|
| "furthermore",
|
| "moreover",
|
|
|
| "workout",
|
| "workouts",
|
| "gym",
|
| "fitness",
|
| "regimen",
|
| "regime",
|
| "wellbeing",
|
| "wellness",
|
| "lifestyle",
|
| "nutrition",
|
| "diet",
|
| "diets",
|
| "calorie",
|
| "calories",
|
| "yoga",
|
| "cardio",
|
| }
|
| )
|
| o_toks = _content_tokens(original)
|
| c_toks = _content_tokens(candidate)
|
| if not c_toks:
|
| return True
|
| novel = c_toks - o_toks
|
|
|
| if novel & _PAD:
|
| return False
|
|
|
| if "program" in novel or "programme" in novel:
|
| if novel & {"exercise", "fitness", "gym", "workout", "training"}:
|
| return False
|
|
|
| for m in re.finditer(
|
| r"\b(?:learn(?:ing)?|study(?:ing)?)\s+to\s+([a-zA-Z]+)\b",
|
| (candidate or ""),
|
| flags=re.I,
|
| ):
|
| verb = m.group(1).lower()
|
| stem = verb
|
| if stem.endswith("ing") and len(stem) > 5:
|
| stem = stem[:-3]
|
| elif stem.endswith("ed") and len(stem) > 4:
|
| stem = stem[:-2]
|
| elif stem.endswith("s") and len(stem) > 3:
|
| stem = stem[:-1]
|
| if stem not in o_toks and verb not in o_toks:
|
| return False
|
|
|
| if re.search(r"\bcheck with\b", candidate or "", flags=re.I) and not re.search(
|
| r"\bcheck with\b", original or "", flags=re.I
|
| ):
|
| return False
|
| return True
|
|
|
|
|
| def _meaning_score(original: str, candidate: str) -> float | None:
|
| """MiniLM cosine if available; None if model missing."""
|
| try:
|
| from app.pipeline.minilm import _cosine, _embed_texts, _ensure_model
|
|
|
| if _ensure_model() is None:
|
| return None
|
| vecs = _embed_texts([original, candidate])
|
| if len(vecs) < 2:
|
| return None
|
| return float(_cosine(vecs[0], vecs[1]))
|
| except Exception:
|
| return None
|
|
|
|
|
| def validate_candidate(
|
| original: str,
|
| candidate: str,
|
| *,
|
| min_meaning: float = 0.80,
|
| max_surface: float = 0.92,
|
| min_surface: float = 0.25,
|
| ) -> ValidationResult:
|
| """Return whether a candidate is safe to keep as a rewrite of `original`."""
|
| reasons: list[str] = []
|
| o = (original or "").strip()
|
| c = (candidate or "").strip()
|
| if not o or not c:
|
| return ValidationResult(False, ["empty"], 0.0, 0.0)
|
|
|
| if c == o:
|
| return ValidationResult(False, ["identical"], 1.0, 1.0)
|
|
|
| if not polarity_safe(o, c):
|
| reasons.append("polarity")
|
|
|
| if not _length_ok(o, c):
|
| reasons.append("length")
|
|
|
| if not _unit_shape_ok(o, c):
|
| reasons.append("shape")
|
|
|
| if not _coverage_ok(o, c):
|
| reasons.append("coverage")
|
|
|
| if not _key_content_ok(o, c):
|
| reasons.append("key_content")
|
|
|
| if not _topic_anchor_ok(o, c):
|
| reasons.append("topic_anchor")
|
|
|
| if not _entity_injection_ok(o, c):
|
| reasons.append("entity_inject")
|
|
|
| if not _drift_ok(o, c):
|
| reasons.append("drift")
|
|
|
| if not _broken_rewrite_ok(o, c):
|
| reasons.append("broken")
|
|
|
| if not _grammar_not_worse(o, c):
|
| reasons.append("grammar_worse")
|
|
|
| if not _invention_ok(o, c):
|
| reasons.append("invention")
|
|
|
| surf = _surface_sim(o, c)
|
| if surf >= max_surface:
|
| reasons.append("too_similar")
|
| if surf < min_surface and len(o.split()) >= 8:
|
| reasons.append("too_divergent")
|
|
|
| o_nums, c_nums = _extract_numbers(o), _extract_numbers(c)
|
| if o_nums and not o_nums.issubset(c_nums):
|
|
|
| o_norm = {n.replace(",", "") for n in o_nums}
|
| c_norm = {n.replace(",", "") for n in c_nums}
|
| if not o_norm.issubset(c_norm):
|
| reasons.append("numbers")
|
|
|
| o_quotes = _extract_quotes(o)
|
| c_quotes = _extract_quotes(c)
|
| if o_quotes and not o_quotes.issubset(c_quotes):
|
| reasons.append("quotes")
|
|
|
| for name in _extract_propers(o):
|
| if name not in c and name.lower() not in c.lower():
|
| reasons.append(f"entity:{name}")
|
| break
|
|
|
| meaning = _meaning_score(o, c)
|
| if meaning is None:
|
|
|
| meaning = 1.0 if not reasons else 0.5
|
| elif meaning < min_meaning:
|
| reasons.append(f"meaning:{meaning:.2f}")
|
|
|
| return ValidationResult(
|
| ok=not reasons,
|
| reasons=reasons,
|
| meaning=float(meaning),
|
| surface_sim=surf,
|
| )
|
|
|
|
|
| def filter_valid_candidates(
|
| original: str,
|
| candidates: list[str],
|
| *,
|
| min_meaning: float = 0.80,
|
| max_surface: float = 0.92,
|
| ) -> list[tuple[str, ValidationResult]]:
|
| """Deduplicate and keep only candidates that pass validation."""
|
| seen: set[str] = set()
|
| out: list[tuple[str, ValidationResult]] = []
|
| for raw in candidates:
|
| text = re.sub(r"\s+", " ", (raw or "").strip())
|
| if not text:
|
| continue
|
| key = text.lower()
|
| if key in seen:
|
| continue
|
| seen.add(key)
|
| result = validate_candidate(
|
| original, text, min_meaning=min_meaning, max_surface=max_surface
|
| )
|
| if result.ok:
|
| out.append((text, result))
|
| return out
|
|
|