Spaces:
Running
Running
File size: 38,054 Bytes
fbb10d7 d71d180 fbb10d7 a130a73 fbb10d7 4d32393 fbb10d7 4d32393 fbb10d7 2906069 fbb10d7 0fdaff6 fbb10d7 d71d180 a130a73 d71d180 fbb10d7 d71d180 a130a73 d71d180 a130a73 d71d180 fbb10d7 d71d180 fbb10d7 d71d180 fbb10d7 d71d180 fbb10d7 4d32393 fbb10d7 a130a73 fbb10d7 4d32393 fbb10d7 a130a73 fbb10d7 d71d180 fbb10d7 2906069 d71d180 4d32393 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 490 491 492 493 494 495 496 497 498 499 500 501 502 503 504 505 506 507 508 509 510 511 512 513 514 515 516 517 518 519 520 521 522 523 524 525 526 527 528 529 530 531 532 533 534 535 536 537 538 539 540 541 542 543 544 545 546 547 548 549 550 551 552 553 554 555 556 557 558 559 560 561 562 563 564 565 566 567 568 569 570 571 572 573 574 575 576 577 578 579 580 581 582 583 584 585 586 587 588 589 590 591 592 593 594 595 596 597 598 599 600 601 602 603 604 605 606 607 608 609 610 611 612 613 614 615 616 617 618 619 620 621 622 623 624 625 626 627 628 629 630 631 632 633 634 635 636 637 638 639 640 641 642 643 644 645 646 647 648 649 650 651 652 653 654 655 656 657 658 659 660 661 662 663 664 665 666 667 668 669 670 671 672 673 674 675 676 677 678 679 680 681 682 683 684 685 686 687 688 689 690 691 692 693 694 695 696 697 698 699 700 701 702 703 704 705 706 707 708 709 710 711 712 713 714 | """
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
@property
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)
|