Rasheed commited on
Commit
d71d180
·
verified ·
1 Parent(s): 7faf3db

Upload 3 files

Browse files
Files changed (3) hide show
  1. app.py +7 -6
  2. groqai.py +78 -6
  3. proofread.py +68 -28
app.py CHANGED
@@ -1586,11 +1586,12 @@ with gr.Blocks(title=f"{APP_NAME} · CS Software Solutions", theme=theme, css=CS
1586
  sd_status = gr.HTML(H.status_idle("Upload a .docx and press **Detect & humanize document**."))
1587
  sd_file = gr.File(label="Download humanized .docx", interactive=False)
1588
  sd_dl = gr.HTML()
1589
- sd_pr_btn = gr.Button("🔍 Rigorous proofread (keeps the AI score)", variant="secondary")
1590
- gr.Markdown("<small>After humanizing: every sentence is proofread (grammar first, then "
1591
- "clarity, academic wording and flow) and removes doubled terms and AI "
1592
- "leftovers. Grammar fixes are always made; style edits only if the AI score "
1593
- "holds. Free, does not use a document from your plan.</small>")
 
1594
  sd_pr_status = gr.HTML()
1595
  sd_pr_file = gr.File(label="Download proofread .docx", interactive=False)
1596
  gr.HTML(next_steps_html())
@@ -1733,7 +1734,7 @@ with gr.Blocks(title=f"{APP_NAME} · CS Software Solutions", theme=theme, css=CS
1733
  hd_status = gr.HTML(H.status_idle("Upload a document and press **Paraphrase document**."))
1734
  hd_file = gr.File(label="Download paraphrased .docx", interactive=False)
1735
  hd_dl = gr.HTML()
1736
- hd_pr_btn = gr.Button("🔍 Rigorous proofread (keeps the AI score)", variant="secondary")
1737
  hd_pr_status = gr.HTML()
1738
  hd_pr_file = gr.File(label="Download proofread .docx", interactive=False)
1739
  hd_pr_dl = gr.HTML()
 
1586
  sd_status = gr.HTML(H.status_idle("Upload a .docx and press **Detect & humanize document**."))
1587
  sd_file = gr.File(label="Download humanized .docx", interactive=False)
1588
  sd_dl = gr.HTML()
1589
+ sd_pr_btn = gr.Button("🔍 Final check & proofread (keeps the AI score)", variant="secondary")
1590
+ gr.Markdown("<small>After humanizing: one last full check of the whole document in a "
1591
+ "single pass: grammar, unclear sentences, academic wording, flow and "
1592
+ "consistent terms, compared with your original. Grammar fixes are always "
1593
+ "made; style edits only if the AI score holds. Problems only the author can "
1594
+ "decide are listed. Free, does not use a document from your plan.</small>")
1595
  sd_pr_status = gr.HTML()
1596
  sd_pr_file = gr.File(label="Download proofread .docx", interactive=False)
1597
  gr.HTML(next_steps_html())
 
1734
  hd_status = gr.HTML(H.status_idle("Upload a document and press **Paraphrase document**."))
1735
  hd_file = gr.File(label="Download paraphrased .docx", interactive=False)
1736
  hd_dl = gr.HTML()
1737
+ hd_pr_btn = gr.Button("🔍 Final check & proofread (keeps the AI score)", variant="secondary")
1738
  hd_pr_status = gr.HTML()
1739
  hd_pr_file = gr.File(label="Download proofread .docx", interactive=False)
1740
  hd_pr_dl = gr.HTML()
groqai.py CHANGED
@@ -217,6 +217,8 @@ OA_STATS = {"calls": 0, "tokens": 0, "last_error": ""}
217
 
218
  PAR_LIMIT = _cv.ContextVar("llm_parallel", default=0) # >0: fewer requests at once (proofread on Groq)
219
  REASONING = _cv.ContextVar("llm_reasoning", default="") # "" = GROQ_REASONING / OPENAI_REASONING
 
 
220
 
221
 
222
  def _par():
@@ -277,7 +279,8 @@ def _chat_openai(messages, temperature, max_tokens):
277
  waited = 0.0
278
  for attempt in range(10):
279
  try:
280
- r = httpx.post(OPENAI_URL + "/chat/completions", json=body, headers=_oa_headers(), timeout=180)
 
281
  except Exception as err:
282
  OA_STATS["last_error"] = f"network: {err}"
283
  if attempt >= 2:
@@ -421,8 +424,10 @@ def _chat(messages, temperature, max_tokens):
421
  model = models[0]
422
  effort = REASONING.get() or REASONING_DEFAULT
423
 
 
 
424
  def budget(m, base):
425
- return min(MAX_OUT, base + (_REASON_ALLOW.get(effort, 700) if _is_reasoning(m) else 0))
426
  body = {"model": model, "messages": messages, "temperature": temperature,
427
  "max_tokens": budget(model, max_tokens), "response_format": {"type": "json_object"}}
428
  if "gpt-oss" in model:
@@ -442,7 +447,7 @@ def _chat(messages, temperature, max_tokens):
442
  continue
443
  try:
444
  try:
445
- r = httpx.post(URL + "/chat/completions", json=body, headers=_headers(k), timeout=120)
446
  except Exception as err:
447
  STATS["errors"] += 1
448
  STATS["last_error"] = f"network: {err}"
@@ -465,10 +470,14 @@ def _chat(messages, temperature, max_tokens):
465
  _pause_key(k, min(max(ra, 60), 6 * 3600) if daily else ra, "daily quota" if daily else "rate limit")
466
  STATS["last_error"] = f"rate limit (key {k + 1}): {txt[:160]}"
467
  continue # next key at once
468
- if r.status_code == 413 or (r.status_code == 400 and "max_tokens" in txt and "must be" in txt.lower()):
 
469
  if body["max_tokens"] > 1024: # budget above what this model accepts: shrink and retry
470
  body["max_tokens"] = max(1024, body["max_tokens"] // 2)
471
  continue
 
 
 
472
  if r.status_code == 401:
473
  _pause_key(k, 3600, "key rejected (401)")
474
  STATS["last_error"] = f"Groq key {k + 1} rejected (401) — check that secret."
@@ -511,9 +520,9 @@ def _chat(messages, temperature, max_tokens):
511
  _KEY_STATE[k]["tokens"] += used
512
  choice = (data.get("choices") or [{}])[0]
513
  content = (choice.get("message") or {}).get("content") or ""
514
- if choice.get("finish_reason") == "length" and grown < 1 and body["max_tokens"] < MAX_OUT:
515
  grown += 1 # reply cut off: one retry with a larger budget
516
- body["max_tokens"] = min(MAX_OUT, int(body["max_tokens"] * 1.8) + 256)
517
  STATS["truncated"] = STATS.get("truncated", 0) + 1
518
  continue
519
  return content
@@ -995,3 +1004,66 @@ def variants(sentences, glossary=(), originals=None, n=3, progress=None, lo=0.8,
995
  sizes = [len(s) * (n + 1) + len(o or "") for s, o in zip(sentences, originals)]
996
  return _run_batches("variants", list(zip(sentences, originals)), sizes, 8, BATCH_CHARS * 2,
997
  lambda its: _variants_batch(its, glossary, n, min_change), None, lo, hi, "Alternatives")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
217
 
218
  PAR_LIMIT = _cv.ContextVar("llm_parallel", default=0) # >0: fewer requests at once (proofread on Groq)
219
  REASONING = _cv.ContextVar("llm_reasoning", default="") # "" = GROQ_REASONING / OPENAI_REASONING
220
+ OUT_CAP = _cv.ContextVar("llm_out_cap", default=0) # >0: larger max_tokens cap for this call
221
+ TIMEOUT = _cv.ContextVar("llm_timeout", default=0) # >0: longer HTTP timeout (s) for this call
222
 
223
 
224
  def _par():
 
279
  waited = 0.0
280
  for attempt in range(10):
281
  try:
282
+ r = httpx.post(OPENAI_URL + "/chat/completions", json=body, headers=_oa_headers(),
283
+ timeout=TIMEOUT.get() or 180)
284
  except Exception as err:
285
  OA_STATS["last_error"] = f"network: {err}"
286
  if attempt >= 2:
 
424
  model = models[0]
425
  effort = REASONING.get() or REASONING_DEFAULT
426
 
427
+ cap = OUT_CAP.get() or MAX_OUT
428
+
429
  def budget(m, base):
430
+ return min(cap, base + (_REASON_ALLOW.get(effort, 700) if _is_reasoning(m) else 0))
431
  body = {"model": model, "messages": messages, "temperature": temperature,
432
  "max_tokens": budget(model, max_tokens), "response_format": {"type": "json_object"}}
433
  if "gpt-oss" in model:
 
447
  continue
448
  try:
449
  try:
450
+ r = httpx.post(URL + "/chat/completions", json=body, headers=_headers(k), timeout=TIMEOUT.get() or 120)
451
  except Exception as err:
452
  STATS["errors"] += 1
453
  STATS["last_error"] = f"network: {err}"
 
470
  _pause_key(k, min(max(ra, 60), 6 * 3600) if daily else ra, "daily quota" if daily else "rate limit")
471
  STATS["last_error"] = f"rate limit (key {k + 1}): {txt[:160]}"
472
  continue # next key at once
473
+ if (r.status_code in (400, 413) and "max_tokens" in txt and ("must be" in txt.lower() or
474
+ "too large" in txt.lower())):
475
  if body["max_tokens"] > 1024: # budget above what this model accepts: shrink and retry
476
  body["max_tokens"] = max(1024, body["max_tokens"] // 2)
477
  continue
478
+ if r.status_code == 413: # the request itself is too large for this key / model
479
+ STATS["last_error"] = f"request too large: {txt[:160]}"
480
+ raise Unavailable(STATS["last_error"])
481
  if r.status_code == 401:
482
  _pause_key(k, 3600, "key rejected (401)")
483
  STATS["last_error"] = f"Groq key {k + 1} rejected (401) — check that secret."
 
520
  _KEY_STATE[k]["tokens"] += used
521
  choice = (data.get("choices") or [{}])[0]
522
  content = (choice.get("message") or {}).get("content") or ""
523
+ if choice.get("finish_reason") == "length" and grown < 1 and body["max_tokens"] < cap:
524
  grown += 1 # reply cut off: one retry with a larger budget
525
+ body["max_tokens"] = min(cap, int(body["max_tokens"] * 1.8) + 256)
526
  STATS["truncated"] = STATS.get("truncated", 0) + 1
527
  continue
528
  return content
 
1004
  sizes = [len(s) * (n + 1) + len(o or "") for s, o in zip(sentences, originals)]
1005
  return _run_batches("variants", list(zip(sentences, originals)), sizes, 8, BATCH_CHARS * 2,
1006
  lambda its: _variants_batch(its, glossary, n, min_change), None, lo, hi, "Alternatives")
1007
+
1008
+
1009
+ # ---------------------------------------------------------------- one-call final check of a whole document
1010
+ DOC_SYSTEM = POLISH_SYSTEM.replace(
1011
+ "It is given paragraph by paragraph; each numbered item is\none sentence of the CURRENT text.",
1012
+ "The WHOLE document is given at once, paragraph by paragraph; each numbered item is one sentence of the CURRENT "
1013
+ "text.").replace(
1014
+ "Answer only with JSON:", "DOCUMENT-LEVEL CHECKS (you see the whole text, so also):\n"
1015
+ "- every construct listed under CONSTRUCTS keeps exactly that name everywhere (never pleasure/contentment/"
1016
+ "happiness/enjoyment for satisfaction, closeness for intimacy, allegiance/devotion for loyalty);\n"
1017
+ "- the same group words as the original (men/women, male/female, genders), the same numbers and the same "
1018
+ "statistics, including operators: never add, remove or change <, >, = (if one is missing, leave it and list it "
1019
+ "in notes);\n"
1020
+ "- every sentence complete (no cut-off endings, no fragments, no two sentences run together), sentences flow "
1021
+ "into each other within each paragraph, commas in lists and after introductory phrases.\n"
1022
+ "In \"notes\", list (short, at most 12) problems you must NOT fix yourself because they need the author: "
1023
+ "inconsistent abbreviations (e.g. EWB vs EWD), a construct named two ways, missing statistics operators, "
1024
+ "results described in the wrong direction, an unexplained place or name.\n"
1025
+ "List in \"items\" ONLY the sentences you changed; leave every correct sentence out.\n"
1026
+ "Answer only with JSON:").replace(
1027
+ '"unchanged": [1, 4]}. ' + _DIFF_RULE, '"notes": ["..."]}').replace(
1028
+ 'List in \"items\" ONLY the sentences you changed;',
1029
+ 'status: "repaired" = unclear/garbled/meaning fixed, "fixed" = a grammar, spelling, punctuation or wrong-term '
1030
+ 'error corrected, "edited" = style or flow only. List in \"items\" ONLY the sentences you changed;')
1031
+
1032
+
1033
+ def proofread_document(sentences, groups, originals, glossary, constructs=()):
1034
+ """ONE request for the whole document: clarity repair + grammar + academic wording + flow + document-level
1035
+ consistency. Returns ([(status, text) per sentence], notes). Unchanged sentences come back as ("ok", text).
1036
+ Raises Unavailable when the provider cannot take the request (too large, quota): the caller falls back to the
1037
+ batched proofread."""
1038
+ n = len(sentences)
1039
+ terms = _surface_terms(" ".join(sentences), glossary)
1040
+ lines, last, para = [], object(), 0
1041
+ for i, (g, t, orig) in enumerate(zip(groups, sentences, originals), 1):
1042
+ if g != last:
1043
+ para += 1
1044
+ lines.append(f"\n[Paragraph {para}]")
1045
+ if orig:
1046
+ lines.append(f"ORIGINAL: {orig}")
1047
+ lines.append("CURRENT:")
1048
+ last = g
1049
+ lines.append(f"{i}. {t}")
1050
+ user = ("CONSTRUCTS: " + ("; ".join(constructs) if constructs else "(none)") + "\n"
1051
+ + "PROTECTED TERMS: " + ("; ".join(terms) if terms else "(none)") + "\n" + "\n".join(lines))
1052
+ chars = sum(len(t) for t in sentences)
1053
+ max_tokens = int(chars / 3.3 * 0.6) + 20 * n + 800 # a reply that changes up to ~60% of the text
1054
+ tok_c, tok_t = OUT_CAP.set(int(os.getenv("PROOF_SINGLE_MAX_OUT", "32000"))), \
1055
+ TIMEOUT.set(float(os.getenv("PROOF_SINGLE_TIMEOUT", "600")))
1056
+ try:
1057
+ content = _chat([{"role": "system", "content": DOC_SYSTEM}, {"role": "user", "content": user}], 0.2,
1058
+ max_tokens)
1059
+ finally:
1060
+ OUT_CAP.reset(tok_c)
1061
+ TIMEOUT.reset(tok_t)
1062
+ data = _load_json(content)
1063
+ if not data or ("items" not in data and "notes" not in data):
1064
+ raise Unavailable("one-call proofread: no usable answer")
1065
+ res = _parse(content, n, sentences)
1066
+ if data.get("_salvaged"): # cut-off reply: the missing ids were NOT checked
1067
+ res = [r if r != (None, None) else (None, None) for r in res]
1068
+ notes = [str(x).strip() for x in (data.get("notes") or []) if str(x).strip()][:12]
1069
+ return res, notes
proofread.py CHANGED
@@ -59,6 +59,11 @@ REPAIR_SIM = float(os.getenv("PROOF_REPAIR_SIM", "0.60"))
59
  REPAIR_SIM_NOREF = float(os.getenv("PROOF_REPAIR_SIM_NOREF", "0.70"))
60
  # reasoning effort of the full proofread on reasoning models (the grammar audit stays on GROQ_REASONING)
61
  REASONING = os.getenv("PROOF_REASONING", "medium").lower()
 
 
 
 
 
62
  PAR_TOL = int(os.getenv("PROOF_PAR_TOL", "0")) # flagged words a paragraph may gain after balancing
63
  PARROT = os.getenv("PROOF_PARROT", "0") == "1"
64
  # which AI does the proofreading: Groq by default (PROOF_PROVIDER=openai for ChatGPT). If the chosen one is out of
@@ -309,35 +314,66 @@ def _proofread(path, orig_path=None, rep=None, hi=0.80, mod=0.50):
309
  for i in FL.bad(base):
310
  defect[i] = True
311
 
312
- # 1. grammar audit: Groq's grammar-only correction tells which sentences contain an error (and gives the
313
- # minimal fix for them). Sentences with an error or a defect are REQUIRED fixes: correctness comes first.
314
- rep(0.15, "Grammar audit of every sentence")
315
  work = [i for i in range(len(jobs)) if i not in drops]
316
- minimal = {}
317
- g = llm.run(G.grammar, [base[i] for i in work], glossary, rep, 0.15, 0.35, size=BATCH)
318
- unchecked = sum(1 for t in g if t is None)
319
- if work and unchecked == len(work):
320
- raise G.Unavailable("The proofreading engine has reached its usage limit for now. Please try again in a "
321
- "while; nothing in your document was changed.")
322
- cands = [None] * len(jobs)
323
- for i, t in zip(work, g):
324
- cands[i] = t
325
- minimal = _checked(jobs, cands, glossary, minimal=True)
326
- required = {i for i in work if defect[i] or i in minimal}
327
-
328
- # 2. full proofread (clarity + grammar + academic wording + flow)
329
  ctx = [j["par"] or j["sent"] for j in jobs]
330
  ctx = [c if c and c.strip() != t.strip() else None for c, t in zip(ctx, texts)]
331
  groups = [id(j["b"]) for j in jobs]
332
  idx_all = list(range(len(jobs)))
333
- tok_r = G.REASONING.set(REASONING) # deeper reading for the full proofread
334
- try:
335
- res = llm.run(lambda ids, *a: G.polish([base[i] for i in ids], [groups[i] for i in ids], glossary, rep,
336
- 0.35, 0.65, originals=[ctx[i] for i in ids], size=BATCH),
337
- idx_all, missing=lambda r: r is None or r[1] is None)
338
- finally:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
339
  G.REASONING.reset(tok_r)
340
- res = [r if r is not None else (None, None) for r in res]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
341
  asked_repair = {i for i, (st, _) in enumerate(res) if st == "repaired"}
342
  full = _checked(jobs, [t for _, t in res], glossary, repaired=asked_repair)
343
  for i in drops:
@@ -380,7 +416,7 @@ def _proofread(path, orig_path=None, rep=None, hi=0.80, mod=0.50):
380
 
381
  # 3. verification rounds: every corrected or required sentence is checked again until Groq finds no error
382
  n_verify = 0
383
- for rnd in range(ROUNDS):
384
  # a sentence whose text IS the grammar check's own output is not sent again (it was just checked)
385
  chk = [i for i in work if (i in final or i in required) and final.get(i, base[i]) != minimal.get(i)]
386
  if not chk:
@@ -466,7 +502,7 @@ def _proofread(path, orig_path=None, rep=None, hi=0.80, mod=0.50):
466
  verified.setdefault(i, set()).add(t)
467
 
468
  n_reworded = 0
469
- for rnd in range(AI_ROUNDS):
470
  ids = [i for i in work if final.get(i)]
471
  if not ids:
472
  break
@@ -539,7 +575,7 @@ def _proofread(path, orig_path=None, rep=None, hi=0.80, mod=0.50):
539
  worse.append((pid, was))
540
  if not worse:
541
  break
542
- if attempt == 1: # more correct versions for the sentences in those paragraphs
543
  gen_alts([i for pid, _ in worse for i in by_par[pid] if i in final and final[i]], 0.88)
544
  for pid, was in worse:
545
  choice = {i: final[i] for i in by_par[pid] if i in final}
@@ -614,7 +650,8 @@ def _proofread(path, orig_path=None, rep=None, hi=0.80, mod=0.50):
614
  after = D.summarise(S._score_doc(out_doc), hi, mod)
615
  out = H._save_output(out_doc, S._clean_stem(path), "_proofread")
616
  who = " + ".join(_NAME.get(p, p) for p in llm.order if p in llm.used) or llm.name
617
- note = (f"🔍 Rigorous proofread by {who}: {n} of {len(jobs)} sentences corrected"
 
618
  + (f" ({len(required)} had a grammar error or defect and were always corrected"
619
  + (f", {len(repaired)} unclear sentence(s) repaired to the original meaning" if repaired else "")
620
  + (f", {n_minimal} with the minimal grammar fix" if n_minimal else "")
@@ -634,5 +671,8 @@ def _proofread(path, orig_path=None, rep=None, hi=0.80, mod=0.50):
634
  "later to finish them." if unchecked else "")
635
  + ("" if orig_path else " Tip: run it right after humanizing, so your original is used as the meaning "
636
  "reference for unclear sentences."))
 
 
637
  print(f"[proofread] {note}", flush=True)
638
- return dict(path=out, before=before, after=after, n=n, total=len(jobs), note=note)
 
 
59
  REPAIR_SIM_NOREF = float(os.getenv("PROOF_REPAIR_SIM_NOREF", "0.70"))
60
  # reasoning effort of the full proofread on reasoning models (the grammar audit stays on GROQ_REASONING)
61
  REASONING = os.getenv("PROOF_REASONING", "medium").lower()
62
+ # single (default): the whole document is checked and corrected in ONE request (grammar, clarity, academic wording,
63
+ # flow and document-wide consistency), and the reply lists problems that need the author. Every correction still
64
+ # goes through the local checks. If the provider cannot take one request of that size, or it fails, the batched
65
+ # proofread (several smaller requests) runs instead. batched = always the batched proofread.
66
+ MODE = os.getenv("PROOF_MODE", "single").lower()
67
  PAR_TOL = int(os.getenv("PROOF_PAR_TOL", "0")) # flagged words a paragraph may gain after balancing
68
  PARROT = os.getenv("PROOF_PARROT", "0") == "1"
69
  # which AI does the proofreading: Groq by default (PROOF_PROVIDER=openai for ChatGPT). If the chosen one is out of
 
314
  for i in FL.bad(base):
315
  defect[i] = True
316
 
 
 
 
317
  work = [i for i in range(len(jobs)) if i not in drops]
 
 
 
 
 
 
 
 
 
 
 
 
 
318
  ctx = [j["par"] or j["sent"] for j in jobs]
319
  ctx = [c if c and c.strip() != t.strip() else None for c, t in zip(ctx, texts)]
320
  groups = [id(j["b"]) for j in jobs]
321
  idx_all = list(range(len(jobs)))
322
+ minimal, notes, single, unchecked = {}, [], False, 0
323
+
324
+ # ONE request for the whole document (PROOF_MODE=single): grammar, clarity, wording, flow and consistency
325
+ if MODE == "single":
326
+ rep(0.2, "Final check: the whole document is read and corrected in one pass")
327
+ constructs = sorted({re.sub(r"\s+", " ", m.group(0)).strip() for m in re.finditer(
328
+ r"(?:[A-Za-z][\w\-]*\s+){1,4}\([A-Z][A-Za-z0-9\-]{1,10}\)",
329
+ " ".join(b.raw for b in D.docx_blocks(doc) if b.kind == "para" and b.raw))})[:40]
330
+ tok_r = G.REASONING.set(REASONING)
331
+ for prov in llm.order: # one call; the other provider only if the first fails
332
+ tok_p = G.PROVIDER.set(prov)
333
+ try:
334
+ res, notes = G.proofread_document([base[i] for i in idx_all], groups, ctx, glossary, constructs)
335
+ llm.used.add(prov)
336
+ single = True
337
+ break
338
+ except G.Unavailable as err:
339
+ print(f"[proofread] one-call check not possible on {prov}: {err}", flush=True)
340
+ except Exception as err:
341
+ print(f"[proofread] one-call check failed on {prov}: {err}", flush=True)
342
+ finally:
343
+ G.PROVIDER.reset(tok_p)
344
  G.REASONING.reset(tok_r)
345
+ if single:
346
+ unchecked = sum(1 for r in res if r == (None, None))
347
+ res = [r if r != (None, None) else ("ok", base[i]) for i, r in enumerate(res)]
348
+ fixed_ids = {i for i, (st, _) in enumerate(res) if st in ("fixed", "repaired")}
349
+ required = {i for i in work if defect[i] or i in fixed_ids}
350
+ else:
351
+ rep(0.2, "The document is too large for one request here: checking it in parts")
352
+
353
+ if not single:
354
+ # 1. grammar audit: Groq's grammar-only correction tells which sentences contain an error (and gives the
355
+ # minimal fix for them). Sentences with an error or a defect are REQUIRED fixes: correctness comes first.
356
+ rep(0.15, "Grammar audit of every sentence")
357
+ g = llm.run(G.grammar, [base[i] for i in work], glossary, rep, 0.15, 0.35, size=BATCH)
358
+ unchecked = sum(1 for t in g if t is None)
359
+ if work and unchecked == len(work):
360
+ raise G.Unavailable("The proofreading engine has reached its usage limit for now. Please try again in "
361
+ "a while; nothing in your document was changed.")
362
+ cands = [None] * len(jobs)
363
+ for i, t in zip(work, g):
364
+ cands[i] = t
365
+ minimal = _checked(jobs, cands, glossary, minimal=True)
366
+ required = {i for i in work if defect[i] or i in minimal}
367
+
368
+ # 2. full proofread (clarity + grammar + academic wording + flow)
369
+ tok_r = G.REASONING.set(REASONING) # deeper reading for the full proofread
370
+ try:
371
+ res = llm.run(lambda ids, *a: G.polish([base[i] for i in ids], [groups[i] for i in ids], glossary,
372
+ rep, 0.35, 0.65, originals=[ctx[i] for i in ids], size=BATCH),
373
+ idx_all, missing=lambda r: r is None or r[1] is None)
374
+ finally:
375
+ G.REASONING.reset(tok_r)
376
+ res = [r if r is not None else (None, None) for r in res]
377
  asked_repair = {i for i, (st, _) in enumerate(res) if st == "repaired"}
378
  full = _checked(jobs, [t for _, t in res], glossary, repaired=asked_repair)
379
  for i in drops:
 
416
 
417
  # 3. verification rounds: every corrected or required sentence is checked again until Groq finds no error
418
  n_verify = 0
419
+ for rnd in range(0 if single else ROUNDS):
420
  # a sentence whose text IS the grammar check's own output is not sent again (it was just checked)
421
  chk = [i for i in work if (i in final or i in required) and final.get(i, base[i]) != minimal.get(i)]
422
  if not chk:
 
502
  verified.setdefault(i, set()).add(t)
503
 
504
  n_reworded = 0
505
+ for rnd in range(0 if single else AI_ROUNDS):
506
  ids = [i for i in work if final.get(i)]
507
  if not ids:
508
  break
 
575
  worse.append((pid, was))
576
  if not worse:
577
  break
578
+ if attempt == 1 and not single: # more correct versions for the sentences in those paragraphs
579
  gen_alts([i for pid, _ in worse for i in by_par[pid] if i in final and final[i]], 0.88)
580
  for pid, was in worse:
581
  choice = {i: final[i] for i in by_par[pid] if i in final}
 
650
  after = D.summarise(S._score_doc(out_doc), hi, mod)
651
  out = H._save_output(out_doc, S._clean_stem(path), "_proofread")
652
  who = " + ".join(_NAME.get(p, p) for p in llm.order if p in llm.used) or llm.name
653
+ note = (("🔍 Final check in one request" if single else "🔍 Rigorous proofread")
654
+ + f" by {who}: {n} of {len(jobs)} sentences corrected"
655
  + (f" ({len(required)} had a grammar error or defect and were always corrected"
656
  + (f", {len(repaired)} unclear sentence(s) repaired to the original meaning" if repaired else "")
657
  + (f", {n_minimal} with the minimal grammar fix" if n_minimal else "")
 
671
  "later to finish them." if unchecked else "")
672
  + ("" if orig_path else " Tip: run it right after humanizing, so your original is used as the meaning "
673
  "reference for unclear sentences."))
674
+ if notes: # document-level problems that need the author
675
+ note += " 📝 For the author: " + " · ".join(notes)
676
  print(f"[proofread] {note}", flush=True)
677
+ return dict(path=out, before=before, after=after, n=n, total=len(jobs), note=note, notes=notes,
678
+ single=single)