unknown Claude Opus 5.5 commited on
Commit
eaf69a7
·
2 Parent(s): 726e1ced71d180

Merge the Space's 8-9 October work (origin/main) into the local changes

Browse files

The Space had moved on (adaptive learning, detector-guided rewriting,
token-budget handling in groqai, shared integrity checks, proofread
changes). Conflicts in groqai, humanizer and textparse were resolved by
keeping both sides:

- groqai: the Space's token-budget chat loop, with the per-model
fallback on a rate limit added to it.
- humanizer: the Space's PARA_MODEL fallback inside the lazy
load_model(); the automatic deeper rewrite (BOOST) feeds the adaptive
candidate count and the plain-wording source.
- textparse: both sets of guard checks run; strict_check keeps its
rejection reasons and gains the Space's integrity checks.
- integrity_issue: "bracket type changed" no longer fires when an
author-year citation is dropped (it has no "["), so the guard can
re-attach the citation again.

53 unit tests pass; text and Word end-to-end runs complete on the merge.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

Files changed (12) hide show
  1. README.md +77 -2
  2. adaptive.py +344 -0
  3. adversarial.py +164 -0
  4. app.py +9 -6
  5. clarity.py +3 -1
  6. groqai.py +494 -172
  7. humanizer.py +95 -14
  8. proofread.py +678 -575
  9. pubrules.py +12 -2
  10. smart.py +71 -6
  11. tests/test_groqai.py +2 -1
  12. textparse.py +315 -10
README.md CHANGED
@@ -75,7 +75,7 @@ Optional variables: `PAY_WHATSAPP` (digits with country code), `PAY_EMAIL`, `PRO
75
  | `APP_NAME` | variable (optional) | Header title (default "CS AI Writing Suite") |
76
  | `REPORT_BRAND` | variable (optional) | Header text on PDF report pages |
77
  | `MAX_PASTE_WORDS` | variable (optional) | Paste limit |
78
- | `DETECTOR_MODEL`, `PARROT_MODEL` | variable (optional) | Swap models |
79
  | `USERS_REPO` / `USERS_REPO_TOKEN` | secret | Private dataset (e.g. `you/suite-users`) + write token: backs up accounts so they survive restarts |
80
  | `DAILY_DOC_LIMIT` | variable (optional) | Document runs per signed-in user per day (default 20, 0 = unlimited) |
81
  | `DAILY_TEXT_LIMIT` | variable (optional) | Pasted-text runs per signed-in user per day (default 100) |
@@ -261,7 +261,7 @@ Scores are indicators from an open-source detector and will differ from Turnitin
261
 
262
  ## Licences
263
 
264
- Parrot paraphraser: Apache-2.0 · RoBERTa AI detector: Apache-2.0.
265
 
266
  ## Final grammar check (Groq)
267
  After all rewriting is finished, Groq corrects grammar, spelling and punctuation only (no rewording). A correction is
@@ -308,3 +308,78 @@ paragraph as a whole reads as AI-written again (`POLISH_KEEP_HUMAN=0` switches t
308
  | `POLISH` | `1` | `0` = grammar-only final check |
309
  | `POLISH_MAX_EDIT` | `0.6` | Largest share of a sentence's words the polish may change |
310
  | `POLISH_KEEP_HUMAN` | `1` | `0` = always keep the polished wording, even if the AI score rises |
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
75
  | `APP_NAME` | variable (optional) | Header title (default "CS AI Writing Suite") |
76
  | `REPORT_BRAND` | variable (optional) | Header text on PDF report pages |
77
  | `MAX_PASTE_WORDS` | variable (optional) | Paste limit |
78
+ | `DETECTOR_MODEL`, `PARA_MODEL` | variable (optional) | Swap models (`PARA_MODEL` = paraphraser; the older name `PARROT_MODEL` still works) |
79
  | `USERS_REPO` / `USERS_REPO_TOKEN` | secret | Private dataset (e.g. `you/suite-users`) + write token: backs up accounts so they survive restarts |
80
  | `DAILY_DOC_LIMIT` | variable (optional) | Document runs per signed-in user per day (default 20, 0 = unlimited) |
81
  | `DAILY_TEXT_LIMIT` | variable (optional) | Pasted-text runs per signed-in user per day (default 100) |
 
261
 
262
  ## Licences
263
 
264
+ Paraphraser: humarin/chatgpt_paraphraser_on_T5_base, OpenRAIL (default) · Parrot paraphraser (fallback): Apache-2.0 · RoBERTa AI detector: Apache-2.0.
265
 
266
  ## Final grammar check (Groq)
267
  After all rewriting is finished, Groq corrects grammar, spelling and punctuation only (no rewording). A correction is
 
308
  | `POLISH` | `1` | `0` = grammar-only final check |
309
  | `POLISH_MAX_EDIT` | `0.6` | Largest share of a sentence's words the polish may change |
310
  | `POLISH_KEEP_HUMAN` | `1` | `0` = always keep the polished wording, even if the AI score rises |
311
+
312
+ ## Token use and key distribution (groqai.py)
313
+
314
+ - **Changes-only replies**: the grammar check, quality check and polish return only the sentences they changed;
315
+ every other id is listed under `"unchanged"`. Ids missing from both lists are retried, so nothing is skipped.
316
+ This cuts output tokens by roughly 80% on a typical document.
317
+ - **Size-based batches**: a request holds up to `GROQ_BATCH` sentences (default 20) and about `GROQ_BATCH_CHARS`
318
+ characters of text (default 6000), so the fixed system prompt is shared by more sentences (about 40% fewer requests).
319
+ - **Token-aware key choice**: each Groq reply reports the key's remaining tokens per minute; the next request goes
320
+ to the key with the most room (requests in flight are booked against it), and waits only when no key has room.
321
+ - **Right-sized `max_tokens`**: sized to the reply, plus a separate allowance for the hidden reasoning of
322
+ reasoning models (`GROQ_REASONING`: low / medium / high). A reply cut off at the limit is retried once with a
323
+ larger budget, and complete items are salvaged from a cut-off reply.
324
+ - **Cache**: a sentence already checked (grammar / quality check) is not sent again (`GROQ_CACHE`, default 20000).
325
+ - Admin → Groq status now shows tokens per key and cache hits.
326
+
327
+ ## Rigorous proofread: quality settings
328
+
329
+ - Repairs of unclear or garbled sentences are judged against the author's original sentence (they used to be
330
+ rejected for not resembling the garbled text). They count as required fixes and are never undone or reworded
331
+ by the AI-score steps.
332
+ - A sentence with a grammar error gets the full correction when its AI rise stays within `PROOF_REQ_RISE`;
333
+ otherwise it gets the minimal grammar fix.
334
+ - The full proofread runs with `PROOF_REASONING` (default `medium`); the grammar audit stays on `low`.
335
+
336
+ | Variable | Default | Effect |
337
+ |---|---|---|
338
+ | `PROOF_KEEP_RISE` | `0.05` | Largest AI rise allowed for a style-only edit (was 0.03) |
339
+ | `PROOF_ALT_RISE` | `0.10` | A corrected sentence is reworded for the AI score only above this rise |
340
+ | `PROOF_MAX_DOC_RISE` | `2.0` | Document AI points allowed before style edits are undone (was 1.0) |
341
+ | `PROOF_VARIANTS` | `3` | Alternative versions per sentence when rewording (was 4) |
342
+ | `PROOF_REPAIR_SIM` | `0.60` | Minimum similarity of a repair to the current sentence (original known) |
343
+ | `PROOF_REPAIR_SIM_NOREF` | `0.70` | Same, when no original document is given |
344
+ | `PROOF_REASONING` | `medium` | Reasoning effort of the full proofread |
345
+
346
+ ## Paraphrase model
347
+
348
+ Parrot (`prithivida/parrot_paraphraser_on_T5`) is the paraphraser. `PARA_MODEL` (or the older `PARROT_MODEL`) can
349
+ name another T5 paraphraser with the same `paraphrase:` input, e.g. `humarin/chatgpt_paraphraser_on_T5_base`
350
+ (`PARA_REP_PENALTY`, default 1.0 for Parrot and 1.15 for other models, discourages copying the input back).
351
+
352
+ ## Adversarial Paraphrasing (detector-guided rewriting, adversarial.py)
353
+
354
+ Technique from Cheng et al. (2025), *Adversarial Paraphrasing: A Universal Attack for Humanizing AI-Generated Text*
355
+ (arXiv:2506.07001). While Parrot writes a sentence, the AI detector chooses the words: at every step Parrot proposes
356
+ its most likely next tokens, each one is appended to the text so far and scored by the detector, and the most
357
+ human-reading one is kept. Parrot keeps the sentence fluent (only its own likely tokens are allowed); the detector
358
+ steers the wording. The result is one extra version per sentence next to Parrot's sampled versions, and it goes
359
+ through exactly the same checks (terms, numbers, citations, meaning, fluency, clarity gate, Groq), so a damaged
360
+ version is never chosen.
361
+
362
+ | Variable | Default | Effect |
363
+ |---|---|---|
364
+ | `HUM_ADV` | `ai` | `ai` = "100% AI-written" box and report-guided rewriting · `always` · `off` |
365
+ | `ADV_K` | `8` | Candidate words scored per step (the paper uses 50; more = stronger, slower) |
366
+ | `ADV_TOP_P` / `ADV_MIN_P` | `0.95` / `0.05` | Only Parrot's likely words are candidates (keeps grammar intact) |
367
+ | `ADV_EVERY` | `1` | Guide every n-th word (`2` halves the detector cost) |
368
+ | `ADV_BATCH` / `ADV_MAX` | `16` / `400` | Sentences decoded together / most sentence pieces per job |
369
+ | `ADV_ON_ZERO` | `0` | ZeroGPU hardware: off (it would need nested GPU calls); `1` = run on CPU |
370
+
371
+ Cost: about 2-3x the paraphrasing time for the sentences it is used on (dedicated GPU). Admin → Users shows its
372
+ counters next to the Groq status.
373
+
374
+ ## Protection against construct renaming and other rewrite errors (textparse.py)
375
+
376
+ Checked on every rewrite, in every mode (including report-guided rewriting, where ordinary words are free):
377
+ - **Study constructs are locked.** Terms defined with an abbreviation ("travel satisfaction (TS)") and the Keywords
378
+ line are read from the whole document; a rewrite that drops one, or brings in an everyday synonym for it
379
+ ("pleasure"/"contentment" for satisfaction, "closeness" for intimacy, "allegiance" for loyalty), is rejected.
380
+ - **Abbreviations** may not change ("EWB" -> "EWD") or appear from nowhere.
381
+ - **Statistics symbols** (< > ≤ ≥ = ± ×) must survive ("p < .001" never becomes "p .001").
382
+ - **Brackets** must stay balanced ("(TS amongst ...").
383
+ - **Foreign words** ("well-être") and **off-topic vocabulary** (2+ uncommon words that occur nowhere in the
384
+ document, e.g. "organizational recruitment" in a tourism paper; `HUM_OFFTOPIC`, 0 = off) are rejected.
385
+ A rejected rewrite falls back to another checked version, or to the author's sentence.
adaptive.py ADDED
@@ -0,0 +1,344 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Self-adaptive learning: the humanizer learns from every run and uses what it learned in the next pass and the next
3
+ document. Nothing here can loosen a safety check: every rewrite still has to pass the guard, the clarity gate and
4
+ Groq; learning only decides HOW to generate (which settings, which sources, which words to avoid) so that more of
5
+ what is generated survives the checks and reads as human.
6
+
7
+ What it learns
8
+ 1. Bad words (cross-document). Every rejected rewrite is examined: the words it brought in that the original
9
+ did not have are counted against the rejection reason (construct renamed, group swapped, invented name,
10
+ attribution, off-topic ...). A word that keeps causing rejections ("tribes", "allegiance", "muscle") and is
11
+ rarely part of an accepted rewrite is banned: Parrot is told never to generate it (bad_words_ids) and the
12
+ guard rejects it at once.
13
+ 2. Which source wins (cross-document). Parrot samples, detector-guided (adversarial) versions, plain-wording
14
+ (de-AI) versions and vocabulary-replaced versions compete; how often each source's surviving versions are
15
+ the one chosen is tracked (Beta posterior). Sources that keep winning get more candidates, sources that
16
+ never win are skipped.
17
+ 3. How many candidates to sample (cross-document). If few candidates survive the checks, more are sampled.
18
+ 4. Temperature (cross-document, Smart Humanize). Each pass picks a temperature offset by Thompson sampling;
19
+ the reward is the AI-score drop of that pass minus a penalty for rejected rewrites, so the setting that
20
+ produces the best accepted output on real documents is used more often.
21
+ 5. Per sentence, within a document (Smart Humanize passes). A sentence whose AI score did not fall gets a
22
+ bolder setting next pass (higher temperature, detector-guided decoding); a sentence whose rewrites keep
23
+ being rejected gets a calmer setting, and after ADAPT_MAX_FAILS failures it is left in the author's wording
24
+ (saves time, avoids damage).
25
+
26
+ Storage: one JSON file (ADAPT_PATH; default /data/cs_adaptive.json on Spaces with persistent storage, else the temp
27
+ folder). Statistics decay slowly (ADAPT_DECAY) so the system keeps adapting when models or detectors change.
28
+
29
+ Space variables
30
+ ADAPT 1 (default) | 0 = off (fixed settings, as before)
31
+ ADAPT_PATH file for the learned state
32
+ ADAPT_BAN_MIN rejections before a word can be banned (default 4)
33
+ ADAPT_BAN_RATIO share of rejections among its uses needed to ban it (default 0.8)
34
+ ADAPT_MAX_FAILS passes with only rejected rewrites before a sentence is left as written (default 3)
35
+ ADAPT_DECAY per-update decay of the source / temperature statistics (default 0.995)
36
+ """
37
+ import contextvars
38
+ import json
39
+ import os
40
+ import random
41
+ import re
42
+ import tempfile
43
+ import threading
44
+ import time
45
+
46
+ ENABLED = os.getenv("ADAPT", "1") != "0"
47
+ BAN_MIN = float(os.getenv("ADAPT_BAN_MIN", "4"))
48
+ BAN_RATIO = float(os.getenv("ADAPT_BAN_RATIO", "0.8"))
49
+ MAX_FAILS = int(os.getenv("ADAPT_MAX_FAILS", "3"))
50
+ DECAY = float(os.getenv("ADAPT_DECAY", "0.995"))
51
+ FLAG = float(os.getenv("ADAPT_FLAG", "0.5")) # a sentence at/above this AI probability still needs work
52
+ TEMP_ARMS = [-0.2, 0.0, 0.15, 0.3]
53
+ # words that are never right in an academic rewrite of someone else's study (learned list starts from these)
54
+ SEED_BANNED = {"tribes", "tribe", "muscle", "muscles", "être"}
55
+ # rejection reasons that point at the WORDS a rewrite introduced (not at its length or punctuation)
56
+ _WORD_REASONS = ("construct renamed", "group or degree swapped", "name", "attribution", "off-topic", "foreign",
57
+ "invented", "non-word", "informal", "lost", "abbreviation", "direction reversed", "causal")
58
+
59
+
60
+ def _default_path():
61
+ p = os.getenv("ADAPT_PATH", "").strip()
62
+ if p:
63
+ return p
64
+ if os.path.isdir("/data") and os.access("/data", os.W_OK):
65
+ return "/data/cs_adaptive.json"
66
+ return os.path.join(tempfile.gettempdir(), "cs_adaptive.json")
67
+
68
+
69
+ PATH = _default_path()
70
+ _lock = threading.RLock()
71
+ _state = {"words": {}, "sources": {}, "accept": [1.0, 1.0], "temps": {}, "runs": 0, "banned_extra": []}
72
+ _dirty = [False, 0.0]
73
+
74
+
75
+ def _load():
76
+ try:
77
+ with open(PATH, encoding="utf-8") as f:
78
+ data = json.load(f)
79
+ if isinstance(data, dict):
80
+ _state.update({k: data[k] for k in _state if k in data})
81
+ except FileNotFoundError:
82
+ pass
83
+ except Exception as err:
84
+ print(f"[adaptive] state not loaded ({err}); starting fresh", flush=True)
85
+
86
+
87
+ _load()
88
+
89
+
90
+ def save(force=False):
91
+ """Write the learned state (at most every 30 s unless forced)."""
92
+ if not ENABLED:
93
+ return
94
+ with _lock:
95
+ if not _dirty[0] or (not force and time.time() - _dirty[1] < 30):
96
+ return
97
+ try:
98
+ tmp = PATH + ".tmp"
99
+ with open(tmp, "w", encoding="utf-8") as f:
100
+ json.dump(_state, f)
101
+ os.replace(tmp, PATH)
102
+ _dirty[0], _dirty[1] = False, time.time()
103
+ except Exception as err:
104
+ print(f"[adaptive] state not saved: {err}", flush=True)
105
+
106
+
107
+ def _touch():
108
+ _dirty[0] = True
109
+ save()
110
+
111
+
112
+ # ------------------------------------------------------------------------------------------- 1. bad words
113
+ def _introduced(original, cand):
114
+ o = set(re.findall(r"[a-zà-ÿ]{4,}", (original or "").lower()))
115
+ return {w for w in re.findall(r"[a-zà-ÿ]{4,}", (cand or "").lower()) if w not in o}
116
+
117
+
118
+ def record_candidate(original, cand, source, accepted, reason=None):
119
+ """One generated rewrite and whether it survived the checks."""
120
+ if not ENABLED or not cand or not original or cand.strip() == original.strip():
121
+ return
122
+ with _lock:
123
+ a = _state["accept"]
124
+ a[0 if accepted else 1] += 1
125
+ new = _introduced(original, cand)
126
+ words = _state["words"]
127
+ word_reason = (not accepted) and reason and any(r in reason for r in _WORD_REASONS)
128
+ for w in new:
129
+ st = words.setdefault(w, [0.0, 0.0]) # [times in a rejected rewrite (word reason), accepted]
130
+ if accepted:
131
+ st[1] += 1
132
+ elif word_reason:
133
+ st[0] += 1
134
+ if len(words) > 50000: # forget rare words
135
+ for w in [w for w, (b, g) in words.items() if b + g <= 1][:20000]:
136
+ words.pop(w, None)
137
+ s = _state["sources"].setdefault(source, [1.0, 1.0, 0.0]) # [wins+1, losses+1, survivors]
138
+ s[2] += 1 if accepted else 0
139
+ _touch()
140
+
141
+
142
+ def banned():
143
+ """Words never to generate: the seed list + words that keep causing rejections."""
144
+ if not ENABLED:
145
+ return set()
146
+ with _lock:
147
+ out = set(SEED_BANNED) | set(_state.get("banned_extra") or [])
148
+ for w, (bad, good) in _state["words"].items():
149
+ if bad >= BAN_MIN and bad / max(1.0, bad + good) >= BAN_RATIO and not _common(w):
150
+ out.add(w)
151
+ return out
152
+
153
+
154
+ try:
155
+ from wordfreq import zipf_frequency as _zipf
156
+ except Exception:
157
+ _zipf = None
158
+
159
+
160
+ def _common(w):
161
+ """Very common words ("shows", "between") are never banned automatically: they only travel along with the
162
+ word that really caused a rejection."""
163
+ return _zipf is not None and _zipf(w, "en") >= 5.0
164
+
165
+
166
+ def ban_word(word):
167
+ """Admin: ban a word by hand (e.g. after reviewing a client's output)."""
168
+ with _lock:
169
+ lst = set(_state.get("banned_extra") or [])
170
+ lst.add(word.strip().lower())
171
+ _state["banned_extra"] = sorted(lst)
172
+ _touch()
173
+ save(force=True)
174
+
175
+
176
+ def banned_in(original, cand):
177
+ """A banned word the rewrite introduces, or None."""
178
+ b = banned()
179
+ if not b:
180
+ return None
181
+ hit = _introduced(original, cand) & b
182
+ return sorted(hit)[0] if hit else None
183
+
184
+
185
+ _bw_cache = {}
186
+
187
+
188
+ def bad_words_ids(tokenizer):
189
+ """Token sequences of the banned words, for model.generate(bad_words_ids=...)."""
190
+ b = banned()
191
+ key = (id(tokenizer), frozenset(b))
192
+ if key in _bw_cache:
193
+ return _bw_cache[key]
194
+ ids = []
195
+ for w in sorted(b):
196
+ for form in (w, " " + w, w.capitalize(), " " + w.capitalize()):
197
+ try:
198
+ t = tokenizer(form, add_special_tokens=False).input_ids
199
+ except Exception:
200
+ continue
201
+ if t and t not in ids and len(t) <= 4:
202
+ ids.append(t)
203
+ _bw_cache.clear()
204
+ _bw_cache[key] = ids or None
205
+ return _bw_cache[key]
206
+
207
+
208
+ # ------------------------------------------------------------------------------------------- 2-3. sources, k
209
+ def record_win(source, won):
210
+ """Among the versions that survived the checks for one sentence: did this source's version get chosen?"""
211
+ if not ENABLED:
212
+ return
213
+ with _lock:
214
+ s = _state["sources"].setdefault(source, [1.0, 1.0, 0.0])
215
+ s[0], s[1] = s[0] * DECAY, s[1] * DECAY # slow forgetting
216
+ s[0 if won else 1] += 1
217
+ _touch()
218
+
219
+
220
+ def source_weight(source):
221
+ """Thompson sample of how likely this source's version is to be the chosen one (0..1)."""
222
+ if not ENABLED:
223
+ return 1.0
224
+ with _lock:
225
+ a, b, _ = _state["sources"].get(source, [1.0, 1.0, 0.0])
226
+ return random.betavariate(max(a, 0.1), max(b, 0.1))
227
+
228
+
229
+ def use_source(source, min_share=0.08):
230
+ """Skip a source only when it has clearly and repeatedly lost (keeps exploring otherwise)."""
231
+ if not ENABLED:
232
+ return True
233
+ with _lock:
234
+ a, b, _ = _state["sources"].get(source, [1.0, 1.0, 0.0])
235
+ if a + b < 30:
236
+ return True
237
+ return a / (a + b) >= min_share or random.random() < 0.1
238
+
239
+
240
+ def candidates(k, cap=8):
241
+ """More samples when few survive the checks."""
242
+ if not ENABLED:
243
+ return k
244
+ with _lock:
245
+ ok, bad = _state["accept"]
246
+ rate = ok / max(1.0, ok + bad)
247
+ if ok + bad < 50:
248
+ return k
249
+ if rate < 0.25:
250
+ return min(cap, k + 2)
251
+ if rate < 0.4:
252
+ return min(cap, k + 1)
253
+ return k
254
+
255
+
256
+ # ------------------------------------------------------------------------------------------- 4. temperature
257
+ def pick_temp_offset():
258
+ """Thompson sampling over TEMP_ARMS (Gaussian posterior of the reward)."""
259
+ if not ENABLED:
260
+ return 0.0
261
+ with _lock:
262
+ stats = dict(_state["temps"])
263
+ best, best_v = 0.0, None
264
+ for arm in TEMP_ARMS:
265
+ n, mean = stats.get(str(arm), [0.0, 0.0])
266
+ v = random.gauss(mean, 1.0 / (1.0 + n) ** 0.5) if n else random.gauss(0.0, 1.0)
267
+ if best_v is None or v > best_v:
268
+ best, best_v = arm, v
269
+ return best
270
+
271
+
272
+ def record_pass(offset, ai_before, ai_after, n_jobs, n_changed):
273
+ """Reward of one Smart Humanize pass: AI-% drop, minus a penalty when most rewrites were rejected."""
274
+ if not ENABLED or n_jobs <= 0:
275
+ return
276
+ drop = (ai_before - ai_after) / 10.0 # 10 points = reward 1
277
+ kept = n_changed / max(1, n_jobs)
278
+ reward = drop - 0.5 * (1.0 - kept)
279
+ with _lock:
280
+ n, mean = _state["temps"].get(str(offset), [0.0, 0.0])
281
+ n = n * DECAY
282
+ _state["temps"][str(offset)] = [n + 1, mean + (reward - mean) / (n + 1)]
283
+ _state["runs"] += 1
284
+ _touch()
285
+
286
+
287
+ # ------------------------------------------------------------------------------------------- 5. per sentence
288
+ SENT = contextvars.ContextVar("adapt_sentences", default=None) # job: anchor -> dict(score, fails, temp, bold)
289
+
290
+
291
+ def start_job():
292
+ SENT.set({})
293
+
294
+
295
+ def observe(anchor, score):
296
+ """Pass k scored this sentence; compare with pass k-1 and adjust its settings for pass k+1."""
297
+ d = SENT.get()
298
+ if d is None or not ENABLED:
299
+ return
300
+ st = d.setdefault(anchor, {"score": None, "fails": 0, "dt": 0.0, "bold": False, "tried": 0, "changed": False})
301
+ if st["score"] is not None and st.get("changed") and score >= FLAG:
302
+ if st["score"] - score < 0.05: # rewritten but still reads as AI: be bolder next time
303
+ st["dt"] = min(0.5, st["dt"] + 0.15)
304
+ st["bold"] = True
305
+ st["score"] = score
306
+
307
+
308
+ def outcome(anchor, changed):
309
+ """This pass's rewrite of the sentence was written into the document (changed) or every version failed."""
310
+ d = SENT.get()
311
+ if d is None or not ENABLED:
312
+ return
313
+ st = d.setdefault(anchor, {"score": None, "fails": 0, "dt": 0.0, "bold": False, "tried": 0, "changed": False})
314
+ st["tried"] += 1
315
+ st["changed"] = bool(changed)
316
+ if changed:
317
+ st["fails"] = 0
318
+ else:
319
+ st["fails"] += 1
320
+ st["dt"] = max(-0.3, st["dt"] - 0.15) # its rewrites keep failing the checks: calmer
321
+
322
+
323
+ def settings(anchor):
324
+ """(temperature delta, detector-guided?, skip?) for this sentence in the next pass."""
325
+ d = SENT.get()
326
+ if d is None or not ENABLED:
327
+ return 0.0, False, False
328
+ st = d.get(anchor)
329
+ if not st:
330
+ return 0.0, False, False
331
+ return st["dt"], st["bold"], st["fails"] >= MAX_FAILS
332
+
333
+
334
+ def status():
335
+ if not ENABLED:
336
+ return "Adaptive learning: off (ADAPT=0)."
337
+ with _lock:
338
+ ok, bad = _state["accept"]
339
+ src = ", ".join(f"{k} {a / (a + b):.0%}" for k, (a, b, _) in sorted(_state["sources"].items()))
340
+ temps = ", ".join(f"{k}: {m:+.2f} ({n:.0f})" for k, (n, m) in sorted(_state["temps"].items()))
341
+ nb = len(banned())
342
+ return (f"Adaptive learning: {_state['runs']} passes learned from; {ok + bad - 2:.0f} rewrites seen, "
343
+ f"{ok / max(1, ok + bad):.0%} pass the checks; {nb} banned words; source win rates: {src or '-'}; "
344
+ f"temperature rewards: {temps or '-'}. State file: {PATH}")
adversarial.py ADDED
@@ -0,0 +1,164 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Adversarial Paraphrasing (detector-guided decoding) for the Parrot paraphraser.
3
+
4
+ Technique from "Adversarial Paraphrasing: A Universal Attack for Humanizing AI-Generated Text" (Cheng et al., 2025,
5
+ arXiv:2506.07001): while the paraphraser writes, the AI detector chooses the words. At each decoding step the
6
+ paraphraser proposes its most likely next tokens (top-k inside top-p); every proposal is appended to the text written
7
+ so far and scored by the detector, and the proposal that reads as the MOST HUMAN is kept. The paraphraser keeps the
8
+ sentence fluent and on-meaning (only its own likely tokens are allowed); the detector steers word choice.
9
+
10
+ Here it adds one extra candidate per sentence next to Parrot's sampled versions. That candidate goes through exactly
11
+ the same checks as every other version (terms / numbers / citations restored and guarded, meaning, fluency, clarity
12
+ gate, Groq check), so a damaged adversarial sentence is simply not chosen.
13
+
14
+ Cost: one detector batch per guided step (sentences x candidates texts). On a dedicated GPU this is roughly 2-3x the
15
+ time of normal paraphrasing for the sentences it is used on; on CPU it is much slower, so by default it runs only in
16
+ the "100% AI-written" / report-guided modes and on at most ADV_MAX sentence pieces per job.
17
+
18
+ Space variables
19
+ HUM_ADV ai (default: 100% AI mode and report-guided rewriting) | always | off
20
+ ADV_K candidate tokens scored per step (default 8; the paper uses 50)
21
+ ADV_TOP_P nucleus for the candidates (default 0.95)
22
+ ADV_MIN_P a candidate needs at least this share of the top token's probability (default 0.05): keeps the
23
+ detector from picking an unlikely, ungrammatical word
24
+ ADV_EVERY guide every n-th token (default 1 = every token, as in the paper; 2 halves the detector cost)
25
+ ADV_BATCH sentences decoded together (default 16)
26
+ ADV_MAX most sentence pieces per job (default 400)
27
+ ADV_ON_ZERO 1 = also run on ZeroGPU hardware (on the CPU copy of the model; slow). Default 0.
28
+ """
29
+ import os
30
+
31
+ MODE = os.getenv("HUM_ADV", "ai").strip().lower()
32
+ K = max(2, int(os.getenv("ADV_K", "8")))
33
+ TOP_P = float(os.getenv("ADV_TOP_P", "0.95"))
34
+ MIN_P = float(os.getenv("ADV_MIN_P", "0.05"))
35
+ EVERY = max(1, int(os.getenv("ADV_EVERY", "1")))
36
+ BATCH = max(1, int(os.getenv("ADV_BATCH", "16")))
37
+ MAX_PIECES = max(0, int(os.getenv("ADV_MAX", "400")))
38
+ ON_ZERO = os.getenv("ADV_ON_ZERO", "0") == "1"
39
+ STATS = {"runs": 0, "pieces": 0, "steps": 0, "scored": 0, "errors": 0, "last_error": ""}
40
+
41
+
42
+ def wanted(full_ai):
43
+ """Is detector-guided decoding used for this job?"""
44
+ if MODE in ("off", "0", "no", "false"):
45
+ return False
46
+ return MODE == "always" or bool(full_ai)
47
+
48
+
49
+ def _no_repeat(prev_ids, n=3):
50
+ """Tokens that would repeat an n-gram already written (same rule as Parrot's no_repeat_ngram_size=3)."""
51
+ if len(prev_ids) < n - 1:
52
+ return set()
53
+ tail = tuple(prev_ids[-(n - 1):])
54
+ banned = set()
55
+ for i in range(len(prev_ids) - n + 1):
56
+ if tuple(prev_ids[i:i + n - 1]) == tail:
57
+ banned.add(prev_ids[i + n - 1])
58
+ return banned
59
+
60
+
61
+ def generate(texts, model, tokenizer, device, score_fn, temperature=1.0, prefix="paraphrase: "):
62
+ """Detector-guided paraphrase of each text (one output per text; the input text where decoding fails).
63
+ score_fn(list[str]) -> list[float] AI probabilities (lower = more human), or None."""
64
+ import torch
65
+ outs = list(texts)
66
+ if not texts:
67
+ return outs
68
+ STATS["runs"] += 1
69
+ temperature = max(0.3, float(temperature))
70
+ order = sorted(range(len(texts)), key=lambda i: len(texts[i])) # similar lengths decode together
71
+ for b0 in range(0, len(order), BATCH):
72
+ ids_ = order[b0:b0 + BATCH]
73
+ batch = [texts[i] for i in ids_]
74
+ try:
75
+ res = _generate_batch(batch, model, tokenizer, device, score_fn, temperature, prefix, torch)
76
+ except Exception as err: # never break the job: Parrot's normal versions remain
77
+ STATS["errors"] += 1
78
+ STATS["last_error"] = str(err)[:200]
79
+ print(f"[adversarial] batch skipped: {err}", flush=True)
80
+ continue
81
+ for i, r in zip(ids_, res):
82
+ if r and r.strip():
83
+ outs[i] = r.strip()
84
+ STATS["pieces"] += len(batch)
85
+ return outs
86
+
87
+
88
+ def _generate_batch(batch, model, tokenizer, device, score_fn, temperature, prefix, torch):
89
+ enc = tokenizer([prefix + t for t in batch], return_tensors="pt", padding=True, truncation=True, max_length=256)
90
+ enc = {k: v.to(device) for k, v in enc.items()}
91
+ n = len(batch)
92
+ max_new = min(180, int(enc["input_ids"].shape[1] * 1.35) + 12)
93
+ eos = tokenizer.eos_token_id
94
+ start = model.config.decoder_start_token_id
95
+ if start is None:
96
+ start = tokenizer.pad_token_id
97
+ written = [[] for _ in range(n)]
98
+ done = [False] * n
99
+ with torch.inference_mode():
100
+ enc_out = model.get_encoder()(input_ids=enc["input_ids"], attention_mask=enc["attention_mask"])
101
+ last = torch.full((n, 1), start, dtype=torch.long, device=device)
102
+ past = None
103
+ for step in range(max_new):
104
+ out = model(encoder_outputs=enc_out, attention_mask=enc["attention_mask"], decoder_input_ids=last,
105
+ past_key_values=past, use_cache=True)
106
+ past = out.past_key_values
107
+ probs = torch.softmax(out.logits[:, -1, :].float() / temperature, dim=-1)
108
+ top_p, top_i = probs.topk(K, dim=-1)
109
+ top_p, top_i = top_p.cpu().tolist(), top_i.cpu().tolist()
110
+ choice = [eos] * n
111
+ texts, owners = [], []
112
+ for b in range(n):
113
+ if done[b]:
114
+ continue
115
+ banned = _no_repeat(written[b])
116
+ cands, cum = [], 0.0
117
+ for p, t in zip(top_p[b], top_i[b]):
118
+ if t in banned:
119
+ continue
120
+ if cands and (cum >= TOP_P or p < MIN_P * top_p[b][0]):
121
+ break
122
+ cands.append((t, p))
123
+ cum += p
124
+ if not cands:
125
+ cands = [(top_i[b][0], top_p[b][0])]
126
+ choice[b] = cands[0][0] # most likely token by default
127
+ if len(cands) > 1 and step % EVERY == 0:
128
+ for t, _ in cands:
129
+ if t == eos: # the sentence so far, as it would end here
130
+ texts.append(tokenizer.decode(written[b], skip_special_tokens=True))
131
+ else:
132
+ texts.append(tokenizer.decode(written[b] + [t], skip_special_tokens=True))
133
+ owners.append((b, t))
134
+ if texts:
135
+ sc = score_fn([t if t.strip() else "." for t in texts])
136
+ STATS["scored"] += len(texts)
137
+ if sc:
138
+ best = {}
139
+ for (b, t), s in zip(owners, sc):
140
+ if b not in best or s < best[b][0] - 1e-6: # ties keep the more likely (earlier) token
141
+ best[b] = (s, t)
142
+ for b, (_, t) in best.items():
143
+ choice[b] = t
144
+ STATS["steps"] += 1
145
+ for b in range(n):
146
+ if done[b]:
147
+ choice[b] = tokenizer.pad_token_id
148
+ continue
149
+ if choice[b] == eos:
150
+ done[b] = True
151
+ else:
152
+ written[b].append(choice[b])
153
+ if all(done):
154
+ break
155
+ last = torch.tensor([[c] for c in choice], dtype=torch.long, device=device)
156
+ return [tokenizer.decode(w, skip_special_tokens=True) for w in written]
157
+
158
+
159
+ def status():
160
+ if not wanted(True):
161
+ return "Adversarial paraphrasing: off (HUM_ADV=off)."
162
+ s = STATS
163
+ return (f"Adversarial paraphrasing ({MODE}, k={K}, every {EVERY} token): {s['pieces']} sentence pieces, "
164
+ f"{s['scored']:,} detector checks" + (f"; last error: {s['last_error']}" if s["last_error"] else "."))
app.py CHANGED
@@ -1564,11 +1564,12 @@ with gr.Blocks(title=f"{APP_NAME} · CS Software Solutions", theme=theme, css=CS
1564
  sd_status = gr.HTML(H.status_idle("Upload your .docx and press **Detect & humanize document**. Nothing else to set."))
1565
  sd_file = gr.File(label="Download humanized .docx", interactive=False)
1566
  sd_dl = gr.HTML()
1567
- sd_pr_btn = gr.Button("🔍 Rigorous proofread (keeps the AI score)", variant="secondary")
1568
- gr.Markdown("<small>After humanizing: every sentence is proofread (grammar first, then "
1569
- "clarity, academic wording and flow) and removes doubled terms and AI "
1570
- "leftovers. Grammar fixes are always made; style edits only if the AI score "
1571
- "holds. Free, does not use a document from your plan.</small>")
 
1572
  sd_pr_status = gr.HTML()
1573
  sd_pr_file = gr.File(label="Download proofread .docx", interactive=False)
1574
  gr.HTML(next_steps_html())
@@ -1741,7 +1742,7 @@ with gr.Blocks(title=f"{APP_NAME} · CS Software Solutions", theme=theme, css=CS
1741
  hd_status = gr.HTML(H.status_idle("Upload a document and press **Paraphrase document**."))
1742
  hd_file = gr.File(label="Download paraphrased .docx", interactive=False)
1743
  hd_dl = gr.HTML()
1744
- hd_pr_btn = gr.Button("🔍 Rigorous proofread (keeps the AI score)", variant="secondary")
1745
  hd_pr_status = gr.HTML()
1746
  hd_pr_file = gr.File(label="Download proofread .docx", interactive=False)
1747
  hd_pr_dl = gr.HTML()
@@ -2204,6 +2205,8 @@ with gr.Blocks(title=f"{APP_NAME} · CS Software Solutions", theme=theme, css=CS
2204
  ad_p_credits.click(admin_credit, [user_state, ad_p_email, ad_p_credits_n, ad_p_amount], plan_out, **HID)
2205
  admin_tab.select(lambda u: (P.admin_requests(u), P.admin_payments(u), A.admin_reset_requests(u), M.admin_log(u),
2206
  f'<div class="cs-gauge-cap" style="text-align:left">{html.escape(H.G.status())}</div>',
 
 
2207
  f'<div class="cs-gauge-cap" style="text-align:left">{html.escape(PU.status())}</div>',
2208
  P.admin_subscriptions(u), P.admin_history(u)),
2209
  user_state, [ad_req, ad_pay, ad_rr, ad_m_log, ad_groq, ad_push, ad_subs, ad_hist], **HID)
 
1564
  sd_status = gr.HTML(H.status_idle("Upload your .docx and press **Detect & humanize document**. Nothing else to set."))
1565
  sd_file = gr.File(label="Download humanized .docx", interactive=False)
1566
  sd_dl = gr.HTML()
1567
+ sd_pr_btn = gr.Button("🔍 Final check & proofread (keeps the AI score)", variant="secondary")
1568
+ gr.Markdown("<small>After humanizing: one last full check of the whole document in a "
1569
+ "single pass: grammar, unclear sentences, academic wording, flow and "
1570
+ "consistent terms, compared with your original. Grammar fixes are always "
1571
+ "made; style edits only if the AI score holds. Problems only the author can "
1572
+ "decide are listed. Free, does not use a document from your plan.</small>")
1573
  sd_pr_status = gr.HTML()
1574
  sd_pr_file = gr.File(label="Download proofread .docx", interactive=False)
1575
  gr.HTML(next_steps_html())
 
1742
  hd_status = gr.HTML(H.status_idle("Upload a document and press **Paraphrase document**."))
1743
  hd_file = gr.File(label="Download paraphrased .docx", interactive=False)
1744
  hd_dl = gr.HTML()
1745
+ hd_pr_btn = gr.Button("🔍 Final check & proofread (keeps the AI score)", variant="secondary")
1746
  hd_pr_status = gr.HTML()
1747
  hd_pr_file = gr.File(label="Download proofread .docx", interactive=False)
1748
  hd_pr_dl = gr.HTML()
 
2205
  ad_p_credits.click(admin_credit, [user_state, ad_p_email, ad_p_credits_n, ad_p_amount], plan_out, **HID)
2206
  admin_tab.select(lambda u: (P.admin_requests(u), P.admin_payments(u), A.admin_reset_requests(u), M.admin_log(u),
2207
  f'<div class="cs-gauge-cap" style="text-align:left">{html.escape(H.G.status())}</div>',
2208
+ f'<div class="cs-gauge-cap" style="text-align:left">{html.escape(H.ADV.status())}</div>',
2209
+ f'<div class="cs-gauge-cap" style="text-align:left">{html.escape(H.AD.status())}</div>',
2210
  f'<div class="cs-gauge-cap" style="text-align:left">{html.escape(PU.status())}</div>',
2211
  P.admin_subscriptions(u), P.admin_history(u)),
2212
  user_state, [ad_req, ad_pay, ad_rr, ad_m_log, ad_groq, ad_push, ad_subs, ad_hist], **HID)
clarity.py CHANGED
@@ -165,9 +165,11 @@ def _acronym_repeat(t):
165
 
166
 
167
  def _garbled(o, n):
168
- why = TP.quality_issue(o, n)
169
  if why:
170
  return why
 
 
171
  v = PR.violations(o, n) # publication English rulebook (pubrules.py)
172
  if v:
173
  return "rule: " + v[0]
 
165
 
166
 
167
  def _garbled(o, n):
168
+ why = TP.quality_issue(o, n) or TP.integrity_issue(o, n)
169
  if why:
170
  return why
171
+ if TP._symbols(n) != TP._symbols(o):
172
+ return "statistics symbol changed"
173
  v = PR.violations(o, n) # publication English rulebook (pubrules.py)
174
  if v:
175
  return "rule: " + v[0]
groqai.py CHANGED
@@ -38,21 +38,99 @@ def _read_keys():
38
  KEYS = _read_keys()
39
  KEY = KEYS[0] if KEYS else ""
40
  ENABLED = bool(KEYS) and os.getenv("GROQ_ENABLED", "1") != "0"
41
- # per key: paused until (rate limit / daily quota / rejected key), requests sent, last error
42
- _KEY_STATE = [{"until": 0.0, "calls": 0, "err": ""} for _ in KEYS]
 
 
 
43
  _rr = [0] # round-robin position
44
  URL = "https://api.groq.com/openai/v1"
45
  PREF = [m.strip() for m in os.getenv(
46
  "GROQ_MODELS", "openai/gpt-oss-120b,llama-3.3-70b-versatile,qwen/qwen3.8-27b,openai/gpt-oss-20b").split(",")
47
  if m.strip()]
48
- BATCH = max(1, int(os.getenv("GROQ_BATCH", "12")))
 
 
 
49
  MAX_WAIT = float(os.getenv("GROQ_MAX_WAIT", "60"))
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
50
 
51
  _lock = threading.RLock()
52
  _models = None # models available on this key (ordered by preference)
53
  _down_until = 0.0 # daily quota exhausted -> skip Groq until then
54
  STATS = {"calls": 0, "checked": 0, "ok": 0, "fixed": 0, "rejected": 0, "errors": 0, "tokens": 0,
55
- "last_error": ""}
56
 
57
 
58
  class Unavailable(Exception):
@@ -63,17 +141,44 @@ def _headers(k=0):
63
  return {"Authorization": f"Bearer {KEYS[k] if KEYS else ''}", "Content-Type": "application/json"}
64
 
65
 
66
- def _pick_key():
67
- """Next usable key (round robin, so the load is spread over all keys); None if all are paused."""
 
 
 
 
 
 
 
 
 
68
  now = time.time()
69
  with _lock:
70
  n = len(KEYS)
 
71
  for step in range(n):
72
  k = (_rr[0] + step) % n
73
- if _KEY_STATE[k]["until"] <= now:
74
- _rr[0] = (k + 1) % n
75
- return k
76
- return None
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
77
 
78
 
79
  def _pause_key(k, seconds, why):
@@ -96,10 +201,18 @@ def _models_free_in(k):
96
  return max(1.0, min((_KM_UNTIL.get((k, m), 0.0) for m in _model_list()), default=now) - now)
97
 
98
 
99
- def _next_free_in():
100
- """Seconds until the first paused key is usable again."""
 
101
  now = time.time()
102
- return max(0.0, min((st["until"] for st in _KEY_STATE), default=0.0) - now)
 
 
 
 
 
 
 
103
 
104
 
105
  # ---------------------------------------------------------------- OpenAI (ChatGPT) as an alternative provider
@@ -117,6 +230,9 @@ OA_STATS = {"calls": 0, "tokens": 0, "last_error": ""}
117
 
118
 
119
  PAR_LIMIT = _cv.ContextVar("llm_parallel", default=0) # >0: fewer requests at once (proofread on Groq)
 
 
 
120
 
121
 
122
  def _par():
@@ -124,13 +240,16 @@ def _par():
124
  return max(1, min(PARALLEL, lim) if lim else PARALLEL)
125
 
126
 
127
- def _in_provider(provider, fn, *args):
128
  """Run fn in a worker thread with the caller's provider (context variables do not cross thread pools)."""
129
  tok = PROVIDER.set(provider)
 
130
  try:
131
  return fn(*args)
132
  finally:
133
  PROVIDER.reset(tok)
 
 
134
 
135
 
136
  def openai_available():
@@ -170,11 +289,12 @@ def _chat_openai(messages, temperature, max_tokens):
170
  "max_completion_tokens": max_tokens + (2000 if model.startswith(("gpt-5", "o")) else 0),
171
  "response_format": {"type": "json_object"}}
172
  if model.startswith(("gpt-5", "o3", "o4")):
173
- body["reasoning_effort"] = os.getenv("OPENAI_REASONING", "low")
174
  waited = 0.0
175
  for attempt in range(10):
176
  try:
177
- r = httpx.post(OPENAI_URL + "/chat/completions", json=body, headers=_oa_headers(), timeout=180)
 
178
  except Exception as err:
179
  OA_STATS["last_error"] = f"network: {err}"
180
  if attempt >= 2:
@@ -282,22 +402,31 @@ def status():
282
  down = f" · quota pause until {time.strftime('%H:%M UTC', time.gmtime(_down_until))}" \
283
  if time.time() < _down_until else ""
284
  now = time.time()
285
- keys = " · ".join(f"key {i + 1}: {st['calls']} calls" + (f", paused {int(st['until'] - now)}s ({st['err']})"
286
- if st["until"] > now else "")
 
 
287
  for i, st in enumerate(_KEY_STATE))
288
  oa = (f" OpenAI (proofread): ON, {(_oa['models'] or OPENAI_PREF)[0]}, {OA_STATS['calls']} calls, "
289
  f"~{OA_STATS['tokens']:,} tokens" + (f", last error: {OA_STATS['last_error']}" if OA_STATS["last_error"] else "")
290
  + ".") if OPENAI_KEY else ""
291
  return oa + (f" Groq quality check is ON ({m}), {len(KEYS)} key(s) [{keys}]{down}. Since restart: {s['checked']} Parrot sentences checked — "
292
  f"{s['ok']} fine as written, {s['fixed']} repaired, {s['rejected']} rejected (original kept); "
293
- f"~{s['tokens']:,} tokens."
294
  + (f" Last error: {s['last_error']}" if s["last_error"] else ""))
295
 
296
 
 
 
 
 
297
  def _chat(messages, temperature, max_tokens):
298
- """One chat completion (JSON mode). With several keys, a rate-limited key is paused and the request moves to
299
- the next key at once; it waits only when every key is paused (short limits), and raises Unavailable when all
300
- keys are out for longer than GROQ_MAX_WAIT."""
 
 
 
301
  global _down_until
302
  import httpx
303
  if PROVIDER.get() == "openai":
@@ -305,36 +434,56 @@ def _chat(messages, temperature, max_tokens):
305
  if not available():
306
  raise Unavailable("Groq quota paused")
307
  waited = 0.0
308
- body = {"messages": messages, "temperature": temperature, "max_tokens": max_tokens,
309
- "response_format": {"type": "json_object"}}
 
 
 
 
 
 
 
 
 
 
 
 
310
  plain = set() # models that do not take reasoning_effort
311
- for attempt in range(8 + 2 * len(KEYS) + 2 * len(PREF)):
312
- k = _pick_key()
313
- if k is None: # every key is paused: wait for the first one, if short
314
- wait = _next_free_in()
 
315
  if wait > MAX_WAIT or waited + wait > MAX_WAIT:
316
  STATS["last_error"] = "rate limit on all Groq keys"
317
  raise Unavailable("Groq rate limit (all keys)")
318
  time.sleep(wait + 0.2)
319
- waited += wait
320
  continue
321
- model = _model_for(k)
322
- if model is None: # every model of this key is at its limit
 
323
  _pause_key(k, _models_free_in(k), "rate limit (all models)")
324
  continue
325
- body["model"] = model
326
- body.pop("reasoning_effort", None)
327
- if "gpt-oss" in model and model not in plain:
328
- body["reasoning_effort"] = "low"
 
 
329
  try:
330
- r = httpx.post(URL + "/chat/completions", json=body, headers=_headers(k), timeout=120)
331
- except Exception as err:
332
- STATS["errors"] += 1
333
- STATS["last_error"] = f"network: {err}"
334
- if attempt >= 2:
335
- raise Unavailable(str(err))
336
- time.sleep(2 * (attempt + 1))
337
- continue
 
 
 
 
338
  with _lock:
339
  _KEY_STATE[k]["calls"] += 1
340
  txt = r.text[:400]
@@ -350,7 +499,15 @@ def _chat(messages, temperature, max_tokens):
350
  with _lock:
351
  _KM_UNTIL[(k, model)] = time.time() + (min(max(ra, 60), 6 * 3600) if daily else ra)
352
  STATS["last_error"] = f"{'daily quota' if daily else 'rate limit'} ({model}, key {k + 1}): {txt[:120]}"
353
- continue
 
 
 
 
 
 
 
 
354
  if r.status_code == 401:
355
  _pause_key(k, 3600, "key rejected (401)")
356
  STATS["last_error"] = f"Groq key {k + 1} rejected (401) — check that secret."
@@ -362,12 +519,19 @@ def _chat(messages, temperature, max_tokens):
362
  w in txt.lower() for w in ("decommission", "not found", "does not exist", "not available",
363
  "no access", "deprecated")):
364
  _drop_model(model)
365
- if _model_list()[0] == model: # the last model left
 
366
  STATS["last_error"] = f"model unavailable: {txt[:160]}"
367
  raise Unavailable(STATS["last_error"])
 
 
 
 
 
368
  continue
369
  if r.status_code == 400 and "reasoning_effort" in txt:
370
  plain.add(model)
 
371
  continue
372
  if r.status_code == 400 and ("response_format" in txt or "json" in txt.lower()) and "response_format" in body:
373
  body.pop("response_format", None) # model without JSON mode, or JSON validation failed
@@ -380,9 +544,19 @@ def _chat(messages, temperature, max_tokens):
380
  STATS["last_error"] = f"HTTP {r.status_code}: {txt[:160]}"
381
  raise Unavailable(STATS["last_error"])
382
  data = r.json()
383
- STATS["calls"] += 1
384
- STATS["tokens"] += int((data.get("usage") or {}).get("total_tokens") or 0)
385
- return data["choices"][0]["message"].get("content") or ""
 
 
 
 
 
 
 
 
 
 
386
  raise Unavailable("Groq: too many retries")
387
 
388
 
@@ -390,6 +564,12 @@ _AI_WORDS = ("delve, crucial, pivotal, comprehensive, furthermore, moreover, not
390
  "robust, seamless, intricate, landscape, realm, underscore, showcase, tapestry, multifaceted, "
391
  "paramount, meticulous, holistic, foster, testament, ever-evolving")
392
 
 
 
 
 
 
 
393
  SYSTEM = f"""You are the quality checker of an academic paraphrasing tool.
394
  Each item has A (the author's original sentence) and B (the tool's paraphrase of A).
395
  Keep B's wording and sentence structure. Make only the minimum edits to B so that:
@@ -401,11 +581,12 @@ Keep B's wording and sentence structure. Make only the minimum edits to B so tha
401
  Reject B if it contains a non-existent word, a wrong-sense word that a reviewer would notice (e.g. "spotted"
402
  for "repeated", "tracks" for "reports"), first person singular, "you", contractions, opinions ("absurd to me"),
403
  or a redundant phrase ("an optimization framework for optimization") that cannot be fixed with small edits.
404
- Do not copy A back; keep B's paraphrasing wherever it is correct. Do not add these words: {_AI_WORDS}. No em dashes.
405
- status: "ok" if B needed no change, "fixed" if you edited it, "reject" if B cannot be repaired with small edits.
406
- Reject B if it breaks any rule below that small edits cannot fix.
407
  {PR.PROMPT}
408
- Answer only with JSON: {{"items": [{{"id": 1, "status": "ok|fixed|reject", "text": "final B"}}, ...]}} — one per id."""
 
409
 
410
 
411
  def _surface_terms(text, glossary):
@@ -425,15 +606,38 @@ def _surface_terms(text, glossary):
425
  return sorted(terms, key=str.lower)
426
 
427
 
428
- def _parse(content, n):
429
- """-> list of n (status, text) tuples; (None, None) when an item is missing."""
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
430
  out = [(None, None)] * n
431
- m = re.search(r"\{.*\}", content or "", re.S)
432
- if not m:
433
- return out
434
- try:
435
- data = json.loads(m.group(0))
436
- except Exception:
437
  return out
438
  out = list(out)
439
  for k, it in enumerate(data.get("items") or data.get("results") or []):
@@ -446,7 +650,80 @@ def _parse(content, n):
446
  st = str(it.get("status") or "").lower().strip()
447
  t = it.get("text")
448
  if 1 <= i <= n:
449
- out[i - 1] = (st, t.strip() if isinstance(t, str) else None)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
450
  return out
451
 
452
 
@@ -454,9 +731,9 @@ def _check_batch(pairs, glossary):
454
  terms = _surface_terms(" ".join(a for a, _ in pairs), glossary)
455
  user = ("PROTECTED TERMS: " + ("; ".join(terms) if terms else "(none)") + "\n\n"
456
  + "\n\n".join(f"{i}.\nA: {a}\nB: {b}" for i, (a, b) in enumerate(pairs, 1)))
457
- max_tokens = min(8000, 400 + int(sum(len(b) for _, b in pairs) * 0.6))
458
  content = _chat([{"role": "system", "content": SYSTEM}, {"role": "user", "content": user}], 0.1, max_tokens)
459
- return _parse(content, len(pairs))
460
 
461
 
462
  PARALLEL = max(1, int(os.getenv("GROQ_PARALLEL", str(min(12, 4 * max(1, len(KEYS)))))))
@@ -468,41 +745,43 @@ def review(pairs, glossary, progress=None, lo=0.85, hi=0.9):
468
  None -> rejected: keep the original sentence
469
  False -> not checked (Groq unavailable for this batch): caller uses its own rules
470
  Batches run GROQ_PARALLEL at a time. Raises Unavailable only if no batch at all could be checked."""
471
- from concurrent.futures import ThreadPoolExecutor, as_completed
472
  n = len(pairs)
473
  out = [False] * n
474
- starts = list(range(0, n, BATCH))
475
- if not starts:
 
 
 
 
 
 
 
 
476
  return out
477
- done, ok_batches, first_err = 0, 0, None
478
  if progress is not None:
479
- progress(lo, desc=f"Groq quality check of {n} sentences")
480
- with ThreadPoolExecutor(max_workers=min(_par(), len(starts))) as pool:
481
- futs = {pool.submit(_in_provider, PROVIDER.get(), _check_batch, pairs[st:st + BATCH], glossary): st for st in starts}
482
- for f in as_completed(futs):
483
- st = futs[f]
484
- done += 1
485
- try:
486
- res = f.result()
487
- except Unavailable as err:
488
- first_err = first_err or err
489
- continue
490
- ok_batches += 1
491
- for k, ((a, b), (status_, t)) in enumerate(zip(pairs[st:st + BATCH], res)):
492
- with _lock:
493
- STATS["checked"] += 1
494
- if status_ == "reject":
495
- STATS["rejected"] += 1
496
- elif t:
497
- STATS["fixed" if status_ == "fixed" and t.strip() != b.strip() else "ok"] += 1
498
- if status_ == "reject":
499
- out[st + k] = None
500
- elif t:
501
- out[st + k] = t
502
- if progress is not None:
503
- progress(lo + (hi - lo) * done / len(starts), desc=f"Groq quality check: {done}/{len(starts)} batches")
504
- if not ok_batches and first_err is not None:
505
- raise first_err
506
  return out
507
 
508
 
@@ -514,51 +793,46 @@ singular/plural, prepositions, missing or doubled words, run-on sentences and br
514
  Use the smallest change that makes the sentence correct; keep its meaning and its wording wherever it is already
515
  correct. Do NOT add new facts, names, people or numbers. Do NOT replace a correct word with a synonym.
516
  Keep every technical term, acronym, formula, symbol, number, unit and citation (e.g. [12], (Kumar et al., 2021))
517
- exactly as written, including every item in PROTECTED TERMS. Do not add these words: {_AI_WORDS}. No em dashes.
518
- If a sentence has no error, return it unchanged.
519
  {PR.PROMPT}
520
- Answer only with JSON: {{"items": [{{"id": 1, "text": "corrected sentence"}}, ...]}} — one per id."""
521
 
522
 
523
  def _grammar_batch(sents, glossary):
524
  terms = _surface_terms(" ".join(sents), glossary)
525
  user = ("PROTECTED TERMS: " + ("; ".join(terms) if terms else "(none)") + "\n\n"
526
  + "\n".join(f"{i}. {t}" for i, t in enumerate(sents, 1)))
527
- max_tokens = min(8000, 400 + int(sum(len(t) for t in sents) * 0.6))
528
  content = _chat([{"role": "system", "content": GRAMMAR_SYSTEM}, {"role": "user", "content": user}], 0.0, max_tokens)
529
- return [t for _, t in _parse(content, len(sents))]
530
 
531
 
532
  def grammar(sentences, glossary, progress=None, lo=0.9, hi=0.95, size=None):
533
  """Grammar-only correction. Returns one entry per sentence: the corrected text (may equal the input),
534
  or None if that batch could not be checked. Raises Unavailable only if nothing could be checked."""
535
- from concurrent.futures import ThreadPoolExecutor, as_completed
536
  n = len(sentences)
537
  out = [None] * n
538
- size = size or BATCH
539
- starts = list(range(0, n, size))
540
- if not starts:
 
 
 
 
 
 
 
 
541
  return out
542
- done, ok_batches, first_err = 0, 0, None
543
  if progress is not None:
544
- progress(lo, desc=f"Final grammar check of {n} sentences (Groq)")
545
- with ThreadPoolExecutor(max_workers=min(_par(), len(starts))) as pool:
546
- futs = {pool.submit(_in_provider, PROVIDER.get(), _grammar_batch, sentences[st:st + size], glossary): st for st in starts}
547
- for f in as_completed(futs):
548
- st = futs[f]
549
- done += 1
550
- try:
551
- res = f.result()
552
- except Unavailable as err:
553
- first_err = first_err or err
554
- continue
555
- ok_batches += 1
556
- for k, t in enumerate(res):
557
- out[st + k] = t
558
- if progress is not None:
559
- progress(lo + (hi - lo) * done / len(starts), desc=f"Final grammar check: {done}/{len(starts)} batches")
560
- if not ok_batches and first_err is not None:
561
- raise first_err
562
  return out
563
 
564
 
@@ -568,12 +842,16 @@ The text was produced by an automatic paraphrasing tool, which sometimes substit
568
  joins phrases so that a sentence no longer makes sense. It is given paragraph by paragraph; each numbered item is
569
  one sentence of the CURRENT text. When available, the author's ORIGINAL paragraph is shown first: use it ONLY to
570
  understand what each sentence is meant to say.
571
- For every numbered sentence:
572
- 1. Clarity first. If the sentence is unclear, garbled, ungrammatical in a way that hides its meaning, or says
573
- something different from the original, REPAIR it: rewrite it so that it says exactly what the original means,
574
- in clear academic English, keeping the current wording wherever that wording is correct. status "repaired".
575
- 2. Otherwise edit it lightly (status "edited") or keep it (status "ok"):
576
- - grammar: agreement, articles, verb forms, tense, prepositions, missing or doubled words, run-on sentences;
 
 
 
 
577
  - academic register: precise technical words instead of informal, odd or wrong-sense words (e.g. "spotted"
578
  -> "repeated", "comprehension" -> "understanding" where that is meant); remove redundancy; no first person
579
  singular, no "you", no contractions, no opinions;
@@ -585,6 +863,7 @@ For every numbered sentence:
585
  - remove leftovers of chatbot writing: labels ("Revised sentence:", "Here is"), "Certainly", markdown (**, #),
586
  and AI-typical filler ("It is important to note that", "plays a crucial role", "delve into", "in today's
587
  world", "a testament to", "navigating the landscape"); say the same thing plainly.
 
588
  Rules: keep the meaning, claims and hedging of the original exactly. Do NOT add facts, names, numbers, results or
589
  citations that are not in the sentence or the original. Do NOT merge, split, drop or reorder sentences: return
590
  exactly one sentence per id. Keep every technical term, acronym, formula, symbol, number, unit and citation (e.g.
@@ -592,7 +871,7 @@ exactly one sentence per id. Keep every technical term, acronym, formula, symbol
592
  original back word for word unless the current sentence cannot be repaired any other way.
593
  Examples of sentences that MUST be repaired (do this kind of deep correction, not a light touch-up):
594
  - "The above are necessary to give readers a thorough comprehension of the current landscape of knowledge
595
- surrounding a specific subject." -> "These elements are necessary to provide readers with a comprehensive
596
  understanding of the current state of knowledge on the subject."
597
  - "Their hybrid purposed to do a 25-item questionnaire with follow-up interviews together qualitative interviews
598
  combined." -> "They used a mixed-methods design that combined a 25-item questionnaire with follow-up
@@ -603,11 +882,10 @@ Examples of sentences that MUST be repaired (do this kind of deep correction, no
603
  - "The survey aimed to identify the literature deficiencies which would be corrected." -> "The review aimed to
604
  identify the gaps in the literature that this study addresses."
605
  Every author name, year, finding, statistic (β, p, r, R², %, n), variable name, citation, heading number and
606
- technical meaning must stay exactly as in the sentence / original.
607
- Do not add these words: {_AI_WORDS}. No em dashes.
608
  {PR.PROMPT}
609
- Answer only with JSON: {{"items": [{{"id": 1, "status": "ok|edited|repaired", "text": "sentence"}}, ...]}} —
610
- one per id."""
611
 
612
 
613
  def _polish_batch(items, glossary):
@@ -626,10 +904,10 @@ def _polish_batch(items, glossary):
626
  last = g
627
  lines.append(f"{i}. {t}")
628
  user = "PROTECTED TERMS: " + ("; ".join(terms) if terms else "(none)") + "\n" + "\n".join(lines)
629
- max_tokens = min(8000, 600 + int(sum(len(t) for t in sents) * 0.8))
630
  content = _chat([{"role": "system", "content": POLISH_SYSTEM}, {"role": "user", "content": user}], 0.2,
631
  max_tokens)
632
- return _parse(content, len(sents))
633
 
634
 
635
  def polish(sentences, groups=None, glossary=(), progress=None, lo=0.9, hi=0.95, size=None, originals=None):
@@ -646,28 +924,34 @@ def polish(sentences, groups=None, glossary=(), progress=None, lo=0.9, hi=0.95,
646
  groups = list(groups) if groups is not None else list(range(n))
647
  originals = list(originals) if originals is not None else [None] * n
648
  size = size or max(BATCH, 16)
649
- batches, cur = [], []
 
 
650
  i = 0
651
- while i < n: # whole paragraphs per batch; long ones are split
652
  j = i
653
  while j < n and groups[j] == groups[i]:
654
  j += 1
655
  para = list(range(i, j))
656
- if cur and len(cur) + len(para) > size:
 
657
  batches.append(cur)
658
- cur = []
659
  while len(para) > size:
660
  batches.append(para[:size])
661
  para = para[size:]
662
  cur += para
 
663
  i = j
664
  if cur:
665
  batches.append(cur)
666
  done, ok_batches, first_err = 0, 0, None
667
  if progress is not None:
668
  progress(lo, desc=f"Final polish of {n} sentences: clarity, grammar, academic wording and flow (Groq)")
 
669
  with ThreadPoolExecutor(max_workers=min(_par(), len(batches))) as pool:
670
- futs = {pool.submit(_in_provider, PROVIDER.get(), _polish_batch, [(groups[k], sentences[k], originals[k]) for k in b], glossary): b
 
671
  for b in batches}
672
  for f in as_completed(futs):
673
  b = futs[f]
@@ -808,17 +1092,11 @@ def _variants_batch(items, glossary, n, min_change=0):
808
  "from the sentence: use different (correct, common) vocabulary and change the clause order or "
809
  "voice where natural. Protected terms, numbers and citations stay exactly as written, and every "
810
  "rule above still holds.")
811
- max_tokens = min(8000, 500 + int(sum(len(t) for t in sents) * 0.6 * (n + 1)))
812
  content = _chat([{"role": "system", "content": VARIANTS_SYSTEM.replace("{n}", str(n))},
813
  {"role": "user", "content": user}], 0.7, max_tokens)
814
  out = [None] * len(items)
815
- m = re.search(r"\{.*\}", content or "", re.S)
816
- if not m:
817
- return out
818
- try:
819
- data = json.loads(m.group(0))
820
- except Exception:
821
- return out
822
  for k, it in enumerate(data.get("items") or []):
823
  if not isinstance(it, dict):
824
  continue
@@ -835,32 +1113,76 @@ def _variants_batch(items, glossary, n, min_change=0):
835
  def variants(sentences, glossary=(), originals=None, n=3, progress=None, lo=0.8, hi=0.85, min_change=0):
836
  """Up to n correct, human-sounding alternatives per sentence (list, or None if its batch failed).
837
  min_change: at least this share (%) of the words should differ (vocabulary-change setting)."""
838
- from concurrent.futures import ThreadPoolExecutor, as_completed
839
  N = len(sentences)
840
- out = [None] * N
841
  if not N:
842
- return out
843
  originals = list(originals) if originals is not None else [None] * N
844
- size = 8
845
- starts = list(range(0, N, size))
846
- ok_batches, first_err = 0, None
847
  if progress is not None:
848
  progress(lo, desc=f"Finding natural-sounding correct versions of {N} sentence(s)")
849
- with ThreadPoolExecutor(max_workers=min(_par(), len(starts))) as pool:
850
- futs = {pool.submit(_in_provider, PROVIDER.get(), _variants_batch,
851
- list(zip(sentences[st:st + size], originals[st:st + size])), glossary, n,
852
- min_change): st
853
- for st in starts}
854
- for f in as_completed(futs):
855
- st = futs[f]
856
- try:
857
- res = f.result()
858
- except Unavailable as err:
859
- first_err = first_err or err
860
- continue
861
- ok_batches += 1
862
- for k, r in enumerate(res):
863
- out[st + k] = r
864
- if not ok_batches and first_err is not None:
865
- raise first_err
866
- return out
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
38
  KEYS = _read_keys()
39
  KEY = KEYS[0] if KEYS else ""
40
  ENABLED = bool(KEYS) and os.getenv("GROQ_ENABLED", "1") != "0"
41
+ # per key: paused until (rate limit / daily quota / rejected key), requests sent, last error, and the token budget
42
+ # Groq reports in its response headers (remaining tokens this minute, when that budget refills, the key's limit),
43
+ # plus the tokens reserved by requests still in flight on that key
44
+ _KEY_STATE = [{"until": 0.0, "calls": 0, "err": "", "tok_left": None, "tok_reset": 0.0, "tok_limit": None,
45
+ "inflight": 0, "tokens": 0} for _ in KEYS]
46
  _rr = [0] # round-robin position
47
  URL = "https://api.groq.com/openai/v1"
48
  PREF = [m.strip() for m in os.getenv(
49
  "GROQ_MODELS", "openai/gpt-oss-120b,llama-3.3-70b-versatile,qwen/qwen3.8-27b,openai/gpt-oss-20b").split(",")
50
  if m.strip()]
51
+ # A batch is limited by sentence count (GROQ_BATCH) AND by size (GROQ_BATCH_CHARS characters of sentence text), so
52
+ # the fixed system prompt is shared by as many sentences as fit, without producing over-long replies.
53
+ BATCH = max(1, int(os.getenv("GROQ_BATCH", "20")))
54
+ BATCH_CHARS = max(800, int(os.getenv("GROQ_BATCH_CHARS", "6000")))
55
  MAX_WAIT = float(os.getenv("GROQ_MAX_WAIT", "60"))
56
+ MAX_OUT = int(os.getenv("GROQ_MAX_OUT", "12000")) # hard cap on max_tokens per request
57
+ # reasoning effort for reasoning models (gpt-oss, gpt-5, o-series); a step can raise it with REASONING.set(...)
58
+ REASONING_DEFAULT = os.getenv("GROQ_REASONING", "low").lower()
59
+ _REASON_ALLOW = {"low": 700, "medium": 2200, "high": 5000} # extra max_tokens reserved for the hidden reasoning
60
+
61
+
62
+ def _est_tokens(text):
63
+ """Rough token count of English text (about 3.6 characters per token; errs on the high side)."""
64
+ return int(len(text or "") / 3.6) + 1
65
+
66
+
67
+ def _out_budget(chars, n_items, factor=1.0):
68
+ """max_tokens for a reply that, in the worst case, returns every sentence (chars) as JSON."""
69
+ return int(chars / 3.3 * factor) + 14 * n_items + 120
70
+
71
+
72
+ def _pack(sizes, max_items, max_chars):
73
+ """Contiguous batches (lists of indices): at most max_items items and about max_chars characters each."""
74
+ out, cur, cc = [], [], 0
75
+ for i, s in enumerate(sizes):
76
+ if cur and (len(cur) >= max_items or cc + s > max_chars):
77
+ out.append(cur)
78
+ cur, cc = [], 0
79
+ cur.append(i)
80
+ cc += s
81
+ if cur:
82
+ out.append(cur)
83
+ return out
84
+
85
+
86
+ def _dur(s):
87
+ """Groq reset header ('7.66s', '2m59.5s', '120ms', '1h2m3s') -> seconds."""
88
+ tot = 0.0
89
+ for num, unit in re.findall(r"([\d.]+)(ms|h|m|s)", str(s or "")):
90
+ try:
91
+ tot += float(num) * {"ms": 0.001, "h": 3600.0, "m": 60.0, "s": 1.0}[unit]
92
+ except ValueError:
93
+ pass
94
+ return tot
95
+
96
+
97
+ def _note_limits(k, headers):
98
+ """Remember the token budget Groq reports for key k (x-ratelimit-* headers)."""
99
+ try:
100
+ left = headers.get("x-ratelimit-remaining-tokens")
101
+ lim = headers.get("x-ratelimit-limit-tokens")
102
+ reset = headers.get("x-ratelimit-reset-tokens")
103
+ req_left = headers.get("x-ratelimit-remaining-requests")
104
+ req_reset = headers.get("x-ratelimit-reset-requests")
105
+ except Exception:
106
+ return
107
+ now = time.time()
108
+ with _lock:
109
+ st = _KEY_STATE[k]
110
+ if left is not None:
111
+ try:
112
+ st["tok_left"] = int(float(left))
113
+ st["tok_reset"] = now + _dur(reset)
114
+ except ValueError:
115
+ pass
116
+ if lim is not None:
117
+ try:
118
+ st["tok_limit"] = int(float(lim))
119
+ except ValueError:
120
+ pass
121
+ if req_left is not None:
122
+ try:
123
+ if int(float(req_left)) <= 0: # requests per day used up on this key
124
+ st["until"] = max(st["until"], now + max(60.0, _dur(req_reset)))
125
+ st["err"] = "daily request limit"
126
+ except ValueError:
127
+ pass
128
 
129
  _lock = threading.RLock()
130
  _models = None # models available on this key (ordered by preference)
131
  _down_until = 0.0 # daily quota exhausted -> skip Groq until then
132
  STATS = {"calls": 0, "checked": 0, "ok": 0, "fixed": 0, "rejected": 0, "errors": 0, "tokens": 0,
133
+ "last_error": "", "cache_hits": 0, "truncated": 0}
134
 
135
 
136
  class Unavailable(Exception):
 
141
  return {"Authorization": f"Bearer {KEYS[k] if KEYS else ''}", "Content-Type": "application/json"}
142
 
143
 
144
+ def _room(st, now):
145
+ """Tokens a key can still take this minute (inf = unknown or already refilled), minus in-flight reservations."""
146
+ if st["tok_left"] is None or now >= st["tok_reset"]:
147
+ return float("inf")
148
+ return st["tok_left"] - st["inflight"]
149
+
150
+
151
+ def _pick_key(need=0, reserve=False):
152
+ """Usable key with the most token room for a request of about `need` tokens (ties: round robin, so the load is
153
+ spread over all keys). With reserve=True the tokens are booked on that key until _release(). None if every
154
+ key is paused or none has room right now."""
155
  now = time.time()
156
  with _lock:
157
  n = len(KEYS)
158
+ best = None
159
  for step in range(n):
160
  k = (_rr[0] + step) % n
161
+ st = _KEY_STATE[k]
162
+ if st["until"] > now:
163
+ continue
164
+ room = _room(st, now)
165
+ want = min(need, int(0.9 * st["tok_limit"])) if st["tok_limit"] else need
166
+ if room < want:
167
+ continue
168
+ if best is None or room > best[0]:
169
+ best = (room, k)
170
+ if best is None:
171
+ return None
172
+ k = best[1]
173
+ _rr[0] = (k + 1) % n
174
+ if reserve:
175
+ _KEY_STATE[k]["inflight"] += need
176
+ return k
177
+
178
+
179
+ def _release(k, need):
180
+ with _lock:
181
+ _KEY_STATE[k]["inflight"] = max(0, _KEY_STATE[k]["inflight"] - need)
182
 
183
 
184
  def _pause_key(k, seconds, why):
 
201
  return max(1.0, min((_KM_UNTIL.get((k, m), 0.0) for m in _model_list()), default=now) - now)
202
 
203
 
204
+ def _next_free_in(need=0):
205
+ """Seconds until some key can take a request of about `need` tokens (need=0: until the first paused key is
206
+ usable again)."""
207
  now = time.time()
208
+ best = None
209
+ for st in _KEY_STATE:
210
+ t = max(0.0, st["until"] - now)
211
+ want = min(need, int(0.9 * st["tok_limit"])) if st["tok_limit"] else need
212
+ if want and _room(st, now) < want:
213
+ t = max(t, st["tok_reset"] - now)
214
+ best = t if best is None else min(best, t)
215
+ return max(0.0, best or 0.0)
216
 
217
 
218
  # ---------------------------------------------------------------- OpenAI (ChatGPT) as an alternative provider
 
230
 
231
 
232
  PAR_LIMIT = _cv.ContextVar("llm_parallel", default=0) # >0: fewer requests at once (proofread on Groq)
233
+ REASONING = _cv.ContextVar("llm_reasoning", default="") # "" = GROQ_REASONING / OPENAI_REASONING
234
+ OUT_CAP = _cv.ContextVar("llm_out_cap", default=0) # >0: larger max_tokens cap for this call
235
+ TIMEOUT = _cv.ContextVar("llm_timeout", default=0) # >0: longer HTTP timeout (s) for this call
236
 
237
 
238
  def _par():
 
240
  return max(1, min(PARALLEL, lim) if lim else PARALLEL)
241
 
242
 
243
+ def _in_provider(provider, fn, *args, _reasoning=None):
244
  """Run fn in a worker thread with the caller's provider (context variables do not cross thread pools)."""
245
  tok = PROVIDER.set(provider)
246
+ tok_r = REASONING.set(_reasoning) if _reasoning is not None else None
247
  try:
248
  return fn(*args)
249
  finally:
250
  PROVIDER.reset(tok)
251
+ if tok_r is not None:
252
+ REASONING.reset(tok_r)
253
 
254
 
255
  def openai_available():
 
289
  "max_completion_tokens": max_tokens + (2000 if model.startswith(("gpt-5", "o")) else 0),
290
  "response_format": {"type": "json_object"}}
291
  if model.startswith(("gpt-5", "o3", "o4")):
292
+ body["reasoning_effort"] = REASONING.get() or os.getenv("OPENAI_REASONING", "low")
293
  waited = 0.0
294
  for attempt in range(10):
295
  try:
296
+ r = httpx.post(OPENAI_URL + "/chat/completions", json=body, headers=_oa_headers(),
297
+ timeout=TIMEOUT.get() or 180)
298
  except Exception as err:
299
  OA_STATS["last_error"] = f"network: {err}"
300
  if attempt >= 2:
 
402
  down = f" · quota pause until {time.strftime('%H:%M UTC', time.gmtime(_down_until))}" \
403
  if time.time() < _down_until else ""
404
  now = time.time()
405
+ keys = " · ".join(f"key {i + 1}: {st['calls']} calls, ~{st['tokens']:,} tokens"
406
+ + (f", {st['tok_left']:,} tokens/min left" if st["tok_left"] is not None
407
+ and st["tok_reset"] > now else "")
408
+ + (f", paused {int(st['until'] - now)}s ({st['err']})" if st["until"] > now else "")
409
  for i, st in enumerate(_KEY_STATE))
410
  oa = (f" OpenAI (proofread): ON, {(_oa['models'] or OPENAI_PREF)[0]}, {OA_STATS['calls']} calls, "
411
  f"~{OA_STATS['tokens']:,} tokens" + (f", last error: {OA_STATS['last_error']}" if OA_STATS["last_error"] else "")
412
  + ".") if OPENAI_KEY else ""
413
  return oa + (f" Groq quality check is ON ({m}), {len(KEYS)} key(s) [{keys}]{down}. Since restart: {s['checked']} Parrot sentences checked — "
414
  f"{s['ok']} fine as written, {s['fixed']} repaired, {s['rejected']} rejected (original kept); "
415
+ f"~{s['tokens']:,} tokens; {s.get('cache_hits', 0)} sentence(s) answered from cache."
416
  + (f" Last error: {s['last_error']}" if s["last_error"] else ""))
417
 
418
 
419
+ def _is_reasoning(model):
420
+ return "gpt-oss" in model or model.startswith(("gpt-5", "o3", "o4"))
421
+
422
+
423
  def _chat(messages, temperature, max_tokens):
424
+ """One chat completion (JSON mode). `max_tokens` is the budget for the visible answer; the hidden reasoning of
425
+ reasoning models gets its own allowance on top. The key with the most token room is used (Groq reports each
426
+ key's remaining tokens per minute in its response headers; requests in flight are booked against it), so the
427
+ load is spread by tokens, not just by request count. A rate-limited key is paused and the request moves to the
428
+ next key at once; it waits only when no key has room (short limits), and raises Unavailable when all keys are
429
+ out for longer than GROQ_MAX_WAIT. A reply cut off at max_tokens is retried once with a larger budget."""
430
  global _down_until
431
  import httpx
432
  if PROVIDER.get() == "openai":
 
434
  if not available():
435
  raise Unavailable("Groq quota paused")
436
  waited = 0.0
437
+ models = _model_list()
438
+ model = models[0]
439
+ effort = REASONING.get() or REASONING_DEFAULT
440
+
441
+ cap = OUT_CAP.get() or MAX_OUT
442
+
443
+ def budget(m, base):
444
+ return min(cap, base + (_REASON_ALLOW.get(effort, 700) if _is_reasoning(m) else 0))
445
+ body = {"model": model, "messages": messages, "temperature": temperature,
446
+ "max_tokens": budget(model, max_tokens), "response_format": {"type": "json_object"}}
447
+ if "gpt-oss" in model:
448
+ body["reasoning_effort"] = effort
449
+ prompt_tok = sum(_est_tokens(m.get("content", "")) for m in messages)
450
+ grown = 0
451
  plain = set() # models that do not take reasoning_effort
452
+ for attempt in range(8 + 2 * len(KEYS)):
453
+ need = prompt_tok + body["max_tokens"] // 2 # expected use: prompt + about half the reply budget
454
+ k = _pick_key(need, reserve=True)
455
+ if k is None: # no key has room: wait for the first one, if short
456
+ wait = _next_free_in(need)
457
  if wait > MAX_WAIT or waited + wait > MAX_WAIT:
458
  STATS["last_error"] = "rate limit on all Groq keys"
459
  raise Unavailable("Groq rate limit (all keys)")
460
  time.sleep(wait + 0.2)
461
+ waited += wait + 0.2
462
  continue
463
+ use = _model_for(k)
464
+ if use is None: # every model of this key is at its limit
465
+ _release(k, need)
466
  _pause_key(k, _models_free_in(k), "rate limit (all models)")
467
  continue
468
+ if use != model: # the preferred model is at its limit (or free again)
469
+ model = body["model"] = use
470
+ body["max_tokens"] = budget(model, max_tokens)
471
+ body.pop("reasoning_effort", None)
472
+ if "gpt-oss" in model and model not in plain:
473
+ body["reasoning_effort"] = effort
474
  try:
475
+ try:
476
+ r = httpx.post(URL + "/chat/completions", json=body, headers=_headers(k), timeout=TIMEOUT.get() or 120)
477
+ except Exception as err:
478
+ STATS["errors"] += 1
479
+ STATS["last_error"] = f"network: {err}"
480
+ if attempt >= 2:
481
+ raise Unavailable(str(err))
482
+ time.sleep(2 * (attempt + 1))
483
+ continue
484
+ _note_limits(k, r.headers)
485
+ finally:
486
+ _release(k, need)
487
  with _lock:
488
  _KEY_STATE[k]["calls"] += 1
489
  txt = r.text[:400]
 
499
  with _lock:
500
  _KM_UNTIL[(k, model)] = time.time() + (min(max(ra, 60), 6 * 3600) if daily else ra)
501
  STATS["last_error"] = f"{'daily quota' if daily else 'rate limit'} ({model}, key {k + 1}): {txt[:120]}"
502
+ continue # next model / key at once
503
+ if (r.status_code in (400, 413) and "max_tokens" in txt and ("must be" in txt.lower() or
504
+ "too large" in txt.lower())):
505
+ if body["max_tokens"] > 1024: # budget above what this model accepts: shrink and retry
506
+ body["max_tokens"] = max(1024, body["max_tokens"] // 2)
507
+ continue
508
+ if r.status_code == 413: # the request itself is too large for this key / model
509
+ STATS["last_error"] = f"request too large: {txt[:160]}"
510
+ raise Unavailable(STATS["last_error"])
511
  if r.status_code == 401:
512
  _pause_key(k, 3600, "key rejected (401)")
513
  STATS["last_error"] = f"Groq key {k + 1} rejected (401) — check that secret."
 
519
  w in txt.lower() for w in ("decommission", "not found", "does not exist", "not available",
520
  "no access", "deprecated")):
521
  _drop_model(model)
522
+ nxt = _model_list()[0]
523
+ if nxt == model:
524
  STATS["last_error"] = f"model unavailable: {txt[:160]}"
525
  raise Unavailable(STATS["last_error"])
526
+ model = body["model"] = nxt
527
+ body["max_tokens"] = budget(model, max_tokens)
528
+ body.pop("reasoning_effort", None)
529
+ if "gpt-oss" in model:
530
+ body["reasoning_effort"] = effort
531
  continue
532
  if r.status_code == 400 and "reasoning_effort" in txt:
533
  plain.add(model)
534
+ body.pop("reasoning_effort", None)
535
  continue
536
  if r.status_code == 400 and ("response_format" in txt or "json" in txt.lower()) and "response_format" in body:
537
  body.pop("response_format", None) # model without JSON mode, or JSON validation failed
 
544
  STATS["last_error"] = f"HTTP {r.status_code}: {txt[:160]}"
545
  raise Unavailable(STATS["last_error"])
546
  data = r.json()
547
+ used = int((data.get("usage") or {}).get("total_tokens") or 0)
548
+ with _lock:
549
+ STATS["calls"] += 1
550
+ STATS["tokens"] += used
551
+ _KEY_STATE[k]["tokens"] += used
552
+ choice = (data.get("choices") or [{}])[0]
553
+ content = (choice.get("message") or {}).get("content") or ""
554
+ if choice.get("finish_reason") == "length" and grown < 1 and body["max_tokens"] < cap:
555
+ grown += 1 # reply cut off: one retry with a larger budget
556
+ body["max_tokens"] = min(cap, int(body["max_tokens"] * 1.8) + 256)
557
+ STATS["truncated"] = STATS.get("truncated", 0) + 1
558
+ continue
559
+ return content
560
  raise Unavailable("Groq: too many retries")
561
 
562
 
 
564
  "robust, seamless, intricate, landscape, realm, underscore, showcase, tapestry, multifaceted, "
565
  "paramount, meticulous, holistic, foster, testament, ever-evolving")
566
 
567
+ # Replies list ONLY the sentences that were changed; every other id goes into "unchanged". Echoing unchanged
568
+ # sentences was most of the output tokens before; ids missing from both lists are treated as "not checked" and the
569
+ # caller retries them, so nothing is silently skipped. (The AI-word list is in PR.PROMPT, rule D2.)
570
+ _DIFF_RULE = ('Every id must appear exactly once: in "items" if you changed (or rejected) that sentence, otherwise '
571
+ 'in "unchanged". Never put an unchanged sentence in "items".')
572
+
573
  SYSTEM = f"""You are the quality checker of an academic paraphrasing tool.
574
  Each item has A (the author's original sentence) and B (the tool's paraphrase of A).
575
  Keep B's wording and sentence structure. Make only the minimum edits to B so that:
 
581
  Reject B if it contains a non-existent word, a wrong-sense word that a reviewer would notice (e.g. "spotted"
582
  for "repeated", "tracks" for "reports"), first person singular, "you", contractions, opinions ("absurd to me"),
583
  or a redundant phrase ("an optimization framework for optimization") that cannot be fixed with small edits.
584
+ Do not copy A back; keep B's paraphrasing wherever it is correct. No em dashes.
585
+ status: "fixed" if you edited B, "reject" if B cannot be repaired with small edits (or breaks a rule below that small
586
+ edits cannot fix). A B that needs no change is "unchanged".
587
  {PR.PROMPT}
588
+ Answer only with JSON: {{"items": [{{"id": 2, "status": "fixed", "text": "final B"}}, {{"id": 4, "status": "reject"}}],
589
+ "unchanged": [1, 3]}}. {_DIFF_RULE}"""
590
 
591
 
592
  def _surface_terms(text, glossary):
 
606
  return sorted(terms, key=str.lower)
607
 
608
 
609
+ def _load_json(content):
610
+ """The JSON object of a reply. A reply cut off mid-way is salvaged: every complete item is kept."""
611
+ content = content or ""
612
+ m = re.search(r"\{.*\}", content, re.S)
613
+ if m:
614
+ try:
615
+ data = json.loads(m.group(0))
616
+ if isinstance(data, dict):
617
+ return data
618
+ except Exception:
619
+ pass
620
+ dec, items = json.JSONDecoder(), []
621
+ for mm in re.finditer(r'\{\s*"id"\s*:', content):
622
+ try:
623
+ obj, _ = dec.raw_decode(content, mm.start())
624
+ if isinstance(obj, dict):
625
+ items.append(obj)
626
+ except Exception:
627
+ continue
628
+ data = {"items": items, "_salvaged": True}
629
+ u = re.search(r'"unchanged"\s*:\s*\[([^\]]*)\]', content)
630
+ if u:
631
+ data["unchanged"] = re.findall(r"\d+", u.group(1))
632
+ return data
633
+
634
+
635
+ def _parse(content, n, inputs=None):
636
+ """-> list of n (status, text) tuples; (None, None) when an item is missing.
637
+ Ids listed under "unchanged" give ("ok", inputs[i]) (the sentence as sent)."""
638
  out = [(None, None)] * n
639
+ data = _load_json(content)
640
+ if not data:
 
 
 
 
641
  return out
642
  out = list(out)
643
  for k, it in enumerate(data.get("items") or data.get("results") or []):
 
650
  st = str(it.get("status") or "").lower().strip()
651
  t = it.get("text")
652
  if 1 <= i <= n:
653
+ t = t.strip() if isinstance(t, str) and t.strip() else None
654
+ if t is None and st not in ("reject", "rejected") and inputs is not None:
655
+ st, t = "ok", inputs[i - 1] # listed without text: nothing to change
656
+ out[i - 1] = (st, t)
657
+ for u in data.get("unchanged") or data.get("ok") or []:
658
+ try:
659
+ i = int(u)
660
+ except (TypeError, ValueError):
661
+ continue
662
+ if 1 <= i <= n and out[i - 1] == (None, None) and inputs is not None:
663
+ out[i - 1] = ("ok", inputs[i - 1])
664
+ if inputs is not None and "unchanged" not in data and "ok" not in data and not data.get("_salvaged") \
665
+ and isinstance(data.get("items"), list):
666
+ # a complete reply that listed only its changes (no "unchanged" list): the rest needed no change
667
+ out = [("ok", inputs[i]) if o == (None, None) else o for i, o in enumerate(out)]
668
+ return out
669
+
670
+
671
+ # ------------------------------------------------------------------ per-sentence cache (deterministic steps only)
672
+ # A sentence already checked (same step, same text) is not sent again: the verification rounds of the proofread,
673
+ # re-runs after a quota stop and repeated Smart Humanize passes re-check many identical sentences.
674
+ from collections import OrderedDict as _OD
675
+ _CACHE = _OD()
676
+ _CACHE_MAX = int(os.getenv("GROQ_CACHE", "20000"))
677
+
678
+
679
+ def _cache_get(kind, key):
680
+ if not _CACHE_MAX:
681
+ return None
682
+ with _lock:
683
+ v = _CACHE.get((kind, key))
684
+ if v is not None:
685
+ _CACHE.move_to_end((kind, key))
686
+ STATS["cache_hits"] = STATS.get("cache_hits", 0) + 1
687
+ return v
688
+
689
+
690
+ def _cache_put(kind, key, value):
691
+ if not _CACHE_MAX or value is None:
692
+ return
693
+ with _lock:
694
+ _CACHE[(kind, key)] = value
695
+ while len(_CACHE) > _CACHE_MAX:
696
+ _CACHE.popitem(last=False)
697
+
698
+
699
+ def _run_batches(kind, items, sizes, max_items, max_chars, worker, progress, lo, hi, label):
700
+ """Shared batch runner: packs `items` by count and size, runs `worker(batch_items)` GROQ_PARALLEL at a time
701
+ (in the caller's provider and reasoning setting) and returns one result per item (None = not checked).
702
+ Raises Unavailable only if no batch at all could be done."""
703
+ from concurrent.futures import ThreadPoolExecutor, as_completed
704
+ out = [None] * len(items)
705
+ batches = _pack(sizes, max_items, max_chars)
706
+ if not batches:
707
+ return out
708
+ done, ok_batches, first_err = 0, 0, None
709
+ prov, reas = PROVIDER.get(), REASONING.get()
710
+ with ThreadPoolExecutor(max_workers=min(_par(), len(batches))) as pool:
711
+ futs = {pool.submit(_in_provider, prov, worker, [items[i] for i in b], _reasoning=reas): b for b in batches}
712
+ for f in as_completed(futs):
713
+ b = futs[f]
714
+ done += 1
715
+ try:
716
+ res = f.result()
717
+ except Unavailable as err:
718
+ first_err = first_err or err
719
+ continue
720
+ ok_batches += 1
721
+ for i, r in zip(b, res):
722
+ out[i] = r
723
+ if progress is not None:
724
+ progress(lo + (hi - lo) * done / len(batches), desc=f"{label}: {done}/{len(batches)} batches")
725
+ if not ok_batches and first_err is not None:
726
+ raise first_err
727
  return out
728
 
729
 
 
731
  terms = _surface_terms(" ".join(a for a, _ in pairs), glossary)
732
  user = ("PROTECTED TERMS: " + ("; ".join(terms) if terms else "(none)") + "\n\n"
733
  + "\n\n".join(f"{i}.\nA: {a}\nB: {b}" for i, (a, b) in enumerate(pairs, 1)))
734
+ max_tokens = _out_budget(sum(len(b) for _, b in pairs), len(pairs))
735
  content = _chat([{"role": "system", "content": SYSTEM}, {"role": "user", "content": user}], 0.1, max_tokens)
736
+ return _parse(content, len(pairs), [b for _, b in pairs])
737
 
738
 
739
  PARALLEL = max(1, int(os.getenv("GROQ_PARALLEL", str(min(12, 4 * max(1, len(KEYS)))))))
 
745
  None -> rejected: keep the original sentence
746
  False -> not checked (Groq unavailable for this batch): caller uses its own rules
747
  Batches run GROQ_PARALLEL at a time. Raises Unavailable only if no batch at all could be checked."""
 
748
  n = len(pairs)
749
  out = [False] * n
750
+ if not n:
751
+ return out
752
+ todo = []
753
+ for k, p in enumerate(pairs):
754
+ hit = _cache_get("review", p)
755
+ if hit is not None:
756
+ out[k] = hit[1] if hit[0] != "reject" else None
757
+ else:
758
+ todo.append(k)
759
+ if not todo:
760
  return out
 
761
  if progress is not None:
762
+ progress(lo, desc=f"Groq quality check of {len(todo)} sentences")
763
+ res = _run_batches("review", [pairs[k] for k in todo], [len(pairs[k][0]) + len(pairs[k][1]) for k in todo],
764
+ BATCH, BATCH_CHARS, lambda ps: _check_batch(ps, glossary), progress, lo, hi,
765
+ "Groq quality check")
766
+ for k, r in zip(todo, res):
767
+ if r is None:
768
+ continue
769
+ status_, t = r
770
+ a, b = pairs[k]
771
+ if status_ is None and t is None: # missing from the reply: not checked
772
+ continue
773
+ with _lock:
774
+ STATS["checked"] += 1
775
+ if status_ in ("reject", "rejected"):
776
+ STATS["rejected"] += 1
777
+ elif t:
778
+ STATS["fixed" if status_ == "fixed" and t.strip() != b.strip() else "ok"] += 1
779
+ if status_ in ("reject", "rejected"):
780
+ out[k] = None
781
+ _cache_put("review", pairs[k], ("reject", None))
782
+ elif t:
783
+ out[k] = t
784
+ _cache_put("review", pairs[k], ("ok", t))
 
 
 
 
785
  return out
786
 
787
 
 
793
  Use the smallest change that makes the sentence correct; keep its meaning and its wording wherever it is already
794
  correct. Do NOT add new facts, names, people or numbers. Do NOT replace a correct word with a synonym.
795
  Keep every technical term, acronym, formula, symbol, number, unit and citation (e.g. [12], (Kumar et al., 2021))
796
+ exactly as written, including every item in PROTECTED TERMS. No em dashes.
797
+ Check every sentence carefully; a sentence with no error at all is "unchanged".
798
  {PR.PROMPT}
799
+ Answer only with JSON: {{"items": [{{"id": 2, "text": "full corrected sentence"}}], "unchanged": [1, 3]}}. {_DIFF_RULE}"""
800
 
801
 
802
  def _grammar_batch(sents, glossary):
803
  terms = _surface_terms(" ".join(sents), glossary)
804
  user = ("PROTECTED TERMS: " + ("; ".join(terms) if terms else "(none)") + "\n\n"
805
  + "\n".join(f"{i}. {t}" for i, t in enumerate(sents, 1)))
806
+ max_tokens = _out_budget(sum(len(t) for t in sents), len(sents))
807
  content = _chat([{"role": "system", "content": GRAMMAR_SYSTEM}, {"role": "user", "content": user}], 0.0, max_tokens)
808
+ return [t for _, t in _parse(content, len(sents), sents)]
809
 
810
 
811
  def grammar(sentences, glossary, progress=None, lo=0.9, hi=0.95, size=None):
812
  """Grammar-only correction. Returns one entry per sentence: the corrected text (may equal the input),
813
  or None if that batch could not be checked. Raises Unavailable only if nothing could be checked."""
 
814
  n = len(sentences)
815
  out = [None] * n
816
+ if not n:
817
+ return out
818
+ prov = PROVIDER.get()
819
+ todo = []
820
+ for k, t in enumerate(sentences):
821
+ hit = _cache_get("grammar:" + prov, t)
822
+ if hit is not None:
823
+ out[k] = hit
824
+ else:
825
+ todo.append(k)
826
+ if not todo:
827
  return out
 
828
  if progress is not None:
829
+ progress(lo, desc=f"Final grammar check of {len(todo)} sentences (Groq)")
830
+ res = _run_batches("grammar", [sentences[k] for k in todo], [len(sentences[k]) for k in todo],
831
+ size or BATCH, BATCH_CHARS, lambda ss: _grammar_batch(ss, glossary), progress, lo, hi,
832
+ "Final grammar check")
833
+ for k, t in zip(todo, res):
834
+ out[k] = t
835
+ _cache_put("grammar:" + prov, sentences[k], t)
 
 
 
 
 
 
 
 
 
 
 
836
  return out
837
 
838
 
 
842
  joins phrases so that a sentence no longer makes sense. It is given paragraph by paragraph; each numbered item is
843
  one sentence of the CURRENT text. When available, the author's ORIGINAL paragraph is shown first: use it ONLY to
844
  understand what each sentence is meant to say.
845
+ Read each sentence the way a journal reviewer would and ask: is it grammatical, is every word the right word in
846
+ this technical context, is it clear on first reading, does it say what the original says, and does it follow from
847
+ the sentence before? For every numbered sentence:
848
+ 1. Clarity first. If the sentence is unclear, garbled, ungrammatical in a way that hides its meaning, uses a
849
+ wrong-sense word, or says something different from the original, REPAIR it: rewrite it so that it says exactly
850
+ what the original means, in clear academic English, keeping the current wording wherever that wording is
851
+ correct. status "repaired".
852
+ 2. Otherwise edit it (status "edited") or leave it unchanged:
853
+ - grammar: agreement, articles, verb forms, tense, prepositions, punctuation, missing or doubled words,
854
+ run-on sentences, dangling modifiers;
855
  - academic register: precise technical words instead of informal, odd or wrong-sense words (e.g. "spotted"
856
  -> "repeated", "comprehension" -> "understanding" where that is meant); remove redundancy; no first person
857
  singular, no "you", no contractions, no opinions;
 
863
  - remove leftovers of chatbot writing: labels ("Revised sentence:", "Here is"), "Certainly", markdown (**, #),
864
  and AI-typical filler ("It is important to note that", "plays a crucial role", "delve into", "in today's
865
  world", "a testament to", "navigating the landscape"); say the same thing plainly.
866
+ A sentence that is already correct, clear and well connected is "unchanged": do not make cosmetic edits.
867
  Rules: keep the meaning, claims and hedging of the original exactly. Do NOT add facts, names, numbers, results or
868
  citations that are not in the sentence or the original. Do NOT merge, split, drop or reorder sentences: return
869
  exactly one sentence per id. Keep every technical term, acronym, formula, symbol, number, unit and citation (e.g.
 
871
  original back word for word unless the current sentence cannot be repaired any other way.
872
  Examples of sentences that MUST be repaired (do this kind of deep correction, not a light touch-up):
873
  - "The above are necessary to give readers a thorough comprehension of the current landscape of knowledge
874
+ surrounding a specific subject." -> "These elements are necessary to provide readers with a thorough
875
  understanding of the current state of knowledge on the subject."
876
  - "Their hybrid purposed to do a 25-item questionnaire with follow-up interviews together qualitative interviews
877
  combined." -> "They used a mixed-methods design that combined a 25-item questionnaire with follow-up
 
882
  - "The survey aimed to identify the literature deficiencies which would be corrected." -> "The review aimed to
883
  identify the gaps in the literature that this study addresses."
884
  Every author name, year, finding, statistic (β, p, r, R², %, n), variable name, citation, heading number and
885
+ technical meaning must stay exactly as in the sentence / original. No em dashes.
 
886
  {PR.PROMPT}
887
+ Answer only with JSON: {{"items": [{{"id": 2, "status": "edited", "text": "sentence"}}, {{"id": 3, "status":
888
+ "repaired", "text": "sentence"}}], "unchanged": [1, 4]}}. {_DIFF_RULE}"""
889
 
890
 
891
  def _polish_batch(items, glossary):
 
904
  last = g
905
  lines.append(f"{i}. {t}")
906
  user = "PROTECTED TERMS: " + ("; ".join(terms) if terms else "(none)") + "\n" + "\n".join(lines)
907
+ max_tokens = _out_budget(sum(len(t) for t in sents), len(sents), 1.1)
908
  content = _chat([{"role": "system", "content": POLISH_SYSTEM}, {"role": "user", "content": user}], 0.2,
909
  max_tokens)
910
+ return _parse(content, len(sents), sents)
911
 
912
 
913
  def polish(sentences, groups=None, glossary=(), progress=None, lo=0.9, hi=0.95, size=None, originals=None):
 
924
  groups = list(groups) if groups is not None else list(range(n))
925
  originals = list(originals) if originals is not None else [None] * n
926
  size = size or max(BATCH, 16)
927
+ # whole paragraphs per batch (long ones are split), limited by sentence count and by size; the original
928
+ # paragraph is sent once per paragraph, so it counts once
929
+ batches, cur, cc = [], [], 0
930
  i = 0
931
+ while i < n:
932
  j = i
933
  while j < n and groups[j] == groups[i]:
934
  j += 1
935
  para = list(range(i, j))
936
+ pc = sum(len(sentences[k]) for k in para) + len(originals[i] or "")
937
+ if cur and (len(cur) + len(para) > size or cc + pc > BATCH_CHARS):
938
  batches.append(cur)
939
+ cur, cc = [], 0
940
  while len(para) > size:
941
  batches.append(para[:size])
942
  para = para[size:]
943
  cur += para
944
+ cc += pc
945
  i = j
946
  if cur:
947
  batches.append(cur)
948
  done, ok_batches, first_err = 0, 0, None
949
  if progress is not None:
950
  progress(lo, desc=f"Final polish of {n} sentences: clarity, grammar, academic wording and flow (Groq)")
951
+ prov, reas = PROVIDER.get(), REASONING.get()
952
  with ThreadPoolExecutor(max_workers=min(_par(), len(batches))) as pool:
953
+ futs = {pool.submit(_in_provider, prov, _polish_batch, [(groups[k], sentences[k], originals[k]) for k in b],
954
+ glossary, _reasoning=reas): b
955
  for b in batches}
956
  for f in as_completed(futs):
957
  b = futs[f]
 
1092
  "from the sentence: use different (correct, common) vocabulary and change the clause order or "
1093
  "voice where natural. Protected terms, numbers and citations stay exactly as written, and every "
1094
  "rule above still holds.")
1095
+ max_tokens = _out_budget(sum(len(t) for t in sents) * n, len(sents) * n, 1.1)
1096
  content = _chat([{"role": "system", "content": VARIANTS_SYSTEM.replace("{n}", str(n))},
1097
  {"role": "user", "content": user}], 0.7, max_tokens)
1098
  out = [None] * len(items)
1099
+ data = _load_json(content)
 
 
 
 
 
 
1100
  for k, it in enumerate(data.get("items") or []):
1101
  if not isinstance(it, dict):
1102
  continue
 
1113
  def variants(sentences, glossary=(), originals=None, n=3, progress=None, lo=0.8, hi=0.85, min_change=0):
1114
  """Up to n correct, human-sounding alternatives per sentence (list, or None if its batch failed).
1115
  min_change: at least this share (%) of the words should differ (vocabulary-change setting)."""
 
1116
  N = len(sentences)
 
1117
  if not N:
1118
+ return [None] * N
1119
  originals = list(originals) if originals is not None else [None] * N
 
 
 
1120
  if progress is not None:
1121
  progress(lo, desc=f"Finding natural-sounding correct versions of {N} sentence(s)")
1122
+ # every version is about as long as the sentence, so a batch holds fewer sentences than a checking batch
1123
+ sizes = [len(s) * (n + 1) + len(o or "") for s, o in zip(sentences, originals)]
1124
+ return _run_batches("variants", list(zip(sentences, originals)), sizes, 8, BATCH_CHARS * 2,
1125
+ lambda its: _variants_batch(its, glossary, n, min_change), None, lo, hi, "Alternatives")
1126
+
1127
+
1128
+ # ---------------------------------------------------------------- one-call final check of a whole document
1129
+ DOC_SYSTEM = POLISH_SYSTEM.replace(
1130
+ "It is given paragraph by paragraph; each numbered item is\none sentence of the CURRENT text.",
1131
+ "The WHOLE document is given at once, paragraph by paragraph; each numbered item is one sentence of the CURRENT "
1132
+ "text.").replace(
1133
+ "Answer only with JSON:", "DOCUMENT-LEVEL CHECKS (you see the whole text, so also):\n"
1134
+ "- every construct listed under CONSTRUCTS keeps exactly that name everywhere (never pleasure/contentment/"
1135
+ "happiness/enjoyment for satisfaction, closeness for intimacy, allegiance/devotion for loyalty);\n"
1136
+ "- the same group words as the original (men/women, male/female, genders), the same numbers and the same "
1137
+ "statistics, including operators: never add, remove or change <, >, = (if one is missing, leave it and list it "
1138
+ "in notes);\n"
1139
+ "- every sentence complete (no cut-off endings, no fragments, no two sentences run together), sentences flow "
1140
+ "into each other within each paragraph, commas in lists and after introductory phrases.\n"
1141
+ "In \"notes\", list (short, at most 12) problems you must NOT fix yourself because they need the author: "
1142
+ "inconsistent abbreviations (e.g. EWB vs EWD), a construct named two ways, missing statistics operators, "
1143
+ "results described in the wrong direction, an unexplained place or name.\n"
1144
+ "List in \"items\" ONLY the sentences you changed; leave every correct sentence out.\n"
1145
+ "Answer only with JSON:").replace(
1146
+ '"unchanged": [1, 4]}. ' + _DIFF_RULE, '"notes": ["..."]}').replace(
1147
+ 'List in \"items\" ONLY the sentences you changed;',
1148
+ 'status: "repaired" = unclear/garbled/meaning fixed, "fixed" = a grammar, spelling, punctuation or wrong-term '
1149
+ 'error corrected, "edited" = style or flow only. List in \"items\" ONLY the sentences you changed;')
1150
+
1151
+
1152
+ def proofread_document(sentences, groups, originals, glossary, constructs=()):
1153
+ """ONE request for the whole document: clarity repair + grammar + academic wording + flow + document-level
1154
+ consistency. Returns ([(status, text) per sentence], notes). Unchanged sentences come back as ("ok", text).
1155
+ Raises Unavailable when the provider cannot take the request (too large, quota): the caller falls back to the
1156
+ batched proofread."""
1157
+ n = len(sentences)
1158
+ terms = _surface_terms(" ".join(sentences), glossary)
1159
+ lines, last, para = [], object(), 0
1160
+ for i, (g, t, orig) in enumerate(zip(groups, sentences, originals), 1):
1161
+ if g != last:
1162
+ para += 1
1163
+ lines.append(f"\n[Paragraph {para}]")
1164
+ if orig:
1165
+ lines.append(f"ORIGINAL: {orig}")
1166
+ lines.append("CURRENT:")
1167
+ last = g
1168
+ lines.append(f"{i}. {t}")
1169
+ user = ("CONSTRUCTS: " + ("; ".join(constructs) if constructs else "(none)") + "\n"
1170
+ + "PROTECTED TERMS: " + ("; ".join(terms) if terms else "(none)") + "\n" + "\n".join(lines))
1171
+ chars = sum(len(t) for t in sentences)
1172
+ max_tokens = int(chars / 3.3 * 0.6) + 20 * n + 800 # a reply that changes up to ~60% of the text
1173
+ tok_c, tok_t = OUT_CAP.set(int(os.getenv("PROOF_SINGLE_MAX_OUT", "32000"))), \
1174
+ TIMEOUT.set(float(os.getenv("PROOF_SINGLE_TIMEOUT", "600")))
1175
+ try:
1176
+ content = _chat([{"role": "system", "content": DOC_SYSTEM}, {"role": "user", "content": user}], 0.2,
1177
+ max_tokens)
1178
+ finally:
1179
+ OUT_CAP.reset(tok_c)
1180
+ TIMEOUT.reset(tok_t)
1181
+ data = _load_json(content)
1182
+ if not data or ("items" not in data and "notes" not in data):
1183
+ raise Unavailable("one-call proofread: no usable answer")
1184
+ res = _parse(content, n, sentences)
1185
+ if data.get("_salvaged"): # cut-off reply: the missing ids were NOT checked
1186
+ res = [r if r != (None, None) else (None, None) for r in res]
1187
+ notes = [str(x).strip() for x in (data.get("notes") or []) if str(x).strip()][:12]
1188
+ return res, notes
humanizer.py CHANGED
@@ -1,4 +1,4 @@
1
- """Humanizer engine (Parrot T5 paraphraser) + stylometric indicators + DOCX helpers."""
2
  import base64
3
  import contextvars
4
  import hashlib
@@ -31,6 +31,8 @@ from docx import Document
31
  from transformers import AutoModelForSeq2SeqLM, AutoTokenizer
32
 
33
  import runtime as RT
 
 
34
  import textparse as TP
35
  import groqai as G
36
  import meaning as MEANING
@@ -74,8 +76,15 @@ if LOGO_PATH.exists():
74
  # ---------------------------------------------------------------------------
75
  # Model (kept in CPU RAM while idle; moved to GPU only inside @spaces.GPU)
76
  # ---------------------------------------------------------------------------
77
- MODEL_ID = os.getenv("PARROT_MODEL", "prithivida/parrot_paraphraser_on_T5")
 
 
 
 
78
  tokenizer = model = None
 
 
 
79
  # ZeroGPU / dedicated GPU: keep the model on "cuda" permanently (ZeroGPU restores it for each
80
  # @spaces.GPU call), so several users can paraphrase at the same time — no global lock.
81
  ON_GPU = ZERO or torch.cuda.is_available()
@@ -86,11 +95,21 @@ _load_lock = threading.Lock()
86
 
87
  def load_model():
88
  """Load the paraphraser once (at startup; SKIP_MODEL_LOAD=1 delays it to the first use, for tests and tools)."""
89
- global tokenizer, model, _cpu_model
90
  with _load_lock:
91
  if model is None:
92
- tok = AutoTokenizer.from_pretrained(MODEL_ID)
93
- m = AutoModelForSeq2SeqLM.from_pretrained(MODEL_ID).eval()
 
 
 
 
 
 
 
 
 
 
94
  if ON_GPU:
95
  m.to("cuda")
96
  else:
@@ -415,6 +434,8 @@ def _generate(texts, temperature, device, batch_size, m=None, k=1):
415
  num_return_sequences=k,
416
  max_new_tokens=min(180, int(longest * 1.35) + 12),
417
  no_repeat_ngram_size=3,
 
 
418
  )
419
  decoded = tokenizer.batch_decode(generated.detach().cpu(), skip_special_tokens=True)
420
  for n_, src in enumerate(chunk):
@@ -466,6 +487,8 @@ AI_CHANGE_TARGET = float(os.getenv("HUM_AI_CHANGE_TARGET", "45"))
466
  MAX_TEMP = float(os.getenv("HUM_MAX_TEMP", "1.0")) # highest sampling variation ever used
467
  MAX_VOCAB = float(os.getenv("HUM_MAX_VOCAB", "45")) # highest new-wording target per sentence (%)
468
  AI_ROUNDS = max(0, int(os.getenv("HUM_AI_ROUNDS", "2")))
 
 
469
  # Smart Humanize sets this per pass (its passes already rewrite again, so fewer inner rounds are needed).
470
  EXTRA_ROUNDS = contextvars.ContextVar("extra_rounds", default=None)
471
  # Vocabulary change (%) slider, per job. Box ticked: the new-wording target (None -> AI_CHANGE_TARGET).
@@ -556,6 +579,35 @@ def _detector_scores(texts):
556
  return None
557
 
558
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
559
  def _paraphrase_local(texts, temperature, progress=None, lo=0.15, hi=0.85, mode="Auto"):
560
  """Returns (paragraphs, engine_note).
561
  1. Parrot writes CANDIDATES paraphrases per sentence (long sentences clause by clause), batched
@@ -583,8 +635,8 @@ def _paraphrase_local(texts, temperature, progress=None, lo=0.15, hi=0.85, mode=
583
  ctx[key] = doc_ctx.get(key) or (sent_map[si - 1][0] if si > a else "",
584
  sent_map[si + 1][0] if si + 1 < b else "")
585
  full_ai = FULL_AI.get()
586
- deep = full_ai or BOOST.get()
587
- k = CANDIDATES_AI if deep else CANDIDATES
588
  target = change_target() if full_ai else min(90.0, float(VOCAB_TARGET.get() or 0))
589
  er = EXTRA_ROUNDS.get()
590
  rounds = 1 + ((AI_ROUNDS if er is None else er) if (full_ai or target) else 0)
@@ -625,6 +677,17 @@ def _paraphrase_local(texts, temperature, progress=None, lo=0.15, hi=0.85, mode=
625
 
626
  glossary = TP.build_glossary(texts) | TP.GLOSSARY.get()
627
 
 
 
 
 
 
 
 
 
 
 
 
628
  def build(si):
629
  """Valid candidates of sentence si (terms restored, guard passed) + first raw paraphrase."""
630
  s, c0, n = sent_map[si]
@@ -634,23 +697,27 @@ def _paraphrase_local(texts, temperature, progress=None, lo=0.15, hi=0.85, mode=
634
  pieces = [cand[c][j] for c in range(c0, c0 + n)]
635
  new = TP.merge_chunks(flat[c0:c0 + n], pieces) if n > 1 else pieces[0]
636
  raw0 = raw0 or new
637
- ok = TP.guard(s, TP.restore_terms(s, new, glossary), glossary)
 
638
  if ok and TP._near_repeats(ok) - TP._near_repeats(s):
639
  ok = None # restored term doubled up ("ethics of care ethics")
640
  if ok and ok.strip() != s.strip() and ok not in opts:
641
  opts.append(ok)
 
642
  # "100% AI-written" mode only: plain-wording versions (chatbot vocabulary / set phrases removed) of each
643
  # paraphrase and of the original itself, so fully AI-written sentences always get a safe, more human option
644
  for v in lex[si]: # vocabulary-replaced versions of the ORIGINAL sentence
645
- ok = TP.guard(ref[si], v, glossary)
646
  if ok and ok.strip() != s.strip() and ok not in opts:
647
  opts.append(ok)
648
- for base in ((list(opts) + [s]) if deep else []):
 
649
  v = TP.de_ai(base)
650
  if v != base:
651
- ok = TP.guard(s, TP.restore_terms(s, v, glossary), glossary)
652
  if ok and ok.strip() != s.strip() and ok not in opts:
653
  opts.append(ok)
 
654
  return opts, raw0 or s
655
 
656
  def change(si, o):
@@ -661,6 +728,8 @@ def _paraphrase_local(texts, temperature, progress=None, lo=0.15, hi=0.85, mode=
661
  todo = list(range(len(sent_map)))
662
  options, first_raw = [[] for _ in sent_map], [s for s, _, _ in sent_map]
663
  extra_rounds = meaning_dropped = 0
 
 
664
  qstat = dict(n=0, meaning=0, fluency=0, second=0, sims=[])
665
  ref = [anchor_of(s) or s for s, _, _ in sent_map] # the author's original sentence
666
  # stricter for technical sentences; a high vocabulary-change setting allows a little more distance (the
@@ -691,6 +760,9 @@ def _paraphrase_local(texts, temperature, progress=None, lo=0.15, hi=0.85, mode=
691
  chunks = [c for si in todo for c in range(sent_map[si][1], sent_map[si][1] + sent_map[si][2])]
692
  generate(chunks, min(1.5, temp0 + 0.25 * r), a_, b_,
693
  "Paraphrasing" if r == 0 else f"Extra rewrite round {r} for")
 
 
 
694
  extra_rounds += r > 0
695
  for si in todo:
696
  options[si], first_raw[si] = build(si)
@@ -729,6 +801,8 @@ def _paraphrase_local(texts, temperature, progress=None, lo=0.15, hi=0.85, mode=
729
  note = "⚡ Processed on GPU."
730
  else:
731
  note = "🖥️ Processed on CPU."
 
 
732
 
733
  # ---- 3. most human candidate (AI detector); 100% AI mode: versions meeting the new-wording target first
734
  score_of = {}
@@ -739,8 +813,8 @@ def _paraphrase_local(texts, temperature, progress=None, lo=0.15, hi=0.85, mode=
739
  sc = _ctx_scores([(sent_map[si][0], o) for si, o in multi], ctx)
740
  if sc:
741
  score_of = {key: v for key, v in zip(multi, sc)}
742
- note += (f" 🎯 Best of {k} Parrot versions per sentence chosen by the AI detector." if k > 1 else
743
- " 🎯 The AI detector chose between Parrot's version and the plain-wording version.")
744
 
745
  def rank_key(si, o):
746
  det = score_of.get((si, o), 1.0 if score_of else 0.0)
@@ -757,6 +831,11 @@ def _paraphrase_local(texts, temperature, progress=None, lo=0.15, hi=0.85, mode=
757
  for o in opts:
758
  print(f"[debug] {score_of.get((si, o), float('nan')):.2f} chg {change(si, o):3.0f}% | {o}", flush=True)
759
  chosen = [r_[0] if r_ else None for r_ in ranked]
 
 
 
 
 
760
 
761
  # ---- 4. Groq: academic-wording check of the chosen sentences + rescue of sentences with no valid version.
762
  # 100% AI mode: Groq checks the GROQ_SHORTLIST most human versions of each sentence, and the most human
@@ -1013,7 +1092,7 @@ def _paraphrase_local(texts, temperature, progress=None, lo=0.15, hi=0.85, mode=
1013
  if full_ai:
1014
  hit = sum(1 for si, (s0, _, _) in enumerate(sent_map)
1015
  if sentences[si].strip() != s0.strip() and change(si, sentences[si]) >= target)
1016
- note += (f" 🤖 100% AI mode: {k} Parrot version{'s' if k != 1 else ''} per sentence"
1017
  + (f", {extra_rounds} extra round(s)" if extra_rounds else "")
1018
  + (f"; {hit} of {len(sent_map)} sentences reach {target:.0f}% new wording." if target else ".")) \
1019
  + f" ✍️ {changed} of {len(sent_map)} sentences rewritten."
@@ -1350,6 +1429,7 @@ def _docx_core(file_path, temperature, min_words, mode, progress):
1350
 
1351
  progress(0.05, desc="Opening Word document")
1352
  doc = Document(str(source_path))
 
1353
 
1354
  import detector as _D
1355
  targets, originals = [], []
@@ -1409,6 +1489,7 @@ def _text_core(text, temperature, min_words, mode, progress, max_words=None):
1409
  progress(0.05, desc="Reading pasted text")
1410
  parts = TP.parse_paste(text)
1411
  paragraphs = [p for p, _ in parts]
 
1412
  idx = [i for i, p in enumerate(paragraphs) if should_process(p, min_words)]
1413
  if not idx:
1414
  raise gr.Error(f"No paragraph has at least {int(min_words)} words of prose. Lower the minimum and try again.")
 
1
+ """Humanizer engine (Parrot T5 paraphraser, optional detector-guided "adversarial" decoding) + stylometric indicators + DOCX helpers."""
2
  import base64
3
  import contextvars
4
  import hashlib
 
31
  from transformers import AutoModelForSeq2SeqLM, AutoTokenizer
32
 
33
  import runtime as RT
34
+ import adversarial as ADV
35
+ import adaptive as AD
36
  import textparse as TP
37
  import groqai as G
38
  import meaning as MEANING
 
76
  # ---------------------------------------------------------------------------
77
  # Model (kept in CPU RAM while idle; moved to GPU only inside @spaces.GPU)
78
  # ---------------------------------------------------------------------------
79
+ # Paraphrase model: Parrot (prithivida/parrot_paraphraser_on_T5) by default. PARA_MODEL (or the older PARROT_MODEL)
80
+ # can name another T5 paraphraser with the same "paraphrase: " input, e.g. humarin/chatgpt_paraphraser_on_T5_base.
81
+ # If the chosen model cannot be loaded, Parrot is used so the Space still starts.
82
+ FALLBACK_MODEL = "prithivida/parrot_paraphraser_on_T5"
83
+ MODEL_ID = (os.getenv("PARA_MODEL") or os.getenv("PARROT_MODEL") or FALLBACK_MODEL).strip()
84
  tokenizer = model = None
85
+ # mild penalty on repeating input tokens: pushes the model away from copying the sentence back (1.0 = off);
86
+ # set again in load_model() if the chosen model could not be loaded and Parrot is used instead
87
+ REP_PENALTY = float(os.getenv("PARA_REP_PENALTY", "1.0" if "parrot" in MODEL_ID.lower() else "1.15"))
88
  # ZeroGPU / dedicated GPU: keep the model on "cuda" permanently (ZeroGPU restores it for each
89
  # @spaces.GPU call), so several users can paraphrase at the same time — no global lock.
90
  ON_GPU = ZERO or torch.cuda.is_available()
 
95
 
96
  def load_model():
97
  """Load the paraphraser once (at startup; SKIP_MODEL_LOAD=1 delays it to the first use, for tests and tools)."""
98
+ global tokenizer, model, _cpu_model, MODEL_ID, REP_PENALTY
99
  with _load_lock:
100
  if model is None:
101
+ try:
102
+ tok = AutoTokenizer.from_pretrained(MODEL_ID)
103
+ m = AutoModelForSeq2SeqLM.from_pretrained(MODEL_ID).eval()
104
+ except Exception as _err:
105
+ if MODEL_ID == FALLBACK_MODEL:
106
+ raise
107
+ print(f"[humanizer] {MODEL_ID} could not be loaded ({_err}); using {FALLBACK_MODEL}", flush=True)
108
+ MODEL_ID = FALLBACK_MODEL
109
+ REP_PENALTY = float(os.getenv("PARA_REP_PENALTY", "1.0"))
110
+ tok = AutoTokenizer.from_pretrained(MODEL_ID)
111
+ m = AutoModelForSeq2SeqLM.from_pretrained(MODEL_ID).eval()
112
+ print(f"[humanizer] paraphrase model: {MODEL_ID}", flush=True)
113
  if ON_GPU:
114
  m.to("cuda")
115
  else:
 
434
  num_return_sequences=k,
435
  max_new_tokens=min(180, int(longest * 1.35) + 12),
436
  no_repeat_ngram_size=3,
437
+ repetition_penalty=REP_PENALTY,
438
+ bad_words_ids=AD.bad_words_ids(tokenizer), # words learned to cause rejected rewrites
439
  )
440
  decoded = tokenizer.batch_decode(generated.detach().cpu(), skip_special_tokens=True)
441
  for n_, src in enumerate(chunk):
 
487
  MAX_TEMP = float(os.getenv("HUM_MAX_TEMP", "1.0")) # highest sampling variation ever used
488
  MAX_VOCAB = float(os.getenv("HUM_MAX_VOCAB", "45")) # highest new-wording target per sentence (%)
489
  AI_ROUNDS = max(0, int(os.getenv("HUM_AI_ROUNDS", "2")))
490
+ # Smart Humanize (adaptive): sentences whose AI score did not fall last pass get detector-guided decoding too.
491
+ FORCE_ADV = contextvars.ContextVar("force_adv", default=False)
492
  # Smart Humanize sets this per pass (its passes already rewrite again, so fewer inner rounds are needed).
493
  EXTRA_ROUNDS = contextvars.ContextVar("extra_rounds", default=None)
494
  # Vocabulary change (%) slider, per job. Box ticked: the new-wording target (None -> AI_CHANGE_TARGET).
 
579
  return None
580
 
581
 
582
+ def _adversarial(chunks, flat, cand, temp, progress, at):
583
+ """Adversarial Paraphrasing: one detector-guided Parrot version per piece, added to cand[piece] (every piece of
584
+ a sentence gets one, so the pieces stay aligned). Returns True if it ran."""
585
+ if ZERO and not ADV.ON_ZERO:
586
+ return False # ZeroGPU: no nested GPU allocation for the detector
587
+ todo = sorted(set(chunks))
588
+ if not todo or len(todo) > ADV.MAX_PIECES: # over the per-job cap (ADV_MAX): Parrot's versions only
589
+ if todo:
590
+ print(f"[humanizer] adversarial paraphrasing skipped: {len(todo)} pieces > ADV_MAX={ADV.MAX_PIECES}",
591
+ flush=True)
592
+ return False
593
+ if progress is not None:
594
+ progress(at, desc=f"Detector-guided rewriting of {len(todo)} sentence piece(s)")
595
+ try:
596
+ if ON_GPU and not ZERO and torch.cuda.is_available():
597
+ outs = ADV.generate([flat[x] for x in todo], load_model(), tokenizer, "cuda", _detector_scores, temp)
598
+ else:
599
+ m = _get_cpu_model()
600
+ with RT.cpu_job():
601
+ outs = ADV.generate([flat[x] for x in todo], m, tokenizer, "cpu", _detector_scores, temp)
602
+ except Exception as err:
603
+ print(f"[humanizer] adversarial paraphrasing skipped: {err}", flush=True)
604
+ return False
605
+ for x, o in zip(todo, outs):
606
+ cand[x].append(o if o and o.strip() else flat[x])
607
+ print(f"[humanizer] adversarial paraphrasing: {len(todo)} piece(s)", flush=True)
608
+ return True
609
+
610
+
611
  def _paraphrase_local(texts, temperature, progress=None, lo=0.15, hi=0.85, mode="Auto"):
612
  """Returns (paragraphs, engine_note).
613
  1. Parrot writes CANDIDATES paraphrases per sentence (long sentences clause by clause), batched
 
635
  ctx[key] = doc_ctx.get(key) or (sent_map[si - 1][0] if si > a else "",
636
  sent_map[si + 1][0] if si + 1 < b else "")
637
  full_ai = FULL_AI.get()
638
+ deep = full_ai or BOOST.get() # deeper rewrite: asked for, or switched on by Smart Humanize
639
+ k = AD.candidates(CANDIDATES_AI if deep else CANDIDATES) # more samples when few survive the checks
640
  target = change_target() if full_ai else min(90.0, float(VOCAB_TARGET.get() or 0))
641
  er = EXTRA_ROUNDS.get()
642
  rounds = 1 + ((AI_ROUNDS if er is None else er) if (full_ai or target) else 0)
 
677
 
678
  glossary = TP.build_glossary(texts) | TP.GLOSSARY.get()
679
 
680
+ def checked(orig, new, source, guarded):
681
+ """Guard result of one version + what the adaptive learner records about it."""
682
+ ok = guarded
683
+ if ok and AD.banned_in(orig, ok): # a word learned to cause bad rewrites
684
+ ok = None
685
+ if new and new.strip() != orig.strip():
686
+ why = None if ok else (AD.banned_in(orig, new) and "banned word") or TP.integrity_issue(orig, new) \
687
+ or TP.quality_issue(orig, new) or ("lost term" if TP.lost_terms(orig, new, glossary) else "other")
688
+ AD.record_candidate(orig, new, source, bool(ok), why)
689
+ return ok
690
+
691
  def build(si):
692
  """Valid candidates of sentence si (terms restored, guard passed) + first raw paraphrase."""
693
  s, c0, n = sent_map[si]
 
697
  pieces = [cand[c][j] for c in range(c0, c0 + n)]
698
  new = TP.merge_chunks(flat[c0:c0 + n], pieces) if n > 1 else pieces[0]
699
  raw0 = raw0 or new
700
+ source = "adversarial" if (adv_used and j == k) else "parrot"
701
+ ok = checked(s, new, source, TP.guard(s, TP.restore_terms(s, new, glossary), glossary))
702
  if ok and TP._near_repeats(ok) - TP._near_repeats(s):
703
  ok = None # restored term doubled up ("ethics of care ethics")
704
  if ok and ok.strip() != s.strip() and ok not in opts:
705
  opts.append(ok)
706
+ src_of[(si, ok)] = source
707
  # "100% AI-written" mode only: plain-wording versions (chatbot vocabulary / set phrases removed) of each
708
  # paraphrase and of the original itself, so fully AI-written sentences always get a safe, more human option
709
  for v in lex[si]: # vocabulary-replaced versions of the ORIGINAL sentence
710
+ ok = checked(ref[si], v, "lexsub", TP.guard(ref[si], v, glossary))
711
  if ok and ok.strip() != s.strip() and ok not in opts:
712
  opts.append(ok)
713
+ src_of[(si, ok)] = "lexsub"
714
+ for base in ((list(opts) + [s]) if deep and AD.use_source("de_ai") else []):
715
  v = TP.de_ai(base)
716
  if v != base:
717
+ ok = checked(s, v, "de_ai", TP.guard(s, TP.restore_terms(s, v, glossary), glossary))
718
  if ok and ok.strip() != s.strip() and ok not in opts:
719
  opts.append(ok)
720
+ src_of[(si, ok)] = "de_ai"
721
  return opts, raw0 or s
722
 
723
  def change(si, o):
 
728
  todo = list(range(len(sent_map)))
729
  options, first_raw = [[] for _ in sent_map], [s for s, _, _ in sent_map]
730
  extra_rounds = meaning_dropped = 0
731
+ adv_used = False
732
+ src_of = {} # (sentence, version) -> source, for the adaptive learner
733
  qstat = dict(n=0, meaning=0, fluency=0, second=0, sims=[])
734
  ref = [anchor_of(s) or s for s, _, _ in sent_map] # the author's original sentence
735
  # stricter for technical sentences; a high vocabulary-change setting allows a little more distance (the
 
760
  chunks = [c for si in todo for c in range(sent_map[si][1], sent_map[si][1] + sent_map[si][2])]
761
  generate(chunks, min(1.5, temp0 + 0.25 * r), a_, b_,
762
  "Paraphrasing" if r == 0 else f"Extra rewrite round {r} for")
763
+ if r == 0 and (ADV.wanted(full_ai) or FORCE_ADV.get()) and AD.use_source("adversarial"):
764
+ if _adversarial(chunks, flat, cand, temp0, progress, b_):
765
+ adv_used = True
766
  extra_rounds += r > 0
767
  for si in todo:
768
  options[si], first_raw[si] = build(si)
 
801
  note = "⚡ Processed on GPU."
802
  else:
803
  note = "🖥️ Processed on CPU."
804
+ if adv_used:
805
+ note += " 🧭 Detector-guided (adversarial) rewriting added one extra version per sentence."
806
 
807
  # ---- 3. most human candidate (AI detector); 100% AI mode: versions meeting the new-wording target first
808
  score_of = {}
 
813
  sc = _ctx_scores([(sent_map[si][0], o) for si, o in multi], ctx)
814
  if sc:
815
  score_of = {key: v for key, v in zip(multi, sc)}
816
+ note += (f" 🎯 Best of {k} rewritten versions per sentence chosen by the AI detector." if k > 1 else
817
+ " 🎯 The AI detector chose between the rewritten version and the plain-wording version.")
818
 
819
  def rank_key(si, o):
820
  det = score_of.get((si, o), 1.0 if score_of else 0.0)
 
831
  for o in opts:
832
  print(f"[debug] {score_of.get((si, o), float('nan')):.2f} chg {change(si, o):3.0f}% | {o}", flush=True)
833
  chosen = [r_[0] if r_ else None for r_ in ranked]
834
+ for si, opts in enumerate(options): # adaptive learner: which source's version won
835
+ if len(opts) > 1 and chosen[si]:
836
+ for o in opts:
837
+ AD.record_win(src_of.get((si, o), "parrot"), o == chosen[si])
838
+ AD.save()
839
 
840
  # ---- 4. Groq: academic-wording check of the chosen sentences + rescue of sentences with no valid version.
841
  # 100% AI mode: Groq checks the GROQ_SHORTLIST most human versions of each sentence, and the most human
 
1092
  if full_ai:
1093
  hit = sum(1 for si, (s0, _, _) in enumerate(sent_map)
1094
  if sentences[si].strip() != s0.strip() and change(si, sentences[si]) >= target)
1095
+ note += (f" 🤖 100% AI mode: {k} rewritten version{'s' if k != 1 else ''} per sentence"
1096
  + (f", {extra_rounds} extra round(s)" if extra_rounds else "")
1097
  + (f"; {hit} of {len(sent_map)} sentences reach {target:.0f}% new wording." if target else ".")) \
1098
  + f" ✍️ {changed} of {len(sent_map)} sentences rewritten."
 
1429
 
1430
  progress(0.05, desc="Opening Word document")
1431
  doc = Document(str(source_path))
1432
+ TP.set_document([p.text for p in collect_paragraphs(doc)]) # constructs / vocabulary of the whole document
1433
 
1434
  import detector as _D
1435
  targets, originals = [], []
 
1489
  progress(0.05, desc="Reading pasted text")
1490
  parts = TP.parse_paste(text)
1491
  paragraphs = [p for p, _ in parts]
1492
+ TP.set_document(paragraphs)
1493
  idx = [i for i, p in enumerate(paragraphs) if should_process(p, min_words)]
1494
  if not idx:
1495
  raise gr.Error(f"No paragraph has at least {int(min_words)} words of prose. Lower the minimum and try again.")
proofread.py CHANGED
@@ -1,575 +1,678 @@
1
- """
2
- Rigorous proofread of a finished (humanized) Word document. Correct grammar comes first; a moderate AI-score rise
3
- is accepted for that, style-only edits must keep the AI score.
4
-
5
- 0. Local clean-up (no model): chatbot leftovers, markdown, doubled words / repeated phrases, AI filler words.
6
- 1. Grammar audit: the AI's grammar-only check marks every sentence that contains an error (plus sentences with
7
- doubled terms, leftovers or broken English): these are REQUIRED fixes.
8
- 2. Full proofread (clarity repair against the original upload, grammar, academic wording, flow). For a required
9
- sentence the full correction is used if its AI rise is at most PROOF_REQ_RISE, otherwise the minimal grammar
10
- fix; a sentence WITH an error is never left uncorrected. Style-only edits must stay within PROOF_MAX_RISE.
11
- 3. Verification rounds (PROOF_ROUNDS): every corrected sentence is grammar-checked again until no error is found.
12
- 4. LanguageTool: last mechanical check.
13
- 5. Document check: if the AI score rose by more than PROOF_MAX_DOC_RISE points, style-only edits are undone
14
- (riskiest first). Grammar fixes are never undone.
15
- Every change keeps terms / numbers / citations and the meaning. Only plain text runs are edited.
16
-
17
- Space variables: PROOF_REQ_RISE (0.35), PROOF_MAX_RISE (0.15), PROOF_MAX_DOC_RISE (5 points), PROOF_ROUNDS (2).
18
- """
19
- import difflib
20
- import os
21
- import time
22
- import re
23
-
24
- import detector as D
25
- import docx_report as DX
26
- import groqai as G
27
- import humanizer as H
28
- import meaning as MEANING
29
- import fluency as FL
30
- import smart as S
31
- import textparse as TP
32
-
33
- MAX_RISE = float(os.getenv("PROOF_MAX_RISE", "0.15"))
34
- FLAG = float(os.getenv("PROOF_FLAG", "0.50"))
35
- MAX_DOC_RISE = float(os.getenv("PROOF_MAX_DOC_RISE", "1.0"))
36
- # a sentence with a real defect (doubled word / repeated phrase / chatbot leftover / broken English) may rise more
37
- DEFECT_RISE = float(os.getenv("PROOF_DEFECT_RISE", "0.25"))
38
- # grammar comes first: a sentence with an error is always corrected; its full correction is used if the AI rise
39
- # stays within PROOF_REQ_RISE (otherwise the minimal grammar fix)
40
- REQ_RISE = float(os.getenv("PROOF_REQ_RISE", "0.35"))
41
- ROUNDS = int(os.getenv("PROOF_ROUNDS", "1"))
42
- BATCH = int(os.getenv("PROOF_BATCH", "24")) # sentences per request (fewer, larger requests = faster)
43
- # keep the AI score: a corrected sentence may be at most this much more AI-like than before; above it, correct
44
- # natural-sounding alternatives are tried (PROOF_AI_ROUNDS rounds, PROOF_VARIANTS versions each, plus Parrot)
45
- KEEP_RISE = float(os.getenv("PROOF_KEEP_RISE", "0.03"))
46
- AI_ROUNDS = int(os.getenv("PROOF_AI_ROUNDS", "1"))
47
- VARIANTS = int(os.getenv("PROOF_VARIANTS", "4"))
48
- PAR_TOL = int(os.getenv("PROOF_PAR_TOL", "0")) # flagged words a paragraph may gain after balancing
49
- PARROT = os.getenv("PROOF_PARROT", "0") == "1"
50
- # which AI does the proofreading: Groq by default (PROOF_PROVIDER=openai for ChatGPT). If the chosen one is out of
51
- # quota, the other one is used automatically when its key is set.
52
- PROVIDER = os.getenv("PROOF_PROVIDER", "groq").lower()
53
- MAX_WAIT = float(os.getenv("PROOF_MAX_WAIT", "180")) # seconds the proofread may wait for rate limits in total
54
- PARALLEL = int(os.getenv("PROOF_PARALLEL", "3")) # requests at once (Groq: low, so limits are not hit)
55
- _NAME = {"groq": "Groq", "openai": "ChatGPT"}
56
-
57
-
58
- class _LLM:
59
- """Calls Groq / ChatGPT for the proofread so that EVERY sentence gets an answer: failed batches are retried
60
- (only the missing sentences), rate limits are waited out (up to PROOF_MAX_WAIT in total), and if the provider
61
- is out of quota the other provider is used when its key is set."""
62
-
63
- def __init__(self, rep):
64
- self.rep = rep
65
- self.order = [PROVIDER] + [p for p in ("groq", "openai") if p != PROVIDER]
66
- self.order = [p for p in self.order if (G.OPENAI_KEY if p == "openai" else G.KEYS)]
67
- self.k = 0
68
- self.waited = 0.0
69
- self.used = set()
70
- self.dead = False
71
-
72
- @property
73
- def name(self):
74
- return _NAME.get(self.order[self.k], self.order[self.k]) if self.order else "AI"
75
-
76
- def _ready(self):
77
- return G.available()
78
-
79
- def _switch(self):
80
- if self.k + 1 < len(self.order):
81
- self.k += 1
82
- print(f"[proofread] switching to {self.name}", flush=True)
83
- return True
84
- return False
85
-
86
- def _blocked_for(self):
87
- """Seconds until the current provider can be used again (large = daily quota / billing / bad key)."""
88
- now = time.time()
89
- if self.order[self.k] == "openai":
90
- return max(0.0, G._oa["until"] - now) if G.OPENAI_KEY else 1e9
91
- return max(G._down_until - now, G._next_free_in(), 0.0)
92
-
93
- def _wait(self, why):
94
- blocked = self._blocked_for()
95
- if blocked > 90: # quota for the day / billing: waiting will not help
96
- print(f"[proofread] {self.name} blocked for {int(blocked)}s ({why}); "
97
- f"groq: {G.STATS.get('last_error', '')[:120]} | openai: {G.OA_STATS.get('last_error', '')[:120]}",
98
- flush=True)
99
- return False
100
- t = min(30.0, max(3.0, blocked or 5.0))
101
- if self.waited + t > MAX_WAIT:
102
- return False
103
- self.rep(None, f"Waiting {int(t)}s for the {self.name} rate limit ({why})")
104
- time.sleep(t)
105
- self.waited += t
106
- return True
107
-
108
- def run(self, fn, items, *args, missing=lambda r: r is None, **kw):
109
- """fn(list, *args, **kw) -> list of results; returns results for all items (None only if impossible)."""
110
- out = [None] * len(items)
111
- todo = list(range(len(items)))
112
- while todo and self.order:
113
- tok_p = G.PROVIDER.set(self.order[self.k])
114
- tok_l = G.PAR_LIMIT.set(PARALLEL if self.order[self.k] == "groq" else 0)
115
- try:
116
- if not self._ready():
117
- raise G.Unavailable("paused")
118
- res = fn([items[i] for i in todo], *args, **kw)
119
- self.used.add(self.order[self.k])
120
- for i, r in zip(todo, res):
121
- if not missing(r):
122
- out[i] = r
123
- left = [i for i in todo if missing(out[i])]
124
- if len(left) == len(todo): # nothing came back: treat as unavailable
125
- raise G.Unavailable("no answers")
126
- todo = left
127
- if todo and not self._wait(f"{len(todo)} sentence(s) to retry"):
128
- break
129
- except G.Unavailable as err:
130
- daily = "quota" in str(err).lower() or "billing" in str(err).lower() or \
131
- (self.order[self.k] == "groq" and G._next_free_in() > 300)
132
- if (daily or self._blocked_for() > 90) and self._switch():
133
- continue
134
- if not self._wait(str(err)[:60]) and not self._switch():
135
- self.dead = True
136
- break
137
- finally:
138
- G.PROVIDER.reset(tok_p)
139
- G.PAR_LIMIT.reset(tok_l)
140
- return out
141
-
142
-
143
- def _jobs(doc, orig_path):
144
- blocks = list(D.docx_blocks(doc))
145
- orig = None
146
- if orig_path:
147
- try:
148
- ob = list(D.docx_blocks(DX.open_clean(orig_path)))
149
- orig = ob if len(ob) == len(blocks) else None
150
- except Exception as err:
151
- print(f"[proofread] original not used: {err}", flush=True)
152
- jobs = []
153
- for bi, b in enumerate(blocks):
154
- if b.kind != "para" or b.el is None or not b.raw:
155
- continue
156
- raws = D.split_sentences(b.raw)
157
- o_raw = orig[bi].raw if orig is not None and orig[bi].kind == "para" else None
158
- for sp, (cp, cs) in zip(DX.sentence_char_spans(b.raw, raws), H.originals_for(raws, o_raw)):
159
- if not sp:
160
- continue
161
- sub = S.safe_subspan(b.el, *sp)
162
- if sub and TP.artifact_only(b.raw[sub[0]:sub[1]]):
163
- jobs.append(dict(b=b, span=sub, text=b.raw[sub[0]:sub[1]], par=cp, sent=None, drop=True))
164
- elif sub and len(b.raw[sub[0]:sub[1]].split()) >= 4:
165
- full = tuple(sub) == tuple(sp)
166
- jobs.append(dict(b=b, span=sub, text=b.raw[sub[0]:sub[1]], par=cp, sent=cs if full else None))
167
- return jobs
168
-
169
-
170
- accept = TP.strict_accept
171
-
172
-
173
- def _checked(jobs, cands, glossary, minimal=False):
174
- """Indices whose candidate passes the strict acceptance, the meaning check and the fluency check. A full
175
- proofread is compared with the author's original sentence (when known); a minimal correction with the
176
- current sentence."""
177
- idx = [i for i, c in enumerate(cands) if c and c.strip() != jobs[i].get("base", jobs[i]["text"]).strip()]
178
- ok = {}
179
- for i in idx:
180
- cur = jobs[i].get("base", jobs[i]["text"])
181
- a = accept(cur, cands[i], glossary, jobs[i]["sent"])
182
- if a:
183
- ok[i] = a
184
- keys = list(ok)
185
- if keys:
186
- refs = [jobs[i].get("base", jobs[i]["text"]) if minimal else (jobs[i]["sent"] or jobs[i]["text"])
187
- for i in keys]
188
- # light edits must keep the meaning closely; repairs of broken sentences are compared with the original
189
- lim = MEANING.min_sim() if minimal else MEANING.soft_sim()
190
- mean = MEANING.keeps_meaning(list(zip(refs, [ok[i] for i in keys])), min_sim=lim)
191
- if not minimal: # and must stay close to the current sentence too
192
- mean = [m and k for m, k in zip(mean, MEANING.keeps_meaning(
193
- [(jobs[i].get("base", jobs[i]["text"]), ok[i]) for i in keys], min_sim=MEANING.soft_sim()))]
194
- flu = FL.acceptable([(jobs[i]["text"], ok[i]) for i in keys])
195
- for i, m, f in zip(keys, mean, flu):
196
- if not (m and f):
197
- ok.pop(i)
198
- return ok
199
-
200
-
201
- def _ai_ok(cur_scores, new_texts, defect=None):
202
- """Per candidate: does it keep the AI probability in bounds? Returns (flags, new scores). A defective sentence
203
- (doubled terms, chatbot leftovers, broken English) may rise up to DEFECT_RISE, and may enter the flagged range
204
- only within that allowance."""
205
- sc = H._detector_scores(new_texts) or [0.0] * len(new_texts)
206
- defect = defect or [False] * len(new_texts)
207
- flags = []
208
- for c, n, d in zip(cur_scores, sc, defect):
209
- if d:
210
- flags.append(n <= c + DEFECT_RISE)
211
- else:
212
- flags.append(n <= c + MAX_RISE and not (n >= FLAG > c))
213
- return flags, sc
214
-
215
-
216
- def proofread_docx(path, orig_path=None, rep=None, hi=0.80, mod=0.50):
217
- return _proofread(path, orig_path, rep, hi, mod)
218
-
219
-
220
- def _proofread(path, orig_path=None, rep=None, hi=0.80, mod=0.50):
221
- rep0 = rep or (lambda *a, **k: None)
222
- _last = [0.05]
223
-
224
- def rep(p, desc=""):
225
- if p is not None:
226
- _last[0] = p
227
- rep0(_last[0], desc=desc)
228
- llm = _LLM(rep)
229
- if not llm.order:
230
- raise G.Unavailable("No AI key is set for the proofread: add GROQ_API_KEY (or OPENAI_API_KEY).")
231
- rep(0.05, "Opening the humanized document")
232
- doc = DX.open_clean(path)
233
- before = D.summarise(S._score_doc(doc), hi, mod)
234
- jobs = _jobs(doc, orig_path)
235
- if not jobs:
236
- raise ValueError("No prose sentences found to proofread.")
237
- # 0. local clean-up first (no model, no AI risk): chatbot leftovers, markdown, doubled words / phrases,
238
- # AI-typical filler words and spaced dashes
239
- texts = [j["text"] for j in jobs]
240
- local = {}
241
- drops = {i for i, j in enumerate(jobs) if j.get("drop")}
242
- for i, t in enumerate(texts):
243
- if i in drops:
244
- continue
245
- c = TP.basic_grammar(TP.de_ai(t))
246
- if c.strip() != t.strip():
247
- g = accept(t, c, set())
248
- if g:
249
- local[i] = g
250
- src = list(dict.fromkeys(j["par"] or j["sent"] or j["text"] for j in jobs))
251
- glossary = TP.build_glossary(src) | TP.GLOSSARY.get()
252
- rep(0.12, f"Scoring {len(texts)} sentences")
253
- raw_sc = H._detector_scores(texts) or [0.0] * len(texts)
254
- if local: # keep a local clean-up unless it raises the AI score
255
- keys = list(local)
256
- sc = H._detector_scores([local[i] for i in keys]) or [0.0] * len(keys)
257
- for i, s_ in zip(keys, sc):
258
- if s_ > raw_sc[i] + MAX_RISE and not TP.has_defect(texts[i]):
259
- local.pop(i)
260
- else:
261
- raw_sc[i] = s_
262
- base = [local.get(i, t) for i, t in enumerate(texts)] # what Groq proofreads
263
- for i, j in enumerate(jobs):
264
- j["base"] = base[i]
265
- cur = raw_sc
266
- defect = [TP.has_defect(t) for t in texts]
267
- for i in FL.bad(base):
268
- defect[i] = True
269
-
270
- # 1. grammar audit: Groq's grammar-only correction tells which sentences contain an error (and gives the
271
- # minimal fix for them). Sentences with an error or a defect are REQUIRED fixes: correctness comes first.
272
- rep(0.15, "Grammar audit of every sentence")
273
- work = [i for i in range(len(jobs)) if i not in drops]
274
- minimal = {}
275
- g = llm.run(G.grammar, [base[i] for i in work], glossary, rep, 0.15, 0.35, size=BATCH)
276
- unchecked = sum(1 for t in g if t is None)
277
- if work and unchecked == len(work):
278
- raise G.Unavailable("The proofreading engine has reached its usage limit for now. Please try again in a "
279
- "while; nothing in your document was changed.")
280
- cands = [None] * len(jobs)
281
- for i, t in zip(work, g):
282
- cands[i] = t
283
- minimal = _checked(jobs, cands, glossary, minimal=True)
284
- required = {i for i in work if defect[i] or i in minimal}
285
-
286
- # 2. full proofread (clarity + grammar + academic wording + flow)
287
- ctx = [j["par"] or j["sent"] for j in jobs]
288
- ctx = [c if c and c.strip() != t.strip() else None for c, t in zip(ctx, texts)]
289
- groups = [id(j["b"]) for j in jobs]
290
- idx_all = list(range(len(jobs)))
291
- res = llm.run(lambda ids, *a: G.polish([base[i] for i in ids], [groups[i] for i in ids], glossary, rep,
292
- 0.35, 0.65, originals=[ctx[i] for i in ids], size=BATCH),
293
- idx_all, missing=lambda r: r is None or r[1] is None)
294
- res = [r if r is not None else (None, None) for r in res]
295
- full = _checked(jobs, [t for _, t in res], glossary)
296
- for i in drops:
297
- full.pop(i, None)
298
- final, rise = {i: t for i, t in local.items()}, {}
299
- for i in drops: # chatbot leftovers on their own: removed
300
- final[i] = ""
301
- # every correct version of a sentence (full proofread, minimal grammar fix) is scored; a sentence with an error
302
- # takes its LEAST AI-like correct version, a correct sentence only takes a style edit that keeps the AI score
303
- opts = {}
304
- for i in work:
305
- for t in (full.get(i), minimal.get(i)):
306
- if t and t not in opts.setdefault(i, []):
307
- opts[i].append(t)
308
- flat = [(i, t) for i, ts in opts.items() for t in ts]
309
- sc = H._detector_scores([t for _, t in flat]) or [0.0] * len(flat)
310
- score_of = {(i, t): s_ for (i, t), s_ in zip(flat, sc)}
311
- n_minimal = 0
312
- for i, ts in opts.items():
313
- if i in required:
314
- best = min(ts, key=lambda t: (score_of[(i, t)], len(t)))
315
- final[i], rise[i] = best, score_of[(i, best)] - cur[i]
316
- n_minimal += best == minimal.get(i) and best != full.get(i)
317
- elif full.get(i):
318
- s_ = score_of[(i, full[i])]
319
- if s_ - cur[i] <= KEEP_RISE: # style-only edit: only if the AI score stays the same
320
- final[i], rise[i] = full[i], s_ - cur[i]
321
-
322
- # 3. verification rounds: every corrected or required sentence is checked again until Groq finds no error
323
- n_verify = 0
324
- for rnd in range(ROUNDS):
325
- chk = [i for i in work if i in final or i in required]
326
- if not chk:
327
- break
328
- rep(0.65 + 0.08 * rnd, f"Verification round {rnd + 1}: re-checking {len(chk)} sentence(s)")
329
- if llm.dead:
330
- break
331
- g = llm.run(G.grammar, [final.get(i, base[i]) for i in chk], glossary, rep, 0.65 + 0.08 * rnd,
332
- 0.72 + 0.08 * rnd, size=BATCH)
333
- changed = 0
334
- for i, t in zip(chk, g):
335
- now = final.get(i, base[i])
336
- if not t or t.strip() == now.strip():
337
- continue
338
- ok = accept(now, t, glossary, jobs[i]["sent"])
339
- if ok and H._small_edit(now, ok) and MEANING.keeps_meaning([(now, ok)])[0]:
340
- final[i] = ok
341
- required.add(i) # it had an error: keep this fix
342
- changed += 1
343
- n_verify += changed
344
- if not changed:
345
- break
346
-
347
- # 4. LanguageTool: last mechanical check (agreement, confused words, a/an, punctuation)
348
- try:
349
- import lt_grammar as LT
350
- if LT.MODE != "off":
351
- ids = [i for i in work if final.get(i, base[i])]
352
- for i, t in zip(ids, LT.correct([final.get(i, base[i]) for i in ids])):
353
- now = final.get(i, base[i])
354
- if t.strip() != now.strip():
355
- ok = accept(now, t, glossary)
356
- if ok and H._small_edit(now, ok):
357
- final[i] = ok
358
- required.add(i)
359
- except Exception as err:
360
- print(f"[proofread] LanguageTool skipped: {err}", flush=True)
361
-
362
- # 5. keep the AI score: correct, natural-sounding alternatives (AI) and Parrot rewrites of corrected sentences;
363
- # only versions verified error-free (grammar check returns them unchanged, LanguageTool finds nothing,
364
- # fluent, same meaning) are kept as options.
365
- verified = {} # sentence -> set of verified correct versions
366
-
367
- def gen_alts(ids, stage):
368
- if not ids or llm.dead:
369
- return
370
- rep(stage, f"Keeping the AI score: natural rewording of {len(ids)} sentence(s)")
371
- alts = llm.run(G.variants, [final.get(i, base[i]) for i in ids], glossary,
372
- [jobs[i]["sent"] or None for i in ids], VARIANTS)
373
- pool = []
374
- for i, vs in zip(ids, alts):
375
- for v in (vs or []):
376
- ok = accept(final.get(i, base[i]), v, glossary, jobs[i]["sent"])
377
- if ok:
378
- pool.append((i, ok))
379
- if PARROT:
380
- try:
381
- pr, _ = H._paraphrase_local([final.get(i, base[i]) for i in ids], 0.9, None, 0.8, 0.85)
382
- for i, t in zip(ids, pr):
383
- ok = accept(final.get(i, base[i]), t, glossary, jobs[i]["sent"])
384
- if ok:
385
- pool.append((i, ok))
386
- except Exception as err:
387
- print(f"[proofread] Parrot rewording skipped: {err}", flush=True)
388
- pool = list(dict.fromkeys(pool))
389
- if not pool:
390
- return
391
- mean = MEANING.keeps_meaning([(final.get(i, base[i]), t) for i, t in pool])
392
- flu = FL.acceptable([(final.get(i, base[i]), t) for i, t in pool])
393
- pool = [(i, t) for (i, t), m, f in zip(pool, mean, flu) if m and f]
394
- if not pool:
395
- return
396
- chk = llm.run(G.grammar, [t for _, t in pool], glossary, rep, stage, stage + 0.02, size=BATCH)
397
- clean = [(i, t) for (i, t), g_ in zip(pool, chk) if g_ is not None and g_.strip() == t.strip()]
398
- try:
399
- import lt_grammar as LT
400
- if LT.MODE != "off" and clean:
401
- ltc = LT.correct([t for _, t in clean])
402
- clean = [c for c, l_ in zip(clean, ltc) if l_.strip() == c[1].strip()]
403
- except Exception:
404
- pass
405
- for i, t in clean:
406
- verified.setdefault(i, set()).add(t)
407
-
408
- n_reworded = 0
409
- for rnd in range(AI_ROUNDS):
410
- ids = [i for i in work if final.get(i)]
411
- if not ids:
412
- break
413
- now_sc = H._detector_scores([final[i] for i in ids]) or [0.0] * len(ids)
414
- hot = [i for i, s_ in zip(ids, now_sc) if s_ > cur[i] + KEEP_RISE]
415
- if not hot:
416
- break
417
- now_of = dict(zip(ids, now_sc))
418
- gen_alts(hot, 0.78 + 0.02 * rnd)
419
- cand = [(i, t) for i in hot for t in verified.get(i, ())]
420
- sc = H._detector_scores([t for _, t in cand]) or [1.0] * len(cand)
421
- best = {}
422
- for (i, t), s_ in zip(cand, sc):
423
- if s_ < now_of[i] - 0.02 and (i not in best or s_ < best[i][1]):
424
- best[i] = (t, s_)
425
- for i, (t, s_) in best.items():
426
- final[i], rise[i] = t, s_ - cur[i]
427
- required.add(i)
428
- n_reworded += 1
429
- if not best:
430
- break
431
-
432
- # 5b. paragraph balance: the document score is measured over sentence windows inside each paragraph, so
433
- # single-sentence scores are not enough. Every paragraph whose AI-flagged words rose is re-balanced by
434
- # choosing, sentence by sentence, among the CORRECT versions only (current, minimal fix, verified natural
435
- # versions; for a sentence without an error also its unedited form) the combination with the fewest
436
- # flagged words.
437
- by_par = {}
438
- for i, j in enumerate(jobs):
439
- by_par.setdefault(id(j["b"]), []).append(i)
440
-
441
- def par_text(pid, choice):
442
- ids = by_par[pid]
443
- raw = jobs[ids[0]]["b"].raw
444
- for i in sorted(ids, key=lambda i: -jobs[i]["span"][0]):
445
- t = choice.get(i, jobs[i]["text"])
446
- a_, e_ = jobs[i]["span"]
447
- if t == "":
448
- while e_ < len(raw) and raw[e_] == " ":
449
- e_ += 1
450
- raw = raw[:a_] + t + raw[e_:]
451
- return raw
452
-
453
- def flagged(text):
454
- try:
455
- st = D.summarise(S._score_paras([text]), hi, mod)
456
- return st["high"] + st["moderate"]
457
- except Exception:
458
- return 0
459
-
460
- def options(i):
461
- o = [final.get(i, base[i])]
462
- for t in [minimal.get(i)] + sorted(verified.get(i, ())):
463
- if t and t not in o:
464
- o.append(t)
465
- if i not in required and jobs[i]["text"] not in o:
466
- o.append(jobs[i]["text"]) # no error: its unedited form is allowed too
467
- return o
468
-
469
- n_balanced = 0
470
- changed_pars = [pid for pid, ids in by_par.items() if any(i in final for i in ids)]
471
- rep(0.86, f"Balancing the AI score of {len(changed_pars)} paragraph(s)")
472
- for attempt in range(2):
473
- worse = []
474
- for pid in changed_pars:
475
- was = flagged(par_text(pid, {}))
476
- if flagged(par_text(pid, final)) > was + PAR_TOL:
477
- worse.append((pid, was))
478
- if not worse:
479
- break
480
- if attempt == 1: # more correct versions for the sentences in those paragraphs
481
- gen_alts([i for pid, _ in worse for i in by_par[pid] if i in final and final[i]], 0.88)
482
- for pid, was in worse:
483
- choice = {i: final[i] for i in by_par[pid] if i in final}
484
- score = flagged(par_text(pid, choice))
485
- for _ in range(len(by_par[pid]) * 2): # greedy: best single swap per step
486
- best = None
487
- for i in by_par[pid]:
488
- if i in drops:
489
- continue
490
- for t in options(i):
491
- if t == choice.get(i, jobs[i]["text"]):
492
- continue
493
- trial = dict(choice)
494
- if t == jobs[i]["text"]:
495
- trial.pop(i, None)
496
- else:
497
- trial[i] = t
498
- f_ = flagged(par_text(pid, trial))
499
- if f_ < score and (best is None or f_ < best[0]):
500
- best = (f_, trial)
501
- if best is None:
502
- break
503
- score, choice = best
504
- n_balanced += 1
505
- if score <= was + PAR_TOL:
506
- break
507
- for i in by_par[pid]:
508
- if i in drops:
509
- continue
510
- if i in choice:
511
- final[i] = choice[i]
512
- else:
513
- final.pop(i, None)
514
-
515
- ids = [i for i in work if final.get(i)]
516
- still = sum(1 for i, s_ in zip(ids, H._detector_scores([final[i] for i in ids]) or [0.0] * len(ids))
517
- if s_ > cur[i] + KEEP_RISE) if ids else 0
518
-
519
- # 6. write back, then document-level AI check (undo style-only edits if the score still rose)
520
- rep(0.85, "Writing corrections and re-checking the AI score")
521
-
522
- def write(chosen):
523
- d = DX.open_clean(path)
524
- js = _jobs(d, None)
525
- if len(js) != len(jobs):
526
- return None, 0
527
- n = 0
528
- for i in sorted(chosen, key=lambda i: (id(js[i]["b"]), -js[i]["span"][0])):
529
- a_, e_ = js[i]["span"]
530
- if chosen[i] == "": # deletion: take the following space with it
531
- raw = js[i]["b"].raw
532
- while e_ < len(raw) and raw[e_] == " ":
533
- e_ += 1
534
- if S.replace_span(js[i]["b"].el, a_, e_, chosen[i]):
535
- n += 1
536
- return d, n
537
-
538
- out_doc, n = write(final)
539
- if out_doc is None:
540
- raise ValueError("The document layout changed while proofreading; please run it again.")
541
- after = D.summarise(S._score_doc(out_doc), hi, mod)
542
- undone = 0
543
- while after["pct"] > before["pct"] + MAX_DOC_RISE:
544
- optional = [i for i in final if i not in required and i not in drops and i not in local]
545
- if not optional: # only required (grammar) fixes left: they always stay
546
- break
547
- worst = sorted(optional, key=lambda i: rise.get(i, 0), reverse=True)[:max(1, len(optional) // 5)]
548
- for i in worst:
549
- final.pop(i)
550
- undone += 1
551
- out_doc, n = write(final)
552
- after = D.summarise(S._score_doc(out_doc), hi, mod)
553
- out = H._save_output(out_doc, S._clean_stem(path), "_proofread")
554
- who = " + ".join(_NAME.get(p, p) for p in llm.order if p in llm.used) or llm.name
555
- note = (f"🔍 Rigorous proofread by {who}: {n} of {len(jobs)} sentences corrected"
556
- + (f" ({len(required)} had a grammar error or defect and were always corrected"
557
- + (f", {n_minimal} with the minimal grammar fix" if n_minimal else "")
558
- + (f"; {n_verify} more fix(es) found in the verification rounds" if n_verify else "")
559
- + (f"; {n_reworded} reworded naturally to keep the AI score" if n_reworded else "")
560
- + (f"; {n_balanced} swap(s) between correct versions to balance paragraphs" if n_balanced else "")
561
- + ")"
562
- if required else "")
563
- + (f"; {sum(defect)} had doubled terms, chatbot leftovers or broken English, "
564
- f"{sum(1 for i, d in enumerate(defect) if d and i in final)} of them fixed" if any(defect) else "")
565
- + f". AI score {before['pct']}% → {after['pct']}%."
566
- + (f" {undone} style-only edit(s) were undone because they raised the AI score; grammar fixes "
567
- "were all kept." if undone else "")
568
- + (f" {still} corrected sentence(s) still read slightly more AI-like: no error-free natural version "
569
- "was found, and correct grammar was kept." if still else "")
570
- + (f" ⚠️ {unchecked} sentence(s) could not be checked (AI quota exhausted); run the proofread again "
571
- "later to finish them." if unchecked else "")
572
- + ("" if orig_path else " Tip: run it right after humanizing, so your original is used as the meaning "
573
- "reference for unclear sentences."))
574
- print(f"[proofread] {note}", flush=True)
575
- return dict(path=out, before=before, after=after, n=n, total=len(jobs), note=note)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Rigorous proofread of a finished (humanized) Word document. Correct grammar comes first; a moderate AI-score rise
3
+ is accepted for that, style-only edits must keep the AI score.
4
+
5
+ 0. Local clean-up (no model): chatbot leftovers, markdown, doubled words / repeated phrases, AI filler words.
6
+ 1. Grammar audit: the AI's grammar-only check marks every sentence that contains an error (plus sentences with
7
+ doubled terms, leftovers or broken English): these are REQUIRED fixes.
8
+ 2. Full proofread (clarity repair against the original upload, grammar, academic wording, flow). For a required
9
+ sentence the full correction is used if its AI rise is at most PROOF_REQ_RISE, otherwise the minimal grammar
10
+ fix; a sentence WITH an error is never left uncorrected. Style-only edits must stay within PROOF_MAX_RISE.
11
+ 3. Verification rounds (PROOF_ROUNDS): every corrected sentence is grammar-checked again until no error is found.
12
+ 4. LanguageTool: last mechanical check.
13
+ 5. Document check: if the AI score rose by more than PROOF_MAX_DOC_RISE points, style-only edits are undone
14
+ (riskiest first). Grammar fixes are never undone.
15
+ Every change keeps terms / numbers / citations and the meaning. Only plain text runs are edited.
16
+
17
+ Unclear / garbled sentences the full proofread REPAIRS are judged against the author's original sentence (not
18
+ against the garbled text, which made good repairs fail the meaning check) and are never undone by the AI-score
19
+ steps: clarity wins.
20
+
21
+ Space variables: PROOF_REQ_RISE (0.35), PROOF_MAX_RISE (0.15), PROOF_MAX_DOC_RISE (2 points), PROOF_ROUNDS (1),
22
+ PROOF_KEEP_RISE (0.05), PROOF_ALT_RISE (0.10), PROOF_REPAIR_SIM (0.60), PROOF_REASONING (medium).
23
+ """
24
+ import difflib
25
+ import os
26
+ import time
27
+ import re
28
+
29
+ import detector as D
30
+ import docx_report as DX
31
+ import groqai as G
32
+ import humanizer as H
33
+ import meaning as MEANING
34
+ import fluency as FL
35
+ import smart as S
36
+ import textparse as TP
37
+
38
+ MAX_RISE = float(os.getenv("PROOF_MAX_RISE", "0.15"))
39
+ FLAG = float(os.getenv("PROOF_FLAG", "0.50"))
40
+ MAX_DOC_RISE = float(os.getenv("PROOF_MAX_DOC_RISE", "2.0"))
41
+ # a sentence with a real defect (doubled word / repeated phrase / chatbot leftover / broken English) may rise more
42
+ DEFECT_RISE = float(os.getenv("PROOF_DEFECT_RISE", "0.25"))
43
+ # grammar comes first: a sentence with an error is always corrected; its full correction is used if the AI rise
44
+ # stays within PROOF_REQ_RISE (otherwise the minimal grammar fix)
45
+ REQ_RISE = float(os.getenv("PROOF_REQ_RISE", "0.35"))
46
+ ROUNDS = int(os.getenv("PROOF_ROUNDS", "1"))
47
+ BATCH = int(os.getenv("PROOF_BATCH", "24")) # sentences per request (fewer, larger requests = faster)
48
+ # keep the AI score: a corrected sentence may be at most this much more AI-like than before; above it, correct
49
+ # natural-sounding alternatives are tried (PROOF_AI_ROUNDS rounds, PROOF_VARIANTS versions each, plus Parrot)
50
+ KEEP_RISE = float(os.getenv("PROOF_KEEP_RISE", "0.05"))
51
+ # a corrected sentence is re-worded for the AI score only if it rose more than this (each rewording costs tokens and
52
+ # moves the wording away from the checked correction)
53
+ ALT_RISE = float(os.getenv("PROOF_ALT_RISE", "0.10"))
54
+ AI_ROUNDS = int(os.getenv("PROOF_AI_ROUNDS", "1"))
55
+ VARIANTS = int(os.getenv("PROOF_VARIANTS", "3"))
56
+ # a repair of a garbled sentence must keep the author's meaning (MEANING_SOFT against the original sentence) and
57
+ # stay at least this close to the current sentence; without any original it needs PROOF_REPAIR_SIM_NOREF
58
+ 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
+ # 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
70
+ # quota, the other one is used automatically when its key is set.
71
+ PROVIDER = os.getenv("PROOF_PROVIDER", "groq").lower()
72
+ MAX_WAIT = float(os.getenv("PROOF_MAX_WAIT", "180")) # seconds the proofread may wait for rate limits in total
73
+ PARALLEL = int(os.getenv("PROOF_PARALLEL", "3")) # requests at once (Groq: low, so limits are not hit)
74
+ _NAME = {"groq": "Groq", "openai": "ChatGPT"}
75
+
76
+
77
+ class _LLM:
78
+ """Calls Groq / ChatGPT for the proofread so that EVERY sentence gets an answer: failed batches are retried
79
+ (only the missing sentences), rate limits are waited out (up to PROOF_MAX_WAIT in total), and if the provider
80
+ is out of quota the other provider is used when its key is set."""
81
+
82
+ def __init__(self, rep):
83
+ self.rep = rep
84
+ self.order = [PROVIDER] + [p for p in ("groq", "openai") if p != PROVIDER]
85
+ self.order = [p for p in self.order if (G.OPENAI_KEY if p == "openai" else G.KEYS)]
86
+ self.k = 0
87
+ self.waited = 0.0
88
+ self.used = set()
89
+ self.dead = False
90
+
91
+ @property
92
+ def name(self):
93
+ return _NAME.get(self.order[self.k], self.order[self.k]) if self.order else "AI"
94
+
95
+ def _ready(self):
96
+ return G.available()
97
+
98
+ def _switch(self):
99
+ if self.k + 1 < len(self.order):
100
+ self.k += 1
101
+ print(f"[proofread] switching to {self.name}", flush=True)
102
+ return True
103
+ return False
104
+
105
+ def _blocked_for(self):
106
+ """Seconds until the current provider can be used again (large = daily quota / billing / bad key)."""
107
+ now = time.time()
108
+ if self.order[self.k] == "openai":
109
+ return max(0.0, G._oa["until"] - now) if G.OPENAI_KEY else 1e9
110
+ return max(G._down_until - now, G._next_free_in(), 0.0)
111
+
112
+ def _wait(self, why):
113
+ blocked = self._blocked_for()
114
+ if blocked > 90: # quota for the day / billing: waiting will not help
115
+ print(f"[proofread] {self.name} blocked for {int(blocked)}s ({why}); "
116
+ f"groq: {G.STATS.get('last_error', '')[:120]} | openai: {G.OA_STATS.get('last_error', '')[:120]}",
117
+ flush=True)
118
+ return False
119
+ t = min(30.0, max(3.0, blocked or 5.0))
120
+ if self.waited + t > MAX_WAIT:
121
+ return False
122
+ self.rep(None, f"Waiting {int(t)}s for the {self.name} rate limit ({why})")
123
+ time.sleep(t)
124
+ self.waited += t
125
+ return True
126
+
127
+ def run(self, fn, items, *args, missing=lambda r: r is None, **kw):
128
+ """fn(list, *args, **kw) -> list of results; returns results for all items (None only if impossible)."""
129
+ out = [None] * len(items)
130
+ todo = list(range(len(items)))
131
+ while todo and self.order:
132
+ tok_p = G.PROVIDER.set(self.order[self.k])
133
+ tok_l = G.PAR_LIMIT.set(PARALLEL if self.order[self.k] == "groq" else 0)
134
+ try:
135
+ if not self._ready():
136
+ raise G.Unavailable("paused")
137
+ res = fn([items[i] for i in todo], *args, **kw)
138
+ self.used.add(self.order[self.k])
139
+ for i, r in zip(todo, res):
140
+ if not missing(r):
141
+ out[i] = r
142
+ left = [i for i in todo if missing(out[i])]
143
+ if len(left) == len(todo): # nothing came back: treat as unavailable
144
+ raise G.Unavailable("no answers")
145
+ todo = left
146
+ if todo and not self._wait(f"{len(todo)} sentence(s) to retry"):
147
+ break
148
+ except G.Unavailable as err:
149
+ daily = "quota" in str(err).lower() or "billing" in str(err).lower() or \
150
+ (self.order[self.k] == "groq" and G._next_free_in() > 300)
151
+ if (daily or self._blocked_for() > 90) and self._switch():
152
+ continue
153
+ if not self._wait(str(err)[:60]) and not self._switch():
154
+ self.dead = True
155
+ break
156
+ finally:
157
+ G.PROVIDER.reset(tok_p)
158
+ G.PAR_LIMIT.reset(tok_l)
159
+ return out
160
+
161
+
162
+ def _jobs(doc, orig_path):
163
+ blocks = list(D.docx_blocks(doc))
164
+ orig = None
165
+ if orig_path:
166
+ try:
167
+ ob = list(D.docx_blocks(DX.open_clean(orig_path)))
168
+ orig = ob if len(ob) == len(blocks) else None
169
+ except Exception as err:
170
+ print(f"[proofread] original not used: {err}", flush=True)
171
+ jobs = []
172
+ for bi, b in enumerate(blocks):
173
+ if b.kind != "para" or b.el is None or not b.raw:
174
+ continue
175
+ raws = D.split_sentences(b.raw)
176
+ o_raw = orig[bi].raw if orig is not None and orig[bi].kind == "para" else None
177
+ for sp, (cp, cs) in zip(DX.sentence_char_spans(b.raw, raws), H.originals_for(raws, o_raw)):
178
+ if not sp:
179
+ continue
180
+ sub = S.safe_subspan(b.el, *sp)
181
+ if sub and TP.artifact_only(b.raw[sub[0]:sub[1]]):
182
+ jobs.append(dict(b=b, span=sub, text=b.raw[sub[0]:sub[1]], par=cp, sent=None, drop=True))
183
+ elif sub and len(b.raw[sub[0]:sub[1]].split()) >= 4:
184
+ full = tuple(sub) == tuple(sp)
185
+ jobs.append(dict(b=b, span=sub, text=b.raw[sub[0]:sub[1]], par=cp, sent=cs if full else None))
186
+ return jobs
187
+
188
+
189
+ accept = TP.strict_accept
190
+
191
+
192
+ def _checked(jobs, cands, glossary, minimal=False, repaired=()):
193
+ """Indices whose candidate passes the strict acceptance, the meaning check and the fluency check. A full
194
+ proofread is compared with the author's original sentence (when known) and with the current sentence; a
195
+ minimal correction with the current sentence only. A REPAIR of a garbled sentence (`repaired`) is judged
196
+ against the author's original: it must say what the original says, and only loosely resemble the garbled
197
+ current wording (that wording is what was wrong)."""
198
+ def cur_of(i):
199
+ return jobs[i].get("base", jobs[i]["text"])
200
+ idx = [i for i, c in enumerate(cands) if c and c.strip() != cur_of(i).strip()]
201
+ ok = {}
202
+ for i in idx:
203
+ a = accept(cur_of(i), cands[i], glossary, jobs[i]["sent"])
204
+ sent = jobs[i]["sent"]
205
+ if not a and not minimal and i in repaired and sent:
206
+ # a repair is measured against the author's sentence (back to the original wording is allowed)
207
+ a = TP.basic_grammar(cands[i].strip()) if cands[i].strip() == sent.strip() else \
208
+ accept(sent, cands[i], glossary)
209
+ if a:
210
+ ok[i] = a
211
+ keys = list(ok)
212
+ if not keys:
213
+ return ok
214
+ if minimal:
215
+ mean = MEANING.keeps_meaning([(cur_of(i), ok[i]) for i in keys], min_sim=MEANING.min_sim())
216
+ else:
217
+ soft = MEANING.soft_sim()
218
+ mean = []
219
+ pairs_o = [(jobs[i]["sent"] or jobs[i]["text"], ok[i]) for i in keys]
220
+ pairs_c = [(cur_of(i), ok[i]) for i in keys]
221
+ m_o = MEANING.keeps_meaning(pairs_o, min_sim=soft)
222
+ sim_c = MEANING.similarities(pairs_c)
223
+ par_keys = [i for i in keys if i in repaired and not jobs[i]["sent"] and jobs[i]["par"]]
224
+ par_sim = {}
225
+ if par_keys:
226
+ ps = MEANING.similarities([(jobs[i]["par"], ok[i]) for i in par_keys]
227
+ + [(jobs[i]["par"], cur_of(i)) for i in par_keys])
228
+ par_sim = {i: (ps[n], ps[len(par_keys) + n]) for n, i in enumerate(par_keys)}
229
+ for i, mo, sc_ in zip(keys, m_o, sim_c):
230
+ same = ok[i].strip() == cur_of(i).strip()
231
+ if i not in repaired: # light edit: close to the original AND the current text
232
+ mean.append(mo and (same or sc_ >= soft))
233
+ elif jobs[i]["sent"]: # repair, original sentence known
234
+ mean.append(mo and (same or sc_ >= REPAIR_SIM))
235
+ elif i in par_sim: # repair, only the original paragraph known
236
+ new_s, old_s = par_sim[i]
237
+ mean.append(new_s >= old_s - 0.02 and sc_ >= REPAIR_SIM)
238
+ else: # repair without any original (standalone proofread)
239
+ mean.append(same or sc_ >= REPAIR_SIM_NOREF)
240
+ flu = FL.acceptable([(jobs[i]["text"], ok[i]) for i in keys])
241
+ for i, m, f in zip(keys, mean, flu):
242
+ if not (m and f):
243
+ ok.pop(i)
244
+ return ok
245
+
246
+
247
+ def _ai_ok(cur_scores, new_texts, defect=None):
248
+ """Per candidate: does it keep the AI probability in bounds? Returns (flags, new scores). A defective sentence
249
+ (doubled terms, chatbot leftovers, broken English) may rise up to DEFECT_RISE, and may enter the flagged range
250
+ only within that allowance."""
251
+ sc = H._detector_scores(new_texts) or [0.0] * len(new_texts)
252
+ defect = defect or [False] * len(new_texts)
253
+ flags = []
254
+ for c, n, d in zip(cur_scores, sc, defect):
255
+ if d:
256
+ flags.append(n <= c + DEFECT_RISE)
257
+ else:
258
+ flags.append(n <= c + MAX_RISE and not (n >= FLAG > c))
259
+ return flags, sc
260
+
261
+
262
+ def proofread_docx(path, orig_path=None, rep=None, hi=0.80, mod=0.50):
263
+ return _proofread(path, orig_path, rep, hi, mod)
264
+
265
+
266
+ def _proofread(path, orig_path=None, rep=None, hi=0.80, mod=0.50):
267
+ rep0 = rep or (lambda *a, **k: None)
268
+ _last = [0.05]
269
+
270
+ def rep(p, desc=""):
271
+ if p is not None:
272
+ _last[0] = p
273
+ rep0(_last[0], desc=desc)
274
+ llm = _LLM(rep)
275
+ if not llm.order:
276
+ raise G.Unavailable("No AI key is set for the proofread: add GROQ_API_KEY (or OPENAI_API_KEY).")
277
+ rep(0.05, "Opening the humanized document")
278
+ doc = DX.open_clean(path)
279
+ before = D.summarise(S._score_doc(doc), hi, mod)
280
+ jobs = _jobs(doc, orig_path)
281
+ if not jobs:
282
+ raise ValueError("No prose sentences found to proofread.")
283
+ # 0. local clean-up first (no model, no AI risk): chatbot leftovers, markdown, doubled words / phrases,
284
+ # AI-typical filler words and spaced dashes
285
+ texts = [j["text"] for j in jobs]
286
+ local = {}
287
+ drops = {i for i, j in enumerate(jobs) if j.get("drop")}
288
+ for i, t in enumerate(texts):
289
+ if i in drops:
290
+ continue
291
+ c = TP.basic_grammar(TP.de_ai(t))
292
+ if c.strip() != t.strip():
293
+ g = accept(t, c, set())
294
+ if g:
295
+ local[i] = g
296
+ src = list(dict.fromkeys(j["par"] or j["sent"] or j["text"] for j in jobs))
297
+ glossary = TP.build_glossary(src) | TP.GLOSSARY.get()
298
+ TP.set_document([b.raw for b in D.docx_blocks(doc) if b.kind == "para" and b.raw])
299
+ rep(0.12, f"Scoring {len(texts)} sentences")
300
+ raw_sc = H._detector_scores(texts) or [0.0] * len(texts)
301
+ if local: # keep a local clean-up unless it raises the AI score
302
+ keys = list(local)
303
+ sc = H._detector_scores([local[i] for i in keys]) or [0.0] * len(keys)
304
+ for i, s_ in zip(keys, sc):
305
+ if s_ > raw_sc[i] + MAX_RISE and not TP.has_defect(texts[i]):
306
+ local.pop(i)
307
+ else:
308
+ raw_sc[i] = s_
309
+ base = [local.get(i, t) for i, t in enumerate(texts)] # what Groq proofreads
310
+ for i, j in enumerate(jobs):
311
+ j["base"] = base[i]
312
+ cur = raw_sc
313
+ defect = [TP.has_defect(t) for t in texts]
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:
380
+ full.pop(i, None)
381
+ # an accepted repair of an unclear sentence is a required fix: clarity comes before the AI score
382
+ repaired = {i for i in full if i in asked_repair}
383
+ required |= repaired
384
+ final, rise = {i: t for i, t in local.items()}, {}
385
+ for i in drops: # chatbot leftovers on their own: removed
386
+ final[i] = ""
387
+ # every correct version of a sentence (full proofread, minimal grammar fix) is scored. A repaired sentence takes
388
+ # its repair; a sentence with an error takes the full correction if its AI rise stays within PROOF_REQ_RISE,
389
+ # otherwise the minimal grammar fix; a correct sentence only takes a style edit that keeps the AI score
390
+ opts = {}
391
+ for i in work:
392
+ for t in (full.get(i), minimal.get(i)):
393
+ if t and t not in opts.setdefault(i, []):
394
+ opts[i].append(t)
395
+ flat = [(i, t) for i, ts in opts.items() for t in ts]
396
+ sc = H._detector_scores([t for _, t in flat]) or [0.0] * len(flat)
397
+ score_of = {(i, t): s_ for (i, t), s_ in zip(flat, sc)}
398
+ n_minimal = 0
399
+ for i, ts in opts.items():
400
+ if i in repaired:
401
+ final[i], rise[i] = full[i], score_of[(i, full[i])] - cur[i]
402
+ elif i in required:
403
+ f_, m_ = full.get(i), minimal.get(i)
404
+ if f_ and (not m_ or score_of[(i, f_)] - cur[i] <= REQ_RISE):
405
+ best = f_
406
+ elif m_:
407
+ best = m_
408
+ else:
409
+ best = min(ts, key=lambda t: (score_of[(i, t)], len(t)))
410
+ final[i], rise[i] = best, score_of[(i, best)] - cur[i]
411
+ n_minimal += best == m_ and best != f_
412
+ elif full.get(i):
413
+ s_ = score_of[(i, full[i])]
414
+ if s_ - cur[i] <= KEEP_RISE: # style-only edit: only if the AI score stays the same
415
+ final[i], rise[i] = full[i], s_ - cur[i]
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:
423
+ break
424
+ rep(0.65 + 0.08 * rnd, f"Verification round {rnd + 1}: re-checking {len(chk)} sentence(s)")
425
+ if llm.dead:
426
+ break
427
+ g = llm.run(G.grammar, [final.get(i, base[i]) for i in chk], glossary, rep, 0.65 + 0.08 * rnd,
428
+ 0.72 + 0.08 * rnd, size=BATCH)
429
+ changed = 0
430
+ for i, t in zip(chk, g):
431
+ now = final.get(i, base[i])
432
+ if not t or t.strip() == now.strip():
433
+ continue
434
+ ok = accept(now, t, glossary, jobs[i]["sent"])
435
+ if ok and H._small_edit(now, ok) and MEANING.keeps_meaning([(now, ok)])[0]:
436
+ final[i] = ok
437
+ required.add(i) # it had an error: keep this fix
438
+ changed += 1
439
+ n_verify += changed
440
+ if not changed:
441
+ break
442
+
443
+ # 4. LanguageTool: last mechanical check (agreement, confused words, a/an, punctuation)
444
+ try:
445
+ import lt_grammar as LT
446
+ if LT.MODE != "off":
447
+ ids = [i for i in work if final.get(i, base[i])]
448
+ for i, t in zip(ids, LT.correct([final.get(i, base[i]) for i in ids])):
449
+ now = final.get(i, base[i])
450
+ if t.strip() != now.strip():
451
+ ok = accept(now, t, glossary)
452
+ if ok and H._small_edit(now, ok):
453
+ final[i] = ok
454
+ required.add(i)
455
+ except Exception as err:
456
+ print(f"[proofread] LanguageTool skipped: {err}", flush=True)
457
+
458
+ # 5. keep the AI score: correct, natural-sounding alternatives (AI) and Parrot rewrites of corrected sentences;
459
+ # only versions verified error-free (grammar check returns them unchanged, LanguageTool finds nothing,
460
+ # fluent, same meaning) are kept as options.
461
+ verified = {} # sentence -> set of verified correct versions
462
+
463
+ def gen_alts(ids, stage):
464
+ if not ids or llm.dead:
465
+ return
466
+ rep(stage, f"Keeping the AI score: natural rewording of {len(ids)} sentence(s)")
467
+ alts = llm.run(G.variants, [final.get(i, base[i]) for i in ids], glossary,
468
+ [jobs[i]["sent"] or None for i in ids], VARIANTS)
469
+ pool = []
470
+ for i, vs in zip(ids, alts):
471
+ for v in (vs or []):
472
+ ok = accept(final.get(i, base[i]), v, glossary, jobs[i]["sent"])
473
+ if ok:
474
+ pool.append((i, ok))
475
+ if PARROT:
476
+ try:
477
+ pr, _ = H._paraphrase_local([final.get(i, base[i]) for i in ids], 0.9, None, 0.8, 0.85)
478
+ for i, t in zip(ids, pr):
479
+ ok = accept(final.get(i, base[i]), t, glossary, jobs[i]["sent"])
480
+ if ok:
481
+ pool.append((i, ok))
482
+ except Exception as err:
483
+ print(f"[proofread] Parrot rewording skipped: {err}", flush=True)
484
+ pool = list(dict.fromkeys(pool))
485
+ if not pool:
486
+ return
487
+ mean = MEANING.keeps_meaning([(final.get(i, base[i]), t) for i, t in pool])
488
+ flu = FL.acceptable([(final.get(i, base[i]), t) for i, t in pool])
489
+ pool = [(i, t) for (i, t), m, f in zip(pool, mean, flu) if m and f]
490
+ if not pool:
491
+ return
492
+ chk = llm.run(G.grammar, [t for _, t in pool], glossary, rep, stage, stage + 0.02, size=BATCH)
493
+ clean = [(i, t) for (i, t), g_ in zip(pool, chk) if g_ is not None and g_.strip() == t.strip()]
494
+ try:
495
+ import lt_grammar as LT
496
+ if LT.MODE != "off" and clean:
497
+ ltc = LT.correct([t for _, t in clean])
498
+ clean = [c for c, l_ in zip(clean, ltc) if l_.strip() == c[1].strip()]
499
+ except Exception:
500
+ pass
501
+ for i, t in clean:
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
509
+ now_sc = H._detector_scores([final[i] for i in ids]) or [0.0] * len(ids)
510
+ hot = [i for i, s_ in zip(ids, now_sc) if s_ > cur[i] + ALT_RISE and i not in repaired]
511
+ if not hot:
512
+ break
513
+ now_of = dict(zip(ids, now_sc))
514
+ gen_alts(hot, 0.78 + 0.02 * rnd)
515
+ cand = [(i, t) for i in hot for t in verified.get(i, ())]
516
+ sc = H._detector_scores([t for _, t in cand]) or [1.0] * len(cand)
517
+ best = {}
518
+ for (i, t), s_ in zip(cand, sc):
519
+ if s_ < now_of[i] - 0.02 and (i not in best or s_ < best[i][1]):
520
+ best[i] = (t, s_)
521
+ for i, (t, s_) in best.items():
522
+ final[i], rise[i] = t, s_ - cur[i]
523
+ required.add(i)
524
+ n_reworded += 1
525
+ if not best:
526
+ break
527
+
528
+ # 5b. paragraph balance: the document score is measured over sentence windows inside each paragraph, so
529
+ # single-sentence scores are not enough. Every paragraph whose AI-flagged words rose is re-balanced by
530
+ # choosing, sentence by sentence, among the CORRECT versions only (current, minimal fix, verified natural
531
+ # versions; for a sentence without an error also its unedited form) the combination with the fewest
532
+ # flagged words.
533
+ by_par = {}
534
+ for i, j in enumerate(jobs):
535
+ by_par.setdefault(id(j["b"]), []).append(i)
536
+
537
+ def par_text(pid, choice):
538
+ ids = by_par[pid]
539
+ raw = jobs[ids[0]]["b"].raw
540
+ for i in sorted(ids, key=lambda i: -jobs[i]["span"][0]):
541
+ t = choice.get(i, jobs[i]["text"])
542
+ a_, e_ = jobs[i]["span"]
543
+ if t == "":
544
+ while e_ < len(raw) and raw[e_] == " ":
545
+ e_ += 1
546
+ raw = raw[:a_] + t + raw[e_:]
547
+ return raw
548
+
549
+ def flagged(text):
550
+ try:
551
+ st = D.summarise(S._score_paras([text]), hi, mod)
552
+ return st["high"] + st["moderate"]
553
+ except Exception:
554
+ return 0
555
+
556
+ def options(i):
557
+ o = [final.get(i, base[i])]
558
+ if i in repaired: # only the repair and its verified natural versions
559
+ return o + [t for t in sorted(verified.get(i, ())) if t not in o]
560
+ for t in [minimal.get(i)] + sorted(verified.get(i, ())):
561
+ if t and t not in o:
562
+ o.append(t)
563
+ if i not in required and jobs[i]["text"] not in o:
564
+ o.append(jobs[i]["text"]) # no error: its unedited form is allowed too
565
+ return o
566
+
567
+ n_balanced = 0
568
+ changed_pars = [pid for pid, ids in by_par.items() if any(i in final for i in ids)]
569
+ rep(0.86, f"Balancing the AI score of {len(changed_pars)} paragraph(s)")
570
+ for attempt in range(2):
571
+ worse = []
572
+ for pid in changed_pars:
573
+ was = flagged(par_text(pid, {}))
574
+ if flagged(par_text(pid, final)) > was + PAR_TOL:
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}
582
+ score = flagged(par_text(pid, choice))
583
+ for _ in range(len(by_par[pid]) * 2): # greedy: best single swap per step
584
+ best = None
585
+ for i in by_par[pid]:
586
+ if i in drops:
587
+ continue
588
+ for t in options(i):
589
+ if t == choice.get(i, jobs[i]["text"]):
590
+ continue
591
+ trial = dict(choice)
592
+ if t == jobs[i]["text"]:
593
+ trial.pop(i, None)
594
+ else:
595
+ trial[i] = t
596
+ f_ = flagged(par_text(pid, trial))
597
+ if f_ < score and (best is None or f_ < best[0]):
598
+ best = (f_, trial)
599
+ if best is None:
600
+ break
601
+ score, choice = best
602
+ n_balanced += 1
603
+ if score <= was + PAR_TOL:
604
+ break
605
+ for i in by_par[pid]:
606
+ if i in drops:
607
+ continue
608
+ if i in choice:
609
+ final[i] = choice[i]
610
+ else:
611
+ final.pop(i, None)
612
+
613
+ ids = [i for i in work if final.get(i)]
614
+ still = sum(1 for i, s_ in zip(ids, H._detector_scores([final[i] for i in ids]) or [0.0] * len(ids))
615
+ if s_ > cur[i] + ALT_RISE and i not in repaired) if ids else 0
616
+
617
+ # 6. write back, then document-level AI check (undo style-only edits if the score still rose)
618
+ rep(0.85, "Writing corrections and re-checking the AI score")
619
+
620
+ def write(chosen):
621
+ d = DX.open_clean(path)
622
+ js = _jobs(d, None)
623
+ if len(js) != len(jobs):
624
+ return None, 0
625
+ n = 0
626
+ for i in sorted(chosen, key=lambda i: (id(js[i]["b"]), -js[i]["span"][0])):
627
+ a_, e_ = js[i]["span"]
628
+ if chosen[i] == "": # deletion: take the following space with it
629
+ raw = js[i]["b"].raw
630
+ while e_ < len(raw) and raw[e_] == " ":
631
+ e_ += 1
632
+ if S.replace_span(js[i]["b"].el, a_, e_, chosen[i]):
633
+ n += 1
634
+ return d, n
635
+
636
+ out_doc, n = write(final)
637
+ if out_doc is None:
638
+ raise ValueError("The document layout changed while proofreading; please run it again.")
639
+ after = D.summarise(S._score_doc(out_doc), hi, mod)
640
+ undone = 0
641
+ while after["pct"] > before["pct"] + MAX_DOC_RISE:
642
+ optional = [i for i in final if i not in required and i not in drops and i not in local]
643
+ if not optional: # only required (grammar) fixes left: they always stay
644
+ break
645
+ worst = sorted(optional, key=lambda i: rise.get(i, 0), reverse=True)[:max(1, len(optional) // 5)]
646
+ for i in worst:
647
+ final.pop(i)
648
+ undone += 1
649
+ out_doc, n = write(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 "")
658
+ + (f"; {n_verify} more fix(es) found in the verification rounds" if n_verify else "")
659
+ + (f"; {n_reworded} reworded naturally to keep the AI score" if n_reworded else "")
660
+ + (f"; {n_balanced} swap(s) between correct versions to balance paragraphs" if n_balanced else "")
661
+ + ")"
662
+ if required else "")
663
+ + (f"; {sum(defect)} had doubled terms, chatbot leftovers or broken English, "
664
+ f"{sum(1 for i, d in enumerate(defect) if d and i in final)} of them fixed" if any(defect) else "")
665
+ + f". AI score {before['pct']}% → {after['pct']}%."
666
+ + (f" {undone} style-only edit(s) were undone because they raised the AI score; grammar fixes "
667
+ "were all kept." if undone else "")
668
+ + (f" {still} corrected sentence(s) still read slightly more AI-like: no error-free natural version "
669
+ "was found, and correct grammar was kept." if still else "")
670
+ + (f" ⚠️ {unchecked} sentence(s) could not be checked (AI quota exhausted); run the proofread again "
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)
pubrules.py CHANGED
@@ -185,9 +185,19 @@ A. Integrity - never broken:
185
  A4. Keep the strength of every claim: keep may/might/suggests/approximately; never add proves/clearly/always/all.
186
  A5. One sentence in, one sentence out: never split, merge, drop or reorder sentences.
187
  A6. Never replace a technical term with a synonym ("accuracy" is not "precision", "sweeps" is not "reaches",
188
- "escalates" is not "increases"); when unsure, keep the author's word.
 
 
 
 
 
 
 
 
189
  B. Grammar - every sentence complete and correct: subject-verb agreement, articles, verb forms, tense, prepositions,
190
- punctuation (commas in lists and after introductory clauses), no missing, doubled or run-together words, no
 
 
191
  fragments, no run-on sentences, a capital only where English needs one ("Greshake et al. generalised", not
192
  "et al. Generalised").
193
  C. Academic register: formal and impersonal; no contractions, no "you", no first person singular, no informal or
 
185
  A4. Keep the strength of every claim: keep may/might/suggests/approximately; never add proves/clearly/always/all.
186
  A5. One sentence in, one sentence out: never split, merge, drop or reorder sentences.
187
  A6. Never replace a technical term with a synonym ("accuracy" is not "precision", "sweeps" is not "reaches",
188
+ "escalates" is not "increases"); when unsure, keep the author's word. The study's own constructs (every term
189
+ defined with an abbreviation, e.g. "travel satisfaction (TS)", "intimacy (IN)") keep their exact names: never
190
+ "pleasure", "contentment", "happiness", "closeness", "allegiance", "devotion" for them.
191
+ A7. Never add a reason, cause, explanation or attribution the original does not state ("according to some
192
+ experts", "since they have ...", "a systematic analysis found", "researchers contend").
193
+ A8. Keep every list item and every name or place ("Digha, Darjeeling, Jhargram and Purulia" keeps all four).
194
+ A9. Keep statistics exactly: "p = .120" stays "p = .120"; never turn "=" into "<" or ">", never add an operator
195
+ that is missing in the original, never drop one ("p < .001" never becomes "p .001").
196
+ A10. Never end a sentence or a term half-way ("... and to enhance their.", "well-,").
197
  B. Grammar - every sentence complete and correct: subject-verb agreement, articles, verb forms, tense, prepositions,
198
+ punctuation (keep every comma of the original that is still needed: in lists, after introductory phrases such as
199
+ "After COVID-19," or "However,", and after a bracketed abbreviation in a list; never remove commas to change the
200
+ style), no missing, doubled or run-together words, no
201
  fragments, no run-on sentences, a capital only where English needs one ("Greshake et al. generalised", not
202
  "et al. Generalised").
203
  C. Academic register: formal and impersonal; no contractions, no "you", no first person singular, no informal or
smart.py CHANGED
@@ -24,6 +24,7 @@ import humanizer as H
24
  import groqai as G
25
  import report_flags as R
26
  import restructure as RS
 
27
  import meaning as MEANING
28
  import profile_settings as PS
29
  import fluency as FL
@@ -304,6 +305,40 @@ def _rewrite_retry(sentences, temperature, mode, rep, lo, hi):
304
  return new, note
305
 
306
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
307
  def _full_ai():
308
  return bool(H.FULL_AI.get())
309
 
@@ -549,12 +584,15 @@ def smart_docx(path, target, passes, temperature, mode, hi, mod, rep):
549
  log, first, note = [], None, ""
550
  orig_text = "\n".join(b.text for b in D.docx_blocks(doc) if b.kind == "para")
551
  TP.GLOSSARY.set(TP.build_glossary([orig_text])) # the document's key terms stay untouched
 
552
  best = None # (pct, docx bytes, scored blocks)
553
  rpt, rows = R.ACTIVE.get(), None # uploaded report: rewrite only the sentences it flags
554
  if rpt is not None:
555
  passes = 1
556
  acc, from_orig = None, True # last version whose AI score went DOWN (passes build on it)
557
  stall, low, t0 = 0, None, time.time() # automatic passes: stop when no pass improves any more
 
 
558
  step = 0.8 / max(1, passes)
559
  for k in range(1, int(passes) + 2): # last iteration = final check only
560
  base = 0.05 + step * (k - 1)
@@ -562,6 +600,9 @@ def smart_docx(path, target, passes, temperature, mode, hi, mod, rep):
562
  blocks = _score_doc(doc)
563
  stats = D.summarise(blocks, hi, mod)
564
  first = first or stats
 
 
 
565
  cur = "\n".join(b.text for b in blocks if b.kind == "para")
566
  chg = H.text_change(orig_text, cur) if k > 1 else None
567
  cand_ = (stats["pct"], chg, (_snapshot(doc), blocks))
@@ -577,6 +618,10 @@ def smart_docx(path, target, passes, temperature, mode, hi, mod, rep):
577
  chg, from_orig = acc[2], acc[3]
578
  else:
579
  acc, from_orig = (stats["pct"], _snapshot(doc), chg, k == 1), k == 1
 
 
 
 
580
  if k > passes:
581
  break
582
  if _done_early(k, stats["pct"], target, chg):
@@ -638,16 +683,19 @@ def smart_docx(path, target, passes, temperature, mode, hi, mod, rep):
638
  rows = linfo
639
  log.append(_log_line(k, stats["pct"]) + f" — no rewritable sentences left ({locked} locked)")
640
  break
641
- temp = min(1.5, float(temperature) + 0.15 * (k - 1))
 
642
  # pass 1 pushes each sentence to the new-wording level (measured against the ORIGINAL sentence);
643
  # later passes only lower the AI score: their input is already rewritten, so a per-sentence level there
644
  # would compare against the previous pass and keep churning. The whole-text level is checked below.
645
  vtok = H.VOCAB_TARGET.set(0.0) if not from_orig else None
646
  try:
647
  if rpt is not None:
648
- new, note = _report_rewrite([j[2] for j in jobs], temp, mode, rep, base + 0.1 * step, base + 0.9 * step)
 
649
  else:
650
- new, note = _rewrite_retry([j[2] for j in jobs], temp, mode, rep, base + 0.1 * step, base + 0.9 * step)
 
651
  finally:
652
  if vtok is not None:
653
  H.VOCAB_TARGET.reset(vtok)
@@ -658,12 +706,14 @@ def smart_docx(path, target, passes, temperature, mode, hi, mod, rep):
658
  H.remember_anchor(t, n)
659
  done += 1
660
  log.append(_log_line(k, stats["pct"], len(jobs), done, locked))
 
661
  if rpt is not None:
662
  rows = [dict(report=ri[0], colour=ri[1], orig=j[2], new=(n if (n and n != j[2]) else j[2]),
663
  status="rephrased" if (n and n != j[2]) else "kept")
664
  for j, n, ri in zip(jobs, new, jinfo)] + linfo
665
  if not done:
666
  break
 
667
  # keep the lowest-scoring version seen (a pass can occasionally make things worse)
668
  rep(0.9, "Preparing results")
669
  from docx import Document
@@ -691,6 +741,7 @@ def smart_text(text, target, passes, temperature, mode, hi, mod, rep):
691
  parts = TP.parse_paste(text)
692
  seps = [sep for _, sep in parts]
693
  TP.GLOSSARY.set(TP.build_glossary([p for p, _ in parts])) # the text's key terms stay untouched
 
694
  paras = [p for p, _ in parts]
695
  orig = TP.join_paste(parts)
696
  H.ANCHOR.set({}) # rewritten sentence -> author's original sentence
@@ -700,6 +751,8 @@ def smart_text(text, target, passes, temperature, mode, hi, mod, rep):
700
  passes = 1
701
  acc, from_orig = None, True # last version whose AI score went DOWN (passes build on it)
702
  stall, low, t0 = 0, None, time.time() # automatic passes: stop when no pass improves any more
 
 
703
  step = 0.8 / max(1, passes)
704
  for k in range(1, int(passes) + 2):
705
  base = 0.05 + step * (k - 1)
@@ -707,6 +760,9 @@ def smart_text(text, target, passes, temperature, mode, hi, mod, rep):
707
  blocks = _score_paras(paras)
708
  stats = D.summarise(blocks, hi, mod)
709
  first = first or stats
 
 
 
710
  chg = H.text_change(orig, TP.join_paste(list(zip(paras, seps)))) if k > 1 else None
711
  cand_ = (stats["pct"], chg, (list(paras), blocks))
712
  if _better(cand_, best, k):
@@ -719,6 +775,10 @@ def smart_text(text, target, passes, temperature, mode, hi, mod, rep):
719
  chg, from_orig = acc[2], acc[3]
720
  else:
721
  acc, from_orig = (stats["pct"], list(paras), chg, k == 1, blocks), k == 1
 
 
 
 
722
  if k > passes:
723
  break
724
  if _done_early(k, stats["pct"], target, chg):
@@ -763,16 +823,19 @@ def smart_text(text, target, passes, temperature, mode, hi, mod, rep):
763
  if not jobs:
764
  log.append(_log_line(k, stats["pct"]) + " — no rewritable sentences left")
765
  break
766
- temp = min(1.5, float(temperature) + 0.15 * (k - 1))
 
767
  # pass 1 pushes each sentence to the new-wording level (measured against the ORIGINAL sentence);
768
  # later passes only lower the AI score: their input is already rewritten, so a per-sentence level there
769
  # would compare against the previous pass and keep churning. The whole-text level is checked below.
770
  vtok = H.VOCAB_TARGET.set(0.0) if not from_orig else None
771
  try:
772
  if rpt is not None:
773
- new, note = _report_rewrite([j[2] for j in jobs], temp, mode, rep, base + 0.1 * step, base + 0.9 * step)
 
774
  else:
775
- new, note = _rewrite_retry([j[2] for j in jobs], temp, mode, rep, base + 0.1 * step, base + 0.9 * step)
 
776
  finally:
777
  if vtok is not None:
778
  H.VOCAB_TARGET.reset(vtok)
@@ -785,12 +848,14 @@ def smart_text(text, target, passes, temperature, mode, hi, mod, rep):
785
  done += 1
786
  paras = [" ".join(sents[i]) if b.kind == "para" else b.text for i, b in enumerate(blocks)]
787
  log.append(_log_line(k, stats["pct"], len(jobs), done))
 
788
  if rpt is not None:
789
  rows = [dict(report=ri[0], colour=ri[1], orig=j[2], new=(n if (n and n != j[2]) else j[2]),
790
  status="rephrased" if (n and n != j[2]) else "kept")
791
  for j, n, ri in zip(jobs, new, jinfo)]
792
  if not done:
793
  break
 
794
  rep(0.9, "Preparing results")
795
  paras, blocks = best[2]
796
  after = D.summarise(blocks, hi, mod)
 
24
  import groqai as G
25
  import report_flags as R
26
  import restructure as RS
27
+ import adaptive as AD
28
  import meaning as MEANING
29
  import profile_settings as PS
30
  import fluency as FL
 
305
  return new, note
306
 
307
 
308
+ def _adaptive(fn, sentences, temperature, mode, rep, lo, hi):
309
+ """Self-adaptive pass: every sentence gets the setting its own history in this document calls for
310
+ (adaptive.settings): bolder (higher temperature + detector-guided decoding) when its AI score did not fall last
311
+ pass, calmer when its rewrites keep failing the checks, left as written after ADAPT_MAX_FAILS failures.
312
+ Sentences with the same setting are rewritten together."""
313
+ anchors = [H.anchor_of(t) or t for t in sentences]
314
+ sets = [AD.settings(a) for a in anchors]
315
+ groups = {}
316
+ for i, (dt, bold, skip) in enumerate(sets):
317
+ if not skip:
318
+ groups.setdefault((round(dt, 2), bold), []).append(i)
319
+ new, note = [None] * len(sentences), ""
320
+ total = max(1, sum(len(v) for v in groups.values()))
321
+ pos = 0
322
+ for (dt, bold), idx in sorted(groups.items()):
323
+ a_ = lo + (hi - lo) * pos / total
324
+ b_ = lo + (hi - lo) * (pos + len(idx)) / total
325
+ tok = H.FORCE_ADV.set(bool(bold))
326
+ try:
327
+ out, n_ = fn([sentences[i] for i in idx], min(1.5, max(0.3, temperature + dt)), mode, rep, a_, b_)
328
+ finally:
329
+ H.FORCE_ADV.reset(tok)
330
+ note = note or n_
331
+ for i, o in zip(idx, out):
332
+ new[i] = o
333
+ pos += len(idx)
334
+ for a, t, n in zip(anchors, sentences, new):
335
+ AD.outcome(a, bool(n and n.strip() != t.strip()))
336
+ skipped = sum(1 for x in sets if x[2])
337
+ if skipped:
338
+ note += f" 🧠 {skipped} sentence(s) kept as written: their rewrites failed the checks in {AD.MAX_FAILS} passes."
339
+ return new, note
340
+
341
+
342
  def _full_ai():
343
  return bool(H.FULL_AI.get())
344
 
 
584
  log, first, note = [], None, ""
585
  orig_text = "\n".join(b.text for b in D.docx_blocks(doc) if b.kind == "para")
586
  TP.GLOSSARY.set(TP.build_glossary([orig_text])) # the document's key terms stay untouched
587
+ TP.set_document([orig_text]) # its constructs (defined terms, keywords) never change
588
  best = None # (pct, docx bytes, scored blocks)
589
  rpt, rows = R.ACTIVE.get(), None # uploaded report: rewrite only the sentences it flags
590
  if rpt is not None:
591
  passes = 1
592
  acc, from_orig = None, True # last version whose AI score went DOWN (passes build on it)
593
  stall, low, t0 = 0, None, time.time() # automatic passes: stop when no pass improves any more
594
+ AD.start_job() # self-adaptive settings per sentence for this document
595
+ pend = None # (temperature offset, AI % before, jobs, rewritten) of last pass
596
  step = 0.8 / max(1, passes)
597
  for k in range(1, int(passes) + 2): # last iteration = final check only
598
  base = 0.05 + step * (k - 1)
 
600
  blocks = _score_doc(doc)
601
  stats = D.summarise(blocks, hi, mod)
602
  first = first or stats
603
+ if pend is not None: # learn how well last pass's setting worked
604
+ AD.record_pass(pend[0], pend[1], stats["pct"], pend[2], pend[3])
605
+ pend = None
606
  cur = "\n".join(b.text for b in blocks if b.kind == "para")
607
  chg = H.text_change(orig_text, cur) if k > 1 else None
608
  cand_ = (stats["pct"], chg, (_snapshot(doc), blocks))
 
618
  chg, from_orig = acc[2], acc[3]
619
  else:
620
  acc, from_orig = (stats["pct"], _snapshot(doc), chg, k == 1), k == 1
621
+ for b_ in blocks: # per-sentence learning: did its AI score fall?
622
+ if b_.kind == "para":
623
+ for s_, p_ in zip(b_.sents, b_.scores):
624
+ AD.observe(H.anchor_of(s_) or s_, p_)
625
  if k > passes:
626
  break
627
  if _done_early(k, stats["pct"], target, chg):
 
683
  rows = linfo
684
  log.append(_log_line(k, stats["pct"]) + f" — no rewritable sentences left ({locked} locked)")
685
  break
686
+ t_off = AD.pick_temp_offset() # learned across documents (Thompson sampling)
687
+ temp = min(1.5, max(0.3, float(temperature) + 0.15 * (k - 1) + t_off))
688
  # pass 1 pushes each sentence to the new-wording level (measured against the ORIGINAL sentence);
689
  # later passes only lower the AI score: their input is already rewritten, so a per-sentence level there
690
  # would compare against the previous pass and keep churning. The whole-text level is checked below.
691
  vtok = H.VOCAB_TARGET.set(0.0) if not from_orig else None
692
  try:
693
  if rpt is not None:
694
+ new, note = _adaptive(_report_rewrite, [j[2] for j in jobs], temp, mode, rep, base + 0.1 * step,
695
+ base + 0.9 * step)
696
  else:
697
+ new, note = _adaptive(_rewrite_retry, [j[2] for j in jobs], temp, mode, rep, base + 0.1 * step,
698
+ base + 0.9 * step)
699
  finally:
700
  if vtok is not None:
701
  H.VOCAB_TARGET.reset(vtok)
 
706
  H.remember_anchor(t, n)
707
  done += 1
708
  log.append(_log_line(k, stats["pct"], len(jobs), done, locked))
709
+ pend = (t_off, stats["pct"], len(jobs), done)
710
  if rpt is not None:
711
  rows = [dict(report=ri[0], colour=ri[1], orig=j[2], new=(n if (n and n != j[2]) else j[2]),
712
  status="rephrased" if (n and n != j[2]) else "kept")
713
  for j, n, ri in zip(jobs, new, jinfo)] + linfo
714
  if not done:
715
  break
716
+ AD.save(force=True)
717
  # keep the lowest-scoring version seen (a pass can occasionally make things worse)
718
  rep(0.9, "Preparing results")
719
  from docx import Document
 
741
  parts = TP.parse_paste(text)
742
  seps = [sep for _, sep in parts]
743
  TP.GLOSSARY.set(TP.build_glossary([p for p, _ in parts])) # the text's key terms stay untouched
744
+ TP.set_document([p for p, _ in parts])
745
  paras = [p for p, _ in parts]
746
  orig = TP.join_paste(parts)
747
  H.ANCHOR.set({}) # rewritten sentence -> author's original sentence
 
751
  passes = 1
752
  acc, from_orig = None, True # last version whose AI score went DOWN (passes build on it)
753
  stall, low, t0 = 0, None, time.time() # automatic passes: stop when no pass improves any more
754
+ AD.start_job() # self-adaptive settings per sentence for this document
755
+ pend = None # (temperature offset, AI % before, jobs, rewritten) of last pass
756
  step = 0.8 / max(1, passes)
757
  for k in range(1, int(passes) + 2):
758
  base = 0.05 + step * (k - 1)
 
760
  blocks = _score_paras(paras)
761
  stats = D.summarise(blocks, hi, mod)
762
  first = first or stats
763
+ if pend is not None: # learn how well last pass's setting worked
764
+ AD.record_pass(pend[0], pend[1], stats["pct"], pend[2], pend[3])
765
+ pend = None
766
  chg = H.text_change(orig, TP.join_paste(list(zip(paras, seps)))) if k > 1 else None
767
  cand_ = (stats["pct"], chg, (list(paras), blocks))
768
  if _better(cand_, best, k):
 
775
  chg, from_orig = acc[2], acc[3]
776
  else:
777
  acc, from_orig = (stats["pct"], list(paras), chg, k == 1, blocks), k == 1
778
+ for b_ in blocks: # per-sentence learning: did its AI score fall?
779
+ if b_.kind == "para":
780
+ for s_, p_ in zip(b_.sents, b_.scores):
781
+ AD.observe(H.anchor_of(s_) or s_, p_)
782
  if k > passes:
783
  break
784
  if _done_early(k, stats["pct"], target, chg):
 
823
  if not jobs:
824
  log.append(_log_line(k, stats["pct"]) + " — no rewritable sentences left")
825
  break
826
+ t_off = AD.pick_temp_offset() # learned across documents (Thompson sampling)
827
+ temp = min(1.5, max(0.3, float(temperature) + 0.15 * (k - 1) + t_off))
828
  # pass 1 pushes each sentence to the new-wording level (measured against the ORIGINAL sentence);
829
  # later passes only lower the AI score: their input is already rewritten, so a per-sentence level there
830
  # would compare against the previous pass and keep churning. The whole-text level is checked below.
831
  vtok = H.VOCAB_TARGET.set(0.0) if not from_orig else None
832
  try:
833
  if rpt is not None:
834
+ new, note = _adaptive(_report_rewrite, [j[2] for j in jobs], temp, mode, rep, base + 0.1 * step,
835
+ base + 0.9 * step)
836
  else:
837
+ new, note = _adaptive(_rewrite_retry, [j[2] for j in jobs], temp, mode, rep, base + 0.1 * step,
838
+ base + 0.9 * step)
839
  finally:
840
  if vtok is not None:
841
  H.VOCAB_TARGET.reset(vtok)
 
848
  done += 1
849
  paras = [" ".join(sents[i]) if b.kind == "para" else b.text for i, b in enumerate(blocks)]
850
  log.append(_log_line(k, stats["pct"], len(jobs), done))
851
+ pend = (t_off, stats["pct"], len(jobs), done)
852
  if rpt is not None:
853
  rows = [dict(report=ri[0], colour=ri[1], orig=j[2], new=(n if (n and n != j[2]) else j[2]),
854
  status="rephrased" if (n and n != j[2]) else "kept")
855
  for j, n, ri in zip(jobs, new, jinfo)]
856
  if not done:
857
  break
858
+ AD.save(force=True)
859
  rep(0.9, "Preparing results")
860
  paras, blocks = best[2]
861
  after = D.summarise(blocks, hi, mod)
tests/test_groqai.py CHANGED
@@ -61,7 +61,8 @@ class _Resp:
61
  def test_rate_limited_model_falls_back_to_the_next_model(monkeypatch):
62
  import httpx
63
  monkeypatch.setattr(G, "KEYS", ["k1"])
64
- monkeypatch.setattr(G, "_KEY_STATE", [{"until": 0.0, "calls": 0, "err": ""}])
 
65
  monkeypatch.setattr(G, "ENABLED", True)
66
  monkeypatch.setattr(G, "_models", ["big", "small"])
67
  monkeypatch.setattr(G, "_KM_UNTIL", {})
 
61
  def test_rate_limited_model_falls_back_to_the_next_model(monkeypatch):
62
  import httpx
63
  monkeypatch.setattr(G, "KEYS", ["k1"])
64
+ monkeypatch.setattr(G, "_KEY_STATE", [dict(G._KEY_STATE[0] if G._KEY_STATE else {}, until=0.0, calls=0, err="",
65
+ tok_left=None, tok_reset=0.0, tok_limit=None, inflight=0, tokens=0)])
66
  monkeypatch.setattr(G, "ENABLED", True)
67
  monkeypatch.setattr(G, "_models", ["big", "small"])
68
  monkeypatch.setattr(G, "_KM_UNTIL", {})
textparse.py CHANGED
@@ -135,6 +135,7 @@ viscosity density velocity torque load cathode anode electrolyte catalyst enzyme
135
  hypothesis significance sample respondent mediation moderation reliability validity simulation mesh node
136
  cluster packet protocol routing entropy matrix vector tensor derivative integral equation theorem spectrum
137
  wavelength phase charge discharge battery circuit converter inverter sensor compression sparse sparsity
 
138
  reconstruction segmentation detection estimation prediction forecasting pavement asphalt binder aggregate
139
  """.split())
140
 
@@ -201,6 +202,8 @@ def build_glossary(texts):
201
  continue
202
  if n == 2 and max(len(g[0]), len(g[1])) < 5:
203
  continue
 
 
204
  k = " ".join(g)
205
  grams[k] = grams.get(k, 0) + 1
206
  if NARROW.get(): # report-flagged rewriting: real technical terms only
@@ -214,6 +217,103 @@ def build_glossary(texts):
214
  return frozenset(t for t in terms if t)
215
 
216
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
217
  def lost_terms(original, new, glossary=None):
218
  """Technical terms of `original` that `new` no longer contains (empty list = safe)."""
219
  glossary = GLOSSARY.get() if glossary is None else glossary
@@ -227,6 +327,7 @@ def lost_terms(original, new, glossary=None):
227
  missing += [g for g in glossary if f" {g} " in no and f" {g} " not in nn]
228
  # statistical relations in any word form: "does not moderate" must not become "does not modify"
229
  missing += [st + "…" for st in _STAT_STEMS if f" {st}" in no and f" {st}" not in nn]
 
230
  return sorted(set(missing))
231
 
232
 
@@ -250,11 +351,12 @@ def invented(original, new):
250
  if any(n not in nums_o for n in re.findall(r"\d+(?:\.\d+)?", CITE.sub(" ", new))):
251
  return "number"
252
  for m in re.finditer(r"\b[A-Z][a-z]{2,}\b", new): # capitalised word mid-sentence = name-like
253
- if _SENT_START.search(new[:m.start()]):
254
- continue
 
255
  w = m.group(0).lower()
256
- if w in o_words:
257
- continue
258
  # another form of a name the author uses ("India's" for "Indian", "Bayes" for "Bayesian") is not invented
259
  if len(w) >= 4 and any(o.startswith(w[:max(4, len(w) - 2)]) or w.startswith(o[:max(4, len(o) - 2)])
260
  for o in o_caps):
@@ -300,11 +402,43 @@ def _known(word, o_words):
300
  return False
301
 
302
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
303
  def quality_issue(original, new):
304
  """Why `new` is not acceptable academic English compared with `original` (None = fine):
305
- a non-word the original doesn't contain, informal / conversational wording, first person singular,
306
- addressing the reader, contractions, or a question where the original made a statement."""
 
307
  o_words = set(re.findall(r"[a-z][a-z'\-]*", original.lower()))
 
 
 
 
 
 
 
 
 
308
  for w in re.findall(r"[A-Za-z][A-Za-z\-]{3,}", CITE.sub(" ", new)):
309
  if w.isupper() or (w[0].isupper() and w.lower() in o_words):
310
  continue # acronyms / names already in the original
@@ -376,6 +510,152 @@ def repair_citations(original, new):
376
  return None
377
  new = new.replace(c, best, 1)
378
  return new
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
379
 
380
 
381
  def guard(original, new, glossary=None):
@@ -389,12 +669,21 @@ def guard(original, new, glossary=None):
389
  new = repair_citations(original, new)
390
  if new is None or invented(original, new) or quality_issue(original, new) or symbol_issue(original, new):
391
  return None
 
 
 
 
 
 
 
 
 
392
  ow, nw = len(original.split()), len(new.split())
393
  if nw < 0.5 * ow or nw > 1.8 * ow + 5:
394
  return None
395
  # numbers (results, years, units) must be kept
396
- nums_o = set(re.findall(r"\d+(?:\.\d+)?%?", CITE.sub(" ", original)))
397
- nums_n = set(re.findall(r"\d+(?:\.\d+)?%?", new))
398
  if not nums_o <= nums_n:
399
  return None
400
  if lost_terms(original, new, glossary): # technical wording must survive unchanged
@@ -747,6 +1036,8 @@ def basic_grammar(s):
747
  t = re.sub(r"\s+([,.;:!?%)\]])", r"\1", t) # space before punctuation
748
  t = re.sub(r"([(\[])\s+", r"\1", t)
749
  t = re.sub(r",\s*,+", ",", t)
 
 
750
  t = re.sub(r"([.!?])\1+", r"\1", t)
751
  t = _outside_cites(t, lambda x: re.sub(r"\b(\w{2,})\s+\1\b", r"\1", x, flags=re.I)) # "the the", "data data"
752
  t = _outside_cites(t, lambda x: re.sub(r"\b([a-z]{3,})\s+(and|or)\s+\1\b", r"\1", x)) # "informed and informed"
@@ -796,11 +1087,21 @@ def strict_check(cur, cand, glossary, ref=None):
796
  new = match_ending(cur, basic_grammar(normalize_typography(cur, cand.strip())))
797
  new = re.sub(r'([.!?])\s*["”“]{2,}', r"\1”", new) # .”” -> .”
798
  new = re.sub(r'["“”]{2,}', "”", new)
 
799
  if new.strip() == cur.strip():
800
  return None, "unchanged"
801
  ow, nw = len(cur.split()), len(new.split())
802
  if nw < 0.55 * ow or nw > 1.6 * ow + 4:
803
  return None, "length"
 
 
 
 
 
 
 
 
 
804
  for base in filter(None, (cur, ref)):
805
  why = invented(base, new)
806
  if why:
@@ -808,8 +1109,8 @@ def strict_check(cur, cand, glossary, ref=None):
808
  why = quality_issue(cur, new) or symbol_issue(cur, new)
809
  if why:
810
  return None, why
811
- nums_c = set(re.findall(r"\d+(?:\.\d+)?%?", CITE.sub(" ", cur)))
812
- if not nums_c <= set(re.findall(r"\d+(?:\.\d+)?%?", new)):
813
  return None, "number lost"
814
  cc, nc = _cites(cur), _cites(new)
815
  if len(nc) > len(cc) or any(not any(difflib.SequenceMatcher(None, c, n).ratio() >= 0.8 for n in nc) for c in cc):
@@ -834,6 +1135,10 @@ def _near_repeats(t):
834
  out = set()
835
  for i, x in enumerate(w):
836
  if len(x) >= 5 and x in w[i + 1:i + 5]:
 
 
 
 
837
  out.add(x)
838
  return out
839
 
 
135
  hypothesis significance sample respondent mediation moderation reliability validity simulation mesh node
136
  cluster packet protocol routing entropy matrix vector tensor derivative integral equation theorem spectrum
137
  wavelength phase charge discharge battery circuit converter inverter sensor compression sparse sparsity
138
+ gender genders male males female females men women participants respondents
139
  reconstruction segmentation detection estimation prediction forecasting pavement asphalt binder aggregate
140
  """.split())
141
 
 
202
  continue
203
  if n == 2 and max(len(g[0]), len(g[1])) < 5:
204
  continue
205
+ if n == 3 and g[1] in _STOP_N and g[1] not in ("of", "to", "and", "for", "in", "on", "per"):
206
+ continue # "support the preservation" is wording, not a term
207
  k = " ".join(g)
208
  grams[k] = grams.get(k, 0) + 1
209
  if NARROW.get(): # report-flagged rewriting: real technical terms only
 
217
  return frozenset(t for t in terms if t)
218
 
219
 
220
+ # ---------------------------------------------------------------- document key terms (always protected)
221
+ # The study's own constructs must never be swapped for synonyms ("travel satisfaction" -> "travel pleasure",
222
+ # "intimacy" -> "closeness", "destination loyalty" -> "allegiance"). They are read from the WHOLE document once per
223
+ # job and protected in every mode, including report-guided rewriting where the ordinary glossary is narrowed:
224
+ # * every term defined with an abbreviation, "travel satisfaction (TS)" -> "travel satisfaction" + "satisfaction"
225
+ # * the Keywords line, "Keywords: Travel motivation, Intimacy, ..." -> each keyword + its head word
226
+ KEY = contextvars.ContextVar("tp_key_terms", default=frozenset())
227
+ KEY_DEF = contextvars.ContextVar("tp_key_defined", default=frozenset()) # terms defined with an abbreviation
228
+ DOC_VOCAB = contextvars.ContextVar("tp_doc_vocab", default=frozenset())
229
+ DOC_PROPER = contextvars.ContextVar("tp_doc_proper", default=frozenset()) # words the document always capitalises
230
+ _GENERIC_HEADS = {"study", "analysi", "analysis", "difference", "differenc", "economy", "engagement", "modeling",
231
+ "modelling", "council", "organization", "organisation", "approach", "model", "method", "framework", "system", "process", "factor",
232
+ "factors", "effect", "effects", "impact", "role", "level", "levels", "rate", "index", "test",
233
+ "scale", "theory", "research", "development", "management", "performance", "behaviour",
234
+ "behavior", "value", "values", "data", "group", "groups", "practice", "practices"}
235
+
236
+
237
+ def key_terms(texts):
238
+ """Construct names of a document (normalised), see KEY."""
239
+ joined = " ".join(t for t in texts if t)
240
+ terms = set()
241
+ for m in re.finditer(r"((?:[A-Za-z][\w\-]*\s+){1,5})\(\s*([A-Z][A-Za-z0-9\-]{1,11}?)s?\s*\)", joined):
242
+ acr, words = m.group(2), m.group(1).split()
243
+ core = len(re.findall(r"\b" + re.escape(acr) + r"\b", joined)) >= 4 # a construct the study keeps using
244
+ n = len(re.findall(r"[A-Z]", acr)) or len(acr)
245
+ cand = words[-n:]
246
+ while cand and cand[0].lower() in _STOP:
247
+ cand = cand[1:]
248
+ if not cand or len(" ".join(cand)) < 4:
249
+ continue
250
+ terms.add(_norm(" ".join(cand)).strip())
251
+ head = _norm(cand[-1]).strip()
252
+ if core and len(head) >= 5 and head not in _GENERIC_HEADS and head not in _STOP_N:
253
+ terms.add(head)
254
+ KEY_DEF.set(frozenset(terms))
255
+ for line in re.findall(r"(?im)^\s*key\s*-?\s*words?\s*[:\-–]\s*(.+)$", "\n".join(t for t in texts if t)):
256
+ for kw in re.split(r"[,;·•]", line):
257
+ kw = _norm(kw).strip()
258
+ if len(kw) >= 4 and len(kw.split()) <= 5:
259
+ terms.add(kw)
260
+ head = kw.split()[-1]
261
+ if len(head) >= 5 and head not in _GENERIC_HEADS and head not in _STOP_N:
262
+ terms.add(head)
263
+ return frozenset(t for t in terms if t)
264
+
265
+
266
+ # everyday synonyms a paraphraser uses for common research constructs; when the construct is one of the document's
267
+ # key terms, a rewrite may not bring in its synonym ("satisfaction" study: "pleasure", "contentment" are rejected)
268
+ _CONSTRUCT_SYN = {
269
+ "satisfaction": {"pleasure", "contentment", "happiness", "delight", "gratification", "fulfilment", "fulfillment"},
270
+ "loyalty": {"allegiance", "faithfulness", "devotion", "fidelity"},
271
+ "intimacy": {"closeness", "familiarity", "affinity"},
272
+ "motivation": {"incentive", "incentives", "impetus", "inducement"},
273
+ "well being": {"welfare", "wellness", "health"},
274
+ "wellbeing": {"welfare", "wellness", "health"},
275
+ "trust": {"faith", "confidence", "reliance"},
276
+ "attachment": {"bond", "fondness", "affection"},
277
+ "intention": {"willingness", "inclination"},
278
+ "engagement": {"involvement", "participation", "commitment"},
279
+ "commitment": {"dedication", "devotion", "pledge"},
280
+ "performance": {"output", "achievement", "efficiency"},
281
+ "image": {"picture", "impression", "reputation"},
282
+ "quality": {"standard", "calibre", "caliber", "grade"},
283
+ "anxiety": {"worry", "nervousness", "unease"},
284
+ "stress": {"strain", "pressure", "tension"},
285
+ }
286
+
287
+
288
+ def renamed_construct(original, new):
289
+ """A key construct's everyday synonym the rewrite introduces ("allegiance" in a loyalty study), or None."""
290
+ key = KEY.get()
291
+ if not key:
292
+ return None
293
+ nn, no = _norm(new), _norm(original)
294
+ for term, syns in _CONSTRUCT_SYN.items():
295
+ if term in key:
296
+ for w in syns:
297
+ if f" {_norm(w).strip()} " in nn and f" {_norm(w).strip()} " not in no:
298
+ return f"{term} -> {w}"
299
+ return None
300
+
301
+
302
+ def set_document(texts):
303
+ """Call once per job with the whole document: key terms and vocabulary for the checks below."""
304
+ texts = [t for t in texts if t]
305
+ KEY.set(key_terms(texts))
306
+ cap, low = {}, {}
307
+ for t in texts:
308
+ for m in re.finditer(r"(?<![.!?:]\s)(?<=\s)([A-Za-z][a-z]{2,})\b", t): # mid-sentence words only
309
+ w = m.group(1)
310
+ d = cap if w[:1].isupper() else low
311
+ d[w.lower()] = d.get(w.lower(), 0) + 1
312
+ DOC_PROPER.set(frozenset(w.capitalize() for w, c in cap.items() if c >= 2 and not low.get(w)))
313
+ vocab = {w.lower() for t in texts for w in re.findall(r"[A-Za-z][A-Za-z\-]+", t)}
314
+ DOC_VOCAB.set(frozenset(vocab) if sum(len(t.split()) for t in texts) >= 300 else frozenset())
315
+
316
+
317
  def lost_terms(original, new, glossary=None):
318
  """Technical terms of `original` that `new` no longer contains (empty list = safe)."""
319
  glossary = GLOSSARY.get() if glossary is None else glossary
 
327
  missing += [g for g in glossary if f" {g} " in no and f" {g} " not in nn]
328
  # statistical relations in any word form: "does not moderate" must not become "does not modify"
329
  missing += [st + "…" for st in _STAT_STEMS if f" {st}" in no and f" {st}" not in nn]
330
+ missing += [g for g in KEY.get() if f" {g} " in no and f" {g} " not in nn] # study constructs
331
  return sorted(set(missing))
332
 
333
 
 
351
  if any(n not in nums_o for n in re.findall(r"\d+(?:\.\d+)?", CITE.sub(" ", new))):
352
  return "number"
353
  for m in re.finditer(r"\b[A-Z][a-z]{2,}\b", new): # capitalised word mid-sentence = name-like
354
+ if _SENT_START.search(new[:m.start()]) or (re.search(r"[\])]\s*$", new[:m.start()])
355
+ and re.fullmatch(r"[A-Z][a-z]+(?:ed|es|s)", m.group(0))):
356
+ continue # sentence start, or "[56] Underscored" (citation + verb)
357
  w = m.group(0).lower()
358
+ if w in o_words or re.search(r"\b" + re.escape(w) + r"\b", o_low):
359
+ continue # ("Men's" in the original counts as "Men")
360
  # another form of a name the author uses ("India's" for "Indian", "Bayes" for "Bayesian") is not invented
361
  if len(w) >= 4 and any(o.startswith(w[:max(4, len(w) - 2)]) or w.startswith(o[:max(4, len(o) - 2)])
362
  for o in o_caps):
 
402
  return False
403
 
404
 
405
+ _FOREIGN = re.compile(r"\b[\w\-]*[À-ÖØ-öø-ÿĀ-žƀ-ɏ][\w\-]*")
406
+ OFFTOPIC_N = int(__import__("os").getenv("HUM_OFFTOPIC", "2")) # 0 = check off
407
+
408
+
409
+ def _offtopic(original, new):
410
+ """Uncommon content words the rewrite brings in that appear nowhere in the document and are not forms of the
411
+ sentence's own words ("organizational recruitment" in a tourism paper). None, or the words."""
412
+ vocab = DOC_VOCAB.get()
413
+ if not vocab or not OFFTOPIC_N or _zipf is None:
414
+ return None
415
+ o_low = original.lower()
416
+ stems = {w[:5] for w in re.findall(r"[a-z]{5,}", o_low)}
417
+ odd = []
418
+ for w in set(re.findall(r"[A-Za-z][a-z\-]{5,}", CITE.sub(" ", new))):
419
+ lw = w.lower()
420
+ if lw in vocab or lw[:5] in stems or lw in _STOP or w[:1].isupper():
421
+ continue
422
+ if _zipf(lw, "en") < 3.3 and not any(v.startswith(lw[:6]) for v in vocab):
423
+ odd.append(lw) # rare AND unrelated to any word the document uses
424
+ return odd if len(odd) >= OFFTOPIC_N else None
425
+
426
+
427
  def quality_issue(original, new):
428
  """Why `new` is not acceptable academic English compared with `original` (None = fine):
429
+ a non-word the original doesn't contain, a foreign word, words foreign to the document's topic, informal /
430
+ conversational wording, first person singular, addressing the reader, contractions, or a question where the
431
+ original made a statement."""
432
  o_words = set(re.findall(r"[a-z][a-z'\-]*", original.lower()))
433
+ syn = renamed_construct(original, new)
434
+ if syn:
435
+ return f"construct renamed ({syn})"
436
+ for m in _FOREIGN.finditer(new): # "well-être": accented words the original does not use
437
+ if m.group(0).lower() not in original.lower():
438
+ return f"foreign word '{m.group(0)}'"
439
+ odd = _offtopic(original, new)
440
+ if odd:
441
+ return "off-topic words '" + ", ".join(sorted(odd)) + "'"
442
  for w in re.findall(r"[A-Za-z][A-Za-z\-]{3,}", CITE.sub(" ", new)):
443
  if w.isupper() or (w[0].isupper() and w.lower() in o_words):
444
  continue # acronyms / names already in the original
 
510
  return None
511
  new = new.replace(c, best, 1)
512
  return new
513
+ def _symbols(t):
514
+ """Comparison / statistics symbols outside citations: < > ≤ ≥ = ± × and their order."""
515
+ return re.findall(r"[<>≤≥=±×]", CITE.sub(" ", t or ""))
516
+
517
+
518
+ def _brackets_ok(t):
519
+ return (t or "").count("(") == (t or "").count(")") and (t or "").count("[") == (t or "").count("]")
520
+
521
+
522
+ def _new_acronyms(original, new):
523
+ acr = lambda t: set(re.findall(r"\b[A-Z][A-Z0-9]{1,}[a-z]?\b", CITE.sub(" ", t or "")))
524
+ return acr(new) - acr(original)
525
+
526
+
527
+ # ---------------------------------------------------------------- integrity checks shared by EVERY rewrite path
528
+ # (Parrot guard, Groq quality check, final polish, proofread, clarity gate). Each one targets an error seen in real
529
+ # humanized papers.
530
+ _ATTRIB = re.compile(r"\b(?:according to|(?:some |many |several )?(?:experts?|scholars?|researchers?|analysts?|"
531
+ r"studies|scientists)\s+(?:say|says|said|argue|argues|contend|contends|believe|believes|"
532
+ r"suggest|suggests|claim|claims|found|find|show|shows|note|notes)|a (?:systematic |recent )?"
533
+ r"(?:analysis|review|study|survey) (?:found|showed|shows|finds|revealed|suggests))\b", re.I)
534
+ _SAID = re.compile(r"\b(?:said|says|told|stated|according to)\b", re.I)
535
+ _CAUSAL = re.compile(r"\b(?:because|since (?:they|it|he|she|we|the|this|these|those|their|its)|due to|owing to|"
536
+ r"as a result of|thereby|in turn)\b", re.I)
537
+ _DANGLING_END = re.compile(r"\b(?:the|a|an|their|its|our|his|her|of|to|and|or|on|in|with|for|by|from|as|than|"
538
+ r"that|which|this|these|between|among)\s*[.!?]$", re.I)
539
+ _DANGLING_HYPHEN = re.compile(r"[A-Za-z]-(?=[\s,.;:)])")
540
+ _EMDASH = re.compile(r"—|\s–\s|\w\s-\s\w") # also a spaced hyphen used as a dash
541
+ _SENT_ADV = r"(?:However|Moreover|Furthermore|Therefore|Consequently|Nevertheless|Nonetheless|Additionally|" \
542
+ r"In addition|In general|In contrast|In particular|In summary|In conclusion|For example|For instance|" \
543
+ r"Thus|Hence|Instead|Similarly|Likewise|Meanwhile|Simultaneously|Finally|" \
544
+ r"Accordingly|Specifically|Notably|Importantly|Ultimately|Conversely|Subsequently)"
545
+ # ("Further", "Overall", "First" are left out: "Further studies…", "Overall satisfaction…" need no comma)
546
+
547
+
548
+ def _proper_nouns(t):
549
+ """Capitalised names inside a sentence (Darjeeling, Singapore, Kumar), not its first word."""
550
+ words = re.findall(r"(?<=[\s(\[,;:])([A-Z][a-z]{2,}(?:-[A-Z][a-z]+)?)\b", " " + CITE.sub(" ", t or "")[1:])
551
+ return {w for w in words if w.lower() not in _STOP and w not in ("Table", "Figure", "Fig", "Section", "Eq",
552
+ "Chapter", "Appendix")}
553
+
554
+
555
+ def integrity_issue(original, new):
556
+ """Damage a rewrite may never do (None = fine):
557
+ * an attribution or a causal explanation the original does not make ("According to some experts … since they
558
+ have more muscle"; "a systematic analysis found");
559
+ * a name / place dropped from the sentence ("Digha, Darjeeling, Jhargram" -> "Digha Jhargram");
560
+ * a key construct used more often than in the original ("… destination loyalty (DL) and intimacy");
561
+ * a cut-off ending ("… and to enhance their.") or a cut-off term ("on well-, and");
562
+ * an em dash, or a doubled conjunction ("and, and");
563
+ * two sentences run together ("… or mass According to …")."""
564
+ o, n = original or "", new or ""
565
+ try:
566
+ import adaptive as _AD # words learned to cause bad rewrites
567
+ bw = _AD.banned_in(o, n)
568
+ if bw:
569
+ return f"banned word: {bw}"
570
+ except Exception:
571
+ pass
572
+ if _ATTRIB.search(n) and not _ATTRIB.search(o):
573
+ return "attribution added"
574
+ if len(_CAUSAL.findall(n)) > len(_CAUSAL.findall(o)):
575
+ return "causal claim added"
576
+ if len(_SAID.findall(n)) > len(_SAID.findall(o)):
577
+ return "attribution added" # "... beyond local capacity the director said"
578
+ if set(re.findall(r"\b[A-Z]\.[A-Z]\.?", n)) - set(re.findall(r"\b[A-Z]\.[A-Z]\.?", o)):
579
+ return "abbreviation added" # "Darjeeling, and M.P, and purulia"
580
+ names = DOC_PROPER.get()
581
+ for w in _proper_nouns(o) | ({o.split()[0].strip(",.;:")} if o.split() else set()):
582
+ is_name = w in names if names else (_zipf is not None and _zipf(w.lower(), "en") < 2.0)
583
+ if len(w) > 3 and is_name and re.search(r"\b" + re.escape(w.lower()) + r"\b", n) and w not in n:
584
+ return f"name lower-cased: {w}" # "purulia" (the document always writes "Purulia")
585
+ for a, b in re.findall(r"\b([A-Z][a-z]+)\s+([A-Z][a-z]+)\b", o):
586
+ if a in names and re.search(r"\b" + a.lower() + r"\s+" + b + r"\b", n):
587
+ return f"name lower-cased: {a} {b}" # "west Bengal"
588
+ stray = r"[a-z,]\s+(?:The|A|An|Of|For|In|And|To|With|On)\s"
589
+ if re.search(stray, n) and not re.search(stray, o):
590
+ return "capital inside a sentence" # "variance in The TS"
591
+ if re.search(r"(?:\b[A-Z][a-z]{2,}\s+){3,}", n) and not re.search(r"(?:\b[A-Z][a-z]{2,}\s+){3,}", o):
592
+ caps = re.findall(r"(?:\b[A-Z][a-z]{2,}\s+){3,}", n)[0].split()
593
+ if sum(w.lower() in _STOP or (_zipf is not None and _zipf(w.lower(), "en") >= 4.5) for w in caps) >= 2:
594
+ return "title case in a sentence" # "The Strong Effect of Intimacy For Both genders"
595
+ if re.match(r"^\[\d[\d,\s–\-]*\]\s+[A-Z][a-z]+(?:ed|es|s)\b", o) and \
596
+ re.match(r"^\[\d[\d,\s–\-]*\]\s+(?:The|A|An|This|These|Its)\b", n):
597
+ return "reporting verb dropped" # "[56] Highlighted ..." -> "[56] The importance of ..."
598
+ if re.search(r"\b(whereas|while|although|though|but|however|and|or|because)\s+(whereas|while|although|"
599
+ r"though|but|however|because)\b", n, re.I):
600
+ return "doubled connective" # "whereas while", "but however"
601
+ sw = [x.lower() for x in re.findall(r"[A-Za-z]{5,}", n)]
602
+ so = [x.lower() for x in re.findall(r"[A-Za-z]{5,}", o)]
603
+ rep_n = {a[:5] for a, b in zip(sw, sw[1:]) if a != b and a[:5] == b[:5]}
604
+ rep_o = {a[:5] for a, b in zip(so, so[1:]) if a != b and a[:5] == b[:5]}
605
+ if rep_n - rep_o:
606
+ return "stem repeated" # "intimate intimacy intimation"
607
+ dash = lambda t: re.sub(r"[‐‑–—]", "-", t).lower()
608
+ lost = {w for w in _proper_nouns(o) if dash(w) not in dash(n) # (case / dash changes are fine)
609
+ and not re.search(r"[\])]\s*" + re.escape(w) + r"\b", o) # "[59] Discussed" is a verb
610
+ and not (_zipf is not None and _zipf(w.lower(), "en") >= 5.0)} # "for Men" -> "for males"
611
+ if lost:
612
+ return "name dropped: " + ", ".join(sorted(lost))
613
+ nn, no = _norm(n), _norm(o)
614
+ for k in KEY_DEF.get():
615
+ if nn.count(f" {k} ") > max(1, no.count(f" {k} ")):
616
+ return f"construct repeated: {k}"
617
+ if _DANGLING_END.search(n.strip()) and not _DANGLING_END.search(o.strip()):
618
+ return "sentence cut off"
619
+ if len(_DANGLING_HYPHEN.findall(n)) > len(_DANGLING_HYPHEN.findall(o)):
620
+ return "term cut off"
621
+ cut = r"\b(?:of|to|for|with|on|in|by|from|the|a|an|and|or|their|its)\s*,"
622
+ if len(re.findall(cut, n, re.I)) > len(re.findall(cut, o, re.I)):
623
+ return "phrase cut off" # "the port of, and the forest"
624
+ op = r"\b(?:Sadly|Unfortunately|Fortunately|Luckily|Interestingly|Surprisingly|Obviously|Clearly|Undoubtedly|" \
625
+ r"Remarkably|Importantly|Notably|Strikingly)\b"
626
+ if re.search(op, n) and not re.search(op, o):
627
+ return "opinion word added" # "Sadly loyalty does not ..."
628
+ letters = lambda t: re.findall(r"(?<![\w.'’-])([b-zB-HJ-Z])(?![\w.'’-])", CITE.sub(" ", t))
629
+ if len(letters(n)) > len(letters(o)):
630
+ return "stray letter" # "... is d a vital predictor"
631
+ sq = lambda t: t.count("[") - sum(1 for c in CITE.findall(t) if c.startswith("[")) # "[" outside citations
632
+ if sq(n) > sq(o): # (author-year citations have no "[": not counted)
633
+ return "bracket type changed" # "(explaining ...)" -> "[explaning ...]"
634
+ if _EMDASH.search(n) and not _EMDASH.search(o):
635
+ return "em dash"
636
+ if re.search(r"\b(\w+),?\s+\1\b", n, re.I) and not re.search(r"\b(\w+),?\s+\1\b", o, re.I):
637
+ return "doubled word"
638
+ if re.search(r"\b[a-z]{3,}\s+(?:According|However|Moreover|Furthermore|Therefore|This|These|The|It|In)\s+[a-z]", n) \
639
+ and not re.search(r"\b[a-z]{3,}\s+(?:According|However|Moreover|Furthermore|Therefore|This|These|The|It|In)\s+[a-z]", o):
640
+ return "sentences run together"
641
+ return None
642
+
643
+
644
+ def restore_commas(original, new):
645
+ """Put back the commas a paraphraser dropped between words that keep their order: list items ("airports,
646
+ trains and taxis"), after an opening phrase ("After COVID-19, tourists") and after an abbreviation in brackets
647
+ ("(TM), eudaimonic"). Also adds the comma after a sentence-opening linking word ("However the" -> "However,
648
+ the")."""
649
+ if not new:
650
+ return new
651
+ out = new
652
+ for m in re.finditer(r"(?<![\w\-])([\w)\]%][\w\-)\]%]*),\s+(?=([\w(\[]+))", original or ""):
653
+ a, b = m.group(1), m.group(2)
654
+ pat = re.compile(r"(?<![\w-])" + re.escape(a) + r"(\s+)" + re.escape(b) + r"(?![\w-])")
655
+ if len(pat.findall(out)) == 1 and not re.search(re.escape(a) + r",\s+" + re.escape(b), out):
656
+ out = pat.sub(lambda k: a + "," + k.group(1) + b, out, count=1)
657
+ out = re.sub(rf"^({_SENT_ADV})\s+(?=[a-z])", r"\1, ", out)
658
+ return out
659
 
660
 
661
  def guard(original, new, glossary=None):
 
669
  new = repair_citations(original, new)
670
  if new is None or invented(original, new) or quality_issue(original, new) or symbol_issue(original, new):
671
  return None
672
+ if _symbols(new) != _symbols(original): # "p < .001" must not become "p .001"
673
+ return None
674
+ if _brackets_ok(original) and not _brackets_ok(new): # "(TS amongst" - a bracket lost
675
+ return None
676
+ if _new_acronyms(original, new): # "EWB" must not become "EWD"
677
+ return None
678
+ new = restore_commas(original, new) # "airports trains and taxis" -> "airports, trains and taxis"
679
+ if integrity_issue(original, new):
680
+ return None
681
  ow, nw = len(original.split()), len(new.split())
682
  if nw < 0.5 * ow or nw > 1.8 * ow + 5:
683
  return None
684
  # numbers (results, years, units) must be kept
685
+ nums_o = set(re.findall(r"(?<![\d])\.?\d+(?:\.\d+)?%?", CITE.sub(" ", original)))
686
+ nums_n = set(re.findall(r"(?<![\d])\.?\d+(?:\.\d+)?%?", new))
687
  if not nums_o <= nums_n:
688
  return None
689
  if lost_terms(original, new, glossary): # technical wording must survive unchanged
 
1036
  t = re.sub(r"\s+([,.;:!?%)\]])", r"\1", t) # space before punctuation
1037
  t = re.sub(r"([(\[])\s+", r"\1", t)
1038
  t = re.sub(r",\s*,+", ",", t)
1039
+ t = re.sub(r"\b(and|or|but|the|of|to|in)\s*,\s*\1\b", r"\1", t, flags=re.I) # "and, and" -> "and"
1040
+ t = re.sub(rf"^({_SENT_ADV})\s+(?=[a-z])", r"\1, ", t) # "However the" -> "However, the"
1041
  t = re.sub(r"([.!?])\1+", r"\1", t)
1042
  t = _outside_cites(t, lambda x: re.sub(r"\b(\w{2,})\s+\1\b", r"\1", x, flags=re.I)) # "the the", "data data"
1043
  t = _outside_cites(t, lambda x: re.sub(r"\b([a-z]{3,})\s+(and|or)\s+\1\b", r"\1", x)) # "informed and informed"
 
1087
  new = match_ending(cur, basic_grammar(normalize_typography(cur, cand.strip())))
1088
  new = re.sub(r'([.!?])\s*["”“]{2,}', r"\1”", new) # .”” -> .”
1089
  new = re.sub(r'["“”]{2,}', "”", new)
1090
+ new = restore_commas(cur, new)
1091
  if new.strip() == cur.strip():
1092
  return None, "unchanged"
1093
  ow, nw = len(cur.split()), len(new.split())
1094
  if nw < 0.55 * ow or nw > 1.6 * ow + 4:
1095
  return None, "length"
1096
+ why = integrity_issue(cur, new)
1097
+ if why:
1098
+ return None, why
1099
+ if _symbols(new) != _symbols(cur):
1100
+ return None, "relation sign changed"
1101
+ if _new_acronyms(cur, new):
1102
+ return None, "acronym added"
1103
+ if _brackets_ok(cur) and not _brackets_ok(new):
1104
+ return None, "unbalanced bracket"
1105
  for base in filter(None, (cur, ref)):
1106
  why = invented(base, new)
1107
  if why:
 
1109
  why = quality_issue(cur, new) or symbol_issue(cur, new)
1110
  if why:
1111
  return None, why
1112
+ nums_c = set(re.findall(r"(?<![\d])\.?\d+(?:\.\d+)?%?", CITE.sub(" ", cur)))
1113
+ if not nums_c <= set(re.findall(r"(?<![\d])\.?\d+(?:\.\d+)?%?", new)):
1114
  return None, "number lost"
1115
  cc, nc = _cites(cur), _cites(new)
1116
  if len(nc) > len(cc) or any(not any(difflib.SequenceMatcher(None, c, n).ratio() >= 0.8 for n in nc) for c in cc):
 
1135
  out = set()
1136
  for i, x in enumerate(w):
1137
  if len(x) >= 5 and x in w[i + 1:i + 5]:
1138
+ j = w.index(x, i + 1)
1139
+ a, b = (w[i - 1] if i else ""), w[j - 1]
1140
+ if a != b and len(a) >= 3 and len(b) >= 3 and a not in _STOP and b not in _STOP:
1141
+ continue # contrast, not a repeat: "female travellers than male travellers"
1142
  out.add(x)
1143
  return out
1144