Spaces:
Paused
Paused
Upload 3 files
Browse files- app.py +7 -6
- groqai.py +78 -6
- 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("🔍
|
| 1590 |
-
gr.Markdown("<small>After humanizing:
|
| 1591 |
-
"
|
| 1592 |
-
"
|
| 1593 |
-
"
|
|
|
|
| 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("🔍
|
| 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(),
|
|
|
|
| 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(
|
| 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
|
|
|
|
| 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"] <
|
| 515 |
grown += 1 # reply cut off: one retry with a larger budget
|
| 516 |
-
body["max_tokens"] = min(
|
| 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 |
-
|
| 334 |
-
|
| 335 |
-
|
| 336 |
-
|
| 337 |
-
|
| 338 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 339 |
G.REASONING.reset(tok_r)
|
| 340 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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:
|
| 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 = (
|
|
|
|
| 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)
|