Spaces:
Running
Merge the Space's 8-9 October work (origin/main) into the local changes
Browse filesThe 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>
- README.md +77 -2
- adaptive.py +344 -0
- adversarial.py +164 -0
- app.py +9 -6
- clarity.py +3 -1
- groqai.py +494 -172
- humanizer.py +95 -14
- proofread.py +678 -575
- pubrules.py +12 -2
- smart.py +71 -6
- tests/test_groqai.py +2 -1
- textparse.py +315 -10
|
@@ -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`, `
|
| 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.
|
|
@@ -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}")
|
|
@@ -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 "."))
|
|
@@ -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("🔍
|
| 1568 |
-
gr.Markdown("<small>After humanizing:
|
| 1569 |
-
"
|
| 1570 |
-
"
|
| 1571 |
-
"
|
|
|
|
| 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("🔍
|
| 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)
|
|
@@ -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]
|
|
@@ -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 |
-
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
| 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
|
| 67 |
-
"""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 68 |
now = time.time()
|
| 69 |
with _lock:
|
| 70 |
n = len(KEYS)
|
|
|
|
| 71 |
for step in range(n):
|
| 72 |
k = (_rr[0] + step) % n
|
| 73 |
-
|
| 74 |
-
|
| 75 |
-
|
| 76 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
|
|
|
| 101 |
now = time.time()
|
| 102 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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(),
|
|
|
|
| 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
|
| 286 |
-
|
|
|
|
|
|
|
| 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).
|
| 299 |
-
|
| 300 |
-
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 309 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 310 |
plain = set() # models that do not take reasoning_effort
|
| 311 |
-
for attempt in range(8 + 2 * len(KEYS)
|
| 312 |
-
|
| 313 |
-
|
| 314 |
-
|
|
|
|
| 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 |
-
|
| 322 |
-
if
|
|
|
|
| 323 |
_pause_key(k, _models_free_in(k), "rate limit (all models)")
|
| 324 |
continue
|
| 325 |
-
|
| 326 |
-
|
| 327 |
-
|
| 328 |
-
body
|
|
|
|
|
|
|
| 329 |
try:
|
| 330 |
-
|
| 331 |
-
|
| 332 |
-
|
| 333 |
-
|
| 334 |
-
|
| 335 |
-
|
| 336 |
-
|
| 337 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
| 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 |
-
|
| 384 |
-
|
| 385 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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.
|
| 405 |
-
status: "
|
| 406 |
-
|
| 407 |
{PR.PROMPT}
|
| 408 |
-
Answer only with JSON: {{"items": [{{"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
|
| 429 |
-
"""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 430 |
out = [(None, None)] * n
|
| 431 |
-
|
| 432 |
-
if not
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 =
|
| 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 |
-
|
| 475 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 {
|
| 480 |
-
|
| 481 |
-
|
| 482 |
-
|
| 483 |
-
|
| 484 |
-
|
| 485 |
-
|
| 486 |
-
|
| 487 |
-
|
| 488 |
-
|
| 489 |
-
|
| 490 |
-
|
| 491 |
-
|
| 492 |
-
|
| 493 |
-
|
| 494 |
-
|
| 495 |
-
|
| 496 |
-
|
| 497 |
-
|
| 498 |
-
|
| 499 |
-
|
| 500 |
-
|
| 501 |
-
|
| 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.
|
| 518 |
-
|
| 519 |
{PR.PROMPT}
|
| 520 |
-
Answer only with JSON: {{"items": [{{"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 =
|
| 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 |
-
|
| 539 |
-
|
| 540 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 {
|
| 545 |
-
|
| 546 |
-
|
| 547 |
-
|
| 548 |
-
|
| 549 |
-
|
| 550 |
-
|
| 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 |
-
|
| 572 |
-
|
| 573 |
-
|
| 574 |
-
|
| 575 |
-
|
| 576 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 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":
|
| 610 |
-
|
| 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 =
|
| 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 |
-
|
|
|
|
|
|
|
| 650 |
i = 0
|
| 651 |
-
while i < n:
|
| 652 |
j = i
|
| 653 |
while j < n and groups[j] == groups[i]:
|
| 654 |
j += 1
|
| 655 |
para = list(range(i, j))
|
| 656 |
-
|
|
|
|
| 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,
|
|
|
|
| 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 =
|
| 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 |
-
|
| 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
|
| 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 |
-
|
| 850 |
-
|
| 851 |
-
|
| 852 |
-
|
| 853 |
-
|
| 854 |
-
|
| 855 |
-
|
| 856 |
-
|
| 857 |
-
|
| 858 |
-
|
| 859 |
-
|
| 860 |
-
|
| 861 |
-
|
| 862 |
-
|
| 863 |
-
|
| 864 |
-
|
| 865 |
-
|
| 866 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
|
@@ -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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 93 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
| 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 |
-
|
|
|
|
| 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}
|
| 743 |
-
" 🎯 The AI detector chose between
|
| 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}
|
| 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.")
|
|
@@ -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 |
-
|
| 18 |
-
|
| 19 |
-
|
| 20 |
-
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
|
| 24 |
-
import
|
| 25 |
-
import
|
| 26 |
-
import
|
| 27 |
-
import
|
| 28 |
-
|
| 29 |
-
import
|
| 30 |
-
import
|
| 31 |
-
import
|
| 32 |
-
|
| 33 |
-
|
| 34 |
-
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
#
|
| 44 |
-
#
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
#
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
|
| 61 |
-
|
| 62 |
-
|
| 63 |
-
|
| 64 |
-
|
| 65 |
-
|
| 66 |
-
|
| 67 |
-
|
| 68 |
-
|
| 69 |
-
|
| 70 |
-
|
| 71 |
-
|
| 72 |
-
|
| 73 |
-
|
| 74 |
-
|
| 75 |
-
|
| 76 |
-
|
| 77 |
-
|
| 78 |
-
|
| 79 |
-
|
| 80 |
-
|
| 81 |
-
|
| 82 |
-
|
| 83 |
-
|
| 84 |
-
|
| 85 |
-
|
| 86 |
-
|
| 87 |
-
|
| 88 |
-
|
| 89 |
-
|
| 90 |
-
|
| 91 |
-
|
| 92 |
-
|
| 93 |
-
|
| 94 |
-
|
| 95 |
-
|
| 96 |
-
|
| 97 |
-
|
| 98 |
-
|
| 99 |
-
|
| 100 |
-
|
| 101 |
-
|
| 102 |
-
return
|
| 103 |
-
|
| 104 |
-
|
| 105 |
-
|
| 106 |
-
|
| 107 |
-
|
| 108 |
-
|
| 109 |
-
|
| 110 |
-
|
| 111 |
-
|
| 112 |
-
|
| 113 |
-
|
| 114 |
-
|
| 115 |
-
|
| 116 |
-
|
| 117 |
-
|
| 118 |
-
|
| 119 |
-
|
| 120 |
-
|
| 121 |
-
|
| 122 |
-
|
| 123 |
-
|
| 124 |
-
|
| 125 |
-
|
| 126 |
-
|
| 127 |
-
|
| 128 |
-
|
| 129 |
-
|
| 130 |
-
|
| 131 |
-
|
| 132 |
-
|
| 133 |
-
|
| 134 |
-
|
| 135 |
-
|
| 136 |
-
|
| 137 |
-
|
| 138 |
-
|
| 139 |
-
|
| 140 |
-
|
| 141 |
-
|
| 142 |
-
|
| 143 |
-
|
| 144 |
-
|
| 145 |
-
|
| 146 |
-
|
| 147 |
-
|
| 148 |
-
|
| 149 |
-
|
| 150 |
-
|
| 151 |
-
|
| 152 |
-
|
| 153 |
-
|
| 154 |
-
|
| 155 |
-
|
| 156 |
-
|
| 157 |
-
|
| 158 |
-
|
| 159 |
-
|
| 160 |
-
|
| 161 |
-
|
| 162 |
-
|
| 163 |
-
|
| 164 |
-
|
| 165 |
-
|
| 166 |
-
|
| 167 |
-
|
| 168 |
-
|
| 169 |
-
|
| 170 |
-
|
| 171 |
-
|
| 172 |
-
|
| 173 |
-
|
| 174 |
-
|
| 175 |
-
|
| 176 |
-
|
| 177 |
-
|
| 178 |
-
|
| 179 |
-
|
| 180 |
-
|
| 181 |
-
|
| 182 |
-
|
| 183 |
-
|
| 184 |
-
|
| 185 |
-
|
| 186 |
-
|
| 187 |
-
|
| 188 |
-
|
| 189 |
-
|
| 190 |
-
|
| 191 |
-
|
| 192 |
-
|
| 193 |
-
|
| 194 |
-
|
| 195 |
-
|
| 196 |
-
|
| 197 |
-
|
| 198 |
-
|
| 199 |
-
|
| 200 |
-
|
| 201 |
-
|
| 202 |
-
|
| 203 |
-
|
| 204 |
-
|
| 205 |
-
|
| 206 |
-
|
| 207 |
-
|
| 208 |
-
|
| 209 |
-
if
|
| 210 |
-
|
| 211 |
-
|
| 212 |
-
|
| 213 |
-
|
| 214 |
-
|
| 215 |
-
|
| 216 |
-
|
| 217 |
-
|
| 218 |
-
|
| 219 |
-
|
| 220 |
-
|
| 221 |
-
|
| 222 |
-
|
| 223 |
-
|
| 224 |
-
|
| 225 |
-
if
|
| 226 |
-
|
| 227 |
-
|
| 228 |
-
|
| 229 |
-
|
| 230 |
-
|
| 231 |
-
|
| 232 |
-
|
| 233 |
-
|
| 234 |
-
|
| 235 |
-
|
| 236 |
-
|
| 237 |
-
|
| 238 |
-
|
| 239 |
-
|
| 240 |
-
|
| 241 |
-
|
| 242 |
-
|
| 243 |
-
|
| 244 |
-
|
| 245 |
-
|
| 246 |
-
|
| 247 |
-
|
| 248 |
-
|
| 249 |
-
|
| 250 |
-
|
| 251 |
-
|
| 252 |
-
|
| 253 |
-
|
| 254 |
-
|
| 255 |
-
|
| 256 |
-
|
| 257 |
-
|
| 258 |
-
|
| 259 |
-
|
| 260 |
-
|
| 261 |
-
|
| 262 |
-
|
| 263 |
-
|
| 264 |
-
|
| 265 |
-
|
| 266 |
-
|
| 267 |
-
|
| 268 |
-
|
| 269 |
-
|
| 270 |
-
|
| 271 |
-
|
| 272 |
-
|
| 273 |
-
|
| 274 |
-
|
| 275 |
-
|
| 276 |
-
|
| 277 |
-
|
| 278 |
-
|
| 279 |
-
|
| 280 |
-
|
| 281 |
-
|
| 282 |
-
|
| 283 |
-
|
| 284 |
-
|
| 285 |
-
|
| 286 |
-
|
| 287 |
-
|
| 288 |
-
|
| 289 |
-
|
| 290 |
-
|
| 291 |
-
|
| 292 |
-
|
| 293 |
-
|
| 294 |
-
|
| 295 |
-
|
| 296 |
-
for
|
| 297 |
-
|
| 298 |
-
|
| 299 |
-
|
| 300 |
-
|
| 301 |
-
#
|
| 302 |
-
|
| 303 |
-
|
| 304 |
-
|
| 305 |
-
|
| 306 |
-
|
| 307 |
-
|
| 308 |
-
|
| 309 |
-
|
| 310 |
-
|
| 311 |
-
|
| 312 |
-
|
| 313 |
-
|
| 314 |
-
|
| 315 |
-
|
| 316 |
-
|
| 317 |
-
|
| 318 |
-
|
| 319 |
-
|
| 320 |
-
|
| 321 |
-
|
| 322 |
-
|
| 323 |
-
|
| 324 |
-
for
|
| 325 |
-
|
| 326 |
-
|
| 327 |
-
|
| 328 |
-
|
| 329 |
-
|
| 330 |
-
|
| 331 |
-
|
| 332 |
-
|
| 333 |
-
|
| 334 |
-
|
| 335 |
-
|
| 336 |
-
|
| 337 |
-
|
| 338 |
-
|
| 339 |
-
|
| 340 |
-
|
| 341 |
-
|
| 342 |
-
|
| 343 |
-
|
| 344 |
-
|
| 345 |
-
|
| 346 |
-
|
| 347 |
-
|
| 348 |
-
|
| 349 |
-
|
| 350 |
-
|
| 351 |
-
|
| 352 |
-
|
| 353 |
-
|
| 354 |
-
|
| 355 |
-
|
| 356 |
-
|
| 357 |
-
|
| 358 |
-
|
| 359 |
-
|
| 360 |
-
|
| 361 |
-
|
| 362 |
-
|
| 363 |
-
|
| 364 |
-
|
| 365 |
-
|
| 366 |
-
|
| 367 |
-
|
| 368 |
-
|
| 369 |
-
|
| 370 |
-
|
| 371 |
-
|
| 372 |
-
|
| 373 |
-
|
| 374 |
-
|
| 375 |
-
|
| 376 |
-
|
| 377 |
-
|
| 378 |
-
|
| 379 |
-
|
| 380 |
-
|
| 381 |
-
|
| 382 |
-
|
| 383 |
-
|
| 384 |
-
|
| 385 |
-
|
| 386 |
-
|
| 387 |
-
|
| 388 |
-
|
| 389 |
-
|
| 390 |
-
|
| 391 |
-
|
| 392 |
-
|
| 393 |
-
|
| 394 |
-
|
| 395 |
-
|
| 396 |
-
|
| 397 |
-
|
| 398 |
-
|
| 399 |
-
|
| 400 |
-
|
| 401 |
-
|
| 402 |
-
|
| 403 |
-
|
| 404 |
-
|
| 405 |
-
|
| 406 |
-
|
| 407 |
-
|
| 408 |
-
|
| 409 |
-
|
| 410 |
-
|
| 411 |
-
|
| 412 |
-
|
| 413 |
-
|
| 414 |
-
|
| 415 |
-
|
| 416 |
-
|
| 417 |
-
|
| 418 |
-
|
| 419 |
-
|
| 420 |
-
|
| 421 |
-
|
| 422 |
-
|
| 423 |
-
|
| 424 |
-
|
| 425 |
-
|
| 426 |
-
|
| 427 |
-
|
| 428 |
-
|
| 429 |
-
|
| 430 |
-
|
| 431 |
-
|
| 432 |
-
|
| 433 |
-
|
| 434 |
-
|
| 435 |
-
|
| 436 |
-
|
| 437 |
-
|
| 438 |
-
|
| 439 |
-
|
| 440 |
-
|
| 441 |
-
|
| 442 |
-
|
| 443 |
-
|
| 444 |
-
|
| 445 |
-
|
| 446 |
-
|
| 447 |
-
|
| 448 |
-
|
| 449 |
-
|
| 450 |
-
|
| 451 |
-
|
| 452 |
-
|
| 453 |
-
|
| 454 |
-
|
| 455 |
-
|
| 456 |
-
|
| 457 |
-
|
| 458 |
-
|
| 459 |
-
|
| 460 |
-
|
| 461 |
-
|
| 462 |
-
|
| 463 |
-
|
| 464 |
-
|
| 465 |
-
|
| 466 |
-
|
| 467 |
-
|
| 468 |
-
|
| 469 |
-
|
| 470 |
-
|
| 471 |
-
|
| 472 |
-
|
| 473 |
-
|
| 474 |
-
|
| 475 |
-
|
| 476 |
-
|
| 477 |
-
|
| 478 |
-
|
| 479 |
-
|
| 480 |
-
|
| 481 |
-
|
| 482 |
-
|
| 483 |
-
|
| 484 |
-
|
| 485 |
-
|
| 486 |
-
|
| 487 |
-
|
| 488 |
-
|
| 489 |
-
|
| 490 |
-
|
| 491 |
-
|
| 492 |
-
|
| 493 |
-
|
| 494 |
-
|
| 495 |
-
|
| 496 |
-
|
| 497 |
-
|
| 498 |
-
|
| 499 |
-
|
| 500 |
-
|
| 501 |
-
|
| 502 |
-
|
| 503 |
-
|
| 504 |
-
|
| 505 |
-
|
| 506 |
-
|
| 507 |
-
|
| 508 |
-
|
| 509 |
-
|
| 510 |
-
|
| 511 |
-
|
| 512 |
-
|
| 513 |
-
|
| 514 |
-
|
| 515 |
-
|
| 516 |
-
|
| 517 |
-
|
| 518 |
-
|
| 519 |
-
|
| 520 |
-
|
| 521 |
-
|
| 522 |
-
|
| 523 |
-
|
| 524 |
-
|
| 525 |
-
if
|
| 526 |
-
|
| 527 |
-
|
| 528 |
-
|
| 529 |
-
|
| 530 |
-
|
| 531 |
-
|
| 532 |
-
|
| 533 |
-
|
| 534 |
-
|
| 535 |
-
|
| 536 |
-
|
| 537 |
-
|
| 538 |
-
|
| 539 |
-
|
| 540 |
-
|
| 541 |
-
|
| 542 |
-
|
| 543 |
-
|
| 544 |
-
|
| 545 |
-
|
| 546 |
-
|
| 547 |
-
|
| 548 |
-
|
| 549 |
-
|
| 550 |
-
|
| 551 |
-
|
| 552 |
-
|
| 553 |
-
|
| 554 |
-
|
| 555 |
-
|
| 556 |
-
|
| 557 |
-
|
| 558 |
-
|
| 559 |
-
|
| 560 |
-
|
| 561 |
-
|
| 562 |
-
|
| 563 |
-
|
| 564 |
-
|
| 565 |
-
|
| 566 |
-
|
| 567 |
-
|
| 568 |
-
|
| 569 |
-
|
| 570 |
-
|
| 571 |
-
|
| 572 |
-
|
| 573 |
-
|
| 574 |
-
|
| 575 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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)
|
|
@@ -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 (
|
|
|
|
|
|
|
| 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
|
|
@@ -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 |
-
|
|
|
|
| 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 =
|
|
|
|
| 649 |
else:
|
| 650 |
-
new, note =
|
|
|
|
| 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 |
-
|
|
|
|
| 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 =
|
|
|
|
| 774 |
else:
|
| 775 |
-
new, note =
|
|
|
|
| 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)
|
|
@@ -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", [{
|
|
|
|
| 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", {})
|
|
@@ -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 |
-
|
|
|
|
| 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,
|
| 306 |
-
addressing the reader, contractions, or a question where the
|
|
|
|
| 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 |
|