Spaces:
Running
Running
Upload 4 files
Browse files- README.md +50 -10
- groqai.py +44 -31
- humanizer.py +93 -32
- smart.py +26 -9
README.md
CHANGED
|
@@ -30,24 +30,32 @@ Shortcut buttons link the tools: *Humanize this text →* (Detector → Smart Hu
|
|
| 30 |
|
| 31 |
## Plans (plans.py)
|
| 32 |
|
| 33 |
-
| Plan | Price | Documents | Paste | Smart passes |
|
| 34 |
|---|---|---|---|---|
|
| 35 |
| Guest | free | — | 300 words, 15 runs/day | 2 |
|
| 36 |
| Free account | free | 2 (lifetime trial) | 300 words, 25 runs/day | 2 |
|
| 37 |
-
| Student |
|
| 38 |
-
|
|
| 39 |
-
|
|
| 40 |
-
| Single document | $2.99 / ₹149 | 1 credit (never expires) | — | — |
|
| 41 |
| **Admin** (`ADMIN_EMAILS`) | **free, unlimited, never charged** | ∞ | 20,000 words | 4 |
|
| 42 |
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
|
| 46 |
|
| 47 |
-
**
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 48 |
|
| 49 |
**Razorpay (next step):** in the payment webhook, after verifying the signature, call
|
| 50 |
-
`plans.activate_plan(email, "student",
|
| 51 |
`plans.add_credits(email, 1, source="razorpay", amount=...)` — the same functions the admin buttons use.
|
| 52 |
|
| 53 |
Optional variables: `PAY_WHATSAPP` (digits with country code), `PAY_EMAIL`, `PROMO_TEXT`, `FREE_TRIAL_DOCS` (default 2).
|
|
@@ -216,3 +224,35 @@ kept only if it changes at most 25% of a sentence's words (`HUM_GRAMMAR_MAX_EDIT
|
|
| 216 |
number and citation. Smart Humanize runs it once on the final version and drops it if it raises the AI score by more
|
| 217 |
than 3 points. `HUM_GRAMMAR=0` turns it off. Without a Groq key the step is skipped.
|
| 218 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 30 |
|
| 31 |
## Plans (plans.py)
|
| 32 |
|
| 33 |
+
| Plan | Price | Documents (reports) | Paste | Smart passes |
|
| 34 |
|---|---|---|---|---|
|
| 35 |
| Guest | free | — | 300 words, 15 runs/day | 2 |
|
| 36 |
| Free account | free | 2 (lifetime trial) | 300 words, 25 runs/day | 2 |
|
| 37 |
+
| Student | ₹4,999 / $59 per month | 5 per day | 2,000 words, 100/day | 4 |
|
| 38 |
+
| Agency | ₹7,999 / $95 per month | 10 per day | 3,000 words, 300/day | 4 |
|
| 39 |
+
| Pro | ₹10,000 / $119 per month | unlimited | 5,000 words, unlimited | 4 |
|
|
|
|
| 40 |
| **Admin** (`ADMIN_EMAILS`) | **free, unlimited, never charged** | ∞ | 20,000 words | 4 |
|
| 41 |
|
| 42 |
+
Prices can be changed with the Space variables `PRICE_STUDENT`, `PRICE_AGENCY`, `PRICE_PRO` (₹) and
|
| 43 |
+
`PRICE_STUDENT_USD`, `PRICE_AGENCY_USD`, `PRICE_PRO_USD` ($); `PRICE_CURRENCY` (INR / USD) sets which one the Pricing page shows first. Daily limits reset at
|
| 44 |
+
midnight IST (`DAY_TZ_OFFSET_MIN`, default 330).
|
| 45 |
|
| 46 |
+
**Monthly subscriptions.** The users table keeps `plan_started` (subscribed since), `period_start` (current month
|
| 47 |
+
from), `plan_until` (paid until: the same date next calendar month) and `plan_status` (active / expired / free).
|
| 48 |
+
A plan that is not renewed by `plan_until` is switched off automatically (checked every hour and whenever the user
|
| 49 |
+
signs in or runs a document): the user goes back to the free plan, gets an email, and the event is written to
|
| 50 |
+
`plan_history`. Renewing before the end date adds a month to the end date; renewing after it starts a new
|
| 51 |
+
subscription from that day. Admin → Users shows every subscription (start date, paid until, days left, status) and
|
| 52 |
+
the full plan history.
|
| 53 |
+
|
| 54 |
+
**Activating / renewing a plan today:** Admin → Users → 💎 Plans & credits → choose the plan and number of months →
|
| 55 |
+
Activate plan. Document credits can still be added there for special cases.
|
| 56 |
|
| 57 |
**Razorpay (next step):** in the payment webhook, after verifying the signature, call
|
| 58 |
+
`plans.activate_plan(email, "student", 1, source="razorpay", amount=...)` (1 = months) or
|
| 59 |
`plans.add_credits(email, 1, source="razorpay", amount=...)` — the same functions the admin buttons use.
|
| 60 |
|
| 61 |
Optional variables: `PAY_WHATSAPP` (digits with country code), `PAY_EMAIL`, `PROMO_TEXT`, `FREE_TRIAL_DOCS` (default 2).
|
|
|
|
| 224 |
number and citation. Smart Humanize runs it once on the final version and drops it if it raises the AI score by more
|
| 225 |
than 3 points. `HUM_GRAMMAR=0` turns it off. Without a Groq key the step is skipped.
|
| 226 |
|
| 227 |
+
## Report-guided rewriting (Smart Humanize) — image processing
|
| 228 |
+
Upload an AI or similarity report PDF from any checker (Turnitin, iThenticate, GPTZero, ZeroGPT, Copyleaks, Academi,
|
| 229 |
+
scanned reports too). `report_flags.py` renders every page to an image and, for every word, looks at the pixels in
|
| 230 |
+
the word's box: a tinted background (cyan, purple, yellow ... even very pale tints) = highlighted; coloured letters =
|
| 231 |
+
coloured text. Word positions come from the PDF text layer, or from Tesseract OCR for scanned pages (parallel).
|
| 232 |
+
Flagged words are grouped into the report's sentences (cyan = High, purple = Moderate), matched to the document
|
| 233 |
+
(word-trigram overlap >= 50%) and only those sentences are rewritten in one pass. The result shows the report's own
|
| 234 |
+
score -> an estimated score, and a table of every flagged sentence with its rephrased version.
|
| 235 |
+
Flagged sentences get the strongest rewrite: `restructure.py` reshapes them first (clause order, splits,
|
| 236 |
+
stock openers; rule-based, no AI model), then Parrot with the 100% AI settings and a 60% new-wording target.
|
| 237 |
+
Rows where less than half the wording changed are marked "Likely still flagged" for the writer to revise.
|
| 238 |
+
Needs `tesseract-ocr` (packages.txt) and `pytesseract` (requirements.txt) for scanned reports.
|
| 239 |
+
|
| 240 |
+
|
| 241 |
+
## Final polish (Groq)
|
| 242 |
+
|
| 243 |
+
After all rewriting, the finished text goes through a final polish, paragraph by paragraph. Groq sees each
|
| 244 |
+
paragraph together with the author's ORIGINAL paragraph. **Unclear or garbled sentences are repaired** to the
|
| 245 |
+
original meaning (clarity wins over the AI score for these); a repair that adds facts or drifts in meaning is
|
| 246 |
+
rejected and the clear original sentence is put back. Clear sentences get the light polish: grammar, technical
|
| 247 |
+
academic wording (wrong-sense or informal words, redundancy) and flow between sentences (logical links, varied
|
| 248 |
+
openings, smoother word order). Sentences are never merged, split, dropped or reordered, so the Word layout stays
|
| 249 |
+
intact. A polished sentence is kept only if it keeps every term, number and citation, keeps its meaning (meaning
|
| 250 |
+
check), changes at most `POLISH_MAX_EDIT` of its words and, with `POLISH_KEEP_HUMAN=1`, does not turn a sentence
|
| 251 |
+
the detector reads as human into one it reads as AI-written; otherwise the grammar-only correction is used.
|
| 252 |
+
LanguageTool then fixes any remaining mechanical errors.
|
| 253 |
+
|
| 254 |
+
| Variable | Default | Effect |
|
| 255 |
+
|---|---|---|
|
| 256 |
+
| `POLISH` | `1` | `0` = grammar-only final check |
|
| 257 |
+
| `POLISH_MAX_EDIT` | `0.6` | Largest share of a sentence's words the polish may change |
|
| 258 |
+
| `POLISH_KEEP_HUMAN` | `1` | `0` = always keep the polished wording, even if the AI score rises |
|
groqai.py
CHANGED
|
@@ -341,56 +341,68 @@ def grammar(sentences, glossary, progress=None, lo=0.9, hi=0.95):
|
|
| 341 |
return out
|
| 342 |
|
| 343 |
|
| 344 |
-
# ---------------------------------------------------------------- final polish:
|
| 345 |
POLISH_SYSTEM = f"""You are the final copy-editor of a technical academic manuscript (journal / thesis quality).
|
| 346 |
-
The text
|
| 347 |
-
|
| 348 |
-
|
| 349 |
-
|
| 350 |
-
|
| 351 |
-
|
| 352 |
-
|
| 353 |
-
|
| 354 |
-
|
| 355 |
-
|
| 356 |
-
|
| 357 |
-
|
| 358 |
-
|
| 359 |
-
|
| 360 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 361 |
Do not add these words: {_AI_WORDS}. No em dashes.
|
| 362 |
-
Answer only with JSON: {{"items": [{{"id": 1, "
|
|
|
|
| 363 |
|
| 364 |
|
| 365 |
def _polish_batch(items, glossary):
|
| 366 |
-
"""items: list of (paragraph_key, sentence) in document order
|
| 367 |
-
|
|
|
|
| 368 |
terms = _surface_terms(" ".join(sents), glossary)
|
| 369 |
lines, last, para = [], object(), 0
|
| 370 |
-
for i, (g, t) in enumerate(items, 1):
|
| 371 |
if g != last:
|
| 372 |
para += 1
|
| 373 |
lines.append(f"\n[Paragraph {para}]")
|
|
|
|
|
|
|
|
|
|
| 374 |
last = g
|
| 375 |
lines.append(f"{i}. {t}")
|
| 376 |
user = "PROTECTED TERMS: " + ("; ".join(terms) if terms else "(none)") + "\n" + "\n".join(lines)
|
| 377 |
-
max_tokens = min(8000,
|
| 378 |
content = _chat([{"role": "system", "content": POLISH_SYSTEM}, {"role": "user", "content": user}], 0.2,
|
| 379 |
max_tokens)
|
| 380 |
-
return
|
| 381 |
|
| 382 |
|
| 383 |
-
def polish(sentences, groups=None, glossary=(), progress=None, lo=0.9, hi=0.95, size=None):
|
| 384 |
-
"""
|
| 385 |
-
|
| 386 |
-
|
|
|
|
| 387 |
Raises Unavailable only if nothing could be edited."""
|
| 388 |
from concurrent.futures import ThreadPoolExecutor, as_completed
|
| 389 |
n = len(sentences)
|
| 390 |
-
out = [None] * n
|
| 391 |
if not n:
|
| 392 |
return out
|
| 393 |
groups = list(groups) if groups is not None else list(range(n))
|
|
|
|
| 394 |
size = size or max(BATCH, 16)
|
| 395 |
batches, cur = [], []
|
| 396 |
i = 0
|
|
@@ -411,9 +423,10 @@ def polish(sentences, groups=None, glossary=(), progress=None, lo=0.9, hi=0.95,
|
|
| 411 |
batches.append(cur)
|
| 412 |
done, ok_batches, first_err = 0, 0, None
|
| 413 |
if progress is not None:
|
| 414 |
-
progress(lo, desc=f"Final polish of {n} sentences: grammar, academic wording and flow (Groq)")
|
| 415 |
with ThreadPoolExecutor(max_workers=min(PARALLEL, len(batches))) as pool:
|
| 416 |
-
futs = {pool.submit(_polish_batch, [(groups[k], sentences[k]) for k in b], glossary): b
|
|
|
|
| 417 |
for f in as_completed(futs):
|
| 418 |
b = futs[f]
|
| 419 |
done += 1
|
|
@@ -423,8 +436,8 @@ def polish(sentences, groups=None, glossary=(), progress=None, lo=0.9, hi=0.95,
|
|
| 423 |
first_err = first_err or err
|
| 424 |
continue
|
| 425 |
ok_batches += 1
|
| 426 |
-
for k,
|
| 427 |
-
out[k] =
|
| 428 |
if progress is not None:
|
| 429 |
progress(lo + (hi - lo) * done / len(batches), desc=f"Final polish: {done}/{len(batches)} batches")
|
| 430 |
if not ok_batches and first_err is not None:
|
|
|
|
| 341 |
return out
|
| 342 |
|
| 343 |
|
| 344 |
+
# ---------------------------------------------------------------- final polish: repair + grammar + register + flow
|
| 345 |
POLISH_SYSTEM = f"""You are the final copy-editor of a technical academic manuscript (journal / thesis quality).
|
| 346 |
+
The text was produced by an automatic paraphrasing tool, which sometimes substitutes wrong words, drops words or
|
| 347 |
+
joins phrases so that a sentence no longer makes sense. It is given paragraph by paragraph; each numbered item is
|
| 348 |
+
one sentence of the CURRENT text. When available, the author's ORIGINAL paragraph is shown first: use it ONLY to
|
| 349 |
+
understand what each sentence is meant to say.
|
| 350 |
+
For every numbered sentence:
|
| 351 |
+
1. Clarity first. If the sentence is unclear, garbled, ungrammatical in a way that hides its meaning, or says
|
| 352 |
+
something different from the original, REPAIR it: rewrite it so that it says exactly what the original means,
|
| 353 |
+
in clear academic English, keeping the current wording wherever that wording is correct. status "repaired".
|
| 354 |
+
2. Otherwise edit it lightly (status "edited") or keep it (status "ok"):
|
| 355 |
+
- grammar: agreement, articles, verb forms, tense, prepositions, missing or doubled words, run-on sentences;
|
| 356 |
+
- academic register: precise technical words instead of informal, odd or wrong-sense words (e.g. "spotted"
|
| 357 |
+
-> "repeated", "comprehension" -> "understanding" where that is meant); remove redundancy; no first person
|
| 358 |
+
singular, no "you", no contractions, no opinions;
|
| 359 |
+
- flow: each sentence should follow naturally from the previous one in the paragraph; add a logical link only
|
| 360 |
+
where it is needed (however, therefore, in contrast, for example) and vary repetitive openings.
|
| 361 |
+
Rules: keep the meaning, claims and hedging of the original exactly. Do NOT add facts, names, numbers, results or
|
| 362 |
+
citations that are not in the sentence or the original. Do NOT merge, split, drop or reorder sentences: return
|
| 363 |
+
exactly one sentence per id. Keep every technical term, acronym, formula, symbol, number, unit and citation (e.g.
|
| 364 |
+
[12], (Kumar et al., 2021)) exactly as written, including every item in PROTECTED TERMS. Do not copy the
|
| 365 |
+
original back word for word unless the current sentence cannot be repaired any other way.
|
| 366 |
Do not add these words: {_AI_WORDS}. No em dashes.
|
| 367 |
+
Answer only with JSON: {{"items": [{{"id": 1, "status": "ok|edited|repaired", "text": "sentence"}}, ...]}} —
|
| 368 |
+
one per id."""
|
| 369 |
|
| 370 |
|
| 371 |
def _polish_batch(items, glossary):
|
| 372 |
+
"""items: list of (paragraph_key, sentence, original_paragraph_or_None) in document order
|
| 373 |
+
-> list of (status, edited sentence) (None, None = missing)."""
|
| 374 |
+
sents = [t for _, t, _ in items]
|
| 375 |
terms = _surface_terms(" ".join(sents), glossary)
|
| 376 |
lines, last, para = [], object(), 0
|
| 377 |
+
for i, (g, t, orig) in enumerate(items, 1):
|
| 378 |
if g != last:
|
| 379 |
para += 1
|
| 380 |
lines.append(f"\n[Paragraph {para}]")
|
| 381 |
+
if orig:
|
| 382 |
+
lines.append(f"ORIGINAL: {orig}")
|
| 383 |
+
lines.append("CURRENT:")
|
| 384 |
last = g
|
| 385 |
lines.append(f"{i}. {t}")
|
| 386 |
user = "PROTECTED TERMS: " + ("; ".join(terms) if terms else "(none)") + "\n" + "\n".join(lines)
|
| 387 |
+
max_tokens = min(8000, 600 + int(sum(len(t) for t in sents) * 0.8))
|
| 388 |
content = _chat([{"role": "system", "content": POLISH_SYSTEM}, {"role": "user", "content": user}], 0.2,
|
| 389 |
max_tokens)
|
| 390 |
+
return _parse(content, len(sents))
|
| 391 |
|
| 392 |
|
| 393 |
+
def polish(sentences, groups=None, glossary=(), progress=None, lo=0.9, hi=0.95, size=None, originals=None):
|
| 394 |
+
"""Repair + grammar + academic wording + flow edit of `sentences`. `groups` gives a paragraph key per sentence
|
| 395 |
+
(a paragraph is edited as a whole); `originals` gives, per sentence, the author's original paragraph (or
|
| 396 |
+
sentence) it came from, so garbled sentences can be repaired to the intended meaning.
|
| 397 |
+
Returns one (status, text) per sentence; (None, None) if its batch could not be edited.
|
| 398 |
Raises Unavailable only if nothing could be edited."""
|
| 399 |
from concurrent.futures import ThreadPoolExecutor, as_completed
|
| 400 |
n = len(sentences)
|
| 401 |
+
out = [(None, None)] * n
|
| 402 |
if not n:
|
| 403 |
return out
|
| 404 |
groups = list(groups) if groups is not None else list(range(n))
|
| 405 |
+
originals = list(originals) if originals is not None else [None] * n
|
| 406 |
size = size or max(BATCH, 16)
|
| 407 |
batches, cur = [], []
|
| 408 |
i = 0
|
|
|
|
| 423 |
batches.append(cur)
|
| 424 |
done, ok_batches, first_err = 0, 0, None
|
| 425 |
if progress is not None:
|
| 426 |
+
progress(lo, desc=f"Final polish of {n} sentences: clarity, grammar, academic wording and flow (Groq)")
|
| 427 |
with ThreadPoolExecutor(max_workers=min(PARALLEL, len(batches))) as pool:
|
| 428 |
+
futs = {pool.submit(_polish_batch, [(groups[k], sentences[k], originals[k]) for k in b], glossary): b
|
| 429 |
+
for b in batches}
|
| 430 |
for f in as_completed(futs):
|
| 431 |
b = futs[f]
|
| 432 |
done += 1
|
|
|
|
| 436 |
first_err = first_err or err
|
| 437 |
continue
|
| 438 |
ok_batches += 1
|
| 439 |
+
for k, r in zip(b, res):
|
| 440 |
+
out[k] = r
|
| 441 |
if progress is not None:
|
| 442 |
progress(lo + (hi - lo) * done / len(batches), desc=f"Final polish: {done}/{len(batches)} batches")
|
| 443 |
if not ok_batches and first_err is not None:
|
humanizer.py
CHANGED
|
@@ -698,37 +698,78 @@ def _edit_share(a, b):
|
|
| 698 |
return sum(max(i2 - i1, j2 - j1) for op, i1, i2, j1, j2 in sm.get_opcodes() if op != "equal") / len(wa)
|
| 699 |
|
| 700 |
|
| 701 |
-
def _polish(sentences, out, idx, groups, glossary, progress, lo, hi):
|
| 702 |
-
"""Groq polish of out[idx]
|
| 703 |
-
|
| 704 |
-
|
| 705 |
-
|
| 706 |
-
|
| 707 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 708 |
continue
|
| 709 |
ok = TP.guard(sentences[i], TP.restore_terms(sentences[i], r.strip(), glossary), glossary)
|
| 710 |
if ok and _edit_share(out[i], ok) <= max(POLISH_MAX_EDIT, 3 / max(1, len(out[i].split()))):
|
| 711 |
-
|
| 712 |
-
|
| 713 |
-
|
| 714 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 715 |
if not k:
|
| 716 |
-
|
| 717 |
-
|
| 718 |
-
|
| 719 |
-
|
|
|
|
| 720 |
if sc:
|
| 721 |
m = len(keys)
|
| 722 |
for n_, i in enumerate(keys):
|
| 723 |
if sc[n_] >= 0.5 > sc[m + n_] or sc[n_] > sc[m + n_] + 0.35:
|
| 724 |
-
|
| 725 |
-
|
|
|
|
| 726 |
out[i] = t
|
| 727 |
-
|
| 728 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 729 |
|
| 730 |
|
| 731 |
-
def grammar_fix_sentences(sentences, glossary=None, progress=None, lo=0.9, hi=0.95, groups=None
|
|
|
|
| 732 |
"""Final correction of `sentences`. Returns (sentences, n_fixed, note).
|
| 733 |
1. rule-based clean-up (doubled words, a/an, 'will opening', spacing, capitals, final full stop) - always runs;
|
| 734 |
2. Groq final polish (POLISH=1, default): whole paragraphs (`groups` = paragraph key per sentence) are edited
|
|
@@ -741,14 +782,17 @@ def grammar_fix_sentences(sentences, glossary=None, progress=None, lo=0.9, hi=0.
|
|
| 741 |
return list(sentences), 0, ""
|
| 742 |
out = [TP.basic_grammar(t) if len(t.split()) >= 3 else t for t in sentences]
|
| 743 |
groups = list(groups) if groups is not None else list(range(len(sentences)))
|
| 744 |
-
|
|
|
|
|
|
|
| 745 |
if G.available():
|
| 746 |
glossary = glossary if glossary is not None else (TP.build_glossary(out) | TP.GLOSSARY.get())
|
| 747 |
idx = [i for i, t in enumerate(out) if len(t.split()) >= 3]
|
| 748 |
todo = idx
|
| 749 |
if POLISH and idx:
|
| 750 |
try:
|
| 751 |
-
|
|
|
|
| 752 |
except G.Unavailable as err: # fall back to the grammar-only check
|
| 753 |
print(f"[humanizer] Groq final polish skipped, grammar-only check instead: {err}", flush=True)
|
| 754 |
try:
|
|
@@ -778,22 +822,39 @@ def grammar_fix_sentences(sentences, glossary=None, progress=None, lo=0.9, hi=0.
|
|
| 778 |
if n_lt:
|
| 779 |
note += f" 🔎 LanguageTool fixed {n_lt} sentence(s)."
|
| 780 |
n = sum(1 for a, b in zip(sentences, out) if a.strip() != b.strip())
|
| 781 |
-
|
| 782 |
-
|
| 783 |
-
|
| 784 |
-
|
|
|
|
| 785 |
return out, n, head + note
|
| 786 |
|
| 787 |
|
| 788 |
-
def
|
| 789 |
-
"""
|
| 790 |
-
|
| 791 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 792 |
ss = split_sentences(p) or [p]
|
| 793 |
groups.append((len(sents), len(sents) + len(ss), len(ss) > 1 or ss[0] != p))
|
| 794 |
sents.extend(ss)
|
|
|
|
|
|
|
|
|
|
| 795 |
para_of = [k for k, (a, b, _) in enumerate(groups) for _ in range(a, b)]
|
| 796 |
-
fixed, n, note = grammar_fix_sentences(sents, None, progress, lo, hi, groups=para_of)
|
| 797 |
if not n:
|
| 798 |
return list(paragraphs), 0, note
|
| 799 |
out = []
|
|
@@ -809,7 +870,7 @@ def paraphrase_paragraphs(texts, temperature, progress=None, lo=0.15, hi=0.85, m
|
|
| 809 |
defers it to its own end) Groq corrects the grammar of the finished paragraphs."""
|
| 810 |
out, note = _paraphrase_local(texts, temperature, progress, lo, hi, mode)
|
| 811 |
if GRAMMAR and not GRAMMAR_DEFER.get():
|
| 812 |
-
out, _, gnote = grammar_pass(out, progress, hi, min(0.97, hi + 0.08))
|
| 813 |
note += gnote
|
| 814 |
return out, note
|
| 815 |
|
|
|
|
| 698 |
return sum(max(i2 - i1, j2 - j1) for op, i1, i2, j1, j2 in sm.get_opcodes() if op != "equal") / len(wa)
|
| 699 |
|
| 700 |
|
| 701 |
+
def _polish(sentences, out, idx, groups, glossary, progress, lo, hi, orig_par, orig_sent, stats):
|
| 702 |
+
"""Groq repair + polish of out[idx]. Garbled sentences are REPAIRED to the meaning of the author's original
|
| 703 |
+
(clarity wins over the AI score); clear sentences get the light polish (grammar, academic wording, flow) with
|
| 704 |
+
the usual limits. A garbled sentence that cannot be repaired safely gets the original sentence back when it is
|
| 705 |
+
known. Returns the indices that still need the grammar-only check."""
|
| 706 |
+
ctx = [orig_par[i] or orig_sent[i] for i in idx]
|
| 707 |
+
ctx = [c if c and c.strip() != out[i].strip() else None for c, i in zip(ctx, idx)]
|
| 708 |
+
res = G.polish([out[i] for i in idx], [groups[i] for i in idx], glossary, progress, lo, hi, originals=ctx)
|
| 709 |
+
edited, repaired, missing, broken = {}, {}, [], []
|
| 710 |
+
for i, (st, r) in zip(idx, res):
|
| 711 |
+
if not r:
|
| 712 |
+
missing.append(i)
|
| 713 |
+
continue
|
| 714 |
+
if r.strip() == out[i].strip():
|
| 715 |
+
continue
|
| 716 |
+
ref = orig_sent[i] or sentences[i]
|
| 717 |
+
if st == "repaired":
|
| 718 |
+
ok = TP.guard(ref, TP.restore_terms(ref, r.strip(), glossary), glossary)
|
| 719 |
+
if not ok and orig_sent[i]:
|
| 720 |
+
ok = TP.guard(sentences[i], TP.restore_terms(sentences[i], r.strip(), glossary), glossary)
|
| 721 |
+
if ok:
|
| 722 |
+
repaired[i] = TP.basic_grammar(ok)
|
| 723 |
+
else:
|
| 724 |
+
broken.append(i)
|
| 725 |
continue
|
| 726 |
ok = TP.guard(sentences[i], TP.restore_terms(sentences[i], r.strip(), glossary), glossary)
|
| 727 |
if ok and _edit_share(out[i], ok) <= max(POLISH_MAX_EDIT, 3 / max(1, len(out[i].split()))):
|
| 728 |
+
edited[i] = TP.basic_grammar(ok)
|
| 729 |
+
else:
|
| 730 |
+
missing.append(i)
|
| 731 |
+
# meaning: a repair must be at least as close to the original as the garbled sentence was
|
| 732 |
+
if repaired:
|
| 733 |
+
keys = list(repaired)
|
| 734 |
+
refs = [orig_sent[i] or orig_par[i] or sentences[i] for i in keys]
|
| 735 |
+
sim = MEANING.similarities([(r_, repaired[i]) for r_, i in zip(refs, keys)]
|
| 736 |
+
+ [(r_, out[i]) for r_, i in zip(refs, keys)])
|
| 737 |
+
m = len(keys)
|
| 738 |
+
for n_, i in enumerate(keys):
|
| 739 |
+
exact = orig_sent[i] is not None
|
| 740 |
+
if (exact and sim[n_] < MEANING.MIN_SIM) or (not exact and sim[n_] < sim[m + n_] - 0.02):
|
| 741 |
+
repaired.pop(i)
|
| 742 |
+
broken.append(i)
|
| 743 |
+
if edited: # light edits must keep the meaning ...
|
| 744 |
+
keys = list(edited)
|
| 745 |
+
for i, k in zip(keys, MEANING.keeps_meaning([(sentences[i], edited[i]) for i in keys])):
|
| 746 |
if not k:
|
| 747 |
+
edited.pop(i)
|
| 748 |
+
missing.append(i)
|
| 749 |
+
if edited and POLISH_KEEP_HUMAN: # ... and must not undo the humanization
|
| 750 |
+
keys = list(edited)
|
| 751 |
+
sc = _detector_scores([edited[i] for i in keys] + [out[i] for i in keys])
|
| 752 |
if sc:
|
| 753 |
m = len(keys)
|
| 754 |
for n_, i in enumerate(keys):
|
| 755 |
if sc[n_] >= 0.5 > sc[m + n_] or sc[n_] > sc[m + n_] + 0.35:
|
| 756 |
+
edited.pop(i)
|
| 757 |
+
missing.append(i)
|
| 758 |
+
for i, t in list(edited.items()) + list(repaired.items()):
|
| 759 |
out[i] = t
|
| 760 |
+
restored = 0
|
| 761 |
+
for i in broken: # unclear and not safely repairable: the clear original
|
| 762 |
+
if orig_sent[i] and orig_sent[i].strip() != out[i].strip():
|
| 763 |
+
out[i] = orig_sent[i]
|
| 764 |
+
restored += 1
|
| 765 |
+
else:
|
| 766 |
+
missing.append(i)
|
| 767 |
+
stats.update(edited=len(edited), repaired=len(repaired), restored=restored)
|
| 768 |
+
return sorted(set(missing))
|
| 769 |
|
| 770 |
|
| 771 |
+
def grammar_fix_sentences(sentences, glossary=None, progress=None, lo=0.9, hi=0.95, groups=None,
|
| 772 |
+
orig_par=None, orig_sent=None):
|
| 773 |
"""Final correction of `sentences`. Returns (sentences, n_fixed, note).
|
| 774 |
1. rule-based clean-up (doubled words, a/an, 'will opening', spacing, capitals, final full stop) - always runs;
|
| 775 |
2. Groq final polish (POLISH=1, default): whole paragraphs (`groups` = paragraph key per sentence) are edited
|
|
|
|
| 782 |
return list(sentences), 0, ""
|
| 783 |
out = [TP.basic_grammar(t) if len(t.split()) >= 3 else t for t in sentences]
|
| 784 |
groups = list(groups) if groups is not None else list(range(len(sentences)))
|
| 785 |
+
orig_par = list(orig_par) if orig_par is not None else [None] * len(sentences)
|
| 786 |
+
orig_sent = list(orig_sent) if orig_sent is not None else [None] * len(sentences)
|
| 787 |
+
note, stats = "", dict(edited=0, repaired=0, restored=0)
|
| 788 |
if G.available():
|
| 789 |
glossary = glossary if glossary is not None else (TP.build_glossary(out) | TP.GLOSSARY.get())
|
| 790 |
idx = [i for i, t in enumerate(out) if len(t.split()) >= 3]
|
| 791 |
todo = idx
|
| 792 |
if POLISH and idx:
|
| 793 |
try:
|
| 794 |
+
todo = _polish(sentences, out, idx, groups, glossary, progress, lo, lo + (hi - lo) * 0.7,
|
| 795 |
+
orig_par, orig_sent, stats)
|
| 796 |
except G.Unavailable as err: # fall back to the grammar-only check
|
| 797 |
print(f"[humanizer] Groq final polish skipped, grammar-only check instead: {err}", flush=True)
|
| 798 |
try:
|
|
|
|
| 822 |
if n_lt:
|
| 823 |
note += f" 🔎 LanguageTool fixed {n_lt} sentence(s)."
|
| 824 |
n = sum(1 for a, b in zip(sentences, out) if a.strip() != b.strip())
|
| 825 |
+
parts = [f"{stats[k]} {lbl}" for k, lbl in (("repaired", "unclear sentence(s) repaired to the original meaning"),
|
| 826 |
+
("restored", "restored to your original wording"),
|
| 827 |
+
("edited", "edited for wording and flow")) if stats[k]]
|
| 828 |
+
head = (f" 📝 Final polish (clarity, grammar, academic wording, flow): {n} sentence(s) improved"
|
| 829 |
+
+ (f" ({'; '.join(parts)})." if parts else ".")) if n else " 📝 Final polish: no changes needed."
|
| 830 |
return out, n, head + note
|
| 831 |
|
| 832 |
|
| 833 |
+
def originals_for(new_sents, orig_paragraph):
|
| 834 |
+
"""Per sentence of a rewritten paragraph: (original paragraph, original sentence or None). The sentence is
|
| 835 |
+
known only when the original paragraph has the same number of sentences (one-to-one rewrite)."""
|
| 836 |
+
if not orig_paragraph:
|
| 837 |
+
return [(None, None)] * len(new_sents)
|
| 838 |
+
os_ = split_sentences(orig_paragraph) or [orig_paragraph]
|
| 839 |
+
same = len(os_) == len(new_sents)
|
| 840 |
+
return [(orig_paragraph, os_[k] if same else None) for k in range(len(new_sents))]
|
| 841 |
+
|
| 842 |
+
|
| 843 |
+
def grammar_pass(paragraphs, progress=None, lo=0.9, hi=0.95, originals=None):
|
| 844 |
+
"""Final polish of whole paragraphs, sentence by sentence. `originals` (same order as `paragraphs`) are the
|
| 845 |
+
author's original paragraphs, used to repair garbled sentences. Returns (paragraphs, n_fixed, note)."""
|
| 846 |
+
groups, sents, op, os_ = [], [], [], []
|
| 847 |
+
if originals is not None and len(originals) != len(paragraphs):
|
| 848 |
+
originals = None
|
| 849 |
+
for k, p in enumerate(paragraphs):
|
| 850 |
ss = split_sentences(p) or [p]
|
| 851 |
groups.append((len(sents), len(sents) + len(ss), len(ss) > 1 or ss[0] != p))
|
| 852 |
sents.extend(ss)
|
| 853 |
+
for a_, b_ in originals_for(ss, originals[k] if originals is not None else None):
|
| 854 |
+
op.append(a_)
|
| 855 |
+
os_.append(b_)
|
| 856 |
para_of = [k for k, (a, b, _) in enumerate(groups) for _ in range(a, b)]
|
| 857 |
+
fixed, n, note = grammar_fix_sentences(sents, None, progress, lo, hi, groups=para_of, orig_par=op, orig_sent=os_)
|
| 858 |
if not n:
|
| 859 |
return list(paragraphs), 0, note
|
| 860 |
out = []
|
|
|
|
| 870 |
defers it to its own end) Groq corrects the grammar of the finished paragraphs."""
|
| 871 |
out, note = _paraphrase_local(texts, temperature, progress, lo, hi, mode)
|
| 872 |
if GRAMMAR and not GRAMMAR_DEFER.get():
|
| 873 |
+
out, _, gnote = grammar_pass(out, progress, hi, min(0.97, hi + 0.08), originals=list(texts))
|
| 874 |
note += gnote
|
| 875 |
return out, note
|
| 876 |
|
smart.py
CHANGED
|
@@ -295,7 +295,8 @@ def _report_rewrite(sentences, temperature, mode, rep, lo, hi):
|
|
| 295 |
# grammar now, so the results table shows exactly the wording that goes into the document
|
| 296 |
idx = [i for i, (o, b) in enumerate(zip(sentences, best)) if b.strip() != o.strip()]
|
| 297 |
if idx and H.GRAMMAR:
|
| 298 |
-
fixed, _, _ = H.grammar_fix_sentences([best[i] for i in idx], gl, rep, hi, hi
|
|
|
|
| 299 |
for i, f in zip(idx, fixed):
|
| 300 |
ok = guard(sentences[i], f)
|
| 301 |
if ok and MEANING.ok(sentences[i], ok):
|
|
@@ -368,30 +369,44 @@ def _better(cand, best, k):
|
|
| 368 |
return cand[1] >= best[1]
|
| 369 |
|
| 370 |
|
| 371 |
-
def _grammar_docx(doc, rep):
|
| 372 |
"""Final Groq grammar check of every sentence in the document's prose, written back in place (only plain
|
| 373 |
text runs are touched, so citation fields, links and sub/superscripts keep their formatting)."""
|
| 374 |
-
jobs = []
|
| 375 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 376 |
if b.kind != "para" or b.el is None or not b.raw:
|
| 377 |
continue
|
| 378 |
raws = D.split_sentences(b.raw)
|
| 379 |
-
|
|
|
|
|
|
|
| 380 |
if not sp:
|
| 381 |
continue
|
| 382 |
sub = safe_subspan(b.el, *sp)
|
| 383 |
if sub:
|
| 384 |
jobs.append((b, sub, b.raw[sub[0]:sub[1]]))
|
|
|
|
|
|
|
| 385 |
if not jobs:
|
| 386 |
return 0, ""
|
| 387 |
fixed, n, note = H.grammar_fix_sentences([j[2] for j in jobs], None, rep, 0.9, 0.95,
|
| 388 |
-
groups=[id(j[0]) for j in jobs])
|
| 389 |
done = 0
|
| 390 |
for (b, (a, e), t), f in sorted(zip(jobs, fixed), key=lambda x: (id(x[0][0]), -x[0][1][0])):
|
| 391 |
if f != t and replace_span(b.el, a, e, f):
|
| 392 |
done += 1
|
| 393 |
if done:
|
| 394 |
-
|
|
|
|
|
|
|
| 395 |
return 0, note if "unavailable" in note else ""
|
| 396 |
|
| 397 |
|
|
@@ -517,7 +532,7 @@ def smart_docx(path, target, passes, temperature, mode, hi, mod, rep):
|
|
| 517 |
blocks = best[2][1]
|
| 518 |
after = D.summarise(blocks, hi, mod)
|
| 519 |
if H.GRAMMAR: # final step: Groq grammar check of the finished text
|
| 520 |
-
n, gnote = _grammar_docx(doc, rep)
|
| 521 |
if n: # always kept: the final text must read correctly
|
| 522 |
blocks = _score_doc(doc)
|
| 523 |
after = D.summarise(blocks, hi, mod)
|
|
@@ -621,7 +636,9 @@ def smart_text(text, target, passes, temperature, mode, hi, mod, rep):
|
|
| 621 |
after = D.summarise(blocks, hi, mod)
|
| 622 |
if H.GRAMMAR: # final step: Groq grammar check of the finished text
|
| 623 |
idx = [i for i, b in enumerate(blocks) if b.kind == "para" and i < len(paras)]
|
| 624 |
-
|
|
|
|
|
|
|
| 625 |
if n: # always kept: the final text must read correctly
|
| 626 |
for i, p in zip(idx, newp):
|
| 627 |
paras[i] = p
|
|
|
|
| 295 |
# grammar now, so the results table shows exactly the wording that goes into the document
|
| 296 |
idx = [i for i, (o, b) in enumerate(zip(sentences, best)) if b.strip() != o.strip()]
|
| 297 |
if idx and H.GRAMMAR:
|
| 298 |
+
fixed, _, _ = H.grammar_fix_sentences([best[i] for i in idx], gl, rep, hi, hi,
|
| 299 |
+
orig_sent=[sentences[i] for i in idx])
|
| 300 |
for i, f in zip(idx, fixed):
|
| 301 |
ok = guard(sentences[i], f)
|
| 302 |
if ok and MEANING.ok(sentences[i], ok):
|
|
|
|
| 369 |
return cand[1] >= best[1]
|
| 370 |
|
| 371 |
|
| 372 |
+
def _grammar_docx(doc, rep, orig_path=None):
|
| 373 |
"""Final Groq grammar check of every sentence in the document's prose, written back in place (only plain
|
| 374 |
text runs are touched, so citation fields, links and sub/superscripts keep their formatting)."""
|
| 375 |
+
jobs, op, os_ = [], [], []
|
| 376 |
+
blocks = list(D.docx_blocks(doc))
|
| 377 |
+
orig = None
|
| 378 |
+
if orig_path: # the author's original paragraphs, block by block
|
| 379 |
+
try:
|
| 380 |
+
ob = list(D.docx_blocks(DX.open_clean(orig_path)))
|
| 381 |
+
orig = ob if len(ob) == len(blocks) else None
|
| 382 |
+
except Exception as err:
|
| 383 |
+
print(f"[smart] original paragraphs not available for the final polish: {err}", flush=True)
|
| 384 |
+
for bi, b in enumerate(blocks):
|
| 385 |
if b.kind != "para" or b.el is None or not b.raw:
|
| 386 |
continue
|
| 387 |
raws = D.split_sentences(b.raw)
|
| 388 |
+
o_raw = orig[bi].raw if orig is not None and orig[bi].kind == "para" else None
|
| 389 |
+
ctx = H.originals_for(raws, o_raw)
|
| 390 |
+
for sp, (cp, cs) in zip(DX.sentence_char_spans(b.raw, raws), ctx):
|
| 391 |
if not sp:
|
| 392 |
continue
|
| 393 |
sub = safe_subspan(b.el, *sp)
|
| 394 |
if sub:
|
| 395 |
jobs.append((b, sub, b.raw[sub[0]:sub[1]]))
|
| 396 |
+
op.append(cp)
|
| 397 |
+
os_.append(cs if sub == tuple(sp) or list(sub) == list(sp) else None)
|
| 398 |
if not jobs:
|
| 399 |
return 0, ""
|
| 400 |
fixed, n, note = H.grammar_fix_sentences([j[2] for j in jobs], None, rep, 0.9, 0.95,
|
| 401 |
+
groups=[id(j[0]) for j in jobs], orig_par=op, orig_sent=os_)
|
| 402 |
done = 0
|
| 403 |
for (b, (a, e), t), f in sorted(zip(jobs, fixed), key=lambda x: (id(x[0][0]), -x[0][1][0])):
|
| 404 |
if f != t and replace_span(b.el, a, e, f):
|
| 405 |
done += 1
|
| 406 |
if done:
|
| 407 |
+
m = re.search(r"improved \((.*?)\)\.", note or "")
|
| 408 |
+
return done, (f" 📝 Final polish by Groq (clarity, grammar, academic wording, flow): {done} sentence(s) "
|
| 409 |
+
f"improved" + (f" ({m.group(1)})." if m else "."))
|
| 410 |
return 0, note if "unavailable" in note else ""
|
| 411 |
|
| 412 |
|
|
|
|
| 532 |
blocks = best[2][1]
|
| 533 |
after = D.summarise(blocks, hi, mod)
|
| 534 |
if H.GRAMMAR: # final step: Groq grammar check of the finished text
|
| 535 |
+
n, gnote = _grammar_docx(doc, rep, path)
|
| 536 |
if n: # always kept: the final text must read correctly
|
| 537 |
blocks = _score_doc(doc)
|
| 538 |
after = D.summarise(blocks, hi, mod)
|
|
|
|
| 636 |
after = D.summarise(blocks, hi, mod)
|
| 637 |
if H.GRAMMAR: # final step: Groq grammar check of the finished text
|
| 638 |
idx = [i for i, b in enumerate(blocks) if b.kind == "para" and i < len(paras)]
|
| 639 |
+
o_paras = [p for p, _ in parts]
|
| 640 |
+
newp, n, gnote = H.grammar_pass([paras[i] for i in idx], rep, 0.9, 0.95,
|
| 641 |
+
originals=[o_paras[i] if i < len(o_paras) else None for i in idx])
|
| 642 |
if n: # always kept: the final text must read correctly
|
| 643 |
for i, p in zip(idx, newp):
|
| 644 |
paras[i] = p
|