Spaces:
Running
Running
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 Download smart.py from Rasheed/Test_AI_writing: direct link, hf CLI and curl.
- Browser
- Download file 46.4 kB
-
https://huggingface.co/spaces/Rasheed/Test_AI_writing/resolve/main/smart.py
- Command line
-
hf download hf://spaces/Rasheed/Test_AI_writing/smart.py
-
curl -L -o smart.py https://huggingface.co/spaces/Rasheed/Test_AI_writing/resolve/main/smart.py
46.4 kB
| """ | |
| 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) | |