Spaces:
Running
Running
unknown
Claude Opus 5.5
Faster humanizing on the GPU Space; the automatic proofread no longer undoes the humanizing
4d32393 Download proofread.py from Rasheed/Test_AI_writing: direct link, hf CLI and curl.
- Browser
- Download file 38.1 kB
-
https://huggingface.co/spaces/Rasheed/Test_AI_writing/resolve/main/proofread.py
- Command line
-
hf download hf://spaces/Rasheed/Test_AI_writing/proofread.py
-
curl -L -o proofread.py https://huggingface.co/spaces/Rasheed/Test_AI_writing/resolve/main/proofread.py
38.1 kB
| """ | |
| Rigorous proofread of a finished (humanized) Word document. Correct grammar comes first; a moderate AI-score rise | |
| is accepted for that, style-only edits must keep the AI score. | |
| 0. Local clean-up (no model): chatbot leftovers, markdown, doubled words / repeated phrases, AI filler words. | |
| 1. Grammar audit: the AI's grammar-only check marks every sentence that contains an error (plus sentences with | |
| doubled terms, leftovers or broken English): these are REQUIRED fixes. | |
| 2. Full proofread (clarity repair against the original upload, grammar, academic wording, flow). For a required | |
| sentence the full correction is used if its AI rise is at most PROOF_REQ_RISE, otherwise the minimal grammar | |
| fix; a sentence WITH an error is never left uncorrected. Style-only edits must stay within PROOF_MAX_RISE. | |
| 3. Verification rounds (PROOF_ROUNDS): every corrected sentence is grammar-checked again until no error is found. | |
| 4. LanguageTool: last mechanical check. | |
| 5. Document check: if the AI score rose by more than PROOF_MAX_DOC_RISE points, style-only edits are undone | |
| (riskiest first). Grammar fixes are never undone. | |
| Every change keeps terms / numbers / citations and the meaning. Only plain text runs are edited. | |
| Unclear / garbled sentences the full proofread REPAIRS are judged against the author's original sentence (not | |
| against the garbled text, which made good repairs fail the meaning check) and are never undone by the AI-score | |
| steps: clarity wins. | |
| Space variables: PROOF_REQ_RISE (0.35), PROOF_MAX_RISE (0.15), PROOF_MAX_DOC_RISE (2 points), PROOF_ROUNDS (1), | |
| PROOF_KEEP_RISE (0.05), PROOF_ALT_RISE (0.10), PROOF_REPAIR_SIM (0.60), PROOF_REASONING (medium). | |
| """ | |
| import difflib | |
| import os | |
| import time | |
| import re | |
| import detector as D | |
| import docx_report as DX | |
| import groqai as G | |
| import humanizer as H | |
| import meaning as MEANING | |
| import fluency as FL | |
| import smart as S | |
| import textparse as TP | |
| MAX_RISE = float(os.getenv("PROOF_MAX_RISE", "0.15")) | |
| FLAG = float(os.getenv("PROOF_FLAG", "0.50")) | |
| MAX_DOC_RISE = float(os.getenv("PROOF_MAX_DOC_RISE", "2.0")) | |
| # a sentence with a real defect (doubled word / repeated phrase / chatbot leftover / broken English) may rise more | |
| DEFECT_RISE = float(os.getenv("PROOF_DEFECT_RISE", "0.25")) | |
| # grammar comes first: a sentence with an error is always corrected; its full correction is used if the AI rise | |
| # stays within PROOF_REQ_RISE (otherwise the minimal grammar fix) | |
| REQ_RISE = float(os.getenv("PROOF_REQ_RISE", "0.35")) | |
| ROUNDS = int(os.getenv("PROOF_ROUNDS", "1")) | |
| BATCH = int(os.getenv("PROOF_BATCH", "24")) # sentences per request (fewer, larger requests = faster) | |
| # keep the AI score: a corrected sentence may be at most this much more AI-like than before; above it, correct | |
| # natural-sounding alternatives are tried (PROOF_AI_ROUNDS rounds, PROOF_VARIANTS versions each, plus Parrot) | |
| KEEP_RISE = float(os.getenv("PROOF_KEEP_RISE", "0.05")) | |
| # a corrected sentence is re-worded for the AI score only if it rose more than this (each rewording costs tokens and | |
| # moves the wording away from the checked correction) | |
| ALT_RISE = float(os.getenv("PROOF_ALT_RISE", "0.10")) | |
| AI_ROUNDS = int(os.getenv("PROOF_AI_ROUNDS", "1")) | |
| VARIANTS = int(os.getenv("PROOF_VARIANTS", "3")) | |
| # a repair of a garbled sentence must keep the author's meaning (MEANING_SOFT against the original sentence) and | |
| # stay at least this close to the current sentence; without any original it needs PROOF_REPAIR_SIM_NOREF | |
| REPAIR_SIM = float(os.getenv("PROOF_REPAIR_SIM", "0.60")) | |
| REPAIR_SIM_NOREF = float(os.getenv("PROOF_REPAIR_SIM_NOREF", "0.70")) | |
| # reasoning effort of the full proofread on reasoning models (the grammar audit stays on GROQ_REASONING) | |
| REASONING = os.getenv("PROOF_REASONING", "medium").lower() | |
| # single (default): the whole document is checked and corrected in ONE request (grammar, clarity, academic wording, | |
| # flow and document-wide consistency), and the reply lists problems that need the author. Every correction still | |
| # goes through the local checks. If the provider cannot take one request of that size, or it fails, the batched | |
| # proofread (several smaller requests) runs instead. batched = always the batched proofread. | |
| MODE = os.getenv("PROOF_MODE", "single").lower() | |
| PAR_TOL = int(os.getenv("PROOF_PAR_TOL", "0")) # flagged words a paragraph may gain after balancing | |
| PARROT = os.getenv("PROOF_PARROT", "0") == "1" | |
| # which AI does the proofreading: Groq by default (PROOF_PROVIDER=openai for ChatGPT). If the chosen one is out of | |
| # quota, the other one is used automatically when its key is set. | |
| PROVIDER = os.getenv("PROOF_PROVIDER", "groq").lower() | |
| MAX_WAIT = float(os.getenv("PROOF_MAX_WAIT", "180")) # seconds the proofread may wait for rate limits in total | |
| PARALLEL = int(os.getenv("PROOF_PARALLEL", "3")) # requests at once (Groq: low, so limits are not hit) | |
| # no language model reachable (usage limits): do the basic proofread (rules + LanguageTool) and say so, not an error | |
| BASIC_FALLBACK = os.getenv("PROOF_BASIC_FALLBACK", "1") != "0" | |
| _NAME = {"groq": "Groq", "openai": "ChatGPT"} | |
| class _LLM: | |
| """Calls Groq / ChatGPT for the proofread so that EVERY sentence gets an answer: failed batches are retried | |
| (only the missing sentences), rate limits are waited out (up to PROOF_MAX_WAIT in total), and if the provider | |
| is out of quota the other provider is used when its key is set.""" | |
| def __init__(self, rep): | |
| self.rep = rep | |
| self.order = [PROVIDER] + [p for p in ("groq", "openai") if p != PROVIDER] | |
| self.order = [p for p in self.order if (G.OPENAI_KEY if p == "openai" else G.KEYS)] | |
| self.k = 0 | |
| self.waited = 0.0 | |
| self.used = set() | |
| self.dead = False | |
| def name(self): | |
| return _NAME.get(self.order[self.k], self.order[self.k]) if self.order else "AI" | |
| def _ready(self): | |
| return G.available() | |
| def _switch(self): | |
| if self.k + 1 < len(self.order): | |
| self.k += 1 | |
| print(f"[proofread] switching to {self.name}", flush=True) | |
| return True | |
| return False | |
| def _blocked_for(self): | |
| """Seconds until the current provider can be used again (large = daily quota / billing / bad key).""" | |
| now = time.time() | |
| if self.order[self.k] == "openai": | |
| return max(0.0, G._oa["until"] - now) if G.OPENAI_KEY else 1e9 | |
| return max(G._down_until - now, G._next_free_in(), 0.0) | |
| def _wait(self, why): | |
| blocked = self._blocked_for() | |
| if blocked > 90: # quota for the day / billing: waiting will not help | |
| print(f"[proofread] {self.name} blocked for {int(blocked)}s ({why}); " | |
| f"groq: {G.STATS.get('last_error', '')[:120]} | openai: {G.OA_STATS.get('last_error', '')[:120]}", | |
| flush=True) | |
| return False | |
| t = min(30.0, max(3.0, blocked or 5.0)) | |
| if self.waited + t > MAX_WAIT: | |
| return False | |
| self.rep(None, f"Waiting {int(t)}s for the {self.name} rate limit ({why})") | |
| time.sleep(t) | |
| self.waited += t | |
| return True | |
| def run(self, fn, items, *args, missing=lambda r: r is None, **kw): | |
| """fn(list, *args, **kw) -> list of results; returns results for all items (None only if impossible).""" | |
| out = [None] * len(items) | |
| todo = list(range(len(items))) | |
| while todo and self.order: | |
| tok_p = G.PROVIDER.set(self.order[self.k]) | |
| tok_l = G.PAR_LIMIT.set(PARALLEL if self.order[self.k] == "groq" else 0) | |
| try: | |
| if not self._ready(): | |
| raise G.Unavailable("paused") | |
| res = fn([items[i] for i in todo], *args, **kw) | |
| self.used.add(self.order[self.k]) | |
| for i, r in zip(todo, res): | |
| if not missing(r): | |
| out[i] = r | |
| left = [i for i in todo if missing(out[i])] | |
| if len(left) == len(todo): # nothing came back: treat as unavailable | |
| raise G.Unavailable("no answers") | |
| todo = left | |
| if todo and not self._wait(f"{len(todo)} sentence(s) to retry"): | |
| break | |
| except G.Unavailable as err: | |
| daily = "quota" in str(err).lower() or "billing" in str(err).lower() or \ | |
| (self.order[self.k] == "groq" and G._next_free_in() > 300) | |
| if (daily or self._blocked_for() > 90) and self._switch(): | |
| continue | |
| if not self._wait(str(err)[:60]) and not self._switch(): | |
| self.dead = True | |
| break | |
| finally: | |
| G.PROVIDER.reset(tok_p) | |
| G.PAR_LIMIT.reset(tok_l) | |
| return out | |
| def _jobs(doc, orig_path): | |
| blocks = list(D.docx_blocks(doc)) | |
| orig = None | |
| if orig_path: | |
| 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"[proofread] original not used: {err}", flush=True) | |
| jobs = [] | |
| 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 | |
| for sp, (cp, cs) in zip(DX.sentence_char_spans(b.raw, raws), H.originals_for(raws, o_raw)): | |
| if not sp: | |
| continue | |
| sub = S.safe_subspan(b.el, *sp) | |
| if sub and TP.artifact_only(b.raw[sub[0]:sub[1]]): | |
| jobs.append(dict(b=b, span=sub, text=b.raw[sub[0]:sub[1]], par=cp, sent=None, drop=True)) | |
| elif sub and len(b.raw[sub[0]:sub[1]].split()) >= 4: | |
| full = tuple(sub) == tuple(sp) | |
| jobs.append(dict(b=b, span=sub, text=b.raw[sub[0]:sub[1]], par=cp, sent=cs if full else None)) | |
| return jobs | |
| accept = TP.strict_accept | |
| def _checked(jobs, cands, glossary, minimal=False, repaired=()): | |
| """Indices whose candidate passes the strict acceptance, the meaning check and the fluency check. A full | |
| proofread is compared with the author's original sentence (when known) and with the current sentence; a | |
| minimal correction with the current sentence only. A REPAIR of a garbled sentence (`repaired`) is judged | |
| against the author's original: it must say what the original says, and only loosely resemble the garbled | |
| current wording (that wording is what was wrong).""" | |
| def cur_of(i): | |
| return jobs[i].get("base", jobs[i]["text"]) | |
| idx = [i for i, c in enumerate(cands) if c and c.strip() != cur_of(i).strip()] | |
| ok = {} | |
| for i in idx: | |
| a = accept(cur_of(i), cands[i], glossary, jobs[i]["sent"]) | |
| sent = jobs[i]["sent"] | |
| if not a and not minimal and i in repaired and sent: | |
| # a repair is measured against the author's sentence (back to the original wording is allowed) | |
| a = TP.basic_grammar(cands[i].strip()) if cands[i].strip() == sent.strip() else \ | |
| accept(sent, cands[i], glossary) | |
| if a: | |
| ok[i] = a | |
| keys = list(ok) | |
| if not keys: | |
| return ok | |
| if minimal: | |
| mean = MEANING.keeps_meaning([(cur_of(i), ok[i]) for i in keys], min_sim=MEANING.min_sim()) | |
| else: | |
| soft = MEANING.soft_sim() | |
| mean = [] | |
| pairs_o = [(jobs[i]["sent"] or jobs[i]["text"], ok[i]) for i in keys] | |
| pairs_c = [(cur_of(i), ok[i]) for i in keys] | |
| m_o = MEANING.keeps_meaning(pairs_o, min_sim=soft) | |
| sim_c = MEANING.similarities(pairs_c) | |
| par_keys = [i for i in keys if i in repaired and not jobs[i]["sent"] and jobs[i]["par"]] | |
| par_sim = {} | |
| if par_keys: | |
| ps = MEANING.similarities([(jobs[i]["par"], ok[i]) for i in par_keys] | |
| + [(jobs[i]["par"], cur_of(i)) for i in par_keys]) | |
| par_sim = {i: (ps[n], ps[len(par_keys) + n]) for n, i in enumerate(par_keys)} | |
| for i, mo, sc_ in zip(keys, m_o, sim_c): | |
| same = ok[i].strip() == cur_of(i).strip() | |
| if i not in repaired: # light edit: close to the original AND the current text | |
| mean.append(mo and (same or sc_ >= soft)) | |
| elif jobs[i]["sent"]: # repair, original sentence known | |
| mean.append(mo and (same or sc_ >= REPAIR_SIM)) | |
| elif i in par_sim: # repair, only the original paragraph known | |
| new_s, old_s = par_sim[i] | |
| mean.append(new_s >= old_s - 0.02 and sc_ >= REPAIR_SIM) | |
| else: # repair without any original (standalone proofread) | |
| mean.append(same or sc_ >= REPAIR_SIM_NOREF) | |
| flu = FL.acceptable([(jobs[i]["text"], ok[i]) for i in keys]) | |
| for i, m, f in zip(keys, mean, flu): | |
| if not (m and f): | |
| ok.pop(i) | |
| return ok | |
| def _ai_ok(cur_scores, new_texts, defect=None): | |
| """Per candidate: does it keep the AI probability in bounds? Returns (flags, new scores). A defective sentence | |
| (doubled terms, chatbot leftovers, broken English) may rise up to DEFECT_RISE, and may enter the flagged range | |
| only within that allowance.""" | |
| sc = H._detector_scores(new_texts) or [0.0] * len(new_texts) | |
| defect = defect or [False] * len(new_texts) | |
| flags = [] | |
| for c, n, d in zip(cur_scores, sc, defect): | |
| if d: | |
| flags.append(n <= c + DEFECT_RISE) | |
| else: | |
| flags.append(n <= c + MAX_RISE and not (n >= FLAG > c)) | |
| return flags, sc | |
| def proofread_docx(path, orig_path=None, rep=None, hi=0.80, mod=0.50, protect_ai=False): | |
| """protect_ai: the automatic proofread right after humanizing. Its job is to correct the humanized text, not to | |
| undo it, so after the style edits, grammar fixes and repairs are undone too (those that raised the AI score most | |
| first) until the score is back within PROOF_MAX_DOC_RISE. A repair puts back the author's original wording, | |
| which is the wording the detector flagged: one repair alone took a paragraph from 0% back to 100%.""" | |
| return _proofread(path, orig_path, rep, hi, mod, protect_ai) | |
| def _proofread(path, orig_path=None, rep=None, hi=0.80, mod=0.50, protect_ai=False): | |
| rep0 = rep or (lambda *a, **k: None) | |
| _last = [0.05] | |
| def rep(p, desc=""): | |
| if p is not None: | |
| _last[0] = p | |
| rep0(_last[0], desc=desc) | |
| llm = _LLM(rep) | |
| acct = G.start_account() # tokens and cost of this proofread (logged at the end) | |
| if not llm.order: | |
| raise G.Unavailable("No AI key is set for the proofread: add GROQ_API_KEY (or OPENAI_API_KEY).") | |
| rep(0.05, "Opening the humanized document") | |
| doc = DX.open_clean(path) | |
| before = D.summarise(S._score_doc(doc), hi, mod) | |
| jobs = _jobs(doc, orig_path) | |
| if not jobs: | |
| raise ValueError("No prose sentences found to proofread.") | |
| # 0. local clean-up first (no model, no AI risk): chatbot leftovers, markdown, doubled words / phrases, | |
| # AI-typical filler words and spaced dashes | |
| texts = [j["text"] for j in jobs] | |
| local = {} | |
| drops = {i for i, j in enumerate(jobs) if j.get("drop")} | |
| for i, t in enumerate(texts): | |
| if i in drops: | |
| continue | |
| c = TP.basic_grammar(TP.de_ai(t)) | |
| if c.strip() != t.strip(): | |
| g = accept(t, c, set()) | |
| if g: | |
| local[i] = g | |
| src = list(dict.fromkeys(j["par"] or j["sent"] or j["text"] for j in jobs)) | |
| glossary = TP.build_glossary(src) | TP.GLOSSARY.get() | |
| TP.set_document([b.raw for b in D.docx_blocks(doc) if b.kind == "para" and b.raw]) | |
| rep(0.12, f"Scoring {len(texts)} sentences") | |
| raw_sc = H._detector_scores(texts) or [0.0] * len(texts) | |
| if local: # keep a local clean-up unless it raises the AI score | |
| keys = list(local) | |
| sc = H._detector_scores([local[i] for i in keys]) or [0.0] * len(keys) | |
| for i, s_ in zip(keys, sc): | |
| if s_ > raw_sc[i] + MAX_RISE and not TP.has_defect(texts[i]): | |
| local.pop(i) | |
| else: | |
| raw_sc[i] = s_ | |
| base = [local.get(i, t) for i, t in enumerate(texts)] # what Groq proofreads | |
| for i, j in enumerate(jobs): | |
| j["base"] = base[i] | |
| cur = raw_sc | |
| defect = [TP.has_defect(t) for t in texts] | |
| for i in FL.bad(base): | |
| defect[i] = True | |
| work = [i for i in range(len(jobs)) if i not in drops] | |
| ctx = [j["par"] or j["sent"] for j in jobs] | |
| ctx = [c if c and c.strip() != t.strip() else None for c, t in zip(ctx, texts)] | |
| groups = [id(j["b"]) for j in jobs] | |
| idx_all = list(range(len(jobs))) | |
| minimal, notes, single, unchecked = {}, [], False, 0 | |
| limited = False # True: no language model could be reached (basic proofread) | |
| # ONE request for the whole document (PROOF_MODE=single): grammar, clarity, wording, flow and consistency | |
| if MODE == "single": | |
| rep(0.2, "Final check: the whole document is read and corrected in one pass") | |
| constructs = sorted({re.sub(r"\s+", " ", m.group(0)).strip() for m in re.finditer( | |
| r"(?:[A-Za-z][\w\-]*\s+){1,4}\([A-Z][A-Za-z0-9\-]{1,10}\)", | |
| " ".join(b.raw for b in D.docx_blocks(doc) if b.kind == "para" and b.raw))})[:40] | |
| tok_r = G.REASONING.set(REASONING) | |
| for prov in llm.order: # one call; the other provider only if the first fails | |
| tok_p = G.PROVIDER.set(prov) | |
| try: | |
| res, notes = G.proofread_document([base[i] for i in idx_all], groups, ctx, glossary, constructs) | |
| llm.used.add(prov) | |
| single = True | |
| break | |
| except G.Unavailable as err: | |
| print(f"[proofread] one-call check not possible on {prov}: {err}", flush=True) | |
| except Exception as err: | |
| print(f"[proofread] one-call check failed on {prov}: {err}", flush=True) | |
| finally: | |
| G.PROVIDER.reset(tok_p) | |
| G.REASONING.reset(tok_r) | |
| if single: | |
| unchecked = sum(1 for r in res if r == (None, None)) | |
| res = [r if r != (None, None) else ("ok", base[i]) for i, r in enumerate(res)] | |
| fixed_ids = {i for i, (st, _) in enumerate(res) if st in ("fixed", "repaired")} | |
| required = {i for i in work if defect[i] or i in fixed_ids} | |
| else: | |
| rep(0.2, "The document is too large for one request here: checking it in parts") | |
| if not single: | |
| # 1. grammar audit: Groq's grammar-only correction tells which sentences contain an error (and gives the | |
| # minimal fix for them). Sentences with an error or a defect are REQUIRED fixes: correctness comes first. | |
| rep(0.15, "Grammar audit of every sentence") | |
| g = llm.run(G.grammar, [base[i] for i in work], glossary, rep, 0.15, 0.35, size=BATCH) | |
| unchecked = sum(1 for t in g if t is None) | |
| if work and unchecked == len(work): | |
| # No language model can be reached (usage limits on every key and model, no credit on the other | |
| # provider). The proofread does not fail: what needs no model still runs (rule-based clean-up above, | |
| # LanguageTool below, the AI-score checks), and the result says plainly that the full check is still | |
| # to be done. PROOF_BASIC_FALLBACK=0 restores the error. | |
| if not BASIC_FALLBACK: | |
| raise G.Unavailable("The proofreading engine has reached its usage limit for now. Please try again " | |
| "in a while; nothing in your document was changed.") | |
| limited = True | |
| llm.dead = True | |
| print("[proofread] no AI provider available: basic proofread only (rules + LanguageTool)", flush=True) | |
| cands = [None] * len(jobs) | |
| for i, t in zip(work, g): | |
| cands[i] = t | |
| minimal = _checked(jobs, cands, glossary, minimal=True) | |
| required = {i for i in work if defect[i] or i in minimal} | |
| # 2. full proofread (clarity + grammar + academic wording + flow) | |
| tok_r = G.REASONING.set(REASONING) # deeper reading for the full proofread | |
| try: | |
| res = [None] * len(idx_all) if limited else llm.run( | |
| lambda ids, *a: G.polish([base[i] for i in ids], [groups[i] for i in ids], glossary, | |
| rep, 0.35, 0.65, originals=[ctx[i] for i in ids], size=BATCH), | |
| idx_all, missing=lambda r: r is None or r[1] is None) | |
| finally: | |
| G.REASONING.reset(tok_r) | |
| res = [r if r is not None else (None, None) for r in res] | |
| asked_repair = {i for i, (st, _) in enumerate(res) if st == "repaired"} | |
| full = _checked(jobs, [t for _, t in res], glossary, repaired=asked_repair) | |
| for i in drops: | |
| full.pop(i, None) | |
| # an accepted repair of an unclear sentence is a required fix: clarity comes before the AI score | |
| repaired = {i for i in full if i in asked_repair} | |
| required |= repaired | |
| final, rise = {i: t for i, t in local.items()}, {} | |
| for i in drops: # chatbot leftovers on their own: removed | |
| final[i] = "" | |
| # every correct version of a sentence (full proofread, minimal grammar fix) is scored. A repaired sentence takes | |
| # its repair; a sentence with an error takes the full correction if its AI rise stays within PROOF_REQ_RISE, | |
| # otherwise the minimal grammar fix; a correct sentence only takes a style edit that keeps the AI score | |
| opts = {} | |
| for i in work: | |
| for t in (full.get(i), minimal.get(i)): | |
| if t and t not in opts.setdefault(i, []): | |
| opts[i].append(t) | |
| flat = [(i, t) for i, ts in opts.items() for t in ts] | |
| sc = H._detector_scores([t for _, t in flat]) or [0.0] * len(flat) | |
| score_of = {(i, t): s_ for (i, t), s_ in zip(flat, sc)} | |
| n_minimal = 0 | |
| for i, ts in opts.items(): | |
| if i in repaired: | |
| final[i], rise[i] = full[i], score_of[(i, full[i])] - cur[i] | |
| elif i in required: | |
| f_, m_ = full.get(i), minimal.get(i) | |
| if f_ and (not m_ or score_of[(i, f_)] - cur[i] <= REQ_RISE): | |
| best = f_ | |
| elif m_: | |
| best = m_ | |
| else: | |
| best = min(ts, key=lambda t: (score_of[(i, t)], len(t))) | |
| final[i], rise[i] = best, score_of[(i, best)] - cur[i] | |
| n_minimal += best == m_ and best != f_ | |
| elif full.get(i): | |
| s_ = score_of[(i, full[i])] | |
| if s_ - cur[i] <= KEEP_RISE: # style-only edit: only if the AI score stays the same | |
| final[i], rise[i] = full[i], s_ - cur[i] | |
| # 3. verification rounds: every corrected or required sentence is checked again until Groq finds no error | |
| n_verify = 0 | |
| for rnd in range(0 if single else ROUNDS): | |
| # a sentence whose text IS the grammar check's own output is not sent again (it was just checked) | |
| chk = [i for i in work if (i in final or i in required) and final.get(i, base[i]) != minimal.get(i)] | |
| if not chk: | |
| break | |
| rep(0.65 + 0.08 * rnd, f"Verification round {rnd + 1}: re-checking {len(chk)} sentence(s)") | |
| if llm.dead: | |
| break | |
| g = llm.run(G.grammar, [final.get(i, base[i]) for i in chk], glossary, rep, 0.65 + 0.08 * rnd, | |
| 0.72 + 0.08 * rnd, size=BATCH) | |
| changed = 0 | |
| for i, t in zip(chk, g): | |
| now = final.get(i, base[i]) | |
| if not t or t.strip() == now.strip(): | |
| continue | |
| ok = accept(now, t, glossary, jobs[i]["sent"]) | |
| if ok and H._small_edit(now, ok) and MEANING.keeps_meaning([(now, ok)])[0]: | |
| final[i] = ok | |
| required.add(i) # it had an error: keep this fix | |
| changed += 1 | |
| n_verify += changed | |
| if not changed: | |
| break | |
| # 4. LanguageTool: last mechanical check (agreement, confused words, a/an, punctuation) | |
| try: | |
| import lt_grammar as LT | |
| if LT.MODE != "off": | |
| ids = [i for i in work if final.get(i, base[i])] | |
| for i, t in zip(ids, LT.correct([final.get(i, base[i]) for i in ids])): | |
| now = final.get(i, base[i]) | |
| if t.strip() != now.strip(): | |
| ok = accept(now, t, glossary) | |
| if ok and H._small_edit(now, ok): | |
| final[i] = ok | |
| required.add(i) | |
| except Exception as err: | |
| print(f"[proofread] LanguageTool skipped: {err}", flush=True) | |
| # 5. keep the AI score: correct, natural-sounding alternatives (AI) and Parrot rewrites of corrected sentences; | |
| # only versions verified error-free (grammar check returns them unchanged, LanguageTool finds nothing, | |
| # fluent, same meaning) are kept as options. | |
| verified = {} # sentence -> set of verified correct versions | |
| def gen_alts(ids, stage): | |
| if not ids or llm.dead: | |
| return | |
| rep(stage, f"Keeping the AI score: natural rewording of {len(ids)} sentence(s)") | |
| alts = llm.run(G.variants, [final.get(i, base[i]) for i in ids], glossary, | |
| [jobs[i]["sent"] or None for i in ids], VARIANTS) | |
| pool = [] | |
| for i, vs in zip(ids, alts): | |
| for v in (vs or []): | |
| ok = accept(final.get(i, base[i]), v, glossary, jobs[i]["sent"]) | |
| if ok: | |
| pool.append((i, ok)) | |
| if PARROT: | |
| try: | |
| pr, _ = H._paraphrase_local([final.get(i, base[i]) for i in ids], 0.9, None, 0.8, 0.85) | |
| for i, t in zip(ids, pr): | |
| ok = accept(final.get(i, base[i]), t, glossary, jobs[i]["sent"]) | |
| if ok: | |
| pool.append((i, ok)) | |
| except Exception as err: | |
| print(f"[proofread] Parrot rewording skipped: {err}", flush=True) | |
| pool = list(dict.fromkeys(pool)) | |
| if not pool: | |
| return | |
| mean = MEANING.keeps_meaning([(final.get(i, base[i]), t) for i, t in pool]) | |
| flu = FL.acceptable([(final.get(i, base[i]), t) for i, t in pool]) | |
| pool = [(i, t) for (i, t), m, f in zip(pool, mean, flu) if m and f] | |
| if not pool: | |
| return | |
| chk = llm.run(G.grammar, [t for _, t in pool], glossary, rep, stage, stage + 0.02, size=BATCH) | |
| clean = [(i, t) for (i, t), g_ in zip(pool, chk) if g_ is not None and g_.strip() == t.strip()] | |
| try: | |
| import lt_grammar as LT | |
| if LT.MODE != "off" and clean: | |
| ltc = LT.correct([t for _, t in clean]) | |
| clean = [c for c, l_ in zip(clean, ltc) if l_.strip() == c[1].strip()] | |
| except Exception: | |
| pass | |
| for i, t in clean: | |
| verified.setdefault(i, set()).add(t) | |
| n_reworded = 0 | |
| for rnd in range(0 if single else AI_ROUNDS): | |
| ids = [i for i in work if final.get(i)] | |
| if not ids: | |
| break | |
| now_sc = H._detector_scores([final[i] for i in ids]) or [0.0] * len(ids) | |
| hot = [i for i, s_ in zip(ids, now_sc) if s_ > cur[i] + ALT_RISE and i not in repaired] | |
| if not hot: | |
| break | |
| now_of = dict(zip(ids, now_sc)) | |
| gen_alts(hot, 0.78 + 0.02 * rnd) | |
| cand = [(i, t) for i in hot for t in verified.get(i, ())] | |
| sc = H._detector_scores([t for _, t in cand]) or [1.0] * len(cand) | |
| best = {} | |
| for (i, t), s_ in zip(cand, sc): | |
| if s_ < now_of[i] - 0.02 and (i not in best or s_ < best[i][1]): | |
| best[i] = (t, s_) | |
| for i, (t, s_) in best.items(): | |
| final[i], rise[i] = t, s_ - cur[i] | |
| required.add(i) | |
| n_reworded += 1 | |
| if not best: | |
| break | |
| # 5b. paragraph balance: the document score is measured over sentence windows inside each paragraph, so | |
| # single-sentence scores are not enough. Every paragraph whose AI-flagged words rose is re-balanced by | |
| # choosing, sentence by sentence, among the CORRECT versions only (current, minimal fix, verified natural | |
| # versions; for a sentence without an error also its unedited form) the combination with the fewest | |
| # flagged words. | |
| by_par = {} | |
| for i, j in enumerate(jobs): | |
| by_par.setdefault(id(j["b"]), []).append(i) | |
| def par_text(pid, choice): | |
| ids = by_par[pid] | |
| raw = jobs[ids[0]]["b"].raw | |
| for i in sorted(ids, key=lambda i: -jobs[i]["span"][0]): | |
| t = choice.get(i, jobs[i]["text"]) | |
| a_, e_ = jobs[i]["span"] | |
| if t == "": | |
| while e_ < len(raw) and raw[e_] == " ": | |
| e_ += 1 | |
| raw = raw[:a_] + t + raw[e_:] | |
| return raw | |
| def flagged(text): | |
| try: | |
| st = D.summarise(S._score_paras([text]), hi, mod) | |
| return st["high"] + st["moderate"] | |
| except Exception: | |
| return 0 | |
| def options(i): | |
| o = [final.get(i, base[i])] | |
| if i in repaired: # only the repair and its verified natural versions | |
| return o + [t for t in sorted(verified.get(i, ())) if t not in o] | |
| for t in [minimal.get(i)] + sorted(verified.get(i, ())): | |
| if t and t not in o: | |
| o.append(t) | |
| if i not in required and jobs[i]["text"] not in o: | |
| o.append(jobs[i]["text"]) # no error: its unedited form is allowed too | |
| return o | |
| n_balanced = 0 | |
| changed_pars = [pid for pid, ids in by_par.items() if any(i in final for i in ids)] | |
| rep(0.86, f"Balancing the AI score of {len(changed_pars)} paragraph(s)") | |
| for attempt in range(2): | |
| worse = [] | |
| for pid in changed_pars: | |
| was = flagged(par_text(pid, {})) | |
| if flagged(par_text(pid, final)) > was + PAR_TOL: | |
| worse.append((pid, was)) | |
| if not worse: | |
| break | |
| if attempt == 1 and not single: # more correct versions for the sentences in those paragraphs | |
| gen_alts([i for pid, _ in worse for i in by_par[pid] if i in final and final[i]], 0.88) | |
| for pid, was in worse: | |
| choice = {i: final[i] for i in by_par[pid] if i in final} | |
| score = flagged(par_text(pid, choice)) | |
| for _ in range(len(by_par[pid]) * 2): # greedy: best single swap per step | |
| best = None | |
| for i in by_par[pid]: | |
| if i in drops: | |
| continue | |
| for t in options(i): | |
| if t == choice.get(i, jobs[i]["text"]): | |
| continue | |
| trial = dict(choice) | |
| if t == jobs[i]["text"]: | |
| trial.pop(i, None) | |
| else: | |
| trial[i] = t | |
| f_ = flagged(par_text(pid, trial)) | |
| if f_ < score and (best is None or f_ < best[0]): | |
| best = (f_, trial) | |
| if best is None: | |
| break | |
| score, choice = best | |
| n_balanced += 1 | |
| if score <= was + PAR_TOL: | |
| break | |
| for i in by_par[pid]: | |
| if i in drops: | |
| continue | |
| if i in choice: | |
| final[i] = choice[i] | |
| else: | |
| final.pop(i, None) | |
| ids = [i for i in work if final.get(i)] | |
| still = sum(1 for i, s_ in zip(ids, H._detector_scores([final[i] for i in ids]) or [0.0] * len(ids)) | |
| if s_ > cur[i] + ALT_RISE and i not in repaired) if ids else 0 | |
| # 6. write back, then document-level AI check (undo style-only edits if the score still rose) | |
| rep(0.85, "Writing corrections and re-checking the AI score") | |
| def write(chosen): | |
| d = DX.open_clean(path) | |
| js = _jobs(d, None) | |
| if len(js) != len(jobs): | |
| return None, 0 | |
| n = 0 | |
| for i in sorted(chosen, key=lambda i: (id(js[i]["b"]), -js[i]["span"][0])): | |
| a_, e_ = js[i]["span"] | |
| if chosen[i] == "": # deletion: take the following space with it | |
| raw = js[i]["b"].raw | |
| while e_ < len(raw) and raw[e_] == " ": | |
| e_ += 1 | |
| if S.replace_span(js[i]["b"].el, a_, e_, chosen[i]): | |
| n += 1 | |
| return d, n | |
| out_doc, n = write(final) | |
| if out_doc is None: | |
| raise ValueError("The document layout changed while proofreading; please run it again.") | |
| after = D.summarise(S._score_doc(out_doc), hi, mod) | |
| undone = 0 | |
| while after["pct"] > before["pct"] + MAX_DOC_RISE: | |
| optional = [i for i in final if i not in required and i not in drops and i not in local] | |
| if not optional: # only required (grammar) fixes left: they always stay | |
| break | |
| worst = sorted(optional, key=lambda i: rise.get(i, 0), reverse=True)[:max(1, len(optional) // 5)] | |
| for i in worst: | |
| final.pop(i) | |
| undone += 1 | |
| out_doc, n = write(final) | |
| after = D.summarise(S._score_doc(out_doc), hi, mod) | |
| held = 0 # protect_ai: corrections left for the author to apply | |
| while protect_ai and after["pct"] > before["pct"] + MAX_DOC_RISE: | |
| left = [i for i in final if i not in drops and i not in local] | |
| if not left: | |
| break | |
| worst = sorted(left, key=lambda i: rise.get(i, 0), reverse=True)[:max(1, len(left) // 5)] | |
| for i in worst: | |
| final.pop(i) | |
| held += 1 | |
| out_doc, n = write(final) | |
| after = D.summarise(S._score_doc(out_doc), hi, mod) | |
| out = H._save_output(out_doc, S._clean_stem(path), "_proofread") | |
| who = " + ".join(_NAME.get(p, p) for p in llm.order if p in llm.used) or llm.name | |
| note = (("⚠️ Basic proofread only — the advanced check has reached its usage limit for now. Rule-based " | |
| "corrections and the grammar checker were applied" if limited else | |
| ("🔍 Final check in one request" if single else "🔍 Rigorous proofread") + f" by {who}") | |
| + f": {n} of {len(jobs)} sentences corrected" | |
| + (f" ({len(required)} had a grammar error or defect and were always corrected" | |
| + (f", {len(repaired)} unclear sentence(s) repaired to the original meaning" if repaired else "") | |
| + (f", {n_minimal} with the minimal grammar fix" if n_minimal else "") | |
| + (f"; {n_verify} more fix(es) found in the verification rounds" if n_verify else "") | |
| + (f"; {n_reworded} reworded naturally to keep the AI score" if n_reworded else "") | |
| + (f"; {n_balanced} swap(s) between correct versions to balance paragraphs" if n_balanced else "") | |
| + ")" | |
| if required else "") | |
| + (f"; {sum(defect)} had doubled terms, chatbot leftovers or broken English, " | |
| f"{sum(1 for i, d in enumerate(defect) if d and i in final)} of them fixed" if any(defect) else "") | |
| + f". AI score {before['pct']}% → {after['pct']}%." | |
| + (f" {undone} style-only edit(s) were undone because they raised the AI score" | |
| + ("." if held else "; grammar fixes were all kept.") if undone else "") | |
| + (f" {held} correction(s) were not applied because they would have undone the humanizing (raised the " | |
| "AI score); press Final check & proofread to apply them anyway." if held else "") | |
| + (f" {still} corrected sentence(s) still read slightly more AI-like: no error-free natural version " | |
| "was found, and correct grammar was kept." if still else "") | |
| + (" The full check (unclear sentences, academic wording, flow, consistency) has NOT been done yet: " | |
| "run the proofread again later to finish it; it stays free." if limited else | |
| f" ⚠️ {unchecked} sentence(s) could not be checked (AI quota exhausted); run the proofread again " | |
| "later to finish them." if unchecked else "") | |
| + ("" if orig_path else " Tip: run it right after humanizing, so your original is used as the meaning " | |
| "reference for unclear sentences.")) | |
| if notes: # document-level problems that need the author | |
| note += " 📝 For the author: " + " · ".join(notes) | |
| print(f"[proofread] {note}", flush=True) | |
| print(f"[cost] proofread, {sum(len(j['text'].split()) for j in jobs)} words: {G.usage_line(acct)}", flush=True) | |
| return dict(path=out, before=before, after=after, n=n, total=len(jobs), note=note, notes=notes, | |
| single=single, limited=limited, held=held) | |