""" Smart Humanize = detector + humanizer working together. Loop (up to `passes` times): 1. score every sentence with the RoBERTa AI detector 2. stop if the AI % is at/below the target (checked from pass 2 on, so every run rewrites at least once) 3. paraphrase ONLY the sentences the detector flags (P(AI) >= moderate threshold), with slightly bolder variation on each later pass 4. write the new sentences back in place (Word runs outside the sentence keep their formatting) Then score once more and report before/after. """ import io import os import re import time import gradio as gr from docx.oxml.ns import qn import detector as D import textparse as TP import docx_report as DX import humanizer as H import groqai as G import report_flags as R import restructure as RS import adaptive as AD import meaning as MEANING import profile_settings as PS import fluency as FL W_T = qn("w:t") _XML_SPACE = "{http://www.w3.org/XML/1998/namespace}space" _UNSAFE_ANCESTORS = {qn("w:fldSimple"), qn("w:hyperlink"), qn("w:ins"), qn("w:smartTag"), qn("w:sdtContent")} from textparse import guard as _guard # noqa: E402 (shared with the Humanizer) def _rewrite(sentences, temperature, mode, rep, lo, hi): if not sentences: return [], "" out, note = H.paraphrase_paragraphs(sentences, temperature, rep, lo=lo, hi=hi, mode=mode) return [_guard(s, n) for s, n in zip(sentences, out)], note # ---------------------------------------------------------------- docx: in-place span replacement _MATH_TAGS = {"{http://schemas.openxmlformats.org/officeDocument/2006/math}oMath", "{http://schemas.openxmlformats.org/officeDocument/2006/math}oMathPara"} def math_inside(p_el, a, b): """True if an equation sits inside the sentence at [a, b) (such sentences are never rewritten).""" return any(r is None and a <= s < b for r, s, _, _ in _run_map(p_el)) def _run_map(p_el): """[(run, start, end, safe)] over DX.para_text(p_el).""" items, pos, depth = [], 0, 0 runs = DX.para_runs(p_el) keep = set(runs) seq = [el for el in p_el.iter() if el in keep or el.tag in _MATH_TAGS] for r in seq: if r.tag in _MATH_TAGS: # an equation: a barrier no rewrite may cross if not items or items[-1][0] is not None or items[-1][1] != pos: items.append((None, pos, pos, False)) continue for fc in r.iter(qn("w:fldChar")): t = fc.get(qn("w:fldCharType")) depth += 1 if t == "begin" else -1 if t == "end" else 0 t = DX.run_text(r) s, e = pos, pos + len(t) pos = e safe = DX._simple(r) and depth == 0 if safe: a = r.getparent() while a is not None and a.tag != qn("w:p"): if a.tag in _UNSAFE_ANCESTORS: safe = False break a = a.getparent() rpr = r.find(qn("w:rPr")) if safe and rpr is not None and rpr.find(qn("w:vertAlign")) is not None: safe = False # superscript citations, formulas items.append((r, s, e, safe)) return items _FMT_TAGS = tuple(qn("w:" + t) for t in ("b", "i", "u", "strike", "dstrike", "caps", "smallCaps", "color", "highlight", "shd", "vertAlign", "rStyle", "sz", "rFonts", "vanish")) def _fmt(r): """Visible formatting of a run (bold, italic, underline, colour, font, size, style ...).""" rpr = r.find(qn("w:rPr")) if rpr is None: return () return tuple(sorted((c.tag, tuple(sorted(c.attrib.items()))) for c in rpr if c.tag in _FMT_TAGS)) def _replace_keeping_format(hit, a, b, new): """Replace [a, b) when the sentence mixes formats (a bold term, an italic model name): a formatted run whose text is still in the new sentence word for word keeps its run and its formatting; the text between such runs goes into the plain runs between them. Returns False (nothing changed) when the new sentence cannot be laid out that way, and the caller then writes it in the sentence's first run.""" segs = [] # [run, w:t, text before the span, text in it, text after] for r, s0, e0, _ in hit: te = r.find(W_T) t = te.text or "" lo, up = max(0, a - s0), min(len(t), b - s0) segs.append((r, te, t[:lo], t[lo:up], t[up:])) weight = {} for r, _, _, seg, _ in segs: weight[_fmt(r)] = weight.get(_fmt(r), 0) + len(seg) base = max(weight, key=weight.get) # the sentence's own format: the one most of it is in anchors, pos = {}, 0 for i, (r, _, _, seg, _) in enumerate(segs): core = seg.strip() if _fmt(r) == base or len(core) < 2: continue j = new.find(core, pos) while j >= 0 and ((j > 0 and new[j - 1].isalnum() and core[0].isalnum()) or (j + len(core) < len(new) and new[j + len(core)].isalnum() and core[-1].isalnum())): j = new.find(core, j + 1) # inside another word: not the same term if j >= 0: anchors[i] = (j, j + len(core)) pos = j + len(core) if not anchors: return False out, prev_i, prev_end = [""] * len(segs), -1, 0 for i in sorted(anchors) + [len(segs)]: gap = new[prev_end:anchors[i][0]] if i < len(segs) else new[prev_end:] zone = [k for k in range(prev_i + 1, i) if _fmt(segs[k][0]) == base] lead = "" if gap: if zone: out[zone[0]] = gap elif gap.strip(): return False # words with no plain run to hold them elif i < len(segs): lead = gap # only a space: it stays next to the formatted term else: out[prev_i] += gap if i < len(segs): out[i] = lead + new[anchors[i][0]:anchors[i][1]] prev_i, prev_end = i, anchors[i][1] for i, (r, te, before, _, after) in enumerate(segs): te.text = before + out[i] + after te.set(_XML_SPACE, "preserve") if i > 0 and not te.text and r.getparent() is not None: r.getparent().remove(r) return True def replace_span(p_el, a, b, new): """Replace characters [a, b) of the paragraph text. Returns False (and leaves the paragraph untouched) if the span crosses fields, hyperlinks, sub/superscripts or non-text runs.""" items = _run_map(p_el) if any(r is None and a <= s < b for r, s, _, _ in items): return False # an equation inside the span hit = [(r, s, e, safe) for r, s, e, safe in items if r is not None and e > a and s < b and e > s] if not hit or not all(x[3] for x in hit): return False if len({_fmt(x[0]) for x in hit}) > 1 and _replace_keeping_format(hit, a, b, new): return True for i, (r, s, e, _) in enumerate(hit): te = r.find(W_T) t = te.text or "" before = t[:max(0, a - s)] after = t[b - s:] if b < e else "" te.text = (before + new + after) if i == 0 else after te.set(_XML_SPACE, "preserve") if i > 0 and not te.text and r.getparent() is not None: r.getparent().remove(r) # drop runs emptied by the merge return True _REASONS = [(qn("w:hyperlink"), "link"), (qn("w:fldSimple"), "citation field"), (qn("w:ins"), "tracked change"), (qn("w:sdtContent"), "content control"), (qn("w:smartTag"), "smart tag")] def lock_reason(p_el, a, b): """Why the sentence at [a, b) cannot be edited safely (shown to the customer).""" reasons = [] items = _run_map(p_el) if any(r is None and a <= s < b for r, s, _, _ in items): reasons.append("equation") for r, s, e, safe in items: if r is None or safe or not (e > a and s < b and e > s): continue anc, why = r.getparent(), None while anc is not None and anc.tag != qn("w:p") and why is None: why = dict(_REASONS).get(anc.tag) anc = anc.getparent() if why is None: rpr = r.find(qn("w:rPr")) if rpr is not None and rpr.find(qn("w:vertAlign")) is not None: why = "superscript / subscript" elif r.find(qn("w:fldChar")) is not None or r.find(qn("w:instrText")) is not None: why = "citation field" elif any(c.tag in (qn("w:tab"), qn("w:br"), qn("w:cr")) for c in r): why = "tab or line break inside the sentence" elif r.find(qn("w:t")) is None: why = "special object (symbol, footnote mark or image)" else: why = "citation field" # text inside a field (e.g. Zotero / EndNote / Mendeley) if why not in reasons: reasons.append(why) return ", ".join(reasons) or "special formatting" def safe_subspan(p_el, a, b, min_words=5): """Largest part of [a, b) that lies in plain text runs (so a sentence ending in a superscript citation or a field can still have its prose rewritten).""" items = _run_map(p_el) if any(r is None and a <= s < b for r, s, _, _ in items): return None # sentences with an equation are never rewritten runs = [(s, e, safe) for r, s, e, safe in items if r is not None and e > a and s < b and e > s] if runs and all(x[2] for x in runs): return a, b best, cur = None, None for s, e, safe in runs + [(b, b, False)]: if safe: cur = (max(a, s) if cur is None else cur[0], min(b, e)) elif cur is not None: if best is None or cur[1] - cur[0] > best[1] - best[0]: best = cur cur = None if best is None: return None raw = DX.para_text(p_el) x, y = best while x < y and raw[x].isspace(): x += 1 while y > x and raw[y - 1].isspace(): y -= 1 frag = raw[x:y] if len(frag.split()) < min_words or (y - x) < 0.5 * (b - a): return None return x, y # ---------------------------------------------------------------- scoring helpers def _score_doc(doc): blocks = D.docx_blocks(doc) if not any(b.kind == "para" for b in blocks): raise gr.Error("No readable sentences were found in this document. If the text is inside images, " "text boxes or a scanned page, paste it into the text tab instead.") D.analyse(blocks) return blocks def _score_paras(paras): blocks = D.classify([D.Block(D._norm(p)) for p in paras], D.PASTE_MIN_WORDS) if not any(b.kind == "para" for b in blocks): raise gr.Error("No qualifying prose found — paste full sentences (headings and short " "fragments are not analysed).") D.analyse(blocks) return blocks def detector_pct_text(text, hi=0.80, mod=0.50): try: paras = [p for p, _ in TP.parse_paste(text)] return D.summarise(_score_paras(paras), hi, mod)["pct"] except Exception: return None def detector_pct_docx(path, hi=0.80, mod=0.50): try: return D.summarise(_score_doc(DX.open_clean(path)), hi, mod)["pct"] except Exception: return None # ---------------------------------------------------------------- pipelines def _log_line(k, pct, n_flag=None, n_done=None, n_locked=0): s = f"Pass {k}: AI score {pct}%" if n_flag is not None: s += f" → rewrote {n_done} of {n_flag} " + ("sentences (100% AI mode)" if _full_ai() else f"sentences (vocabulary {_vocab():.0f}%)" if _vocab() else "flagged sentences") if n_locked: s += f" ({n_locked} skipped: citation fields / links / sub-superscripts)" return s def _rewrite_retry(sentences, temperature, mode, rep, lo, hi): """Paraphrase; sentences that come back unchanged or rejected get one bolder retry. 100% AI mode: the humanizer already re-runs weak sentences itself (1 extra round per pass here, since every Smart pass rewrites again), so the separate retry is skipped — it only repeated the same work.""" if _full_ai(): tok = H.EXTRA_ROUNDS.set(int(__import__("os").getenv("SMART_AI_ROUNDS", "1"))) try: return _rewrite(sentences, temperature, mode, rep, lo, hi) finally: H.EXTRA_ROUNDS.reset(tok) new, note = _rewrite(sentences, temperature, mode, rep, lo, hi) redo = [i for i, (s, n) in enumerate(zip(sentences, new)) if not n or n.strip() == s.strip()] if redo: again, _ = _rewrite([sentences[i] for i in redo], min(1.5, temperature + 0.3), mode, rep, hi, hi) for i, n in zip(redo, again): if n and n.strip() != sentences[i].strip(): new[i] = n return new, note # setting groups of one pass rewritten at the same time (SMART_PARALLEL_GROUPS=1 = one after another) # OFF by default: the checking models' fast tokenizers are not thread-safe ("Already borrowed" when two groups # tokenize with the same model at once, 2026-10-19 on the live Space). Only with per-model locks could this be on. PARALLEL_GROUPS = max(1, int(os.getenv("SMART_PARALLEL_GROUPS", "1"))) def _adaptive(fn, sentences, temperature, mode, rep, lo, hi): """Self-adaptive pass: every sentence gets the setting its own history in this document calls for (adaptive.settings): bolder (higher temperature + detector-guided decoding) when its AI score did not fall last pass, calmer when its rewrites keep failing the checks, left as written after ADAPT_MAX_FAILS failures. Sentences with the same setting are rewritten together.""" anchors = [H.anchor_of(t) or t for t in sentences] sets = [AD.settings(a) for a in anchors] groups = {} for i, (dt, bold, skip) in enumerate(sets): if not skip: groups.setdefault((round(dt, 2), bold), []).append(i) new, note = [None] * len(sentences), "" total = max(1, sum(len(v) for v in groups.values())) specs, pos = [], 0 for (dt, bold), idx in sorted(groups.items()): specs.append((dt, bold, idx, lo + (hi - lo) * pos / total, lo + (hi - lo) * (pos + len(idx)) / total)) pos += len(idx) def run(spec, rep_): dt, bold, idx, a_, b_ = spec tok = H.FORCE_ADV.set(bool(bold)) try: return fn([sentences[i] for i in idx], min(1.5, max(0.3, temperature + dt)), mode, rep_, a_, b_) finally: H.FORCE_ADV.reset(tok) if PARALLEL_GROUPS > 1 and len(specs) > 1: # The groups are independent (different sentences, own settings). One after another, each paid the fixed # cost of a rewrite call (model, checks, several Groq round trips) in turn; together they overlap. Each runs # in a copy of this job's context (its account, glossary, settings); the bar only moves forward. import concurrent.futures as _cf import contextvars as _cv bar = {"at": 0.0} def forward(frac, desc=""): if frac >= bar["at"]: bar["at"] = frac rep(frac, desc) with _cf.ThreadPoolExecutor(max_workers=min(PARALLEL_GROUPS, len(specs))) as pool: outs = list(pool.map(lambda sp: _cv.copy_context().run(run, sp, forward), specs)) else: outs = [run(sp, rep) for sp in specs] for (_, _, idx, _, _), (out, n_) in zip(specs, outs): note = note or n_ for i, o in zip(idx, out): new[i] = o for a, t, n in zip(anchors, sentences, new): AD.outcome(a, bool(n and n.strip() != t.strip())) skipped = sum(1 for x in sets if x[2]) if skipped: note += f" 🧠 {skipped} sentence(s) kept as written: their rewrites failed the checks in {AD.MAX_FAILS} passes." return new, note def _full_ai(): return bool(H.FULL_AI.get()) def _vocab(): """Vocabulary change (%) slider value (0 = off).""" return float(H.VOCAB_TARGET.get() or 0) def _all_sentences(): """Rewrite every sentence, not only flagged ones: 100% AI box ticked, or a vocabulary level is set.""" return _full_ai() or _vocab() > 0 def _target_change(): return H.change_target() if _full_ai() else _vocab() REPORT_VOCAB = PS.get("report_vocab") # AI report: at least this much new wording is aimed for in flagged sentences REPORT_VOCAB_SIM = PS.get("report_vocab_sim") # similarity report: copied wording is exactly what is matched, so aim higher REPORT_EXTRA = PS.get("report_extra") # Automatic passes: Smart Humanize keeps running passes (each one rewriting what is still flagged, starting from the # previous pass's output) until the AI score reaches the target (0% by default), until STALL_PASSES passes in a row # bring no improvement, or until TIME_BUDGET seconds have passed. The plan sets the most passes allowed. STALL_PASSES = int(os.getenv("SMART_STALL_PASSES", "2")) TIME_BUDGET = float(os.getenv("SMART_TIME_BUDGET", "1500")) # follow-up rounds for weak sentences # When a pass brings no improvement, the still-flagged sentences get the deeper rewrite (humanizer.BOOST) for the # remaining passes, so the customer never has to choose a mode. SMART_AUTO_DEEP=0 = off. AUTO_DEEP = os.getenv("SMART_AUTO_DEEP", "1") != "0" def _report_rewrite(sentences, temperature, mode, rep, lo, hi): """Report-flagged sentences get the strongest rewrite: 1. restructured first (clause order, splits, openers; rule-based), 2. Parrot with the 100% AI settings (more versions, plain-wording options, more rounds, >= 60% new-wording target), 3. follow-up rounds for every sentence that is still close to the original (less than half new wording) or had no safe rewrite: Parrot rewrites its own latest version with more variation each round, so the changes stack up. Per sentence, the version with the MOST new wording that still passes the check against the ORIGINAL sentence (terms, numbers, citations, length) is kept.""" gl = TP.narrow(TP.GLOSSARY.get()) # protect real technical terms, not everyday words guard = lambda o, n: TP.guard(o, TP.restore_terms(o, n, gl), gl) if n else None t_gl, t_nw = TP.GLOSSARY.set(gl), TP.NARROW.set(True) ov = lambda o, n: H.similarity_to_source(o, n) / 100.0 shaped = [RS.restructure(s, guard, H.similarity_to_source) for s in sentences] t1 = H.FULL_AI.set(True) rpt = R.ACTIVE.get() level = REPORT_VOCAB_SIM if (rpt is not None and rpt.kind == "Similarity") else REPORT_VOCAB t2 = H.VOCAB_TARGET.set(max(float(H.VOCAB_TARGET.get() or 0), level)) try: new, note = _rewrite(shaped, temperature, mode, rep, lo, hi) new = [guard(o, n) for o, n in zip(sentences, new)] new = [n if n and m and f else None for n, m, f in zip(new, MEANING.keeps_meaning([(o, n or o) for o, n in zip(sentences, new)]), FL.acceptable([(o, n or o) for o, n in zip(sentences, new)]))] best = [n if n else sh for n, sh in zip(new, shaped)] # failed a check: restructured (or original) cur = list(best) # chain: next round rewrites the latest version for r in range(1, REPORT_EXTRA + 1): weak = [i for i, (o, b) in enumerate(zip(sentences, best)) if b.strip() == o.strip() or ov(o, b) >= R.KEEP_OVERLAP] if not weak: break if rep is not None: rep(hi, desc=f"Extra rewrite round {r}: {len(weak)} sentence(s) still close to the original") # always from the ORIGINAL sentence: rewriting a rewrite is what breaks grammar and technical meaning again, _ = _rewrite([sentences[i] for i in weak], min(1.5, float(temperature) + 0.15 * r), mode, rep, hi, hi) again = [guard(sentences[i], n) for i, n in zip(weak, again)] same = [a_ and b_ for a_, b_ in zip( MEANING.keeps_meaning([(sentences[i], n or sentences[i]) for i, n in zip(weak, again)]), FL.acceptable([(sentences[i], n or sentences[i]) for i, n in zip(weak, again)]))] for i, ok, m in zip(weak, again, same): if not ok or not m: # chained rounds drift: compare with the ORIGINAL continue cur[i] = ok if best[i].strip() == sentences[i].strip() or ov(sentences[i], ok) < ov(sentences[i], best[i]): best[i] = ok # final meaning check against the ORIGINAL (the chained rounds can drift): Groq rejects a changed meaning idx = [i for i, (o, b) in enumerate(zip(sentences, best)) if b.strip() != o.strip()] if idx and G.available(): try: res = G.review([(sentences[i], best[i]) for i in idx], gl, rep, hi, hi) dropped = 0 for i, r in zip(idx, res): o = sentences[i] if r is None: # meaning changed: fall back to the safe restructured form best[i] = shaped[i] if shaped[i] != o else o dropped += 1 elif r: ok = guard(o, r) # Groq's minimal repair, if it keeps the new wording if ok and ov(o, ok) <= ov(o, best[i]) + 0.15 and MEANING.ok(o, ok): best[i] = ok if dropped: print(f"[report] {dropped} rewrite(s) rejected by the meaning check", flush=True) except G.Unavailable as err: print(f"[report] meaning check skipped: {err}", flush=True) # grammar now, so the results table shows exactly the wording that goes into the document idx = [i for i, (o, b) in enumerate(zip(sentences, best)) if b.strip() != o.strip()] if idx and H.GRAMMAR: fixed, _, _ = H.grammar_fix_sentences([best[i] for i in idx], gl, rep, hi, hi, orig_sent=[sentences[i] for i in idx]) for i, f in zip(idx, fixed): ok = guard(sentences[i], f) if ok and MEANING.ok(sentences[i], ok): best[i] = ok finally: H.FULL_AI.reset(t1) H.VOCAB_TARGET.reset(t2) TP.GLOSSARY.reset(t_gl) TP.NARROW.reset(t_nw) return best, note def _report_summary(rpt, rows): """Report-guided run -> dict for the result panel: report score, estimate, and one row per flagged sentence (report sentence, highlight colour, final sentence, status).""" if rpt is None or rows is None: return None for r in rows: # still likely flagged: kept, or less than half the wording changed lim = 40 if rpt.kind == "Similarity" else R.KEEP_OVERLAP * 100 r["likely"] = r["status"] != "rephrased" or H.similarity_to_source(r["orig"], r["new"]) >= lim pairs = [(r["orig"], r["new"]) for r in rows] return dict(score=rpt.score, kind=rpt.kind, tool=rpt.tool, ocr=rpt.used_ocr, est=R.estimate_after(rpt.score, pairs, rpt.kind), rows=rows, flagged=len(rows), rephrased=sum(1 for r in rows if r["status"] == "rephrased"), likely=sum(1 for r in rows if r["likely"])) def _not_lower(pct, prev): """A pass is undone only when it made the AI score worse. (Undoing passes that kept the same score threw away sentence-level progress, so later passes started from the original again and flagged text stayed unchanged.)""" return pct > prev def _done_early(k, pct, target, chg): if R.ACTIVE.get() is not None: # report-guided: always rewrite what the report flagged return False return _done_early_inner(k, pct, target, chg) def _done_early_inner(k, pct, target, chg): """Normal mode: stop when the AI score is 0 or (from pass 2) at/below the target. 100% AI mode: at least one rewrite, and the new-wording target must also be reached.""" if not _all_sentences(): return pct == 0 or (k > 1 and pct <= target) return k > 1 and pct <= target and chg is not None and chg >= _target_change() def _better(cand, best, k): """cand/best = (pct, change, payload). Normal mode: lowest AI score (ties -> the rewritten version). 100% AI mode: the original is never returned; versions reaching the new-wording target come first, then the lowest AI score; below the target, the version with the most new wording wins.""" if best is None: return True if R.ACTIVE.get() is not None: # report-guided: the rewritten version is the result return k > 1 if not _all_sentences(): return cand[0] < best[0] or (k > 1 and cand[0] == best[0]) if best[1] is None: # best is still the untouched original return k > 1 t = _target_change() ca, ba = cand[1] >= t, best[1] >= t if ca != ba: return ca if ca: # both reach the level: lowest AI score, then more change return (cand[0], -cand[1]) <= (best[0], -best[1]) # neither reaches the level: never trade AI score for wording; within 2 points, more new wording wins if cand[0] < best[0] - 2: return True if cand[0] > best[0] + 2: return False return cand[1] >= best[1] def _grammar_docx(doc, rep, orig_path=None): """Final Groq grammar check of every sentence in the document's prose, written back in place (only plain text runs are touched, so citation fields, links and sub/superscripts keep their formatting).""" jobs, op, os_ = [], [], [] blocks = list(D.docx_blocks(doc)) orig = None if orig_path: # the author's original paragraphs, block by block try: ob = list(D.docx_blocks(DX.open_clean(orig_path))) orig = ob if len(ob) == len(blocks) else None except Exception as err: print(f"[smart] original paragraphs not available for the final polish: {err}", flush=True) for bi, b in enumerate(blocks): if b.kind != "para" or b.el is None or not b.raw: continue raws = D.split_sentences(b.raw) o_raw = orig[bi].raw if orig is not None and orig[bi].kind == "para" else None ctx = H.originals_for(raws, o_raw) for sp, (cp, cs) in zip(DX.sentence_char_spans(b.raw, raws), ctx): if not sp: continue sub = safe_subspan(b.el, *sp) if sub: jobs.append((b, sub, b.raw[sub[0]:sub[1]])) op.append(cp) os_.append(cs if sub == tuple(sp) or list(sub) == list(sp) else None) if not jobs: return 0, "" fixed, n, note = H.grammar_fix_sentences([j[2] for j in jobs], None, rep, 0.9, 0.95, groups=[id(j[0]) for j in jobs], orig_par=op, orig_sent=os_) done = 0 for (b, (a, e), t), f in sorted(zip(jobs, fixed), key=lambda x: (id(x[0][0]), -x[0][1][0])): if f != t and replace_span(b.el, a, e, f): done += 1 if done: m = re.search(r"improved \((.*?)\)\.", note or "") return done, (f" 📝 Final polish by Groq (clarity, grammar, academic wording, flow): {done} sentence(s) " f"improved" + (f" ({m.group(1)})." if m else ".")) return 0, note if "unavailable" in note else "" def _overall_note(orig, new): """Whole-text new wording vs the original (the same measure as the 'Vocabulary changed' gauge).""" if not _all_sentences(): return "" chg, t = H.text_change(orig, new), _target_change() return (f" 🔤 Whole text: {chg:.0f}% new wording vs your original" + (f" (level {t:.0f}%: {'reached' if chg >= t else 'not reached — try more passes'})." if t else ".")) def _snapshot(doc): buf = io.BytesIO() doc.save(buf) return buf.getvalue() def _clean_stem(path): stem = re.sub(r"\.docx$", "", re.split(r"[\\/]", path)[-1], flags=re.I) return re.sub(r"(_humanized|_paraphrased)+$", "", stem) def smart_docx(path, target, passes, temperature, mode, hi, mod, rep): H.GRAMMAR_DEFER.set(True) G.start_account() # tokens and cost of this job (logged at the end) H.BOOST.set(False) # grammar runs once on the final version (below) rep(0.03, "Opening Word document") doc = DX.open_clean(path) H.ANCHOR.set({}) # rewritten sentence -> author's original sentence log, first, note = [], None, "" orig_text = "\n".join(b.text for b in D.docx_blocks(doc) if b.kind == "para") TP.GLOSSARY.set(TP.build_glossary([orig_text])) # the document's key terms stay untouched TP.set_document([orig_text]) # its constructs (defined terms, keywords) never change best = None # (pct, docx bytes, scored blocks) rpt, rows = R.ACTIVE.get(), None # uploaded report: rewrite only the sentences it flags if rpt is not None: passes = 1 acc, from_orig = None, True # last version whose AI score went DOWN (passes build on it) stall, low, t0 = 0, None, time.time() # automatic passes: stop when no pass improves any more AD.start_job() # self-adaptive settings per sentence for this document pend = None # (temperature offset, AI % before, jobs, rewritten) of last pass step = 0.8 / max(1, passes) for k in range(1, int(passes) + 2): # last iteration = final check only base = 0.05 + step * (k - 1) rep(min(base, 0.88), f"Pass {k}: running AI detector" if k <= passes else "Final AI check") blocks = _score_doc(doc) stats = D.summarise(blocks, hi, mod) first = first or stats if pend is not None: # learn how well last pass's setting worked AD.record_pass(pend[0], pend[1], stats["pct"], pend[2], pend[3]) pend = None cur = "\n".join(b.text for b in blocks if b.kind == "para") chg = H.text_change(orig_text, cur) if k > 1 else None cand_ = (stats["pct"], chg, (_snapshot(doc), blocks)) if _better(cand_, best, k): best = cand_ if k > 1 and acc is not None and rpt is None and _not_lower(stats["pct"], acc[0]): # this pass did not lower the AI score: undo it and retry from the better version log.append(f"Pass {k - 1} did not lower the AI score ({acc[0]}% → {stats['pct']}%), so it was undone") from docx import Document doc = Document(io.BytesIO(acc[1])) blocks = _score_doc(doc) stats = D.summarise(blocks, hi, mod) chg, from_orig = acc[2], acc[3] else: acc, from_orig = (stats["pct"], _snapshot(doc), chg, k == 1), k == 1 for b_ in blocks: # per-sentence learning: did its AI score fall? if b_.kind == "para": for s_, p_ in zip(b_.sents, b_.scores): AD.observe(H.anchor_of(s_) or s_, p_) if k > passes: break if _done_early(k, stats["pct"], target, chg): log.append(_log_line(k, stats["pct"]) + " — target reached") break if low is not None and stats["pct"] >= low: stall += 1 else: stall, low = 0, stats["pct"] if stall >= 1 and AUTO_DEEP and not H.BOOST.get() and not _all_sentences() and rpt is None and k < passes: H.BOOST.set(True) # the normal rewrite is not enough for this text stall = 0 log.append(f"Pass {k - 1} did not lower the AI score: switching to the deeper rewrite automatically") if stall >= STALL_PASSES: log.append(_log_line(k, stats["pct"]) + f" — stopped: {STALL_PASSES} pass(es) without further improvement") break if time.time() - t0 > TIME_BUDGET or H.past_deadline(): log.append(_log_line(k, stats["pct"]) + " — stopped: time limit for one document reached") break full = _all_sentences() # 100% AI box / vocabulary level: every sentence jobs, locked = [], 0 # (block, span, sentence) jinfo, linfo = [], [] # report rows for rewritten / locked sentences for b in blocks: if b.kind != "para" or b.el is None: continue for sp, s, p in zip(b.cspans, b.sents, b.scores): if rpt is not None: hit, rsent, colour = rpt.match(s) pick = hit else: pick = full or p >= mod if sp and pick and len(s.split()) >= 5: sub = safe_subspan(b.el, *sp) if sub: jobs.append((b, sub, b.raw[sub[0]:sub[1]])) if rpt is not None: jinfo.append((rsent, colour)) else: locked += 1 if rpt is not None: linfo.append(dict(report=rsent, colour=colour, orig=s, new=s, status="locked", reason=lock_reason(b.el, *sp))) ctx = {} # sentence -> neighbours, for in-context scoring for b in blocks: if b.kind == "para": for i, t in enumerate(b.sents): ctx[t.strip()] = (b.sents[i - 1] if i else "", b.sents[i + 1] if i + 1 < len(b.sents) else "") for b_, sub_, t_ in jobs: full_ = b_.raw[sub_[0]:sub_[1]].strip() if full_ not in ctx: hit_ = next((v for k, v in ctx.items() if full_ in k or k in full_), ("", "")) ctx[full_] = hit_ H.SENT_CONTEXT.set(ctx) if rpt is not None and not jobs and not locked: raise gr.Error("None of the sentences in your document match the sentences flagged in the report. " "Check that the report belongs to this document.") if not jobs: if rpt is not None: rows = linfo log.append(_log_line(k, stats["pct"]) + f" — no rewritable sentences left ({locked} locked)") break t_off = AD.pick_temp_offset() # learned across documents (Thompson sampling) temp = min(1.5, max(0.3, float(temperature) + 0.15 * (k - 1) + t_off)) # pass 1 pushes each sentence to the new-wording level (measured against the ORIGINAL sentence); # later passes only lower the AI score: their input is already rewritten, so a per-sentence level there # would compare against the previous pass and keep churning. The whole-text level is checked below. vtok = H.VOCAB_TARGET.set(0.0) if not from_orig else None try: if rpt is not None: new, note = _adaptive(_report_rewrite, [j[2] for j in jobs], temp, mode, rep, base + 0.1 * step, base + 0.9 * step) else: new, note = _adaptive(_rewrite_retry, [j[2] for j in jobs], temp, mode, rep, base + 0.1 * step, base + 0.9 * step) finally: if vtok is not None: H.VOCAB_TARGET.reset(vtok) done = 0 # right-to-left inside each paragraph so earlier offsets stay valid for (b, (a, e), t), n in sorted(zip(jobs, new), key=lambda x: (id(x[0][0]), -x[0][1][0])): if n and n != b.raw[a:e] and replace_span(b.el, a, e, n): H.remember_anchor(t, n) done += 1 log.append(_log_line(k, stats["pct"], len(jobs), done, locked)) pend = (t_off, stats["pct"], len(jobs), done) if rpt is not None: rows = [dict(report=ri[0], colour=ri[1], orig=j[2], new=(n if (n and n != j[2]) else j[2]), status="rephrased" if (n and n != j[2]) else "kept") for j, n, ri in zip(jobs, new, jinfo)] + linfo if not done: break AD.save(force=True) # keep the lowest-scoring version seen (a pass can occasionally make things worse) rep(0.9, "Preparing results") from docx import Document doc = Document(io.BytesIO(best[2][0])) blocks = best[2][1] after = D.summarise(blocks, hi, mod) if H.GRAMMAR: # final step: Groq grammar check of the finished text n, gnote = _grammar_docx(doc, rep, path) if n: # always kept: the final text must read correctly blocks = _score_doc(doc) after = D.summarise(blocks, hi, mod) note += gnote new_text = "\n".join(b.text for b in blocks if b.kind == "para") out = H._save_output(doc, _clean_stem(path), "_humanized") note += _overall_note(orig_text, new_text) return dict(before=first, after=after, log=log, note=note, blocks=blocks, path=out, report=_report_summary(rpt, rows), orig=orig_text, new=new_text) def smart_text(text, target, passes, temperature, mode, hi, mod, rep): H.GRAMMAR_DEFER.set(True) G.start_account() # tokens and cost of this job (logged at the end) H.BOOST.set(False) # grammar runs once on the final version (below) rep(0.03, "Reading pasted text") parts = TP.parse_paste(text) seps = [sep for _, sep in parts] TP.GLOSSARY.set(TP.build_glossary([p for p, _ in parts])) # the text's key terms stay untouched TP.set_document([p for p, _ in parts]) paras = [p for p, _ in parts] orig = TP.join_paste(parts) H.ANCHOR.set({}) # rewritten sentence -> author's original sentence log, first, note, best = [], None, "", None # best = (pct, paras, blocks) rpt, rows = R.ACTIVE.get(), None # uploaded report: rewrite only the sentences it flags if rpt is not None: passes = 1 acc, from_orig = None, True # last version whose AI score went DOWN (passes build on it) stall, low, t0 = 0, None, time.time() # automatic passes: stop when no pass improves any more AD.start_job() # self-adaptive settings per sentence for this document pend = None # (temperature offset, AI % before, jobs, rewritten) of last pass step = 0.8 / max(1, passes) for k in range(1, int(passes) + 2): base = 0.05 + step * (k - 1) rep(min(base, 0.88), f"Pass {k}: running AI detector" if k <= passes else "Final AI check") blocks = _score_paras(paras) stats = D.summarise(blocks, hi, mod) first = first or stats if pend is not None: # learn how well last pass's setting worked AD.record_pass(pend[0], pend[1], stats["pct"], pend[2], pend[3]) pend = None chg = H.text_change(orig, TP.join_paste(list(zip(paras, seps)))) if k > 1 else None cand_ = (stats["pct"], chg, (list(paras), blocks)) if _better(cand_, best, k): best = cand_ if k > 1 and acc is not None and rpt is None and _not_lower(stats["pct"], acc[0]): # this pass did not lower the AI score: undo it and retry from the better version log.append(f"Pass {k - 1} did not lower the AI score ({acc[0]}% → {stats['pct']}%), so it was undone") paras, blocks = list(acc[1]), acc[4] stats = D.summarise(blocks, hi, mod) chg, from_orig = acc[2], acc[3] else: acc, from_orig = (stats["pct"], list(paras), chg, k == 1, blocks), k == 1 for b_ in blocks: # per-sentence learning: did its AI score fall? if b_.kind == "para": for s_, p_ in zip(b_.sents, b_.scores): AD.observe(H.anchor_of(s_) or s_, p_) if k > passes: break if _done_early(k, stats["pct"], target, chg): log.append(_log_line(k, stats["pct"]) + " — target reached") break if low is not None and stats["pct"] >= low: stall += 1 else: stall, low = 0, stats["pct"] if stall >= 1 and AUTO_DEEP and not H.BOOST.get() and not _all_sentences() and rpt is None and k < passes: H.BOOST.set(True) # the normal rewrite is not enough for this text stall = 0 log.append(f"Pass {k - 1} did not lower the AI score: switching to the deeper rewrite automatically") if stall >= STALL_PASSES: log.append(_log_line(k, stats["pct"]) + f" — stopped: {STALL_PASSES} pass(es) without further improvement") break if time.time() - t0 > TIME_BUDGET or H.past_deadline(): log.append(_log_line(k, stats["pct"]) + " — stopped: time limit for one document reached") break full = _all_sentences() # 100% AI box / vocabulary level: every sentence jinfo = [] if rpt is not None: jobs = [] for bi, b in enumerate(blocks): if b.kind != "para": continue for si, s in enumerate(b.sents): hit, rsent, colour = rpt.match(s) if hit and len(s.split()) >= 5: jobs.append((bi, si, s)) jinfo.append((rsent, colour)) if not jobs: raise gr.Error("None of your sentences match the sentences flagged in the report. " "Check that the report belongs to this text.") else: jobs = [(bi, si, s) for bi, b in enumerate(blocks) if b.kind == "para" for si, (s, p) in enumerate(zip(b.sents, b.scores)) if (full or p >= mod) and len(s.split()) >= 5] H.SENT_CONTEXT.set({b.sents[si].strip(): (b.sents[si - 1] if si else "", b.sents[si + 1] if si + 1 < len(b.sents) else "") for bi, b in enumerate(blocks) if b.kind == "para" for si in range(len(b.sents))}) if not jobs: log.append(_log_line(k, stats["pct"]) + " — no rewritable sentences left") break t_off = AD.pick_temp_offset() # learned across documents (Thompson sampling) temp = min(1.5, max(0.3, float(temperature) + 0.15 * (k - 1) + t_off)) # pass 1 pushes each sentence to the new-wording level (measured against the ORIGINAL sentence); # later passes only lower the AI score: their input is already rewritten, so a per-sentence level there # would compare against the previous pass and keep churning. The whole-text level is checked below. vtok = H.VOCAB_TARGET.set(0.0) if not from_orig else None try: if rpt is not None: new, note = _adaptive(_report_rewrite, [j[2] for j in jobs], temp, mode, rep, base + 0.1 * step, base + 0.9 * step) else: new, note = _adaptive(_rewrite_retry, [j[2] for j in jobs], temp, mode, rep, base + 0.1 * step, base + 0.9 * step) finally: if vtok is not None: H.VOCAB_TARGET.reset(vtok) sents = [list(b.sents) for b in blocks] done = 0 for (bi, si, s), n in zip(jobs, new): if n and n != s: sents[bi][si] = n H.remember_anchor(s, n) done += 1 paras = [" ".join(sents[i]) if b.kind == "para" else b.text for i, b in enumerate(blocks)] log.append(_log_line(k, stats["pct"], len(jobs), done)) pend = (t_off, stats["pct"], len(jobs), done) if rpt is not None: rows = [dict(report=ri[0], colour=ri[1], orig=j[2], new=(n if (n and n != j[2]) else j[2]), status="rephrased" if (n and n != j[2]) else "kept") for j, n, ri in zip(jobs, new, jinfo)] if not done: break AD.save(force=True) rep(0.9, "Preparing results") paras, blocks = best[2] after = D.summarise(blocks, hi, mod) if H.GRAMMAR: # final step: Groq grammar check of the finished text idx = [i for i, b in enumerate(blocks) if b.kind == "para" and i < len(paras)] o_paras = [p for p, _ in parts] newp, n, gnote = H.grammar_pass([paras[i] for i in idx], rep, 0.9, 0.95, originals=[o_paras[i] if i < len(o_paras) else None for i in idx]) if n: # always kept: the final text must read correctly for i, p in zip(idx, newp): paras[i] = p blocks = _score_paras(paras) after = D.summarise(blocks, hi, mod) note += gnote out_text = TP.join_paste(list(zip(paras, seps))) from docx import Document d = Document() for p in paras: d.add_paragraph(p) path = H._save_output(d, "pasted_text", "_humanized") note += _overall_note(orig, out_text) return dict(before=first, after=after, log=log, note=note, blocks=blocks, path=path, report=_report_summary(rpt, rows), orig=orig, new=out_text, text=out_text)