Test_AI_writing / humanizer.py
unknown
Claude Opus 5.5
Smart engine: parallel setting groups and step overlap off by default (fast tokenizers are not thread-safe: 'Already borrowed')
5f3564c
Raw History Blame Contribute Delete
91.5 kB
"""Humanizer engine (Parrot T5 paraphraser, optional detector-guided "adversarial" decoding) + stylometric indicators + DOCX helpers."""
import base64
import contextvars
import hashlib
import hmac
import secrets
import sqlite3
import os
import re
import statistics
import tempfile
import threading
import time
from pathlib import Path
import gradio as gr
import torch
# HF installs the `spaces` package on EVERY Space; only SPACES_ZERO_GPU=true means ZeroGPU hardware.
ZERO = os.getenv("SPACES_ZERO_GPU", "").strip().lower() in ("1", "true", "yes")
try:
import spaces
except Exception: # pragma: no cover
spaces = None
if ZERO and spaces is not None:
_GPU = spaces.GPU
else:
def _GPU(duration=None):
return lambda fn: fn
from docx import Document
from transformers import AutoModelForSeq2SeqLM, AutoTokenizer
import runtime as RT
import adversarial as ADV
import adaptive as AD
import textparse as TP
import groqai as G
import meaning as MEANING
import privacy as PV
import fluency as FL
import lt_grammar as LT
import clarity as CL
import lexsub as LX
import profile_settings as PS
# ---------------------------------------------------------------------------
# Brand (colours sampled from the CS Software Solutions logo)
# ---------------------------------------------------------------------------
BRAND = {
"navy": "#07228B",
"purple": "#47148B",
"magenta": "#E92E7F",
"red": "#E12B41",
"orange": "#FE5828",
"yellow": "#F5C518",
"green": "#50BF49",
"sky": "#50B4EF",
"grey": "#4A4A4A",
}
RAINBOW = (
f"linear-gradient(90deg, {BRAND['purple']}, {BRAND['navy']}, {BRAND['sky']}, "
f"{BRAND['green']}, {BRAND['yellow']}, {BRAND['orange']}, {BRAND['red']}, {BRAND['magenta']})"
)
APP_NAME = "CS Academic Paraphraser"
SITE_URL = "https://cssoftwaresolutions.in"
LOGO_PATH = Path(__file__).parent / "logo.png"
LOGO_SRC = ""
FAVICON_PATH = Path(__file__).parent / "favicon.png"
FAVICON_SRC = ""
if FAVICON_PATH.exists():
FAVICON_SRC = "data:image/png;base64," + base64.b64encode(FAVICON_PATH.read_bytes()).decode()
if LOGO_PATH.exists():
LOGO_SRC = "data:image/png;base64," + base64.b64encode(LOGO_PATH.read_bytes()).decode()
# ---------------------------------------------------------------------------
# Model (kept in CPU RAM while idle; moved to GPU only inside @spaces.GPU)
# ---------------------------------------------------------------------------
# Paraphrase model: Parrot (prithivida/parrot_paraphraser_on_T5) by default. PARA_MODEL (or the older PARROT_MODEL)
# can name another T5 paraphraser with the same "paraphrase: " input, e.g. humarin/chatgpt_paraphraser_on_T5_base.
# If the chosen model cannot be loaded, Parrot is used so the Space still starts.
FALLBACK_MODEL = "prithivida/parrot_paraphraser_on_T5"
MODEL_ID = (os.getenv("PARA_MODEL") or os.getenv("PARROT_MODEL") or FALLBACK_MODEL).strip()
tokenizer = model = None
# mild penalty on repeating input tokens: pushes the model away from copying the sentence back (1.0 = off);
# set again in load_model() if the chosen model could not be loaded and Parrot is used instead
REP_PENALTY = float(os.getenv("PARA_REP_PENALTY", "1.0" if "parrot" in MODEL_ID.lower() else "1.15"))
# ZeroGPU / dedicated GPU: keep the model on "cuda" permanently (ZeroGPU restores it for each
# @spaces.GPU call), so several users can paraphrase at the same time — no global lock.
ON_GPU = ZERO or torch.cuda.is_available()
_cpu_model = None # CPU copy (int8 on CPU hardware)
_cpu_lock = threading.Lock()
_load_lock = threading.Lock()
def load_model():
"""Load the paraphraser once (at startup; SKIP_MODEL_LOAD=1 delays it to the first use, for tests and tools)."""
global tokenizer, model, _cpu_model, MODEL_ID, REP_PENALTY
with _load_lock:
if model is None:
try:
tok = AutoTokenizer.from_pretrained(MODEL_ID)
m = AutoModelForSeq2SeqLM.from_pretrained(MODEL_ID).eval()
except Exception as _err:
if MODEL_ID == FALLBACK_MODEL:
raise
print(f"[humanizer] {MODEL_ID} could not be loaded ({_err}); using {FALLBACK_MODEL}", flush=True)
MODEL_ID = FALLBACK_MODEL
REP_PENALTY = float(os.getenv("PARA_REP_PENALTY", "1.0"))
tok = AutoTokenizer.from_pretrained(MODEL_ID)
m = AutoModelForSeq2SeqLM.from_pretrained(MODEL_ID).eval()
print(f"[humanizer] paraphrase model: {MODEL_ID}", flush=True)
if ON_GPU:
m.to("cuda")
else:
# CPU hardware only ever runs the int8 copy: keep that one and let the full-precision weights go
# (about 0.9 GB of RAM that was held for nothing)
m = _cpu_model = RT.quantize(m)
tokenizer, model = tok, m
return model
if os.getenv("SKIP_MODEL_LOAD") != "1":
load_model()
def _get_cpu_model():
global _cpu_model
load_model()
with _cpu_lock:
if _cpu_model is None:
_cpu_model = RT.quantize(AutoModelForSeq2SeqLM.from_pretrained(MODEL_ID).eval())
return _cpu_model
GPU_CHUNK = 48 # sentences per ZeroGPU allocation (short reservations)
GPU_BATCH = int(os.getenv("HUM_GPU_BATCH", "32")) # sentences per generate() call on GPU (x versions each)
CPU_CHUNK = 16 # sentences per CPU step (keeps progress updating)
OUT_DIR = Path(tempfile.gettempdir()) / "cs_outputs"
OUT_DIR.mkdir(parents=True, exist_ok=True)
MODES = ["Fast (recommended)", "Standard"]
MAX_PASTE_WORDS = int(os.getenv("MAX_PASTE_WORDS", "300")) # max words per paste (all tools)
# ---------------------------------------------------------------------------
# Text helpers
# ---------------------------------------------------------------------------
def split_sentences(text):
"""Sentences of `text`; never split after 'et al.', 'e.g.', 'Fig.' or inside a decimal number."""
return TP.split_sentences(text)
def should_process(text, min_words):
text = (text or "").strip()
if len(text.split()) < int(min_words):
return False
if re.fullmatch(r"https?://\S+|www\.\S+|\S+@\S+\.\S+", text):
return False
alpha = sum(ch.isalpha() for ch in text)
if alpha < max(5, int(len(text) * 0.35)):
return False
return True
def words(text):
return re.findall(r"[A-Za-z0-9']+", (text or "").lower())
# ---------------------------------------------------------------------------
# Tentative indicators (local heuristics — NOT Turnitin / iThenticate / GPTZero)
# ---------------------------------------------------------------------------
def _ngrams(tokens, n):
return {tuple(tokens[i:i + n]) for i in range(len(tokens) - n + 1)}
def similarity_to_source(original, revised):
"""Share of the revised text's word n-grams that also occur in the original."""
a, b = words(original), words(revised)
if len(b) < 4 or len(a) < 4:
return 0.0
scores, weights = [], []
for n, w in ((2, 0.2), (3, 0.35), (4, 0.45)):
gb = _ngrams(b, n)
if not gb:
continue
scores.append(len(gb & _ngrams(a, n)) / len(gb))
weights.append(w)
if not weights:
return 0.0
return 100.0 * sum(s * w for s, w in zip(scores, weights)) / sum(weights)
AI_MARKERS = [
"delve", "delves", "delving", "furthermore", "moreover", "additionally",
"in conclusion", "it is important to note", "it is worth noting", "plays a crucial role",
"plays a pivotal role", "pivotal", "crucial", "comprehensive", "leverage", "leveraging",
"seamless", "seamlessly", "robust", "landscape", "underscore", "underscores",
"notably", "in today's", "fostering", "foster", "multifaceted", "intricate",
"realm", "tapestry", "navigate", "navigating", "holistic", "paramount",
"a testament to", "shed light", "sheds light", "ever-evolving", "cutting-edge",
"in the realm of", "overall,", "ultimately", "enhance", "enhancing", "streamline",
"valuable insights", "key role", "vital role", "significantly",
]
def ai_likelihood(text):
"""Stylometric estimate (0–100) of machine-like writing."""
toks = words(text)
if len(toks) < 30:
return None
sents = split_sentences(text) or [text]
lengths = [len(words(s)) for s in sents if words(s)]
# 1. Burstiness: humans vary sentence length more than LLMs.
if len(lengths) >= 3:
cv = statistics.pstdev(lengths) / max(1.0, statistics.mean(lengths))
burst = min(1.0, max(0.0, (0.60 - cv) / 0.45))
else:
burst = 0.5
# 2. Density of stock "LLM" vocabulary per 100 words.
low = " " + " ".join(toks) + " "
raw = (text or "").lower()
hits = sum(raw.count(m) if " " in m or "," in m else low.count(f" {m} ") for m in AI_MARKERS)
marker = min(1.0, (hits / len(toks) * 100) / 2.5)
# 3. Uniformity: mid-length sentences, repeated openers, low lexical variety.
mean_len = statistics.mean(lengths)
band = 1.0 if 16 <= mean_len <= 28 else 0.4
openers = [words(s)[0] for s in sents if words(s)]
repeat = 1 - len(set(openers)) / len(openers) if openers else 0
window = toks[:400]
ttr = len(set(window)) / len(window)
variety = min(1.0, max(0.0, (0.62 - ttr) / 0.25))
uniform = 0.45 * band + 0.3 * min(1.0, repeat * 2) + 0.25 * variety
score = 100 * (0.40 * burst + 0.35 * marker + 0.25 * uniform)
return float(max(3.0, min(97.0, score)))
def _content(ws):
"""Vocabulary words: no function words (the, of, and ...), numbers, or one- to three-letter tokens."""
return [w for w in ws if len(w) >= 4 and not w.isdigit() and w not in TP._STOP]
def words_changed(original, revised):
"""Vocabulary change (%): share of the revised text's vocabulary words (content words; function words, numbers
and citations cannot change and are not counted) that are not in the original."""
a, b = words(original), words(revised)
if not b:
return 0.0
ca, cb = _content(a), _content(b)
if len(set(cb)) >= 3:
a, b = ca, cb
return 100.0 * (1 - len(set(b) & set(a)) / len(set(b)))
def text_change(original, revised):
"""Vocabulary change of a whole text, measured the way it is set: sentence by sentence against the sentence it
came from, weighted by length. (Comparing the word sets of two whole documents understates the change, because a
synonym used in one sentence often appears somewhere else in the original.)"""
import difflib
op = [p for p in (original or "").split("\n") if p.strip()]
np_ = [p for p in (revised or "").split("\n") if p.strip()]
if not np_:
return 0.0
if len(op) != len(np_):
sm = difflib.SequenceMatcher(None, [words(p)[:6] and " ".join(words(p)[:6]) for p in op],
[words(p)[:6] and " ".join(words(p)[:6]) for p in np_], autojunk=False)
pairs = []
for tag, i1, i2, j1, j2 in sm.get_opcodes():
if tag == "equal" or (i2 - i1) == (j2 - j1):
pairs += list(zip(op[i1:i2], np_[j1:j2]))
else:
pairs.append((" ".join(op[i1:i2]), " ".join(np_[j1:j2])))
else:
pairs = list(zip(op, np_))
tot = wsum = 0.0
for a, b in pairs:
sa, sb = split_sentences(a) or [a], split_sentences(b) or [b]
if len(sa) == len(sb):
units = list(zip(sa, sb))
else: # split / merged sentences: compare with the closest one
ws = [set(words(x)) for x in sa]
units = []
for y in sb:
wy = set(words(y))
k = max(range(len(sa)), key=lambda i: len(ws[i] & wy) / max(1, len(ws[i] | wy)))
units.append((sa[k], y))
for x, y in units:
k = len(words(y))
if k:
tot += k * words_changed(x, y)
wsum += k
return tot / wsum if wsum else 0.0
def _level(value, invert=False):
if value is None:
return "#9aa0a6", "n/a"
v = 100 - value if invert else value
if v < 30:
return BRAND["green"], "Low"
if v < 55:
return BRAND["orange"], "Moderate"
return BRAND["red"], "High"
def _gauge(title, value, caption, invert=False, fixed_color=None, chip=None):
color, label = _level(value, invert)
if fixed_color:
color = fixed_color
label = chip or "Rewrite depth"
pct = 0 if value is None else round(value)
shown = "—" if value is None else f"{pct}%"
return f"""
<div class="cs-gauge">
<div class="cs-ring" style="--p:{pct};--c:{color}">
<div class="cs-ring-inner"><span>{shown}</span></div>
</div>
<div class="cs-gauge-title">{title}</div>
<div class="cs-chip" style="background:{color}1f;color:{color}">{label}</div>
<div class="cs-gauge-cap">{caption}</div>
</div>"""
def score_panel(original, revised):
sim = similarity_to_source(original, revised)
ai_in = ai_likelihood(original)
ai_out = ai_likelihood(revised)
changed = text_change(original, revised)
return f"""
<div class="cs-scores">
{_gauge("Similarity to source", sim, "Word n-gram overlap with your original")}
{_gauge("AI indicator · input", ai_in, "Stylometric estimate of the original")}
{_gauge("AI indicator · output", ai_out, "Stylometric estimate after paraphrasing")}
{_gauge("Vocabulary changed", changed, "Share of new wording", fixed_color=BRAND["navy"])}
</div>
<div class="cs-disclaimer">
<b>Tentative indicators only.</b> Similarity is measured against your own original text,
not against internet or journal databases, and the AI indicator is a stylometric heuristic.
Results will differ from Turnitin, iThenticate or other detectors. Always review the
output for meaning, technical terms and citations before use.
</div>"""
EMPTY_SCORES = """
<div class="cs-scores cs-empty">
<div>Scores appear here after paraphrasing — similarity to source, AI indicators and rewrite depth.</div>
</div>"""
# ---------------------------------------------------------------------------
# DOCX structure helpers
# ---------------------------------------------------------------------------
def iter_table_paragraphs(table):
for row in table.rows:
for cell in row.cells:
for paragraph in cell.paragraphs:
yield paragraph
for nested_table in cell.tables:
yield from iter_table_paragraphs(nested_table)
def collect_paragraphs(doc):
seen = set()
def unique(paragraphs):
for paragraph in paragraphs:
key = id(paragraph._p)
if key not in seen:
seen.add(key)
yield paragraph
yield from unique(doc.paragraphs)
for table in doc.tables:
yield from unique(iter_table_paragraphs(table))
for section in doc.sections:
for part in (
section.header, section.footer,
section.first_page_header, section.first_page_footer,
section.even_page_header, section.even_page_footer,
):
yield from unique(part.paragraphs)
for table in part.tables:
yield from unique(iter_table_paragraphs(table))
def _is_protected(run):
return bool(
run._r.xpath(".//w:drawing")
or run._r.xpath(".//w:object")
or run._r.xpath(".//w:fldChar")
)
def replace_text_preserving_paragraph(paragraph, new_text):
runs = list(paragraph.runs)
if not runs:
paragraph.add_run(new_text)
return
target = next((r for r in runs if not _is_protected(r)), None)
if target is None:
target = paragraph.add_run()
target.text = new_text
for run in runs:
if run is not target and not _is_protected(run):
run.text = ""
# ---------------------------------------------------------------------------
# Paraphrasing
# ---------------------------------------------------------------------------
def _generate(texts, temperature, device, batch_size, m=None, k=1):
"""Paraphrase `texts`; returns len(texts) * k strings (k sampled candidates per text, in order).
Output length is capped just above the input length, so the decoder never runs on idle."""
m = m or load_model()
temperature = max(0.3, min(float(temperature), PS.get("max_temp")))
k = max(1, int(k))
outputs = []
with torch.inference_mode():
for start in range(0, len(texts), batch_size):
chunk = texts[start:start + batch_size]
encoded = tokenizer(
["paraphrase: " + t for t in chunk],
return_tensors="pt", padding=True, truncation=True, max_length=256,
)
longest = int(encoded["input_ids"].shape[1])
encoded = {kk: v.to(device) for kk, v in encoded.items()}
_sup = AD.suppress_ids(tokenizer)
_t0 = time.time()
generated = m.generate(
**encoded,
do_sample=True,
temperature=temperature,
top_p=0.92,
num_return_sequences=k,
max_new_tokens=min(180, int(longest * 1.35) + 12),
no_repeat_ngram_size=3,
repetition_penalty=REP_PENALTY,
suppress_tokens=_sup, # words learned to cause rejected rewrites
)
print(f"[timing] parrot generate on {device}: {len(chunk)} text(s) x {k}, {len(_sup or [])} banned "
f"tokens, {time.time() - _t0:.1f}s", flush=True)
decoded = tokenizer.batch_decode(generated.detach().cpu(), skip_special_tokens=True)
for n_, src in enumerate(chunk):
for c in decoded[n_ * k:(n_ + 1) * k]:
outputs.append(c.strip() if c.strip() else src)
del encoded, generated
return outputs
def _gpu_duration(texts, temperature, k=1):
# Reserve only what this chunk needs, so visitors' daily ZeroGPU quota lasts longer.
return int(min(90, 12 + 0.3 * len(texts or []) * max(1, int(k))))
@_GPU(duration=_gpu_duration)
def paraphrase_batch_gpu(texts, temperature, k=1):
if not texts:
return []
if not ON_GPU or not torch.cuda.is_available():
raise RuntimeError("no gpu available")
return _generate(texts, temperature, "cuda", batch_size=GPU_BATCH, k=k)
def paraphrase_batch_cpu(texts, temperature, k=1):
if not texts:
return []
m = _get_cpu_model()
with RT.cpu_job(): # share the cores fairly between simultaneous users
return _generate(texts, temperature, "cpu", batch_size=8, m=m, k=k)
def _is_gpu_unavailable(err):
msg = str(err).lower()
return any(k in msg for k in ("quota", "zerogpu", "gpu task aborted", "cuda", "no gpu", "queue is full"))
# Candidates per sentence: Parrot samples several paraphrases in one pass and the one the AI detector finds
# most human is kept (HUM_CANDIDATES=1 turns this off; HUM_SELECT=0 keeps the first valid candidate).
CANDIDATES = max(1, int(os.getenv("HUM_CANDIDATES", "3")))
# Set per job when the customer ticks "My text is 100% AI-written": Parrot writes CANDIDATES_AI versions of
# every sentence (default 5), the plain-wording (de-AI) method is added, and Groq checks the GROQ_SHORTLIST
# most human versions of each sentence (default 2) so a rejected best version falls back to a checked one.
CANDIDATES_AI = max(1, int(os.getenv("HUM_CANDIDATES_AI", "5")))
GROQ_SHORTLIST = max(1, int(os.getenv("HUM_GROQ_SHORTLIST", "2")))
# 100% AI mode: target share of new wording per sentence (same measure as the "Vocabulary changed" gauge)
# and the number of extra Parrot rounds for sentences below it.
AI_CHANGE_TARGET = float(os.getenv("HUM_AI_CHANGE_TARGET", "45"))
# safety limits: above these the paraphraser starts breaking grammar and technical meaning
MAX_TEMP = float(os.getenv("HUM_MAX_TEMP", "1.0")) # highest sampling variation ever used
MAX_VOCAB = float(os.getenv("HUM_MAX_VOCAB", "45")) # highest new-wording target per sentence (%)
AI_ROUNDS = max(0, int(os.getenv("HUM_AI_ROUNDS", "2")))
# Smart Humanize (adaptive): sentences whose AI score did not fall last pass get detector-guided decoding too.
FORCE_ADV = contextvars.ContextVar("force_adv", default=False)
# a job paid from the wallet starts no new pass / round after this moment (epoch seconds), so its cost stays inside
# what was reserved for it (wallet.plan_job); None = the engines' own time budgets only
JOB_DEADLINE = contextvars.ContextVar("job_deadline", default=None)
def past_deadline():
d = JOB_DEADLINE.get()
return d is not None and time.time() > d
# Smart Humanize sets this per pass (its passes already rewrite again, so fewer inner rounds are needed).
EXTRA_ROUNDS = contextvars.ContextVar("extra_rounds", default=None)
# Vocabulary change (%) slider, per job. Box ticked: the new-wording target (None -> AI_CHANGE_TARGET).
# Box unticked: 0 = off (the original behaviour); above 0 = same target logic as the 100% AI mode.
VOCAB_TARGET = contextvars.ContextVar("vocab_target", default=None)
def change_target():
"""New-wording target per sentence: the customer's Vocabulary change setting when they chose one (up to 90%),
otherwise the automatic level (capped at HUM_MAX_VOCAB)."""
v = VOCAB_TARGET.get()
if v:
return min(90.0, float(v))
return min(PS.get("max_vocab"), float(os.getenv("HUM_AI_CHANGE_TARGET", "90")))
FULL_AI = contextvars.ContextVar("full_ai", default=False)
# Set by Smart Humanize itself when the normal rewrite stops lowering the score: the sentences that are still flagged
# get the deeper rewrite (more versions per sentence, plain-wording versions, two versions checked) without the
# customer having to tick anything. Unlike FULL_AI, sentences that read as human stay untouched.
BOOST = contextvars.ContextVar("boost", default=False)
# Kept for compatibility with smart.py / app.py versions that set it per pass. The 100% AI mode is now
# "5 Parrot versions + Groq check" (Parrot does all rewriting), so this switch has no effect.
DEEP = contextvars.ContextVar("deep_rewrite", default=False)
SELECT = os.getenv("HUM_SELECT", "1") != "0"
# Smart Humanize: current sentence text -> the AUTHOR'S original sentence, so every pass is checked against the
# original (rewrites of rewrites cannot drift or pile up damage)
ANCHOR = contextvars.ContextVar("anchor", default=None)
# a sentence with no clear version keeps its original wording (strict, default) or keeps the best version for the
# final polish to repair (lenient)
CLARITY_SELECT = os.getenv("CLARITY_SELECT", "strict").lower()
# rescue rewrites (language model, written to the publication rules) for sentences that have no clear rewrite or
# still read as AI-written; every rescue rewrite passes the same checks
LEX_N = int(os.getenv("LEXSUB_N", "3")) # vocabulary-replaced versions per sentence
DEBUG = os.getenv("HUM_DEBUG", "0") == "1" # print every candidate with its AI score (server log)
RESCUE = os.getenv("HUM_RESCUE", "1") != "0"
RESCUE_AI = float(os.getenv("HUM_RESCUE_AI", "0.35")) # sentences scoring at least this get rescue versions
RESCUE_N = int(os.getenv("HUM_RESCUE_N", "4")) # versions per sentence
RESCUE_MAX = int(os.getenv("HUM_RESCUE_MAX", "300")) # at most this many sentences per run
RESCUE_SWEEPS = max(1, int(os.getenv("HUM_RESCUE_SWEEPS", "3"))) # rounds of the joint choice between versions
# Speed: steps that do not depend on each other run at the same time inside one rewrite call: the vocabulary
# versions (lexsub) while Parrot writes, and the rescue rewrites (Groq) from the start of the call instead of after
# the review. HUM_OVERLAP=0 = one after another, as before.
OVERLAP = os.getenv("HUM_OVERLAP", "0") == "1" # off by default (shared tokenizers: see smart.PARALLEL_GROUPS)
def _rescue_versions(sents, glossary, refs, n, progress, lo, hi, min_change):
"""Rescue rewrites from the language model (the job's provider, then OpenAI when that one is set and the first is
out of quota). None = no provider could take the request."""
for prov in [G.PROVIDER.get()] + (["openai"] if G.PROVIDER.get() != "openai" and G.openai_available() else []):
try:
return G._in_provider(prov, G.variants, sents, glossary, refs, n, progress, lo, hi, min_change)
except G.Unavailable as err:
print(f"[humanizer] rescue rewrites unavailable ({prov}): {err}", flush=True)
return None
# independent reading of every rewritten sentence against the author's sentence (HUM_VERIFY=0 = off)
VERIFY = os.getenv("HUM_VERIFY", "1") != "0"
VERIFY_ALTS = max(0, int(os.getenv("HUM_VERIFY_ALTS", "3"))) # other versions tried for a sentence that fails
def anchor_of(s):
d = ANCHOR.get()
return d.get((s or "").strip()) if d else None
def remember_anchor(old, new):
"""Smart Humanize: `new` replaced `old`; both lead back to the same original sentence."""
d = ANCHOR.get()
if d is not None and new and old and new.strip() != old.strip():
d[new.strip()] = d.get(old.strip(), old.strip())
# Smart Humanize: sentence -> (previous sentence, next sentence) in the document. The detector judges text in
# context (~90-word windows); a sentence scored on its own is scored unreliably, so candidates are scored together
# with their neighbours, the way the final report sees them.
SENT_CONTEXT = contextvars.ContextVar("sent_context", default=None)
def _ctx_scores(pairs, ctx=None, current=None):
"""[(current sentence, candidate)] -> AI probability of each candidate read in its document context.
ctx: sentence -> (previous, next); default = the document context set by Smart Humanize.
current: sentence -> the wording it has now, so a candidate is read next to its neighbours as they WILL be in
the result (a rewritten neighbour changes how the detector reads the sentence)."""
if not pairs:
return []
ctx = ctx if ctx is not None else (SENT_CONTEXT.get() or {})
current = current or {}
texts = []
for s, c in pairs:
p, n = ctx.get((s or "").strip(), ("", ""))
p, n = current.get((p or "").strip(), p), current.get((n or "").strip(), n)
texts.append(" ".join(x for x in (p, c, n) if x))
return _detector_scores(texts)
def _detector_scores(texts):
"""AI probability per text (lower = more human), or None if the detector is unavailable."""
if not texts:
return []
try:
import detector as D
return list(D.score_texts(texts))
except Exception as err:
print(f"[humanizer] candidate scoring skipped: {err}", flush=True)
return None
def _adversarial(chunks, flat, cand, temp, progress, at):
"""Adversarial Paraphrasing: one detector-guided Parrot version per piece, added to cand[piece] (every piece of
a sentence gets one, so the pieces stay aligned). Returns True if it ran."""
if ZERO and not ADV.ON_ZERO:
return False # ZeroGPU: no nested GPU allocation for the detector
todo = sorted(set(chunks))
if not todo or len(todo) > ADV.MAX_PIECES: # over the per-job cap (ADV_MAX): Parrot's versions only
if todo:
print(f"[humanizer] adversarial paraphrasing skipped: {len(todo)} pieces > ADV_MAX={ADV.MAX_PIECES}",
flush=True)
return False
if progress is not None:
progress(at, desc=f"Detector-guided rewriting of {len(todo)} sentence piece(s)")
try:
if ON_GPU and not ZERO and torch.cuda.is_available():
outs = ADV.generate([flat[x] for x in todo], load_model(), tokenizer, "cuda", _detector_scores, temp)
else:
m = _get_cpu_model()
with RT.cpu_job():
outs = ADV.generate([flat[x] for x in todo], m, tokenizer, "cpu", _detector_scores, temp)
except Exception as err:
print(f"[humanizer] adversarial paraphrasing skipped: {err}", flush=True)
return False
for x, o in zip(todo, outs):
cand[x].append(o if o and o.strip() else flat[x])
print(f"[humanizer] adversarial paraphrasing: {len(todo)} piece(s)", flush=True)
return True
def _paraphrase_local(texts, temperature, progress=None, lo=0.15, hi=0.85, mode="Auto"):
"""Returns (paragraphs, engine_note).
1. Parrot writes CANDIDATES paraphrases per sentence (long sentences clause by clause), batched
shortest-first so batches carry little padding.
2. Technical terms a candidate lost are copied back in place (textparse.restore_terms); candidates that
still lose terms, numbers or citations are dropped (textparse.guard).
3. The AI detector scores the surviving candidates; the most human one is kept.
4. Groq (optional) checks the chosen sentence's academic wording with minimal edits, and rescues
sentences where no candidate survived."""
import time as _time
_tm = [("start", _time.time())] # stage timing, one log line per call (where the waiting goes)
groups, flat, sent_map = [], [], [] # sent_map: (sentence, first_chunk, n_chunks)
for text in texts:
sents = split_sentences(text) or [text]
g0 = len(sent_map)
for s in sents:
pieces = TP.chunk_sentence(s)
sent_map.append((s, len(flat), len(pieces)))
flat.extend(pieces)
groups.append((g0, len(sent_map)))
doc_ctx = SENT_CONTEXT.get() or {}
ctx = {}
for a, b in groups: # plain Humanizer: neighbours inside the paragraph
for si in range(a, b):
key = sent_map[si][0].strip()
ctx[key] = doc_ctx.get(key) or (sent_map[si - 1][0] if si > a else "",
sent_map[si + 1][0] if si + 1 < b else "")
full_ai = FULL_AI.get()
deep = full_ai or BOOST.get() # deeper rewrite: asked for, or switched on by Smart Humanize
k = AD.candidates(CANDIDATES_AI if deep else CANDIDATES) # more samples when few survive the checks
target = change_target() if full_ai else min(90.0, float(VOCAB_TARGET.get() or 0))
er = EXTRA_ROUNDS.get()
rounds = 1 + ((AI_ROUNDS if er is None else er) if (full_ai or target) else 0)
temp0 = min(1.5, float(temperature) + (0.2 if deep else 0.0))
p_hi = lo + (hi - lo) * (0.7 if G.available() else 0.85) # room on the bar for scoring / Groq
p_gen = lo + (p_hi - lo) * (0.75 if full_ai else 1.0) # 100% AI: room for the extra rounds
use_gpu = ON_GPU and not (str(mode) == MODES[1] or str(mode).lower().startswith("cpu")) # CPU-only hardware: straight to CPU
fell_back = False
cand = [[] for _ in flat] # cand[i] = all paraphrases of flat[i] (k per round)
def generate(indices, temp, a_, b_, label):
nonlocal use_gpu, fell_back
order = sorted(indices, key=lambda x: len(flat[x]))
total = max(1, len(order))
step = GPU_CHUNK if use_gpu else CPU_CHUNK
i = 0
while i < len(order):
idx = order[i:i + step]
chunk = [flat[x] for x in idx]
engine = "GPU" if use_gpu else "CPU"
if progress is not None:
progress(a_ + (b_ - a_) * i / total,
desc=f"{label} {i + 1}–{i + len(chunk)} of {total} ({engine}, {k} versions each)")
if use_gpu:
try:
outs = paraphrase_batch_gpu(chunk, temp, k)
except Exception as err:
if not _is_gpu_unavailable(err):
raise
use_gpu, fell_back, step = False, True, CPU_CHUNK
continue # retry the same chunk on CPU
else:
outs = paraphrase_batch_cpu(chunk, temp, k)
for n_, x in enumerate(idx):
got = outs[n_ * k:(n_ + 1) * k] or [flat[x]]
cand[x].extend(got + [got[-1]] * (k - len(got))) # keep k per round so chunks line up
i += len(chunk)
glossary = TP.build_glossary(texts) | TP.GLOSSARY.get()
def checked(orig, new, source, guarded):
"""Guard result of one version + what the adaptive learner records about it."""
ok = guarded
if ok and AD.banned_in(orig, ok): # a word learned to cause bad rewrites
ok = None
if new and new.strip() != orig.strip():
why = None if ok else (AD.banned_in(orig, new) and "banned word") or TP.integrity_issue(orig, new) \
or TP.quality_issue(orig, new) or ("lost term" if TP.lost_terms(orig, new, glossary) else "other")
AD.record_candidate(orig, new, source, bool(ok), why)
return ok
def build(si):
"""Valid candidates of sentence si (terms restored, guard passed) + first raw paraphrase."""
s, c0, n = sent_map[si]
opts, raw0 = [], None
m = min(len(cand[c]) for c in range(c0, c0 + n)) if n else 0
for j in range(m):
pieces = [cand[c][j] for c in range(c0, c0 + n)]
new = TP.merge_chunks(flat[c0:c0 + n], pieces) if n > 1 else pieces[0]
raw0 = raw0 or new
source = "adversarial" if (adv_used and j == k) else "parrot"
ok = checked(s, new, source, TP.guard(s, TP.restore_terms(s, new, glossary), glossary))
if ok and TP._near_repeats(ok) - TP._near_repeats(s):
ok = None # restored term doubled up ("ethics of care ethics")
if ok and ok.strip() != s.strip() and ok not in opts:
opts.append(ok)
src_of[(si, ok)] = source
# "100% AI-written" mode only: plain-wording versions (chatbot vocabulary / set phrases removed) of each
# paraphrase and of the original itself, so fully AI-written sentences always get a safe, more human option
for v in lex[si]: # vocabulary-replaced versions of the ORIGINAL sentence
ok = checked(ref[si], v, "lexsub", TP.guard(ref[si], v, glossary))
if ok and ok.strip() != s.strip() and ok not in opts:
opts.append(ok)
src_of[(si, ok)] = "lexsub"
for base in ((list(opts) + [s]) if deep and AD.use_source("de_ai") else []):
v = TP.de_ai(base)
if v != base:
ok = checked(s, v, "de_ai", TP.guard(s, TP.restore_terms(s, v, glossary), glossary))
if ok and ok.strip() != s.strip() and ok not in opts:
opts.append(ok)
src_of[(si, ok)] = "de_ai"
return opts, raw0 or s
def change(si, o):
return words_changed(sent_map[si][0], o)
_tm.append(("split", _time.time()))
# ---- 1–2. Parrot versions; in 100% AI mode, sentences below the new-wording target (or with no safe
# version) go back to Parrot for extra rounds with more variation
todo = list(range(len(sent_map)))
options, first_raw = [[] for _ in sent_map], [s for s, _, _ in sent_map]
extra_rounds = meaning_dropped = 0
adv_used = False
src_of = {} # (sentence, version) -> source, for the adaptive learner
qstat = dict(n=0, meaning=0, fluency=0, second=0, sims=[])
ref = [anchor_of(s) or s for s, _, _ in sent_map] # the author's original sentence
# stricter for technical sentences; a high vocabulary-change setting allows a little more distance (the
# faithfulness check still rejects changed claims)
relax = 0.05 if target >= 50 else 0.03 if target >= 35 else 0.0
thr = [MEANING.min_for(r) - relax for r in ref]
soft = [MEANING.min_for(r, MEANING.soft_sim()) - relax for r in ref]
_tm.append(("setup", _time.time()))
import concurrent.futures as _cf
_bg = _cf.ThreadPoolExecutor(max_workers=2) if OVERLAP else None
# the rescue rewrites (step 5) need only each sentence and its original, not the choices made before them: they
# are requested now and arrive while Parrot, the checks and the review run. Step 5 still decides which
# sentences use them (in practice nearly all that are sent).
rescue_pre = None
want0 = [si for si, (s, _, _) in enumerate(sent_map) if len(s.split()) >= 6]
if _bg is not None and RESCUE and want0 and len(want0) <= RESCUE_MAX and (G.available() or G.openai_available()):
rescue_pre = (want0, _bg.submit(
contextvars.copy_context().run, _rescue_versions, [sent_map[si][0] for si in want0], glossary,
[ref[si] if ref[si] != sent_map[si][0] else None for si in want0], RESCUE_N + (2 if BOOST.get() else 0),
None, hi, hi, min(80, target) if target else 0))
# correct sentences with replaced vocabulary: suitable in-context synonyms, structure unchanged (always available
# as a clear option, so a rejected paraphrase is replaced by new wording instead of the original sentence)
def vocabulary():
lex_ = [[] for _ in sent_map]
if LX.ENABLED and LX.available():
try:
# chained passes: new vocabulary is built on the CURRENT text (the previous pass's output), so every
# pass adds to the last one; terms, numbers and meaning are still checked against the original
lex_ = LX.variants([s for s, _, _ in sent_map], glossary, n=LEX_N,
ratios=LX.ratios_for(target, LEX_N + 1) if target else None)
except Exception as err:
print(f"[humanizer] vocabulary replacement skipped: {err}", flush=True)
print(f"[humanizer] vocabulary replacement: {sum(1 for x in lex_ if x)} of {len(lex_)} sentences got "
f"versions", flush=True)
return lex_
if _bg is not None: # written while Parrot writes (both needed only below)
lex_job, lex = _bg.submit(contextvars.copy_context().run, vocabulary), None
else:
if progress is not None and LX.ENABLED:
progress(lo, desc="Finding suitable replacement vocabulary")
lex_job, lex = None, vocabulary()
_tm.append(("vocab", _time.time()))
for r in range(rounds):
if not todo:
break
a_ = lo if r == 0 else p_gen + (p_hi - p_gen) * (r - 1) / max(1, rounds - 1)
b_ = p_gen if r == 0 else p_gen + (p_hi - p_gen) * r / max(1, rounds - 1)
chunks = [c for si in todo for c in range(sent_map[si][1], sent_map[si][1] + sent_map[si][2])]
generate(chunks, min(1.5, temp0 + 0.25 * r), a_, b_,
"Paraphrasing" if r == 0 else f"Extra rewrite round {r} for")
_tm.append(("parrot", _time.time()))
if r == 0 and (ADV.wanted(full_ai) or FORCE_ADV.get()) and AD.use_source("adversarial"):
if _adversarial(chunks, flat, cand, temp0, progress, b_):
adv_used = True
extra_rounds += r > 0
if lex is None:
lex = lex_job.result()
_tm.append(("vocab_wait", _time.time()))
for si in todo:
options[si], first_raw[si] = build(si)
_tm.append(("guards", _time.time()))
# quality checks of every version: meaning (vs the original sentence) and fluency (well-formed English)
pairs = [(si, o) for si in todo for o in options[si]]
if pairs:
sims = MEANING.similarities([(ref[si], o) for si, o in pairs])
flu = FL.acceptable([(ref[si], o) for si, o in pairs])
keep_ = {}
for (si, o), sm, fl in zip(pairs, sims, flu):
keep_[(si, o)] = (sm >= thr[si] and fl, sm, fl)
qstat["n"] += 1
qstat["meaning"] += sm < thr[si]
qstat["fluency"] += not fl
qstat["sims"].append(sm)
for si in todo:
good = [o for o in options[si] if keep_[(si, o)][0]]
if not good and options[si]:
# second chance: no version passed both checks -> keep the closest-meaning version that is
# fluent English and only slightly below the meaning threshold
soft_ = [(keep_[(si, o)][1], o) for o in options[si]
if keep_[(si, o)][2] and keep_[(si, o)][1] >= soft[si]]
if soft_:
good = [max(soft_)[1]]
qstat["second"] += 1
meaning_dropped += len(options[si]) - len(good)
options[si] = good
if not target:
break
todo = [si for si in todo
if not options[si] or max(change(si, o) for o in options[si]) < target]
if fell_back:
note = "⚠️ GPU quota was unavailable, so the remaining text was processed on CPU (slower, same quality)."
elif use_gpu:
note = "⚡ Processed on GPU."
else:
note = "🖥️ Processed on CPU."
if adv_used:
note += " 🧭 Detector-guided (adversarial) rewriting added one extra version per sentence."
_tm.append(("meaning+fluency", _time.time()))
# ---- 3. most human candidate (AI detector); 100% AI mode: versions meeting the new-wording target first
score_of = {}
multi = [(si, o) for si, opts in enumerate(options) if len(opts) > 1 for o in opts]
if SELECT and multi:
if progress is not None:
progress(p_hi, desc="Choosing the most human-sounding version of each sentence")
sc = _ctx_scores([(sent_map[si][0], o) for si, o in multi], ctx)
if sc:
score_of = {key: v for key, v in zip(multi, sc)}
note += (f" 🎯 Best of {k} rewritten versions per sentence chosen by the AI detector." if k > 1 else
" 🎯 The AI detector chose between the rewritten version and the plain-wording version.")
def rank_key(si, o):
det = score_of.get((si, o), 1.0 if score_of else 0.0)
if target:
ch = change(si, o)
return (0, det) if ch >= target else (1, -ch, det)
return (det,)
ranked = [sorted(opts, key=lambda o, si=si: rank_key(si, o)) if (score_of or target) else list(opts)
for si, opts in enumerate(options)]
if DEBUG:
for si, opts in enumerate(ranked):
print(f"[debug] s{si} | {sent_map[si][0]}", flush=True)
for o in opts:
print(f"[debug] {score_of.get((si, o), float('nan')):.2f} chg {change(si, o):3.0f}% | {o}", flush=True)
chosen = [r_[0] if r_ else None for r_ in ranked]
for si, opts in enumerate(options): # adaptive learner: which source's version won
if len(opts) > 1 and chosen[si]:
for o in opts:
AD.record_win(src_of.get((si, o), "parrot"), o == chosen[si])
AD.save()
_tm.append(("detector_pick", _time.time()))
# ---- 4. Groq: academic-wording check of the chosen sentences + rescue of sentences with no valid version.
# 100% AI mode: Groq checks the GROQ_SHORTLIST most human versions of each sentence, and the most human
# version Groq accepts is kept (so one rejection does not fall back to an unchecked version).
if G.available():
shortlist = GROQ_SHORTLIST if deep else 1
pairs, where = [], []
for si, (s, _, _) in enumerate(sent_map):
if chosen[si]:
todo = [chosen[si]] + [o for o in ranked[si] if o != chosen[si]][:shortlist - 1]
for o in todo:
pairs.append((s, o)); where.append((si, "check", o))
elif first_raw[si] and first_raw[si].strip() != s.strip():
pairs.append((s, first_raw[si])); where.append((si, "rescue", first_raw[si]))
if pairs:
try:
res = G.review(pairs, glossary, progress, p_hi + (hi - p_hi) * 0.3, hi)
verdict = {} # si -> list of (candidate, result) in rank order
for (si, kind, cand_), r in zip(where, res):
verdict.setdefault(si, []).append((kind, cand_, r))
fixes = [] # (si, kind, groq_text, candidate it came from)
for si, items in verdict.items():
s = sent_map[si][0]
kind = items[0][0]
accepted, unchecked, rejected = [], [], 0
for _, cand_, r in items:
if r is None:
rejected += 1
continue
if r is False:
unchecked.append(cand_)
continue
ok = TP.guard(s, TP.restore_terms(s, r, glossary), glossary)
if ok and ok.strip() != s.strip():
accepted.append((ok, cand_))
elif ok and kind == "check":
accepted.append((None, cand_)) # Groq's repair = the original
if kind == "rescue":
if accepted and accepted[0][0]:
chosen[si] = accepted[0][0]
continue
if accepted:
real = [x for x in accepted if x[0]]
ok, cand_ = (real or accepted)[0] # most human version Groq accepted
if ok is None:
chosen[si] = None # keep the original sentence
else:
chosen[si] = cand_
if ok != cand_:
fixes.append((si, ok, cand_))
elif unchecked:
chosen[si] = unchecked[0] # Groq could not check it: local rules decide
elif rejected:
# every checked version rejected: try an unchecked lower-ranked one, else the original
tried = {c for _, c, _ in items}
chosen[si] = next((o for o in ranked[si] if o not in tried), None)
# keep Groq's wording fix unless it makes the sentence clearly more AI-like
sc = _ctx_scores([(sent_map[si][0], ok) for si, ok, _ in fixes]
+ [(sent_map[si][0], c) for si, _, c in fixes], ctx) if fixes else []
m = len(fixes)
for n_, (si, ok, cand_) in enumerate(fixes):
if sc and sc[n_] > sc[m + n_] + 0.02: # never trade AI score for polish
continue
if target and change(si, ok) < 0.5 * change(si, cand_):
continue # 100% AI: Groq must not undo the rewrite
chosen[si] = ok
note += (f" ✅ Groq checked the top {shortlist} versions of each sentence." if shortlist > 1
else " ✅ Academic wording checked by Groq.")
except G.Unavailable as err:
note += " (Groq check unavailable right now — local term checks used.)"
print(f"[humanizer] Groq review skipped: {err}", flush=True)
_tm.append(("groq_review", _time.time()))
# final meaning check of what will actually be used (Groq rescues / repairs included)
final = [si for si in range(len(sent_map)) if chosen[si] and chosen[si].strip() != sent_map[si][0].strip()]
fsims = MEANING.similarities([(ref[si], chosen[si]) for si in final])
for si, k_ in zip(final, [sm >= soft[si] for si, sm in zip(final, fsims)]):
if not k_:
meaning_dropped += 1
chosen[si] = next((o for o in ranked[si] if o != chosen[si]), None) # already meaning-checked
_tm.append(("meaning", _time.time()))
# clarity gate: the chosen version must read as clear, correct English (compared with the ORIGINAL sentence);
# otherwise the clearest acceptable lower-ranked version is used, else the original wording is kept
unclear = switched = 0
if CL.ENABLED and PS.get("select") != "off":
if progress is not None:
progress(hi, desc="Checking every rewritten sentence for clear, correct English")
final = [si for si in range(len(sent_map)) if chosen[si] and chosen[si].strip() != sent_map[si][0].strip()]
verdict = CL.assess([(ref[si], chosen[si]) for si in final], use_meaning=False)
bad = [si for si, v in zip(final, verdict) if v]
if bad:
alts = [(si, o) for si in bad for o in ranked[si] if o != chosen[si]]
good = {}
for (si, o), v in zip(alts, CL.assess([(ref[si], o) for si, o in alts], use_meaning=False)):
if not v and si not in good:
good[si] = o
for si in bad:
if si in good:
chosen[si] = good[si]
switched += 1
else:
unclear += 1
if PS.get("select") == "strict":
chosen[si] = None
CL.report()
_tm.append(("clarity", _time.time()))
# rescue: sentences still kept in the author's wording, or still reading as AI-written, get rule-compliant,
# human-style rewrites from the language model (written from the ORIGINAL sentence); a rewrite is used only if
# it passes every check and reads as more human than what the sentence has now
rescued = 0
pool, before_rescue = {}, list(chosen) # rescue versions per sentence; the choice made without them
if RESCUE and (G.available() or G.openai_available()):
want = [si for si, (s, _, _) in enumerate(sent_map) if len(s.split()) >= 6]
cur = [chosen[si] or sent_map[si][0] for si in want]
def wording():
"""sentence -> its wording right now (rewritten neighbours are read as rewritten)."""
return {sent_map[sj][0].strip(): chosen[sj] for sj in range(len(sent_map)) if chosen[sj]}
sc = _ctx_scores([(sent_map[si][0], c) for si, c in zip(want, cur)], ctx, wording()) or [1.0] * len(cur)
now = dict(zip(want, sc))
short = {si for si in want if target and (chosen[si] is None or change(si, chosen[si]) < target)}
todo = sorted([si for si in want if chosen[si] is None or now[si] >= PS.get("rescue_ai") or si in short],
key=lambda si: (si not in short or chosen[si] is not None, -now[si]))[:RESCUE_MAX]
if todo:
if rescue_pre is not None: # requested at the start of the call (see above)
got = rescue_pre[1].result()
got = None if got is None else dict(zip(rescue_pre[0], got))
vs = None if got is None else [got.get(si) for si in todo]
else:
vs = _rescue_versions([sent_map[si][0] for si in todo], glossary,
[ref[si] if ref[si] != sent_map[si][0] else None for si in todo],
RESCUE_N + (2 if BOOST.get() else 0), progress, hi, hi,
min(80, target) if target else 0)
if vs is None:
vs = [None] * len(todo)
note += " (Advanced rewriting is busy right now: fewer sentences could be rewritten.)"
print(f"[humanizer] rescue: {len(todo)} sentence(s) sent, "
f"{sum(1 for v in vs if v)} got versions", flush=True)
cands = []
for si, lst in zip(todo, vs):
for v in lst or []:
ok, why = TP.strict_check(ref[si], v, glossary)
if ok and (si, ok) not in cands:
cands.append((si, ok))
elif DEBUG and why:
print(f"[debug] rescue s{si} rejected ({why}) | {v}", flush=True)
n_all = len(cands)
if cands:
verdict = CL.assess([(ref[si], v) for si, v in cands])
if DEBUG:
for (si, v), why in zip(cands, verdict):
if why:
print(f"[debug] rescue s{si} unclear ({why}) | {v}", flush=True)
cands = [c for c, why in zip(cands, verdict) if not why]
print(f"[humanizer] rescue: {n_all} version(s) passed the term/number checks, {len(cands)} passed "
f"the clarity checks", flush=True)
if cands:
# Joint choice: a sentence is read by the detector together with its neighbours, so the versions
# are compared next to the neighbours' CURRENT wording, alternating between even and odd sentences
# (neighbours never change in the same step) until nothing improves.
for si, v in cands:
pool.setdefault(si, []).append(v)
switched = set()
for sweep in range(RESCUE_SWEEPS):
moved = 0
for parity in (0, 1):
part = [si for si in sorted(pool) if si % 2 == parity]
if not part:
continue
items = [(si, t) for si in part
for t in dict.fromkeys([chosen[si] or sent_map[si][0]] + pool[si])]
sc = _ctx_scores([(sent_map[si][0], t) for si, t in items], ctx, wording())
if not sc:
break
score = dict(zip(items, sc))
for si in part:
cur_t = chosen[si] or sent_map[si][0]
now_ = score[(si, cur_t)]
if DEBUG and sweep == 0:
for v in pool[si]:
print(f"[debug] rescue s{si} now {now_:.2f} -> {score[(si, v)]:.2f} | {v}",
flush=True)
# with a vocabulary-change setting, versions that reach it come first, then the most human
opts = sorted(((0 if not target or change(si, v) >= target else 1), score[(si, v)], v)
for v in pool[si] if v != cur_t)
if not opts:
continue
miss, d, v = opts[0]
reaches = target and not miss and (chosen[si] is None or change(si, chosen[si]) < target)
if d < now_ - 0.05 or (chosen[si] is None and d < now_) or (reaches and d <= now_ + 0.05):
chosen[si] = v
switched.add(si)
moved += 1
if not moved:
break
rescued = len(switched)
CL.report()
if rescued:
note += f" ✨ {rescued} sentence(s) rewritten in a natural academic style."
_tm.append(("rescue", _time.time()))
# Faithfulness check of what will be used. The checks so far are rules (numbers, terms, citations, negation ...)
# and a similarity score; neither sees that "increased by 38% compared with the control" has become "the
# control was 38% lower", that a method was left out, or that "but" turned into "so". An independent reading
# of every rewritten sentence against the AUTHOR'S sentence does. A sentence that fails gets the next version
# that passes, else the author's wording.
if VERIFY and G.available():
final = [si for si in range(len(sent_map)) if chosen[si] and chosen[si].strip() != ref[si].strip()]
try:
verdict = G.verify([(ref[si], chosen[si]) for si in final], progress, hi, hi) if final else []
bad = [si for si, v in zip(final, verdict) if isinstance(v, str)]
if DEBUG:
for si, v in zip(final, verdict):
if isinstance(v, str):
print(f"[debug] unfaithful s{si} ({v}) | {chosen[si]}", flush=True)
alts = []
for si in bad:
seen = {chosen[si].strip(), ref[si].strip()}
for o in pool.get(si, []) + [before_rescue[si]] + ranked[si]:
if o and o.strip() not in seen and sum(1 for x, _ in alts if x == si) < VERIFY_ALTS:
seen.add(o.strip())
alts.append((si, o))
second = G.verify([(ref[si], o) for si, o in alts], progress, hi, hi) if alts else []
good = {}
for (si, o), v in zip(alts, second):
if v is True and si not in good:
good[si] = o
for si in bad:
chosen[si] = good.get(si) # None = the author's sentence stays
if bad:
print(f"[humanizer] faithfulness check: {len(final)} rewritten sentence(s) read against the "
f"original, {len(bad)} changed the meaning ({len(good)} replaced by another version, "
f"{len(bad) - len(good)} kept in the author's wording)", flush=True)
note += (f" 🔍 Meaning check: {len(bad)} rewrite(s) that changed what the sentence says were "
f"replaced ({len(bad) - len(good)} kept in your wording).")
except G.Unavailable as err:
print(f"[humanizer] faithfulness check skipped: {err}", flush=True)
if CL.ENABLED:
kept = sum(1 for si, (s, _, _) in enumerate(sent_map) if not chosen[si] and len(s.split()) >= 6)
if kept and PS.get("select") == "strict":
note += f" 🧹 {kept} sentence(s) kept in your wording (no clear rewrite found)."
if qstat["n"]:
import statistics as _st
med = _st.median(qstat["sims"]) if qstat["sims"] else 0
print(f"[humanizer] quality checks: {qstat['n']} versions | below meaning {MEANING.min_sim():.2f}: "
f"{qstat['meaning']} (median similarity {med:.2f}) | not fluent: {qstat['fluency']} | "
f"rejected in total: {meaning_dropped} | sentences saved by second chance: {qstat['second']}",
flush=True)
sentences = [chosen[si] or s for si, (s, _, _) in enumerate(sent_map)]
changed = sum(1 for si, (s, _, _) in enumerate(sent_map) if sentences[si].strip() != s.strip())
if target and not full_ai:
hit = sum(1 for si, (s0, _, _) in enumerate(sent_map)
if sentences[si].strip() != s0.strip() and change(si, sentences[si]) >= target)
note += (f" 🔤 Vocabulary change {target:.0f}%"
+ (f", {extra_rounds} extra round(s)" if extra_rounds else "")
+ f": {hit} of {len(sent_map)} sentences reach it.")
if full_ai:
hit = sum(1 for si, (s0, _, _) in enumerate(sent_map)
if sentences[si].strip() != s0.strip() and change(si, sentences[si]) >= target)
note += (f" 🤖 100% AI mode: {k} rewritten version{'s' if k != 1 else ''} per sentence"
+ (f", {extra_rounds} extra round(s)" if extra_rounds else "")
+ (f"; {hit} of {len(sent_map)} sentences reach {target:.0f}% new wording." if target else ".")) \
+ f" ✍️ {changed} of {len(sent_map)} sentences rewritten."
if changed < len(sent_map):
print(f"[humanizer] kept {len(sent_map) - changed} sentences unchanged "
f"(no safe version: {sum(1 for o in options if not o)})", flush=True)
_tm.append(("faithfulness", _time.time()))
print(f"[timing] {len(sent_map)} sentence(s): " + " | ".join(
f"{n} {t - t0:.1f}s" for (_, t0), (n, t) in zip(_tm, _tm[1:])) + f" | total {_tm[-1][1] - _tm[0][1]:.1f}s",
flush=True)
if _bg is not None:
_bg.shutdown(wait=False)
return [" ".join(sentences[a:b]) for a, b in groups], note
# Final grammar pass: after ALL rewriting, Groq corrects grammar / spelling / punctuation only (HUM_GRAMMAR=0 = off).
GRAMMAR = os.getenv("HUM_GRAMMAR", "1") != "0"
GRAMMAR_MAX_EDIT = float(os.getenv("HUM_GRAMMAR_MAX_EDIT", "0.45")) # max share of words Groq may change
# Smart Humanize sets this so its passes skip the grammar step; it runs grammar once on the final text instead.
GRAMMAR_DEFER = contextvars.ContextVar("grammar_defer", default=False)
# Set by the app when the automatic proofread follows the humanizing: that proofread IS the final check (grammar,
# clarity, wording, flow, consistency), so the language-model polish here is not run a second time and paid twice.
# The rule-based clean-up still runs.
PROOFREAD_FOLLOWS = contextvars.ContextVar("proofread_follows", default=False)
def _small_edit(a, b):
"""True if b differs from a by at most GRAMMAR_MAX_EDIT of the words (a correction, not a rewrite)."""
import difflib
wa, wb = a.split(), b.split()
if not wa:
return False
sm = difflib.SequenceMatcher(None, wa, wb, autojunk=False)
changed = sum(max(i2 - i1, j2 - j1) for op, i1, i2, j1, j2 in sm.get_opcodes() if op != "equal")
return changed <= max(2, GRAMMAR_MAX_EDIT * len(wa))
# Final polish (default on): Groq edits whole paragraphs for grammar, technical academic wording and flow, instead of
# a grammar-only check. POLISH=0 -> grammar-only. A polished sentence must keep every term / number / citation,
# keep its meaning, change at most POLISH_MAX_EDIT of its words, and (POLISH_KEEP_HUMAN=1) must not turn a sentence
# the detector reads as human into one it reads as AI-written; otherwise the grammar-only correction is used.
POLISH = os.getenv("POLISH", "1") != "0"
POLISH_MAX_EDIT = float(os.getenv("POLISH_MAX_EDIT", "0.6"))
POLISH_KEEP_HUMAN = os.getenv("POLISH_KEEP_HUMAN", "1") != "0"
POLISH_CHANGED_ONLY = os.getenv("POLISH_CHANGED_ONLY", "1") != "0" # 0 = polish every paragraph of the document
def _edit_share(a, b):
import difflib
wa, wb = a.split(), b.split()
if not wa:
return 1.0
sm = difflib.SequenceMatcher(None, wa, wb, autojunk=False)
return sum(max(i2 - i1, j2 - j1) for op, i1, i2, j1, j2 in sm.get_opcodes() if op != "equal") / len(wa)
def _polish(sentences, out, idx, groups, glossary, progress, lo, hi, orig_par, orig_sent, stats):
"""Groq repair + polish of out[idx]. Garbled sentences are REPAIRED to the meaning of the author's original
(clarity wins over the AI score); clear sentences get the light polish (grammar, academic wording, flow) with
the usual limits. A garbled sentence that cannot be repaired safely gets the original sentence back when it is
known. Returns the indices that still need the grammar-only check."""
ctx = [orig_par[i] or orig_sent[i] for i in idx]
ctx = [c if c and c.strip() != out[i].strip() else None for c, i in zip(ctx, idx)]
res = G.polish([out[i] for i in idx], [groups[i] for i in idx], glossary, progress, lo, hi, originals=ctx)
edited, repaired, missing, broken = {}, {}, [], []
def ctx_known(i):
return bool(orig_sent[i] or orig_par[i])
for i, (st, r) in zip(idx, res):
if not r:
missing.append(i)
continue
if r.strip() == out[i].strip():
continue
ref = orig_sent[i] or sentences[i]
if st == "repaired":
ok = TP.strict_accept(out[i], r, glossary, orig_sent[i]) or \
(TP.strict_accept(orig_sent[i], r, glossary) if orig_sent[i] else None)
if ok:
repaired[i] = ok
else:
broken.append(i)
continue
ok = TP.strict_accept(out[i], r, glossary, orig_sent[i])
if ok and _edit_share(out[i], ok) <= max(POLISH_MAX_EDIT, 3 / max(1, len(out[i].split()))):
edited[i] = TP.basic_grammar(ok)
elif ctx_known(i): # a deep correction: judge it as a repair
ok = ok or (TP.strict_accept(orig_sent[i], r, glossary) if orig_sent[i] else None)
if ok:
repaired[i] = TP.basic_grammar(ok)
else:
missing.append(i)
else:
missing.append(i)
# meaning: a repair must be at least as close to the original as the garbled sentence was
if repaired:
keys = list(repaired)
refs = [orig_sent[i] or orig_par[i] or sentences[i] for i in keys]
sim = MEANING.similarities([(r_, repaired[i]) for r_, i in zip(refs, keys)]
+ [(r_, out[i]) for r_, i in zip(refs, keys)])
m = len(keys)
for n_, i in enumerate(keys):
exact = orig_sent[i] is not None
if (exact and sim[n_] < MEANING.min_sim()) or (not exact and sim[n_] < sim[m + n_] - 0.02):
repaired.pop(i)
broken.append(i)
if edited: # light edits must keep the meaning ...
keys = list(edited)
for i, k in zip(keys, MEANING.keeps_meaning([(sentences[i], edited[i]) for i in keys])):
if not k:
edited.pop(i)
missing.append(i)
def in_context(i, text):
"""The sentence as the detector reads it: together with its neighbours in the paragraph."""
prev = out[i - 1] if i > 0 and groups[i - 1] == groups[i] else ""
nxt = out[i + 1] if i + 1 < len(out) and groups[i + 1] == groups[i] else ""
return " ".join(x for x in (prev, text, nxt) if x)
def more_ai(keys, new_of, margin=0.35):
"""Keys whose new wording turns a human-reading sentence into an AI-reading one."""
sc = _detector_scores([in_context(i, new_of[i]) for i in keys] + [in_context(i, out[i]) for i in keys])
m = len(keys)
if DEBUG and sc:
for n_, i in enumerate(keys):
print(f"[debug] polish {sc[m + n_]:.2f} -> {sc[n_]:.2f} | {out[i]} => {new_of[i]}", flush=True)
return [i for n_, i in enumerate(keys) if sc and (sc[n_] >= 0.5 > sc[m + n_] or sc[n_] > sc[m + n_] + margin)]
if edited and POLISH_KEEP_HUMAN: # ... and must not undo the humanization
for i in more_ai(list(edited), edited):
edited.pop(i)
missing.append(i)
# Which of the sentences the editor called unclear really are? Measured against the author's sentence with the
# clarity checks (defects, grammar, structure, meaning); a sentence that passes them is clear.
clear = [i for i in list(repaired) + broken if orig_sent[i] and not TP.has_defect(out[i])]
if clear and CL.ENABLED:
clear = [i for i, why in zip(clear, CL.assess([(orig_sent[i], out[i]) for i in clear])) if not why]
clear = set(clear)
optional = set()
if repaired and POLISH_KEEP_HUMAN:
# A "repair" rewrites the sentence towards the author's original. That is right for a sentence that IS
# unclear (clarity wins over the AI score), but the editor also calls clear, correctly humanized sentences
# "repaired" and so puts the machine-like wording back. A sentence that passes the clarity checks against
# the original is not broken: its repair counts as an edit and may not raise the AI score.
optional = clear & set(repaired)
for i in more_ai(sorted(optional), repaired, 0.05) if optional else []:
repaired.pop(i) # the clear humanized sentence stays
before = list(out)
for i, t in list(edited.items()) + list(repaired.items()):
out[i] = t
if POLISH_KEEP_HUMAN:
# the paragraph as a whole: several small edits together can read as machine-written again even when each
# one passed on its own; then the optional ones (edits, and repairs of sentences that were clear) are undone
undo = {}
for i in list(edited) + [i for i in repaired if i in optional]:
undo.setdefault(groups[i], []).append(i)
if undo:
members = {g: [k for k in range(len(out)) if groups[k] == g] for g in undo}
sc = _detector_scores([" ".join(before[k] for k in members[g]) for g in undo]
+ [" ".join(out[k] for k in members[g]) for g in undo]) or []
m = len(undo)
for n_, g in enumerate(undo):
if sc and (sc[m + n_] >= 0.5 > sc[n_] or sc[m + n_] > sc[n_] + 0.15):
for i in undo[g]:
out[i] = before[i]
edited.pop(i, None)
repaired.pop(i, None)
missing.append(i)
restored = 0
for i in broken: # unclear and not safely repairable: the clear original
if i in clear and POLISH_KEEP_HUMAN: # (a clear humanized sentence is not thrown away)
missing.append(i)
elif orig_sent[i] and orig_sent[i].strip() != out[i].strip():
out[i] = orig_sent[i]
restored += 1
else:
missing.append(i)
stats.update(edited=len(edited), repaired=len(repaired), restored=restored)
return sorted(set(missing))
def grammar_fix_sentences(sentences, glossary=None, progress=None, lo=0.9, hi=0.95, groups=None,
orig_par=None, orig_sent=None):
"""Final correction of `sentences`. Returns (sentences, n_fixed, note).
1. rule-based clean-up (doubled words, a/an, 'will opening', spacing, capitals, final full stop) - always runs;
2. Groq final polish (POLISH=1, default): whole paragraphs (`groups` = paragraph key per sentence) are edited
for grammar, technical academic wording and flow between sentences, with the checks described at POLISH;
sentences whose polish was not accepted get the Groq grammar-only check (smallest correction);
3. rules once more;
4. LanguageTool (free, self-hosted) for the remaining mechanical errors. Every correction must keep every term,
number and citation and invent nothing."""
if not GRAMMAR or not sentences:
return list(sentences), 0, ""
out = [TP.basic_grammar(t) if len(t.split()) >= 3 else t for t in sentences]
groups = list(groups) if groups is not None else list(range(len(sentences)))
orig_par = list(orig_par) if orig_par is not None else [None] * len(sentences)
orig_sent = list(orig_sent) if orig_sent is not None else [None] * len(sentences)
orig_sent = [o or anchor_of(t) for o, t in zip(orig_sent, sentences)] # Smart Humanize: original sentence
if glossary is None and (any(orig_par) or any(orig_sent)):
# protected terms come from the AUTHOR'S text, never from garbled rewritten phrases
src = list(dict.fromkeys([p_ or s_ for p_, s_ in zip(orig_par, orig_sent) if p_ or s_]))
glossary = TP.build_glossary(src) | TP.GLOSSARY.get()
note, stats = "", dict(edited=0, repaired=0, restored=0)
if G.available() and not PROOFREAD_FOLLOWS.get():
glossary = glossary if glossary is not None else (TP.build_glossary(out) | TP.GLOSSARY.get())
idx = [i for i, t in enumerate(out) if len(t.split()) >= 3]
if POLISH_CHANGED_ONLY:
# A paragraph the tool did not change is the author's own text: sending it to the language model costs
# tokens for nothing and invites edits nobody asked for. Only paragraphs with at least one rewritten
# sentence are polished (as a whole, for flow); the rule-based clean-up and LanguageTool still see all.
def unchanged(i):
t = sentences[i].strip()
return bool((orig_sent[i] and orig_sent[i].strip() == t) or (orig_par[i] and t in orig_par[i]))
touched = {groups[i] for i in range(len(sentences)) if not unchanged(i)}
skipped = sum(1 for i in idx if groups[i] not in touched)
idx = [i for i in idx if groups[i] in touched]
if skipped:
print(f"[humanizer] final polish: {skipped} sentence(s) in unchanged paragraphs not sent "
f"({len(idx)} sent)", flush=True)
todo = idx
if POLISH and idx:
try:
todo = _polish(sentences, out, idx, groups, glossary, progress, lo, lo + (hi - lo) * 0.7,
orig_par, orig_sent, stats)
except G.Unavailable as err: # fall back to the grammar-only check
print(f"[humanizer] Groq final polish skipped, grammar-only check instead: {err}", flush=True)
try:
if todo:
res = G.grammar([out[i] for i in todo], glossary, progress, lo + (hi - lo) * 0.7, hi)
for i, r in zip(todo, res):
s0 = out[i]
if not r or r.strip() == s0.strip():
continue
ok = TP.strict_accept(s0, r, glossary, orig_sent[i])
if ok and _small_edit(s0, ok):
out[i] = ok
except G.Unavailable as err:
print(f"[humanizer] Groq grammar check skipped: {err}", flush=True)
note = " (Groq final polish unavailable right now: rule-based corrections applied.)"
# 4. LanguageTool (free, open source): last mechanical check - agreement, confused words, a/an, punctuation
if LT.MODE != "off":
glossary = glossary if glossary is not None else (TP.build_glossary(out) | TP.GLOSSARY.get())
idx = [i for i, t in enumerate(out) if len(t.split()) >= 3]
n_lt = 0
for i, r in zip(idx, LT.correct([out[i] for i in idx])):
if r.strip() == out[i].strip():
continue
ok = TP.strict_accept(out[i], r, glossary)
if ok and _small_edit(out[i], ok):
out[i], n_lt = ok, n_lt + 1
if n_lt:
note += f" 🔎 LanguageTool fixed {n_lt} sentence(s)."
# last safety net (clarity gate): a sentence that still reads as broken, ungrammatical or awkward English
# compared with the author's original sentence gets the original sentence back
refs = [orig_sent[i] or anchor_of(sentences[i]) for i in range(len(out))]
# the author's spelling (British / American) is kept
vocab = {w.lower() for t in (list(filter(None, refs)) + list(filter(None, orig_par)) + list(sentences))
for w in re.findall(r"[A-Za-z]{4,}", t)}
for i in range(len(out)):
if out[i] != sentences[i] or (refs[i] and out[i] != refs[i]):
out[i] = CL.match_spelling(out[i], vocab)
known = [i for i in range(len(out)) if refs[i] and refs[i].strip() != out[i].strip() and len(out[i].split()) >= 4]
if known and CL.ENABLED and PS.get("final") == "restore":
failed = [(i, why) for i, why in zip(known, CL.assess([(refs[i], out[i]) for i in known])) if why]
lexv = LX.variants([refs[i] for i, _ in failed], glossary or set(), n=LEX_N) if failed and LX.available() \
else [[] for _ in failed]
cands = [(i, v) for (i, _), vs in zip(failed, lexv) for v in vs]
good = {}
for (i, v), why in zip(cands, CL.assess([(refs[i], v) for i, v in cands]) if cands else []):
if not why and i not in good:
good[i] = v
for i, why in failed:
print(f"[clarity] {'new vocabulary' if i in good else 'restored original'} ({why}): "
f"{out[i][:90]!r}", flush=True)
out[i] = good.get(i, refs[i])
stats["revocab" if i in good else "restored"] = stats.get("revocab" if i in good else "restored", 0) + 1
CL.report()
elif known:
sc = FL.scores([out[i] for i in known] + [refs[i] for i in known])
m = len(known)
for k, i in enumerate(known):
broken = sc[k] < FL.MIN or (FL.doubled_word(out[i]) and not FL.doubled_word(refs[i]))
if broken and (sc[m + k] >= sc[k] + 0.15 or FL.doubled_word(out[i])):
out[i] = refs[i]
stats["restored"] = stats.get("restored", 0) + 1
out = [TP.match_ending(a, b) for a, b in zip(sentences, out)] # fragments stay fragments (no added full stop)
n = sum(1 for a, b in zip(sentences, out) if a.strip() != b.strip())
parts = [f"{stats[k]} {lbl}" for k, lbl in (("revocab", "unclear sentence(s) rewritten with suitable vocabulary"),
("repaired", "unclear sentence(s) repaired to the original meaning"),
("restored", "restored to your original wording"),
("edited", "edited for wording and flow")) if stats.get(k)]
head = (f" 📝 Final polish (clarity, grammar, academic wording, flow): {n} sentence(s) improved"
+ (f" ({'; '.join(parts)})." if parts else ".")) if n else " 📝 Final polish: no changes needed."
return out, n, head + note
def originals_for(new_sents, orig_paragraph):
"""Per sentence of a rewritten paragraph: (original paragraph, original sentence or None). The sentence is
known only when the original paragraph has the same number of sentences (one-to-one rewrite)."""
if not orig_paragraph:
return [(None, None)] * len(new_sents)
os_ = split_sentences(orig_paragraph) or [orig_paragraph]
same = len(os_) == len(new_sents)
return [(orig_paragraph, os_[k] if same else None) for k in range(len(new_sents))]
def grammar_pass(paragraphs, progress=None, lo=0.9, hi=0.95, originals=None):
"""Final polish of whole paragraphs, sentence by sentence. `originals` (same order as `paragraphs`) are the
author's original paragraphs, used to repair garbled sentences. Returns (paragraphs, n_fixed, note)."""
groups, sents, op, os_ = [], [], [], []
if originals is not None and len(originals) != len(paragraphs):
originals = None
for k, p in enumerate(paragraphs):
ss = split_sentences(p) or [p]
groups.append((len(sents), len(sents) + len(ss), len(ss) > 1 or ss[0] != p))
sents.extend(ss)
for a_, b_ in originals_for(ss, originals[k] if originals is not None else None):
op.append(a_)
os_.append(b_)
para_of = [k for k, (a, b, _) in enumerate(groups) for _ in range(a, b)]
fixed, n, note = grammar_fix_sentences(sents, None, progress, lo, hi, groups=para_of, orig_par=op, orig_sent=os_)
if not n:
return list(paragraphs), 0, note
out = []
for (a, b, split), p in zip(groups, paragraphs):
same = all(fixed[k] == sents[k] for k in range(a, b))
out.append(p if same else " ".join(fixed[a:b]))
return out, n, note
def paraphrase_paragraphs(texts, temperature, progress=None, lo=0.15, hi=0.85, mode="Auto"):
"""Returns (paragraphs, engine_note). Parrot rewrites; Groq (if GROQ_API_KEY is set) checks and repairs
Parrot's academic/technical wording; the local guard has the final say. Finally (unless Smart Humanize
defers it to its own end) Groq corrects the grammar of the finished paragraphs."""
out, note = _paraphrase_local(texts, temperature, progress, lo, hi, mode)
if GRAMMAR and not GRAMMAR_DEFER.get():
out, _, gnote = grammar_pass(out, progress, hi, min(0.97, hi + 0.08), originals=list(texts))
note += gnote
return out, note
def _preview(pairs, limit=10):
blocks = []
for i, (before, after) in enumerate(pairs[:limit], 1):
blocks.append(
f"#### Change {i}\n**Original:** {before}\n\n**Paraphrased:** {after}"
)
return "\n\n---\n\n".join(blocks) if blocks else "No textual changes were generated."
# ---------------------------------------------------------------------------
# Handlers
# ---------------------------------------------------------------------------
def _docx_core(file_path, temperature, min_words, mode, progress):
if not file_path:
raise gr.Error("Please upload a .docx file.")
source_path = Path(file_path)
if source_path.suffix.lower() != ".docx":
raise gr.Error("Only .docx Word documents are supported.")
progress(0.05, desc="Opening Word document")
doc = Document(str(source_path))
TP.set_document([p.text for p in collect_paragraphs(doc)]) # constructs / vocabulary of the whole document
import detector as _D
targets, originals = [], []
in_refs = False
for paragraph in collect_paragraphs(doc):
text = paragraph.text
if _D.REF_HEAD.match(text): # reference list: never rewritten
in_refs = True
continue
if in_refs or (_D.REF_ENTRY.match(text) and _D.REF_HINT.search(text)):
continue
if paragraph._p.xpath(".//*[local-name()='oMath' or local-name()='oMathPara']"):
continue # inline equations: rewriting would move them
if should_process(text, min_words):
targets.append(paragraph)
originals.append(text)
if not originals:
raise gr.Error("No eligible prose paragraphs were found in the document.")
revised, engine_note = paraphrase_paragraphs(originals, temperature, progress, mode=mode)
progress(0.9, desc="Injecting revised text into the original layout")
changed, pairs, final_after = 0, [], []
for paragraph, before, after in zip(targets, originals, revised):
after = (after or "").strip()
if after and after != before.strip():
replace_text_preserving_paragraph(paragraph, after)
changed += 1
pairs.append((before, after))
final_after.append(after)
else:
final_after.append(before)
output_path = _save_output(doc, source_path.stem)
progress(1.0, desc="Completed")
summary = (
f"✅ **Completed** — {changed} of {len(originals)} eligible paragraphs rewritten. "
f"Layout, styles, tables, images and headers/footers were kept in place.\n\n{engine_note}"
)
scores = score_panel("\n".join(originals), "\n".join(final_after))
return output_path, download_button(output_path), summary, scores, _preview(pairs)
def _text_core(text, temperature, min_words, mode, progress, max_words=None):
text = (text or "").strip()
if not text:
raise gr.Error("Please paste some text to paraphrase.")
n_words = len(text.split())
if n_words > (max_words or MAX_PASTE_WORDS):
raise gr.Error(
f"Pasted text has {n_words} words. Please keep it under {max_words or MAX_PASTE_WORDS} words, "
"or upload it as a .docx instead."
)
progress(0.05, desc="Reading pasted text")
parts = TP.parse_paste(text)
paragraphs = [p for p, _ in parts]
TP.set_document(paragraphs)
idx = [i for i, p in enumerate(paragraphs) if should_process(p, min_words)]
if not idx:
raise gr.Error(f"No paragraph has at least {int(min_words)} words of prose. Lower the minimum and try again.")
revised, engine_note = paraphrase_paragraphs([paragraphs[i] for i in idx], temperature, progress, mode=mode)
result = list(paragraphs)
pairs = []
for i, after in zip(idx, revised):
after = (after or "").strip()
if after and after != paragraphs[i]:
result[i] = after
pairs.append((paragraphs[i], after))
output_text = TP.join_paste([(p, sep) for p, (_, sep) in zip(result, parts)])
progress(0.95, desc="Preparing download")
doc = Document()
for p in result:
if p:
doc.add_paragraph(p)
output_path = _save_output(doc, "pasted_text")
progress(1.0, desc="Completed")
summary = f"✅ **Completed** — {len(pairs)} of {len(idx)} eligible paragraphs rewritten ({n_words} words in).\n\n{engine_note}"
scores = score_panel(
"\n".join(paragraphs[i] for i in idx),
"\n".join(result[i] for i in idx),
)
return output_text, output_path, download_button(output_path), summary, scores
# Single branded progress panel (replaces Gradio's per-component loaders)
# ---------------------------------------------------------------------------
class Cancelled(Exception):
"""The customer pressed Stop & reset: the job ends at its next progress step."""
class _Reporter:
def __init__(self):
self.frac, self.desc, self.start = 0.0, "Starting…", time.time()
self.cancel = threading.Event()
def __call__(self, frac, desc=None):
if self.cancel.is_set():
raise Cancelled("Stopped by you.")
self.frac = max(self.frac, min(1.0, float(frac)))
if desc:
self.desc = desc
def _md2html(md):
html = re.sub(r"\*\*(.+?)\*\*", r"<b>\1</b>", md)
return html.replace("\n\n", "<br><br>").replace("\n", "<br>")
def status_idle(msg):
return f'<div class="cs-status cs-idle">{_md2html(PV.clean(msg))}</div>'
def status_done(msg):
return f'<div class="cs-status cs-done">{_md2html(PV.clean(msg))}</div>'
def status_error(msg):
return f'<div class="cs-status cs-err">⚠️ {PV.clean(str(msg))}</div>'
def status_running(rep):
pct = round(rep.frac * 100)
secs = int(time.time() - rep.start)
return f"""
<div class="cs-status cs-run">
<div class="cs-spin-logo"></div>
<div class="cs-st-text">{PV.clean(rep.desc)}</div>
<div class="cs-bar"><div style="width:{pct}%"></div></div>
<div class="cs-st-meta">{pct}% · {secs}s</div>
</div>"""
# ---------------------------------------------------------------------------
# Background jobs that survive a dropped connection
# A job runs in its own thread, independent of the browser connection. If the connection drops (mobile data,
# switching apps, Wi-Fi hiccup) the job keeps going; when the page reconnects and the same request is sent again
# (the page does this automatically), the running job is picked up again instead of starting over, and a
# finished job's result is shown straight away. Finished jobs are kept for JOB_KEEP seconds.
# ---------------------------------------------------------------------------
JOB_KEEP = int(os.getenv("JOB_KEEP_SECONDS", str(3 * 3600)))
_JOBS = {}
_JOBS_LOCK = threading.Lock()
class Job:
def __init__(self, key, owner=None):
self.key, self.rep, self.box = key, _Reporter(), {}
self.owner = owner
self.id = secrets.token_hex(4)
self.meta, self.extra = {}, {}
self.done = threading.Event()
self.delivered = False
self.created, self.finished_at = time.time(), None
def _purge_jobs():
now = time.time()
for k in [k for k, j in _JOBS.items() if j.finished_at and now - j.finished_at > JOB_KEEP]:
_JOBS.pop(k, None)
def find_job(key):
"""The running or recently finished (successful) job for `key`, or None."""
if not key:
return None
with _JOBS_LOCK:
_purge_jobs()
return _JOBS.get(key)
def cancel_jobs(owner):
"""Stop every running job of this visitor (Stop & reset). Returns how many were stopped."""
if not owner:
return 0
n = 0
with _JOBS_LOCK:
for k, j in list(_JOBS.items()):
if j.owner == owner and not j.done.is_set():
j.rep.cancel.set()
_JOBS.pop(k, None) # a new run starts afresh instead of re-attaching
n += 1
return n
def start_job(key, core, args, on_finish=None, owner=None):
"""Run core(*args, rep) in the background. on_finish(job) runs once when it ends (even if nobody is
watching any more) — used for logging, usage counting and the "ready" push notification."""
job = Job(key, owner)
with _JOBS_LOCK:
_purge_jobs()
if key:
_JOBS[key] = job
job.rep.meta = job.meta
def work():
try:
job.box["result"] = core(*args, job.rep)
except Cancelled:
job.box["error"] = gr.Error("Stopped. Nothing was charged; start again whenever you like.")
except Exception as err:
job.box["error"] = err
job.finished_at = time.time()
if "error" in job.box and key:
with _JOBS_LOCK: # a failed job is not reused: a retry starts afresh
if _JOBS.get(key) is job:
_JOBS.pop(key, None)
if on_finish is not None:
try:
on_finish(job)
except Exception as err:
print(f"[job] finish step failed: {err}", flush=True)
job.done.set()
# copy_context: the worker thread keeps the Gradio request context, so ZeroGPU knows WHO the
# visitor is (their own quota and queue priority) instead of treating every run as anonymous
ctx = contextvars.copy_context()
threading.Thread(target=ctx.run, args=(work,), daemon=True).start()
return job
def follow(job, n_outputs, status_index):
"""Stream the status panel of `job` until it ends, then yield its result (or raise its error)."""
keep = [gr.update()] * n_outputs
while not job.done.is_set():
frame = list(keep)
frame[status_index] = status_running(job.rep)
yield tuple(frame)
job.done.wait(timeout=0.6)
if "error" in job.box:
err = job.box["error"]
frame = list(keep)
frame[status_index] = status_error(str(err) or "Something went wrong.")
yield tuple(frame)
if isinstance(err, gr.Error):
raise err
raise gr.Error(f"Processing failed: {err}")
yield job.box["result"]
def _run_live(core, args, n_outputs, status_index):
"""Run `core` in a thread and stream only the status panel until it finishes."""
yield from follow(start_job(None, core, args), n_outputs, status_index)
def _save_output(doc, stem, suffix="_paraphrased"):
"""Save to a unique folder with a clean, readable file name."""
safe = re.sub(r"[^A-Za-z0-9_-]+", "_", stem).strip("_") or "document"
folder = Path(tempfile.mkdtemp(dir=OUT_DIR))
path = folder / f"{safe}{suffix}.docx"
doc.save(str(path))
return str(path)
_MIME = {".docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
".pdf": "application/pdf"}
def download_button(path):
"""Direct download link embedded in the page (works even when the file widget's link is
blocked, e.g. inside iframes / custom domains)."""
if not path or not Path(path).exists():
return ""
data = base64.b64encode(Path(path).read_bytes()).decode()
mime = _MIME.get(Path(path).suffix.lower(), "application/octet-stream")
name = Path(path).name
return (f'<a class="cs-dl" download="{name}" href="data:{mime};base64,{data}">'
f'⬇️ Download {name}</a>')
def count_words(text, limit=None):
limit = limit or MAX_PASTE_WORDS
n = len((text or "").split())
color = BRAND["red"] if n > limit else BRAND["grey"]
return f"<div class='cs-count' style='color:{color}'>{n:,} / {limit:,} words</div>"