Rasheed commited on
Commit
8dabb01
·
verified ·
1 Parent(s): 2a58020

Upload 4 files

Browse files
Files changed (4) hide show
  1. README.md +50 -10
  2. groqai.py +44 -31
  3. humanizer.py +93 -32
  4. 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 | $5.99 / ₹299 per month | 30 per 30 days | 2,000 words, 100/day | 4 |
38
- | Pro researcher | $14.99 / ₹799 per month | 150 per 30 days | 3,000 words, 300/day | 4 |
39
- | Agency | from $99 / ₹5,999 per month | 1,000 per 30 days | 3,000 words | 4 |
40
- | Single document | $2.99 / ₹149 | 1 credit (never expires) | — | — |
41
  | **Admin** (`ADMIN_EMAILS`) | **free, unlimited, never charged** | ∞ | 20,000 words | 4 |
42
 
43
- Order used for a document: plan quota → purchased credits → free trial. Guests and free users see an
44
- upgrade tip after each run, a 💎 Upgrade button, and the 💎 Pricing tab. "Buy" buttons log an upgrade
45
- request (Admin → Users → Upgrade requests) and show UPI/bank instructions until online payment is live.
46
 
47
- **Activating a paid plan today:** Admin → Users → 💎 Plans & credits → Activate plan / Add credits.
 
 
 
 
 
 
 
 
 
48
 
49
  **Razorpay (next step):** in the payment webhook, after verifying the signature, call
50
- `plans.activate_plan(email, "student", 30, source="razorpay", amount=...)` or
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: grammar + academic register + flow
345
  POLISH_SYSTEM = f"""You are the final copy-editor of a technical academic manuscript (journal / thesis quality).
346
- The text is given paragraph by paragraph; each numbered item is one sentence. Edit every sentence so that the
347
- paragraph reads as clear, natural, formal technical academic English written by a careful researcher:
348
- 1. Grammar: fix grammar, spelling, punctuation, agreement, articles, verb forms, tense, prepositions, missing or
349
- doubled words and broken or run-on sentence structure.
350
- 2. Academic register: replace informal, odd or wrong-sense words with the precise technical word (e.g. "spotted"
351
- -> "repeated", "absurd" -> "inappropriate"); remove redundancy ("an optimization framework for optimization");
352
- no first person singular, no "you", no contractions, no opinions.
353
- 3. Flow: make each sentence follow naturally from the one before it in the same paragraph. Use clear logical
354
- links where they are needed (however, therefore, in contrast, as a result, for example), vary repetitive
355
- sentence openings, and smooth awkward word order. Keep the paragraph's order of ideas.
356
- Rules: keep the meaning, claims, hedging and level of certainty of every sentence exactly. Do NOT add or remove
357
- facts, names, numbers, results or citations. Do NOT merge, split, drop or reorder sentences: return exactly one
358
- sentence per id. Keep every technical term, acronym, formula, symbol, number, unit and citation (e.g. [12],
359
- (Kumar et al., 2021)) exactly as written, including every item in PROTECTED TERMS. Keep the author's wording
360
- wherever it is already correct and clear; change only what improves correctness, precision or flow.
 
 
 
 
 
361
  Do not add these words: {_AI_WORDS}. No em dashes.
362
- Answer only with JSON: {{"items": [{{"id": 1, "text": "edited sentence"}}, ...]}} — one per id."""
 
363
 
364
 
365
  def _polish_batch(items, glossary):
366
- """items: list of (paragraph_key, sentence) in document order -> list of edited sentences (None = missing)."""
367
- sents = [t for _, t in items]
 
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, 500 + int(sum(len(t) for t in sents) * 0.7))
378
  content = _chat([{"role": "system", "content": POLISH_SYSTEM}, {"role": "user", "content": user}], 0.2,
379
  max_tokens)
380
- return [t for _, t in _parse(content, len(sents))]
381
 
382
 
383
- def polish(sentences, groups=None, glossary=(), progress=None, lo=0.9, hi=0.95, size=None):
384
- """Grammar + academic wording + flow edit of `sentences`. `groups` gives a paragraph key per sentence so that
385
- whole paragraphs are edited together (sentences of one paragraph stay in one request where possible).
386
- Returns one entry per sentence: the edited text, or None if its batch could not be edited.
 
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 for b in batches}
 
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, t in zip(b, res):
427
- out[k] = t
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] (grammar + academic wording + flow). Returns (indices polished, indices that still
703
- need a grammar-only check)."""
704
- res = G.polish([out[i] for i in idx], [groups[i] for i in idx], glossary, progress, lo, hi)
705
- cand = {}
706
- for i, r in zip(idx, res):
707
- if not r or r.strip() == out[i].strip():
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- cand[i] = TP.basic_grammar(ok)
712
- if cand: # meaning must stay the same
713
- keys = list(cand)
714
- for i, k in zip(keys, MEANING.keeps_meaning([(sentences[i], cand[i]) for i in keys])):
 
 
 
 
 
 
 
 
 
 
 
 
 
 
715
  if not k:
716
- cand.pop(i)
717
- if cand and POLISH_KEEP_HUMAN: # do not undo the humanization
718
- keys = list(cand)
719
- sc = _detector_scores([cand[i] for i in keys] + [out[i] for i in keys])
 
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
- cand.pop(i)
725
- for i, t in cand.items():
 
726
  out[i] = t
727
- missing = [i for i, r in zip(idx, res) if r is None or (i not in cand and r.strip() != out[i].strip())]
728
- return set(cand), missing
 
 
 
 
 
 
 
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
- note, polished = "", set()
 
 
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
- polished, todo = _polish(sentences, out, idx, groups, glossary, progress, lo, lo + (hi - lo) * 0.7)
 
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
- pol = sum(1 for i in polished if out[i].strip() != sentences[i].strip())
782
- head = (f" 📝 Final polish (grammar, academic wording, flow): {n} sentence(s) improved"
783
- + (f", {pol} of them edited for wording and flow." if pol else ".")) if n else \
784
- " 📝 Final polish: no changes needed."
 
785
  return out, n, head + note
786
 
787
 
788
- def grammar_pass(paragraphs, progress=None, lo=0.9, hi=0.95):
789
- """Grammar-correct whole paragraphs sentence by sentence. Returns (paragraphs, n_fixed, note)."""
790
- groups, sents = [], []
791
- for p in paragraphs:
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- for b in D.docx_blocks(doc):
 
 
 
 
 
 
 
 
376
  if b.kind != "para" or b.el is None or not b.raw:
377
  continue
378
  raws = D.split_sentences(b.raw)
379
- for sp in DX.sentence_char_spans(b.raw, raws):
 
 
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
- return done, f" 📝 Final polish by Groq (grammar, academic wording, flow): {done} sentence(s) improved."
 
 
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
- newp, n, gnote = H.grammar_pass([paras[i] for i in idx], rep, 0.9, 0.95)
 
 
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