| """CPU paraphraser with MiniLM-ranked candidate selection."""
|
|
|
| from __future__ import annotations
|
|
|
| import logging
|
| import re
|
| import threading
|
| from dataclasses import dataclass
|
| from difflib import SequenceMatcher
|
| from functools import lru_cache
|
| from typing import Any
|
|
|
| from app.config import (
|
| ENGINE_PARAPHRASE_MAX_NEW_TOKENS,
|
| ENGINE_PARAPHRASE_MAX_SURFACE,
|
| ENGINE_PARAPHRASE_MIN_DIVERGENCE,
|
| ENGINE_PARAPHRASE_MIN_SIM,
|
| ENGINE_PARAPHRASE_MODEL,
|
| ENGINE_PARAPHRASE_NUM_RETURN,
|
| ENGINE_PARAPHRASE_PRIMARY,
|
| )
|
| from app.pipeline.minilm import pick_best_candidate, score_candidate
|
|
|
| logger = logging.getLogger("plainrewrite.paraphrase")
|
|
|
| _lock = threading.Lock()
|
| _tokenizer = None
|
| _model = None
|
| _failed = False
|
| _WORD = re.compile(r"[A-Za-z']+")
|
|
|
|
|
| @dataclass
|
| class ParaphraseResult:
|
| text: str
|
| confidence: float = 0.0
|
| reason: str = ""
|
| candidates: list[str] | None = None
|
|
|
|
|
| def paraphrase_resource_available() -> bool:
|
| return _get_pipeline() is not None
|
|
|
|
|
| @lru_cache(maxsize=1)
|
| def _torch_device() -> str:
|
| try:
|
| import torch
|
|
|
| return "cpu"
|
| except Exception:
|
| return "cpu"
|
|
|
|
|
| def _get_pipeline() -> tuple[Any, Any] | None:
|
| global _tokenizer, _model, _failed
|
| if _failed:
|
| return None
|
| if _tokenizer is not None and _model is not None:
|
| return _tokenizer, _model
|
| with _lock:
|
| if _failed:
|
| return None
|
| if _tokenizer is not None and _model is not None:
|
| return _tokenizer, _model
|
| try:
|
| import torch
|
| from transformers import AutoModelForSeq2SeqLM, AutoTokenizer
|
|
|
| name = ENGINE_PARAPHRASE_MODEL
|
| _tokenizer = AutoTokenizer.from_pretrained(name)
|
| _model = AutoModelForSeq2SeqLM.from_pretrained(name)
|
| _model.to(_torch_device())
|
| _model.eval()
|
| logger.info("Paraphrase model ready (%s)", name)
|
| return _tokenizer, _model
|
| except Exception as exc:
|
| logger.warning("Paraphrase model unavailable: %s", exc)
|
| _failed = True
|
| _tokenizer = None
|
| _model = None
|
| return None
|
|
|
|
|
| def warm_paraphrase() -> bool:
|
| try:
|
| pipe = _get_pipeline()
|
| if pipe is None:
|
| return False
|
| paraphrase_sentence("Warm up the rewrite model.", num_return=1)
|
| return True
|
| except Exception as exc:
|
| logger.warning("Paraphrase warm failed: %s", exc)
|
| return False
|
|
|
|
|
| def _normalize_candidate(text: str) -> str:
|
| value = re.sub(r"\s+", " ", (text or "").strip())
|
| value = value.strip(" \"'")
|
| if not value:
|
| return ""
|
| if value[-1] not in ".!?":
|
| value += "."
|
| return value[:1].upper() + value[1:]
|
|
|
|
|
| def _content_overlap(source: str, candidate: str) -> float:
|
| src = {w.lower() for w in _WORD.findall(source) if len(w) >= 3}
|
| cand = {w.lower() for w in _WORD.findall(candidate) if len(w) >= 3}
|
| if not src:
|
| return 0.0
|
| return len(src & cand) / max(1, len(src))
|
|
|
|
|
| def surface_similarity(source: str, candidate: str) -> float:
|
| left = re.sub(r"\s+", " ", (source or "").strip().lower()).rstrip(".!?")
|
| right = re.sub(r"\s+", " ", (candidate or "").strip().lower()).rstrip(".!?")
|
| if not left or not right:
|
| return 0.0
|
|
|
|
|
|
|
| return SequenceMatcher(None, left, right, autojunk=False).ratio()
|
|
|
|
|
| def sufficiently_changed(
|
| source: str,
|
| candidate: str,
|
| *,
|
| max_surface: float | None = None,
|
| min_divergence: float | None = None,
|
| ) -> bool:
|
| """Require a real wording or order change, not punctuation/near-copy edits."""
|
| src = (source or "").strip()
|
| cand = (candidate or "").strip()
|
| if not src or not cand:
|
| return False
|
| limit = (
|
| max_surface
|
| if max_surface is not None
|
| else ENGINE_PARAPHRASE_MAX_SURFACE
|
| )
|
| src_tokens = [w.lower() for w in _WORD.findall(src)]
|
| cand_tokens = [w.lower() for w in _WORD.findall(cand)]
|
| if not src_tokens or src_tokens == cand_tokens:
|
| return False
|
| similarity = surface_similarity(src, cand)
|
| divergence = 1.0 - similarity
|
| needed = (
|
| min_divergence
|
| if min_divergence is not None
|
| else 0.0
|
| )
|
| if needed > 0 and divergence < needed:
|
| return False
|
| if similarity >= limit:
|
|
|
|
|
| src_set = {w for w in src_tokens if len(w) >= 3}
|
| cand_set = {w for w in cand_tokens if len(w) >= 3}
|
| if len(src_set ^ cand_set) < 2:
|
| return False
|
| return True
|
|
|
|
|
| def _generate_raw(
|
| text: str,
|
| *,
|
| num_return: int,
|
| max_surface: float,
|
| min_overlap: float = 0.32,
|
| prompts: list[str] | None = None,
|
| max_new_tokens: int | None = None,
|
| ) -> list[str]:
|
| loaded = _get_pipeline()
|
| if loaded is None:
|
| return []
|
| tokenizer, model = loaded
|
| import torch
|
|
|
| prompt_list = prompts or [
|
| f"paraphrase: {text.strip()}",
|
| f"rewrite with different wording but same meaning: {text.strip()}",
|
| ]
|
| returns = max(1, num_return)
|
| per_prompt = max(1, returns // len(prompt_list))
|
| if per_prompt * len(prompt_list) < returns:
|
| per_prompt += 1
|
| token_budget = (
|
| max_new_tokens
|
| if max_new_tokens is not None
|
| else ENGINE_PARAPHRASE_MAX_NEW_TOKENS
|
| )
|
| decoded: list[str] = []
|
| for prompt in prompt_list:
|
| encoded = tokenizer(
|
| prompt,
|
| return_tensors="pt",
|
| truncation=True,
|
| max_length=256,
|
| )
|
| prompt_returns = per_prompt
|
| groups = min(prompt_returns, 4)
|
| beams = max(6, prompt_returns * 2)
|
| beams = max(groups, (beams // groups) * groups)
|
| with torch.no_grad():
|
| try:
|
| outputs = model.generate(
|
| **encoded,
|
| max_new_tokens=token_budget,
|
| num_beams=beams,
|
| num_beam_groups=groups,
|
| diversity_penalty=1.0,
|
| num_return_sequences=prompt_returns,
|
| do_sample=False,
|
| early_stopping=True,
|
| )
|
| except Exception:
|
|
|
| outputs = model.generate(
|
| **encoded,
|
| max_new_tokens=token_budget,
|
| num_beams=max(4, prompt_returns),
|
| num_return_sequences=prompt_returns,
|
| do_sample=False,
|
| early_stopping=True,
|
| )
|
| decoded.extend(tokenizer.batch_decode(outputs, skip_special_tokens=True))
|
| cleaned: list[str] = []
|
| seen: set[str] = set()
|
| source_key = text.strip().lower().rstrip(".!?")
|
| for item in decoded:
|
| value = _normalize_candidate(item)
|
| key = value.lower().rstrip(".!?")
|
| if not value or key == source_key or key in seen:
|
| continue
|
| if _content_overlap(text, value) < min_overlap:
|
| continue
|
| if not sufficiently_changed(text, value, max_surface=max_surface):
|
| continue
|
| seen.add(key)
|
| cleaned.append(value)
|
| return cleaned
|
|
|
|
|
| def paraphrase_sentence(
|
| text: str,
|
| *,
|
| min_sim: float | None = None,
|
| num_return: int | None = None,
|
| prefer_divergent: bool | None = None,
|
| max_surface: float | None = None,
|
| min_divergence: float | None = None,
|
| ) -> ParaphraseResult:
|
| """Generate a meaning-preserving paraphrase when the model is available.
|
|
|
| When prefer_divergent is on (primary rewrite mode), choose the most
|
| surface-different candidate that still clears the MiniLM meaning floor.
|
| """
|
| source = (text or "").strip()
|
| if not source:
|
| return ParaphraseResult(text=text, reason="empty")
|
| threshold = (
|
| min_sim if min_sim is not None else ENGINE_PARAPHRASE_MIN_SIM
|
| )
|
| divergent = (
|
| ENGINE_PARAPHRASE_PRIMARY
|
| if prefer_divergent is None
|
| else prefer_divergent
|
| )
|
| surface_limit = (
|
| max_surface
|
| if max_surface is not None
|
| else ENGINE_PARAPHRASE_MAX_SURFACE
|
| )
|
| divergence_floor = (
|
| min_divergence
|
| if min_divergence is not None
|
| else (ENGINE_PARAPHRASE_MIN_DIVERGENCE if divergent else 0.0)
|
| )
|
| returns = (
|
| num_return
|
| if num_return is not None
|
| else max(ENGINE_PARAPHRASE_NUM_RETURN, 5 if divergent else 3)
|
| )
|
| candidates = _generate_raw(
|
| source,
|
| num_return=returns,
|
| max_surface=surface_limit,
|
| min_overlap=0.30 if divergent else 0.35,
|
| )
|
| if not candidates:
|
| return ParaphraseResult(text=source, reason="no_candidates")
|
|
|
| surface_scores = {
|
| candidate: surface_similarity(source, candidate)
|
| for candidate in candidates
|
| }
|
|
|
| best = pick_best_candidate(
|
| source,
|
| candidates,
|
| min_meaning=threshold,
|
| prefer_divergent=divergent,
|
| surface_scores=surface_scores,
|
| )
|
| if best is None:
|
| scored = []
|
| for candidate in candidates:
|
| meaning = score_candidate(source, candidate)
|
| if meaning is None or meaning <= 0.0:
|
|
|
| meaning = _content_overlap(source, candidate)
|
| if meaning >= threshold * 0.85 and sufficiently_changed(
|
| source,
|
| candidate,
|
| max_surface=surface_limit,
|
| min_divergence=divergence_floor if divergent else 0.0,
|
| ):
|
| scored.append(
|
| (
|
| surface_scores.get(candidate, 1.0) if divergent else -meaning,
|
| -meaning if divergent else surface_scores.get(candidate, 1.0),
|
| meaning,
|
| candidate,
|
| )
|
| )
|
| if not scored:
|
| return ParaphraseResult(
|
| text=source,
|
| reason="below_similarity",
|
| candidates=candidates,
|
| )
|
| scored.sort()
|
| best = scored[0][3]
|
| confidence = scored[0][2]
|
| else:
|
| if not sufficiently_changed(
|
| source,
|
| best,
|
| max_surface=surface_limit,
|
| min_divergence=divergence_floor if divergent else 0.0,
|
| ):
|
|
|
| alternates = [
|
| candidate
|
| for candidate in candidates
|
| if candidate != best
|
| and sufficiently_changed(
|
| source,
|
| candidate,
|
| max_surface=surface_limit,
|
| min_divergence=divergence_floor if divergent else 0.0,
|
| )
|
| ]
|
| if not alternates:
|
| return ParaphraseResult(
|
| text=source,
|
| reason="near_copy",
|
| candidates=candidates,
|
| )
|
| reranked = pick_best_candidate(
|
| source,
|
| alternates,
|
| min_meaning=threshold,
|
| prefer_divergent=divergent,
|
| surface_scores=surface_scores,
|
| )
|
| best = reranked or alternates[0]
|
| scored_best = score_candidate(source, best)
|
| confidence = (
|
| scored_best
|
| if scored_best is not None
|
| else _content_overlap(source, best)
|
| )
|
|
|
| if not sufficiently_changed(
|
| source,
|
| best,
|
| max_surface=surface_limit,
|
| min_divergence=divergence_floor if divergent else 0.0,
|
| ):
|
| return ParaphraseResult(
|
| text=source,
|
| reason="near_copy",
|
| candidates=candidates,
|
| )
|
|
|
| return ParaphraseResult(
|
| text=best,
|
| confidence=round(float(confidence), 4),
|
| candidates=candidates,
|
| )
|
|
|
|
|
| def _normalize_span_candidate(text: str) -> str:
|
| value = re.sub(r"\s+", " ", (text or "").strip())
|
| value = value.strip(" \"'")
|
|
|
| return value.rstrip(".!?")
|
|
|
|
|
| _PROMPT_LEAK = re.compile(
|
| r"\b(?:paraphrase|rewrite|different wording|same meaning|rephrase)\b",
|
| re.I,
|
| )
|
|
|
|
|
| def _span_candidate_ok(source: str, candidate: str) -> bool:
|
| """Reject prompt echoes and structurally invalid mid-sentence spans."""
|
| src = (source or "").strip()
|
| cand = (candidate or "").strip()
|
| if not src or not cand:
|
| return False
|
| low = cand.lower()
|
| if _PROMPT_LEAK.search(low):
|
| return False
|
| if ":" in cand or ";" in cand:
|
| return False
|
| src_tokens = [token.lower() for token in _WORD.findall(src)]
|
| cand_tokens = [token.lower() for token in _WORD.findall(cand)]
|
| if not src_tokens or not cand_tokens:
|
| return False
|
|
|
| if len(cand_tokens) > len(src_tokens) + 1 or len(cand_tokens) + 2 < len(src_tokens):
|
| return False
|
| if len(cand) > max(12, int(len(src) * 1.45)):
|
| return False
|
|
|
|
|
| if src[0].islower() and cand[0].isupper():
|
| return False
|
| src_tail = set(src_tokens[1:])
|
| if cand_tokens[0] in src_tail:
|
| return False
|
|
|
| from collections import Counter
|
|
|
| src_counts = Counter(src_tokens)
|
| cand_counts = Counter(cand_tokens)
|
| for token, count in cand_counts.items():
|
| if len(token) < 4:
|
| continue
|
| if count > max(1, src_counts.get(token, 0)):
|
| return False
|
|
|
| if re.search(r"\bto\s+[a-z]{3,}$", low) and " to " not in src.lower():
|
| return False
|
| return True
|
|
|
|
|
| def paraphrase_span(
|
| text: str,
|
| *,
|
| num_return: int | None = None,
|
| min_sim: float | None = None,
|
| ) -> ParaphraseResult:
|
| """Paraphrase a short verb–object span for phrase-level rewriting."""
|
| source = (text or "").strip()
|
| if not source:
|
| return ParaphraseResult(text=text, reason="empty")
|
| if _get_pipeline() is None:
|
| return ParaphraseResult(text=source, reason="unavailable")
|
|
|
| returns = num_return if num_return is not None else 4
|
| threshold = min_sim if min_sim is not None else max(0.70, ENGINE_PARAPHRASE_MIN_SIM - 0.02)
|
|
|
|
|
| raw = _generate_raw(
|
| source,
|
| num_return=returns,
|
| max_surface=0.92,
|
| min_overlap=0.40,
|
| prompts=[f"paraphrase: {source}"],
|
| max_new_tokens=min(24, ENGINE_PARAPHRASE_MAX_NEW_TOKENS),
|
| )
|
| cleaned: list[str] = []
|
| seen: set[str] = set()
|
| source_key = source.lower().rstrip(".!?")
|
| source_lead_lower = bool(source[:1].islower())
|
| for item in raw:
|
| value = _normalize_span_candidate(item)
|
| if source_lead_lower and value[:1].isupper():
|
| value = value[:1].lower() + value[1:]
|
| key = value.lower()
|
| if not value or key == source_key or key in seen:
|
| continue
|
| if not _span_candidate_ok(source, value):
|
| continue
|
| if _content_overlap(source, value) < 0.40:
|
| continue
|
| if not sufficiently_changed(source, value, max_surface=0.94, min_divergence=0.06):
|
| continue
|
| seen.add(key)
|
| cleaned.append(value)
|
| if not cleaned:
|
| return ParaphraseResult(text=source, reason="no_candidates", candidates=[])
|
|
|
| surface_scores = {
|
| candidate: surface_similarity(source, candidate) for candidate in cleaned
|
| }
|
| best = pick_best_candidate(
|
| source,
|
| cleaned,
|
| min_meaning=threshold,
|
| prefer_divergent=True,
|
| surface_scores=surface_scores,
|
| )
|
| if best is None:
|
| ranked = sorted(
|
| cleaned,
|
| key=lambda c: (surface_scores.get(c, 1.0), -_content_overlap(source, c)),
|
| )
|
| best = ranked[0]
|
| if not _span_candidate_ok(source, best):
|
| return ParaphraseResult(text=source, reason="no_candidates", candidates=cleaned)
|
| meaning = score_candidate(source, best)
|
| confidence = meaning if meaning is not None else _content_overlap(source, best)
|
| return ParaphraseResult(
|
| text=best,
|
| confidence=round(float(confidence), 4),
|
| candidates=cleaned,
|
| reason="span_paraphrase",
|
| )
|
|
|